Skip to main content

ocr_rs/
engine.rs

1//! OCR Engine
2//!
3//! Provides complete OCR pipeline encapsulation, performs detection and recognition in one call
4
5use 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/// Strategy for recognizing text whose baseline is rotated by 90 or 270 degrees.
21///
22/// This is independent of the optional document-orientation model. The latter
23/// rotates a whole page, while this mode handles mixed horizontal and vertical
24/// text regions in the same image.
25#[non_exhaustive]
26#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
27pub enum RotatedTextMode {
28    /// Keep the original single-pass OCR pipeline.
29    #[default]
30    Disabled,
31    /// Re-orient tall regions already found by the normal detection pass.
32    DetectedOnly,
33    /// Also detect on 90° and 270° copies to recover regions missed by the
34    /// normal detection pass.
35    Robust,
36}
37
38/// Per-call recognition options.
39///
40/// Engine configuration and the existing [`OcrEngine::recognize`] API remain
41/// unchanged. Rotated-text support is opt-in so existing users pay no extra
42/// detection or recognition cost.
43#[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    /// Create default per-call recognition options.
60    pub fn new() -> Self {
61        Self::default()
62    }
63
64    /// Select how rotated text is handled.
65    pub fn with_rotated_text_mode(mut self, mode: RotatedTextMode) -> Self {
66        self.rotated_text_mode = mode;
67        self
68    }
69
70    /// Set the minimum long-side/short-side ratio for a vertical-text candidate.
71    ///
72    /// Invalid values are ignored and the current value is retained.
73    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    /// Return the configured rotated-text strategy.
81    pub fn rotated_text_mode(&self) -> RotatedTextMode {
82        self.rotated_text_mode
83    }
84
85    /// Return the vertical candidate aspect-ratio threshold.
86    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/// OCR result
109#[derive(Debug, Clone)]
110pub struct OcrResult_ {
111    /// Recognized text
112    pub text: String,
113    /// Confidence score
114    pub confidence: f32,
115    /// Bounding box
116    pub bbox: TextBox,
117}
118
119impl OcrResult_ {
120    /// Create a new OCR result
121    pub fn new(text: String, confidence: f32, bbox: TextBox) -> Self {
122        Self {
123            text,
124            confidence,
125            bbox,
126        }
127    }
128}
129
130/// OCR engine configuration
131#[derive(Debug, Clone)]
132pub struct OcrEngineConfig {
133    /// Inference backend
134    pub backend: Backend,
135    /// Thread count
136    pub thread_count: i32,
137    /// Precision mode
138    pub precision_mode: PrecisionMode,
139    /// Detection options
140    pub det_options: DetOptions,
141    /// Recognition options
142    pub rec_options: RecOptions,
143    /// Orientation options (used when orientation model is enabled)
144    pub ori_options: OriOptions,
145    /// Whether to enable exact-width parallel recognition for multi-line images
146    pub enable_parallel: bool,
147    /// Minimum confidence threshold at result level (recognition results below this value will be filtered)
148    pub min_result_confidence: f32,
149    /// Minimum confidence threshold for orientation correction
150    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    /// Create new configuration
171    pub fn new() -> Self {
172        Self::default()
173    }
174
175    /// Set inference backend
176    pub fn with_backend(mut self, backend: Backend) -> Self {
177        self.backend = backend;
178        self
179    }
180
181    /// Set thread count
182    pub fn with_threads(mut self, threads: i32) -> Self {
183        self.thread_count = threads;
184        self
185    }
186
187    /// Set precision mode
188    pub fn with_precision(mut self, precision: PrecisionMode) -> Self {
189        self.precision_mode = precision;
190        self
191    }
192
193    /// Set detection options
194    pub fn with_det_options(mut self, options: DetOptions) -> Self {
195        self.det_options = options;
196        self
197    }
198
199    /// Set recognition options
200    pub fn with_rec_options(mut self, options: RecOptions) -> Self {
201        self.rec_options = options;
202        self
203    }
204
205    /// Set orientation options
206    pub fn with_ori_options(mut self, options: OriOptions) -> Self {
207        self.ori_options = options;
208        self
209    }
210
211    /// Enable/disable parallel processing
212    ///
213    /// When at least five text regions are detected, preprocessing is parallelized and each
214    /// region keeps its exact tensor width to avoid padded batch inference.
215    pub fn with_parallel(mut self, enable: bool) -> Self {
216        self.enable_parallel = enable;
217        self
218    }
219
220    /// Set minimum confidence threshold at result level
221    ///
222    /// Recognition results below this threshold will be filtered out.
223    /// Recommended values: 0.5 (lenient), 0.7 (balanced), 0.9 (strict)
224    pub fn with_min_result_confidence(mut self, threshold: f32) -> Self {
225        self.min_result_confidence = threshold;
226        self
227    }
228
229    /// Set minimum confidence threshold for orientation correction
230    pub fn with_ori_min_confidence(mut self, threshold: f32) -> Self {
231        self.ori_min_confidence = threshold;
232        self
233    }
234
235    /// Fast mode preset
236    pub fn fast() -> Self {
237        Self {
238            precision_mode: PrecisionMode::Low,
239            det_options: DetOptions::fast(),
240            ..Default::default()
241        }
242    }
243
244    /// GPU mode preset (Metal)
245    #[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    /// GPU mode preset (OpenCL)
254    #[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
272/// OCR engine
273///
274/// Encapsulates complete OCR pipeline, including text detection and recognition
275///
276/// # Example
277///
278/// ```ignore
279/// use ocr_rs::{OcrEngine, OcrEngineConfig};
280///
281/// // Create engine
282/// let engine = OcrEngine::new(
283///     "det_model.mnn",
284///     "rec_model.mnn",
285///     "ppocr_keys.txt",
286///     None,
287/// )?;
288///
289/// // Recognize image
290/// let image = image::open("test.jpg")?;
291/// let results = engine.recognize(&image)?;
292///
293/// for result in results {
294///     println!("{}: {:.2}", result.text, result.confidence);
295/// }
296/// ```
297pub 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        // Optimization: Directly move the configuration to avoid multiple clones
316        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    /// Create OCR engine from model files
343    ///
344    /// # Parameters
345    /// - `det_model_path`: Detection model file path
346    /// - `rec_model_path`: Recognition model file path
347    /// - `charset_path`: Charset file path
348    /// - `config`: Optional engine configuration
349    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    /// Create OCR engine from model files with orientation model
365    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    /// Create OCR engine from model bytes
382    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        // Optimization: Directly move the configuration to avoid multiple clones
392        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    /// Create OCR engine from model bytes with orientation model
414    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    /// Create detection-only engine
450    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    /// Create recognition-only engine
464    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    /// Perform complete OCR recognition
479    ///
480    /// # Parameters
481    /// - `image`: Input image
482    ///
483    /// # Returns
484    /// List of OCR results, each result contains text, confidence and bounding box
485    pub fn recognize(&self, image: &DynamicImage) -> OcrResult<Vec<OcrResult_>> {
486        self.recognize_with_options(image, &RecognizeOptions::default())
487    }
488
489    /// Perform OCR recognition with per-call options.
490    ///
491    /// [`RotatedTextMode::Disabled`] is the default and runs the same one-pass
492    /// pipeline as [`Self::recognize`]. [`RotatedTextMode::Robust`] performs two
493    /// additional detection passes and only recognizes horizontal candidates
494    /// from those rotated images, keeping the added recognition work small.
495    pub fn recognize_with_options(
496        &self,
497        image: &DynamicImage,
498        options: &RecognizeOptions,
499    ) -> OcrResult<Vec<OcrResult_>> {
500        // 0. Orientation correction for full image (optional)
501        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        // Render regions directly into recognition tensors. Large pages use
612        // exact-width tensors to avoid padding every line to the widest region.
613        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    /// Perform detection only
637    pub fn detect(&self, image: &DynamicImage) -> OcrResult<Vec<TextBox>> {
638        self.det_model.detect(image)
639    }
640
641    /// Perform recognition only (requires pre-cropped text line images)
642    pub fn recognize_text(&self, image: &DynamicImage) -> OcrResult<RecognitionResult> {
643        self.rec_model.recognize(image)
644    }
645
646    /// Batch recognize text line images
647    pub fn recognize_batch(&self, images: &[DynamicImage]) -> OcrResult<Vec<RecognitionResult>> {
648        self.rec_model.recognize_batch(images)
649    }
650
651    /// Get orientation model reference (if enabled)
652    pub fn ori_model(&self) -> Option<&OriModel> {
653        self.ori_model.as_ref()
654    }
655
656    /// Get detection model reference
657    pub fn det_model(&self) -> &DetModel {
658        &self.det_model
659    }
660
661    /// Get recognition model reference
662    pub fn rec_model(&self) -> &RecModel {
663        &self.rec_model
664    }
665
666    /// Get configuration
667    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
876/// Builder for OCR engine
877pub 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    /// Create a new builder
893    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    /// Set detection model path
904    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    /// Set recognition model path
910    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    /// Set charset path
916    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    /// Set orientation model path
922    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    /// Set engine configuration
928    pub fn with_config(mut self, config: OcrEngineConfig) -> Self {
929        self.config = Some(config);
930        self
931    }
932
933    /// Build OCR engine
934    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
955/// Detection-only engine
956pub struct DetOnlyEngine {
957    det_model: DetModel,
958}
959
960impl DetOnlyEngine {
961    /// Detect text regions in image
962    pub fn detect(&self, image: &DynamicImage) -> OcrResult<Vec<TextBox>> {
963        self.det_model.detect(image)
964    }
965
966    /// Detect and return cropped images
967    pub fn detect_and_crop(&self, image: &DynamicImage) -> OcrResult<Vec<(DynamicImage, TextBox)>> {
968        self.det_model.detect_and_crop(image)
969    }
970
971    /// Get detection model reference
972    pub fn model(&self) -> &DetModel {
973        &self.det_model
974    }
975}
976
977/// Recognition-only engine
978pub struct RecOnlyEngine {
979    rec_model: RecModel,
980}
981
982impl RecOnlyEngine {
983    /// Recognize a single image
984    pub fn recognize(&self, image: &DynamicImage) -> OcrResult<RecognitionResult> {
985        self.rec_model.recognize(image)
986    }
987
988    /// Return text only
989    pub fn recognize_text(&self, image: &DynamicImage) -> OcrResult<String> {
990        self.rec_model.recognize_text(image)
991    }
992
993    /// Batch recognition
994    pub fn recognize_batch(&self, images: &[DynamicImage]) -> OcrResult<Vec<RecognitionResult>> {
995        self.rec_model.recognize_batch(images)
996    }
997
998    /// Get recognition model reference
999    pub fn model(&self) -> &RecModel {
1000        &self.rec_model
1001    }
1002}
1003
1004/// Convenience function: recognize from file
1005///
1006/// # Example
1007///
1008/// ```ignore
1009/// let results = ocr_rs::ocr_file(
1010///     "test.jpg",
1011///     "det_model.mnn",
1012///     "rec_model.mnn",
1013///     "ppocr_keys.txt",
1014/// )?;
1015/// ```
1016pub 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
1027/// Convenience function: recognize from file with orientation model
1028pub 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    // The model reports rotation from horizontal; rotate back to correct.
1048    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}