DEV Community

Lank_M
Lank_M

Posted on

The compression ratio looked great and the deliverable still failed

Last quarter I rebuilt the image delivery path for our site and put compression behind a single pipeline: whatever came in, the same parameters went out. I used average compression ratio as the acceptance metric, the dashboard said 78%, and I shipped it feeling pretty good about myself. Over the next two weeks the same pipeline got three separate complaints. Design said the drop shadow on a badge was gone. A frontend dev said the code lines inside our help-center screenshots were unreadable. Someone on the campaign team said a gradient banner had visible horizontal stripes. I treated all three as one problem, bumped the quality level up a notch, and fixed exactly none of them.

They were never the same failure. One category was losing spatial resolution, one was losing color resolution, and one was losing an entire channel. The compression ratio was excellent in all three cases, which is precisely why it never caught anything.

I rebuilt the samples by type and measured each one separately. The screenshot (1280×800, 94.0 KB), the gradient background (960×480, 53.5 KB) and the shadowed transparent badge (960×480, 35.2 KB) are all synthetic files I generated; the photo is a CC0 image from Wikimedia Commons shot on an iPhone 6, not mine.

Screenshots: the savings came out of the pixel count

The help-center path was "downscale to 50%, then save as JPEG q80". That is by far the best-looking number: 94.0 KB becomes 20.1 KB, 21% of the original. Measured against the original it lands at PSNR 27.0 and SSIM 0.939, and essentially all of the loss sits on glyph strokes. Zoom the monospaced line in the screenshot to 3× and it is a colored smear.

Three ways to compress the same UI screenshot

Synthetic UI screenshot with made-up content; the reduced-color column ran through ImgIng's online image compression at its default setting, scaling and JPEG done locally with Pillow

Keep the pixel dimensions and the alternatives are barely larger. WebP q80 is 31.6 KB at PSNR 41.0, AVIF q60 is 18.5 KB at 40.6, and PNG-8 quantized to 256 colors is 37.3 KB at 55.9. Running the same file through ImgIng's compression gave me a 208-color indexed PNG at 37.6 KB, a 60% saving, with PSNR 60.7 and SSIM 1.000 — at 3× the glyph edges line up with the original almost pixel for pixel. The 208 rather than 256 tells me it counted the colors actually present, though I only get to infer that from the output.

So the check I ended up adding for screenshots has nothing to do with quality metrics. Output width and height must equal input width and height, or the job fails. The compression ratio is not consulted.

Gradients: dithering costs 14.9× the file size

The banner stripes are color banding, and banding is measurable. Take one row of pixels, look at the red channel, count how many distinct values exist. My gradient sample has 200 levels in a row. Quantized to 256 colors it drops to 16, at 64 colors to 10, at 16 colors to 5. The file sizes go 12.8 KB, 7.7 KB, 5.0 KB against a 53.5 KB original — the 91% saving is the same setting that produces the worst banding.

Color banding and the cost of dithering when quantizing a gradient

Synthetic 960×480 gradient background; quantization and dithering done locally with Pillow

The textbook fix is dithering, and it loses on both fronts here. At 16 colors with dithering the file goes from 5.0 KB to 74.4 KB — 14.9× larger than without — and PSNR still falls from 25.3 to 22.6. At 64 colors with dithering it is 95.4 KB, 12.3× larger. In hindsight it is obvious: lossless compression feeds on neighboring pixels looking alike, and dithering sprays high-frequency noise across exactly the flattest regions. I reran that row four times anyway, because "adding noise makes the file an order of magnitude bigger" does not feel true the first time you see it.

One aside worth recording. When I fed the same gradient through the hosted pipeline it classified the image as icon/line-art/screenshot, quantized it anyway, produced something larger than the source, and then declined to deliver a new file at all — the UI said the original was already the smallest option. The classification was wrong, but shipping an unchanged original beats shipping a smaller file with banding.

Transparency: the smallest output had no alpha left

The badge was the easiest to pin down. Converted to JPEG q80 it came out at 11.8 KB, the most aggressive saving of the three categories. JPEG has no alpha channel, so the encoder drops it, flattens onto a matte, and writes a perfectly valid small file. No error, no warning, a successful job in the report.

The numbers: alpha went from 255 distinct levels to 1. Composited over a dark background it scores PSNR 17.9 against the original; over a light background, 1.2. A 1.2 dB result is not "lossy", it means the two images are barely related. Compositing over both matte colors matters — 17.9 alone reads as merely bad.

Exported to WebP through ImgIng's convert-and-compress path the badge is 20.2 KB (43% smaller) at PSNR 53.2 with 255 alpha levels; AVIF is 2.4 KB (93% smaller) at 52.1 with 256 levels. A separate 256×256 soft-edged icon went from 11.0 KB to 5.4 KB as WebP with its 244 alpha levels intact. I re-decoded the AVIF file twice before believing the 2.4 KB, and that sample is a large flat area plus one soft shadow, which is about the friendliest possible input. I did not test old-device support in this round, so production still falls back to WebP by Accept header.

Photos: lock the byte budget, not the quality number

Nothing controversial here except one habit worth dropping — hand-picking quality numbers. The same quality value produces wildly different sizes across images, so I search for the quality that fits a size budget instead:

def fit_two_pass(im, fmt, target, coarse=10):
    """Coarse sweep first, then walk back up one point at a time."""
    q = next((c for c in range(95, 0, -coarse) if size_at(im, fmt, c) <= target), None)
    if q is None:
        return None
    while q < 95 and size_at(im, fmt, q + 1) <= target:
        q += 1
    return q, size_at(im, fmt, q)
Enter fullscreen mode Exit fullscreen mode

For the 2048×1536 CC0 photo under a 150 KB budget: JPEG lands on q23 (147.6 KB, PSNR 33.7, SSIM 0.938), WebP on q65 (149.8 KB, 37.6, 0.965), AVIF on q59 (145.8 KB, 38.9, 0.973). Same byte budget, roughly 5 dB between AVIF and JPEG. Median encode times on this Mac were 4 ms, 126 ms and 131 ms, which says something about Pillow on my laptop and nothing about your build.

What acceptance actually checks now

Compression ratio and PSNR both lie on their own. The downscaled screenshot has the best ratio in the batch, the worst banding is not the lowest PSNR, and the flattened badge still scores 17.9 dB over a dark matte. Three assertions run in the pipeline instead, one per failure mode. Dimensions must match. Smooth material must keep its channel levels above a fraction of the source — I set that at a quarter, which is a guess I am still tuning. And anything with alpha gets counted and composited:

def alpha_steps(path):
    """How many distinct alpha values survived? 255 in, 1 out means it was flattened."""
    with Image.open(path) as im:
        if "A" not in im.convert("RGBA").getbands():
            return 1
        return len({px for px in im.convert("RGBA").split()[-1].tobytes()})
Enter fullscreen mode Exit fullscreen mode

Routing is still driven by directory convention — ui/ goes through color reduction, hero/ goes to WebP or AVIF, badge/ is forced into an alpha-capable export. It is not elegant, but when something breaks I no longer have to guess which stage did it. If you want to sanity-check a single asset by hand, ImgIng (https://imging.ai/ ) keeps the screenshot path and the transparent-export path separate, which makes the comparison quick.

Top comments (0)