160 lines
5.3 KiB
Python
160 lines
5.3 KiB
Python
#!/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]))
|