refactor(errors): 重构错误处理,支持强类型匹配并剥离 base64 依赖

- 新增 Other变体以及构造函数new
- 剥离图像预处理中的 Base64 相关错误至业务层处理
- 引入强类型 `LogitsDimensionMismatch` 替代不便匹配的字符串错误
- 优化 `normalize_ocr_logits` 的转换流程,兼顾零拷贝性能与精细化报错
- 优化 全库错误处理
This commit is contained in:
2026-07-17 20:08:32 +08:00
parent 4f6987f594
commit 913ff4d884
12 changed files with 397 additions and 200 deletions

View File

@@ -19,5 +19,5 @@ 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" ndarray = "0.16.1"
thiserror = "1.0" # 刚好可以开始接入你需要的标准库错误处理 thiserror = "1.0" # 刚好可以开始接入你需要的标准库错误处理

View File

@@ -18,59 +18,235 @@ pub(crate) const MODEL_DOWNLOAD_HELP: &str = "\
================================================================================"; ================================================================================";
use thiserror::Error; use thiserror::Error;
//
// #[derive(Error, Debug)]
// pub enum DdddError {
// // 【新增】专门处理文件读取、路径不存在等原生 I/O 错误
// #[error("系统网络或文件 I/O 异常: {0}")]
// Io(#[from] std::io::Error),
//
// #[error("图像预处理失败: {0}")]
// PreprocessError(#[from] ImagePreprocessReason),
//
// #[error("模型推理引擎内部发生异常: {0}")]
// EngineError(#[from] anyhow::Error),
//
// #[error("CTC 解码错误: {0}")]
// DecodeError(String),
//
// #[error("维度转换失败,预期维度 {expected},实际形状为 {actual:?}")]
// DimensionMismatch {
// expected: String,
// actual: Vec<usize>,
// },
//
// #[error("内存不连续,无法执行零拷贝操作")]
// NonContiguousMemory,
//
// #[error("未知的模型输出格式")]
// UnknownOutputFormat,
//
// #[error("解析节点 Fact 失败")]
// InternalError(String),
// }
//
// /// 专门服务于预处理的子错误枚举,保留全部底层上下文
// #[derive(Error, Debug)]
// pub enum ImagePreprocessReason {
// #[error("图片加载或文件 I/O 失败: {0}")]
// ImageIo(#[from] image::ImageError),
//
// #[error("图片转矩阵矩阵(ndarray)失败: {0}")]
// NdarrayError(#[from] ndarray::ShapeError),
//
// #[error("Base64 解码失败: {0}")]
// Base64(#[from] base64::DecodeError),
//
// #[error("Base64 头部格式不正确,缺少 ';base64,' 分隔符")]
// InvalidBase64Header,
//
// #[error("不支持的通道数: {0}")]
// UnsupportedChannels(usize),
//
// #[error("其他预处理错误: {0}")]
// Custom(String),
// }
/// 统一用我们自己的 DdddError 包装 Result
pub type Result<T> = std::result::Result<T, DdddError>;
// =====================================================================
// 1. 顶层全局 Error 分流器 (去 anyhow 化,完全基于标准库/自定义类型)
// =====================================================================
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum DdddError { pub enum DdddError {
// 【新增】专门处理文件读取、路径不存在等原生 I/O 错误 // /// 系统文件、网络等原生 I/O 异常 (高优先级自动转换)
#[error("系统网络或文件 I/O 异常: {0}")] // #[error("系统网络或文件 I/O 异常: {0}")]
Io(#[from] std::io::Error), // Io(#[from] std::io::Error),
/// 图像预处理阶段发生异常
#[error("图像预处理失败: {0}")] #[error("图像预处理失败: {0}")]
PreprocessError(#[from] ImagePreprocessReason), Preprocess(#[from] ImagePreprocessReason),
#[error("模型推理引擎内部发生异常: {0}")] /// 推理引擎与张量操作阶段发生异常
EngineError(#[from] anyhow::Error), #[error("推理与模型输入/输出张量异常: {0}")]
Inference(#[from] TensorErrorReason),
#[error("CTC 解码错误: {0}")] /// 算法后处理解码阶段发生异常
DecodeError(String), #[error("后处理解码错误: {0}")]
Decode(#[from] DecodeReason),
#[error("维度转换失败,预期维度 {expected},实际形状为 {actual:?}")] /// 框架内部不可恢复的逻辑断言错误(例如解析节点 Fact 失败)
DimensionMismatch { #[error("内部严重逻辑错误: {0}")]
Internal(String),
/// 【流派一核心】接替 anyhow::Error 的用户自定义扩展错误
/// 承载任何第三方扩展、解密、特定预处理插件在执行时产生的自定义错误
#[error("用户自定义扩展错误: {0}")]
Other(Box<dyn std::error::Error + Send + Sync>),
}
// =====================================================================
// 2. 子领域 A: 图像预处理错误类型
// =====================================================================
#[derive(Error, Debug)]
pub enum ImagePreprocessReason {
// #[error("图片加载或解码失败: {0}")]
// ImageIo(#[from] image::ImageError),
// image_io
#[error("图片转矩阵(ndarray)基础操作失败: {0}")]
Ndarray(#[from] ndarray::ShapeError),
// image_io
#[error("图像矩阵维度不合规!预期: {expected},实际图像形状: {actual:?}")]
InvalidImageDimensions {
expected: String, expected: String,
actual: Vec<usize>, actual: Vec<usize>,
}, },
// image_io
/// 从 ndarray 原始数据构建图像缓冲区时,缓冲区长度与分辨率/通道数不匹配
#[error("图像缓冲区长度不匹配!预期大小: {expected},实际大小: {actual} (分辨率: {width}x{height}, 通道数: {channels})")]
BufferLengthMismatch {
expected: usize,
actual: usize,
width: u32,
height: u32,
channels: usize,
},
// image_io
#[error("不支持的图像通道数: {0} (仅支持单通道灰度L、3通道RGB、4通道RGBA)")]
UnsupportedChannels(usize),
// #[error("Base64 解码失败: {0}")]
// Base64(#[from] base64::DecodeError),
//
// #[error("Base64 头部格式不正确,缺少 ';base64,' 分隔符")]
// InvalidBase64Header,
// #[error("其他预处理错误: {0}")]
// Other(String),
}
// =====================================================================
// 3. 子领域 B: 推理与张量操作错误类型
// =====================================================================
#[derive(Error, Debug)]
pub enum TensorErrorReason {
/// 替换原有的 anyhow::Error明确将 Tract/ONNX 引擎底层报错序列化为干净的 String
#[error("推理引擎内部发生异常: {0}")]
EngineError(String),
/// 模型张量维度不匹配 (原有的顶层 DimensionMismatch 被优雅地归入本模块)
#[error("模型张量维度不匹配!预期: {expected},实际 Tensor 形状: {actual:?}")]
TensorDimensionMismatch {
expected: String,
actual: Vec<usize>,
},
/// 新增:针对后处理 Logits 矩阵变形Reshape失败的精细化错误
/// 直接包装 ndarray::ShapeError保留强类型完美支持 match
#[error("OCR Logits 矩阵变形失败: {0}")]
LogitsDimensionMismatch(#[from] ndarray::ShapeError),
/// 张量内存布局不是连续的
#[error("内存不连续,无法执行零拷贝操作")] #[error("内存不连续,无法执行零拷贝操作")]
NonContiguousMemory, NonContiguousMemory,
/// 模型的输出数据类型或格式不受支持
#[error("未知的模型输出格式")] #[error("未知的模型输出格式")]
UnknownOutputFormat, UnknownOutputFormat,
#[error("解析节点 Fact 失败")]
InternalError(String),
} }
/// 专门服务于预处理的子错误枚举,保留全部底层上下文 // =====================================================================
// 4. 子领域 C: 算法解码错误类型
// =====================================================================
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum ImagePreprocessReason { pub enum DecodeReason {
#[error("图片加载或文件 I/O 失败: {0}")] /// CTC 解码器解码过程中的逻辑报错
ImageIo(#[from] image::ImageError), #[error("CTC 解码异常: {0}")]
CtcDecodeError(String),
#[error("图片转矩阵矩阵(ndarray)失败: {0}")]
NdarrayError(#[from] ndarray::ShapeError),
#[error("Base64 解码失败: {0}")]
Base64(#[from] base64::DecodeError),
#[error("Base64 头部格式不正确,缺少 ';base64,' 分隔符")]
InvalidBase64Header,
#[error("不支持的通道数: {0}")]
UnsupportedChannels(usize),
#[error("其他预处理错误: {0}")]
Custom(String),
} }
/// 统一用我们自己的 DdddError 包装 Result // =====================================================================
pub type Result<T> = std::result::Result<T, DdddError>; // 5. 【自定义错误安全注入】不使用全局 `#[from]`,采用显式包装避免特化冲突
// =====================================================================
impl DdddError {
/// 提供类似 std::io::Error::new 的构造函数,方便手动且无痛地包装任意第三方错误
pub fn new<E>(error: E) -> Self
where
E: Into<Box<dyn std::error::Error + Send + Sync>>,
{
DdddError::Other(error.into())
}
// -----------------------------------------------------------------
// 2.3 优化提供一键判断与转换的快捷方法Downcasting Helpers
// -----------------------------------------------------------------
/// 快速判断是否是系统 I/O 错误
// pub fn is_io_error(&self) -> bool {
// matches!(self, DdddError::Io(_))
// }
/// 尝试将错误转换为引用形式的 `std::io::Error`
// pub fn as_io_error(&self) -> Option<&std::io::Error> {
// match self {
// DdddError::Io(err) => Some(err),
// _ => None,
// }
// }
/// 快速判断是否是预处理阶段的图片维度不合规错误
pub fn is_invalid_dimensions(&self) -> bool {
matches!(
self,
DdddError::Preprocess(ImagePreprocessReason::InvalidImageDimensions { .. })
)
}
/// 快速判断是否是因为图片通道数不合规导致的失败
pub fn is_unsupported_channels(&self) -> bool {
matches!(
self,
DdddError::Preprocess(ImagePreprocessReason::UnsupportedChannels(_))
)
}
/// 提取出底层最原始的那个错误(无论是 IO、预处理、推理、还是第三方扩展错误
/// 方便外层统一打印更深层的 `source` 链条
pub fn source_error(&self) -> Option<&(dyn std::error::Error + 'static)> {
use std::error::Error;
match self {
// DdddError::Io(err) => Some(err),
DdddError::Preprocess(err) => Some(err),
DdddError::Inference(err) => Some(err),
DdddError::Decode(err) => Some(err),
DdddError::Other(err) => Some(err.as_ref()),
DdddError::Internal(_) => None, // Internal 内部目前只有 String没有底层的 Error source
}
}
}

View File

@@ -2,7 +2,7 @@ use crate::ocr::metadata::Resize;
use crate::ocr::color_filter::{HsvRange, apply_to_image}; use crate::ocr::color_filter::{HsvRange, apply_to_image};
// use ddddocr_tract::session::{ModelOutput, OcrSession}; // use ddddocr_tract::session::{ModelOutput, OcrSession};
use crate::utils::image_io::png_rgba_white_preprocess; 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 anyhow::Result;
use image::DynamicImage; use image::DynamicImage;

View File

@@ -1,4 +1,4 @@
use crate::utils::image_io::{image_to_ndarray,ColorMode}; use crate::utils::image_convert::{image_to_ndarray, ColorMode};
use crate::utils::image_processor; use crate::utils::image_processor;
use crate::utils::image_processor::{abs_diff, min_max_loc, ndarray_to_luma8, rgb_to_gray}; use crate::utils::image_processor::{abs_diff, min_max_loc, ndarray_to_luma8, rgb_to_gray};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};

View File

@@ -1,8 +1,8 @@
pub mod image_io; pub mod image_convert;
pub mod image_processor; pub mod image_processor;
mod tensor_transform; mod tensor_transform;
mod image_helper; mod image_helper;
// 对外统一暴露干净的 API 语义层 // 对外统一暴露干净的 API 语义层
pub use tensor_transform::normalize_ocr_logits; pub use tensor_transform::normalize_ocr_logits;
pub use image_io::{ColorMode}; pub use image_convert::{ColorMode};

View File

@@ -1,9 +1,6 @@
use crate::error::{DdddError, ImagePreprocessReason, Result}; use crate::error::{DdddError, ImagePreprocessReason, Result};
use base64::{Engine as _, engine::general_purpose};
use image::{DynamicImage, GenericImageView, ImageBuffer, Luma, Rgb, Rgba}; use image::{DynamicImage, GenericImageView, ImageBuffer, Luma, Rgb, Rgba};
use ndarray::{Array3, ArrayD, ArrayViewD}; use ndarray::{Array3, ArrayViewD};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug)] #[derive(Debug)]
pub enum ColorMode { pub enum ColorMode {
@@ -11,40 +8,10 @@ pub enum ColorMode {
RGBA, RGBA,
L, L,
} }
/// 将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(ImagePreprocessReason::from)?;
let img = image::load_from_memory(&bytes).map_err(ImagePreprocessReason::from)?;
Ok(img)
}
/// 读取图片文件并转换为 base64 编码字符串
// 对应 Python 版 get_img_base64
pub fn img_base64<P: AsRef<Path>>(image_path: P) -> Result<String> {
// 1. 读取文件原始字节流
// 使用 AsRef<Path> 泛型可以让函数同时支持 String, &str, PathBuf 等类型
let image_data = fs::read(&image_path)?;
// 2. 进行 Base64 编码
// 使用 STANDARD 引擎对齐 Python 的 base64.b64encode
let b64_string = general_purpose::STANDARD.encode(image_data);
Ok(b64_string)
}
/// 封装数组转图像的逻辑, /// 封装数组转图像的逻辑,
// 对应 Python 版 _numpy_to_pil_image // 对应 Python 版 _numpy_to_pil_image
pub(crate) fn ndarray_to_pil_image(array: ArrayViewD<u8>) -> Result<DynamicImage> { pub fn ndarray_to_hwc_image(array: ArrayViewD<u8>) -> Result<DynamicImage> {
let shape = array.shape(); let shape = array.shape();
let dim = shape.len(); let dim = shape.len();
@@ -68,17 +35,19 @@ pub(crate) fn ndarray_to_pil_image(array: ArrayViewD<u8>) -> Result<DynamicImage
// 对应 Python: array.shape[2] == 4 (RGBA H, W, 4) // 对应 Python: array.shape[2] == 4 (RGBA H, W, 4)
4 => ColorMode::RGBA, 4 => ColorMode::RGBA,
_ => { _ => {
return Err(DdddError::PreprocessError( return Err(DdddError::Preprocess(
ImagePreprocessReason::UnsupportedChannels(c), ImagePreprocessReason::UnsupportedChannels(c),
)); ));
} }
} }
} }
_ => { _ => {
return Err(DdddError::DimensionMismatch { return Err(DdddError::Preprocess(
expected: "2D (H,W) 或 3D (H,W,C)".to_string(), ImagePreprocessReason::InvalidImageDimensions {
actual: shape.to_vec(), expected: "2D (H,W) 或 3D (H,W,C)".to_string(),
}); actual: shape.to_vec(),
},
));
} }
}; };
from_ndarray(array, color_mode) from_ndarray(array, color_mode)
@@ -153,10 +122,12 @@ pub fn ndarray_to_image(array: ArrayViewD<u8>, mode: ColorMode) -> Result<Dynami
// 基础边界检查:至少要有 H 和 W 两个维度 // 基础边界检查:至少要有 H 和 W 两个维度
if shape.len() < 2 { if shape.len() < 2 {
return Err(DdddError::DimensionMismatch { return Err(DdddError::Preprocess(
expected: "At least 2D array [H, W]".to_string(), ImagePreprocessReason::InvalidImageDimensions {
actual: shape.to_vec(), expected: "至少为 2D array [H, W]".to_string(),
}); actual: shape.to_vec(),
},
));
} }
from_ndarray(array, mode) from_ndarray(array, mode)
} }
@@ -173,29 +144,37 @@ fn from_ndarray(array: ArrayViewD<u8>, mode: ColorMode) -> Result<DynamicImage>
let standard = array.as_standard_layout(); let standard = array.as_standard_layout();
let (raw_data, _) = standard.to_owned().into_raw_vec_and_offset(); let (raw_data, _) = standard.to_owned().into_raw_vec_and_offset();
let raw_len = raw_data.len(); 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 = || {
DdddError::Preprocess(ImagePreprocessReason::BufferLengthMismatch {
expected: expected_len,
actual: raw_len,
width,
height,
channels,
})
};
// 2. 重新解释内存并构建 ImageBuffer // 2. 重新解释内存并构建 ImageBuffer
match mode { match mode {
ColorMode::L => ImageBuffer::<Luma<u8>, _>::from_raw(width, height, raw_data) ColorMode::L => ImageBuffer::<Luma<u8>, _>::from_raw(width, height, raw_data)
.map(DynamicImage::ImageLuma8).ok_or_else(|| { .map(DynamicImage::ImageLuma8)
DdddError::PreprocessError(ImagePreprocessReason::Custom(format!( .ok_or_else(make_err),
"Failed to construct Luma image: buffer size {} does not match expected {} ({}x{}x1)",
raw_len, width * height * 1, width, height
)))
}),
ColorMode::RGB => ImageBuffer::<Rgb<u8>, _>::from_raw(width, height, raw_data) ColorMode::RGB => ImageBuffer::<Rgb<u8>, _>::from_raw(width, height, raw_data)
.map(DynamicImage::ImageRgb8).ok_or_else(|| { .map(DynamicImage::ImageRgb8)
DdddError::PreprocessError(ImagePreprocessReason::Custom(format!( .ok_or_else(make_err),
"Failed to construct RGB image: buffer size {} does not match expected {} ({}x{}x3)",
raw_len, width * height * 3, width, height
)))
}),
ColorMode::RGBA => ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, raw_data) ColorMode::RGBA => ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, raw_data)
.map(DynamicImage::ImageRgba8).ok_or_else(|| { .map(DynamicImage::ImageRgba8)
DdddError::PreprocessError(ImagePreprocessReason::Custom(format!( .ok_or_else(make_err),
"Failed to construct RGBA image: buffer size {} does not match expected {} ({}x{}x4)",
raw_len, width * height * 4, width, height
)))
}),
} }
} }

