Files
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

308 lines
14 KiB
Python
Raw Permalink 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 -*-
"""
腾讯天御『滑块拼图』求解器 (drag 型验证, 1ms.run aid=195672344 实测 2026-10-11)
题型结构 (真机 iframe `drag_ele.html` 实测):
* 背景图 `img_index=1`: 一张实景照片, 上面画着 **1~2 个缺口** (真缺口 + 干扰缺口)。
缺口 = 与拼图块**同形**的挖空, 内部是"同一张图被压暗"的结果, 外沿一圈浅色描边。
* 拼图块 `img_index=0`: 一张**精灵图**, 里面同时含拖动柄和拼图块本体。页面用
`background-position` / `background-size` 把拼图块那一块裁出来, 显示在滑轨左侧。
* 拖动柄(滑轨)与拼图块**同步横移**, 位移 1:1 (见 `har/1ms_dianxuan/PROGRESS.md`)。
为什么不用"轮廓/暗区阈值"直接找缺口:
背景照片本身有大量强边缘和暗区, 阈值法的召回/精确率都不够 (实测: 用局部变暗比
找缺口会被照片自身的暗部淹没)。但有一个**结构性**事实可以用:
"拼图块里装的是**未被压暗的原始内容**, 而缺口里装的是**同一块内容被压暗**的结果。"
于是 ZNCC(拼图块内容, 背景图窗口) 的峰值必然落在**真缺口**上; 干扰缺口内部装的是
**别处**的内容, 相关性明显更低。实测区分度 (本仓库 fixture):
真缺口 0.732
干扰缺口 0.551
拼图块当前位置 -0.136 (拼图块盖住的是完好的背景, 那里没有洞)
其余局部峰值 <= 0.28
坐标口径 (重要):
* 输入 `piece_x_image` / `piece_y_image` 是**背景图坐标系**(原始像素, 未经页面缩放);
* 输出 `dx_display` 是**页面显示像素**的横向拖动距离, 即 `dx_image * scale`,
其中 `scale = 背景图显示宽度 / 背景图原始宽度`。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Sequence, Tuple
import cv2
import numpy as np
# 拼图块精灵图/背景图的 alpha 阈值 (精灵图是 RGBA, 背景是照片)
DEFAULT_ALPHA_MIN = 128
# 局部峰值去重半径 (背景图坐标): 同一个峰附近的点算同一个候选
DEFAULT_NEIGHBOR = 20
# 纵向搜索范围 (背景图坐标): 允许上下抖 ±N 像素, 吸收取整/描边误差
DEFAULT_Y_JITTER = 3
def _to_bgr(image) -> np.ndarray:
"""把 bytes / PIL / ndarray 统一成 BGR 三通道 ndarray。"""
if isinstance(image, np.ndarray):
if image.ndim == 3 and image.shape[2] == 4:
return cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
return image
if isinstance(image, (bytes, bytearray)):
arr = np.frombuffer(image, np.uint8)
return cv2.imdecode(arr, cv2.IMREAD_COLOR)
return cv2.cvtColor(np.asarray(image.convert("RGB")), cv2.COLOR_RGB2BGR)
def sprite_region(bg_position: Sequence[float], bg_size: Sequence[float],
elem_size: Sequence[float], sprite_shape: Sequence[int]) -> Tuple[int, int, int, int]:
"""把 CSS 的 `background-position` / `background-size` 换算成精灵图里的像素区域。
参数:
bg_position: 元素上的 background-position, 形如 (-70.8333, -247.917) (CSS px)
bg_size: 元素上的 background-size, 形如 (345.06, 313.69) (CSS px, 已缩放后的整图)
elem_size: 元素的显示尺寸, 形如 (60.7143, 60.7143) (CSS px)
sprite_shape: 精灵图原始尺寸 (H, W)
返回 (x, y, w, h), 即该元素在**精灵图原始像素**里对应的矩形。
"""
sx, sy = float(bg_size[0]), float(bg_size[1])
h_sprite, w_sprite = int(sprite_shape[0]), int(sprite_shape[1])
kx, ky = w_sprite / sx, h_sprite / sy # CSS px -> 精灵图 px
x = int(round(-float(bg_position[0]) * kx))
y = int(round(-float(bg_position[1]) * ky))
w = int(round(float(elem_size[0]) * kx))
h = int(round(float(elem_size[1]) * ky))
return x, y, w, h
def piece_mask(piece, alpha_min: int = DEFAULT_ALPHA_MIN) -> np.ndarray:
"""从拼图块裁剪图里取**不透明**区域掩膜 (bool)。
精灵图是 RGBA: 只要 alpha 通道。若调用方给的是三通道图 (例如直接截屏裁的),
退化成"排除近白像素" (精灵图的空白处是纯白/全透明)。
"""
arr = np.asarray(piece)
if arr.ndim == 3 and arr.shape[2] == 4:
return arr[..., 3] > alpha_min
bgr = _to_bgr(arr)
return bgr.min(axis=2) < 245
def zncc_curve(bg_gray: np.ndarray, patch: np.ndarray, mask: np.ndarray,
y0: int, y_span: int = DEFAULT_Y_JITTER) -> Tuple[np.ndarray, int]:
"""在背景图的 y=y0±y_span 行上, 横向逐像素做零均值归一化互相关。
返回 (curve, best_y)。curve[x] 是"把拼图块左上角放在背景图 (x, best_y)"时的 ZNCC。
curve 只在 x ∈ [0, W-w] 上有定义。
"""
h, w = mask.shape
H, W = bg_gray.shape
if h > H or w > W:
raise ValueError(f"拼图块 ({w}x{h}) 比背景图 ({W}x{H}) 还大")
# 只取掩膜内的像素, 后续每列都按同一批下标取样 -> 可以向量化成一次大矩阵运算
idx_y, idx_x = np.nonzero(mask)
p = patch[idx_y, idx_x].astype(np.float32)
p = p - p.mean()
pn = float(np.sqrt((p * p).sum()))
if pn <= 1e-6:
raise ValueError("拼图块内容方差为 0, 无法做相关")
xs = np.arange(0, W - w + 1, dtype=np.int32)
curve = np.zeros(xs.shape, dtype=np.float32)
best_y, best_curve = y0, None
for y in range(max(0, y0 - y_span), min(H - h, y0 + y_span) + 1):
# 一次性把所有候选窗口的掩膜内像素取成 (len(xs), len(idx)) 矩阵
base = bg_gray[y + idx_y] # (N, W)
cols = xs[:, None] + idx_x[None, :] # (X, N)
q = base[np.arange(base.shape[0])[None, :], cols] # (X, N)
q = q.astype(np.float32)
q = q - q.mean(axis=1, keepdims=True)
denom = np.sqrt((q * q).sum(axis=1)) * pn
cur = np.where(denom > 1e-6, (q * p[None, :]).sum(axis=1) / np.maximum(denom, 1e-9), 0.0)
if best_curve is None or cur.max() > best_curve.max():
best_y, best_curve = y, cur
curve = best_curve if best_curve is not None else curve
return curve.astype(np.float64), best_y
def _peaks(curve: np.ndarray, neighbor: int, limit: int = 5) -> List[Tuple[int, float]]:
"""按分数从高到低取**互不相邻**(间距 > neighbor) 的局部峰值。"""
order = np.argsort(-curve)
picked: List[Tuple[int, float]] = []
for x in order:
xi = int(x)
if all(abs(xi - p[0]) > neighbor for p in picked):
picked.append((xi, float(curve[xi])))
if len(picked) >= limit:
break
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 外。"""
ok: bool
dx_image: float
dx_display: float
score: float
runner_up: float
margin: float
piece_x_image: float
target_x_image: float
scale: float
y_image: int = 0
candidates: List[Tuple[int, float]] = field(default_factory=list)
def describe(self) -> str:
return (f"dx_image={self.dx_image:.1f} dx_display={self.dx_display:.1f}px "
f"score={self.score:.3f} runner_up={self.runner_up:.3f} margin={self.margin:.3f} "
f"cands={[(x, round(s, 3)) for x, s in self.candidates]}")
class SliderSolver:
"""滑块缺口求解器 (需权重为 0: 纯几何 + 相关, 不需要训练/模型文件)。"""
def __init__(self, min_score: float = 0.45, min_margin: float = 0.08,
neighbor: int = DEFAULT_NEIGHBOR, alpha_min: int = DEFAULT_ALPHA_MIN,
y_jitter: int = DEFAULT_Y_JITTER) -> None:
self.min_score = float(min_score)
self.min_margin = float(min_margin)
self.neighbor = int(neighbor)
self.alpha_min = int(alpha_min)
self.y_jitter = int(y_jitter)
def solve(self, bg, piece, piece_x_image: float, scale: float = 1.0,
piece_y_image: Optional[float] = None) -> SliderResult:
"""求拖动距离。
bg 背景图 (含缺口; 对应 `img_index=1`)
piece 拼图块裁剪图 (RGBA 精灵图里裁出来的那一块; 对应 `img_index=0`)
piece_x_image 拼图块裁剪图左边缘在背景图坐标系里的 x
scale 背景图显示宽度 / 背景图原始宽度 (页面缩放系数)
piece_y_image 拼图块裁剪图上边缘在背景图坐标系里的 y (缺省 0)
"""
bg_bgr = _to_bgr(bg)
bg_gray = bg_bgr.mean(axis=2).astype(np.float32)
cand = np.asarray(piece)
patch = _to_bgr(cand).mean(axis=2).astype(np.float32)
mask = piece_mask(cand, self.alpha_min)
if mask.sum() < 50:
return SliderResult(False, 0.0, 0.0, 0.0, 0.0, 0.0,
float(piece_x_image), float(piece_x_image), float(scale))
y0 = int(round(piece_y_image or 0))
curve, best_y = zncc_curve(bg_gray, patch, mask, y0, self.y_jitter)
peaks = _peaks(curve, self.neighbor)
if not peaks:
return SliderResult(False, 0.0, 0.0, 0.0, 0.0, 0.0,
float(piece_x_image), float(piece_x_image), float(scale), best_y)
best_x, best_s = peaks[0]
runs = peaks[1][1] if len(peaks) > 1 else 0.0
margin = best_s - runs
dx_image = float(best_x) - float(piece_x_image)
ok = bool(best_s >= self.min_score and margin >= self.min_margin)
return SliderResult(ok=ok, dx_image=dx_image, dx_display=dx_image * float(scale),
score=best_s, runner_up=runs, margin=margin,
piece_x_image=float(piece_x_image),
target_x_image=float(best_x), scale=float(scale),
y_image=best_y, candidates=peaks)
# --- 调试辅助 -------------------------------------------------------
@staticmethod
def annotate(bg, result: SliderResult, piece_w: int, piece_h: int) -> np.ndarray:
"""在背景图上标出"拼图块当前位置"与"目标位置", 用于人工核对。"""
img = _to_bgr(bg).copy()
px, py = int(round(result.piece_x_image)), int(result.y_image)
tx = int(round(result.target_x_image))
cv2.rectangle(img, (px, py), (px + piece_w, py + piece_h), (0, 255, 0), 2)
cv2.rectangle(img, (tx, py), (tx + piece_w, py + piece_h), (0, 0, 255), 2)
cv2.arrowedLine(img, (px + piece_w // 2, py + piece_h + 14),
(tx + piece_w // 2, py + piece_h + 14), (255, 255, 0), 2, tipLength=0.25)
return img