#[cfg(all(not(target_arch = "wasm32"), feature = "ocr"))]
use std::path::PathBuf;
use image::RgbImage;
use crate::Result;
use crate::error::XbergError;
use crate::inference::{InferenceSession, InferenceTensor, default_backend};
use super::types::OrientationResult;
#[cfg(all(not(target_arch = "wasm32"), feature = "ocr"))]
const HF_REPO_ID: &str = "xberg-io/paddleocr-onnx-models";
#[cfg(all(not(target_arch = "wasm32"), feature = "ocr"))]
const HF_REPO_REVISION: &str = "bfaf0b492cfc1dee0c73245fc5860bfdcf2c3443";
#[cfg(all(not(target_arch = "wasm32"), feature = "ocr"))]
const REMOTE_FILENAME: &str = "v2/classifiers/PP-LCNet_x1_0_doc_ori.onnx";
#[cfg(all(not(target_arch = "wasm32"), feature = "ocr"))]
const SHA256: &str = "6b742aebce6f0f7f71f747931ac7becfc7c96c51641e14943b291eeb334e7947";
const INPUT_SIZE: u32 = 224;
const RESIZE_SHORT: u32 = 256;
const ORIENTATION_LABELS: [u32; 4] = [0, 90, 180, 270];
pub const MIN_CONFIDENCE: f32 = 0.35;
enum ModelSource {
#[cfg(all(not(target_arch = "wasm32"), feature = "ocr"))]
CacheDir(PathBuf),
Bytes(Vec<u8>),
}
#[cfg_attr(alef, alef(skip))]
pub struct DocOrientationDetector {
session: once_cell::sync::OnceCell<Box<dyn InferenceSession>>,
source: ModelSource,
acceleration: Option<crate::core::config::acceleration::AccelerationConfig>,
}
impl DocOrientationDetector {
#[cfg(all(not(target_arch = "wasm32"), feature = "ocr"))]
pub(crate) fn with_acceleration(
cache_dir: PathBuf,
accel: Option<crate::core::config::acceleration::AccelerationConfig>,
) -> Self {
Self {
session: once_cell::sync::OnceCell::new(),
source: ModelSource::CacheDir(cache_dir),
acceleration: accel,
}
}
pub fn from_bytes(
model_bytes: Vec<u8>,
accel: Option<crate::core::config::acceleration::AccelerationConfig>,
) -> Self {
Self {
session: once_cell::sync::OnceCell::new(),
source: ModelSource::Bytes(model_bytes),
acceleration: accel,
}
}
pub fn detect_image_bytes(&self, image_bytes: &[u8]) -> Result<OrientationResult> {
let image = image::load_from_memory(image_bytes)
.map_err(|e| XbergError::Ocr {
message: format!("Failed to decode image for orientation detection: {e}"),
source: None,
})?
.to_rgb8();
self.detect(&image)
}
pub(crate) fn detect(&self, image: &RgbImage) -> Result<OrientationResult> {
let session = self.get_or_init_session()?;
let preprocessed = preprocess(image);
let input_tensor = normalize(&preprocessed);
let input_name = session
.input_names()
.first()
.cloned()
.unwrap_or_else(|| "x".to_string());
let outputs = session
.run(vec![(input_name, InferenceTensor::F32(input_tensor.into_dyn()))])
.map_err(|e| XbergError::Ocr {
message: format!("Doc orientation inference failed: {e}"),
source: None,
})?;
let (_, output_value) = outputs.first().ok_or_else(|| XbergError::Ocr {
message: "No output from doc orientation model".to_string(),
source: None,
})?;
let scores: Vec<f32> = output_value
.as_f32()
.ok_or_else(|| XbergError::Ocr {
message: "doc orientation output is not an f32 tensor".to_string(),
source: None,
})?
.iter()
.copied()
.collect();
let max_score = scores.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let exp_scores: Vec<f32> = scores.iter().map(|&s| (s - max_score).exp()).collect();
let sum_exp: f32 = exp_scores.iter().sum();
let probabilities: Vec<f32> = exp_scores.iter().map(|&e| e / sum_exp).collect();
let (best_idx, &best_prob) = probabilities
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or((0, &0.0));
let degrees = ORIENTATION_LABELS.get(best_idx).copied().unwrap_or(0);
Ok(OrientationResult {
degrees,
confidence: best_prob,
})
}
#[cfg(all(not(target_arch = "wasm32"), feature = "ocr"))]
fn ensure_model(cache_dir: &std::path::Path) -> Result<PathBuf> {
crate::model_download::hf_resolve_file(
HF_REPO_ID,
REMOTE_FILENAME,
Some(HF_REPO_REVISION),
Some(cache_dir),
Some(SHA256),
)
.map_err(|e| XbergError::Plugin {
message: e,
plugin_name: "auto-rotate".to_string(),
})
}
fn get_or_init_session(&self) -> Result<&dyn InferenceSession> {
let session = self
.session
.get_or_try_init(|| -> crate::Result<Box<dyn InferenceSession>> {
let session = match &self.source {
#[cfg(all(not(target_arch = "wasm32"), feature = "ocr"))]
ModelSource::CacheDir(cache_dir) => {
let model_path = Self::ensure_model(cache_dir)?;
default_backend()
.load(&model_path, self.acceleration.as_ref())
.map_err(|e| XbergError::Ocr {
message: format!("Failed to load doc_ori model: {e}"),
source: None,
})?
}
ModelSource::Bytes(model_bytes) => default_backend()
.load_from_memory(model_bytes, self.acceleration.as_ref())
.map_err(|e| XbergError::Ocr {
message: format!("Failed to load doc_ori model: {e}"),
source: None,
})?,
};
tracing::info!("Doc orientation model loaded");
Ok(session)
})?;
Ok(session.as_ref())
}
}
#[cfg(all(not(target_arch = "wasm32"), feature = "ocr"))]
pub(crate) fn resolve_cache_dir() -> PathBuf {
hf_hub::resolve_cache_dir()
}
#[cfg_attr(alef, alef(skip))]
#[cfg(feature = "paddle-ocr")]
pub(crate) fn detect_and_rotate(detector: &DocOrientationDetector, image_bytes: &[u8]) -> Result<Option<Vec<u8>>> {
let img = image::load_from_memory(image_bytes)
.map_err(|e| XbergError::Ocr {
message: format!("Failed to load image for orientation detection: {e}"),
source: None,
})?
.to_rgb8();
let result = detector.detect(&img)?;
tracing::debug!(
degrees = result.degrees,
confidence = result.confidence,
"Document orientation detected"
);
if result.degrees == 0 || result.confidence < MIN_CONFIDENCE {
return Ok(None);
}
let rotated = match result.degrees {
90 => image::imageops::rotate270(&img),
180 => image::imageops::rotate180(&img),
270 => image::imageops::rotate90(&img),
_ => return Ok(None),
};
let mut buf = std::io::Cursor::new(Vec::new());
rotated
.write_to(&mut buf, image::ImageFormat::Png)
.map_err(|e| XbergError::Ocr {
message: format!("Failed to encode rotated image: {e}"),
source: None,
})?;
tracing::info!(
degrees = result.degrees,
confidence = result.confidence,
"Auto-rotated document page"
);
Ok(Some(buf.into_inner()))
}
fn preprocess(image: &RgbImage) -> RgbImage {
let (w, h) = (image.width(), image.height());
let (new_w, new_h) = if w < h {
let scale = RESIZE_SHORT as f32 / w as f32;
(RESIZE_SHORT, (h as f32 * scale).round() as u32)
} else {
let scale = RESIZE_SHORT as f32 / h as f32;
((w as f32 * scale).round() as u32, RESIZE_SHORT)
};
let resized = image::imageops::resize(image, new_w, new_h, image::imageops::FilterType::Triangle);
let x_offset = (new_w.saturating_sub(INPUT_SIZE)) / 2;
let y_offset = (new_h.saturating_sub(INPUT_SIZE)) / 2;
let crop_w = INPUT_SIZE.min(new_w);
let crop_h = INPUT_SIZE.min(new_h);
image::imageops::crop_imm(&resized, x_offset, y_offset, crop_w, crop_h).to_image()
}
fn normalize(image: &RgbImage) -> ndarray::Array4<f32> {
let (w, h) = (image.width() as usize, image.height() as usize);
let mut tensor = ndarray::Array4::<f32>::zeros((1, 3, h, w));
const BGR_MEAN: [f32; 3] = [0.406 * 255.0, 0.456 * 255.0, 0.485 * 255.0];
const BGR_NORM: [f32; 3] = [1.0 / (0.225 * 255.0), 1.0 / (0.224 * 255.0), 1.0 / (0.229 * 255.0)];
for y in 0..h {
for x in 0..w {
let pixel = image.get_pixel(x as u32, y as u32);
let r = pixel[0] as f32;
let g = pixel[1] as f32;
let b = pixel[2] as f32;
tensor[[0, 0, y, x]] = (b - BGR_MEAN[0]) * BGR_NORM[0];
tensor[[0, 1, y, x]] = (g - BGR_MEAN[1]) * BGR_NORM[1];
tensor[[0, 2, y, x]] = (r - BGR_MEAN[2]) * BGR_NORM[2];
}
}
tensor
}