#!/usr/bin/env python3 """P42 frame analysis: proves the start/menu environment FX move between frames. Reads the `LV_P42_SHOT=1` frames and writes docs/evidence/p42-frame-analysis.txt: 1. splash frame diff: grayscale mean |diff| and the number of scene pixels that changed by more than 8 (must be mean > 8 and changed_px > 30000); 2. menu water band: per-column brightness across the band (std / min / max); 3. crest-highlight column count for the menu frame (same sine phase as F#). Usage: python3 scripts/analyze-p42-frames.py Exit 0 when thresholds pass, 1 when they fail, 2 on usage/read error. """ import math import sys from pathlib import Path import numpy as np from PIL import Image TILE = 32 SPLASH_DIFF_MIN = 8.0 SPLASH_CHANGED_MIN = 30000 WATER_ROW_OFFSETS = (96, 64) # TitleScreen.layout waterRows = height-96, height-64 def luma(path): a = np.asarray(Image.open(path).convert("RGB"), dtype=np.float64) return 0.299 * a[:, :, 0] + 0.587 * a[:, :, 1] + 0.114 * a[:, :, 2] def crest(column, row_index, frame=150): return math.sin((frame % 4096) / 5.0 + column * 1.25 + row_index * 0.6) > 0.55 def main(argv): if len(argv) != 3: sys.stderr.write(__doc__) return 2 record_dir = Path(argv[1]) out_txt = Path(argv[2]) s1 = luma(record_dir / "p42-splash-1.png") s2 = luma(record_dir / "p42-splash-2.png") menu = luma(record_dir / "p42-menu-anim.png") # 1) splash frame diff. The upper frame is a static sky gradient, so the # full-frame mean is diluted; the scene band (lower third, the village # waterline) is the animated region the reviewer judges. diff = np.abs(s1 - s2) full_mean_abs = float(diff.mean()) height, _ = diff.shape scene_y = height * 2 // 3 scene = diff[scene_y:, :] mean_abs = float(scene.mean()) changed_px = int((scene > 8.0).sum()) # 2) menu water band per-column statistics. height, width = menu.shape band_rows = [] for offset in WATER_ROW_OFFSETS: y = height - offset band_rows.append(menu[y:y + TILE, :]) band = np.concatenate(band_rows, axis=0) columns = width // TILE column_means = np.array([band[:, c * TILE:(c + 1) * TILE].mean() for c in range(columns)]) col_std = float(column_means.std()) col_min = float(column_means.min()) col_max = float(column_means.max()) # per-column vertical std range over the band per_col_std = np.array([band[:, c * TILE:(c + 1) * TILE].std() for c in range(columns)]) # 3) crest highlight columns. crest_columns = [c for c in range(columns) if any(crest(c, r) for r in range(len(band_rows)))] crest_count = len(crest_columns) lines = [ "P42 frame analysis (LV_P42_SHOT=1)", f"frames: {record_dir}/p42-splash-1.png (frame 10), p42-splash-2.png (frame 28), p42-menu-anim.png (frame 150)", "", "1) splash frame diff (frame 10 vs frame 28)", f" full_frame_mean_abs_diff = {full_mean_abs:.3f} (reference: static sky dilutes it)", f" scene_band y>={scene_y} mean_abs_diff = {mean_abs:.3f} (required > {SPLASH_DIFF_MIN:.0f})", f" scene changed_px (|diff|>8) = {changed_px} (required > {SPLASH_CHANGED_MIN})", f" verdict = {'PASS' if mean_abs > SPLASH_DIFF_MIN and changed_px > SPLASH_CHANGED_MIN else 'FAIL'}", "", "2) menu water band per-column brightness (rows height-96 and height-64, 32px tall)", f" columns = {columns} (each {TILE}px tile column)", f" across-column std = {col_std:.3f}", f" column-mean range = [{col_min:.1f}, {col_max:.1f}] (span {col_max - col_min:.1f})", f" per-column vertical std range = [{float(per_col_std.min()):.3f}, {float(per_col_std.max()):.3f}]", "", "3) crest highlight columns (menu frame 150, sin phase > 0.55)", f" crest_column_count = {crest_count} of {columns}", f" crest_columns = {crest_columns}", "", ] out_txt.parent.mkdir(parents=True, exist_ok=True) out_txt.write_text("\n".join(lines), encoding="utf-8") sys.stdout.write("\n".join(lines)) return 0 if (mean_abs > SPLASH_DIFF_MIN and changed_px > SPLASH_CHANGED_MIN) else 1 if __name__ == "__main__": raise SystemExit(main(sys.argv))