Skip to main content

oar_ocr_core/processors/
decode.rs

1//! Text decoding utilities for OCR (Optical Character Recognition) systems.
2//!
3//! This module provides implementations for decoding text recognition results,
4//! particularly focused on CTC (Connectionist Temporal Classification) decoding.
5//! It includes structures and methods for converting model predictions into
6//! readable text strings with confidence scores.
7
8use regex::Regex;
9use std::collections::HashMap;
10use std::sync::LazyLock;
11
12/// Decoded batch outputs along with positional metadata.
13pub type PositionedDecodeResult = (
14    Vec<String>,
15    Vec<f32>,
16    Vec<Vec<f32>>,
17    Vec<Vec<usize>>,
18    Vec<usize>,
19);
20
21static ALPHANUMERIC_REGEX: LazyLock<Regex> = LazyLock::new(|| {
22    Regex::new(r"[a-zA-Z0-9 :*./%+-]").expect("static regex: alphanumeric decoder pattern")
23});
24
25/// Argmax over a 1-D prediction row, returning `(index, value)`.
26///
27/// Contiguous rows (the common row-major case for the per-timestep logits) are
28/// routed through the SIMD kernel in [`crate::processors::simd`]; a scalar scan
29/// handles non-contiguous views. Tie-breaking matches [`Iterator::max_by`]
30/// (the last maximal index wins), so decoded output is unchanged.
31#[inline]
32fn argmax_row(row: ndarray::ArrayView1<f32>) -> Option<(usize, f32)> {
33    match row.as_slice() {
34        Some(slice) => crate::processors::simd::argmax(slice),
35        None => row
36            .iter()
37            .copied()
38            .enumerate()
39            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)),
40    }
41}
42
43/// A base decoder for text recognition that handles character mapping and basic decoding operations.
44///
45/// This struct is responsible for converting model predictions into readable text strings.
46/// It maintains a character dictionary for mapping indices to characters and provides
47/// methods for decoding text with optional duplicate removal and confidence scoring.
48///
49/// # Fields
50/// * `reverse` - Flag indicating whether to reverse the text output
51/// * `dict` - A mapping from characters to their indices in the character list
52/// * `character` - A list of characters in the vocabulary, indexed by their position
53pub struct BaseRecLabelDecode {
54    reverse: bool,
55    dict: HashMap<char, usize>,
56    character: Vec<char>,
57}
58
59impl BaseRecLabelDecode {
60    /// Creates a new `BaseRecLabelDecode` instance.
61    ///
62    /// # Arguments
63    /// * `character_str` - An optional string containing the character vocabulary.
64    ///   If None, a default alphanumeric character set is used.
65    /// * `use_space_char` - Whether to include a space character in the vocabulary.
66    ///
67    /// # Returns
68    /// A new `BaseRecLabelDecode` instance.
69    pub fn new(character_str: Option<&str>, use_space_char: bool) -> Self {
70        let mut character_list: Vec<char> = if let Some(chars) = character_str {
71            chars.chars().collect()
72        } else {
73            "0123456789abcdefghijklmnopqrstuvwxyz".chars().collect()
74        };
75
76        if use_space_char {
77            character_list.push(' ');
78        }
79
80        character_list = Self::add_special_char(character_list);
81
82        let mut dict = HashMap::new();
83        for (i, &char) in character_list.iter().enumerate() {
84            dict.insert(char, i);
85        }
86
87        Self {
88            reverse: false,
89            dict,
90            character: character_list,
91        }
92    }
93
94    /// Creates a new `BaseRecLabelDecode` instance from a list of strings.
95    ///
96    /// # Arguments
97    /// * `character_list` - An optional slice of strings containing the character vocabulary.
98    ///   Only the first character of each string is used. If None, a default alphanumeric
99    ///   character set is used.
100    /// * `use_space_char` - Whether to include a space character in the vocabulary.
101    ///
102    /// # Returns
103    /// A new `BaseRecLabelDecode` instance.
104    pub fn from_string_list(character_list: Option<&[String]>, use_space_char: bool) -> Self {
105        let mut chars: Vec<char> = if let Some(list) = character_list {
106            list.iter().filter_map(|s| s.chars().next()).collect()
107        } else {
108            "0123456789abcdefghijklmnopqrstuvwxyz".chars().collect()
109        };
110
111        if use_space_char {
112            chars.push(' ');
113        }
114
115        chars = Self::add_special_char(chars);
116
117        let mut dict = HashMap::new();
118        for (i, &char) in chars.iter().enumerate() {
119            dict.insert(char, i);
120        }
121
122        Self {
123            reverse: false,
124            dict,
125            character: chars,
126        }
127    }
128
129    /// Reverses the alphanumeric parts of a string while keeping non-alphanumeric parts in place.
130    ///
131    /// # Arguments
132    /// * `pred` - The input string to process.
133    ///
134    /// # Returns
135    /// A new string with alphanumeric parts reversed.
136    fn pred_reverse(&self, pred: &str) -> String {
137        let mut pred_re = Vec::new();
138        let mut c_current = String::new();
139
140        for c in pred.chars() {
141            if !ALPHANUMERIC_REGEX.is_match(&c.to_string()) {
142                if !c_current.is_empty() {
143                    pred_re.push(c_current.clone());
144                    c_current.clear();
145                }
146                pred_re.push(c.to_string());
147            } else {
148                c_current.push(c);
149            }
150        }
151
152        if !c_current.is_empty() {
153            pred_re.push(c_current);
154        }
155
156        pred_re.reverse();
157        pred_re.join("")
158    }
159
160    /// Adds special characters to the character list.
161    ///
162    /// This is a placeholder method that currently just returns the input list unchanged.
163    /// It can be overridden in subclasses to add special characters.
164    ///
165    /// # Arguments
166    /// * `character_list` - The input character list.
167    ///
168    /// # Returns
169    /// The character list with any special characters added.
170    fn add_special_char(character_list: Vec<char>) -> Vec<char> {
171        character_list
172    }
173
174    /// Gets a list of token indices that should be ignored during decoding.
175    ///
176    /// # Returns
177    /// A vector containing the indices of tokens to ignore.
178    fn get_ignored_tokens(&self) -> Vec<usize> {
179        vec![self.get_blank_idx()]
180    }
181
182    /// Decodes model predictions into text strings with confidence scores.
183    ///
184    /// # Arguments
185    /// * `text_index` - A slice of vectors containing the predicted character indices.
186    /// * `text_prob` - An optional slice of vectors containing the prediction probabilities.
187    /// * `is_remove_duplicate` - Whether to remove consecutive duplicate characters.
188    ///
189    /// # Returns
190    /// A vector of tuples, each containing a decoded text string and its confidence score.
191    pub fn decode(
192        &self,
193        text_index: &[Vec<usize>],
194        text_prob: Option<&[Vec<f32>]>,
195        is_remove_duplicate: bool,
196    ) -> Vec<(String, f32)> {
197        let mut result_list = Vec::new();
198        let ignored_tokens = self.get_ignored_tokens();
199
200        for (batch_idx, indices) in text_index.iter().enumerate() {
201            let mut selection = vec![true; indices.len()];
202
203            if is_remove_duplicate && indices.len() > 1 {
204                for i in 1..indices.len() {
205                    if indices[i] == indices[i - 1] {
206                        selection[i] = false;
207                    }
208                }
209            }
210
211            for &ignored_token in &ignored_tokens {
212                for (i, &idx) in indices.iter().enumerate() {
213                    if idx == ignored_token {
214                        selection[i] = false;
215                    }
216                }
217            }
218
219            let char_list: Vec<char> = indices
220                .iter()
221                .enumerate()
222                .filter(|(i, _)| selection[*i])
223                .filter_map(|(_, &text_id)| self.character.get(text_id).copied())
224                .collect();
225
226            let conf_list: Vec<f32> = if let Some(probs) = text_prob {
227                if batch_idx < probs.len() {
228                    probs[batch_idx]
229                        .iter()
230                        .enumerate()
231                        .filter(|(i, _)| *i < selection.len() && selection[*i])
232                        .map(|(_, &prob)| prob)
233                        .collect()
234                } else {
235                    vec![1.0; char_list.len()]
236                }
237            } else {
238                vec![1.0; char_list.len()]
239            };
240
241            let conf_list = if conf_list.is_empty() {
242                vec![0.0]
243            } else {
244                conf_list
245            };
246
247            let mut text: String = char_list.iter().collect();
248
249            if self.reverse {
250                text = self.pred_reverse(&text);
251            }
252
253            let mean_conf = conf_list.iter().sum::<f32>() / conf_list.len() as f32;
254            result_list.push((text, mean_conf));
255        }
256
257        result_list
258    }
259
260    /// Applies the decoder to a tensor of model predictions.
261    ///
262    /// # Arguments
263    /// * `pred` - A 3D tensor containing the model predictions.
264    ///
265    /// # Returns
266    /// A tuple containing:
267    /// * A vector of decoded text strings
268    /// * A vector of confidence scores for each text string
269    pub fn apply(&self, pred: &ndarray::Array3<f32>) -> (Vec<String>, Vec<f32>) {
270        if pred.is_empty() {
271            return (Vec::new(), Vec::new());
272        }
273
274        let batch_size = pred.shape()[0];
275        let mut all_texts = Vec::new();
276        let mut all_scores = Vec::new();
277
278        for batch_idx in 0..batch_size {
279            let preds = pred.index_axis(ndarray::Axis(0), batch_idx);
280
281            let mut sequence_idx = Vec::new();
282            let mut sequence_prob = Vec::new();
283
284            for row in preds.outer_iter() {
285                if let Some((idx, prob)) = argmax_row(row) {
286                    sequence_idx.push(idx);
287                    sequence_prob.push(prob);
288                } else {
289                    sequence_idx.push(0);
290                    sequence_prob.push(0.0);
291                }
292            }
293
294            let text = self.decode(&[sequence_idx], Some(&[sequence_prob]), true);
295
296            for (t, score) in text {
297                all_texts.push(t);
298                all_scores.push(score);
299            }
300        }
301
302        (all_texts, all_scores)
303    }
304
305    /// Gets the index of the blank token.
306    ///
307    /// # Returns
308    /// The index of the blank token (always 0 in this base implementation).
309    fn get_blank_idx(&self) -> usize {
310        0
311    }
312}
313
314/// A decoder for CTC (Connectionist Temporal Classification) based text recognition models.
315///
316/// This struct extends `BaseRecLabelDecode` to provide specialized decoding for CTC models,
317/// which include a blank token that needs to be handled specially during decoding.
318///
319/// # Fields
320/// * `base` - The base decoder that handles character mapping and basic decoding operations
321/// * `blank_index` - The index of the blank token in the character vocabulary
322pub struct CTCLabelDecode {
323    base: BaseRecLabelDecode,
324    blank_index: usize,
325}
326
327impl std::fmt::Debug for CTCLabelDecode {
328    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329        f.debug_struct("CTCLabelDecode")
330            .field("character_count", &self.base.character.len())
331            .field("reverse", &self.base.reverse)
332            .finish()
333    }
334}
335
336impl CTCLabelDecode {
337    /// Creates a new `CTCLabelDecode` instance.
338    ///
339    /// # Arguments
340    /// * `character_list` - An optional string containing the character vocabulary.
341    ///   If None, a default alphanumeric character set is used.
342    /// * `use_space_char` - Whether to include a space character in the vocabulary.
343    ///
344    /// # Returns
345    /// A new `CTCLabelDecode` instance.
346    pub fn new(character_list: Option<&str>, use_space_char: bool) -> Self {
347        let mut base = BaseRecLabelDecode::new(character_list, use_space_char);
348
349        // Use null char for blank to distinguish from actual space
350        let mut new_character = vec!['\0'];
351        new_character.extend(base.character);
352
353        let mut new_dict = HashMap::new();
354        for (i, &char) in new_character.iter().enumerate() {
355            new_dict.insert(char, i);
356        }
357
358        base.character = new_character;
359        base.dict = new_dict;
360
361        let blank_index = 0;
362
363        Self { base, blank_index }
364    }
365
366    /// Creates a new `CTCLabelDecode` instance from a list of strings.
367    ///
368    /// # Arguments
369    /// * `character_list` - An optional slice of strings containing the character vocabulary.
370    ///   Only the first character of each string is used. If None, a default alphanumeric
371    ///   character set is used.
372    /// * `use_space_char` - Whether to include a space character in the vocabulary.
373    /// * `has_explicit_blank` - Whether the character list already includes a blank token.
374    ///
375    /// # Returns
376    /// A new `CTCLabelDecode` instance.
377    pub fn from_string_list(
378        character_list: Option<&[String]>,
379        use_space_char: bool,
380        has_explicit_blank: bool,
381    ) -> Self {
382        if has_explicit_blank {
383            let base = BaseRecLabelDecode::from_string_list(character_list, use_space_char);
384            Self {
385                base,
386                blank_index: 0,
387            }
388        } else {
389            let mut base = BaseRecLabelDecode::from_string_list(character_list, use_space_char);
390
391            // Use null char for blank to distinguish from actual space
392            let mut new_character = vec!['\0'];
393            new_character.extend(base.character);
394
395            let mut new_dict = HashMap::new();
396            for (i, &char) in new_character.iter().enumerate() {
397                new_dict.insert(char, i);
398            }
399
400            base.character = new_character;
401            base.dict = new_dict;
402
403            Self {
404                base,
405                blank_index: 0,
406            }
407        }
408    }
409
410    /// Gets the index of the blank token.
411    ///
412    /// # Returns
413    /// The index of the blank token.
414    pub fn get_blank_index(&self) -> usize {
415        self.blank_index
416    }
417
418    /// Gets the character list used by this decoder.
419    ///
420    /// # Returns
421    /// A slice containing the characters in the vocabulary.
422    pub fn get_character_list(&self) -> &[char] {
423        &self.base.character
424    }
425
426    /// Gets the number of characters in the vocabulary.
427    ///
428    /// # Returns
429    /// The number of characters in the vocabulary.
430    pub fn get_character_count(&self) -> usize {
431        self.base.character.len()
432    }
433
434    /// Applies the CTC decoder to a tensor of model predictions with character position tracking.
435    ///
436    /// This method handles the special requirements of CTC decoding and additionally tracks
437    /// the timestep positions of each character for word box generation.
438    ///
439    /// # Arguments
440    /// * `pred` - A 3D tensor containing the model predictions.
441    ///
442    /// # Returns
443    /// A tuple containing:
444    /// * A vector of decoded text strings
445    /// * A vector of confidence scores for each text string
446    /// * A vector of character positions (normalized 0.0-1.0) for each text string
447    /// * A vector of column indices for each character in each text string
448    /// * A vector of sequence lengths (total columns) for each text string
449    pub fn apply_with_positions(&self, pred: &ndarray::Array3<f32>) -> PositionedDecodeResult {
450        if pred.is_empty() {
451            return (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
452        }
453
454        let batch_size = pred.shape()[0];
455        let mut all_texts = Vec::new();
456        let mut all_scores = Vec::new();
457        let mut all_positions = Vec::new();
458        let mut all_col_indices = Vec::new();
459        let mut all_seq_lengths = Vec::new();
460
461        for batch_idx in 0..batch_size {
462            let preds = pred.index_axis(ndarray::Axis(0), batch_idx);
463            let seq_len = preds.shape()[0] as f32;
464
465            let mut sequence_idx = Vec::new();
466            let mut sequence_prob = Vec::new();
467            let mut sequence_timesteps = Vec::new();
468
469            for (timestep, row) in preds.outer_iter().enumerate() {
470                if let Some((idx, prob)) = argmax_row(row) {
471                    sequence_idx.push(idx);
472                    sequence_prob.push(prob);
473                    sequence_timesteps.push(timestep);
474                } else {
475                    sequence_idx.push(self.blank_index);
476                    sequence_prob.push(0.0);
477                    sequence_timesteps.push(timestep);
478                }
479            }
480
481            let mut filtered_idx = Vec::new();
482            let mut filtered_prob = Vec::new();
483            let mut filtered_timesteps = Vec::new();
484            let mut selection = vec![true; sequence_idx.len()];
485
486            // Remove consecutive duplicates
487            if sequence_idx.len() > 1 {
488                for i in 1..sequence_idx.len() {
489                    if sequence_idx[i] == sequence_idx[i - 1] {
490                        selection[i] = false;
491                    }
492                }
493            }
494
495            // Remove blanks
496            for (i, &idx) in sequence_idx.iter().enumerate() {
497                if idx == self.blank_index {
498                    selection[i] = false;
499                }
500            }
501
502            // Collect filtered results
503            for (i, &idx) in sequence_idx.iter().enumerate() {
504                if selection[i] {
505                    filtered_idx.push(idx);
506                    filtered_prob.push(sequence_prob[i]);
507                    filtered_timesteps.push(sequence_timesteps[i]);
508                }
509            }
510
511            let char_list: Vec<char> = filtered_idx
512                .iter()
513                .filter_map(|&text_id| self.base.character.get(text_id).copied())
514                .collect();
515
516            let conf_list = if filtered_prob.is_empty() {
517                vec![0.0]
518            } else {
519                filtered_prob
520            };
521
522            // Calculate normalized character positions (0.0 to 1.0)
523            let char_positions: Vec<f32> = filtered_timesteps
524                .iter()
525                .map(|&timestep| timestep as f32 / seq_len)
526                .collect();
527
528            // Store column indices (raw timesteps) for accurate word box generation
529            let col_indices: Vec<usize> = filtered_timesteps.clone();
530
531            let text: String = char_list.iter().collect();
532            let mean_conf = conf_list.iter().sum::<f32>() / conf_list.len() as f32;
533
534            all_texts.push(text);
535            all_scores.push(mean_conf);
536            all_positions.push(char_positions);
537            all_col_indices.push(col_indices);
538            all_seq_lengths.push(seq_len as usize);
539        }
540
541        (
542            all_texts,
543            all_scores,
544            all_positions,
545            all_col_indices,
546            all_seq_lengths,
547        )
548    }
549
550    /// Applies the CTC decoder to a tensor of model predictions.
551    ///
552    /// This method handles the special requirements of CTC decoding:
553    /// 1. Removing blank tokens
554    /// 2. Removing consecutive duplicate characters
555    /// 3. Converting indices to characters
556    /// 4. Calculating confidence scores
557    ///
558    /// # Arguments
559    /// * `pred` - A 3D tensor containing the model predictions.
560    ///
561    /// # Returns
562    /// A tuple containing:
563    /// * A vector of decoded text strings
564    /// * A vector of confidence scores for each text string
565    pub fn apply(&self, pred: &ndarray::Array3<f32>) -> (Vec<String>, Vec<f32>) {
566        if pred.is_empty() {
567            return (Vec::new(), Vec::new());
568        }
569
570        let batch_size = pred.shape()[0];
571        let mut all_texts = Vec::new();
572        let mut all_scores = Vec::new();
573        let mut batches_with_text = 0;
574
575        for batch_idx in 0..batch_size {
576            let preds = pred.index_axis(ndarray::Axis(0), batch_idx);
577
578            let mut sequence_idx = Vec::new();
579            let mut sequence_prob = Vec::new();
580
581            for row in preds.outer_iter() {
582                if let Some((idx, prob)) = argmax_row(row) {
583                    sequence_idx.push(idx);
584                    sequence_prob.push(prob);
585                } else {
586                    sequence_idx.push(self.blank_index);
587                    sequence_prob.push(0.0);
588                }
589            }
590
591            let mut filtered_idx = Vec::new();
592            let mut filtered_prob = Vec::new();
593            let mut selection = vec![true; sequence_idx.len()];
594
595            if sequence_idx.len() > 1 {
596                for i in 1..sequence_idx.len() {
597                    if sequence_idx[i] == sequence_idx[i - 1] {
598                        selection[i] = false;
599                    }
600                }
601            }
602
603            for (i, &idx) in sequence_idx.iter().enumerate() {
604                if idx == self.blank_index {
605                    selection[i] = false;
606                }
607            }
608
609            for (i, &idx) in sequence_idx.iter().enumerate() {
610                if selection[i] {
611                    filtered_idx.push(idx);
612                    filtered_prob.push(sequence_prob[i]);
613                }
614            }
615
616            let char_list: Vec<char> = filtered_idx
617                .iter()
618                .filter_map(|&text_id| self.base.character.get(text_id).copied())
619                .collect();
620
621            let conf_list = if filtered_prob.is_empty() {
622                vec![0.0]
623            } else {
624                filtered_prob
625            };
626
627            let text: String = char_list.iter().collect();
628            let mean_conf = conf_list.iter().sum::<f32>() / conf_list.len() as f32;
629
630            if !text.is_empty() {
631                batches_with_text += 1;
632            }
633
634            all_texts.push(text);
635            all_scores.push(mean_conf);
636        }
637
638        // Log summary of decoding results
639        tracing::debug!(
640            "CTC decode summary: batch_size={}, batches_with_text={}, empty_batches={}",
641            batch_size,
642            batches_with_text,
643            batch_size - batches_with_text
644        );
645
646        (all_texts, all_scores)
647    }
648}