summaryrefslogtreecommitdiff
path: root/scripts/analyze-p46.py
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-28 20:08:02 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-28 20:08:02 +0800
commite55edb080153943f396f8b324b9412c540707b24 (patch)
tree5181ccba94d773ab697539bc479cee220ea3a0fd /scripts/analyze-p46.py
parent8d339b4fb8e4f5d7710b99afb1e4b54341a8a72b (diff)
downloadliving-village-e55edb080153943f396f8b324b9412c540707b24.tar.gz
p46: 菜单/启动环境动效 — 逐格涟漪(复用waterFrameTickAt)+灯笼呼吸+屋脊上双视差云影, 全纯函数可复现
Diffstat (limited to 'scripts/analyze-p46.py')
-rw-r--r--scripts/analyze-p46.py158
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))