@@ -2,6 +2,7 @@ import p5 from '../../../src/app.js';
2
2
import { server } from '@vitest/browser/context'
3
3
import { THRESHOLD , DIFFERENCE , ERODE } from '../../../src/core/constants.js' ;
4
4
const { readFile, writeFile } = server . commands
5
+ import pixelmatch from 'pixelmatch' ;
5
6
6
7
// By how much can each color channel value (0-255) differ before
7
8
// we call it a mismatch? This should be large enough to not trigger
@@ -86,57 +87,258 @@ export function visualSuite(
86
87
} ) ;
87
88
}
88
89
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
+
89
136
export async function checkMatch ( actual , expected , p5 ) {
90
137
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
94
138
const ratio = expected . width / expected . height ;
95
139
const narrow = ratio !== 1 ;
96
140
if ( narrow ) {
97
141
scale *= 2 ;
98
142
}
99
-
143
+
100
144
for ( const img of [ actual , expected ] ) {
101
145
img . resize (
102
146
Math . ceil ( img . width * scale ) ,
103
147
Math . ceil ( img . height * scale )
104
148
) ;
105
149
}
106
150
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 } ;
120
196
}
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
+ }
131
217
}
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
+ }
136
331
}
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 ;
137
335
}
138
336
139
- return { ok, diff } ;
337
+ return {
338
+ size,
339
+ pixels : clusterPixels ,
340
+ isLineShift
341
+ } ;
140
342
}
141
343
142
344
/**
0 commit comments