DEV Community

XSron Hou
XSron Hou

Posted on Originally published at scrapio.dev

Beyond Change Detection: Generating Visual Diff Images for Screenshot Alerts

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 httpx and Pillow installed (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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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,
    )
Enter fullscreen mode Exit fullscreen mode

(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

Top comments (0)