mirror of
https://github.com/hrydgard/ppsspp.git
synced 2026-10-14 23:10:33 +02:00
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>
174 lines
7.7 KiB
Python
Executable File
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()
|