Skip to main content

edgefirst_decoder/
lib.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4/*!
5## EdgeFirst HAL - Decoders
6This crate provides decoding utilities for YOLOobject detection and segmentation models, and ModelPack detection and segmentation models.
7It supports both floating-point and quantized model outputs, allowing for efficient processing on edge devices. The crate includes functions
8for efficient post-processing model outputs into usable detection boxes and segmentation masks, as well as utilities for dequantizing model outputs..
9
10For general usage, use the `Decoder` struct which provides functions for decoding various model outputs based on the model configuration.
11If you already know the model type and output formats, you can use the lower-level functions directly from the `yolo` and `modelpack` modules.
12
13
14### Quick Example
15```rust,no_run
16use edgefirst_decoder::{DecoderBuilder, DecoderResult, configs::{self, DecoderVersion}};
17use edgefirst_tensor::TensorDyn;
18
19fn main() -> DecoderResult<()> {
20    // Create a decoder for a YOLOv8 model with quantized int8 output
21    let decoder = DecoderBuilder::new()
22        .with_config_yolo_det(configs::Detection {
23            anchors: None,
24            decoder: configs::DecoderType::Ultralytics,
25            quantization: Some(configs::QuantTuple(0.012345, 26)),
26            shape: vec![1, 84, 8400],
27            dshape: Vec::new(),
28            normalized: Some(true),
29        },
30        Some(DecoderVersion::Yolov8))
31        .with_score_threshold(0.25)
32        .with_iou_threshold(0.7)
33        .build()?;
34
35    // Get the model output tensors from inference
36    let model_output: Vec<TensorDyn> = vec![/* tensors from inference */];
37    let tensor_refs: Vec<&TensorDyn> = model_output.iter().collect();
38
39    let mut output_boxes = Vec::with_capacity(10);
40    let mut output_masks = Vec::with_capacity(10);
41
42    // Decode model output into detection boxes and segmentation masks
43    decoder.decode(&tensor_refs, &mut output_boxes, &mut output_masks)?;
44    Ok(())
45}
46```
47
48# Overview
49
50The primary components of this crate are:
51- `Decoder`/`DecoderBuilder` struct: Provides high-level functions to decode model outputs based on the model configuration.
52- `yolo` module: Contains functions specific to decoding YOLO model outputs.
53- `modelpack` module: Contains functions specific to decoding ModelPack model outputs.
54
55The `Decoder` supports both floating-point and quantized model outputs, allowing for efficient processing on edge devices.
56It also supports mixed integer types for quantized outputs, such as when one output tensor is int8 and another is uint8.
57When decoding quantized outputs, the appropriate quantization parameters must be provided for each output tensor.
58If the integer types used in the model output is not supported by the decoder, the user can manually dequantize the model outputs using
59the `dequantize` functions provided in this crate, and then use the floating-point decoding functions. However, it is recommended
60to not dequantize the model outputs manually before passing them to the decoder, as the quantized decoder functions are optimized for performance.
61
62The `yolo` and `modelpack` modules provide lower-level functions for decoding model outputs directly,
63which can be used if the model type and output formats are known in advance.
64
65
66*/
67#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
68
69use ndarray::{Array, Array2, ArrayView, ArrayView1, ArrayView3, Dimension};
70use num_traits::{AsPrimitive, Float, PrimInt};
71
72pub mod byte;
73pub mod error;
74pub mod float;
75pub mod infer;
76pub mod modelpack;
77pub mod per_scale;
78pub mod schema;
79pub mod tiling;
80pub mod yolo;
81
82pub use infer::{
83    infer_ultralytics_schema, InferError, InferredSchema, ModelSignals, ModelSource, TensorInfo,
84};
85
86mod decoder;
87pub use decoder::*;
88
89// The detection vocabulary lives in edgefirst-tensor so edgefirst-image can
90// render from it without depending on this crate. Re-exported here so the
91// decoder's own modules keep resolving `crate::Segmentation`.
92pub use edgefirst_tensor::{BoundingBox, DetectBox, ProtoData, ProtoLayout, Segmentation};
93
94// TilePlacement is the contract between edgefirst-image (producer) and this
95// crate (consumer); it lives in edgefirst-decoder-abi so image can name it
96// without linking decoder. Re-exported here so the decoder's own modules
97// keep resolving `crate::tiling::TilePlacement`.
98pub use edgefirst_decoder_abi::TilePlacement;
99
100pub use configs::{DecoderVersion, Nms};
101pub use error::{DecoderError, DecoderResult};
102pub use per_scale::DecodeDtype;
103
104use crate::{
105    decoder::configs::QuantTuple, modelpack::modelpack_segmentation_to_mask,
106    yolo::yolo_segmentation_to_mask,
107};
108
109/// Materialise an ndarray mask into the [`TensorDyn`] that
110/// [`Segmentation`] carries.
111///
112/// The decoder computes mask values with ndarray and converts once, here at
113/// the boundary. `Segmentation` holds a `TensorDyn` rather than an
114/// `Array3<u8>` so that `edgefirst-tensor` can own the type without taking an
115/// ndarray dependency, which is what lets `edgefirst-image` render masks
116/// without depending on this crate.
117fn mask_to_tensor(mask: ndarray::ArrayView3<'_, u8>) -> DecoderResult<edgefirst_tensor::TensorDyn> {
118    edgefirst_tensor::Tensor::from_arrayview3(mask)
119        .map(edgefirst_tensor::TensorDyn::from)
120        .map_err(|e| DecoderError::Internal(format!("mask -> TensorDyn: {e}")))
121}
122
123/// Float-epsilon comparison for `DetectBox`, as a test-only extension trait.
124///
125/// This is a TEST ASSERTION helper -- all 98 call sites are in this crate's
126/// test modules and there are none in production code -- so it does not belong
127/// on `edgefirst-tensor`'s public API, where the type move briefly put it. An
128/// extension trait keeps every call site written as
129/// `a.equal_within_delta(b, eps)` while confining the method to tests.
130#[cfg(test)]
131pub(crate) trait DetectBoxTestExt {
132    fn equal_within_delta(&self, rhs: &DetectBox, eps: f32) -> bool;
133}
134
135#[cfg(test)]
136impl DetectBoxTestExt for DetectBox {
137    fn equal_within_delta(&self, rhs: &DetectBox, eps: f32) -> bool {
138        let eq_delta = |a: f32, b: f32| (a - b).abs() <= eps;
139        self.label == rhs.label
140            && eq_delta(self.score, rhs.score)
141            && eq_delta(self.bbox.xmin, rhs.bbox.xmin)
142            && eq_delta(self.bbox.ymin, rhs.bbox.ymin)
143            && eq_delta(self.bbox.xmax, rhs.bbox.xmax)
144            && eq_delta(self.bbox.ymax, rhs.bbox.ymax)
145    }
146}
147
148/// Trait to convert bounding box formats to XYXY float format
149pub trait BBoxTypeTrait {
150    /// Converts the bbox into XYXY float format.
151    fn to_xyxy_float<A: Float + 'static, B: AsPrimitive<A>>(input: &[B; 4]) -> [A; 4];
152
153    /// Converts the bbox into XYXY float format.
154    fn to_xyxy_dequant<A: Float + 'static, B: AsPrimitive<A>>(
155        input: &[B; 4],
156        quant: Quantization,
157    ) -> [A; 4]
158    where
159        f32: AsPrimitive<A>,
160        i32: AsPrimitive<A>;
161
162    /// Converts the bbox into XYXY float format.
163    ///
164    /// # Examples
165    /// ```rust
166    /// # use edgefirst_decoder::{BBoxTypeTrait, XYWH};
167    /// # use ndarray::array;
168    /// let arr = array![10.0_f32, 20.0, 20.0, 20.0];
169    /// let xyxy: [f32; 4] = XYWH::ndarray_to_xyxy_float(arr.view());
170    /// assert_eq!(xyxy, [0.0_f32, 10.0, 20.0, 30.0]);
171    /// ```
172    #[inline(always)]
173    fn ndarray_to_xyxy_float<A: Float + 'static, B: AsPrimitive<A>>(
174        input: ArrayView1<B>,
175    ) -> [A; 4] {
176        Self::to_xyxy_float(&[input[0], input[1], input[2], input[3]])
177    }
178
179    #[inline(always)]
180    /// Converts the bbox into XYXY float format.
181    fn ndarray_to_xyxy_dequant<A: Float + 'static, B: AsPrimitive<A>>(
182        input: ArrayView1<B>,
183        quant: Quantization,
184    ) -> [A; 4]
185    where
186        f32: AsPrimitive<A>,
187        i32: AsPrimitive<A>,
188    {
189        Self::to_xyxy_dequant(&[input[0], input[1], input[2], input[3]], quant)
190    }
191}
192
193/// Converts XYXY bounding boxes to XYXY
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub struct XYXY {}
196
197impl BBoxTypeTrait for XYXY {
198    fn to_xyxy_float<A: Float + 'static, B: AsPrimitive<A>>(input: &[B; 4]) -> [A; 4] {
199        input.map(|b| b.as_())
200    }
201
202    fn to_xyxy_dequant<A: Float + 'static, B: AsPrimitive<A>>(
203        input: &[B; 4],
204        quant: Quantization,
205    ) -> [A; 4]
206    where
207        f32: AsPrimitive<A>,
208        i32: AsPrimitive<A>,
209    {
210        let scale = quant.scale.as_();
211        let zp = quant.zero_point.as_();
212        input.map(|b| (b.as_() - zp) * scale)
213    }
214
215    #[inline(always)]
216    fn ndarray_to_xyxy_float<A: Float + 'static, B: AsPrimitive<A>>(
217        input: ArrayView1<B>,
218    ) -> [A; 4] {
219        [
220            input[0].as_(),
221            input[1].as_(),
222            input[2].as_(),
223            input[3].as_(),
224        ]
225    }
226}
227
228/// Converts XYWH bounding boxes to XYXY. The XY values are the center of the
229/// box
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub struct XYWH {}
232
233impl BBoxTypeTrait for XYWH {
234    #[inline(always)]
235    fn to_xyxy_float<A: Float + 'static, B: AsPrimitive<A>>(input: &[B; 4]) -> [A; 4] {
236        let half = A::one() / (A::one() + A::one());
237        [
238            (input[0].as_()) - (input[2].as_() * half),
239            (input[1].as_()) - (input[3].as_() * half),
240            (input[0].as_()) + (input[2].as_() * half),
241            (input[1].as_()) + (input[3].as_() * half),
242        ]
243    }
244
245    #[inline(always)]
246    fn to_xyxy_dequant<A: Float + 'static, B: AsPrimitive<A>>(
247        input: &[B; 4],
248        quant: Quantization,
249    ) -> [A; 4]
250    where
251        f32: AsPrimitive<A>,
252        i32: AsPrimitive<A>,
253    {
254        let scale = quant.scale.as_();
255        let half_scale = (quant.scale * 0.5).as_();
256        let zp = quant.zero_point.as_();
257        let [x, y, w, h] = [
258            (input[0].as_() - zp) * scale,
259            (input[1].as_() - zp) * scale,
260            (input[2].as_() - zp) * half_scale,
261            (input[3].as_() - zp) * half_scale,
262        ];
263
264        [x - w, y - h, x + w, y + h]
265    }
266
267    #[inline(always)]
268    fn ndarray_to_xyxy_float<A: Float + 'static, B: AsPrimitive<A>>(
269        input: ArrayView1<B>,
270    ) -> [A; 4] {
271        let half = A::one() / (A::one() + A::one());
272        [
273            (input[0].as_()) - (input[2].as_() * half),
274            (input[1].as_()) - (input[3].as_() * half),
275            (input[0].as_()) + (input[2].as_() * half),
276            (input[1].as_()) + (input[3].as_() * half),
277        ]
278    }
279}
280
281/// Describes the quantization parameters for a tensor
282#[derive(Debug, Clone, Copy, PartialEq)]
283pub struct Quantization {
284    pub scale: f32,
285    pub zero_point: i32,
286}
287
288impl Quantization {
289    /// Creates a new Quantization struct
290    /// # Examples
291    /// ```
292    /// # use edgefirst_decoder::Quantization;
293    /// let quant = Quantization::new(0.1, -128);
294    /// assert_eq!(quant.scale, 0.1);
295    /// assert_eq!(quant.zero_point, -128);
296    /// ```
297    pub fn new(scale: f32, zero_point: i32) -> Self {
298        Self { scale, zero_point }
299    }
300
301    /// Returns a quantization that's a no-op: scale=1.0, zero_point=0.
302    ///
303    /// Used by the per-scale pipeline as a sentinel for float-to-float
304    /// passthrough cells where no affine transform is required.
305    /// # Examples
306    /// ```
307    /// # use edgefirst_decoder::Quantization;
308    /// let quant = Quantization::identity();
309    /// assert_eq!(quant.scale, 1.0);
310    /// assert_eq!(quant.zero_point, 0);
311    /// ```
312    pub fn identity() -> Self {
313        Self {
314            scale: 1.0,
315            zero_point: 0,
316        }
317    }
318}
319
320impl From<QuantTuple> for Quantization {
321    /// Creates a new Quantization struct from a QuantTuple
322    /// # Examples
323    /// ```
324    /// # use edgefirst_decoder::Quantization;
325    /// # use edgefirst_decoder::configs::QuantTuple;
326    /// let quant_tuple = QuantTuple(0.1_f32, -128_i32);
327    /// let quant = Quantization::from(quant_tuple);
328    /// assert_eq!(quant.scale, 0.1);
329    /// assert_eq!(quant.zero_point, -128);
330    /// ```
331    fn from(quant_tuple: QuantTuple) -> Quantization {
332        Quantization {
333            scale: quant_tuple.0,
334            zero_point: quant_tuple.1,
335        }
336    }
337}
338
339impl<S, Z> From<(S, Z)> for Quantization
340where
341    S: AsPrimitive<f32>,
342    Z: AsPrimitive<i32>,
343{
344    /// Creates a new Quantization struct from a tuple
345    /// # Examples
346    /// ```
347    /// # use edgefirst_decoder::Quantization;
348    /// let quant = Quantization::from((0.1_f64, -128_i64));
349    /// assert_eq!(quant.scale, 0.1);
350    /// assert_eq!(quant.zero_point, -128);
351    /// ```
352    fn from((scale, zp): (S, Z)) -> Quantization {
353        Self {
354            scale: scale.as_(),
355            zero_point: zp.as_(),
356        }
357    }
358}
359
360impl Default for Quantization {
361    /// Creates a default Quantization struct with scale 1.0 and zero_point 0
362    /// # Examples
363    /// ```rust
364    /// # use edgefirst_decoder::Quantization;
365    /// let quant = Quantization::default();
366    /// assert_eq!(quant.scale, 1.0);
367    /// assert_eq!(quant.zero_point, 0);
368    /// ```
369    fn default() -> Self {
370        Self {
371            scale: 1.0,
372            zero_point: 0,
373        }
374    }
375}
376
377/// Turns a DetectBoxQuantized into a DetectBox by dequantizing the score.
378///
379///  # Examples
380/// ```
381/// # use edgefirst_decoder::{BoundingBox, DetectBoxQuantized, Quantization, dequant_detect_box};
382/// let quant = Quantization::new(0.1, -128);
383/// let bbox = BoundingBox::new(0.1, 0.2, 0.3, 0.4);
384/// let detect_quant = DetectBoxQuantized {
385///     bbox,
386///     score: 100_i8,
387///     label: 1,
388/// };
389/// let detect = dequant_detect_box(&detect_quant, quant);
390/// assert_eq!(detect.score, 0.1 * 100.0 + 12.8);
391/// assert_eq!(detect.label, 1);
392/// assert_eq!(detect.bbox, bbox);
393/// ```
394pub fn dequant_detect_box<SCORE: PrimInt + AsPrimitive<f32>>(
395    detect: &DetectBoxQuantized<SCORE>,
396    quant_scores: Quantization,
397) -> DetectBox {
398    let scaled_zp = -quant_scores.scale * quant_scores.zero_point as f32;
399    DetectBox {
400        bbox: detect.bbox,
401        score: quant_scores.scale * detect.score.as_() + scaled_zp,
402        label: detect.label,
403    }
404}
405/// A detection box with a f32 bbox and quantized score
406#[derive(Debug, Clone, Copy, PartialEq)]
407pub struct DetectBoxQuantized<
408    // BOX: Signed + PrimInt + AsPrimitive<f32>,
409    SCORE: PrimInt + AsPrimitive<f32>,
410> {
411    // pub bbox: BoundingBoxQuantized<BOX>,
412    pub bbox: BoundingBox,
413    /// model-specific score for this detection, higher implies more
414    /// confidence.
415    pub score: SCORE,
416    /// label index for this detect
417    pub label: usize,
418}
419
420/// Dequantizes an ndarray from quantized values to f32 values using the given
421/// quantization parameters
422///
423/// # Examples
424/// ```
425/// # use edgefirst_decoder::{dequantize_ndarray, Quantization};
426/// let quant = Quantization::new(0.1, -128);
427/// let input: Vec<i8> = vec![0, 127, -128, 64];
428/// let input_array = ndarray::Array1::from(input);
429/// let output_array: ndarray::Array1<f32> = dequantize_ndarray(input_array.view(), quant);
430/// assert_eq!(output_array, ndarray::array![12.8, 25.5, 0.0, 19.2]);
431/// ```
432pub fn dequantize_ndarray<T: AsPrimitive<F>, D: Dimension, F: Float + 'static>(
433    input: ArrayView<T, D>,
434    quant: Quantization,
435) -> Array<F, D>
436where
437    i32: num_traits::AsPrimitive<F>,
438    f32: num_traits::AsPrimitive<F>,
439{
440    let zero_point = quant.zero_point.as_();
441    let scale = quant.scale.as_();
442    if zero_point != F::zero() {
443        let scaled_zero = -zero_point * scale;
444        input.mapv(|d| d.as_() * scale + scaled_zero)
445    } else {
446        input.mapv(|d| d.as_() * scale)
447    }
448}
449
450/// Dequantizes a slice from quantized values to float values using the given
451/// quantization parameters
452///
453/// # Examples
454/// ```
455/// # use edgefirst_decoder::{dequantize_cpu, Quantization};
456/// let quant = Quantization::new(0.1, -128);
457/// let input: Vec<i8> = vec![0, 127, -128, 64];
458/// let mut output: Vec<f32> = vec![0.0; input.len()];
459/// dequantize_cpu(&input, quant, &mut output);
460/// assert_eq!(output, vec![12.8, 25.5, 0.0, 19.2]);
461/// ```
462pub fn dequantize_cpu<T: AsPrimitive<F>, F: Float + 'static>(
463    input: &[T],
464    quant: Quantization,
465    output: &mut [F],
466) where
467    f32: num_traits::AsPrimitive<F>,
468    i32: num_traits::AsPrimitive<F>,
469{
470    assert!(input.len() == output.len());
471    let zero_point = quant.zero_point.as_();
472    let scale = quant.scale.as_();
473    if zero_point != F::zero() {
474        let scaled_zero = -zero_point * scale; // scale * (d - zero_point) = d * scale - zero_point * scale
475        input
476            .iter()
477            .zip(output)
478            .for_each(|(d, deq)| *deq = d.as_() * scale + scaled_zero);
479    } else {
480        input
481            .iter()
482            .zip(output)
483            .for_each(|(d, deq)| *deq = d.as_() * scale);
484    }
485}
486
487/// Dequantizes a slice from quantized values to float values using the given
488/// quantization parameters, using chunked processing. This is around 5% faster
489/// than `dequantize_cpu` for large slices.
490///
491/// # Examples
492/// ```
493/// # use edgefirst_decoder::{dequantize_cpu_chunked, Quantization};
494/// let quant = Quantization::new(0.1, -128);
495/// let input: Vec<i8> = vec![0, 127, -128, 64];
496/// let mut output: Vec<f32> = vec![0.0; input.len()];
497/// dequantize_cpu_chunked(&input, quant, &mut output);
498/// assert_eq!(output, vec![12.8, 25.5, 0.0, 19.2]);
499/// ```
500pub fn dequantize_cpu_chunked<T: AsPrimitive<F>, F: Float + 'static>(
501    input: &[T],
502    quant: Quantization,
503    output: &mut [F],
504) where
505    f32: num_traits::AsPrimitive<F>,
506    i32: num_traits::AsPrimitive<F>,
507{
508    assert!(input.len() == output.len());
509    let zero_point = quant.zero_point.as_();
510    let scale = quant.scale.as_();
511
512    let input = input.as_chunks::<4>();
513    let output = output.as_chunks_mut::<4>();
514
515    if zero_point != F::zero() {
516        let scaled_zero = -zero_point * scale; // scale * (d - zero_point) = d * scale - zero_point * scale
517
518        input
519            .0
520            .iter()
521            .zip(output.0)
522            .for_each(|(d, deq)| *deq = d.map(|d| d.as_() * scale + scaled_zero));
523        input
524            .1
525            .iter()
526            .zip(output.1)
527            .for_each(|(d, deq)| *deq = d.as_() * scale + scaled_zero);
528    } else {
529        input
530            .0
531            .iter()
532            .zip(output.0)
533            .for_each(|(d, deq)| *deq = d.map(|d| d.as_() * scale));
534        input
535            .1
536            .iter()
537            .zip(output.1)
538            .for_each(|(d, deq)| *deq = d.as_() * scale);
539    }
540}
541
542/// Converts a segmentation tensor into a 2D mask
543/// If the last dimension of the segmentation tensor is 1, values equal or
544/// above 128 are considered objects. Otherwise the object is the argmax index
545///
546/// # Errors
547///
548/// Returns `DecoderError::InvalidShape` if the segmentation tensor has an
549/// invalid shape.
550///
551/// # Examples
552/// ```
553/// # use edgefirst_decoder::segmentation_to_mask;
554/// let segmentation =
555///     ndarray::Array3::<u8>::from_shape_vec((2, 2, 1), vec![0, 255, 128, 127]).unwrap();
556/// let mask = segmentation_to_mask(segmentation.view()).unwrap();
557/// assert_eq!(mask, ndarray::array![[0, 1], [1, 0]]);
558/// ```
559pub fn segmentation_to_mask(segmentation: ArrayView3<u8>) -> Result<Array2<u8>, DecoderError> {
560    if segmentation.shape()[2] == 0 {
561        return Err(DecoderError::InvalidShape(
562            "Segmentation tensor must have non-zero depth".to_string(),
563        ));
564    }
565    if segmentation.shape()[2] == 1 {
566        yolo_segmentation_to_mask(segmentation, 128)
567    } else {
568        Ok(modelpack_segmentation_to_mask(segmentation))
569    }
570}
571
572/// Returns the maximum value and its index from a 1D array
573fn arg_max<T: PartialOrd + Copy>(score: ArrayView1<T>) -> (T, usize) {
574    score
575        .iter()
576        .enumerate()
577        .fold((score[0], 0), |(max, arg_max), (ind, s)| {
578            if max > *s {
579                (max, arg_max)
580            } else {
581                (*s, ind)
582            }
583        })
584}
585
586/// NEON-accelerated argmax for i8 slices on aarch64.
587///
588/// Finds the maximum value and its index (last index wins on ties, matching
589/// the semantics of [`arg_max`]).  Falls back to the scalar implementation
590/// on non-aarch64 targets or when the slice is too short to benefit from NEON.
591#[cfg(target_arch = "aarch64")]
592pub(crate) fn arg_max_i8(scores: &[i8]) -> (i8, usize) {
593    use std::arch::aarch64::*; // NOSONAR
594
595    let n = scores.len();
596    if n < 16 {
597        // Scalar fallback for very short slices.
598        let mut max = scores[0];
599        let mut idx = 0;
600        for (i, &s) in scores.iter().enumerate().skip(1) {
601            if s >= max {
602                max = s;
603                idx = i;
604            }
605        }
606        return (max, idx);
607    }
608
609    unsafe {
610        // Step 1: Find the global max value using NEON horizontal max.
611        let chunks = n / 16;
612        let mut vmax = vld1q_s8(scores.as_ptr());
613        for i in 1..chunks {
614            let v = vld1q_s8(scores.as_ptr().add(i * 16));
615            vmax = vmaxq_s8(vmax, v);
616        }
617        let global_max = vmaxvq_s8(vmax);
618
619        // Handle remainder with scalar
620        let remainder_start = chunks * 16;
621        let mut final_max = global_max;
622        for &s in &scores[remainder_start..] {
623            if s > final_max {
624                final_max = s;
625            }
626        }
627
628        // Step 2: Find the LAST index of `final_max` (preserves tie semantics).
629        // Scan backwards for the last occurrence.
630        let mut idx = 0;
631        for i in (0..n).rev() {
632            if scores[i] == final_max {
633                idx = i;
634                break;
635            }
636        }
637        (final_max, idx)
638    }
639}
640#[cfg(test)]
641#[cfg_attr(coverage_nightly, coverage(off))]
642mod decoder_tests {
643    #![allow(clippy::excessive_precision)]
644    use crate::DetectBoxTestExt;
645    use crate::{
646        configs::{DecoderType, DimName, Protos},
647        modelpack::{decode_modelpack_det, decode_modelpack_split_quant},
648        yolo::{
649            decode_yolo_det, decode_yolo_det_float, decode_yolo_segdet_float,
650            decode_yolo_segdet_quant,
651        },
652        *,
653    };
654    use edgefirst_tensor::{Tensor, TensorMapTrait, TensorTrait};
655    use ndarray::Dimension;
656    use ndarray::{array, s, Array2, Array3, Array4, Axis};
657    use num_traits::{AsPrimitive, PrimInt};
658
659    fn compare_outputs(
660        boxes: (&[DetectBox], &[DetectBox]),
661        masks: (&[Segmentation], &[Segmentation]),
662    ) {
663        let (boxes0, boxes1) = boxes;
664        let (masks0, masks1) = masks;
665
666        assert_eq!(boxes0.len(), boxes1.len());
667        assert_eq!(masks0.len(), masks1.len());
668
669        for (b_i8, b_f32) in boxes0.iter().zip(boxes1) {
670            assert!(
671                b_i8.equal_within_delta(b_f32, 1e-6),
672                "{b_i8:?} is not equal to {b_f32:?}"
673            );
674        }
675
676        for (m_i8, m_f32) in masks0.iter().zip(masks1) {
677            assert_eq!(
678                [m_i8.xmin, m_i8.ymin, m_i8.xmax, m_i8.ymax],
679                [m_f32.xmin, m_f32.ymin, m_f32.xmax, m_f32.ymax],
680            );
681            let shape = m_i8.segmentation.shape().to_vec();
682            assert_eq!(shape, m_f32.segmentation.shape());
683            // `Segmentation` carries a TensorDyn, so compare the mapped bytes
684            // rather than going through ndarray element ops.
685            let (t_i8, t_f32) = (
686                m_i8.segmentation.as_u8().expect("mask must be U8"),
687                m_f32.segmentation.as_u8().expect("mask must be U8"),
688            );
689            let (map_i8, map_f32) = (
690                t_i8.map_read().expect("map i8 mask"),
691                t_f32.map_read().expect("map f32 mask"),
692            );
693            let (a, b) = (map_i8.as_slice(), map_f32.as_slice());
694            let (w, c) = (shape[1], shape[2]);
695            let mut sq_err = 0f64;
696            for (i, (&va, &vb)) in a.iter().zip(b.iter()).enumerate() {
697                let val = va as i32 - vb as i32;
698                assert!(
699                    val.abs() <= 1,
700                    "Difference between mask0 and mask1 is greater than 1 at ({}, {}, {}): {}",
701                    i / (w * c),
702                    (i / c) % w,
703                    i % c,
704                    val
705                );
706                sq_err += (val as f64) * (val as f64);
707            }
708            let mean_sq_err = sq_err / a.len() as f64;
709            assert!(
710                mean_sq_err < 1e-2,
711                "Mean Square Error between masks was greater than 1%: {:.2}%",
712                mean_sq_err * 100.0
713            );
714        }
715    }
716
717    /// Rebuild an owned `Array3<u8>` from a `Segmentation`'s `TensorDyn`, for
718    /// the ndarray-shaped test helpers that still want a view.
719    fn seg_view(s: &Segmentation) -> ndarray::Array3<u8> {
720        let t = s.segmentation.as_u8().expect("mask must be U8");
721        let m = t.map_read().expect("map mask");
722        let sh = s.segmentation.shape();
723        ndarray::Array3::from_shape_vec((sh[0], sh[1], sh[2]), m.as_slice().to_vec())
724            .expect("mask shape")
725    }
726
727    // ─── Shared test data loaders ────────────────────────
728
729    fn load_yolov8_boxes() -> Array3<i8> {
730        let raw = edgefirst_bench::testdata::read("yolov8_boxes_116x8400.bin");
731        let raw = unsafe { std::slice::from_raw_parts(raw.as_ptr() as *const i8, raw.len()) };
732        Array3::from_shape_vec((1, 116, 8400), raw.to_vec()).unwrap()
733    }
734
735    fn load_yolov8_protos() -> Array4<i8> {
736        let raw = edgefirst_bench::testdata::read("yolov8_protos_160x160x32.bin");
737        let raw = unsafe { std::slice::from_raw_parts(raw.as_ptr() as *const i8, raw.len()) };
738        Array4::from_shape_vec((1, 160, 160, 32), raw.to_vec()).unwrap()
739    }
740
741    fn load_yolov8s_det() -> Array3<i8> {
742        let raw = edgefirst_bench::testdata::read("yolov8s_80_classes.bin");
743        let raw = unsafe { std::slice::from_raw_parts(raw.as_ptr() as *const i8, raw.len()) };
744        Array3::from_shape_vec((1, 84, 8400), raw.to_vec()).unwrap()
745    }
746
747    #[test]
748    fn test_decoder_modelpack() {
749        let score_threshold = 0.45;
750        let iou_threshold = 0.45;
751        let boxes = edgefirst_bench::testdata::read("modelpack_boxes_1935x1x4.bin");
752        let boxes = ndarray::Array4::from_shape_vec((1, 1935, 1, 4), boxes.to_vec()).unwrap();
753
754        let scores = edgefirst_bench::testdata::read("modelpack_scores_1935x1.bin");
755        let scores = ndarray::Array3::from_shape_vec((1, 1935, 1), scores.to_vec()).unwrap();
756
757        let quant_boxes = (0.004656755365431309, 21).into();
758        let quant_scores = (0.0019603664986789227, 0).into();
759
760        let decoder = DecoderBuilder::default()
761            .with_config_modelpack_det(
762                configs::Boxes {
763                    decoder: DecoderType::ModelPack,
764                    quantization: Some(quant_boxes),
765                    shape: vec![1, 1935, 1, 4],
766                    dshape: vec![
767                        (DimName::Batch, 1),
768                        (DimName::NumBoxes, 1935),
769                        (DimName::Padding, 1),
770                        (DimName::BoxCoords, 4),
771                    ],
772                    normalized: Some(true),
773                },
774                configs::Scores {
775                    decoder: DecoderType::ModelPack,
776                    quantization: Some(quant_scores),
777                    shape: vec![1, 1935, 1],
778                    dshape: vec![
779                        (DimName::Batch, 1),
780                        (DimName::NumBoxes, 1935),
781                        (DimName::NumClasses, 1),
782                    ],
783                },
784            )
785            .with_score_threshold(score_threshold)
786            .with_iou_threshold(iou_threshold)
787            .build()
788            .unwrap();
789
790        let quant_boxes = quant_boxes.into();
791        let quant_scores = quant_scores.into();
792
793        let mut output_boxes: Vec<_> = Vec::with_capacity(50);
794        decode_modelpack_det(
795            (boxes.slice(s![0, .., 0, ..]), quant_boxes),
796            (scores.slice(s![0, .., ..]), quant_scores),
797            score_threshold,
798            iou_threshold,
799            300,
800            &mut output_boxes,
801        );
802        assert!(output_boxes[0].equal_within_delta(
803            &DetectBox {
804                bbox: BoundingBox {
805                    xmin: 0.40513772,
806                    ymin: 0.6379755,
807                    xmax: 0.5122431,
808                    ymax: 0.7730214,
809                },
810                score: 0.4861709,
811                label: 0
812            },
813            1e-6
814        ));
815
816        let mut output_boxes1 = Vec::with_capacity(50);
817        let mut output_masks1 = Vec::with_capacity(50);
818
819        decoder
820            .decode_quantized(
821                &[boxes.view().into(), scores.view().into()],
822                &mut output_boxes1,
823                &mut output_masks1,
824            )
825            .unwrap();
826
827        let mut output_boxes_float = Vec::with_capacity(50);
828        let mut output_masks_float = Vec::with_capacity(50);
829
830        let boxes = dequantize_ndarray(boxes.view(), quant_boxes);
831        let scores = dequantize_ndarray(scores.view(), quant_scores);
832
833        decoder
834            .decode_float::<f32>(
835                &[boxes.view().into_dyn(), scores.view().into_dyn()],
836                &mut output_boxes_float,
837                &mut output_masks_float,
838            )
839            .unwrap();
840
841        compare_outputs((&output_boxes, &output_boxes1), (&[], &output_masks1));
842        compare_outputs(
843            (&output_boxes, &output_boxes_float),
844            (&[], &output_masks_float),
845        );
846    }
847
848    #[test]
849    fn test_decoder_modelpack_split_u8() {
850        let score_threshold = 0.45;
851        let iou_threshold = 0.45;
852        let detect0 = edgefirst_bench::testdata::read("modelpack_split_9x15x18.bin");
853        let detect0 = ndarray::Array4::from_shape_vec((1, 9, 15, 18), detect0.to_vec()).unwrap();
854
855        let detect1 = edgefirst_bench::testdata::read("modelpack_split_17x30x18.bin");
856        let detect1 = ndarray::Array4::from_shape_vec((1, 17, 30, 18), detect1.to_vec()).unwrap();
857
858        let quant0 = (0.08547406643629074, 174).into();
859        let quant1 = (0.09929127991199493, 183).into();
860        let anchors0 = vec![
861            [0.36666667461395264, 0.31481480598449707],
862            [0.38749998807907104, 0.4740740656852722],
863            [0.5333333611488342, 0.644444465637207],
864        ];
865        let anchors1 = vec![
866            [0.13750000298023224, 0.2074074000120163],
867            [0.2541666626930237, 0.21481481194496155],
868            [0.23125000298023224, 0.35185185074806213],
869        ];
870
871        let detect_config0 = configs::Detection {
872            decoder: DecoderType::ModelPack,
873            shape: vec![1, 9, 15, 18],
874            anchors: Some(anchors0.clone()),
875            quantization: Some(quant0),
876            dshape: vec![
877                (DimName::Batch, 1),
878                (DimName::Height, 9),
879                (DimName::Width, 15),
880                (DimName::NumAnchorsXFeatures, 18),
881            ],
882            normalized: Some(true),
883        };
884
885        let detect_config1 = configs::Detection {
886            decoder: DecoderType::ModelPack,
887            shape: vec![1, 17, 30, 18],
888            anchors: Some(anchors1.clone()),
889            quantization: Some(quant1),
890            dshape: vec![
891                (DimName::Batch, 1),
892                (DimName::Height, 17),
893                (DimName::Width, 30),
894                (DimName::NumAnchorsXFeatures, 18),
895            ],
896            normalized: Some(true),
897        };
898
899        let config0 = (&detect_config0).try_into().unwrap();
900        let config1 = (&detect_config1).try_into().unwrap();
901
902        let decoder = DecoderBuilder::default()
903            .with_config_modelpack_det_split(vec![detect_config1, detect_config0])
904            .with_score_threshold(score_threshold)
905            .with_iou_threshold(iou_threshold)
906            .build()
907            .unwrap();
908
909        let quant0 = quant0.into();
910        let quant1 = quant1.into();
911
912        let mut output_boxes: Vec<_> = Vec::with_capacity(10);
913        decode_modelpack_split_quant(
914            &[
915                detect0.slice(s![0, .., .., ..]),
916                detect1.slice(s![0, .., .., ..]),
917            ],
918            &[config0, config1],
919            score_threshold,
920            iou_threshold,
921            300,
922            &mut output_boxes,
923        );
924        assert!(output_boxes[0].equal_within_delta(
925            &DetectBox {
926                bbox: BoundingBox {
927                    xmin: 0.43171933,
928                    ymin: 0.68243736,
929                    xmax: 0.5626645,
930                    ymax: 0.808863,
931                },
932                score: 0.99240804,
933                label: 0
934            },
935            1e-6
936        ));
937
938        let mut output_boxes1: Vec<_> = Vec::with_capacity(10);
939        let mut output_masks1: Vec<_> = Vec::with_capacity(10);
940        decoder
941            .decode_quantized(
942                &[detect0.view().into(), detect1.view().into()],
943                &mut output_boxes1,
944                &mut output_masks1,
945            )
946            .unwrap();
947
948        let mut output_boxes1_f32: Vec<_> = Vec::with_capacity(10);
949        let mut output_masks1_f32: Vec<_> = Vec::with_capacity(10);
950
951        let detect0 = dequantize_ndarray(detect0.view(), quant0);
952        let detect1 = dequantize_ndarray(detect1.view(), quant1);
953        decoder
954            .decode_float::<f32>(
955                &[detect0.view().into_dyn(), detect1.view().into_dyn()],
956                &mut output_boxes1_f32,
957                &mut output_masks1_f32,
958            )
959            .unwrap();
960
961        compare_outputs((&output_boxes, &output_boxes1), (&[], &output_masks1));
962        compare_outputs(
963            (&output_boxes, &output_boxes1_f32),
964            (&[], &output_masks1_f32),
965        );
966    }
967
968    #[test]
969    fn test_decoder_parse_config_modelpack_split_u8() {
970        let score_threshold = 0.45;
971        let iou_threshold = 0.45;
972        let detect0 = edgefirst_bench::testdata::read("modelpack_split_9x15x18.bin");
973        let detect0 = ndarray::Array4::from_shape_vec((1, 9, 15, 18), detect0.to_vec()).unwrap();
974
975        let detect1 = edgefirst_bench::testdata::read("modelpack_split_17x30x18.bin");
976        let detect1 = ndarray::Array4::from_shape_vec((1, 17, 30, 18), detect1.to_vec()).unwrap();
977
978        let decoder = DecoderBuilder::default()
979            .with_config_yaml_str(
980                edgefirst_bench::testdata::read_to_string("modelpack_split.yaml").to_string(),
981            )
982            .with_score_threshold(score_threshold)
983            .with_iou_threshold(iou_threshold)
984            .build()
985            .unwrap();
986
987        let mut output_boxes: Vec<_> = Vec::with_capacity(10);
988        let mut output_masks: Vec<_> = Vec::with_capacity(10);
989        decoder
990            .decode_quantized(
991                &[
992                    ArrayViewDQuantized::from(detect1.view()),
993                    ArrayViewDQuantized::from(detect0.view()),
994                ],
995                &mut output_boxes,
996                &mut output_masks,
997            )
998            .unwrap();
999        assert!(output_boxes[0].equal_within_delta(
1000            &DetectBox {
1001                bbox: BoundingBox {
1002                    xmin: 0.43171933,
1003                    ymin: 0.68243736,
1004                    xmax: 0.5626645,
1005                    ymax: 0.808863,
1006                },
1007                score: 0.99240804,
1008                label: 0
1009            },
1010            1e-6
1011        ));
1012    }
1013
1014    #[test]
1015    fn test_modelpack_seg() {
1016        let out = edgefirst_bench::testdata::read("modelpack_seg_2x160x160.bin");
1017        let out = ndarray::Array4::from_shape_vec((1, 2, 160, 160), out.to_vec()).unwrap();
1018        let quant = (1.0 / 255.0, 0).into();
1019
1020        let decoder = DecoderBuilder::default()
1021            .with_config_modelpack_seg(configs::Segmentation {
1022                decoder: DecoderType::ModelPack,
1023                quantization: Some(quant),
1024                shape: vec![1, 2, 160, 160],
1025                dshape: vec![
1026                    (DimName::Batch, 1),
1027                    (DimName::NumClasses, 2),
1028                    (DimName::Height, 160),
1029                    (DimName::Width, 160),
1030                ],
1031            })
1032            .build()
1033            .unwrap();
1034        let mut output_boxes: Vec<_> = Vec::with_capacity(10);
1035        let mut output_masks: Vec<_> = Vec::with_capacity(10);
1036        decoder
1037            .decode_quantized(&[out.view().into()], &mut output_boxes, &mut output_masks)
1038            .unwrap();
1039
1040        let mut mask = out.slice(s![0, .., .., ..]);
1041        mask.swap_axes(0, 1);
1042        mask.swap_axes(1, 2);
1043        let mask = [Segmentation {
1044            xmin: 0.0,
1045            ymin: 0.0,
1046            xmax: 1.0,
1047            ymax: 1.0,
1048            segmentation: crate::mask_to_tensor(mask.view()).unwrap(),
1049        }];
1050        compare_outputs((&[], &output_boxes), (&mask, &output_masks));
1051
1052        decoder
1053            .decode_float::<f32>(
1054                &[dequantize_ndarray(out.view(), quant.into())
1055                    .view()
1056                    .into_dyn()],
1057                &mut output_boxes,
1058                &mut output_masks,
1059            )
1060            .unwrap();
1061
1062        // not expected for float decoder to have same values as quantized decoder, as
1063        // float decoder ensures the data fills 0-255, quantized decoder uses whatever
1064        // the model output. Thus the float output is the same as the quantized output
1065        // but scaled differently. However, it is expected that the mask after argmax
1066        // will be the same.
1067        compare_outputs((&[], &output_boxes), (&[], &[]));
1068        let mask0 = segmentation_to_mask(seg_view(&mask[0]).view()).unwrap();
1069        let mask1 = segmentation_to_mask(seg_view(&output_masks[0]).view()).unwrap();
1070
1071        assert_eq!(mask0, mask1);
1072    }
1073    #[test]
1074    fn test_modelpack_seg_quant() {
1075        let out = edgefirst_bench::testdata::read("modelpack_seg_2x160x160.bin");
1076        let out_u8 = ndarray::Array4::from_shape_vec((1, 2, 160, 160), out.to_vec()).unwrap();
1077        let out_i8 = out_u8.mapv(|x| (x as i16 - 128) as i8);
1078        let out_u16 = out_u8.mapv(|x| (x as u16) << 8);
1079        let out_i16 = out_u8.mapv(|x| (((x as i32) << 8) - 32768) as i16);
1080        let out_u32 = out_u8.mapv(|x| (x as u32) << 24);
1081        let out_i32 = out_u8.mapv(|x| (((x as i64) << 24) - 2147483648) as i32);
1082
1083        let quant = (1.0 / 255.0, 0).into();
1084
1085        let decoder = DecoderBuilder::default()
1086            .with_config_modelpack_seg(configs::Segmentation {
1087                decoder: DecoderType::ModelPack,
1088                quantization: Some(quant),
1089                shape: vec![1, 2, 160, 160],
1090                dshape: vec![
1091                    (DimName::Batch, 1),
1092                    (DimName::NumClasses, 2),
1093                    (DimName::Height, 160),
1094                    (DimName::Width, 160),
1095                ],
1096            })
1097            .build()
1098            .unwrap();
1099        let mut output_boxes: Vec<_> = Vec::with_capacity(10);
1100        let mut output_masks_u8: Vec<_> = Vec::with_capacity(10);
1101        decoder
1102            .decode_quantized(
1103                &[out_u8.view().into()],
1104                &mut output_boxes,
1105                &mut output_masks_u8,
1106            )
1107            .unwrap();
1108
1109        let mut output_masks_i8: Vec<_> = Vec::with_capacity(10);
1110        decoder
1111            .decode_quantized(
1112                &[out_i8.view().into()],
1113                &mut output_boxes,
1114                &mut output_masks_i8,
1115            )
1116            .unwrap();
1117
1118        let mut output_masks_u16: Vec<_> = Vec::with_capacity(10);
1119        decoder
1120            .decode_quantized(
1121                &[out_u16.view().into()],
1122                &mut output_boxes,
1123                &mut output_masks_u16,
1124            )
1125            .unwrap();
1126
1127        let mut output_masks_i16: Vec<_> = Vec::with_capacity(10);
1128        decoder
1129            .decode_quantized(
1130                &[out_i16.view().into()],
1131                &mut output_boxes,
1132                &mut output_masks_i16,
1133            )
1134            .unwrap();
1135
1136        let mut output_masks_u32: Vec<_> = Vec::with_capacity(10);
1137        decoder
1138            .decode_quantized(
1139                &[out_u32.view().into()],
1140                &mut output_boxes,
1141                &mut output_masks_u32,
1142            )
1143            .unwrap();
1144
1145        let mut output_masks_i32: Vec<_> = Vec::with_capacity(10);
1146        decoder
1147            .decode_quantized(
1148                &[out_i32.view().into()],
1149                &mut output_boxes,
1150                &mut output_masks_i32,
1151            )
1152            .unwrap();
1153
1154        compare_outputs((&[], &output_boxes), (&[], &[]));
1155        let mask_u8 = segmentation_to_mask(seg_view(&output_masks_u8[0]).view()).unwrap();
1156        let mask_i8 = segmentation_to_mask(seg_view(&output_masks_i8[0]).view()).unwrap();
1157        let mask_u16 = segmentation_to_mask(seg_view(&output_masks_u16[0]).view()).unwrap();
1158        let mask_i16 = segmentation_to_mask(seg_view(&output_masks_i16[0]).view()).unwrap();
1159        let mask_u32 = segmentation_to_mask(seg_view(&output_masks_u32[0]).view()).unwrap();
1160        let mask_i32 = segmentation_to_mask(seg_view(&output_masks_i32[0]).view()).unwrap();
1161        assert_eq!(mask_u8, mask_i8);
1162        assert_eq!(mask_u8, mask_u16);
1163        assert_eq!(mask_u8, mask_i16);
1164        assert_eq!(mask_u8, mask_u32);
1165        assert_eq!(mask_u8, mask_i32);
1166    }
1167
1168    #[test]
1169    fn test_modelpack_segdet() {
1170        let score_threshold = 0.45;
1171        let iou_threshold = 0.45;
1172
1173        let boxes = edgefirst_bench::testdata::read("modelpack_boxes_1935x1x4.bin");
1174        let boxes = Array4::from_shape_vec((1, 1935, 1, 4), boxes.to_vec()).unwrap();
1175
1176        let scores = edgefirst_bench::testdata::read("modelpack_scores_1935x1.bin");
1177        let scores = Array3::from_shape_vec((1, 1935, 1), scores.to_vec()).unwrap();
1178
1179        let seg = edgefirst_bench::testdata::read("modelpack_seg_2x160x160.bin");
1180        let seg = Array4::from_shape_vec((1, 2, 160, 160), seg.to_vec()).unwrap();
1181
1182        let quant_boxes = (0.004656755365431309, 21).into();
1183        let quant_scores = (0.0019603664986789227, 0).into();
1184        let quant_seg = (1.0 / 255.0, 0).into();
1185
1186        let decoder = DecoderBuilder::default()
1187            .with_config_modelpack_segdet(
1188                configs::Boxes {
1189                    decoder: DecoderType::ModelPack,
1190                    quantization: Some(quant_boxes),
1191                    shape: vec![1, 1935, 1, 4],
1192                    dshape: vec![
1193                        (DimName::Batch, 1),
1194                        (DimName::NumBoxes, 1935),
1195                        (DimName::Padding, 1),
1196                        (DimName::BoxCoords, 4),
1197                    ],
1198                    normalized: Some(true),
1199                },
1200                configs::Scores {
1201                    decoder: DecoderType::ModelPack,
1202                    quantization: Some(quant_scores),
1203                    shape: vec![1, 1935, 1],
1204                    dshape: vec![
1205                        (DimName::Batch, 1),
1206                        (DimName::NumBoxes, 1935),
1207                        (DimName::NumClasses, 1),
1208                    ],
1209                },
1210                configs::Segmentation {
1211                    decoder: DecoderType::ModelPack,
1212                    quantization: Some(quant_seg),
1213                    shape: vec![1, 2, 160, 160],
1214                    dshape: vec![
1215                        (DimName::Batch, 1),
1216                        (DimName::NumClasses, 2),
1217                        (DimName::Height, 160),
1218                        (DimName::Width, 160),
1219                    ],
1220                },
1221            )
1222            .with_iou_threshold(iou_threshold)
1223            .with_score_threshold(score_threshold)
1224            .build()
1225            .unwrap();
1226        let mut output_boxes: Vec<_> = Vec::with_capacity(10);
1227        let mut output_masks: Vec<_> = Vec::with_capacity(10);
1228        decoder
1229            .decode_quantized(
1230                &[scores.view().into(), boxes.view().into(), seg.view().into()],
1231                &mut output_boxes,
1232                &mut output_masks,
1233            )
1234            .unwrap();
1235
1236        let mut mask = seg.slice(s![0, .., .., ..]);
1237        mask.swap_axes(0, 1);
1238        mask.swap_axes(1, 2);
1239        let mask = [Segmentation {
1240            xmin: 0.0,
1241            ymin: 0.0,
1242            xmax: 1.0,
1243            ymax: 1.0,
1244            segmentation: crate::mask_to_tensor(mask.view()).unwrap(),
1245        }];
1246        let correct_boxes = [DetectBox {
1247            bbox: BoundingBox {
1248                xmin: 0.40513772,
1249                ymin: 0.6379755,
1250                xmax: 0.5122431,
1251                ymax: 0.7730214,
1252            },
1253            score: 0.4861709,
1254            label: 0,
1255        }];
1256        compare_outputs((&correct_boxes, &output_boxes), (&mask, &output_masks));
1257
1258        let scores = dequantize_ndarray(scores.view(), quant_scores.into());
1259        let boxes = dequantize_ndarray(boxes.view(), quant_boxes.into());
1260        let seg = dequantize_ndarray(seg.view(), quant_seg.into());
1261        decoder
1262            .decode_float::<f32>(
1263                &[
1264                    scores.view().into_dyn(),
1265                    boxes.view().into_dyn(),
1266                    seg.view().into_dyn(),
1267                ],
1268                &mut output_boxes,
1269                &mut output_masks,
1270            )
1271            .unwrap();
1272
1273        // not expected for float segmentation decoder to have same values as quantized
1274        // segmentation decoder, as float decoder ensures the data fills 0-255,
1275        // quantized decoder uses whatever the model output. Thus the float
1276        // output is the same as the quantized output but scaled differently.
1277        // However, it is expected that the mask after argmax will be the same.
1278        compare_outputs((&correct_boxes, &output_boxes), (&[], &[]));
1279        let mask0 = segmentation_to_mask(seg_view(&mask[0]).view()).unwrap();
1280        let mask1 = segmentation_to_mask(seg_view(&output_masks[0]).view()).unwrap();
1281
1282        assert_eq!(mask0, mask1);
1283    }
1284
1285    #[test]
1286    fn test_modelpack_segdet_split() {
1287        let score_threshold = 0.8;
1288        let iou_threshold = 0.5;
1289
1290        let seg = edgefirst_bench::testdata::read("modelpack_seg_2x160x160.bin");
1291        let seg = ndarray::Array4::from_shape_vec((1, 2, 160, 160), seg.to_vec()).unwrap();
1292
1293        let detect0 = edgefirst_bench::testdata::read("modelpack_split_9x15x18.bin");
1294        let detect0 = ndarray::Array4::from_shape_vec((1, 9, 15, 18), detect0.to_vec()).unwrap();
1295
1296        let detect1 = edgefirst_bench::testdata::read("modelpack_split_17x30x18.bin");
1297        let detect1 = ndarray::Array4::from_shape_vec((1, 17, 30, 18), detect1.to_vec()).unwrap();
1298
1299        let quant0 = (0.08547406643629074, 174).into();
1300        let quant1 = (0.09929127991199493, 183).into();
1301        let quant_seg = (1.0 / 255.0, 0).into();
1302
1303        let anchors0 = vec![
1304            [0.36666667461395264, 0.31481480598449707],
1305            [0.38749998807907104, 0.4740740656852722],
1306            [0.5333333611488342, 0.644444465637207],
1307        ];
1308        let anchors1 = vec![
1309            [0.13750000298023224, 0.2074074000120163],
1310            [0.2541666626930237, 0.21481481194496155],
1311            [0.23125000298023224, 0.35185185074806213],
1312        ];
1313
1314        let decoder = DecoderBuilder::default()
1315            .with_config_modelpack_segdet_split(
1316                vec![
1317                    configs::Detection {
1318                        decoder: DecoderType::ModelPack,
1319                        shape: vec![1, 17, 30, 18],
1320                        anchors: Some(anchors1),
1321                        quantization: Some(quant1),
1322                        dshape: vec![
1323                            (DimName::Batch, 1),
1324                            (DimName::Height, 17),
1325                            (DimName::Width, 30),
1326                            (DimName::NumAnchorsXFeatures, 18),
1327                        ],
1328                        normalized: Some(true),
1329                    },
1330                    configs::Detection {
1331                        decoder: DecoderType::ModelPack,
1332                        shape: vec![1, 9, 15, 18],
1333                        anchors: Some(anchors0),
1334                        quantization: Some(quant0),
1335                        dshape: vec![
1336                            (DimName::Batch, 1),
1337                            (DimName::Height, 9),
1338                            (DimName::Width, 15),
1339                            (DimName::NumAnchorsXFeatures, 18),
1340                        ],
1341                        normalized: Some(true),
1342                    },
1343                ],
1344                configs::Segmentation {
1345                    decoder: DecoderType::ModelPack,
1346                    quantization: Some(quant_seg),
1347                    shape: vec![1, 2, 160, 160],
1348                    dshape: vec![
1349                        (DimName::Batch, 1),
1350                        (DimName::NumClasses, 2),
1351                        (DimName::Height, 160),
1352                        (DimName::Width, 160),
1353                    ],
1354                },
1355            )
1356            .with_score_threshold(score_threshold)
1357            .with_iou_threshold(iou_threshold)
1358            .build()
1359            .unwrap();
1360        let mut output_boxes: Vec<_> = Vec::with_capacity(10);
1361        let mut output_masks: Vec<_> = Vec::with_capacity(10);
1362        decoder
1363            .decode_quantized(
1364                &[
1365                    detect0.view().into(),
1366                    detect1.view().into(),
1367                    seg.view().into(),
1368                ],
1369                &mut output_boxes,
1370                &mut output_masks,
1371            )
1372            .unwrap();
1373
1374        let mut mask = seg.slice(s![0, .., .., ..]);
1375        mask.swap_axes(0, 1);
1376        mask.swap_axes(1, 2);
1377        let mask = [Segmentation {
1378            xmin: 0.0,
1379            ymin: 0.0,
1380            xmax: 1.0,
1381            ymax: 1.0,
1382            segmentation: crate::mask_to_tensor(mask.view()).unwrap(),
1383        }];
1384        let correct_boxes = [DetectBox {
1385            bbox: BoundingBox {
1386                xmin: 0.43171933,
1387                ymin: 0.68243736,
1388                xmax: 0.5626645,
1389                ymax: 0.808863,
1390            },
1391            score: 0.99240804,
1392            label: 0,
1393        }];
1394        println!("Output Boxes: {:?}", output_boxes);
1395        compare_outputs((&correct_boxes, &output_boxes), (&mask, &output_masks));
1396
1397        let detect0 = dequantize_ndarray(detect0.view(), quant0.into());
1398        let detect1 = dequantize_ndarray(detect1.view(), quant1.into());
1399        let seg = dequantize_ndarray(seg.view(), quant_seg.into());
1400        decoder
1401            .decode_float::<f32>(
1402                &[
1403                    detect0.view().into_dyn(),
1404                    detect1.view().into_dyn(),
1405                    seg.view().into_dyn(),
1406                ],
1407                &mut output_boxes,
1408                &mut output_masks,
1409            )
1410            .unwrap();
1411
1412        // not expected for float segmentation decoder to have same values as quantized
1413        // segmentation decoder, as float decoder ensures the data fills 0-255,
1414        // quantized decoder uses whatever the model output. Thus the float
1415        // output is the same as the quantized output but scaled differently.
1416        // However, it is expected that the mask after argmax will be the same.
1417        compare_outputs((&correct_boxes, &output_boxes), (&[], &[]));
1418        let mask0 = segmentation_to_mask(seg_view(&mask[0]).view()).unwrap();
1419        let mask1 = segmentation_to_mask(seg_view(&output_masks[0]).view()).unwrap();
1420
1421        assert_eq!(mask0, mask1);
1422    }
1423
1424    #[test]
1425    fn test_dequant_chunked() {
1426        let mut out = load_yolov8s_det().into_raw_vec_and_offset().0;
1427        out.push(123); // make sure to test non multiple of 16 length
1428
1429        let mut out_dequant = vec![0.0; 84 * 8400 + 1];
1430        let mut out_dequant_simd = vec![0.0; 84 * 8400 + 1];
1431        let quant = Quantization::new(0.0040811873, -123);
1432        dequantize_cpu(&out, quant, &mut out_dequant);
1433
1434        dequantize_cpu_chunked(&out, quant, &mut out_dequant_simd);
1435        assert_eq!(out_dequant, out_dequant_simd);
1436
1437        let quant = Quantization::new(0.0040811873, 0);
1438        dequantize_cpu(&out, quant, &mut out_dequant);
1439
1440        dequantize_cpu_chunked(&out, quant, &mut out_dequant_simd);
1441        assert_eq!(out_dequant, out_dequant_simd);
1442    }
1443
1444    #[test]
1445    fn test_dequant_ground_truth() {
1446        // Formula: output = (input - zero_point) * scale
1447        // Verify both dequantize_cpu and dequantize_cpu_chunked against hand-computed values.
1448
1449        // Case 1: scale=0.1, zero_point=-128 (from doc example)
1450        let quant = Quantization::new(0.1, -128);
1451        let input: Vec<i8> = vec![0, 127, -128, 64];
1452        let mut output = vec![0.0f32; 4];
1453        let mut output_chunked = vec![0.0f32; 4];
1454        dequantize_cpu(&input, quant, &mut output);
1455        dequantize_cpu_chunked(&input, quant, &mut output_chunked);
1456        // (0 - (-128)) * 0.1 = 12.8
1457        // (127 - (-128)) * 0.1 = 25.5
1458        // (-128 - (-128)) * 0.1 = 0.0
1459        // (64 - (-128)) * 0.1 = 19.2
1460        let expected: Vec<f32> = vec![12.8, 25.5, 0.0, 19.2];
1461        for (i, (&out, &exp)) in output.iter().zip(expected.iter()).enumerate() {
1462            assert!((out - exp).abs() < 1e-5, "cpu[{i}]: {out} != {exp}");
1463        }
1464        for (i, (&out, &exp)) in output_chunked.iter().zip(expected.iter()).enumerate() {
1465            assert!((out - exp).abs() < 1e-5, "chunked[{i}]: {out} != {exp}");
1466        }
1467
1468        // Case 2: scale=1.0, zero_point=0 (identity-like)
1469        let quant = Quantization::new(1.0, 0);
1470        dequantize_cpu(&input, quant, &mut output);
1471        dequantize_cpu_chunked(&input, quant, &mut output_chunked);
1472        let expected: Vec<f32> = vec![0.0, 127.0, -128.0, 64.0];
1473        assert_eq!(output, expected);
1474        assert_eq!(output_chunked, expected);
1475
1476        // Case 3: scale=0.5, zero_point=0
1477        let quant = Quantization::new(0.5, 0);
1478        dequantize_cpu(&input, quant, &mut output);
1479        dequantize_cpu_chunked(&input, quant, &mut output_chunked);
1480        let expected: Vec<f32> = vec![0.0, 63.5, -64.0, 32.0];
1481        assert_eq!(output, expected);
1482        assert_eq!(output_chunked, expected);
1483
1484        // Case 4: i8 min/max boundaries with typical quantization params
1485        let quant = Quantization::new(0.021287762, 31);
1486        let input: Vec<i8> = vec![-128, -1, 0, 1, 31, 127];
1487        let mut output = vec![0.0f32; 6];
1488        let mut output_chunked = vec![0.0f32; 6];
1489        dequantize_cpu(&input, quant, &mut output);
1490        dequantize_cpu_chunked(&input, quant, &mut output_chunked);
1491        for i in 0..6 {
1492            let expected = (input[i] as f32 - 31.0) * 0.021287762;
1493            assert!(
1494                (output[i] - expected).abs() < 1e-5,
1495                "cpu[{i}]: {} != {expected}",
1496                output[i]
1497            );
1498            assert!(
1499                (output_chunked[i] - expected).abs() < 1e-5,
1500                "chunked[{i}]: {} != {expected}",
1501                output_chunked[i]
1502            );
1503        }
1504    }
1505
1506    #[test]
1507    fn test_decoder_yolo_det() {
1508        let score_threshold = 0.25;
1509        let iou_threshold = 0.7;
1510        let out = load_yolov8s_det();
1511        let quant = (0.0040811873, -123).into();
1512
1513        let decoder = DecoderBuilder::default()
1514            .with_config_yolo_det(
1515                configs::Detection {
1516                    decoder: DecoderType::Ultralytics,
1517                    shape: vec![1, 84, 8400],
1518                    anchors: None,
1519                    quantization: Some(quant),
1520                    dshape: vec![
1521                        (DimName::Batch, 1),
1522                        (DimName::NumFeatures, 84),
1523                        (DimName::NumBoxes, 8400),
1524                    ],
1525                    normalized: Some(true),
1526                },
1527                Some(DecoderVersion::Yolo11),
1528            )
1529            .with_score_threshold(score_threshold)
1530            .with_iou_threshold(iou_threshold)
1531            .build()
1532            .unwrap();
1533
1534        let mut output_boxes: Vec<_> = Vec::with_capacity(50);
1535        decode_yolo_det(
1536            (out.slice(s![0, .., ..]), quant.into()),
1537            score_threshold,
1538            iou_threshold,
1539            Some(configs::Nms::ClassAgnostic),
1540            &mut output_boxes,
1541        );
1542        assert!(output_boxes[0].equal_within_delta(
1543            &DetectBox {
1544                bbox: BoundingBox {
1545                    xmin: 0.5285137,
1546                    ymin: 0.05305544,
1547                    xmax: 0.87541467,
1548                    ymax: 0.9998909,
1549                },
1550                score: 0.5591227,
1551                label: 0
1552            },
1553            1e-6
1554        ));
1555
1556        assert!(output_boxes[1].equal_within_delta(
1557            &DetectBox {
1558                bbox: BoundingBox {
1559                    xmin: 0.130598,
1560                    ymin: 0.43260583,
1561                    xmax: 0.35098213,
1562                    ymax: 0.9958097,
1563                },
1564                score: 0.33057618,
1565                label: 75
1566            },
1567            1e-6
1568        ));
1569
1570        let mut output_boxes1: Vec<_> = Vec::with_capacity(50);
1571        let mut output_masks1: Vec<_> = Vec::with_capacity(50);
1572        decoder
1573            .decode_quantized(&[out.view().into()], &mut output_boxes1, &mut output_masks1)
1574            .unwrap();
1575
1576        let out = dequantize_ndarray(out.view(), quant.into());
1577        let mut output_boxes_f32: Vec<_> = Vec::with_capacity(50);
1578        let mut output_masks_f32: Vec<_> = Vec::with_capacity(50);
1579        decoder
1580            .decode_float::<f32>(
1581                &[out.view().into_dyn()],
1582                &mut output_boxes_f32,
1583                &mut output_masks_f32,
1584            )
1585            .unwrap();
1586
1587        compare_outputs((&output_boxes, &output_boxes1), (&[], &output_masks1));
1588        compare_outputs((&output_boxes, &output_boxes_f32), (&[], &output_masks_f32));
1589    }
1590
1591    #[test]
1592    fn test_decoder_masks() {
1593        let score_threshold = 0.45;
1594        let iou_threshold = 0.45;
1595        let boxes = load_yolov8_boxes();
1596        let quant_boxes = Quantization::new(0.021287761628627777, 31);
1597
1598        let protos = load_yolov8_protos();
1599        let quant_protos = Quantization::new(0.02491161972284317, -117);
1600        let protos = dequantize_ndarray::<_, _, f32>(protos.view(), quant_protos);
1601        let seg = dequantize_ndarray::<_, _, f32>(boxes.view(), quant_boxes);
1602        let mut output_boxes: Vec<_> = Vec::with_capacity(10);
1603        let mut output_masks: Vec<_> = Vec::with_capacity(10);
1604        decode_yolo_segdet_float(
1605            seg.slice(s![0, .., ..]),
1606            protos.slice(s![0, .., .., ..]),
1607            score_threshold,
1608            iou_threshold,
1609            Some(configs::Nms::ClassAgnostic),
1610            &mut output_boxes,
1611            &mut output_masks,
1612        )
1613        .unwrap();
1614        assert_eq!(output_boxes.len(), 2);
1615        assert_eq!(output_boxes.len(), output_masks.len());
1616
1617        for (b, m) in output_boxes.iter().zip(&output_masks) {
1618            // Mask region is the proto-grid-aligned crop (floor for min,
1619            // ceil for max), so it encloses the post-NMS bbox (EDGEAI-1304).
1620            assert!(b.bbox.xmin >= m.xmin);
1621            assert!(b.bbox.ymin >= m.ymin);
1622            assert!(b.bbox.xmax <= m.xmax);
1623            assert!(b.bbox.ymax <= m.ymax);
1624        }
1625        assert!(output_boxes[0].equal_within_delta(
1626            &DetectBox {
1627                bbox: BoundingBox {
1628                    xmin: 0.08515105,
1629                    ymin: 0.7131401,
1630                    xmax: 0.29802868,
1631                    ymax: 0.8195788,
1632                },
1633                score: 0.91537374,
1634                label: 23
1635            },
1636            1.0 / 160.0, // wider range because mask will expand the box
1637        ));
1638
1639        assert!(output_boxes[1].equal_within_delta(
1640            &DetectBox {
1641                bbox: BoundingBox {
1642                    xmin: 0.59605736,
1643                    ymin: 0.25545314,
1644                    xmax: 0.93666154,
1645                    ymax: 0.72378385,
1646                },
1647                score: 0.91537374,
1648                label: 23
1649            },
1650            1.0 / 160.0, // wider range because mask will expand the box
1651        ));
1652
1653        let full_mask = edgefirst_bench::testdata::read("yolov8_mask_results.bin");
1654        let full_mask = ndarray::Array2::from_shape_vec((160, 160), full_mask.to_vec()).unwrap();
1655
1656        let cropped_mask = full_mask.slice(ndarray::s![
1657            (output_masks[1].ymin * 160.0) as usize..(output_masks[1].ymax * 160.0) as usize,
1658            (output_masks[1].xmin * 160.0) as usize..(output_masks[1].xmax * 160.0) as usize,
1659        ]);
1660
1661        assert_eq!(
1662            cropped_mask,
1663            segmentation_to_mask(seg_view(&output_masks[1]).view()).unwrap()
1664        );
1665    }
1666
1667    /// Regression test: config-driven path with physically-NCHW protos.
1668    /// Simulates YOLOv8-seg ONNX outputs where the producer emits protos
1669    /// as `(1, 32, 160, 160)` in CHW memory order — the caller declares
1670    /// shape and dshape matching that physical order and HAL permutes to
1671    /// canonical HWC via `swap_axes_if_needed`.
1672    ///
1673    /// This is the counterpart to the NHWC-producer case (TFLite /
1674    /// Ara-2) where shape+dshape are `(1, 160, 160, 32)` +
1675    /// `[batch, height, width, num_protos]` and no reordering is needed.
1676    #[test]
1677    fn test_decoder_masks_nchw_protos() {
1678        let score_threshold = 0.45;
1679        let iou_threshold = 0.45;
1680
1681        // Load test data — boxes as [116, 8400]
1682        let boxes_2d = load_yolov8_boxes().slice_move(s![0, .., ..]);
1683        let quant_boxes = Quantization::new(0.021287761628627777, 31);
1684
1685        // Load protos as HWC [160, 160, 32] (file layout) then dequantize
1686        let protos_hwc = load_yolov8_protos().slice_move(s![0, .., .., ..]);
1687        let quant_protos = Quantization::new(0.02491161972284317, -117);
1688        let protos_f32_hwc = dequantize_ndarray::<_, _, f32>(protos_hwc.view(), quant_protos);
1689
1690        // ---- Reference: direct call with HWC protos (known working) ----
1691        let seg = dequantize_ndarray::<_, _, f32>(boxes_2d.view(), quant_boxes);
1692        let mut ref_boxes: Vec<_> = Vec::with_capacity(10);
1693        let mut ref_masks: Vec<_> = Vec::with_capacity(10);
1694        decode_yolo_segdet_float(
1695            seg.view(),
1696            protos_f32_hwc.view(),
1697            score_threshold,
1698            iou_threshold,
1699            Some(configs::Nms::ClassAgnostic),
1700            &mut ref_boxes,
1701            &mut ref_masks,
1702        )
1703        .unwrap();
1704        assert_eq!(ref_boxes.len(), 2);
1705
1706        // ---- Config-driven path: NCHW protos declared in physical order ----
1707        // Permute the HWC test data to CHW memory order — this is what an
1708        // ONNX-style producer would emit into the tensor buffer.
1709        // `to_owned` materialises a C-contiguous Array3<f32> with CHW
1710        // strides, matching a producer that writes channels-outer.
1711        let protos_f32_chw_view = protos_f32_hwc.view().permuted_axes([2, 0, 1]); // [32, 160, 160]
1712        let protos_f32_chw = protos_f32_chw_view.to_owned();
1713        let protos_nchw = protos_f32_chw.insert_axis(ndarray::Axis(0)); // [1, 32, 160, 160]
1714
1715        // Build boxes as [1, 116, 8400] f32
1716        let seg_3d = seg.insert_axis(ndarray::Axis(0)); // [1, 116, 8400]
1717
1718        // Declare shape and dshape in physical memory order (outermost
1719        // first). `swap_axes_if_needed` uses dshape role lookup to
1720        // permute the stride tuple into canonical HWC.
1721        let decoder = DecoderBuilder::default()
1722            .with_config_yolo_segdet(
1723                configs::Detection {
1724                    decoder: configs::DecoderType::Ultralytics,
1725                    quantization: None,
1726                    shape: vec![1, 116, 8400],
1727                    dshape: vec![
1728                        (configs::DimName::Batch, 1),
1729                        (configs::DimName::NumFeatures, 116),
1730                        (configs::DimName::NumBoxes, 8400),
1731                    ],
1732                    normalized: Some(true),
1733                    anchors: None,
1734                },
1735                configs::Protos {
1736                    decoder: configs::DecoderType::Ultralytics,
1737                    quantization: None,
1738                    shape: vec![1, 32, 160, 160],
1739                    dshape: vec![
1740                        (configs::DimName::Batch, 1),
1741                        (configs::DimName::NumProtos, 32),
1742                        (configs::DimName::Height, 160),
1743                        (configs::DimName::Width, 160),
1744                    ],
1745                },
1746                None, // decoder version
1747            )
1748            .with_score_threshold(score_threshold)
1749            .with_iou_threshold(iou_threshold)
1750            .build()
1751            .unwrap();
1752
1753        let mut cfg_boxes: Vec<_> = Vec::with_capacity(10);
1754        let mut cfg_masks: Vec<_> = Vec::with_capacity(10);
1755        decoder
1756            .decode_float(
1757                &[seg_3d.view().into_dyn(), protos_nchw.view().into_dyn()],
1758                &mut cfg_boxes,
1759                &mut cfg_masks,
1760            )
1761            .unwrap();
1762
1763        // Must produce the same number of detections
1764        assert_eq!(
1765            cfg_boxes.len(),
1766            ref_boxes.len(),
1767            "config path produced {} boxes, reference produced {}",
1768            cfg_boxes.len(),
1769            ref_boxes.len()
1770        );
1771
1772        // Boxes must match
1773        for (i, (cb, rb)) in cfg_boxes.iter().zip(&ref_boxes).enumerate() {
1774            assert!(
1775                cb.equal_within_delta(rb, 0.01),
1776                "box {i} mismatch: config={cb:?}, reference={rb:?}"
1777            );
1778        }
1779
1780        // Masks must match pixel-for-pixel
1781        for (i, (cm, rm)) in cfg_masks.iter().zip(&ref_masks).enumerate() {
1782            let cm_arr = segmentation_to_mask(seg_view(cm).view()).unwrap();
1783            let rm_arr = segmentation_to_mask(seg_view(rm).view()).unwrap();
1784            assert_eq!(
1785                cm_arr, rm_arr,
1786                "mask {i} pixel mismatch between config-driven and reference paths"
1787            );
1788        }
1789    }
1790
1791    #[test]
1792    fn test_decoder_masks_i8() {
1793        let score_threshold = 0.45;
1794        let iou_threshold = 0.45;
1795        let boxes = load_yolov8_boxes();
1796        let quant_boxes = (0.021287761628627777, 31).into();
1797
1798        let protos = load_yolov8_protos();
1799        let quant_protos = (0.02491161972284317, -117).into();
1800        let mut output_boxes: Vec<_> = Vec::with_capacity(500);
1801        let mut output_masks: Vec<_> = Vec::with_capacity(500);
1802
1803        let decoder = DecoderBuilder::default()
1804            .with_config_yolo_segdet(
1805                configs::Detection {
1806                    decoder: configs::DecoderType::Ultralytics,
1807                    quantization: Some(quant_boxes),
1808                    shape: vec![1, 116, 8400],
1809                    anchors: None,
1810                    dshape: vec![
1811                        (DimName::Batch, 1),
1812                        (DimName::NumFeatures, 116),
1813                        (DimName::NumBoxes, 8400),
1814                    ],
1815                    normalized: Some(true),
1816                },
1817                Protos {
1818                    decoder: configs::DecoderType::Ultralytics,
1819                    quantization: Some(quant_protos),
1820                    shape: vec![1, 160, 160, 32],
1821                    dshape: vec![
1822                        (DimName::Batch, 1),
1823                        (DimName::Height, 160),
1824                        (DimName::Width, 160),
1825                        (DimName::NumProtos, 32),
1826                    ],
1827                },
1828                Some(DecoderVersion::Yolo11),
1829            )
1830            .with_score_threshold(score_threshold)
1831            .with_iou_threshold(iou_threshold)
1832            .build()
1833            .unwrap();
1834
1835        let quant_boxes = quant_boxes.into();
1836        let quant_protos = quant_protos.into();
1837
1838        decode_yolo_segdet_quant(
1839            (boxes.slice(s![0, .., ..]), quant_boxes),
1840            (protos.slice(s![0, .., .., ..]), quant_protos),
1841            score_threshold,
1842            iou_threshold,
1843            Some(configs::Nms::ClassAgnostic),
1844            &mut output_boxes,
1845            &mut output_masks,
1846        )
1847        .unwrap();
1848
1849        let mut output_boxes1: Vec<_> = Vec::with_capacity(500);
1850        let mut output_masks1: Vec<_> = Vec::with_capacity(500);
1851
1852        decoder
1853            .decode_quantized(
1854                &[boxes.view().into(), protos.view().into()],
1855                &mut output_boxes1,
1856                &mut output_masks1,
1857            )
1858            .unwrap();
1859
1860        let protos = dequantize_ndarray::<_, _, f32>(protos.view(), quant_protos);
1861        let seg = dequantize_ndarray::<_, _, f32>(boxes.view(), quant_boxes);
1862
1863        let mut output_boxes_f32: Vec<_> = Vec::with_capacity(500);
1864        let mut output_masks_f32: Vec<_> = Vec::with_capacity(500);
1865        decode_yolo_segdet_float(
1866            seg.slice(s![0, .., ..]),
1867            protos.slice(s![0, .., .., ..]),
1868            score_threshold,
1869            iou_threshold,
1870            Some(configs::Nms::ClassAgnostic),
1871            &mut output_boxes_f32,
1872            &mut output_masks_f32,
1873        )
1874        .unwrap();
1875
1876        let mut output_boxes1_f32: Vec<_> = Vec::with_capacity(500);
1877        let mut output_masks1_f32: Vec<_> = Vec::with_capacity(500);
1878
1879        decoder
1880            .decode_float(
1881                &[seg.view().into_dyn(), protos.view().into_dyn()],
1882                &mut output_boxes1_f32,
1883                &mut output_masks1_f32,
1884            )
1885            .unwrap();
1886
1887        compare_outputs(
1888            (&output_boxes, &output_boxes1),
1889            (&output_masks, &output_masks1),
1890        );
1891
1892        compare_outputs(
1893            (&output_boxes, &output_boxes_f32),
1894            (&output_masks, &output_masks_f32),
1895        );
1896
1897        compare_outputs(
1898            (&output_boxes_f32, &output_boxes1_f32),
1899            (&output_masks_f32, &output_masks1_f32),
1900        );
1901    }
1902
1903    #[test]
1904    fn test_decoder_yolo_split() {
1905        let score_threshold = 0.45;
1906        let iou_threshold = 0.45;
1907        let boxes = load_yolov8_boxes();
1908        let boxes: Vec<_> = boxes.iter().map(|x| *x as i16 * 256).collect();
1909        let boxes = ndarray::Array3::from_shape_vec((1, 116, 8400), boxes).unwrap();
1910
1911        let quant_boxes = Quantization::new(0.021287761628627777 / 256.0, 31 * 256);
1912
1913        let decoder = DecoderBuilder::default()
1914            .with_config_yolo_split_det(
1915                configs::Boxes {
1916                    decoder: configs::DecoderType::Ultralytics,
1917                    quantization: Some(QuantTuple(quant_boxes.scale, quant_boxes.zero_point)),
1918                    shape: vec![1, 4, 8400],
1919                    dshape: vec![
1920                        (DimName::Batch, 1),
1921                        (DimName::BoxCoords, 4),
1922                        (DimName::NumBoxes, 8400),
1923                    ],
1924                    normalized: Some(true),
1925                },
1926                configs::Scores {
1927                    decoder: configs::DecoderType::Ultralytics,
1928                    quantization: Some(QuantTuple(quant_boxes.scale, quant_boxes.zero_point)),
1929                    shape: vec![1, 80, 8400],
1930                    dshape: vec![
1931                        (DimName::Batch, 1),
1932                        (DimName::NumClasses, 80),
1933                        (DimName::NumBoxes, 8400),
1934                    ],
1935                },
1936            )
1937            .with_score_threshold(score_threshold)
1938            .with_iou_threshold(iou_threshold)
1939            .build()
1940            .unwrap();
1941
1942        let mut output_boxes: Vec<_> = Vec::with_capacity(500);
1943        let mut output_masks: Vec<_> = Vec::with_capacity(500);
1944
1945        decoder
1946            .decode_quantized(
1947                &[
1948                    boxes.slice(s![.., ..4, ..]).into(),
1949                    boxes.slice(s![.., 4..84, ..]).into(),
1950                ],
1951                &mut output_boxes,
1952                &mut output_masks,
1953            )
1954            .unwrap();
1955
1956        let seg = dequantize_ndarray::<_, _, f32>(boxes.view(), quant_boxes);
1957        let mut output_boxes_f32: Vec<_> = Vec::with_capacity(500);
1958        decode_yolo_det_float(
1959            seg.slice(s![0, ..84, ..]),
1960            score_threshold,
1961            iou_threshold,
1962            Some(configs::Nms::ClassAgnostic),
1963            &mut output_boxes_f32,
1964        );
1965
1966        let mut output_boxes1: Vec<_> = Vec::with_capacity(500);
1967        let mut output_masks1: Vec<_> = Vec::with_capacity(500);
1968
1969        decoder
1970            .decode_float(
1971                &[
1972                    seg.slice(s![.., ..4, ..]).into_dyn(),
1973                    seg.slice(s![.., 4..84, ..]).into_dyn(),
1974                ],
1975                &mut output_boxes1,
1976                &mut output_masks1,
1977            )
1978            .unwrap();
1979        compare_outputs((&output_boxes, &output_boxes_f32), (&output_masks, &[]));
1980        compare_outputs((&output_boxes_f32, &output_boxes1), (&[], &output_masks1));
1981    }
1982
1983    #[test]
1984    fn test_decoder_masks_config_mixed() {
1985        let score_threshold = 0.45;
1986        let iou_threshold = 0.45;
1987        let boxes_raw = load_yolov8_boxes();
1988        let boxes: Vec<_> = boxes_raw.iter().map(|x| *x as i16 * 256).collect();
1989        let boxes = ndarray::Array3::from_shape_vec((1, 116, 8400), boxes).unwrap();
1990
1991        let quant_boxes = (0.021287761628627777 / 256.0, 31 * 256);
1992
1993        let protos = load_yolov8_protos();
1994        let quant_protos = (0.02491161972284317, -117);
1995
1996        let decoder = build_yolo_split_segdet_decoder(
1997            score_threshold,
1998            iou_threshold,
1999            quant_boxes,
2000            quant_protos,
2001        );
2002        let mut output_boxes: Vec<_> = Vec::with_capacity(500);
2003        let mut output_masks: Vec<_> = Vec::with_capacity(500);
2004
2005        decoder
2006            .decode_quantized(
2007                &[
2008                    boxes.slice(s![.., ..4, ..]).into(),
2009                    boxes.slice(s![.., 4..84, ..]).into(),
2010                    boxes.slice(s![.., 84.., ..]).into(),
2011                    protos.view().into(),
2012                ],
2013                &mut output_boxes,
2014                &mut output_masks,
2015            )
2016            .unwrap();
2017
2018        let protos = dequantize_ndarray::<_, _, f32>(protos.view(), quant_protos.into());
2019        let seg = dequantize_ndarray::<_, _, f32>(boxes.view(), quant_boxes.into());
2020        let mut output_boxes_f32: Vec<_> = Vec::with_capacity(500);
2021        let mut output_masks_f32: Vec<_> = Vec::with_capacity(500);
2022        decode_yolo_segdet_float(
2023            seg.slice(s![0, .., ..]),
2024            protos.slice(s![0, .., .., ..]),
2025            score_threshold,
2026            iou_threshold,
2027            Some(configs::Nms::ClassAgnostic),
2028            &mut output_boxes_f32,
2029            &mut output_masks_f32,
2030        )
2031        .unwrap();
2032
2033        let mut output_boxes1: Vec<_> = Vec::with_capacity(500);
2034        let mut output_masks1: Vec<_> = Vec::with_capacity(500);
2035
2036        decoder
2037            .decode_float(
2038                &[
2039                    seg.slice(s![.., ..4, ..]).into_dyn(),
2040                    seg.slice(s![.., 4..84, ..]).into_dyn(),
2041                    seg.slice(s![.., 84.., ..]).into_dyn(),
2042                    protos.view().into_dyn(),
2043                ],
2044                &mut output_boxes1,
2045                &mut output_masks1,
2046            )
2047            .unwrap();
2048        compare_outputs(
2049            (&output_boxes, &output_boxes_f32),
2050            (&output_masks, &output_masks_f32),
2051        );
2052        compare_outputs(
2053            (&output_boxes_f32, &output_boxes1),
2054            (&output_masks_f32, &output_masks1),
2055        );
2056    }
2057
2058    fn build_yolo_split_segdet_decoder(
2059        score_threshold: f32,
2060        iou_threshold: f32,
2061        quant_boxes: (f32, i32),
2062        quant_protos: (f32, i32),
2063    ) -> crate::Decoder {
2064        DecoderBuilder::default()
2065            .with_config_yolo_split_segdet(
2066                configs::Boxes {
2067                    decoder: configs::DecoderType::Ultralytics,
2068                    quantization: Some(quant_boxes.into()),
2069                    shape: vec![1, 4, 8400],
2070                    dshape: vec![
2071                        (DimName::Batch, 1),
2072                        (DimName::BoxCoords, 4),
2073                        (DimName::NumBoxes, 8400),
2074                    ],
2075                    normalized: Some(true),
2076                },
2077                configs::Scores {
2078                    decoder: configs::DecoderType::Ultralytics,
2079                    quantization: Some(quant_boxes.into()),
2080                    shape: vec![1, 80, 8400],
2081                    dshape: vec![
2082                        (DimName::Batch, 1),
2083                        (DimName::NumClasses, 80),
2084                        (DimName::NumBoxes, 8400),
2085                    ],
2086                },
2087                configs::MaskCoefficients {
2088                    decoder: configs::DecoderType::Ultralytics,
2089                    quantization: Some(quant_boxes.into()),
2090                    shape: vec![1, 32, 8400],
2091                    dshape: vec![
2092                        (DimName::Batch, 1),
2093                        (DimName::NumProtos, 32),
2094                        (DimName::NumBoxes, 8400),
2095                    ],
2096                },
2097                configs::Protos {
2098                    decoder: configs::DecoderType::Ultralytics,
2099                    quantization: Some(quant_protos.into()),
2100                    shape: vec![1, 160, 160, 32],
2101                    dshape: vec![
2102                        (DimName::Batch, 1),
2103                        (DimName::Height, 160),
2104                        (DimName::Width, 160),
2105                        (DimName::NumProtos, 32),
2106                    ],
2107                },
2108            )
2109            .with_score_threshold(score_threshold)
2110            .with_iou_threshold(iou_threshold)
2111            .build()
2112            .unwrap()
2113    }
2114
2115    fn build_yolov8_seg_decoder(score_threshold: f32, iou_threshold: f32) -> crate::Decoder {
2116        let config_yaml = edgefirst_bench::testdata::read_to_string("yolov8_seg.yaml");
2117        DecoderBuilder::default()
2118            .with_config_yaml_str(config_yaml.to_string())
2119            .with_score_threshold(score_threshold)
2120            .with_iou_threshold(iou_threshold)
2121            .build()
2122            .unwrap()
2123    }
2124    #[test]
2125    fn test_decoder_masks_config_i32() {
2126        let score_threshold = 0.45;
2127        let iou_threshold = 0.45;
2128        let boxes_raw = load_yolov8_boxes();
2129        let scale = 1 << 23;
2130        let boxes: Vec<_> = boxes_raw.iter().map(|x| *x as i32 * scale).collect();
2131        let boxes = ndarray::Array3::from_shape_vec((1, 116, 8400), boxes).unwrap();
2132
2133        let quant_boxes = (0.021287761628627777 / scale as f32, 31 * scale);
2134
2135        let protos_raw = load_yolov8_protos();
2136        let protos: Vec<_> = protos_raw.iter().map(|x| *x as i32 * scale).collect();
2137        let protos = ndarray::Array4::from_shape_vec((1, 160, 160, 32), protos).unwrap();
2138        let quant_protos = (0.02491161972284317 / scale as f32, -117 * scale);
2139
2140        let decoder = build_yolo_split_segdet_decoder(
2141            score_threshold,
2142            iou_threshold,
2143            quant_boxes,
2144            quant_protos,
2145        );
2146
2147        let mut output_boxes: Vec<_> = Vec::with_capacity(500);
2148        let mut output_masks: Vec<_> = Vec::with_capacity(500);
2149
2150        decoder
2151            .decode_quantized(
2152                &[
2153                    boxes.slice(s![.., ..4, ..]).into(),
2154                    boxes.slice(s![.., 4..84, ..]).into(),
2155                    boxes.slice(s![.., 84.., ..]).into(),
2156                    protos.view().into(),
2157                ],
2158                &mut output_boxes,
2159                &mut output_masks,
2160            )
2161            .unwrap();
2162
2163        let protos = dequantize_ndarray::<_, _, f32>(protos.view(), quant_protos.into());
2164        let seg = dequantize_ndarray::<_, _, f32>(boxes.view(), quant_boxes.into());
2165        let mut output_boxes_f32: Vec<_> = Vec::with_capacity(500);
2166        let mut output_masks_f32: Vec<Segmentation> = Vec::with_capacity(500);
2167        decode_yolo_segdet_float(
2168            seg.slice(s![0, .., ..]),
2169            protos.slice(s![0, .., .., ..]),
2170            score_threshold,
2171            iou_threshold,
2172            Some(configs::Nms::ClassAgnostic),
2173            &mut output_boxes_f32,
2174            &mut output_masks_f32,
2175        )
2176        .unwrap();
2177
2178        assert_eq!(output_boxes.len(), output_boxes_f32.len());
2179        assert_eq!(output_masks.len(), output_masks_f32.len());
2180
2181        compare_outputs(
2182            (&output_boxes, &output_boxes_f32),
2183            (&output_masks, &output_masks_f32),
2184        );
2185    }
2186
2187    /// test running multiple decoders concurrently
2188    #[test]
2189    fn test_context_switch() {
2190        let yolo_det = || {
2191            let score_threshold = 0.25;
2192            let iou_threshold = 0.7;
2193            let out = load_yolov8s_det();
2194            let quant = (0.0040811873, -123).into();
2195
2196            let decoder = DecoderBuilder::default()
2197                .with_config_yolo_det(
2198                    configs::Detection {
2199                        decoder: DecoderType::Ultralytics,
2200                        shape: vec![1, 84, 8400],
2201                        anchors: None,
2202                        quantization: Some(quant),
2203                        dshape: vec![
2204                            (DimName::Batch, 1),
2205                            (DimName::NumFeatures, 84),
2206                            (DimName::NumBoxes, 8400),
2207                        ],
2208                        normalized: None,
2209                    },
2210                    None,
2211                )
2212                .with_score_threshold(score_threshold)
2213                .with_iou_threshold(iou_threshold)
2214                .build()
2215                .unwrap();
2216
2217            let mut output_boxes: Vec<_> = Vec::with_capacity(50);
2218            let mut output_masks: Vec<_> = Vec::with_capacity(50);
2219
2220            for _ in 0..100 {
2221                decoder
2222                    .decode_quantized(&[out.view().into()], &mut output_boxes, &mut output_masks)
2223                    .unwrap();
2224
2225                assert!(output_boxes[0].equal_within_delta(
2226                    &DetectBox {
2227                        bbox: BoundingBox {
2228                            xmin: 0.5285137,
2229                            ymin: 0.05305544,
2230                            xmax: 0.87541467,
2231                            ymax: 0.9998909,
2232                        },
2233                        score: 0.5591227,
2234                        label: 0
2235                    },
2236                    1e-6
2237                ));
2238
2239                assert!(output_boxes[1].equal_within_delta(
2240                    &DetectBox {
2241                        bbox: BoundingBox {
2242                            xmin: 0.130598,
2243                            ymin: 0.43260583,
2244                            xmax: 0.35098213,
2245                            ymax: 0.9958097,
2246                        },
2247                        score: 0.33057618,
2248                        label: 75
2249                    },
2250                    1e-6
2251                ));
2252                assert!(output_masks.is_empty());
2253            }
2254        };
2255
2256        let modelpack_det_split = || {
2257            let score_threshold = 0.8;
2258            let iou_threshold = 0.5;
2259
2260            let seg = edgefirst_bench::testdata::read("modelpack_seg_2x160x160.bin");
2261            let seg = ndarray::Array4::from_shape_vec((1, 2, 160, 160), seg.to_vec()).unwrap();
2262
2263            let detect0 = edgefirst_bench::testdata::read("modelpack_split_9x15x18.bin");
2264            let detect0 =
2265                ndarray::Array4::from_shape_vec((1, 9, 15, 18), detect0.to_vec()).unwrap();
2266
2267            let detect1 = edgefirst_bench::testdata::read("modelpack_split_17x30x18.bin");
2268            let detect1 =
2269                ndarray::Array4::from_shape_vec((1, 17, 30, 18), detect1.to_vec()).unwrap();
2270
2271            let mut mask = seg.slice(s![0, .., .., ..]);
2272            mask.swap_axes(0, 1);
2273            mask.swap_axes(1, 2);
2274            let mask = [Segmentation {
2275                xmin: 0.0,
2276                ymin: 0.0,
2277                xmax: 1.0,
2278                ymax: 1.0,
2279                segmentation: crate::mask_to_tensor(mask.view()).unwrap(),
2280            }];
2281            let correct_boxes = [DetectBox {
2282                bbox: BoundingBox {
2283                    xmin: 0.43171933,
2284                    ymin: 0.68243736,
2285                    xmax: 0.5626645,
2286                    ymax: 0.808863,
2287                },
2288                score: 0.99240804,
2289                label: 0,
2290            }];
2291
2292            let quant0 = (0.08547406643629074, 174).into();
2293            let quant1 = (0.09929127991199493, 183).into();
2294            let quant_seg = (1.0 / 255.0, 0).into();
2295
2296            let anchors0 = vec![
2297                [0.36666667461395264, 0.31481480598449707],
2298                [0.38749998807907104, 0.4740740656852722],
2299                [0.5333333611488342, 0.644444465637207],
2300            ];
2301            let anchors1 = vec![
2302                [0.13750000298023224, 0.2074074000120163],
2303                [0.2541666626930237, 0.21481481194496155],
2304                [0.23125000298023224, 0.35185185074806213],
2305            ];
2306
2307            let decoder = DecoderBuilder::default()
2308                .with_config_modelpack_segdet_split(
2309                    vec![
2310                        configs::Detection {
2311                            decoder: DecoderType::ModelPack,
2312                            shape: vec![1, 17, 30, 18],
2313                            anchors: Some(anchors1),
2314                            quantization: Some(quant1),
2315                            dshape: vec![
2316                                (DimName::Batch, 1),
2317                                (DimName::Height, 17),
2318                                (DimName::Width, 30),
2319                                (DimName::NumAnchorsXFeatures, 18),
2320                            ],
2321                            normalized: None,
2322                        },
2323                        configs::Detection {
2324                            decoder: DecoderType::ModelPack,
2325                            shape: vec![1, 9, 15, 18],
2326                            anchors: Some(anchors0),
2327                            quantization: Some(quant0),
2328                            dshape: vec![
2329                                (DimName::Batch, 1),
2330                                (DimName::Height, 9),
2331                                (DimName::Width, 15),
2332                                (DimName::NumAnchorsXFeatures, 18),
2333                            ],
2334                            normalized: None,
2335                        },
2336                    ],
2337                    configs::Segmentation {
2338                        decoder: DecoderType::ModelPack,
2339                        quantization: Some(quant_seg),
2340                        shape: vec![1, 2, 160, 160],
2341                        dshape: vec![
2342                            (DimName::Batch, 1),
2343                            (DimName::NumClasses, 2),
2344                            (DimName::Height, 160),
2345                            (DimName::Width, 160),
2346                        ],
2347                    },
2348                )
2349                .with_score_threshold(score_threshold)
2350                .with_iou_threshold(iou_threshold)
2351                .build()
2352                .unwrap();
2353            let mut output_boxes: Vec<_> = Vec::with_capacity(10);
2354            let mut output_masks: Vec<_> = Vec::with_capacity(10);
2355
2356            for _ in 0..100 {
2357                decoder
2358                    .decode_quantized(
2359                        &[
2360                            detect0.view().into(),
2361                            detect1.view().into(),
2362                            seg.view().into(),
2363                        ],
2364                        &mut output_boxes,
2365                        &mut output_masks,
2366                    )
2367                    .unwrap();
2368
2369                compare_outputs((&correct_boxes, &output_boxes), (&mask, &output_masks));
2370            }
2371        };
2372
2373        let handles = vec![
2374            std::thread::spawn(yolo_det),
2375            std::thread::spawn(modelpack_det_split),
2376            std::thread::spawn(yolo_det),
2377            std::thread::spawn(modelpack_det_split),
2378            std::thread::spawn(yolo_det),
2379            std::thread::spawn(modelpack_det_split),
2380            std::thread::spawn(yolo_det),
2381            std::thread::spawn(modelpack_det_split),
2382        ];
2383        for handle in handles {
2384            handle.join().unwrap();
2385        }
2386    }
2387
2388    #[test]
2389    fn test_ndarray_to_xyxy_float() {
2390        let arr = array![10.0_f32, 20.0, 20.0, 20.0];
2391        let xyxy: [f32; 4] = XYWH::ndarray_to_xyxy_float(arr.view());
2392        assert_eq!(xyxy, [0.0_f32, 10.0, 20.0, 30.0]);
2393
2394        let arr = array![10.0_f32, 20.0, 20.0, 20.0];
2395        let xyxy: [f32; 4] = XYXY::ndarray_to_xyxy_float(arr.view());
2396        assert_eq!(xyxy, [10.0_f32, 20.0, 20.0, 20.0]);
2397    }
2398
2399    #[test]
2400    fn test_class_aware_nms_float() {
2401        use crate::float::nms_class_aware_float;
2402
2403        // Create two overlapping boxes with different classes
2404        let boxes = vec![
2405            DetectBox {
2406                bbox: BoundingBox {
2407                    xmin: 0.0,
2408                    ymin: 0.0,
2409                    xmax: 0.5,
2410                    ymax: 0.5,
2411                },
2412                score: 0.9,
2413                label: 0, // class 0
2414            },
2415            DetectBox {
2416                bbox: BoundingBox {
2417                    xmin: 0.1,
2418                    ymin: 0.1,
2419                    xmax: 0.6,
2420                    ymax: 0.6,
2421                },
2422                score: 0.8,
2423                label: 1, // class 1 - different class
2424            },
2425        ];
2426
2427        // Class-aware NMS should keep both boxes (different classes, IoU ~0.47 >
2428        // threshold 0.3)
2429        let result = nms_class_aware_float(0.3, None, boxes.clone());
2430        assert_eq!(
2431            result.len(),
2432            2,
2433            "Class-aware NMS should keep both boxes with different classes"
2434        );
2435
2436        // Now test with same class - should suppress one
2437        let same_class_boxes = vec![
2438            DetectBox {
2439                bbox: BoundingBox {
2440                    xmin: 0.0,
2441                    ymin: 0.0,
2442                    xmax: 0.5,
2443                    ymax: 0.5,
2444                },
2445                score: 0.9,
2446                label: 0,
2447            },
2448            DetectBox {
2449                bbox: BoundingBox {
2450                    xmin: 0.1,
2451                    ymin: 0.1,
2452                    xmax: 0.6,
2453                    ymax: 0.6,
2454                },
2455                score: 0.8,
2456                label: 0, // same class
2457            },
2458        ];
2459
2460        let result = nms_class_aware_float(0.3, None, same_class_boxes);
2461        assert_eq!(
2462            result.len(),
2463            1,
2464            "Class-aware NMS should suppress overlapping box with same class"
2465        );
2466        assert_eq!(result[0].label, 0);
2467        assert!((result[0].score - 0.9).abs() < 1e-6);
2468    }
2469
2470    #[test]
2471    fn test_class_agnostic_vs_aware_nms() {
2472        use crate::float::{nms_class_aware_float, nms_float};
2473
2474        // Two overlapping boxes with different classes
2475        let boxes = vec![
2476            DetectBox {
2477                bbox: BoundingBox {
2478                    xmin: 0.0,
2479                    ymin: 0.0,
2480                    xmax: 0.5,
2481                    ymax: 0.5,
2482                },
2483                score: 0.9,
2484                label: 0,
2485            },
2486            DetectBox {
2487                bbox: BoundingBox {
2488                    xmin: 0.1,
2489                    ymin: 0.1,
2490                    xmax: 0.6,
2491                    ymax: 0.6,
2492                },
2493                score: 0.8,
2494                label: 1,
2495            },
2496        ];
2497
2498        // Class-agnostic should suppress one (IoU ~0.47 > threshold 0.3)
2499        let agnostic_result = nms_float(0.3, None, boxes.clone());
2500        assert_eq!(
2501            agnostic_result.len(),
2502            1,
2503            "Class-agnostic NMS should suppress overlapping boxes"
2504        );
2505
2506        // Class-aware should keep both (different classes)
2507        let aware_result = nms_class_aware_float(0.3, None, boxes);
2508        assert_eq!(
2509            aware_result.len(),
2510            2,
2511            "Class-aware NMS should keep boxes with different classes"
2512        );
2513    }
2514
2515    #[test]
2516    fn test_class_aware_nms_int() {
2517        use crate::byte::nms_class_aware_int;
2518
2519        // Create two overlapping boxes with different classes
2520        let boxes = vec![
2521            DetectBoxQuantized {
2522                bbox: BoundingBox {
2523                    xmin: 0.0,
2524                    ymin: 0.0,
2525                    xmax: 0.5,
2526                    ymax: 0.5,
2527                },
2528                score: 200_u8,
2529                label: 0,
2530            },
2531            DetectBoxQuantized {
2532                bbox: BoundingBox {
2533                    xmin: 0.1,
2534                    ymin: 0.1,
2535                    xmax: 0.6,
2536                    ymax: 0.6,
2537                },
2538                score: 180_u8,
2539                label: 1, // different class
2540            },
2541        ];
2542
2543        // Should keep both (different classes)
2544        let result = nms_class_aware_int(0.5, None, boxes);
2545        assert_eq!(
2546            result.len(),
2547            2,
2548            "Class-aware NMS (int) should keep boxes with different classes"
2549        );
2550    }
2551
2552    #[test]
2553    fn test_nms_enum_default() {
2554        // Test that Nms enum has the correct default
2555        let default_nms: configs::Nms = Default::default();
2556        assert_eq!(default_nms, configs::Nms::ClassAgnostic);
2557    }
2558
2559    #[test]
2560    fn test_decoder_nms_mode() {
2561        // Test that decoder properly stores NMS mode
2562        let decoder = DecoderBuilder::default()
2563            .with_config_yolo_det(
2564                configs::Detection {
2565                    anchors: None,
2566                    decoder: DecoderType::Ultralytics,
2567                    quantization: None,
2568                    shape: vec![1, 84, 8400],
2569                    dshape: Vec::new(),
2570                    normalized: Some(true),
2571                },
2572                None,
2573            )
2574            .with_nms(Some(configs::Nms::ClassAware))
2575            .build()
2576            .unwrap();
2577
2578        assert_eq!(decoder.nms, Some(configs::Nms::ClassAware));
2579    }
2580
2581    #[test]
2582    fn test_decoder_nms_bypass() {
2583        // Test that decoder can be configured with nms=None (bypass)
2584        let decoder = DecoderBuilder::default()
2585            .with_config_yolo_det(
2586                configs::Detection {
2587                    anchors: None,
2588                    decoder: DecoderType::Ultralytics,
2589                    quantization: None,
2590                    shape: vec![1, 84, 8400],
2591                    dshape: Vec::new(),
2592                    normalized: Some(true),
2593                },
2594                None,
2595            )
2596            .with_nms(None)
2597            .build()
2598            .unwrap();
2599
2600        assert_eq!(decoder.nms, None);
2601    }
2602
2603    #[test]
2604    fn test_decoder_normalized_boxes_true() {
2605        // Test that normalized_boxes returns Some(true) when explicitly set
2606        let decoder = DecoderBuilder::default()
2607            .with_config_yolo_det(
2608                configs::Detection {
2609                    anchors: None,
2610                    decoder: DecoderType::Ultralytics,
2611                    quantization: None,
2612                    shape: vec![1, 84, 8400],
2613                    dshape: Vec::new(),
2614                    normalized: Some(true),
2615                },
2616                None,
2617            )
2618            .build()
2619            .unwrap();
2620
2621        assert_eq!(decoder.normalized_boxes(), Some(true));
2622    }
2623
2624    #[test]
2625    fn test_decoder_normalized_boxes_false() {
2626        // Test that normalized_boxes returns Some(false) when config specifies
2627        // unnormalized
2628        let decoder = DecoderBuilder::default()
2629            .with_config_yolo_det(
2630                configs::Detection {
2631                    anchors: None,
2632                    decoder: DecoderType::Ultralytics,
2633                    quantization: None,
2634                    shape: vec![1, 84, 8400],
2635                    dshape: Vec::new(),
2636                    normalized: Some(false),
2637                },
2638                None,
2639            )
2640            .build()
2641            .unwrap();
2642
2643        assert_eq!(decoder.normalized_boxes(), Some(false));
2644    }
2645
2646    #[test]
2647    fn test_decoder_normalized_boxes_unknown() {
2648        // Test that normalized_boxes returns None when not specified in config
2649        let decoder = DecoderBuilder::default()
2650            .with_config_yolo_det(
2651                configs::Detection {
2652                    anchors: None,
2653                    decoder: DecoderType::Ultralytics,
2654                    quantization: None,
2655                    shape: vec![1, 84, 8400],
2656                    dshape: Vec::new(),
2657                    normalized: None,
2658                },
2659                Some(DecoderVersion::Yolo11),
2660            )
2661            .build()
2662            .unwrap();
2663
2664        assert_eq!(decoder.normalized_boxes(), None);
2665    }
2666
2667    pub fn quantize_ndarray<T: PrimInt + 'static, D: Dimension, F: Float + AsPrimitive<T>>(
2668        input: ArrayView<F, D>,
2669        quant: Quantization,
2670    ) -> Array<T, D>
2671    where
2672        i32: num_traits::AsPrimitive<F>,
2673        f32: num_traits::AsPrimitive<F>,
2674    {
2675        let zero_point = quant.zero_point.as_();
2676        let div_scale = F::one() / quant.scale.as_();
2677        if zero_point != F::zero() {
2678            input.mapv(|d| (d * div_scale + zero_point).round().as_())
2679        } else {
2680            input.mapv(|d| (d * div_scale).round().as_())
2681        }
2682    }
2683
2684    fn real_data_expected_boxes() -> [DetectBox; 2] {
2685        [
2686            DetectBox {
2687                bbox: BoundingBox {
2688                    xmin: 0.08515105,
2689                    ymin: 0.7131401,
2690                    xmax: 0.29802868,
2691                    ymax: 0.8195788,
2692                },
2693                score: 0.91537374,
2694                label: 23,
2695            },
2696            DetectBox {
2697                bbox: BoundingBox {
2698                    xmin: 0.59605736,
2699                    ymin: 0.25545314,
2700                    xmax: 0.93666154,
2701                    ymax: 0.72378385,
2702                },
2703                score: 0.91537374,
2704                label: 23,
2705            },
2706        ]
2707    }
2708
2709    fn e2e_expected_boxes_quant() -> [DetectBox; 1] {
2710        [DetectBox {
2711            bbox: BoundingBox {
2712                xmin: 0.12549022,
2713                ymin: 0.12549022,
2714                xmax: 0.23529413,
2715                ymax: 0.23529413,
2716            },
2717            score: 0.98823535,
2718            label: 2,
2719        }]
2720    }
2721
2722    fn e2e_expected_boxes_float() -> [DetectBox; 1] {
2723        [DetectBox {
2724            bbox: BoundingBox {
2725                xmin: 0.1234,
2726                ymin: 0.1234,
2727                xmax: 0.2345,
2728                ymax: 0.2345,
2729            },
2730            score: 0.9876,
2731            label: 2,
2732        }]
2733    }
2734
2735    macro_rules! real_data_proto_test {
2736        ($name:ident, quantized, $layout:ident) => {
2737            #[test]
2738            fn $name() {
2739                let is_split = matches!(stringify!($layout), "split");
2740
2741                let score_threshold = 0.45;
2742                let iou_threshold = 0.45;
2743                let quant_boxes = (0.021287762_f32, 31_i32);
2744                let quant_protos = (0.02491162_f32, -117_i32);
2745
2746                let raw_boxes = edgefirst_bench::testdata::read("yolov8_boxes_116x8400.bin");
2747                let raw_boxes = unsafe {
2748                    std::slice::from_raw_parts(raw_boxes.as_ptr() as *const i8, raw_boxes.len())
2749                };
2750                let boxes_i8 =
2751                    ndarray::Array3::from_shape_vec((1, 116, 8400), raw_boxes.to_vec()).unwrap();
2752
2753                let raw_protos = edgefirst_bench::testdata::read("yolov8_protos_160x160x32.bin");
2754                let raw_protos = unsafe {
2755                    std::slice::from_raw_parts(raw_protos.as_ptr() as *const i8, raw_protos.len())
2756                };
2757                let protos_i8 =
2758                    ndarray::Array4::from_shape_vec((1, 160, 160, 32), raw_protos.to_vec())
2759                        .unwrap();
2760
2761                // Pre-split (unused for combined, but harmless)
2762                let mask_split = boxes_i8.slice(s![.., 84.., ..]).to_owned();
2763                let scores_split = boxes_i8.slice(s![.., 4..84, ..]).to_owned();
2764                let boxes_split = boxes_i8.slice(s![.., ..4, ..]).to_owned();
2765                let boxes_combined = boxes_i8;
2766
2767                let decoder = if is_split {
2768                    build_yolo_split_segdet_decoder(
2769                        score_threshold,
2770                        iou_threshold,
2771                        quant_boxes,
2772                        quant_protos,
2773                    )
2774                } else {
2775                    build_yolov8_seg_decoder(score_threshold, iou_threshold)
2776                };
2777
2778                let expected = real_data_expected_boxes();
2779                let mut output_boxes = Vec::with_capacity(50);
2780
2781                let inputs: Vec<crate::decoder::ArrayViewDQuantized<'_>> = if is_split {
2782                    vec![
2783                        boxes_split.view().into(),
2784                        scores_split.view().into(),
2785                        mask_split.view().into(),
2786                        protos_i8.view().into(),
2787                    ]
2788                } else {
2789                    vec![boxes_combined.view().into(), protos_i8.view().into()]
2790                };
2791                decoder
2792                    .decode_quantized_proto(&inputs, &mut output_boxes)
2793                    .unwrap();
2794
2795                assert_eq!(output_boxes.len(), 2);
2796                assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
2797                assert!(output_boxes[1].equal_within_delta(&expected[1], 1.0 / 160.0));
2798            }
2799        };
2800        ($name:ident, float, $layout:ident) => {
2801            #[test]
2802            fn $name() {
2803                let is_split = matches!(stringify!($layout), "split");
2804
2805                let score_threshold = 0.45;
2806                let iou_threshold = 0.45;
2807                let quant_boxes = (0.021287762_f32, 31_i32);
2808                let quant_protos = (0.02491162_f32, -117_i32);
2809
2810                let raw_boxes = edgefirst_bench::testdata::read("yolov8_boxes_116x8400.bin");
2811                let raw_boxes = unsafe {
2812                    std::slice::from_raw_parts(raw_boxes.as_ptr() as *const i8, raw_boxes.len())
2813                };
2814                let boxes_i8 =
2815                    ndarray::Array3::from_shape_vec((1, 116, 8400), raw_boxes.to_vec()).unwrap();
2816                let boxes_f32: Array3<f32> =
2817                    dequantize_ndarray(boxes_i8.view(), quant_boxes.into());
2818
2819                let raw_protos = edgefirst_bench::testdata::read("yolov8_protos_160x160x32.bin");
2820                let raw_protos = unsafe {
2821                    std::slice::from_raw_parts(raw_protos.as_ptr() as *const i8, raw_protos.len())
2822                };
2823                let protos_i8 =
2824                    ndarray::Array4::from_shape_vec((1, 160, 160, 32), raw_protos.to_vec())
2825                        .unwrap();
2826                let protos_f32: Array4<f32> =
2827                    dequantize_ndarray(protos_i8.view(), quant_protos.into());
2828
2829                // Pre-split from dequantized data
2830                let mask_split = boxes_f32.slice(s![.., 84.., ..]).to_owned();
2831                let scores_split = boxes_f32.slice(s![.., 4..84, ..]).to_owned();
2832                let boxes_split = boxes_f32.slice(s![.., ..4, ..]).to_owned();
2833                let boxes_combined = boxes_f32;
2834
2835                let decoder = if is_split {
2836                    build_yolo_split_segdet_decoder(
2837                        score_threshold,
2838                        iou_threshold,
2839                        quant_boxes,
2840                        quant_protos,
2841                    )
2842                } else {
2843                    build_yolov8_seg_decoder(score_threshold, iou_threshold)
2844                };
2845
2846                let expected = real_data_expected_boxes();
2847                let mut output_boxes = Vec::with_capacity(50);
2848
2849                let inputs = if is_split {
2850                    vec![
2851                        boxes_split.view().into_dyn(),
2852                        scores_split.view().into_dyn(),
2853                        mask_split.view().into_dyn(),
2854                        protos_f32.view().into_dyn(),
2855                    ]
2856                } else {
2857                    vec![
2858                        boxes_combined.view().into_dyn(),
2859                        protos_f32.view().into_dyn(),
2860                    ]
2861                };
2862                decoder
2863                    .decode_float_proto(&inputs, &mut output_boxes)
2864                    .unwrap();
2865
2866                assert_eq!(output_boxes.len(), 2);
2867                assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
2868                assert!(output_boxes[1].equal_within_delta(&expected[1], 1.0 / 160.0));
2869            }
2870        };
2871    }
2872
2873    real_data_proto_test!(test_decoder_segdet_proto, quantized, combined);
2874    real_data_proto_test!(test_decoder_segdet_proto_float, float, combined);
2875    real_data_proto_test!(test_decoder_segdet_split_proto, quantized, split);
2876    real_data_proto_test!(test_decoder_segdet_split_proto_float, float, split);
2877
2878    const E2E_COMBINED_DET_CONFIG: &str = "
2879decoder_version: yolo26
2880outputs:
2881 - type: detection
2882   decoder: ultralytics
2883   quantization: [0.00784313725490196, 0]
2884   shape: [1, 10, 6]
2885   dshape:
2886    - [batch, 1]
2887    - [num_boxes, 10]
2888    - [num_features, 6]
2889   normalized: true
2890";
2891
2892    const E2E_COMBINED_SEGDET_CONFIG: &str = "
2893decoder_version: yolo26
2894outputs:
2895 - type: detection
2896   decoder: ultralytics
2897   quantization: [0.00784313725490196, 0]
2898   shape: [1, 10, 38]
2899   dshape:
2900    - [batch, 1]
2901    - [num_boxes, 10]
2902    - [num_features, 38]
2903   normalized: true
2904 - type: protos
2905   decoder: ultralytics
2906   quantization: [0.0039215686274509803921568627451, 128]
2907   shape: [1, 160, 160, 32]
2908   dshape:
2909    - [batch, 1]
2910    - [height, 160]
2911    - [width, 160]
2912    - [num_protos, 32]
2913";
2914
2915    const E2E_SPLIT_DET_CONFIG: &str = "
2916decoder_version: yolo26
2917outputs:
2918 - type: boxes
2919   decoder: ultralytics
2920   quantization: [0.00784313725490196, 0]
2921   shape: [1, 10, 4]
2922   dshape:
2923    - [batch, 1]
2924    - [num_boxes, 10]
2925    - [box_coords, 4]
2926   normalized: true
2927 - type: scores
2928   decoder: ultralytics
2929   quantization: [0.00784313725490196, 0]
2930   shape: [1, 10, 1]
2931   dshape:
2932    - [batch, 1]
2933    - [num_boxes, 10]
2934    - [num_classes, 1]
2935 - type: classes
2936   decoder: ultralytics
2937   quantization: [0.00784313725490196, 0]
2938   shape: [1, 10, 1]
2939   dshape:
2940    - [batch, 1]
2941    - [num_boxes, 10]
2942    - [num_classes, 1]
2943";
2944
2945    const E2E_SPLIT_SEGDET_CONFIG: &str = "
2946decoder_version: yolo26
2947outputs:
2948 - type: boxes
2949   decoder: ultralytics
2950   quantization: [0.00784313725490196, 0]
2951   shape: [1, 10, 4]
2952   dshape:
2953    - [batch, 1]
2954    - [num_boxes, 10]
2955    - [box_coords, 4]
2956   normalized: true
2957 - type: scores
2958   decoder: ultralytics
2959   quantization: [0.00784313725490196, 0]
2960   shape: [1, 10, 1]
2961   dshape:
2962    - [batch, 1]
2963    - [num_boxes, 10]
2964    - [num_classes, 1]
2965 - type: classes
2966   decoder: ultralytics
2967   quantization: [0.00784313725490196, 0]
2968   shape: [1, 10, 1]
2969   dshape:
2970    - [batch, 1]
2971    - [num_boxes, 10]
2972    - [num_classes, 1]
2973 - type: mask_coefficients
2974   decoder: ultralytics
2975   quantization: [0.00784313725490196, 0]
2976   shape: [1, 10, 32]
2977   dshape:
2978    - [batch, 1]
2979    - [num_boxes, 10]
2980    - [num_protos, 32]
2981 - type: protos
2982   decoder: ultralytics
2983   quantization: [0.0039215686274509803921568627451, 128]
2984   shape: [1, 160, 160, 32]
2985   dshape:
2986    - [batch, 1]
2987    - [height, 160]
2988    - [width, 160]
2989    - [num_protos, 32]
2990";
2991
2992    macro_rules! e2e_segdet_test {
2993        ($name:ident, quantized, $layout:ident, $output:ident) => {
2994            #[test]
2995            fn $name() {
2996                let is_split = matches!(stringify!($layout), "split");
2997                let is_proto = matches!(stringify!($output), "proto");
2998
2999                let score_threshold = 0.45;
3000                let iou_threshold = 0.45;
3001
3002                let mut boxes = Array2::zeros((10, 4));
3003                let mut scores = Array2::zeros((10, 1));
3004                let mut classes = Array2::zeros((10, 1));
3005                let mask = Array2::zeros((10, 32));
3006                let protos = Array3::<f64>::zeros((160, 160, 32));
3007                let protos = protos.insert_axis(Axis(0));
3008                let protos_quant = (1.0 / 255.0, 0.0);
3009                let protos: Array4<u8> = quantize_ndarray(protos.view(), protos_quant.into());
3010
3011                boxes
3012                    .slice_mut(s![0, ..])
3013                    .assign(&array![0.1234, 0.1234, 0.2345, 0.2345]);
3014                scores.slice_mut(s![0, ..]).assign(&array![0.9876]);
3015                classes.slice_mut(s![0, ..]).assign(&array![2.0]);
3016
3017                let detect_quant = (2.0 / 255.0, 0.0);
3018
3019                let decoder = if is_split {
3020                    DecoderBuilder::default()
3021                        .with_config_yaml_str(E2E_SPLIT_SEGDET_CONFIG.to_string())
3022                        .with_score_threshold(score_threshold)
3023                        .with_iou_threshold(iou_threshold)
3024                        .build()
3025                        .unwrap()
3026                } else {
3027                    DecoderBuilder::default()
3028                        .with_config_yaml_str(E2E_COMBINED_SEGDET_CONFIG.to_string())
3029                        .with_score_threshold(score_threshold)
3030                        .with_iou_threshold(iou_threshold)
3031                        .build()
3032                        .unwrap()
3033                };
3034
3035                let expected = e2e_expected_boxes_quant();
3036                let mut output_boxes = Vec::with_capacity(50);
3037
3038                if is_split {
3039                    let boxes = boxes.insert_axis(Axis(0));
3040                    let scores = scores.insert_axis(Axis(0));
3041                    let classes = classes.insert_axis(Axis(0));
3042                    let mask = mask.insert_axis(Axis(0));
3043
3044                    let boxes: Array3<u8> = quantize_ndarray(boxes.view(), detect_quant.into());
3045                    let scores: Array3<u8> = quantize_ndarray(scores.view(), detect_quant.into());
3046                    let classes: Array3<u8> = quantize_ndarray(classes.view(), detect_quant.into());
3047                    let mask: Array3<u8> = quantize_ndarray(mask.view(), detect_quant.into());
3048
3049                    if is_proto {
3050                        let inputs: Vec<crate::decoder::ArrayViewDQuantized<'_>> = vec![
3051                            boxes.view().into(),
3052                            scores.view().into(),
3053                            classes.view().into(),
3054                            mask.view().into(),
3055                            protos.view().into(),
3056                        ];
3057                        decoder
3058                            .decode_quantized_proto(&inputs, &mut output_boxes)
3059                            .unwrap();
3060
3061                        assert_eq!(output_boxes.len(), 1);
3062                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
3063                    } else {
3064                        let mut output_masks = Vec::with_capacity(50);
3065                        let inputs: Vec<crate::decoder::ArrayViewDQuantized<'_>> = vec![
3066                            boxes.view().into(),
3067                            scores.view().into(),
3068                            classes.view().into(),
3069                            mask.view().into(),
3070                            protos.view().into(),
3071                        ];
3072                        decoder
3073                            .decode_quantized(&inputs, &mut output_boxes, &mut output_masks)
3074                            .unwrap();
3075
3076                        assert_eq!(output_boxes.len(), 1);
3077                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
3078                    }
3079                } else {
3080                    // Combined layout
3081                    let detect = ndarray::concatenate![
3082                        Axis(1),
3083                        boxes.view(),
3084                        scores.view(),
3085                        classes.view(),
3086                        mask.view()
3087                    ];
3088                    let detect = detect.insert_axis(Axis(0));
3089                    assert_eq!(detect.shape(), &[1, 10, 38]);
3090                    let detect: Array3<u8> = quantize_ndarray(detect.view(), detect_quant.into());
3091
3092                    if is_proto {
3093                        let inputs: Vec<crate::decoder::ArrayViewDQuantized<'_>> =
3094                            vec![detect.view().into(), protos.view().into()];
3095                        decoder
3096                            .decode_quantized_proto(&inputs, &mut output_boxes)
3097                            .unwrap();
3098
3099                        assert_eq!(output_boxes.len(), 1);
3100                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
3101                    } else {
3102                        let mut output_masks = Vec::with_capacity(50);
3103                        let inputs: Vec<crate::decoder::ArrayViewDQuantized<'_>> =
3104                            vec![detect.view().into(), protos.view().into()];
3105                        decoder
3106                            .decode_quantized(&inputs, &mut output_boxes, &mut output_masks)
3107                            .unwrap();
3108
3109                        assert_eq!(output_boxes.len(), 1);
3110                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
3111                    }
3112                }
3113            }
3114        };
3115        ($name:ident, float, $layout:ident, $output:ident) => {
3116            #[test]
3117            fn $name() {
3118                let is_split = matches!(stringify!($layout), "split");
3119                let is_proto = matches!(stringify!($output), "proto");
3120
3121                let score_threshold = 0.45;
3122                let iou_threshold = 0.45;
3123
3124                let mut boxes = Array2::zeros((10, 4));
3125                let mut scores = Array2::zeros((10, 1));
3126                let mut classes = Array2::zeros((10, 1));
3127                let mask: Array2<f64> = Array2::zeros((10, 32));
3128                let protos = Array3::<f64>::zeros((160, 160, 32));
3129                let protos = protos.insert_axis(Axis(0));
3130
3131                boxes
3132                    .slice_mut(s![0, ..])
3133                    .assign(&array![0.1234, 0.1234, 0.2345, 0.2345]);
3134                scores.slice_mut(s![0, ..]).assign(&array![0.9876]);
3135                classes.slice_mut(s![0, ..]).assign(&array![2.0]);
3136
3137                let decoder = if is_split {
3138                    DecoderBuilder::default()
3139                        .with_config_yaml_str(E2E_SPLIT_SEGDET_CONFIG.to_string())
3140                        .with_score_threshold(score_threshold)
3141                        .with_iou_threshold(iou_threshold)
3142                        .build()
3143                        .unwrap()
3144                } else {
3145                    DecoderBuilder::default()
3146                        .with_config_yaml_str(E2E_COMBINED_SEGDET_CONFIG.to_string())
3147                        .with_score_threshold(score_threshold)
3148                        .with_iou_threshold(iou_threshold)
3149                        .build()
3150                        .unwrap()
3151                };
3152
3153                let expected = e2e_expected_boxes_float();
3154                let mut output_boxes = Vec::with_capacity(50);
3155
3156                if is_split {
3157                    let boxes = boxes.insert_axis(Axis(0));
3158                    let scores = scores.insert_axis(Axis(0));
3159                    let classes = classes.insert_axis(Axis(0));
3160                    let mask = mask.insert_axis(Axis(0));
3161
3162                    if is_proto {
3163                        let inputs = vec![
3164                            boxes.view().into_dyn(),
3165                            scores.view().into_dyn(),
3166                            classes.view().into_dyn(),
3167                            mask.view().into_dyn(),
3168                            protos.view().into_dyn(),
3169                        ];
3170                        decoder
3171                            .decode_float_proto(&inputs, &mut output_boxes)
3172                            .unwrap();
3173
3174                        assert_eq!(output_boxes.len(), 1);
3175                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
3176                    } else {
3177                        let mut output_masks = Vec::with_capacity(50);
3178                        let inputs = vec![
3179                            boxes.view().into_dyn(),
3180                            scores.view().into_dyn(),
3181                            classes.view().into_dyn(),
3182                            mask.view().into_dyn(),
3183                            protos.view().into_dyn(),
3184                        ];
3185                        decoder
3186                            .decode_float(&inputs, &mut output_boxes, &mut output_masks)
3187                            .unwrap();
3188
3189                        assert_eq!(output_boxes.len(), 1);
3190                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
3191                    }
3192                } else {
3193                    // Combined layout
3194                    let detect = ndarray::concatenate![
3195                        Axis(1),
3196                        boxes.view(),
3197                        scores.view(),
3198                        classes.view(),
3199                        mask.view()
3200                    ];
3201                    let detect = detect.insert_axis(Axis(0));
3202                    assert_eq!(detect.shape(), &[1, 10, 38]);
3203
3204                    if is_proto {
3205                        let inputs = vec![detect.view().into_dyn(), protos.view().into_dyn()];
3206                        decoder
3207                            .decode_float_proto(&inputs, &mut output_boxes)
3208                            .unwrap();
3209
3210                        assert_eq!(output_boxes.len(), 1);
3211                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
3212                    } else {
3213                        let mut output_masks = Vec::with_capacity(50);
3214                        let inputs = vec![detect.view().into_dyn(), protos.view().into_dyn()];
3215                        decoder
3216                            .decode_float(&inputs, &mut output_boxes, &mut output_masks)
3217                            .unwrap();
3218
3219                        assert_eq!(output_boxes.len(), 1);
3220                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
3221                    }
3222                }
3223            }
3224        };
3225    }
3226
3227    e2e_segdet_test!(test_decoder_end_to_end_segdet, quantized, combined, masks);
3228    e2e_segdet_test!(test_decoder_end_to_end_segdet_float, float, combined, masks);
3229    e2e_segdet_test!(
3230        test_decoder_end_to_end_segdet_proto,
3231        quantized,
3232        combined,
3233        proto
3234    );
3235    e2e_segdet_test!(
3236        test_decoder_end_to_end_segdet_proto_float,
3237        float,
3238        combined,
3239        proto
3240    );
3241    e2e_segdet_test!(
3242        test_decoder_end_to_end_segdet_split,
3243        quantized,
3244        split,
3245        masks
3246    );
3247    e2e_segdet_test!(
3248        test_decoder_end_to_end_segdet_split_float,
3249        float,
3250        split,
3251        masks
3252    );
3253    e2e_segdet_test!(
3254        test_decoder_end_to_end_segdet_split_proto,
3255        quantized,
3256        split,
3257        proto
3258    );
3259    e2e_segdet_test!(
3260        test_decoder_end_to_end_segdet_split_proto_float,
3261        float,
3262        split,
3263        proto
3264    );
3265
3266    macro_rules! e2e_det_test {
3267        ($name:ident, quantized, $layout:ident) => {
3268            #[test]
3269            fn $name() {
3270                let is_split = matches!(stringify!($layout), "split");
3271
3272                let score_threshold = 0.45;
3273                let iou_threshold = 0.45;
3274
3275                let mut boxes = Array3::zeros((1, 10, 4));
3276                let mut scores = Array3::zeros((1, 10, 1));
3277                let mut classes = Array3::zeros((1, 10, 1));
3278
3279                boxes
3280                    .slice_mut(s![0, 0, ..])
3281                    .assign(&array![0.1234, 0.1234, 0.2345, 0.2345]);
3282                scores.slice_mut(s![0, 0, ..]).assign(&array![0.9876]);
3283                classes.slice_mut(s![0, 0, ..]).assign(&array![2.0]);
3284
3285                let detect_quant = (2.0 / 255.0, 0_i32);
3286
3287                let decoder = if is_split {
3288                    DecoderBuilder::default()
3289                        .with_config_yaml_str(E2E_SPLIT_DET_CONFIG.to_string())
3290                        .with_score_threshold(score_threshold)
3291                        .with_iou_threshold(iou_threshold)
3292                        .build()
3293                        .unwrap()
3294                } else {
3295                    DecoderBuilder::default()
3296                        .with_config_yaml_str(E2E_COMBINED_DET_CONFIG.to_string())
3297                        .with_score_threshold(score_threshold)
3298                        .with_iou_threshold(iou_threshold)
3299                        .build()
3300                        .unwrap()
3301                };
3302
3303                let expected = e2e_expected_boxes_quant();
3304                let mut output_boxes = Vec::with_capacity(50);
3305
3306                if is_split {
3307                    let boxes: Array<u8, _> = quantize_ndarray(boxes.view(), detect_quant.into());
3308                    let scores: Array<u8, _> = quantize_ndarray(scores.view(), detect_quant.into());
3309                    let classes: Array<u8, _> =
3310                        quantize_ndarray(classes.view(), detect_quant.into());
3311                    let inputs: Vec<crate::decoder::ArrayViewDQuantized<'_>> = vec![
3312                        boxes.view().into(),
3313                        scores.view().into(),
3314                        classes.view().into(),
3315                    ];
3316                    decoder
3317                        .decode_quantized(&inputs, &mut output_boxes, &mut Vec::new())
3318                        .unwrap();
3319                } else {
3320                    let detect =
3321                        ndarray::concatenate![Axis(2), boxes.view(), scores.view(), classes.view()];
3322                    assert_eq!(detect.shape(), &[1, 10, 6]);
3323                    let detect: Array3<u8> = quantize_ndarray(detect.view(), detect_quant.into());
3324                    let inputs: Vec<crate::decoder::ArrayViewDQuantized<'_>> =
3325                        vec![detect.view().into()];
3326                    decoder
3327                        .decode_quantized(&inputs, &mut output_boxes, &mut Vec::new())
3328                        .unwrap();
3329                }
3330
3331                assert_eq!(output_boxes.len(), 1);
3332                assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
3333            }
3334        };
3335        ($name:ident, float, $layout:ident) => {
3336            #[test]
3337            fn $name() {
3338                let is_split = matches!(stringify!($layout), "split");
3339
3340                let score_threshold = 0.45;
3341                let iou_threshold = 0.45;
3342
3343                let mut boxes = Array3::zeros((1, 10, 4));
3344                let mut scores = Array3::zeros((1, 10, 1));
3345                let mut classes = Array3::zeros((1, 10, 1));
3346
3347                boxes
3348                    .slice_mut(s![0, 0, ..])
3349                    .assign(&array![0.1234, 0.1234, 0.2345, 0.2345]);
3350                scores.slice_mut(s![0, 0, ..]).assign(&array![0.9876]);
3351                classes.slice_mut(s![0, 0, ..]).assign(&array![2.0]);
3352
3353                let decoder = if is_split {
3354                    DecoderBuilder::default()
3355                        .with_config_yaml_str(E2E_SPLIT_DET_CONFIG.to_string())
3356                        .with_score_threshold(score_threshold)
3357                        .with_iou_threshold(iou_threshold)
3358                        .build()
3359                        .unwrap()
3360                } else {
3361                    DecoderBuilder::default()
3362                        .with_config_yaml_str(E2E_COMBINED_DET_CONFIG.to_string())
3363                        .with_score_threshold(score_threshold)
3364                        .with_iou_threshold(iou_threshold)
3365                        .build()
3366                        .unwrap()
3367                };
3368
3369                let expected = e2e_expected_boxes_float();
3370                let mut output_boxes = Vec::with_capacity(50);
3371
3372                if is_split {
3373                    let inputs = vec![
3374                        boxes.view().into_dyn(),
3375                        scores.view().into_dyn(),
3376                        classes.view().into_dyn(),
3377                    ];
3378                    decoder
3379                        .decode_float(&inputs, &mut output_boxes, &mut Vec::new())
3380                        .unwrap();
3381                } else {
3382                    let detect =
3383                        ndarray::concatenate![Axis(2), boxes.view(), scores.view(), classes.view()];
3384                    assert_eq!(detect.shape(), &[1, 10, 6]);
3385                    let inputs = vec![detect.view().into_dyn()];
3386                    decoder
3387                        .decode_float(&inputs, &mut output_boxes, &mut Vec::new())
3388                        .unwrap();
3389                }
3390
3391                assert_eq!(output_boxes.len(), 1);
3392                assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
3393            }
3394        };
3395    }
3396
3397    e2e_det_test!(test_decoder_end_to_end_combined_det, quantized, combined);
3398    e2e_det_test!(test_decoder_end_to_end_combined_det_float, float, combined);
3399    e2e_det_test!(test_decoder_end_to_end_split_det, quantized, split);
3400    e2e_det_test!(test_decoder_end_to_end_split_det_float, float, split);
3401
3402    #[test]
3403    fn test_decode_tensor() {
3404        let score_threshold = 0.45;
3405        let iou_threshold = 0.45;
3406
3407        let raw_boxes = edgefirst_bench::testdata::read("yolov8_boxes_116x8400.bin");
3408        let raw_boxes =
3409            unsafe { std::slice::from_raw_parts(raw_boxes.as_ptr() as *const i8, raw_boxes.len()) };
3410        let boxes_i8: Tensor<i8> = Tensor::new(&[1, 116, 8400], None, None).unwrap();
3411        boxes_i8
3412            .map()
3413            .unwrap()
3414            .as_mut_slice()
3415            .copy_from_slice(raw_boxes);
3416        let boxes_i8 = boxes_i8.into();
3417
3418        let raw_protos = edgefirst_bench::testdata::read("yolov8_protos_160x160x32.bin");
3419        let raw_protos = unsafe {
3420            std::slice::from_raw_parts(raw_protos.as_ptr() as *const i8, raw_protos.len())
3421        };
3422        let protos_i8: Tensor<i8> = Tensor::new(&[1, 160, 160, 32], None, None).unwrap();
3423        protos_i8
3424            .map()
3425            .unwrap()
3426            .as_mut_slice()
3427            .copy_from_slice(raw_protos);
3428        let protos_i8 = protos_i8.into();
3429
3430        let decoder = build_yolov8_seg_decoder(score_threshold, iou_threshold);
3431        let expected = real_data_expected_boxes();
3432        let mut output_boxes = Vec::with_capacity(50);
3433
3434        decoder
3435            .decode(&[&boxes_i8, &protos_i8], &mut output_boxes, &mut Vec::new())
3436            .unwrap();
3437
3438        assert_eq!(output_boxes.len(), 2);
3439        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
3440        assert!(output_boxes[1].equal_within_delta(&expected[1], 1.0 / 160.0));
3441    }
3442
3443    #[test]
3444    fn test_decode_tensor_f32() {
3445        let score_threshold = 0.45;
3446        let iou_threshold = 0.45;
3447
3448        let quant_boxes = (0.021287762_f32, 31_i32);
3449        let quant_protos = (0.02491162_f32, -117_i32);
3450        let raw_boxes = edgefirst_bench::testdata::read("yolov8_boxes_116x8400.bin");
3451        let raw_boxes =
3452            unsafe { std::slice::from_raw_parts(raw_boxes.as_ptr() as *const i8, raw_boxes.len()) };
3453        let mut raw_boxes_f32 = vec![0f32; raw_boxes.len()];
3454        dequantize_cpu(raw_boxes, quant_boxes.into(), &mut raw_boxes_f32);
3455        let boxes_f32: Tensor<f32> = Tensor::new(&[1, 116, 8400], None, None).unwrap();
3456        boxes_f32
3457            .map()
3458            .unwrap()
3459            .as_mut_slice()
3460            .copy_from_slice(&raw_boxes_f32);
3461        let boxes_f32 = boxes_f32.into();
3462
3463        let raw_protos = edgefirst_bench::testdata::read("yolov8_protos_160x160x32.bin");
3464        let raw_protos = unsafe {
3465            std::slice::from_raw_parts(raw_protos.as_ptr() as *const i8, raw_protos.len())
3466        };
3467        let mut raw_protos_f32 = vec![0f32; raw_protos.len()];
3468        dequantize_cpu(raw_protos, quant_protos.into(), &mut raw_protos_f32);
3469        let protos_f32: Tensor<f32> = Tensor::new(&[1, 160, 160, 32], None, None).unwrap();
3470        protos_f32
3471            .map()
3472            .unwrap()
3473            .as_mut_slice()
3474            .copy_from_slice(&raw_protos_f32);
3475        let protos_f32 = protos_f32.into();
3476
3477        let decoder = build_yolov8_seg_decoder(score_threshold, iou_threshold);
3478
3479        let expected = real_data_expected_boxes();
3480        let mut output_boxes = Vec::with_capacity(50);
3481
3482        decoder
3483            .decode(
3484                &[&boxes_f32, &protos_f32],
3485                &mut output_boxes,
3486                &mut Vec::new(),
3487            )
3488            .unwrap();
3489
3490        assert_eq!(output_boxes.len(), 2);
3491        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
3492        assert!(output_boxes[1].equal_within_delta(&expected[1], 1.0 / 160.0));
3493    }
3494
3495    #[test]
3496    fn test_decode_tensor_f64() {
3497        let score_threshold = 0.45;
3498        let iou_threshold = 0.45;
3499
3500        let quant_boxes = (0.021287762_f32, 31_i32);
3501        let quant_protos = (0.02491162_f32, -117_i32);
3502        let raw_boxes = edgefirst_bench::testdata::read("yolov8_boxes_116x8400.bin");
3503        let raw_boxes =
3504            unsafe { std::slice::from_raw_parts(raw_boxes.as_ptr() as *const i8, raw_boxes.len()) };
3505        let mut raw_boxes_f64 = vec![0f64; raw_boxes.len()];
3506        dequantize_cpu(raw_boxes, quant_boxes.into(), &mut raw_boxes_f64);
3507        let boxes_f64: Tensor<f64> = Tensor::new(&[1, 116, 8400], None, None).unwrap();
3508        boxes_f64
3509            .map()
3510            .unwrap()
3511            .as_mut_slice()
3512            .copy_from_slice(&raw_boxes_f64);
3513        let boxes_f64 = boxes_f64.into();
3514
3515        let raw_protos = edgefirst_bench::testdata::read("yolov8_protos_160x160x32.bin");
3516        let raw_protos = unsafe {
3517            std::slice::from_raw_parts(raw_protos.as_ptr() as *const i8, raw_protos.len())
3518        };
3519        let mut raw_protos_f64 = vec![0f64; raw_protos.len()];
3520        dequantize_cpu(raw_protos, quant_protos.into(), &mut raw_protos_f64);
3521        let protos_f64: Tensor<f64> = Tensor::new(&[1, 160, 160, 32], None, None).unwrap();
3522        protos_f64
3523            .map()
3524            .unwrap()
3525            .as_mut_slice()
3526            .copy_from_slice(&raw_protos_f64);
3527        let protos_f64 = protos_f64.into();
3528
3529        let decoder = build_yolov8_seg_decoder(score_threshold, iou_threshold);
3530
3531        let expected = real_data_expected_boxes();
3532        let mut output_boxes = Vec::with_capacity(50);
3533
3534        decoder
3535            .decode(
3536                &[&boxes_f64, &protos_f64],
3537                &mut output_boxes,
3538                &mut Vec::new(),
3539            )
3540            .unwrap();
3541
3542        assert_eq!(output_boxes.len(), 2);
3543        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
3544        assert!(output_boxes[1].equal_within_delta(&expected[1], 1.0 / 160.0));
3545    }
3546
3547    #[test]
3548    fn test_decode_tensor_proto() {
3549        let score_threshold = 0.45;
3550        let iou_threshold = 0.45;
3551
3552        let raw_boxes = edgefirst_bench::testdata::read("yolov8_boxes_116x8400.bin");
3553        let raw_boxes =
3554            unsafe { std::slice::from_raw_parts(raw_boxes.as_ptr() as *const i8, raw_boxes.len()) };
3555        let boxes_i8: Tensor<i8> = Tensor::new(&[1, 116, 8400], None, None).unwrap();
3556        boxes_i8
3557            .map()
3558            .unwrap()
3559            .as_mut_slice()
3560            .copy_from_slice(raw_boxes);
3561        let boxes_i8 = boxes_i8.into();
3562
3563        let raw_protos = edgefirst_bench::testdata::read("yolov8_protos_160x160x32.bin");
3564        let raw_protos = unsafe {
3565            std::slice::from_raw_parts(raw_protos.as_ptr() as *const i8, raw_protos.len())
3566        };
3567        let protos_i8: Tensor<i8> = Tensor::new(&[1, 160, 160, 32], None, None).unwrap();
3568        protos_i8
3569            .map()
3570            .unwrap()
3571            .as_mut_slice()
3572            .copy_from_slice(raw_protos);
3573        let protos_i8 = protos_i8.into();
3574
3575        let decoder = build_yolov8_seg_decoder(score_threshold, iou_threshold);
3576
3577        let expected = real_data_expected_boxes();
3578        let mut output_boxes = Vec::with_capacity(50);
3579
3580        let proto_data = decoder
3581            .decode_proto(&[&boxes_i8, &protos_i8], &mut output_boxes)
3582            .unwrap();
3583
3584        assert_eq!(output_boxes.len(), 2);
3585        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
3586        assert!(output_boxes[1].equal_within_delta(&expected[1], 1.0 / 160.0));
3587
3588        let proto_data = proto_data.expect("segmentation model should return ProtoData");
3589        let coeffs_shape = proto_data.mask_coefficients.shape();
3590        assert_eq!(
3591            coeffs_shape[0],
3592            output_boxes.len(),
3593            "mask_coefficients count must match detection count"
3594        );
3595        assert_eq!(
3596            coeffs_shape[1], 32,
3597            "each detection should have 32 mask coefficients"
3598        );
3599    }
3600
3601    // =========================================================================
3602    // Physical-order contract regression tests
3603    //
3604    // These cover the TFLite VX-delegate vertical-stripe mask bug and the
3605    // Ara-2-via-overlay anchor-first split-tensor bug. The core claim:
3606    // declaring shape+dshape in physical memory order produces correct
3607    // strides at wrap time, and `swap_axes_if_needed` permutes the stride
3608    // tuple into canonical logical order without touching bytes.
3609    // =========================================================================
3610
3611    /// TFLite YOLOv8-seg protos arrive as physically-NHWC bytes with
3612    /// NNStreamer dim string `"32:160:160:1"` (innermost-first). The
3613    /// overlay's 2026-04-22 workaround declares `[1, 160, 160, 32]` with
3614    /// dshape `[batch, height, width, num_protos]` — shape matches
3615    /// physical order, so no permutation is needed and the view is
3616    /// already in canonical HWC after the batch-dim slice. This test
3617    /// confirms that path matches the reference (direct-HWC) decode.
3618    #[test]
3619    fn test_physical_order_tflite_nhwc_protos() {
3620        let score_threshold = 0.45;
3621        let iou_threshold = 0.45;
3622
3623        // Load HWC protos and dequantize to f32.
3624        let protos_hwc = load_yolov8_protos().slice_move(s![0, .., .., ..]);
3625        let quant_protos = Quantization::new(0.02491161972284317, -117);
3626        let protos_f32_hwc = dequantize_ndarray::<_, _, f32>(protos_hwc.view(), quant_protos);
3627
3628        let boxes_2d = load_yolov8_boxes().slice_move(s![0, .., ..]);
3629        let quant_boxes = Quantization::new(0.021287761628627777, 31);
3630        let seg = dequantize_ndarray::<_, _, f32>(boxes_2d.view(), quant_boxes);
3631
3632        // Reference: direct call with canonical HWC protos.
3633        let mut ref_boxes: Vec<_> = Vec::with_capacity(10);
3634        let mut ref_masks: Vec<_> = Vec::with_capacity(10);
3635        decode_yolo_segdet_float(
3636            seg.view(),
3637            protos_f32_hwc.view(),
3638            score_threshold,
3639            iou_threshold,
3640            Some(configs::Nms::ClassAgnostic),
3641            &mut ref_boxes,
3642            &mut ref_masks,
3643        )
3644        .unwrap();
3645
3646        // Build the same protos as a 4D NHWC tensor and feed it through
3647        // the config-driven path with dshape in physical order.
3648        let protos_nhwc = protos_f32_hwc.clone().insert_axis(Axis(0)); // [1, 160, 160, 32]
3649        let seg_3d = seg.insert_axis(Axis(0)); // [1, 116, 8400]
3650
3651        let decoder = DecoderBuilder::default()
3652            .with_config_yolo_segdet(
3653                configs::Detection {
3654                    decoder: configs::DecoderType::Ultralytics,
3655                    quantization: None,
3656                    shape: vec![1, 116, 8400],
3657                    dshape: vec![
3658                        (DimName::Batch, 1),
3659                        (DimName::NumFeatures, 116),
3660                        (DimName::NumBoxes, 8400),
3661                    ],
3662                    normalized: Some(true),
3663                    anchors: None,
3664                },
3665                configs::Protos {
3666                    decoder: configs::DecoderType::Ultralytics,
3667                    quantization: None,
3668                    shape: vec![1, 160, 160, 32],
3669                    // Physical NHWC — matches the TFLite buffer layout.
3670                    dshape: vec![
3671                        (DimName::Batch, 1),
3672                        (DimName::Height, 160),
3673                        (DimName::Width, 160),
3674                        (DimName::NumProtos, 32),
3675                    ],
3676                },
3677                None,
3678            )
3679            .with_score_threshold(score_threshold)
3680            .with_iou_threshold(iou_threshold)
3681            .build()
3682            .expect("config with NHWC protos dshape must build");
3683
3684        let mut cfg_boxes = Vec::with_capacity(10);
3685        let mut cfg_masks = Vec::with_capacity(10);
3686        decoder
3687            .decode_float(
3688                &[seg_3d.view().into_dyn(), protos_nhwc.view().into_dyn()],
3689                &mut cfg_boxes,
3690                &mut cfg_masks,
3691            )
3692            .unwrap();
3693
3694        assert_eq!(cfg_boxes.len(), ref_boxes.len(), "box count mismatch");
3695        for (c, r) in cfg_boxes.iter().zip(&ref_boxes) {
3696            assert!(
3697                c.equal_within_delta(r, 0.01),
3698                "NHWC-declared box does not match reference: {c:?} vs {r:?}"
3699            );
3700        }
3701        for (cm, rm) in cfg_masks.iter().zip(&ref_masks) {
3702            let cm_arr = segmentation_to_mask(seg_view(cm).view()).unwrap();
3703            let rm_arr = segmentation_to_mask(seg_view(rm).view()).unwrap();
3704            assert_eq!(
3705                cm_arr, rm_arr,
3706                "NHWC-declared mask must match reference pixel-for-pixel"
3707            );
3708        }
3709    }
3710
3711    /// Ara-2 split-tensor boxes arrive physically anchor-first: NNStreamer
3712    /// dim string `"4:1:8400:1"` means innermost axis is 4 (coords), next
3713    /// is 1 (padding), next is 8400 (anchors), outermost is 1 (batch).
3714    /// The correct physical-order declaration is `[1, 8400, 1, 4]` with
3715    /// dshape `[batch, num_boxes, padding, box_coords]`. This test
3716    /// confirms that declaration produces the same decoded boxes as the
3717    /// canonical features-first TFLite declaration over the same logical
3718    /// data.
3719    #[test]
3720    fn test_physical_order_ara2_anchor_first_split_boxes() {
3721        use configs::{Boxes, Scores};
3722
3723        // Build synthetic boxes data in features-first canonical layout:
3724        // shape [1, 4, 8400] with one detection at anchor 42.
3725        const N: usize = 8400;
3726        let mut boxes_canonical = Array3::<f32>::zeros((1, 4, N));
3727        let target_anchor = 42usize;
3728        boxes_canonical[[0, 0, target_anchor]] = 0.4; // xc
3729        boxes_canonical[[0, 1, target_anchor]] = 0.5; // yc
3730        boxes_canonical[[0, 2, target_anchor]] = 0.2; // w
3731        boxes_canonical[[0, 3, target_anchor]] = 0.2; // h
3732
3733        // Scores: one class at 0.9 for the same anchor.
3734        let mut scores_canonical = Array3::<f32>::zeros((1, 80, N));
3735        scores_canonical[[0, 0, target_anchor]] = 0.9;
3736
3737        // Reference: canonical (features-first) shape declaration.
3738        let ref_decoder = DecoderBuilder::default()
3739            .with_config_yolo_split_det(
3740                Boxes {
3741                    decoder: configs::DecoderType::Ultralytics,
3742                    quantization: None,
3743                    shape: vec![1, 4, N],
3744                    dshape: vec![
3745                        (DimName::Batch, 1),
3746                        (DimName::BoxCoords, 4),
3747                        (DimName::NumBoxes, N),
3748                    ],
3749                    normalized: Some(true),
3750                },
3751                Scores {
3752                    decoder: configs::DecoderType::Ultralytics,
3753                    quantization: None,
3754                    shape: vec![1, 80, N],
3755                    dshape: vec![
3756                        (DimName::Batch, 1),
3757                        (DimName::NumClasses, 80),
3758                        (DimName::NumBoxes, N),
3759                    ],
3760                },
3761            )
3762            .with_score_threshold(0.5)
3763            .with_iou_threshold(0.5)
3764            .with_nms(Some(configs::Nms::ClassAgnostic))
3765            .build()
3766            .expect("reference canonical split decoder must build");
3767
3768        let mut ref_boxes = Vec::with_capacity(4);
3769        let mut ref_masks = Vec::with_capacity(0);
3770        ref_decoder
3771            .decode_float(
3772                &[
3773                    boxes_canonical.view().into_dyn(),
3774                    scores_canonical.view().into_dyn(),
3775                ],
3776                &mut ref_boxes,
3777                &mut ref_masks,
3778            )
3779            .unwrap();
3780        assert_eq!(ref_boxes.len(), 1, "reference should produce one box");
3781
3782        // Ara-2 physical layout: transpose axes 1↔2 so the innermost
3783        // axis is BoxCoords / NumClasses. Materialise to get a
3784        // C-contiguous Array3 with strides matching the physical order
3785        // the Ara-2 backend would write into the tensor.
3786        let boxes_ara2 = boxes_canonical.view().permuted_axes([0, 2, 1]).to_owned(); // [1, 8400, 4]
3787        let scores_ara2 = scores_canonical.view().permuted_axes([0, 2, 1]).to_owned(); // [1, 8400, 80]
3788
3789        let ara2_decoder = DecoderBuilder::default()
3790            .with_config_yolo_split_det(
3791                Boxes {
3792                    decoder: configs::DecoderType::Ultralytics,
3793                    quantization: None,
3794                    shape: vec![1, N, 4],
3795                    dshape: vec![
3796                        (DimName::Batch, 1),
3797                        (DimName::NumBoxes, N),
3798                        (DimName::BoxCoords, 4),
3799                    ],
3800                    normalized: Some(true),
3801                },
3802                Scores {
3803                    decoder: configs::DecoderType::Ultralytics,
3804                    quantization: None,
3805                    shape: vec![1, N, 80],
3806                    dshape: vec![
3807                        (DimName::Batch, 1),
3808                        (DimName::NumBoxes, N),
3809                        (DimName::NumClasses, 80),
3810                    ],
3811                },
3812            )
3813            .with_score_threshold(0.5)
3814            .with_iou_threshold(0.5)
3815            .with_nms(Some(configs::Nms::ClassAgnostic))
3816            .build()
3817            .expect("Ara-2 anchor-first decoder must build");
3818
3819        let mut ara2_boxes = Vec::with_capacity(4);
3820        let mut ara2_masks = Vec::with_capacity(0);
3821        ara2_decoder
3822            .decode_float(
3823                &[boxes_ara2.view().into_dyn(), scores_ara2.view().into_dyn()],
3824                &mut ara2_boxes,
3825                &mut ara2_masks,
3826            )
3827            .unwrap();
3828
3829        assert_eq!(
3830            ara2_boxes.len(),
3831            ref_boxes.len(),
3832            "Ara-2 anchor-first declaration must produce the same number \
3833             of boxes as the canonical features-first reference"
3834        );
3835        for (a, r) in ara2_boxes.iter().zip(&ref_boxes) {
3836            assert!(
3837                a.equal_within_delta(r, 1e-4),
3838                "Ara-2 box differs from reference: {a:?} vs {r:?}"
3839            );
3840        }
3841    }
3842
3843    /// Dshape whose per-axis sizes disagree with shape (the exact
3844    /// failure mode that caused the TFLite stripe bug — shape declared
3845    /// in one order, dshape in another) is rejected with a clear error.
3846    #[test]
3847    fn test_physical_order_rejects_shape_dshape_mismatch() {
3848        let result = DecoderBuilder::default()
3849            .with_config_yolo_segdet(
3850                configs::Detection {
3851                    decoder: configs::DecoderType::Ultralytics,
3852                    quantization: None,
3853                    shape: vec![1, 116, 8400],
3854                    dshape: vec![
3855                        (DimName::Batch, 1),
3856                        (DimName::NumFeatures, 116),
3857                        (DimName::NumBoxes, 8400),
3858                    ],
3859                    normalized: Some(true),
3860                    anchors: None,
3861                },
3862                configs::Protos {
3863                    decoder: configs::DecoderType::Ultralytics,
3864                    quantization: None,
3865                    // Shape in NCHW order...
3866                    shape: vec![1, 32, 160, 160],
3867                    // ...but dshape in NHWC order — the sizes don't line
3868                    // up positionally (dshape[1]=160 vs shape[1]=32).
3869                    dshape: vec![
3870                        (DimName::Batch, 1),
3871                        (DimName::Height, 160),
3872                        (DimName::Width, 160),
3873                        (DimName::NumProtos, 32),
3874                    ],
3875                },
3876                None,
3877            )
3878            .build();
3879
3880        match result {
3881            Err(DecoderError::InvalidConfig(msg)) => {
3882                assert!(
3883                    msg.contains("does not match shape"),
3884                    "expected shape/dshape size mismatch error, got: {msg}"
3885                );
3886            }
3887            other => panic!("expected InvalidConfig, got {other:?}"),
3888        }
3889    }
3890
3891    /// Duplicate dim name in dshape is rejected (two axes mapping to the
3892    /// same canonical slot would break the permutation).
3893    #[test]
3894    fn test_physical_order_rejects_duplicate_dshape_axis() {
3895        let result = DecoderBuilder::default()
3896            .with_config_yolo_split_det(
3897                configs::Boxes {
3898                    decoder: configs::DecoderType::Ultralytics,
3899                    quantization: None,
3900                    shape: vec![1, 4, 8400],
3901                    dshape: vec![
3902                        (DimName::Batch, 1),
3903                        (DimName::BoxCoords, 4),
3904                        (DimName::BoxCoords, 4), // duplicate (size matches shape[2]=8400? no)
3905                    ],
3906                    normalized: Some(true),
3907                },
3908                configs::Scores {
3909                    decoder: configs::DecoderType::Ultralytics,
3910                    quantization: None,
3911                    shape: vec![1, 80, 8400],
3912                    dshape: vec![
3913                        (DimName::Batch, 1),
3914                        (DimName::NumClasses, 80),
3915                        (DimName::NumBoxes, 8400),
3916                    ],
3917                },
3918            )
3919            .build();
3920
3921        // Shape[2] = 8400 ≠ dshape[2] size 4, so the positional-mismatch
3922        // check fires first — which is fine: any mis-shaped dshape
3923        // should be rejected. Check for either error as long as it's a
3924        // clear InvalidConfig.
3925        match result {
3926            Err(DecoderError::InvalidConfig(msg)) => {
3927                assert!(
3928                    msg.contains("appears at both index") || msg.contains("does not match shape"),
3929                    "expected positional or duplicate-axis error, got: {msg}"
3930                );
3931            }
3932            other => panic!("expected InvalidConfig, got {other:?}"),
3933        }
3934
3935        // Separate case: size-consistent duplicate to exercise the
3936        // duplicate-axis code path explicitly. Use `Batch` (no size
3937        // constraint, can repeat with size 1 against a shape that
3938        // carries two singleton dims).
3939        let result = DecoderBuilder::default()
3940            .with_config_yolo_split_det(
3941                configs::Boxes {
3942                    decoder: configs::DecoderType::Ultralytics,
3943                    quantization: None,
3944                    shape: vec![1, 1, 4, 8400],
3945                    dshape: vec![
3946                        (DimName::Batch, 1),
3947                        (DimName::Batch, 1), // duplicate, sizes match
3948                        (DimName::BoxCoords, 4),
3949                        (DimName::NumBoxes, 8400),
3950                    ],
3951                    normalized: Some(true),
3952                },
3953                configs::Scores {
3954                    decoder: configs::DecoderType::Ultralytics,
3955                    quantization: None,
3956                    shape: vec![1, 80, 8400],
3957                    dshape: vec![
3958                        (DimName::Batch, 1),
3959                        (DimName::NumClasses, 80),
3960                        (DimName::NumBoxes, 8400),
3961                    ],
3962                },
3963            )
3964            .build();
3965        match result {
3966            Err(DecoderError::InvalidConfig(msg)) => {
3967                assert!(
3968                    msg.contains("appears at both index"),
3969                    "expected duplicate-axis error, got: {msg}"
3970                );
3971            }
3972            other => panic!("expected InvalidConfig, got {other:?}"),
3973        }
3974    }
3975
3976    /// Canonical (dshape-omitted) high-level decode produces the same
3977    /// numeric result as the dshape-populated form. This closes a
3978    /// coverage gap flagged during review: dshape omission had been
3979    /// exercised only at the builder level, not through the full
3980    /// `decode_float` pipeline.
3981    #[test]
3982    fn test_physical_order_dshape_omitted_decodes_numerically() {
3983        let score_threshold = 0.45;
3984        let iou_threshold = 0.45;
3985
3986        let protos_hwc = load_yolov8_protos().slice_move(s![0, .., .., ..]);
3987        let quant_protos = Quantization::new(0.02491161972284317, -117);
3988        let protos_f32_hwc = dequantize_ndarray::<_, _, f32>(protos_hwc.view(), quant_protos);
3989
3990        let boxes_2d = load_yolov8_boxes().slice_move(s![0, .., ..]);
3991        let quant_boxes = Quantization::new(0.021287761628627777, 31);
3992        let seg = dequantize_ndarray::<_, _, f32>(boxes_2d.view(), quant_boxes);
3993
3994        let protos_nhwc = protos_f32_hwc.clone().insert_axis(Axis(0));
3995        let seg_3d = seg.insert_axis(Axis(0));
3996
3997        let build_decoder = |det_dshape: Vec<(DimName, usize)>,
3998                             proto_dshape: Vec<(DimName, usize)>| {
3999            DecoderBuilder::default()
4000                .with_config_yolo_segdet(
4001                    configs::Detection {
4002                        decoder: configs::DecoderType::Ultralytics,
4003                        quantization: None,
4004                        shape: vec![1, 116, 8400],
4005                        dshape: det_dshape,
4006                        normalized: Some(true),
4007                        anchors: None,
4008                    },
4009                    configs::Protos {
4010                        decoder: configs::DecoderType::Ultralytics,
4011                        quantization: None,
4012                        shape: vec![1, 160, 160, 32],
4013                        dshape: proto_dshape,
4014                    },
4015                    None,
4016                )
4017                .with_score_threshold(score_threshold)
4018                .with_iou_threshold(iou_threshold)
4019                .build()
4020                .unwrap()
4021        };
4022
4023        // Baseline: dshape populated in physical order.
4024        let dshaped = build_decoder(
4025            vec![
4026                (DimName::Batch, 1),
4027                (DimName::NumFeatures, 116),
4028                (DimName::NumBoxes, 8400),
4029            ],
4030            vec![
4031                (DimName::Batch, 1),
4032                (DimName::Height, 160),
4033                (DimName::Width, 160),
4034                (DimName::NumProtos, 32),
4035            ],
4036        );
4037        let mut dshaped_boxes = Vec::new();
4038        let mut dshaped_masks = Vec::new();
4039        dshaped
4040            .decode_float(
4041                &[seg_3d.view().into_dyn(), protos_nhwc.view().into_dyn()],
4042                &mut dshaped_boxes,
4043                &mut dshaped_masks,
4044            )
4045            .unwrap();
4046
4047        // Variant: dshape omitted — caller asserts shape is already in
4048        // the decoder's canonical order.
4049        let bare = build_decoder(vec![], vec![]);
4050        let mut bare_boxes = Vec::new();
4051        let mut bare_masks = Vec::new();
4052        bare.decode_float(
4053            &[seg_3d.view().into_dyn(), protos_nhwc.view().into_dyn()],
4054            &mut bare_boxes,
4055            &mut bare_masks,
4056        )
4057        .unwrap();
4058
4059        assert_eq!(bare_boxes.len(), dshaped_boxes.len());
4060        for (b, d) in bare_boxes.iter().zip(&dshaped_boxes) {
4061            assert!(
4062                b.equal_within_delta(d, 1e-4),
4063                "dshape-omitted box {b:?} differs from dshape-populated {d:?}"
4064            );
4065        }
4066        for (bm, dm) in bare_masks.iter().zip(&dshaped_masks) {
4067            let bm_arr = segmentation_to_mask(seg_view(bm).view()).unwrap();
4068            let dm_arr = segmentation_to_mask(seg_view(dm).view()).unwrap();
4069            assert_eq!(
4070                bm_arr, dm_arr,
4071                "dshape-omitted mask must match dshape-populated pixel-for-pixel"
4072            );
4073        }
4074    }
4075
4076    /// 4D anchor-first boxes schema with a trailing `padding` axis — the
4077    /// exact Ara-2 DVM shape (`"4:1:8400:1"` innermost-first = physical
4078    /// `[1, 8400, 1, 4]`). Exercises the schema path's
4079    /// `squeeze_padding_dims`: the caller declares a 4D physical-order
4080    /// layout including `padding`, HAL squeezes the size-1 axis during
4081    /// `to_legacy_config_outputs`, and the decoder sees the resulting
4082    /// 3D shape. Callers feed HAL a 3D squeezed view of the same bytes.
4083    /// Complements the 3D `test_physical_order_ara2_anchor_first_split_boxes`
4084    /// which exercises the programmatic-builder path.
4085    #[test]
4086    fn test_physical_order_ara2_4d_anchor_first_with_padding() {
4087        // Build synthetic data in anchor-first 3D layout (the shape HAL
4088        // actually operates on after squeezing). The 4D-with-padding
4089        // declaration in the schema is a producer-side convention; the
4090        // caller is expected to present HAL with a squeezed view.
4091        const N: usize = 8400;
4092        let mut boxes = Array3::<f32>::zeros((1, N, 4));
4093        let target = 42usize;
4094        boxes[[0, target, 0]] = 0.4;
4095        boxes[[0, target, 1]] = 0.5;
4096        boxes[[0, target, 2]] = 0.2;
4097        boxes[[0, target, 3]] = 0.2;
4098        let mut scores = Array3::<f32>::zeros((1, N, 80));
4099        scores[[0, target, 0]] = 0.9;
4100
4101        // Schema JSON declares shape+dshape in physical anchor-first
4102        // order including padding — mirrors `ara2_int8_edgefirst.json`'s
4103        // feature-first declaration but with the axes in physical
4104        // anchor-first order. `to_legacy_config_outputs` squeezes
4105        // padding before the decoder's rank-3 verification.
4106        let json = r#"{
4107          "schema_version": 2,
4108          "decoder_version": "yolov8",
4109          "nms": "class_agnostic",
4110          "outputs": [
4111            {"name": "boxes", "type": "boxes",
4112             "shape": [1, 8400, 1, 4],
4113             "dshape": [{"batch":1},{"num_boxes":8400},{"padding":1},{"box_coords":4}],
4114             "encoding": "direct",
4115             "decoder": "ultralytics",
4116             "normalized": true},
4117            {"name": "scores", "type": "scores",
4118             "shape": [1, 8400, 1, 80],
4119             "dshape": [{"batch":1},{"num_boxes":8400},{"padding":1},{"num_classes":80}],
4120             "decoder": "ultralytics",
4121             "score_format": "per_class"}
4122          ]
4123        }"#;
4124        let decoder = DecoderBuilder::default()
4125            .with_config_json_str(json.to_string())
4126            .with_score_threshold(0.5)
4127            .with_iou_threshold(0.5)
4128            .build()
4129            .expect("4D anchor-first schema should build via squeeze_padding_dims");
4130
4131        let mut out_boxes = Vec::with_capacity(4);
4132        let mut out_masks = Vec::with_capacity(0);
4133        decoder
4134            .decode_float(
4135                &[boxes.view().into_dyn(), scores.view().into_dyn()],
4136                &mut out_boxes,
4137                &mut out_masks,
4138            )
4139            .unwrap();
4140
4141        assert_eq!(
4142            out_boxes.len(),
4143            1,
4144            "4D anchor-first with padding should decode exactly one box from the seeded anchor"
4145        );
4146        let b = &out_boxes[0];
4147        // xywh(0.4, 0.5, 0.2, 0.2) → xyxy(0.3, 0.4, 0.5, 0.6)
4148        assert!((b.bbox.xmin - 0.3).abs() < 1e-3, "xmin wrong: {b:?}");
4149        assert!((b.bbox.ymin - 0.4).abs() < 1e-3, "ymin wrong: {b:?}");
4150        assert!((b.bbox.xmax - 0.5).abs() < 1e-3, "xmax wrong: {b:?}");
4151        assert!((b.bbox.ymax - 0.6).abs() < 1e-3, "ymax wrong: {b:?}");
4152        assert_eq!(b.label, 0);
4153        assert!(b.score > 0.85, "score {}: {b:?}", b.score);
4154    }
4155}
4156
4157#[cfg(feature = "tracker")]
4158#[cfg(test)]
4159#[cfg_attr(coverage_nightly, coverage(off))]
4160mod decoder_tracked_tests {
4161
4162    use crate::DetectBoxTestExt;
4163    use edgefirst_tracker::{ByteTrackBuilder, Tracker};
4164    use ndarray::{array, s, Array, Array2, Array3, Array4, ArrayView, Axis, Dimension};
4165    use num_traits::{AsPrimitive, Float, PrimInt};
4166    use rand::{RngExt, SeedableRng};
4167    use rand_distr::StandardNormal;
4168
4169    use crate::{
4170        configs::{self, DimName},
4171        dequantize_ndarray, BoundingBox, DecoderBuilder, DetectBox, Quantization,
4172    };
4173
4174    pub fn quantize_ndarray<T: PrimInt + 'static, D: Dimension, F: Float + AsPrimitive<T>>(
4175        input: ArrayView<F, D>,
4176        quant: Quantization,
4177    ) -> Array<T, D>
4178    where
4179        i32: num_traits::AsPrimitive<F>,
4180        f32: num_traits::AsPrimitive<F>,
4181    {
4182        let zero_point = quant.zero_point.as_();
4183        let div_scale = F::one() / quant.scale.as_();
4184        if zero_point != F::zero() {
4185            input.mapv(|d| (d * div_scale + zero_point).round().as_())
4186        } else {
4187            input.mapv(|d| (d * div_scale).round().as_())
4188        }
4189    }
4190
4191    #[test]
4192    fn test_decoder_tracked_random_jitter() {
4193        use crate::configs::{DecoderType, Nms};
4194        use crate::DecoderBuilder;
4195
4196        let score_threshold = 0.25;
4197        let iou_threshold = 0.1;
4198        let out = edgefirst_bench::testdata::read("yolov8s_80_classes.bin");
4199        let out = unsafe { std::slice::from_raw_parts(out.as_ptr() as *const i8, out.len()) };
4200        let out = Array3::from_shape_vec((1, 84, 8400), out.to_vec()).unwrap();
4201        let quant = (0.0040811873, -123).into();
4202
4203        let decoder = DecoderBuilder::default()
4204            .with_config_yolo_det(
4205                crate::configs::Detection {
4206                    decoder: DecoderType::Ultralytics,
4207                    shape: vec![1, 84, 8400],
4208                    anchors: None,
4209                    quantization: Some(quant),
4210                    dshape: vec![
4211                        (crate::configs::DimName::Batch, 1),
4212                        (crate::configs::DimName::NumFeatures, 84),
4213                        (crate::configs::DimName::NumBoxes, 8400),
4214                    ],
4215                    normalized: Some(true),
4216                },
4217                None,
4218            )
4219            .with_score_threshold(score_threshold)
4220            .with_iou_threshold(iou_threshold)
4221            .with_nms(Some(Nms::ClassAgnostic))
4222            .build()
4223            .unwrap();
4224        let mut rng = rand::rngs::StdRng::seed_from_u64(0xAB_BEEF); // fixed seed for reproducibility
4225
4226        let expected_boxes = [
4227            crate::DetectBox {
4228                bbox: crate::BoundingBox {
4229                    xmin: 0.5285137,
4230                    ymin: 0.05305544,
4231                    xmax: 0.87541467,
4232                    ymax: 0.9998909,
4233                },
4234                score: 0.5591227,
4235                label: 0,
4236            },
4237            crate::DetectBox {
4238                bbox: crate::BoundingBox {
4239                    xmin: 0.130598,
4240                    ymin: 0.43260583,
4241                    xmax: 0.35098213,
4242                    ymax: 0.9958097,
4243                },
4244                score: 0.33057618,
4245                label: 75,
4246            },
4247        ];
4248
4249        let mut tracker = ByteTrackBuilder::new()
4250            .track_update(0.1)
4251            .track_high_conf(0.3)
4252            .build();
4253
4254        let mut output_boxes = Vec::with_capacity(50);
4255        let mut output_masks = Vec::with_capacity(50);
4256        let mut output_tracks = Vec::with_capacity(50);
4257
4258        decoder
4259            .decode_tracked_quantized(
4260                &mut tracker,
4261                0,
4262                &[out.view().into()],
4263                &mut output_boxes,
4264                &mut output_masks,
4265                &mut output_tracks,
4266            )
4267            .unwrap();
4268
4269        assert_eq!(output_boxes.len(), 2);
4270        assert!(output_boxes[0].equal_within_delta(&expected_boxes[0], 1e-6));
4271        assert!(output_boxes[1].equal_within_delta(&expected_boxes[1], 1e-6));
4272
4273        let mut last_boxes = output_boxes.clone();
4274
4275        for i in 1..=100 {
4276            let mut out = out.clone();
4277            // introduce jitter into the XY coordinates to simulate movement and test tracking stability
4278            let mut x_values = out.slice_mut(s![0, 0, ..]);
4279            for x in x_values.iter_mut() {
4280                let r: f32 = rng.sample(StandardNormal);
4281                let r = r.clamp(-2.0, 2.0) / 2.0;
4282                *x = x.saturating_add((r * 1e-2 / quant.0) as i8);
4283            }
4284
4285            let mut y_values = out.slice_mut(s![0, 1, ..]);
4286            for y in y_values.iter_mut() {
4287                let r: f32 = rng.sample(StandardNormal);
4288                let r = r.clamp(-2.0, 2.0) / 2.0;
4289                *y = y.saturating_add((r * 1e-2 / quant.0) as i8);
4290            }
4291
4292            decoder
4293                .decode_tracked_quantized(
4294                    &mut tracker,
4295                    100_000_000 * i / 3, // simulate 33.333ms between frames
4296                    &[out.view().into()],
4297                    &mut output_boxes,
4298                    &mut output_masks,
4299                    &mut output_tracks,
4300                )
4301                .unwrap();
4302
4303            assert_eq!(output_boxes.len(), 2);
4304            assert!(output_boxes[0].equal_within_delta(&expected_boxes[0], 5e-3));
4305            assert!(output_boxes[1].equal_within_delta(&expected_boxes[1], 5e-3));
4306
4307            assert!(output_boxes[0].equal_within_delta(&last_boxes[0], 1e-3));
4308            assert!(output_boxes[1].equal_within_delta(&last_boxes[1], 1e-3));
4309            last_boxes = output_boxes.clone();
4310        }
4311    }
4312
4313    // ─── Shared helpers for tracked decoder tests ────────────────────
4314
4315    fn real_data_expected_boxes() -> [DetectBox; 2] {
4316        [
4317            DetectBox {
4318                bbox: BoundingBox {
4319                    xmin: 0.08515105,
4320                    ymin: 0.7131401,
4321                    xmax: 0.29802868,
4322                    ymax: 0.8195788,
4323                },
4324                score: 0.91537374,
4325                label: 23,
4326            },
4327            DetectBox {
4328                bbox: BoundingBox {
4329                    xmin: 0.59605736,
4330                    ymin: 0.25545314,
4331                    xmax: 0.93666154,
4332                    ymax: 0.72378385,
4333                },
4334                score: 0.91537374,
4335                label: 23,
4336            },
4337        ]
4338    }
4339
4340    fn e2e_expected_boxes_quant() -> [DetectBox; 1] {
4341        [DetectBox {
4342            bbox: BoundingBox {
4343                xmin: 0.12549022,
4344                ymin: 0.12549022,
4345                xmax: 0.23529413,
4346                ymax: 0.23529413,
4347            },
4348            score: 0.98823535,
4349            label: 2,
4350        }]
4351    }
4352
4353    fn e2e_expected_boxes_float() -> [DetectBox; 1] {
4354        [DetectBox {
4355            bbox: BoundingBox {
4356                xmin: 0.1234,
4357                ymin: 0.1234,
4358                xmax: 0.2345,
4359                ymax: 0.2345,
4360            },
4361            score: 0.9876,
4362            label: 2,
4363        }]
4364    }
4365
4366    fn build_yolo_split_segdet_decoder(
4367        score_threshold: f32,
4368        iou_threshold: f32,
4369        quant_boxes: (f32, i32),
4370        quant_protos: (f32, i32),
4371    ) -> crate::Decoder {
4372        DecoderBuilder::default()
4373            .with_config_yolo_split_segdet(
4374                configs::Boxes {
4375                    decoder: configs::DecoderType::Ultralytics,
4376                    quantization: Some(quant_boxes.into()),
4377                    shape: vec![1, 4, 8400],
4378                    dshape: vec![
4379                        (DimName::Batch, 1),
4380                        (DimName::BoxCoords, 4),
4381                        (DimName::NumBoxes, 8400),
4382                    ],
4383                    normalized: Some(true),
4384                },
4385                configs::Scores {
4386                    decoder: configs::DecoderType::Ultralytics,
4387                    quantization: Some(quant_boxes.into()),
4388                    shape: vec![1, 80, 8400],
4389                    dshape: vec![
4390                        (DimName::Batch, 1),
4391                        (DimName::NumClasses, 80),
4392                        (DimName::NumBoxes, 8400),
4393                    ],
4394                },
4395                configs::MaskCoefficients {
4396                    decoder: configs::DecoderType::Ultralytics,
4397                    quantization: Some(quant_boxes.into()),
4398                    shape: vec![1, 32, 8400],
4399                    dshape: vec![
4400                        (DimName::Batch, 1),
4401                        (DimName::NumProtos, 32),
4402                        (DimName::NumBoxes, 8400),
4403                    ],
4404                },
4405                configs::Protos {
4406                    decoder: configs::DecoderType::Ultralytics,
4407                    quantization: Some(quant_protos.into()),
4408                    shape: vec![1, 160, 160, 32],
4409                    dshape: vec![
4410                        (DimName::Batch, 1),
4411                        (DimName::Height, 160),
4412                        (DimName::Width, 160),
4413                        (DimName::NumProtos, 32),
4414                    ],
4415                },
4416            )
4417            .with_score_threshold(score_threshold)
4418            .with_iou_threshold(iou_threshold)
4419            .build()
4420            .unwrap()
4421    }
4422
4423    fn build_yolov8_seg_decoder(score_threshold: f32, iou_threshold: f32) -> crate::Decoder {
4424        let config_yaml = edgefirst_bench::testdata::read_to_string("yolov8_seg.yaml");
4425        DecoderBuilder::default()
4426            .with_config_yaml_str(config_yaml.to_string())
4427            .with_score_threshold(score_threshold)
4428            .with_iou_threshold(iou_threshold)
4429            .build()
4430            .unwrap()
4431    }
4432
4433    // ─── Real-data tracked test macro ───────────────────────────────
4434    //
4435    // Generates tests that load i8 binary test data from testdata/ and
4436    // exercise all (quant/float) × (combined/split) × (masks/proto)
4437    // decoder paths.
4438
4439    macro_rules! real_data_tracked_test {
4440        ($name:ident, quantized, $layout:ident, $output:ident) => {
4441            #[test]
4442            fn $name() {
4443                let is_split = matches!(stringify!($layout), "split");
4444                let is_proto = matches!(stringify!($output), "proto");
4445
4446                let score_threshold = 0.45;
4447                let iou_threshold = 0.45;
4448                let quant_boxes = (0.021287762_f32, 31_i32);
4449                let quant_protos = (0.02491162_f32, -117_i32);
4450
4451                let raw_boxes = edgefirst_bench::testdata::read("yolov8_boxes_116x8400.bin");
4452                let raw_boxes = unsafe {
4453                    std::slice::from_raw_parts(raw_boxes.as_ptr() as *const i8, raw_boxes.len())
4454                };
4455                let boxes_i8 =
4456                    ndarray::Array3::from_shape_vec((1, 116, 8400), raw_boxes.to_vec()).unwrap();
4457
4458                let raw_protos = edgefirst_bench::testdata::read("yolov8_protos_160x160x32.bin");
4459                let raw_protos = unsafe {
4460                    std::slice::from_raw_parts(raw_protos.as_ptr() as *const i8, raw_protos.len())
4461                };
4462                let protos_i8 =
4463                    ndarray::Array4::from_shape_vec((1, 160, 160, 32), raw_protos.to_vec())
4464                        .unwrap();
4465
4466                // Pre-split (unused for combined, but harmless)
4467                let mask_split = boxes_i8.slice(s![.., 84.., ..]).to_owned();
4468                let mut scores_split = boxes_i8.slice(s![.., 4..84, ..]).to_owned();
4469                let boxes_split = boxes_i8.slice(s![.., ..4, ..]).to_owned();
4470                let mut boxes_combined = boxes_i8;
4471
4472                let decoder = if is_split {
4473                    build_yolo_split_segdet_decoder(
4474                        score_threshold,
4475                        iou_threshold,
4476                        quant_boxes,
4477                        quant_protos,
4478                    )
4479                } else {
4480                    build_yolov8_seg_decoder(score_threshold, iou_threshold)
4481                };
4482
4483                let expected = real_data_expected_boxes();
4484                let mut tracker = ByteTrackBuilder::new()
4485                    .track_update(0.1)
4486                    .track_high_conf(0.7)
4487                    .build();
4488                let mut output_boxes = Vec::with_capacity(50);
4489                let mut output_tracks = Vec::with_capacity(50);
4490
4491                // Frame 1: decode
4492                if is_proto {
4493                    {
4494                        let inputs: Vec<crate::decoder::ArrayViewDQuantized<'_>> = if is_split {
4495                            vec![
4496                                boxes_split.view().into(),
4497                                scores_split.view().into(),
4498                                mask_split.view().into(),
4499                                protos_i8.view().into(),
4500                            ]
4501                        } else {
4502                            vec![boxes_combined.view().into(), protos_i8.view().into()]
4503                        };
4504                        decoder
4505                            .decode_tracked_quantized_proto(
4506                                &mut tracker,
4507                                0,
4508                                &inputs,
4509                                &mut output_boxes,
4510                                &mut output_tracks,
4511                            )
4512                            .unwrap();
4513                    }
4514                    assert_eq!(output_boxes.len(), 2);
4515                    assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
4516                    assert!(output_boxes[1].equal_within_delta(&expected[1], 1.0 / 160.0));
4517
4518                    // Zero scores for frame 2
4519                    if is_split {
4520                        for score in scores_split.iter_mut() {
4521                            *score = i8::MIN;
4522                        }
4523                    } else {
4524                        for score in boxes_combined.slice_mut(s![0, 4..84, ..]).iter_mut() {
4525                            *score = i8::MIN;
4526                        }
4527                    }
4528
4529                    let proto_result = {
4530                        let inputs: Vec<crate::decoder::ArrayViewDQuantized<'_>> = if is_split {
4531                            vec![
4532                                boxes_split.view().into(),
4533                                scores_split.view().into(),
4534                                mask_split.view().into(),
4535                                protos_i8.view().into(),
4536                            ]
4537                        } else {
4538                            vec![boxes_combined.view().into(), protos_i8.view().into()]
4539                        };
4540                        decoder
4541                            .decode_tracked_quantized_proto(
4542                                &mut tracker,
4543                                100_000_000 / 3,
4544                                &inputs,
4545                                &mut output_boxes,
4546                                &mut output_tracks,
4547                            )
4548                            .unwrap()
4549                    };
4550                    assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
4551                    assert!(output_boxes[1].equal_within_delta(&expected[1], 1e-6));
4552                    assert!(proto_result.is_some_and(|x| x.mask_coefficients.shape()[0] == 0));
4553                } else {
4554                    let mut output_masks = Vec::with_capacity(50);
4555                    {
4556                        let inputs: Vec<crate::decoder::ArrayViewDQuantized<'_>> = if is_split {
4557                            vec![
4558                                boxes_split.view().into(),
4559                                scores_split.view().into(),
4560                                mask_split.view().into(),
4561                                protos_i8.view().into(),
4562                            ]
4563                        } else {
4564                            vec![boxes_combined.view().into(), protos_i8.view().into()]
4565                        };
4566                        decoder
4567                            .decode_tracked_quantized(
4568                                &mut tracker,
4569                                0,
4570                                &inputs,
4571                                &mut output_boxes,
4572                                &mut output_masks,
4573                                &mut output_tracks,
4574                            )
4575                            .unwrap();
4576                    }
4577                    assert_eq!(output_boxes.len(), 2);
4578                    assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
4579                    assert!(output_boxes[1].equal_within_delta(&expected[1], 1.0 / 160.0));
4580
4581                    if is_split {
4582                        for score in scores_split.iter_mut() {
4583                            *score = i8::MIN;
4584                        }
4585                    } else {
4586                        for score in boxes_combined.slice_mut(s![0, 4..84, ..]).iter_mut() {
4587                            *score = i8::MIN;
4588                        }
4589                    }
4590
4591                    {
4592                        let inputs: Vec<crate::decoder::ArrayViewDQuantized<'_>> = if is_split {
4593                            vec![
4594                                boxes_split.view().into(),
4595                                scores_split.view().into(),
4596                                mask_split.view().into(),
4597                                protos_i8.view().into(),
4598                            ]
4599                        } else {
4600                            vec![boxes_combined.view().into(), protos_i8.view().into()]
4601                        };
4602                        decoder
4603                            .decode_tracked_quantized(
4604                                &mut tracker,
4605                                100_000_000 / 3,
4606                                &inputs,
4607                                &mut output_boxes,
4608                                &mut output_masks,
4609                                &mut output_tracks,
4610                            )
4611                            .unwrap();
4612                    }
4613                    assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
4614                    assert!(output_boxes[1].equal_within_delta(&expected[1], 1e-6));
4615                    assert!(output_masks.is_empty());
4616                }
4617            }
4618        };
4619        ($name:ident, float, $layout:ident, $output:ident) => {
4620            #[test]
4621            fn $name() {
4622                let is_split = matches!(stringify!($layout), "split");
4623                let is_proto = matches!(stringify!($output), "proto");
4624
4625                let score_threshold = 0.45;
4626                let iou_threshold = 0.45;
4627                let quant_boxes = (0.021287762_f32, 31_i32);
4628                let quant_protos = (0.02491162_f32, -117_i32);
4629
4630                let raw_boxes = edgefirst_bench::testdata::read("yolov8_boxes_116x8400.bin");
4631                let raw_boxes = unsafe {
4632                    std::slice::from_raw_parts(raw_boxes.as_ptr() as *const i8, raw_boxes.len())
4633                };
4634                let boxes_i8 =
4635                    ndarray::Array3::from_shape_vec((1, 116, 8400), raw_boxes.to_vec()).unwrap();
4636                let boxes_f32 = dequantize_ndarray(boxes_i8.view(), quant_boxes.into());
4637
4638                let raw_protos = edgefirst_bench::testdata::read("yolov8_protos_160x160x32.bin");
4639                let raw_protos = unsafe {
4640                    std::slice::from_raw_parts(raw_protos.as_ptr() as *const i8, raw_protos.len())
4641                };
4642                let protos_i8 =
4643                    ndarray::Array4::from_shape_vec((1, 160, 160, 32), raw_protos.to_vec())
4644                        .unwrap();
4645                let protos_f32 = dequantize_ndarray(protos_i8.view(), quant_protos.into());
4646
4647                // Pre-split from dequantized data
4648                let mask_split = boxes_f32.slice(s![.., 84.., ..]).to_owned();
4649                let mut scores_split = boxes_f32.slice(s![.., 4..84, ..]).to_owned();
4650                let boxes_split = boxes_f32.slice(s![.., ..4, ..]).to_owned();
4651                let mut boxes_combined = boxes_f32;
4652
4653                let decoder = if is_split {
4654                    build_yolo_split_segdet_decoder(
4655                        score_threshold,
4656                        iou_threshold,
4657                        quant_boxes,
4658                        quant_protos,
4659                    )
4660                } else {
4661                    build_yolov8_seg_decoder(score_threshold, iou_threshold)
4662                };
4663
4664                let expected = real_data_expected_boxes();
4665                let mut tracker = ByteTrackBuilder::new()
4666                    .track_update(0.1)
4667                    .track_high_conf(0.7)
4668                    .build();
4669                let mut output_boxes = Vec::with_capacity(50);
4670                let mut output_tracks = Vec::with_capacity(50);
4671
4672                if is_proto {
4673                    {
4674                        let inputs = if is_split {
4675                            vec![
4676                                boxes_split.view().into_dyn(),
4677                                scores_split.view().into_dyn(),
4678                                mask_split.view().into_dyn(),
4679                                protos_f32.view().into_dyn(),
4680                            ]
4681                        } else {
4682                            vec![
4683                                boxes_combined.view().into_dyn(),
4684                                protos_f32.view().into_dyn(),
4685                            ]
4686                        };
4687                        decoder
4688                            .decode_tracked_float_proto(
4689                                &mut tracker,
4690                                0,
4691                                &inputs,
4692                                &mut output_boxes,
4693                                &mut output_tracks,
4694                            )
4695                            .unwrap();
4696                    }
4697                    assert_eq!(output_boxes.len(), 2);
4698                    assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
4699                    assert!(output_boxes[1].equal_within_delta(&expected[1], 1.0 / 160.0));
4700
4701                    if is_split {
4702                        for score in scores_split.iter_mut() {
4703                            *score = 0.0;
4704                        }
4705                    } else {
4706                        for score in boxes_combined.slice_mut(s![0, 4..84, ..]).iter_mut() {
4707                            *score = 0.0;
4708                        }
4709                    }
4710
4711                    let proto_result = {
4712                        let inputs = if is_split {
4713                            vec![
4714                                boxes_split.view().into_dyn(),
4715                                scores_split.view().into_dyn(),
4716                                mask_split.view().into_dyn(),
4717                                protos_f32.view().into_dyn(),
4718                            ]
4719                        } else {
4720                            vec![
4721                                boxes_combined.view().into_dyn(),
4722                                protos_f32.view().into_dyn(),
4723                            ]
4724                        };
4725                        decoder
4726                            .decode_tracked_float_proto(
4727                                &mut tracker,
4728                                100_000_000 / 3,
4729                                &inputs,
4730                                &mut output_boxes,
4731                                &mut output_tracks,
4732                            )
4733                            .unwrap()
4734                    };
4735                    assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
4736                    assert!(output_boxes[1].equal_within_delta(&expected[1], 1e-6));
4737                    assert!(proto_result.is_some_and(|x| x.mask_coefficients.shape()[0] == 0));
4738                } else {
4739                    let mut output_masks = Vec::with_capacity(50);
4740                    {
4741                        let inputs = if is_split {
4742                            vec![
4743                                boxes_split.view().into_dyn(),
4744                                scores_split.view().into_dyn(),
4745                                mask_split.view().into_dyn(),
4746                                protos_f32.view().into_dyn(),
4747                            ]
4748                        } else {
4749                            vec![
4750                                boxes_combined.view().into_dyn(),
4751                                protos_f32.view().into_dyn(),
4752                            ]
4753                        };
4754                        decoder
4755                            .decode_tracked_float(
4756                                &mut tracker,
4757                                0,
4758                                &inputs,
4759                                &mut output_boxes,
4760                                &mut output_masks,
4761                                &mut output_tracks,
4762                            )
4763                            .unwrap();
4764                    }
4765                    assert_eq!(output_boxes.len(), 2);
4766                    assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
4767                    assert!(output_boxes[1].equal_within_delta(&expected[1], 1.0 / 160.0));
4768
4769                    if is_split {
4770                        for score in scores_split.iter_mut() {
4771                            *score = 0.0;
4772                        }
4773                    } else {
4774                        for score in boxes_combined.slice_mut(s![0, 4..84, ..]).iter_mut() {
4775                            *score = 0.0;
4776                        }
4777                    }
4778
4779                    {
4780                        let inputs = if is_split {
4781                            vec![
4782                                boxes_split.view().into_dyn(),
4783                                scores_split.view().into_dyn(),
4784                                mask_split.view().into_dyn(),
4785                                protos_f32.view().into_dyn(),
4786                            ]
4787                        } else {
4788                            vec![
4789                                boxes_combined.view().into_dyn(),
4790                                protos_f32.view().into_dyn(),
4791                            ]
4792                        };
4793                        decoder
4794                            .decode_tracked_float(
4795                                &mut tracker,
4796                                100_000_000 / 3,
4797                                &inputs,
4798                                &mut output_boxes,
4799                                &mut output_masks,
4800                                &mut output_tracks,
4801                            )
4802                            .unwrap();
4803                    }
4804                    assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
4805                    assert!(output_boxes[1].equal_within_delta(&expected[1], 1e-6));
4806                    assert!(output_masks.is_empty());
4807                }
4808            }
4809        };
4810    }
4811
4812    real_data_tracked_test!(test_decoder_tracked_segdet, quantized, combined, masks);
4813    real_data_tracked_test!(test_decoder_tracked_segdet_float, float, combined, masks);
4814    real_data_tracked_test!(
4815        test_decoder_tracked_segdet_proto,
4816        quantized,
4817        combined,
4818        proto
4819    );
4820    real_data_tracked_test!(
4821        test_decoder_tracked_segdet_proto_float,
4822        float,
4823        combined,
4824        proto
4825    );
4826    real_data_tracked_test!(test_decoder_tracked_segdet_split, quantized, split, masks);
4827    real_data_tracked_test!(test_decoder_tracked_segdet_split_float, float, split, masks);
4828    real_data_tracked_test!(
4829        test_decoder_tracked_segdet_split_proto,
4830        quantized,
4831        split,
4832        proto
4833    );
4834    real_data_tracked_test!(
4835        test_decoder_tracked_segdet_split_proto_float,
4836        float,
4837        split,
4838        proto
4839    );
4840
4841    // ─── End-to-end tracked test macro ──────────────────────────────
4842    //
4843    // Generates tests with synthetic data to exercise all tracked
4844    // decode paths without needing real model output files.
4845
4846    const E2E_COMBINED_CONFIG: &str = "
4847decoder_version: yolo26
4848outputs:
4849 - type: detection
4850   decoder: ultralytics
4851   quantization: [0.00784313725490196, 0]
4852   shape: [1, 10, 38]
4853   dshape:
4854    - [batch, 1]
4855    - [num_boxes, 10]
4856    - [num_features, 38]
4857   normalized: true
4858 - type: protos
4859   decoder: ultralytics
4860   quantization: [0.0039215686274509803921568627451, 128]
4861   shape: [1, 160, 160, 32]
4862   dshape:
4863    - [batch, 1]
4864    - [height, 160]
4865    - [width, 160]
4866    - [num_protos, 32]
4867";
4868
4869    const E2E_SPLIT_CONFIG: &str = "
4870decoder_version: yolo26
4871outputs:
4872 - type: boxes
4873   decoder: ultralytics
4874   quantization: [0.00784313725490196, 0]
4875   shape: [1, 10, 4]
4876   dshape:
4877    - [batch, 1]
4878    - [num_boxes, 10]
4879    - [box_coords, 4]
4880   normalized: true
4881 - type: scores
4882   decoder: ultralytics
4883   quantization: [0.00784313725490196, 0]
4884   shape: [1, 10, 1]
4885   dshape:
4886    - [batch, 1]
4887    - [num_boxes, 10]
4888    - [num_classes, 1]
4889 - type: classes
4890   decoder: ultralytics
4891   quantization: [0.00784313725490196, 0]
4892   shape: [1, 10, 1]
4893   dshape:
4894    - [batch, 1]
4895    - [num_boxes, 10]
4896    - [num_classes, 1]
4897 - type: mask_coefficients
4898   decoder: ultralytics
4899   quantization: [0.00784313725490196, 0]
4900   shape: [1, 10, 32]
4901   dshape:
4902    - [batch, 1]
4903    - [num_boxes, 10]
4904    - [num_protos, 32]
4905 - type: protos
4906   decoder: ultralytics
4907   quantization: [0.0039215686274509803921568627451, 128]
4908   shape: [1, 160, 160, 32]
4909   dshape:
4910    - [batch, 1]
4911    - [height, 160]
4912    - [width, 160]
4913    - [num_protos, 32]
4914";
4915
4916    macro_rules! e2e_tracked_test {
4917        ($name:ident, quantized, $layout:ident, $output:ident) => {
4918            #[test]
4919            fn $name() {
4920                let is_split = matches!(stringify!($layout), "split");
4921                let is_proto = matches!(stringify!($output), "proto");
4922
4923                let score_threshold = 0.45;
4924                let iou_threshold = 0.45;
4925
4926                let mut boxes = Array2::zeros((10, 4));
4927                let mut scores = Array2::zeros((10, 1));
4928                let mut classes = Array2::zeros((10, 1));
4929                let mask = Array2::zeros((10, 32));
4930                let protos = Array3::<f64>::zeros((160, 160, 32));
4931                let protos = protos.insert_axis(Axis(0));
4932                let protos_quant = (1.0 / 255.0, 0.0);
4933                let protos: Array4<u8> = quantize_ndarray(protos.view(), protos_quant.into());
4934
4935                boxes
4936                    .slice_mut(s![0, ..])
4937                    .assign(&array![0.1234, 0.1234, 0.2345, 0.2345]);
4938                scores.slice_mut(s![0, ..]).assign(&array![0.9876]);
4939                classes.slice_mut(s![0, ..]).assign(&array![2.0]);
4940
4941                let detect_quant = (2.0 / 255.0, 0.0);
4942
4943                let decoder = if is_split {
4944                    DecoderBuilder::default()
4945                        .with_config_yaml_str(E2E_SPLIT_CONFIG.to_string())
4946                        .with_score_threshold(score_threshold)
4947                        .with_iou_threshold(iou_threshold)
4948                        .build()
4949                        .unwrap()
4950                } else {
4951                    DecoderBuilder::default()
4952                        .with_config_yaml_str(E2E_COMBINED_CONFIG.to_string())
4953                        .with_score_threshold(score_threshold)
4954                        .with_iou_threshold(iou_threshold)
4955                        .build()
4956                        .unwrap()
4957                };
4958
4959                let expected = e2e_expected_boxes_quant();
4960                let mut tracker = ByteTrackBuilder::new()
4961                    .track_update(0.1)
4962                    .track_high_conf(0.7)
4963                    .build();
4964                let mut output_boxes = Vec::with_capacity(50);
4965                let mut output_tracks = Vec::with_capacity(50);
4966
4967                if is_split {
4968                    let boxes = boxes.insert_axis(Axis(0));
4969                    let scores = scores.insert_axis(Axis(0));
4970                    let classes = classes.insert_axis(Axis(0));
4971                    let mask = mask.insert_axis(Axis(0));
4972
4973                    let boxes: Array3<u8> = quantize_ndarray(boxes.view(), detect_quant.into());
4974                    let mut scores: Array3<u8> =
4975                        quantize_ndarray(scores.view(), detect_quant.into());
4976                    let classes: Array3<u8> = quantize_ndarray(classes.view(), detect_quant.into());
4977                    let mask: Array3<u8> = quantize_ndarray(mask.view(), detect_quant.into());
4978
4979                    if is_proto {
4980                        {
4981                            let inputs: Vec<crate::decoder::ArrayViewDQuantized<'_>> = vec![
4982                                boxes.view().into(),
4983                                scores.view().into(),
4984                                classes.view().into(),
4985                                mask.view().into(),
4986                                protos.view().into(),
4987                            ];
4988                            decoder
4989                                .decode_tracked_quantized_proto(
4990                                    &mut tracker,
4991                                    0,
4992                                    &inputs,
4993                                    &mut output_boxes,
4994                                    &mut output_tracks,
4995                                )
4996                                .unwrap();
4997                        }
4998                        assert_eq!(output_boxes.len(), 1);
4999                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
5000
5001                        for score in scores.slice_mut(s![.., .., ..]).iter_mut() {
5002                            *score = u8::MIN;
5003                        }
5004                        let proto_result = {
5005                            let inputs: Vec<crate::decoder::ArrayViewDQuantized<'_>> = vec![
5006                                boxes.view().into(),
5007                                scores.view().into(),
5008                                classes.view().into(),
5009                                mask.view().into(),
5010                                protos.view().into(),
5011                            ];
5012                            decoder
5013                                .decode_tracked_quantized_proto(
5014                                    &mut tracker,
5015                                    100_000_000 / 3,
5016                                    &inputs,
5017                                    &mut output_boxes,
5018                                    &mut output_tracks,
5019                                )
5020                                .unwrap()
5021                        };
5022                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
5023                        assert!(proto_result.is_some_and(|x| x.mask_coefficients.shape()[0] == 0));
5024                    } else {
5025                        let mut output_masks = Vec::with_capacity(50);
5026                        {
5027                            let inputs: Vec<crate::decoder::ArrayViewDQuantized<'_>> = vec![
5028                                boxes.view().into(),
5029                                scores.view().into(),
5030                                classes.view().into(),
5031                                mask.view().into(),
5032                                protos.view().into(),
5033                            ];
5034                            decoder
5035                                .decode_tracked_quantized(
5036                                    &mut tracker,
5037                                    0,
5038                                    &inputs,
5039                                    &mut output_boxes,
5040                                    &mut output_masks,
5041                                    &mut output_tracks,
5042                                )
5043                                .unwrap();
5044                        }
5045                        assert_eq!(output_boxes.len(), 1);
5046                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
5047
5048                        for score in scores.slice_mut(s![.., .., ..]).iter_mut() {
5049                            *score = u8::MIN;
5050                        }
5051                        {
5052                            let inputs: Vec<crate::decoder::ArrayViewDQuantized<'_>> = vec![
5053                                boxes.view().into(),
5054                                scores.view().into(),
5055                                classes.view().into(),
5056                                mask.view().into(),
5057                                protos.view().into(),
5058                            ];
5059                            decoder
5060                                .decode_tracked_quantized(
5061                                    &mut tracker,
5062                                    100_000_000 / 3,
5063                                    &inputs,
5064                                    &mut output_boxes,
5065                                    &mut output_masks,
5066                                    &mut output_tracks,
5067                                )
5068                                .unwrap();
5069                        }
5070                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
5071                        assert!(output_masks.is_empty());
5072                    }
5073                } else {
5074                    // Combined layout
5075                    let detect = ndarray::concatenate![
5076                        Axis(1),
5077                        boxes.view(),
5078                        scores.view(),
5079                        classes.view(),
5080                        mask.view()
5081                    ];
5082                    let detect = detect.insert_axis(Axis(0));
5083                    assert_eq!(detect.shape(), &[1, 10, 38]);
5084                    let mut detect: Array3<u8> =
5085                        quantize_ndarray(detect.view(), detect_quant.into());
5086
5087                    if is_proto {
5088                        {
5089                            let inputs: Vec<crate::decoder::ArrayViewDQuantized<'_>> =
5090                                vec![detect.view().into(), protos.view().into()];
5091                            decoder
5092                                .decode_tracked_quantized_proto(
5093                                    &mut tracker,
5094                                    0,
5095                                    &inputs,
5096                                    &mut output_boxes,
5097                                    &mut output_tracks,
5098                                )
5099                                .unwrap();
5100                        }
5101                        assert_eq!(output_boxes.len(), 1);
5102                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
5103
5104                        for score in detect.slice_mut(s![.., .., 4]).iter_mut() {
5105                            *score = u8::MIN;
5106                        }
5107                        let proto_result = {
5108                            let inputs: Vec<crate::decoder::ArrayViewDQuantized<'_>> =
5109                                vec![detect.view().into(), protos.view().into()];
5110                            decoder
5111                                .decode_tracked_quantized_proto(
5112                                    &mut tracker,
5113                                    100_000_000 / 3,
5114                                    &inputs,
5115                                    &mut output_boxes,
5116                                    &mut output_tracks,
5117                                )
5118                                .unwrap()
5119                        };
5120                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
5121                        assert!(proto_result.is_some_and(|x| x.mask_coefficients.shape()[0] == 0));
5122                    } else {
5123                        let mut output_masks = Vec::with_capacity(50);
5124                        {
5125                            let inputs: Vec<crate::decoder::ArrayViewDQuantized<'_>> =
5126                                vec![detect.view().into(), protos.view().into()];
5127                            decoder
5128                                .decode_tracked_quantized(
5129                                    &mut tracker,
5130                                    0,
5131                                    &inputs,
5132                                    &mut output_boxes,
5133                                    &mut output_masks,
5134                                    &mut output_tracks,
5135                                )
5136                                .unwrap();
5137                        }
5138                        assert_eq!(output_boxes.len(), 1);
5139                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
5140
5141                        for score in detect.slice_mut(s![.., .., 4]).iter_mut() {
5142                            *score = u8::MIN;
5143                        }
5144                        {
5145                            let inputs: Vec<crate::decoder::ArrayViewDQuantized<'_>> =
5146                                vec![detect.view().into(), protos.view().into()];
5147                            decoder
5148                                .decode_tracked_quantized(
5149                                    &mut tracker,
5150                                    100_000_000 / 3,
5151                                    &inputs,
5152                                    &mut output_boxes,
5153                                    &mut output_masks,
5154                                    &mut output_tracks,
5155                                )
5156                                .unwrap();
5157                        }
5158                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
5159                        assert!(output_masks.is_empty());
5160                    }
5161                }
5162            }
5163        };
5164        ($name:ident, float, $layout:ident, $output:ident) => {
5165            #[test]
5166            fn $name() {
5167                let is_split = matches!(stringify!($layout), "split");
5168                let is_proto = matches!(stringify!($output), "proto");
5169
5170                let score_threshold = 0.45;
5171                let iou_threshold = 0.45;
5172
5173                let mut boxes = Array2::zeros((10, 4));
5174                let mut scores = Array2::zeros((10, 1));
5175                let mut classes = Array2::zeros((10, 1));
5176                let mask: Array2<f64> = Array2::zeros((10, 32));
5177                let protos = Array3::<f64>::zeros((160, 160, 32));
5178                let protos = protos.insert_axis(Axis(0));
5179
5180                boxes
5181                    .slice_mut(s![0, ..])
5182                    .assign(&array![0.1234, 0.1234, 0.2345, 0.2345]);
5183                scores.slice_mut(s![0, ..]).assign(&array![0.9876]);
5184                classes.slice_mut(s![0, ..]).assign(&array![2.0]);
5185
5186                let decoder = if is_split {
5187                    DecoderBuilder::default()
5188                        .with_config_yaml_str(E2E_SPLIT_CONFIG.to_string())
5189                        .with_score_threshold(score_threshold)
5190                        .with_iou_threshold(iou_threshold)
5191                        .build()
5192                        .unwrap()
5193                } else {
5194                    DecoderBuilder::default()
5195                        .with_config_yaml_str(E2E_COMBINED_CONFIG.to_string())
5196                        .with_score_threshold(score_threshold)
5197                        .with_iou_threshold(iou_threshold)
5198                        .build()
5199                        .unwrap()
5200                };
5201
5202                let expected = e2e_expected_boxes_float();
5203                let mut tracker = ByteTrackBuilder::new()
5204                    .track_update(0.1)
5205                    .track_high_conf(0.7)
5206                    .build();
5207                let mut output_boxes = Vec::with_capacity(50);
5208                let mut output_tracks = Vec::with_capacity(50);
5209
5210                if is_split {
5211                    let boxes = boxes.insert_axis(Axis(0));
5212                    let mut scores = scores.insert_axis(Axis(0));
5213                    let classes = classes.insert_axis(Axis(0));
5214                    let mask = mask.insert_axis(Axis(0));
5215
5216                    if is_proto {
5217                        {
5218                            let inputs = vec![
5219                                boxes.view().into_dyn(),
5220                                scores.view().into_dyn(),
5221                                classes.view().into_dyn(),
5222                                mask.view().into_dyn(),
5223                                protos.view().into_dyn(),
5224                            ];
5225                            decoder
5226                                .decode_tracked_float_proto(
5227                                    &mut tracker,
5228                                    0,
5229                                    &inputs,
5230                                    &mut output_boxes,
5231                                    &mut output_tracks,
5232                                )
5233                                .unwrap();
5234                        }
5235                        assert_eq!(output_boxes.len(), 1);
5236                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
5237
5238                        for score in scores.slice_mut(s![.., .., ..]).iter_mut() {
5239                            *score = 0.0;
5240                        }
5241                        let proto_result = {
5242                            let inputs = vec![
5243                                boxes.view().into_dyn(),
5244                                scores.view().into_dyn(),
5245                                classes.view().into_dyn(),
5246                                mask.view().into_dyn(),
5247                                protos.view().into_dyn(),
5248                            ];
5249                            decoder
5250                                .decode_tracked_float_proto(
5251                                    &mut tracker,
5252                                    100_000_000 / 3,
5253                                    &inputs,
5254                                    &mut output_boxes,
5255                                    &mut output_tracks,
5256                                )
5257                                .unwrap()
5258                        };
5259                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
5260                        assert!(proto_result.is_some_and(|x| x.mask_coefficients.shape()[0] == 0));
5261                    } else {
5262                        let mut output_masks = Vec::with_capacity(50);
5263                        {
5264                            let inputs = vec![
5265                                boxes.view().into_dyn(),
5266                                scores.view().into_dyn(),
5267                                classes.view().into_dyn(),
5268                                mask.view().into_dyn(),
5269                                protos.view().into_dyn(),
5270                            ];
5271                            decoder
5272                                .decode_tracked_float(
5273                                    &mut tracker,
5274                                    0,
5275                                    &inputs,
5276                                    &mut output_boxes,
5277                                    &mut output_masks,
5278                                    &mut output_tracks,
5279                                )
5280                                .unwrap();
5281                        }
5282                        assert_eq!(output_boxes.len(), 1);
5283                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
5284
5285                        for score in scores.slice_mut(s![.., .., ..]).iter_mut() {
5286                            *score = 0.0;
5287                        }
5288                        {
5289                            let inputs = vec![
5290                                boxes.view().into_dyn(),
5291                                scores.view().into_dyn(),
5292                                classes.view().into_dyn(),
5293                                mask.view().into_dyn(),
5294                                protos.view().into_dyn(),
5295                            ];
5296                            decoder
5297                                .decode_tracked_float(
5298                                    &mut tracker,
5299                                    100_000_000 / 3,
5300                                    &inputs,
5301                                    &mut output_boxes,
5302                                    &mut output_masks,
5303                                    &mut output_tracks,
5304                                )
5305                                .unwrap();
5306                        }
5307                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
5308                        assert!(output_masks.is_empty());
5309                    }
5310                } else {
5311                    // Combined layout
5312                    let detect = ndarray::concatenate![
5313                        Axis(1),
5314                        boxes.view(),
5315                        scores.view(),
5316                        classes.view(),
5317                        mask.view()
5318                    ];
5319                    let mut detect = detect.insert_axis(Axis(0));
5320                    assert_eq!(detect.shape(), &[1, 10, 38]);
5321
5322                    if is_proto {
5323                        {
5324                            let inputs = vec![detect.view().into_dyn(), protos.view().into_dyn()];
5325                            decoder
5326                                .decode_tracked_float_proto(
5327                                    &mut tracker,
5328                                    0,
5329                                    &inputs,
5330                                    &mut output_boxes,
5331                                    &mut output_tracks,
5332                                )
5333                                .unwrap();
5334                        }
5335                        assert_eq!(output_boxes.len(), 1);
5336                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
5337
5338                        for score in detect.slice_mut(s![.., .., 4]).iter_mut() {
5339                            *score = 0.0;
5340                        }
5341                        let proto_result = {
5342                            let inputs = vec![detect.view().into_dyn(), protos.view().into_dyn()];
5343                            decoder
5344                                .decode_tracked_float_proto(
5345                                    &mut tracker,
5346                                    100_000_000 / 3,
5347                                    &inputs,
5348                                    &mut output_boxes,
5349                                    &mut output_tracks,
5350                                )
5351                                .unwrap()
5352                        };
5353                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
5354                        assert!(proto_result.is_some_and(|x| x.mask_coefficients.shape()[0] == 0));
5355                    } else {
5356                        let mut output_masks = Vec::with_capacity(50);
5357                        {
5358                            let inputs = vec![detect.view().into_dyn(), protos.view().into_dyn()];
5359                            decoder
5360                                .decode_tracked_float(
5361                                    &mut tracker,
5362                                    0,
5363                                    &inputs,
5364                                    &mut output_boxes,
5365                                    &mut output_masks,
5366                                    &mut output_tracks,
5367                                )
5368                                .unwrap();
5369                        }
5370                        assert_eq!(output_boxes.len(), 1);
5371                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
5372
5373                        for score in detect.slice_mut(s![.., .., 4]).iter_mut() {
5374                            *score = 0.0;
5375                        }
5376                        {
5377                            let inputs = vec![detect.view().into_dyn(), protos.view().into_dyn()];
5378                            decoder
5379                                .decode_tracked_float(
5380                                    &mut tracker,
5381                                    100_000_000 / 3,
5382                                    &inputs,
5383                                    &mut output_boxes,
5384                                    &mut output_masks,
5385                                    &mut output_tracks,
5386                                )
5387                                .unwrap();
5388                        }
5389                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
5390                        assert!(output_masks.is_empty());
5391                    }
5392                }
5393            }
5394        };
5395    }
5396
5397    e2e_tracked_test!(
5398        test_decoder_tracked_end_to_end_segdet,
5399        quantized,
5400        combined,
5401        masks
5402    );
5403    e2e_tracked_test!(
5404        test_decoder_tracked_end_to_end_segdet_float,
5405        float,
5406        combined,
5407        masks
5408    );
5409    e2e_tracked_test!(
5410        test_decoder_tracked_end_to_end_segdet_proto,
5411        quantized,
5412        combined,
5413        proto
5414    );
5415    e2e_tracked_test!(
5416        test_decoder_tracked_end_to_end_segdet_proto_float,
5417        float,
5418        combined,
5419        proto
5420    );
5421    e2e_tracked_test!(
5422        test_decoder_tracked_end_to_end_segdet_split,
5423        quantized,
5424        split,
5425        masks
5426    );
5427    e2e_tracked_test!(
5428        test_decoder_tracked_end_to_end_segdet_split_float,
5429        float,
5430        split,
5431        masks
5432    );
5433    e2e_tracked_test!(
5434        test_decoder_tracked_end_to_end_segdet_split_proto,
5435        quantized,
5436        split,
5437        proto
5438    );
5439    e2e_tracked_test!(
5440        test_decoder_tracked_end_to_end_segdet_split_proto_float,
5441        float,
5442        split,
5443        proto
5444    );
5445
5446    // ─── End-to-end tracked TensorDyn test macro ────────────────────
5447    //
5448    // Same as e2e_tracked_test but wraps data in TensorDyn and exercises
5449    // the public decode_tracked / decode_proto_tracked API.
5450
5451    macro_rules! e2e_tracked_tensor_test {
5452        ($name:ident, quantized, $layout:ident, $output:ident) => {
5453            #[test]
5454            fn $name() {
5455                use edgefirst_tensor::{Tensor, TensorMapTrait, TensorTrait};
5456
5457                let is_split = matches!(stringify!($layout), "split");
5458                let is_proto = matches!(stringify!($output), "proto");
5459
5460                let score_threshold = 0.45;
5461                let iou_threshold = 0.45;
5462
5463                let mut boxes = Array2::zeros((10, 4));
5464                let mut scores = Array2::zeros((10, 1));
5465                let mut classes = Array2::zeros((10, 1));
5466                let mask = Array2::zeros((10, 32));
5467                let protos_f64 = Array3::<f64>::zeros((160, 160, 32));
5468                let protos_f64 = protos_f64.insert_axis(Axis(0));
5469                let protos_quant = (1.0 / 255.0, 0.0);
5470                let protos_u8: Array4<u8> =
5471                    quantize_ndarray(protos_f64.view(), protos_quant.into());
5472
5473                boxes
5474                    .slice_mut(s![0, ..])
5475                    .assign(&array![0.1234, 0.1234, 0.2345, 0.2345]);
5476                scores.slice_mut(s![0, ..]).assign(&array![0.9876]);
5477                classes.slice_mut(s![0, ..]).assign(&array![2.0]);
5478
5479                let detect_quant = (2.0 / 255.0, 0.0);
5480
5481                let decoder = if is_split {
5482                    DecoderBuilder::default()
5483                        .with_config_yaml_str(E2E_SPLIT_CONFIG.to_string())
5484                        .with_score_threshold(score_threshold)
5485                        .with_iou_threshold(iou_threshold)
5486                        .build()
5487                        .unwrap()
5488                } else {
5489                    DecoderBuilder::default()
5490                        .with_config_yaml_str(E2E_COMBINED_CONFIG.to_string())
5491                        .with_score_threshold(score_threshold)
5492                        .with_iou_threshold(iou_threshold)
5493                        .build()
5494                        .unwrap()
5495                };
5496
5497                // Helper to wrap a u8 slice into a TensorDyn
5498                let make_u8_tensor =
5499                    |shape: &[usize], data: &[u8]| -> edgefirst_tensor::TensorDyn {
5500                        let t = Tensor::<u8>::new(shape, None, None).unwrap();
5501                        t.map().unwrap().as_mut_slice()[..data.len()].copy_from_slice(data);
5502                        t.into()
5503                    };
5504
5505                let expected = e2e_expected_boxes_quant();
5506                let mut tracker = ByteTrackBuilder::new()
5507                    .track_update(0.1)
5508                    .track_high_conf(0.7)
5509                    .build();
5510                let mut output_boxes = Vec::with_capacity(50);
5511                let mut output_tracks = Vec::with_capacity(50);
5512
5513                let protos_td = make_u8_tensor(protos_u8.shape(), protos_u8.as_slice().unwrap());
5514
5515                if is_split {
5516                    let boxes = boxes.insert_axis(Axis(0));
5517                    let scores = scores.insert_axis(Axis(0));
5518                    let classes = classes.insert_axis(Axis(0));
5519                    let mask = mask.insert_axis(Axis(0));
5520
5521                    let boxes_q: Array3<u8> = quantize_ndarray(boxes.view(), detect_quant.into());
5522                    let mut scores_q: Array3<u8> =
5523                        quantize_ndarray(scores.view(), detect_quant.into());
5524                    let classes_q: Array3<u8> =
5525                        quantize_ndarray(classes.view(), detect_quant.into());
5526                    let mask_q: Array3<u8> = quantize_ndarray(mask.view(), detect_quant.into());
5527
5528                    let boxes_td = make_u8_tensor(boxes_q.shape(), boxes_q.as_slice().unwrap());
5529                    let classes_td =
5530                        make_u8_tensor(classes_q.shape(), classes_q.as_slice().unwrap());
5531                    let mask_td = make_u8_tensor(mask_q.shape(), mask_q.as_slice().unwrap());
5532
5533                    if is_proto {
5534                        let scores_td =
5535                            make_u8_tensor(scores_q.shape(), scores_q.as_slice().unwrap());
5536                        decoder
5537                            .decode_proto_tracked(
5538                                &mut tracker,
5539                                0,
5540                                &[&boxes_td, &scores_td, &classes_td, &mask_td, &protos_td],
5541                                &mut output_boxes,
5542                                &mut output_tracks,
5543                            )
5544                            .unwrap();
5545
5546                        assert_eq!(output_boxes.len(), 1);
5547                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
5548
5549                        for score in scores_q.slice_mut(s![.., .., ..]).iter_mut() {
5550                            *score = u8::MIN;
5551                        }
5552                        let scores_td =
5553                            make_u8_tensor(scores_q.shape(), scores_q.as_slice().unwrap());
5554                        let proto_result = decoder
5555                            .decode_proto_tracked(
5556                                &mut tracker,
5557                                100_000_000 / 3,
5558                                &[&boxes_td, &scores_td, &classes_td, &mask_td, &protos_td],
5559                                &mut output_boxes,
5560                                &mut output_tracks,
5561                            )
5562                            .unwrap();
5563                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
5564                        assert!(proto_result.is_some_and(|x| x.mask_coefficients.shape()[0] == 0));
5565                    } else {
5566                        let scores_td =
5567                            make_u8_tensor(scores_q.shape(), scores_q.as_slice().unwrap());
5568                        let mut output_masks = Vec::with_capacity(50);
5569                        decoder
5570                            .decode_tracked(
5571                                &mut tracker,
5572                                0,
5573                                &[&boxes_td, &scores_td, &classes_td, &mask_td, &protos_td],
5574                                &mut output_boxes,
5575                                &mut output_masks,
5576                                &mut output_tracks,
5577                            )
5578                            .unwrap();
5579
5580                        assert_eq!(output_boxes.len(), 1);
5581                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
5582
5583                        for score in scores_q.slice_mut(s![.., .., ..]).iter_mut() {
5584                            *score = u8::MIN;
5585                        }
5586                        let scores_td =
5587                            make_u8_tensor(scores_q.shape(), scores_q.as_slice().unwrap());
5588                        decoder
5589                            .decode_tracked(
5590                                &mut tracker,
5591                                100_000_000 / 3,
5592                                &[&boxes_td, &scores_td, &classes_td, &mask_td, &protos_td],
5593                                &mut output_boxes,
5594                                &mut output_masks,
5595                                &mut output_tracks,
5596                            )
5597                            .unwrap();
5598                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
5599                        assert!(output_masks.is_empty());
5600                    }
5601                } else {
5602                    // Combined layout
5603                    let detect = ndarray::concatenate![
5604                        Axis(1),
5605                        boxes.view(),
5606                        scores.view(),
5607                        classes.view(),
5608                        mask.view()
5609                    ];
5610                    let detect = detect.insert_axis(Axis(0));
5611                    assert_eq!(detect.shape(), &[1, 10, 38]);
5612                    // Ensure contiguous layout after concatenation for as_slice()
5613                    let detect =
5614                        Array3::from_shape_vec(detect.raw_dim(), detect.iter().copied().collect())
5615                            .unwrap();
5616                    let mut detect_q: Array3<u8> =
5617                        quantize_ndarray(detect.view(), detect_quant.into());
5618
5619                    if is_proto {
5620                        let detect_td =
5621                            make_u8_tensor(detect_q.shape(), detect_q.as_slice().unwrap());
5622                        decoder
5623                            .decode_proto_tracked(
5624                                &mut tracker,
5625                                0,
5626                                &[&detect_td, &protos_td],
5627                                &mut output_boxes,
5628                                &mut output_tracks,
5629                            )
5630                            .unwrap();
5631
5632                        assert_eq!(output_boxes.len(), 1);
5633                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
5634
5635                        for score in detect_q.slice_mut(s![.., .., 4]).iter_mut() {
5636                            *score = u8::MIN;
5637                        }
5638                        let detect_td =
5639                            make_u8_tensor(detect_q.shape(), detect_q.as_slice().unwrap());
5640                        let proto_result = decoder
5641                            .decode_proto_tracked(
5642                                &mut tracker,
5643                                100_000_000 / 3,
5644                                &[&detect_td, &protos_td],
5645                                &mut output_boxes,
5646                                &mut output_tracks,
5647                            )
5648                            .unwrap();
5649                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
5650                        assert!(proto_result.is_some_and(|x| x.mask_coefficients.shape()[0] == 0));
5651                    } else {
5652                        let detect_td =
5653                            make_u8_tensor(detect_q.shape(), detect_q.as_slice().unwrap());
5654                        let mut output_masks = Vec::with_capacity(50);
5655                        decoder
5656                            .decode_tracked(
5657                                &mut tracker,
5658                                0,
5659                                &[&detect_td, &protos_td],
5660                                &mut output_boxes,
5661                                &mut output_masks,
5662                                &mut output_tracks,
5663                            )
5664                            .unwrap();
5665
5666                        assert_eq!(output_boxes.len(), 1);
5667                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
5668
5669                        for score in detect_q.slice_mut(s![.., .., 4]).iter_mut() {
5670                            *score = u8::MIN;
5671                        }
5672                        let detect_td =
5673                            make_u8_tensor(detect_q.shape(), detect_q.as_slice().unwrap());
5674                        decoder
5675                            .decode_tracked(
5676                                &mut tracker,
5677                                100_000_000 / 3,
5678                                &[&detect_td, &protos_td],
5679                                &mut output_boxes,
5680                                &mut output_masks,
5681                                &mut output_tracks,
5682                            )
5683                            .unwrap();
5684                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
5685                        assert!(output_masks.is_empty());
5686                    }
5687                }
5688            }
5689        };
5690        ($name:ident, float, $layout:ident, $output:ident) => {
5691            #[test]
5692            fn $name() {
5693                use edgefirst_tensor::{Tensor, TensorMapTrait, TensorTrait};
5694
5695                let is_split = matches!(stringify!($layout), "split");
5696                let is_proto = matches!(stringify!($output), "proto");
5697
5698                let score_threshold = 0.45;
5699                let iou_threshold = 0.45;
5700
5701                let mut boxes = Array2::zeros((10, 4));
5702                let mut scores = Array2::zeros((10, 1));
5703                let mut classes = Array2::zeros((10, 1));
5704                let mask: Array2<f64> = Array2::zeros((10, 32));
5705                let protos = Array3::<f64>::zeros((160, 160, 32));
5706                let protos = protos.insert_axis(Axis(0));
5707
5708                boxes
5709                    .slice_mut(s![0, ..])
5710                    .assign(&array![0.1234, 0.1234, 0.2345, 0.2345]);
5711                scores.slice_mut(s![0, ..]).assign(&array![0.9876]);
5712                classes.slice_mut(s![0, ..]).assign(&array![2.0]);
5713
5714                let decoder = if is_split {
5715                    DecoderBuilder::default()
5716                        .with_config_yaml_str(E2E_SPLIT_CONFIG.to_string())
5717                        .with_score_threshold(score_threshold)
5718                        .with_iou_threshold(iou_threshold)
5719                        .build()
5720                        .unwrap()
5721                } else {
5722                    DecoderBuilder::default()
5723                        .with_config_yaml_str(E2E_COMBINED_CONFIG.to_string())
5724                        .with_score_threshold(score_threshold)
5725                        .with_iou_threshold(iou_threshold)
5726                        .build()
5727                        .unwrap()
5728                };
5729
5730                // Helper to wrap an f64 slice into a TensorDyn
5731                let make_f64_tensor =
5732                    |shape: &[usize], data: &[f64]| -> edgefirst_tensor::TensorDyn {
5733                        let t = Tensor::<f64>::new(shape, None, None).unwrap();
5734                        t.map().unwrap().as_mut_slice()[..data.len()].copy_from_slice(data);
5735                        t.into()
5736                    };
5737
5738                let expected = e2e_expected_boxes_float();
5739                let mut tracker = ByteTrackBuilder::new()
5740                    .track_update(0.1)
5741                    .track_high_conf(0.7)
5742                    .build();
5743                let mut output_boxes = Vec::with_capacity(50);
5744                let mut output_tracks = Vec::with_capacity(50);
5745
5746                let protos_td = make_f64_tensor(protos.shape(), protos.as_slice().unwrap());
5747
5748                if is_split {
5749                    let boxes = boxes.insert_axis(Axis(0));
5750                    let mut scores = scores.insert_axis(Axis(0));
5751                    let classes = classes.insert_axis(Axis(0));
5752                    let mask = mask.insert_axis(Axis(0));
5753
5754                    let boxes_td = make_f64_tensor(boxes.shape(), boxes.as_slice().unwrap());
5755                    let classes_td = make_f64_tensor(classes.shape(), classes.as_slice().unwrap());
5756                    let mask_td = make_f64_tensor(mask.shape(), mask.as_slice().unwrap());
5757
5758                    if is_proto {
5759                        let scores_td = make_f64_tensor(scores.shape(), scores.as_slice().unwrap());
5760                        decoder
5761                            .decode_proto_tracked(
5762                                &mut tracker,
5763                                0,
5764                                &[&boxes_td, &scores_td, &classes_td, &mask_td, &protos_td],
5765                                &mut output_boxes,
5766                                &mut output_tracks,
5767                            )
5768                            .unwrap();
5769
5770                        assert_eq!(output_boxes.len(), 1);
5771                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
5772
5773                        for score in scores.slice_mut(s![.., .., ..]).iter_mut() {
5774                            *score = 0.0;
5775                        }
5776                        let scores_td = make_f64_tensor(scores.shape(), scores.as_slice().unwrap());
5777                        let proto_result = decoder
5778                            .decode_proto_tracked(
5779                                &mut tracker,
5780                                100_000_000 / 3,
5781                                &[&boxes_td, &scores_td, &classes_td, &mask_td, &protos_td],
5782                                &mut output_boxes,
5783                                &mut output_tracks,
5784                            )
5785                            .unwrap();
5786                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
5787                        assert!(proto_result.is_some_and(|x| x.mask_coefficients.shape()[0] == 0));
5788                    } else {
5789                        let scores_td = make_f64_tensor(scores.shape(), scores.as_slice().unwrap());
5790                        let mut output_masks = Vec::with_capacity(50);
5791                        decoder
5792                            .decode_tracked(
5793                                &mut tracker,
5794                                0,
5795                                &[&boxes_td, &scores_td, &classes_td, &mask_td, &protos_td],
5796                                &mut output_boxes,
5797                                &mut output_masks,
5798                                &mut output_tracks,
5799                            )
5800                            .unwrap();
5801
5802                        assert_eq!(output_boxes.len(), 1);
5803                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
5804
5805                        for score in scores.slice_mut(s![.., .., ..]).iter_mut() {
5806                            *score = 0.0;
5807                        }
5808                        let scores_td = make_f64_tensor(scores.shape(), scores.as_slice().unwrap());
5809                        decoder
5810                            .decode_tracked(
5811                                &mut tracker,
5812                                100_000_000 / 3,
5813                                &[&boxes_td, &scores_td, &classes_td, &mask_td, &protos_td],
5814                                &mut output_boxes,
5815                                &mut output_masks,
5816                                &mut output_tracks,
5817                            )
5818                            .unwrap();
5819                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
5820                        assert!(output_masks.is_empty());
5821                    }
5822                } else {
5823                    // Combined layout
5824                    let detect = ndarray::concatenate![
5825                        Axis(1),
5826                        boxes.view(),
5827                        scores.view(),
5828                        classes.view(),
5829                        mask.view()
5830                    ];
5831                    let detect = detect.insert_axis(Axis(0));
5832                    assert_eq!(detect.shape(), &[1, 10, 38]);
5833                    // Ensure contiguous layout after concatenation for as_slice()
5834                    let mut detect =
5835                        Array3::from_shape_vec(detect.raw_dim(), detect.iter().copied().collect())
5836                            .unwrap();
5837
5838                    if is_proto {
5839                        let detect_td = make_f64_tensor(detect.shape(), detect.as_slice().unwrap());
5840                        decoder
5841                            .decode_proto_tracked(
5842                                &mut tracker,
5843                                0,
5844                                &[&detect_td, &protos_td],
5845                                &mut output_boxes,
5846                                &mut output_tracks,
5847                            )
5848                            .unwrap();
5849
5850                        assert_eq!(output_boxes.len(), 1);
5851                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
5852
5853                        for score in detect.slice_mut(s![.., .., 4]).iter_mut() {
5854                            *score = 0.0;
5855                        }
5856                        let detect_td = make_f64_tensor(detect.shape(), detect.as_slice().unwrap());
5857                        let proto_result = decoder
5858                            .decode_proto_tracked(
5859                                &mut tracker,
5860                                100_000_000 / 3,
5861                                &[&detect_td, &protos_td],
5862                                &mut output_boxes,
5863                                &mut output_tracks,
5864                            )
5865                            .unwrap();
5866                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
5867                        assert!(proto_result.is_some_and(|x| x.mask_coefficients.shape()[0] == 0));
5868                    } else {
5869                        let detect_td = make_f64_tensor(detect.shape(), detect.as_slice().unwrap());
5870                        let mut output_masks = Vec::with_capacity(50);
5871                        decoder
5872                            .decode_tracked(
5873                                &mut tracker,
5874                                0,
5875                                &[&detect_td, &protos_td],
5876                                &mut output_boxes,
5877                                &mut output_masks,
5878                                &mut output_tracks,
5879                            )
5880                            .unwrap();
5881
5882                        assert_eq!(output_boxes.len(), 1);
5883                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1.0 / 160.0));
5884
5885                        for score in detect.slice_mut(s![.., .., 4]).iter_mut() {
5886                            *score = 0.0;
5887                        }
5888                        let detect_td = make_f64_tensor(detect.shape(), detect.as_slice().unwrap());
5889                        decoder
5890                            .decode_tracked(
5891                                &mut tracker,
5892                                100_000_000 / 3,
5893                                &[&detect_td, &protos_td],
5894                                &mut output_boxes,
5895                                &mut output_masks,
5896                                &mut output_tracks,
5897                            )
5898                            .unwrap();
5899                        assert!(output_boxes[0].equal_within_delta(&expected[0], 1e-6));
5900                        assert!(output_masks.is_empty());
5901                    }
5902                }
5903            }
5904        };
5905    }
5906
5907    e2e_tracked_tensor_test!(
5908        test_decoder_tracked_tensor_end_to_end_segdet,
5909        quantized,
5910        combined,
5911        masks
5912    );
5913    e2e_tracked_tensor_test!(
5914        test_decoder_tracked_tensor_end_to_end_segdet_float,
5915        float,
5916        combined,
5917        masks
5918    );
5919    e2e_tracked_tensor_test!(
5920        test_decoder_tracked_tensor_end_to_end_segdet_proto,
5921        quantized,
5922        combined,
5923        proto
5924    );
5925    e2e_tracked_tensor_test!(
5926        test_decoder_tracked_tensor_end_to_end_segdet_proto_float,
5927        float,
5928        combined,
5929        proto
5930    );
5931    e2e_tracked_tensor_test!(
5932        test_decoder_tracked_tensor_end_to_end_segdet_split,
5933        quantized,
5934        split,
5935        masks
5936    );
5937    e2e_tracked_tensor_test!(
5938        test_decoder_tracked_tensor_end_to_end_segdet_split_float,
5939        float,
5940        split,
5941        masks
5942    );
5943    e2e_tracked_tensor_test!(
5944        test_decoder_tracked_tensor_end_to_end_segdet_split_proto,
5945        quantized,
5946        split,
5947        proto
5948    );
5949    e2e_tracked_tensor_test!(
5950        test_decoder_tracked_tensor_end_to_end_segdet_split_proto_float,
5951        float,
5952        split,
5953        proto
5954    );
5955
5956    #[test]
5957    fn test_decoder_tracked_linear_motion() {
5958        use crate::configs::{DecoderType, Nms};
5959        use crate::DecoderBuilder;
5960
5961        let score_threshold = 0.25;
5962        let iou_threshold = 0.1;
5963        let out = edgefirst_bench::testdata::read("yolov8s_80_classes.bin");
5964        let out = unsafe { std::slice::from_raw_parts(out.as_ptr() as *const i8, out.len()) };
5965        let mut out = Array3::from_shape_vec((1, 84, 8400), out.to_vec()).unwrap();
5966        let quant = (0.0040811873, -123).into();
5967
5968        let decoder = DecoderBuilder::default()
5969            .with_config_yolo_det(
5970                crate::configs::Detection {
5971                    decoder: DecoderType::Ultralytics,
5972                    shape: vec![1, 84, 8400],
5973                    anchors: None,
5974                    quantization: Some(quant),
5975                    dshape: vec![
5976                        (crate::configs::DimName::Batch, 1),
5977                        (crate::configs::DimName::NumFeatures, 84),
5978                        (crate::configs::DimName::NumBoxes, 8400),
5979                    ],
5980                    normalized: Some(true),
5981                },
5982                None,
5983            )
5984            .with_score_threshold(score_threshold)
5985            .with_iou_threshold(iou_threshold)
5986            .with_nms(Some(Nms::ClassAgnostic))
5987            .build()
5988            .unwrap();
5989
5990        let mut expected_boxes = [
5991            DetectBox {
5992                bbox: BoundingBox {
5993                    xmin: 0.5285137,
5994                    ymin: 0.05305544,
5995                    xmax: 0.87541467,
5996                    ymax: 0.9998909,
5997                },
5998                score: 0.5591227,
5999                label: 0,
6000            },
6001            DetectBox {
6002                bbox: BoundingBox {
6003                    xmin: 0.130598,
6004                    ymin: 0.43260583,
6005                    xmax: 0.35098213,
6006                    ymax: 0.9958097,
6007                },
6008                score: 0.33057618,
6009                label: 75,
6010            },
6011        ];
6012
6013        let mut tracker = ByteTrackBuilder::new()
6014            .track_update(0.1)
6015            .track_high_conf(0.3)
6016            .build();
6017
6018        let mut output_boxes = Vec::with_capacity(50);
6019        let mut output_masks = Vec::with_capacity(50);
6020        let mut output_tracks = Vec::with_capacity(50);
6021
6022        decoder
6023            .decode_tracked_quantized(
6024                &mut tracker,
6025                0,
6026                &[out.view().into()],
6027                &mut output_boxes,
6028                &mut output_masks,
6029                &mut output_tracks,
6030            )
6031            .unwrap();
6032
6033        assert_eq!(output_boxes.len(), 2);
6034        assert!(output_boxes[0].equal_within_delta(&expected_boxes[0], 1e-6));
6035        assert!(output_boxes[1].equal_within_delta(&expected_boxes[1], 1e-6));
6036
6037        for i in 1..=100 {
6038            let mut out = out.clone();
6039            // introduce linear movement into the XY coordinates
6040            let mut x_values = out.slice_mut(s![0, 0, ..]);
6041            for x in x_values.iter_mut() {
6042                *x = x.saturating_add((i as f32 * 1e-3 / quant.0).round() as i8);
6043            }
6044
6045            decoder
6046                .decode_tracked_quantized(
6047                    &mut tracker,
6048                    100_000_000 * i / 3, // simulate 33.333ms between frames
6049                    &[out.view().into()],
6050                    &mut output_boxes,
6051                    &mut output_masks,
6052                    &mut output_tracks,
6053                )
6054                .unwrap();
6055
6056            assert_eq!(output_boxes.len(), 2);
6057        }
6058        let tracks = tracker.get_active_tracks();
6059        let predicted_boxes: Vec<_> = tracks
6060            .iter()
6061            .map(|track| {
6062                let mut l = track.last_box;
6063                l.bbox = track.info.tracked_location.into();
6064                l
6065            })
6066            .collect();
6067        expected_boxes[0].bbox.xmin += 0.1; // compensate for linear movement
6068        expected_boxes[0].bbox.xmax += 0.1;
6069        expected_boxes[1].bbox.xmin += 0.1;
6070        expected_boxes[1].bbox.xmax += 0.1;
6071
6072        assert!(predicted_boxes[0].equal_within_delta(&expected_boxes[0], 1e-3));
6073        assert!(predicted_boxes[1].equal_within_delta(&expected_boxes[1], 1e-3));
6074
6075        // give the decoder a final frame with no detections to ensure tracks are properly predicting forward when detection is missing
6076        let mut scores_values = out.slice_mut(s![0, 4.., ..]);
6077        for score in scores_values.iter_mut() {
6078            *score = i8::MIN; // set all scores to minimum to simulate no detections
6079        }
6080        decoder
6081            .decode_tracked_quantized(
6082                &mut tracker,
6083                100_000_000 * 101 / 3,
6084                &[out.view().into()],
6085                &mut output_boxes,
6086                &mut output_masks,
6087                &mut output_tracks,
6088            )
6089            .unwrap();
6090        expected_boxes[0].bbox.xmin += 0.001; // compensate for expected movement
6091        expected_boxes[0].bbox.xmax += 0.001;
6092        expected_boxes[1].bbox.xmin += 0.001;
6093        expected_boxes[1].bbox.xmax += 0.001;
6094
6095        assert!(output_boxes[0].equal_within_delta(&expected_boxes[0], 1e-3));
6096        assert!(output_boxes[1].equal_within_delta(&expected_boxes[1], 1e-3));
6097    }
6098
6099    #[test]
6100    fn test_decoder_tracked_end_to_end_float() {
6101        let score_threshold = 0.45;
6102        let iou_threshold = 0.45;
6103
6104        let mut boxes = Array2::zeros((10, 4));
6105        let mut scores = Array2::zeros((10, 1));
6106        let mut classes = Array2::zeros((10, 1));
6107
6108        boxes
6109            .slice_mut(s![0, ..,])
6110            .assign(&array![0.1234, 0.1234, 0.2345, 0.2345]);
6111        scores.slice_mut(s![0, ..]).assign(&array![0.9876]);
6112        classes.slice_mut(s![0, ..]).assign(&array![2.0]);
6113
6114        let detect = ndarray::concatenate![Axis(1), boxes.view(), scores.view(), classes.view(),];
6115        let mut detect = detect.insert_axis(Axis(0));
6116        assert_eq!(detect.shape(), &[1, 10, 6]);
6117        let config = "
6118decoder_version: yolo26
6119outputs:
6120 - type: detection
6121   decoder: ultralytics
6122   quantization: [0.00784313725490196, 0]
6123   shape: [1, 10, 6]
6124   dshape:
6125    - [batch, 1]
6126    - [num_boxes, 10]
6127    - [num_features, 6]
6128   normalized: true
6129";
6130
6131        let decoder = DecoderBuilder::default()
6132            .with_config_yaml_str(config.to_string())
6133            .with_score_threshold(score_threshold)
6134            .with_iou_threshold(iou_threshold)
6135            .build()
6136            .unwrap();
6137
6138        let expected_boxes = [DetectBox {
6139            bbox: BoundingBox {
6140                xmin: 0.1234,
6141                ymin: 0.1234,
6142                xmax: 0.2345,
6143                ymax: 0.2345,
6144            },
6145            score: 0.9876,
6146            label: 2,
6147        }];
6148
6149        let mut tracker = ByteTrackBuilder::new()
6150            .track_update(0.1)
6151            .track_high_conf(0.7)
6152            .build();
6153
6154        let mut output_boxes = Vec::with_capacity(50);
6155        let mut output_masks = Vec::with_capacity(50);
6156        let mut output_tracks = Vec::with_capacity(50);
6157
6158        decoder
6159            .decode_tracked_float(
6160                &mut tracker,
6161                0,
6162                &[detect.view().into_dyn()],
6163                &mut output_boxes,
6164                &mut output_masks,
6165                &mut output_tracks,
6166            )
6167            .unwrap();
6168
6169        assert_eq!(output_boxes.len(), 1);
6170        assert!(output_boxes[0].equal_within_delta(&expected_boxes[0], 1e-6));
6171
6172        // give the decoder a final frame with no detections to ensure tracks are properly predicting forward when detection is missing
6173
6174        for score in detect.slice_mut(s![.., .., 4]).iter_mut() {
6175            *score = 0.0; // set all scores to minimum to simulate no detections
6176        }
6177
6178        decoder
6179            .decode_tracked_float(
6180                &mut tracker,
6181                100_000_000 / 3,
6182                &[detect.view().into_dyn()],
6183                &mut output_boxes,
6184                &mut output_masks,
6185                &mut output_tracks,
6186            )
6187            .unwrap();
6188        assert!(output_boxes[0].equal_within_delta(&expected_boxes[0], 1e-6));
6189    }
6190}