Files
hello_js_reverse_skill/tcaptcha/solvers/text_click.py
T
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

375 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)