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;
6use std::path::{Path, PathBuf};
7
8use crate::det::{DetModel, DetOptions};
9use crate::error::{OcrError, OcrResult};
10use crate::mnn::{Backend, InferenceConfig, PrecisionMode};
11use crate::ori::{OriModel, OriOptions};
12use crate::postprocess::TextBox;
13use crate::rec::{RecModel, RecOptions, RecognitionResult};
14
15/// OCR result
16#[derive(Debug, Clone)]
17pub struct OcrResult_ {
18    /// Recognized text
19    pub text: String,
20    /// Confidence score
21    pub confidence: f32,
22    /// Bounding box
23    pub bbox: TextBox,
24}
25
26impl OcrResult_ {
27    /// Create a new OCR result
28    pub fn new(text: String, confidence: f32, bbox: TextBox) -> Self {
29        Self {
30            text,
31            confidence,
32            bbox,
33        }
34    }
35}
36
37/// OCR engine configuration
38#[derive(Debug, Clone)]
39pub struct OcrEngineConfig {
40    /// Inference backend
41    pub backend: Backend,
42    /// Thread count
43    pub thread_count: i32,
44    /// Precision mode
45    pub precision_mode: PrecisionMode,
46    /// Detection options
47    pub det_options: DetOptions,
48    /// Recognition options
49    pub rec_options: RecOptions,
50    /// Orientation options (used when orientation model is enabled)
51    pub ori_options: OriOptions,
52    /// Whether to enable parallel recognition (use rayon to process multiple text regions in parallel)
53    pub enable_parallel: bool,
54    /// Minimum confidence threshold at result level (recognition results below this value will be filtered)
55    pub min_result_confidence: f32,
56    /// Minimum confidence threshold for orientation correction
57    pub ori_min_confidence: f32,
58}
59
60impl Default for OcrEngineConfig {
61    fn default() -> Self {
62        Self {
63            backend: Backend::CPU,
64            thread_count: 4,
65            precision_mode: PrecisionMode::Normal,
66            det_options: DetOptions::default(),
67            rec_options: RecOptions::default(),
68            ori_options: OriOptions::default(),
69            enable_parallel: true,
70            min_result_confidence: 0.5,
71            ori_min_confidence: 0.3,
72        }
73    }
74}
75
76impl OcrEngineConfig {
77    /// Create new configuration
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Set inference backend
83    pub fn with_backend(mut self, backend: Backend) -> Self {
84        self.backend = backend;
85        self
86    }
87
88    /// Set thread count
89    pub fn with_threads(mut self, threads: i32) -> Self {
90        self.thread_count = threads;
91        self
92    }
93
94    /// Set precision mode
95    pub fn with_precision(mut self, precision: PrecisionMode) -> Self {
96        self.precision_mode = precision;
97        self
98    }
99
100    /// Set detection options
101    pub fn with_det_options(mut self, options: DetOptions) -> Self {
102        self.det_options = options;
103        self
104    }
105
106    /// Set recognition options
107    pub fn with_rec_options(mut self, options: RecOptions) -> Self {
108        self.rec_options = options;
109        self
110    }
111
112    /// Set orientation options
113    pub fn with_ori_options(mut self, options: OriOptions) -> Self {
114        self.ori_options = options;
115        self
116    }
117
118    /// Enable/disable parallel processing
119    ///
120    /// Note: When multiple text regions are detected, use rayon for parallel recognition.
121    /// If MNN is already set to multi-threading, enabling this option may cause thread contention.
122    pub fn with_parallel(mut self, enable: bool) -> Self {
123        self.enable_parallel = enable;
124        self
125    }
126
127    /// Set minimum confidence threshold at result level
128    ///
129    /// Recognition results below this threshold will be filtered out.
130    /// Recommended values: 0.5 (lenient), 0.7 (balanced), 0.9 (strict)
131    pub fn with_min_result_confidence(mut self, threshold: f32) -> Self {
132        self.min_result_confidence = threshold;
133        self
134    }
135
136    /// Set minimum confidence threshold for orientation correction
137    pub fn with_ori_min_confidence(mut self, threshold: f32) -> Self {
138        self.ori_min_confidence = threshold;
139        self
140    }
141
142    /// Fast mode preset
143    pub fn fast() -> Self {
144        Self {
145            precision_mode: PrecisionMode::Low,
146            det_options: DetOptions::fast(),
147            ..Default::default()
148        }
149    }
150
151    /// GPU mode preset (Metal)
152    #[cfg(any(target_os = "macos", target_os = "ios"))]
153    pub fn gpu() -> Self {
154        Self {
155            backend: Backend::Metal,
156            ..Default::default()
157        }
158    }
159
160    /// GPU mode preset (OpenCL)
161    #[cfg(not(any(target_os = "macos", target_os = "ios")))]
162    pub fn gpu() -> Self {
163        Self {
164            backend: Backend::OpenCL,
165            ..Default::default()
166        }
167    }
168
169    fn to_inference_config(&self) -> InferenceConfig {
170        InferenceConfig {
171            thread_count: self.thread_count,
172            precision_mode: self.precision_mode,
173            backend: self.backend,
174            ..Default::default()
175        }
176    }
177}
178
179/// OCR engine
180///
181/// Encapsulates complete OCR pipeline, including text detection and recognition
182///
183/// # Example
184///
185/// ```ignore
186/// use ocr_rs::{OcrEngine, OcrEngineConfig};
187///
188/// // Create engine
189/// let engine = OcrEngine::new(
190///     "det_model.mnn",
191///     "rec_model.mnn",
192///     "ppocr_keys.txt",
193///     None,
194/// )?;
195///
196/// // Recognize image
197/// let image = image::open("test.jpg")?;
198/// let results = engine.recognize(&image)?;
199///
200/// for result in results {
201///     println!("{}: {:.2}", result.text, result.confidence);
202/// }
203/// ```
204pub struct OcrEngine {
205    det_model: DetModel,
206    rec_model: RecModel,
207    ori_model: Option<OriModel>,
208    config: OcrEngineConfig,
209}
210
211impl OcrEngine {
212    fn build_with_paths(
213        det_model_path: &Path,
214        rec_model_path: &Path,
215        charset_path: &Path,
216        ori_model_path: Option<&Path>,
217        config: Option<OcrEngineConfig>,
218    ) -> OcrResult<Self> {
219        let config = config.unwrap_or_default();
220        let inference_config = config.to_inference_config();
221
222        // Optimization: Directly move the configuration to avoid multiple clones
223        let det_options = config.det_options.clone();
224        let rec_options = config.rec_options.clone();
225        let ori_options = config.ori_options.clone();
226
227        let det_model = DetModel::from_file(det_model_path, Some(inference_config.clone()))?
228            .with_options(det_options);
229
230        let rec_model =
231            RecModel::from_file(rec_model_path, charset_path, Some(inference_config.clone()))?
232                .with_options(rec_options);
233
234        let ori_model = match ori_model_path {
235            Some(path) => {
236                Some(OriModel::from_file(path, Some(inference_config))?.with_options(ori_options))
237            }
238            None => None,
239        };
240
241        Ok(Self {
242            det_model,
243            rec_model,
244            ori_model,
245            config,
246        })
247    }
248
249    /// Create OCR engine from model files
250    ///
251    /// # Parameters
252    /// - `det_model_path`: Detection model file path
253    /// - `rec_model_path`: Recognition model file path
254    /// - `charset_path`: Charset file path
255    /// - `config`: Optional engine configuration
256    pub fn new(
257        det_model_path: impl AsRef<Path>,
258        rec_model_path: impl AsRef<Path>,
259        charset_path: impl AsRef<Path>,
260        config: Option<OcrEngineConfig>,
261    ) -> OcrResult<Self> {
262        Self::build_with_paths(
263            det_model_path.as_ref(),
264            rec_model_path.as_ref(),
265            charset_path.as_ref(),
266            None,
267            config,
268        )
269    }
270
271    /// Create OCR engine from model files with orientation model
272    pub fn new_with_ori(
273        det_model_path: impl AsRef<Path>,
274        rec_model_path: impl AsRef<Path>,
275        charset_path: impl AsRef<Path>,
276        ori_model_path: impl AsRef<Path>,
277        config: Option<OcrEngineConfig>,
278    ) -> OcrResult<Self> {
279        Self::build_with_paths(
280            det_model_path.as_ref(),
281            rec_model_path.as_ref(),
282            charset_path.as_ref(),
283            Some(ori_model_path.as_ref()),
284            config,
285        )
286    }
287
288    /// Create OCR engine from model bytes
289    pub fn from_bytes(
290        det_model_bytes: &[u8],
291        rec_model_bytes: &[u8],
292        charset_bytes: &[u8],
293        config: Option<OcrEngineConfig>,
294    ) -> OcrResult<Self> {
295        let config = config.unwrap_or_default();
296        let inference_config = config.to_inference_config();
297
298        // Optimization: Directly move the configuration to avoid multiple clones
299        let det_options = config.det_options.clone();
300        let rec_options = config.rec_options.clone();
301
302        let det_model = DetModel::from_bytes(det_model_bytes, Some(inference_config.clone()))?
303            .with_options(det_options);
304
305        let rec_model = RecModel::from_bytes_with_charset(
306            rec_model_bytes,
307            charset_bytes,
308            Some(inference_config.clone()),
309        )?
310        .with_options(rec_options);
311
312        Ok(Self {
313            det_model,
314            rec_model,
315            ori_model: None,
316            config,
317        })
318    }
319
320    /// Create OCR engine from model bytes with orientation model
321    pub fn from_bytes_with_ori(
322        det_model_bytes: &[u8],
323        rec_model_bytes: &[u8],
324        charset_bytes: &[u8],
325        ori_model_bytes: &[u8],
326        config: Option<OcrEngineConfig>,
327    ) -> OcrResult<Self> {
328        let config = config.unwrap_or_default();
329        let inference_config = config.to_inference_config();
330
331        let det_options = config.det_options.clone();
332        let rec_options = config.rec_options.clone();
333        let ori_options = config.ori_options.clone();
334
335        let det_model = DetModel::from_bytes(det_model_bytes, Some(inference_config.clone()))?
336            .with_options(det_options);
337
338        let rec_model = RecModel::from_bytes_with_charset(
339            rec_model_bytes,
340            charset_bytes,
341            Some(inference_config.clone()),
342        )?
343        .with_options(rec_options);
344
345        let ori_model = OriModel::from_bytes(ori_model_bytes, Some(inference_config))?
346            .with_options(ori_options);
347
348        Ok(Self {
349            det_model,
350            rec_model,
351            ori_model: Some(ori_model),
352            config,
353        })
354    }
355
356    /// Create detection-only engine
357    pub fn det_only(
358        det_model_path: impl AsRef<Path>,
359        config: Option<OcrEngineConfig>,
360    ) -> OcrResult<DetOnlyEngine> {
361        let config = config.unwrap_or_default();
362        let inference_config = config.to_inference_config();
363
364        let det_model = DetModel::from_file(det_model_path, Some(inference_config))?
365            .with_options(config.det_options);
366
367        Ok(DetOnlyEngine { det_model })
368    }
369
370    /// Create recognition-only engine
371    pub fn rec_only(
372        rec_model_path: impl AsRef<Path>,
373        charset_path: impl AsRef<Path>,
374        config: Option<OcrEngineConfig>,
375    ) -> OcrResult<RecOnlyEngine> {
376        let config = config.unwrap_or_default();
377        let inference_config = config.to_inference_config();
378
379        let rec_model = RecModel::from_file(rec_model_path, charset_path, Some(inference_config))?
380            .with_options(config.rec_options);
381
382        Ok(RecOnlyEngine { rec_model })
383    }
384
385    /// Perform complete OCR recognition
386    ///
387    /// # Parameters
388    /// - `image`: Input image
389    ///
390    /// # Returns
391    /// List of OCR results, each result contains text, confidence and bounding box
392    pub fn recognize(&self, image: &DynamicImage) -> OcrResult<Vec<OcrResult_>> {
393        // 0. Orientation correction for full image (optional)
394        let corrected_image = if let Some(ori_model) = self.ori_model.as_ref() {
395            self.correct_orientation_with_model(ori_model, image.clone())
396        } else {
397            image.clone()
398        };
399
400        // 1. Detect text regions
401        let boxes = self.det_model.detect_expanded(&corrected_image)?;
402
403        if boxes.is_empty() {
404            return Ok(Vec::new());
405        }
406
407        // 2. Recognize directly from source image regions. This avoids materializing
408        // DynamicImage crops and a second resize before recognition preprocessing.
409        let rec_results = self.rec_model.recognize_regions(&corrected_image, &boxes)?;
410
411        // 3. Combine results and filter low confidence
412        let results: Vec<OcrResult_> = rec_results
413            .into_iter()
414            .zip(boxes)
415            .filter(|(rec, _)| {
416                !rec.text.is_empty() && rec.confidence >= self.config.min_result_confidence
417            })
418            .map(|(rec, bbox)| OcrResult_::new(rec.text, rec.confidence, bbox))
419            .collect();
420
421        Ok(results)
422    }
423
424    /// Perform detection only
425    pub fn detect(&self, image: &DynamicImage) -> OcrResult<Vec<TextBox>> {
426        self.det_model.detect(image)
427    }
428
429    /// Perform recognition only (requires pre-cropped text line images)
430    pub fn recognize_text(&self, image: &DynamicImage) -> OcrResult<RecognitionResult> {
431        self.rec_model.recognize(image)
432    }
433
434    /// Batch recognize text line images
435    pub fn recognize_batch(&self, images: &[DynamicImage]) -> OcrResult<Vec<RecognitionResult>> {
436        self.rec_model.recognize_batch(images)
437    }
438
439    /// Get orientation model reference (if enabled)
440    pub fn ori_model(&self) -> Option<&OriModel> {
441        self.ori_model.as_ref()
442    }
443
444    /// Get detection model reference
445    pub fn det_model(&self) -> &DetModel {
446        &self.det_model
447    }
448
449    /// Get recognition model reference
450    pub fn rec_model(&self) -> &RecModel {
451        &self.rec_model
452    }
453
454    /// Get configuration
455    pub fn config(&self) -> &OcrEngineConfig {
456        &self.config
457    }
458
459    fn correct_orientation_with_model(
460        &self,
461        ori_model: &OriModel,
462        image: DynamicImage,
463    ) -> DynamicImage {
464        let result = match ori_model.classify(&image) {
465            Ok(result) => result,
466            Err(_) => return image,
467        };
468
469        if !result.is_valid(self.config.ori_min_confidence) {
470            return image;
471        }
472
473        if result.angle.rem_euclid(360) == 0 {
474            return image;
475        }
476
477        rotate_by_angle(&image, result.angle)
478    }
479}
480
481/// Builder for OCR engine
482pub struct OcrEngineBuilder {
483    det_model_path: Option<PathBuf>,
484    rec_model_path: Option<PathBuf>,
485    charset_path: Option<PathBuf>,
486    ori_model_path: Option<PathBuf>,
487    config: Option<OcrEngineConfig>,
488}
489
490impl OcrEngineBuilder {
491    /// Create a new builder
492    pub fn new() -> Self {
493        Self {
494            det_model_path: None,
495            rec_model_path: None,
496            charset_path: None,
497            ori_model_path: None,
498            config: None,
499        }
500    }
501
502    /// Set detection model path
503    pub fn with_det_model_path(mut self, path: impl AsRef<Path>) -> Self {
504        self.det_model_path = Some(path.as_ref().to_path_buf());
505        self
506    }
507
508    /// Set recognition model path
509    pub fn with_rec_model_path(mut self, path: impl AsRef<Path>) -> Self {
510        self.rec_model_path = Some(path.as_ref().to_path_buf());
511        self
512    }
513
514    /// Set charset path
515    pub fn with_charset_path(mut self, path: impl AsRef<Path>) -> Self {
516        self.charset_path = Some(path.as_ref().to_path_buf());
517        self
518    }
519
520    /// Set orientation model path
521    pub fn with_ori_model_path(mut self, path: impl AsRef<Path>) -> Self {
522        self.ori_model_path = Some(path.as_ref().to_path_buf());
523        self
524    }
525
526    /// Set engine configuration
527    pub fn with_config(mut self, config: OcrEngineConfig) -> Self {
528        self.config = Some(config);
529        self
530    }
531
532    /// Build OCR engine
533    pub fn build(self) -> OcrResult<OcrEngine> {
534        let det_model_path = self
535            .det_model_path
536            .ok_or_else(|| OcrError::InvalidParameter("Missing det_model_path".to_string()))?;
537        let rec_model_path = self
538            .rec_model_path
539            .ok_or_else(|| OcrError::InvalidParameter("Missing rec_model_path".to_string()))?;
540        let charset_path = self
541            .charset_path
542            .ok_or_else(|| OcrError::InvalidParameter("Missing charset_path".to_string()))?;
543
544        OcrEngine::build_with_paths(
545            det_model_path.as_path(),
546            rec_model_path.as_path(),
547            charset_path.as_path(),
548            self.ori_model_path.as_deref(),
549            self.config,
550        )
551    }
552}
553
554/// Detection-only engine
555pub struct DetOnlyEngine {
556    det_model: DetModel,
557}
558
559impl DetOnlyEngine {
560    /// Detect text regions in image
561    pub fn detect(&self, image: &DynamicImage) -> OcrResult<Vec<TextBox>> {
562        self.det_model.detect(image)
563    }
564
565    /// Detect and return cropped images
566    pub fn detect_and_crop(&self, image: &DynamicImage) -> OcrResult<Vec<(DynamicImage, TextBox)>> {
567        self.det_model.detect_and_crop(image)
568    }
569
570    /// Get detection model reference
571    pub fn model(&self) -> &DetModel {
572        &self.det_model
573    }
574}
575
576/// Recognition-only engine
577pub struct RecOnlyEngine {
578    rec_model: RecModel,
579}
580
581impl RecOnlyEngine {
582    /// Recognize a single image
583    pub fn recognize(&self, image: &DynamicImage) -> OcrResult<RecognitionResult> {
584        self.rec_model.recognize(image)
585    }
586
587    /// Return text only
588    pub fn recognize_text(&self, image: &DynamicImage) -> OcrResult<String> {
589        self.rec_model.recognize_text(image)
590    }
591
592    /// Batch recognition
593    pub fn recognize_batch(&self, images: &[DynamicImage]) -> OcrResult<Vec<RecognitionResult>> {
594        self.rec_model.recognize_batch(images)
595    }
596
597    /// Get recognition model reference
598    pub fn model(&self) -> &RecModel {
599        &self.rec_model
600    }
601}
602
603/// Convenience function: recognize from file
604///
605/// # Example
606///
607/// ```ignore
608/// let results = ocr_rs::ocr_file(
609///     "test.jpg",
610///     "det_model.mnn",
611///     "rec_model.mnn",
612///     "ppocr_keys.txt",
613/// )?;
614/// ```
615pub fn ocr_file(
616    image_path: impl AsRef<Path>,
617    det_model_path: impl AsRef<Path>,
618    rec_model_path: impl AsRef<Path>,
619    charset_path: impl AsRef<Path>,
620) -> OcrResult<Vec<OcrResult_>> {
621    let image = image::open(image_path)?;
622    let engine = OcrEngine::new(det_model_path, rec_model_path, charset_path, None)?;
623    engine.recognize(&image)
624}
625
626/// Convenience function: recognize from file with orientation model
627pub fn ocr_file_with_ori(
628    image_path: impl AsRef<Path>,
629    det_model_path: impl AsRef<Path>,
630    rec_model_path: impl AsRef<Path>,
631    charset_path: impl AsRef<Path>,
632    ori_model_path: impl AsRef<Path>,
633) -> OcrResult<Vec<OcrResult_>> {
634    let image = image::open(image_path)?;
635    let engine = OcrEngine::new_with_ori(
636        det_model_path,
637        rec_model_path,
638        charset_path,
639        ori_model_path,
640        None,
641    )?;
642    engine.recognize(&image)
643}
644
645fn rotate_by_angle(image: &DynamicImage, angle: i32) -> DynamicImage {
646    // The model reports rotation from horizontal; rotate back to correct.
647    match angle.rem_euclid(360) {
648        90 => DynamicImage::ImageRgb8(image::imageops::rotate270(&image.to_rgb8())),
649        180 => DynamicImage::ImageRgb8(image::imageops::rotate180(&image.to_rgb8())),
650        270 => DynamicImage::ImageRgb8(image::imageops::rotate90(&image.to_rgb8())),
651        _ => image.clone(),
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658
659    #[test]
660    fn test_ocr_result() {
661        let bbox = TextBox::new(imageproc::rect::Rect::at(0, 0).of_size(100, 20), 0.9);
662        let result = OcrResult_::new("Hello".to_string(), 0.95, bbox);
663
664        assert_eq!(result.text, "Hello");
665        assert_eq!(result.confidence, 0.95);
666    }
667}