1use image::{DynamicImage, GenericImageView};
6use imageproc::point::Point;
7use imageproc::rect::Rect;
8use std::path::{Path, PathBuf};
9
10use crate::det::{DetModel, DetOptions};
11use crate::error::{OcrError, OcrResult};
12use crate::mnn::{Backend, InferenceConfig, PrecisionMode};
13use crate::ori::{OriModel, OriOptions};
14use crate::postprocess::{compute_iou, TextBox};
15use crate::rec::{RecModel, RecOptions, RecognitionResult};
16
17const PARALLEL_RECOGNITION_MIN_REGIONS: usize = 5;
18const ROTATED_RESULT_IOU_THRESHOLD: f32 = 0.5;
19
20#[non_exhaustive]
26#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
27pub enum RotatedTextMode {
28 #[default]
30 Disabled,
31 DetectedOnly,
33 Robust,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq)]
44pub struct RecognizeOptions {
45 rotated_text_mode: RotatedTextMode,
46 vertical_aspect_ratio: f32,
47}
48
49impl Default for RecognizeOptions {
50 fn default() -> Self {
51 Self {
52 rotated_text_mode: RotatedTextMode::Disabled,
53 vertical_aspect_ratio: 1.5,
54 }
55 }
56}
57
58impl RecognizeOptions {
59 pub fn new() -> Self {
61 Self::default()
62 }
63
64 pub fn with_rotated_text_mode(mut self, mode: RotatedTextMode) -> Self {
66 self.rotated_text_mode = mode;
67 self
68 }
69
70 pub fn with_vertical_aspect_ratio(mut self, ratio: f32) -> Self {
74 if ratio.is_finite() && ratio >= 1.0 {
75 self.vertical_aspect_ratio = ratio;
76 }
77 self
78 }
79
80 pub fn rotated_text_mode(&self) -> RotatedTextMode {
82 self.rotated_text_mode
83 }
84
85 pub fn vertical_aspect_ratio(&self) -> f32 {
87 self.vertical_aspect_ratio
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92enum RegionRecognitionStrategy {
93 Batch,
94 ExactWidthParallel,
95}
96
97fn select_region_recognition_strategy(
98 enable_parallel: bool,
99 region_count: usize,
100) -> RegionRecognitionStrategy {
101 if enable_parallel && region_count >= PARALLEL_RECOGNITION_MIN_REGIONS {
102 RegionRecognitionStrategy::ExactWidthParallel
103 } else {
104 RegionRecognitionStrategy::Batch
105 }
106}
107
108#[derive(Debug, Clone)]
110pub struct OcrResult_ {
111 pub text: String,
113 pub confidence: f32,
115 pub bbox: TextBox,
117}
118
119impl OcrResult_ {
120 pub fn new(text: String, confidence: f32, bbox: TextBox) -> Self {
122 Self {
123 text,
124 confidence,
125 bbox,
126 }
127 }
128}
129
130#[derive(Debug, Clone)]
132pub struct OcrEngineConfig {
133 pub backend: Backend,
135 pub thread_count: i32,
137 pub precision_mode: PrecisionMode,
139 pub det_options: DetOptions,
141 pub rec_options: RecOptions,
143 pub ori_options: OriOptions,
145 pub enable_parallel: bool,
147 pub min_result_confidence: f32,
149 pub ori_min_confidence: f32,
151}
152
153impl Default for OcrEngineConfig {
154 fn default() -> Self {
155 Self {
156 backend: Backend::CPU,
157 thread_count: 4,
158 precision_mode: PrecisionMode::Normal,
159 det_options: DetOptions::default(),
160 rec_options: RecOptions::default(),
161 ori_options: OriOptions::default(),
162 enable_parallel: true,
163 min_result_confidence: 0.5,
164 ori_min_confidence: 0.3,
165 }
166 }
167}
168
169impl OcrEngineConfig {
170 pub fn new() -> Self {
172 Self::default()
173 }
174
175 pub fn with_backend(mut self, backend: Backend) -> Self {
177 self.backend = backend;
178 self
179 }
180
181 pub fn with_threads(mut self, threads: i32) -> Self {
183 self.thread_count = threads;
184 self
185 }
186
187 pub fn with_precision(mut self, precision: PrecisionMode) -> Self {
189 self.precision_mode = precision;
190 self
191 }
192
193 pub fn with_det_options(mut self, options: DetOptions) -> Self {
195 self.det_options = options;
196 self
197 }
198
199 pub fn with_rec_options(mut self, options: RecOptions) -> Self {
201 self.rec_options = options;
202 self
203 }
204
205 pub fn with_ori_options(mut self, options: OriOptions) -> Self {
207 self.ori_options = options;
208 self
209 }
210
211 pub fn with_parallel(mut self, enable: bool) -> Self {
216 self.enable_parallel = enable;
217 self
218 }
219
220 pub fn with_min_result_confidence(mut self, threshold: f32) -> Self {
225 self.min_result_confidence = threshold;
226 self
227 }
228
229 pub fn with_ori_min_confidence(mut self, threshold: f32) -> Self {
231 self.ori_min_confidence = threshold;
232 self
233 }
234
235 pub fn fast() -> Self {
237 Self {
238 precision_mode: PrecisionMode::Low,
239 det_options: DetOptions::fast(),
240 ..Default::default()
241 }
242 }
243
244 #[cfg(any(target_os = "macos", target_os = "ios"))]
246 pub fn gpu() -> Self {
247 Self {
248 backend: Backend::Metal,
249 ..Default::default()
250 }
251 }
252
253 #[cfg(not(any(target_os = "macos", target_os = "ios")))]
255 pub fn gpu() -> Self {
256 Self {
257 backend: Backend::OpenCL,
258 ..Default::default()
259 }
260 }
261
262 fn to_inference_config(&self) -> InferenceConfig {
263 InferenceConfig {
264 thread_count: self.thread_count,
265 precision_mode: self.precision_mode,
266 backend: self.backend,
267 ..Default::default()
268 }
269 }
270}
271
272pub struct OcrEngine {
298 det_model: DetModel,
299 rec_model: RecModel,
300 ori_model: Option<OriModel>,
301 config: OcrEngineConfig,
302}
303
304impl OcrEngine {
305 fn build_with_paths(
306 det_model_path: &Path,
307 rec_model_path: &Path,
308 charset_path: &Path,
309 ori_model_path: Option<&Path>,
310 config: Option<OcrEngineConfig>,
311 ) -> OcrResult<Self> {
312 let config = config.unwrap_or_default();
313 let inference_config = config.to_inference_config();
314
315 let det_options = config.det_options.clone();
317 let rec_options = config.rec_options.clone();
318 let ori_options = config.ori_options.clone();
319
320 let det_model = DetModel::from_file(det_model_path, Some(inference_config.clone()))?
321 .with_options(det_options);
322
323 let rec_model =
324 RecModel::from_file(rec_model_path, charset_path, Some(inference_config.clone()))?
325 .with_options(rec_options);
326
327 let ori_model = match ori_model_path {
328 Some(path) => {
329 Some(OriModel::from_file(path, Some(inference_config))?.with_options(ori_options))
330 }
331 None => None,
332 };
333
334 Ok(Self {
335 det_model,
336 rec_model,
337 ori_model,
338 config,
339 })
340 }
341
342 pub fn new(
350 det_model_path: impl AsRef<Path>,
351 rec_model_path: impl AsRef<Path>,
352 charset_path: impl AsRef<Path>,
353 config: Option<OcrEngineConfig>,
354 ) -> OcrResult<Self> {
355 Self::build_with_paths(
356 det_model_path.as_ref(),
357 rec_model_path.as_ref(),
358 charset_path.as_ref(),
359 None,
360 config,
361 )
362 }
363
364 pub fn new_with_ori(
366 det_model_path: impl AsRef<Path>,
367 rec_model_path: impl AsRef<Path>,
368 charset_path: impl AsRef<Path>,
369 ori_model_path: impl AsRef<Path>,
370 config: Option<OcrEngineConfig>,
371 ) -> OcrResult<Self> {
372 Self::build_with_paths(
373 det_model_path.as_ref(),
374 rec_model_path.as_ref(),
375 charset_path.as_ref(),
376 Some(ori_model_path.as_ref()),
377 config,
378 )
379 }
380
381 pub fn from_bytes(
383 det_model_bytes: &[u8],
384 rec_model_bytes: &[u8],
385 charset_bytes: &[u8],
386 config: Option<OcrEngineConfig>,
387 ) -> OcrResult<Self> {
388 let config = config.unwrap_or_default();
389 let inference_config = config.to_inference_config();
390
391 let det_options = config.det_options.clone();
393 let rec_options = config.rec_options.clone();
394
395 let det_model = DetModel::from_bytes(det_model_bytes, Some(inference_config.clone()))?
396 .with_options(det_options);
397
398 let rec_model = RecModel::from_bytes_with_charset(
399 rec_model_bytes,
400 charset_bytes,
401 Some(inference_config.clone()),
402 )?
403 .with_options(rec_options);
404
405 Ok(Self {
406 det_model,
407 rec_model,
408 ori_model: None,
409 config,
410 })
411 }
412
413 pub fn from_bytes_with_ori(
415 det_model_bytes: &[u8],
416 rec_model_bytes: &[u8],
417 charset_bytes: &[u8],
418 ori_model_bytes: &[u8],
419 config: Option<OcrEngineConfig>,
420 ) -> OcrResult<Self> {
421 let config = config.unwrap_or_default();
422 let inference_config = config.to_inference_config();
423
424 let det_options = config.det_options.clone();
425 let rec_options = config.rec_options.clone();
426 let ori_options = config.ori_options.clone();
427
428 let det_model = DetModel::from_bytes(det_model_bytes, Some(inference_config.clone()))?
429 .with_options(det_options);
430
431 let rec_model = RecModel::from_bytes_with_charset(
432 rec_model_bytes,
433 charset_bytes,
434 Some(inference_config.clone()),
435 )?
436 .with_options(rec_options);
437
438 let ori_model = OriModel::from_bytes(ori_model_bytes, Some(inference_config))?
439 .with_options(ori_options);
440
441 Ok(Self {
442 det_model,
443 rec_model,
444 ori_model: Some(ori_model),
445 config,
446 })
447 }
448
449 pub fn det_only(
451 det_model_path: impl AsRef<Path>,
452 config: Option<OcrEngineConfig>,
453 ) -> OcrResult<DetOnlyEngine> {
454 let config = config.unwrap_or_default();
455 let inference_config = config.to_inference_config();
456
457 let det_model = DetModel::from_file(det_model_path, Some(inference_config))?
458 .with_options(config.det_options);
459
460 Ok(DetOnlyEngine { det_model })
461 }
462
463 pub fn rec_only(
465 rec_model_path: impl AsRef<Path>,
466 charset_path: impl AsRef<Path>,
467 config: Option<OcrEngineConfig>,
468 ) -> OcrResult<RecOnlyEngine> {
469 let config = config.unwrap_or_default();
470 let inference_config = config.to_inference_config();
471
472 let rec_model = RecModel::from_file(rec_model_path, charset_path, Some(inference_config))?
473 .with_options(config.rec_options);
474
475 Ok(RecOnlyEngine { rec_model })
476 }
477
478 pub fn recognize(&self, image: &DynamicImage) -> OcrResult<Vec<OcrResult_>> {
486 self.recognize_with_options(image, &RecognizeOptions::default())
487 }
488
489 pub fn recognize_with_options(
496 &self,
497 image: &DynamicImage,
498 options: &RecognizeOptions,
499 ) -> OcrResult<Vec<OcrResult_>> {
500 let corrected_image = if let Some(ori_model) = self.ori_model.as_ref() {
502 self.correct_orientation_with_model(ori_model, image.clone())
503 } else {
504 image.clone()
505 };
506
507 match options.rotated_text_mode {
508 RotatedTextMode::Disabled => self.recognize_single_pass(&corrected_image),
509 RotatedTextMode::DetectedOnly => {
510 self.recognize_detected_text(&corrected_image, options.vertical_aspect_ratio)
511 }
512 RotatedTextMode::Robust => {
513 self.recognize_robust(&corrected_image, options.vertical_aspect_ratio)
514 }
515 }
516 }
517
518 fn recognize_single_pass(&self, image: &DynamicImage) -> OcrResult<Vec<OcrResult_>> {
519 let boxes = self.det_model.detect_expanded(image)?;
520 if boxes.is_empty() {
521 return Ok(Vec::new());
522 }
523
524 let rec_results = self.recognize_regions(image, &boxes)?;
525 Ok(self.combine_results(rec_results, boxes))
526 }
527
528 fn recognize_detected_text(
529 &self,
530 image: &DynamicImage,
531 vertical_aspect_ratio: f32,
532 ) -> OcrResult<Vec<OcrResult_>> {
533 let boxes = self.det_model.detect_expanded(image)?;
534
535 if boxes.is_empty() {
536 return Ok(Vec::new());
537 }
538
539 let mut rec_results = self.recognize_regions(image, &boxes)?;
540
541 for (index, text_box) in boxes.iter().enumerate() {
542 if !is_vertical_box(text_box, vertical_aspect_ratio) {
543 continue;
544 }
545
546 let variants = vertical_recognition_variants(text_box);
547 let variant_results = self.rec_model.recognize_regions(image, &variants)?;
548 if let Some(best) = variant_results
549 .into_iter()
550 .max_by(|a, b| a.confidence.total_cmp(&b.confidence))
551 {
552 if best.confidence > rec_results[index].confidence {
553 rec_results[index] = best;
554 }
555 }
556 }
557
558 Ok(self.combine_results(rec_results, boxes))
559 }
560
561 fn recognize_robust(
562 &self,
563 image: &DynamicImage,
564 vertical_aspect_ratio: f32,
565 ) -> OcrResult<Vec<OcrResult_>> {
566 let mut results = self.recognize_detected_text(image, vertical_aspect_ratio)?;
567 let (original_width, original_height) = image.dimensions();
568
569 for turn in [QuarterTurn::Clockwise90, QuarterTurn::Clockwise270] {
570 let rotated_image = turn.rotate(image);
571 let rotated_boxes = self
572 .det_model
573 .detect_expanded(&rotated_image)?
574 .into_iter()
575 .filter(|text_box| is_horizontal_box(text_box, vertical_aspect_ratio))
576 .collect::<Vec<_>>();
577
578 if rotated_boxes.is_empty() {
579 continue;
580 }
581
582 let rec_results = self.recognize_regions(&rotated_image, &rotated_boxes)?;
583 let rotated_results = rec_results
584 .into_iter()
585 .zip(rotated_boxes)
586 .filter(|(rec, _)| {
587 !rec.text.is_empty() && rec.confidence >= self.config.min_result_confidence
588 })
589 .map(|(rec, text_box)| {
590 OcrResult_::new(
591 rec.text,
592 rec.confidence,
593 turn.map_box_to_original(&text_box, original_width, original_height),
594 )
595 });
596
597 for result in rotated_results {
598 merge_spatial_result(&mut results, result);
599 }
600 }
601
602 sort_results_by_reading_order(&mut results);
603 Ok(results)
604 }
605
606 fn recognize_regions(
607 &self,
608 image: &DynamicImage,
609 boxes: &[TextBox],
610 ) -> OcrResult<Vec<RecognitionResult>> {
611 match select_region_recognition_strategy(self.config.enable_parallel, boxes.len()) {
614 RegionRecognitionStrategy::Batch => self.rec_model.recognize_regions(image, boxes),
615 RegionRecognitionStrategy::ExactWidthParallel => self
616 .rec_model
617 .recognize_regions_exact_parallel(image, boxes),
618 }
619 }
620
621 fn combine_results(
622 &self,
623 rec_results: Vec<RecognitionResult>,
624 boxes: Vec<TextBox>,
625 ) -> Vec<OcrResult_> {
626 rec_results
627 .into_iter()
628 .zip(boxes)
629 .filter(|(rec, _)| {
630 !rec.text.is_empty() && rec.confidence >= self.config.min_result_confidence
631 })
632 .map(|(rec, bbox)| OcrResult_::new(rec.text, rec.confidence, bbox))
633 .collect()
634 }
635
636 pub fn detect(&self, image: &DynamicImage) -> OcrResult<Vec<TextBox>> {
638 self.det_model.detect(image)
639 }
640
641 pub fn recognize_text(&self, image: &DynamicImage) -> OcrResult<RecognitionResult> {
643 self.rec_model.recognize(image)
644 }
645
646 pub fn recognize_batch(&self, images: &[DynamicImage]) -> OcrResult<Vec<RecognitionResult>> {
648 self.rec_model.recognize_batch(images)
649 }
650
651 pub fn ori_model(&self) -> Option<&OriModel> {
653 self.ori_model.as_ref()
654 }
655
656 pub fn det_model(&self) -> &DetModel {
658 &self.det_model
659 }
660
661 pub fn rec_model(&self) -> &RecModel {
663 &self.rec_model
664 }
665
666 pub fn config(&self) -> &OcrEngineConfig {
668 &self.config
669 }
670
671 fn correct_orientation_with_model(
672 &self,
673 ori_model: &OriModel,
674 image: DynamicImage,
675 ) -> DynamicImage {
676 let result = match ori_model.classify(&image) {
677 Ok(result) => result,
678 Err(_) => return image,
679 };
680
681 if !result.is_valid(self.config.ori_min_confidence) {
682 return image;
683 }
684
685 if result.angle.rem_euclid(360) == 0 {
686 return image;
687 }
688
689 rotate_by_angle(&image, result.angle)
690 }
691}
692
693#[derive(Debug, Clone, Copy)]
694enum QuarterTurn {
695 Clockwise90,
696 Clockwise270,
697}
698
699impl QuarterTurn {
700 fn rotate(self, image: &DynamicImage) -> DynamicImage {
701 match self {
702 Self::Clockwise90 => image.rotate90(),
703 Self::Clockwise270 => image.rotate270(),
704 }
705 }
706
707 fn map_box_to_original(
708 self,
709 text_box: &TextBox,
710 original_width: u32,
711 original_height: u32,
712 ) -> TextBox {
713 let points = text_box_points(text_box).map(|point| {
714 let mapped = match self {
715 Self::Clockwise90 => {
716 Point::new(point.y, original_height.saturating_sub(1) as f32 - point.x)
717 }
718 Self::Clockwise270 => {
719 Point::new(original_width.saturating_sub(1) as f32 - point.y, point.x)
720 }
721 };
722 Point::new(
723 mapped.x.clamp(0.0, original_width.saturating_sub(1) as f32),
724 mapped
725 .y
726 .clamp(0.0, original_height.saturating_sub(1) as f32),
727 )
728 });
729 let points = order_box_points(points);
730 let rect = rect_for_points(&points, original_width, original_height)
731 .unwrap_or_else(|| Rect::at(0, 0).of_size(1, 1));
732
733 TextBox::with_points(rect, text_box.score, points)
734 }
735}
736
737fn box_dimensions(text_box: &TextBox) -> (f32, f32) {
738 if let Some(points) = text_box.points {
739 let width = point_distance(points[0], points[1]).max(point_distance(points[3], points[2]));
740 let height = point_distance(points[0], points[3]).max(point_distance(points[1], points[2]));
741 (width.max(1.0), height.max(1.0))
742 } else {
743 (
744 text_box.rect.width().max(1) as f32,
745 text_box.rect.height().max(1) as f32,
746 )
747 }
748}
749
750fn is_vertical_box(text_box: &TextBox, aspect_ratio: f32) -> bool {
751 let (width, height) = box_dimensions(text_box);
752 height / width.max(1.0) >= aspect_ratio
753}
754
755fn is_horizontal_box(text_box: &TextBox, aspect_ratio: f32) -> bool {
756 let (width, height) = box_dimensions(text_box);
757 width / height.max(1.0) >= aspect_ratio
758}
759
760fn vertical_recognition_variants(text_box: &TextBox) -> [TextBox; 2] {
761 let [top_left, top_right, bottom_right, bottom_left] = text_box_points(text_box);
762 [
763 TextBox::with_points(
764 text_box.rect,
765 text_box.score,
766 [bottom_left, top_left, top_right, bottom_right],
767 ),
768 TextBox::with_points(
769 text_box.rect,
770 text_box.score,
771 [top_right, bottom_right, bottom_left, top_left],
772 ),
773 ]
774}
775
776fn text_box_points(text_box: &TextBox) -> [Point<f32>; 4] {
777 text_box.points.unwrap_or_else(|| {
778 let left = text_box.rect.left() as f32;
779 let top = text_box.rect.top() as f32;
780 let right = left + text_box.rect.width().saturating_sub(1) as f32;
781 let bottom = top + text_box.rect.height().saturating_sub(1) as f32;
782 [
783 Point::new(left, top),
784 Point::new(right, top),
785 Point::new(right, bottom),
786 Point::new(left, bottom),
787 ]
788 })
789}
790
791fn order_box_points(points: [Point<f32>; 4]) -> [Point<f32>; 4] {
792 let mut top_left = points[0];
793 let mut top_right = points[0];
794 let mut bottom_right = points[0];
795 let mut bottom_left = points[0];
796
797 for point in points {
798 if point.x + point.y < top_left.x + top_left.y {
799 top_left = point;
800 }
801 if point.x + point.y > bottom_right.x + bottom_right.y {
802 bottom_right = point;
803 }
804 if point.x - point.y > top_right.x - top_right.y {
805 top_right = point;
806 }
807 if point.x - point.y < bottom_left.x - bottom_left.y {
808 bottom_left = point;
809 }
810 }
811
812 [top_left, top_right, bottom_right, bottom_left]
813}
814
815fn rect_for_points(points: &[Point<f32>; 4], image_width: u32, image_height: u32) -> Option<Rect> {
816 let min_x = points
817 .iter()
818 .map(|point| point.x)
819 .fold(f32::INFINITY, f32::min)
820 .floor()
821 .max(0.0) as u32;
822 let min_y = points
823 .iter()
824 .map(|point| point.y)
825 .fold(f32::INFINITY, f32::min)
826 .floor()
827 .max(0.0) as u32;
828 let max_x = points
829 .iter()
830 .map(|point| point.x)
831 .fold(f32::NEG_INFINITY, f32::max)
832 .ceil()
833 .min(image_width as f32) as u32;
834 let max_y = points
835 .iter()
836 .map(|point| point.y)
837 .fold(f32::NEG_INFINITY, f32::max)
838 .ceil()
839 .min(image_height as f32) as u32;
840
841 if max_x <= min_x || max_y <= min_y {
842 return None;
843 }
844
845 Some(Rect::at(min_x as i32, min_y as i32).of_size(max_x - min_x, max_y - min_y))
846}
847
848fn point_distance(a: Point<f32>, b: Point<f32>) -> f32 {
849 let dx = a.x - b.x;
850 let dy = a.y - b.y;
851 (dx * dx + dy * dy).sqrt()
852}
853
854fn merge_spatial_result(results: &mut Vec<OcrResult_>, candidate: OcrResult_) {
855 if let Some(existing) = results.iter_mut().find(|result| {
856 compute_iou(&result.bbox.rect, &candidate.bbox.rect) >= ROTATED_RESULT_IOU_THRESHOLD
857 }) {
858 if candidate.confidence > existing.confidence {
859 *existing = candidate;
860 }
861 } else {
862 results.push(candidate);
863 }
864}
865
866fn sort_results_by_reading_order(results: &mut [OcrResult_]) {
867 results.sort_by(|a, b| {
868 a.bbox
869 .rect
870 .top()
871 .cmp(&b.bbox.rect.top())
872 .then_with(|| a.bbox.rect.left().cmp(&b.bbox.rect.left()))
873 });
874}
875
876pub struct OcrEngineBuilder {
878 det_model_path: Option<PathBuf>,
879 rec_model_path: Option<PathBuf>,
880 charset_path: Option<PathBuf>,
881 ori_model_path: Option<PathBuf>,
882 config: Option<OcrEngineConfig>,
883}
884
885impl Default for OcrEngineBuilder {
886 fn default() -> Self {
887 Self::new()
888 }
889}
890
891impl OcrEngineBuilder {
892 pub fn new() -> Self {
894 Self {
895 det_model_path: None,
896 rec_model_path: None,
897 charset_path: None,
898 ori_model_path: None,
899 config: None,
900 }
901 }
902
903 pub fn with_det_model_path(mut self, path: impl AsRef<Path>) -> Self {
905 self.det_model_path = Some(path.as_ref().to_path_buf());
906 self
907 }
908
909 pub fn with_rec_model_path(mut self, path: impl AsRef<Path>) -> Self {
911 self.rec_model_path = Some(path.as_ref().to_path_buf());
912 self
913 }
914
915 pub fn with_charset_path(mut self, path: impl AsRef<Path>) -> Self {
917 self.charset_path = Some(path.as_ref().to_path_buf());
918 self
919 }
920
921 pub fn with_ori_model_path(mut self, path: impl AsRef<Path>) -> Self {
923 self.ori_model_path = Some(path.as_ref().to_path_buf());
924 self
925 }
926
927 pub fn with_config(mut self, config: OcrEngineConfig) -> Self {
929 self.config = Some(config);
930 self
931 }
932
933 pub fn build(self) -> OcrResult<OcrEngine> {
935 let det_model_path = self
936 .det_model_path
937 .ok_or_else(|| OcrError::InvalidParameter("Missing det_model_path".to_string()))?;
938 let rec_model_path = self
939 .rec_model_path
940 .ok_or_else(|| OcrError::InvalidParameter("Missing rec_model_path".to_string()))?;
941 let charset_path = self
942 .charset_path
943 .ok_or_else(|| OcrError::InvalidParameter("Missing charset_path".to_string()))?;
944
945 OcrEngine::build_with_paths(
946 det_model_path.as_path(),
947 rec_model_path.as_path(),
948 charset_path.as_path(),
949 self.ori_model_path.as_deref(),
950 self.config,
951 )
952 }
953}
954
955pub struct DetOnlyEngine {
957 det_model: DetModel,
958}
959
960impl DetOnlyEngine {
961 pub fn detect(&self, image: &DynamicImage) -> OcrResult<Vec<TextBox>> {
963 self.det_model.detect(image)
964 }
965
966 pub fn detect_and_crop(&self, image: &DynamicImage) -> OcrResult<Vec<(DynamicImage, TextBox)>> {
968 self.det_model.detect_and_crop(image)
969 }
970
971 pub fn model(&self) -> &DetModel {
973 &self.det_model
974 }
975}
976
977pub struct RecOnlyEngine {
979 rec_model: RecModel,
980}
981
982impl RecOnlyEngine {
983 pub fn recognize(&self, image: &DynamicImage) -> OcrResult<RecognitionResult> {
985 self.rec_model.recognize(image)
986 }
987
988 pub fn recognize_text(&self, image: &DynamicImage) -> OcrResult<String> {
990 self.rec_model.recognize_text(image)
991 }
992
993 pub fn recognize_batch(&self, images: &[DynamicImage]) -> OcrResult<Vec<RecognitionResult>> {
995 self.rec_model.recognize_batch(images)
996 }
997
998 pub fn model(&self) -> &RecModel {
1000 &self.rec_model
1001 }
1002}
1003
1004pub fn ocr_file(
1017 image_path: impl AsRef<Path>,
1018 det_model_path: impl AsRef<Path>,
1019 rec_model_path: impl AsRef<Path>,
1020 charset_path: impl AsRef<Path>,
1021) -> OcrResult<Vec<OcrResult_>> {
1022 let image = image::open(image_path)?;
1023 let engine = OcrEngine::new(det_model_path, rec_model_path, charset_path, None)?;
1024 engine.recognize(&image)
1025}
1026
1027pub fn ocr_file_with_ori(
1029 image_path: impl AsRef<Path>,
1030 det_model_path: impl AsRef<Path>,
1031 rec_model_path: impl AsRef<Path>,
1032 charset_path: impl AsRef<Path>,
1033 ori_model_path: impl AsRef<Path>,
1034) -> OcrResult<Vec<OcrResult_>> {
1035 let image = image::open(image_path)?;
1036 let engine = OcrEngine::new_with_ori(
1037 det_model_path,
1038 rec_model_path,
1039 charset_path,
1040 ori_model_path,
1041 None,
1042 )?;
1043 engine.recognize(&image)
1044}
1045
1046fn rotate_by_angle(image: &DynamicImage, angle: i32) -> DynamicImage {
1047 match angle.rem_euclid(360) {
1049 90 => DynamicImage::ImageRgb8(image::imageops::rotate270(&image.to_rgb8())),
1050 180 => DynamicImage::ImageRgb8(image::imageops::rotate180(&image.to_rgb8())),
1051 270 => DynamicImage::ImageRgb8(image::imageops::rotate90(&image.to_rgb8())),
1052 _ => image.clone(),
1053 }
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058 use super::*;
1059
1060 #[test]
1061 fn parallel_strategy_uses_exact_width_only_for_multi_line_pages() {
1062 assert_eq!(
1063 select_region_recognition_strategy(true, 5),
1064 RegionRecognitionStrategy::ExactWidthParallel
1065 );
1066 assert_eq!(
1067 select_region_recognition_strategy(true, 4),
1068 RegionRecognitionStrategy::Batch
1069 );
1070 assert_eq!(
1071 select_region_recognition_strategy(false, 12),
1072 RegionRecognitionStrategy::Batch
1073 );
1074 }
1075
1076 #[test]
1077 fn test_ocr_result() {
1078 let bbox = TextBox::new(imageproc::rect::Rect::at(0, 0).of_size(100, 20), 0.9);
1079 let result = OcrResult_::new("Hello".to_string(), 0.95, bbox);
1080
1081 assert_eq!(result.text, "Hello");
1082 assert_eq!(result.confidence, 0.95);
1083 }
1084
1085 #[test]
1086 fn recognize_options_are_disabled_by_default() {
1087 let options = RecognizeOptions::default();
1088
1089 assert_eq!(options.rotated_text_mode(), RotatedTextMode::Disabled);
1090 assert_eq!(options.vertical_aspect_ratio(), 1.5);
1091 }
1092
1093 #[test]
1094 fn recognize_options_ignore_invalid_aspect_ratios() {
1095 let options = RecognizeOptions::new()
1096 .with_vertical_aspect_ratio(2.0)
1097 .with_vertical_aspect_ratio(f32::NAN)
1098 .with_vertical_aspect_ratio(0.5);
1099
1100 assert_eq!(options.vertical_aspect_ratio(), 2.0);
1101 }
1102
1103 #[test]
1104 fn quarter_turns_map_boxes_back_to_original_coordinates() {
1105 let clockwise_90_box = TextBox::with_points(
1106 Rect::at(30, 10).of_size(9, 29),
1107 0.9,
1108 [
1109 Point::new(30.0, 10.0),
1110 Point::new(39.0, 10.0),
1111 Point::new(39.0, 39.0),
1112 Point::new(30.0, 39.0),
1113 ],
1114 );
1115 let clockwise_270_box = TextBox::with_points(
1116 Rect::at(20, 60).of_size(9, 29),
1117 0.9,
1118 [
1119 Point::new(20.0, 60.0),
1120 Point::new(29.0, 60.0),
1121 Point::new(29.0, 89.0),
1122 Point::new(20.0, 89.0),
1123 ],
1124 );
1125
1126 let from_90 = QuarterTurn::Clockwise90.map_box_to_original(&clockwise_90_box, 100, 60);
1127 let from_270 = QuarterTurn::Clockwise270.map_box_to_original(&clockwise_270_box, 100, 60);
1128
1129 for mapped in [from_90, from_270] {
1130 assert_eq!(mapped.rect.left(), 10);
1131 assert_eq!(mapped.rect.top(), 20);
1132 assert_eq!(mapped.rect.width(), 29);
1133 assert_eq!(mapped.rect.height(), 9);
1134 assert!(is_horizontal_box(&mapped, 1.5));
1135 }
1136 }
1137
1138 #[test]
1139 fn vertical_variants_turn_a_tall_box_into_horizontal_regions() {
1140 let text_box = TextBox::new(Rect::at(10, 20).of_size(20, 80), 0.9);
1141
1142 let variants = vertical_recognition_variants(&text_box);
1143
1144 assert!(variants
1145 .iter()
1146 .all(|variant| is_horizontal_box(variant, 1.5)));
1147 }
1148
1149 #[test]
1150 fn merge_spatial_result_keeps_only_the_highest_confidence() {
1151 let bbox = TextBox::new(Rect::at(10, 20).of_size(20, 80), 0.9);
1152 let mut results = vec![OcrResult_::new("低".into(), 0.6, bbox.clone())];
1153
1154 merge_spatial_result(&mut results, OcrResult_::new("高".into(), 0.9, bbox));
1155
1156 assert_eq!(results.len(), 1);
1157 assert_eq!(results[0].text, "高");
1158 assert_eq!(results[0].confidence, 0.9);
1159 }
1160}