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
This commit is contained in:
yml2213
2026-10-12 12:46:35 +08:00
parent 344eea2194
commit 6a8b3b3ce4
7 changed files with 294 additions and 33 deletions
+82 -1
View File
@@ -30,8 +30,9 @@
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import List, Optional, Sequence, Tuple
from typing import Dict, List, Optional, Sequence, Tuple
import cv2
import numpy as np
@@ -142,6 +143,86 @@ def _peaks(curve: np.ndarray, neighbor: int, limit: int = 5) -> List[Tuple[int,
return picked
def piece_from_sprite(sprite: np.ndarray, rect: Sequence[int]) -> np.ndarray:
"""按课题配置里的 `sprite_pos` / `size_2d` 从精灵图裁出拼图块(保留 alpha)。"""
x, y, w, h = (int(v) for v in rect)
if w <= 0 or h <= 0:
raise ValueError(f"精灵图矩形非法: {rect!r}")
return sprite[y:y + h, x:x + w]
def _parse_track_limit(expr: str) -> Optional[Tuple[float, float]]:
"""把 `x>=50&&x<=552` 解析成 (50.0, 552.0);解析不出来返回 None。"""
nums = re.findall(r"x\s*[<>=]+\s*(-?\d+(?:\.\d+)?)", expr or "")
if len(nums) >= 2:
return float(nums[0]), float(nums[1])
return None
@dataclass
class SliderChallenge:
"""滑块参数 —— 全部来自课题 JSON,不依赖 DOM。
真机结构(aid=195672344,subcapclass 2401/2402 实测):
bg_elem_cfg : {img_url, size_2d:[672,480], sprite_pos:[0,0]}
sprite_url : 精灵图(底图与拼图块、滑轨都在这一张里)
fg_elem_list: [滑轨(id=3, size 672x32), 拼图块(id=1, 带 move_cfg)]
拼图块.move_cfg = {track_limit:"x>=50&&x<=552", move_factor:[1,0],
data_type:["DynAnswerType_POS"]}
坐标口径(关键):
`init_pos` 与**提交答案里的坐标**都是「背景图原始像素」(即 `size_2d` 那套),
不是页面显示像素 —— 所以解题时用 `scale=1.0`,直接拿 target_x_image 组答案。
"""
bg_url: str
sprite_url: str
sprite_rect: Tuple[int, int, int, int]
init_pos: Tuple[float, float]
elem_id: int
data_type: str
bg_size: Tuple[int, int]
track_limit: Optional[Tuple[float, float]] = None
@classmethod
def from_challenge(cls, raw: Dict) -> "SliderChallenge":
bg_cfg = raw.get("bg_elem_cfg") or {}
fgs = raw.get("fg_elem_list") or []
move_el = next((e for e in fgs if e.get("move_cfg")), None)
if move_el is None or not raw.get("sprite_url") or not bg_cfg.get("img_url"):
raise ValueError(
"课题配置里没有滑块元素(缺 move_cfg / sprite_url / bg_elem_cfg.img_url)")
sx, sy = (move_el.get("sprite_pos") or (0, 0))[:2]
sw, sh = (move_el.get("size_2d") or (0, 0))[:2]
init = (move_el.get("init_pos") or (0, 0))
mc = move_el.get("move_cfg") or {}
data_types = mc.get("data_type") or []
size2d = (bg_cfg.get("size_2d") or (0, 0))
return cls(
bg_url=str(bg_cfg["img_url"]),
sprite_url=str(raw["sprite_url"]),
sprite_rect=(int(sx), int(sy), int(sw), int(sh)),
init_pos=(float(init[0]), float(init[1])),
elem_id=int(move_el.get("id", 1)),
data_type=str(data_types[0]) if data_types else "DynAnswerType_POS",
bg_size=(int(size2d[0]), int(size2d[1])),
track_limit=_parse_track_limit(str(mc.get("track_limit") or "")),
)
def answer(self, target_x: float, y: float) -> List[Dict]:
"""按 `move_cfg.data_type` 组提交用的 ans(坐标 = 背景图原始像素)。"""
return [{"elem_id": self.elem_id, "type": self.data_type,
"data": "%d,%d" % (int(round(target_x)), int(round(y)))}]
def clamp_x(self, x: float) -> float:
"""夹到 track_limit 允许的范围内(配置没给就不动)。"""
if not self.track_limit:
return float(x)
lo, hi = self.track_limit
return float(min(max(x, lo), hi))
@dataclass
class SliderResult:
"""滑块求解结果。坐标均为**背景图原始像素**, 除 dx_display 外。"""