做了什么:
- 包改为分层: 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
205 lines
7.9 KiB
Python
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")
|