1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
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))
|