Skip to content

Commit 6e793f2

Browse files
committed
tried pixelmatch with some customizations
1 parent 7cdd321 commit 6e793f2

File tree

3 files changed

+166
-32
lines changed

3 files changed

+166
-32
lines changed

package-lock.json

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"libtess": "^1.2.2",
3636
"omggif": "^1.0.10",
3737
"pako": "^2.1.0",
38+
"pixelmatch": "^7.1.0",
3839
"zod": "^3.23.8"
3940
},
4041
"devDependencies": {

test/unit/visual/visualTest.js

Lines changed: 145 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import p5 from '../../../src/app.js';
22
import { server } from '@vitest/browser/context'
33
import { THRESHOLD, DIFFERENCE, ERODE } from '../../../src/core/constants.js';
44
const { readFile, writeFile } = server.commands
5+
import pixelmatch from 'pixelmatch';
56

67
// By how much can each color channel value (0-255) differ before
78
// we call it a mismatch? This should be large enough to not trigger
@@ -88,55 +89,167 @@ export function visualSuite(
8889

8990
export async function checkMatch(actual, expected, p5) {
9091
let scale = Math.min(MAX_SIDE/expected.width, MAX_SIDE/expected.height);
91-
92-
// Long screenshots end up super tiny when fit to a small square, so we
93-
// can double the max side length for these
9492
const ratio = expected.width / expected.height;
9593
const narrow = ratio !== 1;
9694
if (narrow) {
9795
scale *= 2;
9896
}
99-
97+
10098
for (const img of [actual, expected]) {
10199
img.resize(
102100
Math.ceil(img.width * scale),
103101
Math.ceil(img.height * scale)
104102
);
105103
}
106104

107-
const expectedWithBg = p5.createGraphics(expected.width, expected.height);
108-
expectedWithBg.pixelDensity(1);
109-
expectedWithBg.background(BG);
110-
expectedWithBg.image(expected, 0, 0);
111-
112-
const cnv = p5.createGraphics(actual.width, actual.height);
113-
cnv.pixelDensity(1);
114-
cnv.background(BG);
115-
cnv.image(actual, 0, 0);
116-
cnv.blendMode(DIFFERENCE);
117-
cnv.image(expectedWithBg, 0, 0);
118-
for (let i = 0; i < shiftThreshold; i++) {
119-
cnv.filter(ERODE, false);
105+
// Ensure both images have the same dimensions
106+
const width = expected.width;
107+
const height = expected.height;
108+
109+
// Create canvases with background color
110+
const actualCanvas = p5.createGraphics(width, height);
111+
const expectedCanvas = p5.createGraphics(width, height);
112+
actualCanvas.pixelDensity(1);
113+
expectedCanvas.pixelDensity(1);
114+
115+
actualCanvas.background(BG);
116+
expectedCanvas.background(BG);
117+
118+
actualCanvas.image(actual, 0, 0);
119+
expectedCanvas.image(expected, 0, 0);
120+
121+
// Load pixel data
122+
actualCanvas.loadPixels();
123+
expectedCanvas.loadPixels();
124+
125+
// Create diff output canvas
126+
const diffCanvas = p5.createGraphics(width, height);
127+
diffCanvas.pixelDensity(1);
128+
diffCanvas.loadPixels();
129+
130+
// Run pixelmatch
131+
const diffCount = pixelmatch(
132+
actualCanvas.pixels,
133+
expectedCanvas.pixels,
134+
diffCanvas.pixels,
135+
width,
136+
height,
137+
{
138+
threshold: 0.3,
139+
includeAA: false,
140+
alpha: 0.1
141+
}
142+
);
143+
144+
// If no differences, return early
145+
if (diffCount === 0) {
146+
actualCanvas.remove();
147+
expectedCanvas.remove();
148+
diffCanvas.updatePixels();
149+
return { ok: true, diff: diffCanvas };
120150
}
121-
const diff = cnv.get();
122-
cnv.remove();
123-
diff.loadPixels();
124-
expectedWithBg.remove();
151+
152+
// Post-process to identify and filter out isolated differences
153+
const visited = new Set();
154+
const clusterSizes = [];
155+
156+
for (let y = 0; y < height; y++) {
157+
for (let x = 0; x < width; x++) {
158+
const pos = (y * width + x) * 4;
159+
160+
// If this is a diff pixel (red in pixelmatch output) and not yet visited
161+
if (
162+
diffCanvas.pixels[pos] === 255 &&
163+
diffCanvas.pixels[pos + 1] === 0 &&
164+
diffCanvas.pixels[pos + 2] === 0 &&
165+
!visited.has(pos)
166+
) {
167+
// Find the connected cluster size using BFS
168+
const clusterSize = findClusterSize(diffCanvas.pixels, x, y, width, height, 1, visited);
169+
clusterSizes.push(clusterSize);
170+
}
171+
}
172+
}
173+
174+
// Define significance thresholds
175+
const MIN_CLUSTER_SIZE = 4; // Minimum pixels in a significant cluster
176+
const MAX_TOTAL_DIFF_PIXELS = 40; // Maximum total different pixels
177+
178+
// Determine if the differences are significant
179+
const significantClusters = clusterSizes.filter(size => size >= MIN_CLUSTER_SIZE);
180+
const significantDiffPixels = significantClusters.reduce((sum, size) => sum + size, 0);
125181

126-
let ok = true;
127-
for (let i = 0; i < diff.pixels.length; i += 4) {
128-
let diffSum = 0;
129-
for (let off = 0; off < 3; off++) {
130-
diffSum += diff.pixels[i+off]
182+
// Update the diff canvas
183+
diffCanvas.updatePixels();
184+
185+
// Clean up canvases
186+
actualCanvas.remove();
187+
expectedCanvas.remove();
188+
189+
// Determine test result
190+
const ok = (
191+
diffCount === 0 || // No differences at all
192+
(
193+
significantDiffPixels === 0 || // No significant clusters
194+
(
195+
significantDiffPixels <= MAX_TOTAL_DIFF_PIXELS && // Total different pixels within tolerance
196+
significantClusters.length <= 2 // Not too many significant clusters
197+
)
198+
)
199+
);
200+
201+
return {
202+
ok,
203+
diff: diffCanvas,
204+
details: {
205+
totalDiffPixels: diffCount,
206+
significantDiffPixels,
207+
clusters: clusterSizes,
208+
significantClusters
131209
}
132-
diffSum /= 3;
133-
if (diffSum > COLOR_THRESHOLD) {
134-
ok = false;
135-
break;
210+
};
211+
}
212+
213+
/**
214+
* Find the size of a connected cluster of diff pixels using BFS
215+
*/
216+
function findClusterSize(pixels, startX, startY, width, height, radius, visited) {
217+
const queue = [{x: startX, y: startY}];
218+
let size = 0;
219+
220+
while (queue.length > 0) {
221+
const {x, y} = queue.shift();
222+
const pos = (y * width + x) * 4;
223+
224+
// Skip if already visited
225+
if (visited.has(pos)) continue;
226+
227+
// Skip if not a diff pixel
228+
if (pixels[pos] !== 255 || pixels[pos + 1] !== 0 || pixels[pos + 2] !== 0) continue;
229+
230+
// Mark as visited
231+
visited.add(pos);
232+
size++;
233+
234+
// Add neighbors to queue
235+
for (let dy = -radius; dy <= radius; dy++) {
236+
for (let dx = -radius; dx <= radius; dx++) {
237+
const nx = x + dx;
238+
const ny = y + dy;
239+
240+
// Skip if out of bounds
241+
if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
242+
243+
// Skip if already visited
244+
const npos = (ny * width + nx) * 4;
245+
if (!visited.has(npos)) {
246+
queue.push({x: nx, y: ny});
247+
}
248+
}
136249
}
137250
}
138-
139-
return { ok, diff };
251+
252+
return size;
140253
}
141254

142255
/**

0 commit comments

Comments
 (0)