Compare commits
5 Commits
feature-v0
...
feature-v0
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c96fbedbf | |||
| 15ce068025 | |||
| cb786a7a1a | |||
| 0923d92150 | |||
| 0df9022411 |
@@ -10,3 +10,5 @@ anyhow = "1.0.102"
|
||||
image = "0.25.10"
|
||||
base64 = "0.22.1"
|
||||
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> {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
73
src/lib.rs
73
src/lib.rs
@@ -1,14 +1,8 @@
|
||||
pub mod base;
|
||||
mod charset;
|
||||
mod det_model;
|
||||
mod image_io;
|
||||
mod image_processor;
|
||||
mod model;
|
||||
mod model_loader;
|
||||
mod ocr_model;
|
||||
mod utils;
|
||||
pub mod slide_model;
|
||||
mod cv2;
|
||||
|
||||
pub mod models;
|
||||
pub mod utils;
|
||||
mod model_metadata;
|
||||
|
||||
use anyhow::Result;
|
||||
use image::DynamicImage;
|
||||
@@ -16,9 +10,12 @@ use std::fmt::{Display, Formatter};
|
||||
|
||||
// 关键点:直接使用 tract 重导出的 ndarray
|
||||
use crate::charset::get_default_charset;
|
||||
use crate::det_model::Det;
|
||||
use crate::model_loader::ModelSession;
|
||||
use crate::ocr_model::Ocr;
|
||||
use crate::models::ocr::ColorRange;
|
||||
use models::det::Det;
|
||||
use models::loader::ModelSession;
|
||||
use models::ocr::Ocr;
|
||||
use crate::model_metadata::ModelMetadata;
|
||||
|
||||
pub enum ModelSpec {
|
||||
/// 默认 OCR (使用内置路径)
|
||||
OcrModel,
|
||||
@@ -26,7 +23,7 @@ pub enum ModelSpec {
|
||||
/// 自定义 OCR (路径由用户提供)
|
||||
CustomOcrModel {
|
||||
path: String,
|
||||
charset: Vec<String>,
|
||||
model_metadata: ModelMetadata,
|
||||
},
|
||||
}
|
||||
impl ModelSpec {
|
||||
@@ -65,18 +62,21 @@ impl DdddOcrBuilder {
|
||||
}
|
||||
|
||||
/// 设置自定义 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
|
||||
self.mode = ModelSpec::CustomOcrModel { path, charset };
|
||||
self.mode = ModelSpec::CustomOcrModel { path, model_metadata };
|
||||
self
|
||||
}
|
||||
|
||||
/// 核心初始化逻辑
|
||||
pub fn build(self) -> Result<DdddOcr> {
|
||||
let runtime = match self.mode {
|
||||
ModelSpec::OcrModel => Runtime::Ocr(Ocr::new(ModelSpec::DEFAULT_OCR_PATH.into(), get_default_charset())?),
|
||||
ModelSpec::OcrModel => Runtime::Ocr(Ocr::new(
|
||||
ModelSpec::DEFAULT_OCR_PATH.into(),
|
||||
ModelMetadata::from_builtin_beta(),
|
||||
)?),
|
||||
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 })
|
||||
@@ -96,7 +96,7 @@ impl Display for DdddOcr {
|
||||
impl DdddOcr {
|
||||
pub fn classification(&self, img: &DynamicImage) -> Result<String> {
|
||||
match &self.runtime {
|
||||
Runtime::Ocr(s) => s.predict(img, false),
|
||||
Runtime::Ocr(s) => s.predict(img).run(),
|
||||
Runtime::Det(_) => Err(anyhow::anyhow!("当前模型是检测模型,无法执行 OCR")),
|
||||
}
|
||||
}
|
||||
@@ -108,9 +108,42 @@ 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn test_ctc_decode_indices() {
|
||||
// 模拟一个 DdddOcr 实例(如果 decode 不依赖 session,可以设为相关函数)
|
||||
|
||||
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,4 +1,4 @@
|
||||
use crate::model_loader::{ModelLoader, ModelSession, ModelType};
|
||||
use crate::models::loader::{ModelLoader, ModelSession, ModelType};
|
||||
use anyhow::{Context, Result};
|
||||
use image::{DynamicImage, GenericImageView, imageops::FilterType};
|
||||
use tract_onnx::prelude::tract_ndarray::{Array2, Array3, Array4, Axis, prelude::*, s};
|
||||
@@ -219,8 +219,8 @@ impl Det {
|
||||
}
|
||||
/// 6. get_bbox (完全解耦 OpenCV)
|
||||
pub fn get_bbox(&self, image_bytes: &[u8]) -> Result<Vec<Vec<i32>>> {
|
||||
// 使用 image crate 解码
|
||||
let dynamic_img = image::load_from_memory(image_bytes).context("Failed to decode image")?;
|
||||
// 使用 utils crate 解码
|
||||
let dynamic_img = image::load_from_memory(image_bytes).context("Failed to decode utils")?;
|
||||
let (orig_w, orig_h) = dynamic_img.dimensions();
|
||||
|
||||
let (input_tensor, ratio) = self.preproc(&dynamic_img, (416, 416))?;
|
||||
@@ -3,8 +3,8 @@ use image::DynamicImage;
|
||||
use tract_onnx::onnx;
|
||||
use tract_onnx::prelude::*;
|
||||
// 关键点:直接使用 tract 重导出的 ndarray
|
||||
use crate::image_io::png_rgba_white_preprocess;
|
||||
use crate::image_processor::{convert_to_grayscale, resize_image};
|
||||
use crate::utils::image_io::png_rgba_white_preprocess;
|
||||
use crate::utils::image_processor::{convert_to_grayscale, resize_image};
|
||||
use std::collections::HashMap;
|
||||
use tract_onnx::prelude::tract_ndarray::s;
|
||||
|
||||
5
src/models/mod.rs
Normal file
5
src/models/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod base;
|
||||
pub mod loader;
|
||||
pub mod ocr;
|
||||
pub mod det;
|
||||
pub mod slide;
|
||||
311
src/models/ocr.rs
Normal file
311
src/models/ocr.rs
Normal file
@@ -0,0 +1,311 @@
|
||||
use crate::charset::CharsetRestrict;
|
||||
use crate::model_metadata::ModelMetadata;
|
||||
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_processor::{convert_to_grayscale, resize_image};
|
||||
use anyhow::Context;
|
||||
use image::DynamicImage;
|
||||
use std::collections::HashSet;
|
||||
use tract_onnx::prelude::tract_ndarray::s;
|
||||
use tract_onnx::prelude::{
|
||||
DatumType, Graph, IntoTensor, RunnableModel, Tensor, TypedFact, TypedOp, tract_ndarray, tvec,
|
||||
};
|
||||
|
||||
// 颜色过滤的自定义范围:(低值RGB, 高值RGB)
|
||||
pub type ColorRange = ((u8, u8, u8), (u8, u8, u8));
|
||||
|
||||
pub struct Ocr {
|
||||
pub session: RunnableModel<TypedFact, Box<dyn TypedOp>, Graph<TypedFact, Box<dyn TypedOp>>>,
|
||||
pub model_metadata: ModelMetadata,
|
||||
}
|
||||
impl ModelSession for Ocr {
|
||||
fn get_model_type(&self) -> ModelType {
|
||||
todo!()
|
||||
}
|
||||
fn desc(&self) -> String {
|
||||
"Ocr Model 加载成功".to_string()
|
||||
}
|
||||
}
|
||||
impl Ocr {
|
||||
pub fn new(model_path: String, model_metadata: ModelMetadata) -> Result<Self, anyhow::Error> {
|
||||
let session = ModelLoader::load_model(&model_path)?.session;
|
||||
Ok(Self {
|
||||
session,
|
||||
model_metadata,
|
||||
})
|
||||
}
|
||||
pub fn predict<'a>(&'a self, image: &'a DynamicImage) -> OcrBuilder<'a> {
|
||||
OcrBuilder::new(self, image)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
/// 负责:透明背景修复 -> 灰度化 -> 按比例 Resize -> 归一化 -> 4维张量转换
|
||||
fn preprocess_image(&self, img: &DynamicImage, png_fix: bool) -> anyhow::Result<Tensor> {
|
||||
// A. 修复 PNG 透明背景 (内部逻辑你之前已实现)
|
||||
let _ = if png_fix && img.color().has_alpha() {
|
||||
png_rgba_white_preprocess(img)
|
||||
} else {
|
||||
img.clone()
|
||||
};
|
||||
|
||||
let h = 64u32;
|
||||
let w = (img.width() as f32 * (h as f32 / img.height() as f32)) as u32;
|
||||
let gray_img = convert_to_grayscale(img);
|
||||
let resized = resize_image(&gray_img, w, h);
|
||||
// resized.save("debug_preprocessed.png").unwrap();
|
||||
// 1. 预处理:转灰度 -> Resize -> 归一化
|
||||
// let resized = img.resize_exact(w, h, FilterType::Lanczos3).to_luma8();
|
||||
|
||||
// 使用 tract_ndarray 构造,避免版本冲突
|
||||
let array =
|
||||
tract_ndarray::Array4::from_shape_fn((1, 1, h as usize, w as usize), |(_, _, y, x)| {
|
||||
let pixel = resized.get_pixel(x as u32, y as u32)[0] as f32;
|
||||
(pixel / 255.0 - 0.5) / 0.5
|
||||
});
|
||||
|
||||
let tensor = Tensor::from(array);
|
||||
|
||||
Ok(tensor)
|
||||
}
|
||||
/// 对应 Python 的 _inference
|
||||
fn inference(&self, tensor: Tensor) -> anyhow::Result<Tensor> {
|
||||
// tract 的 run 会返回一个 Vec<TValue>,我们通常只需要第一个输出
|
||||
// let result = self.session.run(tvec!(tensor.into()))?;
|
||||
let mut result = self
|
||||
.ocr
|
||||
.session
|
||||
.run(tvec!(tensor.into()))
|
||||
.context("执行模型推理失败")?;
|
||||
println!("模型输出原始数据: {:?}", result);
|
||||
Ok(result.remove(0).into_tensor())
|
||||
}
|
||||
|
||||
/// 核心解析逻辑:将模型输出的各种维度/类型的 Tensor 转为字符索引序列
|
||||
fn extract_indices_from_tensor(&self, raw_tensor: &Tensor) -> anyhow::Result<Vec<i64>> {
|
||||
let shape = raw_tensor.shape();
|
||||
println!("模型输出shape数据: {:?}", shape);
|
||||
let datum_type = raw_tensor.datum_type();
|
||||
println!("模型输出datum_type数据: {:?}", datum_type);
|
||||
|
||||
match raw_tensor.datum_type() {
|
||||
// 情况 1: huashi666 式模型,直接输出 i64 索引 (通常是模型内部做好了 Argmax)
|
||||
DatumType::I64 => {
|
||||
let view = raw_tensor.to_array_view::<i64>()?;
|
||||
Ok(view.iter().cloned().collect())
|
||||
}
|
||||
|
||||
// 情况 2: sml2h3 原版模型,输出 F32 概率矩阵
|
||||
DatumType::F32 => {
|
||||
let view = raw_tensor.to_array_view::<f32>()?;
|
||||
let (steps, classes, data_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())
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
// 形状: [Steps, Classes] -> 已经剥离了 Batch 维度
|
||||
(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 array_2d = data_view.to_shape((steps, classes))?;
|
||||
//
|
||||
// 对每一行执行 Argmax (寻找概率最大的字符索引)
|
||||
let indices = array_2d
|
||||
.outer_iter()
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.enumerate()
|
||||
.max_by(|(_, a), (_, b)| {
|
||||
a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.map(|(idx, _)| idx as i64)
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.collect();
|
||||
Ok(indices)
|
||||
}
|
||||
_ => Err(anyhow::anyhow!(
|
||||
"不支持的模型输出数据类型: {:?}",
|
||||
raw_tensor.datum_type()
|
||||
)),
|
||||
}
|
||||
}
|
||||
/// 获取有效字符索引列表 (用于外部验证或过滤)
|
||||
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);
|
||||
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)
|
||||
let mut res = String::new();
|
||||
let mut prev_idx: i64 = -1;
|
||||
|
||||
for &idx in predicted_indices {
|
||||
// 1. CTC 去重:如果是连续重复的,直接跳过
|
||||
if idx == prev_idx {
|
||||
continue;
|
||||
}
|
||||
// 【关键核心】只要不是连续重复,立刻更新 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);
|
||||
} else {
|
||||
eprintln!("警告: 预测索引 {} 超出字符集范围", u_idx);
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
use crate::cv2::{min_max_loc, rgb_to_gray, ndarray_to_luma8, abs_diff};
|
||||
use crate::image_io::image_to_ndarray;
|
||||
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 anyhow::{Context, Result, anyhow};
|
||||
use image::{DynamicImage, GenericImageView};
|
||||
use image::{ImageBuffer, Luma};
|
||||
use imageproc::contrast::{ThresholdType, threshold};
|
||||
use imageproc::distance_transform::Norm;
|
||||
use imageproc::edges::canny;
|
||||
use imageproc::morphology::{close, open};
|
||||
use imageproc::region_labelling::{Connectivity, connected_components};
|
||||
use imageproc::template_matching::{MatchTemplateMethod, match_template};
|
||||
use std::cmp::{max, min};
|
||||
use imageproc::contrast::{threshold, ThresholdType};
|
||||
use tract_onnx::prelude::tract_ndarray::{Array2, Array3, ArrayView2, ArrayView3, Axis, s};
|
||||
|
||||
pub struct SlideResult {
|
||||
@@ -78,17 +79,12 @@ impl Slide {
|
||||
// 1. 计算差异数组 (复用 cv2::absdiff)
|
||||
let diff_array = abs_diff(&target, &background);
|
||||
|
||||
// 2. 转换为灰度数组 (复用你的 cv2::rgb_to_gray)
|
||||
// 2. 转换为灰度数组 (复用你的 cv2.cvtColor)
|
||||
let gray_array = rgb_to_gray(diff_array.view());
|
||||
// 3. 转为 ImageBuffer 以使用 imageproc 的高级功能
|
||||
let gray_buffer = ndarray_to_luma8(gray_array.view());
|
||||
|
||||
// 2. 二值化 (对应 cv2.threshold(..., 30, 255, cv2.THRESH_BINARY))
|
||||
// let mut binary = ImageBuffer::new(w as u32, h as u32);
|
||||
// for (x, y, pixel) in diff_buffer.enumerate_pixels() {
|
||||
// let val = if pixel.0[0] > 30 { 255u8 } else { 0u8 };
|
||||
// binary.put_pixel(x, y, Luma([val]));
|
||||
// }
|
||||
let binary = threshold(&gray_buffer, 30, ThresholdType::Binary);
|
||||
// 3. 形态学操作去噪 (对应 cv2.morphologyEx)
|
||||
// 闭运算 (Close): 先膨胀后腐蚀,用于填补缺口内的细小黑色空洞
|
||||
@@ -98,58 +94,17 @@ impl Slide {
|
||||
let closed = close(&binary, norm, radius);
|
||||
let cleaned = open(&closed, norm, radius);
|
||||
|
||||
// 4. 寻找最大连通区域 (对应 findContours + max area)
|
||||
// connected_components 会给每个独立的白色区域打上不同的标签 (ID)
|
||||
let background_label = Luma([0u8]);
|
||||
let labelled = connected_components(&cleaned, Connectivity::Eight, background_label);
|
||||
|
||||
// 统计每个标签出现的频率(即面积)
|
||||
let mut max_label = 0;
|
||||
let mut max_area = 0;
|
||||
let mut areas = std::collections::HashMap::new();
|
||||
|
||||
for pixel in labelled.pixels() {
|
||||
let label = pixel.0[0];
|
||||
if label == 0 {
|
||||
continue;
|
||||
} // 跳过背景
|
||||
let count = areas.entry(label).or_insert(0);
|
||||
*count += 1;
|
||||
if *count > max_area {
|
||||
max_area = *count;
|
||||
max_label = label;
|
||||
}
|
||||
}
|
||||
|
||||
if max_label == 0 {
|
||||
return Ok(SlideResult {
|
||||
target: [0, 0],
|
||||
target_x: 0,
|
||||
target_y: 0,
|
||||
confidence: 0.0,
|
||||
});
|
||||
}
|
||||
|
||||
// // 统计每个标签出现的频率(即面积)
|
||||
// 4. 寻找最大连通区域 (对应 findContours + max area)
|
||||
if let Some(max_label) = cv_ops::find_contours_and_max(&labelled) {
|
||||
// 5. 计算最大区域的边界框 (对应 cv2.boundingRect)
|
||||
let mut min_x = w as u32;
|
||||
let mut max_x = 0;
|
||||
let mut min_y = h as u32;
|
||||
let mut max_y = 0;
|
||||
|
||||
for (x, y, pixel) in labelled.enumerate_pixels() {
|
||||
if pixel.0[0] == max_label {
|
||||
min_x = min(min_x, x);
|
||||
max_x = max(max_x, x);
|
||||
min_y = min(min_y, y);
|
||||
max_y = max(max_y, y);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 计算中心点
|
||||
let rect_w = max_x - min_x;
|
||||
let rect_h = max_y - min_y;
|
||||
let center_x = (min_x + rect_w / 2) as i32;
|
||||
let center_y = (min_y + rect_h / 2) as i32;
|
||||
let (x, y, w, h) = cv_ops::bounding_rect(&labelled, max_label);
|
||||
// 6. 计算中心点 (调用之前封装的 calculate_center)
|
||||
let (center_x, center_y) = cv_ops::calculate_center((x, y), w as usize, h as usize);
|
||||
|
||||
Ok(SlideResult {
|
||||
target: [center_x, center_y],
|
||||
@@ -157,6 +112,14 @@ impl Slide {
|
||||
target_y: center_y,
|
||||
confidence: 1.0, // Comparison 模式下通常认为找到即为 1.0
|
||||
})
|
||||
} else {
|
||||
Ok(SlideResult {
|
||||
target: [0, 0],
|
||||
target_x: 0,
|
||||
target_y: 0,
|
||||
confidence: 0.0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 对应 Python: _perform_slide_match
|
||||
@@ -210,8 +173,8 @@ impl Slide {
|
||||
|
||||
// 4. 计算中心点 (与 Python 逻辑完全一致)
|
||||
let (th, tw) = target.dim();
|
||||
let center_x = max_loc.0 as i32 + (tw as i32 / 2);
|
||||
let center_y = max_loc.1 as i32 + (th as i32 / 2);
|
||||
|
||||
let (center_x, center_y) = cv_ops::calculate_center(max_loc, tw as usize, th as usize);
|
||||
// println!("Rust Target Width (tw): {}", tw);
|
||||
// println!("Rust Best Max Loc X: {}", max_loc.0);
|
||||
// println!("Rust Final Center X: {}", center_x);
|
||||
@@ -256,8 +219,7 @@ impl Slide {
|
||||
// 5. 计算中心位置 (对齐 Python 逻辑)
|
||||
// target_w, target_h 来自输入数组的维度
|
||||
let (th, tw) = target.dim();
|
||||
let center_x = max_loc.0 as i32 + (tw as i32 / 2);
|
||||
let center_y = max_loc.1 as i32 + (th as i32 / 2);
|
||||
let (center_x, center_y) = cv_ops::calculate_center(max_loc, tw as usize, th as usize);
|
||||
|
||||
// 打印调试信息,方便与 Python 对比
|
||||
// println!("Edge Match: max_val: {}, max_loc: {:?}", max_val, max_loc);
|
||||
@@ -271,6 +233,4 @@ impl Slide {
|
||||
confidence: max_val as f64,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
251
src/ocr_model.rs
251
src/ocr_model.rs
@@ -1,251 +0,0 @@
|
||||
use crate::base::ModelArgs;
|
||||
use crate::image_io::png_rgba_white_preprocess;
|
||||
use crate::image_processor::{convert_to_grayscale, resize_image};
|
||||
use crate::model_loader::{ModelLoader, ModelSession, ModelType};
|
||||
use anyhow::Context;
|
||||
use image::DynamicImage;
|
||||
use tract_onnx::prelude::tract_ndarray::s;
|
||||
use tract_onnx::prelude::{
|
||||
DatumType, Graph, IntoTensor, RunnableModel, Tensor, TypedFact, TypedOp, tract_ndarray, tvec,
|
||||
};
|
||||
|
||||
// 颜色过滤的自定义范围:(低值RGB, 高值RGB)
|
||||
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 {
|
||||
session: RunnableModel<TypedFact, Box<dyn TypedOp>, Graph<TypedFact, Box<dyn TypedOp>>>,
|
||||
charset: Vec<String>,
|
||||
}
|
||||
impl ModelSession for Ocr {
|
||||
fn get_model_type(&self) -> ModelType {
|
||||
todo!()
|
||||
}
|
||||
fn desc(&self) -> String {
|
||||
"Ocr Model 加载成功".to_string()
|
||||
}
|
||||
}
|
||||
impl Ocr {
|
||||
pub fn new(model_path: String, charset: Vec<String>) -> Result<Self, anyhow::Error> {
|
||||
let session = ModelLoader::load_model(&model_path)?.session;
|
||||
Ok(Self { session, charset })
|
||||
}
|
||||
pub fn predict(&self, image: &DynamicImage, png_fix: bool) -> Result<String, anyhow::Error> {
|
||||
let tensor = self.preprocess_image(image, png_fix)?;
|
||||
//
|
||||
// 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())
|
||||
}
|
||||
/// 对应 Python 的 _preprocess_image
|
||||
/// 负责:透明背景修复 -> 灰度化 -> 按比例 Resize -> 归一化 -> 4维张量转换
|
||||
fn preprocess_image(&self, img: &DynamicImage, png_fix: bool) -> anyhow::Result<Tensor> {
|
||||
// A. 修复 PNG 透明背景 (内部逻辑你之前已实现)
|
||||
let _ = if png_fix && img.color().has_alpha() {
|
||||
png_rgba_white_preprocess(img)
|
||||
} else {
|
||||
img.clone()
|
||||
};
|
||||
|
||||
let h = 64u32;
|
||||
let w = (img.width() as f32 * (h as f32 / img.height() as f32)) as u32;
|
||||
let gray_img = convert_to_grayscale(img);
|
||||
let resized = resize_image(&gray_img, w, h);
|
||||
// resized.save("debug_preprocessed.png").unwrap();
|
||||
// 1. 预处理:转灰度 -> Resize -> 归一化
|
||||
// let resized = img.resize_exact(w, h, FilterType::Lanczos3).to_luma8();
|
||||
|
||||
// 使用 tract_ndarray 构造,避免版本冲突
|
||||
let array =
|
||||
tract_ndarray::Array4::from_shape_fn((1, 1, h as usize, w as usize), |(_, _, y, x)| {
|
||||
let pixel = resized.get_pixel(x as u32, y as u32)[0] as f32;
|
||||
(pixel / 255.0 - 0.5) / 0.5
|
||||
});
|
||||
|
||||
let tensor = Tensor::from(array);
|
||||
|
||||
Ok(tensor)
|
||||
}
|
||||
/// 对应 Python 的 _inference
|
||||
fn inference(&self, tensor: Tensor) -> anyhow::Result<Tensor> {
|
||||
// tract 的 run 会返回一个 Vec<TValue>,我们通常只需要第一个输出
|
||||
// let result = self.session.run(tvec!(tensor.into()))?;
|
||||
let mut result = self
|
||||
.session
|
||||
.run(tvec!(tensor.into()))
|
||||
.context("执行模型推理失败")?;
|
||||
println!("模型输出原始数据: {:?}", result);
|
||||
Ok(result.remove(0).into_tensor())
|
||||
}
|
||||
/// 核心解析逻辑:将模型输出的各种维度/类型的 Tensor 转为字符索引序列
|
||||
fn process_text_output(&self, raw_tensor: &Tensor) -> anyhow::Result<Vec<i64>> {
|
||||
let shape = raw_tensor.shape();
|
||||
println!("模型输出shape数据: {:?}", shape);
|
||||
let datum_type = raw_tensor.datum_type();
|
||||
println!("模型输出datum_type数据: {:?}", datum_type);
|
||||
|
||||
match raw_tensor.datum_type() {
|
||||
// 情况 1: huashi666 式模型,直接输出 i64 索引 (通常是模型内部做好了 Argmax)
|
||||
DatumType::I64 => {
|
||||
let view = raw_tensor.to_array_view::<i64>()?;
|
||||
Ok(view.iter().cloned().collect())
|
||||
}
|
||||
|
||||
// 情况 2: sml2h3 原版模型,输出 F32 概率矩阵
|
||||
DatumType::F32 => {
|
||||
let view = raw_tensor.to_array_view::<f32>()?;
|
||||
let (steps, classes, data_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())
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
// 形状: [Steps, Classes] -> 已经剥离了 Batch 维度
|
||||
(shape[0], shape[1], view.into_dyn())
|
||||
}
|
||||
_ => return Err(anyhow::anyhow!("不支持的输出维度: {:?}", shape)),
|
||||
};
|
||||
let array_2d = data_view.to_shape((steps, classes))?;
|
||||
//
|
||||
// 对每一行执行 Argmax (寻找概率最大的字符索引)
|
||||
let indices = array_2d
|
||||
.outer_iter()
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.enumerate()
|
||||
.max_by(|(_, a), (_, b)| {
|
||||
a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.map(|(idx, _)| idx as i64)
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.collect();
|
||||
Ok(indices)
|
||||
}
|
||||
_ => Err(anyhow::anyhow!(
|
||||
"不支持的模型输出数据类型: {:?}",
|
||||
raw_tensor.datum_type()
|
||||
)),
|
||||
}
|
||||
}
|
||||
fn ctc_decode_indices(&self, predicted_indices: &[i64]) -> String {
|
||||
println!("indices模型输出原始数据: {:?}", predicted_indices);
|
||||
|
||||
// 对应 _ctc_decode_indices 的逻辑:去重、去 blank (0)
|
||||
let mut res = String::new();
|
||||
let mut prev_idx: i64 = -1;
|
||||
|
||||
for &idx in predicted_indices {
|
||||
// 1. 跳过连续重复的索引
|
||||
// 2. 跳过 blank 字符 (假设索引 0 是 blank)
|
||||
if idx != prev_idx && idx != 0 {
|
||||
if let Ok(u_idx) = usize::try_from(idx) {
|
||||
if let Some(char_str) = self.charset.get(u_idx) {
|
||||
res.push_str(char_str);
|
||||
} else {
|
||||
// 保护逻辑:如果模型预测的索引超出了字符集范围
|
||||
eprintln!("警告: 预测索引 {} 超出字符集范围", u_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
prev_idx = idx;
|
||||
}
|
||||
println!("最终识别出的验证码是: {}", res);
|
||||
res
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::cmp::{max, min};
|
||||
use image::{ImageBuffer, Luma};
|
||||
use tract_onnx::prelude::tract_ndarray::{azip, Array2, Array3, ArrayView2, ArrayView3};
|
||||
|
||||
@@ -45,6 +46,55 @@ pub fn min_max_loc(result_map: &ImageBuffer<Luma<f32>, Vec<f32>>) -> (f32, (u32,
|
||||
}
|
||||
(max_val, max_loc)
|
||||
}
|
||||
|
||||
/// 1. 模拟 findContours 并获取最大面积区域的 Label
|
||||
/// 返回 Option<u32>,如果找不到任何区域则返回 None
|
||||
pub fn find_contours_and_max(labelled: &ImageBuffer<Luma<u32>, Vec<u32>>) -> Option<u32> {
|
||||
// 统计每个标签出现的频率(即面积)
|
||||
let mut max_label = 0;
|
||||
let mut max_area = 0;
|
||||
let mut areas = std::collections::HashMap::new();
|
||||
|
||||
for pixel in labelled.pixels() {
|
||||
let label = pixel.0[0];
|
||||
if label == 0 {
|
||||
continue;
|
||||
} // 跳过背景
|
||||
let count = areas.entry(label).or_insert(0);
|
||||
*count += 1;
|
||||
if *count > max_area {
|
||||
max_area = *count;
|
||||
max_label = label;
|
||||
}
|
||||
}
|
||||
if max_label == 0 { None } else { Some(max_label) }
|
||||
}
|
||||
pub fn bounding_rect(labelled: &ImageBuffer<Luma<u32>, Vec<u32>>,max_label: u32) -> (u32, u32, u32, u32) {
|
||||
// 5. 计算最大区域的边界框 (对应 cv2.boundingRect)
|
||||
let mut min_x = labelled.width();
|
||||
let mut max_x = 0;
|
||||
let mut min_y = labelled.height();
|
||||
let mut max_y = 0;
|
||||
|
||||
for (x, y, pixel) in labelled.enumerate_pixels() {
|
||||
if pixel.0[0] == max_label {
|
||||
min_x = min(min_x, x);
|
||||
max_x = max(max_x, x);
|
||||
min_y = min(min_y, y);
|
||||
max_y = max(max_y, y);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let w = max_x - min_x;
|
||||
let h = max_y - min_y;
|
||||
(min_x, min_y, w, h)
|
||||
}
|
||||
pub fn calculate_center(max_loc: (u32, u32), tw: usize, th: usize) -> (i32, i32) {
|
||||
let center_x = max_loc.0 as i32 + (tw as i32 / 2);
|
||||
let center_y = max_loc.1 as i32 + (th as i32 / 2);
|
||||
(center_x, center_y)
|
||||
}
|
||||
pub fn ndarray_to_luma8(array: ArrayView2<u8>) -> ImageBuffer<Luma<u8>, Vec<u8>> {
|
||||
let (height, width) = array.dim();
|
||||
let mut buffer = ImageBuffer::new(width as u32, height as u32);
|
||||
@@ -24,7 +24,7 @@ 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 image from bytes")
|
||||
image::load_from_memory(&bytes).context("Failed to load utils from bytes")
|
||||
}
|
||||
// 1. 已经是 DynamicImage
|
||||
ImageInput::DynamicImage(i) => Ok(i),
|
||||
@@ -34,11 +34,11 @@ pub fn load_image_from_input(img_input: ImageInput) -> Result<DynamicImage> {
|
||||
// 4. 处理 Base64 字符串
|
||||
ImageInput::Base64(b) => base64_to_image(&b),
|
||||
// 3. 处理文件路径 (Path)
|
||||
ImageInput::Path(p) => image::open(p).context("Failed to open image from 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:image/png;base64,"
|
||||
// 过滤掉可能存在的 base64 前缀,例如 "data:utils/png;base64,"
|
||||
let clean_b64 = if let Some(pos) = b64_str.find(",") {
|
||||
&b64_str[pos + 1..]
|
||||
} else {
|
||||
@@ -49,7 +49,7 @@ fn base64_to_image(b64_str: &str) -> Result<DynamicImage> {
|
||||
.decode(clean_b64.trim())
|
||||
.map_err(|e| anyhow!("Base64 decode error: {}", e))?;
|
||||
|
||||
image::load_from_memory(&bytes).context("Failed to load image from decoded base64")
|
||||
image::load_from_memory(&bytes).context("Failed to load utils from decoded base64")
|
||||
}
|
||||
|
||||
/// 读取图片文件并转换为 base64 编码字符串
|
||||
@@ -58,7 +58,7 @@ 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 image file: {:?}", image_path.as_ref()))?;
|
||||
.with_context(|| format!("Failed to read utils file: {:?}", image_path.as_ref()))?;
|
||||
|
||||
// 2. 进行 Base64 编码
|
||||
// 使用 STANDARD 引擎对齐 Python 的 base64.b64encode
|
||||
@@ -83,7 +83,7 @@ fn numpy_to_pil_image(array: ArrayViewD<u8>) -> Result<DynamicImage> {
|
||||
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 image from 2D array"))
|
||||
.ok_or_else(|| anyhow!("Failed to create Luma utils from 2D array"))
|
||||
}
|
||||
|
||||
// 对应 Python: len(array.shape) == 3 (H, W, C)
|
||||
@@ -131,7 +131,7 @@ pub fn png_rgba_white_preprocess(img: &DynamicImage) -> DynamicImage {
|
||||
let rgba_img = img.to_rgba8();
|
||||
|
||||
// 4. 遍历像素并手动进行 Alpha 混合
|
||||
// 对应 Python 的 image.paste(img, ..., mask=img)
|
||||
// 对应 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 是安全的
|
||||
@@ -162,8 +162,8 @@ pub fn png_rgba_white_preprocess(img: &DynamicImage) -> DynamicImage {
|
||||
DynamicImage::ImageRgb8(background)
|
||||
}
|
||||
pub fn image_to_numpy(image: &DynamicImage, mode: ColorMode) -> Result<Array3<u8>> {
|
||||
// 1. 模式转换 (对应 image.convert(target_mode)),此函数在时保留看后续优化是否需要替代image_to_ndarray
|
||||
// Rust image 库通过 to_rgb8, to_luma8 等方法实现转换
|
||||
// 1. 模式转换 (对应 utils.convert(target_mode)),此函数在时保留看后续优化是否需要替代image_to_ndarray
|
||||
// Rust utils 库通过 to_rgb8, to_luma8 等方法实现转换
|
||||
let (width, height) = image.dimensions();
|
||||
|
||||
let (channels, raw) = match mode {
|
||||
@@ -225,7 +225,7 @@ pub fn image_to_ndarray(img: &DynamicImage) -> Array3<u8> {
|
||||
|
||||
// 3. 构造数组 (通道数改为 3)
|
||||
Array3::from_shape_vec((height as usize, width as usize, 3), raw_data)
|
||||
.expect("Failed to construct ndarray from image") // 建议显式报错,而不是返回全黑图
|
||||
.expect("Failed to construct ndarray from utils") // 建议显式报错,而不是返回全黑图
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -4,7 +4,7 @@ use anyhow::Result;
|
||||
/// 对应 Python 的 convert_to_grayscale
|
||||
/// 将图像转换为灰度图 (L模式)
|
||||
pub fn convert_to_grayscale(image: &DynamicImage) -> GrayImage {
|
||||
// Rust image 库的 to_luma8 会根据标准的亮度公式进行转换
|
||||
// Rust utils 库的 to_luma8 会根据标准的亮度公式进行转换
|
||||
image.to_luma8()
|
||||
}
|
||||
|
||||
3
src/utils/mod.rs
Normal file
3
src/utils/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod image_io;
|
||||
pub mod image_processor;
|
||||
pub mod cv_ops;
|
||||
@@ -1,23 +1,24 @@
|
||||
use ddddocr_rs::models::slide::Slide;
|
||||
use ddddocr_rs::{DdddOcr, DdddOcrBuilder}; // 假设你的包名是这个
|
||||
use image::Rgb;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use image::Rgb;
|
||||
use ddddocr_rs::{DdddOcr, DdddOcrBuilder}; // 假设你的包名是这个
|
||||
use ddddocr_rs::slide_model::Slide;
|
||||
fn load_image<P: AsRef<Path>>(path: P) -> anyhow::Result<image::DynamicImage> {
|
||||
// 1. 先将泛型转为具体的 &Path 引用
|
||||
let path_ref = path.as_ref();
|
||||
|
||||
// 2. 调用 open 时传入引用(image::open 支持 AsRef<Path>)
|
||||
image::open(path_ref)
|
||||
.map_err(|e| {
|
||||
// 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( image_bytes: &[u8], bboxes: &Vec<Vec<i32>>, output_path: &str) -> anyhow::Result<()> {
|
||||
|
||||
|
||||
fn save_debug_image(
|
||||
image_bytes: &[u8],
|
||||
bboxes: &Vec<Vec<i32>>,
|
||||
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();
|
||||
@@ -35,16 +36,24 @@ fn save_debug_image( image_bytes: &[u8], bboxes: &Vec<Vec<i32>>, output_path: &s
|
||||
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); }
|
||||
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); }
|
||||
if x1 + 1 < width {
|
||||
img.put_pixel(x1 + 1, y, red);
|
||||
}
|
||||
if x2.saturating_sub(1) > 0 {
|
||||
img.put_pixel(x2 - 1, y, red);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +66,7 @@ fn test_full_classification() {
|
||||
let ocr = DdddOcrBuilder::new().build().expect("模型加载失败");
|
||||
|
||||
// 2. 加载测试图片
|
||||
let img = image::open("samples/code2.png").expect("测试图片不存在");
|
||||
let img = image::open("samples/code3.png").expect("测试图片不存在");
|
||||
|
||||
// 3. 执行识别
|
||||
let result = ocr.classification(&img).expect("识别过程出错");
|
||||
@@ -69,8 +78,8 @@ fn test_full_classification() {
|
||||
fn test_det_load() -> anyhow::Result<()> {
|
||||
let det = DdddOcrBuilder::new().det().build()?;
|
||||
let image_path = "samples/det1.png";
|
||||
let image_bytes = fs::read(image_path)
|
||||
.map_err(|e| anyhow::anyhow!("无法读取图片 {}: {}", image_path, e))?;
|
||||
let image_bytes =
|
||||
fs::read(image_path).map_err(|e| anyhow::anyhow!("无法读取图片 {}: {}", image_path, e))?;
|
||||
|
||||
println!("图片读取成功,字节大小: {}", image_bytes.len());
|
||||
let bboxes = det.detection(&image_bytes)?;
|
||||
@@ -81,28 +90,29 @@ fn test_det_load()->anyhow::Result<()>{
|
||||
} else {
|
||||
save_debug_image(&image_bytes, &bboxes, "samples/result.jpg")?;
|
||||
for (i, bbox) in bboxes.iter().enumerate() {
|
||||
println!("目标 [{}]: x1={}, y1={}, x2={}, y2={}", i, bbox[0], bbox[1], bbox[2], bbox[3]);
|
||||
println!(
|
||||
"目标 [{}]: x1={}, y1={}, x2={}, y2={}",
|
||||
i, bbox[0], bbox[1], bbox[2], bbox[3]
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn test_real_slide_match() {
|
||||
let engine = Slide::new();
|
||||
|
||||
// 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 存在");
|
||||
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, true)
|
||||
let result = engine
|
||||
.slide_match(&target_img, &bg_img, false)
|
||||
.expect("Slide match 执行失败");
|
||||
let duration = start.elapsed();
|
||||
|
||||
@@ -126,15 +136,14 @@ fn test_real_slide_comparison() {
|
||||
|
||||
// 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 存在");
|
||||
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)
|
||||
let result = engine
|
||||
.slide_comparison(&target_img, &bg_img)
|
||||
.expect("Slide match 执行失败");
|
||||
let duration = start.elapsed();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user