Skip to main content

edgefirst_decoder/decoder/
mod.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4use ndarray::{ArrayView, ArrayViewD, Dimension};
5use num_traits::{AsPrimitive, Float};
6
7use crate::{DecoderError, DetectBox, ProtoData, Segmentation};
8
9pub mod config;
10pub mod configs;
11
12use configs::ModelType;
13
14#[derive(Debug)]
15pub struct Decoder {
16    model_type: ModelType,
17    pub iou_threshold: f32,
18    pub score_threshold: f32,
19    /// NMS mode (always a concrete variant after build — `Nms::Auto` is
20    /// resolved during `DecoderBuilder::build()` and never stored here):
21    /// - `Some(ClassAgnostic)` — class-agnostic NMS
22    /// - `Some(ClassAware)` — class-aware NMS
23    /// - `None` — NMS bypassed (end-to-end models)
24    ///
25    /// Assigning `Some(Nms::Auto)` to this field directly violates that
26    /// invariant: `Nms::Auto` must be resolved by
27    /// [`DecoderBuilder::build`](crate::DecoderBuilder::build). Debug builds
28    /// panic on it at decode (a `debug_assert!` in NMS dispatch); release
29    /// builds treat it as class-aware without consulting the config.
30    pub nms: Option<configs::Nms>,
31    /// Maximum number of candidate boxes fed into NMS after score filtering.
32    /// Reduces O(N²) NMS cost when many low-confidence proposals pass the
33    /// threshold (common during COCO mAP evaluation with threshold ≈ 0.001).
34    /// Candidates are ranked by score, equal scores by anchor index; only the
35    /// top `pre_nms_top_k` proceed to NMS. Applies to every NMS decode path;
36    /// ignored when `nms` is `None`.
37    ///
38    /// - `None` (the default unless the builder was given a cap): resolved
39    ///   per decode by [`Decoder::pre_nms_top_k_for`] —
40    ///   [`MULTI_LABEL_PRE_NMS_TOP_K`] (30 000, Ultralytics' `max_nms`) for a
41    ///   multi-label decode, [`DEFAULT_PRE_NMS_TOP_K`] (300) for an argmax
42    ///   decode, including the tracking entry points of a multi-label decoder.
43    /// - `Some(n)`: an explicit cap used by every decode as given; `Some(0)`
44    ///   is unbounded. Assigning it after build is as explicit as
45    ///   [`DecoderBuilder::with_pre_nms_top_k`](crate::DecoderBuilder::with_pre_nms_top_k).
46    ///
47    /// # ⚠️ Validation vs Deployment
48    ///
49    /// The default of 300 is tuned for **deployment** (score threshold ≥ 0.25)
50    /// where few anchors pass the score filter, making top-K a no-op in
51    /// practice while bounding worst-case NMS cost.
52    ///
53    /// For **mAP evaluation** (score threshold ≈ 0.001), most of the 8 400
54    /// YOLO anchors pass the score filter. At `pre_nms_top_k = 300`, roughly
55    /// 74 % of candidates that would survive NMS are discarded *before* NMS
56    /// runs, causing **~9 pp box mAP loss** — a measurement artifact, not a
57    /// model quality issue.
58    ///
59    /// | Use case | `pre_nms_top_k` | `score_threshold` |
60    /// |----------|----------------:|------------------:|
61    /// | Deployment | `None` (300) | ≥ 0.25 |
62    /// | COCO mAP evaluation, argmax | `Some(0)`, or ≥ the anchor count (8 400) | 0.001 |
63    /// | COCO mAP evaluation, multi-label | `Some(0)`, or `None` (30 000) | 0.001 |
64    ///
65    /// Post-processing latency scales with the number of candidates entering
66    /// NMS. At deployment thresholds the candidate count is already small, so
67    /// raising `pre_nms_top_k` has negligible cost. At validation thresholds
68    /// the increase is measurable but necessary for correct recall.
69    pub pre_nms_top_k: Option<usize>,
70    /// Maximum number of detections returned after NMS. Matches the
71    /// Ultralytics `max_det` parameter.  Default: 300.
72    ///
73    /// This bound applies uniformly across all segmentation and detection
74    /// decode paths reached via [`Decoder::decode`] / [`Decoder::decode_proto`].
75    /// The output `Vec`'s capacity is only an allocation hint; the post-NMS
76    /// detection count is bounded solely by `max_det` (EDGEAI-1302).
77    pub max_det: usize,
78    /// Whether decoded boxes are in normalized [0,1] coordinates.
79    /// - `Some(true)`: Coordinates in [0,1] range
80    /// - `Some(false)`: Pixel coordinates
81    /// - `None`: Unknown, caller must infer (e.g., check if any coordinate >
82    ///   1.0)
83    normalized: Option<bool>,
84    /// Model input spatial dimensions `(width, height)`, captured from
85    /// the schema's `input.shape` / `input.dshape` at builder time.
86    /// Required to honour `normalized: false`: pixel-space box coords
87    /// emitted by the model are divided by these dimensions before NMS
88    /// so the post-NMS bbox is in `[0, 1]`. `None` when no schema input
89    /// spec is available — the legacy >2.0 reject in `protobox` then
90    /// preserves the previous safety net (EDGEAI-1303).
91    input_dims: Option<(usize, usize)>,
92    /// Schema v2 merge program. Present when the decoder was built from
93    /// a [`crate::schema::SchemaV2`] whose logical outputs carry
94    /// physical children. Absent for flat configurations (v1 and
95    /// flat-v2).
96    pub(crate) decode_program: Option<merge::DecodeProgram>,
97    /// When `true`, emit one detection per (anchor, class) pair for every
98    /// class whose score meets the threshold — matching Ultralytics `val`
99    /// multi-label decode.  **OFF by default.**
100    ///
101    /// # Deployment safety
102    /// `Decoder::decode_tracked` ignores this and decodes one label per
103    /// box: ByteTrack matches on IoU only, so per-class duplicates of one
104    /// anchor would spawn phantom tracks.
105    pub(crate) multi_label: bool,
106    /// Where `multi_label` came from; named in the tracked-decode warning.
107    pub(crate) multi_label_source: MultiLabelSource,
108    /// Set once this decoder has logged that tracked decode ignores
109    /// multi-label, so each decoder warns once.
110    tracked_multi_label_warned: std::sync::atomic::AtomicBool,
111    /// Per-scale fast path. Constructed at build time from a schema-v2
112    /// document with per-scale children. Wrapped in `Mutex` because
113    /// `Decoder::decode_proto` and `Decoder::decode` are `&self` but
114    /// the per-scale buffers are mutated per-frame.
115    pub(crate) per_scale: Option<std::sync::Mutex<crate::per_scale::PerScaleDecoder>>,
116}
117
118/// Where a decoder's multi-label setting came from.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
120pub(crate) enum MultiLabelSource {
121    /// Neither the caller nor the model config set it.
122    #[default]
123    Default,
124    /// Declared by the model config's `nms_multi_label`.
125    Metadata,
126    /// Set by the caller through [`DecoderBuilder::with_multi_label`].
127    Explicit,
128}
129
130impl PartialEq for Decoder {
131    fn eq(&self, other: &Self) -> bool {
132        // DecodeProgram and PerScaleDecoder have non-comparable embedded
133        // data; compare by the config-derived fields only.
134        self.model_type == other.model_type
135            && self.iou_threshold == other.iou_threshold
136            && self.score_threshold == other.score_threshold
137            && self.nms == other.nms
138            && self.pre_nms_top_k == other.pre_nms_top_k
139            && self.max_det == other.max_det
140            && self.normalized == other.normalized
141            && self.input_dims == other.input_dims
142            && self.multi_label == other.multi_label
143            && self.decode_program.is_some() == other.decode_program.is_some()
144            && self.per_scale.is_some() == other.per_scale.is_some()
145    }
146}
147
148impl Clone for Decoder {
149    /// Cloning a `Decoder` preserves the legacy decode path
150    /// (`decode_program`) but drops the per-scale fast path:
151    /// `PerScaleDecoder` owns mutable per-frame scratch buffers and is
152    /// not `Clone`. Decoders built from a per-scale schema should be
153    /// rebuilt via [`DecoderBuilder`] rather than cloned to preserve the
154    /// fast path; cloning is intended for tests and rare configs.
155    fn clone(&self) -> Self {
156        Self {
157            model_type: self.model_type.clone(),
158            iou_threshold: self.iou_threshold,
159            score_threshold: self.score_threshold,
160            nms: self.nms,
161            pre_nms_top_k: self.pre_nms_top_k,
162            max_det: self.max_det,
163            normalized: self.normalized,
164            input_dims: self.input_dims,
165            multi_label: self.multi_label,
166            multi_label_source: self.multi_label_source,
167            tracked_multi_label_warned: std::sync::atomic::AtomicBool::new(false),
168            decode_program: self.decode_program.clone(),
169            per_scale: None,
170        }
171    }
172}
173
174#[derive(Debug)]
175pub(crate) enum ArrayViewDQuantized<'a> {
176    UInt8(ArrayViewD<'a, u8>),
177    Int8(ArrayViewD<'a, i8>),
178    UInt16(ArrayViewD<'a, u16>),
179    Int16(ArrayViewD<'a, i16>),
180    UInt32(ArrayViewD<'a, u32>),
181    Int32(ArrayViewD<'a, i32>),
182}
183
184impl<'a, D> From<ArrayView<'a, u8, D>> for ArrayViewDQuantized<'a>
185where
186    D: Dimension,
187{
188    fn from(arr: ArrayView<'a, u8, D>) -> Self {
189        Self::UInt8(arr.into_dyn())
190    }
191}
192
193impl<'a, D> From<ArrayView<'a, i8, D>> for ArrayViewDQuantized<'a>
194where
195    D: Dimension,
196{
197    fn from(arr: ArrayView<'a, i8, D>) -> Self {
198        Self::Int8(arr.into_dyn())
199    }
200}
201
202impl<'a, D> From<ArrayView<'a, u16, D>> for ArrayViewDQuantized<'a>
203where
204    D: Dimension,
205{
206    fn from(arr: ArrayView<'a, u16, D>) -> Self {
207        Self::UInt16(arr.into_dyn())
208    }
209}
210
211impl<'a, D> From<ArrayView<'a, i16, D>> for ArrayViewDQuantized<'a>
212where
213    D: Dimension,
214{
215    fn from(arr: ArrayView<'a, i16, D>) -> Self {
216        Self::Int16(arr.into_dyn())
217    }
218}
219
220impl<'a, D> From<ArrayView<'a, u32, D>> for ArrayViewDQuantized<'a>
221where
222    D: Dimension,
223{
224    fn from(arr: ArrayView<'a, u32, D>) -> Self {
225        Self::UInt32(arr.into_dyn())
226    }
227}
228
229impl<'a, D> From<ArrayView<'a, i32, D>> for ArrayViewDQuantized<'a>
230where
231    D: Dimension,
232{
233    fn from(arr: ArrayView<'a, i32, D>) -> Self {
234        Self::Int32(arr.into_dyn())
235    }
236}
237
238impl<'a> ArrayViewDQuantized<'a> {
239    /// Returns the shape of the underlying array.
240    pub(crate) fn shape(&self) -> &[usize] {
241        match self {
242            ArrayViewDQuantized::UInt8(a) => a.shape(),
243            ArrayViewDQuantized::Int8(a) => a.shape(),
244            ArrayViewDQuantized::UInt16(a) => a.shape(),
245            ArrayViewDQuantized::Int16(a) => a.shape(),
246            ArrayViewDQuantized::UInt32(a) => a.shape(),
247            ArrayViewDQuantized::Int32(a) => a.shape(),
248        }
249    }
250}
251
252/// WARNING: Do NOT nest `with_quantized!` calls. Each level multiplies
253/// monomorphized code paths by 6 (one per integer variant), so nesting
254/// N levels deep produces 6^N instantiations.
255///
256/// Instead, dequantize each tensor sequentially with `dequant_3d!`/`dequant_4d!`
257/// (6*N paths) or split into independent phases that each nest at most 2 levels.
258macro_rules! with_quantized {
259    ($x:expr, $var:ident, $body:expr) => {
260        match $x {
261            ArrayViewDQuantized::UInt8(x) => {
262                let $var = x;
263                $body
264            }
265            ArrayViewDQuantized::Int8(x) => {
266                let $var = x;
267                $body
268            }
269            ArrayViewDQuantized::UInt16(x) => {
270                let $var = x;
271                $body
272            }
273            ArrayViewDQuantized::Int16(x) => {
274                let $var = x;
275                $body
276            }
277            ArrayViewDQuantized::UInt32(x) => {
278                let $var = x;
279                $body
280            }
281            ArrayViewDQuantized::Int32(x) => {
282                let $var = x;
283                $body
284            }
285        }
286    };
287}
288
289mod builder;
290mod helpers;
291mod merge;
292mod per_scale_bridge;
293mod postprocess;
294mod tensor_bridge;
295mod tests;
296
297pub use builder::{DecoderBuilder, DEFAULT_PRE_NMS_TOP_K, MULTI_LABEL_PRE_NMS_TOP_K};
298pub use config::{ConfigOutput, ConfigOutputRef, ConfigOutputs};
299
300impl Decoder {
301    /// Static label identifying which dispatch path `decode` / `decode_proto`
302    /// will take, used as a tracing-span attribute. Lets profiling tools
303    /// distinguish `per_scale` (the fast path), `decode_program` (schema-v2
304    /// merge), and `legacy` (config-driven) without requiring callers to
305    /// inspect the model.
306    fn decode_path_label(&self) -> &'static str {
307        if self.per_scale.is_some() {
308            "per_scale"
309        } else if self.decode_program.is_some() {
310            "decode_program"
311        } else {
312            "legacy"
313        }
314    }
315
316    /// Whether [`Decoder::decode`] and [`Decoder::decode_proto`] emit one box
317    /// per class above the score threshold instead of one per anchor.
318    ///
319    /// [`Self::decode_for_tracking`], and `Decoder::decode_tracked` /
320    /// `Decoder::decode_proto_tracked` (behind the `tracker` feature), ignore
321    /// this and always use one label per box.
322    ///
323    /// # Examples
324    ///
325    /// ```rust
326    /// # use edgefirst_decoder::{DecoderBuilder, DecoderResult};
327    /// # fn main() -> DecoderResult<()> {
328    /// #    let config_yaml = edgefirst_bench::testdata::read_to_string("modelpack_split.yaml").to_string();
329    ///     let decoder = DecoderBuilder::default()
330    ///         .with_config_yaml_str(config_yaml)
331    ///         .with_multi_label(true)
332    ///         .build()?;
333    ///     assert!(decoder.multi_label());
334    /// #    Ok(())
335    /// # }
336    /// ```
337    pub fn multi_label(&self) -> bool {
338        self.multi_label
339    }
340
341    /// The pre-NMS cap a decode uses: the explicit
342    /// [`pre_nms_top_k`](Self::pre_nms_top_k) when set, else
343    /// [`MULTI_LABEL_PRE_NMS_TOP_K`] for a multi-label decode and
344    /// [`DEFAULT_PRE_NMS_TOP_K`] for an argmax one. `0` means unbounded.
345    ///
346    /// Pass [`multi_label()`](Self::multi_label) for what `decode` /
347    /// `decode_proto` use, and `false` for the tracking entry points, which
348    /// always decode argmax.
349    ///
350    /// # Examples
351    /// ```rust
352    /// # use edgefirst_decoder::{DecoderBuilder, DecoderResult};
353    /// # fn main() -> DecoderResult<()> {
354    /// # let config_json = edgefirst_bench::testdata::read_to_string("modelpack_split.json").to_string();
355    /// let mut decoder = DecoderBuilder::new()
356    ///     .with_config_json_str(config_json)
357    ///     .with_multi_label(true)
358    ///     .build()?;
359    /// assert_eq!(decoder.pre_nms_top_k, None);
360    /// assert_eq!(decoder.pre_nms_top_k_for(true), 30_000);
361    /// assert_eq!(decoder.pre_nms_top_k_for(false), 300);
362    /// decoder.pre_nms_top_k = Some(30_000);
363    /// assert_eq!(decoder.pre_nms_top_k_for(false), 30_000);
364    /// # Ok(())
365    /// # }
366    /// ```
367    pub fn pre_nms_top_k_for(&self, multi_label: bool) -> usize {
368        match self.pre_nms_top_k {
369            Some(k) => k,
370            None if multi_label => MULTI_LABEL_PRE_NMS_TOP_K,
371            None => DEFAULT_PRE_NMS_TOP_K,
372        }
373    }
374
375    /// Log, the first time this decoder decodes for tracking with
376    /// multi-label enabled, that tracking ignores it. Returns whether this
377    /// call logged.
378    pub(crate) fn warn_once_tracked_multi_label(&self) -> bool {
379        if !self.multi_label
380            || self
381                .tracked_multi_label_warned
382                .swap(true, std::sync::atomic::Ordering::Relaxed)
383        {
384            return false;
385        }
386        tracing::warn!(
387            source = ?self.multi_label_source,
388            "multi_label is ignored when decoding for tracking; one label per box"
389        );
390        true
391    }
392
393    /// This function returns the parsed model type of the decoder.
394    ///
395    /// # Examples
396    ///
397    /// ```rust
398    /// # use edgefirst_decoder::{DecoderBuilder, DecoderResult, configs::ModelType};
399    /// # fn main() -> DecoderResult<()> {
400    /// #    let config_yaml = edgefirst_bench::testdata::read_to_string("modelpack_split.yaml").to_string();
401    ///     let decoder = DecoderBuilder::default()
402    ///         .with_config_yaml_str(config_yaml)
403    ///         .build()?;
404    ///     assert!(matches!(
405    ///         decoder.model_type(),
406    ///         ModelType::ModelPackDetSplit { .. }
407    ///     ));
408    /// #    Ok(())
409    /// # }
410    /// ```
411    pub fn model_type(&self) -> &ModelType {
412        &self.model_type
413    }
414
415    /// Returns the coordinate format of the boxes the decoder emits to
416    /// the caller.
417    ///
418    /// - `Some(true)`: Boxes are in normalized `[0, 1]` coordinates
419    /// - `Some(false)`: Boxes are in pixel coordinates relative to the
420    ///   model input
421    /// - `None`: Unknown, caller must infer (e.g., check if any coordinate
422    ///   > 1.0)
423    ///
424    /// This describes the **post-decode** coordinate space, not the raw
425    /// schema annotation. The decoder applies EDGEAI-1303 normalization
426    /// (dividing bbox channels by `(input_w, input_h)`) on a per-path
427    /// basis, not unconditionally. Four paths are known to invoke the
428    /// helper uniformly across all of their entry points (`decode`,
429    /// `decode_proto`, and — where applicable — `decode_tracked` and
430    /// `decode_tracked_proto`):
431    ///
432    /// 1. The **per-scale fast path** (DFL/LTRB → dist2bbox → sigmoid),
433    ///    which emits pixel-space boxes by design and always normalizes
434    ///    before returning.
435    /// 2. [`ModelType::YoloSegDet`](crate::configs::ModelType::YoloSegDet), whose
436    ///    quantized and float, tracked and untracked, masks and proto
437    ///    variants each call the helper after NMS.
438    /// 3. [`ModelType::YoloSplitSegDet`](crate::configs::ModelType::YoloSplitSegDet),
439    ///    aligned across `decode`, `decode_proto`, `decode_tracked`,
440    ///    and `decode_tracked_proto` for both quantized and float
441    ///    variants.
442    /// 4. [`ModelType::YoloSegDet2Way`](crate::configs::ModelType::YoloSegDet2Way),
443    ///    aligned across the same four entry points and both element
444    ///    type variants.
445    ///
446    /// When any of those paths is active and the schema declares
447    /// `normalized: false` with valid [`input_dims`](Self::input_dims),
448    /// this accessor reports `Some(true)` to match what the caller
449    /// actually receives.
450    ///
451    /// The remaining model types still surface the raw schema flag
452    /// because their post-decode contract differs:
453    /// [`ModelType::YoloDet`](crate::configs::ModelType::YoloDet) and
454    /// [`ModelType::YoloSplitDet`](crate::configs::ModelType::YoloSplitDet)
455    /// (detection-only, no protobox crop coupling), the
456    /// `YoloEndToEnd*` family (model embeds its own NMS and emits its
457    /// own coordinate space), and the `ModelPack*` family (separate
458    /// model conventions). For those, this accessor returns
459    /// `self.normalized` verbatim and leaves it to the caller to
460    /// handle pixel-space output explicitly (e.g. divide by
461    /// `input_dims()` themselves).
462    ///
463    /// # Examples
464    ///
465    /// ```rust
466    /// # use edgefirst_decoder::{DecoderBuilder, DecoderResult};
467    /// # fn main() -> DecoderResult<()> {
468    /// #    let config_yaml = edgefirst_bench::testdata::read_to_string("modelpack_split.yaml").to_string();
469    ///     let decoder = DecoderBuilder::default()
470    ///         .with_config_yaml_str(config_yaml)
471    ///         .build()?;
472    ///     // Config doesn't specify normalized, so it's None
473    ///     assert!(decoder.normalized_boxes().is_none());
474    /// #    Ok(())
475    /// # }
476    /// ```
477    pub fn normalized_boxes(&self) -> Option<bool> {
478        // Four paths invoke `yolo::maybe_normalize_boxes_in_place`
479        // uniformly across every entry point that can reach them:
480        //   - the per-scale fast path (always normalizes by design),
481        //   - `ModelType::YoloSegDet` (helper fires in
482        //     `decode`/`decode_proto` via `yolo::impl_yolo_segdet_*` and
483        //     in `decode_tracked`/`decode_tracked_proto` via the
484        //     `process_tracked_yolo_segmentation!` macro and
485        //     `process_tracked_yolo_segdet_float`),
486        //   - `ModelType::YoloSplitSegDet` (helper fires in
487        //     `decode_yolo_split_segdet_*`, `impl_yolo_split_segdet_*`,
488        //     `process_tracked_yolo_segmentation_split!`, and
489        //     `process_tracked_yolo_segdet_split_float`), and
490        //   - `ModelType::YoloSegDet2Way` (helper fires in
491        //     `decode_yolo_segdet_2way_*`, the float decode routes
492        //     through `impl_yolo_split_segdet_float*`,
493        //     `process_tracked_yolo_segmentation_2way!`, and the
494        //     inline tracked-2way float helpers).
495        // For those, `normalized == Some(false)` with valid `input_dims`
496        // upgrades to a post-decode `Some(true)`. Other paths invoke
497        // the helper inconsistently across `ModelType` variants and
498        // tracked/proto entry points — surface the raw schema flag
499        // there and let callers handle pixel-space output explicitly.
500        if self.per_scale.is_some() || self.legacy_path_normalizes_uniformly() {
501            match (self.normalized, self.input_dims) {
502                (Some(true), _) => Some(true),
503                (Some(false), Some((w, h))) if w != 0 && h != 0 => Some(true),
504                (Some(false), _) => Some(false),
505                (None, _) => None,
506            }
507        } else {
508            self.normalized
509        }
510    }
511
512    /// Returns true for legacy `ModelType` dispatch paths that are known
513    /// to call `yolo::maybe_normalize_boxes_in_place` on every entry
514    /// point (`decode`, `decode_proto`, `decode_tracked`,
515    /// `decode_tracked_proto`, both quantized and float variants).
516    ///
517    /// Used by [`normalized_boxes`](Self::normalized_boxes) to gate the
518    /// pixel→normalized upgrade for non-per-scale model types whose
519    /// post-decode contract matches the per-scale path. Extend this
520    /// list as additional `ModelType` variants are brought into
521    /// uniform-normalization alignment.
522    fn legacy_path_normalizes_uniformly(&self) -> bool {
523        matches!(
524            self.model_type,
525            ModelType::YoloSegDet { .. }
526                | ModelType::YoloSplitSegDet { .. }
527                | ModelType::YoloSegDet2Way { .. }
528        )
529    }
530
531    /// Model input dimensions `(width, height)` captured from the
532    /// schema's `input.shape` / `input.dshape`, or `None` when the
533    /// schema did not declare an input spec (e.g. flat YAML configs
534    /// or `DecoderBuilder::add_output(...)` programmatic builds).
535    ///
536    /// Drives EDGEAI-1303 normalization on the paths that invoke the
537    /// helper uniformly: when the schema declares pixel-space outputs
538    /// and `input_dims()` is `Some((w, h))`, the per-scale bridge and
539    /// the `ModelType::YoloSegDet`, `ModelType::YoloSplitSegDet`, and
540    /// `ModelType::YoloSegDet2Way` dispatch paths divide post-NMS
541    /// bbox coordinates by `(w, h)` so they enter the canonical
542    /// `[0, 1]` range before mask cropping / tracker dispatch, and
543    /// [`normalized_boxes`](Self::normalized_boxes) reports
544    /// `Some(true)` to match. The remaining legacy `ModelType`
545    /// dispatch paths (detection-only `YoloDet`/`YoloSplitDet`, the
546    /// `YoloEndToEnd*` family, and the `ModelPack*` family) do not
547    /// apply this division — see
548    /// [`normalized_boxes`](Self::normalized_boxes) for the per-path
549    /// contract. The legacy `protobox` `> 2.0` reject acts as a safety
550    /// net for paths that emit pixel-space coordinates.
551    ///
552    /// # Examples
553    ///
554    /// ```rust
555    /// # use edgefirst_decoder::{schema::SchemaV2, DecoderBuilder, DecoderResult};
556    /// # fn main() -> DecoderResult<()> {
557    ///     let json = r#"{
558    ///         "schema_version": 2,
559    ///         "nms": "class_agnostic",
560    ///         "input": {
561    ///             "shape": [1, 640, 640, 3],
562    ///             "dshape": [{"batch": 1}, {"height": 640}, {"width": 640}, {"num_features": 3}]
563    ///         },
564    ///         "outputs": [{
565    ///             "name": "out", "type": "detection",
566    ///             "shape": [1, 38, 256],
567    ///             "dshape": [{"batch": 1}, {"num_features": 38}, {"num_boxes": 256}],
568    ///             "decoder": "ultralytics", "encoding": "direct", "normalized": false
569    ///         }]
570    ///     }"#;
571    ///     let schema: SchemaV2 = serde_json::from_str(json).unwrap();
572    ///     let decoder = DecoderBuilder::default().with_schema(schema).build()?;
573    ///     assert_eq!(decoder.input_dims(), Some((640, 640)));
574    /// #   Ok(())
575    /// # }
576    /// ```
577    pub fn input_dims(&self) -> Option<(usize, usize)> {
578        self.input_dims
579    }
580
581    /// Decode quantized model outputs into detection boxes and segmentation
582    /// masks. The quantized outputs can be of u8, i8, u16, i16, u32, or i32
583    /// types. Clears the provided output vectors before populating them.
584    ///
585    /// Test-only: calls [`Self::decode_quantized_impl`] with the decoder's
586    /// own multi-label setting.
587    #[cfg(test)]
588    pub(crate) fn decode_quantized(
589        &self,
590        outputs: &[ArrayViewDQuantized],
591        output_boxes: &mut Vec<DetectBox>,
592        output_masks: &mut Vec<Segmentation>,
593    ) -> Result<(), DecoderError> {
594        self.decode_quantized_impl(outputs, output_boxes, output_masks, self.multi_label)
595    }
596
597    /// Dispatch with an explicit multi-label setting; tracked decode passes `false`.
598    pub(crate) fn decode_quantized_impl(
599        &self,
600        outputs: &[ArrayViewDQuantized],
601        output_boxes: &mut Vec<DetectBox>,
602        output_masks: &mut Vec<Segmentation>,
603        multi_label: bool,
604    ) -> Result<(), DecoderError> {
605        output_boxes.clear();
606        output_masks.clear();
607        match &self.model_type {
608            ModelType::ModelPackSegDet {
609                boxes,
610                scores,
611                segmentation,
612            } => {
613                self.decode_modelpack_det_quantized(
614                    outputs,
615                    boxes,
616                    scores,
617                    multi_label,
618                    output_boxes,
619                )?;
620                self.decode_modelpack_seg_quantized(outputs, segmentation, output_masks)
621            }
622            ModelType::ModelPackSegDetSplit {
623                detection,
624                segmentation,
625            } => {
626                self.decode_modelpack_det_split_quantized(
627                    outputs,
628                    detection,
629                    multi_label,
630                    output_boxes,
631                )?;
632                self.decode_modelpack_seg_quantized(outputs, segmentation, output_masks)
633            }
634            ModelType::ModelPackDet { boxes, scores } => self.decode_modelpack_det_quantized(
635                outputs,
636                boxes,
637                scores,
638                multi_label,
639                output_boxes,
640            ),
641            ModelType::ModelPackDetSplit { detection } => self
642                .decode_modelpack_det_split_quantized(
643                    outputs,
644                    detection,
645                    multi_label,
646                    output_boxes,
647                ),
648            ModelType::ModelPackSeg { segmentation } => {
649                self.decode_modelpack_seg_quantized(outputs, segmentation, output_masks)
650            }
651            ModelType::YoloDet { boxes } => {
652                self.decode_yolo_det_quantized(outputs, boxes, multi_label, output_boxes)
653            }
654            ModelType::YoloSegDet { boxes, protos } => self.decode_yolo_segdet_quantized(
655                outputs,
656                boxes,
657                protos,
658                multi_label,
659                output_boxes,
660                output_masks,
661            ),
662            ModelType::YoloSplitDet { boxes, scores } => self.decode_yolo_split_det_quantized(
663                outputs,
664                boxes,
665                scores,
666                multi_label,
667                output_boxes,
668            ),
669            ModelType::YoloSplitSegDet {
670                boxes,
671                scores,
672                mask_coeff,
673                protos,
674            } => self.decode_yolo_split_segdet_quantized(
675                outputs,
676                boxes,
677                scores,
678                mask_coeff,
679                protos,
680                multi_label,
681                output_boxes,
682                output_masks,
683            ),
684            ModelType::YoloSegDet2Way {
685                boxes,
686                mask_coeff,
687                protos,
688            } => self.decode_yolo_segdet_2way_quantized(
689                outputs,
690                boxes,
691                mask_coeff,
692                protos,
693                multi_label,
694                output_boxes,
695                output_masks,
696            ),
697            ModelType::YoloEndToEndDet { boxes } => {
698                self.decode_yolo_end_to_end_det_quantized(outputs, boxes, output_boxes)
699            }
700            ModelType::YoloEndToEndSegDet { boxes, protos } => self
701                .decode_yolo_end_to_end_segdet_quantized(
702                    outputs,
703                    boxes,
704                    protos,
705                    output_boxes,
706                    output_masks,
707                ),
708            ModelType::YoloSplitEndToEndDet {
709                boxes,
710                scores,
711                classes,
712            } => self.decode_yolo_split_end_to_end_det_quantized(
713                outputs,
714                boxes,
715                scores,
716                classes,
717                output_boxes,
718            ),
719            ModelType::YoloSplitEndToEndSegDet {
720                boxes,
721                scores,
722                classes,
723                mask_coeff,
724                protos,
725            } => self.decode_yolo_split_end_to_end_segdet_quantized(
726                outputs,
727                boxes,
728                scores,
729                classes,
730                mask_coeff,
731                protos,
732                output_boxes,
733                output_masks,
734            ),
735            ModelType::PerScale => Err(DecoderError::Internal(
736                "per-scale path must be intercepted before ModelType dispatch".into(),
737            )),
738        }
739    }
740
741    /// Decode floating point model outputs into detection boxes and
742    /// segmentation masks. Clears the provided output vectors before
743    /// populating them.
744    ///
745    /// Test-only: calls [`Self::decode_float_impl`] with the decoder's own
746    /// multi-label setting.
747    #[cfg(test)]
748    pub(crate) fn decode_float<T>(
749        &self,
750        outputs: &[ArrayViewD<T>],
751        output_boxes: &mut Vec<DetectBox>,
752        output_masks: &mut Vec<Segmentation>,
753    ) -> Result<(), DecoderError>
754    where
755        T: Float + AsPrimitive<f32> + AsPrimitive<u8> + Send + Sync + 'static,
756        f32: AsPrimitive<T>,
757    {
758        self.decode_float_impl(outputs, output_boxes, output_masks, self.multi_label)
759    }
760
761    /// Dispatch with an explicit multi-label setting; tracked decode passes `false`.
762    pub(crate) fn decode_float_impl<T>(
763        &self,
764        outputs: &[ArrayViewD<T>],
765        output_boxes: &mut Vec<DetectBox>,
766        output_masks: &mut Vec<Segmentation>,
767        multi_label: bool,
768    ) -> Result<(), DecoderError>
769    where
770        T: Float + AsPrimitive<f32> + AsPrimitive<u8> + Send + Sync + 'static,
771        f32: AsPrimitive<T>,
772    {
773        output_boxes.clear();
774        output_masks.clear();
775        match &self.model_type {
776            ModelType::ModelPackSegDet {
777                boxes,
778                scores,
779                segmentation,
780            } => {
781                self.decode_modelpack_det_float(outputs, boxes, scores, multi_label, output_boxes)?;
782                self.decode_modelpack_seg_float(outputs, segmentation, output_masks)?;
783            }
784            ModelType::ModelPackSegDetSplit {
785                detection,
786                segmentation,
787            } => {
788                self.decode_modelpack_det_split_float(
789                    outputs,
790                    detection,
791                    multi_label,
792                    output_boxes,
793                )?;
794                self.decode_modelpack_seg_float(outputs, segmentation, output_masks)?;
795            }
796            ModelType::ModelPackDet { boxes, scores } => {
797                self.decode_modelpack_det_float(outputs, boxes, scores, multi_label, output_boxes)?;
798            }
799            ModelType::ModelPackDetSplit { detection } => {
800                self.decode_modelpack_det_split_float(
801                    outputs,
802                    detection,
803                    multi_label,
804                    output_boxes,
805                )?;
806            }
807            ModelType::ModelPackSeg { segmentation } => {
808                self.decode_modelpack_seg_float(outputs, segmentation, output_masks)?;
809            }
810            ModelType::YoloDet { boxes } => {
811                self.decode_yolo_det_float(outputs, boxes, multi_label, output_boxes)?;
812            }
813            ModelType::YoloSegDet { boxes, protos } => {
814                self.decode_yolo_segdet_float(
815                    outputs,
816                    boxes,
817                    protos,
818                    multi_label,
819                    output_boxes,
820                    output_masks,
821                )?;
822            }
823            ModelType::YoloSplitDet { boxes, scores } => {
824                self.decode_yolo_split_det_float(
825                    outputs,
826                    boxes,
827                    scores,
828                    multi_label,
829                    output_boxes,
830                )?;
831            }
832            ModelType::YoloSplitSegDet {
833                boxes,
834                scores,
835                mask_coeff,
836                protos,
837            } => {
838                self.decode_yolo_split_segdet_float(
839                    outputs,
840                    boxes,
841                    scores,
842                    mask_coeff,
843                    protos,
844                    multi_label,
845                    output_boxes,
846                    output_masks,
847                )?;
848            }
849            ModelType::YoloSegDet2Way {
850                boxes,
851                mask_coeff,
852                protos,
853            } => {
854                self.decode_yolo_segdet_2way_float(
855                    outputs,
856                    boxes,
857                    mask_coeff,
858                    protos,
859                    multi_label,
860                    output_boxes,
861                    output_masks,
862                )?;
863            }
864            ModelType::YoloEndToEndDet { boxes } => {
865                self.decode_yolo_end_to_end_det_float(outputs, boxes, output_boxes)?;
866            }
867            ModelType::YoloEndToEndSegDet { boxes, protos } => {
868                self.decode_yolo_end_to_end_segdet_float(
869                    outputs,
870                    boxes,
871                    protos,
872                    output_boxes,
873                    output_masks,
874                )?;
875            }
876            ModelType::YoloSplitEndToEndDet {
877                boxes,
878                scores,
879                classes,
880            } => {
881                self.decode_yolo_split_end_to_end_det_float(
882                    outputs,
883                    boxes,
884                    scores,
885                    classes,
886                    output_boxes,
887                )?;
888            }
889            ModelType::YoloSplitEndToEndSegDet {
890                boxes,
891                scores,
892                classes,
893                mask_coeff,
894                protos,
895            } => {
896                self.decode_yolo_split_end_to_end_segdet_float(
897                    outputs,
898                    boxes,
899                    scores,
900                    classes,
901                    mask_coeff,
902                    protos,
903                    output_boxes,
904                    output_masks,
905                )?;
906            }
907            ModelType::PerScale => {
908                return Err(DecoderError::Internal(
909                    "per-scale path must be intercepted before ModelType dispatch".into(),
910                ));
911            }
912        }
913        Ok(())
914    }
915
916    /// Decodes quantized model outputs into detection boxes, returning raw
917    /// `ProtoData` for segmentation models instead of materialized masks.
918    ///
919    /// Returns `Ok(None)` for detection-only and ModelPack models (detections
920    /// are still decoded into `output_boxes`). Returns `Ok(Some(ProtoData))`
921    /// for YOLO segmentation models.
922    ///
923    /// Test-only: calls [`Self::decode_quantized_proto_impl`] with the
924    /// decoder's own multi-label setting.
925    #[cfg(test)]
926    pub(crate) fn decode_quantized_proto(
927        &self,
928        outputs: &[ArrayViewDQuantized],
929        output_boxes: &mut Vec<DetectBox>,
930    ) -> Result<Option<ProtoData>, DecoderError> {
931        self.decode_quantized_proto_impl(outputs, output_boxes, self.multi_label)
932    }
933
934    /// Dispatch with an explicit multi-label setting; tracked decode passes `false`.
935    pub(crate) fn decode_quantized_proto_impl(
936        &self,
937        outputs: &[ArrayViewDQuantized],
938        output_boxes: &mut Vec<DetectBox>,
939        multi_label: bool,
940    ) -> Result<Option<ProtoData>, DecoderError> {
941        output_boxes.clear();
942        match &self.model_type {
943            // Detection-only variants: decode boxes, return None for proto data.
944            ModelType::ModelPackDet { boxes, scores } => {
945                self.decode_modelpack_det_quantized(
946                    outputs,
947                    boxes,
948                    scores,
949                    multi_label,
950                    output_boxes,
951                )?;
952                Ok(None)
953            }
954            ModelType::ModelPackDetSplit { detection } => {
955                self.decode_modelpack_det_split_quantized(
956                    outputs,
957                    detection,
958                    multi_label,
959                    output_boxes,
960                )?;
961                Ok(None)
962            }
963            ModelType::YoloDet { boxes } => {
964                self.decode_yolo_det_quantized(outputs, boxes, multi_label, output_boxes)?;
965                Ok(None)
966            }
967            ModelType::YoloSplitDet { boxes, scores } => {
968                self.decode_yolo_split_det_quantized(
969                    outputs,
970                    boxes,
971                    scores,
972                    multi_label,
973                    output_boxes,
974                )?;
975                Ok(None)
976            }
977            ModelType::YoloEndToEndDet { boxes } => {
978                self.decode_yolo_end_to_end_det_quantized(outputs, boxes, output_boxes)?;
979                Ok(None)
980            }
981            ModelType::YoloSplitEndToEndDet {
982                boxes,
983                scores,
984                classes,
985            } => {
986                self.decode_yolo_split_end_to_end_det_quantized(
987                    outputs,
988                    boxes,
989                    scores,
990                    classes,
991                    output_boxes,
992                )?;
993                Ok(None)
994            }
995            // ModelPack seg/segdet variants have no YOLO proto data.
996            ModelType::ModelPackSegDet { boxes, scores, .. } => {
997                self.decode_modelpack_det_quantized(
998                    outputs,
999                    boxes,
1000                    scores,
1001                    multi_label,
1002                    output_boxes,
1003                )?;
1004                Ok(None)
1005            }
1006            ModelType::ModelPackSegDetSplit { detection, .. } => {
1007                self.decode_modelpack_det_split_quantized(
1008                    outputs,
1009                    detection,
1010                    multi_label,
1011                    output_boxes,
1012                )?;
1013                Ok(None)
1014            }
1015            ModelType::ModelPackSeg { .. } => Ok(None),
1016
1017            ModelType::YoloSegDet { boxes, protos } => {
1018                let proto = self.decode_yolo_segdet_quantized_proto(
1019                    outputs,
1020                    boxes,
1021                    protos,
1022                    multi_label,
1023                    output_boxes,
1024                )?;
1025                Ok(Some(proto))
1026            }
1027            ModelType::YoloSplitSegDet {
1028                boxes,
1029                scores,
1030                mask_coeff,
1031                protos,
1032            } => {
1033                let proto = self.decode_yolo_split_segdet_quantized_proto(
1034                    outputs,
1035                    boxes,
1036                    scores,
1037                    mask_coeff,
1038                    protos,
1039                    multi_label,
1040                    output_boxes,
1041                )?;
1042                Ok(Some(proto))
1043            }
1044            ModelType::YoloSegDet2Way {
1045                boxes,
1046                mask_coeff,
1047                protos,
1048            } => {
1049                let proto = self.decode_yolo_segdet_2way_quantized_proto(
1050                    outputs,
1051                    boxes,
1052                    mask_coeff,
1053                    protos,
1054                    multi_label,
1055                    output_boxes,
1056                )?;
1057                Ok(Some(proto))
1058            }
1059            ModelType::YoloEndToEndSegDet { boxes, protos } => {
1060                let proto = self.decode_yolo_end_to_end_segdet_quantized_proto(
1061                    outputs,
1062                    boxes,
1063                    protos,
1064                    output_boxes,
1065                )?;
1066                Ok(Some(proto))
1067            }
1068            ModelType::YoloSplitEndToEndSegDet {
1069                boxes,
1070                scores,
1071                classes,
1072                mask_coeff,
1073                protos,
1074            } => {
1075                let proto = self.decode_yolo_split_end_to_end_segdet_quantized_proto(
1076                    outputs,
1077                    boxes,
1078                    scores,
1079                    classes,
1080                    mask_coeff,
1081                    protos,
1082                    output_boxes,
1083                )?;
1084                Ok(Some(proto))
1085            }
1086            ModelType::PerScale => Err(DecoderError::Internal(
1087                "per-scale path must be intercepted before ModelType dispatch".into(),
1088            )),
1089        }
1090    }
1091
1092    /// Decodes floating-point model outputs into detection boxes, returning
1093    /// raw `ProtoData` for segmentation models instead of materialized masks.
1094    ///
1095    /// Returns `Ok(None)` for detection-only and ModelPack models (detections
1096    /// are still decoded into `output_boxes`). Returns `Ok(Some(ProtoData))`
1097    /// for YOLO segmentation models.
1098    ///
1099    /// Test-only: calls [`Self::decode_float_proto_impl`] with the decoder's
1100    /// own multi-label setting.
1101    #[cfg(test)]
1102    pub(crate) fn decode_float_proto<T>(
1103        &self,
1104        outputs: &[ArrayViewD<T>],
1105        output_boxes: &mut Vec<DetectBox>,
1106    ) -> Result<Option<ProtoData>, DecoderError>
1107    where
1108        T: Float + AsPrimitive<f32> + AsPrimitive<u8> + Send + Sync + crate::yolo::FloatProtoElem,
1109        f32: AsPrimitive<T>,
1110    {
1111        self.decode_float_proto_impl(outputs, output_boxes, self.multi_label)
1112    }
1113
1114    /// Dispatch with an explicit multi-label setting; tracked decode passes `false`.
1115    pub(crate) fn decode_float_proto_impl<T>(
1116        &self,
1117        outputs: &[ArrayViewD<T>],
1118        output_boxes: &mut Vec<DetectBox>,
1119        multi_label: bool,
1120    ) -> Result<Option<ProtoData>, DecoderError>
1121    where
1122        T: Float + AsPrimitive<f32> + AsPrimitive<u8> + Send + Sync + crate::yolo::FloatProtoElem,
1123        f32: AsPrimitive<T>,
1124    {
1125        output_boxes.clear();
1126        match &self.model_type {
1127            // Detection-only variants: decode boxes, return None for proto data.
1128            ModelType::ModelPackDet { boxes, scores } => {
1129                self.decode_modelpack_det_float(outputs, boxes, scores, multi_label, output_boxes)?;
1130                Ok(None)
1131            }
1132            ModelType::ModelPackDetSplit { detection } => {
1133                self.decode_modelpack_det_split_float(
1134                    outputs,
1135                    detection,
1136                    multi_label,
1137                    output_boxes,
1138                )?;
1139                Ok(None)
1140            }
1141            ModelType::YoloDet { boxes } => {
1142                self.decode_yolo_det_float(outputs, boxes, multi_label, output_boxes)?;
1143                Ok(None)
1144            }
1145            ModelType::YoloSplitDet { boxes, scores } => {
1146                self.decode_yolo_split_det_float(
1147                    outputs,
1148                    boxes,
1149                    scores,
1150                    multi_label,
1151                    output_boxes,
1152                )?;
1153                Ok(None)
1154            }
1155            ModelType::YoloEndToEndDet { boxes } => {
1156                self.decode_yolo_end_to_end_det_float(outputs, boxes, output_boxes)?;
1157                Ok(None)
1158            }
1159            ModelType::YoloSplitEndToEndDet {
1160                boxes,
1161                scores,
1162                classes,
1163            } => {
1164                self.decode_yolo_split_end_to_end_det_float(
1165                    outputs,
1166                    boxes,
1167                    scores,
1168                    classes,
1169                    output_boxes,
1170                )?;
1171                Ok(None)
1172            }
1173            // ModelPack seg/segdet variants have no YOLO proto data.
1174            ModelType::ModelPackSegDet { boxes, scores, .. } => {
1175                self.decode_modelpack_det_float(outputs, boxes, scores, multi_label, output_boxes)?;
1176                Ok(None)
1177            }
1178            ModelType::ModelPackSegDetSplit { detection, .. } => {
1179                self.decode_modelpack_det_split_float(
1180                    outputs,
1181                    detection,
1182                    multi_label,
1183                    output_boxes,
1184                )?;
1185                Ok(None)
1186            }
1187            ModelType::ModelPackSeg { .. } => Ok(None),
1188
1189            ModelType::YoloSegDet { boxes, protos } => {
1190                let proto = self.decode_yolo_segdet_float_proto(
1191                    outputs,
1192                    boxes,
1193                    protos,
1194                    multi_label,
1195                    output_boxes,
1196                )?;
1197                Ok(Some(proto))
1198            }
1199            ModelType::YoloSplitSegDet {
1200                boxes,
1201                scores,
1202                mask_coeff,
1203                protos,
1204            } => {
1205                let proto = self.decode_yolo_split_segdet_float_proto(
1206                    outputs,
1207                    boxes,
1208                    scores,
1209                    mask_coeff,
1210                    protos,
1211                    multi_label,
1212                    output_boxes,
1213                )?;
1214                Ok(Some(proto))
1215            }
1216            ModelType::YoloSegDet2Way {
1217                boxes,
1218                mask_coeff,
1219                protos,
1220            } => {
1221                let proto = self.decode_yolo_segdet_2way_float_proto(
1222                    outputs,
1223                    boxes,
1224                    mask_coeff,
1225                    protos,
1226                    multi_label,
1227                    output_boxes,
1228                )?;
1229                Ok(Some(proto))
1230            }
1231            ModelType::YoloEndToEndSegDet { boxes, protos } => {
1232                let proto = self.decode_yolo_end_to_end_segdet_float_proto(
1233                    outputs,
1234                    boxes,
1235                    protos,
1236                    output_boxes,
1237                )?;
1238                Ok(Some(proto))
1239            }
1240            ModelType::YoloSplitEndToEndSegDet {
1241                boxes,
1242                scores,
1243                classes,
1244                mask_coeff,
1245                protos,
1246            } => {
1247                let proto = self.decode_yolo_split_end_to_end_segdet_float_proto(
1248                    outputs,
1249                    boxes,
1250                    scores,
1251                    classes,
1252                    mask_coeff,
1253                    protos,
1254                    output_boxes,
1255                )?;
1256                Ok(Some(proto))
1257            }
1258            ModelType::PerScale => Err(DecoderError::Internal(
1259                "per-scale path must be intercepted before ModelType dispatch".into(),
1260            )),
1261        }
1262    }
1263
1264    // ========================================================================
1265    // TensorDyn-based public API
1266    // ========================================================================
1267
1268    /// Decode model outputs into detection boxes and segmentation masks.
1269    ///
1270    /// This is the primary decode API. Accepts `TensorDyn` outputs directly
1271    /// from model inference. Automatically dispatches to quantized or float
1272    /// paths based on the tensor dtype.
1273    ///
1274    /// # Arguments
1275    ///
1276    /// * `outputs` - Tensor outputs from model inference
1277    /// * `output_boxes` - Destination for decoded detection boxes (cleared first)
1278    /// * `output_masks` - Destination for decoded segmentation masks (cleared first)
1279    ///
1280    /// # `output_boxes` / `output_masks` capacity
1281    ///
1282    /// The capacity of the supplied `Vec`s is **only** an allocation hint —
1283    /// it is **not** a cap on the number of detections returned. The
1284    /// post-NMS detection count is bounded by [`Decoder::max_det`] (set
1285    /// via [`DecoderBuilder::with_max_det`], default `300`). Passing
1286    /// `Vec::new()` (capacity 0) returns up to `max_det` detections;
1287    /// pre-allocating with [`Vec::with_capacity`] only avoids the
1288    /// reallocation when the decoder grows the buffer.
1289    ///
1290    /// # Errors
1291    ///
1292    /// Returns `DecoderError` if tensor mapping fails, dtypes are unsupported,
1293    /// or the outputs don't match the decoder's model configuration.
1294    pub fn decode(
1295        &self,
1296        outputs: &[&edgefirst_tensor::TensorDyn],
1297        output_boxes: &mut Vec<DetectBox>,
1298        output_masks: &mut Vec<Segmentation>,
1299    ) -> Result<(), DecoderError> {
1300        self.decode_impl(outputs, output_boxes, output_masks, self.multi_label)
1301    }
1302
1303    /// Decode with one label per box, whatever [`Self::multi_label`] says.
1304    ///
1305    /// Use this when the boxes feed an external tracker: trackers match on
1306    /// IoU only, so per-class duplicates of one anchor become phantom tracks.
1307    /// Logs a warning once per decoder when the decoder has multi-label
1308    /// enabled. `Decoder::decode_tracked` and `Decoder::decode_proto_tracked`
1309    /// (behind the `tracker` feature) apply the same rule. When the builder
1310    /// raised [`Self::pre_nms_top_k`] to [`MULTI_LABEL_PRE_NMS_TOP_K`] only
1311    /// because multi-label was on, this argmax decode uses
1312    /// [`DEFAULT_PRE_NMS_TOP_K`]; an explicit cap is used as given.
1313    ///
1314    /// # Errors
1315    ///
1316    /// The same as [`Self::decode`].
1317    pub fn decode_for_tracking(
1318        &self,
1319        outputs: &[&edgefirst_tensor::TensorDyn],
1320        output_boxes: &mut Vec<DetectBox>,
1321        output_masks: &mut Vec<Segmentation>,
1322    ) -> Result<(), DecoderError> {
1323        self.warn_once_tracked_multi_label();
1324        self.decode_impl(outputs, output_boxes, output_masks, false)
1325    }
1326
1327    /// [`Self::decode`] with an explicit multi-label setting.
1328    fn decode_impl(
1329        &self,
1330        outputs: &[&edgefirst_tensor::TensorDyn],
1331        output_boxes: &mut Vec<DetectBox>,
1332        output_masks: &mut Vec<Segmentation>,
1333        multi_label: bool,
1334    ) -> Result<(), DecoderError> {
1335        let path = self.decode_path_label();
1336        let _span = tracing::trace_span!("decoder.decode", path = path, n_outputs = outputs.len())
1337            .entered();
1338        // Per-scale fast path — selected at builder time when the schema
1339        // declares per-scale children with DFL or LTRB encoding.
1340        if let Some(per_scale_mutex) = &self.per_scale {
1341            let mut ps = per_scale_mutex
1342                .lock()
1343                .map_err(|e| DecoderError::Internal(format!("per_scale mutex poisoned: {e}")))?;
1344            let decoded = ps.run(outputs)?;
1345            return per_scale_bridge::per_scale_to_masks(
1346                &decoded,
1347                output_boxes,
1348                output_masks,
1349                self.iou_threshold,
1350                self.score_threshold,
1351                self.nms,
1352                self.pre_nms_top_k_for(multi_label),
1353                self.max_det,
1354                self.normalized,
1355                self.input_dims,
1356                multi_label,
1357            );
1358        }
1359
1360        // Schema v2 merge path: dequantize physical children into
1361        // logical float32 tensors, then feed through the float dispatch.
1362        if let Some(program) = &self.decode_program {
1363            let merged = program.execute(outputs)?;
1364            let views: Vec<_> = merged.iter().map(|a| a.view()).collect();
1365            return self.decode_float_impl(&views, output_boxes, output_masks, multi_label);
1366        }
1367
1368        let mapped = tensor_bridge::map_tensors(outputs)?;
1369        match &mapped {
1370            tensor_bridge::MappedOutputs::Quantized(maps) => {
1371                let views = tensor_bridge::quantized_views(maps)?;
1372                self.decode_quantized_impl(&views, output_boxes, output_masks, multi_label)
1373            }
1374            tensor_bridge::MappedOutputs::Float16(maps) => {
1375                let views = tensor_bridge::f16_views(maps)?;
1376                self.decode_float_impl(&views, output_boxes, output_masks, multi_label)
1377            }
1378            tensor_bridge::MappedOutputs::Float32(maps) => {
1379                let views = tensor_bridge::f32_views(maps)?;
1380                self.decode_float_impl(&views, output_boxes, output_masks, multi_label)
1381            }
1382            tensor_bridge::MappedOutputs::Float64(maps) => {
1383                let views = tensor_bridge::f64_views(maps)?;
1384                self.decode_float_impl(&views, output_boxes, output_masks, multi_label)
1385            }
1386        }
1387    }
1388
1389    /// Decode model outputs into detection boxes, returning raw proto data
1390    /// for segmentation models instead of materialized masks.
1391    ///
1392    /// Accepts `TensorDyn` outputs directly from model inference.
1393    /// Detections are always decoded into `output_boxes` regardless of model type.
1394    /// Returns `Ok(None)` for detection-only and ModelPack models.
1395    /// Returns `Ok(Some(ProtoData))` for YOLO segmentation models.
1396    ///
1397    /// # Arguments
1398    ///
1399    /// * `outputs` - Tensor outputs from model inference
1400    /// * `output_boxes` - Destination for decoded detection boxes (cleared first)
1401    ///
1402    /// # `output_boxes` capacity
1403    ///
1404    /// The capacity of `output_boxes` is **only** an allocation hint — it
1405    /// is **not** a cap on the number of detections returned. The
1406    /// post-NMS detection count is bounded by [`Decoder::max_det`] (set
1407    /// via [`DecoderBuilder::with_max_det`], default `300`). Passing
1408    /// `Vec::new()` (capacity 0) returns up to `max_det` detections.
1409    ///
1410    /// # Errors
1411    ///
1412    /// Returns `DecoderError` if tensor mapping fails, dtypes are unsupported,
1413    /// or the outputs don't match the decoder's model configuration.
1414    pub fn decode_proto(
1415        &self,
1416        outputs: &[&edgefirst_tensor::TensorDyn],
1417        output_boxes: &mut Vec<DetectBox>,
1418    ) -> Result<Option<ProtoData>, DecoderError> {
1419        self.decode_proto_impl(outputs, output_boxes, self.multi_label)
1420    }
1421
1422    /// [`Self::decode_proto`] with an explicit multi-label setting.
1423    fn decode_proto_impl(
1424        &self,
1425        outputs: &[&edgefirst_tensor::TensorDyn],
1426        output_boxes: &mut Vec<DetectBox>,
1427        multi_label: bool,
1428    ) -> Result<Option<ProtoData>, DecoderError> {
1429        let path = self.decode_path_label();
1430        let _span = tracing::trace_span!(
1431            "decoder.decode_proto",
1432            path = path,
1433            n_outputs = outputs.len()
1434        )
1435        .entered();
1436        // Per-scale fast path — selected at builder time when the schema
1437        // declares per-scale children with DFL or LTRB encoding.
1438        if let Some(per_scale_mutex) = &self.per_scale {
1439            let mut ps = per_scale_mutex
1440                .lock()
1441                .map_err(|e| DecoderError::Internal(format!("per_scale mutex poisoned: {e}")))?;
1442            let decoded = ps.run(outputs)?;
1443            return per_scale_bridge::per_scale_to_proto_data(
1444                &decoded,
1445                output_boxes,
1446                self.iou_threshold,
1447                self.score_threshold,
1448                self.nms,
1449                self.pre_nms_top_k_for(multi_label),
1450                self.max_det,
1451                self.normalized,
1452                self.input_dims,
1453                multi_label,
1454            );
1455        }
1456
1457        // Schema v2 merge path: dequantize physical children into
1458        // logical float32 tensors, then feed through the float dispatch.
1459        if let Some(program) = &self.decode_program {
1460            let merged = program.execute(outputs)?;
1461            let views: Vec<_> = merged.iter().map(|a| a.view()).collect();
1462            return self.decode_float_proto_impl(&views, output_boxes, multi_label);
1463        }
1464
1465        let mapped = tensor_bridge::map_tensors(outputs)?;
1466        let result = match &mapped {
1467            tensor_bridge::MappedOutputs::Quantized(maps) => {
1468                let views = tensor_bridge::quantized_views(maps)?;
1469                self.decode_quantized_proto_impl(&views, output_boxes, multi_label)
1470            }
1471            tensor_bridge::MappedOutputs::Float16(maps) => {
1472                let views = tensor_bridge::f16_views(maps)?;
1473                self.decode_float_proto_impl(&views, output_boxes, multi_label)
1474            }
1475            tensor_bridge::MappedOutputs::Float32(maps) => {
1476                let views = tensor_bridge::f32_views(maps)?;
1477                self.decode_float_proto_impl(&views, output_boxes, multi_label)
1478            }
1479            tensor_bridge::MappedOutputs::Float64(maps) => {
1480                let views = tensor_bridge::f64_views(maps)?;
1481                self.decode_float_proto_impl(&views, output_boxes, multi_label)
1482            }
1483        };
1484        result
1485    }
1486
1487    /// Run the per-scale pipeline and return pre-NMS buffers as owned f32.
1488    ///
1489    /// Test-only entry point used by the parity-fixture tests to compare
1490    /// HAL stage output against the NumPy reference's stage output
1491    /// without NMS ordering noise. Returns an error if the decoder
1492    /// isn't configured for per-scale decoding.
1493    #[doc(hidden)]
1494    pub fn _testing_run_per_scale_pre_nms(
1495        &self,
1496        outputs: &[&edgefirst_tensor::TensorDyn],
1497    ) -> Result<crate::per_scale::PreNmsCapture, crate::error::DecoderError> {
1498        let mutex = self.per_scale.as_ref().ok_or_else(|| {
1499            crate::error::DecoderError::Internal("decoder not configured for per-scale".into())
1500        })?;
1501        let mut ps = mutex.lock().map_err(|e| {
1502            crate::error::DecoderError::Internal(format!("per_scale mutex poisoned: {e}"))
1503        })?;
1504        // Drop the borrowed view immediately so we can reborrow buffers below.
1505        {
1506            ps.run(outputs)?;
1507        }
1508        let total_anchors = ps.plan.total_anchors;
1509        let num_classes = ps.plan.num_classes;
1510        let num_mc = ps.plan.num_mask_coefs;
1511        Ok(ps
1512            .buffers
1513            .snapshot_owned_f32(total_anchors, num_classes, num_mc))
1514    }
1515}
1516
1517#[cfg(feature = "tracker")]
1518pub use edgefirst_tracker::TrackInfo;
1519
1520#[cfg(feature = "tracker")]
1521pub use edgefirst_tracker::Tracker;
1522
1523#[cfg(feature = "tracker")]
1524impl Decoder {
1525    /// Decode quantized model outputs into detection boxes and segmentation
1526    /// masks with tracking. Clears the provided output vectors before
1527    /// populating them.
1528    pub(crate) fn decode_tracked_quantized<TR: edgefirst_tracker::Tracker<DetectBox>>(
1529        &self,
1530        tracker: &mut TR,
1531        timestamp: u64,
1532        outputs: &[ArrayViewDQuantized],
1533        output_boxes: &mut Vec<DetectBox>,
1534        output_masks: &mut Vec<Segmentation>,
1535        output_tracks: &mut Vec<edgefirst_tracker::TrackInfo>,
1536    ) -> Result<(), DecoderError> {
1537        output_boxes.clear();
1538        output_masks.clear();
1539        output_tracks.clear();
1540
1541        // yolo segdet variants require special handling to separate boxes that come from decoding vs active tracks.
1542        // Only boxes that come from decoding can be used for proto/mask generation.
1543        match &self.model_type {
1544            ModelType::YoloSegDet { boxes, protos } => self.decode_tracked_yolo_segdet_quantized(
1545                tracker,
1546                timestamp,
1547                outputs,
1548                boxes,
1549                protos,
1550                output_boxes,
1551                output_masks,
1552                output_tracks,
1553            ),
1554            ModelType::YoloSplitSegDet {
1555                boxes,
1556                scores,
1557                mask_coeff,
1558                protos,
1559            } => self.decode_tracked_yolo_split_segdet_quantized(
1560                tracker,
1561                timestamp,
1562                outputs,
1563                boxes,
1564                scores,
1565                mask_coeff,
1566                protos,
1567                output_boxes,
1568                output_masks,
1569                output_tracks,
1570            ),
1571            ModelType::YoloEndToEndSegDet { boxes, protos } => self
1572                .decode_tracked_yolo_end_to_end_segdet_quantized(
1573                    tracker,
1574                    timestamp,
1575                    outputs,
1576                    boxes,
1577                    protos,
1578                    output_boxes,
1579                    output_masks,
1580                    output_tracks,
1581                ),
1582            ModelType::YoloSplitEndToEndSegDet {
1583                boxes,
1584                scores,
1585                classes,
1586                mask_coeff,
1587                protos,
1588            } => self.decode_tracked_yolo_split_end_to_end_segdet_quantized(
1589                tracker,
1590                timestamp,
1591                outputs,
1592                boxes,
1593                scores,
1594                classes,
1595                mask_coeff,
1596                protos,
1597                output_boxes,
1598                output_masks,
1599                output_tracks,
1600            ),
1601            ModelType::YoloSegDet2Way {
1602                boxes,
1603                mask_coeff,
1604                protos,
1605            } => self.decode_tracked_yolo_segdet_2way_quantized(
1606                tracker,
1607                timestamp,
1608                outputs,
1609                boxes,
1610                mask_coeff,
1611                protos,
1612                output_boxes,
1613                output_masks,
1614                output_tracks,
1615            ),
1616            _ => {
1617                self.decode_quantized_impl(outputs, output_boxes, output_masks, false)?;
1618                Self::update_tracker(tracker, timestamp, output_boxes, output_tracks);
1619                Ok(())
1620            }
1621        }
1622    }
1623
1624    /// This function decodes floating point model outputs into detection boxes
1625    /// and segmentation masks. Up to `max_det` boxes and
1626    /// masks will be decoded. The function clears the provided output
1627    /// vectors before populating them with the decoded results.
1628    ///
1629    /// This function returns an `Error` if the provided outputs don't
1630    /// match the configuration provided by the user when building the decoder.
1631    ///
1632    /// Any quantization information in the configuration will be ignored.
1633    pub(crate) fn decode_tracked_float<TR: edgefirst_tracker::Tracker<DetectBox>, T>(
1634        &self,
1635        tracker: &mut TR,
1636        timestamp: u64,
1637        outputs: &[ArrayViewD<T>],
1638        output_boxes: &mut Vec<DetectBox>,
1639        output_masks: &mut Vec<Segmentation>,
1640        output_tracks: &mut Vec<edgefirst_tracker::TrackInfo>,
1641    ) -> Result<(), DecoderError>
1642    where
1643        T: Float + AsPrimitive<f32> + AsPrimitive<u8> + Send + Sync + 'static,
1644        f32: AsPrimitive<T>,
1645    {
1646        output_boxes.clear();
1647        output_masks.clear();
1648        output_tracks.clear();
1649        match &self.model_type {
1650            ModelType::YoloSegDet { boxes, protos } => {
1651                self.decode_tracked_yolo_segdet_float(
1652                    tracker,
1653                    timestamp,
1654                    outputs,
1655                    boxes,
1656                    protos,
1657                    output_boxes,
1658                    output_masks,
1659                    output_tracks,
1660                )?;
1661            }
1662            ModelType::YoloSplitSegDet {
1663                boxes,
1664                scores,
1665                mask_coeff,
1666                protos,
1667            } => {
1668                self.decode_tracked_yolo_split_segdet_float(
1669                    tracker,
1670                    timestamp,
1671                    outputs,
1672                    boxes,
1673                    scores,
1674                    mask_coeff,
1675                    protos,
1676                    output_boxes,
1677                    output_masks,
1678                    output_tracks,
1679                )?;
1680            }
1681            ModelType::YoloEndToEndSegDet { boxes, protos } => {
1682                self.decode_tracked_yolo_end_to_end_segdet_float(
1683                    tracker,
1684                    timestamp,
1685                    outputs,
1686                    boxes,
1687                    protos,
1688                    output_boxes,
1689                    output_masks,
1690                    output_tracks,
1691                )?;
1692            }
1693            ModelType::YoloSplitEndToEndSegDet {
1694                boxes,
1695                scores,
1696                classes,
1697                mask_coeff,
1698                protos,
1699            } => {
1700                self.decode_tracked_yolo_split_end_to_end_segdet_float(
1701                    tracker,
1702                    timestamp,
1703                    outputs,
1704                    boxes,
1705                    scores,
1706                    classes,
1707                    mask_coeff,
1708                    protos,
1709                    output_boxes,
1710                    output_masks,
1711                    output_tracks,
1712                )?;
1713            }
1714            ModelType::YoloSegDet2Way {
1715                boxes,
1716                mask_coeff,
1717                protos,
1718            } => {
1719                self.decode_tracked_yolo_segdet_2way_float(
1720                    tracker,
1721                    timestamp,
1722                    outputs,
1723                    boxes,
1724                    mask_coeff,
1725                    protos,
1726                    output_boxes,
1727                    output_masks,
1728                    output_tracks,
1729                )?;
1730            }
1731            _ => {
1732                self.decode_float_impl(outputs, output_boxes, output_masks, false)?;
1733                Self::update_tracker(tracker, timestamp, output_boxes, output_tracks);
1734            }
1735        }
1736        Ok(())
1737    }
1738
1739    /// Decodes quantized model outputs into detection boxes, returning raw
1740    /// `ProtoData` for segmentation models instead of materialized masks.
1741    ///
1742    /// Returns `Ok(None)` for detection-only and ModelPack models (use
1743    /// `decode_quantized` for those). Returns `Ok(Some(ProtoData))` for
1744    /// YOLO segmentation models.
1745    pub(crate) fn decode_tracked_quantized_proto<TR: edgefirst_tracker::Tracker<DetectBox>>(
1746        &self,
1747        tracker: &mut TR,
1748        timestamp: u64,
1749        outputs: &[ArrayViewDQuantized],
1750        output_boxes: &mut Vec<DetectBox>,
1751        output_tracks: &mut Vec<edgefirst_tracker::TrackInfo>,
1752    ) -> Result<Option<ProtoData>, DecoderError> {
1753        output_boxes.clear();
1754        output_tracks.clear();
1755        match &self.model_type {
1756            ModelType::YoloSegDet { boxes, protos } => {
1757                let proto = self.decode_tracked_yolo_segdet_quantized_proto(
1758                    tracker,
1759                    timestamp,
1760                    outputs,
1761                    boxes,
1762                    protos,
1763                    output_boxes,
1764                    output_tracks,
1765                )?;
1766                Ok(Some(proto))
1767            }
1768            ModelType::YoloSplitSegDet {
1769                boxes,
1770                scores,
1771                mask_coeff,
1772                protos,
1773            } => {
1774                let proto = self.decode_tracked_yolo_split_segdet_quantized_proto(
1775                    tracker,
1776                    timestamp,
1777                    outputs,
1778                    boxes,
1779                    scores,
1780                    mask_coeff,
1781                    protos,
1782                    output_boxes,
1783                    output_tracks,
1784                )?;
1785                Ok(Some(proto))
1786            }
1787            ModelType::YoloSegDet2Way {
1788                boxes,
1789                mask_coeff,
1790                protos,
1791            } => {
1792                let proto = self.decode_tracked_yolo_segdet_2way_quantized_proto(
1793                    tracker,
1794                    timestamp,
1795                    outputs,
1796                    boxes,
1797                    mask_coeff,
1798                    protos,
1799                    output_boxes,
1800                    output_tracks,
1801                )?;
1802                Ok(Some(proto))
1803            }
1804            ModelType::YoloEndToEndSegDet { boxes, protos } => {
1805                let proto = self.decode_tracked_yolo_end_to_end_segdet_quantized_proto(
1806                    tracker,
1807                    timestamp,
1808                    outputs,
1809                    boxes,
1810                    protos,
1811                    output_boxes,
1812                    output_tracks,
1813                )?;
1814                Ok(Some(proto))
1815            }
1816            ModelType::YoloSplitEndToEndSegDet {
1817                boxes,
1818                scores,
1819                classes,
1820                mask_coeff,
1821                protos,
1822            } => {
1823                let proto = self.decode_tracked_yolo_split_end_to_end_segdet_quantized_proto(
1824                    tracker,
1825                    timestamp,
1826                    outputs,
1827                    boxes,
1828                    scores,
1829                    classes,
1830                    mask_coeff,
1831                    protos,
1832                    output_boxes,
1833                    output_tracks,
1834                )?;
1835                Ok(Some(proto))
1836            }
1837            // Non-seg variants: decode boxes via the non-proto path, then track.
1838            _ => {
1839                let mut masks = Vec::new();
1840                self.decode_quantized_impl(outputs, output_boxes, &mut masks, false)?;
1841                Self::update_tracker(tracker, timestamp, output_boxes, output_tracks);
1842                Ok(None)
1843            }
1844        }
1845    }
1846
1847    /// Decodes floating-point model outputs into detection boxes, returning
1848    /// raw `ProtoData` for segmentation models instead of materialized masks.
1849    ///
1850    /// Detections are always decoded into `output_boxes` regardless of model type.
1851    /// Returns `Ok(None)` for detection-only and ModelPack models. Returns
1852    /// `Ok(Some(ProtoData))` for YOLO segmentation models.
1853    pub(crate) fn decode_tracked_float_proto<TR: edgefirst_tracker::Tracker<DetectBox>, T>(
1854        &self,
1855        tracker: &mut TR,
1856        timestamp: u64,
1857        outputs: &[ArrayViewD<T>],
1858        output_boxes: &mut Vec<DetectBox>,
1859        output_tracks: &mut Vec<edgefirst_tracker::TrackInfo>,
1860    ) -> Result<Option<ProtoData>, DecoderError>
1861    where
1862        T: Float + AsPrimitive<f32> + AsPrimitive<u8> + Send + Sync + crate::yolo::FloatProtoElem,
1863        f32: AsPrimitive<T>,
1864    {
1865        output_boxes.clear();
1866        output_tracks.clear();
1867        match &self.model_type {
1868            ModelType::YoloSegDet { boxes, protos } => {
1869                let proto = self.decode_tracked_yolo_segdet_float_proto(
1870                    tracker,
1871                    timestamp,
1872                    outputs,
1873                    boxes,
1874                    protos,
1875                    output_boxes,
1876                    output_tracks,
1877                )?;
1878                Ok(Some(proto))
1879            }
1880            ModelType::YoloSplitSegDet {
1881                boxes,
1882                scores,
1883                mask_coeff,
1884                protos,
1885            } => {
1886                let proto = self.decode_tracked_yolo_split_segdet_float_proto(
1887                    tracker,
1888                    timestamp,
1889                    outputs,
1890                    boxes,
1891                    scores,
1892                    mask_coeff,
1893                    protos,
1894                    output_boxes,
1895                    output_tracks,
1896                )?;
1897                Ok(Some(proto))
1898            }
1899            ModelType::YoloSegDet2Way {
1900                boxes,
1901                mask_coeff,
1902                protos,
1903            } => {
1904                let proto = self.decode_tracked_yolo_segdet_2way_float_proto(
1905                    tracker,
1906                    timestamp,
1907                    outputs,
1908                    boxes,
1909                    mask_coeff,
1910                    protos,
1911                    output_boxes,
1912                    output_tracks,
1913                )?;
1914                Ok(Some(proto))
1915            }
1916            ModelType::YoloEndToEndSegDet { boxes, protos } => {
1917                let proto = self.decode_tracked_yolo_end_to_end_segdet_float_proto(
1918                    tracker,
1919                    timestamp,
1920                    outputs,
1921                    boxes,
1922                    protos,
1923                    output_boxes,
1924                    output_tracks,
1925                )?;
1926                Ok(Some(proto))
1927            }
1928            ModelType::YoloSplitEndToEndSegDet {
1929                boxes,
1930                scores,
1931                classes,
1932                mask_coeff,
1933                protos,
1934            } => {
1935                let proto = self.decode_tracked_yolo_split_end_to_end_segdet_float_proto(
1936                    tracker,
1937                    timestamp,
1938                    outputs,
1939                    boxes,
1940                    scores,
1941                    classes,
1942                    mask_coeff,
1943                    protos,
1944                    output_boxes,
1945                    output_tracks,
1946                )?;
1947                Ok(Some(proto))
1948            }
1949            // Non-seg variants: decode boxes via the non-proto path, then track.
1950            _ => {
1951                let mut masks = Vec::new();
1952                self.decode_float_impl(outputs, output_boxes, &mut masks, false)?;
1953                Self::update_tracker(tracker, timestamp, output_boxes, output_tracks);
1954                Ok(None)
1955            }
1956        }
1957    }
1958
1959    // ========================================================================
1960    // TensorDyn-based tracked public API
1961    // ========================================================================
1962
1963    /// Decode model outputs with tracking.
1964    ///
1965    /// Accepts `TensorDyn` outputs directly from model inference. Automatically
1966    /// dispatches to quantized or float paths based on the tensor dtype, then
1967    /// updates the tracker with the decoded boxes.
1968    ///
1969    /// Always decodes one label per box, regardless of [`Self::multi_label`]:
1970    /// trackers match on IoU only, so per-class duplicates of one anchor would
1971    /// spawn phantom tracks. Logs a warning once per decoder when the decoder
1972    /// has multi-label enabled. Like [`Self::decode_for_tracking`], it uses
1973    /// the argmax default pre-NMS cap unless the cap was set explicitly.
1974    ///
1975    /// # Arguments
1976    ///
1977    /// * `tracker` - The tracker instance to update
1978    /// * `timestamp` - Current frame timestamp
1979    /// * `outputs` - Tensor outputs from model inference
1980    /// * `output_boxes` - Destination for decoded detection boxes (cleared first)
1981    /// * `output_masks` - Destination for decoded segmentation masks (cleared first)
1982    /// * `output_tracks` - Destination for track info (cleared first)
1983    ///
1984    /// # Errors
1985    ///
1986    /// Returns `DecoderError` if tensor mapping fails, dtypes are unsupported,
1987    /// or the outputs don't match the decoder's model configuration.
1988    pub fn decode_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
1989        &self,
1990        tracker: &mut TR,
1991        timestamp: u64,
1992        outputs: &[&edgefirst_tensor::TensorDyn],
1993        output_boxes: &mut Vec<DetectBox>,
1994        output_masks: &mut Vec<Segmentation>,
1995        output_tracks: &mut Vec<edgefirst_tracker::TrackInfo>,
1996    ) -> Result<(), DecoderError> {
1997        self.warn_once_tracked_multi_label();
1998        // Per-scale fast path: route via the basic decode then update the
1999        // tracker. The current implementation keeps the tracker integration simple; per-frame
2000        // decoupling between detection and tracking is preserved.
2001        if self.per_scale.is_some() {
2002            output_tracks.clear();
2003            self.decode_impl(outputs, output_boxes, output_masks, false)?;
2004            Self::update_tracker(tracker, timestamp, output_boxes, output_tracks);
2005            return Ok(());
2006        }
2007
2008        let mapped = tensor_bridge::map_tensors(outputs)?;
2009        match &mapped {
2010            tensor_bridge::MappedOutputs::Quantized(maps) => {
2011                let views = tensor_bridge::quantized_views(maps)?;
2012                self.decode_tracked_quantized(
2013                    tracker,
2014                    timestamp,
2015                    &views,
2016                    output_boxes,
2017                    output_masks,
2018                    output_tracks,
2019                )
2020            }
2021            tensor_bridge::MappedOutputs::Float16(maps) => {
2022                let views = tensor_bridge::f16_views(maps)?;
2023                self.decode_tracked_float(
2024                    tracker,
2025                    timestamp,
2026                    &views,
2027                    output_boxes,
2028                    output_masks,
2029                    output_tracks,
2030                )
2031            }
2032            tensor_bridge::MappedOutputs::Float32(maps) => {
2033                let views = tensor_bridge::f32_views(maps)?;
2034                self.decode_tracked_float(
2035                    tracker,
2036                    timestamp,
2037                    &views,
2038                    output_boxes,
2039                    output_masks,
2040                    output_tracks,
2041                )
2042            }
2043            tensor_bridge::MappedOutputs::Float64(maps) => {
2044                let views = tensor_bridge::f64_views(maps)?;
2045                self.decode_tracked_float(
2046                    tracker,
2047                    timestamp,
2048                    &views,
2049                    output_boxes,
2050                    output_masks,
2051                    output_tracks,
2052                )
2053            }
2054        }
2055    }
2056
2057    /// Decode model outputs with tracking, returning raw proto data for
2058    /// segmentation models.
2059    ///
2060    /// Accepts `TensorDyn` outputs directly from model inference.
2061    /// Returns `Ok(None)` for detection-only and ModelPack models.
2062    /// Returns `Ok(Some(ProtoData))` for YOLO segmentation models.
2063    ///
2064    /// Always decodes one label per box, regardless of [`Self::multi_label`]:
2065    /// trackers match on IoU only, so per-class duplicates of one anchor would
2066    /// spawn phantom tracks. Logs a warning once per decoder when the decoder
2067    /// has multi-label enabled. Like [`Self::decode_for_tracking`], it uses
2068    /// the argmax default pre-NMS cap unless the cap was set explicitly.
2069    ///
2070    /// # Arguments
2071    ///
2072    /// * `tracker` - The tracker instance to update
2073    /// * `timestamp` - Current frame timestamp
2074    /// * `outputs` - Tensor outputs from model inference
2075    /// * `output_boxes` - Destination for decoded detection boxes (cleared first)
2076    /// * `output_tracks` - Destination for track info (cleared first)
2077    ///
2078    /// # Errors
2079    ///
2080    /// Returns `DecoderError` if tensor mapping fails, dtypes are unsupported,
2081    /// or the outputs don't match the decoder's model configuration.
2082    pub fn decode_proto_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
2083        &self,
2084        tracker: &mut TR,
2085        timestamp: u64,
2086        outputs: &[&edgefirst_tensor::TensorDyn],
2087        output_boxes: &mut Vec<DetectBox>,
2088        output_tracks: &mut Vec<edgefirst_tracker::TrackInfo>,
2089    ) -> Result<Option<ProtoData>, DecoderError> {
2090        self.warn_once_tracked_multi_label();
2091        // Per-scale fast path: route via the basic decode_proto then
2092        // update the tracker on the resulting boxes.
2093        if self.per_scale.is_some() {
2094            output_tracks.clear();
2095            let proto = self.decode_proto_impl(outputs, output_boxes, false)?;
2096            Self::update_tracker(tracker, timestamp, output_boxes, output_tracks);
2097            return Ok(proto);
2098        }
2099
2100        let mapped = tensor_bridge::map_tensors(outputs)?;
2101        match &mapped {
2102            tensor_bridge::MappedOutputs::Quantized(maps) => {
2103                let views = tensor_bridge::quantized_views(maps)?;
2104                self.decode_tracked_quantized_proto(
2105                    tracker,
2106                    timestamp,
2107                    &views,
2108                    output_boxes,
2109                    output_tracks,
2110                )
2111            }
2112            tensor_bridge::MappedOutputs::Float16(maps) => {
2113                let views = tensor_bridge::f16_views(maps)?;
2114                self.decode_tracked_float_proto(
2115                    tracker,
2116                    timestamp,
2117                    &views,
2118                    output_boxes,
2119                    output_tracks,
2120                )
2121            }
2122            tensor_bridge::MappedOutputs::Float32(maps) => {
2123                let views = tensor_bridge::f32_views(maps)?;
2124                self.decode_tracked_float_proto(
2125                    tracker,
2126                    timestamp,
2127                    &views,
2128                    output_boxes,
2129                    output_tracks,
2130                )
2131            }
2132            tensor_bridge::MappedOutputs::Float64(maps) => {
2133                let views = tensor_bridge::f64_views(maps)?;
2134                self.decode_tracked_float_proto(
2135                    tracker,
2136                    timestamp,
2137                    &views,
2138                    output_boxes,
2139                    output_tracks,
2140                )
2141            }
2142        }
2143    }
2144}