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(|×tep| 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}