Skip to main content

edgefirst_decoder/
byte.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! Quantized-domain NMS, the int8 / uint8 mirror of [`crate::float`].
5//!
6//! These kernels suppress and post-process candidates while the scores are
7//! still in their raw integer units, which is the point: dequantizing every
8//! anchor costs more than dequantizing the handful that survive. The threshold
9//! crosses the other way instead — [`quantize_score_threshold`] maps a float
10//! score threshold into the tensor's quantized domain so the comparison stays
11//! an integer one.
12//!
13//! [`postprocess_boxes_quant`] and [`postprocess_boxes_index_quant`] handle
14//! candidate selection; [`nms_int`], [`nms_class_aware_int`], and their
15//! `_extra` payload-carrying variants handle suppression. Boxes are
16//! dequantized only after suppression.
17
18#[cfg(target_arch = "aarch64")]
19use crate::arg_max_i8;
20use crate::{
21    arg_max, float::jaccard, BBoxTypeTrait, BoundingBox, DetectBoxQuantized, Quantization,
22};
23use ndarray::{
24    parallel::prelude::{IntoParallelIterator, ParallelIterator as _},
25    Array1, ArrayView1, ArrayView2, Zip,
26};
27use num_traits::{AsPrimitive, PrimInt};
28use rayon::slice::ParallelSliceMut;
29
30/// NEON-accelerated column max update for the column-major argmax path.
31///
32/// Processes 16 elements per iteration using SIMD max + bitwise select.
33/// Handles both unsigned (u8) and signed (i8) comparison semantics.
34///
35/// # Safety
36///
37/// - `col_ptr` must point to at least `n` valid bytes.
38/// - `max_ptr` must point to at least `n` valid mutable bytes.
39/// - `class_ptr` must point to at least `n` valid mutable bytes.
40#[cfg(target_arch = "aarch64")]
41unsafe fn column_max_update_neon(
42    col_ptr: *const u8,
43    max_ptr: *mut u8,
44    class_ptr: *mut u8,
45    n: usize,
46    class_idx: u8,
47    signed: bool,
48) {
49    unsafe {
50        use std::arch::aarch64::*; // NOSONAR
51
52        let class_vec = vdupq_n_u8(class_idx);
53        let chunks = n / 16;
54        let remainder = n % 16;
55
56        if signed {
57            // Signed i8 comparison: interpret bytes as i8.
58            for chunk in 0..chunks {
59                let offset = chunk * 16;
60                let col = vld1q_s8(col_ptr.add(offset) as *const i8);
61                let cur_max = vld1q_s8(max_ptr.add(offset) as *const i8);
62                // mask[i] = 0xFF where col[i] >= cur_max[i], else 0x00
63                let mask = vcgeq_s8(col, cur_max);
64                // new_max = max(col, cur_max)
65                let new_max = vmaxq_s8(col, cur_max);
66                vst1q_s8(max_ptr.add(offset) as *mut i8, new_max);
67                // Select class_idx where mask is set, keep old class otherwise.
68                let cur_class = vld1q_u8(class_ptr.add(offset));
69                let new_class = vbslq_u8(mask, class_vec, cur_class);
70                vst1q_u8(class_ptr.add(offset), new_class);
71            }
72            // Scalar tail.
73            for i in (chunks * 16)..n {
74                let val = *(col_ptr.add(i) as *const i8);
75                let cur = *(max_ptr.add(i) as *const i8);
76                if val >= cur {
77                    *(max_ptr.add(i) as *mut i8) = val;
78                    *class_ptr.add(i) = class_idx;
79                }
80            }
81        } else {
82            // Unsigned u8 comparison.
83            for chunk in 0..chunks {
84                let offset = chunk * 16;
85                let col = vld1q_u8(col_ptr.add(offset));
86                let cur_max = vld1q_u8(max_ptr.add(offset));
87                let mask = vcgeq_u8(col, cur_max);
88                let new_max = vmaxq_u8(col, cur_max);
89                vst1q_u8(max_ptr.add(offset), new_max);
90                let cur_class = vld1q_u8(class_ptr.add(offset));
91                let new_class = vbslq_u8(mask, class_vec, cur_class);
92                vst1q_u8(class_ptr.add(offset), new_class);
93            }
94            // Scalar tail.
95            for i in (chunks * 16)..n {
96                let val = *col_ptr.add(i);
97                let cur = *max_ptr.add(i);
98                if val >= cur {
99                    *max_ptr.add(i) = val;
100                    *class_ptr.add(i) = class_idx;
101                }
102            }
103        }
104        let _ = remainder; // suppress unused warning
105    }
106}
107
108/// NEON-accelerated column max update with software prefetch for DMA-BUF.
109///
110/// Adds PRFM (prefetch for load, L1 data cache, streaming) hints 2 cache
111/// lines ahead of the current read position to hide CMA memory latency.
112/// On Cortex-A55 (64B cache lines) this prefetches 128 bytes ahead; on
113/// Cortex-A76 (64B L1, 128B L2 lines) the same offset covers one L2 line.
114///
115/// # Safety
116///
117/// - `col_ptr` must point to at least `n` valid bytes.
118/// - `max_ptr` must point to at least `n` valid mutable bytes.
119/// - `class_ptr` must point to at least `n` valid mutable bytes.
120#[cfg(target_arch = "aarch64")]
121#[allow(dead_code)] // Reserved for DMA-BUF CMA paths where latency hiding helps.
122unsafe fn column_max_update_neon_prefetch(
123    col_ptr: *const u8,
124    max_ptr: *mut u8,
125    class_ptr: *mut u8,
126    n: usize,
127    class_idx: u8,
128    signed: bool,
129) {
130    unsafe {
131        use std::arch::aarch64::*; // NOSONAR
132
133        const PREFETCH_AHEAD: usize = 128; // 2 cache lines on A55 (64B each)
134
135        let class_vec = vdupq_n_u8(class_idx);
136        let chunks = n / 16;
137
138        if signed {
139            for chunk in 0..chunks {
140                let offset = chunk * 16;
141                // Software prefetch: hint the next read 2 cache lines ahead.
142                if offset + PREFETCH_AHEAD < n {
143                    core::arch::asm!(
144                        "prfm pldl1strm, [{ptr}]",
145                        ptr = in(reg) col_ptr.add(offset + PREFETCH_AHEAD),
146                        options(nostack, preserves_flags),
147                    );
148                }
149                let col = vld1q_s8(col_ptr.add(offset) as *const i8);
150                let cur_max = vld1q_s8(max_ptr.add(offset) as *const i8);
151                let mask = vcgeq_s8(col, cur_max);
152                let new_max = vmaxq_s8(col, cur_max);
153                vst1q_s8(max_ptr.add(offset) as *mut i8, new_max);
154                let cur_class = vld1q_u8(class_ptr.add(offset));
155                let new_class = vbslq_u8(mask, class_vec, cur_class);
156                vst1q_u8(class_ptr.add(offset), new_class);
157            }
158            for i in (chunks * 16)..n {
159                let val = *(col_ptr.add(i) as *const i8);
160                let cur = *(max_ptr.add(i) as *const i8);
161                if val >= cur {
162                    *(max_ptr.add(i) as *mut i8) = val;
163                    *class_ptr.add(i) = class_idx;
164                }
165            }
166        } else {
167            for chunk in 0..chunks {
168                let offset = chunk * 16;
169                if offset + PREFETCH_AHEAD < n {
170                    core::arch::asm!(
171                        "prfm pldl1strm, [{ptr}]",
172                        ptr = in(reg) col_ptr.add(offset + PREFETCH_AHEAD),
173                        options(nostack, preserves_flags),
174                    );
175                }
176                let col = vld1q_u8(col_ptr.add(offset));
177                let cur_max = vld1q_u8(max_ptr.add(offset));
178                let mask = vcgeq_u8(col, cur_max);
179                let new_max = vmaxq_u8(col, cur_max);
180                vst1q_u8(max_ptr.add(offset), new_max);
181                let cur_class = vld1q_u8(class_ptr.add(offset));
182                let new_class = vbslq_u8(mask, class_vec, cur_class);
183                vst1q_u8(class_ptr.add(offset), new_class);
184            }
185            for i in (chunks * 16)..n {
186                let val = *col_ptr.add(i);
187                let cur = *max_ptr.add(i);
188                if val >= cur {
189                    *max_ptr.add(i) = val;
190                    *class_ptr.add(i) = class_idx;
191                }
192            }
193        }
194    }
195}
196
197/// Fast argmax dispatching to NEON-optimized path for i8 on aarch64.
198#[inline(always)]
199fn fast_arg_max<T: PrimInt + Copy>(score: ArrayView1<T>) -> (T, usize) {
200    #[cfg(target_arch = "aarch64")]
201    {
202        // Check if this is an i8 slice and contiguous.
203        if std::mem::size_of::<T>() == 1 && score.as_slice().is_some() {
204            let slice = score.as_slice().unwrap();
205            // Safety: T is i8 when size_of::<T>() == 1 and PrimInt.
206            // PrimInt covers i8, u8, i16, etc. We only want to use the
207            // i8 NEON path for signed i8.
208            let ptr = slice.as_ptr() as *const i8;
209            let i8_slice = unsafe { std::slice::from_raw_parts(ptr, slice.len()) };
210            // Only valid for signed i8 (not u8). Check sign bit behavior:
211            // PrimInt for i8 means min_value() is negative.
212            if T::min_value() < T::zero() {
213                let (max_val, idx) = arg_max_i8(i8_slice);
214                // Safety: transmute i8 back to T (they have the same size and
215                // representation for i8).
216                let result: T = unsafe { std::mem::transmute_copy(&max_val) };
217                return (result, idx);
218            }
219        }
220    }
221    arg_max(score)
222}
223
224/// Post processes boxes and scores tensors into quantized detection boxes,
225/// filtering out any boxes below the score threshold. The boxes tensor
226/// is converted to XYXY using the given BBoxTypeTrait. The order of the boxes
227/// is preserved.
228#[doc(hidden)]
229pub fn postprocess_boxes_quant<
230    B: BBoxTypeTrait,
231    Boxes: PrimInt + AsPrimitive<f32> + Send + Sync,
232    Scores: PrimInt + AsPrimitive<f32> + Send + Sync,
233>(
234    threshold: Scores,
235    boxes: ArrayView2<Boxes>,
236    scores: ArrayView2<Scores>,
237    quant_boxes: Quantization,
238) -> Vec<DetectBoxQuantized<Scores>> {
239    assert_eq!(scores.dim().0, boxes.dim().0);
240    assert_eq!(boxes.dim().1, 4);
241
242    // Use column-major path for transposed DMA-BUF views (see postprocess_boxes_index_quant).
243    if scores.strides()[0] == 1 && scores.as_slice().is_none() {
244        return postprocess_boxes_quant_column_major::<B, _, _>(
245            threshold,
246            boxes,
247            scores,
248            quant_boxes,
249        );
250    }
251
252    Zip::from(scores.rows())
253        .and(boxes.rows())
254        .into_par_iter()
255        .filter_map(|(score, bbox)| {
256            let (score_, label) = fast_arg_max(score);
257            if score_ < threshold {
258                return None;
259            }
260
261            let bbox_quant = B::ndarray_to_xyxy_dequant(bbox.view(), quant_boxes);
262            Some(DetectBoxQuantized {
263                label,
264                score: score_,
265                bbox: BoundingBox::from(bbox_quant),
266            })
267        })
268        .collect()
269}
270
271/// Column-major optimized path for `postprocess_boxes_quant`.
272fn postprocess_boxes_quant_column_major<
273    B: BBoxTypeTrait,
274    Boxes: PrimInt + AsPrimitive<f32> + Send + Sync,
275    Scores: PrimInt + AsPrimitive<f32> + Send + Sync,
276>(
277    threshold: Scores,
278    boxes: ArrayView2<Boxes>,
279    scores: ArrayView2<Scores>,
280    quant_boxes: Quantization,
281) -> Vec<DetectBoxQuantized<Scores>> {
282    let (n_candidates, n_classes) = scores.dim();
283
284    // Column-major NEON path uses u8 class indices; fall back for >255 classes.
285    if n_classes > 255 {
286        return Zip::from(scores.rows())
287            .and(boxes.rows())
288            .into_par_iter()
289            .filter_map(|(score, bbox)| {
290                let (score_, label) = fast_arg_max(score);
291                if score_ < threshold {
292                    return None;
293                }
294                let bbox_quant = B::ndarray_to_xyxy_dequant(bbox.view(), quant_boxes);
295                Some(DetectBoxQuantized {
296                    label,
297                    score: score_,
298                    bbox: BoundingBox::from(bbox_quant),
299                })
300            })
301            .collect();
302    }
303    let mut max_scores = vec![Scores::min_value(); n_candidates];
304    let mut max_classes = vec![0u8; n_candidates];
305
306    for class_idx in 0..n_classes {
307        let col = scores.column(class_idx);
308        if let Some(slice) = col.as_slice() {
309            #[cfg(target_arch = "aarch64")]
310            {
311                if std::mem::size_of::<Scores>() == 1 {
312                    unsafe {
313                        // Non-prefetch variant for heap-backed tensors;
314                        // prefetch variant reserved for DMA-BUF CMA paths.
315                        column_max_update_neon(
316                            slice.as_ptr() as *const u8,
317                            max_scores.as_mut_ptr() as *mut u8,
318                            max_classes.as_mut_ptr(),
319                            n_candidates,
320                            class_idx as u8,
321                            Scores::min_value() < Scores::zero(),
322                        );
323                    }
324                    continue;
325                }
326            }
327            for (i, &val) in slice.iter().enumerate() {
328                if val >= max_scores[i] {
329                    max_scores[i] = val;
330                    max_classes[i] = class_idx as u8;
331                }
332            }
333        } else {
334            for (i, &val) in col.iter().enumerate() {
335                if val >= max_scores[i] {
336                    max_scores[i] = val;
337                    max_classes[i] = class_idx as u8;
338                }
339            }
340        }
341    }
342
343    // Copy boxes column-by-column if also transposed.
344    let boxes_buf: [Vec<Boxes>; 4] = if boxes.strides()[0] == 1 && boxes.as_slice().is_none() {
345        let mut cols: [Vec<Boxes>; 4] = [
346            vec![Boxes::zero(); n_candidates],
347            vec![Boxes::zero(); n_candidates],
348            vec![Boxes::zero(); n_candidates],
349            vec![Boxes::zero(); n_candidates],
350        ];
351        for (dim, col_buf) in cols.iter_mut().enumerate() {
352            let col = boxes.column(dim);
353            if let Some(slice) = col.as_slice() {
354                col_buf.copy_from_slice(slice);
355            } else {
356                for (i, &val) in col.iter().enumerate() {
357                    col_buf[i] = val;
358                }
359            }
360        }
361        cols
362    } else {
363        [vec![], vec![], vec![], vec![]]
364    };
365    let boxes_copied = !boxes_buf[0].is_empty();
366
367    let mut result = Vec::new();
368    for i in 0..n_candidates {
369        if max_scores[i] >= threshold {
370            let bbox_quant = if boxes_copied {
371                let raw = [
372                    boxes_buf[0][i],
373                    boxes_buf[1][i],
374                    boxes_buf[2][i],
375                    boxes_buf[3][i],
376                ];
377                B::to_xyxy_dequant(&raw, quant_boxes)
378            } else {
379                B::ndarray_to_xyxy_dequant(boxes.row(i), quant_boxes)
380            };
381            result.push(DetectBoxQuantized {
382                label: max_classes[i] as usize,
383                score: max_scores[i],
384                bbox: BoundingBox::from(bbox_quant),
385            });
386        }
387    }
388
389    result
390}
391
392/// Post processes boxes and scores tensors into quantized detection boxes,
393/// filtering out any boxes below the score threshold. The boxes tensor
394/// is converted to XYXY using the given BBoxTypeTrait. The order of the boxes
395/// is preserved.
396///
397/// This function is very similar to `postprocess_boxes_quant` but will also
398/// return the index of the box. The boxes will be in ascending index order.
399///
400/// When scores originate from a transposed DMA-BUF view (stride-1 along axis 0),
401/// an optimized column-major scan is used to avoid catastrophic strided reads on
402/// uncacheable memory.
403#[doc(hidden)]
404pub fn postprocess_boxes_index_quant<
405    B: BBoxTypeTrait,
406    Boxes: PrimInt + AsPrimitive<f32> + Send + Sync,
407    Scores: PrimInt + AsPrimitive<f32> + Send + Sync,
408>(
409    threshold: Scores,
410    boxes: ArrayView2<Boxes>,
411    scores: ArrayView2<Scores>,
412    quant_boxes: Quantization,
413) -> Vec<(DetectBoxQuantized<Scores>, usize)> {
414    assert_eq!(scores.dim().0, boxes.dim().0);
415    assert_eq!(boxes.dim().1, 4);
416
417    // Detect transposed C-contiguous layout (e.g., from reversed_axes() on DMA-BUF).
418    // In this layout columns are contiguous (stride-1 along axis 0) but rows are not.
419    // The column-major path reads memory sequentially, avoiding cache-hostile strides.
420    if scores.strides()[0] == 1 && scores.as_slice().is_none() {
421        return postprocess_boxes_index_quant_column_major::<B, _, _>(
422            threshold,
423            boxes,
424            scores,
425            quant_boxes,
426        );
427    }
428
429    let indices: Array1<usize> = (0..boxes.dim().0).collect();
430    Zip::from(scores.rows())
431        .and(boxes.rows())
432        .and(&indices)
433        .into_par_iter()
434        .filter_map(|(score, bbox, index)| {
435            let (score_, label) = fast_arg_max(score);
436            if score_ < threshold {
437                return None;
438            }
439
440            let bbox_quant = B::ndarray_to_xyxy_dequant(bbox.view(), quant_boxes);
441
442            Some((
443                DetectBoxQuantized {
444                    label,
445                    score: score_,
446                    bbox: BoundingBox::from(bbox_quant),
447                },
448                *index,
449            ))
450        })
451        .collect()
452}
453
454/// Column-major optimized path for `postprocess_boxes_index_quant`.
455///
456/// When scores come from a transposed DMA-BUF view ([N_candidates, N_classes]
457/// with strides [1, N_candidates]), row iteration causes N_classes reads each
458/// N_candidates bytes apart — catastrophic on uncacheable memory. Instead, this
459/// iterates over classes (columns, which are contiguous), maintaining a running
460/// argmax per candidate in cacheable heap buffers.
461fn postprocess_boxes_index_quant_column_major<
462    B: BBoxTypeTrait,
463    Boxes: PrimInt + AsPrimitive<f32> + Send + Sync,
464    Scores: PrimInt + AsPrimitive<f32> + Send + Sync,
465>(
466    threshold: Scores,
467    boxes: ArrayView2<Boxes>,
468    scores: ArrayView2<Scores>,
469    quant_boxes: Quantization,
470) -> Vec<(DetectBoxQuantized<Scores>, usize)> {
471    let (n_candidates, n_classes) = scores.dim();
472
473    // Step 1: Column-based argmax — sequential reads over contiguous columns.
474    // Use u8 for class indices (max 255 classes) for optimal NEON vectorization.
475    // Fall back to row-major path for models with >255 classes.
476    if n_classes > 255 {
477        let indices: Array1<usize> = (0..n_candidates).collect();
478        return Zip::from(scores.rows())
479            .and(boxes.rows())
480            .and(&indices)
481            .into_par_iter()
482            .filter_map(|(score, bbox, index)| {
483                let (score_, label) = fast_arg_max(score);
484                if score_ < threshold {
485                    return None;
486                }
487                let bbox_quant = B::ndarray_to_xyxy_dequant(bbox.view(), quant_boxes);
488                Some((
489                    DetectBoxQuantized {
490                        label,
491                        score: score_,
492                        bbox: BoundingBox::from(bbox_quant),
493                    },
494                    *index,
495                ))
496            })
497            .collect();
498    }
499    let mut max_scores = vec![Scores::min_value(); n_candidates];
500    let mut max_classes = vec![0u8; n_candidates];
501    for class_idx in 0..n_classes {
502        let col = scores.column(class_idx);
503        if let Some(slice) = col.as_slice() {
504            // Use NEON-accelerated column max update on aarch64 for u8 scores.
505            #[cfg(target_arch = "aarch64")]
506            {
507                if std::mem::size_of::<Scores>() == 1 {
508                    // SAFETY: Scores is u8 or i8 (size == 1). We transmute the
509                    // slice pointers to the concrete byte type for NEON processing.
510                    unsafe {
511                        column_max_update_neon(
512                            slice.as_ptr() as *const u8,
513                            max_scores.as_mut_ptr() as *mut u8,
514                            max_classes.as_mut_ptr(),
515                            n_candidates,
516                            class_idx as u8,
517                            Scores::min_value() < Scores::zero(), // signed flag
518                        );
519                    }
520                    continue;
521                }
522            }
523            for (i, &val) in slice.iter().enumerate() {
524                if val >= max_scores[i] {
525                    max_scores[i] = val;
526                    max_classes[i] = class_idx as u8;
527                }
528            }
529        } else {
530            for (i, &val) in col.iter().enumerate() {
531                if val >= max_scores[i] {
532                    max_scores[i] = val;
533                    max_classes[i] = class_idx as u8;
534                }
535            }
536        }
537    }
538
539    // Step 2: Copy boxes column-by-column into contiguous heap buffer.
540    // Boxes view is also transposed [N_candidates, 4] with strides [1, N_candidates],
541    // so column reads are sequential while row reads are strided.
542    let boxes_buf: [Vec<Boxes>; 4] = if boxes.strides()[0] == 1 && boxes.as_slice().is_none() {
543        let mut cols: [Vec<Boxes>; 4] = [
544            vec![Boxes::zero(); n_candidates],
545            vec![Boxes::zero(); n_candidates],
546            vec![Boxes::zero(); n_candidates],
547            vec![Boxes::zero(); n_candidates],
548        ];
549        for (dim, col_buf) in cols.iter_mut().enumerate() {
550            let col = boxes.column(dim);
551            if let Some(slice) = col.as_slice() {
552                col_buf.copy_from_slice(slice);
553            } else {
554                for (i, &val) in col.iter().enumerate() {
555                    col_buf[i] = val;
556                }
557            }
558        }
559        cols
560    } else {
561        // Boxes are contiguous or differently strided — read per-candidate below.
562        [vec![], vec![], vec![], vec![]]
563    };
564    let boxes_copied = !boxes_buf[0].is_empty();
565
566    // Step 3: Threshold filter — collect candidates that pass.
567    let mut result = Vec::new();
568    for i in 0..n_candidates {
569        if max_scores[i] >= threshold {
570            let bbox_quant = if boxes_copied {
571                let raw = [
572                    boxes_buf[0][i],
573                    boxes_buf[1][i],
574                    boxes_buf[2][i],
575                    boxes_buf[3][i],
576                ];
577                B::to_xyxy_dequant(&raw, quant_boxes)
578            } else {
579                B::ndarray_to_xyxy_dequant(boxes.row(i), quant_boxes)
580            };
581            result.push((
582                DetectBoxQuantized {
583                    label: max_classes[i] as usize,
584                    score: max_scores[i],
585                    bbox: BoundingBox::from(bbox_quant),
586                },
587                i,
588            ));
589        }
590    }
591
592    result
593}
594
595/// Uses NMS to filter boxes based on the score and iou. Sorts boxes by score,
596/// then greedily selects a subset of boxes in descending order of score.
597#[doc(hidden)]
598#[must_use]
599pub fn nms_int<SCORE: PrimInt + AsPrimitive<f32> + Send + Sync>(
600    iou: f32,
601    max_det: Option<usize>,
602    mut boxes: Vec<DetectBoxQuantized<SCORE>>,
603) -> Vec<DetectBoxQuantized<SCORE>> {
604    // Boxes get sorted by score in descending order so we know based on the
605    // index the scoring of the boxes and can skip parts of the loop.
606
607    boxes.par_sort_by(|a, b| b.score.cmp(&a.score));
608
609    // When the iou is 1.0 or larger, no boxes will be filtered so we just return
610    // immediately
611    if iou >= 1.0 {
612        return match max_det {
613            Some(n) => {
614                boxes.truncate(n);
615                boxes
616            }
617            None => boxes,
618        };
619    }
620
621    let min_val = SCORE::min_value();
622    let cap = max_det.unwrap_or(usize::MAX);
623    let mut survivors: usize = 0;
624    // Outer loop over all boxes.
625    for i in 0..boxes.len() {
626        if boxes[i].score <= min_val {
627            // this box was merged with a different box earlier
628            continue;
629        }
630        for j in (i + 1)..boxes.len() {
631            // Inner loop over boxes with lower score (later in the list).
632
633            if boxes[j].score <= min_val {
634                // this box was suppressed by different box earlier
635                continue;
636            }
637
638            if jaccard(&boxes[j].bbox, &boxes[i].bbox, iou) {
639                // suppress this box
640                boxes[j].score = min_val;
641            }
642        }
643        survivors += 1;
644        if survivors >= cap {
645            break;
646        }
647    }
648    // Filter out boxes that were suppressed; cap because boxes after the
649    // break may still hold positive scores but are all lower than survivors.
650    boxes
651        .into_iter()
652        .filter(|b| b.score > min_val)
653        .take(cap)
654        .collect()
655}
656
657/// Uses NMS to filter boxes based on the score and iou. Sorts boxes by score,
658/// then greedily selects a subset of boxes in descending order of score.
659///
660/// This is same as `nms_int` but will also include extra information along
661/// with each box, such as the index
662#[doc(hidden)]
663#[must_use]
664pub fn nms_extra_int<SCORE: PrimInt + AsPrimitive<f32> + Send + Sync, E: Send + Sync>(
665    iou: f32,
666    max_det: Option<usize>,
667    mut boxes: Vec<(DetectBoxQuantized<SCORE>, E)>,
668) -> Vec<(DetectBoxQuantized<SCORE>, E)> {
669    // Boxes get sorted by score in descending order so we know based on the
670    // index the scoring of the boxes and can skip parts of the loop.
671    boxes.par_sort_by(|a, b| b.0.score.cmp(&a.0.score));
672
673    // When the iou is 1.0 or larger, no boxes will be filtered so we just return
674    // immediately
675    if iou >= 1.0 {
676        return match max_det {
677            Some(n) => {
678                boxes.truncate(n);
679                boxes
680            }
681            None => boxes,
682        };
683    }
684
685    let min_val = SCORE::min_value();
686    let cap = max_det.unwrap_or(usize::MAX);
687    let mut survivors: usize = 0;
688    // Outer loop over all boxes.
689    for i in 0..boxes.len() {
690        if boxes[i].0.score <= min_val {
691            // this box was merged with a different box earlier
692            continue;
693        }
694        for j in (i + 1)..boxes.len() {
695            // Inner loop over boxes with lower score (later in the list).
696
697            if boxes[j].0.score <= min_val {
698                // this box was suppressed by different box earlier
699                continue;
700            }
701            if jaccard(&boxes[j].0.bbox, &boxes[i].0.bbox, iou) {
702                // suppress this box
703                boxes[j].0.score = min_val;
704            }
705        }
706        survivors += 1;
707        if survivors >= cap {
708            break;
709        }
710    }
711
712    // Filter out boxes that were suppressed; cap at `max_det`.
713    boxes
714        .into_iter()
715        .filter(|b| b.0.score > min_val)
716        .take(cap)
717        .collect()
718}
719
720/// Class-aware NMS for quantized boxes: only suppress boxes with the same
721/// label.
722///
723/// Sorts boxes by score, then greedily selects a subset of boxes in descending
724/// order of score. Unlike class-agnostic NMS, boxes are only suppressed if they
725/// have the same class label AND overlap above the IoU threshold.
726#[doc(hidden)]
727#[must_use]
728pub fn nms_class_aware_int<SCORE: PrimInt + AsPrimitive<f32> + Send + Sync>(
729    iou: f32,
730    max_det: Option<usize>,
731    mut boxes: Vec<DetectBoxQuantized<SCORE>>,
732) -> Vec<DetectBoxQuantized<SCORE>> {
733    boxes.par_sort_by(|a, b| b.score.cmp(&a.score));
734
735    // When the iou is 1.0 or larger, no boxes will be filtered so we just return
736    // immediately
737    if iou >= 1.0 {
738        return match max_det {
739            Some(n) => {
740                boxes.truncate(n);
741                boxes
742            }
743            None => boxes,
744        };
745    }
746
747    let min_val = SCORE::min_value();
748    let cap = max_det.unwrap_or(usize::MAX);
749    let mut survivors: usize = 0;
750    for i in 0..boxes.len() {
751        if boxes[i].score <= min_val {
752            continue;
753        }
754        for j in (i + 1)..boxes.len() {
755            if boxes[j].score <= min_val {
756                continue;
757            }
758            // Only suppress if same class AND overlapping
759            if boxes[j].label == boxes[i].label && jaccard(&boxes[j].bbox, &boxes[i].bbox, iou) {
760                boxes[j].score = min_val;
761            }
762        }
763        survivors += 1;
764        if survivors >= cap {
765            break;
766        }
767    }
768    boxes
769        .into_iter()
770        .filter(|b| b.score > min_val)
771        .take(cap)
772        .collect()
773}
774
775/// Class-aware NMS for quantized boxes with extra data: only suppress boxes
776/// with the same label.
777///
778/// This is same as `nms_class_aware_int` but will also include extra
779/// information along with each box, such as the index.
780#[doc(hidden)]
781#[must_use]
782pub fn nms_extra_class_aware_int<
783    SCORE: PrimInt + AsPrimitive<f32> + Send + Sync,
784    E: Send + Sync,
785>(
786    iou: f32,
787    max_det: Option<usize>,
788    mut boxes: Vec<(DetectBoxQuantized<SCORE>, E)>,
789) -> Vec<(DetectBoxQuantized<SCORE>, E)> {
790    boxes.par_sort_by(|a, b| b.0.score.cmp(&a.0.score));
791
792    // When the iou is 1.0 or larger, no boxes will be filtered so we just return
793    // immediately
794    if iou >= 1.0 {
795        return match max_det {
796            Some(n) => {
797                boxes.truncate(n);
798                boxes
799            }
800            None => boxes,
801        };
802    }
803
804    let min_val = SCORE::min_value();
805    let cap = max_det.unwrap_or(usize::MAX);
806    let mut survivors: usize = 0;
807    for i in 0..boxes.len() {
808        if boxes[i].0.score <= min_val {
809            continue;
810        }
811        for j in (i + 1)..boxes.len() {
812            if boxes[j].0.score <= min_val {
813                continue;
814            }
815            // Only suppress if same class AND overlapping
816            if boxes[j].0.label == boxes[i].0.label
817                && jaccard(&boxes[j].0.bbox, &boxes[i].0.bbox, iou)
818            {
819                boxes[j].0.score = min_val;
820            }
821        }
822        survivors += 1;
823        if survivors >= cap {
824            break;
825        }
826    }
827    boxes
828        .into_iter()
829        .filter(|b| b.0.score > min_val)
830        .take(cap)
831        .collect()
832}
833
834/// Quantizes a score from f32 to the given integer type, using the following
835/// formula `(score/quant.scale + quant.zero_point).ceil()`, then clamping to
836/// the min and max value of the given integer type
837///
838/// # Examples
839/// ```rust
840/// use edgefirst_decoder::{Quantization, byte::quantize_score_threshold};
841/// let quant = Quantization {
842///     scale: 0.1,
843///     zero_point: 128,
844/// };
845/// let q: u8 = quantize_score_threshold::<u8>(0.5, quant);
846/// assert_eq!(q, 128 + 5);
847/// ```
848#[doc(hidden)]
849pub fn quantize_score_threshold<T: PrimInt + AsPrimitive<f32>>(score: f32, quant: Quantization) -> T
850where
851    f32: AsPrimitive<T>,
852{
853    if quant.scale == 0.0 {
854        return T::max_value();
855    }
856    let v = (score / quant.scale + quant.zero_point as f32).ceil();
857    let v = v.clamp(T::min_value().as_(), T::max_value().as_());
858    v.as_()
859}
860
861#[cfg(test)]
862mod tests {
863    use super::*;
864    use crate::{XYWH, XYXY};
865    use ndarray::Array2;
866
867    /// Verify that the column-major path produces identical results to the
868    /// row-major path for a transposed (non-contiguous) score array.
869    #[test]
870    fn column_major_matches_row_major() {
871        // Create scores in "model output" layout: [num_classes, num_candidates]
872        let n_classes = 80usize;
873        let n_candidates = 100usize;
874        let mut scores_physical = Array2::<u8>::zeros((n_classes, n_candidates));
875        // Fill with known pattern: class c, candidate i → (c * 3 + i * 7) % 256
876        for c in 0..n_classes {
877            for i in 0..n_candidates {
878                scores_physical[[c, i]] = ((c * 3 + i * 7) % 256) as u8;
879            }
880        }
881
882        // Create boxes: [4, num_candidates] i16
883        let mut boxes_physical = Array2::<i16>::zeros((4, n_candidates));
884        for i in 0..n_candidates {
885            boxes_physical[[0, i]] = (i * 10) as i16; // x
886            boxes_physical[[1, i]] = (i * 20) as i16; // y
887            boxes_physical[[2, i]] = (i * 10 + 50) as i16; // w
888            boxes_physical[[3, i]] = (i * 20 + 100) as i16; // h
889        }
890
891        let quant = Quantization {
892            scale: 0.00390625,
893            zero_point: 0,
894        };
895
896        let threshold: u8 = 10;
897
898        // Row-major path: contiguous [n_candidates, n_classes] array
899        let scores_contiguous = scores_physical.clone().reversed_axes().to_owned();
900        let boxes_contiguous = boxes_physical.clone().reversed_axes().to_owned();
901        let row_result = postprocess_boxes_index_quant::<XYWH, _, _>(
902            threshold,
903            boxes_contiguous.view(),
904            scores_contiguous.view(),
905            quant,
906        );
907
908        // Column-major path: non-contiguous reversed view
909        let scores_view = scores_physical.view().reversed_axes();
910        let boxes_view = boxes_physical.view().reversed_axes();
911        assert!(scores_view.as_slice().is_none(), "should be non-contiguous");
912        assert_eq!(scores_view.strides()[0], 1);
913        let col_result =
914            postprocess_boxes_index_quant::<XYWH, _, _>(threshold, boxes_view, scores_view, quant);
915
916        // Both paths should produce the same results
917        assert_eq!(
918            row_result.len(),
919            col_result.len(),
920            "different number of results: row={}, col={}",
921            row_result.len(),
922            col_result.len()
923        );
924        for (i, (row, col)) in row_result.iter().zip(col_result.iter()).enumerate() {
925            assert_eq!(
926                row.0.label, col.0.label,
927                "candidate {i}: label mismatch row={} col={}",
928                row.0.label, col.0.label
929            );
930            assert_eq!(row.0.score, col.0.score, "candidate {i}: score mismatch");
931            assert_eq!(row.1, col.1, "candidate {i}: index mismatch");
932            assert_eq!(row.0.bbox, col.0.bbox, "candidate {i}: bbox mismatch");
933        }
934    }
935
936    /// Test column-major path with i8 scores (signed, matches NEON argmax path).
937    #[test]
938    fn column_major_matches_row_major_i8() {
939        let n_classes = 80usize;
940        let n_candidates = 50usize;
941        let mut scores_physical = Array2::<i8>::zeros((n_classes, n_candidates));
942        for c in 0..n_classes {
943            for i in 0..n_candidates {
944                scores_physical[[c, i]] = ((c as i16 * 3 + i as i16 * 7) % 256 - 128) as i8;
945            }
946        }
947
948        let mut boxes_physical = Array2::<i16>::zeros((4, n_candidates));
949        for i in 0..n_candidates {
950            boxes_physical[[0, i]] = (i * 10) as i16;
951            boxes_physical[[1, i]] = (i * 20) as i16;
952            boxes_physical[[2, i]] = (i * 10 + 50) as i16;
953            boxes_physical[[3, i]] = (i * 20 + 100) as i16;
954        }
955
956        let quant = Quantization {
957            scale: 0.0256,
958            zero_point: -116,
959        };
960        let threshold: i8 = -100;
961
962        let scores_contiguous = scores_physical.clone().reversed_axes().to_owned();
963        let boxes_contiguous = boxes_physical.clone().reversed_axes().to_owned();
964        let row_result = postprocess_boxes_index_quant::<XYWH, _, _>(
965            threshold,
966            boxes_contiguous.view(),
967            scores_contiguous.view(),
968            quant,
969        );
970
971        let scores_view = scores_physical.view().reversed_axes();
972        let boxes_view = boxes_physical.view().reversed_axes();
973        let col_result =
974            postprocess_boxes_index_quant::<XYWH, _, _>(threshold, boxes_view, scores_view, quant);
975
976        assert_eq!(row_result.len(), col_result.len());
977        for (i, (row, col)) in row_result.iter().zip(col_result.iter()).enumerate() {
978            assert_eq!(row.0.label, col.0.label, "i8 candidate {i}: label mismatch");
979            assert_eq!(row.0.score, col.0.score, "i8 candidate {i}: score mismatch");
980            assert_eq!(row.1, col.1, "i8 candidate {i}: index mismatch");
981        }
982    }
983
984    /// Helper: create `n` non-overlapping boxes with descending u8 scores.
985    fn make_nms_boxes_int(n: usize) -> Vec<DetectBoxQuantized<u8>> {
986        (0..n)
987            .map(|i| DetectBoxQuantized {
988                bbox: BoundingBox {
989                    xmin: i as f32 * 100.0,
990                    ymin: 0.0,
991                    xmax: i as f32 * 100.0 + 10.0,
992                    ymax: 10.0,
993                },
994                label: 0,
995                score: (200 - i as u32).min(255) as u8,
996            })
997            .collect()
998    }
999
1000    /// Four candidates' worth of boxes, one row each, values distinct enough
1001    /// that a mis-indexed read is visible.
1002    fn probe_boxes(n: usize) -> Array2<i16> {
1003        let mut boxes = Array2::<i16>::zeros((n, 4));
1004        for i in 0..n {
1005            boxes[[i, 0]] = i as i16 * 10;
1006            boxes[[i, 1]] = i as i16 * 10 + 1;
1007            boxes[[i, 2]] = i as i16 * 10 + 2;
1008            boxes[[i, 3]] = i as i16 * 10 + 3;
1009        }
1010        boxes
1011    }
1012
1013    fn probe_quant() -> Quantization {
1014        Quantization {
1015            scale: 1.0,
1016            zero_point: 0,
1017        }
1018    }
1019
1020    /// The threshold is a floor: a score equal to it is a detection. Both
1021    /// entry points share the rule and neither had a test that sat exactly on
1022    /// it.
1023    #[test]
1024    fn postprocess_boxes_quant_keeps_a_score_equal_to_the_threshold() {
1025        let boxes = probe_boxes(2);
1026        let scores = ndarray::array![[200_u8, 0], [199, 0]];
1027
1028        let kept = postprocess_boxes_quant::<XYXY, _, _>(
1029            200_u8,
1030            boxes.view(),
1031            scores.view(),
1032            probe_quant(),
1033        );
1034        assert_eq!(kept.len(), 1, "{kept:?}");
1035        assert_eq!(kept[0].score, 200);
1036
1037        let kept = postprocess_boxes_index_quant::<XYXY, _, _>(
1038            200_u8,
1039            boxes.view(),
1040            scores.view(),
1041            probe_quant(),
1042        );
1043        assert_eq!(kept.len(), 1, "{kept:?}");
1044        assert_eq!(kept[0].1, 0);
1045    }
1046
1047    /// Class indices in the column-major scan are `u8`, so beyond 255 classes
1048    /// it has to hand over to the row-major scan. Running the column scan
1049    /// anyway wraps the label: class 260 comes back as 4.
1050    #[test]
1051    fn column_major_falls_back_beyond_255_classes() {
1052        let n_classes = 300usize;
1053        let boxes = probe_boxes(2);
1054        let mut scores = Array2::<u8>::zeros((2, n_classes));
1055        scores[[0, 260]] = 200;
1056        scores[[1, 3]] = 10;
1057
1058        let kept = postprocess_boxes_quant_column_major::<XYXY, _, _>(
1059            200_u8,
1060            boxes.view(),
1061            scores.view(),
1062            probe_quant(),
1063        );
1064        assert_eq!(kept.len(), 1, "{kept:?}");
1065        assert_eq!(kept[0].label, 260);
1066        assert_eq!(kept[0].score, 200);
1067
1068        let kept = postprocess_boxes_index_quant_column_major::<XYXY, _, _>(
1069            200_u8,
1070            boxes.view(),
1071            scores.view(),
1072            probe_quant(),
1073        );
1074        assert_eq!(kept.len(), 1, "{kept:?}");
1075        assert_eq!(kept[0].0.label, 260);
1076        assert_eq!(kept[0].1, 0);
1077    }
1078
1079    /// Driving the column-major scan with an ordinary row-major array: its
1080    /// columns are not contiguous, so the running argmax takes its strided
1081    /// branch, and the boxes are read in place rather than from the
1082    /// column-copy buffers.
1083    #[test]
1084    fn column_major_scan_handles_row_major_input() {
1085        let boxes = probe_boxes(3);
1086        // Row-major (3 candidates, 4 classes); the winner differs per row and
1087        // is never class 0, so a comparison that keeps the minimum is visible.
1088        let scores = ndarray::array![[10_u8, 90, 30, 40], [5, 6, 7, 200], [1, 2, 3, 4]];
1089
1090        let kept = postprocess_boxes_quant_column_major::<XYXY, _, _>(
1091            50_u8,
1092            boxes.view(),
1093            scores.view(),
1094            probe_quant(),
1095        );
1096        assert_eq!(kept.len(), 2, "{kept:?}");
1097        assert_eq!((kept[0].label, kept[0].score), (1, 90));
1098        assert_eq!((kept[1].label, kept[1].score), (3, 200));
1099        // Boxes were read in place, so row 1's values must line up with it.
1100        assert_eq!(kept[1].bbox.xmin, 10.0);
1101
1102        let kept = postprocess_boxes_index_quant_column_major::<XYXY, _, _>(
1103            50_u8,
1104            boxes.view(),
1105            scores.view(),
1106            probe_quant(),
1107        );
1108        assert_eq!(kept.len(), 2, "{kept:?}");
1109        assert_eq!(kept[0].1, 0);
1110        assert_eq!(kept[1].1, 1);
1111        assert_eq!(kept[1].0.label, 3);
1112    }
1113
1114    /// Two overlapping same-class boxes. `SCORE::min_value()` is the
1115    /// suppression marker, so for `u8` a score of 0 already means suppressed
1116    /// and the lower box here is given a real score.
1117    fn overlapping_pair_int() -> Vec<DetectBoxQuantized<u8>> {
1118        vec![
1119            DetectBoxQuantized {
1120                bbox: BoundingBox {
1121                    xmin: 0.0,
1122                    ymin: 0.0,
1123                    xmax: 10.0,
1124                    ymax: 10.0,
1125                },
1126                label: 0,
1127                score: 200,
1128            },
1129            DetectBoxQuantized {
1130                bbox: BoundingBox {
1131                    xmin: 1.0,
1132                    ymin: 1.0,
1133                    xmax: 11.0,
1134                    ymax: 11.0,
1135                },
1136                label: 0,
1137                score: 180,
1138            },
1139        ]
1140    }
1141
1142    /// A overlaps B, B overlaps C, A does not reach C.
1143    fn suppression_chain_int() -> Vec<DetectBoxQuantized<u8>> {
1144        let mk = |xmin: f32, xmax: f32, score: u8| DetectBoxQuantized {
1145            bbox: BoundingBox {
1146                xmin,
1147                ymin: 0.0,
1148                xmax,
1149                ymax: 10.0,
1150            },
1151            label: 0,
1152            score,
1153        };
1154        vec![mk(0.0, 10.0, 200), mk(2.0, 12.0, 180), mk(4.0, 14.0, 160)]
1155    }
1156
1157    #[test]
1158    fn nms_class_aware_int_suppresses_same_class_without_a_cap() {
1159        let result = nms_class_aware_int(0.5, None, overlapping_pair_int());
1160        assert_eq!(result.len(), 1, "{result:?}");
1161        assert_eq!(result[0].score, 200);
1162    }
1163
1164    #[test]
1165    fn nms_class_aware_int_keeps_overlapping_boxes_of_other_classes() {
1166        let mut boxes = overlapping_pair_int();
1167        boxes[1].label = 1;
1168        let result = nms_class_aware_int(0.5, None, boxes);
1169        assert_eq!(result.len(), 2, "{result:?}");
1170    }
1171
1172    #[test]
1173    fn nms_class_aware_int_does_not_suppress_on_behalf_of_a_suppressed_box() {
1174        let result = nms_class_aware_int(0.5, None, suppression_chain_int());
1175        assert_eq!(result.len(), 2, "{result:?}");
1176        assert_eq!(result[0].score, 200);
1177        assert_eq!(result[1].score, 160);
1178    }
1179
1180    /// A suppressed box is marked with `min_value()`, and the final filter
1181    /// drops it. Keeping `>= min_value` instead would hand every suppressed
1182    /// box back to the caller.
1183    #[test]
1184    fn nms_class_aware_int_drops_boxes_already_at_the_minimum_score() {
1185        let mut boxes = overlapping_pair_int();
1186        boxes.push(DetectBoxQuantized {
1187            bbox: BoundingBox {
1188                xmin: 50.0,
1189                ymin: 50.0,
1190                xmax: 60.0,
1191                ymax: 60.0,
1192            },
1193            label: 0,
1194            score: u8::MIN,
1195        });
1196        let result = nms_class_aware_int(0.5, None, boxes);
1197        assert_eq!(result.len(), 1, "{result:?}");
1198        assert_eq!(result[0].score, 200);
1199    }
1200
1201    /// A box that overlaps nothing, then a pair that overlaps each other. The
1202    /// first box confirms a survivor without suppressing anything, so a loop
1203    /// that stops at the first survivor leaves the pair un-deduplicated while
1204    /// the chain above, where the first box does the suppressing, looks
1205    /// identical either way.
1206    fn isolated_then_overlapping_pair_int() -> Vec<DetectBoxQuantized<u8>> {
1207        let mk = |xmin: f32, xmax: f32, score: u8| DetectBoxQuantized {
1208            bbox: BoundingBox {
1209                xmin,
1210                ymin: 0.0,
1211                xmax,
1212                ymax: 10.0,
1213            },
1214            label: 0,
1215            score,
1216        };
1217        vec![
1218            mk(100.0, 110.0, 200),
1219            mk(0.0, 10.0, 180),
1220            mk(1.0, 11.0, 160),
1221        ]
1222    }
1223
1224    #[test]
1225    fn nms_class_aware_int_keeps_suppressing_after_the_first_survivor() {
1226        let result = nms_class_aware_int(0.5, None, isolated_then_overlapping_pair_int());
1227        assert_eq!(result.len(), 2, "{result:?}");
1228        assert_eq!(result[0].score, 200);
1229        assert_eq!(result[1].score, 180);
1230    }
1231
1232    fn with_index_int(boxes: Vec<DetectBoxQuantized<u8>>) -> Vec<(DetectBoxQuantized<u8>, usize)> {
1233        boxes.into_iter().enumerate().map(|(i, b)| (b, i)).collect()
1234    }
1235
1236    #[test]
1237    fn nms_extra_class_aware_int_keeps_suppressing_after_the_first_survivor() {
1238        let result = nms_extra_class_aware_int(
1239            0.5,
1240            None,
1241            with_index_int(isolated_then_overlapping_pair_int()),
1242        );
1243        assert_eq!(result.len(), 2, "{result:?}");
1244        assert_eq!(result[1].1, 1);
1245    }
1246
1247    #[test]
1248    fn nms_extra_class_aware_int_suppresses_same_class_without_a_cap() {
1249        let result = nms_extra_class_aware_int(0.5, None, with_index_int(overlapping_pair_int()));
1250        assert_eq!(result.len(), 1, "{result:?}");
1251        assert_eq!(result[0].0.score, 200);
1252        assert_eq!(result[0].1, 0, "the surviving box keeps its extra data");
1253    }
1254
1255    #[test]
1256    fn nms_extra_class_aware_int_keeps_overlapping_boxes_of_other_classes() {
1257        let mut boxes = overlapping_pair_int();
1258        boxes[1].label = 1;
1259        let result = nms_extra_class_aware_int(0.5, None, with_index_int(boxes));
1260        assert_eq!(result.len(), 2, "{result:?}");
1261    }
1262
1263    #[test]
1264    fn nms_extra_class_aware_int_does_not_suppress_on_behalf_of_a_suppressed_box() {
1265        let result = nms_extra_class_aware_int(0.5, None, with_index_int(suppression_chain_int()));
1266        assert_eq!(result.len(), 2, "{result:?}");
1267        assert_eq!(result[0].1, 0);
1268        assert_eq!(result[1].1, 2);
1269    }
1270
1271    #[test]
1272    fn nms_extra_class_aware_int_drops_boxes_already_at_the_minimum_score() {
1273        let mut boxes = overlapping_pair_int();
1274        boxes.push(DetectBoxQuantized {
1275            bbox: BoundingBox {
1276                xmin: 50.0,
1277                ymin: 50.0,
1278                xmax: 60.0,
1279                ymax: 60.0,
1280            },
1281            label: 0,
1282            score: u8::MIN,
1283        });
1284        let result = nms_extra_class_aware_int(0.5, None, with_index_int(boxes));
1285        assert_eq!(result.len(), 1, "{result:?}");
1286        assert_eq!(result[0].0.score, 200);
1287    }
1288
1289    /// A single box is never compared with itself: the inner loop starts one
1290    /// past the outer index. Starting at `i` instead makes every box suppress
1291    /// itself, since a box's IoU with itself is 1.
1292    #[test]
1293    fn nms_extra_class_aware_int_keeps_a_lone_box() {
1294        let boxes = with_index_int(vec![overlapping_pair_int().remove(0)]);
1295        let result = nms_extra_class_aware_int(0.5, None, boxes);
1296        assert_eq!(result.len(), 1, "{result:?}");
1297        assert_eq!(result[0].0.score, 200);
1298    }
1299
1300    #[test]
1301    fn nms_int_max_det_matches_full_truncated() {
1302        let boxes = make_nms_boxes_int(20);
1303        let n = 5;
1304        let full = nms_int(0.5, None, boxes.clone());
1305        let capped = nms_int(0.5, Some(n), boxes);
1306        assert_eq!(capped.len(), n);
1307        assert_eq!(&full[..n], &capped[..]);
1308    }
1309
1310    #[test]
1311    fn nms_int_max_det_zero_returns_empty() {
1312        let boxes = make_nms_boxes_int(10);
1313        let result = nms_int(0.5, Some(0), boxes);
1314        assert!(result.is_empty());
1315    }
1316
1317    #[test]
1318    fn nms_int_max_det_iou_ge_1_returns_sorted_truncated() {
1319        let boxes = make_nms_boxes_int(10);
1320        let result = nms_int(1.0, Some(3), boxes);
1321        assert_eq!(result.len(), 3);
1322        // Scores should be in descending order (sorted).
1323        assert!(result[0].score >= result[1].score);
1324        assert!(result[1].score >= result[2].score);
1325    }
1326
1327    #[test]
1328    fn nms_int_max_det_larger_than_input() {
1329        let boxes = make_nms_boxes_int(5);
1330        let full = nms_int(0.5, None, boxes.clone());
1331        let capped = nms_int(0.5, Some(100), boxes);
1332        assert_eq!(full.len(), capped.len());
1333    }
1334}