The Thumbnails Had Been Drifting: Catching Image-Asset Regressions with pHash and SSIM in Your Agent Workflow
After letting an agent rewrite an image pipeline, visual quality can quietly slip in ways tests never catch. This guide shows how to measure that drift with two numbers — pHash and SSIM — and block it in CI, from threshold calibration to a ready-to-run gate script and Antigravity integration.
import RelatedArticles from "@/components/RelatedArticles";
The thumbnails in my wallpaper app had gone slightly sleepy without my noticing.
It started when I asked an Antigravity agent to make thumbnail generation a little faster. It swapped the Pillow resampling filter, tuned the JPEG quality parameter, and shaved about thirty percent off the processing time. The diff looked reasonable. The tests passed. I merged it.
A few days later, glancing at the store's "new arrivals" grid, I noticed faint banding along the edges of a gradient. No user complained. But it was undeniably coarser than before, and the culprit was that resampling change. Nothing was broken — only the look had quietly regressed. That kind of regression never trips an assert.
This piece is about turning that visual regression into a single number and stopping the agent's change at the CI gate. Using two rulers — pHash and SSIM — and building on my own day-to-day practice, we'll assemble a way to catch image-asset regressions mechanically.
Why exact-match and eyeballing both fail
The obvious first idea is to hash the output and compare for an exact match. It breaks down immediately. A single JPEG encoder version bump changes the byte stream; so do EXIF timestamps, an embedded color profile, or a slightly different quantization table — all of them produce "looks identical, bytes differ" situations. An md5 gate goes red on meaningless diffs until nobody looks at it anymore.
Eyeballing doesn't last either. Dozens of thumbnails might be manageable, but once you include resolution variants you're at hundreds. The human eye notices a 5% degradation when two images sit side by side, yet misses 15% when shown one in isolation — and the focus required to line them all up every release doesn't survive two weeks.
What you need is a ruler for perceptual closeness — something closer to "does a person see these as the same" than to byte equality. That's where pHash (perceptual hash) and SSIM (structural similarity) come in.
Method
Catches
Misses
Good for
Byte match (md5)
Exact identity
Flags visually identical files as different
Cache keys, dedup
pHash
Big changes in composition and tonal layout
Local noise, banding
"Turned into a different image"
SSIM
Local texture and gradient degradation
Weak against global shifts
"Same composition, lower quality"
As the table shows, pHash and SSIM are strong in different places. I ended up using both, because either one alone would have missed the banding that first tripped me up.
Turning the drift into one number with pHash
pHash shrinks an image to a small grayscale version and builds a 64-bit fingerprint from the low-frequency components of a discrete cosine transform (DCT). If two images are perceptually close, their fingerprint bits mostly agree, and the size of the difference is the Hamming distance — how many bits differ.
In Python it's a few lines with imagehash and Pillow.
from PIL import Imageimport imagehashdef phash_distance(path_a: str, path_b: str) -> int: """Hamming distance between the perceptual hashes of two images. 0 = near-identical.""" hash_a = imagehash.phash(Image.open(path_a)) hash_b = imagehash.phash(Image.open(path_b)) return hash_a - hash_b # subtracting two ImageHash objects gives the Hamming distanceif __name__ == "__main__": d = phash_distance("baseline/thumb_001.jpg", "candidate/thumb_001.jpg") print(f"pHash distance = {d}")
The ImageHash object returned by imagehash.phash becomes a Hamming distance directly through the - operator. That's the pleasant part of imagehash — you never count bits yourself.
pHash is strong at "turned into a different image" because the low-frequency DCT components describe the overall composition and the layout of light and dark. Swap the subject of a thumbnail or shift the crop, and those components move a lot, so the Hamming distance jumps. A slight change in compression ratio, on the other hand, barely moves them — which is exactly why it doesn't go red on meaningless diffs.
Measuring "re-encode with no change" across 300 real wallpaper thumbnails, the distances stayed at roughly 0–2. On test images where I swapped the subject, they opened up to 18–30. That gap is the room you have to draw a threshold.
✦
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
✦You can detect the quiet visual drift that follows an agent's change to your image pipeline using a single number — the Hamming distance of a perceptual hash
✦You'll take home a practical pattern for the middle ground between exact-match (too strict) and eyeballing (doesn't scale), using SSIM and calibrated thresholds
✦You get a Python gate script and a GitHub Actions snippet you can run tomorrow against your own generated thumbnails and OGP images
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.
But the banding that troubled me slipped right past pHash. Tonal stair-stepping is local and barely moves the low-frequency DCT components, so the Hamming distance stayed at 1 while the look had clearly degraded. This is SSIM's moment.
SSIM (Structural Similarity Index) scans two images with a small sliding window, comparing luminance, contrast, and structure locally, then rolls that into a score from 0 to 1. 1.0 is a perfect match, and it drifts down as local noise or crushed gradients appear. scikit-image has an implementation.
import numpy as npfrom PIL import Imagefrom skimage.metrics import structural_similarity as ssimdef ssim_score(path_a: str, path_b: str) -> float: """SSIM between two images. 1.0 = perfect match.""" img_a = Image.open(path_a).convert("L") img_b = Image.open(path_b).convert("L") # SSIM assumes identical dimensions; conform the candidate to the baseline if img_b.size != img_a.size: img_b = img_b.resize(img_a.size, Image.LANCZOS) arr_a = np.asarray(img_a) arr_b = np.asarray(img_b) return float(ssim(arr_a, arr_b))if __name__ == "__main__": s = ssim_score("baseline/thumb_001.jpg", "candidate/thumb_001.jpg") print(f"SSIM = {s:.4f}")
The key detail is that SSIM assumes identical dimensions. Mismatched sizes raise an exception, so you resize the candidate to the baseline's dimensions first. If you want to include color, drop the grayscale conversion and pass RGB arrays with ssim(arr_a, arr_b, channel_axis=2). That makes it sensitive to color-profile differences, though, so I prefer to run grayscale first and measure color drift separately.
Measuring that banding with SSIM, the score had fallen to 0.983. What pHash saw as "identical" at distance 1, SSIM surfaced clearly as degradation. That difference is the reason to use both.
Setting thresholds from measurement, not intuition
Declaring "distance 5 or under is fine" out of thin air is risky. Image characteristics differ per app. Whether your assets are photographic, gradient-heavy, or contain text changes how much the scores move even for healthy edits.
My procedure is simple. First, run a large batch of re-encodes with no intentional change and measure the distribution of pHash distance and SSIM. Then mix in deliberately degraded images — lower the quality, over-sharpen, swap the subject — and find the boundary where the two populations separate. Add a safety margin to that boundary and use it as your threshold.
Here's what I measured on my own wallpaper assets. These are guides from my data only; always re-measure on yours.
State
pHash Hamming distance
SSIM
Gate verdict
Effectively identical (re-encode only)
0–2
0.99+
Pass
Acceptable (resampling differences, etc.)
3–6
0.95–0.99
Warn (human check)
Suspicious (texture/gradient loss)
7+
Below 0.95
Fail (block)
Turned into a different image
12+
Below 0.85
Fail (block now)
The important thing is not to start from "fail (block)." For the first two weeks, keep everything at "warn" and observe how often, and on which kinds of changes, the scores actually move. Only after the lay of the land is visible should you draw the blocking line. I ignored this order once, went red on every legitimate design update, and had to disable the gate entirely for a while.
Rolling it into a gate script
With both rulers in hand, you compare a baseline directory against a candidate directory and report only the images that deviate. The trick is to emit an exit code an agent or CI can read, plus a diff report for later human review.
#!/usr/bin/env python3"""image_gate.py — compare baseline and candidate image assets perceptually."""import sysimport jsonfrom pathlib import Pathimport numpy as npfrom PIL import Imageimport imagehashfrom skimage.metrics import structural_similarity as ssim# Thresholds (re-measure on your own data)PHASH_WARN, PHASH_FAIL = 3, 7SSIM_WARN, SSIM_FAIL = 0.99, 0.95def compare(base: Path, cand: Path) -> dict: a, b = Image.open(base), Image.open(cand) dist = imagehash.phash(a) - imagehash.phash(b) ga = np.asarray(a.convert("L")) gb_img = b.convert("L") if gb_img.size != a.size: gb_img = gb_img.resize(a.size, Image.LANCZOS) score = float(ssim(ga, np.asarray(gb_img))) if dist >= PHASH_FAIL or score < SSIM_FAIL: verdict = "fail" elif dist >= PHASH_WARN or score < SSIM_WARN: verdict = "warn" else: verdict = "pass" return {"name": base.name, "phash": dist, "ssim": round(score, 4), "verdict": verdict}def main(baseline_dir: str, candidate_dir: str) -> int: base_root, cand_root = Path(baseline_dir), Path(candidate_dir) results, worst = [], "pass" for base in sorted(base_root.glob("*.jpg")): cand = cand_root / base.name if not cand.exists(): results.append({"name": base.name, "verdict": "missing"}) worst = "fail" continue r = compare(base, cand) results.append(r) if r["verdict"] == "fail": worst = "fail" elif r["verdict"] == "warn" and worst != "fail": worst = "warn" Path("image_gate_report.json").write_text(json.dumps(results, indent=2, ensure_ascii=False)) for r in results: print(f"{r['verdict']:>7} {r['name']} phash={r.get('phash','-')} ssim={r.get('ssim','-')}") print(f"\n=== overall: {worst} ===") return 1 if worst == "fail" else 0if __name__ == "__main__": sys.exit(main(sys.argv[1], sys.argv[2]))
The script returns exit code 1 if there's a single fail; a warn stays at code 0 and defers to a human. Keeping image_gate_report.json lets you later hand it to an agent to read which image tripped and why, so it can localize the cause. Treating a missing file (missing) as fail is deliberate: it keeps a pipeline that quietly forgets to emit some images from slipping through.
Wiring it into your Antigravity workflow
Everything so far has value run by hand. But in a workflow where you delegate implementation to agents — as with Antigravity — it pays to close the loop so the agent that made the change runs the gate and fixes its own red.
I keep this passage in AGENTS.md:
## Rules for changing the image pipelineIf you touch `scripts/generate_thumbnails.py` or any Pillow resampling /quality setting, you must run the following after the change: python scripts/generate_thumbnails.py --out candidate/ python image_gate.py baseline/ candidate/When overall is `fail`, do not produce a merge diff. Readimage_gate_report.json, explain the cause, and revisit the generation-sidesettings — not the thresholds. Never update baseline/ without human approval.
That last sentence matters most. When the gate is red, agents often take the shortcut of "update the baseline so it passes." Unless you forbid it, they'll repaint degradation as normal. Baseline updates should happen only when a human confirms an intentional design change — putting that boundary into words is what keeps the automation safe.
if: always() keeps the report as an artifact even when the gate fails — and failure is exactly when the report becomes the clue. If you want broader visual regression across parallel agents, pairing this with how to design parallel-agent visual regression covers both web UI and asset images.
Where people stumble
EXIF orientation. Images from phones sometimes encode rotation in the EXIF Orientation tag. Image.open doesn't apply it automatically, so baseline and candidate can disagree on orientation and pHash spikes. Normalize with ImageOps.exif_transpose(img) before comparing.
Alpha channel. Running convert("L") on a transparent PNG crushes the transparent regions to black and rattles SSIM. Composite assets with transparency onto a known background color before comparing for stable scores.
Resize order for mismatched sizes. When you resize the candidate for SSIM, upscaling and downscaling favor different algorithms. I standardize on LANCZOS for thumbnail comparison; changing it changes the meaning of your thresholds.
Intentional design changes. A refreshed logo or a new palette will turn the gate red even though the change is legitimate. That's not a bug — it's the design. Provide an explicit human step (say, make update-baseline) and keep that action under review, so the intent is recorded in history. If you handle resolution variants as a batch, connecting this with designing wallpaper resolution-variant delivery makes it easier to decide which dimensions the gate should cover.
Wrapping up — stand up a "warn-only" gate this week
Visual regressions are harder to catch than functional ones. Tests green, diff reasonable, and yet the images alone grow quietly sleepy. For the very regression I lived through, pHash and SSIM pick it up without relying on review-time focus.
The first step can be small. This weekend, run image_gate.py in warn mode only. After watching it for two weeks, the "healthy range of variation" for your assets comes into view. Drawing the blocking line can wait until you can see that ground.
I'm still running this myself, re-measuring the thresholds season by season. If it gives you a foundation for protecting your own image assets, I'll be glad. 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.