Skip to main content

edgefirst_decoder/decoder/
builder.rs

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