use std::io::Write;
use std::path::{Path, PathBuf};
use ort::session::Session;
use super::dictionary::Dictionary;
#[derive(Debug)]
pub enum ModelError {
Io(std::io::Error),
Download(String),
Session(String),
Dictionary(String),
ModelsMissing {
missing: Vec<PathBuf>,
model_dir: PathBuf,
},
UnpinnedFile(String),
Integrity(String),
}
impl std::fmt::Display for ModelError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "model I/O error: {e}"),
Self::Download(msg) => write!(f, "model download error: {msg}"),
Self::Session(msg) => write!(f, "session creation error: {msg}"),
Self::Dictionary(msg) => write!(f, "dictionary error: {msg}"),
Self::ModelsMissing { missing, model_dir } => write!(
f,
"OCR model files are missing from {}: {}. \
Nothing was downloaded, by design: place the weights there yourself, \
or set PaddleOcrConfig::model_source to ModelSource::Verified with a \
pinned sha256 for each file. Reference source: {}",
model_dir.display(),
missing
.iter()
.map(|p| p
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned())
.collect::<Vec<_>>()
.join(", "),
REFERENCE_MODEL_BASE_URL
),
Self::UnpinnedFile(path) => write!(
f,
"refusing to download {path}: no sha256 pinned for it. Add one to \
ModelSource::Verified::digests -- a file that cannot be verified \
must not be fetched"
),
Self::Integrity(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for ModelError {}
impl From<std::io::Error> for ModelError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
pub struct ModelSessions {
pub detection: Session,
pub recognition: Session,
pub classifier: Option<Session>,
pub dictionary: Dictionary,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DetectionModel {
V3,
V5,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Language {
English,
Latin,
Chinese,
Japanese,
Korean,
Arabic,
}
impl Language {
pub fn code(self) -> &'static str {
match self {
Self::English => "en",
Self::Latin => "latin",
Self::Chinese => "ch",
Self::Japanese => "japan",
Self::Korean => "korean",
Self::Arabic => "arabic",
}
}
fn hf_dir(self) -> &'static str {
match self {
Self::English => "languages/english",
Self::Latin => "languages/latin",
Self::Chinese => "languages/chinese",
Self::Japanese => "languages/japan",
Self::Korean => "languages/korean",
Self::Arabic => "languages/arabic",
}
}
fn local_dir(self) -> &'static str {
match self {
Self::English => "english",
Self::Latin => "latin",
Self::Chinese => "chinese",
Self::Japanese => "japan",
Self::Korean => "korean",
Self::Arabic => "arabic",
}
}
}
pub struct PaddleOcrConfig {
pub model_dir: PathBuf,
pub detection_model: DetectionModel,
pub languages: Vec<Language>,
pub use_angle_classifier: bool,
pub max_side_len: u32,
pub det_threshold: f32,
pub box_threshold: f32,
pub rec_batch_size: usize,
pub num_threads: usize,
pub model_source: ModelSource,
}
impl Default for PaddleOcrConfig {
fn default() -> Self {
Self {
model_dir: default_cache_dir(),
detection_model: DetectionModel::V3,
languages: vec![Language::Latin],
use_angle_classifier: false,
max_side_len: 960,
det_threshold: 0.3,
box_threshold: 0.6,
rec_batch_size: 8,
num_threads: 4,
model_source: ModelSource::LocalOnly,
}
}
}
fn default_cache_dir() -> PathBuf {
dirs_next::cache_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("xfa")
.join("ocr-models")
}
pub fn detection_model_path(config: &PaddleOcrConfig) -> PathBuf {
let dir = match config.detection_model {
DetectionModel::V3 => "det_v3",
DetectionModel::V5 => "det_v5",
};
config.model_dir.join(dir).join("det.onnx")
}
pub fn recognition_model_path(config: &PaddleOcrConfig) -> PathBuf {
let lang = config.languages.first().copied().unwrap_or(Language::Latin);
config.model_dir.join(lang.local_dir()).join("rec.onnx")
}
pub fn dictionary_path(config: &PaddleOcrConfig) -> PathBuf {
let lang = config.languages.first().copied().unwrap_or(Language::Latin);
config.model_dir.join(lang.local_dir()).join("dict.txt")
}
pub fn classifier_model_path(config: &PaddleOcrConfig) -> PathBuf {
config.model_dir.join("cls").join("cls.onnx")
}
pub fn models_available(config: &PaddleOcrConfig) -> bool {
let det = detection_model_path(config).exists();
let rec = recognition_model_path(config).exists();
let dict = dictionary_path(config).exists();
let cls = if config.use_angle_classifier {
classifier_model_path(config).exists()
} else {
true
};
det && rec && dict && cls
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ModelSource {
#[default]
LocalOnly,
Verified {
base_url: String,
digests: std::collections::BTreeMap<String, String>,
},
}
const MAX_MODEL_BYTES: usize = 256 * 1024 * 1024;
pub const REFERENCE_MODEL_BASE_URL: &str =
"https://huggingface.co/monkt/paddleocr-onnx/resolve/main";
fn required_files(config: &PaddleOcrConfig) -> Vec<(String, PathBuf)> {
let det_hf_dir = match config.detection_model {
DetectionModel::V3 => "detection/v3",
DetectionModel::V5 => "detection/v5",
};
let lang = config.languages.first().copied().unwrap_or(Language::Latin);
let mut files = vec![
(
format!("{det_hf_dir}/det.onnx"),
detection_model_path(config),
),
(
format!("{}/rec.onnx", lang.hf_dir()),
recognition_model_path(config),
),
(
format!("{}/dict.txt", lang.hf_dir()),
dictionary_path(config),
),
];
if config.use_angle_classifier {
files.push((
"preprocessing/cls.onnx".to_string(),
classifier_model_path(config),
));
}
files
}
pub fn ensure_models(config: &PaddleOcrConfig) -> Result<(), ModelError> {
let missing: Vec<(String, PathBuf)> = required_files(config)
.into_iter()
.filter(|(_, local)| !local.exists())
.collect();
if missing.is_empty() {
return Ok(());
}
match &config.model_source {
ModelSource::LocalOnly => Err(ModelError::ModelsMissing {
missing: missing.iter().map(|(_, p)| p.clone()).collect(),
model_dir: config.model_dir.clone(),
}),
ModelSource::Verified { base_url, digests } => {
for (hf_path, local_path) in missing {
let expected = digests
.get(&hf_path)
.ok_or_else(|| ModelError::UnpinnedFile(hf_path.clone()))?;
fetch_verified(base_url, &hf_path, expected, &local_path)?;
}
Ok(())
}
}
}
fn fetch_verified(
base_url: &str,
hf_path: &str,
expected_sha256: &str,
local_path: &Path,
) -> Result<(), ModelError> {
let url = format!("{}/{}", base_url.trim_end_matches('/'), hf_path);
let agent = ureq::Agent::new_with_defaults();
let response = agent
.get(&url)
.call()
.map_err(|e| ModelError::Download(format!("{url}: {e}")))?;
let mut reader = response.into_body().into_reader();
let mut bytes: Vec<u8> = Vec::new();
let mut buf = vec![0u8; 64 * 1024];
loop {
let n = std::io::Read::read(&mut reader, &mut buf)
.map_err(|e| ModelError::Download(format!("{url}: read error: {e}")))?;
if n == 0 {
break;
}
if bytes.len() + n > MAX_MODEL_BYTES {
return Err(ModelError::Download(format!(
"{url}: exceeds {MAX_MODEL_BYTES} bytes; refusing to continue"
)));
}
bytes.extend_from_slice(&buf[..n]);
}
crate::integrity::verify_sha256(&bytes, expected_sha256)
.map_err(|e| ModelError::Integrity(format!("{url}: {e}")))?;
if let Some(parent) = local_path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = local_path.with_extension("part");
{
let mut file = std::fs::File::create(&tmp)?;
file.write_all(&bytes)?;
file.sync_all()?;
}
std::fs::rename(&tmp, local_path)?;
Ok(())
}
pub fn load_sessions(config: &PaddleOcrConfig) -> Result<ModelSessions, ModelError> {
let detection = create_session(&detection_model_path(config), config.num_threads)?;
let recognition = create_session(&recognition_model_path(config), config.num_threads)?;
let classifier = if config.use_angle_classifier {
Some(create_session(
&classifier_model_path(config),
config.num_threads,
)?)
} else {
None
};
let dictionary = Dictionary::from_file(&dictionary_path(config))
.map_err(|e| ModelError::Dictionary(e.to_string()))?;
Ok(ModelSessions {
detection,
recognition,
classifier,
dictionary,
})
}
fn create_session(model_path: &Path, num_threads: usize) -> Result<Session, ModelError> {
let session = Session::builder()
.map_err(|e| ModelError::Session(e.to_string()))?
.with_optimization_level(ort::session::builder::GraphOptimizationLevel::Level3)
.map_err(|e| ModelError::Session(e.to_string()))?
.with_intra_threads(num_threads)
.map_err(|e| ModelError::Session(e.to_string()))?
.commit_from_file(model_path)
.map_err(|e| ModelError::Session(format!("{}: {e}", model_path.display())))?;
Ok(session)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_is_sensible() {
let config = PaddleOcrConfig::default();
assert_eq!(config.max_side_len, 960);
assert!((config.det_threshold - 0.3).abs() < f32::EPSILON);
assert_eq!(config.rec_batch_size, 8);
assert_eq!(config.num_threads, 4);
assert!(!config.use_angle_classifier);
}
#[test]
fn language_code_mapping() {
assert_eq!(Language::English.code(), "en");
assert_eq!(Language::Latin.code(), "latin");
assert_eq!(Language::Chinese.code(), "ch");
assert_eq!(Language::Japanese.code(), "japan");
assert_eq!(Language::Korean.code(), "korean");
assert_eq!(Language::Arabic.code(), "arabic");
}
#[test]
fn model_path_construction_v3() {
let config = PaddleOcrConfig {
model_dir: PathBuf::from("/tmp/models"),
detection_model: DetectionModel::V3,
languages: vec![Language::English],
..Default::default()
};
let det_path = detection_model_path(&config);
assert!(det_path.to_str().unwrap().contains("det_v3"));
assert!(det_path.to_str().unwrap().ends_with("det.onnx"));
}
#[test]
fn model_path_construction_v5() {
let config = PaddleOcrConfig {
model_dir: PathBuf::from("/tmp/models"),
detection_model: DetectionModel::V5,
languages: vec![Language::Latin],
..Default::default()
};
let det_path = detection_model_path(&config);
assert!(det_path.to_str().unwrap().contains("det_v5"));
let rec_path = recognition_model_path(&config);
assert!(rec_path.to_str().unwrap().contains("latin"));
assert!(rec_path.to_str().unwrap().ends_with("rec.onnx"));
let dict_path = dictionary_path(&config);
assert!(dict_path.to_str().unwrap().contains("latin"));
assert!(dict_path.to_str().unwrap().ends_with("dict.txt"));
}
#[test]
fn models_not_available_when_missing() {
let config = PaddleOcrConfig {
model_dir: PathBuf::from("/nonexistent/path"),
..Default::default()
};
assert!(!models_available(&config));
}
}