View File

@@ -1,10 +1,13 @@
use crate::error::{DdddError, ImagePreprocessReason, Result}; use crate::error::{DdddError, ImagePreprocessReason, Result};
use crate::utils::image_io::{base64_to_image, ndarray_to_pil_image}; use crate::utils::image_convert::ndarray_to_hwc_image;
use base64::{Engine as _, engine::general_purpose};
use image::DynamicImage; use image::DynamicImage;
use ndarray::ArrayViewD; use ndarray::ArrayViewD;
use std::fmt;
use std::fmt::{Debug, Formatter};
use std::fs;
use std::path::Path; use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
pub struct Base64<'a>(pub &'a str); pub struct Base64<'a>(pub &'a str);
/// 专属图像输入源转换器 /// 专属图像输入源转换器
pub struct ImageSource { pub struct ImageSource {
@@ -29,6 +32,21 @@ impl TryFromImage<DynamicImage> for ImageSource {
Ok(Self { inner: img }) 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 (最常用) // 2.2 路径类型 A: &str (最常用)
impl<'a> TryFromImage<&'a str> for ImageSource { impl<'a> TryFromImage<&'a str> for ImageSource {
fn try_from_image(path_or_b64: &'a str) -> Result<Self> { fn try_from_image(path_or_b64: &'a str) -> Result<Self> {
@@ -37,13 +55,13 @@ impl<'a> TryFromImage<&'a str> for ImageSource {
// 提取出真正的 base64 数据部分 // 提取出真正的 base64 数据部分
let (_, clean_b64) = path_or_b64.split_once(";base64,").ok_or_else(|| { let (_, clean_b64) = path_or_b64.split_once(";base64,").ok_or_else(|| {
// 返回一个明确的、可读性极佳的格式错误 // 返回一个明确的、可读性极佳的格式错误
DdddError::PreprocessError(ImagePreprocessReason::InvalidBase64Header) DdddError::new(Base64ProcessError::InvalidBase64Header)
})?; })?;
// 转换为 Base64 包装器,并复用其 TryFromImage 实现 // 转换为 Base64 包装器,并复用其 TryFromImage 实现
Self::try_from_image(Base64(clean_b64)) Self::try_from_image(Base64(clean_b64))
} else { } else {
// 2. 否则,老老实实当作本地路径打开 // 2. 否则,老老实实当作本地路径打开
let img = image::open(path_or_b64).map_err(ImagePreprocessReason::from)?; let img = image::open(path_or_b64).map_err(DdddError::new)?;
Ok(Self { inner: img }) Ok(Self { inner: img })
} }
} }
@@ -52,7 +70,7 @@ impl<'a> TryFromImage<&'a str> for ImageSource {
// 2.3 路径类型 B: &Path (标准借用) // 2.3 路径类型 B: &Path (标准借用)
impl<'a> TryFromImage<&'a Path> for ImageSource { impl<'a> TryFromImage<&'a Path> for ImageSource {
fn try_from_image(path: &'a Path) -> Result<Self> { fn try_from_image(path: &'a Path) -> Result<Self> {
let img = image::open(path).map_err(ImagePreprocessReason::from)?; let img = image::open(path).map_err(DdddError::new)?;
Ok(Self { inner: img }) Ok(Self { inner: img })
} }
} }
@@ -71,7 +89,7 @@ impl TryFromImage<String> for ImageSource {
// 2. 支持带有生命周期的借用:直接支持 &[u8](不强绑生命周期到 ImageSource 结构体上!) // 2. 支持带有生命周期的借用:直接支持 &[u8](不强绑生命周期到 ImageSource 结构体上!)
impl<'a> TryFromImage<&'a [u8]> for ImageSource { impl<'a> TryFromImage<&'a [u8]> for ImageSource {
fn try_from_image(bytes: &'a [u8]) -> Result<Self> { fn try_from_image(bytes: &'a [u8]) -> Result<Self> {
let img = image::load_from_memory(bytes).map_err(ImagePreprocessReason::from)?; let img = image::load_from_memory(bytes).map_err(DdddError::new)?;
Ok(Self { inner: img }) Ok(Self { inner: img })
} }
} }
@@ -79,7 +97,7 @@ impl<'a> TryFromImage<&'a [u8]> for ImageSource {
// 4. 完美支持 ndarray 的借用 ArrayViewD // 4. 完美支持 ndarray 的借用 ArrayViewD
impl<'a> TryFromImage<ArrayViewD<'a, u8>> for ImageSource { impl<'a> TryFromImage<ArrayViewD<'a, u8>> for ImageSource {
fn try_from_image(array: ArrayViewD<'a, u8>) -> Result<Self> { fn try_from_image(array: ArrayViewD<'a, u8>) -> Result<Self> {
let img = ndarray_to_pil_image(array)?; let img = ndarray_to_hwc_image(array)?;
Ok(Self { inner: img }) Ok(Self { inner: img })
} }
} }
@@ -100,3 +118,35 @@ where
let img = ImageSource::try_from_image(input)?.into_image(); let img = ImageSource::try_from_image(input)?.into_image();
Ok(img) 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)
}

View File

@@ -1,8 +1,8 @@
use ndarray::s;
use crate::error::{DdddError,Result};
use crate::OcrOutput; use crate::OcrOutput;
/// 🌟 核心层复用资产:将异构的动态维度矩阵转化为标准 OCR 2D Logits 矩阵 use crate::error::{DdddError, Result, TensorErrorReason};
pub fn normalize_ocr_logits(array: ndarray::ArrayD<f32>, shape: &[usize]) -> Result<OcrOutput> { use ndarray::s;
/// 核心层复用资产:将异构的动态维度矩阵转化为标准 OCR 2D Logits 矩阵
pub fn normalize_ocr_logits(array: ndarray::ArrayViewD<f32>, shape: &[usize]) -> Result<OcrOutput> {
let (steps, classes, data_dyn_view) = match shape.len() { let (steps, classes, data_dyn_view) = match shape.len() {
3 => { 3 => {
if shape[1] == 1 { if shape[1] == 1 {
@@ -24,19 +24,26 @@ pub fn normalize_ocr_logits(array: ndarray::ArrayD<f32>, shape: &[usize]) -> Res
// 我们把它虚构成一个 [1, Classes] 的 2D 矩阵来复用后面的 argmax 逻辑 // 我们把它虚构成一个 [1, Classes] 的 2D 矩阵来复用后面的 argmax 逻辑
1 => (1, shape[0], array), 1 => (1, shape[0], array),
_ => { _ => {
return Err(DdddError::DimensionMismatch { return Err(DdddError::Inference(
expected: "1D, 2D, or 3D OCR Logits".to_string(), TensorErrorReason::TensorDimensionMismatch {
actual: shape.to_vec(), expected: "1D, 2D, or 3D OCR Logits".to_string(),
}); actual: shape.to_vec(),
},
));
} }
}; };
// 转换为标准的 2D 静态矩阵 [Steps, Classes] // 转换为标准的 2D 静态矩阵 [Steps, Classes]
let matrix_cow = data_dyn_view let matrix_cow = data_dyn_view
.to_shape(ndarray::Ix2(steps, classes)) .to_shape(ndarray::Ix2(steps, classes))
.map_err(|_| DdddError::DimensionMismatch { .map_err(|shape_err| {
expected: format!("无法将形状调整为 [{}, {}]", steps, classes), // 如果是因为切片导致不连续且无法进行零拷贝变换,抛出 NonContiguousMemory
actual: shape.to_vec(), if !data_dyn_view.is_standard_layout() {
DdddError::Inference(TensorErrorReason::NonContiguousMemory)
} else {
// 否则说明是纯粹的数据元素数量不对Shape 不匹配),抛出专属的强类型错误
DdddError::Inference(TensorErrorReason::LogitsDimensionMismatch(shape_err))
}
})? })?
.to_owned(); .to_owned();

View File

@@ -1,6 +1,6 @@
use crate::loader::ModelLoader; use crate::loader::ModelLoader;
use anyhow::Context; use anyhow::Context;
use ddddocr_core::error::{DdddError, Result}; use ddddocr_core::error::{DdddError, Result, TensorErrorReason};
use ddddocr_core::{DetEngine, DetOutput, InferenceEngine}; use ddddocr_core::{DetEngine, DetOutput, InferenceEngine};
use ndarray::Ix3; use ndarray::Ix3;
use std::path::Path; use std::path::Path;
@@ -42,26 +42,28 @@ impl InferenceEngine for DetSession {
// let result = self.ocr.run(tvec!(tensor.into()))?; // let result = self.ocr.run(tvec!(tensor.into()))?;
let tensor = Tensor::from(input_array); let tensor = Tensor::from(input_array);
let mut result = self let mut result = self.session.run(tvec!(tensor.into())).map_err(|_| {
.session DdddError::Inference(TensorErrorReason::EngineError(
.run(tvec!(tensor.into())) "执行模型推理失败".to_string(),
.context("执行模型推理失败")?; ))
})?;
println!("模型输出原始数据: {:?}", result); println!("模型输出原始数据: {:?}", result);
// Ok(result.swap_remove(0).into_tensor()) // Ok(result.swap_remove(0).into_tensor())
let raw_tensor = result.swap_remove(0).into_tensor(); let raw_tensor = result.swap_remove(0).into_tensor();
let array_d = raw_tensor let array_d = raw_tensor.into_array::<f32>().map_err(|_| {
.into_array::<f32>() DdddError::Inference(TensorErrorReason::EngineError(
.context("Tract 实体张量无法转换为 ndarray::ArrayD")?; "Tract 实体张量无法转换为 ndarray::ArrayD".to_string(),
))
})?;
// 提前利用克隆(Clone)备份好当前未转维度前的真实 shape (Vec<usize>) // 提前利用克隆(Clone)备份好当前未转维度前的真实 shape (Vec<usize>)
let actual_shape = array_d.shape().to_vec(); let actual_shape = array_d.shape().to_vec();
let array3 = let array3 = array_d.into_dimensionality::<Ix3>().map_err(|_| {
array_d DdddError::Inference(TensorErrorReason::TensorDimensionMismatch {
.into_dimensionality::<Ix3>() expected: "3D 检测矩阵 [Batch, Box_Count, Box_Attributes]".to_string(),
.map_err(|_| DdddError::DimensionMismatch { actual: actual_shape, // 优雅降维失败时动态捕获
expected: "3D 检测矩阵 [Batch, Box_Count, Box_Attributes]".to_string(), })
actual: actual_shape, // 优雅降维失败时动态捕获 })?;
})?;
Ok(DetOutput::Detection(array3)) Ok(DetOutput::Detection(array3))
// 在引擎内部消化掉 DatumType 强耦合 // 在引擎内部消化掉 DatumType 强耦合

View File

@@ -56,12 +56,12 @@ impl ModelLoader {
P: AsRef<std::path::Path>, P: AsRef<std::path::Path>,
{ {
let session = onnx() let session = onnx()
.model_for_path(model_path) .model_for_path(model_path).map_err(DdddError::new)?
.with_context(|| "加载 ONNX 模型失败,请检查路径是否正确")? // .with_context(|| "加载 ONNX 模型失败,请检查路径是否正确")?
.into_optimized() .into_optimized().map_err(DdddError::new)?
.with_context(|| "优化 Tract 模型图失败")? // .with_context(|| "优化 Tract 模型图失败")?
.into_runnable() .into_runnable().map_err(DdddError::new)?;
.with_context(|| "构建可运行 Tract 实例失败")?; // .with_context(|| "构建可运行 Tract 实例失败")?;
Ok(Self { session }) Ok(Self { session })
} }
/// 策略 B从内存字节流加载模型配合 include_bytes! 使用) /// 策略 B从内存字节流加载模型配合 include_bytes! 使用)
@@ -71,11 +71,13 @@ impl ModelLoader {
let session = onnx() let session = onnx()
.model_for_read(&mut cursor) .model_for_read(&mut cursor)
.with_context(|| "从内存字节流解析 ONNX 模型失败")? .map_err(DdddError::new)?
// .with_context(|| "从内存字节流解析 ONNX 模型失败")?
.into_optimized() .into_optimized()
.with_context(|| "优化 Tract 模型图失败")? .map_err(DdddError::new)?
.into_runnable() // .with_context(|| "优化 Tract 模型图失败")?
.with_context(|| "构建可运行 Tract 实例失败")?; .into_runnable().map_err(DdddError::new)?;
// .with_context(|| "构建可运行 Tract 实例失败")?;
Ok(Self { session }) Ok(Self { session })
} }
@@ -90,13 +92,13 @@ impl ModelLoader {
// 使用私有辅助函数统一处理,消除重复代码 // 使用私有辅助函数统一处理,消除重复代码
let inputs = self.resolve_tensors( let inputs = self.resolve_tensors(
model model
.input_outlets() .input_outlets().map_err(DdddError::new)?
.map_err(|e| DdddError::InternalError(format!("获取输入节点失败: {:?}", e)))?, // .map_err(|e| DdddError::InternalError(format!("获取输入节点失败: {:?}", e)))?,
)?; )?;
let outputs = self.resolve_tensors( let outputs = self.resolve_tensors(
model model
.output_outlets() .output_outlets().map_err(DdddError::new)?
.map_err(|e| DdddError::InternalError(format!("获取输出节点失败: {:?}", e)))?, // .map_err(|e| DdddError::InternalError(format!("获取输出节点失败: {:?}", e)))?,
)?; )?;
Ok(ModelInfo { Ok(ModelInfo {
@@ -113,9 +115,10 @@ impl ModelLoader {
outlets outlets
.iter() .iter()
.map(|&outlet_id| { .map(|&outlet_id| {
let fact = model.outlet_fact(outlet_id).map_err(|e| { let fact = model.outlet_fact(outlet_id).map_err(DdddError::new)?;
DdddError::InternalError(format!("解析节点 Fact 失败: {:?}", e)) // .map_err(|e| {
})?; // DdddError::InternalError(format!("解析节点 Fact 失败: {:?}", e))
// })?;
let shape = self.resolve_shape(&fact.shape)?; let shape = self.resolve_shape(&fact.shape)?;
let node_name = model.node(outlet_id.node).name.clone(); let node_name = model.node(outlet_id.node).name.clone();

View File

@@ -1,6 +1,7 @@
use crate::loader::ModelLoader; use crate::loader::ModelLoader;
use anyhow::Context; use anyhow::Context;
use ddddocr_core::error::{DdddError, Result}; use ddddocr_core::error::{DdddError, Result, TensorErrorReason};
use ddddocr_core::utils::normalize_ocr_logits;
use ddddocr_core::{InferenceEngine, ModelMetadata, OcrEngine, OcrOutput}; use ddddocr_core::{InferenceEngine, ModelMetadata, OcrEngine, OcrOutput};
use ndarray::s; use ndarray::s;
use std::path::Path; use std::path::Path;
@@ -47,7 +48,12 @@ impl InferenceEngine for OcrSession {
let mut result = self let mut result = self
.session .session
.run(tvec!(tensor.into())) .run(tvec!(tensor.into()))
.context("执行模型推理失败")?; .map_err(|_| {
DdddError::Inference(TensorErrorReason::EngineError(
"执行模型推理失败".to_string(),
))
})?;
// .context("执行模型推理失败")?;
println!("模型输出原始数据: {:?}", result); println!("模型输出原始数据: {:?}", result);
// Ok(result.swap_remove(0).into_tensor()) // Ok(result.swap_remove(0).into_tensor())
let raw_tensor = result.swap_remove(0).into_tensor(); let raw_tensor = result.swap_remove(0).into_tensor();
@@ -56,16 +62,23 @@ impl InferenceEngine for OcrSession {
DatumType::I64 => { DatumType::I64 => {
let array_d = raw_tensor let array_d = raw_tensor
.into_array::<i64>() .into_array::<i64>()
.context("Tract 无法获取 i64 内存视图")?; .map_err(|_| {
DdddError::Inference(TensorErrorReason::EngineError(
"Tract 无法获取 i64 内存视图".to_string(),
))
})?;
// .context("Tract 无法获取 i64 内存视图")?;
// 🌟 提前提取真实维度 // 🌟 提前提取真实维度
let actual_shape = array_d.shape().to_vec(); let actual_shape = array_d.shape().to_vec();
// 转成标准的 Array1 传给 core // 转成标准的 Array1 传给 core
let array1 = array_d let array1 = array_d
.to_owned() .to_owned()
.into_dimensionality::<ndarray::Ix1>() .into_dimensionality::<ndarray::Ix1>()
.map_err(|_| DdddError::DimensionMismatch { .map_err(|_| {
expected: "1D 字符索引静态矩阵".to_string(), DdddError::Inference(TensorErrorReason::TensorDimensionMismatch {
actual: actual_shape, expected: "1D 字符索引静态矩阵".to_string(),
actual: actual_shape,
})
})?; })?;
Ok(OcrOutput::Indices(array1)) Ok(OcrOutput::Indices(array1))
} }
@@ -74,51 +87,17 @@ impl InferenceEngine for OcrSession {
println!("模型输出shape数据: {:?}", shape); println!("模型输出shape数据: {:?}", shape);
let view = raw_tensor let view = raw_tensor
.to_array_view::<f32>() .to_array_view::<f32>()
.context("Tract 无法获取 f32 内存视图")?; .map_err(|_| {
DdddError::Inference(TensorErrorReason::EngineError(
"Tract 无法获取 f32 内存视图".to_string(),
))
})?;
// 1. 极其纯粹的、无拷贝的多维 Shape 压扁清洗 // 1. 极其纯粹的、无拷贝的多维 Shape 压扁清洗
let (steps, classes, data_dyn_view) = match shape.len() { normalize_ocr_logits(view, shape)
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(DdddError::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(|_| DdddError::DimensionMismatch {
expected: format!("无法将形状调整为 [{}, {}]", steps, classes),
actual: shape.to_vec(),
})?
.to_owned(); // 转换为 Owned断开与 tract 内存生命周期的绑定,方便传递给 core
Ok(OcrOutput::Logits(matrix_cow))
} }
_ => Err( _ => Err(
// anyhow::anyhow!("不支持的模型输出数据类型: {:?}",raw_tensor.datum_type()) // anyhow::anyhow!("不支持的模型输出数据类型: {:?}",raw_tensor.datum_type())
DdddError::UnknownOutputFormat, DdddError::Inference(TensorErrorReason::UnknownOutputFormat)
), ),
} }
} }

View File

@@ -232,7 +232,8 @@ fn test_real_slide_comparison() {
fn test_resolve_shape_logic_direct() { fn test_resolve_shape_logic_direct() {
// 创建一个哑 ModelLoader 实例session 用不上,因为我们直接测私有方法) // 创建一个哑 ModelLoader 实例session 用不上,因为我们直接测私有方法)
let loader = ModelLoader::model_for_path( let loader = ModelLoader::model_for_path(
"D:\\CNWei\\CNW\\Rust\\ddddocr-rs\\models\\common_sml2h3_f32.onnx", // "D:\\CNWei\\CNW\\Rust\\ddddocr-rs\\models\\common_sml2h3_f32.onnx",
"D:\\CNWei\\CNW\\Rust\\ddddocr-rs\\models\\common_huashi666_i64.onnx",
) )
.expect("建立测试模型图失败"); .expect("建立测试模型图失败");
let md_info = &loader.model_info().context("信息"); let md_info = &loader.model_info().context("信息");