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 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub activation_applied: Option<Activation>,
214
215 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub activation_required: Option<Activation>,
225}
226
227impl LogicalOutput {
228 pub fn is_split(&self) -> bool {
231 !self.outputs.is_empty()
232 }
233}
234
235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
241pub struct PhysicalOutput {
242 pub name: String,
246
247 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
254 pub type_: Option<PhysicalType>,
255
256 pub shape: Vec<usize>,
258
259 #[serde(
262 default,
263 deserialize_with = "deserialize_dshape",
264 skip_serializing_if = "Vec::is_empty"
265 )]
266 pub dshape: Vec<(DimName, usize)>,
267
268 pub dtype: DType,
270
271 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub quantization: Option<Quantization>,
275
276 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub stride: Option<Stride>,
280
281 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub scale_index: Option<usize>,
285
286 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub activation_applied: Option<Activation>,
291
292 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub activation_required: Option<Activation>,
296}
297
298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
304pub struct Quantization {
305 #[serde(deserialize_with = "deserialize_scalar_or_vec_f32")]
308 pub scale: Vec<f32>,
309
310 #[serde(
314 default,
315 deserialize_with = "deserialize_opt_scalar_or_vec_i32",
316 skip_serializing_if = "Option::is_none"
317 )]
318 pub zero_point: Option<Vec<i32>>,
319
320 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub axis: Option<usize>,
324
325 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub dtype: Option<DType>,
330}
331
332impl Quantization {
333 pub fn is_per_tensor(&self) -> bool {
335 self.scale.len() == 1
336 }
337
338 pub fn is_per_channel(&self) -> bool {
340 self.scale.len() > 1
341 }
342
343 pub fn is_symmetric(&self) -> bool {
345 match &self.zero_point {
346 None => true,
347 Some(zps) => zps.iter().all(|&z| z == 0),
348 }
349 }
350
351 pub fn zero_point_at(&self, channel: usize) -> i32 {
354 match &self.zero_point {
355 None => 0,
356 Some(zps) if zps.len() == 1 => zps[0],
357 Some(zps) => zps.get(channel).copied().unwrap_or(0),
358 }
359 }
360
361 pub fn scale_at(&self, channel: usize) -> f32 {
363 if self.scale.len() == 1 {
364 self.scale[0]
365 } else {
366 self.scale.get(channel).copied().unwrap_or(0.0)
367 }
368 }
369}
370
371impl TryFrom<&Quantization> for edgefirst_tensor::Quantization {
381 type Error = edgefirst_tensor::Error;
382
383 fn try_from(q: &Quantization) -> Result<Self, Self::Error> {
384 match (q.scale.as_slice(), q.zero_point.as_deref(), q.axis) {
385 ([scale], None, None) => Ok(Self::per_tensor_symmetric(*scale)),
387 ([scale], Some([zp]), None) => Ok(Self::per_tensor(*scale, *zp)),
389 ([scale], Some([zp]), Some(_)) => Ok(Self::per_tensor(*scale, *zp)),
391 ([scale], None, Some(_)) => Ok(Self::per_tensor_symmetric(*scale)),
392 (scales, None, Some(axis)) if scales.len() > 1 => {
394 Self::per_channel_symmetric(scales.to_vec(), axis)
395 }
396 (scales, Some(zps), Some(axis)) if scales.len() > 1 => {
397 Self::per_channel(scales.to_vec(), zps.to_vec(), axis)
398 }
399 (scales, _, None) if scales.len() > 1 => {
401 Err(edgefirst_tensor::Error::QuantizationInvalid {
402 field: "axis",
403 expected: "Some(axis) for per-channel".into(),
404 got: "None".into(),
405 })
406 }
407 _ => Err(edgefirst_tensor::Error::QuantizationInvalid {
408 field: "scale",
409 expected: "non-empty".into(),
410 got: format!("len={}", q.scale.len()),
411 }),
412 }
413 }
414}
415
416fn deserialize_scalar_or_vec_f32<'de, D>(de: D) -> Result<Vec<f32>, D::Error>
418where
419 D: serde::Deserializer<'de>,
420{
421 #[derive(Deserialize)]
422 #[serde(untagged)]
423 enum OneOrMany {
424 One(f32),
425 Many(Vec<f32>),
426 }
427 match OneOrMany::deserialize(de)? {
428 OneOrMany::One(v) => Ok(vec![v]),
429 OneOrMany::Many(vs) => Ok(vs),
430 }
431}
432
433fn deserialize_opt_scalar_or_vec_i32<'de, D>(de: D) -> Result<Option<Vec<i32>>, D::Error>
435where
436 D: serde::Deserializer<'de>,
437{
438 #[derive(Deserialize)]
439 #[serde(untagged)]
440 enum OneOrMany {
441 One(i32),
442 Many(Vec<i32>),
443 }
444 match Option::<OneOrMany>::deserialize(de)? {
445 None => Ok(None),
446 Some(OneOrMany::One(v)) => Ok(Some(vec![v])),
447 Some(OneOrMany::Many(vs)) => Ok(Some(vs)),
448 }
449}
450
451#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
454#[serde(untagged)]
455pub enum Stride {
456 Square(u32),
457 Rect([u32; 2]),
458}
459
460impl Stride {
461 pub fn x(self) -> u32 {
463 match self {
464 Stride::Square(s) => s,
465 Stride::Rect([sx, _]) => sx,
466 }
467 }
468
469 pub fn y(self) -> u32 {
471 match self {
472 Stride::Square(s) => s,
473 Stride::Rect([_, sy]) => sy,
474 }
475 }
476}
477
478#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
480#[serde(rename_all = "snake_case")]
481pub enum LogicalType {
482 Boxes,
484 Scores,
486 Objectness,
488 Classes,
490 MaskCoefs,
492 Protos,
494 Landmarks,
496 Detections,
498 Segmentation,
500 Masks,
502 Detection,
504}
505
506#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
512#[serde(rename_all = "snake_case")]
513pub enum PhysicalType {
514 Boxes,
515 Scores,
516 Objectness,
517 Classes,
518 MaskCoefs,
519 Protos,
520 Landmarks,
521 Detections,
522 Segmentation,
523 Masks,
524 Detection,
525 BoxesXy,
527 BoxesWh,
529}
530
531#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
533#[serde(rename_all = "snake_case")]
534pub enum BoxEncoding {
535 Dfl,
538 #[serde(alias = "ltrb")]
541 Direct,
542 Anchor,
545}
546
547#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
549#[serde(rename_all = "snake_case")]
550pub enum ScoreFormat {
551 PerClass,
554 ObjXClass,
558}
559
560#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
562#[serde(rename_all = "snake_case")]
563pub enum Activation {
564 Sigmoid,
565 Softmax,
566 Tanh,
567}
568
569#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
571pub enum DecoderKind {
572 #[serde(rename = "modelpack")]
574 ModelPack,
575 #[serde(rename = "ultralytics")]
577 Ultralytics,
578}
579
580#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
582#[serde(rename_all = "snake_case")]
583pub enum DecoderVersion {
584 Yolov5,
585 Yolov8,
586 Yolo11,
587 Yolo26,
588}
589
590impl DecoderVersion {
591 pub fn is_end_to_end(self) -> bool {
593 matches!(self, DecoderVersion::Yolo26)
594 }
595}
596
597#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
599#[serde(rename_all = "snake_case")]
600pub enum NmsMode {
601 ClassAgnostic,
603 ClassAware,
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
610#[serde(rename_all = "snake_case")]
611pub enum DType {
612 Int8,
613 Uint8,
614 Int16,
615 Uint16,
616 Int32,
617 Uint32,
618 Float16,
619 Float32,
620}
621
622impl DType {
623 pub fn size_bytes(self) -> usize {
625 match self {
626 DType::Int8 | DType::Uint8 => 1,
627 DType::Int16 | DType::Uint16 | DType::Float16 => 2,
628 DType::Int32 | DType::Uint32 | DType::Float32 => 4,
629 }
630 }
631
632 pub fn is_integer(self) -> bool {
634 matches!(
635 self,
636 DType::Int8
637 | DType::Uint8
638 | DType::Int16
639 | DType::Uint16
640 | DType::Int32
641 | DType::Uint32
642 )
643 }
644
645 pub fn is_float(self) -> bool {
647 matches!(self, DType::Float16 | DType::Float32)
648 }
649}
650
651impl SchemaV2 {
656 pub fn parse_json(s: &str) -> DecoderResult<Self> {
664 let value: serde_json::Value = serde_json::from_str(s)?;
665 Self::from_json_value(value)
666 }
667
668 pub fn parse_yaml(s: &str) -> DecoderResult<Self> {
672 let value: serde_yaml::Value = serde_yaml::from_str(s)?;
673 let json = serde_json::to_value(value)
674 .map_err(|e| DecoderError::InvalidConfig(format!("yaml→json bridge failed: {e}")))?;
675 Self::from_json_value(json)
676 }
677
678 pub fn parse_file(path: impl AsRef<std::path::Path>) -> DecoderResult<Self> {
682 let path = path.as_ref();
683 let content = std::fs::read_to_string(path)
684 .map_err(|e| DecoderError::InvalidConfig(format!("read {}: {e}", path.display())))?;
685 let ext = path
686 .extension()
687 .and_then(|e| e.to_str())
688 .map(str::to_ascii_lowercase);
689 match ext.as_deref() {
690 Some("json") => Self::parse_json(&content),
691 Some("yaml") | Some("yml") => Self::parse_yaml(&content),
692 _ => Self::parse_json(&content).or_else(|_| Self::parse_yaml(&content)),
693 }
694 }
695
696 pub fn from_json_value(value: serde_json::Value) -> DecoderResult<Self> {
699 let version = value
700 .get("schema_version")
701 .and_then(|v| v.as_u64())
702 .map(|v| v as u32)
703 .unwrap_or(1);
704
705 if version > MAX_SUPPORTED_SCHEMA_VERSION {
706 return Err(DecoderError::NotSupported(format!(
707 "schema_version {version} is not supported by this HAL \
708 (maximum supported version is {MAX_SUPPORTED_SCHEMA_VERSION}); \
709 upgrade the HAL or downgrade the metadata"
710 )));
711 }
712
713 if version >= 2 {
714 serde_json::from_value(value).map_err(DecoderError::Json)
715 } else {
716 let v1: ConfigOutputs = serde_json::from_value(value).map_err(DecoderError::Json)?;
717 Self::from_v1(&v1)
718 }
719 }
720
721 pub fn from_v1(v1: &ConfigOutputs) -> DecoderResult<Self> {
737 let outputs = v1
738 .outputs
739 .iter()
740 .map(logical_from_v1)
741 .collect::<DecoderResult<Vec<_>>>()?;
742 Ok(SchemaV2 {
743 schema_version: 2,
744 input: None,
745 outputs,
746 nms: v1.nms.as_ref().map(NmsMode::from_v1),
747 decoder_version: v1.decoder_version.as_ref().map(DecoderVersion::from_v1),
748 })
749 }
750}
751
752impl SchemaV2 {
753 pub fn to_legacy_config_outputs(&self) -> DecoderResult<ConfigOutputs> {
780 let mut outputs = Vec::with_capacity(self.outputs.len());
781 for logical in &self.outputs {
782 if logical.type_.is_none() {
788 continue;
789 }
790 if logical.type_ == Some(LogicalType::Boxes)
797 && logical.encoding == Some(BoxEncoding::Dfl)
798 && logical.outputs.is_empty()
799 {
800 return Err(DecoderError::NotSupported(format!(
801 "`boxes` output `{}` has `encoding: dfl` on a flat \
802 logical (no per-scale children); the HAL's DFL \
803 decode kernel only runs inside the per-scale merge \
804 path. Split the boxes output into per-FPN-level \
805 children (Hailo convention) or pre-decode to 4 \
806 channels in the model graph (TFLite convention).",
807 logical.name.as_deref().unwrap_or("<anonymous>"),
808 )));
809 }
810 if let Some(q) = &logical.quantization {
811 if q.is_per_channel() {
812 return Err(DecoderError::NotSupported(format!(
813 "logical `{}` uses per-channel quantization \
814 (axis {:?}, {} scales); the v1 decoder only \
815 supports per-tensor quantization",
816 logical.name.as_deref().unwrap_or("<anonymous>"),
817 q.axis,
818 q.scale.len(),
819 )));
820 }
821 }
822 outputs.push(logical_to_legacy_config_output(logical)?);
823 }
824 Ok(ConfigOutputs {
825 outputs,
826 nms: self.nms.map(NmsMode::to_v1),
827 decoder_version: self.decoder_version.map(|v| v.to_v1()),
828 })
829 }
830
831 pub fn validate(&self) -> DecoderResult<()> {
849 if self.schema_version == 0 || self.schema_version > MAX_SUPPORTED_SCHEMA_VERSION {
850 return Err(DecoderError::InvalidConfig(format!(
851 "schema_version {} outside supported range [1, {MAX_SUPPORTED_SCHEMA_VERSION}]",
852 self.schema_version
853 )));
854 }
855
856 for logical in &self.outputs {
857 validate_logical(logical)?;
858 }
859
860 Ok(())
861 }
862}
863
864fn validate_logical(logical: &LogicalOutput) -> DecoderResult<()> {
865 if logical.outputs.is_empty() {
866 return Ok(());
867 }
868
869 for child in &logical.outputs {
871 if child.name.is_empty() {
872 return Err(DecoderError::InvalidConfig(format!(
873 "physical child of logical `{}` is missing `name`; name is \
874 required for tensor binding",
875 logical.name.as_deref().unwrap_or("<anonymous>")
876 )));
877 }
878 }
879
880 for (i, a) in logical.outputs.iter().enumerate() {
889 for b in &logical.outputs[i + 1..] {
890 let (Some(ta), Some(tb)) = (a.type_, b.type_) else {
891 continue;
892 };
893 if a.shape == b.shape && ta == tb {
894 return Err(DecoderError::InvalidConfig(format!(
895 "physical children `{}` and `{}` share shape {:?} and \
896 type; tensor binding cannot be resolved",
897 a.name, b.name, a.shape
898 )));
899 }
900 }
901 }
902
903 let strided: Vec<_> = logical.outputs.iter().map(|c| c.stride.is_some()).collect();
907 let all_strided = strided.iter().all(|&b| b);
908 let none_strided = strided.iter().all(|&b| !b);
909 if !(all_strided || none_strided) {
910 return Err(DecoderError::InvalidConfig(format!(
911 "logical `{}` mixes per-scale children (with stride) and \
912 channel sub-split children (without stride); decomposition \
913 must be uniform",
914 logical.name.as_deref().unwrap_or("<anonymous>")
915 )));
916 }
917
918 if logical.type_ == Some(LogicalType::Boxes) && logical.encoding == Some(BoxEncoding::Dfl) {
921 for child in &logical.outputs {
922 if let Some(feat) = last_feature_axis(child) {
923 if feat % 4 != 0 {
924 return Err(DecoderError::InvalidConfig(format!(
925 "DFL boxes child `{}` feature axis {feat} is not \
926 divisible by 4 (reg_max×4)",
927 child.name
928 )));
929 }
930 }
931 }
932 }
933
934 Ok(())
935}
936
937pub(crate) fn last_feature_axis(child: &PhysicalOutput) -> Option<usize> {
940 for (name, size) in &child.dshape {
943 if matches!(
944 name,
945 DimName::NumFeatures
946 | DimName::NumClasses
947 | DimName::NumProtos
948 | DimName::BoxCoords
949 | DimName::NumAnchorsXFeatures
950 ) {
951 return Some(*size);
952 }
953 }
954 child.shape.last().copied()
955}
956
957fn quantization_from_v1(q: Option<QuantTuple>) -> Option<Quantization> {
958 q.map(|QuantTuple(scale, zp)| Quantization {
959 scale: vec![scale],
960 zero_point: Some(vec![zp]),
961 axis: None,
962 dtype: None,
963 })
964}
965
966fn logical_from_v1(v1: &ConfigOutput) -> DecoderResult<LogicalOutput> {
967 match v1 {
968 ConfigOutput::Detection(d) => {
969 let encoding = match (d.decoder, d.anchors.is_some()) {
975 (configs::DecoderType::ModelPack, true) => Some(BoxEncoding::Anchor),
976 (configs::DecoderType::Ultralytics, _) => Some(BoxEncoding::Direct),
977 (configs::DecoderType::ModelPack, false) => None,
980 };
981 Ok(LogicalOutput {
982 name: None,
983 type_: Some(LogicalType::Detection),
984 shape: d.shape.clone(),
985 dshape: d.dshape.clone(),
986 decoder: Some(DecoderKind::from_v1(d.decoder)),
987 encoding,
988 score_format: None,
989 normalized: d.normalized,
990 anchors: d.anchors.clone(),
991 stride: None,
992 dtype: None,
993 quantization: quantization_from_v1(d.quantization),
994 outputs: Vec::new(),
995 activation_applied: None,
996 activation_required: None,
997 })
998 }
999 ConfigOutput::Boxes(b) => Ok(LogicalOutput {
1000 name: None,
1001 type_: Some(LogicalType::Boxes),
1002 shape: b.shape.clone(),
1003 dshape: b.dshape.clone(),
1004 decoder: Some(DecoderKind::from_v1(b.decoder)),
1005 encoding: Some(BoxEncoding::Direct),
1009 score_format: None,
1010 normalized: b.normalized,
1011 anchors: None,
1012 stride: None,
1013 dtype: None,
1014 quantization: quantization_from_v1(b.quantization),
1015 outputs: Vec::new(),
1016 activation_applied: None,
1017 activation_required: None,
1018 }),
1019 ConfigOutput::Scores(s) => Ok(LogicalOutput {
1020 name: None,
1021 type_: Some(LogicalType::Scores),
1022 shape: s.shape.clone(),
1023 dshape: s.dshape.clone(),
1024 decoder: Some(DecoderKind::from_v1(s.decoder)),
1025 encoding: None,
1026 score_format: Some(ScoreFormat::PerClass),
1030 normalized: None,
1031 anchors: None,
1032 stride: None,
1033 dtype: None,
1034 quantization: quantization_from_v1(s.quantization),
1035 outputs: Vec::new(),
1036 activation_applied: None,
1037 activation_required: None,
1038 }),
1039 ConfigOutput::Protos(p) => Ok(LogicalOutput {
1040 name: None,
1041 type_: Some(LogicalType::Protos),
1042 shape: p.shape.clone(),
1043 dshape: p.dshape.clone(),
1044 decoder: Some(DecoderKind::from_v1(p.decoder)),
1046 encoding: None,
1047 score_format: None,
1048 normalized: None,
1049 anchors: None,
1050 stride: None,
1051 dtype: None,
1052 quantization: quantization_from_v1(p.quantization),
1053 outputs: Vec::new(),
1054 activation_applied: None,
1055 activation_required: None,
1056 }),
1057 ConfigOutput::MaskCoefficients(m) => Ok(LogicalOutput {
1058 name: None,
1059 type_: Some(LogicalType::MaskCoefs),
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 activation_applied: None,
1072 activation_required: None,
1073 }),
1074 ConfigOutput::Segmentation(seg) => Ok(LogicalOutput {
1075 name: None,
1076 type_: Some(LogicalType::Segmentation),
1077 shape: seg.shape.clone(),
1078 dshape: seg.dshape.clone(),
1079 decoder: Some(DecoderKind::from_v1(seg.decoder)),
1080 encoding: None,
1081 score_format: None,
1082 normalized: None,
1083 anchors: None,
1084 stride: None,
1085 dtype: None,
1086 quantization: quantization_from_v1(seg.quantization),
1087 outputs: Vec::new(),
1088 activation_applied: None,
1089 activation_required: None,
1090 }),
1091 ConfigOutput::Mask(m) => Ok(LogicalOutput {
1092 name: None,
1093 type_: Some(LogicalType::Masks),
1094 shape: m.shape.clone(),
1095 dshape: m.dshape.clone(),
1096 decoder: Some(DecoderKind::from_v1(m.decoder)),
1097 encoding: None,
1098 score_format: None,
1099 normalized: None,
1100 anchors: None,
1101 stride: None,
1102 dtype: None,
1103 quantization: quantization_from_v1(m.quantization),
1104 outputs: Vec::new(),
1105 activation_applied: None,
1106 activation_required: None,
1107 }),
1108 ConfigOutput::Classes(c) => Ok(LogicalOutput {
1109 name: None,
1110 type_: Some(LogicalType::Classes),
1111 shape: c.shape.clone(),
1112 dshape: c.dshape.clone(),
1113 decoder: Some(DecoderKind::from_v1(c.decoder)),
1114 encoding: None,
1115 score_format: None,
1116 normalized: None,
1117 anchors: None,
1118 stride: None,
1119 dtype: None,
1120 quantization: quantization_from_v1(c.quantization),
1121 outputs: Vec::new(),
1122 activation_applied: None,
1123 activation_required: None,
1124 }),
1125 }
1126}
1127
1128impl DecoderKind {
1129 pub fn from_v1(v: configs::DecoderType) -> Self {
1131 match v {
1132 configs::DecoderType::ModelPack => DecoderKind::ModelPack,
1133 configs::DecoderType::Ultralytics => DecoderKind::Ultralytics,
1134 }
1135 }
1136
1137 pub fn to_v1(self) -> configs::DecoderType {
1139 match self {
1140 DecoderKind::ModelPack => configs::DecoderType::ModelPack,
1141 DecoderKind::Ultralytics => configs::DecoderType::Ultralytics,
1142 }
1143 }
1144}
1145
1146impl DecoderVersion {
1147 pub fn from_v1(v: &configs::DecoderVersion) -> Self {
1149 match v {
1150 configs::DecoderVersion::Yolov5 => DecoderVersion::Yolov5,
1151 configs::DecoderVersion::Yolov8 => DecoderVersion::Yolov8,
1152 configs::DecoderVersion::Yolo11 => DecoderVersion::Yolo11,
1153 configs::DecoderVersion::Yolo26 => DecoderVersion::Yolo26,
1154 }
1155 }
1156
1157 pub fn to_v1(self) -> configs::DecoderVersion {
1159 match self {
1160 DecoderVersion::Yolov5 => configs::DecoderVersion::Yolov5,
1161 DecoderVersion::Yolov8 => configs::DecoderVersion::Yolov8,
1162 DecoderVersion::Yolo11 => configs::DecoderVersion::Yolo11,
1163 DecoderVersion::Yolo26 => configs::DecoderVersion::Yolo26,
1164 }
1165 }
1166}
1167
1168impl NmsMode {
1169 pub fn from_v1(v: &configs::Nms) -> Self {
1171 match v {
1172 configs::Nms::Auto | configs::Nms::ClassAgnostic => NmsMode::ClassAgnostic,
1173 configs::Nms::ClassAware => NmsMode::ClassAware,
1174 }
1175 }
1176
1177 pub fn to_v1(self) -> configs::Nms {
1179 match self {
1180 NmsMode::ClassAgnostic => configs::Nms::ClassAgnostic,
1181 NmsMode::ClassAware => configs::Nms::ClassAware,
1182 }
1183 }
1184}
1185
1186fn quantization_to_legacy(q: &Quantization) -> DecoderResult<QuantTuple> {
1189 if q.is_per_channel() {
1190 return Err(DecoderError::NotSupported(
1191 "per-channel quantization cannot be expressed as a v1 QuantTuple".into(),
1192 ));
1193 }
1194 let scale = *q.scale.first().unwrap_or(&0.0);
1195 let zp = q.zero_point_at(0);
1196 Ok(QuantTuple(scale, zp))
1197}
1198
1199pub(crate) fn squeeze_padding_dims(
1205 shape: Vec<usize>,
1206 dshape: Vec<(DimName, usize)>,
1207) -> (Vec<usize>, Vec<(DimName, usize)>) {
1208 if dshape.is_empty() {
1212 return (shape, dshape);
1213 }
1214 let keep: Vec<bool> = dshape
1215 .iter()
1216 .map(|(n, _)| !matches!(n, DimName::Padding))
1217 .collect();
1218 let shape = shape
1219 .into_iter()
1220 .zip(keep.iter())
1221 .filter_map(|(s, &k)| k.then_some(s))
1222 .collect();
1223 let dshape = dshape
1224 .into_iter()
1225 .zip(keep.iter())
1226 .filter_map(|(d, &k)| k.then_some(d))
1227 .collect();
1228 (shape, dshape)
1229}
1230
1231pub(crate) fn padding_axes(dshape: &[(DimName, usize)]) -> Vec<usize> {
1236 let mut v: Vec<usize> = dshape
1237 .iter()
1238 .enumerate()
1239 .filter_map(|(i, (n, _))| matches!(n, DimName::Padding).then_some(i))
1240 .collect();
1241 v.sort_by(|a, b| b.cmp(a));
1242 v
1243}
1244
1245fn logical_to_legacy_config_output(logical: &LogicalOutput) -> DecoderResult<ConfigOutput> {
1246 let decoder = logical
1247 .decoder
1248 .map(|d| d.to_v1())
1249 .unwrap_or(configs::DecoderType::Ultralytics);
1250 let quantization = logical
1251 .quantization
1252 .as_ref()
1253 .map(quantization_to_legacy)
1254 .transpose()?;
1255 let (shape, dshape) = squeeze_padding_dims(logical.shape.clone(), logical.dshape.clone());
1260
1261 let ty = logical.type_.ok_or_else(|| {
1262 DecoderError::InvalidConfig(format!(
1266 "logical output `{}` has no type; typeless outputs should be \
1267 filtered before legacy conversion",
1268 logical.name.as_deref().unwrap_or("<anonymous>")
1269 ))
1270 })?;
1271
1272 Ok(match ty {
1273 LogicalType::Boxes => ConfigOutput::Boxes(configs::Boxes {
1274 decoder,
1275 quantization,
1276 shape,
1277 dshape,
1278 normalized: logical.normalized,
1279 }),
1280 LogicalType::Scores => ConfigOutput::Scores(configs::Scores {
1281 decoder,
1282 quantization,
1283 shape,
1284 dshape,
1285 }),
1286 LogicalType::Protos => ConfigOutput::Protos(configs::Protos {
1287 decoder,
1288 quantization,
1289 shape,
1290 dshape,
1291 }),
1292 LogicalType::MaskCoefs => ConfigOutput::MaskCoefficients(configs::MaskCoefficients {
1293 decoder,
1294 quantization,
1295 shape,
1296 dshape,
1297 }),
1298 LogicalType::Segmentation => ConfigOutput::Segmentation(configs::Segmentation {
1299 decoder,
1300 quantization,
1301 shape,
1302 dshape,
1303 }),
1304 LogicalType::Masks => ConfigOutput::Mask(configs::Mask {
1305 decoder,
1306 quantization,
1307 shape,
1308 dshape,
1309 }),
1310 LogicalType::Classes => ConfigOutput::Classes(configs::Classes {
1311 decoder,
1312 quantization,
1313 shape,
1314 dshape,
1315 }),
1316 LogicalType::Detection | LogicalType::Detections => {
1320 ConfigOutput::Detection(configs::Detection {
1321 anchors: logical.anchors.clone(),
1322 decoder,
1323 quantization,
1324 shape,
1325 dshape,
1326 normalized: logical.normalized,
1327 })
1328 }
1329 LogicalType::Objectness | LogicalType::Landmarks => {
1332 return Err(DecoderError::NotSupported(format!(
1333 "logical type {:?} has no legacy v1 equivalent; use the \
1334 native v2 decoder path",
1335 ty
1336 )));
1337 }
1338 })
1339}
1340
1341#[cfg(test)]
1342#[cfg_attr(coverage_nightly, coverage(off))]
1343mod tests {
1344 use super::*;
1345
1346 #[test]
1347 fn schema_default_is_v2() {
1348 let s = SchemaV2::default();
1349 assert_eq!(s.schema_version, 2);
1350 assert!(s.outputs.is_empty());
1351 }
1352
1353 #[test]
1354 fn fixtures_round_trip_through_serde() {
1355 let yolov8 = include_str!("../../../testdata/per_scale/synthetic_yolov8n_schema.json");
1356 let _: super::SchemaV2 = serde_json::from_str(yolov8).expect("yolov8n fixture must parse");
1357
1358 let yolo26 = include_str!("../../../testdata/per_scale/synthetic_yolo26n_schema.json");
1359 let _: super::SchemaV2 = serde_json::from_str(yolo26).expect("yolo26n fixture must parse");
1360
1361 let flat = include_str!("../../../testdata/per_scale/synthetic_flat_schema.json");
1362 let _: super::SchemaV2 = serde_json::from_str(flat).expect("flat fixture must parse");
1363 }
1364
1365 #[test]
1366 fn box_encoding_accepts_ltrb_alias_for_direct() {
1367 let dfl: BoxEncoding = serde_json::from_str("\"dfl\"").unwrap();
1368 assert_eq!(dfl, BoxEncoding::Dfl);
1369
1370 let direct: BoxEncoding = serde_json::from_str("\"direct\"").unwrap();
1371 assert_eq!(direct, BoxEncoding::Direct);
1372
1373 let ltrb: BoxEncoding = serde_json::from_str("\"ltrb\"").unwrap();
1375 assert_eq!(ltrb, BoxEncoding::Direct);
1376 }
1377
1378 #[test]
1379 fn dtype_roundtrip() {
1380 for d in [
1381 DType::Int8,
1382 DType::Uint8,
1383 DType::Int16,
1384 DType::Uint16,
1385 DType::Float16,
1386 DType::Float32,
1387 ] {
1388 let j = serde_json::to_string(&d).unwrap();
1389 let back: DType = serde_json::from_str(&j).unwrap();
1390 assert_eq!(back, d);
1391 }
1392 }
1393
1394 #[test]
1395 fn dtype_widths() {
1396 assert_eq!(DType::Int8.size_bytes(), 1);
1397 assert_eq!(DType::Float16.size_bytes(), 2);
1398 assert_eq!(DType::Float32.size_bytes(), 4);
1399 }
1400
1401 #[test]
1402 fn stride_accepts_scalar_or_pair() {
1403 let a: Stride = serde_json::from_str("8").unwrap();
1404 let b: Stride = serde_json::from_str("[8, 16]").unwrap();
1405 assert_eq!(a, Stride::Square(8));
1406 assert_eq!(b, Stride::Rect([8, 16]));
1407 assert_eq!(a.x(), 8);
1408 assert_eq!(a.y(), 8);
1409 assert_eq!(b.x(), 8);
1410 assert_eq!(b.y(), 16);
1411 }
1412
1413 #[test]
1414 fn quantization_scalar_scale() {
1415 let j = r#"{"scale": 0.00392, "zero_point": 0, "dtype": "int8"}"#;
1416 let q: Quantization = serde_json::from_str(j).unwrap();
1417 assert!(q.is_per_tensor());
1418 assert!(q.is_symmetric());
1419 assert_eq!(q.scale_at(0), 0.00392);
1420 assert_eq!(q.scale_at(5), 0.00392);
1421 assert_eq!(q.zero_point_at(0), 0);
1422 }
1423
1424 #[test]
1425 fn quantization_per_channel() {
1426 let j = r#"{"scale": [0.054, 0.089, 0.195], "axis": 0, "dtype": "int8"}"#;
1427 let q: Quantization = serde_json::from_str(j).unwrap();
1428 assert!(q.is_per_channel());
1429 assert!(q.is_symmetric());
1430 assert_eq!(q.axis, Some(0));
1431 assert_eq!(q.scale_at(0), 0.054);
1432 assert_eq!(q.scale_at(2), 0.195);
1433 }
1434
1435 #[test]
1436 fn quantization_asymmetric_per_tensor() {
1437 let j = r#"{"scale": 0.176, "zero_point": 198, "dtype": "uint8"}"#;
1438 let q: Quantization = serde_json::from_str(j).unwrap();
1439 assert!(!q.is_symmetric());
1440 assert_eq!(q.zero_point_at(0), 198);
1441 assert_eq!(q.zero_point_at(10), 198);
1442 }
1443
1444 #[test]
1445 fn quantization_symmetric_default_zero_point() {
1446 let j = r#"{"scale": 0.00392, "dtype": "int8"}"#;
1447 let q: Quantization = serde_json::from_str(j).unwrap();
1448 assert!(q.is_symmetric());
1449 assert_eq!(q.zero_point_at(0), 0);
1450 }
1451
1452 #[test]
1453 fn quantization_to_tensor_per_tensor_asymmetric() {
1454 let q = Quantization {
1455 scale: vec![0.1],
1456 zero_point: Some(vec![-5]),
1457 axis: None,
1458 dtype: Some(DType::Int8),
1459 };
1460 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1461 assert!(t.is_per_tensor());
1462 assert!(!t.is_symmetric());
1463 assert_eq!(t.scale(), &[0.1]);
1464 assert_eq!(t.zero_point(), Some(&[-5][..]));
1465 }
1466
1467 #[test]
1468 fn quantization_to_tensor_per_tensor_symmetric() {
1469 let q = Quantization {
1470 scale: vec![0.05],
1471 zero_point: None,
1472 axis: None,
1473 dtype: Some(DType::Int8),
1474 };
1475 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1476 assert!(t.is_per_tensor());
1477 assert!(t.is_symmetric());
1478 }
1479
1480 #[test]
1481 fn quantization_to_tensor_per_channel_asymmetric() {
1482 let q = Quantization {
1483 scale: vec![0.1, 0.2, 0.3],
1484 zero_point: Some(vec![-1, 0, 1]),
1485 axis: Some(2),
1486 dtype: Some(DType::Int8),
1487 };
1488 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1489 assert!(t.is_per_channel());
1490 assert_eq!(t.axis(), Some(2));
1491 assert_eq!(t.scale().len(), 3);
1492 assert_eq!(t.zero_point().map(|z| z.len()), Some(3));
1493 }
1494
1495 #[test]
1496 fn quantization_to_tensor_per_channel_symmetric() {
1497 let q = Quantization {
1498 scale: vec![0.054, 0.089, 0.195],
1499 zero_point: None,
1500 axis: Some(0),
1501 dtype: Some(DType::Int8),
1502 };
1503 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1504 assert!(t.is_per_channel());
1505 assert!(t.is_symmetric());
1506 assert_eq!(t.axis(), Some(0));
1507 }
1508
1509 #[test]
1510 fn quantization_to_tensor_per_channel_missing_axis_errors() {
1511 let q = Quantization {
1512 scale: vec![0.1, 0.2, 0.3],
1513 zero_point: None,
1514 axis: None,
1515 dtype: None,
1516 };
1517 let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
1518 assert!(matches!(
1519 err,
1520 edgefirst_tensor::Error::QuantizationInvalid { .. }
1521 ));
1522 }
1523
1524 #[test]
1525 fn logical_output_flat_tflite_boxes() {
1526 let j = r#"{
1528 "name": "boxes", "type": "boxes",
1529 "shape": [1, 64, 8400],
1530 "dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
1531 "dtype": "int8",
1532 "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1533 "decoder": "ultralytics",
1534 "encoding": "dfl",
1535 "normalized": true
1536 }"#;
1537 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1538 assert_eq!(lo.type_, Some(LogicalType::Boxes));
1539 assert_eq!(lo.encoding, Some(BoxEncoding::Dfl));
1540 assert_eq!(lo.normalized, Some(true));
1541 assert!(!lo.is_split());
1542 assert_eq!(lo.dtype, Some(DType::Int8));
1543 }
1544
1545 #[test]
1546 fn logical_output_hailo_per_scale_split() {
1547 let j = r#"{
1549 "name": "boxes", "type": "boxes",
1550 "shape": [1, 64, 8400],
1551 "encoding": "dfl", "decoder": "ultralytics", "normalized": true,
1552 "outputs": [
1553 {
1554 "name": "boxes_0", "type": "boxes",
1555 "stride": 8, "scale_index": 0,
1556 "shape": [1, 80, 80, 64],
1557 "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_features": 64}],
1558 "dtype": "uint8",
1559 "quantization": {"scale": 0.0234, "zero_point": 128, "dtype": "uint8"}
1560 }
1561 ]
1562 }"#;
1563 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1564 assert!(lo.is_split());
1565 assert_eq!(lo.outputs.len(), 1);
1566 let child = &lo.outputs[0];
1567 assert_eq!(child.name, "boxes_0");
1568 assert_eq!(child.type_, Some(PhysicalType::Boxes));
1569 assert_eq!(child.stride, Some(Stride::Square(8)));
1570 assert_eq!(child.scale_index, Some(0));
1571 assert_eq!(child.dtype, DType::Uint8);
1572 }
1573
1574 #[test]
1575 fn logical_output_ara2_xy_wh_channel_split() {
1576 let j = r#"{
1578 "name": "boxes", "type": "boxes",
1579 "shape": [1, 4, 8400, 1],
1580 "encoding": "direct", "decoder": "ultralytics", "normalized": true,
1581 "outputs": [
1582 {
1583 "name": "_model_22_Div_1_output_0", "type": "boxes_xy",
1584 "shape": [1, 2, 8400, 1],
1585 "dshape": [{"batch": 1}, {"box_coords": 2}, {"num_boxes": 8400}, {"padding": 1}],
1586 "dtype": "int16",
1587 "quantization": {"scale": 3.129e-5, "zero_point": 0, "dtype": "int16"}
1588 },
1589 {
1590 "name": "_model_22_Sub_1_output_0", "type": "boxes_wh",
1591 "shape": [1, 2, 8400, 1],
1592 "dshape": [{"batch": 1}, {"box_coords": 2}, {"num_boxes": 8400}, {"padding": 1}],
1593 "dtype": "int16",
1594 "quantization": {"scale": 3.149e-5, "zero_point": 0, "dtype": "int16"}
1595 }
1596 ]
1597 }"#;
1598 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1599 assert_eq!(lo.encoding, Some(BoxEncoding::Direct));
1600 assert_eq!(lo.outputs.len(), 2);
1601 assert_eq!(lo.outputs[0].type_, Some(PhysicalType::BoxesXy));
1602 assert_eq!(lo.outputs[1].type_, Some(PhysicalType::BoxesWh));
1603 assert!(lo.outputs[0].stride.is_none());
1604 assert!(lo.outputs[1].stride.is_none());
1605 }
1606
1607 #[test]
1608 fn logical_output_hailo_scores_sigmoid_applied() {
1609 let j = r#"{
1610 "name": "scores", "type": "scores",
1611 "shape": [1, 80, 8400],
1612 "decoder": "ultralytics", "score_format": "per_class",
1613 "outputs": [
1614 {
1615 "name": "scores_0", "type": "scores",
1616 "stride": 8, "scale_index": 0,
1617 "shape": [1, 80, 80, 80],
1618 "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_classes": 80}],
1619 "dtype": "uint8",
1620 "quantization": {"scale": 0.003922, "dtype": "uint8"},
1621 "activation_applied": "sigmoid"
1622 }
1623 ]
1624 }"#;
1625 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1626 assert_eq!(lo.score_format, Some(ScoreFormat::PerClass));
1627 let child = &lo.outputs[0];
1628 assert_eq!(child.activation_applied, Some(Activation::Sigmoid));
1629 assert!(child.activation_required.is_none());
1630 }
1631
1632 #[test]
1633 fn yolo26_end_to_end_detections() {
1634 let j = r#"{
1635 "schema_version": 2,
1636 "decoder_version": "yolo26",
1637 "outputs": [{
1638 "name": "output0", "type": "detections",
1639 "shape": [1, 100, 6],
1640 "dshape": [{"batch": 1}, {"num_boxes": 100}, {"num_features": 6}],
1641 "dtype": "int8",
1642 "quantization": {"scale": 0.0078, "zero_point": 0, "dtype": "int8"},
1643 "normalized": false,
1644 "decoder": "ultralytics"
1645 }]
1646 }"#;
1647 let s: SchemaV2 = serde_json::from_str(j).unwrap();
1648 assert_eq!(s.decoder_version, Some(DecoderVersion::Yolo26));
1649 assert!(s.decoder_version.unwrap().is_end_to_end());
1650 assert_eq!(s.outputs[0].type_, Some(LogicalType::Detections));
1651 assert_eq!(s.outputs[0].normalized, Some(false));
1652 assert!(s.nms.is_none());
1653 }
1654
1655 #[test]
1656 fn modelpack_anchor_detection_with_rect_stride() {
1657 let j = r#"{
1658 "schema_version": 2,
1659 "outputs": [{
1660 "name": "output_0", "type": "detection",
1661 "shape": [1, 40, 40, 54],
1662 "dshape": [{"batch": 1}, {"height": 40}, {"width": 40}, {"num_anchors_x_features": 54}],
1663 "dtype": "uint8",
1664 "quantization": {"scale": 0.176, "zero_point": 198, "dtype": "uint8"},
1665 "decoder": "modelpack",
1666 "encoding": "anchor",
1667 "stride": [16, 16],
1668 "anchors": [[0.054, 0.065], [0.089, 0.139], [0.195, 0.196]]
1669 }]
1670 }"#;
1671 let s: SchemaV2 = serde_json::from_str(j).unwrap();
1672 let lo = &s.outputs[0];
1673 assert_eq!(lo.encoding, Some(BoxEncoding::Anchor));
1674 assert_eq!(lo.stride, Some(Stride::Rect([16, 16])));
1675 assert_eq!(lo.anchors.as_ref().map(|a| a.len()), Some(3));
1676 }
1677
1678 #[test]
1679 fn yolov5_obj_x_class_objectness_logical() {
1680 let j = r#"{
1681 "name": "objectness", "type": "objectness",
1682 "shape": [1, 3, 8400],
1683 "decoder": "ultralytics",
1684 "outputs": [{
1685 "name": "objectness_0", "type": "objectness",
1686 "stride": 8, "scale_index": 0,
1687 "shape": [1, 80, 80, 3],
1688 "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_features": 3}],
1689 "dtype": "uint8",
1690 "quantization": {"scale": 0.0039, "zero_point": 0, "dtype": "uint8"},
1691 "activation_applied": "sigmoid"
1692 }]
1693 }"#;
1694 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1695 assert_eq!(lo.type_, Some(LogicalType::Objectness));
1696 assert_eq!(lo.outputs[0].activation_applied, Some(Activation::Sigmoid));
1697 }
1698
1699 #[test]
1700 fn direct_protos_no_decoder() {
1701 let j = r#"{
1703 "name": "protos", "type": "protos",
1704 "shape": [1, 32, 160, 160],
1705 "dshape": [{"batch": 1}, {"num_protos": 32}, {"height": 160}, {"width": 160}],
1706 "dtype": "uint8",
1707 "quantization": {"scale": 0.0203, "zero_point": 45, "dtype": "uint8"},
1708 "stride": 4
1709 }"#;
1710 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1711 assert_eq!(lo.type_, Some(LogicalType::Protos));
1712 assert!(lo.decoder.is_none());
1713 assert_eq!(lo.stride, Some(Stride::Square(4)));
1714 }
1715
1716 #[test]
1717 fn full_yolov8_tflite_flat_detection() {
1718 let j = r#"{
1720 "schema_version": 2,
1721 "decoder_version": "yolov8",
1722 "nms": "class_agnostic",
1723 "input": { "shape": [1, 640, 640, 3], "cameraadaptor": "rgb" },
1724 "outputs": [
1725 {
1726 "name": "boxes", "type": "boxes",
1727 "shape": [1, 64, 8400],
1728 "dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
1729 "dtype": "int8",
1730 "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1731 "decoder": "ultralytics",
1732 "encoding": "dfl",
1733 "normalized": true
1734 },
1735 {
1736 "name": "scores", "type": "scores",
1737 "shape": [1, 80, 8400],
1738 "dshape": [{"batch": 1}, {"num_classes": 80}, {"num_boxes": 8400}],
1739 "dtype": "int8",
1740 "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1741 "decoder": "ultralytics",
1742 "score_format": "per_class"
1743 }
1744 ]
1745 }"#;
1746 let s: SchemaV2 = serde_json::from_str(j).unwrap();
1747 assert_eq!(s.schema_version, 2);
1748 assert_eq!(s.decoder_version, Some(DecoderVersion::Yolov8));
1749 assert_eq!(s.nms, Some(NmsMode::ClassAgnostic));
1750 assert_eq!(s.input.as_ref().unwrap().shape, vec![1, 640, 640, 3]);
1751 assert_eq!(s.outputs.len(), 2);
1752 }
1753
1754 #[test]
1755 fn schema_unknown_version_parses_without_validation() {
1756 let j = r#"{"schema_version": 99, "outputs": []}"#;
1759 let s: SchemaV2 = serde_json::from_str(j).unwrap();
1760 assert_eq!(s.schema_version, 99);
1761 }
1762
1763 #[test]
1764 fn serde_roundtrip_preserves_fields() {
1765 let original = SchemaV2 {
1766 schema_version: 2,
1767 input: Some(InputSpec {
1768 shape: vec![1, 3, 640, 640],
1769 dshape: vec![],
1770 cameraadaptor: Some("rgb".into()),
1771 }),
1772 outputs: vec![LogicalOutput {
1773 name: Some("boxes".into()),
1774 type_: Some(LogicalType::Boxes),
1775 shape: vec![1, 4, 8400],
1776 dshape: vec![],
1777 decoder: Some(DecoderKind::Ultralytics),
1778 encoding: Some(BoxEncoding::Direct),
1779 score_format: None,
1780 normalized: Some(true),
1781 anchors: None,
1782 stride: None,
1783 dtype: Some(DType::Float32),
1784 quantization: None,
1785 outputs: vec![],
1786 activation_applied: None,
1787 activation_required: None,
1788 }],
1789 nms: Some(NmsMode::ClassAgnostic),
1790 decoder_version: Some(DecoderVersion::Yolov8),
1791 };
1792 let j = serde_json::to_string(&original).unwrap();
1793 let parsed: SchemaV2 = serde_json::from_str(&j).unwrap();
1794 assert_eq!(parsed, original);
1795 }
1796
1797 #[test]
1800 fn parse_v1_yaml_yolov8_seg_testdata() {
1801 let yaml = include_str!(concat!(
1802 env!("CARGO_MANIFEST_DIR"),
1803 "/../../testdata/yolov8_seg.yaml"
1804 ));
1805 let schema = SchemaV2::parse_yaml(yaml).expect("parse v1 yaml");
1806 assert_eq!(schema.schema_version, 2);
1807 assert_eq!(schema.outputs.len(), 2);
1808 let det = &schema.outputs[0];
1810 assert_eq!(det.type_, Some(LogicalType::Detection));
1811 assert_eq!(det.shape, vec![1, 116, 8400]);
1812 assert_eq!(det.decoder, Some(DecoderKind::Ultralytics));
1813 assert_eq!(det.encoding, Some(BoxEncoding::Direct));
1814 let q = det.quantization.as_ref().unwrap();
1815 assert_eq!(q.scale.len(), 1);
1816 assert!((q.scale[0] - 0.021_287_762).abs() < 1e-6);
1817 assert_eq!(q.zero_point, Some(vec![31]));
1818 let protos = &schema.outputs[1];
1820 assert_eq!(protos.type_, Some(LogicalType::Protos));
1821 assert_eq!(protos.shape, vec![1, 160, 160, 32]);
1822 }
1823
1824 #[test]
1825 fn parse_v1_json_modelpack_split_testdata() {
1826 let json = include_str!(concat!(
1827 env!("CARGO_MANIFEST_DIR"),
1828 "/../../testdata/modelpack_split.json"
1829 ));
1830 let schema = SchemaV2::parse_json(json).expect("parse v1 json");
1831 assert_eq!(schema.schema_version, 2);
1832 assert_eq!(schema.outputs.len(), 2);
1833 for out in &schema.outputs {
1835 assert_eq!(out.type_, Some(LogicalType::Detection));
1836 assert_eq!(out.decoder, Some(DecoderKind::ModelPack));
1837 assert_eq!(out.encoding, Some(BoxEncoding::Anchor));
1838 assert_eq!(out.anchors.as_ref().map(|a| a.len()), Some(3));
1839 }
1840 }
1841
1842 #[test]
1843 fn parse_v2_json_direct_when_schema_version_present() {
1844 let j = r#"{
1845 "schema_version": 2,
1846 "outputs": [{
1847 "name": "boxes", "type": "boxes",
1848 "shape": [1, 4, 8400],
1849 "dshape": [{"batch": 1}, {"box_coords": 4}, {"num_boxes": 8400}],
1850 "dtype": "float32",
1851 "decoder": "ultralytics",
1852 "encoding": "direct",
1853 "normalized": true
1854 }]
1855 }"#;
1856 let schema = SchemaV2::parse_json(j).unwrap();
1857 assert_eq!(schema.schema_version, 2);
1858 assert_eq!(schema.outputs[0].type_, Some(LogicalType::Boxes));
1859 }
1860
1861 #[test]
1862 fn parse_rejects_future_schema_version() {
1863 let j = r#"{"schema_version": 99, "outputs": []}"#;
1864 let err = SchemaV2::parse_json(j).unwrap_err();
1865 matches!(err, DecoderError::NotSupported(_));
1866 }
1867
1868 #[test]
1869 fn parse_absent_schema_version_treats_as_v1() {
1870 let j = r#"{
1872 "outputs": [
1873 {
1874 "type": "boxes", "decoder": "ultralytics",
1875 "shape": [1, 4, 8400],
1876 "quantization": [0.00392, 0]
1877 },
1878 {
1879 "type": "scores", "decoder": "ultralytics",
1880 "shape": [1, 80, 8400],
1881 "quantization": [0.00392, 0]
1882 }
1883 ]
1884 }"#;
1885 let schema = SchemaV2::parse_json(j).expect("v1 legacy parse");
1886 assert_eq!(schema.schema_version, 2); assert_eq!(schema.outputs.len(), 2);
1888 assert_eq!(schema.outputs[0].type_, Some(LogicalType::Boxes));
1889 assert_eq!(schema.outputs[1].type_, Some(LogicalType::Scores));
1890 assert_eq!(schema.outputs[1].score_format, Some(ScoreFormat::PerClass));
1892 }
1893
1894 #[test]
1895 fn from_v1_preserves_nms_and_decoder_version() {
1896 let v1 = ConfigOutputs {
1897 outputs: vec![ConfigOutput::Boxes(crate::configs::Boxes {
1898 decoder: crate::configs::DecoderType::Ultralytics,
1899 quantization: Some(crate::configs::QuantTuple(0.01, 5)),
1900 shape: vec![1, 4, 8400],
1901 dshape: vec![],
1902 normalized: Some(true),
1903 })],
1904 nms: Some(crate::configs::Nms::ClassAware),
1905 decoder_version: Some(crate::configs::DecoderVersion::Yolo11),
1906 };
1907 let v2 = SchemaV2::from_v1(&v1).unwrap();
1908 assert_eq!(v2.nms, Some(NmsMode::ClassAware));
1909 assert_eq!(v2.decoder_version, Some(DecoderVersion::Yolo11));
1910 assert_eq!(v2.outputs[0].normalized, Some(true));
1911 let q = v2.outputs[0].quantization.as_ref().unwrap();
1912 assert_eq!(q.scale, vec![0.01]);
1913 assert_eq!(q.zero_point, Some(vec![5]));
1914 assert_eq!(q.dtype, None); }
1916
1917 #[test]
1924 fn typeless_logical_output_parses_and_roundtrips() {
1925 let j = r#"{
1926 "schema_version": 2,
1927 "outputs": [
1928 {
1929 "name": "extra_telemetry",
1930 "shape": [1, 16]
1931 },
1932 {
1933 "name": "boxes",
1934 "type": "boxes",
1935 "shape": [1, 4, 8400]
1936 }
1937 ]
1938 }"#;
1939 let schema: SchemaV2 = serde_json::from_str(j).unwrap();
1940 assert_eq!(schema.outputs.len(), 2);
1941 assert_eq!(schema.outputs[0].type_, None);
1942 assert_eq!(schema.outputs[0].name.as_deref(), Some("extra_telemetry"));
1943 assert_eq!(schema.outputs[1].type_, Some(LogicalType::Boxes));
1944
1945 let round = serde_json::to_string(&schema).unwrap();
1947 let first_obj = round
1948 .split("\"outputs\":[")
1949 .nth(1)
1950 .and_then(|s| s.split("}").next())
1951 .expect("outputs array");
1952 assert!(
1953 !first_obj.contains("\"type\""),
1954 "typeless output must not serialize a `type` field, got: {first_obj}"
1955 );
1956 }
1957
1958 #[test]
1964 fn typeless_outputs_filtered_from_legacy_config() {
1965 let schema = SchemaV2 {
1966 schema_version: 2,
1967 input: None,
1968 outputs: vec![
1969 LogicalOutput {
1970 name: Some("diagnostic_histogram".into()),
1971 type_: None,
1972 shape: vec![1, 256],
1973 dshape: vec![],
1974 decoder: None,
1975 encoding: None,
1976 score_format: None,
1977 normalized: None,
1978 anchors: None,
1979 stride: None,
1980 dtype: None,
1981 quantization: None,
1982 outputs: vec![],
1983 activation_applied: None,
1984 activation_required: None,
1985 },
1986 LogicalOutput {
1987 name: Some("boxes".into()),
1988 type_: Some(LogicalType::Boxes),
1989 shape: vec![1, 4, 8400],
1990 dshape: vec![],
1991 decoder: Some(DecoderKind::Ultralytics),
1992 encoding: Some(BoxEncoding::Direct),
1993 score_format: None,
1994 normalized: Some(true),
1995 anchors: None,
1996 stride: None,
1997 dtype: None,
1998 quantization: None,
1999 outputs: vec![],
2000 activation_applied: None,
2001 activation_required: None,
2002 },
2003 ],
2004 nms: None,
2005 decoder_version: None,
2006 };
2007 let legacy = schema.to_legacy_config_outputs().unwrap();
2008 assert_eq!(
2009 legacy.outputs.len(),
2010 1,
2011 "typeless output must be filtered from legacy config"
2012 );
2013 assert!(
2014 matches!(legacy.outputs[0], ConfigOutput::Boxes(_)),
2015 "only the typed `boxes` output should survive lowering"
2016 );
2017 }
2018
2019 #[test]
2024 fn all_typeless_schema_produces_empty_legacy_config() {
2025 let schema = SchemaV2 {
2026 schema_version: 2,
2027 input: None,
2028 outputs: vec![LogicalOutput {
2029 name: Some("aux".into()),
2030 type_: None,
2031 shape: vec![1, 8],
2032 dshape: vec![],
2033 decoder: None,
2034 encoding: None,
2035 score_format: None,
2036 normalized: None,
2037 anchors: None,
2038 stride: None,
2039 dtype: None,
2040 quantization: None,
2041 outputs: vec![],
2042 activation_applied: None,
2043 activation_required: None,
2044 }],
2045 nms: None,
2046 decoder_version: None,
2047 };
2048 let legacy = schema.to_legacy_config_outputs().unwrap();
2049 assert!(legacy.outputs.is_empty());
2050 }
2051
2052 #[test]
2058 fn typeless_physical_child_parses_and_skips_uniqueness() {
2059 let j = r#"{
2060 "name": "boxes",
2061 "type": "boxes",
2062 "shape": [1, 8400, 4],
2063 "outputs": [
2064 {
2065 "name": "boxes_xy",
2066 "type": "boxes_xy",
2067 "shape": [1, 8400, 2],
2068 "dtype": "float32"
2069 },
2070 {
2071 "name": "aux_user_managed",
2072 "shape": [1, 8400, 2],
2073 "dtype": "float32"
2074 }
2075 ]
2076 }"#;
2077 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2078 assert_eq!(lo.outputs.len(), 2);
2079 assert_eq!(lo.outputs[0].type_, Some(PhysicalType::BoxesXy));
2080 assert_eq!(lo.outputs[1].type_, None);
2081
2082 let schema = SchemaV2 {
2086 schema_version: 2,
2087 input: None,
2088 outputs: vec![lo],
2089 nms: None,
2090 decoder_version: None,
2091 };
2092 schema.validate().expect(
2093 "typed + typeless children with equal shape must not trigger \
2094 uniqueness error",
2095 );
2096
2097 let s = serde_json::to_string(&schema).unwrap();
2099 assert!(
2100 s.contains("\"aux_user_managed\""),
2101 "typeless child must survive round-trip: {s}"
2102 );
2103 let aux_obj = s
2105 .split("\"aux_user_managed\"")
2106 .nth(1)
2107 .and_then(|s| s.split('}').next())
2108 .unwrap_or("");
2109 assert!(
2110 !aux_obj.contains("\"type\""),
2111 "typeless child must not serialize `type`, got: {aux_obj}"
2112 );
2113 }
2114
2115 #[test]
2116 fn from_v1_modelpack_anchor_detection_maps_encoding() {
2117 let v1 = ConfigOutputs {
2118 outputs: vec![ConfigOutput::Detection(crate::configs::Detection {
2119 anchors: Some(vec![[0.1, 0.2], [0.3, 0.4]]),
2120 decoder: crate::configs::DecoderType::ModelPack,
2121 quantization: Some(crate::configs::QuantTuple(0.176, 198)),
2122 shape: vec![1, 40, 40, 54],
2123 dshape: vec![],
2124 normalized: None,
2125 })],
2126 nms: None,
2127 decoder_version: None,
2128 };
2129 let v2 = SchemaV2::from_v1(&v1).unwrap();
2130 assert_eq!(v2.outputs[0].encoding, Some(BoxEncoding::Anchor));
2131 assert_eq!(v2.outputs[0].decoder, Some(DecoderKind::ModelPack));
2132 assert_eq!(v2.outputs[0].anchors.as_ref().map(|a| a.len()), Some(2));
2133 }
2134
2135 #[test]
2138 fn validate_accepts_flat_v2_yolov8_detection() {
2139 let j = r#"{
2140 "schema_version": 2,
2141 "outputs": [
2142 {"name":"boxes","type":"boxes","shape":[1,64,8400],
2143 "dtype":"int8","decoder":"ultralytics","encoding":"dfl"},
2144 {"name":"scores","type":"scores","shape":[1,80,8400],
2145 "dtype":"int8","decoder":"ultralytics","score_format":"per_class"}
2146 ]
2147 }"#;
2148 SchemaV2::parse_json(j).unwrap().validate().unwrap();
2149 }
2150
2151 #[test]
2152 fn validate_rejects_unnamed_physical_child() {
2153 let j = r#"{
2154 "schema_version": 2,
2155 "outputs": [{
2156 "name":"boxes","type":"boxes","shape":[1,64,8400],
2157 "encoding":"dfl","decoder":"ultralytics",
2158 "outputs": [{
2159 "name":"","type":"boxes","stride":8,
2160 "shape":[1,80,80,64],"dtype":"uint8"
2161 }]
2162 }]
2163 }"#;
2164 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2165 let msg = format!("{err}");
2166 assert!(msg.contains("missing `name`"), "got: {msg}");
2167 }
2168
2169 #[test]
2170 fn validate_rejects_duplicate_physical_shapes() {
2171 let j = r#"{
2172 "schema_version": 2,
2173 "outputs": [{
2174 "name":"boxes","type":"boxes","shape":[1,64,8400],
2175 "encoding":"dfl","decoder":"ultralytics",
2176 "outputs": [
2177 {"name":"a","type":"boxes","stride":8,"shape":[1,80,80,64],"dtype":"uint8"},
2178 {"name":"b","type":"boxes","stride":16,"shape":[1,80,80,64],"dtype":"uint8"}
2179 ]
2180 }]
2181 }"#;
2182 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2183 let msg = format!("{err}");
2184 assert!(msg.contains("share shape"), "got: {msg}");
2185 }
2186
2187 #[test]
2188 fn validate_rejects_mixed_decomposition() {
2189 let j = r#"{
2191 "schema_version": 2,
2192 "outputs": [{
2193 "name":"boxes","type":"boxes","shape":[1,4,8400,1],
2194 "encoding":"direct","decoder":"ultralytics",
2195 "outputs": [
2196 {"name":"xy","type":"boxes_xy","shape":[1,2,8400,1],"dtype":"int16"},
2197 {"name":"p0","type":"boxes","stride":8,"shape":[1,80,80,64],"dtype":"uint8"}
2198 ]
2199 }]
2200 }"#;
2201 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2202 let msg = format!("{err}");
2203 assert!(msg.contains("uniform"), "got: {msg}");
2204 }
2205
2206 #[test]
2207 fn validate_rejects_dfl_boxes_feature_not_divisible_by_4() {
2208 let j = r#"{
2209 "schema_version": 2,
2210 "outputs": [{
2211 "name":"boxes","type":"boxes","shape":[1,63,8400],
2212 "encoding":"dfl","decoder":"ultralytics",
2213 "outputs": [{
2214 "name":"b0","type":"boxes","stride":8,
2215 "shape":[1,80,80,63],
2216 "dshape":[{"batch":1},{"height":80},{"width":80},{"num_features":63}],
2217 "dtype":"uint8"
2218 }]
2219 }]
2220 }"#;
2221 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2222 let msg = format!("{err}");
2223 assert!(msg.contains("not"), "got: {msg}");
2224 assert!(msg.contains("divisible by 4"), "got: {msg}");
2225 }
2226
2227 #[test]
2228 fn validate_accepts_hailo_per_scale_yolov8() {
2229 let j = r#"{
2230 "schema_version": 2,
2231 "outputs": [{
2232 "name":"boxes","type":"boxes","shape":[1,64,8400],
2233 "encoding":"dfl","decoder":"ultralytics","normalized":true,
2234 "outputs": [
2235 {"name":"b0","type":"boxes","stride":8,
2236 "shape":[1,80,80,64],
2237 "dshape":[{"batch":1},{"height":80},{"width":80},{"num_features":64}],
2238 "dtype":"uint8",
2239 "quantization":{"scale":0.0234,"zero_point":128,"dtype":"uint8"}},
2240 {"name":"b1","type":"boxes","stride":16,
2241 "shape":[1,40,40,64],
2242 "dshape":[{"batch":1},{"height":40},{"width":40},{"num_features":64}],
2243 "dtype":"uint8",
2244 "quantization":{"scale":0.0198,"zero_point":130,"dtype":"uint8"}},
2245 {"name":"b2","type":"boxes","stride":32,
2246 "shape":[1,20,20,64],
2247 "dshape":[{"batch":1},{"height":20},{"width":20},{"num_features":64}],
2248 "dtype":"uint8",
2249 "quantization":{"scale":0.0312,"zero_point":125,"dtype":"uint8"}}
2250 ]
2251 }]
2252 }"#;
2253 let s = SchemaV2::parse_json(j).unwrap();
2254 s.validate().unwrap();
2255 }
2256
2257 #[test]
2258 fn validate_accepts_ara2_xy_wh() {
2259 let j = r#"{
2260 "schema_version": 2,
2261 "outputs": [{
2262 "name":"boxes","type":"boxes","shape":[1,4,8400,1],
2263 "encoding":"direct","decoder":"ultralytics","normalized":true,
2264 "outputs": [
2265 {"name":"xy","type":"boxes_xy","shape":[1,2,8400,1],
2266 "dshape":[{"batch":1},{"box_coords":2},{"num_boxes":8400},{"padding":1}],
2267 "dtype":"int16",
2268 "quantization":{"scale":3.1e-5,"zero_point":0,"dtype":"int16"}},
2269 {"name":"wh","type":"boxes_wh","shape":[1,2,8400,1],
2270 "dshape":[{"batch":1},{"box_coords":2},{"num_boxes":8400},{"padding":1}],
2271 "dtype":"int16",
2272 "quantization":{"scale":3.2e-5,"zero_point":0,"dtype":"int16"}}
2273 ]
2274 }]
2275 }"#;
2276 SchemaV2::parse_json(j).unwrap().validate().unwrap();
2277 }
2278
2279 #[test]
2280 fn parse_file_auto_detects_json() {
2281 let tmp = std::env::temp_dir().join(format!("schema_v2_test_{}.json", std::process::id()));
2282 std::fs::write(&tmp, r#"{"schema_version":2,"outputs":[]}"#).unwrap();
2283 let s = SchemaV2::parse_file(&tmp).unwrap();
2284 assert_eq!(s.schema_version, 2);
2285 let _ = std::fs::remove_file(&tmp);
2286 }
2287
2288 #[test]
2289 fn parse_file_auto_detects_yaml() {
2290 let tmp = std::env::temp_dir().join(format!("schema_v2_test_{}.yaml", std::process::id()));
2291 std::fs::write(&tmp, "schema_version: 2\noutputs: []\n").unwrap();
2292 let s = SchemaV2::parse_file(&tmp).unwrap();
2293 assert_eq!(s.schema_version, 2);
2294 let _ = std::fs::remove_file(&tmp);
2295 }
2296
2297 #[test]
2300 fn parse_real_ara2_int8_dvm_metadata() {
2301 let json = include_str!(concat!(
2302 env!("CARGO_MANIFEST_DIR"),
2303 "/../../testdata/ara2_int8_edgefirst.json"
2304 ));
2305 let schema = SchemaV2::parse_json(json).expect("ARA-2 int8 parse");
2306 assert_eq!(schema.schema_version, 2);
2307 assert_eq!(schema.decoder_version, Some(DecoderVersion::Yolov8));
2308 assert_eq!(schema.nms, Some(NmsMode::ClassAgnostic));
2309 assert_eq!(schema.input.as_ref().unwrap().shape, vec![1, 3, 640, 640]);
2310
2311 assert_eq!(schema.outputs.len(), 4);
2313 let boxes = &schema.outputs[0];
2314 assert_eq!(boxes.type_, Some(LogicalType::Boxes));
2315 assert_eq!(boxes.encoding, Some(BoxEncoding::Direct));
2316 assert_eq!(boxes.normalized, Some(true));
2317 assert_eq!(boxes.shape, vec![1, 4, 8400, 1]); assert_eq!(boxes.outputs.len(), 2);
2319 assert_eq!(boxes.outputs[0].type_, Some(PhysicalType::BoxesXy));
2320 assert_eq!(boxes.outputs[1].type_, Some(PhysicalType::BoxesWh));
2321 let q_xy = boxes.outputs[0].quantization.as_ref().unwrap();
2323 assert_eq!(q_xy.dtype, Some(DType::Int8));
2324 assert!((q_xy.scale[0] - 0.004_177_792).abs() < 1e-6);
2325 assert_eq!(q_xy.zero_point_at(0), -122);
2326
2327 let scores = &schema.outputs[1];
2328 assert_eq!(scores.type_, Some(LogicalType::Scores));
2329 assert_eq!(scores.score_format, Some(ScoreFormat::PerClass));
2330 assert_eq!(scores.shape, vec![1, 80, 8400, 1]);
2331
2332 let mask_coefs = &schema.outputs[2];
2333 assert_eq!(mask_coefs.type_, Some(LogicalType::MaskCoefs));
2334 assert_eq!(mask_coefs.shape, vec![1, 32, 8400, 1]);
2335
2336 let protos = &schema.outputs[3];
2337 assert_eq!(protos.type_, Some(LogicalType::Protos));
2338 assert_eq!(protos.shape, vec![1, 32, 160, 160]);
2339
2340 schema.validate().expect("ARA-2 int8 validate");
2342 }
2343
2344 #[test]
2345 fn parse_real_ara2_int16_dvm_metadata() {
2346 let json = include_str!(concat!(
2347 env!("CARGO_MANIFEST_DIR"),
2348 "/../../testdata/ara2_int16_edgefirst.json"
2349 ));
2350 let schema = SchemaV2::parse_json(json).expect("ARA-2 int16 parse");
2351 assert_eq!(schema.schema_version, 2);
2352 assert_eq!(schema.outputs.len(), 4);
2353 let boxes = &schema.outputs[0];
2354 assert_eq!(boxes.outputs.len(), 2);
2355 let q_xy = boxes.outputs[0].quantization.as_ref().unwrap();
2356 assert_eq!(q_xy.dtype, Some(DType::Int16));
2357 assert!((q_xy.scale[0] - 3.211_570_6e-5).abs() < 1e-10);
2358 assert_eq!(q_xy.zero_point_at(0), 0);
2359 let mc_q = schema.outputs[2].quantization.as_ref().unwrap();
2361 assert_eq!(mc_q.dtype, Some(DType::Int16));
2362 schema.validate().expect("ARA-2 int16 validate");
2363 }
2364
2365 #[test]
2366 fn parse_yaml_with_explicit_schema_version_2() {
2367 let yaml = r#"
2368schema_version: 2
2369outputs:
2370 - name: scores
2371 type: scores
2372 shape: [1, 80, 8400]
2373 dtype: int8
2374 quantization:
2375 scale: 0.00392
2376 dtype: int8
2377 decoder: ultralytics
2378 score_format: per_class
2379"#;
2380 let schema = SchemaV2::parse_yaml(yaml).unwrap();
2381 assert_eq!(schema.schema_version, 2);
2382 assert_eq!(schema.outputs[0].score_format, Some(ScoreFormat::PerClass));
2383 }
2384
2385 #[test]
2388 fn squeeze_padding_dims_preserves_shape_when_dshape_absent() {
2389 let (shape, dshape) = squeeze_padding_dims(vec![1, 4, 8400], vec![]);
2394 assert_eq!(shape, vec![1, 4, 8400]);
2395 assert!(dshape.is_empty());
2396 }
2397
2398 #[test]
2399 fn to_legacy_preserves_shape_for_v2_split_boxes_without_dshape() {
2400 let j = r#"{
2404 "schema_version": 2,
2405 "outputs": [
2406 {"name":"boxes","type":"boxes","shape":[1,4,8400],
2407 "dtype":"float32","decoder":"ultralytics","encoding":"direct"},
2408 {"name":"scores","type":"scores","shape":[1,80,8400],
2409 "dtype":"float32","decoder":"ultralytics","score_format":"per_class"}
2410 ]
2411 }"#;
2412 let schema = SchemaV2::parse_json(j).unwrap();
2413 let legacy = schema.to_legacy_config_outputs().expect("lowers cleanly");
2414 let boxes = match &legacy.outputs[0] {
2415 crate::ConfigOutput::Boxes(b) => b,
2416 other => panic!("expected Boxes, got {other:?}"),
2417 };
2418 assert_eq!(boxes.shape, vec![1, 4, 8400]);
2419 let scores = match &legacy.outputs[1] {
2420 crate::ConfigOutput::Scores(s) => s,
2421 other => panic!("expected Scores, got {other:?}"),
2422 };
2423 assert_eq!(scores.shape, vec![1, 80, 8400]);
2424 }
2425}