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#[derive(Debug, Clone, Copy)]
152struct DetectionGeometry {
153    output_width: u32,
154    output_height: u32,
155    scaled_width: u32,
156    scaled_height: u32,
157    original_width: u32,
158    original_height: u32,
159}
160
161/// Text detection model
162pub struct DetModel {
163    engine: InferenceEngine,
164    options: DetOptions,
165    normalize_params: NormalizeParams,
166}
167
168impl DetModel {
169    /// Create detector from model file
170    ///
171    /// # Parameters
172    /// - `model_path`: Model file path (.mnn format)
173    /// - `config`: Optional inference config
174    pub fn from_file(
175        model_path: impl AsRef<Path>,
176        config: Option<InferenceConfig>,
177    ) -> OcrResult<Self> {
178        let engine = InferenceEngine::from_file(model_path, config)?;
179        Ok(Self {
180            engine,
181            options: DetOptions::default(),
182            normalize_params: NormalizeParams::paddle_det(),
183        })
184    }
185
186    /// Create detector from model bytes
187    pub fn from_bytes(model_bytes: &[u8], config: Option<InferenceConfig>) -> OcrResult<Self> {
188        let engine = InferenceEngine::from_buffer(model_bytes, config)?;
189        Ok(Self {
190            engine,
191            options: DetOptions::default(),
192            normalize_params: NormalizeParams::paddle_det(),
193        })
194    }
195
196    /// Set detection options
197    pub fn with_options(mut self, options: DetOptions) -> Self {
198        self.options = options;
199        self
200    }
201
202    /// Get current detection options
203    pub fn options(&self) -> &DetOptions {
204        &self.options
205    }
206
207    /// Modify detection options
208    pub fn options_mut(&mut self) -> &mut DetOptions {
209        &mut self.options
210    }
211
212    /// Detect text regions in image
213    ///
214    /// # Parameters
215    /// - `image`: Input image
216    ///
217    /// # Returns
218    /// List of detected text bounding boxes
219    pub fn detect(&self, image: &DynamicImage) -> OcrResult<Vec<TextBox>> {
220        self.detect_fast(image)
221    }
222
223    /// Detect and return cropped text images
224    ///
225    /// # Parameters
226    /// - `image`: Input image
227    ///
228    /// # Returns
229    /// List of (text image, corresponding bounding box)
230    pub fn detect_and_crop(&self, image: &DynamicImage) -> OcrResult<Vec<(DynamicImage, TextBox)>> {
231        let boxes = self.detect_expanded(image)?;
232        let rotated_source = if boxes.iter().any(|text_box| text_box.points.is_some()) {
233            Some(image.to_rgb8())
234        } else {
235            None
236        };
237
238        let mut results = Vec::with_capacity(boxes.len());
239
240        for text_box in boxes {
241            // Crop image
242            let cropped = crop_text_region(image, rotated_source.as_ref(), &text_box);
243
244            results.push((cropped, text_box));
245        }
246
247        Ok(results)
248    }
249
250    pub(crate) fn detect_expanded(&self, image: &DynamicImage) -> OcrResult<Vec<TextBox>> {
251        let boxes = self.detect(image)?;
252        let (width, height) = image.dimensions();
253
254        Ok(boxes
255            .into_iter()
256            .map(|text_box| text_box.expand(self.options.box_border, width, height))
257            .collect())
258    }
259
260    /// Fast detection (single inference)
261    fn detect_fast(&self, image: &DynamicImage) -> OcrResult<Vec<TextBox>> {
262        let (original_width, original_height) = image.dimensions();
263
264        // Scale image
265        let scaled = self.scale_image(image)?;
266        let (scaled_width, scaled_height) = scaled.dimensions();
267
268        // Preprocess
269        let input = preprocess_for_det(&scaled, &self.normalize_params)?;
270
271        // Inference (using dynamic shape)
272        let output = self.engine.run_dynamic(input.view().into_dyn())?;
273
274        // Post-processing - output shape matches input (including padding)
275        let output_shape = output.shape();
276        let out_w = output_shape[3] as u32;
277        let out_h = output_shape[2] as u32;
278
279        let geometry = DetectionGeometry {
280            output_width: out_w,
281            output_height: out_h,
282            scaled_width,
283            scaled_height,
284            original_width,
285            original_height,
286        };
287        let boxes = self.postprocess_output(&output, geometry)?;
288
289        Ok(boxes)
290    }
291
292    /// Balanced mode detection (multi-scale)
293    /// Scale image to maximum side length limit
294    fn scale_image(&self, image: &DynamicImage) -> OcrResult<DynamicImage> {
295        resize_to_max_side(image, self.options.max_side_len)
296    }
297
298    /// Post-process inference output
299    fn postprocess_output(
300        &self,
301        output: &ArrayD<f32>,
302        geometry: DetectionGeometry,
303    ) -> OcrResult<Vec<TextBox>> {
304        // Retrieve output data
305        let output_shape = output.shape();
306        if output_shape.len() < 3 {
307            return Err(OcrError::PostprocessError(
308                "Detection model output shape invalid".to_string(),
309            ));
310        }
311
312        // Binarization
313        let binary_mask: Vec<u8> = output
314            .iter()
315            .map(|&v| {
316                if v > self.options.score_threshold {
317                    255u8
318                } else {
319                    0u8
320                }
321            })
322            .collect();
323
324        // Extract bounding boxes (with unclip expansion)
325        // DB algorithm needs to expand detected contours because model output segmentation mask is usually smaller than actual text region
326        let boxes = extract_boxes_with_unclip(
327            &binary_mask,
328            geometry.output_width,
329            geometry.output_height,
330            geometry.scaled_width,
331            geometry.scaled_height,
332            geometry.original_width,
333            geometry.original_height,
334            self.options.min_area,
335            self.options.unclip_ratio,
336        );
337
338        Ok(boxes)
339    }
340}
341
342fn crop_text_region(
343    image: &DynamicImage,
344    rotated_source: Option<&RgbImage>,
345    text_box: &TextBox,
346) -> DynamicImage {
347    if let (Some(points), Some(source)) = (text_box.points, rotated_source) {
348        if let Some(cropped) = crop_rotated_region(source, points) {
349            return cropped;
350        }
351    }
352
353    crop_axis_aligned_region(image, text_box)
354}
355
356fn crop_axis_aligned_region(image: &DynamicImage, text_box: &TextBox) -> DynamicImage {
357    let (image_width, image_height) = image.dimensions();
358    let x = text_box.rect.left().max(0) as u32;
359    let y = text_box.rect.top().max(0) as u32;
360    let width = text_box
361        .rect
362        .width()
363        .min(image_width.saturating_sub(x))
364        .max(1);
365    let height = text_box
366        .rect
367        .height()
368        .min(image_height.saturating_sub(y))
369        .max(1);
370
371    image.crop_imm(x, y, width, height)
372}
373
374fn crop_rotated_region(source: &RgbImage, points: [Point<f32>; 4]) -> Option<DynamicImage> {
375    let crop_width = distance(points[0], points[1])
376        .max(distance(points[3], points[2]))
377        .round()
378        .max(1.0) as u32;
379    let crop_height = distance(points[0], points[3])
380        .max(distance(points[1], points[2]))
381        .round()
382        .max(1.0) as u32;
383
384    if crop_width <= 1 || crop_height <= 1 {
385        return None;
386    }
387
388    let source_points = points.map(|point| (point.x, point.y));
389    let target_points = [
390        (0.0, 0.0),
391        (crop_width.saturating_sub(1) as f32, 0.0),
392        (
393            crop_width.saturating_sub(1) as f32,
394            crop_height.saturating_sub(1) as f32,
395        ),
396        (0.0, crop_height.saturating_sub(1) as f32),
397    ];
398
399    let projection = Projection::from_control_points(source_points, target_points)?;
400    let mut output = RgbImage::new(crop_width, crop_height);
401    warp_into(
402        source,
403        &projection,
404        Interpolation::Bilinear,
405        Rgb([255, 255, 255]),
406        &mut output,
407    );
408
409    Some(DynamicImage::ImageRgb8(output))
410}
411
412fn distance(a: Point<f32>, b: Point<f32>) -> f32 {
413    let dx = a.x - b.x;
414    let dy = a.y - b.y;
415    (dx * dx + dy * dy).sqrt()
416}
417
418/// Low-level detection API
419impl DetModel {
420    /// Raw inference interface
421    ///
422    /// Execute model inference directly without preprocessing and postprocessing
423    ///
424    /// # Parameters
425    /// - `input`: Preprocessed input tensor [1, 3, H, W]
426    ///
427    /// # Returns
428    /// Model raw output
429    pub fn run_raw(&self, input: ndarray::ArrayViewD<f32>) -> OcrResult<ArrayD<f32>> {
430        Ok(self.engine.run_dynamic(input)?)
431    }
432
433    /// Get model input shape
434    pub fn input_shape(&self) -> &[usize] {
435        self.engine.input_shape()
436    }
437
438    /// Get model output shape
439    pub fn output_shape(&self) -> &[usize] {
440        self.engine.output_shape()
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn test_det_options_default() {
450        let opts = DetOptions::default();
451        assert_eq!(opts.max_side_len, 960);
452        assert_eq!(opts.box_threshold, 0.5);
453        assert_eq!(opts.unclip_ratio, 1.5);
454        assert_eq!(opts.score_threshold, 0.3);
455        assert_eq!(opts.min_area, 16);
456        assert_eq!(opts.box_border, 5);
457        assert!(!opts.merge_boxes);
458        assert_eq!(opts.merge_threshold, 10);
459        assert_eq!(opts.precision_mode, DetPrecisionMode::Fast);
460        assert_eq!(opts.nms_threshold, 0.3);
461    }
462
463    #[test]
464    fn test_det_options_fast() {
465        let opts = DetOptions::fast();
466        assert_eq!(opts.max_side_len, 960);
467        assert_eq!(opts.precision_mode, DetPrecisionMode::Fast);
468    }
469
470    #[test]
471    fn test_det_options_builder() {
472        let opts = DetOptions::new()
473            .with_max_side_len(1280)
474            .with_box_threshold(0.6)
475            .with_score_threshold(0.4)
476            .with_min_area(32)
477            .with_box_border(10)
478            .with_merge_boxes(true)
479            .with_merge_threshold(20)
480            .with_precision_mode(DetPrecisionMode::Fast)
481            .with_multi_scales(vec![0.5, 1.0, 1.5])
482            .with_block_size(800);
483
484        assert_eq!(opts.max_side_len, 1280);
485        assert_eq!(opts.box_threshold, 0.6);
486        assert_eq!(opts.score_threshold, 0.4);
487        assert_eq!(opts.min_area, 32);
488        assert_eq!(opts.box_border, 10);
489        assert!(opts.merge_boxes);
490        assert_eq!(opts.merge_threshold, 20);
491        assert_eq!(opts.precision_mode, DetPrecisionMode::Fast);
492        assert_eq!(opts.multi_scales, vec![0.5, 1.0, 1.5]);
493        assert_eq!(opts.block_size, 800);
494    }
495
496    #[test]
497    fn test_det_precision_mode_default() {
498        let mode = DetPrecisionMode::default();
499        assert_eq!(mode, DetPrecisionMode::Fast);
500    }
501
502    #[test]
503    fn test_det_precision_mode_equality() {
504        assert_eq!(DetPrecisionMode::Fast, DetPrecisionMode::Fast);
505    }
506
507    #[test]
508    fn test_det_options_chaining() {
509        // Test that chaining calls do not lose previous settings
510        let opts = DetOptions::new()
511            .with_max_side_len(1000)
512            .with_box_threshold(0.7);
513
514        assert_eq!(opts.max_side_len, 1000);
515        assert_eq!(opts.box_threshold, 0.7);
516        // Other values should be default values
517        assert_eq!(opts.score_threshold, 0.3);
518    }
519
520    #[test]
521    fn test_det_options_presets_are_valid() {
522        // Ensure preset parameter values are within valid ranges
523        let fast = DetOptions::fast();
524        assert!(fast.box_threshold >= 0.0 && fast.box_threshold <= 1.0);
525        assert!(fast.score_threshold >= 0.0 && fast.score_threshold <= 1.0);
526        assert!(fast.nms_threshold >= 0.0 && fast.nms_threshold <= 1.0);
527    }
528}