Skip to main content

ppocr_rs/
crnn_net.rs

1use ort::session::Session;
2use ort::value::Tensor;
3use ort::{inputs, session::builder::SessionBuilder};
4use std::collections::HashMap;
5
6use crate::{base_net::BaseNet, ocr_error::OcrError, ocr_result::TextLine, ocr_utils::OcrUtils};
7
8/// Singolo char dedup-survived dell'output CTC: timestep + score, indicizzato
9/// 1:1 con `text.chars()`. Esposto pub(crate) per `score_to_text_line` →
10/// `get_text_line_with_wh_ratio` → `ocr_lite::detect_once` (che trasforma le
11/// timestep range in word-box image-space).
12#[derive(Debug, Clone, Copy)]
13pub(crate) struct CtcSelection {
14    pub timestep: usize,
15    pub score:    f32,
16}
17
18/// Range timestep di una singola word, già raggruppata da
19/// `selection_to_words`. La conversione timestep → x in crop space è
20/// responsabilità del chiamante (richiede `target_w` e `T` del CRNN run).
21#[derive(Debug, Clone)]
22pub(crate) struct WordRange {
23    pub text:     String,
24    pub start_ts: usize,
25    pub end_ts:   usize,
26    pub score:    f32,
27}
28
29pub(crate) const CRNN_DST_HEIGHT: u32 = 48;
30const MEAN_VALUES: [f32; 3] = [127.5, 127.5, 127.5];
31const NORM_VALUES: [f32; 3] = [1.0 / 127.5, 1.0 / 127.5, 1.0 / 127.5];
32
33#[derive(Debug)]
34pub struct CrnnNet {
35    session: Option<Session>,
36    keys: Vec<String>,
37    input_names: Vec<String>,
38}
39
40impl BaseNet for CrnnNet {
41    fn new() -> Self {
42        Self {
43            session: None,
44            keys: Vec::new(),
45            input_names: Vec::new(),
46        }
47    }
48
49    fn set_input_names(&mut self, input_names: Vec<String>) {
50        self.input_names = input_names;
51    }
52
53    fn set_session(&mut self, session: Option<Session>) {
54        self.session = session;
55    }
56}
57
58impl CrnnNet {
59    pub fn init_model(
60        &mut self,
61        path: &str,
62        num_thread: usize,
63        builder_fn: Option<fn(SessionBuilder) -> Result<SessionBuilder, ort::Error>>,
64    ) -> Result<(), OcrError> {
65        BaseNet::init_model(self, path, num_thread, builder_fn)?;
66
67        self.keys = self.get_keys()?;
68
69        Ok(())
70    }
71
72    pub fn init_model_dict_file(
73        &mut self,
74        path: &str,
75        num_thread: usize,
76        builder_fn: Option<fn(SessionBuilder) -> Result<SessionBuilder, ort::Error>>,
77        dict_file_path: &str,
78    ) -> Result<(), OcrError> {
79        BaseNet::init_model(self, path, num_thread, builder_fn)?;
80
81        self.read_keys_from_file(dict_file_path)?;
82
83        Ok(())
84    }
85
86    pub fn init_model_from_memory(
87        &mut self,
88        model_bytes: &[u8],
89        num_thread: usize,
90        builder_fn: Option<fn(SessionBuilder) -> Result<SessionBuilder, ort::Error>>,
91    ) -> Result<(), OcrError> {
92        BaseNet::init_model_from_memory(self, model_bytes, num_thread, builder_fn)?;
93
94        self.keys = self.get_keys()?;
95
96        Ok(())
97    }
98
99    fn get_keys(&mut self) -> Result<Vec<String>, OcrError> {
100        // [downport rc.11→rc.9] in rc.11 `metadata().custom("character")`
101        // ritorna `Result<Option<String>>`; in rc.9 ritorna anche
102        // `Result<Option<String>>` ma il chain con `.expect()` × 2 lascia
103        // un Option<String> all'esterno. Doppio `.expect()` per ottenere
104        // la String.
105        let model_charater_list = self
106            .session
107            .as_ref()
108            .expect("crnn_net session not initialized")
109            .metadata()
110            .expect("crnn_net metadata not initialized")
111            .custom("character")
112            .expect("crnn_net character meta not found")
113            .expect("crnn_net character meta is None");
114
115        let mut keys = Vec::with_capacity((model_charater_list.len() as f32 / 3.9) as usize);
116
117        keys.push("#".to_string());
118
119        keys.extend(model_charater_list.split('\n').map(|s: &str| s.to_string()));
120
121        keys.push(" ".to_string());
122
123        Ok(keys)
124    }
125
126    fn read_keys_from_file(&mut self, path: &str) -> Result<(), OcrError> {
127        let content = std::fs::read_to_string(path)?;
128        let mut keys = Vec::new();
129
130        // Index 0 = CTC blank token.
131        keys.push("#".to_string());
132
133        // Ogni riga del dict corrisponde a un token del modello ONNX.
134        // NON filtrare le righe interne vuote: alcune entry del dict (es.
135        // il carattere spazio rappresentato come riga vuota in YAML/dict)
136        // hanno posizione fissa nel vocabolario — filtrarle shifta tutti i
137        // token successivi di 1, rendendo la predizione inutilizzabile.
138        // Si rimuove SOLO l'ultima riga vuota causata dal `\n` finale del
139        // file. Su Windows strip `\r` prima del confronto.
140        let mut lines: Vec<&str> = content.split('\n').collect();
141        if lines.last().map_or(false, |l| l.trim_end_matches('\r').is_empty()) {
142            lines.pop();
143        }
144        keys.extend(lines.iter().map(|s| s.trim_end_matches('\r').to_string()));
145
146        // Indice finale = token space (use_space_char=True nel training).
147        keys.push(" ".to_string());
148
149        self.keys = keys;
150        Ok(())
151    }
152
153    pub fn get_text_lines(
154        &mut self,
155        part_imgs: &[image::RgbImage],
156        angle_rollback_records: &HashMap<usize, image::RgbImage>,
157        angle_rollback_threshold: f32,
158    ) -> Result<Vec<TextLine>, OcrError> {
159        let lines_with_words = self.get_text_lines_with_word_ranges(
160            part_imgs,
161            angle_rollback_records,
162            angle_rollback_threshold,
163        )?;
164        Ok(lines_with_words.into_iter().map(|(line, _, _, _, _)| line).collect())
165    }
166
167    /// Variante che ritorna anche i word-range CTC + le dimensioni del crop
168    /// e il `target_w` usato in input al CRNN — informazioni necessarie a
169    /// `ocr_lite::detect_once` per mappare timestep → image-space.
170    ///
171    /// Tuple: `(TextLine, Vec<WordRange>, crop_size, target_w, num_timesteps)`.
172    /// `crop_size` è la dimensione dell'immagine crop ricevuta in input
173    /// (può essere quella ruotata 90° in caso `crop_h >= crop_w*3/2` —
174    /// vedi `OcrUtils::get_rotate_crop_image`).
175    pub(crate) fn get_text_lines_with_word_ranges(
176        &mut self,
177        part_imgs: &[image::RgbImage],
178        angle_rollback_records: &HashMap<usize, image::RgbImage>,
179        angle_rollback_threshold: f32,
180    ) -> Result<Vec<(TextLine, Vec<WordRange>, (u32, u32), usize, usize)>, OcrError> {
181        let mut out = Vec::with_capacity(part_imgs.len());
182
183        // Compute max width/height ratio across all images in the batch.
184        let base_wh_ratio = 320.0 / CRNN_DST_HEIGHT as f32;
185        let max_wh_ratio = part_imgs
186            .iter()
187            .map(|img| img.width() as f32 / img.height().max(1) as f32)
188            .fold(base_wh_ratio, f32::max);
189
190        for (index, img) in part_imgs.iter().enumerate() {
191            let mut entry = self.recognize_one(img, max_wh_ratio)?;
192            // Angle rollback: se confidence troppo bassa e abbiamo l'originale,
193            // ritriamo con la versione non ruotata.
194            if entry.0.text_score.is_nan() || entry.0.text_score < angle_rollback_threshold {
195                if let Some(rollback_img) = angle_rollback_records.get(&index) {
196                    entry = self.recognize_one(rollback_img, max_wh_ratio)?;
197                }
198            }
199            out.push(entry);
200        }
201        Ok(out)
202    }
203
204    /// Helper: esegue CRNN su una singola line crop e ritorna `TextLine` +
205    /// metadata word-level. La `TextLine.words` qui resta sempre vuota; il
206    /// caller (`ocr_lite::detect_once`) la popola dopo aver fatto l'inverse
207    /// warp dei `WordRange`.
208    fn recognize_one(
209        &mut self,
210        img_src: &image::RgbImage,
211        max_wh_ratio: f32,
212    ) -> Result<(TextLine, Vec<WordRange>, (u32, u32), usize, usize), OcrError> {
213        let crop_size = (img_src.width(), img_src.height());
214        let (line, selection, target_w, t) =
215            self.get_text_line_with_wh_ratio(img_src, max_wh_ratio)?;
216        let words = selection_to_word_ranges(&line.text, &selection);
217        Ok((line, words, crop_size, target_w, t))
218    }
219
220    /// Recognize a single text line image with an optional max width/height ratio
221    /// for padding. When `max_wh_ratio > 0`, the normalized tensor is zero-padded
222    /// on the right to `(48 * max_wh_ratio)` pixels. This matches Python PaddleOCR's
223    /// `resize_norm_img` which pads to a fixed batch width.
224    ///
225    /// Ritorna `(TextLine, selection, target_w, T)`:
226    /// - `TextLine.words` resta vuoto (popolato dal caller dopo inverse-warp).
227    /// - `selection` è `Vec<CtcSelection>` indicizzato 1:1 con `text.chars()`.
228    /// - `target_w` = larghezza in pixel dell'input al CRNN (post-padding).
229    /// - `T` = numero di timestep dell'output CRNN (height del tensor 1D).
230    fn get_text_line_with_wh_ratio(
231        &mut self,
232        img_src: &image::RgbImage,
233        max_wh_ratio: f32,
234    ) -> Result<(TextLine, Vec<CtcSelection>, usize, usize), OcrError> {
235        let Some(session) = &mut self.session else {
236            return Err(OcrError::SessionNotInitialized);
237        };
238
239        let scale = CRNN_DST_HEIGHT as f32 / img_src.height() as f32;
240        let resized_w = (img_src.width() as f32 * scale).ceil() as u32;
241
242        let src_resize = image::imageops::resize(
243            img_src,
244            resized_w,
245            CRNN_DST_HEIGHT,
246            image::imageops::FilterType::Triangle,
247        );
248
249        let input_tensors =
250            OcrUtils::substract_mean_normalize(&src_resize, &MEAN_VALUES, &NORM_VALUES);
251
252        // Zero-pad to the target width if max_wh_ratio is specified.
253        // Python PaddleOCR pads recognition inputs to (48 * max_wh_ratio) with zeros.
254        // Zero in normalized space = (0/127.5 - 1.0) = -1.0, but Python uses actual
255        // 0.0 in its padded tensor (the padding is applied AFTER normalization).
256        let target_w_raw = (CRNN_DST_HEIGHT as f32 * max_wh_ratio) as u32;
257        let target_w = target_w_raw.max(resized_w);
258        let input_tensors = if max_wh_ratio > 0.0 && target_w > resized_w {
259            let shape = input_tensors.shape();
260            let c = shape[1];
261            let h = shape[2];
262            let mut padded = ndarray::Array4::<f32>::zeros((1, c, h, target_w as usize));
263            padded
264                .slice_mut(ndarray::s![.., .., .., ..resized_w as usize])
265                .assign(&input_tensors);
266            padded
267        } else {
268            input_tensors
269        };
270        // Effective input width seen by CRNN (post-padding).
271        let effective_target_w = if max_wh_ratio > 0.0 { target_w } else { resized_w };
272
273        let input_tensors = Tensor::from_array(input_tensors)?;
274
275        // [downport rc.11→rc.9] inputs! macro ritorna Result in rc.9
276        let outputs = session.run(inputs![self.input_names[0].clone() => input_tensors]?)?;
277
278        let (_, red_data) = outputs.iter().next().unwrap();
279
280        // [downport rc.11→rc.9] rc.11 ritorna (shape, &[T]); rc.9 ritorna ArrayViewD<T>.
281        let (shape_vec, src_data) = crate::compat::tensor_extract_with_shape_f32(&red_data)?;
282        // shape = (1, T, V): timestep × vocab.
283        let timesteps = shape_vec[1] as usize;
284        let vocab     = shape_vec[2] as usize;
285
286        let (line, selection) = Self::score_to_text_line(&src_data, timesteps, vocab, &self.keys)?;
287        Ok((line, selection, effective_target_w as usize, timesteps))
288    }
289
290    /// Decoder CTC greedy: per ogni timestep prende l'argmax sul vocab,
291    /// applica dedup (skip blank=0 + skip ripetizioni dello stesso index
292    /// rispetto al precedente), accumula `text` e `selection` (un entry
293    /// per ogni char dedup-survived).
294    ///
295    /// **Identità**: `selection.len() == text.chars().count()`. Vale solo
296    /// quando ogni `keys[i]` è un singolo grapheme — vero per i modelli
297    /// latin di PaddleOCR (dict_latin = 1 char per linea).
298    fn score_to_text_line(
299        output_data: &[f32],
300        timesteps:   usize,
301        vocab:       usize,
302        keys:        &[String],
303    ) -> Result<(TextLine, Vec<CtcSelection>), OcrError> {
304        let mut text_line = TextLine::default();
305        let mut selection: Vec<CtcSelection> = Vec::with_capacity(timesteps);
306        let mut last_index = 0usize;
307        let mut text_score_sum = 0.0f32;
308        let mut text_score_count = 0usize;
309
310        for i in 0..timesteps {
311            let start = i * vocab;
312            let stop  = (i + 1) * vocab;
313            let slice = &output_data[start..stop.min(output_data.len())];
314
315            let (max_index, max_value) = slice
316                .iter()
317                .enumerate()
318                .fold((0usize, f32::MIN), |(max_idx, max_val), (idx, &val)| {
319                    if val > max_val { (idx, val) } else { (max_idx, max_val) }
320                });
321
322            if max_index > 0 && max_index < keys.len() && !(i > 0 && max_index == last_index) {
323                text_line.text.push_str(&keys[max_index]);
324                selection.push(CtcSelection { timestep: i, score: max_value });
325                text_score_sum += max_value;
326                text_score_count += 1;
327            }
328            last_index = max_index;
329        }
330
331        text_line.text_score = if text_score_count > 0 {
332            text_score_sum / text_score_count as f32
333        } else {
334            0.0
335        };
336        Ok((text_line, selection))
337    }
338}
339
340/// Group consecutive alphanumeric chars dell'output CTC in word ranges.
341/// I delimiter (whitespace, punctuation) chiudono il word corrente.
342///
343/// Convenzione (allineata a `tools/infer/predict_rec.py::get_word_info`):
344/// - Char alphanumeric (Unicode-aware via [`char::is_alphanumeric`]) →
345///   appartengono al word corrente. Lettere accentate EU incluse.
346/// - Whitespace/punctuation → delimitatori, ignorati e chiudono il word.
347///
348/// **Identità**: `text.chars().count() == selection.len()` (vedi
349/// `score_to_text_line`).
350pub(crate) fn selection_to_word_ranges(text: &str, selection: &[CtcSelection]) -> Vec<WordRange> {
351    let chars: Vec<char> = text.chars().collect();
352    debug_assert_eq!(chars.len(), selection.len(),
353        "selection deve essere indicizzato 1:1 con text.chars()");
354
355    let mut words = Vec::new();
356    let mut buf_text  = String::new();
357    let mut buf_first: Option<usize> = None; // primo char-index del word in selection
358    let mut buf_last:  usize = 0;
359    let mut buf_score_sum = 0.0f32;
360    let mut buf_score_n   = 0usize;
361
362    let flush = |words: &mut Vec<WordRange>,
363                 buf_text:  &mut String,
364                 buf_first: &mut Option<usize>,
365                 buf_last:  usize,
366                 score_sum: f32,
367                 score_n:   usize| {
368        if let Some(first) = *buf_first {
369            if !buf_text.is_empty() {
370                words.push(WordRange {
371                    text:     std::mem::take(buf_text),
372                    start_ts: selection[first].timestep,
373                    end_ts:   selection[buf_last].timestep,
374                    score:    if score_n > 0 { score_sum / score_n as f32 } else { 0.0 },
375                });
376            }
377            *buf_first = None;
378        }
379    };
380
381    for (i, ch) in chars.iter().enumerate() {
382        if ch.is_alphanumeric() {
383            buf_text.push(*ch);
384            if buf_first.is_none() { buf_first = Some(i); }
385            buf_last = i;
386            buf_score_sum += selection[i].score;
387            buf_score_n   += 1;
388        } else {
389            flush(&mut words, &mut buf_text, &mut buf_first, buf_last, buf_score_sum, buf_score_n);
390            buf_score_sum = 0.0;
391            buf_score_n   = 0;
392        }
393    }
394    flush(&mut words, &mut buf_text, &mut buf_first, buf_last, buf_score_sum, buf_score_n);
395    words
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    fn sel(timesteps: &[(usize, f32)]) -> Vec<CtcSelection> {
403        timesteps.iter().map(|&(t, s)| CtcSelection { timestep: t, score: s }).collect()
404    }
405
406    #[test]
407    fn word_grouping_simple() {
408        // "Hello world" — 11 chars (incluso lo spazio).
409        let text = "Hello world";
410        let selection = sel(&[
411            (0, 0.9), (2, 0.9), (3, 0.9), (4, 0.9), (5, 0.9),  // "Hello"
412            (7, 0.0),                                          // " "
413            (10, 0.9), (11, 0.9), (12, 0.9), (13, 0.9), (14, 0.9), // "world"
414        ]);
415        let words = selection_to_word_ranges(text, &selection);
416        assert_eq!(words.len(), 2);
417        assert_eq!(words[0].text, "Hello");
418        assert_eq!(words[0].start_ts, 0);
419        assert_eq!(words[0].end_ts,   5);
420        assert_eq!(words[1].text, "world");
421        assert_eq!(words[1].start_ts, 10);
422        assert_eq!(words[1].end_ts,   14);
423    }
424
425    #[test]
426    fn word_grouping_punctuation() {
427        // "Hello, world!" — la "," chiude "Hello", il "!" non apre nulla.
428        let text = "Hello, world!";
429        let selection = sel(&[
430            (0, 0.9), (1, 0.9), (2, 0.9), (3, 0.9), (4, 0.9),  // "Hello"
431            (5, 0.5),                                          // ","
432            (6, 0.0),                                          // " "
433            (7, 0.9), (8, 0.9), (9, 0.9), (10, 0.9), (11, 0.9),// "world"
434            (12, 0.5),                                         // "!"
435        ]);
436        let words = selection_to_word_ranges(text, &selection);
437        assert_eq!(words.len(), 2);
438        assert_eq!(words[0].text, "Hello");
439        assert_eq!(words[1].text, "world");
440    }
441
442    #[test]
443    fn word_grouping_accented() {
444        // Caratteri accentati EU: "città è grande".
445        let text = "città è grande";
446        let n = text.chars().count();
447        let selection: Vec<CtcSelection> = (0..n)
448            .map(|i| CtcSelection { timestep: i, score: 0.9 })
449            .collect();
450        let words = selection_to_word_ranges(text, &selection);
451        assert_eq!(words.len(), 3);
452        assert_eq!(words[0].text, "città");
453        assert_eq!(words[1].text, "è");
454        assert_eq!(words[2].text, "grande");
455    }
456
457    #[test]
458    fn word_grouping_empty_selection() {
459        let words = selection_to_word_ranges("", &[]);
460        assert!(words.is_empty());
461    }
462
463    #[test]
464    fn word_grouping_only_punctuation() {
465        let text = "...!?";
466        let selection = sel(&[(0, 0.5), (1, 0.5), (2, 0.5), (3, 0.5), (4, 0.5)]);
467        let words = selection_to_word_ranges(text, &selection);
468        assert!(words.is_empty(), "solo punctuation → nessun word");
469    }
470}