Marginal Musings

uint8 vs float32 Can Swing PSNR by 48 dB

Author
Shlomo Stept
Published
Updated

I spent two hours confused by this before I thought to check img.dtype. I was building evaluation pipelines for my resizing disagreement research and getting PSNR values that looked plausible but were, in hindsight, completely wrong. The dangerous kind of wrong — not “obviously broken,” just “silently incomparable.”

PSNR is defined as:

PSNR=10log10(MAX2MSE)\text{PSNR} = 10 \cdot \log_{10}\left(\frac{\text{MAX}^2}{\text{MSE}}\right)

For uint8 images, MAX = 255. For float32 normalized to [0, 1], MAX = 1.0. The difference between log10(2552)\log_{10}(255^2) and log10(1.02)\log_{10}(1.0^2) is about 48 dB. That’s not a rounding error. That’s a chasm.

import numpy as np
from skimage.metrics import peak_signal_noise_ratio

img_uint8 = np.random.randint(0, 256, (256, 256, 3), dtype=np.uint8)
noise = np.random.randint(0, 6, (256, 256, 3), dtype=np.uint8)
noisy_uint8 = np.clip(img_uint8.astype(int) + noise, 0, 255).astype(np.uint8)

img_f32 = img_uint8.astype(np.float32) / 255.0
noisy_f32 = noisy_uint8.astype(np.float32) / 255.0

psnr_uint8 = peak_signal_noise_ratio(img_uint8, noisy_uint8)
psnr_f32 = peak_signal_noise_ratio(img_f32, noisy_f32)

print(f"PSNR (uint8):   {psnr_uint8:.2f} dB")   # ~40 dB
print(f"PSNR (float32): {psnr_f32:.2f} dB")      # ~-8 dB

Same image pair. Same pixel relationships. One reports 40 dB (looks great), the other reports negative 8 dB (looks catastrophic). The float32 version can go negative because MAX = 1.0 makes the ratio tiny when MSE is even moderate.

What’s happening: skimage.peak_signal_noise_ratio infers data_range from the input dtype. For uint8 it assumes 255; for float32 it assumes 1.0. The MSE stays proportional, but the MAX term shifts everything by ~48 dB.

You can force consistency:

psnr_a = peak_signal_noise_ratio(img_uint8, noisy_uint8, data_range=255)
psnr_b = peak_signal_noise_ratio(img_f32, noisy_f32, data_range=1.0)

The rule: always know your dtype, always set data_range explicitly. A “PSNR of 35 dB” is meaningless without knowing whether it was computed on [0, 255] or [0, 1] data. This bites hardest in pipelines where model inference runs in float32 but image I/O happens in uint8 — if you forget to normalize one of the two images, your metrics reflect the type mismatch, not actual quality.