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