# -*- 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