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
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""识别基座:OCR 引擎与提示图切分(被 solvers 复用)。
|
||||
|
||||
engines.py ddddocr + PP-OCRv4 概率级集成识别
|
||||
slicer.py 提示图按垂直/水平投影切成单个字形小图
|
||||
"""
|
||||
@@ -0,0 +1,170 @@
|
||||
# -*- 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))
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
提示词图像自动切割模块
|
||||
将 170x50 的提示图自动切分为 3 个独立的图标小图
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
import io
|
||||
|
||||
def slice_prompt_image(prompt_img: str | Path | bytes | Image.Image, padding: int = 2) -> List[Tuple[Image.Image, Tuple[int, int, int, int]]]:
|
||||
"""
|
||||
自动识别并切割提示图中的 3 个字符/图标
|
||||
返回: [(PIL_Image, (x1, y1, x2, y2)), ...]
|
||||
"""
|
||||
if isinstance(prompt_img, Image.Image):
|
||||
img = prompt_img.convert("L")
|
||||
elif isinstance(prompt_img, bytes):
|
||||
img = Image.open(io.BytesIO(prompt_img)).convert("L")
|
||||
else:
|
||||
img = Image.open(prompt_img).convert("L")
|
||||
arr = np.array(img)
|
||||
h, w = arr.shape
|
||||
|
||||
# 背景灰度基准 (通常取左上角像素)
|
||||
bg_val = int(arr[0, 0])
|
||||
|
||||
# 提取暗色笔画像素(排除灰色背景)
|
||||
mask = arr < (bg_val - 25)
|
||||
|
||||
# 水平方向投影,找出连续包含笔画的列区间
|
||||
cols_has_dark = mask.any(axis=0)
|
||||
|
||||
segments: List[Tuple[int, int]] = []
|
||||
in_seg = False
|
||||
start = 0
|
||||
|
||||
for i, val in enumerate(cols_has_dark):
|
||||
if val and not in_seg:
|
||||
in_seg = True
|
||||
start = i
|
||||
elif not val and in_seg:
|
||||
in_seg = False
|
||||
if i - start >= 5: # 过滤过窄杂点
|
||||
segments.append((start, i))
|
||||
if in_seg and (w - start >= 5):
|
||||
segments.append((start, w))
|
||||
|
||||
# 若投影分割不足 3 段(极罕见),按固定 3 等分兜底
|
||||
if len(segments) != 3:
|
||||
step = w // 3
|
||||
segments = [(i * step + 5, (i + 1) * step - 5) for i in range(3)]
|
||||
|
||||
# 原始彩色图用于切片
|
||||
if isinstance(prompt_img, Image.Image):
|
||||
orig_img = prompt_img.convert("RGBA")
|
||||
elif isinstance(prompt_img, bytes):
|
||||
orig_img = Image.open(io.BytesIO(prompt_img)).convert("RGBA")
|
||||
else:
|
||||
orig_img = Image.open(prompt_img).convert("RGBA")
|
||||
crops = []
|
||||
|
||||
for seg_start, seg_end in segments[:3]:
|
||||
# 纵向精确裁剪
|
||||
sub_mask = mask[:, seg_start:seg_end]
|
||||
rows_has_dark = sub_mask.any(axis=1)
|
||||
row_indices = np.where(rows_has_dark)[0]
|
||||
|
||||
if len(row_indices) > 0:
|
||||
top = max(0, int(row_indices[0]) - padding)
|
||||
bottom = min(h, int(row_indices[-1]) + 1 + padding)
|
||||
else:
|
||||
top, bottom = 0, h
|
||||
|
||||
left = max(0, seg_start - padding)
|
||||
right = min(w, seg_end + padding)
|
||||
|
||||
box = (left, top, right, bottom)
|
||||
crop = orig_img.crop(box)
|
||||
crops.append((crop, box))
|
||||
|
||||
return crops
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sample = sys.argv[1] if len(sys.argv) > 1 else "tcaptcha/data/raw/1789135559041_4170_prompt.png"
|
||||
if Path(sample).exists():
|
||||
results = slice_prompt_image(sample)
|
||||
print(f"切片完成,切出 {len(results)} 个目标:")
|
||||
for idx, (c, b) in enumerate(results):
|
||||
print(f" [{idx+1}] 尺寸: {c.size}, 边界框: {b}")
|
||||
else:
|
||||
print(f"样本文件不存在: {sample}")
|
||||
Reference in New Issue
Block a user