Skip to main content

oar_ocr_core/processors/
layout_postprocess.rs

1//! Layout Detection Post-processing
2//!
3//! This module implements post-processing for layout detection models including
4//! PicoDet, RT-DETR, and PP-DocLayout series models.
5
6use crate::domain::tasks::MergeBboxMode;
7use crate::processors::{BoundingBox, ImageScaleInfo, Point};
8use ndarray::{ArrayView3, Axis};
9use rayon::prelude::*;
10use std::borrow::Cow;
11use std::collections::HashMap;
12
13type LayoutPostprocessOutput = (Vec<Vec<BoundingBox>>, Vec<Vec<usize>>, Vec<Vec<f32>>);
14type NmsResult = (Vec<BoundingBox>, Vec<usize>, Vec<f32>, Vec<(f32, f32)>);
15
16/// Layout detection post-processor for models like PicoDet and RT-DETR.
17///
18/// This processor converts model predictions into bounding boxes with class labels
19/// and confidence scores for document layout elements.
20#[derive(Debug, Clone)]
21pub struct LayoutPostProcess {
22    /// Number of classes the model predicts
23    num_classes: usize,
24    /// Score threshold for filtering predictions
25    score_threshold: f32,
26    /// Non-maximum suppression threshold
27    nms_threshold: f32,
28    /// Maximum number of detections to return
29    max_detections: usize,
30    /// Model type (e.g., "picodet", "rtdetr", "pp-doclayout")
31    model_type: String,
32}
33
34impl LayoutPostProcess {
35    /// Creates a new layout detection post-processor.
36    pub fn new(
37        num_classes: usize,
38        score_threshold: f32,
39        nms_threshold: f32,
40        max_detections: usize,
41        model_type: String,
42    ) -> Self {
43        Self {
44            num_classes,
45            score_threshold,
46            nms_threshold,
47            max_detections,
48            model_type,
49        }
50    }
51
52    /// Applies post-processing to layout detection model predictions.
53    ///
54    /// # Arguments
55    /// * `predictions` - Model output tensor [batch, num_boxes, 4 + num_classes]
56    /// * `img_shapes` - Original image dimensions for each image in batch
57    ///
58    /// # Returns
59    /// Tuple of (bounding_boxes, class_ids, scores) for each image in batch
60    pub fn apply(
61        &self,
62        predictions: &ndarray::Array4<f32>,
63        img_shapes: Vec<ImageScaleInfo>,
64    ) -> LayoutPostprocessOutput {
65        let batch_size = predictions.shape()[0];
66        let n = batch_size.min(img_shapes.len());
67
68        // Each batch entry is independent; the postprocess loop
69        // (parsing, NMS, score thresholding) is dominated by CPU work, so
70        // we fan it out across cores. The threads only read shared state
71        // (model_type strings, thresholds) which is immutable.
72        let per_image: Vec<(Vec<BoundingBox>, Vec<usize>, Vec<f32>)> = (0..n)
73            .into_par_iter()
74            .map(|batch_idx| {
75                let img_shape = &img_shapes[batch_idx];
76                let pred = predictions.index_axis(Axis(0), batch_idx);
77                match self.model_type.as_str() {
78                    "picodet" => self.process_picodet(pred, img_shape),
79                    "rtdetr" => self.process_rtdetr(pred, img_shape),
80                    "pp-doclayout" => self.process_pp_doclayout(pred, img_shape),
81                    _ => self.process_standard(pred, img_shape),
82                }
83            })
84            .collect();
85
86        let mut all_boxes = Vec::with_capacity(n);
87        let mut all_classes = Vec::with_capacity(n);
88        let mut all_scores = Vec::with_capacity(n);
89        for (b, c, s) in per_image {
90            all_boxes.push(b);
91            all_classes.push(c);
92            all_scores.push(s);
93        }
94
95        (all_boxes, all_classes, all_scores)
96    }
97
98    /// Process PicoDet model output.
99    fn process_picodet(
100        &self,
101        predictions: ArrayView3<f32>,
102        img_shape: &ImageScaleInfo,
103    ) -> (Vec<BoundingBox>, Vec<usize>, Vec<f32>) {
104        let mut boxes = Vec::new();
105        let mut classes = Vec::new();
106        let mut scores = Vec::new();
107
108        let orig_width = img_shape.src_w;
109        let orig_height = img_shape.src_h;
110        let shape = predictions.shape();
111        if shape.len() != 3 || shape[2] == 0 {
112            return (boxes, classes, scores);
113        }
114
115        let total_boxes = shape[0] * shape[1];
116        if total_boxes == 0 {
117            return (boxes, classes, scores);
118        }
119
120        let feature_dim = shape[2];
121        let data: Cow<'_, [f32]> = match predictions.as_slice() {
122            Some(slice) => Cow::Borrowed(slice),
123            None => {
124                let (mut vec, offset) = predictions.to_owned().into_raw_vec_and_offset();
125                if let Some(offset) = offset
126                    && offset != 0
127                {
128                    vec.drain(0..offset);
129                }
130                Cow::Owned(vec)
131            }
132        };
133
134        for box_idx in 0..total_boxes {
135            let start = box_idx * feature_dim;
136            let end = start + feature_dim;
137
138            if end > data.len() {
139                break;
140            }
141
142            let row = &data[start..end];
143            if feature_dim == 4 + self.num_classes {
144                // Format: [x1, y1, x2, y2, scores...]
145                let (max_class, max_score) = row[4..].iter().enumerate().fold(
146                    (0usize, f32::NEG_INFINITY),
147                    |(best_cls, best_score), (cls_idx, &score)| {
148                        if score > best_score {
149                            (cls_idx, score)
150                        } else {
151                            (best_cls, best_score)
152                        }
153                    },
154                );
155
156                if max_score < self.score_threshold {
157                    continue;
158                }
159
160                let (sx1, sy1, sx2, sy2) = self.convert_bbox_coords(
161                    row[0],
162                    row[1],
163                    row[2],
164                    row[3],
165                    orig_width,
166                    orig_height,
167                );
168
169                if !Self::is_valid_box(sx1, sy1, sx2, sy2) {
170                    continue;
171                }
172
173                let bbox = BoundingBox::new(vec![
174                    Point::new(sx1, sy1),
175                    Point::new(sx2, sy1),
176                    Point::new(sx2, sy2),
177                    Point::new(sx1, sy2),
178                ]);
179
180                boxes.push(bbox);
181                classes.push(max_class);
182                scores.push(max_score);
183            } else if feature_dim >= 6
184                && let Some((class_id, score, x1, y1, x2, y2)) = self.parse_compact_prediction(row)
185            {
186                if score < self.score_threshold || class_id >= self.num_classes {
187                    continue;
188                }
189
190                let (sx1, sy1, sx2, sy2) =
191                    self.convert_bbox_coords(x1, y1, x2, y2, orig_width, orig_height);
192
193                if !Self::is_valid_box(sx1, sy1, sx2, sy2) {
194                    continue;
195                }
196
197                let bbox = BoundingBox::new(vec![
198                    Point::new(sx1, sy1),
199                    Point::new(sx2, sy1),
200                    Point::new(sx2, sy2),
201                    Point::new(sx1, sy2),
202                ]);
203
204                boxes.push(bbox);
205                classes.push(class_id);
206                scores.push(score);
207            }
208        }
209
210        self.apply_nms(boxes, classes, scores)
211    }
212
213    /// Process RT-DETR model output.
214    fn process_rtdetr(
215        &self,
216        predictions: ArrayView3<f32>,
217        img_shape: &ImageScaleInfo,
218    ) -> (Vec<BoundingBox>, Vec<usize>, Vec<f32>) {
219        // RT-DETR has similar output format to PicoDet
220        self.process_picodet(predictions, img_shape)
221    }
222
223    /// Process PP-DocLayout model output.
224    ///
225    /// Handles 6-dim format (PP-DocLayout), 7-dim format (PP-DocLayoutV3), and 8-dim format (PP-DocLayoutV2).
226    /// - 6-dim: [class_id, score, x1, y1, x2, y2]
227    /// - 7-dim: [class_id, score, x1, y1, x2, y2, extra]
228    /// - 8-dim: [class_id, score, x1, y1, x2, y2, col_index, row_index]
229    ///
230    /// For 8-dim format, boxes are sorted by reading order (col_index ascending, row_index ascending)
231    /// after NMS filtering.
232    fn process_pp_doclayout(
233        &self,
234        predictions: ArrayView3<f32>,
235        img_shape: &ImageScaleInfo,
236    ) -> (Vec<BoundingBox>, Vec<usize>, Vec<f32>) {
237        // PP-DocLayout outputs in [num_boxes, 1, N] format
238        // where N is 6 or 8 depending on model version
239        let shape = predictions.shape();
240
241        let mut boxes = Vec::new();
242        let mut classes = Vec::new();
243        let mut scores = Vec::new();
244        let mut reading_orders: Vec<(f32, f32)> = Vec::new();
245
246        // Guard against unexpected/under-width model output before indexing
247        // `[box_idx, 0, 0..=5]` (and `..=7` for the 8-dim variant). Mirrors the
248        // shape validation in `process_picodet`.
249        if shape.len() != 3 || shape[1] == 0 || shape[2] < 6 {
250            return (boxes, classes, scores);
251        }
252
253        let num_boxes = shape[0];
254        let feature_dim = shape[2];
255
256        let orig_width = img_shape.src_w;
257        let orig_height = img_shape.src_h;
258
259        let has_reading_order = feature_dim == 8;
260
261        // Extract predictions
262        for box_idx in 0..num_boxes {
263            // predictions is [num_boxes, 1, N], so we use 3D indexing [box_idx, 0, i]
264            let class_id = predictions[[box_idx, 0, 0]] as i32;
265            let score = predictions[[box_idx, 0, 1]];
266            let x1 = predictions[[box_idx, 0, 2]];
267            let y1 = predictions[[box_idx, 0, 3]];
268            let x2 = predictions[[box_idx, 0, 4]];
269            let y2 = predictions[[box_idx, 0, 5]];
270
271            // Extract reading order info if available (8-dim format)
272            // Default to (0, box_idx) for 6-dim format to maintain original order
273            let reading_order = if has_reading_order {
274                (predictions[[box_idx, 0, 6]], predictions[[box_idx, 0, 7]])
275            } else {
276                (0.0, box_idx as f32)
277            };
278
279            // Filter by threshold and valid class
280            if score < self.score_threshold
281                || class_id < 0
282                || (class_id as usize) >= self.num_classes
283            {
284                continue;
285            }
286
287            // PP-DocLayout-style models may emit either absolute pixel coords or normalized coords.
288            // Use the same normalization heuristic as other detectors for robustness.
289            let (sx1, sy1, sx2, sy2) =
290                self.convert_bbox_coords(x1, y1, x2, y2, orig_width, orig_height);
291            if !Self::is_valid_box(sx1, sy1, sx2, sy2) {
292                continue;
293            }
294
295            let bbox = BoundingBox::new(vec![
296                Point::new(sx1, sy1),
297                Point::new(sx2, sy1),
298                Point::new(sx2, sy2),
299                Point::new(sx1, sy2),
300            ]);
301
302            boxes.push(bbox);
303            classes.push(class_id as usize);
304            scores.push(score);
305            reading_orders.push(reading_order);
306        }
307
308        // Apply NMS with reading order preservation
309        let (filtered_boxes, filtered_classes, filtered_scores, filtered_reading_orders) =
310            self.apply_nms_with_reading_order(boxes, classes, scores, reading_orders);
311
312        // Sort by reading order if we have 8-dim format
313        if has_reading_order && !filtered_boxes.is_empty() {
314            let mut indices: Vec<usize> = (0..filtered_boxes.len()).collect();
315            indices.sort_by(|&i, &j| {
316                let (col_i, row_i) = filtered_reading_orders[i];
317                let (col_j, row_j) = filtered_reading_orders[j];
318                // Sort by col_index ascending, then row_index ascending
319                // Use total_cmp to handle NaN/infinity values gracefully
320                col_i
321                    .total_cmp(&col_j)
322                    .then_with(|| row_i.total_cmp(&row_j))
323            });
324
325            let sorted_boxes = indices.iter().map(|&i| filtered_boxes[i].clone()).collect();
326            let sorted_classes = indices.iter().map(|&i| filtered_classes[i]).collect();
327            let sorted_scores = indices.iter().map(|&i| filtered_scores[i]).collect();
328
329            (sorted_boxes, sorted_classes, sorted_scores)
330        } else {
331            (filtered_boxes, filtered_classes, filtered_scores)
332        }
333    }
334
335    /// Apply NMS with reading order preservation.
336    fn apply_nms_with_reading_order(
337        &self,
338        boxes: Vec<BoundingBox>,
339        classes: Vec<usize>,
340        scores: Vec<f32>,
341        reading_orders: Vec<(f32, f32)>,
342    ) -> NmsResult {
343        if boxes.is_empty() {
344            return (boxes, classes, scores, reading_orders);
345        }
346
347        let keep = self.compute_nms_keep_indices(&boxes, &classes, &scores);
348
349        let filtered_boxes: Vec<BoundingBox> = keep.iter().map(|&i| boxes[i].clone()).collect();
350        let filtered_classes: Vec<usize> = keep.iter().map(|&i| classes[i]).collect();
351        let filtered_scores: Vec<f32> = keep.iter().map(|&i| scores[i]).collect();
352        let filtered_reading_orders: Vec<(f32, f32)> =
353            keep.iter().map(|&i| reading_orders[i]).collect();
354
355        (
356            filtered_boxes,
357            filtered_classes,
358            filtered_scores,
359            filtered_reading_orders,
360        )
361    }
362
363    /// Process standard detection model output.
364    fn process_standard(
365        &self,
366        predictions: ArrayView3<f32>,
367        img_shape: &ImageScaleInfo,
368    ) -> (Vec<BoundingBox>, Vec<usize>, Vec<f32>) {
369        self.process_picodet(predictions, img_shape)
370    }
371
372    fn parse_compact_prediction(&self, row: &[f32]) -> Option<(usize, f32, f32, f32, f32, f32)> {
373        if row.len() < 6 {
374            return None;
375        }
376
377        // Format: [class_id, score, x1, y1, x2, y2]
378        let score_is_valid = if self.model_type == "rtdetr" {
379            row[1].is_finite()
380        } else {
381            Self::is_valid_score(row[1])
382        };
383
384        if score_is_valid && Self::is_valid_class(row[0], self.num_classes) {
385            let class_id = row[0].round() as i32;
386            if class_id >= 0 {
387                let score = self.adjust_score(row[1]);
388                return Some((class_id as usize, score, row[2], row[3], row[4], row[5]));
389            }
390        }
391
392        // Alternate format: [x1, y1, x2, y2, score, class_id]
393        let score_is_valid = if self.model_type == "rtdetr" {
394            row[4].is_finite()
395        } else {
396            Self::is_valid_score(row[4])
397        };
398        if score_is_valid && Self::is_valid_class(row[5], self.num_classes) {
399            let class_id = row[5].round() as i32;
400            if class_id >= 0 {
401                let score = self.adjust_score(row[4]);
402                return Some((class_id as usize, score, row[0], row[1], row[2], row[3]));
403            }
404        }
405
406        // Alternate format: [score, class_id, x1, y1, x2, y2]
407        let score_is_valid = if self.model_type == "rtdetr" {
408            row[0].is_finite()
409        } else {
410            Self::is_valid_score(row[0])
411        };
412        if score_is_valid && Self::is_valid_class(row[1], self.num_classes) {
413            let class_id = row[1].round() as i32;
414            if class_id >= 0 {
415                let score = self.adjust_score(row[0]);
416                return Some((class_id as usize, score, row[2], row[3], row[4], row[5]));
417            }
418        }
419
420        None
421    }
422
423    fn convert_bbox_coords(
424        &self,
425        x1: f32,
426        y1: f32,
427        x2: f32,
428        y2: f32,
429        orig_width: f32,
430        orig_height: f32,
431    ) -> (f32, f32, f32, f32) {
432        let normalized = x2 <= 1.05
433            && y2 <= 1.05
434            && x1 >= -0.05
435            && y1 >= -0.05
436            && orig_width > 0.0
437            && orig_height > 0.0;
438
439        if normalized {
440            (
441                x1.clamp(0.0, 1.0) * orig_width,
442                y1.clamp(0.0, 1.0) * orig_height,
443                x2.clamp(0.0, 1.0) * orig_width,
444                y2.clamp(0.0, 1.0) * orig_height,
445            )
446        } else {
447            (
448                x1.clamp(0.0, orig_width),
449                y1.clamp(0.0, orig_height),
450                x2.clamp(0.0, orig_width),
451                y2.clamp(0.0, orig_height),
452            )
453        }
454    }
455
456    fn is_valid_box(x1: f32, y1: f32, x2: f32, y2: f32) -> bool {
457        x2 > x1 && y2 > y1 && x1.is_finite() && y1.is_finite() && x2.is_finite() && y2.is_finite()
458    }
459
460    fn is_valid_score(score: f32) -> bool {
461        score.is_finite() && (0.0..=1.0 + f32::EPSILON).contains(&score)
462    }
463
464    fn is_valid_class(raw: f32, num_classes: usize) -> bool {
465        if !raw.is_finite() {
466            return false;
467        }
468        let class_id = raw.round() as i32;
469        class_id >= 0 && (class_id as usize) < num_classes + 5
470    }
471
472    fn adjust_score(&self, raw_score: f32) -> f32 {
473        if self.model_type == "rtdetr" {
474            raw_score.clamp(0.0, 1.0)
475        } else {
476            raw_score
477        }
478    }
479
480    /// Compute indices to keep after NMS.
481    /// Returns the indices of boxes that survive non-maximum suppression.
482    fn compute_nms_keep_indices(
483        &self,
484        boxes: &[BoundingBox],
485        classes: &[usize],
486        scores: &[f32],
487    ) -> Vec<usize> {
488        // Sort by score in descending order
489        let mut indices: Vec<usize> = (0..boxes.len()).collect();
490        indices.sort_by(|&a, &b| {
491            scores[b]
492                .partial_cmp(&scores[a])
493                .unwrap_or(std::cmp::Ordering::Equal)
494        });
495
496        // Precompute AABB bounds once (used by every IoU). This collapses
497        // the original 4 separate `fold` passes per box down to one pass.
498        let bounds: Vec<(f32, f32, f32, f32)> = boxes.iter().map(|b| b.aabb()).collect();
499
500        let mut keep = Vec::new();
501        let mut suppressed = vec![false; boxes.len()];
502
503        for pos in 0..indices.len() {
504            let i = indices[pos];
505            if suppressed[i] {
506                continue;
507            }
508
509            keep.push(i);
510            if keep.len() >= self.max_detections {
511                break;
512            }
513
514            let (ix1, iy1, ix2, iy2) = bounds[i];
515            let ic = classes[i];
516            let area_i = (ix2 - ix1) * (iy2 - iy1);
517
518            // Suppress later boxes with high IoU against `i`. `indices` is sorted
519            // by descending score, so only boxes after `pos` can be suppressed by
520            // `i` (earlier ones were already processed). Uses precomputed AABB
521            // bounds and skips mismatched classes / already-suppressed boxes.
522            for &j in &indices[pos + 1..] {
523                if suppressed[j] || classes[j] != ic {
524                    continue;
525                }
526                let (jx1, jy1, jx2, jy2) = bounds[j];
527                let inter_x_min = ix1.max(jx1);
528                let inter_y_min = iy1.max(jy1);
529                let inter_x_max = ix2.min(jx2);
530                let inter_y_max = iy2.min(jy2);
531                if inter_x_min >= inter_x_max || inter_y_min >= inter_y_max {
532                    continue;
533                }
534                let inter_area = (inter_x_max - inter_x_min) * (inter_y_max - inter_y_min);
535                let area_j = (jx2 - jx1) * (jy2 - jy1);
536                let union_area = area_i + area_j - inter_area;
537                if union_area > 0.0 {
538                    let iou = inter_area / union_area;
539                    if iou > self.nms_threshold {
540                        suppressed[j] = true;
541                    }
542                }
543            }
544        }
545
546        keep
547    }
548
549    /// Apply Non-Maximum Suppression to filter overlapping boxes.
550    fn apply_nms(
551        &self,
552        boxes: Vec<BoundingBox>,
553        classes: Vec<usize>,
554        scores: Vec<f32>,
555    ) -> (Vec<BoundingBox>, Vec<usize>, Vec<f32>) {
556        if boxes.is_empty() {
557            return (boxes, classes, scores);
558        }
559
560        let keep = self.compute_nms_keep_indices(&boxes, &classes, &scores);
561
562        let filtered_boxes: Vec<BoundingBox> = keep.iter().map(|&i| boxes[i].clone()).collect();
563        let filtered_classes: Vec<usize> = keep.iter().map(|&i| classes[i]).collect();
564        let filtered_scores: Vec<f32> = keep.iter().map(|&i| scores[i]).collect();
565
566        (filtered_boxes, filtered_classes, filtered_scores)
567    }
568
569    /// Calculate Intersection over Union between two bounding boxes.
570    #[allow(dead_code)]
571    fn calculate_iou(&self, box1: &BoundingBox, box2: &BoundingBox) -> f32 {
572        // Get bounding rectangle for box1
573        let (x1_min, y1_min, x1_max, y1_max) = self.get_bbox_bounds(box1);
574
575        // Get bounding rectangle for box2
576        let (x2_min, y2_min, x2_max, y2_max) = self.get_bbox_bounds(box2);
577
578        // Calculate intersection
579        let x_min = x1_min.max(x2_min);
580        let y_min = y1_min.max(y2_min);
581        let x_max = x1_max.min(x2_max);
582        let y_max = y1_max.min(y2_max);
583
584        if x_max <= x_min || y_max <= y_min {
585            return 0.0;
586        }
587
588        let intersection = (x_max - x_min) * (y_max - y_min);
589        let area1 = (x1_max - x1_min) * (y1_max - y1_min);
590        let area2 = (x2_max - x2_min) * (y2_max - y2_min);
591        let union = area1 + area2 - intersection;
592
593        if union > 0.0 {
594            intersection / union
595        } else {
596            0.0
597        }
598    }
599
600    /// Get the minimum and maximum coordinates from a bounding box.
601    #[allow(dead_code)]
602    fn get_bbox_bounds(&self, bbox: &BoundingBox) -> (f32, f32, f32, f32) {
603        if bbox.points.is_empty() {
604            return (0.0, 0.0, 0.0, 0.0);
605        }
606
607        let mut x_min = f32::INFINITY;
608        let mut y_min = f32::INFINITY;
609        let mut x_max = f32::NEG_INFINITY;
610        let mut y_max = f32::NEG_INFINITY;
611
612        for point in &bbox.points {
613            x_min = x_min.min(point.x);
614            y_min = y_min.min(point.y);
615            x_max = x_max.max(point.x);
616            y_max = y_max.max(point.y);
617        }
618
619        (x_min, y_min, x_max, y_max)
620    }
621}
622
623/// Apply unclip ratio to expand/shrink bounding boxes while keeping center fixed.
624///
625/// This follows PP-StructureV3's `layout_unclip_ratio` parameter behavior.
626///
627/// # Arguments
628/// * `boxes` - Input bounding boxes
629/// * `classes` - Class IDs for each box
630/// * `width_ratio` - Ratio to apply to box width (1.0 = no change)
631/// * `height_ratio` - Ratio to apply to box height (1.0 = no change)
632/// * `per_class_ratios` - Optional per-class ratios: class_id -> (width_ratio, height_ratio)
633///
634/// # Returns
635/// Transformed bounding boxes with same center but scaled dimensions
636pub fn unclip_boxes(
637    boxes: &[BoundingBox],
638    classes: &[usize],
639    width_ratio: f32,
640    height_ratio: f32,
641    per_class_ratios: Option<&std::collections::HashMap<usize, (f32, f32)>>,
642) -> Vec<BoundingBox> {
643    boxes
644        .iter()
645        .zip(classes.iter())
646        .map(|(bbox, &class_id)| {
647            // Get ratio for this class
648            let (w_ratio, h_ratio) = per_class_ratios
649                .and_then(|ratios| ratios.get(&class_id).copied())
650                .unwrap_or((width_ratio, height_ratio));
651
652            // Skip if ratios are 1.0 (no change)
653            if (w_ratio - 1.0).abs() < 1e-6 && (h_ratio - 1.0).abs() < 1e-6 {
654                return bbox.clone();
655            }
656
657            // Get current bounds in a single pass
658            let (x_min, y_min, x_max, y_max) = bbox.aabb();
659
660            // Calculate center and dimensions
661            let width = x_max - x_min;
662            let height = y_max - y_min;
663            let center_x = x_min + width * 0.5;
664            let center_y = y_min + height * 0.5;
665
666            // Apply ratio
667            let new_width = width * w_ratio;
668            let new_height = height * h_ratio;
669            let half_new_w = new_width * 0.5;
670            let half_new_h = new_height * 0.5;
671
672            // Calculate new bounds
673            let new_x_min = center_x - half_new_w;
674            let new_y_min = center_y - half_new_h;
675            let new_x_max = center_x + half_new_w;
676            let new_y_max = center_y + half_new_h;
677
678            BoundingBox::from_coords(new_x_min, new_y_min, new_x_max, new_y_max)
679        })
680        .collect()
681}
682
683/// Merge two bounding boxes according to the specified mode.
684///
685/// # Arguments
686/// * `box1` - First bounding box
687/// * `box2` - Second bounding box
688/// * `mode` - Merge mode to apply
689///
690/// # Returns
691/// Merged bounding box according to the mode
692pub fn merge_boxes(box1: &BoundingBox, box2: &BoundingBox, mode: MergeBboxMode) -> BoundingBox {
693    let (x1_min, y1_min, x1_max, y1_max) = box1.aabb();
694    let (x2_min, y2_min, x2_max, y2_max) = box2.aabb();
695
696    let area1 = (x1_max - x1_min) * (y1_max - y1_min);
697    let area2 = (x2_max - x2_min) * (y2_max - y2_min);
698
699    match mode {
700        MergeBboxMode::Large => {
701            // Keep the larger bounding box
702            if area1 >= area2 {
703                box1.clone()
704            } else {
705                box2.clone()
706            }
707        }
708        MergeBboxMode::Small => {
709            // Keep the smaller bounding box
710            if area1 <= area2 {
711                box1.clone()
712            } else {
713                box2.clone()
714            }
715        }
716        MergeBboxMode::Union => {
717            // Merge to union of bounding boxes
718            let union_x_min = x1_min.min(x2_min);
719            let union_y_min = y1_min.min(y2_min);
720            let union_x_max = x1_max.max(x2_max);
721            let union_y_max = y1_max.max(y2_max);
722            BoundingBox::from_coords(union_x_min, union_y_min, union_x_max, union_y_max)
723        }
724    }
725}
726
727/// Apply Non-Maximum Suppression with per-class merge modes.
728///
729/// Unlike standard NMS which simply suppresses (discards) overlapping boxes,
730/// this function can merge overlapping boxes according to the specified mode.
731///
732/// # Arguments
733/// * `boxes` - Input bounding boxes
734/// * `classes` - Class IDs for each box
735/// * `scores` - Confidence scores for each box
736/// * `class_labels` - Mapping from class ID to label string
737/// * `class_merge_modes` - Per-class merge modes (label -> mode)
738/// * `nms_threshold` - IoU threshold for overlap detection
739/// * `max_detections` - Maximum number of detections to return
740///
741/// # Returns
742/// Tuple of (filtered_boxes, filtered_classes, filtered_scores)
743pub fn apply_nms_with_merge(
744    boxes: Vec<BoundingBox>,
745    classes: Vec<usize>,
746    scores: Vec<f32>,
747    class_labels: &HashMap<usize, String>,
748    class_merge_modes: &HashMap<String, MergeBboxMode>,
749    nms_threshold: f32,
750    max_detections: usize,
751) -> (Vec<BoundingBox>, Vec<usize>, Vec<f32>) {
752    if boxes.is_empty() {
753        return (boxes, classes, scores);
754    }
755
756    // Sort by score in descending order
757    let mut indices: Vec<usize> = (0..boxes.len()).collect();
758    indices.sort_by(|&a, &b| {
759        scores[b]
760            .partial_cmp(&scores[a])
761            .unwrap_or(std::cmp::Ordering::Equal)
762    });
763
764    let mut result_boxes = Vec::new();
765    let mut result_classes = Vec::new();
766    let mut result_scores = Vec::new();
767    let mut result_order_indices = Vec::new();
768    let mut processed = vec![false; boxes.len()];
769
770    for &i in &indices {
771        if processed[i] {
772            continue;
773        }
774
775        processed[i] = true;
776
777        // Get merge mode for this class
778        let class_label = class_labels
779            .get(&classes[i])
780            .map(|s| s.as_str())
781            .unwrap_or("unknown");
782        let merge_mode = class_merge_modes
783            .get(class_label)
784            .copied()
785            .unwrap_or(MergeBboxMode::Large);
786
787        let mut merged_box = boxes[i].clone();
788        let mut best_score = scores[i];
789        let mut order_idx = i;
790
791        // Find overlapping boxes of the same class and merge them
792        for &j in &indices {
793            if i != j && !processed[j] && classes[i] == classes[j] {
794                let iou = calculate_iou_static(&merged_box, &boxes[j]);
795                if iou > nms_threshold {
796                    // Merge the boxes
797                    merged_box = merge_boxes(&merged_box, &boxes[j], merge_mode);
798                    best_score = best_score.max(scores[j]);
799                    order_idx = order_idx.min(j);
800                    processed[j] = true;
801                }
802            }
803        }
804
805        result_boxes.push(merged_box);
806        result_classes.push(classes[i]);
807        result_scores.push(best_score);
808        result_order_indices.push(order_idx);
809    }
810
811    // First, apply max_detections limit based on score (NMS already processed in score order,
812    // so result_* vectors are implicitly score-ordered). This ensures we keep the highest-scoring
813    // detections rather than earliest ones.
814    let take_count = max_detections.min(result_boxes.len());
815
816    // Preserve input ordering for downstream consumers (e.g., PP-DocLayoutV2 reading-order output).
817    // We keep the score-based selection above, but sort the top-N merged results by the earliest
818    // original index in each merged group.
819    let mut merged: Vec<(usize, BoundingBox, usize, f32)> = result_order_indices
820        .into_iter()
821        .zip(result_boxes)
822        .zip(result_classes)
823        .zip(result_scores)
824        .map(|(((order, bbox), class_id), score)| (order, bbox, class_id, score))
825        .take(take_count) // Apply max_detections limit BEFORE reordering
826        .collect();
827
828    merged.sort_by_key(|(a, _, _, _)| *a);
829
830    let mut final_boxes = Vec::new();
831    let mut final_classes = Vec::new();
832    let mut final_scores = Vec::new();
833
834    for (_, bbox, class_id, score) in merged {
835        final_boxes.push(bbox);
836        final_classes.push(class_id);
837        final_scores.push(score);
838    }
839
840    (final_boxes, final_classes, final_scores)
841}
842
843/// Calculate IoU between two bounding boxes (standalone function).
844fn calculate_iou_static(box1: &BoundingBox, box2: &BoundingBox) -> f32 {
845    let (x1_min, y1_min, x1_max, y1_max) = box1.aabb();
846    let (x2_min, y2_min, x2_max, y2_max) = box2.aabb();
847
848    // Calculate intersection
849    let x_min = x1_min.max(x2_min);
850    let y_min = y1_min.max(y2_min);
851    let x_max = x1_max.min(x2_max);
852    let y_max = y1_max.min(y2_max);
853
854    if x_max <= x_min || y_max <= y_min {
855        return 0.0;
856    }
857
858    let intersection = (x_max - x_min) * (y_max - y_min);
859    let area1 = (x1_max - x1_min) * (y1_max - y1_min);
860    let area2 = (x2_max - x2_min) * (y2_max - y2_min);
861    let union = area1 + area2 - intersection;
862
863    if union > 0.0 {
864        intersection / union
865    } else {
866        0.0
867    }
868}
869
870impl Default for LayoutPostProcess {
871    fn default() -> Self {
872        Self {
873            num_classes: 5, // Default for basic layout detection
874            score_threshold: 0.5,
875            nms_threshold: 0.5,
876            max_detections: 100,
877            model_type: "picodet".to_string(),
878        }
879    }
880}
881
882#[cfg(test)]
883mod tests {
884    use super::*;
885
886    #[test]
887    fn test_layout_postprocess_creation() {
888        let processor = LayoutPostProcess::default();
889        assert_eq!(processor.num_classes, 5);
890        assert_eq!(processor.score_threshold, 0.5);
891    }
892
893    #[test]
894    fn test_iou_calculation() {
895        let processor = LayoutPostProcess::default();
896
897        // Two identical boxes should have IoU = 1.0
898        let box1 = BoundingBox::new(vec![
899            Point::new(0.0, 0.0),
900            Point::new(100.0, 0.0),
901            Point::new(100.0, 100.0),
902            Point::new(0.0, 100.0),
903        ]);
904        let box2 = box1.clone();
905
906        assert_eq!(processor.calculate_iou(&box1, &box2), 1.0);
907
908        // Non-overlapping boxes should have IoU = 0.0
909        let box3 = BoundingBox::new(vec![
910            Point::new(200.0, 200.0),
911            Point::new(300.0, 200.0),
912            Point::new(300.0, 300.0),
913            Point::new(200.0, 300.0),
914        ]);
915
916        assert_eq!(processor.calculate_iou(&box1, &box3), 0.0);
917    }
918
919    #[test]
920    fn test_pp_doclayout_under_width_output_does_not_panic() {
921        // Regression: `process_pp_doclayout` must not index `[box_idx, 0, 5]`
922        // when the model emits fewer than 6 feature columns. It should return
923        // empty results instead of panicking.
924        let processor = LayoutPostProcess::new(17, 0.5, 0.5, 100, "pp-doclayout".to_string());
925        let img_shape = ImageScaleInfo::new(100.0, 100.0, 1.0, 1.0);
926
927        // feature_dim = 4 (< 6)
928        let preds = ndarray::Array3::<f32>::zeros((3, 1, 4));
929        let (boxes, classes, scores) = processor.process_pp_doclayout(preds.view(), &img_shape);
930        assert!(boxes.is_empty() && classes.is_empty() && scores.is_empty());
931
932        // Zero rows in the middle dimension.
933        let preds = ndarray::Array3::<f32>::zeros((3, 0, 8));
934        let (boxes, _, _) = processor.process_pp_doclayout(preds.view(), &img_shape);
935        assert!(boxes.is_empty());
936    }
937
938    #[test]
939    fn test_picodet_argmax_handles_non_positive_scores() {
940        // Regression: the class argmax must not seed with 0.0, otherwise a box
941        // whose true max class score is negative is mislabeled as class 0 / 0.0.
942        // Use a negative threshold so the (correctly-found) max survives filtering.
943        let processor = LayoutPostProcess::new(3, -1.0, 1.0, 100, "picodet".to_string());
944        let img_shape = ImageScaleInfo::new(100.0, 100.0, 1.0, 1.0);
945
946        // One box: bbox [10,10,50,50], class scores [-0.9, -0.2, -0.5].
947        // The true argmax is class 1 (-0.2), not class 0.
948        let preds = ndarray::Array3::<f32>::from_shape_vec(
949            (1, 1, 4 + 3),
950            vec![10.0, 10.0, 50.0, 50.0, -0.9, -0.2, -0.5],
951        )
952        .unwrap();
953        let (boxes, classes, scores) = processor.process_picodet(preds.view(), &img_shape);
954        assert_eq!(boxes.len(), 1);
955        assert_eq!(classes[0], 1);
956        assert!((scores[0] - (-0.2)).abs() < 1e-6);
957    }
958}