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