summaryrefslogtreecommitdiff
path: root/scripts/analyze-p47.py
blob: ae9ade19edff3057b3c575a67b33dc99993e296c (plain)
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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
#!/usr/bin/env python3
"""P47 scene-detail analysis.

Reads the `LV_P47_SHOT=1` day/night/closeup frames + <record-dir>/log.txt and writes
docs/evidence/p47-analysis.txt with:

  1. reflection luma correlation (bank column profile vs vertically-mirrored water
     band) and reflection-vs-control-water luma delta;
  2. element-count metrics (bank reeds / floating props / reflection streaks);
  3. no big black block (min 64px square) / no white block / opaque frames.

Usage:
    python3 scripts/analyze-p47.py <record-dir> <out-txt>
"""

import re
import sys
from pathlib import Path

import numpy as np
from PIL import Image

TILE = 32
MIN_BLOCK = 64
REFLECTION_CORR_MIN = 0.20
REFLECTION_DELTA_MIN = 10.0
REFLECTION_RE = re.compile(r"p47-reflection src=(\d+),(\d+) dst=(\d+),(\d+) alpha=(\d+)")
ELEMENTS_RE = re.compile(r"p47-elements reeds=(\d+) floats=(\d+) reflections=(\d+) sources=(\d+)")


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 has_solid_block(mask, min_side):
    rows, cols = mask.shape
    if rows < min_side or cols < min_side:
        return None
    integral = np.zeros((rows + 1, cols + 1), dtype=np.int64)
    integral[1:, 1:] = mask.astype(np.int64).cumsum(0).cumsum(1)
    for y in range(0, rows - min_side + 1):
        for x in range(0, cols - min_side + 1):
            total = (integral[y + min_side, x + min_side] - integral[y, x + min_side]
                     - integral[y + min_side, x] + integral[y, x])
            if total == min_side * min_side:
                return (x, y)
    return None


def main(argv):
    if len(argv) != 3:
        sys.stderr.write(__doc__)
        return 2
    record_dir = Path(argv[1])
    out_txt = Path(argv[2])
    log_text = (record_dir / "log.txt").read_text(encoding="utf-8")
    frames = [n for n in ["p47-day", "p47-night", "p47-close"] if (record_dir / f"{n}.png").exists()]
    if len(frames) < 3:
        sys.stderr.write(f"expected p47-day/night/close, found {frames}\n")
        return 1

    images = {n: luma(record_dir / f"{n}.png") for n in frames}
    failures = []
    lines = ["P47 scene-detail analysis (LV_P47_SHOT=1)", ""]

    # visible reflections (unique source tiles) and their dst tiles.
    seen = set()
    reflections = []
    for line in log_text.splitlines():
        m = REFLECTION_RE.search(line)
        if m:
            sx, sy, dx, dy, alpha = (int(m.group(i)) for i in range(1, 6))
            if (sx, sy, dx, dy) in seen:
                continue
            seen.add((sx, sy, dx, dy))
            reflections.append((sx, sy, dx, dy, alpha))

    # 1) reflection luma correlation: bank column profile vs vertically-mirrored band.
    lines.append("1) 倒影区 luma 相关性 (bank column profile vs vertically-mirrored water band)")
    for name in ["p47-day", "p47-night"]:
        img = images[name]
        above, below = [], []
        used = set()
        for (sx, sy, _, _, _) in reflections:
            if (sx, sy) in used:
                continue
            used.add((sx, sy))
            half, up, down = 64, TILE, 3 * TILE
            if sx - half < 0 or sx + half > img.shape[1] or sy + up + down > img.shape[0]:
                continue
            above.append(img[sy:sy + up, sx - half:sx + half].mean(axis=0))
            below.append(img[sy + up:sy + up + down, sx - half:sx + half].mean(axis=0))
        if not above:
            failures.append(f"{name}: no fully-visible reflection to correlate")
            lines.append(f"   {name}: no fully-visible reflection")
            continue
        a = np.concatenate(above)
        b = np.concatenate(below)
        a = a - a.mean()
        b = b - b.mean()
        r = float(np.corrcoef(a, b)[0, 1]) if a.std() > 0 and b.std() > 0 else 0.0
        lines.append(f"   {name}: pearson r = {r:.3f} (required >= {REFLECTION_CORR_MIN})")
        if r < REFLECTION_CORR_MIN:
            failures.append(f"{name} reflection correlation {r:.3f} < {REFLECTION_CORR_MIN}")

    # reflection adds warm light vs neighbouring open water.
    night = images["p47-night"]
    deltas = []
    for (_, _, dx, dy, _) in reflections:
        if dy + TILE > night.shape[0] or dx - 96 < 0:
            continue
        refl = night[dy:dy + TILE, dx:dx + TILE].mean()
        ctrl = night[dy:dy + TILE, dx - 96:dx - 96 + TILE].mean()
        deltas.append(float(refl - ctrl))
    if deltas:
        lines.append(f"   reflection vs control water luma delta (night) = {np.mean(deltas):.2f} (required >= {REFLECTION_DELTA_MIN})")
        if float(np.mean(deltas)) < REFLECTION_DELTA_MIN:
            failures.append(f"reflection luma delta {np.mean(deltas):.2f} < {REFLECTION_DELTA_MIN}")
    else:
        failures.append("no visible reflection tile to measure luma delta")
    lines.append("")

    # 2) element-count metrics.
    lines.append("2) 元素数量指标")
    seen_counts = set()
    for line in log_text.splitlines():
        m = ELEMENTS_RE.search(line)
        if m:
            counts = tuple(int(m.group(i)) for i in range(1, 5))
            if counts in seen_counts:
                continue
            seen_counts.add(counts)
            lines.append(f"   reeds={counts[0]} floats={counts[1]} reflections={counts[2]} sources={counts[3]}")
    if not seen_counts:
        failures.append("no p47-elements line in log")
    else:
        reeds, floats, refl, sources = next(iter(seen_counts))
        if min(reeds, floats, refl, sources) <= 0:
            failures.append("element counts must all be positive")
    lines.append("")

    # 3) no big black block / no white block / opaque.
    lines.append("3) 无大黑块 / 无白块 / 不透明校验")
    names = ["p47-day", "p47-night", "p47-close"]
    for name in names:
        a = np.asarray(Image.open(record_dir / f"{name}.png").convert("RGBA"), dtype=np.uint8)
        rgb = a[:, :, :3]
        alpha_min = int(a[:, :, 3].min())
        black = (rgb.max(axis=2) < 6)
        white = (rgb.min(axis=2) >= 250)
        bb = has_solid_block(black, MIN_BLOCK)
        wb = has_solid_block(white, MIN_BLOCK)
        lines.append(f"   {name}: alpha_min={alpha_min} black_px={int(black.sum())} black_block={bb} white_block={wb}")
        if alpha_min < 250:
            failures.append(f"{name} has transparent pixels (alpha_min {alpha_min})")
        if bb is not None:
            failures.append(f"{name} has a {MIN_BLOCK}x{MIN_BLOCK} black block at {bb}")
        if wb is not None:
            failures.append(f"{name} has a {MIN_BLOCK}x{MIN_BLOCK} white block at {wb}")
    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))