Skip to main content

edgefirst_decoder/decoder/
builder.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::HashSet;
5
6use super::config::ConfigOutputRef;
7use super::configs::{self, DecoderType, DecoderVersion, DimName, ModelType};
8use super::merge::DecodeProgram;
9use super::{ConfigOutput, ConfigOutputs, Decoder};
10use crate::per_scale::{DecodeDtype, PerScalePlan};
11use crate::schema::SchemaV2;
12use crate::DecoderError;
13
14/// Extract `(width, height)` from a schema [`crate::schema::InputSpec`].
15///
16/// Prefers named dims (`DimName::Width` / `DimName::Height`) when the
17/// `dshape` is populated, falling back to the NHWC convention
18/// (`shape[1] = H, shape[2] = W`) for 4-element shapes whenever either
19/// named dim is missing. Returns `None` for any other shape arity — the
20/// decoder treats that as "input dims unknown" and skips the EDGEAI-1303
21/// normalization path.
22///
23/// The fallback fires when **either** `Height` or `Width` is missing from
24/// the dshape (not only when both are absent), so a partially-named
25/// dshape (e.g. only `Width`) still resolves both dims via positional
26/// inference instead of silently disabling normalization.
27fn input_dims_from_spec(input: &crate::schema::InputSpec) -> Option<(usize, usize)> {
28    use crate::configs::DimName;
29    let mut h = None;
30    let mut w = None;
31    // `SchemaV2::validate()` doesn't currently enforce
32    // `dshape.len() <= shape.len()`, so a malformed schema can trip an
33    // out-of-bounds index here. Use `shape.get(i)` to silently skip
34    // dshape entries that overflow the shape — the caller treats
35    // missing dims as "unknown" and disables EDGEAI-1303 normalization.
36    for (i, (name, _)) in input.dshape.iter().enumerate() {
37        match name {
38            DimName::Height => h = input.shape.get(i).copied(),
39            DimName::Width => w = input.shape.get(i).copied(),
40            _ => {}
41        }
42    }
43    if (h.is_none() || w.is_none()) && input.shape.len() == 4 {
44        // NHWC default: [N, H, W, C]. Mirrors the per-scale `extract_hw`
45        // fallback (`crates/decoder/src/per_scale/plan.rs::extract_hw`).
46        // Only fill the missing axis so a partial named dshape still
47        // resolves both dims.
48        h = h.or(Some(input.shape[1]));
49        w = w.or(Some(input.shape[2]));
50    }
51    match (w, h) {
52        (Some(w), Some(h)) => Some((w, h)),
53        _ => None,
54    }
55}
56
57#[cfg(test)]
58mod input_dims_from_spec_tests {
59    use super::input_dims_from_spec;
60    use crate::configs::DimName;
61    use crate::schema::InputSpec;
62
63    #[test]
64    fn named_dshape_resolves_dims() {
65        let spec = InputSpec {
66            shape: vec![1, 480, 640, 3],
67            dshape: vec![
68                (DimName::Batch, 1),
69                (DimName::Height, 480),
70                (DimName::Width, 640),
71                (DimName::NumFeatures, 3),
72            ],
73            cameraadaptor: None,
74        };
75        assert_eq!(input_dims_from_spec(&spec), Some((640, 480)));
76    }
77
78    #[test]
79    fn empty_dshape_falls_back_to_nhwc_for_4d() {
80        let spec = InputSpec {
81            shape: vec![1, 480, 640, 3],
82            dshape: vec![],
83            cameraadaptor: None,
84        };
85        assert_eq!(input_dims_from_spec(&spec), Some((640, 480)));
86    }
87
88    #[test]
89    fn malformed_dshape_longer_than_shape_does_not_panic() {
90        // Regression for Copilot review on PR #63: indexing
91        // `input.shape[i]` while iterating dshape can OOB-panic when
92        // `dshape.len() > shape.len()`. The fix uses `shape.get(i)`
93        // and silently treats overflow as "dim missing".
94        let spec = InputSpec {
95            shape: vec![640, 480], // 2-D shape
96            dshape: vec![
97                (DimName::Width, 640),
98                (DimName::Height, 480),
99                (DimName::NumFeatures, 3), // overflow — index 2 ≥ shape.len()
100            ],
101            cameraadaptor: None,
102        };
103        // First two dshape entries resolve via `shape.get()`; the third
104        // is a no-op. Width/Height both resolved, so we expect Some.
105        assert_eq!(input_dims_from_spec(&spec), Some((640, 480)));
106    }
107
108    #[test]
109    fn malformed_dshape_only_overflow_returns_none() {
110        // All dshape entries are past the shape boundary — width and
111        // height stay None and the 4-D NHWC fallback doesn't fire
112        // (shape.len() == 1), so we get None instead of a panic.
113        let spec = InputSpec {
114            shape: vec![1],
115            dshape: vec![
116                (DimName::NumFeatures, 3),
117                (DimName::Height, 480),
118                (DimName::Width, 640),
119            ],
120            cameraadaptor: None,
121        };
122        assert_eq!(input_dims_from_spec(&spec), None);
123    }
124}
125
126#[derive(Debug, Clone, PartialEq)]
127pub struct DecoderBuilder {
128    config_src: Option<ConfigSource>,
129    iou_threshold: f32,
130    score_threshold: f32,
131    /// NMS mode.
132    ///
133    /// - `Some(Nms::Auto)` — resolve from config or fallback to
134    ///   `ClassAgnostic` (builder default)
135    /// - `Some(Nms::ClassAgnostic)` — explicit class-agnostic override
136    /// - `Some(Nms::ClassAware)` — explicit class-aware override
137    /// - `None` — bypass NMS entirely
138    nms: Option<configs::Nms>,
139    /// Output dtype for the per-scale fast path. Has no effect on
140    /// schemas without per-scale children (which use the legacy decode
141    /// path).
142    decode_dtype: DecodeDtype,
143    pre_nms_top_k: usize,
144    max_det: usize,
145    /// Explicit override for the model input dimensions `(width, height)`,
146    /// consumed by EDGEAI-1303 normalization. When set, takes precedence
147    /// over schema-derived dims; when `None`, the value is read from the
148    /// schema's `input.shape` / `input.dshape` at build time.
149    input_dims: Option<(usize, usize)>,
150    /// Emit one candidate per (anchor, class) for every class above the score
151    /// threshold — matching Ultralytics `val` multi-label decode.
152    /// OFF by default.  Never read from schema/config (builder-only flag).
153    multi_label: bool,
154}
155
156#[derive(Debug, Clone, PartialEq)]
157enum ConfigSource {
158    Yaml(String),
159    Json(String),
160    Config(ConfigOutputs),
161    /// Schema v2 metadata. During build the schema is either converted
162    /// to a legacy [`ConfigOutputs`] (flat case) or used to construct a
163    /// [`DecodeProgram`] that performs per-child dequant + merge at
164    /// decode time.
165    Schema(SchemaV2),
166}
167
168impl Default for DecoderBuilder {
169    /// Creates a default DecoderBuilder with no configuration and 0.5 score
170    /// threshold and 0.5 IoU threshold.
171    ///
172    /// A valid configuration must be provided before building the Decoder.
173    ///
174    /// # Examples
175    /// ```rust
176    /// # use edgefirst_decoder::{DecoderBuilder, DecoderResult};
177    /// # fn main() -> DecoderResult<()> {
178    /// #  let config_yaml = edgefirst_bench::testdata::read_to_string("modelpack_split.yaml").to_string();
179    /// let decoder = DecoderBuilder::default()
180    ///     .with_config_yaml_str(config_yaml)
181    ///     .build()?;
182    /// assert_eq!(decoder.score_threshold, 0.5);
183    /// assert_eq!(decoder.iou_threshold, 0.5);
184    ///
185    /// # Ok(())
186    /// # }
187    /// ```
188    fn default() -> Self {
189        Self {
190            config_src: None,
191            iou_threshold: 0.5,
192            score_threshold: 0.5,
193            nms: Some(configs::Nms::Auto),
194            decode_dtype: DecodeDtype::F32,
195            pre_nms_top_k: 300,
196            max_det: 300,
197            input_dims: None,
198            multi_label: false,
199        }
200    }
201}
202
203impl DecoderBuilder {
204    /// Creates a default DecoderBuilder with no configuration and 0.5 score
205    /// threshold and 0.5 IoU threshold.
206    ///
207    /// A valid configuration must be provided before building the Decoder.
208    ///
209    /// # Examples
210    /// ```rust
211    /// # use edgefirst_decoder::{DecoderBuilder, DecoderResult};
212    /// # fn main() -> DecoderResult<()> {
213    /// #  let config_yaml = edgefirst_bench::testdata::read_to_string("modelpack_split.yaml").to_string();
214    /// let decoder = DecoderBuilder::new()
215    ///     .with_config_yaml_str(config_yaml)
216    ///     .build()?;
217    /// assert_eq!(decoder.score_threshold, 0.5);
218    /// assert_eq!(decoder.iou_threshold, 0.5);
219    ///
220    /// # Ok(())
221    /// # }
222    /// ```
223    pub fn new() -> Self {
224        Self::default()
225    }
226
227    /// Loads a model configuration in YAML format. Does not check if the string
228    /// is a correct configuration file. Use `DecoderBuilder.build()` to
229    /// deserialize the YAML and parse the model configuration.
230    ///
231    /// # Examples
232    /// ```rust,no_run
233    /// # use edgefirst_decoder::DecoderBuilder;
234    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
235    /// let config_yaml = std::fs::read_to_string("modelpack_split.yaml")?;
236    /// let decoder = DecoderBuilder::new()
237    ///     .with_config_yaml_str(config_yaml)
238    ///     .build()?;
239    ///
240    /// # Ok(())
241    /// # }
242    /// ```
243    pub fn with_config_yaml_str(mut self, yaml_str: String) -> Self {
244        self.config_src.replace(ConfigSource::Yaml(yaml_str));
245        self
246    }
247
248    /// Loads a model configuration in JSON format. Does not check if the string
249    /// is a correct configuration file. Use `DecoderBuilder.build()` to
250    /// deserialize the JSON and parse the model configuration.
251    ///
252    /// # Examples
253    /// ```rust,no_run
254    /// # use edgefirst_decoder::DecoderBuilder;
255    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
256    /// let config_json = std::fs::read_to_string("modelpack_split.json")?;
257    /// let decoder = DecoderBuilder::new()
258    ///     .with_config_json_str(config_json)
259    ///     .build()?;
260    ///
261    /// # Ok(())
262    /// # }
263    /// ```
264    pub fn with_config_json_str(mut self, json_str: String) -> Self {
265        self.config_src.replace(ConfigSource::Json(json_str));
266        self
267    }
268
269    /// Loads a model configuration. Does not check if the configuration is
270    /// correct. Intended to be used when the user needs control over the
271    /// deserialize of the configuration information. Use
272    /// `DecoderBuilder.build()` to parse the model configuration.
273    ///
274    /// # Examples
275    /// ```rust,no_run
276    /// # use edgefirst_decoder::{DecoderBuilder, ConfigOutputs};
277    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
278    /// let config_json = std::fs::read_to_string("modelpack_split.json")?;
279    /// let config: ConfigOutputs = serde_json::from_str(&config_json)?;
280    /// let decoder = DecoderBuilder::new().with_config(config).build()?;
281    ///
282    /// # Ok(())
283    /// # }
284    /// ```
285    pub fn with_config(mut self, config: ConfigOutputs) -> Self {
286        self.config_src.replace(ConfigSource::Config(config));
287        self
288    }
289
290    /// Configure the decoder from a schema v2 metadata document.
291    ///
292    /// Accepts a [`SchemaV2`] as produced by [`SchemaV2::parse_json`],
293    /// [`SchemaV2::parse_yaml`], [`SchemaV2::parse_file`], or
294    /// constructed programmatically. The builder validates the schema,
295    /// compiles a [`DecodeProgram`] for any split logical outputs
296    /// (per-scale or channel sub-splits), and downconverts the
297    /// logical-level semantics to the legacy [`ConfigOutputs`]
298    /// representation consumed by the existing decoder dispatch.
299    ///
300    /// # Examples
301    ///
302    /// ```rust,no_run
303    /// use edgefirst_decoder::{DecoderBuilder, DecoderResult};
304    /// use edgefirst_decoder::schema::SchemaV2;
305    ///
306    /// # fn main() -> DecoderResult<()> {
307    /// let schema = SchemaV2::parse_file("model/edgefirst.json")?;
308    /// let decoder = DecoderBuilder::new()
309    ///     .with_schema(schema)
310    ///     .with_score_threshold(0.25)
311    ///     .build()?;
312    /// # Ok(())
313    /// # }
314    /// ```
315    pub fn with_schema(mut self, schema: SchemaV2) -> Self {
316        self.config_src.replace(ConfigSource::Schema(schema));
317        self
318    }
319
320    /// Choose the output dtype for the per-scale decoder pipeline.
321    ///
322    /// Defaults to [`DecodeDtype::F32`]. Has no effect on schemas
323    /// without per-scale children (which use the legacy decode path).
324    /// `F16` saves ~2× memory bandwidth at the cost of 10-bit mantissa
325    /// precision; empirically safe for YOLO-family models.
326    ///
327    /// # Examples
328    ///
329    /// ```rust,no_run
330    /// use edgefirst_decoder::{DecodeDtype, DecoderBuilder, DecoderResult};
331    /// use edgefirst_decoder::schema::SchemaV2;
332    ///
333    /// # fn main() -> DecoderResult<()> {
334    /// let schema = SchemaV2::parse_file("model/edgefirst.json")?;
335    /// let decoder = DecoderBuilder::new()
336    ///     .with_schema(schema)
337    ///     .with_decode_dtype(DecodeDtype::F32)
338    ///     .build()?;
339    /// # Ok(())
340    /// # }
341    /// ```
342    pub fn with_decode_dtype(mut self, dtype: DecodeDtype) -> Self {
343        self.decode_dtype = dtype;
344        self
345    }
346
347    /// Loads a YOLO detection model configuration.  Use
348    /// `DecoderBuilder.build()` to parse the model configuration.
349    ///
350    /// # Examples
351    /// ```rust
352    /// # use edgefirst_decoder::{ DecoderBuilder, DecoderResult, configs };
353    /// # fn main() -> DecoderResult<()> {
354    /// let decoder = DecoderBuilder::new()
355    ///     .with_config_yolo_det(
356    ///         configs::Detection {
357    ///             anchors: None,
358    ///             decoder: configs::DecoderType::Ultralytics,
359    ///             quantization: Some(configs::QuantTuple(0.012345, 26)),
360    ///             shape: vec![1, 84, 8400],
361    ///             dshape: Vec::new(),
362    ///             normalized: Some(true),
363    ///         },
364    ///         None,
365    ///     )
366    ///     .build()?;
367    ///
368    /// # Ok(())
369    /// # }
370    /// ```
371    pub fn with_config_yolo_det(
372        mut self,
373        boxes: configs::Detection,
374        version: Option<DecoderVersion>,
375    ) -> Self {
376        let config = ConfigOutputs {
377            outputs: vec![ConfigOutput::Detection(boxes)],
378            decoder_version: version,
379            ..Default::default()
380        };
381        self.config_src.replace(ConfigSource::Config(config));
382        self
383    }
384
385    /// Loads a YOLO split detection model configuration.  Use
386    /// `DecoderBuilder.build()` to parse the model configuration.
387    ///
388    /// # Examples
389    /// ```rust
390    /// # use edgefirst_decoder::{ DecoderBuilder, DecoderResult, configs };
391    /// # fn main() -> DecoderResult<()> {
392    /// let boxes_config = configs::Boxes {
393    ///     decoder: configs::DecoderType::Ultralytics,
394    ///     quantization: Some(configs::QuantTuple(0.012345, 26)),
395    ///     shape: vec![1, 4, 8400],
396    ///     dshape: Vec::new(),
397    ///     normalized: Some(true),
398    /// };
399    /// let scores_config = configs::Scores {
400    ///     decoder: configs::DecoderType::Ultralytics,
401    ///     quantization: Some(configs::QuantTuple(0.0064123, -31)),
402    ///     shape: vec![1, 80, 8400],
403    ///     dshape: Vec::new(),
404    /// };
405    /// let decoder = DecoderBuilder::new()
406    ///     .with_config_yolo_split_det(boxes_config, scores_config)
407    ///     .build()?;
408    /// # Ok(())
409    /// # }
410    /// ```
411    pub fn with_config_yolo_split_det(
412        mut self,
413        boxes: configs::Boxes,
414        scores: configs::Scores,
415    ) -> Self {
416        let config = ConfigOutputs {
417            outputs: vec![ConfigOutput::Boxes(boxes), ConfigOutput::Scores(scores)],
418            ..Default::default()
419        };
420        self.config_src.replace(ConfigSource::Config(config));
421        self
422    }
423
424    /// Loads a YOLO segmentation model configuration.  Use
425    /// `DecoderBuilder.build()` to parse the model configuration.
426    ///
427    /// # Examples
428    /// ```rust
429    /// # use edgefirst_decoder::{ DecoderBuilder, DecoderResult, configs };
430    /// # fn main() -> DecoderResult<()> {
431    /// let seg_config = configs::Detection {
432    ///     decoder: configs::DecoderType::Ultralytics,
433    ///     quantization: Some(configs::QuantTuple(0.012345, 26)),
434    ///     shape: vec![1, 116, 8400],
435    ///     anchors: None,
436    ///     dshape: Vec::new(),
437    ///     normalized: Some(true),
438    /// };
439    /// let protos_config = configs::Protos {
440    ///     decoder: configs::DecoderType::Ultralytics,
441    ///     quantization: Some(configs::QuantTuple(0.0064123, -31)),
442    ///     shape: vec![1, 160, 160, 32],
443    ///     dshape: Vec::new(),
444    /// };
445    /// let decoder = DecoderBuilder::new()
446    ///     .with_config_yolo_segdet(
447    ///         seg_config,
448    ///         protos_config,
449    ///         Some(configs::DecoderVersion::Yolov8),
450    ///     )
451    ///     .build()?;
452    /// # Ok(())
453    /// # }
454    /// ```
455    pub fn with_config_yolo_segdet(
456        mut self,
457        boxes: configs::Detection,
458        protos: configs::Protos,
459        version: Option<DecoderVersion>,
460    ) -> Self {
461        let config = ConfigOutputs {
462            outputs: vec![ConfigOutput::Detection(boxes), ConfigOutput::Protos(protos)],
463            decoder_version: version,
464            ..Default::default()
465        };
466        self.config_src.replace(ConfigSource::Config(config));
467        self
468    }
469
470    /// Loads a YOLO split segmentation model configuration.  Use
471    /// `DecoderBuilder.build()` to parse the model configuration.
472    ///
473    /// # Examples
474    /// ```rust
475    /// # use edgefirst_decoder::{ DecoderBuilder, DecoderResult, configs };
476    /// # fn main() -> DecoderResult<()> {
477    /// let boxes_config = configs::Boxes {
478    ///     decoder: configs::DecoderType::Ultralytics,
479    ///     quantization: Some(configs::QuantTuple(0.012345, 26)),
480    ///     shape: vec![1, 4, 8400],
481    ///     dshape: Vec::new(),
482    ///     normalized: Some(true),
483    /// };
484    /// let scores_config = configs::Scores {
485    ///     decoder: configs::DecoderType::Ultralytics,
486    ///     quantization: Some(configs::QuantTuple(0.012345, 14)),
487    ///     shape: vec![1, 80, 8400],
488    ///     dshape: Vec::new(),
489    /// };
490    /// let mask_config = configs::MaskCoefficients {
491    ///     decoder: configs::DecoderType::Ultralytics,
492    ///     quantization: Some(configs::QuantTuple(0.0064123, 125)),
493    ///     shape: vec![1, 32, 8400],
494    ///     dshape: Vec::new(),
495    /// };
496    /// let protos_config = configs::Protos {
497    ///     decoder: configs::DecoderType::Ultralytics,
498    ///     quantization: Some(configs::QuantTuple(0.0064123, -31)),
499    ///     shape: vec![1, 160, 160, 32],
500    ///     dshape: Vec::new(),
501    /// };
502    /// let decoder = DecoderBuilder::new()
503    ///     .with_config_yolo_split_segdet(boxes_config, scores_config, mask_config, protos_config)
504    ///     .build()?;
505    /// # Ok(())
506    /// # }
507    /// ```
508    pub fn with_config_yolo_split_segdet(
509        mut self,
510        boxes: configs::Boxes,
511        scores: configs::Scores,
512        mask_coefficients: configs::MaskCoefficients,
513        protos: configs::Protos,
514    ) -> Self {
515        let config = ConfigOutputs {
516            outputs: vec![
517                ConfigOutput::Boxes(boxes),
518                ConfigOutput::Scores(scores),
519                ConfigOutput::MaskCoefficients(mask_coefficients),
520                ConfigOutput::Protos(protos),
521            ],
522            ..Default::default()
523        };
524        self.config_src.replace(ConfigSource::Config(config));
525        self
526    }
527
528    /// Loads a ModelPack detection model configuration.  Use
529    /// `DecoderBuilder.build()` to parse the model configuration.
530    ///
531    /// # Examples
532    /// ```rust
533    /// # use edgefirst_decoder::{ DecoderBuilder, DecoderResult, configs };
534    /// # fn main() -> DecoderResult<()> {
535    /// let boxes_config = configs::Boxes {
536    ///     decoder: configs::DecoderType::ModelPack,
537    ///     quantization: Some(configs::QuantTuple(0.012345, 26)),
538    ///     shape: vec![1, 8400, 1, 4],
539    ///     dshape: Vec::new(),
540    ///     normalized: Some(true),
541    /// };
542    /// let scores_config = configs::Scores {
543    ///     decoder: configs::DecoderType::ModelPack,
544    ///     quantization: Some(configs::QuantTuple(0.0064123, -31)),
545    ///     shape: vec![1, 8400, 3],
546    ///     dshape: Vec::new(),
547    /// };
548    /// let decoder = DecoderBuilder::new()
549    ///     .with_config_modelpack_det(boxes_config, scores_config)
550    ///     .build()?;
551    /// # Ok(())
552    /// # }
553    /// ```
554    pub fn with_config_modelpack_det(
555        mut self,
556        boxes: configs::Boxes,
557        scores: configs::Scores,
558    ) -> Self {
559        let config = ConfigOutputs {
560            outputs: vec![ConfigOutput::Boxes(boxes), ConfigOutput::Scores(scores)],
561            ..Default::default()
562        };
563        self.config_src.replace(ConfigSource::Config(config));
564        self
565    }
566
567    /// Loads a ModelPack split detection model configuration. Use
568    /// `DecoderBuilder.build()` to parse the model configuration.
569    ///
570    /// # Examples
571    /// ```rust
572    /// # use edgefirst_decoder::{ DecoderBuilder, DecoderResult, configs };
573    /// # fn main() -> DecoderResult<()> {
574    /// let config0 = configs::Detection {
575    ///     anchors: Some(vec![
576    ///         [0.13750000298023224, 0.2074074000120163],
577    ///         [0.2541666626930237, 0.21481481194496155],
578    ///         [0.23125000298023224, 0.35185185074806213],
579    ///     ]),
580    ///     decoder: configs::DecoderType::ModelPack,
581    ///     quantization: Some(configs::QuantTuple(0.012345, 26)),
582    ///     shape: vec![1, 17, 30, 18],
583    ///     dshape: Vec::new(),
584    ///     normalized: Some(true),
585    /// };
586    /// let config1 = configs::Detection {
587    ///     anchors: Some(vec![
588    ///         [0.36666667461395264, 0.31481480598449707],
589    ///         [0.38749998807907104, 0.4740740656852722],
590    ///         [0.5333333611488342, 0.644444465637207],
591    ///     ]),
592    ///     decoder: configs::DecoderType::ModelPack,
593    ///     quantization: Some(configs::QuantTuple(0.0064123, -31)),
594    ///     shape: vec![1, 9, 15, 18],
595    ///     dshape: Vec::new(),
596    ///     normalized: Some(true),
597    /// };
598    ///
599    /// let decoder = DecoderBuilder::new()
600    ///     .with_config_modelpack_det_split(vec![config0, config1])
601    ///     .build()?;
602    /// # Ok(())
603    /// # }
604    /// ```
605    pub fn with_config_modelpack_det_split(mut self, boxes: Vec<configs::Detection>) -> Self {
606        let outputs = boxes.into_iter().map(ConfigOutput::Detection).collect();
607        let config = ConfigOutputs {
608            outputs,
609            ..Default::default()
610        };
611        self.config_src.replace(ConfigSource::Config(config));
612        self
613    }
614
615    /// Loads a ModelPack segmentation detection model configuration. Use
616    /// `DecoderBuilder.build()` to parse the model configuration.
617    ///
618    /// # Examples
619    /// ```rust
620    /// # use edgefirst_decoder::{ DecoderBuilder, DecoderResult, configs };
621    /// # fn main() -> DecoderResult<()> {
622    /// let boxes_config = configs::Boxes {
623    ///     decoder: configs::DecoderType::ModelPack,
624    ///     quantization: Some(configs::QuantTuple(0.012345, 26)),
625    ///     shape: vec![1, 8400, 1, 4],
626    ///     dshape: Vec::new(),
627    ///     normalized: Some(true),
628    /// };
629    /// let scores_config = configs::Scores {
630    ///     decoder: configs::DecoderType::ModelPack,
631    ///     quantization: Some(configs::QuantTuple(0.0064123, -31)),
632    ///     shape: vec![1, 8400, 2],
633    ///     dshape: Vec::new(),
634    /// };
635    /// let seg_config = configs::Segmentation {
636    ///     decoder: configs::DecoderType::ModelPack,
637    ///     quantization: Some(configs::QuantTuple(0.0064123, -31)),
638    ///     shape: vec![1, 640, 640, 3],
639    ///     dshape: Vec::new(),
640    /// };
641    /// let decoder = DecoderBuilder::new()
642    ///     .with_config_modelpack_segdet(boxes_config, scores_config, seg_config)
643    ///     .build()?;
644    /// # Ok(())
645    /// # }
646    /// ```
647    pub fn with_config_modelpack_segdet(
648        mut self,
649        boxes: configs::Boxes,
650        scores: configs::Scores,
651        segmentation: configs::Segmentation,
652    ) -> Self {
653        let config = ConfigOutputs {
654            outputs: vec![
655                ConfigOutput::Boxes(boxes),
656                ConfigOutput::Scores(scores),
657                ConfigOutput::Segmentation(segmentation),
658            ],
659            ..Default::default()
660        };
661        self.config_src.replace(ConfigSource::Config(config));
662        self
663    }
664
665    /// Loads a ModelPack segmentation split detection model configuration. Use
666    /// `DecoderBuilder.build()` to parse the model configuration.
667    ///
668    /// # Examples
669    /// ```rust
670    /// # use edgefirst_decoder::{ DecoderBuilder, DecoderResult, configs };
671    /// # fn main() -> DecoderResult<()> {
672    /// let config0 = configs::Detection {
673    ///     anchors: Some(vec![
674    ///         [0.36666667461395264, 0.31481480598449707],
675    ///         [0.38749998807907104, 0.4740740656852722],
676    ///         [0.5333333611488342, 0.644444465637207],
677    ///     ]),
678    ///     decoder: configs::DecoderType::ModelPack,
679    ///     quantization: Some(configs::QuantTuple(0.08547406643629074, 174)),
680    ///     shape: vec![1, 9, 15, 18],
681    ///     dshape: Vec::new(),
682    ///     normalized: Some(true),
683    /// };
684    /// let config1 = configs::Detection {
685    ///     anchors: Some(vec![
686    ///         [0.13750000298023224, 0.2074074000120163],
687    ///         [0.2541666626930237, 0.21481481194496155],
688    ///         [0.23125000298023224, 0.35185185074806213],
689    ///     ]),
690    ///     decoder: configs::DecoderType::ModelPack,
691    ///     quantization: Some(configs::QuantTuple(0.09929127991199493, 183)),
692    ///     shape: vec![1, 17, 30, 18],
693    ///     dshape: Vec::new(),
694    ///     normalized: Some(true),
695    /// };
696    /// let seg_config = configs::Segmentation {
697    ///     decoder: configs::DecoderType::ModelPack,
698    ///     quantization: Some(configs::QuantTuple(0.0064123, -31)),
699    ///     shape: vec![1, 640, 640, 2],
700    ///     dshape: Vec::new(),
701    /// };
702    /// let decoder = DecoderBuilder::new()
703    ///     .with_config_modelpack_segdet_split(vec![config0, config1], seg_config)
704    ///     .build()?;
705    /// # Ok(())
706    /// # }
707    /// ```
708    pub fn with_config_modelpack_segdet_split(
709        mut self,
710        boxes: Vec<configs::Detection>,
711        segmentation: configs::Segmentation,
712    ) -> Self {
713        let mut outputs = boxes
714            .into_iter()
715            .map(ConfigOutput::Detection)
716            .collect::<Vec<_>>();
717        outputs.push(ConfigOutput::Segmentation(segmentation));
718        let config = ConfigOutputs {
719            outputs,
720            ..Default::default()
721        };
722        self.config_src.replace(ConfigSource::Config(config));
723        self
724    }
725
726    /// Loads a ModelPack segmentation model configuration. Use
727    /// `DecoderBuilder.build()` to parse the model configuration.
728    ///
729    /// # Examples
730    /// ```rust
731    /// # use edgefirst_decoder::{ DecoderBuilder, DecoderResult, configs };
732    /// # fn main() -> DecoderResult<()> {
733    /// let seg_config = configs::Segmentation {
734    ///     decoder: configs::DecoderType::ModelPack,
735    ///     quantization: Some(configs::QuantTuple(0.0064123, -31)),
736    ///     shape: vec![1, 640, 640, 3],
737    ///     dshape: Vec::new(),
738    /// };
739    /// let decoder = DecoderBuilder::new()
740    ///     .with_config_modelpack_seg(seg_config)
741    ///     .build()?;
742    /// # Ok(())
743    /// # }
744    /// ```
745    pub fn with_config_modelpack_seg(mut self, segmentation: configs::Segmentation) -> Self {
746        let config = ConfigOutputs {
747            outputs: vec![ConfigOutput::Segmentation(segmentation)],
748            ..Default::default()
749        };
750        self.config_src.replace(ConfigSource::Config(config));
751        self
752    }
753
754    /// Add an output to the decoder configuration.
755    ///
756    /// Incrementally builds the model configuration by adding outputs one at
757    /// a time. The decoder resolves the model type from the combination of
758    /// outputs during `build()`.
759    ///
760    /// If `dshape` is non-empty on the output, `shape` is automatically
761    /// derived from it (the size component of each named dimension). This
762    /// prevents conflicts between `shape` and `dshape`.
763    ///
764    /// This uses the programmatic config path. Calling this after
765    /// `with_config_json_str()` or `with_config_yaml_str()` replaces the
766    /// string-based config source.
767    ///
768    /// # Examples
769    /// ```rust
770    /// # use edgefirst_decoder::{DecoderBuilder, DecoderResult, ConfigOutput, configs};
771    /// # fn main() -> DecoderResult<()> {
772    /// let decoder = DecoderBuilder::new()
773    ///     .add_output(ConfigOutput::Scores(configs::Scores {
774    ///         decoder: configs::DecoderType::Ultralytics,
775    ///         dshape: vec![
776    ///             (configs::DimName::Batch, 1),
777    ///             (configs::DimName::NumClasses, 80),
778    ///             (configs::DimName::NumBoxes, 8400),
779    ///         ],
780    ///         ..Default::default()
781    ///     }))
782    ///     .add_output(ConfigOutput::Boxes(configs::Boxes {
783    ///         decoder: configs::DecoderType::Ultralytics,
784    ///         dshape: vec![
785    ///             (configs::DimName::Batch, 1),
786    ///             (configs::DimName::BoxCoords, 4),
787    ///             (configs::DimName::NumBoxes, 8400),
788    ///         ],
789    ///         ..Default::default()
790    ///     }))
791    ///     .build()?;
792    /// # Ok(())
793    /// # }
794    /// ```
795    pub fn add_output(mut self, output: ConfigOutput) -> Self {
796        if !matches!(self.config_src, Some(ConfigSource::Config(_))) {
797            self.config_src = Some(ConfigSource::Config(ConfigOutputs::default()));
798        }
799        if let Some(ConfigSource::Config(ref mut config)) = self.config_src {
800            config.outputs.push(Self::normalize_output(output));
801        }
802        self
803    }
804
805    /// Sets the decoder version for Ultralytics models.
806    ///
807    /// This is used with `add_output()` to specify the YOLO architecture
808    /// version when it cannot be inferred from the output shapes alone.
809    ///
810    /// - `Yolov5`, `Yolov8`, `Yolo11`: Traditional models requiring external
811    ///   NMS
812    /// - `Yolo26`: End-to-end models with NMS embedded in the model graph
813    ///
814    /// # Examples
815    /// ```rust
816    /// # use edgefirst_decoder::{DecoderBuilder, DecoderResult, ConfigOutput, configs};
817    /// # fn main() -> DecoderResult<()> {
818    /// let decoder = DecoderBuilder::new()
819    ///     .add_output(ConfigOutput::Detection(configs::Detection {
820    ///         decoder: configs::DecoderType::Ultralytics,
821    ///         dshape: vec![
822    ///             (configs::DimName::Batch, 1),
823    ///             (configs::DimName::NumBoxes, 100),
824    ///             (configs::DimName::NumFeatures, 6),
825    ///         ],
826    ///         ..Default::default()
827    ///     }))
828    ///     .with_decoder_version(configs::DecoderVersion::Yolo26)
829    ///     .build()?;
830    /// # Ok(())
831    /// # }
832    /// ```
833    pub fn with_decoder_version(mut self, version: configs::DecoderVersion) -> Self {
834        if !matches!(self.config_src, Some(ConfigSource::Config(_))) {
835            self.config_src = Some(ConfigSource::Config(ConfigOutputs::default()));
836        }
837        if let Some(ConfigSource::Config(ref mut config)) = self.config_src {
838            config.decoder_version = Some(version);
839        }
840        self
841    }
842
843    /// Normalize an output: if dshape is non-empty, derive shape from it.
844    fn normalize_output(mut output: ConfigOutput) -> ConfigOutput {
845        fn normalize_shape(shape: &mut Vec<usize>, dshape: &[(configs::DimName, usize)]) {
846            if !dshape.is_empty() {
847                *shape = dshape.iter().map(|(_, size)| *size).collect();
848            }
849        }
850        match &mut output {
851            ConfigOutput::Detection(c) => normalize_shape(&mut c.shape, &c.dshape),
852            ConfigOutput::Boxes(c) => normalize_shape(&mut c.shape, &c.dshape),
853            ConfigOutput::Scores(c) => normalize_shape(&mut c.shape, &c.dshape),
854            ConfigOutput::Protos(c) => normalize_shape(&mut c.shape, &c.dshape),
855            ConfigOutput::Segmentation(c) => normalize_shape(&mut c.shape, &c.dshape),
856            ConfigOutput::MaskCoefficients(c) => normalize_shape(&mut c.shape, &c.dshape),
857            ConfigOutput::Mask(c) => normalize_shape(&mut c.shape, &c.dshape),
858            ConfigOutput::Classes(c) => normalize_shape(&mut c.shape, &c.dshape),
859        }
860        output
861    }
862
863    /// Sets the scores threshold of the decoder
864    ///
865    /// # Examples
866    /// ```rust
867    /// # use edgefirst_decoder::{DecoderBuilder, DecoderResult};
868    /// # fn main() -> DecoderResult<()> {
869    /// # let config_json = edgefirst_bench::testdata::read_to_string("modelpack_split.json").to_string();
870    /// let decoder = DecoderBuilder::new()
871    ///     .with_config_json_str(config_json)
872    ///     .with_score_threshold(0.654)
873    ///     .build()?;
874    /// assert_eq!(decoder.score_threshold, 0.654);
875    /// # Ok(())
876    /// # }
877    /// ```
878    pub fn with_score_threshold(mut self, score_threshold: f32) -> Self {
879        self.score_threshold = score_threshold;
880        self
881    }
882
883    /// Sets the IOU threshold of the decoder. Has no effect when NMS is set to
884    /// `None`
885    ///
886    /// # Examples
887    /// ```rust
888    /// # use edgefirst_decoder::{DecoderBuilder, DecoderResult};
889    /// # fn main() -> DecoderResult<()> {
890    /// # let config_json = edgefirst_bench::testdata::read_to_string("modelpack_split.json").to_string();
891    /// let decoder = DecoderBuilder::new()
892    ///     .with_config_json_str(config_json)
893    ///     .with_iou_threshold(0.654)
894    ///     .build()?;
895    /// assert_eq!(decoder.iou_threshold, 0.654);
896    /// # Ok(())
897    /// # }
898    /// ```
899    pub fn with_iou_threshold(mut self, iou_threshold: f32) -> Self {
900        self.iou_threshold = iou_threshold;
901        self
902    }
903
904    /// Sets the NMS mode for the decoder.
905    ///
906    /// - `Some(Nms::Auto)` — resolve from model config (e.g. `edgefirst.json`)
907    ///   or fall back to `ClassAgnostic` (this is the builder default)
908    /// - `Some(Nms::ClassAgnostic)` — class-agnostic NMS: suppress overlapping
909    ///   boxes regardless of class label
910    /// - `Some(Nms::ClassAware)` — class-aware NMS: only suppress boxes that
911    ///   share the same class label AND overlap above the IoU threshold
912    /// - `None` — bypass NMS entirely (for end-to-end models with embedded NMS)
913    ///
914    /// # Examples
915    /// ```rust
916    /// # use edgefirst_decoder::{DecoderBuilder, DecoderResult, configs::Nms};
917    /// # fn main() -> DecoderResult<()> {
918    /// # let config_json = edgefirst_bench::testdata::read_to_string("modelpack_split.json").to_string();
919    /// let decoder = DecoderBuilder::new()
920    ///     .with_config_json_str(config_json)
921    ///     .with_nms(Some(Nms::ClassAware))
922    ///     .build()?;
923    /// assert_eq!(decoder.nms, Some(Nms::ClassAware));
924    /// # Ok(())
925    /// # }
926    /// ```
927    pub fn with_nms(mut self, nms: Option<configs::Nms>) -> Self {
928        self.nms = nms;
929        self
930    }
931
932    /// Enable multi-label box decode for validation runs.
933    ///
934    /// When `true`, the decoder emits one candidate per `(anchor, class)` pair
935    /// for every class whose score meets the threshold — matching the
936    /// Ultralytics `val` multi-label decode that drives COCO mAP evaluation.
937    ///
938    /// **Default: `false`** (argmax: one class per anchor).  This flag is
939    /// intentionally builder-only: a deployed `edgefirst.json` can never
940    /// enable it, and `decode_tracked_*` entry points `debug_assert` it is off.
941    ///
942    /// Multi-label automatically forces class-aware NMS so per-class duplicates
943    /// are suppressed correctly without cross-class suppression.
944    ///
945    /// # Examples
946    /// ```rust
947    /// # use edgefirst_decoder::{DecoderBuilder, DecoderResult};
948    /// # fn main() -> DecoderResult<()> {
949    /// # let config_json = edgefirst_bench::testdata::read_to_string("modelpack_split.json").to_string();
950    /// let decoder = DecoderBuilder::new()
951    ///     .with_config_json_str(config_json)
952    ///     .with_multi_label(true)
953    ///     .build()?;
954    /// # Ok(())
955    /// # }
956    /// ```
957    pub fn with_multi_label(mut self, v: bool) -> Self {
958        self.multi_label = v;
959        self
960    }
961
962    /// Sets the maximum number of candidate boxes fed into NMS after score
963    /// filtering.  Uses partial sort (O(N)) to select the top-K candidates,
964    /// dramatically reducing the O(N²) NMS cost when many low-confidence
965    /// proposals pass the threshold (common with mAP eval at 0.001).
966    ///
967    /// Default: 300.
968    ///
969    /// # ⚠️ Validation vs Deployment
970    ///
971    /// The default is appropriate for **deployment** where
972    /// `score_threshold ≥ 0.25` means few anchors survive filtering and
973    /// top-K is effectively a no-op.
974    ///
975    /// For **COCO mAP evaluation** (`score_threshold ≈ 0.001`), set this to
976    /// the total anchor count (8 400 for standard 640 × 640 YOLO models) or
977    /// to `0` (no limit) so that all score-passing candidates reach NMS.
978    /// Failing to do so causes **~9 pp box mAP loss** — the decoder math is
979    /// correct but the evaluation protocol requires full recall across the
980    /// confidence range.
981    ///
982    /// Post-processing latency scales with candidate count. At deployment
983    /// thresholds the cost difference is negligible; at validation thresholds
984    /// it is measurable but necessary for correct results.
985    ///
986    /// # Examples
987    ///
988    /// Deployment (default top-K, high score threshold):
989    /// ```rust
990    /// # use edgefirst_decoder::{DecoderBuilder, DecoderResult};
991    /// # fn main() -> DecoderResult<()> {
992    /// # let config_json = edgefirst_bench::testdata::read_to_string("modelpack_split.json").to_string();
993    /// let decoder = DecoderBuilder::new()
994    ///     .with_config_json_str(config_json)
995    ///     .with_score_threshold(0.25)
996    ///     // pre_nms_top_k defaults to 300 — appropriate here
997    ///     .build()?;
998    /// # Ok(())
999    /// # }
1000    /// ```
1001    ///
1002    /// COCO mAP evaluation (pass all anchors to NMS):
1003    /// ```rust
1004    /// # use edgefirst_decoder::{DecoderBuilder, DecoderResult};
1005    /// # fn main() -> DecoderResult<()> {
1006    /// # let config_json = edgefirst_bench::testdata::read_to_string("modelpack_split.json").to_string();
1007    /// let decoder = DecoderBuilder::new()
1008    ///     .with_config_json_str(config_json)
1009    ///     .with_score_threshold(0.001)
1010    ///     .with_pre_nms_top_k(8400)  // all YOLO anchors
1011    ///     .with_max_det(300)
1012    ///     .build()?;
1013    /// assert_eq!(decoder.pre_nms_top_k, 8400);
1014    /// # Ok(())
1015    /// # }
1016    /// ```
1017    pub fn with_pre_nms_top_k(mut self, pre_nms_top_k: usize) -> Self {
1018        self.pre_nms_top_k = pre_nms_top_k;
1019        self
1020    }
1021
1022    /// Sets the maximum number of detections returned after NMS.
1023    /// Matches the Ultralytics `max_det` parameter.
1024    ///
1025    /// Default: 300.
1026    ///
1027    /// # Examples
1028    /// ```rust
1029    /// # use edgefirst_decoder::{DecoderBuilder, DecoderResult};
1030    /// # fn main() -> DecoderResult<()> {
1031    /// # let config_json = edgefirst_bench::testdata::read_to_string("modelpack_split.json").to_string();
1032    /// let decoder = DecoderBuilder::new()
1033    ///     .with_config_json_str(config_json)
1034    ///     .with_max_det(100)
1035    ///     .build()?;
1036    /// assert_eq!(decoder.max_det, 100);
1037    /// # Ok(())
1038    /// # }
1039    /// ```
1040    pub fn with_max_det(mut self, max_det: usize) -> Self {
1041        self.max_det = max_det;
1042        self
1043    }
1044
1045    /// Sets the model input dimensions `(width, height)` consumed by the
1046    /// EDGEAI-1303 normalization path. Use this when building via
1047    /// [`with_config`](Self::with_config) / [`add_output`](Self::add_output)
1048    /// (no schema) and the model emits pixel-space boxes that need to be
1049    /// divided by `(W, H)` before NMS.
1050    ///
1051    /// When the builder is also configured with [`with_schema`](Self::with_schema)
1052    /// (or `with_config_json_str` / `with_config_yaml_str`) and the schema's
1053    /// `input` block carries usable dims, this explicit override **takes
1054    /// precedence** so callers can correct schemas with missing or wrong
1055    /// input specs without rewriting the schema.
1056    ///
1057    /// # Examples
1058    /// ```rust
1059    /// # use edgefirst_decoder::{DecoderBuilder, DecoderResult};
1060    /// # fn main() -> DecoderResult<()> {
1061    /// # let config_yaml = edgefirst_bench::testdata::read_to_string("modelpack_split.yaml").to_string();
1062    /// let decoder = DecoderBuilder::new()
1063    ///     .with_config_yaml_str(config_yaml)
1064    ///     .with_input_dims(640, 640)
1065    ///     .build()?;
1066    /// assert_eq!(decoder.input_dims(), Some((640, 640)));
1067    /// # Ok(())
1068    /// # }
1069    /// ```
1070    pub fn with_input_dims(mut self, width: usize, height: usize) -> Self {
1071        self.input_dims = Some((width, height));
1072        self
1073    }
1074
1075    /// Builds the decoder with the given settings. If the config is a JSON or
1076    /// YAML string, this will deserialize the JSON or YAML and then parse the
1077    /// configuration information.
1078    ///
1079    /// # Examples
1080    /// ```rust
1081    /// # use edgefirst_decoder::{DecoderBuilder, DecoderResult};
1082    /// # fn main() -> DecoderResult<()> {
1083    /// # let config_json = edgefirst_bench::testdata::read_to_string("modelpack_split.json").to_string();
1084    /// let decoder = DecoderBuilder::new()
1085    ///     .with_config_json_str(config_json)
1086    ///     .with_score_threshold(0.654)
1087    ///     .build()?;
1088    /// # Ok(())
1089    /// # }
1090    /// ```
1091    pub fn build(self) -> Result<Decoder, DecoderError> {
1092        let decode_dtype = self.decode_dtype;
1093        let explicit_input_dims = self.input_dims;
1094        let (config, decode_program, per_scale_plan, schema_input_dims) = match self.config_src {
1095            Some(ConfigSource::Json(s)) => {
1096                Self::build_from_schema(SchemaV2::parse_json(&s)?, decode_dtype)?
1097            }
1098            Some(ConfigSource::Yaml(s)) => {
1099                Self::build_from_schema(SchemaV2::parse_yaml(&s)?, decode_dtype)?
1100            }
1101            Some(ConfigSource::Config(c)) => (c, None, None, None),
1102            Some(ConfigSource::Schema(schema)) => Self::build_from_schema(schema, decode_dtype)?,
1103            None => return Err(DecoderError::NoConfig),
1104        };
1105        // Explicit `with_input_dims(W, H)` overrides any schema-derived
1106        // value so callers can fix schemas with missing or wrong input
1107        // specs without rewriting the schema (EDGEAI-1303).
1108        let input_dims = explicit_input_dims.or(schema_input_dims);
1109
1110        // Enforce the physical-order contract: when dshape is present
1111        // it must describe the same axes as shape in the same order,
1112        // listed from outermost to innermost. Ambiguous-layout roles
1113        // (Protos, Boxes, Scores, MaskCoefficients, Classes, Detection)
1114        // may still omit dshape when shape is already in the decoder's
1115        // canonical order.
1116        for output in &config.outputs {
1117            Decoder::validate_output_layout(output.into())?;
1118        }
1119
1120        // Extract normalized flag from config outputs.
1121        //
1122        // The per-scale subsystem (DFL/LTRB → dist2bbox → sigmoid) emits
1123        // boxes in pixel coordinates by design — `(grid + dist) * stride`
1124        // — independently of any `normalized: true` annotation in the
1125        // schema. Override to `Some(false)` so the per-scale bridge's
1126        // call to `yolo::maybe_normalize_boxes_in_place` fires and
1127        // divides by `input_dims`, yielding `[0, 1]` output. The
1128        // accessor `Decoder::normalized_boxes()` applies the
1129        // pixel→normalized upgrade for the per-scale path and for any
1130        // legacy `ModelType` whose every entry point normalizes
1131        // uniformly (currently `YoloSegDet`, `YoloSplitSegDet`, and
1132        // `YoloSegDet2Way`); other paths surface the raw flag.
1133        let normalized = if per_scale_plan.is_some() {
1134            Some(false)
1135        } else {
1136            Self::get_normalized(&config.outputs)
1137        };
1138
1139        // NMS precedence:
1140        //   Some(ClassAgnostic|ClassAware) → explicit user override
1141        //   Some(Auto) → resolve from config, fallback to ClassAgnostic
1142        //   None → NMS disabled (explicit)
1143        //
1144        // `Auto` is always resolved to a concrete mode here — it never
1145        // persists into the built `Decoder`, even if the config itself
1146        // contains `Auto`.
1147        let resolve_auto = |nms: Option<configs::Nms>| match nms {
1148            Some(configs::Nms::Auto) | None => Some(configs::Nms::ClassAgnostic),
1149            concrete => concrete,
1150        };
1151        let nms = match self.nms {
1152            Some(configs::Nms::Auto) => resolve_auto(config.nms),
1153            other => other,
1154        };
1155        // When the per-scale path is active, the per_scale subsystem owns
1156        // model decoding entirely — `decode` / `decode_proto` short-circuit
1157        // on `per_scale.is_some()` before reading `model_type`. Skip the
1158        // legacy ModelType validation, which otherwise rejects per-scale
1159        // schemas that carry `decoder_version: yolo26` (an
1160        // "end-to-end" hint) but use the per-scale split outputs rather
1161        // than the end-to-end split-output shape the legacy validator
1162        // expects. We keep a placeholder `ModelType` so the field remains
1163        // valid; it is dead state for per-scale Decoders.
1164        let model_type = if per_scale_plan.is_some() {
1165            // Drop the un-needed config; the per-scale subsystem owns it.
1166            drop(config);
1167            ModelType::PerScale
1168        } else {
1169            Self::get_model_type(config)?
1170        };
1171
1172        let per_scale = per_scale_plan
1173            .map(|plan| std::sync::Mutex::new(crate::per_scale::PerScaleDecoder::new(plan)));
1174
1175        debug_assert!(
1176            !matches!(nms, Some(configs::Nms::Auto)),
1177            "Nms::Auto must be resolved to a concrete mode before building Decoder"
1178        );
1179
1180        Ok(Decoder {
1181            model_type,
1182            iou_threshold: self.iou_threshold,
1183            score_threshold: self.score_threshold,
1184            nms,
1185            pre_nms_top_k: self.pre_nms_top_k,
1186            max_det: self.max_det,
1187            normalized,
1188            input_dims,
1189            multi_label: self.multi_label,
1190            decode_program,
1191            per_scale,
1192        })
1193    }
1194
1195    /// Validate a [`SchemaV2`] and lower it to the (legacy `ConfigOutputs`,
1196    /// optional `DecodeProgram`, optional `PerScalePlan`) tuple the rest
1197    /// of `build()` consumes.
1198    ///
1199    /// Centralises the v2 lowering so JSON, YAML, and direct
1200    /// `with_schema` callers all go through the same validation,
1201    /// merge-program, and per-scale plan construction. `SchemaV2::parse_json`
1202    /// / `parse_yaml` already auto-detect v1 vs v2 input and return a v2
1203    /// schema either way (v1 inputs are upgraded in memory via
1204    /// `from_v1`), so this helper is the sole place that turns a
1205    /// schema into builder-ready state.
1206    #[allow(clippy::type_complexity)]
1207    fn build_from_schema(
1208        schema: SchemaV2,
1209        decode_dtype: DecodeDtype,
1210    ) -> Result<
1211        (
1212            ConfigOutputs,
1213            Option<DecodeProgram>,
1214            Option<PerScalePlan>,
1215            Option<(usize, usize)>,
1216        ),
1217        DecoderError,
1218    > {
1219        schema.validate()?;
1220        // The per-scale subsystem claims per-scale schemas in full and owns
1221        // their decode end-to-end (`decode` / `decode_proto` short-circuit on
1222        // `per_scale.is_some()`). Build it first and use its claim as the
1223        // single source of truth: only fall back to the schema-v2 merge
1224        // program for split schemas it does NOT claim (e.g. ARA-2 channel
1225        // sub-splits). This keeps `decode_program` `None` for per-scale
1226        // schemas so the merge path never sees per-scale logicals.
1227        let per_scale = PerScalePlan::try_from_schema(&schema, decode_dtype)?;
1228        let program = if per_scale.is_some() {
1229            None
1230        } else {
1231            DecodeProgram::try_from_schema(&schema)?
1232        };
1233        // Extract model input (W, H) from `input.shape`/`dshape`. Used by
1234        // the legacy decode path to honour `normalized: false` (see
1235        // EDGEAI-1303). `None` is fine when the schema omits the input
1236        // spec — the decoder falls back to the protobox `>2.0` reject.
1237        let input_dims = schema.input.as_ref().and_then(input_dims_from_spec);
1238        let legacy = schema.to_legacy_config_outputs()?;
1239        Ok((legacy, program, per_scale, input_dims))
1240    }
1241
1242    /// Extracts the normalized flag from config outputs.
1243    /// - `Some(true)`: Boxes are in normalized [0,1] coordinates
1244    /// - `Some(false)`: Boxes are in pixel coordinates
1245    /// - `None`: Unknown (not specified in config), caller must infer
1246    fn get_normalized(outputs: &[ConfigOutput]) -> Option<bool> {
1247        for output in outputs {
1248            match output {
1249                ConfigOutput::Detection(det) => return det.normalized,
1250                ConfigOutput::Boxes(boxes) => return boxes.normalized,
1251                _ => {}
1252            }
1253        }
1254        None // not specified
1255    }
1256
1257    fn get_model_type(configs: ConfigOutputs) -> Result<ModelType, DecoderError> {
1258        // yolo or modelpack
1259        let mut yolo = false;
1260        let mut modelpack = false;
1261        for c in &configs.outputs {
1262            match c.decoder() {
1263                DecoderType::ModelPack => modelpack = true,
1264                DecoderType::Ultralytics => yolo = true,
1265            }
1266        }
1267        match (modelpack, yolo) {
1268            (true, true) => Err(DecoderError::InvalidConfig(
1269                "Both ModelPack and Yolo outputs found in config".to_string(),
1270            )),
1271            (true, false) => Self::get_model_type_modelpack(configs),
1272            (false, true) => Self::get_model_type_yolo(configs),
1273            (false, false) => Err(DecoderError::InvalidConfig(
1274                "No outputs found in config".to_string(),
1275            )),
1276        }
1277    }
1278
1279    fn get_model_type_yolo(configs: ConfigOutputs) -> Result<ModelType, DecoderError> {
1280        let mut boxes = None;
1281        let mut protos = None;
1282        let mut split_boxes = None;
1283        let mut split_scores = None;
1284        let mut split_mask_coeff = None;
1285        let mut split_classes = None;
1286        for c in configs.outputs {
1287            match c {
1288                ConfigOutput::Detection(detection) => boxes = Some(detection),
1289                ConfigOutput::Segmentation(_) => {
1290                    return Err(DecoderError::InvalidConfig(
1291                        "Invalid Segmentation output with Yolo decoder".to_string(),
1292                    ));
1293                }
1294                ConfigOutput::Protos(protos_) => protos = Some(protos_),
1295                ConfigOutput::Mask(_) => {
1296                    return Err(DecoderError::InvalidConfig(
1297                        "Invalid Mask output with Yolo decoder".to_string(),
1298                    ));
1299                }
1300                ConfigOutput::Scores(scores) => split_scores = Some(scores),
1301                ConfigOutput::Boxes(boxes) => split_boxes = Some(boxes),
1302                ConfigOutput::MaskCoefficients(mask_coeff) => split_mask_coeff = Some(mask_coeff),
1303                ConfigOutput::Classes(classes) => split_classes = Some(classes),
1304            }
1305        }
1306
1307        // Use end-to-end model types when:
1308        // 1. decoder_version is explicitly set to Yolo26 (definitive), OR
1309        //    decoder_version is not set but the dshapes are (batch, num_boxes,
1310        //    num_features)
1311        let is_end_to_end_dshape = boxes.as_ref().is_some_and(|b| {
1312            let dims = b.dshape.iter().map(|(d, _)| *d).collect::<Vec<_>>();
1313            dims == vec![DimName::Batch, DimName::NumBoxes, DimName::NumFeatures]
1314        });
1315
1316        let is_end_to_end = configs
1317            .decoder_version
1318            .map(|v| v.is_end_to_end())
1319            .unwrap_or(is_end_to_end_dshape);
1320
1321        if is_end_to_end {
1322            if let Some(boxes) = boxes {
1323                if let Some(protos) = protos {
1324                    Self::verify_yolo_seg_det_26(&boxes, &protos)?;
1325                    return Ok(ModelType::YoloEndToEndSegDet { boxes, protos });
1326                } else {
1327                    Self::verify_yolo_det_26(&boxes)?;
1328                    return Ok(ModelType::YoloEndToEndDet { boxes });
1329                }
1330            } else if let (Some(split_boxes), Some(split_scores), Some(split_classes)) =
1331                (split_boxes, split_scores, split_classes)
1332            {
1333                if let (Some(split_mask_coeff), Some(protos)) = (split_mask_coeff, protos) {
1334                    Self::verify_yolo_split_end_to_end_segdet(
1335                        &split_boxes,
1336                        &split_scores,
1337                        &split_classes,
1338                        &split_mask_coeff,
1339                        &protos,
1340                    )?;
1341                    return Ok(ModelType::YoloSplitEndToEndSegDet {
1342                        boxes: split_boxes,
1343                        scores: split_scores,
1344                        classes: split_classes,
1345                        mask_coeff: split_mask_coeff,
1346                        protos,
1347                    });
1348                }
1349                Self::verify_yolo_split_end_to_end_det(
1350                    &split_boxes,
1351                    &split_scores,
1352                    &split_classes,
1353                )?;
1354                return Ok(ModelType::YoloSplitEndToEndDet {
1355                    boxes: split_boxes,
1356                    scores: split_scores,
1357                    classes: split_classes,
1358                });
1359            } else {
1360                return Err(DecoderError::InvalidConfig(
1361                    "Invalid Yolo end-to-end model outputs".to_string(),
1362                ));
1363            }
1364        }
1365
1366        if let Some(boxes) = boxes {
1367            match (split_mask_coeff, protos) {
1368                (Some(mask_coeff), Some(protos)) => {
1369                    // 2-way split: combined detection + separate mask_coeff + protos
1370                    Self::verify_yolo_seg_det_2way(&boxes, &mask_coeff, &protos)?;
1371                    Ok(ModelType::YoloSegDet2Way {
1372                        boxes,
1373                        mask_coeff,
1374                        protos,
1375                    })
1376                }
1377                (_, Some(protos)) => {
1378                    // Unsplit: mask_coefs embedded in detection tensor
1379                    Self::verify_yolo_seg_det(&boxes, &protos)?;
1380                    Ok(ModelType::YoloSegDet { boxes, protos })
1381                }
1382                _ => {
1383                    Self::verify_yolo_det(&boxes)?;
1384                    Ok(ModelType::YoloDet { boxes })
1385                }
1386            }
1387        } else if let (Some(boxes), Some(scores)) = (split_boxes, split_scores) {
1388            if let (Some(mask_coeff), Some(protos)) = (split_mask_coeff, protos) {
1389                Self::verify_yolo_split_segdet(&boxes, &scores, &mask_coeff, &protos)?;
1390                Ok(ModelType::YoloSplitSegDet {
1391                    boxes,
1392                    scores,
1393                    mask_coeff,
1394                    protos,
1395                })
1396            } else {
1397                Self::verify_yolo_split_det(&boxes, &scores)?;
1398                Ok(ModelType::YoloSplitDet { boxes, scores })
1399            }
1400        } else {
1401            Err(DecoderError::InvalidConfig(
1402                "Invalid Yolo model outputs".to_string(),
1403            ))
1404        }
1405    }
1406
1407    fn verify_yolo_det(detect: &configs::Detection) -> Result<(), DecoderError> {
1408        if detect.shape.len() != 3 {
1409            return Err(DecoderError::InvalidConfig(format!(
1410                "Invalid Yolo Detection shape {:?}",
1411                detect.shape
1412            )));
1413        }
1414
1415        Self::verify_dshapes(
1416            &detect.dshape,
1417            &detect.shape,
1418            "Detection",
1419            &[DimName::Batch, DimName::NumFeatures, DimName::NumBoxes],
1420        )?;
1421        if !detect.dshape.is_empty() {
1422            Self::get_class_count(&detect.dshape, None, None)?;
1423        } else {
1424            Self::get_class_count_no_dshape(detect.into(), None)?;
1425        }
1426
1427        Ok(())
1428    }
1429
1430    fn verify_yolo_det_26(detect: &configs::Detection) -> Result<(), DecoderError> {
1431        if detect.shape.len() != 3 {
1432            return Err(DecoderError::InvalidConfig(format!(
1433                "Invalid Yolo Detection shape {:?}",
1434                detect.shape
1435            )));
1436        }
1437
1438        Self::verify_dshapes(
1439            &detect.dshape,
1440            &detect.shape,
1441            "Detection",
1442            &[DimName::Batch, DimName::NumFeatures, DimName::NumBoxes],
1443        )?;
1444
1445        if !detect.shape.contains(&6) {
1446            return Err(DecoderError::InvalidConfig(
1447                "Yolo26 Detection must have 6 features".to_string(),
1448            ));
1449        }
1450
1451        Ok(())
1452    }
1453
1454    fn verify_yolo_seg_det(
1455        detection: &configs::Detection,
1456        protos: &configs::Protos,
1457    ) -> Result<(), DecoderError> {
1458        if detection.shape.len() != 3 {
1459            return Err(DecoderError::InvalidConfig(format!(
1460                "Invalid Yolo Detection shape {:?}",
1461                detection.shape
1462            )));
1463        }
1464        if protos.shape.len() != 4 {
1465            return Err(DecoderError::InvalidConfig(format!(
1466                "Invalid Yolo Protos shape {:?}",
1467                protos.shape
1468            )));
1469        }
1470
1471        Self::verify_dshapes(
1472            &detection.dshape,
1473            &detection.shape,
1474            "Detection",
1475            &[DimName::Batch, DimName::NumFeatures, DimName::NumBoxes],
1476        )?;
1477        Self::verify_dshapes(
1478            &protos.dshape,
1479            &protos.shape,
1480            "Protos",
1481            &[
1482                DimName::Batch,
1483                DimName::Height,
1484                DimName::Width,
1485                DimName::NumProtos,
1486            ],
1487        )?;
1488
1489        let protos_count = Self::get_protos_count(&protos.dshape)
1490            .unwrap_or_else(|| protos.shape[1].min(protos.shape[3]));
1491        log::debug!("Protos count: {}", protos_count);
1492        log::debug!("Detection dshape: {:?}", detection.dshape);
1493        let classes = if !detection.dshape.is_empty() {
1494            Self::get_class_count(&detection.dshape, Some(protos_count), None)?
1495        } else {
1496            Self::get_class_count_no_dshape(detection.into(), Some(protos_count))?
1497        };
1498
1499        if classes == 0 {
1500            return Err(DecoderError::InvalidConfig(
1501                "Yolo Segmentation Detection has zero classes".to_string(),
1502            ));
1503        }
1504
1505        Ok(())
1506    }
1507
1508    fn verify_yolo_seg_det_2way(
1509        detection: &configs::Detection,
1510        mask_coeff: &configs::MaskCoefficients,
1511        protos: &configs::Protos,
1512    ) -> Result<(), DecoderError> {
1513        if detection.shape.len() != 3 {
1514            return Err(DecoderError::InvalidConfig(format!(
1515                "Invalid Yolo 2-Way Detection shape {:?}",
1516                detection.shape
1517            )));
1518        }
1519        if mask_coeff.shape.len() != 3 {
1520            return Err(DecoderError::InvalidConfig(format!(
1521                "Invalid Yolo 2-Way Mask Coefficients shape {:?}",
1522                mask_coeff.shape
1523            )));
1524        }
1525        if protos.shape.len() != 4 {
1526            return Err(DecoderError::InvalidConfig(format!(
1527                "Invalid Yolo 2-Way Protos shape {:?}",
1528                protos.shape
1529            )));
1530        }
1531
1532        Self::verify_dshapes(
1533            &detection.dshape,
1534            &detection.shape,
1535            "Detection",
1536            &[DimName::Batch, DimName::NumFeatures, DimName::NumBoxes],
1537        )?;
1538        Self::verify_dshapes(
1539            &mask_coeff.dshape,
1540            &mask_coeff.shape,
1541            "Mask Coefficients",
1542            &[DimName::Batch, DimName::NumProtos, DimName::NumBoxes],
1543        )?;
1544        Self::verify_dshapes(
1545            &protos.dshape,
1546            &protos.shape,
1547            "Protos",
1548            &[
1549                DimName::Batch,
1550                DimName::Height,
1551                DimName::Width,
1552                DimName::NumProtos,
1553            ],
1554        )?;
1555
1556        // Validate num_boxes match between detection and mask_coeff
1557        let det_num = Self::get_box_count(&detection.dshape).unwrap_or(detection.shape[2]);
1558        let mask_num = Self::get_box_count(&mask_coeff.dshape).unwrap_or(mask_coeff.shape[2]);
1559        if det_num != mask_num {
1560            return Err(DecoderError::InvalidConfig(format!(
1561                "Yolo 2-Way Detection num_boxes {} incompatible with Mask Coefficients num_boxes {}",
1562                det_num, mask_num
1563            )));
1564        }
1565
1566        // Validate mask_coeff channels match protos channels
1567        let mask_channels = if !mask_coeff.dshape.is_empty() {
1568            Self::get_protos_count(&mask_coeff.dshape).ok_or_else(|| {
1569                DecoderError::InvalidConfig(
1570                    "Could not find num_protos in mask_coeff config".to_string(),
1571                )
1572            })?
1573        } else {
1574            mask_coeff.shape[1]
1575        };
1576        let proto_channels = if !protos.dshape.is_empty() {
1577            Self::get_protos_count(&protos.dshape).ok_or_else(|| {
1578                DecoderError::InvalidConfig(
1579                    "Could not find num_protos in protos config".to_string(),
1580                )
1581            })?
1582        } else {
1583            protos.shape[1].min(protos.shape[3])
1584        };
1585        if mask_channels != proto_channels {
1586            return Err(DecoderError::InvalidConfig(format!(
1587                "Yolo 2-Way Protos channels {} incompatible with Mask Coefficients channels {}",
1588                proto_channels, mask_channels
1589            )));
1590        }
1591
1592        // Validate detection has classes (nc+4 features, no mask_coefs embedded)
1593        if !detection.dshape.is_empty() {
1594            Self::get_class_count(&detection.dshape, None, None)?;
1595        } else {
1596            Self::get_class_count_no_dshape(detection.into(), None)?;
1597        }
1598
1599        Ok(())
1600    }
1601
1602    fn verify_yolo_seg_det_26(
1603        detection: &configs::Detection,
1604        protos: &configs::Protos,
1605    ) -> Result<(), DecoderError> {
1606        if detection.shape.len() != 3 {
1607            return Err(DecoderError::InvalidConfig(format!(
1608                "Invalid Yolo Detection shape {:?}",
1609                detection.shape
1610            )));
1611        }
1612        if protos.shape.len() != 4 {
1613            return Err(DecoderError::InvalidConfig(format!(
1614                "Invalid Yolo Protos shape {:?}",
1615                protos.shape
1616            )));
1617        }
1618
1619        Self::verify_dshapes(
1620            &detection.dshape,
1621            &detection.shape,
1622            "Detection",
1623            &[DimName::Batch, DimName::NumFeatures, DimName::NumBoxes],
1624        )?;
1625        Self::verify_dshapes(
1626            &protos.dshape,
1627            &protos.shape,
1628            "Protos",
1629            &[
1630                DimName::Batch,
1631                DimName::Height,
1632                DimName::Width,
1633                DimName::NumProtos,
1634            ],
1635        )?;
1636
1637        let protos_count = Self::get_protos_count(&protos.dshape)
1638            .unwrap_or_else(|| protos.shape[1].min(protos.shape[3]));
1639        log::debug!("Protos count: {}", protos_count);
1640        log::debug!("Detection dshape: {:?}", detection.dshape);
1641
1642        if !detection.shape.contains(&(6 + protos_count)) {
1643            return Err(DecoderError::InvalidConfig(format!(
1644                "Yolo26 Segmentation Detection must have num_features be 6 + num_protos = {}",
1645                6 + protos_count
1646            )));
1647        }
1648
1649        Ok(())
1650    }
1651
1652    fn verify_yolo_split_det(
1653        boxes: &configs::Boxes,
1654        scores: &configs::Scores,
1655    ) -> Result<(), DecoderError> {
1656        if boxes.shape.len() != 3 {
1657            return Err(DecoderError::InvalidConfig(format!(
1658                "Invalid Yolo Split Boxes shape {:?}",
1659                boxes.shape
1660            )));
1661        }
1662        if scores.shape.len() != 3 {
1663            return Err(DecoderError::InvalidConfig(format!(
1664                "Invalid Yolo Split Scores shape {:?}",
1665                scores.shape
1666            )));
1667        }
1668
1669        Self::verify_dshapes(
1670            &boxes.dshape,
1671            &boxes.shape,
1672            "Boxes",
1673            &[DimName::Batch, DimName::BoxCoords, DimName::NumBoxes],
1674        )?;
1675        Self::verify_dshapes(
1676            &scores.dshape,
1677            &scores.shape,
1678            "Scores",
1679            &[DimName::Batch, DimName::NumClasses, DimName::NumBoxes],
1680        )?;
1681
1682        let boxes_num = Self::get_box_count(&boxes.dshape).unwrap_or(boxes.shape[2]);
1683        let scores_num = Self::get_box_count(&scores.dshape).unwrap_or(scores.shape[2]);
1684
1685        if boxes_num != scores_num {
1686            return Err(DecoderError::InvalidConfig(format!(
1687                "Yolo Split Detection Boxes num {} incompatible with Scores num {}",
1688                boxes_num, scores_num
1689            )));
1690        }
1691
1692        Ok(())
1693    }
1694
1695    fn verify_yolo_split_segdet(
1696        boxes: &configs::Boxes,
1697        scores: &configs::Scores,
1698        mask_coeff: &configs::MaskCoefficients,
1699        protos: &configs::Protos,
1700    ) -> Result<(), DecoderError> {
1701        if boxes.shape.len() != 3 {
1702            return Err(DecoderError::InvalidConfig(format!(
1703                "Invalid Yolo Split Boxes shape {:?}",
1704                boxes.shape
1705            )));
1706        }
1707        if scores.shape.len() != 3 {
1708            return Err(DecoderError::InvalidConfig(format!(
1709                "Invalid Yolo Split Scores shape {:?}",
1710                scores.shape
1711            )));
1712        }
1713
1714        if mask_coeff.shape.len() != 3 {
1715            return Err(DecoderError::InvalidConfig(format!(
1716                "Invalid Yolo Split Mask Coefficients shape {:?}",
1717                mask_coeff.shape
1718            )));
1719        }
1720
1721        if protos.shape.len() != 4 {
1722            return Err(DecoderError::InvalidConfig(format!(
1723                "Invalid Yolo Protos shape {:?}",
1724                mask_coeff.shape
1725            )));
1726        }
1727
1728        Self::verify_dshapes(
1729            &boxes.dshape,
1730            &boxes.shape,
1731            "Boxes",
1732            &[DimName::Batch, DimName::BoxCoords, DimName::NumBoxes],
1733        )?;
1734        Self::verify_dshapes(
1735            &scores.dshape,
1736            &scores.shape,
1737            "Scores",
1738            &[DimName::Batch, DimName::NumClasses, DimName::NumBoxes],
1739        )?;
1740        Self::verify_dshapes(
1741            &mask_coeff.dshape,
1742            &mask_coeff.shape,
1743            "Mask Coefficients",
1744            &[DimName::Batch, DimName::NumProtos, DimName::NumBoxes],
1745        )?;
1746        Self::verify_dshapes(
1747            &protos.dshape,
1748            &protos.shape,
1749            "Protos",
1750            &[
1751                DimName::Batch,
1752                DimName::Height,
1753                DimName::Width,
1754                DimName::NumProtos,
1755            ],
1756        )?;
1757
1758        let boxes_num = Self::get_box_count(&boxes.dshape).unwrap_or(boxes.shape[2]);
1759        let scores_num = Self::get_box_count(&scores.dshape).unwrap_or(scores.shape[2]);
1760        let mask_num = Self::get_box_count(&mask_coeff.dshape).unwrap_or(mask_coeff.shape[2]);
1761
1762        let mask_channels = if !mask_coeff.dshape.is_empty() {
1763            Self::get_protos_count(&mask_coeff.dshape).ok_or_else(|| {
1764                DecoderError::InvalidConfig("Could not find num_protos in config".to_string())
1765            })?
1766        } else {
1767            mask_coeff.shape[1]
1768        };
1769        let proto_channels = if !protos.dshape.is_empty() {
1770            Self::get_protos_count(&protos.dshape).ok_or_else(|| {
1771                DecoderError::InvalidConfig("Could not find num_protos in config".to_string())
1772            })?
1773        } else {
1774            protos.shape[1].min(protos.shape[3])
1775        };
1776
1777        if boxes_num != scores_num {
1778            return Err(DecoderError::InvalidConfig(format!(
1779                "Yolo Split Detection Boxes num {} incompatible with Scores num {}",
1780                boxes_num, scores_num
1781            )));
1782        }
1783
1784        if boxes_num != mask_num {
1785            return Err(DecoderError::InvalidConfig(format!(
1786                "Yolo Split Detection Boxes num {} incompatible with Mask Coefficients num {}",
1787                boxes_num, mask_num
1788            )));
1789        }
1790
1791        if proto_channels != mask_channels {
1792            return Err(DecoderError::InvalidConfig(format!(
1793                "Yolo Protos channels {} incompatible with Mask Coefficients channels {}",
1794                proto_channels, mask_channels
1795            )));
1796        }
1797
1798        Ok(())
1799    }
1800
1801    fn verify_yolo_split_end_to_end_det(
1802        boxes: &configs::Boxes,
1803        scores: &configs::Scores,
1804        classes: &configs::Classes,
1805    ) -> Result<(), DecoderError> {
1806        if boxes.shape.len() != 3 || !boxes.shape.contains(&4) {
1807            return Err(DecoderError::InvalidConfig(format!(
1808                "Split end-to-end boxes must be [batch, N, 4], got {:?}",
1809                boxes.shape
1810            )));
1811        }
1812        if scores.shape.len() != 3 || !scores.shape.contains(&1) {
1813            return Err(DecoderError::InvalidConfig(format!(
1814                "Split end-to-end scores must be [batch, N, 1], got {:?}",
1815                scores.shape
1816            )));
1817        }
1818        if classes.shape.len() != 3 || !classes.shape.contains(&1) {
1819            return Err(DecoderError::InvalidConfig(format!(
1820                "Split end-to-end classes must be [batch, N, 1], got {:?}",
1821                classes.shape
1822            )));
1823        }
1824        Ok(())
1825    }
1826
1827    fn verify_yolo_split_end_to_end_segdet(
1828        boxes: &configs::Boxes,
1829        scores: &configs::Scores,
1830        classes: &configs::Classes,
1831        mask_coeff: &configs::MaskCoefficients,
1832        protos: &configs::Protos,
1833    ) -> Result<(), DecoderError> {
1834        Self::verify_yolo_split_end_to_end_det(boxes, scores, classes)?;
1835        if mask_coeff.shape.len() != 3 {
1836            return Err(DecoderError::InvalidConfig(format!(
1837                "Invalid split end-to-end mask coefficients shape {:?}",
1838                mask_coeff.shape
1839            )));
1840        }
1841        if protos.shape.len() != 4 {
1842            return Err(DecoderError::InvalidConfig(format!(
1843                "Invalid protos shape {:?}",
1844                protos.shape
1845            )));
1846        }
1847        Ok(())
1848    }
1849
1850    fn get_model_type_modelpack(configs: ConfigOutputs) -> Result<ModelType, DecoderError> {
1851        let mut split_decoders = Vec::new();
1852        let mut segment_ = None;
1853        let mut scores_ = None;
1854        let mut boxes_ = None;
1855        for c in configs.outputs {
1856            match c {
1857                ConfigOutput::Detection(detection) => split_decoders.push(detection),
1858                ConfigOutput::Segmentation(segmentation) => segment_ = Some(segmentation),
1859                ConfigOutput::Mask(_) => {}
1860                ConfigOutput::Protos(_) => {
1861                    return Err(DecoderError::InvalidConfig(
1862                        "ModelPack should not have protos".to_string(),
1863                    ));
1864                }
1865                ConfigOutput::Scores(scores) => scores_ = Some(scores),
1866                ConfigOutput::Boxes(boxes) => boxes_ = Some(boxes),
1867                ConfigOutput::MaskCoefficients(_) => {
1868                    return Err(DecoderError::InvalidConfig(
1869                        "ModelPack should not have mask coefficients".to_string(),
1870                    ));
1871                }
1872                ConfigOutput::Classes(_) => {
1873                    return Err(DecoderError::InvalidConfig(
1874                        "ModelPack should not have classes output".to_string(),
1875                    ));
1876                }
1877            }
1878        }
1879
1880        if let Some(segmentation) = segment_ {
1881            if !split_decoders.is_empty() {
1882                let classes = Self::verify_modelpack_split_det(&split_decoders)?;
1883                Self::verify_modelpack_seg(&segmentation, Some(classes))?;
1884                Ok(ModelType::ModelPackSegDetSplit {
1885                    detection: split_decoders,
1886                    segmentation,
1887                })
1888            } else if let (Some(scores), Some(boxes)) = (scores_, boxes_) {
1889                let classes = Self::verify_modelpack_det(&boxes, &scores)?;
1890                Self::verify_modelpack_seg(&segmentation, Some(classes))?;
1891                Ok(ModelType::ModelPackSegDet {
1892                    boxes,
1893                    scores,
1894                    segmentation,
1895                })
1896            } else {
1897                Self::verify_modelpack_seg(&segmentation, None)?;
1898                Ok(ModelType::ModelPackSeg { segmentation })
1899            }
1900        } else if !split_decoders.is_empty() {
1901            Self::verify_modelpack_split_det(&split_decoders)?;
1902            Ok(ModelType::ModelPackDetSplit {
1903                detection: split_decoders,
1904            })
1905        } else if let (Some(scores), Some(boxes)) = (scores_, boxes_) {
1906            Self::verify_modelpack_det(&boxes, &scores)?;
1907            Ok(ModelType::ModelPackDet { boxes, scores })
1908        } else {
1909            Err(DecoderError::InvalidConfig(
1910                "Invalid ModelPack model outputs".to_string(),
1911            ))
1912        }
1913    }
1914
1915    fn verify_modelpack_det(
1916        boxes: &configs::Boxes,
1917        scores: &configs::Scores,
1918    ) -> Result<usize, DecoderError> {
1919        if boxes.shape.len() != 4 {
1920            return Err(DecoderError::InvalidConfig(format!(
1921                "Invalid ModelPack Boxes shape {:?}",
1922                boxes.shape
1923            )));
1924        }
1925        if scores.shape.len() != 3 {
1926            return Err(DecoderError::InvalidConfig(format!(
1927                "Invalid ModelPack Scores shape {:?}",
1928                scores.shape
1929            )));
1930        }
1931
1932        Self::verify_dshapes(
1933            &boxes.dshape,
1934            &boxes.shape,
1935            "Boxes",
1936            &[
1937                DimName::Batch,
1938                DimName::NumBoxes,
1939                DimName::Padding,
1940                DimName::BoxCoords,
1941            ],
1942        )?;
1943        Self::verify_dshapes(
1944            &scores.dshape,
1945            &scores.shape,
1946            "Scores",
1947            &[DimName::Batch, DimName::NumBoxes, DimName::NumClasses],
1948        )?;
1949
1950        let boxes_num = Self::get_box_count(&boxes.dshape).unwrap_or(boxes.shape[1]);
1951        let scores_num = Self::get_box_count(&scores.dshape).unwrap_or(scores.shape[1]);
1952
1953        if boxes_num != scores_num {
1954            return Err(DecoderError::InvalidConfig(format!(
1955                "ModelPack Detection Boxes num {} incompatible with Scores num {}",
1956                boxes_num, scores_num
1957            )));
1958        }
1959
1960        let num_classes = if !scores.dshape.is_empty() {
1961            Self::get_class_count(&scores.dshape, None, None)?
1962        } else {
1963            Self::get_class_count_no_dshape(scores.into(), None)?
1964        };
1965
1966        Ok(num_classes)
1967    }
1968
1969    fn verify_modelpack_split_det(boxes: &[configs::Detection]) -> Result<usize, DecoderError> {
1970        let mut num_classes = None;
1971        for b in boxes {
1972            let Some(num_anchors) = b.anchors.as_ref().map(|a| a.len()) else {
1973                return Err(DecoderError::InvalidConfig(
1974                    "ModelPack Split Detection missing anchors".to_string(),
1975                ));
1976            };
1977
1978            if num_anchors == 0 {
1979                return Err(DecoderError::InvalidConfig(
1980                    "ModelPack Split Detection has zero anchors".to_string(),
1981                ));
1982            }
1983
1984            if b.shape.len() != 4 {
1985                return Err(DecoderError::InvalidConfig(format!(
1986                    "Invalid ModelPack Split Detection shape {:?}",
1987                    b.shape
1988                )));
1989            }
1990
1991            Self::verify_dshapes(
1992                &b.dshape,
1993                &b.shape,
1994                "Split Detection",
1995                &[
1996                    DimName::Batch,
1997                    DimName::Height,
1998                    DimName::Width,
1999                    DimName::NumAnchorsXFeatures,
2000                ],
2001            )?;
2002            let classes = if !b.dshape.is_empty() {
2003                Self::get_class_count(&b.dshape, None, Some(num_anchors))?
2004            } else {
2005                Self::get_class_count_no_dshape(b.into(), None)?
2006            };
2007
2008            match num_classes {
2009                Some(n) => {
2010                    if n != classes {
2011                        return Err(DecoderError::InvalidConfig(format!(
2012                            "ModelPack Split Detection inconsistent number of classes: previous {}, current {}",
2013                            n, classes
2014                        )));
2015                    }
2016                }
2017                None => {
2018                    num_classes = Some(classes);
2019                }
2020            }
2021        }
2022
2023        Ok(num_classes.unwrap_or(0))
2024    }
2025
2026    fn verify_modelpack_seg(
2027        segmentation: &configs::Segmentation,
2028        classes: Option<usize>,
2029    ) -> Result<(), DecoderError> {
2030        if segmentation.shape.len() != 4 {
2031            return Err(DecoderError::InvalidConfig(format!(
2032                "Invalid ModelPack Segmentation shape {:?}",
2033                segmentation.shape
2034            )));
2035        }
2036        // ModelPack segmentation is canonical NHWC: [Batch, Height, Width,
2037        // NumClasses]. Structural integrity of the dshape (rank, per-axis size
2038        // agreement with `shape`, no duplicate axes) is already enforced for
2039        // every output by `Decoder::validate_output_layout` at build entry, so
2040        // here we only require the spatial axes to be named and recover the
2041        // class count from the channel axis below.
2042        //
2043        // Metadata producers that lack embedded metadata (the validator's
2044        // shape-inference fallback for ModelPack `.keras` models) tag the
2045        // channel axis as `num_protos` or omit its name, which previously
2046        // hard-failed here with "Segmentation dshape missing required
2047        // dimension NumClasses" (DE-2651 / DE-2628). That is unnecessarily
2048        // strict: an unnamed or mis-named channel axis at position 3 is sorted
2049        // to the canonical tail by `swap_axes_if_needed`, so the physical NHWC
2050        // order — and therefore decode — is unaffected. Infer the class count
2051        // from the NHWC shape instead of rejecting the model.
2052        if !segmentation.dshape.is_empty() {
2053            let names =
2054                HashSet::<DimName>::from_iter(segmentation.dshape.iter().map(|(name, _)| *name));
2055            for dim in [DimName::Batch, DimName::Height, DimName::Width] {
2056                if !names.contains(&dim) {
2057                    return Err(DecoderError::InvalidConfig(format!(
2058                        "Segmentation dshape missing required dimension {dim:?}"
2059                    )));
2060                }
2061            }
2062        }
2063
2064        if let Some(classes) = classes {
2065            let seg_classes = Self::modelpack_seg_num_classes(segmentation);
2066
2067            if seg_classes != classes + 1 {
2068                return Err(DecoderError::InvalidConfig(format!(
2069                    "ModelPack Segmentation channels {} incompatible with number of classes {}",
2070                    seg_classes, classes
2071                )));
2072            }
2073        }
2074        Ok(())
2075    }
2076
2077    /// Number of segmentation classes for a ModelPack segmentation output.
2078    ///
2079    /// Prefers an explicit `NumClasses` dshape dimension when present;
2080    /// otherwise falls back to the canonical NHWC channel axis (`shape[3]`).
2081    /// This tolerates metadata that omits or mis-tags the class axis while
2082    /// still honouring a correctly-tagged dshape. Only called after
2083    /// `verify_modelpack_seg` has validated that `shape` is rank-4 (it returns
2084    /// `InvalidConfig` otherwise), so `shape[3]` cannot panic.
2085    fn modelpack_seg_num_classes(segmentation: &configs::Segmentation) -> usize {
2086        for (dim_name, dim_size) in &segmentation.dshape {
2087            if *dim_name == DimName::NumClasses {
2088                return *dim_size;
2089            }
2090        }
2091        segmentation.shape[3]
2092    }
2093
2094    // verifies that dshapes match the given shape
2095    fn verify_dshapes(
2096        dshape: &[(DimName, usize)],
2097        shape: &[usize],
2098        name: &str,
2099        dims: &[DimName],
2100    ) -> Result<(), DecoderError> {
2101        for s in shape {
2102            if *s == 0 {
2103                return Err(DecoderError::InvalidConfig(format!(
2104                    "{} shape has zero dimension",
2105                    name
2106                )));
2107            }
2108        }
2109
2110        if shape.len() != dims.len() {
2111            return Err(DecoderError::InvalidConfig(format!(
2112                "{} shape length {} does not match expected dims length {}",
2113                name,
2114                shape.len(),
2115                dims.len()
2116            )));
2117        }
2118
2119        if dshape.is_empty() {
2120            return Ok(());
2121        }
2122        // check the dshape lengths match the shape lengths
2123        if dshape.len() != shape.len() {
2124            return Err(DecoderError::InvalidConfig(format!(
2125                "{} dshape length does not match shape length",
2126                name
2127            )));
2128        }
2129
2130        // check the dshape values match the shape values
2131        for ((dim_name, dim_size), shape_size) in dshape.iter().zip(shape) {
2132            if dim_size != shape_size {
2133                return Err(DecoderError::InvalidConfig(format!(
2134                    "{} dshape dimension {} size {} does not match shape size {}",
2135                    name, dim_name, dim_size, shape_size
2136                )));
2137            }
2138            if *dim_name == DimName::Padding && *dim_size != 1 {
2139                return Err(DecoderError::InvalidConfig(
2140                    "Padding dimension size must be 1".to_string(),
2141                ));
2142            }
2143
2144            if *dim_name == DimName::BoxCoords && *dim_size != 4 {
2145                return Err(DecoderError::InvalidConfig(
2146                    "BoxCoords dimension size must be 4".to_string(),
2147                ));
2148            }
2149        }
2150
2151        let dims_present = HashSet::<DimName>::from_iter(dshape.iter().map(|(name, _)| *name));
2152        for dim in dims {
2153            if !dims_present.contains(dim) {
2154                return Err(DecoderError::InvalidConfig(format!(
2155                    "{} dshape missing required dimension {:?}",
2156                    name, dim
2157                )));
2158            }
2159        }
2160
2161        Ok(())
2162    }
2163
2164    fn get_box_count(dshape: &[(DimName, usize)]) -> Option<usize> {
2165        for (dim_name, dim_size) in dshape {
2166            if *dim_name == DimName::NumBoxes {
2167                return Some(*dim_size);
2168            }
2169        }
2170        None
2171    }
2172
2173    fn get_class_count_no_dshape(
2174        config: ConfigOutputRef,
2175        protos: Option<usize>,
2176    ) -> Result<usize, DecoderError> {
2177        match config {
2178            ConfigOutputRef::Detection(detection) => match detection.decoder {
2179                DecoderType::Ultralytics => {
2180                    if detection.shape[1] <= 4 + protos.unwrap_or(0) {
2181                        return Err(DecoderError::InvalidConfig(format!(
2182                            "Invalid shape: Yolo num_features {} must be greater than {}",
2183                            detection.shape[1],
2184                            4 + protos.unwrap_or(0),
2185                        )));
2186                    }
2187                    Ok(detection.shape[1] - 4 - protos.unwrap_or(0))
2188                }
2189                DecoderType::ModelPack => {
2190                    let Some(num_anchors) = detection.anchors.as_ref().map(|a| a.len()) else {
2191                        return Err(DecoderError::Internal(
2192                            "ModelPack Detection missing anchors".to_string(),
2193                        ));
2194                    };
2195                    let anchors_x_features = detection.shape[3];
2196                    if anchors_x_features <= num_anchors * 5 {
2197                        return Err(DecoderError::InvalidConfig(format!(
2198                            "Invalid ModelPack Split Detection shape: anchors_x_features {} not greater than number of anchors * 5 = {}",
2199                            anchors_x_features,
2200                            num_anchors * 5,
2201                        )));
2202                    }
2203
2204                    if !anchors_x_features.is_multiple_of(num_anchors) {
2205                        return Err(DecoderError::InvalidConfig(format!(
2206                            "Invalid ModelPack Split Detection shape: anchors_x_features {} not a multiple of number of anchors {}",
2207                            anchors_x_features, num_anchors
2208                        )));
2209                    }
2210                    Ok(anchors_x_features / num_anchors - 5)
2211                }
2212            },
2213
2214            ConfigOutputRef::Scores(scores) => match scores.decoder {
2215                DecoderType::Ultralytics => Ok(scores.shape[1]),
2216                DecoderType::ModelPack => Ok(scores.shape[2]),
2217            },
2218            _ => Err(DecoderError::Internal(
2219                "Attempted to get class count from unsupported config output".to_owned(),
2220            )),
2221        }
2222    }
2223
2224    // get the class count from dshape or calculate from num_features
2225    fn get_class_count(
2226        dshape: &[(DimName, usize)],
2227        protos: Option<usize>,
2228        anchors: Option<usize>,
2229    ) -> Result<usize, DecoderError> {
2230        if dshape.is_empty() {
2231            return Ok(0);
2232        }
2233        // if it has num_classes in dshape, return it
2234        for (dim_name, dim_size) in dshape {
2235            if *dim_name == DimName::NumClasses {
2236                return Ok(*dim_size);
2237            }
2238        }
2239
2240        // number of classes can be calculated from num_features - 4 for yolo.  If the
2241        // model has protos, we also subtract the number of protos.
2242        for (dim_name, dim_size) in dshape {
2243            if *dim_name == DimName::NumFeatures {
2244                let protos = protos.unwrap_or(0);
2245                if protos + 4 >= *dim_size {
2246                    return Err(DecoderError::InvalidConfig(format!(
2247                        "Invalid shape: Yolo num_features {} must be greater than {}",
2248                        *dim_size,
2249                        protos + 4,
2250                    )));
2251                }
2252                return Ok(*dim_size - 4 - protos);
2253            }
2254        }
2255
2256        // number of classes can be calculated from number of anchors for modelpack
2257        // split detection
2258        if let Some(num_anchors) = anchors {
2259            for (dim_name, dim_size) in dshape {
2260                if *dim_name == DimName::NumAnchorsXFeatures {
2261                    let anchors_x_features = *dim_size;
2262                    if anchors_x_features <= num_anchors * 5 {
2263                        return Err(DecoderError::InvalidConfig(format!(
2264                            "Invalid ModelPack Split Detection shape: anchors_x_features {} not greater than number of anchors * 5 = {}",
2265                            anchors_x_features,
2266                            num_anchors * 5,
2267                        )));
2268                    }
2269
2270                    if !anchors_x_features.is_multiple_of(num_anchors) {
2271                        return Err(DecoderError::InvalidConfig(format!(
2272                            "Invalid ModelPack Split Detection shape: anchors_x_features {} not a multiple of number of anchors {}",
2273                            anchors_x_features, num_anchors
2274                        )));
2275                    }
2276                    return Ok((anchors_x_features / num_anchors) - 5);
2277                }
2278            }
2279        }
2280        Err(DecoderError::InvalidConfig(
2281            "Cannot determine number of classes from dshape".to_owned(),
2282        ))
2283    }
2284
2285    fn get_protos_count(dshape: &[(DimName, usize)]) -> Option<usize> {
2286        for (dim_name, dim_size) in dshape {
2287            if *dim_name == DimName::NumProtos {
2288                return Some(*dim_size);
2289            }
2290        }
2291        None
2292    }
2293}