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    #[test]
610    fn nms_float_max_det_matches_full_truncated() {
611        let boxes = make_nms_boxes_float(20);
612        let n = 5;
613        let full = nms_float(0.5, None, boxes.clone());
614        let capped = nms_float(0.5, Some(n), boxes);
615        assert_eq!(capped.len(), n);
616        for (f, c) in full[..n].iter().zip(capped.iter()) {
617            assert_eq!(f.bbox, c.bbox);
618            assert_eq!(f.score, c.score);
619        }
620    }
621
622    #[test]
623    fn nms_float_max_det_zero_returns_empty() {
624        let boxes = make_nms_boxes_float(10);
625        let result = nms_float(0.5, Some(0), boxes);
626        assert!(result.is_empty());
627    }
628
629    #[test]
630    fn nms_float_max_det_iou_ge_1_returns_sorted_truncated() {
631        let boxes = make_nms_boxes_float(10);
632        let result = nms_float(1.0, Some(3), boxes);
633        assert_eq!(result.len(), 3);
634        assert!(result[0].score >= result[1].score);
635        assert!(result[1].score >= result[2].score);
636    }
637
638    #[test]
639    fn nms_float_max_det_larger_than_input() {
640        let boxes = make_nms_boxes_float(5);
641        let full = nms_float(0.5, None, boxes.clone());
642        let capped = nms_float(0.5, Some(100), boxes);
643        assert_eq!(full.len(), capped.len());
644    }
645
646    // Parity literals from ModelPack metrics/tiled.py::_match for
647    // A=[100,100,400,300], B=[350,100,400,300] (B fully inside A):
648    // inter=10000, area_a=60000, area_b=10000 => IoS=1.0, IoU=0.16667.
649    #[test]
650    fn metric_values_match_modelpack_match() {
651        let a = BoundingBox::new(100.0, 100.0, 400.0, 300.0);
652        let b = BoundingBox::new(350.0, 100.0, 400.0, 300.0);
653        assert!((intersection_area(&a, &b) - 10000.0).abs() < 1e-3);
654        assert!((box_area(&a) - 60000.0).abs() < 1e-3);
655        assert!((box_area(&b) - 10000.0).abs() < 1e-3);
656        assert!((ios_value(&a, &b) - 1.0).abs() < 1e-6);
657        assert!((iou_value(&a, &b) - (1.0 / 6.0)).abs() < 1e-6);
658    }
659
660    #[test]
661    fn metric_values_disjoint_are_zero() {
662        let a = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
663        let b = BoundingBox::new(20.0, 20.0, 30.0, 30.0);
664        assert_eq!(intersection_area(&a, &b), 0.0);
665        assert_eq!(ios_value(&a, &b), 0.0);
666        assert_eq!(iou_value(&a, &b), 0.0);
667    }
668
669    #[test]
670    fn metric_values_zero_area_box_no_panic() {
671        // Degenerate (zero-area) box: denom floored to 1e-9, result finite.
672        let degenerate = BoundingBox::new(10.0, 10.0, 10.0, 20.0);
673        let other = BoundingBox::new(0.0, 0.0, 30.0, 30.0);
674        assert_eq!(box_area(&degenerate), 0.0);
675        assert!(ios_value(&degenerate, &other).is_finite());
676        assert!(iou_value(&degenerate, &other).is_finite());
677    }
678
679    #[test]
680    fn ios_high_where_iou_low_for_contained_box() {
681        // The SAHI seam case: a small box fully inside a large one.
682        let big = BoundingBox::new(0.0, 0.0, 100.0, 100.0);
683        let small = BoundingBox::new(10.0, 10.0, 20.0, 20.0);
684        assert!((ios_value(&big, &small) - 1.0).abs() < 1e-6); // fully contained
685        assert!(iou_value(&big, &small) < 0.05); // but IoU tiny
686    }
687
688    #[test]
689    fn jaccard_batch4_matches_scalar() {
690        let a = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
691        let boxes = [
692            BoundingBox::new(5.0, 5.0, 15.0, 15.0),   // overlap
693            BoundingBox::new(20.0, 20.0, 30.0, 30.0), // no overlap
694            BoundingBox::new(0.0, 0.0, 10.0, 10.0),   // identical
695            BoundingBox::new(8.0, 8.0, 18.0, 18.0),   // small overlap
696        ];
697        let iou_threshold = 0.1;
698        let batch = jaccard_batch4(&a, &boxes, iou_threshold);
699        for (i, b) in boxes.iter().enumerate() {
700            let scalar = jaccard(&a, b, iou_threshold);
701            assert_eq!(
702                batch[i], scalar,
703                "batch4 mismatch at {i}: batch={} scalar={}",
704                batch[i], scalar
705            );
706        }
707    }
708}