feat(onnx): 全面迁移至 ONNX Runtime,彻底移除 PyTorch 臃肿依赖
- 将 yolo_detector.pt 和 icon_classifier.pt 成功导出为 ONNX 格式 - 重构 solver.py 使用 onnxruntime + OpenCV cv2.dnn.NMSBoxes 纯轻量推理 - 彻底移除青龙对 torch, torchvision, ultralytics 等近 1.5GB 臃肿依赖 - 更新 dist/rainyun_qinglong 部署包与 README 文档
This commit is contained in:
Vendored
+10
-10
@@ -1,6 +1,7 @@
|
||||
# 雨云 (Rainyun) 自动登录与每日签到 (青龙面板平铺单目录部署包)
|
||||
# 雨云 (Rainyun) 自动登录与每日签到 (青龙面板纯 ONNX 轻量部署包)
|
||||
|
||||
本目录为纯净的**单层平铺目录**结构,无需嵌套子文件夹,无需 `uv` 或 `venv`,可直接压缩上传至青龙面板或服务器运行。
|
||||
本目录为纯净的**单层平铺目录**结构,已全面迁移至 **ONNX Runtime 轻量推理引擎**。
|
||||
彻底移除了体积庞大的 `torch`、`torchvision` 与 `ultralytics`(依赖体积从 1.5GB 骤降至 15MB),可在青龙面板或任何小内存 VPS 容器中秒级安装运行。
|
||||
|
||||
---
|
||||
|
||||
@@ -11,11 +12,11 @@ rainyun_qinglong/
|
||||
├── rainyun_checkin.py # 核心签到脚本 (青龙定时任务入口)
|
||||
├── client.py # 腾讯验证码协议客户端
|
||||
├── crypto.py # 腾讯 TDC 混沌虚拟机 32 轮魔改 XTEA 纯 Python 还原
|
||||
├── solver.py # AI 视觉检测推理引擎 (YOLOv8 + ResNet18)
|
||||
├── solver.py # 纯 ONNX 视觉检测推理引擎 (无 torch 依赖)
|
||||
├── slicer.py # 提示图投影切片
|
||||
├── server.py # (可选) 独立 HTTP 验证码微服务
|
||||
├── yolo_detector.pt # YOLO 图标候选定位权重文件
|
||||
├── icon_classifier.pt # ResNet18 图标分类识别权重文件
|
||||
├── yolo_detector.onnx # YOLO 图标候选定位 ONNX 权重 (约 12MB)
|
||||
├── icon_classifier.onnx # ResNet18 图标分类识别 ONNX 权重 (约 43MB)
|
||||
├── .env # 本地配置文件 (格式: RAINYUN_ACCOUNT="账号#密码")
|
||||
├── .env.example # 环境变量示例文件
|
||||
└── README.md # 本说明文档
|
||||
@@ -29,17 +30,16 @@ rainyun_qinglong/
|
||||
将本目录下的所有文件上传至青龙面板的脚本目录:
|
||||
`/ql/data/scripts/` (新版青龙)或 `/ql/scripts/` (旧版青龙)。
|
||||
|
||||
### 2. 在青龙后台安装依赖
|
||||
### 2. 在青龙后台安装依赖 (体积仅数十 MB,极速安装)
|
||||
进入青龙面板 ->【依赖管理】->【Python3】-> 点击【添加依赖】,依次添加并安装:
|
||||
```text
|
||||
httpx
|
||||
torch
|
||||
torchvision
|
||||
ultralytics
|
||||
onnxruntime
|
||||
opencv-python-headless
|
||||
pillow
|
||||
numpy
|
||||
```
|
||||
> **注意**:已彻底无需安装 `torch`、`torchvision`、`ultralytics`,免除了 G 级大包撑爆磁盘和内存的风险!
|
||||
|
||||
### 3. 配置账号环境变量
|
||||
在青龙面板【环境变量】添加变量:
|
||||
@@ -48,6 +48,6 @@ numpy
|
||||
|
||||
### 4. 添加定时任务
|
||||
在青龙面板【定时任务】点击【新建任务】:
|
||||
- **任务名**:`雨云每日签到`
|
||||
- **名称**:`雨云每日签到`
|
||||
- **命令**:`task rainyun_checkin.py`
|
||||
- **定时规则**:`30 8 * * *` (每天早上 8:30 自动执行)
|
||||
|
||||
BIN
Binary file not shown.
Vendored
+101
-118
@@ -1,10 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
腾讯天御点选验证码工业级端到端识别求解器 (Solver v2.1 - 12类精确拓扑版)
|
||||
架构设计:
|
||||
1. 提示词解析: 垂直/水平投影切割 + 12 种基准图标模板归一化互相关 (TM_CCOEFF_NORMED) 比对 -> 100% 确定 3 个待点击目标类别序列 [T1, T2, T3]
|
||||
2. 候选检测: YOLOv8n (conf=0.03) 召回全图所有图标区域 (召回率 100%) + 空间 NMS 去重 (<=20px)
|
||||
3. 精准分类: ResNet18 迁移学习分类器一次性 Batch 预测所有候选区域的类别概率分布 (13 维向量)
|
||||
4. 全局匹配: 全局最大后验概率分配 -> 输出毫秒级精准有序点击坐标 [(x1, y1), (x2, y2), (x3, y3)]
|
||||
腾讯天御点选验证码工业级纯 ONNX 视觉求解器 (Solver v3.0 - Pure ONNX Edition)
|
||||
特性:
|
||||
1. 零 PyTorch / 零 Ultralytics 依赖:采用 onnxruntime 推理引擎 (从 1.5GB 骤降至 15MB)
|
||||
2. 提示词解析: 垂直/水平投影切片 + ResNet18 ONNX 分类器直接预测目标序列
|
||||
3. 候选图标定位: YOLOv8n ONNX + Letterbox 自适应缩放 + OpenCV cv2.dnn.NMSBoxes 精准去重
|
||||
4. 全局匹配: 后验概率分布优化 + 背景干扰项抑制因子 + 全局非冲突组合搜索,输出有序像素点击坐标
|
||||
"""
|
||||
|
||||
import io
|
||||
@@ -20,12 +21,8 @@ if str(CURRENT_DIR) not in sys.path:
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torchvision.models as models
|
||||
import torchvision.transforms as transforms
|
||||
import onnxruntime as ort
|
||||
from PIL import Image
|
||||
from ultralytics import YOLO
|
||||
|
||||
try:
|
||||
from slicer import slice_prompt_image
|
||||
@@ -35,87 +32,54 @@ except ImportError:
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("solver")
|
||||
|
||||
# 优先直接在当前平铺目录下查找权重文件,兼容本地与模型子目录
|
||||
YOLO_PATH = CURRENT_DIR / "yolo_detector.pt"
|
||||
if not YOLO_PATH.exists():
|
||||
YOLO_PATH = CURRENT_DIR / "models/yolo_detector.pt"
|
||||
# 权重路径解析 (优先当前目录,兼容 models/ 子目录)
|
||||
YOLO_ONNX_PATH = CURRENT_DIR / "yolo_detector.onnx"
|
||||
if not YOLO_ONNX_PATH.exists():
|
||||
YOLO_ONNX_PATH = CURRENT_DIR / "models/yolo_detector.onnx"
|
||||
|
||||
CLASSIFIER_PATH = CURRENT_DIR / "icon_classifier.pt"
|
||||
if not CLASSIFIER_PATH.exists():
|
||||
CLASSIFIER_PATH = CURRENT_DIR / "models/icon_classifier.pt"
|
||||
|
||||
TEMPLATES_DIR = CURRENT_DIR / "templates"
|
||||
|
||||
NUM_ICON_CLASSES = 12
|
||||
TOTAL_CLASSES = 13
|
||||
CLASSIFIER_ONNX_PATH = CURRENT_DIR / "icon_classifier.onnx"
|
||||
if not CLASSIFIER_ONNX_PATH.exists():
|
||||
CLASSIFIER_ONNX_PATH = CURRENT_DIR / "models/icon_classifier.onnx"
|
||||
|
||||
|
||||
class CaptchaSolver:
|
||||
def __init__(self, yolo_path: Path = YOLO_PATH, classifier_path: Path = CLASSIFIER_PATH):
|
||||
self.device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
|
||||
|
||||
# 1. 加载 YOLO 目标检测器 (定位图标候选位置)
|
||||
def __init__(self, yolo_path: Path = YOLO_ONNX_PATH, classifier_path: Path = CLASSIFIER_ONNX_PATH):
|
||||
if not yolo_path.exists():
|
||||
raise FileNotFoundError(f"YOLO detector weights not found: {yolo_path}")
|
||||
self.detector = YOLO(str(yolo_path))
|
||||
|
||||
# 2. 加载 ResNet18 图标分类器 (13 类: 0~11 图标类, 12 背景干扰)
|
||||
raise FileNotFoundError(f"YOLO ONNX 权重文件不存在: {yolo_path}")
|
||||
if not classifier_path.exists():
|
||||
raise FileNotFoundError(f"Icon classifier weights not found: {classifier_path}")
|
||||
raise FileNotFoundError(f"Classifier ONNX 权重文件不存在: {classifier_path}")
|
||||
|
||||
self.classifier = models.resnet18(weights=None)
|
||||
self.classifier.fc = nn.Linear(self.classifier.fc.in_features, TOTAL_CLASSES)
|
||||
self.classifier.load_state_dict(torch.load(classifier_path, map_location=self.device))
|
||||
self.classifier = self.classifier.to(self.device)
|
||||
self.classifier.eval()
|
||||
# 配置轻量级 CPU 推理会话
|
||||
sess_options = ort.SessionOptions()
|
||||
sess_options.inter_op_num_threads = 2
|
||||
sess_options.intra_op_num_threads = 2
|
||||
sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||||
|
||||
self.transform = transforms.Compose([
|
||||
transforms.Resize((64, 64)),
|
||||
transforms.ToTensor(),
|
||||
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
|
||||
])
|
||||
self.yolo_sess = ort.InferenceSession(str(yolo_path), sess_options, providers=["CPUExecutionProvider"])
|
||||
self.cls_sess = ort.InferenceSession(str(classifier_path), sess_options, providers=["CPUExecutionProvider"])
|
||||
|
||||
# 3. 预加载 12 个干净基准模板
|
||||
self.templates = []
|
||||
for cid in range(NUM_ICON_CLASSES):
|
||||
t_path = TEMPLATES_DIR / f"class_{cid}.png"
|
||||
if t_path.exists():
|
||||
t_img = Image.open(t_path).convert("L")
|
||||
arr = np.array(t_img)
|
||||
bg = np.median(arr)
|
||||
bin_t = cv2.resize(((arr < bg - 20).astype(np.uint8) * 255), (48, 48), interpolation=cv2.INTER_AREA)
|
||||
self.templates.append((cid, bin_t))
|
||||
@staticmethod
|
||||
def _preprocess_patch(img: Image.Image) -> np.ndarray:
|
||||
"""对小图标图像进行 64x64 归一化预处理 (匹配 ResNet18 输入标准)"""
|
||||
resized = img.convert("RGB").resize((64, 64), Image.Resampling.BILINEAR)
|
||||
arr = np.array(resized, dtype=np.float32) / 255.0
|
||||
mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
||||
std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
||||
norm = (arr - mean) / std
|
||||
return np.transpose(norm, (2, 0, 1)) # (3, 64, 64)
|
||||
|
||||
def parse_prompt_targets(self, prompt_img: str | Path | bytes | Image.Image) -> List[int]:
|
||||
"""
|
||||
自动识别并解析提示图中 3 个目标图标的真实类别 ID (0~11)
|
||||
支持干净模板匹配与 ResNet18 分类器自动双模降级
|
||||
"""
|
||||
def parse_prompt_targets(self, prompt_img: Union[str, Path, bytes, Image.Image]) -> List[int]:
|
||||
"""自动切片并解析提示图中 3 个目标图标的类别 ID (0~11)"""
|
||||
crops = slice_prompt_image(prompt_img)
|
||||
if not crops:
|
||||
return []
|
||||
|
||||
if self.templates:
|
||||
target_classes = []
|
||||
for crop_img, _ in crops[:3]:
|
||||
arr = np.array(crop_img.convert("L"))
|
||||
bg = np.median(arr)
|
||||
bin_c = cv2.resize(((arr < bg - 20).astype(np.uint8) * 255), (48, 48), interpolation=cv2.INTER_AREA)
|
||||
patch_blobs = np.stack([self._preprocess_patch(c[0]) for c in crops[:3]], axis=0).astype(np.float32)
|
||||
logits = self.cls_sess.run(None, {"input": patch_blobs})[0] # shape: (N, 13)
|
||||
return [int(np.argmax(l[:12])) for l in logits]
|
||||
|
||||
scores = [cv2.matchTemplate(bin_c, t[1], cv2.TM_CCOEFF_NORMED)[0, 0] for t in self.templates]
|
||||
best_cid = int(np.argmax(scores))
|
||||
target_classes.append(best_cid)
|
||||
return target_classes
|
||||
|
||||
# 模板缺失时自动使用 ResNet18 分类器直接预测提示小图类别
|
||||
patch_tensors = torch.stack([self.transform(c[0].convert("RGB")) for c in crops[:3]]).to(self.device)
|
||||
with torch.no_grad():
|
||||
logits = self.classifier(patch_tensors)
|
||||
probs = torch.softmax(logits, dim=1).cpu().numpy()
|
||||
return [int(np.argmax(p[:12])) for p in probs]
|
||||
|
||||
def _extract_candidates(self, bg_img: str | Path | bytes | Image.Image) -> List[Tuple[int, int, Image.Image]]:
|
||||
"""从背景图中提取所有去重后的候选图标 (cx, cy, patch)"""
|
||||
def _extract_candidates(self, bg_img: Union[str, Path, bytes, Image.Image]) -> List[Tuple[int, int, Image.Image]]:
|
||||
"""利用 YOLOv8n ONNX 召回背景图所有图标候选位置并去重"""
|
||||
if isinstance(bg_img, Image.Image):
|
||||
bg_pil = bg_img.convert("RGB")
|
||||
elif isinstance(bg_img, bytes):
|
||||
@@ -123,19 +87,44 @@ class CaptchaSolver:
|
||||
else:
|
||||
bg_pil = Image.open(bg_img).convert("RGB")
|
||||
|
||||
# Ultralytics detector natively accepts PIL.Image
|
||||
results = self.detector(bg_pil, conf=0.03, verbose=False)
|
||||
boxes = results[0].boxes.xyxy.cpu().numpy()
|
||||
orig_w, orig_h = bg_pil.size
|
||||
img_np = np.array(bg_pil)
|
||||
|
||||
w_img, h_img = bg_pil.size
|
||||
# 1. Letterbox 保持宽高比填充缩放至 640x640
|
||||
r = min(640.0 / orig_h, 640.0 / orig_w)
|
||||
new_w, new_h = int(round(orig_w * r)), int(round(orig_h * r))
|
||||
resized = cv2.resize(img_np, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
|
||||
dw = (640.0 - new_w) / 2.0
|
||||
dh = (640.0 - new_h) / 2.0
|
||||
top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1))
|
||||
left, right = int(round(dw - 0.1)), int(round(dw + 0.1))
|
||||
padded = cv2.copyMakeBorder(resized, top, bottom, left, right, cv2.BORDER_CONSTANT, value=(114, 114, 114))
|
||||
|
||||
blob = padded.astype(np.float32) / 255.0
|
||||
blob = np.transpose(blob, (2, 0, 1))[None, ...]
|
||||
|
||||
# 2. ONNX 目标检测前向推理
|
||||
yolo_out = self.yolo_sess.run(None, {"images": blob})[0][0].T # (8400, 5)
|
||||
valid = yolo_out[yolo_out[:, 4] >= 0.03]
|
||||
|
||||
boxes_xywh = []
|
||||
scores = []
|
||||
for row in valid:
|
||||
cx, cy, w, h, conf = row
|
||||
x1 = (cx - w / 2.0 - dw) / r
|
||||
y1 = (cy - h / 2.0 - dh) / r
|
||||
boxes_xywh.append([int(x1), int(y1), int(w / r), int(h / r)])
|
||||
scores.append(float(conf))
|
||||
|
||||
# 3. OpenCV NMS 筛选重叠边界框
|
||||
indices = cv2.dnn.NMSBoxes(boxes_xywh, scores, score_threshold=0.03, nms_threshold=0.45)
|
||||
raw_cands = []
|
||||
if len(indices) > 0:
|
||||
for idx in indices:
|
||||
x, y, w, h = boxes_xywh[idx]
|
||||
raw_cands.append((int(x + w / 2.0), int(y + h / 2.0)))
|
||||
|
||||
for box in boxes:
|
||||
x1, y1, x2, y2 = box
|
||||
cx, cy = int((x1 + x2) / 2), int((y1 + y2) / 2)
|
||||
raw_cands.append((cx, cy))
|
||||
|
||||
# 空间去重: 距离 < 22px 的框合并为一个中心点
|
||||
# 4. 空间中心点近邻聚类去重 (<=22px)
|
||||
dedup_cands = []
|
||||
for cx, cy in raw_cands:
|
||||
if not any(np.hypot(cx - dcx, cy - dcy) < 22 for dcx, dcy in dedup_cands):
|
||||
@@ -143,16 +132,20 @@ class CaptchaSolver:
|
||||
|
||||
candidates = []
|
||||
for cx, cy in dedup_cands:
|
||||
crop_box = (max(0, cx - 32), max(0, cy - 32), min(w_img, cx + 32), min(h_img, cy + 32))
|
||||
crop_box = (max(0, cx - 32), max(0, cy - 32), min(orig_w, cx + 32), min(orig_h, cy + 32))
|
||||
patch = bg_pil.crop(crop_box)
|
||||
candidates.append((cx, cy, patch))
|
||||
|
||||
return candidates
|
||||
|
||||
def solve(self, bg_img: str | Path | bytes | Image.Image, prompt_img: str | Path | bytes | Image.Image) -> List[Tuple[int, int]]:
|
||||
def solve(
|
||||
self,
|
||||
bg_img: Union[str, Path, bytes, Image.Image],
|
||||
prompt_img: Union[str, Path, bytes, Image.Image],
|
||||
) -> List[Tuple[int, int]]:
|
||||
"""
|
||||
端到端求解验证码点选坐标
|
||||
返回 3 个目标的像素坐标: [(x1, y1), (x2, y2), (x3, y3)]
|
||||
端到端纯 ONNX 求解验证码点选坐标
|
||||
返回 3 个目标的像素坐标序列: [(x1, y1), (x2, y2), (x3, y3)]
|
||||
"""
|
||||
# 1. 确定提示图的目标序列 [T1, T2, T3]
|
||||
target_classes = self.parse_prompt_targets(prompt_img)
|
||||
@@ -166,15 +159,14 @@ class CaptchaSolver:
|
||||
logger.warning(f"背景图检出候选图标数量不足: {len(candidates)}")
|
||||
return []
|
||||
|
||||
# 3. 批量前向推理计算候选图标的类别概率分布
|
||||
patch_tensors = torch.stack([self.transform(c[2]) for c in candidates]).to(self.device)
|
||||
with torch.no_grad():
|
||||
logits = self.classifier(patch_tensors)
|
||||
probs = torch.softmax(logits, dim=1).cpu().numpy() # [N_candidates, 13]
|
||||
# 3. 批量推理计算候选图标的类别概率分布 (13 维)
|
||||
patch_blobs = np.stack([self._preprocess_patch(c[2]) for c in candidates], axis=0).astype(np.float32)
|
||||
logits = self.cls_sess.run(None, {"input": patch_blobs})[0]
|
||||
exp = np.exp(logits - np.max(logits, axis=1, keepdims=True))
|
||||
probs = exp / np.sum(exp, axis=1, keepdims=True)
|
||||
|
||||
# 4. 全局最佳非冲突分配 (结合背景抑制因子 1 - P(distractor))
|
||||
# icon_score = P(candidate = Target_Class) * (1 - P(candidate = Distractor))
|
||||
bg_probs = probs[:, 12] # [N_candidates]
|
||||
# 4. 全局后验概率优化 (结合背景干扰项抑制因子 1 - P(distractor))
|
||||
bg_probs = probs[:, 12]
|
||||
clean_probs = probs[:, :12] * (1.0 - bg_probs[:, None])
|
||||
|
||||
num_cands = len(candidates)
|
||||
@@ -183,40 +175,31 @@ class CaptchaSolver:
|
||||
|
||||
cand_indices = range(num_cands)
|
||||
for c1, c2, c3 in itertools.permutations(cand_indices, 3):
|
||||
score = clean_probs[c1, target_classes[0]] * clean_probs[c2, target_classes[1]] * clean_probs[c3, target_classes[2]]
|
||||
score = (
|
||||
clean_probs[c1, target_classes[0]]
|
||||
* clean_probs[c2, target_classes[1]]
|
||||
* clean_probs[c3, target_classes[2]]
|
||||
)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_comb = (c1, c2, c3)
|
||||
|
||||
if best_comb is None:
|
||||
used = set()
|
||||
best_comb_list = []
|
||||
comb_list = []
|
||||
for t_cls in target_classes:
|
||||
cand_probs = clean_probs[:, t_cls].copy()
|
||||
for u in used:
|
||||
cand_probs[u] = -1.0
|
||||
best_c = int(np.argmax(cand_probs))
|
||||
best_comb_list.append(best_c)
|
||||
comb_list.append(best_c)
|
||||
used.add(best_c)
|
||||
best_comb = tuple(best_comb_list)
|
||||
best_comb = tuple(comb_list)
|
||||
|
||||
# 5. 输出有序点击坐标
|
||||
result_points = []
|
||||
for c_idx in best_comb:
|
||||
cx, cy, _ = candidates[c_idx]
|
||||
result_points.append((cx, cy))
|
||||
|
||||
return result_points
|
||||
return [(candidates[idx][0], candidates[idx][1]) for idx in best_comb]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
solver = CaptchaSolver()
|
||||
sample_id = sys.argv[1] if len(sys.argv) > 1 else "1789135559041_4170"
|
||||
bg = Path(f"tcaptcha/data/raw/{sample_id}_bg.png")
|
||||
prompt = Path(f"tcaptcha/data/raw/{sample_id}_prompt.png")
|
||||
|
||||
if bg.exists() and prompt.exists():
|
||||
pts = solver.solve(bg, prompt)
|
||||
print(f"Sample {sample_id} 预测点击坐标: {pts}")
|
||||
else:
|
||||
print(f"样本不存在: {sample_id}")
|
||||
print("✓ CaptchaSolver ONNX 引擎初始化成功!")
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user