workshop private

← all viz

Squint

viz · created 2026-10-09

Half way from black to white is byte 188, not 128, and the test is not an argument — it is a checkerboard you can look at. Byte 128 emits 21.59% of the light where half was asked for: 2.32× too dark, 1.21 stops, ΔL* 22.5 against a threshold of 1. Run one box filter in the two spaces and one-pixel hairlines come out at 21.59% or 50.29% of the light they went in with; a field of one-pixel dots loses 91.7% of its light to the filter that averaged the bytes.

algorithmscanvas

Ask for the colour half way between black and white and almost everything answers 128. It is the midpoint of the byte, and the byte is what the question was about. It is also 2.32× too dark, and the demo does not argue the point — it puts the answer next to a surface that is already emitting the right amount of light and lets you look.

That surface is a checkerboard of one-pixel black and white cells. Nobody decoded anything to make it; half the pixels are on and the light from them adds, which is the one operation in this whole subject that cannot be done in the wrong space. So a patch laid on that field is correct exactly when it disappears. Squint, lean back, or turn the integrate slider up and let a box filter do the squinting.

bytelight emittedΔL* vs the field
sRGB midpoint12821.59%22.5
linear midpoint18850.29%0.18
the field itself—50.00%—

ΔL* is CIE lightness difference, where 1 is roughly the smallest difference anyone can see. The naive answer is off by twenty-two of them. It is not a subtlety that only shows up in a side-by-side; it is one of the largest errors you can make while every individual pixel is still a legal colour.

188, and the two numbers that make it

sRGB stores a number that is roughly the 1/2.2 power of the light. Inverting it at a half:

linearToSrgb(0.5) × 255 = 187.516  →  188
srgbToLinear(128/255)   = 0.21586

Those are the whole creation. The second one is the one worth keeping: byte 128 is not half the light, it is 21.6% of it, which is 1.21 stops down. Every pipeline that treats the byte as a quantity — averages it, lerps it, multiplies it by an alpha — is doing arithmetic on the logarithm and reporting the answer as if it were the number.

The 0.29 points by which 188 overshoots are not a flaw in the method. 187.516 is not a byte; 188 is the nearest one, and across the whole ramp the rounding costs at most ΔL* 0.234, a quarter of the threshold. The 8-bit grid is the floor here, not the maths.

The whole ramp is wrong, and the worst of it is not the middle

Same two endpoints, swept:

tnaive byteits lightlinear byteits lightwantedshort byΔL*
0.1261.0%8910.0%10%9.028.6
0.2513.3%12420.2%20%16.730.6
0.3777.4%14930.1%30%22.628.9
0.512821.6%18850.3%50%28.422.5
0.53513624.6%19353.3%53.5%28.921.5
0.717945.1%21870.1%70%24.914.1
0.923079.1%24389.6%90%10.94.7

Two different maxima, and they are in different places. The light goes missing fastest just past the middle — 28.9 points at t = 0.535 — because that is where the gap between a curve and its chord is widest. What you can see peaks much earlier, at ΔL* 30.8 at t = 0.194, because the eye spends most of its resolution in the shadows and a deficit down there costs more lightness per point of light. The shadows are where a naive blend looks worst, and the shadows are the part everyone checks last.

The band in between is the real finding: 98.1% of the ramp sits above ΔL* 1. There is no safe region. Only the two endpoints, where the two spaces are forced to agree, are right.

One filter, two spaces

Nothing in the downscale view is interpolated by hand. It is one two-valued picture — every pixel is one endpoint or the other, so there is no midtone to get wrong and any midtone in the result was manufactured by the filter — put through one box filter, run in either space. The mean light is the only number that should survive:

picturesourcesRGB boxlinear boxlight thrown awaytoo dark by
one-pixel lines, period 250.00%21.59%50.29%56.8%2.32×
one-pixel lines, period 425.00%5.13%25.02%79.5%4.88×
hairline grid, period 443.75%16.20%43.97%63.0%2.70×
one-pixel dots, period 46.25%0.52%6.30%91.7%12.06×
zone plate51.33%35.38%51.36%31.1%1.45×

The linear column is right to within the byte grid on every row. The sRGB column is a different picture of a different scene.

The dots row is the one to stare at. Six and a quarter percent coverage — sparse white specks, a starfield, a highlight pass, a particle buffer — and averaging the bytes returns 0.52%, twelve times too little. The sparser the bright detail, the worse it gets, because the filter is averaging numbers whose distance from zero is a power of the thing being averaged. Resizing an image in the wrong space does not blur it. It dims it, and it dims the parts with the finest bright detail the most.

The zone plate is the honest counterexample: at 31.1% it is the mildest row here, because it is half light and half dark everywhere at a coarse scale and the error partly cancels. Pictures of the real world behave more like the zone plate than like the dots — which is why this bug survives in production for years, visible only on the content that happens to be sparse and bright.

Where the choice is actually being made

Not usually anywhere anyone wrote 0.5. The box filter above is the same arithmetic as a mipmap level, a thumbnail, a resize, a coverage-based antialiasing blend, a source-over composite at 50% alpha, and a two-tap bilinear sample. Each of them averages stored values, and the stored value is the encoded one unless someone decided otherwise. The decision is almost always made by a default, and the default is almost always the wrong one, because averaging bytes is what the memory layout makes easy.

Reading it

Three views, 1 2 3, and e cycles the endpoints.

Try blue → yellow: both endpoints are fully saturated, the correct midpoint is a neutral #bcbcbc, and the naive one is the muddy #808080 that every two-colour gradient in the world passes through.

Reuse

src/squint.js is a framework-free ES module. No rendering, no timers.

Gotchas