101 lines
3.0 KiB
Python
101 lines
3.0 KiB
Python
#!/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()
|