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

@@ -18,59 +18,235 @@ pub(crate) const MODEL_DOWNLOAD_HELP: &str = "\
================================================================================";
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)]
pub enum DdddError {
// 【新增】专门处理文件读取、路径不存在等原生 I/O 错误
#[error("系统网络或文件 I/O 异常: {0}")]
Io(#[from] std::io::Error),
// /// 系统文件、网络等原生 I/O 异常 (高优先级自动转换)
// #[error("系统网络或文件 I/O 异常: {0}")]
// Io(#[from] std::io::Error),
/// 图像预处理阶段发生异常
#[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:?}")]
DimensionMismatch {
/// 框架内部不可恢复的逻辑断言错误(例如解析节点 Fact 失败)
#[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,
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("内存不连续,无法执行零拷贝操作")]
NonContiguousMemory,
/// 模型的输出数据类型或格式不受支持
#[error("未知的模型输出格式")]
UnknownOutputFormat,
#[error("解析节点 Fact 失败")]
InternalError(String),
}
/// 专门服务于预处理的子错误枚举,保留全部底层上下文
// =====================================================================
// 4. 子领域 C: 算法解码错误类型
// =====================================================================
#[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),
pub enum DecodeReason {
/// CTC 解码器解码过程中的逻辑报错
#[error("CTC 解码异常: {0}")]
CtcDecodeError(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 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 anyhow::Result;
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::{abs_diff, min_max_loc, ndarray_to_luma8, rgb_to_gray};
use anyhow::{Result, anyhow};

View File

@@ -1,8 +1,8 @@
pub mod image_io;
pub mod image_convert;
pub mod image_processor;
mod tensor_transform;
mod image_helper;
// 对外统一暴露干净的 API 语义层
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 base64::{Engine as _, engine::general_purpose};
use image::{DynamicImage, GenericImageView, ImageBuffer, Luma, Rgb, Rgba};
use ndarray::{Array3, ArrayD, ArrayViewD};
use std::fs;
use std::path::{Path, PathBuf};
use ndarray::{Array3, ArrayViewD};
#[derive(Debug)]
pub enum ColorMode {
@@ -11,40 +8,10 @@ pub enum ColorMode {
RGBA,
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
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 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)
4 => ColorMode::RGBA,
_ => {
return Err(DdddError::PreprocessError(
return Err(DdddError::Preprocess(
ImagePreprocessReason::UnsupportedChannels(c),
));
}
}
}
_ => {
return Err(DdddError::DimensionMismatch {
expected: "2D (H,W) 或 3D (H,W,C)".to_string(),
actual: shape.to_vec(),
});
return Err(DdddError::Preprocess(
ImagePreprocessReason::InvalidImageDimensions {
expected: "2D (H,W) 或 3D (H,W,C)".to_string(),
actual: shape.to_vec(),
},
));
}
};
from_ndarray(array, color_mode)
@@ -153,10 +122,12 @@ pub fn ndarray_to_image(array: ArrayViewD<u8>, mode: ColorMode) -> Result<Dynami
// 基础边界检查:至少要有 H 和 W 两个维度
if shape.len() < 2 {
return Err(DdddError::DimensionMismatch {
expected: "At least 2D array [H, W]".to_string(),
actual: shape.to_vec(),
});
return Err(DdddError::Preprocess(
ImagePreprocessReason::InvalidImageDimensions {
expected: "至少为 2D array [H, W]".to_string(),
actual: shape.to_vec(),
},
));
}
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 (raw_data, _) = standard.to_owned().into_raw_vec_and_offset();
let raw_len = raw_data.len();
// 获取当前模式对应的通道数
let channels = match mode {
ColorMode::L => 1,
ColorMode::RGB => 3,
ColorMode::RGBA => 4,
};
let expected_len = (width * height) as usize * channels;
// 构造通用错误闭包,避免 match 分支中重复编写冗长的错误对象
let make_err = || {
DdddError::Preprocess(ImagePreprocessReason::BufferLengthMismatch {
expected: expected_len,
actual: raw_len,
width,
height,
channels,
})
};
// 2. 重新解释内存并构建 ImageBuffer
match mode {
ColorMode::L => ImageBuffer::<Luma<u8>, _>::from_raw(width, height, raw_data)
.map(DynamicImage::ImageLuma8).ok_or_else(|| {
DdddError::PreprocessError(ImagePreprocessReason::Custom(format!(
"Failed to construct Luma image: buffer size {} does not match expected {} ({}x{}x1)",
raw_len, width * height * 1, width, height
)))
}),
.map(DynamicImage::ImageLuma8)
.ok_or_else(make_err),
ColorMode::RGB => ImageBuffer::<Rgb<u8>, _>::from_raw(width, height, raw_data)
.map(DynamicImage::ImageRgb8).ok_or_else(|| {
DdddError::PreprocessError(ImagePreprocessReason::Custom(format!(
"Failed to construct RGB image: buffer size {} does not match expected {} ({}x{}x3)",
raw_len, width * height * 3, width, height
)))
}),
.map(DynamicImage::ImageRgb8)
.ok_or_else(make_err),
ColorMode::RGBA => ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, raw_data)
.map(DynamicImage::ImageRgba8).ok_or_else(|| {
DdddError::PreprocessError(ImagePreprocessReason::Custom(format!(
"Failed to construct RGBA image: buffer size {} does not match expected {} ({}x{}x4)",
raw_len, width * height * 4, width, height
)))
}),
.map(DynamicImage::ImageRgba8)
.ok_or_else(make_err),
}
}

View File

@@ -1,10 +1,13 @@
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 ndarray::ArrayViewD;
use std::fmt;
use std::fmt::{Debug, Formatter};
use std::fs;
use std::path::Path;
use std::path::PathBuf;
pub struct Base64<'a>(pub &'a str);
/// 专属图像输入源转换器
pub struct ImageSource {
@@ -29,6 +32,21 @@ impl TryFromImage<DynamicImage> for ImageSource {
Ok(Self { inner: img })
}
}
#[derive(Debug)]
enum Base64ProcessError {
InvalidBase64Header,
}
impl fmt::Display for Base64ProcessError {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Base64ProcessError::InvalidBase64Header => {
write!(f, "Base64 头部格式不正确,缺少 ';base64,' 分隔符")
}
}
}
}
impl std::error::Error for Base64ProcessError {}
// 2.2 路径类型 A: &str (最常用)
impl<'a> TryFromImage<&'a str> for ImageSource {
fn try_from_image(path_or_b64: &'a str) -> Result<Self> {
@@ -37,13 +55,13 @@ impl<'a> TryFromImage<&'a str> for ImageSource {
// 提取出真正的 base64 数据部分
let (_, clean_b64) = path_or_b64.split_once(";base64,").ok_or_else(|| {
// 返回一个明确的、可读性极佳的格式错误
DdddError::PreprocessError(ImagePreprocessReason::InvalidBase64Header)
DdddError::new(Base64ProcessError::InvalidBase64Header)
})?;
// 转换为 Base64 包装器,并复用其 TryFromImage 实现
Self::try_from_image(Base64(clean_b64))
} else {
// 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 })
}
}
@@ -52,7 +70,7 @@ impl<'a> TryFromImage<&'a str> for ImageSource {
// 2.3 路径类型 B: &Path (标准借用)
impl<'a> TryFromImage<&'a Path> for ImageSource {
fn try_from_image(path: &'a Path) -> Result<Self> {
let img = image::open(path).map_err(ImagePreprocessReason::from)?;
let img = image::open(path).map_err(DdddError::new)?;
Ok(Self { inner: img })
}
}
@@ -71,7 +89,7 @@ impl TryFromImage<String> for ImageSource {
// 2. 支持带有生命周期的借用:直接支持 &[u8](不强绑生命周期到 ImageSource 结构体上!)
impl<'a> TryFromImage<&'a [u8]> for ImageSource {
fn try_from_image(bytes: &'a [u8]) -> Result<Self> {
let img = image::load_from_memory(bytes).map_err(ImagePreprocessReason::from)?;
let img = image::load_from_memory(bytes).map_err(DdddError::new)?;
Ok(Self { inner: img })
}
}
@@ -79,7 +97,7 @@ impl<'a> TryFromImage<&'a [u8]> for ImageSource {
// 4. 完美支持 ndarray 的借用 ArrayViewD
impl<'a> TryFromImage<ArrayViewD<'a, u8>> for ImageSource {
fn try_from_image(array: ArrayViewD<'a, u8>) -> Result<Self> {
let img = ndarray_to_pil_image(array)?;
let img = ndarray_to_hwc_image(array)?;
Ok(Self { inner: img })
}
}
@@ -100,3 +118,35 @@ where
let img = ImageSource::try_from_image(input)?.into_image();
Ok(img)
}
/// 将base64编码的图片转换为 DynamicImage
pub fn base64_to_image(b64_str: &str) -> Result<DynamicImage> {
// 过滤掉可能存在的 base64 前缀,例如 "data:utils/png;base64,"
let clean_b64 = if let Some(pos) = b64_str.find(",") {
&b64_str[pos + 1..]
} else {
&b64_str
};
let bytes = general_purpose::STANDARD
.decode(clean_b64.trim())
// .map_err(|e| DdddError::new(e))?;
.map_err(DdddError::new)?;
let img = image::load_from_memory(&bytes).map_err(DdddError::new)?;
Ok(img)
}
/// 读取图片文件并转换为 base64 编码字符串
// 对应 Python 版 get_img_base64
pub fn img_to_base64<P: AsRef<Path>>(image_path: P) -> Result<String> {
// 1. 读取文件原始字节流
// 使用 AsRef<Path> 泛型可以让函数同时支持 String, &str, PathBuf 等类型
let image_data = fs::read(&image_path).map_err(DdddError::new)?;
// 2. 进行 Base64 编码
// 使用 STANDARD 引擎对齐 Python 的 base64.b64encode
let b64_string = general_purpose::STANDARD.encode(image_data);
Ok(b64_string)
}

View File

@@ -1,8 +1,8 @@
use ndarray::s;
use crate::error::{DdddError,Result};
use crate::OcrOutput;
/// 🌟 核心层复用资产:将异构的动态维度矩阵转化为标准 OCR 2D Logits 矩阵
pub fn normalize_ocr_logits(array: ndarray::ArrayD<f32>, shape: &[usize]) -> Result<OcrOutput> {
use crate::error::{DdddError, Result, TensorErrorReason};
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() {
3 => {
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 => (1, shape[0], array),
_ => {
return Err(DdddError::DimensionMismatch {
expected: "1D, 2D, or 3D OCR Logits".to_string(),
actual: shape.to_vec(),
});
return Err(DdddError::Inference(
TensorErrorReason::TensorDimensionMismatch {
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(),
.map_err(|shape_err| {
// 如果是因为切片导致不连续且无法进行零拷贝变换,抛出 NonContiguousMemory
if !data_dyn_view.is_standard_layout() {
DdddError::Inference(TensorErrorReason::NonContiguousMemory)
} else {
// 否则说明是纯粹的数据元素数量不对Shape 不匹配),抛出专属的强类型错误
DdddError::Inference(TensorErrorReason::LogitsDimensionMismatch(shape_err))
}
})?
.to_owned();