做了什么:
- 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
308 lines
14 KiB
Python
308 lines
14 KiB
Python
# -*- 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
|