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