Compare commits
14 Commits
feature-v0
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 00e8ab5308 | |||
| fe61895926 | |||
| 1362243f4e | |||
| 0bddaeba24 | |||
| 84cc97b201 | |||
| a3c4614574 | |||
| 7d159c5702 | |||
| 44dae08221 | |||
| 3499e89bf1 | |||
| 913ff4d884 | |||
| 4f6987f594 | |||
| cd70748393 | |||
| 4fd38022fd | |||
| ea7fb43a14 |
31
Cargo.toml
@@ -1,20 +1,29 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "ddddocr-rs"
|
resolver = "2"
|
||||||
version = "0.1.0"
|
members = [
|
||||||
|
"ddddocr-core", "ddddocr-ort",
|
||||||
|
"ddddocr-tract2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.2.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
tract-onnx = { version = "0.21.10" }
|
[workspace.dependencies]
|
||||||
anyhow = "1.0.102"
|
tract-onnx = "0.23.4"
|
||||||
|
tract-linalg = { version = "0.23.4",features = ["multithread-mm"]}
|
||||||
|
ort = "2.0.0-rc.12"
|
||||||
|
ndarray = "0.17.2"
|
||||||
|
|
||||||
image = "0.25.10"
|
image = "0.25.10"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
imageproc = { version = "0.26.2", default-features = true }
|
imageproc = { version = "0.26.2", default-features = true }
|
||||||
|
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.150"
|
serde_json = "1.0.150"
|
||||||
ndarray="0.16.1"
|
|
||||||
|
|
||||||
|
anyhow = "1.0.102"
|
||||||
[features]
|
thiserror = "1.0" # 刚好可以开始接入你需要的标准库错误处理
|
||||||
default = []
|
tracing = "0.1.44" # 埋入日志打点(后续需要继续优化,现在只是简单尝试)
|
||||||
embed-models = [] # 这是一个留给有特殊需求、且自己下载了模型放入 models/ 目录的人的后门
|
|
||||||
|
|||||||
2
NOTICE
@@ -4,3 +4,5 @@ Copyright 2026 CNWei
|
|||||||
This product includes software developed by:
|
This product includes software developed by:
|
||||||
- sml2h3 (ddddocr) - Model weights and original logic.
|
- 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.
|
||||||
|
|||||||
142
README.md
@@ -1,45 +1,135 @@
|
|||||||
# ddddocr-rs
|
# ddddocr-rs
|
||||||
|
|
||||||
带带弟弟 OCR (ddddocr) 的 Rust 移植版。高性能、低占用,支持多种验证码识别与检测。
|
[带带弟弟 OCR(ddddocr)](https://github.com/sml2h3/ddddocr) 的 Rust 移植版:提供验证码 OCR 识别、目标检测与滑块匹配能力。核心库与推理引擎解耦,同一套 API 可切换 [Tract](https://github.com/sonos/tract) 或 [ONNX Runtime](https://onnxruntime.ai/) 后端。
|
||||||
|
|
||||||
🧩 滑块识别算法核心知识点总结
|
## 架构
|
||||||
本项目实现了两种核心匹配模式,其底层逻辑与 OpenCV 的对齐情况如下:
|
|
||||||
|
|
||||||
1. 匹配模式对比 (Match Modes)
|
项目为 Cargo workspace,包含三个 crate:
|
||||||
|**模式**|**算法原理**|**适用场景**|**备注**|
|
|
||||||
|
| crate | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `ddddocr-core` | 引擎无关的核心库:OCR / 目标检测 / 滑块匹配、模型元数据与图像预处理 |
|
||||||
|
| `ddddocr-ort` | ONNX Runtime 推理后端(可选 `cuda` feature 启用 GPU 加速) |
|
||||||
|
| `ddddocr-tract2` | Tract(纯 Rust)推理后端 |
|
||||||
|
|
||||||
|
`ddddocr-core` 通过 `traits::InferenceEngine` / `OcrEngine` / `DetEngine` 抽象推理能力,由 `ddddocr-ort` / `ddddocr-tract2` 实现,业务代码只依赖核心库接口即可。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
在 Cargo.toml 中添加:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
ddddocr-core = "0.2"
|
||||||
|
ddddocr-ort = "0.2" # 或 ddddocr-tract2 = "0.2"
|
||||||
|
```
|
||||||
|
|
||||||
|
核心库自带一个不依赖真实模型的演示示例(用假引擎演示完整调用链):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p ddddocr-core --example quick_start
|
||||||
|
```
|
||||||
|
|
||||||
|
真实推理需要自行准备 ONNX 模型文件(crate 包内不包含模型)。以 ORT 后端为例:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use ddddocr_core::traits::{Info, Loader};
|
||||||
|
use ddddocr_core::{ModelMetadata, Normalization, Ocr, Resize};
|
||||||
|
|
||||||
|
// 1. 构建会话(等价写法:ddddocr_tract2::loader::ModelLoader)
|
||||||
|
let session = ddddocr_ort::loader::ModelLoader::default()
|
||||||
|
.build_for_path("models/common.onnx")?;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
```
|
||||||
|
|
||||||
|
目标检测与滑块匹配类似:`Detector::new(&engine).predict(&image)` 返回检测框列表;`Slider::new().slide_match(target, background, simple_target)` 返回匹配坐标与置信度。
|
||||||
|
|
||||||
|
## 模型下载
|
||||||
|
|
||||||
|
OCR / 目标检测需要 ONNX 模型文件。官方模型位于 [ddddocr 仓库 `ddddocr/` 目录](https://github.com/sml2h3/ddddocr/blob/master/ddddocr/),下载后放入仓库根目录的 `models/` 文件夹:
|
||||||
|
|
||||||
|
| 文件 | 大小 | 用途 | 直链 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|**边缘模式** (Edge-based)|基于 **Canny 边缘检测** 提取轮廓后再进行匹配。|**推荐方案**
|
| `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) |
|
||||||
|**简单模式** (Simple/Gray)|直接基于 **灰度像素值** 进行归一化互相关计算。|适用于无明显边缘、靠颜色差异识别的场景。|对背景和透明边框敏感,可能存在重心偏移。|
|
| `common_old.onnx` | 约 13 MB | 旧版 OCR 模型 | [下载](https://raw.githubusercontent.com/sml2h3/ddddocr/master/ddddocr/common_old.onnx) |
|
||||||
|
|
||||||
2. 数学公式差异 (NCC vs. CCOEFF)
|
> 注意:`ddddocr-tract2` 不支持旧版模型 `common_old.onnx`,请使用 `common.onnx`。
|
||||||
在简单模式下,本项目采用的是 归一化互相关 (NCC),对应 OpenCV 中的 TM_CCORR_NORMED。
|
>
|
||||||
|
> 字符集配对:`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` 同源),不在上述官方目录中,需自行获取。
|
||||||
|
|
||||||
逻辑对齐:Rust 的 match_template 结果与 Python cv2.TM_CCORR_NORMED 完全一致。
|
## 字符集与内置默认值
|
||||||
|
|
||||||
关于偏移:若拼图原始图片(Target)四周包含大量的透明留白:
|
OCR 模型的字符集(token 列表)是模型的一部分:字符 `i` 对应模型输出 logits 的第 `i` 列,必须与模型训练时一致,否则识别结果会错位。
|
||||||
|
|
||||||
CCORR (本项目):会将留白视为图像的一部分,计算出的是整张图片框的中心。
|
字符集有两种提供方式:
|
||||||
|
|
||||||
CCOEFF (OpenCV 默认):会自动进行“均值中心化”,在一定程度上能削弱留白的影响。
|
- 通过模型元数据 JSON 的 `charset` 字段(`Metadata::from_json_str` / `from_json_bytes` 自动解析);
|
||||||
|
- 代码内显式指定:`ModelMetadata::from_static_slice(&["", "a", "b"], ...)` 或 `Charset::new(...)`。
|
||||||
|
|
||||||
最佳实践:若发现坐标有固定位移,建议优先切换至 边缘模式,或对滑块图进行 Bounding Box 裁剪 后再匹配。
|
`ddddocr-core` 内置官方模型的默认字符集(旧版 `CHARSET_OLD` 与 Beta `CHARSET_BETA`),作为免配置的快速通道。字符集数据独立存放在 `ddddocr-core/src/ocr/builtin.rs` 私有模块中(与业务逻辑分离),通过 `ModelMetadata` 的公开构造方法使用:
|
||||||
|
|
||||||
3. 图像预处理一致性
|
```rust
|
||||||
|
use ddddocr_core::{ModelMetadata, Normalization, Resize};
|
||||||
|
|
||||||
为确保识别精度,本项目在 Rust 中完美复刻了 Python OpenCV 的预处理链路:
|
// 官方旧版模型配套
|
||||||
|
let meta = ModelMetadata::from_builtin_old(
|
||||||
|
false,
|
||||||
|
Resize::DynamicWidth(64),
|
||||||
|
1,
|
||||||
|
Normalization::ZeroToOne,
|
||||||
|
);
|
||||||
|
|
||||||
- **灰度化权重**:采用 OpenCV 标准感光公式 $0.299R + 0.587G + 0.114B$。
|
// 官方 Beta 模型配套
|
||||||
|
let meta = ModelMetadata::from_builtin_beta(
|
||||||
|
false,
|
||||||
|
Resize::DynamicWidth(64),
|
||||||
|
1,
|
||||||
|
Normalization::MinusOneToOne,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
- **Alpha 处理**:在将 PNG 转为 RGB 时,自动将透明区域填充为黑色,确保与 PIL (Python Imaging Library) 行为一致。
|
数据内置于代码,不依赖外部文件。若使用自有模型,请仍以元数据 JSON 或 `from_static_slice` 指定匹配的字符集。
|
||||||
|
|
||||||
- **坐标定义**:所有返回坐标均为匹配区域的 **几何中心点** $(x + w/2, y + h/2)$。
|
## 滑块匹配核心知识点
|
||||||
|
|
||||||
💡 开发者建议:
|
项目实现两种匹配模式,底层逻辑与 OpenCV 对齐:
|
||||||
|
|
||||||
如果识别结果在 $X$ 轴上有大约 $10px$ 左右的固定误差,通常是因为滑块原图自带了透明边距(留白)。此时请确保
|
| 模式 | 算法原理 | 适用场景 | 备注 |
|
||||||
simple_target=false。该模式会通过 Canny 边缘检测 提取轮廓特征,能自动锁定拼图实体并忽略背景留白的像素干扰。
|
|---|---|---|---|
|
||||||
鸣谢 (Credits)
|
| 边缘模式(Edge-based) | 基于 Canny 边缘检测提取轮廓后匹配 | **推荐方案**,适用于绝大多数拼图滑块 | 天然免疫拼图周边透明/黑色留白干扰,坐标最精准 |
|
||||||
|
| 简单模式(Simple/Gray) | 基于灰度像素值做归一化互相关(NCC) | 无明显边缘、靠颜色差异识别的场景 | 对背景和透明边框敏感,可能存在重心偏移 |
|
||||||
|
|
||||||
- 本项目是 [ddddocr](https://github.com/sml2h3/ddddocr) 的 Rust 移植版本,原作者为 sml2h3。衷心感谢原作者对 OCR 社区做出的杰出贡献。
|
关键点:
|
||||||
- 推理引擎基于 [tract (Sonos)](https://github.com/sonos/tract)。感谢其为 Rust 生态提供的轻量级推理方案。
|
|
||||||
|
- 简单模式采用归一化互相关(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`。
|
||||||
|
|||||||
18
ddddocr-core/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
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 }
|
||||||
|
base64 = { workspace = true }
|
||||||
|
image = { workspace = true }
|
||||||
|
imageproc = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
63
ddddocr-core/examples/quick_start.rs
Normal 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}");
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
//! 目标检测模块:检测器构建器与执行入口。
|
||||||
|
|
||||||
mod builder;
|
mod builder;
|
||||||
mod executor;
|
mod executor;
|
||||||
mod session;
|
|
||||||
|
|
||||||
pub use builder::DetBuilder;
|
pub use builder::DetBuilder;
|
||||||
pub use executor::{DetectionResult, Detector};
|
pub use executor::{DetectionResult, Detector};
|
||||||
pub use session::DetSession;
|
|
||||||
15
ddddocr-core/src/det/builder.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
//! 检测器构建器。
|
||||||
|
|
||||||
|
use crate::det::executor::Detector;
|
||||||
|
use crate::traits::DetEngine;
|
||||||
|
|
||||||
|
/// 检测器构建器,通过 [`crate::Detector::builder`] 创建。
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct DetBuilder;
|
||||||
|
|
||||||
|
impl DetBuilder {
|
||||||
|
/// 绑定检测引擎会话并构建 Detector。
|
||||||
|
pub fn build_with<E: DetEngine>(self, runtime: &E) -> Detector<'_> {
|
||||||
|
Detector { runtime }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,27 @@
|
|||||||
use anyhow::{Context, Result};
|
//! 目标检测执行器:检测后处理与预测入口。
|
||||||
use image::{imageops::FilterType, DynamicImage, GenericImageView};
|
|
||||||
|
use crate::error::{Result, TensorError};
|
||||||
|
use image::{DynamicImage, GenericImageView, imageops::FilterType};
|
||||||
|
use ndarray::{Array2, Array3, Array4, Axis, prelude::*, s};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use tract_onnx::prelude::tract_ndarray::{prelude::*, s, Array2, Array3, Array4, Axis};
|
|
||||||
use tract_onnx::prelude::{Tensor};
|
|
||||||
|
|
||||||
|
use crate::traits::DetEngine;
|
||||||
|
use crate::{DetBuilder, DetOutput};
|
||||||
|
|
||||||
use crate::models::det::session::DetSession;
|
/// 目标检测结果:原图像素坐标系下的边界框、置信度与类别 ID。
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct DetectionResult {
|
pub struct DetectionResult {
|
||||||
|
/// 左上角 x 坐标。
|
||||||
pub x1: i32,
|
pub x1: i32,
|
||||||
|
/// 左上角 y 坐标。
|
||||||
pub y1: i32,
|
pub y1: i32,
|
||||||
|
/// 右下角 x 坐标。
|
||||||
pub x2: i32,
|
pub x2: i32,
|
||||||
|
/// 右下角 y 坐标。
|
||||||
pub y2: i32,
|
pub y2: i32,
|
||||||
|
/// 置信度。
|
||||||
pub score: f32,
|
pub score: f32,
|
||||||
|
/// 类别 ID。
|
||||||
pub class_id: u32,
|
pub class_id: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,30 +36,28 @@ impl fmt::Display for DetectionResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
/// 目标检测器:对输入图像执行检测并返回结果。
|
||||||
pub struct Detector<'a> {
|
pub struct Detector<'a> {
|
||||||
pub(crate) session: &'a DetSession,
|
pub(crate) runtime: &'a dyn DetEngine,
|
||||||
#[allow(dead_code)]
|
|
||||||
pub(crate) use_gpu: bool,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub(crate) device_id: u8,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Detector<'a> {
|
impl<'a> Detector<'a> {
|
||||||
pub fn new(session: &'a DetSession) -> Self {
|
/// 绑定检测引擎会话创建检测器。
|
||||||
Detector {
|
pub fn new(runtime: &'a dyn DetEngine) -> Self {
|
||||||
session,
|
Detector { runtime }
|
||||||
use_gpu: false,
|
}
|
||||||
device_id: 0,
|
/// 创建检测器构建器。
|
||||||
|
pub fn builder() -> DetBuilder {
|
||||||
|
DetBuilder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
impl<'a> Detector<'a> {
|
||||||
|
/// 对输入图像执行目标检测,返回检测框列表。
|
||||||
pub fn predict(&self, image: &DynamicImage) -> Result<Vec<DetectionResult>> {
|
pub fn predict(&self, image: &DynamicImage) -> Result<Vec<DetectionResult>> {
|
||||||
// Rust 中通常在调用层处理文件/PIL转换,这里直接进入核心逻辑
|
// Rust 中通常在调用层处理文件/PIL转换,这里直接进入核心逻辑
|
||||||
self.get_bbox(image)
|
Ok(self.get_bbox(image)?)
|
||||||
}
|
}
|
||||||
/// 2. preproc: 纯 Rust 实现 (替代 OpenCV)
|
fn preproc(&self, image: &DynamicImage, input_size: (u32, u32)) -> (Array4<f32>, f32) {
|
||||||
fn preproc(&self, image: &DynamicImage, input_size: (u32, u32)) -> Result<(Tensor, f32)> {
|
|
||||||
let (target_h, target_w) = input_size;
|
let (target_h, target_w) = input_size;
|
||||||
let (img_w, img_h) = image.dimensions();
|
let (img_w, img_h) = image.dimensions();
|
||||||
|
|
||||||
@@ -89,10 +95,9 @@ impl<'a> Detector<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((array.into(), r))
|
(array, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 3. demo_postprocess (逻辑与 Python 一致)
|
|
||||||
fn demo_postprocess(&self, mut outputs: Array3<f32>, img_size: (i32, i32)) -> Array3<f32> {
|
fn demo_postprocess(&self, mut outputs: Array3<f32>, img_size: (i32, i32)) -> Array3<f32> {
|
||||||
let strides = [8, 16, 32];
|
let strides = [8, 16, 32];
|
||||||
|
|
||||||
@@ -128,7 +133,6 @@ impl<'a> Detector<'a> {
|
|||||||
outputs
|
outputs
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 4. nms
|
|
||||||
fn nms(&self, boxes: &Array2<f32>, scores: &Array1<f32>, nms_thr: f32) -> Vec<usize> {
|
fn nms(&self, boxes: &Array2<f32>, scores: &Array1<f32>, nms_thr: f32) -> Vec<usize> {
|
||||||
let mut keep = Vec::new();
|
let mut keep = Vec::new();
|
||||||
let x1 = boxes.column(0);
|
let x1 = boxes.column(0);
|
||||||
@@ -188,8 +192,7 @@ impl<'a> Detector<'a> {
|
|||||||
keep
|
keep
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 5. multiclass_nms
|
/// 多类别 NMS 后处理:按分数阈值筛选候选框,并用 NMS 阈值去重。
|
||||||
//multiclass_nms_class_agnostic
|
|
||||||
pub fn multiclass_nms(
|
pub fn multiclass_nms(
|
||||||
&self,
|
&self,
|
||||||
boxes: &Array2<f32>, // [25200, 4] -> xyxy 格式
|
boxes: &Array2<f32>, // [25200, 4] -> xyxy 格式
|
||||||
@@ -246,22 +249,20 @@ impl<'a> Detector<'a> {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
/// 6. get_bbox (完全解耦 OpenCV)
|
/// 对图像执行完整检测流程(预处理、推理、后处理),返回像素坐标系下的检测框。
|
||||||
pub fn get_bbox(&self, dynamic_img: &DynamicImage) -> Result<Vec<DetectionResult>> {
|
pub fn get_bbox(
|
||||||
|
&self,
|
||||||
|
dynamic_img: &DynamicImage,
|
||||||
|
) -> Result<Vec<DetectionResult>, TensorError> {
|
||||||
// 使用 utils crate 解码
|
// 使用 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 (orig_w, orig_h) = dynamic_img.dimensions();
|
||||||
|
|
||||||
let (input_tensor, ratio) = self.preproc(dynamic_img, (416, 416))?;
|
let (input_tensor, ratio) = self.preproc(dynamic_img, (416, 416));
|
||||||
|
|
||||||
// tract 推理
|
// tract 推理
|
||||||
// let outputs = self.session.session.run(tvec!(input_tensor.into()))?;
|
let outputs = self.runtime.inference(input_tensor)?;
|
||||||
let outputs = self.session.inference(input_tensor)?;
|
// 2. 无缝、安全地解包出标准 3维 矩阵
|
||||||
// let output_array = outputs[0]
|
let DetOutput::Detection(output_array) = outputs;
|
||||||
let output_array = outputs
|
|
||||||
.to_array_view::<f32>()?
|
|
||||||
.to_owned()
|
|
||||||
.into_dimensionality::<Ix3>()?;
|
|
||||||
|
|
||||||
let predictions = self.demo_postprocess(output_array, (416, 416));
|
let predictions = self.demo_postprocess(output_array, (416, 416));
|
||||||
let pred = predictions.slice(s![0, .., ..]);
|
let pred = predictions.slice(s![0, .., ..]);
|
||||||
@@ -269,11 +270,14 @@ impl<'a> Detector<'a> {
|
|||||||
let boxes = pred.slice(s![.., 0..4]);
|
let boxes = pred.slice(s![.., 0..4]);
|
||||||
let obj_conf = pred.slice(s![.., 4..5]);
|
let obj_conf = pred.slice(s![.., 4..5]);
|
||||||
let cls_conf = pred.slice(s![.., 5..]);
|
let cls_conf = pred.slice(s![.., 5..]);
|
||||||
let obj_broadcast = obj_conf
|
let obj_broadcast =
|
||||||
|
obj_conf
|
||||||
.broadcast(cls_conf.dim())
|
.broadcast(cls_conf.dim())
|
||||||
.context("ndarray broadcasting failed for scores calculation")?;
|
.ok_or_else(|| TensorError::DimensionMismatch {
|
||||||
|
expected: format!("可广播至 cls_conf 形状 {:?}", cls_conf.shape()),
|
||||||
|
actual: obj_conf.shape().to_vec(),
|
||||||
|
})?;
|
||||||
let scores = &obj_broadcast * &cls_conf;
|
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());
|
let mut boxes_xyxy = Array2::<f32>::zeros(boxes.raw_dim());
|
||||||
for i in 0..boxes.nrows() {
|
for i in 0..boxes.nrows() {
|
||||||
191
ddddocr-core/src/error.rs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
//! 分层错误类型:预处理、推理、解码三阶段的强类型错误。
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// 全局统一的 `Result` 别名,默认错误类型为 [`DdddError`]。
|
||||||
|
pub type Result<T, E = DdddError> = std::result::Result<T, E>;
|
||||||
|
|
||||||
|
/// 顶层错误类型,聚合本库各阶段错误。
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum DdddError {
|
||||||
|
/// 图像预处理阶段异常。
|
||||||
|
#[error("图像预处理失败: {0}")]
|
||||||
|
Preprocess(#[from] ImagePreprocessError),
|
||||||
|
|
||||||
|
/// 推理与张量操作阶段异常。
|
||||||
|
#[error("推理与模型输入/输出张量异常: {0}")]
|
||||||
|
Inference(#[from] TensorError),
|
||||||
|
|
||||||
|
/// 后处理解码阶段异常。
|
||||||
|
#[error("后处理解码错误: {0}")]
|
||||||
|
Decode(#[from] DecodeError),
|
||||||
|
|
||||||
|
/// 框架内部不可恢复的逻辑断言错误(如解析节点 Fact 失败)。
|
||||||
|
#[error("内部严重逻辑错误: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
|
||||||
|
/// 用户自定义扩展错误,用于包装第三方插件产生的错误。
|
||||||
|
#[error("用户自定义扩展错误: {0}")]
|
||||||
|
Other(#[source] Box<dyn std::error::Error + Send + Sync>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 图像预处理阶段错误类型。
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum ImagePreprocessError {
|
||||||
|
/// ndarray 基础操作失败。
|
||||||
|
#[error("图片转矩阵(ndarray)基础操作失败: {0}")]
|
||||||
|
Ndarray(#[from] ndarray::ShapeError),
|
||||||
|
|
||||||
|
/// 图像矩阵维度不合规。
|
||||||
|
#[error("图像矩阵维度不合规!预期: {expected},实际图像形状: {actual:?}")]
|
||||||
|
InvalidDimensions {
|
||||||
|
/// 期望的维度描述。
|
||||||
|
expected: String,
|
||||||
|
/// 实际的图像形状。
|
||||||
|
actual: Vec<usize>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// 图像缓冲区长度与分辨率/通道数不匹配。
|
||||||
|
#[error(
|
||||||
|
"图像缓冲区长度不匹配!预期大小: {expected},实际大小: {actual} (分辨率: {width}x{height}, 通道数: {channels})"
|
||||||
|
)]
|
||||||
|
BufferLengthMismatch {
|
||||||
|
/// 期望的缓冲区长度。
|
||||||
|
expected: usize,
|
||||||
|
/// 实际的缓冲区长度。
|
||||||
|
actual: usize,
|
||||||
|
/// 图像宽度。
|
||||||
|
width: u32,
|
||||||
|
/// 图像高度。
|
||||||
|
height: u32,
|
||||||
|
/// 图像通道数。
|
||||||
|
channels: usize,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// 不支持的图像通道数。
|
||||||
|
#[error("不支持的图像通道数: {0} (仅支持单通道灰度L、3通道RGB、4通道RGBA)")]
|
||||||
|
UnsupportedChannels(usize),
|
||||||
|
|
||||||
|
/// HSV 颜色区间参数非法。
|
||||||
|
#[error("HSV 颜色区间参数非法: {0}")]
|
||||||
|
InvalidHsvRange(String),
|
||||||
|
|
||||||
|
/// 未知的颜色预设名称。
|
||||||
|
#[error("不支持的颜色预设名称: {0}")]
|
||||||
|
UnknownColorPreset(String),
|
||||||
|
|
||||||
|
/// 颜色过滤器配置无效。
|
||||||
|
#[error("颜色过滤器配置无效或初始化失败: {0}")]
|
||||||
|
FilterConfigInvalid(String),
|
||||||
|
|
||||||
|
/// 图像维度不匹配。
|
||||||
|
#[error("图像维度不匹配!{0}")]
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 推理与张量操作阶段错误类型。
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum TensorError {
|
||||||
|
/// 推理引擎内部异常。
|
||||||
|
#[error("推理引擎内部发生异常: {0}")]
|
||||||
|
Engine(String),
|
||||||
|
|
||||||
|
/// 模型张量维度不匹配。
|
||||||
|
#[error("模型张量维度不匹配!预期: {expected},实际 Tensor 形状: {actual:?}")]
|
||||||
|
DimensionMismatch {
|
||||||
|
/// 期望的维度描述。
|
||||||
|
expected: String,
|
||||||
|
/// 实际的 Tensor 形状。
|
||||||
|
actual: Vec<usize>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// OCR Logits 矩阵变形失败。
|
||||||
|
#[error("OCR Logits 矩阵变形失败: {0}")]
|
||||||
|
LogitsDimensionMismatch(#[from] ndarray::ShapeError),
|
||||||
|
|
||||||
|
/// 张量内存不连续。
|
||||||
|
#[error("内存不连续,无法执行零拷贝操作")]
|
||||||
|
NonContiguousMemory,
|
||||||
|
|
||||||
|
/// 未知的模型输出格式。
|
||||||
|
#[error("未知的模型输出格式")]
|
||||||
|
UnknownOutputFormat,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 算法解码阶段错误类型。
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum DecodeError {
|
||||||
|
/// CTC 解码异常。
|
||||||
|
#[error("CTC 解码异常: {0}")]
|
||||||
|
Ctc(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DdddError {
|
||||||
|
/// 手动包装任意第三方错误为 [`DdddError::Other`]。
|
||||||
|
pub fn new<E>(error: E) -> Self
|
||||||
|
where
|
||||||
|
E: Into<Box<dyn std::error::Error + Send + Sync>>,
|
||||||
|
{
|
||||||
|
DdddError::Other(error.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否为图片维度不合规错误。
|
||||||
|
pub fn is_invalid_dimensions(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
DdddError::Preprocess(ImagePreprocessError::InvalidDimensions { .. })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否因通道数不合规而失败。
|
||||||
|
pub fn is_unsupported_channels(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
DdddError::Preprocess(ImagePreprocessError::UnsupportedChannels(_))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
43
ddddocr-core/src/lib.rs
Normal file
@@ -0,0 +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 traits;
|
||||||
|
/// 模型输入输出信息等共享类型。
|
||||||
|
pub mod types;
|
||||||
|
/// 图像加载、转换与处理工具。
|
||||||
|
pub mod utils;
|
||||||
|
|
||||||
|
pub use crate::det::{DetBuilder, DetectionResult, Detector};
|
||||||
|
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};
|
||||||
|
|
||||||
|
/// OCR 模型的统一输出枚举,由推理引擎产出,供 [`Ocr`] 后处理。
|
||||||
|
pub enum OcrOutput {
|
||||||
|
/// 索引序列输出(CTC 解码输入)。
|
||||||
|
Indices(ndarray::Array1<i64>),
|
||||||
|
/// Logits 矩阵输出 `[Steps, Classes]`。
|
||||||
|
Logits(ndarray::Array2<f32>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 目标检测模型的统一输出枚举,由推理引擎产出,供 [`Detector`] 后处理。
|
||||||
|
pub enum DetOutput {
|
||||||
|
/// 原始检测输出张量。
|
||||||
|
Detection(ndarray::Array3<f32>),
|
||||||
|
}
|
||||||
16
ddddocr-core/src/ocr.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
//! OCR 模块:识别器构建器、执行入口及元数据、字符集等类型。
|
||||||
|
|
||||||
|
mod builder;
|
||||||
|
mod builtin;
|
||||||
|
mod charset;
|
||||||
|
mod color_filter;
|
||||||
|
mod executor;
|
||||||
|
mod metadata;
|
||||||
|
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::{CharRestrict, IdRestrict, MultiOrRestrict, TokenFilter, ValidationCtx};
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
use crate::models::ocr::executor::Ocr;
|
//! OCR 构建器。
|
||||||
use crate::models::ocr::session::OcrSession;
|
|
||||||
use crate::models::ocr::color_filter::ColorFilter;
|
|
||||||
use crate::models::ocr::token_filter::TokenFilter;
|
|
||||||
|
|
||||||
|
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 {
|
pub struct OcrBuilder {
|
||||||
/// 是否修复PNG格式问题
|
/// 是否修复PNG格式问题
|
||||||
png_fix: bool,
|
png_fix: bool,
|
||||||
@@ -16,6 +20,7 @@ pub struct OcrBuilder {
|
|||||||
|
|
||||||
impl OcrBuilder {
|
impl OcrBuilder {
|
||||||
// 初始化任务,设置默认参数
|
// 初始化任务,设置默认参数
|
||||||
|
/// 创建默认配置的构建器。
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
png_fix: false, // 默认值
|
png_fix: false, // 默认值
|
||||||
@@ -24,15 +29,18 @@ impl OcrBuilder {
|
|||||||
charset_restrict: None,
|
charset_restrict: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// 设置是否修复 PNG 透明背景问题。
|
||||||
pub fn png_fix(mut self, value: bool) -> Self {
|
pub fn png_fix(mut self, value: bool) -> Self {
|
||||||
self.png_fix = value;
|
self.png_fix = value;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
/// 设置是否返回概率信息。
|
||||||
pub fn probability(mut self, value: bool) -> Self {
|
pub fn probability(mut self, value: bool) -> Self {
|
||||||
self.probability = value;
|
self.probability = value;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 设置颜色过滤约束。
|
||||||
pub fn color_filter<T>(mut self, filter: T) -> Self
|
pub fn color_filter<T>(mut self, filter: T) -> Self
|
||||||
where
|
where
|
||||||
T: ColorFilter + Send + Sync + 'static,
|
T: ColorFilter + Send + Sync + 'static,
|
||||||
@@ -41,6 +49,7 @@ impl OcrBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 设置字符集限制。
|
||||||
pub fn charset_restrict<T>(mut self, restrict: T) -> Self
|
pub fn charset_restrict<T>(mut self, restrict: T) -> Self
|
||||||
where
|
where
|
||||||
T: TokenFilter + Send + Sync + 'static,
|
T: TokenFilter + Send + Sync + 'static,
|
||||||
@@ -48,22 +57,22 @@ impl OcrBuilder {
|
|||||||
self.charset_restrict = Some(Box::new(restrict));
|
self.charset_restrict = Some(Box::new(restrict));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
pub fn build(self, session: &OcrSession) -> Ocr<'_> {
|
/// 绑定引擎会话并构建 OCR 识别器。
|
||||||
|
pub fn build_with<E: OcrEngine>(self, runtime: &E) -> Ocr<'_> {
|
||||||
// 1. 原地解析颜色过滤器
|
// 1. 原地解析颜色过滤器
|
||||||
let final_color_ranges = match &self.color_filter {
|
let final_color_ranges = match &self.color_filter {
|
||||||
Some(filter) => filter.collect_to_vec(),
|
Some(filter) => filter.collect_to_vec(),
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
};
|
};
|
||||||
// 2. 原地解析字符集过滤
|
// 2. 原地解析字符集过滤
|
||||||
let tokens = &session.model_metadata.charset.tokens;
|
let tokens = &runtime.metadata().charset.tokens;
|
||||||
let final_charset_indices = match &self.charset_restrict {
|
let final_charset_indices = match &self.charset_restrict {
|
||||||
Some(restrict) => restrict.apply_to_charset(tokens),
|
Some(restrict) => restrict.apply_to_charset(tokens),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ocr::new(session, self)
|
|
||||||
Ocr {
|
Ocr {
|
||||||
session,
|
runtime,
|
||||||
png_fix: self.png_fix, // 原地解构出来
|
png_fix: self.png_fix, // 原地解构出来
|
||||||
probability: self.probability,
|
probability: self.probability,
|
||||||
final_color_ranges,
|
final_color_ranges,
|
||||||
1038
ddddocr-core/src/ocr/builtin.rs
Normal file
103
ddddocr-core/src/ocr/charset.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
//! 字符集: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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 逻辑)
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
tokens,
|
||||||
|
char_to_idx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 业务策略方法 ---
|
||||||
|
|
||||||
|
/// 字符转索引,不存在时返回 -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
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 索引转字符引用,越界时返回 `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.contains_key(char_str)
|
||||||
|
}
|
||||||
|
/// 返回字符集大小。
|
||||||
|
pub fn size(&self) -> usize {
|
||||||
|
self.tokens.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 4. 标准 Display 接口实现 (对应 __str__)
|
||||||
|
// ==========================================
|
||||||
|
impl std::fmt::Display for Charset {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "Charset [Total Size: {}", self.size(),)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
use crate::utils::cv_ops::rgb_to_opencv_hsv;
|
//! 颜色过滤:HSV 区间匹配与颜色预设。
|
||||||
use anyhow::anyhow;
|
|
||||||
|
use crate::error::{ImagePreprocessError, Result};
|
||||||
|
use crate::utils::image_processor::rgb_to_opencv_hsv;
|
||||||
use image::{DynamicImage, ImageBuffer, Rgb};
|
use image::{DynamicImage, ImageBuffer, Rgb};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
/// 核心区间判定辅助函数
|
|
||||||
#[inline(always)]
|
#[inline(always)]
|
||||||
fn is_pixel_matched(ranges: &[HsvRange], h: u8, s: u8, v: u8) -> bool {
|
fn is_pixel_matched(ranges: &[HsvRange], h: u8, s: u8, v: u8) -> bool {
|
||||||
ranges.iter().any(|range| {
|
ranges.iter().any(|range| {
|
||||||
@@ -15,15 +16,17 @@ fn is_pixel_matched(ranges: &[HsvRange], h: u8, s: u8, v: u8) -> bool {
|
|||||||
&& v <= range.upper.2
|
&& v <= range.upper.2
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
/// 按 HSV 区间过滤图像:未命中任一区间的像素刷白。
|
||||||
pub fn apply_to_image(
|
pub fn apply_to_image(
|
||||||
image: &DynamicImage,
|
image: &DynamicImage,
|
||||||
hsv_ranges: &[HsvRange],
|
hsv_ranges: &[HsvRange],
|
||||||
) -> anyhow::Result<DynamicImage> {
|
) -> Result<DynamicImage, ImagePreprocessError> {
|
||||||
// 1. 统一转换为连续内存的 RGB8 缓冲区 (对应 Python 的 Image 到 RGB/BGR 数组转换)
|
// 1. 统一转换为连续内存的 RGB8 缓冲区 (对应 Python 的 Image 到 RGB/BGR 数组转换)
|
||||||
let rgb_img = image.to_rgb8();
|
let rgb_img = image.to_rgb8();
|
||||||
let (width, height) = rgb_img.dimensions();
|
let (width, height) = rgb_img.dimensions();
|
||||||
let mut raw_pixels = rgb_img.into_raw();
|
let mut raw_pixels = rgb_img.into_raw();
|
||||||
|
let actual_len = raw_pixels.len();
|
||||||
|
let expected_len = (width as usize) * (height as usize) * 3;
|
||||||
// 2. 密集计算核心:原地流式迭代修改
|
// 2. 密集计算核心:原地流式迭代修改
|
||||||
// 每次取出 3 个 u8 字节,分别代表 [R, G, B],无多余掩膜矩阵内存分配
|
// 每次取出 3 个 u8 字节,分别代表 [R, G, B],无多余掩膜矩阵内存分配
|
||||||
for chunk in raw_pixels.chunks_exact_mut(3) {
|
for chunk in raw_pixels.chunks_exact_mut(3) {
|
||||||
@@ -45,59 +48,81 @@ pub fn apply_to_image(
|
|||||||
|
|
||||||
// 3. 将扁平字节数组重新打包回 DynamicImage 容器
|
// 3. 将扁平字节数组重新打包回 DynamicImage 容器
|
||||||
let filtered_buffer = ImageBuffer::<Rgb<u8>, Vec<u8>>::from_raw(width, height, raw_pixels)
|
let filtered_buffer = ImageBuffer::<Rgb<u8>, Vec<u8>>::from_raw(width, height, raw_pixels)
|
||||||
.ok_or_else(|| anyhow!("图像缓冲重新组装失败,维度与数据大小不匹配"))?;
|
.ok_or(ImagePreprocessError::BufferLengthMismatch {
|
||||||
|
expected: expected_len,
|
||||||
|
actual: actual_len,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
channels: 3,
|
||||||
|
})?;
|
||||||
Ok(DynamicImage::ImageRgb8(filtered_buffer))
|
Ok(DynamicImage::ImageRgb8(filtered_buffer))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
/// HSV 颜色区间,下界与上界各为 `(H, S, V)`。
|
||||||
pub struct HsvRange {
|
pub struct HsvRange {
|
||||||
|
/// 区间下界 `(H, S, V)`。
|
||||||
pub lower: (u8, u8, u8), // (H, S, V)
|
pub lower: (u8, u8, u8), // (H, S, V)
|
||||||
|
/// 区间上界 `(H, S, V)`。
|
||||||
pub upper: (u8, u8, u8), // (H, S, V)
|
pub upper: (u8, u8, u8), // (H, S, V)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HsvRange {
|
impl HsvRange {
|
||||||
|
/// 创建 HSV 区间。
|
||||||
pub const fn new(lower: (u8, u8, u8), upper: (u8, u8, u8)) -> Self {
|
pub const fn new(lower: (u8, u8, u8), upper: (u8, u8, u8)) -> Self {
|
||||||
Self { lower, upper }
|
Self { lower, upper }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl HsvRange {
|
impl HsvRange {
|
||||||
/// 验证当前 HSV 范围是否合法
|
/// 校验区间是否合法(H 0-180,S/V 0-255,且下界 <= 上界)。
|
||||||
/// 对应 Python 逻辑:H 在 0-180,S/V 在 0-255,且下界 <= 上界
|
pub fn validate(&self) -> Result<(), ImagePreprocessError> {
|
||||||
pub fn validate(&self) -> Result<(), String> {
|
|
||||||
// 1. 校验 H 通道边界 (OpenCV 中 H 范围是 0-180)
|
// 1. 校验 H 通道边界 (OpenCV 中 H 范围是 0-180)
|
||||||
if self.lower.0 > 180 || self.upper.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(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 校验下界不能大于上界
|
// 2. 校验下界不能大于上界
|
||||||
if self.lower.0 > self.upper.0 || self.lower.1 > self.upper.1 || self.lower.2 > self.upper.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(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
/// 颜色预设:常见颜色对应的 HSV 区间集合。
|
||||||
pub enum ColorPreset {
|
pub enum ColorPreset {
|
||||||
|
/// 红色。
|
||||||
Red,
|
Red,
|
||||||
|
/// 蓝色。
|
||||||
Blue,
|
Blue,
|
||||||
|
/// 绿色。
|
||||||
Green,
|
Green,
|
||||||
|
/// 黄色。
|
||||||
Yellow,
|
Yellow,
|
||||||
|
/// 橙色。
|
||||||
Orange,
|
Orange,
|
||||||
|
/// 紫色。
|
||||||
Purple,
|
Purple,
|
||||||
|
/// 青色。
|
||||||
Cyan,
|
Cyan,
|
||||||
|
/// 黑色。
|
||||||
Black,
|
Black,
|
||||||
|
/// 白色。
|
||||||
White,
|
White,
|
||||||
|
/// 灰色。
|
||||||
Gray,
|
Gray,
|
||||||
|
/// 自定义区间列表。
|
||||||
Custom(Vec<HsvRange>),
|
Custom(Vec<HsvRange>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ColorPreset {
|
impl ColorPreset {
|
||||||
/// 纯裸数据定义,没有任何结构体包装,干净利落
|
/// 返回预设对应的 HSV 区间列表。
|
||||||
/// 返回值:(范围数量, 范围数组)
|
|
||||||
/// 完美的零成本抽象:利用常量提升将数据直接打入只读数据段 (.rodata)
|
|
||||||
pub fn matches(&self) -> &[HsvRange] {
|
pub fn matches(&self) -> &[HsvRange] {
|
||||||
match self {
|
match self {
|
||||||
ColorPreset::Red => &[
|
ColorPreset::Red => &[
|
||||||
@@ -149,8 +174,8 @@ impl ColorPreset {
|
|||||||
ColorPreset::Custom(ranges) => ranges,
|
ColorPreset::Custom(ranges) => ranges,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// 校验逻辑:在这里实现完美的“责任分离”
|
/// 校验预设的 HSV 区间是否合法。
|
||||||
pub fn validate(&self) -> Result<(), String> {
|
pub fn validate(&self) -> Result<(), ImagePreprocessError> {
|
||||||
match self {
|
match self {
|
||||||
// 1. 快捷变体:完全绕过,根本不校验,0 运行时开销放行!
|
// 1. 快捷变体:完全绕过,根本不校验,0 运行时开销放行!
|
||||||
ColorPreset::Custom(ranges) => {
|
ColorPreset::Custom(ranges) => {
|
||||||
@@ -166,7 +191,7 @@ impl ColorPreset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FromStr for ColorPreset {
|
impl FromStr for ColorPreset {
|
||||||
type Err = String;
|
type Err = ImagePreprocessError;
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
match s.to_lowercase().as_str() {
|
match s.to_lowercase().as_str() {
|
||||||
"red" => Ok(ColorPreset::Red),
|
"red" => Ok(ColorPreset::Red),
|
||||||
@@ -179,7 +204,7 @@ impl FromStr for ColorPreset {
|
|||||||
"black" => Ok(ColorPreset::Black),
|
"black" => Ok(ColorPreset::Black),
|
||||||
"white" => Ok(ColorPreset::White),
|
"white" => Ok(ColorPreset::White),
|
||||||
"gray" => Ok(ColorPreset::Gray),
|
"gray" => Ok(ColorPreset::Gray),
|
||||||
_ => Err(format!("不支持的颜色预设: {}", s)),
|
_ => Err(ImagePreprocessError::UnknownColorPreset(s.to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,24 +213,24 @@ impl FromStr for ColorPreset {
|
|||||||
// 3. 颜色约束特征(Trait)与组合子设计模式
|
// 3. 颜色约束特征(Trait)与组合子设计模式
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
||||||
|
/// 颜色匹配上下文:当前像素的 HSV 值。
|
||||||
pub struct PixelCtx {
|
pub struct PixelCtx {
|
||||||
|
/// 当前像素的 HSV 值。
|
||||||
pub hsv: (u8, u8, u8),
|
pub hsv: (u8, u8, u8),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 统一的颜色约束接口
|
/// 颜色过滤约束接口:提供一组 HSV 区间。
|
||||||
pub trait ColorFilter {
|
pub trait ColorFilter {
|
||||||
/// 将自身的有效约束平铺追加到统一的目标容器中
|
/// 将有效区间追加到目标容器。
|
||||||
fn append_ranges(&self, target: &mut Vec<HsvRange>);
|
fn append_ranges(&self, target: &mut Vec<HsvRange>);
|
||||||
/// 预估范围数量,借助原生内置的 len() 实现 O(1) 完美控容
|
/// 预估有效区间数量。
|
||||||
fn estimated_count(&self) -> usize;
|
fn estimated_count(&self) -> usize;
|
||||||
/// 将自身的有效约束平铺追加到统一目标容器中
|
/// 验证过滤器配置是否合法,默认直接放行。
|
||||||
/// 验证当前过滤器是否合法,默认直接放行(Ok(()))
|
fn validate_self(&self) -> Result<(), ImagePreprocessError> {
|
||||||
fn validate_self(&self) -> Result<(), String> {
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
/// 【新扩展的架构方法】将自身安全的合并到已有的普通容器中,并完成去重和排序
|
/// 收集全部有效区间并排序去重;无有效区间时返回 `None`。
|
||||||
/// 完美的责任分离:Builder 不再需要关心怎么分配内存、怎么排序去重
|
fn collect_to_vec(&self) -> Result<Option<Vec<HsvRange>>, ImagePreprocessError> {
|
||||||
fn collect_to_vec(&self) -> Result<Option<Vec<HsvRange>>, String> {
|
|
||||||
// 1. 触发自检
|
// 1. 触发自检
|
||||||
self.validate_self()?;
|
self.validate_self()?;
|
||||||
|
|
||||||
@@ -237,7 +262,7 @@ impl ColorFilter for ColorPreset {
|
|||||||
// 直接获取切片长度
|
// 直接获取切片长度
|
||||||
self.matches().len()
|
self.matches().len()
|
||||||
}
|
}
|
||||||
fn validate_self(&self) -> Result<(), String> {
|
fn validate_self(&self) -> Result<(), ImagePreprocessError> {
|
||||||
// 直接调用我们在第一步中为 ColorPreset 实现的精细化分流校验
|
// 直接调用我们在第一步中为 ColorPreset 实现的精细化分流校验
|
||||||
// 快捷变体在这里会直接返回 Ok(()), 只有 Custom 才会去真正校验
|
// 快捷变体在这里会直接返回 Ok(()), 只有 Custom 才会去真正校验
|
||||||
self.validate()
|
self.validate()
|
||||||
@@ -246,6 +271,7 @@ impl ColorFilter for ColorPreset {
|
|||||||
|
|
||||||
/// 多路颜色“或”逻辑组合子(并集网络)
|
/// 多路颜色“或”逻辑组合子(并集网络)
|
||||||
pub struct MultiOrColorRestrict<'a> {
|
pub struct MultiOrColorRestrict<'a> {
|
||||||
|
/// 参与「或」组合的过滤器列表。
|
||||||
pub filters: Vec<&'a dyn ColorFilter>,
|
pub filters: Vec<&'a dyn ColorFilter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +287,7 @@ impl<'a> ColorFilter for MultiOrColorRestrict<'a> {
|
|||||||
self.filters.iter().map(|f| f.estimated_count()).sum()
|
self.filters.iter().map(|f| f.estimated_count()).sum()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_self(&self) -> Result<(), String> {
|
fn validate_self(&self) -> Result<(), ImagePreprocessError> {
|
||||||
// 递归政审:只要其中一个子过滤器校验失败(比如某个 Custom 变体非法),立刻熔断
|
// 递归政审:只要其中一个子过滤器校验失败(比如某个 Custom 变体非法),立刻熔断
|
||||||
for f in &self.filters {
|
for f in &self.filters {
|
||||||
f.validate_self()?;
|
f.validate_self()?;
|
||||||
@@ -274,6 +300,7 @@ impl<'a> ColorFilter for MultiOrColorRestrict<'a> {
|
|||||||
// 4. 声明式宏:一语定乾坤
|
// 4. 声明式宏:一语定乾坤
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
||||||
|
/// 组合多个颜色过滤器为「或」关系的快捷宏。
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! color_any_of {
|
macro_rules! color_any_of {
|
||||||
($only:expr) => {
|
($only:expr) => {
|
||||||
@@ -285,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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +1,40 @@
|
|||||||
use crate::models::ocr::metadata::Resize;
|
//! OCR 执行器:预测入口与结果类型。
|
||||||
|
|
||||||
use crate::models::ocr::session::OcrSession;
|
use crate::ocr::metadata::Resize;
|
||||||
use crate::models::ocr::color_filter::{HsvRange, apply_to_image};
|
|
||||||
use crate::utils::image_io::png_rgba_white_preprocess;
|
use crate::error::{ImagePreprocessError, Result, TensorError};
|
||||||
|
use crate::ocr::color_filter::{HsvRange, apply_to_image};
|
||||||
|
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::utils::image_processor::{convert_to_grayscale, resize_image};
|
||||||
use anyhow::Result;
|
use crate::{OcrBuilder, OcrOutput};
|
||||||
use image::DynamicImage;
|
use image::DynamicImage;
|
||||||
use serde::Serialize;
|
use ndarray::ArrayView2;
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use tract_onnx::prelude::tract_ndarray::{ArrayView2, Ix2, s};
|
use tracing::{debug, warn};
|
||||||
use tract_onnx::prelude::{DatumType, Tensor, tract_ndarray};
|
/// OCR 识别结果:纯文本或携带概率的文本。
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum OcrResult {
|
pub enum OcrResult {
|
||||||
/// 纯文本分支(对应 probability = false)
|
/// 纯文本结果(`probability = false` 时返回)。
|
||||||
Text(String),
|
Text(String),
|
||||||
/// 包含全量概率的分支(对应 probability = true)
|
/// 携带概率的结果(`probability = true` 时返回)。
|
||||||
Probability {
|
Probability {
|
||||||
|
/// 识别出的文本。
|
||||||
text: String,
|
text: String,
|
||||||
/// 满额概率矩阵 [Steps, Classes]
|
/// 全量概率矩阵 `[Steps, Classes]`。
|
||||||
probabilities: Vec<Vec<f32>>,
|
probabilities: Vec<Vec<f32>>,
|
||||||
/// 全局平均置信度
|
/// 全局平均置信度。
|
||||||
confidence: f64,
|
confidence: f64,
|
||||||
},
|
},
|
||||||
/// 不支持的模型或未知输出
|
/// 不支持的模型或未知输出。
|
||||||
Unsupported { message: String },
|
Unsupported {
|
||||||
|
/// 不支持原因说明。
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
impl OcrResult {
|
impl OcrResult {
|
||||||
/// 消费自身,直接提取最终文本
|
/// 消费自身并提取最终文本。
|
||||||
pub fn into_text(self) -> String {
|
pub fn into_text(self) -> String {
|
||||||
match self {
|
match self {
|
||||||
OcrResult::Text(text) => text,
|
OcrResult::Text(text) => text,
|
||||||
@@ -95,12 +102,13 @@ impl fmt::Display for OcrResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OCR 识别器:预处理、推理调度与后处理解码。
|
||||||
pub struct Ocr<'a> {
|
pub struct Ocr<'a> {
|
||||||
pub(crate) session: &'a OcrSession,
|
pub(crate) runtime: &'a dyn OcrEngine,
|
||||||
pub(crate) png_fix: bool,
|
pub(crate) png_fix: bool,
|
||||||
pub(crate) probability: bool,
|
pub(crate) probability: bool,
|
||||||
/// 颜色过滤:保留的颜色列表
|
/// 颜色过滤:保留的颜色列表
|
||||||
pub(crate) final_color_ranges: Result<Option<Vec<HsvRange>>, String>,
|
pub(crate) final_color_ranges: Result<Option<Vec<HsvRange>>, ImagePreprocessError>,
|
||||||
|
|
||||||
/// 字符集范围
|
/// 字符集范围
|
||||||
pub(crate) final_charset_indices: Option<Vec<usize>>,
|
pub(crate) final_charset_indices: Option<Vec<usize>>,
|
||||||
@@ -109,19 +117,25 @@ pub struct Ocr<'a> {
|
|||||||
impl<'a> Ocr<'a> {
|
impl<'a> Ocr<'a> {
|
||||||
// 初始化任务,设置默认参数
|
// 初始化任务,设置默认参数
|
||||||
|
|
||||||
pub fn new(session: &'a OcrSession) -> Self {
|
/// 绑定引擎会话创建 OCR 识别器。
|
||||||
|
pub fn new(runtime: &'a dyn OcrEngine) -> Self {
|
||||||
Ocr {
|
Ocr {
|
||||||
session,
|
runtime,
|
||||||
png_fix: false, // 默认值
|
png_fix: false, // 默认值
|
||||||
probability: false,
|
probability: false,
|
||||||
final_color_ranges: Ok(None),
|
final_color_ranges: Ok(None),
|
||||||
final_charset_indices: None,
|
final_charset_indices: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// 创建 OCR 构建器。
|
||||||
|
pub fn builder() -> OcrBuilder {
|
||||||
|
OcrBuilder::default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
impl<'a> Ocr<'a> {
|
impl<'a> Ocr<'a> {
|
||||||
pub fn predict(&self, image: &DynamicImage) -> anyhow::Result<OcrResult> {
|
/// 对输入图像执行 OCR 识别并返回结果。
|
||||||
println!("当前颜色过滤器状态: {:?}", self.final_color_ranges);
|
pub fn predict(&self, image: &DynamicImage) -> Result<OcrResult> {
|
||||||
|
debug!("当前颜色过滤器状态: {:?}", self.final_color_ranges);
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// 管道节点 1: 颜色过滤流水线
|
// 管道节点 1: 颜色过滤流水线
|
||||||
@@ -130,10 +144,9 @@ impl<'a> Ocr<'a> {
|
|||||||
// =====================================================================
|
// =====================================================================
|
||||||
let img_cow = match &self.final_color_ranges {
|
let img_cow = match &self.final_color_ranges {
|
||||||
Err(err_msg) => {
|
Err(err_msg) => {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(ImagePreprocessError::FilterConfigInvalid(
|
||||||
"颜色过滤器初始化失败,全链路短路: {}",
|
err_msg.to_string(),
|
||||||
err_msg
|
))?;
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
// 核心优化点:直接借用原图,不发生任何克隆
|
// 核心优化点:直接借用原图,不发生任何克隆
|
||||||
@@ -147,28 +160,18 @@ impl<'a> Ocr<'a> {
|
|||||||
};
|
};
|
||||||
let tensor = self.preprocess_image(&img_cow)?;
|
let tensor = self.preprocess_image(&img_cow)?;
|
||||||
|
|
||||||
let raw_tensor = self.session.inference(tensor)?;
|
let raw_tensor = self.runtime.inference(tensor)?;
|
||||||
|
|
||||||
// 3. 后处理分流:直接返回 OcrResult
|
// 3. 后处理分流:直接返回 OcrResult
|
||||||
let ocr_output = match raw_tensor.datum_type() {
|
let ocr_output = self.process_model_output(raw_tensor)?;
|
||||||
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);
|
|
||||||
|
|
||||||
Ok(ocr_output)
|
Ok(ocr_output)
|
||||||
}
|
}
|
||||||
/// 对应 Python 的 _preprocess_image
|
fn preprocess_image(
|
||||||
/// 负责:透明背景修复 -> 灰度化 -> 按比例 Resize -> 归一化 -> 4维张量转换
|
&self,
|
||||||
fn preprocess_image(&self, img: &DynamicImage) -> anyhow::Result<Tensor> {
|
img: &DynamicImage,
|
||||||
|
) -> Result<ndarray::Array4<f32>, ImagePreprocessError> {
|
||||||
// 1. 获取模型元数据配置
|
// 1. 获取模型元数据配置
|
||||||
let meta = &self.session.model_metadata;
|
let meta = self.runtime.metadata();
|
||||||
let norm = &meta.normalization; // 获取归一化器
|
let norm = &meta.normalization; // 获取归一化器
|
||||||
|
|
||||||
// A. 修复 PNG 透明背景 (内部逻辑你之前已实现)
|
// A. 修复 PNG 透明背景 (内部逻辑你之前已实现)
|
||||||
@@ -198,12 +201,12 @@ impl<'a> Ocr<'a> {
|
|||||||
let resized_img = resize_image(¤t_img, target_w, target_h);
|
let resized_img = resize_image(¤t_img, target_w, target_h);
|
||||||
|
|
||||||
// 4. 管道节点 3: 颜色通道转换(单通道灰度 vs 三通道 RGB)与 4D 张量填充
|
// 4. 管道节点 3: 颜色通道转换(单通道灰度 vs 三通道 RGB)与 4D 张量填充
|
||||||
let tensor = match meta.channel {
|
let array4 = match meta.channel {
|
||||||
// --- 情况 A: 单通道(灰度图),对应 Python 的 len(shape) == 2 展开 ---
|
// --- 情况 A: 单通道(灰度图),对应 Python 的 len(shape) == 2 展开 ---
|
||||||
1 => {
|
1 => {
|
||||||
let gray_img = convert_to_grayscale(&resized_img);
|
let gray_img = convert_to_grayscale(&resized_img);
|
||||||
|
|
||||||
let array = tract_ndarray::Array4::from_shape_fn(
|
ndarray::Array4::from_shape_fn(
|
||||||
(1, 1, target_h as usize, target_w as usize),
|
(1, 1, target_h as usize, target_w as usize),
|
||||||
|(_, _, y, x)| {
|
|(_, _, y, x)| {
|
||||||
let pixel = gray_img.get_pixel(x as u32, y as u32)[0] as f32;
|
let pixel = gray_img.get_pixel(x as u32, y as u32)[0] as f32;
|
||||||
@@ -211,15 +214,14 @@ impl<'a> Ocr<'a> {
|
|||||||
// (pixel / 255.0 - 0.5) / 0.5
|
// (pixel / 255.0 - 0.5) / 0.5
|
||||||
norm.normalize(pixel)
|
norm.normalize(pixel)
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
Tensor::from(array)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 情况 B: 三通道(RGB),对应 Python 的 transpose(2, 0, 1) 的 CHW 布局 ---
|
// --- 情况 B: 三通道(RGB),对应 Python 的 transpose(2, 0, 1) 的 CHW 布局 ---
|
||||||
3 => {
|
3 => {
|
||||||
let rgb_img = resized_img.to_rgb8();
|
let rgb_img = resized_img.to_rgb8();
|
||||||
|
|
||||||
let array = tract_ndarray::Array4::from_shape_fn(
|
ndarray::Array4::from_shape_fn(
|
||||||
(1, 3, target_h as usize, target_w as usize),
|
(1, 3, target_h as usize, target_w as usize),
|
||||||
|(_, c, y, x)| {
|
|(_, c, y, x)| {
|
||||||
let pixel = rgb_img.get_pixel(x as u32, y as u32)[c] as f32;
|
let pixel = rgb_img.get_pixel(x as u32, y as u32)[c] as f32;
|
||||||
@@ -227,38 +229,71 @@ impl<'a> Ocr<'a> {
|
|||||||
// (pixel / 255.0 - 0.5) / 0.5
|
// (pixel / 255.0 - 0.5) / 0.5
|
||||||
norm.normalize(pixel)
|
norm.normalize(pixel)
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
Tensor::from(array)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_ => return Err(anyhow::anyhow!("不支持的通道数配置: {}", meta.channel)),
|
// _ => return Err(anyhow::anyhow!("不支持的通道数配置: {}", meta.channel)),
|
||||||
|
_ => {
|
||||||
|
return Err(ImagePreprocessError::UnsupportedChannels(
|
||||||
|
meta.channel as usize,
|
||||||
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
Ok(array4)
|
||||||
|
}
|
||||||
|
|
||||||
Ok(tensor)
|
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(TensorError::NonContiguousMemory)?;
|
||||||
|
let final_text = self.ctc_decode_to_string(slice);
|
||||||
|
|
||||||
// let h = 64u32;
|
if self.probability {
|
||||||
// let w = (current_img.width() as f32 * (h as f32 / current_img.height() as f32)) as u32;
|
Ok(OcrResult::Probability {
|
||||||
// let gray_img = convert_to_grayscale(¤t_img);
|
text: final_text,
|
||||||
// let resized = resize_image(&gray_img, w, h);
|
probabilities: vec![],
|
||||||
// // resized.save("debug_preprocessed.png").unwrap();
|
confidence: 1.0,
|
||||||
// // 1. 预处理:转灰度 -> Resize -> 归一化
|
})
|
||||||
// // let resized = img.resize_exact(w, h, FilterType::Lanczos3).to_luma8();
|
} else {
|
||||||
//
|
Ok(OcrResult::Text(final_text))
|
||||||
// // 使用 tract_ndarray 构造,避免版本冲突
|
}
|
||||||
// let array =
|
}
|
||||||
// tract_ndarray::Array4::from_shape_fn((1, 1, h as usize, w as usize), |(_, _, y, x)| {
|
OcrOutput::Logits(matrix_view) => {
|
||||||
// let pixel = resized.get_pixel(x as u32, y as u32)[0] as f32;
|
// 对应原来的 process_f32_tensor
|
||||||
// (pixel / 255.0 - 0.5) / 0.5
|
// 注意:此时的 matrix_view 已经是干净的标准的 ndarray::Array2<f32>,且保证是 [Steps, Classes] 2D 形状
|
||||||
// });
|
if self.probability {
|
||||||
//
|
let (probabilities_list, confidence, predicted_indices) =
|
||||||
// let tensor = Tensor::from(array);
|
self.compute_f32_full_probability(matrix_view.view());
|
||||||
//
|
let final_text = self.ctc_decode_to_string(&predicted_indices);
|
||||||
// Ok(tensor)
|
Ok(OcrResult::Probability {
|
||||||
|
text: final_text,
|
||||||
|
probabilities: probabilities_list,
|
||||||
|
confidence: confidence as f64,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl<'a> Ocr<'a> {
|
impl<'a> Ocr<'a> {
|
||||||
fn is_valid_indices(&self, idx: usize) -> bool {
|
/// 判断索引是否为当前限制下的有效字符索引。
|
||||||
if idx >= self.session.model_metadata.charset.size() {
|
pub fn is_valid_indices(&self, idx: usize) -> bool {
|
||||||
|
if idx >= self.runtime.metadata().charset.size() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,10 +302,9 @@ impl<'a> Ocr<'a> {
|
|||||||
None => true,
|
None => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// 【按需延迟打印】:当用户真的需要“知道当前有哪些限制字符”时,一秒反查并打印
|
/// 返回当前生效的可用 token 列表。
|
||||||
/// 这里的 &str 完美借用了自 tokens,依然是彻底的零拷贝!
|
|
||||||
pub fn valid_tokens(&self) -> Vec<&str> {
|
pub fn valid_tokens(&self) -> Vec<&str> {
|
||||||
let charset = &self.session.model_metadata.charset;
|
let charset = &self.runtime.metadata().charset;
|
||||||
let tokens = &charset.tokens;
|
let tokens = &charset.tokens;
|
||||||
match &self.final_charset_indices {
|
match &self.final_charset_indices {
|
||||||
Some(indices) => indices
|
Some(indices) => indices
|
||||||
@@ -281,13 +315,13 @@ impl<'a> Ocr<'a> {
|
|||||||
None => tokens.iter().map(|cow| cow.as_ref()).collect(),
|
None => tokens.iter().map(|cow| cow.as_ref()).collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// 返回当前生效的可用 token 数量。
|
||||||
pub fn valid_size(&self) -> usize {
|
pub fn valid_size(&self) -> usize {
|
||||||
match &self.final_charset_indices {
|
match &self.final_charset_indices {
|
||||||
Some(indices) => indices.len(),
|
Some(indices) => indices.len(),
|
||||||
None => self.session.model_metadata.charset.tokens.len(),
|
None => self.runtime.metadata().charset.tokens.len(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// 变体 B 核心处理器:单次遍历 2D 视图,融合计算 Softmax、Argmax、置信度并输出概率大包
|
|
||||||
fn compute_f32_full_probability(
|
fn compute_f32_full_probability(
|
||||||
&self,
|
&self,
|
||||||
matrix_view: ArrayView2<f32>,
|
matrix_view: ArrayView2<f32>,
|
||||||
@@ -296,7 +330,7 @@ impl<'a> Ocr<'a> {
|
|||||||
let classes = matrix_view.ncols();
|
let classes = matrix_view.ncols();
|
||||||
|
|
||||||
// 1. 预分配满额概率矩阵内存
|
// 1. 预分配满额概率矩阵内存
|
||||||
let mut prob_matrix = tract_ndarray::Array2::<f32>::zeros((steps, classes));
|
let mut prob_matrix = ndarray::Array2::<f32>::zeros((steps, classes));
|
||||||
let mut predicted_indices = Vec::with_capacity(steps);
|
let mut predicted_indices = Vec::with_capacity(steps);
|
||||||
let mut confidence_sum = 0.0f32;
|
let mut confidence_sum = 0.0f32;
|
||||||
|
|
||||||
@@ -340,101 +374,10 @@ impl<'a> Ocr<'a> {
|
|||||||
|
|
||||||
(probabilities_list, confidence, predicted_indices)
|
(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 {
|
fn ctc_decode_to_string(&self, predicted_indices: &[i64]) -> String {
|
||||||
println!("indices模型输出原始数据: {:?}", predicted_indices);
|
debug!("indices模型输出原始数据: {:?}", predicted_indices);
|
||||||
let charset = &self.session.model_metadata.charset;
|
let charset = &self.runtime.metadata().charset;
|
||||||
let tokens = &charset.tokens;
|
let tokens = &charset.tokens;
|
||||||
// let valid_indices = &charset.valid_indices;
|
|
||||||
|
|
||||||
// 对应 _ctc_decode_indices 的逻辑:去重、去 blank (0)
|
// 对应 _ctc_decode_indices 的逻辑:去重、去 blank (0)
|
||||||
let mut res = String::new();
|
let mut res = String::new();
|
||||||
@@ -460,17 +403,17 @@ impl<'a> Ocr<'a> {
|
|||||||
|
|
||||||
// 史诗级加速点:如果是 None,说明没限制,根本不进入分支,直接放行!
|
// 史诗级加速点:如果是 None,说明没限制,根本不进入分支,直接放行!
|
||||||
// 只有当有具体限制(Some)时,才去跑 4-5 次 CPU 寄存器级别的二分查找
|
// 只有当有具体限制(Some)时,才去跑 4-5 次 CPU 寄存器级别的二分查找
|
||||||
if let Some(ref indices) = self.final_charset_indices {
|
if let Some(ref indices) = self.final_charset_indices
|
||||||
if indices.binary_search(&u_idx).is_err() {
|
&& indices.binary_search(&u_idx).is_err()
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 字符映射
|
// 5. 字符映射
|
||||||
if let Some(char_str) = tokens.get(u_idx) {
|
if let Some(char_str) = tokens.get(u_idx) {
|
||||||
res.push_str(char_str);
|
res.push_str(char_str);
|
||||||
} else {
|
} else {
|
||||||
eprintln!("警告: 预测索引 {} 超出字符集范围", u_idx);
|
warn!("警告: 预测索引 {} 超出字符集范围", u_idx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
res
|
res
|
||||||
156
ddddocr-core/src/ocr/metadata.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
//! OCR 模型元数据:归一化、缩放策略与模型信息。
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 1. 辅助定义的枚举与结构体
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
use crate::ocr::Charset;
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
/// 像素归一化策略。
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum Normalization {
|
||||||
|
/// 映射到 [0.0, 1.0] -> pixel / 255.0
|
||||||
|
ZeroToOne,
|
||||||
|
/// 映射到 [-1.0, 1.0] -> (pixel / 255.0 - 0.5) / 0.5
|
||||||
|
MinusOneToOne,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Normalization {
|
||||||
|
/// 对像素值执行归一化。
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn normalize(&self, pixel: f32) -> f32 {
|
||||||
|
match self {
|
||||||
|
Normalization::ZeroToOne => pixel / 255.0,
|
||||||
|
Normalization::MinusOneToOne => (pixel / 255.0 - 0.5) / 0.5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 图像缩放策略枚举
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Resize {
|
||||||
|
/// 固定宽高,例如 (64, 64)
|
||||||
|
Fixed(u32, u32),
|
||||||
|
/// 高度固定,宽度根据原始比例动态计算(对应 Python 的 [-1, H])
|
||||||
|
DynamicWidth(u32),
|
||||||
|
/// 单字识别的正方形切图(对应 Python 的 word 为 True 且 [-1, H])
|
||||||
|
Square(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OCR 模型元数据:字符集、缩放策略、通道数与归一化配置。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ModelMetadata {
|
||||||
|
/// 字符集。
|
||||||
|
pub charset: Charset,
|
||||||
|
/// 是否为单字识别模型
|
||||||
|
pub word: bool,
|
||||||
|
/// 缩放策略。
|
||||||
|
pub resize: Resize,
|
||||||
|
/// 图像通道数(1 或 3)
|
||||||
|
pub channel: u8,
|
||||||
|
/// 像素归一化配置。
|
||||||
|
pub normalization: Normalization,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelMetadata {
|
||||||
|
/// 创建模型元数据。
|
||||||
|
pub fn new(
|
||||||
|
charset: Charset,
|
||||||
|
word: bool,
|
||||||
|
resize: Resize,
|
||||||
|
channel: u8,
|
||||||
|
normalization: Normalization,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
charset,
|
||||||
|
word,
|
||||||
|
resize,
|
||||||
|
channel,
|
||||||
|
normalization,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// --- 优雅的工厂模式构造器 ---
|
||||||
|
/// 从静态字符切片构建元数据并自动生成字符集。
|
||||||
|
pub fn from_static_slice(
|
||||||
|
slice: &[&'static str],
|
||||||
|
word: bool,
|
||||||
|
resize: Resize,
|
||||||
|
channel: u8,
|
||||||
|
normalization: Normalization,
|
||||||
|
) -> Self {
|
||||||
|
let tokens: Vec<Cow<'static, str>> = slice.iter().map(|&s| Cow::Borrowed(s)).collect();
|
||||||
|
Self {
|
||||||
|
charset: Charset::new(tokens),
|
||||||
|
word,
|
||||||
|
resize,
|
||||||
|
channel,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,26 @@
|
|||||||
use std::borrow::Cow;
|
//! 字符集限制:按字符属性或索引过滤识别范围。
|
||||||
|
|
||||||
/// 字符集范围限制枚举
|
use std::borrow::Cow;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
/// 字符集校验上下文:当前 token 的文本与索引。
|
||||||
pub struct ValidationCtx<'a> {
|
pub struct ValidationCtx<'a> {
|
||||||
|
/// 当前 token 的文本内容。
|
||||||
pub text: &'a str, // 当前 Token 的文本内容
|
pub text: &'a str, // 当前 Token 的文本内容
|
||||||
|
/// 当前 token 的 ID 索引。
|
||||||
pub token_id: usize, // 当前 Token 的 ID 索引
|
pub token_id: usize, // 当前 Token 的 ID 索引
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 统一的约束接口
|
/// 字符集限制接口:决定某个 token 是否放行。
|
||||||
pub trait TokenFilter {
|
pub trait TokenFilter {
|
||||||
|
/// 判断 token 是否放行。
|
||||||
fn matches(&self, ctx: &ValidationCtx) -> bool;
|
fn matches(&self, ctx: &ValidationCtx) -> bool;
|
||||||
/// 预估容量提示,帮助精准开辟 Vec 内存
|
/// 预估匹配数量的容量提示。
|
||||||
fn estimated_capacity(&self) -> usize {
|
fn estimated_capacity(&self) -> usize {
|
||||||
128
|
128
|
||||||
}
|
}
|
||||||
/// 【新引入的架构级核心方法】
|
|
||||||
/// 统一接管全量字符集的密集遍历、CTC Blank放行、去重、排序及空交集退化兜底
|
/// 遍历全量字符集筛选可用索引(放行 CTC blank、排序去重、空交集返回 `None`)。
|
||||||
fn apply_to_charset(&self, tokens: &[Cow<str>]) -> Option<Vec<usize>> {
|
fn apply_to_charset(&self, tokens: &[Cow<str>]) -> Option<Vec<usize>> {
|
||||||
let mut has_any_match = false;
|
let mut has_any_match = false;
|
||||||
let estimated_capacity = self.estimated_capacity();
|
let estimated_capacity = self.estimated_capacity();
|
||||||
@@ -47,7 +53,7 @@ pub trait TokenFilter {
|
|||||||
|
|
||||||
// 3. 终极防御:如果整个模型字符集除了 Blank,一个都没对上,直接退化为 None(全量识别)
|
// 3. 终极防御:如果整个模型字符集除了 Blank,一个都没对上,直接退化为 None(全量识别)
|
||||||
if !has_any_match {
|
if !has_any_match {
|
||||||
println!("警告:当前限制策略与模型字符集完全没有交集!已自动恢复全量识别。");
|
warn!("当前限制策略与模型字符集完全没有交集,已自动恢复全量识别");
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
// 4. 排序并去重,为 Ocr 引擎后续进行极其高频的『二分查找』筑起绝对安全的底层保障
|
// 4. 排序并去重,为 Ocr 引擎后续进行极其高频的『二分查找』筑起绝对安全的底层保障
|
||||||
@@ -58,11 +64,16 @@ pub trait TokenFilter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按字符属性限制:数字、大小写字母或自定义列表。
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum CharRestrict {
|
pub enum CharRestrict {
|
||||||
|
/// 仅数字。
|
||||||
Digit,
|
Digit,
|
||||||
|
/// 仅小写字母。
|
||||||
Lowercase,
|
Lowercase,
|
||||||
|
/// 仅大写字母。
|
||||||
Uppercase,
|
Uppercase,
|
||||||
|
/// 自定义字符列表。
|
||||||
CustomList(Vec<String>),
|
CustomList(Vec<String>),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,10 +95,14 @@ impl TokenFilter for CharRestrict {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按索引限制:前 N 个、索引范围或索引列表。
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum IdRestrict {
|
pub enum IdRestrict {
|
||||||
|
/// 前 N 个索引。
|
||||||
TopN(usize),
|
TopN(usize),
|
||||||
|
/// 指定索引范围。
|
||||||
IdRange(std::ops::Range<usize>),
|
IdRange(std::ops::Range<usize>),
|
||||||
|
/// 指定索引列表。
|
||||||
IdList(Vec<usize>),
|
IdList(Vec<usize>),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +128,7 @@ impl TokenFilter for IdRestrict {
|
|||||||
|
|
||||||
/// 多路“或”逻辑组合子(支持 N 个规则无缝并集)
|
/// 多路“或”逻辑组合子(支持 N 个规则无缝并集)
|
||||||
pub struct MultiOrRestrict<'a> {
|
pub struct MultiOrRestrict<'a> {
|
||||||
|
/// 参与「或」组合的过滤器列表。
|
||||||
pub filters: Vec<&'a dyn TokenFilter>,
|
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()
|
self.filters.iter().map(|f| f.estimated_capacity()).sum()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// =====================================================================
|
|
||||||
// 声明式宏:替代 `+` 运算符,解决组合扩展痛苦
|
/// 组合多个字符限制规则为「或」关系的快捷宏。
|
||||||
// =====================================================================
|
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! any_of {
|
macro_rules! any_of {
|
||||||
// 场景 A:如果用户只传了一个规则,免去构建 Vec 的开销,直接返回其引用
|
// 场景 A:如果用户只传了一个规则,免去构建 Vec 的开销,直接返回其引用
|
||||||
@@ -137,10 +152,81 @@ macro_rules! any_of {
|
|||||||
&$only as &dyn $crate::TokenFilter
|
&$only as &dyn $crate::TokenFilter
|
||||||
};
|
};
|
||||||
|
|
||||||
// 场景 B:如果用户传入了多个规则,自动织成一张静态组合网
|
// 场景 B:如果用户传入了多个规则,自动组合
|
||||||
($($filter:expr),+ $(,)?) => {
|
($($filter:expr),+ $(,)?) => {
|
||||||
&$crate::MultiOrRestrict {
|
&$crate::MultiOrRestrict {
|
||||||
filters: vec![ $( &$filter as &dyn $crate::TokenFilter ),+ ]
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
use crate::utils::cv_ops;
|
//! 滑块验证码匹配:模板匹配与差异比较两种模式。
|
||||||
use crate::utils::cv_ops::{abs_diff, min_max_loc, ndarray_to_luma8, rgb_to_gray};
|
|
||||||
use crate::utils::image_io::image_to_ndarray;
|
use crate::error::{ImagePreprocessError, Result};
|
||||||
use anyhow::{Result, anyhow};
|
use crate::utils::image_convert::{ColorMode, image_to_ndarray};
|
||||||
|
use crate::utils::image_processor;
|
||||||
|
use crate::utils::image_processor::{abs_diff, min_max_loc, ndarray_to_luma8, rgb_to_gray};
|
||||||
use image::DynamicImage;
|
use image::DynamicImage;
|
||||||
use image::Luma;
|
use image::Luma;
|
||||||
use imageproc::contrast::{ThresholdType, threshold};
|
use imageproc::contrast::{ThresholdType, threshold};
|
||||||
@@ -10,13 +12,20 @@ use imageproc::edges::canny;
|
|||||||
use imageproc::morphology::{close, open};
|
use imageproc::morphology::{close, open};
|
||||||
use imageproc::region_labelling::{Connectivity, connected_components};
|
use imageproc::region_labelling::{Connectivity, connected_components};
|
||||||
use imageproc::template_matching::{MatchTemplateMethod, match_template};
|
use imageproc::template_matching::{MatchTemplateMethod, match_template};
|
||||||
|
use ndarray::{ArrayView2, ArrayView3};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use tract_onnx::prelude::tract_ndarray::{ArrayView2, ArrayView3};
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// 滑块匹配结果:检测中心坐标与置信度。
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct SlideResult {
|
pub struct SlideResult {
|
||||||
|
/// 检测中心坐标 `[x, y]`。
|
||||||
pub target: [i32; 2],
|
pub target: [i32; 2],
|
||||||
|
/// 检测中心的 x 坐标。
|
||||||
pub target_x: i32,
|
pub target_x: i32,
|
||||||
|
/// 检测中心的 y 坐标。
|
||||||
pub target_y: i32,
|
pub target_y: i32,
|
||||||
|
/// 匹配置信度。
|
||||||
pub confidence: f64,
|
pub confidence: f64,
|
||||||
}
|
}
|
||||||
impl fmt::Display for SlideResult {
|
impl fmt::Display for SlideResult {
|
||||||
@@ -29,63 +38,64 @@ impl fmt::Display for SlideResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 滑块匹配服务:提供模板匹配与差异比较两种识别模式。
|
||||||
|
#[derive(Default)]
|
||||||
pub struct Slider;
|
pub struct Slider;
|
||||||
|
|
||||||
impl Slider {
|
impl Slider {
|
||||||
pub fn new() -> Result<Self, anyhow::Error> {
|
/// 创建滑块匹配服务。
|
||||||
Ok(Self)
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
}
|
}
|
||||||
/// 对应 Python: slide_match 滑块匹配接口
|
/// 模板匹配滑块:在背景图中定位滑块中心(对应 Python 的 `slide_match`)。
|
||||||
pub fn slide_match(
|
pub fn slide_match(
|
||||||
&self,
|
&self,
|
||||||
target_image: &DynamicImage,
|
target_image: &DynamicImage,
|
||||||
background_image: &DynamicImage,
|
background_image: &DynamicImage,
|
||||||
simple_target: bool,
|
simple_target: bool,
|
||||||
) -> Result<SlideResult> {
|
) -> Result<SlideResult> {
|
||||||
let target_array = image_to_ndarray(target_image);
|
let target_array = image_to_ndarray(target_image, ColorMode::RGB)?;
|
||||||
let background_array = image_to_ndarray(background_image);
|
let background_array = image_to_ndarray(background_image, ColorMode::RGB)?;
|
||||||
|
|
||||||
self.perform_slide_match(target_array.view(), background_array.view(), simple_target)
|
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(
|
pub fn slide_comparison(
|
||||||
&self,
|
&self,
|
||||||
target_image: &DynamicImage,
|
target_image: &DynamicImage,
|
||||||
background_image: &DynamicImage,
|
background_image: &DynamicImage,
|
||||||
) -> Result<SlideResult> {
|
) -> Result<SlideResult> {
|
||||||
// 1. 转换为 ndarray (HWC RGB)
|
// 1. 转换为 ndarray (HWC RGB)
|
||||||
let target_array = image_to_ndarray(target_image);
|
let target_array = image_to_ndarray(target_image, ColorMode::RGB)?;
|
||||||
let background_array = image_to_ndarray(background_image);
|
let background_array = image_to_ndarray(background_image, ColorMode::RGB)?;
|
||||||
|
|
||||||
// 2. 执行比较逻辑 (对应 _perform_slide_comparison)
|
// 2. 执行比较逻辑 (对应 _perform_slide_comparison)
|
||||||
self.perform_slide_comparison(target_array.view(), background_array.view())
|
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(
|
pub fn perform_slide_comparison(
|
||||||
&self,
|
&self,
|
||||||
target: ArrayView3<u8>,
|
target: ArrayView3<u8>,
|
||||||
background: ArrayView3<u8>,
|
background: ArrayView3<u8>,
|
||||||
) -> Result<SlideResult> {
|
) -> Result<SlideResult, ImagePreprocessError> {
|
||||||
|
|
||||||
// 1. 计算差异数组 (复用 cv2::absdiff)
|
// 1. 计算差异数组 (复用 cv2::absdiff)
|
||||||
let (th, tw, tc) = target.dim();
|
let (th, tw, tc) = target.dim();
|
||||||
let (bh, bw, bc) = background.dim();
|
let (bh, bw, bc) = background.dim();
|
||||||
|
|
||||||
// 1. 比较模式下的严格尺寸校验
|
// 1. 比较模式下的严格尺寸校验
|
||||||
if th != bh || tw != bw || tc != bc {
|
if th != bh || tw != bw || tc != bc {
|
||||||
return Err(anyhow!(
|
return Err(ImagePreprocessError::MismatchDimensions(format!(
|
||||||
"比较模式要求两张图分辨率与通道数完全一致!Target: [{}x{}x{}], Background: [{}x{}x{}]",
|
"比较模式要求两张图分辨率与通道数完全一致!Target: [{}x{}x{}], Background: [{}x{}x{}]",
|
||||||
tw,
|
tw, th, tc, bw, bh, bc
|
||||||
th,
|
)));
|
||||||
tc,
|
|
||||||
bw,
|
|
||||||
bh,
|
|
||||||
bc
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
if th == 0 || tw == 0 {
|
if th == 0 || tw == 0 {
|
||||||
return Err(anyhow!("输入图像尺寸不能为0"));
|
return Err(ImagePreprocessError::InvalidDimensions {
|
||||||
|
expected: "输入图像尺寸不能为0".to_string(),
|
||||||
|
actual: vec![th, tw],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let diff_array = abs_diff(&target, &background);
|
let diff_array = abs_diff(&target, &background);
|
||||||
@@ -109,13 +119,13 @@ impl Slider {
|
|||||||
let background_label = Luma([0u8]);
|
let background_label = Luma([0u8]);
|
||||||
let labelled = connected_components(&cleaned, Connectivity::Eight, background_label);
|
let labelled = connected_components(&cleaned, Connectivity::Eight, background_label);
|
||||||
|
|
||||||
// // 统计每个标签出现的频率(即面积)
|
|
||||||
// 4. 寻找最大连通区域 (对应 findContours + max area)
|
// 4. 寻找最大连通区域 (对应 findContours + max area)
|
||||||
if let Some(max_label) = cv_ops::find_contours_and_max(&labelled) {
|
if let Some(max_label) = image_processor::find_contours_and_max(&labelled) {
|
||||||
// 5. 计算最大区域的边界框 (对应 cv2.boundingRect)
|
// 5. 计算最大区域的边界框 (对应 cv2.boundingRect)
|
||||||
let (x, y, w, h) = cv_ops::bounding_rect(&labelled, max_label);
|
let (x, y, w, h) = image_processor::bounding_rect(&labelled, max_label);
|
||||||
// 6. 计算中心点 (调用之前封装的 calculate_center)
|
// 6. 计算中心点 (调用之前封装的 calculate_center)
|
||||||
let (center_x, center_y) = cv_ops::calculate_center((x, y), w as usize, h as usize);
|
let (center_x, center_y) =
|
||||||
|
image_processor::calculate_center((x, y), w as usize, h as usize);
|
||||||
|
|
||||||
Ok(SlideResult {
|
Ok(SlideResult {
|
||||||
target: [center_x, center_y],
|
target: [center_x, center_y],
|
||||||
@@ -133,36 +143,36 @@ impl Slider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 对应 Python: _perform_slide_match
|
/// 模板匹配核心实现(对应 Python 的 `_perform_slide_match`)。
|
||||||
// 在 SlideEngine 中修改此入口进行测试
|
|
||||||
fn perform_slide_match(
|
fn perform_slide_match(
|
||||||
&self,
|
&self,
|
||||||
target: ArrayView3<u8>,
|
target: ArrayView3<u8>,
|
||||||
background: ArrayView3<u8>,
|
background: ArrayView3<u8>,
|
||||||
simple_target: bool, // 增加这个参数
|
simple_target: bool, // 增加这个参数
|
||||||
) -> Result<SlideResult> {
|
) -> Result<SlideResult, ImagePreprocessError> {
|
||||||
let (th, tw, tc) = target.dim();
|
let (th, tw, tc) = target.dim();
|
||||||
let (bh, bw, bc) = background.dim();
|
let (bh, bw, bc) = background.dim();
|
||||||
|
|
||||||
// 1. 严格的鲁棒性校验(防止底层的 imageproc 算子崩溃)
|
// 1. 严格的鲁棒性校验(防止底层的 imageproc 算子崩溃)
|
||||||
if th == 0 || tw == 0 || bh == 0 || bw == 0 {
|
if th == 0 || tw == 0 || bh == 0 || bw == 0 {
|
||||||
return Err(anyhow!("输入图像的宽度或高度不能为0"));
|
return Err(ImagePreprocessError::InvalidDimensions {
|
||||||
|
expected: "输入图像的宽度或高度不能为0".to_string(),
|
||||||
|
actual: vec![th, tw, tc],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if th > bh || tw > bw {
|
if th > bh || tw > bw {
|
||||||
return Err(anyhow!(
|
return Err(ImagePreprocessError::TargetExceedsBackground {
|
||||||
"尺寸不匹配:滑块模板(target)尺寸 [{}x{}] 不能大于背景图(background) [{}x{}]",
|
target_w: tw,
|
||||||
tw,
|
target_h: th,
|
||||||
th,
|
bg_w: bw,
|
||||||
bw,
|
bg_h: bh,
|
||||||
bh
|
});
|
||||||
));
|
|
||||||
}
|
}
|
||||||
if tc != bc {
|
if tc != bc {
|
||||||
return Err(anyhow!(
|
return Err(ImagePreprocessError::MismatchDimensions(format!(
|
||||||
"目标图与背景图的通道数不一致 (target: {}, bg: {})",
|
"目标图与背景图的通道数不一致 (target: {}, bg: {})",
|
||||||
tc,
|
tc, bc
|
||||||
bc
|
)));
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 统一灰度化
|
// 1. 统一灰度化
|
||||||
@@ -171,26 +181,23 @@ impl Slider {
|
|||||||
|
|
||||||
if simple_target {
|
if simple_target {
|
||||||
// 2a. 简单模式:直接在灰度图上匹配
|
// 2a. 简单模式:直接在灰度图上匹配
|
||||||
self.simple_template_match(target_gray.view(), background_gray.view())
|
Ok(self.simple_template_match(target_gray.view(), background_gray.view()))
|
||||||
} else {
|
} else {
|
||||||
// 2b. 复杂模式:先提取边缘,再匹配
|
// 2b. 复杂模式:先提取边缘,再匹配
|
||||||
|
|
||||||
self.edge_based_match(target_gray.view(), background_gray.view())
|
Ok(self.edge_based_match(target_gray.view(), background_gray.view()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// 对应 Python: _simple_template_match
|
/// 简单模式模板匹配:直接对灰度图做归一化互相关(对应 Python 的 `_simple_template_match`)。
|
||||||
/// 使用 SAD (Sum of Absolute Differences) 算法
|
|
||||||
/// 核心模板匹配:SAD + 有效像素过滤
|
|
||||||
fn simple_template_match(
|
fn simple_template_match(
|
||||||
&self,
|
&self,
|
||||||
target: ArrayView2<u8>,
|
target: ArrayView2<u8>,
|
||||||
background: ArrayView2<u8>,
|
background: ArrayView2<u8>,
|
||||||
) -> Result<SlideResult> {
|
) -> SlideResult {
|
||||||
// 1. 将 ndarray 转换为 imageproc 需要的 ImageBuffer (无拷贝或轻量转换)
|
// 1. 将 ndarray 转换为 imageproc 需要的 ImageBuffer (无拷贝或轻量转换)
|
||||||
// 转换逻辑 (假设你已经有方法转回 ImageBuffer)
|
// 转换逻辑 (假设你已经有方法转回 ImageBuffer)
|
||||||
let t_buf = ndarray_to_luma8(target);
|
let t_buf = ndarray_to_luma8(target);
|
||||||
let b_buf = ndarray_to_luma8(background);
|
let b_buf = ndarray_to_luma8(background);
|
||||||
// t_buf.save("debug_rust_target.png").unwrap();
|
|
||||||
|
|
||||||
// 2. 调用 imageproc 的 NCC 算法 (等价于 cv2.TM_CCOEFF_NORMED)
|
// 2. 调用 imageproc 的 NCC 算法 (等价于 cv2.TM_CCOEFF_NORMED)
|
||||||
// 模板匹配 (完全对齐 cv2.matchTemplate(..., cv2.TM_CCOEFF_NORMED))
|
// 模板匹配 (完全对齐 cv2.matchTemplate(..., cv2.TM_CCOEFF_NORMED))
|
||||||
@@ -199,32 +206,27 @@ impl Slider {
|
|||||||
&t_buf,
|
&t_buf,
|
||||||
MatchTemplateMethod::CrossCorrelationNormalized,
|
MatchTemplateMethod::CrossCorrelationNormalized,
|
||||||
);
|
);
|
||||||
// save_rust_result(&result, "debug_rust_target2.png");
|
|
||||||
// 3. 寻找最大值 (等价于 cv2.minMaxLoc)
|
// 3. 寻找最大值 (等价于 cv2.minMaxLoc)
|
||||||
let (max_val, max_loc) = min_max_loc(&result);
|
let (max_val, max_loc) = min_max_loc(&result);
|
||||||
|
|
||||||
// 4. 计算中心点 (与 Python 逻辑完全一致)
|
// 4. 计算中心点 (与 Python 逻辑完全一致)
|
||||||
let (th, tw) = target.dim();
|
let (th, tw) = target.dim();
|
||||||
|
|
||||||
let (center_x, center_y) = cv_ops::calculate_center(max_loc, tw as usize, th as usize);
|
let (center_x, center_y) = image_processor::calculate_center(max_loc, tw, th);
|
||||||
// println!("Rust Target Width (tw): {}", tw);
|
SlideResult {
|
||||||
// println!("Rust Best Max Loc X: {}", max_loc.0);
|
|
||||||
// println!("Rust Final Center X: {}", center_x);
|
|
||||||
Ok(SlideResult {
|
|
||||||
target: [center_x, center_y],
|
target: [center_x, center_y],
|
||||||
target_x: center_x,
|
target_x: center_x,
|
||||||
target_y: center_y,
|
target_y: center_y,
|
||||||
confidence: max_val as f64,
|
confidence: max_val as f64,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 对应 Python: _edge_based_match
|
/// 边缘模式模板匹配:基于 Canny 边缘检测后再匹配(对应 Python 的 `_edge_based_match`)。
|
||||||
/// 基于边缘检测的滑块匹配 (对齐 Python _edge_based_match)
|
|
||||||
pub fn edge_based_match(
|
pub fn edge_based_match(
|
||||||
&self,
|
&self,
|
||||||
target: ArrayView2<u8>,
|
target: ArrayView2<u8>,
|
||||||
background: ArrayView2<u8>,
|
background: ArrayView2<u8>,
|
||||||
) -> Result<SlideResult> {
|
) -> SlideResult {
|
||||||
// 1. 将 ndarray 转换为 ImageBuffer
|
// 1. 将 ndarray 转换为 ImageBuffer
|
||||||
// 注意:Canny 和 match_template 需要 ImageBuffer 格式
|
// 注意:Canny 和 match_template 需要 ImageBuffer 格式
|
||||||
let t_buf = ndarray_to_luma8(target);
|
let t_buf = ndarray_to_luma8(target);
|
||||||
@@ -235,9 +237,6 @@ impl Slider {
|
|||||||
let target_edges = canny(&t_buf, 50.0, 150.0);
|
let target_edges = canny(&t_buf, 50.0, 150.0);
|
||||||
let background_edges = canny(&b_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))
|
// 3. 模板匹配 (完全对齐 cv2.matchTemplate(..., cv2.TM_CCOEFF_NORMED))
|
||||||
// 在边缘图上计算归一化互相关系数
|
// 在边缘图上计算归一化互相关系数
|
||||||
let result = match_template(
|
let result = match_template(
|
||||||
@@ -251,18 +250,58 @@ impl Slider {
|
|||||||
// 5. 计算中心位置 (对齐 Python 逻辑)
|
// 5. 计算中心位置 (对齐 Python 逻辑)
|
||||||
// target_w, target_h 来自输入数组的维度
|
// target_w, target_h 来自输入数组的维度
|
||||||
let (th, tw) = target.dim();
|
let (th, tw) = target.dim();
|
||||||
let (center_x, center_y) = cv_ops::calculate_center(max_loc, tw as usize, th as usize);
|
let (center_x, center_y) = image_processor::calculate_center(max_loc, tw, th);
|
||||||
|
|
||||||
// 打印调试信息,方便与 Python 对比
|
// 打印调试信息,方便与 Python 对比
|
||||||
// println!("Edge Match: max_val: {}, max_loc: {:?}", max_val, max_loc);
|
debug!("-Rust Target Width (tw): {}", tw);
|
||||||
println!("-Rust Target Width (tw): {}", tw);
|
debug!("-Rust Best Max Loc X: {}", max_loc.0);
|
||||||
println!("-Rust Best Max Loc X: {}", max_loc.0);
|
debug!("-Rust Final Center X: {}", center_x);
|
||||||
println!("-Rust Final Center X: {}", center_x);
|
SlideResult {
|
||||||
Ok(SlideResult {
|
|
||||||
target: [center_x, center_y],
|
target: [center_x, center_y],
|
||||||
target_x: center_x,
|
target_x: center_x,
|
||||||
target_y: center_y,
|
target_y: center_y,
|
||||||
confidence: max_val as f64,
|
confidence: max_val as f64,
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
55
ddddocr-core/src/traits.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
//! 推理引擎统一抽象接口。
|
||||||
|
|
||||||
|
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-tract2、ddddocr-ort 等引擎 crate 实现。
|
||||||
|
pub trait InferenceEngine {
|
||||||
|
/// 引擎产出的输出枚举(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],
|
||||||
|
) -> crate::error::Result<Self::Session, Self::Error>;
|
||||||
|
}
|
||||||
60
ddddocr-core/src/types.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
//! 模型输入输出信息等共享类型,由 [`crate::traits::Info`] 接口返回。
|
||||||
|
|
||||||
|
/// 张量元素的数据类型标记。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum TensorType {
|
||||||
|
/// 32 位浮点类型。
|
||||||
|
F32,
|
||||||
|
/// 64 位整数类型。
|
||||||
|
I64,
|
||||||
|
/// 其他类型。
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模型某个轴的维度特征:静态数值或动态符号。
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
pub enum AxisDim {
|
||||||
|
/// 静态固定维度。
|
||||||
|
Static(usize),
|
||||||
|
/// 动态符号维度。
|
||||||
|
Dynamic(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个张量(输入或输出)的名称、形状与数据类型描述。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TensorInfo {
|
||||||
|
/// 张量名称。
|
||||||
|
pub name: String,
|
||||||
|
/// 各轴形状描述。
|
||||||
|
pub shape: Vec<AxisDim>,
|
||||||
|
/// 元素数据类型。
|
||||||
|
pub tensor_type: TensorType,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模型的完整输入/输出信息。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ModelInfo {
|
||||||
|
/// 输入张量列表。
|
||||||
|
pub inputs: Vec<TensorInfo>,
|
||||||
|
/// 输出张量列表。
|
||||||
|
pub outputs: Vec<TensorInfo>,
|
||||||
|
/// 硬件执行提供者(`None` 表示使用引擎默认后端)。
|
||||||
|
pub providers: Option<Vec<String>>,
|
||||||
|
}
|
||||||
10
ddddocr-core/src/utils.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
//! 图像与张量工具:加载、格式转换、图像处理与归一化。
|
||||||
|
|
||||||
|
pub mod image_convert;
|
||||||
|
mod image_helper;
|
||||||
|
pub mod image_processor;
|
||||||
|
mod tensor_transform;
|
||||||
|
|
||||||
|
// 对外统一暴露干净的 API 语义层
|
||||||
|
pub use image_convert::ColorMode;
|
||||||
|
pub use tensor_transform::normalize_ocr_logits;
|
||||||
217
ddddocr-core/src/utils/image_convert.rs
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
//! 图像格式转换: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> {
|
||||||
|
let shape = array.shape();
|
||||||
|
let dim = shape.len();
|
||||||
|
|
||||||
|
// 1. 确保数据在内存中是连续的 (C order / Standard Layout)
|
||||||
|
// 如果 arr 是经过切片或转置的,这一步会进行必要的内存拷贝
|
||||||
|
|
||||||
|
let color_mode = match dim {
|
||||||
|
// 对应 Python: len(array.shape) == 2 (灰度图 H, W)
|
||||||
|
2 => ColorMode::L,
|
||||||
|
|
||||||
|
// 对应 Python: len(array.shape) == 3 (H, W, C)
|
||||||
|
3 => {
|
||||||
|
let (_h, _w, c) = (shape[0], shape[1], shape[2]);
|
||||||
|
match c {
|
||||||
|
// 对应 Python: array.shape[2] == 1 (单通道 H, W, 1)
|
||||||
|
1 => ColorMode::L,
|
||||||
|
// 对应 Python: array.shape[2] == 3 (RGB H, W, 3)
|
||||||
|
3 => ColorMode::RGB,
|
||||||
|
// 对应 Python: array.shape[2] == 4 (RGBA H, W, 4)
|
||||||
|
4 => ColorMode::RGBA,
|
||||||
|
_ => {
|
||||||
|
return Err(ImagePreprocessError::UnsupportedChannels(c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(ImagePreprocessError::InvalidDimensions {
|
||||||
|
expected: "2D (H,W) 或 3D (H,W,C)".to_string(),
|
||||||
|
actual: shape.to_vec(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
from_ndarray(array, color_mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理PNG图片的RGBA透明背景,将透明部分设置为白色背景
|
||||||
|
// 对应 Python 的 png_rgba_black_preprocess
|
||||||
|
pub fn png_rgba_white_preprocess(img: &DynamicImage) -> DynamicImage {
|
||||||
|
// 1. 检查是否包含透明通道,如果没有,直接克隆并返回
|
||||||
|
if !img.color().has_alpha() {
|
||||||
|
return DynamicImage::ImageRgb8(img.to_rgb8());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (width, height) = img.dimensions();
|
||||||
|
|
||||||
|
// 2. 创建一个新的 RGB 图像缓冲,默认填充为白色 (255, 255, 255)
|
||||||
|
let mut background = ImageBuffer::from_pixel(width, height, Rgb([255u8, 255u8, 255u8]));
|
||||||
|
|
||||||
|
// 3. 获取原图的 RGBA 视图
|
||||||
|
let rgba_img = img.to_rgba8();
|
||||||
|
|
||||||
|
// 4. 遍历像素并手动进行 Alpha 混合
|
||||||
|
// 对应 Python 的 utils.paste(img, ..., mask=img)
|
||||||
|
// 使用 enumerate_pixels_mut 同时获取坐标和背景像素的可变引用,减少查找开销
|
||||||
|
for (x, y, bg_pixel) in background.enumerate_pixels_mut() {
|
||||||
|
// 安全性说明:x, y 源自 background 尺寸,与 rgba_img 一致,get_pixel 是安全的
|
||||||
|
let src_pixel = rgba_img.get_pixel(x, y);
|
||||||
|
let alpha_u8 = src_pixel[3];
|
||||||
|
|
||||||
|
match alpha_u8 {
|
||||||
|
// 情况 A:完全不透明,直接覆盖背景色
|
||||||
|
255 => {
|
||||||
|
bg_pixel.0 = [src_pixel[0], src_pixel[1], src_pixel[2]];
|
||||||
|
}
|
||||||
|
// 情况 B:完全透明,保持背景色(白色),无需操作
|
||||||
|
0 => {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 情况 C:半透明,进行 Alpha 混合计算
|
||||||
|
_ => {
|
||||||
|
let alpha = alpha_u8 as f32 / 255.0;
|
||||||
|
let inv_alpha = 1.0 - alpha;
|
||||||
|
|
||||||
|
bg_pixel[0] = (src_pixel[0] as f32 * alpha + 255.0 * inv_alpha).round() as u8;
|
||||||
|
bg_pixel[1] = (src_pixel[1] as f32 * alpha + 255.0 * inv_alpha).round() as u8;
|
||||||
|
bg_pixel[2] = (src_pixel[2] as f32 * alpha + 255.0 * inv_alpha).round() as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DynamicImage::ImageRgb8(background)
|
||||||
|
}
|
||||||
|
/// 将 DynamicImage 转换为 array 数组
|
||||||
|
pub fn image_to_ndarray(
|
||||||
|
image: &DynamicImage,
|
||||||
|
mode: ColorMode,
|
||||||
|
) -> Result<Array3<u8>, ImagePreprocessError> {
|
||||||
|
// Rust utils 库通过 to_rgb8, to_luma8 等方法实现转换
|
||||||
|
let (width, height) = image.dimensions();
|
||||||
|
|
||||||
|
let (channels, raw) = match mode {
|
||||||
|
ColorMode::L => (1, image.to_luma8().into_raw()),
|
||||||
|
ColorMode::RGB => (3, image.to_rgb8().into_raw()),
|
||||||
|
ColorMode::RGBA => (4, image.to_rgba8().into_raw()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let array = Array3::from_shape_vec((height as usize, width as usize, channels), raw)
|
||||||
|
.map_err(ImagePreprocessError::from)?;
|
||||||
|
Ok(array)
|
||||||
|
}
|
||||||
|
/// 将 array 数组转换为 DynamicImage
|
||||||
|
pub fn ndarray_to_image(
|
||||||
|
array: ArrayViewD<u8>,
|
||||||
|
mode: ColorMode,
|
||||||
|
) -> Result<DynamicImage, ImagePreprocessError> {
|
||||||
|
let shape = array.shape();
|
||||||
|
|
||||||
|
// 基础边界检查:至少要有 H 和 W 两个维度
|
||||||
|
if shape.len() < 2 {
|
||||||
|
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> {
|
||||||
|
let shape = array.shape();
|
||||||
|
|
||||||
|
// 映射:ndarray 的 shape 默认是 [Height, Width, (Channels)]
|
||||||
|
// image 库的 from_raw 接收 (width, height)
|
||||||
|
let height = shape[0] as u32;
|
||||||
|
let width = shape[1] as u32;
|
||||||
|
|
||||||
|
// 1. 确保数据在内存中是连续的 (C order)
|
||||||
|
let standard = array.as_standard_layout();
|
||||||
|
let (raw_data, _) = standard.to_owned().into_raw_vec_and_offset();
|
||||||
|
let raw_len = raw_data.len();
|
||||||
|
|
||||||
|
// 获取当前模式对应的通道数
|
||||||
|
let channels = match mode {
|
||||||
|
ColorMode::L => 1,
|
||||||
|
ColorMode::RGB => 3,
|
||||||
|
ColorMode::RGBA => 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
let expected_len = (width * height) as usize * channels;
|
||||||
|
|
||||||
|
// 构造通用错误闭包,避免 match 分支中重复编写冗长的错误对象
|
||||||
|
let make_err = || ImagePreprocessError::BufferLengthMismatch {
|
||||||
|
expected: expected_len,
|
||||||
|
actual: raw_len,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
channels,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 重新解释内存并构建 ImageBuffer
|
||||||
|
match mode {
|
||||||
|
ColorMode::L => ImageBuffer::<Luma<u8>, _>::from_raw(width, height, raw_data)
|
||||||
|
.map(DynamicImage::ImageLuma8)
|
||||||
|
.ok_or_else(make_err),
|
||||||
|
ColorMode::RGB => ImageBuffer::<Rgb<u8>, _>::from_raw(width, height, raw_data)
|
||||||
|
.map(DynamicImage::ImageRgb8)
|
||||||
|
.ok_or_else(make_err),
|
||||||
|
ColorMode::RGBA => ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, raw_data)
|
||||||
|
.map(DynamicImage::ImageRgba8)
|
||||||
|
.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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
156
ddddocr-core/src/utils/image_helper.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
//! 图像输入源:从路径、Base64、字节等统一加载图片。
|
||||||
|
|
||||||
|
use crate::error::{DdddError, Result};
|
||||||
|
use crate::utils::image_convert::ndarray_to_hwc_image;
|
||||||
|
use base64::{Engine as _, engine::general_purpose};
|
||||||
|
use image::DynamicImage;
|
||||||
|
use ndarray::ArrayViewD;
|
||||||
|
use std::fmt;
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
// 1. 将 into_inner 优化为 into_image,符合 Rust 官方命名规范
|
||||||
|
impl ImageSource {
|
||||||
|
/// 消耗当前包装器,获取最终的 DynamicImage
|
||||||
|
pub fn into_image(self) -> DynamicImage {
|
||||||
|
self.inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从多种输入类型转换为 [`ImageSource`] 的转换接口。
|
||||||
|
pub trait TryFromImage<T>: Sized {
|
||||||
|
// 唯一的转换入口,通过目标类型来调用
|
||||||
|
fn try_from_image(value: T) -> Result<Self>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 本身是 DynamicImage
|
||||||
|
impl TryFromImage<DynamicImage> for ImageSource {
|
||||||
|
fn try_from_image(img: DynamicImage) -> Result<Self> {
|
||||||
|
Ok(Self { inner: img })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum Base64ProcessError {
|
||||||
|
InvalidBase64Header,
|
||||||
|
}
|
||||||
|
impl fmt::Display for Base64ProcessError {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Base64ProcessError::InvalidBase64Header => {
|
||||||
|
write!(f, "Base64 头部格式不正确,缺少 ';base64,' 分隔符")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Base64ProcessError {}
|
||||||
|
// 2.2 路径类型 A: &str (最常用)
|
||||||
|
impl<'a> TryFromImage<&'a str> for ImageSource {
|
||||||
|
fn try_from_image(path_or_b64: &'a str) -> Result<Self> {
|
||||||
|
// 1. 嗅探:如果包含 Base64 特征
|
||||||
|
if path_or_b64.starts_with("data:image/") && path_or_b64.contains(";base64,") {
|
||||||
|
// 提取出真正的 base64 数据部分
|
||||||
|
let (_, clean_b64) = path_or_b64.split_once(";base64,").ok_or_else(|| {
|
||||||
|
// 返回一个明确的、可读性极佳的格式错误
|
||||||
|
DdddError::new(Base64ProcessError::InvalidBase64Header)
|
||||||
|
})?;
|
||||||
|
// 转换为 Base64 包装器,并复用其 TryFromImage 实现
|
||||||
|
Self::try_from_image(Base64(clean_b64))
|
||||||
|
} else {
|
||||||
|
// 2. 否则,老老实实当作本地路径打开
|
||||||
|
let img = image::open(path_or_b64).map_err(DdddError::new)?;
|
||||||
|
Ok(Self { inner: img })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.3 路径类型 B: &Path (标准借用)
|
||||||
|
impl<'a> TryFromImage<&'a Path> for ImageSource {
|
||||||
|
fn try_from_image(path: &'a Path) -> Result<Self> {
|
||||||
|
let img = image::open(path).map_err(DdddError::new)?;
|
||||||
|
Ok(Self { inner: img })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2.4 路径类型 C: PathBuf / String (拥有所有权,透传给借用)
|
||||||
|
impl TryFromImage<PathBuf> for ImageSource {
|
||||||
|
fn try_from_image(path: PathBuf) -> Result<Self> {
|
||||||
|
Self::try_from_image(path.as_path())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFromImage<String> for ImageSource {
|
||||||
|
fn try_from_image(path_or_b64: String) -> Result<Self> {
|
||||||
|
Self::try_from_image(path_or_b64.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. 支持带有生命周期的借用:直接支持 &[u8](不强绑生命周期到 ImageSource 结构体上!)
|
||||||
|
impl<'a> TryFromImage<&'a [u8]> for ImageSource {
|
||||||
|
fn try_from_image(bytes: &'a [u8]) -> Result<Self> {
|
||||||
|
let img = image::load_from_memory(bytes).map_err(DdddError::new)?;
|
||||||
|
Ok(Self { inner: img })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 完美支持 ndarray 的借用 ArrayViewD
|
||||||
|
impl<'a> TryFromImage<ArrayViewD<'a, u8>> for ImageSource {
|
||||||
|
fn try_from_image(array: ArrayViewD<'a, u8>) -> Result<Self> {
|
||||||
|
let img = ndarray_to_hwc_image(array)?;
|
||||||
|
Ok(Self { inner: img })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> TryFromImage<Base64<'a>> for ImageSource {
|
||||||
|
fn try_from_image(b64_str: Base64<'a>) -> Result<Self> {
|
||||||
|
let img = base64_to_image(b64_str.0)?;
|
||||||
|
Ok(Self { inner: img })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从任意受支持的输入类型加载图像。
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn load_image_from_input<I>(input: I) -> Result<DynamicImage>
|
||||||
|
where
|
||||||
|
ImageSource: TryFromImage<I>,
|
||||||
|
{
|
||||||
|
let img = ImageSource::try_from_image(input)?.into_image();
|
||||||
|
Ok(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 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(",") {
|
||||||
|
&b64_str[pos + 1..]
|
||||||
|
} else {
|
||||||
|
&b64_str
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes = general_purpose::STANDARD
|
||||||
|
.decode(clean_b64.trim())
|
||||||
|
// .map_err(|e| DdddError::new(e))?;
|
||||||
|
.map_err(DdddError::new)?;
|
||||||
|
|
||||||
|
let img = image::load_from_memory(&bytes).map_err(DdddError::new)?;
|
||||||
|
Ok(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取图片文件并转换为 base64 编码字符串
|
||||||
|
// 对应 Python 版 get_img_base64
|
||||||
|
pub fn img_to_base64<P: AsRef<Path>>(image_path: P) -> Result<String> {
|
||||||
|
// 1. 读取文件原始字节流
|
||||||
|
// 使用 AsRef<Path> 泛型可以让函数同时支持 String, &str, PathBuf 等类型
|
||||||
|
let image_data = fs::read(&image_path).map_err(DdddError::new)?;
|
||||||
|
|
||||||
|
// 2. 进行 Base64 编码
|
||||||
|
// 使用 STANDARD 引擎对齐 Python 的 base64.b64encode
|
||||||
|
let b64_string = general_purpose::STANDARD.encode(image_data);
|
||||||
|
|
||||||
|
Ok(b64_string)
|
||||||
|
}
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
use image::{ImageBuffer, Luma};
|
//! 图像处理算法:OpenCV 风格的常用函数封装。
|
||||||
use std::cmp::{max, min};
|
|
||||||
use tract_onnx::prelude::tract_ndarray::{Array2, Array3, ArrayView2, ArrayView3, azip};
|
|
||||||
|
|
||||||
/// 1. 计算两个数组的绝对差值 (对应 cv2.absdiff)
|
use image::{DynamicImage, GrayImage, ImageBuffer, Luma, imageops::FilterType};
|
||||||
|
|
||||||
|
use ndarray::{Array2, Array3, ArrayView2, ArrayView3, azip};
|
||||||
|
use std::cmp::{max, min};
|
||||||
|
|
||||||
|
// 模拟openCV
|
||||||
|
/// 计算两个 HWC 数组的绝对差值(对应 cv2.absdiff)。
|
||||||
pub fn abs_diff(a: &ArrayView3<u8>, b: &ArrayView3<u8>) -> Array3<u8> {
|
pub fn abs_diff(a: &ArrayView3<u8>, b: &ArrayView3<u8>) -> Array3<u8> {
|
||||||
// 利用 ndarray 的 map_collect,生成差值的绝对值数组
|
// 利用 ndarray 的 map_collect,生成差值的绝对值数组
|
||||||
// 或者直接使用 zip_mut_with 处理以减少内存分配
|
// 或者直接使用 zip_mut_with 处理以减少内存分配
|
||||||
let mut diff = Array3::zeros(a.dim());
|
let mut diff = Array3::zeros(a.dim());
|
||||||
azip!((res in &mut diff, &va in a, &vb in b) {
|
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
|
diff
|
||||||
}
|
}
|
||||||
@@ -25,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)) {
|
pub fn min_max_loc(result_map: &ImageBuffer<Luma<f32>, Vec<f32>>) -> (f32, (u32, u32)) {
|
||||||
// 4. 找到最佳匹配位置 (对齐 cv2.minMaxLoc)
|
// 4. 找到最佳匹配位置 (对齐 cv2.minMaxLoc)
|
||||||
let mut max_val: f32 = -1.0;
|
let mut max_val: f32 = -1.0;
|
||||||
@@ -46,8 +50,7 @@ pub fn min_max_loc(result_map: &ImageBuffer<Luma<f32>, Vec<f32>>) -> (f32, (u32,
|
|||||||
(max_val, max_loc)
|
(max_val, max_loc)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 1. 模拟 findContours 并获取最大面积区域的 Label
|
/// 模拟 findContours:返回面积最大的连通域标签,找不到时返回 `None`。
|
||||||
/// 返回 Option<u32>,如果找不到任何区域则返回 None
|
|
||||||
pub fn find_contours_and_max(labelled: &ImageBuffer<Luma<u32>, Vec<u32>>) -> Option<u32> {
|
pub fn find_contours_and_max(labelled: &ImageBuffer<Luma<u32>, Vec<u32>>) -> Option<u32> {
|
||||||
// 统计每个标签出现的频率(即面积)
|
// 统计每个标签出现的频率(即面积)
|
||||||
let mut max_label = 0;
|
let mut max_label = 0;
|
||||||
@@ -72,6 +75,7 @@ pub fn find_contours_and_max(labelled: &ImageBuffer<Luma<u32>, Vec<u32>>) -> Opt
|
|||||||
Some(max_label)
|
Some(max_label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// 计算指定连通域标签的外接矩形(对应 cv2.boundingRect),返回 `(min_x, min_y, width, height)`。
|
||||||
pub fn bounding_rect(
|
pub fn bounding_rect(
|
||||||
labelled: &ImageBuffer<Luma<u32>, Vec<u32>>,
|
labelled: &ImageBuffer<Luma<u32>, Vec<u32>>,
|
||||||
max_label: u32,
|
max_label: u32,
|
||||||
@@ -95,13 +99,20 @@ pub fn bounding_rect(
|
|||||||
let h = max_y - min_y;
|
let h = max_y - min_y;
|
||||||
(min_x, min_y, w, h)
|
(min_x, min_y, w, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 根据左上角坐标与矩形长宽,计算其中央核心点坐标
|
||||||
|
#[inline]
|
||||||
pub fn calculate_center(top_left: (u32, u32), width: usize, height: usize) -> (i32, i32) {
|
pub fn calculate_center(top_left: (u32, u32), width: usize, height: usize) -> (i32, i32) {
|
||||||
let center_x = top_left.0 as i32 + (width as i32 / 2);
|
let center_x = top_left.0 as i32 + (width as i32 / 2);
|
||||||
let center_y = top_left.1 as i32 + (height as i32 / 2);
|
let center_y = top_left.1 as i32 + (height as i32 / 2);
|
||||||
(center_x, center_y)
|
(center_x, center_y)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将 2D 灰度 ndarray 视图转换为灰度 ImageBuffer。
|
||||||
pub fn ndarray_to_luma8(array: ArrayView2<u8>) -> ImageBuffer<Luma<u8>, Vec<u8>> {
|
pub fn ndarray_to_luma8(array: ArrayView2<u8>) -> ImageBuffer<Luma<u8>, Vec<u8>> {
|
||||||
let (height, width) = array.dim();
|
let (height, width) = array.dim();
|
||||||
|
// 技巧:直接将已有的规整连续内存打平转换,或用 from_raw 包装
|
||||||
|
// 此处保留安全的一步转换,但用更内聚的迭代器或切片拷贝进行速度优化
|
||||||
let mut buffer = ImageBuffer::new(width as u32, height as u32);
|
let mut buffer = ImageBuffer::new(width as u32, height as u32);
|
||||||
for y in 0..height {
|
for y in 0..height {
|
||||||
for x in 0..width {
|
for x in 0..width {
|
||||||
@@ -114,6 +125,7 @@ pub fn ndarray_to_luma8(array: ArrayView2<u8>) -> ImageBuffer<Luma<u8>, Vec<u8>>
|
|||||||
// 5. 核心高性能图像转换算法 (纯 Rust 编写)
|
// 5. 核心高性能图像转换算法 (纯 Rust 编写)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
||||||
|
/// RGB 像素转换为 OpenCV 风格的 HSV 值。
|
||||||
#[inline(always)]
|
#[inline(always)]
|
||||||
pub fn rgb_to_opencv_hsv(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
|
pub fn rgb_to_opencv_hsv(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
|
||||||
// 1. 规避高昂的除法,直接转为 f32 进行比对
|
// 1. 规避高昂的除法,直接转为 f32 进行比对
|
||||||
@@ -126,7 +138,7 @@ pub fn rgb_to_opencv_hsv(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
|
|||||||
let delta = max - min;
|
let delta = max - min;
|
||||||
|
|
||||||
// 2. 计算 H (色调) - 移除负数取余陷阱,改用平铺分支
|
// 2. 计算 H (色调) - 移除负数取余陷阱,改用平铺分支
|
||||||
let mut h = if delta == 0.0 {
|
let h = if delta == 0.0 {
|
||||||
0.0
|
0.0
|
||||||
} else if max == r_f {
|
} else if max == r_f {
|
||||||
let mut diff = (g_f - b_f) / delta;
|
let mut diff = (g_f - b_f) / delta;
|
||||||
@@ -159,3 +171,62 @@ pub fn rgb_to_opencv_hsv(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
|
|||||||
|
|
||||||
(h_opencv, s_opencv, v_opencv)
|
(h_opencv, s_opencv, v_opencv)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将图像转换为灰度图(L 模式)。
|
||||||
|
pub fn convert_to_grayscale(image: &DynamicImage) -> GrayImage {
|
||||||
|
// Rust utils 库的 to_luma8 会根据标准的亮度公式进行转换
|
||||||
|
image.to_luma8()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按指定宽高调整图像尺寸。
|
||||||
|
pub fn resize_image(
|
||||||
|
image: &DynamicImage,
|
||||||
|
target_width: u32,
|
||||||
|
target_height: u32,
|
||||||
|
// resample 参数我们直接使用 FilterType,Lanczos3 是最接近 Python LANCZOS 的
|
||||||
|
) -> DynamicImage {
|
||||||
|
// image::imageops::resize 的最高层封装
|
||||||
|
// FilterType::Lanczos3 与 Python Pillow 的 Image.LANCZOS 算法完全对齐,缩放质量最高
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
84
ddddocr-core/src/utils/tensor_transform.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
//! 张量变换:将异构形状的模型输出规整为统一格式。
|
||||||
|
|
||||||
|
use crate::OcrOutput;
|
||||||
|
use crate::error::{Result, TensorError};
|
||||||
|
use ndarray::s;
|
||||||
|
|
||||||
|
/// 将异构形状的模型输出规整为标准 `[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 {
|
||||||
|
// 形状: [Steps, 1, Classes]
|
||||||
|
(shape[0], shape[2], array)
|
||||||
|
} else if shape[0] == 1 {
|
||||||
|
// 形状: [1, Steps, Classes]
|
||||||
|
(shape[1], shape[2], array)
|
||||||
|
} else {
|
||||||
|
// 默认取第一个 batch: [Batch, Steps, Classes]
|
||||||
|
// 使用 ndarray 的 s! 宏,对应 Python 的 output[0, :, :]
|
||||||
|
let sliced = array.slice_move(s![0, .., ..]);
|
||||||
|
(shape[1], shape[2], sliced.into_dyn())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 形状: [Steps, Classes]
|
||||||
|
2 => (shape[0], shape[1], array),
|
||||||
|
// 形状: [Classes] -> 单字符输出(对应 Python 的 ndim == 0 保护逻辑)
|
||||||
|
// 我们把它虚构成一个 [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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 转换为标准的 2D 静态矩阵 [Steps, Classes]
|
||||||
|
let matrix_cow = data_dyn_view
|
||||||
|
.to_shape(ndarray::Ix2(steps, classes))
|
||||||
|
.map_err(|shape_err| {
|
||||||
|
// 如果是因为切片导致不连续且无法进行零拷贝变换,抛出 NonContiguousMemory
|
||||||
|
if !data_dyn_view.is_standard_layout() {
|
||||||
|
TensorError::NonContiguousMemory
|
||||||
|
} else {
|
||||||
|
// 否则,说明是纯粹的数据元素数量不对(Shape 不匹配),抛出专属的强类型错误
|
||||||
|
TensorError::LogitsDimensionMismatch(shape_err)
|
||||||
|
}
|
||||||
|
})?
|
||||||
|
.to_owned();
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
72
ddddocr-core/tests/api_surface.rs
Normal 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
25
ddddocr-ort/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "ddddocr-ort"
|
||||||
|
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", 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 }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# 开启后启用底层 ort 库的 CUDA 执行提供者支持。
|
||||||
|
cuda = ["ort/cuda"]
|
||||||
1
ddddocr-ort/src/det.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod session;
|
||||||
41
ddddocr-ort/src/det/session.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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 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;
|
||||||
|
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 (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,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
Ok(DetOutput::Detection(array3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DetEngine for DetRuntime {}
|
||||||
20
ddddocr-ort/src/lib.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//! # ddddocr-ort
|
||||||
|
//!
|
||||||
|
//! 基于 ONNX Runtime(ORT)的推理引擎实现:通过 [`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::{OcrBuilder, SlideResult, Slider};
|
||||||
|
pub use det::session::DetRuntime;
|
||||||
|
pub use ocr::session::OcrRuntime;
|
||||||
|
pub use types::Session;
|
||||||
7
ddddocr-ort/src/loader.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
mod error;
|
||||||
|
mod metadata;
|
||||||
|
mod model;
|
||||||
|
|
||||||
|
pub use error::{Error, ParseError, Result};
|
||||||
|
pub use metadata::{Metadata, ModelMetadataDto, NormalizationDto};
|
||||||
|
pub use model::ModelLoader;
|
||||||
101
ddddocr-ort/src/loader/error.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
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 模型/路径失败(如文件损坏、算子不支持、路径非法)
|
||||||
|
#[error("解析 ONNX 模型结构失败: {0}")]
|
||||||
|
ModelParse(#[from] ParseError),
|
||||||
|
|
||||||
|
/// 模型计算图优化失败(如常量折叠、形状推导失败)
|
||||||
|
#[error("优化 ORT 模型图失败: {0}")]
|
||||||
|
OptimizationFailed(#[source] OrtError),
|
||||||
|
|
||||||
|
/// 构建可执行 Session 失败(如输入输出 Tensor 类型/形状未确定)
|
||||||
|
#[error("构建可运行的 ORT Session 失败: {0}")]
|
||||||
|
RunnableBuildFailed(#[source] OrtError),
|
||||||
|
|
||||||
|
/// JSON 反序列化失败(自动透传 serde_json 报错)
|
||||||
|
#[error("模型 Metadata JSON 解析失败: {0}")]
|
||||||
|
JsonParse(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
/// 字节流非合法 UTF-8 编码(自动透传 Utf8Error)
|
||||||
|
#[error("Metadata 字节流不是合法的 UTF-8 编码: {0}")]
|
||||||
|
InvalidUtf8(#[from] std::str::Utf8Error),
|
||||||
|
|
||||||
|
/// 模型元数据内容解析失败。
|
||||||
|
#[error("模型元数据解析失败: {0}")]
|
||||||
|
MetadataParse(String),
|
||||||
|
|
||||||
|
/// 承载任何第三方扩展、解密、特定预处理插件在执行时产生的自定义错误
|
||||||
|
#[error("{0}: {1}")]
|
||||||
|
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
|
||||||
|
where
|
||||||
|
E: Into<Box<dyn std::error::Error + Send + Sync>>,
|
||||||
|
{
|
||||||
|
Self::Other(msg.into(), err.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
/// 从路径或字节流解析 ONNX 模型失败的错误。
|
||||||
|
pub enum ParseError {
|
||||||
|
/// 策略 A:从文件路径加载失败(附带路径上下文信息,方便排查是找不到文件还是格式不对)
|
||||||
|
#[error("从路径 '{0}' 加载 ONNX 模型失败: {1}")]
|
||||||
|
Path(String, #[source] OrtError),
|
||||||
|
|
||||||
|
/// 策略 B:从内存字节流加载失败(如 include_bytes! 传入的字节流损坏)
|
||||||
|
#[error("从内存字节流解析 ONNX 模型失败: {0}")]
|
||||||
|
Bytes(#[source] OrtError),
|
||||||
|
}
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
pub enum BuildError {
|
||||||
|
/// 底层 SessionBuilder 构建失败。
|
||||||
|
#[error("构建 ORT SessionBuilder 失败: {0}")]
|
||||||
|
BuildFailed(#[from] OrtError),
|
||||||
|
/// 线程数配置失败。
|
||||||
|
#[error("{0}")]
|
||||||
|
Threads(String),
|
||||||
|
/// 启用 CUDA 执行提供者失败。
|
||||||
|
#[error("{0}")]
|
||||||
|
EnabledCudaFailed(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(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
193
ddddocr-ort/src/loader/metadata.rs
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
use crate::loader::error::{Error, Result};
|
||||||
|
|
||||||
|
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,
|
||||||
|
/// 映射到 [-1.0, 1.0] -> (pixel / 255.0 - 0.5) / 0.5
|
||||||
|
MinusOneToOne,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<NormalizationDto> for Normalization {
|
||||||
|
fn from(dto: NormalizationDto) -> Self {
|
||||||
|
match dto {
|
||||||
|
NormalizationDto::ZeroToOne => Normalization::ZeroToOne,
|
||||||
|
NormalizationDto::MinusOneToOne => Normalization::MinusOneToOne,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仅用于反序列化 JSON 的中间临时结构体(DTO)
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ModelMetadataDto {
|
||||||
|
charset: Vec<String>,
|
||||||
|
word: bool,
|
||||||
|
#[serde(alias = "image")]
|
||||||
|
resize: Vec<i32>,
|
||||||
|
channel: u8,
|
||||||
|
/// 新增:允许在配置文件中指定归一化策略。
|
||||||
|
/// 使用 serde(default) 可以在不配置时提供一个默认值(比如默认 ZeroToOne)
|
||||||
|
#[serde(default = "default_normalization")]
|
||||||
|
normalization: NormalizationDto,
|
||||||
|
}
|
||||||
|
fn default_normalization() -> NormalizationDto {
|
||||||
|
NormalizationDto::ZeroToOne
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 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> {
|
||||||
|
let json_str = std::str::from_utf8(bytes)?;
|
||||||
|
Self::from_json_str(json_str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Metadata for ModelMetadata {
|
||||||
|
// --- 优雅的工厂模式构造器 ---
|
||||||
|
fn from_json_str(json_str: &str) -> Result<ModelMetadata> {
|
||||||
|
let dto: ModelMetadataDto = serde_json::from_str(json_str)?;
|
||||||
|
|
||||||
|
// 1. 将 DTO 的字符串数组转化为强类型的 Charset
|
||||||
|
let tokens: Vec<Cow<'static, str>> = dto.charset.into_iter().map(Cow::Owned).collect();
|
||||||
|
let charset = Charset::new(tokens);
|
||||||
|
|
||||||
|
// 2. 解析 resize 策略(重现 Python 的复杂条件判断)
|
||||||
|
if dto.resize.len() != 2 {
|
||||||
|
return Err(Error::MetadataParse(
|
||||||
|
"'resize (or image)' 字段必须是包含两个元素的数组,例如 [-1, 64]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let r0 = dto.resize[0];
|
||||||
|
let r1 = dto.resize[1];
|
||||||
|
|
||||||
|
let resize = if r0 == -1 {
|
||||||
|
if dto.word {
|
||||||
|
// 如果 word 为 true,且包含 -1,Python 里是 resize 为 (r1, r1) 的正方形
|
||||||
|
Resize::Square(r1 as u32)
|
||||||
|
} else {
|
||||||
|
// 如果 word 为 false,且包含 -1,Python 里是高度固定为 r1,宽度按原图比例缩放
|
||||||
|
Resize::DynamicWidth(r1 as u32)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 正常的固定宽高
|
||||||
|
Resize::Fixed(r0 as u32, r1 as u32)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ModelMetadata::new(
|
||||||
|
charset,
|
||||||
|
dto.word,
|
||||||
|
resize,
|
||||||
|
dto.channel,
|
||||||
|
dto.normalization.into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
131
ddddocr-ort/src/loader/model.rs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
use crate::loader::Error;
|
||||||
|
use crate::loader::error::{BuildError, ParseError, Result};
|
||||||
|
use crate::types::Session;
|
||||||
|
use ddddocr_core::traits::Loader;
|
||||||
|
use ort::session::Session as OrtSession;
|
||||||
|
use ort::session::builder::SessionBuilder;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
/// ORT 专用的链式构建器
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ModelLoader {
|
||||||
|
use_gpu: bool,
|
||||||
|
device_id: i32,
|
||||||
|
intra_threads: Option<usize>,
|
||||||
|
}
|
||||||
|
impl ModelLoader {
|
||||||
|
/// 开启或关闭 GPU 加速
|
||||||
|
pub fn use_gpu(mut self, enable: bool) -> Self {
|
||||||
|
self.use_gpu = enable;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 指定 GPU 设备 ID
|
||||||
|
pub fn device_id(mut self, id: i32) -> Self {
|
||||||
|
self.device_id = id;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
/// 指定 ORT 内部线程数。
|
||||||
|
pub fn num_threads(mut self, threads: usize) -> Self {
|
||||||
|
self.intra_threads = Some(threads);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 内部辅助方法:根据当前的配置构建 ORT 底层的 SessionBuilder
|
||||||
|
fn create_session_builder(&self) -> Result<SessionBuilder> {
|
||||||
|
let mut builder = OrtSession::builder().map_err(BuildError::BuildFailed)?;
|
||||||
|
// 如果用户显式设置了线程数,则配置给 ORT
|
||||||
|
if let Some(threads) = self.intra_threads {
|
||||||
|
builder = builder
|
||||||
|
.with_intra_threads(threads)
|
||||||
|
.map_err(|e| BuildError::Threads(format!("设置线程数失败: {e}")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.use_gpu {
|
||||||
|
// 根据 ort 库版本配置 CUDA 执行提供者 (Execution Provider)
|
||||||
|
#[cfg(feature = "cuda")]
|
||||||
|
{
|
||||||
|
use ort::ep::CUDAExecutionProvider;
|
||||||
|
let cuda_ep = CUDAExecutionProvider::default().with_device_id(self.device_id);
|
||||||
|
builder = builder
|
||||||
|
.with_execution_providers([cuda_ep.build()])
|
||||||
|
.map_err(|e| {
|
||||||
|
BuildError::EnabledCudaFailed(format!("配置 CUDA 硬件加速失败: {e}"))
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "cuda"))]
|
||||||
|
{
|
||||||
|
// 如果用户明确开启了 GPU,但 Feature 没编译进去,明确抛错提醒
|
||||||
|
return Err(BuildError::NotEnabledCuda(
|
||||||
|
"未启用 CUDA 支持:请在 Cargo.toml 中为 ddddocr-ort 开启 `cuda` feature"
|
||||||
|
.to_string(),
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Loader for ModelLoader {
|
||||||
|
type Session = Session;
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
fn build_for_path<P>(&self, model_path: P) -> Result<Session>
|
||||||
|
where
|
||||||
|
P: AsRef<std::path::Path>,
|
||||||
|
{
|
||||||
|
let path_ref = model_path.as_ref();
|
||||||
|
let mut builder = self.create_session_builder()?;
|
||||||
|
// Session::builder() 会返回 Result<SessionBuilder, ort::Error>
|
||||||
|
|
||||||
|
let session = builder
|
||||||
|
.commit_from_file(path_ref)
|
||||||
|
.map_err(|e| ParseError::Path(path_ref.display().to_string(), e))?;
|
||||||
|
|
||||||
|
Ok(Arc::new(Mutex::new(session)))
|
||||||
|
}
|
||||||
|
/// 策略 B:从内存字节流加载模型(配合 include_bytes! 使用)
|
||||||
|
fn build_from_bytes(&self, model_bytes: &[u8]) -> Result<Session> {
|
||||||
|
let mut builder = self.create_session_builder()?;
|
||||||
|
|
||||||
|
let session = builder
|
||||||
|
.commit_from_memory(model_bytes)
|
||||||
|
.map_err(ParseError::Bytes)?;
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ddddocr-ort/src/ocr.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod session;
|
||||||
188
ddddocr-ort/src/ocr/session.rs
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
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::{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::value::{Outlet, TensorElementType, ValueType};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
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() {
|
||||||
|
Some(TensorElementType::Int64) => {
|
||||||
|
let (view, actual_shape) = extract_tensor::<i64>(raw_value)?;
|
||||||
|
let array1 = view
|
||||||
|
.to_owned()
|
||||||
|
.into_dimensionality::<ndarray::Ix1>()
|
||||||
|
.map_err(|_| TensorError::DimensionMismatch {
|
||||||
|
expected: "1D 字符索引静态矩阵".to_string(),
|
||||||
|
actual: actual_shape,
|
||||||
|
})?;
|
||||||
|
Ok(OcrOutput::Indices(array1))
|
||||||
|
}
|
||||||
|
Some(TensorElementType::Float32) => {
|
||||||
|
let shape = raw_value.shape();
|
||||||
|
debug!("模型输出 shape 数据: {:?}", shape);
|
||||||
|
let (view, shape_vec) = extract_tensor::<f32>(raw_value)?;
|
||||||
|
normalize_ocr_logits(view, shape_vec.as_slice())
|
||||||
|
}
|
||||||
|
_ => Err(TensorError::UnknownOutputFormat),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Info for OcrRuntime {
|
||||||
|
fn input_info(&self) -> Result<Vec<TensorInfo>> {
|
||||||
|
let session_guard = lock_session(&self.session)?;
|
||||||
|
self.resolve_outlets(session_guard.inputs())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_info(&self) -> Result<Vec<TensorInfo>> {
|
||||||
|
let session_guard = lock_session(&self.session)?;
|
||||||
|
self.resolve_outlets(session_guard.outputs())
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
41
ddddocr-ort/src/runtime.rs
Normal 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))
|
||||||
|
}
|
||||||
7
ddddocr-ort/src/types.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
//! ORT 会话共享类型。
|
||||||
|
|
||||||
|
use ort::session::Session as OrtSession;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
/// ORT 会话句柄:由 [`crate::loader::ModelLoader`] 构建,推理时通过互斥锁串行访问。
|
||||||
|
pub type Session = Arc<Mutex<OrtSession>>;
|
||||||
76
ddddocr-ort/tests/api_surface.rs
Normal 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(())
|
||||||
|
}
|
||||||
25
ddddocr-ort/tests/common/mod.rs
Normal 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
@@ -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
@@ -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(())
|
||||||
|
}
|
||||||
52
ddddocr-ort/tests/slide.rs
Normal 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);
|
||||||
|
}
|
||||||
22
ddddocr-tract2/Cargo.toml
Normal 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 }
|
||||||
1
ddddocr-tract2/src/det.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod session;
|
||||||
44
ddddocr-tract2/src/det/session.rs
Normal 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
@@ -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;
|
||||||
7
ddddocr-tract2/src/loader.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
mod error;
|
||||||
|
mod metadata;
|
||||||
|
mod model;
|
||||||
|
|
||||||
|
pub use error::{Error, ParseError, Result};
|
||||||
|
pub use metadata::{Metadata, ModelMetadataDto, NormalizationDto};
|
||||||
|
pub use model::ModelLoader;
|
||||||
82
ddddocr-tract2/src/loader/error.rs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
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}")]
|
||||||
|
ModelParse(#[from] ParseError),
|
||||||
|
|
||||||
|
/// 模型计算图优化失败(如常量折叠、形状推导失败)
|
||||||
|
#[error("优化 Tract 模型图失败: {0}")]
|
||||||
|
OptimizationFailed(#[source] TractError),
|
||||||
|
|
||||||
|
/// 构建可执行 Session 失败(如输入输出 Tensor 类型/形状未确定)
|
||||||
|
#[error("构建可运行 Tract 实例失败: {0}")]
|
||||||
|
RunnableBuildFailed(#[source] TractError),
|
||||||
|
|
||||||
|
/// JSON 反序列化失败(自动透传 serde_json 报错)
|
||||||
|
#[error("模型 Metadata JSON 解析失败: {0}")]
|
||||||
|
JsonParse(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
/// 字节流非合法 UTF-8 编码(自动透传 Utf8Error)
|
||||||
|
#[error("Metadata 字节流不是合法的 UTF-8 编码: {0}")]
|
||||||
|
InvalidUtf8(#[from] std::str::Utf8Error),
|
||||||
|
|
||||||
|
/// 模型元数据内容解析失败。
|
||||||
|
#[error("模型元数据解析失败: {0}")]
|
||||||
|
MetadataParse(String),
|
||||||
|
|
||||||
|
/// 承载任何第三方扩展、解密、特定预处理插件在执行时产生的自定义错误
|
||||||
|
#[error("{0}: {1}")]
|
||||||
|
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
|
||||||
|
where
|
||||||
|
E: Into<Box<dyn std::error::Error + Send + Sync>>,
|
||||||
|
{
|
||||||
|
Self::Other(msg.into(), err.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
/// 从路径或字节流解析 ONNX 模型失败的错误。
|
||||||
|
pub enum ParseError {
|
||||||
|
/// 策略 A:从文件路径加载失败(附带路径上下文信息,方便排查是找不到文件还是格式不对)
|
||||||
|
#[error("从路径 '{0}' 加载 ONNX 模型失败: {1}")]
|
||||||
|
Path(String, #[source] TractError),
|
||||||
|
|
||||||
|
/// 策略 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(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
195
ddddocr-tract2/src/loader/metadata.rs
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
use crate::loader::error::{Error, Result};
|
||||||
|
|
||||||
|
use ddddocr_core::ModelMetadata;
|
||||||
|
use ddddocr_core::Resize;
|
||||||
|
use ddddocr_core::{Charset, Normalization};
|
||||||
|
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,
|
||||||
|
/// 映射到 [-1.0, 1.0] -> (pixel / 255.0 - 0.5) / 0.5
|
||||||
|
MinusOneToOne,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<NormalizationDto> for Normalization {
|
||||||
|
fn from(dto: NormalizationDto) -> Self {
|
||||||
|
match dto {
|
||||||
|
NormalizationDto::ZeroToOne => Normalization::ZeroToOne,
|
||||||
|
NormalizationDto::MinusOneToOne => Normalization::MinusOneToOne,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仅用于反序列化 JSON 的中间临时结构体(DTO)
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ModelMetadataDto {
|
||||||
|
charset: Vec<String>,
|
||||||
|
word: bool,
|
||||||
|
#[serde(alias = "image")]
|
||||||
|
resize: Vec<i32>,
|
||||||
|
channel: u8,
|
||||||
|
/// 新增:允许在配置文件中指定归一化策略。
|
||||||
|
/// 使用 serde(default) 可以在不配置时提供一个默认值(比如默认 ZeroToOne)
|
||||||
|
#[serde(default = "default_normalization")]
|
||||||
|
normalization: NormalizationDto,
|
||||||
|
}
|
||||||
|
fn default_normalization() -> NormalizationDto {
|
||||||
|
NormalizationDto::ZeroToOne
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 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> {
|
||||||
|
let json_str = std::str::from_utf8(bytes)?;
|
||||||
|
Self::from_json_str(json_str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Metadata for ModelMetadata {
|
||||||
|
// --- 优雅的工厂模式构造器 ---
|
||||||
|
fn from_json_str(json_str: &str) -> Result<ModelMetadata> {
|
||||||
|
let dto: ModelMetadataDto = serde_json::from_str(json_str)?;
|
||||||
|
|
||||||
|
// 1. 将 DTO 的字符串数组转化为强类型的 Charset
|
||||||
|
let tokens: Vec<Cow<'static, str>> = dto.charset.into_iter().map(Cow::Owned).collect();
|
||||||
|
let charset = Charset::new(tokens);
|
||||||
|
|
||||||
|
// 2. 解析 resize 策略(重现 Python 的复杂条件判断)
|
||||||
|
if dto.resize.len() != 2 {
|
||||||
|
return Err(Error::MetadataParse(
|
||||||
|
"'resize (or image)' 字段必须是包含两个元素的数组,例如 [-1, 64]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let r0 = dto.resize[0];
|
||||||
|
let r1 = dto.resize[1];
|
||||||
|
|
||||||
|
let resize = if r0 == -1 {
|
||||||
|
if dto.word {
|
||||||
|
// 如果 word 为 true,且包含 -1,Python 里是 resize 为 (r1, r1) 的正方形
|
||||||
|
Resize::Square(r1 as u32)
|
||||||
|
} else {
|
||||||
|
// 如果 word 为 false,且包含 -1,Python 里是高度固定为 r1,宽度按原图比例缩放
|
||||||
|
Resize::DynamicWidth(r1 as u32)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 正常的固定宽高
|
||||||
|
Resize::Fixed(r0 as u32, r1 as u32)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ModelMetadata::new(
|
||||||
|
charset,
|
||||||
|
dto.word,
|
||||||
|
resize,
|
||||||
|
dto.channel,
|
||||||
|
dto.normalization.into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
95
ddddocr-tract2/src/loader/model.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
use crate::loader::error::{Error, ParseError, Result};
|
||||||
|
use crate::types::Session;
|
||||||
|
use ddddocr_core::traits::Loader;
|
||||||
|
use std::io::Cursor;
|
||||||
|
use tract_linalg::multithread::{Executor, set_default_executor};
|
||||||
|
use tract_onnx::onnx;
|
||||||
|
use tract_onnx::prelude::*;
|
||||||
|
|
||||||
|
/// Tract 专用的链式构建器
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ModelLoader {
|
||||||
|
num_threads: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelLoader {
|
||||||
|
/// 可选扩展:设置 CPU 线程数(不提供任何 GPU 相关的 API)
|
||||||
|
pub fn num_threads(mut self, threads: usize) -> Self {
|
||||||
|
self.num_threads = Some(threads);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn setup_tract_threads(&self) {
|
||||||
|
// 💡 1. 如果设置了线程数,可以通过 tract 的 multithread 配置应用给 model
|
||||||
|
if let Some(threads) = self.num_threads {
|
||||||
|
// 在 Tract 中可以通过 set_num_threads 或设置底层环境控制并发
|
||||||
|
// (注:Tract 0.20+ 版本支持全局/局部线程控制)
|
||||||
|
let executor = if threads <= 1 {
|
||||||
|
Executor::SingleThread
|
||||||
|
} else {
|
||||||
|
Executor::multithread(threads)
|
||||||
|
};
|
||||||
|
set_default_executor(executor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Loader for ModelLoader {
|
||||||
|
type Session = Session;
|
||||||
|
type Error = Error;
|
||||||
|
fn build_for_path<P>(&self, model_path: P) -> Result<Session>
|
||||||
|
where
|
||||||
|
P: AsRef<std::path::Path>,
|
||||||
|
{
|
||||||
|
self.setup_tract_threads();
|
||||||
|
let path_ref = model_path.as_ref();
|
||||||
|
|
||||||
|
let session = onnx()
|
||||||
|
.model_for_path(path_ref)
|
||||||
|
.map_err(|e| ParseError::Path(path_ref.display().to_string(), e))?
|
||||||
|
.into_optimized()
|
||||||
|
.map_err(Error::OptimizationFailed)?
|
||||||
|
.into_runnable()
|
||||||
|
.map_err(Error::RunnableBuildFailed)?;
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
/// 策略 B:从内存字节流加载模型(配合 include_bytes! 使用)
|
||||||
|
fn build_from_bytes(&self, model_bytes: &[u8]) -> Result<Session> {
|
||||||
|
self.setup_tract_threads();
|
||||||
|
// 使用 std::io::Cursor 将 &[u8] 包装为可读的流(实现 std::io::Read)
|
||||||
|
let mut cursor = Cursor::new(model_bytes);
|
||||||
|
|
||||||
|
let session = onnx()
|
||||||
|
.model_for_read(&mut cursor)
|
||||||
|
.map_err(ParseError::Bytes)?
|
||||||
|
.into_optimized()
|
||||||
|
.map_err(Error::OptimizationFailed)?
|
||||||
|
.into_runnable()
|
||||||
|
.map_err(Error::RunnableBuildFailed)?;
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_config() {
|
||||||
|
let loader = ModelLoader::default();
|
||||||
|
assert!(loader.num_threads.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ddddocr-tract2/src/ocr.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod session;
|
||||||
174
ddddocr-tract2/src/ocr/session.rs
Normal 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(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
25
ddddocr-tract2/src/runtime.rs
Normal 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()))
|
||||||
|
}
|
||||||
7
ddddocr-tract2/src/types.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
//! Tract 会话共享类型。
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tract_onnx::prelude::TypedRunnableModel;
|
||||||
|
|
||||||
|
/// Tract 会话句柄:由 [`crate::loader::ModelLoader`] 构建,可直接并发执行推理。
|
||||||
|
pub type Session = Arc<TypedRunnableModel>;
|
||||||
74
ddddocr-tract2/tests/api_surface.rs
Normal 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(())
|
||||||
|
}
|
||||||
25
ddddocr-tract2/tests/common/mod.rs
Normal 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-tract2/tests/det.rs
Normal 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(())
|
||||||
|
}
|
||||||
55
ddddocr-tract2/tests/ocr.rs
Normal 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(())
|
||||||
|
}
|
||||||
52
ddddocr-tract2/tests/slide.rs
Normal 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);
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
@@ -1,3 +0,0 @@
|
|||||||
mod slide;
|
|
||||||
|
|
||||||
pub use slide::{SlideResult, Slider};
|
|
||||||
18
src/error.rs
@@ -1,18 +0,0 @@
|
|||||||
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. 或者直接将模型文件重命名并放置在您运行程序的“当前工作目录”或“可执行文件同级目录”下。
|
|
||||||
================================================================================";
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
mod algo;
|
|
||||||
mod error;
|
|
||||||
pub mod models;
|
|
||||||
pub mod utils;
|
|
||||||
|
|
||||||
pub use crate::algo::{SlideResult, Slider};
|
|
||||||
pub use crate::models::det::{DetBuilder, DetSession, DetectionResult, Detector};
|
|
||||||
pub use crate::models::ocr::{Ocr, OcrBuilder, OcrResult, OcrSession};
|
|
||||||
pub use models::ocr::metadata::ModelMetadata;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
use crate::models::det::executor::Detector;
|
|
||||||
use crate::models::det::session::DetSession;
|
|
||||||
|
|
||||||
pub struct DetBuilder {
|
|
||||||
use_gpu: bool,
|
|
||||||
device_id: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DetBuilder {
|
|
||||||
fn use_gpu(mut self) -> Self {
|
|
||||||
self.use_gpu = true;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
fn device_id(mut self, device_id: u8) -> Self {
|
|
||||||
self.device_id = device_id;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
fn build(self, session: &DetSession) -> Detector<'_> {
|
|
||||||
Detector {
|
|
||||||
session,
|
|
||||||
use_gpu: self.use_gpu,
|
|
||||||
device_id: self.device_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
use crate::models::loader::{ModelLoader, ModelSession, ModelType};
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use std::path::Path;
|
|
||||||
use tract_onnx::prelude::{tvec, Graph, IntoTensor, RunnableModel, Tensor, TypedFact, TypedOp};
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct DetSession {
|
|
||||||
pub(crate) session: RunnableModel<TypedFact, Box<dyn TypedOp>, Graph<TypedFact, Box<dyn TypedOp>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ModelSession for DetSession {
|
|
||||||
fn get_model_type(&self) -> ModelType {
|
|
||||||
todo!()
|
|
||||||
}
|
|
||||||
fn desc(&self) -> String {
|
|
||||||
"Detection Model 加载成功".to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DetSession {
|
|
||||||
pub fn new<P>(model_path: P) -> Result<Self, anyhow::Error>
|
|
||||||
where
|
|
||||||
P: AsRef<Path>,
|
|
||||||
{
|
|
||||||
let session = ModelLoader::model_for_path(&model_path)?.session;
|
|
||||||
Ok(Self { session })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn model_from_bytes(model_bytes: &[u8]) -> Result<Self, anyhow::Error> {
|
|
||||||
let session = ModelLoader::model_from_bytes(model_bytes)?.session;
|
|
||||||
Ok(Self { session })
|
|
||||||
}
|
|
||||||
pub fn inference(&self, tensor: Tensor) -> anyhow::Result<Tensor> {
|
|
||||||
// tract 的 run 会返回一个 Vec<TValue>,我们通常只需要第一个输出
|
|
||||||
// let result = self.ocr.run(tvec!(tensor.into()))?;
|
|
||||||
let mut result = self
|
|
||||||
.session
|
|
||||||
.run(tvec!(tensor.into()))
|
|
||||||
.context("执行模型推理失败")?;
|
|
||||||
println!("模型输出原始数据: {:?}", result);
|
|
||||||
Ok(result.swap_remove(0).into_tensor())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
use anyhow::Context;
|
|
||||||
use std::io::Cursor;
|
|
||||||
use tract_onnx::onnx;
|
|
||||||
use tract_onnx::prelude::*;
|
|
||||||
|
|
||||||
/// OCR 模型:包含路径和字符集
|
|
||||||
|
|
||||||
pub enum ModelType {
|
|
||||||
Ocr,
|
|
||||||
Det,
|
|
||||||
Custom,
|
|
||||||
}
|
|
||||||
// 定义统一的 trait
|
|
||||||
pub trait ModelSession {
|
|
||||||
fn get_model_type(&self) -> ModelType;
|
|
||||||
fn desc(&self) -> String;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ModelLoader {
|
|
||||||
pub session: RunnableModel<TypedFact, Box<dyn TypedOp>, Graph<TypedFact, Box<dyn TypedOp>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ModelLoader {
|
|
||||||
pub fn model_for_path<P>(model_path: P) -> anyhow::Result<Self>
|
|
||||||
where
|
|
||||||
P: AsRef<std::path::Path>,
|
|
||||||
{
|
|
||||||
let session = onnx()
|
|
||||||
.model_for_path(model_path)
|
|
||||||
.with_context(|| "加载 ONNX 模型失败,请检查路径是否正确")?
|
|
||||||
.into_optimized()?
|
|
||||||
.into_runnable()?;
|
|
||||||
Ok(Self { session })
|
|
||||||
}
|
|
||||||
/// 策略 B:从内存字节流加载模型(配合 include_bytes! 使用)
|
|
||||||
pub fn model_from_bytes(model_bytes: &[u8]) -> anyhow::Result<Self> {
|
|
||||||
// 使用 std::io::Cursor 将 &[u8] 包装为可读的流(实现 std::io::Read)
|
|
||||||
let mut cursor = Cursor::new(model_bytes);
|
|
||||||
|
|
||||||
let session = onnx()
|
|
||||||
.model_for_read(&mut cursor)
|
|
||||||
.with_context(|| "从内存字节流解析 ONNX 模型失败")?
|
|
||||||
.into_optimized()?
|
|
||||||
.into_runnable()?;
|
|
||||||
|
|
||||||
Ok(Self { session })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
pub mod loader;
|
|
||||||
pub mod ocr;
|
|
||||||
pub mod det;
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
use anyhow::{anyhow, Result};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use std::borrow::Cow;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 3. 字符集核心结构体 (重命名为 Charset)
|
|
||||||
// ==========================================
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Charset {
|
|
||||||
// 使用 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 {
|
|
||||||
// 内部底层统一收拢构造
|
|
||||||
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 {
|
|
||||||
tokens,
|
|
||||||
char_to_idx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 业务策略方法 ---
|
|
||||||
|
|
||||||
/// 将字符转为索引,不存在返回 -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
|
|
||||||
} else {
|
|
||||||
-1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将索引转为字符引用,零拷贝。若越界返回 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()
|
|
||||||
}
|
|
||||||
pub fn size(&self) -> usize {
|
|
||||||
self.tokens.len()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 4. 标准 Display 接口实现 (对应 __str__)
|
|
||||||
// ==========================================
|
|
||||||
impl std::fmt::Display for Charset {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(f, "Charset [Total Size: {}", self.size(),)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// 1. 辅助定义的枚举与结构体
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")] // 支持 json 中写 "zero_to_one" 或 "minus_one_to_one"
|
|
||||||
pub enum Normalization {
|
|
||||||
/// 映射到 [0.0, 1.0] -> pixel / 255.0
|
|
||||||
ZeroToOne,
|
|
||||||
/// 映射到 [-1.0, 1.0] -> (pixel / 255.0 - 0.5) / 0.5
|
|
||||||
MinusOneToOne,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Normalization {
|
|
||||||
/// 统一归一化计算逻辑
|
|
||||||
#[inline(always)]
|
|
||||||
pub fn normalize(&self, pixel: f32) -> f32 {
|
|
||||||
match self {
|
|
||||||
Normalization::ZeroToOne => pixel / 255.0,
|
|
||||||
Normalization::MinusOneToOne => (pixel / 255.0 - 0.5) / 0.5,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 图像缩放策略枚举
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Resize {
|
|
||||||
/// 固定宽高,例如 (64, 64)
|
|
||||||
Fixed(u32, u32),
|
|
||||||
/// 高度固定,宽度根据原始比例动态计算(对应 Python 的 [-1, H])
|
|
||||||
DynamicWidth(u32),
|
|
||||||
/// 单字识别的正方形切图(对应 Python 的 word 为 True 且 [-1, H])
|
|
||||||
Square(u32),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 仅用于反序列化 JSON 的中间临时结构体(DTO)
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct ModelMetadataDto {
|
|
||||||
charset: Vec<String>,
|
|
||||||
word: bool,
|
|
||||||
#[serde(alias = "image")]
|
|
||||||
resize: Vec<i32>,
|
|
||||||
channel: u8,
|
|
||||||
/// 新增:允许在配置文件中指定归一化策略。
|
|
||||||
/// 使用 serde(default) 可以在不配置时提供一个默认值(比如默认 ZeroToOne)
|
|
||||||
#[serde(default = "default_normalization")]
|
|
||||||
normalization: Normalization,
|
|
||||||
}
|
|
||||||
fn default_normalization() -> Normalization {
|
|
||||||
Normalization::ZeroToOne
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ModelMetadata {
|
|
||||||
/// 字符集管理器
|
|
||||||
pub charset: Charset,
|
|
||||||
/// 是否为单字识别模型
|
|
||||||
pub word: bool,
|
|
||||||
/// 预处理的缩放策略
|
|
||||||
pub resize: Resize,
|
|
||||||
/// 图像通道数 (1 或 3)
|
|
||||||
pub channel: u8,
|
|
||||||
/// 新增:传递给核心业务使用的归一化配置
|
|
||||||
pub normalization: Normalization,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ModelMetadata {
|
|
||||||
// --- 优雅的工厂模式构造器 ---
|
|
||||||
/// 通用的静态切片转换构造器
|
|
||||||
pub fn from_static_slice(
|
|
||||||
slice: &[&'static str],
|
|
||||||
word: bool,
|
|
||||||
resize: Resize,
|
|
||||||
channel: u8,
|
|
||||||
normalization: Normalization,
|
|
||||||
) -> Self {
|
|
||||||
let tokens: Vec<Cow<'static, str>> = slice.iter().map(|&s| Cow::Borrowed(s)).collect();
|
|
||||||
Self {
|
|
||||||
charset: Charset::new(tokens),
|
|
||||||
word,
|
|
||||||
resize,
|
|
||||||
channel,
|
|
||||||
normalization,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn from_json_str(json_str: &str) -> Result<Self> {
|
|
||||||
let dto: ModelMetadataDto = serde_json::from_str(json_str)
|
|
||||||
.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,且包含 -1,Python 里是 resize 为 (r1, r1) 的正方形
|
|
||||||
Resize::Square(r1 as u32)
|
|
||||||
} else {
|
|
||||||
// 如果 word 为 false,且包含 -1,Python 里是高度固定为 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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/// 机制 2:从内存字节流加载(极大地方便 include_bytes! 或网络下载)
|
|
||||||
pub fn from_json_bytes(bytes: &[u8]) -> Result<Self> {
|
|
||||||
let json_str = std::str::from_utf8(bytes)
|
|
||||||
.map_err(|e| anyhow!("JSON 字节流不是合法的 UTF-8 编码: {}", e))?;
|
|
||||||
Self::from_json_str(json_str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
mod builder;
|
|
||||||
mod executor;
|
|
||||||
mod session;
|
|
||||||
pub mod metadata;
|
|
||||||
pub mod color_filter;
|
|
||||||
mod token_filter;
|
|
||||||
|
|
||||||
pub use builder::OcrBuilder;
|
|
||||||
pub use executor::{Ocr, OcrResult};
|
|
||||||
pub use session::OcrSession;
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
use crate::models::ocr::metadata::ModelMetadata;
|
|
||||||
use crate::models::loader::{ModelLoader, ModelSession, ModelType};
|
|
||||||
use anyhow::Context;
|
|
||||||
use anyhow::Result;
|
|
||||||
use std::path::Path;
|
|
||||||
use tract_onnx::prelude::{tvec, Graph, IntoTensor, RunnableModel, Tensor, TypedFact, TypedOp};
|
|
||||||
|
|
||||||
pub struct OcrSession {
|
|
||||||
pub session: RunnableModel<TypedFact, Box<dyn TypedOp>, Graph<TypedFact, Box<dyn TypedOp>>>,
|
|
||||||
pub model_metadata: ModelMetadata,
|
|
||||||
}
|
|
||||||
impl ModelSession for OcrSession {
|
|
||||||
fn get_model_type(&self) -> ModelType {
|
|
||||||
todo!("使用thiserror作为错误处理的库,thiserror 专门用于开发库(Library)");
|
|
||||||
}
|
|
||||||
fn desc(&self) -> String {
|
|
||||||
"Ocr Model 加载成功".to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl OcrSession {
|
|
||||||
pub fn new<P>(model_path: P, model_metadata: ModelMetadata) -> Result<Self, anyhow::Error>
|
|
||||||
where
|
|
||||||
P: AsRef<Path>,
|
|
||||||
{
|
|
||||||
let session = ModelLoader::model_for_path(model_path)?.session;
|
|
||||||
Ok(Self {
|
|
||||||
session,
|
|
||||||
model_metadata,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn model_from_bytes(
|
|
||||||
model_bytes: &[u8],
|
|
||||||
model_metadata: ModelMetadata,
|
|
||||||
) -> Result<Self, anyhow::Error> {
|
|
||||||
let session = ModelLoader::model_from_bytes(model_bytes)?.session;
|
|
||||||
Ok(Self {
|
|
||||||
session,
|
|
||||||
model_metadata,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/// 对应 Python 的 _inference
|
|
||||||
pub fn inference(&self, tensor: Tensor) -> anyhow::Result<Tensor> {
|
|
||||||
// tract 的 run 会返回一个 Vec<TValue>,我们通常只需要第一个输出
|
|
||||||
// let result = self.ocr.run(tvec!(tensor.into()))?;
|
|
||||||
let mut result = self
|
|
||||||
.session
|
|
||||||
.run(tvec!(tensor.into()))
|
|
||||||
.context("执行模型推理失败")?;
|
|
||||||
println!("模型输出原始数据: {:?}", result);
|
|
||||||
Ok(result.swap_remove(0).into_tensor())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
use anyhow::{Context, Result, anyhow, bail};
|
|
||||||
use base64::{Engine as _, engine::general_purpose};
|
|
||||||
use image::{DynamicImage, GenericImageView, ImageBuffer, ImageFormat, Luma, Rgb, RgbImage, Rgba};
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use tract_onnx::prelude::tract_ndarray::{Array3, ArrayD, ArrayViewD};
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum ColorMode {
|
|
||||||
RGB,
|
|
||||||
RGBA,
|
|
||||||
L,
|
|
||||||
}
|
|
||||||
/// 定义支持的输入类型枚举
|
|
||||||
pub enum ImageInput {
|
|
||||||
Bytes(Vec<u8>),
|
|
||||||
Array(ArrayD<u8>), // 对应 numpy 数组
|
|
||||||
Path(PathBuf),
|
|
||||||
Base64(String),
|
|
||||||
DynamicImage(DynamicImage),
|
|
||||||
}
|
|
||||||
/// 模拟 Python 的 load_image_from_input
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn load_image_from_input(img_input: ImageInput) -> Result<DynamicImage> {
|
|
||||||
match img_input {
|
|
||||||
// 2. 处理字节流 (Bytes)
|
|
||||||
ImageInput::Bytes(bytes) => {
|
|
||||||
image::load_from_memory(&bytes).context("Failed to load utils from bytes")
|
|
||||||
}
|
|
||||||
// 1. 已经是 DynamicImage
|
|
||||||
ImageInput::DynamicImage(i) => Ok(i),
|
|
||||||
// 5. 处理 ndarray (Numpy-like)
|
|
||||||
// 假设输入是 HWC 格式的 Array3<u8>
|
|
||||||
ImageInput::Array(a) => numpy_to_pil_image(a.view()),
|
|
||||||
// 4. 处理 Base64 字符串
|
|
||||||
ImageInput::Base64(b) => base64_to_image(&b),
|
|
||||||
// 3. 处理文件路径 (Path)
|
|
||||||
ImageInput::Path(p) => image::open(p).context("Failed to open utils from path"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn base64_to_image(b64_str: &str) -> Result<DynamicImage> {
|
|
||||||
// 过滤掉可能存在的 base64 前缀,例如 "data:utils/png;base64,"
|
|
||||||
let clean_b64 = if let Some(pos) = b64_str.find(",") {
|
|
||||||
&b64_str[pos + 1..]
|
|
||||||
} else {
|
|
||||||
&b64_str
|
|
||||||
};
|
|
||||||
|
|
||||||
let bytes = general_purpose::STANDARD
|
|
||||||
.decode(clean_b64.trim())
|
|
||||||
.map_err(|e| anyhow!("Base64 decode error: {}", e))?;
|
|
||||||
|
|
||||||
image::load_from_memory(&bytes).context("Failed to load utils from decoded base64")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取图片文件并转换为 base64 编码字符串
|
|
||||||
/// 对应 Python 版 get_img_base64
|
|
||||||
pub fn get_img_base64<P: AsRef<Path>>(image_path: P) -> Result<String> {
|
|
||||||
// 1. 读取文件原始字节流
|
|
||||||
// 使用 AsRef<Path> 泛型可以让函数同时支持 String, &str, PathBuf 等类型
|
|
||||||
let image_data = fs::read(&image_path)
|
|
||||||
.with_context(|| format!("Failed to read utils file: {:?}", image_path.as_ref()))?;
|
|
||||||
|
|
||||||
// 2. 进行 Base64 编码
|
|
||||||
// 使用 STANDARD 引擎对齐 Python 的 base64.b64encode
|
|
||||||
let b64_string = general_purpose::STANDARD.encode(image_data);
|
|
||||||
|
|
||||||
Ok(b64_string)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 封装数组转图像的逻辑,对齐 Python 版 _numpy_to_pil_image
|
|
||||||
fn numpy_to_pil_image(array: ArrayViewD<u8>) -> Result<DynamicImage> {
|
|
||||||
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();
|
|
||||||
|
|
||||||
match dim {
|
|
||||||
// 对应 Python: len(array.shape) == 2 (灰度图 H, W)
|
|
||||||
2 => {
|
|
||||||
let (h, w) = (shape[0], shape[1]);
|
|
||||||
ImageBuffer::<Luma<u8>, _>::from_raw(w as u32, h as u32, raw_data)
|
|
||||||
.map(DynamicImage::ImageLuma8)
|
|
||||||
.ok_or_else(|| anyhow!("Failed to create Luma utils from 2D array"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 对应 Python: len(array.shape) == 3 (H, W, C)
|
|
||||||
3 => {
|
|
||||||
let (h, w, c) = (shape[0], shape[1], shape[2]);
|
|
||||||
match c {
|
|
||||||
// 对应 Python: array.shape[2] == 1 (单通道 H, W, 1)
|
|
||||||
1 => ImageBuffer::<Luma<u8>, _>::from_raw(w as u32, h as u32, raw_data)
|
|
||||||
.map(DynamicImage::ImageLuma8),
|
|
||||||
|
|
||||||
// 对应 Python: array.shape[2] == 3 (RGB H, W, 3)
|
|
||||||
3 => ImageBuffer::<Rgb<u8>, _>::from_raw(w as u32, h as u32, raw_data)
|
|
||||||
.map(DynamicImage::ImageRgb8),
|
|
||||||
|
|
||||||
// 对应 Python: array.shape[2] == 4 (RGBA H, W, 4)
|
|
||||||
4 => ImageBuffer::<Rgba<u8>, _>::from_raw(w as u32, h as u32, raw_data)
|
|
||||||
.map(DynamicImage::ImageRgba8),
|
|
||||||
|
|
||||||
_ => {
|
|
||||||
return Err(anyhow!("不支持的通道数: {}", c));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.ok_or_else(|| anyhow!("转换彩色图失败"))
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => Err(anyhow!("不支持的数组维度: {},仅支持 2D 或 3D", dim)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 对应 Python 的 png_rgba_black_preprocess
|
|
||||||
/// 将带有透明通道的图片转换为白色背景的 RGB 图片
|
|
||||||
|
|
||||||
pub fn png_rgba_white_preprocess(img: &DynamicImage) -> DynamicImage {
|
|
||||||
// 1. 检查是否包含透明通道,如果没有,直接克隆并返回
|
|
||||||
if !img.color().has_alpha() {
|
|
||||||
return DynamicImage::ImageRgb8(img.to_rgb8());
|
|
||||||
}
|
|
||||||
|
|
||||||
let (width, height) = img.dimensions();
|
|
||||||
|
|
||||||
// 2. 创建一个新的 RGB 图像缓冲,默认填充为白色 (255, 255, 255)
|
|
||||||
let mut background = ImageBuffer::from_pixel(width, height, Rgb([255u8, 255u8, 255u8]));
|
|
||||||
|
|
||||||
// 3. 获取原图的 RGBA 视图
|
|
||||||
let rgba_img = img.to_rgba8();
|
|
||||||
|
|
||||||
// 4. 遍历像素并手动进行 Alpha 混合
|
|
||||||
// 对应 Python 的 utils.paste(img, ..., mask=img)
|
|
||||||
// 使用 enumerate_pixels_mut 同时获取坐标和背景像素的可变引用,减少查找开销
|
|
||||||
for (x, y, bg_pixel) in background.enumerate_pixels_mut() {
|
|
||||||
// 安全性说明:x, y 源自 background 尺寸,与 rgba_img 一致,get_pixel 是安全的
|
|
||||||
let src_pixel = rgba_img.get_pixel(x, y);
|
|
||||||
let alpha_u8 = src_pixel[3];
|
|
||||||
|
|
||||||
match alpha_u8 {
|
|
||||||
// 情况 A:完全不透明,直接覆盖背景色
|
|
||||||
255 => {
|
|
||||||
bg_pixel.0 = [src_pixel[0], src_pixel[1], src_pixel[2]];
|
|
||||||
}
|
|
||||||
// 情况 B:完全透明,保持背景色(白色),无需操作
|
|
||||||
0 => {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// 情况 C:半透明,进行 Alpha 混合计算
|
|
||||||
_ => {
|
|
||||||
let alpha = alpha_u8 as f32 / 255.0;
|
|
||||||
let inv_alpha = 1.0 - alpha;
|
|
||||||
|
|
||||||
bg_pixel[0] = (src_pixel[0] as f32 * alpha + 255.0 * inv_alpha).round() as u8;
|
|
||||||
bg_pixel[1] = (src_pixel[1] as f32 * alpha + 255.0 * inv_alpha).round() as u8;
|
|
||||||
bg_pixel[2] = (src_pixel[2] as f32 * alpha + 255.0 * inv_alpha).round() as u8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DynamicImage::ImageRgb8(background)
|
|
||||||
}
|
|
||||||
pub fn image_to_numpy(image: &DynamicImage, mode: ColorMode) -> Result<Array3<u8>> {
|
|
||||||
// 1. 模式转换 (对应 utils.convert(target_mode)),此函数在时保留看后续优化是否需要替代image_to_ndarray
|
|
||||||
// Rust utils 库通过 to_rgb8, to_luma8 等方法实现转换
|
|
||||||
let (width, height) = image.dimensions();
|
|
||||||
|
|
||||||
let (channels, raw) = match mode {
|
|
||||||
ColorMode::RGB => (3, image.to_rgb8().into_raw()),
|
|
||||||
ColorMode::L => (1, image.to_luma8().into_raw()),
|
|
||||||
ColorMode::RGBA => (4, image.to_rgba8().into_raw()),
|
|
||||||
};
|
|
||||||
|
|
||||||
Array3::from_shape_vec((height as usize, width as usize, channels), raw)
|
|
||||||
.map_err(|e| anyhow!("Failed to build ndarray: {}", e))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn numpy_to_image(array: ArrayViewD<u8>, mode: ColorMode) -> Result<DynamicImage> {
|
|
||||||
let shape = array.shape();
|
|
||||||
// 1. 基础维度检查 (必须是 H, W, C 三维数组)
|
|
||||||
if shape.len() != 3 {
|
|
||||||
bail!("Expected a 3D array (H, W, C), but got {}D", shape.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
let height = shape[0] as u32;
|
|
||||||
let width = shape[1] as u32;
|
|
||||||
let channels = shape[2];
|
|
||||||
// 2. 检查通道数是否与模式匹配
|
|
||||||
let expected_channels = match mode {
|
|
||||||
ColorMode::L => 1,
|
|
||||||
ColorMode::RGB => 3,
|
|
||||||
ColorMode::RGBA => 4,
|
|
||||||
};
|
|
||||||
if channels != expected_channels {
|
|
||||||
bail!(
|
|
||||||
"Mode {:?} expects {} channels, but array has {}",
|
|
||||||
mode,
|
|
||||||
expected_channels,
|
|
||||||
channels
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// 确保数据连续性 (C-order)
|
|
||||||
let standard = array.as_standard_layout();
|
|
||||||
let (raw_data, _) = standard.to_owned().into_raw_vec_and_offset();
|
|
||||||
|
|
||||||
match mode {
|
|
||||||
ColorMode::L => ImageBuffer::<Luma<u8>, _>::from_raw(width, height, raw_data)
|
|
||||||
.map(DynamicImage::ImageLuma8),
|
|
||||||
ColorMode::RGB => ImageBuffer::<Rgb<u8>, _>::from_raw(width, height, raw_data)
|
|
||||||
.map(DynamicImage::ImageRgb8),
|
|
||||||
ColorMode::RGBA => ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, raw_data)
|
|
||||||
.map(DynamicImage::ImageRgba8),
|
|
||||||
}
|
|
||||||
.ok_or_else(|| anyhow!("Failed to construct ImageBuffer. Buffer size might be incorrect."))
|
|
||||||
}
|
|
||||||
pub fn image_to_ndarray(img: &DynamicImage) -> Array3<u8> {
|
|
||||||
let (width, height) = img.dimensions();
|
|
||||||
|
|
||||||
// 1. 强制转为 RGB8 (丢弃 Alpha 通道,与 Python 的 target_mode='RGB' 对齐)
|
|
||||||
let rgb_img = img.to_rgb8();
|
|
||||||
|
|
||||||
// 2. 获取原始像素数据
|
|
||||||
let raw_data = rgb_img.into_raw();
|
|
||||||
|
|
||||||
// 3. 构造数组 (通道数改为 3)
|
|
||||||
Array3::from_shape_vec((height as usize, width as usize, 3), raw_data)
|
|
||||||
.expect("Failed to construct ndarray from utils") // 建议显式报错,而不是返回全黑图
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
use image::{DynamicImage, GrayImage, imageops::FilterType, Rgb, ImageBuffer};
|
|
||||||
use anyhow::{anyhow, Result};
|
|
||||||
use crate::models::ocr::color_filter::HsvRange;
|
|
||||||
use crate::utils::cv_ops::rgb_to_opencv_hsv;
|
|
||||||
|
|
||||||
/// 对应 Python 的 convert_to_grayscale
|
|
||||||
/// 将图像转换为灰度图 (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,
|
|
||||||
target_height: u32,
|
|
||||||
// resample 参数我们直接使用 FilterType,Lanczos3 是最接近 Python LANCZOS 的
|
|
||||||
) -> DynamicImage {
|
|
||||||
|
|
||||||
// image::imageops::resize 的最高层封装
|
|
||||||
// FilterType::Lanczos3 与 Python Pillow 的 Image.LANCZOS 算法完全对齐,缩放质量最高
|
|
||||||
image.resize_exact(target_width, target_height, FilterType::Lanczos3)
|
|
||||||
}
|
|
||||||
// pub fn resize_image(
|
|
||||||
// image: &GrayImage,
|
|
||||||
// target_width: u32,
|
|
||||||
// target_height: u32,
|
|
||||||
// // resample 参数我们直接使用 FilterType,Lanczos3 是最接近 Python LANCZOS 的
|
|
||||||
// ) -> GrayImage {
|
|
||||||
// // 使用 resize 算法进行精确缩放
|
|
||||||
// image::imageops::resize(
|
|
||||||
// image,
|
|
||||||
// target_width,
|
|
||||||
// target_height,
|
|
||||||
// FilterType::Lanczos3
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
pub mod image_io;
|
|
||||||
pub mod image_processor;
|
|
||||||
pub mod cv_ops;
|
|
||||||
@@ -1,600 +0,0 @@
|
|||||||
use std::borrow::Cow;
|
|
||||||
use std::fs::File;
|
|
||||||
use std::path::Path;
|
|
||||||
use anyhow::anyhow;
|
|
||||||
use ddddocr_rs::models::ocr::metadata::Charset;
|
|
||||||
use ddddocr_rs::models::ocr::metadata::{Normalization, Resize};
|
|
||||||
|
|
||||||
pub const CHARSET_BETA: &[&str] = &[
|
|
||||||
"", "笤", "谴", "膀", "荔", "佰", "电", "臁", "矍", "同", "奇", "芄", "吠", "6", "曛", "荇",
|
|
||||||
"砥", "蹅", "晃", "厄", "殣", "c", "辱", "钋", "杻", "價", "眙", "鴿", "⒄", "裙", "训", "涛",
|
|
||||||
"酉", "挞", "忙", "怍", "︰", "镍", "檐", "眯", "茓", "辖", "淩", "啟", "蜀", "芟", "裟", "楝",
|
|
||||||
"彘", "嶪", "费", "亞", "滁", "榉", "朝", "f", "倻", "裎", "谧", "崂", "卑", "助", "触", "氐",
|
|
||||||
"锟", "铢", "膪", "脐", "渲", "荫", "佾", "琯", "钣", "珰", "翦", "膻", "娥", "浥", "淄", "猸",
|
|
||||||
"内", "消", "粞", "反", "苪", "冽", "酵", "玩", "父", "存", "屯", "殷", "俐", "篱", "俛", "塮",
|
|
||||||
"苕", "耲", "输", "壖", "溶", "琤", "氏", "真", "黩", "瑄", "阶", "茔", "眩", "浙", "痄", "噔",
|
|
||||||
"烤", "楯", "²", "铆", "裈", "偬", "盏", "祐", "伯", "庙", "獯", "榀", "裒", "综", "蒸", "架",
|
|
||||||
"蜱", "鹖", "涴", "肌", "廖", "祾", "蔗", "破", "!", "鑫", "瓷", "H", "宛", "倪", "贝", "酝",
|
|
||||||
"倞", "榼", "菊", "帕", "胍", "淌", "抨", "倕", "味", "独", "à", "庐", "蹲", "肸", "洹", "騳",
|
|
||||||
"绖", "觉", "蝙", "铜", "選", "郚", "奄", "手", "篦", "忮", "潺", "歁", "湖", "貔", "缚", "癫",
|
|
||||||
"捣", "翎", "勇", "徒", "杪", "捃", "纴", "郞", "蛱", "浓", "講", "薇", "汊", "彻", "琖", "觇",
|
|
||||||
"驩", "野", "闺", "彩", "膊", "簸", "瑭", "龏", "栓", "攸", "堕", "鹿", "檠", "锽", "晟", "煊",
|
|
||||||
"衖", "p", "L", "侞", "吹", "岵", "捡", "邃", "曩", "泼", "娌", "磙", "鞮", "號", "苤", "骁",
|
|
||||||
"感", "氙", "榜", "菟", "蠲", "∶", "焌", "漯", "胪", "以", "剜", "=", "衰", "剔", "疏", "韩",
|
|
||||||
"邋", "探", "搌", "握", "舵", "腰", "咐", "郎", "鈺", "赴", "斩", "铥", "棂", "褫", "秾", "城",
|
|
||||||
"葬", "缺", "甓", "沙", "鴨", "恧", "吩", "膈", "俗", "引", "濑", "坛", "蛘", "谍", "飘", "鎚",
|
|
||||||
"貋", "袅", "圃", "肽", "祖", "瑢", "鄣", "卡", "恸", "饷", "撖", "阖", "碌", "墉", "⇋", "抗",
|
|
||||||
"僇", "撩", "狷", "静", "荽", "憯", "虻", "滹", "簧", "Y", "汾", "嫁", "蚴", "岈", "榔", "邶",
|
|
||||||
"挛", "火", "w", "旌", "線", "3", "跏", "F", "楸", "瞬", "證", "現", "符", "鲀", "窸", "朗",
|
|
||||||
"m", "劉", "襄", "鸻", "敖", "憩", "濛", "胶", "雏", "禽", "缜", "鐀", "澄", "泉", "懈", "鹟",
|
|
||||||
"牙", "叟", "镖", "膨", "硷", "钏", "嵌", "冲", "橪", "厨", "溘", "妻", "贺", "耀", "潲", "瞳",
|
|
||||||
"惺", "涑", "鄄", "舡", "战", "钹", "盍", "窀", "凳", "锋", "(", "绤", "翻", "》", "嶷", "戛",
|
|
||||||
"照", "设", "两", "霹", "風", "格", "栖", "椹", "№", "蔌", "达", "悠", "旒", "函", "抔", "逆",
|
|
||||||
"疙", "玫", "箢", "恩", "樘", "远", "考", "荭", "殒", "靡", "蝾", "舅", "䀲", "偾", "灿", "埴",
|
|
||||||
"瀍", "特", "诸", "搦", "恒", "妊", "课", "劂", "殊", "艋", "柚", "硕", "捅", "钍", "芘", "脖",
|
|
||||||
"襁", "募", "卅", "疆", "嫖", "黹", "臀", "豇", "瘗", "憎", "嗯", "Θ", "跺", "喧", "捺", "爪",
|
|
||||||
"鲠", "縠", "屋", "撑", "者", "娶", "喝", "墼", "丶", "茚", "髅", "瓢", "农", "橼", "攰", "折",
|
|
||||||
"诋", "镒", "赂", "捲", "耑", "沐", "窜", "亰", "煌", "阕", "羯", "纤", "滇", "值", "琥", "渴",
|
|
||||||
"祗", "鳌", "蛞", "庄", "鹇", "訄", "犷", "弋", "琐", "佗", "氛", "揄", "旺", "聱", "榨", "湿",
|
|
||||||
"蟥", "湎", "敕", "轷", "耿", "三", "運", "瑶", "困", "勁", "蚱", "泸", "螫", "斜", "蟠", "轨",
|
|
||||||
"镞", "霆", "嶙", "烷", "瘩", "敦", "塾", "僚", "澙", "關", "酩", "殴", "—", "例", "筷", "乘",
|
|
||||||
"颗", "核", "孬", "舣", "糇", "劾", "黧", "镌", "罅", "X", "仅", "哐", "蟊", "呻", "呕", "粟",
|
|
||||||
"配", "伊", "槊", "昌", "宰", "盘", "肫", "鳥", "圾", "恬", "辘", "绿", "時", "丐", "扃", "敓",
|
|
||||||
"摄", "陕", "滿", "鹆", "嗬", "龅", "渣", "釣", "萦", "督", "孑", "∧", "疥", "噱", "蝎", "君",
|
|
||||||
"笮", "泌", "镔", "称", "柘", "鬣", "罨", "潍", "垣", "顔", "褶", "礓", "△", "骐", "湍", "獾",
|
|
||||||
"羖", "戾", "预", "祭", "鹨", "凤", "茕", "珏", "蛊", "毛", "枰", "鄭", "娲", "丕", "蜜", "纟",
|
|
||||||
"蛹", "粮", "嚰", "嚓", "螨", "裰", "G", "集", "删", "郜", "舻", "嵛", "鵰", "腊", "峄", "脸",
|
|
||||||
"鲺", "坢", "寞", "撰", "顸", "枋", "荠", "夡", "豆", "馆", "赭", "傎", "淼", "镪", "許", "礌",
|
|
||||||
"带", "訂", "饧", "锜", "及", "漾", "編", "究", "仍", "糜", "喳", "嗝", "醦", "堅", "企", "烔",
|
|
||||||
"图", "垲", "枥", "畿", "踱", "槟", "◆", "酽", "溢", "酥", "谚", "缨", "死", "镑", "干", "用",
|
|
||||||
"紊", "坑", "副", "枭", "琮", "鸨", "獻", "弈", "伺", "醍", "氓", "宁", "臻", "贾", "啐", "玉",
|
|
||||||
"咀", "孀", "烆", "嘱", "频", "蜥", "楡", "瞩", "委", "锺", "赀", "睬", "旅", "刿", "韪", "抹",
|
|
||||||
"鞘", "x", "钝", "倌", "奖", "蟭", "灌", "肼", "曰", "啊", "属", "唐", "彦", "煦", "鄘", "坦",
|
|
||||||
"鬻", "告", "单", "菽", "匏", "浚", "仺", "怜", "拦", "鸡", "拄", "乍", "燠", "暧", "竭", "⒂",
|
|
||||||
"济", "Ⓡ", "趑", "舴", "体", "拒", "罂", "说", "猎", "闫", "鄗", "妲", "鑙", "葶", "匍", "等",
|
|
||||||
"略", "盲", "唔", "钰", "渠", "镦", "葫", "蹒", "姘", "婵", "夯", "实", "何", "株", "锌", "礁",
|
|
||||||
"桺", "捉", "鮕", "莓", "轶", "辉", "溋", "视", "嗒", "猞", "猁", "杓", "怡", "咴", "巷", "仂",
|
|
||||||
"婉", "睛", "?", "淡", "/", "郛", "绨", "较", "毁", "沓", "瞎", "馏", "蕙", "戏", "i", "董",
|
|
||||||
"臣", "鹫", "栳", "锹", "裕", "蜷", "唣", "9", "缂", "螬", "笥", "惶", "蚰", "徨", "忭", "传",
|
|
||||||
"绛", "离", "锑", "候", "拓", "德", "损", "附", "紡", "徕", "錾", "蕻", "⑧", "構", "镊", "脘",
|
|
||||||
"靸", "涒", "镡", "光", "廒", "尴", "荥", "佳", "弼", "暮", "榆", "鼑", "辅", "钆", "湝", "佤",
|
|
||||||
"瘌", "炽", "筰", "嗈", "電", "飙", "坶", "椿", "俾", "灊", "泵", "像", "咅", "樓", "苒", "烨",
|
|
||||||
"溺", "棽", "戒", "箅", "愔", "缢", "楞", "庤", "塑", "湮", "沽", "蝌", "赌", "薨", "锾", "圜",
|
|
||||||
"骥", "秉", "瞌", "惇", "诊", "圣", "睢", "我", "廰", "苠", "襻", "鲚", "酮", "厮", "评", "沈",
|
|
||||||
"愀", "垞", "习", "敷", "比", "欢", "尚", "钪", "卣", "她", ",", "伴", "赃", "蚬", "喇", "醇",
|
|
||||||
"嚅", "T", "姝", "鍊", "悯", "N", "抚", "颡", "獐", "趟", "洑", "缝", "喁", "帷", "憷", "获",
|
|
||||||
"阉", "镫", "臨", "炮", "奴", "揩", "叩", "恺", "粱", "胁", "憬", "痿", "´", "沘", "彀", "饩",
|
|
||||||
"滋", "竑", "嗑", "鸟", "T", "璠", "快", "銀", "舶", "羞", "桞", "飛", "茹", "師", "偌", "節",
|
|
||||||
"冁", "叡", "臑", "踔", "酔", "養", "溦", "岐", "豉", "杌", "胩", "仳", "沾", "窑", "曳", "闷",
|
|
||||||
"垢", "垆", "磨", "髹", "態", "丽", "見", "洃", "遣", "场", "铭", "溉", "衬", "橦", "详", "馊",
|
|
||||||
"濂", "瑾", "鲫", "贞", "搴", "胺", " ", "景", "执", "袴", "炖", "箭", "楂", "婚", "镧", "厩",
|
|
||||||
"宣", "缣", "跃", "痘", "亓", "宦", "豪", "侣", "郧", "與", "痳", "挚", "殻", "嘢", "洽", "蹉",
|
|
||||||
"孽", "森", "俚", "八", "砹", "凹", "訇", "屹", "啰", "宴", "廨", "沇", "麇", "泅", "绡", "辛",
|
|
||||||
"鹲", "鋆", "旮", "婀", "幔", "赞", ":", "永", "活", "萸", "霉", "←", "媲", "阚", "鲮", "佝",
|
|
||||||
"獒", "圹", "隋", "征", "蚶", "龊", "搀", "嫫", "鬏", "幞", "學", "然", "沧", "萑", "襜", "撻",
|
|
||||||
"苯", "狳", "鞠", "咋", "壹", "栅", "款", "镘", "阳", "蚜", "荬", "糊", "疳", "糕", "镵", "寐",
|
|
||||||
"褴", "v", "醋", "诀", "汰", "啸", "备", "娘", "氚", "镓", "室", "簟", "硝", "嘌", "釐", "争",
|
|
||||||
"男", "疬", "鹄", "艳", "倒", "忸", "庶", "葒", "岖", "涨", "羝", "诼", "纨", "纰", "扉", "酎",
|
|
||||||
"藉", "疮", "枞", "黜", "戮", "芽", "鳑", "末", "蒿", "茈", "透", "渊", "秷", "〈", "试", "络",
|
|
||||||
"羼", "滪", "奋", "虏", "脁", "沫", "蓰", "襆", "披", "鲌", "艚", "逹", "炳", "泔", "叹", "轳",
|
|
||||||
"锵", "嫜", "佥", "严", "迅", "筠", "逵", "铿", "钇", "拇", "诏", "绯", "吊", "纠", "蟾", "c",
|
|
||||||
"涝", "汩", "盐", "跋", "拤", "邹", "镨", "羚", "龆", "脊", "攉", "傕", "短", "團", "蹬", "嘤",
|
|
||||||
"奎", "熨", "芪", "鸢", "濉", "''", "莴", "義", "赜", "踺", "皂", "努", "偏", "狡", "遭", "吞",
|
|
||||||
"嘿", "婊", "媸", "增", "殿", "刮", "燘", "劫", "娜", "瞄", "寡", "优", "捋", "佴", "菰", "蓠",
|
|
||||||
"笙", "镃", "樇", "瘫", "B", "橇", "逯", "堍", "О", "磐", "腼", "て", "送", "狭", "皓", "亡",
|
|
||||||
"嗉", "菠", "顺", "連", "嶶", "瑪", "辟", "婷", "牛", "笫", "窅", "萁", "戟", "覃", "馍", "建",
|
|
||||||
"謇", "旘", "镣", "燏", "葉", "轺", "倏", "堪", "见", "葛", "钕", "键", "押", "僊", "槐", "戎",
|
|
||||||
"窨", "洙", "鲢", "鞒", "慒", "雁", "圭", "D", "陌", "肱", "蜿", "洧", "惑", "祛", "樟", "矧",
|
|
||||||
"呵", "峻", "凝", "蕨", "拯", "珮", "塥", "展", "贻", "囐", "弱", "庳", "嫔", "緣", "呈", "策",
|
|
||||||
"漉", "瑗", "鲂", "鹂", "吾", "灶", "并", "挲", "重", "奭", "皙", "侪", "埗", "烬", "纾", "椒",
|
|
||||||
"技", "ɔ", "擀", "恍", "遨", "订", "雨", "卵", "锏", "猗", "癸", ")", "谡", "稷", "枨", "蹽",
|
|
||||||
"荑", "沅", "稽", "間", "冉", "颇", "酺", "份", "瞾", "毯", "藥", "蕞", "狲", "吡", "慷", "卯",
|
|
||||||
"摽", "肿", "嗛", "悒", "丨", "横", "鳡", "仫", "狎", "砗", "聿", "腥", "酡", "飱", "柳", "遽",
|
|
||||||
"汇", "湔", "麋", "垃", "粽", "坷", "鳗", "迫", "丢", "\"", "⒀", "嗲", "肐", "結", "署", "飨",
|
|
||||||
"蠡", "涩", "挈", "浿", "鐾", "姞", "隧", "铘", "呜", "蜕", "鷄", "逼", "哌", "病", "係", "偿",
|
|
||||||
"Ⅲ", "埋", "妤", "赘", "悉", "陷", "沸", "呲", "誓", "舆", "髀", "挫", "羑", "据", "顿", "淝",
|
|
||||||
"抟", "珧", "郑", "仗", "怛", "掠", "稳", "尥", "祙", "找", "郐", "锔", "轹", "钓", "黙", "饸",
|
|
||||||
"谌", "斐", "龙", "噫", "駆", "浼", "峒", "育", "纣", "溠", "铊", "亨", "杮", "呓", "钌", "业",
|
|
||||||
"繻", "溪", "戌", "蓿", "椱", "悱", "仉", "阮", "芈", "濋", "搔", "纽", "琛", "趄", "双", "镯",
|
|
||||||
"☆", "敉", "啬", "讦", "娱", "爾", "遶", "漱", "郗", "锪", "颃", "靰", "醊", "驊", "呢", "術",
|
|
||||||
"妙", "蚣", "溽", "酇", "巾", "舐", "却", "废", "邾", "砣", "乙", "鲜", "蒤", "囍", "璈", "稔",
|
|
||||||
"蘘", "匐", "業", "碟", "渺", "贤", "绋", "畑", "颞", "侥", "盟", "鼍", "阊", "蔽", "标", "吮",
|
|
||||||
"淬", "鏾", "圗", "夜", "乕", "娇", "瞿", "循", "讲", "懒", "熘", "禚", "观", "钷", "万", "n",
|
|
||||||
"未", "藝", "愿", "圈", "浩", "伦", "扛", "暄", "饶", "梧", "欣", "咿", "檔", "吼", "妮", "覆",
|
|
||||||
"辰", "誤", "允", "危", "硗", "惫", "瘪", "李", "焱", "沣", "坯", "穄", "归", "画", "营", ":",
|
|
||||||
"色", "哔", "矢", "巯", "祆", "傍", "享", "悻", "取", "凫", "铒", "唅", "眈", "疹", "败", "晴",
|
|
||||||
"顼", "绶", "剃", "斗", "禾", "4", "誜", "俨", "2", "患", "结", "可", "帇", "抍", "筝", "衢",
|
|
||||||
"鹛", "跸", "颢", "钾", "渡", "棒", "丛", "皱", "梓", "将", "压", "#", "岛", "?", "砺", "过",
|
|
||||||
"党", "挣", "瞋", "谶", "妯", "羡", "化", "淫", "歪", "鼗", "阄", "蔓", "烩", "餘", "猊", ".",
|
|
||||||
"畯", "祧", "狒", "碁", "咛", "鲈", "叨", "哞", "5", "娈", "半", "免", "拿", "畎", "媾", "棚",
|
|
||||||
"丈", "周", "匋", "酯", "奚", "爇", "摇", "搭", "蓇", "陽", "岢", "禤", "藠", "雅", "哲", "弹",
|
|
||||||
"按", "↑", "蹀", "察", "螾", "渎", "褂", "觳", "耍", "皲", "骗", "箫", "蕺", "亚", "保", "棵",
|
|
||||||
"放", "踪", "了", "熣", "亦", "痛", "币", "馐", "夢", "诱", "梱", "鲰", "郕", "璜", "祯", "颦",
|
|
||||||
"走", "踣", "嫚", "旯", "雲", "湟", "墨", "笃", "肇", "撝", "腦", "账", "舞", "⑨", "噻", "幂",
|
|
||||||
"僵", "崦", "’", "牢", "号", "嫡", "囱", "肥", "代", "锶", "掏", "随", "棓", "殉", "嘅", "掰",
|
|
||||||
"功", "垛", "踶", "娠", "霜", "碣", "鲼", "伉", "凄", "骋", "鹞", "洺", "乌", "赧", "瑛", "黎",
|
|
||||||
"曚", "鲴", "髫", "瘴", "藏", "雍", "畐", "蔻", "爼", "蹴", "巨", "贱", "汜", "胡", "虬", "椎",
|
|
||||||
"逸", "魇", "亶", "Φ", "忆", "赉", "塞", "潢", "垌", "简", "鼹", "發", "枢", "麝", "虹", "惭",
|
|
||||||
"唛", "春", "瑟", "郰", "桡", "捩", "堙", "嗨", "驳", "F", "荪", "忑", "贪", "躅", "步", "揜",
|
|
||||||
"闪", "垟", "晶", "分", "韭", "戴", "泪", "啧", "機", "峙", "和", "鸱", "绎", "屠", "阋", "黍",
|
|
||||||
"淸", "萩", "汉", "吐", "匙", "铗", "蠔", "簠", "鲵", "须", "蛣", "躏", "完", "咻", "釜", "馼",
|
|
||||||
"崤", "欻", "珐", "于", "郅", "焓", "轴", "递", "堰", "嗷", "儇", "壕", "嘟", "酸", "庾", "龂",
|
|
||||||
"妍", "锅", "雳", "桦", "抬", "谄", "气", "六", "诎", "绀", "张", "復", "客", "荞", "鳚", "衔",
|
|
||||||
"亁", "昂", "漤", "鞚", "筘", "绫", "彝", "枪", "苊", "榟", "饺", "苦", "顶", "衷", "聚", "寮",
|
|
||||||
"揆", "轪", "栋", "臂", "葖", "颋", "镐", "愕", "贸", "Q", "琼", "糥", "世", "莪", "龁", "禁",
|
|
||||||
"绲", "陶", "弑", "黢", "铵", "睐", "沄", "紬", "防", "癣", "曾", "钉", "纶", "膘", "句", "莸",
|
|
||||||
"踝", "躐", "酤", "腑", "雄", "堤", "喀", "姣", "孢", "阡", "褐", "胂", "髙", ";", "骖", "膺",
|
|
||||||
"糙", "辢", "⒃", "险", "砻", "缫", "骎", "低", "蚵", "箐", "苞", "劭", "峪", "工", "盈", "腹",
|
|
||||||
"袄", "祉", "癔", "笨", "R", "乚", "畏", "虍", "臾", "泛", "噙", "杷", "麗", "蹋", "逍", "迓",
|
|
||||||
"摅", "页", "戥", "胞", "艄", "壅", "啶", "趼", "牟", "翙", "蓝", "府", "轿", "砼", "荜", "杆",
|
|
||||||
"惊", "起", "瘅", "墈", "氖", "匀", "麃", "阘", "虮", "蘇", "蚤", "汗", "鳞", "籁", "缲", "畈",
|
|
||||||
"亟", "劬", "課", "蓄", "缅", "楮", "湜", "珩", "斋", "塬", "殁", "魃", "脞", "H", "澼", "钚",
|
|
||||||
"饕", "缕", "Ⅱ", "攮", "卿", "莅", "镆", "熹", "藩", "汁", "順", "趿", "拆", "蟹", "砒", "惴",
|
|
||||||
"㎡", "忖", "寝", "戕", "螭", "酿", "™", "柬", "枧", "凶", "蚁", "島", "殄", "鲊", "忠", "肉",
|
|
||||||
"辕", "叫", "徙", "漆", "缞", "夀", "楦", "佪", "兴", "粉", "裳", "蘧", "國", "旬", "看", "Ⅰ",
|
|
||||||
"剑", "痭", "襟", "恐", "遹", "◎", "窃", "穰", "澎", "敬", "旱", "燚", "坩", "彤", "尜", "猃",
|
|
||||||
"夏", "穈", "媒", "柑", "駡", "孛", "脉", "车", "零", "菩", "痊", "卉", "桔", "距", "吧", "漦",
|
|
||||||
"启", "仁", "滬", "馋", "帅", "鳈", "鄌", "超", "芡", "窘", "刽", "掌", "氤", "梽", "拎", "踏",
|
|
||||||
"勋", "甍", "玑", "稱", "鞍", "浍", "翅", "饟", "鼎", "罩", "加", "虚", "蕰", "簉", "堇", "巢",
|
|
||||||
"疲", "蟑", "狝", "瓮", "潋", "行", "饥", "散", "糌", "牵", "貢", "偉", "咄", "痕", "沃", "苓",
|
|
||||||
"锂", "狻", "褿", "畸", "姿", "煎", "胜", "觅", "烊", "質", "疵", "擢", "椤", "米", "累", "诳",
|
|
||||||
"斡", "K", "恻", "匦", "烫", "湾", "鹎", "吟", "摘", "涞", "恿", "嫉", "炎", "婧", "朽", "铑",
|
|
||||||
"ㆍ", "讧", "曜", "挑", "〇", "搅", "鹐", "丁", "彼", "棠", "饪", "箬", "祎", "魄", "囿", "犬",
|
|
||||||
"市", "髃", "勚", "桶", "辎", "瓞", "财", "缄", "園", "睒", "护", "尿", "融", "围", "水", "糍",
|
|
||||||
"虢", "呦", "越", "棺", "砮", "邓", "鹦", "稣", "呃", "柙", "鎌", "转", "袋", "湉", "亘", "俩",
|
|
||||||
"腆", "谣", "飔", "撂", "鄳", "爲", "盦", "谳", "卸", "W", "嚆", "婕", "卫", "拚", "呀", "汽",
|
|
||||||
"洣", "冻", "鳝", "录", "毋", "閥", "熬", "谜", "齐", "匳", "慧", "猴", "撬", "妳", "諾", "蠼",
|
|
||||||
"瘟", "伐", "颤", "奶", "陧", "麾", "岌", "浇", "邸", "「", "不", "哼", "热", "旳", "慙", "&",
|
|
||||||
"苔", "郿", "钗", "氡", "纹", "侬", "霓", "靈", "扁", "聢", "疼", "岣", "甥", "恭", "喷", "芫",
|
|
||||||
"骂", "肪", "熥", "揠", "鲷", "遁", "霎", "娆", "圩", "爬", "傲", "贽", "紫", "觑", "琇", "蟆",
|
|
||||||
"怙", "玙", "庼", "筮", "慗", "层", "娓", "蚨", "糟", "璩", "隼", "锧", "疱", "铎", "祠", "绁",
|
|
||||||
"速", "湛", "蝮", "立", "媵", "禇", "撸", "禳", ">", "恋", "⑥", "鹡", "蓑", "樱", "奸", "蝣",
|
|
||||||
"埭", "聪", "慭", "睑", "肢", "焗", "骃", "毵", "潼", "塘", "烧", "劓", "栾", "牯", "〉", "毓",
|
|
||||||
"釉", "庞", "宕", "磚", "夺", "畜", "俏", "筼", "虱", "釆", "计", "陀", "诧", "臱", "牌", "固",
|
|
||||||
"鹉", "凰", "擞", "■", "㛃", "词", "店", "當", "许", "妣", "耋", "硼", "根", "焅", "砸", "霈",
|
|
||||||
"锃", "巳", "誉", "咕", "锊", "P", "云", "乞", "为", "姆", "桕", "丞", "鳤", "楷", "醒", "趔",
|
|
||||||
"怔", "砉", "潏", "肝", "拷", "也", "璐", "厘", "致", "昇", "绥", "抃", "佩", "斥", "⑶", "断",
|
|
||||||
"纵", "翁", "耄", "沭", "洄", "别", "吻", "渍", "r", "愦", "替", "骓", "攻", "旦", "来", "案",
|
|
||||||
"坫", "辞", "班", "锝", "拜", "掎", "穸", "笛", "痂", "笈", "鲶", "僻", "依", "碎", "蕤", "炕",
|
|
||||||
"寥", "拔", "髑", "慨", "眺", "⑿", "珞", "歧", "湲", "錦", "拮", "哭", "爰", "验", "寸", "%",
|
|
||||||
"口", "阔", "积", "篝", "狴", "殂", "痧", "绽", "搪", "祸", "缘", "遯", "祜", "种", "缓", "冒",
|
|
||||||
"庵", "窥", "颂", "哕", "谘", "蜗", "掖", "驮", "郎", "跂", "蝓", "贲", "馔", "琰", "大", "芊",
|
|
||||||
"粝", "啻", "蒉", "谒", "羔", "蘑", "菑", "布", "徂", "l", "炬", "蘩", "弭", "嵎", "補", "岑",
|
|
||||||
"佯", "棍", "没", "志", "裣", "咸", "权", "豺", "韦", "優", "紧", "嚚", "牴", "酚", "沤", "睥",
|
|
||||||
"葆", "乓", "划", "犒", "惚", "埕", "锴", "就", "滓", "汕", "冏", "缵", "囟", "旸", "麂", "接",
|
|
||||||
"餮", "瓤", "偕", "灏", "溻", "揶", "钯", "荸", "宪", "泠", "槠", "抺", "威", "燴", "井", "骣",
|
|
||||||
"耜", "磔", "懜", "淇", "瘘", "蹼", "觏", "圬", "纔", "恤", "铝", "薏", "鞫", "衉", "E", "球",
|
|
||||||
"姽", "毹", "嵘", "睦", "蛉", "伽", "橥", "痤", "卞", "警", "则", "芬", "磉", "悢", "逾", "吆",
|
|
||||||
"朿", "锒", "卧", "E", "焉", "纥", "髦", "鞯", "牞", "蹿", "琪", "洌", "幛", "淳", "菲", "涔",
|
|
||||||
"噗", "勺", "势", "哓", "毪", "刖", "雜", "浪", "懵", "棁", "秋", "◇", "玡", "埯", "谥", "芑",
|
|
||||||
"拌", "Z", "埂", "寻", "虼", "豫", "蛄", "蜞", "椟", "〔", "哩", "絷", "咱", "郈", "湫", "抡",
|
|
||||||
"矮", "庖", "锰", "鎖", "葸", "棣", "砑", "题", "棋", "晕", "兠", "蓣", "貂", "裤", "昊", "扳",
|
|
||||||
"讴", "弩", "蚀", "尓", "暗", "莘", "黏", "寖", "蛔", "И", "痪", "饳", "妈", "Z", "秏", "矽",
|
|
||||||
"蘭", "艴", "菖", "寿", "撅", "秤", "颓", "廪", "醪", "耢", "螣", "椰", "麈", "听", "敞", "铠",
|
|
||||||
"润", "穑", "涟", "澹", "茨", "昨", "嚯", "钟", "護", "榇", "曦", "蜎", "呷", "邦", "瘵", "鲬",
|
|
||||||
"谖", "概", "九", "飞", "潇", "高", "锫", "钐", "徘", "诟", "缧", "剞", "【", "簋", "噍", "痋",
|
|
||||||
"关", "糁", "鹊", "潤", "邬", "赔", "序", "盤", "拙", "盾", "鸫", "迟", "氇", "塄", "庀", "噩",
|
|
||||||
"臃", "绔", "岔", "珠", "癀", "骡", "隶", "堋", "笆", "影", "昶", "飗", "α", "薄", "碜", "瞅",
|
|
||||||
"奠", "鲽", "牂", "裆", "刀", "遑", "隙", "沔", "訾", "旇", "s", "诮", "炱", "榚", "些", "縯",
|
|
||||||
"提", "甚", "埏", "峣", "意", "出", "郡", "摊", "哦", "轼", "嚒", "杈", "痢", "裥", "袖", "陔",
|
|
||||||
"溷", "裀", "迄", "枇", "筑", "貊", "鲃", "鸪", "V", "鞭", "粳", "菥", "盒", "毙", "风", "壽",
|
|
||||||
"胝", "章", "泄", "浜", "菇", "釘", "揖", "蝰", "榧", "掊", "瘿", "亍", "欤", "髻", "歃", "朱",
|
|
||||||
"撇", "铁", "兜", "喋", "趵", "瘼", "芤", "邳", "除", "滌", "么", "践", "矜", "曆", "炒", "咔",
|
|
||||||
"促", "姐", "酦", "货", "箔", "锡", "吝", "医", "汀", "精", "馘", "骶", "碉", "啷", "缉", "褪",
|
|
||||||
"阙", "滕", "蓐", "搏", "俅", "癌", "股", "圪", "孕", "鳕", "话", "鳳", "妩", "馬", "鞣", "璁",
|
|
||||||
"测", "螠", "圆", "颥", "卒", "钲", "䨱", "骢", "批", "陨", "婶", "熵", "鸣", "梁", "怅", "滑",
|
|
||||||
"驵", "帑", "鑽", "痈", "鲑", "犍", "茑", "糧", "谫", "咒", "绘", "塆", "耖", "铮", "殳", "角",
|
|
||||||
"齮", "挻", "捎", "2", "З", "廋", "泻", "犊", "儋", "椋", "聍", "鼬", "鳏", "收", "跞", "盥",
|
|
||||||
"信", "嘴", "眉", "黠", "纡", "Ω", "愆", "桹", "昽", "讠", "蚧", "龀", "鳓", "麓", "括", "蘊",
|
|
||||||
"鈇", "写", "焕", "哒", "忌", "咳", "蜮", "衄", "氰", "邘", "缇", "缮", "表", "续", "篇", "旄",
|
|
||||||
"闻", "門", "偃", "茳", "晗", "鋪", "讶", "衙", "闭", "管", "傒", "藔", "鳯", "萼", "泥", "汔",
|
|
||||||
"浆", "姚", "几", "含", "沆", "繇", "灾", "嗦", "饬", "系", "熠", "漶", "潭", "牲", "湘", "稁",
|
|
||||||
"宄", "亳", "杏", "豳", "監", "印", "旁", "辙", "播", "让", "乳", "镚", "腒", "衅", "铅", "殛",
|
|
||||||
"形", "颚", "茸", "绚", "铲", "贬", "溆", "寘", "傫", "解", "事", "旻", "差", "竣", "篃", "渥",
|
|
||||||
"嵩", "@", "鹀", "哈", "痍", "绪", "囔", "渗", "帐", "叙", "⑦", "跖", "貝", "盅", "碶", "哗",
|
|
||||||
"裼", "窝", "滙", "郸", "延", "炼", "芹", "簃", "谭", "莱", "佞", "猝", "褥", "陆", "王", "碴",
|
|
||||||
"粑", "怃", "倭", "貉", "硈", "裴", "溍", "坂", "勐", "踬", "敌", "改", "膠", "罘", "罚", "繁",
|
|
||||||
"幢", "煸", "扩", "黑", "氮", "孓", "饔", "姹", "蠖", "倬", "慝", "沩", "嚏", "雌", "琏", "莹",
|
|
||||||
"觊", "颟", "坤", "筅", "桉", "肈", "幻", "”", "烽", "蓅", "噌", "桎", "矻", "指", "瀚", "羊",
|
|
||||||
"邒", "筒", "匣", "旨", "葑", "团", "偻", "镛", "忉", "帱", "往", "刎", "害", "母", "串", "酢",
|
|
||||||
"舟", "栝", "娣", "⒉", "装", "笊", "篑", "垄", "镳", "牝", "煲", "氵", "遂", "骜", "钜", "屡",
|
|
||||||
"宇", "z", "惨", "潆", "鹃", "豌", "浞", "幄", "麺", "暽", "弁", "菤", "酲", "虑", "样", "访",
|
|
||||||
"睁", "黼", "悞", "禧", "涠", "舂", "駕", "屈", "噀", "邽", "炅", "欸", "隆", "描", "绦", "派",
|
|
||||||
"溲", "颙", "忐", "鹺", "只", "诈", "腭", "亵", "这", "炀", "趣", "喙", "跨", "場", "全", "搢",
|
|
||||||
"舨", "踢", "巿", "糸", "乐", "澈", "羰", "匯", "D", "濒", "莛", "癯", "娴", "姒", "祥", "渖",
|
|
||||||
"庭", "渑", "挹", "狉", "萍", "鹚", "焻", "自", "舸", "岫", "飑", "戍", "淙", "愠", "导", "賣",
|
|
||||||
"进", "赳", "鱿", "硂", "僤", "O", "拟", "钨", "笳", "汴", "挤", "稞", "柏", "阐", "弥", "艽",
|
|
||||||
"爻", "魭", "俶", "聂", "鲎", "齌", "菀", "僜", "煅", "满", "兀", "辔", "舊", "胥", "卢", "额",
|
|
||||||
"膝", "嵬", "昼", "唏", "璨", "苷", "舫", "倨", "耵", "碳", "鄺", "橘", "裔", "裸", "宋", "窋",
|
|
||||||
"悚", "昱", "错", "咣", "檄", "硁", "莆", "肃", "曼", "垍", "绵", "役", "內", "館", "规", "犋",
|
|
||||||
"毅", "锨", "瓘", "丘", "疝", "杀", "袗", "屏", "阎", "衹", "嚣", "冼", "铪", "熏", "t", "储",
|
|
||||||
"傧", "髁", "邙", "窴", "泾", "止", "晾", "涵", "庸", "庠", "靿", "睚", "减", "衲", "思", "簖",
|
|
||||||
"耘", "之", "斑", "扬", "袼", "缗", "菏", "滍", "研", "垚", "艘", "黇", "蜘", "汨", "侦", "疃",
|
|
||||||
"再", "沂", "榛", "励", "嚼", "拴", "钴", "珑", "撕", "鸯", "共", "醐", "揿", "醴", "凭", "菹",
|
|
||||||
"鹬", "捷", "掼", "芝", "缪", "咏", "挺", "蒗", "疖", "伸", "索", "黉", "髋", "習", "姜", "蔺",
|
|
||||||
"扶", "忽", "锉", "戚", "珲", "摸", "黛", "跌", "螈", "冚", "洱", "鼯", "庡", "痒", "哆", "品",
|
|
||||||
"歆", "登", "呔", "追", "鉫", "碾", "祚", "总", "帚", "薹", "趴", "容", "滘", "❋", "迷", "拾",
|
|
||||||
"炯", "析", "佼", "嗾", "-", "针", "滩", "禅", "讯", "織", "饱", "哟", "蹓", "侧", "蹯", "踽",
|
|
||||||
"趙", "漫", "卷", "朴", "卺", "狈", "绉", "浉", "玳", "榕", "畛", "蛾", "帔", "欠", "劙", "矾",
|
|
||||||
"進", "蚺", "骨", "捂", "疾", "璱", "圠", "坚", "弯", "秩", "逧", "烟", "佛", "嫱", "鳢", "裹",
|
|
||||||
"耥", "潮", "巽", "嶝", "廷", "鳆", "五", "西", "壤", "慌", "鴈", "爡", "‧", "儆", "萘", "愁",
|
|
||||||
"昔", "咩", "侃", "炫", "迳", "构", "缶", "皴", "捱", "菜", "秀", "粢", "畵", "辊", "伟", "榄",
|
|
||||||
"嗪", "已", "聖", "枕", "恢", "厍", "涅", "鄢", "饨", "鲨", "谪", "礤", "搁", "疔", "雎", "羿",
|
|
||||||
"犁", "硐", "盹", "若", "操", "昀", "亩", "凑", "睃", "贰", "穷", "荆", "葹", "嘃", "廓", "憔",
|
|
||||||
"制", "胧", "染", "麿", "镴", "砀", "组", "茎", "畫", "哑", "坨", "罱", "刳", "嘲", "瞰", "缏",
|
|
||||||
"侩", "谝", "谮", "还", "惋", "佈", "谏", "酗", "怩", "i", "秽", "抻", "蟪", "狠", "辑", "羟",
|
|
||||||
"娉", "肯", "毕", "枸", "啮", "鳙", "要", "娑", "蜈", "忍", "侗", "Р", "凛", "巉", "拽", "该",
|
|
||||||
"俎", "惠", "薅", "猬", "忲", "●", "哏", "柔", "沉", "磁", "脾", "龃", "鲩", "钞", "厂", "鱽",
|
|
||||||
"濞", "群", "唱", "攝", "刃", "蹻", "剕", "朊", "啜", "语", "蜻", "遠", "呖", "都", "嫄", "戢",
|
|
||||||
"丫", "酞", "修", "户", "膏", "侈", "浣", "捍", "馒", "谱", "點", "滏", "跟", "鹁", "书", "愉",
|
|
||||||
"扭", "票", "耒", "燁", "魚", "拨", "鞑", "匆", "最", "憨", "晨", "癍", "邻", "醅", "骘", "骇",
|
|
||||||
"蔫", "。", "珪", "安", "歉", "邑", "淦", "胱", "漏", "咂", "叮", "瘛", "钺", "聘", "玎", "荻",
|
|
||||||
"涰", "淏", "烈", "囚", "㙟", "矫", "嗵", "澶", "磛", "肖", "蹑", "支", "哪", "久", "嗅", "稍",
|
|
||||||
"抠", "具", "裨", "韨", "虿", "漪", "外", "埸", "ä", "葡", "鲇", "廛", "蝥", "广", "兒", "涧",
|
|
||||||
"阌", "谊", "嵴", "拂", "悸", "哳", "4", "佘", "篁", "基", "赪", "掬", "演", "谋", "酣", "植",
|
|
||||||
"波", "恅", "杼", "胀", "纯", "缩", "盆", "芷", "绺", "施", "礞", "莳", "稻", "狐", "馌", "椑",
|
|
||||||
"恃", "镩", "骼", "孱", "硭", "曈", "焼", "秕", "觥", "茛", "射", "肣", "蟞", "′", "彰", "罔",
|
|
||||||
"麻", "逋", "谓", "革", "藤", "堆", "忿", "茂", "屦", "炟", "碹", "啤", "垤", "文", "佐", "叼",
|
|
||||||
"蹭", "踉", "眍", "龟", "黾", "崋", "氘", "尼", "瞢", "悦", "罢", "瑀", "睇", "贴", "涿", "拍",
|
|
||||||
"庥", "粪", "陳", "闌", "蓍", "元", "磊", "忻", "葴", "买", "诺", "㐂", "沦", "苏", "岂", "浊",
|
|
||||||
"脍", "誠", "嚬", "蔬", "黄", "銘", "钩", "劃", "嫘", "嵖", "笄", "鸰", "熳", "堑", "河", "遮",
|
|
||||||
"尝", "灭", "愬", "娅", "鸽", "锢", "狂", "弓", "侨", "滚", "释", "晏", "徵", "苡", "佚", "切",
|
|
||||||
"惰", "降", "槎", "脑", "磺", "谟", "裉", "弘", "醺", "眇", "顒", "兰", "忧", "础", "-", "茫",
|
|
||||||
"袒", "喤", "慶", "話", "跽", "堵", "能", "武", "壳", "汤", "橐", "圧", "嗡", "簌", "斧", "林",
|
|
||||||
"楊", "净", "嫌", "猛", "炸", "冠", "弦", "洵", "°", "創", "葜", "苁", "顏", "薛", "平", "舜",
|
|
||||||
"崧", "撼", "哮", "窣", "殖", "象", "飒", "瞥", "玥", "斝", "胭", "葱", "擎", "踰", "盛", "稇",
|
|
||||||
"斫", "滴", "硫", "胬", "唁", "拐", "殍", "冱", "陪", "鋈", "烦", "辋", "妇", "轸", "潴", "荤",
|
|
||||||
"碡", "徭", "膜", "秧", "淀", "缤", "琶", "^", "肋", "违", "轭", "$", "箕", "墩", "罗", "摆",
|
|
||||||
"槛", "笠", "塃", "畚", "嘛", "秸", "≥", "剪", "羅", "帜", "庇", "骙", "酾", "康", "陬", "蝶",
|
|
||||||
"闇", "圏", "琴", "鬈", "苶", "黡", "瓠", "酋", "琎", "奏", "科", "搒", "億", "翡", "熊", "性",
|
|
||||||
"痞", "篾", "邴", "篥", "饵", "揸", "狙", "鳂", "否", "煺", "畦", "凖", "鹧", "宵", "庛", "鬯",
|
|
||||||
"袂", "昴", "徽", "奧", "鸥", "锸", "庚", "彪", "槿", "趱", "铡", "讹", "莎", "約", "璞", "┌",
|
|
||||||
"铫", "鸈", "瞀", "鼙", "廙", "估", "屺", "锘", "渝", "汶", "囵", "跻", "酏", "扎", "孜", "腔",
|
|
||||||
"吗", "块", "潽", "“", "焚", "倘", "靥", "默", "笺", "遍", "剅", "桠", "极", "夷", "眸", "泳",
|
|
||||||
"谢", "懦", ")", "泜", "物", "开", "淮", "邵", "卟", "徜", "轧", "苍", "酶", "捌", "耨", "膄",
|
|
||||||
"琦", "痦", "涪", "舰", "迥", "涘", "季", "悫", "初", "贩", "碍", "沼", "腮", "勻", "恣", "他",
|
|
||||||
"院", "7", "蜒", "叉", "昉", "瞽", "疢", "蓼", "相", "臜", "ü", "秘", "蚌", "摉", "莞", "番",
|
|
||||||
"盼", "冀", "缠", "学", "馗", "搞", "苛", "羹", "潟", "嬴", "杶", "妖", "↓", "銭", "斄", "猢",
|
|
||||||
"搂", "芦", "埶", "癜", "铴", "從", "留", "硎", "簇", "臬", "侮", "鳎", "頉", "所", "邝", "哂",
|
|
||||||
"弛", "垂", "暑", "仿", "療", "轾", "熜", "悃", "勘", "鋫", "泞", "閃", "+", "士", "虐", "盡",
|
|
||||||
"溯", "劣", "1", "锇", "蜇", "碛", "婴", "8", "捶", "藻", "嫰", "跹", "芋", "淠", "疯", "Й",
|
|
||||||
"诨", "醢", "痨", "栌", "摧", "噤", "灸", "馉", "蒇", "洲", "孪", "孵", "亿", "枝", "涕", "孟",
|
|
||||||
"缎", "攒", "湧", "褛", "铺", "廣", "诃", "蕈", "维", "珣", "逖", "瓯", "苘", "榫", "寰", "供",
|
|
||||||
"铽", "暲", "义", "兖", "喆", "祇", "蝽", "膛", "髭", "尹", "乒", "柈", "抓", "镋", "脏", "乩",
|
|
||||||
"挎", "酬", "靳", "躔", "霞", "離", "桌", "o", " ", "滨", "懷", "萭", "望", "仆", "怂", "麸",
|
|
||||||
"槁", "洨", "俬", "孚", "匕", "栩", "t", "被", "邗", "纺", "狄", "国", "苋", "眢", "秣", "畤",
|
|
||||||
"∵", "创", "嵫", "胖", "垮", "镁", "壑", "A", "趾", "荟", "汝", "嗥", "驰", "向", "磴", "讪",
|
|
||||||
"溅", "箍", "驾", "讙", "蛐", "眨", "醯", "筣", "鼫", "叠", "沥", "恕", "埽", "甩", "煜", "端",
|
|
||||||
"蜃", "帏", "丙", "荏", "圻", "夥", "暖", "抛", "岸", "头", "恝", "船", "鞲", "の", "七", "怪",
|
|
||||||
"诅", "茧", "北", "搓", "皖", "綉", "坪", "簪", "贵", "阍", "刑", "偶", "雷", "體", "髡", "岚",
|
|
||||||
"剂", "妾", "茬", "鹣", "鲳", "蜊", "假", "认", "嗍", "傈", "焘", "驷", "賀", "氆", "炝", "聋",
|
|
||||||
"嘡", "悺", "﹐", "僭", "证", "侉", "屐", "吃", "十", "〕", "瀦", "骤", "俳", "舁", "驸", "竿",
|
|
||||||
"坍", "|", "普", "軒", "瘃", "现", "醾", "铈", "榭", "蓁", "唷", "睫", "绰", "叶", "頤", "锤",
|
|
||||||
"浸", "杂", "汹", "璟", "蓖", "迦", "截", "錎", "年", "達", "劁", "码", "乃", "捯", "髪", "埚",
|
|
||||||
"呯", "頭", "镈", "汆", "β", "舷", "菂", "纸", "造", "晁", "卦", "蛎", "枉", "脯", "诛", "生",
|
|
||||||
"令", "桄", "恶", "鬟", "怯", "囡", "充", "省", "控", "螂", "笕", "邺", "扅", "斲", "骏", "鹋",
|
|
||||||
"队", "套", "镢", "牖", "I", "援", "封", "泚", "艿", "沮", "铙", "揳", "抄", "帨", "近", "唾",
|
|
||||||
"珥", "销", "非", "啱", "驼", "瓅", "遢", "筱", "奥", "善", "氩", "盉", "帖", "杜", "鈉", "蕉",
|
|
||||||
"鳄", "江", "鄂", "矩", "排", "诠", "准", "菼", "刻", "足", "峦", "灰", "僳", "蝢", "孫", "據",
|
|
||||||
"璆", "孳", "徹", "妆", "坭", "渌", "喏", "钦", "朔", "机", "徼", "掾", "其", "愧", "刹", "迹",
|
|
||||||
"惹", "闿", "鹘", "窗", "贳", "箓", "奡", "鸮", "翟", "滠", "辄", "夸", "袱", "】", "阿", "摁",
|
|
||||||
"觽", "垠", "棼", "杉", "甏", "旖", "阜", "猩", "埤", "醛", "馥", "哫", "囝", "晖", "骸", "椭",
|
|
||||||
"军", "哺", "束", "犄", "遛", "涡", "罄", "赇", "幌", "曙", "契", "饹", "圳", "仝", "爝", "示",
|
|
||||||
"敝", "漓", "马", "铐", "掳", "鲱", "蟀", "阵", "栒", "b", "轮", "俯", "嬉", "漩", "佟", "捆",
|
|
||||||
"迩", "液", "炊", "抒", "迭", "换", "菘", "请", "柩", "腓", "妥", "怖", "穆", "骝", "胛", "幺",
|
|
||||||
"燥", "棹", "孖", "洁", "边", "彥", "睨", "桁", "淆", "醤", "奘", "伙", "刘", "o", "過", "鏖",
|
|
||||||
"怄", "镗", "蘖", "卩", "龈", "枣", "联", "圊", "贫", "郭", "梗", "赡", "輸", "拼", "冰", "Ë",
|
|
||||||
"姓", "办", "拉", "富", "对", "肩", "缬", "逻", "瞠", "な", "荚", "宝", "緃", "峋", "鞁", "坉",
|
|
||||||
"译", "银", "猹", "榞", "借", "楠", "殽", "片", "谸", "雩", "琨", "颏", "晷", ";", "莠", "楽",
|
|
||||||
"嬗", "啉", "爍", "視", "岽", "荙", "冗", "『", "鯭", "觌", "喹", "药", "腈", "蹰", "–", "泷",
|
|
||||||
"榷", "卮", "缥", "各", "瘙", "凡", "悍", "劢", "6", "镰", "四", "摈", "驹", "陵", "沟", "麟",
|
|
||||||
"骊", "椀", "★", "滦", "菌", "彊", "疽", "辫", "璎", "郁", "资", "粼", "萄", "鳁", "鸤", "漁",
|
|
||||||
"痃", "绌", "蒴", "尊", "哙", "守", "坠", "胯", "泽", "持", "隅", "耧", "逛", "鄯", "瑜", "召",
|
|
||||||
"眷", "逄", "垦", "窄", "板", "赵", "源", "绹", "總", "适", "轰", "钛", "停", "唝", "燎", "栽",
|
|
||||||
"燃", "搬", "夬", "掐", "簦", "旃", "更", "蹊", "熙", "埃", "偲", "蒙", "舍", "杭", "朋", "霍",
|
|
||||||
"谙", "蓥", "赫", "鲏", "純", "缃", "蓓", "è", "萧", "锆", "诉", "恚", "u", "旷", "⑵", "褲",
|
|
||||||
"鲟", "互", "峂", "绝", "陒", "议", "脽", "态", "岙", "僮", "突", "濯", "犹", "阆", "溧", "宜",
|
|
||||||
"霣", "呆", "鄉", "觖", "濠", "耐", "橱", "敛", "踮", "怦", "鸠", "授", "滫", "叭", "镤", "仵",
|
|
||||||
"圖", "救", "变", "寕", "洋", "泃", "晥", "檎", "缦", "鏠", "悬", "彧", "韓", "籽", "磷", "歙",
|
|
||||||
"÷", "杨", "闯", "罴", "鼠", "陋", "瀣", "揾", "匪", "晚", "丧", "彷", "梏", "诽", "尻", "蹢",
|
|
||||||
"拊", "氕", "芳", "凸", "懊", "型", "媄", "憝", "骆", "纭", "刺", "悼", "藍", "欧", "葳", "艺",
|
|
||||||
"±", "紗", "钡", "钭", "首", "栈", "闩", "勃", "喑", "栉", "栟", "焊", "哀", "竖", "肘", "悲",
|
|
||||||
"鳜", "迨", "怿", "裘", "綮", "蕹", "冕", "无", "鼢", "戽", "鲣", "蜍", "插", "扯", "俣", "袪",
|
|
||||||
"级", "理", "茵", "矣", "障", "禊", "惯", "䄂", "宏", "韫", "栗", "编", "座", "跫", "、", "捞",
|
|
||||||
"谠", "車", "袆", "舖", "暝", "9", "混", "蓊", "韂", "拳", "j", "伶", "啦", "0", "诰", "缑",
|
|
||||||
"郴", "蕲", "谛", "皋", "兢", "件", "隈", "溊", "窈", "檀", "洪", "揭", "油", "颐", "剎", "侠",
|
|
||||||
"或", "腻", "迪", "舔", "牺", "倦", "夭", "蠋", "鹑", "倜", "喱", "豨", "谨", "翥", "馄", "蔚",
|
|
||||||
"类", "鳍", "泯", "岷", "張", "紅", "情", "婪", "睡", "诒", "旋", "舱", "列", "求", "姥", "翯",
|
|
||||||
"锲", "適", "蒡", "㭗", "韵", "撄", "律", "湊", "鲪", "碓", "馓", "汛", "尤", "℃", "泫", "竹",
|
|
||||||
"溃", "诵", "乡", "楹", "肷", "戋", "獭", "撴", "晤", "嘎", "裢", "轱", "歘", "鼱", "舀", "潵",
|
|
||||||
"腩", "挽", "至", "骞", "佻", "蕊", "轫", "岘", "斌", "《", "雇", "枘", "贶", "履", "亭", "绍",
|
|
||||||
"空", "泡", "擒", "蠓", "桼", "舢", "踵", "柢", "躺", "罥", "蔁", "幪", "蒯", "≤", "齉", "況",
|
|
||||||
"锕", "徇", "儿", "帽", "谕", "抽", "獍", "揪", "膳", "酴", "姊", "作", "弍", "郦", "翮", "髌",
|
|
||||||
"勝", "孔", "颁", "泩", "衾", "翊", "噉", "∩", "先", "的", "財", "绢", "崇", "饲", "賓", "绾",
|
|
||||||
"陡", "砾", "纷", "酱", "窎", "浕", "琫", "补", "嗽", "呛", "蚓", "伪", "艨", "搡", "粗", "阱",
|
|
||||||
"商", "郫", "喵", "缯", "炻", "州", "珽", "芒", "脶", "蕖", "拘", "\\", "蕃", "伏", "蛏", "璘",
|
|
||||||
"缡", "伞", "吸", "煞", "嗫", "餪", "━", "8", "漠", "衮", "峭", "筌", "阀", "乇", "杞", "镕",
|
|
||||||
"鹈", "觚", "疋", "猥", "肚", "阃", "地", "鸾", "玚", "镀", "诬", "凇", "惬", "劝", "堠", "睪",
|
|
||||||
"瞪", "涓", "專", "琢", "蠹", "裂", "芎", "轲", "谯", "畋", "长", "攘", "缁", "吵", "汈", "稂",
|
|
||||||
"咤", "甸", "瀑", "讣", "翠", "綦", "笑", "掺", "宫", "唆", "旗", "帘", "遵", "唪", "连", "必",
|
|
||||||
"姬", "枵", "渫", "神", "凱", "虞", "闵", "爭", "迢", "飓", "桴", "冑", "招", "鬶", "赊", "朦",
|
|
||||||
"薜", "逗", "铩", "焖", "狱", "昕", "犀", "捏", "侓", "爆", "箧", "怠", "缐", "苄", "踯", "暹",
|
|
||||||
"絮", "裁", "嘶", "诙", "攥", "¥", "痫", "摹", "朕", "薰", "闼", "樨", "需", "蕾", "扔", "服",
|
|
||||||
"蚩", "漳", "瞻", "陟", "诐", "爽", "耩", "坏", "寶", "瞧", "蘅", "爺", "仇", "瞈", "揉", "隰",
|
|
||||||
"阏", "皤", "鉏", "郇", "啵", "僧", "渐", "骠", "厾", "谦", "榱", "蓮", "乂", "伷", "觐", "遫",
|
|
||||||
"畀", "污", "眭", "蝗", "亢", "腾", "遇", "欺", "狸", "籼", "锯", "赚", "槔", "域", "老", "掩",
|
|
||||||
"锎", "邪", "沬", "袯", "映", "肤", "舒", "箦", "涎", "缔", "孃", "萎", "推", "峧", "嫒", "嵐",
|
|
||||||
"怒", "闾", "阅", "扢", "絡", "剩", "腢", "晌", "铟", "铚", "燔", "鄜", "墠", "擤", "芸", "鼩",
|
|
||||||
"食", "朰", "啥", "贿", "券", "垩", "典", "逭", "成", "帧", "侯", "妒", "顷", "晩", "浦", "汳",
|
|
||||||
"㾄", "坊", "②", "蝼", "倩", "挦", "騠", "蓂", "兹", "渤", "茴", "矶", "煤", "伛", "逃", "堞",
|
|
||||||
"漭", "闽", "埠", "妗", "烙", "傀", "滢", "堂", "骅", "崮", "兿", "螟", "榴", "蒈", "蟛", "员",
|
|
||||||
"嗟", "毗", "鹭", "毖", "籍", "傣", "偈", "澴", "耆", "黔", "薷", "俘", "箸", "蒺", "记", "铣",
|
|
||||||
"拥", "瑰", "抉", "史", "急", "什", "川", "曪", "剖", "罪", "扥", "鱼", "羌", "复", "昵", "朓",
|
|
||||||
"氪", "矅", "ò", "摒", "催", "{", "懂", "莉", "屃", "症", "遒", "璋", "鄒", "玄", "氦", "舳",
|
|
||||||
"納", "锷", "鲥", "羸", "怵", "嬖", "飚", "倔", "辨", "篌", "娄", "洎", "弟", "悝", "婥", "待",
|
|
||||||
"梃", "棕", "撞", "肊", "婺", "桥", "慕", "5", "尖", "监", "囊", "腎", "瘁", "⑴", "伢", "邈",
|
|
||||||
"炷", "籴", "月", "终", "龠", "蹇", "柯", "噜", "多", "玮", "踹", "泣", "圮", "辈", "鄙", "赆",
|
|
||||||
"蠃", "珈", "鬃", "识", "甑", "藓", "宥", "韧", "隗", "跚", "峁", "晒", "戳", "環", "葓", "筐",
|
|
||||||
"瑚", "稆", "巩", "葵", "网", "铨", "仡", "呶", "休", "村", "傩", "浡", "祝", "幅", "晳", "岿",
|
|
||||||
"[", "尃", "响", "咽", "讵", "淹", "糠", "驴", "烹", "甪", "焯", "整", "瘆", "茀", "瑞", "嘁",
|
|
||||||
"赅", "芼", "震", "怼", "膑", "蒟", "钊", "缷", "獨", "俄", "噪", "亏", "芍", "鲲", "黟", "酃",
|
|
||||||
"铻", "→", "鲡", "仑", "有", "扌", "治", "聆", "腙", "冬", "剥", "诓", "祃", "棘", "孥", "濆",
|
|
||||||
"鞋", "赣", "卜", "谆", "投", "琊", "塍", "洗", "津", "肴", "奕", "掴", "螗", "胸", "惦", "赠",
|
|
||||||
"耶", "蛸", "曷", "名", "栏", "崖", "始", "孤", "篆", "椴", "模", "儙", "袁", "颌", "卤", "寂",
|
|
||||||
"呸", "蓺", "噶", "钔", "粹", "潸", "郾", "娡", "啖", "芜", "䧳", "缟", "鞅", "哇", "嚄", "熛",
|
|
||||||
"私", "住", "鉄", "U", "失", "蜾", "㺃", "莨", "擂", "沕", "铄", "耰", "炔", "藐", "锈", "苇",
|
|
||||||
"芯", "檇", "龍", "珀", "筻", "褚", "缀", "棬", "仲", "杧", "铛", "挖", "缸", "嵇", "剟", "觋",
|
|
||||||
"鬼", "天", "鹪", "豭", "腴", "驿", "粵", "馈", "迸", "夕", "蜚", "禺", "迎", "狞", "嶽", "躯",
|
|
||||||
"暅", "蹾", "甾", "柆", "e", "欃", "無", "羽", "蛛", "僖", "碲", "遴", "厝", "滂", "眦", "鹅",
|
|
||||||
"咖", "潜", "媽", "啴", "寨", "琬", "判", "塌", "与", "剧", "掮", "读", "庆", "愚", "氲", "梾",
|
|
||||||
"稗", "很", "饯", "淘", "灞", "A", "!", "飾", "醚", "壁", "芮", "蔼", "知", "郯", "瞒", "术",
|
|
||||||
"法", "匠", "┅", "尕", "脂", "琅", "孝", "埙", "濟", "袷", "方", "芾", "驽", "线", "咡", "睿",
|
|
||||||
"滥", "鞨", "駹", "馎", "蛲", "申", "涣", "牗", "跄", "鲐", "泐", "铍", "肓", "é", "司", "辣",
|
|
||||||
"慑", "崞", "魏", "髯", "涯", "滃", "薪", "~", "鐵", "町", "芥", "匿", "癞", "嘚", "□", "啪",
|
|
||||||
"俇", "橛", "巅", "鄞", "痱", "回", "着", "魂", "瘀", "埒", "措", "蚪", "锁", "巴", "芏", "另",
|
|
||||||
"忏", "洇", "⑾", "箱", "劳", "榈", "B", "毒", "泰", "巍", "寇", "斛", "赖", "酒", "密", "左",
|
|
||||||
"莜", "茯", "脆", "饴", "洳", "舉", "酪", "鼒", "職", "桫", "螋", "砌", "戬", "前", "豁", "膲",
|
|
||||||
"鲯", "憋", "珺", "蹂", "亥", "冷", "蜔", "俦", "碇", "慰", "扒", "亮", "弒", "胫", "肠", "弇",
|
|
||||||
"拣", "丸", "涫", "勉", "帝", "玠", "应", "砜", "鬓", "泮", "鳖", "峇", "辗", "吴", "僡", "玊",
|
|
||||||
"钘", "惘", "奁", "翰", "爨", "罽", "莶", "纻", "菪", "興", "钧", "鄹", "缌", "嘉", "玲", "蛋",
|
|
||||||
"倚", "镬", "彳", "氯", "谐", "疗", "魉", "扈", "鏡", "涤", "佑", "勾", "饽", "R", "局", "醉",
|
|
||||||
"燫", "驱", "崽", "榮", "锄", "脎", "枓", "敲", "俑", "镶", "当", "逦", "寅", "捽", "宗", "茼",
|
|
||||||
"稿", "躇", "晋", "东", "渭", "闶", "潦", "慊", "蔟", "疑", "蜡", "嬛", "荒", "扪", "拈", "受",
|
|
||||||
"沱", "螽", "嗣", "瘰", "颖", "百", "楱", "崛", "窿", "蓬", "禨", "螵", "捕", "鳀", "霰", "瘸",
|
|
||||||
"圄", "陂", "敢", "矗", "炉", "篪", "注", "凉", "摔", "衿", "g", "遘", "鳟", "腚", "泱", "荣",
|
|
||||||
"池", "杰", "阻", "里", "峥", "葺", "翔", "柁", "飐", "载", "刨", "瘊", "溏", "嚭", "穝", "站",
|
|
||||||
"笞", "厌", "门", "汞", "雠", "靖", "怨", "葙", "厅", "凉", "荨", "底", "坼", "海", "轩", "秃",
|
|
||||||
"醭", "莒", "坎", "驺", "瀛", "嵝", "溥", "嚋", "寤", "k", "築", "耔", "跎", "小", "弊", "疭",
|
|
||||||
"√", "洞", "利", "次", "迕", "鸿", "刁", "媳", "酹", "磋", "勒", "伎", "浔", "斤", "珊", "鸳",
|
|
||||||
"颊", "塗", "刈", "龛", "縻", "楚", "脓", "南", "伧", "桨", "尸", "退", "亊", "侔", "蓟", "削",
|
|
||||||
"诲", "瑯", "挝", "绩", "嶂", "耷", "太", "著", "讥", "烂", "哥", "菔", "唳", "筫", "嚷", "斯",
|
|
||||||
"蔡", "碧", "慵", "搐", "琵", "逶", "菡", "篙", "驭", "莲", "毫", "犯", "坌", "啕", "疴", "傻",
|
|
||||||
"牧", "肺", "既", "悟", "讳", "练", "钠", "胨", "G", "迁", "帙", "嫦", "哿", "柝", "迤", "庑",
|
|
||||||
"踟", "粤", "乜", "悭", "霁", "在", "虔", "蓦", "暾", "鲅", "様", "徐", "诫", "料", "蒂", "揎",
|
|
||||||
"溜", "崎", "玦", "隐", "绳", "幼", "钼", "腧", "槌", "镅", "锿", "蹁", "猱", "狁", "蹈", "S",
|
|
||||||
"傢", "}", "珉", "浴", "龚", "吇", "肆", "⑩", "臌", "鏢", "―", "定", "哧", "螳", "爹", "摞",
|
|
||||||
"榍", "苩", "丑", "犴", "璀", "偎", "燹", "洯", "啁", "鱾", "計", "镂", "字", "琉", "壴", "霖",
|
|
||||||
"蟜", "做", "松", "扣", "徊", "叱", "桑", "≠", "寓", "崔", "茌", "`", "殓", "猖", "慎", "坻",
|
|
||||||
"皮", "喔", "耕", "拧", "织", "桂", "垝", "约", "飯", "皿", "移", "微", "桅", "钬", "赑", "宓",
|
|
||||||
"颉", "掂", "希", "沁", "翳", "澝", "崭", "聃", "蔸", "磕", "崚", "奉", "槍", "颠", "任", "飕",
|
|
||||||
"蛴", "歼", "蛭", "¥", "鉵", "刷", "泓", "頫", "诤", "阈", "诖", "漂", "冯", "柠", "本", "狼",
|
|
||||||
"芗", "丹", "柒", "饫", "衫", "摩", "桃", "啼", "譄", "醌", "帡", "少", "螃", "祷", "幸", "喘",
|
|
||||||
"噼", "甲", "刭", "泊", "那", "舄", "畲", "遊", "記", "绠", "蝈", "忒", "耙", "鹌", "搽", "绕",
|
|
||||||
"镠", "凼", "扫", "鸵", "匡", "缈", "铼", "挢", "踊", "噭", "苴", "貌", "啾", "蒜", "茱", "⒅",
|
|
||||||
"邛", "棉", "怀", "筵", "笾", "邢", "濱", "蒲", "嫠", "儡", "益", "萊", "歩", "日", "婢", "楔",
|
|
||||||
"嚎", "髂", "禄", "巫", "懿", "砍", "惆", "袭", "~", "欲", "鳅", "書", "撷", "库", "绻", "⒁",
|
|
||||||
"蒹", "辜", "屉", "奂", "龇", "诡", "醑", "儀", "讷", "聒", "盖", "浅", "長", "蛑", "念", "辒",
|
|
||||||
"蚊", "勖", "揣", "岬", "糅", "刊", "蛆", "硌", "柜", "惗", "激", "颈", "閣", "唬", "捧", "蒽",
|
|
||||||
"爸", "侏", "氧", "蛤", "牡", "瘦", "铉", "撒", "屿", "谔", "钀", "瓦", "恂", "钻", "声", "馨",
|
|
||||||
"贷", "藁", "闰", "人", "瀹", "间", "炜", "煨", "畴", "鞡", "悔", "量", "原", "屁", "杯", "趋",
|
|
||||||
"愎", "瓶", "烘", "韬", "姮", "勰", "眛", "饮", "瘐", "决", "版", "際", "俸", "此", "辽", "愫",
|
|
||||||
"爷", "隘", "螯", "腕", "闟", "厣", "齁", "矿", "女", "扇", "郄", "诚", "焜", "<", "檵", "赙",
|
|
||||||
"郢", "酌", "恳", "侵", "劍", "徳", "脔", "岍", "倍", "隹", "镲", "僬", "○", "啭", "坳", "谰",
|
|
||||||
"璪", "昆", "⒆", "惧", "虺", "篡", "锐", "邮", "嵊", "恹", "茄", "黯", "對", "魑", "禀", "添",
|
|
||||||
"汎", "廉", "戈", "∈", "陈", "肟", "螅", "缰", "啃", "瘾", "辁", "茜", "曝", "C", "餍", "蜴",
|
|
||||||
"咦", "臺", "扺", "隽", "崴", "遐", "胃", "铕", "楗", "墀", "魈", "淑", "邰", "飧", "勤", "糨",
|
|
||||||
"你", "铂", "蛰", "锻", "塈", "予", "怏", "鷪", "暂", "诞", "會", "鼐", "辏", "矬", "筜", "蟒",
|
|
||||||
"柫", "靴", "垒", "樵", "狨", "魋", "查", "掸", "'", "每", "浮", "绞", "疍", "於", "饦", "辐",
|
|
||||||
"墟", "窳", "养", "墦", "鲔", "黝", "迈", "砟", "伥", "藜", "墅", "喬", "卬", "赶", "眼", "龌",
|
|
||||||
"主", "嗞", "転", "襕", ",", "杲", "儒", "政", "蹩", "付", "Я", "厢", "点", "如", "茗", "奍",
|
|
||||||
"嗌", "蕴", "问", "膚", "硒", "佃", "居", "赍", "鯨", "槜", "衡", "廐", "鲾", "慈", "莫", "篮",
|
|
||||||
"铱", "园", "范", "靠", "叻", "绸", "众", "抑", "浃", "極", "辀", "千", "蔯", "r", "碱", "應",
|
|
||||||
"夼", "祺", "绣", "澉", "愛", "譬", "刚", "扼", "檫", "时", "荼", "部", "耽", "稀", "琳", "鎮",
|
|
||||||
"鳃", "疌", "挠", "京", "筴", "胳", "橡", "裱", "栊", "蛩", "流", "猄", "葭", "邯", "伤", "秦",
|
|
||||||
"孺", "郓", "筏", "茉", "谈", "黥", "鳇", "魅", "秫", "敫", "肄", "俪", "尺", "度", "廊", "昏",
|
|
||||||
"怎", "钎", "愤", "腿", "Λ", "碘", "囤", "婆", "煋", "桀", "阪", "焒", "砘", "澡", "阒", "臼",
|
|
||||||
"茭", "纛", "唻", "胗", "歌", "吱", "铸", "種", "礳", "舾", "砰", "即", "慥", "缆", "躞", "卖",
|
|
||||||
"眆", "搎", "鲁", "稠", "惝", "毶", "耎", "挡", "异", "咨", "歔", "垫", "素", "鳊", "打", "镏",
|
|
||||||
"耗", "锭", "剀", "讓", "鍋", "歇", "拗", "齿", "聊", "惕", "塔", "况", "汐", "采", "灣", "菁",
|
|
||||||
"峰", "呋", "阴", "衽", "鹏", "挂", "鲞", "第", "浑", "秭", "铯", "胎", "滔", "雕", "渔", "骍",
|
|
||||||
"鴻", "眚", "易", "枫", "麥", "罹", "劈", "苣", "蹄", "是", "锛", "饼", "铳", "器", "秒", "開",
|
|
||||||
"舛", "纳", "庒", "饰", "发", "虎", "乏", "届", "浯", "目", "霪", "氢", "华", "澧", "陴", "誊",
|
|
||||||
"欷", "瀌", "梵", "嗖", "惩", "妞", "螓", "溇", "嬃", "笼", "嬢", "獠", "辩", "骛", "挪", "礅",
|
|
||||||
"銎", "趺", "狍", "阑", "擦", "咙", "+", "鼻", "戆", "宿", "霏", "牁", "耦", "枳", "鲸", "阽",
|
|
||||||
"峯", "瘭", "祁", "镜", "绮", "道", "摭", "﹒", "耠", "限", "轻", "莽", "帛", "唯", "墒", "W",
|
|
||||||
"荦", "矸", "崃", "洫", "杳", "舌", "涌", "朵", "苑", "瓻", "辂", "凬", "畺", "莩", "奔", "荐",
|
|
||||||
"撵", "嶲", "憧", "沖", "邱", "瞑", "瘢", "腺", "嵯", "诗", "經", "胚", "谎", "央", "稼", "3",
|
|
||||||
"冈", "痣", "故", "皎", "珅", "踅", "硇", "通", "鹱", "草", "粕", "而", "梨", "萜", "搛", "鸺",
|
|
||||||
"托", "倡", "匾", "骷", "桐", "阇", "璃", "抖", "显", "镝", "I", "锣", "辆", "茝", "虫", "谀",
|
|
||||||
"萤", "帼", "滈", "鮼", "乔", "会", "产", "縢", "纱", "跆", "铤", "圯", "旚", "墓", "棰", "苜",
|
|
||||||
"聩", "蚍", "筢", "殇", "槚", "阂", "豊", "翚", "犰", "疚", "浠", "礴", "界", "蛙", "吁", "忪",
|
|
||||||
"讫", "辇", "汧", "澥", "纮", "糺", "拭", "镉", "鄠", "讼", "鎏", "逐", "烝", "扊", "筹", "仪",
|
|
||||||
"段", "杵", "卲", "钤", "曹", "潘", "浏", "陑", "答", "期", "橋", "位", "梅", "榻", "芨", "汭",
|
|
||||||
"贇", "杅", "伋", "馁", "皈", "躁", "鲦", "良", "幤", "曺", "榖", "便", "氍", "窍", "槃", "設",
|
|
||||||
"襦", "菓", "猡", "槲", "觱", "邠", "岩", "骈", "鬲", "睽", "残", "碗", "凯", "览", "伾", "吣",
|
|
||||||
"峡", "箨", "忡", "岁", "醵", "霸", "焐", "谁", "疟", "樾", "竺", "果", "阝", "花", "•", "耱",
|
|
||||||
"嗜", "骰", "萋", "甄", "侍", "厦", "辌", "给", "健", "垧", "娩", "校", "又", "临", "揽", "犟",
|
|
||||||
"蠢", "包", "檬", "穹", "燮", "札", "翏", "领", "深", "诶", "窒", "鸷", "统", "肾", "澍", "畹",
|
|
||||||
"职", "蝟", "眬", "旎", "朘", "眠", "篚", "贼", "貴", "#", "茆", "磬", "①", "祀", "坞", "盯",
|
|
||||||
"苹", "痼", "耪", "狺", "妓", "湨", "動", "渚", "售", "鸶", "霄", "某", "×", "铀", "桓", "脲",
|
|
||||||
"心", "节", "[", "蜩", "剐", "猜", "盂", "穿", "洮", "坜", "掷", "坐", "烜", "冶", "袍", "穴",
|
|
||||||
"偷", "槽", "面", "媛", "撙", "孩", "询", "苈", "橙", "恙", "吉", "燈", "暇", "杋", "後", "璇",
|
|
||||||
"喟", "妪", "湄", "贡", "兮", "楫", "锗", "肮", "圉", "参", "烃", "葩", "骀", "锖", "犿", "恼",
|
|
||||||
"麯", "牮", "溱", "俵", "祢", "割", "羲", "中", "博", "膦", "7", "钙", "耸", "蚋", "嘬", "逝",
|
|
||||||
"瓿", "且", "苗", "屑", "華", "糯", "吓", "陇", ".", "魆", "d", "拶", "箜", "澳", "蘼", "垸",
|
|
||||||
"钅", "樯", "侑", "娼", "赛", "恪", "击", "濮", "隔", "似", "惜", "滤", "培", "谩", "坝", "煙",
|
|
||||||
"馕", "茡", "颼", "啡", "强", "巡", "糗", "芙", "骒", "常", "鳔", "楣", "嫩", "师", "攀", "旭",
|
|
||||||
"茠", "吏", "剌", "露", "把", "携", "忘", "软", "荩", "弧", "锩", "僦", "掘", "仓", "嵚", "沲",
|
|
||||||
"傅", "蝴", "爵", "纪", "薮", "參", "荀", "珙", "逞", "蹙", "徉", "佶", "庹", "弢", "锞", "想",
|
|
||||||
"窕", "躬", "蚡", "闸", "难", "妃", "唶", "V", "蚂", "酐", "]", "羁", "土", "敏", "蝻", "赎",
|
|
||||||
"闹", "铷", "脬", "攫", "椐", "橹", "(", "暨", "剁", "灵", "咫", "苻", "赈", "萝", "鹮", "颛",
|
|
||||||
"勿", "臆", "福", "蝠", "烺", "跤", "垓", "庋", "鏊", "彭", "碚", "膂", "鐘", "鸹", "择", "疫",
|
|
||||||
"暌", "胙", "翘", "仞", "實", "猫", "嘹", "坟", "泺", "靽", "仟", "鲋", "畅", "箪", "箩", "星",
|
|
||||||
"辍", "餐", "伍", "狃", "蹐", "芴", "龋", "仨", "狩", "诌", "媚", "琚", "❤", "鉰", "仔", "忝",
|
|
||||||
"垅", "嗤", "氅", "填", "詹", "诿", "皑", "椅", "鲭", "繪", "碑", "选", "廠", "嶓", "迮", "唠",
|
|
||||||
"赏", "│", "泍", "姑", "晦", "直", "艾", "邂", "裝", "郊", "疡", "」", "鸲", "兕", "县", "邡",
|
|
||||||
"麤", "旧", "壶", "礼", "梭", "铔", "玕", "硬", "后", "馅", "窠", "俊", "犾", "槭", "姨", "妨",
|
|
||||||
"幡", "沿", "帮", "爿", "褊", "淅", "册", "诔", "使", "唢", "族", "逮", "蟋", "掣", "红", "颔",
|
|
||||||
"闲", "咧", "衍", "矛", "蹜", "抱", "雀", "箴", "雙", "茁", "聲", "芰", "瘤", "落", "峨", "秆",
|
|
||||||
"献", "霭", "胰", "墁", "柽", "跛", "阼", "菝", "谗", "蠊", "懋", "蔷", "拃", "岱", "戡", "逡",
|
|
||||||
"背", "克", "棱", "栲", "乖", "橄", "炭", "萱", "胲", "摺", "殆", "魍", "宾", "拢", "姁", "祲",
|
|
||||||
"·", "歹", "胆", "爱", "旰", "抢", "匜", "硚", "愈", "跳", "▲", "跪", "網", "糈", "維", "痹",
|
|
||||||
"娃", "疸", "痔", "褙", "芭", "鹜", "猷", "檩", "吨", "淖", "竟", "呱", "瘠", "奢", "呐", "扞",
|
|
||||||
"灼", "家", "饿", "玻", "倾", "锚", "遗", "赝", "去", "跣", "纬", "玢", "梆", "陉", "屎", "上",
|
|
||||||
"酆", "搤", "併", "腌", "避", "禹", "绅", "帆", "英", "区", "交", "▪", "巧", "豹", "游", "清",
|
|
||||||
"荧", "娀", "訓", "尾", "觫", "梢", "均", "忤", "继", "變", "喽", "尉", "骚", "趸", "馃", "石",
|
|
||||||
"置", "毡", "竴", "龄", "掭", "骟", "堡", "确", "街", "沌", "恨", "壮", "弄", "鞴", "剿", "貅",
|
|
||||||
"路", "杠", "豚", "澜", "靼", "擐", "租", "媪", "彖", "逢", "撺", "盗", "呑", "锥", "栎", "唧",
|
|
||||||
"翩", "倥", "虽", "郏", "钒", "雉", "捒", "官", "酷", "樽", "房", "屣", "堌", "蒍", "悌", "④",
|
|
||||||
"蛳", "俱", "慢", "筇", "框", "椽", "债", "吭", "柱", "夤", "嘈", "骺", "趹", "刓", "豕", "噢",
|
|
||||||
"J", "论", "俞", "仙", "碰", "经", "铋", "⑤", "顽", "曲", "得", "汪", "浒", "际", "嘏", "扮",
|
|
||||||
"祊", "嵋", "‘", "牒", "禮", "粒", "籀", "闱", "刍", "邕", "美", "苫", "靺", "铹", "莰", "蔑",
|
|
||||||
"敵", "逑", "锬", "宸", "鞥", "匝", "褀", "菅", "逊", "脱", "晔", "罾", "郝", "恰", "丟", "芩",
|
|
||||||
"枷", "鹩", "a", "夙", "述", "钢", "瀵", "铧", "翱", "尧", "醮", "&", "鳐", "懲", "濩", "树",
|
|
||||||
"劵", "虾", "砚", "腄", "罟", "硖", "崩", "埘", "╱", "血", "觯", "巂", "栀", "萏", "雪", "眵",
|
|
||||||
"白", "新", "靶", "猪", "亻", "窬", "Ⅳ", "麽", "遄", "沏", "茅", "笋", "甬", "潔", "悛", "幕",
|
|
||||||
"鹤", "悴", "耻", "觜", "滞", "己", "鏂", "肛", "婤", "嘞", "蘸", "胴", "柿", "穗", "咯", "嵕",
|
|
||||||
"蜣", "僰", "玃", "细", "▏", "戗", "尘", "仄", "子", "历", "翌", "烀", "晞", "桷", "藕", "啄",
|
|
||||||
"腐", "瘥", "诣", "彗", "捭", "懑", "陛", "傥", "沚", "贯", "﹑", "雒", "航", "跬", "晓", "赢",
|
|
||||||
"鸭", "臊", "闳", "稲", "阁", "鼋", "‰", "个", "農", "匮", "辚", "滗", "桢", "詈", "绗", "馇",
|
|
||||||
"蜓", "专", "余", "鳉", "稚", "午", "珍", "癃", "钵", "档", "颍", "厕", "岭", "祓", "诂", "襙",
|
|
||||||
"炙", "枯", "锦", "袤", "桊", "嗓", "䘵", "嫂", "丰", "苎", "梦", "早", "菱", "钱", "灯", "钮",
|
|
||||||
"锓", "豐", "蝤", "协", "h", "鼓", "燕", "覺", "涮", "呗", "専", "篓", "玷", "褓", "从", "鲍",
|
|
||||||
"羧", "俭", "萌", "樣", "颜", "谷", "夹", "囫", "』", "缙", "鹰", "条", "状", "沨", "因", "莺",
|
|
||||||
"楼", "扰", "禢", "砧", "埔", "纩", "臭", "茶", "妹", "莼", "境", "山", "诘", "蒌", "唤", "仃",
|
|
||||||
"眄", "田", "畼", "缱", "祘", "痴", "揍", "猾", "甙", "躲", "返", "砝", "叽", "苟", "肭", "悖",
|
|
||||||
"嫣", "凋", "踞", "勢", "弃", "般", "赤", "佣", "嘘", "智", "寒", "旆", "咎", "戊", "温", "昙",
|
|
||||||
"蝄", "掉", "冤", "凍", "一", "鋼", "茏", "兼", "昃", "峤", "挟", "0", "顾", "颅", "*", "遆",
|
|
||||||
"硅", "烯", "眊", "鳘", "下", "雾", "癖", "贮", "缴", "升", "劲", "婞", "掇", "梯", "壬", "撮",
|
|
||||||
"绑", "氣", "薤", "兔", "赟", "漕", "签", "顇", "砭", "鸼", "明", "佧", "鹗", "哉", "席", "趁",
|
|
||||||
"砖", "澌", "撤", "萨", "熔", "蚝", "狮", "夔", "介", "青", "喈", "郂", "埝", "狗", "哚", "咪",
|
|
||||||
"材", "跑", "炗", "龉", "髓", "衣", "觀", "淋", "寄", "羮", "抵", "恁", "藿", "蜢", "甜", "兽",
|
|
||||||
"洒", "眶", "薯", "绷", "颀", "骑", "鲻", "|", "q", "繄", "丝", "焙", "瑕", "童", "豸", "讨",
|
|
||||||
"钿", "艮", "踧", "幽", "焰", "宽", "址", "鸩", "堉", "音", "擅", "铞", "埌", "宙", "_", "钥",
|
|
||||||
"承", "率", "噬", "凌", "程", "蚕", "嘣", "饭", "橉", "径", "1", "鄏", "袜", "孙", "樊", "窦",
|
|
||||||
"牦", "歡", "罕", "蓉", "诩", "㧎", "责", "豢", "逅", "甘", "礶", "镥", "穂", "到", "檑", "皇",
|
|
||||||
"泖", "腱", "郤", "竞", "竽", "溴", "木", "蛀", "蛟", "燧", "鹳", "谵", "钳", "魔", "链", "咆",
|
|
||||||
"荡", "婳", "冢", "鲧", "昝", "※", "倫", "才", "簿", "醫", "吕", "姗", "L", "俺", "隳", "鮮",
|
|
||||||
"衩", "鲆", "崟", "鲹", "岗", "镇", "嗄", "硪", "盎", "氟", "笪", "今", "公", "尽", "佬", "骧",
|
|
||||||
"驻", "翼", "咚", "盱", "镎", "浈", "谅", "洟", "嘭", "跶", "蔹", "咭", "妁", "殚", "砷", "购",
|
|
||||||
"犸", "哄", "笸", "喂", "赋", "倓", "项", "臧", "梳", "笏", "鹝", "昭", "罿", "镄", "П", "湯",
|
|
||||||
"U", "绐", "绂", "鸬", "毐", "台", "鈴", "憾", "痰", "缭", "绒", "赁", "效", "哝", "─", "偆",
|
|
||||||
"泗", "谇", "貘", "搜", "阗", "個", "艟", "嗔", "正", "筛", "鼷", "摛", "馀", "坡", "卓", "厥",
|
|
||||||
"睾", "雯", "篷", "狰", "掀", "夫", "靛", "伻", "茇", "顧", "猕", "占", "俟", "兄", "垡", "兆",
|
|
||||||
"铃", "屙", "轵", "銮", "磲", "桤", "跷", "缳", "粘", "袢", "尬", "獘", "社", "彬", "哨", "煳",
|
|
||||||
"莊", "教", "鸸", "窖", "晰", "捻", "环", "廿", "驶", "椠", "熟", "赓", "挥", "兵", "洚", "桧",
|
|
||||||
"埼", "嘀", "哎", "柞", "悄", "调", "洼", "蛇", "铬", "蝇", "倆", "谂", "姻", "怕", "瑙", "算",
|
|
||||||
"惮", "遥", "惟", "甫", "玺", "袈", "税", "玟", "械", "擗", "淤", "溟", "揞", "纲", "谤", "條",
|
|
||||||
"翕", "荛", "忾", "馿", "糖", "檗", "垱", "┐", "躜", "缒", "您", "蹶", "合", "数", "斟", "粲",
|
|
||||||
"玖", "淞", "孰", "觎", "M", "嫪", "毽", "桩", "蹦", "亹", "胾", "亲", "痉", "莙", "捐", "烁",
|
|
||||||
"瓣", "邀", "但", "误", "饻", "妄", "镱", "民", "嗳", "竦", "柰", "脒", "斓", "瞟", "拱", "霾",
|
|
||||||
"粜", "腽", "咬", "唿", "礻", "y", "褒", "郪", "熰", "梻", "甭", "处", "拖", "蜉", "鄱", "絲",
|
|
||||||
"宠", "瞵", "荷", "靓", "喻", "阢", "伫", "担", "朐", "墙", "儣", "胄", "簏", "皆", "踌", "萃",
|
|
||||||
"命", "滟", "睹", "叁", "蟮", "東", "﹖", "式", "營", "疠", "醳", "尔", "購", "吒", "柴", "途",
|
|
||||||
"力", "疰", "寳", "萬", "挨", "碥", "牍", "嘻", "柃", "剡", "徝", "杖", "螺", "祈", "産", "煽",
|
|
||||||
"讽", "瞭", "由", "牀", "涉", "暴", "枚", "仰", "它", "胤", "驯", "港", "寵", "熄", "蛮", "务",
|
|
||||||
"殃", "亸", "涢", "妫", "粥", "扦", "寺", "抿", "湃", "C", "脚", "涸", "动", "務", "镭", "检",
|
|
||||||
"釂", "耳", "榦", "怆", "氨", "瘳", "钽", "牚", "负", "纂", "獬", "息", "婄", "襞", "囹", "酰",
|
|
||||||
"扑", "臛", "价", "褡", "篼", "媢", "酊", "腋", "愣", "垯", "喃", "牾", "犨", "蜂", "诹", "迂",
|
|
||||||
"槑", "好", "褰", "咝", "③", "劐", "骄", "腉", "仕", "叵", "磅", "金", "皁", "勍", "煮", "鍪",
|
|
||||||
"筚", "吲", "艰", "床", "叛", "﹔", "喜", "齑", "昧", "乎", "谲", "匈", "麒", "身", "唑", "鲤",
|
|
||||||
"栂", "垭", "殪", "沪", "苌", "濡", "蒎", "遏", "®", "质", "纫", "罐", "瞍", "柄", "呼", "锱",
|
|
||||||
"盔", "搋", "烛", "喉", "奈", "赦", "轘", "鉴", "瓒", "铰", "恽", "腠", "韶", "胼", "侄", "珂",
|
|
||||||
"鹕", "䝉", "呤", "椁", "岳", "汲", "疤", "绊", "涂", "乾", "噎", "兑", "絜", "嘧", "蚯", "们",
|
|
||||||
"蝉", "玛", "魁", "黻", "振", "冥", "报", "窟", "笱", "喊", "瓴", "喾", "説", "雹", "恫", "a",
|
|
||||||
"颧", "給", "宅", "鞧", "嬷", "鼾", "笔", "鲉", "匹", "蠕", "愍", "氽", "赐", "畔", "絪", "洴",
|
|
||||||
"审", "窂", "褔", "狯", "谑", "毳", "右", "疣", "鲙", "堃", "瓜", "…", "崆", "钫", "樭", "鲛",
|
|
||||||
"砂", "筋", "犏", "乱", "入", "浐", "膙", "厚", "獗", "覌", "焦", "罃", "宬", "隍", "荃", "醜",
|
|
||||||
"厉", "艇", "娟", "樂", "筲", "洛", "唼", "诜", "馑", "香", "缛", "运", "祟", "踩", "陲", "叔",
|
|
||||||
"潞", "二", "搠", "祼", "瀘", "蒋", "樗", "麦", "鸦", "棻", "古", "鹠", "怊", "裾", "够", "璧",
|
|
||||||
"晡", "擘", "毂", "御", "葚", "忱", "觞", "瑁", "唇", "罡", "剽", "殡", "沛", "帻", "举", "瞓",
|
|
||||||
"谬", "溝", "言", "哽", "婿", "猿", "跗", "獴", "俜", "呙", "弗", "凿", "窭", "铌", "友", "唉",
|
|
||||||
"怫", "荘",
|
|
||||||
];
|
|
||||||
|
|
||||||
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,且包含 -1,Python 里是 resize 为 (r1, r1) 的正方形
|
|
||||||
// Resize::Square(r1 as u32)
|
|
||||||
// } else {
|
|
||||||
// // 如果 word 为 false,且包含 -1,Python 里是高度固定为 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,
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
use ddddocr_rs::models::det::DetectionResult;
|
|
||||||
use ddddocr_rs::{DetBuilder, DetSession, Detector, ModelMetadata, Ocr, OcrSession, Slider}; // 假设你的包名是这个
|
|
||||||
use image::{DynamicImage, Rgb};
|
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
mod char_slice;
|
|
||||||
use char_slice::CHARSET_BETA;
|
|
||||||
use ddddocr_rs::models::ocr::metadata::{Normalization, Resize};
|
|
||||||
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_full_classification() {
|
|
||||||
// 1. 初始化模型
|
|
||||||
let ocr = OcrSession::new(
|
|
||||||
"D:\\CNWei\\CNW\\Rust\\ddddocr-rs\\models\\common_sml2h3_f32.onnx",
|
|
||||||
ModelMetadata::from_static_slice(
|
|
||||||
CHARSET_BETA,
|
|
||||||
false,
|
|
||||||
Resize::DynamicWidth(64),
|
|
||||||
1,
|
|
||||||
Normalization::MinusOneToOne,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.expect("模型加载失败");
|
|
||||||
|
|
||||||
// 2. 加载测试图片
|
|
||||||
let img = image::open("samples/code2.png").expect("测试图片不存在");
|
|
||||||
|
|
||||||
// 3. 执行识别
|
|
||||||
let result = Ocr::new(&ocr)
|
|
||||||
.predict(&img)
|
|
||||||
.expect("识别过程出错")
|
|
||||||
.into_text();
|
|
||||||
|
|
||||||
println!("识别结果: {}", result);
|
|
||||||
assert!(!result.is_empty());
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn test_det_load() -> anyhow::Result<()> {
|
|
||||||
let det = DetSession::new("D:\\CNWei\\CNW\\Rust\\ddddocr-rs\\models\\common_det.onnx")?;
|
|
||||||
let image_path = "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, "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().unwrap();
|
|
||||||
|
|
||||||
// 1. 加载你准备好的测试图
|
|
||||||
// 假设图片放在项目根目录下的 assets 文件夹
|
|
||||||
let target_img = load_image("samples/hua.png").expect("请确保 samples/hua.png 存在");
|
|
||||||
let bg_img = load_image("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().unwrap();
|
|
||||||
|
|
||||||
// 1. 加载你准备好的测试图
|
|
||||||
// 假设图片放在项目根目录下的 assets 文件夹
|
|
||||||
let target_img = load_image("samples/ken.jpg").expect("请确保 samples/ken.jpg 存在");
|
|
||||||
let bg_img = load_image("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);
|
|
||||||
}
|
|
||||||