#!/usr/bin/env python3 """P46 menu/splash environment-FX analysis. Reads the `LV_P46_SHOT=1` frames and writes docs/evidence/p46-analysis.txt with the three acceptance numbers, each measured from real screenshots: 1. water band per-column std (逐格涟漪: splash ripple frame + menu night frames); 2. lantern luma delta (灯笼呼吸两相位: menu peak vs trough); 3. cloud shadow shift px (屋脊上方视差云影: cross-correlation of the sky band), plus a no-black-block / opaque check on every frame. Usage: python3 scripts/analyze-p46.py Exit 0 when all thresholds pass, 1 when they fail, 2 on usage/read error. """ import sys from pathlib import Path import numpy as np from PIL import Image TILE = 32 WATER_ROW_OFFSETS = (96, 64) # TitleScreen.layout waterRows = height-96, height-64 SKY_BAND = 44 # clouds live above TitleScreen.layout RidgeY = 44 WATER_COL_STD_MIN = 3.0 LANTERN_LUMA_DELTA_MIN = 1.5 CLOUD_SHIFT_TOL = 3 # Mirrors MenuAmbience.cloud*: two layers, speeds 0.5 / 1.0 px per frame, alpha 12 / 30. CLOUD_SPEEDS = (0.5, 1.0) CLOUD_ALPHAS = (12, 30) CLOUD_EARLY_FRAME = 100 CLOUD_LATE_FRAME = 140 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 rgba(path): return np.asarray(Image.open(path).convert("RGBA"), dtype=np.uint8) def water_col_std(image, height, width): band = np.concatenate( [image[height - off:height - off + TILE, :] for off in WATER_ROW_OFFSETS], axis=0 ) columns = np.array([band[:, c * TILE:(c + 1) * TILE].mean() for c in range(width // TILE)]) return float(columns.std()), float(columns.min()), float(columns.max()) def expected_cloud_offsets(width): span = width + 200 offsets = [] for speed in CLOUD_SPEEDS: early = int(round(speed * CLOUD_EARLY_FRAME)) late = int(round(speed * CLOUD_LATE_FRAME)) offsets.append(((late - early) % span + span) % span) return offsets def main(argv): if len(argv) != 3: sys.stderr.write(__doc__) return 2 record_dir = Path(argv[1]) out_txt = Path(argv[2]) splash = luma(record_dir / "p46-splash-ripple.png") menu_a = luma(record_dir / "p46-menu-lantern-a.png") menu_b = luma(record_dir / "p46-menu-lantern-b.png") cloud_a = luma(record_dir / "p46-cloud-a.png") cloud_b = luma(record_dir / "p46-cloud-b.png") height, width = splash.shape failures = [] lines = ["P46 menu/splash environment FX analysis (LV_P46_SHOT=1)", ""] # 1) water band per-column std s_std, s_min, s_max = water_col_std(splash, height, width) a_std, a_min, a_max = water_col_std(menu_a, height, width) b_std, b_min, b_max = water_col_std(menu_b, height, width) lines.append("1) 水面逐格涟漪 — per-column brightness std over the 2x32px water band") lines.append(f" splash ripple frame: col_std={s_std:.3f} range=[{s_min:.1f}, {s_max:.1f}] (required >= {WATER_COL_STD_MIN})") lines.append(f" menu lantern-a : col_std={a_std:.3f} range=[{a_min:.1f}, {a_max:.1f}]") lines.append(f" menu lantern-b : col_std={b_std:.3f} range=[{b_min:.1f}, {b_max:.1f}]") menu_water_diff = float(np.abs(menu_a[height - 96:height - 64, :] - menu_b[height - 96:height - 64, :]).mean()) lines.append(f" menu water row frame diff (a vs b) mean |d| = {menu_water_diff:.3f}") if s_std < WATER_COL_STD_MIN: failures.append(f"splash water col_std {s_std:.3f} < {WATER_COL_STD_MIN}") if a_std < WATER_COL_STD_MIN: failures.append(f"menu water col_std {a_std:.3f} < {WATER_COL_STD_MIN}") lines.append("") # 2) lantern luma delta. Bank lantern index 1 -> x = width/5, y = (height-96)-44. lx, ly = width // 5, (height - 96) - 44 box = (slice(ly - 44, ly + 44), slice(lx - 44, lx + 44)) luma_a = float(menu_a[box].mean()) luma_b = float(menu_b[box].mean()) delta = abs(luma_a - luma_b) lines.append("2) 灯笼呼吸光晕 — mean luma in a 88x88 box around bank lantern #1") lines.append(f" lantern box = x[{lx-44},{lx+44}) y[{ly-44},{ly+44})") lines.append(f" phase a (peak) luma = {luma_a:.3f}") lines.append(f" phase b (trough) luma = {luma_b:.3f}") lines.append(f" luma delta = {delta:.3f} (required >= {LANTERN_LUMA_DELTA_MIN})") if delta < LANTERN_LUMA_DELTA_MIN: failures.append(f"lantern luma delta {delta:.3f} < {LANTERN_LUMA_DELTA_MIN}") lines.append("") # 3) cloud shadow shift px, cross-correlating the sky band (clouds only live up there). pa = cloud_a[:SKY_BAND, :].mean(axis=0) pb = cloud_b[:SKY_BAND, :].mean(axis=0) pa = pa - pa.mean() pb = pb - pb.mean() search = width // 8 sad = [float(np.mean((pb[s:] - pa[:width - s]) ** 2)) for s in range(search)] measured = int(np.argmin(sad)) expected = expected_cloud_offsets(width) dominant = int(np.argmax(CLOUD_ALPHAS)) lines.append("3) 屋脊上方视差云影 — horizontal shift of the sky band (0..43px)") lines.append(f" measured shift = {measured}px") lines.append(f" expected per layer (speeds {CLOUD_SPEEDS}, frames {CLOUD_EARLY_FRAME}->{CLOUD_LATE_FRAME}) = {expected}px") lines.append(f" dominant layer = {dominant} (alpha {CLOUD_ALPHAS[dominant]}) -> expected {expected[dominant]}px") if abs(measured - expected[dominant]) > CLOUD_SHIFT_TOL: failures.append(f"cloud shift {measured} != dominant layer {expected[dominant]} (tol {CLOUD_SHIFT_TOL})") if expected[0] == expected[1]: failures.append("parallax layers must have different speeds") lines.append("") # no black block / opaque guard on every evidence frame. names = ["p46-splash-ripple", "p46-cloud-a", "p46-cloud-b", "p46-menu-lantern-a", "p46-menu-lantern-b"] lines.append("4) 无大黑块 / 不透明校验 (every evidence frame)") for name in names: a = rgba(record_dir / f"{name}.png") alpha_min = int(a[:, :, 3].min()) black_px = int((a[:, :, :3].max(axis=2) < 6).sum()) lines.append(f" {name}: alpha_min={alpha_min} black_px(rgb<6)={black_px}") if alpha_min < 250: failures.append(f"{name} has transparent pixels (alpha_min {alpha_min})") if black_px > 0: failures.append(f"{name} has black pixels ({black_px})") lines.append("") lines.append("verdict = " + ("PASS" if not failures else "FAIL")) out_txt.parent.mkdir(parents=True, exist_ok=True) out_txt.write_text("\n".join(lines) + "\n", encoding="utf-8") sys.stdout.write("\n".join(lines) + "\n") for f in failures: sys.stderr.write(f"FAIL {f}\n") return 0 if not failures else 1 if __name__ == "__main__": raise SystemExit(main(sys.argv))