5 Commits

Author SHA1 Message Date
00e8ab5308 feat: ddddocr-rs 完成 core/ort/tract2 规范整改与发布
准备

  - core:内置官方字符集(OLD/BETA)与 ModelMetadata::from_builtin_*
  构造器
  - ort:导出 Session、实现 Info、修正 cuda feature 接线、共享推理工具
  - tract2:包名更名(原 ddddocr-tract 已被占用)并完成规范整改
  - 集成测试按领域拆分(ocr / det / slide / common / api_surface)
  - 发布准备:Cargo.toml 元数据、workspace 版本 0.2.4、LICENSE/
  NOTICE、README 模型下载说明
2026-08-10 19:56:29 +08:00
fe61895926 feat(core): 扩展 API、完善日志与代码文档规范
- 公开颜色过滤与字符集限制扩展 API,修复宏路径
- 库内打印替换为 tracing 日志,清理遗留废弃代码
- 补充核心逻辑单元测试与 crate 元数据
- 开启 missing_docs 并统一 rustfmt/clippy 格式
2026-08-06 19:58:54 +08:00
1362243f4e feat(core): 公开颜色过滤与字符集限制扩展 API 并修复宏路径 2026-08-05 20:12:27 +08:00
0bddaeba24 docs(core): 为 ddddocr-core 全量补充并精简 Rustdoc 文档 2026-08-05 17:40:32 +08:00
84cc97b201 docs(core): 精简 lib/types/error 文档注释并新增快速开始示例 2026-08-05 09:43:46 +08:00
76 changed files with 3431 additions and 2756 deletions

View File

@@ -2,11 +2,11 @@
resolver = "2"
members = [
"ddddocr-core", "ddddocr-ort",
"ddddocr-tract",
"ddddocr-tract2",
]
[workspace.package]
version = "0.2.1"
version = "0.2.4"
edition = "2024"
license = "MIT OR Apache-2.0"

4
NOTICE
View File

@@ -3,4 +3,6 @@ Copyright 2026 CNWei
This product includes software developed by:
- sml2h3 (ddddocr) - Model weights and original logic.
- Sonos (tract) - ONNX inference engine.
- Sonos (tract) - ONNX inference engine.
- pyke.io (ort) - Rust bindings for ONNX Runtime.
- Microsoft (ONNX Runtime contributors) - ONNX inference engine.

144
README.md
View File

