#!/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))
