use crate::configs::{self, deserialize_dshape, DimName, QuantTuple};
use crate::{ConfigOutput, ConfigOutputs, DecoderError, DecoderResult};
use serde::{Deserialize, Serialize};
pub use edgefirst_tensor::{
FitMode, Interpolation, LetterboxPlacement, NormalizationPreset, PadSpace,
};
pub const MAX_SUPPORTED_SCHEMA_VERSION: u32 = 2;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SchemaV2 {
pub schema_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input: Option<InputSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub outputs: Vec<LogicalOutput>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nms: Option<NmsMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nms_multi_label: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decoder_version: Option<DecoderVersion>,
}
impl SchemaV2 {
pub fn new() -> Self {
Self::default()
}
}
impl Default for SchemaV2 {
fn default() -> Self {
Self {
schema_version: 2,
input: None,
outputs: Vec::new(),
nms: None,
nms_multi_label: None,
decoder_version: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InputSpec {
pub shape: Vec<usize>,
#[serde(
default,
deserialize_with = "deserialize_dshape",
skip_serializing_if = "Vec::is_empty"
)]
pub dshape: Vec<(DimName, usize)>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cameraadaptor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preprocessing: Option<Preprocessing>,
}
impl InputSpec {
pub fn channels(&self) -> Option<usize> {
if !self.dshape.is_empty() {
let mut rest = self.dshape.iter().enumerate().filter(|(_, (name, _))| {
!matches!(name, DimName::Batch | DimName::Height | DimName::Width)
});
return match (rest.next(), rest.next()) {
(Some((i, _)), None) => self.shape.get(i).copied(),
_ => None,
};
}
let [_, c1, _, c3] = self.shape.as_slice() else {
return None;
};
let is_channels = |c: usize| (1..=4).contains(&c);
match (is_channels(*c1), is_channels(*c3)) {
(true, false) => Some(*c1),
(false, true) => Some(*c3),
_ => None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Preprocessing {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub normalization: Option<NormalizationConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resize: Option<FitMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interpolation: Option<Interpolation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub letterbox: Option<Letterbox>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum NormalizationConfig {
Preset(NormalizationPreset),
MeanStd {
mean: Vec<f32>,
std: Vec<f32>,
},
}
impl NormalizationConfig {
pub fn mean_std(&self, channels: usize) -> DecoderResult<(Vec<f32>, Vec<f32>)> {
let (mean, std): (&[f32], &[f32]) = match self {
NormalizationConfig::Preset(NormalizationPreset::Imagenet) if channels != 3 => {
return Err(DecoderError::InvalidConfig(format!(
"imagenet normalization needs 3 channels, got {channels}"
)));
}
NormalizationConfig::Preset(p) => {
let table = edgefirst_tensor::Normalization::preset(*p);
let n = if channels == 3 { 3 } else { 1 };
return Ok((
broadcast_channels("mean", &table.mean()[..n], channels)?,
broadcast_channels("std", &table.std()[..n], channels)?,
));
}
NormalizationConfig::MeanStd { mean, std } => (mean, std),
};
let mean = broadcast_channels("mean", mean, channels)?;
let std = broadcast_channels("std", std, channels)?;
if let Some(bad) = mean.iter().find(|m| !m.is_finite()) {
return Err(DecoderError::InvalidConfig(format!(
"normalization mean must be finite, got {bad}"
)));
}
if let Some(bad) = std.iter().find(|s| **s <= 0.0 || !s.is_finite()) {
return Err(DecoderError::InvalidConfig(format!(
"normalization std must be finite and positive, got {bad}"
)));
}
Ok((mean, std))
}
}
fn broadcast_channels(field: &str, values: &[f32], channels: usize) -> DecoderResult<Vec<f32>> {
match values.len() {
1 => Ok(vec![values[0]; channels]),
n if n == channels => Ok(values.to_vec()),
n => Err(DecoderError::InvalidConfig(format!(
"normalization {field} has {n} values; expected 1 or {channels}"
))),
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Letterbox {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placement: Option<LetterboxPlacement>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pad: Option<Vec<f32>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pad_space: Option<PadSpace>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LogicalOutput {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
pub type_: Option<LogicalType>,
pub shape: Vec<usize>,
#[serde(
default,
deserialize_with = "deserialize_dshape",
skip_serializing_if = "Vec::is_empty"
)]
pub dshape: Vec<(DimName, usize)>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decoder: Option<DecoderKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encoding: Option<BoxEncoding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub score_format: Option<ScoreFormat>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub normalized: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub anchors: Option<Vec<[f32; 2]>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stride: Option<Stride>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dtype: Option<DType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quantization: Option<Quantization>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub outputs: Vec<PhysicalOutput>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activation_applied: Option<Activation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activation_required: Option<Activation>,
#[serde(flatten)]
pub semantic: SemanticParams,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SemanticParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upsample: Option<Upsample>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decode_mode: Option<DecodeMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_index: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub no_object_index: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mask_activation: Option<MaskActivation>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Upsample {
pub method: UpsampleMethod,
#[serde(default)]
pub align_corners: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum UpsampleMethod {
Bilinear,
Nearest,
Bicubic,
}
edgefirst_tensor::ef_vocabulary! {
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum DecodeMode {
Accurate = 0, "accurate", ACCURATE,
Fast = 1, "fast", FAST,
}
#[doc(hidden)]
pub mod decode_mode_code;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum MaskActivation {
Sigmoid,
SoftmaxOverQueries,
}
impl LogicalOutput {
pub fn is_split(&self) -> bool {
!self.outputs.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PhysicalOutput {
pub name: String,
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
pub type_: Option<PhysicalType>,
pub shape: Vec<usize>,
#[serde(
default,
deserialize_with = "deserialize_dshape",
skip_serializing_if = "Vec::is_empty"
)]
pub dshape: Vec<(DimName, usize)>,
pub dtype: DType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quantization: Option<Quantization>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stride: Option<Stride>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scale_index: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activation_applied: Option<Activation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activation_required: Option<Activation>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Quantization {
#[serde(deserialize_with = "deserialize_scalar_or_vec_f32")]
pub scale: Vec<f32>,
#[serde(
default,
deserialize_with = "deserialize_opt_scalar_or_vec_i32",
skip_serializing_if = "Option::is_none"
)]
pub zero_point: Option<Vec<i32>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub axis: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dtype: Option<DType>,
}
impl Quantization {
pub fn is_per_tensor(&self) -> bool {
self.scale.len() == 1
}
pub fn is_per_channel(&self) -> bool {
self.scale.len() > 1
}
pub fn is_symmetric(&self) -> bool {
match &self.zero_point {
None => true,
Some(zps) => zps.iter().all(|&z| z == 0),
}
}
pub fn zero_point_at(&self, channel: usize) -> i32 {
match &self.zero_point {
None => 0,
Some(zps) if zps.len() == 1 => zps[0],
Some(zps) => zps.get(channel).copied().unwrap_or(0),
}
}
pub fn scale_at(&self, channel: usize) -> f32 {
if self.scale.len() == 1 {
self.scale[0]
} else {
self.scale.get(channel).copied().unwrap_or(0.0)
}
}
}
impl TryFrom<&Quantization> for edgefirst_tensor::Quantization {
type Error = edgefirst_tensor::Error;
fn try_from(q: &Quantization) -> Result<Self, Self::Error> {
match (q.scale.as_slice(), q.zero_point.as_deref(), q.axis) {
([scale], None, None) => Ok(Self::per_tensor_symmetric(*scale)),
([scale], Some([zp]), None) => Ok(Self::per_tensor(*scale, *zp)),
([scale], Some([zp]), Some(_)) => Ok(Self::per_tensor(*scale, *zp)),
([scale], None, Some(_)) => Ok(Self::per_tensor_symmetric(*scale)),
(scales, None, Some(axis)) if scales.len() > 1 => {
Self::per_channel_symmetric(scales.to_vec(), axis)
}
(scales, Some(zps), Some(axis)) if scales.len() > 1 => {
Self::per_channel(scales.to_vec(), zps.to_vec(), axis)
}
(scales, _, None) if scales.len() > 1 => {
Err(edgefirst_tensor::Error::QuantizationInvalid {
field: "axis",
expected: "Some(axis) for per-channel".into(),
got: "None".into(),
})
}
_ => Err(edgefirst_tensor::Error::QuantizationInvalid {
field: "scale",
expected: "non-empty".into(),
got: format!("len={}", q.scale.len()),
}),
}
}
}
fn deserialize_scalar_or_vec_f32<'de, D>(de: D) -> Result<Vec<f32>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(f32),
Many(Vec<f32>),
}
match OneOrMany::deserialize(de)? {
OneOrMany::One(v) => Ok(vec![v]),
OneOrMany::Many(vs) => Ok(vs),
}
}
fn deserialize_opt_scalar_or_vec_i32<'de, D>(de: D) -> Result<Option<Vec<i32>>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(i32),
Many(Vec<i32>),
}
match Option::<OneOrMany>::deserialize(de)? {
None => Ok(None),
Some(OneOrMany::One(v)) => Ok(Some(vec![v])),
Some(OneOrMany::Many(vs)) => Ok(Some(vs)),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Stride {
Square(u32),
Rect([u32; 2]),
}
impl Stride {
pub fn x(self) -> u32 {
match self {
Stride::Square(s) => s,
Stride::Rect([sx, _]) => sx,
}
}
pub fn y(self) -> u32 {
match self {
Stride::Square(s) => s,
Stride::Rect([_, sy]) => sy,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LogicalType {
Boxes,
Scores,
Objectness,
Classes,
MaskCoefs,
Protos,
Landmarks,
Detections,
Segmentation,
Masks,
Detection,
ClassLogits,
MaskLogits,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PhysicalType {
Boxes,
Scores,
Objectness,
Classes,
MaskCoefs,
Protos,
Landmarks,
Detections,
Segmentation,
Masks,
Detection,
ClassLogits,
MaskLogits,
BoxesXy,
BoxesWh,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BoxEncoding {
Dfl,
#[serde(alias = "ltrb")]
Direct,
Anchor,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScoreFormat {
PerClass,
ObjXClass,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Activation {
Sigmoid,
Softmax,
Tanh,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[repr(u32)]
pub enum DecoderKind {
#[serde(rename = "modelpack")]
ModelPack = configs::decoder_type_code::MODELPACK,
#[serde(rename = "ultralytics")]
Ultralytics = configs::decoder_type_code::ULTRALYTICS,
#[serde(rename = "semantic")]
Semantic = configs::decoder_type_code::SEMANTIC,
}
impl DecoderKind {
pub const fn code(self) -> u32 {
self as u32
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[repr(u32)]
pub enum DecoderVersion {
Yolov5 = configs::decoder_version_code::YOLOV5,
Yolov8 = configs::decoder_version_code::YOLOV8,
Yolo11 = configs::decoder_version_code::YOLO11,
Yolo26 = configs::decoder_version_code::YOLO26,
}
impl DecoderVersion {
pub const fn code(self) -> u32 {
self as u32
}
pub fn is_end_to_end(self) -> bool {
matches!(self, DecoderVersion::Yolo26)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[repr(u32)]
pub enum NmsMode {
ClassAgnostic = configs::nms_code::CLASS_AGNOSTIC,
ClassAware = configs::nms_code::CLASS_AWARE,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u32)]
pub enum DType {
Int8 = edgefirst_tensor::protocol::dtype::I8,
Uint8 = edgefirst_tensor::protocol::dtype::U8,
Int16 = edgefirst_tensor::protocol::dtype::I16,
Uint16 = edgefirst_tensor::protocol::dtype::U16,
Int32 = edgefirst_tensor::protocol::dtype::I32,
Uint32 = edgefirst_tensor::protocol::dtype::U32,
Float16 = edgefirst_tensor::protocol::dtype::F16,
Float32 = edgefirst_tensor::protocol::dtype::F32,
Int64 = edgefirst_tensor::protocol::dtype::I64,
}
const DTYPE_LEGACY_NAMES: [(&str, DType); 9] = [
("int8", DType::Int8),
("uint8", DType::Uint8),
("int16", DType::Int16),
("uint16", DType::Uint16),
("int32", DType::Int32),
("uint32", DType::Uint32),
("float16", DType::Float16),
("float32", DType::Float32),
("int64", DType::Int64),
];
impl DType {
pub fn all() -> &'static [DType] {
&[
DType::Int8,
DType::Uint8,
DType::Int16,
DType::Uint16,
DType::Int32,
DType::Uint32,
DType::Float16,
DType::Float32,
DType::Int64,
]
}
pub const fn code(self) -> u32 {
self as u32
}
pub fn to_tensor_dtype(self) -> edgefirst_tensor::DType {
match self {
DType::Int8 => edgefirst_tensor::DType::I8,
DType::Uint8 => edgefirst_tensor::DType::U8,
DType::Int16 => edgefirst_tensor::DType::I16,
DType::Uint16 => edgefirst_tensor::DType::U16,
DType::Int32 => edgefirst_tensor::DType::I32,
DType::Uint32 => edgefirst_tensor::DType::U32,
DType::Float16 => edgefirst_tensor::DType::F16,
DType::Float32 => edgefirst_tensor::DType::F32,
DType::Int64 => edgefirst_tensor::DType::I64,
}
}
pub fn from_tensor_dtype(dtype: edgefirst_tensor::DType) -> Option<Self> {
Self::all()
.iter()
.copied()
.find(|d| d.code() == dtype.code())
}
pub fn from_code(code: u32) -> Option<Self> {
edgefirst_tensor::DType::from_code(code).and_then(Self::from_tensor_dtype)
}
pub fn as_str(self) -> &'static str {
self.to_tensor_dtype().as_str()
}
pub fn from_str_code(s: &str) -> Option<Self> {
edgefirst_tensor::DType::from_str_code(s)
.and_then(Self::from_tensor_dtype)
.or_else(|| {
DTYPE_LEGACY_NAMES
.iter()
.find(|(name, _)| *name == s)
.map(|(_, d)| *d)
})
}
pub fn accepted_names() -> impl Iterator<Item = &'static str> {
Self::all()
.iter()
.map(|d| d.as_str())
.chain(DTYPE_LEGACY_NAMES.iter().map(|(name, _)| *name))
}
}
impl Serialize for DType {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for DType {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = std::borrow::Cow::<'de, str>::deserialize(deserializer)?;
DType::from_str_code(&s).ok_or_else(|| {
let expected: Vec<&str> = DType::accepted_names().collect();
serde::de::Error::custom(format!(
"unknown dtype `{s}`, expected one of: {}",
expected.join(", ")
))
})
}
}
impl DType {
pub fn size_bytes(self) -> usize {
match self {
DType::Int8 | DType::Uint8 => 1,
DType::Int16 | DType::Uint16 | DType::Float16 => 2,
DType::Int32 | DType::Uint32 | DType::Float32 => 4,
DType::Int64 => 8,
}
}
pub fn is_integer(self) -> bool {
matches!(
self,
DType::Int8
| DType::Uint8
| DType::Int16
| DType::Uint16
| DType::Int32
| DType::Uint32
| DType::Int64
)
}
pub fn is_float(self) -> bool {
matches!(self, DType::Float16 | DType::Float32)
}
}
impl SchemaV2 {
pub fn parse_json(s: &str) -> DecoderResult<Self> {
let value: serde_json::Value = serde_json::from_str(s)?;
Self::from_json_value(value)
}
pub fn parse_yaml(s: &str) -> DecoderResult<Self> {
let value: serde_yaml::Value = serde_yaml::from_str(s)?;
let json = serde_json::to_value(value)
.map_err(|e| DecoderError::InvalidConfig(format!("yaml→json bridge failed: {e}")))?;
Self::from_json_value(json)
}
pub fn parse_file(path: impl AsRef<std::path::Path>) -> DecoderResult<Self> {
let path = path.as_ref();
let content = std::fs::read_to_string(path)
.map_err(|e| DecoderError::InvalidConfig(format!("read {}: {e}", path.display())))?;
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
match ext.as_deref() {
Some("json") => Self::parse_json(&content),
Some("yaml") | Some("yml") => Self::parse_yaml(&content),
_ => Self::parse_json(&content).or_else(|_| Self::parse_yaml(&content)),
}
}
pub fn from_json_value(value: serde_json::Value) -> DecoderResult<Self> {
let version = value
.get("schema_version")
.and_then(|v| v.as_u64())
.map(|v| v as u32)
.unwrap_or(1);
if version > MAX_SUPPORTED_SCHEMA_VERSION {
return Err(DecoderError::NotSupported(format!(
"schema_version {version} is not supported by this HAL \
(maximum supported version is {MAX_SUPPORTED_SCHEMA_VERSION}); \
upgrade the HAL or downgrade the metadata"
)));
}
if version >= 2 {
serde_json::from_value(value).map_err(DecoderError::Json)
} else {
let v1: ConfigOutputs = serde_json::from_value(value).map_err(DecoderError::Json)?;
Self::from_v1(&v1)
}
}
pub fn from_v1(v1: &ConfigOutputs) -> DecoderResult<Self> {
let outputs = v1
.outputs
.iter()
.map(logical_from_v1)
.collect::<DecoderResult<Vec<_>>>()?;
Ok(SchemaV2 {
schema_version: 2,
input: None,
outputs,
nms: v1.nms.as_ref().and_then(NmsMode::from_v1),
nms_multi_label: v1.nms_multi_label,
decoder_version: v1.decoder_version.as_ref().map(DecoderVersion::from_v1),
})
}
}
impl SchemaV2 {
pub fn to_legacy_config_outputs(&self) -> DecoderResult<ConfigOutputs> {
let mut outputs = Vec::with_capacity(self.outputs.len());
for logical in &self.outputs {
if logical.type_.is_none() {
continue;
}
if logical.type_ == Some(LogicalType::Boxes)
&& logical.encoding == Some(BoxEncoding::Dfl)
&& logical.outputs.is_empty()
{
return Err(DecoderError::NotSupported(format!(
"`boxes` output `{}` has `encoding: dfl` on a flat \
logical (no per-scale children); the HAL's DFL \
decode kernel only runs inside the per-scale merge \
path. Split the boxes output into per-FPN-level \
children (Hailo convention) or pre-decode to 4 \
channels in the model graph (TFLite convention).",
logical.name.as_deref().unwrap_or("<anonymous>"),
)));
}
if let Some(q) = &logical.quantization {
if q.is_per_channel() {
return Err(DecoderError::NotSupported(format!(
"logical `{}` uses per-channel quantization \
(axis {:?}, {} scales); the v1 decoder only \
supports per-tensor quantization",
logical.name.as_deref().unwrap_or("<anonymous>"),
q.axis,
q.scale.len(),
)));
}
}
outputs.push(logical_to_legacy_config_output(logical)?);
}
Ok(ConfigOutputs {
outputs,
nms: self.nms.map(NmsMode::to_v1),
nms_multi_label: self.nms_multi_label,
decoder_version: self.decoder_version.map(|v| v.to_v1()),
})
}
pub fn validate(&self) -> DecoderResult<()> {
if self.schema_version == 0 || self.schema_version > MAX_SUPPORTED_SCHEMA_VERSION {
return Err(DecoderError::InvalidConfig(format!(
"schema_version {} outside supported range [1, {MAX_SUPPORTED_SCHEMA_VERSION}]",
self.schema_version
)));
}
if let Some(input) = &self.input {
if let Some(pre) = &input.preprocessing {
validate_preprocessing(input, pre)?;
}
}
for logical in &self.outputs {
validate_logical(logical)?;
}
Ok(())
}
}
fn validate_preprocessing(input: &InputSpec, pre: &Preprocessing) -> DecoderResult<()> {
let channels = input.channels();
let mut vectors: Vec<(&str, &[f32])> = Vec::new();
if let Some(NormalizationConfig::MeanStd { mean, std }) = &pre.normalization {
vectors.push(("normalization mean", mean));
vectors.push(("normalization std", std));
}
if let Some(pad) = pre.letterbox.as_ref().and_then(|l| l.pad.as_ref()) {
vectors.push(("letterbox pad", pad));
}
let mut expected = channels;
for (field, values) in &vectors {
if values.is_empty() || values.iter().any(|v| !v.is_finite()) {
return Err(DecoderError::InvalidConfig(format!(
"{field} {values:?} must hold one or more finite values"
)));
}
if values.len() == 1 {
continue;
}
match expected {
Some(n) if n != values.len() => {
return Err(DecoderError::InvalidConfig(format!(
"{field} has {} values; expected 1 or {n} (one per input channel)",
values.len()
)));
}
Some(_) => {}
None => expected = Some(values.len()),
}
}
if let Some(norm) = &pre.normalization {
let n = match (norm, expected) {
(_, Some(n)) => n,
(NormalizationConfig::Preset(NormalizationPreset::Imagenet), None) => 3,
(_, None) => 1,
};
norm.mean_std(n)?;
}
Ok(())
}
fn validate_logical(logical: &LogicalOutput) -> DecoderResult<()> {
if logical.outputs.is_empty() {
return Ok(());
}
for child in &logical.outputs {
if child.name.is_empty() {
return Err(DecoderError::InvalidConfig(format!(
"physical child of logical `{}` is missing `name`; name is \
required for tensor binding",
logical.name.as_deref().unwrap_or("<anonymous>")
)));
}
}
for (i, a) in logical.outputs.iter().enumerate() {
for b in &logical.outputs[i + 1..] {
let (Some(ta), Some(tb)) = (a.type_, b.type_) else {
continue;
};
if a.shape == b.shape && ta == tb {
return Err(DecoderError::InvalidConfig(format!(
"physical children `{}` and `{}` share shape {:?} and \
type; tensor binding cannot be resolved",
a.name, b.name, a.shape
)));
}
}
}
let strided: Vec<_> = logical.outputs.iter().map(|c| c.stride.is_some()).collect();
let all_strided = strided.iter().all(|&b| b);
let none_strided = strided.iter().all(|&b| !b);
if !(all_strided || none_strided) {
return Err(DecoderError::InvalidConfig(format!(
"logical `{}` mixes per-scale children (with stride) and \
channel sub-split children (without stride); decomposition \
must be uniform",
logical.name.as_deref().unwrap_or("<anonymous>")
)));
}
if logical.type_ == Some(LogicalType::Boxes) && logical.encoding == Some(BoxEncoding::Dfl) {
for child in &logical.outputs {
if let Some(feat) = last_feature_axis(child) {
if feat % 4 != 0 {
return Err(DecoderError::InvalidConfig(format!(
"DFL boxes child `{}` feature axis {feat} is not \
divisible by 4 (reg_max×4)",
child.name
)));
}
}
}
}
Ok(())
}
pub(crate) fn last_feature_axis(child: &PhysicalOutput) -> Option<usize> {
for (name, size) in &child.dshape {
if matches!(
name,
DimName::NumFeatures
| DimName::NumClasses
| DimName::NumProtos
| DimName::BoxCoords
| DimName::NumAnchorsXFeatures
) {
return Some(*size);
}
}
child.shape.last().copied()
}
fn quantization_from_v1(q: Option<QuantTuple>) -> Option<Quantization> {
q.map(|QuantTuple(scale, zp)| Quantization {
scale: vec![scale],
zero_point: Some(vec![zp]),
axis: None,
dtype: None,
})
}
fn logical_from_v1(v1: &ConfigOutput) -> DecoderResult<LogicalOutput> {
match v1 {
ConfigOutput::Detection(d) => {
let encoding = match (d.decoder, d.anchors.is_some()) {
(configs::DecoderType::ModelPack, true) => Some(BoxEncoding::Anchor),
(configs::DecoderType::Ultralytics, _) => Some(BoxEncoding::Direct),
(configs::DecoderType::ModelPack, false) => None,
(configs::DecoderType::Semantic, _) => None,
};
Ok(LogicalOutput {
name: None,
type_: Some(LogicalType::Detection),
shape: d.shape.clone(),
dshape: d.dshape.clone(),
decoder: Some(DecoderKind::from_v1(d.decoder)),
encoding,
score_format: None,
normalized: d.normalized,
anchors: d.anchors.clone(),
stride: None,
dtype: None,
quantization: quantization_from_v1(d.quantization),
outputs: Vec::new(),
activation_applied: None,
activation_required: None,
semantic: Default::default(),
})
}
ConfigOutput::Boxes(b) => Ok(LogicalOutput {
name: None,
type_: Some(LogicalType::Boxes),
shape: b.shape.clone(),
dshape: b.dshape.clone(),
decoder: Some(DecoderKind::from_v1(b.decoder)),
encoding: Some(BoxEncoding::Direct),
score_format: None,
normalized: b.normalized,
anchors: None,
stride: None,
dtype: None,
quantization: quantization_from_v1(b.quantization),
outputs: Vec::new(),
activation_applied: None,
activation_required: None,
semantic: Default::default(),
}),
ConfigOutput::Scores(s) => Ok(LogicalOutput {
name: None,
type_: Some(LogicalType::Scores),
shape: s.shape.clone(),
dshape: s.dshape.clone(),
decoder: Some(DecoderKind::from_v1(s.decoder)),
encoding: None,
score_format: Some(ScoreFormat::PerClass),
normalized: None,
anchors: None,
stride: None,
dtype: None,
quantization: quantization_from_v1(s.quantization),
outputs: Vec::new(),
activation_applied: None,
activation_required: None,
semantic: Default::default(),
}),
ConfigOutput::Protos(p) => Ok(LogicalOutput {
name: None,
type_: Some(LogicalType::Protos),
shape: p.shape.clone(),
dshape: p.dshape.clone(),
decoder: Some(DecoderKind::from_v1(p.decoder)),
encoding: None,
score_format: None,
normalized: None,
anchors: None,
stride: None,
dtype: None,
quantization: quantization_from_v1(p.quantization),
outputs: Vec::new(),
activation_applied: None,
activation_required: None,
semantic: Default::default(),
}),
ConfigOutput::MaskCoefficients(m) => Ok(LogicalOutput {
name: None,
type_: Some(LogicalType::MaskCoefs),
shape: m.shape.clone(),
dshape: m.dshape.clone(),
decoder: Some(DecoderKind::from_v1(m.decoder)),
encoding: None,
score_format: None,
normalized: None,
anchors: None,
stride: None,
dtype: None,
quantization: quantization_from_v1(m.quantization),
outputs: Vec::new(),
activation_applied: None,
activation_required: None,
semantic: Default::default(),
}),
ConfigOutput::Segmentation(seg) => Ok(LogicalOutput {
name: None,
type_: Some(LogicalType::Segmentation),
shape: seg.shape.clone(),
dshape: seg.dshape.clone(),
decoder: Some(DecoderKind::from_v1(seg.decoder)),
encoding: None,
score_format: None,
normalized: None,
anchors: None,
stride: None,
dtype: None,
quantization: quantization_from_v1(seg.quantization),
outputs: Vec::new(),
activation_applied: None,
activation_required: None,
semantic: Default::default(),
}),
ConfigOutput::Mask(m) => Ok(LogicalOutput {
name: None,
type_: Some(LogicalType::Masks),
shape: m.shape.clone(),
dshape: m.dshape.clone(),
decoder: Some(DecoderKind::from_v1(m.decoder)),
encoding: None,
score_format: None,
normalized: None,
anchors: None,
stride: None,
dtype: None,
quantization: quantization_from_v1(m.quantization),
outputs: Vec::new(),
activation_applied: None,
activation_required: None,
semantic: Default::default(),
}),
ConfigOutput::Classes(c) => Ok(LogicalOutput {
name: None,
type_: Some(LogicalType::Classes),
shape: c.shape.clone(),
dshape: c.dshape.clone(),
decoder: Some(DecoderKind::from_v1(c.decoder)),
encoding: None,
score_format: None,
normalized: None,
anchors: None,
stride: None,
dtype: None,
quantization: quantization_from_v1(c.quantization),
outputs: Vec::new(),
activation_applied: None,
activation_required: None,
semantic: Default::default(),
}),
}
}
impl DecoderKind {
pub fn from_v1(v: configs::DecoderType) -> Self {
match v {
configs::DecoderType::ModelPack => DecoderKind::ModelPack,
configs::DecoderType::Ultralytics => DecoderKind::Ultralytics,
configs::DecoderType::Semantic => DecoderKind::Semantic,
}
}
pub fn to_v1(self) -> configs::DecoderType {
match self {
DecoderKind::ModelPack => configs::DecoderType::ModelPack,
DecoderKind::Ultralytics => configs::DecoderType::Ultralytics,
DecoderKind::Semantic => configs::DecoderType::Semantic,
}
}
}
impl DecoderVersion {
pub fn from_v1(v: &configs::DecoderVersion) -> Self {
match v {
configs::DecoderVersion::Yolov5 => DecoderVersion::Yolov5,
configs::DecoderVersion::Yolov8 => DecoderVersion::Yolov8,
configs::DecoderVersion::Yolo11 => DecoderVersion::Yolo11,
configs::DecoderVersion::Yolo26 => DecoderVersion::Yolo26,
}
}
pub fn to_v1(self) -> configs::DecoderVersion {
match self {
DecoderVersion::Yolov5 => configs::DecoderVersion::Yolov5,
DecoderVersion::Yolov8 => configs::DecoderVersion::Yolov8,
DecoderVersion::Yolo11 => configs::DecoderVersion::Yolo11,
DecoderVersion::Yolo26 => configs::DecoderVersion::Yolo26,
}
}
}
impl NmsMode {
pub const fn code(self) -> u32 {
self as u32
}
pub fn from_v1(v: &configs::Nms) -> Option<Self> {
match v {
configs::Nms::Auto => None,
configs::Nms::ClassAgnostic => Some(NmsMode::ClassAgnostic),
configs::Nms::ClassAware => Some(NmsMode::ClassAware),
}
}
pub fn to_v1(self) -> configs::Nms {
match self {
NmsMode::ClassAgnostic => configs::Nms::ClassAgnostic,
NmsMode::ClassAware => configs::Nms::ClassAware,
}
}
}
fn quantization_to_legacy(q: &Quantization) -> DecoderResult<QuantTuple> {
if q.is_per_channel() {
return Err(DecoderError::NotSupported(
"per-channel quantization cannot be expressed as a v1 QuantTuple".into(),
));
}
let scale = *q.scale.first().unwrap_or(&0.0);
let zp = q.zero_point_at(0);
Ok(QuantTuple(scale, zp))
}
pub(crate) fn squeeze_padding_dims(
shape: Vec<usize>,
dshape: Vec<(DimName, usize)>,
) -> (Vec<usize>, Vec<(DimName, usize)>) {
if dshape.is_empty() {
return (shape, dshape);
}
let keep: Vec<bool> = dshape
.iter()
.map(|(n, _)| !matches!(n, DimName::Padding))
.collect();
let shape = shape
.into_iter()
.zip(keep.iter())
.filter_map(|(s, &k)| k.then_some(s))
.collect();
let dshape = dshape
.into_iter()
.zip(keep.iter())
.filter_map(|(d, &k)| k.then_some(d))
.collect();
(shape, dshape)
}
pub(crate) fn padding_axes(dshape: &[(DimName, usize)]) -> Vec<usize> {
let mut v: Vec<usize> = dshape
.iter()
.enumerate()
.filter_map(|(i, (n, _))| matches!(n, DimName::Padding).then_some(i))
.collect();
v.sort_by(|a, b| b.cmp(a));
v
}
fn logical_to_legacy_config_output(logical: &LogicalOutput) -> DecoderResult<ConfigOutput> {
let decoder = match logical.decoder {
None => configs::DecoderType::Ultralytics,
Some(DecoderKind::Semantic) => {
return Err(DecoderError::NotSupported(format!(
"logical `{}` uses decoder `Semantic`, which this HAL \
version does not decode",
logical.name.as_deref().unwrap_or("<anonymous>")
)))
}
Some(kind) => kind.to_v1(),
};
let quantization = logical
.quantization
.as_ref()
.map(quantization_to_legacy)
.transpose()?;
let (shape, dshape) = match logical.decoder {
Some(DecoderKind::ModelPack) => (logical.shape.clone(), logical.dshape.clone()),
_ => squeeze_padding_dims(logical.shape.clone(), logical.dshape.clone()),
};
let ty = logical.type_.ok_or_else(|| {
DecoderError::InvalidConfig(format!(
"logical output `{}` has no type; typeless outputs should be \
filtered before legacy conversion",
logical.name.as_deref().unwrap_or("<anonymous>")
))
})?;
let ctx = LegacyConvertCtx {
decoder,
quantization,
shape,
dshape,
normalized: logical.normalized,
anchors: logical.anchors.clone(),
};
match ty {
LogicalType::Boxes => Ok(boxes_to_legacy(&ctx)),
LogicalType::Scores => Ok(scores_to_legacy(&ctx)),
LogicalType::Protos => Ok(protos_to_legacy(&ctx)),
LogicalType::MaskCoefs => Ok(mask_coefs_to_legacy(&ctx)),
LogicalType::Segmentation => Ok(segmentation_to_legacy(&ctx)),
LogicalType::Masks => Ok(masks_to_legacy(&ctx)),
LogicalType::Classes => Ok(classes_to_legacy(&ctx)),
LogicalType::Detection | LogicalType::Detections => Ok(detection_to_legacy(&ctx)),
LogicalType::Objectness
| LogicalType::Landmarks
| LogicalType::ClassLogits
| LogicalType::MaskLogits => Err(DecoderError::NotSupported(format!(
"logical type {:?} has no legacy v1 equivalent; use the \
native v2 decoder path",
ty
))),
}
}
struct LegacyConvertCtx {
decoder: configs::DecoderType,
quantization: Option<QuantTuple>,
shape: Vec<usize>,
dshape: Vec<(DimName, usize)>,
normalized: Option<bool>,
anchors: Option<Vec<[f32; 2]>>,
}
fn boxes_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
ConfigOutput::Boxes(configs::Boxes {
decoder: ctx.decoder,
quantization: ctx.quantization,
shape: ctx.shape.clone(),
dshape: ctx.dshape.clone(),
normalized: ctx.normalized,
})
}
fn scores_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
ConfigOutput::Scores(configs::Scores {
decoder: ctx.decoder,
quantization: ctx.quantization,
shape: ctx.shape.clone(),
dshape: ctx.dshape.clone(),
})
}
fn protos_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
ConfigOutput::Protos(configs::Protos {
decoder: ctx.decoder,
quantization: ctx.quantization,
shape: ctx.shape.clone(),
dshape: ctx.dshape.clone(),
})
}
fn mask_coefs_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
ConfigOutput::MaskCoefficients(configs::MaskCoefficients {
decoder: ctx.decoder,
quantization: ctx.quantization,
shape: ctx.shape.clone(),
dshape: ctx.dshape.clone(),
})
}
fn segmentation_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
ConfigOutput::Segmentation(configs::Segmentation {
decoder: ctx.decoder,
quantization: ctx.quantization,
shape: ctx.shape.clone(),
dshape: ctx.dshape.clone(),
})
}
fn masks_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
ConfigOutput::Mask(configs::Mask {
decoder: ctx.decoder,
quantization: ctx.quantization,
shape: ctx.shape.clone(),
dshape: ctx.dshape.clone(),
})
}
fn classes_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
ConfigOutput::Classes(configs::Classes {
decoder: ctx.decoder,
quantization: ctx.quantization,
shape: ctx.shape.clone(),
dshape: ctx.dshape.clone(),
})
}
fn detection_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
ConfigOutput::Detection(configs::Detection {
anchors: ctx.anchors.clone(),
decoder: ctx.decoder,
quantization: ctx.quantization,
shape: ctx.shape.clone(),
dshape: ctx.dshape.clone(),
normalized: ctx.normalized,
})
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
#[test]
fn nms_multi_label_true_round_trips_through_json() {
let json = r#"{
"schema_version": 2,
"nms": "class_aware",
"nms_multi_label": true,
"outputs": [{
"name": "output0", "type": "detection",
"shape": [1, 84, 8400],
"dshape": [{"batch": 1}, {"num_features": 84}, {"num_boxes": 8400}],
"dtype": "float32", "decoder": "ultralytics", "encoding": "direct",
"normalized": true
}]
}"#;
let schema = SchemaV2::parse_json(json).unwrap();
assert_eq!(schema.nms_multi_label, Some(true));
let text = serde_json::to_string(&schema).unwrap();
let value: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(value["nms_multi_label"], serde_json::json!(true));
let again = SchemaV2::parse_json(&text).unwrap();
assert_eq!(again, schema);
let legacy = schema.to_legacy_config_outputs().unwrap();
assert_eq!(legacy.nms_multi_label, Some(true));
let back = SchemaV2::from_v1(&legacy).unwrap();
assert_eq!(back.nms_multi_label, Some(true));
}
#[test]
fn schema_default_is_v2() {
let s = SchemaV2::default();
assert_eq!(s.schema_version, 2);
assert!(s.outputs.is_empty());
}
#[test]
fn fixtures_round_trip_through_serde() {
let yolov8 =
edgefirst_bench::testdata::read_to_string("per_scale/synthetic_yolov8n_schema.json");
let _: super::SchemaV2 = serde_json::from_str(&yolov8).expect("yolov8n fixture must parse");
let yolo26 =
edgefirst_bench::testdata::read_to_string("per_scale/synthetic_yolo26n_schema.json");
let _: super::SchemaV2 = serde_json::from_str(&yolo26).expect("yolo26n fixture must parse");
let flat =
edgefirst_bench::testdata::read_to_string("per_scale/synthetic_flat_schema.json");
let _: super::SchemaV2 = serde_json::from_str(&flat).expect("flat fixture must parse");
}
#[test]
fn box_encoding_accepts_ltrb_alias_for_direct() {
let dfl: BoxEncoding = serde_json::from_str("\"dfl\"").unwrap();
assert_eq!(dfl, BoxEncoding::Dfl);
let direct: BoxEncoding = serde_json::from_str("\"direct\"").unwrap();
assert_eq!(direct, BoxEncoding::Direct);
let ltrb: BoxEncoding = serde_json::from_str("\"ltrb\"").unwrap();
assert_eq!(ltrb, BoxEncoding::Direct);
}
#[test]
fn dtype_roundtrip() {
for &d in DType::all() {
let j = serde_json::to_string(&d).unwrap();
let back: DType = serde_json::from_str(&j).unwrap();
assert_eq!(back, d);
}
}
#[test]
fn dtype_is_numbered_and_named_by_the_tensor_dtype() {
for &d in DType::all() {
let t = d.to_tensor_dtype();
assert_eq!(d.code(), t.code(), "{d:?}");
assert_eq!(d.as_str(), t.as_str(), "{d:?}");
assert_eq!(DType::from_code(t.code()), Some(d));
assert_eq!(DType::from_tensor_dtype(t), Some(d));
assert_eq!(
serde_json::to_string(&d).unwrap(),
format!("\"{}\"", t.as_str())
);
}
for t in [edgefirst_tensor::DType::U64, edgefirst_tensor::DType::F64] {
assert_eq!(DType::from_tensor_dtype(t), None);
assert_eq!(DType::from_code(t.code()), None);
assert_eq!(DType::from_str_code(t.as_str()), None);
}
assert_eq!(DType::from_code(u32::MAX), None);
}
#[test]
fn dtype_accepts_every_legacy_name() {
let legacy = [
("int8", DType::Int8),
("uint8", DType::Uint8),
("int16", DType::Int16),
("uint16", DType::Uint16),
("int32", DType::Int32),
("uint32", DType::Uint32),
("float16", DType::Float16),
("float32", DType::Float32),
("int64", DType::Int64),
];
assert_eq!(legacy.len(), DType::all().len());
for (name, d) in legacy {
assert_eq!(DType::from_str_code(name), Some(d), "{name}");
let back: DType = serde_json::from_str(&format!("\"{name}\"")).unwrap();
assert_eq!(back, d, "{name}");
assert_ne!(d.as_str(), name);
}
assert_eq!(DType::accepted_names().count(), 2 * DType::all().len());
assert!(serde_json::from_str::<DType>("\"float64\"").is_err());
assert!(serde_json::from_str::<DType>("\"Int8\"").is_err());
}
#[test]
fn schema_enums_take_their_codes_from_the_config_vocabularies() {
for v in [
DecoderVersion::Yolov5,
DecoderVersion::Yolov8,
DecoderVersion::Yolo11,
DecoderVersion::Yolo26,
] {
assert_eq!(v.code(), v.to_v1().code(), "{v:?}");
}
for m in [NmsMode::ClassAgnostic, NmsMode::ClassAware] {
assert_eq!(m.code(), m.to_v1().code(), "{m:?}");
}
for &v in configs::DecoderType::all() {
let k = DecoderKind::from_v1(v);
assert_eq!(k.code(), v.code(), "{k:?}");
assert_eq!(k.to_v1(), v, "{k:?}");
}
}
#[test]
fn dtype_widths() {
assert_eq!(DType::Int8.size_bytes(), 1);
assert_eq!(DType::Float16.size_bytes(), 2);
assert_eq!(DType::Float32.size_bytes(), 4);
}
#[test]
fn stride_accepts_scalar_or_pair() {
let a: Stride = serde_json::from_str("8").unwrap();
let b: Stride = serde_json::from_str("[8, 16]").unwrap();
assert_eq!(a, Stride::Square(8));
assert_eq!(b, Stride::Rect([8, 16]));
assert_eq!(a.x(), 8);
assert_eq!(a.y(), 8);
assert_eq!(b.x(), 8);
assert_eq!(b.y(), 16);
}
#[test]
fn quantization_scalar_scale() {
let j = r#"{"scale": 0.00392, "zero_point": 0, "dtype": "int8"}"#;
let q: Quantization = serde_json::from_str(j).unwrap();
assert!(q.is_per_tensor());
assert!(q.is_symmetric());
assert_eq!(q.scale_at(0), 0.00392);
assert_eq!(q.scale_at(5), 0.00392);
assert_eq!(q.zero_point_at(0), 0);
}
#[test]
fn quantization_per_channel() {
let j = r#"{"scale": [0.054, 0.089, 0.195], "axis": 0, "dtype": "int8"}"#;
let q: Quantization = serde_json::from_str(j).unwrap();
assert!(q.is_per_channel());
assert!(q.is_symmetric());
assert_eq!(q.axis, Some(0));
assert_eq!(q.scale_at(0), 0.054);
assert_eq!(q.scale_at(2), 0.195);
}
#[test]
fn quantization_asymmetric_per_tensor() {
let j = r#"{"scale": 0.176, "zero_point": 198, "dtype": "uint8"}"#;
let q: Quantization = serde_json::from_str(j).unwrap();
assert!(!q.is_symmetric());
assert_eq!(q.zero_point_at(0), 198);
assert_eq!(q.zero_point_at(10), 198);
}
#[test]
fn quantization_symmetric_default_zero_point() {
let j = r#"{"scale": 0.00392, "dtype": "int8"}"#;
let q: Quantization = serde_json::from_str(j).unwrap();
assert!(q.is_symmetric());
assert_eq!(q.zero_point_at(0), 0);
}
#[test]
fn quantization_to_tensor_per_tensor_asymmetric() {
let q = Quantization {
scale: vec![0.1],
zero_point: Some(vec![-5]),
axis: None,
dtype: Some(DType::Int8),
};
let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
assert!(t.is_per_tensor());
assert!(!t.is_symmetric());
assert_eq!(t.scale(), &[0.1]);
assert_eq!(t.zero_point(), Some(&[-5][..]));
}
#[test]
fn quantization_to_tensor_per_tensor_symmetric() {
let q = Quantization {
scale: vec![0.05],
zero_point: None,
axis: None,
dtype: Some(DType::Int8),
};
let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
assert!(t.is_per_tensor());
assert!(t.is_symmetric());
}
#[test]
fn quantization_to_tensor_per_channel_asymmetric() {
let q = Quantization {
scale: vec![0.1, 0.2, 0.3],
zero_point: Some(vec![-1, 0, 1]),
axis: Some(2),
dtype: Some(DType::Int8),
};
let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
assert!(t.is_per_channel());
assert_eq!(t.axis(), Some(2));
assert_eq!(t.scale().len(), 3);
assert_eq!(t.zero_point().map(|z| z.len()), Some(3));
}
#[test]
fn quantization_to_tensor_per_channel_symmetric() {
let q = Quantization {
scale: vec![0.054, 0.089, 0.195],
zero_point: None,
axis: Some(0),
dtype: Some(DType::Int8),
};
let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
assert!(t.is_per_channel());
assert!(t.is_symmetric());
assert_eq!(t.axis(), Some(0));
}
#[test]
fn quantization_to_tensor_per_channel_missing_axis_errors() {
let q = Quantization {
scale: vec![0.1, 0.2, 0.3],
zero_point: None,
axis: None,
dtype: None,
};
let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
assert!(matches!(
err,
edgefirst_tensor::Error::QuantizationInvalid { field: "axis", .. }
));
}
#[test]
fn quantization_to_tensor_per_tensor_ignores_a_redundant_axis() {
let q = Quantization {
scale: vec![0.5],
zero_point: Some(vec![3]),
axis: Some(0),
dtype: Some(DType::Int8),
};
let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
assert!(t.is_per_tensor());
assert_eq!(t.scale(), &[0.5][..]);
assert_eq!(t.zero_point(), Some(&[3][..]));
}
#[test]
fn quantization_to_tensor_symmetric_ignores_a_redundant_axis() {
let q = Quantization {
scale: vec![0.5],
zero_point: None,
axis: Some(0),
dtype: Some(DType::Int8),
};
let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
assert!(t.is_per_tensor());
assert!(t.is_symmetric());
assert_eq!(t.scale(), &[0.5][..]);
}
#[test]
fn quantization_to_tensor_empty_scale_errors_on_scale() {
for (zero_point, axis) in [
(None, None),
(None, Some(0)),
(Some(vec![]), Some(0)),
(Some(vec![]), None),
] {
let q = Quantization {
scale: vec![],
zero_point,
axis,
dtype: Some(DType::Int8),
};
let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
assert!(
matches!(
err,
edgefirst_tensor::Error::QuantizationInvalid { field: "scale", .. }
),
"axis={axis:?} gave {err:?}"
);
}
}
#[test]
fn quantization_to_tensor_single_scale_with_many_zero_points_errors_on_scale() {
for axis in [None, Some(0)] {
let q = Quantization {
scale: vec![0.5],
zero_point: Some(vec![1, 2]),
axis,
dtype: Some(DType::Int8),
};
let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
assert!(
matches!(
err,
edgefirst_tensor::Error::QuantizationInvalid { field: "scale", .. }
),
"axis={axis:?} gave {err:?}"
);
}
}
#[test]
fn per_channel_quantization_is_not_per_tensor() {
let q = Quantization {
scale: vec![0.1, 0.2, 0.3],
zero_point: None,
axis: Some(0),
dtype: Some(DType::Int8),
};
assert!(q.is_per_channel());
assert!(!q.is_per_tensor());
let per_tensor = Quantization {
scale: vec![0.1],
zero_point: None,
axis: None,
dtype: Some(DType::Int8),
};
assert!(per_tensor.is_per_tensor());
assert!(!per_tensor.is_per_channel());
}
#[test]
fn zero_point_at_indexes_per_channel_zero_points() {
let per_channel = Quantization {
scale: vec![0.1, 0.2, 0.3],
zero_point: Some(vec![10, 20, 30]),
axis: Some(0),
dtype: Some(DType::Int8),
};
assert_eq!(per_channel.zero_point_at(0), 10);
assert_eq!(per_channel.zero_point_at(1), 20);
assert_eq!(per_channel.zero_point_at(2), 30);
assert_eq!(per_channel.zero_point_at(3), 0);
let shared = Quantization {
scale: vec![0.1, 0.2, 0.3],
zero_point: Some(vec![7]),
axis: Some(0),
dtype: Some(DType::Int8),
};
assert_eq!(shared.zero_point_at(0), 7);
assert_eq!(shared.zero_point_at(2), 7);
let symmetric = Quantization {
scale: vec![0.1],
zero_point: None,
axis: None,
dtype: Some(DType::Int8),
};
assert_eq!(symmetric.zero_point_at(0), 0);
}
#[test]
fn logical_output_flat_tflite_boxes() {
let j = r#"{
"name": "boxes", "type": "boxes",
"shape": [1, 64, 8400],
"dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
"dtype": "int8",
"quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
"decoder": "ultralytics",
"encoding": "dfl",
"normalized": true
}"#;
let lo: LogicalOutput = serde_json::from_str(j).unwrap();
assert_eq!(lo.type_, Some(LogicalType::Boxes));
assert_eq!(lo.encoding, Some(BoxEncoding::Dfl));
assert_eq!(lo.normalized, Some(true));
assert!(!lo.is_split());
assert_eq!(lo.dtype, Some(DType::Int8));
}
#[test]
fn logical_output_hailo_per_scale_split() {
let j = r#"{
"name": "boxes", "type": "boxes",
"shape": [1, 64, 8400],
"encoding": "dfl", "decoder": "ultralytics", "normalized": true,
"outputs": [
{
"name": "boxes_0", "type": "boxes",
"stride": 8, "scale_index": 0,
"shape": [1, 80, 80, 64],
"dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_features": 64}],
"dtype": "uint8",
"quantization": {"scale": 0.0234, "zero_point": 128, "dtype": "uint8"}
}
]
}"#;
let lo: LogicalOutput = serde_json::from_str(j).unwrap();
assert!(lo.is_split());
assert_eq!(lo.outputs.len(), 1);
let child = &lo.outputs[0];
assert_eq!(child.name, "boxes_0");
assert_eq!(child.type_, Some(PhysicalType::Boxes));
assert_eq!(child.stride, Some(Stride::Square(8)));
assert_eq!(child.scale_index, Some(0));
assert_eq!(child.dtype, DType::Uint8);
}
#[test]
fn logical_output_ara2_xy_wh_channel_split() {
let j = r#"{
"name": "boxes", "type": "boxes",
"shape": [1, 4, 8400, 1],
"encoding": "direct", "decoder": "ultralytics", "normalized": true,
"outputs": [
{
"name": "_model_22_Div_1_output_0", "type": "boxes_xy",
"shape": [1, 2, 8400, 1],
"dshape": [{"batch": 1}, {"box_coords": 2}, {"num_boxes": 8400}, {"padding": 1}],
"dtype": "int16",
"quantization": {"scale": 3.129e-5, "zero_point": 0, "dtype": "int16"}
},
{
"name": "_model_22_Sub_1_output_0", "type": "boxes_wh",
"shape": [1, 2, 8400, 1],
"dshape": [{"batch": 1}, {"box_coords": 2}, {"num_boxes": 8400}, {"padding": 1}],
"dtype": "int16",
"quantization": {"scale": 3.149e-5, "zero_point": 0, "dtype": "int16"}
}
]
}"#;
let lo: LogicalOutput = serde_json::from_str(j).unwrap();
assert_eq!(lo.encoding, Some(BoxEncoding::Direct));
assert_eq!(lo.outputs.len(), 2);
assert_eq!(lo.outputs[0].type_, Some(PhysicalType::BoxesXy));
assert_eq!(lo.outputs[1].type_, Some(PhysicalType::BoxesWh));
assert!(lo.outputs[0].stride.is_none());
assert!(lo.outputs[1].stride.is_none());
}
#[test]
fn logical_output_hailo_scores_sigmoid_applied() {
let j = r#"{
"name": "scores", "type": "scores",
"shape": [1, 80, 8400],
"decoder": "ultralytics", "score_format": "per_class",
"outputs": [
{
"name": "scores_0", "type": "scores",
"stride": 8, "scale_index": 0,
"shape": [1, 80, 80, 80],
"dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_classes": 80}],
"dtype": "uint8",
"quantization": {"scale": 0.003922, "dtype": "uint8"},
"activation_applied": "sigmoid"
}
]
}"#;
let lo: LogicalOutput = serde_json::from_str(j).unwrap();
assert_eq!(lo.score_format, Some(ScoreFormat::PerClass));
let child = &lo.outputs[0];
assert_eq!(child.activation_applied, Some(Activation::Sigmoid));
assert!(child.activation_required.is_none());
}
#[test]
fn yolo26_end_to_end_detections() {
let j = r#"{
"schema_version": 2,
"decoder_version": "yolo26",
"outputs": [{
"name": "output0", "type": "detections",
"shape": [1, 100, 6],
"dshape": [{"batch": 1}, {"num_boxes": 100}, {"num_features": 6}],
"dtype": "int8",
"quantization": {"scale": 0.0078, "zero_point": 0, "dtype": "int8"},
"normalized": false,
"decoder": "ultralytics"
}]
}"#;
let s: SchemaV2 = serde_json::from_str(j).unwrap();
assert_eq!(s.decoder_version, Some(DecoderVersion::Yolo26));
assert!(s.decoder_version.unwrap().is_end_to_end());
assert_eq!(s.outputs[0].type_, Some(LogicalType::Detections));
assert_eq!(s.outputs[0].normalized, Some(false));
assert!(s.nms.is_none());
}
#[test]
fn modelpack_anchor_detection_with_rect_stride() {
let j = r#"{
"schema_version": 2,
"outputs": [{
"name": "output_0", "type": "detection",
"shape": [1, 40, 40, 54],
"dshape": [{"batch": 1}, {"height": 40}, {"width": 40}, {"num_anchors_x_features": 54}],
"dtype": "uint8",
"quantization": {"scale": 0.176, "zero_point": 198, "dtype": "uint8"},
"decoder": "modelpack",
"encoding": "anchor",
"stride": [16, 16],
"anchors": [[0.054, 0.065], [0.089, 0.139], [0.195, 0.196]]
}]
}"#;
let s: SchemaV2 = serde_json::from_str(j).unwrap();
let lo = &s.outputs[0];
assert_eq!(lo.encoding, Some(BoxEncoding::Anchor));
assert_eq!(lo.stride, Some(Stride::Rect([16, 16])));
assert_eq!(lo.anchors.as_ref().map(|a| a.len()), Some(3));
}
#[test]
fn yolov5_obj_x_class_objectness_logical() {
let j = r#"{
"name": "objectness", "type": "objectness",
"shape": [1, 3, 8400],
"decoder": "ultralytics",
"outputs": [{
"name": "objectness_0", "type": "objectness",
"stride": 8, "scale_index": 0,
"shape": [1, 80, 80, 3],
"dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_features": 3}],
"dtype": "uint8",
"quantization": {"scale": 0.0039, "zero_point": 0, "dtype": "uint8"},
"activation_applied": "sigmoid"
}]
}"#;
let lo: LogicalOutput = serde_json::from_str(j).unwrap();
assert_eq!(lo.type_, Some(LogicalType::Objectness));
assert_eq!(lo.outputs[0].activation_applied, Some(Activation::Sigmoid));
}
#[test]
fn direct_protos_no_decoder() {
let j = r#"{
"name": "protos", "type": "protos",
"shape": [1, 32, 160, 160],
"dshape": [{"batch": 1}, {"num_protos": 32}, {"height": 160}, {"width": 160}],
"dtype": "uint8",
"quantization": {"scale": 0.0203, "zero_point": 45, "dtype": "uint8"},
"stride": 4
}"#;
let lo: LogicalOutput = serde_json::from_str(j).unwrap();
assert_eq!(lo.type_, Some(LogicalType::Protos));
assert!(lo.decoder.is_none());
assert_eq!(lo.stride, Some(Stride::Square(4)));
}
#[test]
fn full_yolov8_tflite_flat_detection() {
let j = r#"{
"schema_version": 2,
"decoder_version": "yolov8",
"nms": "class_agnostic",
"input": { "shape": [1, 640, 640, 3], "cameraadaptor": "rgb" },
"outputs": [
{
"name": "boxes", "type": "boxes",
"shape": [1, 64, 8400],
"dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
"dtype": "int8",
"quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
"decoder": "ultralytics",
"encoding": "dfl",
"normalized": true
},
{
"name": "scores", "type": "scores",
"shape": [1, 80, 8400],
"dshape": [{"batch": 1}, {"num_classes": 80}, {"num_boxes": 8400}],
"dtype": "int8",
"quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
"decoder": "ultralytics",
"score_format": "per_class"
}
]
}"#;
let s: SchemaV2 = serde_json::from_str(j).unwrap();
assert_eq!(s.schema_version, 2);
assert_eq!(s.decoder_version, Some(DecoderVersion::Yolov8));
assert_eq!(s.nms, Some(NmsMode::ClassAgnostic));
assert_eq!(s.input.as_ref().unwrap().shape, vec![1, 640, 640, 3]);
assert_eq!(s.outputs.len(), 2);
}
#[test]
fn schema_unknown_version_parses_without_validation() {
let j = r#"{"schema_version": 99, "outputs": []}"#;
let s: SchemaV2 = serde_json::from_str(j).unwrap();
assert_eq!(s.schema_version, 99);
}
#[test]
fn serde_roundtrip_preserves_fields() {
let original = SchemaV2 {
schema_version: 2,
input: Some(InputSpec {
shape: vec![1, 3, 640, 640],
dshape: vec![],
cameraadaptor: Some("rgb".into()),
preprocessing: None,
}),
outputs: vec![LogicalOutput {
name: Some("boxes".into()),
type_: Some(LogicalType::Boxes),
shape: vec![1, 4, 8400],
dshape: vec![],
decoder: Some(DecoderKind::Ultralytics),
encoding: Some(BoxEncoding::Direct),
score_format: None,
normalized: Some(true),
anchors: None,
stride: None,
dtype: Some(DType::Float32),
quantization: None,
outputs: vec![],
activation_applied: None,
activation_required: None,
semantic: Default::default(),
}],
nms: Some(NmsMode::ClassAgnostic),
nms_multi_label: None,
decoder_version: Some(DecoderVersion::Yolov8),
};
let j = serde_json::to_string(&original).unwrap();
let parsed: SchemaV2 = serde_json::from_str(&j).unwrap();
assert_eq!(parsed, original);
}
#[test]
fn parse_v1_yaml_yolov8_seg_testdata() {
let yaml = edgefirst_bench::testdata::read_to_string("yolov8_seg.yaml");
let schema = SchemaV2::parse_yaml(&yaml).expect("parse v1 yaml");
assert_eq!(schema.schema_version, 2);
assert_eq!(schema.outputs.len(), 2);
let det = &schema.outputs[0];
assert_eq!(det.type_, Some(LogicalType::Detection));
assert_eq!(det.shape, vec![1, 116, 8400]);
assert_eq!(det.decoder, Some(DecoderKind::Ultralytics));
assert_eq!(det.encoding, Some(BoxEncoding::Direct));
let q = det.quantization.as_ref().unwrap();
assert_eq!(q.scale.len(), 1);
assert!((q.scale[0] - 0.021_287_762).abs() < 1e-6);
assert_eq!(q.zero_point, Some(vec![31]));
let protos = &schema.outputs[1];
assert_eq!(protos.type_, Some(LogicalType::Protos));
assert_eq!(protos.shape, vec![1, 160, 160, 32]);
}
#[test]
fn parse_v1_json_modelpack_split_testdata() {
let json = edgefirst_bench::testdata::read_to_string("modelpack_split.json");
let schema = SchemaV2::parse_json(&json).expect("parse v1 json");
assert_eq!(schema.schema_version, 2);
assert_eq!(schema.outputs.len(), 2);
for out in &schema.outputs {
assert_eq!(out.type_, Some(LogicalType::Detection));
assert_eq!(out.decoder, Some(DecoderKind::ModelPack));
assert_eq!(out.encoding, Some(BoxEncoding::Anchor));
assert_eq!(out.anchors.as_ref().map(|a| a.len()), Some(3));
}
}
#[test]
fn parse_v2_json_direct_when_schema_version_present() {
let j = r#"{
"schema_version": 2,
"outputs": [{
"name": "boxes", "type": "boxes",
"shape": [1, 4, 8400],
"dshape": [{"batch": 1}, {"box_coords": 4}, {"num_boxes": 8400}],
"dtype": "float32",
"decoder": "ultralytics",
"encoding": "direct",
"normalized": true
}]
}"#;
let schema = SchemaV2::parse_json(j).unwrap();
assert_eq!(schema.schema_version, 2);
assert_eq!(schema.outputs[0].type_, Some(LogicalType::Boxes));
}
#[test]
fn parse_rejects_future_schema_version() {
let j = r#"{"schema_version": 99, "outputs": []}"#;
let err = SchemaV2::parse_json(j).unwrap_err();
matches!(err, DecoderError::NotSupported(_));
}
#[test]
fn parse_absent_schema_version_treats_as_v1() {
let j = r#"{
"outputs": [
{
"type": "boxes", "decoder": "ultralytics",
"shape": [1, 4, 8400],
"quantization": [0.00392, 0]
},
{
"type": "scores", "decoder": "ultralytics",
"shape": [1, 80, 8400],
"quantization": [0.00392, 0]
}
]
}"#;
let schema = SchemaV2::parse_json(j).expect("v1 legacy parse");
assert_eq!(schema.schema_version, 2); assert_eq!(schema.outputs.len(), 2);
assert_eq!(schema.outputs[0].type_, Some(LogicalType::Boxes));
assert_eq!(schema.outputs[1].type_, Some(LogicalType::Scores));
assert_eq!(schema.outputs[1].score_format, Some(ScoreFormat::PerClass));
}
#[test]
fn from_v1_preserves_nms_and_decoder_version() {
let v1 = ConfigOutputs {
outputs: vec![ConfigOutput::Boxes(crate::configs::Boxes {
decoder: crate::configs::DecoderType::Ultralytics,
quantization: Some(crate::configs::QuantTuple(0.01, 5)),
shape: vec![1, 4, 8400],
dshape: vec![],
normalized: Some(true),
})],
nms: Some(crate::configs::Nms::ClassAware),
nms_multi_label: None,
decoder_version: Some(crate::configs::DecoderVersion::Yolo11),
};
let v2 = SchemaV2::from_v1(&v1).unwrap();
assert_eq!(v2.nms, Some(NmsMode::ClassAware));
assert_eq!(v2.decoder_version, Some(DecoderVersion::Yolo11));
assert_eq!(v2.outputs[0].normalized, Some(true));
let q = v2.outputs[0].quantization.as_ref().unwrap();
assert_eq!(q.scale, vec![0.01]);
assert_eq!(q.zero_point, Some(vec![5]));
assert_eq!(q.dtype, None); }
#[test]
fn typeless_logical_output_parses_and_roundtrips() {
let j = r#"{
"schema_version": 2,
"outputs": [
{
"name": "extra_telemetry",
"shape": [1, 16]
},
{
"name": "boxes",
"type": "boxes",
"shape": [1, 4, 8400]
}
]
}"#;
let schema: SchemaV2 = serde_json::from_str(j).unwrap();
assert_eq!(schema.outputs.len(), 2);
assert_eq!(schema.outputs[0].type_, None);
assert_eq!(schema.outputs[0].name.as_deref(), Some("extra_telemetry"));
assert_eq!(schema.outputs[1].type_, Some(LogicalType::Boxes));
let round = serde_json::to_string(&schema).unwrap();
let first_obj = round
.split("\"outputs\":[")
.nth(1)
.and_then(|s| s.split("}").next())
.expect("outputs array");
assert!(
!first_obj.contains("\"type\""),
"typeless output must not serialize a `type` field, got: {first_obj}"
);
}
#[test]
fn typeless_outputs_filtered_from_legacy_config() {
let schema = SchemaV2 {
schema_version: 2,
input: None,
outputs: vec![
LogicalOutput {
name: Some("diagnostic_histogram".into()),
type_: None,
shape: vec![1, 256],
dshape: vec![],
decoder: None,
encoding: None,
score_format: None,
normalized: None,
anchors: None,
stride: None,
dtype: None,
quantization: None,
outputs: vec![],
activation_applied: None,
activation_required: None,
semantic: Default::default(),
},
LogicalOutput {
name: Some("boxes".into()),
type_: Some(LogicalType::Boxes),
shape: vec![1, 4, 8400],
dshape: vec![],
decoder: Some(DecoderKind::Ultralytics),
encoding: Some(BoxEncoding::Direct),
score_format: None,
normalized: Some(true),
anchors: None,
stride: None,
dtype: None,
quantization: None,
outputs: vec![],
activation_applied: None,
activation_required: None,
semantic: Default::default(),
},
],
nms: None,
nms_multi_label: None,
decoder_version: None,
};
let legacy = schema.to_legacy_config_outputs().unwrap();
assert_eq!(
legacy.outputs.len(),
1,
"typeless output must be filtered from legacy config"
);
assert!(
matches!(legacy.outputs[0], ConfigOutput::Boxes(_)),
"only the typed `boxes` output should survive lowering"
);
}
#[test]
fn all_typeless_schema_produces_empty_legacy_config() {
let schema = SchemaV2 {
schema_version: 2,
input: None,
outputs: vec![LogicalOutput {
name: Some("aux".into()),
type_: None,
shape: vec![1, 8],
dshape: vec![],
decoder: None,
encoding: None,
score_format: None,
normalized: None,
anchors: None,
stride: None,
dtype: None,
quantization: None,
outputs: vec![],
activation_applied: None,
activation_required: None,
semantic: Default::default(),
}],
nms: None,
nms_multi_label: None,
decoder_version: None,
};
let legacy = schema.to_legacy_config_outputs().unwrap();
assert!(legacy.outputs.is_empty());
}
#[test]
fn typeless_physical_child_parses_and_skips_uniqueness() {
let j = r#"{
"name": "boxes",
"type": "boxes",
"shape": [1, 8400, 4],
"outputs": [
{
"name": "boxes_xy",
"type": "boxes_xy",
"shape": [1, 8400, 2],
"dtype": "float32"
},
{
"name": "aux_user_managed",
"shape": [1, 8400, 2],
"dtype": "float32"
}
]
}"#;
let lo: LogicalOutput = serde_json::from_str(j).unwrap();
assert_eq!(lo.outputs.len(), 2);
assert_eq!(lo.outputs[0].type_, Some(PhysicalType::BoxesXy));
assert_eq!(lo.outputs[1].type_, None);
let schema = SchemaV2 {
schema_version: 2,
input: None,
outputs: vec![lo],
nms: None,
nms_multi_label: None,
decoder_version: None,
};
schema.validate().expect(
"typed + typeless children with equal shape must not trigger \
uniqueness error",
);
let s = serde_json::to_string(&schema).unwrap();
assert!(
s.contains("\"aux_user_managed\""),
"typeless child must survive round-trip: {s}"
);
let aux_obj = s
.split("\"aux_user_managed\"")
.nth(1)
.and_then(|s| s.split('}').next())
.unwrap_or("");
assert!(
!aux_obj.contains("\"type\""),
"typeless child must not serialize `type`, got: {aux_obj}"
);
}
#[test]
fn from_v1_modelpack_anchor_detection_maps_encoding() {
let v1 = ConfigOutputs {
outputs: vec![ConfigOutput::Detection(crate::configs::Detection {
anchors: Some(vec![[0.1, 0.2], [0.3, 0.4]]),
decoder: crate::configs::DecoderType::ModelPack,
quantization: Some(crate::configs::QuantTuple(0.176, 198)),
shape: vec![1, 40, 40, 54],
dshape: vec![],
normalized: None,
})],
nms: None,
nms_multi_label: None,
decoder_version: None,
};
let v2 = SchemaV2::from_v1(&v1).unwrap();
assert_eq!(v2.outputs[0].encoding, Some(BoxEncoding::Anchor));
assert_eq!(v2.outputs[0].decoder, Some(DecoderKind::ModelPack));
assert_eq!(v2.outputs[0].anchors.as_ref().map(|a| a.len()), Some(2));
}
#[test]
fn validate_accepts_flat_v2_yolov8_detection() {
let j = r#"{
"schema_version": 2,
"outputs": [
{"name":"boxes","type":"boxes","shape":[1,64,8400],
"dtype":"int8","decoder":"ultralytics","encoding":"dfl"},
{"name":"scores","type":"scores","shape":[1,80,8400],
"dtype":"int8","decoder":"ultralytics","score_format":"per_class"}
]
}"#;
SchemaV2::parse_json(j).unwrap().validate().unwrap();
}
#[test]
fn validate_rejects_unnamed_physical_child() {
let j = r#"{
"schema_version": 2,
"outputs": [{
"name":"boxes","type":"boxes","shape":[1,64,8400],
"encoding":"dfl","decoder":"ultralytics",
"outputs": [{
"name":"","type":"boxes","stride":8,
"shape":[1,80,80,64],"dtype":"uint8"
}]
}]
}"#;
let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("missing `name`"), "got: {msg}");
}
#[test]
fn validate_rejects_duplicate_physical_shapes() {
let j = r#"{
"schema_version": 2,
"outputs": [{
"name":"boxes","type":"boxes","shape":[1,64,8400],
"encoding":"dfl","decoder":"ultralytics",
"outputs": [
{"name":"a","type":"boxes","stride":8,"shape":[1,80,80,64],"dtype":"uint8"},
{"name":"b","type":"boxes","stride":16,"shape":[1,80,80,64],"dtype":"uint8"}
]
}]
}"#;
let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("share shape"), "got: {msg}");
}
#[test]
fn validate_rejects_mixed_decomposition() {
let j = r#"{
"schema_version": 2,
"outputs": [{
"name":"boxes","type":"boxes","shape":[1,4,8400,1],
"encoding":"direct","decoder":"ultralytics",
"outputs": [
{"name":"xy","type":"boxes_xy","shape":[1,2,8400,1],"dtype":"int16"},
{"name":"p0","type":"boxes","stride":8,"shape":[1,80,80,64],"dtype":"uint8"}
]
}]
}"#;
let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("uniform"), "got: {msg}");
}
#[test]
fn validate_rejects_dfl_boxes_feature_not_divisible_by_4() {
let j = r#"{
"schema_version": 2,
"outputs": [{
"name":"boxes","type":"boxes","shape":[1,63,8400],
"encoding":"dfl","decoder":"ultralytics",
"outputs": [{
"name":"b0","type":"boxes","stride":8,
"shape":[1,80,80,63],
"dshape":[{"batch":1},{"height":80},{"width":80},{"num_features":63}],
"dtype":"uint8"
}]
}]
}"#;
let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("not"), "got: {msg}");
assert!(msg.contains("divisible by 4"), "got: {msg}");
}
#[test]
fn validate_accepts_hailo_per_scale_yolov8() {
let j = r#"{
"schema_version": 2,
"outputs": [{
"name":"boxes","type":"boxes","shape":[1,64,8400],
"encoding":"dfl","decoder":"ultralytics","normalized":true,
"outputs": [
{"name":"b0","type":"boxes","stride":8,
"shape":[1,80,80,64],
"dshape":[{"batch":1},{"height":80},{"width":80},{"num_features":64}],
"dtype":"uint8",
"quantization":{"scale":0.0234,"zero_point":128,"dtype":"uint8"}},
{"name":"b1","type":"boxes","stride":16,
"shape":[1,40,40,64],
"dshape":[{"batch":1},{"height":40},{"width":40},{"num_features":64}],
"dtype":"uint8",
"quantization":{"scale":0.0198,"zero_point":130,"dtype":"uint8"}},
{"name":"b2","type":"boxes","stride":32,
"shape":[1,20,20,64],
"dshape":[{"batch":1},{"height":20},{"width":20},{"num_features":64}],
"dtype":"uint8",
"quantization":{"scale":0.0312,"zero_point":125,"dtype":"uint8"}}
]
}]
}"#;
let s = SchemaV2::parse_json(j).unwrap();
s.validate().unwrap();
}
#[test]
fn validate_accepts_ara2_xy_wh() {
let j = r#"{
"schema_version": 2,
"outputs": [{
"name":"boxes","type":"boxes","shape":[1,4,8400,1],
"encoding":"direct","decoder":"ultralytics","normalized":true,
"outputs": [
{"name":"xy","type":"boxes_xy","shape":[1,2,8400,1],
"dshape":[{"batch":1},{"box_coords":2},{"num_boxes":8400},{"padding":1}],
"dtype":"int16",
"quantization":{"scale":3.1e-5,"zero_point":0,"dtype":"int16"}},
{"name":"wh","type":"boxes_wh","shape":[1,2,8400,1],
"dshape":[{"batch":1},{"box_coords":2},{"num_boxes":8400},{"padding":1}],
"dtype":"int16",
"quantization":{"scale":3.2e-5,"zero_point":0,"dtype":"int16"}}
]
}]
}"#;
SchemaV2::parse_json(j).unwrap().validate().unwrap();
}
#[test]
fn parse_file_auto_detects_json() {
let tmp = std::env::temp_dir().join(format!("schema_v2_test_{}.json", std::process::id()));
std::fs::write(&tmp, r#"{"schema_version":2,"outputs":[]}"#).unwrap();
let s = SchemaV2::parse_file(&tmp).unwrap();
assert_eq!(s.schema_version, 2);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn parse_file_auto_detects_yaml() {
let tmp = std::env::temp_dir().join(format!("schema_v2_test_{}.yaml", std::process::id()));
std::fs::write(&tmp, "schema_version: 2\noutputs: []\n").unwrap();
let s = SchemaV2::parse_file(&tmp).unwrap();
assert_eq!(s.schema_version, 2);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn parse_real_ara2_int8_dvm_metadata() {
let json = edgefirst_bench::testdata::read_to_string("ara2_int8_edgefirst.json");
let schema = SchemaV2::parse_json(&json).expect("ARA-2 int8 parse");
assert_eq!(schema.schema_version, 2);
assert_eq!(schema.decoder_version, Some(DecoderVersion::Yolov8));
assert_eq!(schema.nms, Some(NmsMode::ClassAgnostic));
assert_eq!(schema.input.as_ref().unwrap().shape, vec![1, 3, 640, 640]);
assert_eq!(schema.outputs.len(), 4);
let boxes = &schema.outputs[0];
assert_eq!(boxes.type_, Some(LogicalType::Boxes));
assert_eq!(boxes.encoding, Some(BoxEncoding::Direct));
assert_eq!(boxes.normalized, Some(true));
assert_eq!(boxes.shape, vec![1, 4, 8400, 1]); assert_eq!(boxes.outputs.len(), 2);
assert_eq!(boxes.outputs[0].type_, Some(PhysicalType::BoxesXy));
assert_eq!(boxes.outputs[1].type_, Some(PhysicalType::BoxesWh));
let q_xy = boxes.outputs[0].quantization.as_ref().unwrap();
assert_eq!(q_xy.dtype, Some(DType::Int8));
assert!((q_xy.scale[0] - 0.004_177_792).abs() < 1e-6);
assert_eq!(q_xy.zero_point_at(0), -122);
let scores = &schema.outputs[1];
assert_eq!(scores.type_, Some(LogicalType::Scores));
assert_eq!(scores.score_format, Some(ScoreFormat::PerClass));
assert_eq!(scores.shape, vec![1, 80, 8400, 1]);
let mask_coefs = &schema.outputs[2];
assert_eq!(mask_coefs.type_, Some(LogicalType::MaskCoefs));
assert_eq!(mask_coefs.shape, vec![1, 32, 8400, 1]);
let protos = &schema.outputs[3];
assert_eq!(protos.type_, Some(LogicalType::Protos));
assert_eq!(protos.shape, vec![1, 32, 160, 160]);
schema.validate().expect("ARA-2 int8 validate");
}
#[test]
fn parse_real_ara2_int16_dvm_metadata() {
let json = edgefirst_bench::testdata::read_to_string("ara2_int16_edgefirst.json");
let schema = SchemaV2::parse_json(&json).expect("ARA-2 int16 parse");
assert_eq!(schema.schema_version, 2);
assert_eq!(schema.outputs.len(), 4);
let boxes = &schema.outputs[0];
assert_eq!(boxes.outputs.len(), 2);
let q_xy = boxes.outputs[0].quantization.as_ref().unwrap();
assert_eq!(q_xy.dtype, Some(DType::Int16));
assert!((q_xy.scale[0] - 3.211_570_6e-5).abs() < 1e-10);
assert_eq!(q_xy.zero_point_at(0), 0);
let mc_q = schema.outputs[2].quantization.as_ref().unwrap();
assert_eq!(mc_q.dtype, Some(DType::Int16));
schema.validate().expect("ARA-2 int16 validate");
}
#[test]
fn parse_yaml_with_explicit_schema_version_2() {
let yaml = r#"
schema_version: 2
outputs:
- name: scores
type: scores
shape: [1, 80, 8400]
dtype: int8
quantization:
scale: 0.00392
dtype: int8
decoder: ultralytics
score_format: per_class
"#;
let schema = SchemaV2::parse_yaml(yaml).unwrap();
assert_eq!(schema.schema_version, 2);
assert_eq!(schema.outputs[0].score_format, Some(ScoreFormat::PerClass));
}
#[test]
fn squeeze_padding_dims_preserves_shape_when_dshape_absent() {
let (shape, dshape) = squeeze_padding_dims(vec![1, 4, 8400], vec![]);
assert_eq!(shape, vec![1, 4, 8400]);
assert!(dshape.is_empty());
}
fn sample_logical(ty: LogicalType) -> LogicalOutput {
LogicalOutput {
name: Some("sample".into()),
type_: Some(ty),
shape: vec![1, 4, 100],
dshape: vec![],
decoder: None,
encoding: None,
score_format: None,
normalized: Some(true),
anchors: None,
stride: None,
dtype: None,
quantization: None,
outputs: vec![],
activation_applied: None,
activation_required: None,
semantic: Default::default(),
}
}
#[test]
fn logical_to_legacy_maps_each_supported_type() {
let cases = [
(LogicalType::Boxes, "Boxes"),
(LogicalType::Scores, "Scores"),
(LogicalType::Protos, "Protos"),
(LogicalType::MaskCoefs, "MaskCoefficients"),
(LogicalType::Segmentation, "Segmentation"),
(LogicalType::Masks, "Mask"),
(LogicalType::Classes, "Classes"),
(LogicalType::Detection, "Detection"),
(LogicalType::Detections, "Detection"),
];
for (ty, expected) in cases {
let out = logical_to_legacy_config_output(&sample_logical(ty)).unwrap();
let name = match &out {
ConfigOutput::Boxes(_) => "Boxes",
ConfigOutput::Scores(_) => "Scores",
ConfigOutput::Protos(_) => "Protos",
ConfigOutput::MaskCoefficients(_) => "MaskCoefficients",
ConfigOutput::Segmentation(_) => "Segmentation",
ConfigOutput::Mask(_) => "Mask",
ConfigOutput::Classes(_) => "Classes",
ConfigOutput::Detection(_) => "Detection",
};
assert_eq!(name, expected, "type {ty:?}");
}
}
#[test]
fn logical_to_legacy_rejects_objectness_and_landmarks() {
for ty in [LogicalType::Objectness, LogicalType::Landmarks] {
let err = logical_to_legacy_config_output(&sample_logical(ty)).unwrap_err();
assert!(
matches!(err, DecoderError::NotSupported(_)),
"expected NotSupported for {ty:?}, got {err:?}"
);
}
}
#[test]
fn logical_to_legacy_rejects_typeless_output() {
let mut logical = sample_logical(LogicalType::Boxes);
logical.type_ = None;
let err = logical_to_legacy_config_output(&logical).unwrap_err();
assert!(matches!(err, DecoderError::InvalidConfig(_)));
}
#[test]
fn to_legacy_modelpack_boxes_preserves_padding_dim() {
let j = r#"{
"schema_version": 2,
"outputs": [
{"name":"boxes","type":"boxes",
"shape":[1,1935,1,4],
"dshape":[{"batch":1},{"num_boxes":1935},{"padding":1},{"box_coords":4}],
"decoder":"modelpack"}
]
}"#;
let schema = SchemaV2::parse_json(j).unwrap();
let legacy = schema.to_legacy_config_outputs().expect("lowers cleanly");
let boxes = match &legacy.outputs[0] {
crate::ConfigOutput::Boxes(b) => b,
other => panic!("expected Boxes, got {other:?}"),
};
assert_eq!(boxes.shape, vec![1, 1935, 1, 4]);
assert_eq!(
boxes.dshape,
vec![
(DimName::Batch, 1),
(DimName::NumBoxes, 1935),
(DimName::Padding, 1),
(DimName::BoxCoords, 4),
]
);
}
#[test]
fn to_legacy_preserves_shape_for_v2_split_boxes_without_dshape() {
let j = r#"{
"schema_version": 2,
"outputs": [
{"name":"boxes","type":"boxes","shape":[1,4,8400],
"dtype":"float32","decoder":"ultralytics","encoding":"direct"},
{"name":"scores","type":"scores","shape":[1,80,8400],
"dtype":"float32","decoder":"ultralytics","score_format":"per_class"}
]
}"#;
let schema = SchemaV2::parse_json(j).unwrap();
let legacy = schema.to_legacy_config_outputs().expect("lowers cleanly");
let boxes = match &legacy.outputs[0] {
crate::ConfigOutput::Boxes(b) => b,
other => panic!("expected Boxes, got {other:?}"),
};
assert_eq!(boxes.shape, vec![1, 4, 8400]);
let scores = match &legacy.outputs[1] {
crate::ConfigOutput::Scores(s) => s,
other => panic!("expected Scores, got {other:?}"),
};
assert_eq!(scores.shape, vec![1, 80, 8400]);
}
const SEGFORMER_B1: &str = r#"{
"schema_version": 2,
"input": {
"shape": [1, 3, 512, 672],
"dshape": [{"batch": 1}, {"channels": 3}, {"height": 512}, {"width": 672}],
"cameraadaptor": "rgb",
"preprocessing": {
"normalization": {"mean": [123.675, 116.28, 103.53], "std": [58.395, 57.12, 57.375]},
"resize": "letterbox",
"interpolation": "bilinear",
"letterbox": {"placement": "topleft", "pad": [0.0, 0.0, 0.0], "pad_space": "normalized"}
}
},
"outputs": [{
"name": "logits", "type": "segmentation", "decoder": "semantic",
"shape": [1, 150, 128, 168],
"dshape": [{"batch": 1}, {"num_classes": 150}, {"height": 128}, {"width": 168}],
"dtype": "float32", "stride": 4,
"upsample": {"method": "bilinear", "align_corners": false},
"decode_mode": "accurate"
}]
}"#;
#[test]
fn semantic_segformer_parses_and_validates() {
let schema = SchemaV2::parse_json(SEGFORMER_B1).unwrap();
schema.validate().unwrap();
let pre = schema
.input
.as_ref()
.unwrap()
.preprocessing
.as_ref()
.unwrap();
assert_eq!(pre.resize, Some(FitMode::Letterbox));
assert_eq!(pre.interpolation, Some(Interpolation::Bilinear));
let lb = pre.letterbox.as_ref().unwrap();
assert_eq!(lb.placement, Some(LetterboxPlacement::TopLeft));
assert_eq!(lb.pad_space, Some(PadSpace::Normalized));
let (mean, std) = pre.normalization.as_ref().unwrap().mean_std(3).unwrap();
assert_eq!(
mean,
edgefirst_tensor::Normalization::imagenet().mean().to_vec()
);
assert_eq!(
std,
edgefirst_tensor::Normalization::imagenet().std().to_vec()
);
let out = &schema.outputs[0];
assert_eq!(out.decoder, Some(DecoderKind::Semantic));
assert_eq!(out.stride, Some(Stride::Square(4)));
assert_eq!(
out.semantic.upsample,
Some(Upsample {
method: UpsampleMethod::Bilinear,
align_corners: false
})
);
assert_eq!(out.semantic.decode_mode, Some(DecodeMode::Accurate));
}
#[test]
fn semantic_round_trips_with_flat_fields() {
let schema = SchemaV2::parse_json(SEGFORMER_B1).unwrap();
let value = serde_json::to_value(&schema).unwrap();
let out = &value["outputs"][0];
assert_eq!(out["decode_mode"], "accurate");
assert_eq!(out["upsample"]["method"], "bilinear");
assert!(out.get("semantic").is_none(), "fields must serialize flat");
assert!(out.get("threshold").is_none(), "unset fields are omitted");
let back: SchemaV2 = serde_json::from_value(value).unwrap();
assert_eq!(back, schema);
}
#[test]
fn semantic_decoder_is_not_lowered_to_legacy() {
let schema = SchemaV2::parse_json(SEGFORMER_B1).unwrap();
let err = schema.to_legacy_config_outputs().unwrap_err();
assert!(matches!(err, DecoderError::NotSupported(_)), "{err:?}");
}
#[test]
fn modelpack_segmentation_still_lowers_to_legacy() {
let j = r#"{
"schema_version": 2,
"outputs": [{
"name": "segmentation_output", "type": "segmentation",
"shape": [1, 480, 640, 5],
"dshape": [{"batch": 1}, {"height": 480}, {"width": 640}, {"num_classes": 5}],
"dtype": "uint8",
"quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "uint8"},
"decoder": "modelpack"
}]
}"#;
let schema = SchemaV2::parse_json(j).unwrap();
assert_eq!(schema.outputs[0].semantic, SemanticParams::default());
let legacy = schema.to_legacy_config_outputs().unwrap();
assert!(matches!(
legacy.outputs[0],
crate::ConfigOutput::Segmentation(_)
));
}
#[test]
fn normalization_presets_resolve() {
let cases = [
(NormalizationPreset::Unsigned, 0.0, 255.0),
(NormalizationPreset::Signed, 127.5, 127.5),
(NormalizationPreset::Raw, 0.0, 1.0),
];
for (preset, m, s) in cases {
let (mean, std) = NormalizationConfig::Preset(preset).mean_std(4).unwrap();
assert_eq!(mean, vec![m; 4], "{preset:?}");
assert_eq!(std, vec![s; 4], "{preset:?}");
}
let imagenet = NormalizationConfig::Preset(NormalizationPreset::Imagenet);
assert!(imagenet.mean_std(3).is_ok());
assert!(imagenet.mean_std(1).is_err());
}
#[test]
fn normalization_preset_parses_from_string() {
for (text, preset) in [
("unsigned", NormalizationPreset::Unsigned),
("signed", NormalizationPreset::Signed),
("raw", NormalizationPreset::Raw),
("imagenet", NormalizationPreset::Imagenet),
] {
let norm: NormalizationConfig =
serde_json::from_value(serde_json::json!(text)).unwrap();
assert_eq!(norm, NormalizationConfig::Preset(preset));
}
assert!(
serde_json::from_value::<NormalizationConfig>(serde_json::json!("zscore")).is_err()
);
}
#[test]
fn mean_std_broadcasts_and_rejects_bad_input() {
let one = NormalizationConfig::MeanStd {
mean: vec![127.5],
std: vec![127.5],
};
assert_eq!(one.mean_std(3).unwrap().0, vec![127.5; 3]);
let mismatched = NormalizationConfig::MeanStd {
mean: vec![0.0, 0.0],
std: vec![1.0; 3],
};
assert!(mismatched.mean_std(3).is_err());
let zero_std = NormalizationConfig::MeanStd {
mean: vec![0.0; 3],
std: vec![1.0, 0.0, 1.0],
};
assert!(zero_std.mean_std(3).is_err());
}
#[test]
fn validate_rejects_bad_preprocessing() {
let bad_pad = r#"{
"schema_version": 2,
"input": {"shape": [1, 3, 8, 8], "preprocessing": {
"normalization": {"mean": [0, 0, 0], "std": [1, 1, 1]},
"letterbox": {"pad": [0, 0]}
}}
}"#;
assert!(SchemaV2::parse_json(bad_pad).unwrap().validate().is_err());
let zero_std = r#"{
"schema_version": 2,
"input": {"shape": [1, 3, 8, 8], "preprocessing": {
"normalization": {"mean": [0], "std": [0]}
}}
}"#;
assert!(SchemaV2::parse_json(zero_std).unwrap().validate().is_err());
}
#[test]
fn non_finite_mean_is_rejected() {
for bad in [f32::NAN, f32::INFINITY] {
let norm = NormalizationConfig::MeanStd {
mean: vec![bad],
std: vec![1.0],
};
assert!(norm.mean_std(3).is_err(), "{bad}");
let mut schema =
SchemaV2::parse_json(r#"{"schema_version": 2, "input": {"shape": [1, 3, 8, 8]}}"#)
.unwrap();
schema.input.as_mut().unwrap().preprocessing = Some(Preprocessing {
normalization: Some(norm),
..Default::default()
});
assert!(schema.validate().is_err(), "{bad}");
}
}
fn validate_input(shape: &str, preprocessing: &str) -> DecoderResult<()> {
let j = format!(
r#"{{"schema_version": 2, "input": {{{shape}, "preprocessing": {preprocessing}}}}}"#
);
SchemaV2::parse_json(&j).unwrap().validate()
}
#[test]
fn scalar_coefficients_broadcast_with_per_channel_pad() {
let pre = r#"{"normalization": {"mean": [0], "std": [255]},
"letterbox": {"pad": [114, 114, 114]}}"#;
validate_input(r#""shape": [1, 3, 512, 512]"#, pre).unwrap();
validate_input(r#""shape": [1, 512, 512, 3]"#, pre).unwrap();
validate_input(r#""shape": [1, 3, 4, 3]"#, pre).unwrap();
}
#[test]
fn coefficients_must_match_input_channels() {
let two = r#"{"normalization": {"mean": [0, 0], "std": [1, 1]}}"#;
let nchw = r#""shape": [1, 3, 512, 512]"#;
let named = r#""shape": [1, 512, 512, 3],
"dshape": [{"batch": 1}, {"height": 512}, {"width": 512}, {"channels": 3}]"#;
assert!(validate_input(nchw, two).is_err());
assert!(validate_input(named, two).is_err());
let pad_two = r#"{"normalization": "unsigned", "letterbox": {"pad": [0, 0]}}"#;
assert!(validate_input(nchw, pad_two).is_err());
let imagenet = r#"{"normalization": "imagenet"}"#;
assert!(validate_input(r#""shape": [1, 1, 64, 64]"#, imagenet).is_err());
validate_input(nchw, imagenet).unwrap();
let mixed = r#"{"normalization": {"mean": [0, 0, 0], "std": [1, 1]}}"#;
assert!(validate_input(r#""shape": [1, 3, 4, 3]"#, mixed).is_err());
}
#[test]
fn input_channels_resolve_from_dshape_or_shape() {
let spec = |j: &str| serde_json::from_str::<InputSpec>(j).unwrap().channels();
assert_eq!(spec(r#"{"shape": [1, 3, 512, 512]}"#), Some(3));
assert_eq!(spec(r#"{"shape": [1, 512, 512, 4]}"#), Some(4));
assert_eq!(spec(r#"{"shape": [1, 3, 4, 3]}"#), None);
assert_eq!(spec(r#"{"shape": [1, 512, 512]}"#), None);
assert_eq!(
spec(
r#"{"shape": [1, 3, 4, 3],
"dshape": [{"batch": 1}, {"channels": 3}, {"height": 4}, {"width": 3}]}"#
),
Some(3)
);
}
#[test]
fn preprocessing_parses_from_yaml() {
let y = r#"
schema_version: 2
input:
shape: [1, 256, 256, 3]
preprocessing:
normalization: unsigned
resize: stretch
"#;
let schema = SchemaV2::parse_yaml(y).unwrap();
let pre = schema.input.unwrap().preprocessing.unwrap();
assert_eq!(
pre.normalization,
Some(NormalizationConfig::Preset(NormalizationPreset::Unsigned))
);
assert_eq!(pre.resize, Some(FitMode::Stretch));
assert!(pre.letterbox.is_none());
}
#[test]
fn class_map_masks_accept_int64() {
let j = r#"{
"schema_version": 2,
"outputs": [{
"name": "ArgMax", "type": "masks", "decoder": "semantic",
"shape": [1, 513, 513],
"dshape": [{"batch": 1}, {"height": 513}, {"width": 513}],
"dtype": "int64"
}]
}"#;
let schema = SchemaV2::parse_json(j).unwrap();
schema.validate().unwrap();
assert_eq!(schema.outputs[0].dtype, Some(DType::Int64));
assert_eq!(DType::Int64.size_bytes(), 8);
assert!(DType::Int64.is_integer());
}
#[test]
fn single_channel_threshold_and_background_parse() {
let j = r#"{
"schema_version": 2,
"outputs": [{
"name": "segment", "type": "segmentation", "decoder": "semantic",
"shape": [1, 256, 256, 1],
"dshape": [{"batch": 1}, {"height": 256}, {"width": 256}, {"num_classes": 1}],
"dtype": "float32", "activation_applied": "sigmoid",
"threshold": 0.5, "background_index": 0
}]
}"#;
let schema = SchemaV2::parse_json(j).unwrap();
let out = &schema.outputs[0];
assert_eq!(out.activation_applied, Some(Activation::Sigmoid));
assert_eq!(out.semantic.threshold, Some(0.5));
assert_eq!(out.semantic.background_index, Some(0));
}
#[test]
fn reserved_mask_classification_parses_but_does_not_lower() {
let j = r#"{
"schema_version": 2,
"outputs": [
{"name": "class_queries_logits", "type": "class_logits",
"shape": [1, 100, 151],
"dshape": [{"batch": 1}, {"num_queries": 100}, {"num_classes": 151}],
"dtype": "float32", "no_object_index": 150},
{"name": "masks_queries_logits", "type": "mask_logits",
"shape": [1, 100, 128, 128],
"dshape": [{"batch": 1}, {"num_queries": 100}, {"height": 128}, {"width": 128}],
"dtype": "float32", "stride": 4, "mask_activation": "sigmoid"}
]
}"#;
let schema = SchemaV2::parse_json(j).unwrap();
schema.validate().unwrap();
assert_eq!(schema.outputs[0].type_, Some(LogicalType::ClassLogits));
assert_eq!(schema.outputs[0].dshape[1].0, DimName::NumQueries);
assert_eq!(schema.outputs[0].semantic.no_object_index, Some(150));
assert_eq!(
schema.outputs[1].semantic.mask_activation,
Some(MaskActivation::Sigmoid)
);
assert!(matches!(
schema.to_legacy_config_outputs(),
Err(DecoderError::NotSupported(_))
));
}
#[test]
fn semantic_decoder_kind_shares_the_v1_code() {
assert_eq!(
DecoderKind::Semantic.to_v1(),
configs::DecoderType::Semantic
);
let v: configs::DecoderType = serde_json::from_str("\"semantic\"").unwrap();
assert_eq!(v, configs::DecoderType::Semantic);
}
}