Files

75 lines
2.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""Compare two QA captures pixel by pixel.
Usage: compare.py vanilla.png gpu.png OUT_PREFIX [--threshold PCT] [--delta N]
Counts pixels whose max channel delta exceeds --delta (default 16, the
value the development parity harness settled on: below that it is
antialiasing fringe, not structure). PASS if the percentage of such
pixels is below --threshold (default 0.5).
Writes OUT_PREFIX-triptych.png (vanilla | gpu | diff heatmap) and prints
one JSON line with the verdict.
"""
import json
import sys
from PIL import Image, ImageChops, ImageFilter
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
opts = {a.split("=")[0].lstrip("-"): float(a.split("=")[1])
for a in sys.argv[1:] if a.startswith("--")}
vanilla_path, gpu_path, prefix = args
threshold = opts.get("threshold", 0.5)
delta = int(opts.get("delta", 16))
a = Image.open(vanilla_path).convert("RGB")
b = Image.open(gpu_path).convert("RGB")
if a.size != b.size:
print(json.dumps({"pass": False, "error": "size mismatch",
"vanilla": a.size, "gpu": b.size}))
return 1
diff = ImageChops.difference(a, b)
# max channel delta per pixel, then threshold
gray = diff.convert("L")
mask = gray.point(lambda p: 255 if p > delta else 0)
hist = mask.histogram()
bad = hist[255]
total = a.size[0] * a.size[1]
pct = 100.0 * bad / total
# Anti-aliasing fringe is a 1px halo on glyph edges and varies between
# rasterizers; real defects (wrong color, missing decoration, shifted
# block) are contiguous blobs. A 3x3 erosion kills the halo and keeps
# the blobs, so the PASS gate runs on the eroded mask.
blob_mask = mask.filter(ImageFilter.MinFilter(3))
blob_bad = blob_mask.histogram()[255]
blob_pct = 100.0 * blob_bad / total
# triptych: vanilla | gpu | heatmap (red = blobs that gate the verdict,
# dark red = AA fringe that does not)
heat = a.point(lambda p: p // 3)
fringe = Image.new("RGB", a.size, (110, 24, 24))
heat.paste(fringe, mask=mask)
red = Image.new("RGB", a.size, (255, 32, 32))
heat.paste(red, mask=blob_mask)
strip = Image.new("RGB", (a.size[0] * 3 + 20, a.size[1]), (24, 24, 24))
strip.paste(a, (0, 0))
strip.paste(b, (a.size[0] + 10, 0))
strip.paste(heat, (a.size[0] * 2 + 20, 0))
strip.save(prefix + "-triptych.png")
ok = blob_pct <= threshold
print(json.dumps({"pass": ok, "blob_pct": round(blob_pct, 4),
"fringe_pct": round(pct, 4),
"blob_pixels": blob_bad, "total": total,
"threshold_pct": threshold, "delta": delta}))
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())