Files
ppsspp/Tools/image_compare.py
T
Henrik RydgårdandClaude Opus 5.5 d717a38a34 Tools: Add image_compare.py, comparing renderers' screenshots past rasterizer differences
A pixel counts as different only if it's outside the range of the other
image's 3x3 neighborhood (widened by a tolerance) and enough of its neighbors
are too, so edges a pixel off, filtering rounding and speckle drop out.
8x8 block averages catch broad shifts separately, and heatmaps show where.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 10:36:19 +02:00

174 lines
7.7 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright (c) 2012- PPSSPP Project.
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, version 2.0 or later versions.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License 2.0 for more details.
# A copy of the GPL 2.0 should have been included with the program.
# If not, see http://www.gnu.org/licenses/
# Official git repository and contact information can be found at
# https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
"""Compare screenshots from different renderers (a hardware backend, the software renderer, a PSP capture)
without counting what any two rasterizers disagree on.
A plain pixel diff between Vulkan and the software renderer counts thousands of pixels for edges a pixel
apart and interpolation rounding, which hides a missing effect or wrong lighting. Here a difference counts
only if it survives three filters:
- Neighborhood: a pixel matches if each channel is within the range the other image's pixels within
--radius span, widened by --tolerance. That takes in edges a pixel off and the values between
neighbors that different filtering and interpolation give. Checked both ways, so a thin object present
in only one image still shows.
- Area: a mismatching pixel counts only if at least --min-neighbors of the 3x3 block around it (itself
included) mismatch too. Shifted edges leave lines a pixel wide and scattered speckle, which drop out; a
wrong surface is an area.
- Low frequency, separately: both images averaged over --block x --block blocks, a block counting when an
average differs by more than --block-tolerance. This catches a broad shift, like lighting too dark
everywhere, that the neighborhood ranges let through.
Prints, per image pair, the area pixels, the low-frequency blocks (0 and 0 means equivalent) and the mean
difference per channel (B - A: a bias from color conversion, which 16-bit framebuffers often show), and with
--heatmaps writes each pair side by side with the counted pixels in red and the blocks in yellow.
Examples:
python3 Tools/image_compare.py vulkan.png softgpu.png
python3 Tools/image_compare.py out_vulkan/ out_soft/ --heatmaps heat/ --sort
"""
import argparse
import concurrent.futures
import itertools
import os
import sys
import numpy as np
from PIL import Image
def load(path):
return np.asarray(Image.open(path).convert('RGB')).astype(np.int16)
def shifted(img, dy, dx):
# img moved by (dy, dx), edges repeated.
h, w = img.shape[:2]
ys = np.clip(np.arange(h) - dy, 0, h - 1)
xs = np.clip(np.arange(w) - dx, 0, w - 1)
return img[ys][:, xs]
def unmatched(a, b, radius, tolerance):
# True where a's pixel is outside the range b's pixels within radius span, widened by tolerance, on
# some channel. A range rather than single pixels, so values between neighbors (filtering and
# interpolation that differ) match too.
lo = hi = b
for dy in range(-radius, radius + 1):
for dx in range(-radius, radius + 1):
s = shifted(b, dy, dx)
lo = np.minimum(lo, s)
hi = np.maximum(hi, s)
return ((a < lo - tolerance) | (a > hi + tolerance)).any(axis=2)
def box_count(mask):
# Mismatching pixels in each pixel's 3x3 block, itself included.
m = mask.astype(np.int8)
total = np.zeros(mask.shape, np.int16)
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
total += shifted(m, dy, dx)
return total
def block_means(img, block):
h, w = img.shape[0] // block * block, img.shape[1] // block * block
v = img[:h, :w].astype(np.float32).reshape(h // block, block, w // block, block, 3)
return v.mean(axis=(1, 3))
def compare(a, b, args):
h, w = min(a.shape[0], b.shape[0]), min(a.shape[1], b.shape[1])
if args.crop:
h, w = min(h, args.crop[1]), min(w, args.crop[0])
a, b = a[:h, :w], b[:h, :w]
mismatch = unmatched(a, b, args.radius, args.tolerance) | unmatched(b, a, args.radius, args.tolerance)
area = mismatch & (box_count(mismatch) >= args.min_neighbors)
blocks = np.abs(block_means(a, args.block) - block_means(b, args.block)).max(axis=2) > args.block_tolerance
bias = (b.astype(np.float32) - a).reshape(-1, 3).mean(axis=0)
return a, b, area, blocks, bias
def heatmap(path, a, b, area, blocks, block):
out = (np.concatenate([a, b], axis=1) // 2).astype(np.uint8)
mark = np.zeros(a.shape[:2], np.uint8)
by, bx = np.nonzero(blocks)
for y, x in zip(by, bx):
mark[y * block:(y + 1) * block, x * block:(x + 1) * block] = 1
mark[area] = 2
diff = (a // 4).astype(np.uint8)
diff[mark == 1] = (200, 200, 0)
diff[mark == 2] = (255, 0, 0)
Image.fromarray(np.concatenate([out, diff], axis=1)).save(path)
def pairs(a, b):
if os.path.isdir(a) and os.path.isdir(b):
names = sorted(n for n in os.listdir(a) if n.lower().endswith('.png') and os.path.exists(os.path.join(b, n)))
return [(n, os.path.join(a, n), os.path.join(b, n)) for n in names]
return [(os.path.basename(a), a, b)]
def compare_pair(pair, args):
name, pa, pb = pair
try:
a, b, area, blocks, bias = compare(load(pa), load(pb), args)
except Exception as e:
print('%s: %s' % (name, e), file=sys.stderr)
return None
if args.heatmaps and (area.any() or blocks.any()):
heatmap(os.path.join(args.heatmaps, name), a, b, area, blocks, args.block)
return name, int(area.sum()), int(blocks.sum()), int(blocks.size), bias
def main():
ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
ap.add_argument('a', help='image, or directory of PNGs')
ap.add_argument('b', help='image, or directory with PNGs of the same names')
ap.add_argument('--tolerance', type=int, default=12, help='per-channel difference a matching pixel may have (default 12)')
ap.add_argument('--radius', type=int, default=1, help='how far to look for a matching pixel (default 1)')
ap.add_argument('--min-neighbors', type=int, default=5, help='mismatching pixels a 3x3 block needs for its center to count (default 5)')
ap.add_argument('--block', type=int, default=8, help='low-frequency block size (default 8)')
ap.add_argument('--block-tolerance', type=float, default=8.0, help='per-channel difference of block averages (default 8)')
ap.add_argument('--crop', type=int, nargs=2, metavar=('W', 'H'), default=(480, 272), help='compare only the top left W x H (default 480 272, the PSP screen)')
ap.add_argument('--heatmaps', metavar='DIR', help='write NAME.png side by side: A, B, and the counted differences')
ap.add_argument('--sort', action='store_true', help='list the most different first')
ap.add_argument('--all', action='store_true', help='list equivalent pairs too')
ap.add_argument('-j', type=int, default=os.cpu_count(), help='pairs compared at once (default: all cores)')
args = ap.parse_args()
if args.heatmaps:
os.makedirs(args.heatmaps, exist_ok=True)
with concurrent.futures.ProcessPoolExecutor(args.j) as ex:
results = [r for r in ex.map(compare_pair, pairs(args.a, args.b), itertools.repeat(args), chunksize=4) if r]
if args.sort:
results.sort(key=lambda r: (-r[1], -r[2]))
differing = 0
for name, area, blocks, nblocks, bias in results:
if area or blocks:
differing += 1
if area or blocks or args.all:
print('%7d px %5d/%d blocks bias %+5.1f %+5.1f %+5.1f %s' % (area, blocks, nblocks, bias[0], bias[1], bias[2], name))
print('%d compared, %d differ' % (len(results), differing))
if __name__ == '__main__':
main()