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