Files
hello_js_reverse_skill/tcaptcha/solvers/slider.py
T
yml2213 344eea2194 refactor(结构): 分层重组 + 清除全部废弃路线
做了什么:
- 包改为分层: tcaptcha/{protocol,telemetry,solvers,ocr,adapters,models,tests}
  * protocol/  client.py  transport.py  proxy.py
  * telemetry/ crypto.py  values.py  key.py  opcodes.py
               disasm.py  program.py  layout.py  modules.py
  * solvers/   text_click.py  icon_click.py  icon_click_rotate.py  slider.py
  * ocr/       engines.py  slicer.py
  * adapters/  one_ms.py
- tools/ 里原本是**包运行时依赖**的 tdc_{disasm,program,layout,modules}.py 并入 telemetry/
  (放在 tools/ 属于分层倒置),tools/ 只留 CLI: tdc_key_pure.py / tdc_profile.py / cdp_*.js
- crypto.py 529 -> 340 行: 切除「成功样本模板回填」与「Node 插桩兜底」两条老路;
  generate_telemetry 只剩 python_values 一种模式,传别的会直接报错并说明原因
- 求解器改名分清: solver.py -> solvers/icon_click.py(client 实际在用的 YOLO+ResNet18 ONNX),
  icon_click_solver.py -> solvers/icon_click_rotate.py(仅测试在用,留作对照)
- pyproject: 去掉 ultralytics/onnx(训练栈已删),补上真正在用的 onnxruntime

删除了什么:
- 雨云整条线 + dist/rainyun_qinglong(104M 旧快照)
- 模板回填: models/success_collect_template.b64、tdc_worker.js、analyze_collect_layout.py
- 「补环境」时代浏览器 JS: chrome_fetch/manual_captcha/probe_captcha/solve_in_browser/tdc_browser
- 旧 VM 探索工具 10 个(tdc_collectgen/tdc_slotmap/tdc_lift/tdc_vm_run/tdc_query/tdc_probe 等)
  + 8 个基于它们的旧 verify 脚本
- 训练/标注/评估: train_classifier/train_detector/prepare_yolo/web_labeler/collector/
  siamese/evaluate/benchmark_verify
- tcaptcha/core_js/(60 个分析产物)、TOOLCHAIN_REPORT.md(整篇在描述已删工具)
- 3 个已无任何引用的 PyTorch 权重:icon_classifier.pt 43M / yolo_detector.pt 5.9M /
  siamese_matcher.pt 1.6M —— 线上只跑 onnx,tcaptcha/ 117M -> 66M

为什么:
- 项目后面要专门承载「滑块 / 文字点选 / 图标点选」三种求解器;扁平包 + 运行时依赖混在
  tools/ + 两条已废弃的 collect 生成路线,会让后续扩展持续踩坑

怎么验证:
- .venv/bin/python -m tcaptcha.selftest                    全绿
- scripts/verify/verify_offline.py                         逐槽全等 + 分段几何一致
- scripts/verify/verify_values.py                          4 份真机明文 152/152 逐槽全等
- scripts/verify/send_test.py --mode python_values         1ms 签到页文字点选 errorCode=0
- 回滚点: tag backup/pre-refactor-20261012
2026-10-12 12:34:57 +08:00

227 lines
10 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
from dataclasses import dataclass, field
from typing import 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
@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