@@ -1,45 +1,135 @@
# ddddocr-rs
带带弟弟 OCR (ddddocr) 的 Rust 移植版。高性能、低占用,支持多种验证码识别与检测
[带带弟弟 OCRddddocr](https://github.com/sml2h3/ddddocr) 的 Rust 移植版:提供验证码 OCR 识别、目标检测与滑块匹配能力。核心库与推理引擎解耦,同一套 API 可切换 [Tract](https://github.com/sonos/tract) 或 [ONNX Runtime](https://onnxruntime.ai/) 后端
🧩 滑块识别算法核心知识点总结
本项目实现了两种核心匹配模式,其底层逻辑与 OpenCV 的对齐情况如下:
## 架构
1. 匹配模式对比 (Match Modes)
|**模式**|**算法原理**|**适用场景**|**备注**|
|---|---|---|---|
|**边缘模式** (Edge-based)|基于 **Canny 边缘检测** 提取轮廓后再进行匹配。|**推荐方案**
。适用于绝大多数拼图滑块。|天然免疫拼图周边的透明/黑色留白干扰,坐标最精准。|
|**简单模式** (Simple/Gray)|直接基于 **灰度像素值** 进行归一化互相关计算。|适用于无明显边缘、靠颜色差异识别的场景。|对背景和透明边框敏感,可能存在重心偏移。|
项目为 Cargo workspace包含三个 crate
2. 数学公式差异 (NCC vs. CCOEFF)
在简单模式下,本项目采用的是 归一化互相关 (NCC),对应 OpenCV 中的 TM_CCORR_NORMED。
| crate | 说明 |
|---|---|
| `ddddocr-core` | 引擎无关的核心库OCR / 目标检测 / 滑块匹配、模型元数据与图像预处理 |
| `ddddocr-ort` | ONNX Runtime 推理后端(可选 `cuda` feature 启用 GPU 加速) |
| `ddddocr-tract2` | Tract纯 Rust推理后端 |
逻辑对齐Rust 的 match_template 结果与 Python cv2.TM_CCORR_NORMED 完全一致
`ddddocr-core` 通过 `traits::InferenceEngine` / `OcrEngine` / `DetEngine` 抽象推理能力,由 `ddddocr-ort` / `ddddocr-tract2` 实现,业务代码只依赖核心库接口即可
关于偏移若拼图原始图片Target四周包含大量的透明留白
## 快速开始
CCORR (本项目):会将留白视为图像的一部分,计算出的是整张图片框的中心。
在 Cargo.toml 中添加:
CCOEFF (OpenCV 默认):会自动进行“均值中心化”,在一定程度上能削弱留白的影响。
```toml
[dependencies]
ddddocr-core = "0.2"
ddddocr-ort = "0.2" # 或 ddddocr-tract2 = "0.2"
```
最佳实践:若发现坐标有固定位移,建议优先切换至 边缘模式,或对滑块图进行 Bounding Box 裁剪 后再匹配。
核心库自带一个不依赖真实模型的演示示例(用假引擎演示完整调用链):
3. 图像预处理一致性
```bash
cargo run -p ddddocr-core --example quick_start
```
为确保识别精度,本项目在 Rust 中完美复刻了 Python OpenCV 的预处理链路
真实推理需要自行准备 ONNX 模型文件crate 包内不包含模型)。以 ORT 后端为例
- **灰度化权重**:采用 OpenCV 标准感光公式 $0.299R + 0.587G + 0.114B$。
```rust
use ddddocr_core::traits::{Info, Loader};
use ddddocr_core::{ModelMetadata, Normalization, Ocr, Resize};
- **Alpha 处理**:在将 PNG 转为 RGB 时,自动将透明区域填充为黑色,确保与 PIL (Python Imaging Library) 行为一致。
// 1. 构建会话等价写法ddddocr_tract2::loader::ModelLoader
let session = ddddocr_ort::loader::ModelLoader::default()
.build_for_path("models/common.onnx")?;
- **坐标定义**:所有返回坐标均为匹配区域的 **几何中心点** $(x + w/2, y + h/2)$。
// 2. 组装 OCR 运行时
let metadata = ModelMetadata::from_static_slice(
&["a", "b"], // 字符集
false, // 非单字模型
Resize::DynamicWidth(64), // 高度固定 64、宽度等比缩放
1, // 灰度单通道
Normalization::MinusOneToOne,
);
let ocr = ddddocr_ort::OcrRuntime::new(session, metadata);
💡 开发者建议:
// 3. 识别图片
let result = Ocr::builder().build_with(&ocr).predict(&image)?;
println!("识别结果: {}", result);
```
如果识别结果在 $X$ 轴上有大约 $10px$ 左右的固定误差,通常是因为滑块原图自带了透明边距(留白)。此时请确保
simple_target=false。该模式会通过 Canny 边缘检测 提取轮廓特征,能自动锁定拼图实体并忽略背景留白的像素干扰。
鸣谢 (Credits)
目标检测与滑块匹配类似:`Detector::new(&engine).predict(&image)` 返回检测框列表;`Slider::new().slide_match(target, background, simple_target)` 返回匹配坐标与置信度。
- 本项目是 [ddddocr](https://github.com/sml2h3/ddddocr) 的 Rust 移植版本,原作者为 sml2h3。衷心感谢原作者对 OCR 社区做出的杰出贡献。
- 推理引擎基于 [tract (Sonos)](https://github.com/sonos/tract)。感谢其为 Rust 生态提供的轻量级推理方案。
## 模型下载
OCR / 目标检测需要 ONNX 模型文件。官方模型位于 [ddddocr 仓库 `ddddocr/` 目录](https://github.com/sml2h3/ddddocr/blob/master/ddddocr/),下载后放入仓库根目录的 `models/` 文件夹:
| 文件 | 大小 | 用途 | 直链 |
|---|---|---|---|
| `common.onnx` | 约 51.6 MB | 新版 OCR 模型(默认) | [下载](https://raw.githubusercontent.com/sml2h3/ddddocr/master/ddddocr/common.onnx) |
| `common_det.onnx` | 约 19.2 MB | 目标检测模型 | [下载](https://raw.githubusercontent.com/sml2h3/ddddocr/master/ddddocr/common_det.onnx) |
| `common_old.onnx` | 约 13 MB | 旧版 OCR 模型 | [下载](https://raw.githubusercontent.com/sml2h3/ddddocr/master/ddddocr/common_old.onnx) |
> 注意:`ddddocr-tract2` 不支持旧版模型 `common_old.onnx`,请使用 `common.onnx`。
>
> 字符集配对:`common.onnx` 对应内置 Beta 字符集(`Charset` / `ModelMetadata::from_builtin_beta`,归一化 `MinusOneToOne``common_old.onnx` 对应旧版字符集(`from_builtin_old`,归一化 `ZeroToOne`)。
>
> 仓库测试使用的 `common_sml2h3_f32.onnx`、`common_huashi666_i64.onnx` 为社区转换的 f32 / i64 变体模型(与 `common.onnx` 同源),不在上述官方目录中,需自行获取。
## 字符集与内置默认值
OCR 模型的字符集token 列表)是模型的一部分:字符 `i` 对应模型输出 logits 的第 `i` 列,必须与模型训练时一致,否则识别结果会错位。
字符集有两种提供方式:
- 通过模型元数据 JSON 的 `charset` 字段(`Metadata::from_json_str` / `from_json_bytes` 自动解析);
- 代码内显式指定:`ModelMetadata::from_static_slice(&["", "a", "b"], ...)``Charset::new(...)`
`ddddocr-core` 内置官方模型的默认字符集(旧版 `CHARSET_OLD` 与 Beta `CHARSET_BETA`),作为免配置的快速通道。字符集数据独立存放在 `ddddocr-core/src/ocr/builtin.rs` 私有模块中(与业务逻辑分离),通过 `ModelMetadata` 的公开构造方法使用:
```rust
use ddddocr_core::{ModelMetadata, Normalization, Resize};
// 官方旧版模型配套
let meta = ModelMetadata::from_builtin_old(
false,
Resize::DynamicWidth(64),
1,
Normalization::ZeroToOne,
);
// 官方 Beta 模型配套
let meta = ModelMetadata::from_builtin_beta(
false,
Resize::DynamicWidth(64),
1,
Normalization::MinusOneToOne,
);
```
数据内置于代码,不依赖外部文件。若使用自有模型,请仍以元数据 JSON 或 `from_static_slice` 指定匹配的字符集。
## 滑块匹配核心知识点
项目实现两种匹配模式,底层逻辑与 OpenCV 对齐:
| 模式 | 算法原理 | 适用场景 | 备注 |
|---|---|---|---|
| 边缘模式Edge-based | 基于 Canny 边缘检测提取轮廓后匹配 | **推荐方案**,适用于绝大多数拼图滑块 | 天然免疫拼图周边透明/黑色留白干扰,坐标最精准 |
| 简单模式Simple/Gray | 基于灰度像素值做归一化互相关NCC | 无明显边缘、靠颜色差异识别的场景 | 对背景和透明边框敏感,可能存在重心偏移 |
关键点:
- 简单模式采用归一化互相关NCC与 OpenCV 的 `TM_CCORR_NORMED` 完全一致。
- 若拼图原图四周带透明留白本项目会将留白计入整张图片框中心OpenCV 默认的 CCOEFF 会做均值中心化削弱留白影响。
- 图像预处理与 Python 链路保持一致:灰度权重采用 OpenCV 标准感光公式 `0.299R + 0.587G + 0.114B`PNG 转 RGB 时透明区域填充为黑色;返回坐标为匹配区域的几何中心 `(x + w/2, y + h/2)`
> 若识别结果在 X 轴上有约 10px 固定误差,通常是滑块原图自带透明边距所致。此时请确保 `simple_target = false`,边缘模式会自动锁定拼图实体并忽略留白干扰。
## 致谢
- [sml2h3 (ddddocr)](https://github.com/sml2h3/ddddocr) - 模型权重与原始逻辑。
- [Sonos (tract)](https://github.com/sonos/tract) - 纯 Rust ONNX 推理引擎。
- [pyke.io (ort)](https://github.com/pykeio/ort) - ONNX Runtime 的 Rust 绑定。
- [Microsoft (ONNX Runtime)](https://onnxruntime.ai/) - ONNX 推理引擎。
## License
`MIT OR Apache-2.0`,许可证文本见仓库根目录的 `LICENSE-MIT``LICENSE-APACHE`

BIN
code3.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -3,14 +3,16 @@ name = "ddddocr-core"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "ddddocr-rs 的核心库:引擎无关的 OCR、目标检测与滑块匹配实现"
keywords = ["ocr", "captcha", "ddddocr", "onnx", "image"]
categories = ["multimedia::images", "computer-vision"]
# repository = "https://github.com/<用户名>/<仓库名>" # 发布前请补充
readme = "../README.md"
[dependencies]
ndarray = { workspace = true } # 继承自工作空间
ndarray = { workspace = true }
base64 = { workspace = true }
image = { workspace = true }
imageproc = { workspace = true }
thiserror = { workspace = true } # 刚好可以开始接入你需要的标准库错误处理
tracing={workspace = true}
thiserror = { workspace = true }
tracing = { workspace = true }

View File

@@ -0,0 +1,63 @@
//! 快速开始示例:演示 ddddocr-core 与引擎 crate 的解耦用法。
//!
//! 运行:`cargo run -p ddddocr-core --example quick_start`
use ddddocr_core::error::{Result, TensorError};
use ddddocr_core::traits::{InferenceEngine, Info, OcrEngine};
use ddddocr_core::types::{ModelInfo, TensorInfo};
use ddddocr_core::{ModelMetadata, Normalization, OcrBuilder, OcrOutput, Resize};
/// 演示引擎:只实现接口,不接入真实 ONNX 运行时。
struct DemoEngine {
meta: ModelMetadata,
}
impl Info for DemoEngine {
fn input_info(&self) -> Result<Vec<TensorInfo>> {
Ok(vec![])
}
fn output_info(&self) -> Result<Vec<TensorInfo>> {
Ok(vec![])
}
fn model_info(&self) -> Result<ModelInfo> {
Ok(ModelInfo {
inputs: vec![],
outputs: vec![],
providers: None,
})
}
}
impl InferenceEngine for DemoEngine {
type Output = OcrOutput;
fn inference(&self, input: ndarray::Array4<f32>) -> Result<Self::Output, TensorError> {
// 用全零 logits 模拟推理输出:[Steps, Classes]
let steps = input.shape()[2];
let classes = self.meta.charset.size();
Ok(OcrOutput::Logits(ndarray::Array2::zeros((steps, classes))))
}
}
impl OcrEngine for DemoEngine {
fn metadata(&self) -> &ModelMetadata {
&self.meta
}
}
fn main() {
let engine = DemoEngine {
meta: ModelMetadata::from_static_slice(
&["", "a", "b"],
false,
Resize::Fixed(64, 64),
1,
Normalization::ZeroToOne,
),
};
let ocr = OcrBuilder::new().probability(true).build_with(&engine);
let image = image::DynamicImage::new_luma8(64, 64);
let result = ocr.predict(&image).expect("识别失败");
println!("识别结果: {result}");
}

View File

@@ -1,6 +1,7 @@
//! 目标检测模块:检测器构建器与执行入口。
mod builder;
mod executor;
pub use builder::DetBuilder;
pub use executor::{DetectionResult, Detector};
// pub use ddddocr_tract::det::session::DetSession;

View File

@@ -1,11 +1,15 @@
//! 检测器构建器。
use crate::det::executor::Detector;
// use ddddocr_tract::det::session::DetSession;
use crate::traits::DetEngine;
/// 检测器构建器,通过 [`crate::Detector::builder`] 创建。
#[derive(Default)]
pub struct DetBuilder;
impl DetBuilder {
fn build<E: DetEngine>(self, session: &E) -> Detector<'_> {
Detector { session }
/// 绑定检测引擎会话并构建 Detector。
pub fn build_with<E: DetEngine>(self, runtime: &E) -> Detector<'_> {
Detector { runtime }
}
}

View File

@@ -1,19 +1,27 @@
//! 目标检测执行器:检测后处理与预测入口。
use crate::error::{Result, TensorError};
use image::{DynamicImage, GenericImageView, imageops::FilterType};
use ndarray::{Array2, Array3, Array4, Axis, prelude::*, s};
use std::fmt;
// use tract_onnx::prelude::{Tensor};
// use ddddocr_tract::det::session::DetSession;
use crate::{DetBuilder, DetOutput, OcrBuilder};
use crate::traits::DetEngine;
use crate::{DetBuilder, DetOutput};
/// 目标检测结果:原图像素坐标系下的边界框、置信度与类别 ID。
#[derive(Debug, Clone, Copy)]
pub struct DetectionResult {
/// 左上角 x 坐标。
pub x1: i32,
/// 左上角 y 坐标。
pub y1: i32,
/// 右下角 x 坐标。
pub x2: i32,
/// 右下角 y 坐标。
pub y2: i32,
/// 置信度。
pub score: f32,
/// 类别 ID。
pub class_id: u32,
}
@@ -28,24 +36,27 @@ impl fmt::Display for DetectionResult {
}
}
/// 目标检测器:对输入图像执行检测并返回结果。
pub struct Detector<'a> {
pub(crate) session: &'a dyn DetEngine,
pub(crate) runtime: &'a dyn DetEngine,
}
impl<'a> Detector<'a> {
pub fn new(session: &'a dyn DetEngine) -> Self {
Detector { session }
/// 绑定检测引擎会话创建检测器。
pub fn new(runtime: &'a dyn DetEngine) -> Self {
Detector { runtime }
}
/// 创建检测器构建器。
pub fn builder() -> DetBuilder {
DetBuilder::default()
DetBuilder
}
}
impl<'a> Detector<'a> {
/// 对输入图像执行目标检测,返回检测框列表。
pub fn predict(&self, image: &DynamicImage) -> Result<Vec<DetectionResult>> {
// Rust 中通常在调用层处理文件/PIL转换这里直接进入核心逻辑
Ok(self.get_bbox(image)?)
}
/// 2. preproc: 纯 Rust 实现 (替代 OpenCV)
fn preproc(&self, image: &DynamicImage, input_size: (u32, u32)) -> (Array4<f32>, f32) {
let (target_h, target_w) = input_size;
let (img_w, img_h) = image.dimensions();
@@ -87,7 +98,6 @@ impl<'a> Detector<'a> {
(array, r)
}
/// 3. demo_postprocess (逻辑与 Python 一致)
fn demo_postprocess(&self, mut outputs: Array3<f32>, img_size: (i32, i32)) -> Array3<f32> {
let strides = [8, 16, 32];
@@ -123,7 +133,6 @@ impl<'a> Detector<'a> {
outputs
}
/// 4. nms
fn nms(&self, boxes: &Array2<f32>, scores: &Array1<f32>, nms_thr: f32) -> Vec<usize> {
let mut keep = Vec::new();
let x1 = boxes.column(0);
@@ -183,8 +192,7 @@ impl<'a> Detector<'a> {
keep
}
/// 5. multiclass_nms
//multiclass_nms_class_agnostic
/// 多类别 NMS 后处理:按分数阈值筛选候选框,并用 NMS 阈值去重。
pub fn multiclass_nms(
&self,
boxes: &Array2<f32>, // [25200, 4] -> xyxy 格式
@@ -241,21 +249,18 @@ impl<'a> Detector<'a> {
})
.collect()
}
/// 6. get_bbox (完全解耦 OpenCV)
/// 对图像执行完整检测流程(预处理、推理、后处理),返回像素坐标系下的检测框。
pub fn get_bbox(
&self,
dynamic_img: &DynamicImage,
) -> Result<Vec<DetectionResult>, TensorError> {
// 使用 utils crate 解码
// let dynamic_img = image::load_from_memory(image_bytes).context("Failed to decode utils")?;
let (orig_w, orig_h) = dynamic_img.dimensions();
let (input_tensor, ratio) = self.preproc(dynamic_img, (416, 416));
// tract 推理
// let outputs = self.session.session.run(tvec!(input_tensor.into()))?;
let outputs = self.session.inference(input_tensor)?;
// let output_array = outputs[0]
let outputs = self.runtime.inference(input_tensor)?;
// 2. 无缝、安全地解包出标准 3维 矩阵
let DetOutput::Detection(output_array) = outputs;
@@ -272,9 +277,7 @@ impl<'a> Detector<'a> {
expected: format!("可广播至 cls_conf 形状 {:?}", cls_conf.shape()),
actual: obj_conf.shape().to_vec(),
})?;
// .context("ndarray broadcasting failed for scores calculation")?;
let scores = &obj_broadcast * &cls_conf;
// let scores = &pred.slice(s![.., 4..5]) * &pred.slice(s![.., 5..]);
let mut boxes_xyxy = Array2::<f32>::zeros(boxes.raw_dim());
for i in 0..boxes.nrows() {

View File

@@ -1,248 +1,148 @@
pub(crate) const MODEL_DOWNLOAD_HELP: &str = "\
================================================================================
[ddddocr-rust] 错误:未找到默认的模型文件!
--------------------------------------------------------------------------------
由于打包体积限制,本库未内置 ONNX 模型。请按照以下步骤操作:
1. 前往官方 GitHub 下载对应的模型权重:
- OCR 模型: https://github.com/sml2h3/ddddocr/raw/master/ddddocr/common_sml2h3_f32.onnx
- DET 模型: https://github.com/sml2h3/ddddocr/raw/master/ddddocr/common_det.onnx
2. 配置加载方式(二选一):
A. 【推荐】设置环境变量指向您下载的文件:
Linux/macOS: export DDDD_OCR_MODEL=\"/path/to/common_sml2h3_f32.onnx\"
Windows (CMD): set DDDD_OCR_MODEL=C:\\path\\to\\common_sml2h3_f32.onnx
Windows (PowerShell): $env:DDDD_OCR_MODEL=\"C:\\path\\to\\common_sml2h3_f32.onnx\"
B. 或者直接将模型文件重命名并放置在您运行程序的“当前工作目录”或“可执行文件同级目录”下。
================================================================================";
//! 分层错误类型:预处理、推理、解码三阶段的强类型错误。
use thiserror::Error;
//
// #[derive(Error, Debug)]
// pub enum DdddError {
// // 【新增】专门处理文件读取、路径不存在等原生 I/O 错误
// #[error("系统网络或文件 I/O 异常: {0}")]
// Io(#[from] std::io::Error),
//
// #[error("图像预处理失败: {0}")]
// PreprocessError(#[from] ImagePreprocessReason),
//
// #[error("模型推理引擎内部发生异常: {0}")]
// EngineError(#[from] anyhow::Error),
//
// #[error("CTC 解码错误: {0}")]
// DecodeError(String),
//
// #[error("维度转换失败,预期维度 {expected},实际形状为 {actual:?}")]
// DimensionMismatch {
// expected: String,
// actual: Vec<usize>,
// },
//
// #[error("内存不连续,无法执行零拷贝操作")]
// NonContiguousMemory,
//
// #[error("未知的模型输出格式")]
// UnknownOutputFormat,
//
// #[error("解析节点 Fact 失败")]
// InternalError(String),
// }
//
// /// 专门服务于预处理的子错误枚举,保留全部底层上下文
// #[derive(Error, Debug)]
// pub enum ImagePreprocessReason {
// #[error("图片加载或文件 I/O 失败: {0}")]
// ImageIo(#[from] image::ImageError),
//
// #[error("图片转矩阵矩阵(ndarray)失败: {0}")]
// NdarrayError(#[from] ndarray::ShapeError),
//
// #[error("Base64 解码失败: {0}")]
// Base64(#[from] base64::DecodeError),
//
// #[error("Base64 头部格式不正确,缺少 ';base64,' 分隔符")]
// InvalidBase64Header,
//
// #[error("不支持的通道数: {0}")]
// UnsupportedChannels(usize),
//
// #[error("其他预处理错误: {0}")]
// Custom(String),
// }
/// 统一用我们自己的 DdddError 包装 Result
// pub type Result<T> = std::result::Result<T, DdddError>;
/// 全局统一的 `Result` 别名,默认错误类型为 [`DdddError`]。
pub type Result<T, E = DdddError> = std::result::Result<T, E>;
// =====================================================================
// 1. 顶层全局 Error 分流器 (去 anyhow 化,完全基于标准库/自定义类型)
// =====================================================================
/// 顶层错误类型,聚合本库各阶段错误。
#[derive(Error, Debug)]
pub enum DdddError {
// /// 系统文件、网络等原生 I/O 异常 (高优先级自动转换)
// #[error("系统网络或文件 I/O 异常: {0}")]
// Io(#[from] std::io::Error),
/// 图像预处理阶段发生异常
/// 图像预处理阶段异常。
#[error("图像预处理失败: {0}")]
Preprocess(#[from] ImagePreprocessError),
/// 推理引擎与张量操作阶段发生异常
/// 推理与张量操作阶段异常
#[error("推理与模型输入/输出张量异常: {0}")]
Inference(#[from] TensorError),
/// 算法后处理解码阶段发生异常
/// 后处理解码阶段异常
#[error("后处理解码错误: {0}")]
Decode(#[from] DecodeError),
/// 框架内部不可恢复的逻辑断言错误(如解析节点 Fact 失败)
/// 框架内部不可恢复的逻辑断言错误(如解析节点 Fact 失败)
#[error("内部严重逻辑错误: {0}")]
Internal(String),
/// 【流派核心】接替 anyhow::Error 的用户自定义扩展错误
/// 承载任何第三方扩展、解密、特定预处理插件在执行时产生的自定义错误
/// 用户自定义扩展错误,用于包装第三方插件产生的错误
#[error("用户自定义扩展错误: {0}")]
Other(#[source] Box<dyn std::error::Error + Send + Sync>),
}
// =====================================================================
// 2. 子领域 A: 图像预处理错误类型
// =====================================================================
/// 图像预处理阶段错误类型。
#[derive(Error, Debug)]
pub enum ImagePreprocessError {
// #[error("图片加载或解码失败: {0}")]
// ImageIo(#[from] image::ImageError),
// image_io
/// ndarray 基础操作失败。
#[error("图片转矩阵(ndarray)基础操作失败: {0}")]
Ndarray(#[from] ndarray::ShapeError),
// image_io
/// 图像矩阵维度不合规。
#[error("图像矩阵维度不合规!预期: {expected},实际图像形状: {actual:?}")]
InvalidDimensions {
/// 期望的维度描述。
expected: String,
/// 实际的图像形状。
actual: Vec<usize>,
},
// image_io
/// 从 ndarray 原始数据构建图像缓冲区时,缓冲区长度与分辨率/通道数不匹配
/// 图像缓冲区长度与分辨率/通道数不匹配。
#[error(
"图像缓冲区长度不匹配!预期大小: {expected},实际大小: {actual} (分辨率: {width}x{height}, 通道数: {channels})"
)]
BufferLengthMismatch {
/// 期望的缓冲区长度。
expected: usize,
/// 实际的缓冲区长度。
actual: usize,
/// 图像宽度。
width: u32,
/// 图像高度。
height: u32,
/// 图像通道数。
channels: usize,
},
// image_io
/// 不支持的图像通道数。
#[error("不支持的图像通道数: {0} (仅支持单通道灰度L、3通道RGB、4通道RGBA)")]
UnsupportedChannels(usize),
// ================= 新增:针对 HSV 和 Preset 的强类型错误 =================
/// HSV 颜色区间非法 (例如 H > 180 或 lower > upper)
/// HSV 颜色区间参数非法。
#[error("HSV 颜色区间参数非法: {0}")]
InvalidHsvRange(String),
/// 不支持或未知的颜色预设名称
/// 未知的颜色预设名称
#[error("不支持的颜色预设名称: {0}")]
UnknownColorPreset(String),
/// 颜色过滤器/预处理规则配置非法导致失败
/// 颜色过滤器配置无效。
#[error("颜色过滤器配置无效或初始化失败: {0}")]
FilterConfigInvalid(String),
/// 图像维度不匹配。
#[error("图像维度不匹配!{0}")]
MismatchDimensions (String),
MismatchDimensions(String),
/// 滑块模板尺寸大于背景图。
#[error("滑块模板尺寸 [{target_w}x{target_h}] 大于背景图 [{bg_w}x{bg_h}]")]
TargetExceedsBackground {
/// 滑块模板宽度。
target_w: usize,
/// 滑块模板高度。
target_h: usize,
/// 背景图宽度。
bg_w: usize,
/// 背景图高度。
bg_h: usize,
},
// #[error("Base64 解码失败: {0}")]
// Base64(#[from] base64::DecodeError),
//
// #[error("Base64 头部格式不正确,缺少 ';base64,' 分隔符")]
// InvalidBase64Header,
// #[error("其他预处理错误: {0}")]
// Other(String),
}
// =====================================================================
// 3. 子领域 B: 推理与张量操作错误类型
// =====================================================================
/// 推理与张量操作阶段错误类型。
#[derive(Error, Debug)]
pub enum TensorError {
/// 替换原有的 anyhow::Error明确将 Tract/ONNX 引擎底层报错序列化为干净的 String
/// 推理引擎内部异常。
#[error("推理引擎内部发生异常: {0}")]
Engine(String),
/// 模型张量维度不匹配 (原有的顶层 DimensionMismatch 被优雅地归入本模块)
/// 模型张量维度不匹配
#[error("模型张量维度不匹配!预期: {expected},实际 Tensor 形状: {actual:?}")]
DimensionMismatch {
/// 期望的维度描述。
expected: String,
/// 实际的 Tensor 形状。
actual: Vec<usize>,
},
/// 新增:针对后处理 Logits 矩阵变形Reshape失败的精细化错误
/// 直接包装 ndarray::ShapeError保留强类型完美支持 match
/// OCR Logits 矩阵变形失败。
#[error("OCR Logits 矩阵变形失败: {0}")]
LogitsDimensionMismatch(#[from] ndarray::ShapeError),
/// 张量内存布局不是连续
/// 张量内存连续
#[error("内存不连续,无法执行零拷贝操作")]
NonContiguousMemory,
/// 模型输出数据类型或格式不受支持
/// 未知的模型输出格式。
#[error("未知的模型输出格式")]
UnknownOutputFormat,
}
// =====================================================================
// 4. 子领域 C: 算法解码错误类型
// =====================================================================
/// 算法解码阶段错误类型。
#[derive(Error, Debug)]
pub enum DecodeError {
/// CTC 解码器解码过程中的逻辑报错
/// CTC 解码异常。
#[error("CTC 解码异常: {0}")]
Ctc(String),
}
// =====================================================================
// 5. 【自定义错误安全注入】不使用全局 `#[from]`,采用显式包装避免特化冲突
// =====================================================================
impl DdddError {
/// 提供类似 std::io::Error::new 的构造函数,方便手动且无痛地包装任意第三方错误
/// 手动包装任意第三方错误为 [`DdddError::Other`]。
pub fn new<E>(error: E) -> Self
where
E: Into<Box<dyn std::error::Error + Send + Sync>>,
{
DdddError::Other(error.into())
}
// -----------------------------------------------------------------
// 2.3 优化提供一键判断与转换的快捷方法Downcasting Helpers
// -----------------------------------------------------------------
/// 快速判断是否是系统 I/O 错误
// pub fn is_io_error(&self) -> bool {
// matches!(self, DdddError::Io(_))
// }
/// 尝试将错误转换为引用形式的 `std::io::Error`
// pub fn as_io_error(&self) -> Option<&std::io::Error> {
// match self {
// DdddError::Io(err) => Some(err),
// _ => None,
// }
// }
/// 快速判断是否是预处理阶段的图片维度不合规错误
/// 是否为图片维度不合规错误
pub fn is_invalid_dimensions(&self) -> bool {
matches!(
self,
@@ -250,25 +150,42 @@ impl DdddError {
)
}
/// 快速判断是否是因为图片通道数不合规导致的失败
/// 是否因通道数不合规失败
pub fn is_unsupported_channels(&self) -> bool {
matches!(
self,
DdddError::Preprocess(ImagePreprocessError::UnsupportedChannels(_))
)
}
// 提取出底层最原始的那个错误(无论是 IO、预处理、推理、还是第三方扩展错误
// 方便外层统一打印更深层的 `source` 链条
// pub fn source_error(&self) -> Option<&(dyn std::error::Error + 'static)> {
// use std::error::Error;
// match self {
// // DdddError::Io(err) => Some(err),
// DdddError::Preprocess(err) => Some(err),
// DdddError::Inference(err) => Some(err),
// DdddError::Decode(err) => Some(err),
// DdddError::Other(err) => Some(err.as_ref()),
// DdddError::Internal(_) => None, // Internal 内部目前只有 String没有底层的 Error source
// }
// }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_wraps_third_party_error() {
let io_err = std::io::Error::other("boom");
let err = DdddError::new(io_err);
assert!(matches!(err, DdddError::Other(_)));
}
#[test]
fn preprocess_conversion_and_predicates() {
let e: DdddError = ImagePreprocessError::UnsupportedChannels(2).into();
assert!(e.is_unsupported_channels());
let e2: DdddError = ImagePreprocessError::InvalidDimensions {
expected: "x".into(),
actual: vec![0],
}
.into();
assert!(e2.is_invalid_dimensions());
}
#[test]
fn decode_conversion() {
let e: DdddError = DecodeError::Ctc("bad".into()).into();
assert!(matches!(e, DdddError::Decode(_)));
}
}

View File

@@ -1,24 +1,43 @@
//! # ddddocr-core
//!
//! `ddddocr-rs` 的核心库:提供与具体推理引擎解耦的 OCR 识别、目标检测det与滑块匹配slide能力。
//! 推理能力由 [`traits::InferenceEngine`]、[`traits::OcrEngine`]、[`traits::DetEngine`] 抽象,
//! 由 `ddddocr-tract2`、`ddddocr-ort` 等引擎 crate 实现。
//!
//! 完整可运行示例见 `ddddocr-core/examples/quick_start.rs`。
#![warn(missing_docs)]
mod det;
/// 分层错误类型。
pub mod error;
mod ocr;
mod slide;
pub mod utils;
pub mod types;
/// 推理引擎统一抽象接口。
pub mod traits;
/// 模型输入输出信息等共享类型。
pub mod types;
/// 图像加载、转换与处理工具。
pub mod utils;
pub use crate::det::{DetBuilder, DetectionResult, Detector};
pub use crate::ocr::{Charset, ModelMetadata, Normalization, Ocr, OcrBuilder, OcrResult, Resize};
pub use crate::ocr::{
CharRestrict, Charset, ColorFilter, ColorPreset, HsvRange, IdRestrict, ModelMetadata,
MultiOrColorRestrict, MultiOrRestrict, Normalization, Ocr, OcrBuilder, OcrResult, PixelCtx,
Resize, TokenFilter, ValidationCtx,
};
pub use crate::slide::{SlideResult, Slider};
// DetSession
/// OCR 模型的统一输出枚举,由推理引擎产出,供 [`Ocr`] 后处理。
pub enum OcrOutput {
Indices(ndarray::Array1<i64>), // 拥有完整所有权的 1维数组可任意传递和返回
/// 索引序列输出CTC 解码输入)。
Indices(ndarray::Array1<i64>),
/// Logits 矩阵输出 `[Steps, Classes]`。
Logits(ndarray::Array2<f32>),
}
/// 2. 目标检测专属的、编译期安全的输出枚举
pub enum DetOutput {
Detection(ndarray::Array3<f32>), // 拥有完整所有权的 2维矩阵可任意传递和返回
}
/// 目标检测模型的统一输出枚举,由推理引擎产出,供 [`Detector`] 后处理。
pub enum DetOutput {
/// 原始检测输出张量。
Detection(ndarray::Array3<f32>),
}

View File

@@ -1,4 +1,7 @@
//! OCR 模块:识别器构建器、执行入口及元数据、字符集等类型。
mod builder;
mod builtin;
mod charset;
mod color_filter;
mod executor;
@@ -7,7 +10,7 @@ mod token_filter;
pub use builder::OcrBuilder;
pub use charset::Charset;
pub use color_filter::{ColorFilter, ColorPreset, HsvRange, MultiOrColorRestrict, PixelCtx};
pub use executor::{Ocr, OcrResult};
pub use metadata::{ModelMetadata, Normalization, Resize};
pub use token_filter::TokenFilter;
// pub use ddddocr_tract::session::OcrSession;
pub use token_filter::{CharRestrict, IdRestrict, MultiOrRestrict, TokenFilter, ValidationCtx};

View File

@@ -1,8 +1,11 @@
use crate::ocr::executor::Ocr;
// use ddddocr_tract::session::OcrSession;
use crate::traits::OcrEngine;
//! OCR 构建器。
use crate::ocr::color_filter::ColorFilter;
use crate::ocr::executor::Ocr;
use crate::ocr::token_filter::TokenFilter;
use crate::traits::OcrEngine;
/// OCR 构建器:配置识别选项后绑定引擎会话构建 [`crate::Ocr`]。
#[derive(Default)]
pub struct OcrBuilder {
/// 是否修复PNG格式问题
@@ -17,6 +20,7 @@ pub struct OcrBuilder {
impl OcrBuilder {
// 初始化任务,设置默认参数
/// 创建默认配置的构建器。
pub fn new() -> Self {
Self {
png_fix: false, // 默认值
@@ -25,15 +29,18 @@ impl OcrBuilder {
charset_restrict: None,
}
}
/// 设置是否修复 PNG 透明背景问题。
pub fn png_fix(mut self, value: bool) -> Self {
self.png_fix = value;
self
}
/// 设置是否返回概率信息。
pub fn probability(mut self, value: bool) -> Self {
self.probability = value;
self
}
/// 设置颜色过滤约束。
pub fn color_filter<T>(mut self, filter: T) -> Self
where
T: ColorFilter + Send + Sync + 'static,
@@ -42,6 +49,7 @@ impl OcrBuilder {
self
}
/// 设置字符集限制。
pub fn charset_restrict<T>(mut self, restrict: T) -> Self
where
T: TokenFilter + Send + Sync + 'static,
@@ -49,7 +57,8 @@ impl OcrBuilder {
self.charset_restrict = Some(Box::new(restrict));
self
}
pub fn runner<E: OcrEngine>(self, runtime: &E) -> Ocr<'_> {
/// 绑定引擎会话并构建 OCR 识别器。
pub fn build_with<E: OcrEngine>(self, runtime: &E) -> Ocr<'_> {
// 1. 原地解析颜色过滤器
let final_color_ranges = match &self.color_filter {
Some(filter) => filter.collect_to_vec(),
@@ -62,7 +71,6 @@ impl OcrBuilder {
None => None,
};
// Ocr::new(session, self)
Ocr {
runtime,
png_fix: self.png_fix, // 原地解构出来

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,30 @@
//! 字符集token 列表与索引的双向映射。
use std::borrow::Cow;
use std::collections::HashMap;
// ==========================================
// 3. 字符集核心结构体 (重命名为 Charset)
// ==========================================
/// 字符集token 列表与索引的双向映射。
#[derive(Debug, Clone)]
pub struct Charset {
/// 字符集 token 列表。
// 使用 Cow 统一静态切片和动态读取的 Vec<String>,内部实现真正的零拷贝
pub tokens: Vec<Cow<'static, str>>,
/// 字符到索引的反查表。
// 反向查找表,保证字符转索引为 O(1)
pub char_to_idx: HashMap<Cow<'static, str>, usize>,
// 当前处于激活状态的有效索引缓存 (用于 CTC 解码前的过滤加速)
// pub valid_indices: HashSet<usize>,
}
impl Charset {
// 内部底层统一收拢构造
/// 从 token 列表构建字符集。
pub fn new(tokens: Vec<Cow<'static, str>>) -> Self {
let mut char_to_idx = HashMap::with_capacity(tokens.len());
for (idx, token) in tokens.iter().enumerate() {
char_to_idx.entry(token.clone()).or_insert(idx);
// 如果字符集有重复,保留第一个遇到的索引 (符合 Python .index 逻辑)
// char_to_idx.entry(token.to_string()).or_insert(idx);
}
Self {
@@ -32,7 +35,7 @@ impl Charset {
// --- 业务策略方法 ---
/// 字符转索引,不存在返回 -1 (保持与原 Python 行为一致)
/// 字符转索引,不存在返回 -1(与 Python 行为一致)。
pub fn char_to_index(&self, char_str: &str) -> i32 {
if let Some(&idx) = self.char_to_idx.get(char_str) {
idx as i32
@@ -41,14 +44,16 @@ impl Charset {
}
}
/// 索引转字符引用,零拷贝。若越界返回 None
/// 索引转字符引用,越界返回 `None`。
pub fn index_to_char_ref(&self, index: usize) -> Option<&str> {
self.tokens.get(index).map(|cow| cow.as_ref())
}
/// 判断字符是否在字符集中。
pub fn is_valid_char(&self, char_str: &str) -> bool {
self.char_to_idx.get(char_str).is_some()
self.char_to_idx.contains_key(char_str)
}
/// 返回字符集大小。
pub fn size(&self) -> usize {
self.tokens.len()
}
@@ -63,4 +68,36 @@ impl std::fmt::Display for Charset {
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_tokens() -> Vec<Cow<'static, str>> {
vec![Cow::Borrowed(""), Cow::Borrowed("a"), Cow::Borrowed("b")]
}
#[test]
fn char_to_index_roundtrip() {
let cs = Charset::new(sample_tokens());
assert_eq!(cs.char_to_index("a"), 1);
assert_eq!(cs.char_to_index("z"), -1);
assert_eq!(cs.index_to_char_ref(2), Some("b"));
assert_eq!(cs.index_to_char_ref(99), None);
}
#[test]
fn duplicate_tokens_keep_first_index() {
let cs = Charset::new(vec![Cow::Borrowed("x"), Cow::Borrowed("x")]);
assert_eq!(cs.char_to_index("x"), 0);
assert_eq!(cs.size(), 2);
}
#[test]
fn is_valid_char_and_size() {
let cs = Charset::new(sample_tokens());
assert!(cs.is_valid_char(""));
assert!(cs.is_valid_char("a"));
assert!(!cs.is_valid_char("A"));
assert_eq!(cs.size(), 3);
}
}

View File

@@ -1,9 +1,10 @@
//! 颜色过滤HSV 区间匹配与颜色预设。
use crate::error::{ImagePreprocessError, Result};
use crate::utils::image_processor::rgb_to_opencv_hsv;
use image::{DynamicImage, ImageBuffer, Rgb};
use std::str::FromStr;
/// 核心区间判定辅助函数
#[inline(always)]
fn is_pixel_matched(ranges: &[HsvRange], h: u8, s: u8, v: u8) -> bool {
ranges.iter().any(|range| {
@@ -15,6 +16,7 @@ fn is_pixel_matched(ranges: &[HsvRange], h: u8, s: u8, v: u8) -> bool {
&& v <= range.upper.2
})
}
/// 按 HSV 区间过滤图像:未命中任一区间的像素刷白。
pub fn apply_to_image(
image: &DynamicImage,
hsv_ranges: &[HsvRange],
@@ -46,8 +48,7 @@ pub fn apply_to_image(
// 3. 将扁平字节数组重新打包回 DynamicImage 容器
let filtered_buffer = ImageBuffer::<Rgb<u8>, Vec<u8>>::from_raw(width, height, raw_pixels)
// .ok_or_else(|| anyhow!("图像缓冲重新组装失败,维度与数据大小不匹配"))?;
.ok_or_else(|| ImagePreprocessError::BufferLengthMismatch {
.ok_or(ImagePreprocessError::BufferLengthMismatch {
expected: expected_len,
actual: actual_len,
width,
@@ -58,23 +59,25 @@ pub fn apply_to_image(
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
/// HSV 颜色区间,下界与上界各为 `(H, S, V)`。
pub struct HsvRange {
/// 区间下界 `(H, S, V)`。
pub lower: (u8, u8, u8), // (H, S, V)
/// 区间上界 `(H, S, V)`。
pub upper: (u8, u8, u8), // (H, S, V)
}
impl HsvRange {
/// 创建 HSV 区间。
pub const fn new(lower: (u8, u8, u8), upper: (u8, u8, u8)) -> Self {
Self { lower, upper }
}
}
impl HsvRange {
/// 验证当前 HSV 范围是否合法
/// 对应 Python 逻辑H 在 0-180S/V 在 0-255且下界 <= 上界
/// 校验区间是否合法H 0-180S/V 0-255且下界 <= 上界)。
pub fn validate(&self) -> Result<(), ImagePreprocessError> {
// 1. 校验 H 通道边界 (OpenCV 中 H 范围是 0-180)
if self.lower.0 > 180 || self.upper.0 > 180 {
// return Err("H通道值必须在 0-180 范围内".to_string());
return Err(ImagePreprocessError::InvalidHsvRange(
"H通道值必须在 0-180 范围内".to_string(),
));
@@ -83,7 +86,6 @@ impl HsvRange {
// 2. 校验下界不能大于上界
if self.lower.0 > self.upper.0 || self.lower.1 > self.upper.1 || self.lower.2 > self.upper.2
{
// return Err("HSV范围下界不能大于上界".to_string());
return Err(ImagePreprocessError::InvalidHsvRange(
"HSV范围下界不能大于上界".to_string(),
));
@@ -93,24 +95,34 @@ impl HsvRange {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// 颜色预设:常见颜色对应的 HSV 区间集合。
pub enum ColorPreset {
/// 红色。
Red,
/// 蓝色。
Blue,
/// 绿色。
Green,
/// 黄色。
Yellow,
/// 橙色。
Orange,
/// 紫色。
Purple,
/// 青色。
Cyan,
/// 黑色。
Black,
/// 白色。
White,
/// 灰色。
Gray,
/// 自定义区间列表。
Custom(Vec<HsvRange>),
}
impl ColorPreset {
/// 纯裸数据定义,没有任何结构体包装,干净利落
/// 返回值:(范围数量, 范围数组)
/// 完美的零成本抽象:利用常量提升将数据直接打入只读数据段 (.rodata)
/// 返回预设对应的 HSV 区间列表。
pub fn matches(&self) -> &[HsvRange] {
match self {
ColorPreset::Red => &[
@@ -162,7 +174,7 @@ impl ColorPreset {
ColorPreset::Custom(ranges) => ranges,
}
}
/// 校验逻辑:在这里实现完美的“责任分离”
/// 校验预设的 HSV 区间是否合法。
pub fn validate(&self) -> Result<(), ImagePreprocessError> {
match self {
// 1. 快捷变体完全绕过根本不校验0 运行时开销放行!
@@ -192,7 +204,6 @@ impl FromStr for ColorPreset {
"black" => Ok(ColorPreset::Black),
"white" => Ok(ColorPreset::White),
"gray" => Ok(ColorPreset::Gray),
// _ => Err(format!("不支持的颜色预设: {}", s)),
_ => Err(ImagePreprocessError::UnknownColorPreset(s.to_string())),
}
}
@@ -202,23 +213,23 @@ impl FromStr for ColorPreset {
// 3. 颜色约束特征Trait与组合子设计模式
// =====================================================================
/// 颜色匹配上下文:当前像素的 HSV 值。
pub struct PixelCtx {
/// 当前像素的 HSV 值。
pub hsv: (u8, u8, u8),
}
/// 统一的颜色约束接口
/// 颜色过滤约束接口:提供一组 HSV 区间。
pub trait ColorFilter {
/// 将自身的有效约束平铺追加到统一的目标容器
/// 将有效区间追加到目标容器
fn append_ranges(&self, target: &mut Vec<HsvRange>);
/// 预估范围数量,借助原生内置的 len() 实现 O(1) 完美控容
/// 预估有效区间数量。
fn estimated_count(&self) -> usize;
/// 将自身的有效约束平铺追加到统一目标容器中
/// 验证当前过滤器是否合法默认直接放行Ok(())
/// 验证过滤器配置是否合法,默认直接放行。
fn validate_self(&self) -> Result<(), ImagePreprocessError> {
Ok(())
}
/// 【新扩展的架构方法】将自身安全的合并到已有的普通容器中,并完成去重和排序
/// 完美的责任分离Builder 不再需要关心怎么分配内存、怎么排序去重
/// 收集全部有效区间并排序去重;无有效区间时返回 `None`。
fn collect_to_vec(&self) -> Result<Option<Vec<HsvRange>>, ImagePreprocessError> {
// 1. 触发自检
self.validate_self()?;
@@ -260,6 +271,7 @@ impl ColorFilter for ColorPreset {
/// 多路颜色“或”逻辑组合子(并集网络)
pub struct MultiOrColorRestrict<'a> {
/// 参与「或」组合的过滤器列表。
pub filters: Vec<&'a dyn ColorFilter>,
}
@@ -288,6 +300,7 @@ impl<'a> ColorFilter for MultiOrColorRestrict<'a> {
// 4. 声明式宏:一语定乾坤
// =====================================================================
/// 组合多个颜色过滤器为「或」关系的快捷宏。
#[macro_export]
macro_rules! color_any_of {
($only:expr) => {
@@ -299,3 +312,59 @@ macro_rules! color_any_of {
}
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hsv_range_validate() {
assert!(HsvRange::new((0, 0, 0), (180, 255, 255)).validate().is_ok());
assert!(
HsvRange::new((181, 0, 0), (255, 255, 255))
.validate()
.is_err()
);
assert!(
HsvRange::new((20, 50, 50), (10, 255, 255))
.validate()
.is_err()
);
}
#[test]
fn color_preset_matches_counts() {
assert_eq!(ColorPreset::Red.matches().len(), 2);
assert_eq!(ColorPreset::Blue.matches().len(), 1);
let custom = ColorPreset::Custom(vec![HsvRange::new((0, 0, 0), (1, 1, 1))]);
assert_eq!(custom.matches().len(), 1);
}
#[test]
fn color_preset_from_str() {
assert_eq!("red".parse::<ColorPreset>().unwrap(), ColorPreset::Red);
assert!("pink".parse::<ColorPreset>().is_err());
}
#[test]
fn color_any_of_collects() {
let ranges = crate::color_any_of!(ColorPreset::Red, ColorPreset::Blue)
.collect_to_vec()
.unwrap()
.unwrap();
assert_eq!(ranges.len(), 3);
}
#[test]
fn apply_to_image_whitens_non_matching() {
let img = DynamicImage::ImageRgb8(image::ImageBuffer::from_pixel(
2,
2,
image::Rgb([255, 0, 0]),
));
let filtered = apply_to_image(&img, ColorPreset::Blue.matches()).unwrap();
for p in filtered.to_rgb8().pixels() {
assert_eq!(p.0, [255, 255, 255]);
}
}
}

View File

@@ -1,41 +1,40 @@
//! OCR 执行器:预测入口与结果类型。
use crate::ocr::metadata::Resize;
use crate::error::{ImagePreprocessError, Result, TensorError};
use crate::ocr::color_filter::{HsvRange, apply_to_image};
// use ddddocr_tract::session::{ModelOutput, OcrSession};
use crate::traits::OcrEngine;
use crate::utils::image_convert::png_rgba_white_preprocess;
use crate::utils::image_processor::{convert_to_grayscale, resize_image};
use crate::{OcrBuilder, OcrOutput};
use image::DynamicImage;
use ndarray::ArrayView2;
use std::borrow::Cow;
use std::fmt;
// use tract_onnx::prelude::tract_ndarray::{ Ix2, s};
// use tract_onnx::prelude::{DatumType, Tensor, tract_ndarray};
// !!!【核心纠正】:彻底弃用 tract_ndarray全线转用标准 ndarray
use ndarray::ArrayView2;
// pub enum ModelOutput {
// Indices(ndarray::Array1<i64>), // 拥有完整所有权的 1维数组可任意传递和返回
// Logits(ndarray::Array2<f32>), // 拥有完整所有权的 2维矩阵可任意传递和返回
// }
use crate::error::{ImagePreprocessError, Result, TensorError};
use crate::{OcrBuilder, OcrOutput};
use crate::traits::OcrEngine;
use tracing::{ warn};
use tracing::{debug, warn};
/// OCR 识别结果:纯文本或携带概率的文本。
#[derive(Debug, Clone)]
pub enum OcrResult {
/// 纯文本分支(对应 probability = false
/// 纯文本结果(`probability = false` 时返回)。
Text(String),
/// 包含全量概率的分支(对应 probability = true
/// 携带概率的结果(`probability = true` 时返回)。
Probability {
/// 识别出的文本。
text: String,
/// 满额概率矩阵 [Steps, Classes]
/// 全量概率矩阵 `[Steps, Classes]`。
probabilities: Vec<Vec<f32>>,
/// 全局平均置信度
/// 全局平均置信度
confidence: f64,
},
/// 不支持的模型或未知输出
Unsupported { message: String },
/// 不支持的模型或未知输出
Unsupported {
/// 不支持原因说明。
message: String,
},
}
impl OcrResult {
/// 消费自身,直接提取最终文本
/// 消费自身提取最终文本
pub fn into_text(self) -> String {
match self {
OcrResult::Text(text) => text,
@@ -103,6 +102,7 @@ impl fmt::Display for OcrResult {
}
}
/// OCR 识别器:预处理、推理调度与后处理解码。
pub struct Ocr<'a> {
pub(crate) runtime: &'a dyn OcrEngine,
pub(crate) png_fix: bool,
@@ -117,6 +117,7 @@ pub struct Ocr<'a> {
impl<'a> Ocr<'a> {
// 初始化任务,设置默认参数
/// 绑定引擎会话创建 OCR 识别器。
pub fn new(runtime: &'a dyn OcrEngine) -> Self {
Ocr {
runtime,
@@ -126,14 +127,15 @@ impl<'a> Ocr<'a> {
final_charset_indices: None,
}
}
/// 创建 OCR 构建器。
pub fn builder() -> OcrBuilder {
OcrBuilder::default()
}
}
impl<'a> Ocr<'a> {
/// 对输入图像执行 OCR 识别并返回结果。
pub fn predict(&self, image: &DynamicImage) -> Result<OcrResult> {
println!("当前颜色过滤器状态: {:?}", self.final_color_ranges);
debug!("当前颜色过滤器状态: {:?}", self.final_color_ranges);
// =====================================================================
// 管道节点 1: 颜色过滤流水线
@@ -142,10 +144,6 @@ impl<'a> Ocr<'a> {
// =====================================================================
let img_cow = match &self.final_color_ranges {
Err(err_msg) => {
// return Err(anyhow::anyhow!(
// "颜色过滤器初始化失败,全链路短路: {}",
// err_msg
// ));
return Err(ImagePreprocessError::FilterConfigInvalid(
err_msg.to_string(),
))?;
@@ -165,23 +163,13 @@ impl<'a> Ocr<'a> {
let raw_tensor = self.runtime.inference(tensor)?;
// 3. 后处理分流:直接返回 OcrResult
// let ocr_output = match raw_tensor.datum_type() {
// DatumType::I64 => self.process_i64_tensor(raw_tensor)?,
// DatumType::F32 => self.process_f32_tensor(raw_tensor)?,
// _ => OcrResult::Unsupported {
// message: format!("不支持的模型输出数据类型: {:?}", raw_tensor.datum_type()),
// },
// };
// let raw_indices = self.ocr.extract_indices_from_tensor(&raw_tensor)?;
// // 步骤 2: 将索引切片 `&[i64]` 传给解码器进行 CTC 去重和字符映射
// let final_text = self.ctc_decode_to_string(&raw_indices);
let ocr_output = self.process_model_output(raw_tensor)?;
Ok(ocr_output)
}
/// 对应 Python 的 _preprocess_image
/// 负责:透明背景修复 -> 灰度化 -> 按比例 Resize -> 归一化 -> 4维张量转换
fn preprocess_image(&self, img: &DynamicImage) -> Result<ndarray::Array4<f32>,ImagePreprocessError> {
fn preprocess_image(
&self,
img: &DynamicImage,
) -> Result<ndarray::Array4<f32>, ImagePreprocessError> {
// 1. 获取模型元数据配置
let meta = self.runtime.metadata();
let norm = &meta.normalization; // 获取归一化器
@@ -218,7 +206,7 @@ impl<'a> Ocr<'a> {
1 => {
let gray_img = convert_to_grayscale(&resized_img);
let array = ndarray::Array4::from_shape_fn(
ndarray::Array4::from_shape_fn(
(1, 1, target_h as usize, target_w as usize),
|(_, _, y, x)| {
let pixel = gray_img.get_pixel(x as u32, y as u32)[0] as f32;
@@ -226,15 +214,14 @@ impl<'a> Ocr<'a> {
// (pixel / 255.0 - 0.5) / 0.5
norm.normalize(pixel)
},
);
array
)
}
// --- 情况 B: 三通道RGB对应 Python 的 transpose(2, 0, 1) 的 CHW 布局 ---
3 => {
let rgb_img = resized_img.to_rgb8();
let array = ndarray::Array4::from_shape_fn(
ndarray::Array4::from_shape_fn(
(1, 3, target_h as usize, target_w as usize),
|(_, c, y, x)| {
let pixel = rgb_img.get_pixel(x as u32, y as u32)[c] as f32;
@@ -242,9 +229,7 @@ impl<'a> Ocr<'a> {
// (pixel / 255.0 - 0.5) / 0.5
norm.normalize(pixel)
},
);
// Tensor::from(array)
array
)
}
// _ => return Err(anyhow::anyhow!("不支持的通道数配置: {}", meta.channel)),
@@ -255,37 +240,13 @@ impl<'a> Ocr<'a> {
}
};
Ok(array4)
// Ok(tensor)
// let h = 64u32;
// let w = (current_img.width() as f32 * (h as f32 / current_img.height() as f32)) as u32;
// let gray_img = convert_to_grayscale(&current_img);
// let resized = resize_image(&gray_img, w, h);
// // resized.save("debug_preprocessed.png").unwrap();
// // 1. 预处理:转灰度 -> Resize -> 归一化
// // let resized = img.resize_exact(w, h, FilterType::Lanczos3).to_luma8();
//
// // 使用 tract_ndarray 构造,避免版本冲突
// let array =
// tract_ndarray::Array4::from_shape_fn((1, 1, h as usize, w as usize), |(_, _, y, x)| {
// let pixel = resized.get_pixel(x as u32, y as u32)[0] as f32;
// (pixel / 255.0 - 0.5) / 0.5
// });
//
// let tensor = Tensor::from(array);
//
// Ok(tensor)
}
// 这段代码未来直接放入 ddddocr-core
fn process_model_output(&self, output: OcrOutput) -> Result<OcrResult,TensorError> {
fn process_model_output(&self, output: OcrOutput) -> Result<OcrResult, TensorError> {
match output {
OcrOutput::Indices(array1) => {
// 对应原来的 process_i64_tensor
let slice = array1
.as_slice()
// .ok_or_else(|| anyhow::anyhow!("内存不连续,无法执行零拷贝解码"))?;
.ok_or_else(|| TensorError::NonContiguousMemory)?;
// 对应原来的 process_i64_tensor
let slice = array1.as_slice().ok_or(TensorError::NonContiguousMemory)?;
let final_text = self.ctc_decode_to_string(slice);
if self.probability {
@@ -299,7 +260,7 @@ impl<'a> Ocr<'a> {
}
}
OcrOutput::Logits(matrix_view) => {
// 对应原来的 process_f32_tensor
// 对应原来的 process_f32_tensor
// 注意:此时的 matrix_view 已经是干净的标准的 ndarray::Array2<f32>,且保证是 [Steps, Classes] 2D 形状
if self.probability {
let (probabilities_list, confidence, predicted_indices) =
@@ -330,7 +291,8 @@ impl<'a> Ocr<'a> {
}
}
impl<'a> Ocr<'a> {
fn is_valid_indices(&self, idx: usize) -> bool {
/// 判断索引是否为当前限制下的有效字符索引。
pub fn is_valid_indices(&self, idx: usize) -> bool {
if idx >= self.runtime.metadata().charset.size() {
return false;
}
@@ -340,8 +302,7 @@ impl<'a> Ocr<'a> {
None => true,
}
}
/// 【按需延迟打印】:当用户真的需要“知道当前有哪些限制字符”时,一秒反查并打印
/// 这里的 &str 完美借用了自 tokens依然是彻底的零拷贝
/// 返回当前生效的可用 token 列表。
pub fn valid_tokens(&self) -> Vec<&str> {
let charset = &self.runtime.metadata().charset;
let tokens = &charset.tokens;
@@ -354,13 +315,13 @@ impl<'a> Ocr<'a> {
None => tokens.iter().map(|cow| cow.as_ref()).collect(),
}
}
/// 返回当前生效的可用 token 数量。
pub fn valid_size(&self) -> usize {
match &self.final_charset_indices {
Some(indices) => indices.len(),
None => self.runtime.metadata().charset.tokens.len(),
}
}
/// 变体 B 核心处理器:单次遍历 2D 视图,融合计算 Softmax、Argmax、置信度并输出概率大包
fn compute_f32_full_probability(
&self,
matrix_view: ArrayView2<f32>,
@@ -413,101 +374,10 @@ impl<'a> Ocr<'a> {
(probabilities_list, confidence, predicted_indices)
}
/// 变体 A 专属提取器:直接从 I64 Tensor 零拷贝提取 CTC 文本与初始概率包
// fn process_i64_tensor(&self, raw_tensor: Tensor) -> anyhow::Result<OcrResult> {
// // 1. 拿到底层的动态维度只读视图
// let view = raw_tensor.to_array_view::<i64>()?;
//
// // 2. 索要底层连续的只读切片引用
// let slice = view
// .as_slice()
// .ok_or_else(|| anyhow::anyhow!("I64 模型输出内存不连续,无法执行零拷贝解码"))?;
//
// // 3. 直接喂给 CTC 解码器(无任何物理克隆开销)
// let final_text = self.ctc_decode_to_string(slice);
//
// // 4. 组装返回
// if self.probability {
// Ok(OcrResult::Probability {
// text: final_text,
// probabilities: vec![], // I64 模型物理上丢失了全量 Logits 分值网,降级处理
// confidence: 1.0, // 判定即百分之百置信
// })
// } else {
// Ok(OcrResult::Text(final_text))
// }
// }
// /// 变体二F32的总体管线负责降维并分流文本和概率
// fn process_f32_tensor(&self, raw_tensor: Tensor) -> anyhow::Result<OcrResult> {
// let shape = raw_tensor.shape();
// println!("模型输出shape数据: {:?}", shape);
// let view = raw_tensor.to_array_view::<f32>()?;
//
// // 1. 极其纯粹的、无拷贝的多维 Shape 压扁清洗
// let (steps, classes, data_dyn_view) = match shape.len() {
// 3 => {
// if shape[1] == 1 {
// // 形状: [Steps, 1, Classes] -> 你的原有逻辑
// (shape[0], shape[2], view.into_dyn())
// } else if shape[0] == 1 {
// // 形状: [1, Steps, Classes] -> 另一种常见导出格式
// (shape[1], shape[2], view.into_dyn())
// } else {
// // 默认取第一个 batch: [Batch, Steps, Classes]
// // 使用 slice 对应 Python 的 output[0, :, :]
// let sliced = view.slice(s![0, .., ..]);
// (shape[1], shape[2], sliced.into_dyn())
// }
// }
// // 形状: [Steps, Classes] -> 已经剥离了 Batch 维度
// 2 => (shape[0], shape[1], view.into_dyn()),
// // 形状: [Classes] -> 单字符输出(对应 Python 的 ndim == 0 保护逻辑)
// // 我们把它虚构成一个 [1, Classes] 的 2D 矩阵来复用后面的 argmax 逻辑
// 1 => (1, shape[0], view.into_dyn()),
// _ => return Err(anyhow::anyhow!("不支持的输出维度: {:?}", shape)),
// };
// let matrix_cow = data_dyn_view
// .to_shape(Ix2(steps, classes))
// .map_err(|e| anyhow::anyhow!("转换为2D静态矩阵失败: {:?}", e))?;
//
// let matrix_view: ArrayView2<f32> = matrix_cow.view();
//
// // 2. 根据业务参数明确分流
// if self.probability {
// // 走向 B1调用刚刚拆分出来的“全量概率计算器”
// let (probabilities_list, confidence, predicted_indices) =
// self.compute_f32_full_probability(matrix_view);
// // 5. 执行 CTC 解码
// let final_text = self.ctc_decode_to_string(&predicted_indices);
//
// Ok(OcrResult::Probability {
// text: final_text,
// probabilities: probabilities_list,
// confidence: confidence as f64,
// })
// } else {
// // 走向 B2极速免 Softmax 提取纯文本(代码保持原地提取,简单短小不需要再拆)
// let predicted_indices: Vec<i64> = matrix_view
// .outer_iter()
// .map(|row| {
// row.iter()
// .enumerate()
// .max_by(|(_, a), (_, b)| a.total_cmp(b))
// .map(|(idx, _)| idx as i64)
// .unwrap_or(0)
// })
// .collect();
//
// let final_text = self.ctc_decode_to_string(&predicted_indices);
// Ok(OcrResult::Text(final_text))
// }
// }
/// 获取有效字符索引列表 (用于外部验证或过滤)
fn ctc_decode_to_string(&self, predicted_indices: &[i64]) -> String {
println!("indices模型输出原始数据: {:?}", predicted_indices);
debug!("indices模型输出原始数据: {:?}", predicted_indices);
let charset = &self.runtime.metadata().charset;
let tokens = &charset.tokens;
// let valid_indices = &charset.valid_indices;
// 对应 _ctc_decode_indices 的逻辑:去重、去 blank (0)
let mut res = String::new();
@@ -533,17 +403,16 @@ impl<'a> Ocr<'a> {
// 史诗级加速点:如果是 None说明没限制根本不进入分支直接放行
// 只有当有具体限制Some才去跑 4-5 次 CPU 寄存器级别的二分查找
if let Some(ref indices) = self.final_charset_indices {
if indices.binary_search(&u_idx).is_err() {
continue;
}
if let Some(ref indices) = self.final_charset_indices
&& indices.binary_search(&u_idx).is_err()
{
continue;
}
// 5. 字符映射
if let Some(char_str) = tokens.get(u_idx) {
res.push_str(char_str);
}
else {
} else {
warn!("警告: 预测索引 {} 超出字符集范围", u_idx);
}
}

View File

@@ -1,3 +1,5 @@
//! OCR 模型元数据:归一化、缩放策略与模型信息。
// =====================================================================
// 1. 辅助定义的枚举与结构体
// =====================================================================
@@ -5,6 +7,7 @@
use crate::ocr::Charset;
use std::borrow::Cow;
/// 像素归一化策略。
#[derive(Debug, Clone, Copy)]
pub enum Normalization {
/// 映射到 [0.0, 1.0] -> pixel / 255.0
@@ -14,7 +17,7 @@ pub enum Normalization {
}
impl Normalization {
/// 统一归一化计算逻辑
/// 对像素值执行归一化。
#[inline(always)]
pub fn normalize(&self, pixel: f32) -> f32 {
match self {
@@ -35,21 +38,23 @@ pub enum Resize {
Square(u32),
}
/// OCR 模型元数据:字符集、缩放策略、通道数与归一化配置。
#[derive(Debug, Clone)]
pub struct ModelMetadata {
/// 字符集管理器
/// 字符集
pub charset: Charset,
/// 是否为单字识别模型
pub word: bool,
/// 预处理的缩放策略
/// 缩放策略
pub resize: Resize,
/// 图像通道数 (1 或 3)
/// 图像通道数1 或 3
pub channel: u8,
/// 新增:传递给核心业务使用的归一化配置
/// 像素归一化配置
pub normalization: Normalization,
}
impl ModelMetadata {
/// 创建模型元数据。
pub fn new(
charset: Charset,
word: bool,
@@ -66,7 +71,7 @@ impl ModelMetadata {
}
}
// --- 优雅的工厂模式构造器 ---
/// 通用的静态切片转换构造器
/// 从静态字符切片构建元数据并自动生成字符集。
pub fn from_static_slice(
slice: &[&'static str],
word: bool,
@@ -83,4 +88,69 @@ impl ModelMetadata {
normalization,
}
}
/// 使用内置旧版字符集(与官方旧版模型配套)。
pub fn from_builtin_old(
word: bool,
resize: Resize,
channel: u8,
normalization: Normalization,
) -> Self {
Self::from_static_slice(
super::builtin::CHARSET_OLD,
word,
resize,
channel,
normalization,
)
}
/// 使用内置 Beta 字符集(与官方 Beta 模型配套)。
pub fn from_builtin_beta(
word: bool,
resize: Resize,
channel: u8,
normalization: Normalization,
) -> Self {
Self::from_static_slice(
super::builtin::CHARSET_BETA,
word,
resize,
channel,
normalization,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalization_zero_to_one() {
let n = Normalization::ZeroToOne;
assert_eq!(n.normalize(0.0), 0.0);
assert_eq!(n.normalize(255.0), 1.0);
}
#[test]
fn normalization_minus_one_to_one() {
let n = Normalization::MinusOneToOne;
assert_eq!(n.normalize(0.0), -1.0);
assert_eq!(n.normalize(255.0), 1.0);
}
#[test]
fn from_static_slice_builds_charset() {
let meta = ModelMetadata::from_static_slice(
&["", "a"],
false,
Resize::Fixed(64, 64),
1,
Normalization::ZeroToOne,
);
assert_eq!(meta.charset.size(), 2);
assert_eq!(meta.charset.char_to_index("a"), 1);
assert_eq!(meta.channel, 1);
assert!(!meta.word);
}
}

View File

@@ -1,20 +1,26 @@
use std::borrow::Cow;
//! 字符集限制:按字符属性或索引过滤识别范围。
/// 字符集范围限制枚举
use std::borrow::Cow;
use tracing::warn;
/// 字符集校验上下文:当前 token 的文本与索引。
pub struct ValidationCtx<'a> {
pub text: &'a str, // 当前 Token 的文本内容
/// 当前 token 的文本内容
pub text: &'a str, // 当前 Token 的文本内容
/// 当前 token 的 ID 索引。
pub token_id: usize, // 当前 Token 的 ID 索引
}
/// 统一的约束接口
/// 字符集限制接口:决定某个 token 是否放行。
pub trait TokenFilter {
/// 判断 token 是否放行。
fn matches(&self, ctx: &ValidationCtx) -> bool;
/// 预估容量提示,帮助精准开辟 Vec 内存
/// 预估匹配数量的容量提示。
fn estimated_capacity(&self) -> usize {
128
}
/// 【新引入的架构级核心方法】
/// 统一接管全量字符集的密集遍历、CTC Blank放行、去重、排序及空交集退化兜底
/// 遍历全量字符集筛选可用索引(放行 CTC blank、排序去重、空交集返回 `None`)。
fn apply_to_charset(&self, tokens: &[Cow<str>]) -> Option<Vec<usize>> {
let mut has_any_match = false;
let estimated_capacity = self.estimated_capacity();
@@ -47,7 +53,7 @@ pub trait TokenFilter {
// 3. 终极防御:如果整个模型字符集除了 Blank一个都没对上直接退化为 None全量识别
if !has_any_match {
println!("警告:当前限制策略与模型字符集完全没有交集已自动恢复全量识别");
warn!("当前限制策略与模型字符集完全没有交集已自动恢复全量识别");
None
} else {
// 4. 排序并去重,为 Ocr 引擎后续进行极其高频的『二分查找』筑起绝对安全的底层保障
@@ -58,11 +64,16 @@ pub trait TokenFilter {
}
}
/// 按字符属性限制:数字、大小写字母或自定义列表。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CharRestrict {
/// 仅数字。
Digit,
/// 仅小写字母。
Lowercase,
/// 仅大写字母。
Uppercase,
/// 自定义字符列表。
CustomList(Vec<String>),
}
@@ -84,10 +95,14 @@ impl TokenFilter for CharRestrict {
}
}
/// 按索引限制:前 N 个、索引范围或索引列表。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IdRestrict {
/// 前 N 个索引。
TopN(usize),
/// 指定索引范围。
IdRange(std::ops::Range<usize>),
/// 指定索引列表。
IdList(Vec<usize>),
}
@@ -113,6 +128,7 @@ impl TokenFilter for IdRestrict {
/// 多路“或”逻辑组合子(支持 N 个规则无缝并集)
pub struct MultiOrRestrict<'a> {
/// 参与「或」组合的过滤器列表。
pub filters: Vec<&'a dyn TokenFilter>,
}
@@ -127,9 +143,8 @@ impl<'a> TokenFilter for MultiOrRestrict<'a> {
self.filters.iter().map(|f| f.estimated_capacity()).sum()
}
}
// =====================================================================
// 声明式宏:替代 `+` 运算符,解决组合扩展痛苦
// =====================================================================
/// 组合多个字符限制规则为「或」关系的快捷宏。
#[macro_export]
macro_rules! any_of {
// 场景 A如果用户只传了一个规则免去构建 Vec 的开销,直接返回其引用
@@ -137,10 +152,81 @@ macro_rules! any_of {
&$only as &dyn $crate::TokenFilter
};
// 场景 B如果用户传入了多个规则自动织成一张静态组合
// 场景 B如果用户传入了多个规则自动组合
($($filter:expr),+ $(,)?) => {
&$crate::MultiOrRestrict {
filters: vec![ $( &$filter as &dyn $crate::TokenFilter ),+ ]
}
};
}
#[cfg(test)]
mod tests {
use super::*;
fn tokens() -> Vec<Cow<'static, str>> {
vec![
Cow::Borrowed(""),
Cow::Borrowed("1"),
Cow::Borrowed("a"),
Cow::Borrowed("A"),
Cow::Borrowed("!"),
]
}
#[test]
fn char_restrict_digit() {
let indices = CharRestrict::Digit.apply_to_charset(&tokens()).unwrap();
assert_eq!(indices, vec![0, 1]);
}
#[test]
fn char_restrict_case() {
assert_eq!(
CharRestrict::Lowercase.apply_to_charset(&tokens()).unwrap(),
vec![0, 2]
);
assert_eq!(
CharRestrict::Uppercase.apply_to_charset(&tokens()).unwrap(),
vec![0, 3]
);
}
#[test]
fn custom_list() {
let r = CharRestrict::CustomList(vec!["!".into(), "a".into()]);
assert_eq!(r.apply_to_charset(&tokens()).unwrap(), vec![0, 2, 4]);
}
#[test]
fn no_intersection_falls_back_to_none() {
let r = CharRestrict::CustomList(vec!["z".into()]);
assert_eq!(r.apply_to_charset(&tokens()), None);
}
#[test]
fn id_restrict_variants() {
assert_eq!(
IdRestrict::TopN(3).apply_to_charset(&tokens()).unwrap(),
vec![0, 1, 2]
);
assert_eq!(
IdRestrict::IdRange(1..3)
.apply_to_charset(&tokens())
.unwrap(),
vec![0, 1, 2]
);
assert_eq!(
IdRestrict::IdList(vec![0, 4])
.apply_to_charset(&tokens())
.unwrap(),
vec![0, 4]
);
}
#[test]
fn multi_or_restrict_unions() {
let combined = crate::any_of!(CharRestrict::Digit, CharRestrict::Uppercase);
assert_eq!(combined.apply_to_charset(&tokens()).unwrap(), vec![0, 1, 3]);
}
}

View File

@@ -1,3 +1,5 @@
//! 滑块验证码匹配:模板匹配与差异比较两种模式。
use crate::error::{ImagePreprocessError, Result};
use crate::utils::image_convert::{ColorMode, image_to_ndarray};
use crate::utils::image_processor;
@@ -12,11 +14,18 @@ use imageproc::region_labelling::{Connectivity, connected_components};
use imageproc::template_matching::{MatchTemplateMethod, match_template};
use ndarray::{ArrayView2, ArrayView3};
use std::fmt;
use tracing::debug;
/// 滑块匹配结果:检测中心坐标与置信度。
#[derive(Debug)]
pub struct SlideResult {
/// 检测中心坐标 `[x, y]`。
pub target: [i32; 2],
/// 检测中心的 x 坐标。
pub target_x: i32,
/// 检测中心的 y 坐标。
pub target_y: i32,
/// 匹配置信度。
pub confidence: f64,
}
impl fmt::Display for SlideResult {
@@ -29,13 +38,16 @@ impl fmt::Display for SlideResult {
}
}
/// 滑块匹配服务:提供模板匹配与差异比较两种识别模式。
#[derive(Default)]
pub struct Slider;
impl Slider {
/// 创建滑块匹配服务。
pub fn new() -> Self {
Self
}
/// 对应 Python: slide_match 滑块匹配接口
/// 模板匹配滑块:在背景图中定位滑块中心(对应 Python 的 `slide_match`)。
pub fn slide_match(
&self,
target_image: &DynamicImage,
@@ -48,8 +60,7 @@ impl Slider {
self.perform_slide_match(target_array.view(), background_array.view(), simple_target)
.map_err(Into::into)
}
/// 对应 Python: slide_comparison 差异比较接口
/// 用于比较带坑位的图片与原始背景图,定位差异点
/// 差异比较滑块:对比带坑位的图与原始背景图,定位差异中心(对应 Python 的 `slide_comparison`)。
pub fn slide_comparison(
&self,
target_image: &DynamicImage,
@@ -63,7 +74,7 @@ impl Slider {
self.perform_slide_comparison(target_array.view(), background_array.view())
.map_err(Into::into)
}
/// 对应 Python: _perform_slide_comparison
/// 差异比较核心实现(对应 Python 的 `_perform_slide_comparison`)。
pub fn perform_slide_comparison(
&self,
target: ArrayView3<u8>,
@@ -108,7 +119,6 @@ impl Slider {
let background_label = Luma([0u8]);
let labelled = connected_components(&cleaned, Connectivity::Eight, background_label);
// // 统计每个标签出现的频率(即面积)
// 4. 寻找最大连通区域 (对应 findContours + max area)
if let Some(max_label) = image_processor::find_contours_and_max(&labelled) {
// 5. 计算最大区域的边界框 (对应 cv2.boundingRect)
@@ -133,8 +143,7 @@ impl Slider {
}
}
/// 对应 Python: _perform_slide_match
// 在 SlideEngine 中修改此入口进行测试
/// 模板匹配核心实现(对应 Python 的 `_perform_slide_match`)。
fn perform_slide_match(
&self,
target: ArrayView3<u8>,
@@ -153,7 +162,6 @@ impl Slider {
}
if th > bh || tw > bw {
return Err(ImagePreprocessError::TargetExceedsBackground {
// "尺寸不匹配:滑块模板(target)尺寸 [{}x{}] 不能大于背景图(background) [{}x{}]",
target_w: tw,
target_h: th,
bg_w: bw,
@@ -180,9 +188,7 @@ impl Slider {
Ok(self.edge_based_match(target_gray.view(), background_gray.view()))
}
}
/// 对应 Python: _simple_template_match
/// 使用 SAD (Sum of Absolute Differences) 算法
/// 核心模板匹配SAD + 有效像素过滤
/// 简单模式模板匹配:直接对灰度图做归一化互相关(对应 Python 的 `_simple_template_match`)。
fn simple_template_match(
&self,
target: ArrayView2<u8>,
@@ -192,7 +198,6 @@ impl Slider {
// 转换逻辑 (假设你已经有方法转回 ImageBuffer)
let t_buf = ndarray_to_luma8(target);
let b_buf = ndarray_to_luma8(background);
// t_buf.save("debug_rust_target.png").unwrap();
// 2. 调用 imageproc 的 NCC 算法 (等价于 cv2.TM_CCOEFF_NORMED)
// 模板匹配 (完全对齐 cv2.matchTemplate(..., cv2.TM_CCOEFF_NORMED))
@@ -201,18 +206,13 @@ impl Slider {
&t_buf,
MatchTemplateMethod::CrossCorrelationNormalized,
);
// save_rust_result(&result, "debug_rust_target2.png");
// 3. 寻找最大值 (等价于 cv2.minMaxLoc)
let (max_val, max_loc) = min_max_loc(&result);
// 4. 计算中心点 (与 Python 逻辑完全一致)
let (th, tw) = target.dim();
let (center_x, center_y) =
image_processor::calculate_center(max_loc, tw as usize, th as usize);
// println!("Rust Target Width (tw): {}", tw);
// println!("Rust Best Max Loc X: {}", max_loc.0);
// println!("Rust Final Center X: {}", center_x);
let (center_x, center_y) = image_processor::calculate_center(max_loc, tw, th);
SlideResult {
target: [center_x, center_y],
target_x: center_x,
@@ -221,8 +221,7 @@ impl Slider {
}
}
/// 对应 Python: _edge_based_match
/// 基于边缘检测的滑块匹配 (对齐 Python _edge_based_match)
/// 边缘模式模板匹配:基于 Canny 边缘检测后再匹配(对应 Python 的 `_edge_based_match`)。
pub fn edge_based_match(
&self,
target: ArrayView2<u8>,
@@ -238,9 +237,6 @@ impl Slider {
let target_edges = canny(&t_buf, 50.0, 150.0);
let background_edges = canny(&b_buf, 50.0, 150.0);
// target_edges.save("debug_target_edges.png").ok();
// background_edges.save("debug_bg_edges.png").ok();
// 3. 模板匹配 (完全对齐 cv2.matchTemplate(..., cv2.TM_CCOEFF_NORMED))
// 在边缘图上计算归一化互相关系数
let result = match_template(
@@ -254,14 +250,12 @@ impl Slider {
// 5. 计算中心位置 (对齐 Python 逻辑)
// target_w, target_h 来自输入数组的维度
let (th, tw) = target.dim();
let (center_x, center_y) =
image_processor::calculate_center(max_loc, tw as usize, th as usize);
let (center_x, center_y) = image_processor::calculate_center(max_loc, tw, th);
// 打印调试信息,方便与 Python 对比
// println!("Edge Match: max_val: {}, max_loc: {:?}", max_val, max_loc);
println!("-Rust Target Width (tw): {}", tw);
println!("-Rust Best Max Loc X: {}", max_loc.0);
println!("-Rust Final Center X: {}", center_x);
debug!("-Rust Target Width (tw): {}", tw);
debug!("-Rust Best Max Loc X: {}", max_loc.0);
debug!("-Rust Final Center X: {}", center_x);
SlideResult {
target: [center_x, center_y],
target_x: center_x,
@@ -270,3 +264,44 @@ impl Slider {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn paint_block(img: &mut DynamicImage, x: u32, y: u32, w: u32, h: u32) {
let mut luma = img.to_luma8();
for yy in y..y + h {
for xx in x..x + w {
luma.put_pixel(xx, yy, Luma([255u8]));
}
}
*img = DynamicImage::ImageLuma8(luma);
}
#[test]
fn slide_match_finds_block_center() {
let slider = Slider::new();
let mut target = DynamicImage::new_luma8(10, 10);
paint_block(&mut target, 3, 3, 4, 4);
let mut background = DynamicImage::new_luma8(30, 30);
paint_block(&mut background, 8, 8, 4, 4);
let res = slider.slide_match(&target, &background, true).unwrap();
// 模板内白块位于 (3,3),因此最佳匹配原点为 (8-3, 8-3)=(5,5),中心为 (5+5, 5+5)
assert_eq!(res.target_x, 10);
assert_eq!(res.target_y, 10);
assert!((res.confidence - 1.0).abs() < 1e-3);
}
#[test]
fn slide_comparison_identical_returns_zero() {
let slider = Slider::new();
let mut img = DynamicImage::new_luma8(16, 16);
paint_block(&mut img, 4, 4, 4, 4);
let res = slider.slide_comparison(&img, &img).unwrap();
assert_eq!(res.target, [0, 0]);
assert_eq!(res.confidence, 0.0);
}
}

View File

@@ -1,40 +1,53 @@
//! 推理引擎统一抽象接口。
use crate::error::TensorError;
use crate::types::{ModelInfo, TensorInfo};
use crate::{DetOutput, ModelMetadata, OcrOutput};
use std::path::Path;
/// 查询模型输入/输出信息的接口。
pub trait Info {
/// 获取输入张量信息列表。
fn input_info(&self) -> crate::error::Result<Vec<TensorInfo>>;
/// 获取输出张量信息列表。
fn output_info(&self) -> crate::error::Result<Vec<TensorInfo>>;
/// 获取模型完整输入/输出信息。
fn model_info(&self) -> crate::error::Result<ModelInfo>;
}
/// 核心层定义的统一推理引擎接口
/// 未来的 ddddocr-tract 和 ddddocr-ort 都必须实现这个 Trait
/// 推理引擎统一抽象接口,由 ddddocr-tract2、ddddocr-ort 等引擎 crate 实现
pub trait InferenceEngine {
/// 关联类型:具体的 Session 需要声明自己到底产出什么枚举
/// 引擎产出的输出枚举OCR 为 [`crate::OcrOutput`],检测为 [`crate::DetOutput`])。
type Output;
/// 对输入张量执行推理并返回引擎定义的输出。
fn inference(
&self,
input_array: ndarray::Array4<f32>,
) -> crate::error::Result<Self::Output, TensorError>;
}
/// OCR 引擎接口:输出 [`crate::OcrOutput`],并提供模型元数据。
pub trait OcrEngine: InferenceEngine<Output = OcrOutput> + Info {
/// 获取模型元数据。
fn metadata(&self) -> &ModelMetadata;
}
/// 目标检测引擎接口:输出 [`crate::DetOutput`]。
pub trait DetEngine: InferenceEngine<Output = DetOutput> {}
/// 模型加载器:从本地路径或字节流构建引擎会话。
pub trait Loader {
/// 构建出的引擎会话类型。
type Session;
/// 构建过程中的错误类型。
type Error;
/// 从本地模型路径构建会话。
fn build_for_path<P: AsRef<Path>>(
&self,
model_path: P,
) -> crate::error::Result<Self::Session, Self::Error>;
/// 从模型字节流构建会话。
fn build_from_bytes(
&self,
model_bytes: &[u8],

View File

@@ -1,25 +1,33 @@
#[derive(Debug,Clone)]
pub enum TensorType{
//! 模型输入输出信息等共享类型,由 [`crate::traits::Info`] 接口返回。
/// 张量元素的数据类型标记。
#[derive(Debug, Clone)]
pub enum TensorType {
/// 32 位浮点类型。
F32,
/// 64 位整数类型。
I64,
Other
/// 其他类型。
Other,
}
/// 明确命名为 AxisDim代表模型某一个轴的维度特征
/// 模型某个轴的维度特征:静态数值或动态符号。
#[derive(Clone, PartialEq, Eq)]
pub enum AxisDim {
/// 静态固定维度(如通道数固定为 1高度固定为 64
/// 静态固定维度
Static(usize),
/// 动态符号维度(如宽度是动态的 "image_width"
/// 动态符号维度
Dynamic(String),
}
impl AxisDim {
/// 便捷方法:判断是否为动态维度
/// 是否为动态维度
pub fn is_dynamic(&self) -> bool {
matches!(self, AxisDim::Dynamic(_))
}
}
/// 自定义 Debug 格式化输出,彻底融化套娃外壳,保证日志干净漂亮
/// 自定义 `Debug` 输出:静态维度显示数值,动态维度显示符号名。
impl std::fmt::Debug for AxisDim {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@@ -28,19 +36,25 @@ impl std::fmt::Debug for AxisDim {
}
}
}
/// 模拟 Python 的 input_info 和 output_info 结构
/// 单个张量(输入或输出)的名称、形状与数据类型描述。
#[derive(Debug, Clone)]
pub struct TensorInfo {
/// 张量名称。
pub name: String,
pub shape: Vec<AxisDim>, // 既包含 Fixed 静态维度,也包含 Dynamic 动态符号
pub tensor_type: TensorType, // 对应 Python 的 type
/// 各轴形状描述。
pub shape: Vec<AxisDim>,
/// 元素数据类型。
pub tensor_type: TensorType,
}
/// 最终返回的模型完整信息
/// 模型完整输入/输出信息
#[derive(Debug, Clone)]
pub struct ModelInfo {
/// 输入张量列表。
pub inputs: Vec<TensorInfo>,
/// 输出张量列表。
pub outputs: Vec<TensorInfo>,
/// 硬件执行提供者(采用 Option 兼容不同底层的推理引擎)
/// 硬件执行提供者(`None` 表示使用引擎默认后端)。
pub providers: Option<Vec<String>>,
}
}

View File

@@ -1,3 +1,5 @@
//! 图像与张量工具:加载、格式转换、图像处理与归一化。
pub mod image_convert;
mod image_helper;
pub mod image_processor;

View File

@@ -1,24 +1,28 @@
//! 图像格式转换DynamicImage 与 ndarray 数组互转。
use crate::error::{ImagePreprocessError, Result};
use image::{DynamicImage, GenericImageView, ImageBuffer, Luma, Rgb, Rgba};
use ndarray::{Array3, ArrayViewD};
/// 图像通道模式。
#[derive(Debug)]
pub enum ColorMode {
/// RGB 三通道。
RGB,
/// RGBA 四通道。
RGBA,
/// 灰度单通道。
L,
}
/// 封装数组转图像的逻辑,
/// 将 ndarray 数组转换为图像(自动识别 HWC 通道数)。
// 对应 Python 版 _numpy_to_pil_image
pub fn ndarray_to_hwc_image(array: ArrayViewD<u8>) -> Result<DynamicImage,ImagePreprocessError> {
pub fn ndarray_to_hwc_image(array: ArrayViewD<u8>) -> Result<DynamicImage, ImagePreprocessError> {
let shape = array.shape();
let dim = shape.len();
// 1. 确保数据在内存中是连续的 (C order / Standard Layout)
// 如果 arr 是经过切片或转置的,这一步会进行必要的内存拷贝
// let standard = array.as_standard_layout();
// let (raw_data, _offset) = standard.to_owned().into_raw_vec_and_offset();
let color_mode = match dim {
// 对应 Python: len(array.shape) == 2 (灰度图 H, W)
@@ -97,8 +101,10 @@ pub fn png_rgba_white_preprocess(img: &DynamicImage) -> DynamicImage {
DynamicImage::ImageRgb8(background)
}
/// 将 DynamicImage 转换为 array 数组
pub fn image_to_ndarray(image: &DynamicImage, mode: ColorMode) -> Result<Array3<u8>,ImagePreprocessError> {
// 1. 模式转换 (对应 utils.convert(target_mode)),此函数在时保留看后续优化是否需要替代image_to_ndarray
pub fn image_to_ndarray(
image: &DynamicImage,
mode: ColorMode,
) -> Result<Array3<u8>, ImagePreprocessError> {
// Rust utils 库通过 to_rgb8, to_luma8 等方法实现转换
let (width, height) = image.dimensions();
@@ -113,7 +119,10 @@ pub fn image_to_ndarray(image: &DynamicImage, mode: ColorMode) -> Result<Array3<
Ok(array)
}
/// 将 array 数组转换为 DynamicImage
pub fn ndarray_to_image(array: ArrayViewD<u8>, mode: ColorMode) -> Result<DynamicImage,ImagePreprocessError> {
pub fn ndarray_to_image(
array: ArrayViewD<u8>,
mode: ColorMode,
) -> Result<DynamicImage, ImagePreprocessError> {
let shape = array.shape();
// 基础边界检查:至少要有 H 和 W 两个维度
@@ -121,12 +130,15 @@ pub fn ndarray_to_image(array: ArrayViewD<u8>, mode: ColorMode) -> Result<Dynami
return Err(ImagePreprocessError::InvalidDimensions {
expected: "至少为 2D array [H, W]".to_string(),
actual: shape.to_vec(),
})?;
});
}
from_ndarray(array, mode)
}
fn from_ndarray(array: ArrayViewD<u8>, mode: ColorMode) -> Result<DynamicImage,ImagePreprocessError> {
fn from_ndarray(
array: ArrayViewD<u8>,
mode: ColorMode,
) -> Result<DynamicImage, ImagePreprocessError> {
let shape = array.shape();
// 映射ndarray 的 shape 默认是 [Height, Width, (Channels)]
@@ -149,14 +161,12 @@ fn from_ndarray(array: ArrayViewD<u8>, mode: ColorMode) -> Result<DynamicImage,I
let expected_len = (width * height) as usize * channels;
// 构造通用错误闭包,避免 match 分支中重复编写冗长的错误对象
let make_err = || {
ImagePreprocessError::BufferLengthMismatch {
expected: expected_len,
actual: raw_len,
width,
height,
channels,
}
let make_err = || ImagePreprocessError::BufferLengthMismatch {
expected: expected_len,
actual: raw_len,
width,
height,
channels,
};
// 2. 重新解释内存并构建 ImageBuffer
@@ -172,3 +182,36 @@ fn from_ndarray(array: ArrayViewD<u8>, mode: ColorMode) -> Result<DynamicImage,I
.ok_or_else(make_err),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn image_to_ndarray_rgb_dims() {
let img = DynamicImage::new_rgb8(4, 3);
let arr = image_to_ndarray(&img, ColorMode::RGB).unwrap();
assert_eq!(arr.dim(), (3, 4, 3));
}
#[test]
fn ndarray_roundtrip() {
let img = DynamicImage::new_rgb8(2, 2);
let arr = image_to_ndarray(&img, ColorMode::RGB).unwrap();
let back = ndarray_to_image(arr.view().into_dyn(), ColorMode::RGB).unwrap();
assert_eq!(back.to_rgb8().dimensions(), (2, 2));
}
#[test]
fn png_white_preprocess_fills_transparent() {
let img = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
2,
2,
image::Rgba([255, 0, 0, 0]),
));
let out = png_rgba_white_preprocess(&img);
for p in out.to_rgb8().pixels() {
assert_eq!(p.0, [255, 255, 255]);
}
}
}

View File

@@ -1,6 +1,8 @@
//! 图像输入源从路径、Base64、字节等统一加载图片。
use crate::error::{DdddError, Result};
use crate::utils::image_convert::ndarray_to_hwc_image;
use base64::{engine::general_purpose, Engine as _};
use base64::{Engine as _, engine::general_purpose};
use image::DynamicImage;
use ndarray::ArrayViewD;
use std::fmt;
@@ -8,8 +10,9 @@ use std::fmt::{Debug, Formatter};
use std::fs;
use std::path::Path;
use std::path::PathBuf;
/// Base64 字符串包装。
pub struct Base64<'a>(pub &'a str);
/// 专属图像输入源转换器
/// 统一图像输入源,包装加载完成的图像。
pub struct ImageSource {
inner: DynamicImage,
}
@@ -21,6 +24,7 @@ impl ImageSource {
}
}
/// 从多种输入类型转换为 [`ImageSource`] 的转换接口。
pub trait TryFromImage<T>: Sized {
// 唯一的转换入口,通过目标类型来调用
fn try_from_image(value: T) -> Result<Self>;
@@ -109,7 +113,7 @@ impl<'a> TryFromImage<Base64<'a>> for ImageSource {
}
}
/// 模拟 Python 的 load_image_from_input
/// 从任意受支持的输入类型加载图像。
#[allow(dead_code)]
pub fn load_image_from_input<I>(input: I) -> Result<DynamicImage>
where
@@ -119,7 +123,7 @@ where
Ok(img)
}
/// 将base64编码的图片转换为 DynamicImage
/// 将 Base64 编码的图片转换为 DynamicImage
pub fn base64_to_image(b64_str: &str) -> Result<DynamicImage> {
// 过滤掉可能存在的 base64 前缀,例如 "data:utils/png;base64,"
let clean_b64 = if let Some(pos) = b64_str.find(",") {

View File

@@ -1,16 +1,18 @@
use image::{imageops::FilterType, DynamicImage, GrayImage, ImageBuffer, Luma};
//! 图像处理算法OpenCV 风格的常用函数封装。
use ndarray::{azip, Array2, Array3, ArrayView2, ArrayView3};
use image::{DynamicImage, GrayImage, ImageBuffer, Luma, imageops::FilterType};
use ndarray::{Array2, Array3, ArrayView2, ArrayView3, azip};
use std::cmp::{max, min};
// 模拟openCV
/// 1. 计算两个数组的绝对差值 (对应 cv2.absdiff)
/// 计算两个 HWC 数组的绝对差值对应 cv2.absdiff)。
pub fn abs_diff(a: &ArrayView3<u8>, b: &ArrayView3<u8>) -> Array3<u8> {
// 利用 ndarray 的 map_collect生成差值的绝对值数组
// 或者直接使用 zip_mut_with 处理以减少内存分配
let mut diff = Array3::zeros(a.dim());
azip!((res in &mut diff, &va in a, &vb in b) {
*res = (va as i16 - vb as i16).abs() as u8;
*res = va.abs_diff(vb);
});
diff
}
@@ -27,7 +29,7 @@ pub fn rgb_to_gray(rgb: ArrayView3<u8>) -> Array2<u8> {
})
}
/// 找匹配结果图中的最大值及其坐标 (模拟 cv2.minMaxLoc 的一部分)
/// 找匹配结果图中的最大值及其坐标(对应 cv2.minMaxLoc)。
pub fn min_max_loc(result_map: &ImageBuffer<Luma<f32>, Vec<f32>>) -> (f32, (u32, u32)) {
// 4. 找到最佳匹配位置 (对齐 cv2.minMaxLoc)
let mut max_val: f32 = -1.0;
@@ -48,8 +50,7 @@ pub fn min_max_loc(result_map: &ImageBuffer<Luma<f32>, Vec<f32>>) -> (f32, (u32,
(max_val, max_loc)
}
/// 1. 模拟 findContours 并获取最大面积区域的 Label
/// 返回 Option<u32>,如果找不到任何区域则返回 None
/// 模拟 findContours:返回面积最大的连通域标签,找不到时返回 `None`。
pub fn find_contours_and_max(labelled: &ImageBuffer<Luma<u32>, Vec<u32>>) -> Option<u32> {
// 统计每个标签出现的频率(即面积)
let mut max_label = 0;
@@ -74,9 +75,7 @@ pub fn find_contours_and_max(labelled: &ImageBuffer<Luma<u32>, Vec<u32>>) -> Opt
Some(max_label)
}
}
/// 根据目标连通域标签,计算其在图像中的外接矩形边界框(对应 `cv2.boundingRect`
///
/// 返回格式: `(min_x, min_y, width, height)`
/// 计算指定连通域标签的外接矩形(对应 cv2.boundingRect,返回 `(min_x, min_y, width, height)`。
pub fn bounding_rect(
labelled: &ImageBuffer<Luma<u32>, Vec<u32>>,
max_label: u32,
@@ -109,9 +108,7 @@ pub fn calculate_center(top_left: (u32, u32), width: usize, height: usize) -> (i
(center_x, center_y)
}
/// 高性能转换:将 `ndarray` 2D 灰度视图规整为 `image::ImageBuffer` 格式
///
/// 放弃低效的逐像素显式嵌套循环,采用原生内存池直接构造,减少寻址开销
/// 将 2D 灰度 ndarray 视图转换为灰度 ImageBuffer
pub fn ndarray_to_luma8(array: ArrayView2<u8>) -> ImageBuffer<Luma<u8>, Vec<u8>> {
let (height, width) = array.dim();
// 技巧:直接将已有的规整连续内存打平转换,或用 from_raw 包装
@@ -128,6 +125,7 @@ pub fn ndarray_to_luma8(array: ArrayView2<u8>) -> ImageBuffer<Luma<u8>, Vec<u8>>
// 5. 核心高性能图像转换算法 (纯 Rust 编写)
// =====================================================================
/// RGB 像素转换为 OpenCV 风格的 HSV 值。
#[inline(always)]
pub fn rgb_to_opencv_hsv(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
// 1. 规避高昂的除法,直接转为 f32 进行比对
@@ -174,15 +172,13 @@ pub fn rgb_to_opencv_hsv(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
(h_opencv, s_opencv, v_opencv)
}
/// 对应 Python 的 convert_to_grayscale
/// 将图像转换为灰度图 (L模式)
/// 将图像转换为灰度图L 模式)。
pub fn convert_to_grayscale(image: &DynamicImage) -> GrayImage {
// Rust utils 库的 to_luma8 会根据标准的亮度公式进行转换
image.to_luma8()
}
/// 对应 Python 的 resize_image
/// 调整图像尺寸。当前版本仅实现 keep_aspect_ratio=false
/// 按指定宽高调整图像尺寸。
pub fn resize_image(
image: &DynamicImage,
target_width: u32,
@@ -194,3 +190,43 @@ pub fn resize_image(
image.resize_exact(target_width, target_height, FilterType::Lanczos3)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn abs_diff_absolutes() {
let a = Array3::from_shape_vec((1, 1, 3), vec![10, 200, 5]).unwrap();
let b = Array3::from_shape_vec((1, 1, 3), vec![200, 100, 5]).unwrap();
let d = abs_diff(&a.view(), &b.view());
assert_eq!(d[[0, 0, 0]], 190);
assert_eq!(d[[0, 0, 1]], 100);
assert_eq!(d[[0, 0, 2]], 0);
}
#[test]
fn rgb_to_gray_white_is_255() {
let rgb = Array3::from_shape_vec((1, 1, 3), vec![255, 255, 255]).unwrap();
assert_eq!(rgb_to_gray(rgb.view())[[0, 0]], 255);
}
#[test]
fn min_max_loc_finds_max() {
let buf = ImageBuffer::<Luma<f32>, Vec<f32>>::from_fn(3, 2, |x, y| {
Luma([if x == 2 && y == 1 { 0.9 } else { 0.1 }])
});
let (val, loc) = min_max_loc(&buf);
assert_eq!(val, 0.9);
assert_eq!(loc, (2, 1));
}
#[test]
fn calculate_center_midpoint() {
assert_eq!(calculate_center((10, 20), 6, 4), (13, 22));
}
#[test]
fn rgb_to_opencv_hsv_red() {
assert_eq!(rgb_to_opencv_hsv(255, 0, 0), (0, 255, 255));
}
}

View File

@@ -1,8 +1,14 @@
//! 张量变换:将异构形状的模型输出规整为统一格式。
use crate::OcrOutput;
use crate::error::{Result, TensorError};
use ndarray::s;
/// 核心层复用资产:将异构的动态维度矩阵转化为标准 OCR 2D Logits 矩阵
pub fn normalize_ocr_logits(array: ndarray::ArrayViewD<f32>, shape: &[usize]) -> Result<OcrOutput,TensorError> {
/// 将异构形状的模型输出规整为标准 `[Steps, Classes]` Logits 矩阵。
pub fn normalize_ocr_logits(
array: ndarray::ArrayViewD<f32>,
shape: &[usize],
) -> Result<OcrOutput, TensorError> {
let (steps, classes, data_dyn_view) = match shape.len() {
3 => {
if shape[1] == 1 {
@@ -24,12 +30,10 @@ pub fn normalize_ocr_logits(array: ndarray::ArrayViewD<f32>, shape: &[usize]) ->
// 我们把它虚构成一个 [1, Classes] 的 2D 矩阵来复用后面的 argmax 逻辑
1 => (1, shape[0], array),
_ => {
return Err(
TensorError::DimensionMismatch {
expected: "1D, 2D, or 3D OCR Logits".to_string(),
actual: shape.to_vec(),
},
);
return Err(TensorError::DimensionMismatch {
expected: "1D, 2D, or 3D OCR Logits".to_string(),
actual: shape.to_vec(),
});
}
};
@@ -49,3 +53,32 @@ pub fn normalize_ocr_logits(array: ndarray::ArrayViewD<f32>, shape: &[usize]) ->
Ok(OcrOutput::Logits(matrix_cow))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_2d_logits() {
let array = ndarray::Array2::<f32>::zeros((2, 3));
match normalize_ocr_logits(array.view().into_dyn(), &[2, 3]).unwrap() {
crate::OcrOutput::Logits(m) => assert_eq!(m.dim(), (2, 3)),
_ => panic!("应为 Logits 输出"),
}
}
#[test]
fn normalize_3d_batch_first() {
let array = ndarray::Array3::<f32>::zeros((1, 2, 3));
match normalize_ocr_logits(array.view().into_dyn(), &[1, 2, 3]).unwrap() {
crate::OcrOutput::Logits(m) => assert_eq!(m.dim(), (2, 3)),
_ => panic!("应为 Logits 输出"),
}
}
#[test]
fn unsupported_dim_errors() {
let array = ndarray::Array4::<f32>::zeros((1, 1, 1, 1));
assert!(normalize_ocr_logits(array.view().into_dyn(), &[1, 1, 1, 1]).is_err());
}
}

View File

@@ -0,0 +1,72 @@
//! 外部视角 API 测试:验证颜色过滤与字符集限制扩展点对外可用。
use std::borrow::Cow;
use ddddocr_core::color_any_of;
use ddddocr_core::{
CharRestrict, ColorFilter, ColorPreset, HsvRange, IdRestrict, OcrBuilder, TokenFilter, any_of,
};
#[test]
fn color_filter_setter_accepts_owned_preset() {
let builder = OcrBuilder::new().color_filter(ColorPreset::Red);
let _ = builder;
}
#[test]
fn color_any_of_macro_expands_and_collects() {
let ranges = color_any_of!(ColorPreset::Red, ColorPreset::Blue)
.collect_to_vec()
.expect("收集颜色区间失败")
.expect("应存在有效颜色区间");
// Red 两段 + Blue 一段
assert_eq!(ranges.len(), 3);
}
#[test]
fn charset_restrict_setter_accepts_owned_restrict() {
let builder = OcrBuilder::new().charset_restrict(CharRestrict::Digit);
let _ = builder;
}
#[test]
fn any_of_macro_expands_and_filters() {
let tokens: Vec<Cow<'static, str>> = vec![
Cow::Borrowed(""),
Cow::Borrowed("1"),
Cow::Borrowed("a"),
Cow::Borrowed("A"),
];
let indices = any_of!(CharRestrict::Digit, CharRestrict::Lowercase)
.apply_to_charset(&tokens)
.expect("字符集应存在交集");
// 0 号 blank 放行 + 数字 '1' + 小写 'a'
assert_eq!(indices, vec![0, 1, 2]);
}
#[test]
fn id_restrict_filters_by_index() {
let tokens: Vec<Cow<'static, str>> = vec![
Cow::Borrowed(""),
Cow::Borrowed("1"),
Cow::Borrowed("2"),
Cow::Borrowed("3"),
];
let indices = IdRestrict::TopN(2)
.apply_to_charset(&tokens)
.expect("字符集应存在交集");
assert_eq!(indices, vec![0, 1]);
}
#[test]
fn hsv_range_validate() {
let range = HsvRange::new((0, 50, 50), (10, 255, 255));
assert!(range.validate().is_ok());
assert!(
HsvRange::new((200, 0, 0), (255, 255, 255))
.validate()
.is_err()
);
}

View File

@@ -1,23 +1,25 @@
[package]
name = "ddddocr-ort"
version.workspace = true
edition.workspace = true
license.workspace = true
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "ddddocr-rs 的 ONNX Runtime 推理引擎实现"
keywords = ["ocr", "captcha", "ddddocr", "onnxruntime", "ort"]
categories = ["multimedia::images", "computer-vision"]
# repository = "https://github.com/<用户名>/<仓库名>" # 发布前请补充
readme = "../README.md"
[dependencies]
ddddocr-core = { path = "../ddddocr-core" } # 引入兄弟库
ort = { workspace = true ,features = ["cuda"]}
ndarray = { workspace = true } # 继承自工作空间
ddddocr-core = { path = "../ddddocr-core", version = "0.2.4" }
ort = { workspace = true }
ndarray = { workspace = true }
image = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true } # 刚好可以开始接入你需要的标准库错误处理
thiserror = { workspace = true }
tracing = { workspace = true }
[features]
# 1. 声明 cuda feature
# 2. 当开启 ddddocr-ort 的 cuda feature 时,自动开启底层 ort 库的 cuda 支持(如果 ort 库支持的话)
cuda = ["ort/cuda"]
# 开启后启用底层 ort 库的 CUDA 执行提供者支持。
cuda = ["ort/cuda"]

View File

@@ -1 +1 @@
pub mod session;
pub mod session;

View File

@@ -1,64 +1,40 @@
use crate::runtime::{extract_tensor, lock_session, run_session};
use crate::types::Session;
use ddddocr_core::DetOutput;
use ddddocr_core::error::{Result, TensorError};
use ddddocr_core::traits::{DetEngine, InferenceEngine};
use ndarray::Ix3;
use ort::inputs;
use ort::value::TensorRef;
// use tract_onnx::prelude::{tvec, IntoTensor, Tensor};
use tracing::debug;
/// 目标检测推理运行时:持有 ORT 会话,输出 [`DetOutput`]。
#[derive(Debug)]
pub struct DetRuntime {
/// ORT 会话句柄。
pub session: Session,
}
impl DetRuntime {
/// 基于已构建的会话创建检测运行时。
pub fn new(session: Session) -> Self {
Self { session }
}
}
impl InferenceEngine for DetRuntime {
type Output = DetOutput; // 明确绑定 OCR 小枚举
type Output = DetOutput;
fn inference(&self, input_array: ndarray::Array4<f32>) -> Result<Self::Output, TensorError> {
// tract 的 run 会返回一个 Vec<TValue>,我们通常只需要第一个输出
// let result = self.ocr.run(tvec!(tensor.into()))?;
let mut session_guard = self
.session
.lock()
.map_err(|_| TensorError::Engine("获取 Session 锁失败 (Poisoned)".to_string()))?;
let result = session_guard
.run(inputs![TensorRef::from_array_view(&input_array).map_err(
|e| TensorError::Engine(format!("构建输入失败: {e}"))
)?])
.map_err(|e| TensorError::Engine(format!("执行模型推理失败: {e}")))?;
// .context("执行模型推理失败")?;
println!("模型输出原始数据: {:?}", result);
// Ok(result.swap_remove(0).into_tensor())
let raw_value = &result[0];
// raw_tensor.into_plain_array()?
let (shape_ref, slice) = raw_value.try_extract_tensor::<f32>().map_err(|_| {
TensorError::Engine("Tract 实体张量无法转换为 ndarray::ArrayD".to_string())
})?;
// 提前利用克隆(Clone)备份好当前未转维度前的真实 shape (Vec<usize>)
let shape_vec: Vec<usize> = shape_ref.to_vec().iter().map(|v| *v as usize).collect();
let shape_vec_slice = shape_vec.as_slice();
let view = ndarray::ArrayViewD::from_shape(shape_vec_slice, slice)
.map_err(|_| TensorError::Engine("构建 ndarray ArrayViewD 失败".to_string()))?;
let mut session_guard = lock_session(&self.session)?;
let result = run_session(&mut session_guard, &input_array)?;
debug!("模型输出原始数据: {:?}", result);
let (view, shape_vec) = extract_tensor::<f32>(&result[0])?;
let array3 = view.to_owned().into_dimensionality::<Ix3>().map_err(|_| {
TensorError::DimensionMismatch {
expected: "3D 检测矩阵 [Batch, Box_Count, Box_Attributes]".to_string(),
actual: shape_vec, // 优雅降维失败时动态捕获
actual: shape_vec,
}
})?;
Ok(DetOutput::Detection(array3))
// 在引擎内部消化掉 DatumType 强耦合
}
}

View File

@@ -1,9 +1,20 @@
//! # ddddocr-ort
//!
//! 基于 ONNX RuntimeORT的推理引擎实现通过 [`loader::ModelLoader`] 构建会话,
//! 再由 [`OcrRuntime`] / [`DetRuntime`] 实现 core 的
//! [`ddddocr_core::traits::InferenceEngine`] 接口。
//! 同时重导出 core 的 [`OcrBuilder`]、[`Slider`]、[`SlideResult`] 等便捷 API。
#![warn(missing_docs)]
mod det;
/// 模型加载器:从路径或字节流构建 ORT 会话。
pub mod loader;
mod ocr;
mod runtime;
mod types;
pub use ddddocr_core::{SlideResult, Slider,OcrBuilder};
pub use ddddocr_core::{OcrBuilder, SlideResult, Slider};
pub use det::session::DetRuntime;
pub use ocr::session::OcrRuntime;
pub use ocr::session::OcrRuntime;
pub use types::Session;

View File

@@ -3,5 +3,5 @@ mod metadata;
mod model;
pub use error::{Error, ParseError, Result};
pub use metadata::{ModelMetadataDto, NormalizationDto, Metadata};
pub use model::ModelLoader;
pub use metadata::{Metadata, ModelMetadataDto, NormalizationDto};
pub use model::ModelLoader;

View File

@@ -1,7 +1,10 @@
use ort::Error as OrtError;
/// 模型加载与解析的通用结果类型。
pub type Result<T> = std::result::Result<T, Error>;
#[derive(thiserror::Error, Debug)]
/// 模型加载、解析与 Session 构建阶段的错误。
pub enum Error {
/// 底层构建器配置失败。
#[error("builder构建失败")]
Build(#[from] BuildError),
/// 解析 ONNX 模型/路径失败(如文件损坏、算子不支持、路径非法)
@@ -9,11 +12,11 @@ pub enum Error {
ModelParse(#[from] ParseError),
/// 模型计算图优化失败(如常量折叠、形状推导失败)
#[error("优化 Tract 模型图失败: {0}")]
#[error("优化 ORT 模型图失败: {0}")]
OptimizationFailed(#[source] OrtError),
/// 构建可执行 Session 失败(如输入输出 Tensor 类型/形状未确定)
#[error("构建可运行 Tract 实例失败: {0}")]
#[error("构建可运行的 ORT Session 失败: {0}")]
RunnableBuildFailed(#[source] OrtError),
/// JSON 反序列化失败(自动透传 serde_json 报错)
@@ -24,6 +27,7 @@ pub enum Error {
#[error("Metadata 字节流不是合法的 UTF-8 编码: {0}")]
InvalidUtf8(#[from] std::str::Utf8Error),
/// 模型元数据内容解析失败。
#[error("模型元数据解析失败: {0}")]
MetadataParse(String),
@@ -32,8 +36,6 @@ pub enum Error {
Other(String, #[source] Box<dyn std::error::Error + Send + Sync>),
}
impl Error {
/// 方便将任何第三方 Error 包装为 Error::Other
pub fn new<E>(msg: impl Into<String>, err: E) -> Self
@@ -44,6 +46,7 @@ impl Error {
}
}
#[derive(thiserror::Error, Debug)]
/// 从路径或字节流解析 ONNX 模型失败的错误。
pub enum ParseError {
/// 策略 A从文件路径加载失败附带路径上下文信息方便排查是找不到文件还是格式不对
#[error("从路径 '{0}' 加载 ONNX 模型失败: {1}")]
@@ -54,13 +57,45 @@ pub enum ParseError {
Bytes(#[source] OrtError),
}
#[derive(thiserror::Error, Debug)]
pub enum BuildError{
#[error("builder构建失败")]
pub enum BuildError {
/// 底层 SessionBuilder 构建失败
#[error("构建 ORT SessionBuilder 失败: {0}")]
BuildFailed(#[from] OrtError),
#[error("builder构建失败")]
/// 线程数配置失败。
#[error("{0}")]
Threads(String),
#[error("builder构建失败")]
/// 启用 CUDA 执行提供者失败。
#[error("{0}")]
EnabledCudaFailed(String),
#[error("builder构建失败")]
NotEnabledCuda(String)
}
/// 未编译 CUDA 支持时尝试启用 GPU。
#[error("{0}")]
NotEnabledCuda(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wraps_external_error_via_new() {
let io_err = std::io::Error::other("boom");
let err = Error::new("自定义错误", io_err);
assert!(matches!(err, Error::Other(_, _)));
assert!(err.to_string().contains("自定义错误"));
}
#[test]
fn serde_json_error_converts_to_error() {
let json_err = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
let err: Error = json_err.into();
assert!(matches!(err, Error::JsonParse(_)));
}
#[test]
fn ut8_error_converts_to_error() {
let bytes = vec![0xffu8];
let utf8_err = std::str::from_utf8(&bytes).unwrap_err();
let err: Error = utf8_err.into();
assert!(matches!(err, Error::InvalidUtf8(_)));
}
}

View File

@@ -1,13 +1,12 @@
use crate::loader::error::{Error, Result};
use ddddocr_core::ModelMetadata;
use ddddocr_core::Resize;
use ddddocr_core::{Charset, Normalization};
use ddddocr_core::{Charset, ModelMetadata, Normalization, Resize};
use serde::Deserialize;
use std::borrow::Cow;
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")] // 支持 json 中写 "zero_to_one" 或 "minus_one_to_one"
/// 归一化策略的 JSON 反序列化中间表示。
pub enum NormalizationDto {
/// 映射到 [0.0, 1.0] -> pixel / 255.0
ZeroToOne,
@@ -41,8 +40,9 @@ fn default_normalization() -> NormalizationDto {
NormalizationDto::ZeroToOne
}
/// Tract 专属扩展trait 或 工具函数
/// 从 JSON 字符串或字节流解析模型元数据的扩展接口。
pub trait Metadata: Sized {
/// 从 JSON 字符串解析模型元数据。
fn from_json_str(json_str: &str) -> Result<Self>;
/// 机制 2从内存字节流加载极大地方便 include_bytes! 或网络下载)
fn from_json_bytes(bytes: &[u8]) -> Result<Self> {
@@ -56,8 +56,7 @@ impl Metadata for ModelMetadata {
let dto: ModelMetadataDto = serde_json::from_str(json_str)?;
// 1. 将 DTO 的字符串数组转化为强类型的 Charset
let tokens: Vec<Cow<'static, str>> =
dto.charset.into_iter().map(|s| Cow::Owned(s)).collect();
let tokens: Vec<Cow<'static, str>> = dto.charset.into_iter().map(Cow::Owned).collect();
let charset = Charset::new(tokens);
// 2. 解析 resize 策略(重现 Python 的复杂条件判断)
@@ -91,3 +90,104 @@ impl Metadata for ModelMetadata {
))
}
}
#[cfg(test)]
mod tests {
use super::*;
const MINIMAL_JSON: &str = r#"{
"charset": ["a", "b", "c"],
"word": false,
"resize": [-1, 64],
"channel": 1
}"#;
#[test]
fn parses_minimal_json_with_default_normalization() {
let meta = ModelMetadata::from_json_str(MINIMAL_JSON).unwrap();
assert_eq!(meta.charset.size(), 3);
assert!(!meta.word);
assert_eq!(meta.channel, 1);
assert!(matches!(meta.resize, Resize::DynamicWidth(64)));
assert!(matches!(meta.normalization, Normalization::ZeroToOne));
}
#[test]
fn parses_minus_one_to_one_normalization() {
let json = r#"{
"charset": ["a"],
"word": false,
"resize": [-1, 64],
"channel": 1,
"normalization": "minus_one_to_one"
}"#;
let meta = ModelMetadata::from_json_str(json).unwrap();
assert!(matches!(meta.normalization, Normalization::MinusOneToOne));
}
#[test]
fn parses_word_model_as_square_resize() {
let json = r#"{
"charset": ["a"],
"word": true,
"resize": [-1, 64],
"channel": 1
}"#;
let meta = ModelMetadata::from_json_str(json).unwrap();
assert!(matches!(meta.resize, Resize::Square(64)));
}
#[test]
fn parses_fixed_resize() {
let json = r#"{
"charset": ["a"],
"word": false,
"resize": [100, 64],
"channel": 1
}"#;
let meta = ModelMetadata::from_json_str(json).unwrap();
assert!(matches!(meta.resize, Resize::Fixed(100, 64)));
}
#[test]
fn parses_image_alias() {
let json = r#"{
"charset": ["a"],
"word": false,
"image": [200, 64],
"channel": 1
}"#;
let meta = ModelMetadata::from_json_str(json).unwrap();
assert!(matches!(meta.resize, Resize::Fixed(200, 64)));
}
#[test]
fn rejects_resize_with_wrong_length() {
let json = r#"{
"charset": ["a"],
"word": false,
"resize": [1, 2, 3],
"channel": 1
}"#;
assert!(matches!(
ModelMetadata::from_json_str(json),
Err(Error::MetadataParse(_))
));
}
#[test]
fn rejects_invalid_json() {
assert!(matches!(
ModelMetadata::from_json_str("not json"),
Err(Error::JsonParse(_))
));
}
#[test]
fn rejects_invalid_utf8_bytes() {
assert!(matches!(
ModelMetadata::from_json_bytes(&[0xff, 0xfe]),
Err(Error::InvalidUtf8(_))
));
}
}

View File

@@ -6,30 +6,13 @@ use ort::session::Session as OrtSession;
use ort::session::builder::SessionBuilder;
use std::sync::{Arc, Mutex};
// pub struct OrtModelLoader;
//
// impl OrtModelLoader {
// /// 获取针对 ORT 后端的链式构建器
// pub fn builder() -> OrtModelBuilder {
// OrtModelBuilder::default()
// }
// }
/// ORT 专用的链式构建器
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct ModelLoader {
use_gpu: bool,
device_id: i32,
intra_threads: Option<usize>,
}
impl Default for ModelLoader {
fn default() -> Self {
Self {
use_gpu: false,
device_id: 0,
intra_threads: None,
}
}
}
impl ModelLoader {
/// 开启或关闭 GPU 加速
pub fn use_gpu(mut self, enable: bool) -> Self {
@@ -42,6 +25,7 @@ impl ModelLoader {
self.device_id = id;
self
}
/// 指定 ORT 内部线程数。
pub fn num_threads(mut self, threads: usize) -> Self {
self.intra_threads = Some(threads);
self
@@ -49,7 +33,7 @@ impl ModelLoader {
/// 内部辅助方法:根据当前的配置构建 ORT 底层的 SessionBuilder
fn create_session_builder(&self) -> Result<SessionBuilder> {
let mut builder = OrtSession::builder().map_err(|e| BuildError::BuildFailed(e))?;
let mut builder = OrtSession::builder().map_err(BuildError::BuildFailed)?;
// 如果用户显式设置了线程数,则配置给 ORT
if let Some(threads) = self.intra_threads {
builder = builder
@@ -99,7 +83,7 @@ impl Loader for ModelLoader {
.commit_from_file(path_ref)
.map_err(|e| ParseError::Path(path_ref.display().to_string(), e))?;
Ok(Arc::new(Mutex::new(session))) // 这里的session需要包装下
Ok(Arc::new(Mutex::new(session)))
}
/// 策略 B从内存字节流加载模型配合 include_bytes! 使用)
fn build_from_bytes(&self, model_bytes: &[u8]) -> Result<Session> {
@@ -112,3 +96,36 @@ impl Loader for ModelLoader {
Ok(Arc::new(Mutex::new(session)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config() {
let loader = ModelLoader::default();
assert!(!loader.use_gpu);
assert_eq!(loader.device_id, 0);
assert!(loader.intra_threads.is_none());
}
#[test]
fn builder_configures_fields() {
let loader = ModelLoader::default()
.use_gpu(true)
.device_id(2)
.num_threads(4);
assert!(loader.use_gpu);
assert_eq!(loader.device_id, 2);
assert_eq!(loader.intra_threads, Some(4));
}
#[test]
fn builder_is_chainable_and_immutable() {
let base = ModelLoader::default();
let _configured = base.clone().use_gpu(true).device_id(1).num_threads(8);
assert!(!base.use_gpu);
assert_eq!(base.device_id, 0);
assert!(base.intra_threads.is_none());
}
}

View File

@@ -1,107 +1,87 @@
use crate::runtime::{extract_tensor, lock_session, run_session};
use crate::types::Session;
use ddddocr_core::ModelMetadata;
use ddddocr_core::OcrOutput;
use ddddocr_core::error::{DdddError, Result, TensorError};
use ddddocr_core::error::{Result, TensorError};
use ddddocr_core::traits::{InferenceEngine, Info, OcrEngine};
use ddddocr_core::types::{AxisDim, ModelInfo, TensorInfo, TensorType};
use ddddocr_core::utils::normalize_ocr_logits;
use ort::inputs;
use ort::value::{TensorElementType, TensorRef};
use std::sync::Mutex;
// 引入核心层的统一错误类型
/// 明确命名为 AxisDim代表模型某一个轴的维度特征
// #[derive(Clone, PartialEq, Eq)]
// pub enum AxisDim {
// /// 静态固定维度(如通道数固定为 1高度固定为 64
// Static(usize),
// /// 动态符号维度(如宽度是动态的 "image_width"
// Dynamic(String),
// }
use ort::value::{Outlet, TensorElementType, ValueType};
use tracing::debug;
// impl AxisDim {
// /// 便捷方法:判断是否为动态维度
// pub fn is_dynamic(&self) -> bool {
// matches!(self, AxisDim::Dynamic(_))
// }
// }
// /// 自定义 Debug 格式化输出,彻底融化套娃外壳,保证日志干净漂亮
// impl std::fmt::Debug for AxisDim {
// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// match self {
// AxisDim::Static(size) => write!(f, "{}", size),
// AxisDim::Dynamic(expr) => write!(f, "Dynamic(\"{}\")", expr),
// }
// }
// }
/// 模拟 Python 的 input_info 和 output_info 结构
// #[derive(Debug, Clone)]
// pub struct TensorInfo {
// pub name: String,
// pub shape: Vec<AxisDim>, // 既包含 Fixed 静态维度,也包含 Dynamic 动态符号
// pub data_type: TensorElementType, // 对应 Python 的 type
// }
//
// /// 最终返回的模型完整信息
// #[derive(Debug, Clone)]
// pub struct ModelInfo {
// pub inputs: Vec<TensorInfo>,
// pub outputs: Vec<TensorInfo>,
// /// 硬件执行提供者(采用 Option 兼容不同底层的推理引擎)
// pub providers: Option<Vec<String>>,
// }
/// OCR 推理运行时:持有 ORT 会话与模型元数据,输出 [`OcrOutput`]。
pub struct OcrRuntime {
/// ORT 会话句柄。
pub session: Session,
/// 模型元数据(字符集、归一化策略等)。
pub metadata: ModelMetadata,
}
impl OcrRuntime {
/// 基于已构建的会话与元数据创建 OCR 运行时。
pub fn new(session: Session, metadata: ModelMetadata) -> Self {
Self { session, metadata }
}
/// 将 ORT 输入/输出出口解析为 core 的 [`TensorInfo`] 列表。
fn resolve_outlets(&self, outlets: &[Outlet]) -> Result<Vec<TensorInfo>> {
Ok(outlets
.iter()
.map(|outlet| TensorInfo {
name: outlet.name().to_string(),
shape: resolve_shape(outlet.dtype()),
tensor_type: tensor_type_from_ort(outlet.dtype()),
})
.collect())
}
}
/// 将 ORT 值类型映射为 core 的 [`TensorType`]。
fn tensor_type_from_ort(dtype: &ValueType) -> TensorType {
match dtype.tensor_type() {
Some(TensorElementType::Float32) => TensorType::F32,
Some(TensorElementType::Int64) => TensorType::I64,
_ => TensorType::Other,
}
}
/// 将 ORT 张量形状解析为 [`AxisDim`] 列表,动态维度(`-1`)标记为符号维度。
fn resolve_shape(dtype: &ValueType) -> Vec<AxisDim> {
dtype
.tensor_shape()
.map(|shape| {
shape
.iter()
.map(|&dim| {
if dim >= 0 {
AxisDim::Static(dim as usize)
} else {
AxisDim::Dynamic("dynamic".to_string())
}
})
.collect()
})
.unwrap_or_default()
}
impl OcrEngine for OcrRuntime {
fn metadata(&self) -> &ModelMetadata {
&self.metadata
}
}
impl InferenceEngine for OcrRuntime {
type Output = OcrOutput;
/// 对应 Python 的 _inference
fn inference(&self, input_array: ndarray::Array4<f32>) -> Result<Self::Output, TensorError> {
// tract 的 run 会返回一个 Vec<TValue>,我们通常只需要第一个输出
// let result = self.ocr.run(tvec!(tensor.into()))?;
// let tensor = Tensor::from(input_array);
let mut session_guard = self
.session
.lock()
.map_err(|_| TensorError::Engine("获取 Session 锁失败 (Poisoned)".to_string()))?;
let result = session_guard
.run(inputs![TensorRef::from_array_view(&input_array).map_err(
|e| TensorError::Engine(format!("构建输入失败: {e}"))
)?])
.map_err(|e| TensorError::Engine(format!("执行模型推理失败: {e}")))?;
// .context("执行模型推理失败")?;
println!("模型输出原始数据: {:?}", result);
// Ok(result.swap_remove(0).into_tensor())
fn inference(&self, input_array: ndarray::Array4<f32>) -> Result<Self::Output, TensorError> {
let mut session_guard = lock_session(&self.session)?;
let result = run_session(&mut session_guard, &input_array)?;
debug!("模型输出原始数据: {:?}", result);
let raw_value = &result[0];
match raw_value.dtype().tensor_type().unwrap() {
TensorElementType::Int64 => {
let (array_d, slice) = raw_value
.try_extract_tensor::<i64>()
.map_err(|_| TensorError::Engine("Tract 无法获取 i64 内存视图".to_string()))?;
// .context("Tract 无法获取 i64 内存视图")?;
// 提前提取真实维度
let actual_shape = array_d
.to_vec()
.iter()
.map(|v| *v as usize)
.collect::<Vec<usize>>();
let view = ndarray::ArrayViewD::from_shape(actual_shape.as_slice(), slice)
.map_err(|_| TensorError::Engine("构建 ndarray ArrayViewD 失败".to_string()))?;
// 转成标准的 Array1 传给 core
match raw_value.dtype().tensor_type() {
Some(TensorElementType::Int64) => {
let (view, actual_shape) = extract_tensor::<i64>(raw_value)?;
let array1 = view
.to_owned()
.into_dimensionality::<ndarray::Ix1>()
@@ -111,40 +91,98 @@ impl InferenceEngine for OcrRuntime {
})?;
Ok(OcrOutput::Indices(array1))
}
TensorElementType::Float32 => {
Some(TensorElementType::Float32) => {
let shape = raw_value.shape();
println!("模型输出shape数据: {:?}", shape);
// raw_tensor.to_plain_array_view()
let (shape_ref, slice) = raw_value
.try_extract_tensor::<f32>()
.map_err(|_| TensorError::Engine("Tract 无法获取 f32 内存视图".to_string()))?;
// 1. 极其纯粹的、无拷贝的多维 Shape 压扁清洗
let shape_vec: Vec<usize> =
shape_ref.to_vec().iter().map(|v| *v as usize).collect();
let shape_vec_slice = shape_vec.as_slice();
let view = ndarray::ArrayViewD::from_shape(shape_vec_slice, slice)
.map_err(|_| TensorError::Engine("构建 ndarray ArrayViewD 失败".to_string()))?;
normalize_ocr_logits(view, shape_vec_slice)
debug!("模型输出 shape 数据: {:?}", shape);
let (view, shape_vec) = extract_tensor::<f32>(raw_value)?;
normalize_ocr_logits(view, shape_vec.as_slice())
}
_ => Err(
// anyhow::anyhow!("不支持的模型输出数据类型: {:?}",raw_tensor.datum_type())
TensorError::UnknownOutputFormat,
),
_ => Err(TensorError::UnknownOutputFormat),
}
}
}
impl Info for OcrRuntime {
fn input_info(&self) -> Result<Vec<TensorInfo>> {
todo!()
let session_guard = lock_session(&self.session)?;
self.resolve_outlets(session_guard.inputs())
}
fn output_info(&self) -> Result<Vec<TensorInfo>> {
todo!()
let session_guard = lock_session(&self.session)?;
self.resolve_outlets(session_guard.outputs())
}
fn model_info(&self) -> Result<ModelInfo> {
todo!()
Ok(ModelInfo {
inputs: self.input_info()?,
outputs: self.output_info()?,
providers: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use ort::value::{Shape, SymbolicDimensions, TensorElementType};
fn tensor_dtype(ty: TensorElementType) -> ValueType {
ValueType::Tensor {
ty,
shape: Shape::new([1, 64]),
dimension_symbols: SymbolicDimensions::new([String::new(), String::new()]),
}
}
#[test]
fn maps_tensor_element_types() {
assert!(matches!(
tensor_type_from_ort(&tensor_dtype(TensorElementType::Float32)),
TensorType::F32
));
assert!(matches!(
tensor_type_from_ort(&tensor_dtype(TensorElementType::Int64)),
TensorType::I64
));
assert!(matches!(
tensor_type_from_ort(&tensor_dtype(TensorElementType::Uint8)),
TensorType::Other
));
}
#[test]
fn resolves_static_shape() {
let shape = resolve_shape(&tensor_dtype(TensorElementType::Float32));
assert_eq!(shape, vec![AxisDim::Static(1), AxisDim::Static(64)]);
}
#[test]
fn resolves_dynamic_dimension_as_symbol() {
let dtype = ValueType::Tensor {
ty: TensorElementType::Float32,
shape: Shape::new([1, 64, -1]),
dimension_symbols: SymbolicDimensions::new([
String::new(),
String::new(),
String::from("width"),
]),
};
let shape = resolve_shape(&dtype);
assert_eq!(
shape,
vec![
AxisDim::Static(1),
AxisDim::Static(64),
AxisDim::Dynamic("dynamic".to_string())
]
);
}
#[test]
fn non_tensor_dtype_yields_empty_shape() {
let dtype = ValueType::Sequence(Box::new(tensor_dtype(TensorElementType::Float32)));
assert!(resolve_shape(&dtype).is_empty());
assert!(matches!(tensor_type_from_ort(&dtype), TensorType::Other));
}
}

View File

@@ -0,0 +1,41 @@
//! 会话执行相关的共享工具函数。
use crate::types::Session;
use ddddocr_core::error::TensorError;
use ort::inputs;
use ort::session::Session as OrtSession;
use ort::session::SessionOutputs;
use ort::value::{PrimitiveTensorElementType, TensorRef, Value};
use std::sync::MutexGuard;
/// 获取会话互斥锁,失败时转换为引擎错误。
pub(crate) fn lock_session(session: &Session) -> Result<MutexGuard<'_, OrtSession>, TensorError> {
session
.lock()
.map_err(|_| TensorError::Engine("获取 Session 锁失败 (Poisoned)".to_string()))
}
/// 对输入张量执行一次 ORT 推理,返回原始输出值列表。
pub(crate) fn run_session<'a>(
session: &'a mut OrtSession,
input_array: &ndarray::Array4<f32>,
) -> Result<SessionOutputs<'a>, TensorError> {
session
.run(inputs![TensorRef::from_array_view(input_array).map_err(
|e| { TensorError::Engine(format!("构建输入失败: {e}")) }
)?])
.map_err(|e| TensorError::Engine(format!("执行模型推理失败: {e}")))
}
/// 从输出值中提取类型化张量视图,同时返回其真实维度。
pub(crate) fn extract_tensor<T: PrimitiveTensorElementType>(
value: &Value,
) -> Result<(ndarray::ArrayViewD<'_, T>, Vec<usize>), TensorError> {
let (shape_ref, slice) = value
.try_extract_tensor::<T>()
.map_err(|_| TensorError::Engine("无法获取张量内存视图".to_string()))?;
let shape: Vec<usize> = shape_ref.iter().map(|v| *v as usize).collect();
let view = ndarray::ArrayViewD::from_shape(shape.as_slice(), slice)
.map_err(|_| TensorError::Engine("构建 ndarray ArrayViewD 失败".to_string()))?;
Ok((view, shape))
}

View File

@@ -1,4 +1,7 @@
//! ORT 会话共享类型。
use ort::session::Session as OrtSession;
use std::sync::{Arc, Mutex};
/// ORT 会话句柄:由 [`crate::loader::ModelLoader`] 构建,推理时通过互斥锁串行访问。
pub type Session = Arc<Mutex<OrtSession>>;

View File

@@ -0,0 +1,76 @@
//! 外部视角 API 测试:验证 `ddddocr-ort` 的公开类型与方法在外部 crate 中可正常使用。
use ddddocr_core::traits::{Info, Loader};
use ddddocr_core::types::AxisDim;
use ddddocr_core::{ModelMetadata, Normalization, OcrBuilder, Resize, SlideResult, Slider};
use ddddocr_ort::loader::ModelLoader;
use ddddocr_ort::{DetRuntime, OcrRuntime, Session};
use std::path::{Path, PathBuf};
fn model_path(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("models")
.join(name)
}
/// 验证对外导出的类型(含 `Session` 与 core 便捷重导出)均可直接命名。
#[test]
fn public_types_are_nameable() {
let _: Option<Session> = None;
let _: Option<OcrBuilder> = None;
let _: Option<SlideResult> = None;
let _slider = Slider::new();
}
/// 验证构建器链式 API 可组合使用,且返回的会话类型可显式标注。
#[test]
fn loader_chain_builds_session() -> anyhow::Result<()> {
let path = model_path("common_sml2h3_f32.onnx");
assert!(path.exists(), "缺少测试模型: {}", path.display());
let _session: Session = ModelLoader::default()
.use_gpu(false)
.device_id(0)
.num_threads(4)
.build_for_path(path)?;
Ok(())
}
/// 验证 `Info` trait 能从真实会话中解析输入/输出信息。
#[test]
fn info_trait_returns_model_metadata() -> anyhow::Result<()> {
let path = model_path("common_sml2h3_f32.onnx");
assert!(path.exists(), "缺少测试模型: {}", path.display());
let session: Session = ModelLoader::default().build_for_path(path)?;
let metadata = ModelMetadata::from_static_slice(
&["a", "b"],
false,
Resize::DynamicWidth(64),
1,
Normalization::MinusOneToOne,
);
let ocr = OcrRuntime::new(session, metadata);
let inputs = ocr.input_info()?;
let outputs = ocr.output_info()?;
assert!(!inputs.is_empty());
assert!(!outputs.is_empty());
assert!(matches!(inputs[0].shape[0], AxisDim::Static(1)));
assert!(matches!(inputs[0].shape[2], AxisDim::Static(64)));
let model_info = ocr.model_info()?;
assert_eq!(model_info.inputs.len(), inputs.len());
assert_eq!(model_info.outputs.len(), outputs.len());
assert!(model_info.providers.is_none());
Ok(())
}
/// 验证检测运行时可以从构建的会话创建。
#[test]
fn det_runtime_builds_from_session() -> anyhow::Result<()> {
let path = model_path("common_det.onnx");
assert!(path.exists(), "缺少测试模型: {}", path.display());
let session: Session = ModelLoader::default().build_for_path(path)?;
let _det = DetRuntime::new(session);
Ok(())
}

View File

@@ -1,600 +0,0 @@
use std::borrow::Cow;
use std::fs::File;
use std::path::Path;
use anyhow::anyhow;
use ddddocr_core::Charset;
use ddddocr_core::{Normalization, Resize};
pub const CHARSET_BETA: &[&str] = &[
"", "", "", "", "", "", "", "", "", "", "", "", "", "6", "", "",
"", "", "", "", "", "", "", "", "", "", "", "鴿", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "f", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "²", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "!", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "à", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "鹿", "", "", "", "",
"", "p", "L", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "=", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "Y", "", "", "", "", "", "",
"", "", "w", "", "", "3", "", "F", "", "", "", "", "", "", "", "",
"m", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "耀", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "Θ", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "湿",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "X", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "绿", "", "", "", "",
"", "", "滿", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "G", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "x", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "/", "", "", "", "", "", "", "", "", "", "i", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "椿", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", ",", "", "", "", "", "",
"", "T", "", "", "", "N", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "´", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", " ", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "v", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "c",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "''", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "殿", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "B", "", "", "", "О", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "ɔ", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "\"", "", "", "", "", "", "",
"", "", "", "浿", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "n",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ":",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "#", "", "?", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "Φ", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "Q", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", ";", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "轿", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "H", "", "",
"", "", "", "", "", "", "", "", "", "", "", "趿", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "褿", "", "姿", "", "", "", "", "", "", "", "", "", "", "",
"", "K", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "尿", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "W", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", ">", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "P", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "r", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "%",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "l", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "E", "", "", "", "", "", "蹿", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "И", "", "", "", "Z", "", "",
"", "", "", "寿", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "α", "", "", "",
"", "", "", "", "", "", "", "", "", "", "s", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "2", "З", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "Ω", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "@", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "z", "", "", "", "", "", "", "", "", "", "", "", "", "", "访",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "巿", "", "", "", "", "", "D", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "鱿", "", "", "O", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "-", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "西", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "羿",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "麿", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "Р", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "ä", "", "", "", "", "广", "", "",
"", "", "", "", "", "", "4", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "忿", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "涿", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "°", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "^", "", "", "", "$", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "槿", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", ")", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "ü", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "仿", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "1", "", "", "", "", "", "", "", "", "", "", "", "", "Й",
"", "", "", "", "", "", "", "", "", "", "", "", "亿", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", " ", "", "", "", "", "", "", "",
"", "", "", "", "", "", "t", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "竿",
"", "|", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "β", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "b", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "o", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "Ë",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "è", "", "", "", "", "u", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"÷", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"±", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "9", "", "", "", "", "j", "", "", "0", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "\\", "", "", "", "",
"", "", "", "", "", "", "", "8", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "¥", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "贿", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "ò", "", "", "{", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "5", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "岿",
"[", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "驿", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "e", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "A", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "线", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "é", "", "",
"", "", "", "", "", "", "", "~", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "R", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"稿", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "窿", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "g", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "k", "", "", "", "", "", "",
"", "", "", "", "", "鸿", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "退", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "S",
"", "}", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "`", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "怀", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "屿", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "<", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "Я", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "Λ", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "齿", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "+", "", "", "宿", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "I", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "便", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "×", "", "", "",
"", "", "", "", "", "", "", "穿", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "7", "", "", "", "", "",
"", "", "", "", "", "", "", "", ".", "", "d", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "V", "", "", "]", "", "", "", "", "",
"", "", "", "", "", "", "(", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "诿", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "沿", "", "", "", "", "", "", "使", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"·", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "饿", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"J", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "a", "", "", "", "", "", "", "", "", "&", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "h", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "*", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "q", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "_", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "簿", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "罿", "", "П", "",
"U", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "廿", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "馿", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "M", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "y", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "C", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "®", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "婿", "", "", "", "", "", "", "", "", "", "", "",
"", "",
];
pub const CHARSET_OLD: &[&str] = &["", "", "", "", ""];
// pub fn from_builtin_old() -> Self {
// Self::from_static_slice(
// CHARSET_OLD,
// false,
// Resize::DynamicWidth(64),
// 1,
// Normalization::ZeroToOne,
// )
// }
//
// /// 从预设的 Beta 版字符集创建
// pub fn from_builtin_beta() -> Self {
// Self::from_static_slice(
// CHARSET_BETA,
// false,
// Resize::DynamicWidth(64),
// 1,
// Normalization::MinusOneToOne,
// )
// }
// /// 从外部外部 JSON 文件动态加载字符集(在后续优化中移除)
// pub fn from_json_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
// let path = path.as_ref();
// if !path.exists() {
// return Err(anyhow!("模型元数据配置文件不存在: {:?}", path));
// }
//
// let mut file = File::open(path)?;
// let mut content = String::new();
// file.read_to_string(&mut content)?;
//
// let dto: ModelMetadataDto = serde_json::from_str(&content)
// .map_err(|e| anyhow!("JSON 反序列化失败,请检查字段是否完整: {}", e))?;
//
// // 1. 将 DTO 的字符串数组转化为强类型的 Charset
// let tokens: Vec<Cow<'static, str>> =
// dto.charset.into_iter().map(|s| Cow::Owned(s)).collect();
// let charset = Charset::new(tokens);
//
// // 2. 解析 resize 策略(重现 Python 的复杂条件判断)
// if dto.resize.len() != 2 {
// return Err(anyhow!(
// "'resize (or image)' 字段必须是包含两个元素的数组,例如 [-1, 64]"
// ));
// }
// let r0 = dto.resize[0];
// let r1 = dto.resize[1];
//
// let resize = if r0 == -1 {
// if dto.word {
// // 如果 word 为 true且包含 -1Python 里是 resize 为 (r1, r1) 的正方形
// Resize::Square(r1 as u32)
// } else {
// // 如果 word 为 false且包含 -1Python 里是高度固定为 r1宽度按原图比例缩放
// Resize::DynamicWidth(r1 as u32)
// }
// } else {
// // 正常的固定宽高
// Resize::Fixed(r0 as u32, r1 as u32)
// };
//
// Ok(Self {
// charset,
// word: dto.word,
// resize,
// channel: dto.channel,
// normalization: dto.normalization,
// })
// }

View File

@@ -0,0 +1,25 @@
//! 集成测试共享工具。
use std::path::{Path, PathBuf};
/// 仓库根目录 `models/` 下模型文件的路径。
pub fn model_path(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("models")
.join(name)
}
/// 仓库根目录 `samples/` 下样例图片的路径。
pub fn sample_path(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("samples")
.join(name)
}
/// 加载图片,失败时附带路径上下文。
pub fn load_image<P: AsRef<Path>>(path: P) -> anyhow::Result<image::DynamicImage> {
let path_ref = path.as_ref();
image::open(path_ref).map_err(|e| anyhow::anyhow!("无法加载图片 {:?}: {}", path_ref, e))
}

33
ddddocr-ort/tests/det.rs Normal file
View File

@@ -0,0 +1,33 @@
//! 目标检测集成测试。
mod common;
use common::{model_path, sample_path};
use ddddocr_core::Detector;
use ddddocr_core::traits::Loader;
use ddddocr_ort::DetRuntime;
use ddddocr_ort::loader::ModelLoader;
use image::GenericImageView;
/// 检测模型应能从样例图片中找到至少一个目标,且坐标在图片范围内。
#[test]
fn det_model_detects_targets_in_image() -> anyhow::Result<()> {
let session = ModelLoader::default()
.build_for_path(model_path("common_det.onnx"))
.expect("模型加载失败");
let det = DetRuntime::new(session);
let img = image::open(sample_path("det1.png")).expect("测试图片不存在");
let bboxes = Detector::new(&det).predict(&img)?;
assert!(!bboxes.is_empty(), "应检测到至少一个目标");
let (width, height) = img.dimensions();
for bbox in &bboxes {
assert!(bbox.x1 >= 0 && bbox.y1 >= 0, "检测框左上角不应为负");
assert!(
bbox.x2 <= width as i32 && bbox.y2 <= height as i32,
"检测框右下角不应超出图片范围"
);
}
Ok(())
}

56
ddddocr-ort/tests/ocr.rs Normal file
View File

@@ -0,0 +1,56 @@
//! OCR 识别与模型信息集成测试。
mod common;
use common::{model_path, sample_path};
use ddddocr_core::traits::{Info, Loader};
use ddddocr_core::{ModelMetadata, Normalization, Ocr, Resize};
use ddddocr_ort::OcrRuntime;
use ddddocr_ort::loader::ModelLoader;
/// 用官方 sml2h3 f32 模型识别验证码图片,结果不应为空。
#[test]
fn ocr_classification_recognizes_code_image() {
let session = ModelLoader::default()
.use_gpu(false)
.build_for_path(model_path("common_sml2h3_f32.onnx"))
.expect("模型加载失败");
let metadata = ModelMetadata::from_builtin_beta(
false,
Resize::DynamicWidth(64),
1,
Normalization::MinusOneToOne,
);
let ocr = OcrRuntime::new(session, metadata);
let img = image::open(sample_path("code2.png")).expect("测试图片不存在");
let text = Ocr::builder()
.build_with(&ocr)
.predict(&img)
.expect("识别过程出错")
.into_text();
println!("识别结果: {text}");
assert!(!text.is_empty(), "识别结果不应为空");
}
/// 真实模型应能通过 `Info` trait 返回输入/输出张量信息。
#[test]
fn model_info_lists_inputs_and_outputs() -> anyhow::Result<()> {
let session = ModelLoader::default()
.build_for_path(model_path("common_huashi666_i64.onnx"))
.expect("建立测试模型图失败");
let metadata = ModelMetadata::from_builtin_beta(
false,
Resize::DynamicWidth(64),
1,
Normalization::MinusOneToOne,
);
let ocr = OcrRuntime::new(session, metadata);
let inputs = ocr.input_info()?;
let outputs = ocr.output_info()?;
assert!(!inputs.is_empty(), "模型应有输入张量信息");
assert!(!outputs.is_empty(), "模型应有输出张量信息");
Ok(())
}

View File

@@ -1,253 +0,0 @@
use anyhow::Context;
use ddddocr_core::{DetectionResult, Ocr};
use ddddocr_core::traits::Loader;
use ddddocr_core::{Detector, ModelMetadata, Normalization, Slider};
// 假设你的包名是这个
use ddddocr_ort::{DetRuntime, OcrBuilder, OcrRuntime};
use image::{DynamicImage, ImageBuffer, Luma, Rgb};
use std::fs;
use std::path::Path;
mod char_slice;
use char_slice::CHARSET_BETA;
use ddddocr_core::Resize;
use ddddocr_ort::loader::ModelLoader as OrtModelLoader;
fn load_image<P: AsRef<Path>>(path: P) -> anyhow::Result<image::DynamicImage> {
// 1. 先将泛型转为具体的 &Path 引用
let path_ref = path.as_ref();
// 2. 调用 open 时传入引用utils::open 支持 AsRef<Path>
image::open(path_ref).map_err(|e| {
// 3. 此时 path_ref 依然有效,可以安全地在闭包中使用
anyhow::anyhow!("无法加载图片 {:?}: {}", path_ref, e)
})
}
/// 将检测结果绘制在图像上并保存
fn save_debug_image(
dynamic_img: &DynamicImage, // 【优化点 1】直接传入解码好的引用拒绝重复解码
bboxes: &[DetectionResult], // 【修改点 1】类型改为自定义结构体切片
output_path: &str,
) -> anyhow::Result<()> {
// 删除了原本的 let dynamic_img = image::load_from_memory(image_bytes)?;
let mut img = dynamic_img.to_rgb8();
let (width, height) = img.dimensions();
let red = Rgb([255u8, 0, 0]);
for bbox in bboxes {
// 【修改点 2】将原来的索引 bbox[0].. 改为结构体字段访问 .x1, .y1 ..
let x1 = bbox.x1.max(0).min(width as i32 - 1) as u32;
let y1 = bbox.y1.max(0).min(height as i32 - 1) as u32;
let x2 = bbox.x2.max(0).min(width as i32 - 1) as u32;
let y2 = bbox.y2.max(0).min(height as i32 - 1) as u32;
// 绘制横向线条
for x in x1..=x2 {
img.put_pixel(x, y1, red);
img.put_pixel(x, y2, red);
if y1 + 1 < height {
img.put_pixel(x, y1 + 1, red);
}
if y2.saturating_sub(1) > 0 {
img.put_pixel(x, y2 - 1, red);
}
}
// 绘制纵向线条
for y in y1..=y2 {
img.put_pixel(x1, y, red);
img.put_pixel(x2, y, red);
if x1 + 1 < width {
img.put_pixel(x1 + 1, y, red);
}
if x2.saturating_sub(1) > 0 {
img.put_pixel(x2 - 1, y, red);
}
}
}
img.save(output_path)?;
Ok(())
}
#[allow(dead_code)]
fn save_rust_result(result: &ImageBuffer<Luma<f32>, Vec<f32>>, filename: &str) {
let (width, height) = result.dimensions();
// 1. 寻找最值进行归一化
let mut max_val = f32::MIN;
let mut min_val = f32::MAX;
for p in result.pixels() {
if p.0[0] > max_val {
max_val = p.0[0];
}
if p.0[0] < min_val {
min_val = p.0[0];
}
}
// 2. 创建 8 位灰度图
let mut out_buf = ImageBuffer::new(width, height);
for y in 0..height {
for x in 0..width {
let val = result.get_pixel(x, y).0[0];
let normalized = if max_val > min_val {
((val - min_val) / (max_val - min_val) * 255.0) as u8
} else {
0u8
};
out_buf.put_pixel(x, y, Luma([normalized]));
}
}
// 3. 保存
DynamicImage::ImageLuma8(out_buf).save(filename).unwrap();
println!("Rust 结果热力图已保存至: {}", filename);
}
#[test]
fn test_full_classification() {
let model = OrtModelLoader::default().use_gpu(false)
.build_for_path("D:\\CNWei\\CNW\\Rust\\ddddocr-rs\\models\\common_sml2h3_f32.onnx")
// .build_for_path("D:\\CNWei\\CNW\\Rust\\ddddocr-rs\\models\\common_old.onnx")
.expect("模型加载失败");
let metadata = ModelMetadata::from_static_slice(
CHARSET_BETA,
false,
Resize::DynamicWidth(64),
1,
Normalization::MinusOneToOne,
);
// 1. 初始化模型
let ocr = OcrRuntime::new(model, metadata);
// 2. 加载测试图片
let img =
image::open("D:/CNWei/CNW/Rust/ddddocr-rs/samples/code2.png").expect("测试图片不存在");
// 3. 执行识别
// let result = Ocr::new(&ocr)
// .predict(&img)
// .expect("识别过程出错")
// .into_text();
// let result = OcrBuilder::new()
// .build(&ocr)
// .predict(&img)
// .expect("识别过程出错")
// .into_text();
let res=Ocr::builder().runner(&ocr).predict(&img).expect("s").into_text();
// println!("识别结果: {}", result);
println!("识别结果: {}", res);
// assert!(!result.is_empty());
assert!(!res.is_empty());
}
#[test]
fn test_det_load() -> anyhow::Result<()> {
let det_model = OrtModelLoader::default()
.build_for_path("D:\\CNWei\\CNW\\Rust\\ddddocr-rs\\models\\common_det.onnx")
.expect("模型加载失败");
let det = DetRuntime::new(det_model);
let image_path = "D:/CNWei/CNW/Rust/ddddocr-rs/samples/det1.png";
let image_bytes =
fs::read(image_path).map_err(|e| anyhow::anyhow!("无法读取图片 {}: {}", image_path, e))?;
println!("图片读取成功,字节大小: {}", image_bytes.len());
// 【修改点 1】将字节流解码为统一的 DynamicImage
let img = image::load_from_memory(&image_bytes)
.map_err(|e| anyhow::anyhow!("图片解码失败: {}", e))?;
// 【修改点 2】传入统一的 &DynamicImage 引用
let bboxes = Detector::new(&det).predict(&img)?;
// println!("{:?}", det);
println!("检测到的目标数量: {}", bboxes.len());
if bboxes.is_empty() {
println!("未检测到任何目标。");
} else {
// 如果 save_debug_image 报错,记得去把它的入参类型和内部访问也改为 DetectionResult
save_debug_image(
&img,
&bboxes,
"D:/CNWei/CNW/Rust/ddddocr-rs/samples/result.jpg",
)?;
for (i, bbox) in bboxes.iter().enumerate() {
// 【修改点 3】将原来的 bbox[0].. 索引访问改为结构体字段访问
println!("目标 [{}]: {}", i, bbox);
}
}
Ok(())
}
#[test]
fn test_real_slide_match() {
let engine = Slider::new();
// 1. 加载你准备好的测试图
// 假设图片放在项目根目录下的 assets 文件夹
let target_img = load_image("D:/CNWei/CNW/Rust/ddddocr-rs/samples/hua.png")
.expect("请确保 samples/hua.png 存在");
let bg_img = load_image("D:/CNWei/CNW/Rust/ddddocr-rs/samples/huatu.png")
.expect("请确保 samples/huatu.png 存在");
// 2. 执行匹配
// 如果是那种带有明显阴影边缘的复杂滑块,建议 simple_target 传 false
let start = std::time::Instant::now();
let result = engine
.slide_match(&target_img, &bg_img, false)
.expect("Slide match 执行失败");
let duration = start.elapsed();
// 3. 打印结果
println!("-------------------------------------------");
println!("{}", result);
println!("耗时: {:?}", duration);
println!("-------------------------------------------");
// 验证基本逻辑:坐标不应为 0 (除非匹配失败)
assert_eq!(result.target_x, 237);
assert_eq!(result.target_y, 77);
assert!(result.confidence > 0.0);
}
#[test]
fn test_real_slide_comparison() {
let engine = Slider::new();
// 1. 加载你准备好的测试图
// 假设图片放在项目根目录下的 assets 文件夹
let target_img = load_image("D:/CNWei/CNW/Rust/ddddocr-rs/samples/ken.jpg")
.expect("请确保 samples/ken.jpg 存在");
let bg_img = load_image("D:/CNWei/CNW/Rust/ddddocr-rs/samples/kenyuan.jpg")
.expect("请确保 samples/kenyuan.jpg 存在");
// 2. 执行匹配
// 如果是那种带有明显阴影边缘的复杂滑块,建议 simple_target 传 false
let start = std::time::Instant::now();
let result = engine
.slide_comparison(&target_img, &bg_img)
.expect("Slide match 执行失败");
let duration = start.elapsed();
// 3. 打印结果
println!("-------------------------------------------");
println!("滑块匹配测试结果:");
println!("检测坐标: [x: {}, y: {}]", result.target_x, result.target_y);
println!("置信度: {:.4}", result.confidence);
println!("耗时: {:?}", duration);
println!("-------------------------------------------");
// 验证基本逻辑:坐标不应为 0 (除非匹配失败)
assert_eq!(result.target_x, 171);
assert_eq!(result.target_y, 90);
assert!(result.confidence > 0.0);
}
#[test]
fn test_resolve_shape_logic_direct() {
// 创建一个哑 ModelLoader 实例session 用不上,因为我们直接测私有方法)
let loader = OrtModelLoader::default()
.build_for_path(
// "D:\\CNWei\\CNW\\Rust\\ddddocr-rs\\models\\common_sml2h3_f32.onnx",
"D:\\CNWei\\CNW\\Rust\\ddddocr-rs\\models\\common_huashi666_i64.onnx",
)
.expect("建立测试模型图失败");
}

View File

@@ -0,0 +1,52 @@
//! 滑块匹配集成测试。
mod common;
use common::{load_image, sample_path};
use ddddocr_core::Slider;
/// 边缘模式匹配应定位到预期坐标。
#[test]
fn slide_match_locates_target_position() {
let engine = Slider::new();
let target = load_image(sample_path("target1.png")).expect("请确保 samples/target1.png 存在");
let background = load_image(sample_path("background1.png")).expect("请确保 samples/background1.png 存在");
let start = std::time::Instant::now();
let result = engine
.slide_match(&target, &background, false)
.expect("Slide match 执行失败");
let elapsed = start.elapsed();
println!("边缘模式匹配: {result}");
println!("耗时: {elapsed:?}");
assert_eq!(result.target_x, 237);
assert_eq!(result.target_y, 77);
assert!(result.confidence > 0.0);
}
/// 灰度对比匹配应定位到预期坐标。
#[test]
fn slide_comparison_locates_target_position() {
let engine = Slider::new();
let target = load_image(sample_path("target2.jpg")).expect("请确保 samples/target2.jpg 存在");
let background =
load_image(sample_path("background2.jpg")).expect("请确保 samples/background2.jpg 存在");
let start = std::time::Instant::now();
let result = engine
.slide_comparison(&target, &background)
.expect("Slide comparison 执行失败");
let elapsed = start.elapsed();
println!(
"灰度对比匹配: 坐标 [x: {}, y: {}], 置信度 {:.4}",
result.target_x, result.target_y, result.confidence
);
println!("耗时: {elapsed:?}");
assert_eq!(result.target_x, 171);
assert_eq!(result.target_y, 90);
assert!(result.confidence > 0.0);
}

View File

@@ -1,26 +0,0 @@
[package]
name = "ddddocr-tract"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
[dependencies]
ddddocr-core = { path = "../ddddocr-core" } # 引入兄弟库
tract-onnx = { workspace = true }
tract-linalg = {workspace = true }
ndarray = { workspace = true } # 继承自工作空间
image = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true } # 刚好可以开始接入你需要的标准库错误处理
[features]
default = []
embed-models = [] # 这是一个留给有特殊需求、且自己下载了模型放入 models/ 目录的人的后门

View File

@@ -1 +0,0 @@
pub mod session;

View File

@@ -1,52 +0,0 @@
use crate::types::Session;
use ddddocr_core::error::{Result, TensorError};
use ddddocr_core::{ DetOutput};
use ddddocr_core::traits::{DetEngine, InferenceEngine};
use ndarray::Ix3;
use tract_onnx::prelude::*;
#[derive(Debug)]
pub struct DetRuntime {
pub session: Session,
}
impl DetRuntime {
pub fn new(session: Session) -> Self {
Self { session }
}
}
impl InferenceEngine for DetRuntime {
type Output = DetOutput; // 明确绑定 OCR 小枚举
fn inference(&self, input_array: ndarray::Array4<f32>) -> Result<Self::Output, TensorError> {
// tract 的 run 会返回一个 Vec<TValue>,我们通常只需要第一个输出
// let result = self.ocr.run(tvec!(tensor.into()))?;
let tensor = Tensor::from(input_array);
let mut result = self
.session
.run(tvec!(tensor.into()))
.map_err(|_| TensorError::Engine("执行模型推理失败".to_string()))?;
println!("模型输出原始数据: {:?}", result);
// Ok(result.swap_remove(0).into_tensor())
let raw_tensor = result.swap_remove(0).into_tensor();
// raw_tensor.into_plain_array()?
let array_d = raw_tensor.into_plain_array::<f32>().map_err(|_| {
TensorError::Engine("Tract 实体张量无法转换为 ndarray::ArrayD".to_string())
})?;
// 提前利用克隆(Clone)备份好当前未转维度前的真实 shape (Vec<usize>)
let actual_shape = array_d.shape().to_vec();
let array3 = array_d.into_dimensionality::<Ix3>().map_err(|_| {
TensorError::DimensionMismatch {
expected: "3D 检测矩阵 [Batch, Box_Count, Box_Attributes]".to_string(),
actual: actual_shape, // 优雅降维失败时动态捕获
}
})?;
Ok(DetOutput::Detection(array3))
// 在引擎内部消化掉 DatumType 强耦合
}
}
impl DetEngine for DetRuntime {}

View File

@@ -1,8 +0,0 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum TensorError {
/// 替换原有的 anyhow::Error明确将 Tract/ONNX 引擎底层报错序列化为干净的 String
#[error("推理引擎内部发生异常: {0}")]
Engine(String),
}

View File

@@ -1,11 +0,0 @@
mod det;
mod error;
pub mod loader;
mod ocr;
mod types;
pub use ddddocr_core::{
DetectionResult, Detector, ModelMetadata, Normalization, Ocr, OcrBuilder, SlideResult, Slider,
};
pub use det::session::DetRuntime;
pub use ocr::session::OcrRuntime;

View File

@@ -1,153 +0,0 @@
use crate::types::Session;
use ddddocr_core::ModelMetadata;
use ddddocr_core::OcrOutput;
use ddddocr_core::error::{DdddError, Result, TensorError};
use ddddocr_core::traits::{InferenceEngine, Info, OcrEngine};
use ddddocr_core::types::{AxisDim, ModelInfo, TensorInfo, TensorType};
use ddddocr_core::utils::normalize_ocr_logits;
use tract_onnx::prelude::{DatumType, OutletId, ShapeFact, TypedModel};
use tract_onnx::prelude::{IntoTensor, Tensor, tvec};
pub struct OcrRuntime {
pub session: Session,
pub metadata: ModelMetadata,
}
impl OcrRuntime {
pub fn new(session: Session, metadata: ModelMetadata) -> Self {
Self { session, metadata }
}
/// 获取模型输入的节点信息列表
/// 提取出来的公共转换逻辑:将一组 OutletId 解析为 TensorInfo 列表
fn resolve_tensors(&self, model: &TypedModel, outlets: &[OutletId]) -> Result<Vec<TensorInfo>> {
outlets
.iter()
.map(|&outlet_id| {
let fact = model.outlet_fact(outlet_id).map_err(DdddError::new)?;
// .map_err(|e| {
// DdddError::InternalError(format!("解析节点 Fact 失败: {:?}", e))
// })?;
let shape = self.resolve_shape(&fact.shape);
let node_name = model.node(outlet_id.node).name.clone();
let tensor_type = match fact.datum_type {
DatumType::F32 => TensorType::F32,
DatumType::I64 => TensorType::I64,
_ => TensorType::Other,
};
Ok(TensorInfo {
name: node_name,
shape,
tensor_type,
})
})
.collect() // 函数式声明:自动传播第一处发生的错误
}
/// 安全还原 Tract 维度至 Vec<AxisDim>
fn resolve_shape(&self, shape_fact: &ShapeFact) -> Vec<AxisDim> {
let tract_shape = shape_fact.to_tvec();
let resolved = tract_shape
.iter()
.map(|dim| {
// 防御性编程:必须同时满足能够转换为 i64 且 大于等于 0
if let Ok(size) = dim.to_i64() {
if size >= 0 {
AxisDim::Static(size as usize)
} else {
// 如果 ONNX 导出时某些动态维度被标记为了 -1安全地作为动态符号捕获
AxisDim::Dynamic(dim.to_string())
}
} else {
AxisDim::Dynamic(dim.to_string())
}
})
.collect();
resolved
}
}
impl OcrEngine for OcrRuntime {
fn metadata(&self) -> &ModelMetadata {
&self.metadata
}
}
impl InferenceEngine for OcrRuntime {
type Output = OcrOutput;
/// 对应 Python 的 _inference
fn inference(&self, input_array: ndarray::Array4<f32>) -> Result<Self::Output, TensorError> {
// tract 的 run 会返回一个 Vec<TValue>,我们通常只需要第一个输出
// let result = self.ocr.run(tvec!(tensor.into()))?;
let tensor = Tensor::from(input_array);
let mut result = self
.session
.run(tvec!(tensor.into()))
.map_err(|_| TensorError::Engine("执行模型推理失败".to_string()))?;
// .context("执行模型推理失败")?;
println!("模型输出原始数据: {:?}", result);
// Ok(result.swap_remove(0).into_tensor())
let raw_tensor = result.swap_remove(0).into_tensor();
// 在引擎内部消化掉 DatumType 强耦合
match raw_tensor.datum_type() {
DatumType::I64 => {
let array_d = raw_tensor
.into_plain_array::<i64>()
.map_err(|_| TensorError::Engine("Tract 无法获取 i64 内存视图".to_string()))?;
// .context("Tract 无法获取 i64 内存视图")?;
// 🌟 提前提取真实维度
let actual_shape = array_d.shape().to_vec();
// 转成标准的 Array1 传给 core
let array1 = array_d
.to_owned()
.into_dimensionality::<ndarray::Ix1>()
.map_err(|_| TensorError::DimensionMismatch {
expected: "1D 字符索引静态矩阵".to_string(),
actual: actual_shape,
})?;
Ok(OcrOutput::Indices(array1))
}
DatumType::F32 => {
let shape = raw_tensor.shape();
println!("模型输出shape数据: {:?}", shape);
// raw_tensor.to_plain_array_view()
let view = raw_tensor
.to_plain_array_view::<f32>()
.map_err(|_| TensorError::Engine("Tract 无法获取 f32 内存视图".to_string()))?;
// 1. 极其纯粹的、无拷贝的多维 Shape 压扁清洗
normalize_ocr_logits(view, shape)
}
_ => Err(
// anyhow::anyhow!("不支持的模型输出数据类型: {:?}",raw_tensor.datum_type())
TensorError::UnknownOutputFormat,
),
}
}
}
impl Info for OcrRuntime {
fn input_info(&self) -> Result<Vec<TensorInfo>> {
let model = self.session.model();
let outlets = model.input_outlets().map_err(DdddError::new)?;
self.resolve_tensors(model, outlets)
}
/// 获取模型输出的节点信息列表
fn output_info(&self) -> Result<Vec<TensorInfo>> {
let model = self.session.model();
let outlets = model.output_outlets().map_err(DdddError::new)?;
self.resolve_tensors(model, outlets)
}
/// 获取模型详细元数据信息(对标 Python ddddocr 的 get_model_info
/// 完美包容 [1, 1, 64, image_width] 这样的变长图像模型
/// 获取模型详细元数据信息(代码更紧凑、优雅)
fn model_info(&self) -> Result<ModelInfo> {
Ok(ModelInfo {
inputs: self.input_info()?,
outputs: self.output_info()?,
providers: None,
})
}
}

View File

@@ -1,4 +0,0 @@
use std::sync::Arc;
use tract_onnx::prelude::TypedRunnableModel;
pub type Session = Arc<TypedRunnableModel>;

View File

@@ -1,600 +0,0 @@
use std::borrow::Cow;
use std::fs::File;
use std::path::Path;
use anyhow::anyhow;
use ddddocr_core::Charset;
use ddddocr_core::{Normalization, Resize};
pub const CHARSET_BETA: &[&str] = &[
"", "", "", "", "", "", "", "", "", "", "", "", "", "6", "", "",
"", "", "", "", "", "", "", "", "", "", "", "鴿", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "f", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "²", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "!", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "à", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "鹿", "", "", "", "",
"", "p", "L", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "=", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "Y", "", "", "", "", "", "",
"", "", "w", "", "", "3", "", "F", "", "", "", "", "", "", "", "",
"m", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "耀", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "Θ", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "湿",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "X", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "绿", "", "", "", "",
"", "", "滿", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "G", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "x", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "/", "", "", "", "", "", "", "", "", "", "i", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "椿", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", ",", "", "", "", "", "",
"", "T", "", "", "", "N", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "´", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", " ", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "v", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "c",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "''", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "殿", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "B", "", "", "", "О", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "ɔ", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "\"", "", "", "", "", "", "",
"", "", "", "浿", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "n",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ":",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "#", "", "?", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "Φ", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "Q", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", ";", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "轿", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "H", "", "",
"", "", "", "", "", "", "", "", "", "", "", "趿", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "褿", "", "姿", "", "", "", "", "", "", "", "", "", "", "",
"", "K", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "尿", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "W", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", ">", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "P", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "r", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "%",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "l", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "E", "", "", "", "", "", "蹿", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "И", "", "", "", "Z", "", "",
"", "", "", "寿", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "α", "", "", "",
"", "", "", "", "", "", "", "", "", "", "s", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "2", "З", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "Ω", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "@", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "z", "", "", "", "", "", "", "", "", "", "", "", "", "", "访",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "巿", "", "", "", "", "", "D", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "鱿", "", "", "O", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "-", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "西", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "羿",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "麿", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "Р", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "ä", "", "", "", "", "广", "", "",
"", "", "", "", "", "", "4", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "忿", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "涿", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "°", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "^", "", "", "", "$", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "槿", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", ")", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "ü", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "仿", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "1", "", "", "", "", "", "", "", "", "", "", "", "", "Й",
"", "", "", "", "", "", "", "", "", "", "", "", "亿", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", " ", "", "", "", "", "", "", "",
"", "", "", "", "", "", "t", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "竿",
"", "|", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "β", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "b", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "o", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "Ë",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "è", "", "", "", "", "u", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"÷", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"±", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "9", "", "", "", "", "j", "", "", "0", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "\\", "", "", "", "",
"", "", "", "", "", "", "", "8", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "¥", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "贿", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "ò", "", "", "{", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "5", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "岿",
"[", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "驿", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "e", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "A", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "线", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "é", "", "",
"", "", "", "", "", "", "", "~", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "R", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"稿", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "窿", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "g", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "k", "", "", "", "", "", "",
"", "", "", "", "", "鸿", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "退", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "S",
"", "}", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "`", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "怀", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "屿", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "<", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "Я", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "Λ", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "齿", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "+", "", "", "宿", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "I", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "便", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "×", "", "", "",
"", "", "", "", "", "", "", "穿", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "7", "", "", "", "", "",
"", "", "", "", "", "", "", "", ".", "", "d", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "V", "", "", "]", "", "", "", "", "",
"", "", "", "", "", "", "(", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "诿", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "沿", "", "", "", "", "", "", "使", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"·", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "饿", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"J", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "a", "", "", "", "", "", "", "", "", "&", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "h", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "*", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "q", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "_", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "簿", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "罿", "", "П", "",
"U", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "廿", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "馿", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "M", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "y", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "C", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "®", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "婿", "", "", "", "", "", "", "", "", "", "", "",
"", "",
];
pub const CHARSET_OLD: &[&str] = &["", "", "", "", ""];
// pub fn from_builtin_old() -> Self {
// Self::from_static_slice(
// CHARSET_OLD,
// false,
// Resize::DynamicWidth(64),
// 1,
// Normalization::ZeroToOne,
// )
// }
//
// /// 从预设的 Beta 版字符集创建
// pub fn from_builtin_beta() -> Self {
// Self::from_static_slice(
// CHARSET_BETA,
// false,
// Resize::DynamicWidth(64),
// 1,
// Normalization::MinusOneToOne,
// )
// }
// /// 从外部外部 JSON 文件动态加载字符集(在后续优化中移除)
// pub fn from_json_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
// let path = path.as_ref();
// if !path.exists() {
// return Err(anyhow!("模型元数据配置文件不存在: {:?}", path));
// }
//
// let mut file = File::open(path)?;
// let mut content = String::new();
// file.read_to_string(&mut content)?;
//
// let dto: ModelMetadataDto = serde_json::from_str(&content)
// .map_err(|e| anyhow!("JSON 反序列化失败,请检查字段是否完整: {}", e))?;
//
// // 1. 将 DTO 的字符串数组转化为强类型的 Charset
// let tokens: Vec<Cow<'static, str>> =
// dto.charset.into_iter().map(|s| Cow::Owned(s)).collect();
// let charset = Charset::new(tokens);
//
// // 2. 解析 resize 策略(重现 Python 的复杂条件判断)
// if dto.resize.len() != 2 {
// return Err(anyhow!(
// "'resize (or image)' 字段必须是包含两个元素的数组,例如 [-1, 64]"
// ));
// }
// let r0 = dto.resize[0];
// let r1 = dto.resize[1];
//
// let resize = if r0 == -1 {
// if dto.word {
// // 如果 word 为 true且包含 -1Python 里是 resize 为 (r1, r1) 的正方形
// Resize::Square(r1 as u32)
// } else {
// // 如果 word 为 false且包含 -1Python 里是高度固定为 r1宽度按原图比例缩放
// Resize::DynamicWidth(r1 as u32)
// }
// } else {
// // 正常的固定宽高
// Resize::Fixed(r0 as u32, r1 as u32)
// };
//
// Ok(Self {
// charset,
// word: dto.word,
// resize,
// channel: dto.channel,
// normalization: dto.normalization,
// })
// }

View File

@@ -1,251 +0,0 @@
use anyhow::Context;
use ddddocr_core::traits::Loader;
use ddddocr_tract::{DetectionResult, Ocr};
use ddddocr_tract::{Detector, ModelMetadata, Normalization, Slider};
// 假设你的包名是这个
use ddddocr_tract::{DetRuntime, OcrRuntime};
use image::{DynamicImage, ImageBuffer, Luma, Rgb};
use std::fs;
use std::path::Path;
mod char_slice;
use char_slice::CHARSET_BETA;
use ddddocr_core::Resize;
use ddddocr_tract::loader::ModelLoader as TractModelLoader;
fn load_image<P: AsRef<Path>>(path: P) -> anyhow::Result<image::DynamicImage> {
// 1. 先将泛型转为具体的 &Path 引用
let path_ref = path.as_ref();
// 2. 调用 open 时传入引用utils::open 支持 AsRef<Path>
image::open(path_ref).map_err(|e| {
// 3. 此时 path_ref 依然有效,可以安全地在闭包中使用
anyhow::anyhow!("无法加载图片 {:?}: {}", path_ref, e)
})
}
/// 将检测结果绘制在图像上并保存
fn save_debug_image(
dynamic_img: &DynamicImage, // 【优化点 1】直接传入解码好的引用拒绝重复解码
bboxes: &[DetectionResult], // 【修改点 1】类型改为自定义结构体切片
output_path: &str,
) -> anyhow::Result<()> {
// 删除了原本的 let dynamic_img = image::load_from_memory(image_bytes)?;
let mut img = dynamic_img.to_rgb8();
let (width, height) = img.dimensions();
let red = Rgb([255u8, 0, 0]);
for bbox in bboxes {
// 【修改点 2】将原来的索引 bbox[0].. 改为结构体字段访问 .x1, .y1 ..
let x1 = bbox.x1.max(0).min(width as i32 - 1) as u32;
let y1 = bbox.y1.max(0).min(height as i32 - 1) as u32;
let x2 = bbox.x2.max(0).min(width as i32 - 1) as u32;
let y2 = bbox.y2.max(0).min(height as i32 - 1) as u32;
// 绘制横向线条
for x in x1..=x2 {
img.put_pixel(x, y1, red);
img.put_pixel(x, y2, red);
if y1 + 1 < height {
img.put_pixel(x, y1 + 1, red);
}
if y2.saturating_sub(1) > 0 {
img.put_pixel(x, y2 - 1, red);
}
}
// 绘制纵向线条
for y in y1..=y2 {
img.put_pixel(x1, y, red);
img.put_pixel(x2, y, red);
if x1 + 1 < width {
img.put_pixel(x1 + 1, y, red);
}
if x2.saturating_sub(1) > 0 {
img.put_pixel(x2 - 1, y, red);
}
}
}
img.save(output_path)?;
Ok(())
}
#[allow(dead_code)]
fn save_rust_result(result: &ImageBuffer<Luma<f32>, Vec<f32>>, filename: &str) {
let (width, height) = result.dimensions();
// 1. 寻找最值进行归一化
let mut max_val = f32::MIN;
let mut min_val = f32::MAX;
for p in result.pixels() {
if p.0[0] > max_val {
max_val = p.0[0];
}
if p.0[0] < min_val {
min_val = p.0[0];
}
}
// 2. 创建 8 位灰度图
let mut out_buf = ImageBuffer::new(width, height);
for y in 0..height {
for x in 0..width {
let val = result.get_pixel(x, y).0[0];
let normalized = if max_val > min_val {
((val - min_val) / (max_val - min_val) * 255.0) as u8
} else {
0u8
};
out_buf.put_pixel(x, y, Luma([normalized]));
}
}
// 3. 保存
DynamicImage::ImageLuma8(out_buf).save(filename).unwrap();
println!("Rust 结果热力图已保存至: {}", filename);
}
#[test]
fn test_full_classification() {
let session = TractModelLoader::default()
.build_for_path("D:\\CNWei\\CNW\\Rust\\ddddocr-rs\\models\\common_sml2h3_f32.onnx")
.expect("模型加载失败");
let metadata = ModelMetadata::from_static_slice(
CHARSET_BETA,
false,
Resize::DynamicWidth(64),
1,
Normalization::MinusOneToOne,
);
// 1. 初始化模型
let ocr_runtime = OcrRuntime::new(session, metadata);
// 2. 加载测试图片
let img =
image::open("D:/CNWei/CNW/Rust/ddddocr-rs/samples/code2.png").expect("测试图片不存在");
// 3. 执行识别
// let result = Ocr::new(&ocr_runtime)
// .predict(&img)
// .expect("识别过程出错")
// .into_text();
let result = Ocr::builder()
.runner(&ocr_runtime)
.predict(&img)
.expect("识别过程出错")
.into_text();
println!("识别结果: {}", result);
assert!(!result.is_empty());
}
#[test]
fn test_det_load() -> anyhow::Result<()> {
let det_model = TractModelLoader::default()
.build_for_path("D:\\CNWei\\CNW\\Rust\\ddddocr-rs\\models\\common_det.onnx")
.expect("模型加载失败");
let det = DetRuntime::new(det_model);
let image_path = "D:/CNWei/CNW/Rust/ddddocr-rs/samples/det1.png";
let image_bytes =
fs::read(image_path).map_err(|e| anyhow::anyhow!("无法读取图片 {}: {}", image_path, e))?;
println!("图片读取成功,字节大小: {}", image_bytes.len());
// 【修改点 1】将字节流解码为统一的 DynamicImage
let img = image::load_from_memory(&image_bytes)
.map_err(|e| anyhow::anyhow!("图片解码失败: {}", e))?;
// 【修改点 2】传入统一的 &DynamicImage 引用
let bboxes = Detector::new(&det).predict(&img)?;
// println!("{:?}", det);
println!("检测到的目标数量: {}", bboxes.len());
if bboxes.is_empty() {
println!("未检测到任何目标。");
} else {
// 如果 save_debug_image 报错,记得去把它的入参类型和内部访问也改为 DetectionResult
save_debug_image(
&img,
&bboxes,
"D:/CNWei/CNW/Rust/ddddocr-rs/samples/result.jpg",
)?;
for (i, bbox) in bboxes.iter().enumerate() {
// 【修改点 3】将原来的 bbox[0].. 索引访问改为结构体字段访问
println!("目标 [{}]: {}", i, bbox);
}
}
Ok(())
}
#[test]
fn test_real_slide_match() {
let engine = Slider::new();
// 1. 加载你准备好的测试图
// 假设图片放在项目根目录下的 assets 文件夹
let target_img = load_image("D:/CNWei/CNW/Rust/ddddocr-rs/samples/hua.png")
.expect("请确保 samples/hua.png 存在");
let bg_img = load_image("D:/CNWei/CNW/Rust/ddddocr-rs/samples/huatu.png")
.expect("请确保 samples/huatu.png 存在");
// 2. 执行匹配
// 如果是那种带有明显阴影边缘的复杂滑块,建议 simple_target 传 false
let start = std::time::Instant::now();
let result = engine
.slide_match(&target_img, &bg_img, false)
.expect("Slide match 执行失败");
let duration = start.elapsed();
// 3. 打印结果
println!("-------------------------------------------");
println!("{}", result);
println!("耗时: {:?}", duration);
println!("-------------------------------------------");
// 验证基本逻辑:坐标不应为 0 (除非匹配失败)
assert_eq!(result.target_x, 237);
assert_eq!(result.target_y, 77);
assert!(result.confidence > 0.0);
}
#[test]
fn test_real_slide_comparison() {
let engine = Slider::new();
// 1. 加载你准备好的测试图
// 假设图片放在项目根目录下的 assets 文件夹
let target_img = load_image("D:/CNWei/CNW/Rust/ddddocr-rs/samples/ken.jpg")
.expect("请确保 samples/ken.jpg 存在");
let bg_img = load_image("D:/CNWei/CNW/Rust/ddddocr-rs/samples/kenyuan.jpg")
.expect("请确保 samples/kenyuan.jpg 存在");
// 2. 执行匹配
// 如果是那种带有明显阴影边缘的复杂滑块,建议 simple_target 传 false
let start = std::time::Instant::now();
let result = engine
.slide_comparison(&target_img, &bg_img)
.expect("Slide match 执行失败");
let duration = start.elapsed();
// 3. 打印结果
println!("-------------------------------------------");
println!("滑块匹配测试结果:");
println!("检测坐标: [x: {}, y: {}]", result.target_x, result.target_y);
println!("置信度: {:.4}", result.confidence);
println!("耗时: {:?}", duration);
println!("-------------------------------------------");
// 验证基本逻辑:坐标不应为 0 (除非匹配失败)
assert_eq!(result.target_x, 171);
assert_eq!(result.target_y, 90);
assert!(result.confidence > 0.0);
}
#[test]
fn test_resolve_shape_logic_direct() {
// 创建一个哑 ModelLoader 实例session 用不上,因为我们直接测私有方法)
let loader = TractModelLoader::default()
.build_for_path(
// "D:\\CNWei\\CNW\\Rust\\ddddocr-rs\\models\\common_sml2h3_f32.onnx",
"D:\\CNWei\\CNW\\Rust\\ddddocr-rs\\models\\common_huashi666_i64.onnx",
)
.expect("建立测试模型图失败");
println!("{:?}", loader.model().inputs);
}

22
ddddocr-tract2/Cargo.toml Normal file
View File

@@ -0,0 +1,22 @@
[package]
name = "ddddocr-tract2"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "ddddocr-rs 的 Tract 推理引擎实现"
keywords = ["ocr", "captcha", "ddddocr", "onnxruntime", "tract"]
categories = ["multimedia::images", "computer-vision"]
# repository = "https://github.com/<用户名>/<仓库名>" # 发布前请补充
readme = "../README.md"
[dependencies]
ddddocr-core = { path = "../ddddocr-core", version = "0.2.4" }
tract-onnx = { workspace = true }
tract-linalg = { workspace = true }
ndarray = { workspace = true }
image = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }

View File

@@ -0,0 +1,44 @@
use crate::runtime::{extract_plain_array, run_session};
use crate::types::Session;
use ddddocr_core::DetOutput;
use ddddocr_core::error::{Result, TensorError};
use ddddocr_core::traits::{DetEngine, InferenceEngine};
use ndarray::Ix3;
use tracing::debug;
use tract_onnx::prelude::*;
#[derive(Debug)]
/// 目标检测推理运行时:持有 Tract 会话,输出 [`DetOutput`]。
pub struct DetRuntime {
/// Tract 会话句柄。
pub session: Session,
}
impl DetRuntime {
/// 基于已构建的会话创建检测运行时。
pub fn new(session: Session) -> Self {
Self { session }
}
}
impl InferenceEngine for DetRuntime {
type Output = DetOutput;
fn inference(&self, input_array: ndarray::Array4<f32>) -> Result<Self::Output, TensorError> {
let mut result = run_session(&self.session, input_array)?;
debug!("模型输出原始数据: {:?}", result);
let raw_tensor = result.swap_remove(0).into_tensor();
let array_d = extract_plain_array::<f32>(raw_tensor)?;
let actual_shape = array_d.shape().to_vec();
let array3 =
array_d
.into_dimensionality::<Ix3>()
.map_err(|_| TensorError::DimensionMismatch {
expected: "3D 检测矩阵 [Batch, Box_Count, Box_Attributes]".to_string(),
actual: actual_shape,
})?;
Ok(DetOutput::Detection(array3))
}
}
impl DetEngine for DetRuntime {}

22
ddddocr-tract2/src/lib.rs Normal file
View File

@@ -0,0 +1,22 @@
//! # ddddocr-tract2
//!
//! 基于 Tract纯 Rust ONNX 推理框架)的推理引擎实现:通过 [`loader::ModelLoader`] 构建会话,
//! 再由 [`OcrRuntime`] / [`DetRuntime`] 实现 core 的
//! [`ddddocr_core::traits::InferenceEngine`] 接口。
//! 同时重导出 core 的 [`OcrBuilder`]、[`Slider`]、[`SlideResult`] 等便捷 API。
#![warn(missing_docs)]
mod det;
/// 模型加载器:从路径或字节流构建 Tract 会话。
pub mod loader;
mod ocr;
mod runtime;
mod types;
pub use ddddocr_core::{
DetectionResult, Detector, ModelMetadata, Normalization, Ocr, OcrBuilder, SlideResult, Slider,
};
pub use det::session::DetRuntime;
pub use ocr::session::OcrRuntime;
pub use types::Session;

View File

@@ -3,5 +3,5 @@ mod metadata;
mod model;
pub use error::{Error, ParseError, Result};
pub use metadata::{ModelMetadataDto, NormalizationDto, Metadata};
pub use metadata::{Metadata, ModelMetadataDto, NormalizationDto};
pub use model::ModelLoader;

View File

@@ -1,6 +1,8 @@
use tract_onnx::prelude::TractError;
/// 模型加载与解析的通用结果类型。
pub type Result<T> = std::result::Result<T, Error>;
#[derive(thiserror::Error, Debug)]
/// 模型加载、解析与 Session 构建阶段的错误。
pub enum Error {
/// 解析 ONNX 模型/路径失败(如文件损坏、算子不支持、路径非法)
#[error("解析 ONNX 模型结构失败: {0}")]
@@ -22,6 +24,7 @@ pub enum Error {
#[error("Metadata 字节流不是合法的 UTF-8 编码: {0}")]
InvalidUtf8(#[from] std::str::Utf8Error),
/// 模型元数据内容解析失败。
#[error("模型元数据解析失败: {0}")]
MetadataParse(String),
@@ -38,8 +41,9 @@ impl Error {
Self::Other(msg.into(), err.into())
}
}
#[derive(thiserror::Error,Debug)]
pub enum ParseError{
#[derive(thiserror::Error, Debug)]
/// 从路径或字节流解析 ONNX 模型失败的错误。
pub enum ParseError {
/// 策略 A从文件路径加载失败附带路径上下文信息方便排查是找不到文件还是格式不对
#[error("从路径 '{0}' 加载 ONNX 模型失败: {1}")]
Path(String, #[source] TractError),
@@ -47,4 +51,32 @@ pub enum ParseError{
/// 策略 B从内存字节流加载失败如 include_bytes! 传入的字节流损坏)
#[error("从内存字节流解析 ONNX 模型失败: {0}")]
Bytes(#[source] TractError),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wraps_external_error_via_new() {
let io_err = std::io::Error::other("boom");
let err = Error::new("自定义错误", io_err);
assert!(matches!(err, Error::Other(_, _)));
assert!(err.to_string().contains("自定义错误"));
}
#[test]
fn serde_json_error_converts_to_error() {
let json_err = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
let err: Error = json_err.into();
assert!(matches!(err, Error::JsonParse(_)));
}
#[test]
fn invalid_utf8_converts_to_error() {
let bytes = vec![0xffu8];
let utf8_err = std::str::from_utf8(&bytes).unwrap_err();
let err: Error = utf8_err.into();
assert!(matches!(err, Error::InvalidUtf8(_)));
}
}

View File

@@ -8,6 +8,7 @@ use std::borrow::Cow;
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")] // 支持 json 中写 "zero_to_one" 或 "minus_one_to_one"
/// 归一化策略的 JSON 反序列化中间表示。
pub enum NormalizationDto {
/// 映射到 [0.0, 1.0] -> pixel / 255.0
ZeroToOne,
@@ -41,8 +42,9 @@ fn default_normalization() -> NormalizationDto {
NormalizationDto::ZeroToOne
}
/// Tract 专属扩展trait 或 工具函数
/// 从 JSON 字符串或字节流解析模型元数据的扩展接口。
pub trait Metadata: Sized {
/// 从 JSON 字符串解析模型元数据。
fn from_json_str(json_str: &str) -> Result<Self>;
/// 机制 2从内存字节流加载极大地方便 include_bytes! 或网络下载)
fn from_json_bytes(bytes: &[u8]) -> Result<Self> {
@@ -56,8 +58,7 @@ impl Metadata for ModelMetadata {
let dto: ModelMetadataDto = serde_json::from_str(json_str)?;
// 1. 将 DTO 的字符串数组转化为强类型的 Charset
let tokens: Vec<Cow<'static, str>> =
dto.charset.into_iter().map(|s| Cow::Owned(s)).collect();
let tokens: Vec<Cow<'static, str>> = dto.charset.into_iter().map(Cow::Owned).collect();
let charset = Charset::new(tokens);
// 2. 解析 resize 策略(重现 Python 的复杂条件判断)
@@ -91,3 +92,104 @@ impl Metadata for ModelMetadata {
))
}
}
#[cfg(test)]
mod tests {
use super::*;
const MINIMAL_JSON: &str = r#"{
"charset": ["a", "b", "c"],
"word": false,
"resize": [-1, 64],
"channel": 1
}"#;
#[test]
fn parses_minimal_json_with_default_normalization() {
let meta = ModelMetadata::from_json_str(MINIMAL_JSON).unwrap();
assert_eq!(meta.charset.size(), 3);
assert!(!meta.word);
assert_eq!(meta.channel, 1);
assert!(matches!(meta.resize, Resize::DynamicWidth(64)));
assert!(matches!(meta.normalization, Normalization::ZeroToOne));
}
#[test]
fn parses_minus_one_to_one_normalization() {
let json = r#"{
"charset": ["a"],
"word": false,
"resize": [-1, 64],
"channel": 1,
"normalization": "minus_one_to_one"
}"#;
let meta = ModelMetadata::from_json_str(json).unwrap();
assert!(matches!(meta.normalization, Normalization::MinusOneToOne));
}
#[test]
fn parses_word_model_as_square_resize() {
let json = r#"{
"charset": ["a"],
"word": true,
"resize": [-1, 64],
"channel": 1
}"#;
let meta = ModelMetadata::from_json_str(json).unwrap();
assert!(matches!(meta.resize, Resize::Square(64)));
}
#[test]
fn parses_fixed_resize() {
let json = r#"{
"charset": ["a"],
"word": false,
"resize": [100, 64],
"channel": 1
}"#;
let meta = ModelMetadata::from_json_str(json).unwrap();
assert!(matches!(meta.resize, Resize::Fixed(100, 64)));
}
#[test]
fn parses_image_alias() {
let json = r#"{
"charset": ["a"],
"word": false,
"image": [200, 64],
"channel": 1
}"#;
let meta = ModelMetadata::from_json_str(json).unwrap();
assert!(matches!(meta.resize, Resize::Fixed(200, 64)));
}
#[test]
fn rejects_resize_with_wrong_length() {
let json = r#"{
"charset": ["a"],
"word": false,
"resize": [1, 2, 3],
"channel": 1
}"#;
assert!(matches!(
ModelMetadata::from_json_str(json),
Err(Error::MetadataParse(_))
));
}
#[test]
fn rejects_invalid_json() {
assert!(matches!(
ModelMetadata::from_json_str("not json"),
Err(Error::JsonParse(_))
));
}
#[test]
fn rejects_invalid_utf8_bytes() {
assert!(matches!(
ModelMetadata::from_json_bytes(&[0xff, 0xfe]),
Err(Error::InvalidUtf8(_))
));
}
}

View File

@@ -46,13 +46,10 @@ impl Loader for ModelLoader {
let session = onnx()
.model_for_path(path_ref)
.map_err(|e| ParseError::Path(path_ref.display().to_string(), e))?
// .with_context(|| "加载 ONNX 模型失败,请检查路径是否正确")?
.into_optimized()
.map_err(Error::OptimizationFailed)?
// .with_context(|| "优化 Tract 模型图失败")?
.into_runnable()
.map_err(Error::RunnableBuildFailed)?;
// .with_context(|| "构建可运行 Tract 实例失败")?;
Ok(session)
}
/// 策略 B从内存字节流加载模型配合 include_bytes! 使用)
@@ -64,13 +61,10 @@ impl Loader for ModelLoader {
let session = onnx()
.model_for_read(&mut cursor)
.map_err(ParseError::Bytes)?
// .with_context(|| "从内存字节流解析 ONNX 模型失败")?
.into_optimized()
.map_err(Error::OptimizationFailed)?
// .with_context(|| "优化 Tract 模型图失败")?
.into_runnable()
.map_err(Error::RunnableBuildFailed)?;
// .with_context(|| "构建可运行 Tract 实例失败")?;
Ok(session)
}
@@ -80,48 +74,22 @@ impl Loader for ModelLoader {
mod tests {
use super::*;
/// 辅助函数:动态构建一个简单的 ONNX/Tract 内存模型图用于测试
fn create_test_model() -> std::result::Result<Session, anyhow::Error> {
let mut rect = TypedModel::default();
// 0.21.10 最稳妥的静态 Fact 构建
let input_fact = TypedFact::dt_shape(DatumType::F32, &[1, 3, 224, 224]);
let input_node = rect
.add_source("input", input_fact)
.map_err(|e| anyhow::anyhow!("{:?}", e))?;
rect.set_input_outlets(&[input_node.into()])
.map_err(|e| anyhow::anyhow!("{:?}", e))?;
let typed = rect
.into_optimized()
.map_err(|e| anyhow::anyhow!("{:?}", e))?;
let runnable = typed
.into_runnable()
.map_err(|e| anyhow::anyhow!("{:?}", e))?;
Ok(runnable)
#[test]
fn default_config() {
let loader = ModelLoader::default();
assert!(loader.num_threads.is_none());
}
// #[test]
// fn test_input_output_shapes_and_type() {
// let session = create_test_model().expect("建立测试模型图失败");
//
// println!("{:?}", ModelLoader::model_info(&session).unwrap());
// // 1. 测试输入维度解析
// }
//
// #[test]
// fn test_resolve_shape_logic_direct() {
// // 创建一个哑 ModelLoader 实例session 用不上,因为我们直接测私有方法)
// let session = create_test_model().expect("建立测试模型图失败");
//
// let dims: Vec<TDim> = vec![TDim::from(1), TDim::from(3), TDim::from(224)];
// // 方案二的精髓:我们直接利用已导出的 ShapeFact 来纯手工验证边界逻辑!
// // 1. 验证纯静态维度是否被正确还原
// let static_shape = ShapeFact::from_dims(dims);
//
// let res = ModelLoader
// ::resolve_shape(&static_shape);
// }
#[test]
fn builder_configures_threads() {
let loader = ModelLoader::default().num_threads(4);
assert_eq!(loader.num_threads, Some(4));
}
#[test]
fn builder_is_chainable_and_immutable() {
let base = ModelLoader::default();
let _configured = base.clone().num_threads(8);
assert!(base.num_threads.is_none());
}
}

View File

@@ -0,0 +1 @@
pub mod session;

View File

@@ -0,0 +1,174 @@
use crate::runtime::{extract_plain_array, run_session};
use crate::types::Session;
use ddddocr_core::ModelMetadata;
use ddddocr_core::OcrOutput;
use ddddocr_core::error::{DdddError, Result, TensorError};
use ddddocr_core::traits::{InferenceEngine, Info, OcrEngine};
use ddddocr_core::types::{AxisDim, ModelInfo, TensorInfo, TensorType};
use ddddocr_core::utils::normalize_ocr_logits;
use tracing::debug;
use tract_onnx::prelude::{DatumType, IntoTensor, OutletId, ShapeFact, TypedModel};
/// OCR 推理运行时:持有 Tract 会话与模型元数据,输出 [`OcrOutput`]。
pub struct OcrRuntime {
/// Tract 会话句柄。
pub session: Session,
/// 模型元数据(字符集、归一化策略等)。
pub metadata: ModelMetadata,
}
impl OcrRuntime {
/// 基于已构建的会话与元数据创建 OCR 运行时。
pub fn new(session: Session, metadata: ModelMetadata) -> Self {
Self { session, metadata }
}
/// 提取出来的公共转换逻辑:将一组 OutletId 解析为 TensorInfo 列表
fn resolve_tensors(&self, model: &TypedModel, outlets: &[OutletId]) -> Result<Vec<TensorInfo>> {
outlets
.iter()
.map(|&outlet_id| {
let fact = model.outlet_fact(outlet_id).map_err(DdddError::new)?;
let shape = resolve_shape(&fact.shape);
let node_name = model.node(outlet_id.node).name.clone();
let tensor_type = tensor_type_from_datum(fact.datum_type);
Ok(TensorInfo {
name: node_name,
shape,
tensor_type,
})
})
.collect() // 函数式声明:自动传播第一处发生的错误
}
}
/// 将 Tract 的 DatumType 映射为 core 的 [`TensorType`]。
fn tensor_type_from_datum(datum: DatumType) -> TensorType {
match datum {
DatumType::F32 => TensorType::F32,
DatumType::I64 => TensorType::I64,
_ => TensorType::Other,
}
}
/// 安全还原 Tract 维度至 [`AxisDim`] 列表。
fn resolve_shape(shape_fact: &ShapeFact) -> Vec<AxisDim> {
shape_fact
.to_tvec()
.iter()
.map(|dim| {
// 防御性编程:必须同时满足能够转换为 i64 且 大于等于 0
if let Ok(size) = dim.to_i64() {
if size >= 0 {
AxisDim::Static(size as usize)
} else {
// 如果 ONNX 导出时某些动态维度被标记为了 -1安全地作为动态符号捕获
AxisDim::Dynamic(dim.to_string())
}
} else {
AxisDim::Dynamic(dim.to_string())
}
})
.collect()
}
impl OcrEngine for OcrRuntime {
fn metadata(&self) -> &ModelMetadata {
&self.metadata
}
}
impl InferenceEngine for OcrRuntime {
type Output = OcrOutput;
fn inference(&self, input_array: ndarray::Array4<f32>) -> Result<Self::Output, TensorError> {
let mut result = run_session(&self.session, input_array)?;
debug!("模型输出原始数据: {:?}", result);
let raw_tensor = result.swap_remove(0).into_tensor();
match raw_tensor.datum_type() {
DatumType::I64 => {
let array_d = extract_plain_array::<i64>(raw_tensor)?;
let actual_shape = array_d.shape().to_vec();
let array1 = array_d.into_dimensionality::<ndarray::Ix1>().map_err(|_| {
TensorError::DimensionMismatch {
expected: "1D 字符索引静态矩阵".to_string(),
actual: actual_shape,
}
})?;
Ok(OcrOutput::Indices(array1))
}
DatumType::F32 => {
let shape = raw_tensor.shape().to_vec();
debug!("模型输出 shape 数据: {:?}", shape);
let array = extract_plain_array::<f32>(raw_tensor)?;
normalize_ocr_logits(array.view(), array.shape())
}
_ => Err(TensorError::UnknownOutputFormat),
}
}
}
impl Info for OcrRuntime {
fn input_info(&self) -> Result<Vec<TensorInfo>> {
let model = self.session.model();
let outlets = model.input_outlets().map_err(DdddError::new)?;
self.resolve_tensors(model, outlets)
}
/// 获取模型输出的节点信息列表
fn output_info(&self) -> Result<Vec<TensorInfo>> {
let model = self.session.model();
let outlets = model.output_outlets().map_err(DdddError::new)?;
self.resolve_tensors(model, outlets)
}
/// 获取模型详细元数据信息(对标 Python ddddocr 的 get_model_info
/// 完美包容 [1, 1, 64, image_width] 这样的变长图像模型
/// 获取模型详细元数据信息(代码更紧凑、优雅)
fn model_info(&self) -> Result<ModelInfo> {
Ok(ModelInfo {
inputs: self.input_info()?,
outputs: self.output_info()?,
providers: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use tract_onnx::prelude::*;
#[test]
fn maps_datum_types() {
assert!(matches!(
tensor_type_from_datum(DatumType::F32),
TensorType::F32
));
assert!(matches!(
tensor_type_from_datum(DatumType::I64),
TensorType::I64
));
assert!(matches!(
tensor_type_from_datum(DatumType::U8),
TensorType::Other
));
}
#[test]
fn resolves_static_shape() {
let fact = ShapeFact::from_dims(vec![TDim::from(1), TDim::from(64)]);
assert_eq!(
resolve_shape(&fact),
vec![AxisDim::Static(1), AxisDim::Static(64)]
);
}
#[test]
fn resolves_dynamic_dimension_as_symbol() {
let scope = SymbolScope::default();
let dims = vec![TDim::from(1), TDim::from(64), scope.sym("width").into()];
let fact = ShapeFact::from_dims(dims);
let shape = resolve_shape(&fact);
assert_eq!(shape[0], AxisDim::Static(1));
assert_eq!(shape[1], AxisDim::Static(64));
assert!(matches!(&shape[2], AxisDim::Dynamic(_)));
}
}

View File

@@ -0,0 +1,25 @@
//! 会话执行相关的共享工具函数。
use crate::types::Session;
use ddddocr_core::error::TensorError;
use tract_onnx::prelude::{Datum, TValue, TVec, Tensor, tvec};
/// 对输入张量执行一次 Tract 推理,返回原始输出值列表。
pub(crate) fn run_session(
session: &Session,
input_array: ndarray::Array4<f32>,
) -> Result<TVec<TValue>, TensorError> {
let tensor = Tensor::from(input_array);
session
.run(tvec!(tensor.into()))
.map_err(|e| TensorError::Engine(format!("执行模型推理失败: {e}")))
}
/// 将 Tensor 转换为 ndarray 的 `ArrayD`。
pub(crate) fn extract_plain_array<D: Datum>(
tensor: Tensor,
) -> Result<ndarray::ArrayD<D>, TensorError> {
tensor
.into_plain_array::<D>()
.map_err(|_| TensorError::Engine("无法获取张量内存视图".to_string()))
}

View File

@@ -0,0 +1,7 @@
//! Tract 会话共享类型。
use std::sync::Arc;
use tract_onnx::prelude::TypedRunnableModel;
/// Tract 会话句柄:由 [`crate::loader::ModelLoader`] 构建,可直接并发执行推理。
pub type Session = Arc<TypedRunnableModel>;

View File

@@ -0,0 +1,74 @@
//! 外部视角 API 测试:验证 `ddddocr-tract2` 的公开类型与方法在外部 crate 中可正常使用。
use ddddocr_core::Resize;
use ddddocr_core::traits::{Info, Loader};
use ddddocr_core::types::AxisDim;
use ddddocr_tract2::loader::ModelLoader;
use ddddocr_tract2::{
DetRuntime, ModelMetadata, Normalization, OcrBuilder, OcrRuntime, Session, SlideResult, Slider,
};
use std::path::{Path, PathBuf};
fn model_path(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("models")
.join(name)
}
/// 验证对外导出的类型(含 `Session` 与 core 便捷重导出)均可直接命名。
#[test]
fn public_types_are_nameable() {
let _: Option<Session> = None;
let _: Option<OcrBuilder> = None;
let _: Option<SlideResult> = None;
let _slider = Slider::new();
}
/// 验证构建器链式 API 可组合使用,且返回的会话类型可显式标注。
#[test]
fn loader_chain_builds_session() -> anyhow::Result<()> {
let path = model_path("common_sml2h3_f32.onnx");
assert!(path.exists(), "缺少测试模型: {}", path.display());
let _session: Session = ModelLoader::default().num_threads(4).build_for_path(path)?;
Ok(())
}
/// 验证 `Info` trait 能从真实会话中解析输入/输出信息。
#[test]
fn info_trait_returns_model_metadata() -> anyhow::Result<()> {
let path = model_path("common_sml2h3_f32.onnx");
assert!(path.exists(), "缺少测试模型: {}", path.display());
let session: Session = ModelLoader::default().build_for_path(path)?;
let metadata = ModelMetadata::from_static_slice(
&["a", "b"],
false,
Resize::DynamicWidth(64),
1,
Normalization::MinusOneToOne,
);
let ocr = OcrRuntime::new(session, metadata);
let inputs = ocr.input_info()?;
let outputs = ocr.output_info()?;
assert!(!inputs.is_empty());
assert!(!outputs.is_empty());
assert!(matches!(inputs[0].shape[0], AxisDim::Static(1)));
assert!(matches!(inputs[0].shape[2], AxisDim::Static(64)));
let model_info = ocr.model_info()?;
assert_eq!(model_info.inputs.len(), inputs.len());
assert_eq!(model_info.outputs.len(), outputs.len());
assert!(model_info.providers.is_none());
Ok(())
}
/// 验证检测运行时可以从构建的会话创建。
#[test]
fn det_runtime_builds_from_session() -> anyhow::Result<()> {
let path = model_path("common_det.onnx");
assert!(path.exists(), "缺少测试模型: {}", path.display());
let session: Session = ModelLoader::default().build_for_path(path)?;
let _det = DetRuntime::new(session);
Ok(())
}

View File

@@ -0,0 +1,25 @@
//! 集成测试共享工具。
use std::path::{Path, PathBuf};
/// 仓库根目录 `models/` 下模型文件的路径。
pub fn model_path(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("models")
.join(name)
}
/// 仓库根目录 `samples/` 下样例图片的路径。
pub fn sample_path(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("samples")
.join(name)
}
/// 加载图片,失败时附带路径上下文。
pub fn load_image<P: AsRef<Path>>(path: P) -> anyhow::Result<image::DynamicImage> {
let path_ref = path.as_ref();
image::open(path_ref).map_err(|e| anyhow::anyhow!("无法加载图片 {:?}: {}", path_ref, e))
}

View File

@@ -0,0 +1,33 @@
//! 目标检测集成测试。
mod common;
use common::{model_path, sample_path};
use ddddocr_core::Detector;
use ddddocr_core::traits::Loader;
use ddddocr_tract2::DetRuntime;
use ddddocr_tract2::loader::ModelLoader;
use image::GenericImageView;
/// 检测模型应能从样例图片中找到至少一个目标,且坐标在图片范围内。
#[test]
fn det_model_detects_targets_in_image() -> anyhow::Result<()> {
let session = ModelLoader::default()
.build_for_path(model_path("common_det.onnx"))
.expect("模型加载失败");
let det = DetRuntime::new(session);
let img = image::open(sample_path("det1.png")).expect("测试图片不存在");
let bboxes = Detector::new(&det).predict(&img)?;
assert!(!bboxes.is_empty(), "应检测到至少一个目标");
let (width, height) = img.dimensions();
for bbox in &bboxes {
assert!(bbox.x1 >= 0 && bbox.y1 >= 0, "检测框左上角不应为负");
assert!(
bbox.x2 <= width as i32 && bbox.y2 <= height as i32,
"检测框右下角不应超出图片范围"
);
}
Ok(())
}

View File

@@ -0,0 +1,55 @@
//! OCR 识别与模型信息集成测试。
mod common;
use common::{model_path, sample_path};
use ddddocr_core::traits::{Info, Loader};
use ddddocr_core::{ModelMetadata, Normalization, Ocr, Resize};
use ddddocr_tract2::OcrRuntime;
use ddddocr_tract2::loader::ModelLoader;
/// 用官方 sml2h3 f32 模型识别验证码图片,结果不应为空。
#[test]
fn ocr_classification_recognizes_code_image() {
let session = ModelLoader::default()
.build_for_path(model_path("common_sml2h3_f32.onnx"))
.expect("模型加载失败");
let metadata = ModelMetadata::from_builtin_beta(
false,
Resize::DynamicWidth(64),
1,
Normalization::MinusOneToOne,
);
let ocr = OcrRuntime::new(session, metadata);
let img = image::open(sample_path("code2.png")).expect("测试图片不存在");
let text = Ocr::builder()
.build_with(&ocr)
.predict(&img)
.expect("识别过程出错")
.into_text();
println!("识别结果: {text}");
assert!(!text.is_empty(), "识别结果不应为空");
}
/// 真实模型应能通过 `Info` trait 返回输入/输出张量信息。
#[test]
fn model_info_lists_inputs_and_outputs() -> anyhow::Result<()> {
let session = ModelLoader::default()
.build_for_path(model_path("common_huashi666_i64.onnx"))
.expect("建立测试模型图失败");
let metadata = ModelMetadata::from_builtin_beta(
false,
Resize::DynamicWidth(64),
1,
Normalization::MinusOneToOne,
);
let ocr = OcrRuntime::new(session, metadata);
let inputs = ocr.input_info()?;
let outputs = ocr.output_info()?;
assert!(!inputs.is_empty(), "模型应有输入张量信息");
assert!(!outputs.is_empty(), "模型应有输出张量信息");
Ok(())
}

View File

@@ -0,0 +1,52 @@
//! 滑块匹配集成测试。
mod common;
use common::{load_image, sample_path};
use ddddocr_core::Slider;
/// 边缘模式匹配应定位到预期坐标。
#[test]
fn slide_match_locates_target_position() {
let engine = Slider::new();
let target = load_image(sample_path("target1.png")).expect("请确保 samples/target1.png 存在");
let background = load_image(sample_path("background1.png")).expect("请确保 samples/background1.png 存在");
let start = std::time::Instant::now();
let result = engine
.slide_match(&target, &background, false)
.expect("Slide match 执行失败");
let elapsed = start.elapsed();
println!("边缘模式匹配: {result}");
println!("耗时: {elapsed:?}");
assert_eq!(result.target_x, 237);
assert_eq!(result.target_y, 77);
assert!(result.confidence > 0.0);
}
/// 灰度对比匹配应定位到预期坐标。
#[test]
fn slide_comparison_locates_target_position() {
let engine = Slider::new();
let target = load_image(sample_path("target2.jpg")).expect("请确保 samples/target2.jpg 存在");
let background =
load_image(sample_path("background2.jpg")).expect("请确保 samples/background2.jpg 存在");
let start = std::time::Instant::now();
let result = engine
.slide_comparison(&target, &background)
.expect("Slide comparison 执行失败");
let elapsed = start.elapsed();
println!(
"灰度对比匹配: 坐标 [x: {}, y: {}], 置信度 {:.4}",
result.target_x, result.target_y, result.confidence
);
println!("耗时: {elapsed:?}");
assert_eq!(result.target_x, 171);
assert_eq!(result.target_y, 90);
assert!(result.confidence > 0.0);
}

View File

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB