The Gate That Stops Visual Damage Before You Hand Bulk Image Optimization to an Agent
When an agent bulk re-encoded a few hundred wallpaper assets, a handful came back with dulled color. Size-reduction alone cannot catch that. Here is how to design a gate that stops bad conversions before merge using three axes — SSIM, ΔE, and file size — with a checker that runs on Pillow and scikit-image.
One night I was tidying the assets in one of my wallpaper apps. There were about 320 preview PNGs for the home grid, and I asked an agent to batch-convert them to WebP. By morning the log looked flawless: total size down from 41MB to 17MB. Nothing to argue with.
Then I checked on a real device. A few images — deep indigo night skies — had turned slightly gray. The kind of difference you only notice when you place the two side by side. The agent had reported "size reduced" as success, and I had trusted that report right up to the edge of merging.
Something tightened in my chest. Had it shipped unnoticed, the color of the very pieces I most wanted people to see would have been quietly degraded.
This article is about how to design a gate that captures "visual damage" as numbers and stops it before merge when you delegate bulk image optimization to an agent. I will share the implementation from an indie wallpaper app — a place where color itself is the product — along with the threshold guidance I settled on in production.
Why "size went down = success" is the wrong place to stop
Image optimizers love to report their reduction ratio, and agents do the same: "Converted 320 images, average 59% smaller." That is not a lie. But a reduction ratio only tells you how much was thrown away, never what was thrown away.
Most of the loss is invisible. Lossy JPEG and WebP compression, depending on the quantization tables, introduces banding across smooth gradients. Conversions that reduce the palette round off subtle tonal steps. If a color profile is stripped, pixels with identical values render as different colors.
Each of these produces a "perfectly valid file" that sails past corruption checks. Human review has limits too. Comparing 320 images one by one against the originals exceeds the number of frames your attention can actually hold. That is precisely the kind of quiet degradation I nearly missed.
So we need to translate degradation into numbers a machine can measure, and route only the ones that fall below threshold back to a human. Let the agent do the optimization itself — that is fine. What it must not own is the final pass/fail decision.
Capturing visual damage as numbers — three axes
I judge on three axes. One is not enough; the three cover each other's blind spots.
Axis
What it measures
What it catches
SSIM (structural similarity)
Agreement of luminance, contrast, and structure
Banding, crushed detail, softened edges
ΔE (color difference)
Perceptual color shift (distance in CIELAB)
Hue drift, desaturation, profile stripping
File size
The effect of optimization
Both over-compression and no-op conversion
SSIM is 1.0 for a perfect match, and lower as structure breaks down. Unlike a per-pixel difference (MSE), it captures structural change in a way closer to whether a human would call two images "the same." Its sensitivity to banding and crushed detail is the advantage.
SSIM, however, is relatively blind to hue drift. As long as luminance holds, a blue leaning slightly green barely moves the score. For a wallpaper app, where color is everything, ΔE fills that gap. ΔE is a distance in CIELAB space, where roughly 1.0 is "a difference a trained eye can just perceive," and 2–3 is "a difference visible when placed side by side."
The third axis, file size, checks the health of the optimization rather than degradation itself. An extreme reduction ratio hints at over-compression; almost no reduction means the conversion is not doing anything — so there is no reason to take on a quality risk for it.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A gate design that catches the quality loss a size-reduction number can never reveal, using SSIM, ΔE, and file size together
✦A CI-ready image quality checker built with nothing but Pillow and scikit-image
✦Real threshold guidance by conversion preset, drawn from running a wallpaper app in production
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The decision lives in one function: take the original and the converted image, compute the three axes, return a verdict. The only dependencies are Pillow, scikit-image, and numpy. I avoided heavier libraries so it runs in a few seconds even inside an isolated CI environment.
# image_quality_gate.pyfrom dataclasses import dataclassfrom pathlib import Pathimport numpy as npfrom PIL import Imagefrom skimage.metrics import structural_similarity as ssim@dataclassclass GateResult: ssim: float delta_e: float size_ratio: float # converted / original passed: bool reasons: list[str]def _load_rgb(path: Path, size: tuple[int, int] | None = None) -> Image.Image: img = Image.open(path).convert("RGB") if size is not None and img.size != size: img = img.resize(size, Image.LANCZOS) return imgdef _mean_delta_e(a: Image.Image, b: Image.Image) -> float: # Pillow packs L,a,b into 0-255, so scale back to real ranges la = np.asarray(a.convert("LAB"), dtype=np.float64) lb = np.asarray(b.convert("LAB"), dtype=np.float64) la[..., 0] *= 100.0 / 255.0 lb[..., 0] *= 100.0 / 255.0 la[..., 1:] -= 128.0 lb[..., 1:] -= 128.0 # CIE76: Euclidean distance in LAB, averaged over pixels diff = la - lb return float(np.sqrt((diff ** 2).sum(axis=-1)).mean())def evaluate( original: Path, converted: Path, *, ssim_floor: float = 0.985, delta_e_ceiling: float = 2.0, size_ratio_floor: float = 0.10, # over 90% off suggests over-compression size_ratio_ceiling: float = 0.95, # under 5% off means the conversion barely helps) -> GateResult: orig = _load_rgb(original) conv = _load_rgb(converted, size=orig.size) g_orig = np.asarray(orig.convert("L"), dtype=np.float64) g_conv = np.asarray(conv.convert("L"), dtype=np.float64) s = float(ssim(g_orig, g_conv, data_range=255)) de = _mean_delta_e(orig, conv) ratio = converted.stat().st_size / original.stat().st_size reasons: list[str] = [] if s < ssim_floor: reasons.append(f"SSIM {s:.4f} < {ssim_floor}") if de > delta_e_ceiling: reasons.append(f"delta_e {de:.2f} > {delta_e_ceiling}") if ratio < size_ratio_floor: reasons.append(f"possible over-compression: size_ratio {ratio:.2f}") if ratio > size_ratio_ceiling: reasons.append(f"optimization not effective: size_ratio {ratio:.2f}") return GateResult(s, de, ratio, len(reasons) == 0, reasons)
The important part is that the verdict comes back not as a bare boolean but with reasons attached. When you route a failure back to the agent, telling it "SSIM was 0.971, below the floor" lets it pick a next move — switch the conversion preset, or drop that single image to lossless. Return only "failed" and it tends to repeat the same mistake.
A word on why SSIM is computed in grayscale. You can run SSIM per channel and average, but color drift is ΔE's job. Keeping SSIM strictly on structure means the two metrics do not overlap in responsibility, so whichever one trips tells you the cause directly.
Deciding the thresholds — from measurement
Thresholds are not something you know upfront. I ran my own assets through the representative conversion presets and looked at the distribution before deciding. Below are rough measurements from 20 images each of three kinds of wallpaper preview (photographic, flat illustration, gradient-heavy).
Conversion preset
Median SSIM
Median ΔE
Size reduction
Tendency
WebP q=90
0.996
0.6
52%
Passes reliably
WebP q=80
0.991
1.1
63%
Photos pass, gradients need care
WebP q=70
0.983
1.9
71%
Gradient-heavy often fails
PNG quantized 256
0.972
2.8
68%
Clearly fails on gradients
From this spread I set the SSIM floor at 0.985 and the ΔE ceiling at 2.0. WebP q=80 became my baseline preset; only gradient-heavy images cluster near the threshold, so the gate picks those up naturally. A picked-up image gets bumped to q=90 or routed to lossless WebP.
The numbers depend heavily on the character of the artwork, so rather than borrowing this table wholesale, I would encourage you to take the distribution on your own assets first, then decide. Adding a --report mode that dumps SSIM, ΔE, and size to CSV makes revisiting thresholds much easier later.
Wiring the gate into the agent's workflow
The gate slots in as an independent verification step after the agent's optimization step. If you fold the two into one step, the same actor that did the optimizing also grades it, and slack creeps in.
Before, my instruction to the agent read like this:
Convert the PNGs under assets/preview/ to WebP, replace the originals, and commit.
After, conversion and judgment are separated, and judgment is delegated to the script:
1. Write each PNG under assets/preview/ out to assets/_staging/ as WebP (do not delete originals)2. Run: python image_quality_gate.py --check <original> <converted> on each3. Replace the original only for the ones that pass4. Leave failures in _staging, list the reasons in quality_report.md, and stop
Not overwriting the originals immediately is the crux. Staging first lets you safely return the failures while finalizing only the passes. Instead of asking the agent to judge, you ask it to obey the gate's output.
Give the script a thin CLI entry point so it works from CI and from the agent's shell alike:
# image_quality_gate.py (append at the end)import sysif __name__ == "__main__": # usage: python image_quality_gate.py --check original.png converted.webp if len(sys.argv) == 4 and sys.argv[1] == "--check": r = evaluate(Path(sys.argv[2]), Path(sys.argv[3])) print(f"SSIM={r.ssim:.4f} dE={r.delta_e:.2f} ratio={r.size_ratio:.2f}") if not r.passed: print("FAIL: " + "; ".join(r.reasons)) sys.exit(1) print("PASS")
Mapping the exit code to the verdict means CI and the agent's shell can both branch on it directly. A non-zero exit simply stops the run, and the following commit never happens.
The axis people miss — color profiles
Even with three axes measured, one kind of degradation slips through: a stripped color profile.
Some conversion tools discard the embedded ICC profile (Display P3, for instance) and write the file as if it were sRGB. Because the numeric pixel values are unchanged, even the ΔE above may show little difference. Yet on a wide-gamut display, reds and greens that should have been vivid render dulled.
This is better prevented at conversion time than judged after. Preserve the profile explicitly on write, and check separately that it was not dropped.
from PIL import Imagedef has_icc_profile(path) -> bool: with Image.open(path) as im: return "icc_profile" in im.info and bool(im.info["icc_profile"])
If the original had a profile and the converted file lost it, that alone is a failure. Until I added this one-line check, my P3 assets had been silently falling back to sRGB for a long time without my noticing. Even when you measure what you measure, whatever axis you do not measure slips through. A gate is not omnipotent; it is a tool for reliably stopping known failures.
What running it taught me
After a few months with this gate, a few things settled into place.
One: the gate makes instructions to the agent shorter. I used to pile on vague requests — "don't over-compress," "keep the color" — but now that the pass/fail criteria live outside as numbers, the instruction is just "convert and run it through the gate." Peeling the definition of quality out of the prompt and moving it into verification code helped more than I expected.
Two: the single image near the threshold teaches the most. Nothing is learned from artwork that passes reliably. The "deep gradient" pictures the gate occasionally catches are exactly the weak point of my asset set, and they prompted me to branch conversion presets by artwork type. A gate is a gatekeeper, but also an observation point that tells you the character of your own assets.
Finally — and this is a note to myself — the easier a number is to read, the more it deserves suspicion. The better the report, the more worth pausing to check what happened behind it. The wider you spread automation, the more that one beat of hesitation pays off.
If this becomes a prompt to think about "which number you accept an agent's work by," beyond images specifically, I would be glad. Start by running your own assets through the checker once and looking at the SSIM and ΔE distributions. From there, the shape of "passing" for your app will start to come into view. Thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.