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) = match logical.decoder {
1261 Some(DecoderKind::ModelPack) => (logical.shape.clone(), logical.dshape.clone()),
1262 _ => squeeze_padding_dims(logical.shape.clone(), logical.dshape.clone()),
1263 };
1264
1265 let ty = logical.type_.ok_or_else(|| {
1266 DecoderError::InvalidConfig(format!(
1270 "logical output `{}` has no type; typeless outputs should be \
1271 filtered before legacy conversion",
1272 logical.name.as_deref().unwrap_or("<anonymous>")
1273 ))
1274 })?;
1275
1276 let ctx = LegacyConvertCtx {
1277 decoder,
1278 quantization,
1279 shape,
1280 dshape,
1281 normalized: logical.normalized,
1282 anchors: logical.anchors.clone(),
1283 };
1284
1285 match ty {
1286 LogicalType::Boxes => Ok(boxes_to_legacy(&ctx)),
1287 LogicalType::Scores => Ok(scores_to_legacy(&ctx)),
1288 LogicalType::Protos => Ok(protos_to_legacy(&ctx)),
1289 LogicalType::MaskCoefs => Ok(mask_coefs_to_legacy(&ctx)),
1290 LogicalType::Segmentation => Ok(segmentation_to_legacy(&ctx)),
1291 LogicalType::Masks => Ok(masks_to_legacy(&ctx)),
1292 LogicalType::Classes => Ok(classes_to_legacy(&ctx)),
1293 LogicalType::Detection | LogicalType::Detections => Ok(detection_to_legacy(&ctx)),
1294 LogicalType::Objectness | LogicalType::Landmarks => {
1297 Err(DecoderError::NotSupported(format!(
1298 "logical type {:?} has no legacy v1 equivalent; use the \
1299 native v2 decoder path",
1300 ty
1301 )))
1302 }
1303 }
1304}
1305
1306struct LegacyConvertCtx {
1308 decoder: configs::DecoderType,
1309 quantization: Option<QuantTuple>,
1310 shape: Vec<usize>,
1311 dshape: Vec<(DimName, usize)>,
1312 normalized: Option<bool>,
1313 anchors: Option<Vec<[f32; 2]>>,
1314}
1315
1316fn boxes_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1317 ConfigOutput::Boxes(configs::Boxes {
1318 decoder: ctx.decoder,
1319 quantization: ctx.quantization,
1320 shape: ctx.shape.clone(),
1321 dshape: ctx.dshape.clone(),
1322 normalized: ctx.normalized,
1323 })
1324}
1325
1326fn scores_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1327 ConfigOutput::Scores(configs::Scores {
1328 decoder: ctx.decoder,
1329 quantization: ctx.quantization,
1330 shape: ctx.shape.clone(),
1331 dshape: ctx.dshape.clone(),
1332 })
1333}
1334
1335fn protos_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1336 ConfigOutput::Protos(configs::Protos {
1337 decoder: ctx.decoder,
1338 quantization: ctx.quantization,
1339 shape: ctx.shape.clone(),
1340 dshape: ctx.dshape.clone(),
1341 })
1342}
1343
1344fn mask_coefs_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1345 ConfigOutput::MaskCoefficients(configs::MaskCoefficients {
1346 decoder: ctx.decoder,
1347 quantization: ctx.quantization,
1348 shape: ctx.shape.clone(),
1349 dshape: ctx.dshape.clone(),
1350 })
1351}
1352
1353fn segmentation_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1354 ConfigOutput::Segmentation(configs::Segmentation {
1355 decoder: ctx.decoder,
1356 quantization: ctx.quantization,
1357 shape: ctx.shape.clone(),
1358 dshape: ctx.dshape.clone(),
1359 })
1360}
1361
1362fn masks_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1363 ConfigOutput::Mask(configs::Mask {
1364 decoder: ctx.decoder,
1365 quantization: ctx.quantization,
1366 shape: ctx.shape.clone(),
1367 dshape: ctx.dshape.clone(),
1368 })
1369}
1370
1371fn classes_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1372 ConfigOutput::Classes(configs::Classes {
1373 decoder: ctx.decoder,
1374 quantization: ctx.quantization,
1375 shape: ctx.shape.clone(),
1376 dshape: ctx.dshape.clone(),
1377 })
1378}
1379
1380fn detection_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1381 ConfigOutput::Detection(configs::Detection {
1385 anchors: ctx.anchors.clone(),
1386 decoder: ctx.decoder,
1387 quantization: ctx.quantization,
1388 shape: ctx.shape.clone(),
1389 dshape: ctx.dshape.clone(),
1390 normalized: ctx.normalized,
1391 })
1392}
1393
1394#[cfg(test)]
1395#[cfg_attr(coverage_nightly, coverage(off))]
1396mod tests {
1397 use super::*;
1398
1399 #[test]
1400 fn schema_default_is_v2() {
1401 let s = SchemaV2::default();
1402 assert_eq!(s.schema_version, 2);
1403 assert!(s.outputs.is_empty());
1404 }
1405
1406 #[test]
1407 fn fixtures_round_trip_through_serde() {
1408 let yolov8 =
1409 edgefirst_bench::testdata::read_to_string("per_scale/synthetic_yolov8n_schema.json");
1410 let _: super::SchemaV2 = serde_json::from_str(&yolov8).expect("yolov8n fixture must parse");
1411
1412 let yolo26 =
1413 edgefirst_bench::testdata::read_to_string("per_scale/synthetic_yolo26n_schema.json");
1414 let _: super::SchemaV2 = serde_json::from_str(&yolo26).expect("yolo26n fixture must parse");
1415
1416 let flat =
1417 edgefirst_bench::testdata::read_to_string("per_scale/synthetic_flat_schema.json");
1418 let _: super::SchemaV2 = serde_json::from_str(&flat).expect("flat fixture must parse");
1419 }
1420
1421 #[test]
1422 fn box_encoding_accepts_ltrb_alias_for_direct() {
1423 let dfl: BoxEncoding = serde_json::from_str("\"dfl\"").unwrap();
1424 assert_eq!(dfl, BoxEncoding::Dfl);
1425
1426 let direct: BoxEncoding = serde_json::from_str("\"direct\"").unwrap();
1427 assert_eq!(direct, BoxEncoding::Direct);
1428
1429 let ltrb: BoxEncoding = serde_json::from_str("\"ltrb\"").unwrap();
1431 assert_eq!(ltrb, BoxEncoding::Direct);
1432 }
1433
1434 #[test]
1435 fn dtype_roundtrip() {
1436 for d in [
1437 DType::Int8,
1438 DType::Uint8,
1439 DType::Int16,
1440 DType::Uint16,
1441 DType::Float16,
1442 DType::Float32,
1443 ] {
1444 let j = serde_json::to_string(&d).unwrap();
1445 let back: DType = serde_json::from_str(&j).unwrap();
1446 assert_eq!(back, d);
1447 }
1448 }
1449
1450 #[test]
1451 fn dtype_widths() {
1452 assert_eq!(DType::Int8.size_bytes(), 1);
1453 assert_eq!(DType::Float16.size_bytes(), 2);
1454 assert_eq!(DType::Float32.size_bytes(), 4);
1455 }
1456
1457 #[test]
1458 fn stride_accepts_scalar_or_pair() {
1459 let a: Stride = serde_json::from_str("8").unwrap();
1460 let b: Stride = serde_json::from_str("[8, 16]").unwrap();
1461 assert_eq!(a, Stride::Square(8));
1462 assert_eq!(b, Stride::Rect([8, 16]));
1463 assert_eq!(a.x(), 8);
1464 assert_eq!(a.y(), 8);
1465 assert_eq!(b.x(), 8);
1466 assert_eq!(b.y(), 16);
1467 }
1468
1469 #[test]
1470 fn quantization_scalar_scale() {
1471 let j = r#"{"scale": 0.00392, "zero_point": 0, "dtype": "int8"}"#;
1472 let q: Quantization = serde_json::from_str(j).unwrap();
1473 assert!(q.is_per_tensor());
1474 assert!(q.is_symmetric());
1475 assert_eq!(q.scale_at(0), 0.00392);
1476 assert_eq!(q.scale_at(5), 0.00392);
1477 assert_eq!(q.zero_point_at(0), 0);
1478 }
1479
1480 #[test]
1481 fn quantization_per_channel() {
1482 let j = r#"{"scale": [0.054, 0.089, 0.195], "axis": 0, "dtype": "int8"}"#;
1483 let q: Quantization = serde_json::from_str(j).unwrap();
1484 assert!(q.is_per_channel());
1485 assert!(q.is_symmetric());
1486 assert_eq!(q.axis, Some(0));
1487 assert_eq!(q.scale_at(0), 0.054);
1488 assert_eq!(q.scale_at(2), 0.195);
1489 }
1490
1491 #[test]
1492 fn quantization_asymmetric_per_tensor() {
1493 let j = r#"{"scale": 0.176, "zero_point": 198, "dtype": "uint8"}"#;
1494 let q: Quantization = serde_json::from_str(j).unwrap();
1495 assert!(!q.is_symmetric());
1496 assert_eq!(q.zero_point_at(0), 198);
1497 assert_eq!(q.zero_point_at(10), 198);
1498 }
1499
1500 #[test]
1501 fn quantization_symmetric_default_zero_point() {
1502 let j = r#"{"scale": 0.00392, "dtype": "int8"}"#;
1503 let q: Quantization = serde_json::from_str(j).unwrap();
1504 assert!(q.is_symmetric());
1505 assert_eq!(q.zero_point_at(0), 0);
1506 }
1507
1508 #[test]
1509 fn quantization_to_tensor_per_tensor_asymmetric() {
1510 let q = Quantization {
1511 scale: vec![0.1],
1512 zero_point: Some(vec![-5]),
1513 axis: None,
1514 dtype: Some(DType::Int8),
1515 };
1516 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1517 assert!(t.is_per_tensor());
1518 assert!(!t.is_symmetric());
1519 assert_eq!(t.scale(), &[0.1]);
1520 assert_eq!(t.zero_point(), Some(&[-5][..]));
1521 }
1522
1523 #[test]
1524 fn quantization_to_tensor_per_tensor_symmetric() {
1525 let q = Quantization {
1526 scale: vec![0.05],
1527 zero_point: None,
1528 axis: None,
1529 dtype: Some(DType::Int8),
1530 };
1531 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1532 assert!(t.is_per_tensor());
1533 assert!(t.is_symmetric());
1534 }
1535
1536 #[test]
1537 fn quantization_to_tensor_per_channel_asymmetric() {
1538 let q = Quantization {
1539 scale: vec![0.1, 0.2, 0.3],
1540 zero_point: Some(vec![-1, 0, 1]),
1541 axis: Some(2),
1542 dtype: Some(DType::Int8),
1543 };
1544 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1545 assert!(t.is_per_channel());
1546 assert_eq!(t.axis(), Some(2));
1547 assert_eq!(t.scale().len(), 3);
1548 assert_eq!(t.zero_point().map(|z| z.len()), Some(3));
1549 }
1550
1551 #[test]
1552 fn quantization_to_tensor_per_channel_symmetric() {
1553 let q = Quantization {
1554 scale: vec![0.054, 0.089, 0.195],
1555 zero_point: None,
1556 axis: Some(0),
1557 dtype: Some(DType::Int8),
1558 };
1559 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1560 assert!(t.is_per_channel());
1561 assert!(t.is_symmetric());
1562 assert_eq!(t.axis(), Some(0));
1563 }
1564
1565 #[test]
1566 fn quantization_to_tensor_per_channel_missing_axis_errors() {
1567 let q = Quantization {
1568 scale: vec![0.1, 0.2, 0.3],
1569 zero_point: None,
1570 axis: None,
1571 dtype: None,
1572 };
1573 let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
1574 assert!(matches!(
1575 err,
1576 edgefirst_tensor::Error::QuantizationInvalid { .. }
1577 ));
1578 }
1579
1580 #[test]
1581 fn logical_output_flat_tflite_boxes() {
1582 let j = r#"{
1584 "name": "boxes", "type": "boxes",
1585 "shape": [1, 64, 8400],
1586 "dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
1587 "dtype": "int8",
1588 "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1589 "decoder": "ultralytics",
1590 "encoding": "dfl",
1591 "normalized": true
1592 }"#;
1593 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1594 assert_eq!(lo.type_, Some(LogicalType::Boxes));
1595 assert_eq!(lo.encoding, Some(BoxEncoding::Dfl));
1596 assert_eq!(lo.normalized, Some(true));
1597 assert!(!lo.is_split());
1598 assert_eq!(lo.dtype, Some(DType::Int8));
1599 }
1600
1601 #[test]
1602 fn logical_output_hailo_per_scale_split() {
1603 let j = r#"{
1605 "name": "boxes", "type": "boxes",
1606 "shape": [1, 64, 8400],
1607 "encoding": "dfl", "decoder": "ultralytics", "normalized": true,
1608 "outputs": [
1609 {
1610 "name": "boxes_0", "type": "boxes",
1611 "stride": 8, "scale_index": 0,
1612 "shape": [1, 80, 80, 64],
1613 "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_features": 64}],
1614 "dtype": "uint8",
1615 "quantization": {"scale": 0.0234, "zero_point": 128, "dtype": "uint8"}
1616 }
1617 ]
1618 }"#;
1619 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1620 assert!(lo.is_split());
1621 assert_eq!(lo.outputs.len(), 1);
1622 let child = &lo.outputs[0];
1623 assert_eq!(child.name, "boxes_0");
1624 assert_eq!(child.type_, Some(PhysicalType::Boxes));
1625 assert_eq!(child.stride, Some(Stride::Square(8)));
1626 assert_eq!(child.scale_index, Some(0));
1627 assert_eq!(child.dtype, DType::Uint8);
1628 }
1629
1630 #[test]
1631 fn logical_output_ara2_xy_wh_channel_split() {
1632 let j = r#"{
1634 "name": "boxes", "type": "boxes",
1635 "shape": [1, 4, 8400, 1],
1636 "encoding": "direct", "decoder": "ultralytics", "normalized": true,
1637 "outputs": [
1638 {
1639 "name": "_model_22_Div_1_output_0", "type": "boxes_xy",
1640 "shape": [1, 2, 8400, 1],
1641 "dshape": [{"batch": 1}, {"box_coords": 2}, {"num_boxes": 8400}, {"padding": 1}],
1642 "dtype": "int16",
1643 "quantization": {"scale": 3.129e-5, "zero_point": 0, "dtype": "int16"}
1644 },
1645 {
1646 "name": "_model_22_Sub_1_output_0", "type": "boxes_wh",
1647 "shape": [1, 2, 8400, 1],
1648 "dshape": [{"batch": 1}, {"box_coords": 2}, {"num_boxes": 8400}, {"padding": 1}],
1649 "dtype": "int16",
1650 "quantization": {"scale": 3.149e-5, "zero_point": 0, "dtype": "int16"}
1651 }
1652 ]
1653 }"#;
1654 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1655 assert_eq!(lo.encoding, Some(BoxEncoding::Direct));
1656 assert_eq!(lo.outputs.len(), 2);
1657 assert_eq!(lo.outputs[0].type_, Some(PhysicalType::BoxesXy));
1658 assert_eq!(lo.outputs[1].type_, Some(PhysicalType::BoxesWh));
1659 assert!(lo.outputs[0].stride.is_none());
1660 assert!(lo.outputs[1].stride.is_none());
1661 }
1662
1663 #[test]
1664 fn logical_output_hailo_scores_sigmoid_applied() {
1665 let j = r#"{
1666 "name": "scores", "type": "scores",
1667 "shape": [1, 80, 8400],
1668 "decoder": "ultralytics", "score_format": "per_class",
1669 "outputs": [
1670 {
1671 "name": "scores_0", "type": "scores",
1672 "stride": 8, "scale_index": 0,
1673 "shape": [1, 80, 80, 80],
1674 "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_classes": 80}],
1675 "dtype": "uint8",
1676 "quantization": {"scale": 0.003922, "dtype": "uint8"},
1677 "activation_applied": "sigmoid"
1678 }
1679 ]
1680 }"#;
1681 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1682 assert_eq!(lo.score_format, Some(ScoreFormat::PerClass));
1683 let child = &lo.outputs[0];
1684 assert_eq!(child.activation_applied, Some(Activation::Sigmoid));
1685 assert!(child.activation_required.is_none());
1686 }
1687
1688 #[test]
1689 fn yolo26_end_to_end_detections() {
1690 let j = r#"{
1691 "schema_version": 2,
1692 "decoder_version": "yolo26",
1693 "outputs": [{
1694 "name": "output0", "type": "detections",
1695 "shape": [1, 100, 6],
1696 "dshape": [{"batch": 1}, {"num_boxes": 100}, {"num_features": 6}],
1697 "dtype": "int8",
1698 "quantization": {"scale": 0.0078, "zero_point": 0, "dtype": "int8"},
1699 "normalized": false,
1700 "decoder": "ultralytics"
1701 }]
1702 }"#;
1703 let s: SchemaV2 = serde_json::from_str(j).unwrap();
1704 assert_eq!(s.decoder_version, Some(DecoderVersion::Yolo26));
1705 assert!(s.decoder_version.unwrap().is_end_to_end());
1706 assert_eq!(s.outputs[0].type_, Some(LogicalType::Detections));
1707 assert_eq!(s.outputs[0].normalized, Some(false));
1708 assert!(s.nms.is_none());
1709 }
1710
1711 #[test]
1712 fn modelpack_anchor_detection_with_rect_stride() {
1713 let j = r#"{
1714 "schema_version": 2,
1715 "outputs": [{
1716 "name": "output_0", "type": "detection",
1717 "shape": [1, 40, 40, 54],
1718 "dshape": [{"batch": 1}, {"height": 40}, {"width": 40}, {"num_anchors_x_features": 54}],
1719 "dtype": "uint8",
1720 "quantization": {"scale": 0.176, "zero_point": 198, "dtype": "uint8"},
1721 "decoder": "modelpack",
1722 "encoding": "anchor",
1723 "stride": [16, 16],
1724 "anchors": [[0.054, 0.065], [0.089, 0.139], [0.195, 0.196]]
1725 }]
1726 }"#;
1727 let s: SchemaV2 = serde_json::from_str(j).unwrap();
1728 let lo = &s.outputs[0];
1729 assert_eq!(lo.encoding, Some(BoxEncoding::Anchor));
1730 assert_eq!(lo.stride, Some(Stride::Rect([16, 16])));
1731 assert_eq!(lo.anchors.as_ref().map(|a| a.len()), Some(3));
1732 }
1733
1734 #[test]
1735 fn yolov5_obj_x_class_objectness_logical() {
1736 let j = r#"{
1737 "name": "objectness", "type": "objectness",
1738 "shape": [1, 3, 8400],
1739 "decoder": "ultralytics",
1740 "outputs": [{
1741 "name": "objectness_0", "type": "objectness",
1742 "stride": 8, "scale_index": 0,
1743 "shape": [1, 80, 80, 3],
1744 "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_features": 3}],
1745 "dtype": "uint8",
1746 "quantization": {"scale": 0.0039, "zero_point": 0, "dtype": "uint8"},
1747 "activation_applied": "sigmoid"
1748 }]
1749 }"#;
1750 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1751 assert_eq!(lo.type_, Some(LogicalType::Objectness));
1752 assert_eq!(lo.outputs[0].activation_applied, Some(Activation::Sigmoid));
1753 }
1754
1755 #[test]
1756 fn direct_protos_no_decoder() {
1757 let j = r#"{
1759 "name": "protos", "type": "protos",
1760 "shape": [1, 32, 160, 160],
1761 "dshape": [{"batch": 1}, {"num_protos": 32}, {"height": 160}, {"width": 160}],
1762 "dtype": "uint8",
1763 "quantization": {"scale": 0.0203, "zero_point": 45, "dtype": "uint8"},
1764 "stride": 4
1765 }"#;
1766 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1767 assert_eq!(lo.type_, Some(LogicalType::Protos));
1768 assert!(lo.decoder.is_none());
1769 assert_eq!(lo.stride, Some(Stride::Square(4)));
1770 }
1771
1772 #[test]
1773 fn full_yolov8_tflite_flat_detection() {
1774 let j = r#"{
1776 "schema_version": 2,
1777 "decoder_version": "yolov8",
1778 "nms": "class_agnostic",
1779 "input": { "shape": [1, 640, 640, 3], "cameraadaptor": "rgb" },
1780 "outputs": [
1781 {
1782 "name": "boxes", "type": "boxes",
1783 "shape": [1, 64, 8400],
1784 "dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
1785 "dtype": "int8",
1786 "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1787 "decoder": "ultralytics",
1788 "encoding": "dfl",
1789 "normalized": true
1790 },
1791 {
1792 "name": "scores", "type": "scores",
1793 "shape": [1, 80, 8400],
1794 "dshape": [{"batch": 1}, {"num_classes": 80}, {"num_boxes": 8400}],
1795 "dtype": "int8",
1796 "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1797 "decoder": "ultralytics",
1798 "score_format": "per_class"
1799 }
1800 ]
1801 }"#;
1802 let s: SchemaV2 = serde_json::from_str(j).unwrap();
1803 assert_eq!(s.schema_version, 2);
1804 assert_eq!(s.decoder_version, Some(DecoderVersion::Yolov8));
1805 assert_eq!(s.nms, Some(NmsMode::ClassAgnostic));
1806 assert_eq!(s.input.as_ref().unwrap().shape, vec![1, 640, 640, 3]);
1807 assert_eq!(s.outputs.len(), 2);
1808 }
1809
1810 #[test]
1811 fn schema_unknown_version_parses_without_validation() {
1812 let j = r#"{"schema_version": 99, "outputs": []}"#;
1815 let s: SchemaV2 = serde_json::from_str(j).unwrap();
1816 assert_eq!(s.schema_version, 99);
1817 }
1818
1819 #[test]
1820 fn serde_roundtrip_preserves_fields() {
1821 let original = SchemaV2 {
1822 schema_version: 2,
1823 input: Some(InputSpec {
1824 shape: vec![1, 3, 640, 640],
1825 dshape: vec![],
1826 cameraadaptor: Some("rgb".into()),
1827 }),
1828 outputs: vec![LogicalOutput {
1829 name: Some("boxes".into()),
1830 type_: Some(LogicalType::Boxes),
1831 shape: vec![1, 4, 8400],
1832 dshape: vec![],
1833 decoder: Some(DecoderKind::Ultralytics),
1834 encoding: Some(BoxEncoding::Direct),
1835 score_format: None,
1836 normalized: Some(true),
1837 anchors: None,
1838 stride: None,
1839 dtype: Some(DType::Float32),
1840 quantization: None,
1841 outputs: vec![],
1842 activation_applied: None,
1843 activation_required: None,
1844 }],
1845 nms: Some(NmsMode::ClassAgnostic),
1846 decoder_version: Some(DecoderVersion::Yolov8),
1847 };
1848 let j = serde_json::to_string(&original).unwrap();
1849 let parsed: SchemaV2 = serde_json::from_str(&j).unwrap();
1850 assert_eq!(parsed, original);
1851 }
1852
1853 #[test]
1856 fn parse_v1_yaml_yolov8_seg_testdata() {
1857 let yaml = edgefirst_bench::testdata::read_to_string("yolov8_seg.yaml");
1858 let schema = SchemaV2::parse_yaml(&yaml).expect("parse v1 yaml");
1859 assert_eq!(schema.schema_version, 2);
1860 assert_eq!(schema.outputs.len(), 2);
1861 let det = &schema.outputs[0];
1863 assert_eq!(det.type_, Some(LogicalType::Detection));
1864 assert_eq!(det.shape, vec![1, 116, 8400]);
1865 assert_eq!(det.decoder, Some(DecoderKind::Ultralytics));
1866 assert_eq!(det.encoding, Some(BoxEncoding::Direct));
1867 let q = det.quantization.as_ref().unwrap();
1868 assert_eq!(q.scale.len(), 1);
1869 assert!((q.scale[0] - 0.021_287_762).abs() < 1e-6);
1870 assert_eq!(q.zero_point, Some(vec![31]));
1871 let protos = &schema.outputs[1];
1873 assert_eq!(protos.type_, Some(LogicalType::Protos));
1874 assert_eq!(protos.shape, vec![1, 160, 160, 32]);
1875 }
1876
1877 #[test]
1878 fn parse_v1_json_modelpack_split_testdata() {
1879 let json = edgefirst_bench::testdata::read_to_string("modelpack_split.json");
1880 let schema = SchemaV2::parse_json(&json).expect("parse v1 json");
1881 assert_eq!(schema.schema_version, 2);
1882 assert_eq!(schema.outputs.len(), 2);
1883 for out in &schema.outputs {
1885 assert_eq!(out.type_, Some(LogicalType::Detection));
1886 assert_eq!(out.decoder, Some(DecoderKind::ModelPack));
1887 assert_eq!(out.encoding, Some(BoxEncoding::Anchor));
1888 assert_eq!(out.anchors.as_ref().map(|a| a.len()), Some(3));
1889 }
1890 }
1891
1892 #[test]
1893 fn parse_v2_json_direct_when_schema_version_present() {
1894 let j = r#"{
1895 "schema_version": 2,
1896 "outputs": [{
1897 "name": "boxes", "type": "boxes",
1898 "shape": [1, 4, 8400],
1899 "dshape": [{"batch": 1}, {"box_coords": 4}, {"num_boxes": 8400}],
1900 "dtype": "float32",
1901 "decoder": "ultralytics",
1902 "encoding": "direct",
1903 "normalized": true
1904 }]
1905 }"#;
1906 let schema = SchemaV2::parse_json(j).unwrap();
1907 assert_eq!(schema.schema_version, 2);
1908 assert_eq!(schema.outputs[0].type_, Some(LogicalType::Boxes));
1909 }
1910
1911 #[test]
1912 fn parse_rejects_future_schema_version() {
1913 let j = r#"{"schema_version": 99, "outputs": []}"#;
1914 let err = SchemaV2::parse_json(j).unwrap_err();
1915 matches!(err, DecoderError::NotSupported(_));
1916 }
1917
1918 #[test]
1919 fn parse_absent_schema_version_treats_as_v1() {
1920 let j = r#"{
1922 "outputs": [
1923 {
1924 "type": "boxes", "decoder": "ultralytics",
1925 "shape": [1, 4, 8400],
1926 "quantization": [0.00392, 0]
1927 },
1928 {
1929 "type": "scores", "decoder": "ultralytics",
1930 "shape": [1, 80, 8400],
1931 "quantization": [0.00392, 0]
1932 }
1933 ]
1934 }"#;
1935 let schema = SchemaV2::parse_json(j).expect("v1 legacy parse");
1936 assert_eq!(schema.schema_version, 2); assert_eq!(schema.outputs.len(), 2);
1938 assert_eq!(schema.outputs[0].type_, Some(LogicalType::Boxes));
1939 assert_eq!(schema.outputs[1].type_, Some(LogicalType::Scores));
1940 assert_eq!(schema.outputs[1].score_format, Some(ScoreFormat::PerClass));
1942 }
1943
1944 #[test]
1945 fn from_v1_preserves_nms_and_decoder_version() {
1946 let v1 = ConfigOutputs {
1947 outputs: vec![ConfigOutput::Boxes(crate::configs::Boxes {
1948 decoder: crate::configs::DecoderType::Ultralytics,
1949 quantization: Some(crate::configs::QuantTuple(0.01, 5)),
1950 shape: vec![1, 4, 8400],
1951 dshape: vec![],
1952 normalized: Some(true),
1953 })],
1954 nms: Some(crate::configs::Nms::ClassAware),
1955 decoder_version: Some(crate::configs::DecoderVersion::Yolo11),
1956 };
1957 let v2 = SchemaV2::from_v1(&v1).unwrap();
1958 assert_eq!(v2.nms, Some(NmsMode::ClassAware));
1959 assert_eq!(v2.decoder_version, Some(DecoderVersion::Yolo11));
1960 assert_eq!(v2.outputs[0].normalized, Some(true));
1961 let q = v2.outputs[0].quantization.as_ref().unwrap();
1962 assert_eq!(q.scale, vec![0.01]);
1963 assert_eq!(q.zero_point, Some(vec![5]));
1964 assert_eq!(q.dtype, None); }
1966
1967 #[test]
1974 fn typeless_logical_output_parses_and_roundtrips() {
1975 let j = r#"{
1976 "schema_version": 2,
1977 "outputs": [
1978 {
1979 "name": "extra_telemetry",
1980 "shape": [1, 16]
1981 },
1982 {
1983 "name": "boxes",
1984 "type": "boxes",
1985 "shape": [1, 4, 8400]
1986 }
1987 ]
1988 }"#;
1989 let schema: SchemaV2 = serde_json::from_str(j).unwrap();
1990 assert_eq!(schema.outputs.len(), 2);
1991 assert_eq!(schema.outputs[0].type_, None);
1992 assert_eq!(schema.outputs[0].name.as_deref(), Some("extra_telemetry"));
1993 assert_eq!(schema.outputs[1].type_, Some(LogicalType::Boxes));
1994
1995 let round = serde_json::to_string(&schema).unwrap();
1997 let first_obj = round
1998 .split("\"outputs\":[")
1999 .nth(1)
2000 .and_then(|s| s.split("}").next())
2001 .expect("outputs array");
2002 assert!(
2003 !first_obj.contains("\"type\""),
2004 "typeless output must not serialize a `type` field, got: {first_obj}"
2005 );
2006 }
2007
2008 #[test]
2014 fn typeless_outputs_filtered_from_legacy_config() {
2015 let schema = SchemaV2 {
2016 schema_version: 2,
2017 input: None,
2018 outputs: vec![
2019 LogicalOutput {
2020 name: Some("diagnostic_histogram".into()),
2021 type_: None,
2022 shape: vec![1, 256],
2023 dshape: vec![],
2024 decoder: None,
2025 encoding: None,
2026 score_format: None,
2027 normalized: None,
2028 anchors: None,
2029 stride: None,
2030 dtype: None,
2031 quantization: None,
2032 outputs: vec![],
2033 activation_applied: None,
2034 activation_required: None,
2035 },
2036 LogicalOutput {
2037 name: Some("boxes".into()),
2038 type_: Some(LogicalType::Boxes),
2039 shape: vec![1, 4, 8400],
2040 dshape: vec![],
2041 decoder: Some(DecoderKind::Ultralytics),
2042 encoding: Some(BoxEncoding::Direct),
2043 score_format: None,
2044 normalized: Some(true),
2045 anchors: None,
2046 stride: None,
2047 dtype: None,
2048 quantization: None,
2049 outputs: vec![],
2050 activation_applied: None,
2051 activation_required: None,
2052 },
2053 ],
2054 nms: None,
2055 decoder_version: None,
2056 };
2057 let legacy = schema.to_legacy_config_outputs().unwrap();
2058 assert_eq!(
2059 legacy.outputs.len(),
2060 1,
2061 "typeless output must be filtered from legacy config"
2062 );
2063 assert!(
2064 matches!(legacy.outputs[0], ConfigOutput::Boxes(_)),
2065 "only the typed `boxes` output should survive lowering"
2066 );
2067 }
2068
2069 #[test]
2074 fn all_typeless_schema_produces_empty_legacy_config() {
2075 let schema = SchemaV2 {
2076 schema_version: 2,
2077 input: None,
2078 outputs: vec![LogicalOutput {
2079 name: Some("aux".into()),
2080 type_: None,
2081 shape: vec![1, 8],
2082 dshape: vec![],
2083 decoder: None,
2084 encoding: None,
2085 score_format: None,
2086 normalized: None,
2087 anchors: None,
2088 stride: None,
2089 dtype: None,
2090 quantization: None,
2091 outputs: vec![],
2092 activation_applied: None,
2093 activation_required: None,
2094 }],
2095 nms: None,
2096 decoder_version: None,
2097 };
2098 let legacy = schema.to_legacy_config_outputs().unwrap();
2099 assert!(legacy.outputs.is_empty());
2100 }
2101
2102 #[test]
2108 fn typeless_physical_child_parses_and_skips_uniqueness() {
2109 let j = r#"{
2110 "name": "boxes",
2111 "type": "boxes",
2112 "shape": [1, 8400, 4],
2113 "outputs": [
2114 {
2115 "name": "boxes_xy",
2116 "type": "boxes_xy",
2117 "shape": [1, 8400, 2],
2118 "dtype": "float32"
2119 },
2120 {
2121 "name": "aux_user_managed",
2122 "shape": [1, 8400, 2],
2123 "dtype": "float32"
2124 }
2125 ]
2126 }"#;
2127 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2128 assert_eq!(lo.outputs.len(), 2);
2129 assert_eq!(lo.outputs[0].type_, Some(PhysicalType::BoxesXy));
2130 assert_eq!(lo.outputs[1].type_, None);
2131
2132 let schema = SchemaV2 {
2136 schema_version: 2,
2137 input: None,
2138 outputs: vec![lo],
2139 nms: None,
2140 decoder_version: None,
2141 };
2142 schema.validate().expect(
2143 "typed + typeless children with equal shape must not trigger \
2144 uniqueness error",
2145 );
2146
2147 let s = serde_json::to_string(&schema).unwrap();
2149 assert!(
2150 s.contains("\"aux_user_managed\""),
2151 "typeless child must survive round-trip: {s}"
2152 );
2153 let aux_obj = s
2155 .split("\"aux_user_managed\"")
2156 .nth(1)
2157 .and_then(|s| s.split('}').next())
2158 .unwrap_or("");
2159 assert!(
2160 !aux_obj.contains("\"type\""),
2161 "typeless child must not serialize `type`, got: {aux_obj}"
2162 );
2163 }
2164
2165 #[test]
2166 fn from_v1_modelpack_anchor_detection_maps_encoding() {
2167 let v1 = ConfigOutputs {
2168 outputs: vec![ConfigOutput::Detection(crate::configs::Detection {
2169 anchors: Some(vec![[0.1, 0.2], [0.3, 0.4]]),
2170 decoder: crate::configs::DecoderType::ModelPack,
2171 quantization: Some(crate::configs::QuantTuple(0.176, 198)),
2172 shape: vec![1, 40, 40, 54],
2173 dshape: vec![],
2174 normalized: None,
2175 })],
2176 nms: None,
2177 decoder_version: None,
2178 };
2179 let v2 = SchemaV2::from_v1(&v1).unwrap();
2180 assert_eq!(v2.outputs[0].encoding, Some(BoxEncoding::Anchor));
2181 assert_eq!(v2.outputs[0].decoder, Some(DecoderKind::ModelPack));
2182 assert_eq!(v2.outputs[0].anchors.as_ref().map(|a| a.len()), Some(2));
2183 }
2184
2185 #[test]
2188 fn validate_accepts_flat_v2_yolov8_detection() {
2189 let j = r#"{
2190 "schema_version": 2,
2191 "outputs": [
2192 {"name":"boxes","type":"boxes","shape":[1,64,8400],
2193 "dtype":"int8","decoder":"ultralytics","encoding":"dfl"},
2194 {"name":"scores","type":"scores","shape":[1,80,8400],
2195 "dtype":"int8","decoder":"ultralytics","score_format":"per_class"}
2196 ]
2197 }"#;
2198 SchemaV2::parse_json(j).unwrap().validate().unwrap();
2199 }
2200
2201 #[test]
2202 fn validate_rejects_unnamed_physical_child() {
2203 let j = r#"{
2204 "schema_version": 2,
2205 "outputs": [{
2206 "name":"boxes","type":"boxes","shape":[1,64,8400],
2207 "encoding":"dfl","decoder":"ultralytics",
2208 "outputs": [{
2209 "name":"","type":"boxes","stride":8,
2210 "shape":[1,80,80,64],"dtype":"uint8"
2211 }]
2212 }]
2213 }"#;
2214 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2215 let msg = format!("{err}");
2216 assert!(msg.contains("missing `name`"), "got: {msg}");
2217 }
2218
2219 #[test]
2220 fn validate_rejects_duplicate_physical_shapes() {
2221 let j = r#"{
2222 "schema_version": 2,
2223 "outputs": [{
2224 "name":"boxes","type":"boxes","shape":[1,64,8400],
2225 "encoding":"dfl","decoder":"ultralytics",
2226 "outputs": [
2227 {"name":"a","type":"boxes","stride":8,"shape":[1,80,80,64],"dtype":"uint8"},
2228 {"name":"b","type":"boxes","stride":16,"shape":[1,80,80,64],"dtype":"uint8"}
2229 ]
2230 }]
2231 }"#;
2232 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2233 let msg = format!("{err}");
2234 assert!(msg.contains("share shape"), "got: {msg}");
2235 }
2236
2237 #[test]
2238 fn validate_rejects_mixed_decomposition() {
2239 let j = r#"{
2241 "schema_version": 2,
2242 "outputs": [{
2243 "name":"boxes","type":"boxes","shape":[1,4,8400,1],
2244 "encoding":"direct","decoder":"ultralytics",
2245 "outputs": [
2246 {"name":"xy","type":"boxes_xy","shape":[1,2,8400,1],"dtype":"int16"},
2247 {"name":"p0","type":"boxes","stride":8,"shape":[1,80,80,64],"dtype":"uint8"}
2248 ]
2249 }]
2250 }"#;
2251 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2252 let msg = format!("{err}");
2253 assert!(msg.contains("uniform"), "got: {msg}");
2254 }
2255
2256 #[test]
2257 fn validate_rejects_dfl_boxes_feature_not_divisible_by_4() {
2258 let j = r#"{
2259 "schema_version": 2,
2260 "outputs": [{
2261 "name":"boxes","type":"boxes","shape":[1,63,8400],
2262 "encoding":"dfl","decoder":"ultralytics",
2263 "outputs": [{
2264 "name":"b0","type":"boxes","stride":8,
2265 "shape":[1,80,80,63],
2266 "dshape":[{"batch":1},{"height":80},{"width":80},{"num_features":63}],
2267 "dtype":"uint8"
2268 }]
2269 }]
2270 }"#;
2271 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2272 let msg = format!("{err}");
2273 assert!(msg.contains("not"), "got: {msg}");
2274 assert!(msg.contains("divisible by 4"), "got: {msg}");
2275 }
2276
2277 #[test]
2278 fn validate_accepts_hailo_per_scale_yolov8() {
2279 let j = r#"{
2280 "schema_version": 2,
2281 "outputs": [{
2282 "name":"boxes","type":"boxes","shape":[1,64,8400],
2283 "encoding":"dfl","decoder":"ultralytics","normalized":true,
2284 "outputs": [
2285 {"name":"b0","type":"boxes","stride":8,
2286 "shape":[1,80,80,64],
2287 "dshape":[{"batch":1},{"height":80},{"width":80},{"num_features":64}],
2288 "dtype":"uint8",
2289 "quantization":{"scale":0.0234,"zero_point":128,"dtype":"uint8"}},
2290 {"name":"b1","type":"boxes","stride":16,
2291 "shape":[1,40,40,64],
2292 "dshape":[{"batch":1},{"height":40},{"width":40},{"num_features":64}],
2293 "dtype":"uint8",
2294 "quantization":{"scale":0.0198,"zero_point":130,"dtype":"uint8"}},
2295 {"name":"b2","type":"boxes","stride":32,
2296 "shape":[1,20,20,64],
2297 "dshape":[{"batch":1},{"height":20},{"width":20},{"num_features":64}],
2298 "dtype":"uint8",
2299 "quantization":{"scale":0.0312,"zero_point":125,"dtype":"uint8"}}
2300 ]
2301 }]
2302 }"#;
2303 let s = SchemaV2::parse_json(j).unwrap();
2304 s.validate().unwrap();
2305 }
2306
2307 #[test]
2308 fn validate_accepts_ara2_xy_wh() {
2309 let j = r#"{
2310 "schema_version": 2,
2311 "outputs": [{
2312 "name":"boxes","type":"boxes","shape":[1,4,8400,1],
2313 "encoding":"direct","decoder":"ultralytics","normalized":true,
2314 "outputs": [
2315 {"name":"xy","type":"boxes_xy","shape":[1,2,8400,1],
2316 "dshape":[{"batch":1},{"box_coords":2},{"num_boxes":8400},{"padding":1}],
2317 "dtype":"int16",
2318 "quantization":{"scale":3.1e-5,"zero_point":0,"dtype":"int16"}},
2319 {"name":"wh","type":"boxes_wh","shape":[1,2,8400,1],
2320 "dshape":[{"batch":1},{"box_coords":2},{"num_boxes":8400},{"padding":1}],
2321 "dtype":"int16",
2322 "quantization":{"scale":3.2e-5,"zero_point":0,"dtype":"int16"}}
2323 ]
2324 }]
2325 }"#;
2326 SchemaV2::parse_json(j).unwrap().validate().unwrap();
2327 }
2328
2329 #[test]
2330 fn parse_file_auto_detects_json() {
2331 let tmp = std::env::temp_dir().join(format!("schema_v2_test_{}.json", std::process::id()));
2332 std::fs::write(&tmp, r#"{"schema_version":2,"outputs":[]}"#).unwrap();
2333 let s = SchemaV2::parse_file(&tmp).unwrap();
2334 assert_eq!(s.schema_version, 2);
2335 let _ = std::fs::remove_file(&tmp);
2336 }
2337
2338 #[test]
2339 fn parse_file_auto_detects_yaml() {
2340 let tmp = std::env::temp_dir().join(format!("schema_v2_test_{}.yaml", std::process::id()));
2341 std::fs::write(&tmp, "schema_version: 2\noutputs: []\n").unwrap();
2342 let s = SchemaV2::parse_file(&tmp).unwrap();
2343 assert_eq!(s.schema_version, 2);
2344 let _ = std::fs::remove_file(&tmp);
2345 }
2346
2347 #[test]
2350 fn parse_real_ara2_int8_dvm_metadata() {
2351 let json = edgefirst_bench::testdata::read_to_string("ara2_int8_edgefirst.json");
2352 let schema = SchemaV2::parse_json(&json).expect("ARA-2 int8 parse");
2353 assert_eq!(schema.schema_version, 2);
2354 assert_eq!(schema.decoder_version, Some(DecoderVersion::Yolov8));
2355 assert_eq!(schema.nms, Some(NmsMode::ClassAgnostic));
2356 assert_eq!(schema.input.as_ref().unwrap().shape, vec![1, 3, 640, 640]);
2357
2358 assert_eq!(schema.outputs.len(), 4);
2360 let boxes = &schema.outputs[0];
2361 assert_eq!(boxes.type_, Some(LogicalType::Boxes));
2362 assert_eq!(boxes.encoding, Some(BoxEncoding::Direct));
2363 assert_eq!(boxes.normalized, Some(true));
2364 assert_eq!(boxes.shape, vec![1, 4, 8400, 1]); assert_eq!(boxes.outputs.len(), 2);
2366 assert_eq!(boxes.outputs[0].type_, Some(PhysicalType::BoxesXy));
2367 assert_eq!(boxes.outputs[1].type_, Some(PhysicalType::BoxesWh));
2368 let q_xy = boxes.outputs[0].quantization.as_ref().unwrap();
2370 assert_eq!(q_xy.dtype, Some(DType::Int8));
2371 assert!((q_xy.scale[0] - 0.004_177_792).abs() < 1e-6);
2372 assert_eq!(q_xy.zero_point_at(0), -122);
2373
2374 let scores = &schema.outputs[1];
2375 assert_eq!(scores.type_, Some(LogicalType::Scores));
2376 assert_eq!(scores.score_format, Some(ScoreFormat::PerClass));
2377 assert_eq!(scores.shape, vec![1, 80, 8400, 1]);
2378
2379 let mask_coefs = &schema.outputs[2];
2380 assert_eq!(mask_coefs.type_, Some(LogicalType::MaskCoefs));
2381 assert_eq!(mask_coefs.shape, vec![1, 32, 8400, 1]);
2382
2383 let protos = &schema.outputs[3];
2384 assert_eq!(protos.type_, Some(LogicalType::Protos));
2385 assert_eq!(protos.shape, vec![1, 32, 160, 160]);
2386
2387 schema.validate().expect("ARA-2 int8 validate");
2389 }
2390
2391 #[test]
2392 fn parse_real_ara2_int16_dvm_metadata() {
2393 let json = edgefirst_bench::testdata::read_to_string("ara2_int16_edgefirst.json");
2394 let schema = SchemaV2::parse_json(&json).expect("ARA-2 int16 parse");
2395 assert_eq!(schema.schema_version, 2);
2396 assert_eq!(schema.outputs.len(), 4);
2397 let boxes = &schema.outputs[0];
2398 assert_eq!(boxes.outputs.len(), 2);
2399 let q_xy = boxes.outputs[0].quantization.as_ref().unwrap();
2400 assert_eq!(q_xy.dtype, Some(DType::Int16));
2401 assert!((q_xy.scale[0] - 3.211_570_6e-5).abs() < 1e-10);
2402 assert_eq!(q_xy.zero_point_at(0), 0);
2403 let mc_q = schema.outputs[2].quantization.as_ref().unwrap();
2405 assert_eq!(mc_q.dtype, Some(DType::Int16));
2406 schema.validate().expect("ARA-2 int16 validate");
2407 }
2408
2409 #[test]
2410 fn parse_yaml_with_explicit_schema_version_2() {
2411 let yaml = r#"
2412schema_version: 2
2413outputs:
2414 - name: scores
2415 type: scores
2416 shape: [1, 80, 8400]
2417 dtype: int8
2418 quantization:
2419 scale: 0.00392
2420 dtype: int8
2421 decoder: ultralytics
2422 score_format: per_class
2423"#;
2424 let schema = SchemaV2::parse_yaml(yaml).unwrap();
2425 assert_eq!(schema.schema_version, 2);
2426 assert_eq!(schema.outputs[0].score_format, Some(ScoreFormat::PerClass));
2427 }
2428
2429 #[test]
2432 fn squeeze_padding_dims_preserves_shape_when_dshape_absent() {
2433 let (shape, dshape) = squeeze_padding_dims(vec![1, 4, 8400], vec![]);
2438 assert_eq!(shape, vec![1, 4, 8400]);
2439 assert!(dshape.is_empty());
2440 }
2441
2442 fn sample_logical(ty: LogicalType) -> LogicalOutput {
2443 LogicalOutput {
2444 name: Some("sample".into()),
2445 type_: Some(ty),
2446 shape: vec![1, 4, 100],
2447 dshape: vec![],
2448 decoder: None,
2449 encoding: None,
2450 score_format: None,
2451 normalized: Some(true),
2452 anchors: None,
2453 stride: None,
2454 dtype: None,
2455 quantization: None,
2456 outputs: vec![],
2457 activation_applied: None,
2458 activation_required: None,
2459 }
2460 }
2461
2462 #[test]
2463 fn logical_to_legacy_maps_each_supported_type() {
2464 let cases = [
2465 (LogicalType::Boxes, "Boxes"),
2466 (LogicalType::Scores, "Scores"),
2467 (LogicalType::Protos, "Protos"),
2468 (LogicalType::MaskCoefs, "MaskCoefficients"),
2469 (LogicalType::Segmentation, "Segmentation"),
2470 (LogicalType::Masks, "Mask"),
2471 (LogicalType::Classes, "Classes"),
2472 (LogicalType::Detection, "Detection"),
2473 (LogicalType::Detections, "Detection"),
2474 ];
2475 for (ty, expected) in cases {
2476 let out = logical_to_legacy_config_output(&sample_logical(ty)).unwrap();
2477 let name = match &out {
2478 ConfigOutput::Boxes(_) => "Boxes",
2479 ConfigOutput::Scores(_) => "Scores",
2480 ConfigOutput::Protos(_) => "Protos",
2481 ConfigOutput::MaskCoefficients(_) => "MaskCoefficients",
2482 ConfigOutput::Segmentation(_) => "Segmentation",
2483 ConfigOutput::Mask(_) => "Mask",
2484 ConfigOutput::Classes(_) => "Classes",
2485 ConfigOutput::Detection(_) => "Detection",
2486 };
2487 assert_eq!(name, expected, "type {ty:?}");
2488 }
2489 }
2490
2491 #[test]
2492 fn logical_to_legacy_rejects_objectness_and_landmarks() {
2493 for ty in [LogicalType::Objectness, LogicalType::Landmarks] {
2494 let err = logical_to_legacy_config_output(&sample_logical(ty)).unwrap_err();
2495 assert!(
2496 matches!(err, DecoderError::NotSupported(_)),
2497 "expected NotSupported for {ty:?}, got {err:?}"
2498 );
2499 }
2500 }
2501
2502 #[test]
2503 fn logical_to_legacy_rejects_typeless_output() {
2504 let mut logical = sample_logical(LogicalType::Boxes);
2505 logical.type_ = None;
2506 let err = logical_to_legacy_config_output(&logical).unwrap_err();
2507 assert!(matches!(err, DecoderError::InvalidConfig(_)));
2508 }
2509
2510 #[test]
2511 fn to_legacy_modelpack_boxes_preserves_padding_dim() {
2512 let j = r#"{
2517 "schema_version": 2,
2518 "outputs": [
2519 {"name":"boxes","type":"boxes",
2520 "shape":[1,1935,1,4],
2521 "dshape":[{"batch":1},{"num_boxes":1935},{"padding":1},{"box_coords":4}],
2522 "decoder":"modelpack"}
2523 ]
2524 }"#;
2525 let schema = SchemaV2::parse_json(j).unwrap();
2526 let legacy = schema.to_legacy_config_outputs().expect("lowers cleanly");
2527 let boxes = match &legacy.outputs[0] {
2528 crate::ConfigOutput::Boxes(b) => b,
2529 other => panic!("expected Boxes, got {other:?}"),
2530 };
2531 assert_eq!(boxes.shape, vec![1, 1935, 1, 4]);
2534 assert_eq!(
2535 boxes.dshape,
2536 vec![
2537 (DimName::Batch, 1),
2538 (DimName::NumBoxes, 1935),
2539 (DimName::Padding, 1),
2540 (DimName::BoxCoords, 4),
2541 ]
2542 );
2543 }
2544
2545 #[test]
2546 fn to_legacy_preserves_shape_for_v2_split_boxes_without_dshape() {
2547 let j = r#"{
2551 "schema_version": 2,
2552 "outputs": [
2553 {"name":"boxes","type":"boxes","shape":[1,4,8400],
2554 "dtype":"float32","decoder":"ultralytics","encoding":"direct"},
2555 {"name":"scores","type":"scores","shape":[1,80,8400],
2556 "dtype":"float32","decoder":"ultralytics","score_format":"per_class"}
2557 ]
2558 }"#;
2559 let schema = SchemaV2::parse_json(j).unwrap();
2560 let legacy = schema.to_legacy_config_outputs().expect("lowers cleanly");
2561 let boxes = match &legacy.outputs[0] {
2562 crate::ConfigOutput::Boxes(b) => b,
2563 other => panic!("expected Boxes, got {other:?}"),
2564 };
2565 assert_eq!(boxes.shape, vec![1, 4, 8400]);
2566 let scores = match &legacy.outputs[1] {
2567 crate::ConfigOutput::Scores(s) => s,
2568 other => panic!("expected Scores, got {other:?}"),
2569 };
2570 assert_eq!(scores.shape, vec![1, 80, 8400]);
2571 }
2572}