feat(icon): 轨迹时序指纹修复 —— 1ms 图标 4/8→7/8=0,errorCode 语义与轨迹对拍收口

- 真因:dt 分布与真机严重不符(旧 50% 落 0~16ms,真机 79% 落 13~20ms)+ 点数
  随机 57~65(真机 6/6 恒 61)。按 6 条真机轨迹重拟合 _sample_dt,新增 _fit_points
  钉死 61 点(补齐用 [0,0,dt] 驻留点,不能复制位移点——净位移会被放大)。
- A/B:1ms 图标 0×4→0×7(前 7 连 0);残余偶发 12 为连发频控形态,低频使用即可。
- errorCode 语义标定:50=答案被否(容差实测 25~40px,我们点偏仅 2~3px),
  12=包级拒答;判点位只看 50/0。
- 顺带修 build_plain:轨迹落末槽(k=37)时 chunk2 恒补前导逗号 → 非法 JSON。
- 新增 scripts/verify/send_campaign.py(批量真发+落图+统计)与 judge_icons.py
  (提示目标 vs 点击点并排判读);账本 #64~#74 + 专文 ICON2408.md。

复验:selftest 全绿;判读图 24/24 点位全对;mliev 图标 9/10=0。
This commit is contained in:
yml2213
2026-10-12 14:32:14 +08:00
parent 6a8b3b3ce4
commit 74a863c8b3
9 changed files with 752 additions and 35 deletions
+95
View File
@@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
"""judge_icons.py —— 图标点选题的**目视判读图**生成器。
把 `send_campaign.py` 的一次实验目录变成一张对照图:每行一次跑,列出
[提示图第 j 个目标] vs [第 j 个点击点 ±32px 的背景 patch] (j = 1,2,3)
这样"点得对不对 / 顺序对不对"一眼可判 —— 顺序即红框上的 #1/#2/#3。
用法:
.venv/bin/python scripts/verify/judge_icons.py /tmp/icon_campaign/<tag> [out.png]
"""
from __future__ import annotations
import json
import os
import re
import sys
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from tcaptcha.ocr.slicer import slice_prompt_image # noqa: E402
CELL = 120
PAD = 8
ROWH = CELL + 26
def build(campaign_dir: str, out_path: str) -> str:
d = Path(campaign_dir)
runs = sorted([p.name for p in d.iterdir() if re.fullmatch(r"run\d+", p.name)],
key=lambda s: int(s[3:]))
if not runs:
raise SystemExit("目录下没有 run* 子目录: %s" % d)
width = PAD + 60 + 3 * (CELL + PAD + CELL) + PAD + 150
height = PAD + len(runs) * (ROWH + PAD)
canvas = Image.new("RGB", (width, height), (245, 245, 245))
dr = ImageDraw.Draw(canvas)
y = PAD
for rn in runs:
rd = d / rn
info = json.loads((rd / "points.json").read_text(encoding="utf-8"))
ec = info.get("errorCode")
pts = info.get("points") or []
offs = info.get("centroid_offsets_px") or []
dr.text((PAD, y + 4), "%s\nec=%s" % (rn, ec), fill=(20, 20, 20))
try:
prompt = Image.open(rd / "prompt.jpg").convert("RGB")
crops = slice_prompt_image(prompt)
except Exception: # noqa: BLE001
crops = []
try:
bg = Image.open(rd / "bg.jpg").convert("RGB")
except Exception: # noqa: BLE001
bg = None
x = PAD + 60
for j in range(3):
if j < len(crops):
pc = crops[j][0].convert("RGB").resize((CELL, CELL), Image.LANCZOS)
else:
pc = Image.new("RGB", (CELL, CELL), (255, 210, 210))
canvas.paste(pc, (x, y + 20))
dr.rectangle([x, y + 20, x + CELL, y + 20 + CELL], outline=(0, 0, 0))
x2 = x + CELL + PAD
if bg is not None and j < len(pts):
px, py = pts[j]
box = (max(0, px - 32), max(0, py - 32),
min(bg.width, px + 32) + 1, min(bg.height, py + 32) + 1)
patch = bg.crop(box).resize((CELL, CELL), Image.LANCZOS)
tag = "#%d(%d,%d)" % (j + 1, px, py)
if j < len(offs) and offs[j] is not None:
tag += " 偏%.0fpx" % offs[j]
dr.text((x2 + 2, y + 4), tag, fill=(0, 0, 0))
else:
patch = Image.new("RGB", (CELL, CELL), (232, 232, 232))
canvas.paste(patch, (x2, y + 20))
dr.rectangle([x2, y + 20, x2 + CELL, y + 20 + CELL], outline=(200, 0, 0), width=3)
x = x2 + CELL + PAD
y += ROWH + PAD
canvas.save(out_path)
return out_path
if __name__ == "__main__":
if len(sys.argv) < 2:
raise SystemExit(__doc__)
src = sys.argv[1]
dst = sys.argv[2] if len(sys.argv) > 2 else os.path.join(src, "judge.png")
print("saved", build(src, dst))