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: crate::mask_to_tensor(m.view())?,
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: crate::mask_to_tensor(m.view())?,
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: coeff_tensor.into(),
1653            protos: protos_tensor.into(),
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        coeff_tensor.into()
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        coeff_tensor.into()
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        coeff_tensor.into()
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: protos_tensor.into(),
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).map(Into::into)
1847    }
1848    fn arrayview3_into_tensor_dyn(
1849        view: ArrayView3<'_, f32>,
1850    ) -> edgefirst_tensor::Result<edgefirst_tensor::TensorDyn> {
1851        edgefirst_tensor::Tensor::<f32>::from_arrayview3(view).map(Into::into)
1852    }
1853}
1854
1855impl FloatProtoElem for half::f16 {
1856    fn slice_into_tensor_dyn(
1857        values: &[half::f16],
1858        shape: &[usize],
1859    ) -> edgefirst_tensor::Result<edgefirst_tensor::TensorDyn> {
1860        edgefirst_tensor::Tensor::<half::f16>::from_slice(values, shape).map(Into::into)
1861    }
1862    fn arrayview3_into_tensor_dyn(
1863        view: ArrayView3<'_, half::f16>,
1864    ) -> edgefirst_tensor::Result<edgefirst_tensor::TensorDyn> {
1865        edgefirst_tensor::Tensor::<half::f16>::from_arrayview3(view).map(Into::into)
1866    }
1867}
1868
1869impl FloatProtoElem for f64 {
1870    fn slice_into_tensor_dyn(
1871        values: &[f64],
1872        shape: &[usize],
1873    ) -> edgefirst_tensor::Result<edgefirst_tensor::TensorDyn> {
1874        // Narrow to f32 — no native f64 kernel path.
1875        let narrowed: Vec<f32> = values.iter().map(|&v| v as f32).collect();
1876        edgefirst_tensor::Tensor::<f32>::from_slice(&narrowed, shape).map(Into::into)
1877    }
1878    fn arrayview3_into_tensor_dyn(
1879        view: ArrayView3<'_, f64>,
1880    ) -> edgefirst_tensor::Result<edgefirst_tensor::TensorDyn> {
1881        let narrowed: ndarray::Array3<f32> = view.mapv(|v| v as f32);
1882        edgefirst_tensor::Tensor::<f32>::from_arrayview3(narrowed.view()).map(Into::into)
1883    }
1884}
1885
1886fn postprocess_yolo<'a, T>(
1887    output: &'a ArrayView2<'_, T>,
1888) -> (ArrayView2<'a, T>, ArrayView2<'a, T>) {
1889    let boxes_tensor = output.slice(s![..4, ..,]).reversed_axes();
1890    let scores_tensor = output.slice(s![4.., ..,]).reversed_axes();
1891    (boxes_tensor, scores_tensor)
1892}
1893
1894pub(crate) fn postprocess_yolo_seg<'a, T>(
1895    output: &'a ArrayView2<'_, T>,
1896    num_protos: usize,
1897) -> (ArrayView2<'a, T>, ArrayView2<'a, T>, ArrayView2<'a, T>) {
1898    assert!(
1899        output.shape()[0] > num_protos + 4,
1900        "Output shape is too short: {} <= {} + 4",
1901        output.shape()[0],
1902        num_protos
1903    );
1904    let num_classes = output.shape()[0] - 4 - num_protos;
1905    let boxes_tensor = output.slice(s![..4, ..,]).reversed_axes();
1906    let scores_tensor = output.slice(s![4..(num_classes + 4), ..,]).reversed_axes();
1907    let mask_tensor = output.slice(s![(num_classes + 4).., ..,]).reversed_axes();
1908    (boxes_tensor, scores_tensor, mask_tensor)
1909}
1910
1911pub(crate) fn postprocess_yolo_split_segdet<'a, 'b, 'c, BOX, SCORE, MASK>(
1912    boxes_tensor: ArrayView2<'a, BOX>,
1913    scores_tensor: ArrayView2<'b, SCORE>,
1914    mask_tensor: ArrayView2<'c, MASK>,
1915) -> (
1916    ArrayView2<'a, BOX>,
1917    ArrayView2<'b, SCORE>,
1918    ArrayView2<'c, MASK>,
1919) {
1920    let boxes_tensor = boxes_tensor.reversed_axes();
1921    let scores_tensor = scores_tensor.reversed_axes();
1922    let mask_tensor = mask_tensor.reversed_axes();
1923    (boxes_tensor, scores_tensor, mask_tensor)
1924}
1925
1926fn decode_segdet_f32<
1927    MASK: Float + AsPrimitive<f32> + Send + Sync,
1928    PROTO: Float + AsPrimitive<f32> + Send + Sync,
1929>(
1930    boxes: Vec<(DetectBox, usize)>,
1931    masks: ArrayView2<MASK>,
1932    protos: ArrayView3<PROTO>,
1933) -> Result<Vec<(DetectBox, BoundingBox, Array3<u8>)>, crate::DecoderError> {
1934    if boxes.is_empty() {
1935        return Ok(Vec::new());
1936    }
1937    if masks.shape()[1] != protos.shape()[2] {
1938        return Err(crate::DecoderError::InvalidShape(format!(
1939            "Mask coefficients count ({}) doesn't match protos channel count ({})",
1940            masks.shape()[1],
1941            protos.shape()[2],
1942        )));
1943    }
1944    boxes
1945        .into_par_iter()
1946        .map(|b| {
1947            let ind = b.1;
1948            // `protobox` returns the cropped proto slice for `make_segmentation`
1949            // and a `roi` snapped to the 1/proto-grid step. The detection bbox
1950            // stays untouched (EDGEAI-1304); the snapped roi is reported back
1951            // separately so callers can describe where the cropped mask lives.
1952            let (protos, roi) = protobox(&protos, &b.0.bbox)?;
1953            Ok((b.0, roi, make_segmentation(masks.row(ind), protos.view())))
1954        })
1955        .collect()
1956}
1957
1958pub(crate) fn decode_segdet_quant<
1959    MASK: PrimInt + AsPrimitive<i64> + AsPrimitive<i128> + Send + Sync,
1960    PROTO: PrimInt + AsPrimitive<i64> + AsPrimitive<i128> + Send + Sync,
1961>(
1962    boxes: Vec<(DetectBox, usize)>,
1963    masks: ArrayView2<MASK>,
1964    protos: ArrayView3<PROTO>,
1965    quant_masks: Quantization,
1966    quant_protos: Quantization,
1967) -> Result<Vec<(DetectBox, BoundingBox, Array3<u8>)>, crate::DecoderError> {
1968    if boxes.is_empty() {
1969        return Ok(Vec::new());
1970    }
1971    if masks.shape()[1] != protos.shape()[2] {
1972        return Err(crate::DecoderError::InvalidShape(format!(
1973            "Mask coefficients count ({}) doesn't match protos channel count ({})",
1974            masks.shape()[1],
1975            protos.shape()[2],
1976        )));
1977    }
1978
1979    let total_bits = MASK::zero().count_zeros() + PROTO::zero().count_zeros() + 5; // 32 protos is 2^5
1980    boxes
1981        .into_iter()
1982        .map(|b| {
1983            let i = b.1;
1984            // See EDGEAI-1304: the caller's bbox stays untouched; the
1985            // proto-grid-snapped `roi` is reported back so the Segmentation's
1986            // bounds can describe the actual cropped mask region.
1987            let (protos, roi) = protobox(&protos, &b.0.bbox.to_canonical())?;
1988            let seg = match total_bits {
1989                0..=64 => make_segmentation_quant::<MASK, PROTO, i64>(
1990                    masks.row(i),
1991                    protos.view(),
1992                    quant_masks,
1993                    quant_protos,
1994                ),
1995                65..=128 => make_segmentation_quant::<MASK, PROTO, i128>(
1996                    masks.row(i),
1997                    protos.view(),
1998                    quant_masks,
1999                    quant_protos,
2000                ),
2001                _ => {
2002                    return Err(crate::DecoderError::NotSupported(format!(
2003                        "Unsupported bit width ({total_bits}) for segmentation computation"
2004                    )));
2005                }
2006            };
2007            Ok((b.0, roi, seg))
2008        })
2009        .collect()
2010}
2011
2012fn protobox<'a, T>(
2013    protos: &'a ArrayView3<T>,
2014    roi: &BoundingBox,
2015) -> Result<(ArrayView3<'a, T>, BoundingBox), crate::DecoderError> {
2016    let width = protos.dim().1 as f32;
2017    let height = protos.dim().0 as f32;
2018
2019    // Detect un-normalized bounding boxes (pixel-space coordinates).
2020    // protobox expects normalized coordinates in [0, 1]. The decoder will
2021    // normalize pixel-space coords automatically when the schema declares
2022    // `Detection::normalized = false` AND model input dimensions are known
2023    // (EDGEAI-1303); reaching this guard means at least one of those is
2024    // missing.
2025    //
2026    // The limit is set to 2.0 (not 1.01) because YOLO models legitimately
2027    // predict coordinates slightly > 1.0 for objects near frame edges.
2028    // Any value > 2.0 is clearly pixel-space (even the smallest practical
2029    // model input of 32×32 would produce coordinates >> 2.0).
2030    const NORM_LIMIT: f32 = 2.0;
2031    if roi.xmin > NORM_LIMIT
2032        || roi.ymin > NORM_LIMIT
2033        || roi.xmax > NORM_LIMIT
2034        || roi.ymax > NORM_LIMIT
2035    {
2036        return Err(crate::DecoderError::InvalidShape(format!(
2037            "Bounding box coordinates appear un-normalized (pixel-space). \
2038             Got bbox=({:.2}, {:.2}, {:.2}, {:.2}) but expected values in [0, 1]. \
2039             Two ways to fix this: \
2040             (1) declare `Detection::normalized = false` in the model schema \
2041             AND make sure the schema's `input.shape` / `input.dshape` carries \
2042             the model input dims so the decoder can divide by (W, H) before NMS \
2043             (EDGEAI-1303 — verify with `Decoder::input_dims().is_some()`); or \
2044             (2) normalize the boxes in-graph before decode().",
2045            roi.xmin, roi.ymin, roi.xmax, roi.ymax,
2046        )));
2047    }
2048
2049    let roi = [
2050        (roi.xmin * width).clamp(0.0, width) as usize,
2051        (roi.ymin * height).clamp(0.0, height) as usize,
2052        (roi.xmax * width).clamp(0.0, width).ceil() as usize,
2053        (roi.ymax * height).clamp(0.0, height).ceil() as usize,
2054    ];
2055
2056    let roi_norm = [
2057        roi[0] as f32 / width,
2058        roi[1] as f32 / height,
2059        roi[2] as f32 / width,
2060        roi[3] as f32 / height,
2061    ]
2062    .into();
2063
2064    let cropped = protos.slice(s![roi[1]..roi[3], roi[0]..roi[2], ..]);
2065
2066    Ok((cropped, roi_norm))
2067}
2068
2069/// Compute a single instance segmentation mask from mask coefficients and
2070/// proto maps (float path).
2071///
2072/// Computes `sigmoid(coefficients · protos)` and maps to `[0, 255]`.
2073/// Returns an `(H, W, 1)` u8 array.
2074fn make_segmentation<
2075    MASK: Float + AsPrimitive<f32> + Send + Sync,
2076    PROTO: Float + AsPrimitive<f32> + Send + Sync,
2077>(
2078    mask: ArrayView1<MASK>,
2079    protos: ArrayView3<PROTO>,
2080) -> Array3<u8> {
2081    let shape = protos.shape();
2082
2083    // Safe to unwrap since the shapes will always be compatible
2084    let mask = mask.to_shape((1, mask.len())).unwrap();
2085    let protos = protos.to_shape([shape[0] * shape[1], shape[2]]).unwrap();
2086    let protos = protos.reversed_axes();
2087    let mask = mask.map(|x| x.as_());
2088    let protos = protos.map(|x| x.as_());
2089
2090    // Safe to unwrap since the shapes will always be compatible
2091    let mask = mask
2092        .dot(&protos)
2093        .into_shape_with_order((shape[0], shape[1], 1))
2094        .unwrap();
2095
2096    mask.map(|x| {
2097        let sigmoid = 1.0 / (1.0 + (-*x).exp());
2098        (sigmoid * 255.0).round() as u8
2099    })
2100}
2101
2102/// Compute a single instance segmentation mask from quantized mask
2103/// coefficients and proto maps.
2104///
2105/// Dequantizes both inputs (subtracting zero-points), computes the dot
2106/// product, applies sigmoid, and maps to `[0, 255]`.
2107/// Returns an `(H, W, 1)` u8 array.
2108fn make_segmentation_quant<
2109    MASK: PrimInt + AsPrimitive<DEST> + Send + Sync,
2110    PROTO: PrimInt + AsPrimitive<DEST> + Send + Sync,
2111    DEST: PrimInt + 'static + Signed + AsPrimitive<f32> + Debug,
2112>(
2113    mask: ArrayView1<MASK>,
2114    protos: ArrayView3<PROTO>,
2115    quant_masks: Quantization,
2116    quant_protos: Quantization,
2117) -> Array3<u8>
2118where
2119    i32: AsPrimitive<DEST>,
2120    f32: AsPrimitive<DEST>,
2121{
2122    let shape = protos.shape();
2123
2124    // Safe to unwrap since the shapes will always be compatible
2125    let mask = mask.to_shape((1, mask.len())).unwrap();
2126
2127    let protos = protos.to_shape([shape[0] * shape[1], shape[2]]).unwrap();
2128    let protos = protos.reversed_axes();
2129
2130    let zp = quant_masks.zero_point.as_();
2131
2132    let mask = mask.mapv(|x| x.as_() - zp);
2133
2134    let zp = quant_protos.zero_point.as_();
2135    let protos = protos.mapv(|x| x.as_() - zp);
2136
2137    // Safe to unwrap since the shapes will always be compatible
2138    let segmentation = mask
2139        .dot(&protos)
2140        .into_shape_with_order((shape[0], shape[1], 1))
2141        .unwrap();
2142
2143    let combined_scale = quant_masks.scale * quant_protos.scale;
2144    segmentation.map(|x| {
2145        let val: f32 = (*x).as_() * combined_scale;
2146        let sigmoid = 1.0 / (1.0 + (-val).exp());
2147        (sigmoid * 255.0).round() as u8
2148    })
2149}
2150
2151/// Converts Yolo Instance Segmentation into a 2D mask.
2152///
2153/// The input segmentation is expected to have shape (H, W, 1).
2154///
2155/// The output mask will have shape (H, W), with values 0 or 1 based on the
2156/// threshold.
2157///
2158/// # Errors
2159///
2160/// Returns `DecoderError::InvalidShape` if the input segmentation does not
2161/// have shape (H, W, 1).
2162pub(crate) fn yolo_segmentation_to_mask(
2163    segmentation: ArrayView3<u8>,
2164    threshold: u8,
2165) -> Result<Array2<u8>, crate::DecoderError> {
2166    if segmentation.shape()[2] != 1 {
2167        return Err(crate::DecoderError::InvalidShape(format!(
2168            "Yolo Instance Segmentation should have shape (H, W, 1), got (H, W, {})",
2169            segmentation.shape()[2]
2170        )));
2171    }
2172    Ok(segmentation
2173        .slice(s![.., .., 0])
2174        .map(|x| if *x >= threshold { 1 } else { 0 }))
2175}
2176
2177#[cfg(test)]
2178#[cfg_attr(coverage_nightly, coverage(off))]
2179mod tests {
2180    use super::*;
2181    use ndarray::Array2;
2182
2183    // ========================================================================
2184    // Tests for decode_yolo_end_to_end_det_float
2185    // ========================================================================
2186
2187    #[test]
2188    fn test_end_to_end_det_basic_filtering() {
2189        // Create synthetic end-to-end detection output: (6, N) where rows are
2190        // [x1, y1, x2, y2, conf, class]
2191        // 3 detections: one above threshold, two below
2192        let data: Vec<f32> = vec![
2193            // Detection 0: high score (0.9)
2194            0.1, 0.2, 0.3, // x1 values
2195            0.1, 0.2, 0.3, // y1 values
2196            0.5, 0.6, 0.7, // x2 values
2197            0.5, 0.6, 0.7, // y2 values
2198            0.9, 0.1, 0.2, // confidence scores
2199            0.0, 1.0, 2.0, // class indices
2200        ];
2201        let output = Array2::from_shape_vec((6, 3), data).unwrap();
2202
2203        let mut boxes = Vec::with_capacity(10);
2204        decode_yolo_end_to_end_det_float(output.view(), 0.5, &mut boxes).unwrap();
2205
2206        // Only 1 detection should pass threshold of 0.5
2207        assert_eq!(boxes.len(), 1);
2208        assert_eq!(boxes[0].label, 0);
2209        assert!((boxes[0].score - 0.9).abs() < 0.01);
2210        assert!((boxes[0].bbox.xmin - 0.1).abs() < 0.01);
2211        assert!((boxes[0].bbox.ymin - 0.1).abs() < 0.01);
2212        assert!((boxes[0].bbox.xmax - 0.5).abs() < 0.01);
2213        assert!((boxes[0].bbox.ymax - 0.5).abs() < 0.01);
2214    }
2215
2216    #[test]
2217    fn test_end_to_end_det_all_pass_threshold() {
2218        // All detections above threshold
2219        let data: Vec<f32> = vec![
2220            10.0, 20.0, // x1
2221            10.0, 20.0, // y1
2222            50.0, 60.0, // x2
2223            50.0, 60.0, // y2
2224            0.8, 0.7, // conf (both above 0.5)
2225            1.0, 2.0, // class
2226        ];
2227        let output = Array2::from_shape_vec((6, 2), data).unwrap();
2228
2229        let mut boxes = Vec::with_capacity(10);
2230        decode_yolo_end_to_end_det_float(output.view(), 0.5, &mut boxes).unwrap();
2231
2232        assert_eq!(boxes.len(), 2);
2233        assert_eq!(boxes[0].label, 1);
2234        assert_eq!(boxes[1].label, 2);
2235    }
2236
2237    #[test]
2238    fn test_end_to_end_det_none_pass_threshold() {
2239        // All detections below threshold
2240        let data: Vec<f32> = vec![
2241            10.0, 20.0, // x1
2242            10.0, 20.0, // y1
2243            50.0, 60.0, // x2
2244            50.0, 60.0, // y2
2245            0.1, 0.2, // conf (both below 0.5)
2246            1.0, 2.0, // class
2247        ];
2248        let output = Array2::from_shape_vec((6, 2), data).unwrap();
2249
2250        let mut boxes = Vec::with_capacity(10);
2251        decode_yolo_end_to_end_det_float(output.view(), 0.5, &mut boxes).unwrap();
2252
2253        assert_eq!(boxes.len(), 0);
2254    }
2255
2256    #[test]
2257    fn test_end_to_end_det_capacity_limit() {
2258        // Test that output is truncated to capacity
2259        let data: Vec<f32> = vec![
2260            0.1, 0.2, 0.3, 0.4, 0.5, // x1
2261            0.1, 0.2, 0.3, 0.4, 0.5, // y1
2262            0.5, 0.6, 0.7, 0.8, 0.9, // x2
2263            0.5, 0.6, 0.7, 0.8, 0.9, // y2
2264            0.9, 0.9, 0.9, 0.9, 0.9, // conf (all pass)
2265            0.0, 1.0, 2.0, 3.0, 4.0, // class
2266        ];
2267        let output = Array2::from_shape_vec((6, 5), data).unwrap();
2268
2269        let mut boxes = Vec::with_capacity(2); // Only allow 2 boxes
2270        decode_yolo_end_to_end_det_float(output.view(), 0.5, &mut boxes).unwrap();
2271
2272        assert_eq!(boxes.len(), 2);
2273    }
2274
2275    #[test]
2276    fn test_end_to_end_det_empty_output() {
2277        // Test with zero detections
2278        let output = Array2::<f32>::zeros((6, 0));
2279
2280        let mut boxes = Vec::with_capacity(10);
2281        decode_yolo_end_to_end_det_float(output.view(), 0.5, &mut boxes).unwrap();
2282
2283        assert_eq!(boxes.len(), 0);
2284    }
2285
2286    #[test]
2287    fn test_end_to_end_det_pixel_coordinates() {
2288        // Test with pixel coordinates (non-normalized)
2289        let data: Vec<f32> = vec![
2290            100.0, // x1
2291            200.0, // y1
2292            300.0, // x2
2293            400.0, // y2
2294            0.95,  // conf
2295            5.0,   // class
2296        ];
2297        let output = Array2::from_shape_vec((6, 1), data).unwrap();
2298
2299        let mut boxes = Vec::with_capacity(10);
2300        decode_yolo_end_to_end_det_float(output.view(), 0.5, &mut boxes).unwrap();
2301
2302        assert_eq!(boxes.len(), 1);
2303        assert_eq!(boxes[0].label, 5);
2304        assert!((boxes[0].bbox.xmin - 100.0).abs() < 0.01);
2305        assert!((boxes[0].bbox.ymin - 200.0).abs() < 0.01);
2306        assert!((boxes[0].bbox.xmax - 300.0).abs() < 0.01);
2307        assert!((boxes[0].bbox.ymax - 400.0).abs() < 0.01);
2308    }
2309
2310    #[test]
2311    fn test_end_to_end_det_invalid_shape() {
2312        // Test with too few rows (needs at least 6)
2313        let output = Array2::<f32>::zeros((5, 3));
2314
2315        let mut boxes = Vec::with_capacity(10);
2316        let result = decode_yolo_end_to_end_det_float(output.view(), 0.5, &mut boxes);
2317
2318        assert!(result.is_err());
2319        assert!(matches!(
2320            result,
2321            Err(crate::DecoderError::InvalidShape(s)) if s.contains("at least 6 rows")
2322        ));
2323    }
2324
2325    // ========================================================================
2326    // Tests for decode_yolo_end_to_end_segdet_float
2327    // ========================================================================
2328
2329    #[test]
2330    fn test_end_to_end_segdet_basic() {
2331        // Create synthetic segdet output: (6 + num_protos, N)
2332        // Detection format: [x1, y1, x2, y2, conf, class, mask_coeff_0..31]
2333        let num_protos = 32;
2334        let num_detections = 2;
2335        let num_features = 6 + num_protos;
2336
2337        // Build detection tensor
2338        let mut data = vec![0.0f32; num_features * num_detections];
2339        // Detection 0: passes threshold
2340        data[0] = 0.1; // x1[0]
2341        data[1] = 0.5; // x1[1]
2342        data[num_detections] = 0.1; // y1[0]
2343        data[num_detections + 1] = 0.5; // y1[1]
2344        data[2 * num_detections] = 0.4; // x2[0]
2345        data[2 * num_detections + 1] = 0.9; // x2[1]
2346        data[3 * num_detections] = 0.4; // y2[0]
2347        data[3 * num_detections + 1] = 0.9; // y2[1]
2348        data[4 * num_detections] = 0.9; // conf[0] - passes
2349        data[4 * num_detections + 1] = 0.3; // conf[1] - fails
2350        data[5 * num_detections] = 1.0; // class[0]
2351        data[5 * num_detections + 1] = 2.0; // class[1]
2352                                            // Fill mask coefficients with small values
2353        for i in 6..num_features {
2354            data[i * num_detections] = 0.1;
2355            data[i * num_detections + 1] = 0.1;
2356        }
2357
2358        let output = Array2::from_shape_vec((num_features, num_detections), data).unwrap();
2359
2360        // Create protos tensor: (proto_height, proto_width, num_protos)
2361        let protos = Array3::<f32>::zeros((16, 16, num_protos));
2362
2363        let mut boxes = Vec::with_capacity(10);
2364        let mut masks = Vec::with_capacity(10);
2365        decode_yolo_end_to_end_segdet_float(
2366            output.view(),
2367            protos.view(),
2368            0.5,
2369            &mut boxes,
2370            &mut masks,
2371        )
2372        .unwrap();
2373
2374        // Only detection 0 should pass
2375        assert_eq!(boxes.len(), 1);
2376        assert_eq!(masks.len(), 1);
2377        assert_eq!(boxes[0].label, 1);
2378        assert!((boxes[0].score - 0.9).abs() < 0.01);
2379    }
2380
2381    #[test]
2382    fn test_end_to_end_segdet_mask_coordinates() {
2383        // Test that mask coordinates match box coordinates
2384        let num_protos = 32;
2385        let num_features = 6 + num_protos;
2386
2387        let mut data = vec![0.0f32; num_features];
2388        data[0] = 0.2; // x1
2389        data[1] = 0.2; // y1
2390        data[2] = 0.8; // x2
2391        data[3] = 0.8; // y2
2392        data[4] = 0.95; // conf
2393        data[5] = 3.0; // class
2394
2395        let output = Array2::from_shape_vec((num_features, 1), data).unwrap();
2396        let protos = Array3::<f32>::zeros((16, 16, num_protos));
2397
2398        let mut boxes = Vec::with_capacity(10);
2399        let mut masks = Vec::with_capacity(10);
2400        decode_yolo_end_to_end_segdet_float(
2401            output.view(),
2402            protos.view(),
2403            0.5,
2404            &mut boxes,
2405            &mut masks,
2406        )
2407        .unwrap();
2408
2409        assert_eq!(boxes.len(), 1);
2410        assert_eq!(masks.len(), 1);
2411
2412        // Mask region is the proto-grid-aligned crop and encloses the
2413        // post-NMS bbox (EDGEAI-1304); on a 16x16 grid each side may snap
2414        // by up to 1/16 = 0.0625.
2415        let step = 1.0 / 16.0;
2416        assert!(masks[0].xmin <= boxes[0].bbox.xmin);
2417        assert!(masks[0].ymin <= boxes[0].bbox.ymin);
2418        assert!(masks[0].xmax >= boxes[0].bbox.xmax);
2419        assert!(masks[0].ymax >= boxes[0].bbox.ymax);
2420        assert!((boxes[0].bbox.xmin - masks[0].xmin) < step);
2421        assert!((boxes[0].bbox.ymin - masks[0].ymin) < step);
2422        assert!((masks[0].xmax - boxes[0].bbox.xmax) < step);
2423        assert!((masks[0].ymax - boxes[0].bbox.ymax) < step);
2424    }
2425
2426    #[test]
2427    fn test_end_to_end_segdet_empty_output() {
2428        let num_protos = 32;
2429        let output = Array2::<f32>::zeros((6 + num_protos, 0));
2430        let protos = Array3::<f32>::zeros((16, 16, num_protos));
2431
2432        let mut boxes = Vec::with_capacity(10);
2433        let mut masks = Vec::with_capacity(10);
2434        decode_yolo_end_to_end_segdet_float(
2435            output.view(),
2436            protos.view(),
2437            0.5,
2438            &mut boxes,
2439            &mut masks,
2440        )
2441        .unwrap();
2442
2443        assert_eq!(boxes.len(), 0);
2444        assert_eq!(masks.len(), 0);
2445    }
2446
2447    #[test]
2448    fn test_end_to_end_segdet_capacity_limit() {
2449        let num_protos = 32;
2450        let num_detections = 5;
2451        let num_features = 6 + num_protos;
2452
2453        let mut data = vec![0.0f32; num_features * num_detections];
2454        // All detections pass threshold
2455        for i in 0..num_detections {
2456            data[i] = 0.1 * (i as f32); // x1
2457            data[num_detections + i] = 0.1 * (i as f32); // y1
2458            data[2 * num_detections + i] = 0.1 * (i as f32) + 0.2; // x2
2459            data[3 * num_detections + i] = 0.1 * (i as f32) + 0.2; // y2
2460            data[4 * num_detections + i] = 0.9; // conf
2461            data[5 * num_detections + i] = i as f32; // class
2462        }
2463
2464        let output = Array2::from_shape_vec((num_features, num_detections), data).unwrap();
2465        let protos = Array3::<f32>::zeros((16, 16, num_protos));
2466
2467        let mut boxes = Vec::with_capacity(2); // Limit to 2
2468        let mut masks = Vec::with_capacity(2);
2469        decode_yolo_end_to_end_segdet_float(
2470            output.view(),
2471            protos.view(),
2472            0.5,
2473            &mut boxes,
2474            &mut masks,
2475        )
2476        .unwrap();
2477
2478        assert_eq!(boxes.len(), 2);
2479        assert_eq!(masks.len(), 2);
2480    }
2481
2482    #[test]
2483    fn test_end_to_end_segdet_invalid_shape_too_few_rows() {
2484        // Test with too few rows (needs at least 7: 6 base + 1 mask coeff)
2485        let output = Array2::<f32>::zeros((6, 3));
2486        let protos = Array3::<f32>::zeros((16, 16, 32));
2487
2488        let mut boxes = Vec::with_capacity(10);
2489        let mut masks = Vec::with_capacity(10);
2490        let result = decode_yolo_end_to_end_segdet_float(
2491            output.view(),
2492            protos.view(),
2493            0.5,
2494            &mut boxes,
2495            &mut masks,
2496        );
2497
2498        assert!(result.is_err());
2499        assert!(matches!(
2500            result,
2501            Err(crate::DecoderError::InvalidShape(s)) if s.contains("at least 7 rows")
2502        ));
2503    }
2504
2505    #[test]
2506    fn test_end_to_end_segdet_invalid_shape_protos_mismatch() {
2507        // Test with mismatched mask coefficients and protos count
2508        let num_protos = 32;
2509        let output = Array2::<f32>::zeros((6 + 16, 3)); // 16 mask coeffs
2510        let protos = Array3::<f32>::zeros((16, 16, num_protos)); // 32 protos
2511
2512        let mut boxes = Vec::with_capacity(10);
2513        let mut masks = Vec::with_capacity(10);
2514        let result = decode_yolo_end_to_end_segdet_float(
2515            output.view(),
2516            protos.view(),
2517            0.5,
2518            &mut boxes,
2519            &mut masks,
2520        );
2521
2522        assert!(result.is_err());
2523        assert!(matches!(
2524            result,
2525            Err(crate::DecoderError::InvalidShape(s)) if s.contains("doesn't match protos count")
2526        ));
2527    }
2528
2529    // ========================================================================
2530    // Tests for decode_yolo_split_end_to_end_segdet_float
2531    // ========================================================================
2532
2533    #[test]
2534    fn test_split_end_to_end_segdet_basic() {
2535        // Create synthetic segdet output: (6 + num_protos, N)
2536        // Detection format: [x1, y1, x2, y2, conf, class, mask_coeff_0..31]
2537        let num_protos = 32;
2538        let num_detections = 2;
2539        let num_features = 6 + num_protos;
2540
2541        // Build detection tensor
2542        let mut data = vec![0.0f32; num_features * num_detections];
2543        // Detection 0: passes threshold
2544        data[0] = 0.1; // x1[0]
2545        data[1] = 0.5; // x1[1]
2546        data[num_detections] = 0.1; // y1[0]
2547        data[num_detections + 1] = 0.5; // y1[1]
2548        data[2 * num_detections] = 0.4; // x2[0]
2549        data[2 * num_detections + 1] = 0.9; // x2[1]
2550        data[3 * num_detections] = 0.4; // y2[0]
2551        data[3 * num_detections + 1] = 0.9; // y2[1]
2552        data[4 * num_detections] = 0.9; // conf[0] - passes
2553        data[4 * num_detections + 1] = 0.3; // conf[1] - fails
2554        data[5 * num_detections] = 1.0; // class[0]
2555        data[5 * num_detections + 1] = 2.0; // class[1]
2556                                            // Fill mask coefficients with small values
2557        for i in 6..num_features {
2558            data[i * num_detections] = 0.1;
2559            data[i * num_detections + 1] = 0.1;
2560        }
2561
2562        let output = Array2::from_shape_vec((num_features, num_detections), data).unwrap();
2563        let box_coords = output.slice(s![..4, ..]);
2564        let scores = output.slice(s![4..5, ..]);
2565        let classes = output.slice(s![5..6, ..]);
2566        let mask_coeff = output.slice(s![6.., ..]);
2567        // Create protos tensor: (proto_height, proto_width, num_protos)
2568        let protos = Array3::<f32>::zeros((16, 16, num_protos));
2569
2570        let mut boxes = Vec::with_capacity(10);
2571        let mut masks = Vec::with_capacity(10);
2572        decode_yolo_split_end_to_end_segdet_float(
2573            box_coords,
2574            scores,
2575            classes,
2576            mask_coeff,
2577            protos.view(),
2578            0.5,
2579            &mut boxes,
2580            &mut masks,
2581        )
2582        .unwrap();
2583
2584        // Only detection 0 should pass
2585        assert_eq!(boxes.len(), 1);
2586        assert_eq!(masks.len(), 1);
2587        assert_eq!(boxes[0].label, 1);
2588        assert!((boxes[0].score - 0.9).abs() < 0.01);
2589    }
2590
2591    // ========================================================================
2592    // Tests for yolo_segmentation_to_mask
2593    // ========================================================================
2594
2595    #[test]
2596    fn test_segmentation_to_mask_basic() {
2597        // Create a 4x4x1 segmentation with values above and below threshold
2598        let data: Vec<u8> = vec![
2599            100, 200, 50, 150, // row 0
2600            10, 255, 128, 64, // row 1
2601            0, 127, 128, 255, // row 2
2602            64, 64, 192, 192, // row 3
2603        ];
2604        let segmentation = Array3::from_shape_vec((4, 4, 1), data).unwrap();
2605
2606        let mask = yolo_segmentation_to_mask(segmentation.view(), 128).unwrap();
2607
2608        // Values >= 128 should be 1, others 0
2609        assert_eq!(mask[[0, 0]], 0); // 100 < 128
2610        assert_eq!(mask[[0, 1]], 1); // 200 >= 128
2611        assert_eq!(mask[[0, 2]], 0); // 50 < 128
2612        assert_eq!(mask[[0, 3]], 1); // 150 >= 128
2613        assert_eq!(mask[[1, 1]], 1); // 255 >= 128
2614        assert_eq!(mask[[1, 2]], 1); // 128 >= 128
2615        assert_eq!(mask[[2, 0]], 0); // 0 < 128
2616        assert_eq!(mask[[2, 1]], 0); // 127 < 128
2617    }
2618
2619    #[test]
2620    fn test_segmentation_to_mask_all_above() {
2621        let segmentation = Array3::from_elem((4, 4, 1), 255u8);
2622        let mask = yolo_segmentation_to_mask(segmentation.view(), 128).unwrap();
2623        assert!(mask.iter().all(|&x| x == 1));
2624    }
2625
2626    #[test]
2627    fn test_segmentation_to_mask_all_below() {
2628        let segmentation = Array3::from_elem((4, 4, 1), 64u8);
2629        let mask = yolo_segmentation_to_mask(segmentation.view(), 128).unwrap();
2630        assert!(mask.iter().all(|&x| x == 0));
2631    }
2632
2633    #[test]
2634    fn test_segmentation_to_mask_invalid_shape() {
2635        let segmentation = Array3::from_elem((4, 4, 3), 128u8);
2636        let result = yolo_segmentation_to_mask(segmentation.view(), 128);
2637
2638        assert!(result.is_err());
2639        assert!(matches!(
2640            result,
2641            Err(crate::DecoderError::InvalidShape(s)) if s.contains("(H, W, 1)")
2642        ));
2643    }
2644
2645    // ========================================================================
2646    // Tests for protobox / NORM_LIMIT regression
2647    // ========================================================================
2648
2649    #[test]
2650    fn test_protobox_clamps_edge_coordinates() {
2651        // bbox with xmax=1.0 should not panic (OOB guard)
2652        let protos = Array3::<f32>::zeros((16, 16, 4));
2653        let view = protos.view();
2654        let roi = BoundingBox {
2655            xmin: 0.5,
2656            ymin: 0.5,
2657            xmax: 1.0,
2658            ymax: 1.0,
2659        };
2660        let result = protobox(&view, &roi);
2661        assert!(result.is_ok(), "protobox should accept xmax=1.0");
2662        let (cropped, _roi_norm) = result.unwrap();
2663        // Cropped region must have non-zero spatial dimensions
2664        assert!(cropped.shape()[0] > 0);
2665        assert!(cropped.shape()[1] > 0);
2666        assert_eq!(cropped.shape()[2], 4);
2667    }
2668
2669    #[test]
2670    fn test_protobox_rejects_wildly_out_of_range() {
2671        // bbox with coords > NORM_LIMIT (e.g. 3.0) returns error
2672        let protos = Array3::<f32>::zeros((16, 16, 4));
2673        let view = protos.view();
2674        let roi = BoundingBox {
2675            xmin: 0.0,
2676            ymin: 0.0,
2677            xmax: 3.0,
2678            ymax: 3.0,
2679        };
2680        let result = protobox(&view, &roi);
2681        assert!(
2682            matches!(result, Err(crate::DecoderError::InvalidShape(s)) if s.contains("un-normalized")),
2683            "protobox should reject coords > NORM_LIMIT"
2684        );
2685    }
2686
2687    #[test]
2688    fn test_protobox_accepts_slightly_over_one() {
2689        // bbox with coords at 1.5 (within NORM_LIMIT=2.0) succeeds
2690        let protos = Array3::<f32>::zeros((16, 16, 4));
2691        let view = protos.view();
2692        let roi = BoundingBox {
2693            xmin: 0.0,
2694            ymin: 0.0,
2695            xmax: 1.5,
2696            ymax: 1.5,
2697        };
2698        let result = protobox(&view, &roi);
2699        assert!(
2700            result.is_ok(),
2701            "protobox should accept coords <= NORM_LIMIT (2.0)"
2702        );
2703        let (cropped, _roi_norm) = result.unwrap();
2704        // Entire proto map should be selected when coords > 1.0 (clamped to boundary)
2705        assert_eq!(cropped.shape()[0], 16);
2706        assert_eq!(cropped.shape()[1], 16);
2707    }
2708
2709    #[test]
2710    fn test_segdet_float_proto_no_panic() {
2711        // Simulates YOLOv8n-seg: output0 = [116, 8400] (4 box + 80 class + 32 mask coeff)
2712        // output1 (protos) = [32, 160, 160]
2713        let num_proposals = 100; // enough to produce idx >= 32
2714        let num_classes = 80;
2715        let num_mask_coeffs = 32;
2716        let rows = 4 + num_classes + num_mask_coeffs; // 116
2717
2718        // Fill boxes with valid xywh data so some detections pass the threshold.
2719        // Layout is [116, num_proposals] row-major: row 0=cx, 1=cy, 2=w, 3=h,
2720        // rows 4..84=class scores, rows 84..116=mask coefficients.
2721        let mut data = vec![0.0f32; rows * num_proposals];
2722        for i in 0..num_proposals {
2723            let row = |r: usize| r * num_proposals + i;
2724            data[row(0)] = 320.0; // cx
2725            data[row(1)] = 320.0; // cy
2726            data[row(2)] = 50.0; // w
2727            data[row(3)] = 50.0; // h
2728            data[row(4)] = 0.9; // class-0 score
2729        }
2730        let boxes = ndarray::Array2::from_shape_vec((rows, num_proposals), data).unwrap();
2731
2732        // Protos must be in HWC order. Under the HAL physical-order
2733        // contract, callers declare shape+dshape matching producer memory
2734        // and swap_axes_if_needed permutes the stride tuple into canonical
2735        // [batch, height, width, num_protos] before this function sees it.
2736        let protos = ndarray::Array3::<f32>::zeros((160, 160, num_mask_coeffs));
2737
2738        let mut output_boxes = Vec::with_capacity(300);
2739
2740        // This panicked before fix: mask_tensor.row(idx) with idx >= 32
2741        let proto_data = impl_yolo_segdet_float_proto::<XYWH, _, _>(
2742            boxes.view(),
2743            protos.view(),
2744            0.5,
2745            0.7,
2746            Some(Nms::default()),
2747            MAX_NMS_CANDIDATES,
2748            300,
2749            None,
2750            None,
2751            false, // multi_label: argmax for this test
2752            &mut output_boxes,
2753        );
2754
2755        // Should produce detections (NMS will collapse many overlapping boxes)
2756        assert!(!output_boxes.is_empty());
2757        let coeffs_shape = proto_data.mask_coefficients.shape();
2758        assert_eq!(coeffs_shape[0], output_boxes.len());
2759        // Each mask coefficient vector should have 32 elements
2760        assert_eq!(coeffs_shape[1], num_mask_coeffs);
2761    }
2762
2763    // ========================================================================
2764    // Pre-NMS top-K cap (MAX_NMS_CANDIDATES)
2765    // ========================================================================
2766
2767    /// At very low score thresholds (e.g., t=0.01 on YOLOv8 with 8400×80
2768    /// candidates) almost every score passes the filter, feeding O(n²)
2769    /// NMS and a per-survivor mask matmul. The decoder caps the
2770    /// candidate set fed to NMS at `MAX_NMS_CANDIDATES` (Ultralytics
2771    /// default 30 000) to bound worst-case decode time.
2772    ///
2773    /// This regression test pumps 50 000 above-threshold candidates
2774    /// into `impl_yolo_segdet_get_boxes` with NMS bypassed (Nms=None)
2775    /// and a generous post-NMS cap. Before the fix, the function
2776    /// returned all 50 000; after the fix, exactly 30 000.
2777    #[test]
2778    fn test_pre_nms_cap_truncates_excess_candidates() {
2779        let n: usize = 50_000;
2780        let num_classes = 1;
2781
2782        // Identical valid boxes. Distinct scores (descending) so the
2783        // top-K cap keeps the highest-scoring ones in deterministic
2784        // order — letting us assert *which* ones survived.
2785        let mut boxes_data = Vec::with_capacity(n * 4);
2786        let mut scores_data = Vec::with_capacity(n * num_classes);
2787        for i in 0..n {
2788            boxes_data.extend_from_slice(&[0.1f32, 0.1, 0.5, 0.5]);
2789            // score_i = 0.99 - i * 1e-7 keeps everything well above 0.1
2790            // threshold but strictly decreasing.
2791            scores_data.push(0.99 - (i as f32) * 1e-7);
2792        }
2793        let boxes = Array2::from_shape_vec((n, 4), boxes_data).unwrap();
2794        let scores = Array2::from_shape_vec((n, num_classes), scores_data).unwrap();
2795
2796        let result = impl_yolo_segdet_get_boxes::<XYXY, _, _>(
2797            boxes.view(),
2798            scores.view(),
2799            0.1,
2800            1.0,                             // IoU 1.0 → NMS suppresses nothing
2801            Some(Nms::ClassAgnostic),        // NMS enabled so pre_nms_top_k applies
2802            crate::yolo::MAX_NMS_CANDIDATES, // pre_nms_top_k
2803            usize::MAX,                      // no post-NMS truncation
2804            false,                           // multi_label: argmax for this test
2805        );
2806
2807        assert_eq!(
2808            result.len(),
2809            crate::yolo::MAX_NMS_CANDIDATES,
2810            "pre-NMS cap should truncate to MAX_NMS_CANDIDATES; got {}",
2811            result.len()
2812        );
2813        // Top-K survivors: highest scores were the first n indices,
2814        // so survivor 0 must have score ~0.99.
2815        let top_score = result[0].0.score;
2816        assert!(
2817            top_score > 0.98,
2818            "highest-ranked survivor should have the largest score, got {top_score}"
2819        );
2820    }
2821
2822    /// Counterpart for the quantized split path. Same contract: feed
2823    /// more than `MAX_NMS_CANDIDATES` survivors above the quantized
2824    /// threshold, confirm `impl_yolo_split_segdet_quant_get_boxes`
2825    /// truncates before NMS.
2826    #[test]
2827    fn test_pre_nms_cap_truncates_excess_candidates_quant() {
2828        use crate::Quantization;
2829        let n: usize = 50_000;
2830        let num_classes = 1;
2831
2832        // i8 boxes with simple scale/zp; the box value 50 dequantizes
2833        // to 0.5 with scale=0.01, zp=0 — fine for a flat box set.
2834        let boxes_data = (0..n).flat_map(|_| [10i8, 10, 50, 50]).collect::<Vec<_>>();
2835        let boxes = Array2::from_shape_vec((n, 4), boxes_data).unwrap();
2836        let quant_boxes = Quantization {
2837            scale: 0.01,
2838            zero_point: 0,
2839        };
2840
2841        // u8 scores: distinct descending values, all well above threshold.
2842        // value 250 → 0.98 with scale 0.00392, zp 0.
2843        // value (250 - i % 200) keeps a wide spread above the dequant
2844        // threshold of 0.5.
2845        let scores_data: Vec<u8> = (0..n)
2846            .map(|i| 250u8.saturating_sub((i % 200) as u8))
2847            .collect();
2848        let scores = Array2::from_shape_vec((n, num_classes), scores_data).unwrap();
2849        let quant_scores = Quantization {
2850            scale: 0.00392,
2851            zero_point: 0,
2852        };
2853
2854        let result = impl_yolo_split_segdet_quant_get_boxes::<XYXY, _, _>(
2855            (boxes.view(), quant_boxes),
2856            (scores.view(), quant_scores),
2857            0.1,
2858            1.0,                             // IoU 1.0 → NMS suppresses nothing
2859            Some(Nms::ClassAgnostic),        // NMS enabled so pre_nms_top_k applies
2860            crate::yolo::MAX_NMS_CANDIDATES, // pre_nms_top_k
2861            usize::MAX,                      // no post-NMS truncation
2862        );
2863
2864        assert_eq!(
2865            result.len(),
2866            crate::yolo::MAX_NMS_CANDIDATES,
2867            "quant path pre-NMS cap should truncate to MAX_NMS_CANDIDATES; got {}",
2868            result.len()
2869        );
2870    }
2871
2872    /// Regression test for HAILORT_BUG.md — the YoloSegDet path
2873    /// (combined `(4 + nc + nm, N)` detection tensor + separate protos)
2874    /// must pair each surviving detection with the mask coefficient
2875    /// row at the SAME anchor index the box came from. The validator
2876    /// sees this path miss the pairing under schema-v2 Hailo inputs
2877    /// (mAP collapse from 46.8 → 3.65 while mask IoU stays at 66.9,
2878    /// the fingerprint of mask-to-detection misalignment).
2879    ///
2880    /// Construction: three anchors with distinct mask-coef signatures
2881    /// that, after dot(coefs, protos) + sigmoid, produce HIGH vs LOW
2882    /// mask pixel values. Two anchors survive (one high, one low); if
2883    /// the mask row is looked up at the wrong index, the per-detection
2884    /// mean mask value would cross the threshold and we catch it.
2885    #[test]
2886    fn segdet_combined_tensor_pairs_detection_with_matching_mask_row() {
2887        let nc = 2; // num_classes
2888        let nm = 2; // num_protos
2889        let n = 3; // num_anchors
2890        let feat = 4 + nc + nm; // 8
2891
2892        // Tensor layout: (8, 3) rows=features, cols=anchors.
2893        // Row indices:  0..4 = xywh, 4..6 = scores, 6..8 = mask_coefs.
2894        //
2895        //         anchor 0 | anchor 1 | anchor 2
2896        // xc       0.2      | 0.5      | 0.8
2897        // yc       0.2      | 0.5      | 0.8
2898        // w        0.1      | 0.1      | 0.1
2899        // h        0.1      | 0.1      | 0.1
2900        // s[0]     0.9      | 0.0      | 0.8   (class 0)
2901        // s[1]     0.0      | 0.0      | 0.0   (class 1 — always loses)
2902        // m[0]     3.0      | 0.0      | -3.0  (high for a0, low for a2)
2903        // m[1]     3.0      | 0.0      | -3.0
2904        //
2905        // Proto[0] = Proto[1] = all-ones (8x8), so
2906        //   mask(a0) = sigmoid(3 + 3) ≈ 0.9975 → 254
2907        //   mask(a2) = sigmoid(-3 + -3) ≈ 0.0025 → 1
2908        // 250-point gap makes any misalignment trivially detectable.
2909        let mut data = vec![0.0f32; feat * n];
2910        let set = |d: &mut [f32], r: usize, c: usize, v: f32| d[r * n + c] = v;
2911        set(&mut data, 0, 0, 0.2);
2912        set(&mut data, 1, 0, 0.2);
2913        set(&mut data, 2, 0, 0.1);
2914        set(&mut data, 3, 0, 0.1);
2915        set(&mut data, 0, 1, 0.5);
2916        set(&mut data, 1, 1, 0.5);
2917        set(&mut data, 2, 1, 0.1);
2918        set(&mut data, 3, 1, 0.1);
2919        set(&mut data, 0, 2, 0.8);
2920        set(&mut data, 1, 2, 0.8);
2921        set(&mut data, 2, 2, 0.1);
2922        set(&mut data, 3, 2, 0.1);
2923        set(&mut data, 4, 0, 0.9);
2924        set(&mut data, 4, 2, 0.8);
2925        set(&mut data, 6, 0, 3.0);
2926        set(&mut data, 7, 0, 3.0);
2927        set(&mut data, 6, 2, -3.0);
2928        set(&mut data, 7, 2, -3.0);
2929
2930        let output = Array2::from_shape_vec((feat, n), data).unwrap();
2931        let protos = Array3::<f32>::from_elem((8, 8, nm), 1.0);
2932
2933        let mut boxes: Vec<DetectBox> = Vec::with_capacity(10);
2934        let mut masks: Vec<Segmentation> = Vec::with_capacity(10);
2935        decode_yolo_segdet_float(
2936            output.view(),
2937            protos.view(),
2938            0.5,
2939            0.5,
2940            Some(Nms::ClassAgnostic),
2941            &mut boxes,
2942            &mut masks,
2943        )
2944        .unwrap();
2945
2946        assert_eq!(
2947            boxes.len(),
2948            2,
2949            "two anchors above threshold should survive (a0 score=0.9, a2 score=0.8); got {}",
2950            boxes.len()
2951        );
2952
2953        // Build a (anchor_index → mask_mean) mapping from the results.
2954        // Anchor 0 has centre (0.2, 0.2), anchor 2 has centre (0.8,
2955        // 0.8). The DetectBox bbox is the post-XYWH-to-XYXY conversion
2956        // of the original xywh; cropping inside protobox may shrink it,
2957        // so match by centre (0.2 vs 0.8) rather than exact bbox.
2958        for (b, m) in boxes.iter().zip(masks.iter()) {
2959            let cx = (b.bbox.xmin + b.bbox.xmax) * 0.5;
2960            let mean = {
2961                use edgefirst_tensor::{TensorMapTrait as _, TensorTrait as _};
2962                let t = m.segmentation.as_u8().expect("mask must be U8");
2963                let map = t.map_read().expect("map mask");
2964                let s = map.as_slice();
2965                let total: u32 = s.iter().map(|&v| v as u32).sum();
2966                total as f32 / s.len() as f32
2967            };
2968            if cx < 0.3 {
2969                // anchor 0 — expect HIGH mask values ≈ 254
2970                assert!(
2971                    mean > 200.0,
2972                    "anchor 0 detection (centre {cx:.2}) should have high-value mask; got mean {mean}"
2973                );
2974            } else if cx > 0.7 {
2975                // anchor 2 — expect LOW mask values ≈ 1
2976                assert!(
2977                    mean < 50.0,
2978                    "anchor 2 detection (centre {cx:.2}) should have low-value mask; got mean {mean}"
2979                );
2980            } else {
2981                panic!("unexpected detection centre {cx:.2}");
2982            }
2983        }
2984    }
2985
2986    // ========================================================================
2987    // Tests for truncate_to_top_k_by_score / truncate_to_top_k_by_score_quant
2988    // ========================================================================
2989
2990    /// Helper: build a Vec of (DetectBox, ()) with the given scores.
2991    fn make_float_boxes(scores: &[f32]) -> Vec<(DetectBox, ())> {
2992        scores
2993            .iter()
2994            .enumerate()
2995            .map(|(i, &s)| {
2996                (
2997                    DetectBox {
2998                        bbox: BoundingBox {
2999                            xmin: 0.0,
3000                            ymin: 0.0,
3001                            xmax: 1.0,
3002                            ymax: 1.0,
3003                        },
3004                        score: s,
3005                        label: i,
3006                    },
3007                    (),
3008                )
3009            })
3010            .collect()
3011    }
3012
3013    /// Helper: build a Vec of (DetectBoxQuantized<i8>, ()) with the given scores.
3014    fn make_quant_boxes(scores: &[i8]) -> Vec<(DetectBoxQuantized<i8>, ())> {
3015        scores
3016            .iter()
3017            .enumerate()
3018            .map(|(i, &s)| {
3019                (
3020                    DetectBoxQuantized {
3021                        bbox: BoundingBox {
3022                            xmin: 0.0,
3023                            ymin: 0.0,
3024                            xmax: 1.0,
3025                            ymax: 1.0,
3026                        },
3027                        score: s,
3028                        label: i,
3029                    },
3030                    (),
3031                )
3032            })
3033            .collect()
3034    }
3035
3036    #[test]
3037    fn truncate_float_top_k_zero_is_unbounded() {
3038        let mut boxes = make_float_boxes(&[0.9, 0.1, 0.5, 0.3, 0.7]);
3039        let original_len = boxes.len();
3040        truncate_to_top_k_by_score(&mut boxes, 0);
3041        assert_eq!(
3042            boxes.len(),
3043            original_len,
3044            "top_k=0 should keep all candidates (no-limit semantics)"
3045        );
3046    }
3047
3048    #[test]
3049    fn truncate_float_top_k_normal() {
3050        let mut boxes = make_float_boxes(&[0.9, 0.1, 0.5, 0.3, 0.7]);
3051        truncate_to_top_k_by_score(&mut boxes, 3);
3052        assert_eq!(boxes.len(), 3);
3053        // The top-3 scores should be 0.9, 0.7, 0.5 (order within top-K is unspecified)
3054        let mut retained: Vec<f32> = boxes.iter().map(|(b, _)| b.score).collect();
3055        retained.sort_by(|a, b| b.total_cmp(a));
3056        assert_eq!(retained, vec![0.9, 0.7, 0.5]);
3057    }
3058
3059    #[test]
3060    fn truncate_float_top_k_noop_when_under_cap() {
3061        let mut boxes = make_float_boxes(&[0.9, 0.5]);
3062        truncate_to_top_k_by_score(&mut boxes, 10);
3063        assert_eq!(boxes.len(), 2, "should be no-op when len <= top_k");
3064    }
3065
3066    #[test]
3067    fn truncate_quant_top_k_zero_is_unbounded() {
3068        let mut boxes = make_quant_boxes(&[120, -50, 30, -10, 80]);
3069        let original_len = boxes.len();
3070        truncate_to_top_k_by_score_quant(&mut boxes, 0);
3071        assert_eq!(
3072            boxes.len(),
3073            original_len,
3074            "top_k=0 should keep all candidates (no-limit semantics)"
3075        );
3076    }
3077
3078    #[test]
3079    fn truncate_quant_top_k_normal() {
3080        let mut boxes = make_quant_boxes(&[120, -50, 30, -10, 80]);
3081        truncate_to_top_k_by_score_quant(&mut boxes, 3);
3082        assert_eq!(boxes.len(), 3);
3083        let mut retained: Vec<i8> = boxes.iter().map(|(b, _)| b.score).collect();
3084        retained.sort_by(|a, b| b.cmp(a));
3085        assert_eq!(retained, vec![120, 80, 30]);
3086    }
3087
3088    #[test]
3089    fn truncate_quant_top_k_noop_when_under_cap() {
3090        let mut boxes = make_quant_boxes(&[120, 80]);
3091        truncate_to_top_k_by_score_quant(&mut boxes, 10);
3092        assert_eq!(boxes.len(), 2, "should be no-op when len <= top_k");
3093    }
3094}