@@ -2,6 +2,7 @@ import p5 from '../../../src/app.js';
22import { server } from '@vitest/browser/context'
33import { THRESHOLD , DIFFERENCE , ERODE } from '../../../src/core/constants.js' ;
44const { 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+
89136export 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