diff --git a/scripts/chatgpt-check-offset-diff.py b/scripts/chatgpt-check-offset-diff.py new file mode 100644 index 0000000..c1cf230 --- /dev/null +++ b/scripts/chatgpt-check-offset-diff.py @@ -0,0 +1,118 @@ +#!/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()