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)]
74#[non_exhaustive]
75pub struct SchemaV2 {
76 pub schema_version: u32,
78
79 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub input: Option<InputSpec>,
86
87 #[serde(default, skip_serializing_if = "Vec::is_empty")]
90 pub outputs: Vec<LogicalOutput>,
91
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub nms: Option<NmsMode>,
95
96 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub nms_multi_label: Option<bool>,
100
101 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub decoder_version: Option<DecoderVersion>,
107}
108
109impl SchemaV2 {
110 pub fn new() -> Self {
112 Self::default()
113 }
114}
115
116impl Default for SchemaV2 {
117 fn default() -> Self {
118 Self {
119 schema_version: 2,
120 input: None,
121 outputs: Vec::new(),
122 nms: None,
123 nms_multi_label: None,
124 decoder_version: None,
125 }
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131pub struct InputSpec {
132 pub shape: Vec<usize>,
134
135 #[serde(
138 default,
139 deserialize_with = "deserialize_dshape",
140 skip_serializing_if = "Vec::is_empty"
141 )]
142 pub dshape: Vec<(DimName, usize)>,
143
144 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub cameraadaptor: Option<String>,
149}
150
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
159pub struct LogicalOutput {
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub name: Option<String>,
163
164 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
171 pub type_: Option<LogicalType>,
172
173 pub shape: Vec<usize>,
176
177 #[serde(
179 default,
180 deserialize_with = "deserialize_dshape",
181 skip_serializing_if = "Vec::is_empty"
182 )]
183 pub dshape: Vec<(DimName, usize)>,
184
185 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub decoder: Option<DecoderKind>,
189
190 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub encoding: Option<BoxEncoding>,
193
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub score_format: Option<ScoreFormat>,
197
198 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub normalized: Option<bool>,
204
205 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub anchors: Option<Vec<[f32; 2]>>,
209
210 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub stride: Option<Stride>,
215
216 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub dtype: Option<DType>,
220
221 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub quantization: Option<Quantization>,
225
226 #[serde(default, skip_serializing_if = "Vec::is_empty")]
230 pub outputs: Vec<PhysicalOutput>,
231
232 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub activation_applied: Option<Activation>,
241
242 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub activation_required: Option<Activation>,
252}
253
254impl LogicalOutput {
255 pub fn is_split(&self) -> bool {
258 !self.outputs.is_empty()
259 }
260}
261
262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
268pub struct PhysicalOutput {
269 pub name: String,
273
274 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
281 pub type_: Option<PhysicalType>,
282
283 pub shape: Vec<usize>,
285
286 #[serde(
289 default,
290 deserialize_with = "deserialize_dshape",
291 skip_serializing_if = "Vec::is_empty"
292 )]
293 pub dshape: Vec<(DimName, usize)>,
294
295 pub dtype: DType,
297
298 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub quantization: Option<Quantization>,
302
303 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub stride: Option<Stride>,
307
308 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub scale_index: Option<usize>,
312
313 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub activation_applied: Option<Activation>,
318
319 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub activation_required: Option<Activation>,
323}
324
325#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
331pub struct Quantization {
332 #[serde(deserialize_with = "deserialize_scalar_or_vec_f32")]
335 pub scale: Vec<f32>,
336
337 #[serde(
341 default,
342 deserialize_with = "deserialize_opt_scalar_or_vec_i32",
343 skip_serializing_if = "Option::is_none"
344 )]
345 pub zero_point: Option<Vec<i32>>,
346
347 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub axis: Option<usize>,
351
352 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub dtype: Option<DType>,
357}
358
359impl Quantization {
360 pub fn is_per_tensor(&self) -> bool {
362 self.scale.len() == 1
363 }
364
365 pub fn is_per_channel(&self) -> bool {
367 self.scale.len() > 1
368 }
369
370 pub fn is_symmetric(&self) -> bool {
372 match &self.zero_point {
373 None => true,
374 Some(zps) => zps.iter().all(|&z| z == 0),
375 }
376 }
377
378 pub fn zero_point_at(&self, channel: usize) -> i32 {
381 match &self.zero_point {
382 None => 0,
383 Some(zps) if zps.len() == 1 => zps[0],
384 Some(zps) => zps.get(channel).copied().unwrap_or(0),
385 }
386 }
387
388 pub fn scale_at(&self, channel: usize) -> f32 {
390 if self.scale.len() == 1 {
391 self.scale[0]
392 } else {
393 self.scale.get(channel).copied().unwrap_or(0.0)
394 }
395 }
396}
397
398impl TryFrom<&Quantization> for edgefirst_tensor::Quantization {
408 type Error = edgefirst_tensor::Error;
409
410 fn try_from(q: &Quantization) -> Result<Self, Self::Error> {
411 match (q.scale.as_slice(), q.zero_point.as_deref(), q.axis) {
412 ([scale], None, None) => Ok(Self::per_tensor_symmetric(*scale)),
414 ([scale], Some([zp]), None) => Ok(Self::per_tensor(*scale, *zp)),
416 ([scale], Some([zp]), Some(_)) => Ok(Self::per_tensor(*scale, *zp)),
418 ([scale], None, Some(_)) => Ok(Self::per_tensor_symmetric(*scale)),
419 (scales, None, Some(axis)) if scales.len() > 1 => {
421 Self::per_channel_symmetric(scales.to_vec(), axis)
422 }
423 (scales, Some(zps), Some(axis)) if scales.len() > 1 => {
424 Self::per_channel(scales.to_vec(), zps.to_vec(), axis)
425 }
426 (scales, _, None) if scales.len() > 1 => {
428 Err(edgefirst_tensor::Error::QuantizationInvalid {
429 field: "axis",
430 expected: "Some(axis) for per-channel".into(),
431 got: "None".into(),
432 })
433 }
434 _ => Err(edgefirst_tensor::Error::QuantizationInvalid {
435 field: "scale",
436 expected: "non-empty".into(),
437 got: format!("len={}", q.scale.len()),
438 }),
439 }
440 }
441}
442
443fn deserialize_scalar_or_vec_f32<'de, D>(de: D) -> Result<Vec<f32>, D::Error>
445where
446 D: serde::Deserializer<'de>,
447{
448 #[derive(Deserialize)]
449 #[serde(untagged)]
450 enum OneOrMany {
451 One(f32),
452 Many(Vec<f32>),
453 }
454 match OneOrMany::deserialize(de)? {
455 OneOrMany::One(v) => Ok(vec![v]),
456 OneOrMany::Many(vs) => Ok(vs),
457 }
458}
459
460fn deserialize_opt_scalar_or_vec_i32<'de, D>(de: D) -> Result<Option<Vec<i32>>, D::Error>
462where
463 D: serde::Deserializer<'de>,
464{
465 #[derive(Deserialize)]
466 #[serde(untagged)]
467 enum OneOrMany {
468 One(i32),
469 Many(Vec<i32>),
470 }
471 match Option::<OneOrMany>::deserialize(de)? {
472 None => Ok(None),
473 Some(OneOrMany::One(v)) => Ok(Some(vec![v])),
474 Some(OneOrMany::Many(vs)) => Ok(Some(vs)),
475 }
476}
477
478#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
481#[serde(untagged)]
482pub enum Stride {
483 Square(u32),
484 Rect([u32; 2]),
485}
486
487impl Stride {
488 pub fn x(self) -> u32 {
490 match self {
491 Stride::Square(s) => s,
492 Stride::Rect([sx, _]) => sx,
493 }
494 }
495
496 pub fn y(self) -> u32 {
498 match self {
499 Stride::Square(s) => s,
500 Stride::Rect([_, sy]) => sy,
501 }
502 }
503}
504
505#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
507#[serde(rename_all = "snake_case")]
508pub enum LogicalType {
509 Boxes,
511 Scores,
513 Objectness,
515 Classes,
517 MaskCoefs,
519 Protos,
521 Landmarks,
523 Detections,
525 Segmentation,
527 Masks,
529 Detection,
531}
532
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
539#[serde(rename_all = "snake_case")]
540pub enum PhysicalType {
541 Boxes,
542 Scores,
543 Objectness,
544 Classes,
545 MaskCoefs,
546 Protos,
547 Landmarks,
548 Detections,
549 Segmentation,
550 Masks,
551 Detection,
552 BoxesXy,
554 BoxesWh,
556}
557
558#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
560#[serde(rename_all = "snake_case")]
561pub enum BoxEncoding {
562 Dfl,
565 #[serde(alias = "ltrb")]
568 Direct,
569 Anchor,
572}
573
574#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
576#[serde(rename_all = "snake_case")]
577pub enum ScoreFormat {
578 PerClass,
581 ObjXClass,
585}
586
587#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
589#[serde(rename_all = "snake_case")]
590pub enum Activation {
591 Sigmoid,
592 Softmax,
593 Tanh,
594}
595
596#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
598pub enum DecoderKind {
599 #[serde(rename = "modelpack")]
601 ModelPack,
602 #[serde(rename = "ultralytics")]
604 Ultralytics,
605}
606
607#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
609#[serde(rename_all = "snake_case")]
610pub enum DecoderVersion {
611 Yolov5,
612 Yolov8,
613 Yolo11,
614 Yolo26,
615}
616
617impl DecoderVersion {
618 pub fn is_end_to_end(self) -> bool {
620 matches!(self, DecoderVersion::Yolo26)
621 }
622}
623
624#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
626#[serde(rename_all = "snake_case")]
627pub enum NmsMode {
628 ClassAgnostic,
630 ClassAware,
633}
634
635#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
637#[serde(rename_all = "snake_case")]
638pub enum DType {
639 Int8,
640 Uint8,
641 Int16,
642 Uint16,
643 Int32,
644 Uint32,
645 Float16,
646 Float32,
647}
648
649impl DType {
650 pub fn size_bytes(self) -> usize {
652 match self {
653 DType::Int8 | DType::Uint8 => 1,
654 DType::Int16 | DType::Uint16 | DType::Float16 => 2,
655 DType::Int32 | DType::Uint32 | DType::Float32 => 4,
656 }
657 }
658
659 pub fn is_integer(self) -> bool {
661 matches!(
662 self,
663 DType::Int8
664 | DType::Uint8
665 | DType::Int16
666 | DType::Uint16
667 | DType::Int32
668 | DType::Uint32
669 )
670 }
671
672 pub fn is_float(self) -> bool {
674 matches!(self, DType::Float16 | DType::Float32)
675 }
676}
677
678impl SchemaV2 {
683 pub fn parse_json(s: &str) -> DecoderResult<Self> {
691 let value: serde_json::Value = serde_json::from_str(s)?;
692 Self::from_json_value(value)
693 }
694
695 pub fn parse_yaml(s: &str) -> DecoderResult<Self> {
699 let value: serde_yaml::Value = serde_yaml::from_str(s)?;
700 let json = serde_json::to_value(value)
701 .map_err(|e| DecoderError::InvalidConfig(format!("yaml→json bridge failed: {e}")))?;
702 Self::from_json_value(json)
703 }
704
705 pub fn parse_file(path: impl AsRef<std::path::Path>) -> DecoderResult<Self> {
709 let path = path.as_ref();
710 let content = std::fs::read_to_string(path)
711 .map_err(|e| DecoderError::InvalidConfig(format!("read {}: {e}", path.display())))?;
712 let ext = path
713 .extension()
714 .and_then(|e| e.to_str())
715 .map(str::to_ascii_lowercase);
716 match ext.as_deref() {
717 Some("json") => Self::parse_json(&content),
718 Some("yaml") | Some("yml") => Self::parse_yaml(&content),
719 _ => Self::parse_json(&content).or_else(|_| Self::parse_yaml(&content)),
720 }
721 }
722
723 pub fn from_json_value(value: serde_json::Value) -> DecoderResult<Self> {
726 let version = value
727 .get("schema_version")
728 .and_then(|v| v.as_u64())
729 .map(|v| v as u32)
730 .unwrap_or(1);
731
732 if version > MAX_SUPPORTED_SCHEMA_VERSION {
733 return Err(DecoderError::NotSupported(format!(
734 "schema_version {version} is not supported by this HAL \
735 (maximum supported version is {MAX_SUPPORTED_SCHEMA_VERSION}); \
736 upgrade the HAL or downgrade the metadata"
737 )));
738 }
739
740 if version >= 2 {
741 serde_json::from_value(value).map_err(DecoderError::Json)
742 } else {
743 let v1: ConfigOutputs = serde_json::from_value(value).map_err(DecoderError::Json)?;
744 Self::from_v1(&v1)
745 }
746 }
747
748 pub fn from_v1(v1: &ConfigOutputs) -> DecoderResult<Self> {
764 let outputs = v1
765 .outputs
766 .iter()
767 .map(logical_from_v1)
768 .collect::<DecoderResult<Vec<_>>>()?;
769 Ok(SchemaV2 {
770 schema_version: 2,
771 input: None,
772 outputs,
773 nms: v1.nms.as_ref().and_then(NmsMode::from_v1),
774 nms_multi_label: v1.nms_multi_label,
775 decoder_version: v1.decoder_version.as_ref().map(DecoderVersion::from_v1),
776 })
777 }
778}
779
780impl SchemaV2 {
781 pub fn to_legacy_config_outputs(&self) -> DecoderResult<ConfigOutputs> {
808 let mut outputs = Vec::with_capacity(self.outputs.len());
809 for logical in &self.outputs {
810 if logical.type_.is_none() {
816 continue;
817 }
818 if logical.type_ == Some(LogicalType::Boxes)
825 && logical.encoding == Some(BoxEncoding::Dfl)
826 && logical.outputs.is_empty()
827 {
828 return Err(DecoderError::NotSupported(format!(
829 "`boxes` output `{}` has `encoding: dfl` on a flat \
830 logical (no per-scale children); the HAL's DFL \
831 decode kernel only runs inside the per-scale merge \
832 path. Split the boxes output into per-FPN-level \
833 children (Hailo convention) or pre-decode to 4 \
834 channels in the model graph (TFLite convention).",
835 logical.name.as_deref().unwrap_or("<anonymous>"),
836 )));
837 }
838 if let Some(q) = &logical.quantization {
839 if q.is_per_channel() {
840 return Err(DecoderError::NotSupported(format!(
841 "logical `{}` uses per-channel quantization \
842 (axis {:?}, {} scales); the v1 decoder only \
843 supports per-tensor quantization",
844 logical.name.as_deref().unwrap_or("<anonymous>"),
845 q.axis,
846 q.scale.len(),
847 )));
848 }
849 }
850 outputs.push(logical_to_legacy_config_output(logical)?);
851 }
852 Ok(ConfigOutputs {
853 outputs,
854 nms: self.nms.map(NmsMode::to_v1),
855 nms_multi_label: self.nms_multi_label,
856 decoder_version: self.decoder_version.map(|v| v.to_v1()),
857 })
858 }
859
860 pub fn validate(&self) -> DecoderResult<()> {
878 if self.schema_version == 0 || self.schema_version > MAX_SUPPORTED_SCHEMA_VERSION {
879 return Err(DecoderError::InvalidConfig(format!(
880 "schema_version {} outside supported range [1, {MAX_SUPPORTED_SCHEMA_VERSION}]",
881 self.schema_version
882 )));
883 }
884
885 for logical in &self.outputs {
886 validate_logical(logical)?;
887 }
888
889 Ok(())
890 }
891}
892
893fn validate_logical(logical: &LogicalOutput) -> DecoderResult<()> {
894 if logical.outputs.is_empty() {
895 return Ok(());
896 }
897
898 for child in &logical.outputs {
900 if child.name.is_empty() {
901 return Err(DecoderError::InvalidConfig(format!(
902 "physical child of logical `{}` is missing `name`; name is \
903 required for tensor binding",
904 logical.name.as_deref().unwrap_or("<anonymous>")
905 )));
906 }
907 }
908
909 for (i, a) in logical.outputs.iter().enumerate() {
918 for b in &logical.outputs[i + 1..] {
919 let (Some(ta), Some(tb)) = (a.type_, b.type_) else {
920 continue;
921 };
922 if a.shape == b.shape && ta == tb {
923 return Err(DecoderError::InvalidConfig(format!(
924 "physical children `{}` and `{}` share shape {:?} and \
925 type; tensor binding cannot be resolved",
926 a.name, b.name, a.shape
927 )));
928 }
929 }
930 }
931
932 let strided: Vec<_> = logical.outputs.iter().map(|c| c.stride.is_some()).collect();
936 let all_strided = strided.iter().all(|&b| b);
937 let none_strided = strided.iter().all(|&b| !b);
938 if !(all_strided || none_strided) {
939 return Err(DecoderError::InvalidConfig(format!(
940 "logical `{}` mixes per-scale children (with stride) and \
941 channel sub-split children (without stride); decomposition \
942 must be uniform",
943 logical.name.as_deref().unwrap_or("<anonymous>")
944 )));
945 }
946
947 if logical.type_ == Some(LogicalType::Boxes) && logical.encoding == Some(BoxEncoding::Dfl) {
950 for child in &logical.outputs {
951 if let Some(feat) = last_feature_axis(child) {
952 if feat % 4 != 0 {
953 return Err(DecoderError::InvalidConfig(format!(
954 "DFL boxes child `{}` feature axis {feat} is not \
955 divisible by 4 (reg_max×4)",
956 child.name
957 )));
958 }
959 }
960 }
961 }
962
963 Ok(())
964}
965
966pub(crate) fn last_feature_axis(child: &PhysicalOutput) -> Option<usize> {
969 for (name, size) in &child.dshape {
972 if matches!(
973 name,
974 DimName::NumFeatures
975 | DimName::NumClasses
976 | DimName::NumProtos
977 | DimName::BoxCoords
978 | DimName::NumAnchorsXFeatures
979 ) {
980 return Some(*size);
981 }
982 }
983 child.shape.last().copied()
984}
985
986fn quantization_from_v1(q: Option<QuantTuple>) -> Option<Quantization> {
987 q.map(|QuantTuple(scale, zp)| Quantization {
988 scale: vec![scale],
989 zero_point: Some(vec![zp]),
990 axis: None,
991 dtype: None,
992 })
993}
994
995fn logical_from_v1(v1: &ConfigOutput) -> DecoderResult<LogicalOutput> {
996 match v1 {
997 ConfigOutput::Detection(d) => {
998 let encoding = match (d.decoder, d.anchors.is_some()) {
1004 (configs::DecoderType::ModelPack, true) => Some(BoxEncoding::Anchor),
1005 (configs::DecoderType::Ultralytics, _) => Some(BoxEncoding::Direct),
1006 (configs::DecoderType::ModelPack, false) => None,
1009 };
1010 Ok(LogicalOutput {
1011 name: None,
1012 type_: Some(LogicalType::Detection),
1013 shape: d.shape.clone(),
1014 dshape: d.dshape.clone(),
1015 decoder: Some(DecoderKind::from_v1(d.decoder)),
1016 encoding,
1017 score_format: None,
1018 normalized: d.normalized,
1019 anchors: d.anchors.clone(),
1020 stride: None,
1021 dtype: None,
1022 quantization: quantization_from_v1(d.quantization),
1023 outputs: Vec::new(),
1024 activation_applied: None,
1025 activation_required: None,
1026 })
1027 }
1028 ConfigOutput::Boxes(b) => Ok(LogicalOutput {
1029 name: None,
1030 type_: Some(LogicalType::Boxes),
1031 shape: b.shape.clone(),
1032 dshape: b.dshape.clone(),
1033 decoder: Some(DecoderKind::from_v1(b.decoder)),
1034 encoding: Some(BoxEncoding::Direct),
1038 score_format: None,
1039 normalized: b.normalized,
1040 anchors: None,
1041 stride: None,
1042 dtype: None,
1043 quantization: quantization_from_v1(b.quantization),
1044 outputs: Vec::new(),
1045 activation_applied: None,
1046 activation_required: None,
1047 }),
1048 ConfigOutput::Scores(s) => Ok(LogicalOutput {
1049 name: None,
1050 type_: Some(LogicalType::Scores),
1051 shape: s.shape.clone(),
1052 dshape: s.dshape.clone(),
1053 decoder: Some(DecoderKind::from_v1(s.decoder)),
1054 encoding: None,
1055 score_format: Some(ScoreFormat::PerClass),
1059 normalized: None,
1060 anchors: None,
1061 stride: None,
1062 dtype: None,
1063 quantization: quantization_from_v1(s.quantization),
1064 outputs: Vec::new(),
1065 activation_applied: None,
1066 activation_required: None,
1067 }),
1068 ConfigOutput::Protos(p) => Ok(LogicalOutput {
1069 name: None,
1070 type_: Some(LogicalType::Protos),
1071 shape: p.shape.clone(),
1072 dshape: p.dshape.clone(),
1073 decoder: Some(DecoderKind::from_v1(p.decoder)),
1075 encoding: None,
1076 score_format: None,
1077 normalized: None,
1078 anchors: None,
1079 stride: None,
1080 dtype: None,
1081 quantization: quantization_from_v1(p.quantization),
1082 outputs: Vec::new(),
1083 activation_applied: None,
1084 activation_required: None,
1085 }),
1086 ConfigOutput::MaskCoefficients(m) => Ok(LogicalOutput {
1087 name: None,
1088 type_: Some(LogicalType::MaskCoefs),
1089 shape: m.shape.clone(),
1090 dshape: m.dshape.clone(),
1091 decoder: Some(DecoderKind::from_v1(m.decoder)),
1092 encoding: None,
1093 score_format: None,
1094 normalized: None,
1095 anchors: None,
1096 stride: None,
1097 dtype: None,
1098 quantization: quantization_from_v1(m.quantization),
1099 outputs: Vec::new(),
1100 activation_applied: None,
1101 activation_required: None,
1102 }),
1103 ConfigOutput::Segmentation(seg) => Ok(LogicalOutput {
1104 name: None,
1105 type_: Some(LogicalType::Segmentation),
1106 shape: seg.shape.clone(),
1107 dshape: seg.dshape.clone(),
1108 decoder: Some(DecoderKind::from_v1(seg.decoder)),
1109 encoding: None,
1110 score_format: None,
1111 normalized: None,
1112 anchors: None,
1113 stride: None,
1114 dtype: None,
1115 quantization: quantization_from_v1(seg.quantization),
1116 outputs: Vec::new(),
1117 activation_applied: None,
1118 activation_required: None,
1119 }),
1120 ConfigOutput::Mask(m) => Ok(LogicalOutput {
1121 name: None,
1122 type_: Some(LogicalType::Masks),
1123 shape: m.shape.clone(),
1124 dshape: m.dshape.clone(),
1125 decoder: Some(DecoderKind::from_v1(m.decoder)),
1126 encoding: None,
1127 score_format: None,
1128 normalized: None,
1129 anchors: None,
1130 stride: None,
1131 dtype: None,
1132 quantization: quantization_from_v1(m.quantization),
1133 outputs: Vec::new(),
1134 activation_applied: None,
1135 activation_required: None,
1136 }),
1137 ConfigOutput::Classes(c) => Ok(LogicalOutput {
1138 name: None,
1139 type_: Some(LogicalType::Classes),
1140 shape: c.shape.clone(),
1141 dshape: c.dshape.clone(),
1142 decoder: Some(DecoderKind::from_v1(c.decoder)),
1143 encoding: None,
1144 score_format: None,
1145 normalized: None,
1146 anchors: None,
1147 stride: None,
1148 dtype: None,
1149 quantization: quantization_from_v1(c.quantization),
1150 outputs: Vec::new(),
1151 activation_applied: None,
1152 activation_required: None,
1153 }),
1154 }
1155}
1156
1157impl DecoderKind {
1158 pub fn from_v1(v: configs::DecoderType) -> Self {
1160 match v {
1161 configs::DecoderType::ModelPack => DecoderKind::ModelPack,
1162 configs::DecoderType::Ultralytics => DecoderKind::Ultralytics,
1163 }
1164 }
1165
1166 pub fn to_v1(self) -> configs::DecoderType {
1168 match self {
1169 DecoderKind::ModelPack => configs::DecoderType::ModelPack,
1170 DecoderKind::Ultralytics => configs::DecoderType::Ultralytics,
1171 }
1172 }
1173}
1174
1175impl DecoderVersion {
1176 pub fn from_v1(v: &configs::DecoderVersion) -> Self {
1178 match v {
1179 configs::DecoderVersion::Yolov5 => DecoderVersion::Yolov5,
1180 configs::DecoderVersion::Yolov8 => DecoderVersion::Yolov8,
1181 configs::DecoderVersion::Yolo11 => DecoderVersion::Yolo11,
1182 configs::DecoderVersion::Yolo26 => DecoderVersion::Yolo26,
1183 }
1184 }
1185
1186 pub fn to_v1(self) -> configs::DecoderVersion {
1188 match self {
1189 DecoderVersion::Yolov5 => configs::DecoderVersion::Yolov5,
1190 DecoderVersion::Yolov8 => configs::DecoderVersion::Yolov8,
1191 DecoderVersion::Yolo11 => configs::DecoderVersion::Yolo11,
1192 DecoderVersion::Yolo26 => configs::DecoderVersion::Yolo26,
1193 }
1194 }
1195}
1196
1197impl NmsMode {
1198 pub fn from_v1(v: &configs::Nms) -> Option<Self> {
1200 match v {
1201 configs::Nms::Auto => None,
1202 configs::Nms::ClassAgnostic => Some(NmsMode::ClassAgnostic),
1203 configs::Nms::ClassAware => Some(NmsMode::ClassAware),
1204 }
1205 }
1206
1207 pub fn to_v1(self) -> configs::Nms {
1209 match self {
1210 NmsMode::ClassAgnostic => configs::Nms::ClassAgnostic,
1211 NmsMode::ClassAware => configs::Nms::ClassAware,
1212 }
1213 }
1214}
1215
1216fn quantization_to_legacy(q: &Quantization) -> DecoderResult<QuantTuple> {
1219 if q.is_per_channel() {
1220 return Err(DecoderError::NotSupported(
1221 "per-channel quantization cannot be expressed as a v1 QuantTuple".into(),
1222 ));
1223 }
1224 let scale = *q.scale.first().unwrap_or(&0.0);
1225 let zp = q.zero_point_at(0);
1226 Ok(QuantTuple(scale, zp))
1227}
1228
1229pub(crate) fn squeeze_padding_dims(
1235 shape: Vec<usize>,
1236 dshape: Vec<(DimName, usize)>,
1237) -> (Vec<usize>, Vec<(DimName, usize)>) {
1238 if dshape.is_empty() {
1242 return (shape, dshape);
1243 }
1244 let keep: Vec<bool> = dshape
1245 .iter()
1246 .map(|(n, _)| !matches!(n, DimName::Padding))
1247 .collect();
1248 let shape = shape
1249 .into_iter()
1250 .zip(keep.iter())
1251 .filter_map(|(s, &k)| k.then_some(s))
1252 .collect();
1253 let dshape = dshape
1254 .into_iter()
1255 .zip(keep.iter())
1256 .filter_map(|(d, &k)| k.then_some(d))
1257 .collect();
1258 (shape, dshape)
1259}
1260
1261pub(crate) fn padding_axes(dshape: &[(DimName, usize)]) -> Vec<usize> {
1266 let mut v: Vec<usize> = dshape
1267 .iter()
1268 .enumerate()
1269 .filter_map(|(i, (n, _))| matches!(n, DimName::Padding).then_some(i))
1270 .collect();
1271 v.sort_by(|a, b| b.cmp(a));
1272 v
1273}
1274
1275fn logical_to_legacy_config_output(logical: &LogicalOutput) -> DecoderResult<ConfigOutput> {
1276 let decoder = logical
1277 .decoder
1278 .map(|d| d.to_v1())
1279 .unwrap_or(configs::DecoderType::Ultralytics);
1280 let quantization = logical
1281 .quantization
1282 .as_ref()
1283 .map(quantization_to_legacy)
1284 .transpose()?;
1285 let (shape, dshape) = match logical.decoder {
1291 Some(DecoderKind::ModelPack) => (logical.shape.clone(), logical.dshape.clone()),
1292 _ => squeeze_padding_dims(logical.shape.clone(), logical.dshape.clone()),
1293 };
1294
1295 let ty = logical.type_.ok_or_else(|| {
1296 DecoderError::InvalidConfig(format!(
1300 "logical output `{}` has no type; typeless outputs should be \
1301 filtered before legacy conversion",
1302 logical.name.as_deref().unwrap_or("<anonymous>")
1303 ))
1304 })?;
1305
1306 let ctx = LegacyConvertCtx {
1307 decoder,
1308 quantization,
1309 shape,
1310 dshape,
1311 normalized: logical.normalized,
1312 anchors: logical.anchors.clone(),
1313 };
1314
1315 match ty {
1316 LogicalType::Boxes => Ok(boxes_to_legacy(&ctx)),
1317 LogicalType::Scores => Ok(scores_to_legacy(&ctx)),
1318 LogicalType::Protos => Ok(protos_to_legacy(&ctx)),
1319 LogicalType::MaskCoefs => Ok(mask_coefs_to_legacy(&ctx)),
1320 LogicalType::Segmentation => Ok(segmentation_to_legacy(&ctx)),
1321 LogicalType::Masks => Ok(masks_to_legacy(&ctx)),
1322 LogicalType::Classes => Ok(classes_to_legacy(&ctx)),
1323 LogicalType::Detection | LogicalType::Detections => Ok(detection_to_legacy(&ctx)),
1324 LogicalType::Objectness | LogicalType::Landmarks => {
1327 Err(DecoderError::NotSupported(format!(
1328 "logical type {:?} has no legacy v1 equivalent; use the \
1329 native v2 decoder path",
1330 ty
1331 )))
1332 }
1333 }
1334}
1335
1336struct LegacyConvertCtx {
1338 decoder: configs::DecoderType,
1339 quantization: Option<QuantTuple>,
1340 shape: Vec<usize>,
1341 dshape: Vec<(DimName, usize)>,
1342 normalized: Option<bool>,
1343 anchors: Option<Vec<[f32; 2]>>,
1344}
1345
1346fn boxes_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1347 ConfigOutput::Boxes(configs::Boxes {
1348 decoder: ctx.decoder,
1349 quantization: ctx.quantization,
1350 shape: ctx.shape.clone(),
1351 dshape: ctx.dshape.clone(),
1352 normalized: ctx.normalized,
1353 })
1354}
1355
1356fn scores_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1357 ConfigOutput::Scores(configs::Scores {
1358 decoder: ctx.decoder,
1359 quantization: ctx.quantization,
1360 shape: ctx.shape.clone(),
1361 dshape: ctx.dshape.clone(),
1362 })
1363}
1364
1365fn protos_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1366 ConfigOutput::Protos(configs::Protos {
1367 decoder: ctx.decoder,
1368 quantization: ctx.quantization,
1369 shape: ctx.shape.clone(),
1370 dshape: ctx.dshape.clone(),
1371 })
1372}
1373
1374fn mask_coefs_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1375 ConfigOutput::MaskCoefficients(configs::MaskCoefficients {
1376 decoder: ctx.decoder,
1377 quantization: ctx.quantization,
1378 shape: ctx.shape.clone(),
1379 dshape: ctx.dshape.clone(),
1380 })
1381}
1382
1383fn segmentation_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1384 ConfigOutput::Segmentation(configs::Segmentation {
1385 decoder: ctx.decoder,
1386 quantization: ctx.quantization,
1387 shape: ctx.shape.clone(),
1388 dshape: ctx.dshape.clone(),
1389 })
1390}
1391
1392fn masks_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1393 ConfigOutput::Mask(configs::Mask {
1394 decoder: ctx.decoder,
1395 quantization: ctx.quantization,
1396 shape: ctx.shape.clone(),
1397 dshape: ctx.dshape.clone(),
1398 })
1399}
1400
1401fn classes_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1402 ConfigOutput::Classes(configs::Classes {
1403 decoder: ctx.decoder,
1404 quantization: ctx.quantization,
1405 shape: ctx.shape.clone(),
1406 dshape: ctx.dshape.clone(),
1407 })
1408}
1409
1410fn detection_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1411 ConfigOutput::Detection(configs::Detection {
1415 anchors: ctx.anchors.clone(),
1416 decoder: ctx.decoder,
1417 quantization: ctx.quantization,
1418 shape: ctx.shape.clone(),
1419 dshape: ctx.dshape.clone(),
1420 normalized: ctx.normalized,
1421 })
1422}
1423
1424#[cfg(test)]
1425#[cfg_attr(coverage_nightly, coverage(off))]
1426mod tests {
1427 use super::*;
1428
1429 #[test]
1430 fn nms_multi_label_true_round_trips_through_json() {
1431 let json = r#"{
1432 "schema_version": 2,
1433 "nms": "class_aware",
1434 "nms_multi_label": true,
1435 "outputs": [{
1436 "name": "output0", "type": "detection",
1437 "shape": [1, 84, 8400],
1438 "dshape": [{"batch": 1}, {"num_features": 84}, {"num_boxes": 8400}],
1439 "dtype": "float32", "decoder": "ultralytics", "encoding": "direct",
1440 "normalized": true
1441 }]
1442 }"#;
1443 let schema = SchemaV2::parse_json(json).unwrap();
1444 assert_eq!(schema.nms_multi_label, Some(true));
1445
1446 let text = serde_json::to_string(&schema).unwrap();
1447 let value: serde_json::Value = serde_json::from_str(&text).unwrap();
1448 assert_eq!(value["nms_multi_label"], serde_json::json!(true));
1449 let again = SchemaV2::parse_json(&text).unwrap();
1450 assert_eq!(again, schema);
1451
1452 let legacy = schema.to_legacy_config_outputs().unwrap();
1453 assert_eq!(legacy.nms_multi_label, Some(true));
1454 let back = SchemaV2::from_v1(&legacy).unwrap();
1455 assert_eq!(back.nms_multi_label, Some(true));
1456 }
1457
1458 #[test]
1459 fn schema_default_is_v2() {
1460 let s = SchemaV2::default();
1461 assert_eq!(s.schema_version, 2);
1462 assert!(s.outputs.is_empty());
1463 }
1464
1465 #[test]
1466 fn fixtures_round_trip_through_serde() {
1467 let yolov8 =
1468 edgefirst_bench::testdata::read_to_string("per_scale/synthetic_yolov8n_schema.json");
1469 let _: super::SchemaV2 = serde_json::from_str(&yolov8).expect("yolov8n fixture must parse");
1470
1471 let yolo26 =
1472 edgefirst_bench::testdata::read_to_string("per_scale/synthetic_yolo26n_schema.json");
1473 let _: super::SchemaV2 = serde_json::from_str(&yolo26).expect("yolo26n fixture must parse");
1474
1475 let flat =
1476 edgefirst_bench::testdata::read_to_string("per_scale/synthetic_flat_schema.json");
1477 let _: super::SchemaV2 = serde_json::from_str(&flat).expect("flat fixture must parse");
1478 }
1479
1480 #[test]
1481 fn box_encoding_accepts_ltrb_alias_for_direct() {
1482 let dfl: BoxEncoding = serde_json::from_str("\"dfl\"").unwrap();
1483 assert_eq!(dfl, BoxEncoding::Dfl);
1484
1485 let direct: BoxEncoding = serde_json::from_str("\"direct\"").unwrap();
1486 assert_eq!(direct, BoxEncoding::Direct);
1487
1488 let ltrb: BoxEncoding = serde_json::from_str("\"ltrb\"").unwrap();
1490 assert_eq!(ltrb, BoxEncoding::Direct);
1491 }
1492
1493 #[test]
1494 fn dtype_roundtrip() {
1495 for d in [
1496 DType::Int8,
1497 DType::Uint8,
1498 DType::Int16,
1499 DType::Uint16,
1500 DType::Float16,
1501 DType::Float32,
1502 ] {
1503 let j = serde_json::to_string(&d).unwrap();
1504 let back: DType = serde_json::from_str(&j).unwrap();
1505 assert_eq!(back, d);
1506 }
1507 }
1508
1509 #[test]
1510 fn dtype_widths() {
1511 assert_eq!(DType::Int8.size_bytes(), 1);
1512 assert_eq!(DType::Float16.size_bytes(), 2);
1513 assert_eq!(DType::Float32.size_bytes(), 4);
1514 }
1515
1516 #[test]
1517 fn stride_accepts_scalar_or_pair() {
1518 let a: Stride = serde_json::from_str("8").unwrap();
1519 let b: Stride = serde_json::from_str("[8, 16]").unwrap();
1520 assert_eq!(a, Stride::Square(8));
1521 assert_eq!(b, Stride::Rect([8, 16]));
1522 assert_eq!(a.x(), 8);
1523 assert_eq!(a.y(), 8);
1524 assert_eq!(b.x(), 8);
1525 assert_eq!(b.y(), 16);
1526 }
1527
1528 #[test]
1529 fn quantization_scalar_scale() {
1530 let j = r#"{"scale": 0.00392, "zero_point": 0, "dtype": "int8"}"#;
1531 let q: Quantization = serde_json::from_str(j).unwrap();
1532 assert!(q.is_per_tensor());
1533 assert!(q.is_symmetric());
1534 assert_eq!(q.scale_at(0), 0.00392);
1535 assert_eq!(q.scale_at(5), 0.00392);
1536 assert_eq!(q.zero_point_at(0), 0);
1537 }
1538
1539 #[test]
1540 fn quantization_per_channel() {
1541 let j = r#"{"scale": [0.054, 0.089, 0.195], "axis": 0, "dtype": "int8"}"#;
1542 let q: Quantization = serde_json::from_str(j).unwrap();
1543 assert!(q.is_per_channel());
1544 assert!(q.is_symmetric());
1545 assert_eq!(q.axis, Some(0));
1546 assert_eq!(q.scale_at(0), 0.054);
1547 assert_eq!(q.scale_at(2), 0.195);
1548 }
1549
1550 #[test]
1551 fn quantization_asymmetric_per_tensor() {
1552 let j = r#"{"scale": 0.176, "zero_point": 198, "dtype": "uint8"}"#;
1553 let q: Quantization = serde_json::from_str(j).unwrap();
1554 assert!(!q.is_symmetric());
1555 assert_eq!(q.zero_point_at(0), 198);
1556 assert_eq!(q.zero_point_at(10), 198);
1557 }
1558
1559 #[test]
1560 fn quantization_symmetric_default_zero_point() {
1561 let j = r#"{"scale": 0.00392, "dtype": "int8"}"#;
1562 let q: Quantization = serde_json::from_str(j).unwrap();
1563 assert!(q.is_symmetric());
1564 assert_eq!(q.zero_point_at(0), 0);
1565 }
1566
1567 #[test]
1568 fn quantization_to_tensor_per_tensor_asymmetric() {
1569 let q = Quantization {
1570 scale: vec![0.1],
1571 zero_point: Some(vec![-5]),
1572 axis: None,
1573 dtype: Some(DType::Int8),
1574 };
1575 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1576 assert!(t.is_per_tensor());
1577 assert!(!t.is_symmetric());
1578 assert_eq!(t.scale(), &[0.1]);
1579 assert_eq!(t.zero_point(), Some(&[-5][..]));
1580 }
1581
1582 #[test]
1583 fn quantization_to_tensor_per_tensor_symmetric() {
1584 let q = Quantization {
1585 scale: vec![0.05],
1586 zero_point: None,
1587 axis: None,
1588 dtype: Some(DType::Int8),
1589 };
1590 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1591 assert!(t.is_per_tensor());
1592 assert!(t.is_symmetric());
1593 }
1594
1595 #[test]
1596 fn quantization_to_tensor_per_channel_asymmetric() {
1597 let q = Quantization {
1598 scale: vec![0.1, 0.2, 0.3],
1599 zero_point: Some(vec![-1, 0, 1]),
1600 axis: Some(2),
1601 dtype: Some(DType::Int8),
1602 };
1603 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1604 assert!(t.is_per_channel());
1605 assert_eq!(t.axis(), Some(2));
1606 assert_eq!(t.scale().len(), 3);
1607 assert_eq!(t.zero_point().map(|z| z.len()), Some(3));
1608 }
1609
1610 #[test]
1611 fn quantization_to_tensor_per_channel_symmetric() {
1612 let q = Quantization {
1613 scale: vec![0.054, 0.089, 0.195],
1614 zero_point: None,
1615 axis: Some(0),
1616 dtype: Some(DType::Int8),
1617 };
1618 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1619 assert!(t.is_per_channel());
1620 assert!(t.is_symmetric());
1621 assert_eq!(t.axis(), Some(0));
1622 }
1623
1624 #[test]
1625 fn quantization_to_tensor_per_channel_missing_axis_errors() {
1626 let q = Quantization {
1627 scale: vec![0.1, 0.2, 0.3],
1628 zero_point: None,
1629 axis: None,
1630 dtype: None,
1631 };
1632 let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
1633 assert!(matches!(
1637 err,
1638 edgefirst_tensor::Error::QuantizationInvalid { field: "axis", .. }
1639 ));
1640 }
1641
1642 #[test]
1646 fn quantization_to_tensor_per_tensor_ignores_a_redundant_axis() {
1647 let q = Quantization {
1648 scale: vec![0.5],
1649 zero_point: Some(vec![3]),
1650 axis: Some(0),
1651 dtype: Some(DType::Int8),
1652 };
1653 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1654 assert!(t.is_per_tensor());
1655 assert_eq!(t.scale(), &[0.5][..]);
1656 assert_eq!(t.zero_point(), Some(&[3][..]));
1657 }
1658
1659 #[test]
1660 fn quantization_to_tensor_symmetric_ignores_a_redundant_axis() {
1661 let q = Quantization {
1662 scale: vec![0.5],
1663 zero_point: None,
1664 axis: Some(0),
1665 dtype: Some(DType::Int8),
1666 };
1667 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1668 assert!(t.is_per_tensor());
1669 assert!(t.is_symmetric());
1670 assert_eq!(t.scale(), &[0.5][..]);
1671 }
1672
1673 #[test]
1677 fn quantization_to_tensor_empty_scale_errors_on_scale() {
1678 for (zero_point, axis) in [
1679 (None, None),
1680 (None, Some(0)),
1681 (Some(vec![]), Some(0)),
1682 (Some(vec![]), None),
1683 ] {
1684 let q = Quantization {
1685 scale: vec![],
1686 zero_point,
1687 axis,
1688 dtype: Some(DType::Int8),
1689 };
1690 let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
1691 assert!(
1692 matches!(
1693 err,
1694 edgefirst_tensor::Error::QuantizationInvalid { field: "scale", .. }
1695 ),
1696 "axis={axis:?} gave {err:?}"
1697 );
1698 }
1699 }
1700
1701 #[test]
1705 fn quantization_to_tensor_single_scale_with_many_zero_points_errors_on_scale() {
1706 for axis in [None, Some(0)] {
1707 let q = Quantization {
1708 scale: vec![0.5],
1709 zero_point: Some(vec![1, 2]),
1710 axis,
1711 dtype: Some(DType::Int8),
1712 };
1713 let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
1714 assert!(
1715 matches!(
1716 err,
1717 edgefirst_tensor::Error::QuantizationInvalid { field: "scale", .. }
1718 ),
1719 "axis={axis:?} gave {err:?}"
1720 );
1721 }
1722 }
1723
1724 #[test]
1725 fn per_channel_quantization_is_not_per_tensor() {
1726 let q = Quantization {
1727 scale: vec![0.1, 0.2, 0.3],
1728 zero_point: None,
1729 axis: Some(0),
1730 dtype: Some(DType::Int8),
1731 };
1732 assert!(q.is_per_channel());
1733 assert!(!q.is_per_tensor());
1734
1735 let per_tensor = Quantization {
1736 scale: vec![0.1],
1737 zero_point: None,
1738 axis: None,
1739 dtype: Some(DType::Int8),
1740 };
1741 assert!(per_tensor.is_per_tensor());
1742 assert!(!per_tensor.is_per_channel());
1743 }
1744
1745 #[test]
1749 fn zero_point_at_indexes_per_channel_zero_points() {
1750 let per_channel = Quantization {
1751 scale: vec![0.1, 0.2, 0.3],
1752 zero_point: Some(vec![10, 20, 30]),
1753 axis: Some(0),
1754 dtype: Some(DType::Int8),
1755 };
1756 assert_eq!(per_channel.zero_point_at(0), 10);
1757 assert_eq!(per_channel.zero_point_at(1), 20);
1758 assert_eq!(per_channel.zero_point_at(2), 30);
1759 assert_eq!(per_channel.zero_point_at(3), 0);
1761
1762 let shared = Quantization {
1763 scale: vec![0.1, 0.2, 0.3],
1764 zero_point: Some(vec![7]),
1765 axis: Some(0),
1766 dtype: Some(DType::Int8),
1767 };
1768 assert_eq!(shared.zero_point_at(0), 7);
1769 assert_eq!(shared.zero_point_at(2), 7);
1770
1771 let symmetric = Quantization {
1772 scale: vec![0.1],
1773 zero_point: None,
1774 axis: None,
1775 dtype: Some(DType::Int8),
1776 };
1777 assert_eq!(symmetric.zero_point_at(0), 0);
1778 }
1779
1780 #[test]
1781 fn logical_output_flat_tflite_boxes() {
1782 let j = r#"{
1784 "name": "boxes", "type": "boxes",
1785 "shape": [1, 64, 8400],
1786 "dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
1787 "dtype": "int8",
1788 "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1789 "decoder": "ultralytics",
1790 "encoding": "dfl",
1791 "normalized": true
1792 }"#;
1793 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1794 assert_eq!(lo.type_, Some(LogicalType::Boxes));
1795 assert_eq!(lo.encoding, Some(BoxEncoding::Dfl));
1796 assert_eq!(lo.normalized, Some(true));
1797 assert!(!lo.is_split());
1798 assert_eq!(lo.dtype, Some(DType::Int8));
1799 }
1800
1801 #[test]
1802 fn logical_output_hailo_per_scale_split() {
1803 let j = r#"{
1805 "name": "boxes", "type": "boxes",
1806 "shape": [1, 64, 8400],
1807 "encoding": "dfl", "decoder": "ultralytics", "normalized": true,
1808 "outputs": [
1809 {
1810 "name": "boxes_0", "type": "boxes",
1811 "stride": 8, "scale_index": 0,
1812 "shape": [1, 80, 80, 64],
1813 "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_features": 64}],
1814 "dtype": "uint8",
1815 "quantization": {"scale": 0.0234, "zero_point": 128, "dtype": "uint8"}
1816 }
1817 ]
1818 }"#;
1819 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1820 assert!(lo.is_split());
1821 assert_eq!(lo.outputs.len(), 1);
1822 let child = &lo.outputs[0];
1823 assert_eq!(child.name, "boxes_0");
1824 assert_eq!(child.type_, Some(PhysicalType::Boxes));
1825 assert_eq!(child.stride, Some(Stride::Square(8)));
1826 assert_eq!(child.scale_index, Some(0));
1827 assert_eq!(child.dtype, DType::Uint8);
1828 }
1829
1830 #[test]
1831 fn logical_output_ara2_xy_wh_channel_split() {
1832 let j = r#"{
1834 "name": "boxes", "type": "boxes",
1835 "shape": [1, 4, 8400, 1],
1836 "encoding": "direct", "decoder": "ultralytics", "normalized": true,
1837 "outputs": [
1838 {
1839 "name": "_model_22_Div_1_output_0", "type": "boxes_xy",
1840 "shape": [1, 2, 8400, 1],
1841 "dshape": [{"batch": 1}, {"box_coords": 2}, {"num_boxes": 8400}, {"padding": 1}],
1842 "dtype": "int16",
1843 "quantization": {"scale": 3.129e-5, "zero_point": 0, "dtype": "int16"}
1844 },
1845 {
1846 "name": "_model_22_Sub_1_output_0", "type": "boxes_wh",
1847 "shape": [1, 2, 8400, 1],
1848 "dshape": [{"batch": 1}, {"box_coords": 2}, {"num_boxes": 8400}, {"padding": 1}],
1849 "dtype": "int16",
1850 "quantization": {"scale": 3.149e-5, "zero_point": 0, "dtype": "int16"}
1851 }
1852 ]
1853 }"#;
1854 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1855 assert_eq!(lo.encoding, Some(BoxEncoding::Direct));
1856 assert_eq!(lo.outputs.len(), 2);
1857 assert_eq!(lo.outputs[0].type_, Some(PhysicalType::BoxesXy));
1858 assert_eq!(lo.outputs[1].type_, Some(PhysicalType::BoxesWh));
1859 assert!(lo.outputs[0].stride.is_none());
1860 assert!(lo.outputs[1].stride.is_none());
1861 }
1862
1863 #[test]
1864 fn logical_output_hailo_scores_sigmoid_applied() {
1865 let j = r#"{
1866 "name": "scores", "type": "scores",
1867 "shape": [1, 80, 8400],
1868 "decoder": "ultralytics", "score_format": "per_class",
1869 "outputs": [
1870 {
1871 "name": "scores_0", "type": "scores",
1872 "stride": 8, "scale_index": 0,
1873 "shape": [1, 80, 80, 80],
1874 "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_classes": 80}],
1875 "dtype": "uint8",
1876 "quantization": {"scale": 0.003922, "dtype": "uint8"},
1877 "activation_applied": "sigmoid"
1878 }
1879 ]
1880 }"#;
1881 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1882 assert_eq!(lo.score_format, Some(ScoreFormat::PerClass));
1883 let child = &lo.outputs[0];
1884 assert_eq!(child.activation_applied, Some(Activation::Sigmoid));
1885 assert!(child.activation_required.is_none());
1886 }
1887
1888 #[test]
1889 fn yolo26_end_to_end_detections() {
1890 let j = r#"{
1891 "schema_version": 2,
1892 "decoder_version": "yolo26",
1893 "outputs": [{
1894 "name": "output0", "type": "detections",
1895 "shape": [1, 100, 6],
1896 "dshape": [{"batch": 1}, {"num_boxes": 100}, {"num_features": 6}],
1897 "dtype": "int8",
1898 "quantization": {"scale": 0.0078, "zero_point": 0, "dtype": "int8"},
1899 "normalized": false,
1900 "decoder": "ultralytics"
1901 }]
1902 }"#;
1903 let s: SchemaV2 = serde_json::from_str(j).unwrap();
1904 assert_eq!(s.decoder_version, Some(DecoderVersion::Yolo26));
1905 assert!(s.decoder_version.unwrap().is_end_to_end());
1906 assert_eq!(s.outputs[0].type_, Some(LogicalType::Detections));
1907 assert_eq!(s.outputs[0].normalized, Some(false));
1908 assert!(s.nms.is_none());
1909 }
1910
1911 #[test]
1912 fn modelpack_anchor_detection_with_rect_stride() {
1913 let j = r#"{
1914 "schema_version": 2,
1915 "outputs": [{
1916 "name": "output_0", "type": "detection",
1917 "shape": [1, 40, 40, 54],
1918 "dshape": [{"batch": 1}, {"height": 40}, {"width": 40}, {"num_anchors_x_features": 54}],
1919 "dtype": "uint8",
1920 "quantization": {"scale": 0.176, "zero_point": 198, "dtype": "uint8"},
1921 "decoder": "modelpack",
1922 "encoding": "anchor",
1923 "stride": [16, 16],
1924 "anchors": [[0.054, 0.065], [0.089, 0.139], [0.195, 0.196]]
1925 }]
1926 }"#;
1927 let s: SchemaV2 = serde_json::from_str(j).unwrap();
1928 let lo = &s.outputs[0];
1929 assert_eq!(lo.encoding, Some(BoxEncoding::Anchor));
1930 assert_eq!(lo.stride, Some(Stride::Rect([16, 16])));
1931 assert_eq!(lo.anchors.as_ref().map(|a| a.len()), Some(3));
1932 }
1933
1934 #[test]
1935 fn yolov5_obj_x_class_objectness_logical() {
1936 let j = r#"{
1937 "name": "objectness", "type": "objectness",
1938 "shape": [1, 3, 8400],
1939 "decoder": "ultralytics",
1940 "outputs": [{
1941 "name": "objectness_0", "type": "objectness",
1942 "stride": 8, "scale_index": 0,
1943 "shape": [1, 80, 80, 3],
1944 "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_features": 3}],
1945 "dtype": "uint8",
1946 "quantization": {"scale": 0.0039, "zero_point": 0, "dtype": "uint8"},
1947 "activation_applied": "sigmoid"
1948 }]
1949 }"#;
1950 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1951 assert_eq!(lo.type_, Some(LogicalType::Objectness));
1952 assert_eq!(lo.outputs[0].activation_applied, Some(Activation::Sigmoid));
1953 }
1954
1955 #[test]
1956 fn direct_protos_no_decoder() {
1957 let j = r#"{
1959 "name": "protos", "type": "protos",
1960 "shape": [1, 32, 160, 160],
1961 "dshape": [{"batch": 1}, {"num_protos": 32}, {"height": 160}, {"width": 160}],
1962 "dtype": "uint8",
1963 "quantization": {"scale": 0.0203, "zero_point": 45, "dtype": "uint8"},
1964 "stride": 4
1965 }"#;
1966 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1967 assert_eq!(lo.type_, Some(LogicalType::Protos));
1968 assert!(lo.decoder.is_none());
1969 assert_eq!(lo.stride, Some(Stride::Square(4)));
1970 }
1971
1972 #[test]
1973 fn full_yolov8_tflite_flat_detection() {
1974 let j = r#"{
1976 "schema_version": 2,
1977 "decoder_version": "yolov8",
1978 "nms": "class_agnostic",
1979 "input": { "shape": [1, 640, 640, 3], "cameraadaptor": "rgb" },
1980 "outputs": [
1981 {
1982 "name": "boxes", "type": "boxes",
1983 "shape": [1, 64, 8400],
1984 "dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
1985 "dtype": "int8",
1986 "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1987 "decoder": "ultralytics",
1988 "encoding": "dfl",
1989 "normalized": true
1990 },
1991 {
1992 "name": "scores", "type": "scores",
1993 "shape": [1, 80, 8400],
1994 "dshape": [{"batch": 1}, {"num_classes": 80}, {"num_boxes": 8400}],
1995 "dtype": "int8",
1996 "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1997 "decoder": "ultralytics",
1998 "score_format": "per_class"
1999 }
2000 ]
2001 }"#;
2002 let s: SchemaV2 = serde_json::from_str(j).unwrap();
2003 assert_eq!(s.schema_version, 2);
2004 assert_eq!(s.decoder_version, Some(DecoderVersion::Yolov8));
2005 assert_eq!(s.nms, Some(NmsMode::ClassAgnostic));
2006 assert_eq!(s.input.as_ref().unwrap().shape, vec![1, 640, 640, 3]);
2007 assert_eq!(s.outputs.len(), 2);
2008 }
2009
2010 #[test]
2011 fn schema_unknown_version_parses_without_validation() {
2012 let j = r#"{"schema_version": 99, "outputs": []}"#;
2015 let s: SchemaV2 = serde_json::from_str(j).unwrap();
2016 assert_eq!(s.schema_version, 99);
2017 }
2018
2019 #[test]
2020 fn serde_roundtrip_preserves_fields() {
2021 let original = SchemaV2 {
2022 schema_version: 2,
2023 input: Some(InputSpec {
2024 shape: vec![1, 3, 640, 640],
2025 dshape: vec![],
2026 cameraadaptor: Some("rgb".into()),
2027 }),
2028 outputs: vec![LogicalOutput {
2029 name: Some("boxes".into()),
2030 type_: Some(LogicalType::Boxes),
2031 shape: vec![1, 4, 8400],
2032 dshape: vec![],
2033 decoder: Some(DecoderKind::Ultralytics),
2034 encoding: Some(BoxEncoding::Direct),
2035 score_format: None,
2036 normalized: Some(true),
2037 anchors: None,
2038 stride: None,
2039 dtype: Some(DType::Float32),
2040 quantization: None,
2041 outputs: vec![],
2042 activation_applied: None,
2043 activation_required: None,
2044 }],
2045 nms: Some(NmsMode::ClassAgnostic),
2046 nms_multi_label: None,
2047 decoder_version: Some(DecoderVersion::Yolov8),
2048 };
2049 let j = serde_json::to_string(&original).unwrap();
2050 let parsed: SchemaV2 = serde_json::from_str(&j).unwrap();
2051 assert_eq!(parsed, original);
2052 }
2053
2054 #[test]
2057 fn parse_v1_yaml_yolov8_seg_testdata() {
2058 let yaml = edgefirst_bench::testdata::read_to_string("yolov8_seg.yaml");
2059 let schema = SchemaV2::parse_yaml(&yaml).expect("parse v1 yaml");
2060 assert_eq!(schema.schema_version, 2);
2061 assert_eq!(schema.outputs.len(), 2);
2062 let det = &schema.outputs[0];
2064 assert_eq!(det.type_, Some(LogicalType::Detection));
2065 assert_eq!(det.shape, vec![1, 116, 8400]);
2066 assert_eq!(det.decoder, Some(DecoderKind::Ultralytics));
2067 assert_eq!(det.encoding, Some(BoxEncoding::Direct));
2068 let q = det.quantization.as_ref().unwrap();
2069 assert_eq!(q.scale.len(), 1);
2070 assert!((q.scale[0] - 0.021_287_762).abs() < 1e-6);
2071 assert_eq!(q.zero_point, Some(vec![31]));
2072 let protos = &schema.outputs[1];
2074 assert_eq!(protos.type_, Some(LogicalType::Protos));
2075 assert_eq!(protos.shape, vec![1, 160, 160, 32]);
2076 }
2077
2078 #[test]
2079 fn parse_v1_json_modelpack_split_testdata() {
2080 let json = edgefirst_bench::testdata::read_to_string("modelpack_split.json");
2081 let schema = SchemaV2::parse_json(&json).expect("parse v1 json");
2082 assert_eq!(schema.schema_version, 2);
2083 assert_eq!(schema.outputs.len(), 2);
2084 for out in &schema.outputs {
2086 assert_eq!(out.type_, Some(LogicalType::Detection));
2087 assert_eq!(out.decoder, Some(DecoderKind::ModelPack));
2088 assert_eq!(out.encoding, Some(BoxEncoding::Anchor));
2089 assert_eq!(out.anchors.as_ref().map(|a| a.len()), Some(3));
2090 }
2091 }
2092
2093 #[test]
2094 fn parse_v2_json_direct_when_schema_version_present() {
2095 let j = r#"{
2096 "schema_version": 2,
2097 "outputs": [{
2098 "name": "boxes", "type": "boxes",
2099 "shape": [1, 4, 8400],
2100 "dshape": [{"batch": 1}, {"box_coords": 4}, {"num_boxes": 8400}],
2101 "dtype": "float32",
2102 "decoder": "ultralytics",
2103 "encoding": "direct",
2104 "normalized": true
2105 }]
2106 }"#;
2107 let schema = SchemaV2::parse_json(j).unwrap();
2108 assert_eq!(schema.schema_version, 2);
2109 assert_eq!(schema.outputs[0].type_, Some(LogicalType::Boxes));
2110 }
2111
2112 #[test]
2113 fn parse_rejects_future_schema_version() {
2114 let j = r#"{"schema_version": 99, "outputs": []}"#;
2115 let err = SchemaV2::parse_json(j).unwrap_err();
2116 matches!(err, DecoderError::NotSupported(_));
2117 }
2118
2119 #[test]
2120 fn parse_absent_schema_version_treats_as_v1() {
2121 let j = r#"{
2123 "outputs": [
2124 {
2125 "type": "boxes", "decoder": "ultralytics",
2126 "shape": [1, 4, 8400],
2127 "quantization": [0.00392, 0]
2128 },
2129 {
2130 "type": "scores", "decoder": "ultralytics",
2131 "shape": [1, 80, 8400],
2132 "quantization": [0.00392, 0]
2133 }
2134 ]
2135 }"#;
2136 let schema = SchemaV2::parse_json(j).expect("v1 legacy parse");
2137 assert_eq!(schema.schema_version, 2); assert_eq!(schema.outputs.len(), 2);
2139 assert_eq!(schema.outputs[0].type_, Some(LogicalType::Boxes));
2140 assert_eq!(schema.outputs[1].type_, Some(LogicalType::Scores));
2141 assert_eq!(schema.outputs[1].score_format, Some(ScoreFormat::PerClass));
2143 }
2144
2145 #[test]
2146 fn from_v1_preserves_nms_and_decoder_version() {
2147 let v1 = ConfigOutputs {
2148 outputs: vec![ConfigOutput::Boxes(crate::configs::Boxes {
2149 decoder: crate::configs::DecoderType::Ultralytics,
2150 quantization: Some(crate::configs::QuantTuple(0.01, 5)),
2151 shape: vec![1, 4, 8400],
2152 dshape: vec![],
2153 normalized: Some(true),
2154 })],
2155 nms: Some(crate::configs::Nms::ClassAware),
2156 nms_multi_label: None,
2157 decoder_version: Some(crate::configs::DecoderVersion::Yolo11),
2158 };
2159 let v2 = SchemaV2::from_v1(&v1).unwrap();
2160 assert_eq!(v2.nms, Some(NmsMode::ClassAware));
2161 assert_eq!(v2.decoder_version, Some(DecoderVersion::Yolo11));
2162 assert_eq!(v2.outputs[0].normalized, Some(true));
2163 let q = v2.outputs[0].quantization.as_ref().unwrap();
2164 assert_eq!(q.scale, vec![0.01]);
2165 assert_eq!(q.zero_point, Some(vec![5]));
2166 assert_eq!(q.dtype, None); }
2168
2169 #[test]
2176 fn typeless_logical_output_parses_and_roundtrips() {
2177 let j = r#"{
2178 "schema_version": 2,
2179 "outputs": [
2180 {
2181 "name": "extra_telemetry",
2182 "shape": [1, 16]
2183 },
2184 {
2185 "name": "boxes",
2186 "type": "boxes",
2187 "shape": [1, 4, 8400]
2188 }
2189 ]
2190 }"#;
2191 let schema: SchemaV2 = serde_json::from_str(j).unwrap();
2192 assert_eq!(schema.outputs.len(), 2);
2193 assert_eq!(schema.outputs[0].type_, None);
2194 assert_eq!(schema.outputs[0].name.as_deref(), Some("extra_telemetry"));
2195 assert_eq!(schema.outputs[1].type_, Some(LogicalType::Boxes));
2196
2197 let round = serde_json::to_string(&schema).unwrap();
2199 let first_obj = round
2200 .split("\"outputs\":[")
2201 .nth(1)
2202 .and_then(|s| s.split("}").next())
2203 .expect("outputs array");
2204 assert!(
2205 !first_obj.contains("\"type\""),
2206 "typeless output must not serialize a `type` field, got: {first_obj}"
2207 );
2208 }
2209
2210 #[test]
2216 fn typeless_outputs_filtered_from_legacy_config() {
2217 let schema = SchemaV2 {
2218 schema_version: 2,
2219 input: None,
2220 outputs: vec![
2221 LogicalOutput {
2222 name: Some("diagnostic_histogram".into()),
2223 type_: None,
2224 shape: vec![1, 256],
2225 dshape: vec![],
2226 decoder: None,
2227 encoding: None,
2228 score_format: None,
2229 normalized: None,
2230 anchors: None,
2231 stride: None,
2232 dtype: None,
2233 quantization: None,
2234 outputs: vec![],
2235 activation_applied: None,
2236 activation_required: None,
2237 },
2238 LogicalOutput {
2239 name: Some("boxes".into()),
2240 type_: Some(LogicalType::Boxes),
2241 shape: vec![1, 4, 8400],
2242 dshape: vec![],
2243 decoder: Some(DecoderKind::Ultralytics),
2244 encoding: Some(BoxEncoding::Direct),
2245 score_format: None,
2246 normalized: Some(true),
2247 anchors: None,
2248 stride: None,
2249 dtype: None,
2250 quantization: None,
2251 outputs: vec![],
2252 activation_applied: None,
2253 activation_required: None,
2254 },
2255 ],
2256 nms: None,
2257 nms_multi_label: None,
2258 decoder_version: None,
2259 };
2260 let legacy = schema.to_legacy_config_outputs().unwrap();
2261 assert_eq!(
2262 legacy.outputs.len(),
2263 1,
2264 "typeless output must be filtered from legacy config"
2265 );
2266 assert!(
2267 matches!(legacy.outputs[0], ConfigOutput::Boxes(_)),
2268 "only the typed `boxes` output should survive lowering"
2269 );
2270 }
2271
2272 #[test]
2277 fn all_typeless_schema_produces_empty_legacy_config() {
2278 let schema = SchemaV2 {
2279 schema_version: 2,
2280 input: None,
2281 outputs: vec![LogicalOutput {
2282 name: Some("aux".into()),
2283 type_: None,
2284 shape: vec![1, 8],
2285 dshape: vec![],
2286 decoder: None,
2287 encoding: None,
2288 score_format: None,
2289 normalized: None,
2290 anchors: None,
2291 stride: None,
2292 dtype: None,
2293 quantization: None,
2294 outputs: vec![],
2295 activation_applied: None,
2296 activation_required: None,
2297 }],
2298 nms: None,
2299 nms_multi_label: None,
2300 decoder_version: None,
2301 };
2302 let legacy = schema.to_legacy_config_outputs().unwrap();
2303 assert!(legacy.outputs.is_empty());
2304 }
2305
2306 #[test]
2312 fn typeless_physical_child_parses_and_skips_uniqueness() {
2313 let j = r#"{
2314 "name": "boxes",
2315 "type": "boxes",
2316 "shape": [1, 8400, 4],
2317 "outputs": [
2318 {
2319 "name": "boxes_xy",
2320 "type": "boxes_xy",
2321 "shape": [1, 8400, 2],
2322 "dtype": "float32"
2323 },
2324 {
2325 "name": "aux_user_managed",
2326 "shape": [1, 8400, 2],
2327 "dtype": "float32"
2328 }
2329 ]
2330 }"#;
2331 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2332 assert_eq!(lo.outputs.len(), 2);
2333 assert_eq!(lo.outputs[0].type_, Some(PhysicalType::BoxesXy));
2334 assert_eq!(lo.outputs[1].type_, None);
2335
2336 let schema = SchemaV2 {
2340 schema_version: 2,
2341 input: None,
2342 outputs: vec![lo],
2343 nms: None,
2344 nms_multi_label: None,
2345 decoder_version: None,
2346 };
2347 schema.validate().expect(
2348 "typed + typeless children with equal shape must not trigger \
2349 uniqueness error",
2350 );
2351
2352 let s = serde_json::to_string(&schema).unwrap();
2354 assert!(
2355 s.contains("\"aux_user_managed\""),
2356 "typeless child must survive round-trip: {s}"
2357 );
2358 let aux_obj = s
2360 .split("\"aux_user_managed\"")
2361 .nth(1)
2362 .and_then(|s| s.split('}').next())
2363 .unwrap_or("");
2364 assert!(
2365 !aux_obj.contains("\"type\""),
2366 "typeless child must not serialize `type`, got: {aux_obj}"
2367 );
2368 }
2369
2370 #[test]
2371 fn from_v1_modelpack_anchor_detection_maps_encoding() {
2372 let v1 = ConfigOutputs {
2373 outputs: vec![ConfigOutput::Detection(crate::configs::Detection {
2374 anchors: Some(vec![[0.1, 0.2], [0.3, 0.4]]),
2375 decoder: crate::configs::DecoderType::ModelPack,
2376 quantization: Some(crate::configs::QuantTuple(0.176, 198)),
2377 shape: vec![1, 40, 40, 54],
2378 dshape: vec![],
2379 normalized: None,
2380 })],
2381 nms: None,
2382 nms_multi_label: None,
2383 decoder_version: None,
2384 };
2385 let v2 = SchemaV2::from_v1(&v1).unwrap();
2386 assert_eq!(v2.outputs[0].encoding, Some(BoxEncoding::Anchor));
2387 assert_eq!(v2.outputs[0].decoder, Some(DecoderKind::ModelPack));
2388 assert_eq!(v2.outputs[0].anchors.as_ref().map(|a| a.len()), Some(2));
2389 }
2390
2391 #[test]
2394 fn validate_accepts_flat_v2_yolov8_detection() {
2395 let j = r#"{
2396 "schema_version": 2,
2397 "outputs": [
2398 {"name":"boxes","type":"boxes","shape":[1,64,8400],
2399 "dtype":"int8","decoder":"ultralytics","encoding":"dfl"},
2400 {"name":"scores","type":"scores","shape":[1,80,8400],
2401 "dtype":"int8","decoder":"ultralytics","score_format":"per_class"}
2402 ]
2403 }"#;
2404 SchemaV2::parse_json(j).unwrap().validate().unwrap();
2405 }
2406
2407 #[test]
2408 fn validate_rejects_unnamed_physical_child() {
2409 let j = r#"{
2410 "schema_version": 2,
2411 "outputs": [{
2412 "name":"boxes","type":"boxes","shape":[1,64,8400],
2413 "encoding":"dfl","decoder":"ultralytics",
2414 "outputs": [{
2415 "name":"","type":"boxes","stride":8,
2416 "shape":[1,80,80,64],"dtype":"uint8"
2417 }]
2418 }]
2419 }"#;
2420 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2421 let msg = format!("{err}");
2422 assert!(msg.contains("missing `name`"), "got: {msg}");
2423 }
2424
2425 #[test]
2426 fn validate_rejects_duplicate_physical_shapes() {
2427 let j = r#"{
2428 "schema_version": 2,
2429 "outputs": [{
2430 "name":"boxes","type":"boxes","shape":[1,64,8400],
2431 "encoding":"dfl","decoder":"ultralytics",
2432 "outputs": [
2433 {"name":"a","type":"boxes","stride":8,"shape":[1,80,80,64],"dtype":"uint8"},
2434 {"name":"b","type":"boxes","stride":16,"shape":[1,80,80,64],"dtype":"uint8"}
2435 ]
2436 }]
2437 }"#;
2438 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2439 let msg = format!("{err}");
2440 assert!(msg.contains("share shape"), "got: {msg}");
2441 }
2442
2443 #[test]
2444 fn validate_rejects_mixed_decomposition() {
2445 let j = r#"{
2447 "schema_version": 2,
2448 "outputs": [{
2449 "name":"boxes","type":"boxes","shape":[1,4,8400,1],
2450 "encoding":"direct","decoder":"ultralytics",
2451 "outputs": [
2452 {"name":"xy","type":"boxes_xy","shape":[1,2,8400,1],"dtype":"int16"},
2453 {"name":"p0","type":"boxes","stride":8,"shape":[1,80,80,64],"dtype":"uint8"}
2454 ]
2455 }]
2456 }"#;
2457 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2458 let msg = format!("{err}");
2459 assert!(msg.contains("uniform"), "got: {msg}");
2460 }
2461
2462 #[test]
2463 fn validate_rejects_dfl_boxes_feature_not_divisible_by_4() {
2464 let j = r#"{
2465 "schema_version": 2,
2466 "outputs": [{
2467 "name":"boxes","type":"boxes","shape":[1,63,8400],
2468 "encoding":"dfl","decoder":"ultralytics",
2469 "outputs": [{
2470 "name":"b0","type":"boxes","stride":8,
2471 "shape":[1,80,80,63],
2472 "dshape":[{"batch":1},{"height":80},{"width":80},{"num_features":63}],
2473 "dtype":"uint8"
2474 }]
2475 }]
2476 }"#;
2477 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2478 let msg = format!("{err}");
2479 assert!(msg.contains("not"), "got: {msg}");
2480 assert!(msg.contains("divisible by 4"), "got: {msg}");
2481 }
2482
2483 #[test]
2484 fn validate_accepts_hailo_per_scale_yolov8() {
2485 let j = r#"{
2486 "schema_version": 2,
2487 "outputs": [{
2488 "name":"boxes","type":"boxes","shape":[1,64,8400],
2489 "encoding":"dfl","decoder":"ultralytics","normalized":true,
2490 "outputs": [
2491 {"name":"b0","type":"boxes","stride":8,
2492 "shape":[1,80,80,64],
2493 "dshape":[{"batch":1},{"height":80},{"width":80},{"num_features":64}],
2494 "dtype":"uint8",
2495 "quantization":{"scale":0.0234,"zero_point":128,"dtype":"uint8"}},
2496 {"name":"b1","type":"boxes","stride":16,
2497 "shape":[1,40,40,64],
2498 "dshape":[{"batch":1},{"height":40},{"width":40},{"num_features":64}],
2499 "dtype":"uint8",
2500 "quantization":{"scale":0.0198,"zero_point":130,"dtype":"uint8"}},
2501 {"name":"b2","type":"boxes","stride":32,
2502 "shape":[1,20,20,64],
2503 "dshape":[{"batch":1},{"height":20},{"width":20},{"num_features":64}],
2504 "dtype":"uint8",
2505 "quantization":{"scale":0.0312,"zero_point":125,"dtype":"uint8"}}
2506 ]
2507 }]
2508 }"#;
2509 let s = SchemaV2::parse_json(j).unwrap();
2510 s.validate().unwrap();
2511 }
2512
2513 #[test]
2514 fn validate_accepts_ara2_xy_wh() {
2515 let j = r#"{
2516 "schema_version": 2,
2517 "outputs": [{
2518 "name":"boxes","type":"boxes","shape":[1,4,8400,1],
2519 "encoding":"direct","decoder":"ultralytics","normalized":true,
2520 "outputs": [
2521 {"name":"xy","type":"boxes_xy","shape":[1,2,8400,1],
2522 "dshape":[{"batch":1},{"box_coords":2},{"num_boxes":8400},{"padding":1}],
2523 "dtype":"int16",
2524 "quantization":{"scale":3.1e-5,"zero_point":0,"dtype":"int16"}},
2525 {"name":"wh","type":"boxes_wh","shape":[1,2,8400,1],
2526 "dshape":[{"batch":1},{"box_coords":2},{"num_boxes":8400},{"padding":1}],
2527 "dtype":"int16",
2528 "quantization":{"scale":3.2e-5,"zero_point":0,"dtype":"int16"}}
2529 ]
2530 }]
2531 }"#;
2532 SchemaV2::parse_json(j).unwrap().validate().unwrap();
2533 }
2534
2535 #[test]
2536 fn parse_file_auto_detects_json() {
2537 let tmp = std::env::temp_dir().join(format!("schema_v2_test_{}.json", std::process::id()));
2538 std::fs::write(&tmp, r#"{"schema_version":2,"outputs":[]}"#).unwrap();
2539 let s = SchemaV2::parse_file(&tmp).unwrap();
2540 assert_eq!(s.schema_version, 2);
2541 let _ = std::fs::remove_file(&tmp);
2542 }
2543
2544 #[test]
2545 fn parse_file_auto_detects_yaml() {
2546 let tmp = std::env::temp_dir().join(format!("schema_v2_test_{}.yaml", std::process::id()));
2547 std::fs::write(&tmp, "schema_version: 2\noutputs: []\n").unwrap();
2548 let s = SchemaV2::parse_file(&tmp).unwrap();
2549 assert_eq!(s.schema_version, 2);
2550 let _ = std::fs::remove_file(&tmp);
2551 }
2552
2553 #[test]
2556 fn parse_real_ara2_int8_dvm_metadata() {
2557 let json = edgefirst_bench::testdata::read_to_string("ara2_int8_edgefirst.json");
2558 let schema = SchemaV2::parse_json(&json).expect("ARA-2 int8 parse");
2559 assert_eq!(schema.schema_version, 2);
2560 assert_eq!(schema.decoder_version, Some(DecoderVersion::Yolov8));
2561 assert_eq!(schema.nms, Some(NmsMode::ClassAgnostic));
2562 assert_eq!(schema.input.as_ref().unwrap().shape, vec![1, 3, 640, 640]);
2563
2564 assert_eq!(schema.outputs.len(), 4);
2566 let boxes = &schema.outputs[0];
2567 assert_eq!(boxes.type_, Some(LogicalType::Boxes));
2568 assert_eq!(boxes.encoding, Some(BoxEncoding::Direct));
2569 assert_eq!(boxes.normalized, Some(true));
2570 assert_eq!(boxes.shape, vec![1, 4, 8400, 1]); assert_eq!(boxes.outputs.len(), 2);
2572 assert_eq!(boxes.outputs[0].type_, Some(PhysicalType::BoxesXy));
2573 assert_eq!(boxes.outputs[1].type_, Some(PhysicalType::BoxesWh));
2574 let q_xy = boxes.outputs[0].quantization.as_ref().unwrap();
2576 assert_eq!(q_xy.dtype, Some(DType::Int8));
2577 assert!((q_xy.scale[0] - 0.004_177_792).abs() < 1e-6);
2578 assert_eq!(q_xy.zero_point_at(0), -122);
2579
2580 let scores = &schema.outputs[1];
2581 assert_eq!(scores.type_, Some(LogicalType::Scores));
2582 assert_eq!(scores.score_format, Some(ScoreFormat::PerClass));
2583 assert_eq!(scores.shape, vec![1, 80, 8400, 1]);
2584
2585 let mask_coefs = &schema.outputs[2];
2586 assert_eq!(mask_coefs.type_, Some(LogicalType::MaskCoefs));
2587 assert_eq!(mask_coefs.shape, vec![1, 32, 8400, 1]);
2588
2589 let protos = &schema.outputs[3];
2590 assert_eq!(protos.type_, Some(LogicalType::Protos));
2591 assert_eq!(protos.shape, vec![1, 32, 160, 160]);
2592
2593 schema.validate().expect("ARA-2 int8 validate");
2595 }
2596
2597 #[test]
2598 fn parse_real_ara2_int16_dvm_metadata() {
2599 let json = edgefirst_bench::testdata::read_to_string("ara2_int16_edgefirst.json");
2600 let schema = SchemaV2::parse_json(&json).expect("ARA-2 int16 parse");
2601 assert_eq!(schema.schema_version, 2);
2602 assert_eq!(schema.outputs.len(), 4);
2603 let boxes = &schema.outputs[0];
2604 assert_eq!(boxes.outputs.len(), 2);
2605 let q_xy = boxes.outputs[0].quantization.as_ref().unwrap();
2606 assert_eq!(q_xy.dtype, Some(DType::Int16));
2607 assert!((q_xy.scale[0] - 3.211_570_6e-5).abs() < 1e-10);
2608 assert_eq!(q_xy.zero_point_at(0), 0);
2609 let mc_q = schema.outputs[2].quantization.as_ref().unwrap();
2611 assert_eq!(mc_q.dtype, Some(DType::Int16));
2612 schema.validate().expect("ARA-2 int16 validate");
2613 }
2614
2615 #[test]
2616 fn parse_yaml_with_explicit_schema_version_2() {
2617 let yaml = r#"
2618schema_version: 2
2619outputs:
2620 - name: scores
2621 type: scores
2622 shape: [1, 80, 8400]
2623 dtype: int8
2624 quantization:
2625 scale: 0.00392
2626 dtype: int8
2627 decoder: ultralytics
2628 score_format: per_class
2629"#;
2630 let schema = SchemaV2::parse_yaml(yaml).unwrap();
2631 assert_eq!(schema.schema_version, 2);
2632 assert_eq!(schema.outputs[0].score_format, Some(ScoreFormat::PerClass));
2633 }
2634
2635 #[test]
2638 fn squeeze_padding_dims_preserves_shape_when_dshape_absent() {
2639 let (shape, dshape) = squeeze_padding_dims(vec![1, 4, 8400], vec![]);
2644 assert_eq!(shape, vec![1, 4, 8400]);
2645 assert!(dshape.is_empty());
2646 }
2647
2648 fn sample_logical(ty: LogicalType) -> LogicalOutput {
2649 LogicalOutput {
2650 name: Some("sample".into()),
2651 type_: Some(ty),
2652 shape: vec![1, 4, 100],
2653 dshape: vec![],
2654 decoder: None,
2655 encoding: None,
2656 score_format: None,
2657 normalized: Some(true),
2658 anchors: None,
2659 stride: None,
2660 dtype: None,
2661 quantization: None,
2662 outputs: vec![],
2663 activation_applied: None,
2664 activation_required: None,
2665 }
2666 }
2667
2668 #[test]
2669 fn logical_to_legacy_maps_each_supported_type() {
2670 let cases = [
2671 (LogicalType::Boxes, "Boxes"),
2672 (LogicalType::Scores, "Scores"),
2673 (LogicalType::Protos, "Protos"),
2674 (LogicalType::MaskCoefs, "MaskCoefficients"),
2675 (LogicalType::Segmentation, "Segmentation"),
2676 (LogicalType::Masks, "Mask"),
2677 (LogicalType::Classes, "Classes"),
2678 (LogicalType::Detection, "Detection"),
2679 (LogicalType::Detections, "Detection"),
2680 ];
2681 for (ty, expected) in cases {
2682 let out = logical_to_legacy_config_output(&sample_logical(ty)).unwrap();
2683 let name = match &out {
2684 ConfigOutput::Boxes(_) => "Boxes",
2685 ConfigOutput::Scores(_) => "Scores",
2686 ConfigOutput::Protos(_) => "Protos",
2687 ConfigOutput::MaskCoefficients(_) => "MaskCoefficients",
2688 ConfigOutput::Segmentation(_) => "Segmentation",
2689 ConfigOutput::Mask(_) => "Mask",
2690 ConfigOutput::Classes(_) => "Classes",
2691 ConfigOutput::Detection(_) => "Detection",
2692 };
2693 assert_eq!(name, expected, "type {ty:?}");
2694 }
2695 }
2696
2697 #[test]
2698 fn logical_to_legacy_rejects_objectness_and_landmarks() {
2699 for ty in [LogicalType::Objectness, LogicalType::Landmarks] {
2700 let err = logical_to_legacy_config_output(&sample_logical(ty)).unwrap_err();
2701 assert!(
2702 matches!(err, DecoderError::NotSupported(_)),
2703 "expected NotSupported for {ty:?}, got {err:?}"
2704 );
2705 }
2706 }
2707
2708 #[test]
2709 fn logical_to_legacy_rejects_typeless_output() {
2710 let mut logical = sample_logical(LogicalType::Boxes);
2711 logical.type_ = None;
2712 let err = logical_to_legacy_config_output(&logical).unwrap_err();
2713 assert!(matches!(err, DecoderError::InvalidConfig(_)));
2714 }
2715
2716 #[test]
2717 fn to_legacy_modelpack_boxes_preserves_padding_dim() {
2718 let j = r#"{
2723 "schema_version": 2,
2724 "outputs": [
2725 {"name":"boxes","type":"boxes",
2726 "shape":[1,1935,1,4],
2727 "dshape":[{"batch":1},{"num_boxes":1935},{"padding":1},{"box_coords":4}],
2728 "decoder":"modelpack"}
2729 ]
2730 }"#;
2731 let schema = SchemaV2::parse_json(j).unwrap();
2732 let legacy = schema.to_legacy_config_outputs().expect("lowers cleanly");
2733 let boxes = match &legacy.outputs[0] {
2734 crate::ConfigOutput::Boxes(b) => b,
2735 other => panic!("expected Boxes, got {other:?}"),
2736 };
2737 assert_eq!(boxes.shape, vec![1, 1935, 1, 4]);
2740 assert_eq!(
2741 boxes.dshape,
2742 vec![
2743 (DimName::Batch, 1),
2744 (DimName::NumBoxes, 1935),
2745 (DimName::Padding, 1),
2746 (DimName::BoxCoords, 4),
2747 ]
2748 );
2749 }
2750
2751 #[test]
2752 fn to_legacy_preserves_shape_for_v2_split_boxes_without_dshape() {
2753 let j = r#"{
2757 "schema_version": 2,
2758 "outputs": [
2759 {"name":"boxes","type":"boxes","shape":[1,4,8400],
2760 "dtype":"float32","decoder":"ultralytics","encoding":"direct"},
2761 {"name":"scores","type":"scores","shape":[1,80,8400],
2762 "dtype":"float32","decoder":"ultralytics","score_format":"per_class"}
2763 ]
2764 }"#;
2765 let schema = SchemaV2::parse_json(j).unwrap();
2766 let legacy = schema.to_legacy_config_outputs().expect("lowers cleanly");
2767 let boxes = match &legacy.outputs[0] {
2768 crate::ConfigOutput::Boxes(b) => b,
2769 other => panic!("expected Boxes, got {other:?}"),
2770 };
2771 assert_eq!(boxes.shape, vec![1, 4, 8400]);
2772 let scores = match &legacy.outputs[1] {
2773 crate::ConfigOutput::Scores(s) => s,
2774 other => panic!("expected Scores, got {other:?}"),
2775 };
2776 assert_eq!(scores.shape, vec![1, 80, 8400]);
2777 }
2778}