Skip to content

Commit 199b40c

Browse files
ref(snapshots): Make diffing logic more strict + better handle different sized images when comparing
1 parent 9c4470c commit 199b40c

File tree

2 files changed

+67
-10
lines changed

2 files changed

+67
-10
lines changed

src/sentry/preprod/snapshots/image_diff/compare.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@
1313

1414
logger = logging.getLogger(__name__)
1515

16-
DIFF_THRESHOLD = 0
16+
# odiff color-distance sensitivity: pixels within this threshold are treated
17+
# as identical. 0.01 tolerates sub-pixel rendering variance (anti-aliasing,
18+
# font smoothing) while still catching meaningful visual changes.
19+
#
20+
# This is NOT a minimum % changed value, but
21+
# rather adjusts the sensitivity of pixel change detection.
22+
ODIFF_SENSITIVITY_DIFF_THRESHOLD = 0.01
1723

1824

1925
def _as_image(source: bytes | Image.Image) -> Image.Image:
@@ -28,6 +34,14 @@ def _as_image(source: bytes | Image.Image) -> Image.Image:
2834
return source
2935

3036

37+
def _pad_to(img: Image.Image, width: int, height: int) -> Image.Image:
38+
if img.size == (width, height):
39+
return img
40+
padded = Image.new("RGBA", (width, height), (0, 0, 0, 0))
41+
padded.paste(img, (0, 0))
42+
return padded
43+
44+
3145
def _mask_from_diff_output(output_path: Path) -> Image.Image:
3246
with Image.open(output_path) as img:
3347
rgba = img.convert("RGBA")
@@ -88,6 +102,8 @@ def _compare_single_pair(
88102
) -> DiffResult | None:
89103
before_img: Image.Image | None = None
90104
after_img: Image.Image | None = None
105+
before_padded: Image.Image | None = None
106+
after_padded: Image.Image | None = None
91107
diff_mask: Image.Image | None = None
92108
try:
93109
before_img = _as_image(before)
@@ -97,20 +113,22 @@ def _compare_single_pair(
97113
max_w = max(bw, aw)
98114
max_h = max(bh, ah)
99115

116+
before_padded = _pad_to(before_img, max_w, max_h)
117+
after_padded = _pad_to(after_img, max_w, max_h)
118+
100119
before_path = tmpdir_path / f"before_{idx}.png"
101120
after_path = tmpdir_path / f"after_{idx}.png"
102-
before_img.save(before_path, "PNG")
103-
after_img.save(after_path, "PNG")
121+
before_padded.save(before_path, "PNG")
122+
after_padded.save(after_path, "PNG")
104123

105124
output_path = tmpdir_path / f"diff_{idx}.png"
106125
resp = server.compare(
107126
before_path,
108127
after_path,
109128
output_path,
110-
threshold=DIFF_THRESHOLD,
129+
threshold=ODIFF_SENSITIVITY_DIFF_THRESHOLD,
111130
antialiasing=True,
112131
outputDiffMask=True,
113-
failOnLayoutDiff=False,
114132
)
115133
changed_pixels = resp.diffCount or 0
116134
total_pixels = max_w * max_h
@@ -121,10 +139,6 @@ def _compare_single_pair(
121139
raise RuntimeError(f"odiff did not produce output file: {output_path}")
122140
else:
123141
diff_mask = _mask_from_diff_output(output_path)
124-
if diff_mask.size != (max_w, max_h):
125-
old_mask = diff_mask
126-
diff_mask = diff_mask.resize((max_w, max_h), Image.NEAREST)
127-
old_mask.close()
128142

129143
diff_mask_png = _encode_mask_png(diff_mask)
130144

@@ -142,6 +156,10 @@ def _compare_single_pair(
142156
logger.exception("Failed to compare image pair %d", idx)
143157
return None
144158
finally:
159+
if before_padded is not None and before_padded is not before_img:
160+
before_padded.close()
161+
if after_padded is not None and after_padded is not after_img:
162+
after_padded.close()
145163
if before_img is not None and isinstance(before, bytes):
146164
before_img.close()
147165
if after_img is not None and isinstance(after, bytes):

tests/sentry/preprod/snapshots/image_diff/test_image_diff.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,53 @@ def test_different_sizes(self) -> None:
2424
large = _make_solid_image(50, 50, (100, 100, 100, 255))
2525
result = compare_images(small, large)
2626
assert result is not None
27-
assert result.changed_pixels == 0
27+
assert result.changed_pixels == 50 * 50 - 30 * 30
2828
assert result.total_pixels == 50 * 50
2929
assert result.aligned_height == 50
3030
assert result.before_width == 30
3131
assert result.before_height == 30
3232
assert result.after_width == 50
3333
assert result.after_height == 50
3434

35+
def test_different_sizes_with_content_diff(self) -> None:
36+
before = _make_solid_image(50, 50, (100, 100, 100, 255))
37+
after = _make_solid_image(80, 80, (200, 200, 200, 255))
38+
result = compare_images(before, after)
39+
assert result is not None
40+
non_overlap = 80 * 80 - 50 * 50
41+
assert result.changed_pixels > non_overlap
42+
assert result.total_pixels == 80 * 80
43+
44+
def test_different_height_same_width(self) -> None:
45+
before = _make_solid_image(100, 50, (100, 100, 100, 255))
46+
after = _make_solid_image(100, 80, (100, 100, 100, 255))
47+
result = compare_images(before, after)
48+
assert result is not None
49+
assert result.changed_pixels == 100 * 80 - 100 * 50
50+
assert result.total_pixels == 100 * 80
51+
assert result.before_width == 100
52+
assert result.after_width == 100
53+
assert result.before_height == 50
54+
assert result.after_height == 80
55+
56+
def test_different_width_same_height(self) -> None:
57+
before = _make_solid_image(50, 100, (100, 100, 100, 255))
58+
after = _make_solid_image(80, 100, (100, 100, 100, 255))
59+
result = compare_images(before, after)
60+
assert result is not None
61+
assert result.changed_pixels == 80 * 100 - 50 * 100
62+
assert result.total_pixels == 80 * 100
63+
64+
def test_smaller_after_image(self) -> None:
65+
before = _make_solid_image(50, 50, (100, 100, 100, 255))
66+
after = _make_solid_image(30, 30, (100, 100, 100, 255))
67+
result = compare_images(before, after)
68+
assert result is not None
69+
assert result.changed_pixels == 50 * 50 - 30 * 30
70+
assert result.total_pixels == 50 * 50
71+
assert result.before_width == 50
72+
assert result.after_width == 30
73+
3574
def test_modified_block(self) -> None:
3675
before = _make_solid_image(100, 100, (100, 100, 100, 255))
3776
after = _make_solid_image(100, 100, (100, 100, 100, 255))

0 commit comments

Comments
 (0)