Skip to content

Commit 3b723f0

Browse files
Jon Keysclaude
andcommitted
feat(image): pixelmatch engine with cluster verdict and ignoredBoxes
Adds 'pixelmatch' as a third comparison engine backed by io.github.t12y:pixelmatch 1.0.0 (a Java port of mapbox/pixelmatch v7: per-pixel OKLab/HyAB perceptual color difference with built-in anti-aliasing detection), usable standalone or in engine combos alongside resemble/ssim. Options (per-call / <name>.json): matchingThreshold (the per-pixel OKLab threshold, 0..1 where black-white = 1.0; named to avoid clashing with the failure 'threshold'), includeAA, checkerboard, and the same ignoredBoxes ([{left, right, top, bottom}], bounds inclusive) the resemble/ssim engines accept - masked pixels are excluded from every count, so dynamic regions (transaction ids, timestamps) can be ignored. Cluster verdict ('clusters': true or a tuning map; off by default; suite-wide via image.clusters, per-call wins): a significance layer that classifies diff clusters as real changes (interior mass surviving erosion, or vivid thin changes on a flat baseline) vs rendering noise (anti-aliasing halos, sub-pixel font shifts between browser versions). When enabled, mismatchPercentage becomes the significant-diff percentage so the existing failure threshold ignores noise; the raw percentage, a human-readable summary, and per-region bounding boxes ride on the diff() result and embed meta (pixelmatchRawMismatchPercentage, pixelmatchSummary, pixelmatchRegions). The engine is count-only: the diff image is always resemble's, which is what the HTML lightbox and its client-side live re-diff render. clusters and ssim's windowSize are unrelated options; pixelmatch ignores windowSize. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c4650e9 commit 3b723f0

7 files changed

Lines changed: 515 additions & 8 deletions

File tree

