Rank image check failures by severity - #865
Conversation
The current image check asks only "do any pixels differ?", which for scientific plots is almost always yes: a matplotlib upgrade moves every anti-aliased edge by a fraction of a pixel, so visually identical images score 2-20% and all 1274 MPAS-Analysis mismatches in the 2026-08-04 weekly test looked alike to a reviewer. This adds a scorer that reports *how badly* two images differ, so failures can be ranked and reviewed worst-first. It measures two things: how much the figure's size changed (which catches a missing panel or dropped subtitle), and how much of the picture looks different once small movements are forgiven. The movement tolerance compares each pixel against a neighborhood of the other image, so a feature that merely shifted matches while one that vanished does not. Both directions are checked and the larger difference wins -- a one-directional check silently misses deleted thin features such as contour lines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Severity is measured as the fraction of the picture that looks different, and area is not the same as importance. A single wrong digit in a "Mean -0.18" label is about 13 pixels, roughly 240 times smaller than the noise floor, so no area-based score can ever see it -- yet it means the underlying data changed. This is not hypothetical: the repository's own e3sm_diags test fixture differs only by "Mean -0.18" becoming "Mean -0.17". Without this check that fixture scores zero and the existing test fails. The check looks for compact spots of strong difference, using a one pixel movement tolerance rather than four, because at four pixels one digit simply looks like another. Diffuse anti-aliasing noise does not survive it: of 5375 unchanged e3sm_diags images, 5357 report zero. Such images are marked MINOR so they are always reviewed, but stay ranked below changes that are visible at a glance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The image check previously produced one undifferentiated list of every
image that differed, which in the 2026-08-04 weekly test meant 1274
entries for MPAS-Analysis with no indication of which mattered.
Failures are now scored, sorted worst first, and grouped by likely
cause, so a reviewer can start at the top and stop once the remaining
differences are clearly cosmetic. Two new files are written next to the
existing outputs:
severity_report.txt ranked and grouped, for a human
image_scores.json raw scores, so thresholds can be re-examined
without re-running the comparison
Images whose only differences are cosmetic are counted in the summary
but no longer reported as failures, and their diff images are no longer
written, which removes most of the output the check produces.
The diff grid PDF is now ordered worst first for the same reason.
numpy and scipy are listed in conda/dev.yml. Both already arrived
indirectly through mpas_tools; the test code now imports them directly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Explains why images differ after a dependency upgrade, what each severity means, which files to read first, and how to use the grouping to check one example per cause rather than every image. Also flags the one case where a low rank does not mean a small problem: a changed number is only a few pixels, so it is found by a separate check and reported as MINOR even though it matters. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The small-change check looked for compact spots of strong difference, but a plot whose gridlines and coastlines all shifted by a pixel produces such spots across the whole figure. On pcmdi_diags that misread 172 of 647 unchanged images as possible value changes. A changed number is a few spots in one place, so ignore the signal entirely when the spots are scattered. This drops pcmdi_diags from 176 images needing review to 40, and still finds the CRU fixture, whose changed number is 4 spots totalling 55 pixels. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude's response: TestingUnit tests22 tests in The existing
Validation on real weekly-test outputScored ~20,000 real image pairs across 3 weeks, both v2 and v3, and 4 tasks. "Old" is what the current checker reported.
Two things worth pulling out:
One honest caveat on the last row: the 1404 global_time_series images that the old check flagged are now bit-identical to the expected images, so that list was stale. It is a useful specificity check (1404 identical images, zero flagged), not 1404 false positives removed. What the output looks like
Those three groups were checked by eye and are all genuine:
And {
"name": "mpas_analysis/mvm/.../evaporationFlux_ANN_antarctic_antarctic_...png",
"severity": "STRUCTURAL",
"content_fraction": 1.0,
"geometry_change": 0.262981,
"localized_pixels": 0,
"cause": "panel added/removed"
}Where the thresholds came fromHand-labelled 13 image pairs by looking at them, then swept the movement tolerance from 2 to 5 pixels. 4 pixels gave the best separation between images that were genuinely different and images that only looked different:
5 px separates slightly better but forgives more real movement, so 4 is the compromise. Two independent cross-checks: the split it produces (40% cosmetic / 55% real on the 08-04 run) closely matches the "43% cosmetic / 54% real" human assessment recorded in #859, which nothing here was tuned against. Status of the remaining rough edgeThe small-change check (§ "One case that needed separate handling" in the description) is the least settled part, so here is exactly where it stands. It works, with a known false-positive rate that varies by task. On e3sm_diags it returns zero on 5357 of 5375 unchanged images (0.33% false positives). On pcmdi_diags the first version was much worse — 172 of 647 — because those plots have dense gridlines that all shift a pixel at once and produce spots everywhere. Requiring the spots to be few and in one place (a changed number is; shifted line art isn't) brought that to 36, and the last commit does exactly that. What I could not solve: there is no way to catch this with the main severity score. A changed digit is ~13 pixels, about 240× below the noise floor, and I measured that floor three ways (resize, padding, integer alignment) — the best is ~0.0009, still far above 0.0000037. So it genuinely needs to be a separate check rather than a tuning change, and a separate check means a separate false positive rate. If those |
|
@forsyth2, I hope this makes sense as a replacement for #860 in your eyes. I think it will work better. I don't know if it really requires incrementing the minor version number, since it is only a testing feature. Maybe Claude and I went overboard there. I have not attempted to read the code carefully, instead focusing on the testing outcomes, which seem promising to me. Claude is still working on improving the PDF produced as part of testing. Once that gets fixed, I've asked it to post an example. |
The PDF remains the quickest way to review failures by eye, so it now follows the same worst-first order as the rest of the report and says which severity each row is. Also: - Wrap the row title. Paths are long enough that the title ran off both edges of the page and could not be read. - Name the three columns actual/expected/diff, rather than expecting the reader to remember the order. - Save the figure just built rather than whichever figure is numbered 1. That worked only because matplotlib reuses the lowest free number after the previous page is closed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude's response: The diff grid PDF is still there, and now follows the rankingIt was never dropped — but it was the one output I had not actually exercised, because my end-to-end test wrote outside the web portal path and Example (24-image sample of the 08-04 MPAS-Analysis run): Of those 24 images, 9 are cosmetic and dropped; the PDF holds the remaining 15, worst first — 9 What changed in the PDF
Two unit tests now cover the ordering contract the PDF depends on, since the grid itself needs One caveat on the demo: the linked directory is a scratch copy under my |
The last page is usually not full, and the leftover axes were still drawn -- empty boxes with 0.0-1.0 tick labels, which read as though images had failed to load. Also pass squeeze=False to plt.subplots so `axes` stays two-dimensional. With rows_per_page=1 it would otherwise be a flat array of three axes and the row loop would iterate over single axes instead of rows. Verified by rendering the final page before and after. Not covered by a unit test: building a grid needs mache and a web-portal path, and checking the result needs a PDF renderer that is not a project dependency. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude's response: Two follow-ups1. Blank axes on the last page — fixedYou were right that this predates the PR. The last page is usually not full, and the leftover axes were still drawn, so the PDF ended with empty boxes carrying 0.0–1.0 tick labels — it read as though images had failed to load. They are now hidden. The example PDF has been regenerated; page 8 now shows only its single real row. While there, No unit test for this one: building a grid needs 2. The missing subtitles were an MPAS-Analysis bug, and it is fixedNot a mystery — MPAS-Analysis #1158, merged 2026-08-23.
That single root cause explains both of the failure groups this PR's report separates out:
The weekly runs bracket the fix, which makes a useful end-to-end check of the severity scoring. Taking the 302 images that scored
Figure heights go from 1399/1418 back to 1446, matching expected. Nothing remains So the calibration question I raised earlier — whether the missing subtitle was a real regression or an intended change — is settled: it was a real bug, it is fixed upstream, and the scorer ranked it as |
The report groups failures by cause so a reviewer can check one example per group rather than every image. The PDF now says which group each row belongs to, so it can be used the same way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude's response: Why the old PDF had 15 rows, not 24Because 9 of those 24 scored 1. A sample of every categoryRe-sampled the 08-04 MPAS-Analysis run stratified by (severity, cause), 3 per group, rather than 24 at random. All four severity levels and all four causes now appear:
Each row now also names its cause, not just its severity, so the PDF can be used the same way as the grouped report: check one example per group. 2. What is not getting flaggedFour cosmetic cases the old check reported as failures and this one drops. Left to right: expected, actual, difference amplified 8x so the noise is visible at all.
Identical statistics ( Same story across three panels — all six printed statistics match exactly, and the difference is confined to edges. The other two: snowFlux, salinArgo. There is also a cosmetic_sample PDF with 6 more, if it is easier to page through. That one is generated by a demo script, not by the checker — the checker deliberately does not write diff images for cosmetic results. |
@xylar This is amazing, thank you! I had tried using CV to do some sort of categorization in a hackathon project (#727), but wasn't able to get it to a production-ready state. This categorization by severity is a great improvement.
I wouldn't worry too much about that. The PR template forces a choice (patch, minor, major), but realistically the version increment of the next
This example looks pretty nice -- the diffs are indeed marked structural, major, minor.
|
|
I think this PR could also close #849, as it removes much of the motivation for testing with frozen environments in the first place (i.e., "do we actually care about this diff?") |
forsyth2
left a comment
There was a problem hiding this comment.
Following today's (9/4) test, I reran the image checker test using this PR's updates. Thanks to #861, doing so won't overwrite the image diffs seen on the main testing log.
Retest with this PR
cd /lcrc/group/e3sm/ac.forsyth2/zppy_main_branch_test_dirs/zppy
git status
# On branch test_zppy_20260904_run1
# Has uncommitted changes
git add -A
git commit -m "Testing" --no-verify
# Fetch the branch from https://github.com/E3SM-Project/zppy/pull/865
git fetch upstream better-image-diffs
git cherry-pick 0e1fbdd849e7a860f048a4c9dd7221415ae869f5
git cherry-pick 2a5831e79cc1907e90fe64b1c699f4d51018260c
git cherry-pick 0c9c61a4a52886f95f59a0cde2dc7effe8b6d854
git cherry-pick da50af1a7e152d32eaa58a48a824fcb2b27fccb6
git cherry-pick 91f35091e43c56fb71d7cc5adfb677886ed68406
git cherry-pick 168ed53f844bcdd9215eb724533502b9423011f0
git cherry-pick 330e5fcd9f406468988813093b4d0b1a612b8aee
git cherry-pick 0762c226baf7cec8ab1bf681bbebd100e819ab81
git log --oneline | head -n 10
# 7775d42f Name the likely cause on each diff grid row
# 8588ffb2 Hide unused axes on the last page of the diff grid
# e13ee3e0 Label and order the diff grid PDF by severity
# 2dd4d83c Ignore scattered spots when looking for changed values
# ce369bd5 Document how to review image check results
# 3bbacefd Rank image check failures by severity instead of listing them flat
# 31f3d9cd Detect small isolated changes such as a changed number
# 5c346f72 Add image severity scorer
# d0aa0826 Testing
# 77f4ab66 Append try# suffix to image_check_failures dir instead of overwriting (#861)
# The image checker test, which we'll run from a compute node:
salloc --nodes=1 --partition=debug --time=02:00:00 --account=e3sm
source /home/ac.forsyth2/miniforge3/etc/profile.d/conda.sh
conda activate test-zppy-main-20260904_run1
cd /lcrc/group/e3sm/ac.forsyth2/zppy_main_branch_test_dirs/zppy
python -m pip install .
pytest tests/integration/test_images.py
# Pasted `Captured stdout call` below
cat test_images_summary.md
# Pasted below
exit # Exit compute note--------------------------------------------------------------- Captured stdout call ----------------------------------------------------------------
Preparing weekly cfg tests
Preparing legacy 3.1.0 cfg tests
Preparing legacy 3.0.0 cfg tests
Running 9 tests in parallel
Individual test logs will be written to test_<name>.log files
✗ Test bundles generated an exception:
Copy the output of early_test_images_summary.md to a Pull Request comment
Copy the output of early_test_images_summary.md to a Pull Request comment
============================================================== short test summary info ==============================================================
FAILED tests/integration/test_images.py::test_images - AssertionError
========================================================== 1 failed in 3947.40s (1:05:47) ===========================================================
cat early_test_images_summary.md:
| Test name | Total images | Correct images | Cosmetic only | Missing images | Needs review | Severity |
|---|
I don't have time today to do a thorough review, but an early takeaway is that it lasted 4x longer (65 min instead of 15 min) than the 9/4 test itself and still didn't produce a test summary.
Next week I'll aim to debug the logs and also do a visual inspection of this PR.
The image check crashed on a clean run. Only failures write into diff_dir, so when nothing needed review the directory was never created and a bare assert -- written when the only way to have no directory was for every image to be missing -- fired instead. It surfaced as an AssertionError with no message, which the harness reported as "Test bundles generated an exception:" with nothing after the colon, and left the summary table empty. The bundles config has no real differences, so it hit this first and took the whole run with it. The directory is now created up front, which also gives the ranked report and scores file somewhere to live on a clean run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scoring every pixel of every image made the check about four times slower than the one it replaces, which on the weekly test meant 65 minutes instead of 15. Almost nothing actually changes between runs -- 98.5% of the 2026-09-04 weekly test was byte for byte identical -- and those pairs can be settled by comparing the files directly, without decoding either PNG. Decoding and the pixel comparison now happen only for images that really differ. Measured per image, against the check this replaces: e3sm_diags 238 ms -> 44 ms mpas_analysis 233 ms -> 30 ms so the new check is faster than the old one rather than slower. Files that differ may still decode to identical images; that costs nothing, as such a pair simply falls through to the full comparison. Parallelism was deliberately not used: the test harness already runs these in a six-worker process pool. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A large change in figure size was always reported as a panel being added or removed. On pcmdi_diags it is not: those figures gained y-axis latitude labels and a CORR statistic, which grew them by enough to trip the same threshold. The severity was right -- these are real differences worth reviewing -- but the stated cause was not, and the cause is what a reviewer uses to decide which group an example stands for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude's response: Thanks @forsyth2 — that run found two real bugs, both mine. Both are fixed, and I reran your exact test against your 9/4 outputs. 1. The crash
else:
# diff_dir won't exist if all the expected images are missing
assert len(missing_images) == counter # 0 == 1843 -> AssertionError('')A bare assert has an empty message, which is why you got 2. The 4x slowdownMy fault, and my earlier comment was misleading about it: the "50s on 32 cores" figure came from my analysis scripts, which used a process pool. The shipped code is a serial loop. Measured per image it was 2.5–4.9x slower than the check it replaces, which matches your 65 minutes. The fix came from profiling: 98.5% of pairs on your 9/4 run are byte-for-byte identical — only 150 pcmdi_diags images differ at all. Those can be settled from the file bytes without decoding either PNG.
Rerun of your test, same dataI symlinked your 9/4 outputs into my 8 minutes 2 seconds, versus 65:47 on your run and ~15 min for the 9/4 test itself. Full summary table produced. 50,655 images compared; 155 need review. It still exits non-zero, which is correct — there are genuine differences: The pcmdi_diags count is exactly the 150 your 9/4 run flagged, so we agree on what differs; this adds the ranking and grouping. I checked On
|
| config | images | share |
|---|---|---|
| comprehensive_v3 | 9615 | 19.8% |
| legacy_3.1.0_comprehensive_v3 | 8540 | 17.6% |
| legacy_3.0.0_comprehensive_v3 | 7226 | 14.9% |
| the other six | 5514 / 2234 each |
comprehensive_v3 alone accounts for a fifth of the work, so it is the critical path and no amount of extra workers shortens it.
Worth knowing if this is ever revisited: the work is filesystem-latency bound, not CPU bound. A single process manages ~10 images/s, but 24 concurrent readers reach ~1360/s, because concurrency hides per-file latency rather than adding compute. So the lever is granularity, not worker count — splitting per (config, task) would give 33 items instead of 9 and cut the critical path to the largest single task. That is a change to the test harness rather than to this PR, so I have not touched it.
Cause labels reworded
Per @xylar's feedback, the labels were trying to name culprits the check cannot actually identify. ts_ocean_AC.png above is the example: labelled "panel added/removed" when no panel changed. They now describe what was measured — "figure size changed a lot", "figure height changed", "same size, content differs" — with the docs giving typical interpretations rather than pretending to be exhaustive.
One thing to know before reviewing
The expected-images baseline was regenerated on 9/4 at 11:55 — 6727 of 10007 v3 images and 4726 of 5514 v2. Presumably deliberate on your side, but it means the ~20,000-image validation table in my earlier comment was computed against a baseline that no longer exists. The conclusions about the method stand; those particular numbers no longer describe current behaviour.
Commits: 38be3d1e, a5894a9a, b6627be5, f737a548.
The cause labels named specific culprits -- "panel added/removed", "title or label line added/removed" -- but the check cannot know any of that. It measures dimensions and pixels; the reason is for the reviewer to see. On pcmdi_diags the "panel" label was simply wrong: those figures gained axis labels and a printed statistic. Labels now say what was measured. They still group well, because one upstream change tends to alter many images the same way, and the docs give typical interpretations rather than pretending the list is exhaustive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>


Summary
Objectives:
Issue resolution:
Select one: This pull request is...
The problem
The image check asked one question: do any pixels differ? For our plots the answer is nearly always yes. Upgrading matplotlib nudges every anti-aliased edge by a fraction of a pixel, so images that look identical differ in 2–20% of their pixels — far above the 0.02% threshold.
In the 2026-08-04 weekly test that meant 1274 of 1280 MPAS-Analysis images were reported as failures, in one flat alphabetical list, with nothing to say which ones mattered. Some were real bugs (a whole panel missing). Most were not. There is no threshold on that metric that separates them.
What this changes
Every image pair now gets a severity, and failures are sorted worst first.
STRUCTURALMAJORMODERATEMINORNEGLIGIBLETwo files are written next to the existing outputs:
severity_report.txt— the ranked list. Starts with counts, then a grouping by likely cause, then every image needing review, worst first.image_scores.json— the same as raw numbers, so thresholds can be revisited without re-running the comparison.On that same 2026-08-04 run: 516 images are now cosmetic and not reported, and the remaining 764 fall into 4 cause groups, so you check a handful of examples instead of scrolling 1274 filenames.
How it works, in one paragraph
Instead of comparing pixel to pixel, we ask of each pixel: does this colour appear anywhere within 4 pixels in the other image? If it does, the feature merely moved and we report nothing. If it does not, it genuinely changed or disappeared. That forgives the small reflow a matplotlib upgrade causes while still catching a deleted contour or a missing panel.
This is checked in both directions, which matters more than it sounds. If a contour line was deleted, the actual image is blank there — and blank pixels certainly do appear near the line in the expected image, so looking only one way sees nothing wrong. Only asking the reverse question notices the line is gone.
Why not #860
#860 assumed the whole figure slid by a fixed amount and tried to shift it back. Figures don't slide uniformly: the title, each panel and the colourbar all move by different amounts. To absorb that, #860 needed a tolerance so loose that real deletions passed silently. Letting each part of the image move a little on its own handles the same problem without that trade.
One case that needed separate handling
Severity is based on how much of the picture changed, which is not the same as how much it matters. A wrong digit in
Mean -0.18is about 13 pixels — roughly 240× smaller than the background noise level — so no area-based score can ever see it.This is not hypothetical: our own e3sm_diags test fixture (
tests/images/CRU-TREFHT-*) differs only byMean -0.18becomingMean -0.17, andtest_image_checker.pyasserts it must be caught.So there is a second, stricter check that looks for a few compact spots of strong difference. It finds the fixture, and returns exactly zero on 5357 of 5375 unchanged e3sm_diags images. Those images are marked
MINORwith the cause "small isolated change (possible value change)" — worth knowing that this is the one group where a low rank does not mean a small problem.Big Change
1. Does this do what we want it to do?
Required:
2. Are the implementation details accurate & efficient?
Required:
3. Is this well documented?
Required:
docs/source/dev_guide/tests/image_checking.rst.)4. Is this code clean?
Required: