#!/usr/bin/env python3 from pathlib import Path from PIL import Image, ImageChops import argparse import re def load_rgb(path: Path) -> Image.Image: return Image.open(path).convert("RGB") def mse(img: Image.Image) -> float: hist = img.histogram() sq = sum(v * ((i % 256) ** 2) for i, v in enumerate(hist)) return sq / (img.width * img.height * 3) def crop_pair(expected, actual, shift): # shift > 0 means actual content is lower than expected. w = min(expected.width, actual.width) if shift >= 0: e_box = (0, 0, w, min(expected.height, actual.height - shift)) a_box = (0, shift, w, shift + (e_box[3] - e_box[1])) else: s = -shift a_box = (0, 0, w, min(actual.height, expected.height - s)) e_box = (0, s, w, s + (a_box[3] - a_box[1])) return expected.crop(e_box), actual.crop(a_box) def best_vertical_shift(expected, actual, max_shift=100): best = None for shift in range(18, 19): e, a = crop_pair(expected, actual, shift) if e.height <= 0: continue score = mse(ImageChops.difference(e, a)) if best is None or score < best[1]: best = (shift, score) return best def changed_until_y(expected, actual, threshold=5, min_changed_pixels=10): diff = ImageChops.difference(expected, actual) last_changed = None for y in range(diff.height): row = diff.crop((0, y, diff.width, y + 1)).convert("L") changed = sum(1 for px in row.getdata() if px > threshold) if changed >= min_changed_pixels: last_changed = y return { "last_changed_y": last_changed, "clean_after_y": 0 if last_changed is None else last_changed + 1, } def find_pairs(root: Path): expected_files = sorted(root.rglob("*-expected.png")) for expected in expected_files: actual = expected.with_name( expected.name.replace("-expected.png", "-actual.png") ) if actual.exists(): yield expected, actual def main(): ap = argparse.ArgumentParser() ap.add_argument("root", type=Path, nargs="?", default=Path("test-results")) ap.add_argument("--max-shift", type=int, default=100) ap.add_argument("--make-diffs", action="store_true") args = ap.parse_args() failed = False for expected_path, actual_path in find_pairs(args.root): expected = load_rgb(expected_path) actual = load_rgb(actual_path) raw_score = mse(ImageChops.difference( expected.crop((0, 0, min(expected.width, actual.width), min(expected.height, actual.height))), actual.crop((0, 0, min(expected.width, actual.width), min(expected.height, actual.height))), )) shift, shifted_score = best_vertical_shift( expected, actual, max_shift=args.max_shift ) status = "OK" if shifted_score < raw_score * 0.05 else "CHECK" if status == "CHECK": failed = True rel = expected_path.relative_to(args.root) print(f"{status} {rel}") print(f" expected: {expected.size}, actual: {actual.size}") print(f" best shift: {shift:+d}px") print(f" raw mse: {raw_score:.3f}") print(f" shifted mse: {shifted_score:.3f}") if args.make_diffs: e, a = crop_pair(expected, actual, shift) change_info = changed_until_y(e, a, threshold=2) print(f" last changed y after shift: {change_info['last_changed_y']}") print(f" clean after y after shift: {change_info['clean_after_y']}") diff = ImageChops.difference(e, a) out = expected_path.with_name( expected_path.name.replace("-expected.png", f"-shift-{shift:+d}-diff.png") ) diff.save(out) print(f" wrote: {out}") raise SystemExit(1 if failed else 0) if __name__ == "__main__": main()