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