A Java port of pixelmatch
Notable differences from the JavaScript original (v7.2.0):
- Pixel data is passed as
double[]in RGBA order (4 values per pixel, each an integer 0-255), matching the otherio.github.t12yimage libraries. Options.windowSize <= 0disables windowed mode (the JavaScript default ofInfinity).- There is no "wrong data format" error;
nullimage data throwsIllegalArgumentExceptioninstead. Options.ignoredBoxes(not in upstream pixelmatch) excludes boxes of pixels from the comparison — each{left, right, top, bottom}, all bounds inclusive, the same convention as the otherio.github.t12yimage libraries. Excluded pixels are never counted (raw, windowed, or cluster-verdict) and draw as background in the diff image.
An optional significance layer that distinguishes rendering noise (anti-aliasing halos,
sub-pixel shifts, font hinting differences between browser versions) from real visual
regressions. Disabled by default — with Options.clusters unset (null), behavior is
byte-for-byte identical to plain pixelmatch and no extra code runs.
The idea: rendering noise has no interior; real changes do. The post-anti-aliasing diff
mask is eroded by a small radius, which annihilates anything thinner than 2r+1
pixels in any direction — including the sprawling clusters produced by a global 1px
layout shift. What survives ("cores") is interior mass, which only real changes have.
Two ways a diff region counts as significant:
- CORE — its eroded interior core is at least
minCoreAreapixels. - THIN_VIVID — a legitimately thin change (underline, divider, recolored text) is
rescued by a safety net: its color difference is vivid (
>= hardDelta ×the matching threshold) and it sits on a region that was flat in the baseline. Noise is thin, faint, and rides on pre-existing edges; a red underline on white is thin, vivid, and appears where the baseline had no gradient.
Options options = Options.defaults();
options.clusters = ClusterOptions.defaults();
// full analysis:
ClusterAnalysis analysis = Pixelmatch.analyze(baseline, latest, null, width, height, options);
// or through the plain API: with clusters set, the returned count is the
// significant-diff pixel count instead of the raw diff count
int significant = Pixelmatch.pixelmatch(baseline, latest, null, width, height, options);Reporting semantics: with clusters enabled, the number that drives pass/fail
(the returned count / significantPercentage) covers only pixels in significant
regions; noise clusters contribute zero. A pure rendering-noise comparison reports
~0.0% and passes any threshold, while a missing button reports its true area share.
The raw (unfiltered) numbers stay available in ClusterAnalysis as a diagnostic —
compare both while building trust in the filter. Because noise no longer inflates the
number, you can and should tighten your percent-difference threshold (a strict value
like 0.01% becomes practical once noise reports as 0).
Example ClusterAnalysis.summary() output (from the test suite):
significant: 0.53% (640 px, 2 regions); raw diff: 1.20% (1440 px in 3 clusters, 1 classified as rendering noise)
region 20x20 at (50, 40): interior core area 324 px (>= 16)
region 120x2 at (150, 250): thin high-contrast change, mean delta 6.9x threshold on flat background
And when everything is noise (a 1px divider shift):
significant: 0.00%; raw diff: 2.00% (400 px in 1 cluster, all classified as rendering noise)
All multipliers apply to the matching threshold (Options.threshold, OKLab HyAB units
where black↔white = 1.0). Defaults shown; cannot be combined with windowSize.
| Param | Default | Controls | Raising it | Lowering it |
|---|---|---|---|---|
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. |
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. |
hardDelta |
2.5 | How vivid a thin change must be for the safety net (× threshold) | Real thin changes (underlines, dividers, recolored text) missed → false negatives. | Strong-ish rendering noise gets rescued as significant → false positives. |
flatness |
0.5 | How flat the baseline must be under a thin change (× threshold) | More of the baseline qualifies as "flat" → safety net fires more often. | Stricter flatness → thin changes near existing detail are ignored. |
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. |
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. |
Note: a cluster whose cores are all smaller than minCoreArea is neither CORE nor
eligible for the safety net — it demonstrably has some interior, just not enough.
That's borderline noise by design.
<dependency>
<groupId>io.github.t12y</groupId>
<artifactId>pixelmatch</artifactId>
<version>1.0.0</version>
</dependency>compile group: 'io.github.t12y', name: 'pixelmatch', version: '1.0.0'