Files
yml2213 99565f65f0 feat(web): 新增 Web 观测台(形态 A)+ 抽出 render/targets 共用层
- tcaptcha/web/:FastAPI + SSE + 零构建单页。把同步阻塞的 solve() 包成可观测
  后台任务(工作线程 + 全局串行,因 onnxruntime session 非线程安全),用
  logging.Handler 零侵入把 client 日志变成事件流;按靶子实测 min_interval 强制节流。
- tcaptcha/render.py:标注图(点击顺序编号 + 质心偏差)与点位量尺,CLI 与 web 共用。
- tcaptcha/targets.py:靶子表提为单一来源(含实测频控间隔 1ms=60s / mliev=3s)。
- fastapi/uvicorn 走 optional extra `web`,核心包零 web 依赖(带回归断言)。
- 修两个真 bug:hook 求解器时 args[0] 是实例自身(真入参从 args[1] 起);
  client 默认每次新建 solver,预热实例未被复用(每发重载 56MB ONNX)。
- 复验:icon/text/slider 三题型各拿到 errorCode=0;selftest 全绿;依赖隔离通过。

用法:uv run --extra web python -m tcaptcha.web.app
2026-10-14 19:10:54 +08:00

8.0 KiB
Raw Permalink Blame History

2408 图标点选 —— 「errorCode 是什么错」完整实验记录(2026-10-12)

最终结论:两层原因,都已实锤——

  1. 不是"点得不准":点击离图标中心 23px,服务端容差 2540px,富余一个数量级。
  2. 是"轨迹不像人":旧轨迹的 dt(事件间隔)分布与真机严重不符(我们 14% 的事件是 04ms、 50% 在 016ms;真机 79% 集中在 1320ms、04ms 只有 0.28%),点数还随机 57~65 而真机恒为 61。修掉之后 1ms 图标 4/8 → 7/8 = errorCode 0(前 7 连 0)。

错误码语义:0=通过,50=答案被否(点位/目标错了才会给),12=包级拒答(轨迹/指纹不像人, 答案还没被裁判)。修轨迹前我们一直把 12 误当成"位置/风控玄学"。


1. 仓内工具(本轮新增,别再写 /tmp 一次性脚本)

工具 作用
scripts/verify/send_campaign.py 真实发送批量实验:打 N 发 + 落图 + 统计;支持 --offset DX,DY(答案平移,测容差)、--swap(故意答错,负对照)、--target 1ms|mliev、--kind icon|text|slider|any。每次跑落 bg.jpg / prompt.jpg / annotated.jpg / points.json,并把点击顺序(1/2/3)画在底图上
scripts/verify/judge_icons.py 把一次实验目录变成目视判读图:每行一次跑,列 = [提示图第 j 个目标] vs [第 j 个点击点 ±32px 的背景 patch] —— 点得对不对、顺序对不对一眼可判

