#!/usr/bin/env python3 """P64(A3 成熟度)证据分析:样板区俯瞰 / 桥近景 / 内景。 判据(沿用既有 analyze 脚本风格:读真实实跑日志 + 逐帧像素统计): 1. 计划计数(来自真实 renderer 使用的 VillageArt.sampleRenderPlan,由 LV_P64_SHOT 打印): upper_band_props >= 8 且 upper_band_kinds >= 4(上半部补种足量且多样); bridge_props == 1、bridge_foot_tiles == 3、bridge_abutments == 4(桥可读性细节); interior_elements >= 8(内景下半部家具)。 2. 俯瞰「上半部内容密度」:y∈[120,360] 非背景像素占比 >= 0.14 (P62 同带基线 0.103;HUD 顶部条带 y<120 不计,避免把 UI 当内容)。 3. 桥近景:桥所在区域 (x 560..720, y 360..500) 的「木色像素」占比 >= 0.20, 证明桥面/栏杆木色进入实拍帧。 4. 栏杆可辨(整改新增):以日志相机定位桥格(世界 tile x31,y29..31),取两侧栏杆条带 (相对桥格 x 偏移 1..6 与 26..31,纵向 3 格)。栏杆像素口径 = 亮色 (r>150,g>130,b>100) 或深色 (r<80,g<80,b<80)——纯桥面木色 (≈119,77,47) 两类都不满足,故该指标直接区分 「有栏杆」与「只有桥面木板」。要求左右两条带各自 rail_ratio >= 0.70。 在俯瞰帧(1x 全帧)与桥近景帧上都成立。 5. 三帧均 1280x720、非空、无 64x64 纯白块、无 24x24 纯黑块,且两两不同。 6. 缺字空白位:中文经 CjkGlyphAtlas 绘制,P61 已证覆盖(P61GlyphCoverageTests 绿)。 Usage: python3 scripts/analyze-p64.py Exit 0 pass / 1 fail / 2 usage. """ import re import sys from pathlib import Path import numpy as np from PIL import Image PLAN_RE = re.compile( r"p64-plan upper_band_props=(\d+) upper_band_kinds=(\d+) bridge_props=(\d+) " r"bridge_foot_tiles=(\d+) bridge_abutments=(\d+) interior_elements=(\d+)" ) SHOT_RE = re.compile( r"p64-shot=(\S+) tick=(\d+) avatar=\(([\-\d.]+),([\-\d.]+)\) " r"camera=\(([\-\d.]+),([\-\d.]+)\) home=(.*)$", re.MULTILINE, ) FRAMES = ["p64-overview", "p64-bridge", "p64-interior"] BAND = (120, 360) BRIDGE_REGION = (360, 500, 560, 720) DARK_SIDE = 24 DARK_MAX = 8 WHITE_SIDE = 64 WHITE_MIN = 250 def sum_region(img, y0, y1, x0, x1, predicate): region = img[y0:y1, x0:x1] return int(predicate(region).sum()) 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 non_background_ratio(img, y0, y1): band = img[y0:y1] _, counts = np.unique(band.reshape(-1, 3), axis=0, return_counts=True) return 1.0 - float(counts.max()) / float(counts.sum()) BRIDGE_TILE_X = 31 BRIDGE_TILE_Y0 = 29 BRIDGE_TILE_H = 3 RAIL_THRESHOLD = 0.70 def rail_strip_ratios(img, camx, camy): """Return (left_ratio, right_ratio) of rail-colored pixels in the two rail strips.""" bx = BRIDGE_TILE_X * 32 - camx y0 = BRIDGE_TILE_Y0 * 32 - camy y1 = y0 + BRIDGE_TILE_H * 32 results = [] for sx0 in (bx + 1, bx + 26): reg = img[y0:y1, sx0 : sx0 + 5].astype(np.int16) r, g, b = reg[:, :, 0], reg[:, :, 1], reg[:, :, 2] rail = ((r > 150) & (g > 130) & (b > 100)) | ((r < 80) & (g < 80) & (b < 80)) results.append(float(rail.mean()) if rail.size else 0.0) return results[0], results[1] 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 = [ "P64(A3 成熟度)证据分析 — 真实 Desktop 实跑 xvfb 1280x720", "钩子:LV_P64_SHOT=1 LV_LEGACY_MAP=1(可玩样板地图),3 帧:p64-overview / p64-bridge / p64-interior", "内景路径:真实 Sim.step 走向门口 → 真实 Interact 进门;桥位置/通行不变,仅增强可读性", "", ] m = PLAN_RE.search(log) if not m: failures.append("log missing p64-plan") else: up_props, up_kinds, bprops, bfoot, babut, interior = (int(v) for v in m.groups()) lines.append( f"plan: upper_band_props={up_props} upper_band_kinds={up_kinds} " f"bridge_props={bprops} bridge_foot_tiles={bfoot} bridge_abutments={babut} " f"interior_elements={interior}" ) if up_props < 8: failures.append(f"upper_band_props {up_props} < 8") if up_kinds < 4: failures.append(f"upper_band_kinds {up_kinds} < 4") if bprops != 1 or bfoot != 3: failures.append(f"bridge props/foot {bprops}/{bfoot} != 1/3") if babut != 4: failures.append(f"bridge_abutments {babut} != 4") if interior < 8: failures.append(f"interior_elements {interior} < 8") lines.append("") shots = {mm.group(1): mm for mm in SHOT_RE.finditer(log)} stats = {} for name in FRAMES: path = rec / f"{name}.png" if not path.exists(): failures.append(f"missing frame {name}.png") continue rgb = np.asarray(Image.open(path).convert("RGB"), dtype=np.int16) rgba = np.asarray(Image.open(path).convert("RGBA"), dtype=np.uint8) h, w = rgb.shape[0], rgb.shape[1] mean = float(rgb.mean()) std = float(rgb.std()) 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)) stats[name] = rgb lines.append(f"{name}.png {w}x{h} mean={mean:.1f} std={std:.1f} max_dark_block={dark} max_white_block={white}") if (w, h) != (1280, 720): failures.append(f"{name} size {w}x{h} != 1280x720") if std < 5.0: failures.append(f"{name} near-blank std={std:.2f}") if dark != 0: failures.append(f"{name} dark block {dark}") if white >= WHITE_SIDE * WHITE_SIDE: failures.append(f"{name} fully-white block {white}") lines.append("") if "p64-overview" in stats: density = non_background_ratio(stats["p64-overview"], *BAND) lines.append(f"overview upper-band non-background ratio (y {BAND[0]}..{BAND[1]}) = {density:.4f} (P62 基线 0.1030)") if density < 0.14: failures.append(f"overview upper-band density {density:.4f} < 0.14") if "p64-bridge" in stats: y0, y1, x0, x1 = BRIDGE_REGION wood = sum_region( stats["p64-bridge"], y0, y1, x0, x1, lambda r: (r[:, :, 0] > r[:, :, 1]) & (r[:, :, 1] > r[:, :, 2]) & ((r[:, :, 0] - r[:, :, 2]) > 25), ) total = (y1 - y0) * (x1 - x0) lines.append(f"bridge region wood-pixel ratio (x {x0}..{x1}, y {y0}..{y1}) = {wood}/{total} = {wood/total:.4f}") if wood / total < 0.20: failures.append(f"bridge wood ratio {wood/total:.4f} < 0.20") for name in ("p64-overview", "p64-bridge"): if name not in stats or name not in shots: continue camx = int(float(shots[name].group(5))) camy = int(float(shots[name].group(6))) left, right = rail_strip_ratios(stats[name], camx, camy) lines.append(f"rail strips [{name}] cam=({camx},{camy}) left={left:.3f} right={right:.3f} (threshold {RAIL_THRESHOLD})") if left < RAIL_THRESHOLD: failures.append(f"{name} left rail ratio {left:.3f} < {RAIL_THRESHOLD}") if right < RAIL_THRESHOLD: failures.append(f"{name} right rail ratio {right:.3f} < {RAIL_THRESHOLD}") lines.append("") for name, shot in sorted(shots.items()): lines.append("log: " + shot.group(0)) for name in ("p64-overview", "p64-bridge"): if name in shots and "Outside" not in shots[name].group(7): failures.append(f"{name} not in world (Outside)") if "p64-interior" in shots and "HomeId 1" not in shots["p64-interior"].group(7): failures.append("interior not Inside (HomeId 1)") lines.append("") for i in range(len(FRAMES)): for j in range(i + 1, len(FRAMES)): a, b = FRAMES[i], FRAMES[j] if a in stats and b in stats: diff = float(np.abs(stats[a].astype(np.int32) - stats[b].astype(np.int32)).mean()) lines.append(f"{a} vs {b} mean_abs_diff={diff:.1f}") if diff < 2.0: failures.append(f"{a} and {b} are near-identical") lines.append("") lines.append("缺字空白位 total=0(三帧仅中文经 CjkGlyphAtlas 绘制;P61 覆盖测试绿)") 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())