Skip to main content

ocr_rs/
rec.rs

1//! Text Recognition Model
2//!
3//! Provides text recognition functionality based on PaddleOCR recognition models
4
5use image::{DynamicImage, RgbImage};
6use imageproc::geometric_transformations::Projection;
7use imageproc::point::Point;
8use ndarray::{Array4, ArrayD, ArrayViewD, Axis};
9use rayon::prelude::*;
10use std::{borrow::Cow, path::Path};
11
12use crate::error::{OcrError, OcrResult};
13use crate::mnn::{InferenceConfig, InferenceEngine};
14use crate::postprocess::TextBox;
15use crate::preprocess::{preprocess_for_rec, NormalizeParams};
16
17/// Recognition result
18#[derive(Debug, Clone)]
19pub struct RecognitionResult {
20    /// Recognized text
21    pub text: String,
22    /// Confidence score (0.0 - 1.0)
23    pub confidence: f32,
24    /// Confidence score for each character
25    pub char_scores: Vec<(char, f32)>,
26}
27
28impl RecognitionResult {
29    /// Create a new recognition result
30    pub fn new(text: String, confidence: f32, char_scores: Vec<(char, f32)>) -> Self {
31        Self {
32            text,
33            confidence,
34            char_scores,
35        }
36    }
37
38    /// Check if the result is valid (confidence above threshold)
39    pub fn is_valid(&self, threshold: f32) -> bool {
40        self.confidence >= threshold
41    }
42}
43
44/// Recognition options
45#[derive(Debug, Clone)]
46pub struct RecOptions {
47    /// Target height (recognition model input height)
48    pub target_height: u32,
49    /// Minimum confidence threshold (characters below this value will be filtered)
50    pub min_score: f32,
51    /// Minimum confidence threshold for punctuation
52    pub punct_min_score: f32,
53    /// Batch size
54    pub batch_size: usize,
55    /// Whether to enable batch processing
56    pub enable_batch: bool,
57}
58
59impl Default for RecOptions {
60    fn default() -> Self {
61        Self {
62            target_height: 48,
63            min_score: 0.3, // Lower threshold, model output is raw logit
64            punct_min_score: 0.1,
65            batch_size: 8,
66            enable_batch: true,
67        }
68    }
69}
70
71impl RecOptions {
72    /// Create new recognition options
73    pub fn new() -> Self {
74        Self::default()
75    }
76
77    /// Set target height
78    pub fn with_target_height(mut self, height: u32) -> Self {
79        self.target_height = height;
80        self
81    }
82
83    /// Set minimum confidence
84    pub fn with_min_score(mut self, score: f32) -> Self {
85        self.min_score = score;
86        self
87    }
88
89    /// Set punctuation minimum confidence
90    pub fn with_punct_min_score(mut self, score: f32) -> Self {
91        self.punct_min_score = score;
92        self
93    }
94
95    /// Set batch size
96    pub fn with_batch_size(mut self, size: usize) -> Self {
97        self.batch_size = size;
98        self
99    }
100
101    /// Enable/disable batch processing
102    pub fn with_batch(mut self, enable: bool) -> Self {
103        self.enable_batch = enable;
104        self
105    }
106}
107
108/// Text recognition model
109pub struct RecModel {
110    engine: InferenceEngine,
111    /// Character set (index to character mapping)
112    charset: Vec<char>,
113    options: RecOptions,
114    normalize_params: NormalizeParams,
115}
116
117/// Common punctuation marks
118const PUNCTUATIONS: [char; 49] = [
119    ',', '.', '!', '?', ';', ':', '"', '\'', '(', ')', '[', ']', '{', '}', '-', '_', '/', '\\',
120    '|', '@', '#', '$', '%', '&', '*', '+', '=', '~', ',', '。', '!', '?', ';', ':', '、',
121    '「', '」', '『', '』', '(', ')', '【', '】', '《', '》', '—', '…', '·', '~',
122];
123
124impl RecModel {
125    /// Create recognizer from model file and charset file
126    ///
127    /// # Parameters
128    /// - `model_path`: Model file path (.mnn format)
129    /// - `charset_path`: Charset file path (one character per line)
130    /// - `config`: Optional inference config
131    pub fn from_file(
132        model_path: impl AsRef<Path>,
133        charset_path: impl AsRef<Path>,
134        config: Option<InferenceConfig>,
135    ) -> OcrResult<Self> {
136        let engine = InferenceEngine::from_file(model_path, config)?;
137        let charset = Self::load_charset_from_file(charset_path)?;
138
139        Ok(Self {
140            engine,
141            charset,
142            options: RecOptions::default(),
143            normalize_params: NormalizeParams::paddle_rec(),
144        })
145    }
146
147    /// Create recognizer from model bytes and charset file
148    pub fn from_bytes(
149        model_bytes: &[u8],
150        charset_path: impl AsRef<Path>,
151        config: Option<InferenceConfig>,
152    ) -> OcrResult<Self> {
153        let engine = InferenceEngine::from_buffer(model_bytes, config)?;
154        let charset = Self::load_charset_from_file(charset_path)?;
155
156        Ok(Self {
157            engine,
158            charset,
159            options: RecOptions::default(),
160            normalize_params: NormalizeParams::paddle_rec(),
161        })
162    }
163
164    /// Create recognizer from model bytes and charset bytes
165    pub fn from_bytes_with_charset(
166        model_bytes: &[u8],
167        charset_bytes: &[u8],
168        config: Option<InferenceConfig>,
169    ) -> OcrResult<Self> {
170        let engine = InferenceEngine::from_buffer(model_bytes, config)?;
171        let charset = Self::parse_charset(charset_bytes)?;
172
173        Ok(Self {
174            engine,
175            charset,
176            options: RecOptions::default(),
177            normalize_params: NormalizeParams::paddle_rec(),
178        })
179    }
180
181    /// Load charset from file
182    fn load_charset_from_file(path: impl AsRef<Path>) -> OcrResult<Vec<char>> {
183        let content = std::fs::read_to_string(path)?;
184        Self::parse_charset(content.as_bytes())
185    }
186
187    /// Parse charset data
188    fn parse_charset(data: &[u8]) -> OcrResult<Vec<char>> {
189        let content = std::str::from_utf8(data)
190            .map_err(|e| OcrError::CharsetError(format!("UTF-8 decode error: {}", e)))?;
191
192        // Charset format: one character per line
193        // Add space at beginning and end as blank and padding
194        let mut charset: Vec<char> = vec![' ']; // blank token at start
195
196        for ch in content.chars() {
197            if ch != '\n' && ch != '\r' {
198                charset.push(ch);
199            }
200        }
201
202        charset.push(' '); // padding token at end
203
204        if charset.len() < 3 {
205            return Err(OcrError::CharsetError("Charset too small".to_string()));
206        }
207
208        Ok(charset)
209    }
210
211    /// Set recognition options
212    pub fn with_options(mut self, options: RecOptions) -> Self {
213        self.options = options;
214        self
215    }
216
217    /// Get current recognition options
218    pub fn options(&self) -> &RecOptions {
219        &self.options
220    }
221
222    /// Modify recognition options
223    pub fn options_mut(&mut self) -> &mut RecOptions {
224        &mut self.options
225    }
226
227    /// Get charset size
228    pub fn charset_size(&self) -> usize {
229        self.charset.len()
230    }
231
232    /// Recognize a single image
233    ///
234    /// # Parameters
235    /// - `image`: Input image (text line image)
236    ///
237    /// # Returns
238    /// Recognition result
239    pub fn recognize(&self, image: &DynamicImage) -> OcrResult<RecognitionResult> {
240        // Preprocess
241        let input = preprocess_for_rec(image, self.options.target_height, &self.normalize_params)?;
242
243        // Inference (using dynamic shape)
244        let output = self.engine.run_dynamic(input.view().into_dyn())?;
245
246        // Decode
247        self.decode_output_view(output.view())
248    }
249
250    /// Recognize a single image, return text only
251    pub fn recognize_text(&self, image: &DynamicImage) -> OcrResult<String> {
252        let result = self.recognize(image)?;
253        Ok(result.text)
254    }
255
256    /// Batch recognize images
257    ///
258    /// # Parameters
259    /// - `images`: List of input images
260    ///
261    /// # Returns
262    /// List of recognition results
263    pub fn recognize_batch(&self, images: &[DynamicImage]) -> OcrResult<Vec<RecognitionResult>> {
264        if images.is_empty() {
265            return Ok(Vec::new());
266        }
267
268        // For small number of images, process individually
269        if images.len() <= 2 || !self.options.enable_batch {
270            return images.iter().map(|img| self.recognize(img)).collect();
271        }
272
273        // Batch processing
274        let mut results = Vec::with_capacity(images.len());
275
276        for chunk in images.chunks(self.options.batch_size) {
277            let batch_results = self.recognize_batch_internal(chunk)?;
278            results.extend(batch_results);
279        }
280
281        Ok(results)
282    }
283
284    /// Batch recognize images (borrowed version, avoid cloning)
285    ///
286    /// # Parameters
287    /// - `images`: List of input image references
288    ///
289    /// # Returns
290    /// List of recognition results
291    pub fn recognize_batch_ref(
292        &self,
293        images: &[&DynamicImage],
294    ) -> OcrResult<Vec<RecognitionResult>> {
295        if images.is_empty() {
296            return Ok(Vec::new());
297        }
298
299        // For small number of images, process individually
300        if images.len() <= 2 || !self.options.enable_batch {
301            return images.iter().map(|img| self.recognize(img)).collect();
302        }
303
304        // Batch processing
305        let mut results = Vec::with_capacity(images.len());
306
307        for chunk in images.chunks(self.options.batch_size) {
308            // Dereference and convert to Vec<DynamicImage>
309            let chunk_owned: Vec<DynamicImage> = chunk.iter().map(|img| (*img).clone()).collect();
310            let batch_results = self.recognize_batch_internal(&chunk_owned)?;
311            results.extend(batch_results);
312        }
313
314        Ok(results)
315    }
316
317    pub(crate) fn recognize_regions(
318        &self,
319        image: &DynamicImage,
320        boxes: &[TextBox],
321    ) -> OcrResult<Vec<RecognitionResult>> {
322        if boxes.is_empty() {
323            return Ok(Vec::new());
324        }
325
326        let source = image.to_rgb8();
327        let mut results = Vec::with_capacity(boxes.len());
328        let batch_size = self.options.batch_size.max(1);
329
330        for chunk in boxes.chunks(batch_size) {
331            let batch_input = self.preprocess_regions_batch(&source, chunk)?;
332            let batch_output = self.engine.run_dynamic(batch_input.view().into_dyn())?;
333
334            let shape = batch_output.shape();
335            if shape.len() != 3 {
336                return Err(OcrError::PostprocessError(format!(
337                    "Region batch inference output shape error: {:?}",
338                    shape
339                )));
340            }
341
342            for i in 0..shape[0] {
343                let sample_output = batch_output.index_axis(Axis(0), i).into_dyn();
344                results.push(self.decode_output_view(sample_output)?);
345            }
346        }
347
348        Ok(results)
349    }
350
351    pub(crate) fn recognize_regions_exact_parallel(
352        &self,
353        image: &DynamicImage,
354        boxes: &[TextBox],
355    ) -> OcrResult<Vec<RecognitionResult>> {
356        if boxes.is_empty() {
357            return Ok(Vec::new());
358        }
359
360        let source = image.to_rgb8();
361        boxes
362            .par_iter()
363            .map(|text_box| {
364                let input =
365                    self.preprocess_regions_batch(&source, std::slice::from_ref(text_box))?;
366                let output = self.engine.run_dynamic(input.view().into_dyn())?;
367                self.decode_output_view(output.view())
368            })
369            .collect()
370    }
371
372    fn preprocess_regions_batch(
373        &self,
374        source: &RgbImage,
375        boxes: &[TextBox],
376    ) -> OcrResult<Array4<f32>> {
377        if boxes.is_empty() {
378            return Ok(Array4::<f32>::zeros((
379                0,
380                3,
381                self.options.target_height as usize,
382                0,
383            )));
384        }
385
386        if self.options.target_height == 0 {
387            return Err(OcrError::InvalidParameter(
388                "Recognition target height must be greater than 0".into(),
389            ));
390        }
391
392        let target_height = self.options.target_height;
393        let target_widths = boxes
394            .iter()
395            .map(|text_box| region_target_width(text_box, target_height))
396            .collect::<Vec<_>>();
397        let max_width = target_widths.iter().copied().max().unwrap_or(1) as usize;
398        let batch_size = boxes.len();
399        let target_height_usize = target_height as usize;
400        let sample_size = 3 * target_height_usize * max_width;
401        let plane_size = target_height_usize * max_width;
402
403        let mut batch = Array4::<f32>::zeros((batch_size, 3, target_height_usize, max_width));
404        let data = batch
405            .as_slice_mut()
406            .expect("Array4 created by zeros should be contiguous");
407        let scales = [
408            1.0 / (255.0 * self.normalize_params.std[0]),
409            1.0 / (255.0 * self.normalize_params.std[1]),
410            1.0 / (255.0 * self.normalize_params.std[2]),
411        ];
412        let offsets = [
413            -self.normalize_params.mean[0] / self.normalize_params.std[0],
414            -self.normalize_params.mean[1] / self.normalize_params.std[1],
415            -self.normalize_params.mean[2] / self.normalize_params.std[2],
416        ];
417
418        for (i, (text_box, &target_width)) in boxes.iter().zip(target_widths.iter()).enumerate() {
419            let projection =
420                target_to_source_projection(source, text_box, target_width, target_height)
421                    .ok_or_else(|| {
422                        OcrError::PreprocessError(format!(
423                            "Failed to render recognition region: {:?}",
424                            text_box.rect
425                        ))
426                    })?;
427            let target_width = target_width as usize;
428            let sample_offset = i * sample_size;
429
430            write_projected_region_to_tensor(
431                source,
432                projection,
433                target_width,
434                target_height_usize,
435                max_width,
436                sample_offset,
437                plane_size,
438                data,
439                &scales,
440                &offsets,
441            );
442        }
443
444        Ok(batch)
445    }
446
447    /// Internal batch recognition
448    fn recognize_batch_internal(
449        &self,
450        images: &[DynamicImage],
451    ) -> OcrResult<Vec<RecognitionResult>> {
452        if images.is_empty() {
453            return Ok(Vec::new());
454        }
455
456        // If only one image, process individually
457        if images.len() == 1 {
458            return Ok(vec![self.recognize(&images[0])?]);
459        }
460
461        // Batch preprocessing
462        let batch_input = crate::preprocess::preprocess_batch_for_rec(
463            images,
464            self.options.target_height,
465            &self.normalize_params,
466        )?;
467
468        // Batch inference
469        let batch_output = self.engine.run_dynamic(batch_input.view().into_dyn())?;
470
471        // Decode output for each sample
472        let shape = batch_output.shape();
473        if shape.len() != 3 {
474            return Err(OcrError::PostprocessError(format!(
475                "Batch inference output shape error: {:?}",
476                shape
477            )));
478        }
479
480        let batch_size = shape[0];
481        let mut results = Vec::with_capacity(batch_size);
482
483        for i in 0..batch_size {
484            let sample_output = batch_output.index_axis(Axis(0), i).into_dyn();
485            let result = self.decode_output_view(sample_output)?;
486            results.push(result);
487        }
488
489        Ok(results)
490    }
491
492    fn decode_output_view(&self, output: ArrayViewD<'_, f32>) -> OcrResult<RecognitionResult> {
493        let shape = output.shape();
494        let output_data = match output.as_slice_memory_order() {
495            Some(slice) => Cow::Borrowed(slice),
496            None => Cow::Owned(output.iter().copied().collect()),
497        };
498
499        // Output shape should be [batch, seq_len, num_classes] or [seq_len, num_classes]
500        let (seq_len, num_classes) = if shape.len() == 3 {
501            (shape[1], shape[2])
502        } else if shape.len() == 2 {
503            (shape[0], shape[1])
504        } else {
505            return Err(OcrError::PostprocessError(format!(
506                "Invalid output shape: {:?}",
507                shape
508            )));
509        };
510
511        if num_classes == 0 {
512            return Err(OcrError::PostprocessError(
513                "Invalid output shape with zero classes".into(),
514            ));
515        }
516
517        // CTC decoding
518        let mut char_scores = Vec::with_capacity(seq_len.min(32));
519        let mut text = String::new();
520        let mut score_sum = 0.0f32;
521        let mut prev_idx = 0usize;
522
523        for t in 0..seq_len {
524            // Find character with maximum probability at current time step
525            let start = t * num_classes;
526            let end = start + num_classes;
527            let probs = &output_data[start..end];
528
529            let mut max_idx = 0usize;
530            let mut max_prob = f32::NEG_INFINITY;
531            for (idx, &prob) in probs.iter().enumerate() {
532                if prob > max_prob {
533                    max_idx = idx;
534                    max_prob = prob;
535                }
536            }
537
538            // CTC decoding rule: skip blank (index 0) and duplicate characters
539            if max_idx != 0 && max_idx != prev_idx && max_idx < self.charset.len() {
540                let ch = self.charset[max_idx];
541
542                // Use raw logit value as confidence (model output is already softmax probability)
543                // For large character sets, softmax scores can be very small, so use max_prob directly
544                let score = max_prob;
545
546                // Only filter out very low confidence characters
547                let threshold = if Self::is_punctuation(ch) {
548                    self.options.punct_min_score
549                } else {
550                    self.options.min_score
551                };
552
553                if score >= threshold {
554                    text.push(ch);
555                    score_sum += score;
556                    char_scores.push((ch, score));
557                }
558            }
559
560            prev_idx = max_idx;
561        }
562
563        // Calculate average confidence
564        let confidence = if char_scores.is_empty() {
565            0.0
566        } else {
567            score_sum / char_scores.len() as f32
568        };
569
570        Ok(RecognitionResult::new(text, confidence, char_scores))
571    }
572
573    /// Check if character is punctuation
574    fn is_punctuation(ch: char) -> bool {
575        PUNCTUATIONS.contains(&ch)
576    }
577}
578
579fn region_target_width(text_box: &TextBox, target_height: u32) -> u32 {
580    let (width, height) = region_dimensions(text_box);
581    ((width / height.max(1.0)) * target_height as f32)
582        .round()
583        .max(2.0) as u32
584}
585
586fn target_to_source_projection(
587    source: &RgbImage,
588    text_box: &TextBox,
589    target_width: u32,
590    target_height: u32,
591) -> Option<Projection> {
592    if target_width < 2 || target_height < 2 {
593        return None;
594    }
595
596    if let Some(source_points) =
597        source_points_for_text_box(text_box, source.width(), source.height())
598    {
599        if let Some(projection) =
600            build_target_to_source_projection(source_points, target_width, target_height)
601        {
602            return Some(projection);
603        }
604    }
605
606    let source_points = rect_source_points_for_text_box(text_box, source.width(), source.height())?;
607    build_target_to_source_projection(source_points, target_width, target_height)
608}
609
610fn build_target_to_source_projection(
611    source_points: [(f32, f32); 4],
612    target_width: u32,
613    target_height: u32,
614) -> Option<Projection> {
615    let target_points = [
616        (0.0, 0.0),
617        (target_width.saturating_sub(1) as f32, 0.0),
618        (
619            target_width.saturating_sub(1) as f32,
620            target_height.saturating_sub(1) as f32,
621        ),
622        (0.0, target_height.saturating_sub(1) as f32),
623    ];
624
625    Projection::from_control_points(source_points, target_points)
626        .map(|projection| projection.invert())
627}
628
629#[allow(clippy::too_many_arguments)]
630fn write_projected_region_to_tensor(
631    source: &RgbImage,
632    target_to_source: Projection,
633    target_width: usize,
634    target_height: usize,
635    max_width: usize,
636    sample_offset: usize,
637    plane_size: usize,
638    data: &mut [f32],
639    scales: &[f32; 3],
640    offsets: &[f32; 3],
641) {
642    let source_width = source.width() as usize;
643    let source_height = source.height() as usize;
644    let source_data = source.as_raw();
645
646    for y in 0..target_height {
647        let dst_row = y * max_width;
648
649        for x in 0..target_width {
650            let (source_x, source_y) = target_to_source * (x as f32, y as f32);
651            let dst = sample_offset + dst_row + x;
652            write_normalized_sample(
653                source_data,
654                source_width,
655                source_height,
656                source_x,
657                source_y,
658                data,
659                dst,
660                sample_offset + plane_size + dst_row + x,
661                sample_offset + plane_size * 2 + dst_row + x,
662                scales,
663                offsets,
664            );
665        }
666    }
667}
668
669#[allow(clippy::too_many_arguments)]
670#[inline(always)]
671fn write_normalized_sample(
672    source_data: &[u8],
673    source_width: usize,
674    source_height: usize,
675    x: f32,
676    y: f32,
677    data: &mut [f32],
678    dst_r: usize,
679    dst_g: usize,
680    dst_b: usize,
681    scales: &[f32; 3],
682    offsets: &[f32; 3],
683) {
684    let left = x.floor();
685    let right = left + 1.0;
686    let top = y.floor();
687    let bottom = top + 1.0;
688
689    if !(left >= 0.0 && right < source_width as f32 && top >= 0.0 && bottom < source_height as f32)
690    {
691        data[dst_r] = 255.0 * scales[0] + offsets[0];
692        data[dst_g] = 255.0 * scales[1] + offsets[1];
693        data[dst_b] = 255.0 * scales[2] + offsets[2];
694        return;
695    }
696
697    let right_weight = x - left;
698    let bottom_weight = y - top;
699    let left = left as usize;
700    let right = right as usize;
701    let top = top as usize;
702    let bottom = bottom as usize;
703    let top_left = (top * source_width + left) * 3;
704    let top_right = (top * source_width + right) * 3;
705    let bottom_left = (bottom * source_width + left) * 3;
706    let bottom_right = (bottom * source_width + right) * 3;
707
708    let r = bilinear_channel(
709        source_data[top_left],
710        source_data[top_right],
711        source_data[bottom_left],
712        source_data[bottom_right],
713        right_weight,
714        bottom_weight,
715    );
716    let g = bilinear_channel(
717        source_data[top_left + 1],
718        source_data[top_right + 1],
719        source_data[bottom_left + 1],
720        source_data[bottom_right + 1],
721        right_weight,
722        bottom_weight,
723    );
724    let b = bilinear_channel(
725        source_data[top_left + 2],
726        source_data[top_right + 2],
727        source_data[bottom_left + 2],
728        source_data[bottom_right + 2],
729        right_weight,
730        bottom_weight,
731    );
732
733    data[dst_r] = r as f32 * scales[0] + offsets[0];
734    data[dst_g] = g as f32 * scales[1] + offsets[1];
735    data[dst_b] = b as f32 * scales[2] + offsets[2];
736}
737
738#[inline(always)]
739fn bilinear_channel(
740    top_left: u8,
741    top_right: u8,
742    bottom_left: u8,
743    bottom_right: u8,
744    right_weight: f32,
745    bottom_weight: f32,
746) -> u8 {
747    let top = lerp(top_left as f32, top_right as f32, right_weight);
748    let bottom = lerp(bottom_left as f32, bottom_right as f32, right_weight);
749    clamp_to_u8(lerp(top, bottom, bottom_weight))
750}
751
752#[inline]
753fn lerp(left: f32, right: f32, weight: f32) -> f32 {
754    (1.0 - weight) * left + weight * right
755}
756
757#[inline]
758fn clamp_to_u8(value: f32) -> u8 {
759    if value < u8::MAX as f32 {
760        if value > u8::MIN as f32 {
761            value as u8
762        } else {
763            u8::MIN
764        }
765    } else {
766        u8::MAX
767    }
768}
769
770fn source_points_for_text_box(
771    text_box: &TextBox,
772    image_width: u32,
773    image_height: u32,
774) -> Option<[(f32, f32); 4]> {
775    if let Some(points) = text_box.points {
776        let max_x = image_width.saturating_sub(1) as f32;
777        let max_y = image_height.saturating_sub(1) as f32;
778        return Some(points.map(|point| (point.x.clamp(0.0, max_x), point.y.clamp(0.0, max_y))));
779    }
780
781    rect_source_points_for_text_box(text_box, image_width, image_height)
782}
783
784fn rect_source_points_for_text_box(
785    text_box: &TextBox,
786    image_width: u32,
787    image_height: u32,
788) -> Option<[(f32, f32); 4]> {
789    let left = text_box.rect.left().max(0) as u32;
790    let top = text_box.rect.top().max(0) as u32;
791    let right = left
792        .saturating_add(text_box.rect.width())
793        .min(image_width)
794        .saturating_sub(1);
795    let bottom = top
796        .saturating_add(text_box.rect.height())
797        .min(image_height)
798        .saturating_sub(1);
799
800    if right <= left || bottom <= top {
801        return None;
802    }
803
804    Some([
805        (left as f32, top as f32),
806        (right as f32, top as f32),
807        (right as f32, bottom as f32),
808        (left as f32, bottom as f32),
809    ])
810}
811
812fn region_dimensions(text_box: &TextBox) -> (f32, f32) {
813    if let Some(points) = text_box.points {
814        let width = distance(points[0], points[1]).max(distance(points[3], points[2]));
815        let height = distance(points[0], points[3]).max(distance(points[1], points[2]));
816        (width.max(1.0), height.max(1.0))
817    } else {
818        (
819            text_box.rect.width().max(1) as f32,
820            text_box.rect.height().max(1) as f32,
821        )
822    }
823}
824
825fn distance(a: Point<f32>, b: Point<f32>) -> f32 {
826    let dx = a.x - b.x;
827    let dy = a.y - b.y;
828    (dx * dx + dy * dy).sqrt()
829}
830
831/// Low-level recognition API
832impl RecModel {
833    /// Raw inference interface
834    ///
835    /// Execute model inference directly without preprocessing and postprocessing
836    ///
837    /// # Parameters
838    /// - `input`: Preprocessed input tensor [1, 3, H, W]
839    ///
840    /// # Returns
841    /// Model raw output
842    pub fn run_raw(&self, input: ndarray::ArrayViewD<f32>) -> OcrResult<ArrayD<f32>> {
843        Ok(self.engine.run_dynamic(input)?)
844    }
845
846    /// Get model input shape
847    pub fn input_shape(&self) -> &[usize] {
848        self.engine.input_shape()
849    }
850
851    /// Get model output shape
852    pub fn output_shape(&self) -> &[usize] {
853        self.engine.output_shape()
854    }
855
856    /// Get charset
857    pub fn charset(&self) -> &[char] {
858        &self.charset
859    }
860
861    /// Get character by index
862    pub fn get_char(&self, index: usize) -> Option<char> {
863        self.charset.get(index).copied()
864    }
865}
866
867#[cfg(test)]
868mod tests {
869    use super::*;
870
871    #[test]
872    fn test_rec_options_default() {
873        let opts = RecOptions::default();
874        assert_eq!(opts.target_height, 48);
875        assert_eq!(opts.min_score, 0.3);
876        assert_eq!(opts.punct_min_score, 0.1);
877        assert_eq!(opts.batch_size, 8);
878        assert!(opts.enable_batch);
879    }
880
881    #[test]
882    fn test_rec_options_builder() {
883        let opts = RecOptions::new()
884            .with_target_height(32)
885            .with_min_score(0.6)
886            .with_punct_min_score(0.2)
887            .with_batch_size(16)
888            .with_batch(false);
889
890        assert_eq!(opts.target_height, 32);
891        assert_eq!(opts.min_score, 0.6);
892        assert_eq!(opts.punct_min_score, 0.2);
893        assert_eq!(opts.batch_size, 16);
894        assert!(!opts.enable_batch);
895    }
896
897    #[test]
898    fn test_recognition_result_new() {
899        let char_scores = vec![
900            ('H', 0.99),
901            ('e', 0.94),
902            ('l', 0.93),
903            ('l', 0.95),
904            ('o', 0.94),
905        ];
906        let result = RecognitionResult::new("Hello".to_string(), 0.95, char_scores.clone());
907
908        assert_eq!(result.text, "Hello");
909        assert_eq!(result.confidence, 0.95);
910        assert_eq!(result.char_scores.len(), 5);
911        assert_eq!(result.char_scores[0].0, 'H');
912        assert_eq!(result.char_scores[0].1, 0.99);
913    }
914
915    #[test]
916    fn test_recognition_result_is_valid() {
917        let result = RecognitionResult::new(
918            "Hello".to_string(),
919            0.95,
920            vec![
921                ('H', 0.99),
922                ('e', 0.94),
923                ('l', 0.93),
924                ('l', 0.95),
925                ('o', 0.94),
926            ],
927        );
928
929        assert!(result.is_valid(0.9));
930        assert!(result.is_valid(0.95));
931        assert!(!result.is_valid(0.96));
932        assert!(!result.is_valid(0.99));
933    }
934
935    #[test]
936    fn test_recognition_result_empty() {
937        let result = RecognitionResult::new(String::new(), 0.0, vec![]);
938
939        assert!(result.text.is_empty());
940        assert_eq!(result.confidence, 0.0);
941        assert!(!result.is_valid(0.1));
942    }
943
944    #[test]
945    fn test_region_target_width_avoids_projection_degenerate_width() {
946        let text_box = TextBox::with_points(
947            imageproc::rect::Rect::at(747, 14).of_size(61, 1695),
948            0.9,
949            [
950                Point::new(747.0, 14.0),
951                Point::new(747.4, 14.0),
952                Point::new(747.4, 1709.0),
953                Point::new(747.0, 1709.0),
954            ],
955        );
956
957        assert_eq!(region_target_width(&text_box, 48), 2);
958    }
959
960    #[test]
961    fn test_is_punctuation_common() {
962        // English punctuation
963        assert!(RecModel::is_punctuation(','));
964        assert!(RecModel::is_punctuation('.'));
965        assert!(RecModel::is_punctuation('!'));
966        assert!(RecModel::is_punctuation('?'));
967        assert!(RecModel::is_punctuation(';'));
968        assert!(RecModel::is_punctuation(':'));
969        assert!(RecModel::is_punctuation('"'));
970        assert!(RecModel::is_punctuation('\''));
971    }
972
973    #[test]
974    fn test_is_punctuation_chinese() {
975        // Chinese punctuation
976        assert!(RecModel::is_punctuation(','));
977        assert!(RecModel::is_punctuation('。'));
978        assert!(RecModel::is_punctuation('!'));
979        assert!(RecModel::is_punctuation('?'));
980        assert!(RecModel::is_punctuation(';'));
981        assert!(RecModel::is_punctuation(':'));
982        assert!(RecModel::is_punctuation('、'));
983        assert!(RecModel::is_punctuation('—'));
984        assert!(RecModel::is_punctuation('…'));
985    }
986
987    #[test]
988    fn test_is_punctuation_brackets() {
989        assert!(RecModel::is_punctuation('('));
990        assert!(RecModel::is_punctuation(')'));
991        assert!(RecModel::is_punctuation('['));
992        assert!(RecModel::is_punctuation(']'));
993        assert!(RecModel::is_punctuation('{'));
994        assert!(RecModel::is_punctuation('}'));
995        assert!(RecModel::is_punctuation('「'));
996        assert!(RecModel::is_punctuation('」'));
997        assert!(RecModel::is_punctuation('《'));
998        assert!(RecModel::is_punctuation('》'));
999    }
1000
1001    #[test]
1002    fn test_is_punctuation_false() {
1003        // Non-punctuation characters
1004        assert!(!RecModel::is_punctuation('A'));
1005        assert!(!RecModel::is_punctuation('z'));
1006        assert!(!RecModel::is_punctuation('0'));
1007        assert!(!RecModel::is_punctuation('中'));
1008        assert!(!RecModel::is_punctuation('文'));
1009        assert!(!RecModel::is_punctuation(' '));
1010    }
1011}