产物默认落在项目内 har/1ms_dianxuan/icon_campaign/<tag>/(har/** 只跟踪 .md, 所以图片不进 git,但留在项目树里便于复核)。

# 基线
.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 8
# 容差标定 / 负对照
.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 2 --offset 25,0
.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 2 --swap
# 目视判读
.venv/bin/python scripts/verify/judge_icons.py har/1ms_dianxuan/icon_campaign/<tag>

1.1 「点得准不准」的独立量尺

send_campaign.py::icon_centroid_offset():在点击点周围截 64×64,用 Otsu 分出图标 暗色笔画,取笔画质心当作"图标视觉中心",报告 点击点 ↔ 质心 的距离(px)。

这条量尺独立于求解器(求解器用的是 YOLO box 中心),所以能直接回答"我们点偏了多少"。


2. 实验与结果

2.1 可复现靶子 aid 190758559(mliev.login.wang)

实验 结果
基线 --kind icon --n 8 0 × 7,None × 1(Connection reset by peer,网络层,非判负)
点击点偏离图标笔画质心(24 个点) 均值 3.3px,最小 0.6 / 最大 10.6
负对照 --swap(交换前两点,必错) 50 × 2
容差 --offset 10,0 0 × 2(偏离质心 3.3~10.6px)
容差 --offset 25,0 0 × 2(偏离质心 6.4~22.0px)
容差 --offset 50,0 50 × 2(偏离质心 11.2~34.8px)
容差 --offset 0,75 50

→ 服务端命中半径落在 25px 与 50px 之间(按"离图标中心"算约 25~40px)。

2.2 主靶 aid 195672344(1ms 签到页)

时间 实验 结果
13:15–13:17 图标 ×6 0,0,12,12,12,12
13:24–13:25 混合题型 ×6 12 × 6(三题型都 12)
13:31 图标 ×2 50,50(点位目视逐点正确)
13:49–13:52 图标 ×8 0,0,0,0,None,12,12,12(同一次爆发:前 4 次 0,之后 12)
13:52 点击点偏离质心(24 个点) 均值 2.2px,最小 0.3 / 最大 5.2

决定性证据(13:49 那轮的 solver_diag):三次 12 的答案里,每个目标类都能在底图中 找到 top-1 概率 1.0 的候选(例:run6 targets [2,1,4] → 候选 top-1 分别 2:1.0 / 1:1.0 / 4:1.0;run7 [8,9,11] → 8:1.0 / 9:1.0 / 11:1.0; run8 [1,4,0] → 1:0.998 / 4:1.0 / 0:1.0)。 判读图(judge_icons.py)也确认:8 次跑 24 个点全部落在与提示同序的目标图标上, 连那三次 12 也一样,偏离只有 0~5px。


3. 决定性一步:解开真机轨迹对拍(用户的判断:"位置没问题那就是轨迹被校验")

用同会话 tdc.js 密钥把 4 份真机抓包 / 6 条成功轨迹全部解出,dt(事件间隔 ms)汇总:

口径 真机(6 条 / 360 个非首点 dt) 旧实现(修复前)
总点数 6/6 恒为 61(连 点选.har 的 3 次提交都是) 随机 57~65
dt 众数 17ms ×126 / 16ms ×93 无主峰
13~20ms 占比 79.2% ~36%
0~4ms 占比 0.28% 14%
41ms 以上占比 ~5.6% ~10%

修复(tcaptcha/telemetry/values.py):

  • _sample_dt() 按 6 条真机轨迹重拟合(主峰 13~20ms 占 78%);
  • 新增 _fit_points():点选轨迹钉死 61 点,补齐用 [0,0,dt] 驻留点 (不能复制最后一个位移点——会把净位移放大,实测 166 vs 期望 139.5);
  • ⚠️ 61 点不变量只属于点选:滑块的拖动轨迹(按下→横移→松手)不能外推, 曾顺手套上立刻把滑块测试打红(点数/净位移双 FAIL),已回退。

修复后 A/B(真实发送):

靶子 / 题型 修复前 修复后
1ms 图标 ×8 0,0,0,0,None,12,12,12(4/8) 0 ×7 + 12 ×1(7/8,前 7 连 0)
1ms 文字 ×3 — 0,12,0(2/3)
1ms 滑块 ×3 — 0,12,12(1/3,样本少)
mliev 图标 ×8 0×7 (未重跑,修复前已 9/10)

修后轨迹 vs 真机:点数 61=61;13~20ms 占比 77% vs 72%;均值 32.4 vs 31.3 —— 贴合。 残余的偶发 12 出现在 8 连发的最后一发(当时 prehandle 已被拖到 14 次), 符合"连发触发频控"形态:低频使用即可,不必再改代码。

4. 结论

  1. 50 = 答案被否(负对照:故意答错 2/2 = 50;平移 50px 2/2 = 50); 12 = 包级拒答(轨迹/指纹不像人,答案还没被裁判)。判"点位对不对"只看 50/0。
  2. "点得不准"被排除:点击精度 ≈ 质心 ±3px,服务端容差 25~40px,富余一个数量级。
  3. 1ms 的 12 主因 = 轨迹时序指纹(dt 分布 + 61 点硬不变量),A/B 见效(4/8 → 7/8)。
  4. 偶发 12 = 连发频控;偶发 50(13:31 那轮 2/2)为修复前轨迹 + 连发叠加的残留现象, 修复后未再出现。

5. 复现须知

  • 1ms 抽到图标题约 1/6,190758559 约 1/10;脚本会按 accept_kinds 自动换题(getsig 不换题型, 只有重新 prehandle 才换)。

  • 靶子会偶发 Connection reset by peer(两个靶子都遇到过),那不是判负,统计时要单列。

  • 1ms 有 aid+IP 级频控(2026-10-14 标定,实测三次):

    打法 结果
    间隔 4s × 12 发 0,0 + 12 ×10(只有前 2 发过;icon/text/slider 三种题型都被打到 12)
    冷却 2min + 间隔 30s × 6 12 ×6(冷却不够)
    冷却 25min + 间隔 60s × 4 0 ×4 ✓(text×2 + slider×2)

    → 频控与题型无关、与 prehandle 次数无关(每次都是 1 个 prehandle),只与短时间内的请求量有关。 用法:≥60s 一发、连续不超过约 2 发;回归测试一律用 190758559。

5.1 三种题型在 1ms 上的实测("都能过吗")

修复轨迹后,三种题型都拿到过 errorCode=0(含 ticket),链路完好;失败全是频控:

题型 修复后成绩 备注
图标 2408 7/8 = 0(前 7 连 0) 修复轨迹后首次连跑
文字 2404 2/3 = 0;冷却后 2/2 = 0 与图标共用 collect 链路
滑块 2401/2402 1/3 = 0;冷却后 2/2 = 0 拖动轨迹另算(不套 61 点)

注意:成绩好坏取决于"打得多稀疏",不取决于题型——4 秒间隔连打时三种题型一起全 12。