1use std::collections::BTreeMap;
13use std::fmt;
14
15use crate::configs::DimName;
16use crate::schema::{
17 BoxEncoding, DType, DecoderKind, DecoderVersion, InputSpec, LogicalOutput, LogicalType,
18 NmsMode, Quantization, SchemaV2, ScoreFormat,
19};
20
21#[derive(Debug, Clone, PartialEq)]
23pub struct TensorInfo {
24 pub name: String,
26 pub shape: Vec<usize>,
28 pub dtype: DType,
30 pub quantization: Option<Quantization>,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ModelSource {
38 Onnx,
41 TfLite,
44 Other,
52}
53
54#[derive(Debug, Clone, PartialEq)]
57pub struct ModelSignals {
58 pub source: ModelSource,
60 pub inputs: Vec<TensorInfo>,
62 pub outputs: Vec<TensorInfo>,
64 pub metadata: BTreeMap<String, String>,
67}
68
69#[derive(Debug, Clone, PartialEq)]
71pub struct InferredSchema {
72 pub schema: SchemaV2,
74 pub labels: Vec<String>,
76 pub description: String,
78}
79
80#[derive(Debug)]
82pub enum InferError {
83 NotUltralytics(String),
85 BadNames(String),
87 BadMetadata(String),
91 UnsupportedTask(String),
93 ClassCountMismatch { expected: usize, found: usize },
96 UnsupportedLayout(String),
99 AmbiguousLayout(String),
103 UnsupportedQuantization(String),
107 UnknownBoxConvention,
112}
113
114impl fmt::Display for InferError {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 match self {
117 InferError::NotUltralytics(s) => {
118 write!(f, "model metadata carries no Ultralytics signature: {s}")
119 }
120 InferError::BadNames(s) => {
121 write!(f, "cannot parse Ultralytics `names` metadata: {s}")
122 }
123 InferError::BadMetadata(s) => {
124 write!(f, "malformed Ultralytics metadata: {s}")
125 }
126 InferError::UnsupportedTask(s) => write!(
127 f,
128 "unsupported Ultralytics task `{s}` (supported: detect, segment)"
129 ),
130 InferError::ClassCountMismatch { expected, found } => write!(
131 f,
132 "class-count mismatch: names give {expected} classes but output features imply {found}"
133 ),
134 InferError::UnsupportedLayout(s) => write!(f, "unsupported output layout: {s}"),
135 InferError::AmbiguousLayout(s) => write!(f, "ambiguous output layout: {s}"),
136 InferError::UnsupportedQuantization(s) => {
137 write!(f, "unsupported quantization: {s}")
138 }
139 InferError::UnknownBoxConvention => write!(
140 f,
141 "box coordinate convention is unknown for this container; only \
142 onnx (pixel-space) and tflite ([0,1]) are characterized"
143 ),
144 }
145 }
146}
147
148impl std::error::Error for InferError {}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152enum Task {
153 Detect,
154 Segment,
155}
156
157impl Task {
158 fn parse(s: &str) -> Result<Self, InferError> {
159 match s.trim().to_ascii_lowercase().as_str() {
160 "detect" => Ok(Task::Detect),
161 "segment" => Ok(Task::Segment),
162 other => Err(InferError::UnsupportedTask(other.to_string())),
163 }
164 }
165}
166
167#[derive(Debug, Clone, PartialEq)]
170struct UltralyticsMeta {
171 names: Vec<String>,
172 task: Option<Task>,
173 end2end: Option<bool>,
177}
178
179fn reject_foreign_vendor(author: Option<&String>, docs: Option<&String>) -> Result<(), InferError> {
195 let names_ultralytics = |v: &String| v.to_ascii_lowercase().contains("ultralytics");
196 for (field, value) in [("author", author), ("docs", docs)] {
197 if let Some(v) = value {
198 if !v.trim().is_empty() && !names_ultralytics(v) {
199 return Err(InferError::NotUltralytics(format!(
200 "metadata `{field}` is `{v}`, not Ultralytics"
201 )));
202 }
203 }
204 }
205 Ok(())
206}
207
208impl UltralyticsMeta {
209 fn from_metadata(meta: &BTreeMap<String, String>) -> Result<Self, InferError> {
215 if let Some(names_raw) = meta.get("names") {
216 reject_foreign_vendor(meta.get("author"), meta.get("docs"))?;
217 return Self::from_flat_props(meta, names_raw);
218 }
219
220 for value in meta.values() {
223 if let Ok(serde_json::Value::Object(obj)) = serde_json::from_str(value) {
224 if let Some(names) = obj.get("names") {
225 let author = obj
226 .get("author")
227 .and_then(|v| v.as_str())
228 .map(str::to_string);
229 let docs = obj.get("docs").and_then(|v| v.as_str()).map(str::to_string);
230 reject_foreign_vendor(author.as_ref(), docs.as_ref())?;
231 return Self::from_json_envelope(&obj, names);
232 }
233 }
234 }
235
236 Err(InferError::NotUltralytics(
237 "no `names` metadata field or metadata.json envelope found".into(),
238 ))
239 }
240
241 fn from_flat_props(
242 meta: &BTreeMap<String, String>,
243 names_raw: &str,
244 ) -> Result<Self, InferError> {
245 let names = parse_names(names_raw)?;
246 let task = meta.get("task").map(|t| Task::parse(t)).transpose()?;
247 let end2end = meta.get("end2end").map(|s| parse_bool_str(s)).transpose()?;
248 Ok(UltralyticsMeta {
249 names,
250 task,
251 end2end,
252 })
253 }
254
255 fn from_json_envelope(
259 obj: &serde_json::Map<String, serde_json::Value>,
260 names_value: &serde_json::Value,
261 ) -> Result<Self, InferError> {
262 let names = names_from_json_value(names_value)?;
263 let task = obj
264 .get("task")
265 .and_then(|v| v.as_str())
266 .map(Task::parse)
267 .transpose()?;
268 let end2end = match obj.get("end2end") {
271 None | Some(serde_json::Value::Null) => None,
272 Some(serde_json::Value::Bool(b)) => Some(*b),
273 Some(other) => {
274 return Err(InferError::BadMetadata(format!(
275 "`end2end` is {other}, expected a boolean"
276 )))
277 }
278 };
279 Ok(UltralyticsMeta {
280 names,
281 task,
282 end2end,
283 })
284 }
285}
286
287fn parse_bool_str(s: &str) -> Result<bool, InferError> {
295 match s.trim().to_ascii_lowercase().as_str() {
296 "true" => Ok(true),
297 "false" => Ok(false),
298 other => Err(InferError::BadMetadata(format!(
299 "`end2end` is `{other}`, expected true or false"
300 ))),
301 }
302}
303
304fn parse_names(s: &str) -> Result<Vec<String>, InferError> {
309 let trimmed = s.trim();
310
311 if let Ok(map) = serde_json::from_str::<BTreeMap<String, String>>(trimmed) {
312 return names_from_str_keyed_map(map);
313 }
314
315 let body = trimmed
316 .strip_prefix('{')
317 .and_then(|s| s.strip_suffix('}'))
318 .ok_or_else(|| InferError::BadNames(format!("not a dict: {trimmed}")))?;
319
320 let mut indexed: BTreeMap<usize, String> = BTreeMap::new();
321 for item in split_top_level_commas(body) {
322 let item = item.trim();
323 if item.is_empty() {
324 continue;
325 }
326 let (key, value) = item
327 .split_once(':')
328 .ok_or_else(|| InferError::BadNames(format!("malformed entry `{item}`")))?;
329 let idx: usize = key
330 .trim()
331 .parse()
332 .map_err(|_| InferError::BadNames(format!("non-numeric key `{key}`")))?;
333 let value = unquote(value.trim())
334 .ok_or_else(|| InferError::BadNames(format!("unquoted value `{value}`")))?;
335 indexed.insert(idx, value);
336 }
337 contiguous_values(indexed)
338}
339
340fn names_from_json_value(v: &serde_json::Value) -> Result<Vec<String>, InferError> {
343 let obj = v
344 .as_object()
345 .ok_or_else(|| InferError::BadNames("`names` is not a JSON object".into()))?;
346 let mut map = BTreeMap::new();
347 for (k, val) in obj {
348 let s = val
349 .as_str()
350 .ok_or_else(|| InferError::BadNames(format!("non-string name for key `{k}`")))?;
351 map.insert(k.clone(), s.to_string());
352 }
353 names_from_str_keyed_map(map)
354}
355
356fn names_from_str_keyed_map(map: BTreeMap<String, String>) -> Result<Vec<String>, InferError> {
360 let mut indexed: BTreeMap<usize, String> = BTreeMap::new();
361 for (k, v) in map {
362 let idx: usize = k
363 .parse()
364 .map_err(|_| InferError::BadNames(format!("non-numeric key `{k}`")))?;
365 indexed.insert(idx, v);
366 }
367 contiguous_values(indexed)
368}
369
370fn contiguous_values(indexed: BTreeMap<usize, String>) -> Result<Vec<String>, InferError> {
373 if indexed.is_empty() {
378 return Err(InferError::BadNames("class map is empty".into()));
379 }
380 let n = indexed.len();
381 for i in 0..n {
382 if !indexed.contains_key(&i) {
383 return Err(InferError::BadNames("non-contiguous class indices".into()));
384 }
385 }
386 Ok(indexed.into_values().collect())
387}
388
389fn split_top_level_commas(s: &str) -> Vec<String> {
392 let mut parts = Vec::new();
393 let mut current = String::new();
394 let mut quote: Option<char> = None;
395 for c in s.chars() {
396 match quote {
397 Some(q) => {
398 current.push(c);
399 if c == q {
400 quote = None;
401 }
402 }
403 None => match c {
404 '\'' | '"' => {
405 quote = Some(c);
406 current.push(c);
407 }
408 ',' => parts.push(std::mem::take(&mut current)),
409 _ => current.push(c),
410 },
411 }
412 }
413 if !current.is_empty() {
414 parts.push(current);
415 }
416 parts
417}
418
419fn unquote(s: &str) -> Option<String> {
422 let bytes = s.as_bytes();
423 if s.len() >= 2 {
424 let first = bytes[0];
425 let last = bytes[s.len() - 1];
426 if (first == b'\'' && last == b'\'') || (first == b'"' && last == b'"') {
427 return Some(s[1..s.len() - 1].to_string());
428 }
429 }
430 None
431}
432
433#[allow(clippy::type_complexity)]
444fn classify_input_layout(
445 input: &TensorInfo,
446) -> Result<(usize, usize, Vec<(DimName, usize)>), InferError> {
447 debug_assert_eq!(
448 input.shape.len(),
449 4,
450 "caller selects the input by rank-4; see infer_ultralytics_schema"
451 );
452 if input.shape[0] != 1 {
453 return Err(InferError::UnsupportedLayout(format!(
454 "model input `{}` has batch {} but every supported output layout \
455 is batch-1; shape {:?}",
456 input.name, input.shape[0], input.shape
457 )));
458 }
459 if input.shape[1] == 3 {
460 let (h, w) = (input.shape[2], input.shape[3]);
461 Ok((
462 h,
463 w,
464 vec![
465 (DimName::Batch, input.shape[0]),
466 (DimName::NumFeatures, 3),
467 (DimName::Height, h),
468 (DimName::Width, w),
469 ],
470 ))
471 } else if input.shape[3] == 3 {
472 let (h, w) = (input.shape[1], input.shape[2]);
473 Ok((
474 h,
475 w,
476 vec![
477 (DimName::Batch, input.shape[0]),
478 (DimName::Height, h),
479 (DimName::Width, w),
480 (DimName::NumFeatures, 3),
481 ],
482 ))
483 } else {
484 Err(InferError::UnsupportedLayout(format!(
485 "cannot locate the channel dimension (=3) in input shape {:?}",
486 input.shape
487 )))
488 }
489}
490
491fn find_anchors_dim(shape: &[usize], expected_anchors: usize) -> Option<(usize, usize)> {
498 if shape.len() != 3 || shape[0] != 1 {
499 return None;
500 }
501 if shape[2] == expected_anchors {
502 Some((shape[1], 1)) } else if shape[1] == expected_anchors {
504 Some((shape[2], 2)) } else {
506 None
507 }
508}
509
510#[allow(clippy::type_complexity)]
526fn classify_pre_nms<'a>(
527 rank3_outputs: &[&'a TensorInfo],
528 expected_anchors: usize,
529 feat: usize,
530 nc: usize,
531) -> Result<(&'a TensorInfo, Vec<(DimName, usize)>), InferError> {
532 let mut mismatch: Option<InferError> = None;
533 let mut matched: Option<(&'a TensorInfo, Vec<(DimName, usize)>)> = None;
534 for out in rank3_outputs {
535 let Some((other_dim, feat_idx)) = find_anchors_dim(&out.shape, expected_anchors) else {
536 continue;
537 };
538 if other_dim == feat {
539 let dshape = if feat_idx == 1 {
540 vec![
541 (DimName::Batch, 1),
542 (DimName::NumFeatures, feat),
543 (DimName::NumBoxes, expected_anchors),
544 ]
545 } else {
546 vec![
547 (DimName::Batch, 1),
548 (DimName::NumBoxes, expected_anchors),
549 (DimName::NumFeatures, feat),
550 ]
551 };
552 if let Some((first, _)) = &matched {
555 return Err(InferError::AmbiguousLayout(format!(
556 "outputs `{}` and `{}` both match the pre-NMS detection layout \
557 ({:?}); cannot tell which carries the detections",
558 first.name, out.name, out.shape
559 )));
560 }
561 matched = Some((out, dshape));
562 continue;
563 }
564 mismatch.get_or_insert_with(|| InferError::ClassCountMismatch {
565 expected: nc,
566 found: other_dim.saturating_sub(feat.saturating_sub(nc)),
569 });
570 }
571 if let Some(found) = matched {
572 return Ok(found);
573 }
574 Err(mismatch.unwrap_or_else(|| {
575 InferError::UnsupportedLayout(format!(
576 "no output matches the Ultralytics pre-NMS layout (expected a dim == \
577 {expected_anchors} anchors and a dim == {feat} features [4 + {nc} classes]); \
578 outputs seen: {:?}",
579 rank3_outputs.iter().map(|o| &o.shape).collect::<Vec<_>>()
580 ))
581 }))
582}
583
584fn find_e2e_candidate<'a>(
599 rank3_outputs: &[&'a TensorInfo],
600 expected_anchors: usize,
601 feat: usize,
602) -> Result<Option<(&'a TensorInfo, usize)>, InferError> {
603 let matches: Vec<(&'a TensorInfo, usize)> = rank3_outputs
604 .iter()
605 .filter_map(|o| {
606 let s = &o.shape;
607 debug_assert_eq!(s.len(), 3, "caller filters to rank-3 outputs");
608 (s[0] == 1 && s[2] == feat && s[1] > 0 && s[1] <= expected_anchors)
611 .then_some((*o, s[1]))
612 })
613 .collect();
614 if matches.len() > 1 {
618 return Err(InferError::AmbiguousLayout(format!(
619 "{} outputs match the end-to-end layout [1, N, {feat}]: {:?}; \
620 cannot tell which carries the detections",
621 matches.len(),
622 matches.iter().map(|(o, _)| &o.name).collect::<Vec<_>>()
623 )));
624 }
625 Ok(matches.into_iter().next())
626}
627
628fn classify_proto(
641 proto: &TensorInfo,
642 h: usize,
643 w: usize,
644) -> Result<(usize, Vec<(DimName, usize)>), InferError> {
645 let (eh, ew) = (h / 4, w / 4);
646 let s = &proto.shape;
647 if s.len() != 4 || s[0] != 1 {
648 return Err(InferError::UnsupportedLayout(format!(
649 "expected a rank-4 proto tensor with batch 1, found shape {s:?}"
650 )));
651 }
652 if s[2] == eh && s[3] == ew {
653 let k = s[1];
654 Ok((
655 k,
656 vec![
657 (DimName::Batch, 1),
658 (DimName::NumProtos, k),
659 (DimName::Height, eh),
660 (DimName::Width, ew),
661 ],
662 ))
663 } else if s[1] == eh && s[2] == ew {
664 let k = s[3];
665 Ok((
666 k,
667 vec![
668 (DimName::Batch, 1),
669 (DimName::Height, eh),
670 (DimName::Width, ew),
671 (DimName::NumProtos, k),
672 ],
673 ))
674 } else {
675 Err(InferError::UnsupportedLayout(format!(
676 "proto tensor shape {s:?} doesn't match the expected ({eh}, {ew}) spatial dims"
677 )))
678 }
679}
680
681fn default_logical() -> LogicalOutput {
684 LogicalOutput {
685 name: None,
686 type_: None,
687 shape: Vec::new(),
688 dshape: Vec::new(),
689 decoder: None,
690 encoding: None,
691 score_format: None,
692 normalized: None,
693 anchors: None,
694 stride: None,
695 dtype: None,
696 quantization: None,
697 outputs: Vec::new(),
698 activation_applied: None,
699 activation_required: None,
700 }
701}
702
703fn check_quantized_boundary(tensor: &TensorInfo) -> Result<(), InferError> {
719 if tensor.dtype.is_integer() && tensor.quantization.is_none() {
720 return Err(InferError::UnsupportedLayout(format!(
721 "output `{}` has integer dtype {:?} but no quantization params",
722 tensor.name, tensor.dtype
723 )));
724 }
725 if let Some(q) = &tensor.quantization {
726 if q.scale.len() > 1 {
727 return Err(InferError::UnsupportedQuantization(format!(
728 "output `{}` carries per-channel quantization ({} scales); \
729 the decoder supports per-tensor quantization only",
730 tensor.name,
731 q.scale.len()
732 )));
733 }
734 if q.scale.is_empty() {
738 return Err(InferError::UnsupportedQuantization(format!(
739 "output `{}` is quantized but carries no scale",
740 tensor.name
741 )));
742 }
743 if let Some(zp) = &q.zero_point {
747 if zp.len() != q.scale.len() {
748 return Err(InferError::UnsupportedQuantization(format!(
749 "output `{}` has {} scale(s) but {} zero point(s)",
750 tensor.name,
751 q.scale.len(),
752 zp.len()
753 )));
754 }
755 }
756 }
757 Ok(())
758}
759
760pub fn infer_ultralytics_schema(signals: &ModelSignals) -> Result<InferredSchema, InferError> {
837 let meta = UltralyticsMeta::from_metadata(&signals.metadata)?;
838
839 let proto = signals.outputs.iter().find(|o| o.shape.len() == 4);
840 match (meta.task, proto.is_some()) {
841 (Some(Task::Segment), false) => {
842 return Err(InferError::UnsupportedLayout(
843 "segment task but no proto tensor".into(),
844 ));
845 }
846 (Some(Task::Detect), true) => {
847 return Err(InferError::UnsupportedLayout(
848 "detect task but proto tensor present".into(),
849 ));
850 }
851 _ => {}
852 }
853 let is_segment = meta.task == Some(Task::Segment) || proto.is_some();
854
855 let input = signals
856 .inputs
857 .iter()
858 .find(|t| t.shape.len() == 4)
859 .ok_or_else(|| {
860 InferError::UnsupportedLayout(format!(
861 "no rank-4 model input found; inputs seen: {:?}",
862 signals.inputs.iter().map(|t| &t.shape).collect::<Vec<_>>()
863 ))
864 })?;
865 let (h, w, input_dshape) = classify_input_layout(input)?;
866
867 let expected_anchors = (h / 8) * (w / 8) + (h / 16) * (w / 16) + (h / 32) * (w / 32);
868 let nc = meta.names.len();
869
870 let (k, proto_dshape) = match proto {
878 Some(proto) => {
879 let (k, dshape) = classify_proto(proto, h, w)?;
880 (k, Some(dshape))
881 }
882 None => (0, None),
883 };
884 let feat = 4 + nc + k;
885 let e2e_feat = 6 + k;
886
887 let rank3_outputs: Vec<&TensorInfo> = signals
888 .outputs
889 .iter()
890 .filter(|o| o.shape.len() == 3)
891 .collect();
892
893 let (out, dshape, version) = match meta.end2end {
897 Some(true) => {
898 let (out, n) = find_e2e_candidate(&rank3_outputs, expected_anchors, e2e_feat)?
899 .ok_or_else(|| {
900 InferError::UnsupportedLayout(format!(
901 "metadata declares end2end=true but no output matches the YOLO26 \
902 end-to-end layout [1, N<={expected_anchors}, {e2e_feat}]; \
903 outputs seen: {:?}",
904 rank3_outputs.iter().map(|o| &o.shape).collect::<Vec<_>>()
905 ))
906 })?;
907 (
908 out,
909 vec![
910 (DimName::Batch, 1),
911 (DimName::NumBoxes, n),
912 (DimName::NumFeatures, e2e_feat),
913 ],
914 DecoderVersion::Yolo26,
915 )
916 }
917 Some(false) => {
918 let (out, dshape) = classify_pre_nms(&rank3_outputs, expected_anchors, feat, nc)?;
919 (out, dshape, DecoderVersion::Yolov8)
920 }
921 None => match classify_pre_nms(&rank3_outputs, expected_anchors, feat, nc) {
922 Ok((out, dshape)) => (out, dshape, DecoderVersion::Yolov8),
923 Err(err @ InferError::ClassCountMismatch { .. }) => return Err(err),
924 Err(_) => {
925 let (out, n) = find_e2e_candidate(&rank3_outputs, expected_anchors, e2e_feat)?
926 .ok_or_else(|| {
927 InferError::UnsupportedLayout(format!(
928 "no output matches a known Ultralytics detection layout \
929 (pre-NMS or end-to-end); outputs seen: {:?}",
930 rank3_outputs.iter().map(|o| &o.shape).collect::<Vec<_>>()
931 ))
932 })?;
933 (
934 out,
935 vec![
936 (DimName::Batch, 1),
937 (DimName::NumBoxes, n),
938 (DimName::NumFeatures, e2e_feat),
939 ],
940 DecoderVersion::Yolo26,
941 )
942 }
943 },
944 };
945
946 check_quantized_boundary(out)?;
947 if let Some(proto) = proto {
948 check_quantized_boundary(proto)?;
949 }
950
951 let normalized = match signals.source {
959 ModelSource::TfLite => true,
960 ModelSource::Onnx => false,
961 ModelSource::Other => {
962 return Err(InferError::UnknownBoxConvention);
963 }
964 };
965 let det = LogicalOutput {
966 name: Some(out.name.clone()),
967 type_: Some(match version {
974 DecoderVersion::Yolo26 => LogicalType::Detections,
975 _ => LogicalType::Detection,
976 }),
977 shape: out.shape.clone(),
978 dshape,
979 decoder: Some(DecoderKind::Ultralytics),
980 encoding: Some(BoxEncoding::Direct), score_format: Some(ScoreFormat::PerClass),
982 normalized: Some(normalized),
983 anchors: None,
984 stride: None,
985 dtype: Some(out.dtype),
986 quantization: out.quantization.clone(),
987 outputs: Vec::new(),
988 activation_applied: None,
989 activation_required: None,
990 };
991
992 let mut outputs = vec![det];
993 if let (Some(proto), Some(proto_dshape)) = (proto, proto_dshape) {
994 outputs.push(LogicalOutput {
995 name: Some(proto.name.clone()),
996 type_: Some(LogicalType::Protos),
997 shape: proto.shape.clone(),
998 dshape: proto_dshape,
999 decoder: Some(DecoderKind::Ultralytics),
1000 dtype: Some(proto.dtype),
1001 quantization: proto.quantization.clone(),
1002 ..default_logical()
1003 });
1004 }
1005
1006 let schema = SchemaV2 {
1007 schema_version: 2,
1008 input: Some(InputSpec {
1009 shape: input.shape.clone(),
1010 dshape: input_dshape,
1011 cameraadaptor: Some("rgb".into()),
1012 }),
1013 outputs,
1014 nms: match version {
1025 DecoderVersion::Yolo26 => None,
1026 _ => Some(NmsMode::ClassAware),
1027 },
1028 decoder_version: Some(version),
1029 };
1030
1031 let version_label = if version == DecoderVersion::Yolo26 {
1032 "YOLO26"
1033 } else {
1034 "YOLOv8/11"
1035 };
1036 let task_label = if is_segment { "segment" } else { "detect" };
1037 let description = format!("Ultralytics {version_label} {task_label}, {nc} classes");
1038
1039 Ok(InferredSchema {
1040 schema,
1041 labels: meta.names,
1042 description,
1043 })
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048 use super::*;
1049
1050 fn infer_fixture_dir() -> std::path::PathBuf {
1062 std::env::var("EDGEFIRST_TESTDATA_DIR")
1063 .map(std::path::PathBuf::from)
1064 .unwrap_or_else(|_| {
1065 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata")
1066 })
1067 .join("infer")
1068 }
1069
1070 #[test]
1071 fn parse_names_python_dict_repr() {
1072 let s = "{0: 'person', 1: 'bicycle', 2: \"fire hydrant\"}";
1073 assert_eq!(
1074 parse_names(s).unwrap(),
1075 vec!["person", "bicycle", "fire hydrant"]
1076 );
1077 }
1078
1079 #[test]
1080 fn parse_names_json_object() {
1081 let s = r#"{"0": "person", "1": "bicycle"}"#;
1082 assert_eq!(parse_names(s).unwrap(), vec!["person", "bicycle"]);
1083 }
1084
1085 #[test]
1086 fn parse_names_orders_by_index_not_insertion() {
1087 let s = "{1: 'b', 0: 'a'}";
1088 assert_eq!(parse_names(s).unwrap(), vec!["a", "b"]);
1089 }
1090
1091 #[test]
1092 fn parse_names_rejects_garbage() {
1093 assert!(matches!(
1094 parse_names("not a dict"),
1095 Err(InferError::BadNames(_))
1096 ));
1097 }
1098
1099 #[test]
1100 fn parse_names_rejects_non_contiguous_indices() {
1101 match parse_names("{0: 'a', 2: 'b'}") {
1103 Err(InferError::BadNames(msg)) => {
1104 assert!(
1105 msg.contains("non-contiguous"),
1106 "unexpected BadNames message: {msg}"
1107 );
1108 }
1109 other => panic!("expected BadNames, got {other:?}"),
1110 }
1111
1112 match parse_names(r#"{"0": "a", "2": "b"}"#) {
1114 Err(InferError::BadNames(msg)) => {
1115 assert!(
1116 msg.contains("non-contiguous"),
1117 "unexpected BadNames message: {msg}"
1118 );
1119 }
1120 other => panic!("expected BadNames, got {other:?}"),
1121 }
1122 }
1123
1124 #[test]
1125 fn meta_from_onnx_props() {
1126 let mut m = BTreeMap::new();
1127 m.insert("names".into(), "{0: 'person'}".into());
1128 m.insert("task".into(), "detect".into());
1129 let u = UltralyticsMeta::from_metadata(&m).unwrap();
1130 assert_eq!(u.names, vec!["person"]);
1131 assert_eq!(u.task, Some(Task::Detect));
1132 }
1133
1134 #[test]
1135 fn meta_task_pose_rejected() {
1136 let mut m = BTreeMap::new();
1137 m.insert("names".into(), "{0: 'person'}".into());
1138 m.insert("task".into(), "pose".into());
1139 assert!(matches!(
1140 UltralyticsMeta::from_metadata(&m),
1141 Err(InferError::UnsupportedTask(_))
1142 ));
1143 }
1144
1145 #[test]
1146 fn meta_missing_names_is_not_ultralytics() {
1147 assert!(matches!(
1148 UltralyticsMeta::from_metadata(&BTreeMap::new()),
1149 Err(InferError::NotUltralytics(_))
1150 ));
1151 }
1152
1153 #[test]
1154 fn meta_end2end_true_or_false_from_onnx_flat_props() {
1155 let mut m = BTreeMap::new();
1156 m.insert("names".into(), "{0: 'person'}".into());
1157 m.insert("end2end".into(), "True".into());
1158 assert_eq!(
1159 UltralyticsMeta::from_metadata(&m).unwrap().end2end,
1160 Some(true)
1161 );
1162
1163 m.insert("end2end".into(), "False".into());
1164 assert_eq!(
1165 UltralyticsMeta::from_metadata(&m).unwrap().end2end,
1166 Some(false)
1167 );
1168 }
1169
1170 fn load_fixture_metadata(name: &str) -> BTreeMap<String, String> {
1175 let path = infer_fixture_dir().join(format!("{name}.signals.json"));
1176 let content = std::fs::read_to_string(&path)
1177 .unwrap_or_else(|e| panic!("failed to read fixture {}: {e}", path.display()));
1178 let json: serde_json::Value =
1179 serde_json::from_str(&content).expect("fixture is valid JSON");
1180 json["metadata"]
1181 .as_object()
1182 .expect("fixture has a metadata object")
1183 .iter()
1184 .map(|(k, v)| {
1185 (
1186 k.clone(),
1187 v.as_str().expect("metadata value is a string").to_string(),
1188 )
1189 })
1190 .collect()
1191 }
1192
1193 #[test]
1194 fn meta_from_real_tflite_metadata_json_envelope() {
1195 let meta = load_fixture_metadata("yolov8n_float32");
1196 let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1197 assert_eq!(u.names.len(), 80);
1198 assert_eq!(u.task, Some(Task::Detect));
1199 }
1200
1201 #[test]
1202 fn meta_end2end_true_on_real_yolo26_tflite_fixture() {
1203 let meta = load_fixture_metadata("yolo26n_float32");
1204 let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1205 assert_eq!(u.end2end, Some(true));
1206 }
1207
1208 #[test]
1209 fn meta_end2end_false_on_real_yolov8_onnx_fixture() {
1210 let meta = load_fixture_metadata("yolov8n");
1211 let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1212 assert_eq!(u.end2end, Some(false));
1213 }
1214
1215 fn signals_from_fixture(name: &str) -> ModelSignals {
1219 let path = infer_fixture_dir().join(format!("{name}.signals.json"));
1220 let content = std::fs::read_to_string(&path)
1221 .unwrap_or_else(|e| panic!("failed to read fixture {}: {e}", path.display()));
1222 let json: serde_json::Value =
1223 serde_json::from_str(&content).expect("fixture is valid JSON");
1224
1225 let source = match json["source"]
1226 .as_str()
1227 .expect("fixture `source` is a string")
1228 {
1229 "onnx" => ModelSource::Onnx,
1230 "tflite" => ModelSource::TfLite,
1231 other => panic!("fixture {name}: unknown source `{other}`"),
1232 };
1233
1234 fn parse_dtype(s: &str) -> DType {
1235 match s {
1236 "float32" => DType::Float32,
1237 "float16" => DType::Float16,
1238 "int8" => DType::Int8,
1239 "uint8" => DType::Uint8,
1240 "int16" => DType::Int16,
1241 "uint16" => DType::Uint16,
1242 "int32" => DType::Int32,
1243 "uint32" => DType::Uint32,
1244 other => panic!("unknown dtype `{other}`"),
1245 }
1246 }
1247
1248 fn parse_tensor(v: &serde_json::Value) -> TensorInfo {
1249 TensorInfo {
1250 name: v["name"]
1251 .as_str()
1252 .expect("tensor `name` is a string")
1253 .to_string(),
1254 shape: v["shape"]
1255 .as_array()
1256 .expect("tensor `shape` is an array")
1257 .iter()
1258 .map(|d| d.as_u64().expect("shape dim is a number") as usize)
1259 .collect(),
1260 dtype: parse_dtype(v["dtype"].as_str().expect("tensor `dtype` is a string")),
1261 quantization: None,
1265 }
1266 }
1267
1268 let inputs = json["inputs"]
1269 .as_array()
1270 .expect("fixture `inputs` is an array")
1271 .iter()
1272 .map(parse_tensor)
1273 .collect();
1274 let outputs = json["outputs"]
1275 .as_array()
1276 .expect("fixture `outputs` is an array")
1277 .iter()
1278 .map(parse_tensor)
1279 .collect();
1280 let metadata = json["metadata"]
1281 .as_object()
1282 .expect("fixture `metadata` is an object")
1283 .iter()
1284 .map(|(k, v)| {
1285 (
1286 k.clone(),
1287 v.as_str().expect("metadata value is a string").to_string(),
1288 )
1289 })
1290 .collect();
1291
1292 ModelSignals {
1293 source,
1294 inputs,
1295 outputs,
1296 metadata,
1297 }
1298 }
1299
1300 fn synthetic_metadata(nc: usize, task: &str, end2end: &str) -> BTreeMap<String, String> {
1304 let names = (0..nc)
1305 .map(|i| format!("{i}: 'c{i}'"))
1306 .collect::<Vec<_>>()
1307 .join(", ");
1308 let mut m = BTreeMap::new();
1309 m.insert("names".into(), format!("{{{names}}}"));
1310 m.insert("task".into(), task.into());
1311 m.insert("end2end".into(), end2end.into());
1312 m
1313 }
1314
1315 #[test]
1316 fn infer_transposed_det_layout_classified() {
1317 let s = ModelSignals {
1323 source: ModelSource::TfLite,
1324 inputs: vec![TensorInfo {
1325 name: "images".into(),
1326 shape: vec![1, 640, 640, 3],
1327 dtype: DType::Float32,
1328 quantization: None,
1329 }],
1330 outputs: vec![TensorInfo {
1331 name: "output0".into(),
1332 shape: vec![1, 8400, 84],
1333 dtype: DType::Float32,
1334 quantization: None,
1335 }],
1336 metadata: synthetic_metadata(80, "detect", "False"),
1337 };
1338 let r = infer_ultralytics_schema(&s).unwrap();
1339 let o = &r.schema.outputs[0];
1340 assert_eq!(
1341 o.dshape,
1342 vec![
1343 (DimName::Batch, 1),
1344 (DimName::NumBoxes, 8400),
1345 (DimName::NumFeatures, 84),
1346 ]
1347 );
1348 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1349 }
1350
1351 #[test]
1352 fn infer_yolo26n_onnx_end_to_end() {
1353 let s = signals_from_fixture("yolo26n");
1354 let r = infer_ultralytics_schema(&s).unwrap();
1355 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1356 assert_eq!(r.schema.outputs[0].shape[2], 6);
1357 }
1358
1359 #[test]
1360 fn infer_class_count_mismatch_rejected() {
1361 let mut s = signals_from_fixture("yolov8n");
1362 s.metadata.insert("names".into(), "{0: 'only-one'}".into()); assert!(matches!(
1364 infer_ultralytics_schema(&s),
1365 Err(InferError::ClassCountMismatch {
1366 expected: 1,
1367 found: 80
1368 })
1369 ));
1370 }
1371
1372 #[test]
1373 fn infer_seg_class_count_mismatch_rejected() {
1374 let mut s = signals_from_fixture("yolov8n-seg");
1383 s.metadata.insert("names".into(), "{0: 'only-one'}".into()); assert!(matches!(
1385 infer_ultralytics_schema(&s),
1386 Err(InferError::ClassCountMismatch {
1387 expected: 1,
1388 found: 80
1389 })
1390 ));
1391 }
1392
1393 #[test]
1394 fn infer_end2end_flag_shape_conflict_rejected() {
1395 let mut s = signals_from_fixture("yolov8n");
1400 s.metadata.insert("end2end".into(), "True".into());
1401 assert!(matches!(
1402 infer_ultralytics_schema(&s),
1403 Err(InferError::UnsupportedLayout(_))
1404 ));
1405 }
1406
1407 #[test]
1408 fn infer_yolov8n_seg_onnx() {
1409 let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n-seg")).unwrap();
1410 assert_eq!(r.schema.outputs.len(), 2);
1411 let proto = r
1412 .schema
1413 .outputs
1414 .iter()
1415 .find(|o| o.type_ == Some(LogicalType::Protos))
1416 .unwrap();
1417 assert_eq!(proto.dshape[1].0, DimName::NumProtos); let det = r
1419 .schema
1420 .outputs
1421 .iter()
1422 .find(|o| o.type_ == Some(LogicalType::Detection))
1423 .unwrap();
1424 assert!(det.shape.contains(&116)); }
1426
1427 #[test]
1428 fn infer_yolov8n_seg_tflite_proto_nchw() {
1429 let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n-seg_float32")).unwrap();
1436 let proto = r
1437 .schema
1438 .outputs
1439 .iter()
1440 .find(|o| o.type_ == Some(LogicalType::Protos))
1441 .unwrap();
1442 assert_eq!(proto.dshape[1].0, DimName::NumProtos); assert_eq!(proto.dshape[1].1, 32);
1444 }
1445
1446 #[test]
1447 fn infer_yolo26n_seg_end_to_end() {
1448 let r = infer_ultralytics_schema(&signals_from_fixture("yolo26n-seg")).unwrap();
1449 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1450 assert_eq!(r.schema.outputs.len(), 2);
1451 }
1452
1453 #[test]
1454 fn infer_nhwc_proto_layout_classified() {
1455 let s = ModelSignals {
1459 source: ModelSource::TfLite,
1460 inputs: vec![TensorInfo {
1461 name: "images".into(),
1462 shape: vec![1, 640, 640, 3],
1463 dtype: DType::Float32,
1464 quantization: None,
1465 }],
1466 outputs: vec![
1467 TensorInfo {
1468 name: "output0".into(),
1469 shape: vec![1, 116, 8400],
1470 dtype: DType::Float32,
1471 quantization: None,
1472 },
1473 TensorInfo {
1474 name: "output1".into(),
1475 shape: vec![1, 160, 160, 32],
1476 dtype: DType::Float32,
1477 quantization: None,
1478 },
1479 ],
1480 metadata: synthetic_metadata(80, "segment", "False"),
1481 };
1482 let r = infer_ultralytics_schema(&s).unwrap();
1483 let proto = r
1484 .schema
1485 .outputs
1486 .iter()
1487 .find(|o| o.type_ == Some(LogicalType::Protos))
1488 .unwrap();
1489 assert_eq!(proto.dshape[3].0, DimName::NumProtos); assert_eq!(proto.dshape[3].1, 32);
1491 }
1492
1493 #[test]
1494 fn infer_segment_task_without_proto_rejected() {
1495 let s = ModelSignals {
1496 source: ModelSource::Onnx,
1497 inputs: vec![TensorInfo {
1498 name: "images".into(),
1499 shape: vec![1, 3, 640, 640],
1500 dtype: DType::Float32,
1501 quantization: None,
1502 }],
1503 outputs: vec![TensorInfo {
1504 name: "output0".into(),
1505 shape: vec![1, 84, 8400],
1506 dtype: DType::Float32,
1507 quantization: None,
1508 }],
1509 metadata: synthetic_metadata(80, "segment", "False"),
1510 };
1511 assert!(matches!(
1512 infer_ultralytics_schema(&s),
1513 Err(InferError::UnsupportedLayout(_))
1514 ));
1515 }
1516
1517 #[test]
1518 fn infer_detect_task_with_proto_rejected() {
1519 let s = ModelSignals {
1520 source: ModelSource::Onnx,
1521 inputs: vec![TensorInfo {
1522 name: "images".into(),
1523 shape: vec![1, 3, 640, 640],
1524 dtype: DType::Float32,
1525 quantization: None,
1526 }],
1527 outputs: vec![
1528 TensorInfo {
1529 name: "output0".into(),
1530 shape: vec![1, 84, 8400],
1531 dtype: DType::Float32,
1532 quantization: None,
1533 },
1534 TensorInfo {
1535 name: "output1".into(),
1536 shape: vec![1, 32, 160, 160],
1537 dtype: DType::Float32,
1538 quantization: None,
1539 },
1540 ],
1541 metadata: synthetic_metadata(80, "detect", "False"),
1542 };
1543 assert!(matches!(
1544 infer_ultralytics_schema(&s),
1545 Err(InferError::UnsupportedLayout(_))
1546 ));
1547 }
1548
1549 #[test]
1550 fn infer_int8_tflite_classifies_like_float32() {
1551 let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n_int8")).unwrap();
1557 let det = &r.schema.outputs[0];
1558 assert_eq!(det.type_, Some(LogicalType::Detection));
1559 assert_eq!(det.dtype, Some(DType::Float32));
1560 assert_eq!(det.quantization, None);
1561 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1562 assert_eq!(det.normalized, Some(true));
1563 }
1564
1565 #[test]
1566 fn infer_quantized_boundary_carries_quantization() {
1567 let quant = Quantization {
1572 scale: vec![0.02],
1573 zero_point: Some(vec![-5]),
1574 axis: None,
1575 dtype: Some(DType::Int8),
1576 };
1577 let s = ModelSignals {
1578 source: ModelSource::TfLite,
1579 inputs: vec![TensorInfo {
1580 name: "images".into(),
1581 shape: vec![1, 640, 640, 3],
1582 dtype: DType::Float32,
1583 quantization: None,
1584 }],
1585 outputs: vec![TensorInfo {
1586 name: "output0".into(),
1587 shape: vec![1, 84, 8400],
1588 dtype: DType::Int8,
1589 quantization: Some(quant.clone()),
1590 }],
1591 metadata: synthetic_metadata(80, "detect", "False"),
1592 };
1593 let r = infer_ultralytics_schema(&s).unwrap();
1594 let det = &r.schema.outputs[0];
1595 assert_eq!(det.dtype, Some(DType::Int8));
1596 assert_eq!(det.quantization, Some(quant));
1597 }
1598
1599 #[test]
1600 fn infer_rejects_batched_input() {
1601 let s = ModelSignals {
1606 source: ModelSource::Onnx,
1607 inputs: vec![TensorInfo {
1608 name: "images".into(),
1609 shape: vec![4, 3, 640, 640],
1610 dtype: DType::Float32,
1611 quantization: None,
1612 }],
1613 outputs: vec![TensorInfo {
1614 name: "output0".into(),
1615 shape: vec![1, 84, 8400],
1616 dtype: DType::Float32,
1617 quantization: None,
1618 }],
1619 metadata: synthetic_metadata(80, "detect", "False"),
1620 };
1621 match infer_ultralytics_schema(&s) {
1622 Err(InferError::UnsupportedLayout(msg)) => {
1623 assert!(msg.contains("batch 4"), "unexpected message: {msg}");
1624 }
1625 other => panic!("expected UnsupportedLayout, got {other:?}"),
1626 }
1627 }
1628
1629 #[test]
1630 fn infer_rejects_input_without_a_channel_dim() {
1631 let s = ModelSignals {
1632 source: ModelSource::Onnx,
1633 inputs: vec![TensorInfo {
1634 name: "images".into(),
1635 shape: vec![1, 4, 640, 640],
1636 dtype: DType::Float32,
1637 quantization: None,
1638 }],
1639 outputs: vec![TensorInfo {
1640 name: "output0".into(),
1641 shape: vec![1, 84, 8400],
1642 dtype: DType::Float32,
1643 quantization: None,
1644 }],
1645 metadata: synthetic_metadata(80, "detect", "False"),
1646 };
1647 match infer_ultralytics_schema(&s) {
1648 Err(InferError::UnsupportedLayout(msg)) => {
1649 assert!(msg.contains("channel dimension"), "unexpected: {msg}");
1650 }
1651 other => panic!("expected UnsupportedLayout, got {other:?}"),
1652 }
1653 }
1654
1655 #[test]
1656 fn infer_rejects_model_with_no_rank4_input() {
1657 let s = ModelSignals {
1658 source: ModelSource::Onnx,
1659 inputs: vec![TensorInfo {
1660 name: "images".into(),
1661 shape: vec![1, 3],
1662 dtype: DType::Float32,
1663 quantization: None,
1664 }],
1665 outputs: vec![TensorInfo {
1666 name: "output0".into(),
1667 shape: vec![1, 84, 8400],
1668 dtype: DType::Float32,
1669 quantization: None,
1670 }],
1671 metadata: synthetic_metadata(80, "detect", "False"),
1672 };
1673 match infer_ultralytics_schema(&s) {
1674 Err(InferError::UnsupportedLayout(msg)) => {
1675 assert!(msg.contains("no rank-4 model input"), "unexpected: {msg}");
1676 }
1677 other => panic!("expected UnsupportedLayout, got {other:?}"),
1678 }
1679 }
1680
1681 fn det_signals(nc: usize, out_shape: Vec<usize>, end2end: &str) -> ModelSignals {
1683 ModelSignals {
1684 source: ModelSource::Onnx,
1685 inputs: vec![TensorInfo {
1686 name: "images".into(),
1687 shape: vec![1, 3, 640, 640],
1688 dtype: DType::Float32,
1689 quantization: None,
1690 }],
1691 outputs: vec![TensorInfo {
1692 name: "output0".into(),
1693 shape: out_shape,
1694 dtype: DType::Float32,
1695 quantization: None,
1696 }],
1697 metadata: synthetic_metadata(nc, "detect", end2end),
1698 }
1699 }
1700
1701 #[test]
1702 fn infer_end_to_end_accepts_any_max_det_up_to_the_anchor_count() {
1703 for n in [1, 300, 2000, 8400] {
1715 let r = infer_ultralytics_schema(&det_signals(80, vec![1, n, 6], "True"))
1716 .unwrap_or_else(|e| panic!("max_det={n} must infer: {e}"));
1717 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1718 assert_eq!(r.schema.outputs[0].type_, Some(LogicalType::Detections));
1719 assert_eq!(
1720 r.schema.outputs[0].dshape,
1721 vec![
1722 (DimName::Batch, 1),
1723 (DimName::NumBoxes, n),
1724 (DimName::NumFeatures, 6),
1725 ],
1726 "max_det={n}"
1727 );
1728 assert_eq!(r.schema.nms, None, "max_det={n}");
1730 }
1731 }
1732
1733 #[test]
1734 fn infer_end_to_end_rejects_more_rows_than_anchors() {
1735 match infer_ultralytics_schema(&det_signals(80, vec![1, 8401, 6], "True")) {
1739 Err(InferError::UnsupportedLayout(msg)) => {
1740 assert!(msg.contains("N<=8400"), "unexpected message: {msg}");
1741 }
1742 other => panic!("expected UnsupportedLayout, got {other:?}"),
1743 }
1744 }
1745
1746 #[test]
1747 fn infer_legacy_nms_model_is_unaffected_by_max_det() {
1748 let r = infer_ultralytics_schema(&det_signals(80, vec![1, 84, 8400], "False")).unwrap();
1754 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1755 assert_eq!(r.schema.outputs[0].type_, Some(LogicalType::Detection));
1756 assert_eq!(
1757 r.schema.outputs[0].dshape,
1758 vec![
1759 (DimName::Batch, 1),
1760 (DimName::NumFeatures, 84),
1761 (DimName::NumBoxes, 8400),
1762 ]
1763 );
1764 assert_eq!(r.schema.nms, Some(NmsMode::ClassAware));
1768
1769 let t = infer_ultralytics_schema(&det_signals(80, vec![1, 8400, 84], "False")).unwrap();
1772 assert_eq!(t.schema.decoder_version, Some(DecoderVersion::Yolov8));
1773 assert_eq!(
1774 t.schema.outputs[0].dshape,
1775 vec![
1776 (DimName::Batch, 1),
1777 (DimName::NumBoxes, 8400),
1778 (DimName::NumFeatures, 84),
1779 ]
1780 );
1781 }
1782
1783 #[test]
1784 fn infer_pre_nms_wins_when_a_shape_reads_as_both() {
1785 let mut m = synthetic_metadata(2, "detect", "False");
1790 m.remove("end2end");
1791 let s = ModelSignals {
1792 metadata: m,
1793 ..det_signals(2, vec![1, 8400, 6], "False")
1794 };
1795 let r = infer_ultralytics_schema(&s).unwrap();
1796 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1797 }
1798
1799 #[test]
1800 fn meta_empty_names_rejected() {
1801 let mut m = BTreeMap::new();
1804 m.insert("names".into(), "{}".into());
1805 match UltralyticsMeta::from_metadata(&m) {
1806 Err(InferError::BadNames(msg)) => assert!(msg.contains("empty"), "{msg}"),
1807 other => panic!("expected BadNames, got {other:?}"),
1808 }
1809 }
1810
1811 #[test]
1812 fn meta_malformed_end2end_is_an_error_not_an_absent_key() {
1813 let mut m = synthetic_metadata(80, "detect", "False");
1817 m.insert("end2end".into(), "yes".into());
1818 match UltralyticsMeta::from_metadata(&m) {
1819 Err(InferError::BadMetadata(msg)) => assert!(msg.contains("end2end"), "{msg}"),
1820 other => panic!("expected BadMetadata, got {other:?}"),
1821 }
1822
1823 m.remove("end2end");
1825 assert_eq!(UltralyticsMeta::from_metadata(&m).unwrap().end2end, None);
1826 }
1827
1828 #[test]
1829 fn meta_foreign_vendor_rejected_but_missing_provenance_allowed() {
1830 let mut m = synthetic_metadata(80, "detect", "False");
1834 m.insert("author".into(), "SomeOtherVendor".into());
1835 assert!(matches!(
1836 UltralyticsMeta::from_metadata(&m),
1837 Err(InferError::NotUltralytics(_))
1838 ));
1839
1840 m.insert("author".into(), "Ultralytics".into());
1842 m.insert("docs".into(), "https://docs.ultralytics.com".into());
1843 assert!(UltralyticsMeta::from_metadata(&m).is_ok());
1844
1845 m.remove("author");
1848 m.remove("docs");
1849 assert!(UltralyticsMeta::from_metadata(&m).is_ok());
1850 }
1851
1852 #[test]
1853 fn infer_zero_row_end_to_end_rejected() {
1854 assert!(matches!(
1857 infer_ultralytics_schema(&det_signals(80, vec![1, 0, 6], "True")),
1858 Err(InferError::UnsupportedLayout(_))
1859 ));
1860 }
1861
1862 #[test]
1863 fn infer_duplicate_matching_outputs_are_ambiguous() {
1864 let input = TensorInfo {
1865 name: "images".into(),
1866 shape: vec![1, 3, 640, 640],
1867 dtype: DType::Float32,
1868 quantization: None,
1869 };
1870 let t = |name: &str, shape: Vec<usize>| TensorInfo {
1871 name: name.into(),
1872 shape,
1873 dtype: DType::Float32,
1874 quantization: None,
1875 };
1876
1877 let pre = ModelSignals {
1880 source: ModelSource::Onnx,
1881 inputs: vec![input.clone()],
1882 outputs: vec![t("a", vec![1, 84, 8400]), t("b", vec![1, 84, 8400])],
1883 metadata: synthetic_metadata(80, "detect", "False"),
1884 };
1885 match infer_ultralytics_schema(&pre) {
1886 Err(InferError::AmbiguousLayout(msg)) => {
1887 assert!(msg.contains("`a`") && msg.contains("`b`"), "{msg}");
1888 }
1889 other => panic!("expected AmbiguousLayout, got {other:?}"),
1890 }
1891
1892 let e2e = ModelSignals {
1894 source: ModelSource::Onnx,
1895 inputs: vec![input],
1896 outputs: vec![t("a", vec![1, 300, 6]), t("b", vec![1, 200, 6])],
1897 metadata: synthetic_metadata(80, "detect", "True"),
1898 };
1899 assert!(matches!(
1900 infer_ultralytics_schema(&e2e),
1901 Err(InferError::AmbiguousLayout(_))
1902 ));
1903 }
1904
1905 #[test]
1906 fn infer_malformed_per_tensor_quantization_rejected() {
1907 let build = |q: Quantization| ModelSignals {
1908 source: ModelSource::TfLite,
1909 inputs: vec![TensorInfo {
1910 name: "images".into(),
1911 shape: vec![1, 640, 640, 3],
1912 dtype: DType::Float32,
1913 quantization: None,
1914 }],
1915 outputs: vec![TensorInfo {
1916 name: "output0".into(),
1917 shape: vec![1, 84, 8400],
1918 dtype: DType::Int8,
1919 quantization: Some(q),
1920 }],
1921 metadata: synthetic_metadata(80, "detect", "False"),
1922 };
1923
1924 match infer_ultralytics_schema(&build(Quantization {
1927 scale: vec![],
1928 zero_point: Some(vec![]),
1929 axis: None,
1930 dtype: Some(DType::Int8),
1931 })) {
1932 Err(InferError::UnsupportedQuantization(msg)) => {
1933 assert!(msg.contains("no scale"), "{msg}");
1934 }
1935 other => panic!("expected UnsupportedQuantization, got {other:?}"),
1936 }
1937
1938 match infer_ultralytics_schema(&build(Quantization {
1941 scale: vec![0.02],
1942 zero_point: Some(vec![-5, -7]),
1943 axis: None,
1944 dtype: Some(DType::Int8),
1945 })) {
1946 Err(InferError::UnsupportedQuantization(msg)) => {
1947 assert!(msg.contains("zero point"), "{msg}");
1948 }
1949 other => panic!("expected UnsupportedQuantization, got {other:?}"),
1950 }
1951
1952 assert!(infer_ultralytics_schema(&build(Quantization {
1954 scale: vec![0.02],
1955 zero_point: None,
1956 axis: None,
1957 dtype: Some(DType::Int8),
1958 }))
1959 .is_ok());
1960 }
1961
1962 #[test]
1963 fn infer_pre_nms_is_independent_of_output_order() {
1964 let det = TensorInfo {
1970 name: "output0".into(),
1971 shape: vec![1, 84, 8400],
1972 dtype: DType::Float32,
1973 quantization: None,
1974 };
1975 let aux = TensorInfo {
1976 name: "boxes".into(),
1977 shape: vec![1, 4, 8400],
1978 dtype: DType::Float32,
1979 quantization: None,
1980 };
1981 let build = |outputs: Vec<TensorInfo>| ModelSignals {
1982 source: ModelSource::Onnx,
1983 inputs: vec![TensorInfo {
1984 name: "images".into(),
1985 shape: vec![1, 3, 640, 640],
1986 dtype: DType::Float32,
1987 quantization: None,
1988 }],
1989 outputs,
1990 metadata: synthetic_metadata(80, "detect", "False"),
1991 };
1992
1993 let det_first = infer_ultralytics_schema(&build(vec![det.clone(), aux.clone()]))
1994 .expect("detection tensor first must classify");
1995 let aux_first = infer_ultralytics_schema(&build(vec![aux, det]))
1996 .expect("an auxiliary anchor-shaped tensor first must not shadow the detection tensor");
1997 assert_eq!(det_first.schema.outputs[0].name, Some("output0".into()));
1998 assert_eq!(aux_first.schema.outputs[0].name, Some("output0".into()));
1999 assert_eq!(det_first.schema, aux_first.schema);
2000 }
2001
2002 #[test]
2003 fn infer_class_count_mismatch_survives_a_full_scan() {
2004 let s = ModelSignals {
2007 source: ModelSource::Onnx,
2008 inputs: vec![TensorInfo {
2009 name: "images".into(),
2010 shape: vec![1, 3, 640, 640],
2011 dtype: DType::Float32,
2012 quantization: None,
2013 }],
2014 outputs: vec![TensorInfo {
2015 name: "output0".into(),
2016 shape: vec![1, 84, 8400],
2017 dtype: DType::Float32,
2018 quantization: None,
2019 }],
2020 metadata: synthetic_metadata(10, "detect", "False"),
2022 };
2023 assert!(matches!(
2024 infer_ultralytics_schema(&s),
2025 Err(InferError::ClassCountMismatch {
2026 expected: 10,
2027 found: 80
2028 })
2029 ));
2030 }
2031
2032 struct FixtureExpectation {
2042 name: &'static str,
2043 version: DecoderVersion,
2044 det_type: LogicalType,
2045 det_dshape: &'static [(DimName, usize)],
2047 normalized: bool,
2048 nms: Option<NmsMode>,
2049 proto: Option<(usize, usize, usize)>,
2051 }
2052
2053 const A: usize = 8400; const NC: usize = 80;
2055
2056 fn fixture_expectations() -> Vec<FixtureExpectation> {
2057 use DimName::{Batch, NumBoxes, NumFeatures};
2058 const DET: &[(DimName, usize)] = &[(Batch, 1), (NumFeatures, 4 + NC), (NumBoxes, A)];
2062 const SEG: &[(DimName, usize)] = &[(Batch, 1), (NumFeatures, 4 + NC + 32), (NumBoxes, A)];
2063 const E2E: &[(DimName, usize)] = &[(Batch, 1), (NumBoxes, 300), (NumFeatures, 6)];
2065 const E2E_SEG: &[(DimName, usize)] = &[(Batch, 1), (NumBoxes, 300), (NumFeatures, 6 + 32)];
2066
2067 let pre_nms = |name, det_dshape, normalized, proto| FixtureExpectation {
2068 name,
2069 version: DecoderVersion::Yolov8,
2070 det_type: LogicalType::Detection,
2071 det_dshape,
2072 normalized,
2073 nms: Some(NmsMode::ClassAware),
2074 proto,
2075 };
2076 let e2e = |name, det_dshape, normalized, proto| FixtureExpectation {
2077 name,
2078 version: DecoderVersion::Yolo26,
2079 det_type: LogicalType::Detections,
2080 det_dshape,
2081 normalized,
2082 nms: None,
2084 proto,
2085 };
2086 const P: Option<(usize, usize, usize)> = Some((32, 160, 160));
2089 vec![
2090 pre_nms("yolov8n", DET, false, None),
2091 pre_nms("yolo11n", DET, false, None),
2092 pre_nms("yolov8n-seg", SEG, false, P),
2093 pre_nms("yolo11n-seg", SEG, false, P),
2094 pre_nms("yolov8n_float32", DET, true, None),
2095 pre_nms("yolov8n_int8", DET, true, None),
2096 pre_nms("yolov8n-seg_float32", SEG, true, P),
2097 pre_nms("yolov8n-seg_int8", SEG, true, P),
2098 e2e("yolo26n", E2E, false, None),
2099 e2e("yolo26n-seg", E2E_SEG, false, P),
2100 e2e("yolo26n_float32", E2E, true, None),
2101 ]
2102 }
2103
2104 #[test]
2105 fn every_captured_fixture_infers_the_expected_schema() {
2106 for want in fixture_expectations() {
2107 let name = want.name;
2108 let r = infer_ultralytics_schema(&signals_from_fixture(name))
2109 .unwrap_or_else(|e| panic!("{name}: inference failed: {e}"));
2110
2111 assert_eq!(r.labels.len(), NC, "{name}: class count");
2112 assert_eq!(
2113 r.labels[0], "person",
2114 "{name}: labels must be index-ordered"
2115 );
2116 assert_eq!(r.labels[NC - 1], "toothbrush", "{name}: labels index order");
2117 assert_eq!(
2118 r.schema.decoder_version,
2119 Some(want.version),
2120 "{name}: version"
2121 );
2122 assert_eq!(r.schema.nms, want.nms, "{name}: nms mode");
2123
2124 let det = &r.schema.outputs[0];
2125 assert_eq!(det.type_, Some(want.det_type), "{name}: detection type");
2126 assert_eq!(det.dshape, want.det_dshape, "{name}: detection dshape");
2127 assert_eq!(det.normalized, Some(want.normalized), "{name}: normalized");
2128 assert_eq!(
2129 det.decoder,
2130 Some(DecoderKind::Ultralytics),
2131 "{name}: decoder"
2132 );
2133 assert_eq!(det.encoding, Some(BoxEncoding::Direct), "{name}: encoding");
2134 assert_eq!(
2135 det.score_format,
2136 Some(ScoreFormat::PerClass),
2137 "{name}: scores"
2138 );
2139
2140 match want.proto {
2141 None => {
2142 assert_eq!(r.schema.outputs.len(), 1, "{name}: detection-only");
2143 }
2144 Some((k, ph, pw)) => {
2145 assert_eq!(r.schema.outputs.len(), 2, "{name}: detection + proto");
2146 let proto = &r.schema.outputs[1];
2147 assert_eq!(proto.type_, Some(LogicalType::Protos), "{name}: proto type");
2148 assert_eq!(
2149 proto.dshape,
2150 vec![
2151 (DimName::Batch, 1),
2152 (DimName::NumProtos, k),
2153 (DimName::Height, ph),
2154 (DimName::Width, pw),
2155 ],
2156 "{name}: proto dshape"
2157 );
2158 }
2159 }
2160
2161 let task = if want.proto.is_some() {
2162 "segment"
2163 } else {
2164 "detect"
2165 };
2166 assert!(
2167 r.description.contains(task) && r.description.contains("Ultralytics"),
2168 "{name}: description was `{}`",
2169 r.description
2170 );
2171 }
2172 }
2173
2174 #[test]
2175 fn every_fixture_is_asserted() {
2176 let dir = infer_fixture_dir();
2180 let mut on_disk: Vec<String> = std::fs::read_dir(&dir)
2181 .unwrap_or_else(|e| panic!("{} exists: {e}", dir.display()))
2182 .filter_map(|e| {
2183 let name = e.ok()?.file_name().into_string().ok()?;
2184 name.strip_suffix(".signals.json").map(str::to_string)
2185 })
2186 .collect();
2187 on_disk.sort();
2188
2189 let mut asserted: Vec<String> = fixture_expectations()
2190 .iter()
2191 .map(|f| f.name.to_string())
2192 .collect();
2193 asserted.sort();
2194
2195 assert_eq!(
2196 on_disk, asserted,
2197 "every *.signals.json fixture must appear in fixture_expectations()"
2198 );
2199 }
2200
2201 #[test]
2202 fn infer_other_source_refuses_rather_than_guessing_normalization() {
2203 let s = ModelSignals {
2209 source: ModelSource::Other,
2210 inputs: vec![TensorInfo {
2211 name: "images".into(),
2212 shape: vec![1, 3, 640, 640],
2213 dtype: DType::Float32,
2214 quantization: None,
2215 }],
2216 outputs: vec![TensorInfo {
2217 name: "output0".into(),
2218 shape: vec![1, 84, 8400],
2219 dtype: DType::Float32,
2220 quantization: None,
2221 }],
2222 metadata: synthetic_metadata(80, "detect", "False"),
2223 };
2224 assert!(matches!(
2225 infer_ultralytics_schema(&s),
2226 Err(InferError::UnknownBoxConvention)
2227 ));
2228
2229 let ok = ModelSignals {
2232 source: ModelSource::Onnx,
2233 ..s
2234 };
2235 assert_eq!(
2236 infer_ultralytics_schema(&ok).unwrap().schema.outputs[0].normalized,
2237 Some(false)
2238 );
2239 }
2240
2241 #[test]
2242 fn infer_per_channel_quantization_rejected() {
2243 let s = ModelSignals {
2252 source: ModelSource::TfLite,
2253 inputs: vec![TensorInfo {
2254 name: "images".into(),
2255 shape: vec![1, 640, 640, 3],
2256 dtype: DType::Float32,
2257 quantization: None,
2258 }],
2259 outputs: vec![TensorInfo {
2260 name: "output0".into(),
2261 shape: vec![1, 84, 8400],
2262 dtype: DType::Int8,
2263 quantization: Some(Quantization {
2264 scale: vec![0.02, 0.03],
2265 zero_point: Some(vec![-5, -7]),
2266 axis: Some(1),
2267 dtype: Some(DType::Int8),
2268 }),
2269 }],
2270 metadata: synthetic_metadata(80, "detect", "False"),
2271 };
2272 match infer_ultralytics_schema(&s) {
2273 Err(InferError::UnsupportedQuantization(msg)) => {
2274 assert!(msg.contains("per-channel"), "unexpected message: {msg}");
2275 assert!(
2276 msg.contains("output0"),
2277 "message must name the tensor: {msg}"
2278 );
2279 }
2280 other => panic!("expected UnsupportedQuantization, got {other:?}"),
2281 }
2282 }
2283
2284 #[test]
2285 fn infer_per_channel_proto_quantization_rejected() {
2286 let s = ModelSignals {
2289 source: ModelSource::TfLite,
2290 inputs: vec![TensorInfo {
2291 name: "images".into(),
2292 shape: vec![1, 640, 640, 3],
2293 dtype: DType::Float32,
2294 quantization: None,
2295 }],
2296 outputs: vec![
2297 TensorInfo {
2298 name: "output0".into(),
2299 shape: vec![1, 116, 8400],
2300 dtype: DType::Float32,
2301 quantization: None,
2302 },
2303 TensorInfo {
2304 name: "output1".into(),
2305 shape: vec![1, 32, 160, 160],
2306 dtype: DType::Int8,
2307 quantization: Some(Quantization {
2308 scale: vec![0.02, 0.03],
2309 zero_point: Some(vec![-5, -7]),
2310 axis: Some(1),
2311 dtype: Some(DType::Int8),
2312 }),
2313 },
2314 ],
2315 metadata: synthetic_metadata(80, "segment", "False"),
2316 };
2317 match infer_ultralytics_schema(&s) {
2318 Err(InferError::UnsupportedQuantization(msg)) => {
2319 assert!(
2320 msg.contains("output1"),
2321 "message must name the proto: {msg}"
2322 );
2323 }
2324 other => panic!("expected UnsupportedQuantization, got {other:?}"),
2325 }
2326 }
2327
2328 #[test]
2329 fn infer_integer_output_without_quant_rejected() {
2330 let s = ModelSignals {
2334 source: ModelSource::TfLite,
2335 inputs: vec![TensorInfo {
2336 name: "images".into(),
2337 shape: vec![1, 640, 640, 3],
2338 dtype: DType::Float32,
2339 quantization: None,
2340 }],
2341 outputs: vec![TensorInfo {
2342 name: "output0".into(),
2343 shape: vec![1, 84, 8400],
2344 dtype: DType::Int8,
2345 quantization: None,
2346 }],
2347 metadata: synthetic_metadata(80, "detect", "False"),
2348 };
2349 match infer_ultralytics_schema(&s) {
2350 Err(InferError::UnsupportedLayout(msg)) => {
2351 assert!(
2352 msg.contains("quantization"),
2353 "unexpected UnsupportedLayout message: {msg}"
2354 );
2355 }
2356 other => panic!("expected UnsupportedLayout, got {other:?}"),
2357 }
2358 }
2359
2360 #[test]
2361 fn infer_pre_nms_rejects_non_unit_batch() {
2362 let mut s = signals_from_fixture("yolov8n");
2367 s.outputs[0].shape = vec![2, 84, 8400];
2368 assert!(matches!(
2369 infer_ultralytics_schema(&s),
2370 Err(InferError::UnsupportedLayout(_))
2371 ));
2372 }
2373}