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