75 lines
2.7 KiB
Python
Executable File
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())
|