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