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