Skip to main content

edgefirst_decoder/
float.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! Float-domain NMS and box geometry.
5//!
6//! Two groups of primitives, both reusable outside the decoder:
7//!
8//! - **Suppression** — [`nms_float`], [`nms_class_aware_float`], and the
9//!   `_extra` variants that carry a per-box payload (mask coefficients, say)
10//!   through suppression so the survivors stay paired with their data.
11//! - **Geometry** — [`intersection_area`], [`box_area`], [`iou_value`], and
12//!   [`ios_value`]. The tiled-inference merge in [`crate::tiling`] shares
13//!   these, which is what keeps its IoS metric numerically identical to the
14//!   IoU used by ordinary NMS.
15//!
16//! [`jaccard`] and [`jaccard_batch4`] are the threshold-test forms used on the
17//! suppression hot path; they answer "do these overlap past `iou`?" without
18//! materializing the ratio.
19
20use crate::{arg_max, BBoxTypeTrait, BoundingBox, DetectBox};
21use ndarray::{
22    parallel::prelude::{IntoParallelIterator, ParallelIterator as _},
23    Array1, ArrayView2, Zip,
24};
25use num_traits::{AsPrimitive, Float};
26use rayon::slice::ParallelSliceMut;
27
28/// Post processes boxes and scores tensors into detection boxes, filtering out
29/// any boxes below the score threshold. The boxes tensor is converted to XYXY
30/// using the given BBoxTypeTrait. The order of the boxes is preserved.
31pub fn postprocess_boxes_float<
32    B: BBoxTypeTrait,
33    BOX: Float + AsPrimitive<f32> + Send + Sync,
34    SCORE: Float + AsPrimitive<f32> + Send + Sync,
35>(
36    threshold: SCORE,
37    boxes: ArrayView2<BOX>,
38    scores: ArrayView2<SCORE>,
39) -> Vec<DetectBox> {
40    assert_eq!(scores.dim().0, boxes.dim().0);
41    assert_eq!(boxes.dim().1, 4);
42    Zip::from(scores.rows())
43        .and(boxes.rows())
44        .into_par_iter()
45        .filter_map(|(score, bbox)| {
46            let (score_, label) = arg_max(score);
47            if score_ < threshold {
48                return None;
49            }
50
51            let bbox = B::ndarray_to_xyxy_float(bbox);
52            Some(DetectBox {
53                label,
54                score: score_.as_(),
55                bbox: bbox.into(),
56            })
57        })
58        .collect()
59}
60
61/// Post processes boxes and scores tensors into detection boxes, filtering out
62/// any boxes below the score threshold. The boxes tensor is converted to XYXY
63/// using the given BBoxTypeTrait. The order of the boxes is preserved.
64///
65/// This function is very similar to `postprocess_boxes_float` but will also
66/// return the index of the box. The boxes will be in ascending index order.
67pub fn postprocess_boxes_index_float<
68    B: BBoxTypeTrait,
69    BOX: Float + AsPrimitive<f32> + Send + Sync,
70    SCORE: Float + AsPrimitive<f32> + Send + Sync,
71>(
72    threshold: SCORE,
73    boxes: ArrayView2<BOX>,
74    scores: ArrayView2<SCORE>,
75) -> Vec<(DetectBox, usize)> {
76    assert_eq!(scores.dim().0, boxes.dim().0);
77    assert_eq!(boxes.dim().1, 4);
78    let indices: Array1<usize> = (0..boxes.dim().0).collect();
79    Zip::from(scores.rows())
80        .and(boxes.rows())
81        .and(&indices)
82        .into_par_iter()
83        .filter_map(|(score, bbox, i)| {
84            let (score_, label) = arg_max(score);
85            if score_ < threshold {
86                return None;
87            }
88
89            let bbox = B::ndarray_to_xyxy_float(bbox);
90            Some((
91                DetectBox {
92                    label,
93                    score: score_.as_(),
94                    bbox: bbox.into(),
95                },
96                *i,
97            ))
98        })
99        .collect()
100}
101
102/// Multi-label variant of [`postprocess_boxes_index_float`].
103///
104/// For each anchor row, emits one `(DetectBox, anchor_idx)` per class whose
105/// score meets `threshold` — every class, not just the argmax.  The same
106/// `anchor_idx` is returned for all per-class entries of a given anchor so
107/// that downstream mask-coefficient lookup can reuse the shared coefficient
108/// row.
109///
110/// The bbox is computed once per anchor (via `B::ndarray_to_xyxy_float`) and
111/// reused across all emitted classes, avoiding redundant work.
112///
113/// Intended for **validation/mAP evaluation only** (the Ultralytics `val`
114/// convention).  Deployment must use the argmax variant
115/// [`postprocess_boxes_index_float`] so trackers see at most one box per
116/// anchor.
117pub fn postprocess_boxes_multilabel_index_float<
118    B: BBoxTypeTrait,
119    BOX: Float + AsPrimitive<f32> + Send + Sync,
120    SCORE: Float + AsPrimitive<f32> + Send + Sync,
121>(
122    threshold: SCORE,
123    boxes: ArrayView2<BOX>,
124    scores: ArrayView2<SCORE>,
125) -> Vec<(DetectBox, usize)> {
126    assert_eq!(scores.dim().0, boxes.dim().0);
127    assert_eq!(boxes.dim().1, 4);
128    let n = boxes.dim().0;
129    let indices: Array1<usize> = (0..n).collect();
130    Zip::from(scores.rows())
131        .and(boxes.rows())
132        .and(&indices)
133        .into_par_iter()
134        .flat_map(|(score_row, bbox_row, &anchor_idx)| {
135            // Compute bbox once; clone into each per-class candidate.
136            let bbox = B::ndarray_to_xyxy_float(bbox_row);
137            let bbox: crate::BoundingBox = bbox.into();
138            score_row
139                .iter()
140                .enumerate()
141                .filter(|(_, &s)| s >= threshold)
142                .map(move |(c, &s)| {
143                    (
144                        DetectBox {
145                            label: c,
146                            score: s.as_(),
147                            bbox,
148                        },
149                        anchor_idx,
150                    )
151                })
152                .collect::<Vec<_>>()
153        })
154        .collect()
155}
156
157/// [`postprocess_boxes_multilabel_index_float`] without anchor indices.
158///
159/// # Errors
160/// [`crate::DecoderError::InvalidShape`] unless boxes are `[N, 4]` and scores
161/// `[N, C]`.
162pub fn postprocess_boxes_multilabel_float<
163    B: BBoxTypeTrait,
164    BOX: Float + AsPrimitive<f32> + Send + Sync,
165    SCORE: Float + AsPrimitive<f32> + Send + Sync,
166>(
167    threshold: SCORE,
168    boxes: ArrayView2<BOX>,
169    scores: ArrayView2<SCORE>,
170) -> crate::DecoderResult<Vec<DetectBox>> {
171    crate::byte::check_candidate_shapes(boxes.dim(), scores.dim())?;
172    Ok(
173        postprocess_boxes_multilabel_index_float::<B, _, _>(threshold, boxes, scores)
174            .into_iter()
175            .map(|(b, _)| b)
176            .collect(),
177    )
178}
179
180/// Uses NMS to filter boxes based on the score and iou. Sorts boxes by score,
181/// then greedily selects a subset of boxes in descending order of score.
182///
183/// If `max_det` is `Some(n)`, the greedy loop stops as soon as `n` survivors
184/// have been confirmed. Because the input is sorted descending, the first `n`
185/// survivors are the highest-scoring `n`, so the post-NMS top-`n` is preserved
186/// without iterating the full O(N²) suppression loop.
187#[must_use]
188pub fn nms_float(iou: f32, max_det: Option<usize>, mut boxes: Vec<DetectBox>) -> Vec<DetectBox> {
189    // Boxes get sorted by score in descending order so we know based on the
190    // index the scoring of the boxes and can skip parts of the loop.
191    boxes.par_sort_by(|a, b| b.score.total_cmp(&a.score));
192
193    // When the iou is 1.0 or larger, no boxes will be filtered so we just return
194    // immediately
195    if iou >= 1.0 {
196        return match max_det {
197            Some(n) => {
198                boxes.truncate(n);
199                boxes
200            }
201            None => boxes,
202        };
203    }
204
205    let cap = max_det.unwrap_or(usize::MAX);
206    let mut survivors: usize = 0;
207
208    // Outer loop over all boxes.
209    for i in 0..boxes.len() {
210        if boxes[i].score < 0.0 {
211            // this box was merged with a different box earlier
212            continue;
213        }
214        for j in (i + 1)..boxes.len() {
215            // Inner loop over boxes with lower score (later in the list).
216
217            if boxes[j].score < 0.0 {
218                // this box was suppressed by different box earlier
219                continue;
220            }
221            if jaccard(&boxes[j].bbox, &boxes[i].bbox, iou) {
222                // max_box(boxes[j].bbox, &mut boxes[i].bbox);
223                boxes[j].score = -1.0;
224            }
225        }
226
227        // NOTE: jaccard_batch4_neon is available for callers that can
228        // batch unsuppressed candidates externally. It is not used
229        // inline here because score=-1 marking creates sparse gaps
230        // that prevent contiguous 4-box batching.
231        survivors += 1;
232        if survivors >= cap {
233            break;
234        }
235    }
236    // Filter out suppressed boxes; cap at `max_det` because boxes after the
237    // break may still hold positive scores but score lower than every survivor.
238    boxes
239        .into_iter()
240        .filter(|b| b.score >= 0.0)
241        .take(cap)
242        .collect()
243}
244
245/// Uses NMS to filter boxes based on the score and iou. Sorts boxes by score,
246/// then greedily selects a subset of boxes in descending order of score.
247///
248/// This is same as `nms_float` but will also include extra information along
249/// with each box, such as the index
250#[must_use]
251pub fn nms_extra_float<E: Send + Sync>(
252    iou: f32,
253    max_det: Option<usize>,
254    mut boxes: Vec<(DetectBox, E)>,
255) -> Vec<(DetectBox, E)> {
256    // Boxes get sorted by score in descending order so we know based on the
257    // index the scoring of the boxes and can skip parts of the loop.
258    boxes.par_sort_by(|a, b| b.0.score.total_cmp(&a.0.score));
259
260    // When the iou is 1.0 or larger, no boxes will be filtered so we just return
261    // immediately
262    if iou >= 1.0 {
263        return match max_det {
264            Some(n) => {
265                boxes.truncate(n);
266                boxes
267            }
268            None => boxes,
269        };
270    }
271
272    let cap = max_det.unwrap_or(usize::MAX);
273    let mut survivors: usize = 0;
274
275    // Outer loop over all boxes.
276    for i in 0..boxes.len() {
277        if boxes[i].0.score < 0.0 {
278            // this box was merged with a different box earlier
279            continue;
280        }
281        for j in (i + 1)..boxes.len() {
282            // Inner loop over boxes with lower score (later in the list).
283
284            if boxes[j].0.score < 0.0 {
285                // this box was suppressed by different box earlier
286                continue;
287            }
288            if jaccard(&boxes[j].0.bbox, &boxes[i].0.bbox, iou) {
289                // max_box(boxes[j].bbox, &mut boxes[i].bbox);
290                boxes[j].0.score = -1.0;
291            }
292        }
293        survivors += 1;
294        if survivors >= cap {
295            break;
296        }
297    }
298
299    // Filter out suppressed boxes; cap at `max_det` for the same reason as
300    // `nms_float`.
301    boxes
302        .into_iter()
303        .filter(|b| b.0.score >= 0.0)
304        .take(cap)
305        .collect()
306}
307
308/// Class-aware NMS: only suppress boxes with the same label.
309///
310/// Sorts boxes by score, then greedily selects a subset of boxes in descending
311/// order of score. Unlike class-agnostic NMS, boxes are only suppressed if they
312/// have the same class label AND overlap above the IoU threshold.
313///
314/// # Example
315/// ```
316/// # use edgefirst_decoder::{BoundingBox, DetectBox, float::nms_class_aware_float};
317/// let boxes = vec![
318///     DetectBox {
319///         bbox: BoundingBox::new(0.0, 0.0, 0.5, 0.5),
320///         score: 0.9,
321///         label: 0,
322///     },
323///     DetectBox {
324///         bbox: BoundingBox::new(0.1, 0.1, 0.6, 0.6),
325///         score: 0.8,
326///         label: 1,
327///     }, // different class
328/// ];
329/// // Both boxes survive because they have different labels
330/// let result = nms_class_aware_float(0.3, None, boxes);
331/// assert_eq!(result.len(), 2);
332/// ```
333#[must_use]
334pub fn nms_class_aware_float(
335    iou: f32,
336    max_det: Option<usize>,
337    mut boxes: Vec<DetectBox>,
338) -> Vec<DetectBox> {
339    boxes.par_sort_by(|a, b| b.score.total_cmp(&a.score));
340
341    if iou >= 1.0 {
342        return match max_det {
343            Some(n) => {
344                boxes.truncate(n);
345                boxes
346            }
347            None => boxes,
348        };
349    }
350
351    let cap = max_det.unwrap_or(usize::MAX);
352    let mut survivors: usize = 0;
353
354    for i in 0..boxes.len() {
355        if boxes[i].score < 0.0 {
356            continue;
357        }
358        for j in (i + 1)..boxes.len() {
359            if boxes[j].score < 0.0 {
360                continue;
361            }
362            // Only suppress if same class AND overlapping
363            if boxes[j].label == boxes[i].label && jaccard(&boxes[j].bbox, &boxes[i].bbox, iou) {
364                boxes[j].score = -1.0;
365            }
366        }
367        survivors += 1;
368        if survivors >= cap {
369            break;
370        }
371    }
372    boxes
373        .into_iter()
374        .filter(|b| b.score >= 0.0)
375        .take(cap)
376        .collect()
377}
378
379/// Class-aware NMS with extra data: only suppress boxes with the same label.
380///
381/// This is same as `nms_class_aware_float` but will also include extra
382/// information along with each box, such as the index.
383#[must_use]
384pub fn nms_extra_class_aware_float<E: Send + Sync>(
385    iou: f32,
386    max_det: Option<usize>,
387    mut boxes: Vec<(DetectBox, E)>,
388) -> Vec<(DetectBox, E)> {
389    boxes.par_sort_by(|a, b| b.0.score.total_cmp(&a.0.score));
390
391    // When the iou is 1.0 or larger, no boxes will be filtered so we just return
392    // immediately
393    if iou >= 1.0 {
394        return match max_det {
395            Some(n) => {
396                boxes.truncate(n);
397                boxes
398            }
399            None => boxes,
400        };
401    }
402
403    let cap = max_det.unwrap_or(usize::MAX);
404    let mut survivors: usize = 0;
405
406    for i in 0..boxes.len() {
407        if boxes[i].0.score < 0.0 {
408            continue;
409        }
410        for j in (i + 1)..boxes.len() {
411            if boxes[j].0.score < 0.0 {
412                continue;
413            }
414            // Only suppress if same class AND overlapping
415            if boxes[j].0.label == boxes[i].0.label
416                && jaccard(&boxes[j].0.bbox, &boxes[i].0.bbox, iou)
417            {
418                boxes[j].0.score = -1.0;
419            }
420        }
421        survivors += 1;
422        if survivors >= cap {
423            break;
424        }
425    }
426    boxes
427        .into_iter()
428        .filter(|b| b.0.score >= 0.0)
429        .take(cap)
430        .collect()
431}
432
433/// Area of the axis-aligned intersection of two boxes, clamped to `>= 0` per
434/// side. Mirrors the `inter` term in ModelPack `metrics/tiled.py::_match`.
435#[must_use]
436#[inline]
437pub fn intersection_area(a: &BoundingBox, b: &BoundingBox) -> f32 {
438    let left = a.xmin.max(b.xmin);
439    let top = a.ymin.max(b.ymin);
440    let right = a.xmax.min(b.xmax);
441    let bottom = a.ymax.min(b.ymax);
442    (right - left).max(0.0) * (bottom - top).max(0.0)
443}
444
445/// Area of a box, clamped to `>= 0` per side. Mirrors ModelPack `_areas`.
446#[must_use]
447#[inline]
448pub fn box_area(b: &BoundingBox) -> f32 {
449    (b.xmax - b.xmin).max(0.0) * (b.ymax - b.ymin).max(0.0)
450}
451
452/// Intersection-over-Union value in `[0, 1]`. Mirrors ModelPack
453/// `_match(metric='iou')`: `inter / max(area_a + area_b - inter, 1e-9)`.
454#[must_use]
455#[inline]
456pub fn iou_value(a: &BoundingBox, b: &BoundingBox) -> f32 {
457    let inter = intersection_area(a, b);
458    let union = (box_area(a) + box_area(b) - inter).max(1e-9);
459    inter / union
460}
461
462/// Intersection-over-Smaller value in `[0, 1]`. Mirrors ModelPack
463/// `_match(metric='ios')`: `inter / max(min(area_a, area_b), 1e-9)`. Used by the
464/// tiled-detection merge: a seam-split object has low IoU but high IoS.
465#[must_use]
466#[inline]
467pub fn ios_value(a: &BoundingBox, b: &BoundingBox) -> f32 {
468    let inter = intersection_area(a, b);
469    let denom = box_area(a).min(box_area(b)).max(1e-9);
470    inter / denom
471}
472
473/// Returns true if the IOU of the given bounding boxes is greater than the iou
474/// threshold
475///
476/// Kept as a standalone inline (NOT routed through [`iou_value`]) so this hot
477/// NMS primitive stays byte-identical to its NEON sibling
478/// [`jaccard_batch4`]; the value helpers above are additive and used by the
479/// tiled-detection merge.
480///
481/// # Example
482/// ```
483/// # use edgefirst_decoder::{BoundingBox, float::jaccard};
484/// let a = BoundingBox::new(0.0, 0.0, 0.2, 0.2);
485/// let b = BoundingBox::new(0.1, 0.1, 0.3, 0.3);
486/// let iou_threshold = 0.1;
487/// let result = jaccard(&a, &b, iou_threshold);
488/// assert!(result);
489/// ```
490pub fn jaccard(a: &BoundingBox, b: &BoundingBox, iou: f32) -> bool {
491    let left = a.xmin.max(b.xmin);
492    let top = a.ymin.max(b.ymin);
493    let right = a.xmax.min(b.xmax);
494    let bottom = a.ymax.min(b.ymax);
495
496    let intersection = (right - left).max(0.0) * (bottom - top).max(0.0);
497    let area_a = (a.xmax - a.xmin) * (a.ymax - a.ymin);
498    let area_b = (b.xmax - b.xmin) * (b.ymax - b.ymin);
499
500    // need to make sure we are not dividing by zero
501    let union = area_a + area_b - intersection;
502
503    intersection > iou * union
504}
505
506/// Batch IoU check: test one reference box `a` against 4 candidate boxes.
507///
508/// Returns a 4-element array of booleans: `result[i]` is true if
509/// `jaccard(a, boxes[i], iou)` would return true.
510///
511/// On aarch64, uses NEON `vmaxq_f32`/`vminq_f32` for vectorized
512/// intersection computation. On other architectures falls back to
513/// 4 scalar `jaccard` calls.
514#[inline]
515pub fn jaccard_batch4(a: &BoundingBox, boxes: &[BoundingBox; 4], iou: f32) -> [bool; 4] {
516    #[cfg(target_arch = "aarch64")]
517    {
518        // SAFETY: NEON is mandatory on aarch64.
519        unsafe { jaccard_batch4_neon(a, boxes, iou) }
520    }
521    #[cfg(not(target_arch = "aarch64"))]
522    {
523        [
524            jaccard(a, &boxes[0], iou),
525            jaccard(a, &boxes[1], iou),
526            jaccard(a, &boxes[2], iou),
527            jaccard(a, &boxes[3], iou),
528        ]
529    }
530}
531
532/// NEON-vectorized batch IoU for 4 candidate boxes against one reference.
533///
534/// Loads xmin/ymin/xmax/ymax of the 4 candidates into separate NEON
535/// registers (AoS→SoA transpose), then computes intersection, union,
536/// and the `intersection > iou * union` test in 4-wide SIMD.
537#[cfg(target_arch = "aarch64")]
538#[target_feature(enable = "neon")]
539unsafe fn jaccard_batch4_neon(a: &BoundingBox, boxes: &[BoundingBox; 4], iou: f32) -> [bool; 4] {
540    unsafe {
541        use std::arch::aarch64::*; // NOSONAR
542
543        let zero = vdupq_n_f32(0.0);
544        let iou_v = vdupq_n_f32(iou);
545
546        // Reference box broadcast.
547        let a_xmin = vdupq_n_f32(a.xmin);
548        let a_ymin = vdupq_n_f32(a.ymin);
549        let a_xmax = vdupq_n_f32(a.xmax);
550        let a_ymax = vdupq_n_f32(a.ymax);
551        let area_a = vmulq_f32(vsubq_f32(a_xmax, a_xmin), vsubq_f32(a_ymax, a_ymin));
552
553        // Load 4 boxes (each BoundingBox is [xmin, ymin, xmax, ymax]).
554        let b0 = vld1q_f32(&boxes[0].xmin as *const f32);
555        let b1 = vld1q_f32(&boxes[1].xmin as *const f32);
556        let b2 = vld1q_f32(&boxes[2].xmin as *const f32);
557        let b3 = vld1q_f32(&boxes[3].xmin as *const f32);
558
559        // AoS → SoA transpose (4×4).
560        let t01_lo = vtrn1q_f32(b0, b1); // xmin0,xmin1,xmax0,xmax1
561        let t01_hi = vtrn2q_f32(b0, b1); // ymin0,ymin1,ymax0,ymax1
562        let t23_lo = vtrn1q_f32(b2, b3);
563        let t23_hi = vtrn2q_f32(b2, b3);
564
565        let b_xmin = vreinterpretq_f32_f64(vtrn1q_f64(
566            vreinterpretq_f64_f32(t01_lo),
567            vreinterpretq_f64_f32(t23_lo),
568        ));
569        let b_ymin = vreinterpretq_f32_f64(vtrn1q_f64(
570            vreinterpretq_f64_f32(t01_hi),
571            vreinterpretq_f64_f32(t23_hi),
572        ));
573        let b_xmax = vreinterpretq_f32_f64(vtrn2q_f64(
574            vreinterpretq_f64_f32(t01_lo),
575            vreinterpretq_f64_f32(t23_lo),
576        ));
577        let b_ymax = vreinterpretq_f32_f64(vtrn2q_f64(
578            vreinterpretq_f64_f32(t01_hi),
579            vreinterpretq_f64_f32(t23_hi),
580        ));
581
582        // Intersection.
583        let left = vmaxq_f32(a_xmin, b_xmin);
584        let top = vmaxq_f32(a_ymin, b_ymin);
585        let right = vminq_f32(a_xmax, b_xmax);
586        let bottom = vminq_f32(a_ymax, b_ymax);
587        let w = vmaxq_f32(vsubq_f32(right, left), zero);
588        let h = vmaxq_f32(vsubq_f32(bottom, top), zero);
589        let intersection = vmulq_f32(w, h);
590
591        // Area B.
592        let area_b = vmulq_f32(vsubq_f32(b_xmax, b_xmin), vsubq_f32(b_ymax, b_ymin));
593
594        // Union = area_a + area_b - intersection.
595        let union = vsubq_f32(vaddq_f32(area_a, area_b), intersection);
596
597        // Test: intersection > iou * union (equivalent to IoU > threshold).
598        let iou_union = vmulq_f32(iou_v, union);
599        let mask = vcgtq_f32(intersection, iou_union);
600
601        // Extract per-lane results.
602        [
603            vgetq_lane_u32(mask, 0) != 0,
604            vgetq_lane_u32(mask, 1) != 0,
605            vgetq_lane_u32(mask, 2) != 0,
606            vgetq_lane_u32(mask, 3) != 0,
607        ]
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use crate::BoundingBox;
615
616    #[test]
617    fn multilabel_float_drops_indices_only() {
618        let boxes = ndarray::arr2(&[[0.1f32, 0.1, 0.2, 0.2], [0.3, 0.3, 0.4, 0.4]]);
619        let scores = ndarray::arr2(&[[0.6f32, 0.1, 0.7], [0.05, 0.9, 0.05]]);
620        let with_idx = postprocess_boxes_multilabel_index_float::<crate::XYXY, _, _>(
621            0.5,
622            boxes.view(),
623            scores.view(),
624        );
625        let without = postprocess_boxes_multilabel_float::<crate::XYXY, _, _>(
626            0.5,
627            boxes.view(),
628            scores.view(),
629        )
630        .unwrap();
631        let stripped: Vec<_> = with_idx.into_iter().map(|(b, _)| b).collect();
632        assert_eq!(stripped, without);
633        assert_eq!(without.len(), 3);
634    }
635
636    #[test]
637    fn multilabel_float_rejects_non_four_column_boxes() {
638        let boxes = ndarray::arr2(&[[0.1f32, 0.1, 0.2, 0.2, 0.0]]);
639        let scores = ndarray::arr2(&[[0.6f32]]);
640        let r = postprocess_boxes_multilabel_float::<crate::XYXY, _, _>(
641            0.5,
642            boxes.view(),
643            scores.view(),
644        );
645        assert!(matches!(r, Err(crate::DecoderError::InvalidShape(_))));
646    }
647
648    /// Helper: create `n` non-overlapping boxes with descending f32 scores.
649    fn make_nms_boxes_float(n: usize) -> Vec<DetectBox> {
650        (0..n)
651            .map(|i| DetectBox {
652                bbox: BoundingBox {
653                    xmin: i as f32 * 100.0,
654                    ymin: 0.0,
655                    xmax: i as f32 * 100.0 + 10.0,
656                    ymax: 10.0,
657                },
658                label: 0,
659                score: 1.0 - i as f32 * 0.01,
660            })
661            .collect()
662    }
663
664    /// Two boxes at the same place with the same score. Sorting is stable
665    /// enough that the first stays and the second is suppressed; a score of
666    /// exactly 0.0 must not be mistaken for the `-1.0` suppression marker at
667    /// either end of the comparison.
668    fn overlapping_pair(score: f32) -> Vec<DetectBox> {
669        vec![
670            DetectBox {
671                bbox: BoundingBox {
672                    xmin: 0.0,
673                    ymin: 0.0,
674                    xmax: 10.0,
675                    ymax: 10.0,
676                },
677                label: 0,
678                score,
679            },
680            DetectBox {
681                bbox: BoundingBox {
682                    xmin: 1.0,
683                    ymin: 1.0,
684                    xmax: 11.0,
685                    ymax: 11.0,
686                },
687                label: 0,
688                score,
689            },
690        ]
691    }
692
693    /// A box scoring exactly 0.0 is a real detection that passed the
694    /// threshold, not a suppressed one. Treating `< 0.0` as `<= 0.0` would
695    /// skip it and leave its overlapping neighbour unsuppressed.
696    #[test]
697    fn nms_float_suppresses_overlapping_zero_score_boxes() {
698        let result = nms_float(0.5, None, overlapping_pair(0.0));
699        assert_eq!(result.len(), 1, "{result:?}");
700        assert_eq!(result[0].score, 0.0);
701    }
702
703    /// Three boxes in a chain: A overlaps B, B overlaps C, A does not reach
704    /// C. Once B is suppressed it must take no further part, or it suppresses
705    /// C on behalf of a box that is not in the output.
706    ///
707    /// The offsets carry the whole test. At 10 wide and stepping 2, A-B and
708    /// B-C are both 8/12 = 0.667 and A-C is 6/14 = 0.429, so only the
709    /// adjacent pairs clear the 0.5 threshold. Step further apart -- 0, 1 and
710    /// 9.5, say -- and B-C falls to 0.081, B cannot reach C whatever the
711    /// implementation does, and all three tests below pass with the bug they
712    /// exist to catch.
713    fn suppression_chain() -> Vec<DetectBox> {
714        let mk = |xmin: f32, xmax: f32, score: f32| DetectBox {
715            bbox: BoundingBox {
716                xmin,
717                ymin: 0.0,
718                xmax,
719                ymax: 10.0,
720            },
721            label: 0,
722            score,
723        };
724        vec![mk(0.0, 10.0, 0.9), mk(2.0, 12.0, 0.8), mk(4.0, 14.0, 0.7)]
725    }
726
727    #[test]
728    fn nms_float_does_not_suppress_on_behalf_of_a_suppressed_box() {
729        let result = nms_float(0.5, None, suppression_chain());
730        assert_eq!(result.len(), 2, "{result:?}");
731        assert_eq!(result[0].score, 0.9);
732        assert_eq!(result[1].score, 0.7);
733    }
734
735    #[test]
736    fn nms_extra_float_suppresses_overlapping_zero_score_boxes() {
737        let boxes = overlapping_pair(0.0)
738            .into_iter()
739            .enumerate()
740            .map(|(i, b)| (b, i))
741            .collect();
742        let result = nms_extra_float(0.5, None, boxes);
743        assert_eq!(result.len(), 1, "{result:?}");
744        // Both boxes score 0.0 and par_sort_by is an unstable sort, so which
745        // of the two survives is not fixed; that it carries its own payload
746        // is.
747        assert!(result[0].1 == 0 || result[0].1 == 1, "{result:?}");
748    }
749
750    #[test]
751    fn nms_extra_float_does_not_suppress_on_behalf_of_a_suppressed_box() {
752        let boxes = suppression_chain()
753            .into_iter()
754            .enumerate()
755            .map(|(i, b)| (b, i))
756            .collect();
757        let result = nms_extra_float(0.5, None, boxes);
758        assert_eq!(result.len(), 2, "{result:?}");
759        assert_eq!(result[0].1, 0);
760        assert_eq!(result[1].1, 2);
761    }
762
763    /// Class-aware NMS with no cap still has to run the whole suppression
764    /// loop. Breaking out of it early leaves overlapping same-class boxes in
765    /// the output.
766    #[test]
767    fn nms_class_aware_float_suppresses_same_class_without_a_cap() {
768        let mut boxes = overlapping_pair(0.9);
769        boxes[1].score = 0.8;
770        let result = nms_class_aware_float(0.5, None, boxes);
771        assert_eq!(result.len(), 1, "{result:?}");
772        assert_eq!(result[0].score, 0.9);
773    }
774
775    /// The same two boxes with different labels are both kept: class-aware
776    /// NMS suppresses within a class, never across.
777    #[test]
778    fn nms_class_aware_float_keeps_overlapping_boxes_of_other_classes() {
779        let mut boxes = overlapping_pair(0.9);
780        boxes[1].score = 0.8;
781        boxes[1].label = 1;
782        let result = nms_class_aware_float(0.5, None, boxes);
783        assert_eq!(result.len(), 2, "{result:?}");
784    }
785
786    #[test]
787    fn nms_class_aware_float_suppresses_overlapping_zero_score_boxes() {
788        let result = nms_class_aware_float(0.5, None, overlapping_pair(0.0));
789        assert_eq!(result.len(), 1, "{result:?}");
790    }
791
792    #[test]
793    fn nms_class_aware_float_does_not_suppress_on_behalf_of_a_suppressed_box() {
794        let result = nms_class_aware_float(0.5, None, suppression_chain());
795        assert_eq!(result.len(), 2, "{result:?}");
796        assert_eq!(result[1].score, 0.7);
797    }
798
799    /// A box that overlaps nothing, then a pair that overlaps each other. The
800    /// first box confirms a survivor without suppressing anything, so a loop
801    /// that stops at the first survivor leaves the pair un-deduplicated while
802    /// the three-box chain above, where the first box does the suppressing,
803    /// looks identical either way.
804    fn isolated_then_overlapping_pair() -> Vec<DetectBox> {
805        let mk = |xmin: f32, xmax: f32, score: f32| DetectBox {
806            bbox: BoundingBox {
807                xmin,
808                ymin: 0.0,
809                xmax,
810                ymax: 10.0,
811            },
812            label: 0,
813            score,
814        };
815        vec![
816            mk(100.0, 110.0, 0.9),
817            mk(0.0, 10.0, 0.8),
818            mk(1.0, 11.0, 0.7),
819        ]
820    }
821
822    #[test]
823    fn nms_class_aware_float_keeps_suppressing_after_the_first_survivor() {
824        let result = nms_class_aware_float(0.5, None, isolated_then_overlapping_pair());
825        assert_eq!(result.len(), 2, "{result:?}");
826        assert_eq!(result[0].score, 0.9);
827        assert_eq!(result[1].score, 0.8);
828    }
829
830    fn with_index(boxes: Vec<DetectBox>) -> Vec<(DetectBox, usize)> {
831        boxes.into_iter().enumerate().map(|(i, b)| (b, i)).collect()
832    }
833
834    #[test]
835    fn nms_extra_class_aware_float_keeps_suppressing_after_the_first_survivor() {
836        let result =
837            nms_extra_class_aware_float(0.5, None, with_index(isolated_then_overlapping_pair()));
838        assert_eq!(result.len(), 2, "{result:?}");
839        assert_eq!(result[1].1, 1);
840    }
841
842    #[test]
843    fn nms_extra_class_aware_float_suppresses_same_class_without_a_cap() {
844        let mut boxes = overlapping_pair(0.9);
845        boxes[1].score = 0.8;
846        let result = nms_extra_class_aware_float(0.5, None, with_index(boxes));
847        assert_eq!(result.len(), 1, "{result:?}");
848        assert_eq!(result[0].0.score, 0.9);
849        assert_eq!(result[0].1, 0);
850    }
851
852    #[test]
853    fn nms_extra_class_aware_float_keeps_overlapping_boxes_of_other_classes() {
854        let mut boxes = overlapping_pair(0.9);
855        boxes[1].score = 0.8;
856        boxes[1].label = 1;
857        let result = nms_extra_class_aware_float(0.5, None, with_index(boxes));
858        assert_eq!(result.len(), 2, "{result:?}");
859    }
860
861    #[test]
862    fn nms_extra_class_aware_float_suppresses_overlapping_zero_score_boxes() {
863        let result = nms_extra_class_aware_float(0.5, None, with_index(overlapping_pair(0.0)));
864        assert_eq!(result.len(), 1, "{result:?}");
865    }
866
867    #[test]
868    fn nms_extra_class_aware_float_does_not_suppress_on_behalf_of_a_suppressed_box() {
869        let result = nms_extra_class_aware_float(0.5, None, with_index(suppression_chain()));
870        assert_eq!(result.len(), 2, "{result:?}");
871        assert_eq!(result[1].1, 2);
872    }
873
874    /// The threshold is a floor, not a cut: a score equal to it is kept.
875    #[test]
876    fn postprocess_boxes_float_keeps_a_score_equal_to_the_threshold() {
877        let boxes = ndarray::array![[0.0_f32, 0.0, 10.0, 10.0], [0.0, 0.0, 10.0, 10.0]];
878        let scores = ndarray::array![[0.5_f32], [0.25]];
879
880        let kept = postprocess_boxes_float::<crate::XYXY, _, _>(0.5, boxes.view(), scores.view());
881        assert_eq!(kept.len(), 1, "{kept:?}");
882        assert_eq!(kept[0].score, 0.5);
883
884        let kept =
885            postprocess_boxes_index_float::<crate::XYXY, _, _>(0.5, boxes.view(), scores.view());
886        assert_eq!(kept.len(), 1, "{kept:?}");
887        assert_eq!(kept[0].1, 0);
888    }
889
890    #[test]
891    fn nms_float_max_det_matches_full_truncated() {
892        let boxes = make_nms_boxes_float(20);
893        let n = 5;
894        let full = nms_float(0.5, None, boxes.clone());
895        let capped = nms_float(0.5, Some(n), boxes);
896        assert_eq!(capped.len(), n);
897        for (f, c) in full[..n].iter().zip(capped.iter()) {
898            assert_eq!(f.bbox, c.bbox);
899            assert_eq!(f.score, c.score);
900        }
901    }
902
903    #[test]
904    fn nms_float_max_det_zero_returns_empty() {
905        let boxes = make_nms_boxes_float(10);
906        let result = nms_float(0.5, Some(0), boxes);
907        assert!(result.is_empty());
908    }
909
910    #[test]
911    fn nms_float_max_det_iou_ge_1_returns_sorted_truncated() {
912        let boxes = make_nms_boxes_float(10);
913        let result = nms_float(1.0, Some(3), boxes);
914        assert_eq!(result.len(), 3);
915        assert!(result[0].score >= result[1].score);
916        assert!(result[1].score >= result[2].score);
917    }
918
919    #[test]
920    fn nms_float_max_det_larger_than_input() {
921        let boxes = make_nms_boxes_float(5);
922        let full = nms_float(0.5, None, boxes.clone());
923        let capped = nms_float(0.5, Some(100), boxes);
924        assert_eq!(full.len(), capped.len());
925    }
926
927    // Parity literals from ModelPack metrics/tiled.py::_match for
928    // A=[100,100,400,300], B=[350,100,400,300] (B fully inside A):
929    // inter=10000, area_a=60000, area_b=10000 => IoS=1.0, IoU=0.16667.
930    #[test]
931    fn metric_values_match_modelpack_match() {
932        let a = BoundingBox::new(100.0, 100.0, 400.0, 300.0);
933        let b = BoundingBox::new(350.0, 100.0, 400.0, 300.0);
934        assert!((intersection_area(&a, &b) - 10000.0).abs() < 1e-3);
935        assert!((box_area(&a) - 60000.0).abs() < 1e-3);
936        assert!((box_area(&b) - 10000.0).abs() < 1e-3);
937        assert!((ios_value(&a, &b) - 1.0).abs() < 1e-6);
938        assert!((iou_value(&a, &b) - (1.0 / 6.0)).abs() < 1e-6);
939    }
940
941    #[test]
942    fn metric_values_disjoint_are_zero() {
943        let a = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
944        let b = BoundingBox::new(20.0, 20.0, 30.0, 30.0);
945        assert_eq!(intersection_area(&a, &b), 0.0);
946        assert_eq!(ios_value(&a, &b), 0.0);
947        assert_eq!(iou_value(&a, &b), 0.0);
948    }
949
950    #[test]
951    fn metric_values_zero_area_box_no_panic() {
952        // Degenerate (zero-area) box: denom floored to 1e-9, result finite.
953        let degenerate = BoundingBox::new(10.0, 10.0, 10.0, 20.0);
954        let other = BoundingBox::new(0.0, 0.0, 30.0, 30.0);
955        assert_eq!(box_area(&degenerate), 0.0);
956        assert!(ios_value(&degenerate, &other).is_finite());
957        assert!(iou_value(&degenerate, &other).is_finite());
958    }
959
960    #[test]
961    fn ios_high_where_iou_low_for_contained_box() {
962        // The SAHI seam case: a small box fully inside a large one.
963        let big = BoundingBox::new(0.0, 0.0, 100.0, 100.0);
964        let small = BoundingBox::new(10.0, 10.0, 20.0, 20.0);
965        assert!((ios_value(&big, &small) - 1.0).abs() < 1e-6); // fully contained
966        assert!(iou_value(&big, &small) < 0.05); // but IoU tiny
967    }
968
969    #[test]
970    fn jaccard_batch4_matches_scalar() {
971        let a = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
972        let boxes = [
973            BoundingBox::new(5.0, 5.0, 15.0, 15.0),   // overlap
974            BoundingBox::new(20.0, 20.0, 30.0, 30.0), // no overlap
975            BoundingBox::new(0.0, 0.0, 10.0, 10.0),   // identical
976            BoundingBox::new(8.0, 8.0, 18.0, 18.0),   // small overlap
977        ];
978        let iou_threshold = 0.1;
979        let batch = jaccard_batch4(&a, &boxes, iou_threshold);
980        for (i, b) in boxes.iter().enumerate() {
981            let scalar = jaccard(&a, b, iou_threshold);
982            assert_eq!(
983                batch[i], scalar,
984                "batch4 mismatch at {i}: batch={} scalar={}",
985                batch[i], scalar
986            );
987        }
988    }
989}