diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-28 20:08:02 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-28 20:08:02 +0800 |
| commit | e55edb080153943f396f8b324b9412c540707b24 (patch) | |
| tree | 5181ccba94d773ab697539bc479cee220ea3a0fd /scripts | |
| parent | 8d339b4fb8e4f5d7710b99afb1e4b54341a8a72b (diff) | |
| download | living-village-e55edb080153943f396f8b324b9412c540707b24.tar.gz | |
p46: 菜单/启动环境动效 — 逐格涟漪(复用waterFrameTickAt)+灯笼呼吸+屋脊上双视差云影, 全纯函数可复现
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/analyze-p46.py | 158 |
1 files changed, 158 insertions, 0 deletions
diff --git a/scripts/analyze-p46.py b/scripts/analyze-p46.py new file mode 100644 index 0000000..a5828d5 --- /dev/null +++ b/scripts/analyze-p46.py @@ -0,0 +1,158 @@ +#!/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 <record-dir> <out-txt> + +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)) |
