做了什么:
- 包改为分层: 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
375 lines
17 KiB
Python
375 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
腾讯天御『文字点选』验证码求解器 (Tencent Turing Text-Click Solver)
|
||
|
||
适用题型: subcapclass = 2404, click_cfg.data_type = ["DynAnswerType_POS"] 且提示为
|
||
纯文字指令, 例如: "请依次点击:虫 呈 般"
|
||
|
||
与 tcaptcha.solver.CaptchaSolver (图标点选 2408: 提示是 img_index=0 的图标图) 的区别:
|
||
* 本题型 **没有提示图** (img_index=0 返回 0 字节)
|
||
* 背景图里只出现与提示完全相同的 3 个汉字 (随机位置), 不含额外干扰字
|
||
* 部分题目会做 **斜切(italic)变形**, 但实测不影响颜色分割
|
||
|
||
算法管线:
|
||
1. HSV 颜色分割 (腾讯点选汉字固定为橙黄色, H≈20 S≈185 V≈228) 提取字形掩膜
|
||
2. 形态学闭运算 + 膨胀连通同一汉字的多个偏旁, 连通域得到汉字包围盒
|
||
3. 掩膜 -> 白底黑字 -> 放大, 交给 OCR 集成(ddddocr + PP-OCRv4)做 CTC 前向
|
||
4. 取 CTC 每一时间步上目标字的概率最大值 -> (字形, 目标字) 相似度矩阵
|
||
5. 对 3! 个全排列求最优指派, 并用归一化后验概率给出本次求解的置信度
|
||
(置信度低的题目直接刷新会话, 避免提交明显不可靠的答案)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import itertools
|
||
import logging
|
||
import re
|
||
from typing import Dict, List, Optional, Sequence, Tuple
|
||
|
||
import cv2
|
||
import numpy as np
|
||
from PIL import Image
|
||
|
||
from tcaptcha.ocr.engines import EnsembleOcr
|
||
|
||
logger = logging.getLogger("tcaptcha_glyph")
|
||
|
||
# 腾讯点选汉字颜色 (实测 H≈20.5 S≈200 V≈232)。
|
||
# 关键是 **V(亮度)** 而不是色相: 暖色草地/沙地的 H 会落在 24~28、S 也不低,
|
||
# 但 V 只有 120~156, 而汉字 V≈230。早期版本用 V>=145 会把草地并进字形掩膜,
|
||
# 导致 3 个字被背景吞成 1~2 个候选 (见图 proxy/fail_2、fail_3)。
|
||
HSV_LOWER = (12, 145, 190)
|
||
HSV_UPPER = (34, 255, 255)
|
||
|
||
MIN_GLYPH_AREA = 700 # 单个汉字掩膜最小像素数
|
||
MERGE_KERNEL = 13 # 膨胀核, 用于把偏旁/离散笔画合并为同一汉字
|
||
SCALE = 3 # OCR 输入放大倍数
|
||
PAD = 24 # OCR 输入白边
|
||
|
||
_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
|
||
# 滑块/图标题型的模板串关键词: 出现即说明提示里没有目标汉字
|
||
_NON_TARGET_HINTS = ("滑块", "拖动", "拼图", "请依次", "点击", "完成")
|
||
|
||
|
||
def parse_instruction(instruction: str) -> List[str]:
|
||
"""从 '请依次点击:虫 呈 般' 中解析出目标汉字序列 ['虫','呈','般']
|
||
|
||
注意: 图标点选 (2408) 与滑块 (2402/2401) 的 instruction 只有 '请依次点击:' /
|
||
'拖动下方滑块完成拼图' 这类无目标文字的模板串, 此时必须返回空列表, 否则会把
|
||
'请依次点击' 本身当成目标字。
|
||
"""
|
||
if not instruction:
|
||
return []
|
||
parts = re.split(r"[::]", instruction, maxsplit=1)
|
||
if len(parts) == 2:
|
||
return _CJK_RE.findall(parts[1])
|
||
# 没有分隔符: 只有当整体就是一个短目标字序列 (如 '虫 呈 般') 时才接受
|
||
if any(h in instruction for h in _NON_TARGET_HINTS):
|
||
return []
|
||
chars = _CJK_RE.findall(instruction)
|
||
# 文字点选恒为 3~4 个目标字, 超过该数量必然是模板句
|
||
return chars if 0 < len(chars) <= 4 else []
|
||
|
||
|
||
class Glyph:
|
||
"""一个已定位的候选汉字字形"""
|
||
|
||
__slots__ = ("x0", "y0", "x1", "y1", "cx", "cy", "mask", "ocr_text", "confidence")
|
||
|
||
def __init__(self, x0: int, y0: int, x1: int, y1: int, mask: np.ndarray):
|
||
self.x0, self.y0, self.x1, self.y1 = x0, y0, x1, y1
|
||
self.cx = (x0 + x1) // 2
|
||
self.cy = (y0 + y1) // 2
|
||
self.mask = mask
|
||
self.ocr_text = ""
|
||
self.confidence = 0.0
|
||
|
||
@property
|
||
def point(self) -> Tuple[int, int]:
|
||
return self.cx, self.cy
|
||
|
||
def gray_image(self, thick: int = 0, scale: int = SCALE) -> np.ndarray:
|
||
"""掩膜 -> 白底黑字灰度图 -> 放大 (thick>0 时加粗笔画, 用于多尺度集成)"""
|
||
m = self.mask
|
||
if thick:
|
||
m = cv2.dilate(m, np.ones((3, 3), np.uint8), iterations=thick)
|
||
gray = 255 - m
|
||
h, w = gray.shape
|
||
side = max(h, w) + PAD
|
||
canvas = np.full((side, side), 255, np.uint8)
|
||
canvas[(side - h) // 2:(side - h) // 2 + h, (side - w) // 2:(side - w) // 2 + w] = gray
|
||
return cv2.resize(canvas, (side * scale, side * scale), interpolation=cv2.INTER_LINEAR)
|
||
|
||
# 兼容旧接口
|
||
def ocr_image(self, thick: int = 0, scale: int = SCALE) -> Image.Image:
|
||
return Image.fromarray(self.gray_image(thick=thick, scale=scale))
|
||
|
||
|
||
class SolveResult:
|
||
"""一次求解结果"""
|
||
|
||
__slots__ = ("points", "targets", "glyphs", "score_matrix", "posterior", "assignments")
|
||
|
||
def __init__(self, points, targets, glyphs, score_matrix, posterior, assignments):
|
||
self.points = points
|
||
self.targets = targets
|
||
self.glyphs = glyphs
|
||
self.score_matrix = score_matrix
|
||
self.posterior = posterior
|
||
self.assignments = assignments
|
||
|
||
@property
|
||
def text_match_ok(self) -> bool:
|
||
"""**自洽性校验**: 每个被指派的字形, 在集成得分矩阵里最优的目标字, 必须正好是它被指派的目标。
|
||
|
||
注意: 不能用单引擎的 `ocr_text` 做判断 —— 那只是给肉眼看的弱信号, 实测会把
|
||
"簿"读成"垂"、"笆"读成"芭"、"筹"读成"努"; 而**用于指派的那份集成矩阵**的行 argmax
|
||
始终与指派一致。因此这里以矩阵为唯一依据 (既可靠又不会误杀正确解)。
|
||
"""
|
||
if self.score_matrix is None or not self.assignments:
|
||
return False
|
||
if len(self.assignments) != len(self.targets):
|
||
return False
|
||
for ti, g in enumerate(self.assignments):
|
||
row = self.score_matrix[g]
|
||
if row.size == 0 or int(row.argmax()) != ti:
|
||
return False
|
||
return True
|
||
|
||
@property
|
||
def ok(self) -> bool:
|
||
return (len(self.points) == len(self.targets)
|
||
and len(self.points) > 0
|
||
and self.text_match_ok)
|
||
|
||
@property
|
||
def min_target_score(self) -> float:
|
||
if not self.assignments or self.score_matrix is None:
|
||
return 0.0
|
||
return min(float(self.score_matrix[g, ti]) for ti, g in enumerate(self.assignments))
|
||
|
||
def describe(self) -> str:
|
||
detail = ", ".join(
|
||
f"{t}<-{self.glyphs[g].ocr_text or '?'}@{self.glyphs[g].point}"
|
||
f"({self.score_matrix[g, ti]:.3f})"
|
||
for ti, (t, g) in enumerate(zip(self.targets, self.assignments))
|
||
)
|
||
status = "ALL_MATCHED" if self.text_match_ok else "MISMATCH"
|
||
return f"status={status} posterior={self.posterior:.3f} [{detail}]"
|
||
|
||
|
||
class TextClickSolver:
|
||
"""腾讯天御文字点选求解器 (颜色分割 + OCR 集成概率指派)"""
|
||
|
||
def __init__(self, recognizer: Optional[EnsembleOcr] = None, min_area: int = MIN_GLYPH_AREA,
|
||
variants: Sequence[Tuple[int, int]] = ((0, SCALE), (1, SCALE)),
|
||
min_glyph_score: float = 0.02):
|
||
self.min_area = min_area
|
||
self.variants = tuple(variants)
|
||
# 颜色分割会在照片的暖色区域误检出少量"伪字形", 用识别得分把它们剔除
|
||
self.min_glyph_score = min_glyph_score
|
||
self._recognizer = recognizer
|
||
|
||
@property
|
||
def recognizer(self) -> EnsembleOcr:
|
||
if self._recognizer is None:
|
||
self._recognizer = EnsembleOcr.default()
|
||
return self._recognizer
|
||
|
||
# ------------------------------------------------------------- detection
|
||
@staticmethod
|
||
def _mask_of(bgr: np.ndarray) -> np.ndarray:
|
||
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
|
||
mask = cv2.inRange(hsv, np.array(HSV_LOWER, np.uint8), np.array(HSV_UPPER, np.uint8))
|
||
# 闭运算填掉字内高光空洞
|
||
return cv2.morphologyEx(mask, cv2.MORPH_CLOSE,
|
||
cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)))
|
||
|
||
def detect_glyphs(self, image: bytes | np.ndarray | Image.Image) -> List[Glyph]:
|
||
"""颜色分割定位背景图中的全部汉字"""
|
||
bgr = self._to_bgr(image)
|
||
mask = self._mask_of(bgr)
|
||
dilated = cv2.dilate(mask, cv2.getStructuringElement(cv2.MORPH_ELLIPSE,
|
||
(MERGE_KERNEL, MERGE_KERNEL)))
|
||
count, labels, stats, _ = cv2.connectedComponentsWithStats(dilated)
|
||
|
||
glyphs: List[Glyph] = []
|
||
for i in range(1, count):
|
||
if stats[i, cv2.CC_STAT_AREA] < self.min_area:
|
||
continue
|
||
sub = ((labels == i) & (mask > 0))
|
||
ys, xs = np.nonzero(sub)
|
||
if len(xs) == 0:
|
||
continue
|
||
x0, y0, x1, y1 = int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())
|
||
w, h = x1 - x0 + 1, y1 - y0 + 1
|
||
# 单字包围盒合理性: 过扁/过瘦/过小的多半是背景误检
|
||
if not (0.35 <= w / h <= 2.8) or w < 24 or h < 24:
|
||
continue
|
||
glyphs.append(Glyph(x0, y0, x1, y1,
|
||
(sub[y0:y1 + 1, x0:x1 + 1] * 255).astype(np.uint8)))
|
||
glyphs.sort(key=lambda g: (g.y0 // 64, g.x0))
|
||
return glyphs
|
||
|
||
def detect_glyphs_relaxed(self, image: bytes | np.ndarray | Image.Image) -> List[Glyph]:
|
||
"""放宽阈值的备用分割, 防止汉字偏暗或面积偏小导致的漏检"""
|
||
bgr = self._to_bgr(image)
|
||
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
|
||
mask = cv2.inRange(hsv, np.array((8, 85, 135), np.uint8), np.array((45, 255, 255), np.uint8))
|
||
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)))
|
||
dilated = cv2.dilate(mask, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (MERGE_KERNEL, MERGE_KERNEL)))
|
||
count, labels, stats, _ = cv2.connectedComponentsWithStats(dilated)
|
||
|
||
glyphs: List[Glyph] = []
|
||
for i in range(1, count):
|
||
if stats[i, cv2.CC_STAT_AREA] < 350:
|
||
continue
|
||
sub = ((labels == i) & (mask > 0))
|
||
ys, xs = np.nonzero(sub)
|
||
if len(xs) == 0:
|
||
continue
|
||
x0, y0, x1, y1 = int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())
|
||
w, h = x1 - x0 + 1, y1 - y0 + 1
|
||
if not (0.28 <= w / h <= 3.5) or w < 18 or h < 18:
|
||
continue
|
||
glyphs.append(Glyph(x0, y0, x1, y1, (sub[y0:y1 + 1, x0:x1 + 1] * 255).astype(np.uint8)))
|
||
glyphs.sort(key=lambda g: (g.y0 // 64, g.x0))
|
||
return glyphs
|
||
|
||
# ---------------------------------------------------------- recognition
|
||
def char_scores(self, glyph: Glyph, targets: Sequence[str]) -> Dict[str, float]:
|
||
"""对单个字形, 返回每个目标字的集成概率 (多预处理变体取最大)"""
|
||
acc = {t: 0.0 for t in targets}
|
||
best_text, best_conf = "", 0.0
|
||
for thick, scale in self.variants:
|
||
gray = glyph.gray_image(thick=thick, scale=scale)
|
||
scores = self.recognizer.scores(gray, targets)
|
||
for t, v in scores.items():
|
||
acc[t] = max(acc[t], v)
|
||
# 用集成里最强单引擎的读数做可读性展示
|
||
try:
|
||
engine = self.recognizer.engines[-1]
|
||
chars, probs = engine.probabilities(glyph.gray_image())
|
||
idx = probs.argmax(axis=1)
|
||
seq, prev = [], -1
|
||
for i in idx:
|
||
if i != 0 and i != prev and i < len(chars):
|
||
seq.append(chars[i])
|
||
prev = i
|
||
best_text = "".join(seq)
|
||
best_conf = float(probs.max(axis=1).mean())
|
||
except Exception: # 展示信息失败不影响求解
|
||
pass
|
||
glyph.ocr_text, glyph.confidence = best_text, best_conf
|
||
return acc
|
||
|
||
# ----------------------------------------------------------------- solve
|
||
def solve(self, image: bytes | np.ndarray | Image.Image, instruction: str,
|
||
retries: int = 1) -> List[Tuple[int, int]]:
|
||
"""返回提示顺序的像素点击坐标; 失败返回空列表 (兼容旧接口)"""
|
||
res = self.solve_ex(image, instruction, retries=retries)
|
||
return res.points if res.ok else []
|
||
|
||
def solve_ex(self, image: bytes | np.ndarray | Image.Image, instruction: str,
|
||
retries: int = 1) -> SolveResult:
|
||
"""完整求解, 附带置信度(归一化后验概率)与命中明细"""
|
||
targets = parse_instruction(instruction)
|
||
empty = SolveResult([], targets, [], np.zeros((0, 0)), 0.0, [])
|
||
if len(targets) < 2:
|
||
logger.warning("提示语解析失败: %r", instruction)
|
||
return empty
|
||
|
||
for attempt in range(retries):
|
||
glyphs = self.detect_glyphs(image)
|
||
if len(glyphs) < len(targets):
|
||
glyphs = self.detect_glyphs_relaxed(image)
|
||
if len(glyphs) < len(targets):
|
||
logger.warning("第 %d 次: 检出字形 %d 个 < 目标 %d 个",
|
||
attempt + 1, len(glyphs), len(targets))
|
||
if attempt + 1 < retries:
|
||
continue
|
||
return empty
|
||
if len(glyphs) > len(targets):
|
||
logger.info("检出字形 %d 个 (目标 %d 个), 进行全局指派", len(glyphs), len(targets))
|
||
|
||
matrix = np.array([[self.char_scores(g, targets)[t] for t in targets]
|
||
for g in glyphs], dtype=np.float64) # (n, m)
|
||
|
||
# 剔除伪字形: 任何目标字都识别不出来的候选不参与指派 (不影响后验置信度)
|
||
row_max = matrix.max(axis=1) if matrix.size else np.zeros(0)
|
||
strong = [i for i in range(len(glyphs)) if row_max[i] >= self.min_glyph_score]
|
||
if len(strong) < len(targets):
|
||
# 得分整体偏低(生僻字/罕见字形)时退化为取得分最高的 m 个
|
||
order = np.argsort(-row_max)[:len(targets)]
|
||
strong = sorted(int(i) for i in order)
|
||
logger.info("可用强字形不足, 退化为得分 Top-%d 候选", len(targets))
|
||
dropped = [i for i in range(len(glyphs)) if i not in strong]
|
||
if dropped:
|
||
logger.info("剔除伪字形 %s", [glyphs[i].point for i in dropped])
|
||
glyphs = [glyphs[i] for i in strong]
|
||
matrix = matrix[strong]
|
||
|
||
# 每个字形对目标字的归一化分布 (尺度无关), 避免某个字形整体概率偏低时被忽略
|
||
eps = 1e-9
|
||
row_sums = matrix.sum(axis=1, keepdims=True)
|
||
norm = (matrix + eps) / (row_sums + eps * matrix.shape[1])
|
||
|
||
best_perm, best_score, second_score = None, -1.0, 0.0
|
||
perms = list(itertools.permutations(range(len(glyphs)), len(targets)))
|
||
probs = []
|
||
for perm in perms:
|
||
p = 1.0
|
||
for t_idx, g_idx in enumerate(perm):
|
||
p *= float(norm[g_idx, t_idx])
|
||
probs.append(p)
|
||
if p > best_score:
|
||
second_score = best_score
|
||
best_score, best_perm = p, perm
|
||
elif p > second_score:
|
||
second_score = p
|
||
total = float(sum(probs)) or 1.0
|
||
posterior = float(best_score / total)
|
||
|
||
if best_perm is None:
|
||
return empty
|
||
logger.info("题目 %s -> %s", "".join(targets),
|
||
", ".join(f"{targets[t]}<-{glyphs[g].ocr_text or '?'}@{glyphs[g].point}"
|
||
f"({matrix[g, t]:.4f})" for t, g in enumerate(best_perm)))
|
||
logger.info(" 后验置信度=%.4f (次优/最优=%.4f)", posterior,
|
||
(second_score / best_score) if best_score > 0 else 1.0)
|
||
|
||
result = SolveResult([glyphs[g].point for g in best_perm], targets, glyphs,
|
||
matrix, posterior, list(best_perm))
|
||
if best_score <= 0:
|
||
logger.warning("所有候选得分均为 0, 判定求解失败")
|
||
return SolveResult([], targets, glyphs, matrix, 0.0, list(best_perm))
|
||
return result
|
||
return empty
|
||
|
||
# ------------------------------------------------------------- utilities
|
||
@staticmethod
|
||
def _to_bgr(image: bytes | np.ndarray | Image.Image) -> np.ndarray:
|
||
if isinstance(image, np.ndarray):
|
||
return image
|
||
if isinstance(image, Image.Image):
|
||
return cv2.cvtColor(np.array(image.convert("RGB")), cv2.COLOR_RGB2BGR)
|
||
arr = np.frombuffer(image, np.uint8)
|
||
bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
||
if bgr is None:
|
||
bgr = cv2.cvtColor(np.array(Image.open(io.BytesIO(image)).convert("RGB")),
|
||
cv2.COLOR_RGB2BGR)
|
||
return bgr
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
|
||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||
solver = TextClickSolver()
|
||
img_path = sys.argv[1] if len(sys.argv) > 1 else "har/1ms_dianxuan/bg_3.jpg"
|
||
instr = sys.argv[2] if len(sys.argv) > 2 else "请依次点击:虫 呈 般"
|
||
res = solver.solve_ex(open(img_path, "rb").read(), instr)
|
||
print("points:", res.points)
|
||
print("confidence:", res.posterior)
|