1use 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#[derive(Debug, Clone)]
19pub struct RecognitionResult {
20 pub text: String,
22 pub confidence: f32,
24 pub char_scores: Vec<(char, f32)>,
26}
27
28impl RecognitionResult {
29 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 pub fn is_valid(&self, threshold: f32) -> bool {
40 self.confidence >= threshold
41 }
42}
43
44#[derive(Debug, Clone)]
46pub struct RecOptions {
47 pub target_height: u32,
49 pub min_score: f32,
51 pub punct_min_score: f32,
53 pub batch_size: usize,
55 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, punct_min_score: 0.1,
65 batch_size: 8,
66 enable_batch: true,
67 }
68 }
69}
70
71impl RecOptions {
72 pub fn new() -> Self {
74 Self::default()
75 }
76
77 pub fn with_target_height(mut self, height: u32) -> Self {
79 self.target_height = height;
80 self
81 }
82
83 pub fn with_min_score(mut self, score: f32) -> Self {
85 self.min_score = score;
86 self
87 }
88
89 pub fn with_punct_min_score(mut self, score: f32) -> Self {
91 self.punct_min_score = score;
92 self
93 }
94
95 pub fn with_batch_size(mut self, size: usize) -> Self {
97 self.batch_size = size;
98 self
99 }
100
101 pub fn with_batch(mut self, enable: bool) -> Self {
103 self.enable_batch = enable;
104 self
105 }
106}
107
108pub struct RecModel {
110 engine: InferenceEngine,
111 charset: Vec<char>,
113 options: RecOptions,
114 normalize_params: NormalizeParams,
115}
116
117const PUNCTUATIONS: [char; 49] = [
119 ',', '.', '!', '?', ';', ':', '"', '\'', '(', ')', '[', ']', '{', '}', '-', '_', '/', '\\',
120 '|', '@', '#', '$', '%', '&', '*', '+', '=', '~', ',', '。', '!', '?', ';', ':', '、',
121 '「', '」', '『', '』', '(', ')', '【', '】', '《', '》', '—', '…', '·', '~',
122];
123
124impl RecModel {
125 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 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 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 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 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 let mut charset: Vec<char> = vec![' ']; for ch in content.chars() {
197 if ch != '\n' && ch != '\r' {
198 charset.push(ch);
199 }
200 }
201
202 charset.push(' '); if charset.len() < 3 {
205 return Err(OcrError::CharsetError("Charset too small".to_string()));
206 }
207
208 Ok(charset)
209 }
210
211 pub fn with_options(mut self, options: RecOptions) -> Self {
213 self.options = options;
214 self
215 }
216
217 pub fn options(&self) -> &RecOptions {
219 &self.options
220 }
221
222 pub fn options_mut(&mut self) -> &mut RecOptions {
224 &mut self.options
225 }
226
227 pub fn charset_size(&self) -> usize {
229 self.charset.len()
230 }
231
232 pub fn recognize(&self, image: &DynamicImage) -> OcrResult<RecognitionResult> {
240 let input = preprocess_for_rec(image, self.options.target_height, &self.normalize_params)?;
242
243 let output = self.engine.run_dynamic(input.view().into_dyn())?;
245
246 self.decode_output_view(output.view())
248 }
249
250 pub fn recognize_text(&self, image: &DynamicImage) -> OcrResult<String> {
252 let result = self.recognize(image)?;
253 Ok(result.text)
254 }
255
256 pub fn recognize_batch(&self, images: &[DynamicImage]) -> OcrResult<Vec<RecognitionResult>> {
264 if images.is_empty() {
265 return Ok(Vec::new());
266 }
267
268 if images.len() <= 2 || !self.options.enable_batch {
270 return images.iter().map(|img| self.recognize(img)).collect();
271 }
272
273 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 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 if images.len() <= 2 || !self.options.enable_batch {
301 return images.iter().map(|img| self.recognize(img)).collect();
302 }
303
304 let mut results = Vec::with_capacity(images.len());
306
307 for chunk in images.chunks(self.options.batch_size) {
308 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 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 images.len() == 1 {
458 return Ok(vec![self.recognize(&images[0])?]);
459 }
460
461 let batch_input = crate::preprocess::preprocess_batch_for_rec(
463 images,
464 self.options.target_height,
465 &self.normalize_params,
466 )?;
467
468 let batch_output = self.engine.run_dynamic(batch_input.view().into_dyn())?;
470
471 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 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 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 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 if max_idx != 0 && max_idx != prev_idx && max_idx < self.charset.len() {
540 let ch = self.charset[max_idx];
541
542 let score = max_prob;
545
546 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 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 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
831impl RecModel {
833 pub fn run_raw(&self, input: ndarray::ArrayViewD<f32>) -> OcrResult<ArrayD<f32>> {
843 Ok(self.engine.run_dynamic(input)?)
844 }
845
846 pub fn input_shape(&self) -> &[usize] {
848 self.engine.input_shape()
849 }
850
851 pub fn output_shape(&self) -> &[usize] {
853 self.engine.output_shape()
854 }
855
856 pub fn charset(&self) -> &[char] {
858 &self.charset
859 }
860
861 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 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 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 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}