
I wanted to know why a white background saved as JPEG stops being exactly 255. On one sneaker photo the drift split three ways. Quantization steps move even a flat white image, but only at low quality. At normal quality the drift lives in the blocks an edge crosses. And 4:2:0 chroma adds a coloured share. Amazon's US seller guide (viewed 2026-10-23) asks for RGB 255, 255, 255, so the last digit matters to someone.
The photo is a Wikimedia Commons shot of white sneakers on beige tile (JustinLiang522, CC BY-SA 4.0). I made the white PNG in ImgIng (https://imging.ai/) in a desktop browser: AI cutout, background set to white, exported at 1,280×1,707. I did not look into how it composites, only at what it exported. The export row lists WebP, PNG, AVIF, TIFF and GIF with no JPEG, so every JPEG below comes from Pillow 11.3.0 and numpy 2.0.2 on an Apple M4. "Background" means the 55.2% of pixels the cutout left transparent, all exactly 255 in the PNG.
Flat white only drifts at low quality
JPEG cuts the image into 8×8 blocks, turns each into 64 coefficients, divides every coefficient by an entry of a quantization table and rounds. The luma table's first row at quality 85 is 5, 3, 3, 5, 7, 12, 15, 18, with a largest entry of 36.
A flat white block has a single nonzero coefficient, the DC term, 8×(255−128) = 1016. At quality 85 that is divided by 5 and stored as 203, which by my arithmetic (not the decoder's own path) decodes to 254.875 and rounds back to 255. I swept a flat white 64×64 image over quality 1 to 100. Every setting from 46 to 100 came back all 255, while 20 of the 45 settings below 46 moved every pixel, to 254 in 14 of them and down to 248 at quality 5.
At normal quality the drift lives in edge blocks
Above that line a flat area is safe, so drift needs detail, and the simplest detail is one hard edge. In a 48×16 grey image, white left and black right, I slid the edge across the block spanning x = 24 to 31 and counted white-side pixels that were not 255. The second number counts those left of the edge block.
def strays(edge, q=85):
a = np.full((16, 48), 255, np.uint8); a[:, edge:] = 0
buf = io.BytesIO(); Image.fromarray(a).save(buf, "JPEG", quality=q)
back = np.array(Image.open(buf))[:, :edge]
return int((back != 255).sum()), int((back[:, :24] != 255).sum())
print([strays(24 + off) for off in range(8)])
# [(0, 0), (0, 0), (0, 0), (16, 0), (16, 0), (32, 0), (32, 0), (16, 0)]
An edge sitting on the block boundary leaves the white side alone, an edge inside the block cost 0 to 32 pixels at quality 85 depending on where it fell, and nothing outside the edge block ever moved. The real photo shows the same pattern at scale. At quality 85 with 4:2:0, 11.58% of the background pixels within 8 pixels of the shoe drifted, against 0.0162% beyond that.
In the zoomed pair on the right, the red specks form a ragged strip hugging the dark edge and the white further out has none; the left panel shows it following the whole outline. Photo by JustinLiang522, Wikimedia Commons, CC BY-SA 4.0, adapted.
White is a ceiling that hides most of it
Ringing swings both ways around an edge, but a decoder clamps at 255. To see how much that hides, I repainted the sneaker's background at a flat grey level and re-encoded at quality 85. At 255, 7,916 background pixels changed and none went up, by construction. At 200, 27,894 changed, 15,263 with a channel above 200 and 14,460 with one below, and a pixel can be in both groups. Grey 128 gave 28,392. White shows about 28% of the drift a mid-tone background would.
4:2:0 adds a coloured share
JPEG stores colour as two chroma planes. At 4:2:0 each chroma sample covers 2×2 pixels, so a chroma block spans 16×16 pixels. Of the 7,916 strays at 4:2:0, 6,284 are coloured, meaning R, G and B differ, like 254, 255, 253. At 4:4:4 the total falls to 3,501 and the coloured part to 1,458. The farthest stray sat 13 pixels from the subject at 4:2:0 and 6 at 4:4:4. That fits a luma block reaching at most 7 pixels and a chroma block up to 15, but I only counted, so the reach is my reading and not confirmed in the encoder.
A neutral grey square on white drifted by 250 pixels in both modes and none of them were coloured, so in that toy case chroma changed nothing.
What I could not explain
Grey-only strays rose from 1,632 to 2,043 when I switched to 4:4:4, and I have not traced why. An orange square on white drifted by 674 pixels at 4:2:0 and 575 at 4:4:4, nowhere near the photo's halving, so my toy case does not reproduce the effect.
Everything here is one photo and a few synthetic images through one Pillow build. Nothing took the sneaker background to 100%: quality 100 with 4:4:4 still left 0.052%. To check a pipeline I would count strays inside the 8-pixel band and beyond it separately: the far background should stay near 0.0162%, and the band is where a change of encoder shows up.
Top comments (0)