PebbleGolfScore/tests/compare.py

160 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
compare.py <baseline.png> <candidate.png>
Pixel-level comparison of two PNG screenshots.
Exit codes:
0 images are identical (or within tolerance)
1 images differ beyond tolerance
2 usage / file error
Outputs a one-line summary and, on failure, saves a diff image alongside
the candidate as <candidate>_diff.png highlighting changed pixels in red.
"""
import struct
import sys
import zlib
TOLERANCE = 10 # per-channel delta considered noise
THRESHOLD = 0.01 # max fraction of differing pixels before failure (1 %)
# ── Minimal PNG reader ────────────────────────────────────────────────────────
def _read_chunk(data, pos):
length = struct.unpack_from('>I', data, pos)[0]
tag = data[pos+4:pos+8]
body = data[pos+8:pos+8+length]
pos += 12 + length
return tag, body, pos
def load_png(path):
with open(path, 'rb') as f:
raw = f.read()
assert raw[:8] == b'\x89PNG\r\n\x1a\n', f"{path}: not a PNG"
ihdr = width = height = None
idat_chunks = []
pos = 8
while pos < len(raw):
tag, body, pos = _read_chunk(raw, pos)
if tag == b'IHDR':
width, height = struct.unpack('>II', body[:8])
bit_depth, colour_type = body[8], body[9]
assert bit_depth == 8 and colour_type == 2, \
f"{path}: only 8-bit RGB PNGs supported"
elif tag == b'IDAT':
idat_chunks.append(body)
elif tag == b'IEND':
break
assert width and height, f"{path}: no IHDR"
raw_pixels = zlib.decompress(b''.join(idat_chunks))
# Reconstruct pixels via PNG filter (only filter 0 = None is used here)
stride = width * 3
pixels = []
idx = 0
prev_row = bytes(stride)
for _ in range(height):
filt = raw_pixels[idx]; idx += 1
row = bytearray(raw_pixels[idx:idx+stride]); idx += stride
if filt == 0:
pass
elif filt == 1: # Sub
for i in range(3, stride):
row[i] = (row[i] + row[i-3]) & 0xFF
elif filt == 2: # Up
for i in range(stride):
row[i] = (row[i] + prev_row[i]) & 0xFF
elif filt == 3: # Average
for i in range(stride):
a = row[i-3] if i >= 3 else 0
row[i] = (row[i] + (a + prev_row[i]) // 2) & 0xFF
elif filt == 4: # Paeth
for i in range(stride):
a = row[i-3] if i >= 3 else 0
b = prev_row[i]
c = prev_row[i-3] if i >= 3 else 0
p = a + b - c
pa = abs(p - a); pb = abs(p - b); pc = abs(p - c)
pr = a if pa <= pb and pa <= pc else (b if pb <= pc else c)
row[i] = (row[i] + pr) & 0xFF
prev_row = bytes(row)
pixels.append([tuple(row[i:i+3]) for i in range(0, stride, 3)])
return width, height, pixels
def save_png(path, pixels):
h = len(pixels)
w = len(pixels[0])
rows = bytearray()
for row in pixels:
rows.append(0) # filter = None
for p in row:
rows += bytearray(p)
def chunk(tag, data):
crc = zlib.crc32(tag + data) & 0xffffffff
return struct.pack('>I', len(data)) + tag + data + struct.pack('>I', crc)
out = b'\x89PNG\r\n\x1a\n'
out += chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 0))
out += chunk(b'IDAT', zlib.compress(bytes(rows), 9))
out += chunk(b'IEND', b'')
with open(path, 'wb') as f:
f.write(out)
# ── Comparison logic ──────────────────────────────────────────────────────────
def compare(baseline_path, candidate_path):
try:
bw, bh, base_px = load_png(baseline_path)
cw, ch, cand_px = load_png(candidate_path)
except Exception as e:
print(f"ERROR: {e}")
return 2
if bw != cw or bh != ch:
print(f"FAIL size mismatch: baseline={bw}×{bh} candidate={cw}×{ch}")
return 1
total = bw * bh
diff_px = 0
diff_img = [[(0, 0, 0)] * bw for _ in range(bh)]
for y in range(bh):
for x in range(bw):
br, bg, bb = base_px[y][x]
cr, cg, cb = cand_px[y][x]
if abs(br-cr) > TOLERANCE or abs(bg-cg) > TOLERANCE or abs(bb-cb) > TOLERANCE:
diff_px += 1
diff_img[y][x] = (220, 0, 0) # red highlight on diff image
else:
# Dim the matching pixel so diffs stand out
diff_img[y][x] = (cr//3, cg//3, cb//3)
frac = diff_px / total
if frac > THRESHOLD:
diff_path = candidate_path.replace('.png', '_diff.png')
save_png(diff_path, diff_img)
print(f"FAIL {diff_px}/{total} pixels differ ({frac:.2%}) "
f"— diff saved to {diff_path}")
return 1
else:
print(f"PASS {diff_px}/{total} pixels differ ({frac:.2%}) "
f"[tolerance ≤{THRESHOLD:.0%}]")
return 0
if __name__ == '__main__':
if len(sys.argv) != 3:
print(f"Usage: {sys.argv[0]} <baseline.png> <candidate.png>")
sys.exit(2)
sys.exit(compare(sys.argv[1], sys.argv[2]))