Skip to main content

edgefirst_decoder/
yolo.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! Internal YOLO decoder kernels.
5//!
6//! All items in this module are `pub(crate)`. The public API surface for
7//! decoding is [`crate::Decoder`] + [`crate::DecoderBuilder`]; external
8//! callers must go through that entry point so we can evolve the kernels
9//! (split paths, dispatch tables, NEON tiers) without breaking semver.
10//! See `CHANGELOG.md` for the 0.20.0 narrowing.
11
12use std::fmt::Debug;
13
14use ndarray::{
15    parallel::prelude::{IntoParallelIterator, ParallelIterator},
16    s, Array2, Array3, ArrayView1, ArrayView2, ArrayView3,
17};
18use num_traits::{AsPrimitive, Float, PrimInt, Signed};
19
20use crate::{
21    byte::{
22        nms_class_aware_int, nms_extra_class_aware_int, nms_extra_int, nms_int,
23        postprocess_boxes_index_quant, postprocess_boxes_multilabel_index_quant,
24        postprocess_boxes_multilabel_quant, postprocess_boxes_quant, quantize_score_threshold,
25    },
26    configs::Nms,
27    dequant_detect_box,
28    float::{
29        nms_class_aware_float, nms_extra_class_aware_float, nms_extra_float, nms_float,
30        postprocess_boxes_float, postprocess_boxes_index_float, postprocess_boxes_multilabel_float,
31        postprocess_boxes_multilabel_index_float,
32    },
33    BBoxTypeTrait, BoundingBox, DetectBox, DetectBoxQuantized, ProtoData, ProtoLayout,
34    Quantization, Segmentation, XYWH, XYXY,
35};
36
37/// Maximum number of above-threshold candidates fed to NMS.
38///
39/// At very low score thresholds (e.g., t=0.01 on YOLOv8 with
40/// 8400 anchors × 80 classes), the number of survivors approaches
41/// the full 672 000-entry score grid. NMS is O(n²) and the
42/// downstream mask matmul runs once per survivor, so an
43/// unbounded set produces minutes-per-frame decode times.
44///
45/// `MAX_NMS_CANDIDATES` matches the Ultralytics `max_nms` default
46/// and is applied as a top-K-by-score truncation immediately
47/// before NMS. Values above the cap are silently dropped — at the
48/// score thresholds where the cap activates the bottom of the
49/// candidate list is dominated by noise that NMS would discard
50/// anyway.
51///
52/// Production callers configure this via [`crate::DecoderBuilder::with_pre_nms_top_k`];
53/// only the test-only seg-det wrappers reach for this constant directly.
54#[cfg(test)]
55pub(crate) const MAX_NMS_CANDIDATES: usize = 30_000;
56
57/// Default detection cap for the test-only shims when the output `Vec` has
58/// no capacity; matches the `DecoderBuilder` `max_det` default.
59#[cfg(test)]
60pub(crate) const DEFAULT_MAX_DETECTIONS: usize = 300;
61
62/// A decode candidate the pre-NMS cap, NMS and the `max_det` cap rank by
63/// score: a float or quantized box, alone or with a payload (anchor index,
64/// mask coefficients).
65pub(crate) trait ScoredCandidate: Sized + Send + Sync {
66    /// Score used for ranking. Quantized boxes rank on the raw quantized
67    /// score, which preserves order under monotonic dequantization.
68    type Score: Copy;
69
70    fn score_key(&self) -> Self::Score;
71
72    /// Ascending comparison of two scores.
73    fn cmp_score(a: &Self::Score, b: &Self::Score) -> std::cmp::Ordering;
74
75    /// Run NMS in `mode` over `boxes`, stopping once `max_det` survive.
76    fn suppress(mode: Nms, iou: f32, max_det: usize, boxes: Vec<Self>) -> Vec<Self>;
77}
78
79impl ScoredCandidate for DetectBox {
80    type Score = f32;
81    fn score_key(&self) -> f32 {
82        self.score
83    }
84    fn cmp_score(a: &f32, b: &f32) -> std::cmp::Ordering {
85        a.total_cmp(b)
86    }
87    fn suppress(mode: Nms, iou: f32, max_det: usize, boxes: Vec<Self>) -> Vec<Self> {
88        dispatch_nms_float(Some(mode), iou, Some(max_det), boxes)
89    }
90}
91
92impl<S: PrimInt + AsPrimitive<f32> + Send + Sync> ScoredCandidate for DetectBoxQuantized<S> {
93    type Score = S;
94    fn score_key(&self) -> S {
95        self.score
96    }
97    fn cmp_score(a: &S, b: &S) -> std::cmp::Ordering {
98        a.cmp(b)
99    }
100    fn suppress(mode: Nms, iou: f32, max_det: usize, boxes: Vec<Self>) -> Vec<Self> {
101        dispatch_nms_int(Some(mode), iou, Some(max_det), boxes)
102    }
103}
104
105impl<E: Send + Sync> ScoredCandidate for (DetectBox, E) {
106    type Score = f32;
107    fn score_key(&self) -> f32 {
108        self.0.score
109    }
110    fn cmp_score(a: &f32, b: &f32) -> std::cmp::Ordering {
111        a.total_cmp(b)
112    }
113    fn suppress(mode: Nms, iou: f32, max_det: usize, boxes: Vec<Self>) -> Vec<Self> {
114        dispatch_nms_extra_float(Some(mode), iou, Some(max_det), boxes)
115    }
116}
117
118impl<S: PrimInt + AsPrimitive<f32> + Send + Sync, E: Send + Sync> ScoredCandidate
119    for (DetectBoxQuantized<S>, E)
120{
121    type Score = S;
122    fn score_key(&self) -> S {
123        self.0.score
124    }
125    fn cmp_score(a: &S, b: &S) -> std::cmp::Ordering {
126        a.cmp(b)
127    }
128    fn suppress(mode: Nms, iou: f32, max_det: usize, boxes: Vec<Self>) -> Vec<Self> {
129        dispatch_nms_extra_int(Some(mode), iou, Some(max_det), boxes)
130    }
131}
132
133/// Keep the `top_k` highest-scoring candidates in place, in their original
134/// order. No-op when `top_k` is 0 (unbounded) or when `boxes.len() <= top_k`.
135///
136/// Candidates arrive in anchor order (then class order under multi-label),
137/// so equal scores at the cut keep the lower anchor index, which makes the
138/// selection reproducible against a reference decoder. Runs in O(N): one
139/// partial select over a copy of the scores, then one `retain` pass.
140pub(crate) fn truncate_to_top_k_by_score<T: ScoredCandidate>(boxes: &mut Vec<T>, top_k: usize) {
141    use std::cmp::Ordering;
142    if top_k == 0 || boxes.len() <= top_k {
143        return;
144    }
145    let mut scores: Vec<T::Score> = boxes.iter().map(T::score_key).collect();
146    let (higher, kth, _) = scores.select_nth_unstable_by(top_k - 1, |a, b| T::cmp_score(b, a));
147    let kth = *kth;
148    let above = higher
149        .iter()
150        .filter(|s| T::cmp_score(s, &kth) == Ordering::Greater)
151        .count();
152    let mut ties = top_k - above;
153    boxes.retain(|b| match T::cmp_score(&b.score_key(), &kth) {
154        Ordering::Greater => true,
155        Ordering::Equal if ties > 0 => {
156            ties -= 1;
157            true
158        }
159        _ => false,
160    });
161}
162
163/// Cap, suppress and cap again: the shared tail of every NMS decode path.
164///
165/// With NMS enabled, keeps the `pre_nms_top_k` highest-scoring candidates
166/// (0 = unbounded), runs NMS in `nms`'s mode, and returns at most `max_det`
167/// survivors in descending score order. With NMS bypassed (`nms = None`),
168/// returns the `max_det` highest-scoring candidates in descending score
169/// order. Equal scores keep candidate (anchor) order throughout.
170pub(crate) fn nms_and_cap<T: ScoredCandidate>(
171    mut boxes: Vec<T>,
172    nms: Option<Nms>,
173    iou_threshold: f32,
174    pre_nms_top_k: usize,
175    max_det: usize,
176) -> Vec<T> {
177    let mut boxes = match nms {
178        Some(mode) => {
179            {
180                let _s = tracing::trace_span!(
181                    "decoder.nms_get_boxes.top_k",
182                    k = pre_nms_top_k,
183                    n = boxes.len()
184                )
185                .entered();
186                truncate_to_top_k_by_score(&mut boxes, pre_nms_top_k);
187            }
188            let _s =
189                tracing::trace_span!("decoder.nms_get_boxes.suppress", n = boxes.len()).entered();
190            T::suppress(mode, iou_threshold, max_det, boxes)
191        }
192        None => {
193            truncate_to_top_k_by_score(&mut boxes, max_det);
194            boxes.sort_by(|a, b| T::cmp_score(&b.score_key(), &a.score_key()));
195            boxes
196        }
197    };
198    boxes.truncate(max_det);
199    boxes
200}
201
202/// Dispatches to the appropriate NMS function based on mode for float boxes.
203///
204/// `max_det` is the post-NMS detection cap; when present it lets the greedy
205/// inner loop break as soon as that many survivors are confirmed (the survivors
206/// are guaranteed to be the top-`max_det` by score because the input is sorted
207/// descending). Pass `None` to run the full O(N²) suppression.
208///
209/// `nms` must be a concrete mode: the builder resolves `Nms::Auto` before a
210/// `Decoder` exists.
211pub(crate) fn dispatch_nms_float(
212    nms: Option<Nms>,
213    iou: f32,
214    max_det: Option<usize>,
215    boxes: Vec<DetectBox>,
216) -> Vec<DetectBox> {
217    debug_assert_ne!(
218        nms,
219        Some(Nms::Auto),
220        "Nms::Auto must be resolved by the builder"
221    );
222    match nms {
223        Some(Nms::ClassAgnostic) => nms_float(iou, max_det, boxes),
224        Some(Nms::ClassAware | Nms::Auto) => nms_class_aware_float(iou, max_det, boxes),
225        None => boxes, // bypass NMS
226    }
227}
228
229/// Dispatches to the appropriate NMS function based on mode for float boxes
230/// with extra data. `nms` must be a concrete mode (see [`dispatch_nms_float`]).
231pub(super) fn dispatch_nms_extra_float<E: Send + Sync>(
232    nms: Option<Nms>,
233    iou: f32,
234    max_det: Option<usize>,
235    boxes: Vec<(DetectBox, E)>,
236) -> Vec<(DetectBox, E)> {
237    debug_assert_ne!(
238        nms,
239        Some(Nms::Auto),
240        "Nms::Auto must be resolved by the builder"
241    );
242    match nms {
243        Some(Nms::ClassAgnostic) => nms_extra_float(iou, max_det, boxes),
244        Some(Nms::ClassAware | Nms::Auto) => nms_extra_class_aware_float(iou, max_det, boxes),
245        None => boxes, // bypass NMS
246    }
247}
248
249/// Dispatches to the appropriate NMS function based on mode for quantized
250/// boxes. `nms` must be a concrete mode (see [`dispatch_nms_float`]).
251pub(crate) fn dispatch_nms_int<SCORE: PrimInt + AsPrimitive<f32> + Send + Sync>(
252    nms: Option<Nms>,
253    iou: f32,
254    max_det: Option<usize>,
255    boxes: Vec<DetectBoxQuantized<SCORE>>,
256) -> Vec<DetectBoxQuantized<SCORE>> {
257    debug_assert_ne!(
258        nms,
259        Some(Nms::Auto),
260        "Nms::Auto must be resolved by the builder"
261    );
262    match nms {
263        Some(Nms::ClassAgnostic) => nms_int(iou, max_det, boxes),
264        Some(Nms::ClassAware | Nms::Auto) => nms_class_aware_int(iou, max_det, boxes),
265        None => boxes, // bypass NMS
266    }
267}
268
269/// Dispatches to the appropriate NMS function based on mode for quantized boxes
270/// with extra data. `nms` must be a concrete mode (see [`dispatch_nms_float`]).
271fn dispatch_nms_extra_int<SCORE: PrimInt + AsPrimitive<f32> + Send + Sync, E: Send + Sync>(
272    nms: Option<Nms>,
273    iou: f32,
274    max_det: Option<usize>,
275    boxes: Vec<(DetectBoxQuantized<SCORE>, E)>,
276) -> Vec<(DetectBoxQuantized<SCORE>, E)> {
277    debug_assert_ne!(
278        nms,
279        Some(Nms::Auto),
280        "Nms::Auto must be resolved by the builder"
281    );
282    match nms {
283        Some(Nms::ClassAgnostic) => nms_extra_int(iou, max_det, boxes),
284        Some(Nms::ClassAware | Nms::Auto) => nms_extra_class_aware_int(iou, max_det, boxes),
285        None => boxes, // bypass NMS
286    }
287}
288
289/// Detection cap for the test-only seg-det shims: the caller's `Vec`
290/// capacity, else [`DEFAULT_MAX_DETECTIONS`].
291#[cfg(test)]
292#[inline]
293pub(crate) fn cap_or_default<T>(v: &Vec<T>) -> usize {
294    if v.capacity() > 0 {
295        v.capacity()
296    } else {
297        DEFAULT_MAX_DETECTIONS
298    }
299}
300
301// ─── Public free decode_yolo_* convenience wrappers ────────────────────────
302//
303// These crate-internal wrappers take the detection cap (`max_det`) and the
304// pre-NMS candidate cap (`pre_nms_top_k`) explicitly, so `Decoder` bounds
305// every path by its own settings; a caller's `Vec` capacity is only an
306// allocation hint.
307
308/// Decodes YOLO detection outputs from quantized tensors into detection boxes.
309///
310/// Boxes are expected to be in XYWH format.
311///
312/// Expected shapes of inputs:
313/// - output: (4 + num_classes, num_boxes)
314///
315/// At most `pre_nms_top_k` candidates (0 = unbounded) enter NMS, and at most
316/// `max_det` detections are returned.
317///
318/// # Errors
319/// Returns [`DecoderError::InvalidShape`](crate::DecoderError::InvalidShape)
320/// when boxes are not `[N, 4]` or scores do not have one row per box.
321#[allow(clippy::too_many_arguments)]
322pub(crate) fn decode_yolo_det<BOX: PrimInt + AsPrimitive<f32> + Send + Sync>(
323    output: (ArrayView2<BOX>, Quantization),
324    score_threshold: f32,
325    iou_threshold: f32,
326    nms: Option<Nms>,
327    pre_nms_top_k: usize,
328    max_det: usize,
329    multi_label: bool,
330    output_boxes: &mut Vec<DetectBox>,
331) -> crate::DecoderResult<()>
332where
333    f32: AsPrimitive<BOX>,
334{
335    impl_yolo_quant::<XYWH, _>(
336        output,
337        score_threshold,
338        iou_threshold,
339        nms,
340        pre_nms_top_k,
341        max_det,
342        multi_label,
343        output_boxes,
344    )
345}
346
347/// Decodes YOLO detection outputs from float tensors into detection boxes.
348///
349/// Boxes are expected to be in XYWH format.
350///
351/// Expected shapes of inputs:
352/// - output: (4 + num_classes, num_boxes)
353///
354/// # Errors
355/// Returns [`DecoderError::InvalidShape`](crate::DecoderError::InvalidShape)
356/// when boxes are not `[N, 4]` or scores do not have one row per box.
357#[allow(clippy::too_many_arguments)]
358pub(crate) fn decode_yolo_det_float<T>(
359    output: ArrayView2<T>,
360    score_threshold: f32,
361    iou_threshold: f32,
362    nms: Option<Nms>,
363    pre_nms_top_k: usize,
364    max_det: usize,
365    multi_label: bool,
366    output_boxes: &mut Vec<DetectBox>,
367) -> crate::DecoderResult<()>
368where
369    T: Float + AsPrimitive<f32> + Send + Sync + 'static,
370    f32: AsPrimitive<T>,
371{
372    impl_yolo_float::<XYWH, _>(
373        output,
374        score_threshold,
375        iou_threshold,
376        nms,
377        pre_nms_top_k,
378        max_det,
379        multi_label,
380        output_boxes,
381    )
382}
383
384/// Test-only seg-det quantized decode shim.
385///
386/// Production callers go through [`crate::Decoder::decode`]; this wrapper
387/// exists only so the parity tests in `lib.rs` and `yolo.rs` can compare the
388/// kernel output against the Decoder output without duplicating the
389/// `impl_yolo_segdet_quant` argument plumbing.
390///
391/// Boxes are expected to be in XYWH format. Expected shapes:
392/// - `boxes`: `(4 + num_classes + num_protos, num_boxes)`
393/// - `protos`: `(proto_height, proto_width, num_protos)`
394///
395/// # Errors
396/// Returns `DecoderError::InvalidShape` if bounding boxes are not normalized.
397#[cfg(test)]
398pub(crate) fn decode_yolo_segdet_quant<
399    BOX: PrimInt + AsPrimitive<i64> + AsPrimitive<i128> + AsPrimitive<f32> + Send + Sync,
400    PROTO: PrimInt + AsPrimitive<i64> + AsPrimitive<i128> + AsPrimitive<f32> + Send + Sync,
401>(
402    boxes: (ArrayView2<BOX>, Quantization),
403    protos: (ArrayView3<PROTO>, Quantization),
404    score_threshold: f32,
405    iou_threshold: f32,
406    nms: Option<Nms>,
407    output_boxes: &mut Vec<DetectBox>,
408    output_masks: &mut Vec<Segmentation>,
409) -> Result<(), crate::DecoderError>
410where
411    f32: AsPrimitive<BOX>,
412{
413    // Pre-Decoder convenience wrapper: no schema-derived `normalized`
414    // or `input_dims`, so the EDGEAI-1303 normalization is a no-op.
415    // Callers that need that path should go through `DecoderBuilder`
416    // with a schema.
417    let cap = cap_or_default(output_boxes);
418    impl_yolo_segdet_quant::<XYWH, _, _>(
419        boxes,
420        protos,
421        score_threshold,
422        iou_threshold,
423        nms,
424        MAX_NMS_CANDIDATES,
425        cap,
426        None,
427        None,
428        false, // multi_label: test shim stays on argmax
429        output_boxes,
430        output_masks,
431    )
432}
433
434/// Test-only seg-det float decode shim. See [`decode_yolo_segdet_quant`].
435#[cfg(test)]
436pub(crate) fn decode_yolo_segdet_float<T>(
437    boxes: ArrayView2<T>,
438    protos: ArrayView3<T>,
439    score_threshold: f32,
440    iou_threshold: f32,
441    nms: Option<Nms>,
442    output_boxes: &mut Vec<DetectBox>,
443    output_masks: &mut Vec<Segmentation>,
444) -> Result<(), crate::DecoderError>
445where
446    T: Float + AsPrimitive<f32> + Send + Sync + 'static,
447    f32: AsPrimitive<T>,
448{
449    // Pre-Decoder convenience wrapper: schema-derived normalization is
450    // not available here (see EDGEAI-1303 note in the quantized sibling).
451    let cap = cap_or_default(output_boxes);
452    impl_yolo_segdet_float::<XYWH, _, _>(
453        boxes,
454        protos,
455        score_threshold,
456        iou_threshold,
457        nms,
458        MAX_NMS_CANDIDATES,
459        cap,
460        None,
461        None,
462        false, // multi_label: test shim stays on argmax
463        output_boxes,
464        output_masks,
465    )
466}
467
468/// Decodes YOLO split detection outputs from quantized tensors into detection
469/// boxes.
470///
471/// Boxes are expected to be in XYWH format.
472///
473/// Expected shapes of inputs:
474/// - boxes: (4, num_boxes)
475/// - scores: (num_classes, num_boxes)
476///
477/// # Panics
478/// Panics if shapes don't match the expected dimensions (argmax path).
479///
480/// # Errors
481/// Returns [`DecoderError::InvalidShape`](crate::DecoderError::InvalidShape)
482/// when boxes are not `[N, 4]` or scores do not have one row per box.
483#[allow(clippy::too_many_arguments)]
484pub(crate) fn decode_yolo_split_det_quant<
485    BOX: PrimInt + AsPrimitive<i32> + AsPrimitive<f32> + Send + Sync,
486    SCORE: PrimInt + AsPrimitive<f32> + Send + Sync,
487>(
488    boxes: (ArrayView2<BOX>, Quantization),
489    scores: (ArrayView2<SCORE>, Quantization),
490    score_threshold: f32,
491    iou_threshold: f32,
492    nms: Option<Nms>,
493    pre_nms_top_k: usize,
494    max_det: usize,
495    multi_label: bool,
496    output_boxes: &mut Vec<DetectBox>,
497) -> crate::DecoderResult<()>
498where
499    f32: AsPrimitive<SCORE>,
500{
501    impl_yolo_split_quant::<XYWH, _, _>(
502        boxes,
503        scores,
504        score_threshold,
505        iou_threshold,
506        nms,
507        pre_nms_top_k,
508        max_det,
509        multi_label,
510        output_boxes,
511    )
512}
513
514/// Decodes YOLO split detection outputs from float tensors into detection
515/// boxes.
516///
517/// Boxes are expected to be in XYWH format.
518///
519/// Expected shapes of inputs:
520/// - boxes: (4, num_boxes)
521/// - scores: (num_classes, num_boxes)
522///
523/// # Panics
524/// Panics if shapes don't match the expected dimensions (argmax path).
525///
526/// # Errors
527/// Returns [`DecoderError::InvalidShape`](crate::DecoderError::InvalidShape)
528/// when boxes are not `[N, 4]` or scores do not have one row per box.
529#[allow(clippy::too_many_arguments)]
530pub(crate) fn decode_yolo_split_det_float<T>(
531    boxes: ArrayView2<T>,
532    scores: ArrayView2<T>,
533    score_threshold: f32,
534    iou_threshold: f32,
535    nms: Option<Nms>,
536    pre_nms_top_k: usize,
537    max_det: usize,
538    multi_label: bool,
539    output_boxes: &mut Vec<DetectBox>,
540) -> crate::DecoderResult<()>
541where
542    T: Float + AsPrimitive<f32> + Send + Sync + 'static,
543    f32: AsPrimitive<T>,
544{
545    impl_yolo_split_float::<XYWH, _, _>(
546        boxes,
547        scores,
548        score_threshold,
549        iou_threshold,
550        nms,
551        pre_nms_top_k,
552        max_det,
553        multi_label,
554        output_boxes,
555    )
556}
557
558/// Decodes end-to-end YOLO detection outputs (post-NMS from model).
559/// Expects an array of shape `(6, N)`, where the first dimension (rows)
560/// corresponds to the 6 per-detection features
561/// `[x1, y1, x2, y2, conf, class]` and the second dimension (columns)
562/// indexes the `N` detections.
563/// Boxes are output directly without NMS (the model already applied NMS).
564///
565/// Coordinates may be normalized `[0, 1]` or absolute pixel values depending
566/// on the model configuration. The caller should check
567/// `decoder.normalized_boxes()` to determine which.
568///
569/// # Errors
570///
571/// Returns `DecoderError::InvalidShape` if `output` has fewer than 6 rows.
572pub(crate) fn decode_yolo_end_to_end_det_float<T>(
573    output: ArrayView2<T>,
574    score_threshold: f32,
575    max_det: usize,
576    output_boxes: &mut Vec<DetectBox>,
577) -> Result<(), crate::DecoderError>
578where
579    T: Float + AsPrimitive<f32> + Send + Sync + 'static,
580    f32: AsPrimitive<T>,
581{
582    // Validate input shape: need at least 6 rows (x1, y1, x2, y2, conf, class)
583    if output.shape()[0] < 6 {
584        return Err(crate::DecoderError::InvalidShape(format!(
585            "End-to-end detection output requires at least 6 rows, got {}",
586            output.shape()[0]
587        )));
588    }
589
590    // Input shape: (6, N) -> transpose to (N, 4) for boxes and (N, 1) for scores
591    let boxes = output.slice(s![0..4, ..]).reversed_axes();
592    let scores = output.slice(s![4..5, ..]).reversed_axes();
593    let classes = output.slice(s![5, ..]);
594    let mut boxes =
595        postprocess_boxes_index_float::<XYXY, _, _>(score_threshold.as_(), boxes, scores);
596    boxes.truncate(max_det);
597    output_boxes.clear();
598    for (mut b, i) in boxes.into_iter() {
599        b.label = classes[i].as_() as usize;
600        output_boxes.push(b);
601    }
602    // No NMS — model output is already post-NMS
603    Ok(())
604}
605
606/// Decodes end-to-end YOLO detection + segmentation outputs (post-NMS from
607/// model).
608///
609/// Input shapes:
610/// - detection: (6 + num_protos, N) where rows are [x1, y1, x2, y2, conf,
611///   class, mask_coeff_0, ..., mask_coeff_31]
612/// - protos: (proto_height, proto_width, num_protos)
613///
614/// Boxes are output directly without NMS (model already applied NMS).
615/// Coordinates may be normalized [0,1] or pixel values depending on model
616/// config.
617///
618/// # Errors
619///
620/// Returns `DecoderError::InvalidShape` if:
621/// - output has fewer than 7 rows (6 base + at least 1 mask coefficient)
622/// - protos shape doesn't match mask coefficients count
623pub(crate) fn decode_yolo_end_to_end_segdet_float<T>(
624    output: ArrayView2<T>,
625    protos: ArrayView3<T>,
626    score_threshold: f32,
627    max_det: usize,
628    output_boxes: &mut Vec<DetectBox>,
629    output_masks: &mut Vec<crate::Segmentation>,
630) -> Result<(), crate::DecoderError>
631where
632    T: Float + AsPrimitive<f32> + Send + Sync + 'static,
633    f32: AsPrimitive<T>,
634{
635    let (boxes, scores, classes, mask_coeff) =
636        postprocess_yolo_end_to_end_segdet(&output, protos.dim().2)?;
637    let cap = max_det;
638    let boxes = impl_yolo_end_to_end_segdet_get_boxes::<XYXY, _, _, _>(
639        boxes,
640        scores,
641        classes,
642        score_threshold,
643        cap,
644    );
645
646    // No NMS — model output is already post-NMS
647
648    impl_yolo_split_segdet_process_masks(boxes, mask_coeff, protos, output_boxes, output_masks)
649}
650
651/// Decodes split end-to-end YOLO detection outputs (post-NMS from model).
652///
653/// Input shapes (after batch dim removed):
654/// - boxes: (4, N) — xyxy pixel coordinates
655/// - scores: (1, N) — confidence of the top class
656/// - classes: (1, N) — class index of the top class
657///
658/// Boxes are output directly without NMS (model already applied NMS).
659pub(crate) fn decode_yolo_split_end_to_end_det_float<T: Float + AsPrimitive<f32>>(
660    boxes: ArrayView2<T>,
661    scores: ArrayView2<T>,
662    classes: ArrayView2<T>,
663    score_threshold: f32,
664    max_det: usize,
665    output_boxes: &mut Vec<DetectBox>,
666) -> Result<(), crate::DecoderError> {
667    let n = boxes.shape()[1];
668
669    let cap = max_det;
670    output_boxes.clear();
671
672    let (boxes, scores, classes) = postprocess_yolo_split_end_to_end_det(boxes, scores, &classes)?;
673
674    for i in 0..n {
675        let score: f32 = scores[[i, 0]].as_();
676        if score < score_threshold {
677            continue;
678        }
679        if output_boxes.len() >= cap {
680            break;
681        }
682        output_boxes.push(DetectBox {
683            bbox: BoundingBox {
684                xmin: boxes[[i, 0]].as_(),
685                ymin: boxes[[i, 1]].as_(),
686                xmax: boxes[[i, 2]].as_(),
687                ymax: boxes[[i, 3]].as_(),
688            },
689            score,
690            label: classes[i].as_() as usize,
691        });
692    }
693    Ok(())
694}
695
696/// Decodes split end-to-end YOLO detection + segmentation outputs.
697///
698/// Input shapes (after batch dim removed):
699/// - boxes: (4, N) — xyxy pixel coordinates
700/// - scores: (1, N) — confidence
701/// - classes: (1, N) — class index
702/// - mask_coeff: (num_protos, N) — mask coefficients per detection
703/// - protos: (proto_h, proto_w, num_protos) — prototype masks
704#[allow(clippy::too_many_arguments)]
705pub(crate) fn decode_yolo_split_end_to_end_segdet_float<T>(
706    boxes: ArrayView2<T>,
707    scores: ArrayView2<T>,
708    classes: ArrayView2<T>,
709    mask_coeff: ArrayView2<T>,
710    protos: ArrayView3<T>,
711    score_threshold: f32,
712    max_det: usize,
713    output_boxes: &mut Vec<DetectBox>,
714    output_masks: &mut Vec<crate::Segmentation>,
715) -> Result<(), crate::DecoderError>
716where
717    T: Float + AsPrimitive<f32> + Send + Sync + 'static,
718    f32: AsPrimitive<T>,
719{
720    let (boxes, scores, classes, mask_coeff) =
721        postprocess_yolo_split_end_to_end_segdet(boxes, scores, &classes, mask_coeff)?;
722    let cap = max_det;
723    let boxes = impl_yolo_end_to_end_segdet_get_boxes::<XYXY, _, _, _>(
724        boxes,
725        scores,
726        classes,
727        score_threshold,
728        cap,
729    );
730
731    impl_yolo_split_segdet_process_masks(boxes, mask_coeff, protos, output_boxes, output_masks)
732}
733
734#[allow(clippy::type_complexity)]
735pub(crate) fn postprocess_yolo_end_to_end_segdet<'a, T>(
736    output: &'a ArrayView2<'_, T>,
737    num_protos: usize,
738) -> Result<
739    (
740        ArrayView2<'a, T>,
741        ArrayView2<'a, T>,
742        ArrayView1<'a, T>,
743        ArrayView2<'a, T>,
744    ),
745    crate::DecoderError,
746> {
747    // Validate input shape: need at least 7 rows (6 base + at least 1 mask coeff)
748    if output.shape()[0] < 7 {
749        return Err(crate::DecoderError::InvalidShape(format!(
750            "End-to-end segdet output requires at least 7 rows, got {}",
751            output.shape()[0]
752        )));
753    }
754
755    let num_mask_coeffs = output.shape()[0] - 6;
756    if num_mask_coeffs != num_protos {
757        return Err(crate::DecoderError::InvalidShape(format!(
758            "Mask coefficients count ({}) doesn't match protos count ({})",
759            num_mask_coeffs, num_protos
760        )));
761    }
762
763    // Input shape: (6+num_protos, N) -> transpose for postprocessing
764    let boxes = output.slice(s![0..4, ..]).reversed_axes();
765    let scores = output.slice(s![4..5, ..]).reversed_axes();
766    let classes = output.slice(s![5, ..]);
767    let mask_coeff = output.slice(s![6.., ..]).reversed_axes();
768    Ok((boxes, scores, classes, mask_coeff))
769}
770
771/// Postprocess yolo split end to end det by reversing axes of boxes,
772/// scores, and flattening the class tensor.
773/// Expected input shapes:
774/// - boxes: (4, N)
775/// - scores: (1, N)
776/// - classes: (1, N)
777#[allow(clippy::type_complexity)]
778pub(crate) fn postprocess_yolo_split_end_to_end_det<'a, 'b, 'c, BOXES, SCORES, CLASS>(
779    boxes: ArrayView2<'a, BOXES>,
780    scores: ArrayView2<'b, SCORES>,
781    classes: &'c ArrayView2<CLASS>,
782) -> Result<
783    (
784        ArrayView2<'a, BOXES>,
785        ArrayView2<'b, SCORES>,
786        ArrayView1<'c, CLASS>,
787    ),
788    crate::DecoderError,
789> {
790    let num_boxes = boxes.shape()[1];
791    if boxes.shape()[0] != 4 {
792        return Err(crate::DecoderError::InvalidShape(format!(
793            "Split end-to-end box_coords must be 4, got {}",
794            boxes.shape()[0]
795        )));
796    }
797
798    if scores.shape()[0] != 1 {
799        return Err(crate::DecoderError::InvalidShape(format!(
800            "Split end-to-end scores num_classes must be 1, got {}",
801            scores.shape()[0]
802        )));
803    }
804
805    if classes.shape()[0] != 1 {
806        return Err(crate::DecoderError::InvalidShape(format!(
807            "Split end-to-end classes num_classes must be 1, got {}",
808            classes.shape()[0]
809        )));
810    }
811
812    if scores.shape()[1] != num_boxes {
813        return Err(crate::DecoderError::InvalidShape(format!(
814            "Split end-to-end scores must have same num_boxes as boxes ({}), got {}",
815            num_boxes,
816            scores.shape()[1]
817        )));
818    }
819
820    if classes.shape()[1] != num_boxes {
821        return Err(crate::DecoderError::InvalidShape(format!(
822            "Split end-to-end classes must have same num_boxes as boxes ({}), got {}",
823            num_boxes,
824            classes.shape()[1]
825        )));
826    }
827
828    let boxes = boxes.reversed_axes();
829    let scores = scores.reversed_axes();
830    let classes = classes.slice(s![0, ..]);
831    Ok((boxes, scores, classes))
832}
833
834/// Postprocess yolo split end to end segdet by reversing axes of boxes,
835/// scores, mask tensors and flattening the class tensor.
836#[allow(clippy::type_complexity)]
837pub(crate) fn postprocess_yolo_split_end_to_end_segdet<
838    'a,
839    'b,
840    'c,
841    'd,
842    BOXES,
843    SCORES,
844    CLASS,
845    MASK,
846>(
847    boxes: ArrayView2<'a, BOXES>,
848    scores: ArrayView2<'b, SCORES>,
849    classes: &'c ArrayView2<CLASS>,
850    mask_coeff: ArrayView2<'d, MASK>,
851) -> Result<
852    (
853        ArrayView2<'a, BOXES>,
854        ArrayView2<'b, SCORES>,
855        ArrayView1<'c, CLASS>,
856        ArrayView2<'d, MASK>,
857    ),
858    crate::DecoderError,
859> {
860    let num_boxes = boxes.shape()[1];
861    if boxes.shape()[0] != 4 {
862        return Err(crate::DecoderError::InvalidShape(format!(
863            "Split end-to-end box_coords must be 4, got {}",
864            boxes.shape()[0]
865        )));
866    }
867
868    if scores.shape()[0] != 1 {
869        return Err(crate::DecoderError::InvalidShape(format!(
870            "Split end-to-end scores num_classes must be 1, got {}",
871            scores.shape()[0]
872        )));
873    }
874
875    if classes.shape()[0] != 1 {
876        return Err(crate::DecoderError::InvalidShape(format!(
877            "Split end-to-end classes num_classes must be 1, got {}",
878            classes.shape()[0]
879        )));
880    }
881
882    if scores.shape()[1] != num_boxes {
883        return Err(crate::DecoderError::InvalidShape(format!(
884            "Split end-to-end scores must have same num_boxes as boxes ({}), got {}",
885            num_boxes,
886            scores.shape()[1]
887        )));
888    }
889
890    if classes.shape()[1] != num_boxes {
891        return Err(crate::DecoderError::InvalidShape(format!(
892            "Split end-to-end classes must have same num_boxes as boxes ({}), got {}",
893            num_boxes,
894            classes.shape()[1]
895        )));
896    }
897
898    if mask_coeff.shape()[1] != num_boxes {
899        return Err(crate::DecoderError::InvalidShape(format!(
900            "Split end-to-end mask_coeff must have same num_boxes as boxes ({}), got {}",
901            num_boxes,
902            mask_coeff.shape()[1]
903        )));
904    }
905
906    let boxes = boxes.reversed_axes();
907    let scores = scores.reversed_axes();
908    let classes = classes.slice(s![0, ..]);
909    let mask_coeff = mask_coeff.reversed_axes();
910    Ok((boxes, scores, classes, mask_coeff))
911}
912/// Internal implementation of YOLO decoding for quantized tensors.
913///
914/// Expected shapes of inputs:
915/// - output: (4 + num_classes, num_boxes)
916///
917/// # Errors
918/// Returns [`DecoderError::InvalidShape`](crate::DecoderError::InvalidShape)
919/// when boxes are not `[N, 4]` or scores do not have one row per box.
920#[allow(clippy::too_many_arguments)]
921pub(crate) fn impl_yolo_quant<B: BBoxTypeTrait, T: PrimInt + AsPrimitive<f32> + Send + Sync>(
922    output: (ArrayView2<T>, Quantization),
923    score_threshold: f32,
924    iou_threshold: f32,
925    nms: Option<Nms>,
926    pre_nms_top_k: usize,
927    max_det: usize,
928    multi_label: bool,
929    output_boxes: &mut Vec<DetectBox>,
930) -> crate::DecoderResult<()>
931where
932    f32: AsPrimitive<T>,
933{
934    let _span = tracing::trace_span!("decoder.decode.yolo_quant_flat").entered();
935    let (boxes, quant_boxes) = output;
936    let (boxes_tensor, scores_tensor) = postprocess_yolo(&boxes);
937
938    let boxes = {
939        let score_threshold = quantize_score_threshold(score_threshold, quant_boxes);
940        if multi_label {
941            postprocess_boxes_multilabel_quant::<B, _, _>(
942                score_threshold,
943                boxes_tensor,
944                scores_tensor,
945                quant_boxes,
946            )?
947        } else {
948            postprocess_boxes_quant::<B, _, _>(
949                score_threshold,
950                boxes_tensor,
951                scores_tensor,
952                quant_boxes,
953            )
954        }
955    };
956
957    let boxes = nms_and_cap(boxes, nms, iou_threshold, pre_nms_top_k, max_det);
958    output_boxes.clear();
959    output_boxes.extend(boxes.iter().map(|b| dequant_detect_box(b, quant_boxes)));
960    Ok(())
961}
962
963/// Internal implementation of YOLO decoding for float tensors.
964///
965/// Expected shapes of inputs:
966/// - output: (4 + num_classes, num_boxes)
967///
968/// # Errors
969/// Returns [`DecoderError::InvalidShape`](crate::DecoderError::InvalidShape)
970/// when boxes are not `[N, 4]` or scores do not have one row per box.
971#[allow(clippy::too_many_arguments)]
972pub(crate) fn impl_yolo_float<B: BBoxTypeTrait, T: Float + AsPrimitive<f32> + Send + Sync>(
973    output: ArrayView2<T>,
974    score_threshold: f32,
975    iou_threshold: f32,
976    nms: Option<Nms>,
977    pre_nms_top_k: usize,
978    max_det: usize,
979    multi_label: bool,
980    output_boxes: &mut Vec<DetectBox>,
981) -> crate::DecoderResult<()>
982where
983    f32: AsPrimitive<T>,
984{
985    let _span = tracing::trace_span!("decoder.decode.yolo_float_flat").entered();
986    let (boxes_tensor, scores_tensor) = postprocess_yolo(&output);
987    let boxes = if multi_label {
988        postprocess_boxes_multilabel_float::<B, _, _>(
989            score_threshold.as_(),
990            boxes_tensor,
991            scores_tensor,
992        )?
993    } else {
994        postprocess_boxes_float::<B, _, _>(score_threshold.as_(), boxes_tensor, scores_tensor)
995    };
996    output_boxes.clear();
997    output_boxes.extend(nms_and_cap(
998        boxes,
999        nms,
1000        iou_threshold,
1001        pre_nms_top_k,
1002        max_det,
1003    ));
1004    Ok(())
1005}
1006
1007/// Internal implementation of YOLO split detection decoding for quantized
1008/// tensors.
1009///
1010/// Expected shapes of inputs:
1011/// - boxes: (4, num_boxes)
1012/// - scores: (num_classes, num_boxes)
1013///
1014/// # Panics
1015/// Panics if shapes don't match the expected dimensions (argmax path).
1016///
1017/// # Errors
1018/// Returns [`DecoderError::InvalidShape`](crate::DecoderError::InvalidShape)
1019/// when boxes are not `[N, 4]` or scores do not have one row per box.
1020#[allow(clippy::too_many_arguments)]
1021pub(crate) fn impl_yolo_split_quant<
1022    B: BBoxTypeTrait,
1023    BOX: PrimInt + AsPrimitive<f32> + Send + Sync,
1024    SCORE: PrimInt + AsPrimitive<f32> + Send + Sync,
1025>(
1026    boxes: (ArrayView2<BOX>, Quantization),
1027    scores: (ArrayView2<SCORE>, Quantization),
1028    score_threshold: f32,
1029    iou_threshold: f32,
1030    nms: Option<Nms>,
1031    pre_nms_top_k: usize,
1032    max_det: usize,
1033    multi_label: bool,
1034    output_boxes: &mut Vec<DetectBox>,
1035) -> crate::DecoderResult<()>
1036where
1037    f32: AsPrimitive<SCORE>,
1038{
1039    let _span = tracing::trace_span!("decoder.decode.yolo_quant_split").entered();
1040    let (boxes_tensor, quant_boxes) = boxes;
1041    let (scores_tensor, quant_scores) = scores;
1042
1043    let boxes_tensor = boxes_tensor.reversed_axes();
1044    let scores_tensor = scores_tensor.reversed_axes();
1045
1046    let boxes = {
1047        let score_threshold = quantize_score_threshold(score_threshold, quant_scores);
1048        if multi_label {
1049            postprocess_boxes_multilabel_quant::<B, _, _>(
1050                score_threshold,
1051                boxes_tensor,
1052                scores_tensor,
1053                quant_boxes,
1054            )?
1055        } else {
1056            postprocess_boxes_quant::<B, _, _>(
1057                score_threshold,
1058                boxes_tensor,
1059                scores_tensor,
1060                quant_boxes,
1061            )
1062        }
1063    };
1064
1065    let boxes = nms_and_cap(boxes, nms, iou_threshold, pre_nms_top_k, max_det);
1066    output_boxes.clear();
1067    output_boxes.extend(boxes.iter().map(|b| dequant_detect_box(b, quant_scores)));
1068    Ok(())
1069}
1070
1071/// Internal implementation of YOLO split detection decoding for float tensors.
1072///
1073/// Expected shapes of inputs:
1074/// - boxes: (4, num_boxes)
1075/// - scores: (num_classes, num_boxes)
1076///
1077/// # Panics
1078/// Panics if shapes don't match the expected dimensions (argmax path).
1079///
1080/// # Errors
1081/// Returns [`DecoderError::InvalidShape`](crate::DecoderError::InvalidShape)
1082/// when boxes are not `[N, 4]` or scores do not have one row per box.
1083#[allow(clippy::too_many_arguments)]
1084pub(crate) fn impl_yolo_split_float<
1085    B: BBoxTypeTrait,
1086    BOX: Float + AsPrimitive<f32> + Send + Sync,
1087    SCORE: Float + AsPrimitive<f32> + Send + Sync,
1088>(
1089    boxes_tensor: ArrayView2<BOX>,
1090    scores_tensor: ArrayView2<SCORE>,
1091    score_threshold: f32,
1092    iou_threshold: f32,
1093    nms: Option<Nms>,
1094    pre_nms_top_k: usize,
1095    max_det: usize,
1096    multi_label: bool,
1097    output_boxes: &mut Vec<DetectBox>,
1098) -> crate::DecoderResult<()>
1099where
1100    f32: AsPrimitive<SCORE>,
1101{
1102    let _span = tracing::trace_span!("decoder.decode.yolo_float_split").entered();
1103    let boxes_tensor = boxes_tensor.reversed_axes();
1104    let scores_tensor = scores_tensor.reversed_axes();
1105    let boxes = if multi_label {
1106        postprocess_boxes_multilabel_float::<B, _, _>(
1107            score_threshold.as_(),
1108            boxes_tensor,
1109            scores_tensor,
1110        )?
1111    } else {
1112        postprocess_boxes_float::<B, _, _>(score_threshold.as_(), boxes_tensor, scores_tensor)
1113    };
1114    output_boxes.clear();
1115    output_boxes.extend(nms_and_cap(
1116        boxes,
1117        nms,
1118        iou_threshold,
1119        pre_nms_top_k,
1120        max_det,
1121    ));
1122    Ok(())
1123}
1124
1125/// Divide each survivor's bbox by `(input_w, input_h)` when the schema
1126/// declares `normalized: false`. Pixel-space box coords from the model
1127/// are pulled into the canonical `[0, 1]` range expected by `protobox`
1128/// and downstream callers — see EDGEAI-1303.
1129///
1130/// No-op when `normalized` is `Some(true)` / `None`, when `input_dims`
1131/// is `None`, or when there are no survivors.
1132#[inline]
1133pub(crate) fn maybe_normalize_boxes_in_place(
1134    boxes: &mut [(DetectBox, usize)],
1135    normalized: Option<bool>,
1136    input_dims: Option<(usize, usize)>,
1137) {
1138    if normalized != Some(false) {
1139        return;
1140    }
1141    let Some((w, h)) = input_dims else {
1142        return;
1143    };
1144    if w == 0 || h == 0 {
1145        return;
1146    }
1147    let inv_w = 1.0 / w as f32;
1148    let inv_h = 1.0 / h as f32;
1149    for (b, _) in boxes.iter_mut() {
1150        b.bbox.xmin *= inv_w;
1151        b.bbox.ymin *= inv_h;
1152        b.bbox.xmax *= inv_w;
1153        b.bbox.ymax *= inv_h;
1154    }
1155}
1156
1157/// Internal implementation of YOLO detection segmentation decoding for
1158/// quantized tensors.
1159///
1160/// Expected shapes of inputs:
1161/// - boxes: (4 + num_classes + num_protos, num_boxes)
1162/// - protos: (proto_height, proto_width, num_protos)
1163///
1164/// # Errors
1165/// Returns `DecoderError::InvalidShape` if bounding boxes are not normalized.
1166#[allow(clippy::too_many_arguments)]
1167pub(crate) fn impl_yolo_segdet_quant<
1168    B: BBoxTypeTrait,
1169    BOX: PrimInt + AsPrimitive<i64> + AsPrimitive<i128> + AsPrimitive<f32> + Send + Sync,
1170    PROTO: PrimInt + AsPrimitive<i64> + AsPrimitive<i128> + AsPrimitive<f32> + Send + Sync,
1171>(
1172    boxes: (ArrayView2<BOX>, Quantization),
1173    protos: (ArrayView3<PROTO>, Quantization),
1174    score_threshold: f32,
1175    iou_threshold: f32,
1176    nms: Option<Nms>,
1177    pre_nms_top_k: usize,
1178    max_det: usize,
1179    normalized: Option<bool>,
1180    input_dims: Option<(usize, usize)>,
1181    multi_label: bool,
1182    output_boxes: &mut Vec<DetectBox>,
1183    output_masks: &mut Vec<Segmentation>,
1184) -> Result<(), crate::DecoderError>
1185where
1186    f32: AsPrimitive<BOX>,
1187{
1188    let (boxes, quant_boxes) = boxes;
1189    let num_protos = protos.0.dim().2;
1190
1191    let (boxes_tensor, scores_tensor, mask_tensor) = postprocess_yolo_seg(&boxes, num_protos);
1192    let mut boxes = impl_yolo_split_segdet_quant_get_boxes::<B, _, _>(
1193        (boxes_tensor, quant_boxes),
1194        (scores_tensor, quant_boxes),
1195        score_threshold,
1196        iou_threshold,
1197        nms,
1198        pre_nms_top_k,
1199        max_det,
1200        multi_label,
1201    )?;
1202    maybe_normalize_boxes_in_place(&mut boxes, normalized, input_dims);
1203
1204    impl_yolo_split_segdet_quant_process_masks::<_, _>(
1205        boxes,
1206        (mask_tensor, quant_boxes),
1207        protos,
1208        output_boxes,
1209        output_masks,
1210    )
1211}
1212
1213/// Internal implementation of YOLO detection segmentation decoding for
1214/// float tensors.
1215///
1216/// Expected shapes of inputs:
1217/// - boxes: (4 + num_classes + num_protos, num_boxes)
1218/// - protos: (proto_height, proto_width, num_protos)
1219///
1220/// # Panics
1221/// Panics if shapes don't match the expected dimensions.
1222#[allow(clippy::too_many_arguments)]
1223pub(crate) fn impl_yolo_segdet_float<
1224    B: BBoxTypeTrait,
1225    BOX: Float + AsPrimitive<f32> + Send + Sync,
1226    PROTO: Float + AsPrimitive<f32> + Send + Sync,
1227>(
1228    boxes: ArrayView2<BOX>,
1229    protos: ArrayView3<PROTO>,
1230    score_threshold: f32,
1231    iou_threshold: f32,
1232    nms: Option<Nms>,
1233    pre_nms_top_k: usize,
1234    max_det: usize,
1235    normalized: Option<bool>,
1236    input_dims: Option<(usize, usize)>,
1237    multi_label: bool,
1238    output_boxes: &mut Vec<DetectBox>,
1239    output_masks: &mut Vec<Segmentation>,
1240) -> Result<(), crate::DecoderError>
1241where
1242    f32: AsPrimitive<BOX>,
1243{
1244    let num_protos = protos.dim().2;
1245    let (boxes_tensor, scores_tensor, mask_tensor) = postprocess_yolo_seg(&boxes, num_protos);
1246    let mut boxes = impl_yolo_segdet_get_boxes::<B, _, _>(
1247        boxes_tensor,
1248        scores_tensor,
1249        score_threshold,
1250        iou_threshold,
1251        nms,
1252        pre_nms_top_k,
1253        max_det,
1254        multi_label,
1255    )?;
1256    maybe_normalize_boxes_in_place(&mut boxes, normalized, input_dims);
1257    impl_yolo_split_segdet_process_masks(boxes, mask_tensor, protos, output_boxes, output_masks)
1258}
1259
1260/// Score-filters float boxes (argmax or multi-label), runs NMS, and returns
1261/// the survivors with their anchor indices, sorted by score and capped at
1262/// `max_det`.
1263///
1264/// # Errors
1265/// Returns [`DecoderError::InvalidShape`](crate::DecoderError::InvalidShape)
1266/// when boxes are not `[N, 4]` or scores do not have one row per box.
1267#[allow(clippy::too_many_arguments)]
1268pub(crate) fn impl_yolo_segdet_get_boxes<
1269    B: BBoxTypeTrait,
1270    BOX: Float + AsPrimitive<f32> + Send + Sync,
1271    SCORE: Float + AsPrimitive<f32> + Send + Sync,
1272>(
1273    boxes_tensor: ArrayView2<BOX>,
1274    scores_tensor: ArrayView2<SCORE>,
1275    score_threshold: f32,
1276    iou_threshold: f32,
1277    nms: Option<Nms>,
1278    pre_nms_top_k: usize,
1279    max_det: usize,
1280    multi_label: bool,
1281) -> crate::DecoderResult<Vec<(DetectBox, usize)>>
1282where
1283    f32: AsPrimitive<SCORE>,
1284{
1285    crate::byte::check_candidate_shapes(boxes_tensor.dim(), scores_tensor.dim())?;
1286    let span = tracing::trace_span!(
1287        "decoder.nms_get_boxes",
1288        n_candidates = tracing::field::Empty,
1289        n_detections = tracing::field::Empty,
1290    );
1291    let _guard = span.enter();
1292
1293    // Multi-label emits one candidate per (anchor, class) above threshold;
1294    // argmax emits one per anchor.
1295    let boxes = {
1296        let _s = tracing::trace_span!("decoder.nms_get_boxes.score_filter").entered();
1297        if multi_label {
1298            postprocess_boxes_multilabel_index_float::<B, _, _>(
1299                score_threshold.as_(),
1300                boxes_tensor,
1301                scores_tensor,
1302            )
1303        } else {
1304            postprocess_boxes_index_float::<B, _, _>(
1305                score_threshold.as_(),
1306                boxes_tensor,
1307                scores_tensor,
1308            )
1309        }
1310    };
1311    span.record("n_candidates", boxes.len());
1312
1313    let boxes = nms_and_cap(boxes, nms, iou_threshold, pre_nms_top_k, max_det);
1314    span.record("n_detections", boxes.len());
1315
1316    Ok(boxes)
1317}
1318
1319pub(crate) fn impl_yolo_end_to_end_segdet_get_boxes<
1320    B: BBoxTypeTrait,
1321    BOX: Float + AsPrimitive<f32> + Send + Sync,
1322    SCORE: Float + AsPrimitive<f32> + Send + Sync,
1323    CLASS: AsPrimitive<f32> + Send + Sync,
1324>(
1325    boxes: ArrayView2<BOX>,
1326    scores: ArrayView2<SCORE>,
1327    classes: ArrayView1<CLASS>,
1328    score_threshold: f32,
1329    max_boxes: usize,
1330) -> Vec<(DetectBox, usize)>
1331where
1332    f32: AsPrimitive<SCORE>,
1333{
1334    let mut boxes = postprocess_boxes_index_float::<B, _, _>(score_threshold.as_(), boxes, scores);
1335    boxes.truncate(max_boxes);
1336    for (b, ind) in &mut boxes {
1337        b.label = classes[*ind].as_().round() as usize;
1338    }
1339    boxes
1340}
1341
1342pub(crate) fn impl_yolo_split_segdet_process_masks<
1343    MASK: Float + AsPrimitive<f32> + Send + Sync,
1344    PROTO: Float + AsPrimitive<f32> + Send + Sync,
1345>(
1346    boxes: Vec<(DetectBox, usize)>,
1347    masks_tensor: ArrayView2<MASK>,
1348    protos_tensor: ArrayView3<PROTO>,
1349    output_boxes: &mut Vec<DetectBox>,
1350    output_masks: &mut Vec<Segmentation>,
1351) -> Result<(), crate::DecoderError> {
1352    let _span = tracing::trace_span!(
1353        "decoder.decode.process_masks",
1354        n = boxes.len(),
1355        mode = "float"
1356    )
1357    .entered();
1358    // Boxes are already bounded by the upstream `max_det` cap from
1359    // `_get_boxes`; no second cap is needed here (EDGEAI-1302).
1360
1361    let boxes = decode_segdet_f32(boxes, masks_tensor, protos_tensor)?;
1362    output_boxes.clear();
1363    output_masks.clear();
1364    for (b, roi, m) in boxes.into_iter() {
1365        output_boxes.push(b);
1366        output_masks.push(Segmentation {
1367            xmin: roi.xmin,
1368            ymin: roi.ymin,
1369            xmax: roi.xmax,
1370            ymax: roi.ymax,
1371            segmentation: crate::mask_to_tensor(m.view())?,
1372        });
1373    }
1374    Ok(())
1375}
1376/// Expected input shapes:
1377/// - boxes_tensor: (num_boxes, 4)
1378/// - scores_tensor: (num_boxes, num_classes)
1379///
1380/// # Errors
1381/// Returns [`DecoderError::InvalidShape`](crate::DecoderError::InvalidShape)
1382/// when boxes are not `[N, 4]` or scores do not have one row per box.
1383#[allow(clippy::too_many_arguments)]
1384pub(crate) fn impl_yolo_split_segdet_quant_get_boxes<
1385    B: BBoxTypeTrait,
1386    BOX: PrimInt + AsPrimitive<f32> + Send + Sync,
1387    SCORE: PrimInt + AsPrimitive<f32> + Send + Sync,
1388>(
1389    boxes: (ArrayView2<BOX>, Quantization),
1390    scores: (ArrayView2<SCORE>, Quantization),
1391    score_threshold: f32,
1392    iou_threshold: f32,
1393    nms: Option<Nms>,
1394    pre_nms_top_k: usize,
1395    max_det: usize,
1396    multi_label: bool,
1397) -> crate::DecoderResult<Vec<(DetectBox, usize)>>
1398where
1399    f32: AsPrimitive<SCORE>,
1400{
1401    let (boxes_tensor, quant_boxes) = boxes;
1402    let (scores_tensor, quant_scores) = scores;
1403
1404    let span = tracing::trace_span!(
1405        "decoder.nms_get_boxes",
1406        n_candidates = tracing::field::Empty,
1407        n_detections = tracing::field::Empty,
1408    );
1409    let _guard = span.enter();
1410
1411    let boxes = {
1412        let _s = tracing::trace_span!("decoder.nms_get_boxes.score_filter").entered();
1413        let score_threshold = quantize_score_threshold(score_threshold, quant_scores);
1414        if multi_label {
1415            postprocess_boxes_multilabel_index_quant::<B, _, _>(
1416                score_threshold,
1417                boxes_tensor,
1418                scores_tensor,
1419                quant_boxes,
1420            )?
1421        } else {
1422            crate::byte::check_candidate_shapes(boxes_tensor.dim(), scores_tensor.dim())?;
1423            postprocess_boxes_index_quant::<B, _, _>(
1424                score_threshold,
1425                boxes_tensor,
1426                scores_tensor,
1427                quant_boxes,
1428            )
1429        }
1430    };
1431    span.record("n_candidates", boxes.len());
1432
1433    let boxes = nms_and_cap(boxes, nms, iou_threshold, pre_nms_top_k, max_det);
1434    let result: Vec<_> = {
1435        let _s =
1436            tracing::trace_span!("decoder.nms_get_boxes.dequant_boxes", n = boxes.len()).entered();
1437        boxes
1438            .into_iter()
1439            .map(|(b, i)| (dequant_detect_box(&b, quant_scores), i))
1440            .collect()
1441    };
1442    span.record("n_detections", result.len());
1443
1444    Ok(result)
1445}
1446
1447pub(crate) fn impl_yolo_split_segdet_quant_process_masks<
1448    MASK: PrimInt + AsPrimitive<i64> + AsPrimitive<i128> + AsPrimitive<f32> + Send + Sync,
1449    PROTO: PrimInt + AsPrimitive<i64> + AsPrimitive<i128> + AsPrimitive<f32> + Send + Sync,
1450>(
1451    boxes: Vec<(DetectBox, usize)>,
1452    mask_coeff: (ArrayView2<MASK>, Quantization),
1453    protos: (ArrayView3<PROTO>, Quantization),
1454    output_boxes: &mut Vec<DetectBox>,
1455    output_masks: &mut Vec<Segmentation>,
1456) -> Result<(), crate::DecoderError> {
1457    let _span = tracing::trace_span!(
1458        "decoder.decode.process_masks",
1459        n = boxes.len(),
1460        mode = "quant"
1461    )
1462    .entered();
1463    let (masks, quant_masks) = mask_coeff;
1464    let (protos, quant_protos) = protos;
1465
1466    // Boxes are already bounded by the upstream `max_det` cap from
1467    // `_get_boxes`; no second cap is needed here (EDGEAI-1302).
1468
1469    let boxes = decode_segdet_quant(boxes, masks, protos, quant_masks, quant_protos)?;
1470    output_boxes.clear();
1471    output_masks.clear();
1472    for (b, roi, m) in boxes.into_iter() {
1473        output_boxes.push(b);
1474        output_masks.push(Segmentation {
1475            xmin: roi.xmin,
1476            ymin: roi.ymin,
1477            xmax: roi.xmax,
1478            ymax: roi.ymax,
1479            segmentation: crate::mask_to_tensor(m.view())?,
1480        });
1481    }
1482    Ok(())
1483}
1484
1485/// Internal implementation of YOLO split detection segmentation decoding for
1486/// float tensors.
1487///
1488/// Expected shapes of inputs:
1489/// - boxes_tensor: (4, num_boxes)
1490/// - scores_tensor: (num_classes, num_boxes)
1491/// - mask_tensor: (num_protos, num_boxes)
1492/// - protos: (proto_height, proto_width, num_protos)
1493///
1494/// # Errors
1495/// Returns `DecoderError::InvalidShape` if bounding boxes are not normalized.
1496#[allow(clippy::too_many_arguments)]
1497pub(crate) fn impl_yolo_split_segdet_float<
1498    B: BBoxTypeTrait,
1499    BOX: Float + AsPrimitive<f32> + Send + Sync,
1500    SCORE: Float + AsPrimitive<f32> + Send + Sync,
1501    MASK: Float + AsPrimitive<f32> + Send + Sync,
1502    PROTO: Float + AsPrimitive<f32> + Send + Sync,
1503>(
1504    boxes_tensor: ArrayView2<BOX>,
1505    scores_tensor: ArrayView2<SCORE>,
1506    mask_tensor: ArrayView2<MASK>,
1507    protos: ArrayView3<PROTO>,
1508    score_threshold: f32,
1509    iou_threshold: f32,
1510    nms: Option<Nms>,
1511    pre_nms_top_k: usize,
1512    max_det: usize,
1513    normalized: Option<bool>,
1514    input_dims: Option<(usize, usize)>,
1515    multi_label: bool,
1516    output_boxes: &mut Vec<DetectBox>,
1517    output_masks: &mut Vec<Segmentation>,
1518) -> Result<(), crate::DecoderError>
1519where
1520    f32: AsPrimitive<SCORE>,
1521{
1522    let (boxes_tensor, scores_tensor, mask_tensor) =
1523        postprocess_yolo_split_segdet(boxes_tensor, scores_tensor, mask_tensor);
1524
1525    let mut boxes = impl_yolo_segdet_get_boxes::<B, _, _>(
1526        boxes_tensor,
1527        scores_tensor,
1528        score_threshold,
1529        iou_threshold,
1530        nms,
1531        pre_nms_top_k,
1532        max_det,
1533        multi_label,
1534    )?;
1535    maybe_normalize_boxes_in_place(&mut boxes, normalized, input_dims);
1536    impl_yolo_split_segdet_process_masks(boxes, mask_tensor, protos, output_boxes, output_masks)
1537}
1538
1539// ---------------------------------------------------------------------------
1540// Proto-extraction variants: return ProtoData instead of materialized masks
1541// ---------------------------------------------------------------------------
1542
1543/// Proto-extraction variant of `impl_yolo_segdet_quant`.
1544/// Runs NMS but returns raw `ProtoData` instead of materialized masks.
1545///
1546/// # Errors
1547/// Returns [`DecoderError::InvalidShape`](crate::DecoderError::InvalidShape)
1548/// when boxes are not `[N, 4]` or scores do not have one row per box.
1549#[allow(clippy::too_many_arguments)]
1550pub(crate) fn impl_yolo_segdet_quant_proto<
1551    B: BBoxTypeTrait,
1552    BOX: PrimInt
1553        + AsPrimitive<i64>
1554        + AsPrimitive<i128>
1555        + AsPrimitive<f32>
1556        + AsPrimitive<i8>
1557        + Send
1558        + Sync,
1559    PROTO: PrimInt
1560        + AsPrimitive<i64>
1561        + AsPrimitive<i128>
1562        + AsPrimitive<f32>
1563        + AsPrimitive<i8>
1564        + Send
1565        + Sync,
1566>(
1567    boxes: (ArrayView2<BOX>, Quantization),
1568    protos: (ArrayView3<PROTO>, Quantization),
1569    score_threshold: f32,
1570    iou_threshold: f32,
1571    nms: Option<Nms>,
1572    pre_nms_top_k: usize,
1573    max_det: usize,
1574    normalized: Option<bool>,
1575    input_dims: Option<(usize, usize)>,
1576    multi_label: bool,
1577    output_boxes: &mut Vec<DetectBox>,
1578) -> crate::DecoderResult<ProtoData>
1579where
1580    f32: AsPrimitive<BOX>,
1581{
1582    let (boxes_arr, quant_boxes) = boxes;
1583    let (protos_arr, quant_protos) = protos;
1584    let num_protos = protos_arr.dim().2;
1585
1586    let (boxes_tensor, scores_tensor, mask_tensor) = postprocess_yolo_seg(&boxes_arr, num_protos);
1587
1588    let mut det_indices = impl_yolo_split_segdet_quant_get_boxes::<B, _, _>(
1589        (boxes_tensor, quant_boxes),
1590        (scores_tensor, quant_boxes),
1591        score_threshold,
1592        iou_threshold,
1593        nms,
1594        pre_nms_top_k,
1595        max_det,
1596        multi_label,
1597    )?;
1598    maybe_normalize_boxes_in_place(&mut det_indices, normalized, input_dims);
1599
1600    Ok(extract_proto_data_quant(
1601        det_indices,
1602        mask_tensor,
1603        quant_boxes,
1604        protos_arr,
1605        quant_protos,
1606        output_boxes,
1607    ))
1608}
1609
1610/// Proto-extraction variant of `impl_yolo_segdet_float`.
1611/// Runs NMS but returns raw `ProtoData` instead of materialized masks.
1612///
1613/// # Errors
1614/// Returns [`DecoderError::InvalidShape`](crate::DecoderError::InvalidShape)
1615/// when boxes are not `[N, 4]` or scores do not have one row per box.
1616#[allow(clippy::too_many_arguments)]
1617pub(crate) fn impl_yolo_segdet_float_proto<
1618    B: BBoxTypeTrait,
1619    BOX: Float + AsPrimitive<f32> + Copy + Send + Sync + FloatProtoElem,
1620    PROTO: Float + AsPrimitive<f32> + Copy + Send + Sync + FloatProtoElem,
1621>(
1622    boxes: ArrayView2<BOX>,
1623    protos: ArrayView3<PROTO>,
1624    score_threshold: f32,
1625    iou_threshold: f32,
1626    nms: Option<Nms>,
1627    pre_nms_top_k: usize,
1628    max_det: usize,
1629    normalized: Option<bool>,
1630    input_dims: Option<(usize, usize)>,
1631    multi_label: bool,
1632    output_boxes: &mut Vec<DetectBox>,
1633) -> crate::DecoderResult<ProtoData>
1634where
1635    f32: AsPrimitive<BOX>,
1636{
1637    let num_protos = protos.dim().2;
1638    let (boxes_tensor, scores_tensor, mask_tensor) = postprocess_yolo_seg(&boxes, num_protos);
1639
1640    let mut boxes = impl_yolo_segdet_get_boxes::<B, _, _>(
1641        boxes_tensor,
1642        scores_tensor,
1643        score_threshold,
1644        iou_threshold,
1645        nms,
1646        pre_nms_top_k,
1647        max_det,
1648        multi_label,
1649    )?;
1650    maybe_normalize_boxes_in_place(&mut boxes, normalized, input_dims);
1651
1652    Ok(extract_proto_data_float(
1653        boxes,
1654        mask_tensor,
1655        protos,
1656        output_boxes,
1657    ))
1658}
1659
1660/// Proto-extraction variant of `impl_yolo_split_segdet_float`.
1661/// Runs NMS but returns raw `ProtoData` instead of materialized masks.
1662///
1663/// # Errors
1664/// Returns [`DecoderError::InvalidShape`](crate::DecoderError::InvalidShape)
1665/// when boxes are not `[N, 4]` or scores do not have one row per box.
1666#[allow(clippy::too_many_arguments)]
1667pub(crate) fn impl_yolo_split_segdet_float_proto<
1668    B: BBoxTypeTrait,
1669    BOX: Float + AsPrimitive<f32> + Send + Sync,
1670    SCORE: Float + AsPrimitive<f32> + Send + Sync,
1671    MASK: Float + AsPrimitive<f32> + Copy + Send + Sync + FloatProtoElem,
1672    PROTO: Float + AsPrimitive<f32> + Copy + Send + Sync + FloatProtoElem,
1673>(
1674    boxes_tensor: ArrayView2<BOX>,
1675    scores_tensor: ArrayView2<SCORE>,
1676    mask_tensor: ArrayView2<MASK>,
1677    protos: ArrayView3<PROTO>,
1678    score_threshold: f32,
1679    iou_threshold: f32,
1680    nms: Option<Nms>,
1681    pre_nms_top_k: usize,
1682    max_det: usize,
1683    normalized: Option<bool>,
1684    input_dims: Option<(usize, usize)>,
1685    multi_label: bool,
1686    output_boxes: &mut Vec<DetectBox>,
1687) -> crate::DecoderResult<ProtoData>
1688where
1689    f32: AsPrimitive<SCORE>,
1690{
1691    let (boxes_tensor, scores_tensor, mask_tensor) =
1692        postprocess_yolo_split_segdet(boxes_tensor, scores_tensor, mask_tensor);
1693    let mut det_indices = impl_yolo_segdet_get_boxes::<B, _, _>(
1694        boxes_tensor,
1695        scores_tensor,
1696        score_threshold,
1697        iou_threshold,
1698        nms,
1699        pre_nms_top_k,
1700        max_det,
1701        multi_label,
1702    )?;
1703    maybe_normalize_boxes_in_place(&mut det_indices, normalized, input_dims);
1704
1705    Ok(extract_proto_data_float(
1706        det_indices,
1707        mask_tensor,
1708        protos,
1709        output_boxes,
1710    ))
1711}
1712
1713/// Proto-extraction variant of `decode_yolo_end_to_end_segdet_float`.
1714pub(crate) fn decode_yolo_end_to_end_segdet_float_proto<T>(
1715    output: ArrayView2<T>,
1716    protos: ArrayView3<T>,
1717    score_threshold: f32,
1718    max_det: usize,
1719    output_boxes: &mut Vec<DetectBox>,
1720) -> Result<ProtoData, crate::DecoderError>
1721where
1722    T: Float + AsPrimitive<f32> + Copy + Send + Sync + FloatProtoElem,
1723    f32: AsPrimitive<T>,
1724{
1725    let (boxes, scores, classes, mask_coeff) =
1726        postprocess_yolo_end_to_end_segdet(&output, protos.dim().2)?;
1727    let cap = max_det;
1728    let boxes = impl_yolo_end_to_end_segdet_get_boxes::<XYXY, _, _, _>(
1729        boxes,
1730        scores,
1731        classes,
1732        score_threshold,
1733        cap,
1734    );
1735
1736    Ok(extract_proto_data_float(
1737        boxes,
1738        mask_coeff,
1739        protos,
1740        output_boxes,
1741    ))
1742}
1743
1744/// Proto-extraction variant of `decode_yolo_split_end_to_end_segdet_float`.
1745#[allow(clippy::too_many_arguments)]
1746pub(crate) fn decode_yolo_split_end_to_end_segdet_float_proto<T>(
1747    boxes: ArrayView2<T>,
1748    scores: ArrayView2<T>,
1749    classes: ArrayView2<T>,
1750    mask_coeff: ArrayView2<T>,
1751    protos: ArrayView3<T>,
1752    score_threshold: f32,
1753    max_det: usize,
1754    output_boxes: &mut Vec<DetectBox>,
1755) -> Result<ProtoData, crate::DecoderError>
1756where
1757    T: Float + AsPrimitive<f32> + Copy + Send + Sync + FloatProtoElem,
1758    f32: AsPrimitive<T>,
1759{
1760    let (boxes, scores, classes, mask_coeff) =
1761        postprocess_yolo_split_end_to_end_segdet(boxes, scores, &classes, mask_coeff)?;
1762    let cap = max_det;
1763    let boxes = impl_yolo_end_to_end_segdet_get_boxes::<XYXY, _, _, _>(
1764        boxes,
1765        scores,
1766        classes,
1767        score_threshold,
1768        cap,
1769    );
1770
1771    Ok(extract_proto_data_float(
1772        boxes,
1773        mask_coeff,
1774        protos,
1775        output_boxes,
1776    ))
1777}
1778
1779/// Helper: extract ProtoData from float mask coefficients + protos.
1780///
1781/// Builds [`ProtoData`] with both `protos` and `mask_coefficients` as
1782/// [`edgefirst_tensor::TensorDyn`]. Preserves the native element type for
1783/// `f16` and `f32`; narrows `f64` to `f32` (there is no native f64 kernel
1784/// path). `mask_coefficients` shape is `[num_detections, num_protos]`.
1785pub(super) fn extract_proto_data_float<
1786    MASK: Float + AsPrimitive<f32> + Copy + Send + Sync + FloatProtoElem,
1787    PROTO: Float + AsPrimitive<f32> + Copy + Send + Sync + FloatProtoElem,
1788>(
1789    det_indices: Vec<(DetectBox, usize)>,
1790    mask_tensor: ArrayView2<MASK>,
1791    protos: ArrayView3<PROTO>,
1792    output_boxes: &mut Vec<DetectBox>,
1793) -> ProtoData {
1794    let _span = tracing::trace_span!(
1795        "decoder.decode_proto.extract_proto_data",
1796        mode = "float",
1797        n = det_indices.len(),
1798        num_protos = mask_tensor.ncols(),
1799        layout = "nhwc",
1800    )
1801    .entered();
1802
1803    let num_protos = mask_tensor.ncols();
1804    let n = det_indices.len();
1805
1806    // Per-detection coefficients packed row-major into a contiguous buffer,
1807    // preserving the source dtype. Shape: [N, num_protos] — N=0 is permitted
1808    // (tracker path emits no detections this frame) since Mem-backed tensors
1809    // accept zero-element shapes as "empty collection" sentinels.
1810    let mut coeff_rows: Vec<MASK> = Vec::with_capacity(n * num_protos);
1811    output_boxes.clear();
1812    for (det, idx) in det_indices {
1813        output_boxes.push(det);
1814        let row = mask_tensor.row(idx);
1815        coeff_rows.extend(row.iter().copied());
1816    }
1817
1818    let mask_coefficients = MASK::slice_into_tensor_dyn(&coeff_rows, &[n, num_protos])
1819        .expect("allocating mask_coefficients TensorDyn");
1820    let protos_tensor =
1821        PROTO::arrayview3_into_tensor_dyn(protos).expect("allocating protos TensorDyn");
1822
1823    ProtoData {
1824        mask_coefficients,
1825        protos: protos_tensor,
1826        layout: ProtoLayout::Nhwc,
1827    }
1828}
1829
1830/// Helper: extract ProtoData from quantized mask coefficients + protos.
1831///
1832/// Keeps i8 protos raw, attaching the dequantization params as
1833/// [`edgefirst_tensor::Quantization::per_tensor`] metadata on the proto
1834/// tensor; the GPU shader / CPU kernel reads `protos.quantization()` and
1835/// dequantizes per-texel. Protos of any other integer type (u8, i16, u16)
1836/// are dequantized to f32 NHWC here, since narrowing them to i8 would
1837/// discard their range. Mask coefficients stay raw for i8 and i16 and are
1838/// dequantized to f32 otherwise. The dtypes do not depend on whether any
1839/// detection survived.
1840pub(crate) fn extract_proto_data_quant<
1841    MASK: PrimInt + AsPrimitive<f32> + AsPrimitive<i8> + Send + Sync + 'static,
1842    PROTO: PrimInt + AsPrimitive<f32> + AsPrimitive<i8> + Send + Sync + 'static,
1843>(
1844    det_indices: Vec<(DetectBox, usize)>,
1845    mask_tensor: ArrayView2<MASK>,
1846    quant_masks: Quantization,
1847    protos: ArrayView3<PROTO>,
1848    quant_protos: Quantization,
1849    output_boxes: &mut Vec<DetectBox>,
1850) -> ProtoData {
1851    use edgefirst_tensor::{Tensor, TensorDyn, TensorMapTrait, TensorMemory, TensorTrait};
1852
1853    let span = tracing::trace_span!(
1854        "decoder.decode_proto.extract_proto_data",
1855        mode = "quant",
1856        n = det_indices.len(),
1857        num_protos = tracing::field::Empty,
1858        layout = tracing::field::Empty,
1859    );
1860    let _guard = span.enter();
1861
1862    let num_protos = mask_tensor.ncols();
1863    let n = det_indices.len();
1864    span.record("num_protos", num_protos);
1865
1866    // Mask coefficients: keep i8 losslessly when MASK == i8 (preserves
1867    // the fast i8×i8→i32 integer kernel in materialize_masks). Preserve
1868    // i16 natively so the downstream i16×i8 integer path can avoid lossy
1869    // truncation. Other wider types (u16, …) dequantize to f32 at
1870    // extraction because the downstream mask kernels accept F32 natively.
1871    let mask_coefficients: TensorDyn = if std::any::TypeId::of::<MASK>()
1872        == std::any::TypeId::of::<i8>()
1873    {
1874        let mut coeff_i8 = Vec::<i8>::with_capacity(n * num_protos);
1875        output_boxes.clear();
1876        for (det, idx) in det_indices {
1877            output_boxes.push(det);
1878            let row = mask_tensor.row(idx);
1879            coeff_i8.extend(row.iter().map(|v| {
1880                let v_i8: i8 = v.as_();
1881                v_i8
1882            }));
1883        }
1884        let coeff_tensor = Tensor::<i8>::new(&[n, num_protos], Some(TensorMemory::Mem), None)
1885            .expect("allocating mask_coefficients tensor");
1886        if n > 0 {
1887            let mut m = coeff_tensor
1888                .map()
1889                .expect("mapping mask_coefficients tensor");
1890            m.as_mut_slice().copy_from_slice(&coeff_i8);
1891        }
1892        let coeff_quant =
1893            edgefirst_tensor::Quantization::per_tensor(quant_masks.scale, quant_masks.zero_point);
1894        let coeff_tensor = coeff_tensor
1895            .with_quantization(coeff_quant)
1896            .expect("per-tensor quantization on mask coefficients");
1897        coeff_tensor.into()
1898    } else if std::any::TypeId::of::<MASK>() == std::any::TypeId::of::<i16>() {
1899        // i16 path: preserve natively for the fast i16×i8→i32 integer kernel.
1900        // f32 has 24-bit mantissa, so all i16 values are exactly representable.
1901        let mut coeff_i16 = Vec::<i16>::with_capacity(n * num_protos);
1902        output_boxes.clear();
1903        for (det, idx) in det_indices {
1904            output_boxes.push(det);
1905            let row = mask_tensor.row(idx);
1906            coeff_i16.extend(row.iter().map(|v| {
1907                let v_f32: f32 = v.as_();
1908                v_f32 as i16
1909            }));
1910        }
1911        let coeff_tensor = Tensor::<i16>::new(&[n, num_protos], Some(TensorMemory::Mem), None)
1912            .expect("allocating mask_coefficients tensor");
1913        if n > 0 {
1914            let mut m = coeff_tensor
1915                .map()
1916                .expect("mapping mask_coefficients tensor");
1917            m.as_mut_slice().copy_from_slice(&coeff_i16);
1918        }
1919        let coeff_quant =
1920            edgefirst_tensor::Quantization::per_tensor(quant_masks.scale, quant_masks.zero_point);
1921        let coeff_tensor = coeff_tensor
1922            .with_quantization(coeff_quant)
1923            .expect("per-tensor quantization on mask coefficients");
1924        coeff_tensor.into()
1925    } else {
1926        // Other types (u8, u16, etc.): dequantize to f32 to avoid lossy truncation.
1927        let scale = quant_masks.scale;
1928        let zp = quant_masks.zero_point as f32;
1929        let mut coeff_f32 = Vec::<f32>::with_capacity(n * num_protos);
1930        output_boxes.clear();
1931        for (det, idx) in det_indices {
1932            output_boxes.push(det);
1933            let row = mask_tensor.row(idx);
1934            coeff_f32.extend(row.iter().map(|v| {
1935                let v_f32: f32 = v.as_();
1936                (v_f32 - zp) * scale
1937            }));
1938        }
1939        let coeff_tensor = Tensor::<f32>::new(&[n, num_protos], Some(TensorMemory::Mem), None)
1940            .expect("allocating mask_coefficients tensor");
1941        if n > 0 {
1942            let mut m = coeff_tensor
1943                .map()
1944                .expect("mapping mask_coefficients tensor");
1945            m.as_mut_slice().copy_from_slice(&coeff_f32);
1946        }
1947        coeff_tensor.into()
1948    };
1949
1950    let (h, w, k) = protos.dim();
1951
1952    // With no detections the protos are never read (`materialize_masks`
1953    // returns early on an empty slice), so the tensors below keep their
1954    // shape and dtype but skip the copy (819 KB for 160×160×32).
1955
1956    // Protos of any other integer type (u8, i16, u16, …) cannot be narrowed
1957    // to i8 without discarding their range, so dequantize them to f32 NHWC.
1958    // `protos.iter()` walks the logical [H, W, K] order whatever the
1959    // physical strides, so the copy also normalizes NCHW sources.
1960    if std::any::TypeId::of::<PROTO>() != std::any::TypeId::of::<i8>() {
1961        let scale = quant_protos.scale;
1962        let zp = quant_protos.zero_point as f32;
1963        let protos_tensor = Tensor::<f32>::new(&[h, w, k], Some(TensorMemory::Mem), None)
1964            .expect("allocating protos tensor");
1965        if n > 0 {
1966            let mut m = protos_tensor.map().expect("mapping protos tensor");
1967            for (d, s) in m.as_mut_slice().iter_mut().zip(protos.iter()) {
1968                let v: f32 = s.as_();
1969                *d = (v - zp) * scale;
1970            }
1971        }
1972        span.record("layout", tracing::field::debug(&ProtoLayout::Nhwc));
1973        return ProtoData {
1974            mask_coefficients,
1975            protos: protos_tensor.into(),
1976            layout: ProtoLayout::Nhwc,
1977        };
1978    }
1979
1980    // Keep i8 protos raw — consumers dequantize via protos.quantization().
1981    // Detect the physical layout and copy without transposing; the mask
1982    // materialisation kernels dispatch on the layout.
1983    let (proto_shape, proto_layout) = if protos.is_standard_layout() {
1984        // Already NHWC [H, W, K] in contiguous memory.
1985        (&[h, w, k][..], ProtoLayout::Nhwc)
1986    } else if protos.ndim() == 3 && protos.strides() == [w as isize, 1, (h * w) as isize] {
1987        // NCHW reinterpreted as NHWC via stride swap. Physical storage
1988        // is [K, H, W] contiguous. Keep in NCHW — eliminates the costly
1989        // 3.1ms transpose entirely.
1990        (&[k, h, w][..], ProtoLayout::Nchw)
1991    } else {
1992        // Unknown layout — fall back to iter copy as NHWC.
1993        (&[h, w, k][..], ProtoLayout::Nhwc)
1994    };
1995
1996    let protos_tensor = Tensor::<i8>::new(proto_shape, Some(TensorMemory::Mem), None)
1997        .expect("allocating protos tensor");
1998    if n > 0 {
1999        let mut m = protos_tensor.map().expect("mapping protos tensor");
2000        let dst = m.as_mut_slice();
2001        // SAFETY: PROTO == i8 checked via TypeId above; the cast slice view
2002        // is size/alignment-compatible by construction.
2003        if protos.is_standard_layout() {
2004            let src: &[i8] =
2005                unsafe { std::slice::from_raw_parts(protos.as_ptr() as *const i8, protos.len()) };
2006            dst.copy_from_slice(src);
2007        } else if protos.ndim() == 3 && protos.strides() == [w as isize, 1, (h * w) as isize] {
2008            // NCHW physical layout — sequential copy WITHOUT transpose.
2009            // This saves ~3.1ms on A53/A55 by avoiding the tiled
2010            // NCHW→NHWC transpose of the 819KB proto buffer.
2011            let total = h * w * k;
2012            // SAFETY: ArrayView was constructed from a contiguous slice of
2013            // `total` elements. as_ptr() points to the base of that slice.
2014            let src: &[i8] =
2015                unsafe { std::slice::from_raw_parts(protos.as_ptr() as *const i8, total) };
2016            dst.copy_from_slice(src);
2017        } else {
2018            for (d, s) in dst.iter_mut().zip(protos.iter()) {
2019                let v_i8: i8 = s.as_();
2020                *d = v_i8;
2021            }
2022        }
2023    }
2024    let tensor_quant =
2025        edgefirst_tensor::Quantization::per_tensor(quant_protos.scale, quant_protos.zero_point);
2026    let protos_tensor = protos_tensor
2027        .with_quantization(tensor_quant)
2028        .expect("per-tensor quantization on new Tensor<i8>");
2029
2030    span.record("layout", tracing::field::debug(&proto_layout));
2031
2032    ProtoData {
2033        mask_coefficients,
2034        protos: protos_tensor.into(),
2035        layout: proto_layout,
2036    }
2037}
2038
2039/// Per-float-dtype construction of an [`edgefirst_tensor::TensorDyn`] from a
2040/// flat slice or a 3-D `ArrayView`. Each implementor either passes its element
2041/// type straight to `Tensor::from_slice` / `Tensor::from_arrayview3`, or
2042/// narrows `f64` to `f32` — there is no native f64 kernel path, so `f64` protos
2043/// lose precision here rather than at some later, less obvious point.
2044///
2045/// Implemented for `f32`, `f64`, and `half::f16`. It is public because it
2046/// appears in the bounds of the proto-extraction paths; there is no reason to
2047/// implement it outside this crate.
2048pub trait FloatProtoElem: Copy + 'static {
2049    /// Wrap a flat slice with the given `shape` as a tensor of this element
2050    /// type.
2051    fn slice_into_tensor_dyn(
2052        values: &[Self],
2053        shape: &[usize],
2054    ) -> edgefirst_tensor::Result<edgefirst_tensor::TensorDyn>;
2055
2056    /// Wrap a 3-D `ArrayView` (the proto-mask layout) as a tensor of this
2057    /// element type.
2058    fn arrayview3_into_tensor_dyn(
2059        view: ArrayView3<'_, Self>,
2060    ) -> edgefirst_tensor::Result<edgefirst_tensor::TensorDyn>;
2061}
2062
2063impl FloatProtoElem for f32 {
2064    fn slice_into_tensor_dyn(
2065        values: &[f32],
2066        shape: &[usize],
2067    ) -> edgefirst_tensor::Result<edgefirst_tensor::TensorDyn> {
2068        edgefirst_tensor::Tensor::<f32>::from_slice(values, shape).map(Into::into)
2069    }
2070    fn arrayview3_into_tensor_dyn(
2071        view: ArrayView3<'_, f32>,
2072    ) -> edgefirst_tensor::Result<edgefirst_tensor::TensorDyn> {
2073        edgefirst_tensor::Tensor::<f32>::from_arrayview3(view).map(Into::into)
2074    }
2075}
2076
2077impl FloatProtoElem for half::f16 {
2078    fn slice_into_tensor_dyn(
2079        values: &[half::f16],
2080        shape: &[usize],
2081    ) -> edgefirst_tensor::Result<edgefirst_tensor::TensorDyn> {
2082        edgefirst_tensor::Tensor::<half::f16>::from_slice(values, shape).map(Into::into)
2083    }
2084    fn arrayview3_into_tensor_dyn(
2085        view: ArrayView3<'_, half::f16>,
2086    ) -> edgefirst_tensor::Result<edgefirst_tensor::TensorDyn> {
2087        edgefirst_tensor::Tensor::<half::f16>::from_arrayview3(view).map(Into::into)
2088    }
2089}
2090
2091impl FloatProtoElem for f64 {
2092    fn slice_into_tensor_dyn(
2093        values: &[f64],
2094        shape: &[usize],
2095    ) -> edgefirst_tensor::Result<edgefirst_tensor::TensorDyn> {
2096        // Narrow to f32 — no native f64 kernel path.
2097        let narrowed: Vec<f32> = values.iter().map(|&v| v as f32).collect();
2098        edgefirst_tensor::Tensor::<f32>::from_slice(&narrowed, shape).map(Into::into)
2099    }
2100    fn arrayview3_into_tensor_dyn(
2101        view: ArrayView3<'_, f64>,
2102    ) -> edgefirst_tensor::Result<edgefirst_tensor::TensorDyn> {
2103        let narrowed: ndarray::Array3<f32> = view.mapv(|v| v as f32);
2104        edgefirst_tensor::Tensor::<f32>::from_arrayview3(narrowed.view()).map(Into::into)
2105    }
2106}
2107
2108fn postprocess_yolo<'a, T>(
2109    output: &'a ArrayView2<'_, T>,
2110) -> (ArrayView2<'a, T>, ArrayView2<'a, T>) {
2111    let boxes_tensor = output.slice(s![..4, ..,]).reversed_axes();
2112    let scores_tensor = output.slice(s![4.., ..,]).reversed_axes();
2113    (boxes_tensor, scores_tensor)
2114}
2115
2116pub(crate) fn postprocess_yolo_seg<'a, T>(
2117    output: &'a ArrayView2<'_, T>,
2118    num_protos: usize,
2119) -> (ArrayView2<'a, T>, ArrayView2<'a, T>, ArrayView2<'a, T>) {
2120    assert!(
2121        output.shape()[0] > num_protos + 4,
2122        "Output shape is too short: {} <= {} + 4",
2123        output.shape()[0],
2124        num_protos
2125    );
2126    let num_classes = output.shape()[0] - 4 - num_protos;
2127    let boxes_tensor = output.slice(s![..4, ..,]).reversed_axes();
2128    let scores_tensor = output.slice(s![4..(num_classes + 4), ..,]).reversed_axes();
2129    let mask_tensor = output.slice(s![(num_classes + 4).., ..,]).reversed_axes();
2130    (boxes_tensor, scores_tensor, mask_tensor)
2131}
2132
2133pub(crate) fn postprocess_yolo_split_segdet<'a, 'b, 'c, BOX, SCORE, MASK>(
2134    boxes_tensor: ArrayView2<'a, BOX>,
2135    scores_tensor: ArrayView2<'b, SCORE>,
2136    mask_tensor: ArrayView2<'c, MASK>,
2137) -> (
2138    ArrayView2<'a, BOX>,
2139    ArrayView2<'b, SCORE>,
2140    ArrayView2<'c, MASK>,
2141) {
2142    let boxes_tensor = boxes_tensor.reversed_axes();
2143    let scores_tensor = scores_tensor.reversed_axes();
2144    let mask_tensor = mask_tensor.reversed_axes();
2145    (boxes_tensor, scores_tensor, mask_tensor)
2146}
2147
2148fn decode_segdet_f32<
2149    MASK: Float + AsPrimitive<f32> + Send + Sync,
2150    PROTO: Float + AsPrimitive<f32> + Send + Sync,
2151>(
2152    boxes: Vec<(DetectBox, usize)>,
2153    masks: ArrayView2<MASK>,
2154    protos: ArrayView3<PROTO>,
2155) -> Result<Vec<(DetectBox, BoundingBox, Array3<u8>)>, crate::DecoderError> {
2156    if boxes.is_empty() {
2157        return Ok(Vec::new());
2158    }
2159    if masks.shape()[1] != protos.shape()[2] {
2160        return Err(crate::DecoderError::InvalidShape(format!(
2161            "Mask coefficients count ({}) doesn't match protos channel count ({})",
2162            masks.shape()[1],
2163            protos.shape()[2],
2164        )));
2165    }
2166    boxes
2167        .into_par_iter()
2168        .map(|b| {
2169            let ind = b.1;
2170            // `protobox` returns the cropped proto slice for `make_segmentation`
2171            // and a `roi` snapped to the 1/proto-grid step. The detection bbox
2172            // stays untouched (EDGEAI-1304); the snapped roi is reported back
2173            // separately so callers can describe where the cropped mask lives.
2174            let (protos, roi) = protobox(&protos, &b.0.bbox)?;
2175            Ok((b.0, roi, make_segmentation(masks.row(ind), protos.view())))
2176        })
2177        .collect()
2178}
2179
2180pub(crate) fn decode_segdet_quant<
2181    MASK: PrimInt + AsPrimitive<i64> + AsPrimitive<i128> + Send + Sync,
2182    PROTO: PrimInt + AsPrimitive<i64> + AsPrimitive<i128> + Send + Sync,
2183>(
2184    boxes: Vec<(DetectBox, usize)>,
2185    masks: ArrayView2<MASK>,
2186    protos: ArrayView3<PROTO>,
2187    quant_masks: Quantization,
2188    quant_protos: Quantization,
2189) -> Result<Vec<(DetectBox, BoundingBox, Array3<u8>)>, crate::DecoderError> {
2190    if boxes.is_empty() {
2191        return Ok(Vec::new());
2192    }
2193    if masks.shape()[1] != protos.shape()[2] {
2194        return Err(crate::DecoderError::InvalidShape(format!(
2195            "Mask coefficients count ({}) doesn't match protos channel count ({})",
2196            masks.shape()[1],
2197            protos.shape()[2],
2198        )));
2199    }
2200
2201    let total_bits = MASK::zero().count_zeros() + PROTO::zero().count_zeros() + 5; // 32 protos is 2^5
2202    boxes
2203        .into_iter()
2204        .map(|b| {
2205            let i = b.1;
2206            // See EDGEAI-1304: the caller's bbox stays untouched; the
2207            // proto-grid-snapped `roi` is reported back so the Segmentation's
2208            // bounds can describe the actual cropped mask region.
2209            let (protos, roi) = protobox(&protos, &b.0.bbox.to_canonical())?;
2210            let seg = match total_bits {
2211                0..=64 => make_segmentation_quant::<MASK, PROTO, i64>(
2212                    masks.row(i),
2213                    protos.view(),
2214                    quant_masks,
2215                    quant_protos,
2216                ),
2217                65..=128 => make_segmentation_quant::<MASK, PROTO, i128>(
2218                    masks.row(i),
2219                    protos.view(),
2220                    quant_masks,
2221                    quant_protos,
2222                ),
2223                _ => {
2224                    return Err(crate::DecoderError::NotSupported(format!(
2225                        "Unsupported bit width ({total_bits}) for segmentation computation"
2226                    )));
2227                }
2228            };
2229            Ok((b.0, roi, seg))
2230        })
2231        .collect()
2232}
2233
2234fn protobox<'a, T>(
2235    protos: &'a ArrayView3<T>,
2236    roi: &BoundingBox,
2237) -> Result<(ArrayView3<'a, T>, BoundingBox), crate::DecoderError> {
2238    let width = protos.dim().1 as f32;
2239    let height = protos.dim().0 as f32;
2240
2241    // Detect un-normalized bounding boxes (pixel-space coordinates).
2242    // protobox expects normalized coordinates in [0, 1]. The decoder will
2243    // normalize pixel-space coords automatically when the schema declares
2244    // `Detection::normalized = false` AND model input dimensions are known
2245    // (EDGEAI-1303); reaching this guard means at least one of those is
2246    // missing.
2247    //
2248    // The limit is set to 2.0 (not 1.01) because YOLO models legitimately
2249    // predict coordinates slightly > 1.0 for objects near frame edges.
2250    // Any value > 2.0 is clearly pixel-space (even the smallest practical
2251    // model input of 32×32 would produce coordinates >> 2.0).
2252    const NORM_LIMIT: f32 = 2.0;
2253    if roi.xmin > NORM_LIMIT
2254        || roi.ymin > NORM_LIMIT
2255        || roi.xmax > NORM_LIMIT
2256        || roi.ymax > NORM_LIMIT
2257    {
2258        return Err(crate::DecoderError::InvalidShape(format!(
2259            "Bounding box coordinates appear un-normalized (pixel-space). \
2260             Got bbox=({:.2}, {:.2}, {:.2}, {:.2}) but expected values in [0, 1]. \
2261             Two ways to fix this: \
2262             (1) declare `Detection::normalized = false` in the model schema \
2263             AND make sure the schema's `input.shape` / `input.dshape` carries \
2264             the model input dims so the decoder can divide by (W, H) before NMS \
2265             (EDGEAI-1303 — verify with `Decoder::input_dims().is_some()`); or \
2266             (2) normalize the boxes in-graph before decode().",
2267            roi.xmin, roi.ymin, roi.xmax, roi.ymax,
2268        )));
2269    }
2270
2271    let roi = [
2272        (roi.xmin * width).clamp(0.0, width) as usize,
2273        (roi.ymin * height).clamp(0.0, height) as usize,
2274        (roi.xmax * width).clamp(0.0, width).ceil() as usize,
2275        (roi.ymax * height).clamp(0.0, height).ceil() as usize,
2276    ];
2277
2278    let roi_norm = [
2279        roi[0] as f32 / width,
2280        roi[1] as f32 / height,
2281        roi[2] as f32 / width,
2282        roi[3] as f32 / height,
2283    ]
2284    .into();
2285
2286    let cropped = protos.slice(s![roi[1]..roi[3], roi[0]..roi[2], ..]);
2287
2288    Ok((cropped, roi_norm))
2289}
2290
2291/// Compute a single instance segmentation mask from mask coefficients and
2292/// proto maps (float path).
2293///
2294/// Computes `sigmoid(coefficients · protos)` and maps to `[0, 255]`.
2295/// Returns an `(H, W, 1)` u8 array.
2296fn make_segmentation<
2297    MASK: Float + AsPrimitive<f32> + Send + Sync,
2298    PROTO: Float + AsPrimitive<f32> + Send + Sync,
2299>(
2300    mask: ArrayView1<MASK>,
2301    protos: ArrayView3<PROTO>,
2302) -> Array3<u8> {
2303    let shape = protos.shape();
2304
2305    // Safe to unwrap since the shapes will always be compatible
2306    let mask = mask.to_shape((1, mask.len())).unwrap();
2307    let protos = protos.to_shape([shape[0] * shape[1], shape[2]]).unwrap();
2308    let protos = protos.reversed_axes();
2309    let mask = mask.map(|x| x.as_());
2310    let protos = protos.map(|x| x.as_());
2311
2312    // Safe to unwrap since the shapes will always be compatible
2313    let mask = mask
2314        .dot(&protos)
2315        .into_shape_with_order((shape[0], shape[1], 1))
2316        .unwrap();
2317
2318    mask.map(|x| {
2319        let sigmoid = 1.0 / (1.0 + (-*x).exp());
2320        (sigmoid * 255.0).round() as u8
2321    })
2322}
2323
2324/// Compute a single instance segmentation mask from quantized mask
2325/// coefficients and proto maps.
2326///
2327/// Dequantizes both inputs (subtracting zero-points), computes the dot
2328/// product, applies sigmoid, and maps to `[0, 255]`.
2329/// Returns an `(H, W, 1)` u8 array.
2330fn make_segmentation_quant<
2331    MASK: PrimInt + AsPrimitive<DEST> + Send + Sync,
2332    PROTO: PrimInt + AsPrimitive<DEST> + Send + Sync,
2333    DEST: PrimInt + 'static + Signed + AsPrimitive<f32> + Debug,
2334>(
2335    mask: ArrayView1<MASK>,
2336    protos: ArrayView3<PROTO>,
2337    quant_masks: Quantization,
2338    quant_protos: Quantization,
2339) -> Array3<u8>
2340where
2341    i32: AsPrimitive<DEST>,
2342    f32: AsPrimitive<DEST>,
2343{
2344    let shape = protos.shape();
2345
2346    // Safe to unwrap since the shapes will always be compatible
2347    let mask = mask.to_shape((1, mask.len())).unwrap();
2348
2349    let protos = protos.to_shape([shape[0] * shape[1], shape[2]]).unwrap();
2350    let protos = protos.reversed_axes();
2351
2352    let zp = quant_masks.zero_point.as_();
2353
2354    let mask = mask.mapv(|x| x.as_() - zp);
2355
2356    let zp = quant_protos.zero_point.as_();
2357    let protos = protos.mapv(|x| x.as_() - zp);
2358
2359    // Safe to unwrap since the shapes will always be compatible
2360    let segmentation = mask
2361        .dot(&protos)
2362        .into_shape_with_order((shape[0], shape[1], 1))
2363        .unwrap();
2364
2365    let combined_scale = quant_masks.scale * quant_protos.scale;
2366    segmentation.map(|x| {
2367        let val: f32 = (*x).as_() * combined_scale;
2368        let sigmoid = 1.0 / (1.0 + (-val).exp());
2369        (sigmoid * 255.0).round() as u8
2370    })
2371}
2372
2373/// Converts Yolo Instance Segmentation into a 2D mask.
2374///
2375/// The input segmentation is expected to have shape (H, W, 1).
2376///
2377/// The output mask will have shape (H, W), with values 0 or 1 based on the
2378/// threshold.
2379///
2380/// # Errors
2381///
2382/// Returns `DecoderError::InvalidShape` if the input segmentation does not
2383/// have shape (H, W, 1).
2384pub(crate) fn yolo_segmentation_to_mask(
2385    segmentation: ArrayView3<u8>,
2386    threshold: u8,
2387) -> Result<Array2<u8>, crate::DecoderError> {
2388    if segmentation.shape()[2] != 1 {
2389        return Err(crate::DecoderError::InvalidShape(format!(
2390            "Yolo Instance Segmentation should have shape (H, W, 1), got (H, W, {})",
2391            segmentation.shape()[2]
2392        )));
2393    }
2394    Ok(segmentation
2395        .slice(s![.., .., 0])
2396        .map(|x| if *x >= threshold { 1 } else { 0 }))
2397}
2398
2399#[cfg(test)]
2400#[cfg_attr(coverage_nightly, coverage(off))]
2401mod tests {
2402    use super::*;
2403    use ndarray::Array2;
2404
2405    /// Labels a kernel keeps for one anchor whose two classes both clear the
2406    /// threshold, decoded multi-label under `nms`.
2407    fn multi_label_kernel_labels(nms: Nms) -> Vec<Vec<usize>> {
2408        // Feature-major [4 + nc, N] with N = 1, nc = 2.
2409        let flat = Array2::from_shape_vec((6, 1), vec![0.5f32, 0.5, 0.2, 0.2, 0.9, 0.8]).unwrap();
2410        let flat_q = flat.mapv(|v| (v * 100.0).round() as i8);
2411        let q = Quantization::new(0.01, 0);
2412        let (boxes, scores) = (flat.slice(s![..4, ..]), flat.slice(s![4.., ..]));
2413        let (boxes_q, scores_q) = (flat_q.slice(s![..4, ..]), flat_q.slice(s![4.., ..]));
2414        let sorted = |mut v: Vec<usize>| {
2415            v.sort_unstable();
2416            v
2417        };
2418        let of = |b: &[DetectBox]| sorted(b.iter().map(|b| b.label).collect());
2419        let of_idx = |b: &[(DetectBox, usize)]| sorted(b.iter().map(|b| b.0.label).collect());
2420        let mut out = Vec::new();
2421        let mut v = Vec::new();
2422        impl_yolo_float::<XYWH, f32>(flat.view(), 0.5, 0.5, Some(nms), 0, 8, true, &mut v).unwrap();
2423        out.push(of(&v));
2424        impl_yolo_quant::<XYWH, i8>((flat_q.view(), q), 0.5, 0.5, Some(nms), 0, 8, true, &mut v)
2425            .unwrap();
2426        out.push(of(&v));
2427        impl_yolo_split_float::<XYWH, f32, f32>(
2428            boxes,
2429            scores,
2430            0.5,
2431            0.5,
2432            Some(nms),
2433            0,
2434            8,
2435            true,
2436            &mut v,
2437        )
2438        .unwrap();
2439        out.push(of(&v));
2440        impl_yolo_split_quant::<XYWH, i8, i8>(
2441            (boxes_q, q),
2442            (scores_q, q),
2443            0.5,
2444            0.5,
2445            Some(nms),
2446            0,
2447            8,
2448            true,
2449            &mut v,
2450        )
2451        .unwrap();
2452        out.push(of(&v));
2453        let r = impl_yolo_segdet_get_boxes::<XYWH, f32, f32>(
2454            boxes.reversed_axes(),
2455            scores.reversed_axes(),
2456            0.5,
2457            0.5,
2458            Some(nms),
2459            0,
2460            8,
2461            true,
2462        )
2463        .unwrap();
2464        out.push(of_idx(&r));
2465        let r = impl_yolo_split_segdet_quant_get_boxes::<XYWH, i8, i8>(
2466            (boxes_q.reversed_axes(), q),
2467            (scores_q.reversed_axes(), q),
2468            0.5,
2469            0.5,
2470            Some(nms),
2471            0,
2472            8,
2473            true,
2474        )
2475        .unwrap();
2476        out.push(of_idx(&r));
2477        out
2478    }
2479
2480    #[test]
2481    fn multi_label_honours_class_agnostic_nms_on_every_kernel() {
2482        // Agnostic NMS runs over the multi-label candidates, as Ultralytics
2483        // `non_max_suppression(multi_label=True, agnostic=True)` does: the
2484        // lower-scoring class of the shared bbox is suppressed.
2485        for (path, labels) in multi_label_kernel_labels(Nms::ClassAgnostic)
2486            .into_iter()
2487            .enumerate()
2488        {
2489            assert_eq!(labels, vec![0], "kernel {path}: class-agnostic");
2490        }
2491        for (path, labels) in multi_label_kernel_labels(Nms::ClassAware)
2492            .into_iter()
2493            .enumerate()
2494        {
2495            assert_eq!(labels, vec![0, 1], "kernel {path}: class-aware");
2496        }
2497    }
2498
2499    // ========================================================================
2500    // Tests for decode_yolo_end_to_end_det_float
2501    // ========================================================================
2502
2503    #[test]
2504    fn test_end_to_end_det_basic_filtering() {
2505        // Create synthetic end-to-end detection output: (6, N) where rows are
2506        // [x1, y1, x2, y2, conf, class]
2507        // 3 detections: one above threshold, two below
2508        let data: Vec<f32> = vec![
2509            // Detection 0: high score (0.9)
2510            0.1, 0.2, 0.3, // x1 values
2511            0.1, 0.2, 0.3, // y1 values
2512            0.5, 0.6, 0.7, // x2 values
2513            0.5, 0.6, 0.7, // y2 values
2514            0.9, 0.1, 0.2, // confidence scores
2515            0.0, 1.0, 2.0, // class indices
2516        ];
2517        let output = Array2::from_shape_vec((6, 3), data).unwrap();
2518
2519        let mut boxes = Vec::with_capacity(10);
2520        decode_yolo_end_to_end_det_float(output.view(), 0.5, cap_or_default(&boxes), &mut boxes)
2521            .unwrap();
2522
2523        // Only 1 detection should pass threshold of 0.5
2524        assert_eq!(boxes.len(), 1);
2525        assert_eq!(boxes[0].label, 0);
2526        assert!((boxes[0].score - 0.9).abs() < 0.01);
2527        assert!((boxes[0].bbox.xmin - 0.1).abs() < 0.01);
2528        assert!((boxes[0].bbox.ymin - 0.1).abs() < 0.01);
2529        assert!((boxes[0].bbox.xmax - 0.5).abs() < 0.01);
2530        assert!((boxes[0].bbox.ymax - 0.5).abs() < 0.01);
2531    }
2532
2533    #[test]
2534    fn test_end_to_end_det_all_pass_threshold() {
2535        // All detections above threshold
2536        let data: Vec<f32> = vec![
2537            10.0, 20.0, // x1
2538            10.0, 20.0, // y1
2539            50.0, 60.0, // x2
2540            50.0, 60.0, // y2
2541            0.8, 0.7, // conf (both above 0.5)
2542            1.0, 2.0, // class
2543        ];
2544        let output = Array2::from_shape_vec((6, 2), data).unwrap();
2545
2546        let mut boxes = Vec::with_capacity(10);
2547        decode_yolo_end_to_end_det_float(output.view(), 0.5, cap_or_default(&boxes), &mut boxes)
2548            .unwrap();
2549
2550        assert_eq!(boxes.len(), 2);
2551        assert_eq!(boxes[0].label, 1);
2552        assert_eq!(boxes[1].label, 2);
2553    }
2554
2555    #[test]
2556    fn test_end_to_end_det_none_pass_threshold() {
2557        // All detections below threshold
2558        let data: Vec<f32> = vec![
2559            10.0, 20.0, // x1
2560            10.0, 20.0, // y1
2561            50.0, 60.0, // x2
2562            50.0, 60.0, // y2
2563            0.1, 0.2, // conf (both below 0.5)
2564            1.0, 2.0, // class
2565        ];
2566        let output = Array2::from_shape_vec((6, 2), data).unwrap();
2567
2568        let mut boxes = Vec::with_capacity(10);
2569        decode_yolo_end_to_end_det_float(output.view(), 0.5, cap_or_default(&boxes), &mut boxes)
2570            .unwrap();
2571
2572        assert_eq!(boxes.len(), 0);
2573    }
2574
2575    #[test]
2576    fn test_end_to_end_det_capacity_limit() {
2577        // Test that output is truncated to capacity
2578        let data: Vec<f32> = vec![
2579            0.1, 0.2, 0.3, 0.4, 0.5, // x1
2580            0.1, 0.2, 0.3, 0.4, 0.5, // y1
2581            0.5, 0.6, 0.7, 0.8, 0.9, // x2
2582            0.5, 0.6, 0.7, 0.8, 0.9, // y2
2583            0.9, 0.9, 0.9, 0.9, 0.9, // conf (all pass)
2584            0.0, 1.0, 2.0, 3.0, 4.0, // class
2585        ];
2586        let output = Array2::from_shape_vec((6, 5), data).unwrap();
2587
2588        let mut boxes = Vec::with_capacity(2); // Only allow 2 boxes
2589        decode_yolo_end_to_end_det_float(output.view(), 0.5, cap_or_default(&boxes), &mut boxes)
2590            .unwrap();
2591
2592        assert_eq!(boxes.len(), 2);
2593    }
2594
2595    #[test]
2596    fn test_end_to_end_det_empty_output() {
2597        // Test with zero detections
2598        let output = Array2::<f32>::zeros((6, 0));
2599
2600        let mut boxes = Vec::with_capacity(10);
2601        decode_yolo_end_to_end_det_float(output.view(), 0.5, cap_or_default(&boxes), &mut boxes)
2602            .unwrap();
2603
2604        assert_eq!(boxes.len(), 0);
2605    }
2606
2607    #[test]
2608    fn test_end_to_end_det_pixel_coordinates() {
2609        // Test with pixel coordinates (non-normalized)
2610        let data: Vec<f32> = vec![
2611            100.0, // x1
2612            200.0, // y1
2613            300.0, // x2
2614            400.0, // y2
2615            0.95,  // conf
2616            5.0,   // class
2617        ];
2618        let output = Array2::from_shape_vec((6, 1), data).unwrap();
2619
2620        let mut boxes = Vec::with_capacity(10);
2621        decode_yolo_end_to_end_det_float(output.view(), 0.5, cap_or_default(&boxes), &mut boxes)
2622            .unwrap();
2623
2624        assert_eq!(boxes.len(), 1);
2625        assert_eq!(boxes[0].label, 5);
2626        assert!((boxes[0].bbox.xmin - 100.0).abs() < 0.01);
2627        assert!((boxes[0].bbox.ymin - 200.0).abs() < 0.01);
2628        assert!((boxes[0].bbox.xmax - 300.0).abs() < 0.01);
2629        assert!((boxes[0].bbox.ymax - 400.0).abs() < 0.01);
2630    }
2631
2632    #[test]
2633    fn test_end_to_end_det_invalid_shape() {
2634        // Test with too few rows (needs at least 6)
2635        let output = Array2::<f32>::zeros((5, 3));
2636
2637        let mut boxes = Vec::with_capacity(10);
2638        let result = decode_yolo_end_to_end_det_float(
2639            output.view(),
2640            0.5,
2641            cap_or_default(&boxes),
2642            &mut boxes,
2643        );
2644
2645        assert!(result.is_err());
2646        assert!(matches!(
2647            result,
2648            Err(crate::DecoderError::InvalidShape(s)) if s.contains("at least 6 rows")
2649        ));
2650    }
2651
2652    // ========================================================================
2653    // Tests for decode_yolo_end_to_end_segdet_float
2654    // ========================================================================
2655
2656    #[test]
2657    fn test_end_to_end_segdet_basic() {
2658        // Create synthetic segdet output: (6 + num_protos, N)
2659        // Detection format: [x1, y1, x2, y2, conf, class, mask_coeff_0..31]
2660        let num_protos = 32;
2661        let num_detections = 2;
2662        let num_features = 6 + num_protos;
2663
2664        // Build detection tensor
2665        let mut data = vec![0.0f32; num_features * num_detections];
2666        // Detection 0: passes threshold
2667        data[0] = 0.1; // x1[0]
2668        data[1] = 0.5; // x1[1]
2669        data[num_detections] = 0.1; // y1[0]
2670        data[num_detections + 1] = 0.5; // y1[1]
2671        data[2 * num_detections] = 0.4; // x2[0]
2672        data[2 * num_detections + 1] = 0.9; // x2[1]
2673        data[3 * num_detections] = 0.4; // y2[0]
2674        data[3 * num_detections + 1] = 0.9; // y2[1]
2675        data[4 * num_detections] = 0.9; // conf[0] - passes
2676        data[4 * num_detections + 1] = 0.3; // conf[1] - fails
2677        data[5 * num_detections] = 1.0; // class[0]
2678        data[5 * num_detections + 1] = 2.0; // class[1]
2679                                            // Fill mask coefficients with small values
2680        for i in 6..num_features {
2681            data[i * num_detections] = 0.1;
2682            data[i * num_detections + 1] = 0.1;
2683        }
2684
2685        let output = Array2::from_shape_vec((num_features, num_detections), data).unwrap();
2686
2687        // Create protos tensor: (proto_height, proto_width, num_protos)
2688        let protos = Array3::<f32>::zeros((16, 16, num_protos));
2689
2690        let mut boxes = Vec::with_capacity(10);
2691        let mut masks = Vec::with_capacity(10);
2692        decode_yolo_end_to_end_segdet_float(
2693            output.view(),
2694            protos.view(),
2695            0.5,
2696            cap_or_default(&boxes),
2697            &mut boxes,
2698            &mut masks,
2699        )
2700        .unwrap();
2701
2702        // Only detection 0 should pass
2703        assert_eq!(boxes.len(), 1);
2704        assert_eq!(masks.len(), 1);
2705        assert_eq!(boxes[0].label, 1);
2706        assert!((boxes[0].score - 0.9).abs() < 0.01);
2707    }
2708
2709    #[test]
2710    fn test_end_to_end_segdet_mask_coordinates() {
2711        // Test that mask coordinates match box coordinates
2712        let num_protos = 32;
2713        let num_features = 6 + num_protos;
2714
2715        let mut data = vec![0.0f32; num_features];
2716        data[0] = 0.2; // x1
2717        data[1] = 0.2; // y1
2718        data[2] = 0.8; // x2
2719        data[3] = 0.8; // y2
2720        data[4] = 0.95; // conf
2721        data[5] = 3.0; // class
2722
2723        let output = Array2::from_shape_vec((num_features, 1), data).unwrap();
2724        let protos = Array3::<f32>::zeros((16, 16, num_protos));
2725
2726        let mut boxes = Vec::with_capacity(10);
2727        let mut masks = Vec::with_capacity(10);
2728        decode_yolo_end_to_end_segdet_float(
2729            output.view(),
2730            protos.view(),
2731            0.5,
2732            cap_or_default(&boxes),
2733            &mut boxes,
2734            &mut masks,
2735        )
2736        .unwrap();
2737
2738        assert_eq!(boxes.len(), 1);
2739        assert_eq!(masks.len(), 1);
2740
2741        // Mask region is the proto-grid-aligned crop and encloses the
2742        // post-NMS bbox (EDGEAI-1304); on a 16x16 grid each side may snap
2743        // by up to 1/16 = 0.0625.
2744        let step = 1.0 / 16.0;
2745        assert!(masks[0].xmin <= boxes[0].bbox.xmin);
2746        assert!(masks[0].ymin <= boxes[0].bbox.ymin);
2747        assert!(masks[0].xmax >= boxes[0].bbox.xmax);
2748        assert!(masks[0].ymax >= boxes[0].bbox.ymax);
2749        assert!((boxes[0].bbox.xmin - masks[0].xmin) < step);
2750        assert!((boxes[0].bbox.ymin - masks[0].ymin) < step);
2751        assert!((masks[0].xmax - boxes[0].bbox.xmax) < step);
2752        assert!((masks[0].ymax - boxes[0].bbox.ymax) < step);
2753    }
2754
2755    #[test]
2756    fn test_end_to_end_segdet_empty_output() {
2757        let num_protos = 32;
2758        let output = Array2::<f32>::zeros((6 + num_protos, 0));
2759        let protos = Array3::<f32>::zeros((16, 16, num_protos));
2760
2761        let mut boxes = Vec::with_capacity(10);
2762        let mut masks = Vec::with_capacity(10);
2763        decode_yolo_end_to_end_segdet_float(
2764            output.view(),
2765            protos.view(),
2766            0.5,
2767            cap_or_default(&boxes),
2768            &mut boxes,
2769            &mut masks,
2770        )
2771        .unwrap();
2772
2773        assert_eq!(boxes.len(), 0);
2774        assert_eq!(masks.len(), 0);
2775    }
2776
2777    #[test]
2778    fn test_end_to_end_segdet_capacity_limit() {
2779        let num_protos = 32;
2780        let num_detections = 5;
2781        let num_features = 6 + num_protos;
2782
2783        let mut data = vec![0.0f32; num_features * num_detections];
2784        // All detections pass threshold
2785        for i in 0..num_detections {
2786            data[i] = 0.1 * (i as f32); // x1
2787            data[num_detections + i] = 0.1 * (i as f32); // y1
2788            data[2 * num_detections + i] = 0.1 * (i as f32) + 0.2; // x2
2789            data[3 * num_detections + i] = 0.1 * (i as f32) + 0.2; // y2
2790            data[4 * num_detections + i] = 0.9; // conf
2791            data[5 * num_detections + i] = i as f32; // class
2792        }
2793
2794        let output = Array2::from_shape_vec((num_features, num_detections), data).unwrap();
2795        let protos = Array3::<f32>::zeros((16, 16, num_protos));
2796
2797        let mut boxes = Vec::with_capacity(2); // Limit to 2
2798        let mut masks = Vec::with_capacity(2);
2799        decode_yolo_end_to_end_segdet_float(
2800            output.view(),
2801            protos.view(),
2802            0.5,
2803            cap_or_default(&boxes),
2804            &mut boxes,
2805            &mut masks,
2806        )
2807        .unwrap();
2808
2809        assert_eq!(boxes.len(), 2);
2810        assert_eq!(masks.len(), 2);
2811    }
2812
2813    #[test]
2814    fn test_end_to_end_segdet_invalid_shape_too_few_rows() {
2815        // Test with too few rows (needs at least 7: 6 base + 1 mask coeff)
2816        let output = Array2::<f32>::zeros((6, 3));
2817        let protos = Array3::<f32>::zeros((16, 16, 32));
2818
2819        let mut boxes = Vec::with_capacity(10);
2820        let mut masks = Vec::with_capacity(10);
2821        let result = decode_yolo_end_to_end_segdet_float(
2822            output.view(),
2823            protos.view(),
2824            0.5,
2825            cap_or_default(&boxes),
2826            &mut boxes,
2827            &mut masks,
2828        );
2829
2830        assert!(result.is_err());
2831        assert!(matches!(
2832            result,
2833            Err(crate::DecoderError::InvalidShape(s)) if s.contains("at least 7 rows")
2834        ));
2835    }
2836
2837    #[test]
2838    fn test_end_to_end_segdet_invalid_shape_protos_mismatch() {
2839        // Test with mismatched mask coefficients and protos count
2840        let num_protos = 32;
2841        let output = Array2::<f32>::zeros((6 + 16, 3)); // 16 mask coeffs
2842        let protos = Array3::<f32>::zeros((16, 16, num_protos)); // 32 protos
2843
2844        let mut boxes = Vec::with_capacity(10);
2845        let mut masks = Vec::with_capacity(10);
2846        let result = decode_yolo_end_to_end_segdet_float(
2847            output.view(),
2848            protos.view(),
2849            0.5,
2850            cap_or_default(&boxes),
2851            &mut boxes,
2852            &mut masks,
2853        );
2854
2855        assert!(result.is_err());
2856        assert!(matches!(
2857            result,
2858            Err(crate::DecoderError::InvalidShape(s)) if s.contains("doesn't match protos count")
2859        ));
2860    }
2861
2862    // ========================================================================
2863    // Tests for decode_yolo_split_end_to_end_segdet_float
2864    // ========================================================================
2865
2866    #[test]
2867    fn test_split_end_to_end_segdet_basic() {
2868        // Create synthetic segdet output: (6 + num_protos, N)
2869        // Detection format: [x1, y1, x2, y2, conf, class, mask_coeff_0..31]
2870        let num_protos = 32;
2871        let num_detections = 2;
2872        let num_features = 6 + num_protos;
2873
2874        // Build detection tensor
2875        let mut data = vec![0.0f32; num_features * num_detections];
2876        // Detection 0: passes threshold
2877        data[0] = 0.1; // x1[0]
2878        data[1] = 0.5; // x1[1]
2879        data[num_detections] = 0.1; // y1[0]
2880        data[num_detections + 1] = 0.5; // y1[1]
2881        data[2 * num_detections] = 0.4; // x2[0]
2882        data[2 * num_detections + 1] = 0.9; // x2[1]
2883        data[3 * num_detections] = 0.4; // y2[0]
2884        data[3 * num_detections + 1] = 0.9; // y2[1]
2885        data[4 * num_detections] = 0.9; // conf[0] - passes
2886        data[4 * num_detections + 1] = 0.3; // conf[1] - fails
2887        data[5 * num_detections] = 1.0; // class[0]
2888        data[5 * num_detections + 1] = 2.0; // class[1]
2889                                            // Fill mask coefficients with small values
2890        for i in 6..num_features {
2891            data[i * num_detections] = 0.1;
2892            data[i * num_detections + 1] = 0.1;
2893        }
2894
2895        let output = Array2::from_shape_vec((num_features, num_detections), data).unwrap();
2896        let box_coords = output.slice(s![..4, ..]);
2897        let scores = output.slice(s![4..5, ..]);
2898        let classes = output.slice(s![5..6, ..]);
2899        let mask_coeff = output.slice(s![6.., ..]);
2900        // Create protos tensor: (proto_height, proto_width, num_protos)
2901        let protos = Array3::<f32>::zeros((16, 16, num_protos));
2902
2903        let mut boxes = Vec::with_capacity(10);
2904        let mut masks = Vec::with_capacity(10);
2905        decode_yolo_split_end_to_end_segdet_float(
2906            box_coords,
2907            scores,
2908            classes,
2909            mask_coeff,
2910            protos.view(),
2911            0.5,
2912            cap_or_default(&boxes),
2913            &mut boxes,
2914            &mut masks,
2915        )
2916        .unwrap();
2917
2918        // Only detection 0 should pass
2919        assert_eq!(boxes.len(), 1);
2920        assert_eq!(masks.len(), 1);
2921        assert_eq!(boxes[0].label, 1);
2922        assert!((boxes[0].score - 0.9).abs() < 0.01);
2923    }
2924
2925    // ========================================================================
2926    // Tests for yolo_segmentation_to_mask
2927    // ========================================================================
2928
2929    #[test]
2930    fn test_segmentation_to_mask_basic() {
2931        // Create a 4x4x1 segmentation with values above and below threshold
2932        let data: Vec<u8> = vec![
2933            100, 200, 50, 150, // row 0
2934            10, 255, 128, 64, // row 1
2935            0, 127, 128, 255, // row 2
2936            64, 64, 192, 192, // row 3
2937        ];
2938        let segmentation = Array3::from_shape_vec((4, 4, 1), data).unwrap();
2939
2940        let mask = yolo_segmentation_to_mask(segmentation.view(), 128).unwrap();
2941
2942        // Values >= 128 should be 1, others 0
2943        assert_eq!(mask[[0, 0]], 0); // 100 < 128
2944        assert_eq!(mask[[0, 1]], 1); // 200 >= 128
2945        assert_eq!(mask[[0, 2]], 0); // 50 < 128
2946        assert_eq!(mask[[0, 3]], 1); // 150 >= 128
2947        assert_eq!(mask[[1, 1]], 1); // 255 >= 128
2948        assert_eq!(mask[[1, 2]], 1); // 128 >= 128
2949        assert_eq!(mask[[2, 0]], 0); // 0 < 128
2950        assert_eq!(mask[[2, 1]], 0); // 127 < 128
2951    }
2952
2953    #[test]
2954    fn test_segmentation_to_mask_all_above() {
2955        let segmentation = Array3::from_elem((4, 4, 1), 255u8);
2956        let mask = yolo_segmentation_to_mask(segmentation.view(), 128).unwrap();
2957        assert!(mask.iter().all(|&x| x == 1));
2958    }
2959
2960    #[test]
2961    fn test_segmentation_to_mask_all_below() {
2962        let segmentation = Array3::from_elem((4, 4, 1), 64u8);
2963        let mask = yolo_segmentation_to_mask(segmentation.view(), 128).unwrap();
2964        assert!(mask.iter().all(|&x| x == 0));
2965    }
2966
2967    #[test]
2968    fn test_segmentation_to_mask_invalid_shape() {
2969        let segmentation = Array3::from_elem((4, 4, 3), 128u8);
2970        let result = yolo_segmentation_to_mask(segmentation.view(), 128);
2971
2972        assert!(result.is_err());
2973        assert!(matches!(
2974            result,
2975            Err(crate::DecoderError::InvalidShape(s)) if s.contains("(H, W, 1)")
2976        ));
2977    }
2978
2979    // ========================================================================
2980    // Tests for protobox / NORM_LIMIT regression
2981    // ========================================================================
2982
2983    #[test]
2984    fn test_protobox_clamps_edge_coordinates() {
2985        // bbox with xmax=1.0 should not panic (OOB guard)
2986        let protos = Array3::<f32>::zeros((16, 16, 4));
2987        let view = protos.view();
2988        let roi = BoundingBox {
2989            xmin: 0.5,
2990            ymin: 0.5,
2991            xmax: 1.0,
2992            ymax: 1.0,
2993        };
2994        let result = protobox(&view, &roi);
2995        assert!(result.is_ok(), "protobox should accept xmax=1.0");
2996        let (cropped, _roi_norm) = result.unwrap();
2997        // Cropped region must have non-zero spatial dimensions
2998        assert!(cropped.shape()[0] > 0);
2999        assert!(cropped.shape()[1] > 0);
3000        assert_eq!(cropped.shape()[2], 4);
3001    }
3002
3003    #[test]
3004    fn test_protobox_rejects_wildly_out_of_range() {
3005        // bbox with coords > NORM_LIMIT (e.g. 3.0) returns error
3006        let protos = Array3::<f32>::zeros((16, 16, 4));
3007        let view = protos.view();
3008        let roi = BoundingBox {
3009            xmin: 0.0,
3010            ymin: 0.0,
3011            xmax: 3.0,
3012            ymax: 3.0,
3013        };
3014        let result = protobox(&view, &roi);
3015        assert!(
3016            matches!(result, Err(crate::DecoderError::InvalidShape(s)) if s.contains("un-normalized")),
3017            "protobox should reject coords > NORM_LIMIT"
3018        );
3019    }
3020
3021    #[test]
3022    fn test_protobox_accepts_slightly_over_one() {
3023        // bbox with coords at 1.5 (within NORM_LIMIT=2.0) succeeds
3024        let protos = Array3::<f32>::zeros((16, 16, 4));
3025        let view = protos.view();
3026        let roi = BoundingBox {
3027            xmin: 0.0,
3028            ymin: 0.0,
3029            xmax: 1.5,
3030            ymax: 1.5,
3031        };
3032        let result = protobox(&view, &roi);
3033        assert!(
3034            result.is_ok(),
3035            "protobox should accept coords <= NORM_LIMIT (2.0)"
3036        );
3037        let (cropped, _roi_norm) = result.unwrap();
3038        // Entire proto map should be selected when coords > 1.0 (clamped to boundary)
3039        assert_eq!(cropped.shape()[0], 16);
3040        assert_eq!(cropped.shape()[1], 16);
3041    }
3042
3043    #[test]
3044    fn test_segdet_float_proto_no_panic() {
3045        // Simulates YOLOv8n-seg: output0 = [116, 8400] (4 box + 80 class + 32 mask coeff)
3046        // output1 (protos) = [32, 160, 160]
3047        let num_proposals = 100; // enough to produce idx >= 32
3048        let num_classes = 80;
3049        let num_mask_coeffs = 32;
3050        let rows = 4 + num_classes + num_mask_coeffs; // 116
3051
3052        // Fill boxes with valid xywh data so some detections pass the threshold.
3053        // Layout is [116, num_proposals] row-major: row 0=cx, 1=cy, 2=w, 3=h,
3054        // rows 4..84=class scores, rows 84..116=mask coefficients.
3055        let mut data = vec![0.0f32; rows * num_proposals];
3056        for i in 0..num_proposals {
3057            let row = |r: usize| r * num_proposals + i;
3058            data[row(0)] = 320.0; // cx
3059            data[row(1)] = 320.0; // cy
3060            data[row(2)] = 50.0; // w
3061            data[row(3)] = 50.0; // h
3062            data[row(4)] = 0.9; // class-0 score
3063        }
3064        let boxes = ndarray::Array2::from_shape_vec((rows, num_proposals), data).unwrap();
3065
3066        // Protos must be in HWC order. Under the HAL physical-order
3067        // contract, callers declare shape+dshape matching producer memory
3068        // and swap_axes_if_needed permutes the stride tuple into canonical
3069        // [batch, height, width, num_protos] before this function sees it.
3070        let protos = ndarray::Array3::<f32>::zeros((160, 160, num_mask_coeffs));
3071
3072        let mut output_boxes = Vec::with_capacity(300);
3073
3074        // This panicked before fix: mask_tensor.row(idx) with idx >= 32
3075        let proto_data = impl_yolo_segdet_float_proto::<XYWH, _, _>(
3076            boxes.view(),
3077            protos.view(),
3078            0.5,
3079            0.7,
3080            Some(Nms::ClassAgnostic),
3081            MAX_NMS_CANDIDATES,
3082            300,
3083            None,
3084            None,
3085            false, // multi_label: argmax for this test
3086            &mut output_boxes,
3087        )
3088        .unwrap();
3089
3090        // Should produce detections (NMS will collapse many overlapping boxes)
3091        assert!(!output_boxes.is_empty());
3092        let coeffs_shape = proto_data.mask_coefficients.shape();
3093        assert_eq!(coeffs_shape[0], output_boxes.len());
3094        // Each mask coefficient vector should have 32 elements
3095        assert_eq!(coeffs_shape[1], num_mask_coeffs);
3096    }
3097
3098    // ========================================================================
3099    // Pre-NMS top-K cap (MAX_NMS_CANDIDATES)
3100    // ========================================================================
3101
3102    /// At very low score thresholds (e.g., t=0.01 on YOLOv8 with 8400×80
3103    /// candidates) almost every score passes the filter, feeding O(n²)
3104    /// NMS and a per-survivor mask matmul. The decoder caps the
3105    /// candidate set fed to NMS at `MAX_NMS_CANDIDATES` (Ultralytics
3106    /// default 30 000) to bound worst-case decode time.
3107    ///
3108    /// This regression test pumps 50 000 above-threshold candidates
3109    /// into `impl_yolo_segdet_get_boxes` with NMS bypassed (Nms=None)
3110    /// and a generous post-NMS cap. Before the fix, the function
3111    /// returned all 50 000; after the fix, exactly 30 000.
3112    #[test]
3113    fn test_pre_nms_cap_truncates_excess_candidates() {
3114        let n: usize = 50_000;
3115        let num_classes = 1;
3116
3117        // Identical valid boxes. Distinct scores (descending) so the
3118        // top-K cap keeps the highest-scoring ones in deterministic
3119        // order — letting us assert *which* ones survived.
3120        let mut boxes_data = Vec::with_capacity(n * 4);
3121        let mut scores_data = Vec::with_capacity(n * num_classes);
3122        for i in 0..n {
3123            boxes_data.extend_from_slice(&[0.1f32, 0.1, 0.5, 0.5]);
3124            // score_i = 0.99 - i * 1e-7 keeps everything well above 0.1
3125            // threshold but strictly decreasing.
3126            scores_data.push(0.99 - (i as f32) * 1e-7);
3127        }
3128        let boxes = Array2::from_shape_vec((n, 4), boxes_data).unwrap();
3129        let scores = Array2::from_shape_vec((n, num_classes), scores_data).unwrap();
3130
3131        let result = impl_yolo_segdet_get_boxes::<XYXY, _, _>(
3132            boxes.view(),
3133            scores.view(),
3134            0.1,
3135            1.0,                             // IoU 1.0 → NMS suppresses nothing
3136            Some(Nms::ClassAgnostic),        // NMS enabled so pre_nms_top_k applies
3137            crate::yolo::MAX_NMS_CANDIDATES, // pre_nms_top_k
3138            usize::MAX,                      // no post-NMS truncation
3139            false,                           // multi_label: argmax for this test
3140        )
3141        .unwrap();
3142
3143        assert_eq!(
3144            result.len(),
3145            crate::yolo::MAX_NMS_CANDIDATES,
3146            "pre-NMS cap should truncate to MAX_NMS_CANDIDATES; got {}",
3147            result.len()
3148        );
3149        // Top-K survivors: highest scores were the first n indices,
3150        // so survivor 0 must have score ~0.99.
3151        let top_score = result[0].0.score;
3152        assert!(
3153            top_score > 0.98,
3154            "highest-ranked survivor should have the largest score, got {top_score}"
3155        );
3156    }
3157
3158    /// Counterpart for the quantized split path. Same contract: feed
3159    /// more than `MAX_NMS_CANDIDATES` survivors above the quantized
3160    /// threshold, confirm `impl_yolo_split_segdet_quant_get_boxes`
3161    /// truncates before NMS.
3162    #[test]
3163    fn test_pre_nms_cap_truncates_excess_candidates_quant() {
3164        use crate::Quantization;
3165        let n: usize = 50_000;
3166        let num_classes = 1;
3167
3168        // i8 boxes with simple scale/zp; the box value 50 dequantizes
3169        // to 0.5 with scale=0.01, zp=0 — fine for a flat box set.
3170        let boxes_data = (0..n).flat_map(|_| [10i8, 10, 50, 50]).collect::<Vec<_>>();
3171        let boxes = Array2::from_shape_vec((n, 4), boxes_data).unwrap();
3172        let quant_boxes = Quantization {
3173            scale: 0.01,
3174            zero_point: 0,
3175        };
3176
3177        // u8 scores: distinct descending values, all well above threshold.
3178        // value 250 → 0.98 with scale 0.00392, zp 0.
3179        // value (250 - i % 200) keeps a wide spread above the dequant
3180        // threshold of 0.5.
3181        let scores_data: Vec<u8> = (0..n)
3182            .map(|i| 250u8.saturating_sub((i % 200) as u8))
3183            .collect();
3184        let scores = Array2::from_shape_vec((n, num_classes), scores_data).unwrap();
3185        let quant_scores = Quantization {
3186            scale: 0.00392,
3187            zero_point: 0,
3188        };
3189
3190        let result = impl_yolo_split_segdet_quant_get_boxes::<XYXY, _, _>(
3191            (boxes.view(), quant_boxes),
3192            (scores.view(), quant_scores),
3193            0.1,
3194            1.0,                             // IoU 1.0 → NMS suppresses nothing
3195            Some(Nms::ClassAgnostic),        // NMS enabled so pre_nms_top_k applies
3196            crate::yolo::MAX_NMS_CANDIDATES, // pre_nms_top_k
3197            usize::MAX,                      // no post-NMS truncation
3198            false,
3199        )
3200        .unwrap();
3201
3202        assert_eq!(
3203            result.len(),
3204            crate::yolo::MAX_NMS_CANDIDATES,
3205            "quant path pre-NMS cap should truncate to MAX_NMS_CANDIDATES; got {}",
3206            result.len()
3207        );
3208    }
3209
3210    /// Regression test for HAILORT_BUG.md — the YoloSegDet path
3211    /// (combined `(4 + nc + nm, N)` detection tensor + separate protos)
3212    /// must pair each surviving detection with the mask coefficient
3213    /// row at the SAME anchor index the box came from. The validator
3214    /// sees this path miss the pairing under schema-v2 Hailo inputs
3215    /// (mAP collapse from 46.8 → 3.65 while mask IoU stays at 66.9,
3216    /// the fingerprint of mask-to-detection misalignment).
3217    ///
3218    /// Construction: three anchors with distinct mask-coef signatures
3219    /// that, after dot(coefs, protos) + sigmoid, produce HIGH vs LOW
3220    /// mask pixel values. Two anchors survive (one high, one low); if
3221    /// the mask row is looked up at the wrong index, the per-detection
3222    /// mean mask value would cross the threshold and we catch it.
3223    #[test]
3224    fn segdet_combined_tensor_pairs_detection_with_matching_mask_row() {
3225        let nc = 2; // num_classes
3226        let nm = 2; // num_protos
3227        let n = 3; // num_anchors
3228        let feat = 4 + nc + nm; // 8
3229
3230        // Tensor layout: (8, 3) rows=features, cols=anchors.
3231        // Row indices:  0..4 = xywh, 4..6 = scores, 6..8 = mask_coefs.
3232        //
3233        //         anchor 0 | anchor 1 | anchor 2
3234        // xc       0.2      | 0.5      | 0.8
3235        // yc       0.2      | 0.5      | 0.8
3236        // w        0.1      | 0.1      | 0.1
3237        // h        0.1      | 0.1      | 0.1
3238        // s[0]     0.9      | 0.0      | 0.8   (class 0)
3239        // s[1]     0.0      | 0.0      | 0.0   (class 1 — always loses)
3240        // m[0]     3.0      | 0.0      | -3.0  (high for a0, low for a2)
3241        // m[1]     3.0      | 0.0      | -3.0
3242        //
3243        // Proto[0] = Proto[1] = all-ones (8x8), so
3244        //   mask(a0) = sigmoid(3 + 3) ≈ 0.9975 → 254
3245        //   mask(a2) = sigmoid(-3 + -3) ≈ 0.0025 → 1
3246        // 250-point gap makes any misalignment trivially detectable.
3247        let mut data = vec![0.0f32; feat * n];
3248        let set = |d: &mut [f32], r: usize, c: usize, v: f32| d[r * n + c] = v;
3249        set(&mut data, 0, 0, 0.2);
3250        set(&mut data, 1, 0, 0.2);
3251        set(&mut data, 2, 0, 0.1);
3252        set(&mut data, 3, 0, 0.1);
3253        set(&mut data, 0, 1, 0.5);
3254        set(&mut data, 1, 1, 0.5);
3255        set(&mut data, 2, 1, 0.1);
3256        set(&mut data, 3, 1, 0.1);
3257        set(&mut data, 0, 2, 0.8);
3258        set(&mut data, 1, 2, 0.8);
3259        set(&mut data, 2, 2, 0.1);
3260        set(&mut data, 3, 2, 0.1);
3261        set(&mut data, 4, 0, 0.9);
3262        set(&mut data, 4, 2, 0.8);
3263        set(&mut data, 6, 0, 3.0);
3264        set(&mut data, 7, 0, 3.0);
3265        set(&mut data, 6, 2, -3.0);
3266        set(&mut data, 7, 2, -3.0);
3267
3268        let output = Array2::from_shape_vec((feat, n), data).unwrap();
3269        let protos = Array3::<f32>::from_elem((8, 8, nm), 1.0);
3270
3271        let mut boxes: Vec<DetectBox> = Vec::with_capacity(10);
3272        let mut masks: Vec<Segmentation> = Vec::with_capacity(10);
3273        decode_yolo_segdet_float(
3274            output.view(),
3275            protos.view(),
3276            0.5,
3277            0.5,
3278            Some(Nms::ClassAgnostic),
3279            &mut boxes,
3280            &mut masks,
3281        )
3282        .unwrap();
3283
3284        assert_eq!(
3285            boxes.len(),
3286            2,
3287            "two anchors above threshold should survive (a0 score=0.9, a2 score=0.8); got {}",
3288            boxes.len()
3289        );
3290
3291        // Build a (anchor_index → mask_mean) mapping from the results.
3292        // Anchor 0 has centre (0.2, 0.2), anchor 2 has centre (0.8,
3293        // 0.8). The DetectBox bbox is the post-XYWH-to-XYXY conversion
3294        // of the original xywh; cropping inside protobox may shrink it,
3295        // so match by centre (0.2 vs 0.8) rather than exact bbox.
3296        for (b, m) in boxes.iter().zip(masks.iter()) {
3297            let cx = (b.bbox.xmin + b.bbox.xmax) * 0.5;
3298            let mean = {
3299                use edgefirst_tensor::{TensorMapTrait as _, TensorTrait as _};
3300                let t = m.segmentation.as_u8().expect("mask must be U8");
3301                let map = t.map_read().expect("map mask");
3302                let s = map.as_slice();
3303                let total: u32 = s.iter().map(|&v| v as u32).sum();
3304                total as f32 / s.len() as f32
3305            };
3306            if cx < 0.3 {
3307                // anchor 0 — expect HIGH mask values ≈ 254
3308                assert!(
3309                    mean > 200.0,
3310                    "anchor 0 detection (centre {cx:.2}) should have high-value mask; got mean {mean}"
3311                );
3312            } else if cx > 0.7 {
3313                // anchor 2 — expect LOW mask values ≈ 1
3314                assert!(
3315                    mean < 50.0,
3316                    "anchor 2 detection (centre {cx:.2}) should have low-value mask; got mean {mean}"
3317                );
3318            } else {
3319                panic!("unexpected detection centre {cx:.2}");
3320            }
3321        }
3322    }
3323
3324    // ========================================================================
3325    // Tests for truncate_to_top_k_by_score (float and quantized)
3326    // ========================================================================
3327
3328    /// Helper: build a Vec of (DetectBox, ()) with the given scores.
3329    fn make_float_boxes(scores: &[f32]) -> Vec<(DetectBox, ())> {
3330        scores
3331            .iter()
3332            .enumerate()
3333            .map(|(i, &s)| {
3334                (
3335                    DetectBox {
3336                        bbox: BoundingBox {
3337                            xmin: 0.0,
3338                            ymin: 0.0,
3339                            xmax: 1.0,
3340                            ymax: 1.0,
3341                        },
3342                        score: s,
3343                        label: i,
3344                    },
3345                    (),
3346                )
3347            })
3348            .collect()
3349    }
3350
3351    /// Helper: build a Vec of (DetectBoxQuantized<i8>, ()) with the given scores.
3352    fn make_quant_boxes(scores: &[i8]) -> Vec<(DetectBoxQuantized<i8>, ())> {
3353        scores
3354            .iter()
3355            .enumerate()
3356            .map(|(i, &s)| {
3357                (
3358                    DetectBoxQuantized {
3359                        bbox: BoundingBox {
3360                            xmin: 0.0,
3361                            ymin: 0.0,
3362                            xmax: 1.0,
3363                            ymax: 1.0,
3364                        },
3365                        score: s,
3366                        label: i,
3367                    },
3368                    (),
3369                )
3370            })
3371            .collect()
3372    }
3373
3374    #[test]
3375    fn truncate_float_top_k_zero_is_unbounded() {
3376        let mut boxes = make_float_boxes(&[0.9, 0.1, 0.5, 0.3, 0.7]);
3377        let original_len = boxes.len();
3378        truncate_to_top_k_by_score(&mut boxes, 0);
3379        assert_eq!(
3380            boxes.len(),
3381            original_len,
3382            "top_k=0 should keep all candidates (no-limit semantics)"
3383        );
3384    }
3385
3386    #[test]
3387    fn truncate_float_top_k_normal() {
3388        let mut boxes = make_float_boxes(&[0.9, 0.1, 0.5, 0.3, 0.7]);
3389        truncate_to_top_k_by_score(&mut boxes, 3);
3390        assert_eq!(boxes.len(), 3);
3391        // The top-3 scores should be 0.9, 0.7, 0.5 (order within top-K is unspecified)
3392        let mut retained: Vec<f32> = boxes.iter().map(|(b, _)| b.score).collect();
3393        retained.sort_by(|a, b| b.total_cmp(a));
3394        assert_eq!(retained, vec![0.9, 0.7, 0.5]);
3395    }
3396
3397    #[test]
3398    fn truncate_float_top_k_noop_when_under_cap() {
3399        let mut boxes = make_float_boxes(&[0.9, 0.5]);
3400        truncate_to_top_k_by_score(&mut boxes, 10);
3401        assert_eq!(boxes.len(), 2, "should be no-op when len <= top_k");
3402    }
3403
3404    #[test]
3405    fn truncate_quant_top_k_zero_is_unbounded() {
3406        let mut boxes = make_quant_boxes(&[120, -50, 30, -10, 80]);
3407        let original_len = boxes.len();
3408        truncate_to_top_k_by_score(&mut boxes, 0);
3409        assert_eq!(
3410            boxes.len(),
3411            original_len,
3412            "top_k=0 should keep all candidates (no-limit semantics)"
3413        );
3414    }
3415
3416    #[test]
3417    fn truncate_quant_top_k_normal() {
3418        let mut boxes = make_quant_boxes(&[120, -50, 30, -10, 80]);
3419        truncate_to_top_k_by_score(&mut boxes, 3);
3420        assert_eq!(boxes.len(), 3);
3421        let mut retained: Vec<i8> = boxes.iter().map(|(b, _)| b.score).collect();
3422        retained.sort_by(|a, b| b.cmp(a));
3423        assert_eq!(retained, vec![120, 80, 30]);
3424    }
3425
3426    #[test]
3427    fn truncate_quant_top_k_noop_when_under_cap() {
3428        let mut boxes = make_quant_boxes(&[120, 80]);
3429        truncate_to_top_k_by_score(&mut boxes, 10);
3430        assert_eq!(boxes.len(), 2, "should be no-op when len <= top_k");
3431    }
3432
3433    /// 200 candidates scoring 0.5 except five at 0.9 near the end: a cap of
3434    /// 10 keeps the five 0.9s and the five lowest-index 0.5s, in candidate
3435    /// order.
3436    fn tie_scene() -> (Vec<f32>, Vec<usize>) {
3437        let scores: Vec<f32> = (0..200)
3438            .map(|i| if (150..155).contains(&i) { 0.9 } else { 0.5 })
3439            .collect();
3440        let expected = (0..5).chain(150..155).collect();
3441        (scores, expected)
3442    }
3443
3444    #[test]
3445    fn truncate_breaks_boundary_ties_by_candidate_order() {
3446        let (scores, expected) = tie_scene();
3447        let mut boxes = make_float_boxes(&scores);
3448        truncate_to_top_k_by_score(&mut boxes, 10);
3449        let kept: Vec<usize> = boxes.iter().map(|(b, _)| b.label).collect();
3450        assert_eq!(kept, expected);
3451
3452        let q: Vec<i8> = scores.iter().map(|&s| (s * 100.0) as i8).collect();
3453        let mut boxes = make_quant_boxes(&q);
3454        truncate_to_top_k_by_score(&mut boxes, 10);
3455        let kept: Vec<usize> = boxes.iter().map(|(b, _)| b.label).collect();
3456        assert_eq!(kept, expected);
3457    }
3458
3459    #[test]
3460    fn nms_and_cap_ties_at_pre_nms_cap_keep_lowest_anchors() {
3461        // Every candidate shares one bbox and label, so NMS keeps exactly one:
3462        // the first of the equal-score survivors of the pre-NMS cap.
3463        let (scores, _) = tie_scene();
3464        let boxes: Vec<(DetectBox, usize)> = make_float_boxes(&scores)
3465            .into_iter()
3466            .map(|(mut b, ())| {
3467                let anchor = b.label;
3468                b.label = 0;
3469                (b, anchor)
3470            })
3471            .collect();
3472        let mut low = boxes.clone();
3473        for (b, _) in &mut low {
3474            b.score = 0.5;
3475        }
3476        let kept = nms_and_cap(low, Some(Nms::ClassAware), 0.5, 10, 300);
3477        assert_eq!(kept.iter().map(|(_, a)| *a).collect::<Vec<_>>(), vec![0]);
3478        let kept = nms_and_cap(boxes, Some(Nms::ClassAware), 0.5, 10, 300);
3479        assert_eq!(kept.iter().map(|(_, a)| *a).collect::<Vec<_>>(), vec![150]);
3480    }
3481
3482    #[test]
3483    fn nms_and_cap_bypass_keeps_highest_scores() {
3484        // Anchor order is not score order: the cap must keep scores, not the
3485        // first anchors, and return them in descending score order.
3486        let boxes = make_float_boxes(&[0.1, 0.2, 0.9, 0.3, 0.8, 0.8]);
3487        let kept = nms_and_cap(boxes.clone(), None, 0.5, 0, 3);
3488        let labels: Vec<usize> = kept.iter().map(|(b, _)| b.label).collect();
3489        assert_eq!(labels, vec![2, 4, 5]);
3490        // Without truncation every candidate stays, still in score order.
3491        let kept = nms_and_cap(boxes, None, 0.5, 0, 300);
3492        let labels: Vec<usize> = kept.iter().map(|(b, _)| b.label).collect();
3493        assert_eq!(labels, vec![2, 4, 5, 3, 1, 0]);
3494
3495        let q = make_quant_boxes(&[10, 20, 90, 30, 80, 80]);
3496        let kept = nms_and_cap(q, None, 0.5, 0, 2);
3497        let labels: Vec<usize> = kept.iter().map(|(b, _)| b.label).collect();
3498        assert_eq!(labels, vec![2, 4]);
3499    }
3500
3501    #[test]
3502    fn nms_and_cap_pre_nms_top_k_zero_is_unbounded() {
3503        // 400 disjoint boxes: a cap of 300 drops 100 before NMS, 0 keeps all.
3504        let boxes: Vec<(DetectBox, usize)> = (0..400)
3505            .map(|i| {
3506                let x = i as f32;
3507                (
3508                    DetectBox {
3509                        bbox: BoundingBox {
3510                            xmin: x,
3511                            ymin: 0.0,
3512                            xmax: x + 0.5,
3513                            ymax: 0.5,
3514                        },
3515                        score: 0.5 + (i % 7) as f32 * 0.01,
3516                        label: 0,
3517                    },
3518                    i,
3519                )
3520            })
3521            .collect();
3522        let capped = nms_and_cap(boxes.clone(), Some(Nms::ClassAware), 0.5, 300, 1000);
3523        assert_eq!(capped.len(), 300);
3524        let all = nms_and_cap(boxes, Some(Nms::ClassAware), 0.5, 0, 1000);
3525        assert_eq!(all.len(), 400);
3526    }
3527
3528    #[test]
3529    #[cfg(debug_assertions)]
3530    #[should_panic(expected = "Nms::Auto must be resolved by the builder")]
3531    fn dispatch_nms_float_rejects_unresolved_auto() {
3532        dispatch_nms_float(Some(Nms::Auto), 0.5, None, Vec::new());
3533    }
3534
3535    #[test]
3536    #[cfg(debug_assertions)]
3537    #[should_panic(expected = "Nms::Auto must be resolved by the builder")]
3538    fn dispatch_nms_int_rejects_unresolved_auto() {
3539        dispatch_nms_int::<i8>(Some(Nms::Auto), 0.5, None, Vec::new());
3540    }
3541}