ppocr_rs/model_hub.rs
1//! Download e cache locale dei modelli ONNX PP-OCRv6 da HuggingFace.
2//!
3//! I modelli vengono scaricati una sola volta e salvati in una directory di
4//! cache persistente. Il dizionario dei caratteri (`dict.txt`) viene estratto
5//! dall'`inference.yml` del modello rec — contiene il **vocabolario effettivo
6//! del modello**, che differisce per tier:
7//!
8//! - tiny / small: ~6 904 caratteri (CH + Latin esteso)
9//! - medium: ~18 000+ caratteri (multilingual completo)
10//!
11//! Il `ppocrv6_dict.txt` del repo GitHub (18 708 righe) è il dizionario di
12//! training, non quello di inference — usarlo con il modello tiny produrrebbe
13//! output garbage (mismatch dimensione output layer).
14//!
15//! ## Normalizzazione confermata
16//!
17//! `resize_norm_img` in PaddleOCR 3.7 (`ppocr/data/imaug/rec_img_aug.py`):
18//! ```python
19//! resized_image = resized_image / 255
20//! resized_image -= 0.5
21//! resized_image /= 0.5 # ≡ mean=127.5, std=127.5
22//! ```
23//! Il codice attuale (`crnn_net.rs`: `MEAN=[127.5,127.5,127.5]`,
24//! `NORM=[1/127.5,...]`) è **compatibile con PP-OCRv6 senza modifiche**.
25//!
26//! ## Feature `fetch-models`
27//!
28//! Il download HTTP richiede `--features fetch-models`. Senza la feature,
29//! `ensure()` ritorna `OcrError::ModelHubError` se il file non è già in cache.
30
31use std::io::Write;
32use std::path::{Path, PathBuf};
33
34use crate::ocr_error::OcrError;
35
36const HF_BASE: &str = "https://huggingface.co/PaddlePaddle";
37
38// ─── Modelli PP-StructureV3 opzionali ─────────────────────────────────────────
39
40/// Modelli PP-StructureV3 / PP-DocLayoutV3 scaricabili singolarmente.
41///
42/// Usa [`ModelHub::ensure_single`] per ottenere il path ONNX locale.
43///
44/// | Variante | Dimensione | Funzione |
45/// |------------------------|------------|-------------------------------------------------|
46/// | `TableCls` | ~7 MB | Classifica tabella: wired vs wireless |
47/// | `TableStructureWired` | ~351 MB | SLANeXt struttura tabelle con bordi |
48/// | `TableStructureWireless`| ~300 MB | SLANeXt struttura tabelle senza bordi |
49/// | `CellDetWireless` | ~120 MB | RT-DETR-L cell det su tabelle wireless |
50/// | `DocOrientation` | ~7 MB | Orientamento documento (0/90/180/270°) |
51/// | `DocUnwarp` | ~150 MB | Raddrizzamento prospettico UVDoc |
52/// | `FormulaRec` | ~800 MB | PP-FormulaNet-plus-L (LaTeX output) |
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum PpStructureModel {
55 /// Classificatore wired vs wireless — `PP-LCNet_x1_0_table_cls_onnx`.
56 TableCls,
57 /// SLANeXt structure recognition (tabelle con bordi) — `SLANeXt_wired_onnx`.
58 TableStructureWired,
59 /// SLANeXt structure recognition (tabelle senza bordi) — `SLANeXt_wireless_onnx`.
60 TableStructureWireless,
61 /// RT-DETR-L cell detector (variante wireless) — `RT-DETR-L_wireless_table_cell_det_onnx`.
62 CellDetWireless,
63 /// Classificatore orientamento documento — `PP-LCNet_x1_0_doc_ori_onnx`.
64 DocOrientation,
65 /// Document unwarping UVDoc — `UVDoc_onnx`.
66 DocUnwarp,
67 /// Riconoscimento formule LaTeX — `PP-FormulaNet_plus-L_onnx`.
68 FormulaRec,
69}
70
71impl PpStructureModel {
72 fn hf_repo(self) -> &'static str {
73 match self {
74 Self::TableCls => "PP-LCNet_x1_0_table_cls_onnx",
75 Self::TableStructureWired => "SLANeXt_wired_onnx",
76 Self::TableStructureWireless => "SLANeXt_wireless_onnx",
77 Self::CellDetWireless => "RT-DETR-L_wireless_table_cell_det_onnx",
78 Self::DocOrientation => "PP-LCNet_x1_0_doc_ori_onnx",
79 Self::DocUnwarp => "UVDoc_onnx",
80 Self::FormulaRec => "PP-FormulaNet_plus-L_onnx",
81 }
82 }
83
84 fn dir_name(self) -> &'static str {
85 match self {
86 Self::TableCls => "table_cls",
87 Self::TableStructureWired => "slanext_wired",
88 Self::TableStructureWireless => "slanext_wireless",
89 Self::CellDetWireless => "cell_det_wireless",
90 Self::DocOrientation => "doc_orientation",
91 Self::DocUnwarp => "doc_unwarp",
92 Self::FormulaRec => "formula_rec",
93 }
94 }
95
96 /// Ritorna `true` se questo modello ha anche un file `inference.yml`
97 /// che contiene il vocabolario (come i modelli SLANeXt).
98 fn has_yml(self) -> bool {
99 matches!(self, Self::TableStructureWired | Self::TableStructureWireless)
100 }
101
102 /// Ritorna `true` se questo modello ha un `tokenizer.json` HuggingFace.
103 fn has_tokenizer(self) -> bool {
104 matches!(self, Self::FormulaRec)
105 }
106}
107
108/// Path ai file di un modello PP-StructureV3 scaricato via [`ModelHub::ensure_single`].
109#[derive(Debug, Clone)]
110pub struct StructureModelPaths {
111 /// Path al file ONNX del modello.
112 pub onnx: PathBuf,
113 /// Path al dizionario token (solo per SLANeXt; `None` per gli altri modelli).
114 pub dict_txt: Option<PathBuf>,
115 /// `inference.yml` grezzo del modello (solo se `has_yml()` = true).
116 pub yml: Option<PathBuf>,
117 /// `tokenizer.json` HuggingFace BPE (solo per `FormulaRec`).
118 pub tokenizer_json: Option<PathBuf>,
119}
120
121// ─── Versioni supportate ──────────────────────────────────────────────────────
122
123/// Versione del modello PP-OCRv6 da scaricare.
124///
125/// | Variante | det.onnx | rec.onnx | Vocab | Totale |
126/// |-----------|----------|----------|--------|--------|
127/// | `V6Tiny` | 1.8 MB | 4.5 MB | ~6 904 | ~6 MB |
128/// | `V6Small` | ~6 MB | ~20 MB | ~6 904 | ~26 MB |
129/// | `V6Medium`| 62 MB | 77 MB | ~18k+ | ~139 MB|
130///
131/// Per il primo test su ARM64 Snapdragon X Elite si consiglia `V6Tiny`.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum PpOcrVersion {
134 V6Tiny,
135 V6Small,
136 V6Medium,
137}
138
139impl PpOcrVersion {
140 fn det_repo(self) -> &'static str {
141 match self {
142 Self::V6Tiny => "PP-OCRv6_tiny_det_onnx",
143 Self::V6Small => "PP-OCRv6_small_det_onnx",
144 Self::V6Medium => "PP-OCRv6_medium_det_onnx",
145 }
146 }
147
148 fn rec_repo(self) -> &'static str {
149 match self {
150 Self::V6Tiny => "PP-OCRv6_tiny_rec_onnx",
151 Self::V6Small => "PP-OCRv6_small_rec_onnx",
152 Self::V6Medium => "PP-OCRv6_medium_rec_onnx",
153 }
154 }
155
156 fn dir_name(self) -> &'static str {
157 match self {
158 Self::V6Tiny => "pp_ocrv6_tiny",
159 Self::V6Small => "pp_ocrv6_small",
160 Self::V6Medium => "pp_ocrv6_medium",
161 }
162 }
163}
164
165// ─── Risultato del download ───────────────────────────────────────────────────
166
167/// Percorsi ai file locali (post-`ensure`). Pronti per essere passati a
168/// [`OcrLite::init_models_with_dict`].
169///
170/// `dict_txt` contiene il vocabolario **effettivo** del modello, estratto da
171/// `rec_inference.yml` — una voce per riga, senza blank né space (aggiunti
172/// automaticamente da `CrnnNet::read_keys_from_file`).
173#[derive(Debug, Clone)]
174pub struct ModelPaths {
175 pub det_onnx: PathBuf,
176 pub rec_onnx: PathBuf,
177 /// Dizionario estratto dall'`inference.yml` del modello rec.
178 /// Usa questo con `init_models_with_dict`, NON il `ppocrv6_dict.txt`
179 /// generico (che è il dict di training, non quello del modello).
180 pub dict_txt: PathBuf,
181 /// `inference.yml` grezzo del modello rec — per ispezione e debug.
182 pub rec_yml: PathBuf,
183}
184
185// ─── ModelHub ─────────────────────────────────────────────────────────────────
186
187/// Hub per il download e la cache locale dei modelli ONNX.
188///
189/// Tutti i file finiscono in `<cache_dir>/<version>/`:
190/// - `det.onnx` — detection model
191/// - `rec.onnx` — recognition model
192/// - `rec_inference.yml` — config rec (contiene character_dict inline)
193/// - `dict.txt` — caratteri estratti dal YML (una riga per char)
194pub struct ModelHub {
195 cache_dir: PathBuf,
196}
197
198impl ModelHub {
199 /// Crea un hub con la directory di cache specificata.
200 pub fn new(cache_dir: impl Into<PathBuf>) -> Self {
201 Self { cache_dir: cache_dir.into() }
202 }
203
204 /// Crea un hub con la directory di cache di default del sistema:
205 /// - Windows: `%LOCALAPPDATA%\ppocr-rs\models\`
206 /// - macOS/Linux: `$HOME/.cache/ppocr-rs/models/`
207 pub fn with_default_cache() -> Result<Self, OcrError> {
208 let base = Self::default_cache_dir()?;
209 Ok(Self::new(base.join("models")))
210 }
211
212 /// Assicura che i modelli per la versione richiesta siano presenti.
213 /// Scarica da HuggingFace se mancanti (richiede feature `fetch-models`).
214 ///
215 /// Il download è **bloccante** — esegui su un thread secondario in GUI
216 /// o in runtime async.
217 pub fn ensure(&self, version: PpOcrVersion) -> Result<ModelPaths, OcrError> {
218 let dir = self.cache_dir.join(version.dir_name());
219 std::fs::create_dir_all(&dir)?;
220
221 let det_path = dir.join("det.onnx");
222 let rec_path = dir.join("rec.onnx");
223 let rec_yml = dir.join("rec_inference.yml");
224 let dict_path = dir.join("dict.txt");
225
226 let det_url = format!("{}/{}/resolve/main/inference.onnx", HF_BASE, version.det_repo());
227 let rec_url = format!("{}/{}/resolve/main/inference.onnx", HF_BASE, version.rec_repo());
228 let rec_yml_url = format!("{}/{}/resolve/main/inference.yml", HF_BASE, version.rec_repo());
229
230 if !is_cached(&det_path) {
231 eprintln!("[ppocr-rs] download det → {}", det_path.display());
232 fetch_file(&det_url, &det_path)?;
233 }
234 if !is_cached(&rec_path) {
235 eprintln!("[ppocr-rs] download rec → {}", rec_path.display());
236 fetch_file(&rec_url, &rec_path)?;
237 }
238 if !is_cached(&rec_yml) {
239 eprintln!("[ppocr-rs] download rec yml → {}", rec_yml.display());
240 fetch_file(&rec_yml_url, &rec_yml)?;
241 }
242 // Il dict viene estratto dal YML — non scaricato da GitHub.
243 // In questo modo il vocabolario corrisponde esattamente all'output
244 // layer del modello ONNX, indipendentemente dal tier (tiny/medium/…).
245 if !is_cached(&dict_path) {
246 eprintln!("[ppocr-rs] estrai dict da yml → {}", dict_path.display());
247 extract_dict_from_yml(&rec_yml, &dict_path)?;
248 }
249
250 Ok(ModelPaths { det_onnx: det_path, rec_onnx: rec_path, dict_txt: dict_path, rec_yml })
251 }
252
253 /// Ritorna la directory di cache usata da questo hub.
254 pub fn cache_dir(&self) -> &Path {
255 &self.cache_dir
256 }
257
258 /// Scarica (o riusa dalla cache) un singolo modello PP-StructureV3.
259 ///
260 /// Tutti i file finiscono in `<cache_dir>/<model.dir_name()>/`:
261 /// - `inference.onnx` — sempre presente
262 /// - `inference.yml` — solo se il modello ha vocabolario inline
263 /// - `dict.txt` — estratto dall'yml (per SLANeXt)
264 ///
265 /// ## Esempio
266 ///
267 /// ```no_run
268 /// use ppocr_rs::{ModelHub, PpStructureModel};
269 /// let hub = ModelHub::with_default_cache().unwrap();
270 /// let paths = hub.ensure_single(PpStructureModel::TableCls).unwrap();
271 /// // usa paths.onnx con TableTypeClassifier::from_path(paths.onnx)
272 /// ```
273 pub fn ensure_single(&self, model: PpStructureModel) -> Result<StructureModelPaths, OcrError> {
274 let dir = self.cache_dir.join(model.dir_name());
275 std::fs::create_dir_all(&dir)?;
276
277 let onnx_path = dir.join("inference.onnx");
278 let onnx_url = format!("{}/{}/resolve/main/inference.onnx", HF_BASE, model.hf_repo());
279
280 if !is_cached(&onnx_path) {
281 eprintln!("[ppocr-rs] download {} → {}", model.hf_repo(), onnx_path.display());
282 fetch_file(&onnx_url, &onnx_path)?;
283 }
284
285 let (yml_out, dict_out) = if model.has_yml() {
286 let yml_path = dir.join("inference.yml");
287 let dict_path = dir.join("dict.txt");
288 let yml_url = format!("{}/{}/resolve/main/inference.yml", HF_BASE, model.hf_repo());
289
290 if !is_cached(&yml_path) {
291 eprintln!("[ppocr-rs] download yml → {}", yml_path.display());
292 // Non fatale: alcuni modelli HuggingFace non hanno yml
293 let _ = fetch_file(&yml_url, &yml_path);
294 }
295 // Estrai dict solo se lo yml esiste ed è leggibile
296 if is_cached(&yml_path) && !is_cached(&dict_path) {
297 eprintln!("[ppocr-rs] estrai dict SLANeXt → {}", dict_path.display());
298 if let Err(e) = extract_dict_from_yml(&yml_path, &dict_path) {
299 eprintln!("[ppocr-rs] warn: dict extraction fallita: {e}");
300 }
301 }
302 (
303 Some(yml_path).filter(|p| is_cached(p)),
304 Some(dict_path).filter(|p| is_cached(p)),
305 )
306 } else {
307 (None, None)
308 };
309
310 let tokenizer_out = if model.has_tokenizer() {
311 let tok_path = dir.join("tokenizer.json");
312 let tok_url = format!("{}/{}/resolve/main/tokenizer.json", HF_BASE, model.hf_repo());
313 if !is_cached(&tok_path) {
314 eprintln!("[ppocr-rs] download tokenizer → {}", tok_path.display());
315 // Non fatale: se manca, FormulaRecognizer userà il fallback
316 let _ = fetch_file(&tok_url, &tok_path);
317 }
318 Some(tok_path).filter(|p| is_cached(p))
319 } else {
320 None
321 };
322
323 Ok(StructureModelPaths {
324 onnx: onnx_path,
325 dict_txt: dict_out,
326 yml: yml_out,
327 tokenizer_json: tokenizer_out,
328 })
329 }
330
331 fn default_cache_dir() -> Result<PathBuf, OcrError> {
332 #[cfg(windows)]
333 if let Some(v) = std::env::var_os("LOCALAPPDATA") {
334 return Ok(PathBuf::from(v).join("ppocr-rs"));
335 }
336 if let Some(v) = std::env::var_os("XDG_CACHE_HOME") {
337 return Ok(PathBuf::from(v).join("ppocr-rs"));
338 }
339 if let Some(v) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
340 return Ok(PathBuf::from(v).join(".cache").join("ppocr-rs"));
341 }
342 Err(OcrError::ModelHubError(
343 "impossibile determinare la cache dir: HOME/LOCALAPPDATA non impostato".into(),
344 ))
345 }
346}
347
348// ─── Estrazione dict dal YML ──────────────────────────────────────────────────
349
350/// Estrae la sezione `PostProcess.character_dict` dall'inference.yml e la
351/// scrive come file di testo (una entry per riga), compatibile con
352/// `CrnnNet::read_keys_from_file`.
353///
354/// Il dict nel YML è il vocabolario **effettivo** dell'output layer ONNX.
355/// Per il tiny model: 6 904 voci; per il medium: ~18 000+.
356fn extract_dict_from_yml(yml_path: &Path, dict_path: &Path) -> Result<(), OcrError> {
357 let content = std::fs::read_to_string(yml_path)?;
358
359 let mut chars: Vec<String> = Vec::new();
360 let mut in_dict = false;
361
362 for line in content.lines() {
363 if !in_dict {
364 // Cerca ` character_dict:` (con spazi iniziali arbitrari)
365 if line.trim_start().starts_with("character_dict:") {
366 in_dict = true;
367 }
368 continue;
369 }
370
371 // Ogni entry ha forma ` - 'x'`, ` - x` o ` - ` (valore nullo/vuoto).
372 // Il singolo apice è `''''` (YAML single-quoted con escape ''→').
373 // Alcune entry serializzate da PaddleOCR/yaml.dump appaiono vuote
374 // (carattere alla posizione 616 nel tiny). Vengono scritte come riga
375 // vuota — `read_keys_from_file` NON le filtra più, preservando la
376 // posizione corretta dei token successivi nel vocabolario ONNX.
377 let trimmed = line.trim_start();
378 if let Some(rest) = trimmed.strip_prefix("- ") {
379 let rest_trimmed = rest.trim_end_matches('\r');
380 let ch = if rest_trimmed.starts_with('\'') && rest_trimmed.ends_with('\'') && rest_trimmed.len() >= 2 {
381 // Single-quoted YAML: strip delimitatori esterni, unescape ''→'
382 rest_trimmed[1..rest_trimmed.len() - 1].replace("''", "'")
383 } else {
384 rest_trimmed.to_string()
385 };
386 chars.push(ch);
387 } else if !trimmed.is_empty() && !trimmed.starts_with('-') {
388 // Fine della lista (nuova chiave YAML)
389 break;
390 }
391 }
392
393 if chars.is_empty() {
394 return Err(OcrError::ModelHubError(
395 "character_dict non trovato in rec_inference.yml".into(),
396 ));
397 }
398
399 let tmp_ext = format!("tmp_{:?}", std::thread::current().id())
400 .replace(['(', ')'], "");
401 let tmp = dict_path.with_extension(&tmp_ext);
402
403 {
404 let mut f = std::fs::File::create(&tmp)?;
405 for ch in &chars {
406 writeln!(f, "{ch}")?;
407 }
408 }
409
410 if let Err(e) = std::fs::rename(&tmp, dict_path) {
411 std::fs::remove_file(&tmp).ok();
412 if !is_cached(dict_path) {
413 return Err(OcrError::ModelHubError(format!("rename dict: {e}")));
414 }
415 }
416
417 eprintln!("[ppocr-rs] dict estratto: {} voci", chars.len());
418 Ok(())
419}
420
421// ─── HTTP download ────────────────────────────────────────────────────────────
422
423fn is_cached(path: &Path) -> bool {
424 path.metadata().map(|m| m.len() > 0).unwrap_or(false)
425}
426
427#[cfg(feature = "fetch-models")]
428fn fetch_file(url: &str, dest: &Path) -> Result<(), OcrError> {
429 let tid = format!("{:?}", std::thread::current().id())
430 .replace(['(', ')'], "");
431 let tmp = dest.with_extension(format!("tmp_{tid}"));
432
433 let response = ureq::get(url)
434 .call()
435 .map_err(|e| OcrError::ModelHubError(format!("GET {url}: {e}")))?;
436
437 let mut reader = response.into_reader();
438 let mut file = std::fs::File::create(&tmp)?;
439
440 let mut buf = [0u8; 65536];
441 let mut total = 0u64;
442 loop {
443 let n = reader.read(&mut buf)?;
444 if n == 0 { break; }
445 file.write_all(&buf[..n])?;
446 total += n as u64;
447 }
448 drop(file);
449
450 if total == 0 {
451 std::fs::remove_file(&tmp).ok();
452 return Err(OcrError::ModelHubError(format!("risposta vuota da {url}")));
453 }
454
455 if let Err(e) = std::fs::rename(&tmp, dest) {
456 std::fs::remove_file(&tmp).ok();
457 if !is_cached(dest) {
458 return Err(OcrError::ModelHubError(format!("rename tmp→dest: {e}")));
459 }
460 return Ok(());
461 }
462
463 eprintln!("[ppocr-rs] salvati {:.1} MB", total as f64 / 1_048_576.0);
464 Ok(())
465}
466
467#[cfg(not(feature = "fetch-models"))]
468fn fetch_file(url: &str, _dest: &Path) -> Result<(), OcrError> {
469 Err(OcrError::ModelHubError(format!(
470 "download richiede --features fetch-models. URL: {url}"
471 )))
472}