结论 (已知 #29~#32, 推翻旧 #25/#27 与"collect 逐字节同构"这条否定实验):
* 同一个 tdc.js URL 每次请求返回不同构建 (4/4 md5 + XTEA 密钥全不同)
* 同会话同时抓 tdc.js + verify -> 解密出真机 collect 明文 (784B/38 槽, 可打印率 .991)
* 5 份同日样本 (真机 cap2 / 签到成功-1 / 点选 / login_checkin / 仓库模板) 全部 38 槽、
同一批取值、同一个 sd, 但槽位序 5/5 互不相同
* 仓库在用的模板槽位序 == 签到成功-1 那一次; generate_collect_success() 只回填
轨迹/时间戳/entry_url, 槽位序仍是那一次的
=> 头号可证伪假设 H-LAYOUT: 服务端按"本次会话构建"的槽位表解释 cd, 用别的会话的槽位序
会被读成自相矛盾的环境 -> 12 (答案错理应是 50/9)。这也解释了为何换 IP / 换遥测后端 /
换传输指纹 / 换 cookie / 长静默全都无效 —— 它们都不动槽位序。
新增:
* scripts/analyze_collect_layout.py —— collect 槽位序对比 (复现 #31)
* tcaptcha/slider_solver.py + test —— 滑块缺口求解 (ZNCC, 真机首轮命中 ret=0)
* tcaptcha/icon_click_solver.py + test —— 图文点选形状模板求解
* scripts/bridge_1ms_captcha.py —— 真机桥接三模式 (slider/icon/text) + --probe 题型侦察
* tools/cdp_eval.js / cdp_click.js / cdp_shot.js / cdp_net_capture.js / cdp_capture.js
* selftest 纳入 icon_click / slider 两个套件
账本: §一 修正 #25/#27、新增 #29~#32; §三 标注"collect 逐字节同构"否定实验有缺陷;
§五 重写 12 的归因与下一步 (决定性实验 A + 取槽位表两条路)
227 lines
10 KiB
Python
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
|