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    use std::arch::aarch64::*;
50
51    let class_vec = vdupq_n_u8(class_idx);
52    let chunks = n / 16;
53    let remainder = n % 16;
54
55    if signed {
56        // Signed i8 comparison: interpret bytes as i8.
57        for chunk in 0..chunks {
58            let offset = chunk * 16;
59            let col = vld1q_s8(col_ptr.add(offset) as *const i8);
60            let cur_max = vld1q_s8(max_ptr.add(offset) as *const i8);
61            // mask[i] = 0xFF where col[i] >= cur_max[i], else 0x00
62            let mask = vcgeq_s8(col, cur_max);
63            // new_max = max(col, cur_max)
64            let new_max = vmaxq_s8(col, cur_max);
65            vst1q_s8(max_ptr.add(offset) as *mut i8, new_max);
66            // Select class_idx where mask is set, keep old class otherwise.
67            let cur_class = vld1q_u8(class_ptr.add(offset));
68            let new_class = vbslq_u8(mask, class_vec, cur_class);
69            vst1q_u8(class_ptr.add(offset), new_class);
70        }
71        // Scalar tail.
72        for i in (chunks * 16)..n {
73            let val = *(col_ptr.add(i) as *const i8);
74            let cur = *(max_ptr.add(i) as *const i8);
75            if val >= cur {
76                *(max_ptr.add(i) as *mut i8) = val;
77                *class_ptr.add(i) = class_idx;
78            }
79        }
80    } else {
81        // Unsigned u8 comparison.
82        for chunk in 0..chunks {
83            let offset = chunk * 16;
84            let col = vld1q_u8(col_ptr.add(offset));
85            let cur_max = vld1q_u8(max_ptr.add(offset));
86            let mask = vcgeq_u8(col, cur_max);
87            let new_max = vmaxq_u8(col, cur_max);
88            vst1q_u8(max_ptr.add(offset), new_max);
89            let cur_class = vld1q_u8(class_ptr.add(offset));
90            let new_class = vbslq_u8(mask, class_vec, cur_class);
91            vst1q_u8(class_ptr.add(offset), new_class);
92        }
93        // Scalar tail.
94        for i in (chunks * 16)..n {
95            let val = *col_ptr.add(i);
96            let cur = *max_ptr.add(i);
97            if val >= cur {
98                *max_ptr.add(i) = val;
99                *class_ptr.add(i) = class_idx;
100            }
101        }
102    }
103    let _ = remainder; // suppress unused warning
104}
105
106/// NEON-accelerated column max update with software prefetch for DMA-BUF.
107///
108/// Adds PRFM (prefetch for load, L1 data cache, streaming) hints 2 cache
109/// lines ahead of the current read position to hide CMA memory latency.
110/// On Cortex-A55 (64B cache lines) this prefetches 128 bytes ahead; on
111/// Cortex-A76 (64B L1, 128B L2 lines) the same offset covers one L2 line.
112///
113/// # Safety
114///
115/// - `col_ptr` must point to at least `n` valid bytes.
116/// - `max_ptr` must point to at least `n` valid mutable bytes.
117/// - `class_ptr` must point to at least `n` valid mutable bytes.
118#[cfg(target_arch = "aarch64")]
119#[allow(dead_code)] // Reserved for DMA-BUF CMA paths where latency hiding helps.
120unsafe fn column_max_update_neon_prefetch(
121    col_ptr: *const u8,
122    max_ptr: *mut u8,
123    class_ptr: *mut u8,
124    n: usize,
125    class_idx: u8,
126    signed: bool,
127) {
128    use std::arch::aarch64::*;
129
130    const PREFETCH_AHEAD: usize = 128; // 2 cache lines on A55 (64B each)
131
132    let class_vec = vdupq_n_u8(class_idx);
133    let chunks = n / 16;
134
135    if signed {
136        for chunk in 0..chunks {
137            let offset = chunk * 16;
138            // Software prefetch: hint the next read 2 cache lines ahead.
139            if offset + PREFETCH_AHEAD < n {
140                core::arch::asm!(
141                    "prfm pldl1strm, [{ptr}]",
142                    ptr = in(reg) col_ptr.add(offset + PREFETCH_AHEAD),
143                    options(nostack, preserves_flags),
144                );
145            }
146            let col = vld1q_s8(col_ptr.add(offset) as *const i8);
147            let cur_max = vld1q_s8(max_ptr.add(offset) as *const i8);
148            let mask = vcgeq_s8(col, cur_max);
149            let new_max = vmaxq_s8(col, cur_max);
150            vst1q_s8(max_ptr.add(offset) as *mut i8, new_max);
151            let cur_class = vld1q_u8(class_ptr.add(offset));
152            let new_class = vbslq_u8(mask, class_vec, cur_class);
153            vst1q_u8(class_ptr.add(offset), new_class);
154        }
155        for i in (chunks * 16)..n {
156            let val = *(col_ptr.add(i) as *const i8);
157            let cur = *(max_ptr.add(i) as *const i8);
158            if val >= cur {
159                *(max_ptr.add(i) as *mut i8) = val;
160                *class_ptr.add(i) = class_idx;
161            }
162        }
163    } else {
164        for chunk in 0..chunks {
165            let offset = chunk * 16;
166            if offset + PREFETCH_AHEAD < n {
167                core::arch::asm!(
168                    "prfm pldl1strm, [{ptr}]",
169                    ptr = in(reg) col_ptr.add(offset + PREFETCH_AHEAD),
170                    options(nostack, preserves_flags),
171                );
172            }
173            let col = vld1q_u8(col_ptr.add(offset));
174            let cur_max = vld1q_u8(max_ptr.add(offset));
175            let mask = vcgeq_u8(col, cur_max);
176            let new_max = vmaxq_u8(col, cur_max);
177            vst1q_u8(max_ptr.add(offset), new_max);
178            let cur_class = vld1q_u8(class_ptr.add(offset));
179            let new_class = vbslq_u8(mask, class_vec, cur_class);
180            vst1q_u8(class_ptr.add(offset), new_class);
181        }
182        for i in (chunks * 16)..n {
183            let val = *col_ptr.add(i);
184            let cur = *max_ptr.add(i);
185            if val >= cur {
186                *max_ptr.add(i) = val;
187                *class_ptr.add(i) = class_idx;
188            }
189        }
190    }
191}
192
193/// Fast argmax dispatching to NEON-optimized path for i8 on aarch64.
194#[inline(always)]
195fn fast_arg_max<T: PrimInt + Copy>(score: ArrayView1<T>) -> (T, usize) {
196    #[cfg(target_arch = "aarch64")]
197    {
198        // Check if this is an i8 slice and contiguous.
199        if std::mem::size_of::<T>() == 1 && score.as_slice().is_some() {
200            let slice = score.as_slice().unwrap();
201            // Safety: T is i8 when size_of::<T>() == 1 and PrimInt.
202            // PrimInt covers i8, u8, i16, etc. We only want to use the
203            // i8 NEON path for signed i8.
204            let ptr = slice.as_ptr() as *const i8;
205            let i8_slice = unsafe { std::slice::from_raw_parts(ptr, slice.len()) };
206            // Only valid for signed i8 (not u8). Check sign bit behavior:
207            // PrimInt for i8 means min_value() is negative.
208            if T::min_value() < T::zero() {
209                let (max_val, idx) = arg_max_i8(i8_slice);
210                // Safety: transmute i8 back to T (they have the same size and
211                // representation for i8).
212                let result: T = unsafe { std::mem::transmute_copy(&max_val) };
213                return (result, idx);
214            }
215        }
216    }
217    arg_max(score)
218}
219
220/// Post processes boxes and scores tensors into quantized detection boxes,
221/// filtering out any boxes below the score threshold. The boxes tensor
222/// is converted to XYXY using the given BBoxTypeTrait. The order of the boxes
223/// is preserved.
224#[doc(hidden)]
225pub fn postprocess_boxes_quant<
226    B: BBoxTypeTrait,
227    Boxes: PrimInt + AsPrimitive<f32> + Send + Sync,
228    Scores: PrimInt + AsPrimitive<f32> + Send + Sync,
229>(
230    threshold: Scores,
231    boxes: ArrayView2<Boxes>,
232    scores: ArrayView2<Scores>,
233    quant_boxes: Quantization,
234) -> Vec<DetectBoxQuantized<Scores>> {
235    assert_eq!(scores.dim().0, boxes.dim().0);
236    assert_eq!(boxes.dim().1, 4);
237
238    // Use column-major path for transposed DMA-BUF views (see postprocess_boxes_index_quant).
239    if scores.strides()[0] == 1 && scores.as_slice().is_none() {
240        return postprocess_boxes_quant_column_major::<B, _, _>(
241            threshold,
242            boxes,
243            scores,
244            quant_boxes,
245        );
246    }
247
248    Zip::from(scores.rows())
249        .and(boxes.rows())
250        .into_par_iter()
251        .filter_map(|(score, bbox)| {
252            let (score_, label) = fast_arg_max(score);
253            if score_ < threshold {
254                return None;
255            }
256
257            let bbox_quant = B::ndarray_to_xyxy_dequant(bbox.view(), quant_boxes);
258            Some(DetectBoxQuantized {
259                label,
260                score: score_,
261                bbox: BoundingBox::from(bbox_quant),
262            })
263        })
264        .collect()
265}
266
267/// Column-major optimized path for `postprocess_boxes_quant`.
268fn postprocess_boxes_quant_column_major<
269    B: BBoxTypeTrait,
270    Boxes: PrimInt + AsPrimitive<f32> + Send + Sync,
271    Scores: PrimInt + AsPrimitive<f32> + Send + Sync,
272>(
273    threshold: Scores,
274    boxes: ArrayView2<Boxes>,
275    scores: ArrayView2<Scores>,
276    quant_boxes: Quantization,
277) -> Vec<DetectBoxQuantized<Scores>> {
278    let (n_candidates, n_classes) = scores.dim();
279
280    // Column-major NEON path uses u8 class indices; fall back for >255 classes.
281    if n_classes > 255 {
282        return Zip::from(scores.rows())
283            .and(boxes.rows())
284            .into_par_iter()
285            .filter_map(|(score, bbox)| {
286                let (score_, label) = fast_arg_max(score);
287                if score_ < threshold {
288                    return None;
289                }
290                let bbox_quant = B::ndarray_to_xyxy_dequant(bbox.view(), quant_boxes);
291                Some(DetectBoxQuantized {
292                    label,
293                    score: score_,
294                    bbox: BoundingBox::from(bbox_quant),
295                })
296            })
297            .collect();
298    }
299    let mut max_scores = vec![Scores::min_value(); n_candidates];
300    let mut max_classes = vec![0u8; n_candidates];
301
302    for class_idx in 0..n_classes {
303        let col = scores.column(class_idx);
304        if let Some(slice) = col.as_slice() {
305            #[cfg(target_arch = "aarch64")]
306            {
307                if std::mem::size_of::<Scores>() == 1 {
308                    unsafe {
309                        // Non-prefetch variant for heap-backed tensors;
310                        // prefetch variant reserved for DMA-BUF CMA paths.
311                        column_max_update_neon(
312                            slice.as_ptr() as *const u8,
313                            max_scores.as_mut_ptr() as *mut u8,
314                            max_classes.as_mut_ptr(),
315                            n_candidates,
316                            class_idx as u8,
317                            Scores::min_value() < Scores::zero(),
318                        );
319                    }
320                    continue;
321                }
322            }
323            for (i, &val) in slice.iter().enumerate() {
324                if val >= max_scores[i] {
325                    max_scores[i] = val;
326                    max_classes[i] = class_idx as u8;
327                }
328            }
329        } else {
330            for (i, &val) in col.iter().enumerate() {
331                if val >= max_scores[i] {
332                    max_scores[i] = val;
333                    max_classes[i] = class_idx as u8;
334                }
335            }
336        }
337    }
338
339    // Copy boxes column-by-column if also transposed.
340    let boxes_buf: [Vec<Boxes>; 4] = if boxes.strides()[0] == 1 && boxes.as_slice().is_none() {
341        let mut cols: [Vec<Boxes>; 4] = [
342            vec![Boxes::zero(); n_candidates],
343            vec![Boxes::zero(); n_candidates],
344            vec![Boxes::zero(); n_candidates],
345            vec![Boxes::zero(); n_candidates],
346        ];
347        for (dim, col_buf) in cols.iter_mut().enumerate() {
348            let col = boxes.column(dim);
349            if let Some(slice) = col.as_slice() {
350                col_buf.copy_from_slice(slice);
351            } else {
352                for (i, &val) in col.iter().enumerate() {
353                    col_buf[i] = val;
354                }
355            }
356        }
357        cols
358    } else {
359        [vec![], vec![], vec![], vec![]]
360    };
361    let boxes_copied = !boxes_buf[0].is_empty();
362
363    let mut result = Vec::new();
364    for i in 0..n_candidates {
365        if max_scores[i] >= threshold {
366            let bbox_quant = if boxes_copied {
367                let raw = [
368                    boxes_buf[0][i],
369                    boxes_buf[1][i],
370                    boxes_buf[2][i],
371                    boxes_buf[3][i],
372                ];
373                B::to_xyxy_dequant(&raw, quant_boxes)
374            } else {
375                B::ndarray_to_xyxy_dequant(boxes.row(i), quant_boxes)
376            };
377            result.push(DetectBoxQuantized {
378                label: max_classes[i] as usize,
379                score: max_scores[i],
380                bbox: BoundingBox::from(bbox_quant),
381            });
382        }
383    }
384
385    result
386}
387
388/// Post processes boxes and scores tensors into quantized detection boxes,
389/// filtering out any boxes below the score threshold. The boxes tensor
390/// is converted to XYXY using the given BBoxTypeTrait. The order of the boxes
391/// is preserved.
392///
393/// This function is very similar to `postprocess_boxes_quant` but will also
394/// return the index of the box. The boxes will be in ascending index order.
395///
396/// When scores originate from a transposed DMA-BUF view (stride-1 along axis 0),
397/// an optimized column-major scan is used to avoid catastrophic strided reads on
398/// uncacheable memory.
399#[doc(hidden)]
400pub fn postprocess_boxes_index_quant<
401    B: BBoxTypeTrait,
402    Boxes: PrimInt + AsPrimitive<f32> + Send + Sync,
403    Scores: PrimInt + AsPrimitive<f32> + Send + Sync,
404>(
405    threshold: Scores,
406    boxes: ArrayView2<Boxes>,
407    scores: ArrayView2<Scores>,
408    quant_boxes: Quantization,
409) -> Vec<(DetectBoxQuantized<Scores>, usize)> {
410    assert_eq!(scores.dim().0, boxes.dim().0);
411    assert_eq!(boxes.dim().1, 4);
412
413    // Detect transposed C-contiguous layout (e.g., from reversed_axes() on DMA-BUF).
414    // In this layout columns are contiguous (stride-1 along axis 0) but rows are not.
415    // The column-major path reads memory sequentially, avoiding cache-hostile strides.
416    if scores.strides()[0] == 1 && scores.as_slice().is_none() {
417        return postprocess_boxes_index_quant_column_major::<B, _, _>(
418            threshold,
419            boxes,
420            scores,
421            quant_boxes,
422        );
423    }
424
425    let indices: Array1<usize> = (0..boxes.dim().0).collect();
426    Zip::from(scores.rows())
427        .and(boxes.rows())
428        .and(&indices)
429        .into_par_iter()
430        .filter_map(|(score, bbox, index)| {
431            let (score_, label) = fast_arg_max(score);
432            if score_ < threshold {
433                return None;
434            }
435
436            let bbox_quant = B::ndarray_to_xyxy_dequant(bbox.view(), quant_boxes);
437
438            Some((
439                DetectBoxQuantized {
440                    label,
441                    score: score_,
442                    bbox: BoundingBox::from(bbox_quant),
443                },
444                *index,
445            ))
446        })
447        .collect()
448}
449
450/// Column-major optimized path for `postprocess_boxes_index_quant`.
451///
452/// When scores come from a transposed DMA-BUF view ([N_candidates, N_classes]
453/// with strides [1, N_candidates]), row iteration causes N_classes reads each
454/// N_candidates bytes apart — catastrophic on uncacheable memory. Instead, this
455/// iterates over classes (columns, which are contiguous), maintaining a running
456/// argmax per candidate in cacheable heap buffers.
457fn postprocess_boxes_index_quant_column_major<
458    B: BBoxTypeTrait,
459    Boxes: PrimInt + AsPrimitive<f32> + Send + Sync,
460    Scores: PrimInt + AsPrimitive<f32> + Send + Sync,
461>(
462    threshold: Scores,
463    boxes: ArrayView2<Boxes>,
464    scores: ArrayView2<Scores>,
465    quant_boxes: Quantization,
466) -> Vec<(DetectBoxQuantized<Scores>, usize)> {
467    let (n_candidates, n_classes) = scores.dim();
468
469    // Step 1: Column-based argmax — sequential reads over contiguous columns.
470    // Use u8 for class indices (max 255 classes) for optimal NEON vectorization.
471    // Fall back to row-major path for models with >255 classes.
472    if n_classes > 255 {
473        let indices: Array1<usize> = (0..n_candidates).collect();
474        return Zip::from(scores.rows())
475            .and(boxes.rows())
476            .and(&indices)
477            .into_par_iter()
478            .filter_map(|(score, bbox, index)| {
479                let (score_, label) = fast_arg_max(score);
480                if score_ < threshold {
481                    return None;
482                }
483                let bbox_quant = B::ndarray_to_xyxy_dequant(bbox.view(), quant_boxes);
484                Some((
485                    DetectBoxQuantized {
486                        label,
487                        score: score_,
488                        bbox: BoundingBox::from(bbox_quant),
489                    },
490                    *index,
491                ))
492            })
493            .collect();
494    }
495    let mut max_scores = vec![Scores::min_value(); n_candidates];
496    let mut max_classes = vec![0u8; n_candidates];
497    for class_idx in 0..n_classes {
498        let col = scores.column(class_idx);
499        if let Some(slice) = col.as_slice() {
500            // Use NEON-accelerated column max update on aarch64 for u8 scores.
501            #[cfg(target_arch = "aarch64")]
502            {
503                if std::mem::size_of::<Scores>() == 1 {
504                    // SAFETY: Scores is u8 or i8 (size == 1). We transmute the
505                    // slice pointers to the concrete byte type for NEON processing.
506                    unsafe {
507                        column_max_update_neon(
508                            slice.as_ptr() as *const u8,
509                            max_scores.as_mut_ptr() as *mut u8,
510                            max_classes.as_mut_ptr(),
511                            n_candidates,
512                            class_idx as u8,
513                            Scores::min_value() < Scores::zero(), // signed flag
514                        );
515                    }
516                    continue;
517                }
518            }
519            for (i, &val) in slice.iter().enumerate() {
520                if val >= max_scores[i] {
521                    max_scores[i] = val;
522                    max_classes[i] = class_idx as u8;
523                }
524            }
525        } else {
526            for (i, &val) in col.iter().enumerate() {
527                if val >= max_scores[i] {
528                    max_scores[i] = val;
529                    max_classes[i] = class_idx as u8;
530                }
531            }
532        }
533    }
534
535    // Step 2: Copy boxes column-by-column into contiguous heap buffer.
536    // Boxes view is also transposed [N_candidates, 4] with strides [1, N_candidates],
537    // so column reads are sequential while row reads are strided.
538    let boxes_buf: [Vec<Boxes>; 4] = if boxes.strides()[0] == 1 && boxes.as_slice().is_none() {
539        let mut cols: [Vec<Boxes>; 4] = [
540            vec![Boxes::zero(); n_candidates],
541            vec![Boxes::zero(); n_candidates],
542            vec![Boxes::zero(); n_candidates],
543            vec![Boxes::zero(); n_candidates],
544        ];
545        for (dim, col_buf) in cols.iter_mut().enumerate() {
546            let col = boxes.column(dim);
547            if let Some(slice) = col.as_slice() {
548                col_buf.copy_from_slice(slice);
549            } else {
550                for (i, &val) in col.iter().enumerate() {
551                    col_buf[i] = val;
552                }
553            }
554        }
555        cols
556    } else {
557        // Boxes are contiguous or differently strided — read per-candidate below.
558        [vec![], vec![], vec![], vec![]]
559    };
560    let boxes_copied = !boxes_buf[0].is_empty();
561
562    // Step 3: Threshold filter — collect candidates that pass.
563    let mut result = Vec::new();
564    for i in 0..n_candidates {
565        if max_scores[i] >= threshold {
566            let bbox_quant = if boxes_copied {
567                let raw = [
568                    boxes_buf[0][i],
569                    boxes_buf[1][i],
570                    boxes_buf[2][i],
571                    boxes_buf[3][i],
572                ];
573                B::to_xyxy_dequant(&raw, quant_boxes)
574            } else {
575                B::ndarray_to_xyxy_dequant(boxes.row(i), quant_boxes)
576            };
577            result.push((
578                DetectBoxQuantized {
579                    label: max_classes[i] as usize,
580                    score: max_scores[i],
581                    bbox: BoundingBox::from(bbox_quant),
582                },
583                i,
584            ));
585        }
586    }
587
588    result
589}
590
591/// Uses NMS to filter boxes based on the score and iou. Sorts boxes by score,
592/// then greedily selects a subset of boxes in descending order of score.
593#[doc(hidden)]
594#[must_use]
595pub fn nms_int<SCORE: PrimInt + AsPrimitive<f32> + Send + Sync>(
596    iou: f32,
597    max_det: Option<usize>,
598    mut boxes: Vec<DetectBoxQuantized<SCORE>>,
599) -> Vec<DetectBoxQuantized<SCORE>> {
600    // Boxes get sorted by score in descending order so we know based on the
601    // index the scoring of the boxes and can skip parts of the loop.
602
603    boxes.par_sort_by(|a, b| b.score.cmp(&a.score));
604
605    // When the iou is 1.0 or larger, no boxes will be filtered so we just return
606    // immediately
607    if iou >= 1.0 {
608        return match max_det {
609            Some(n) => {
610                boxes.truncate(n);
611                boxes
612            }
613            None => boxes,
614        };
615    }
616
617    let min_val = SCORE::min_value();
618    let cap = max_det.unwrap_or(usize::MAX);
619    let mut survivors: usize = 0;
620    // Outer loop over all boxes.
621    for i in 0..boxes.len() {
622        if boxes[i].score <= min_val {
623            // this box was merged with a different box earlier
624            continue;
625        }
626        for j in (i + 1)..boxes.len() {
627            // Inner loop over boxes with lower score (later in the list).
628
629            if boxes[j].score <= min_val {
630                // this box was suppressed by different box earlier
631                continue;
632            }
633
634            if jaccard(&boxes[j].bbox, &boxes[i].bbox, iou) {
635                // suppress this box
636                boxes[j].score = min_val;
637            }
638        }
639        survivors += 1;
640        if survivors >= cap {
641            break;
642        }
643    }
644    // Filter out boxes that were suppressed; cap because boxes after the
645    // break may still hold positive scores but are all lower than survivors.
646    boxes
647        .into_iter()
648        .filter(|b| b.score > min_val)
649        .take(cap)
650        .collect()
651}
652
653/// Uses NMS to filter boxes based on the score and iou. Sorts boxes by score,
654/// then greedily selects a subset of boxes in descending order of score.
655///
656/// This is same as `nms_int` but will also include extra information along
657/// with each box, such as the index
658#[doc(hidden)]
659#[must_use]
660pub fn nms_extra_int<SCORE: PrimInt + AsPrimitive<f32> + Send + Sync, E: Send + Sync>(
661    iou: f32,
662    max_det: Option<usize>,
663    mut boxes: Vec<(DetectBoxQuantized<SCORE>, E)>,
664) -> Vec<(DetectBoxQuantized<SCORE>, E)> {
665    // Boxes get sorted by score in descending order so we know based on the
666    // index the scoring of the boxes and can skip parts of the loop.
667    boxes.par_sort_by(|a, b| b.0.score.cmp(&a.0.score));
668
669    // When the iou is 1.0 or larger, no boxes will be filtered so we just return
670    // immediately
671    if iou >= 1.0 {
672        return match max_det {
673            Some(n) => {
674                boxes.truncate(n);
675                boxes
676            }
677            None => boxes,
678        };
679    }
680
681    let min_val = SCORE::min_value();
682    let cap = max_det.unwrap_or(usize::MAX);
683    let mut survivors: usize = 0;
684    // Outer loop over all boxes.
685    for i in 0..boxes.len() {
686        if boxes[i].0.score <= min_val {
687            // this box was merged with a different box earlier
688            continue;
689        }
690        for j in (i + 1)..boxes.len() {
691            // Inner loop over boxes with lower score (later in the list).
692
693            if boxes[j].0.score <= min_val {
694                // this box was suppressed by different box earlier
695                continue;
696            }
697            if jaccard(&boxes[j].0.bbox, &boxes[i].0.bbox, iou) {
698                // suppress this box
699                boxes[j].0.score = min_val;
700            }
701        }
702        survivors += 1;
703        if survivors >= cap {
704            break;
705        }
706    }
707
708    // Filter out boxes that were suppressed; cap at `max_det`.
709    boxes
710        .into_iter()
711        .filter(|b| b.0.score > min_val)
712        .take(cap)
713        .collect()
714}
715
716/// Class-aware NMS for quantized boxes: only suppress boxes with the same
717/// label.
718///
719/// Sorts boxes by score, then greedily selects a subset of boxes in descending
720/// order of score. Unlike class-agnostic NMS, boxes are only suppressed if they
721/// have the same class label AND overlap above the IoU threshold.
722#[doc(hidden)]
723#[must_use]
724pub fn nms_class_aware_int<SCORE: PrimInt + AsPrimitive<f32> + Send + Sync>(
725    iou: f32,
726    max_det: Option<usize>,
727    mut boxes: Vec<DetectBoxQuantized<SCORE>>,
728) -> Vec<DetectBoxQuantized<SCORE>> {
729    boxes.par_sort_by(|a, b| b.score.cmp(&a.score));
730
731    // When the iou is 1.0 or larger, no boxes will be filtered so we just return
732    // immediately
733    if iou >= 1.0 {
734        return match max_det {
735            Some(n) => {
736                boxes.truncate(n);
737                boxes
738            }
739            None => boxes,
740        };
741    }
742
743    let min_val = SCORE::min_value();
744    let cap = max_det.unwrap_or(usize::MAX);
745    let mut survivors: usize = 0;
746    for i in 0..boxes.len() {
747        if boxes[i].score <= min_val {
748            continue;
749        }
750        for j in (i + 1)..boxes.len() {
751            if boxes[j].score <= min_val {
752                continue;
753            }
754            // Only suppress if same class AND overlapping
755            if boxes[j].label == boxes[i].label && jaccard(&boxes[j].bbox, &boxes[i].bbox, iou) {
756                boxes[j].score = min_val;
757            }
758        }
759        survivors += 1;
760        if survivors >= cap {
761            break;
762        }
763    }
764    boxes
765        .into_iter()
766        .filter(|b| b.score > min_val)
767        .take(cap)
768        .collect()
769}
770
771/// Class-aware NMS for quantized boxes with extra data: only suppress boxes
772/// with the same label.
773///
774/// This is same as `nms_class_aware_int` but will also include extra
775/// information along with each box, such as the index.
776#[doc(hidden)]
777#[must_use]
778pub fn nms_extra_class_aware_int<
779    SCORE: PrimInt + AsPrimitive<f32> + Send + Sync,
780    E: Send + Sync,
781>(
782    iou: f32,
783    max_det: Option<usize>,
784    mut boxes: Vec<(DetectBoxQuantized<SCORE>, E)>,
785) -> Vec<(DetectBoxQuantized<SCORE>, E)> {
786    boxes.par_sort_by(|a, b| b.0.score.cmp(&a.0.score));
787
788    // When the iou is 1.0 or larger, no boxes will be filtered so we just return
789    // immediately
790    if iou >= 1.0 {
791        return match max_det {
792            Some(n) => {
793                boxes.truncate(n);
794                boxes
795            }
796            None => boxes,
797        };
798    }
799
800    let min_val = SCORE::min_value();
801    let cap = max_det.unwrap_or(usize::MAX);
802    let mut survivors: usize = 0;
803    for i in 0..boxes.len() {
804        if boxes[i].0.score <= min_val {
805            continue;
806        }
807        for j in (i + 1)..boxes.len() {
808            if boxes[j].0.score <= min_val {
809                continue;
810            }
811            // Only suppress if same class AND overlapping
812            if boxes[j].0.label == boxes[i].0.label
813                && jaccard(&boxes[j].0.bbox, &boxes[i].0.bbox, iou)
814            {
815                boxes[j].0.score = min_val;
816            }
817        }
818        survivors += 1;
819        if survivors >= cap {
820            break;
821        }
822    }
823    boxes
824        .into_iter()
825        .filter(|b| b.0.score > min_val)
826        .take(cap)
827        .collect()
828}
829
830/// Quantizes a score from f32 to the given integer type, using the following
831/// formula `(score/quant.scale + quant.zero_point).ceil()`, then clamping to
832/// the min and max value of the given integer type
833///
834/// # Examples
835/// ```rust
836/// use edgefirst_decoder::{Quantization, byte::quantize_score_threshold};
837/// let quant = Quantization {
838///     scale: 0.1,
839///     zero_point: 128,
840/// };
841/// let q: u8 = quantize_score_threshold::<u8>(0.5, quant);
842/// assert_eq!(q, 128 + 5);
843/// ```
844#[doc(hidden)]
845pub fn quantize_score_threshold<T: PrimInt + AsPrimitive<f32>>(score: f32, quant: Quantization) -> T
846where
847    f32: AsPrimitive<T>,
848{
849    if quant.scale == 0.0 {
850        return T::max_value();
851    }
852    let v = (score / quant.scale + quant.zero_point as f32).ceil();
853    let v = v.clamp(T::min_value().as_(), T::max_value().as_());
854    v.as_()
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860    use crate::XYWH;
861    use ndarray::Array2;
862
863    /// Verify that the column-major path produces identical results to the
864    /// row-major path for a transposed (non-contiguous) score array.
865    #[test]
866    fn column_major_matches_row_major() {
867        // Create scores in "model output" layout: [num_classes, num_candidates]
868        let n_classes = 80usize;
869        let n_candidates = 100usize;
870        let mut scores_physical = Array2::<u8>::zeros((n_classes, n_candidates));
871        // Fill with known pattern: class c, candidate i → (c * 3 + i * 7) % 256
872        for c in 0..n_classes {
873            for i in 0..n_candidates {
874                scores_physical[[c, i]] = ((c * 3 + i * 7) % 256) as u8;
875            }
876        }
877
878        // Create boxes: [4, num_candidates] i16
879        let mut boxes_physical = Array2::<i16>::zeros((4, n_candidates));
880        for i in 0..n_candidates {
881            boxes_physical[[0, i]] = (i * 10) as i16; // x
882            boxes_physical[[1, i]] = (i * 20) as i16; // y
883            boxes_physical[[2, i]] = (i * 10 + 50) as i16; // w
884            boxes_physical[[3, i]] = (i * 20 + 100) as i16; // h
885        }
886
887        let quant = Quantization {
888            scale: 0.00390625,
889            zero_point: 0,
890        };
891
892        let threshold: u8 = 10;
893
894        // Row-major path: contiguous [n_candidates, n_classes] array
895        let scores_contiguous = scores_physical.clone().reversed_axes().to_owned();
896        let boxes_contiguous = boxes_physical.clone().reversed_axes().to_owned();
897        let row_result = postprocess_boxes_index_quant::<XYWH, _, _>(
898            threshold,
899            boxes_contiguous.view(),
900            scores_contiguous.view(),
901            quant,
902        );
903
904        // Column-major path: non-contiguous reversed view
905        let scores_view = scores_physical.view().reversed_axes();
906        let boxes_view = boxes_physical.view().reversed_axes();
907        assert!(scores_view.as_slice().is_none(), "should be non-contiguous");
908        assert_eq!(scores_view.strides()[0], 1);
909        let col_result =
910            postprocess_boxes_index_quant::<XYWH, _, _>(threshold, boxes_view, scores_view, quant);
911
912        // Both paths should produce the same results
913        assert_eq!(
914            row_result.len(),
915            col_result.len(),
916            "different number of results: row={}, col={}",
917            row_result.len(),
918            col_result.len()
919        );
920        for (i, (row, col)) in row_result.iter().zip(col_result.iter()).enumerate() {
921            assert_eq!(
922                row.0.label, col.0.label,
923                "candidate {i}: label mismatch row={} col={}",
924                row.0.label, col.0.label
925            );
926            assert_eq!(row.0.score, col.0.score, "candidate {i}: score mismatch");
927            assert_eq!(row.1, col.1, "candidate {i}: index mismatch");
928            assert_eq!(row.0.bbox, col.0.bbox, "candidate {i}: bbox mismatch");
929        }
930    }
931
932    /// Test column-major path with i8 scores (signed, matches NEON argmax path).
933    #[test]
934    fn column_major_matches_row_major_i8() {
935        let n_classes = 80usize;
936        let n_candidates = 50usize;
937        let mut scores_physical = Array2::<i8>::zeros((n_classes, n_candidates));
938        for c in 0..n_classes {
939            for i in 0..n_candidates {
940                scores_physical[[c, i]] = ((c as i16 * 3 + i as i16 * 7) % 256 - 128) as i8;
941            }
942        }
943
944        let mut boxes_physical = Array2::<i16>::zeros((4, n_candidates));
945        for i in 0..n_candidates {
946            boxes_physical[[0, i]] = (i * 10) as i16;
947            boxes_physical[[1, i]] = (i * 20) as i16;
948            boxes_physical[[2, i]] = (i * 10 + 50) as i16;
949            boxes_physical[[3, i]] = (i * 20 + 100) as i16;
950        }
951
952        let quant = Quantization {
953            scale: 0.0256,
954            zero_point: -116,
955        };
956        let threshold: i8 = -100;
957
958        let scores_contiguous = scores_physical.clone().reversed_axes().to_owned();
959        let boxes_contiguous = boxes_physical.clone().reversed_axes().to_owned();
960        let row_result = postprocess_boxes_index_quant::<XYWH, _, _>(
961            threshold,
962            boxes_contiguous.view(),
963            scores_contiguous.view(),
964            quant,
965        );
966
967        let scores_view = scores_physical.view().reversed_axes();
968        let boxes_view = boxes_physical.view().reversed_axes();
969        let col_result =
970            postprocess_boxes_index_quant::<XYWH, _, _>(threshold, boxes_view, scores_view, quant);
971
972        assert_eq!(row_result.len(), col_result.len());
973        for (i, (row, col)) in row_result.iter().zip(col_result.iter()).enumerate() {
974            assert_eq!(row.0.label, col.0.label, "i8 candidate {i}: label mismatch");
975            assert_eq!(row.0.score, col.0.score, "i8 candidate {i}: score mismatch");
976            assert_eq!(row.1, col.1, "i8 candidate {i}: index mismatch");
977        }
978    }
979
980    /// Helper: create `n` non-overlapping boxes with descending u8 scores.
981    fn make_nms_boxes_int(n: usize) -> Vec<DetectBoxQuantized<u8>> {
982        (0..n)
983            .map(|i| DetectBoxQuantized {
984                bbox: BoundingBox {
985                    xmin: i as f32 * 100.0,
986                    ymin: 0.0,
987                    xmax: i as f32 * 100.0 + 10.0,
988                    ymax: 10.0,
989                },
990                label: 0,
991                score: (200 - i as u32).min(255) as u8,
992            })
993            .collect()
994    }
995
996    #[test]
997    fn nms_int_max_det_matches_full_truncated() {
998        let boxes = make_nms_boxes_int(20);
999        let n = 5;
1000        let full = nms_int(0.5, None, boxes.clone());
1001        let capped = nms_int(0.5, Some(n), boxes);
1002        assert_eq!(capped.len(), n);
1003        assert_eq!(&full[..n], &capped[..]);
1004    }
1005
1006    #[test]
1007    fn nms_int_max_det_zero_returns_empty() {
1008        let boxes = make_nms_boxes_int(10);
1009        let result = nms_int(0.5, Some(0), boxes);
1010        assert!(result.is_empty());
1011    }
1012
1013    #[test]
1014    fn nms_int_max_det_iou_ge_1_returns_sorted_truncated() {
1015        let boxes = make_nms_boxes_int(10);
1016        let result = nms_int(1.0, Some(3), boxes);
1017        assert_eq!(result.len(), 3);
1018        // Scores should be in descending order (sorted).
1019        assert!(result[0].score >= result[1].score);
1020        assert!(result[1].score >= result[2].score);
1021    }
1022
1023    #[test]
1024    fn nms_int_max_det_larger_than_input() {
1025        let boxes = make_nms_boxes_int(5);
1026        let full = nms_int(0.5, None, boxes.clone());
1027        let capped = nms_int(0.5, Some(100), boxes);
1028        assert_eq!(full.len(), capped.len());
1029    }
1030}