Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,15 @@
[![Node](https://github.com/mapbox/pixelmatch/actions/workflows/node.yml/badge.svg)](https://github.com/mapbox/pixelmatch/actions/workflows/node.yml)
[![](https://img.shields.io/badge/simply-awesome-brightgreen.svg)](https://github.com/mourner/projects)

The smallest, simplest and fastest JavaScript pixel-level image comparison library,
A small, simple and fast JavaScript pixel-level **image comparison library**,
originally created to compare screenshots in tests.

Features accurate **anti-aliased pixels detection**
and **perceptual color difference metrics**.

Features accurate **anti-aliased pixels detection** and **perceptual color difference** metrics.
Inspired by [Resemble.js](https://github.com/Huddle/Resemble.js)
and [Blink-diff](https://github.com/yahoo/blink-diff).
Unlike these libraries, pixelmatch is around **150 lines of code**,
Unlike these libraries, pixelmatch is just **a few hundred lines of code**,
has **no dependencies**, and works on **raw typed arrays** of image data,
so it's **blazing fast** and can be used in **any environment** (Node or browsers).
so it's **very fast** and can be used in both Node and browsers.

```js
const numDiffPixels = pixelmatch(img1, img2, diff, 800, 600, {threshold: 0.1});
Expand Down Expand Up @@ -52,9 +50,17 @@ Implements ideas from the following papers:
- `diffColor` — The color of differing pixels in the diff output in `[R, G, B]` format. `[255, 0, 0]` by default.
- `diffColorAlt` — An alternative color to use for dark on light differences to differentiate between "added" and "removed" parts. If not provided, all differing pixels use the color specified by `diffColor`. `null` by default.
- `diffMask` — Draw the diff over a transparent background (a mask), rather than over the original image. Will not draw anti-aliased pixels (if detected).
- `checkerboard` — Blend semi-transparent pixels against a checkerboard pattern when comparing (`true`) rather than plain white (`false`), avoiding false matches between colors that only look alike over one background. `true` by default.
- `windowSize` — If set to a finite number `N`, return the maximum number of differing pixels in any `N`×`N` sliding window instead of the total count (see below). `Infinity` by default.

Compares two images, writes the output diff and returns the number of mismatched pixels.

### Windowed diff density

By default the return value is the total number of differing pixels. With `windowSize: N`, it instead becomes the largest number of diff pixels found in any `N`×`N` region (`N` is clamped to the image dimensions, and anti-aliased pixels are never counted).

This makes the result robust to scattered noise. Spread-out speckle (GPU dithering, sub-pixel anti-aliasing) never packs densely into a single window, while a genuine regression does. A test can then fail on density — `result / N² > tau` — which stays comparable across image sizes, so you can run a stricter threshold to catch smaller real changes without tripping over noise. The total count is just the degenerate whole-image window, so the return value is always "max diff pixels in any window".

## Command line

Pixelmatch comes with a binary that works with PNG images:
Expand Down
81 changes: 79 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@
* @param {[number, number, number]} [options.diffColorAlt=options.diffColor] Whether to detect dark on light differences between img1 and img2 and set an alternative color to differentiate between the two.
* @param {boolean} [options.diffMask=false] Draw the diff over a transparent background (a mask).
* @param {boolean} [options.checkerboard=true] Whether to blend semi-transparent pixels against a checkerboard pattern (true) or plain white (false) when comparing.
* @param {number} [options.windowSize=Infinity] If finite, return the maximum number of diff pixels found in any N×N sliding window instead of the total diff count.
*
* @return {number} The number of mismatched pixels.
* @return {number} The number of mismatched pixels (or the maximum per-window count if windowSize is finite).
*/
export default function pixelmatch(img1, img2, output, width, height, options = {}) {
const {
Expand All @@ -26,6 +27,7 @@ export default function pixelmatch(img1, img2, output, width, height, options =
aaColor = [255, 255, 0],
diffColor = [255, 0, 0],
checkerboard = true,
windowSize = Infinity,
includeAA, diffColorAlt, diffMask
} = options;

Expand Down Expand Up @@ -61,6 +63,15 @@ export default function pixelmatch(img1, img2, output, width, height, options =
const [altR, altG, altB] = diffColorAlt || diffColor;
let diff = 0;

// per-pixel diff mask, only allocated when windowSize is finite (keeps the
// default path allocation-free): 0 same/ignored, 1 diff, 2 excluded AA.
// diff pixels are given odd values so the window scan can count them
// branchlessly with `& 1`.
const mask = windowSize !== Infinity ? new Uint8Array(len) : null;
// first/last row containing a counted diff, to bound the windowed post-pass
let firstDiffY = -1;
let lastDiffY = 0;

// compare each pixel of one image against the other one
for (let i = 0, pos = 0; i < len; i++, pos += 4) {
// whether the HyAB OKLab distance exceeds the threshold: 0 if not, ±1 if yes (negative if img2 pixel is darker)
Expand All @@ -76,6 +87,7 @@ export default function pixelmatch(img1, img2, output, width, height, options =
// one of the pixels is anti-aliasing; draw as yellow and do not count as difference
// note that we do not include such pixels in a mask
if (output && !diffMask) drawPixel(output, pos, aaR, aaG, aaB);
if (mask) mask[i] = 2;

} else {
// found substantial difference not caused by anti-aliasing; draw it as such
Expand All @@ -86,6 +98,11 @@ export default function pixelmatch(img1, img2, output, width, height, options =
drawPixel(output, pos, diffR, diffG, diffB);
}
}
if (mask) {
mask[i] = 1;
if (firstDiffY < 0) firstDiffY = y;
lastDiffY = y;
}
diff++;
}

Expand All @@ -96,7 +113,67 @@ export default function pixelmatch(img1, img2, output, width, height, options =
}

// return the number of different pixels
return diff;
if (!mask) return diff;

// windowed mode: return the maximum number of diff pixels (state 1) over all
// N×N sliding windows, N clamped to the image dimensions
const n = Math.max(1, Math.min(windowSize | 0, width, height));

// colSum[x] counts diff pixels in column x over the last n rows; maintained
// incrementally (add entering row, subtract leaving row), which is why the
// full mask has to be kept around. diff pixels are odd (`& 1`), AA/same even.
if (firstDiffY < 0) return 0; // all diffs were excluded as AA

const colSum = new Uint16Array(width);
let maxCount = 0;
// running total of all column sums = diff pixels in the current n-row band;
// an upper bound for any single window, so bands that can't beat maxCount skip
// the horizontal scan entirely (most bands are sparse or empty)
let bandTotal = 0;

// only rows in [firstDiffY, lastDiffY] hold diffs, so colSum is zero before the
// first and drained after a leaving row passes the last — bound the scan to the
// bands that can be nonzero (rows before firstDiffY stay empty, so starting there
// keeps colSum correct without special initialization)
const yEnd = Math.min(height - 1, lastDiffY + n - 1);

for (let y = firstDiffY; y <= yEnd; y++) {
const rowStart = y * width;
const leaving = y - n;
// update column sums: add the entering row, subtract the leaving row
// (both in one pass over the width)
if (leaving >= 0) {
const leavingStart = leaving * width;
for (let x = 0; x < width; x++) {
const d = (mask[rowStart + x] & 1) - (mask[leavingStart + x] & 1);
colSum[x] += d;
bandTotal += d;
}
} else {
for (let x = 0; x < width; x++) {
const e = mask[rowStart + x] & 1;
colSum[x] += e;
bandTotal += e;
}
// only scan windows that are fully inside vertically
if (y < n - 1) continue;
}

// no window in this band can exceed the total diff count it contains
if (bandTotal <= maxCount) continue;

// horizontal running sum over colSum yields every window sum in this band;
// prime the first n-1 columns, then slide with no per-iteration bounds checks
let windowSum = 0;
for (let x = 0; x < n - 1; x++) windowSum += colSum[x];
for (let x = n - 1; x < width; x++) {
windowSum += colSum[x];
if (windowSum > maxCount) maxCount = windowSum;
windowSum -= colSum[x - n + 1];
}
}

return maxCount;
}

/** @param {Uint8Array | Uint8ClampedArray} arr */
Expand Down
38 changes: 38 additions & 0 deletions test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,44 @@ test('checkerboard: false blends semi-transparent pixels against white', () => {
assert.equal(match(img1, img2, null, 1, 1), 1);
});

test('windowSize returns the max diff-pixel count over N×N sliding windows', () => {
const w = 10, h = 10;
const img1 = new Uint8Array(w * h * 4).fill(255);
const img2 = new Uint8Array(w * h * 4).fill(255);
// dense 3×3 block of black diff pixels at (2,2)–(4,4)
for (let y = 2; y < 5; y++) for (let x = 2; x < 5; x++) {
const p = (y * w + x) * 4;
img2[p] = img2[p + 1] = img2[p + 2] = 0;
}
// omitted / Infinity: total diff count
assert.equal(match(img1, img2, null, w, h, {includeAA: true}), 9);
assert.equal(match(img1, img2, null, w, h, {includeAA: true, windowSize: Infinity}), 9);
// 3×3 window captures the whole block
assert.equal(match(img1, img2, null, w, h, {includeAA: true, windowSize: 3}), 9);
// 2×2 window captures at most 4
assert.equal(match(img1, img2, null, w, h, {includeAA: true, windowSize: 2}), 4);
// window larger than the image is clamped, still finds all 9
assert.equal(match(img1, img2, null, w, h, {includeAA: true, windowSize: 100}), 9);
// identical images return 0 in windowed mode
assert.equal(match(img1, img1, null, w, h, {windowSize: 3}), 0);
});

test('windowSize on a real fixture (6a/6b, 256×256, 51 diff pixels)', () => {
const img1 = readImage('6a');
const img2 = readImage('6b');
const {width, height} = img1;
const opts = {threshold: 0.05};
const total = match(img1.data, img2.data, null, width, height, opts);
assert.equal(total, 51); // matches the 6diff fixture test
// a square window at least as large as the image is the degenerate whole-image
// window, so it must equal the total count
assert.equal(match(img1.data, img2.data, null, width, height, {...opts, windowSize: width}), total);
assert.equal(match(img1.data, img2.data, null, width, height, {...opts, windowSize: 100000}), total);
// smaller windows contain a subset of the diffs, but never more than the total
assert.equal(match(img1.data, img2.data, null, width, height, {...opts, windowSize: 32}), 29);
assert.equal(match(img1.data, img2.data, null, width, height, {...opts, windowSize: 8}), 6);
});

test('throws error if image sizes do not match', () => {
assert.throws(() => match(new Uint8Array(8), new Uint8Array(9), null, 2, 1), 'Image sizes do not match');
});
Expand Down
Loading