1use crate::configs::{self, deserialize_dshape, DimName, QuantTuple};
47use crate::{ConfigOutput, ConfigOutputs, DecoderError, DecoderResult};
48use serde::{Deserialize, Serialize};
49
50pub const MAX_SUPPORTED_SCHEMA_VERSION: u32 = 2;
54
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct SchemaV2 {
62 pub schema_version: u32,
64
65 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub input: Option<InputSpec>,
72
73 #[serde(default, skip_serializing_if = "Vec::is_empty")]
76 pub outputs: Vec<LogicalOutput>,
77
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub nms: Option<NmsMode>,
81
82 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub decoder_version: Option<DecoderVersion>,
88}
89
90impl Default for SchemaV2 {
91 fn default() -> Self {
92 Self {
93 schema_version: 2,
94 input: None,
95 outputs: Vec::new(),
96 nms: None,
97 decoder_version: None,
98 }
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104pub struct InputSpec {
105 pub shape: Vec<usize>,
107
108 #[serde(
111 default,
112 deserialize_with = "deserialize_dshape",
113 skip_serializing_if = "Vec::is_empty"
114 )]
115 pub dshape: Vec<(DimName, usize)>,
116
117 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub cameraadaptor: Option<String>,
122}
123
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct LogicalOutput {
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub name: Option<String>,
136
137 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
144 pub type_: Option<LogicalType>,
145
146 pub shape: Vec<usize>,
149
150 #[serde(
152 default,
153 deserialize_with = "deserialize_dshape",
154 skip_serializing_if = "Vec::is_empty"
155 )]
156 pub dshape: Vec<(DimName, usize)>,
157
158 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub decoder: Option<DecoderKind>,
162
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub encoding: Option<BoxEncoding>,
166
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub score_format: Option<ScoreFormat>,
170
171 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub normalized: Option<bool>,
177
178 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub anchors: Option<Vec<[f32; 2]>>,
182
183 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub stride: Option<Stride>,
188
189 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub dtype: Option<DType>,
193
194 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub quantization: Option<Quantization>,
198
199 #[serde(default, skip_serializing_if = "Vec::is_empty")]
203 pub outputs: Vec<PhysicalOutput>,
204}
205
206impl LogicalOutput {
207 pub fn is_split(&self) -> bool {
210 !self.outputs.is_empty()
211 }
212}
213
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
220pub struct PhysicalOutput {
221 pub name: String,
225
226 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
233 pub type_: Option<PhysicalType>,
234
235 pub shape: Vec<usize>,
237
238 #[serde(
241 default,
242 deserialize_with = "deserialize_dshape",
243 skip_serializing_if = "Vec::is_empty"
244 )]
245 pub dshape: Vec<(DimName, usize)>,
246
247 pub dtype: DType,
249
250 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub quantization: Option<Quantization>,
254
255 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub stride: Option<Stride>,
259
260 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub scale_index: Option<usize>,
264
265 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub activation_applied: Option<Activation>,
270
271 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub activation_required: Option<Activation>,
275}
276
277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
283pub struct Quantization {
284 #[serde(deserialize_with = "deserialize_scalar_or_vec_f32")]
287 pub scale: Vec<f32>,
288
289 #[serde(
293 default,
294 deserialize_with = "deserialize_opt_scalar_or_vec_i32",
295 skip_serializing_if = "Option::is_none"
296 )]
297 pub zero_point: Option<Vec<i32>>,
298
299 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub axis: Option<usize>,
303
304 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub dtype: Option<DType>,
309}
310
311impl Quantization {
312 pub fn is_per_tensor(&self) -> bool {
314 self.scale.len() == 1
315 }
316
317 pub fn is_per_channel(&self) -> bool {
319 self.scale.len() > 1
320 }
321
322 pub fn is_symmetric(&self) -> bool {
324 match &self.zero_point {
325 None => true,
326 Some(zps) => zps.iter().all(|&z| z == 0),
327 }
328 }
329
330 pub fn zero_point_at(&self, channel: usize) -> i32 {
333 match &self.zero_point {
334 None => 0,
335 Some(zps) if zps.len() == 1 => zps[0],
336 Some(zps) => zps.get(channel).copied().unwrap_or(0),
337 }
338 }
339
340 pub fn scale_at(&self, channel: usize) -> f32 {
342 if self.scale.len() == 1 {
343 self.scale[0]
344 } else {
345 self.scale.get(channel).copied().unwrap_or(0.0)
346 }
347 }
348}
349
350impl TryFrom<&Quantization> for edgefirst_tensor::Quantization {
360 type Error = edgefirst_tensor::Error;
361
362 fn try_from(q: &Quantization) -> Result<Self, Self::Error> {
363 match (q.scale.as_slice(), q.zero_point.as_deref(), q.axis) {
364 ([scale], None, None) => Ok(Self::per_tensor_symmetric(*scale)),
366 ([scale], Some([zp]), None) => Ok(Self::per_tensor(*scale, *zp)),
368 ([scale], Some([zp]), Some(_)) => Ok(Self::per_tensor(*scale, *zp)),
370 ([scale], None, Some(_)) => Ok(Self::per_tensor_symmetric(*scale)),
371 (scales, None, Some(axis)) if scales.len() > 1 => {
373 Self::per_channel_symmetric(scales.to_vec(), axis)
374 }
375 (scales, Some(zps), Some(axis)) if scales.len() > 1 => {
376 Self::per_channel(scales.to_vec(), zps.to_vec(), axis)
377 }
378 (scales, _, None) if scales.len() > 1 => {
380 Err(edgefirst_tensor::Error::QuantizationInvalid {
381 field: "axis",
382 expected: "Some(axis) for per-channel".into(),
383 got: "None".into(),
384 })
385 }
386 _ => Err(edgefirst_tensor::Error::QuantizationInvalid {
387 field: "scale",
388 expected: "non-empty".into(),
389 got: format!("len={}", q.scale.len()),
390 }),
391 }
392 }
393}
394
395fn deserialize_scalar_or_vec_f32<'de, D>(de: D) -> Result<Vec<f32>, D::Error>
397where
398 D: serde::Deserializer<'de>,
399{
400 #[derive(Deserialize)]
401 #[serde(untagged)]
402 enum OneOrMany {
403 One(f32),
404 Many(Vec<f32>),
405 }
406 match OneOrMany::deserialize(de)? {
407 OneOrMany::One(v) => Ok(vec![v]),
408 OneOrMany::Many(vs) => Ok(vs),
409 }
410}
411
412fn deserialize_opt_scalar_or_vec_i32<'de, D>(de: D) -> Result<Option<Vec<i32>>, D::Error>
414where
415 D: serde::Deserializer<'de>,
416{
417 #[derive(Deserialize)]
418 #[serde(untagged)]
419 enum OneOrMany {
420 One(i32),
421 Many(Vec<i32>),
422 }
423 match Option::<OneOrMany>::deserialize(de)? {
424 None => Ok(None),
425 Some(OneOrMany::One(v)) => Ok(Some(vec![v])),
426 Some(OneOrMany::Many(vs)) => Ok(Some(vs)),
427 }
428}
429
430#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
433#[serde(untagged)]
434pub enum Stride {
435 Square(u32),
436 Rect([u32; 2]),
437}
438
439impl Stride {
440 pub fn x(self) -> u32 {
442 match self {
443 Stride::Square(s) => s,
444 Stride::Rect([sx, _]) => sx,
445 }
446 }
447
448 pub fn y(self) -> u32 {
450 match self {
451 Stride::Square(s) => s,
452 Stride::Rect([_, sy]) => sy,
453 }
454 }
455}
456
457#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
459#[serde(rename_all = "snake_case")]
460pub enum LogicalType {
461 Boxes,
463 Scores,
465 Objectness,
467 Classes,
469 MaskCoefs,
471 Protos,
473 Landmarks,
475 Detections,
477 Segmentation,
479 Masks,
481 Detection,
483}
484
485#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
491#[serde(rename_all = "snake_case")]
492pub enum PhysicalType {
493 Boxes,
494 Scores,
495 Objectness,
496 Classes,
497 MaskCoefs,
498 Protos,
499 Landmarks,
500 Detections,
501 Segmentation,
502 Masks,
503 Detection,
504 BoxesXy,
506 BoxesWh,
508}
509
510#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
512#[serde(rename_all = "snake_case")]
513pub enum BoxEncoding {
514 Dfl,
517 Direct,
520 Anchor,
523}
524
525#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
527#[serde(rename_all = "snake_case")]
528pub enum ScoreFormat {
529 PerClass,
532 ObjXClass,
536}
537
538#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
540#[serde(rename_all = "snake_case")]
541pub enum Activation {
542 Sigmoid,
543 Softmax,
544 Tanh,
545}
546
547#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
549pub enum DecoderKind {
550 #[serde(rename = "modelpack")]
552 ModelPack,
553 #[serde(rename = "ultralytics")]
555 Ultralytics,
556}
557
558#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
560#[serde(rename_all = "snake_case")]
561pub enum DecoderVersion {
562 Yolov5,
563 Yolov8,
564 Yolo11,
565 Yolo26,
566}
567
568impl DecoderVersion {
569 pub fn is_end_to_end(self) -> bool {
571 matches!(self, DecoderVersion::Yolo26)
572 }
573}
574
575#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
577#[serde(rename_all = "snake_case")]
578pub enum NmsMode {
579 ClassAgnostic,
581 ClassAware,
584}
585
586#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
588#[serde(rename_all = "snake_case")]
589pub enum DType {
590 Int8,
591 Uint8,
592 Int16,
593 Uint16,
594 Int32,
595 Uint32,
596 Float16,
597 Float32,
598}
599
600impl DType {
601 pub fn size_bytes(self) -> usize {
603 match self {
604 DType::Int8 | DType::Uint8 => 1,
605 DType::Int16 | DType::Uint16 | DType::Float16 => 2,
606 DType::Int32 | DType::Uint32 | DType::Float32 => 4,
607 }
608 }
609
610 pub fn is_integer(self) -> bool {
612 matches!(
613 self,
614 DType::Int8
615 | DType::Uint8
616 | DType::Int16
617 | DType::Uint16
618 | DType::Int32
619 | DType::Uint32
620 )
621 }
622
623 pub fn is_float(self) -> bool {
625 matches!(self, DType::Float16 | DType::Float32)
626 }
627}
628
629impl SchemaV2 {
634 pub fn parse_json(s: &str) -> DecoderResult<Self> {
642 let value: serde_json::Value = serde_json::from_str(s)?;
643 Self::from_json_value(value)
644 }
645
646 pub fn parse_yaml(s: &str) -> DecoderResult<Self> {
650 let value: serde_yaml::Value = serde_yaml::from_str(s)?;
651 let json = serde_json::to_value(value)
652 .map_err(|e| DecoderError::InvalidConfig(format!("yaml→json bridge failed: {e}")))?;
653 Self::from_json_value(json)
654 }
655
656 pub fn parse_file(path: impl AsRef<std::path::Path>) -> DecoderResult<Self> {
660 let path = path.as_ref();
661 let content = std::fs::read_to_string(path)
662 .map_err(|e| DecoderError::InvalidConfig(format!("read {}: {e}", path.display())))?;
663 let ext = path
664 .extension()
665 .and_then(|e| e.to_str())
666 .map(str::to_ascii_lowercase);
667 match ext.as_deref() {
668 Some("json") => Self::parse_json(&content),
669 Some("yaml") | Some("yml") => Self::parse_yaml(&content),
670 _ => Self::parse_json(&content).or_else(|_| Self::parse_yaml(&content)),
671 }
672 }
673
674 pub fn from_json_value(value: serde_json::Value) -> DecoderResult<Self> {
677 let version = value
678 .get("schema_version")
679 .and_then(|v| v.as_u64())
680 .map(|v| v as u32)
681 .unwrap_or(1);
682
683 if version > MAX_SUPPORTED_SCHEMA_VERSION {
684 return Err(DecoderError::NotSupported(format!(
685 "schema_version {version} is not supported by this HAL \
686 (maximum supported version is {MAX_SUPPORTED_SCHEMA_VERSION}); \
687 upgrade the HAL or downgrade the metadata"
688 )));
689 }
690
691 if version >= 2 {
692 serde_json::from_value(value).map_err(DecoderError::Json)
693 } else {
694 let v1: ConfigOutputs = serde_json::from_value(value).map_err(DecoderError::Json)?;
695 Self::from_v1(&v1)
696 }
697 }
698
699 pub fn from_v1(v1: &ConfigOutputs) -> DecoderResult<Self> {
715 let outputs = v1
716 .outputs
717 .iter()
718 .map(logical_from_v1)
719 .collect::<DecoderResult<Vec<_>>>()?;
720 Ok(SchemaV2 {
721 schema_version: 2,
722 input: None,
723 outputs,
724 nms: v1.nms.as_ref().map(NmsMode::from_v1),
725 decoder_version: v1.decoder_version.as_ref().map(DecoderVersion::from_v1),
726 })
727 }
728}
729
730impl SchemaV2 {
731 pub fn to_legacy_config_outputs(&self) -> DecoderResult<ConfigOutputs> {
758 let mut outputs = Vec::with_capacity(self.outputs.len());
759 for logical in &self.outputs {
760 if logical.type_.is_none() {
766 continue;
767 }
768 if logical.type_ == Some(LogicalType::Boxes)
775 && logical.encoding == Some(BoxEncoding::Dfl)
776 && logical.outputs.is_empty()
777 {
778 return Err(DecoderError::NotSupported(format!(
779 "`boxes` output `{}` has `encoding: dfl` on a flat \
780 logical (no per-scale children); the HAL's DFL \
781 decode kernel only runs inside the per-scale merge \
782 path. Split the boxes output into per-FPN-level \
783 children (Hailo convention) or pre-decode to 4 \
784 channels in the model graph (TFLite convention).",
785 logical.name.as_deref().unwrap_or("<anonymous>"),
786 )));
787 }
788 if let Some(q) = &logical.quantization {
789 if q.is_per_channel() {
790 return Err(DecoderError::NotSupported(format!(
791 "logical `{}` uses per-channel quantization \
792 (axis {:?}, {} scales); the v1 decoder only \
793 supports per-tensor quantization",
794 logical.name.as_deref().unwrap_or("<anonymous>"),
795 q.axis,
796 q.scale.len(),
797 )));
798 }
799 }
800 outputs.push(logical_to_legacy_config_output(logical)?);
801 }
802 Ok(ConfigOutputs {
803 outputs,
804 nms: self.nms.map(NmsMode::to_v1),
805 decoder_version: self.decoder_version.map(|v| v.to_v1()),
806 })
807 }
808
809 pub fn validate(&self) -> DecoderResult<()> {
827 if self.schema_version == 0 || self.schema_version > MAX_SUPPORTED_SCHEMA_VERSION {
828 return Err(DecoderError::InvalidConfig(format!(
829 "schema_version {} outside supported range [1, {MAX_SUPPORTED_SCHEMA_VERSION}]",
830 self.schema_version
831 )));
832 }
833
834 for logical in &self.outputs {
835 validate_logical(logical)?;
836 }
837
838 Ok(())
839 }
840}
841
842fn validate_logical(logical: &LogicalOutput) -> DecoderResult<()> {
843 if logical.outputs.is_empty() {
844 return Ok(());
845 }
846
847 for child in &logical.outputs {
849 if child.name.is_empty() {
850 return Err(DecoderError::InvalidConfig(format!(
851 "physical child of logical `{}` is missing `name`; name is \
852 required for tensor binding",
853 logical.name.as_deref().unwrap_or("<anonymous>")
854 )));
855 }
856 }
857
858 for (i, a) in logical.outputs.iter().enumerate() {
867 for b in &logical.outputs[i + 1..] {
868 let (Some(ta), Some(tb)) = (a.type_, b.type_) else {
869 continue;
870 };
871 if a.shape == b.shape && ta == tb {
872 return Err(DecoderError::InvalidConfig(format!(
873 "physical children `{}` and `{}` share shape {:?} and \
874 type; tensor binding cannot be resolved",
875 a.name, b.name, a.shape
876 )));
877 }
878 }
879 }
880
881 let strided: Vec<_> = logical.outputs.iter().map(|c| c.stride.is_some()).collect();
885 let all_strided = strided.iter().all(|&b| b);
886 let none_strided = strided.iter().all(|&b| !b);
887 if !(all_strided || none_strided) {
888 return Err(DecoderError::InvalidConfig(format!(
889 "logical `{}` mixes per-scale children (with stride) and \
890 channel sub-split children (without stride); decomposition \
891 must be uniform",
892 logical.name.as_deref().unwrap_or("<anonymous>")
893 )));
894 }
895
896 if logical.type_ == Some(LogicalType::Boxes) && logical.encoding == Some(BoxEncoding::Dfl) {
899 for child in &logical.outputs {
900 if let Some(feat) = last_feature_axis(child) {
901 if feat % 4 != 0 {
902 return Err(DecoderError::InvalidConfig(format!(
903 "DFL boxes child `{}` feature axis {feat} is not \
904 divisible by 4 (reg_max×4)",
905 child.name
906 )));
907 }
908 }
909 }
910 }
911
912 Ok(())
913}
914
915fn last_feature_axis(child: &PhysicalOutput) -> Option<usize> {
918 for (name, size) in &child.dshape {
921 if matches!(
922 name,
923 DimName::NumFeatures
924 | DimName::NumClasses
925 | DimName::NumProtos
926 | DimName::BoxCoords
927 | DimName::NumAnchorsXFeatures
928 ) {
929 return Some(*size);
930 }
931 }
932 child.shape.last().copied()
933}
934
935fn quantization_from_v1(q: Option<QuantTuple>) -> Option<Quantization> {
936 q.map(|QuantTuple(scale, zp)| Quantization {
937 scale: vec![scale],
938 zero_point: Some(vec![zp]),
939 axis: None,
940 dtype: None,
941 })
942}
943
944fn logical_from_v1(v1: &ConfigOutput) -> DecoderResult<LogicalOutput> {
945 match v1 {
946 ConfigOutput::Detection(d) => {
947 let encoding = match (d.decoder, d.anchors.is_some()) {
953 (configs::DecoderType::ModelPack, true) => Some(BoxEncoding::Anchor),
954 (configs::DecoderType::Ultralytics, _) => Some(BoxEncoding::Direct),
955 (configs::DecoderType::ModelPack, false) => None,
958 };
959 Ok(LogicalOutput {
960 name: None,
961 type_: Some(LogicalType::Detection),
962 shape: d.shape.clone(),
963 dshape: d.dshape.clone(),
964 decoder: Some(DecoderKind::from_v1(d.decoder)),
965 encoding,
966 score_format: None,
967 normalized: d.normalized,
968 anchors: d.anchors.clone(),
969 stride: None,
970 dtype: None,
971 quantization: quantization_from_v1(d.quantization),
972 outputs: Vec::new(),
973 })
974 }
975 ConfigOutput::Boxes(b) => Ok(LogicalOutput {
976 name: None,
977 type_: Some(LogicalType::Boxes),
978 shape: b.shape.clone(),
979 dshape: b.dshape.clone(),
980 decoder: Some(DecoderKind::from_v1(b.decoder)),
981 encoding: Some(BoxEncoding::Direct),
985 score_format: None,
986 normalized: b.normalized,
987 anchors: None,
988 stride: None,
989 dtype: None,
990 quantization: quantization_from_v1(b.quantization),
991 outputs: Vec::new(),
992 }),
993 ConfigOutput::Scores(s) => Ok(LogicalOutput {
994 name: None,
995 type_: Some(LogicalType::Scores),
996 shape: s.shape.clone(),
997 dshape: s.dshape.clone(),
998 decoder: Some(DecoderKind::from_v1(s.decoder)),
999 encoding: None,
1000 score_format: Some(ScoreFormat::PerClass),
1004 normalized: None,
1005 anchors: None,
1006 stride: None,
1007 dtype: None,
1008 quantization: quantization_from_v1(s.quantization),
1009 outputs: Vec::new(),
1010 }),
1011 ConfigOutput::Protos(p) => Ok(LogicalOutput {
1012 name: None,
1013 type_: Some(LogicalType::Protos),
1014 shape: p.shape.clone(),
1015 dshape: p.dshape.clone(),
1016 decoder: Some(DecoderKind::from_v1(p.decoder)),
1018 encoding: None,
1019 score_format: None,
1020 normalized: None,
1021 anchors: None,
1022 stride: None,
1023 dtype: None,
1024 quantization: quantization_from_v1(p.quantization),
1025 outputs: Vec::new(),
1026 }),
1027 ConfigOutput::MaskCoefficients(m) => Ok(LogicalOutput {
1028 name: None,
1029 type_: Some(LogicalType::MaskCoefs),
1030 shape: m.shape.clone(),
1031 dshape: m.dshape.clone(),
1032 decoder: Some(DecoderKind::from_v1(m.decoder)),
1033 encoding: None,
1034 score_format: None,
1035 normalized: None,
1036 anchors: None,
1037 stride: None,
1038 dtype: None,
1039 quantization: quantization_from_v1(m.quantization),
1040 outputs: Vec::new(),
1041 }),
1042 ConfigOutput::Segmentation(seg) => Ok(LogicalOutput {
1043 name: None,
1044 type_: Some(LogicalType::Segmentation),
1045 shape: seg.shape.clone(),
1046 dshape: seg.dshape.clone(),
1047 decoder: Some(DecoderKind::from_v1(seg.decoder)),
1048 encoding: None,
1049 score_format: None,
1050 normalized: None,
1051 anchors: None,
1052 stride: None,
1053 dtype: None,
1054 quantization: quantization_from_v1(seg.quantization),
1055 outputs: Vec::new(),
1056 }),
1057 ConfigOutput::Mask(m) => Ok(LogicalOutput {
1058 name: None,
1059 type_: Some(LogicalType::Masks),
1060 shape: m.shape.clone(),
1061 dshape: m.dshape.clone(),
1062 decoder: Some(DecoderKind::from_v1(m.decoder)),
1063 encoding: None,
1064 score_format: None,
1065 normalized: None,
1066 anchors: None,
1067 stride: None,
1068 dtype: None,
1069 quantization: quantization_from_v1(m.quantization),
1070 outputs: Vec::new(),
1071 }),
1072 ConfigOutput::Classes(c) => Ok(LogicalOutput {
1073 name: None,
1074 type_: Some(LogicalType::Classes),
1075 shape: c.shape.clone(),
1076 dshape: c.dshape.clone(),
1077 decoder: Some(DecoderKind::from_v1(c.decoder)),
1078 encoding: None,
1079 score_format: None,
1080 normalized: None,
1081 anchors: None,
1082 stride: None,
1083 dtype: None,
1084 quantization: quantization_from_v1(c.quantization),
1085 outputs: Vec::new(),
1086 }),
1087 }
1088}
1089
1090impl DecoderKind {
1091 pub fn from_v1(v: configs::DecoderType) -> Self {
1093 match v {
1094 configs::DecoderType::ModelPack => DecoderKind::ModelPack,
1095 configs::DecoderType::Ultralytics => DecoderKind::Ultralytics,
1096 }
1097 }
1098
1099 pub fn to_v1(self) -> configs::DecoderType {
1101 match self {
1102 DecoderKind::ModelPack => configs::DecoderType::ModelPack,
1103 DecoderKind::Ultralytics => configs::DecoderType::Ultralytics,
1104 }
1105 }
1106}
1107
1108impl DecoderVersion {
1109 pub fn from_v1(v: &configs::DecoderVersion) -> Self {
1111 match v {
1112 configs::DecoderVersion::Yolov5 => DecoderVersion::Yolov5,
1113 configs::DecoderVersion::Yolov8 => DecoderVersion::Yolov8,
1114 configs::DecoderVersion::Yolo11 => DecoderVersion::Yolo11,
1115 configs::DecoderVersion::Yolo26 => DecoderVersion::Yolo26,
1116 }
1117 }
1118
1119 pub fn to_v1(self) -> configs::DecoderVersion {
1121 match self {
1122 DecoderVersion::Yolov5 => configs::DecoderVersion::Yolov5,
1123 DecoderVersion::Yolov8 => configs::DecoderVersion::Yolov8,
1124 DecoderVersion::Yolo11 => configs::DecoderVersion::Yolo11,
1125 DecoderVersion::Yolo26 => configs::DecoderVersion::Yolo26,
1126 }
1127 }
1128}
1129
1130impl NmsMode {
1131 pub fn from_v1(v: &configs::Nms) -> Self {
1133 match v {
1134 configs::Nms::ClassAgnostic => NmsMode::ClassAgnostic,
1135 configs::Nms::ClassAware => NmsMode::ClassAware,
1136 }
1137 }
1138
1139 pub fn to_v1(self) -> configs::Nms {
1141 match self {
1142 NmsMode::ClassAgnostic => configs::Nms::ClassAgnostic,
1143 NmsMode::ClassAware => configs::Nms::ClassAware,
1144 }
1145 }
1146}
1147
1148fn quantization_to_legacy(q: &Quantization) -> DecoderResult<QuantTuple> {
1151 if q.is_per_channel() {
1152 return Err(DecoderError::NotSupported(
1153 "per-channel quantization cannot be expressed as a v1 QuantTuple".into(),
1154 ));
1155 }
1156 let scale = *q.scale.first().unwrap_or(&0.0);
1157 let zp = q.zero_point_at(0);
1158 Ok(QuantTuple(scale, zp))
1159}
1160
1161pub(crate) fn squeeze_padding_dims(
1167 shape: Vec<usize>,
1168 dshape: Vec<(DimName, usize)>,
1169) -> (Vec<usize>, Vec<(DimName, usize)>) {
1170 if dshape.is_empty() {
1174 return (shape, dshape);
1175 }
1176 let keep: Vec<bool> = dshape
1177 .iter()
1178 .map(|(n, _)| !matches!(n, DimName::Padding))
1179 .collect();
1180 let shape = shape
1181 .into_iter()
1182 .zip(keep.iter())
1183 .filter_map(|(s, &k)| k.then_some(s))
1184 .collect();
1185 let dshape = dshape
1186 .into_iter()
1187 .zip(keep.iter())
1188 .filter_map(|(d, &k)| k.then_some(d))
1189 .collect();
1190 (shape, dshape)
1191}
1192
1193pub(crate) fn padding_axes(dshape: &[(DimName, usize)]) -> Vec<usize> {
1198 let mut v: Vec<usize> = dshape
1199 .iter()
1200 .enumerate()
1201 .filter_map(|(i, (n, _))| matches!(n, DimName::Padding).then_some(i))
1202 .collect();
1203 v.sort_by(|a, b| b.cmp(a));
1204 v
1205}
1206
1207fn logical_to_legacy_config_output(logical: &LogicalOutput) -> DecoderResult<ConfigOutput> {
1208 let decoder = logical
1209 .decoder
1210 .map(|d| d.to_v1())
1211 .unwrap_or(configs::DecoderType::Ultralytics);
1212 let quantization = logical
1213 .quantization
1214 .as_ref()
1215 .map(quantization_to_legacy)
1216 .transpose()?;
1217 let (shape, dshape) = squeeze_padding_dims(logical.shape.clone(), logical.dshape.clone());
1222
1223 let ty = logical.type_.ok_or_else(|| {
1224 DecoderError::InvalidConfig(format!(
1228 "logical output `{}` has no type; typeless outputs should be \
1229 filtered before legacy conversion",
1230 logical.name.as_deref().unwrap_or("<anonymous>")
1231 ))
1232 })?;
1233
1234 Ok(match ty {
1235 LogicalType::Boxes => ConfigOutput::Boxes(configs::Boxes {
1236 decoder,
1237 quantization,
1238 shape,
1239 dshape,
1240 normalized: logical.normalized,
1241 }),
1242 LogicalType::Scores => ConfigOutput::Scores(configs::Scores {
1243 decoder,
1244 quantization,
1245 shape,
1246 dshape,
1247 }),
1248 LogicalType::Protos => ConfigOutput::Protos(configs::Protos {
1249 decoder,
1250 quantization,
1251 shape,
1252 dshape,
1253 }),
1254 LogicalType::MaskCoefs => ConfigOutput::MaskCoefficients(configs::MaskCoefficients {
1255 decoder,
1256 quantization,
1257 shape,
1258 dshape,
1259 }),
1260 LogicalType::Segmentation => ConfigOutput::Segmentation(configs::Segmentation {
1261 decoder,
1262 quantization,
1263 shape,
1264 dshape,
1265 }),
1266 LogicalType::Masks => ConfigOutput::Mask(configs::Mask {
1267 decoder,
1268 quantization,
1269 shape,
1270 dshape,
1271 }),
1272 LogicalType::Classes => ConfigOutput::Classes(configs::Classes {
1273 decoder,
1274 quantization,
1275 shape,
1276 dshape,
1277 }),
1278 LogicalType::Detection | LogicalType::Detections => {
1282 ConfigOutput::Detection(configs::Detection {
1283 anchors: logical.anchors.clone(),
1284 decoder,
1285 quantization,
1286 shape,
1287 dshape,
1288 normalized: logical.normalized,
1289 })
1290 }
1291 LogicalType::Objectness | LogicalType::Landmarks => {
1294 return Err(DecoderError::NotSupported(format!(
1295 "logical type {:?} has no legacy v1 equivalent; use the \
1296 native v2 decoder path",
1297 ty
1298 )));
1299 }
1300 })
1301}
1302
1303#[cfg(test)]
1304#[cfg_attr(coverage_nightly, coverage(off))]
1305mod tests {
1306 use super::*;
1307
1308 #[test]
1309 fn schema_default_is_v2() {
1310 let s = SchemaV2::default();
1311 assert_eq!(s.schema_version, 2);
1312 assert!(s.outputs.is_empty());
1313 }
1314
1315 #[test]
1316 fn dtype_roundtrip() {
1317 for d in [
1318 DType::Int8,
1319 DType::Uint8,
1320 DType::Int16,
1321 DType::Uint16,
1322 DType::Float16,
1323 DType::Float32,
1324 ] {
1325 let j = serde_json::to_string(&d).unwrap();
1326 let back: DType = serde_json::from_str(&j).unwrap();
1327 assert_eq!(back, d);
1328 }
1329 }
1330
1331 #[test]
1332 fn dtype_widths() {
1333 assert_eq!(DType::Int8.size_bytes(), 1);
1334 assert_eq!(DType::Float16.size_bytes(), 2);
1335 assert_eq!(DType::Float32.size_bytes(), 4);
1336 }
1337
1338 #[test]
1339 fn stride_accepts_scalar_or_pair() {
1340 let a: Stride = serde_json::from_str("8").unwrap();
1341 let b: Stride = serde_json::from_str("[8, 16]").unwrap();
1342 assert_eq!(a, Stride::Square(8));
1343 assert_eq!(b, Stride::Rect([8, 16]));
1344 assert_eq!(a.x(), 8);
1345 assert_eq!(a.y(), 8);
1346 assert_eq!(b.x(), 8);
1347 assert_eq!(b.y(), 16);
1348 }
1349
1350 #[test]
1351 fn quantization_scalar_scale() {
1352 let j = r#"{"scale": 0.00392, "zero_point": 0, "dtype": "int8"}"#;
1353 let q: Quantization = serde_json::from_str(j).unwrap();
1354 assert!(q.is_per_tensor());
1355 assert!(q.is_symmetric());
1356 assert_eq!(q.scale_at(0), 0.00392);
1357 assert_eq!(q.scale_at(5), 0.00392);
1358 assert_eq!(q.zero_point_at(0), 0);
1359 }
1360
1361 #[test]
1362 fn quantization_per_channel() {
1363 let j = r#"{"scale": [0.054, 0.089, 0.195], "axis": 0, "dtype": "int8"}"#;
1364 let q: Quantization = serde_json::from_str(j).unwrap();
1365 assert!(q.is_per_channel());
1366 assert!(q.is_symmetric());
1367 assert_eq!(q.axis, Some(0));
1368 assert_eq!(q.scale_at(0), 0.054);
1369 assert_eq!(q.scale_at(2), 0.195);
1370 }
1371
1372 #[test]
1373 fn quantization_asymmetric_per_tensor() {
1374 let j = r#"{"scale": 0.176, "zero_point": 198, "dtype": "uint8"}"#;
1375 let q: Quantization = serde_json::from_str(j).unwrap();
1376 assert!(!q.is_symmetric());
1377 assert_eq!(q.zero_point_at(0), 198);
1378 assert_eq!(q.zero_point_at(10), 198);
1379 }
1380
1381 #[test]
1382 fn quantization_symmetric_default_zero_point() {
1383 let j = r#"{"scale": 0.00392, "dtype": "int8"}"#;
1384 let q: Quantization = serde_json::from_str(j).unwrap();
1385 assert!(q.is_symmetric());
1386 assert_eq!(q.zero_point_at(0), 0);
1387 }
1388
1389 #[test]
1390 fn quantization_to_tensor_per_tensor_asymmetric() {
1391 let q = Quantization {
1392 scale: vec![0.1],
1393 zero_point: Some(vec![-5]),
1394 axis: None,
1395 dtype: Some(DType::Int8),
1396 };
1397 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1398 assert!(t.is_per_tensor());
1399 assert!(!t.is_symmetric());
1400 assert_eq!(t.scale(), &[0.1]);
1401 assert_eq!(t.zero_point(), Some(&[-5][..]));
1402 }
1403
1404 #[test]
1405 fn quantization_to_tensor_per_tensor_symmetric() {
1406 let q = Quantization {
1407 scale: vec![0.05],
1408 zero_point: None,
1409 axis: None,
1410 dtype: Some(DType::Int8),
1411 };
1412 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1413 assert!(t.is_per_tensor());
1414 assert!(t.is_symmetric());
1415 }
1416
1417 #[test]
1418 fn quantization_to_tensor_per_channel_asymmetric() {
1419 let q = Quantization {
1420 scale: vec![0.1, 0.2, 0.3],
1421 zero_point: Some(vec![-1, 0, 1]),
1422 axis: Some(2),
1423 dtype: Some(DType::Int8),
1424 };
1425 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1426 assert!(t.is_per_channel());
1427 assert_eq!(t.axis(), Some(2));
1428 assert_eq!(t.scale().len(), 3);
1429 assert_eq!(t.zero_point().map(|z| z.len()), Some(3));
1430 }
1431
1432 #[test]
1433 fn quantization_to_tensor_per_channel_symmetric() {
1434 let q = Quantization {
1435 scale: vec![0.054, 0.089, 0.195],
1436 zero_point: None,
1437 axis: Some(0),
1438 dtype: Some(DType::Int8),
1439 };
1440 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1441 assert!(t.is_per_channel());
1442 assert!(t.is_symmetric());
1443 assert_eq!(t.axis(), Some(0));
1444 }
1445
1446 #[test]
1447 fn quantization_to_tensor_per_channel_missing_axis_errors() {
1448 let q = Quantization {
1449 scale: vec![0.1, 0.2, 0.3],
1450 zero_point: None,
1451 axis: None,
1452 dtype: None,
1453 };
1454 let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
1455 assert!(matches!(
1456 err,
1457 edgefirst_tensor::Error::QuantizationInvalid { .. }
1458 ));
1459 }
1460
1461 #[test]
1462 fn logical_output_flat_tflite_boxes() {
1463 let j = r#"{
1465 "name": "boxes", "type": "boxes",
1466 "shape": [1, 64, 8400],
1467 "dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
1468 "dtype": "int8",
1469 "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1470 "decoder": "ultralytics",
1471 "encoding": "dfl",
1472 "normalized": true
1473 }"#;
1474 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1475 assert_eq!(lo.type_, Some(LogicalType::Boxes));
1476 assert_eq!(lo.encoding, Some(BoxEncoding::Dfl));
1477 assert_eq!(lo.normalized, Some(true));
1478 assert!(!lo.is_split());
1479 assert_eq!(lo.dtype, Some(DType::Int8));
1480 }
1481
1482 #[test]
1483 fn logical_output_hailo_per_scale_split() {
1484 let j = r#"{
1486 "name": "boxes", "type": "boxes",
1487 "shape": [1, 64, 8400],
1488 "encoding": "dfl", "decoder": "ultralytics", "normalized": true,
1489 "outputs": [
1490 {
1491 "name": "boxes_0", "type": "boxes",
1492 "stride": 8, "scale_index": 0,
1493 "shape": [1, 80, 80, 64],
1494 "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_features": 64}],
1495 "dtype": "uint8",
1496 "quantization": {"scale": 0.0234, "zero_point": 128, "dtype": "uint8"}
1497 }
1498 ]
1499 }"#;
1500 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1501 assert!(lo.is_split());
1502 assert_eq!(lo.outputs.len(), 1);
1503 let child = &lo.outputs[0];
1504 assert_eq!(child.name, "boxes_0");
1505 assert_eq!(child.type_, Some(PhysicalType::Boxes));
1506 assert_eq!(child.stride, Some(Stride::Square(8)));
1507 assert_eq!(child.scale_index, Some(0));
1508 assert_eq!(child.dtype, DType::Uint8);
1509 }
1510
1511 #[test]
1512 fn logical_output_ara2_xy_wh_channel_split() {
1513 let j = r#"{
1515 "name": "boxes", "type": "boxes",
1516 "shape": [1, 4, 8400, 1],
1517 "encoding": "direct", "decoder": "ultralytics", "normalized": true,
1518 "outputs": [
1519 {
1520 "name": "_model_22_Div_1_output_0", "type": "boxes_xy",
1521 "shape": [1, 2, 8400, 1],
1522 "dshape": [{"batch": 1}, {"box_coords": 2}, {"num_boxes": 8400}, {"padding": 1}],
1523 "dtype": "int16",
1524 "quantization": {"scale": 3.129e-5, "zero_point": 0, "dtype": "int16"}
1525 },
1526 {
1527 "name": "_model_22_Sub_1_output_0", "type": "boxes_wh",
1528 "shape": [1, 2, 8400, 1],
1529 "dshape": [{"batch": 1}, {"box_coords": 2}, {"num_boxes": 8400}, {"padding": 1}],
1530 "dtype": "int16",
1531 "quantization": {"scale": 3.149e-5, "zero_point": 0, "dtype": "int16"}
1532 }
1533 ]
1534 }"#;
1535 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1536 assert_eq!(lo.encoding, Some(BoxEncoding::Direct));
1537 assert_eq!(lo.outputs.len(), 2);
1538 assert_eq!(lo.outputs[0].type_, Some(PhysicalType::BoxesXy));
1539 assert_eq!(lo.outputs[1].type_, Some(PhysicalType::BoxesWh));
1540 assert!(lo.outputs[0].stride.is_none());
1541 assert!(lo.outputs[1].stride.is_none());
1542 }
1543
1544 #[test]
1545 fn logical_output_hailo_scores_sigmoid_applied() {
1546 let j = r#"{
1547 "name": "scores", "type": "scores",
1548 "shape": [1, 80, 8400],
1549 "decoder": "ultralytics", "score_format": "per_class",
1550 "outputs": [
1551 {
1552 "name": "scores_0", "type": "scores",
1553 "stride": 8, "scale_index": 0,
1554 "shape": [1, 80, 80, 80],
1555 "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_classes": 80}],
1556 "dtype": "uint8",
1557 "quantization": {"scale": 0.003922, "dtype": "uint8"},
1558 "activation_applied": "sigmoid"
1559 }
1560 ]
1561 }"#;
1562 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1563 assert_eq!(lo.score_format, Some(ScoreFormat::PerClass));
1564 let child = &lo.outputs[0];
1565 assert_eq!(child.activation_applied, Some(Activation::Sigmoid));
1566 assert!(child.activation_required.is_none());
1567 }
1568
1569 #[test]
1570 fn yolo26_end_to_end_detections() {
1571 let j = r#"{
1572 "schema_version": 2,
1573 "decoder_version": "yolo26",
1574 "outputs": [{
1575 "name": "output0", "type": "detections",
1576 "shape": [1, 100, 6],
1577 "dshape": [{"batch": 1}, {"num_boxes": 100}, {"num_features": 6}],
1578 "dtype": "int8",
1579 "quantization": {"scale": 0.0078, "zero_point": 0, "dtype": "int8"},
1580 "normalized": false,
1581 "decoder": "ultralytics"
1582 }]
1583 }"#;
1584 let s: SchemaV2 = serde_json::from_str(j).unwrap();
1585 assert_eq!(s.decoder_version, Some(DecoderVersion::Yolo26));
1586 assert!(s.decoder_version.unwrap().is_end_to_end());
1587 assert_eq!(s.outputs[0].type_, Some(LogicalType::Detections));
1588 assert_eq!(s.outputs[0].normalized, Some(false));
1589 assert!(s.nms.is_none());
1590 }
1591
1592 #[test]
1593 fn modelpack_anchor_detection_with_rect_stride() {
1594 let j = r#"{
1595 "schema_version": 2,
1596 "outputs": [{
1597 "name": "output_0", "type": "detection",
1598 "shape": [1, 40, 40, 54],
1599 "dshape": [{"batch": 1}, {"height": 40}, {"width": 40}, {"num_anchors_x_features": 54}],
1600 "dtype": "uint8",
1601 "quantization": {"scale": 0.176, "zero_point": 198, "dtype": "uint8"},
1602 "decoder": "modelpack",
1603 "encoding": "anchor",
1604 "stride": [16, 16],
1605 "anchors": [[0.054, 0.065], [0.089, 0.139], [0.195, 0.196]]
1606 }]
1607 }"#;
1608 let s: SchemaV2 = serde_json::from_str(j).unwrap();
1609 let lo = &s.outputs[0];
1610 assert_eq!(lo.encoding, Some(BoxEncoding::Anchor));
1611 assert_eq!(lo.stride, Some(Stride::Rect([16, 16])));
1612 assert_eq!(lo.anchors.as_ref().map(|a| a.len()), Some(3));
1613 }
1614
1615 #[test]
1616 fn yolov5_obj_x_class_objectness_logical() {
1617 let j = r#"{
1618 "name": "objectness", "type": "objectness",
1619 "shape": [1, 3, 8400],
1620 "decoder": "ultralytics",
1621 "outputs": [{
1622 "name": "objectness_0", "type": "objectness",
1623 "stride": 8, "scale_index": 0,
1624 "shape": [1, 80, 80, 3],
1625 "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_features": 3}],
1626 "dtype": "uint8",
1627 "quantization": {"scale": 0.0039, "zero_point": 0, "dtype": "uint8"},
1628 "activation_applied": "sigmoid"
1629 }]
1630 }"#;
1631 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1632 assert_eq!(lo.type_, Some(LogicalType::Objectness));
1633 assert_eq!(lo.outputs[0].activation_applied, Some(Activation::Sigmoid));
1634 }
1635
1636 #[test]
1637 fn direct_protos_no_decoder() {
1638 let j = r#"{
1640 "name": "protos", "type": "protos",
1641 "shape": [1, 32, 160, 160],
1642 "dshape": [{"batch": 1}, {"num_protos": 32}, {"height": 160}, {"width": 160}],
1643 "dtype": "uint8",
1644 "quantization": {"scale": 0.0203, "zero_point": 45, "dtype": "uint8"},
1645 "stride": 4
1646 }"#;
1647 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1648 assert_eq!(lo.type_, Some(LogicalType::Protos));
1649 assert!(lo.decoder.is_none());
1650 assert_eq!(lo.stride, Some(Stride::Square(4)));
1651 }
1652
1653 #[test]
1654 fn full_yolov8_tflite_flat_detection() {
1655 let j = r#"{
1657 "schema_version": 2,
1658 "decoder_version": "yolov8",
1659 "nms": "class_agnostic",
1660 "input": { "shape": [1, 640, 640, 3], "cameraadaptor": "rgb" },
1661 "outputs": [
1662 {
1663 "name": "boxes", "type": "boxes",
1664 "shape": [1, 64, 8400],
1665 "dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
1666 "dtype": "int8",
1667 "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1668 "decoder": "ultralytics",
1669 "encoding": "dfl",
1670 "normalized": true
1671 },
1672 {
1673 "name": "scores", "type": "scores",
1674 "shape": [1, 80, 8400],
1675 "dshape": [{"batch": 1}, {"num_classes": 80}, {"num_boxes": 8400}],
1676 "dtype": "int8",
1677 "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1678 "decoder": "ultralytics",
1679 "score_format": "per_class"
1680 }
1681 ]
1682 }"#;
1683 let s: SchemaV2 = serde_json::from_str(j).unwrap();
1684 assert_eq!(s.schema_version, 2);
1685 assert_eq!(s.decoder_version, Some(DecoderVersion::Yolov8));
1686 assert_eq!(s.nms, Some(NmsMode::ClassAgnostic));
1687 assert_eq!(s.input.as_ref().unwrap().shape, vec![1, 640, 640, 3]);
1688 assert_eq!(s.outputs.len(), 2);
1689 }
1690
1691 #[test]
1692 fn schema_unknown_version_parses_without_validation() {
1693 let j = r#"{"schema_version": 99, "outputs": []}"#;
1696 let s: SchemaV2 = serde_json::from_str(j).unwrap();
1697 assert_eq!(s.schema_version, 99);
1698 }
1699
1700 #[test]
1701 fn serde_roundtrip_preserves_fields() {
1702 let original = SchemaV2 {
1703 schema_version: 2,
1704 input: Some(InputSpec {
1705 shape: vec![1, 3, 640, 640],
1706 dshape: vec![],
1707 cameraadaptor: Some("rgb".into()),
1708 }),
1709 outputs: vec![LogicalOutput {
1710 name: Some("boxes".into()),
1711 type_: Some(LogicalType::Boxes),
1712 shape: vec![1, 4, 8400],
1713 dshape: vec![],
1714 decoder: Some(DecoderKind::Ultralytics),
1715 encoding: Some(BoxEncoding::Direct),
1716 score_format: None,
1717 normalized: Some(true),
1718 anchors: None,
1719 stride: None,
1720 dtype: Some(DType::Float32),
1721 quantization: None,
1722 outputs: vec![],
1723 }],
1724 nms: Some(NmsMode::ClassAgnostic),
1725 decoder_version: Some(DecoderVersion::Yolov8),
1726 };
1727 let j = serde_json::to_string(&original).unwrap();
1728 let parsed: SchemaV2 = serde_json::from_str(&j).unwrap();
1729 assert_eq!(parsed, original);
1730 }
1731
1732 #[test]
1735 fn parse_v1_yaml_yolov8_seg_testdata() {
1736 let yaml = include_str!(concat!(
1737 env!("CARGO_MANIFEST_DIR"),
1738 "/../../testdata/yolov8_seg.yaml"
1739 ));
1740 let schema = SchemaV2::parse_yaml(yaml).expect("parse v1 yaml");
1741 assert_eq!(schema.schema_version, 2);
1742 assert_eq!(schema.outputs.len(), 2);
1743 let det = &schema.outputs[0];
1745 assert_eq!(det.type_, Some(LogicalType::Detection));
1746 assert_eq!(det.shape, vec![1, 116, 8400]);
1747 assert_eq!(det.decoder, Some(DecoderKind::Ultralytics));
1748 assert_eq!(det.encoding, Some(BoxEncoding::Direct));
1749 let q = det.quantization.as_ref().unwrap();
1750 assert_eq!(q.scale.len(), 1);
1751 assert!((q.scale[0] - 0.021_287_762).abs() < 1e-6);
1752 assert_eq!(q.zero_point, Some(vec![31]));
1753 let protos = &schema.outputs[1];
1755 assert_eq!(protos.type_, Some(LogicalType::Protos));
1756 assert_eq!(protos.shape, vec![1, 160, 160, 32]);
1757 }
1758
1759 #[test]
1760 fn parse_v1_json_modelpack_split_testdata() {
1761 let json = include_str!(concat!(
1762 env!("CARGO_MANIFEST_DIR"),
1763 "/../../testdata/modelpack_split.json"
1764 ));
1765 let schema = SchemaV2::parse_json(json).expect("parse v1 json");
1766 assert_eq!(schema.schema_version, 2);
1767 assert_eq!(schema.outputs.len(), 2);
1768 for out in &schema.outputs {
1770 assert_eq!(out.type_, Some(LogicalType::Detection));
1771 assert_eq!(out.decoder, Some(DecoderKind::ModelPack));
1772 assert_eq!(out.encoding, Some(BoxEncoding::Anchor));
1773 assert_eq!(out.anchors.as_ref().map(|a| a.len()), Some(3));
1774 }
1775 }
1776
1777 #[test]
1778 fn parse_v2_json_direct_when_schema_version_present() {
1779 let j = r#"{
1780 "schema_version": 2,
1781 "outputs": [{
1782 "name": "boxes", "type": "boxes",
1783 "shape": [1, 4, 8400],
1784 "dshape": [{"batch": 1}, {"box_coords": 4}, {"num_boxes": 8400}],
1785 "dtype": "float32",
1786 "decoder": "ultralytics",
1787 "encoding": "direct",
1788 "normalized": true
1789 }]
1790 }"#;
1791 let schema = SchemaV2::parse_json(j).unwrap();
1792 assert_eq!(schema.schema_version, 2);
1793 assert_eq!(schema.outputs[0].type_, Some(LogicalType::Boxes));
1794 }
1795
1796 #[test]
1797 fn parse_rejects_future_schema_version() {
1798 let j = r#"{"schema_version": 99, "outputs": []}"#;
1799 let err = SchemaV2::parse_json(j).unwrap_err();
1800 matches!(err, DecoderError::NotSupported(_));
1801 }
1802
1803 #[test]
1804 fn parse_absent_schema_version_treats_as_v1() {
1805 let j = r#"{
1807 "outputs": [
1808 {
1809 "type": "boxes", "decoder": "ultralytics",
1810 "shape": [1, 4, 8400],
1811 "quantization": [0.00392, 0]
1812 },
1813 {
1814 "type": "scores", "decoder": "ultralytics",
1815 "shape": [1, 80, 8400],
1816 "quantization": [0.00392, 0]
1817 }
1818 ]
1819 }"#;
1820 let schema = SchemaV2::parse_json(j).expect("v1 legacy parse");
1821 assert_eq!(schema.schema_version, 2); assert_eq!(schema.outputs.len(), 2);
1823 assert_eq!(schema.outputs[0].type_, Some(LogicalType::Boxes));
1824 assert_eq!(schema.outputs[1].type_, Some(LogicalType::Scores));
1825 assert_eq!(schema.outputs[1].score_format, Some(ScoreFormat::PerClass));
1827 }
1828
1829 #[test]
1830 fn from_v1_preserves_nms_and_decoder_version() {
1831 let v1 = ConfigOutputs {
1832 outputs: vec![ConfigOutput::Boxes(crate::configs::Boxes {
1833 decoder: crate::configs::DecoderType::Ultralytics,
1834 quantization: Some(crate::configs::QuantTuple(0.01, 5)),
1835 shape: vec![1, 4, 8400],
1836 dshape: vec![],
1837 normalized: Some(true),
1838 })],
1839 nms: Some(crate::configs::Nms::ClassAware),
1840 decoder_version: Some(crate::configs::DecoderVersion::Yolo11),
1841 };
1842 let v2 = SchemaV2::from_v1(&v1).unwrap();
1843 assert_eq!(v2.nms, Some(NmsMode::ClassAware));
1844 assert_eq!(v2.decoder_version, Some(DecoderVersion::Yolo11));
1845 assert_eq!(v2.outputs[0].normalized, Some(true));
1846 let q = v2.outputs[0].quantization.as_ref().unwrap();
1847 assert_eq!(q.scale, vec![0.01]);
1848 assert_eq!(q.zero_point, Some(vec![5]));
1849 assert_eq!(q.dtype, None); }
1851
1852 #[test]
1859 fn typeless_logical_output_parses_and_roundtrips() {
1860 let j = r#"{
1861 "schema_version": 2,
1862 "outputs": [
1863 {
1864 "name": "extra_telemetry",
1865 "shape": [1, 16]
1866 },
1867 {
1868 "name": "boxes",
1869 "type": "boxes",
1870 "shape": [1, 4, 8400]
1871 }
1872 ]
1873 }"#;
1874 let schema: SchemaV2 = serde_json::from_str(j).unwrap();
1875 assert_eq!(schema.outputs.len(), 2);
1876 assert_eq!(schema.outputs[0].type_, None);
1877 assert_eq!(schema.outputs[0].name.as_deref(), Some("extra_telemetry"));
1878 assert_eq!(schema.outputs[1].type_, Some(LogicalType::Boxes));
1879
1880 let round = serde_json::to_string(&schema).unwrap();
1882 let first_obj = round
1883 .split("\"outputs\":[")
1884 .nth(1)
1885 .and_then(|s| s.split("}").next())
1886 .expect("outputs array");
1887 assert!(
1888 !first_obj.contains("\"type\""),
1889 "typeless output must not serialize a `type` field, got: {first_obj}"
1890 );
1891 }
1892
1893 #[test]
1899 fn typeless_outputs_filtered_from_legacy_config() {
1900 let schema = SchemaV2 {
1901 schema_version: 2,
1902 input: None,
1903 outputs: vec![
1904 LogicalOutput {
1905 name: Some("diagnostic_histogram".into()),
1906 type_: None,
1907 shape: vec![1, 256],
1908 dshape: vec![],
1909 decoder: None,
1910 encoding: None,
1911 score_format: None,
1912 normalized: None,
1913 anchors: None,
1914 stride: None,
1915 dtype: None,
1916 quantization: None,
1917 outputs: vec![],
1918 },
1919 LogicalOutput {
1920 name: Some("boxes".into()),
1921 type_: Some(LogicalType::Boxes),
1922 shape: vec![1, 4, 8400],
1923 dshape: vec![],
1924 decoder: Some(DecoderKind::Ultralytics),
1925 encoding: Some(BoxEncoding::Direct),
1926 score_format: None,
1927 normalized: Some(true),
1928 anchors: None,
1929 stride: None,
1930 dtype: None,
1931 quantization: None,
1932 outputs: vec![],
1933 },
1934 ],
1935 nms: None,
1936 decoder_version: None,
1937 };
1938 let legacy = schema.to_legacy_config_outputs().unwrap();
1939 assert_eq!(
1940 legacy.outputs.len(),
1941 1,
1942 "typeless output must be filtered from legacy config"
1943 );
1944 assert!(
1945 matches!(legacy.outputs[0], ConfigOutput::Boxes(_)),
1946 "only the typed `boxes` output should survive lowering"
1947 );
1948 }
1949
1950 #[test]
1955 fn all_typeless_schema_produces_empty_legacy_config() {
1956 let schema = SchemaV2 {
1957 schema_version: 2,
1958 input: None,
1959 outputs: vec![LogicalOutput {
1960 name: Some("aux".into()),
1961 type_: None,
1962 shape: vec![1, 8],
1963 dshape: vec![],
1964 decoder: None,
1965 encoding: None,
1966 score_format: None,
1967 normalized: None,
1968 anchors: None,
1969 stride: None,
1970 dtype: None,
1971 quantization: None,
1972 outputs: vec![],
1973 }],
1974 nms: None,
1975 decoder_version: None,
1976 };
1977 let legacy = schema.to_legacy_config_outputs().unwrap();
1978 assert!(legacy.outputs.is_empty());
1979 }
1980
1981 #[test]
1987 fn typeless_physical_child_parses_and_skips_uniqueness() {
1988 let j = r#"{
1989 "name": "boxes",
1990 "type": "boxes",
1991 "shape": [1, 8400, 4],
1992 "outputs": [
1993 {
1994 "name": "boxes_xy",
1995 "type": "boxes_xy",
1996 "shape": [1, 8400, 2],
1997 "dtype": "float32"
1998 },
1999 {
2000 "name": "aux_user_managed",
2001 "shape": [1, 8400, 2],
2002 "dtype": "float32"
2003 }
2004 ]
2005 }"#;
2006 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2007 assert_eq!(lo.outputs.len(), 2);
2008 assert_eq!(lo.outputs[0].type_, Some(PhysicalType::BoxesXy));
2009 assert_eq!(lo.outputs[1].type_, None);
2010
2011 let schema = SchemaV2 {
2015 schema_version: 2,
2016 input: None,
2017 outputs: vec![lo],
2018 nms: None,
2019 decoder_version: None,
2020 };
2021 schema.validate().expect(
2022 "typed + typeless children with equal shape must not trigger \
2023 uniqueness error",
2024 );
2025
2026 let s = serde_json::to_string(&schema).unwrap();
2028 assert!(
2029 s.contains("\"aux_user_managed\""),
2030 "typeless child must survive round-trip: {s}"
2031 );
2032 let aux_obj = s
2034 .split("\"aux_user_managed\"")
2035 .nth(1)
2036 .and_then(|s| s.split('}').next())
2037 .unwrap_or("");
2038 assert!(
2039 !aux_obj.contains("\"type\""),
2040 "typeless child must not serialize `type`, got: {aux_obj}"
2041 );
2042 }
2043
2044 #[test]
2045 fn from_v1_modelpack_anchor_detection_maps_encoding() {
2046 let v1 = ConfigOutputs {
2047 outputs: vec![ConfigOutput::Detection(crate::configs::Detection {
2048 anchors: Some(vec![[0.1, 0.2], [0.3, 0.4]]),
2049 decoder: crate::configs::DecoderType::ModelPack,
2050 quantization: Some(crate::configs::QuantTuple(0.176, 198)),
2051 shape: vec![1, 40, 40, 54],
2052 dshape: vec![],
2053 normalized: None,
2054 })],
2055 nms: None,
2056 decoder_version: None,
2057 };
2058 let v2 = SchemaV2::from_v1(&v1).unwrap();
2059 assert_eq!(v2.outputs[0].encoding, Some(BoxEncoding::Anchor));
2060 assert_eq!(v2.outputs[0].decoder, Some(DecoderKind::ModelPack));
2061 assert_eq!(v2.outputs[0].anchors.as_ref().map(|a| a.len()), Some(2));
2062 }
2063
2064 #[test]
2067 fn validate_accepts_flat_v2_yolov8_detection() {
2068 let j = r#"{
2069 "schema_version": 2,
2070 "outputs": [
2071 {"name":"boxes","type":"boxes","shape":[1,64,8400],
2072 "dtype":"int8","decoder":"ultralytics","encoding":"dfl"},
2073 {"name":"scores","type":"scores","shape":[1,80,8400],
2074 "dtype":"int8","decoder":"ultralytics","score_format":"per_class"}
2075 ]
2076 }"#;
2077 SchemaV2::parse_json(j).unwrap().validate().unwrap();
2078 }
2079
2080 #[test]
2081 fn validate_rejects_unnamed_physical_child() {
2082 let j = r#"{
2083 "schema_version": 2,
2084 "outputs": [{
2085 "name":"boxes","type":"boxes","shape":[1,64,8400],
2086 "encoding":"dfl","decoder":"ultralytics",
2087 "outputs": [{
2088 "name":"","type":"boxes","stride":8,
2089 "shape":[1,80,80,64],"dtype":"uint8"
2090 }]
2091 }]
2092 }"#;
2093 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2094 let msg = format!("{err}");
2095 assert!(msg.contains("missing `name`"), "got: {msg}");
2096 }
2097
2098 #[test]
2099 fn validate_rejects_duplicate_physical_shapes() {
2100 let j = r#"{
2101 "schema_version": 2,
2102 "outputs": [{
2103 "name":"boxes","type":"boxes","shape":[1,64,8400],
2104 "encoding":"dfl","decoder":"ultralytics",
2105 "outputs": [
2106 {"name":"a","type":"boxes","stride":8,"shape":[1,80,80,64],"dtype":"uint8"},
2107 {"name":"b","type":"boxes","stride":16,"shape":[1,80,80,64],"dtype":"uint8"}
2108 ]
2109 }]
2110 }"#;
2111 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2112 let msg = format!("{err}");
2113 assert!(msg.contains("share shape"), "got: {msg}");
2114 }
2115
2116 #[test]
2117 fn validate_rejects_mixed_decomposition() {
2118 let j = r#"{
2120 "schema_version": 2,
2121 "outputs": [{
2122 "name":"boxes","type":"boxes","shape":[1,4,8400,1],
2123 "encoding":"direct","decoder":"ultralytics",
2124 "outputs": [
2125 {"name":"xy","type":"boxes_xy","shape":[1,2,8400,1],"dtype":"int16"},
2126 {"name":"p0","type":"boxes","stride":8,"shape":[1,80,80,64],"dtype":"uint8"}
2127 ]
2128 }]
2129 }"#;
2130 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2131 let msg = format!("{err}");
2132 assert!(msg.contains("uniform"), "got: {msg}");
2133 }
2134
2135 #[test]
2136 fn validate_rejects_dfl_boxes_feature_not_divisible_by_4() {
2137 let j = r#"{
2138 "schema_version": 2,
2139 "outputs": [{
2140 "name":"boxes","type":"boxes","shape":[1,63,8400],
2141 "encoding":"dfl","decoder":"ultralytics",
2142 "outputs": [{
2143 "name":"b0","type":"boxes","stride":8,
2144 "shape":[1,80,80,63],
2145 "dshape":[{"batch":1},{"height":80},{"width":80},{"num_features":63}],
2146 "dtype":"uint8"
2147 }]
2148 }]
2149 }"#;
2150 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2151 let msg = format!("{err}");
2152 assert!(msg.contains("not"), "got: {msg}");
2153 assert!(msg.contains("divisible by 4"), "got: {msg}");
2154 }
2155
2156 #[test]
2157 fn validate_accepts_hailo_per_scale_yolov8() {
2158 let j = r#"{
2159 "schema_version": 2,
2160 "outputs": [{
2161 "name":"boxes","type":"boxes","shape":[1,64,8400],
2162 "encoding":"dfl","decoder":"ultralytics","normalized":true,
2163 "outputs": [
2164 {"name":"b0","type":"boxes","stride":8,
2165 "shape":[1,80,80,64],
2166 "dshape":[{"batch":1},{"height":80},{"width":80},{"num_features":64}],
2167 "dtype":"uint8",
2168 "quantization":{"scale":0.0234,"zero_point":128,"dtype":"uint8"}},
2169 {"name":"b1","type":"boxes","stride":16,
2170 "shape":[1,40,40,64],
2171 "dshape":[{"batch":1},{"height":40},{"width":40},{"num_features":64}],
2172 "dtype":"uint8",
2173 "quantization":{"scale":0.0198,"zero_point":130,"dtype":"uint8"}},
2174 {"name":"b2","type":"boxes","stride":32,
2175 "shape":[1,20,20,64],
2176 "dshape":[{"batch":1},{"height":20},{"width":20},{"num_features":64}],
2177 "dtype":"uint8",
2178 "quantization":{"scale":0.0312,"zero_point":125,"dtype":"uint8"}}
2179 ]
2180 }]
2181 }"#;
2182 let s = SchemaV2::parse_json(j).unwrap();
2183 s.validate().unwrap();
2184 }
2185
2186 #[test]
2187 fn validate_accepts_ara2_xy_wh() {
2188 let j = r#"{
2189 "schema_version": 2,
2190 "outputs": [{
2191 "name":"boxes","type":"boxes","shape":[1,4,8400,1],
2192 "encoding":"direct","decoder":"ultralytics","normalized":true,
2193 "outputs": [
2194 {"name":"xy","type":"boxes_xy","shape":[1,2,8400,1],
2195 "dshape":[{"batch":1},{"box_coords":2},{"num_boxes":8400},{"padding":1}],
2196 "dtype":"int16",
2197 "quantization":{"scale":3.1e-5,"zero_point":0,"dtype":"int16"}},
2198 {"name":"wh","type":"boxes_wh","shape":[1,2,8400,1],
2199 "dshape":[{"batch":1},{"box_coords":2},{"num_boxes":8400},{"padding":1}],
2200 "dtype":"int16",
2201 "quantization":{"scale":3.2e-5,"zero_point":0,"dtype":"int16"}}
2202 ]
2203 }]
2204 }"#;
2205 SchemaV2::parse_json(j).unwrap().validate().unwrap();
2206 }
2207
2208 #[test]
2209 fn parse_file_auto_detects_json() {
2210 let tmp = std::env::temp_dir().join(format!("schema_v2_test_{}.json", std::process::id()));
2211 std::fs::write(&tmp, r#"{"schema_version":2,"outputs":[]}"#).unwrap();
2212 let s = SchemaV2::parse_file(&tmp).unwrap();
2213 assert_eq!(s.schema_version, 2);
2214 let _ = std::fs::remove_file(&tmp);
2215 }
2216
2217 #[test]
2218 fn parse_file_auto_detects_yaml() {
2219 let tmp = std::env::temp_dir().join(format!("schema_v2_test_{}.yaml", std::process::id()));
2220 std::fs::write(&tmp, "schema_version: 2\noutputs: []\n").unwrap();
2221 let s = SchemaV2::parse_file(&tmp).unwrap();
2222 assert_eq!(s.schema_version, 2);
2223 let _ = std::fs::remove_file(&tmp);
2224 }
2225
2226 #[test]
2229 fn parse_real_ara2_int8_dvm_metadata() {
2230 let json = include_str!(concat!(
2231 env!("CARGO_MANIFEST_DIR"),
2232 "/../../testdata/ara2_int8_edgefirst.json"
2233 ));
2234 let schema = SchemaV2::parse_json(json).expect("ARA-2 int8 parse");
2235 assert_eq!(schema.schema_version, 2);
2236 assert_eq!(schema.decoder_version, Some(DecoderVersion::Yolov8));
2237 assert_eq!(schema.nms, Some(NmsMode::ClassAgnostic));
2238 assert_eq!(schema.input.as_ref().unwrap().shape, vec![1, 3, 640, 640]);
2239
2240 assert_eq!(schema.outputs.len(), 4);
2242 let boxes = &schema.outputs[0];
2243 assert_eq!(boxes.type_, Some(LogicalType::Boxes));
2244 assert_eq!(boxes.encoding, Some(BoxEncoding::Direct));
2245 assert_eq!(boxes.normalized, Some(true));
2246 assert_eq!(boxes.shape, vec![1, 4, 8400, 1]); assert_eq!(boxes.outputs.len(), 2);
2248 assert_eq!(boxes.outputs[0].type_, Some(PhysicalType::BoxesXy));
2249 assert_eq!(boxes.outputs[1].type_, Some(PhysicalType::BoxesWh));
2250 let q_xy = boxes.outputs[0].quantization.as_ref().unwrap();
2252 assert_eq!(q_xy.dtype, Some(DType::Int8));
2253 assert!((q_xy.scale[0] - 0.004_177_792).abs() < 1e-6);
2254 assert_eq!(q_xy.zero_point_at(0), -122);
2255
2256 let scores = &schema.outputs[1];
2257 assert_eq!(scores.type_, Some(LogicalType::Scores));
2258 assert_eq!(scores.score_format, Some(ScoreFormat::PerClass));
2259 assert_eq!(scores.shape, vec![1, 80, 8400, 1]);
2260
2261 let mask_coefs = &schema.outputs[2];
2262 assert_eq!(mask_coefs.type_, Some(LogicalType::MaskCoefs));
2263 assert_eq!(mask_coefs.shape, vec![1, 32, 8400, 1]);
2264
2265 let protos = &schema.outputs[3];
2266 assert_eq!(protos.type_, Some(LogicalType::Protos));
2267 assert_eq!(protos.shape, vec![1, 32, 160, 160]);
2268
2269 schema.validate().expect("ARA-2 int8 validate");
2271 }
2272
2273 #[test]
2274 fn parse_real_ara2_int16_dvm_metadata() {
2275 let json = include_str!(concat!(
2276 env!("CARGO_MANIFEST_DIR"),
2277 "/../../testdata/ara2_int16_edgefirst.json"
2278 ));
2279 let schema = SchemaV2::parse_json(json).expect("ARA-2 int16 parse");
2280 assert_eq!(schema.schema_version, 2);
2281 assert_eq!(schema.outputs.len(), 4);
2282 let boxes = &schema.outputs[0];
2283 assert_eq!(boxes.outputs.len(), 2);
2284 let q_xy = boxes.outputs[0].quantization.as_ref().unwrap();
2285 assert_eq!(q_xy.dtype, Some(DType::Int16));
2286 assert!((q_xy.scale[0] - 3.211_570_6e-5).abs() < 1e-10);
2287 assert_eq!(q_xy.zero_point_at(0), 0);
2288 let mc_q = schema.outputs[2].quantization.as_ref().unwrap();
2290 assert_eq!(mc_q.dtype, Some(DType::Int16));
2291 schema.validate().expect("ARA-2 int16 validate");
2292 }
2293
2294 #[test]
2295 fn parse_yaml_with_explicit_schema_version_2() {
2296 let yaml = r#"
2297schema_version: 2
2298outputs:
2299 - name: scores
2300 type: scores
2301 shape: [1, 80, 8400]
2302 dtype: int8
2303 quantization:
2304 scale: 0.00392
2305 dtype: int8
2306 decoder: ultralytics
2307 score_format: per_class
2308"#;
2309 let schema = SchemaV2::parse_yaml(yaml).unwrap();
2310 assert_eq!(schema.schema_version, 2);
2311 assert_eq!(schema.outputs[0].score_format, Some(ScoreFormat::PerClass));
2312 }
2313
2314 #[test]
2317 fn squeeze_padding_dims_preserves_shape_when_dshape_absent() {
2318 let (shape, dshape) = squeeze_padding_dims(vec![1, 4, 8400], vec![]);
2323 assert_eq!(shape, vec![1, 4, 8400]);
2324 assert!(dshape.is_empty());
2325 }
2326
2327 #[test]
2328 fn to_legacy_preserves_shape_for_v2_split_boxes_without_dshape() {
2329 let j = r#"{
2333 "schema_version": 2,
2334 "outputs": [
2335 {"name":"boxes","type":"boxes","shape":[1,4,8400],
2336 "dtype":"float32","decoder":"ultralytics","encoding":"direct"},
2337 {"name":"scores","type":"scores","shape":[1,80,8400],
2338 "dtype":"float32","decoder":"ultralytics","score_format":"per_class"}
2339 ]
2340 }"#;
2341 let schema = SchemaV2::parse_json(j).unwrap();
2342 let legacy = schema.to_legacy_config_outputs().expect("lowers cleanly");
2343 let boxes = match &legacy.outputs[0] {
2344 crate::ConfigOutput::Boxes(b) => b,
2345 other => panic!("expected Boxes, got {other:?}"),
2346 };
2347 assert_eq!(boxes.shape, vec![1, 4, 8400]);
2348 let scores = match &legacy.outputs[1] {
2349 crate::ConfigOutput::Scores(s) => s,
2350 other => panic!("expected Scores, got {other:?}"),
2351 };
2352 assert_eq!(scores.shape, vec![1, 80, 8400]);
2353 }
2354}