Files
hello_js_reverse_skill/tcaptcha/tests/test_slider_solver.py
T
yml2213 6a8b3b3ce4 feat(slider): 滑块 (2401/2402) 端到端打通 —— errorCode=0
做了什么:
- solvers/slider.py: 新增 SliderChallenge(把课题 JSON 解析成解题参数)+ piece_from_sprite
  (按 sprite_pos/size_2d 裁拼图块)+ track_limit 解析 / clamp_x / answer(按 data_type 组答案)
- telemetry/values.py: 新增 drag_trajectory_points(拖动轨迹:按下->横移->松手,
  首点绝对、其余增量、尾点固定 [1,1,12]);_sample_dt 抽成模块级供两条轨迹复用
- telemetry/{values,crypto}.py: generate_collect / generate_telemetry 接受 trajectory=
- protocol/client.py: 新增滑块分支(拉底图 + 精灵图 -> 裁块 -> ZNCC -> 组答案 -> 拖动轨迹),
  accept_kinds 默认扩为 文字点选 + 图标点选 + 滑块
- scripts/verify/send_test.py: 加 --kind text|slider|icon|any

关键结论(账本 #60~#63):
- 滑块答案是 DynAnswerType_POS,但坐标用**背景图原始像素**(不是页面显示像素):
  所以解题用 scale=1.0 直接提交 target_x_image;elem_id / data_type 均取自 move_cfg
- 拼图块可从课题配置直接裁,与旧 DOM 换算结果**逐像素相同**(测试已加交叉断言)
- **ddddocr 的滑块距离识别不可用**:fixture 偏 59px、实时偏 60px。判定手段是把拼图块
  alpha 轮廓叠到背景上目视 —— 本项目 ZNCC 与缺口描边严丝合缝,ddddocr 整体右偏
- 低置信度(margin=0.024 < 0.08)那次被正确拒答并换题,换题后立刻成功,安全网有效

怎么验证:
- .venv/bin/python -m tcaptcha.tests.test_slider_solver   全绿(新增 [6]/[7] 两组断言)
- .venv/bin/python -m tcaptcha.selftest                   全绿
- .venv/bin/python scripts/verify/send_test.py --kind slider   3/3 errorCode=0
- 文字点选回归不受影响:--kind text 仍为 errorCode=0
2026-10-12 12:46:35 +08:00

169 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
滑块拼图求解器 回归测试 (AGENTS.md §5.9)
固化的性质 (样本取自 1ms.run aid=195672344 真机页面 2026-10-11 的滑块验证码):
1. `sprite_region` 能把 CSS 的 background-position/size 正确换算成精灵图像素区域;
2. ZNCC 峰值落在**真缺口**上, 而不是干扰缺口, 也不是拼图块当前位置;
3. 通过 y 方向小抖动吸收取整误差, 结果稳定;
4. `ok` 的判定 (分数 + 与次峰间距) 在该样本上成立。
样本: tcaptcha/tests/fixtures/1ms_slider_1_{bg.jpg,sprite.png}
bg = 页面 `img_index=1` 的背景图 (672x390, 含真缺口 + 干扰缺口)
sprite = 页面 `img_index=0` 的精灵图 (682x620, 含拖动柄 + 拼图块)
DOM 实测口径 (drag_ele.html iframe 内):
slideBg rect=(10, 68.78) 显示 340 x 242.857 -> scale = 340/672 = 0.50595238
拼图块元素 rect=(35.2976, 133.53) 显示 60.7143 x 60.7143
background-position: -70.8333px -247.917px
background-size: 345.06px 313.69px
=> 拼图块裁剪图在背景图坐标系里的左上角 = (50.0, 127.97)
运行:
.venv/bin/python -m tcaptcha.tests.test_slider_solver
"""
from __future__ import annotations
import sys
from pathlib import Path
import cv2
import numpy as np
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from tcaptcha.solvers.slider import ( # noqa: E402
SliderChallenge, SliderSolver, piece_mask, piece_from_sprite, sprite_region,
)
from tcaptcha.telemetry.values import drag_trajectory_points # noqa: E402
FIX = ROOT / "tcaptcha" / "tests" / "fixtures"
# 真机课题配置快照(aid=195672344 下滑块 2401/2402 的 dyn_show_info,
# 只把两个很长的 image/sess 参数截短,字段结构一字未改)
REAL_SLIDER_CHALLENGE = {
"lang": "zh-cn",
"instruction": "拖动下方滑块完成拼图",
"bg_elem_cfg": {"sprite_pos": [0, 0], "size_2d": [672, 480],
"img_url": "/cap_union_new_getcapbysig?img_index=1&image=XX&sess=YY"},
"sprite_url": "/cap_union_new_getcapbysig?img_index=0&image=XX&sess=YY",
"fg_elem_list": [
{"id": 3, "sprite_pos": [0, 422], "size_2d": [672, 32], "init_pos": [0, 422]},
{"id": 1, "sprite_pos": [140, 490], "size_2d": [120, 120], "init_pos": [50, 128],
"move_cfg": {"track_limit": "x>=50&&x<=552", "move_factor": [1, 0],
"data_type": ["DynAnswerType_POS"]}},
],
}
# 真机 DOM 口径
BG_POS = (-70.8333, -247.917)
BG_SIZE = (345.06, 313.69)
ELEM_SIZE = (60.7143, 60.7143)
SLIDE_W = 340.0
BG_NATURAL_W = 672.0
SCALE = SLIDE_W / BG_NATURAL_W # 0.50595238
PIECE_X_IMAGE = 50.0
PIECE_Y_IMAGE = 128.0
EXPECTED_DX_IMAGE = 293.0 # 真缺口 x - 拼图块 x
EXPECTED_DECOY_X = 489 # 干扰缺口在背景图坐标里的 x
_fails: list = []
def check(name: str, cond: bool, detail: str = "") -> None:
print(f" [{'PASS' if cond else 'FAIL'}] {name}" + (f" {detail}" if detail else ""))
if not cond:
_fails.append(name)
def test_challenge_config() -> None:
"""课题配置 -> 解题参数 -> 提交答案(滑块端到端里最容易出错的一环)。"""
print("\n[6] 课题配置解析 (SliderChallenge)")
sc = SliderChallenge.from_challenge(REAL_SLIDER_CHALLENGE)
check("sprite_rect == (140,490,120,120)", sc.sprite_rect == (140, 490, 120, 120),
str(sc.sprite_rect))
check("init_pos == (50,128)", sc.init_pos == (50.0, 128.0), str(sc.init_pos))
check("elem_id == 1(拼图块,不是滑轨的 3)", sc.elem_id == 1, str(sc.elem_id))
check("data_type == DynAnswerType_POS", sc.data_type == "DynAnswerType_POS", sc.data_type)
check("track_limit == (50,552)", sc.track_limit == (50.0, 552.0), str(sc.track_limit))
check("bg_size == (672,480)", sc.bg_size == (672, 480), str(sc.bg_size))
check("clamp_x 把越界值夹回轨道", sc.clamp_x(600) == 552.0 and sc.clamp_x(10) == 50.0,
f"{sc.clamp_x(600)} / {sc.clamp_x(10)}")
ans = sc.answer(343.0, 128.4)
check("answer 结构 == [{elem_id,type,data}]",
ans == [{"elem_id": 1, "type": "DynAnswerType_POS", "data": "343,128"}], str(ans))
print("\n[7] 拖动轨迹 (drag_trajectory_points)")
traj = drag_trajectory_points(sc.init_pos, (343.0, 128.0), img_w=sc.bg_size[0])
check("首点是绝对坐标", len(traj[0]) == 3 and traj[0][0] > 20, str(traj[0]))
check("尾点固定 [1,1,12]", traj[-1] == [1, 1, 12], str(traj[-1]))
net = sum(p[0] for p in traj[1:-1])
expect = (343.0 - 50.0) * (320.0 / 672.0)
check("净横向位移 ≈ dx*scale", abs(net - expect) <= 6, f"{net} vs {expect:.1f}")
check("点数在合理范围", 20 <= len(traj) <= 50, str(len(traj)))
def main() -> int:
bg = cv2.imread(str(FIX / "1ms_slider_1_bg.jpg"))
sprite = cv2.imread(str(FIX / "1ms_slider_1_sprite.png"), cv2.IMREAD_UNCHANGED)
if bg is None or sprite is None:
print("缺少 fixture, 请确认 tcaptcha/tests/fixtures/1ms_slider_1_* 存在")
return 1
print(f"bg={bg.shape} sprite={sprite.shape}")
print("\n[1] 精灵图区域换算")
x, y, w, h = sprite_region(BG_POS, BG_SIZE, ELEM_SIZE, sprite.shape[:2])
check("sprite_region == (140, 490, 120, 120)", (x, y, w, h) == (140, 490, 120, 120), f"got={(x, y, w, h)}")
crop = sprite[y:y + h, x:x + w]
# 交叉核对:课题配置里直接给的 sprite 矩形,应当与 DOM 换算结果完全一致
crop_cfg = piece_from_sprite(sprite, (140, 490, 120, 120))
check("配置矩形裁出的块 == DOM 换算裁出的块", np.array_equal(crop, crop_cfg))
mask = piece_mask(crop)
check("拼图块掩膜非空且不过分满", 500 < int(mask.sum()) < w * h * 0.9,
f"mask={int(mask.sum())}/{w * h}")
print("\n[2] 缺口定位 (ZNCC)")
res = SliderSolver().solve(bg, crop, piece_x_image=PIECE_X_IMAGE, scale=SCALE,
piece_y_image=PIECE_Y_IMAGE)
print(f" {res.describe()}")
check("ok", res.ok)
check("分数 >= 0.65", res.score >= 0.65, f"score={res.score:.3f}")
check("与次峰间距 >= 0.15", res.margin >= 0.15, f"margin={res.margin:.3f}")
check("落在真缺口 (误差 <= 4 背景像素)", abs(res.target_x_image - (PIECE_X_IMAGE + EXPECTED_DX_IMAGE)) <= 4,
f"target_x={res.target_x_image}")
print("\n[3] 干扰缺口没有被选中")
decoy = [x0 for x0, _ in res.candidates if abs(x0 - EXPECTED_DECOY_X) <= 6]
check("次峰就是干扰缺口", bool(decoy), f"candidates={res.candidates}")
check("真缺口分数严格高于干扰缺口", res.score > res.runner_up + 0.1,
f"{res.score:.3f} vs {res.runner_up:.3f}")
print("\n[4] 显示坐标换算")
check("dx_display == dx_image * scale",
abs(res.dx_display - res.dx_image * SCALE) < 1e-6,
f"{res.dx_display:.2f} == {res.dx_image:.2f} * {SCALE:.6f}")
check("dx_display 落在 [140, 160]", 140 <= res.dx_display <= 160, f"{res.dx_display:.2f}")
print("\n[5] 拼图块当前位置不是答案 (那里没有洞)")
near_piece = [s for x0, s in res.candidates if abs(x0 - PIECE_X_IMAGE) <= 8]
check("拼图块自身位置的分数很低", all(s < 0.3 for s in near_piece),
f"{near_piece}")
test_challenge_config()
print(f"\n{'=' * 62}")
if _fails:
print(f"❌ slider_solver 测试失败: {_fails}")
return 1
print("✅ slider_solver 测试全部通过")
return 0
if __name__ == "__main__":
raise SystemExit(main())