diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/analyze-p65.py | 220 |
1 files changed, 220 insertions, 0 deletions
diff --git a/scripts/analyze-p65.py b/scripts/analyze-p65.py new file mode 100644 index 0000000..5251a0f --- /dev/null +++ b/scripts/analyze-p65.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""P65(职业切片五)证据分析:货郎「打听行情」(提议交易)读取既有 World.Rumors 池。 + +判据(沿用既有 analyze 脚本风格:读真实实跑日志 + 逐帧像素统计): + 1. 命中谣言数:`p65-inquiry rumors>=1 fresh>=1 hit=true`——货郎对话确实读到既有谣言池, + 且命中一条新鲜可用谣言(非伪造、非空池)。 + 2. 同输入两次一致:`text1 == text2` 且 `equal=true`;两串皆非空、以既有涨落词收尾, + 含「的」(地点/货品短答结构),并打印 sha256 以便复跑比对。 + 3. 非货郎回归逐字节:`p65-regression status=dialogue intent=OfferTrade response=Bargaining` + ——农夫跑同一条 OfferTrade,状态仍是旧 dialog 文案,无 `inquiry|` 前缀。 + 4. 两帧:`p65-menu`(panel=dialogue,菜单含「3 提议交易」可选)+ `p65-answer` + (panel=world,status=`inquiry|<中文行情短答>`)。 + 5. 行情短答确实画到 HUD:状态药丸带(x 8..210, y 41..69)内亮色文字像素, + 答题帧 >= 300 且横向铺满 >= 150px(≈11 个 CJK @ scale2 = 176px),菜单帧 ≈ 0。 + 6. 两帧均 1280x720、非空、无 64x64 纯白块、无 24x24 纯黑块,且两两不同。 + 7. 缺字空白位:中文经 CjkGlyphAtlas 绘制,P65RumorInquiryTests 已断言行情短答覆盖。 + (逐帧「字形是否清晰可辨」的 vision 复核由 Hermes 侧完成,非本脚本判据。) + +Usage: python3 scripts/analyze-p65.py <record-dir> <out-txt> +Exit 0 pass / 1 fail / 2 usage. +""" + +import hashlib +import re +import sys +from pathlib import Path + +import numpy as np +from PIL import Image + +REGRESSION_RE = re.compile(r"^p65-regression status=(.+)$", re.MULTILINE) +INQUIRY_RE = re.compile( + r"^p65-inquiry rumors=(\d+) fresh=(\d+) hit=(\w+) text1=(.*?) text2=(.*?) equal=(\w+)$", + re.MULTILINE, +) +SHOT_RE = re.compile( + r"^p65-shot=(\S+) panel=(\S+) avatar=\(([\-\d.]+),([\-\d.]+)\) status=(.*)$", + re.MULTILINE, +) +FRAMES = ["p65-menu", "p65-answer"] +LEGACY_REGRESSION = "dialogue intent=OfferTrade response=Bargaining" +MOVEMENTS = ("涨了两文", "涨了一文", "还稳当", "有些回落") + +PILL = (41, 69, 8, 210) # y0, y1, x0, x1 +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 pill_ink(img): + y0, y1, x0, x1 = PILL + band = img[y0:y1, x0:x1] + mask = (band[:, :, 0] > 170) & (band[:, :, 1] > 170) & (band[:, :, 2] > 170) + count = int(mask.sum()) + if count == 0: + return count, 0 + cols = np.where(mask.any(axis=0))[0] + span = int(cols.max() - cols.min() + 1) + return count, span + + +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 = [ + "P65(职业切片五)证据分析 — 真实 Desktop 实跑 xvfb 1280x720", + "钩子:LV_P65_SHOT=1 LV_LEGACY_MAP=1(货郎真实开局),2 帧:p65-menu / p65-answer", + "路径:真实闲聊写谣言池 → 真实 Interact 开菜单 → 提议交易读既有谣言出中文行情短答", + "", + ] + + m = REGRESSION_RE.search(log) + if not m: + failures.append("log missing p65-regression") + lines.append("regression: <missing>") + else: + status = m.group(1).strip() + lines.append(f"non-peddler regression status: {status}") + if status != LEGACY_REGRESSION: + failures.append(f"non-peddler status {status!r} != legacy {LEGACY_REGRESSION!r}") + lines.append("") + + m = INQUIRY_RE.search(log) + if not m: + failures.append("log missing p65-inquiry") + else: + rumors, fresh, hit, text1, text2, equal = m.groups() + rumors, fresh = int(rumors), int(fresh) + lines.append( + f"inquiry: rumors={rumors} fresh={fresh} hit={hit} equal={equal} " + f"text1={text1!r} text2={text2!r}" + ) + lines.append(f"sha256(text1)={hashlib.sha256(text1.encode('utf-8')).hexdigest()}") + if rumors < 1: + failures.append(f"rumors {rumors} < 1") + if fresh < 1: + failures.append(f"fresh {fresh} < 1") + if hit != "true": + failures.append(f"hit {hit} != true") + if equal != "true" or text1 != text2: + failures.append("text1 != text2 (same input must be identical)") + if not text1: + failures.append("empty inquiry text") + if not text1.endswith(MOVEMENTS): + failures.append(f"text1 {text1!r} does not end with a movement word") + if "的" not in text1: + failures.append(f"text1 {text1!r} missing place/good separator") + 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("") + + for name in ("p65-menu", "p65-answer"): + if name not in shots: + failures.append(f"log missing p65-shot={name}") + continue + lines.append("log: " + shots[name].group(0)) + if "p65-menu" in shots and shots["p65-menu"].group(2) != "dialogue": + failures.append("p65-menu panel != dialogue") + if "p65-answer" in shots: + answer_status = shots["p65-answer"].group(5) + if not answer_status.startswith("inquiry|"): + failures.append(f"p65-answer status {answer_status!r} lacks inquiry| prefix") + lines.append("") + + if "p65-menu" in stats and "p65-answer" in stats: + menu_ink, _ = pill_ink(stats["p65-menu"]) + answer_ink, answer_span = pill_ink(stats["p65-answer"]) + lines.append( + f"status pill ink (x {PILL[2]}..{PILL[3]}, y {PILL[0]}..{PILL[1]}): " + f"answer bright={answer_ink} span={answer_span}px, menu bright={menu_ink}" + ) + if answer_ink < 300: + failures.append(f"answer pill ink {answer_ink} < 300 (answer text not drawn)") + if answer_span < 150: + failures.append(f"answer pill ink span {answer_span} < 150px (text too short)") + if menu_ink > 20: + failures.append(f"menu pill ink {menu_ink} > 20 (menu frame should not show the pill)") + 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 绘制;P65RumorInquiryTests 断言覆盖)") + 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()) |
