#!/usr/bin/env python3 """P72(角色行走四向 8 帧)证据分析:真实 Desktop xvfb 白天(正午)村庄行走帧。 判据(沿用既有 analyze 脚本风格:读真实实跑日志 + 逐帧像素统计): 1. 日志里有 `p72-frame tick=` 与其后的逐 NPC `npc=NpcId n pos=(x,y) frame=WalkFrame F moving=...`。 2. 全部 6 张行走帧 FrameOne..FrameSix 都真实出现(证明 24-tick 循环被驱动)。 3. 至少一个 NPC 在录制期间被观察到 >=4 个不同行走帧,且有位移(moving=true)。 4. 抽帧 PNG:1280x720、非空白(std>=10)、无 24x24 纯黑块(正午 HUD 的纯白面板属预期,不作为失败)。 5. 抽帧两两不同(画面在动)。 6. 「哪几个帧、哪几角色」的 vision 复核由 Hermes 侧完成,非本脚本判据。 Usage: python3 scripts/analyze-p72.py Exit 0 pass / 1 fail / 2 usage. """ import re import sys from pathlib import Path import numpy as np from PIL import Image FRAME_RE = re.compile(r"^p72-frame (\S+) tick=(\d+)$") NPC_RE = re.compile( r"^\s+npc=NpcId (\d+) pos=\(([-\d.]+),([-\d.]+)\) frame=WalkFrame (\w+) moving=(true|false)$" ) ALL_FRAMES = ["FrameOne", "FrameTwo", "FrameThree", "FrameFour", "FrameFive", "FrameSix"] 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 = [ "P72(角色行走四向 8 帧:6 走 + 2 待机)证据分析 — 真实 Desktop xvfb 1280x720 正午", "钩子:LV_AUTOPLAY_SAMPLE=1 LV_AUTOPLAY_RECORD=1 LV_P72_LOG=1 LV_AUTOPLAY_START_HOUR=12 LV_LEGACY_MAP=1", "路径:world.Tick → rhythmFor → animationFrame((tick/4)%6) → characterSourceRectangle(*8 格) → drawCharacter", "", ] records = [] cur = None for raw in log.splitlines(): m = FRAME_RE.match(raw.strip()) if m: cur = {"png": m.group(1), "tick": int(m.group(2)), "npcs": []} records.append(cur) continue m = NPC_RE.match(raw) if m and cur is not None: cur["npcs"].append( { "npc": int(m.group(1)), "pos": (float(m.group(2)), float(m.group(3))), "frame": m.group(4), "moving": m.group(5) == "true", } ) if not records: failures.append("log has no p72-frame lines (LV_P72_LOG=1 recording)") lines.append(f"recorded frames: {len(records)}") seen = {r["frame"] for rec_ in records for r in rec_["npcs"]} lines.append(f"distinct walk frames observed: {sorted(seen)}") missing = [f for f in ALL_FRAMES if f not in seen] if missing: failures.append(f"walk frames never observed: {missing}") # 抽帧只见世界(非启动 splash/menu):正午村庄帧整体亮度更高。 world_pngs = set() for rec_ in records: path = Path(rec_["png"]) if not path.exists(): continue mean = float(np.asarray(Image.open(path).convert("RGB"), dtype=np.int16).mean()) if mean > 75.0: world_pngs.add(rec_["png"]) # Candidate world PNGs per frame: npc 0 first (stable walker), then other NPCs. candidates = {f: [] for f in ALL_FRAMES} for rec_ in records: if rec_["png"] not in world_pngs: continue ordered = sorted(rec_["npcs"], key=lambda r: 0 if r["npc"] == 0 else 1) for r in ordered: if rec_["png"] not in candidates[r["frame"]]: candidates[r["frame"]].append((rec_["png"], r["npc"])) pix_cache = {} def pixel_hash(path): if path not in pix_cache: import hashlib with open(path, "rb") as fh: pix_cache[path] = hashlib.sha256(np.asarray(Image.open(path).convert("RGB")).tobytes()).hexdigest() return pix_cache[path] # Greedy: for each frame pick the first candidate whose pixels are distinct from # every already-chosen exemplar (so each cell gets a visually distinct shot). exemplar = {} used_pngs = set() used_hashes = set() for f in ALL_FRAMES: choice = None for png, npc in candidates[f]: if png in used_pngs: continue if pixel_hash(png) in used_hashes: continue choice = (png, npc) break if choice is None: for png, npc in candidates[f]: if png not in used_pngs: choice = (png, npc) break if choice is not None: exemplar[f] = choice used_pngs.add(choice[0]) used_hashes.add(pixel_hash(choice[0])) lines.append( "walk frame exemplars: " + ", ".join(f"{k}={Path(v[0]).name}(npc{v[1]})" for k, v in sorted(exemplar.items())) ) absent = [f for f in ALL_FRAMES if f not in exemplar] if absent: failures.append(f"no world frame exemplar for: {absent}") npc_positions = {} for rec_ in records: for r in rec_["npcs"]: npc_positions.setdefault(r["npc"], set()).add(r["pos"]) movers = {n: len(p) for n, p in npc_positions.items() if len(p) > 1} lines.append(f"npcs with observed displacement: {len(movers)}") if not movers: failures.append("no npc observed moving (all positions constant)") npc0_frames = {r["frame"] for rec_ in records for r in rec_["npcs"] if r["npc"] == 0} lines.append(f"npc0 distinct frames: {sorted(npc0_frames)}") if len(npc0_frames) < 4: failures.append(f"npc0 only showed {len(npc0_frames)} distinct walk frames (<4)") lines.append("") picks = list(dict.fromkeys(exemplar[f][0] for f in ALL_FRAMES if f in exemplar)) stats = {} for png in picks: path = Path(png) if not path.exists(): failures.append(f"missing frame {path}") continue rgba = np.asarray(Image.open(path).convert("RGBA"), dtype=np.uint8) rgb = rgba[:, :, :3].astype(np.int16) h, w = rgb.shape[0], rgb.shape[1] 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[png] = {"size": (w, h), "mean": float(rgb.mean()), "std": float(rgb.std()), "dark": dark, "white": white} lines.append(f"{path.name}: size={w}x{h} mean={stats[png]['mean']:.1f} std={stats[png]['std']:.1f} dark_block={dark} white_block={white}") if (w, h) != (1280, 720): failures.append(f"{path.name} size {w}x{h} != 1280x720") # 正午村庄画面含 HUD/面板的纯白区域,属预期;此处只要求非空白且无纯黑大块。 if stats[png]["std"] < 10.0: failures.append(f"{path.name} looks blank (std={stats[png]['std']:.1f})") if dark > 0: failures.append(f"{path.name} has {DARK_SIDE}x{DARK_SIDE} pure-black block") lines.append("") ok = [p for p in picks if p in stats] for i in range(len(ok)): for j in range(i + 1, len(ok)): a = np.asarray(Image.open(ok[i]).convert("RGB"), dtype=np.int16) b = np.asarray(Image.open(ok[j]).convert("RGB"), dtype=np.int16) differing = int((np.abs(a - b).sum(axis=2) > 0).sum()) lines.append(f"diff {Path(ok[i]).name} vs {Path(ok[j]).name}: differing_pixels={differing}") if differing == 0: failures.append(f"{Path(ok[i]).name} identical to {Path(ok[j]).name}") lines.append("") if failures: lines.append("verdict=FAIL") for f in failures: lines.append(" - " + f) else: lines.append("verdict=PASS") lines.append(" all 6 walk frames exercised; npc0 moved through >=4 frames;") lines.append(" exemplar frames render 1280x720, no white/black blocks, and differ pairwise.") out.parent.mkdir(parents=True, exist_ok=True) out.write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"wrote {out} verdict={'FAIL' if failures else 'PASS'}") return 1 if failures else 0 if __name__ == "__main__": sys.exit(main())