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