Files
retroDE_ps2/tools/gs_frame_metric.py
thejayman77 ba74bbd5aa Snapshot: fog implementation + fidelity tooling baseline (pre bilinear-clamp fix)
Per-vertex GS fog end-to-end (gs_stub emit incl. persp_emit5, gs_prim_list_feeder
XYZ2->XYZF2 on PRIM.FGE, gs_make_sh3_scheduler_fixture.py F/FGE packing), new fog
TBs, fidelity attribution tooling. Functional baseline before removing the dead
bilinear lerp8 clamps (Codex: 161-node comb loop -> -0.042ns setup fail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:56:46 -04:00

124 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""retroDE_ps2 — quantitative fidelity metric: our rendered frame vs the real PCSX2 frame (reference C).
Turns "does it look like SH3?" into numbers. Consumes any 640x480 RGB(A) PNG we produce (software composite B
from gs_sh3_frame_ref.py, or a framebuffer dumped from an RTL sim) and a PCSX2 screenshot (C), and reports:
- GLOBAL: MSE, PSNR, mean-abs-error, %pixels within a per-channel tolerance.
- PER-REGION: a GxH tile grid of per-tile MSE, the worst-N tiles ranked, and a diff heatmap PNG so the
deficit is ATTRIBUTED (which part of the screen, how badly) instead of eyeballed.
- Optionally restricted to PAINTED pixels only (alpha>0 in ours) so background fill doesn't dilute the score.
Reference C is the display output of the real GS; our composite is opaque-textures-only (no GS blend/fog/light),
so a perfect score is NOT expected. The value is the ATTRIBUTION: a high-error tile localized to, say, the
alpha-blended fog band tells us blending is the next rung; uniformly high error would implicate reciprocal/UV.
Alignment: C is resized to 640x480. PCSX2 NTSC output may be cropped/offset a few px vs GS screen space; use
--search P to brute-force the best integer (dx,dy) shift in [-P..P] (minimising global MSE) before scoring.
Usage:
gs_frame_metric.py --ours frameN_composite.png --ref pcsx2_ref.png [--grid 16x12] [--worst 12]
[--tol 16] [--painted-only] [--search 4] [--out DIR]
"""
import sys, os
def load_rgb(path, resize=None):
from PIL import Image
img = Image.open(path).convert("RGBA")
if resize and img.size != resize:
img = img.resize(resize, Image.BILINEAR)
return img
def main(argv):
if len(argv) < 2 or "--ours" not in argv or "--ref" not in argv:
print(__doc__); return 2
def opt(n, dv=None): return argv[argv.index(n)+1] if n in argv else dv
ours_p = opt("--ours"); ref_p = opt("--ref")
gx, gy = (int(v) for v in opt("--grid", "16x12").lower().split("x"))
worst_n = int(opt("--worst", "12")); tol = int(opt("--tol", "16"))
search = int(opt("--search", "0")); painted_only = ("--painted-only" in argv)
outdir = opt("--out", os.path.dirname(os.path.abspath(ours_p)))
os.makedirs(outdir, exist_ok=True)
from PIL import Image
ours = load_rgb(ours_p)
W, H = ours.size
ref = load_rgb(ref_p, resize=(W, H))
op = ours.load(); rp = ref.load()
def scored_pixels(dx, dy):
"""Yield (x,y,(or,og,ob),(rr,rg,rb)) for pixels compared at shift (dx,dy)."""
for y in range(H):
ry = y + dy
if ry < 0 or ry >= H: continue
for x in range(W):
rx = x + dx
if rx < 0 or rx >= W: continue
o = op[x, y]
if painted_only and o[3] == 0: continue
yield x, y, o[:3], rp[rx, ry][:3]
def mse_at(dx, dy):
s = n = 0
for _, _, o, r in scored_pixels(dx, dy):
s += (o[0]-r[0])**2 + (o[1]-r[1])**2 + (o[2]-r[2])**2; n += 1
return (s/(3*n) if n else float("inf")), n
# optional integer-shift alignment
bdx = bdy = 0
if search > 0:
best = None
for dy in range(-search, search+1):
for dx in range(-search, search+1):
m, n = mse_at(dx, dy)
if best is None or m < best[0]: best = (m, dx, dy)
_, bdx, bdy = best
print(f"[metric] best alignment shift dx={bdx} dy={bdy} (searched +/-{search})")
# global stats + per-tile accumulation at the chosen shift
tiles_se = [[0]*gx for _ in range(gy)]
tiles_n = [[0]*gx for _ in range(gy)]
tw, th = W/gx, H/gy
S = Nabs = 0; N = 0; within = 0
diff = Image.new("RGB", (W, H))
dp = diff.load()
for x, y, o, r in scored_pixels(bdx, bdy):
e = [abs(o[i]-r[i]) for i in range(3)]
se = e[0]**2 + e[1]**2 + e[2]**2
S += se; Nabs += e[0]+e[1]+e[2]; N += 1
if max(e) <= tol: within += 1
gxi = min(gx-1, int(x/tw)); gyi = min(gy-1, int(y/th))
tiles_se[gyi][gxi] += se; tiles_n[gyi][gxi] += 1
# heatmap: brighter red = larger per-pixel error (scaled so 0..441 -> 0..255)
mag = min(255, int((se**0.5)))
dp[x, y] = (mag, 0, 128-min(128, mag//2))
if N == 0:
print("[metric] no comparable pixels (painted-only with empty frame?)"); return 1
mse = S/(3*N); import math
psnr = 10*math.log10((255.0**2)/mse) if mse > 0 else float("inf")
print(f"[metric] compared {N} px ({'painted-only' if painted_only else 'all'}), grid {gx}x{gy}, tol +/-{tol}/ch")
print(f"[metric] MSE={mse:.2f} PSNR={psnr:.2f} dB MAE={Nabs/(3*N):.2f}/ch within-tol={100*within/N:.1f}%")
tile_mse = []
for gyi in range(gy):
for gxi in range(gx):
n = tiles_n[gyi][gxi]
if n: tile_mse.append((tiles_se[gyi][gxi]/(3*n), gxi, gyi, n))
tile_mse.sort(reverse=True)
print(f"[metric] worst {min(worst_n,len(tile_mse))} tiles (col,row of {gx}x{gy} grid) by MSE:")
for m, gxi, gyi, n in tile_mse[:worst_n]:
px0, py0 = int(gxi*tw), int(gyi*th)
print(f" tile(c{gxi:2d},r{gyi:2d}) screen x[{px0}..{px0+int(tw)}] y[{py0}..{py0+int(th)}] MSE={m:.1f} n={n}")
base = os.path.splitext(os.path.basename(ours_p))[0]
hm = os.path.join(outdir, f"{base}_vs_ref_heatmap.png"); diff.save(hm)
# triptych: ours | ref | heatmap
trip = Image.new("RGB", (W*3+16, H), (30, 30, 30))
trip.paste(ours.convert("RGB"), (0, 0)); trip.paste(ref, (W+8, 0)); trip.paste(diff, (W*2+16, 0))
tp = os.path.join(outdir, f"{base}_vs_ref_triptych.png"); trip.save(tp)
print(f"[metric] wrote {hm}\n[metric] wrote {tp} (ours | C | error-heatmap)")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))