1use crate::configs::{self, deserialize_dshape, DimName, QuantTuple};
47use crate::{ConfigOutput, ConfigOutputs, DecoderError, DecoderResult};
48use serde::{Deserialize, Serialize};
49
50pub use edgefirst_tensor::{
51 FitMode, Interpolation, LetterboxPlacement, NormalizationPreset, PadSpace,
52};
53
54pub const MAX_SUPPORTED_SCHEMA_VERSION: u32 = 2;
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78#[non_exhaustive]
79pub struct SchemaV2 {
80 pub schema_version: u32,
82
83 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub input: Option<InputSpec>,
90
91 #[serde(default, skip_serializing_if = "Vec::is_empty")]
94 pub outputs: Vec<LogicalOutput>,
95
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub nms: Option<NmsMode>,
99
100 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub nms_multi_label: Option<bool>,
104
105 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub decoder_version: Option<DecoderVersion>,
111}
112
113impl SchemaV2 {
114 pub fn new() -> Self {
116 Self::default()
117 }
118}
119
120impl Default for SchemaV2 {
121 fn default() -> Self {
122 Self {
123 schema_version: 2,
124 input: None,
125 outputs: Vec::new(),
126 nms: None,
127 nms_multi_label: None,
128 decoder_version: None,
129 }
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135pub struct InputSpec {
136 pub shape: Vec<usize>,
138
139 #[serde(
142 default,
143 deserialize_with = "deserialize_dshape",
144 skip_serializing_if = "Vec::is_empty"
145 )]
146 pub dshape: Vec<(DimName, usize)>,
147
148 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub cameraadaptor: Option<String>,
153
154 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub preprocessing: Option<Preprocessing>,
160}
161
162impl InputSpec {
163 pub fn channels(&self) -> Option<usize> {
179 if !self.dshape.is_empty() {
180 let mut rest = self.dshape.iter().enumerate().filter(|(_, (name, _))| {
181 !matches!(name, DimName::Batch | DimName::Height | DimName::Width)
182 });
183 return match (rest.next(), rest.next()) {
184 (Some((i, _)), None) => self.shape.get(i).copied(),
185 _ => None,
186 };
187 }
188 let [_, c1, _, c3] = self.shape.as_slice() else {
189 return None;
190 };
191 let is_channels = |c: usize| (1..=4).contains(&c);
192 match (is_channels(*c1), is_channels(*c3)) {
193 (true, false) => Some(*c1),
194 (false, true) => Some(*c3),
195 _ => None,
196 }
197 }
198}
199
200#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
220#[non_exhaustive]
221pub struct Preprocessing {
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub normalization: Option<NormalizationConfig>,
225
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub resize: Option<FitMode>,
229
230 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub interpolation: Option<Interpolation>,
234
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub letterbox: Option<Letterbox>,
238}
239
240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
243#[serde(untagged)]
244pub enum NormalizationConfig {
245 Preset(NormalizationPreset),
247 MeanStd {
250 mean: Vec<f32>,
252 std: Vec<f32>,
254 },
255}
256
257impl NormalizationConfig {
258 pub fn mean_std(&self, channels: usize) -> DecoderResult<(Vec<f32>, Vec<f32>)> {
277 let (mean, std): (&[f32], &[f32]) = match self {
278 NormalizationConfig::Preset(NormalizationPreset::Imagenet) if channels != 3 => {
279 return Err(DecoderError::InvalidConfig(format!(
280 "imagenet normalization needs 3 channels, got {channels}"
281 )));
282 }
283 NormalizationConfig::Preset(p) => {
284 let table = edgefirst_tensor::Normalization::preset(*p);
286 let n = if channels == 3 { 3 } else { 1 };
287 return Ok((
288 broadcast_channels("mean", &table.mean()[..n], channels)?,
289 broadcast_channels("std", &table.std()[..n], channels)?,
290 ));
291 }
292 NormalizationConfig::MeanStd { mean, std } => (mean, std),
293 };
294 let mean = broadcast_channels("mean", mean, channels)?;
295 let std = broadcast_channels("std", std, channels)?;
296 if let Some(bad) = mean.iter().find(|m| !m.is_finite()) {
297 return Err(DecoderError::InvalidConfig(format!(
298 "normalization mean must be finite, got {bad}"
299 )));
300 }
301 if let Some(bad) = std.iter().find(|s| **s <= 0.0 || !s.is_finite()) {
302 return Err(DecoderError::InvalidConfig(format!(
303 "normalization std must be finite and positive, got {bad}"
304 )));
305 }
306 Ok((mean, std))
307 }
308}
309
310fn broadcast_channels(field: &str, values: &[f32], channels: usize) -> DecoderResult<Vec<f32>> {
311 match values.len() {
312 1 => Ok(vec![values[0]; channels]),
313 n if n == channels => Ok(values.to_vec()),
314 n => Err(DecoderError::InvalidConfig(format!(
315 "normalization {field} has {n} values; expected 1 or {channels}"
316 ))),
317 }
318}
319
320#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
322#[non_exhaustive]
323pub struct Letterbox {
324 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub placement: Option<LetterboxPlacement>,
327
328 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub pad: Option<Vec<f32>>,
332
333 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub pad_space: Option<PadSpace>,
336}
337
338#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
346pub struct LogicalOutput {
347 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub name: Option<String>,
350
351 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
358 pub type_: Option<LogicalType>,
359
360 pub shape: Vec<usize>,
363
364 #[serde(
366 default,
367 deserialize_with = "deserialize_dshape",
368 skip_serializing_if = "Vec::is_empty"
369 )]
370 pub dshape: Vec<(DimName, usize)>,
371
372 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub decoder: Option<DecoderKind>,
376
377 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub encoding: Option<BoxEncoding>,
380
381 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub score_format: Option<ScoreFormat>,
384
385 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub normalized: Option<bool>,
391
392 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub anchors: Option<Vec<[f32; 2]>>,
396
397 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub stride: Option<Stride>,
402
403 #[serde(default, skip_serializing_if = "Option::is_none")]
406 pub dtype: Option<DType>,
407
408 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub quantization: Option<Quantization>,
412
413 #[serde(default, skip_serializing_if = "Vec::is_empty")]
417 pub outputs: Vec<PhysicalOutput>,
418
419 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub activation_applied: Option<Activation>,
428
429 #[serde(default, skip_serializing_if = "Option::is_none")]
438 pub activation_required: Option<Activation>,
439
440 #[serde(flatten)]
443 pub semantic: SemanticParams,
444}
445
446#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
451#[non_exhaustive]
452pub struct SemanticParams {
453 #[serde(default, skip_serializing_if = "Option::is_none")]
455 pub upsample: Option<Upsample>,
456
457 #[serde(default, skip_serializing_if = "Option::is_none")]
459 pub decode_mode: Option<DecodeMode>,
460
461 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub threshold: Option<f32>,
466
467 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub background_index: Option<u32>,
470
471 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub no_object_index: Option<u32>,
475
476 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub mask_activation: Option<MaskActivation>,
479}
480
481#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
483pub struct Upsample {
484 pub method: UpsampleMethod,
486 #[serde(default)]
488 pub align_corners: bool,
489}
490
491#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
493#[serde(rename_all = "snake_case")]
494#[non_exhaustive]
495pub enum UpsampleMethod {
496 Bilinear,
497 Nearest,
498 Bicubic,
499}
500
501edgefirst_tensor::ef_vocabulary! {
502 #[derive(Serialize, Deserialize)]
506 #[serde(rename_all = "snake_case")]
507 #[non_exhaustive]
508 pub enum DecodeMode {
509 Accurate = 0, "accurate", ACCURATE,
511 Fast = 1, "fast", FAST,
513 }
514 #[doc(hidden)]
515 pub mod decode_mode_code;
516}
517
518#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
520#[serde(rename_all = "snake_case")]
521#[non_exhaustive]
522pub enum MaskActivation {
523 Sigmoid,
525 SoftmaxOverQueries,
527}
528
529impl LogicalOutput {
530 pub fn is_split(&self) -> bool {
533 !self.outputs.is_empty()
534 }
535}
536
537#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
543pub struct PhysicalOutput {
544 pub name: String,
548
549 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
556 pub type_: Option<PhysicalType>,
557
558 pub shape: Vec<usize>,
560
561 #[serde(
564 default,
565 deserialize_with = "deserialize_dshape",
566 skip_serializing_if = "Vec::is_empty"
567 )]
568 pub dshape: Vec<(DimName, usize)>,
569
570 pub dtype: DType,
572
573 #[serde(default, skip_serializing_if = "Option::is_none")]
576 pub quantization: Option<Quantization>,
577
578 #[serde(default, skip_serializing_if = "Option::is_none")]
581 pub stride: Option<Stride>,
582
583 #[serde(default, skip_serializing_if = "Option::is_none")]
586 pub scale_index: Option<usize>,
587
588 #[serde(default, skip_serializing_if = "Option::is_none")]
592 pub activation_applied: Option<Activation>,
593
594 #[serde(default, skip_serializing_if = "Option::is_none")]
597 pub activation_required: Option<Activation>,
598}
599
600#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
606pub struct Quantization {
607 #[serde(deserialize_with = "deserialize_scalar_or_vec_f32")]
610 pub scale: Vec<f32>,
611
612 #[serde(
616 default,
617 deserialize_with = "deserialize_opt_scalar_or_vec_i32",
618 skip_serializing_if = "Option::is_none"
619 )]
620 pub zero_point: Option<Vec<i32>>,
621
622 #[serde(default, skip_serializing_if = "Option::is_none")]
625 pub axis: Option<usize>,
626
627 #[serde(default, skip_serializing_if = "Option::is_none")]
631 pub dtype: Option<DType>,
632}
633
634impl Quantization {
635 pub fn is_per_tensor(&self) -> bool {
637 self.scale.len() == 1
638 }
639
640 pub fn is_per_channel(&self) -> bool {
642 self.scale.len() > 1
643 }
644
645 pub fn is_symmetric(&self) -> bool {
647 match &self.zero_point {
648 None => true,
649 Some(zps) => zps.iter().all(|&z| z == 0),
650 }
651 }
652
653 pub fn zero_point_at(&self, channel: usize) -> i32 {
656 match &self.zero_point {
657 None => 0,
658 Some(zps) if zps.len() == 1 => zps[0],
659 Some(zps) => zps.get(channel).copied().unwrap_or(0),
660 }
661 }
662
663 pub fn scale_at(&self, channel: usize) -> f32 {
665 if self.scale.len() == 1 {
666 self.scale[0]
667 } else {
668 self.scale.get(channel).copied().unwrap_or(0.0)
669 }
670 }
671}
672
673impl TryFrom<&Quantization> for edgefirst_tensor::Quantization {
683 type Error = edgefirst_tensor::Error;
684
685 fn try_from(q: &Quantization) -> Result<Self, Self::Error> {
686 match (q.scale.as_slice(), q.zero_point.as_deref(), q.axis) {
687 ([scale], None, None) => Ok(Self::per_tensor_symmetric(*scale)),
689 ([scale], Some([zp]), None) => Ok(Self::per_tensor(*scale, *zp)),
691 ([scale], Some([zp]), Some(_)) => Ok(Self::per_tensor(*scale, *zp)),
693 ([scale], None, Some(_)) => Ok(Self::per_tensor_symmetric(*scale)),
694 (scales, None, Some(axis)) if scales.len() > 1 => {
696 Self::per_channel_symmetric(scales.to_vec(), axis)
697 }
698 (scales, Some(zps), Some(axis)) if scales.len() > 1 => {
699 Self::per_channel(scales.to_vec(), zps.to_vec(), axis)
700 }
701 (scales, _, None) if scales.len() > 1 => {
703 Err(edgefirst_tensor::Error::QuantizationInvalid {
704 field: "axis",
705 expected: "Some(axis) for per-channel".into(),
706 got: "None".into(),
707 })
708 }
709 _ => Err(edgefirst_tensor::Error::QuantizationInvalid {
710 field: "scale",
711 expected: "non-empty".into(),
712 got: format!("len={}", q.scale.len()),
713 }),
714 }
715 }
716}
717
718fn deserialize_scalar_or_vec_f32<'de, D>(de: D) -> Result<Vec<f32>, D::Error>
720where
721 D: serde::Deserializer<'de>,
722{
723 #[derive(Deserialize)]
724 #[serde(untagged)]
725 enum OneOrMany {
726 One(f32),
727 Many(Vec<f32>),
728 }
729 match OneOrMany::deserialize(de)? {
730 OneOrMany::One(v) => Ok(vec![v]),
731 OneOrMany::Many(vs) => Ok(vs),
732 }
733}
734
735fn deserialize_opt_scalar_or_vec_i32<'de, D>(de: D) -> Result<Option<Vec<i32>>, D::Error>
737where
738 D: serde::Deserializer<'de>,
739{
740 #[derive(Deserialize)]
741 #[serde(untagged)]
742 enum OneOrMany {
743 One(i32),
744 Many(Vec<i32>),
745 }
746 match Option::<OneOrMany>::deserialize(de)? {
747 None => Ok(None),
748 Some(OneOrMany::One(v)) => Ok(Some(vec![v])),
749 Some(OneOrMany::Many(vs)) => Ok(Some(vs)),
750 }
751}
752
753#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
756#[serde(untagged)]
757pub enum Stride {
758 Square(u32),
759 Rect([u32; 2]),
760}
761
762impl Stride {
763 pub fn x(self) -> u32 {
765 match self {
766 Stride::Square(s) => s,
767 Stride::Rect([sx, _]) => sx,
768 }
769 }
770
771 pub fn y(self) -> u32 {
773 match self {
774 Stride::Square(s) => s,
775 Stride::Rect([_, sy]) => sy,
776 }
777 }
778}
779
780#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
782#[serde(rename_all = "snake_case")]
783pub enum LogicalType {
784 Boxes,
786 Scores,
788 Objectness,
790 Classes,
792 MaskCoefs,
794 Protos,
796 Landmarks,
798 Detections,
800 Segmentation,
803 Masks,
806 Detection,
808 ClassLogits,
811 MaskLogits,
814}
815
816#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
822#[serde(rename_all = "snake_case")]
823pub enum PhysicalType {
824 Boxes,
825 Scores,
826 Objectness,
827 Classes,
828 MaskCoefs,
829 Protos,
830 Landmarks,
831 Detections,
832 Segmentation,
833 Masks,
834 Detection,
835 ClassLogits,
836 MaskLogits,
837 BoxesXy,
839 BoxesWh,
841}
842
843#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
845#[serde(rename_all = "snake_case")]
846pub enum BoxEncoding {
847 Dfl,
850 #[serde(alias = "ltrb")]
853 Direct,
854 Anchor,
857}
858
859#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
861#[serde(rename_all = "snake_case")]
862pub enum ScoreFormat {
863 PerClass,
866 ObjXClass,
870}
871
872#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
874#[serde(rename_all = "snake_case")]
875pub enum Activation {
876 Sigmoid,
877 Softmax,
878 Tanh,
879}
880
881#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
886#[repr(u32)]
887pub enum DecoderKind {
888 #[serde(rename = "modelpack")]
890 ModelPack = configs::decoder_type_code::MODELPACK,
891 #[serde(rename = "ultralytics")]
893 Ultralytics = configs::decoder_type_code::ULTRALYTICS,
894 #[serde(rename = "semantic")]
897 Semantic = configs::decoder_type_code::SEMANTIC,
898}
899
900impl DecoderKind {
901 pub const fn code(self) -> u32 {
903 self as u32
904 }
905}
906
907#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
912#[serde(rename_all = "snake_case")]
913#[repr(u32)]
914pub enum DecoderVersion {
915 Yolov5 = configs::decoder_version_code::YOLOV5,
916 Yolov8 = configs::decoder_version_code::YOLOV8,
917 Yolo11 = configs::decoder_version_code::YOLO11,
918 Yolo26 = configs::decoder_version_code::YOLO26,
919}
920
921impl DecoderVersion {
922 pub const fn code(self) -> u32 {
924 self as u32
925 }
926
927 pub fn is_end_to_end(self) -> bool {
929 matches!(self, DecoderVersion::Yolo26)
930 }
931}
932
933#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
938#[serde(rename_all = "snake_case")]
939#[repr(u32)]
940pub enum NmsMode {
941 ClassAgnostic = configs::nms_code::CLASS_AGNOSTIC,
943 ClassAware = configs::nms_code::CLASS_AWARE,
946}
947
948#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
956#[repr(u32)]
957pub enum DType {
958 Int8 = edgefirst_tensor::protocol::dtype::I8,
959 Uint8 = edgefirst_tensor::protocol::dtype::U8,
960 Int16 = edgefirst_tensor::protocol::dtype::I16,
961 Uint16 = edgefirst_tensor::protocol::dtype::U16,
962 Int32 = edgefirst_tensor::protocol::dtype::I32,
963 Uint32 = edgefirst_tensor::protocol::dtype::U32,
964 Float16 = edgefirst_tensor::protocol::dtype::F16,
965 Float32 = edgefirst_tensor::protocol::dtype::F32,
966 Int64 = edgefirst_tensor::protocol::dtype::I64,
968}
969
970const DTYPE_LEGACY_NAMES: [(&str, DType); 9] = [
972 ("int8", DType::Int8),
973 ("uint8", DType::Uint8),
974 ("int16", DType::Int16),
975 ("uint16", DType::Uint16),
976 ("int32", DType::Int32),
977 ("uint32", DType::Uint32),
978 ("float16", DType::Float16),
979 ("float32", DType::Float32),
980 ("int64", DType::Int64),
981];
982
983impl DType {
984 pub fn all() -> &'static [DType] {
986 &[
987 DType::Int8,
988 DType::Uint8,
989 DType::Int16,
990 DType::Uint16,
991 DType::Int32,
992 DType::Uint32,
993 DType::Float16,
994 DType::Float32,
995 DType::Int64,
996 ]
997 }
998
999 pub const fn code(self) -> u32 {
1001 self as u32
1002 }
1003
1004 pub fn to_tensor_dtype(self) -> edgefirst_tensor::DType {
1006 match self {
1007 DType::Int8 => edgefirst_tensor::DType::I8,
1008 DType::Uint8 => edgefirst_tensor::DType::U8,
1009 DType::Int16 => edgefirst_tensor::DType::I16,
1010 DType::Uint16 => edgefirst_tensor::DType::U16,
1011 DType::Int32 => edgefirst_tensor::DType::I32,
1012 DType::Uint32 => edgefirst_tensor::DType::U32,
1013 DType::Float16 => edgefirst_tensor::DType::F16,
1014 DType::Float32 => edgefirst_tensor::DType::F32,
1015 DType::Int64 => edgefirst_tensor::DType::I64,
1016 }
1017 }
1018
1019 pub fn from_tensor_dtype(dtype: edgefirst_tensor::DType) -> Option<Self> {
1022 Self::all()
1023 .iter()
1024 .copied()
1025 .find(|d| d.code() == dtype.code())
1026 }
1027
1028 pub fn from_code(code: u32) -> Option<Self> {
1031 edgefirst_tensor::DType::from_code(code).and_then(Self::from_tensor_dtype)
1032 }
1033
1034 pub fn as_str(self) -> &'static str {
1036 self.to_tensor_dtype().as_str()
1037 }
1038
1039 pub fn from_str_code(s: &str) -> Option<Self> {
1042 edgefirst_tensor::DType::from_str_code(s)
1043 .and_then(Self::from_tensor_dtype)
1044 .or_else(|| {
1045 DTYPE_LEGACY_NAMES
1046 .iter()
1047 .find(|(name, _)| *name == s)
1048 .map(|(_, d)| *d)
1049 })
1050 }
1051
1052 pub fn accepted_names() -> impl Iterator<Item = &'static str> {
1054 Self::all()
1055 .iter()
1056 .map(|d| d.as_str())
1057 .chain(DTYPE_LEGACY_NAMES.iter().map(|(name, _)| *name))
1058 }
1059}
1060
1061impl Serialize for DType {
1062 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1063 serializer.serialize_str(self.as_str())
1064 }
1065}
1066
1067impl<'de> Deserialize<'de> for DType {
1068 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1069 let s = std::borrow::Cow::<'de, str>::deserialize(deserializer)?;
1070 DType::from_str_code(&s).ok_or_else(|| {
1071 let expected: Vec<&str> = DType::accepted_names().collect();
1072 serde::de::Error::custom(format!(
1073 "unknown dtype `{s}`, expected one of: {}",
1074 expected.join(", ")
1075 ))
1076 })
1077 }
1078}
1079
1080impl DType {
1081 pub fn size_bytes(self) -> usize {
1083 match self {
1084 DType::Int8 | DType::Uint8 => 1,
1085 DType::Int16 | DType::Uint16 | DType::Float16 => 2,
1086 DType::Int32 | DType::Uint32 | DType::Float32 => 4,
1087 DType::Int64 => 8,
1088 }
1089 }
1090
1091 pub fn is_integer(self) -> bool {
1093 matches!(
1094 self,
1095 DType::Int8
1096 | DType::Uint8
1097 | DType::Int16
1098 | DType::Uint16
1099 | DType::Int32
1100 | DType::Uint32
1101 | DType::Int64
1102 )
1103 }
1104
1105 pub fn is_float(self) -> bool {
1107 matches!(self, DType::Float16 | DType::Float32)
1108 }
1109}
1110
1111impl SchemaV2 {
1116 pub fn parse_json(s: &str) -> DecoderResult<Self> {
1124 let value: serde_json::Value = serde_json::from_str(s)?;
1125 Self::from_json_value(value)
1126 }
1127
1128 pub fn parse_yaml(s: &str) -> DecoderResult<Self> {
1132 let value: serde_yaml::Value = serde_yaml::from_str(s)?;
1133 let json = serde_json::to_value(value)
1134 .map_err(|e| DecoderError::InvalidConfig(format!("yaml→json bridge failed: {e}")))?;
1135 Self::from_json_value(json)
1136 }
1137
1138 pub fn parse_file(path: impl AsRef<std::path::Path>) -> DecoderResult<Self> {
1142 let path = path.as_ref();
1143 let content = std::fs::read_to_string(path)
1144 .map_err(|e| DecoderError::InvalidConfig(format!("read {}: {e}", path.display())))?;
1145 let ext = path
1146 .extension()
1147 .and_then(|e| e.to_str())
1148 .map(str::to_ascii_lowercase);
1149 match ext.as_deref() {
1150 Some("json") => Self::parse_json(&content),
1151 Some("yaml") | Some("yml") => Self::parse_yaml(&content),
1152 _ => Self::parse_json(&content).or_else(|_| Self::parse_yaml(&content)),
1153 }
1154 }
1155
1156 pub fn from_json_value(value: serde_json::Value) -> DecoderResult<Self> {
1159 let version = value
1160 .get("schema_version")
1161 .and_then(|v| v.as_u64())
1162 .map(|v| v as u32)
1163 .unwrap_or(1);
1164
1165 if version > MAX_SUPPORTED_SCHEMA_VERSION {
1166 return Err(DecoderError::NotSupported(format!(
1167 "schema_version {version} is not supported by this HAL \
1168 (maximum supported version is {MAX_SUPPORTED_SCHEMA_VERSION}); \
1169 upgrade the HAL or downgrade the metadata"
1170 )));
1171 }
1172
1173 if version >= 2 {
1174 serde_json::from_value(value).map_err(DecoderError::Json)
1175 } else {
1176 let v1: ConfigOutputs = serde_json::from_value(value).map_err(DecoderError::Json)?;
1177 Self::from_v1(&v1)
1178 }
1179 }
1180
1181 pub fn from_v1(v1: &ConfigOutputs) -> DecoderResult<Self> {
1197 let outputs = v1
1198 .outputs
1199 .iter()
1200 .map(logical_from_v1)
1201 .collect::<DecoderResult<Vec<_>>>()?;
1202 Ok(SchemaV2 {
1203 schema_version: 2,
1204 input: None,
1205 outputs,
1206 nms: v1.nms.as_ref().and_then(NmsMode::from_v1),
1207 nms_multi_label: v1.nms_multi_label,
1208 decoder_version: v1.decoder_version.as_ref().map(DecoderVersion::from_v1),
1209 })
1210 }
1211}
1212
1213impl SchemaV2 {
1214 pub fn to_legacy_config_outputs(&self) -> DecoderResult<ConfigOutputs> {
1241 let mut outputs = Vec::with_capacity(self.outputs.len());
1242 for logical in &self.outputs {
1243 if logical.type_.is_none() {
1249 continue;
1250 }
1251 if logical.type_ == Some(LogicalType::Boxes)
1258 && logical.encoding == Some(BoxEncoding::Dfl)
1259 && logical.outputs.is_empty()
1260 {
1261 return Err(DecoderError::NotSupported(format!(
1262 "`boxes` output `{}` has `encoding: dfl` on a flat \
1263 logical (no per-scale children); the HAL's DFL \
1264 decode kernel only runs inside the per-scale merge \
1265 path. Split the boxes output into per-FPN-level \
1266 children (Hailo convention) or pre-decode to 4 \
1267 channels in the model graph (TFLite convention).",
1268 logical.name.as_deref().unwrap_or("<anonymous>"),
1269 )));
1270 }
1271 if let Some(q) = &logical.quantization {
1272 if q.is_per_channel() {
1273 return Err(DecoderError::NotSupported(format!(
1274 "logical `{}` uses per-channel quantization \
1275 (axis {:?}, {} scales); the v1 decoder only \
1276 supports per-tensor quantization",
1277 logical.name.as_deref().unwrap_or("<anonymous>"),
1278 q.axis,
1279 q.scale.len(),
1280 )));
1281 }
1282 }
1283 outputs.push(logical_to_legacy_config_output(logical)?);
1284 }
1285 Ok(ConfigOutputs {
1286 outputs,
1287 nms: self.nms.map(NmsMode::to_v1),
1288 nms_multi_label: self.nms_multi_label,
1289 decoder_version: self.decoder_version.map(|v| v.to_v1()),
1290 })
1291 }
1292
1293 pub fn validate(&self) -> DecoderResult<()> {
1311 if self.schema_version == 0 || self.schema_version > MAX_SUPPORTED_SCHEMA_VERSION {
1312 return Err(DecoderError::InvalidConfig(format!(
1313 "schema_version {} outside supported range [1, {MAX_SUPPORTED_SCHEMA_VERSION}]",
1314 self.schema_version
1315 )));
1316 }
1317
1318 if let Some(input) = &self.input {
1319 if let Some(pre) = &input.preprocessing {
1320 validate_preprocessing(input, pre)?;
1321 }
1322 }
1323
1324 for logical in &self.outputs {
1325 validate_logical(logical)?;
1326 }
1327
1328 Ok(())
1329 }
1330}
1331
1332fn validate_preprocessing(input: &InputSpec, pre: &Preprocessing) -> DecoderResult<()> {
1333 let channels = input.channels();
1334 let mut vectors: Vec<(&str, &[f32])> = Vec::new();
1335 if let Some(NormalizationConfig::MeanStd { mean, std }) = &pre.normalization {
1336 vectors.push(("normalization mean", mean));
1337 vectors.push(("normalization std", std));
1338 }
1339 if let Some(pad) = pre.letterbox.as_ref().and_then(|l| l.pad.as_ref()) {
1340 vectors.push(("letterbox pad", pad));
1341 }
1342
1343 let mut expected = channels;
1346 for (field, values) in &vectors {
1347 if values.is_empty() || values.iter().any(|v| !v.is_finite()) {
1348 return Err(DecoderError::InvalidConfig(format!(
1349 "{field} {values:?} must hold one or more finite values"
1350 )));
1351 }
1352 if values.len() == 1 {
1353 continue;
1354 }
1355 match expected {
1356 Some(n) if n != values.len() => {
1357 return Err(DecoderError::InvalidConfig(format!(
1358 "{field} has {} values; expected 1 or {n} (one per input channel)",
1359 values.len()
1360 )));
1361 }
1362 Some(_) => {}
1363 None => expected = Some(values.len()),
1364 }
1365 }
1366
1367 if let Some(norm) = &pre.normalization {
1368 let n = match (norm, expected) {
1369 (_, Some(n)) => n,
1370 (NormalizationConfig::Preset(NormalizationPreset::Imagenet), None) => 3,
1373 (_, None) => 1,
1374 };
1375 norm.mean_std(n)?;
1376 }
1377 Ok(())
1378}
1379
1380fn validate_logical(logical: &LogicalOutput) -> DecoderResult<()> {
1381 if logical.outputs.is_empty() {
1382 return Ok(());
1383 }
1384
1385 for child in &logical.outputs {
1387 if child.name.is_empty() {
1388 return Err(DecoderError::InvalidConfig(format!(
1389 "physical child of logical `{}` is missing `name`; name is \
1390 required for tensor binding",
1391 logical.name.as_deref().unwrap_or("<anonymous>")
1392 )));
1393 }
1394 }
1395
1396 for (i, a) in logical.outputs.iter().enumerate() {
1405 for b in &logical.outputs[i + 1..] {
1406 let (Some(ta), Some(tb)) = (a.type_, b.type_) else {
1407 continue;
1408 };
1409 if a.shape == b.shape && ta == tb {
1410 return Err(DecoderError::InvalidConfig(format!(
1411 "physical children `{}` and `{}` share shape {:?} and \
1412 type; tensor binding cannot be resolved",
1413 a.name, b.name, a.shape
1414 )));
1415 }
1416 }
1417 }
1418
1419 let strided: Vec<_> = logical.outputs.iter().map(|c| c.stride.is_some()).collect();
1423 let all_strided = strided.iter().all(|&b| b);
1424 let none_strided = strided.iter().all(|&b| !b);
1425 if !(all_strided || none_strided) {
1426 return Err(DecoderError::InvalidConfig(format!(
1427 "logical `{}` mixes per-scale children (with stride) and \
1428 channel sub-split children (without stride); decomposition \
1429 must be uniform",
1430 logical.name.as_deref().unwrap_or("<anonymous>")
1431 )));
1432 }
1433
1434 if logical.type_ == Some(LogicalType::Boxes) && logical.encoding == Some(BoxEncoding::Dfl) {
1437 for child in &logical.outputs {
1438 if let Some(feat) = last_feature_axis(child) {
1439 if feat % 4 != 0 {
1440 return Err(DecoderError::InvalidConfig(format!(
1441 "DFL boxes child `{}` feature axis {feat} is not \
1442 divisible by 4 (reg_max×4)",
1443 child.name
1444 )));
1445 }
1446 }
1447 }
1448 }
1449
1450 Ok(())
1451}
1452
1453pub(crate) fn last_feature_axis(child: &PhysicalOutput) -> Option<usize> {
1456 for (name, size) in &child.dshape {
1459 if matches!(
1460 name,
1461 DimName::NumFeatures
1462 | DimName::NumClasses
1463 | DimName::NumProtos
1464 | DimName::BoxCoords
1465 | DimName::NumAnchorsXFeatures
1466 ) {
1467 return Some(*size);
1468 }
1469 }
1470 child.shape.last().copied()
1471}
1472
1473fn quantization_from_v1(q: Option<QuantTuple>) -> Option<Quantization> {
1474 q.map(|QuantTuple(scale, zp)| Quantization {
1475 scale: vec![scale],
1476 zero_point: Some(vec![zp]),
1477 axis: None,
1478 dtype: None,
1479 })
1480}
1481
1482fn logical_from_v1(v1: &ConfigOutput) -> DecoderResult<LogicalOutput> {
1483 match v1 {
1484 ConfigOutput::Detection(d) => {
1485 let encoding = match (d.decoder, d.anchors.is_some()) {
1491 (configs::DecoderType::ModelPack, true) => Some(BoxEncoding::Anchor),
1492 (configs::DecoderType::Ultralytics, _) => Some(BoxEncoding::Direct),
1493 (configs::DecoderType::ModelPack, false) => None,
1496 (configs::DecoderType::Semantic, _) => None,
1497 };
1498 Ok(LogicalOutput {
1499 name: None,
1500 type_: Some(LogicalType::Detection),
1501 shape: d.shape.clone(),
1502 dshape: d.dshape.clone(),
1503 decoder: Some(DecoderKind::from_v1(d.decoder)),
1504 encoding,
1505 score_format: None,
1506 normalized: d.normalized,
1507 anchors: d.anchors.clone(),
1508 stride: None,
1509 dtype: None,
1510 quantization: quantization_from_v1(d.quantization),
1511 outputs: Vec::new(),
1512 activation_applied: None,
1513 activation_required: None,
1514 semantic: Default::default(),
1515 })
1516 }
1517 ConfigOutput::Boxes(b) => Ok(LogicalOutput {
1518 name: None,
1519 type_: Some(LogicalType::Boxes),
1520 shape: b.shape.clone(),
1521 dshape: b.dshape.clone(),
1522 decoder: Some(DecoderKind::from_v1(b.decoder)),
1523 encoding: Some(BoxEncoding::Direct),
1527 score_format: None,
1528 normalized: b.normalized,
1529 anchors: None,
1530 stride: None,
1531 dtype: None,
1532 quantization: quantization_from_v1(b.quantization),
1533 outputs: Vec::new(),
1534 activation_applied: None,
1535 activation_required: None,
1536 semantic: Default::default(),
1537 }),
1538 ConfigOutput::Scores(s) => Ok(LogicalOutput {
1539 name: None,
1540 type_: Some(LogicalType::Scores),
1541 shape: s.shape.clone(),
1542 dshape: s.dshape.clone(),
1543 decoder: Some(DecoderKind::from_v1(s.decoder)),
1544 encoding: None,
1545 score_format: Some(ScoreFormat::PerClass),
1549 normalized: None,
1550 anchors: None,
1551 stride: None,
1552 dtype: None,
1553 quantization: quantization_from_v1(s.quantization),
1554 outputs: Vec::new(),
1555 activation_applied: None,
1556 activation_required: None,
1557 semantic: Default::default(),
1558 }),
1559 ConfigOutput::Protos(p) => Ok(LogicalOutput {
1560 name: None,
1561 type_: Some(LogicalType::Protos),
1562 shape: p.shape.clone(),
1563 dshape: p.dshape.clone(),
1564 decoder: Some(DecoderKind::from_v1(p.decoder)),
1566 encoding: None,
1567 score_format: None,
1568 normalized: None,
1569 anchors: None,
1570 stride: None,
1571 dtype: None,
1572 quantization: quantization_from_v1(p.quantization),
1573 outputs: Vec::new(),
1574 activation_applied: None,
1575 activation_required: None,
1576 semantic: Default::default(),
1577 }),
1578 ConfigOutput::MaskCoefficients(m) => Ok(LogicalOutput {
1579 name: None,
1580 type_: Some(LogicalType::MaskCoefs),
1581 shape: m.shape.clone(),
1582 dshape: m.dshape.clone(),
1583 decoder: Some(DecoderKind::from_v1(m.decoder)),
1584 encoding: None,
1585 score_format: None,
1586 normalized: None,
1587 anchors: None,
1588 stride: None,
1589 dtype: None,
1590 quantization: quantization_from_v1(m.quantization),
1591 outputs: Vec::new(),
1592 activation_applied: None,
1593 activation_required: None,
1594 semantic: Default::default(),
1595 }),
1596 ConfigOutput::Segmentation(seg) => Ok(LogicalOutput {
1597 name: None,
1598 type_: Some(LogicalType::Segmentation),
1599 shape: seg.shape.clone(),
1600 dshape: seg.dshape.clone(),
1601 decoder: Some(DecoderKind::from_v1(seg.decoder)),
1602 encoding: None,
1603 score_format: None,
1604 normalized: None,
1605 anchors: None,
1606 stride: None,
1607 dtype: None,
1608 quantization: quantization_from_v1(seg.quantization),
1609 outputs: Vec::new(),
1610 activation_applied: None,
1611 activation_required: None,
1612 semantic: Default::default(),
1613 }),
1614 ConfigOutput::Mask(m) => Ok(LogicalOutput {
1615 name: None,
1616 type_: Some(LogicalType::Masks),
1617 shape: m.shape.clone(),
1618 dshape: m.dshape.clone(),
1619 decoder: Some(DecoderKind::from_v1(m.decoder)),
1620 encoding: None,
1621 score_format: None,
1622 normalized: None,
1623 anchors: None,
1624 stride: None,
1625 dtype: None,
1626 quantization: quantization_from_v1(m.quantization),
1627 outputs: Vec::new(),
1628 activation_applied: None,
1629 activation_required: None,
1630 semantic: Default::default(),
1631 }),
1632 ConfigOutput::Classes(c) => Ok(LogicalOutput {
1633 name: None,
1634 type_: Some(LogicalType::Classes),
1635 shape: c.shape.clone(),
1636 dshape: c.dshape.clone(),
1637 decoder: Some(DecoderKind::from_v1(c.decoder)),
1638 encoding: None,
1639 score_format: None,
1640 normalized: None,
1641 anchors: None,
1642 stride: None,
1643 dtype: None,
1644 quantization: quantization_from_v1(c.quantization),
1645 outputs: Vec::new(),
1646 activation_applied: None,
1647 activation_required: None,
1648 semantic: Default::default(),
1649 }),
1650 }
1651}
1652
1653impl DecoderKind {
1654 pub fn from_v1(v: configs::DecoderType) -> Self {
1656 match v {
1657 configs::DecoderType::ModelPack => DecoderKind::ModelPack,
1658 configs::DecoderType::Ultralytics => DecoderKind::Ultralytics,
1659 configs::DecoderType::Semantic => DecoderKind::Semantic,
1660 }
1661 }
1662
1663 pub fn to_v1(self) -> configs::DecoderType {
1665 match self {
1666 DecoderKind::ModelPack => configs::DecoderType::ModelPack,
1667 DecoderKind::Ultralytics => configs::DecoderType::Ultralytics,
1668 DecoderKind::Semantic => configs::DecoderType::Semantic,
1669 }
1670 }
1671}
1672
1673impl DecoderVersion {
1674 pub fn from_v1(v: &configs::DecoderVersion) -> Self {
1676 match v {
1677 configs::DecoderVersion::Yolov5 => DecoderVersion::Yolov5,
1678 configs::DecoderVersion::Yolov8 => DecoderVersion::Yolov8,
1679 configs::DecoderVersion::Yolo11 => DecoderVersion::Yolo11,
1680 configs::DecoderVersion::Yolo26 => DecoderVersion::Yolo26,
1681 }
1682 }
1683
1684 pub fn to_v1(self) -> configs::DecoderVersion {
1686 match self {
1687 DecoderVersion::Yolov5 => configs::DecoderVersion::Yolov5,
1688 DecoderVersion::Yolov8 => configs::DecoderVersion::Yolov8,
1689 DecoderVersion::Yolo11 => configs::DecoderVersion::Yolo11,
1690 DecoderVersion::Yolo26 => configs::DecoderVersion::Yolo26,
1691 }
1692 }
1693}
1694
1695impl NmsMode {
1696 pub const fn code(self) -> u32 {
1698 self as u32
1699 }
1700
1701 pub fn from_v1(v: &configs::Nms) -> Option<Self> {
1703 match v {
1704 configs::Nms::Auto => None,
1705 configs::Nms::ClassAgnostic => Some(NmsMode::ClassAgnostic),
1706 configs::Nms::ClassAware => Some(NmsMode::ClassAware),
1707 }
1708 }
1709
1710 pub fn to_v1(self) -> configs::Nms {
1712 match self {
1713 NmsMode::ClassAgnostic => configs::Nms::ClassAgnostic,
1714 NmsMode::ClassAware => configs::Nms::ClassAware,
1715 }
1716 }
1717}
1718
1719fn quantization_to_legacy(q: &Quantization) -> DecoderResult<QuantTuple> {
1722 if q.is_per_channel() {
1723 return Err(DecoderError::NotSupported(
1724 "per-channel quantization cannot be expressed as a v1 QuantTuple".into(),
1725 ));
1726 }
1727 let scale = *q.scale.first().unwrap_or(&0.0);
1728 let zp = q.zero_point_at(0);
1729 Ok(QuantTuple(scale, zp))
1730}
1731
1732pub(crate) fn squeeze_padding_dims(
1738 shape: Vec<usize>,
1739 dshape: Vec<(DimName, usize)>,
1740) -> (Vec<usize>, Vec<(DimName, usize)>) {
1741 if dshape.is_empty() {
1745 return (shape, dshape);
1746 }
1747 let keep: Vec<bool> = dshape
1748 .iter()
1749 .map(|(n, _)| !matches!(n, DimName::Padding))
1750 .collect();
1751 let shape = shape
1752 .into_iter()
1753 .zip(keep.iter())
1754 .filter_map(|(s, &k)| k.then_some(s))
1755 .collect();
1756 let dshape = dshape
1757 .into_iter()
1758 .zip(keep.iter())
1759 .filter_map(|(d, &k)| k.then_some(d))
1760 .collect();
1761 (shape, dshape)
1762}
1763
1764pub(crate) fn padding_axes(dshape: &[(DimName, usize)]) -> Vec<usize> {
1769 let mut v: Vec<usize> = dshape
1770 .iter()
1771 .enumerate()
1772 .filter_map(|(i, (n, _))| matches!(n, DimName::Padding).then_some(i))
1773 .collect();
1774 v.sort_by(|a, b| b.cmp(a));
1775 v
1776}
1777
1778fn logical_to_legacy_config_output(logical: &LogicalOutput) -> DecoderResult<ConfigOutput> {
1779 let decoder = match logical.decoder {
1780 None => configs::DecoderType::Ultralytics,
1781 Some(DecoderKind::Semantic) => {
1782 return Err(DecoderError::NotSupported(format!(
1783 "logical `{}` uses decoder `Semantic`, which this HAL \
1784 version does not decode",
1785 logical.name.as_deref().unwrap_or("<anonymous>")
1786 )))
1787 }
1788 Some(kind) => kind.to_v1(),
1789 };
1790 let quantization = logical
1791 .quantization
1792 .as_ref()
1793 .map(quantization_to_legacy)
1794 .transpose()?;
1795 let (shape, dshape) = match logical.decoder {
1801 Some(DecoderKind::ModelPack) => (logical.shape.clone(), logical.dshape.clone()),
1802 _ => squeeze_padding_dims(logical.shape.clone(), logical.dshape.clone()),
1803 };
1804
1805 let ty = logical.type_.ok_or_else(|| {
1806 DecoderError::InvalidConfig(format!(
1810 "logical output `{}` has no type; typeless outputs should be \
1811 filtered before legacy conversion",
1812 logical.name.as_deref().unwrap_or("<anonymous>")
1813 ))
1814 })?;
1815
1816 let ctx = LegacyConvertCtx {
1817 decoder,
1818 quantization,
1819 shape,
1820 dshape,
1821 normalized: logical.normalized,
1822 anchors: logical.anchors.clone(),
1823 };
1824
1825 match ty {
1826 LogicalType::Boxes => Ok(boxes_to_legacy(&ctx)),
1827 LogicalType::Scores => Ok(scores_to_legacy(&ctx)),
1828 LogicalType::Protos => Ok(protos_to_legacy(&ctx)),
1829 LogicalType::MaskCoefs => Ok(mask_coefs_to_legacy(&ctx)),
1830 LogicalType::Segmentation => Ok(segmentation_to_legacy(&ctx)),
1831 LogicalType::Masks => Ok(masks_to_legacy(&ctx)),
1832 LogicalType::Classes => Ok(classes_to_legacy(&ctx)),
1833 LogicalType::Detection | LogicalType::Detections => Ok(detection_to_legacy(&ctx)),
1834 LogicalType::Objectness
1837 | LogicalType::Landmarks
1838 | LogicalType::ClassLogits
1839 | LogicalType::MaskLogits => Err(DecoderError::NotSupported(format!(
1840 "logical type {:?} has no legacy v1 equivalent; use the \
1841 native v2 decoder path",
1842 ty
1843 ))),
1844 }
1845}
1846
1847struct LegacyConvertCtx {
1849 decoder: configs::DecoderType,
1850 quantization: Option<QuantTuple>,
1851 shape: Vec<usize>,
1852 dshape: Vec<(DimName, usize)>,
1853 normalized: Option<bool>,
1854 anchors: Option<Vec<[f32; 2]>>,
1855}
1856
1857fn boxes_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1858 ConfigOutput::Boxes(configs::Boxes {
1859 decoder: ctx.decoder,
1860 quantization: ctx.quantization,
1861 shape: ctx.shape.clone(),
1862 dshape: ctx.dshape.clone(),
1863 normalized: ctx.normalized,
1864 })
1865}
1866
1867fn scores_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1868 ConfigOutput::Scores(configs::Scores {
1869 decoder: ctx.decoder,
1870 quantization: ctx.quantization,
1871 shape: ctx.shape.clone(),
1872 dshape: ctx.dshape.clone(),
1873 })
1874}
1875
1876fn protos_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1877 ConfigOutput::Protos(configs::Protos {
1878 decoder: ctx.decoder,
1879 quantization: ctx.quantization,
1880 shape: ctx.shape.clone(),
1881 dshape: ctx.dshape.clone(),
1882 })
1883}
1884
1885fn mask_coefs_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1886 ConfigOutput::MaskCoefficients(configs::MaskCoefficients {
1887 decoder: ctx.decoder,
1888 quantization: ctx.quantization,
1889 shape: ctx.shape.clone(),
1890 dshape: ctx.dshape.clone(),
1891 })
1892}
1893
1894fn segmentation_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1895 ConfigOutput::Segmentation(configs::Segmentation {
1896 decoder: ctx.decoder,
1897 quantization: ctx.quantization,
1898 shape: ctx.shape.clone(),
1899 dshape: ctx.dshape.clone(),
1900 })
1901}
1902
1903fn masks_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1904 ConfigOutput::Mask(configs::Mask {
1905 decoder: ctx.decoder,
1906 quantization: ctx.quantization,
1907 shape: ctx.shape.clone(),
1908 dshape: ctx.dshape.clone(),
1909 })
1910}
1911
1912fn classes_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1913 ConfigOutput::Classes(configs::Classes {
1914 decoder: ctx.decoder,
1915 quantization: ctx.quantization,
1916 shape: ctx.shape.clone(),
1917 dshape: ctx.dshape.clone(),
1918 })
1919}
1920
1921fn detection_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1922 ConfigOutput::Detection(configs::Detection {
1926 anchors: ctx.anchors.clone(),
1927 decoder: ctx.decoder,
1928 quantization: ctx.quantization,
1929 shape: ctx.shape.clone(),
1930 dshape: ctx.dshape.clone(),
1931 normalized: ctx.normalized,
1932 })
1933}
1934
1935#[cfg(test)]
1936#[cfg_attr(coverage_nightly, coverage(off))]
1937mod tests {
1938 use super::*;
1939
1940 #[test]
1941 fn nms_multi_label_true_round_trips_through_json() {
1942 let json = r#"{
1943 "schema_version": 2,
1944 "nms": "class_aware",
1945 "nms_multi_label": true,
1946 "outputs": [{
1947 "name": "output0", "type": "detection",
1948 "shape": [1, 84, 8400],
1949 "dshape": [{"batch": 1}, {"num_features": 84}, {"num_boxes": 8400}],
1950 "dtype": "float32", "decoder": "ultralytics", "encoding": "direct",
1951 "normalized": true
1952 }]
1953 }"#;
1954 let schema = SchemaV2::parse_json(json).unwrap();
1955 assert_eq!(schema.nms_multi_label, Some(true));
1956
1957 let text = serde_json::to_string(&schema).unwrap();
1958 let value: serde_json::Value = serde_json::from_str(&text).unwrap();
1959 assert_eq!(value["nms_multi_label"], serde_json::json!(true));
1960 let again = SchemaV2::parse_json(&text).unwrap();
1961 assert_eq!(again, schema);
1962
1963 let legacy = schema.to_legacy_config_outputs().unwrap();
1964 assert_eq!(legacy.nms_multi_label, Some(true));
1965 let back = SchemaV2::from_v1(&legacy).unwrap();
1966 assert_eq!(back.nms_multi_label, Some(true));
1967 }
1968
1969 #[test]
1970 fn schema_default_is_v2() {
1971 let s = SchemaV2::default();
1972 assert_eq!(s.schema_version, 2);
1973 assert!(s.outputs.is_empty());
1974 }
1975
1976 #[test]
1977 fn fixtures_round_trip_through_serde() {
1978 let yolov8 =
1979 edgefirst_bench::testdata::read_to_string("per_scale/synthetic_yolov8n_schema.json");
1980 let _: super::SchemaV2 = serde_json::from_str(&yolov8).expect("yolov8n fixture must parse");
1981
1982 let yolo26 =
1983 edgefirst_bench::testdata::read_to_string("per_scale/synthetic_yolo26n_schema.json");
1984 let _: super::SchemaV2 = serde_json::from_str(&yolo26).expect("yolo26n fixture must parse");
1985
1986 let flat =
1987 edgefirst_bench::testdata::read_to_string("per_scale/synthetic_flat_schema.json");
1988 let _: super::SchemaV2 = serde_json::from_str(&flat).expect("flat fixture must parse");
1989 }
1990
1991 #[test]
1992 fn box_encoding_accepts_ltrb_alias_for_direct() {
1993 let dfl: BoxEncoding = serde_json::from_str("\"dfl\"").unwrap();
1994 assert_eq!(dfl, BoxEncoding::Dfl);
1995
1996 let direct: BoxEncoding = serde_json::from_str("\"direct\"").unwrap();
1997 assert_eq!(direct, BoxEncoding::Direct);
1998
1999 let ltrb: BoxEncoding = serde_json::from_str("\"ltrb\"").unwrap();
2001 assert_eq!(ltrb, BoxEncoding::Direct);
2002 }
2003
2004 #[test]
2005 fn dtype_roundtrip() {
2006 for &d in DType::all() {
2007 let j = serde_json::to_string(&d).unwrap();
2008 let back: DType = serde_json::from_str(&j).unwrap();
2009 assert_eq!(back, d);
2010 }
2011 }
2012
2013 #[test]
2014 fn dtype_is_numbered_and_named_by_the_tensor_dtype() {
2015 for &d in DType::all() {
2016 let t = d.to_tensor_dtype();
2017 assert_eq!(d.code(), t.code(), "{d:?}");
2018 assert_eq!(d.as_str(), t.as_str(), "{d:?}");
2019 assert_eq!(DType::from_code(t.code()), Some(d));
2020 assert_eq!(DType::from_tensor_dtype(t), Some(d));
2021 assert_eq!(
2022 serde_json::to_string(&d).unwrap(),
2023 format!("\"{}\"", t.as_str())
2024 );
2025 }
2026 for t in [edgefirst_tensor::DType::U64, edgefirst_tensor::DType::F64] {
2028 assert_eq!(DType::from_tensor_dtype(t), None);
2029 assert_eq!(DType::from_code(t.code()), None);
2030 assert_eq!(DType::from_str_code(t.as_str()), None);
2031 }
2032 assert_eq!(DType::from_code(u32::MAX), None);
2033 }
2034
2035 #[test]
2036 fn dtype_accepts_every_legacy_name() {
2037 let legacy = [
2038 ("int8", DType::Int8),
2039 ("uint8", DType::Uint8),
2040 ("int16", DType::Int16),
2041 ("uint16", DType::Uint16),
2042 ("int32", DType::Int32),
2043 ("uint32", DType::Uint32),
2044 ("float16", DType::Float16),
2045 ("float32", DType::Float32),
2046 ("int64", DType::Int64),
2047 ];
2048 assert_eq!(legacy.len(), DType::all().len());
2049 for (name, d) in legacy {
2050 assert_eq!(DType::from_str_code(name), Some(d), "{name}");
2051 let back: DType = serde_json::from_str(&format!("\"{name}\"")).unwrap();
2052 assert_eq!(back, d, "{name}");
2053 assert_ne!(d.as_str(), name);
2055 }
2056 assert_eq!(DType::accepted_names().count(), 2 * DType::all().len());
2057 assert!(serde_json::from_str::<DType>("\"float64\"").is_err());
2058 assert!(serde_json::from_str::<DType>("\"Int8\"").is_err());
2059 }
2060
2061 #[test]
2062 fn schema_enums_take_their_codes_from_the_config_vocabularies() {
2063 for v in [
2064 DecoderVersion::Yolov5,
2065 DecoderVersion::Yolov8,
2066 DecoderVersion::Yolo11,
2067 DecoderVersion::Yolo26,
2068 ] {
2069 assert_eq!(v.code(), v.to_v1().code(), "{v:?}");
2070 }
2071 for m in [NmsMode::ClassAgnostic, NmsMode::ClassAware] {
2072 assert_eq!(m.code(), m.to_v1().code(), "{m:?}");
2073 }
2074 for &v in configs::DecoderType::all() {
2075 let k = DecoderKind::from_v1(v);
2076 assert_eq!(k.code(), v.code(), "{k:?}");
2077 assert_eq!(k.to_v1(), v, "{k:?}");
2078 }
2079 }
2080
2081 #[test]
2082 fn dtype_widths() {
2083 assert_eq!(DType::Int8.size_bytes(), 1);
2084 assert_eq!(DType::Float16.size_bytes(), 2);
2085 assert_eq!(DType::Float32.size_bytes(), 4);
2086 }
2087
2088 #[test]
2089 fn stride_accepts_scalar_or_pair() {
2090 let a: Stride = serde_json::from_str("8").unwrap();
2091 let b: Stride = serde_json::from_str("[8, 16]").unwrap();
2092 assert_eq!(a, Stride::Square(8));
2093 assert_eq!(b, Stride::Rect([8, 16]));
2094 assert_eq!(a.x(), 8);
2095 assert_eq!(a.y(), 8);
2096 assert_eq!(b.x(), 8);
2097 assert_eq!(b.y(), 16);
2098 }
2099
2100 #[test]
2101 fn quantization_scalar_scale() {
2102 let j = r#"{"scale": 0.00392, "zero_point": 0, "dtype": "int8"}"#;
2103 let q: Quantization = serde_json::from_str(j).unwrap();
2104 assert!(q.is_per_tensor());
2105 assert!(q.is_symmetric());
2106 assert_eq!(q.scale_at(0), 0.00392);
2107 assert_eq!(q.scale_at(5), 0.00392);
2108 assert_eq!(q.zero_point_at(0), 0);
2109 }
2110
2111 #[test]
2112 fn quantization_per_channel() {
2113 let j = r#"{"scale": [0.054, 0.089, 0.195], "axis": 0, "dtype": "int8"}"#;
2114 let q: Quantization = serde_json::from_str(j).unwrap();
2115 assert!(q.is_per_channel());
2116 assert!(q.is_symmetric());
2117 assert_eq!(q.axis, Some(0));
2118 assert_eq!(q.scale_at(0), 0.054);
2119 assert_eq!(q.scale_at(2), 0.195);
2120 }
2121
2122 #[test]
2123 fn quantization_asymmetric_per_tensor() {
2124 let j = r#"{"scale": 0.176, "zero_point": 198, "dtype": "uint8"}"#;
2125 let q: Quantization = serde_json::from_str(j).unwrap();
2126 assert!(!q.is_symmetric());
2127 assert_eq!(q.zero_point_at(0), 198);
2128 assert_eq!(q.zero_point_at(10), 198);
2129 }
2130
2131 #[test]
2132 fn quantization_symmetric_default_zero_point() {
2133 let j = r#"{"scale": 0.00392, "dtype": "int8"}"#;
2134 let q: Quantization = serde_json::from_str(j).unwrap();
2135 assert!(q.is_symmetric());
2136 assert_eq!(q.zero_point_at(0), 0);
2137 }
2138
2139 #[test]
2140 fn quantization_to_tensor_per_tensor_asymmetric() {
2141 let q = Quantization {
2142 scale: vec![0.1],
2143 zero_point: Some(vec![-5]),
2144 axis: None,
2145 dtype: Some(DType::Int8),
2146 };
2147 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
2148 assert!(t.is_per_tensor());
2149 assert!(!t.is_symmetric());
2150 assert_eq!(t.scale(), &[0.1]);
2151 assert_eq!(t.zero_point(), Some(&[-5][..]));
2152 }
2153
2154 #[test]
2155 fn quantization_to_tensor_per_tensor_symmetric() {
2156 let q = Quantization {
2157 scale: vec![0.05],
2158 zero_point: None,
2159 axis: None,
2160 dtype: Some(DType::Int8),
2161 };
2162 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
2163 assert!(t.is_per_tensor());
2164 assert!(t.is_symmetric());
2165 }
2166
2167 #[test]
2168 fn quantization_to_tensor_per_channel_asymmetric() {
2169 let q = Quantization {
2170 scale: vec![0.1, 0.2, 0.3],
2171 zero_point: Some(vec![-1, 0, 1]),
2172 axis: Some(2),
2173 dtype: Some(DType::Int8),
2174 };
2175 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
2176 assert!(t.is_per_channel());
2177 assert_eq!(t.axis(), Some(2));
2178 assert_eq!(t.scale().len(), 3);
2179 assert_eq!(t.zero_point().map(|z| z.len()), Some(3));
2180 }
2181
2182 #[test]
2183 fn quantization_to_tensor_per_channel_symmetric() {
2184 let q = Quantization {
2185 scale: vec![0.054, 0.089, 0.195],
2186 zero_point: None,
2187 axis: Some(0),
2188 dtype: Some(DType::Int8),
2189 };
2190 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
2191 assert!(t.is_per_channel());
2192 assert!(t.is_symmetric());
2193 assert_eq!(t.axis(), Some(0));
2194 }
2195
2196 #[test]
2197 fn quantization_to_tensor_per_channel_missing_axis_errors() {
2198 let q = Quantization {
2199 scale: vec![0.1, 0.2, 0.3],
2200 zero_point: None,
2201 axis: None,
2202 dtype: None,
2203 };
2204 let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
2205 assert!(matches!(
2209 err,
2210 edgefirst_tensor::Error::QuantizationInvalid { field: "axis", .. }
2211 ));
2212 }
2213
2214 #[test]
2218 fn quantization_to_tensor_per_tensor_ignores_a_redundant_axis() {
2219 let q = Quantization {
2220 scale: vec![0.5],
2221 zero_point: Some(vec![3]),
2222 axis: Some(0),
2223 dtype: Some(DType::Int8),
2224 };
2225 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
2226 assert!(t.is_per_tensor());
2227 assert_eq!(t.scale(), &[0.5][..]);
2228 assert_eq!(t.zero_point(), Some(&[3][..]));
2229 }
2230
2231 #[test]
2232 fn quantization_to_tensor_symmetric_ignores_a_redundant_axis() {
2233 let q = Quantization {
2234 scale: vec![0.5],
2235 zero_point: None,
2236 axis: Some(0),
2237 dtype: Some(DType::Int8),
2238 };
2239 let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
2240 assert!(t.is_per_tensor());
2241 assert!(t.is_symmetric());
2242 assert_eq!(t.scale(), &[0.5][..]);
2243 }
2244
2245 #[test]
2249 fn quantization_to_tensor_empty_scale_errors_on_scale() {
2250 for (zero_point, axis) in [
2251 (None, None),
2252 (None, Some(0)),
2253 (Some(vec![]), Some(0)),
2254 (Some(vec![]), None),
2255 ] {
2256 let q = Quantization {
2257 scale: vec![],
2258 zero_point,
2259 axis,
2260 dtype: Some(DType::Int8),
2261 };
2262 let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
2263 assert!(
2264 matches!(
2265 err,
2266 edgefirst_tensor::Error::QuantizationInvalid { field: "scale", .. }
2267 ),
2268 "axis={axis:?} gave {err:?}"
2269 );
2270 }
2271 }
2272
2273 #[test]
2277 fn quantization_to_tensor_single_scale_with_many_zero_points_errors_on_scale() {
2278 for axis in [None, Some(0)] {
2279 let q = Quantization {
2280 scale: vec![0.5],
2281 zero_point: Some(vec![1, 2]),
2282 axis,
2283 dtype: Some(DType::Int8),
2284 };
2285 let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
2286 assert!(
2287 matches!(
2288 err,
2289 edgefirst_tensor::Error::QuantizationInvalid { field: "scale", .. }
2290 ),
2291 "axis={axis:?} gave {err:?}"
2292 );
2293 }
2294 }
2295
2296 #[test]
2297 fn per_channel_quantization_is_not_per_tensor() {
2298 let q = Quantization {
2299 scale: vec![0.1, 0.2, 0.3],
2300 zero_point: None,
2301 axis: Some(0),
2302 dtype: Some(DType::Int8),
2303 };
2304 assert!(q.is_per_channel());
2305 assert!(!q.is_per_tensor());
2306
2307 let per_tensor = Quantization {
2308 scale: vec![0.1],
2309 zero_point: None,
2310 axis: None,
2311 dtype: Some(DType::Int8),
2312 };
2313 assert!(per_tensor.is_per_tensor());
2314 assert!(!per_tensor.is_per_channel());
2315 }
2316
2317 #[test]
2321 fn zero_point_at_indexes_per_channel_zero_points() {
2322 let per_channel = Quantization {
2323 scale: vec![0.1, 0.2, 0.3],
2324 zero_point: Some(vec![10, 20, 30]),
2325 axis: Some(0),
2326 dtype: Some(DType::Int8),
2327 };
2328 assert_eq!(per_channel.zero_point_at(0), 10);
2329 assert_eq!(per_channel.zero_point_at(1), 20);
2330 assert_eq!(per_channel.zero_point_at(2), 30);
2331 assert_eq!(per_channel.zero_point_at(3), 0);
2333
2334 let shared = Quantization {
2335 scale: vec![0.1, 0.2, 0.3],
2336 zero_point: Some(vec![7]),
2337 axis: Some(0),
2338 dtype: Some(DType::Int8),
2339 };
2340 assert_eq!(shared.zero_point_at(0), 7);
2341 assert_eq!(shared.zero_point_at(2), 7);
2342
2343 let symmetric = Quantization {
2344 scale: vec![0.1],
2345 zero_point: None,
2346 axis: None,
2347 dtype: Some(DType::Int8),
2348 };
2349 assert_eq!(symmetric.zero_point_at(0), 0);
2350 }
2351
2352 #[test]
2353 fn logical_output_flat_tflite_boxes() {
2354 let j = r#"{
2356 "name": "boxes", "type": "boxes",
2357 "shape": [1, 64, 8400],
2358 "dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
2359 "dtype": "int8",
2360 "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
2361 "decoder": "ultralytics",
2362 "encoding": "dfl",
2363 "normalized": true
2364 }"#;
2365 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2366 assert_eq!(lo.type_, Some(LogicalType::Boxes));
2367 assert_eq!(lo.encoding, Some(BoxEncoding::Dfl));
2368 assert_eq!(lo.normalized, Some(true));
2369 assert!(!lo.is_split());
2370 assert_eq!(lo.dtype, Some(DType::Int8));
2371 }
2372
2373 #[test]
2374 fn logical_output_hailo_per_scale_split() {
2375 let j = r#"{
2377 "name": "boxes", "type": "boxes",
2378 "shape": [1, 64, 8400],
2379 "encoding": "dfl", "decoder": "ultralytics", "normalized": true,
2380 "outputs": [
2381 {
2382 "name": "boxes_0", "type": "boxes",
2383 "stride": 8, "scale_index": 0,
2384 "shape": [1, 80, 80, 64],
2385 "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_features": 64}],
2386 "dtype": "uint8",
2387 "quantization": {"scale": 0.0234, "zero_point": 128, "dtype": "uint8"}
2388 }
2389 ]
2390 }"#;
2391 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2392 assert!(lo.is_split());
2393 assert_eq!(lo.outputs.len(), 1);
2394 let child = &lo.outputs[0];
2395 assert_eq!(child.name, "boxes_0");
2396 assert_eq!(child.type_, Some(PhysicalType::Boxes));
2397 assert_eq!(child.stride, Some(Stride::Square(8)));
2398 assert_eq!(child.scale_index, Some(0));
2399 assert_eq!(child.dtype, DType::Uint8);
2400 }
2401
2402 #[test]
2403 fn logical_output_ara2_xy_wh_channel_split() {
2404 let j = r#"{
2406 "name": "boxes", "type": "boxes",
2407 "shape": [1, 4, 8400, 1],
2408 "encoding": "direct", "decoder": "ultralytics", "normalized": true,
2409 "outputs": [
2410 {
2411 "name": "_model_22_Div_1_output_0", "type": "boxes_xy",
2412 "shape": [1, 2, 8400, 1],
2413 "dshape": [{"batch": 1}, {"box_coords": 2}, {"num_boxes": 8400}, {"padding": 1}],
2414 "dtype": "int16",
2415 "quantization": {"scale": 3.129e-5, "zero_point": 0, "dtype": "int16"}
2416 },
2417 {
2418 "name": "_model_22_Sub_1_output_0", "type": "boxes_wh",
2419 "shape": [1, 2, 8400, 1],
2420 "dshape": [{"batch": 1}, {"box_coords": 2}, {"num_boxes": 8400}, {"padding": 1}],
2421 "dtype": "int16",
2422 "quantization": {"scale": 3.149e-5, "zero_point": 0, "dtype": "int16"}
2423 }
2424 ]
2425 }"#;
2426 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2427 assert_eq!(lo.encoding, Some(BoxEncoding::Direct));
2428 assert_eq!(lo.outputs.len(), 2);
2429 assert_eq!(lo.outputs[0].type_, Some(PhysicalType::BoxesXy));
2430 assert_eq!(lo.outputs[1].type_, Some(PhysicalType::BoxesWh));
2431 assert!(lo.outputs[0].stride.is_none());
2432 assert!(lo.outputs[1].stride.is_none());
2433 }
2434
2435 #[test]
2436 fn logical_output_hailo_scores_sigmoid_applied() {
2437 let j = r#"{
2438 "name": "scores", "type": "scores",
2439 "shape": [1, 80, 8400],
2440 "decoder": "ultralytics", "score_format": "per_class",
2441 "outputs": [
2442 {
2443 "name": "scores_0", "type": "scores",
2444 "stride": 8, "scale_index": 0,
2445 "shape": [1, 80, 80, 80],
2446 "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_classes": 80}],
2447 "dtype": "uint8",
2448 "quantization": {"scale": 0.003922, "dtype": "uint8"},
2449 "activation_applied": "sigmoid"
2450 }
2451 ]
2452 }"#;
2453 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2454 assert_eq!(lo.score_format, Some(ScoreFormat::PerClass));
2455 let child = &lo.outputs[0];
2456 assert_eq!(child.activation_applied, Some(Activation::Sigmoid));
2457 assert!(child.activation_required.is_none());
2458 }
2459
2460 #[test]
2461 fn yolo26_end_to_end_detections() {
2462 let j = r#"{
2463 "schema_version": 2,
2464 "decoder_version": "yolo26",
2465 "outputs": [{
2466 "name": "output0", "type": "detections",
2467 "shape": [1, 100, 6],
2468 "dshape": [{"batch": 1}, {"num_boxes": 100}, {"num_features": 6}],
2469 "dtype": "int8",
2470 "quantization": {"scale": 0.0078, "zero_point": 0, "dtype": "int8"},
2471 "normalized": false,
2472 "decoder": "ultralytics"
2473 }]
2474 }"#;
2475 let s: SchemaV2 = serde_json::from_str(j).unwrap();
2476 assert_eq!(s.decoder_version, Some(DecoderVersion::Yolo26));
2477 assert!(s.decoder_version.unwrap().is_end_to_end());
2478 assert_eq!(s.outputs[0].type_, Some(LogicalType::Detections));
2479 assert_eq!(s.outputs[0].normalized, Some(false));
2480 assert!(s.nms.is_none());
2481 }
2482
2483 #[test]
2484 fn modelpack_anchor_detection_with_rect_stride() {
2485 let j = r#"{
2486 "schema_version": 2,
2487 "outputs": [{
2488 "name": "output_0", "type": "detection",
2489 "shape": [1, 40, 40, 54],
2490 "dshape": [{"batch": 1}, {"height": 40}, {"width": 40}, {"num_anchors_x_features": 54}],
2491 "dtype": "uint8",
2492 "quantization": {"scale": 0.176, "zero_point": 198, "dtype": "uint8"},
2493 "decoder": "modelpack",
2494 "encoding": "anchor",
2495 "stride": [16, 16],
2496 "anchors": [[0.054, 0.065], [0.089, 0.139], [0.195, 0.196]]
2497 }]
2498 }"#;
2499 let s: SchemaV2 = serde_json::from_str(j).unwrap();
2500 let lo = &s.outputs[0];
2501 assert_eq!(lo.encoding, Some(BoxEncoding::Anchor));
2502 assert_eq!(lo.stride, Some(Stride::Rect([16, 16])));
2503 assert_eq!(lo.anchors.as_ref().map(|a| a.len()), Some(3));
2504 }
2505
2506 #[test]
2507 fn yolov5_obj_x_class_objectness_logical() {
2508 let j = r#"{
2509 "name": "objectness", "type": "objectness",
2510 "shape": [1, 3, 8400],
2511 "decoder": "ultralytics",
2512 "outputs": [{
2513 "name": "objectness_0", "type": "objectness",
2514 "stride": 8, "scale_index": 0,
2515 "shape": [1, 80, 80, 3],
2516 "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_features": 3}],
2517 "dtype": "uint8",
2518 "quantization": {"scale": 0.0039, "zero_point": 0, "dtype": "uint8"},
2519 "activation_applied": "sigmoid"
2520 }]
2521 }"#;
2522 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2523 assert_eq!(lo.type_, Some(LogicalType::Objectness));
2524 assert_eq!(lo.outputs[0].activation_applied, Some(Activation::Sigmoid));
2525 }
2526
2527 #[test]
2528 fn direct_protos_no_decoder() {
2529 let j = r#"{
2531 "name": "protos", "type": "protos",
2532 "shape": [1, 32, 160, 160],
2533 "dshape": [{"batch": 1}, {"num_protos": 32}, {"height": 160}, {"width": 160}],
2534 "dtype": "uint8",
2535 "quantization": {"scale": 0.0203, "zero_point": 45, "dtype": "uint8"},
2536 "stride": 4
2537 }"#;
2538 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2539 assert_eq!(lo.type_, Some(LogicalType::Protos));
2540 assert!(lo.decoder.is_none());
2541 assert_eq!(lo.stride, Some(Stride::Square(4)));
2542 }
2543
2544 #[test]
2545 fn full_yolov8_tflite_flat_detection() {
2546 let j = r#"{
2548 "schema_version": 2,
2549 "decoder_version": "yolov8",
2550 "nms": "class_agnostic",
2551 "input": { "shape": [1, 640, 640, 3], "cameraadaptor": "rgb" },
2552 "outputs": [
2553 {
2554 "name": "boxes", "type": "boxes",
2555 "shape": [1, 64, 8400],
2556 "dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
2557 "dtype": "int8",
2558 "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
2559 "decoder": "ultralytics",
2560 "encoding": "dfl",
2561 "normalized": true
2562 },
2563 {
2564 "name": "scores", "type": "scores",
2565 "shape": [1, 80, 8400],
2566 "dshape": [{"batch": 1}, {"num_classes": 80}, {"num_boxes": 8400}],
2567 "dtype": "int8",
2568 "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
2569 "decoder": "ultralytics",
2570 "score_format": "per_class"
2571 }
2572 ]
2573 }"#;
2574 let s: SchemaV2 = serde_json::from_str(j).unwrap();
2575 assert_eq!(s.schema_version, 2);
2576 assert_eq!(s.decoder_version, Some(DecoderVersion::Yolov8));
2577 assert_eq!(s.nms, Some(NmsMode::ClassAgnostic));
2578 assert_eq!(s.input.as_ref().unwrap().shape, vec![1, 640, 640, 3]);
2579 assert_eq!(s.outputs.len(), 2);
2580 }
2581
2582 #[test]
2583 fn schema_unknown_version_parses_without_validation() {
2584 let j = r#"{"schema_version": 99, "outputs": []}"#;
2587 let s: SchemaV2 = serde_json::from_str(j).unwrap();
2588 assert_eq!(s.schema_version, 99);
2589 }
2590
2591 #[test]
2592 fn serde_roundtrip_preserves_fields() {
2593 let original = SchemaV2 {
2594 schema_version: 2,
2595 input: Some(InputSpec {
2596 shape: vec![1, 3, 640, 640],
2597 dshape: vec![],
2598 cameraadaptor: Some("rgb".into()),
2599 preprocessing: None,
2600 }),
2601 outputs: vec![LogicalOutput {
2602 name: Some("boxes".into()),
2603 type_: Some(LogicalType::Boxes),
2604 shape: vec![1, 4, 8400],
2605 dshape: vec![],
2606 decoder: Some(DecoderKind::Ultralytics),
2607 encoding: Some(BoxEncoding::Direct),
2608 score_format: None,
2609 normalized: Some(true),
2610 anchors: None,
2611 stride: None,
2612 dtype: Some(DType::Float32),
2613 quantization: None,
2614 outputs: vec![],
2615 activation_applied: None,
2616 activation_required: None,
2617 semantic: Default::default(),
2618 }],
2619 nms: Some(NmsMode::ClassAgnostic),
2620 nms_multi_label: None,
2621 decoder_version: Some(DecoderVersion::Yolov8),
2622 };
2623 let j = serde_json::to_string(&original).unwrap();
2624 let parsed: SchemaV2 = serde_json::from_str(&j).unwrap();
2625 assert_eq!(parsed, original);
2626 }
2627
2628 #[test]
2631 fn parse_v1_yaml_yolov8_seg_testdata() {
2632 let yaml = edgefirst_bench::testdata::read_to_string("yolov8_seg.yaml");
2633 let schema = SchemaV2::parse_yaml(&yaml).expect("parse v1 yaml");
2634 assert_eq!(schema.schema_version, 2);
2635 assert_eq!(schema.outputs.len(), 2);
2636 let det = &schema.outputs[0];
2638 assert_eq!(det.type_, Some(LogicalType::Detection));
2639 assert_eq!(det.shape, vec![1, 116, 8400]);
2640 assert_eq!(det.decoder, Some(DecoderKind::Ultralytics));
2641 assert_eq!(det.encoding, Some(BoxEncoding::Direct));
2642 let q = det.quantization.as_ref().unwrap();
2643 assert_eq!(q.scale.len(), 1);
2644 assert!((q.scale[0] - 0.021_287_762).abs() < 1e-6);
2645 assert_eq!(q.zero_point, Some(vec![31]));
2646 let protos = &schema.outputs[1];
2648 assert_eq!(protos.type_, Some(LogicalType::Protos));
2649 assert_eq!(protos.shape, vec![1, 160, 160, 32]);
2650 }
2651
2652 #[test]
2653 fn parse_v1_json_modelpack_split_testdata() {
2654 let json = edgefirst_bench::testdata::read_to_string("modelpack_split.json");
2655 let schema = SchemaV2::parse_json(&json).expect("parse v1 json");
2656 assert_eq!(schema.schema_version, 2);
2657 assert_eq!(schema.outputs.len(), 2);
2658 for out in &schema.outputs {
2660 assert_eq!(out.type_, Some(LogicalType::Detection));
2661 assert_eq!(out.decoder, Some(DecoderKind::ModelPack));
2662 assert_eq!(out.encoding, Some(BoxEncoding::Anchor));
2663 assert_eq!(out.anchors.as_ref().map(|a| a.len()), Some(3));
2664 }
2665 }
2666
2667 #[test]
2668 fn parse_v2_json_direct_when_schema_version_present() {
2669 let j = r#"{
2670 "schema_version": 2,
2671 "outputs": [{
2672 "name": "boxes", "type": "boxes",
2673 "shape": [1, 4, 8400],
2674 "dshape": [{"batch": 1}, {"box_coords": 4}, {"num_boxes": 8400}],
2675 "dtype": "float32",
2676 "decoder": "ultralytics",
2677 "encoding": "direct",
2678 "normalized": true
2679 }]
2680 }"#;
2681 let schema = SchemaV2::parse_json(j).unwrap();
2682 assert_eq!(schema.schema_version, 2);
2683 assert_eq!(schema.outputs[0].type_, Some(LogicalType::Boxes));
2684 }
2685
2686 #[test]
2687 fn parse_rejects_future_schema_version() {
2688 let j = r#"{"schema_version": 99, "outputs": []}"#;
2689 let err = SchemaV2::parse_json(j).unwrap_err();
2690 matches!(err, DecoderError::NotSupported(_));
2691 }
2692
2693 #[test]
2694 fn parse_absent_schema_version_treats_as_v1() {
2695 let j = r#"{
2697 "outputs": [
2698 {
2699 "type": "boxes", "decoder": "ultralytics",
2700 "shape": [1, 4, 8400],
2701 "quantization": [0.00392, 0]
2702 },
2703 {
2704 "type": "scores", "decoder": "ultralytics",
2705 "shape": [1, 80, 8400],
2706 "quantization": [0.00392, 0]
2707 }
2708 ]
2709 }"#;
2710 let schema = SchemaV2::parse_json(j).expect("v1 legacy parse");
2711 assert_eq!(schema.schema_version, 2); assert_eq!(schema.outputs.len(), 2);
2713 assert_eq!(schema.outputs[0].type_, Some(LogicalType::Boxes));
2714 assert_eq!(schema.outputs[1].type_, Some(LogicalType::Scores));
2715 assert_eq!(schema.outputs[1].score_format, Some(ScoreFormat::PerClass));
2717 }
2718
2719 #[test]
2720 fn from_v1_preserves_nms_and_decoder_version() {
2721 let v1 = ConfigOutputs {
2722 outputs: vec![ConfigOutput::Boxes(crate::configs::Boxes {
2723 decoder: crate::configs::DecoderType::Ultralytics,
2724 quantization: Some(crate::configs::QuantTuple(0.01, 5)),
2725 shape: vec![1, 4, 8400],
2726 dshape: vec![],
2727 normalized: Some(true),
2728 })],
2729 nms: Some(crate::configs::Nms::ClassAware),
2730 nms_multi_label: None,
2731 decoder_version: Some(crate::configs::DecoderVersion::Yolo11),
2732 };
2733 let v2 = SchemaV2::from_v1(&v1).unwrap();
2734 assert_eq!(v2.nms, Some(NmsMode::ClassAware));
2735 assert_eq!(v2.decoder_version, Some(DecoderVersion::Yolo11));
2736 assert_eq!(v2.outputs[0].normalized, Some(true));
2737 let q = v2.outputs[0].quantization.as_ref().unwrap();
2738 assert_eq!(q.scale, vec![0.01]);
2739 assert_eq!(q.zero_point, Some(vec![5]));
2740 assert_eq!(q.dtype, None); }
2742
2743 #[test]
2750 fn typeless_logical_output_parses_and_roundtrips() {
2751 let j = r#"{
2752 "schema_version": 2,
2753 "outputs": [
2754 {
2755 "name": "extra_telemetry",
2756 "shape": [1, 16]
2757 },
2758 {
2759 "name": "boxes",
2760 "type": "boxes",
2761 "shape": [1, 4, 8400]
2762 }
2763 ]
2764 }"#;
2765 let schema: SchemaV2 = serde_json::from_str(j).unwrap();
2766 assert_eq!(schema.outputs.len(), 2);
2767 assert_eq!(schema.outputs[0].type_, None);
2768 assert_eq!(schema.outputs[0].name.as_deref(), Some("extra_telemetry"));
2769 assert_eq!(schema.outputs[1].type_, Some(LogicalType::Boxes));
2770
2771 let round = serde_json::to_string(&schema).unwrap();
2773 let first_obj = round
2774 .split("\"outputs\":[")
2775 .nth(1)
2776 .and_then(|s| s.split("}").next())
2777 .expect("outputs array");
2778 assert!(
2779 !first_obj.contains("\"type\""),
2780 "typeless output must not serialize a `type` field, got: {first_obj}"
2781 );
2782 }
2783
2784 #[test]
2790 fn typeless_outputs_filtered_from_legacy_config() {
2791 let schema = SchemaV2 {
2792 schema_version: 2,
2793 input: None,
2794 outputs: vec![
2795 LogicalOutput {
2796 name: Some("diagnostic_histogram".into()),
2797 type_: None,
2798 shape: vec![1, 256],
2799 dshape: vec![],
2800 decoder: None,
2801 encoding: None,
2802 score_format: None,
2803 normalized: None,
2804 anchors: None,
2805 stride: None,
2806 dtype: None,
2807 quantization: None,
2808 outputs: vec![],
2809 activation_applied: None,
2810 activation_required: None,
2811 semantic: Default::default(),
2812 },
2813 LogicalOutput {
2814 name: Some("boxes".into()),
2815 type_: Some(LogicalType::Boxes),
2816 shape: vec![1, 4, 8400],
2817 dshape: vec![],
2818 decoder: Some(DecoderKind::Ultralytics),
2819 encoding: Some(BoxEncoding::Direct),
2820 score_format: None,
2821 normalized: Some(true),
2822 anchors: None,
2823 stride: None,
2824 dtype: None,
2825 quantization: None,
2826 outputs: vec![],
2827 activation_applied: None,
2828 activation_required: None,
2829 semantic: Default::default(),
2830 },
2831 ],
2832 nms: None,
2833 nms_multi_label: None,
2834 decoder_version: None,
2835 };
2836 let legacy = schema.to_legacy_config_outputs().unwrap();
2837 assert_eq!(
2838 legacy.outputs.len(),
2839 1,
2840 "typeless output must be filtered from legacy config"
2841 );
2842 assert!(
2843 matches!(legacy.outputs[0], ConfigOutput::Boxes(_)),
2844 "only the typed `boxes` output should survive lowering"
2845 );
2846 }
2847
2848 #[test]
2853 fn all_typeless_schema_produces_empty_legacy_config() {
2854 let schema = SchemaV2 {
2855 schema_version: 2,
2856 input: None,
2857 outputs: vec![LogicalOutput {
2858 name: Some("aux".into()),
2859 type_: None,
2860 shape: vec![1, 8],
2861 dshape: vec![],
2862 decoder: None,
2863 encoding: None,
2864 score_format: None,
2865 normalized: None,
2866 anchors: None,
2867 stride: None,
2868 dtype: None,
2869 quantization: None,
2870 outputs: vec![],
2871 activation_applied: None,
2872 activation_required: None,
2873 semantic: Default::default(),
2874 }],
2875 nms: None,
2876 nms_multi_label: None,
2877 decoder_version: None,
2878 };
2879 let legacy = schema.to_legacy_config_outputs().unwrap();
2880 assert!(legacy.outputs.is_empty());
2881 }
2882
2883 #[test]
2889 fn typeless_physical_child_parses_and_skips_uniqueness() {
2890 let j = r#"{
2891 "name": "boxes",
2892 "type": "boxes",
2893 "shape": [1, 8400, 4],
2894 "outputs": [
2895 {
2896 "name": "boxes_xy",
2897 "type": "boxes_xy",
2898 "shape": [1, 8400, 2],
2899 "dtype": "float32"
2900 },
2901 {
2902 "name": "aux_user_managed",
2903 "shape": [1, 8400, 2],
2904 "dtype": "float32"
2905 }
2906 ]
2907 }"#;
2908 let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2909 assert_eq!(lo.outputs.len(), 2);
2910 assert_eq!(lo.outputs[0].type_, Some(PhysicalType::BoxesXy));
2911 assert_eq!(lo.outputs[1].type_, None);
2912
2913 let schema = SchemaV2 {
2917 schema_version: 2,
2918 input: None,
2919 outputs: vec![lo],
2920 nms: None,
2921 nms_multi_label: None,
2922 decoder_version: None,
2923 };
2924 schema.validate().expect(
2925 "typed + typeless children with equal shape must not trigger \
2926 uniqueness error",
2927 );
2928
2929 let s = serde_json::to_string(&schema).unwrap();
2931 assert!(
2932 s.contains("\"aux_user_managed\""),
2933 "typeless child must survive round-trip: {s}"
2934 );
2935 let aux_obj = s
2937 .split("\"aux_user_managed\"")
2938 .nth(1)
2939 .and_then(|s| s.split('}').next())
2940 .unwrap_or("");
2941 assert!(
2942 !aux_obj.contains("\"type\""),
2943 "typeless child must not serialize `type`, got: {aux_obj}"
2944 );
2945 }
2946
2947 #[test]
2948 fn from_v1_modelpack_anchor_detection_maps_encoding() {
2949 let v1 = ConfigOutputs {
2950 outputs: vec![ConfigOutput::Detection(crate::configs::Detection {
2951 anchors: Some(vec![[0.1, 0.2], [0.3, 0.4]]),
2952 decoder: crate::configs::DecoderType::ModelPack,
2953 quantization: Some(crate::configs::QuantTuple(0.176, 198)),
2954 shape: vec![1, 40, 40, 54],
2955 dshape: vec![],
2956 normalized: None,
2957 })],
2958 nms: None,
2959 nms_multi_label: None,
2960 decoder_version: None,
2961 };
2962 let v2 = SchemaV2::from_v1(&v1).unwrap();
2963 assert_eq!(v2.outputs[0].encoding, Some(BoxEncoding::Anchor));
2964 assert_eq!(v2.outputs[0].decoder, Some(DecoderKind::ModelPack));
2965 assert_eq!(v2.outputs[0].anchors.as_ref().map(|a| a.len()), Some(2));
2966 }
2967
2968 #[test]
2971 fn validate_accepts_flat_v2_yolov8_detection() {
2972 let j = r#"{
2973 "schema_version": 2,
2974 "outputs": [
2975 {"name":"boxes","type":"boxes","shape":[1,64,8400],
2976 "dtype":"int8","decoder":"ultralytics","encoding":"dfl"},
2977 {"name":"scores","type":"scores","shape":[1,80,8400],
2978 "dtype":"int8","decoder":"ultralytics","score_format":"per_class"}
2979 ]
2980 }"#;
2981 SchemaV2::parse_json(j).unwrap().validate().unwrap();
2982 }
2983
2984 #[test]
2985 fn validate_rejects_unnamed_physical_child() {
2986 let j = r#"{
2987 "schema_version": 2,
2988 "outputs": [{
2989 "name":"boxes","type":"boxes","shape":[1,64,8400],
2990 "encoding":"dfl","decoder":"ultralytics",
2991 "outputs": [{
2992 "name":"","type":"boxes","stride":8,
2993 "shape":[1,80,80,64],"dtype":"uint8"
2994 }]
2995 }]
2996 }"#;
2997 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2998 let msg = format!("{err}");
2999 assert!(msg.contains("missing `name`"), "got: {msg}");
3000 }
3001
3002 #[test]
3003 fn validate_rejects_duplicate_physical_shapes() {
3004 let j = r#"{
3005 "schema_version": 2,
3006 "outputs": [{
3007 "name":"boxes","type":"boxes","shape":[1,64,8400],
3008 "encoding":"dfl","decoder":"ultralytics",
3009 "outputs": [
3010 {"name":"a","type":"boxes","stride":8,"shape":[1,80,80,64],"dtype":"uint8"},
3011 {"name":"b","type":"boxes","stride":16,"shape":[1,80,80,64],"dtype":"uint8"}
3012 ]
3013 }]
3014 }"#;
3015 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
3016 let msg = format!("{err}");
3017 assert!(msg.contains("share shape"), "got: {msg}");
3018 }
3019
3020 #[test]
3021 fn validate_rejects_mixed_decomposition() {
3022 let j = r#"{
3024 "schema_version": 2,
3025 "outputs": [{
3026 "name":"boxes","type":"boxes","shape":[1,4,8400,1],
3027 "encoding":"direct","decoder":"ultralytics",
3028 "outputs": [
3029 {"name":"xy","type":"boxes_xy","shape":[1,2,8400,1],"dtype":"int16"},
3030 {"name":"p0","type":"boxes","stride":8,"shape":[1,80,80,64],"dtype":"uint8"}
3031 ]
3032 }]
3033 }"#;
3034 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
3035 let msg = format!("{err}");
3036 assert!(msg.contains("uniform"), "got: {msg}");
3037 }
3038
3039 #[test]
3040 fn validate_rejects_dfl_boxes_feature_not_divisible_by_4() {
3041 let j = r#"{
3042 "schema_version": 2,
3043 "outputs": [{
3044 "name":"boxes","type":"boxes","shape":[1,63,8400],
3045 "encoding":"dfl","decoder":"ultralytics",
3046 "outputs": [{
3047 "name":"b0","type":"boxes","stride":8,
3048 "shape":[1,80,80,63],
3049 "dshape":[{"batch":1},{"height":80},{"width":80},{"num_features":63}],
3050 "dtype":"uint8"
3051 }]
3052 }]
3053 }"#;
3054 let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
3055 let msg = format!("{err}");
3056 assert!(msg.contains("not"), "got: {msg}");
3057 assert!(msg.contains("divisible by 4"), "got: {msg}");
3058 }
3059
3060 #[test]
3061 fn validate_accepts_hailo_per_scale_yolov8() {
3062 let j = r#"{
3063 "schema_version": 2,
3064 "outputs": [{
3065 "name":"boxes","type":"boxes","shape":[1,64,8400],
3066 "encoding":"dfl","decoder":"ultralytics","normalized":true,
3067 "outputs": [
3068 {"name":"b0","type":"boxes","stride":8,
3069 "shape":[1,80,80,64],
3070 "dshape":[{"batch":1},{"height":80},{"width":80},{"num_features":64}],
3071 "dtype":"uint8",
3072 "quantization":{"scale":0.0234,"zero_point":128,"dtype":"uint8"}},
3073 {"name":"b1","type":"boxes","stride":16,
3074 "shape":[1,40,40,64],
3075 "dshape":[{"batch":1},{"height":40},{"width":40},{"num_features":64}],
3076 "dtype":"uint8",
3077 "quantization":{"scale":0.0198,"zero_point":130,"dtype":"uint8"}},
3078 {"name":"b2","type":"boxes","stride":32,
3079 "shape":[1,20,20,64],
3080 "dshape":[{"batch":1},{"height":20},{"width":20},{"num_features":64}],
3081 "dtype":"uint8",
3082 "quantization":{"scale":0.0312,"zero_point":125,"dtype":"uint8"}}
3083 ]
3084 }]
3085 }"#;
3086 let s = SchemaV2::parse_json(j).unwrap();
3087 s.validate().unwrap();
3088 }
3089
3090 #[test]
3091 fn validate_accepts_ara2_xy_wh() {
3092 let j = r#"{
3093 "schema_version": 2,
3094 "outputs": [{
3095 "name":"boxes","type":"boxes","shape":[1,4,8400,1],
3096 "encoding":"direct","decoder":"ultralytics","normalized":true,
3097 "outputs": [
3098 {"name":"xy","type":"boxes_xy","shape":[1,2,8400,1],
3099 "dshape":[{"batch":1},{"box_coords":2},{"num_boxes":8400},{"padding":1}],
3100 "dtype":"int16",
3101 "quantization":{"scale":3.1e-5,"zero_point":0,"dtype":"int16"}},
3102 {"name":"wh","type":"boxes_wh","shape":[1,2,8400,1],
3103 "dshape":[{"batch":1},{"box_coords":2},{"num_boxes":8400},{"padding":1}],
3104 "dtype":"int16",
3105 "quantization":{"scale":3.2e-5,"zero_point":0,"dtype":"int16"}}
3106 ]
3107 }]
3108 }"#;
3109 SchemaV2::parse_json(j).unwrap().validate().unwrap();
3110 }
3111
3112 #[test]
3113 fn parse_file_auto_detects_json() {
3114 let tmp = std::env::temp_dir().join(format!("schema_v2_test_{}.json", std::process::id()));
3115 std::fs::write(&tmp, r#"{"schema_version":2,"outputs":[]}"#).unwrap();
3116 let s = SchemaV2::parse_file(&tmp).unwrap();
3117 assert_eq!(s.schema_version, 2);
3118 let _ = std::fs::remove_file(&tmp);
3119 }
3120
3121 #[test]
3122 fn parse_file_auto_detects_yaml() {
3123 let tmp = std::env::temp_dir().join(format!("schema_v2_test_{}.yaml", std::process::id()));
3124 std::fs::write(&tmp, "schema_version: 2\noutputs: []\n").unwrap();
3125 let s = SchemaV2::parse_file(&tmp).unwrap();
3126 assert_eq!(s.schema_version, 2);
3127 let _ = std::fs::remove_file(&tmp);
3128 }
3129
3130 #[test]
3133 fn parse_real_ara2_int8_dvm_metadata() {
3134 let json = edgefirst_bench::testdata::read_to_string("ara2_int8_edgefirst.json");
3135 let schema = SchemaV2::parse_json(&json).expect("ARA-2 int8 parse");
3136 assert_eq!(schema.schema_version, 2);
3137 assert_eq!(schema.decoder_version, Some(DecoderVersion::Yolov8));
3138 assert_eq!(schema.nms, Some(NmsMode::ClassAgnostic));
3139 assert_eq!(schema.input.as_ref().unwrap().shape, vec![1, 3, 640, 640]);
3140
3141 assert_eq!(schema.outputs.len(), 4);
3143 let boxes = &schema.outputs[0];
3144 assert_eq!(boxes.type_, Some(LogicalType::Boxes));
3145 assert_eq!(boxes.encoding, Some(BoxEncoding::Direct));
3146 assert_eq!(boxes.normalized, Some(true));
3147 assert_eq!(boxes.shape, vec![1, 4, 8400, 1]); assert_eq!(boxes.outputs.len(), 2);
3149 assert_eq!(boxes.outputs[0].type_, Some(PhysicalType::BoxesXy));
3150 assert_eq!(boxes.outputs[1].type_, Some(PhysicalType::BoxesWh));
3151 let q_xy = boxes.outputs[0].quantization.as_ref().unwrap();
3153 assert_eq!(q_xy.dtype, Some(DType::Int8));
3154 assert!((q_xy.scale[0] - 0.004_177_792).abs() < 1e-6);
3155 assert_eq!(q_xy.zero_point_at(0), -122);
3156
3157 let scores = &schema.outputs[1];
3158 assert_eq!(scores.type_, Some(LogicalType::Scores));
3159 assert_eq!(scores.score_format, Some(ScoreFormat::PerClass));
3160 assert_eq!(scores.shape, vec![1, 80, 8400, 1]);
3161
3162 let mask_coefs = &schema.outputs[2];
3163 assert_eq!(mask_coefs.type_, Some(LogicalType::MaskCoefs));
3164 assert_eq!(mask_coefs.shape, vec![1, 32, 8400, 1]);
3165
3166 let protos = &schema.outputs[3];
3167 assert_eq!(protos.type_, Some(LogicalType::Protos));
3168 assert_eq!(protos.shape, vec![1, 32, 160, 160]);
3169
3170 schema.validate().expect("ARA-2 int8 validate");
3172 }
3173
3174 #[test]
3175 fn parse_real_ara2_int16_dvm_metadata() {
3176 let json = edgefirst_bench::testdata::read_to_string("ara2_int16_edgefirst.json");
3177 let schema = SchemaV2::parse_json(&json).expect("ARA-2 int16 parse");
3178 assert_eq!(schema.schema_version, 2);
3179 assert_eq!(schema.outputs.len(), 4);
3180 let boxes = &schema.outputs[0];
3181 assert_eq!(boxes.outputs.len(), 2);
3182 let q_xy = boxes.outputs[0].quantization.as_ref().unwrap();
3183 assert_eq!(q_xy.dtype, Some(DType::Int16));
3184 assert!((q_xy.scale[0] - 3.211_570_6e-5).abs() < 1e-10);
3185 assert_eq!(q_xy.zero_point_at(0), 0);
3186 let mc_q = schema.outputs[2].quantization.as_ref().unwrap();
3188 assert_eq!(mc_q.dtype, Some(DType::Int16));
3189 schema.validate().expect("ARA-2 int16 validate");
3190 }
3191
3192 #[test]
3193 fn parse_yaml_with_explicit_schema_version_2() {
3194 let yaml = r#"
3195schema_version: 2
3196outputs:
3197 - name: scores
3198 type: scores
3199 shape: [1, 80, 8400]
3200 dtype: int8
3201 quantization:
3202 scale: 0.00392
3203 dtype: int8
3204 decoder: ultralytics
3205 score_format: per_class
3206"#;
3207 let schema = SchemaV2::parse_yaml(yaml).unwrap();
3208 assert_eq!(schema.schema_version, 2);
3209 assert_eq!(schema.outputs[0].score_format, Some(ScoreFormat::PerClass));
3210 }
3211
3212 #[test]
3215 fn squeeze_padding_dims_preserves_shape_when_dshape_absent() {
3216 let (shape, dshape) = squeeze_padding_dims(vec![1, 4, 8400], vec![]);
3221 assert_eq!(shape, vec![1, 4, 8400]);
3222 assert!(dshape.is_empty());
3223 }
3224
3225 fn sample_logical(ty: LogicalType) -> LogicalOutput {
3226 LogicalOutput {
3227 name: Some("sample".into()),
3228 type_: Some(ty),
3229 shape: vec![1, 4, 100],
3230 dshape: vec![],
3231 decoder: None,
3232 encoding: None,
3233 score_format: None,
3234 normalized: Some(true),
3235 anchors: None,
3236 stride: None,
3237 dtype: None,
3238 quantization: None,
3239 outputs: vec![],
3240 activation_applied: None,
3241 activation_required: None,
3242 semantic: Default::default(),
3243 }
3244 }
3245
3246 #[test]
3247 fn logical_to_legacy_maps_each_supported_type() {
3248 let cases = [
3249 (LogicalType::Boxes, "Boxes"),
3250 (LogicalType::Scores, "Scores"),
3251 (LogicalType::Protos, "Protos"),
3252 (LogicalType::MaskCoefs, "MaskCoefficients"),
3253 (LogicalType::Segmentation, "Segmentation"),
3254 (LogicalType::Masks, "Mask"),
3255 (LogicalType::Classes, "Classes"),
3256 (LogicalType::Detection, "Detection"),
3257 (LogicalType::Detections, "Detection"),
3258 ];
3259 for (ty, expected) in cases {
3260 let out = logical_to_legacy_config_output(&sample_logical(ty)).unwrap();
3261 let name = match &out {
3262 ConfigOutput::Boxes(_) => "Boxes",
3263 ConfigOutput::Scores(_) => "Scores",
3264 ConfigOutput::Protos(_) => "Protos",
3265 ConfigOutput::MaskCoefficients(_) => "MaskCoefficients",
3266 ConfigOutput::Segmentation(_) => "Segmentation",
3267 ConfigOutput::Mask(_) => "Mask",
3268 ConfigOutput::Classes(_) => "Classes",
3269 ConfigOutput::Detection(_) => "Detection",
3270 };
3271 assert_eq!(name, expected, "type {ty:?}");
3272 }
3273 }
3274
3275 #[test]
3276 fn logical_to_legacy_rejects_objectness_and_landmarks() {
3277 for ty in [LogicalType::Objectness, LogicalType::Landmarks] {
3278 let err = logical_to_legacy_config_output(&sample_logical(ty)).unwrap_err();
3279 assert!(
3280 matches!(err, DecoderError::NotSupported(_)),
3281 "expected NotSupported for {ty:?}, got {err:?}"
3282 );
3283 }
3284 }
3285
3286 #[test]
3287 fn logical_to_legacy_rejects_typeless_output() {
3288 let mut logical = sample_logical(LogicalType::Boxes);
3289 logical.type_ = None;
3290 let err = logical_to_legacy_config_output(&logical).unwrap_err();
3291 assert!(matches!(err, DecoderError::InvalidConfig(_)));
3292 }
3293
3294 #[test]
3295 fn to_legacy_modelpack_boxes_preserves_padding_dim() {
3296 let j = r#"{
3301 "schema_version": 2,
3302 "outputs": [
3303 {"name":"boxes","type":"boxes",
3304 "shape":[1,1935,1,4],
3305 "dshape":[{"batch":1},{"num_boxes":1935},{"padding":1},{"box_coords":4}],
3306 "decoder":"modelpack"}
3307 ]
3308 }"#;
3309 let schema = SchemaV2::parse_json(j).unwrap();
3310 let legacy = schema.to_legacy_config_outputs().expect("lowers cleanly");
3311 let boxes = match &legacy.outputs[0] {
3312 crate::ConfigOutput::Boxes(b) => b,
3313 other => panic!("expected Boxes, got {other:?}"),
3314 };
3315 assert_eq!(boxes.shape, vec![1, 1935, 1, 4]);
3318 assert_eq!(
3319 boxes.dshape,
3320 vec![
3321 (DimName::Batch, 1),
3322 (DimName::NumBoxes, 1935),
3323 (DimName::Padding, 1),
3324 (DimName::BoxCoords, 4),
3325 ]
3326 );
3327 }
3328
3329 #[test]
3330 fn to_legacy_preserves_shape_for_v2_split_boxes_without_dshape() {
3331 let j = r#"{
3335 "schema_version": 2,
3336 "outputs": [
3337 {"name":"boxes","type":"boxes","shape":[1,4,8400],
3338 "dtype":"float32","decoder":"ultralytics","encoding":"direct"},
3339 {"name":"scores","type":"scores","shape":[1,80,8400],
3340 "dtype":"float32","decoder":"ultralytics","score_format":"per_class"}
3341 ]
3342 }"#;
3343 let schema = SchemaV2::parse_json(j).unwrap();
3344 let legacy = schema.to_legacy_config_outputs().expect("lowers cleanly");
3345 let boxes = match &legacy.outputs[0] {
3346 crate::ConfigOutput::Boxes(b) => b,
3347 other => panic!("expected Boxes, got {other:?}"),
3348 };
3349 assert_eq!(boxes.shape, vec![1, 4, 8400]);
3350 let scores = match &legacy.outputs[1] {
3351 crate::ConfigOutput::Scores(s) => s,
3352 other => panic!("expected Scores, got {other:?}"),
3353 };
3354 assert_eq!(scores.shape, vec![1, 80, 8400]);
3355 }
3356
3357 const SEGFORMER_B1: &str = r#"{
3358 "schema_version": 2,
3359 "input": {
3360 "shape": [1, 3, 512, 672],
3361 "dshape": [{"batch": 1}, {"channels": 3}, {"height": 512}, {"width": 672}],
3362 "cameraadaptor": "rgb",
3363 "preprocessing": {
3364 "normalization": {"mean": [123.675, 116.28, 103.53], "std": [58.395, 57.12, 57.375]},
3365 "resize": "letterbox",
3366 "interpolation": "bilinear",
3367 "letterbox": {"placement": "topleft", "pad": [0.0, 0.0, 0.0], "pad_space": "normalized"}
3368 }
3369 },
3370 "outputs": [{
3371 "name": "logits", "type": "segmentation", "decoder": "semantic",
3372 "shape": [1, 150, 128, 168],
3373 "dshape": [{"batch": 1}, {"num_classes": 150}, {"height": 128}, {"width": 168}],
3374 "dtype": "float32", "stride": 4,
3375 "upsample": {"method": "bilinear", "align_corners": false},
3376 "decode_mode": "accurate"
3377 }]
3378 }"#;
3379
3380 #[test]
3381 fn semantic_segformer_parses_and_validates() {
3382 let schema = SchemaV2::parse_json(SEGFORMER_B1).unwrap();
3383 schema.validate().unwrap();
3384
3385 let pre = schema
3386 .input
3387 .as_ref()
3388 .unwrap()
3389 .preprocessing
3390 .as_ref()
3391 .unwrap();
3392 assert_eq!(pre.resize, Some(FitMode::Letterbox));
3393 assert_eq!(pre.interpolation, Some(Interpolation::Bilinear));
3394 let lb = pre.letterbox.as_ref().unwrap();
3395 assert_eq!(lb.placement, Some(LetterboxPlacement::TopLeft));
3396 assert_eq!(lb.pad_space, Some(PadSpace::Normalized));
3397 let (mean, std) = pre.normalization.as_ref().unwrap().mean_std(3).unwrap();
3398 assert_eq!(
3399 mean,
3400 edgefirst_tensor::Normalization::imagenet().mean().to_vec()
3401 );
3402 assert_eq!(
3403 std,
3404 edgefirst_tensor::Normalization::imagenet().std().to_vec()
3405 );
3406
3407 let out = &schema.outputs[0];
3408 assert_eq!(out.decoder, Some(DecoderKind::Semantic));
3409 assert_eq!(out.stride, Some(Stride::Square(4)));
3410 assert_eq!(
3411 out.semantic.upsample,
3412 Some(Upsample {
3413 method: UpsampleMethod::Bilinear,
3414 align_corners: false
3415 })
3416 );
3417 assert_eq!(out.semantic.decode_mode, Some(DecodeMode::Accurate));
3418 }
3419
3420 #[test]
3421 fn semantic_round_trips_with_flat_fields() {
3422 let schema = SchemaV2::parse_json(SEGFORMER_B1).unwrap();
3423 let value = serde_json::to_value(&schema).unwrap();
3424 let out = &value["outputs"][0];
3425 assert_eq!(out["decode_mode"], "accurate");
3426 assert_eq!(out["upsample"]["method"], "bilinear");
3427 assert!(out.get("semantic").is_none(), "fields must serialize flat");
3428 assert!(out.get("threshold").is_none(), "unset fields are omitted");
3429 let back: SchemaV2 = serde_json::from_value(value).unwrap();
3430 assert_eq!(back, schema);
3431 }
3432
3433 #[test]
3434 fn semantic_decoder_is_not_lowered_to_legacy() {
3435 let schema = SchemaV2::parse_json(SEGFORMER_B1).unwrap();
3436 let err = schema.to_legacy_config_outputs().unwrap_err();
3437 assert!(matches!(err, DecoderError::NotSupported(_)), "{err:?}");
3438 }
3439
3440 #[test]
3441 fn modelpack_segmentation_still_lowers_to_legacy() {
3442 let j = r#"{
3443 "schema_version": 2,
3444 "outputs": [{
3445 "name": "segmentation_output", "type": "segmentation",
3446 "shape": [1, 480, 640, 5],
3447 "dshape": [{"batch": 1}, {"height": 480}, {"width": 640}, {"num_classes": 5}],
3448 "dtype": "uint8",
3449 "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "uint8"},
3450 "decoder": "modelpack"
3451 }]
3452 }"#;
3453 let schema = SchemaV2::parse_json(j).unwrap();
3454 assert_eq!(schema.outputs[0].semantic, SemanticParams::default());
3455 let legacy = schema.to_legacy_config_outputs().unwrap();
3456 assert!(matches!(
3457 legacy.outputs[0],
3458 crate::ConfigOutput::Segmentation(_)
3459 ));
3460 }
3461
3462 #[test]
3463 fn normalization_presets_resolve() {
3464 let cases = [
3465 (NormalizationPreset::Unsigned, 0.0, 255.0),
3466 (NormalizationPreset::Signed, 127.5, 127.5),
3467 (NormalizationPreset::Raw, 0.0, 1.0),
3468 ];
3469 for (preset, m, s) in cases {
3470 let (mean, std) = NormalizationConfig::Preset(preset).mean_std(4).unwrap();
3471 assert_eq!(mean, vec![m; 4], "{preset:?}");
3472 assert_eq!(std, vec![s; 4], "{preset:?}");
3473 }
3474 let imagenet = NormalizationConfig::Preset(NormalizationPreset::Imagenet);
3475 assert!(imagenet.mean_std(3).is_ok());
3476 assert!(imagenet.mean_std(1).is_err());
3477 }
3478
3479 #[test]
3480 fn normalization_preset_parses_from_string() {
3481 for (text, preset) in [
3482 ("unsigned", NormalizationPreset::Unsigned),
3483 ("signed", NormalizationPreset::Signed),
3484 ("raw", NormalizationPreset::Raw),
3485 ("imagenet", NormalizationPreset::Imagenet),
3486 ] {
3487 let norm: NormalizationConfig =
3488 serde_json::from_value(serde_json::json!(text)).unwrap();
3489 assert_eq!(norm, NormalizationConfig::Preset(preset));
3490 }
3491 assert!(
3492 serde_json::from_value::<NormalizationConfig>(serde_json::json!("zscore")).is_err()
3493 );
3494 }
3495
3496 #[test]
3497 fn mean_std_broadcasts_and_rejects_bad_input() {
3498 let one = NormalizationConfig::MeanStd {
3499 mean: vec![127.5],
3500 std: vec![127.5],
3501 };
3502 assert_eq!(one.mean_std(3).unwrap().0, vec![127.5; 3]);
3503
3504 let mismatched = NormalizationConfig::MeanStd {
3505 mean: vec![0.0, 0.0],
3506 std: vec![1.0; 3],
3507 };
3508 assert!(mismatched.mean_std(3).is_err());
3509
3510 let zero_std = NormalizationConfig::MeanStd {
3511 mean: vec![0.0; 3],
3512 std: vec![1.0, 0.0, 1.0],
3513 };
3514 assert!(zero_std.mean_std(3).is_err());
3515 }
3516
3517 #[test]
3518 fn validate_rejects_bad_preprocessing() {
3519 let bad_pad = r#"{
3520 "schema_version": 2,
3521 "input": {"shape": [1, 3, 8, 8], "preprocessing": {
3522 "normalization": {"mean": [0, 0, 0], "std": [1, 1, 1]},
3523 "letterbox": {"pad": [0, 0]}
3524 }}
3525 }"#;
3526 assert!(SchemaV2::parse_json(bad_pad).unwrap().validate().is_err());
3527
3528 let zero_std = r#"{
3529 "schema_version": 2,
3530 "input": {"shape": [1, 3, 8, 8], "preprocessing": {
3531 "normalization": {"mean": [0], "std": [0]}
3532 }}
3533 }"#;
3534 assert!(SchemaV2::parse_json(zero_std).unwrap().validate().is_err());
3535 }
3536
3537 #[test]
3538 fn non_finite_mean_is_rejected() {
3539 for bad in [f32::NAN, f32::INFINITY] {
3540 let norm = NormalizationConfig::MeanStd {
3541 mean: vec![bad],
3542 std: vec![1.0],
3543 };
3544 assert!(norm.mean_std(3).is_err(), "{bad}");
3545
3546 let mut schema =
3547 SchemaV2::parse_json(r#"{"schema_version": 2, "input": {"shape": [1, 3, 8, 8]}}"#)
3548 .unwrap();
3549 schema.input.as_mut().unwrap().preprocessing = Some(Preprocessing {
3550 normalization: Some(norm),
3551 ..Default::default()
3552 });
3553 assert!(schema.validate().is_err(), "{bad}");
3554 }
3555 }
3556
3557 fn validate_input(shape: &str, preprocessing: &str) -> DecoderResult<()> {
3558 let j = format!(
3559 r#"{{"schema_version": 2, "input": {{{shape}, "preprocessing": {preprocessing}}}}}"#
3560 );
3561 SchemaV2::parse_json(&j).unwrap().validate()
3562 }
3563
3564 #[test]
3565 fn scalar_coefficients_broadcast_with_per_channel_pad() {
3566 let pre = r#"{"normalization": {"mean": [0], "std": [255]},
3567 "letterbox": {"pad": [114, 114, 114]}}"#;
3568 validate_input(r#""shape": [1, 3, 512, 512]"#, pre).unwrap();
3569 validate_input(r#""shape": [1, 512, 512, 3]"#, pre).unwrap();
3570 validate_input(r#""shape": [1, 3, 4, 3]"#, pre).unwrap();
3572 }
3573
3574 #[test]
3575 fn coefficients_must_match_input_channels() {
3576 let two = r#"{"normalization": {"mean": [0, 0], "std": [1, 1]}}"#;
3577 let nchw = r#""shape": [1, 3, 512, 512]"#;
3578 let named = r#""shape": [1, 512, 512, 3],
3579 "dshape": [{"batch": 1}, {"height": 512}, {"width": 512}, {"channels": 3}]"#;
3580 assert!(validate_input(nchw, two).is_err());
3581 assert!(validate_input(named, two).is_err());
3582
3583 let pad_two = r#"{"normalization": "unsigned", "letterbox": {"pad": [0, 0]}}"#;
3584 assert!(validate_input(nchw, pad_two).is_err());
3585
3586 let imagenet = r#"{"normalization": "imagenet"}"#;
3587 assert!(validate_input(r#""shape": [1, 1, 64, 64]"#, imagenet).is_err());
3588 validate_input(nchw, imagenet).unwrap();
3589
3590 let mixed = r#"{"normalization": {"mean": [0, 0, 0], "std": [1, 1]}}"#;
3592 assert!(validate_input(r#""shape": [1, 3, 4, 3]"#, mixed).is_err());
3593 }
3594
3595 #[test]
3596 fn input_channels_resolve_from_dshape_or_shape() {
3597 let spec = |j: &str| serde_json::from_str::<InputSpec>(j).unwrap().channels();
3598 assert_eq!(spec(r#"{"shape": [1, 3, 512, 512]}"#), Some(3));
3599 assert_eq!(spec(r#"{"shape": [1, 512, 512, 4]}"#), Some(4));
3600 assert_eq!(spec(r#"{"shape": [1, 3, 4, 3]}"#), None);
3601 assert_eq!(spec(r#"{"shape": [1, 512, 512]}"#), None);
3602 assert_eq!(
3603 spec(
3604 r#"{"shape": [1, 3, 4, 3],
3605 "dshape": [{"batch": 1}, {"channels": 3}, {"height": 4}, {"width": 3}]}"#
3606 ),
3607 Some(3)
3608 );
3609 }
3610
3611 #[test]
3612 fn preprocessing_parses_from_yaml() {
3613 let y = r#"
3614schema_version: 2
3615input:
3616 shape: [1, 256, 256, 3]
3617 preprocessing:
3618 normalization: unsigned
3619 resize: stretch
3620"#;
3621 let schema = SchemaV2::parse_yaml(y).unwrap();
3622 let pre = schema.input.unwrap().preprocessing.unwrap();
3623 assert_eq!(
3624 pre.normalization,
3625 Some(NormalizationConfig::Preset(NormalizationPreset::Unsigned))
3626 );
3627 assert_eq!(pre.resize, Some(FitMode::Stretch));
3628 assert!(pre.letterbox.is_none());
3629 }
3630
3631 #[test]
3632 fn class_map_masks_accept_int64() {
3633 let j = r#"{
3634 "schema_version": 2,
3635 "outputs": [{
3636 "name": "ArgMax", "type": "masks", "decoder": "semantic",
3637 "shape": [1, 513, 513],
3638 "dshape": [{"batch": 1}, {"height": 513}, {"width": 513}],
3639 "dtype": "int64"
3640 }]
3641 }"#;
3642 let schema = SchemaV2::parse_json(j).unwrap();
3643 schema.validate().unwrap();
3644 assert_eq!(schema.outputs[0].dtype, Some(DType::Int64));
3645 assert_eq!(DType::Int64.size_bytes(), 8);
3646 assert!(DType::Int64.is_integer());
3647 }
3648
3649 #[test]
3650 fn single_channel_threshold_and_background_parse() {
3651 let j = r#"{
3652 "schema_version": 2,
3653 "outputs": [{
3654 "name": "segment", "type": "segmentation", "decoder": "semantic",
3655 "shape": [1, 256, 256, 1],
3656 "dshape": [{"batch": 1}, {"height": 256}, {"width": 256}, {"num_classes": 1}],
3657 "dtype": "float32", "activation_applied": "sigmoid",
3658 "threshold": 0.5, "background_index": 0
3659 }]
3660 }"#;
3661 let schema = SchemaV2::parse_json(j).unwrap();
3662 let out = &schema.outputs[0];
3663 assert_eq!(out.activation_applied, Some(Activation::Sigmoid));
3664 assert_eq!(out.semantic.threshold, Some(0.5));
3665 assert_eq!(out.semantic.background_index, Some(0));
3666 }
3667
3668 #[test]
3669 fn reserved_mask_classification_parses_but_does_not_lower() {
3670 let j = r#"{
3671 "schema_version": 2,
3672 "outputs": [
3673 {"name": "class_queries_logits", "type": "class_logits",
3674 "shape": [1, 100, 151],
3675 "dshape": [{"batch": 1}, {"num_queries": 100}, {"num_classes": 151}],
3676 "dtype": "float32", "no_object_index": 150},
3677 {"name": "masks_queries_logits", "type": "mask_logits",
3678 "shape": [1, 100, 128, 128],
3679 "dshape": [{"batch": 1}, {"num_queries": 100}, {"height": 128}, {"width": 128}],
3680 "dtype": "float32", "stride": 4, "mask_activation": "sigmoid"}
3681 ]
3682 }"#;
3683 let schema = SchemaV2::parse_json(j).unwrap();
3684 schema.validate().unwrap();
3685 assert_eq!(schema.outputs[0].type_, Some(LogicalType::ClassLogits));
3686 assert_eq!(schema.outputs[0].dshape[1].0, DimName::NumQueries);
3687 assert_eq!(schema.outputs[0].semantic.no_object_index, Some(150));
3688 assert_eq!(
3689 schema.outputs[1].semantic.mask_activation,
3690 Some(MaskActivation::Sigmoid)
3691 );
3692 assert!(matches!(
3693 schema.to_legacy_config_outputs(),
3694 Err(DecoderError::NotSupported(_))
3695 ));
3696 }
3697
3698 #[test]
3699 fn semantic_decoder_kind_shares_the_v1_code() {
3700 assert_eq!(
3701 DecoderKind::Semantic.to_v1(),
3702 configs::DecoderType::Semantic
3703 );
3704 let v: configs::DecoderType = serde_json::from_str("\"semantic\"").unwrap();
3705 assert_eq!(v, configs::DecoderType::Semantic);
3706 }
3707}