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,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""求解器层:把一道题解成点击坐标 / 滑动距离。
|
||||
|
||||
统一约定(后续新增题型沿用):
|
||||
solver.solve(...) -> 结果对象,带 .points / .ok / .describe()
|
||||
* text_click 2404 文字点选(颜色分割 + OCR 集成指派)
|
||||
* icon_click 2408 图标点选(YOLOv8 ONNX 定位 + ResNet18 ONNX 分类)
|
||||
* icon_click_rotate 另一版图标点选(旋转角模板匹配,备用/对照)
|
||||
* slider 2402 滑块拼图(缺口定位)
|
||||
"""
|
||||
@@ -0,0 +1,193 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
腾讯天御点选验证码工业级纯 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
|
||||
import itertools
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Union
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import onnxruntime as ort
|
||||
from PIL import Image
|
||||
|
||||
from tcaptcha.ocr.slicer import slice_prompt_image
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 权重在包根的 models/(本文件在 tcaptcha/solvers/ 下,故上跳一层)
|
||||
MODEL_DIR = Path(__file__).resolve().parents[1] / "models"
|
||||
YOLO_ONNX_PATH = MODEL_DIR / "yolo_detector.onnx"
|
||||
CLASSIFIER_ONNX_PATH = MODEL_DIR / "icon_classifier.onnx"
|
||||
|
||||
|
||||
class CaptchaSolver:
|
||||
def __init__(self, yolo_path: Path = YOLO_ONNX_PATH, classifier_path: Path = CLASSIFIER_ONNX_PATH):
|
||||
if not yolo_path.exists():
|
||||
raise FileNotFoundError(f"YOLO ONNX 权重文件不存在: {yolo_path}")
|
||||
if not classifier_path.exists():
|
||||
raise FileNotFoundError(f"Classifier ONNX 权重文件不存在: {classifier_path}")
|
||||
|
||||
# 配置轻量级 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.yolo_sess = ort.InferenceSession(str(yolo_path), sess_options, providers=["CPUExecutionProvider"])
|
||||
self.cls_sess = ort.InferenceSession(str(classifier_path), sess_options, providers=["CPUExecutionProvider"])
|
||||
|
||||
@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: Union[str, Path, bytes, Image.Image]) -> List[int]:
|
||||
"""自动切片并解析提示图中 3 个目标图标的类别 ID (0~11)"""
|
||||
crops = slice_prompt_image(prompt_img)
|
||||
if not crops:
|
||||
return []
|
||||
|
||||
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]
|
||||
|
||||
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):
|
||||
bg_pil = Image.open(io.BytesIO(bg_img)).convert("RGB")
|
||||
else:
|
||||
bg_pil = Image.open(bg_img).convert("RGB")
|
||||
|
||||
orig_w, orig_h = bg_pil.size
|
||||
img_np = np.array(bg_pil)
|
||||
|
||||
# 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)))
|
||||
|
||||
# 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):
|
||||
dedup_cands.append((cx, cy))
|
||||
|
||||
candidates = []
|
||||
for cx, cy in dedup_cands:
|
||||
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: Union[str, Path, bytes, Image.Image],
|
||||
prompt_img: Union[str, Path, bytes, Image.Image],
|
||||
) -> List[Tuple[int, int]]:
|
||||
"""
|
||||
端到端纯 ONNX 求解验证码点选坐标
|
||||
返回 3 个目标的像素坐标序列: [(x1, y1), (x2, y2), (x3, y3)]
|
||||
"""
|
||||
# 1. 确定提示图的目标序列 [T1, T2, T3]
|
||||
target_classes = self.parse_prompt_targets(prompt_img)
|
||||
if len(target_classes) < 3:
|
||||
logger.warning("提示图未成功切出 3 个目标")
|
||||
return []
|
||||
|
||||
# 2. 定位背景图所有候选图标
|
||||
candidates = self._extract_candidates(bg_img)
|
||||
if len(candidates) < 3:
|
||||
logger.warning(f"背景图检出候选图标数量不足: {len(candidates)}")
|
||||
return []
|
||||
|
||||
# 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))
|
||||
bg_probs = probs[:, 12]
|
||||
clean_probs = probs[:, :12] * (1.0 - bg_probs[:, None])
|
||||
|
||||
num_cands = len(candidates)
|
||||
best_score = -1.0
|
||||
best_comb = None
|
||||
|
||||
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]]
|
||||
)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_comb = (c1, c2, c3)
|
||||
|
||||
if best_comb is None:
|
||||
used = set()
|
||||
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))
|
||||
comb_list.append(best_c)
|
||||
used.add(best_c)
|
||||
best_comb = tuple(comb_list)
|
||||
|
||||
# 5. 输出有序点击坐标
|
||||
return [(candidates[idx][0], candidates[idx][1]) for idx in best_comb]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
solver = CaptchaSolver()
|
||||
print("✓ CaptchaSolver ONNX 引擎初始化成功!")
|
||||
@@ -0,0 +1,204 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
腾讯天御『图标/图文混合点选』求解器 (subcapclass 2408 / 2401 之外的图形点选)
|
||||
|
||||
适用题型 (1ms.run aid=195672344 实测,2026-10-11):
|
||||
提示条 `img_index=0` 是 170x50 的**目标原图条**(按点击顺序排列, 例如: "0" + 图标 + "7"),
|
||||
背景图 `img_index=1` 是 672x480 的实景照片, 上面画着若干**纯黑轮廓**的符号
|
||||
(目标 + 干扰项), 部分目标被**旋转/倾斜**。
|
||||
|
||||
与 `glyph_solver.TextClickSolver`(汉字点选 2404) 的区别:
|
||||
* 2404: 指令是纯文字 ("请依次点击:本 车 憋"), 背景是**彩色**(橙黄)汉字, 用 HSV 分割 + OCR
|
||||
* 本类: 指令是**图标/数字**, 提示以**图片**给出, 背景符号是**纯黑轮廓**, 且会被旋转
|
||||
|
||||
算法 (不重训分类器, 直接利用"提示条给的就是目标原图"这一事实):
|
||||
1. 背景/提示条都按"近黑像素"分割成字形掩膜 (符号是纯黑, 背景是照片)
|
||||
2. 提示条连通域 = 有序目标模板 (按 x 排序)
|
||||
3. 背景连通域 = 候选符号
|
||||
4. 每个目标 vs 每个候选: 二值掩膜归一化(等比缩放到正方形)后做
|
||||
**旋转搜索 + IoU** 相似度 (旋转不变, 正好对付倾斜干扰)
|
||||
5. 用匈牙利/贪心做**一对一**指派, 按提示顺序输出点击坐标
|
||||
|
||||
坐标口径: 输出与腾讯 `ans` 相同的**原图坐标系 (672x480)**。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# 近黑阈值: 符号是纯黑(<=60), 照片最暗处也远高于此; 用"三通道都暗"避免蓝色天空误检
|
||||
DARK_MAX = 95
|
||||
MERGE_KERNEL = 5
|
||||
MIN_AREA = 80
|
||||
MAX_AREA = 9000
|
||||
|
||||
|
||||
def _to_bgr(image) -> np.ndarray:
|
||||
if isinstance(image, np.ndarray):
|
||||
return image
|
||||
if isinstance(image, (bytes, bytearray)):
|
||||
arr = np.frombuffer(image, np.uint8)
|
||||
return cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
||||
return cv2.cvtColor(np.asarray(image.convert("RGB")), cv2.COLOR_RGB2BGR)
|
||||
|
||||
|
||||
def glyph_mask(bgr: np.ndarray, dark_max: int = DARK_MAX, kernel: int = MERGE_KERNEL,
|
||||
dilate: int = 3) -> np.ndarray:
|
||||
"""近黑像素 -> 掩膜 (取三通道最大值, 只有真正的黑色轮廓会通过)。
|
||||
|
||||
kernel>1 时做闭运算合并同一符号的笔画; dilate>0 时再膨胀一点 (分割阶段用)。
|
||||
"""
|
||||
mx = bgr.max(axis=2)
|
||||
m = (mx < dark_max).astype(np.uint8) * 255
|
||||
if kernel > 1:
|
||||
m = cv2.morphologyEx(m, cv2.MORPH_CLOSE,
|
||||
cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel, kernel)))
|
||||
if dilate > 0:
|
||||
m = cv2.dilate(m, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (dilate, dilate)))
|
||||
return m
|
||||
|
||||
|
||||
def components(mask: np.ndarray, min_area: int = MIN_AREA, max_area: int = MAX_AREA) -> List[Tuple[int, int, int, int, int]]:
|
||||
n, lab, stats, _ = cv2.connectedComponentsWithStats(mask)
|
||||
out = []
|
||||
for i in range(1, n):
|
||||
x, y, w, h, a = (int(v) for v in stats[i])
|
||||
if a < min_area or a > max_area:
|
||||
continue
|
||||
if w < 6 or h < 6:
|
||||
continue
|
||||
out.append((x, y, w, h, a))
|
||||
return out
|
||||
|
||||
|
||||
def _norm(bgr: np.ndarray, box, size: int = 64) -> np.ndarray:
|
||||
"""把 box 内的符号归一化成 size x size 的二值掩膜 (等比居中, 不拉伸)。
|
||||
|
||||
注意: 画布必须用**白色**填充 —— 黑色会被"近黑"掩膜当成字形 (踩过这个坑)。
|
||||
"""
|
||||
x, y, w, h, _ = box
|
||||
crop = bgr[y:y + h, x:x + w]
|
||||
s = max(w, h)
|
||||
canvas = np.full((s, s, 3), 255, np.uint8)
|
||||
canvas[(s - h) // 2:(s - h) // 2 + h, (s - w) // 2:(s - w) // 2 + w] = crop
|
||||
small = cv2.resize(canvas, (size, size), interpolation=cv2.INTER_AREA)
|
||||
return glyph_mask(small, dark_max=140, kernel=3, dilate=0) > 127
|
||||
|
||||
|
||||
def _rotated(mask: np.ndarray, ang: float, size: int = 64) -> np.ndarray:
|
||||
M = cv2.getRotationMatrix2D((size / 2, size / 2), ang, 1.0)
|
||||
r = cv2.warpAffine(mask.astype(np.uint8) * 255, M, (size, size))
|
||||
return r > 127
|
||||
|
||||
|
||||
def _iou(a: np.ndarray, b: np.ndarray) -> float:
|
||||
inter = np.logical_and(a, b).sum()
|
||||
union = np.logical_or(a, b).sum()
|
||||
return float(inter) / float(union) if union else 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class IconClickResult:
|
||||
points: List[Tuple[int, int]] # 原图坐标 (672x480 口径)
|
||||
scores: List[float]
|
||||
angles: List[float]
|
||||
boxes: List[Tuple[int, int, int, int]]
|
||||
target_count: int
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return len(self.points) == self.target_count and self.target_count > 0
|
||||
|
||||
@property
|
||||
def min_score(self) -> float:
|
||||
return min(self.scores) if self.scores else 0.0
|
||||
|
||||
def describe(self) -> str:
|
||||
return " ".join("(%d,%d|%.2f@%d°)" % (p[0], p[1], s, a)
|
||||
for p, s, a in zip(self.points, self.scores, self.angles))
|
||||
|
||||
|
||||
class IconClickSolver:
|
||||
"""提示图(目标条) + 背景图 -> 有序点击坐标。"""
|
||||
|
||||
def __init__(self, angles: Sequence[float] = tuple(range(-45, 46, 5)),
|
||||
size: int = 64, min_area: int = MIN_AREA):
|
||||
self.angles = tuple(angles)
|
||||
self.size = size
|
||||
self.min_area = min_area
|
||||
|
||||
def solve(self, bg, prompt) -> IconClickResult:
|
||||
bg_bgr = _to_bgr(bg)
|
||||
pr_bgr = _to_bgr(prompt)
|
||||
|
||||
targets = sorted(components(glyph_mask(pr_bgr), self.min_area), key=lambda b: b[0])
|
||||
cands = components(glyph_mask(bg_bgr), self.min_area)
|
||||
if not targets or not cands:
|
||||
return IconClickResult([], [], [], [], len(targets))
|
||||
|
||||
tm = [_norm(pr_bgr, b, self.size) for b in targets]
|
||||
cm = [_norm(bg_bgr, b, self.size) for b in cands]
|
||||
# 候选旋转缓存
|
||||
rot = [[(a, _rotated(c, a, self.size)) for a in self.angles] for c in cm]
|
||||
|
||||
# 得分矩阵: 每个目标在 (候选, 角度) 上的最大 IoU
|
||||
S = np.zeros((len(tm), len(cm)), np.float32)
|
||||
A = np.zeros((len(tm), len(cm)), np.float32)
|
||||
for ti, t in enumerate(tm):
|
||||
for ci, rs in enumerate(rot):
|
||||
best = (-1.0, 0.0)
|
||||
for ang, r in rs:
|
||||
v = _iou(t, r)
|
||||
if v > best[0]:
|
||||
best = (v, ang)
|
||||
S[ti, ci], A[ti, ci] = best
|
||||
|
||||
# 贪心一对一指派 (按全局得分从高到低)
|
||||
pairs = sorted(((S[i, j], i, j) for i in range(len(tm)) for j in range(len(cm))),
|
||||
key=lambda x: -x[0])
|
||||
used_t, used_c = set(), set()
|
||||
assign = {}
|
||||
for v, i, j in pairs:
|
||||
if i in used_t or j in used_c:
|
||||
continue
|
||||
used_t.add(i)
|
||||
used_c.add(j)
|
||||
assign[i] = (j, v, A[i, j])
|
||||
|
||||
points, scores, angs, boxes = [], [], [], []
|
||||
for i in range(len(tm)):
|
||||
if i not in assign:
|
||||
continue
|
||||
j, v, ang = assign[i]
|
||||
x, y, w, h, _ = cands[j]
|
||||
points.append((x + w // 2, y + h // 2))
|
||||
scores.append(v)
|
||||
angs.append(ang)
|
||||
boxes.append((x, y, w, h))
|
||||
return IconClickResult(points, scores, angs, boxes, len(tm))
|
||||
|
||||
def annotate(self, bg, result: IconClickResult) -> np.ndarray:
|
||||
"""把结果画到背景图上 (自检用)。"""
|
||||
img = _to_bgr(bg).copy()
|
||||
for idx, ((px, py), sc) in enumerate(zip(result.points, result.scores), 1):
|
||||
cv2.circle(img, (px, py), 26, (0, 0, 255), 3)
|
||||
cv2.putText(img, str(idx), (px - 8, py + 8), cv2.FONT_HERSHEY_SIMPLEX,
|
||||
1.0, (0, 255, 0), 2)
|
||||
return img
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
bg = cv2.imread(sys.argv[1] if len(sys.argv) > 1 else "/tmp/1mscap/bg.jpg")
|
||||
pr = cv2.imread(sys.argv[2] if len(sys.argv) > 2 else "/tmp/1mscap/prompt.jpg")
|
||||
s = IconClickSolver()
|
||||
r = s.solve(bg, pr)
|
||||
print("ok=%s points=%s scores=%s angles=%s" % (r.ok, r.points, [round(x, 3) for x in r.scores], r.angles))
|
||||
out = s.annotate(bg, r)
|
||||
cv2.imwrite("/tmp/1mscap/annotated.jpg", out)
|
||||
print("annotated -> /tmp/1mscap/annotated.jpg")
|
||||
@@ -0,0 +1,226 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
腾讯天御『滑块拼图』求解器 (drag 型验证, 1ms.run aid=195672344 实测 2026-10-11)
|
||||
|
||||
题型结构 (真机 iframe `drag_ele.html` 实测):
|
||||
* 背景图 `img_index=1`: 一张实景照片, 上面画着 **1~2 个缺口** (真缺口 + 干扰缺口)。
|
||||
缺口 = 与拼图块**同形**的挖空, 内部是"同一张图被压暗"的结果, 外沿一圈浅色描边。
|
||||
* 拼图块 `img_index=0`: 一张**精灵图**, 里面同时含拖动柄和拼图块本体。页面用
|
||||
`background-position` / `background-size` 把拼图块那一块裁出来, 显示在滑轨左侧。
|
||||
* 拖动柄(滑轨)与拼图块**同步横移**, 位移 1:1 (见 `har/1ms_dianxuan/PROGRESS.md`)。
|
||||
|
||||
为什么不用"轮廓/暗区阈值"直接找缺口:
|
||||
背景照片本身有大量强边缘和暗区, 阈值法的召回/精确率都不够 (实测: 用局部变暗比
|
||||
找缺口会被照片自身的暗部淹没)。但有一个**结构性**事实可以用:
|
||||
|
||||
"拼图块里装的是**未被压暗的原始内容**, 而缺口里装的是**同一块内容被压暗**的结果。"
|
||||
|
||||
于是 ZNCC(拼图块内容, 背景图窗口) 的峰值必然落在**真缺口**上; 干扰缺口内部装的是
|
||||
**别处**的内容, 相关性明显更低。实测区分度 (本仓库 fixture):
|
||||
|
||||
真缺口 0.732
|
||||
干扰缺口 0.551
|
||||
拼图块当前位置 -0.136 (拼图块盖住的是完好的背景, 那里没有洞)
|
||||
其余局部峰值 <= 0.28
|
||||
|
||||
坐标口径 (重要):
|
||||
* 输入 `piece_x_image` / `piece_y_image` 是**背景图坐标系**(原始像素, 未经页面缩放);
|
||||
* 输出 `dx_display` 是**页面显示像素**的横向拖动距离, 即 `dx_image * scale`,
|
||||
其中 `scale = 背景图显示宽度 / 背景图原始宽度`。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
# 拼图块精灵图/背景图的 alpha 阈值 (精灵图是 RGBA, 背景是照片)
|
||||
DEFAULT_ALPHA_MIN = 128
|
||||
# 局部峰值去重半径 (背景图坐标): 同一个峰附近的点算同一个候选
|
||||
DEFAULT_NEIGHBOR = 20
|
||||
# 纵向搜索范围 (背景图坐标): 允许上下抖 ±N 像素, 吸收取整/描边误差
|
||||
DEFAULT_Y_JITTER = 3
|
||||
|
||||
|
||||
def _to_bgr(image) -> np.ndarray:
|
||||
"""把 bytes / PIL / ndarray 统一成 BGR 三通道 ndarray。"""
|
||||
if isinstance(image, np.ndarray):
|
||||
if image.ndim == 3 and image.shape[2] == 4:
|
||||
return cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
|
||||
return image
|
||||
if isinstance(image, (bytes, bytearray)):
|
||||
arr = np.frombuffer(image, np.uint8)
|
||||
return cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
||||
return cv2.cvtColor(np.asarray(image.convert("RGB")), cv2.COLOR_RGB2BGR)
|
||||
|
||||
|
||||
def sprite_region(bg_position: Sequence[float], bg_size: Sequence[float],
|
||||
elem_size: Sequence[float], sprite_shape: Sequence[int]) -> Tuple[int, int, int, int]:
|
||||
"""把 CSS 的 `background-position` / `background-size` 换算成精灵图里的像素区域。
|
||||
|
||||
参数:
|
||||
bg_position: 元素上的 background-position, 形如 (-70.8333, -247.917) (CSS px)
|
||||
bg_size: 元素上的 background-size, 形如 (345.06, 313.69) (CSS px, 已缩放后的整图)
|
||||
elem_size: 元素的显示尺寸, 形如 (60.7143, 60.7143) (CSS px)
|
||||
sprite_shape: 精灵图原始尺寸 (H, W)
|
||||
|
||||
返回 (x, y, w, h), 即该元素在**精灵图原始像素**里对应的矩形。
|
||||
"""
|
||||
sx, sy = float(bg_size[0]), float(bg_size[1])
|
||||
h_sprite, w_sprite = int(sprite_shape[0]), int(sprite_shape[1])
|
||||
kx, ky = w_sprite / sx, h_sprite / sy # CSS px -> 精灵图 px
|
||||
x = int(round(-float(bg_position[0]) * kx))
|
||||
y = int(round(-float(bg_position[1]) * ky))
|
||||
w = int(round(float(elem_size[0]) * kx))
|
||||
h = int(round(float(elem_size[1]) * ky))
|
||||
return x, y, w, h
|
||||
|
||||
|
||||
def piece_mask(piece, alpha_min: int = DEFAULT_ALPHA_MIN) -> np.ndarray:
|
||||
"""从拼图块裁剪图里取**不透明**区域掩膜 (bool)。
|
||||
|
||||
精灵图是 RGBA: 只要 alpha 通道。若调用方给的是三通道图 (例如直接截屏裁的),
|
||||
退化成"排除近白像素" (精灵图的空白处是纯白/全透明)。
|
||||
"""
|
||||
arr = np.asarray(piece)
|
||||
if arr.ndim == 3 and arr.shape[2] == 4:
|
||||
return arr[..., 3] > alpha_min
|
||||
bgr = _to_bgr(arr)
|
||||
return bgr.min(axis=2) < 245
|
||||
|
||||
|
||||
def zncc_curve(bg_gray: np.ndarray, patch: np.ndarray, mask: np.ndarray,
|
||||
y0: int, y_span: int = DEFAULT_Y_JITTER) -> Tuple[np.ndarray, int]:
|
||||
"""在背景图的 y=y0±y_span 行上, 横向逐像素做零均值归一化互相关。
|
||||
|
||||
返回 (curve, best_y)。curve[x] 是"把拼图块左上角放在背景图 (x, best_y)"时的 ZNCC。
|
||||
curve 只在 x ∈ [0, W-w] 上有定义。
|
||||
"""
|
||||
h, w = mask.shape
|
||||
H, W = bg_gray.shape
|
||||
if h > H or w > W:
|
||||
raise ValueError(f"拼图块 ({w}x{h}) 比背景图 ({W}x{H}) 还大")
|
||||
|
||||
# 只取掩膜内的像素, 后续每列都按同一批下标取样 -> 可以向量化成一次大矩阵运算
|
||||
idx_y, idx_x = np.nonzero(mask)
|
||||
p = patch[idx_y, idx_x].astype(np.float32)
|
||||
p = p - p.mean()
|
||||
pn = float(np.sqrt((p * p).sum()))
|
||||
if pn <= 1e-6:
|
||||
raise ValueError("拼图块内容方差为 0, 无法做相关")
|
||||
|
||||
xs = np.arange(0, W - w + 1, dtype=np.int32)
|
||||
curve = np.zeros(xs.shape, dtype=np.float32)
|
||||
best_y, best_curve = y0, None
|
||||
for y in range(max(0, y0 - y_span), min(H - h, y0 + y_span) + 1):
|
||||
# 一次性把所有候选窗口的掩膜内像素取成 (len(xs), len(idx)) 矩阵
|
||||
base = bg_gray[y + idx_y] # (N, W)
|
||||
cols = xs[:, None] + idx_x[None, :] # (X, N)
|
||||
q = base[np.arange(base.shape[0])[None, :], cols] # (X, N)
|
||||
q = q.astype(np.float32)
|
||||
q = q - q.mean(axis=1, keepdims=True)
|
||||
denom = np.sqrt((q * q).sum(axis=1)) * pn
|
||||
cur = np.where(denom > 1e-6, (q * p[None, :]).sum(axis=1) / np.maximum(denom, 1e-9), 0.0)
|
||||
if best_curve is None or cur.max() > best_curve.max():
|
||||
best_y, best_curve = y, cur
|
||||
curve = best_curve if best_curve is not None else curve
|
||||
return curve.astype(np.float64), best_y
|
||||
|
||||
|
||||
def _peaks(curve: np.ndarray, neighbor: int, limit: int = 5) -> List[Tuple[int, float]]:
|
||||
"""按分数从高到低取**互不相邻**(间距 > neighbor) 的局部峰值。"""
|
||||
order = np.argsort(-curve)
|
||||
picked: List[Tuple[int, float]] = []
|
||||
for x in order:
|
||||
xi = int(x)
|
||||
if all(abs(xi - p[0]) > neighbor for p in picked):
|
||||
picked.append((xi, float(curve[xi])))
|
||||
if len(picked) >= limit:
|
||||
break
|
||||
return picked
|
||||
|
||||
|
||||
@dataclass
|
||||
class SliderResult:
|
||||
"""滑块求解结果。坐标均为**背景图原始像素**, 除 dx_display 外。"""
|
||||
ok: bool
|
||||
dx_image: float
|
||||
dx_display: float
|
||||
score: float
|
||||
runner_up: float
|
||||
margin: float
|
||||
piece_x_image: float
|
||||
target_x_image: float
|
||||
scale: float
|
||||
y_image: int = 0
|
||||
candidates: List[Tuple[int, float]] = field(default_factory=list)
|
||||
|
||||
def describe(self) -> str:
|
||||
return (f"dx_image={self.dx_image:.1f} dx_display={self.dx_display:.1f}px "
|
||||
f"score={self.score:.3f} runner_up={self.runner_up:.3f} margin={self.margin:.3f} "
|
||||
f"cands={[(x, round(s, 3)) for x, s in self.candidates]}")
|
||||
|
||||
|
||||
class SliderSolver:
|
||||
"""滑块缺口求解器 (需权重为 0: 纯几何 + 相关, 不需要训练/模型文件)。"""
|
||||
|
||||
def __init__(self, min_score: float = 0.45, min_margin: float = 0.08,
|
||||
neighbor: int = DEFAULT_NEIGHBOR, alpha_min: int = DEFAULT_ALPHA_MIN,
|
||||
y_jitter: int = DEFAULT_Y_JITTER) -> None:
|
||||
self.min_score = float(min_score)
|
||||
self.min_margin = float(min_margin)
|
||||
self.neighbor = int(neighbor)
|
||||
self.alpha_min = int(alpha_min)
|
||||
self.y_jitter = int(y_jitter)
|
||||
|
||||
def solve(self, bg, piece, piece_x_image: float, scale: float = 1.0,
|
||||
piece_y_image: Optional[float] = None) -> SliderResult:
|
||||
"""求拖动距离。
|
||||
|
||||
bg 背景图 (含缺口; 对应 `img_index=1`)
|
||||
piece 拼图块裁剪图 (RGBA 精灵图里裁出来的那一块; 对应 `img_index=0`)
|
||||
piece_x_image 拼图块裁剪图左边缘在背景图坐标系里的 x
|
||||
scale 背景图显示宽度 / 背景图原始宽度 (页面缩放系数)
|
||||
piece_y_image 拼图块裁剪图上边缘在背景图坐标系里的 y (缺省 0)
|
||||
"""
|
||||
bg_bgr = _to_bgr(bg)
|
||||
bg_gray = bg_bgr.mean(axis=2).astype(np.float32)
|
||||
cand = np.asarray(piece)
|
||||
patch = _to_bgr(cand).mean(axis=2).astype(np.float32)
|
||||
mask = piece_mask(cand, self.alpha_min)
|
||||
if mask.sum() < 50:
|
||||
return SliderResult(False, 0.0, 0.0, 0.0, 0.0, 0.0,
|
||||
float(piece_x_image), float(piece_x_image), float(scale))
|
||||
|
||||
y0 = int(round(piece_y_image or 0))
|
||||
curve, best_y = zncc_curve(bg_gray, patch, mask, y0, self.y_jitter)
|
||||
peaks = _peaks(curve, self.neighbor)
|
||||
if not peaks:
|
||||
return SliderResult(False, 0.0, 0.0, 0.0, 0.0, 0.0,
|
||||
float(piece_x_image), float(piece_x_image), float(scale), best_y)
|
||||
|
||||
best_x, best_s = peaks[0]
|
||||
runs = peaks[1][1] if len(peaks) > 1 else 0.0
|
||||
margin = best_s - runs
|
||||
dx_image = float(best_x) - float(piece_x_image)
|
||||
ok = bool(best_s >= self.min_score and margin >= self.min_margin)
|
||||
return SliderResult(ok=ok, dx_image=dx_image, dx_display=dx_image * float(scale),
|
||||
score=best_s, runner_up=runs, margin=margin,
|
||||
piece_x_image=float(piece_x_image),
|
||||
target_x_image=float(best_x), scale=float(scale),
|
||||
y_image=best_y, candidates=peaks)
|
||||
|
||||
# --- 调试辅助 -------------------------------------------------------
|
||||
@staticmethod
|
||||
def annotate(bg, result: SliderResult, piece_w: int, piece_h: int) -> np.ndarray:
|
||||
"""在背景图上标出"拼图块当前位置"与"目标位置", 用于人工核对。"""
|
||||
img = _to_bgr(bg).copy()
|
||||
px, py = int(round(result.piece_x_image)), int(result.y_image)
|
||||
tx = int(round(result.target_x_image))
|
||||
cv2.rectangle(img, (px, py), (px + piece_w, py + piece_h), (0, 255, 0), 2)
|
||||
cv2.rectangle(img, (tx, py), (tx + piece_w, py + piece_h), (0, 0, 255), 2)
|
||||
cv2.arrowedLine(img, (px + piece_w // 2, py + piece_h + 14),
|
||||
(tx + piece_w // 2, py + piece_h + 14), (255, 255, 0), 2, tipLength=0.25)
|
||||
return img
|
||||
@@ -0,0 +1,374 @@
|
||||
# -*- 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)
|
||||
Reference in New Issue
Block a user