DEV Community

yue xing
yue xing

Posted on

Why does a white background read 254 after I save it as JPEG? I counted the pixels


I cut a pair of white sneakers out of a photo, put them on a pure white background and saved a PNG. Then I saved the same image as a JPEG and read one background pixel in both files. The PNG said 255, 255, 255. The JPEG said 254, 254, 254. Nothing is broken: JPEG never promised that a flat white area stays 255. On this photo 0.66% of the background came back different, nearly all of it right next to the shoe.

The photo is from Wikimedia Commons (JustinLiang522, CC BY-SA 4.0). I cut it out with ImgIng (https://imging.ai/) in a desktop browser, set the background to white in the refine step and exported a 1,280×1,707 PNG. The export list has PNG, WebP, AVIF, TIFF and GIF but no JPEG, so I made the JPEG with Pillow 11.3.0 on an M4 Mac. ImgIng's compress and convert page writes JPEG too, and what I measured there was 4:2:0, the same chroma setting as my default case below. "Background" means the pixels the cut-out left fully transparent, 55.2% of the image, all 255 in the PNG. KB means 1024 bytes. One photo, one encoder.

Reading the pixel without a picker

A color picker shows one pixel's value. My first suspicion was my screen or the picker, so I read the pixel from the decoded file and counted how many background pixels changed.

plain = Image.open("shoe_white.png").convert("RGB")
mask = np.array(Image.open("shoe_cutout.png").convert("RGBA"))[..., 3]
bio = io.BytesIO()
plain.save(bio, "JPEG", quality=85, subsampling=2)
back = np.array(Image.open(bio))
empty = mask == 0
print("PNG ", np.array(plain)[932, 200], "JPEG", back[932, 200])
ring = Image.fromarray(((mask > 0) * 255).astype("uint8")).filter(ImageFilter.MaxFilter(17))
close = empty & (np.array(ring) > 0)
wrong = (back != 255).any(axis=2)
for label, zone in [("all", empty), ("within 8 px", close), ("beyond 8 px", empty & ~close)]:
    print(f"{label:12s}{zone.sum():>9,d} px, {wrong[zone].mean():.4%} not 255")
Enter fullscreen mode Exit fullscreen mode
PNG  [255 255 255] JPEG [254 254 254]
all           1,206,652 px, 0.6560% not 255
within 8 px      66,781 px, 11.5766% not 255
beyond 8 px   1,139,871 px, 0.0162% not 255
Enter fullscreen mode Exit fullscreen mode

That pixel, x=200 and y=932, sits within one pixel of the shoe's outline. The file really holds 254, so the picker told the truth. The count says where the rest went: of the 7,916 changed background pixels, 97.7% are in the 8-pixel band, which is only 5.5% of the background. Half of them read 254 in their darkest channel, a third 253, and the worst reads 239.

Heatmap of background pixels that are not 255 after saving as JPEG, with a 4x zoom of the shoe edge

Look at where the red sits: only along the outline, never in the open white. In the zoom on the right the JPEG pixels look white to my eye, and the red marks the ones that are not 255. Photo: JustinLiang522, Wikimedia Commons, CC BY-SA 4.0; the figure is an adaptation under the same licence.

I read this as JPEG working in 8×8 blocks: a block holding a hard dark edge spreads its rounding error over the whole block, white part included, and all-white blocks have nothing to spread. I haven't read the encoder source, so this is my interpretation.

Quality and chroma help but never reach zero

I ran the same count over quality and chroma subsampling.

Quality Chroma Size (KB) Background not 255
75 4:2:0 182 0.61%
75 4:4:4 209 0.29%
85 4:2:0 216 0.66%
85 4:4:4 246 0.29%
95 4:2:0 324 0.39%
95 4:4:4 365 0.18%
100 4:4:4 625 0.05%

Going from quality 75 to 95 at 4:2:0 cost 142 KB for a drop from 0.61% to 0.39%. Switching to 4:4:4 at quality 85 cost 30 KB and cut the count by more than half. Quality 100 with 4:4:4 reached 0.05% at 625 KB, about three times the default size. A plush toy and a stemmed glass gave the same pattern, and none of the three photos reached zero: quality 100 with 4:4:4 ended between 0.02% and 0.05%.

What I would do about a rule that says 255

Amazon's seller central product image guide for the US marketplace (viewed 2026-10-23) asks for a pure white background, RGB 255, 255, 255, and recommends JPEG. On this photo a JPEG cannot meet both to the last pixel. I don't know how any marketplace checks, and I read only that page.

For my own project I'll keep the PNG as the master and export JPEG at 85 or 95, with 4:4:4 if the size is fine. A check that only looks at the outer 3 pixels of the image would have said 99.99% here and missed everything around the shoe. Because most changed pixels read 253 or 254, I would count a background pixel as white when all three channels are at least 250. That threshold is my guess from one photo, not something I validated.

What I still do not get

I expected chroma to matter little for white, since white has no color to lose, yet 4:4:4 halved the count at the same quality. My guess is that the shoe's dark edge bleeds through the chroma blocks into the white beside it, but I only changed the setting and counted. Next I'll split the changed pixels into exactly 255, 250 to 254 and below 250 on a second photo.

Top comments (0)