diff --git a/README.md b/README.md index 7324663..2c4e9cd 100644 --- a/README.md +++ b/README.md @@ -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}); @@ -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: diff --git a/index.js b/index.js index 4b57760..c791ab6 100644 --- a/index.js +++ b/index.js @@ -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 { @@ -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; @@ -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) @@ -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 @@ -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++; } @@ -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 */ diff --git a/test/test.js b/test/test.js index 3e770f9..be3b934 100644 --- a/test/test.js +++ b/test/test.js @@ -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'); });