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 the per-scale pipeline and return pre-NMS buffers as owned f32.
1537 ///
1538 /// Test-only entry point used by the parity-fixture tests to compare
1539 /// HAL stage output against the NumPy reference's stage output
1540 /// without NMS ordering noise. Returns an error if the decoder
1541 /// isn't configured for per-scale decoding.
1542 #[doc(hidden)]
1543 pub fn _testing_run_per_scale_pre_nms(
1544 &self,
1545 outputs: &[&edgefirst_tensor::TensorDyn],
1546 ) -> Result<crate::per_scale::PreNmsCapture, 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 // Drop the borrowed view immediately so we can reborrow buffers below.
1554 {
1555 ps.run(outputs, None)?;
1556 }
1557 let total_anchors = ps.plan.total_anchors;
1558 let num_classes = ps.plan.num_classes;
1559 let num_mc = ps.plan.num_mask_coefs;
1560 Ok(ps
1561 .buffers
1562 .snapshot_owned_f32(total_anchors, num_classes, num_mc))
1563 }
1564}
1565
1566#[cfg(feature = "tracker")]
1567pub use edgefirst_tracker::TrackInfo;
1568
1569#[cfg(feature = "tracker")]
1570pub use edgefirst_tracker::Tracker;
1571
1572#[cfg(feature = "tracker")]
1573impl Decoder {
1574 /// Decode quantized model outputs into detection boxes and segmentation
1575 /// masks with tracking. Clears the provided output vectors before
1576 /// populating them.
1577 pub(crate) fn decode_tracked_quantized<TR: edgefirst_tracker::Tracker<DetectBox>>(
1578 &self,
1579 tracker: &mut TR,
1580 timestamp: u64,
1581 outputs: &[ArrayViewDQuantized],
1582 output_boxes: &mut Vec<DetectBox>,
1583 output_masks: &mut Vec<Segmentation>,
1584 output_tracks: &mut Vec<edgefirst_tracker::TrackInfo>,
1585 ) -> Result<(), DecoderError> {
1586 output_boxes.clear();
1587 output_masks.clear();
1588 output_tracks.clear();
1589
1590 // yolo segdet variants require special handling to separate boxes that come from decoding vs active tracks.
1591 // Only boxes that come from decoding can be used for proto/mask generation.
1592 match &self.model_type {
1593 ModelType::YoloSegDet { boxes, protos } => self.decode_tracked_yolo_segdet_quantized(
1594 tracker,
1595 timestamp,
1596 outputs,
1597 boxes,
1598 protos,
1599 output_boxes,
1600 output_masks,
1601 output_tracks,
1602 ),
1603 ModelType::YoloSplitSegDet {
1604 boxes,
1605 scores,
1606 mask_coeff,
1607 protos,
1608 } => self.decode_tracked_yolo_split_segdet_quantized(
1609 tracker,
1610 timestamp,
1611 outputs,
1612 boxes,
1613 scores,
1614 mask_coeff,
1615 protos,
1616 output_boxes,
1617 output_masks,
1618 output_tracks,
1619 ),
1620 ModelType::YoloEndToEndSegDet { boxes, protos } => self
1621 .decode_tracked_yolo_end_to_end_segdet_quantized(
1622 tracker,
1623 timestamp,
1624 outputs,
1625 boxes,
1626 protos,
1627 output_boxes,
1628 output_masks,
1629 output_tracks,
1630 ),
1631 ModelType::YoloSplitEndToEndSegDet {
1632 boxes,
1633 scores,
1634 classes,
1635 mask_coeff,
1636 protos,
1637 } => self.decode_tracked_yolo_split_end_to_end_segdet_quantized(
1638 tracker,
1639 timestamp,
1640 outputs,
1641 boxes,
1642 scores,
1643 classes,
1644 mask_coeff,
1645 protos,
1646 output_boxes,
1647 output_masks,
1648 output_tracks,
1649 ),
1650 ModelType::YoloSegDet2Way {
1651 boxes,
1652 mask_coeff,
1653 protos,
1654 } => self.decode_tracked_yolo_segdet_2way_quantized(
1655 tracker,
1656 timestamp,
1657 outputs,
1658 boxes,
1659 mask_coeff,
1660 protos,
1661 output_boxes,
1662 output_masks,
1663 output_tracks,
1664 ),
1665 _ => {
1666 self.decode_quantized_impl(outputs, output_boxes, output_masks, false)?;
1667 Self::update_tracker(tracker, timestamp, output_boxes, output_tracks);
1668 Ok(())
1669 }
1670 }
1671 }
1672
1673 /// This function decodes floating point model outputs into detection boxes
1674 /// and segmentation masks. Up to `max_det` boxes and
1675 /// masks will be decoded. The function clears the provided output
1676 /// vectors before populating them with the decoded results.
1677 ///
1678 /// This function returns an `Error` if the provided outputs don't
1679 /// match the configuration provided by the user when building the decoder.
1680 ///
1681 /// Any quantization information in the configuration will be ignored.
1682 pub(crate) fn decode_tracked_float<TR: edgefirst_tracker::Tracker<DetectBox>, T>(
1683 &self,
1684 tracker: &mut TR,
1685 timestamp: u64,
1686 outputs: &[ArrayViewD<T>],
1687 output_boxes: &mut Vec<DetectBox>,
1688 output_masks: &mut Vec<Segmentation>,
1689 output_tracks: &mut Vec<edgefirst_tracker::TrackInfo>,
1690 ) -> Result<(), DecoderError>
1691 where
1692 T: Float + AsPrimitive<f32> + AsPrimitive<u8> + Send + Sync + 'static,
1693 f32: AsPrimitive<T>,
1694 {
1695 output_boxes.clear();
1696 output_masks.clear();
1697 output_tracks.clear();
1698 match &self.model_type {
1699 ModelType::YoloSegDet { boxes, protos } => {
1700 self.decode_tracked_yolo_segdet_float(
1701 tracker,
1702 timestamp,
1703 outputs,
1704 boxes,
1705 protos,
1706 output_boxes,
1707 output_masks,
1708 output_tracks,
1709 )?;
1710 }
1711 ModelType::YoloSplitSegDet {
1712 boxes,
1713 scores,
1714 mask_coeff,
1715 protos,
1716 } => {
1717 self.decode_tracked_yolo_split_segdet_float(
1718 tracker,
1719 timestamp,
1720 outputs,
1721 boxes,
1722 scores,
1723 mask_coeff,
1724 protos,
1725 output_boxes,
1726 output_masks,
1727 output_tracks,
1728 )?;
1729 }
1730 ModelType::YoloEndToEndSegDet { boxes, protos } => {
1731 self.decode_tracked_yolo_end_to_end_segdet_float(
1732 tracker,
1733 timestamp,
1734 outputs,
1735 boxes,
1736 protos,
1737 output_boxes,
1738 output_masks,
1739 output_tracks,
1740 )?;
1741 }
1742 ModelType::YoloSplitEndToEndSegDet {
1743 boxes,
1744 scores,
1745 classes,
1746 mask_coeff,
1747 protos,
1748 } => {
1749 self.decode_tracked_yolo_split_end_to_end_segdet_float(
1750 tracker,
1751 timestamp,
1752 outputs,
1753 boxes,
1754 scores,
1755 classes,
1756 mask_coeff,
1757 protos,
1758 output_boxes,
1759 output_masks,
1760 output_tracks,
1761 )?;
1762 }
1763 ModelType::YoloSegDet2Way {
1764 boxes,
1765 mask_coeff,
1766 protos,
1767 } => {
1768 self.decode_tracked_yolo_segdet_2way_float(
1769 tracker,
1770 timestamp,
1771 outputs,
1772 boxes,
1773 mask_coeff,
1774 protos,
1775 output_boxes,
1776 output_masks,
1777 output_tracks,
1778 )?;
1779 }
1780 _ => {
1781 self.decode_float_impl(outputs, output_boxes, output_masks, false)?;
1782 Self::update_tracker(tracker, timestamp, output_boxes, output_tracks);
1783 }
1784 }
1785 Ok(())
1786 }
1787
1788 /// Decodes quantized model outputs into detection boxes, returning raw
1789 /// `ProtoData` for segmentation models instead of materialized masks.
1790 ///
1791 /// Returns `Ok(None)` for detection-only and ModelPack models (use
1792 /// `decode_quantized` for those). Returns `Ok(Some(ProtoData))` for
1793 /// YOLO segmentation models.
1794 pub(crate) fn decode_tracked_quantized_proto<TR: edgefirst_tracker::Tracker<DetectBox>>(
1795 &self,
1796 tracker: &mut TR,
1797 timestamp: u64,
1798 outputs: &[ArrayViewDQuantized],
1799 output_boxes: &mut Vec<DetectBox>,
1800 output_tracks: &mut Vec<edgefirst_tracker::TrackInfo>,
1801 ) -> Result<Option<ProtoData>, DecoderError> {
1802 output_boxes.clear();
1803 output_tracks.clear();
1804 match &self.model_type {
1805 ModelType::YoloSegDet { boxes, protos } => {
1806 let proto = self.decode_tracked_yolo_segdet_quantized_proto(
1807 tracker,
1808 timestamp,
1809 outputs,
1810 boxes,
1811 protos,
1812 output_boxes,
1813 output_tracks,
1814 )?;
1815 Ok(Some(proto))
1816 }
1817 ModelType::YoloSplitSegDet {
1818 boxes,
1819 scores,
1820 mask_coeff,
1821 protos,
1822 } => {
1823 let proto = self.decode_tracked_yolo_split_segdet_quantized_proto(
1824 tracker,
1825 timestamp,
1826 outputs,
1827 boxes,
1828 scores,
1829 mask_coeff,
1830 protos,
1831 output_boxes,
1832 output_tracks,
1833 )?;
1834 Ok(Some(proto))
1835 }
1836 ModelType::YoloSegDet2Way {
1837 boxes,
1838 mask_coeff,
1839 protos,
1840 } => {
1841 let proto = self.decode_tracked_yolo_segdet_2way_quantized_proto(
1842 tracker,
1843 timestamp,
1844 outputs,
1845 boxes,
1846 mask_coeff,
1847 protos,
1848 output_boxes,
1849 output_tracks,
1850 )?;
1851 Ok(Some(proto))
1852 }
1853 ModelType::YoloEndToEndSegDet { boxes, protos } => {
1854 let proto = self.decode_tracked_yolo_end_to_end_segdet_quantized_proto(
1855 tracker,
1856 timestamp,
1857 outputs,
1858 boxes,
1859 protos,
1860 output_boxes,
1861 output_tracks,
1862 )?;
1863 Ok(Some(proto))
1864 }
1865 ModelType::YoloSplitEndToEndSegDet {
1866 boxes,
1867 scores,
1868 classes,
1869 mask_coeff,
1870 protos,
1871 } => {
1872 let proto = self.decode_tracked_yolo_split_end_to_end_segdet_quantized_proto(
1873 tracker,
1874 timestamp,
1875 outputs,
1876 boxes,
1877 scores,
1878 classes,
1879 mask_coeff,
1880 protos,
1881 output_boxes,
1882 output_tracks,
1883 )?;
1884 Ok(Some(proto))
1885 }
1886 // Non-seg variants: decode boxes via the non-proto path, then track.
1887 _ => {
1888 let mut masks = Vec::new();
1889 self.decode_quantized_impl(outputs, output_boxes, &mut masks, false)?;
1890 Self::update_tracker(tracker, timestamp, output_boxes, output_tracks);
1891 Ok(None)
1892 }
1893 }
1894 }
1895
1896 /// Decodes floating-point model outputs into detection boxes, returning
1897 /// raw `ProtoData` for segmentation models instead of materialized masks.
1898 ///
1899 /// Detections are always decoded into `output_boxes` regardless of model type.
1900 /// Returns `Ok(None)` for detection-only and ModelPack models. Returns
1901 /// `Ok(Some(ProtoData))` for YOLO segmentation models.
1902 pub(crate) fn decode_tracked_float_proto<TR: edgefirst_tracker::Tracker<DetectBox>, T>(
1903 &self,
1904 tracker: &mut TR,
1905 timestamp: u64,
1906 outputs: &[ArrayViewD<T>],
1907 output_boxes: &mut Vec<DetectBox>,
1908 output_tracks: &mut Vec<edgefirst_tracker::TrackInfo>,
1909 ) -> Result<Option<ProtoData>, DecoderError>
1910 where
1911 T: Float + AsPrimitive<f32> + AsPrimitive<u8> + Send + Sync + crate::yolo::FloatProtoElem,
1912 f32: AsPrimitive<T>,
1913 {
1914 output_boxes.clear();
1915 output_tracks.clear();
1916 match &self.model_type {
1917 ModelType::YoloSegDet { boxes, protos } => {
1918 let proto = self.decode_tracked_yolo_segdet_float_proto(
1919 tracker,
1920 timestamp,
1921 outputs,
1922 boxes,
1923 protos,
1924 output_boxes,
1925 output_tracks,
1926 )?;
1927 Ok(Some(proto))
1928 }
1929 ModelType::YoloSplitSegDet {
1930 boxes,
1931 scores,
1932 mask_coeff,
1933 protos,
1934 } => {
1935 let proto = self.decode_tracked_yolo_split_segdet_float_proto(
1936 tracker,
1937 timestamp,
1938 outputs,
1939 boxes,
1940 scores,
1941 mask_coeff,
1942 protos,
1943 output_boxes,
1944 output_tracks,
1945 )?;
1946 Ok(Some(proto))
1947 }
1948 ModelType::YoloSegDet2Way {
1949 boxes,
1950 mask_coeff,
1951 protos,
1952 } => {
1953 let proto = self.decode_tracked_yolo_segdet_2way_float_proto(
1954 tracker,
1955 timestamp,
1956 outputs,
1957 boxes,
1958 mask_coeff,
1959 protos,
1960 output_boxes,
1961 output_tracks,
1962 )?;
1963 Ok(Some(proto))
1964 }
1965 ModelType::YoloEndToEndSegDet { boxes, protos } => {
1966 let proto = self.decode_tracked_yolo_end_to_end_segdet_float_proto(
1967 tracker,
1968 timestamp,
1969 outputs,
1970 boxes,
1971 protos,
1972 output_boxes,
1973 output_tracks,
1974 )?;
1975 Ok(Some(proto))
1976 }
1977 ModelType::YoloSplitEndToEndSegDet {
1978 boxes,
1979 scores,
1980 classes,
1981 mask_coeff,
1982 protos,
1983 } => {
1984 let proto = self.decode_tracked_yolo_split_end_to_end_segdet_float_proto(
1985 tracker,
1986 timestamp,
1987 outputs,
1988 boxes,
1989 scores,
1990 classes,
1991 mask_coeff,
1992 protos,
1993 output_boxes,
1994 output_tracks,
1995 )?;
1996 Ok(Some(proto))
1997 }
1998 // Non-seg variants: decode boxes via the non-proto path, then track.
1999 _ => {
2000 let mut masks = Vec::new();
2001 self.decode_float_impl(outputs, output_boxes, &mut masks, false)?;
2002 Self::update_tracker(tracker, timestamp, output_boxes, output_tracks);
2003 Ok(None)
2004 }
2005 }
2006 }
2007
2008 // ========================================================================
2009 // TensorDyn-based tracked public API
2010 // ========================================================================
2011
2012 /// Decode model outputs with tracking.
2013 ///
2014 /// Accepts `TensorDyn` outputs directly from model inference. Automatically
2015 /// dispatches to quantized or float paths based on the tensor dtype, then
2016 /// updates the tracker with the decoded boxes.
2017 ///
2018 /// Always decodes one label per box, regardless of [`Self::multi_label`]:
2019 /// trackers match on IoU only, so per-class duplicates of one anchor would
2020 /// spawn phantom tracks. Logs a warning once per decoder when the decoder
2021 /// has multi-label enabled. Like [`Self::decode_for_tracking`], it uses
2022 /// the argmax default pre-NMS cap unless the cap was set explicitly.
2023 ///
2024 /// # Arguments
2025 ///
2026 /// * `tracker` - The tracker instance to update
2027 /// * `timestamp` - Current frame timestamp
2028 /// * `outputs` - Tensor outputs from model inference
2029 /// * `output_boxes` - Destination for decoded detection boxes (cleared first)
2030 /// * `output_masks` - Destination for decoded segmentation masks (cleared first)
2031 /// * `output_tracks` - Destination for track info (cleared first)
2032 ///
2033 /// # Errors
2034 ///
2035 /// Returns `DecoderError` if tensor mapping fails, dtypes are unsupported,
2036 /// or the outputs don't match the decoder's model configuration.
2037 pub fn decode_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
2038 &self,
2039 tracker: &mut TR,
2040 timestamp: u64,
2041 outputs: &[&edgefirst_tensor::TensorDyn],
2042 output_boxes: &mut Vec<DetectBox>,
2043 output_masks: &mut Vec<Segmentation>,
2044 output_tracks: &mut Vec<edgefirst_tracker::TrackInfo>,
2045 ) -> Result<(), DecoderError> {
2046 self.warn_once_tracked_multi_label();
2047 // Per-scale fast path: route via the basic decode then update the
2048 // tracker. The current implementation keeps the tracker integration simple; per-frame
2049 // decoupling between detection and tracking is preserved.
2050 if self.per_scale.is_some() {
2051 output_tracks.clear();
2052 self.decode_impl(outputs, output_boxes, output_masks, false)?;
2053 Self::update_tracker(tracker, timestamp, output_boxes, output_tracks);
2054 return Ok(());
2055 }
2056
2057 let mapped = tensor_bridge::map_tensors(outputs)?;
2058 match &mapped {
2059 tensor_bridge::MappedOutputs::Quantized(maps) => {
2060 let views = tensor_bridge::quantized_views(maps)?;
2061 self.decode_tracked_quantized(
2062 tracker,
2063 timestamp,
2064 &views,
2065 output_boxes,
2066 output_masks,
2067 output_tracks,
2068 )
2069 }
2070 tensor_bridge::MappedOutputs::Float16(maps) => {
2071 let views = tensor_bridge::f16_views(maps)?;
2072 self.decode_tracked_float(
2073 tracker,
2074 timestamp,
2075 &views,
2076 output_boxes,
2077 output_masks,
2078 output_tracks,
2079 )
2080 }
2081 tensor_bridge::MappedOutputs::Float32(maps) => {
2082 let views = tensor_bridge::f32_views(maps)?;
2083 self.decode_tracked_float(
2084 tracker,
2085 timestamp,
2086 &views,
2087 output_boxes,
2088 output_masks,
2089 output_tracks,
2090 )
2091 }
2092 tensor_bridge::MappedOutputs::Float64(maps) => {
2093 let views = tensor_bridge::f64_views(maps)?;
2094 self.decode_tracked_float(
2095 tracker,
2096 timestamp,
2097 &views,
2098 output_boxes,
2099 output_masks,
2100 output_tracks,
2101 )
2102 }
2103 }
2104 }
2105
2106 /// Decode model outputs with tracking, returning raw proto data for
2107 /// segmentation models.
2108 ///
2109 /// Accepts `TensorDyn` outputs directly from model inference.
2110 /// Returns `Ok(None)` for detection-only and ModelPack models.
2111 /// Returns `Ok(Some(ProtoData))` for YOLO segmentation models.
2112 ///
2113 /// Always decodes one label per box, regardless of [`Self::multi_label`]:
2114 /// trackers match on IoU only, so per-class duplicates of one anchor would
2115 /// spawn phantom tracks. Logs a warning once per decoder when the decoder
2116 /// has multi-label enabled. Like [`Self::decode_for_tracking`], it uses
2117 /// the argmax default pre-NMS cap unless the cap was set explicitly.
2118 ///
2119 /// # Arguments
2120 ///
2121 /// * `tracker` - The tracker instance to update
2122 /// * `timestamp` - Current frame timestamp
2123 /// * `outputs` - Tensor outputs from model inference
2124 /// * `output_boxes` - Destination for decoded detection boxes (cleared first)
2125 /// * `output_tracks` - Destination for track info (cleared first)
2126 ///
2127 /// # Errors
2128 ///
2129 /// Returns `DecoderError` if tensor mapping fails, dtypes are unsupported,
2130 /// or the outputs don't match the decoder's model configuration.
2131 pub fn decode_proto_tracked<TR: edgefirst_tracker::Tracker<DetectBox>>(
2132 &self,
2133 tracker: &mut TR,
2134 timestamp: u64,
2135 outputs: &[&edgefirst_tensor::TensorDyn],
2136 output_boxes: &mut Vec<DetectBox>,
2137 output_tracks: &mut Vec<edgefirst_tracker::TrackInfo>,
2138 ) -> Result<Option<ProtoData>, DecoderError> {
2139 self.warn_once_tracked_multi_label();
2140 // Per-scale fast path: route via the basic decode_proto then
2141 // update the tracker on the resulting boxes.
2142 if self.per_scale.is_some() {
2143 output_tracks.clear();
2144 let proto = self.decode_proto_impl(outputs, output_boxes, false)?;
2145 Self::update_tracker(tracker, timestamp, output_boxes, output_tracks);
2146 return Ok(proto);
2147 }
2148
2149 let mapped = tensor_bridge::map_tensors(outputs)?;
2150 match &mapped {
2151 tensor_bridge::MappedOutputs::Quantized(maps) => {
2152 let views = tensor_bridge::quantized_views(maps)?;
2153 self.decode_tracked_quantized_proto(
2154 tracker,
2155 timestamp,
2156 &views,
2157 output_boxes,
2158 output_tracks,
2159 )
2160 }
2161 tensor_bridge::MappedOutputs::Float16(maps) => {
2162 let views = tensor_bridge::f16_views(maps)?;
2163 self.decode_tracked_float_proto(
2164 tracker,
2165 timestamp,
2166 &views,
2167 output_boxes,
2168 output_tracks,
2169 )
2170 }
2171 tensor_bridge::MappedOutputs::Float32(maps) => {
2172 let views = tensor_bridge::f32_views(maps)?;
2173 self.decode_tracked_float_proto(
2174 tracker,
2175 timestamp,
2176 &views,
2177 output_boxes,
2178 output_tracks,
2179 )
2180 }
2181 tensor_bridge::MappedOutputs::Float64(maps) => {
2182 let views = tensor_bridge::f64_views(maps)?;
2183 self.decode_tracked_float_proto(
2184 tracker,
2185 timestamp,
2186 &views,
2187 output_boxes,
2188 output_tracks,
2189 )
2190 }
2191 }
2192 }
2193}