Skip to content

Commit 0906c29

Browse files
authored
Merge pull request #7552 from Vaivaswat2244/snapshot-test
Tried an approch for text case in pixelmatch
2 parents 0842096 + dc4ed3f commit 0906c29

File tree

3 files changed

+255
-32
lines changed

3 files changed

+255
-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: 234 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
@@ -86,57 +87,258 @@ export function visualSuite(
8687
});
8788
}
8889

90+
/**
91+
* Image Diff Algorithm for p5.js Visual Tests
92+
*
93+
* This algorithm addresses the challenge of cross-platform rendering differences in p5.js visual tests.
94+
* Different operating systems and browsers render graphics with subtle variations, particularly with
95+
* anti-aliasing, text rendering, and sub-pixel positioning. This can cause false negatives in tests
96+
* when the visual differences are acceptable rendering variations rather than actual bugs.
97+
*
98+
* Key components of the approach:
99+
*
100+
* 1. Initial pixel-by-pixel comparison:
101+
* - Uses pixelmatch to identify differences between expected and actual images
102+
* - Sets a moderate threshold (0.5) to filter out minor color/intensity variations
103+
* - Produces a diff image with red pixels marking differences
104+
*
105+
* 2. Cluster identification using BFS (Breadth-First Search):
106+
* - Groups connected difference pixels into clusters
107+
* - Uses a queue-based BFS algorithm to find all connected pixels
108+
* - Defines connectivity based on 8-way adjacency (all surrounding pixels)
109+
*
110+
* 3. Cluster categorization by type:
111+
* - Analyzes each pixel's neighborhood characteristics
112+
* - Specifically identifies "line shift" clusters - differences that likely represent
113+
* the same visual elements shifted by 1px due to platform rendering differences
114+
* - Line shifts are identified when >80% of pixels in a cluster have ≤2 neighboring diff pixels
115+
*
116+
* 4. Intelligent failure criteria:
117+
* - Filters out clusters smaller than MIN_CLUSTER_SIZE pixels (noise reduction)
118+
* - Applies different thresholds for regular differences vs. line shifts
119+
* - Considers both the total number of significant pixels and number of distinct clusters
120+
*
121+
* This approach balances the need to catch genuine visual bugs (like changes to shape geometry,
122+
* colors, or positioning) while tolerating acceptable cross-platform rendering variations.
123+
*
124+
* Parameters:
125+
* - MIN_CLUSTER_SIZE: Minimum size for a cluster to be considered significant (default: 4)
126+
* - MAX_TOTAL_DIFF_PIXELS: Maximum allowed non-line-shift difference pixels (default: 40)
127+
* Note: These can be adjusted for further updation
128+
*
129+
* Note for contributors: When running tests locally, you may not see these differences as they
130+
* mainly appear when tests run on different operating systems or browser rendering engines.
131+
* However, the same code may produce slightly different renderings on CI environments, particularly
132+
* with text positioning, thin lines, or curved shapes. This algorithm helps distinguish between
133+
* these acceptable variations and actual visual bugs.
134+
*/
135+
89136
export async function checkMatch(actual, expected, p5) {
90137
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
94138
const ratio = expected.width / expected.height;
95139
const narrow = ratio !== 1;
96140
if (narrow) {
97141
scale *= 2;
98142
}
99-
143+
100144
for (const img of [actual, expected]) {
101145
img.resize(
102146
Math.ceil(img.width * scale),
103147
Math.ceil(img.height * scale)
104148
);
105149
}
106150

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);
151+
// Ensure both images have the same dimensions
152+
const width = expected.width;
153+
const height = expected.height;
154+
155+
// Create canvases with background color
156+
const actualCanvas = p5.createGraphics(width, height);
157+
const expectedCanvas = p5.createGraphics(width, height);
158+
actualCanvas.pixelDensity(1);
159+
expectedCanvas.pixelDensity(1);
160+
161+
actualCanvas.background(BG);
162+
expectedCanvas.background(BG);
163+
164+
actualCanvas.image(actual, 0, 0);
165+
expectedCanvas.image(expected, 0, 0);
166+
167+
// Load pixel data
168+
actualCanvas.loadPixels();
169+
expectedCanvas.loadPixels();
170+
171+
// Create diff output canvas
172+
const diffCanvas = p5.createGraphics(width, height);
173+
diffCanvas.pixelDensity(1);
174+
diffCanvas.loadPixels();
175+
176+
// Run pixelmatch
177+
const diffCount = pixelmatch(
178+
actualCanvas.pixels,
179+
expectedCanvas.pixels,
180+
diffCanvas.pixels,
181+
width,
182+
height,
183+
{
184+
threshold: 0.5,
185+
includeAA: false,
186+
alpha: 0.1
187+
}
188+
);
189+
190+
// If no differences, return early
191+
if (diffCount === 0) {
192+
actualCanvas.remove();
193+
expectedCanvas.remove();
194+
diffCanvas.updatePixels();
195+
return { ok: true, diff: diffCanvas };
120196
}
121-
const diff = cnv.get();
122-
cnv.remove();
123-
diff.loadPixels();
124-
expectedWithBg.remove();
125-
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]
197+
198+
// Post-process to identify and filter out isolated differences
199+
const visited = new Set();
200+
const clusterSizes = [];
201+
202+
for (let y = 0; y < height; y++) {
203+
for (let x = 0; x < width; x++) {
204+
const pos = (y * width + x) * 4;
205+
206+
// If this is a diff pixel (red in pixelmatch output) and not yet visited
207+
if (
208+
diffCanvas.pixels[pos] === 255 &&
209+
diffCanvas.pixels[pos + 1] === 0 &&
210+
diffCanvas.pixels[pos + 2] === 0 &&
211+
!visited.has(pos)
212+
) {
213+
// Find the connected cluster size using BFS
214+
const clusterSize = findClusterSize(diffCanvas.pixels, x, y, width, height, 1, visited);
215+
clusterSizes.push(clusterSize);
216+
}
131217
}
132-
diffSum /= 3;
133-
if (diffSum > COLOR_THRESHOLD) {
134-
ok = false;
135-
break;
218+
}
219+
220+
// Define significance thresholds
221+
const MIN_CLUSTER_SIZE = 4; // Minimum pixels in a significant cluster
222+
const MAX_TOTAL_DIFF_PIXELS = 40; // Maximum total different pixels
223+
224+
// Determine if the differences are significant
225+
const nonLineShiftClusters = clusterSizes.filter(c => !c.isLineShift && c.size >= MIN_CLUSTER_SIZE);
226+
227+
// Calculate significant differences excluding line shifts
228+
const significantDiffPixels = nonLineShiftClusters.reduce((sum, c) => sum + c.size, 0);
229+
230+
// Update the diff canvas
231+
diffCanvas.updatePixels();
232+
233+
// Clean up canvases
234+
actualCanvas.remove();
235+
expectedCanvas.remove();
236+
237+
// Determine test result
238+
const ok = (
239+
diffCount === 0 ||
240+
(
241+
significantDiffPixels === 0 ||
242+
(
243+
(significantDiffPixels <= MAX_TOTAL_DIFF_PIXELS) &&
244+
(nonLineShiftClusters.length <= 2) // Not too many significant clusters
245+
)
246+
)
247+
);
248+
249+
return {
250+
ok,
251+
diff: diffCanvas,
252+
details: {
253+
totalDiffPixels: diffCount,
254+
significantDiffPixels,
255+
clusters: clusterSizes
256+
}
257+
};
258+
}
259+
260+
/**
261+
* Find the size of a connected cluster of diff pixels using BFS
262+
*/
263+
function findClusterSize(pixels, startX, startY, width, height, radius, visited) {
264+
const queue = [{x: startX, y: startY}];
265+
let size = 0;
266+
const clusterPixels = [];
267+
268+
while (queue.length > 0) {
269+
const {x, y} = queue.shift();
270+
const pos = (y * width + x) * 4;
271+
272+
// Skip if already visited
273+
if (visited.has(pos)) continue;
274+
275+
// Skip if not a diff pixel
276+
if (pixels[pos] !== 255 || pixels[pos + 1] !== 0 || pixels[pos + 2] !== 0) continue;
277+
278+
// Mark as visited
279+
visited.add(pos);
280+
size++;
281+
clusterPixels.push({x, y});
282+
283+
// Add neighbors to queue
284+
for (let dy = -radius; dy <= radius; dy++) {
285+
for (let dx = -radius; dx <= radius; dx++) {
286+
const nx = x + dx;
287+
const ny = y + dy;
288+
289+
// Skip if out of bounds
290+
if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
291+
292+
// Skip if already visited
293+
const npos = (ny * width + nx) * 4;
294+
if (!visited.has(npos)) {
295+
queue.push({x: nx, y: ny});
296+
}
297+
}
298+
}
299+
}
300+
301+
let isLineShift = false;
302+
if (clusterPixels.length > 0) {
303+
// Count pixels with limited neighbors (line-like characteristic)
304+
let linelikePixels = 0;
305+
306+
for (const {x, y} of clusterPixels) {
307+
// Count neighbors
308+
let neighbors = 0;
309+
for (let dy = -1; dy <= 1; dy++) {
310+
for (let dx = -1; dx <= 1; dx++) {
311+
if (dx === 0 && dy === 0) continue; // Skip self
312+
313+
const nx = x + dx;
314+
const ny = y + dy;
315+
316+
// Skip if out of bounds
317+
if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
318+
319+
const npos = (ny * width + nx) * 4;
320+
// Check if neighbor is a diff pixel
321+
if (pixels[npos] === 255 && pixels[npos + 1] === 0 && pixels[npos + 2] === 0) {
322+
neighbors++;
323+
}
324+
}
325+
}
326+
327+
// Line-like pixels typically have 1-2 neighbors
328+
if (neighbors <= 2) {
329+
linelikePixels++;
330+
}
136331
}
332+
333+
// If most pixels (>80%) in the cluster have ≤2 neighbors, it's likely a line shift
334+
isLineShift = linelikePixels / clusterPixels.length > 0.8;
137335
}
138336

139-
return { ok, diff };
337+
return {
338+
size,
339+
pixels: clusterPixels,
340+
isLineShift
341+
};
140342
}
141343

142344
/**

0 commit comments

Comments
 (0)