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/// Returns true if the IOU of the given bounding boxes is greater than the iou
395/// threshold
396///
397/// # Example
398/// ```
399/// # use edgefirst_decoder::{BoundingBox, float::jaccard};
400/// let a = BoundingBox::new(0.0, 0.0, 0.2, 0.2);
401/// let b = BoundingBox::new(0.1, 0.1, 0.3, 0.3);
402/// let iou_threshold = 0.1;
403/// let result = jaccard(&a, &b, iou_threshold);
404/// assert!(result);
405/// ```
406pub fn jaccard(a: &BoundingBox, b: &BoundingBox, iou: f32) -> bool {
407    let left = a.xmin.max(b.xmin);
408    let top = a.ymin.max(b.ymin);
409    let right = a.xmax.min(b.xmax);
410    let bottom = a.ymax.min(b.ymax);
411
412    let intersection = (right - left).max(0.0) * (bottom - top).max(0.0);
413    let area_a = (a.xmax - a.xmin) * (a.ymax - a.ymin);
414    let area_b = (b.xmax - b.xmin) * (b.ymax - b.ymin);
415
416    // need to make sure we are not dividing by zero
417    let union = area_a + area_b - intersection;
418
419    intersection > iou * union
420}
421
422/// Batch IoU check: test one reference box `a` against 4 candidate boxes.
423///
424/// Returns a 4-element array of booleans: `result[i]` is true if
425/// `jaccard(a, boxes[i], iou)` would return true.
426///
427/// On aarch64, uses NEON `vmaxq_f32`/`vminq_f32` for vectorized
428/// intersection computation. On other architectures falls back to
429/// 4 scalar `jaccard` calls.
430#[inline]
431pub fn jaccard_batch4(a: &BoundingBox, boxes: &[BoundingBox; 4], iou: f32) -> [bool; 4] {
432    #[cfg(target_arch = "aarch64")]
433    {
434        // SAFETY: NEON is mandatory on aarch64.
435        unsafe { jaccard_batch4_neon(a, boxes, iou) }
436    }
437    #[cfg(not(target_arch = "aarch64"))]
438    {
439        [
440            jaccard(a, &boxes[0], iou),
441            jaccard(a, &boxes[1], iou),
442            jaccard(a, &boxes[2], iou),
443            jaccard(a, &boxes[3], iou),
444        ]
445    }
446}
447
448/// NEON-vectorized batch IoU for 4 candidate boxes against one reference.
449///
450/// Loads xmin/ymin/xmax/ymax of the 4 candidates into separate NEON
451/// registers (AoS→SoA transpose), then computes intersection, union,
452/// and the `intersection > iou * union` test in 4-wide SIMD.
453#[cfg(target_arch = "aarch64")]
454#[target_feature(enable = "neon")]
455unsafe fn jaccard_batch4_neon(a: &BoundingBox, boxes: &[BoundingBox; 4], iou: f32) -> [bool; 4] {
456    use std::arch::aarch64::*;
457
458    let zero = vdupq_n_f32(0.0);
459    let iou_v = vdupq_n_f32(iou);
460
461    // Reference box broadcast.
462    let a_xmin = vdupq_n_f32(a.xmin);
463    let a_ymin = vdupq_n_f32(a.ymin);
464    let a_xmax = vdupq_n_f32(a.xmax);
465    let a_ymax = vdupq_n_f32(a.ymax);
466    let area_a = vmulq_f32(vsubq_f32(a_xmax, a_xmin), vsubq_f32(a_ymax, a_ymin));
467
468    // Load 4 boxes (each BoundingBox is [xmin, ymin, xmax, ymax]).
469    let b0 = vld1q_f32(&boxes[0].xmin as *const f32);
470    let b1 = vld1q_f32(&boxes[1].xmin as *const f32);
471    let b2 = vld1q_f32(&boxes[2].xmin as *const f32);
472    let b3 = vld1q_f32(&boxes[3].xmin as *const f32);
473
474    // AoS → SoA transpose (4×4).
475    let t01_lo = vtrn1q_f32(b0, b1); // xmin0,xmin1,xmax0,xmax1
476    let t01_hi = vtrn2q_f32(b0, b1); // ymin0,ymin1,ymax0,ymax1
477    let t23_lo = vtrn1q_f32(b2, b3);
478    let t23_hi = vtrn2q_f32(b2, b3);
479
480    let b_xmin = vreinterpretq_f32_f64(vtrn1q_f64(
481        vreinterpretq_f64_f32(t01_lo),
482        vreinterpretq_f64_f32(t23_lo),
483    ));
484    let b_ymin = vreinterpretq_f32_f64(vtrn1q_f64(
485        vreinterpretq_f64_f32(t01_hi),
486        vreinterpretq_f64_f32(t23_hi),
487    ));
488    let b_xmax = vreinterpretq_f32_f64(vtrn2q_f64(
489        vreinterpretq_f64_f32(t01_lo),
490        vreinterpretq_f64_f32(t23_lo),
491    ));
492    let b_ymax = vreinterpretq_f32_f64(vtrn2q_f64(
493        vreinterpretq_f64_f32(t01_hi),
494        vreinterpretq_f64_f32(t23_hi),
495    ));
496
497    // Intersection.
498    let left = vmaxq_f32(a_xmin, b_xmin);
499    let top = vmaxq_f32(a_ymin, b_ymin);
500    let right = vminq_f32(a_xmax, b_xmax);
501    let bottom = vminq_f32(a_ymax, b_ymax);
502    let w = vmaxq_f32(vsubq_f32(right, left), zero);
503    let h = vmaxq_f32(vsubq_f32(bottom, top), zero);
504    let intersection = vmulq_f32(w, h);
505
506    // Area B.
507    let area_b = vmulq_f32(vsubq_f32(b_xmax, b_xmin), vsubq_f32(b_ymax, b_ymin));
508
509    // Union = area_a + area_b - intersection.
510    let union = vsubq_f32(vaddq_f32(area_a, area_b), intersection);
511
512    // Test: intersection > iou * union (equivalent to IoU > threshold).
513    let iou_union = vmulq_f32(iou_v, union);
514    let mask = vcgtq_f32(intersection, iou_union);
515
516    // Extract per-lane results.
517    [
518        vgetq_lane_u32(mask, 0) != 0,
519        vgetq_lane_u32(mask, 1) != 0,
520        vgetq_lane_u32(mask, 2) != 0,
521        vgetq_lane_u32(mask, 3) != 0,
522    ]
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use crate::BoundingBox;
529
530    /// Helper: create `n` non-overlapping boxes with descending f32 scores.
531    fn make_nms_boxes_float(n: usize) -> Vec<DetectBox> {
532        (0..n)
533            .map(|i| DetectBox {
534                bbox: BoundingBox {
535                    xmin: i as f32 * 100.0,
536                    ymin: 0.0,
537                    xmax: i as f32 * 100.0 + 10.0,
538                    ymax: 10.0,
539                },
540                label: 0,
541                score: 1.0 - i as f32 * 0.01,
542            })
543            .collect()
544    }
545
546    #[test]
547    fn nms_float_max_det_matches_full_truncated() {
548        let boxes = make_nms_boxes_float(20);
549        let n = 5;
550        let full = nms_float(0.5, None, boxes.clone());
551        let capped = nms_float(0.5, Some(n), boxes);
552        assert_eq!(capped.len(), n);
553        for (f, c) in full[..n].iter().zip(capped.iter()) {
554            assert_eq!(f.bbox, c.bbox);
555            assert_eq!(f.score, c.score);
556        }
557    }
558
559    #[test]
560    fn nms_float_max_det_zero_returns_empty() {
561        let boxes = make_nms_boxes_float(10);
562        let result = nms_float(0.5, Some(0), boxes);
563        assert!(result.is_empty());
564    }
565
566    #[test]
567    fn nms_float_max_det_iou_ge_1_returns_sorted_truncated() {
568        let boxes = make_nms_boxes_float(10);
569        let result = nms_float(1.0, Some(3), boxes);
570        assert_eq!(result.len(), 3);
571        assert!(result[0].score >= result[1].score);
572        assert!(result[1].score >= result[2].score);
573    }
574
575    #[test]
576    fn nms_float_max_det_larger_than_input() {
577        let boxes = make_nms_boxes_float(5);
578        let full = nms_float(0.5, None, boxes.clone());
579        let capped = nms_float(0.5, Some(100), boxes);
580        assert_eq!(full.len(), capped.len());
581    }
582
583    #[test]
584    fn jaccard_batch4_matches_scalar() {
585        let a = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
586        let boxes = [
587            BoundingBox::new(5.0, 5.0, 15.0, 15.0),   // overlap
588            BoundingBox::new(20.0, 20.0, 30.0, 30.0), // no overlap
589            BoundingBox::new(0.0, 0.0, 10.0, 10.0),   // identical
590            BoundingBox::new(8.0, 8.0, 18.0, 18.0),   // small overlap
591        ];
592        let iou_threshold = 0.1;
593        let batch = jaccard_batch4(&a, &boxes, iou_threshold);
594        for (i, b) in boxes.iter().enumerate() {
595            let scalar = jaccard(&a, b, iou_threshold);
596            assert_eq!(
597                batch[i], scalar,
598                "batch4 mismatch at {i}: batch={} scalar={}",
599                batch[i], scalar
600            );
601        }
602    }
603}