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