Skip to main content

edgefirst_decoder/decoder/
configs.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! The vocabulary types a model config is built from.
5//!
6//! Per-role output descriptors ([`Detection`], [`Boxes`], [`Scores`],
7//! [`Classes`], [`Segmentation`], [`Protos`], [`MaskCoefficients`],
8//! [`Mask`]), the axis names used to declare physical layout ([`DimName`]),
9//! quantization pairs ([`QuantTuple`]), and the enums that steer decoding:
10//! [`DecoderType`], [`DecoderVersion`], [`Nms`], and the resolved
11//! [`ModelType`].
12//!
13//! [`Nms`] is worth a note: it has no `None` variant. Bypassing suppression is
14//! expressed as `Option<Nms>::None` on the decoder configuration, and
15//! [`Nms::Auto`] means "take the mode from the config document", falling back
16//! to [`Nms::ClassAware`] when the document is silent.
17
18use std::collections::HashMap;
19use std::fmt::Display;
20
21use serde::{Deserialize, Serialize};
22
23/// Deserialize dshape from either array-of-tuples or array-of-single-key-dicts.
24///
25/// The metadata spec produces `[{"batch": 1}, {"num_features": 84}]` (dict format),
26/// while serde's default `Vec<(A, B)>` expects `[["batch", 1]]` (tuple format).
27/// This deserializer accepts both.
28pub fn deserialize_dshape<'de, D>(deserializer: D) -> Result<Vec<(DimName, usize)>, D::Error>
29where
30    D: serde::Deserializer<'de>,
31{
32    #[derive(Deserialize)]
33    #[serde(untagged)]
34    enum DShapeItem {
35        Tuple(DimName, usize),
36        Map(HashMap<DimName, usize>),
37    }
38
39    let items: Vec<DShapeItem> = Vec::deserialize(deserializer)?;
40    items
41        .into_iter()
42        .map(|item| match item {
43            DShapeItem::Tuple(name, size) => Ok((name, size)),
44            DShapeItem::Map(map) => {
45                if map.len() != 1 {
46                    return Err(serde::de::Error::custom(
47                        "dshape map entry must have exactly one key",
48                    ));
49                }
50                let (name, size) = map.into_iter().next().unwrap();
51                Ok((name, size))
52            }
53        })
54        .collect()
55}
56
57#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Copy)]
58pub struct QuantTuple(pub f32, pub i32);
59impl From<QuantTuple> for (f32, i32) {
60    fn from(value: QuantTuple) -> Self {
61        (value.0, value.1)
62    }
63}
64
65impl From<(f32, i32)> for QuantTuple {
66    fn from(value: (f32, i32)) -> Self {
67        QuantTuple(value.0, value.1)
68    }
69}
70
71#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Default)]
72pub struct Segmentation {
73    #[serde(default)]
74    pub decoder: DecoderType,
75    #[serde(default)]
76    pub quantization: Option<QuantTuple>,
77    #[serde(default)]
78    pub shape: Vec<usize>,
79    #[serde(default, deserialize_with = "deserialize_dshape")]
80    pub dshape: Vec<(DimName, usize)>,
81}
82
83#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Default)]
84pub struct Protos {
85    #[serde(default)]
86    pub decoder: DecoderType,
87    #[serde(default)]
88    pub quantization: Option<QuantTuple>,
89    #[serde(default)]
90    pub shape: Vec<usize>,
91    #[serde(default, deserialize_with = "deserialize_dshape")]
92    pub dshape: Vec<(DimName, usize)>,
93}
94
95#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Default)]
96pub struct MaskCoefficients {
97    #[serde(default)]
98    pub decoder: DecoderType,
99    #[serde(default)]
100    pub quantization: Option<QuantTuple>,
101    #[serde(default)]
102    pub shape: Vec<usize>,
103    #[serde(default, deserialize_with = "deserialize_dshape")]
104    pub dshape: Vec<(DimName, usize)>,
105}
106
107#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Default)]
108pub struct Mask {
109    #[serde(default)]
110    pub decoder: DecoderType,
111    #[serde(default)]
112    pub quantization: Option<QuantTuple>,
113    #[serde(default)]
114    pub shape: Vec<usize>,
115    #[serde(default, deserialize_with = "deserialize_dshape")]
116    pub dshape: Vec<(DimName, usize)>,
117}
118
119#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Default)]
120pub struct Detection {
121    #[serde(default)]
122    pub anchors: Option<Vec<[f32; 2]>>,
123    #[serde(default)]
124    pub decoder: DecoderType,
125    #[serde(default)]
126    pub quantization: Option<QuantTuple>,
127    #[serde(default)]
128    pub shape: Vec<usize>,
129    #[serde(default, deserialize_with = "deserialize_dshape")]
130    pub dshape: Vec<(DimName, usize)>,
131    /// Whether box coordinates are normalized to `[0,1]` range.
132    /// - `Some(true)`: Coordinates in `[0,1]` range relative to model input
133    /// - `Some(false)`: Pixel coordinates relative to model input
134    ///   (letterboxed)
135    /// - `None`: Unknown, caller must infer (e.g., check if any coordinate
136    ///   > 1.0)
137    #[serde(default)]
138    pub normalized: Option<bool>,
139}
140
141#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Default)]
142pub struct Scores {
143    #[serde(default)]
144    pub decoder: DecoderType,
145    #[serde(default)]
146    pub quantization: Option<QuantTuple>,
147    #[serde(default)]
148    pub shape: Vec<usize>,
149    #[serde(default, deserialize_with = "deserialize_dshape")]
150    pub dshape: Vec<(DimName, usize)>,
151}
152
153#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Default)]
154pub struct Boxes {
155    #[serde(default)]
156    pub decoder: DecoderType,
157    #[serde(default)]
158    pub quantization: Option<QuantTuple>,
159    #[serde(default)]
160    pub shape: Vec<usize>,
161    #[serde(default, deserialize_with = "deserialize_dshape")]
162    pub dshape: Vec<(DimName, usize)>,
163    /// Whether box coordinates are normalized to `[0,1]` range.
164    /// - `Some(true)`: Coordinates in `[0,1]` range relative to model input
165    /// - `Some(false)`: Pixel coordinates relative to model input
166    ///   (letterboxed)
167    /// - `None`: Unknown, caller must infer (e.g., check if any coordinate
168    ///   > 1.0)
169    #[serde(default)]
170    pub normalized: Option<bool>,
171}
172
173#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Default)]
174pub struct Classes {
175    #[serde(default)]
176    pub decoder: DecoderType,
177    #[serde(default)]
178    pub quantization: Option<QuantTuple>,
179    #[serde(default)]
180    pub shape: Vec<usize>,
181    #[serde(default, deserialize_with = "deserialize_dshape")]
182    pub dshape: Vec<(DimName, usize)>,
183}
184
185edgefirst_tensor::ef_vocabulary! {
186    /// A named tensor axis. The code is the one the C `EF_DIM_NAME_*`
187    /// enumerators and the Python `DimName` enum use.
188    #[derive(Serialize, Deserialize)]
189    pub enum DimName {
190        #[serde(rename = "batch")]
191        Batch = 0, "batch", BATCH,
192        #[serde(rename = "height")]
193        Height = 1, "height", HEIGHT,
194        #[serde(rename = "width")]
195        Width = 2, "width", WIDTH,
196        #[serde(rename = "num_classes")]
197        NumClasses = 3, "num_classes", NUM_CLASSES,
198        #[serde(rename = "num_features")]
199        NumFeatures = 4, "num_features", NUM_FEATURES,
200        #[serde(rename = "num_boxes")]
201        NumBoxes = 5, "num_boxes", NUM_BOXES,
202        #[serde(rename = "num_protos")]
203        NumProtos = 6, "num_protos", NUM_PROTOS,
204        #[serde(rename = "num_anchors_x_features")]
205        NumAnchorsXFeatures = 7, "num_anchors_x_features", NUM_ANCHORS_X_FEATURES,
206        #[serde(rename = "padding")]
207        Padding = 8, "padding", PADDING,
208        #[serde(rename = "box_coords")]
209        BoxCoords = 9, "box_coords", BOX_COORDS,
210        /// Query axis of mask-classification outputs (`class_logits`,
211        /// `mask_logits`).
212        #[serde(rename = "num_queries")]
213        NumQueries = 11, "num_queries", NUM_QUERIES,
214        /// Any axis name the HAL does not recognise (e.g. a producer's
215        /// `channels` on the input dshape). Preserved so the dshape length still
216        /// matches the shape and the axis sorts to the canonical tail in
217        /// `swap_axes_if_needed`, but it never satisfies a required-dimension
218        /// check. Keeps metadata parsing tolerant of unknown axis names instead
219        /// of failing the whole decoder build.
220        #[serde(other)]
221        Unknown = 10, "unknown", UNKNOWN,
222    }
223    #[doc(hidden)]
224    pub mod dim_name_code;
225}
226
227impl Display for DimName {
228    /// Formats the DimName for display
229    /// # Examples
230    /// ```rust
231    /// # use edgefirst_decoder::configs::DimName;
232    /// let dim = DimName::Height;
233    /// assert_eq!(format!("{}", dim), "height");
234    /// # let s = format!("{} {} {} {} {} {} {} {} {} {}", DimName::Batch, DimName::Height, DimName::Width, DimName::NumClasses, DimName::NumFeatures, DimName::NumBoxes, DimName::NumProtos, DimName::NumAnchorsXFeatures, DimName::Padding, DimName::BoxCoords);
235    /// # assert_eq!(s, "batch height width num_classes num_features num_boxes num_protos num_anchors_x_features padding box_coords");
236    /// ```
237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238        f.write_str(self.as_str())
239    }
240}
241
242edgefirst_tensor::ef_vocabulary! {
243    /// The post-processing family. The code is the one the C
244    /// `EF_DECODER_TYPE_*` enumerators and the Python `DecoderType` enum use.
245    #[derive(Serialize, Deserialize, Default)]
246    pub enum DecoderType {
247        #[default]
248        #[serde(rename = "ultralytics", alias = "yolov8")]
249        Ultralytics = 0, "ultralytics", ULTRALYTICS,
250        #[serde(rename = "modelpack")]
251        ModelPack = 1, "modelpack", MODELPACK,
252        /// Generic semantic segmentation (per-pixel scores or class map).
253        #[serde(rename = "semantic")]
254        Semantic = 2, "semantic", SEMANTIC,
255    }
256    #[doc(hidden)]
257    pub mod decoder_type_code;
258}
259
260edgefirst_tensor::ef_vocabulary! {
261    /// Decoder version for Ultralytics models.
262    ///
263    /// Specifies the YOLO architecture version, which determines the decoding
264    /// strategy:
265    /// - `Yolov5`, `Yolov8`, `Yolo11`: Traditional models requiring external
266    ///   NMS
267    /// - `Yolo26`: End-to-end models with NMS embedded in the model
268    ///   architecture
269    ///
270    /// When `decoder_version` is set to `Yolo26`, the decoder uses end-to-end
271    /// model types regardless of the `nms` setting.
272    ///
273    /// The code is the one the Python `DecoderVersion` enum uses.
274    #[derive(Serialize, Deserialize)]
275    pub enum DecoderVersion {
276        /// YOLOv5 - anchor-free DFL decoder, requires external NMS
277        #[serde(rename = "yolov5")]
278        Yolov5 = 0, "yolov5", YOLOV5,
279        /// YOLOv8 - anchor-free DFL decoder, requires external NMS
280        #[serde(rename = "yolov8")]
281        Yolov8 = 1, "yolov8", YOLOV8,
282        /// YOLO11 - anchor-free DFL decoder, requires external NMS
283        #[serde(rename = "yolo11")]
284        Yolo11 = 2, "yolo11", YOLO11,
285        /// YOLO26 - end-to-end model with embedded NMS (one-to-one matching
286        /// heads)
287        #[serde(rename = "yolo26")]
288        Yolo26 = 3, "yolo26", YOLO26,
289    }
290    #[doc(hidden)]
291    pub mod decoder_version_code;
292}
293
294impl DecoderVersion {
295    /// Returns true if this version uses end-to-end inference (embedded
296    /// NMS).
297    pub fn is_end_to_end(&self) -> bool {
298        matches!(self, DecoderVersion::Yolo26)
299    }
300}
301
302edgefirst_tensor::ef_vocabulary! {
303    /// NMS (Non-Maximum Suppression) mode for filtering overlapping detections.
304    ///
305    /// This enum is used with `Option<Nms>`:
306    /// - `Some(Nms::Auto)` — resolve from config or fall back to `ClassAware`
307    /// - `Some(Nms::ClassAgnostic)` — class-agnostic NMS: suppress overlapping
308    ///   boxes regardless of class label
309    /// - `Some(Nms::ClassAware)` — class-aware NMS: only suppress boxes that
310    ///   share the same class label AND overlap above the IoU threshold
311    /// - `None` — bypass NMS entirely (for end-to-end models with embedded NMS)
312    ///
313    /// The code is the one the C `EF_NMS_*` enumerators and the Python `Nms`
314    /// enum use. Code 0 is reserved for `None` (NMS off), which has no
315    /// variant here; see [`NMS_OFF_CODE`].
316    #[derive(Serialize, Deserialize, Default)]
317    #[serde(rename_all = "snake_case")]
318    pub enum Nms {
319        /// Let the builder resolve NMS mode from the model config (e.g.
320        /// `edgefirst.json`).  Falls back to [`Nms::ClassAware`] when no
321        /// config specifies a mode.  This is the builder default — callers
322        /// should only use an explicit variant when they need to override
323        /// the config.
324        Auto = 1, "auto", AUTO,
325        /// Only suppress boxes with the same class label that overlap (default
326        /// concrete behavior; matches trainer and COCO evaluation).
327        #[default]
328        ClassAware = 2, "class_aware", CLASS_AWARE,
329        /// Suppress overlapping boxes regardless of class label.
330        ClassAgnostic = 3, "class_agnostic", CLASS_AGNOSTIC,
331    }
332    #[doc(hidden)]
333    pub mod nms_code;
334}
335
336edgefirst_tensor::ef_vocabulary! {
337    /// The kind of a model output, without its configuration: the tag of
338    /// [`crate::ConfigOutput`]. The string form is that enum's serialized
339    /// `type`; the code is the one the C `EF_OUTPUT_TYPE_*` enumerators use.
340    pub enum OutputType {
341        Detection = 0, "detection", DETECTION,
342        Boxes = 1, "boxes", BOXES,
343        Scores = 2, "scores", SCORES,
344        Protos = 3, "protos", PROTOS,
345        Segmentation = 4, "segmentation", SEGMENTATION,
346        MaskCoefficients = 5, "mask_coefs", MASK_COEFFICIENTS,
347        Mask = 6, "masks", MASK,
348        Classes = 7, "classes", CLASSES,
349    }
350    #[doc(hidden)]
351    pub mod output_type_code;
352}
353
354/// The code for "NMS off" (`Option::<Nms>::None`). No [`Nms`] variant uses it.
355pub const NMS_OFF_CODE: u32 = 0;
356
357impl Nms {
358    /// The code for an optional mode, `None` (NMS off) included.
359    pub const fn option_code(nms: Option<Nms>) -> u32 {
360        match nms {
361            Some(n) => n.code(),
362            None => NMS_OFF_CODE,
363        }
364    }
365
366    /// Decode an optional mode, [`NMS_OFF_CODE`] included. The outer `None`
367    /// is an unknown code; `Some(None)` is NMS off.
368    pub fn from_option_code(code: u32) -> Option<Option<Nms>> {
369        if code == NMS_OFF_CODE {
370            Some(None)
371        } else {
372            Nms::from_code(code).map(Some)
373        }
374    }
375}
376
377#[derive(Debug, Clone, PartialEq)]
378pub enum ModelType {
379    ModelPackSegDet {
380        boxes: Boxes,
381        scores: Scores,
382        segmentation: Segmentation,
383    },
384    ModelPackSegDetSplit {
385        detection: Vec<Detection>,
386        segmentation: Segmentation,
387    },
388    ModelPackDet {
389        boxes: Boxes,
390        scores: Scores,
391    },
392    ModelPackDetSplit {
393        detection: Vec<Detection>,
394    },
395    ModelPackSeg {
396        segmentation: Segmentation,
397    },
398    YoloDet {
399        boxes: Detection,
400    },
401    YoloSegDet {
402        boxes: Detection,
403        protos: Protos,
404    },
405    YoloSplitDet {
406        boxes: Boxes,
407        scores: Scores,
408    },
409    YoloSplitSegDet {
410        boxes: Boxes,
411        scores: Scores,
412        mask_coeff: MaskCoefficients,
413        protos: Protos,
414    },
415    /// 2-way split YOLO segmentation detection.
416    /// Combined detection tensor (boxes + scores) with separate mask
417    /// coefficients and prototype masks.
418    /// - detection: [1, nc+4, N] — boxes and scores combined
419    /// - mask_coeff: [1, 32, N] — mask coefficients (separate tensor)
420    /// - protos: [1, H/4, W/4, 32] — prototype masks
421    YoloSegDet2Way {
422        boxes: Detection,
423        mask_coeff: MaskCoefficients,
424        protos: Protos,
425    },
426    /// End-to-end YOLO detection (post-NMS output from model)
427    /// Input shape: (1, N, 6+) where columns are [x1, y1, x2, y2, conf,
428    /// class, ...]
429    YoloEndToEndDet {
430        boxes: Detection,
431    },
432    /// End-to-end YOLO detection + segmentation (post-NMS output from
433    /// model) Input shape: (1, N, 6 + num_protos) where columns are
434    /// [x1, y1, x2, y2, conf, class, mask_coeff_0, ..., mask_coeff_31]
435    YoloEndToEndSegDet {
436        boxes: Detection,
437        protos: Protos,
438    },
439    /// Split end-to-end YOLO detection (onnx2tf splits `[1,N,6]` into 3
440    /// tensors) boxes: [batch, N, 4] xyxy, scores: [batch, N, 1],
441    /// classes: [batch, N, 1]
442    YoloSplitEndToEndDet {
443        boxes: Boxes,
444        scores: Scores,
445        classes: Classes,
446    },
447    /// Split end-to-end YOLO seg detection (onnx2tf splits into 5
448    /// tensors)
449    YoloSplitEndToEndSegDet {
450        boxes: Boxes,
451        scores: Scores,
452        classes: Classes,
453        mask_coeff: MaskCoefficients,
454        protos: Protos,
455    },
456    /// Per-scale (physical-output-decomposition) YOLO model. The
457    /// per-scale subsystem (`crates/decoder/src/per_scale/`) owns
458    /// model decoding entirely; this variant exists as a marker so the
459    /// `Decoder::model_type` field has a sensible value for per-scale
460    /// Decoders that bypass the legacy `ModelType`-driven dispatch.
461    PerScale,
462    /// Semantic segmentation model (`decoder: semantic`). Decoded only by
463    /// `Decoder::decode_semantic`; the box and mask entry points reject it.
464    Semantic,
465}
466
467/// Checks that a `dshape` describes `shape`: same rank, matching sizes, unique
468/// axis names, `padding` axes of 1 and `box_coords` axes of 4. An empty
469/// `dshape` asserts the shape is already canonical.
470pub(crate) fn validate_dshape(
471    role_name: &str,
472    shape: &[usize],
473    dshape: &[(DimName, usize)],
474) -> Result<(), crate::DecoderError> {
475    use crate::DecoderError;
476    if dshape.is_empty() {
477        // Omitted dshape → caller asserts shape is already canonical.
478        return Ok(());
479    }
480
481    if dshape.len() != shape.len() {
482        return Err(DecoderError::InvalidConfig(format!(
483            "{role_name} output: dshape has {} entries but shape has {} entries \
484             — shape and dshape must describe the same axes in the same order",
485            dshape.len(),
486            shape.len()
487        )));
488    }
489
490    for (i, (name, size)) in dshape.iter().enumerate() {
491        if *size != shape[i] {
492            return Err(DecoderError::InvalidConfig(format!(
493                "{role_name} output: dshape[{i}] = ({name}, {size}) does not \
494                 match shape[{i}] = {} — declare shape and dshape in the \
495                 same physical order (outermost axis first)",
496                shape[i]
497            )));
498        }
499        // Role-agnostic size constraints on well-known axis roles.
500        if *name == DimName::Padding && *size != 1 {
501            return Err(DecoderError::InvalidConfig(format!(
502                "{role_name} output: `padding` axis must have size 1, got {size}"
503            )));
504        }
505        if *name == DimName::BoxCoords && *size != 4 {
506            return Err(DecoderError::InvalidConfig(format!(
507                "{role_name} output: `box_coords` axis must have size 4, got {size}"
508            )));
509        }
510    }
511
512    // Duplicate-axis check: duplicates map to the same canonical slot
513    // and would break `swap_axes_if_needed` by producing a
514    // non-permutation ordering.
515    for i in 0..dshape.len() {
516        for j in (i + 1)..dshape.len() {
517            if dshape[i].0 == dshape[j].0 {
518                return Err(DecoderError::InvalidConfig(format!(
519                    "{role_name} output: dshape axis `{}` appears at \
520                     both index {i} and {j}",
521                    dshape[i].0
522                )));
523            }
524        }
525    }
526
527    Ok(())
528}
529
530#[cfg(test)]
531mod vocabulary_tests {
532    use super::*;
533
534    #[test]
535    fn nms_off_is_code_zero_and_no_variant_claims_it() {
536        assert_eq!(Nms::from_code(NMS_OFF_CODE), None);
537        assert_eq!(Nms::option_code(None), NMS_OFF_CODE);
538        assert_eq!(Nms::from_option_code(NMS_OFF_CODE), Some(None));
539        for &n in Nms::all() {
540            assert_ne!(n.code(), NMS_OFF_CODE);
541            assert_eq!(Nms::from_option_code(n.code()), Some(Some(n)));
542        }
543        assert_eq!(Nms::from_option_code(99), None);
544    }
545
546    #[test]
547    fn serde_names_match_the_vocabulary_strings() {
548        for &n in Nms::all() {
549            assert_eq!(serde_json::to_value(n).unwrap(), n.as_str());
550        }
551        for &d in DecoderType::all() {
552            assert_eq!(serde_json::to_value(d).unwrap(), d.as_str());
553        }
554        for &v in DecoderVersion::all() {
555            assert_eq!(serde_json::to_value(v).unwrap(), v.as_str());
556        }
557        for &d in DimName::all() {
558            assert_eq!(d.to_string(), d.as_str());
559            if d != DimName::Unknown {
560                assert_eq!(serde_json::to_value(d).unwrap(), d.as_str());
561            }
562        }
563    }
564}