108 lines
3.2 KiB
Python
Executable File
108 lines
3.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
diff.py — Pixel diff two PNGs, producing a highlighted diff image and stats.
|
|
|
|
Uses the `pixelmatch` Python port (pip install pixelmatch pillow). Returns JSON to stdout:
|
|
{ diff_pct, diff_image_path, worst_regions: [ { x, y, width, height } ] }
|
|
|
|
Worst regions are computed via connected-component analysis on the diff mask, sorted by area desc.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from PIL import Image
|
|
from pixelmatch.contrib.PIL import pixelmatch
|
|
except ImportError:
|
|
print(json.dumps({"error": "Missing dependencies. pip install pixelmatch pillow numpy"}), file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
import numpy as np
|
|
from scipy import ndimage
|
|
HAVE_SCIPY = True
|
|
except ImportError:
|
|
HAVE_SCIPY = False
|
|
|
|
|
|
def resize_to_match(a: Image.Image, b: Image.Image) -> tuple[Image.Image, Image.Image]:
|
|
if a.size == b.size:
|
|
return a, b
|
|
target = (min(a.size[0], b.size[0]), min(a.size[1], b.size[1]))
|
|
return a.resize(target), b.resize(target)
|
|
|
|
|
|
def worst_regions_from_diff(diff_img: Image.Image, top_n: int = 5) -> list[dict]:
|
|
if not HAVE_SCIPY:
|
|
return []
|
|
arr = np.array(diff_img)
|
|
if arr.ndim == 3:
|
|
mask = (arr[..., :3].sum(axis=-1) > 0).astype(np.uint8)
|
|
else:
|
|
mask = (arr > 0).astype(np.uint8)
|
|
labels, n = ndimage.label(mask)
|
|
if n == 0:
|
|
return []
|
|
regions = []
|
|
for i in range(1, n + 1):
|
|
ys, xs = np.where(labels == i)
|
|
if len(xs) < 20:
|
|
continue
|
|
regions.append({
|
|
"x": int(xs.min()),
|
|
"y": int(ys.min()),
|
|
"width": int(xs.max() - xs.min() + 1),
|
|
"height": int(ys.max() - ys.min() + 1),
|
|
"area": int(len(xs)),
|
|
})
|
|
regions.sort(key=lambda r: r["area"], reverse=True)
|
|
out = []
|
|
for r in regions[:top_n]:
|
|
r.pop("area", None)
|
|
out.append(r)
|
|
return out
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--before", required=True)
|
|
parser.add_argument("--after", required=True)
|
|
parser.add_argument("--diff-out", required=True)
|
|
parser.add_argument("--threshold", type=float, default=0.1)
|
|
parser.add_argument("--json", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
a = Image.open(args.before).convert("RGBA")
|
|
b = Image.open(args.after).convert("RGBA")
|
|
a, b = resize_to_match(a, b)
|
|
diff = Image.new("RGBA", a.size, (0, 0, 0, 0))
|
|
|
|
mismatched = pixelmatch(a, b, diff, threshold=args.threshold, includeAA=False)
|
|
total = a.size[0] * a.size[1]
|
|
diff_pct = (mismatched / total) * 100.0 if total else 0.0
|
|
|
|
Path(args.diff_out).parent.mkdir(parents=True, exist_ok=True)
|
|
diff.save(args.diff_out)
|
|
|
|
worst = worst_regions_from_diff(diff)
|
|
|
|
result = {
|
|
"diff_pct": round(diff_pct, 3),
|
|
"diff_image_path": args.diff_out,
|
|
"worst_regions": worst,
|
|
"size": {"width": a.size[0], "height": a.size[1]},
|
|
}
|
|
if args.json:
|
|
print(json.dumps(result))
|
|
else:
|
|
print(f"diff_pct={result['diff_pct']}% diff_image={result['diff_image_path']} regions={len(worst)}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|