#!/usr/bin/env python3 """P57 menu/splash second water highlight layer analysis. Reads the `LV_P57_SHOT=1` frames (same pinned splash/menu frame captured once with the highlight layer off, once on) plus /log.txt, and writes docs/evidence/p57-analysis.txt with: * per-layer column profile std (main water band brightness vs highlight alpha) and their correlation, from the logged pure-function arrays; * adjacent-column phase steps for both layers (highlight must be phase-shifted and move at a slightly different rate); * pixel diff of base vs highlight frames (the second layer must be visible and confined to the water band), and a no-big-black/white-block sanity check. Hard verdict: highlight std >= main std * 0.3, layer correlation < 0.95, phase step > 0, pixel diff > threshold inside the water band, no large pure black/white blocks. Usage: python3 scripts/analyze-p57.py Exit 0 on PASS, 1 on FAIL, 2 on usage/read error. """ import re import sys from pathlib import Path import numpy as np from PIL import Image STATUS_RE = re.compile( r"p57-status=(\S+) pendingName=(\S+) disabled=(true|false) frame=(\d+) " r"main=([\d.,\-]+) hl=([\d.,\-]+) mainphase=([\d.,\-]+) hlphase=([\d.,\-]+)" ) HIGHLIGHT_STD_RATIO_MIN = 0.3 LAYER_CORR_MAX = 0.95 PIXEL_DIFF_MIN = 20.0 TOP_CLEAN_DIFF_MAX = 20.0 PURE_BLACK_MAX_FRACTION = 0.02 PURE_WHITE_MAX_FRACTION = 0.05 WATER_ROWS = (624, 656) TILE = 32 def fail(lines, out_txt, message): lines.append(f"FAIL: {message}") lines.append("") lines.append("verdict: FAIL") text = "\n".join(lines) + "\n" if out_txt is not None: out_txt.parent.mkdir(parents=True, exist_ok=True) out_txt.write_text(text, encoding="utf-8") print(text) return 1 def load(path): return np.asarray(Image.open(path).convert("RGB"), dtype=np.float64) def parse_floats(text): return [float(v) for v in text.split(",")] def main(argv): if len(argv) != 3: sys.stderr.write(__doc__) return 2 record_dir = Path(argv[1]) out_txt = Path(argv[2]) log_path = record_dir / "log.txt" if not log_path.exists(): sys.stderr.write(f"missing {log_path}\n") return 2 lines = ["P57 second water highlight layer analysis (LV_P57_SHOT=1)", ""] statuses = {} for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines(): match = STATUS_RE.search(line) if match: statuses[match.group(1)] = { "disabled": match.group(3) == "true", "frame": int(match.group(4)), "main": parse_floats(match.group(5)), "hl": parse_floats(match.group(6)), "mainphase": parse_floats(match.group(7)), "hlphase": parse_floats(match.group(8)), } wanted = [ ("splash", "p57-splash-base", "p57-splash-highlight"), ("menu", "p57-menu-base", "p57-menu-highlight"), ] for label, base_name, hl_name in wanted: if base_name not in statuses or hl_name not in statuses: return fail(lines, out_txt, f"missing log status for {base_name}/{hl_name}") base_status = statuses[base_name] hl_status = statuses[hl_name] if not base_status["disabled"] or hl_status["disabled"]: return fail(lines, out_txt, f"{label}: base must be disabled, highlight must be enabled") if base_status["frame"] != hl_status["frame"]: return fail(lines, out_txt, f"{label}: both captures must pin the same frame") main = np.array(hl_status["main"], dtype=np.float64) hl = np.array(hl_status["hl"], dtype=np.float64) main_phase = np.array(hl_status["mainphase"], dtype=np.float64) hl_phase = np.array(hl_status["hlphase"], dtype=np.float64) main_std = float(main.std()) hl_std = float(hl.std()) if hl_std <= 0.0 or main_std <= 0.0: return fail(lines, out_txt, f"{label}: column profiles must vary") corr = float(np.corrcoef(main, hl)[0, 1]) main_step = float(main_phase[1] - main_phase[0]) hl_step = float(hl_phase[1] - hl_phase[0]) lines += [ f"[{label}] frame={hl_status['frame']} columns={len(main)}", f"[{label}] main column std = {main_std:.4f}, highlight column std = {hl_std:.4f}", f"[{label}] highlight std / main std = {hl_std / main_std:.3f} (>= {HIGHLIGHT_STD_RATIO_MIN})", f"[{label}] layer correlation = {corr:.4f} (< {LAYER_CORR_MAX})", f"[{label}] adjacent phase step: main = {main_step:.4f} rad, highlight = {hl_step:.4f} rad", ] if hl_std < main_std * HIGHLIGHT_STD_RATIO_MIN: return fail(lines, out_txt, f"{label}: highlight std {hl_std:.4f} < main std * {HIGHLIGHT_STD_RATIO_MIN}") if not (corr < LAYER_CORR_MAX): return fail(lines, out_txt, f"{label}: layer correlation {corr:.4f} is not < {LAYER_CORR_MAX}") if abs(hl_step) < 1e-3: return fail(lines, out_txt, f"{label}: adjacent highlight columns are not phase-shifted") base_img = load(record_dir / f"{base_name}.png") hl_img = load(record_dir / f"{hl_name}.png") if base_img.shape != hl_img.shape: return fail(lines, out_txt, f"{label}: frame sizes differ") diff = np.abs(hl_img - base_img).mean(axis=2) band = diff[WATER_ROWS[0]:WATER_ROWS[1] + TILE, :] top = diff[:300, :] band_mean = float(band.mean()) band_max = float(band.max()) band_pixels = int((band > 10.0).sum()) top_max = float(top.max()) lines += [ f"[{label}] pixel diff: water band mean={band_mean:.3f} max={band_max:.1f} " f"pixels>10={band_pixels}, top max={top_max:.1f}", ] if band_max < 20.0 or band_pixels < 50: return fail(lines, out_txt, f"{label}: the highlight layer is not visible in the water band") if top_max > 20.0: return fail(lines, out_txt, f"{label}: highlight leaked outside the water band") for tag, img in (("base", base_img), ("highlight", hl_img)): std = float(img.std()) black = float((img.max(axis=2) <= 1.0).mean()) white = float((img.min(axis=2) >= 254.0).mean()) lines.append( f"[{label}] {tag}: std={std:.1f} pure_black={black:.4%} pure_white={white:.4%}" ) if std < 5.0: return fail(lines, out_txt, f"{label} {tag}: frame looks blank") if black > PURE_BLACK_MAX_FRACTION: return fail(lines, out_txt, f"{label} {tag}: large pure-black block ({black:.2%})") if white > PURE_WHITE_MAX_FRACTION: return fail(lines, out_txt, f"{label} {tag}: large pure-white block ({white:.2%})") lines.append("") lines.append("verdict: PASS") text = "\n".join(lines) + "\n" out_txt.parent.mkdir(parents=True, exist_ok=True) out_txt.write_text(text, encoding="utf-8") print(text) return 0 if __name__ == "__main__": sys.exit(main(sys.argv))