Files
hello_js_reverse_skill/tcaptcha/icon_click_solver.py
T
yml2213 c55f833c66 feat(诊断): 用真机复现解出 1ms 成功会话的 collect 明文 —— 归因改写为「cd 槽位序按会话随机」
结论 (已知 #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 + 取槽位表两条路)
2026-10-11 23:59:42 +08:00

205 lines
7.9 KiB
Python

# -*- coding: utf-8 -*-
"""
腾讯天御『图标/图文混合点选』求解器 (subcapclass 2408 / 2401 之外的图形点选)
适用题型 (1ms.run aid=195672344 实测,2026-10-11):
提示条 `img_index=0` 是 170x50 的**目标原图条**(按点击顺序排列, 例如: "0" + 图标 + "7"),
背景图 `img_index=1` 是 672x480 的实景照片, 上面画着若干**纯黑轮廓**的符号
(目标 + 干扰项), 部分目标被**旋转/倾斜**。
与 `glyph_solver.TextClickSolver`(汉字点选 2404) 的区别:
* 2404: 指令是纯文字 ("请依次点击:本 车 憋"), 背景是**彩色**(橙黄)汉字, 用 HSV 分割 + OCR
* 本类: 指令是**图标/数字**, 提示以**图片**给出, 背景符号是**纯黑轮廓**, 且会被旋转
算法 (不重训分类器, 直接利用"提示条给的就是目标原图"这一事实):
1. 背景/提示条都按"近黑像素"分割成字形掩膜 (符号是纯黑, 背景是照片)
2. 提示条连通域 = 有序目标模板 (按 x 排序)
3. 背景连通域 = 候选符号
4. 每个目标 vs 每个候选: 二值掩膜归一化(等比缩放到正方形)后做
**旋转搜索 + IoU** 相似度 (旋转不变, 正好对付倾斜干扰)
5. 用匈牙利/贪心做**一对一**指派, 按提示顺序输出点击坐标
坐标口径: 输出与腾讯 `ans` 相同的**原图坐标系 (672x480)**。
"""
from __future__ import annotations
import io
from dataclasses import dataclass
from typing import List, Optional, Sequence, Tuple
import cv2
import numpy as np
from PIL import Image
# 近黑阈值: 符号是纯黑(<=60), 照片最暗处也远高于此; 用"三通道都暗"避免蓝色天空误检
DARK_MAX = 95
MERGE_KERNEL = 5
MIN_AREA = 80
MAX_AREA = 9000
def _to_bgr(image) -> np.ndarray:
if isinstance(image, np.ndarray):
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 glyph_mask(bgr: np.ndarray, dark_max: int = DARK_MAX, kernel: int = MERGE_KERNEL,
dilate: int = 3) -> np.ndarray:
"""近黑像素 -> 掩膜 (取三通道最大值, 只有真正的黑色轮廓会通过)。
kernel>1 时做闭运算合并同一符号的笔画; dilate>0 时再膨胀一点 (分割阶段用)。
"""
mx = bgr.max(axis=2)
m = (mx < dark_max).astype(np.uint8) * 255
if kernel > 1:
m = cv2.morphologyEx(m, cv2.MORPH_CLOSE,
cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel, kernel)))
if dilate > 0:
m = cv2.dilate(m, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (dilate, dilate)))
return m
def components(mask: np.ndarray, min_area: int = MIN_AREA, max_area: int = MAX_AREA) -> List[Tuple[int, int, int, int, int]]:
n, lab, stats, _ = cv2.connectedComponentsWithStats(mask)
out = []
for i in range(1, n):
x, y, w, h, a = (int(v) for v in stats[i])
if a < min_area or a > max_area:
continue
if w < 6 or h < 6:
continue
out.append((x, y, w, h, a))
return out
def _norm(bgr: np.ndarray, box, size: int = 64) -> np.ndarray:
"""把 box 内的符号归一化成 size x size 的二值掩膜 (等比居中, 不拉伸)。
注意: 画布必须用**白色**填充 —— 黑色会被"近黑"掩膜当成字形 (踩过这个坑)。
"""
x, y, w, h, _ = box
crop = bgr[y:y + h, x:x + w]
s = max(w, h)
canvas = np.full((s, s, 3), 255, np.uint8)
canvas[(s - h) // 2:(s - h) // 2 + h, (s - w) // 2:(s - w) // 2 + w] = crop
small = cv2.resize(canvas, (size, size), interpolation=cv2.INTER_AREA)
return glyph_mask(small, dark_max=140, kernel=3, dilate=0) > 127
def _rotated(mask: np.ndarray, ang: float, size: int = 64) -> np.ndarray:
M = cv2.getRotationMatrix2D((size / 2, size / 2), ang, 1.0)
r = cv2.warpAffine(mask.astype(np.uint8) * 255, M, (size, size))
return r > 127
def _iou(a: np.ndarray, b: np.ndarray) -> float:
inter = np.logical_and(a, b).sum()
union = np.logical_or(a, b).sum()
return float(inter) / float(union) if union else 0.0
@dataclass
class IconClickResult:
points: List[Tuple[int, int]] # 原图坐标 (672x480 口径)
scores: List[float]
angles: List[float]
boxes: List[Tuple[int, int, int, int]]
target_count: int
@property
def ok(self) -> bool:
return len(self.points) == self.target_count and self.target_count > 0
@property
def min_score(self) -> float:
return min(self.scores) if self.scores else 0.0
def describe(self) -> str:
return " ".join("(%d,%d|%.2f@%d°)" % (p[0], p[1], s, a)
for p, s, a in zip(self.points, self.scores, self.angles))
class IconClickSolver:
"""提示图(目标条) + 背景图 -> 有序点击坐标。"""
def __init__(self, angles: Sequence[float] = tuple(range(-45, 46, 5)),
size: int = 64, min_area: int = MIN_AREA):
self.angles = tuple(angles)
self.size = size
self.min_area = min_area
def solve(self, bg, prompt) -> IconClickResult:
bg_bgr = _to_bgr(bg)
pr_bgr = _to_bgr(prompt)
targets = sorted(components(glyph_mask(pr_bgr), self.min_area), key=lambda b: b[0])
cands = components(glyph_mask(bg_bgr), self.min_area)
if not targets or not cands:
return IconClickResult([], [], [], [], len(targets))
tm = [_norm(pr_bgr, b, self.size) for b in targets]
cm = [_norm(bg_bgr, b, self.size) for b in cands]
# 候选旋转缓存
rot = [[(a, _rotated(c, a, self.size)) for a in self.angles] for c in cm]
# 得分矩阵: 每个目标在 (候选, 角度) 上的最大 IoU
S = np.zeros((len(tm), len(cm)), np.float32)
A = np.zeros((len(tm), len(cm)), np.float32)
for ti, t in enumerate(tm):
for ci, rs in enumerate(rot):
best = (-1.0, 0.0)
for ang, r in rs:
v = _iou(t, r)
if v > best[0]:
best = (v, ang)
S[ti, ci], A[ti, ci] = best
# 贪心一对一指派 (按全局得分从高到低)
pairs = sorted(((S[i, j], i, j) for i in range(len(tm)) for j in range(len(cm))),
key=lambda x: -x[0])
used_t, used_c = set(), set()
assign = {}
for v, i, j in pairs:
if i in used_t or j in used_c:
continue
used_t.add(i)
used_c.add(j)
assign[i] = (j, v, A[i, j])
points, scores, angs, boxes = [], [], [], []
for i in range(len(tm)):
if i not in assign:
continue
j, v, ang = assign[i]
x, y, w, h, _ = cands[j]
points.append((x + w // 2, y + h // 2))
scores.append(v)
angs.append(ang)
boxes.append((x, y, w, h))
return IconClickResult(points, scores, angs, boxes, len(tm))
def annotate(self, bg, result: IconClickResult) -> np.ndarray:
"""把结果画到背景图上 (自检用)。"""
img = _to_bgr(bg).copy()
for idx, ((px, py), sc) in enumerate(zip(result.points, result.scores), 1):
cv2.circle(img, (px, py), 26, (0, 0, 255), 3)
cv2.putText(img, str(idx), (px - 8, py + 8), cv2.FONT_HERSHEY_SIMPLEX,
1.0, (0, 255, 0), 2)
return img
if __name__ == "__main__":
import sys
bg = cv2.imread(sys.argv[1] if len(sys.argv) > 1 else "/tmp/1mscap/bg.jpg")
pr = cv2.imread(sys.argv[2] if len(sys.argv) > 2 else "/tmp/1mscap/prompt.jpg")
s = IconClickSolver()
r = s.solve(bg, pr)
print("ok=%s points=%s scores=%s angles=%s" % (r.ok, r.points, [round(x, 3) for x in r.scores], r.angles))
out = s.annotate(bg, r)
cv2.imwrite("/tmp/1mscap/annotated.jpg", out)
print("annotated -> /tmp/1mscap/annotated.jpg")