Karhunen-Loeve type-safe computer vision part 02 of 3

Type-Safe Computer Vision · Part 2 of 3

128 Is Not the Middle

Average a black pixel and a white pixel. Your code computes 127.5, rounds it to 128, and stores it. That answer is wrong by a margin you can see from across the room, and it is in nearly every resize you have ever written.

Thomas Stephan · · Series index

A one-line quiz

You are downscaling a tiny image: a 2×2 checkerboard of pure black and pure white, shrinking to a single pixel. Physically, that pixel should emit exactly half the light of white, because it replaces an area that was half black, half white. What value do you store?

50% black, 50% white mean = 127.5 → 128 naive byte average, clearly too dark average in light = 188 what half of white’s light actually looks like the same pattern one square per screen pixel
Fig. 1. The third swatch is the same 50/50 pattern with one square per screen pixel, so your eye averages it instead of resolving it. Hold it against the two above: it is not the byte average. Your eye averages light, and so does every camera lens; naive code averages bytes.

If your answer was 128, so was nearly every image library, browser, and game engine for years, and it is still the default in most computer-vision code today. It is also measurably, visibly wrong. The correct answer is 188.

The trick: pixel values aren’t light

Here is the secret those three bytes never told you. The numbers stored in an ordinary image (a JPEG, a PNG, a camera frame) are not amounts of light. They are sRGB-encoded values: light that has been run through a compression curve (a “gamma” curve) before storage. The curve exists for a good reason: human vision is far more sensitive to differences in shadows than in highlights, so encoding spends more of its 256 codes on dark tones. But it means the scale is not linear:

100% 75% 50% 25% 0% 0 64 128 192 255 stored value (what the file says) light emitted (what you see) if values were light (they aren’t) stored 128 → only ≈22% of white’s light stored 188 → ≈50%
Fig. 2. The sRGB transfer curve. Stored values are a compressed code for light. The halfway code (128) sits at barely a fifth of the light; the halfway light (50%) is stored as 188. Any arithmetic that pretends the x-axis is the y-axis inherits this distortion.

Follow the checkerboard through both pipelines, with the actual numbers:

stored bytes 0 255 255 0 mean(0, 255, 255, 0) = 127.5 128 ✗ decode to light 0.0 1.0 1.0 0.0 mean = 0.5 0.5 re-encode 188 ✓
Fig. 3. Same input, two pipelines. The red path does arithmetic on the code; the green path decodes to light, averages, and re-encodes. The 0.5 swatch and the 188 swatch are the same gray, because re-encoding changes the number and not the light. Three extra steps, one visibly different answer.

This is every resize, every blur, every blend

“Fine,” you say, “but who averages checkerboards?” You do. Constantly. Downscaling an image averages neighborhoods of pixels. Bilinear interpolation is a weighted average of four pixels. Gaussian blur is a weighted average of many. Alpha blending, anti-aliasing, feathered masks, image pyramids. All of it is the same operation: a weighted average of pixel values. Run any of them on encoded values and every output pixel that mixes different values is pulled toward black, exactly as 128 was, and the harder the contrast, the further.

Interpolation makes the damage easy to see. Ask for the point halfway between a dark pixel (40) and a bright pixel (220):

interpolating the stored bytes mid = 130 ✗ 40 220 interpolating in linear light mid = 163 ✓ 40 220 + = muddy (bytes) vs bright (light) 255,0,0 0,255,0 128,128,0 188,188,0
Fig. 4. Top: the naive gradient sags dark; its midpoint is 130 where the true half-light point is 163. Bottom: blending pure red and pure green on bytes gives a muddy olive. In linear light the mixture stays bright, as two overlapping stage lights would. Dark edges around bright objects after a blur? Same bug.
The Milky Way reduced to a thumbnail by averaging the stored bytes. The band of the galaxy survives, but most of the individual stars are gone.

averaged as bytes

The same reduction performed in linear light. The star field is still there, and the galaxy reads brighter.

averaged in light

Fig. 5. The same photograph shrunk to a thumbnail twice, same filter, same number of steps. The only difference is whether the bytes were decoded to light before averaging. The stars survive on one side only. Photo: ESO/S. Brunier, CC BY 4.0. Both thumbnails produced with gamma_thumbnails in fovea-examples, which prints the measured difference.

This is why gamma-ignorant thumbnails look slightly lifeless, why blurred highlights grow dark halos, and why Eric Brasseur’s gamma test image (a fine black-and-white pattern whose structure survives a correct downscale) collapses into flat gray in essentially every scaler he tried. The internet has re-discovered this bug on a loop for two decades: Pillow closed the request as out of scope, imgproxy and darktable carry the same report, and it still ships in production, everywhere, today.

Nobody’s math was wrong. The average was computed perfectly, on numbers that were never averages-compatible in the first place.

Look at what kind of bug this is

Once more, there is no logic error. The interpolation formula is textbook-correct. The bug is that the formula was applied to encoded values as if they were linear ones: two kinds of numbers that happen to share the same byte width, living in the same arrays, with nothing anywhere to tell them apart. That’s not a logic bug. It is a type bug. An ndarray of uint8 cannot say “I am gamma-encoded; please don’t do arithmetic on me,” so nothing stops you. Or your teammate. Or the coding agent that wrote cv2.resize into your pipeline, exactly as ten thousand tutorials taught it to.

What if the image knew it was encoded?

In fovea, gamma-encoded and linear pixels are different types. Srgb8 and Rgb8 have identical bytes, but only the linear one implements the trait that blending operations require. The compiler knows the difference, so it enforces the physics:

use fovea::pixel::{Srgb8, RgbF32};
use fovea::transform::{Bilinear, SrgbGamma, convert_image, resize};

let photo: Image<Srgb8> = /* decoded from a PNG, gamma-encoded like every photo */;

resize(&photo, Size::new(800, 600), Bilinear);
// error[E0277]: `Srgb8` does not implement `LinearSpace`
//   = interpolation blends samples; blending gamma-encoded
//     values is not meaningful

The compile error is the lesson from this article, delivered at the exact line where it applies, before the program ever runs. And the fix reads like the green path in Fig. 3: decode, operate, re-encode, each step named:

// 1. decode the gamma curve, bytes become linear light
let light: Image<RgbF32> = convert_image(&photo, SrgbGamma);

// 2. now averaging is physically meaningful, resize away
let small = resize(&light, Size::new(800, 600), Bilinear);

// 3. re-encode for storage/display
let out: Image<Srgb8> = convert_image(&small, SrgbGamma);

Note what the type system did not do: it didn’t silently linearize behind your back, and it didn’t forbid anything useful. If you genuinely want nearest-neighbor sampling on encoded data (which copies pixels and never blends them), that compiles fine, because it’s actually valid. The rule is precise: operations that mix values require values that may be mixed. Everything the checkerboard taught us, encoded in one trait bound.

One line of forum wisdom (“convert to linear before you blur”) has been repeated for twenty years and forgotten for twenty years. A type checker never forgets. As more vision code gets written by agents trained on those very tutorials, the difference between advice and a trait bound is that one of them is enforced.

So far our bugs lived inside a single image. The final part climbs a whole stack of them: image pyramids and scale-space, where sizes stop matching, coordinates drift by half a pixel per level, and one library packed three numbers into one integer and called it an octave.