Skip to main content

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}