做了什么:
- 包改为分层: 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
171 lines
6.1 KiB
Python
171 lines
6.1 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
腾讯天御点选验证码 OCR 识别引擎集合 (可插拔 + 集成投票)
|
|
|
|
设计动机
|
|
--------
|
|
单一 OCR 对生僻字(如 "筹")置信度极低: ddddocr 对 "筹" 的 CTC 最大概率仅 0.005,
|
|
而另一侧 PP-OCRv4 对 "雹" 又会误识为 "电"。两者互补, 因此本模块做 **概率级集成**:
|
|
对同一个字形, 把每个引擎在每个目标字上的 CTC 最大时间步概率加权求和, 再交给
|
|
全局指派算法, 可在 3 选 1 的题型上取得远高于单引擎的准确率。
|
|
|
|
引擎统一接口
|
|
------------
|
|
probabilities(gray: np.ndarray) -> (chars: List[str], probs: np.ndarray) # probs: (T, C)
|
|
|
|
gray 约定: 白底黑字的 uint8 灰度图 (字形像素值低)。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Sequence, Tuple
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
logger = logging.getLogger("tcaptcha_ocr")
|
|
|
|
# 权重在包根的 models/(本文件在 tcaptcha/ocr/ 下,故上跳一层)
|
|
MODEL_DIR = Path(__file__).resolve().parents[1] / "models"
|
|
PPOCR_DIR = MODEL_DIR / "ppocr"
|
|
|
|
|
|
class BaseOcrEngine:
|
|
name = "base"
|
|
|
|
def probabilities(self, gray: np.ndarray) -> Tuple[List[str], np.ndarray]:
|
|
raise NotImplementedError
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
class DdddOcrEngine(BaseOcrEngine):
|
|
"""ddddocr (CTC, 8210 字集) —— 对常用字准确率高, 集成中的稳健票"""
|
|
|
|
name = "ddddocr"
|
|
|
|
def __init__(self, beta: bool = False, ocr=None):
|
|
self._ocr = ocr
|
|
self._beta = beta
|
|
|
|
@property
|
|
def ocr(self):
|
|
if self._ocr is None:
|
|
import ddddocr
|
|
|
|
self._ocr = ddddocr.DdddOcr(show_ad=False, beta=self._beta)
|
|
return self._ocr
|
|
|
|
def probabilities(self, gray: np.ndarray) -> Tuple[List[str], np.ndarray]:
|
|
res = self.ocr.classification(gray, probability=True)
|
|
probs = np.asarray(res["probabilities"]) # (T, 1, C)
|
|
if probs.ndim == 3:
|
|
probs = probs[:, 0, :]
|
|
return list(res["charset"]), probs
|
|
|
|
|
|
class PpOcrEngine(BaseOcrEngine):
|
|
"""PaddleOCR PP-OCRv4 中文识别模型 (ONNX, 6623 字集) —— 对生僻字覆盖更好"""
|
|
|
|
name = "ppocr_v4"
|
|
|
|
def __init__(self, model_path: Optional[Path] = None, keys_path: Optional[Path] = None,
|
|
rec_height: int = 48):
|
|
import onnxruntime as ort
|
|
|
|
self.model_path = Path(model_path or PPOCR_DIR / "ch_PP-OCRv4_rec_infer.onnx")
|
|
self.keys_path = Path(keys_path or PPOCR_DIR / "ppocr_keys_v1.txt")
|
|
if not self.model_path.exists():
|
|
raise FileNotFoundError(
|
|
f"缺少 PP-OCRv4 识别模型: {self.model_path}\n"
|
|
"下载: https://huggingface.co/SWHL/RapidOCR/resolve/main/PP-OCRv4/ch_PP-OCRv4_rec_infer.onnx"
|
|
)
|
|
if not self.keys_path.exists():
|
|
raise FileNotFoundError(f"缺少 PP-OCR 字典: {self.keys_path}")
|
|
|
|
so = ort.SessionOptions()
|
|
so.intra_op_num_threads = 2
|
|
so.inter_op_num_threads = 2
|
|
self.sess = ort.InferenceSession(str(self.model_path), so, providers=["CPUExecutionProvider"])
|
|
self.input_name = self.sess.get_inputs()[0].name
|
|
self.rec_height = rec_height
|
|
|
|
keys = self.keys_path.read_text(encoding="utf-8").split("\n")
|
|
while keys and keys[-1] == "":
|
|
keys.pop()
|
|
# PaddleOCR 约定: 输出维度 = 1(blank) + 字典 + 1(空格)
|
|
self.chars = ["blank"] + keys + [" "]
|
|
|
|
def _preprocess(self, gray: np.ndarray) -> np.ndarray:
|
|
g = gray.astype(np.float32) / 255.0
|
|
ratio = self.rec_height / float(g.shape[0])
|
|
g = cv2.resize(g, (0, 0), fx=ratio, fy=ratio, interpolation=cv2.INTER_LINEAR)
|
|
g = (g - 0.5) / 0.5
|
|
g = g[None, None, :, :]
|
|
return np.concatenate([g, g, g], axis=1).astype(np.float32)
|
|
|
|
def probabilities(self, gray: np.ndarray) -> Tuple[List[str], np.ndarray]:
|
|
logits = self.sess.run(None, {self.input_name: self._preprocess(gray)})[0] # (1, T, C)
|
|
return self.chars, np.asarray(logits[0])
|
|
|
|
|
|
class EnsembleOcr:
|
|
"""概率级加权集成: score(t) = Σ w_i * max_t P_i(t)"""
|
|
|
|
def __init__(self, engines: Sequence[BaseOcrEngine], weights: Optional[Sequence[float]] = None):
|
|
self.engines = list(engines)
|
|
self.weights = list(weights) if weights else [1.0] * len(self.engines)
|
|
if len(self.weights) != len(self.engines):
|
|
raise ValueError("engines 与 weights 数量不一致")
|
|
|
|
@classmethod
|
|
def default(cls) -> "EnsembleOcr":
|
|
engines: List[BaseOcrEngine] = []
|
|
try:
|
|
engines.append(PpOcrEngine())
|
|
except FileNotFoundError as exc:
|
|
logger.warning("PP-OCRv4 不可用, 退化为单引擎: %s", exc)
|
|
engines.append(DdddOcrEngine())
|
|
return cls(engines)
|
|
|
|
def per_engine_scores(self, gray: np.ndarray, targets: Sequence[str]) -> List[Dict[str, float]]:
|
|
per: List[Dict[str, float]] = []
|
|
for engine in self.engines:
|
|
try:
|
|
chars, probs = engine.probabilities(gray)
|
|
except Exception as exc: # 单个引擎异常不影响整体求解
|
|
logger.warning("引擎 %s 推理失败: %s", engine.name, exc)
|
|
per.append({t: 0.0 for t in targets})
|
|
continue
|
|
index = {c: i for i, c in enumerate(chars)}
|
|
row = {}
|
|
for t in targets:
|
|
j = index.get(t)
|
|
row[t] = float(probs[:, j].max()) if j is not None else 0.0
|
|
per.append(row)
|
|
return per
|
|
|
|
def scores(self, gray: np.ndarray, targets: Sequence[str]) -> Dict[str, float]:
|
|
per = self.per_engine_scores(gray, targets)
|
|
out = {}
|
|
for t in targets:
|
|
out[t] = float(sum(w * r.get(t, 0.0) for w, r in zip(self.weights, per)))
|
|
return out
|
|
|
|
def close(self):
|
|
for engine in self.engines:
|
|
engine.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
ens = EnsembleOcr.default()
|
|
img = cv2.imread(sys.argv[1], cv2.IMREAD_GRAYSCALE)
|
|
targets = sys.argv[2:]
|
|
print(ens.per_engine_scores(img, targets))
|
|
print("ensemble:", ens.scores(img, targets))
|