Originally posted on the Scrapio blog — sharing here too.
Boolean change detection ("did the page look different this run?") answers half the question. The other half — the one that actually matters when you're staring at a Slack alert at 2am — is what changed and where. A pixel-diff ratio over a threshold tells you to look; it doesn't tell you where to look. This guide covers generating an actual annotated diff image: the changed region boxed in red, ready to attach to a webhook payload or Slack message.
This builds on Screenshot Monitoring, which covers the scheduled capture-and-threshold-comparison side. This post is specifically about turning that comparison into a visual artifact a human can glance at instead of opening both screenshots side by side themselves.
What you'll need
- A Scrapio API key
- Python 3.10+ with
httpxandPillowinstalled (pip install httpx pillow) - Two screenshots already captured on a schedule (see Screenshot Monitoring for the capture + basic threshold-comparison side)
From boolean to bounding box
ImageChops.difference() (used in the boolean check) already computes a full diff image — the trick is using its bounding box to know where the change is, then drawing that box onto a copy of the current screenshot instead of discarding the diff once you have a yes/no answer:
from io import BytesIO
from PIL import Image, ImageChops, ImageDraw
def generate_diff_image(previous_bytes: bytes, current_bytes: bytes) -> bytes | None:
prev = Image.open(BytesIO(previous_bytes)).convert("RGB")
curr = Image.open(BytesIO(current_bytes)).convert("RGB")
if prev.size != curr.size:
curr = curr.resize(prev.size)
diff = ImageChops.difference(prev, curr)
bbox = diff.getbbox()
if bbox is None:
return None # no change
annotated = curr.copy()
draw = ImageDraw.Draw(annotated)
# Pad the box slightly so the highlight isn't drawn flush against the
# changed pixels themselves -- easier to actually see at a glance.
padded = (max(bbox[0] - 8, 0), max(bbox[1] - 8, 0), bbox[2] + 8, bbox[3] + 8)
draw.rectangle(padded, outline=(255, 0, 0), width=4)
output = BytesIO()
annotated.save(output, format="PNG")
return output.getvalue()
diff.getbbox() returns the smallest rectangle containing every non-zero pixel in the diff — i.e. the bounding box of all changed regions combined, not just one. For a page with multiple unrelated changes (a banner and a footer link both changed), this draws one box spanning both rather than two separate boxes. That's a real limitation worth knowing: if you need per-region boxes instead of one combined box, you'd need to find connected components in the diff mask rather than a single global bounding box — out of scope here, but worth flagging before you build an alerting pipeline around this that assumes one box means one change.
Noise reduction before drawing the box
A raw pixel diff still over-triggers on things like ad rotators, timestamps, or A/B-tested banners — annotating noise as if it were a real change defeats the purpose. Threshold the diff mask before computing the bounding box, same principle as the boolean check in Screenshot Monitoring:
def generate_diff_image(previous_bytes: bytes, current_bytes: bytes, noise_threshold: int = 24) -> bytes | None:
prev = Image.open(BytesIO(previous_bytes)).convert("RGB")
curr = Image.open(BytesIO(current_bytes)).convert("RGB")
if prev.size != curr.size:
curr = curr.resize(prev.size)
diff = ImageChops.difference(prev, curr)
# Zero out pixels below the noise threshold before computing the bbox,
# so minor anti-aliasing/compression jitter doesn't produce a box.
mask = diff.point(lambda p: 255 if p > noise_threshold else 0)
bbox = mask.getbbox()
if bbox is None:
return None
annotated = curr.copy()
draw = ImageDraw.Draw(annotated)
padded = (max(bbox[0] - 8, 0), max(bbox[1] - 8, 0), bbox[2] + 8, bbox[3] + 8)
draw.rectangle(padded, outline=(255, 0, 0), width=4)
output = BytesIO()
annotated.save(output, format="PNG")
return output.getvalue()
Attaching it to an alert
Slack, Discord, and most webhook receivers accept an image upload alongside a message. The exact upload mechanism depends on where the alert goes — the part specific to this pipeline is just that generate_diff_image() returns raw PNG bytes ready to attach:
import httpx
def send_diff_alert(webhook_url: str, page_url: str, diff_png: bytes) -> None:
httpx.post(
webhook_url,
files={"file": ("diff.png", diff_png, "image/png")},
data={"payload_json": f'{{"text": "Visual change detected on {page_url}"}}'},
timeout=10,
)
(Slack's incoming webhooks specifically don't accept file uploads this way — that example is illustrative of the general pattern; Slack requires the separate files.upload API with a bot token. Discord webhooks do accept multipart file uploads directly in this shape.)
Next steps
- Screenshot Monitoring — the scheduled capture and threshold-comparison side this builds on
- Website Change Detector — if you need to know a page's text content changed, not its visual appearance
-
Fetch API reference for screenshot capture options (
device,wait_for)
Top comments (0)