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:
@@ -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 外。"""
|
||||
|
||||
Reference in New Issue
Block a user