#!/usr/bin/env python3 """P68(512×384 整图渲染截图证据)分析:读 headless 离线渲染的整图 PNG + 实跑日志。 判据: 1. 尺寸:PNG 宽高 = 512×scale × 384×scale(scale>=8),日志 `p68-map` 一致。 2. 河流/主路/农田/民居像素存在性:按 MapSnapshot 调色板精确计数 (water / paddy+vegetable / road / path / bridge / roof / grove 均 > 0)。 3. 无纯白/纯黑大块(64x64 全白、24x24 全黑)。 4. missing_glyph_slots=0(本图为纯地图,无 UI 文案帧)。 5. 确定性:日志 `deterministic=true`。 Usage: python3 scripts/analyze-p68.py Exit 0 pass / 1 fail / 2 usage. """ import re import sys from pathlib import Path import numpy as np from PIL import Image MAP_RE = re.compile( r"^p68-map width=(\d+) height=(\d+) scale=(\d+) png=(\d+)x(\d+) bytes=(\d+)$", re.MULTILINE, ) TILES_RE = re.compile( r"^p68-tiles grass=(\d+) water=(\d+) stone=(\d+) peat=(\d+) paddy=(\d+) vegetable=(\d+)$", re.MULTILINE, ) FEATURES_RE = re.compile( r"^p68-features rivers=(\d+) bridges=(\d+) paths=(\d+) buildings=(\d+) farmhouses=(\d+) groves=(\d+) road_columns=(\d+)$", re.MULTILINE, ) RENDER_RE = re.compile(r"^p68-render deterministic=(\w+) sha256=([0-9A-F]+)$", re.MULTILINE) PALETTE = { "grass": (86, 140, 74), "water": (52, 110, 180), "stone": (150, 150, 150), "peat": (110, 96, 72), "paddy": (120, 168, 84), "vegetable": (154, 186, 96), "path": (196, 176, 120), "road": (214, 190, 128), "bridge": (140, 96, 64), "roof": (58, 58, 70), "door": (120, 72, 40), "grove": (34, 96, 52), } DARK_SIDE = 24 DARK_MAX = 8 WHITE_SIDE = 64 WHITE_MIN = 250 def max_block(rgba, side, predicate): h, w = rgba.shape[0], rgba.shape[1] if h < side or w < side: return 0 mask = predicate(rgba).astype(np.int64) integral = np.zeros((h + 1, w + 1), dtype=np.int64) integral[1:, 1:] = mask.cumsum(axis=0).cumsum(axis=1) best = 0 for y in range(0, h - side + 1): for x in range(0, w - side + 1): total = ( integral[y + side, x + side] - integral[y, x + side] - integral[y + side, x] + integral[y, x] ) best = max(best, int(total)) return best def main(): if len(sys.argv) != 3: print(__doc__) return 2 rec = Path(sys.argv[1]) out = Path(sys.argv[2]) log = (rec / "log.txt").read_text(encoding="utf-8", errors="replace") failures = [] lines = [ "P68(512×384 整图渲染截图证据)分析 — headless 离线渲染(无图形设备)", "钩子:LV_P68_MAPSHOT=1(MapGen.paramsForSize 512 384 seed=4242 → MapSnapshot 整图 PNG)", "", ] m = MAP_RE.search(log) if not m: failures.append("log missing p68-map") else: width, height, scale, pw, ph, nbytes = (int(v) for v in m.groups()) lines.append(f"log: {m.group(0)}") if (width, height) != (512, 384): failures.append(f"map {width}x{height} != 512x384") if scale < 8: failures.append(f"scale {scale} < 8 (每瓦片像素不足)") if (pw, ph) != (width * scale, height * scale): failures.append(f"png {pw}x{ph} != {width * scale}x{height * scale}") lines.append("") m = TILES_RE.search(log) if not m: failures.append("log missing p68-tiles") tile_counts = None else: tile_counts = {k: int(v) for k, v in zip( ["grass", "water", "stone", "peat", "paddy", "vegetable"], m.groups())} lines.append("log: " + m.group(0)) m = FEATURES_RE.search(log) if not m: failures.append("log missing p68-features") else: lines.append("log: " + m.group(0)) lines.append("") m = RENDER_RE.search(log) if not m: failures.append("log missing p68-render") else: deterministic, sha = m.groups() lines.append(f"log: {m.group(0)}") if deterministic != "true": failures.append("render not deterministic") if len(sha) != 64: failures.append(f"sha256 length {len(sha)} != 64") lines.append("") png = rec / "p68-map.png" if not png.exists(): failures.append("missing p68-map.png") else: rgb = np.asarray(Image.open(png).convert("RGB"), dtype=np.uint8) rgba = np.asarray(Image.open(png).convert("RGBA"), dtype=np.uint8) h, w = rgb.shape[0], rgb.shape[1] lines.append(f"p68-map.png {w}x{h}") if (w, h) != (512 * 8, 384 * 8): failures.append(f"decoded png {w}x{h} != 4096x3072") flat = rgb.reshape(-1, 3) counts = {} for name, color in PALETTE.items(): counts[name] = int((flat == np.array(color, dtype=np.uint8)).all(axis=1).sum()) lines.append("palette pixel counts: " + " ".join(f"{k}={v}" for k, v in counts.items())) for name in ("water", "paddy", "vegetable", "road", "path", "bridge", "roof", "grove"): if counts[name] == 0: failures.append(f"missing {name} pixels") if counts["paddy"] + counts["vegetable"] == 0: failures.append("missing farmland (paddy+vegetable) pixels") if counts["roof"] == 0: failures.append("missing building roof pixels") if tile_counts is not None and tile_counts["water"] > 0: ratio = counts["water"] / (tile_counts["water"] * 64) lines.append(f"water pixel ratio (vs tile count) = {ratio:.3f}") if ratio < 0.5: failures.append(f"water ratio {ratio:.3f} < 0.5 (river pixels missing)") dark = max_block( rgba, DARK_SIDE, lambda a: (a[:, :, 0] <= DARK_MAX) & (a[:, :, 1] <= DARK_MAX) & (a[:, :, 2] <= DARK_MAX) & (a[:, :, 3] >= 250), ) white = max_block( rgba, WHITE_SIDE, lambda a: (a[:, :, 0] >= WHITE_MIN) & (a[:, :, 1] >= WHITE_MIN) & (a[:, :, 2] >= WHITE_MIN) & (a[:, :, 3] >= 250), ) lines.append(f"max_black_block={dark} max_white_block={white}") if dark != 0: failures.append(f"black block {dark}") if white >= WHITE_SIDE * WHITE_SIDE: failures.append(f"fully-white block {white}") lines.append("") lines.append("missing_glyph_slots=0(纯地图整图,无 UI 文案帧)") lines.append("") verdict = "PASS" if not failures else "FAIL" lines.append(f"verdict: {verdict}") for f in failures: lines.append(f"FAIL: {f}") out.write_text("\n".join(lines) + "\n", encoding="utf-8") print("\n".join(lines)) return 0 if not failures else 1 if __name__ == "__main__": sys.exit(main())