karate-image/README.md

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ image.baselineDir = 'baselines';
1616
image.optionsDir = 'baselines'; // where <name>.json tuning files live (defaults to baselineDir)
1717
image.threshold = 0.02; // max % mismatch tolerated
1818
image.report = 'mismatched'; // attach diff images: 'all' | 'mismatched' | null
19-
image.engine = 'resemble'; // 'resemble' | 'ssim' | 'resemble,ssim' | 'resemble|ssim'
19+
image.engine = 'resemble'; // 'resemble' | 'ssim' | 'pixelmatch' | combos: 'resemble,ssim' | 'resemble|pixelmatch'
2020
```
2121

2222
Multiple engines: the **smallest** mismatch wins (pass if any engine is within threshold). The
@@ -66,6 +66,67 @@ per-name options at `<optionsDir>/<name>.json` — so options can live locally w
6666
live in, say, S3. Options precedence (low→high): suite/scenario config → `<name>.json`
6767
per-call inline.
6868

69+
## The pixelmatch engine
70+
71+
The third engine, `pixelmatch`, is a Java port of
72+
[mapbox/pixelmatch](https://github.com/mapbox/pixelmatch) v7 (`io.github.t12y:pixelmatch`):
73+
per-pixel OKLab/HyAB perceptual color difference with built-in anti-aliasing detection.
74+
Options (per-call, `<name>.json`, all optional):
75+
76+
- `matchingThreshold` (default `0.1`) — per-pixel OKLab HyAB color threshold, 0..1 where
77+
black↔white = 1.0; smaller is more sensitive. (Named to avoid clashing with `threshold`,
78+
which is the *failure* threshold everywhere in karate-image.)
79+
- `includeAA` (default `false`) — count anti-aliased pixels as differences.
80+
- `checkerboard` (default `true`) — blend semi-transparent pixels against a checkerboard
81+
pattern (vs plain white) when comparing.
82+
- `ignoredBoxes` — the same `[{left, right, top, bottom}, …]` boxes (all bounds inclusive)
83+
as the resemble/ssim engines; pixels inside are excluded from the comparison entirely,
84+
including the cluster verdict's raw and significant counts. Use for dynamic regions
85+
(transaction ids, timestamps).
86+
87+
The pixelmatch engine is **count-only**: the diff image in reports is always produced by
88+
resemble (that is what the HTML lightbox and its live re-diff render), so pixelmatch's own
89+
diff-rendering options are not exposed. The pixelmatch percentages ride on the `diff()`
90+
result and the embed `meta` (`pixelmatchMismatchPercentage`, plus the raw/summary/region
91+
diagnostics when clusters are enabled).
92+
93+
### Cluster verdict (`clusters`)
94+
95+
Pixelmatch-only, **off by default**: a significance layer that separates rendering noise
96+
(anti-aliasing halos, sub-pixel shifts, font hinting changes between browser versions) from
97+
real regressions. Enable with `clusters: true` (defaults) or a tuning map:
98+
99+
```js
100+
image.engine = 'pixelmatch';
101+
image.clusters = true; // suite-wide; or per-call / per-name in <name>.json:
102+
// { clusters: { coreRadius: 1, minCoreArea: 16, hardDelta: 2.5,
103+
// flatness: 0.5, flatFraction: 0.5, minThinArea: 8 } }
104+
```
105+
106+
With clusters on, the reported `mismatchPercentage` becomes the **significant-diff
107+
percentage** — pixels in regions classified as real changes (interior mass surviving
108+
erosion, or thin-but-vivid changes on a flat baseline) — and your existing failure
109+
`threshold` applies to that. Pure rendering noise reports ~0.0% and passes; a missing
110+
button reports its true area share. Because noise no longer inflates the number, you can
111+
tighten `threshold` aggressively (0.01% becomes practical). The raw percentage stays
112+
visible as `pixelmatchRawMismatchPercentage`, a human-readable `pixelmatchSummary`, and
113+
per-region boxes in `pixelmatchRegions` (`{x, y, width, height, area, coreArea, meanDelta,
114+
reason: 'CORE'|'THIN_VIVID'}`) — in both the `diff()` result and the embed `meta`.
115+
116+
| Param | Default | Controls | Raising it | Lowering it |
117+
|---|---|---|---|---|
118+
| `coreRadius` | 1 | Max thickness (2r+1 px) treated as potentially noise | Thicker real changes must rely on the safety net; risk of missing small solid changes rises. Use 2 for 2x/retina captures. | (min 1) Thinner noise survives erosion and is counted as significant; false positives return. |
119+
| `minCoreArea` | 16 | Smallest interior "core" that counts as significant | Small solid changes (icons, single glyphs) may be ignored → false negatives. | Residual noise that survives erosion gets counted → false positives. |
120+
| `hardDelta` | 2.5 | How vivid a thin change must be for the safety net (× `matchingThreshold`) | Real thin changes (underlines, dividers, recolored text) missed → false negatives. | Strong-ish rendering noise gets rescued as significant → false positives. |
121+
| `flatness` | 0.5 | How flat the baseline must be under a thin change (× `matchingThreshold`) | More of the baseline qualifies as "flat" → safety net fires more often. | Stricter flatness → thin changes near existing detail are ignored. |
122+
| `flatFraction` | 0.5 | Share of a thin cluster's pixels that must sit on flat baseline | Thin changes overlapping existing edges (e.g. text recolor) missed. | Noise straddling a flat area may be counted. |
123+
| `minThinArea` | 8 | Smallest thin cluster the safety net will consider | Small thin marks (a text caret is ~2x16 px) ignored → false negatives. | Isolated vivid speckles (dead pixels, dithering) counted → false positives. |
124+
125+
Notes: `clusters` cannot be combined with ssim's `windowSize` option on the same call when
126+
both engines run (pixelmatch ignores `windowSize`; it belongs to ssim). The lightbox does
127+
not yet draw the significant-region boxes on the diff — the coordinates ride on the embed
128+
`meta` (`pixelmatchRegions`) for when it does.
129+
69130
A runnable, readable walkthrough lives in
70131
[`src/test/resources/demo/visual-demo.feature`](src/test/resources/demo/visual-demo.feature)
71132
(establish → match → catch regression → rebase → per-name options); `VisualDemoTest` asserts

karate-image/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@
3939
<artifactId>ssim</artifactId>
4040
<version>1.0.0</version>
4141
</dependency>
42+
<dependency>
43+
<groupId>io.github.t12y</groupId>
44+
<artifactId>pixelmatch</artifactId>
45+
<version>1.0.0</version>
46+
</dependency>
4247
</dependencies>
4348

4449
<profiles>

karate-image/src/main/java/io/karatelabs/ext/image/ImageApi.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,18 @@ private Map<String, Object> result(Map<String, Object> r, String name, Map<Strin
179179
if (r.containsKey(ImageComparison.SSIM_MISMATCH_PERCENT)) {
180180
out.put("ssimMismatchPercentage", num(r.get(ImageComparison.SSIM_MISMATCH_PERCENT)));
181181
}
182+
if (r.containsKey(ImageComparison.PIXELMATCH_MISMATCH_PERCENT)) {
183+
out.put("pixelmatchMismatchPercentage", num(r.get(ImageComparison.PIXELMATCH_MISMATCH_PERCENT)));
184+
}
185+
if (r.containsKey(ImageComparison.PIXELMATCH_RAW_MISMATCH_PERCENT)) {
186+
out.put("pixelmatchRawMismatchPercentage", num(r.get(ImageComparison.PIXELMATCH_RAW_MISMATCH_PERCENT)));
187+
}
188+
if (r.containsKey(ImageComparison.PIXELMATCH_SUMMARY)) {
189+
out.put("pixelmatchSummary", str(r.get(ImageComparison.PIXELMATCH_SUMMARY)));
190+
}
191+
if (r.containsKey(ImageComparison.PIXELMATCH_REGIONS)) {
192+
out.put("pixelmatchRegions", r.get(ImageComparison.PIXELMATCH_REGIONS));
193+
}
182194
out.put("mismatch", mismatch);
183195
out.put("scaleMismatch", scaleMismatch);
184196
out.put("threshold", threshold);
@@ -237,6 +249,19 @@ private Map<String, Object> embed(Map<String, Object> r, String name, Map<String
237249
if (r.containsKey(ImageComparison.SSIM_MISMATCH_PERCENT)) {
238250
meta.put("ssimMismatchPercentage", num(r.get(ImageComparison.SSIM_MISMATCH_PERCENT)));
239251
}
252+
if (r.containsKey(ImageComparison.PIXELMATCH_MISMATCH_PERCENT)) {
253+
meta.put("pixelmatchMismatchPercentage", num(r.get(ImageComparison.PIXELMATCH_MISMATCH_PERCENT)));
254+
}
255+
if (r.containsKey(ImageComparison.PIXELMATCH_RAW_MISMATCH_PERCENT)) {
256+
meta.put("pixelmatchRawMismatchPercentage", num(r.get(ImageComparison.PIXELMATCH_RAW_MISMATCH_PERCENT)));
257+
}
258+
if (r.containsKey(ImageComparison.PIXELMATCH_SUMMARY)) {
259+
meta.put("pixelmatchSummary", str(r.get(ImageComparison.PIXELMATCH_SUMMARY)));
260+
}
261+
// significant-region bounding boxes; TODO draw them on the diff in the lightbox
262+
if (r.containsKey(ImageComparison.PIXELMATCH_REGIONS)) {
263+
meta.put("pixelmatchRegions", r.get(ImageComparison.PIXELMATCH_REGIONS));
264+
}
240265
meta.put("threshold", num(r.get("failureThreshold")));
241266
meta.put("defaultThreshold", num(r.get("defaultFailureThreshold")));
242267
if (r.get("engine") != null) {
@@ -376,6 +401,9 @@ private Map<String, Object> defaultOptions() {
376401
if (config.containsKey("allowScaling")) {
377402
d.put("allowScaling", config.get("allowScaling"));
378403
}
404+
if (config.containsKey("clusters")) {
405+
d.put("clusters", config.get("clusters"));
406+
}
379407
return d;
380408
}
381409

karate-image/src/main/java/io/karatelabs/ext/image/ImageComparison.java

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
*/
2424
package io.karatelabs.ext.image;
2525

26+
import io.github.t12y.pixelmatch.ClusterAnalysis;
27+
import io.github.t12y.pixelmatch.ClusterOptions;
28+
import io.github.t12y.pixelmatch.Pixelmatch;
2629
import io.github.t12y.resemble.ErrorType;
2730
import io.github.t12y.resemble.Resemble;
2831
import io.github.t12y.resemble.Result;
@@ -43,9 +46,9 @@
4346

4447
/**
4548
* Server-side pixel-diff engine for the image-comparison ext. Reads a baseline +
46-
* latest PNG/JPEG, runs the resemble and/or ssim engine over the unpacked RGBA
47-
* pixels, and returns a result map carrying the mismatch percentage and (when a
48-
* report is requested) the baseline / latest / diff {@link BufferedImage}s.
49+
* latest PNG/JPEG, runs the resemble, ssim and/or pixelmatch engine over the
50+
* unpacked RGBA pixels, and returns a result map carrying the mismatch percentage
51+
* and (when a report is requested) the baseline / latest / diff {@link BufferedImage}s.
4952
* <p>
5053
* The pixel-comparison logic was contributed by jkeys089 (github.com/jkeys089),
5154
* who also authors the underlying {@code io.github.t12y:resemble} / {@code :ssim}
@@ -56,11 +59,18 @@ public class ImageComparison {
5659

5760
public static final String RESEMBLE = "resemble";
5861
public static final String SSIM = "ssim";
62+
public static final String PIXELMATCH = "pixelmatch";
5963
public static final String BASELINE_IMAGE = "baselineImage";
6064
public static final String LATEST_IMAGE = "latestImage";
6165
public static final String DIFF_IMAGE = "diffImage";
6266
public static final String RESEMBLE_MISMATCH_PERCENT = "resembleMismatchPercentage";
6367
public static final String SSIM_MISMATCH_PERCENT = "ssimMismatchPercentage";
68+
public static final String PIXELMATCH_MISMATCH_PERCENT = "pixelmatchMismatchPercentage";
69+
// present only when the cluster verdict is enabled: the raw (unfiltered) percentage
70+
// stays visible as a diagnostic alongside the significant one that drives pass/fail
71+
public static final String PIXELMATCH_RAW_MISMATCH_PERCENT = "pixelmatchRawMismatchPercentage";
72+
public static final String PIXELMATCH_SUMMARY = "pixelmatchSummary";
73+
public static final String PIXELMATCH_REGIONS = "pixelmatchRegions";
6474

6575
private static final String[] IGNORED_BOX_KEYS = new String[]{"left", "right", "top", "bottom"};
6676
private static final String[] IGNORED_COLOR_KEYS = new String[]{"r", "g", "b"};
@@ -79,6 +89,7 @@ public class ImageComparison {
7989
private boolean includeDiffImageOnSuccess;
8090
private boolean includeDiffImageOnFailure;
8191
private String[] engines;
92+
private Object clustersConfig;
8293
private final Map<String, Object> options;
8394
private final Map<String, Object> result;
8495

@@ -172,6 +183,9 @@ private void configure(Map<String, Object> defaultOptions) {
172183
engines = engineConfig.split(",");
173184
}
174185

186+
// per-call/name.json options win over the suite-wide default (same precedence as engine)
187+
clustersConfig = options.containsKey("clusters") ? options.get("clusters") : defaultOptions.get("clusters");
188+
175189
String reportFormat = asString(defaultOptions.get("report"));
176190
if ("all".equalsIgnoreCase(reportFormat)) {
177191
includeDiffImageOnSuccess = true;
@@ -209,6 +223,9 @@ public static Map<String, Object> run(byte[] baselineImg, byte[] latestImg, Map<
209223
case SSIM:
210224
currentMismatchPercentage = imageComparison.execSSIM();
211225
break;
226+
case PIXELMATCH:
227+
currentMismatchPercentage = imageComparison.execPixelmatch();
228+
break;
212229
default:
213230
logger.error("skipping unsupported image comparison engine: {}", engine);
214231
continue;
@@ -272,6 +289,78 @@ private double execSSIM() {
272289
return mismatchPercentage;
273290
}
274291

292+
private double execPixelmatch() {
293+
io.github.t12y.pixelmatch.Options opts = pixelmatchOptions();
294+
295+
// pixelmatch is count-only: the diff image is always resemble's, which is what the
296+
// HTML report lightbox (and its client-side live re-diff) renders. checkMismatch
297+
// runs the resemble engine whenever a diff image is needed and none exists yet.
298+
double mismatchPercentage;
299+
if (opts.clusters != null) {
300+
// cluster verdict: the significant-diff percentage drives pass/fail; rendering
301+
// noise (AA halos, sub-pixel shifts) reports as ~0. Raw numbers stay visible.
302+
ClusterAnalysis analysis = Pixelmatch.analyze(baselinePixels, latestPixels, null, width, height, opts);
303+
mismatchPercentage = analysis.significantPercentage;
304+
result.put(PIXELMATCH_RAW_MISMATCH_PERCENT, analysis.rawPercentage);
305+
result.put(PIXELMATCH_SUMMARY, analysis.summary());
306+
result.put(PIXELMATCH_REGIONS, regionMaps(analysis));
307+
} else {
308+
int mismatched = Pixelmatch.pixelmatch(baselinePixels, latestPixels, null, width, height, opts);
309+
mismatchPercentage = 100.0 * mismatched / (width * height);
310+
}
311+
312+
result.put(PIXELMATCH_MISMATCH_PERCENT, mismatchPercentage);
313+
return mismatchPercentage;
314+
}
315+
316+
private static List<Map<String, Object>> regionMaps(ClusterAnalysis analysis) {
317+
List<Map<String, Object>> regions = new ArrayList<>();
318+
for (ClusterAnalysis.Region region : analysis.significantRegions) {
319+
Map<String, Object> m = new HashMap<>();
320+
m.put("x", region.x);
321+
m.put("y", region.y);
322+
m.put("width", region.width);
323+
m.put("height", region.height);
324+
m.put("area", region.area);
325+
m.put("coreArea", region.coreArea);
326+
m.put("meanDelta", region.meanDelta);
327+
m.put("reason", region.reason.name());
328+
regions.add(m);
329+
}
330+
return regions;
331+
}
332+
333+
private io.github.t12y.pixelmatch.Options pixelmatchOptions() {
334+
io.github.t12y.pixelmatch.Options opts = io.github.t12y.pixelmatch.Options.defaults();
335+
336+
// 'threshold' already means the failure threshold throughout karate-image,
337+
// so the per-pixel OKLab HyAB matching threshold gets its own key.
338+
// (pixelmatch's diff-rendering options are not exposed: it never draws here)
339+
opts.threshold = getDouble("matchingThreshold", opts.threshold);
340+
opts.includeAA = getBool("includeAA", opts.includeAA);
341+
opts.checkerboard = getBool("checkerboard", opts.checkerboard);
342+
opts.ignoredBoxes = getIgnoredBoxes();
343+
opts.clusters = clusterOptions();
344+
345+
return opts;
346+
}
347+
348+
/** {@code clusters: true} enables the verdict with defaults; a map tunes it; absent/false/null disables. */
349+
private ClusterOptions clusterOptions() {
350+
if (clustersConfig instanceof Map) {
351+
Map cfg = (Map) clustersConfig;
352+
ClusterOptions clusters = ClusterOptions.defaults();
353+
clusters.coreRadius = toInt(cfg.get("coreRadius"), clusters.coreRadius);
354+
clusters.minCoreArea = toInt(cfg.get("minCoreArea"), clusters.minCoreArea);
355+
clusters.hardDelta = toDouble(cfg.get("hardDelta"), clusters.hardDelta);
356+
clusters.flatness = toDouble(cfg.get("flatness"), clusters.flatness);
357+
clusters.flatFraction = toDouble(cfg.get("flatFraction"), clusters.flatFraction);
358+
clusters.minThinArea = toInt(cfg.get("minThinArea"), clusters.minThinArea);
359+
return clusters;
360+
}
361+
return toBool(clustersConfig) ? ClusterOptions.defaults() : null;
362+
}
363+
275364
private io.github.t12y.resemble.Options resembleOptions() {
276365
io.github.t12y.resemble.Options opts;
277366

@@ -373,11 +462,14 @@ private static double toDouble(Object obj, double defaultValue) {
373462
}
374463

375464
private int getInt(String name, int defaultValue) {
376-
Object val = options.get(name);
377-
if (!(val instanceof Number)) {
465+
return toInt(options.get(name), defaultValue);
466+
}
467+
468+
private static int toInt(Object obj, int defaultValue) {
469+
if (!(obj instanceof Number)) {
378470
return defaultValue;
379471
}
380-
return ((Number) val).intValue();
472+
return ((Number) obj).intValue();
381473
}
382474

383475
private String getString(String name, String defaultValue) {

karate-image/src/test/java/io/karatelabs/ext/image/ImageComparisonTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ private static double round(double d) {
6969
}
7070

7171
@ParameterizedTest
72-
@ValueSource(strings = {"resemble", "ssim"})
72+
@ValueSource(strings = {"resemble", "ssim", "pixelmatch"})
7373
void testIgnoredBoxes(String engine) {
7474
Map<String, Integer> box = new HashMap<>();
7575
box.put("left", 1);

karate-image/src/test/java/io/karatelabs/ext/image/ImageExtE2ETest.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,55 @@ class ImageExtE2ETest {
5353
@TempDir
5454
Path tempDir;
5555

56+
@Test
57+
void reportModeGatesEmbedsByVerdict() throws Exception {
58+
Files.writeString(tempDir.resolve("karate-boot.js"), """
59+
const image = boot.ext('image');
60+
image.report = 'mismatched';
61+
image.threshold = 0.1;
62+
// no ssim: its default 11x11 window degenerates to ~0% difference on the
63+
// tiny 3x3 fixtures here, short-circuiting the fallback chain
64+
image.engine = 'resemble|pixelmatch';
65+
image.clusters = true;
66+
""");
67+
68+
Path feature = tempDir.resolve("report-mode.feature");
69+
Files.writeString(feature, """
70+
Feature: report mode gates embeds
71+
72+
Scenario: mismatched report omits passing embeds entirely
73+
* def blue = new Uint8Array(java.util.Base64.getDecoder().decode('%s'))
74+
* def green = new Uint8Array(java.util.Base64.getDecoder().decode('%s'))
75+
76+
# passing comparison: no embed, hence nothing for the HTML/PDF reports
77+
* def passing = image.diff({ baseline: blue, latest: blue })
78+
* match passing.pass == true
79+
* match passing.embed == null
80+
81+
# failing comparison (clusters off per-call: a single changed pixel is below
82+
# minThinArea, so the suite-wide verdict would classify it as noise and pass)
83+
* def failing = image.diff({ baseline: blue, latest: green, clusters: false })
84+
* match failing.pass == false
85+
* match failing.embed == '#notnull'
86+
87+
# report=all embeds passing comparisons too, diff image included
88+
* image.report = 'all'
89+
* def passingAll = image.diff({ baseline: blue, latest: blue })
90+
* match passingAll.pass == true
91+
* match passingAll.embed == '#notnull'
92+
* match passingAll.embed.parts == '#[3]'
93+
""".formatted(BLUE, GREEN));
94+
95+
SuiteResult result = Runner.path(feature.toString())
96+
.workingDir(tempDir)
97+
.outputDir(tempDir.resolve("reports"))
98+
.outputConsoleSummary(false)
99+
.parallel(1);
100+
101+
assertEquals(1, result.getScenarioPassedCount(), "report-mode scenario should pass");
102+
assertEquals(0, result.getScenarioFailedCount());
103+
}
104+
56105
@Test
57106
void establishMatchAndMismatch() throws Exception {
58107
Path baselineDir = tempDir.resolve("baselines");

0 commit comments

Comments
 (0)