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
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))
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")
Run it with a new output directory:
python image_audit.py before.png after.png mask.png audit --threshold 8
The result is:
{
"threshold": 8,
"preserved_pixels": 5600,
"changed_outside": 6,
"changed_outside_percent": 0.1071
}
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)