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}