Diff algorithm

snapdiff/diff.py reimplements pixelmatch with numpy. The result is the same as the reference for the colour delta and anti-aliasing rules, but the per-pixel work is vectorised and only ambiguous pixels run through a Python loop.

1. Perceptual colour delta

Both pixels are blended onto white by their alpha, converted to YIQ (the NTSC colour space, where Y is luma and I/Q are chroma), and compared:

delta = 0.5053 * dY^2 + 0.299 * dI^2 + 0.1957 * dQ^2

The weights reflect that the eye is most sensitive to luma changes. The sign encodes whether the first image is brighter (negative) or darker (positive), which the anti-aliasing step needs.

The tolerance is threshold in 0..1, turned into a squared-distance limit: max_delta = 35215 * threshold^2. 35215 is the largest possible YIQ delta (black vs white), so threshold=1 accepts everything and 0 accepts nothing.

Worked example: grey 100 vs grey 110 differs only in luma, dY = 10, delta = 0.5053 * 100 = 50.5. At the default threshold=0.1 the limit is 352, so it is ignored. At threshold=0.01 the limit is 3.5, so it is flagged. Grey 0 vs grey 128 gives delta = 8279, flagged at any sensible threshold.

2. Anti-aliasing detection

A pixel over the threshold is a candidate. A candidate is ignored if it looks like an anti-aliased edge pixel in either image (Vysniauskas, 2009). For a pixel in image A, look at its 8 neighbours:

  1. Count neighbours with identical luma. More than 2 identical neighbours means a flat area or a hard edge, so it is not anti-aliasing. Pixels on the image border start with one free "identical neighbour".
  2. Find the neighbour that is darkest and the one that is brightest relative to the pixel. If either does not exist (the pixel is a local extreme), it is not anti-aliasing: real edges have both lighter and darker neighbours.
  3. The pixel is anti-aliased only if the darkest neighbour, or the brightest, has more than 2 identical neighbours itself in both images. That means the neighbour sits inside a flat region in both versions, so the edge pixel is just blending between two stable regions.

Worked example (tests/test_diff.py): image A is white on the left and black on the right, with a hard edge at column 10. Image B is the same but column 10 is grey 128. Column 10 differs by delta = 8279, far over the limit. But in B each of those pixels has a white neighbour on the left and a black one on the right (both extremes exist), only the vertical neighbours are identical (2, not more than 2), and the white/black neighbours are in flat areas in both images. So all of column 10 is classed as anti-aliasing: diff_pixels = 0, aa_pixels = 20. By contrast, a flat black square that turns grey has every neighbour identical, step 1 rejects it, and it counts as a real change.

Vectorised prefilter: step 1 is computed for the whole image with 8 shifted comparisons; only candidates with 2 or fewer identical neighbours in either image reach the Python loop. A full-page redesign therefore costs a few array operations instead of millions of Python iterations.

include_aa=True skips the check and counts anti-aliased pixels as differences.

3. Output

4. Size changes

If the images differ in size, both are padded at the bottom/right to the larger width and height with opaque magenta, then compared normally. Everything that exists in only one image therefore shows as changed, the diff image shows exactly where the extra or missing area is, and size_changed is set. snapdiff always marks a size change as changed, even if max_diff_percent would allow the pixel count.

Example: a 20x20 white baseline against a 20x30 white current gives diff_pixels = 200 (the 20x10 strip) and one region (0, 20, 20, 10).

5. Changed regions

connected_components(mask, block=8) returns the bounding boxes of 8-connected changed areas. To keep one changed word from becoming dozens of boxes, the mask is first pooled into 8x8 cells and the cells are labelled with a flood fill; each box is then tightened to the changed pixels inside it. block=1 gives exact pixel connectivity.

Example: two specks at x=5 and x=12 on the same row are 1 region with block=8 and 2 regions with block=1.

6. Pass/fail

A screenshot is changed if mismatch_percent > max_diff_percent (default 0) or its size changed. Both knobs are per page: threshold controls how different a pixel must be, max_diff_percent how many such pixels are tolerated.