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