Skip to content

Commit 65d7311

Browse files
feat(preprod): Add image comparison library with odiff batch support (#109381)
## Summary - Add `compare_images` and `compare_images_batch` functions using dual-threshold detection (base + color-sensitive) - Produces base64-encoded diff mask PNGs with pixel-level change data - Unit tests covering identical, different, different-size, threshold sensitivity, bytes input, and batch modes **Stack**: 2/3 — depends on #109380, next: Celery task --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3bfe268 commit 65d7311

File tree

5 files changed

+253
-2
lines changed

5 files changed

+253
-2
lines changed

.github/workflows/backend.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,13 @@ jobs:
269269
with:
270270
mode: backend-ci
271271

272+
- name: Download odiff binary
273+
run: |
274+
curl -sL https://registry.npmjs.org/odiff-bin/-/odiff-bin-4.3.2.tgz \
275+
| tar -xz --strip-components=2 package/raw_binaries/odiff-linux-x64
276+
sudo install -m 755 odiff-linux-x64 /usr/local/bin/odiff
277+
rm odiff-linux-x64
278+
272279
- name: Download selected tests artifact
273280
if: needs.prepare-selective-tests.outputs.has-selected-tests == 'true'
274281
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
from __future__ import annotations
2+
3+
import base64
4+
import io
5+
import logging
6+
import tempfile
7+
from collections.abc import Sequence
8+
from pathlib import Path
9+
10+
from PIL import Image
11+
12+
from .odiff import OdiffServer
13+
from .types import DiffResult
14+
15+
logger = logging.getLogger(__name__)
16+
17+
DIFF_THRESHOLD = 0
18+
19+
20+
def _as_image(source: bytes | Image.Image) -> Image.Image:
21+
if isinstance(source, bytes):
22+
img = Image.open(io.BytesIO(source))
23+
try:
24+
img.load()
25+
except Exception:
26+
img.close()
27+
raise
28+
return img
29+
return source
30+
31+
32+
def _mask_from_diff_output(output_path: Path) -> Image.Image:
33+
with Image.open(output_path) as img:
34+
rgba = img.convert("RGBA")
35+
bands: tuple[Image.Image, ...] = ()
36+
try:
37+
bands = rgba.split()
38+
alpha = bands[3]
39+
mask = alpha.point(lambda px: 255 if px > 0 else 0)
40+
return mask
41+
finally:
42+
for band in bands:
43+
band.close()
44+
rgba.close()
45+
46+
47+
def _encode_mask_png_base64(mask: Image.Image) -> str:
48+
buf = io.BytesIO()
49+
mask.save(buf, format="PNG")
50+
return base64.b64encode(buf.getvalue()).decode("ascii")
51+
52+
53+
def compare_images(
54+
before: bytes | Image.Image,
55+
after: bytes | Image.Image,
56+
) -> DiffResult | None:
57+
return compare_images_batch([(before, after)])[0]
58+
59+
60+
def compare_images_batch(
61+
pairs: Sequence[tuple[bytes | Image.Image, bytes | Image.Image]],
62+
server: OdiffServer | None = None,
63+
) -> list[DiffResult | None]:
64+
with tempfile.TemporaryDirectory() as tmpdir:
65+
tmpdir_path = Path(tmpdir)
66+
if server is not None:
67+
return _compare_pairs(pairs, server, tmpdir_path)
68+
with OdiffServer() as new_server:
69+
return _compare_pairs(pairs, new_server, tmpdir_path)
70+
71+
72+
def _compare_pairs(
73+
pairs: Sequence[tuple[bytes | Image.Image, bytes | Image.Image]],
74+
server: OdiffServer,
75+
tmpdir_path: Path,
76+
) -> list[DiffResult | None]:
77+
return [
78+
_compare_single_pair(idx, before, after, server, tmpdir_path)
79+
for idx, (before, after) in enumerate(pairs)
80+
]
81+
82+
83+
def _compare_single_pair(
84+
idx: int,
85+
before: bytes | Image.Image,
86+
after: bytes | Image.Image,
87+
server: OdiffServer,
88+
tmpdir_path: Path,
89+
) -> DiffResult | None:
90+
before_img: Image.Image | None = None
91+
after_img: Image.Image | None = None
92+
diff_mask: Image.Image | None = None
93+
try:
94+
before_img = _as_image(before)
95+
after_img = _as_image(after)
96+
bw, bh = before_img.size
97+
aw, ah = after_img.size
98+
max_w = max(bw, aw)
99+
max_h = max(bh, ah)
100+
101+
before_path = tmpdir_path / f"before_{idx}.png"
102+
after_path = tmpdir_path / f"after_{idx}.png"
103+
before_img.save(before_path, "PNG")
104+
after_img.save(after_path, "PNG")
105+
106+
output_path = tmpdir_path / f"diff_{idx}.png"
107+
resp = server.compare(
108+
before_path,
109+
after_path,
110+
output_path,
111+
threshold=DIFF_THRESHOLD,
112+
antialiasing=True,
113+
outputDiffMask=True,
114+
failOnLayoutDiff=False,
115+
)
116+
changed_pixels = resp.diffCount or 0
117+
diff_pct = resp.diffPercentage or 0.0
118+
119+
total_pixels = max_w * max_h
120+
diff_score = diff_pct / 100.0
121+
122+
if changed_pixels == 0:
123+
diff_mask = Image.new("L", (max_w, max_h), 0)
124+
elif not output_path.exists():
125+
raise RuntimeError(f"odiff did not produce output file: {output_path}")
126+
else:
127+
diff_mask = _mask_from_diff_output(output_path)
128+
if diff_mask.size != (max_w, max_h):
129+
old_mask = diff_mask
130+
diff_mask = diff_mask.resize((max_w, max_h), Image.NEAREST)
131+
old_mask.close()
132+
133+
diff_mask_png = _encode_mask_png_base64(diff_mask)
134+
135+
return DiffResult(
136+
diff_mask_png=diff_mask_png,
137+
diff_score=diff_score,
138+
changed_pixels=changed_pixels,
139+
total_pixels=total_pixels,
140+
aligned_height=max_h,
141+
width=max_w,
142+
before_width=bw,
143+
before_height=bh,
144+
after_width=aw,
145+
after_height=ah,
146+
)
147+
except Exception:
148+
logger.exception("Failed to compare image pair %d", idx)
149+
return None
150+
finally:
151+
if before_img is not None and isinstance(before, bytes):
152+
before_img.close()
153+
if after_img is not None and isinstance(after, bytes):
154+
after_img.close()
155+
if diff_mask is not None:
156+
diff_mask.close()

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,11 @@ def close(self) -> None:
154154
proc.stdin.close()
155155
except OSError:
156156
pass
157+
if proc.stdout:
158+
try:
159+
proc.stdout.close()
160+
except OSError:
161+
pass
157162
try:
158163
proc.wait(timeout=3)
159164
return

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ class OdiffResponse(BaseModel):
2222
model_config = ConfigDict(frozen=True)
2323

2424
requestId: int
25-
exitCode: int
26-
result: str
25+
match: bool = False
26+
reason: str | None = None
2727
diffCount: int | None = None
2828
diffPercentage: float | None = None
2929
error: str | None = None
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from __future__ import annotations
2+
3+
import io
4+
5+
from PIL import Image, ImageDraw
6+
7+
from sentry.preprod.snapshots.image_diff.compare import compare_images, compare_images_batch
8+
9+
10+
def _make_solid_image(width: int, height: int, color: tuple[int, int, int, int]) -> Image.Image:
11+
return Image.new("RGBA", (width, height), color)
12+
13+
14+
class TestCompareImages:
15+
def test_identical_images(self):
16+
img = _make_solid_image(100, 100, (128, 128, 128, 255))
17+
result = compare_images(img, img.copy())
18+
assert result is not None
19+
assert result.diff_score == 0.0
20+
assert result.changed_pixels == 0
21+
assert result.total_pixels == 100 * 100
22+
23+
def test_different_sizes(self):
24+
small = _make_solid_image(30, 30, (100, 100, 100, 255))
25+
large = _make_solid_image(50, 50, (100, 100, 100, 255))
26+
result = compare_images(small, large)
27+
assert result is not None
28+
assert result.width == 50
29+
assert result.aligned_height == 50
30+
assert result.before_width == 30
31+
assert result.before_height == 30
32+
assert result.after_width == 50
33+
assert result.after_height == 50
34+
35+
def test_modified_block(self):
36+
before = _make_solid_image(100, 100, (100, 100, 100, 255))
37+
after = _make_solid_image(100, 100, (100, 100, 100, 255))
38+
draw = ImageDraw.Draw(after)
39+
draw.rectangle((10, 10, 29, 29), fill=(255, 0, 0, 255))
40+
result = compare_images(before, after)
41+
assert result is not None
42+
assert result.changed_pixels > 0
43+
44+
def test_bytes_input(self):
45+
img = _make_solid_image(30, 30, (128, 128, 128, 255))
46+
buf = io.BytesIO()
47+
img.save(buf, format="PNG")
48+
img_bytes = buf.getvalue()
49+
50+
result = compare_images(img_bytes, img_bytes)
51+
assert result is not None
52+
assert result.diff_score == 0.0
53+
54+
55+
class TestCompareImagesBatch:
56+
def test_batch_returns_correct_count(self):
57+
img1 = _make_solid_image(50, 50, (100, 100, 100, 255))
58+
img2 = _make_solid_image(50, 50, (200, 200, 200, 255))
59+
60+
results = compare_images_batch(
61+
[
62+
(img1, img1.copy()),
63+
(img1, img2),
64+
]
65+
)
66+
67+
assert len(results) == 2
68+
assert results[0] is not None
69+
assert results[1] is not None
70+
assert results[0].diff_score == 0.0
71+
assert results[1].diff_score > 0.0
72+
73+
def test_batch_single_pair_matches_single(self):
74+
before = _make_solid_image(50, 50, (100, 100, 100, 255))
75+
after = _make_solid_image(50, 50, (200, 200, 200, 255))
76+
77+
single = compare_images(before, after)
78+
batch = compare_images_batch([(before, after)])[0]
79+
80+
assert single is not None
81+
assert batch is not None
82+
assert single.diff_score == batch.diff_score
83+
assert single.changed_pixels == batch.changed_pixels

0 commit comments

Comments
 (0)