Skip to main content

ocr_rs/
det.rs

1//! Text Detection Model
2//!
3//! Provides text region detection functionality based on PaddleOCR detection models
4
5use image::{DynamicImage, GenericImageView, Rgb, RgbImage};
6use imageproc::geometric_transformations::{warp_into, Interpolation, Projection};
7use imageproc::point::Point;
8use ndarray::ArrayD;
9use std::path::Path;
10
11use crate::error::{OcrError, OcrResult};
12use crate::mnn::{InferenceConfig, InferenceEngine};
13use crate::postprocess::{extract_boxes_with_unclip, TextBox};
14use crate::preprocess::{preprocess_for_det, resize_to_max_side, NormalizeParams};
15
16/// Detection precision mode
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum DetPrecisionMode {
19    /// Fast mode - single detection
20    #[default]
21    Fast,
22}
23
24/// Detection options
25#[derive(Debug, Clone)]
26pub struct DetOptions {
27    /// Maximum image side length limit (will be scaled if exceeded)
28    pub max_side_len: u32,
29    /// Bounding box binarization threshold (0.0 - 1.0)
30    pub box_threshold: f32,
31    /// Text box expansion ratio
32    pub unclip_ratio: f32,
33    /// Pixel-level segmentation threshold
34    pub score_threshold: f32,
35    /// Minimum bounding box area
36    pub min_area: u32,
37    /// Bounding box border expansion
38    pub box_border: u32,
39    /// Whether to merge adjacent text boxes
40    pub merge_boxes: bool,
41    /// Merge distance threshold
42    pub merge_threshold: i32,
43    /// Precision mode
44    pub precision_mode: DetPrecisionMode,
45    /// Scale ratios for multi-scale detection (high precision mode only)
46    pub multi_scales: Vec<f32>,
47    /// Block size for block detection (high precision mode only)
48    pub block_size: u32,
49    /// Overlap area for block detection
50    pub block_overlap: u32,
51    /// NMS IoU threshold
52    pub nms_threshold: f32,
53}
54
55impl Default for DetOptions {
56    fn default() -> Self {
57        Self {
58            max_side_len: 960,
59            box_threshold: 0.5,
60            unclip_ratio: 1.5,
61            score_threshold: 0.3,
62            min_area: 16,
63            box_border: 5,
64            merge_boxes: false,
65            merge_threshold: 10,
66            precision_mode: DetPrecisionMode::Fast,
67            multi_scales: vec![0.5, 1.0, 1.5],
68            block_size: 640,
69            block_overlap: 100,
70            nms_threshold: 0.3,
71        }
72    }
73}
74
75impl DetOptions {
76    /// Create new detection options
77    pub fn new() -> Self {
78        Self::default()
79    }
80
81    /// Set maximum side length
82    pub fn with_max_side_len(mut self, len: u32) -> Self {
83        self.max_side_len = len;
84        self
85    }
86
87    /// Set bounding box threshold
88    pub fn with_box_threshold(mut self, threshold: f32) -> Self {
89        self.box_threshold = threshold;
90        self
91    }
92
93    /// Set segmentation threshold
94    pub fn with_score_threshold(mut self, threshold: f32) -> Self {
95        self.score_threshold = threshold;
96        self
97    }
98
99    /// Set minimum area
100    pub fn with_min_area(mut self, area: u32) -> Self {
101        self.min_area = area;
102        self
103    }
104
105    /// Set box border expansion
106    pub fn with_box_border(mut self, border: u32) -> Self {
107        self.box_border = border;
108        self
109    }
110
111    /// Enable box merging
112    pub fn with_merge_boxes(mut self, merge: bool) -> Self {
113        self.merge_boxes = merge;
114        self
115    }
116
117    /// Set merge threshold
118    pub fn with_merge_threshold(mut self, threshold: i32) -> Self {
119        self.merge_threshold = threshold;
120        self
121    }
122
123    /// Set precision mode
124    pub fn with_precision_mode(mut self, mode: DetPrecisionMode) -> Self {
125        self.precision_mode = mode;
126        self
127    }
128
129    /// Set multi-scale ratios
130    pub fn with_multi_scales(mut self, scales: Vec<f32>) -> Self {
131        self.multi_scales = scales;
132        self
133    }
134
135    /// Set block size
136    pub fn with_block_size(mut self, size: u32) -> Self {
137        self.block_size = size;
138        self
139    }
140
141    /// Fast mode preset
142    pub fn fast() -> Self {
143        Self {
144            max_side_len: 960,
145            precision_mode: DetPrecisionMode::Fast,
146            ..Default::default()
147        }
148    }
149}
150
151/// Text detection model
152pub struct DetModel {
153    engine: InferenceEngine,
154    options: DetOptions,
155    normalize_params: NormalizeParams,
156}
157
158impl DetModel {
159    /// Create detector from model file
160    ///
161    /// # Parameters
162    /// - `model_path`: Model file path (.mnn format)
163    /// - `config`: Optional inference config
164    pub fn from_file(
165        model_path: impl AsRef<Path>,
166        config: Option<InferenceConfig>,
167    ) -> OcrResult<Self> {
168        let engine = InferenceEngine::from_file(model_path, config)?;
169        Ok(Self {
170            engine,
171            options: DetOptions::default(),
172            normalize_params: NormalizeParams::paddle_det(),
173        })
174    }
175
176    /// Create detector from model bytes
177    pub fn from_bytes(model_bytes: &[u8], config: Option<InferenceConfig>) -> OcrResult<Self> {
178        let engine = InferenceEngine::from_buffer(model_bytes, config)?;
179        Ok(Self {
180            engine,
181            options: DetOptions::default(),
182            normalize_params: NormalizeParams::paddle_det(),
183        })
184    }
185
186    /// Set detection options
187    pub fn with_options(mut self, options: DetOptions) -> Self {
188        self.options = options;
189        self
190    }
191
192    /// Get current detection options
193    pub fn options(&self) -> &DetOptions {
194        &self.options
195    }
196
197    /// Modify detection options
198    pub fn options_mut(&mut self) -> &mut DetOptions {
199        &mut self.options
200    }
201
202    /// Detect text regions in image
203    ///
204    /// # Parameters
205    /// - `image`: Input image
206    ///
207    /// # Returns
208    /// List of detected text bounding boxes
209    pub fn detect(&self, image: &DynamicImage) -> OcrResult<Vec<TextBox>> {
210        self.detect_fast(image)
211    }
212
213    /// Detect and return cropped text images
214    ///
215    /// # Parameters
216    /// - `image`: Input image
217    ///
218    /// # Returns
219    /// List of (text image, corresponding bounding box)
220    pub fn detect_and_crop(&self, image: &DynamicImage) -> OcrResult<Vec<(DynamicImage, TextBox)>> {
221        let boxes = self.detect_expanded(image)?;
222        let rotated_source = if boxes.iter().any(|text_box| text_box.points.is_some()) {
223            Some(image.to_rgb8())
224        } else {
225            None
226        };
227
228        let mut results = Vec::with_capacity(boxes.len());
229
230        for text_box in boxes {
231            // Crop image
232            let cropped = crop_text_region(image, rotated_source.as_ref(), &text_box);
233
234            results.push((cropped, text_box));
235        }
236
237        Ok(results)
238    }
239
240    pub(crate) fn detect_expanded(&self, image: &DynamicImage) -> OcrResult<Vec<TextBox>> {
241        let boxes = self.detect(image)?;
242        let (width, height) = image.dimensions();
243
244        Ok(boxes
245            .into_iter()
246            .map(|text_box| text_box.expand(self.options.box_border, width, height))
247            .collect())
248    }
249
250    /// Fast detection (single inference)
251    fn detect_fast(&self, image: &DynamicImage) -> OcrResult<Vec<TextBox>> {
252        let (original_width, original_height) = image.dimensions();
253
254        // Scale image
255        let scaled = self.scale_image(image)?;
256        let (scaled_width, scaled_height) = scaled.dimensions();
257
258        // Preprocess
259        let input = preprocess_for_det(&scaled, &self.normalize_params)?;
260
261        // Inference (using dynamic shape)
262        let output = self.engine.run_dynamic(input.view().into_dyn())?;
263
264        // Post-processing - output shape matches input (including padding)
265        let output_shape = output.shape();
266        let out_w = output_shape[3] as u32;
267        let out_h = output_shape[2] as u32;
268
269        let boxes = self.postprocess_output(
270            &output,
271            out_w,
272            out_h,
273            scaled_width,
274            scaled_height,
275            original_width,
276            original_height,
277        )?;
278
279        Ok(boxes)
280    }
281
282    /// Balanced mode detection (multi-scale)
283    /// Scale image to maximum side length limit
284    fn scale_image(&self, image: &DynamicImage) -> OcrResult<DynamicImage> {
285        resize_to_max_side(image, self.options.max_side_len)
286    }
287
288    /// Post-process inference output
289    fn postprocess_output(
290        &self,
291        output: &ArrayD<f32>,
292        out_w: u32,
293        out_h: u32,
294        scaled_width: u32,
295        scaled_height: u32,
296        original_width: u32,
297        original_height: u32,
298    ) -> OcrResult<Vec<TextBox>> {
299        // Retrieve output data
300        let output_shape = output.shape();
301        if output_shape.len() < 3 {
302            return Err(OcrError::PostprocessError(
303                "Detection model output shape invalid".to_string(),
304            ));
305        }
306
307        // Binarization
308        let binary_mask: Vec<u8> = output
309            .iter()
310            .map(|&v| {
311                if v > self.options.score_threshold {
312                    255u8
313                } else {
314                    0u8
315                }
316            })
317            .collect();
318
319        // Extract bounding boxes (with unclip expansion)
320        // DB algorithm needs to expand detected contours because model output segmentation mask is usually smaller than actual text region
321        let boxes = extract_boxes_with_unclip(
322            &binary_mask,
323            out_w,
324            out_h,
325            scaled_width,
326            scaled_height,
327            original_width,
328            original_height,
329            self.options.min_area,
330            self.options.unclip_ratio,
331        );
332
333        Ok(boxes)
334    }
335}
336
337fn crop_text_region(
338    image: &DynamicImage,
339    rotated_source: Option<&RgbImage>,
340    text_box: &TextBox,
341) -> DynamicImage {
342    if let (Some(points), Some(source)) = (text_box.points, rotated_source) {
343        if let Some(cropped) = crop_rotated_region(source, points) {
344            return cropped;
345        }
346    }
347
348    crop_axis_aligned_region(image, text_box)
349}
350
351fn crop_axis_aligned_region(image: &DynamicImage, text_box: &TextBox) -> DynamicImage {
352    let (image_width, image_height) = image.dimensions();
353    let x = text_box.rect.left().max(0) as u32;
354    let y = text_box.rect.top().max(0) as u32;
355    let width = text_box
356        .rect
357        .width()
358        .min(image_width.saturating_sub(x))
359        .max(1);
360    let height = text_box
361        .rect
362        .height()
363        .min(image_height.saturating_sub(y))
364        .max(1);
365
366    image.crop_imm(x, y, width, height)
367}
368
369fn crop_rotated_region(source: &RgbImage, points: [Point<f32>; 4]) -> Option<DynamicImage> {
370    let crop_width = distance(points[0], points[1])
371        .max(distance(points[3], points[2]))
372        .round()
373        .max(1.0) as u32;
374    let crop_height = distance(points[0], points[3])
375        .max(distance(points[1], points[2]))
376        .round()
377        .max(1.0) as u32;
378
379    if crop_width <= 1 || crop_height <= 1 {
380        return None;
381    }
382
383    let source_points = points.map(|point| (point.x, point.y));
384    let target_points = [
385        (0.0, 0.0),
386        (crop_width.saturating_sub(1) as f32, 0.0),
387        (
388            crop_width.saturating_sub(1) as f32,
389            crop_height.saturating_sub(1) as f32,
390        ),
391        (0.0, crop_height.saturating_sub(1) as f32),
392    ];
393
394    let projection = Projection::from_control_points(source_points, target_points)?;
395    let mut output = RgbImage::new(crop_width, crop_height);
396    warp_into(
397        source,
398        &projection,
399        Interpolation::Bilinear,
400        Rgb([255, 255, 255]),
401        &mut output,
402    );
403
404    Some(DynamicImage::ImageRgb8(output))
405}
406
407fn distance(a: Point<f32>, b: Point<f32>) -> f32 {
408    let dx = a.x - b.x;
409    let dy = a.y - b.y;
410    (dx * dx + dy * dy).sqrt()
411}
412
413/// Low-level detection API
414impl DetModel {
415    /// Raw inference interface
416    ///
417    /// Execute model inference directly without preprocessing and postprocessing
418    ///
419    /// # Parameters
420    /// - `input`: Preprocessed input tensor [1, 3, H, W]
421    ///
422    /// # Returns
423    /// Model raw output
424    pub fn run_raw(&self, input: ndarray::ArrayViewD<f32>) -> OcrResult<ArrayD<f32>> {
425        Ok(self.engine.run_dynamic(input)?)
426    }
427
428    /// Get model input shape
429    pub fn input_shape(&self) -> &[usize] {
430        self.engine.input_shape()
431    }
432
433    /// Get model output shape
434    pub fn output_shape(&self) -> &[usize] {
435        self.engine.output_shape()
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn test_det_options_default() {
445        let opts = DetOptions::default();
446        assert_eq!(opts.max_side_len, 960);
447        assert_eq!(opts.box_threshold, 0.5);
448        assert_eq!(opts.unclip_ratio, 1.5);
449        assert_eq!(opts.score_threshold, 0.3);
450        assert_eq!(opts.min_area, 16);
451        assert_eq!(opts.box_border, 5);
452        assert!(!opts.merge_boxes);
453        assert_eq!(opts.merge_threshold, 10);
454        assert_eq!(opts.precision_mode, DetPrecisionMode::Fast);
455        assert_eq!(opts.nms_threshold, 0.3);
456    }
457
458    #[test]
459    fn test_det_options_fast() {
460        let opts = DetOptions::fast();
461        assert_eq!(opts.max_side_len, 960);
462        assert_eq!(opts.precision_mode, DetPrecisionMode::Fast);
463    }
464
465    #[test]
466    fn test_det_options_builder() {
467        let opts = DetOptions::new()
468            .with_max_side_len(1280)
469            .with_box_threshold(0.6)
470            .with_score_threshold(0.4)
471            .with_min_area(32)
472            .with_box_border(10)
473            .with_merge_boxes(true)
474            .with_merge_threshold(20)
475            .with_precision_mode(DetPrecisionMode::Fast)
476            .with_multi_scales(vec![0.5, 1.0, 1.5])
477            .with_block_size(800);
478
479        assert_eq!(opts.max_side_len, 1280);
480        assert_eq!(opts.box_threshold, 0.6);
481        assert_eq!(opts.score_threshold, 0.4);
482        assert_eq!(opts.min_area, 32);
483        assert_eq!(opts.box_border, 10);
484        assert!(opts.merge_boxes);
485        assert_eq!(opts.merge_threshold, 20);
486        assert_eq!(opts.precision_mode, DetPrecisionMode::Fast);
487        assert_eq!(opts.multi_scales, vec![0.5, 1.0, 1.5]);
488        assert_eq!(opts.block_size, 800);
489    }
490
491    #[test]
492    fn test_det_precision_mode_default() {
493        let mode = DetPrecisionMode::default();
494        assert_eq!(mode, DetPrecisionMode::Fast);
495    }
496
497    #[test]
498    fn test_det_precision_mode_equality() {
499        assert_eq!(DetPrecisionMode::Fast, DetPrecisionMode::Fast);
500    }
501
502    #[test]
503    fn test_det_options_chaining() {
504        // Test that chaining calls do not lose previous settings
505        let opts = DetOptions::new()
506            .with_max_side_len(1000)
507            .with_box_threshold(0.7);
508
509        assert_eq!(opts.max_side_len, 1000);
510        assert_eq!(opts.box_threshold, 0.7);
511        // Other values should be default values
512        assert_eq!(opts.score_threshold, 0.3);
513    }
514
515    #[test]
516    fn test_det_options_presets_are_valid() {
517        // Ensure preset parameter values are within valid ranges
518        let fast = DetOptions::fast();
519        assert!(fast.box_threshold >= 0.0 && fast.box_threshold <= 1.0);
520        assert!(fast.score_threshold >= 0.0 && fast.score_threshold <= 1.0);
521        assert!(fast.nms_threshold >= 0.0 && fast.nms_threshold <= 1.0);
522    }
523}