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!(
1578 err,
1579 edgefirst_tensor::Error::QuantizationInvalid { field: "axis", .. }
1580 ));
1581 }
1582
1583 #[test]
1587 fn quantization_to_tensor_per_tensor_ignores_a_redundant_axis() {
1588 let q = Quantization {
1589 scale: vec![0.5],
1590 zero_point: Some(vec![3]),
1591 axis: Some(0),
1592 dtype: Some(DType::Int8),
1593 };
1594 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1595 assert!(t.is_per_tensor());
1596 assert_eq!(t.scale(), &[0.5][..]);
1597 assert_eq!(t.zero_point(), Some(&[3][..]));
1598 }
1599
1600 #[test]
1601 fn quantization_to_tensor_symmetric_ignores_a_redundant_axis() {
1602 let q = Quantization {
1603 scale: vec![0.5],
1604 zero_point: None,
1605 axis: Some(0),
1606 dtype: Some(DType::Int8),
1607 };
1608 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1609 assert!(t.is_per_tensor());
1610 assert!(t.is_symmetric());
1611 assert_eq!(t.scale(), &[0.5][..]);
1612 }
1613
1614 #[test]
1618 fn quantization_to_tensor_empty_scale_errors_on_scale() {
1619 for (zero_point, axis) in [
1620 (None, None),
1621 (None, Some(0)),
1622 (Some(vec![]), Some(0)),
1623 (Some(vec![]), None),
1624 ] {
1625 let q = Quantization {
1626 scale: vec![],
1627 zero_point,
1628 axis,
1629 dtype: Some(DType::Int8),
1630 };
1631 let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
1632 assert!(
1633 matches!(
1634 err,
1635 edgefirst_tensor::Error::QuantizationInvalid { field: "scale", .. }
1636 ),
1637 "axis={axis:?} gave {err:?}"
1638 );
1639 }
1640 }
1641
1642 #[test]
1646 fn quantization_to_tensor_single_scale_with_many_zero_points_errors_on_scale() {
1647 for axis in [None, Some(0)] {
1648 let q = Quantization {
1649 scale: vec![0.5],
1650 zero_point: Some(vec![1, 2]),
1651 axis,
1652 dtype: Some(DType::Int8),
1653 };
1654 let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
1655 assert!(
1656 matches!(
1657 err,
1658 edgefirst_tensor::Error::QuantizationInvalid { field: "scale", .. }
1659 ),
1660 "axis={axis:?} gave {err:?}"
1661 );
1662 }
1663 }
1664
1665 #[test]
1666 fn per_channel_quantization_is_not_per_tensor() {
1667 let q = Quantization {
1668 scale: vec![0.1, 0.2, 0.3],
1669 zero_point: None,
1670 axis: Some(0),
1671 dtype: Some(DType::Int8),
1672 };
1673 assert!(q.is_per_channel());
1674 assert!(!q.is_per_tensor());
1675
1676 let per_tensor = Quantization {
1677 scale: vec![0.1],
1678 zero_point: None,
1679 axis: None,
1680 dtype: Some(DType::Int8),
1681 };
1682 assert!(per_tensor.is_per_tensor());
1683 assert!(!per_tensor.is_per_channel());
1684 }
1685
1686 #[test]
1690 fn zero_point_at_indexes_per_channel_zero_points() {
1691 let per_channel = Quantization {
1692 scale: vec![0.1, 0.2, 0.3],
1693 zero_point: Some(vec![10, 20, 30]),
1694 axis: Some(0),
1695 dtype: Some(DType::Int8),
1696 };
1697 assert_eq!(per_channel.zero_point_at(0), 10);
1698 assert_eq!(per_channel.zero_point_at(1), 20);
1699 assert_eq!(per_channel.zero_point_at(2), 30);
1700 assert_eq!(per_channel.zero_point_at(3), 0);
1702
1703 let shared = Quantization {
1704 scale: vec![0.1, 0.2, 0.3],
1705 zero_point: Some(vec![7]),
1706 axis: Some(0),
1707 dtype: Some(DType::Int8),
1708 };
1709 assert_eq!(shared.zero_point_at(0), 7);
1710 assert_eq!(shared.zero_point_at(2), 7);
1711
1712 let symmetric = Quantization {
1713 scale: vec![0.1],
1714 zero_point: None,
1715 axis: None,
1716 dtype: Some(DType::Int8),
1717 };
1718 assert_eq!(symmetric.zero_point_at(0), 0);
1719 }
1720
1721 #[test]
1722 fn logical_output_flat_tflite_boxes() {
1723 let j = r#"{
1725 "name": "boxes", "type": "boxes",
1726 "shape": [1, 64, 8400],
1727 "dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
1728 "dtype": "int8",
1729 "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1730 "decoder": "ultralytics",
1731 "encoding": "dfl",
1732 "normalized": true
1733 }"#;
1734 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1735 assert_eq!(lo.type_, Some(LogicalType::Boxes));
1736 assert_eq!(lo.encoding, Some(BoxEncoding::Dfl));
1737 assert_eq!(lo.normalized, Some(true));
1738 assert!(!lo.is_split());
1739 assert_eq!(lo.dtype, Some(DType::Int8));
1740 }
1741
1742 #[test]
1743 fn logical_output_hailo_per_scale_split() {
1744 let j = r#"{
1746 "name": "boxes", "type": "boxes",
1747 "shape": [1, 64, 8400],
1748 "encoding": "dfl", "decoder": "ultralytics", "normalized": true,
1749 "outputs": [
1750 {
1751 "name": "boxes_0", "type": "boxes",
1752 "stride": 8, "scale_index": 0,
1753 "shape": [1, 80, 80, 64],
1754 "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_features": 64}],
1755 "dtype": "uint8",
1756 "quantization": {"scale": 0.0234, "zero_point": 128, "dtype": "uint8"}
1757 }
1758 ]
1759 }"#;
1760 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1761 assert!(lo.is_split());
1762 assert_eq!(lo.outputs.len(), 1);
1763 let child = &lo.outputs[0];
1764 assert_eq!(child.name, "boxes_0");
1765 assert_eq!(child.type_, Some(PhysicalType::Boxes));
1766 assert_eq!(child.stride, Some(Stride::Square(8)));
1767 assert_eq!(child.scale_index, Some(0));
1768 assert_eq!(child.dtype, DType::Uint8);
1769 }
1770
1771 #[test]
1772 fn logical_output_ara2_xy_wh_channel_split() {
1773 let j = r#"{
1775 "name": "boxes", "type": "boxes",
1776 "shape": [1, 4, 8400, 1],
1777 "encoding": "direct", "decoder": "ultralytics", "normalized": true,
1778 "outputs": [
1779 {
1780 "name": "_model_22_Div_1_output_0", "type": "boxes_xy",
1781 "shape": [1, 2, 8400, 1],
1782 "dshape": [{"batch": 1}, {"box_coords": 2}, {"num_boxes": 8400}, {"padding": 1}],
1783 "dtype": "int16",
1784 "quantization": {"scale": 3.129e-5, "zero_point": 0, "dtype": "int16"}
1785 },
1786 {
1787 "name": "_model_22_Sub_1_output_0", "type": "boxes_wh",
1788 "shape": [1, 2, 8400, 1],
1789 "dshape": [{"batch": 1}, {"box_coords": 2}, {"num_boxes": 8400}, {"padding": 1}],
1790 "dtype": "int16",
1791 "quantization": {"scale": 3.149e-5, "zero_point": 0, "dtype": "int16"}
1792 }
1793 ]
1794 }"#;
1795 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1796 assert_eq!(lo.encoding, Some(BoxEncoding::Direct));
1797 assert_eq!(lo.outputs.len(), 2);
1798 assert_eq!(lo.outputs[0].type_, Some(PhysicalType::BoxesXy));
1799 assert_eq!(lo.outputs[1].type_, Some(PhysicalType::BoxesWh));
1800 assert!(lo.outputs[0].stride.is_none());
1801 assert!(lo.outputs[1].stride.is_none());
1802 }
1803
1804 #[test]
1805 fn logical_output_hailo_scores_sigmoid_applied() {
1806 let j = r#"{
1807 "name": "scores", "type": "scores",
1808 "shape": [1, 80, 8400],
1809 "decoder": "ultralytics", "score_format": "per_class",
1810 "outputs": [
1811 {
1812 "name": "scores_0", "type": "scores",
1813 "stride": 8, "scale_index": 0,
1814 "shape": [1, 80, 80, 80],
1815 "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_classes": 80}],
1816 "dtype": "uint8",
1817 "quantization": {"scale": 0.003922, "dtype": "uint8"},
1818 "activation_applied": "sigmoid"
1819 }
1820 ]
1821 }"#;
1822 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1823 assert_eq!(lo.score_format, Some(ScoreFormat::PerClass));
1824 let child = &lo.outputs[0];
1825 assert_eq!(child.activation_applied, Some(Activation::Sigmoid));
1826 assert!(child.activation_required.is_none());
1827 }
1828
1829 #[test]
1830 fn yolo26_end_to_end_detections() {
1831 let j = r#"{
1832 "schema_version": 2,
1833 "decoder_version": "yolo26",
1834 "outputs": [{
1835 "name": "output0", "type": "detections",
1836 "shape": [1, 100, 6],
1837 "dshape": [{"batch": 1}, {"num_boxes": 100}, {"num_features": 6}],
1838 "dtype": "int8",
1839 "quantization": {"scale": 0.0078, "zero_point": 0, "dtype": "int8"},
1840 "normalized": false,
1841 "decoder": "ultralytics"
1842 }]
1843 }"#;
1844 let s: SchemaV2 = serde_json::from_str(j).unwrap();
1845 assert_eq!(s.decoder_version, Some(DecoderVersion::Yolo26));
1846 assert!(s.decoder_version.unwrap().is_end_to_end());
1847 assert_eq!(s.outputs[0].type_, Some(LogicalType::Detections));
1848 assert_eq!(s.outputs[0].normalized, Some(false));
1849 assert!(s.nms.is_none());
1850 }
1851
1852 #[test]
1853 fn modelpack_anchor_detection_with_rect_stride() {
1854 let j = r#"{
1855 "schema_version": 2,
1856 "outputs": [{
1857 "name": "output_0", "type": "detection",
1858 "shape": [1, 40, 40, 54],
1859 "dshape": [{"batch": 1}, {"height": 40}, {"width": 40}, {"num_anchors_x_features": 54}],
1860 "dtype": "uint8",
1861 "quantization": {"scale": 0.176, "zero_point": 198, "dtype": "uint8"},
1862 "decoder": "modelpack",
1863 "encoding": "anchor",
1864 "stride": [16, 16],
1865 "anchors": [[0.054, 0.065], [0.089, 0.139], [0.195, 0.196]]
1866 }]
1867 }"#;
1868 let s: SchemaV2 = serde_json::from_str(j).unwrap();
1869 let lo = &s.outputs[0];
1870 assert_eq!(lo.encoding, Some(BoxEncoding::Anchor));
1871 assert_eq!(lo.stride, Some(Stride::Rect([16, 16])));
1872 assert_eq!(lo.anchors.as_ref().map(|a| a.len()), Some(3));
1873 }
1874
1875 #[test]
1876 fn yolov5_obj_x_class_objectness_logical() {
1877 let j = r#"{
1878 "name": "objectness", "type": "objectness",
1879 "shape": [1, 3, 8400],
1880 "decoder": "ultralytics",
1881 "outputs": [{
1882 "name": "objectness_0", "type": "objectness",
1883 "stride": 8, "scale_index": 0,
1884 "shape": [1, 80, 80, 3],
1885 "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_features": 3}],
1886 "dtype": "uint8",
1887 "quantization": {"scale": 0.0039, "zero_point": 0, "dtype": "uint8"},
1888 "activation_applied": "sigmoid"
1889 }]
1890 }"#;
1891 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1892 assert_eq!(lo.type_, Some(LogicalType::Objectness));
1893 assert_eq!(lo.outputs[0].activation_applied, Some(Activation::Sigmoid));
1894 }
1895
1896 #[test]
1897 fn direct_protos_no_decoder() {
1898 let j = r#"{
1900 "name": "protos", "type": "protos",
1901 "shape": [1, 32, 160, 160],
1902 "dshape": [{"batch": 1}, {"num_protos": 32}, {"height": 160}, {"width": 160}],
1903 "dtype": "uint8",
1904 "quantization": {"scale": 0.0203, "zero_point": 45, "dtype": "uint8"},
1905 "stride": 4
1906 }"#;
1907 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1908 assert_eq!(lo.type_, Some(LogicalType::Protos));
1909 assert!(lo.decoder.is_none());
1910 assert_eq!(lo.stride, Some(Stride::Square(4)));
1911 }
1912
1913 #[test]
1914 fn full_yolov8_tflite_flat_detection() {
1915 let j = r#"{
1917 "schema_version": 2,
1918 "decoder_version": "yolov8",
1919 "nms": "class_agnostic",
1920 "input": { "shape": [1, 640, 640, 3], "cameraadaptor": "rgb" },
1921 "outputs": [
1922 {
1923 "name": "boxes", "type": "boxes",
1924 "shape": [1, 64, 8400],
1925 "dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
1926 "dtype": "int8",
1927 "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1928 "decoder": "ultralytics",
1929 "encoding": "dfl",
1930 "normalized": true
1931 },
1932 {
1933 "name": "scores", "type": "scores",
1934 "shape": [1, 80, 8400],
1935 "dshape": [{"batch": 1}, {"num_classes": 80}, {"num_boxes": 8400}],
1936 "dtype": "int8",
1937 "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1938 "decoder": "ultralytics",
1939 "score_format": "per_class"
1940 }
1941 ]
1942 }"#;
1943 let s: SchemaV2 = serde_json::from_str(j).unwrap();
1944 assert_eq!(s.schema_version, 2);
1945 assert_eq!(s.decoder_version, Some(DecoderVersion::Yolov8));
1946 assert_eq!(s.nms, Some(NmsMode::ClassAgnostic));
1947 assert_eq!(s.input.as_ref().unwrap().shape, vec![1, 640, 640, 3]);
1948 assert_eq!(s.outputs.len(), 2);
1949 }
1950
1951 #[test]
1952 fn schema_unknown_version_parses_without_validation() {
1953 let j = r#"{"schema_version": 99, "outputs": []}"#;
1956 let s: SchemaV2 = serde_json::from_str(j).unwrap();
1957 assert_eq!(s.schema_version, 99);
1958 }
1959
1960 #[test]
1961 fn serde_roundtrip_preserves_fields() {
1962 let original = SchemaV2 {
1963 schema_version: 2,
1964 input: Some(InputSpec {
1965 shape: vec![1, 3, 640, 640],
1966 dshape: vec![],
1967 cameraadaptor: Some("rgb".into()),
1968 }),
1969 outputs: vec![LogicalOutput {
1970 name: Some("boxes".into()),
1971 type_: Some(LogicalType::Boxes),
1972 shape: vec![1, 4, 8400],
1973 dshape: vec![],
1974 decoder: Some(DecoderKind::Ultralytics),
1975 encoding: Some(BoxEncoding::Direct),
1976 score_format: None,
1977 normalized: Some(true),
1978 anchors: None,
1979 stride: None,
1980 dtype: Some(DType::Float32),
1981 quantization: None,
1982 outputs: vec![],
1983 activation_applied: None,
1984 activation_required: None,
1985 }],
1986 nms: Some(NmsMode::ClassAgnostic),
1987 decoder_version: Some(DecoderVersion::Yolov8),
1988 };
1989 let j = serde_json::to_string(&original).unwrap();
1990 let parsed: SchemaV2 = serde_json::from_str(&j).unwrap();
1991 assert_eq!(parsed, original);
1992 }
1993
1994 #[test]
1997 fn parse_v1_yaml_yolov8_seg_testdata() {
1998 let yaml = edgefirst_bench::testdata::read_to_string("yolov8_seg.yaml");
1999 let schema = SchemaV2::parse_yaml(&yaml).expect("parse v1 yaml");
2000 assert_eq!(schema.schema_version, 2);
2001 assert_eq!(schema.outputs.len(), 2);
2002 let det = &schema.outputs[0];
2004 assert_eq!(det.type_, Some(LogicalType::Detection));
2005 assert_eq!(det.shape, vec![1, 116, 8400]);
2006 assert_eq!(det.decoder, Some(DecoderKind::Ultralytics));
2007 assert_eq!(det.encoding, Some(BoxEncoding::Direct));
2008 let q = det.quantization.as_ref().unwrap();
2009 assert_eq!(q.scale.len(), 1);
2010 assert!((q.scale[0] - 0.021_287_762).abs() < 1e-6);
2011 assert_eq!(q.zero_point, Some(vec![31]));
2012 let protos = &schema.outputs[1];
2014 assert_eq!(protos.type_, Some(LogicalType::Protos));
2015 assert_eq!(protos.shape, vec![1, 160, 160, 32]);
2016 }
2017
2018 #[test]
2019 fn parse_v1_json_modelpack_split_testdata() {
2020 let json = edgefirst_bench::testdata::read_to_string("modelpack_split.json");
2021 let schema = SchemaV2::parse_json(&json).expect("parse v1 json");
2022 assert_eq!(schema.schema_version, 2);
2023 assert_eq!(schema.outputs.len(), 2);
2024 for out in &schema.outputs {
2026 assert_eq!(out.type_, Some(LogicalType::Detection));
2027 assert_eq!(out.decoder, Some(DecoderKind::ModelPack));
2028 assert_eq!(out.encoding, Some(BoxEncoding::Anchor));
2029 assert_eq!(out.anchors.as_ref().map(|a| a.len()), Some(3));
2030 }
2031 }
2032
2033 #[test]
2034 fn parse_v2_json_direct_when_schema_version_present() {
2035 let j = r#"{
2036 "schema_version": 2,
2037 "outputs": [{
2038 "name": "boxes", "type": "boxes",
2039 "shape": [1, 4, 8400],
2040 "dshape": [{"batch": 1}, {"box_coords": 4}, {"num_boxes": 8400}],
2041 "dtype": "float32",
2042 "decoder": "ultralytics",
2043 "encoding": "direct",
2044 "normalized": true
2045 }]
2046 }"#;
2047 let schema = SchemaV2::parse_json(j).unwrap();
2048 assert_eq!(schema.schema_version, 2);
2049 assert_eq!(schema.outputs[0].type_, Some(LogicalType::Boxes));
2050 }
2051
2052 #[test]
2053 fn parse_rejects_future_schema_version() {
2054 let j = r#"{"schema_version": 99, "outputs": []}"#;
2055 let err = SchemaV2::parse_json(j).unwrap_err();
2056 matches!(err, DecoderError::NotSupported(_));
2057 }
2058
2059 #[test]
2060 fn parse_absent_schema_version_treats_as_v1() {
2061 let j = r#"{
2063 "outputs": [
2064 {
2065 "type": "boxes", "decoder": "ultralytics",
2066 "shape": [1, 4, 8400],
2067 "quantization": [0.00392, 0]
2068 },
2069 {
2070 "type": "scores", "decoder": "ultralytics",
2071 "shape": [1, 80, 8400],
2072 "quantization": [0.00392, 0]
2073 }
2074 ]
2075 }"#;
2076 let schema = SchemaV2::parse_json(j).expect("v1 legacy parse");
2077 assert_eq!(schema.schema_version, 2); assert_eq!(schema.outputs.len(), 2);
2079 assert_eq!(schema.outputs[0].type_, Some(LogicalType::Boxes));
2080 assert_eq!(schema.outputs[1].type_, Some(LogicalType::Scores));
2081 assert_eq!(schema.outputs[1].score_format, Some(ScoreFormat::PerClass));
2083 }
2084
2085 #[test]
2086 fn from_v1_preserves_nms_and_decoder_version() {
2087 let v1 = ConfigOutputs {
2088 outputs: vec![ConfigOutput::Boxes(crate::configs::Boxes {
2089 decoder: crate::configs::DecoderType::Ultralytics,
2090 quantization: Some(crate::configs::QuantTuple(0.01, 5)),
2091 shape: vec![1, 4, 8400],
2092 dshape: vec![],
2093 normalized: Some(true),
2094 })],
2095 nms: Some(crate::configs::Nms::ClassAware),
2096 decoder_version: Some(crate::configs::DecoderVersion::Yolo11),
2097 };
2098 let v2 = SchemaV2::from_v1(&v1).unwrap();
2099 assert_eq!(v2.nms, Some(NmsMode::ClassAware));
2100 assert_eq!(v2.decoder_version, Some(DecoderVersion::Yolo11));
2101 assert_eq!(v2.outputs[0].normalized, Some(true));
2102 let q = v2.outputs[0].quantization.as_ref().unwrap();
2103 assert_eq!(q.scale, vec![0.01]);
2104 assert_eq!(q.zero_point, Some(vec![5]));
2105 assert_eq!(q.dtype, None); }
2107
2108 #[test]
2115 fn typeless_logical_output_parses_and_roundtrips() {
2116 let j = r#"{
2117 "schema_version": 2,
2118 "outputs": [
2119 {
2120 "name": "extra_telemetry",
2121 "shape": [1, 16]
2122 },
2123 {
2124 "name": "boxes",
2125 "type": "boxes",
2126 "shape": [1, 4, 8400]
2127 }
2128 ]
2129 }"#;
2130 let schema: SchemaV2 = serde_json::from_str(j).unwrap();
2131 assert_eq!(schema.outputs.len(), 2);
2132 assert_eq!(schema.outputs[0].type_, None);
2133 assert_eq!(schema.outputs[0].name.as_deref(), Some("extra_telemetry"));
2134 assert_eq!(schema.outputs[1].type_, Some(LogicalType::Boxes));
2135
2136 let round = serde_json::to_string(&schema).unwrap();
2138 let first_obj = round
2139 .split("\"outputs\":[")
2140 .nth(1)
2141 .and_then(|s| s.split("}").next())
2142 .expect("outputs array");
2143 assert!(
2144 !first_obj.contains("\"type\""),
2145 "typeless output must not serialize a `type` field, got: {first_obj}"
2146 );
2147 }
2148
2149 #[test]
2155 fn typeless_outputs_filtered_from_legacy_config() {
2156 let schema = SchemaV2 {
2157 schema_version: 2,
2158 input: None,
2159 outputs: vec![
2160 LogicalOutput {
2161 name: Some("diagnostic_histogram".into()),
2162 type_: None,
2163 shape: vec![1, 256],
2164 dshape: vec![],
2165 decoder: None,
2166 encoding: None,
2167 score_format: None,
2168 normalized: None,
2169 anchors: None,
2170 stride: None,
2171 dtype: None,
2172 quantization: None,
2173 outputs: vec![],
2174 activation_applied: None,
2175 activation_required: None,
2176 },
2177 LogicalOutput {
2178 name: Some("boxes".into()),
2179 type_: Some(LogicalType::Boxes),
2180 shape: vec![1, 4, 8400],
2181 dshape: vec![],
2182 decoder: Some(DecoderKind::Ultralytics),
2183 encoding: Some(BoxEncoding::Direct),
2184 score_format: None,
2185 normalized: Some(true),
2186 anchors: None,
2187 stride: None,
2188 dtype: None,
2189 quantization: None,
2190 outputs: vec![],
2191 activation_applied: None,
2192 activation_required: None,
2193 },
2194 ],
2195 nms: None,
2196 decoder_version: None,
2197 };
2198 let legacy = schema.to_legacy_config_outputs().unwrap();
2199 assert_eq!(
2200 legacy.outputs.len(),
2201 1,
2202 "typeless output must be filtered from legacy config"
2203 );
2204 assert!(
2205 matches!(legacy.outputs[0], ConfigOutput::Boxes(_)),
2206 "only the typed `boxes` output should survive lowering"
2207 );
2208 }
2209
2210 #[test]
2215 fn all_typeless_schema_produces_empty_legacy_config() {
2216 let schema = SchemaV2 {
2217 schema_version: 2,
2218 input: None,
2219 outputs: vec![LogicalOutput {
2220 name: Some("aux".into()),
2221 type_: None,
2222 shape: vec![1, 8],
2223 dshape: vec![],
2224 decoder: None,
2225 encoding: None,
2226 score_format: None,
2227 normalized: None,
2228 anchors: None,
2229 stride: None,
2230 dtype: None,
2231 quantization: None,
2232 outputs: vec![],
2233 activation_applied: None,
2234 activation_required: None,
2235 }],
2236 nms: None,
2237 decoder_version: None,
2238 };
2239 let legacy = schema.to_legacy_config_outputs().unwrap();
2240 assert!(legacy.outputs.is_empty());
2241 }
2242
2243 #[test]
2249 fn typeless_physical_child_parses_and_skips_uniqueness() {
2250 let j = r#"{
2251 "name": "boxes",
2252 "type": "boxes",
2253 "shape": [1, 8400, 4],
2254 "outputs": [
2255 {
2256 "name": "boxes_xy",
2257 "type": "boxes_xy",
2258 "shape": [1, 8400, 2],
2259 "dtype": "float32"
2260 },
2261 {
2262 "name": "aux_user_managed",
2263 "shape": [1, 8400, 2],
2264 "dtype": "float32"
2265 }
2266 ]
2267 }"#;
2268 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2269 assert_eq!(lo.outputs.len(), 2);
2270 assert_eq!(lo.outputs[0].type_, Some(PhysicalType::BoxesXy));
2271 assert_eq!(lo.outputs[1].type_, None);
2272
2273 let schema = SchemaV2 {
2277 schema_version: 2,
2278 input: None,
2279 outputs: vec![lo],
2280 nms: None,
2281 decoder_version: None,
2282 };
2283 schema.validate().expect(
2284 "typed + typeless children with equal shape must not trigger \
2285 uniqueness error",
2286 );
2287
2288 let s = serde_json::to_string(&schema).unwrap();
2290 assert!(
2291 s.contains("\"aux_user_managed\""),
2292 "typeless child must survive round-trip: {s}"
2293 );
2294 let aux_obj = s
2296 .split("\"aux_user_managed\"")
2297 .nth(1)
2298 .and_then(|s| s.split('}').next())
2299 .unwrap_or("");
2300 assert!(
2301 !aux_obj.contains("\"type\""),
2302 "typeless child must not serialize `type`, got: {aux_obj}"
2303 );
2304 }
2305
2306 #[test]
2307 fn from_v1_modelpack_anchor_detection_maps_encoding() {
2308 let v1 = ConfigOutputs {
2309 outputs: vec![ConfigOutput::Detection(crate::configs::Detection {
2310 anchors: Some(vec![[0.1, 0.2], [0.3, 0.4]]),
2311 decoder: crate::configs::DecoderType::ModelPack,
2312 quantization: Some(crate::configs::QuantTuple(0.176, 198)),
2313 shape: vec![1, 40, 40, 54],
2314 dshape: vec![],
2315 normalized: None,
2316 })],
2317 nms: None,
2318 decoder_version: None,
2319 };
2320 let v2 = SchemaV2::from_v1(&v1).unwrap();
2321 assert_eq!(v2.outputs[0].encoding, Some(BoxEncoding::Anchor));
2322 assert_eq!(v2.outputs[0].decoder, Some(DecoderKind::ModelPack));
2323 assert_eq!(v2.outputs[0].anchors.as_ref().map(|a| a.len()), Some(2));
2324 }
2325
2326 #[test]
2329 fn validate_accepts_flat_v2_yolov8_detection() {
2330 let j = r#"{
2331 "schema_version": 2,
2332 "outputs": [
2333 {"name":"boxes","type":"boxes","shape":[1,64,8400],
2334 "dtype":"int8","decoder":"ultralytics","encoding":"dfl"},
2335 {"name":"scores","type":"scores","shape":[1,80,8400],
2336 "dtype":"int8","decoder":"ultralytics","score_format":"per_class"}
2337 ]
2338 }"#;
2339 SchemaV2::parse_json(j).unwrap().validate().unwrap();
2340 }
2341
2342 #[test]
2343 fn validate_rejects_unnamed_physical_child() {
2344 let j = r#"{
2345 "schema_version": 2,
2346 "outputs": [{
2347 "name":"boxes","type":"boxes","shape":[1,64,8400],
2348 "encoding":"dfl","decoder":"ultralytics",
2349 "outputs": [{
2350 "name":"","type":"boxes","stride":8,
2351 "shape":[1,80,80,64],"dtype":"uint8"
2352 }]
2353 }]
2354 }"#;
2355 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2356 let msg = format!("{err}");
2357 assert!(msg.contains("missing `name`"), "got: {msg}");
2358 }
2359
2360 #[test]
2361 fn validate_rejects_duplicate_physical_shapes() {
2362 let j = r#"{
2363 "schema_version": 2,
2364 "outputs": [{
2365 "name":"boxes","type":"boxes","shape":[1,64,8400],
2366 "encoding":"dfl","decoder":"ultralytics",
2367 "outputs": [
2368 {"name":"a","type":"boxes","stride":8,"shape":[1,80,80,64],"dtype":"uint8"},
2369 {"name":"b","type":"boxes","stride":16,"shape":[1,80,80,64],"dtype":"uint8"}
2370 ]
2371 }]
2372 }"#;
2373 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2374 let msg = format!("{err}");
2375 assert!(msg.contains("share shape"), "got: {msg}");
2376 }
2377
2378 #[test]
2379 fn validate_rejects_mixed_decomposition() {
2380 let j = r#"{
2382 "schema_version": 2,
2383 "outputs": [{
2384 "name":"boxes","type":"boxes","shape":[1,4,8400,1],
2385 "encoding":"direct","decoder":"ultralytics",
2386 "outputs": [
2387 {"name":"xy","type":"boxes_xy","shape":[1,2,8400,1],"dtype":"int16"},
2388 {"name":"p0","type":"boxes","stride":8,"shape":[1,80,80,64],"dtype":"uint8"}
2389 ]
2390 }]
2391 }"#;
2392 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2393 let msg = format!("{err}");
2394 assert!(msg.contains("uniform"), "got: {msg}");
2395 }
2396
2397 #[test]
2398 fn validate_rejects_dfl_boxes_feature_not_divisible_by_4() {
2399 let j = r#"{
2400 "schema_version": 2,
2401 "outputs": [{
2402 "name":"boxes","type":"boxes","shape":[1,63,8400],
2403 "encoding":"dfl","decoder":"ultralytics",
2404 "outputs": [{
2405 "name":"b0","type":"boxes","stride":8,
2406 "shape":[1,80,80,63],
2407 "dshape":[{"batch":1},{"height":80},{"width":80},{"num_features":63}],
2408 "dtype":"uint8"
2409 }]
2410 }]
2411 }"#;
2412 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2413 let msg = format!("{err}");
2414 assert!(msg.contains("not"), "got: {msg}");
2415 assert!(msg.contains("divisible by 4"), "got: {msg}");
2416 }
2417
2418 #[test]
2419 fn validate_accepts_hailo_per_scale_yolov8() {
2420 let j = r#"{
2421 "schema_version": 2,
2422 "outputs": [{
2423 "name":"boxes","type":"boxes","shape":[1,64,8400],
2424 "encoding":"dfl","decoder":"ultralytics","normalized":true,
2425 "outputs": [
2426 {"name":"b0","type":"boxes","stride":8,
2427 "shape":[1,80,80,64],
2428 "dshape":[{"batch":1},{"height":80},{"width":80},{"num_features":64}],
2429 "dtype":"uint8",
2430 "quantization":{"scale":0.0234,"zero_point":128,"dtype":"uint8"}},
2431 {"name":"b1","type":"boxes","stride":16,
2432 "shape":[1,40,40,64],
2433 "dshape":[{"batch":1},{"height":40},{"width":40},{"num_features":64}],
2434 "dtype":"uint8",
2435 "quantization":{"scale":0.0198,"zero_point":130,"dtype":"uint8"}},
2436 {"name":"b2","type":"boxes","stride":32,
2437 "shape":[1,20,20,64],
2438 "dshape":[{"batch":1},{"height":20},{"width":20},{"num_features":64}],
2439 "dtype":"uint8",
2440 "quantization":{"scale":0.0312,"zero_point":125,"dtype":"uint8"}}
2441 ]
2442 }]
2443 }"#;
2444 let s = SchemaV2::parse_json(j).unwrap();
2445 s.validate().unwrap();
2446 }
2447
2448 #[test]
2449 fn validate_accepts_ara2_xy_wh() {
2450 let j = r#"{
2451 "schema_version": 2,
2452 "outputs": [{
2453 "name":"boxes","type":"boxes","shape":[1,4,8400,1],
2454 "encoding":"direct","decoder":"ultralytics","normalized":true,
2455 "outputs": [
2456 {"name":"xy","type":"boxes_xy","shape":[1,2,8400,1],
2457 "dshape":[{"batch":1},{"box_coords":2},{"num_boxes":8400},{"padding":1}],
2458 "dtype":"int16",
2459 "quantization":{"scale":3.1e-5,"zero_point":0,"dtype":"int16"}},
2460 {"name":"wh","type":"boxes_wh","shape":[1,2,8400,1],
2461 "dshape":[{"batch":1},{"box_coords":2},{"num_boxes":8400},{"padding":1}],
2462 "dtype":"int16",
2463 "quantization":{"scale":3.2e-5,"zero_point":0,"dtype":"int16"}}
2464 ]
2465 }]
2466 }"#;
2467 SchemaV2::parse_json(j).unwrap().validate().unwrap();
2468 }
2469
2470 #[test]
2471 fn parse_file_auto_detects_json() {
2472 let tmp = std::env::temp_dir().join(format!("schema_v2_test_{}.json", std::process::id()));
2473 std::fs::write(&tmp, r#"{"schema_version":2,"outputs":[]}"#).unwrap();
2474 let s = SchemaV2::parse_file(&tmp).unwrap();
2475 assert_eq!(s.schema_version, 2);
2476 let _ = std::fs::remove_file(&tmp);
2477 }
2478
2479 #[test]
2480 fn parse_file_auto_detects_yaml() {
2481 let tmp = std::env::temp_dir().join(format!("schema_v2_test_{}.yaml", std::process::id()));
2482 std::fs::write(&tmp, "schema_version: 2\noutputs: []\n").unwrap();
2483 let s = SchemaV2::parse_file(&tmp).unwrap();
2484 assert_eq!(s.schema_version, 2);
2485 let _ = std::fs::remove_file(&tmp);
2486 }
2487
2488 #[test]
2491 fn parse_real_ara2_int8_dvm_metadata() {
2492 let json = edgefirst_bench::testdata::read_to_string("ara2_int8_edgefirst.json");
2493 let schema = SchemaV2::parse_json(&json).expect("ARA-2 int8 parse");
2494 assert_eq!(schema.schema_version, 2);
2495 assert_eq!(schema.decoder_version, Some(DecoderVersion::Yolov8));
2496 assert_eq!(schema.nms, Some(NmsMode::ClassAgnostic));
2497 assert_eq!(schema.input.as_ref().unwrap().shape, vec![1, 3, 640, 640]);
2498
2499 assert_eq!(schema.outputs.len(), 4);
2501 let boxes = &schema.outputs[0];
2502 assert_eq!(boxes.type_, Some(LogicalType::Boxes));
2503 assert_eq!(boxes.encoding, Some(BoxEncoding::Direct));
2504 assert_eq!(boxes.normalized, Some(true));
2505 assert_eq!(boxes.shape, vec![1, 4, 8400, 1]); assert_eq!(boxes.outputs.len(), 2);
2507 assert_eq!(boxes.outputs[0].type_, Some(PhysicalType::BoxesXy));
2508 assert_eq!(boxes.outputs[1].type_, Some(PhysicalType::BoxesWh));
2509 let q_xy = boxes.outputs[0].quantization.as_ref().unwrap();
2511 assert_eq!(q_xy.dtype, Some(DType::Int8));
2512 assert!((q_xy.scale[0] - 0.004_177_792).abs() < 1e-6);
2513 assert_eq!(q_xy.zero_point_at(0), -122);
2514
2515 let scores = &schema.outputs[1];
2516 assert_eq!(scores.type_, Some(LogicalType::Scores));
2517 assert_eq!(scores.score_format, Some(ScoreFormat::PerClass));
2518 assert_eq!(scores.shape, vec![1, 80, 8400, 1]);
2519
2520 let mask_coefs = &schema.outputs[2];
2521 assert_eq!(mask_coefs.type_, Some(LogicalType::MaskCoefs));
2522 assert_eq!(mask_coefs.shape, vec![1, 32, 8400, 1]);
2523
2524 let protos = &schema.outputs[3];
2525 assert_eq!(protos.type_, Some(LogicalType::Protos));
2526 assert_eq!(protos.shape, vec![1, 32, 160, 160]);
2527
2528 schema.validate().expect("ARA-2 int8 validate");
2530 }
2531
2532 #[test]
2533 fn parse_real_ara2_int16_dvm_metadata() {
2534 let json = edgefirst_bench::testdata::read_to_string("ara2_int16_edgefirst.json");
2535 let schema = SchemaV2::parse_json(&json).expect("ARA-2 int16 parse");
2536 assert_eq!(schema.schema_version, 2);
2537 assert_eq!(schema.outputs.len(), 4);
2538 let boxes = &schema.outputs[0];
2539 assert_eq!(boxes.outputs.len(), 2);
2540 let q_xy = boxes.outputs[0].quantization.as_ref().unwrap();
2541 assert_eq!(q_xy.dtype, Some(DType::Int16));
2542 assert!((q_xy.scale[0] - 3.211_570_6e-5).abs() < 1e-10);
2543 assert_eq!(q_xy.zero_point_at(0), 0);
2544 let mc_q = schema.outputs[2].quantization.as_ref().unwrap();
2546 assert_eq!(mc_q.dtype, Some(DType::Int16));
2547 schema.validate().expect("ARA-2 int16 validate");
2548 }
2549
2550 #[test]
2551 fn parse_yaml_with_explicit_schema_version_2() {
2552 let yaml = r#"
2553schema_version: 2
2554outputs:
2555 - name: scores
2556 type: scores
2557 shape: [1, 80, 8400]
2558 dtype: int8
2559 quantization:
2560 scale: 0.00392
2561 dtype: int8
2562 decoder: ultralytics
2563 score_format: per_class
2564"#;
2565 let schema = SchemaV2::parse_yaml(yaml).unwrap();
2566 assert_eq!(schema.schema_version, 2);
2567 assert_eq!(schema.outputs[0].score_format, Some(ScoreFormat::PerClass));
2568 }
2569
2570 #[test]
2573 fn squeeze_padding_dims_preserves_shape_when_dshape_absent() {
2574 let (shape, dshape) = squeeze_padding_dims(vec![1, 4, 8400], vec![]);
2579 assert_eq!(shape, vec![1, 4, 8400]);
2580 assert!(dshape.is_empty());
2581 }
2582
2583 fn sample_logical(ty: LogicalType) -> LogicalOutput {
2584 LogicalOutput {
2585 name: Some("sample".into()),
2586 type_: Some(ty),
2587 shape: vec![1, 4, 100],
2588 dshape: vec![],
2589 decoder: None,
2590 encoding: None,
2591 score_format: None,
2592 normalized: Some(true),
2593 anchors: None,
2594 stride: None,
2595 dtype: None,
2596 quantization: None,
2597 outputs: vec![],
2598 activation_applied: None,
2599 activation_required: None,
2600 }
2601 }
2602
2603 #[test]
2604 fn logical_to_legacy_maps_each_supported_type() {
2605 let cases = [
2606 (LogicalType::Boxes, "Boxes"),
2607 (LogicalType::Scores, "Scores"),
2608 (LogicalType::Protos, "Protos"),
2609 (LogicalType::MaskCoefs, "MaskCoefficients"),
2610 (LogicalType::Segmentation, "Segmentation"),
2611 (LogicalType::Masks, "Mask"),
2612 (LogicalType::Classes, "Classes"),
2613 (LogicalType::Detection, "Detection"),
2614 (LogicalType::Detections, "Detection"),
2615 ];
2616 for (ty, expected) in cases {
2617 let out = logical_to_legacy_config_output(&sample_logical(ty)).unwrap();
2618 let name = match &out {
2619 ConfigOutput::Boxes(_) => "Boxes",
2620 ConfigOutput::Scores(_) => "Scores",
2621 ConfigOutput::Protos(_) => "Protos",
2622 ConfigOutput::MaskCoefficients(_) => "MaskCoefficients",
2623 ConfigOutput::Segmentation(_) => "Segmentation",
2624 ConfigOutput::Mask(_) => "Mask",
2625 ConfigOutput::Classes(_) => "Classes",
2626 ConfigOutput::Detection(_) => "Detection",
2627 };
2628 assert_eq!(name, expected, "type {ty:?}");
2629 }
2630 }
2631
2632 #[test]
2633 fn logical_to_legacy_rejects_objectness_and_landmarks() {
2634 for ty in [LogicalType::Objectness, LogicalType::Landmarks] {
2635 let err = logical_to_legacy_config_output(&sample_logical(ty)).unwrap_err();
2636 assert!(
2637 matches!(err, DecoderError::NotSupported(_)),
2638 "expected NotSupported for {ty:?}, got {err:?}"
2639 );
2640 }
2641 }
2642
2643 #[test]
2644 fn logical_to_legacy_rejects_typeless_output() {
2645 let mut logical = sample_logical(LogicalType::Boxes);
2646 logical.type_ = None;
2647 let err = logical_to_legacy_config_output(&logical).unwrap_err();
2648 assert!(matches!(err, DecoderError::InvalidConfig(_)));
2649 }
2650
2651 #[test]
2652 fn to_legacy_modelpack_boxes_preserves_padding_dim() {
2653 let j = r#"{
2658 "schema_version": 2,
2659 "outputs": [
2660 {"name":"boxes","type":"boxes",
2661 "shape":[1,1935,1,4],
2662 "dshape":[{"batch":1},{"num_boxes":1935},{"padding":1},{"box_coords":4}],
2663 "decoder":"modelpack"}
2664 ]
2665 }"#;
2666 let schema = SchemaV2::parse_json(j).unwrap();
2667 let legacy = schema.to_legacy_config_outputs().expect("lowers cleanly");
2668 let boxes = match &legacy.outputs[0] {
2669 crate::ConfigOutput::Boxes(b) => b,
2670 other => panic!("expected Boxes, got {other:?}"),
2671 };
2672 assert_eq!(boxes.shape, vec![1, 1935, 1, 4]);
2675 assert_eq!(
2676 boxes.dshape,
2677 vec![
2678 (DimName::Batch, 1),
2679 (DimName::NumBoxes, 1935),
2680 (DimName::Padding, 1),
2681 (DimName::BoxCoords, 4),
2682 ]
2683 );
2684 }
2685
2686 #[test]
2687 fn to_legacy_preserves_shape_for_v2_split_boxes_without_dshape() {
2688 let j = r#"{
2692 "schema_version": 2,
2693 "outputs": [
2694 {"name":"boxes","type":"boxes","shape":[1,4,8400],
2695 "dtype":"float32","decoder":"ultralytics","encoding":"direct"},
2696 {"name":"scores","type":"scores","shape":[1,80,8400],
2697 "dtype":"float32","decoder":"ultralytics","score_format":"per_class"}
2698 ]
2699 }"#;
2700 let schema = SchemaV2::parse_json(j).unwrap();
2701 let legacy = schema.to_legacy_config_outputs().expect("lowers cleanly");
2702 let boxes = match &legacy.outputs[0] {
2703 crate::ConfigOutput::Boxes(b) => b,
2704 other => panic!("expected Boxes, got {other:?}"),
2705 };
2706 assert_eq!(boxes.shape, vec![1, 4, 8400]);
2707 let scores = match &legacy.outputs[1] {
2708 crate::ConfigOutput::Scores(s) => s,
2709 other => panic!("expected Scores, got {other:?}"),
2710 };
2711 assert_eq!(scores.shape, vec![1, 80, 8400]);
2712 }
2713}