Skip to main content

edgefirst_decoder/
float.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::{arg_max, BBoxTypeTrait, BoundingBox, DetectBox};
5use ndarray::{
6    parallel::prelude::{IntoParallelIterator, ParallelIterator as _},
7    Array1, ArrayView2, Zip,
8};
9use num_traits::{AsPrimitive, Float};
10use rayon::slice::ParallelSliceMut;
11
12/// Post processes boxes and scores tensors into detection boxes, filtering out
13/// any boxes below the score threshold. The boxes tensor is converted to XYXY
14/// using the given BBoxTypeTrait. The order of the boxes is preserved.
15pub fn postprocess_boxes_float<
16    B: BBoxTypeTrait,
17    BOX: Float + AsPrimitive<f32> + Send + Sync,
18    SCORE: Float + AsPrimitive<f32> + Send + Sync,
19>(
20    threshold: SCORE,
21    boxes: ArrayView2<BOX>,
22    scores: ArrayView2<SCORE>,
23) -> Vec<DetectBox> {
24    assert_eq!(scores.dim().0, boxes.dim().0);
25    assert_eq!(boxes.dim().1, 4);
26    Zip::from(scores.rows())
27        .and(boxes.rows())
28        .into_par_iter()
29        .filter_map(|(score, bbox)| {
30            let (score_, label) = arg_max(score);
31            if score_ < threshold {
32                return None;
33            }
34
35            let bbox = B::ndarray_to_xyxy_float(bbox);
36            Some(DetectBox {
37                label,
38                score: score_.as_(),
39                bbox: bbox.into(),
40            })
41        })
42        .collect()
43}
44
45/// Post processes boxes and scores tensors into detection boxes, filtering out
46/// any boxes below the score threshold. The boxes tensor is converted to XYXY
47/// using the given BBoxTypeTrait. The order of the boxes is preserved.
48///
49/// This function is very similar to `postprocess_boxes_float` but will also
50/// return the index of the box. The boxes will be in ascending index order.
51pub fn postprocess_boxes_index_float<
52    B: BBoxTypeTrait,
53    BOX: Float + AsPrimitive<f32> + Send + Sync,
54    SCORE: Float + AsPrimitive<f32> + Send + Sync,
55>(
56    threshold: SCORE,
57    boxes: ArrayView2<BOX>,
58    scores: ArrayView2<SCORE>,
59) -> Vec<(DetectBox, usize)> {
60    assert_eq!(scores.dim().0, boxes.dim().0);
61    assert_eq!(boxes.dim().1, 4);
62    let indices: Array1<usize> = (0..boxes.dim().0).collect();
63    Zip::from(scores.rows())
64        .and(boxes.rows())
65        .and(&indices)
66        .into_par_iter()
67        .filter_map(|(score, bbox, i)| {
68            let (score_, label) = arg_max(score);
69            if score_ < threshold {
70                return None;
71            }
72
73            let bbox = B::ndarray_to_xyxy_float(bbox);
74            Some((
75                DetectBox {
76                    label,
77                    score: score_.as_(),
78                    bbox: bbox.into(),
79                },
80                *i,
81            ))
82        })
83        .collect()
84}
85
86/// Multi-label variant of [`postprocess_boxes_index_float`].
87///
88/// For each anchor row, emits one `(DetectBox, anchor_idx)` per class whose
89/// score meets `threshold` — every class, not just the argmax.  The same
90/// `anchor_idx` is returned for all per-class entries of a given anchor so
91/// that downstream mask-coefficient lookup can reuse the shared coefficient
92/// row.
93///
94/// The bbox is computed once per anchor (via `B::ndarray_to_xyxy_float`) and
95/// reused across all emitted classes, avoiding redundant work.
96///
97/// Intended for **validation/mAP evaluation only** (the Ultralytics `val`
98/// convention).  Deployment must use the argmax variant
99/// [`postprocess_boxes_index_float`] so trackers see at most one box per
100/// anchor.
101pub fn postprocess_boxes_multilabel_index_float<
102    B: BBoxTypeTrait,
103    BOX: Float + AsPrimitive<f32> + Send + Sync,
104    SCORE: Float + AsPrimitive<f32> + Send + Sync,
105>(
106    threshold: SCORE,
107    boxes: ArrayView2<BOX>,
108    scores: ArrayView2<SCORE>,
109) -> Vec<(DetectBox, usize)> {
110    assert_eq!(scores.dim().0, boxes.dim().0);
111    assert_eq!(boxes.dim().1, 4);
112    let n = boxes.dim().0;
113    let indices: Array1<usize> = (0..n).collect();
114    Zip::from(scores.rows())
115        .and(boxes.rows())
116        .and(&indices)
117        .into_par_iter()
118        .flat_map(|(score_row, bbox_row, &anchor_idx)| {
119            // Compute bbox once; clone into each per-class candidate.
120            let bbox = B::ndarray_to_xyxy_float(bbox_row);
121            let bbox: crate::BoundingBox = bbox.into();
122            score_row
123                .iter()
124                .enumerate()
125                .filter(|(_, &s)| s >= threshold)
126                .map(move |(c, &s)| {
127                    (
128                        DetectBox {
129                            label: c,
130                            score: s.as_(),
131                            bbox,
132                        },
133                        anchor_idx,
134                    )
135                })
136                .collect::<Vec<_>>()
137        })
138        .collect()
139}
140
141/// Uses NMS to filter boxes based on the score and iou. Sorts boxes by score,
142/// then greedily selects a subset of boxes in descending order of score.
143///
144/// If `max_det` is `Some(n)`, the greedy loop stops as soon as `n` survivors
145/// have been confirmed. Because the input is sorted descending, the first `n`
146/// survivors are the highest-scoring `n`, so the post-NMS top-`n` is preserved
147/// without iterating the full O(N²) suppression loop.
148#[must_use]
149pub fn nms_float(iou: f32, max_det: Option<usize>, mut boxes: Vec<DetectBox>) -> Vec<DetectBox> {
150    // Boxes get sorted by score in descending order so we know based on the
151    // index the scoring of the boxes and can skip parts of the loop.
152    boxes.par_sort_by(|a, b| b.score.total_cmp(&a.score));
153
154    // When the iou is 1.0 or larger, no boxes will be filtered so we just return
155    // immediately
156    if iou >= 1.0 {
157        return match max_det {
158            Some(n) => {
159                boxes.truncate(n);
160                boxes
161            }
162            None => boxes,
163        };
164    }
165
166    let cap = max_det.unwrap_or(usize::MAX);
167    let mut survivors: usize = 0;
168
169    // Outer loop over all boxes.
170    for i in 0..boxes.len() {
171        if boxes[i].score < 0.0 {
172            // this box was merged with a different box earlier
173            continue;
174        }
175        for j in (i + 1)..boxes.len() {
176            // Inner loop over boxes with lower score (later in the list).
177
178            if boxes[j].score < 0.0 {
179                // this box was suppressed by different box earlier
180                continue;
181            }
182            if jaccard(&boxes[j].bbox, &boxes[i].bbox, iou) {
183                // max_box(boxes[j].bbox, &mut boxes[i].bbox);
184                boxes[j].score = -1.0;
185            }
186        }
187
188        // NOTE: jaccard_batch4_neon is available for callers that can
189        // batch unsuppressed candidates externally. It is not used
190        // inline here because score=-1 marking creates sparse gaps
191        // that prevent contiguous 4-box batching.
192        survivors += 1;
193        if survivors >= cap {
194            break;
195        }
196    }
197    // Filter out suppressed boxes; cap at `max_det` because boxes after the
198    // break may still hold positive scores but score lower than every survivor.
199    boxes
200        .into_iter()
201        .filter(|b| b.score >= 0.0)
202        .take(cap)
203        .collect()
204}
205
206/// Uses NMS to filter boxes based on the score and iou. Sorts boxes by score,
207/// then greedily selects a subset of boxes in descending order of score.
208///
209/// This is same as `nms_float` but will also include extra information along
210/// with each box, such as the index
211#[must_use]
212pub fn nms_extra_float<E: Send + Sync>(
213    iou: f32,
214    max_det: Option<usize>,
215    mut boxes: Vec<(DetectBox, E)>,
216) -> Vec<(DetectBox, E)> {
217    // Boxes get sorted by score in descending order so we know based on the
218    // index the scoring of the boxes and can skip parts of the loop.
219    boxes.par_sort_by(|a, b| b.0.score.total_cmp(&a.0.score));
220
221    // When the iou is 1.0 or larger, no boxes will be filtered so we just return
222    // immediately
223    if iou >= 1.0 {
224        return match max_det {
225            Some(n) => {
226                boxes.truncate(n);
227                boxes
228            }
229            None => boxes,
230        };
231    }
232
233    let cap = max_det.unwrap_or(usize::MAX);
234    let mut survivors: usize = 0;
235
236    // Outer loop over all boxes.
237    for i in 0..boxes.len() {
238        if boxes[i].0.score < 0.0 {
239            // this box was merged with a different box earlier
240            continue;
241        }
242        for j in (i + 1)..boxes.len() {
243            // Inner loop over boxes with lower score (later in the list).
244
245            if boxes[j].0.score < 0.0 {
246                // this box was suppressed by different box earlier
247                continue;
248            }
249            if jaccard(&boxes[j].0.bbox, &boxes[i].0.bbox, iou) {
250                // max_box(boxes[j].bbox, &mut boxes[i].bbox);
251                boxes[j].0.score = -1.0;
252            }
253        }
254        survivors += 1;
255        if survivors >= cap {
256            break;
257        }
258    }
259
260    // Filter out suppressed boxes; cap at `max_det` for the same reason as
261    // `nms_float`.
262    boxes
263        .into_iter()
264        .filter(|b| b.0.score >= 0.0)
265        .take(cap)
266        .collect()
267}
268
269/// Class-aware NMS: only suppress boxes with the same label.
270///
271/// Sorts boxes by score, then greedily selects a subset of boxes in descending
272/// order of score. Unlike class-agnostic NMS, boxes are only suppressed if they
273/// have the same class label AND overlap above the IoU threshold.
274///
275/// # Example
276/// ```
277/// # use edgefirst_decoder::{BoundingBox, DetectBox, float::nms_class_aware_float};
278/// let boxes = vec![
279///     DetectBox {
280///         bbox: BoundingBox::new(0.0, 0.0, 0.5, 0.5),
281///         score: 0.9,
282///         label: 0,
283///     },
284///     DetectBox {
285///         bbox: BoundingBox::new(0.1, 0.1, 0.6, 0.6),
286///         score: 0.8,
287///         label: 1,
288///     }, // different class
289/// ];
290/// // Both boxes survive because they have different labels
291/// let result = nms_class_aware_float(0.3, None, boxes);
292/// assert_eq!(result.len(), 2);
293/// ```
294#[must_use]
295pub fn nms_class_aware_float(
296    iou: f32,
297    max_det: Option<usize>,
298    mut boxes: Vec<DetectBox>,
299) -> Vec<DetectBox> {
300    boxes.par_sort_by(|a, b| b.score.total_cmp(&a.score));
301
302    if iou >= 1.0 {
303        return match max_det {
304            Some(n) => {
305                boxes.truncate(n);
306                boxes
307            }
308            None => boxes,
309        };
310    }
311
312    let cap = max_det.unwrap_or(usize::MAX);
313    let mut survivors: usize = 0;
314
315    for i in 0..boxes.len() {
316        if boxes[i].score < 0.0 {
317            continue;
318        }
319        for j in (i + 1)..boxes.len() {
320            if boxes[j].score < 0.0 {
321                continue;
322            }
323            // Only suppress if same class AND overlapping
324            if boxes[j].label == boxes[i].label && jaccard(&boxes[j].bbox, &boxes[i].bbox, iou) {
325                boxes[j].score = -1.0;
326            }
327        }
328        survivors += 1;
329        if survivors >= cap {
330            break;
331        }
332    }
333    boxes
334        .into_iter()
335        .filter(|b| b.score >= 0.0)
336        .take(cap)
337        .collect()
338}
339
340/// Class-aware NMS with extra data: only suppress boxes with the same label.
341///
342/// This is same as `nms_class_aware_float` but will also include extra
343/// information along with each box, such as the index.
344#[must_use]
345pub fn nms_extra_class_aware_float<E: Send + Sync>(
346    iou: f32,
347    max_det: Option<usize>,
348    mut boxes: Vec<(DetectBox, E)>,
349) -> Vec<(DetectBox, E)> {
350    boxes.par_sort_by(|a, b| b.0.score.total_cmp(&a.0.score));
351
352    // When the iou is 1.0 or larger, no boxes will be filtered so we just return
353    // immediately
354    if iou >= 1.0 {
355        return match max_det {
356            Some(n) => {
357                boxes.truncate(n);
358                boxes
359            }
360            None => boxes,
361        };
362    }
363
364    let cap = max_det.unwrap_or(usize::MAX);
365    let mut survivors: usize = 0;
366
367    for i in 0..boxes.len() {
368        if boxes[i].0.score < 0.0 {
369            continue;
370        }
371        for j in (i + 1)..boxes.len() {
372            if boxes[j].0.score < 0.0 {
373                continue;
374            }
375            // Only suppress if same class AND overlapping
376            if boxes[j].0.label == boxes[i].0.label
377                && jaccard(&boxes[j].0.bbox, &boxes[i].0.bbox, iou)
378            {
379                boxes[j].0.score = -1.0;
380            }
381        }
382        survivors += 1;
383        if survivors >= cap {
384            break;
385        }
386    }
387    boxes
388        .into_iter()
389        .filter(|b| b.0.score >= 0.0)
390        .take(cap)
391        .collect()
392}
393
394/// Area of the axis-aligned intersection of two boxes, clamped to `>= 0` per
395/// side. Mirrors the `inter` term in ModelPack `metrics/tiled.py::_match`.
396#[must_use]
397#[inline]
398pub fn intersection_area(a: &BoundingBox, b: &BoundingBox) -> f32 {
399    let left = a.xmin.max(b.xmin);
400    let top = a.ymin.max(b.ymin);
401    let right = a.xmax.min(b.xmax);
402    let bottom = a.ymax.min(b.ymax);
403    (right - left).max(0.0) * (bottom - top).max(0.0)
404}
405
406/// Area of a box, clamped to `>= 0` per side. Mirrors ModelPack `_areas`.
407#[must_use]
408#[inline]
409pub fn box_area(b: &BoundingBox) -> f32 {
410    (b.xmax - b.xmin).max(0.0) * (b.ymax - b.ymin).max(0.0)
411}
412
413/// Intersection-over-Union value in `[0, 1]`. Mirrors ModelPack
414/// `_match(metric='iou')`: `inter / max(area_a + area_b - inter, 1e-9)`.
415#[must_use]
416#[inline]
417pub fn iou_value(a: &BoundingBox, b: &BoundingBox) -> f32 {
418    let inter = intersection_area(a, b);
419    let union = (box_area(a) + box_area(b) - inter).max(1e-9);
420    inter / union
421}
422
423/// Intersection-over-Smaller value in `[0, 1]`. Mirrors ModelPack
424/// `_match(metric='ios')`: `inter / max(min(area_a, area_b), 1e-9)`. Used by the
425/// tiled-detection merge: a seam-split object has low IoU but high IoS.
426#[must_use]
427#[inline]
428pub fn ios_value(a: &BoundingBox, b: &BoundingBox) -> f32 {
429    let inter = intersection_area(a, b);
430    let denom = box_area(a).min(box_area(b)).max(1e-9);
431    inter / denom
432}
433
434/// Returns true if the IOU of the given bounding boxes is greater than the iou
435/// threshold
436///
437/// Kept as a standalone inline (NOT routed through [`iou_value`]) so this hot
438/// NMS primitive stays byte-identical to its NEON sibling
439/// [`jaccard_batch4`]; the value helpers above are additive and used by the
440/// tiled-detection merge.
441///
442/// # Example
443/// ```
444/// # use edgefirst_decoder::{BoundingBox, float::jaccard};
445/// let a = BoundingBox::new(0.0, 0.0, 0.2, 0.2);
446/// let b = BoundingBox::new(0.1, 0.1, 0.3, 0.3);
447/// let iou_threshold = 0.1;
448/// let result = jaccard(&a, &b, iou_threshold);
449/// assert!(result);
450/// ```
451pub fn jaccard(a: &BoundingBox, b: &BoundingBox, iou: f32) -> bool {
452    let left = a.xmin.max(b.xmin);
453    let top = a.ymin.max(b.ymin);
454    let right = a.xmax.min(b.xmax);
455    let bottom = a.ymax.min(b.ymax);
456
457    let intersection = (right - left).max(0.0) * (bottom - top).max(0.0);
458    let area_a = (a.xmax - a.xmin) * (a.ymax - a.ymin);
459    let area_b = (b.xmax - b.xmin) * (b.ymax - b.ymin);
460
461    // need to make sure we are not dividing by zero
462    let union = area_a + area_b - intersection;
463
464    intersection > iou * union
465}
466
467/// Batch IoU check: test one reference box `a` against 4 candidate boxes.
468///
469/// Returns a 4-element array of booleans: `result[i]` is true if
470/// `jaccard(a, boxes[i], iou)` would return true.
471///
472/// On aarch64, uses NEON `vmaxq_f32`/`vminq_f32` for vectorized
473/// intersection computation. On other architectures falls back to
474/// 4 scalar `jaccard` calls.
475#[inline]
476pub fn jaccard_batch4(a: &BoundingBox, boxes: &[BoundingBox; 4], iou: f32) -> [bool; 4] {
477    #[cfg(target_arch = "aarch64")]
478    {
479        // SAFETY: NEON is mandatory on aarch64.
480        unsafe { jaccard_batch4_neon(a, boxes, iou) }
481    }
482    #[cfg(not(target_arch = "aarch64"))]
483    {
484        [
485            jaccard(a, &boxes[0], iou),
486            jaccard(a, &boxes[1], iou),
487            jaccard(a, &boxes[2], iou),
488            jaccard(a, &boxes[3], iou),
489        ]
490    }
491}
492
493/// NEON-vectorized batch IoU for 4 candidate boxes against one reference.
494///
495/// Loads xmin/ymin/xmax/ymax of the 4 candidates into separate NEON
496/// registers (AoS→SoA transpose), then computes intersection, union,
497/// and the `intersection > iou * union` test in 4-wide SIMD.
498#[cfg(target_arch = "aarch64")]
499#[target_feature(enable = "neon")]
500unsafe fn jaccard_batch4_neon(a: &BoundingBox, boxes: &[BoundingBox; 4], iou: f32) -> [bool; 4] {
501    use std::arch::aarch64::*;
502
503    let zero = vdupq_n_f32(0.0);
504    let iou_v = vdupq_n_f32(iou);
505
506    // Reference box broadcast.
507    let a_xmin = vdupq_n_f32(a.xmin);
508    let a_ymin = vdupq_n_f32(a.ymin);
509    let a_xmax = vdupq_n_f32(a.xmax);
510    let a_ymax = vdupq_n_f32(a.ymax);
511    let area_a = vmulq_f32(vsubq_f32(a_xmax, a_xmin), vsubq_f32(a_ymax, a_ymin));
512
513    // Load 4 boxes (each BoundingBox is [xmin, ymin, xmax, ymax]).
514    let b0 = vld1q_f32(&boxes[0].xmin as *const f32);
515    let b1 = vld1q_f32(&boxes[1].xmin as *const f32);
516    let b2 = vld1q_f32(&boxes[2].xmin as *const f32);
517    let b3 = vld1q_f32(&boxes[3].xmin as *const f32);
518
519    // AoS → SoA transpose (4×4).
520    let t01_lo = vtrn1q_f32(b0, b1); // xmin0,xmin1,xmax0,xmax1
521    let t01_hi = vtrn2q_f32(b0, b1); // ymin0,ymin1,ymax0,ymax1
522    let t23_lo = vtrn1q_f32(b2, b3);
523    let t23_hi = vtrn2q_f32(b2, b3);
524
525    let b_xmin = vreinterpretq_f32_f64(vtrn1q_f64(
526        vreinterpretq_f64_f32(t01_lo),
527        vreinterpretq_f64_f32(t23_lo),
528    ));
529    let b_ymin = vreinterpretq_f32_f64(vtrn1q_f64(
530        vreinterpretq_f64_f32(t01_hi),
531        vreinterpretq_f64_f32(t23_hi),
532    ));
533    let b_xmax = vreinterpretq_f32_f64(vtrn2q_f64(
534        vreinterpretq_f64_f32(t01_lo),
535        vreinterpretq_f64_f32(t23_lo),
536    ));
537    let b_ymax = vreinterpretq_f32_f64(vtrn2q_f64(
538        vreinterpretq_f64_f32(t01_hi),
539        vreinterpretq_f64_f32(t23_hi),
540    ));
541
542    // Intersection.
543    let left = vmaxq_f32(a_xmin, b_xmin);
544    let top = vmaxq_f32(a_ymin, b_ymin);
545    let right = vminq_f32(a_xmax, b_xmax);
546    let bottom = vminq_f32(a_ymax, b_ymax);
547    let w = vmaxq_f32(vsubq_f32(right, left), zero);
548    let h = vmaxq_f32(vsubq_f32(bottom, top), zero);
549    let intersection = vmulq_f32(w, h);
550
551    // Area B.
552    let area_b = vmulq_f32(vsubq_f32(b_xmax, b_xmin), vsubq_f32(b_ymax, b_ymin));
553
554    // Union = area_a + area_b - intersection.
555    let union = vsubq_f32(vaddq_f32(area_a, area_b), intersection);
556
557    // Test: intersection > iou * union (equivalent to IoU > threshold).
558    let iou_union = vmulq_f32(iou_v, union);
559    let mask = vcgtq_f32(intersection, iou_union);
560
561    // Extract per-lane results.
562    [
563        vgetq_lane_u32(mask, 0) != 0,
564        vgetq_lane_u32(mask, 1) != 0,
565        vgetq_lane_u32(mask, 2) != 0,
566        vgetq_lane_u32(mask, 3) != 0,
567    ]
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use crate::BoundingBox;
574
575    /// Helper: create `n` non-overlapping boxes with descending f32 scores.
576    fn make_nms_boxes_float(n: usize) -> Vec<DetectBox> {
577        (0..n)
578            .map(|i| DetectBox {
579                bbox: BoundingBox {
580                    xmin: i as f32 * 100.0,
581                    ymin: 0.0,
582                    xmax: i as f32 * 100.0 + 10.0,
583                    ymax: 10.0,
584                },
585                label: 0,
586                score: 1.0 - i as f32 * 0.01,
587            })
588            .collect()
589    }
590
591    #[test]
592    fn nms_float_max_det_matches_full_truncated() {
593        let boxes = make_nms_boxes_float(20);
594        let n = 5;
595        let full = nms_float(0.5, None, boxes.clone());
596        let capped = nms_float(0.5, Some(n), boxes);
597        assert_eq!(capped.len(), n);
598        for (f, c) in full[..n].iter().zip(capped.iter()) {
599            assert_eq!(f.bbox, c.bbox);
600            assert_eq!(f.score, c.score);
601        }
602    }
603
604    #[test]
605    fn nms_float_max_det_zero_returns_empty() {
606        let boxes = make_nms_boxes_float(10);
607        let result = nms_float(0.5, Some(0), boxes);
608        assert!(result.is_empty());
609    }
610
611    #[test]
612    fn nms_float_max_det_iou_ge_1_returns_sorted_truncated() {
613        let boxes = make_nms_boxes_float(10);
614        let result = nms_float(1.0, Some(3), boxes);
615        assert_eq!(result.len(), 3);
616        assert!(result[0].score >= result[1].score);
617        assert!(result[1].score >= result[2].score);
618    }
619
620    #[test]
621    fn nms_float_max_det_larger_than_input() {
622        let boxes = make_nms_boxes_float(5);
623        let full = nms_float(0.5, None, boxes.clone());
624        let capped = nms_float(0.5, Some(100), boxes);
625        assert_eq!(full.len(), capped.len());
626    }
627
628    // Parity literals from ModelPack metrics/tiled.py::_match for
629    // A=[100,100,400,300], B=[350,100,400,300] (B fully inside A):
630    // inter=10000, area_a=60000, area_b=10000 => IoS=1.0, IoU=0.16667.
631    #[test]
632    fn metric_values_match_modelpack_match() {
633        let a = BoundingBox::new(100.0, 100.0, 400.0, 300.0);
634        let b = BoundingBox::new(350.0, 100.0, 400.0, 300.0);
635        assert!((intersection_area(&a, &b) - 10000.0).abs() < 1e-3);
636        assert!((box_area(&a) - 60000.0).abs() < 1e-3);
637        assert!((box_area(&b) - 10000.0).abs() < 1e-3);
638        assert!((ios_value(&a, &b) - 1.0).abs() < 1e-6);
639        assert!((iou_value(&a, &b) - (1.0 / 6.0)).abs() < 1e-6);
640    }
641
642    #[test]
643    fn metric_values_disjoint_are_zero() {
644        let a = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
645        let b = BoundingBox::new(20.0, 20.0, 30.0, 30.0);
646        assert_eq!(intersection_area(&a, &b), 0.0);
647        assert_eq!(ios_value(&a, &b), 0.0);
648        assert_eq!(iou_value(&a, &b), 0.0);
649    }
650
651    #[test]
652    fn metric_values_zero_area_box_no_panic() {
653        // Degenerate (zero-area) box: denom floored to 1e-9, result finite.
654        let degenerate = BoundingBox::new(10.0, 10.0, 10.0, 20.0);
655        let other = BoundingBox::new(0.0, 0.0, 30.0, 30.0);
656        assert_eq!(box_area(&degenerate), 0.0);
657        assert!(ios_value(&degenerate, &other).is_finite());
658        assert!(iou_value(&degenerate, &other).is_finite());
659    }
660
661    #[test]
662    fn ios_high_where_iou_low_for_contained_box() {
663        // The SAHI seam case: a small box fully inside a large one.
664        let big = BoundingBox::new(0.0, 0.0, 100.0, 100.0);
665        let small = BoundingBox::new(10.0, 10.0, 20.0, 20.0);
666        assert!((ios_value(&big, &small) - 1.0).abs() < 1e-6); // fully contained
667        assert!(iou_value(&big, &small) < 0.05); // but IoU tiny
668    }
669
670    #[test]
671    fn jaccard_batch4_matches_scalar() {
672        let a = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
673        let boxes = [
674            BoundingBox::new(5.0, 5.0, 15.0, 15.0),   // overlap
675            BoundingBox::new(20.0, 20.0, 30.0, 30.0), // no overlap
676            BoundingBox::new(0.0, 0.0, 10.0, 10.0),   // identical
677            BoundingBox::new(8.0, 8.0, 18.0, 18.0),   // small overlap
678        ];
679        let iou_threshold = 0.1;
680        let batch = jaccard_batch4(&a, &boxes, iou_threshold);
681        for (i, b) in boxes.iter().enumerate() {
682            let scalar = jaccard(&a, b, iou_threshold);
683            assert_eq!(
684                batch[i], scalar,
685                "batch4 mismatch at {i}: batch={} scalar={}",
686                batch[i], scalar
687            );
688        }
689    }
690}