#!/usr/bin/env python3 """ Image comparator for React vs HTMX showcase validation. Compares two PNG/WEBP screenshots and produces: 1. Similarity percentage (pixels within tolerance / total pixels) 2. A diff image with mismatches highlighted magenta on a faded background Tolerance is per-channel: anti-aliasing / sub-pixel hinting is accepted (default 12 of 255 per channel, tweakable via --tol). Font / layout / color changes produce large regions of divergence that will exceed the tolerance. Usage: python3 compare.py react.webp htmx.webp [--out diff.webp] [--tol 12] """ import argparse import sys from pathlib import Path from PIL import Image, ImageChops, ImageDraw def load(path): img = Image.open(path).convert("RGB") return img def compare(react_path, htmx_path, out_path, tol): a = load(react_path) b = load(htmx_path) if a.size != b.size: # Pad the smaller one with transparent/white so we can still diff w, h = max(a.width, b.width), max(a.height, b.height) pad_a = Image.new("RGB", (w, h), (255, 255, 255)) pad_b = Image.new("RGB", (w, h), (255, 255, 255)) pad_a.paste(a, (0, 0)) pad_b.paste(b, (0, 0)) a, b = pad_a, pad_b size_mismatch = True else: size_mismatch = False diff = ImageChops.difference(a, b) # Per-pixel max channel diff total = a.width * a.height differing = 0 mask = Image.new("L", a.size, 0) mask_pixels = mask.load() diff_pixels = diff.load() for y in range(a.height): for x in range(a.width): r, g, bl = diff_pixels[x, y] if max(r, g, bl) > tol: differing += 1 mask_pixels[x, y] = 255 similarity = 100.0 * (total - differing) / total # Build diff image: React screenshot faded 50%, with diffs in magenta faded = Image.eval(a, lambda v: int(v * 0.4 + 0.6 * 255)) magenta = Image.new("RGB", a.size, (255, 0, 180)) out = Image.composite(magenta, faded, mask) # Add a header text draw = ImageDraw.Draw(out) header = ( f"similarity={similarity:.2f}% " f"differing={differing}/{total} " f"tol={tol}" + (" (SIZE MISMATCH — padded)" if size_mismatch else "") ) draw.rectangle([0, 0, a.width, 24], fill=(0, 0, 0)) draw.text((8, 4), header, fill=(255, 255, 255)) out.save(out_path) return similarity, differing, total def main(): p = argparse.ArgumentParser() p.add_argument("react") p.add_argument("htmx") p.add_argument("--out", default="diff.webp") p.add_argument("--tol", type=int, default=12) args = p.parse_args() sim, diff_px, total = compare(args.react, args.htmx, args.out, args.tol) print(f"react = {args.react}") print(f"htmx = {args.htmx}") print(f"diff -> {args.out}") print(f" similarity = {sim:.2f}% ({diff_px} / {total} pixels differ > {args.tol})") if sim < 99.5: sys.exit(1) sys.exit(0) if __name__ == "__main__": main()