DEV Community

Cover image for Audit Image Edits Outside the Requested Region With Python
Joey
Joey

Posted on

Audit Image Edits Outside the Requested Region With Python

A local image edit gives me two things to review: the requested change and everything that was meant to stay untouched. Looking only at the edited object makes the second part easy to miss.

I want a map of changes outside the allowed region. A binary mask makes that region explicit: white means editable; black means preserve.

Compare the preserved pixels

Install Pillow:

python -m pip install Pillow
Enter fullscreen mode Exit fullscreen mode

Save this as image_audit.py:

import argparse
import json
from pathlib import Path
from PIL import Image, ImageChops, ImageOps


def load(path, mode):
    with Image.open(path) as image:
        return ImageOps.exif_transpose(image).convert(mode)


def audit(before, after, mask, output, threshold=8):
    if not 0 <= threshold <= 255:
        raise ValueError("threshold must be between 0 and 255")
    a, b = load(before, "RGBA"), load(after, "RGBA")
    allowed = load(mask, "L")
    if a.size != b.size or a.size != allowed.size:
        raise ValueError("images and mask must have identical dimensions")
    allowed = allowed.point(lambda p: 255 if p >= 128 else 0)
    outside = ImageOps.invert(allowed)
    count = outside.histogram()[255]
    if not count:
        raise ValueError("mask leaves no preserved pixels to check")

    channels = ImageChops.difference(a, b).split()
    delta = channels[0]
    for channel in channels[1:]:
        delta = ImageChops.lighter(delta, channel)
    changed = delta.point(lambda p: 255 if p > threshold else 0)
    changed_outside = ImageChops.multiply(changed, outside)
    hits = changed_outside.histogram()[255]
    heatmap = Image.merge("RGB", (
        ImageChops.multiply(delta, outside),
        ImageChops.multiply(delta, allowed),
        Image.new("L", a.size),
    ))
    report = {
        "threshold": threshold,
        "preserved_pixels": count,
        "changed_outside": hits,
        "changed_outside_percent": round(100 * hits / count, 4),
    }
    output = Path(output)
    output.mkdir(parents=True, exist_ok=False)
    heatmap.save(output / "heatmap.png")
    (output / "report.json").write_text(json.dumps(report, indent=2) + "\n")
    return report


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    for name in ("before", "after", "mask", "output"):
        parser.add_argument(name)
    parser.add_argument("--threshold", type=int, default=8)
    args = parser.parse_args()
    try:
        result = audit(args.before, args.after, args.mask,
                       args.output, args.threshold)
    except (OSError, ValueError) as error:
        parser.exit(2, f"error: {error}\n")
    print(json.dumps(result, indent=2))
Enter fullscreen mode Exit fullscreen mode

The difference uses the largest absolute change across RGBA channels. A change in just one channel still counts. The report includes pixels strictly above the chosen threshold; the heatmap keeps the raw difference strength, with red outside the editable area and green inside.

I deliberately stop on a size mismatch. Resizing one image to make the comparison work would introduce another change before the audit even starts.

Try a known change

This fixture changes 100 allowed pixels, six preserved pixels, and one extra preserved pixel by only five channel values:

from PIL import Image, ImageDraw

a = Image.new("RGB", (100, 60), (128, 128, 128))
a.save("before.png")
mask = Image.new("L", a.size, 0)
ImageDraw.Draw(mask).rectangle((20, 10, 39, 29), fill=255)
mask.save("mask.png")
b = a.copy()
ImageDraw.Draw(b).rectangle((20, 10, 29, 19), fill=(200, 128, 128))
ImageDraw.Draw(b).rectangle((0, 0, 1, 2), fill=(128, 170, 128))
b.putpixel((99, 59), (133, 128, 128))
b.save("after.png")
Enter fullscreen mode Exit fullscreen mode

Run it with a new output directory:

python image_audit.py before.png after.png mask.png audit --threshold 8
Enter fullscreen mode Exit fullscreen mode

The result is:

{
  "threshold": 8,
  "preserved_pixels": 5600,
  "changed_outside": 6,
  "changed_outside_percent": 0.1071
}
Enter fullscreen mode Exit fullscreen mode

That percentage uses only the preserved region as its denominator. A large editable area cannot dilute it.

The threshold of eight is a starting choice, not a quality standard. If nearly the whole preserved region lights up, I check alignment, compression, and export settings before treating every red pixel as an editing mistake. For transparent images, this compares stored RGBA values, including RGB hidden under transparency.

The useful output is where to look next. Six changed pixels near a logo may matter more than a much larger change in background grain.

Reference: Pillow ImageChops documentation.

Top comments (0)