Compare commits
3 Commits
feature-v0
...
feature-v0
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c96fbedbf | |||
| 15ce068025 | |||
| cb786a7a1a |
@@ -10,3 +10,5 @@ anyhow = "1.0.102"
|
|||||||
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_json = "1.0.150"
|
||||||
144
src/charset.rs
144
src/charset.rs
@@ -514,6 +514,150 @@ pub const CHARSET_BETA: &[&str] = &[
|
|||||||
"谬", "溝", "言", "哽", "婿", "猿", "跗", "獴", "俜", "呙", "弗", "凿", "窭", "铌", "友", "唉",
|
"谬", "溝", "言", "哽", "婿", "猿", "跗", "獴", "俜", "呙", "弗", "凿", "窭", "铌", "友", "唉",
|
||||||
"怫", "荘",
|
"怫", "荘",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
pub const CHARSET_OLD: &[&str] = &["", "笤", "谴", "膀", "荔"];
|
||||||
|
|
||||||
pub fn get_default_charset() -> Vec<String> {
|
pub fn get_default_charset() -> Vec<String> {
|
||||||
CHARSET_BETA.iter().map(|&s| s.to_string()).collect()
|
CHARSET_BETA.iter().map(|&s| s.to_string()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use std::ops::{Add, Deref};
|
||||||
|
// 字符集范围类型
|
||||||
|
/// 字符集范围限制组合子枚举
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum CharsetRestrict {
|
||||||
|
/// 纯整数 0-9
|
||||||
|
Digit,
|
||||||
|
|
||||||
|
/// 纯小写字母 a-z
|
||||||
|
Lowercase,
|
||||||
|
|
||||||
|
/// 纯大写字母 A-Z
|
||||||
|
Uppercase,
|
||||||
|
|
||||||
|
// /// 过滤模式:删除所有 ASCII 字母和数字(通常用于仅保留汉字、特殊标点)
|
||||||
|
// // ExcludeAlphanumeric,
|
||||||
|
// /// 自定义单字字符集,例如 "0123456789+-x/="
|
||||||
|
// Single(String),
|
||||||
|
/// 直接设置完整的 Token 白名单(支持多字 Token),例如 vec!["html".to_string()]
|
||||||
|
CustomList(Vec<String>),
|
||||||
|
|
||||||
|
/// 核心组合子:满足左边或右边任意一个条件即可(即 A + B 的并集逻辑)
|
||||||
|
/// 使用 Box 打破 Rust 编译期对递归枚举的无限大小限制
|
||||||
|
Or(Box<CharsetRestrict>, Box<CharsetRestrict>),
|
||||||
|
}
|
||||||
|
impl From<i32> for CharsetRestrict {
|
||||||
|
fn from(value: i32) -> Self {
|
||||||
|
match value {
|
||||||
|
0 => Self::Digit,
|
||||||
|
1 => Self::Lowercase,
|
||||||
|
2 => Self::Uppercase,
|
||||||
|
// 3 => Self::LowercaseUppercase,
|
||||||
|
// 4 => Self::LowercaseDigit,
|
||||||
|
// 5 => Self::UppercaseDigit,
|
||||||
|
// 6 => Self::LowercaseUppercaseDigit,
|
||||||
|
// 7 => Self::DefaultCharsetLowercaseUppercaseDigit,
|
||||||
|
_ => panic!("invalid charset range: {}", value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl CharsetRestrict {
|
||||||
|
/// 💡 辅助构造函数:直接在源头把用户的长字符串切碎,伪装成基础积木
|
||||||
|
pub fn from_chars(custom_str: &str) -> Self {
|
||||||
|
let tokens = custom_str.chars().map(|c| c.to_string()).collect();
|
||||||
|
CharsetRestrict::CustomList(tokens)
|
||||||
|
}
|
||||||
|
// 内部递归收集器:利用硬编码切片快速无损展开
|
||||||
|
pub(crate) fn matches(&self, s: &str) -> bool {
|
||||||
|
match self {
|
||||||
|
CharsetRestrict::Digit => s.len() == 1 && s.as_bytes()[0].is_ascii_digit(),
|
||||||
|
CharsetRestrict::Lowercase => s.len() == 1 && s.as_bytes()[0].is_ascii_lowercase(),
|
||||||
|
CharsetRestrict::Uppercase => s.len() == 1 && s.as_bytes()[0].is_ascii_uppercase(),
|
||||||
|
CharsetRestrict::CustomList(vec) => vec.iter().any(|t| t == s),
|
||||||
|
CharsetRestrict::Or(left, right) => left.matches(s) || right.matches(s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// =====================================================================
|
||||||
|
// 5. 优雅的魔法:重载 + 运算符 (实现 std::ops::Add)
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/// 支持 `CharsetRestrict::Digit + CharsetRestrict::Lowercase`
|
||||||
|
impl Add for CharsetRestrict {
|
||||||
|
type Output = Self;
|
||||||
|
|
||||||
|
fn add(self, rhs: Self) -> Self::Output {
|
||||||
|
CharsetRestrict::Or(Box::new(self), Box::new(rhs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
52
src/lib.rs
52
src/lib.rs
@@ -2,6 +2,7 @@ mod charset;
|
|||||||
|
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
mod model_metadata;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use image::DynamicImage;
|
use image::DynamicImage;
|
||||||
@@ -9,9 +10,12 @@ use std::fmt::{Display, Formatter};
|
|||||||
|
|
||||||
// 关键点:直接使用 tract 重导出的 ndarray
|
// 关键点:直接使用 tract 重导出的 ndarray
|
||||||
use crate::charset::get_default_charset;
|
use crate::charset::get_default_charset;
|
||||||
|
use crate::models::ocr::ColorRange;
|
||||||
use models::det::Det;
|
use models::det::Det;
|
||||||
use models::loader::ModelSession;
|
use models::loader::ModelSession;
|
||||||
use models::ocr::Ocr;
|
use models::ocr::Ocr;
|
||||||
|
use crate::model_metadata::ModelMetadata;
|
||||||
|
|
||||||
pub enum ModelSpec {
|
pub enum ModelSpec {
|
||||||
/// 默认 OCR (使用内置路径)
|
/// 默认 OCR (使用内置路径)
|
||||||
OcrModel,
|
OcrModel,
|
||||||
@@ -19,12 +23,12 @@ pub enum ModelSpec {
|
|||||||
/// 自定义 OCR (路径由用户提供)
|
/// 自定义 OCR (路径由用户提供)
|
||||||
CustomOcrModel {
|
CustomOcrModel {
|
||||||
path: String,
|
path: String,
|
||||||
charset: Vec<String>,
|
model_metadata: ModelMetadata,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
impl ModelSpec {
|
impl ModelSpec {
|
||||||
// 将默认路径定义为内部关联常量
|
// 将默认路径定义为内部关联常量
|
||||||
const DEFAULT_OCR_PATH: &'static str = "models/common.onnx";
|
const DEFAULT_OCR_PATH: &'static str = "models/common_sml2h3_f32.onnx";
|
||||||
const DEFAULT_DET_PATH: &'static str = "models/common_det.onnx";
|
const DEFAULT_DET_PATH: &'static str = "models/common_det.onnx";
|
||||||
}
|
}
|
||||||
pub enum Runtime {
|
pub enum Runtime {
|
||||||
@@ -58,9 +62,9 @@ impl DdddOcrBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 设置自定义 OCR 路径
|
/// 设置自定义 OCR 路径
|
||||||
pub fn custom_ocr(mut self, path: String, charset: Vec<String>) -> Self {
|
pub fn custom_ocr(mut self, path: String, model_metadata: ModelMetadata) -> Self {
|
||||||
// 直接重写枚举,替换掉之前的 Ocr 或 Det
|
// 直接重写枚举,替换掉之前的 Ocr 或 Det
|
||||||
self.mode = ModelSpec::CustomOcrModel { path, charset };
|
self.mode = ModelSpec::CustomOcrModel { path, model_metadata };
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,10 +73,10 @@ impl DdddOcrBuilder {
|
|||||||
let runtime = match self.mode {
|
let runtime = match self.mode {
|
||||||
ModelSpec::OcrModel => Runtime::Ocr(Ocr::new(
|
ModelSpec::OcrModel => Runtime::Ocr(Ocr::new(
|
||||||
ModelSpec::DEFAULT_OCR_PATH.into(),
|
ModelSpec::DEFAULT_OCR_PATH.into(),
|
||||||
get_default_charset(),
|
ModelMetadata::from_builtin_beta(),
|
||||||
)?),
|
)?),
|
||||||
ModelSpec::DetModel => Runtime::Det(Det::new(ModelSpec::DEFAULT_DET_PATH.into())?),
|
ModelSpec::DetModel => Runtime::Det(Det::new(ModelSpec::DEFAULT_DET_PATH.into())?),
|
||||||
ModelSpec::CustomOcrModel { path, charset } => Runtime::Ocr(Ocr::new(path, charset)?),
|
ModelSpec::CustomOcrModel { path, model_metadata } => Runtime::Ocr(Ocr::new(path, model_metadata)?),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(DdddOcr { runtime })
|
Ok(DdddOcr { runtime })
|
||||||
@@ -92,7 +96,7 @@ impl Display for DdddOcr {
|
|||||||
impl DdddOcr {
|
impl DdddOcr {
|
||||||
pub fn classification(&self, img: &DynamicImage) -> Result<String> {
|
pub fn classification(&self, img: &DynamicImage) -> Result<String> {
|
||||||
match &self.runtime {
|
match &self.runtime {
|
||||||
Runtime::Ocr(s) => s.predict(img, false),
|
Runtime::Ocr(s) => s.predict(img).run(),
|
||||||
Runtime::Det(_) => Err(anyhow::anyhow!("当前模型是检测模型,无法执行 OCR")),
|
Runtime::Det(_) => Err(anyhow::anyhow!("当前模型是检测模型,无法执行 OCR")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,6 +108,40 @@ impl DdddOcr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct Classification {}
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ClassificationBuilder {
|
||||||
|
img: DynamicImage,
|
||||||
|
png_fix: bool,
|
||||||
|
color_filter_colors: Option<Vec<ColorRange>>,
|
||||||
|
color_filter_custom_ranges: Option<Vec<ColorRange>>,
|
||||||
|
}
|
||||||
|
impl ClassificationBuilder {
|
||||||
|
pub fn new(img: DynamicImage) -> Self {
|
||||||
|
ClassificationBuilder {
|
||||||
|
img,
|
||||||
|
png_fix: false,
|
||||||
|
color_filter_colors: None,
|
||||||
|
color_filter_custom_ranges: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn png_fix(mut self, value: bool) -> Self {
|
||||||
|
self.png_fix = value;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn color_filter_colors(mut self, value: Vec<ColorRange>) -> Self {
|
||||||
|
self.color_filter_colors = Some(value);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn color_filter_custom_ranges(mut self, value: Vec<ColorRange>) -> Self {
|
||||||
|
self.color_filter_custom_ranges = Some(value);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn build(self) -> Classification {
|
||||||
|
Classification {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
122
src/model_metadata.rs
Normal file
122
src/model_metadata.rs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
use crate::charset::{CHARSET_BETA, CHARSET_OLD, Charset, CharsetRestrict};
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::Path;
|
||||||
|
// =====================================================================
|
||||||
|
// 1. 辅助定义的枚举与结构体
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/// 图像缩放策略枚举
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ModelMetadata {
|
||||||
|
/// 字符集管理器
|
||||||
|
pub charset: Charset,
|
||||||
|
/// 是否为单字识别模型
|
||||||
|
pub word: bool,
|
||||||
|
/// 预处理的缩放策略
|
||||||
|
pub resize: Resize,
|
||||||
|
/// 图像通道数 (1 或 3)
|
||||||
|
pub channel: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelMetadata {
|
||||||
|
// --- 优雅的工厂模式构造器 ---
|
||||||
|
/// 从预设的旧版字符集创建
|
||||||
|
pub fn from_builtin_old() -> Self {
|
||||||
|
Self::from_static_slice(CHARSET_OLD, false, Resize::Fixed(64, 64), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从预设的 Beta 版字符集创建
|
||||||
|
pub fn from_builtin_beta() -> Self {
|
||||||
|
Self::from_static_slice(CHARSET_BETA, false, Resize::Fixed(64, 64), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通用的静态切片转换构造器
|
||||||
|
pub fn from_static_slice(
|
||||||
|
slice: &[&'static str],
|
||||||
|
word: bool,
|
||||||
|
resize: Resize,
|
||||||
|
channel: u8,
|
||||||
|
) -> Self {
|
||||||
|
let tokens: Vec<Cow<'static, str>> = slice.iter().map(|&s| Cow::Borrowed(s)).collect();
|
||||||
|
Self {
|
||||||
|
charset: Charset::new(tokens),
|
||||||
|
word,
|
||||||
|
resize,
|
||||||
|
channel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从外部外部 JSON 文件动态加载字符集
|
||||||
|
pub fn from_json_file<P: AsRef<Path>>(path: P) -> 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
|
use crate::charset::CharsetRestrict;
|
||||||
|
use crate::model_metadata::ModelMetadata;
|
||||||
use crate::models::base::ModelArgs;
|
use crate::models::base::ModelArgs;
|
||||||
|
use crate::models::loader::{ModelLoader, ModelSession, ModelType};
|
||||||
use crate::utils::image_io::png_rgba_white_preprocess;
|
use crate::utils::image_io::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 crate::models::loader::{ModelLoader, ModelSession, ModelType};
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use image::DynamicImage;
|
use image::DynamicImage;
|
||||||
|
use std::collections::HashSet;
|
||||||
use tract_onnx::prelude::tract_ndarray::s;
|
use tract_onnx::prelude::tract_ndarray::s;
|
||||||
use tract_onnx::prelude::{
|
use tract_onnx::prelude::{
|
||||||
DatumType, Graph, IntoTensor, RunnableModel, Tensor, TypedFact, TypedOp, tract_ndarray, tvec,
|
DatumType, Graph, IntoTensor, RunnableModel, Tensor, TypedFact, TypedOp, tract_ndarray, tvec,
|
||||||
@@ -12,91 +15,9 @@ use tract_onnx::prelude::{
|
|||||||
// 颜色过滤的自定义范围:(低值RGB, 高值RGB)
|
// 颜色过滤的自定义范围:(低值RGB, 高值RGB)
|
||||||
pub type ColorRange = ((u8, u8, u8), (u8, u8, u8));
|
pub type ColorRange = ((u8, u8, u8), (u8, u8, u8));
|
||||||
|
|
||||||
// 字符集范围类型
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum CharsetRange {
|
|
||||||
All, // 所有字符
|
|
||||||
Digit, // 数字
|
|
||||||
Letter, // 字母
|
|
||||||
Alphanumeric, // 字母数字
|
|
||||||
Single(String), // 单字符串
|
|
||||||
Multiple(Vec<String>), // 多个字符串
|
|
||||||
Range(char, char), // 字符范围
|
|
||||||
Custom(Vec<char>), // 自定义字符列表
|
|
||||||
}
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct PredictArgs {
|
|
||||||
/// 是否修复PNG格式问题
|
|
||||||
pub png_fix: bool,
|
|
||||||
/// 是否返回概率信息
|
|
||||||
pub probability: bool,
|
|
||||||
/// 颜色过滤:保留的颜色列表
|
|
||||||
pub color_filter_colors: Option<Vec<String>>,
|
|
||||||
/// 颜色过滤:自定义RGB范围
|
|
||||||
pub color_filter_custom_ranges: Option<Vec<ColorRange>>,
|
|
||||||
/// 字符集范围
|
|
||||||
pub charset_range: Option<CharsetRange>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for PredictArgs {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
png_fix: false,
|
|
||||||
probability: false,
|
|
||||||
color_filter_colors: None,
|
|
||||||
color_filter_custom_ranges: None,
|
|
||||||
charset_range: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PredictArgs {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Builder 模式方法
|
|
||||||
pub fn png_fix(mut self, enabled: bool) -> Self {
|
|
||||||
self.png_fix = enabled;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn probability(mut self, enabled: bool) -> Self {
|
|
||||||
self.probability = enabled;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn color_filter_colors(mut self, colors: Vec<String>) -> Self {
|
|
||||||
self.color_filter_colors = Some(colors);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn color_filter_custom_ranges(mut self, ranges: Vec<ColorRange>) -> Self {
|
|
||||||
self.color_filter_custom_ranges = Some(ranges);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn charset_range(mut self, range: CharsetRange) -> Self {
|
|
||||||
self.charset_range = Some(range);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
// 便捷构造方法
|
|
||||||
pub fn quick() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_probability() -> Self {
|
|
||||||
Self::default().probability(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_png_fix() -> Self {
|
|
||||||
Self::default().png_fix(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub struct Ocr {
|
pub struct Ocr {
|
||||||
session: RunnableModel<TypedFact, Box<dyn TypedOp>, Graph<TypedFact, Box<dyn TypedOp>>>,
|
pub session: RunnableModel<TypedFact, Box<dyn TypedOp>, Graph<TypedFact, Box<dyn TypedOp>>>,
|
||||||
charset: Vec<String>,
|
pub model_metadata: ModelMetadata,
|
||||||
}
|
}
|
||||||
impl ModelSession for Ocr {
|
impl ModelSession for Ocr {
|
||||||
fn get_model_type(&self) -> ModelType {
|
fn get_model_type(&self) -> ModelType {
|
||||||
@@ -107,21 +28,77 @@ impl ModelSession for Ocr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl Ocr {
|
impl Ocr {
|
||||||
pub fn new(model_path: String, charset: Vec<String>) -> Result<Self, anyhow::Error> {
|
pub fn new(model_path: String, model_metadata: ModelMetadata) -> Result<Self, anyhow::Error> {
|
||||||
let session = ModelLoader::load_model(&model_path)?.session;
|
let session = ModelLoader::load_model(&model_path)?.session;
|
||||||
Ok(Self { session, charset })
|
Ok(Self {
|
||||||
|
session,
|
||||||
|
model_metadata,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
pub fn predict(&self, image: &DynamicImage, png_fix: bool) -> Result<String, anyhow::Error> {
|
pub fn predict<'a>(&'a self, image: &'a DynamicImage) -> OcrBuilder<'a> {
|
||||||
let tensor = self.preprocess_image(image, png_fix)?;
|
OcrBuilder::new(self, image)
|
||||||
//
|
|
||||||
// let result = self.session.run(tvec!(tensor.into()))?;
|
|
||||||
// // 3. 解析结果
|
|
||||||
// // let output = result[0].to_array_view::<i64>()?;
|
|
||||||
let output = self.inference(tensor)?;
|
|
||||||
let output2 = self.process_text_output(&output)?;
|
|
||||||
Ok(self.ctc_decode_indices(&output2))
|
|
||||||
// Ok("ocr result".to_string())
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct OcrBuilder<'a> {
|
||||||
|
ocr: &'a Ocr,
|
||||||
|
image: &'a DynamicImage,
|
||||||
|
/// 是否修复PNG格式问题
|
||||||
|
png_fix: bool,
|
||||||
|
/// 是否返回概率信息
|
||||||
|
#[allow(dead_code)]
|
||||||
|
probability: bool,
|
||||||
|
/// 颜色过滤:保留的颜色列表
|
||||||
|
color_filter_colors: Option<Vec<ColorRange>>,
|
||||||
|
/// 颜色过滤:自定义RGB范围
|
||||||
|
color_filter_custom_ranges: Option<Vec<ColorRange>>,
|
||||||
|
/// 字符集范围
|
||||||
|
charset_restrict: Option<CharsetRestrict>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> OcrBuilder<'a> {
|
||||||
|
// 初始化任务,设置默认参数
|
||||||
|
pub fn new(ocr: &'a Ocr, image: &'a DynamicImage) -> Self {
|
||||||
|
Self {
|
||||||
|
ocr,
|
||||||
|
image,
|
||||||
|
png_fix: false, // 默认值
|
||||||
|
probability: false,
|
||||||
|
color_filter_colors: None,
|
||||||
|
color_filter_custom_ranges: None,
|
||||||
|
charset_restrict: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn png_fix(mut self, value: bool) -> Self {
|
||||||
|
self.png_fix = value;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn color_filter_colors(mut self, value: Vec<ColorRange>) -> Self {
|
||||||
|
self.color_filter_colors = Some(value);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn color_filter_custom_ranges(mut self, value: Vec<ColorRange>) -> Self {
|
||||||
|
self.color_filter_custom_ranges = Some(value);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn charset_restrict(mut self, restrict: CharsetRestrict) -> Self {
|
||||||
|
self.charset_restrict = Some(restrict);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(&self) -> anyhow::Result<String> {
|
||||||
|
let tensor = self.preprocess_image(self.image, self.png_fix)?;
|
||||||
|
|
||||||
|
let raw_tensor = self.inference(tensor)?;
|
||||||
|
let raw_indices = self.extract_indices_from_tensor(&raw_tensor)?;
|
||||||
|
|
||||||
|
// 步骤 2: 将索引切片 `&[i64]` 传给解码器进行 CTC 去重和字符映射
|
||||||
|
let final_text = self.ctc_decode_to_string(&raw_indices);
|
||||||
|
|
||||||
|
println!("最终识别出的验证码是: {}", final_text);
|
||||||
|
Ok(final_text)
|
||||||
|
}
|
||||||
|
|
||||||
/// 对应 Python 的 _preprocess_image
|
/// 对应 Python 的 _preprocess_image
|
||||||
/// 负责:透明背景修复 -> 灰度化 -> 按比例 Resize -> 归一化 -> 4维张量转换
|
/// 负责:透明背景修复 -> 灰度化 -> 按比例 Resize -> 归一化 -> 4维张量转换
|
||||||
fn preprocess_image(&self, img: &DynamicImage, png_fix: bool) -> anyhow::Result<Tensor> {
|
fn preprocess_image(&self, img: &DynamicImage, png_fix: bool) -> anyhow::Result<Tensor> {
|
||||||
@@ -156,14 +133,16 @@ impl Ocr {
|
|||||||
// tract 的 run 会返回一个 Vec<TValue>,我们通常只需要第一个输出
|
// tract 的 run 会返回一个 Vec<TValue>,我们通常只需要第一个输出
|
||||||
// let result = self.session.run(tvec!(tensor.into()))?;
|
// let result = self.session.run(tvec!(tensor.into()))?;
|
||||||
let mut result = self
|
let mut result = self
|
||||||
|
.ocr
|
||||||
.session
|
.session
|
||||||
.run(tvec!(tensor.into()))
|
.run(tvec!(tensor.into()))
|
||||||
.context("执行模型推理失败")?;
|
.context("执行模型推理失败")?;
|
||||||
println!("模型输出原始数据: {:?}", result);
|
println!("模型输出原始数据: {:?}", result);
|
||||||
Ok(result.remove(0).into_tensor())
|
Ok(result.remove(0).into_tensor())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 核心解析逻辑:将模型输出的各种维度/类型的 Tensor 转为字符索引序列
|
/// 核心解析逻辑:将模型输出的各种维度/类型的 Tensor 转为字符索引序列
|
||||||
fn process_text_output(&self, raw_tensor: &Tensor) -> anyhow::Result<Vec<i64>> {
|
fn extract_indices_from_tensor(&self, raw_tensor: &Tensor) -> anyhow::Result<Vec<i64>> {
|
||||||
let shape = raw_tensor.shape();
|
let shape = raw_tensor.shape();
|
||||||
println!("模型输出shape数据: {:?}", shape);
|
println!("模型输出shape数据: {:?}", shape);
|
||||||
let datum_type = raw_tensor.datum_type();
|
let datum_type = raw_tensor.datum_type();
|
||||||
@@ -198,6 +177,9 @@ impl Ocr {
|
|||||||
// 形状: [Steps, Classes] -> 已经剥离了 Batch 维度
|
// 形状: [Steps, Classes] -> 已经剥离了 Batch 维度
|
||||||
(shape[0], shape[1], view.into_dyn())
|
(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)),
|
_ => return Err(anyhow::anyhow!("不支持的输出维度: {:?}", shape)),
|
||||||
};
|
};
|
||||||
let array_2d = data_view.to_shape((steps, classes))?;
|
let array_2d = data_view.to_shape((steps, classes))?;
|
||||||
@@ -223,29 +205,107 @@ impl Ocr {
|
|||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn ctc_decode_indices(&self, predicted_indices: &[i64]) -> String {
|
/// 获取有效字符索引列表 (用于外部验证或过滤)
|
||||||
|
pub fn get_valid_indices(&self) -> HashSet<usize> {
|
||||||
|
let (_, valid_indices) = self.valid_indices();
|
||||||
|
valid_indices
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_indices(&self) -> (bool, HashSet<usize>) {
|
||||||
|
let charset = &self.ocr.model_metadata.charset;
|
||||||
|
let tokens = &charset.tokens;
|
||||||
|
/// 根据传入的 CharsetRestrict 枚举策略,动态更新有效索引
|
||||||
|
// 1. 🧠 零克隆防御战:在局部判断并动态构建专属的白名单判定表
|
||||||
|
let mut valid_indices = HashSet::new();
|
||||||
|
let mut has_any_match = false;
|
||||||
|
if let Some(ref policy) = self.charset_restrict {
|
||||||
|
// 🧠 性能精算:根据限制策略的类型,智能分配合适的初始容量,1个字节都不浪费!
|
||||||
|
let estimated_capacity = match policy {
|
||||||
|
CharsetRestrict::Digit => 16,
|
||||||
|
CharsetRestrict::Lowercase | CharsetRestrict::Uppercase => 32,
|
||||||
|
CharsetRestrict::CustomList(vec) => vec.len() + 1, // 动态匹配列表大小
|
||||||
|
_ => 128, // 组合子(Or)等复杂情况,给个 128 黄金保底值
|
||||||
|
};
|
||||||
|
// 🚀 精准开辟内存,完美避开 8120 个槽位的巨大空置浪费
|
||||||
|
valid_indices = HashSet::with_capacity(estimated_capacity);
|
||||||
|
|
||||||
|
for (idx, token) in tokens.iter().enumerate() {
|
||||||
|
let token_str = token.as_ref();
|
||||||
|
// CTC Blank 空字符串无条件放行,其余交给超高性能的 matches
|
||||||
|
if token_str.is_empty() {
|
||||||
|
valid_indices.insert(idx);
|
||||||
|
} else if policy.matches(token_str) {
|
||||||
|
valid_indices.insert(idx);
|
||||||
|
has_any_match = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 终极防御:如果除了 Blank 之外,没有任何一个字符被匹配到
|
||||||
|
if !has_any_match {
|
||||||
|
valid_indices = (0..tokens.len()).collect();
|
||||||
|
println!("警告:当前限制策略与模型字符集完全没有交集!已自动恢复全量识别。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(has_any_match, valid_indices)
|
||||||
|
}
|
||||||
|
/// 🌟 【按需延迟打印】:当用户真的需要“知道当前有哪些限制字符”时,一秒反查并打印
|
||||||
|
/// 这里的 &str 完美借用了自 tokens,依然是彻底的零拷贝!
|
||||||
|
pub fn get_valid_tokens(&self) -> Vec<&str> {
|
||||||
|
let charset = &self.ocr.model_metadata.charset;
|
||||||
|
let tokens = &charset.tokens;
|
||||||
|
self.get_valid_indices()
|
||||||
|
.iter()
|
||||||
|
.map(|&idx| tokens[idx].as_ref())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
pub fn valid_size(&self) -> usize {
|
||||||
|
self.get_valid_indices().len()
|
||||||
|
}
|
||||||
|
fn ctc_decode_to_string(&self, predicted_indices: &[i64]) -> String {
|
||||||
println!("indices模型输出原始数据: {:?}", predicted_indices);
|
println!("indices模型输出原始数据: {:?}", predicted_indices);
|
||||||
|
let charset = &self.ocr.model_metadata.charset;
|
||||||
|
let tokens = &charset.tokens;
|
||||||
|
// let valid_indices = &charset.valid_indices;
|
||||||
|
|
||||||
|
let (has_any_match, valid_indices) = self.valid_indices();
|
||||||
|
|
||||||
// 对应 _ctc_decode_indices 的逻辑:去重、去 blank (0)
|
// 对应 _ctc_decode_indices 的逻辑:去重、去 blank (0)
|
||||||
let mut res = String::new();
|
let mut res = String::new();
|
||||||
let mut prev_idx: i64 = -1;
|
let mut prev_idx: i64 = -1;
|
||||||
|
|
||||||
for &idx in predicted_indices {
|
for &idx in predicted_indices {
|
||||||
// 1. 跳过连续重复的索引
|
// 1. CTC 去重:如果是连续重复的,直接跳过
|
||||||
// 2. 跳过 blank 字符 (假设索引 0 是 blank)
|
if idx == prev_idx {
|
||||||
if idx != prev_idx && idx != 0 {
|
continue;
|
||||||
if let Ok(u_idx) = usize::try_from(idx) {
|
}
|
||||||
if let Some(char_str) = self.charset.get(u_idx) {
|
// 【关键核心】只要不是连续重复,立刻更新 prev_idx 状态,绝对不能被后续的过滤短路!
|
||||||
|
prev_idx = idx;
|
||||||
|
|
||||||
|
// 2. CTC 过滤 Blank (0)
|
||||||
|
if idx == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 3. 类型安全转换
|
||||||
|
let u_idx = match usize::try_from(idx) {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. 终极性能:既然你的有效索引库必然有全量或部分数据,这里直接进行 O(1) 包含校验
|
||||||
|
// 注意:去掉原本错误的 `!`
|
||||||
|
if has_any_match {
|
||||||
|
if !valid_indices.contains(&u_idx) {
|
||||||
|
continue; // 不在有效字符集内,安全跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 字符映射
|
||||||
|
if let Some(char_str) = tokens.get(u_idx) {
|
||||||
res.push_str(char_str);
|
res.push_str(char_str);
|
||||||
} else {
|
} else {
|
||||||
// 保护逻辑:如果模型预测的索引超出了字符集范围
|
|
||||||
eprintln!("警告: 预测索引 {} 超出字符集范围", u_idx);
|
eprintln!("警告: 预测索引 {} 超出字符集范围", u_idx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
prev_idx = idx;
|
|
||||||
}
|
|
||||||
println!("最终识别出的验证码是: {}", res);
|
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ fn test_full_classification() {
|
|||||||
let ocr = DdddOcrBuilder::new().build().expect("模型加载失败");
|
let ocr = DdddOcrBuilder::new().build().expect("模型加载失败");
|
||||||
|
|
||||||
// 2. 加载测试图片
|
// 2. 加载测试图片
|
||||||
let img = image::open("samples/code2.png").expect("测试图片不存在");
|
let img = image::open("samples/code3.png").expect("测试图片不存在");
|
||||||
|
|
||||||
// 3. 执行识别
|
// 3. 执行识别
|
||||||
let result = ocr.classification(&img).expect("识别过程出错");
|
let result = ocr.classification(&img).expect("识别过程出错");
|
||||||
|
|||||||
Reference in New Issue
Block a user