use std::collections::BTreeMap;
use std::fmt;
use crate::configs::DimName;
use crate::schema::{
BoxEncoding, DType, DecoderKind, DecoderVersion, InputSpec, LogicalOutput, LogicalType,
NmsMode, Quantization, SchemaV2, ScoreFormat,
};
#[derive(Debug, Clone, PartialEq)]
pub struct TensorInfo {
pub name: String,
pub shape: Vec<usize>,
pub dtype: DType,
pub quantization: Option<Quantization>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelSource {
Onnx,
TfLite,
Other,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ModelSignals {
pub source: ModelSource,
pub inputs: Vec<TensorInfo>,
pub outputs: Vec<TensorInfo>,
pub metadata: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct InferredSchema {
pub schema: SchemaV2,
pub labels: Vec<String>,
pub description: String,
}
#[derive(Debug)]
pub enum InferError {
NotUltralytics(String),
BadNames(String),
BadMetadata(String),
UnsupportedTask(String),
ClassCountMismatch { expected: usize, found: usize },
UnsupportedLayout(String),
AmbiguousLayout(String),
UnsupportedQuantization(String),
UnknownBoxConvention,
}
impl fmt::Display for InferError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
InferError::NotUltralytics(s) => {
write!(f, "model metadata carries no Ultralytics signature: {s}")
}
InferError::BadNames(s) => {
write!(f, "cannot parse Ultralytics `names` metadata: {s}")
}
InferError::BadMetadata(s) => {
write!(f, "malformed Ultralytics metadata: {s}")
}
InferError::UnsupportedTask(s) => write!(
f,
"unsupported Ultralytics task `{s}` (supported: detect, segment)"
),
InferError::ClassCountMismatch { expected, found } => write!(
f,
"class-count mismatch: names give {expected} classes but output features imply {found}"
),
InferError::UnsupportedLayout(s) => write!(f, "unsupported output layout: {s}"),
InferError::AmbiguousLayout(s) => write!(f, "ambiguous output layout: {s}"),
InferError::UnsupportedQuantization(s) => {
write!(f, "unsupported quantization: {s}")
}
InferError::UnknownBoxConvention => write!(
f,
"box coordinate convention is unknown for this container; only \
onnx (pixel-space) and tflite ([0,1]) are characterized"
),
}
}
}
impl std::error::Error for InferError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Task {
Detect,
Segment,
}
impl Task {
fn parse(s: &str) -> Result<Self, InferError> {
match s.trim().to_ascii_lowercase().as_str() {
"detect" => Ok(Task::Detect),
"segment" => Ok(Task::Segment),
other => Err(InferError::UnsupportedTask(other.to_string())),
}
}
}
#[derive(Debug, Clone, PartialEq)]
struct UltralyticsMeta {
names: Vec<String>,
task: Option<Task>,
end2end: Option<bool>,
}
fn reject_foreign_vendor(author: Option<&String>, docs: Option<&String>) -> Result<(), InferError> {
let names_ultralytics = |v: &String| v.to_ascii_lowercase().contains("ultralytics");
for (field, value) in [("author", author), ("docs", docs)] {
if let Some(v) = value {
if !v.trim().is_empty() && !names_ultralytics(v) {
return Err(InferError::NotUltralytics(format!(
"metadata `{field}` is `{v}`, not Ultralytics"
)));
}
}
}
Ok(())
}
impl UltralyticsMeta {
fn from_metadata(meta: &BTreeMap<String, String>) -> Result<Self, InferError> {
if let Some(names_raw) = meta.get("names") {
reject_foreign_vendor(meta.get("author"), meta.get("docs"))?;
return Self::from_flat_props(meta, names_raw);
}
for value in meta.values() {
if let Ok(serde_json::Value::Object(obj)) = serde_json::from_str(value) {
if let Some(names) = obj.get("names") {
let author = obj
.get("author")
.and_then(|v| v.as_str())
.map(str::to_string);
let docs = obj.get("docs").and_then(|v| v.as_str()).map(str::to_string);
reject_foreign_vendor(author.as_ref(), docs.as_ref())?;
return Self::from_json_envelope(&obj, names);
}
}
}
Err(InferError::NotUltralytics(
"no `names` metadata field or metadata.json envelope found".into(),
))
}
fn from_flat_props(
meta: &BTreeMap<String, String>,
names_raw: &str,
) -> Result<Self, InferError> {
let names = parse_names(names_raw)?;
let task = meta.get("task").map(|t| Task::parse(t)).transpose()?;
let end2end = meta.get("end2end").map(|s| parse_bool_str(s)).transpose()?;
Ok(UltralyticsMeta {
names,
task,
end2end,
})
}
fn from_json_envelope(
obj: &serde_json::Map<String, serde_json::Value>,
names_value: &serde_json::Value,
) -> Result<Self, InferError> {
let names = names_from_json_value(names_value)?;
let task = obj
.get("task")
.and_then(|v| v.as_str())
.map(Task::parse)
.transpose()?;
let end2end = match obj.get("end2end") {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::Bool(b)) => Some(*b),
Some(other) => {
return Err(InferError::BadMetadata(format!(
"`end2end` is {other}, expected a boolean"
)))
}
};
Ok(UltralyticsMeta {
names,
task,
end2end,
})
}
}
fn parse_bool_str(s: &str) -> Result<bool, InferError> {
match s.trim().to_ascii_lowercase().as_str() {
"true" => Ok(true),
"false" => Ok(false),
other => Err(InferError::BadMetadata(format!(
"`end2end` is `{other}`, expected true or false"
))),
}
}
fn parse_names(s: &str) -> Result<Vec<String>, InferError> {
let trimmed = s.trim();
if let Ok(map) = serde_json::from_str::<BTreeMap<String, String>>(trimmed) {
return names_from_str_keyed_map(map);
}
let body = trimmed
.strip_prefix('{')
.and_then(|s| s.strip_suffix('}'))
.ok_or_else(|| InferError::BadNames(format!("not a dict: {trimmed}")))?;
let mut indexed: BTreeMap<usize, String> = BTreeMap::new();
for item in split_top_level_commas(body) {
let item = item.trim();
if item.is_empty() {
continue;
}
let (key, value) = item
.split_once(':')
.ok_or_else(|| InferError::BadNames(format!("malformed entry `{item}`")))?;
let idx: usize = key
.trim()
.parse()
.map_err(|_| InferError::BadNames(format!("non-numeric key `{key}`")))?;
let value = unquote(value.trim())
.ok_or_else(|| InferError::BadNames(format!("unquoted value `{value}`")))?;
indexed.insert(idx, value);
}
contiguous_values(indexed)
}
fn names_from_json_value(v: &serde_json::Value) -> Result<Vec<String>, InferError> {
let obj = v
.as_object()
.ok_or_else(|| InferError::BadNames("`names` is not a JSON object".into()))?;
let mut map = BTreeMap::new();
for (k, val) in obj {
let s = val
.as_str()
.ok_or_else(|| InferError::BadNames(format!("non-string name for key `{k}`")))?;
map.insert(k.clone(), s.to_string());
}
names_from_str_keyed_map(map)
}
fn names_from_str_keyed_map(map: BTreeMap<String, String>) -> Result<Vec<String>, InferError> {
let mut indexed: BTreeMap<usize, String> = BTreeMap::new();
for (k, v) in map {
let idx: usize = k
.parse()
.map_err(|_| InferError::BadNames(format!("non-numeric key `{k}`")))?;
indexed.insert(idx, v);
}
contiguous_values(indexed)
}
fn contiguous_values(indexed: BTreeMap<usize, String>) -> Result<Vec<String>, InferError> {
if indexed.is_empty() {
return Err(InferError::BadNames("class map is empty".into()));
}
let n = indexed.len();
for i in 0..n {
if !indexed.contains_key(&i) {
return Err(InferError::BadNames("non-contiguous class indices".into()));
}
}
Ok(indexed.into_values().collect())
}
fn split_top_level_commas(s: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
for c in s.chars() {
match quote {
Some(q) => {
current.push(c);
if c == q {
quote = None;
}
}
None => match c {
'\'' | '"' => {
quote = Some(c);
current.push(c);
}
',' => parts.push(std::mem::take(&mut current)),
_ => current.push(c),
},
}
}
if !current.is_empty() {
parts.push(current);
}
parts
}
fn unquote(s: &str) -> Option<String> {
let bytes = s.as_bytes();
if s.len() >= 2 {
let first = bytes[0];
let last = bytes[s.len() - 1];
if (first == b'\'' && last == b'\'') || (first == b'"' && last == b'"') {
return Some(s[1..s.len() - 1].to_string());
}
}
None
}
#[allow(clippy::type_complexity)]
fn classify_input_layout(
input: &TensorInfo,
) -> Result<(usize, usize, Vec<(DimName, usize)>), InferError> {
debug_assert_eq!(
input.shape.len(),
4,
"caller selects the input by rank-4; see infer_ultralytics_schema"
);
if input.shape[0] != 1 {
return Err(InferError::UnsupportedLayout(format!(
"model input `{}` has batch {} but every supported output layout \
is batch-1; shape {:?}",
input.name, input.shape[0], input.shape
)));
}
if input.shape[1] == 3 {
let (h, w) = (input.shape[2], input.shape[3]);
Ok((
h,
w,
vec![
(DimName::Batch, input.shape[0]),
(DimName::NumFeatures, 3),
(DimName::Height, h),
(DimName::Width, w),
],
))
} else if input.shape[3] == 3 {
let (h, w) = (input.shape[1], input.shape[2]);
Ok((
h,
w,
vec![
(DimName::Batch, input.shape[0]),
(DimName::Height, h),
(DimName::Width, w),
(DimName::NumFeatures, 3),
],
))
} else {
Err(InferError::UnsupportedLayout(format!(
"cannot locate the channel dimension (=3) in input shape {:?}",
input.shape
)))
}
}
fn find_anchors_dim(shape: &[usize], expected_anchors: usize) -> Option<(usize, usize)> {
if shape.len() != 3 || shape[0] != 1 {
return None;
}
if shape[2] == expected_anchors {
Some((shape[1], 1)) } else if shape[1] == expected_anchors {
Some((shape[2], 2)) } else {
None
}
}
#[allow(clippy::type_complexity)]
fn classify_pre_nms<'a>(
rank3_outputs: &[&'a TensorInfo],
expected_anchors: usize,
feat: usize,
nc: usize,
) -> Result<(&'a TensorInfo, Vec<(DimName, usize)>), InferError> {
let mut mismatch: Option<InferError> = None;
let mut matched: Option<(&'a TensorInfo, Vec<(DimName, usize)>)> = None;
for out in rank3_outputs {
let Some((other_dim, feat_idx)) = find_anchors_dim(&out.shape, expected_anchors) else {
continue;
};
if other_dim == feat {
let dshape = if feat_idx == 1 {
vec![
(DimName::Batch, 1),
(DimName::NumFeatures, feat),
(DimName::NumBoxes, expected_anchors),
]
} else {
vec![
(DimName::Batch, 1),
(DimName::NumBoxes, expected_anchors),
(DimName::NumFeatures, feat),
]
};
if let Some((first, _)) = &matched {
return Err(InferError::AmbiguousLayout(format!(
"outputs `{}` and `{}` both match the pre-NMS detection layout \
({:?}); cannot tell which carries the detections",
first.name, out.name, out.shape
)));
}
matched = Some((out, dshape));
continue;
}
mismatch.get_or_insert_with(|| InferError::ClassCountMismatch {
expected: nc,
found: other_dim.saturating_sub(feat.saturating_sub(nc)),
});
}
if let Some(found) = matched {
return Ok(found);
}
Err(mismatch.unwrap_or_else(|| {
InferError::UnsupportedLayout(format!(
"no output matches the Ultralytics pre-NMS layout (expected a dim == \
{expected_anchors} anchors and a dim == {feat} features [4 + {nc} classes]); \
outputs seen: {:?}",
rank3_outputs.iter().map(|o| &o.shape).collect::<Vec<_>>()
))
}))
}
fn find_e2e_candidate<'a>(
rank3_outputs: &[&'a TensorInfo],
expected_anchors: usize,
feat: usize,
) -> Result<Option<(&'a TensorInfo, usize)>, InferError> {
let matches: Vec<(&'a TensorInfo, usize)> = rank3_outputs
.iter()
.filter_map(|o| {
let s = &o.shape;
debug_assert_eq!(s.len(), 3, "caller filters to rank-3 outputs");
(s[0] == 1 && s[2] == feat && s[1] > 0 && s[1] <= expected_anchors)
.then_some((*o, s[1]))
})
.collect();
if matches.len() > 1 {
return Err(InferError::AmbiguousLayout(format!(
"{} outputs match the end-to-end layout [1, N, {feat}]: {:?}; \
cannot tell which carries the detections",
matches.len(),
matches.iter().map(|(o, _)| &o.name).collect::<Vec<_>>()
)));
}
Ok(matches.into_iter().next())
}
fn classify_proto(
proto: &TensorInfo,
h: usize,
w: usize,
) -> Result<(usize, Vec<(DimName, usize)>), InferError> {
let (eh, ew) = (h / 4, w / 4);
let s = &proto.shape;
if s.len() != 4 || s[0] != 1 {
return Err(InferError::UnsupportedLayout(format!(
"expected a rank-4 proto tensor with batch 1, found shape {s:?}"
)));
}
if s[2] == eh && s[3] == ew {
let k = s[1];
Ok((
k,
vec![
(DimName::Batch, 1),
(DimName::NumProtos, k),
(DimName::Height, eh),
(DimName::Width, ew),
],
))
} else if s[1] == eh && s[2] == ew {
let k = s[3];
Ok((
k,
vec![
(DimName::Batch, 1),
(DimName::Height, eh),
(DimName::Width, ew),
(DimName::NumProtos, k),
],
))
} else {
Err(InferError::UnsupportedLayout(format!(
"proto tensor shape {s:?} doesn't match the expected ({eh}, {ew}) spatial dims"
)))
}
}
fn default_logical() -> LogicalOutput {
LogicalOutput {
name: None,
type_: None,
shape: Vec::new(),
dshape: Vec::new(),
decoder: None,
encoding: None,
score_format: None,
normalized: None,
anchors: None,
stride: None,
dtype: None,
quantization: None,
outputs: Vec::new(),
activation_applied: None,
activation_required: None,
}
}
fn check_quantized_boundary(tensor: &TensorInfo) -> Result<(), InferError> {
if tensor.dtype.is_integer() && tensor.quantization.is_none() {
return Err(InferError::UnsupportedLayout(format!(
"output `{}` has integer dtype {:?} but no quantization params",
tensor.name, tensor.dtype
)));
}
if let Some(q) = &tensor.quantization {
if q.scale.len() > 1 {
return Err(InferError::UnsupportedQuantization(format!(
"output `{}` carries per-channel quantization ({} scales); \
the decoder supports per-tensor quantization only",
tensor.name,
q.scale.len()
)));
}
if q.scale.is_empty() {
return Err(InferError::UnsupportedQuantization(format!(
"output `{}` is quantized but carries no scale",
tensor.name
)));
}
if let Some(zp) = &q.zero_point {
if zp.len() != q.scale.len() {
return Err(InferError::UnsupportedQuantization(format!(
"output `{}` has {} scale(s) but {} zero point(s)",
tensor.name,
q.scale.len(),
zp.len()
)));
}
}
}
Ok(())
}
pub fn infer_ultralytics_schema(signals: &ModelSignals) -> Result<InferredSchema, InferError> {
let meta = UltralyticsMeta::from_metadata(&signals.metadata)?;
let proto = signals.outputs.iter().find(|o| o.shape.len() == 4);
match (meta.task, proto.is_some()) {
(Some(Task::Segment), false) => {
return Err(InferError::UnsupportedLayout(
"segment task but no proto tensor".into(),
));
}
(Some(Task::Detect), true) => {
return Err(InferError::UnsupportedLayout(
"detect task but proto tensor present".into(),
));
}
_ => {}
}
let is_segment = meta.task == Some(Task::Segment) || proto.is_some();
let input = signals
.inputs
.iter()
.find(|t| t.shape.len() == 4)
.ok_or_else(|| {
InferError::UnsupportedLayout(format!(
"no rank-4 model input found; inputs seen: {:?}",
signals.inputs.iter().map(|t| &t.shape).collect::<Vec<_>>()
))
})?;
let (h, w, input_dshape) = classify_input_layout(input)?;
let expected_anchors = (h / 8) * (w / 8) + (h / 16) * (w / 16) + (h / 32) * (w / 32);
let nc = meta.names.len();
let (k, proto_dshape) = match proto {
Some(proto) => {
let (k, dshape) = classify_proto(proto, h, w)?;
(k, Some(dshape))
}
None => (0, None),
};
let feat = 4 + nc + k;
let e2e_feat = 6 + k;
let rank3_outputs: Vec<&TensorInfo> = signals
.outputs
.iter()
.filter(|o| o.shape.len() == 3)
.collect();
let (out, dshape, version) = match meta.end2end {
Some(true) => {
let (out, n) = find_e2e_candidate(&rank3_outputs, expected_anchors, e2e_feat)?
.ok_or_else(|| {
InferError::UnsupportedLayout(format!(
"metadata declares end2end=true but no output matches the YOLO26 \
end-to-end layout [1, N<={expected_anchors}, {e2e_feat}]; \
outputs seen: {:?}",
rank3_outputs.iter().map(|o| &o.shape).collect::<Vec<_>>()
))
})?;
(
out,
vec![
(DimName::Batch, 1),
(DimName::NumBoxes, n),
(DimName::NumFeatures, e2e_feat),
],
DecoderVersion::Yolo26,
)
}
Some(false) => {
let (out, dshape) = classify_pre_nms(&rank3_outputs, expected_anchors, feat, nc)?;
(out, dshape, DecoderVersion::Yolov8)
}
None => match classify_pre_nms(&rank3_outputs, expected_anchors, feat, nc) {
Ok((out, dshape)) => (out, dshape, DecoderVersion::Yolov8),
Err(err @ InferError::ClassCountMismatch { .. }) => return Err(err),
Err(_) => {
let (out, n) = find_e2e_candidate(&rank3_outputs, expected_anchors, e2e_feat)?
.ok_or_else(|| {
InferError::UnsupportedLayout(format!(
"no output matches a known Ultralytics detection layout \
(pre-NMS or end-to-end); outputs seen: {:?}",
rank3_outputs.iter().map(|o| &o.shape).collect::<Vec<_>>()
))
})?;
(
out,
vec![
(DimName::Batch, 1),
(DimName::NumBoxes, n),
(DimName::NumFeatures, e2e_feat),
],
DecoderVersion::Yolo26,
)
}
},
};
check_quantized_boundary(out)?;
if let Some(proto) = proto {
check_quantized_boundary(proto)?;
}
let normalized = match signals.source {
ModelSource::TfLite => true,
ModelSource::Onnx => false,
ModelSource::Other => {
return Err(InferError::UnknownBoxConvention);
}
};
let det = LogicalOutput {
name: Some(out.name.clone()),
type_: Some(match version {
DecoderVersion::Yolo26 => LogicalType::Detections,
_ => LogicalType::Detection,
}),
shape: out.shape.clone(),
dshape,
decoder: Some(DecoderKind::Ultralytics),
encoding: Some(BoxEncoding::Direct), score_format: Some(ScoreFormat::PerClass),
normalized: Some(normalized),
anchors: None,
stride: None,
dtype: Some(out.dtype),
quantization: out.quantization.clone(),
outputs: Vec::new(),
activation_applied: None,
activation_required: None,
};
let mut outputs = vec![det];
if let (Some(proto), Some(proto_dshape)) = (proto, proto_dshape) {
outputs.push(LogicalOutput {
name: Some(proto.name.clone()),
type_: Some(LogicalType::Protos),
shape: proto.shape.clone(),
dshape: proto_dshape,
decoder: Some(DecoderKind::Ultralytics),
dtype: Some(proto.dtype),
quantization: proto.quantization.clone(),
..default_logical()
});
}
let schema = SchemaV2 {
schema_version: 2,
input: Some(InputSpec {
shape: input.shape.clone(),
dshape: input_dshape,
cameraadaptor: Some("rgb".into()),
}),
outputs,
nms: match version {
DecoderVersion::Yolo26 => None,
_ => Some(NmsMode::ClassAware),
},
decoder_version: Some(version),
};
let version_label = if version == DecoderVersion::Yolo26 {
"YOLO26"
} else {
"YOLOv8/11"
};
let task_label = if is_segment { "segment" } else { "detect" };
let description = format!("Ultralytics {version_label} {task_label}, {nc} classes");
Ok(InferredSchema {
schema,
labels: meta.names,
description,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_names_python_dict_repr() {
let s = "{0: 'person', 1: 'bicycle', 2: \"fire hydrant\"}";
assert_eq!(
parse_names(s).unwrap(),
vec!["person", "bicycle", "fire hydrant"]
);
}
#[test]
fn parse_names_json_object() {
let s = r#"{"0": "person", "1": "bicycle"}"#;
assert_eq!(parse_names(s).unwrap(), vec!["person", "bicycle"]);
}
#[test]
fn parse_names_orders_by_index_not_insertion() {
let s = "{1: 'b', 0: 'a'}";
assert_eq!(parse_names(s).unwrap(), vec!["a", "b"]);
}
#[test]
fn parse_names_rejects_garbage() {
assert!(matches!(
parse_names("not a dict"),
Err(InferError::BadNames(_))
));
}
#[test]
fn parse_names_rejects_non_contiguous_indices() {
match parse_names("{0: 'a', 2: 'b'}") {
Err(InferError::BadNames(msg)) => {
assert!(
msg.contains("non-contiguous"),
"unexpected BadNames message: {msg}"
);
}
other => panic!("expected BadNames, got {other:?}"),
}
match parse_names(r#"{"0": "a", "2": "b"}"#) {
Err(InferError::BadNames(msg)) => {
assert!(
msg.contains("non-contiguous"),
"unexpected BadNames message: {msg}"
);
}
other => panic!("expected BadNames, got {other:?}"),
}
}
#[test]
fn meta_from_onnx_props() {
let mut m = BTreeMap::new();
m.insert("names".into(), "{0: 'person'}".into());
m.insert("task".into(), "detect".into());
let u = UltralyticsMeta::from_metadata(&m).unwrap();
assert_eq!(u.names, vec!["person"]);
assert_eq!(u.task, Some(Task::Detect));
}
#[test]
fn meta_task_pose_rejected() {
let mut m = BTreeMap::new();
m.insert("names".into(), "{0: 'person'}".into());
m.insert("task".into(), "pose".into());
assert!(matches!(
UltralyticsMeta::from_metadata(&m),
Err(InferError::UnsupportedTask(_))
));
}
#[test]
fn meta_missing_names_is_not_ultralytics() {
assert!(matches!(
UltralyticsMeta::from_metadata(&BTreeMap::new()),
Err(InferError::NotUltralytics(_))
));
}
#[test]
fn meta_end2end_true_or_false_from_onnx_flat_props() {
let mut m = BTreeMap::new();
m.insert("names".into(), "{0: 'person'}".into());
m.insert("end2end".into(), "True".into());
assert_eq!(
UltralyticsMeta::from_metadata(&m).unwrap().end2end,
Some(true)
);
m.insert("end2end".into(), "False".into());
assert_eq!(
UltralyticsMeta::from_metadata(&m).unwrap().end2end,
Some(false)
);
}
fn load_fixture_metadata(name: &str) -> BTreeMap<String, String> {
let path = format!(
"{}/testdata/infer/{name}.signals.json",
env!("CARGO_MANIFEST_DIR")
);
let content = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read fixture {path}: {e}"));
let json: serde_json::Value =
serde_json::from_str(&content).expect("fixture is valid JSON");
json["metadata"]
.as_object()
.expect("fixture has a metadata object")
.iter()
.map(|(k, v)| {
(
k.clone(),
v.as_str().expect("metadata value is a string").to_string(),
)
})
.collect()
}
#[test]
fn meta_from_real_tflite_metadata_json_envelope() {
let meta = load_fixture_metadata("yolov8n_float32");
let u = UltralyticsMeta::from_metadata(&meta).unwrap();
assert_eq!(u.names.len(), 80);
assert_eq!(u.task, Some(Task::Detect));
}
#[test]
fn meta_end2end_true_on_real_yolo26_tflite_fixture() {
let meta = load_fixture_metadata("yolo26n_float32");
let u = UltralyticsMeta::from_metadata(&meta).unwrap();
assert_eq!(u.end2end, Some(true));
}
#[test]
fn meta_end2end_false_on_real_yolov8_onnx_fixture() {
let meta = load_fixture_metadata("yolov8n");
let u = UltralyticsMeta::from_metadata(&meta).unwrap();
assert_eq!(u.end2end, Some(false));
}
fn signals_from_fixture(name: &str) -> ModelSignals {
let path = format!(
"{}/testdata/infer/{name}.signals.json",
env!("CARGO_MANIFEST_DIR")
);
let content = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read fixture {path}: {e}"));
let json: serde_json::Value =
serde_json::from_str(&content).expect("fixture is valid JSON");
let source = match json["source"]
.as_str()
.expect("fixture `source` is a string")
{
"onnx" => ModelSource::Onnx,
"tflite" => ModelSource::TfLite,
other => panic!("fixture {name}: unknown source `{other}`"),
};
fn parse_dtype(s: &str) -> DType {
match s {
"float32" => DType::Float32,
"float16" => DType::Float16,
"int8" => DType::Int8,
"uint8" => DType::Uint8,
"int16" => DType::Int16,
"uint16" => DType::Uint16,
"int32" => DType::Int32,
"uint32" => DType::Uint32,
other => panic!("unknown dtype `{other}`"),
}
}
fn parse_tensor(v: &serde_json::Value) -> TensorInfo {
TensorInfo {
name: v["name"]
.as_str()
.expect("tensor `name` is a string")
.to_string(),
shape: v["shape"]
.as_array()
.expect("tensor `shape` is an array")
.iter()
.map(|d| d.as_u64().expect("shape dim is a number") as usize)
.collect(),
dtype: parse_dtype(v["dtype"].as_str().expect("tensor `dtype` is a string")),
quantization: None,
}
}
let inputs = json["inputs"]
.as_array()
.expect("fixture `inputs` is an array")
.iter()
.map(parse_tensor)
.collect();
let outputs = json["outputs"]
.as_array()
.expect("fixture `outputs` is an array")
.iter()
.map(parse_tensor)
.collect();
let metadata = json["metadata"]
.as_object()
.expect("fixture `metadata` is an object")
.iter()
.map(|(k, v)| {
(
k.clone(),
v.as_str().expect("metadata value is a string").to_string(),
)
})
.collect();
ModelSignals {
source,
inputs,
outputs,
metadata,
}
}
fn synthetic_metadata(nc: usize, task: &str, end2end: &str) -> BTreeMap<String, String> {
let names = (0..nc)
.map(|i| format!("{i}: 'c{i}'"))
.collect::<Vec<_>>()
.join(", ");
let mut m = BTreeMap::new();
m.insert("names".into(), format!("{{{names}}}"));
m.insert("task".into(), task.into());
m.insert("end2end".into(), end2end.into());
m
}
#[test]
fn infer_transposed_det_layout_classified() {
let s = ModelSignals {
source: ModelSource::TfLite,
inputs: vec![TensorInfo {
name: "images".into(),
shape: vec![1, 640, 640, 3],
dtype: DType::Float32,
quantization: None,
}],
outputs: vec![TensorInfo {
name: "output0".into(),
shape: vec![1, 8400, 84],
dtype: DType::Float32,
quantization: None,
}],
metadata: synthetic_metadata(80, "detect", "False"),
};
let r = infer_ultralytics_schema(&s).unwrap();
let o = &r.schema.outputs[0];
assert_eq!(
o.dshape,
vec![
(DimName::Batch, 1),
(DimName::NumBoxes, 8400),
(DimName::NumFeatures, 84),
]
);
assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
}
#[test]
fn infer_yolo26n_onnx_end_to_end() {
let s = signals_from_fixture("yolo26n");
let r = infer_ultralytics_schema(&s).unwrap();
assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
assert_eq!(r.schema.outputs[0].shape[2], 6);
}
#[test]
fn infer_class_count_mismatch_rejected() {
let mut s = signals_from_fixture("yolov8n");
s.metadata.insert("names".into(), "{0: 'only-one'}".into()); assert!(matches!(
infer_ultralytics_schema(&s),
Err(InferError::ClassCountMismatch {
expected: 1,
found: 80
})
));
}
#[test]
fn infer_seg_class_count_mismatch_rejected() {
let mut s = signals_from_fixture("yolov8n-seg");
s.metadata.insert("names".into(), "{0: 'only-one'}".into()); assert!(matches!(
infer_ultralytics_schema(&s),
Err(InferError::ClassCountMismatch {
expected: 1,
found: 80
})
));
}
#[test]
fn infer_end2end_flag_shape_conflict_rejected() {
let mut s = signals_from_fixture("yolov8n");
s.metadata.insert("end2end".into(), "True".into());
assert!(matches!(
infer_ultralytics_schema(&s),
Err(InferError::UnsupportedLayout(_))
));
}
#[test]
fn infer_yolov8n_seg_onnx() {
let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n-seg")).unwrap();
assert_eq!(r.schema.outputs.len(), 2);
let proto = r
.schema
.outputs
.iter()
.find(|o| o.type_ == Some(LogicalType::Protos))
.unwrap();
assert_eq!(proto.dshape[1].0, DimName::NumProtos); let det = r
.schema
.outputs
.iter()
.find(|o| o.type_ == Some(LogicalType::Detection))
.unwrap();
assert!(det.shape.contains(&116)); }
#[test]
fn infer_yolov8n_seg_tflite_proto_nchw() {
let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n-seg_float32")).unwrap();
let proto = r
.schema
.outputs
.iter()
.find(|o| o.type_ == Some(LogicalType::Protos))
.unwrap();
assert_eq!(proto.dshape[1].0, DimName::NumProtos); assert_eq!(proto.dshape[1].1, 32);
}
#[test]
fn infer_yolo26n_seg_end_to_end() {
let r = infer_ultralytics_schema(&signals_from_fixture("yolo26n-seg")).unwrap();
assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
assert_eq!(r.schema.outputs.len(), 2);
}
#[test]
fn infer_nhwc_proto_layout_classified() {
let s = ModelSignals {
source: ModelSource::TfLite,
inputs: vec![TensorInfo {
name: "images".into(),
shape: vec![1, 640, 640, 3],
dtype: DType::Float32,
quantization: None,
}],
outputs: vec![
TensorInfo {
name: "output0".into(),
shape: vec![1, 116, 8400],
dtype: DType::Float32,
quantization: None,
},
TensorInfo {
name: "output1".into(),
shape: vec![1, 160, 160, 32],
dtype: DType::Float32,
quantization: None,
},
],
metadata: synthetic_metadata(80, "segment", "False"),
};
let r = infer_ultralytics_schema(&s).unwrap();
let proto = r
.schema
.outputs
.iter()
.find(|o| o.type_ == Some(LogicalType::Protos))
.unwrap();
assert_eq!(proto.dshape[3].0, DimName::NumProtos); assert_eq!(proto.dshape[3].1, 32);
}
#[test]
fn infer_segment_task_without_proto_rejected() {
let s = ModelSignals {
source: ModelSource::Onnx,
inputs: vec![TensorInfo {
name: "images".into(),
shape: vec![1, 3, 640, 640],
dtype: DType::Float32,
quantization: None,
}],
outputs: vec![TensorInfo {
name: "output0".into(),
shape: vec![1, 84, 8400],
dtype: DType::Float32,
quantization: None,
}],
metadata: synthetic_metadata(80, "segment", "False"),
};
assert!(matches!(
infer_ultralytics_schema(&s),
Err(InferError::UnsupportedLayout(_))
));
}
#[test]
fn infer_detect_task_with_proto_rejected() {
let s = ModelSignals {
source: ModelSource::Onnx,
inputs: vec![TensorInfo {
name: "images".into(),
shape: vec![1, 3, 640, 640],
dtype: DType::Float32,
quantization: None,
}],
outputs: vec![
TensorInfo {
name: "output0".into(),
shape: vec![1, 84, 8400],
dtype: DType::Float32,
quantization: None,
},
TensorInfo {
name: "output1".into(),
shape: vec![1, 32, 160, 160],
dtype: DType::Float32,
quantization: None,
},
],
metadata: synthetic_metadata(80, "detect", "False"),
};
assert!(matches!(
infer_ultralytics_schema(&s),
Err(InferError::UnsupportedLayout(_))
));
}
#[test]
fn infer_int8_tflite_classifies_like_float32() {
let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n_int8")).unwrap();
let det = &r.schema.outputs[0];
assert_eq!(det.type_, Some(LogicalType::Detection));
assert_eq!(det.dtype, Some(DType::Float32));
assert_eq!(det.quantization, None);
assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
assert_eq!(det.normalized, Some(true));
}
#[test]
fn infer_quantized_boundary_carries_quantization() {
let quant = Quantization {
scale: vec![0.02],
zero_point: Some(vec![-5]),
axis: None,
dtype: Some(DType::Int8),
};
let s = ModelSignals {
source: ModelSource::TfLite,
inputs: vec![TensorInfo {
name: "images".into(),
shape: vec![1, 640, 640, 3],
dtype: DType::Float32,
quantization: None,
}],
outputs: vec![TensorInfo {
name: "output0".into(),
shape: vec![1, 84, 8400],
dtype: DType::Int8,
quantization: Some(quant.clone()),
}],
metadata: synthetic_metadata(80, "detect", "False"),
};
let r = infer_ultralytics_schema(&s).unwrap();
let det = &r.schema.outputs[0];
assert_eq!(det.dtype, Some(DType::Int8));
assert_eq!(det.quantization, Some(quant));
}
#[test]
fn infer_rejects_batched_input() {
let s = ModelSignals {
source: ModelSource::Onnx,
inputs: vec![TensorInfo {
name: "images".into(),
shape: vec![4, 3, 640, 640],
dtype: DType::Float32,
quantization: None,
}],
outputs: vec![TensorInfo {
name: "output0".into(),
shape: vec![1, 84, 8400],
dtype: DType::Float32,
quantization: None,
}],
metadata: synthetic_metadata(80, "detect", "False"),
};
match infer_ultralytics_schema(&s) {
Err(InferError::UnsupportedLayout(msg)) => {
assert!(msg.contains("batch 4"), "unexpected message: {msg}");
}
other => panic!("expected UnsupportedLayout, got {other:?}"),
}
}
#[test]
fn infer_rejects_input_without_a_channel_dim() {
let s = ModelSignals {
source: ModelSource::Onnx,
inputs: vec![TensorInfo {
name: "images".into(),
shape: vec![1, 4, 640, 640],
dtype: DType::Float32,
quantization: None,
}],
outputs: vec![TensorInfo {
name: "output0".into(),
shape: vec![1, 84, 8400],
dtype: DType::Float32,
quantization: None,
}],
metadata: synthetic_metadata(80, "detect", "False"),
};
match infer_ultralytics_schema(&s) {
Err(InferError::UnsupportedLayout(msg)) => {
assert!(msg.contains("channel dimension"), "unexpected: {msg}");
}
other => panic!("expected UnsupportedLayout, got {other:?}"),
}
}
#[test]
fn infer_rejects_model_with_no_rank4_input() {
let s = ModelSignals {
source: ModelSource::Onnx,
inputs: vec![TensorInfo {
name: "images".into(),
shape: vec![1, 3],
dtype: DType::Float32,
quantization: None,
}],
outputs: vec![TensorInfo {
name: "output0".into(),
shape: vec![1, 84, 8400],
dtype: DType::Float32,
quantization: None,
}],
metadata: synthetic_metadata(80, "detect", "False"),
};
match infer_ultralytics_schema(&s) {
Err(InferError::UnsupportedLayout(msg)) => {
assert!(msg.contains("no rank-4 model input"), "unexpected: {msg}");
}
other => panic!("expected UnsupportedLayout, got {other:?}"),
}
}
fn det_signals(nc: usize, out_shape: Vec<usize>, end2end: &str) -> ModelSignals {
ModelSignals {
source: ModelSource::Onnx,
inputs: vec![TensorInfo {
name: "images".into(),
shape: vec![1, 3, 640, 640],
dtype: DType::Float32,
quantization: None,
}],
outputs: vec![TensorInfo {
name: "output0".into(),
shape: out_shape,
dtype: DType::Float32,
quantization: None,
}],
metadata: synthetic_metadata(nc, "detect", end2end),
}
}
#[test]
fn infer_end_to_end_accepts_any_max_det_up_to_the_anchor_count() {
for n in [1, 300, 2000, 8400] {
let r = infer_ultralytics_schema(&det_signals(80, vec![1, n, 6], "True"))
.unwrap_or_else(|e| panic!("max_det={n} must infer: {e}"));
assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
assert_eq!(r.schema.outputs[0].type_, Some(LogicalType::Detections));
assert_eq!(
r.schema.outputs[0].dshape,
vec![
(DimName::Batch, 1),
(DimName::NumBoxes, n),
(DimName::NumFeatures, 6),
],
"max_det={n}"
);
assert_eq!(r.schema.nms, None, "max_det={n}");
}
}
#[test]
fn infer_end_to_end_rejects_more_rows_than_anchors() {
match infer_ultralytics_schema(&det_signals(80, vec![1, 8401, 6], "True")) {
Err(InferError::UnsupportedLayout(msg)) => {
assert!(msg.contains("N<=8400"), "unexpected message: {msg}");
}
other => panic!("expected UnsupportedLayout, got {other:?}"),
}
}
#[test]
fn infer_legacy_nms_model_is_unaffected_by_max_det() {
let r = infer_ultralytics_schema(&det_signals(80, vec![1, 84, 8400], "False")).unwrap();
assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
assert_eq!(r.schema.outputs[0].type_, Some(LogicalType::Detection));
assert_eq!(
r.schema.outputs[0].dshape,
vec![
(DimName::Batch, 1),
(DimName::NumFeatures, 84),
(DimName::NumBoxes, 8400),
]
);
assert_eq!(r.schema.nms, Some(NmsMode::ClassAware));
let t = infer_ultralytics_schema(&det_signals(80, vec![1, 8400, 84], "False")).unwrap();
assert_eq!(t.schema.decoder_version, Some(DecoderVersion::Yolov8));
assert_eq!(
t.schema.outputs[0].dshape,
vec![
(DimName::Batch, 1),
(DimName::NumBoxes, 8400),
(DimName::NumFeatures, 84),
]
);
}
#[test]
fn infer_pre_nms_wins_when_a_shape_reads_as_both() {
let mut m = synthetic_metadata(2, "detect", "False");
m.remove("end2end");
let s = ModelSignals {
metadata: m,
..det_signals(2, vec![1, 8400, 6], "False")
};
let r = infer_ultralytics_schema(&s).unwrap();
assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
}
#[test]
fn meta_empty_names_rejected() {
let mut m = BTreeMap::new();
m.insert("names".into(), "{}".into());
match UltralyticsMeta::from_metadata(&m) {
Err(InferError::BadNames(msg)) => assert!(msg.contains("empty"), "{msg}"),
other => panic!("expected BadNames, got {other:?}"),
}
}
#[test]
fn meta_malformed_end2end_is_an_error_not_an_absent_key() {
let mut m = synthetic_metadata(80, "detect", "False");
m.insert("end2end".into(), "yes".into());
match UltralyticsMeta::from_metadata(&m) {
Err(InferError::BadMetadata(msg)) => assert!(msg.contains("end2end"), "{msg}"),
other => panic!("expected BadMetadata, got {other:?}"),
}
m.remove("end2end");
assert_eq!(UltralyticsMeta::from_metadata(&m).unwrap().end2end, None);
}
#[test]
fn meta_foreign_vendor_rejected_but_missing_provenance_allowed() {
let mut m = synthetic_metadata(80, "detect", "False");
m.insert("author".into(), "SomeOtherVendor".into());
assert!(matches!(
UltralyticsMeta::from_metadata(&m),
Err(InferError::NotUltralytics(_))
));
m.insert("author".into(), "Ultralytics".into());
m.insert("docs".into(), "https://docs.ultralytics.com".into());
assert!(UltralyticsMeta::from_metadata(&m).is_ok());
m.remove("author");
m.remove("docs");
assert!(UltralyticsMeta::from_metadata(&m).is_ok());
}
#[test]
fn infer_zero_row_end_to_end_rejected() {
assert!(matches!(
infer_ultralytics_schema(&det_signals(80, vec![1, 0, 6], "True")),
Err(InferError::UnsupportedLayout(_))
));
}
#[test]
fn infer_duplicate_matching_outputs_are_ambiguous() {
let input = TensorInfo {
name: "images".into(),
shape: vec![1, 3, 640, 640],
dtype: DType::Float32,
quantization: None,
};
let t = |name: &str, shape: Vec<usize>| TensorInfo {
name: name.into(),
shape,
dtype: DType::Float32,
quantization: None,
};
let pre = ModelSignals {
source: ModelSource::Onnx,
inputs: vec![input.clone()],
outputs: vec![t("a", vec![1, 84, 8400]), t("b", vec![1, 84, 8400])],
metadata: synthetic_metadata(80, "detect", "False"),
};
match infer_ultralytics_schema(&pre) {
Err(InferError::AmbiguousLayout(msg)) => {
assert!(msg.contains("`a`") && msg.contains("`b`"), "{msg}");
}
other => panic!("expected AmbiguousLayout, got {other:?}"),
}
let e2e = ModelSignals {
source: ModelSource::Onnx,
inputs: vec![input],
outputs: vec![t("a", vec![1, 300, 6]), t("b", vec![1, 200, 6])],
metadata: synthetic_metadata(80, "detect", "True"),
};
assert!(matches!(
infer_ultralytics_schema(&e2e),
Err(InferError::AmbiguousLayout(_))
));
}
#[test]
fn infer_malformed_per_tensor_quantization_rejected() {
let build = |q: Quantization| ModelSignals {
source: ModelSource::TfLite,
inputs: vec![TensorInfo {
name: "images".into(),
shape: vec![1, 640, 640, 3],
dtype: DType::Float32,
quantization: None,
}],
outputs: vec![TensorInfo {
name: "output0".into(),
shape: vec![1, 84, 8400],
dtype: DType::Int8,
quantization: Some(q),
}],
metadata: synthetic_metadata(80, "detect", "False"),
};
match infer_ultralytics_schema(&build(Quantization {
scale: vec![],
zero_point: Some(vec![]),
axis: None,
dtype: Some(DType::Int8),
})) {
Err(InferError::UnsupportedQuantization(msg)) => {
assert!(msg.contains("no scale"), "{msg}");
}
other => panic!("expected UnsupportedQuantization, got {other:?}"),
}
match infer_ultralytics_schema(&build(Quantization {
scale: vec![0.02],
zero_point: Some(vec![-5, -7]),
axis: None,
dtype: Some(DType::Int8),
})) {
Err(InferError::UnsupportedQuantization(msg)) => {
assert!(msg.contains("zero point"), "{msg}");
}
other => panic!("expected UnsupportedQuantization, got {other:?}"),
}
assert!(infer_ultralytics_schema(&build(Quantization {
scale: vec![0.02],
zero_point: None,
axis: None,
dtype: Some(DType::Int8),
}))
.is_ok());
}
#[test]
fn infer_pre_nms_is_independent_of_output_order() {
let det = TensorInfo {
name: "output0".into(),
shape: vec![1, 84, 8400],
dtype: DType::Float32,
quantization: None,
};
let aux = TensorInfo {
name: "boxes".into(),
shape: vec![1, 4, 8400],
dtype: DType::Float32,
quantization: None,
};
let build = |outputs: Vec<TensorInfo>| ModelSignals {
source: ModelSource::Onnx,
inputs: vec![TensorInfo {
name: "images".into(),
shape: vec![1, 3, 640, 640],
dtype: DType::Float32,
quantization: None,
}],
outputs,
metadata: synthetic_metadata(80, "detect", "False"),
};
let det_first = infer_ultralytics_schema(&build(vec![det.clone(), aux.clone()]))
.expect("detection tensor first must classify");
let aux_first = infer_ultralytics_schema(&build(vec![aux, det]))
.expect("an auxiliary anchor-shaped tensor first must not shadow the detection tensor");
assert_eq!(det_first.schema.outputs[0].name, Some("output0".into()));
assert_eq!(aux_first.schema.outputs[0].name, Some("output0".into()));
assert_eq!(det_first.schema, aux_first.schema);
}
#[test]
fn infer_class_count_mismatch_survives_a_full_scan() {
let s = ModelSignals {
source: ModelSource::Onnx,
inputs: vec![TensorInfo {
name: "images".into(),
shape: vec![1, 3, 640, 640],
dtype: DType::Float32,
quantization: None,
}],
outputs: vec![TensorInfo {
name: "output0".into(),
shape: vec![1, 84, 8400],
dtype: DType::Float32,
quantization: None,
}],
metadata: synthetic_metadata(10, "detect", "False"),
};
assert!(matches!(
infer_ultralytics_schema(&s),
Err(InferError::ClassCountMismatch {
expected: 10,
found: 80
})
));
}
struct FixtureExpectation {
name: &'static str,
version: DecoderVersion,
det_type: LogicalType,
det_dshape: &'static [(DimName, usize)],
normalized: bool,
nms: Option<NmsMode>,
proto: Option<(usize, usize, usize)>,
}
const A: usize = 8400; const NC: usize = 80;
fn fixture_expectations() -> Vec<FixtureExpectation> {
use DimName::{Batch, NumBoxes, NumFeatures};
const DET: &[(DimName, usize)] = &[(Batch, 1), (NumFeatures, 4 + NC), (NumBoxes, A)];
const SEG: &[(DimName, usize)] = &[(Batch, 1), (NumFeatures, 4 + NC + 32), (NumBoxes, A)];
const E2E: &[(DimName, usize)] = &[(Batch, 1), (NumBoxes, 300), (NumFeatures, 6)];
const E2E_SEG: &[(DimName, usize)] = &[(Batch, 1), (NumBoxes, 300), (NumFeatures, 6 + 32)];
let pre_nms = |name, det_dshape, normalized, proto| FixtureExpectation {
name,
version: DecoderVersion::Yolov8,
det_type: LogicalType::Detection,
det_dshape,
normalized,
nms: Some(NmsMode::ClassAware),
proto,
};
let e2e = |name, det_dshape, normalized, proto| FixtureExpectation {
name,
version: DecoderVersion::Yolo26,
det_type: LogicalType::Detections,
det_dshape,
normalized,
nms: None,
proto,
};
const P: Option<(usize, usize, usize)> = Some((32, 160, 160));
vec![
pre_nms("yolov8n", DET, false, None),
pre_nms("yolo11n", DET, false, None),
pre_nms("yolov8n-seg", SEG, false, P),
pre_nms("yolo11n-seg", SEG, false, P),
pre_nms("yolov8n_float32", DET, true, None),
pre_nms("yolov8n_int8", DET, true, None),
pre_nms("yolov8n-seg_float32", SEG, true, P),
pre_nms("yolov8n-seg_int8", SEG, true, P),
e2e("yolo26n", E2E, false, None),
e2e("yolo26n-seg", E2E_SEG, false, P),
e2e("yolo26n_float32", E2E, true, None),
]
}
#[test]
fn every_captured_fixture_infers_the_expected_schema() {
for want in fixture_expectations() {
let name = want.name;
let r = infer_ultralytics_schema(&signals_from_fixture(name))
.unwrap_or_else(|e| panic!("{name}: inference failed: {e}"));
assert_eq!(r.labels.len(), NC, "{name}: class count");
assert_eq!(
r.labels[0], "person",
"{name}: labels must be index-ordered"
);
assert_eq!(r.labels[NC - 1], "toothbrush", "{name}: labels index order");
assert_eq!(
r.schema.decoder_version,
Some(want.version),
"{name}: version"
);
assert_eq!(r.schema.nms, want.nms, "{name}: nms mode");
let det = &r.schema.outputs[0];
assert_eq!(det.type_, Some(want.det_type), "{name}: detection type");
assert_eq!(det.dshape, want.det_dshape, "{name}: detection dshape");
assert_eq!(det.normalized, Some(want.normalized), "{name}: normalized");
assert_eq!(
det.decoder,
Some(DecoderKind::Ultralytics),
"{name}: decoder"
);
assert_eq!(det.encoding, Some(BoxEncoding::Direct), "{name}: encoding");
assert_eq!(
det.score_format,
Some(ScoreFormat::PerClass),
"{name}: scores"
);
match want.proto {
None => {
assert_eq!(r.schema.outputs.len(), 1, "{name}: detection-only");
}
Some((k, ph, pw)) => {
assert_eq!(r.schema.outputs.len(), 2, "{name}: detection + proto");
let proto = &r.schema.outputs[1];
assert_eq!(proto.type_, Some(LogicalType::Protos), "{name}: proto type");
assert_eq!(
proto.dshape,
vec![
(DimName::Batch, 1),
(DimName::NumProtos, k),
(DimName::Height, ph),
(DimName::Width, pw),
],
"{name}: proto dshape"
);
}
}
let task = if want.proto.is_some() {
"segment"
} else {
"detect"
};
assert!(
r.description.contains(task) && r.description.contains("Ultralytics"),
"{name}: description was `{}`",
r.description
);
}
}
#[test]
fn every_fixture_is_asserted() {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata/infer");
let mut on_disk: Vec<String> = std::fs::read_dir(dir)
.expect("testdata/infer exists")
.filter_map(|e| {
let name = e.ok()?.file_name().into_string().ok()?;
name.strip_suffix(".signals.json").map(str::to_string)
})
.collect();
on_disk.sort();
let mut asserted: Vec<String> = fixture_expectations()
.iter()
.map(|f| f.name.to_string())
.collect();
asserted.sort();
assert_eq!(
on_disk, asserted,
"every *.signals.json fixture must appear in fixture_expectations()"
);
}
#[test]
fn infer_other_source_refuses_rather_than_guessing_normalization() {
let s = ModelSignals {
source: ModelSource::Other,
inputs: vec![TensorInfo {
name: "images".into(),
shape: vec![1, 3, 640, 640],
dtype: DType::Float32,
quantization: None,
}],
outputs: vec![TensorInfo {
name: "output0".into(),
shape: vec![1, 84, 8400],
dtype: DType::Float32,
quantization: None,
}],
metadata: synthetic_metadata(80, "detect", "False"),
};
assert!(matches!(
infer_ultralytics_schema(&s),
Err(InferError::UnknownBoxConvention)
));
let ok = ModelSignals {
source: ModelSource::Onnx,
..s
};
assert_eq!(
infer_ultralytics_schema(&ok).unwrap().schema.outputs[0].normalized,
Some(false)
);
}
#[test]
fn infer_per_channel_quantization_rejected() {
let s = ModelSignals {
source: ModelSource::TfLite,
inputs: vec![TensorInfo {
name: "images".into(),
shape: vec![1, 640, 640, 3],
dtype: DType::Float32,
quantization: None,
}],
outputs: vec![TensorInfo {
name: "output0".into(),
shape: vec![1, 84, 8400],
dtype: DType::Int8,
quantization: Some(Quantization {
scale: vec![0.02, 0.03],
zero_point: Some(vec![-5, -7]),
axis: Some(1),
dtype: Some(DType::Int8),
}),
}],
metadata: synthetic_metadata(80, "detect", "False"),
};
match infer_ultralytics_schema(&s) {
Err(InferError::UnsupportedQuantization(msg)) => {
assert!(msg.contains("per-channel"), "unexpected message: {msg}");
assert!(
msg.contains("output0"),
"message must name the tensor: {msg}"
);
}
other => panic!("expected UnsupportedQuantization, got {other:?}"),
}
}
#[test]
fn infer_per_channel_proto_quantization_rejected() {
let s = ModelSignals {
source: ModelSource::TfLite,
inputs: vec![TensorInfo {
name: "images".into(),
shape: vec![1, 640, 640, 3],
dtype: DType::Float32,
quantization: None,
}],
outputs: vec![
TensorInfo {
name: "output0".into(),
shape: vec![1, 116, 8400],
dtype: DType::Float32,
quantization: None,
},
TensorInfo {
name: "output1".into(),
shape: vec![1, 32, 160, 160],
dtype: DType::Int8,
quantization: Some(Quantization {
scale: vec![0.02, 0.03],
zero_point: Some(vec![-5, -7]),
axis: Some(1),
dtype: Some(DType::Int8),
}),
},
],
metadata: synthetic_metadata(80, "segment", "False"),
};
match infer_ultralytics_schema(&s) {
Err(InferError::UnsupportedQuantization(msg)) => {
assert!(
msg.contains("output1"),
"message must name the proto: {msg}"
);
}
other => panic!("expected UnsupportedQuantization, got {other:?}"),
}
}
#[test]
fn infer_integer_output_without_quant_rejected() {
let s = ModelSignals {
source: ModelSource::TfLite,
inputs: vec![TensorInfo {
name: "images".into(),
shape: vec![1, 640, 640, 3],
dtype: DType::Float32,
quantization: None,
}],
outputs: vec![TensorInfo {
name: "output0".into(),
shape: vec![1, 84, 8400],
dtype: DType::Int8,
quantization: None,
}],
metadata: synthetic_metadata(80, "detect", "False"),
};
match infer_ultralytics_schema(&s) {
Err(InferError::UnsupportedLayout(msg)) => {
assert!(
msg.contains("quantization"),
"unexpected UnsupportedLayout message: {msg}"
);
}
other => panic!("expected UnsupportedLayout, got {other:?}"),
}
}
#[test]
fn infer_pre_nms_rejects_non_unit_batch() {
let mut s = signals_from_fixture("yolov8n");
s.outputs[0].shape = vec![2, 84, 8400];
assert!(matches!(
infer_ultralytics_schema(&s),
Err(InferError::UnsupportedLayout(_))
));
}
}