Skip to main content

ocr_rs/
postprocess.rs

1//! Postprocessing Utilities
2//!
3//! Provides post-processing functions for text detection results, including bounding box extraction, NMS, box merging, etc.
4
5use image::GrayImage;
6use imageproc::contours::{find_contours, Contour};
7use imageproc::point::Point;
8use imageproc::rect::Rect;
9
10/// Text bounding box
11#[derive(Debug, Clone)]
12pub struct TextBox {
13    /// Bounding box rectangle
14    pub rect: Rect,
15    /// Confidence score
16    pub score: f32,
17    /// Four corner points (optional, for rotated boxes)
18    pub points: Option<[Point<f32>; 4]>,
19}
20
21impl TextBox {
22    /// Create new text bounding box
23    pub fn new(rect: Rect, score: f32) -> Self {
24        Self {
25            rect,
26            score,
27            points: None,
28        }
29    }
30
31    /// Create with corner points
32    pub fn with_points(rect: Rect, score: f32, points: [Point<f32>; 4]) -> Self {
33        Self {
34            rect,
35            score,
36            points: Some(points),
37        }
38    }
39
40    /// Calculate area
41    pub fn area(&self) -> u32 {
42        self.rect.width() * self.rect.height()
43    }
44
45    /// Expand bounding box
46    pub fn expand(&self, border: u32, max_width: u32, max_height: u32) -> Self {
47        let x = (self.rect.left() - border as i32).max(0) as u32;
48        let y = (self.rect.top() - border as i32).max(0) as u32;
49        let right = ((self.rect.left() as u32 + self.rect.width()) + border).min(max_width);
50        let bottom = ((self.rect.top() as u32 + self.rect.height()) + border).min(max_height);
51
52        // 确保 right >= x 和 bottom >= y,避免减法溢出
53        let width = if right > x { right - x } else { 1 };
54        let height = if bottom > y { bottom - y } else { 1 };
55
56        Self {
57            rect: Rect::at(x as i32, y as i32).of_size(width, height),
58            score: self.score,
59            points: self
60                .points
61                .map(|points| expand_ordered_points(points, border as f32, max_width, max_height)),
62        }
63    }
64}
65
66/// Extract text bounding boxes from segmentation mask
67///
68/// # Parameters
69/// - `mask`: Binarized mask (0 or 255)
70/// - `width`: Mask width
71/// - `height`: Mask height
72/// - `original_width`: Original image width
73/// - `original_height`: Original image height
74/// - `min_area`: Minimum bounding box area
75/// - `box_threshold`: Bounding box score threshold
76pub fn extract_boxes_from_mask(
77    mask: &[u8],
78    width: u32,
79    height: u32,
80    original_width: u32,
81    original_height: u32,
82    min_area: u32,
83    _box_threshold: f32,
84) -> Vec<TextBox> {
85    extract_boxes_from_mask_with_padding(
86        mask,
87        width,
88        height,
89        width,
90        height,
91        original_width,
92        original_height,
93        min_area,
94        _box_threshold,
95    )
96}
97
98/// Extract text bounding boxes from segmentation mask with padding
99///
100/// # Parameters
101/// - `mask`: Binarized mask (0 or 255)
102/// - `mask_width`: Mask width (including padding)
103/// - `mask_height`: Mask height (including padding)
104/// - `valid_width`: Valid region width (excluding padding)
105/// - `valid_height`: Valid region height (excluding padding)
106/// - `original_width`: Original image width
107/// - `original_height`: Original image height
108/// - `min_area`: Minimum bounding box area
109/// - `box_threshold`: Bounding box score threshold
110#[allow(clippy::too_many_arguments)]
111pub fn extract_boxes_from_mask_with_padding(
112    mask: &[u8],
113    mask_width: u32,
114    mask_height: u32,
115    valid_width: u32,
116    valid_height: u32,
117    original_width: u32,
118    original_height: u32,
119    min_area: u32,
120    _box_threshold: f32,
121) -> Vec<TextBox> {
122    extract_boxes_with_unclip(
123        mask,
124        mask_width,
125        mask_height,
126        valid_width,
127        valid_height,
128        original_width,
129        original_height,
130        min_area,
131        1.5, // 默认 unclip_ratio
132    )
133}
134
135/// Extract text bounding boxes from segmentation mask (with unclip expansion)
136///
137/// Core of DB algorithm is to perform unclip expansion on detected contours,
138/// because model output segmentation mask is usually smaller than actual text region.
139#[allow(clippy::too_many_arguments)]
140pub fn extract_boxes_with_unclip(
141    mask: &[u8],
142    mask_width: u32,
143    mask_height: u32,
144    valid_width: u32,
145    valid_height: u32,
146    original_width: u32,
147    original_height: u32,
148    min_area: u32,
149    unclip_ratio: f32,
150) -> Vec<TextBox> {
151    // Create grayscale image
152    let gray_image = GrayImage::from_raw(mask_width, mask_height, mask.to_vec())
153        .unwrap_or_else(|| GrayImage::new(mask_width, mask_height));
154
155    // Find contours
156    let contours = find_contours::<i32>(&gray_image);
157
158    // Calculate scale ratio (from valid region to original image)
159    let scale_x = original_width as f32 / valid_width as f32;
160    let scale_y = original_height as f32 / valid_height as f32;
161
162    let mut boxes = Vec::new();
163
164    for contour in contours {
165        // Only keep outer contours (without parent), filter out inner/nested contours
166        // This avoids producing overlapping detection boxes
167        if contour.parent.is_some() {
168            continue;
169        }
170
171        if contour.points.len() < 4 {
172            continue;
173        }
174
175        let contour_points = contour_points_in_valid_region(&contour, valid_width, valid_height);
176        if contour_points.len() < 4 {
177            continue;
178        }
179
180        let Some(rotated_box) = minimum_area_rect(&contour_points) else {
181            continue;
182        };
183
184        if rotated_box.area() < min_area as f32 {
185            continue;
186        }
187
188        let expanded_points = rotated_box
189            .expand(unclip_ratio)
190            .clamped_points(valid_width, valid_height);
191        let scaled_points = scale_and_order_points(
192            expanded_points,
193            scale_x,
194            scale_y,
195            original_width,
196            original_height,
197        );
198
199        if let Some(rect) =
200            rect_from_ordered_points(&scaled_points, original_width, original_height)
201        {
202            boxes.push(TextBox::with_points(rect, 1.0, scaled_points));
203        }
204    }
205
206    boxes
207}
208
209fn contour_points_in_valid_region(
210    contour: &Contour<i32>,
211    valid_width: u32,
212    valid_height: u32,
213) -> Vec<Point<f32>> {
214    let max_x = valid_width.saturating_sub(1) as f32;
215    let max_y = valid_height.saturating_sub(1) as f32;
216
217    contour
218        .points
219        .iter()
220        .filter(|point| point.x >= 0 && point.y >= 0)
221        .filter(|point| point.x < valid_width as i32 && point.y < valid_height as i32)
222        .map(|point| Point::new((point.x as f32).min(max_x), (point.y as f32).min(max_y)))
223        .collect()
224}
225
226#[derive(Debug, Clone, Copy)]
227struct RotatedBox {
228    center: Point<f32>,
229    width: f32,
230    height: f32,
231    angle: f32,
232}
233
234impl RotatedBox {
235    fn area(&self) -> f32 {
236        self.width * self.height
237    }
238
239    fn perimeter(&self) -> f32 {
240        2.0 * (self.width + self.height)
241    }
242
243    fn expand(self, unclip_ratio: f32) -> Self {
244        let distance = (self.area() * unclip_ratio / self.perimeter()).max(1.0);
245        Self {
246            width: self.width + distance * 2.0,
247            height: self.height + distance * 2.0,
248            ..self
249        }
250    }
251
252    fn clamped_points(&self, valid_width: u32, valid_height: u32) -> [Point<f32>; 4] {
253        let cos = self.angle.cos();
254        let sin = self.angle.sin();
255        let half_w = self.width * 0.5;
256        let half_h = self.height * 0.5;
257        let corners = [
258            (-half_w, -half_h),
259            (half_w, -half_h),
260            (half_w, half_h),
261            (-half_w, half_h),
262        ];
263        let max_x = valid_width.saturating_sub(1) as f32;
264        let max_y = valid_height.saturating_sub(1) as f32;
265
266        let points = corners.map(|(x, y)| {
267            Point::new(
268                (self.center.x + x * cos - y * sin).clamp(0.0, max_x),
269                (self.center.y + x * sin + y * cos).clamp(0.0, max_y),
270            )
271        });
272
273        order_points(points)
274    }
275}
276
277fn minimum_area_rect(points: &[Point<f32>]) -> Option<RotatedBox> {
278    let hull = convex_hull(points);
279    if hull.len() < 3 {
280        return None;
281    }
282
283    let mut best: Option<RotatedBox> = None;
284    let mut best_area = f32::INFINITY;
285
286    for i in 0..hull.len() {
287        let p1 = hull[i];
288        let p2 = hull[(i + 1) % hull.len()];
289        let dx = p2.x - p1.x;
290        let dy = p2.y - p1.y;
291        if dx.abs() < f32::EPSILON && dy.abs() < f32::EPSILON {
292            continue;
293        }
294
295        let angle = dy.atan2(dx);
296        let cos = angle.cos();
297        let sin = angle.sin();
298
299        let mut min_x = f32::INFINITY;
300        let mut max_x = f32::NEG_INFINITY;
301        let mut min_y = f32::INFINITY;
302        let mut max_y = f32::NEG_INFINITY;
303
304        for point in &hull {
305            let x = point.x * cos + point.y * sin;
306            let y = -point.x * sin + point.y * cos;
307            min_x = min_x.min(x);
308            max_x = max_x.max(x);
309            min_y = min_y.min(y);
310            max_y = max_y.max(y);
311        }
312
313        let width = max_x - min_x;
314        let height = max_y - min_y;
315        let area = width * height;
316        if width <= 0.0 || height <= 0.0 || area >= best_area {
317            continue;
318        }
319
320        let center_x = (min_x + max_x) * 0.5;
321        let center_y = (min_y + max_y) * 0.5;
322        let center = Point::new(
323            center_x * cos - center_y * sin,
324            center_x * sin + center_y * cos,
325        );
326
327        best_area = area;
328        best = Some(RotatedBox {
329            center,
330            width,
331            height,
332            angle,
333        });
334    }
335
336    best
337}
338
339fn convex_hull(points: &[Point<f32>]) -> Vec<Point<f32>> {
340    let mut sorted = points.to_vec();
341    sorted.sort_by(|a, b| {
342        a.x.partial_cmp(&b.x)
343            .unwrap_or(std::cmp::Ordering::Equal)
344            .then_with(|| a.y.partial_cmp(&b.y).unwrap_or(std::cmp::Ordering::Equal))
345    });
346    sorted.dedup_by(|a, b| (a.x - b.x).abs() < f32::EPSILON && (a.y - b.y).abs() < f32::EPSILON);
347
348    if sorted.len() <= 2 {
349        return sorted;
350    }
351
352    let mut lower = Vec::new();
353    for point in &sorted {
354        while lower.len() >= 2
355            && cross(lower[lower.len() - 2], lower[lower.len() - 1], *point) <= 0.0
356        {
357            lower.pop();
358        }
359        lower.push(*point);
360    }
361
362    let mut upper = Vec::new();
363    for point in sorted.iter().rev() {
364        while upper.len() >= 2
365            && cross(upper[upper.len() - 2], upper[upper.len() - 1], *point) <= 0.0
366        {
367            upper.pop();
368        }
369        upper.push(*point);
370    }
371
372    lower.pop();
373    upper.pop();
374    lower.extend(upper);
375    lower
376}
377
378fn cross(origin: Point<f32>, a: Point<f32>, b: Point<f32>) -> f32 {
379    (a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x)
380}
381
382fn scale_and_order_points(
383    points: [Point<f32>; 4],
384    scale_x: f32,
385    scale_y: f32,
386    original_width: u32,
387    original_height: u32,
388) -> [Point<f32>; 4] {
389    let max_x = original_width.saturating_sub(1) as f32;
390    let max_y = original_height.saturating_sub(1) as f32;
391    order_points(points.map(|point| {
392        Point::new(
393            (point.x * scale_x).clamp(0.0, max_x),
394            (point.y * scale_y).clamp(0.0, max_y),
395        )
396    }))
397}
398
399fn order_points(points: [Point<f32>; 4]) -> [Point<f32>; 4] {
400    let mut top_left = points[0];
401    let mut top_right = points[0];
402    let mut bottom_right = points[0];
403    let mut bottom_left = points[0];
404
405    for point in points {
406        let sum = point.x + point.y;
407        let diff = point.x - point.y;
408
409        if sum < top_left.x + top_left.y {
410            top_left = point;
411        }
412        if sum > bottom_right.x + bottom_right.y {
413            bottom_right = point;
414        }
415        if diff > top_right.x - top_right.y {
416            top_right = point;
417        }
418        if diff < bottom_left.x - bottom_left.y {
419            bottom_left = point;
420        }
421    }
422
423    [top_left, top_right, bottom_right, bottom_left]
424}
425
426fn rect_from_ordered_points(
427    points: &[Point<f32>; 4],
428    original_width: u32,
429    original_height: u32,
430) -> Option<Rect> {
431    let min_x = points
432        .iter()
433        .map(|point| point.x)
434        .fold(f32::INFINITY, f32::min)
435        .floor()
436        .max(0.0) as u32;
437    let min_y = points
438        .iter()
439        .map(|point| point.y)
440        .fold(f32::INFINITY, f32::min)
441        .floor()
442        .max(0.0) as u32;
443    let max_x = points
444        .iter()
445        .map(|point| point.x)
446        .fold(f32::NEG_INFINITY, f32::max)
447        .ceil()
448        .min(original_width as f32) as u32;
449    let max_y = points
450        .iter()
451        .map(|point| point.y)
452        .fold(f32::NEG_INFINITY, f32::max)
453        .ceil()
454        .min(original_height as f32) as u32;
455
456    if max_x <= min_x || max_y <= min_y {
457        return None;
458    }
459
460    Some(Rect::at(min_x as i32, min_y as i32).of_size(max_x - min_x, max_y - min_y))
461}
462
463fn expand_ordered_points(
464    points: [Point<f32>; 4],
465    border: f32,
466    max_width: u32,
467    max_height: u32,
468) -> [Point<f32>; 4] {
469    if border <= 0.0 {
470        return points;
471    }
472
473    let center = Point::new(
474        points.iter().map(|p| p.x).sum::<f32>() / 4.0,
475        points.iter().map(|p| p.y).sum::<f32>() / 4.0,
476    );
477    let max_x = max_width.saturating_sub(1) as f32;
478    let max_y = max_height.saturating_sub(1) as f32;
479
480    order_points(points.map(|point| {
481        let dx = point.x - center.x;
482        let dy = point.y - center.y;
483        let len = (dx * dx + dy * dy).sqrt();
484        if len <= f32::EPSILON {
485            return point;
486        }
487
488        Point::new(
489            (point.x + dx / len * border).clamp(0.0, max_x),
490            (point.y + dy / len * border).clamp(0.0, max_y),
491        )
492    }))
493}
494
495/// Get contour bounds
496fn get_contour_bounds(contour: &Contour<i32>) -> (i32, i32, i32, i32) {
497    let mut min_x = i32::MAX;
498    let mut min_y = i32::MAX;
499    let mut max_x = i32::MIN;
500    let mut max_y = i32::MIN;
501
502    for point in &contour.points {
503        min_x = min_x.min(point.x);
504        min_y = min_y.min(point.y);
505        max_x = max_x.max(point.x);
506        max_y = max_y.max(point.y);
507    }
508
509    (min_x, min_y, max_x, max_y)
510}
511
512/// Calculate containment ratio of one box inside another
513fn compute_containment_ratio(inner: &Rect, outer: &Rect) -> f32 {
514    let x1 = inner.left().max(outer.left());
515    let y1 = inner.top().max(outer.top());
516    let x2 = (inner.left() + inner.width() as i32).min(outer.left() + outer.width() as i32);
517    let y2 = (inner.top() + inner.height() as i32).min(outer.top() + outer.height() as i32);
518
519    if x2 <= x1 || y2 <= y1 {
520        return 0.0;
521    }
522
523    let intersection = (x2 - x1) as f32 * (y2 - y1) as f32;
524    let inner_area = inner.width() as f32 * inner.height() as f32;
525
526    if inner_area <= 0.0 {
527        0.0
528    } else {
529        intersection / inner_area
530    }
531}
532
533/// Non-Maximum Suppression (NMS)
534///
535/// Filter overlapping bounding boxes, keep ones with highest scores
536/// Also filters small boxes that are largely contained within other boxes
537///
538/// # Parameters
539/// - `boxes`: List of bounding boxes
540/// - `iou_threshold`: IoU threshold, boxes exceeding this value are considered overlapping
541pub fn nms(boxes: &[TextBox], iou_threshold: f32) -> Vec<TextBox> {
542    if boxes.is_empty() {
543        return Vec::new();
544    }
545
546    // Sort by score descending, area descending (keep boxes with higher score and larger area first)
547    let mut indices: Vec<usize> = (0..boxes.len()).collect();
548    indices.sort_by(|&a, &b| {
549        // First sort by score descending
550        let score_cmp = boxes[b]
551            .score
552            .partial_cmp(&boxes[a].score)
553            .unwrap_or(std::cmp::Ordering::Equal);
554        if score_cmp != std::cmp::Ordering::Equal {
555            return score_cmp;
556        }
557        // When scores are equal, sort by area descending (prefer larger boxes)
558        boxes[b].area().cmp(&boxes[a].area())
559    });
560
561    let mut keep = Vec::new();
562    let mut suppressed = vec![false; boxes.len()];
563
564    for (pos, &i) in indices.iter().enumerate() {
565        if suppressed[i] {
566            continue;
567        }
568
569        keep.push(boxes[i].clone());
570
571        // Check all subsequent boxes (lower score or smaller area)
572        for &j in indices.iter().skip(pos + 1) {
573            if suppressed[j] {
574                continue;
575            }
576
577            // Check IoU
578            let iou = compute_iou(&boxes[i].rect, &boxes[j].rect);
579            if iou > iou_threshold {
580                suppressed[j] = true;
581                continue;
582            }
583
584            // Check containment relationship: if j is largely contained (>50%) by i, suppress j
585            let containment_j_in_i = compute_containment_ratio(&boxes[j].rect, &boxes[i].rect);
586            if containment_j_in_i > 0.5 {
587                suppressed[j] = true;
588                continue;
589            }
590
591            // Check reverse containment: if i is largely contained (>70%) by j,
592            // since i was selected first (higher score or larger area), suppress j
593            let containment_i_in_j = compute_containment_ratio(&boxes[i].rect, &boxes[j].rect);
594            if containment_i_in_j > 0.7 {
595                suppressed[j] = true;
596                continue;
597            }
598        }
599    }
600
601    keep
602}
603
604/// Calculate IoU (Intersection over Union) of two rectangles
605pub fn compute_iou(a: &Rect, b: &Rect) -> f32 {
606    let x1 = a.left().max(b.left());
607    let y1 = a.top().max(b.top());
608    let x2 = (a.left() + a.width() as i32).min(b.left() + b.width() as i32);
609    let y2 = (a.top() + a.height() as i32).min(b.top() + b.height() as i32);
610
611    if x2 <= x1 || y2 <= y1 {
612        return 0.0;
613    }
614
615    let intersection = (x2 - x1) as f32 * (y2 - y1) as f32;
616    let area_a = a.width() as f32 * a.height() as f32;
617    let area_b = b.width() as f32 * b.height() as f32;
618    let union = area_a + area_b - intersection;
619
620    if union <= 0.0 {
621        0.0
622    } else {
623        intersection / union
624    }
625}
626
627/// Merge adjacent bounding boxes
628///
629/// Merge bounding boxes that are close to each other into one
630///
631/// # Parameters
632/// - `boxes`: List of bounding boxes
633/// - `distance_threshold`: Distance threshold, boxes below this value will be merged
634pub fn merge_adjacent_boxes(boxes: &[TextBox], distance_threshold: i32) -> Vec<TextBox> {
635    if boxes.is_empty() {
636        return Vec::new();
637    }
638
639    let mut merged = Vec::new();
640    let mut used = vec![false; boxes.len()];
641
642    for i in 0..boxes.len() {
643        if used[i] {
644            continue;
645        }
646
647        let mut current = boxes[i].rect;
648        let mut group_score = boxes[i].score;
649        let mut count = 1;
650        used[i] = true;
651
652        // Find boxes that can be merged
653        loop {
654            let mut found = false;
655
656            for j in 0..boxes.len() {
657                if used[j] {
658                    continue;
659                }
660
661                if can_merge(&current, &boxes[j].rect, distance_threshold) {
662                    current = merge_rects(&current, &boxes[j].rect);
663                    group_score += boxes[j].score;
664                    count += 1;
665                    used[j] = true;
666                    found = true;
667                }
668            }
669
670            if !found {
671                break;
672            }
673        }
674
675        merged.push(TextBox::new(current, group_score / count as f32));
676    }
677
678    merged
679}
680
681/// Check if two boxes can be merged
682fn can_merge(a: &Rect, b: &Rect, threshold: i32) -> bool {
683    // Calculate vertical distance
684    let a_bottom = a.top() + a.height() as i32;
685    let b_bottom = b.top() + b.height() as i32;
686
687    let _vertical_dist = if a.top() > b_bottom {
688        a.top() - b_bottom
689    } else if b.top() > a_bottom {
690        b.top() - a_bottom
691    } else {
692        0 // Vertical overlap
693    };
694
695    // Calculate horizontal distance
696    let a_right = a.left() + a.width() as i32;
697    let b_right = b.left() + b.width() as i32;
698
699    let horizontal_dist = if a.left() > b_right {
700        a.left() - b_right
701    } else if b.left() > a_right {
702        b.left() - a_right
703    } else {
704        0 // Horizontal overlap
705    };
706
707    // Check if on same line (vertical overlap) and horizontal distance is less than threshold
708    let vertical_overlap = !(a.top() > b_bottom || b.top() > a_bottom);
709
710    vertical_overlap && horizontal_dist <= threshold
711}
712
713/// Merge two rectangles
714fn merge_rects(a: &Rect, b: &Rect) -> Rect {
715    let x1 = a.left().min(b.left());
716    let y1 = a.top().min(b.top());
717    let x2 = (a.left() + a.width() as i32).max(b.left() + b.width() as i32);
718    let y2 = (a.top() + a.height() as i32).max(b.top() + b.height() as i32);
719
720    Rect::at(x1, y1).of_size((x2 - x1) as u32, (y2 - y1) as u32)
721}
722
723/// Sort bounding boxes by reading order (top to bottom, left to right)
724pub fn sort_boxes_by_reading_order(boxes: &mut [TextBox]) {
725    boxes.sort_by(|a, b| {
726        // First sort by y coordinate (row)
727        let y_cmp = a.rect.top().cmp(&b.rect.top());
728        if y_cmp != std::cmp::Ordering::Equal {
729            return y_cmp;
730        }
731        // Same row, sort by x coordinate
732        a.rect.left().cmp(&b.rect.left())
733    });
734}
735
736/// Group bounding boxes by line
737///
738/// Group boxes with close y coordinates into the same line
739pub fn group_boxes_by_line(boxes: &[TextBox], line_threshold: i32) -> Vec<Vec<TextBox>> {
740    if boxes.is_empty() {
741        return Vec::new();
742    }
743
744    let mut sorted_boxes = boxes.to_vec();
745    sorted_boxes.sort_by_key(|b| b.rect.top());
746
747    let mut lines: Vec<Vec<TextBox>> = Vec::new();
748    let mut current_line: Vec<TextBox> = vec![sorted_boxes[0].clone()];
749    let mut current_y = sorted_boxes[0].rect.top();
750
751    for box_item in sorted_boxes.iter().skip(1) {
752        if (box_item.rect.top() - current_y).abs() <= line_threshold {
753            current_line.push(box_item.clone());
754        } else {
755            // Sort current line by x
756            current_line.sort_by_key(|b| b.rect.left());
757            lines.push(current_line);
758            current_line = vec![box_item.clone()];
759            current_y = box_item.rect.top();
760        }
761    }
762
763    // Add last line
764    if !current_line.is_empty() {
765        current_line.sort_by_key(|b| b.rect.left());
766        lines.push(current_line);
767    }
768
769    lines
770}
771
772/// Merge bounding boxes from multiple detection results (for high precision mode)
773///
774/// # Parameters
775/// - `results`: Multiple detection results, each element is (boxes, offset_x, offset_y, scale)
776/// - `iou_threshold`: NMS IoU threshold
777pub fn merge_multi_scale_results(
778    results: &[(Vec<TextBox>, u32, u32, f32)],
779    iou_threshold: f32,
780) -> Vec<TextBox> {
781    let mut all_boxes = Vec::new();
782
783    for (boxes, offset_x, offset_y, scale) in results {
784        for box_item in boxes {
785            // Convert box coordinates to original image coordinate system
786            let scaled_x = (box_item.rect.left() as f32 / scale) as i32 + *offset_x as i32;
787            let scaled_y = (box_item.rect.top() as f32 / scale) as i32 + *offset_y as i32;
788            let scaled_w = (box_item.rect.width() as f32 / scale) as u32;
789            let scaled_h = (box_item.rect.height() as f32 / scale) as u32;
790
791            let rect = Rect::at(scaled_x, scaled_y).of_size(scaled_w, scaled_h);
792            all_boxes.push(TextBox::new(rect, box_item.score));
793        }
794    }
795
796    // Apply NMS to remove duplicates
797    nms(&all_boxes, iou_threshold)
798}
799
800// ============== Traditional Algorithm Detection ==============
801
802/// Detect text regions using traditional algorithm (suitable for solid background)
803///
804/// Based on OTSU binarization + connected component analysis, suitable for:
805/// - Document images with solid background
806/// - High contrast text
807/// - As supplement to deep learning detection
808///
809/// # Parameters
810/// - `gray_image`: Grayscale image
811/// - `min_area`: Minimum text region area
812/// - `expand_ratio`: Bounding box expansion ratio
813pub fn detect_text_traditional(
814    gray_image: &GrayImage,
815    min_area: u32,
816    expand_ratio: f32,
817) -> Vec<TextBox> {
818    let (width, height) = gray_image.dimensions();
819
820    // 1. Calculate OTSU threshold
821    let threshold = otsu_threshold(gray_image);
822
823    // 2. Binarization
824    let binary: Vec<u8> = gray_image
825        .pixels()
826        .map(|p| if p.0[0] < threshold { 255 } else { 0 })
827        .collect();
828
829    // 3. Create binary image and find contours
830    let binary_image =
831        GrayImage::from_raw(width, height, binary).unwrap_or_else(|| GrayImage::new(width, height));
832    let contours = find_contours::<i32>(&binary_image);
833
834    // 4. Extract bounding boxes
835    let mut boxes = Vec::new();
836    for contour in contours {
837        if contour.points.len() < 4 {
838            continue;
839        }
840
841        let (min_x, min_y, max_x, max_y) = get_contour_bounds(&contour);
842        let box_width = (max_x - min_x) as u32;
843        let box_height = (max_y - min_y) as u32;
844
845        if box_width * box_height < min_area {
846            continue;
847        }
848
849        // Expand bounding box
850        let expand_w = (box_width as f32 * expand_ratio * 0.5) as i32;
851        let expand_h = (box_height as f32 * expand_ratio * 0.5) as i32;
852
853        let final_x = (min_x - expand_w).max(0) as u32;
854        let final_y = (min_y - expand_h).max(0) as u32;
855        let final_w = ((max_x + expand_w) as u32)
856            .min(width)
857            .saturating_sub(final_x);
858        let final_h = ((max_y + expand_h) as u32)
859            .min(height)
860            .saturating_sub(final_y);
861
862        if final_w > 0 && final_h > 0 {
863            let rect = Rect::at(final_x as i32, final_y as i32).of_size(final_w, final_h);
864            boxes.push(TextBox::new(rect, 1.0));
865        }
866    }
867
868    // 5. Merge adjacent boxes to form text lines
869    merge_into_text_lines(&boxes, 10)
870}
871
872/// OTSU adaptive threshold calculation
873fn otsu_threshold(image: &GrayImage) -> u8 {
874    // Calculate histogram
875    let mut histogram = [0u32; 256];
876    for pixel in image.pixels() {
877        histogram[pixel.0[0] as usize] += 1;
878    }
879
880    let total = image.pixels().count() as f64;
881    let mut sum = 0.0;
882    for (i, &count) in histogram.iter().enumerate() {
883        sum += i as f64 * count as f64;
884    }
885
886    let mut sum_b = 0.0;
887    let mut w_b = 0.0;
888    let mut max_variance = 0.0;
889    let mut threshold = 0u8;
890
891    for (t, &count) in histogram.iter().enumerate() {
892        w_b += count as f64;
893        if w_b == 0.0 {
894            continue;
895        }
896
897        let w_f = total - w_b;
898        if w_f == 0.0 {
899            break;
900        }
901
902        sum_b += t as f64 * count as f64;
903        let m_b = sum_b / w_b;
904        let m_f = (sum - sum_b) / w_f;
905
906        let variance = w_b * w_f * (m_b - m_f).powi(2);
907        if variance > max_variance {
908            max_variance = variance;
909            threshold = t as u8;
910        }
911    }
912
913    threshold
914}
915
916/// Merge independent character boxes into text lines
917fn merge_into_text_lines(boxes: &[TextBox], gap_threshold: i32) -> Vec<TextBox> {
918    if boxes.is_empty() {
919        return Vec::new();
920    }
921
922    // Group by y coordinate
923    let mut sorted_boxes: Vec<_> = boxes.iter().collect();
924    sorted_boxes.sort_by_key(|b| b.rect.top());
925
926    let mut lines: Vec<TextBox> = Vec::new();
927
928    for bbox in sorted_boxes {
929        let mut merged = false;
930
931        // Try to merge into existing lines
932        for line in &mut lines {
933            let line_center_y = line.rect.top() + line.rect.height() as i32 / 2;
934            let box_center_y = bbox.rect.top() + bbox.rect.height() as i32 / 2;
935
936            // If vertical overlap and horizontal proximity
937            if (line_center_y - box_center_y).abs() < line.rect.height() as i32 / 2 {
938                let line_right = line.rect.left() + line.rect.width() as i32;
939                let box_left = bbox.rect.left();
940
941                if (box_left - line_right).abs() < gap_threshold * 3 {
942                    // Merge
943                    let new_left = line.rect.left().min(bbox.rect.left());
944                    let new_top = line.rect.top().min(bbox.rect.top());
945                    let new_right = (line.rect.left() + line.rect.width() as i32)
946                        .max(bbox.rect.left() + bbox.rect.width() as i32);
947                    let new_bottom = (line.rect.top() + line.rect.height() as i32)
948                        .max(bbox.rect.top() + bbox.rect.height() as i32);
949
950                    line.rect = Rect::at(new_left, new_top)
951                        .of_size((new_right - new_left) as u32, (new_bottom - new_top) as u32);
952                    merged = true;
953                    break;
954                }
955            }
956        }
957
958        if !merged {
959            lines.push(bbox.clone());
960        }
961    }
962
963    lines
964}
965
966#[cfg(test)]
967mod tests {
968    use super::*;
969
970    #[test]
971    fn test_textbox_new() {
972        let rect = Rect::at(10, 20).of_size(100, 50);
973        let tb = TextBox::new(rect, 0.95);
974
975        assert_eq!(tb.rect.left(), 10);
976        assert_eq!(tb.rect.top(), 20);
977        assert_eq!(tb.rect.width(), 100);
978        assert_eq!(tb.rect.height(), 50);
979        assert_eq!(tb.score, 0.95);
980        assert!(tb.points.is_none());
981    }
982
983    #[test]
984    fn test_textbox_with_points() {
985        let rect = Rect::at(0, 0).of_size(100, 50);
986        let points = [
987            Point::new(0.0, 0.0),
988            Point::new(100.0, 0.0),
989            Point::new(100.0, 50.0),
990            Point::new(0.0, 50.0),
991        ];
992        let tb = TextBox::with_points(rect, 0.9, points);
993
994        assert!(tb.points.is_some());
995        let pts = tb.points.unwrap();
996        assert_eq!(pts[0].x, 0.0);
997        assert_eq!(pts[1].x, 100.0);
998    }
999
1000    #[test]
1001    fn test_textbox_area() {
1002        let tb = TextBox::new(Rect::at(0, 0).of_size(100, 50), 0.9);
1003        assert_eq!(tb.area(), 5000);
1004    }
1005
1006    #[test]
1007    fn test_textbox_expand() {
1008        let tb = TextBox::new(Rect::at(50, 50).of_size(100, 100), 0.9);
1009        let expanded = tb.expand(10, 500, 500);
1010
1011        assert_eq!(expanded.rect.left(), 40);
1012        assert_eq!(expanded.rect.top(), 40);
1013        assert_eq!(expanded.rect.width(), 120);
1014        assert_eq!(expanded.rect.height(), 120);
1015    }
1016
1017    #[test]
1018    fn test_textbox_expand_clamp() {
1019        // 测试边界裁剪
1020        let tb = TextBox::new(Rect::at(5, 5).of_size(100, 100), 0.9);
1021        let expanded = tb.expand(10, 200, 200);
1022
1023        // 左上角应该被限制在 (0, 0)
1024        assert_eq!(expanded.rect.left(), 0);
1025        assert_eq!(expanded.rect.top(), 0);
1026    }
1027
1028    #[test]
1029    fn test_textbox_expand_keeps_rotated_points() {
1030        let rect = Rect::at(10, 10).of_size(100, 40);
1031        let points = [
1032            Point::new(12.0, 20.0),
1033            Point::new(105.0, 12.0),
1034            Point::new(108.0, 48.0),
1035            Point::new(15.0, 56.0),
1036        ];
1037        let expanded = TextBox::with_points(rect, 0.9, points).expand(5, 200, 200);
1038
1039        assert!(expanded.points.is_some());
1040        let expanded_points = expanded.points.unwrap();
1041        assert_ne!(expanded_points[0], points[0]);
1042        assert_ne!(expanded_points[1], points[1]);
1043    }
1044
1045    #[test]
1046    fn test_extract_boxes_returns_rotated_points() {
1047        let width = 100;
1048        let height = 80;
1049        let quad = [
1050            Point::new(20.0, 30.0),
1051            Point::new(80.0, 20.0),
1052            Point::new(85.0, 40.0),
1053            Point::new(25.0, 50.0),
1054        ];
1055        let mut mask = vec![0u8; width * height];
1056        for y in 0..height {
1057            for x in 0..width {
1058                if point_in_quad(x as f32 + 0.5, y as f32 + 0.5, &quad) {
1059                    mask[y * width + x] = 255;
1060                }
1061            }
1062        }
1063
1064        let boxes = extract_boxes_with_unclip(
1065            &mask,
1066            width as u32,
1067            height as u32,
1068            width as u32,
1069            height as u32,
1070            width as u32,
1071            height as u32,
1072            16,
1073            1.0,
1074        );
1075
1076        let text_box = boxes
1077            .iter()
1078            .find(|text_box| text_box.points.is_some())
1079            .expect("expected a rotated text box");
1080        let points = text_box.points.unwrap();
1081        assert!(
1082            (points[0].y - points[1].y).abs() > 2.0,
1083            "top edge should preserve rotation: {points:?}"
1084        );
1085    }
1086
1087    fn point_in_quad(x: f32, y: f32, points: &[Point<f32>; 4]) -> bool {
1088        let mut inside = false;
1089        let mut prev = points.len() - 1;
1090        for current in 0..points.len() {
1091            let pi = points[current];
1092            let pj = points[prev];
1093            if (pi.y > y) != (pj.y > y) && x < (pj.x - pi.x) * (y - pi.y) / (pj.y - pi.y) + pi.x {
1094                inside = !inside;
1095            }
1096            prev = current;
1097        }
1098        inside
1099    }
1100
1101    #[test]
1102    fn test_compute_iou() {
1103        let a = Rect::at(0, 0).of_size(10, 10);
1104        let b = Rect::at(5, 5).of_size(10, 10);
1105
1106        let iou = compute_iou(&a, &b);
1107        assert!(iou > 0.0 && iou < 1.0);
1108
1109        // 不相交
1110        let c = Rect::at(100, 100).of_size(10, 10);
1111        assert_eq!(compute_iou(&a, &c), 0.0);
1112
1113        // 完全重叠
1114        assert_eq!(compute_iou(&a, &a), 1.0);
1115    }
1116
1117    #[test]
1118    fn test_compute_iou_partial_overlap() {
1119        // 50% 重叠的情况
1120        let a = Rect::at(0, 0).of_size(10, 10);
1121        let b = Rect::at(5, 0).of_size(10, 10);
1122
1123        let iou = compute_iou(&a, &b);
1124        // 交集面积 = 5 * 10 = 50
1125        // 并集面积 = 100 + 100 - 50 = 150
1126        // IoU = 50 / 150 ≈ 0.333
1127        assert!((iou - 0.333).abs() < 0.01);
1128    }
1129
1130    #[test]
1131    fn test_nms() {
1132        // 第一个和第二个框有很大重叠,第三个框独立
1133        let boxes = vec![
1134            TextBox::new(Rect::at(0, 0).of_size(10, 10), 0.9),
1135            TextBox::new(Rect::at(1, 1).of_size(10, 10), 0.8), // 与第一个框高度重叠
1136            TextBox::new(Rect::at(100, 100).of_size(10, 10), 0.7),
1137        ];
1138
1139        let result = nms(&boxes, 0.3); // 使用较低的阈值确保重叠框被过滤
1140                                       // 第一个框(最高分数)和第三个框(无重叠)应该保留
1141        assert!(
1142            result.len() >= 2,
1143            "至少应该保留2个框,实际: {}",
1144            result.len()
1145        );
1146    }
1147
1148    #[test]
1149    fn test_nms_empty() {
1150        let boxes: Vec<TextBox> = vec![];
1151        let result = nms(&boxes, 0.5);
1152        assert!(result.is_empty());
1153    }
1154
1155    #[test]
1156    fn test_nms_single() {
1157        let boxes = vec![TextBox::new(Rect::at(0, 0).of_size(10, 10), 0.9)];
1158        let result = nms(&boxes, 0.5);
1159        assert_eq!(result.len(), 1);
1160    }
1161
1162    #[test]
1163    fn test_nms_no_overlap() {
1164        let boxes = vec![
1165            TextBox::new(Rect::at(0, 0).of_size(10, 10), 0.9),
1166            TextBox::new(Rect::at(50, 50).of_size(10, 10), 0.8),
1167            TextBox::new(Rect::at(100, 100).of_size(10, 10), 0.7),
1168        ];
1169
1170        let result = nms(&boxes, 0.5);
1171        assert_eq!(result.len(), 3); // 所有框都保留
1172    }
1173
1174    #[test]
1175    fn test_merge_adjacent() {
1176        let boxes = vec![
1177            TextBox::new(Rect::at(0, 0).of_size(10, 10), 1.0),
1178            TextBox::new(Rect::at(12, 0).of_size(10, 10), 1.0), // 水平距离 2
1179            TextBox::new(Rect::at(100, 100).of_size(10, 10), 1.0),
1180        ];
1181
1182        let result = merge_adjacent_boxes(&boxes, 5);
1183        assert_eq!(result.len(), 2); // 前两个应该合并
1184    }
1185
1186    #[test]
1187    fn test_merge_adjacent_empty() {
1188        let boxes: Vec<TextBox> = vec![];
1189        let result = merge_adjacent_boxes(&boxes, 5);
1190        assert!(result.is_empty());
1191    }
1192
1193    #[test]
1194    fn test_sort_boxes_by_reading_order() {
1195        let mut boxes = vec![
1196            TextBox::new(Rect::at(100, 0).of_size(10, 10), 0.9), // 第一行右边
1197            TextBox::new(Rect::at(0, 0).of_size(10, 10), 0.9),   // 第一行左边
1198            TextBox::new(Rect::at(0, 50).of_size(10, 10), 0.9),  // 第二行
1199        ];
1200
1201        sort_boxes_by_reading_order(&mut boxes);
1202
1203        // 应该先按行排序,然后行内按x坐标排序
1204        assert_eq!(boxes[0].rect.left(), 0);
1205        assert_eq!(boxes[0].rect.top(), 0);
1206    }
1207
1208    #[test]
1209    fn test_group_boxes_by_line() {
1210        let boxes = vec![
1211            TextBox::new(Rect::at(0, 0).of_size(50, 20), 0.9),
1212            TextBox::new(Rect::at(60, 0).of_size(50, 20), 0.9),
1213            TextBox::new(Rect::at(0, 50).of_size(50, 20), 0.9),
1214        ];
1215
1216        let lines = group_boxes_by_line(&boxes, 10);
1217
1218        // 应该分成两行
1219        assert_eq!(lines.len(), 2);
1220    }
1221}