Files
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

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")