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 #[test]
1051 fn parse_names_python_dict_repr() {
1052 let s = "{0: 'person', 1: 'bicycle', 2: \"fire hydrant\"}";
1053 assert_eq!(
1054 parse_names(s).unwrap(),
1055 vec!["person", "bicycle", "fire hydrant"]
1056 );
1057 }
1058
1059 #[test]
1060 fn parse_names_json_object() {
1061 let s = r#"{"0": "person", "1": "bicycle"}"#;
1062 assert_eq!(parse_names(s).unwrap(), vec!["person", "bicycle"]);
1063 }
1064
1065 #[test]
1066 fn parse_names_orders_by_index_not_insertion() {
1067 let s = "{1: 'b', 0: 'a'}";
1068 assert_eq!(parse_names(s).unwrap(), vec!["a", "b"]);
1069 }
1070
1071 #[test]
1072 fn parse_names_rejects_garbage() {
1073 assert!(matches!(
1074 parse_names("not a dict"),
1075 Err(InferError::BadNames(_))
1076 ));
1077 }
1078
1079 #[test]
1080 fn parse_names_rejects_non_contiguous_indices() {
1081 match parse_names("{0: 'a', 2: 'b'}") {
1083 Err(InferError::BadNames(msg)) => {
1084 assert!(
1085 msg.contains("non-contiguous"),
1086 "unexpected BadNames message: {msg}"
1087 );
1088 }
1089 other => panic!("expected BadNames, got {other:?}"),
1090 }
1091
1092 match parse_names(r#"{"0": "a", "2": "b"}"#) {
1094 Err(InferError::BadNames(msg)) => {
1095 assert!(
1096 msg.contains("non-contiguous"),
1097 "unexpected BadNames message: {msg}"
1098 );
1099 }
1100 other => panic!("expected BadNames, got {other:?}"),
1101 }
1102 }
1103
1104 #[test]
1105 fn meta_from_onnx_props() {
1106 let mut m = BTreeMap::new();
1107 m.insert("names".into(), "{0: 'person'}".into());
1108 m.insert("task".into(), "detect".into());
1109 let u = UltralyticsMeta::from_metadata(&m).unwrap();
1110 assert_eq!(u.names, vec!["person"]);
1111 assert_eq!(u.task, Some(Task::Detect));
1112 }
1113
1114 #[test]
1115 fn meta_task_pose_rejected() {
1116 let mut m = BTreeMap::new();
1117 m.insert("names".into(), "{0: 'person'}".into());
1118 m.insert("task".into(), "pose".into());
1119 assert!(matches!(
1120 UltralyticsMeta::from_metadata(&m),
1121 Err(InferError::UnsupportedTask(_))
1122 ));
1123 }
1124
1125 #[test]
1126 fn meta_missing_names_is_not_ultralytics() {
1127 assert!(matches!(
1128 UltralyticsMeta::from_metadata(&BTreeMap::new()),
1129 Err(InferError::NotUltralytics(_))
1130 ));
1131 }
1132
1133 #[test]
1134 fn meta_end2end_true_or_false_from_onnx_flat_props() {
1135 let mut m = BTreeMap::new();
1136 m.insert("names".into(), "{0: 'person'}".into());
1137 m.insert("end2end".into(), "True".into());
1138 assert_eq!(
1139 UltralyticsMeta::from_metadata(&m).unwrap().end2end,
1140 Some(true)
1141 );
1142
1143 m.insert("end2end".into(), "False".into());
1144 assert_eq!(
1145 UltralyticsMeta::from_metadata(&m).unwrap().end2end,
1146 Some(false)
1147 );
1148 }
1149
1150 fn load_fixture_metadata(name: &str) -> BTreeMap<String, String> {
1155 let path = format!(
1156 "{}/testdata/infer/{name}.signals.json",
1157 env!("CARGO_MANIFEST_DIR")
1158 );
1159 let content = std::fs::read_to_string(&path)
1160 .unwrap_or_else(|e| panic!("failed to read fixture {path}: {e}"));
1161 let json: serde_json::Value =
1162 serde_json::from_str(&content).expect("fixture is valid JSON");
1163 json["metadata"]
1164 .as_object()
1165 .expect("fixture has a metadata object")
1166 .iter()
1167 .map(|(k, v)| {
1168 (
1169 k.clone(),
1170 v.as_str().expect("metadata value is a string").to_string(),
1171 )
1172 })
1173 .collect()
1174 }
1175
1176 #[test]
1177 fn meta_from_real_tflite_metadata_json_envelope() {
1178 let meta = load_fixture_metadata("yolov8n_float32");
1179 let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1180 assert_eq!(u.names.len(), 80);
1181 assert_eq!(u.task, Some(Task::Detect));
1182 }
1183
1184 #[test]
1185 fn meta_end2end_true_on_real_yolo26_tflite_fixture() {
1186 let meta = load_fixture_metadata("yolo26n_float32");
1187 let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1188 assert_eq!(u.end2end, Some(true));
1189 }
1190
1191 #[test]
1192 fn meta_end2end_false_on_real_yolov8_onnx_fixture() {
1193 let meta = load_fixture_metadata("yolov8n");
1194 let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1195 assert_eq!(u.end2end, Some(false));
1196 }
1197
1198 fn signals_from_fixture(name: &str) -> ModelSignals {
1202 let path = format!(
1203 "{}/testdata/infer/{name}.signals.json",
1204 env!("CARGO_MANIFEST_DIR")
1205 );
1206 let content = std::fs::read_to_string(&path)
1207 .unwrap_or_else(|e| panic!("failed to read fixture {path}: {e}"));
1208 let json: serde_json::Value =
1209 serde_json::from_str(&content).expect("fixture is valid JSON");
1210
1211 let source = match json["source"]
1212 .as_str()
1213 .expect("fixture `source` is a string")
1214 {
1215 "onnx" => ModelSource::Onnx,
1216 "tflite" => ModelSource::TfLite,
1217 other => panic!("fixture {name}: unknown source `{other}`"),
1218 };
1219
1220 fn parse_dtype(s: &str) -> DType {
1221 match s {
1222 "float32" => DType::Float32,
1223 "float16" => DType::Float16,
1224 "int8" => DType::Int8,
1225 "uint8" => DType::Uint8,
1226 "int16" => DType::Int16,
1227 "uint16" => DType::Uint16,
1228 "int32" => DType::Int32,
1229 "uint32" => DType::Uint32,
1230 other => panic!("unknown dtype `{other}`"),
1231 }
1232 }
1233
1234 fn parse_tensor(v: &serde_json::Value) -> TensorInfo {
1235 TensorInfo {
1236 name: v["name"]
1237 .as_str()
1238 .expect("tensor `name` is a string")
1239 .to_string(),
1240 shape: v["shape"]
1241 .as_array()
1242 .expect("tensor `shape` is an array")
1243 .iter()
1244 .map(|d| d.as_u64().expect("shape dim is a number") as usize)
1245 .collect(),
1246 dtype: parse_dtype(v["dtype"].as_str().expect("tensor `dtype` is a string")),
1247 quantization: None,
1251 }
1252 }
1253
1254 let inputs = json["inputs"]
1255 .as_array()
1256 .expect("fixture `inputs` is an array")
1257 .iter()
1258 .map(parse_tensor)
1259 .collect();
1260 let outputs = json["outputs"]
1261 .as_array()
1262 .expect("fixture `outputs` is an array")
1263 .iter()
1264 .map(parse_tensor)
1265 .collect();
1266 let metadata = json["metadata"]
1267 .as_object()
1268 .expect("fixture `metadata` is an object")
1269 .iter()
1270 .map(|(k, v)| {
1271 (
1272 k.clone(),
1273 v.as_str().expect("metadata value is a string").to_string(),
1274 )
1275 })
1276 .collect();
1277
1278 ModelSignals {
1279 source,
1280 inputs,
1281 outputs,
1282 metadata,
1283 }
1284 }
1285
1286 fn synthetic_metadata(nc: usize, task: &str, end2end: &str) -> BTreeMap<String, String> {
1290 let names = (0..nc)
1291 .map(|i| format!("{i}: 'c{i}'"))
1292 .collect::<Vec<_>>()
1293 .join(", ");
1294 let mut m = BTreeMap::new();
1295 m.insert("names".into(), format!("{{{names}}}"));
1296 m.insert("task".into(), task.into());
1297 m.insert("end2end".into(), end2end.into());
1298 m
1299 }
1300
1301 #[test]
1302 fn infer_transposed_det_layout_classified() {
1303 let s = ModelSignals {
1309 source: ModelSource::TfLite,
1310 inputs: vec![TensorInfo {
1311 name: "images".into(),
1312 shape: vec![1, 640, 640, 3],
1313 dtype: DType::Float32,
1314 quantization: None,
1315 }],
1316 outputs: vec![TensorInfo {
1317 name: "output0".into(),
1318 shape: vec![1, 8400, 84],
1319 dtype: DType::Float32,
1320 quantization: None,
1321 }],
1322 metadata: synthetic_metadata(80, "detect", "False"),
1323 };
1324 let r = infer_ultralytics_schema(&s).unwrap();
1325 let o = &r.schema.outputs[0];
1326 assert_eq!(
1327 o.dshape,
1328 vec![
1329 (DimName::Batch, 1),
1330 (DimName::NumBoxes, 8400),
1331 (DimName::NumFeatures, 84),
1332 ]
1333 );
1334 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1335 }
1336
1337 #[test]
1338 fn infer_yolo26n_onnx_end_to_end() {
1339 let s = signals_from_fixture("yolo26n");
1340 let r = infer_ultralytics_schema(&s).unwrap();
1341 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1342 assert_eq!(r.schema.outputs[0].shape[2], 6);
1343 }
1344
1345 #[test]
1346 fn infer_class_count_mismatch_rejected() {
1347 let mut s = signals_from_fixture("yolov8n");
1348 s.metadata.insert("names".into(), "{0: 'only-one'}".into()); assert!(matches!(
1350 infer_ultralytics_schema(&s),
1351 Err(InferError::ClassCountMismatch {
1352 expected: 1,
1353 found: 80
1354 })
1355 ));
1356 }
1357
1358 #[test]
1359 fn infer_seg_class_count_mismatch_rejected() {
1360 let mut s = signals_from_fixture("yolov8n-seg");
1369 s.metadata.insert("names".into(), "{0: 'only-one'}".into()); assert!(matches!(
1371 infer_ultralytics_schema(&s),
1372 Err(InferError::ClassCountMismatch {
1373 expected: 1,
1374 found: 80
1375 })
1376 ));
1377 }
1378
1379 #[test]
1380 fn infer_end2end_flag_shape_conflict_rejected() {
1381 let mut s = signals_from_fixture("yolov8n");
1386 s.metadata.insert("end2end".into(), "True".into());
1387 assert!(matches!(
1388 infer_ultralytics_schema(&s),
1389 Err(InferError::UnsupportedLayout(_))
1390 ));
1391 }
1392
1393 #[test]
1394 fn infer_yolov8n_seg_onnx() {
1395 let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n-seg")).unwrap();
1396 assert_eq!(r.schema.outputs.len(), 2);
1397 let proto = r
1398 .schema
1399 .outputs
1400 .iter()
1401 .find(|o| o.type_ == Some(LogicalType::Protos))
1402 .unwrap();
1403 assert_eq!(proto.dshape[1].0, DimName::NumProtos); let det = r
1405 .schema
1406 .outputs
1407 .iter()
1408 .find(|o| o.type_ == Some(LogicalType::Detection))
1409 .unwrap();
1410 assert!(det.shape.contains(&116)); }
1412
1413 #[test]
1414 fn infer_yolov8n_seg_tflite_proto_nchw() {
1415 let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n-seg_float32")).unwrap();
1422 let proto = r
1423 .schema
1424 .outputs
1425 .iter()
1426 .find(|o| o.type_ == Some(LogicalType::Protos))
1427 .unwrap();
1428 assert_eq!(proto.dshape[1].0, DimName::NumProtos); assert_eq!(proto.dshape[1].1, 32);
1430 }
1431
1432 #[test]
1433 fn infer_yolo26n_seg_end_to_end() {
1434 let r = infer_ultralytics_schema(&signals_from_fixture("yolo26n-seg")).unwrap();
1435 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1436 assert_eq!(r.schema.outputs.len(), 2);
1437 }
1438
1439 #[test]
1440 fn infer_nhwc_proto_layout_classified() {
1441 let s = ModelSignals {
1445 source: ModelSource::TfLite,
1446 inputs: vec![TensorInfo {
1447 name: "images".into(),
1448 shape: vec![1, 640, 640, 3],
1449 dtype: DType::Float32,
1450 quantization: None,
1451 }],
1452 outputs: vec![
1453 TensorInfo {
1454 name: "output0".into(),
1455 shape: vec![1, 116, 8400],
1456 dtype: DType::Float32,
1457 quantization: None,
1458 },
1459 TensorInfo {
1460 name: "output1".into(),
1461 shape: vec![1, 160, 160, 32],
1462 dtype: DType::Float32,
1463 quantization: None,
1464 },
1465 ],
1466 metadata: synthetic_metadata(80, "segment", "False"),
1467 };
1468 let r = infer_ultralytics_schema(&s).unwrap();
1469 let proto = r
1470 .schema
1471 .outputs
1472 .iter()
1473 .find(|o| o.type_ == Some(LogicalType::Protos))
1474 .unwrap();
1475 assert_eq!(proto.dshape[3].0, DimName::NumProtos); assert_eq!(proto.dshape[3].1, 32);
1477 }
1478
1479 #[test]
1480 fn infer_segment_task_without_proto_rejected() {
1481 let s = ModelSignals {
1482 source: ModelSource::Onnx,
1483 inputs: vec![TensorInfo {
1484 name: "images".into(),
1485 shape: vec![1, 3, 640, 640],
1486 dtype: DType::Float32,
1487 quantization: None,
1488 }],
1489 outputs: vec![TensorInfo {
1490 name: "output0".into(),
1491 shape: vec![1, 84, 8400],
1492 dtype: DType::Float32,
1493 quantization: None,
1494 }],
1495 metadata: synthetic_metadata(80, "segment", "False"),
1496 };
1497 assert!(matches!(
1498 infer_ultralytics_schema(&s),
1499 Err(InferError::UnsupportedLayout(_))
1500 ));
1501 }
1502
1503 #[test]
1504 fn infer_detect_task_with_proto_rejected() {
1505 let s = ModelSignals {
1506 source: ModelSource::Onnx,
1507 inputs: vec![TensorInfo {
1508 name: "images".into(),
1509 shape: vec![1, 3, 640, 640],
1510 dtype: DType::Float32,
1511 quantization: None,
1512 }],
1513 outputs: vec![
1514 TensorInfo {
1515 name: "output0".into(),
1516 shape: vec![1, 84, 8400],
1517 dtype: DType::Float32,
1518 quantization: None,
1519 },
1520 TensorInfo {
1521 name: "output1".into(),
1522 shape: vec![1, 32, 160, 160],
1523 dtype: DType::Float32,
1524 quantization: None,
1525 },
1526 ],
1527 metadata: synthetic_metadata(80, "detect", "False"),
1528 };
1529 assert!(matches!(
1530 infer_ultralytics_schema(&s),
1531 Err(InferError::UnsupportedLayout(_))
1532 ));
1533 }
1534
1535 #[test]
1536 fn infer_int8_tflite_classifies_like_float32() {
1537 let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n_int8")).unwrap();
1543 let det = &r.schema.outputs[0];
1544 assert_eq!(det.type_, Some(LogicalType::Detection));
1545 assert_eq!(det.dtype, Some(DType::Float32));
1546 assert_eq!(det.quantization, None);
1547 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1548 assert_eq!(det.normalized, Some(true));
1549 }
1550
1551 #[test]
1552 fn infer_quantized_boundary_carries_quantization() {
1553 let quant = Quantization {
1558 scale: vec![0.02],
1559 zero_point: Some(vec![-5]),
1560 axis: None,
1561 dtype: Some(DType::Int8),
1562 };
1563 let s = ModelSignals {
1564 source: ModelSource::TfLite,
1565 inputs: vec![TensorInfo {
1566 name: "images".into(),
1567 shape: vec![1, 640, 640, 3],
1568 dtype: DType::Float32,
1569 quantization: None,
1570 }],
1571 outputs: vec![TensorInfo {
1572 name: "output0".into(),
1573 shape: vec![1, 84, 8400],
1574 dtype: DType::Int8,
1575 quantization: Some(quant.clone()),
1576 }],
1577 metadata: synthetic_metadata(80, "detect", "False"),
1578 };
1579 let r = infer_ultralytics_schema(&s).unwrap();
1580 let det = &r.schema.outputs[0];
1581 assert_eq!(det.dtype, Some(DType::Int8));
1582 assert_eq!(det.quantization, Some(quant));
1583 }
1584
1585 #[test]
1586 fn infer_rejects_batched_input() {
1587 let s = ModelSignals {
1592 source: ModelSource::Onnx,
1593 inputs: vec![TensorInfo {
1594 name: "images".into(),
1595 shape: vec![4, 3, 640, 640],
1596 dtype: DType::Float32,
1597 quantization: None,
1598 }],
1599 outputs: vec![TensorInfo {
1600 name: "output0".into(),
1601 shape: vec![1, 84, 8400],
1602 dtype: DType::Float32,
1603 quantization: None,
1604 }],
1605 metadata: synthetic_metadata(80, "detect", "False"),
1606 };
1607 match infer_ultralytics_schema(&s) {
1608 Err(InferError::UnsupportedLayout(msg)) => {
1609 assert!(msg.contains("batch 4"), "unexpected message: {msg}");
1610 }
1611 other => panic!("expected UnsupportedLayout, got {other:?}"),
1612 }
1613 }
1614
1615 #[test]
1616 fn infer_rejects_input_without_a_channel_dim() {
1617 let s = ModelSignals {
1618 source: ModelSource::Onnx,
1619 inputs: vec![TensorInfo {
1620 name: "images".into(),
1621 shape: vec![1, 4, 640, 640],
1622 dtype: DType::Float32,
1623 quantization: None,
1624 }],
1625 outputs: vec![TensorInfo {
1626 name: "output0".into(),
1627 shape: vec![1, 84, 8400],
1628 dtype: DType::Float32,
1629 quantization: None,
1630 }],
1631 metadata: synthetic_metadata(80, "detect", "False"),
1632 };
1633 match infer_ultralytics_schema(&s) {
1634 Err(InferError::UnsupportedLayout(msg)) => {
1635 assert!(msg.contains("channel dimension"), "unexpected: {msg}");
1636 }
1637 other => panic!("expected UnsupportedLayout, got {other:?}"),
1638 }
1639 }
1640
1641 #[test]
1642 fn infer_rejects_model_with_no_rank4_input() {
1643 let s = ModelSignals {
1644 source: ModelSource::Onnx,
1645 inputs: vec![TensorInfo {
1646 name: "images".into(),
1647 shape: vec![1, 3],
1648 dtype: DType::Float32,
1649 quantization: None,
1650 }],
1651 outputs: vec![TensorInfo {
1652 name: "output0".into(),
1653 shape: vec![1, 84, 8400],
1654 dtype: DType::Float32,
1655 quantization: None,
1656 }],
1657 metadata: synthetic_metadata(80, "detect", "False"),
1658 };
1659 match infer_ultralytics_schema(&s) {
1660 Err(InferError::UnsupportedLayout(msg)) => {
1661 assert!(msg.contains("no rank-4 model input"), "unexpected: {msg}");
1662 }
1663 other => panic!("expected UnsupportedLayout, got {other:?}"),
1664 }
1665 }
1666
1667 fn det_signals(nc: usize, out_shape: Vec<usize>, end2end: &str) -> ModelSignals {
1669 ModelSignals {
1670 source: ModelSource::Onnx,
1671 inputs: vec![TensorInfo {
1672 name: "images".into(),
1673 shape: vec![1, 3, 640, 640],
1674 dtype: DType::Float32,
1675 quantization: None,
1676 }],
1677 outputs: vec![TensorInfo {
1678 name: "output0".into(),
1679 shape: out_shape,
1680 dtype: DType::Float32,
1681 quantization: None,
1682 }],
1683 metadata: synthetic_metadata(nc, "detect", end2end),
1684 }
1685 }
1686
1687 #[test]
1688 fn infer_end_to_end_accepts_any_max_det_up_to_the_anchor_count() {
1689 for n in [1, 300, 2000, 8400] {
1701 let r = infer_ultralytics_schema(&det_signals(80, vec![1, n, 6], "True"))
1702 .unwrap_or_else(|e| panic!("max_det={n} must infer: {e}"));
1703 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1704 assert_eq!(r.schema.outputs[0].type_, Some(LogicalType::Detections));
1705 assert_eq!(
1706 r.schema.outputs[0].dshape,
1707 vec![
1708 (DimName::Batch, 1),
1709 (DimName::NumBoxes, n),
1710 (DimName::NumFeatures, 6),
1711 ],
1712 "max_det={n}"
1713 );
1714 assert_eq!(r.schema.nms, None, "max_det={n}");
1716 }
1717 }
1718
1719 #[test]
1720 fn infer_end_to_end_rejects_more_rows_than_anchors() {
1721 match infer_ultralytics_schema(&det_signals(80, vec![1, 8401, 6], "True")) {
1725 Err(InferError::UnsupportedLayout(msg)) => {
1726 assert!(msg.contains("N<=8400"), "unexpected message: {msg}");
1727 }
1728 other => panic!("expected UnsupportedLayout, got {other:?}"),
1729 }
1730 }
1731
1732 #[test]
1733 fn infer_legacy_nms_model_is_unaffected_by_max_det() {
1734 let r = infer_ultralytics_schema(&det_signals(80, vec![1, 84, 8400], "False")).unwrap();
1740 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1741 assert_eq!(r.schema.outputs[0].type_, Some(LogicalType::Detection));
1742 assert_eq!(
1743 r.schema.outputs[0].dshape,
1744 vec![
1745 (DimName::Batch, 1),
1746 (DimName::NumFeatures, 84),
1747 (DimName::NumBoxes, 8400),
1748 ]
1749 );
1750 assert_eq!(r.schema.nms, Some(NmsMode::ClassAware));
1754
1755 let t = infer_ultralytics_schema(&det_signals(80, vec![1, 8400, 84], "False")).unwrap();
1758 assert_eq!(t.schema.decoder_version, Some(DecoderVersion::Yolov8));
1759 assert_eq!(
1760 t.schema.outputs[0].dshape,
1761 vec![
1762 (DimName::Batch, 1),
1763 (DimName::NumBoxes, 8400),
1764 (DimName::NumFeatures, 84),
1765 ]
1766 );
1767 }
1768
1769 #[test]
1770 fn infer_pre_nms_wins_when_a_shape_reads_as_both() {
1771 let mut m = synthetic_metadata(2, "detect", "False");
1776 m.remove("end2end");
1777 let s = ModelSignals {
1778 metadata: m,
1779 ..det_signals(2, vec![1, 8400, 6], "False")
1780 };
1781 let r = infer_ultralytics_schema(&s).unwrap();
1782 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1783 }
1784
1785 #[test]
1786 fn meta_empty_names_rejected() {
1787 let mut m = BTreeMap::new();
1790 m.insert("names".into(), "{}".into());
1791 match UltralyticsMeta::from_metadata(&m) {
1792 Err(InferError::BadNames(msg)) => assert!(msg.contains("empty"), "{msg}"),
1793 other => panic!("expected BadNames, got {other:?}"),
1794 }
1795 }
1796
1797 #[test]
1798 fn meta_malformed_end2end_is_an_error_not_an_absent_key() {
1799 let mut m = synthetic_metadata(80, "detect", "False");
1803 m.insert("end2end".into(), "yes".into());
1804 match UltralyticsMeta::from_metadata(&m) {
1805 Err(InferError::BadMetadata(msg)) => assert!(msg.contains("end2end"), "{msg}"),
1806 other => panic!("expected BadMetadata, got {other:?}"),
1807 }
1808
1809 m.remove("end2end");
1811 assert_eq!(UltralyticsMeta::from_metadata(&m).unwrap().end2end, None);
1812 }
1813
1814 #[test]
1815 fn meta_foreign_vendor_rejected_but_missing_provenance_allowed() {
1816 let mut m = synthetic_metadata(80, "detect", "False");
1820 m.insert("author".into(), "SomeOtherVendor".into());
1821 assert!(matches!(
1822 UltralyticsMeta::from_metadata(&m),
1823 Err(InferError::NotUltralytics(_))
1824 ));
1825
1826 m.insert("author".into(), "Ultralytics".into());
1828 m.insert("docs".into(), "https://docs.ultralytics.com".into());
1829 assert!(UltralyticsMeta::from_metadata(&m).is_ok());
1830
1831 m.remove("author");
1834 m.remove("docs");
1835 assert!(UltralyticsMeta::from_metadata(&m).is_ok());
1836 }
1837
1838 #[test]
1839 fn infer_zero_row_end_to_end_rejected() {
1840 assert!(matches!(
1843 infer_ultralytics_schema(&det_signals(80, vec![1, 0, 6], "True")),
1844 Err(InferError::UnsupportedLayout(_))
1845 ));
1846 }
1847
1848 #[test]
1849 fn infer_duplicate_matching_outputs_are_ambiguous() {
1850 let input = TensorInfo {
1851 name: "images".into(),
1852 shape: vec![1, 3, 640, 640],
1853 dtype: DType::Float32,
1854 quantization: None,
1855 };
1856 let t = |name: &str, shape: Vec<usize>| TensorInfo {
1857 name: name.into(),
1858 shape,
1859 dtype: DType::Float32,
1860 quantization: None,
1861 };
1862
1863 let pre = ModelSignals {
1866 source: ModelSource::Onnx,
1867 inputs: vec![input.clone()],
1868 outputs: vec![t("a", vec![1, 84, 8400]), t("b", vec![1, 84, 8400])],
1869 metadata: synthetic_metadata(80, "detect", "False"),
1870 };
1871 match infer_ultralytics_schema(&pre) {
1872 Err(InferError::AmbiguousLayout(msg)) => {
1873 assert!(msg.contains("`a`") && msg.contains("`b`"), "{msg}");
1874 }
1875 other => panic!("expected AmbiguousLayout, got {other:?}"),
1876 }
1877
1878 let e2e = ModelSignals {
1880 source: ModelSource::Onnx,
1881 inputs: vec![input],
1882 outputs: vec![t("a", vec![1, 300, 6]), t("b", vec![1, 200, 6])],
1883 metadata: synthetic_metadata(80, "detect", "True"),
1884 };
1885 assert!(matches!(
1886 infer_ultralytics_schema(&e2e),
1887 Err(InferError::AmbiguousLayout(_))
1888 ));
1889 }
1890
1891 #[test]
1892 fn infer_malformed_per_tensor_quantization_rejected() {
1893 let build = |q: Quantization| ModelSignals {
1894 source: ModelSource::TfLite,
1895 inputs: vec![TensorInfo {
1896 name: "images".into(),
1897 shape: vec![1, 640, 640, 3],
1898 dtype: DType::Float32,
1899 quantization: None,
1900 }],
1901 outputs: vec![TensorInfo {
1902 name: "output0".into(),
1903 shape: vec![1, 84, 8400],
1904 dtype: DType::Int8,
1905 quantization: Some(q),
1906 }],
1907 metadata: synthetic_metadata(80, "detect", "False"),
1908 };
1909
1910 match infer_ultralytics_schema(&build(Quantization {
1913 scale: vec![],
1914 zero_point: Some(vec![]),
1915 axis: None,
1916 dtype: Some(DType::Int8),
1917 })) {
1918 Err(InferError::UnsupportedQuantization(msg)) => {
1919 assert!(msg.contains("no scale"), "{msg}");
1920 }
1921 other => panic!("expected UnsupportedQuantization, got {other:?}"),
1922 }
1923
1924 match infer_ultralytics_schema(&build(Quantization {
1927 scale: vec![0.02],
1928 zero_point: Some(vec![-5, -7]),
1929 axis: None,
1930 dtype: Some(DType::Int8),
1931 })) {
1932 Err(InferError::UnsupportedQuantization(msg)) => {
1933 assert!(msg.contains("zero point"), "{msg}");
1934 }
1935 other => panic!("expected UnsupportedQuantization, got {other:?}"),
1936 }
1937
1938 assert!(infer_ultralytics_schema(&build(Quantization {
1940 scale: vec![0.02],
1941 zero_point: None,
1942 axis: None,
1943 dtype: Some(DType::Int8),
1944 }))
1945 .is_ok());
1946 }
1947
1948 #[test]
1949 fn infer_pre_nms_is_independent_of_output_order() {
1950 let det = TensorInfo {
1956 name: "output0".into(),
1957 shape: vec![1, 84, 8400],
1958 dtype: DType::Float32,
1959 quantization: None,
1960 };
1961 let aux = TensorInfo {
1962 name: "boxes".into(),
1963 shape: vec![1, 4, 8400],
1964 dtype: DType::Float32,
1965 quantization: None,
1966 };
1967 let build = |outputs: Vec<TensorInfo>| ModelSignals {
1968 source: ModelSource::Onnx,
1969 inputs: vec![TensorInfo {
1970 name: "images".into(),
1971 shape: vec![1, 3, 640, 640],
1972 dtype: DType::Float32,
1973 quantization: None,
1974 }],
1975 outputs,
1976 metadata: synthetic_metadata(80, "detect", "False"),
1977 };
1978
1979 let det_first = infer_ultralytics_schema(&build(vec![det.clone(), aux.clone()]))
1980 .expect("detection tensor first must classify");
1981 let aux_first = infer_ultralytics_schema(&build(vec![aux, det]))
1982 .expect("an auxiliary anchor-shaped tensor first must not shadow the detection tensor");
1983 assert_eq!(det_first.schema.outputs[0].name, Some("output0".into()));
1984 assert_eq!(aux_first.schema.outputs[0].name, Some("output0".into()));
1985 assert_eq!(det_first.schema, aux_first.schema);
1986 }
1987
1988 #[test]
1989 fn infer_class_count_mismatch_survives_a_full_scan() {
1990 let s = ModelSignals {
1993 source: ModelSource::Onnx,
1994 inputs: vec![TensorInfo {
1995 name: "images".into(),
1996 shape: vec![1, 3, 640, 640],
1997 dtype: DType::Float32,
1998 quantization: None,
1999 }],
2000 outputs: vec![TensorInfo {
2001 name: "output0".into(),
2002 shape: vec![1, 84, 8400],
2003 dtype: DType::Float32,
2004 quantization: None,
2005 }],
2006 metadata: synthetic_metadata(10, "detect", "False"),
2008 };
2009 assert!(matches!(
2010 infer_ultralytics_schema(&s),
2011 Err(InferError::ClassCountMismatch {
2012 expected: 10,
2013 found: 80
2014 })
2015 ));
2016 }
2017
2018 struct FixtureExpectation {
2028 name: &'static str,
2029 version: DecoderVersion,
2030 det_type: LogicalType,
2031 det_dshape: &'static [(DimName, usize)],
2033 normalized: bool,
2034 nms: Option<NmsMode>,
2035 proto: Option<(usize, usize, usize)>,
2037 }
2038
2039 const A: usize = 8400; const NC: usize = 80;
2041
2042 fn fixture_expectations() -> Vec<FixtureExpectation> {
2043 use DimName::{Batch, NumBoxes, NumFeatures};
2044 const DET: &[(DimName, usize)] = &[(Batch, 1), (NumFeatures, 4 + NC), (NumBoxes, A)];
2048 const SEG: &[(DimName, usize)] = &[(Batch, 1), (NumFeatures, 4 + NC + 32), (NumBoxes, A)];
2049 const E2E: &[(DimName, usize)] = &[(Batch, 1), (NumBoxes, 300), (NumFeatures, 6)];
2051 const E2E_SEG: &[(DimName, usize)] = &[(Batch, 1), (NumBoxes, 300), (NumFeatures, 6 + 32)];
2052
2053 let pre_nms = |name, det_dshape, normalized, proto| FixtureExpectation {
2054 name,
2055 version: DecoderVersion::Yolov8,
2056 det_type: LogicalType::Detection,
2057 det_dshape,
2058 normalized,
2059 nms: Some(NmsMode::ClassAware),
2060 proto,
2061 };
2062 let e2e = |name, det_dshape, normalized, proto| FixtureExpectation {
2063 name,
2064 version: DecoderVersion::Yolo26,
2065 det_type: LogicalType::Detections,
2066 det_dshape,
2067 normalized,
2068 nms: None,
2070 proto,
2071 };
2072 const P: Option<(usize, usize, usize)> = Some((32, 160, 160));
2075 vec![
2076 pre_nms("yolov8n", DET, false, None),
2077 pre_nms("yolo11n", DET, false, None),
2078 pre_nms("yolov8n-seg", SEG, false, P),
2079 pre_nms("yolo11n-seg", SEG, false, P),
2080 pre_nms("yolov8n_float32", DET, true, None),
2081 pre_nms("yolov8n_int8", DET, true, None),
2082 pre_nms("yolov8n-seg_float32", SEG, true, P),
2083 pre_nms("yolov8n-seg_int8", SEG, true, P),
2084 e2e("yolo26n", E2E, false, None),
2085 e2e("yolo26n-seg", E2E_SEG, false, P),
2086 e2e("yolo26n_float32", E2E, true, None),
2087 ]
2088 }
2089
2090 #[test]
2091 fn every_captured_fixture_infers_the_expected_schema() {
2092 for want in fixture_expectations() {
2093 let name = want.name;
2094 let r = infer_ultralytics_schema(&signals_from_fixture(name))
2095 .unwrap_or_else(|e| panic!("{name}: inference failed: {e}"));
2096
2097 assert_eq!(r.labels.len(), NC, "{name}: class count");
2098 assert_eq!(
2099 r.labels[0], "person",
2100 "{name}: labels must be index-ordered"
2101 );
2102 assert_eq!(r.labels[NC - 1], "toothbrush", "{name}: labels index order");
2103 assert_eq!(
2104 r.schema.decoder_version,
2105 Some(want.version),
2106 "{name}: version"
2107 );
2108 assert_eq!(r.schema.nms, want.nms, "{name}: nms mode");
2109
2110 let det = &r.schema.outputs[0];
2111 assert_eq!(det.type_, Some(want.det_type), "{name}: detection type");
2112 assert_eq!(det.dshape, want.det_dshape, "{name}: detection dshape");
2113 assert_eq!(det.normalized, Some(want.normalized), "{name}: normalized");
2114 assert_eq!(
2115 det.decoder,
2116 Some(DecoderKind::Ultralytics),
2117 "{name}: decoder"
2118 );
2119 assert_eq!(det.encoding, Some(BoxEncoding::Direct), "{name}: encoding");
2120 assert_eq!(
2121 det.score_format,
2122 Some(ScoreFormat::PerClass),
2123 "{name}: scores"
2124 );
2125
2126 match want.proto {
2127 None => {
2128 assert_eq!(r.schema.outputs.len(), 1, "{name}: detection-only");
2129 }
2130 Some((k, ph, pw)) => {
2131 assert_eq!(r.schema.outputs.len(), 2, "{name}: detection + proto");
2132 let proto = &r.schema.outputs[1];
2133 assert_eq!(proto.type_, Some(LogicalType::Protos), "{name}: proto type");
2134 assert_eq!(
2135 proto.dshape,
2136 vec![
2137 (DimName::Batch, 1),
2138 (DimName::NumProtos, k),
2139 (DimName::Height, ph),
2140 (DimName::Width, pw),
2141 ],
2142 "{name}: proto dshape"
2143 );
2144 }
2145 }
2146
2147 let task = if want.proto.is_some() {
2148 "segment"
2149 } else {
2150 "detect"
2151 };
2152 assert!(
2153 r.description.contains(task) && r.description.contains("Ultralytics"),
2154 "{name}: description was `{}`",
2155 r.description
2156 );
2157 }
2158 }
2159
2160 #[test]
2161 fn every_fixture_is_asserted() {
2162 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata/infer");
2166 let mut on_disk: Vec<String> = std::fs::read_dir(dir)
2167 .expect("testdata/infer exists")
2168 .filter_map(|e| {
2169 let name = e.ok()?.file_name().into_string().ok()?;
2170 name.strip_suffix(".signals.json").map(str::to_string)
2171 })
2172 .collect();
2173 on_disk.sort();
2174
2175 let mut asserted: Vec<String> = fixture_expectations()
2176 .iter()
2177 .map(|f| f.name.to_string())
2178 .collect();
2179 asserted.sort();
2180
2181 assert_eq!(
2182 on_disk, asserted,
2183 "every *.signals.json fixture must appear in fixture_expectations()"
2184 );
2185 }
2186
2187 #[test]
2188 fn infer_other_source_refuses_rather_than_guessing_normalization() {
2189 let s = ModelSignals {
2195 source: ModelSource::Other,
2196 inputs: vec![TensorInfo {
2197 name: "images".into(),
2198 shape: vec![1, 3, 640, 640],
2199 dtype: DType::Float32,
2200 quantization: None,
2201 }],
2202 outputs: vec![TensorInfo {
2203 name: "output0".into(),
2204 shape: vec![1, 84, 8400],
2205 dtype: DType::Float32,
2206 quantization: None,
2207 }],
2208 metadata: synthetic_metadata(80, "detect", "False"),
2209 };
2210 assert!(matches!(
2211 infer_ultralytics_schema(&s),
2212 Err(InferError::UnknownBoxConvention)
2213 ));
2214
2215 let ok = ModelSignals {
2218 source: ModelSource::Onnx,
2219 ..s
2220 };
2221 assert_eq!(
2222 infer_ultralytics_schema(&ok).unwrap().schema.outputs[0].normalized,
2223 Some(false)
2224 );
2225 }
2226
2227 #[test]
2228 fn infer_per_channel_quantization_rejected() {
2229 let s = ModelSignals {
2238 source: ModelSource::TfLite,
2239 inputs: vec![TensorInfo {
2240 name: "images".into(),
2241 shape: vec![1, 640, 640, 3],
2242 dtype: DType::Float32,
2243 quantization: None,
2244 }],
2245 outputs: vec![TensorInfo {
2246 name: "output0".into(),
2247 shape: vec![1, 84, 8400],
2248 dtype: DType::Int8,
2249 quantization: Some(Quantization {
2250 scale: vec![0.02, 0.03],
2251 zero_point: Some(vec![-5, -7]),
2252 axis: Some(1),
2253 dtype: Some(DType::Int8),
2254 }),
2255 }],
2256 metadata: synthetic_metadata(80, "detect", "False"),
2257 };
2258 match infer_ultralytics_schema(&s) {
2259 Err(InferError::UnsupportedQuantization(msg)) => {
2260 assert!(msg.contains("per-channel"), "unexpected message: {msg}");
2261 assert!(
2262 msg.contains("output0"),
2263 "message must name the tensor: {msg}"
2264 );
2265 }
2266 other => panic!("expected UnsupportedQuantization, got {other:?}"),
2267 }
2268 }
2269
2270 #[test]
2271 fn infer_per_channel_proto_quantization_rejected() {
2272 let s = ModelSignals {
2275 source: ModelSource::TfLite,
2276 inputs: vec![TensorInfo {
2277 name: "images".into(),
2278 shape: vec![1, 640, 640, 3],
2279 dtype: DType::Float32,
2280 quantization: None,
2281 }],
2282 outputs: vec![
2283 TensorInfo {
2284 name: "output0".into(),
2285 shape: vec![1, 116, 8400],
2286 dtype: DType::Float32,
2287 quantization: None,
2288 },
2289 TensorInfo {
2290 name: "output1".into(),
2291 shape: vec![1, 32, 160, 160],
2292 dtype: DType::Int8,
2293 quantization: Some(Quantization {
2294 scale: vec![0.02, 0.03],
2295 zero_point: Some(vec![-5, -7]),
2296 axis: Some(1),
2297 dtype: Some(DType::Int8),
2298 }),
2299 },
2300 ],
2301 metadata: synthetic_metadata(80, "segment", "False"),
2302 };
2303 match infer_ultralytics_schema(&s) {
2304 Err(InferError::UnsupportedQuantization(msg)) => {
2305 assert!(
2306 msg.contains("output1"),
2307 "message must name the proto: {msg}"
2308 );
2309 }
2310 other => panic!("expected UnsupportedQuantization, got {other:?}"),
2311 }
2312 }
2313
2314 #[test]
2315 fn infer_integer_output_without_quant_rejected() {
2316 let s = ModelSignals {
2320 source: ModelSource::TfLite,
2321 inputs: vec![TensorInfo {
2322 name: "images".into(),
2323 shape: vec![1, 640, 640, 3],
2324 dtype: DType::Float32,
2325 quantization: None,
2326 }],
2327 outputs: vec![TensorInfo {
2328 name: "output0".into(),
2329 shape: vec![1, 84, 8400],
2330 dtype: DType::Int8,
2331 quantization: None,
2332 }],
2333 metadata: synthetic_metadata(80, "detect", "False"),
2334 };
2335 match infer_ultralytics_schema(&s) {
2336 Err(InferError::UnsupportedLayout(msg)) => {
2337 assert!(
2338 msg.contains("quantization"),
2339 "unexpected UnsupportedLayout message: {msg}"
2340 );
2341 }
2342 other => panic!("expected UnsupportedLayout, got {other:?}"),
2343 }
2344 }
2345
2346 #[test]
2347 fn infer_pre_nms_rejects_non_unit_batch() {
2348 let mut s = signals_from_fixture("yolov8n");
2353 s.outputs[0].shape = vec![2, 84, 8400];
2354 assert!(matches!(
2355 infer_ultralytics_schema(&s),
2356 Err(InferError::UnsupportedLayout(_))
2357 ));
2358 }
2359}