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 CoreMl,
64 Other,
72}
73
74#[derive(Debug, Clone, PartialEq)]
77pub struct ModelSignals {
78 pub source: ModelSource,
80 pub inputs: Vec<TensorInfo>,
82 pub outputs: Vec<TensorInfo>,
84 pub metadata: BTreeMap<String, String>,
88}
89
90#[derive(Debug, Clone, PartialEq)]
92pub struct InferredSchema {
93 pub schema: SchemaV2,
95 pub labels: Vec<String>,
97 pub description: String,
99}
100
101#[derive(Debug)]
103pub enum InferError {
104 NotUltralytics(String),
106 BadNames(String),
108 BadMetadata(String),
112 UnsupportedTask(String),
114 ClassCountMismatch { expected: usize, found: usize },
117 UnsupportedLayout(String),
120 AmbiguousLayout(String),
124 UnsupportedQuantization(String),
128 UnknownBoxConvention,
134}
135
136impl fmt::Display for InferError {
137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138 match self {
139 InferError::NotUltralytics(s) => {
140 write!(f, "model metadata carries no Ultralytics signature: {s}")
141 }
142 InferError::BadNames(s) => {
143 write!(f, "cannot parse Ultralytics `names` metadata: {s}")
144 }
145 InferError::BadMetadata(s) => {
146 write!(f, "malformed Ultralytics metadata: {s}")
147 }
148 InferError::UnsupportedTask(s) => write!(
149 f,
150 "unsupported Ultralytics task `{s}` (supported: detect, segment)"
151 ),
152 InferError::ClassCountMismatch { expected, found } => write!(
153 f,
154 "class-count mismatch: names give {expected} classes but output features imply {found}"
155 ),
156 InferError::UnsupportedLayout(s) => write!(f, "unsupported output layout: {s}"),
157 InferError::AmbiguousLayout(s) => write!(f, "ambiguous output layout: {s}"),
158 InferError::UnsupportedQuantization(s) => {
159 write!(f, "unsupported quantization: {s}")
160 }
161 InferError::UnknownBoxConvention => write!(
162 f,
163 "box coordinate convention is unknown for this container; only \
164 onnx (pixel-space), tflite ([0,1]), and coreml (pixel-space) \
165 are characterized"
166 ),
167 }
168 }
169}
170
171impl std::error::Error for InferError {}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175enum Task {
176 Detect,
177 Segment,
178}
179
180impl Task {
181 fn parse(s: &str) -> Result<Self, InferError> {
182 match s.trim().to_ascii_lowercase().as_str() {
183 "detect" => Ok(Task::Detect),
184 "segment" => Ok(Task::Segment),
185 other => Err(InferError::UnsupportedTask(other.to_string())),
186 }
187 }
188}
189
190#[derive(Debug, Clone, PartialEq)]
193struct UltralyticsMeta {
194 names: Vec<String>,
195 task: Option<Task>,
196 end2end: Option<bool>,
200}
201
202fn reject_foreign_vendor(author: Option<&String>, docs: Option<&String>) -> Result<(), InferError> {
218 let names_ultralytics = |v: &String| v.to_ascii_lowercase().contains("ultralytics");
219 for (field, value) in [("author", author), ("docs", docs)] {
220 if let Some(v) = value {
221 if !v.trim().is_empty() && !names_ultralytics(v) {
222 return Err(InferError::NotUltralytics(format!(
223 "metadata `{field}` is `{v}`, not Ultralytics"
224 )));
225 }
226 }
227 }
228 Ok(())
229}
230
231impl UltralyticsMeta {
232 fn from_metadata(meta: &BTreeMap<String, String>) -> Result<Self, InferError> {
238 if let Some(names_raw) = meta.get("names") {
239 reject_foreign_vendor(meta.get("author"), meta.get("docs"))?;
240 return Self::from_flat_props(meta, names_raw);
241 }
242
243 for value in meta.values() {
246 if let Ok(serde_json::Value::Object(obj)) = serde_json::from_str(value) {
247 if let Some(names) = obj.get("names") {
248 let author = obj
249 .get("author")
250 .and_then(|v| v.as_str())
251 .map(str::to_string);
252 let docs = obj.get("docs").and_then(|v| v.as_str()).map(str::to_string);
253 reject_foreign_vendor(author.as_ref(), docs.as_ref())?;
254 return Self::from_json_envelope(&obj, names);
255 }
256 }
257 }
258
259 Err(InferError::NotUltralytics(
260 "no `names` metadata field or metadata.json envelope found".into(),
261 ))
262 }
263
264 fn from_flat_props(
265 meta: &BTreeMap<String, String>,
266 names_raw: &str,
267 ) -> Result<Self, InferError> {
268 let names = parse_names(names_raw)?;
269 let task = meta.get("task").map(|t| Task::parse(t)).transpose()?;
270 let end2end = meta.get("end2end").map(|s| parse_bool_str(s)).transpose()?;
271 Ok(UltralyticsMeta {
272 names,
273 task,
274 end2end,
275 })
276 }
277
278 fn from_json_envelope(
282 obj: &serde_json::Map<String, serde_json::Value>,
283 names_value: &serde_json::Value,
284 ) -> Result<Self, InferError> {
285 let names = names_from_json_value(names_value)?;
286 let task = obj
287 .get("task")
288 .and_then(|v| v.as_str())
289 .map(Task::parse)
290 .transpose()?;
291 let end2end = match obj.get("end2end") {
294 None | Some(serde_json::Value::Null) => None,
295 Some(serde_json::Value::Bool(b)) => Some(*b),
296 Some(other) => {
297 return Err(InferError::BadMetadata(format!(
298 "`end2end` is {other}, expected a boolean"
299 )))
300 }
301 };
302 Ok(UltralyticsMeta {
303 names,
304 task,
305 end2end,
306 })
307 }
308}
309
310fn parse_bool_str(s: &str) -> Result<bool, InferError> {
318 match s.trim().to_ascii_lowercase().as_str() {
319 "true" => Ok(true),
320 "false" => Ok(false),
321 other => Err(InferError::BadMetadata(format!(
322 "`end2end` is `{other}`, expected true or false"
323 ))),
324 }
325}
326
327fn parse_names(s: &str) -> Result<Vec<String>, InferError> {
332 let trimmed = s.trim();
333
334 if let Ok(map) = serde_json::from_str::<BTreeMap<String, String>>(trimmed) {
335 return names_from_str_keyed_map(map);
336 }
337
338 let body = trimmed
339 .strip_prefix('{')
340 .and_then(|s| s.strip_suffix('}'))
341 .ok_or_else(|| InferError::BadNames(format!("not a dict: {trimmed}")))?;
342
343 let mut indexed: BTreeMap<usize, String> = BTreeMap::new();
344 for item in split_top_level_commas(body) {
345 let item = item.trim();
346 if item.is_empty() {
347 continue;
348 }
349 let (key, value) = item
350 .split_once(':')
351 .ok_or_else(|| InferError::BadNames(format!("malformed entry `{item}`")))?;
352 let idx: usize = key
353 .trim()
354 .parse()
355 .map_err(|_| InferError::BadNames(format!("non-numeric key `{key}`")))?;
356 let value = unquote(value.trim())
357 .ok_or_else(|| InferError::BadNames(format!("unquoted value `{value}`")))?;
358 indexed.insert(idx, value);
359 }
360 contiguous_values(indexed)
361}
362
363fn names_from_json_value(v: &serde_json::Value) -> Result<Vec<String>, InferError> {
366 let obj = v
367 .as_object()
368 .ok_or_else(|| InferError::BadNames("`names` is not a JSON object".into()))?;
369 let mut map = BTreeMap::new();
370 for (k, val) in obj {
371 let s = val
372 .as_str()
373 .ok_or_else(|| InferError::BadNames(format!("non-string name for key `{k}`")))?;
374 map.insert(k.clone(), s.to_string());
375 }
376 names_from_str_keyed_map(map)
377}
378
379fn names_from_str_keyed_map(map: BTreeMap<String, String>) -> Result<Vec<String>, InferError> {
383 let mut indexed: BTreeMap<usize, String> = BTreeMap::new();
384 for (k, v) in map {
385 let idx: usize = k
386 .parse()
387 .map_err(|_| InferError::BadNames(format!("non-numeric key `{k}`")))?;
388 indexed.insert(idx, v);
389 }
390 contiguous_values(indexed)
391}
392
393fn contiguous_values(indexed: BTreeMap<usize, String>) -> Result<Vec<String>, InferError> {
396 if indexed.is_empty() {
401 return Err(InferError::BadNames("class map is empty".into()));
402 }
403 let n = indexed.len();
404 for i in 0..n {
405 if !indexed.contains_key(&i) {
406 return Err(InferError::BadNames("non-contiguous class indices".into()));
407 }
408 }
409 Ok(indexed.into_values().collect())
410}
411
412fn split_top_level_commas(s: &str) -> Vec<String> {
415 let mut parts = Vec::new();
416 let mut current = String::new();
417 let mut quote: Option<char> = None;
418 for c in s.chars() {
419 match quote {
420 Some(q) => {
421 current.push(c);
422 if c == q {
423 quote = None;
424 }
425 }
426 None => match c {
427 '\'' | '"' => {
428 quote = Some(c);
429 current.push(c);
430 }
431 ',' => parts.push(std::mem::take(&mut current)),
432 _ => current.push(c),
433 },
434 }
435 }
436 if !current.is_empty() {
437 parts.push(current);
438 }
439 parts
440}
441
442fn unquote(s: &str) -> Option<String> {
445 let bytes = s.as_bytes();
446 if s.len() >= 2 {
447 let first = bytes[0];
448 let last = bytes[s.len() - 1];
449 if (first == b'\'' && last == b'\'') || (first == b'"' && last == b'"') {
450 return Some(s[1..s.len() - 1].to_string());
451 }
452 }
453 None
454}
455
456#[allow(clippy::type_complexity)]
467fn classify_input_layout(
468 input: &TensorInfo,
469) -> Result<(usize, usize, Vec<(DimName, usize)>), InferError> {
470 debug_assert_eq!(
471 input.shape.len(),
472 4,
473 "caller selects the input by rank-4; see infer_ultralytics_schema"
474 );
475 if input.shape[0] != 1 {
476 return Err(InferError::UnsupportedLayout(format!(
477 "model input `{}` has batch {} but every supported output layout \
478 is batch-1; shape {:?}",
479 input.name, input.shape[0], input.shape
480 )));
481 }
482 if input.shape[1] == 3 {
483 let (h, w) = (input.shape[2], input.shape[3]);
484 Ok((
485 h,
486 w,
487 vec![
488 (DimName::Batch, input.shape[0]),
489 (DimName::NumFeatures, 3),
490 (DimName::Height, h),
491 (DimName::Width, w),
492 ],
493 ))
494 } else if input.shape[3] == 3 {
495 let (h, w) = (input.shape[1], input.shape[2]);
496 Ok((
497 h,
498 w,
499 vec![
500 (DimName::Batch, input.shape[0]),
501 (DimName::Height, h),
502 (DimName::Width, w),
503 (DimName::NumFeatures, 3),
504 ],
505 ))
506 } else {
507 Err(InferError::UnsupportedLayout(format!(
508 "cannot locate the channel dimension (=3) in input shape {:?}",
509 input.shape
510 )))
511 }
512}
513
514fn find_anchors_dim(shape: &[usize], expected_anchors: usize) -> Option<(usize, usize)> {
521 if shape.len() != 3 || shape[0] != 1 {
522 return None;
523 }
524 if shape[2] == expected_anchors {
525 Some((shape[1], 1)) } else if shape[1] == expected_anchors {
527 Some((shape[2], 2)) } else {
529 None
530 }
531}
532
533#[allow(clippy::type_complexity)]
549fn classify_pre_nms<'a>(
550 rank3_outputs: &[&'a TensorInfo],
551 expected_anchors: usize,
552 feat: usize,
553 nc: usize,
554) -> Result<(&'a TensorInfo, Vec<(DimName, usize)>), InferError> {
555 let mut mismatch: Option<InferError> = None;
556 let mut matched: Option<(&'a TensorInfo, Vec<(DimName, usize)>)> = None;
557 for out in rank3_outputs {
558 let Some((other_dim, feat_idx)) = find_anchors_dim(&out.shape, expected_anchors) else {
559 continue;
560 };
561 if other_dim == feat {
562 let dshape = if feat_idx == 1 {
563 vec![
564 (DimName::Batch, 1),
565 (DimName::NumFeatures, feat),
566 (DimName::NumBoxes, expected_anchors),
567 ]
568 } else {
569 vec![
570 (DimName::Batch, 1),
571 (DimName::NumBoxes, expected_anchors),
572 (DimName::NumFeatures, feat),
573 ]
574 };
575 if let Some((first, _)) = &matched {
578 return Err(InferError::AmbiguousLayout(format!(
579 "outputs `{}` and `{}` both match the pre-NMS detection layout \
580 ({:?}); cannot tell which carries the detections",
581 first.name, out.name, out.shape
582 )));
583 }
584 matched = Some((out, dshape));
585 continue;
586 }
587 mismatch.get_or_insert_with(|| InferError::ClassCountMismatch {
588 expected: nc,
589 found: other_dim.saturating_sub(feat.saturating_sub(nc)),
592 });
593 }
594 if let Some(found) = matched {
595 return Ok(found);
596 }
597 Err(mismatch.unwrap_or_else(|| {
598 InferError::UnsupportedLayout(format!(
599 "no output matches the Ultralytics pre-NMS layout (expected a dim == \
600 {expected_anchors} anchors and a dim == {feat} features [4 + {nc} classes]); \
601 outputs seen: {:?}",
602 rank3_outputs.iter().map(|o| &o.shape).collect::<Vec<_>>()
603 ))
604 }))
605}
606
607fn find_e2e_candidate<'a>(
622 rank3_outputs: &[&'a TensorInfo],
623 expected_anchors: usize,
624 feat: usize,
625) -> Result<Option<(&'a TensorInfo, usize)>, InferError> {
626 let matches: Vec<(&'a TensorInfo, usize)> = rank3_outputs
627 .iter()
628 .filter_map(|o| {
629 let s = &o.shape;
630 debug_assert_eq!(s.len(), 3, "caller filters to rank-3 outputs");
631 (s[0] == 1 && s[2] == feat && s[1] > 0 && s[1] <= expected_anchors)
634 .then_some((*o, s[1]))
635 })
636 .collect();
637 if matches.len() > 1 {
641 return Err(InferError::AmbiguousLayout(format!(
642 "{} outputs match the end-to-end layout [1, N, {feat}]: {:?}; \
643 cannot tell which carries the detections",
644 matches.len(),
645 matches.iter().map(|(o, _)| &o.name).collect::<Vec<_>>()
646 )));
647 }
648 Ok(matches.into_iter().next())
649}
650
651fn classify_proto(
664 proto: &TensorInfo,
665 h: usize,
666 w: usize,
667) -> Result<(usize, Vec<(DimName, usize)>), InferError> {
668 let (eh, ew) = (h / 4, w / 4);
669 let s = &proto.shape;
670 if s.len() != 4 || s[0] != 1 {
671 return Err(InferError::UnsupportedLayout(format!(
672 "expected a rank-4 proto tensor with batch 1, found shape {s:?}"
673 )));
674 }
675 if s[2] == eh && s[3] == ew {
676 let k = s[1];
677 Ok((
678 k,
679 vec![
680 (DimName::Batch, 1),
681 (DimName::NumProtos, k),
682 (DimName::Height, eh),
683 (DimName::Width, ew),
684 ],
685 ))
686 } else if s[1] == eh && s[2] == ew {
687 let k = s[3];
688 Ok((
689 k,
690 vec![
691 (DimName::Batch, 1),
692 (DimName::Height, eh),
693 (DimName::Width, ew),
694 (DimName::NumProtos, k),
695 ],
696 ))
697 } else {
698 Err(InferError::UnsupportedLayout(format!(
699 "proto tensor shape {s:?} doesn't match the expected ({eh}, {ew}) spatial dims"
700 )))
701 }
702}
703
704fn default_logical() -> LogicalOutput {
707 LogicalOutput {
708 name: None,
709 type_: None,
710 shape: Vec::new(),
711 dshape: Vec::new(),
712 decoder: None,
713 encoding: None,
714 score_format: None,
715 normalized: None,
716 anchors: None,
717 stride: None,
718 dtype: None,
719 quantization: None,
720 outputs: Vec::new(),
721 activation_applied: None,
722 activation_required: None,
723 }
724}
725
726fn check_quantized_boundary(tensor: &TensorInfo) -> Result<(), InferError> {
742 if tensor.dtype.is_integer() && tensor.quantization.is_none() {
743 return Err(InferError::UnsupportedLayout(format!(
744 "output `{}` has integer dtype {:?} but no quantization params",
745 tensor.name, tensor.dtype
746 )));
747 }
748 if let Some(q) = &tensor.quantization {
749 if q.scale.len() > 1 {
750 return Err(InferError::UnsupportedQuantization(format!(
751 "output `{}` carries per-channel quantization ({} scales); \
752 the decoder supports per-tensor quantization only",
753 tensor.name,
754 q.scale.len()
755 )));
756 }
757 if q.scale.is_empty() {
761 return Err(InferError::UnsupportedQuantization(format!(
762 "output `{}` is quantized but carries no scale",
763 tensor.name
764 )));
765 }
766 if let Some(zp) = &q.zero_point {
770 if zp.len() != q.scale.len() {
771 return Err(InferError::UnsupportedQuantization(format!(
772 "output `{}` has {} scale(s) but {} zero point(s)",
773 tensor.name,
774 q.scale.len(),
775 zp.len()
776 )));
777 }
778 }
779 }
780 Ok(())
781}
782
783pub fn infer_ultralytics_schema(signals: &ModelSignals) -> Result<InferredSchema, InferError> {
860 let meta = UltralyticsMeta::from_metadata(&signals.metadata)?;
861
862 let proto = signals.outputs.iter().find(|o| o.shape.len() == 4);
863 match (meta.task, proto.is_some()) {
864 (Some(Task::Segment), false) => {
865 return Err(InferError::UnsupportedLayout(
866 "segment task but no proto tensor".into(),
867 ));
868 }
869 (Some(Task::Detect), true) => {
870 return Err(InferError::UnsupportedLayout(
871 "detect task but proto tensor present".into(),
872 ));
873 }
874 _ => {}
875 }
876 let is_segment = meta.task == Some(Task::Segment) || proto.is_some();
877
878 let input = signals
879 .inputs
880 .iter()
881 .find(|t| t.shape.len() == 4)
882 .ok_or_else(|| {
883 InferError::UnsupportedLayout(format!(
884 "no rank-4 model input found; inputs seen: {:?}",
885 signals.inputs.iter().map(|t| &t.shape).collect::<Vec<_>>()
886 ))
887 })?;
888 let (h, w, input_dshape) = classify_input_layout(input)?;
889
890 let expected_anchors = (h / 8) * (w / 8) + (h / 16) * (w / 16) + (h / 32) * (w / 32);
891 let nc = meta.names.len();
892
893 let (k, proto_dshape) = match proto {
901 Some(proto) => {
902 let (k, dshape) = classify_proto(proto, h, w)?;
903 (k, Some(dshape))
904 }
905 None => (0, None),
906 };
907 let feat = 4 + nc + k;
908 let e2e_feat = 6 + k;
909
910 let rank3_outputs: Vec<&TensorInfo> = signals
911 .outputs
912 .iter()
913 .filter(|o| o.shape.len() == 3)
914 .collect();
915
916 let (out, dshape, version) = match meta.end2end {
920 Some(true) => {
921 let (out, n) = find_e2e_candidate(&rank3_outputs, expected_anchors, e2e_feat)?
922 .ok_or_else(|| {
923 InferError::UnsupportedLayout(format!(
924 "metadata declares end2end=true but no output matches the YOLO26 \
925 end-to-end layout [1, N<={expected_anchors}, {e2e_feat}]; \
926 outputs seen: {:?}",
927 rank3_outputs.iter().map(|o| &o.shape).collect::<Vec<_>>()
928 ))
929 })?;
930 (
931 out,
932 vec![
933 (DimName::Batch, 1),
934 (DimName::NumBoxes, n),
935 (DimName::NumFeatures, e2e_feat),
936 ],
937 DecoderVersion::Yolo26,
938 )
939 }
940 Some(false) => {
941 let (out, dshape) = classify_pre_nms(&rank3_outputs, expected_anchors, feat, nc)?;
942 (out, dshape, DecoderVersion::Yolov8)
943 }
944 None => match classify_pre_nms(&rank3_outputs, expected_anchors, feat, nc) {
945 Ok((out, dshape)) => (out, dshape, DecoderVersion::Yolov8),
946 Err(err @ InferError::ClassCountMismatch { .. }) => return Err(err),
947 Err(_) => {
948 let (out, n) = find_e2e_candidate(&rank3_outputs, expected_anchors, e2e_feat)?
949 .ok_or_else(|| {
950 InferError::UnsupportedLayout(format!(
951 "no output matches a known Ultralytics detection layout \
952 (pre-NMS or end-to-end); outputs seen: {:?}",
953 rank3_outputs.iter().map(|o| &o.shape).collect::<Vec<_>>()
954 ))
955 })?;
956 (
957 out,
958 vec![
959 (DimName::Batch, 1),
960 (DimName::NumBoxes, n),
961 (DimName::NumFeatures, e2e_feat),
962 ],
963 DecoderVersion::Yolo26,
964 )
965 }
966 },
967 };
968
969 check_quantized_boundary(out)?;
970 if let Some(proto) = proto {
971 check_quantized_boundary(proto)?;
972 }
973
974 let normalized = match signals.source {
991 ModelSource::TfLite => true,
992 ModelSource::Onnx | ModelSource::CoreMl => false,
993 ModelSource::Other => {
994 return Err(InferError::UnknownBoxConvention);
995 }
996 };
997 let det = LogicalOutput {
998 name: Some(out.name.clone()),
999 type_: Some(match version {
1006 DecoderVersion::Yolo26 => LogicalType::Detections,
1007 _ => LogicalType::Detection,
1008 }),
1009 shape: out.shape.clone(),
1010 dshape,
1011 decoder: Some(DecoderKind::Ultralytics),
1012 encoding: Some(BoxEncoding::Direct), score_format: Some(ScoreFormat::PerClass),
1014 normalized: Some(normalized),
1015 anchors: None,
1016 stride: None,
1017 dtype: Some(out.dtype),
1018 quantization: out.quantization.clone(),
1019 outputs: Vec::new(),
1020 activation_applied: None,
1021 activation_required: None,
1022 };
1023
1024 let mut outputs = vec![det];
1025 if let (Some(proto), Some(proto_dshape)) = (proto, proto_dshape) {
1026 outputs.push(LogicalOutput {
1027 name: Some(proto.name.clone()),
1028 type_: Some(LogicalType::Protos),
1029 shape: proto.shape.clone(),
1030 dshape: proto_dshape,
1031 decoder: Some(DecoderKind::Ultralytics),
1032 dtype: Some(proto.dtype),
1033 quantization: proto.quantization.clone(),
1034 ..default_logical()
1035 });
1036 }
1037
1038 let schema = SchemaV2 {
1039 schema_version: 2,
1040 input: Some(InputSpec {
1041 shape: input.shape.clone(),
1042 dshape: input_dshape,
1043 cameraadaptor: Some("rgb".into()),
1044 }),
1045 outputs,
1046 nms: match version {
1053 DecoderVersion::Yolo26 => None,
1054 _ => Some(NmsMode::ClassAware),
1055 },
1056 nms_multi_label: None,
1057 decoder_version: Some(version),
1058 };
1059
1060 let version_label = if version == DecoderVersion::Yolo26 {
1061 "YOLO26"
1062 } else {
1063 "YOLOv8/11"
1064 };
1065 let task_label = if is_segment { "segment" } else { "detect" };
1066 let description = format!("Ultralytics {version_label} {task_label}, {nc} classes");
1067
1068 Ok(InferredSchema {
1069 schema,
1070 labels: meta.names,
1071 description,
1072 })
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077 use super::*;
1078
1079 fn infer_fixture_dir() -> std::path::PathBuf {
1091 std::env::var("EDGEFIRST_TESTDATA_DIR")
1092 .map(std::path::PathBuf::from)
1093 .unwrap_or_else(|_| {
1094 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata")
1095 })
1096 .join("infer")
1097 }
1098
1099 #[test]
1100 fn parse_names_python_dict_repr() {
1101 let s = "{0: 'person', 1: 'bicycle', 2: \"fire hydrant\"}";
1102 assert_eq!(
1103 parse_names(s).unwrap(),
1104 vec!["person", "bicycle", "fire hydrant"]
1105 );
1106 }
1107
1108 #[test]
1109 fn parse_names_json_object() {
1110 let s = r#"{"0": "person", "1": "bicycle"}"#;
1111 assert_eq!(parse_names(s).unwrap(), vec!["person", "bicycle"]);
1112 }
1113
1114 #[test]
1115 fn parse_names_orders_by_index_not_insertion() {
1116 let s = "{1: 'b', 0: 'a'}";
1117 assert_eq!(parse_names(s).unwrap(), vec!["a", "b"]);
1118 }
1119
1120 #[test]
1121 fn parse_names_rejects_garbage() {
1122 assert!(matches!(
1123 parse_names("not a dict"),
1124 Err(InferError::BadNames(_))
1125 ));
1126 }
1127
1128 #[test]
1129 fn parse_names_rejects_non_contiguous_indices() {
1130 match parse_names("{0: 'a', 2: 'b'}") {
1132 Err(InferError::BadNames(msg)) => {
1133 assert!(
1134 msg.contains("non-contiguous"),
1135 "unexpected BadNames message: {msg}"
1136 );
1137 }
1138 other => panic!("expected BadNames, got {other:?}"),
1139 }
1140
1141 match parse_names(r#"{"0": "a", "2": "b"}"#) {
1143 Err(InferError::BadNames(msg)) => {
1144 assert!(
1145 msg.contains("non-contiguous"),
1146 "unexpected BadNames message: {msg}"
1147 );
1148 }
1149 other => panic!("expected BadNames, got {other:?}"),
1150 }
1151 }
1152
1153 #[test]
1154 fn meta_from_onnx_props() {
1155 let mut m = BTreeMap::new();
1156 m.insert("names".into(), "{0: 'person'}".into());
1157 m.insert("task".into(), "detect".into());
1158 let u = UltralyticsMeta::from_metadata(&m).unwrap();
1159 assert_eq!(u.names, vec!["person"]);
1160 assert_eq!(u.task, Some(Task::Detect));
1161 }
1162
1163 #[test]
1164 fn meta_task_pose_rejected() {
1165 let mut m = BTreeMap::new();
1166 m.insert("names".into(), "{0: 'person'}".into());
1167 m.insert("task".into(), "pose".into());
1168 assert!(matches!(
1169 UltralyticsMeta::from_metadata(&m),
1170 Err(InferError::UnsupportedTask(_))
1171 ));
1172 }
1173
1174 #[test]
1175 fn meta_missing_names_is_not_ultralytics() {
1176 assert!(matches!(
1177 UltralyticsMeta::from_metadata(&BTreeMap::new()),
1178 Err(InferError::NotUltralytics(_))
1179 ));
1180 }
1181
1182 #[test]
1183 fn meta_end2end_true_or_false_from_onnx_flat_props() {
1184 let mut m = BTreeMap::new();
1185 m.insert("names".into(), "{0: 'person'}".into());
1186 m.insert("end2end".into(), "True".into());
1187 assert_eq!(
1188 UltralyticsMeta::from_metadata(&m).unwrap().end2end,
1189 Some(true)
1190 );
1191
1192 m.insert("end2end".into(), "False".into());
1193 assert_eq!(
1194 UltralyticsMeta::from_metadata(&m).unwrap().end2end,
1195 Some(false)
1196 );
1197 }
1198
1199 fn load_fixture_metadata(name: &str) -> BTreeMap<String, String> {
1204 let path = infer_fixture_dir().join(format!("{name}.signals.json"));
1205 let content = std::fs::read_to_string(&path)
1206 .unwrap_or_else(|e| panic!("failed to read fixture {}: {e}", path.display()));
1207 let json: serde_json::Value =
1208 serde_json::from_str(&content).expect("fixture is valid JSON");
1209 json["metadata"]
1210 .as_object()
1211 .expect("fixture has a metadata object")
1212 .iter()
1213 .map(|(k, v)| {
1214 (
1215 k.clone(),
1216 v.as_str().expect("metadata value is a string").to_string(),
1217 )
1218 })
1219 .collect()
1220 }
1221
1222 #[test]
1223 fn meta_from_real_tflite_metadata_json_envelope() {
1224 let meta = load_fixture_metadata("yolov8n_float32");
1225 let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1226 assert_eq!(u.names.len(), 80);
1227 assert_eq!(u.task, Some(Task::Detect));
1228 }
1229
1230 #[test]
1231 fn meta_end2end_true_on_real_yolo26_tflite_fixture() {
1232 let meta = load_fixture_metadata("yolo26n_float32");
1233 let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1234 assert_eq!(u.end2end, Some(true));
1235 }
1236
1237 #[test]
1238 fn meta_end2end_false_on_real_yolov8_onnx_fixture() {
1239 let meta = load_fixture_metadata("yolov8n");
1240 let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1241 assert_eq!(u.end2end, Some(false));
1242 }
1243
1244 fn signals_from_fixture(name: &str) -> ModelSignals {
1248 let path = infer_fixture_dir().join(format!("{name}.signals.json"));
1249 let content = std::fs::read_to_string(&path)
1250 .unwrap_or_else(|e| panic!("failed to read fixture {}: {e}", path.display()));
1251 let json: serde_json::Value =
1252 serde_json::from_str(&content).expect("fixture is valid JSON");
1253
1254 let source = match json["source"]
1255 .as_str()
1256 .expect("fixture `source` is a string")
1257 {
1258 "onnx" => ModelSource::Onnx,
1259 "tflite" => ModelSource::TfLite,
1260 "coreml" => ModelSource::CoreMl,
1261 other => panic!("fixture {name}: unknown source `{other}`"),
1262 };
1263
1264 fn parse_dtype(s: &str) -> DType {
1265 match s {
1266 "float32" => DType::Float32,
1267 "float16" => DType::Float16,
1268 "int8" => DType::Int8,
1269 "uint8" => DType::Uint8,
1270 "int16" => DType::Int16,
1271 "uint16" => DType::Uint16,
1272 "int32" => DType::Int32,
1273 "uint32" => DType::Uint32,
1274 other => panic!("unknown dtype `{other}`"),
1275 }
1276 }
1277
1278 fn parse_tensor(v: &serde_json::Value) -> TensorInfo {
1279 TensorInfo {
1280 name: v["name"]
1281 .as_str()
1282 .expect("tensor `name` is a string")
1283 .to_string(),
1284 shape: v["shape"]
1285 .as_array()
1286 .expect("tensor `shape` is an array")
1287 .iter()
1288 .map(|d| d.as_u64().expect("shape dim is a number") as usize)
1289 .collect(),
1290 dtype: parse_dtype(v["dtype"].as_str().expect("tensor `dtype` is a string")),
1291 quantization: None,
1295 }
1296 }
1297
1298 let inputs = json["inputs"]
1299 .as_array()
1300 .expect("fixture `inputs` is an array")
1301 .iter()
1302 .map(parse_tensor)
1303 .collect();
1304 let outputs = json["outputs"]
1305 .as_array()
1306 .expect("fixture `outputs` is an array")
1307 .iter()
1308 .map(parse_tensor)
1309 .collect();
1310 let metadata = json["metadata"]
1311 .as_object()
1312 .expect("fixture `metadata` is an object")
1313 .iter()
1314 .map(|(k, v)| {
1315 (
1316 k.clone(),
1317 v.as_str().expect("metadata value is a string").to_string(),
1318 )
1319 })
1320 .collect();
1321
1322 ModelSignals {
1323 source,
1324 inputs,
1325 outputs,
1326 metadata,
1327 }
1328 }
1329
1330 fn synthetic_metadata(nc: usize, task: &str, end2end: &str) -> BTreeMap<String, String> {
1334 let names = (0..nc)
1335 .map(|i| format!("{i}: 'c{i}'"))
1336 .collect::<Vec<_>>()
1337 .join(", ");
1338 let mut m = BTreeMap::new();
1339 m.insert("names".into(), format!("{{{names}}}"));
1340 m.insert("task".into(), task.into());
1341 m.insert("end2end".into(), end2end.into());
1342 m
1343 }
1344
1345 #[test]
1346 fn infer_transposed_det_layout_classified() {
1347 let s = ModelSignals {
1353 source: ModelSource::TfLite,
1354 inputs: vec![TensorInfo {
1355 name: "images".into(),
1356 shape: vec![1, 640, 640, 3],
1357 dtype: DType::Float32,
1358 quantization: None,
1359 }],
1360 outputs: vec![TensorInfo {
1361 name: "output0".into(),
1362 shape: vec![1, 8400, 84],
1363 dtype: DType::Float32,
1364 quantization: None,
1365 }],
1366 metadata: synthetic_metadata(80, "detect", "False"),
1367 };
1368 let r = infer_ultralytics_schema(&s).unwrap();
1369 let o = &r.schema.outputs[0];
1370 assert_eq!(
1371 o.dshape,
1372 vec![
1373 (DimName::Batch, 1),
1374 (DimName::NumBoxes, 8400),
1375 (DimName::NumFeatures, 84),
1376 ]
1377 );
1378 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1379 }
1380
1381 #[test]
1382 fn infer_yolo26n_onnx_end_to_end() {
1383 let s = signals_from_fixture("yolo26n");
1384 let r = infer_ultralytics_schema(&s).unwrap();
1385 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1386 assert_eq!(r.schema.outputs[0].shape[2], 6);
1387 }
1388
1389 #[test]
1390 fn infer_class_count_mismatch_rejected() {
1391 let mut s = signals_from_fixture("yolov8n");
1392 s.metadata.insert("names".into(), "{0: 'only-one'}".into()); assert!(matches!(
1394 infer_ultralytics_schema(&s),
1395 Err(InferError::ClassCountMismatch {
1396 expected: 1,
1397 found: 80
1398 })
1399 ));
1400 }
1401
1402 #[test]
1403 fn infer_seg_class_count_mismatch_rejected() {
1404 let mut s = signals_from_fixture("yolov8n-seg");
1413 s.metadata.insert("names".into(), "{0: 'only-one'}".into()); assert!(matches!(
1415 infer_ultralytics_schema(&s),
1416 Err(InferError::ClassCountMismatch {
1417 expected: 1,
1418 found: 80
1419 })
1420 ));
1421 }
1422
1423 #[test]
1424 fn infer_end2end_flag_shape_conflict_rejected() {
1425 let mut s = signals_from_fixture("yolov8n");
1430 s.metadata.insert("end2end".into(), "True".into());
1431 assert!(matches!(
1432 infer_ultralytics_schema(&s),
1433 Err(InferError::UnsupportedLayout(_))
1434 ));
1435 }
1436
1437 #[test]
1438 fn infer_yolov8n_seg_onnx() {
1439 let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n-seg")).unwrap();
1440 assert_eq!(r.schema.outputs.len(), 2);
1441 let proto = r
1442 .schema
1443 .outputs
1444 .iter()
1445 .find(|o| o.type_ == Some(LogicalType::Protos))
1446 .unwrap();
1447 assert_eq!(proto.dshape[1].0, DimName::NumProtos); let det = r
1449 .schema
1450 .outputs
1451 .iter()
1452 .find(|o| o.type_ == Some(LogicalType::Detection))
1453 .unwrap();
1454 assert!(det.shape.contains(&116)); }
1456
1457 #[test]
1458 fn infer_yolov8n_seg_tflite_proto_nchw() {
1459 let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n-seg_float32")).unwrap();
1466 let proto = r
1467 .schema
1468 .outputs
1469 .iter()
1470 .find(|o| o.type_ == Some(LogicalType::Protos))
1471 .unwrap();
1472 assert_eq!(proto.dshape[1].0, DimName::NumProtos); assert_eq!(proto.dshape[1].1, 32);
1474 }
1475
1476 #[test]
1477 fn infer_yolo26n_seg_end_to_end() {
1478 let r = infer_ultralytics_schema(&signals_from_fixture("yolo26n-seg")).unwrap();
1479 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1480 assert_eq!(r.schema.outputs.len(), 2);
1481 }
1482
1483 #[test]
1484 fn infer_nhwc_proto_layout_classified() {
1485 let s = ModelSignals {
1489 source: ModelSource::TfLite,
1490 inputs: vec![TensorInfo {
1491 name: "images".into(),
1492 shape: vec![1, 640, 640, 3],
1493 dtype: DType::Float32,
1494 quantization: None,
1495 }],
1496 outputs: vec![
1497 TensorInfo {
1498 name: "output0".into(),
1499 shape: vec![1, 116, 8400],
1500 dtype: DType::Float32,
1501 quantization: None,
1502 },
1503 TensorInfo {
1504 name: "output1".into(),
1505 shape: vec![1, 160, 160, 32],
1506 dtype: DType::Float32,
1507 quantization: None,
1508 },
1509 ],
1510 metadata: synthetic_metadata(80, "segment", "False"),
1511 };
1512 let r = infer_ultralytics_schema(&s).unwrap();
1513 let proto = r
1514 .schema
1515 .outputs
1516 .iter()
1517 .find(|o| o.type_ == Some(LogicalType::Protos))
1518 .unwrap();
1519 assert_eq!(proto.dshape[3].0, DimName::NumProtos); assert_eq!(proto.dshape[3].1, 32);
1521 }
1522
1523 #[test]
1527 fn infer_proto_output_carries_its_name_decoder_dtype_and_quantization() {
1528 let quant = Quantization {
1529 scale: vec![0.0125],
1530 zero_point: Some(vec![-7]),
1531 axis: None,
1532 dtype: Some(DType::Int8),
1533 };
1534 let s = ModelSignals {
1535 source: ModelSource::TfLite,
1536 inputs: vec![TensorInfo {
1537 name: "images".into(),
1538 shape: vec![1, 640, 640, 3],
1539 dtype: DType::Int8,
1540 quantization: None,
1541 }],
1542 outputs: vec![
1543 TensorInfo {
1544 name: "output0".into(),
1545 shape: vec![1, 116, 8400],
1546 dtype: DType::Int8,
1547 quantization: Some(Quantization {
1548 scale: vec![0.003_921_6],
1549 zero_point: Some(vec![0]),
1550 axis: None,
1551 dtype: Some(DType::Int8),
1552 }),
1553 },
1554 TensorInfo {
1555 name: "protos".into(),
1556 shape: vec![1, 160, 160, 32],
1557 dtype: DType::Int8,
1558 quantization: Some(quant.clone()),
1559 },
1560 ],
1561 metadata: synthetic_metadata(80, "segment", "False"),
1562 };
1563 let r = infer_ultralytics_schema(&s).unwrap();
1564 let proto = r
1565 .schema
1566 .outputs
1567 .iter()
1568 .find(|o| o.type_ == Some(LogicalType::Protos))
1569 .unwrap();
1570 assert_eq!(proto.name.as_deref(), Some("protos"));
1571 assert_eq!(proto.decoder, Some(DecoderKind::Ultralytics));
1572 assert_eq!(proto.dtype, Some(DType::Int8));
1573 assert_eq!(proto.quantization.as_ref(), Some(&quant));
1574 }
1575
1576 #[test]
1580 fn infer_treats_a_proto_tensor_without_a_task_as_segmentation() {
1581 let mut metadata = synthetic_metadata(80, "segment", "False");
1582 metadata.remove("task");
1583 let s = ModelSignals {
1584 source: ModelSource::TfLite,
1585 inputs: vec![TensorInfo {
1586 name: "images".into(),
1587 shape: vec![1, 640, 640, 3],
1588 dtype: DType::Float32,
1589 quantization: None,
1590 }],
1591 outputs: vec![
1592 TensorInfo {
1593 name: "output0".into(),
1594 shape: vec![1, 116, 8400],
1595 dtype: DType::Float32,
1596 quantization: None,
1597 },
1598 TensorInfo {
1599 name: "output1".into(),
1600 shape: vec![1, 160, 160, 32],
1601 dtype: DType::Float32,
1602 quantization: None,
1603 },
1604 ],
1605 metadata,
1606 };
1607 let r = infer_ultralytics_schema(&s).unwrap();
1608 assert!(
1609 r.schema
1610 .outputs
1611 .iter()
1612 .any(|o| o.type_ == Some(LogicalType::Protos)),
1613 "proto tensor present but no protos output was emitted"
1614 );
1615 assert!(r.description.contains("segment"), "{}", r.description);
1618 }
1619
1620 fn proto_tensor(shape: Vec<usize>) -> TensorInfo {
1621 TensorInfo {
1622 name: "protos".into(),
1623 shape,
1624 dtype: DType::Float32,
1625 quantization: None,
1626 }
1627 }
1628
1629 #[test]
1632 fn classify_proto_rejects_wrong_rank_or_batch() {
1633 for shape in [vec![1, 32, 160], vec![2, 32, 160, 160], vec![1, 160, 160]] {
1634 let err = classify_proto(&proto_tensor(shape.clone()), 640, 640).unwrap_err();
1635 assert!(
1636 matches!(err, InferError::UnsupportedLayout(_)),
1637 "shape {shape:?} gave {err:?}"
1638 );
1639 }
1640 }
1641
1642 #[test]
1645 fn classify_proto_rejects_a_shape_matching_only_one_spatial_dim() {
1646 let err = classify_proto(&proto_tensor(vec![1, 160, 7, 9]), 640, 640).unwrap_err();
1648 assert!(matches!(err, InferError::UnsupportedLayout(_)), "{err:?}");
1649
1650 let (k, dshape) = classify_proto(&proto_tensor(vec![1, 160, 160, 32]), 640, 640).unwrap();
1652 assert_eq!(k, 32);
1653 assert_eq!(dshape[3], (DimName::NumProtos, 32));
1654 }
1655
1656 #[test]
1659 fn unquote_requires_a_matching_pair_of_quotes() {
1660 assert_eq!(unquote("'abc'").as_deref(), Some("abc"));
1661 assert_eq!(unquote("\"abc\"").as_deref(), Some("abc"));
1662 assert_eq!(unquote("'abc"), None);
1663 assert_eq!(unquote("abc'"), None);
1664 assert_eq!(unquote("\"abc"), None);
1665 assert_eq!(unquote("abc\""), None);
1666 assert_eq!(unquote("abc"), None);
1667 }
1668
1669 #[test]
1673 fn split_top_level_commas_keeps_quoted_commas_together() {
1674 let parts = split_top_level_commas("a='x,y', b=2");
1675 assert_eq!(parts, vec!["a='x,y'".to_string(), " b=2".to_string()]);
1676
1677 let parts = split_top_level_commas("a=\"x,y\",b=\"p,q\"");
1678 assert_eq!(
1679 parts,
1680 vec!["a=\"x,y\"".to_string(), "b=\"p,q\"".to_string()]
1681 );
1682 }
1683
1684 #[test]
1687 fn infer_error_display_states_the_reason() {
1688 let err = InferError::NotUltralytics("no names key".into());
1689 let shown = err.to_string();
1690 assert!(shown.contains("no names key"), "{shown}");
1691 assert!(shown.contains("Ultralytics"), "{shown}");
1692
1693 let err = InferError::UnsupportedLayout("rank-3 proto".into());
1694 assert!(err.to_string().contains("rank-3 proto"));
1695 }
1696
1697 #[test]
1698 fn infer_segment_task_without_proto_rejected() {
1699 let s = ModelSignals {
1700 source: ModelSource::Onnx,
1701 inputs: vec![TensorInfo {
1702 name: "images".into(),
1703 shape: vec![1, 3, 640, 640],
1704 dtype: DType::Float32,
1705 quantization: None,
1706 }],
1707 outputs: vec![TensorInfo {
1708 name: "output0".into(),
1709 shape: vec![1, 84, 8400],
1710 dtype: DType::Float32,
1711 quantization: None,
1712 }],
1713 metadata: synthetic_metadata(80, "segment", "False"),
1714 };
1715 assert!(matches!(
1716 infer_ultralytics_schema(&s),
1717 Err(InferError::UnsupportedLayout(_))
1718 ));
1719 }
1720
1721 #[test]
1722 fn infer_detect_task_with_proto_rejected() {
1723 let s = ModelSignals {
1724 source: ModelSource::Onnx,
1725 inputs: vec![TensorInfo {
1726 name: "images".into(),
1727 shape: vec![1, 3, 640, 640],
1728 dtype: DType::Float32,
1729 quantization: None,
1730 }],
1731 outputs: vec![
1732 TensorInfo {
1733 name: "output0".into(),
1734 shape: vec![1, 84, 8400],
1735 dtype: DType::Float32,
1736 quantization: None,
1737 },
1738 TensorInfo {
1739 name: "output1".into(),
1740 shape: vec![1, 32, 160, 160],
1741 dtype: DType::Float32,
1742 quantization: None,
1743 },
1744 ],
1745 metadata: synthetic_metadata(80, "detect", "False"),
1746 };
1747 assert!(matches!(
1748 infer_ultralytics_schema(&s),
1749 Err(InferError::UnsupportedLayout(_))
1750 ));
1751 }
1752
1753 #[test]
1754 fn infer_int8_tflite_classifies_like_float32() {
1755 let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n_int8")).unwrap();
1761 let det = &r.schema.outputs[0];
1762 assert_eq!(det.type_, Some(LogicalType::Detection));
1763 assert_eq!(det.dtype, Some(DType::Float32));
1764 assert_eq!(det.quantization, None);
1765 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1766 assert_eq!(det.normalized, Some(true));
1767 }
1768
1769 #[test]
1770 fn infer_quantized_boundary_carries_quantization() {
1771 let quant = Quantization {
1776 scale: vec![0.02],
1777 zero_point: Some(vec![-5]),
1778 axis: None,
1779 dtype: Some(DType::Int8),
1780 };
1781 let s = ModelSignals {
1782 source: ModelSource::TfLite,
1783 inputs: vec![TensorInfo {
1784 name: "images".into(),
1785 shape: vec![1, 640, 640, 3],
1786 dtype: DType::Float32,
1787 quantization: None,
1788 }],
1789 outputs: vec![TensorInfo {
1790 name: "output0".into(),
1791 shape: vec![1, 84, 8400],
1792 dtype: DType::Int8,
1793 quantization: Some(quant.clone()),
1794 }],
1795 metadata: synthetic_metadata(80, "detect", "False"),
1796 };
1797 let r = infer_ultralytics_schema(&s).unwrap();
1798 let det = &r.schema.outputs[0];
1799 assert_eq!(det.dtype, Some(DType::Int8));
1800 assert_eq!(det.quantization, Some(quant));
1801 }
1802
1803 #[test]
1804 fn infer_rejects_batched_input() {
1805 let s = ModelSignals {
1810 source: ModelSource::Onnx,
1811 inputs: vec![TensorInfo {
1812 name: "images".into(),
1813 shape: vec![4, 3, 640, 640],
1814 dtype: DType::Float32,
1815 quantization: None,
1816 }],
1817 outputs: vec![TensorInfo {
1818 name: "output0".into(),
1819 shape: vec![1, 84, 8400],
1820 dtype: DType::Float32,
1821 quantization: None,
1822 }],
1823 metadata: synthetic_metadata(80, "detect", "False"),
1824 };
1825 match infer_ultralytics_schema(&s) {
1826 Err(InferError::UnsupportedLayout(msg)) => {
1827 assert!(msg.contains("batch 4"), "unexpected message: {msg}");
1828 }
1829 other => panic!("expected UnsupportedLayout, got {other:?}"),
1830 }
1831 }
1832
1833 #[test]
1834 fn infer_rejects_input_without_a_channel_dim() {
1835 let s = ModelSignals {
1836 source: ModelSource::Onnx,
1837 inputs: vec![TensorInfo {
1838 name: "images".into(),
1839 shape: vec![1, 4, 640, 640],
1840 dtype: DType::Float32,
1841 quantization: None,
1842 }],
1843 outputs: vec![TensorInfo {
1844 name: "output0".into(),
1845 shape: vec![1, 84, 8400],
1846 dtype: DType::Float32,
1847 quantization: None,
1848 }],
1849 metadata: synthetic_metadata(80, "detect", "False"),
1850 };
1851 match infer_ultralytics_schema(&s) {
1852 Err(InferError::UnsupportedLayout(msg)) => {
1853 assert!(msg.contains("channel dimension"), "unexpected: {msg}");
1854 }
1855 other => panic!("expected UnsupportedLayout, got {other:?}"),
1856 }
1857 }
1858
1859 #[test]
1860 fn infer_rejects_model_with_no_rank4_input() {
1861 let s = ModelSignals {
1862 source: ModelSource::Onnx,
1863 inputs: vec![TensorInfo {
1864 name: "images".into(),
1865 shape: vec![1, 3],
1866 dtype: DType::Float32,
1867 quantization: None,
1868 }],
1869 outputs: vec![TensorInfo {
1870 name: "output0".into(),
1871 shape: vec![1, 84, 8400],
1872 dtype: DType::Float32,
1873 quantization: None,
1874 }],
1875 metadata: synthetic_metadata(80, "detect", "False"),
1876 };
1877 match infer_ultralytics_schema(&s) {
1878 Err(InferError::UnsupportedLayout(msg)) => {
1879 assert!(msg.contains("no rank-4 model input"), "unexpected: {msg}");
1880 }
1881 other => panic!("expected UnsupportedLayout, got {other:?}"),
1882 }
1883 }
1884
1885 fn det_signals(nc: usize, out_shape: Vec<usize>, end2end: &str) -> ModelSignals {
1887 ModelSignals {
1888 source: ModelSource::Onnx,
1889 inputs: vec![TensorInfo {
1890 name: "images".into(),
1891 shape: vec![1, 3, 640, 640],
1892 dtype: DType::Float32,
1893 quantization: None,
1894 }],
1895 outputs: vec![TensorInfo {
1896 name: "output0".into(),
1897 shape: out_shape,
1898 dtype: DType::Float32,
1899 quantization: None,
1900 }],
1901 metadata: synthetic_metadata(nc, "detect", end2end),
1902 }
1903 }
1904
1905 #[test]
1906 fn infer_end_to_end_accepts_any_max_det_up_to_the_anchor_count() {
1907 for n in [1, 300, 2000, 8400] {
1919 let r = infer_ultralytics_schema(&det_signals(80, vec![1, n, 6], "True"))
1920 .unwrap_or_else(|e| panic!("max_det={n} must infer: {e}"));
1921 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1922 assert_eq!(r.schema.outputs[0].type_, Some(LogicalType::Detections));
1923 assert_eq!(
1924 r.schema.outputs[0].dshape,
1925 vec![
1926 (DimName::Batch, 1),
1927 (DimName::NumBoxes, n),
1928 (DimName::NumFeatures, 6),
1929 ],
1930 "max_det={n}"
1931 );
1932 assert_eq!(r.schema.nms, None, "max_det={n}");
1934 }
1935 }
1936
1937 #[test]
1938 fn infer_end_to_end_rejects_more_rows_than_anchors() {
1939 match infer_ultralytics_schema(&det_signals(80, vec![1, 8401, 6], "True")) {
1943 Err(InferError::UnsupportedLayout(msg)) => {
1944 assert!(msg.contains("N<=8400"), "unexpected message: {msg}");
1945 }
1946 other => panic!("expected UnsupportedLayout, got {other:?}"),
1947 }
1948 }
1949
1950 #[test]
1951 fn infer_legacy_nms_model_is_unaffected_by_max_det() {
1952 let r = infer_ultralytics_schema(&det_signals(80, vec![1, 84, 8400], "False")).unwrap();
1958 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1959 assert_eq!(r.schema.outputs[0].type_, Some(LogicalType::Detection));
1960 assert_eq!(
1961 r.schema.outputs[0].dshape,
1962 vec![
1963 (DimName::Batch, 1),
1964 (DimName::NumFeatures, 84),
1965 (DimName::NumBoxes, 8400),
1966 ]
1967 );
1968 assert_eq!(r.schema.nms, Some(NmsMode::ClassAware));
1972
1973 let t = infer_ultralytics_schema(&det_signals(80, vec![1, 8400, 84], "False")).unwrap();
1976 assert_eq!(t.schema.decoder_version, Some(DecoderVersion::Yolov8));
1977 assert_eq!(
1978 t.schema.outputs[0].dshape,
1979 vec![
1980 (DimName::Batch, 1),
1981 (DimName::NumBoxes, 8400),
1982 (DimName::NumFeatures, 84),
1983 ]
1984 );
1985 }
1986
1987 #[test]
1988 fn infer_pre_nms_wins_when_a_shape_reads_as_both() {
1989 let mut m = synthetic_metadata(2, "detect", "False");
1994 m.remove("end2end");
1995 let s = ModelSignals {
1996 metadata: m,
1997 ..det_signals(2, vec![1, 8400, 6], "False")
1998 };
1999 let r = infer_ultralytics_schema(&s).unwrap();
2000 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
2001 }
2002
2003 #[test]
2004 fn meta_empty_names_rejected() {
2005 let mut m = BTreeMap::new();
2008 m.insert("names".into(), "{}".into());
2009 match UltralyticsMeta::from_metadata(&m) {
2010 Err(InferError::BadNames(msg)) => assert!(msg.contains("empty"), "{msg}"),
2011 other => panic!("expected BadNames, got {other:?}"),
2012 }
2013 }
2014
2015 #[test]
2016 fn meta_malformed_end2end_is_an_error_not_an_absent_key() {
2017 let mut m = synthetic_metadata(80, "detect", "False");
2021 m.insert("end2end".into(), "yes".into());
2022 match UltralyticsMeta::from_metadata(&m) {
2023 Err(InferError::BadMetadata(msg)) => assert!(msg.contains("end2end"), "{msg}"),
2024 other => panic!("expected BadMetadata, got {other:?}"),
2025 }
2026
2027 m.remove("end2end");
2029 assert_eq!(UltralyticsMeta::from_metadata(&m).unwrap().end2end, None);
2030 }
2031
2032 #[test]
2033 fn meta_foreign_vendor_rejected_but_missing_provenance_allowed() {
2034 let mut m = synthetic_metadata(80, "detect", "False");
2038 m.insert("author".into(), "SomeOtherVendor".into());
2039 assert!(matches!(
2040 UltralyticsMeta::from_metadata(&m),
2041 Err(InferError::NotUltralytics(_))
2042 ));
2043
2044 m.insert("author".into(), "Ultralytics".into());
2046 m.insert("docs".into(), "https://docs.ultralytics.com".into());
2047 assert!(UltralyticsMeta::from_metadata(&m).is_ok());
2048
2049 m.remove("author");
2052 m.remove("docs");
2053 assert!(UltralyticsMeta::from_metadata(&m).is_ok());
2054 }
2055
2056 #[test]
2057 fn infer_zero_row_end_to_end_rejected() {
2058 assert!(matches!(
2061 infer_ultralytics_schema(&det_signals(80, vec![1, 0, 6], "True")),
2062 Err(InferError::UnsupportedLayout(_))
2063 ));
2064 }
2065
2066 #[test]
2067 fn infer_duplicate_matching_outputs_are_ambiguous() {
2068 let input = TensorInfo {
2069 name: "images".into(),
2070 shape: vec![1, 3, 640, 640],
2071 dtype: DType::Float32,
2072 quantization: None,
2073 };
2074 let t = |name: &str, shape: Vec<usize>| TensorInfo {
2075 name: name.into(),
2076 shape,
2077 dtype: DType::Float32,
2078 quantization: None,
2079 };
2080
2081 let pre = ModelSignals {
2084 source: ModelSource::Onnx,
2085 inputs: vec![input.clone()],
2086 outputs: vec![t("a", vec![1, 84, 8400]), t("b", vec![1, 84, 8400])],
2087 metadata: synthetic_metadata(80, "detect", "False"),
2088 };
2089 match infer_ultralytics_schema(&pre) {
2090 Err(InferError::AmbiguousLayout(msg)) => {
2091 assert!(msg.contains("`a`") && msg.contains("`b`"), "{msg}");
2092 }
2093 other => panic!("expected AmbiguousLayout, got {other:?}"),
2094 }
2095
2096 let e2e = ModelSignals {
2098 source: ModelSource::Onnx,
2099 inputs: vec![input],
2100 outputs: vec![t("a", vec![1, 300, 6]), t("b", vec![1, 200, 6])],
2101 metadata: synthetic_metadata(80, "detect", "True"),
2102 };
2103 assert!(matches!(
2104 infer_ultralytics_schema(&e2e),
2105 Err(InferError::AmbiguousLayout(_))
2106 ));
2107 }
2108
2109 #[test]
2110 fn infer_malformed_per_tensor_quantization_rejected() {
2111 let build = |q: Quantization| ModelSignals {
2112 source: ModelSource::TfLite,
2113 inputs: vec![TensorInfo {
2114 name: "images".into(),
2115 shape: vec![1, 640, 640, 3],
2116 dtype: DType::Float32,
2117 quantization: None,
2118 }],
2119 outputs: vec![TensorInfo {
2120 name: "output0".into(),
2121 shape: vec![1, 84, 8400],
2122 dtype: DType::Int8,
2123 quantization: Some(q),
2124 }],
2125 metadata: synthetic_metadata(80, "detect", "False"),
2126 };
2127
2128 match infer_ultralytics_schema(&build(Quantization {
2131 scale: vec![],
2132 zero_point: Some(vec![]),
2133 axis: None,
2134 dtype: Some(DType::Int8),
2135 })) {
2136 Err(InferError::UnsupportedQuantization(msg)) => {
2137 assert!(msg.contains("no scale"), "{msg}");
2138 }
2139 other => panic!("expected UnsupportedQuantization, got {other:?}"),
2140 }
2141
2142 match infer_ultralytics_schema(&build(Quantization {
2145 scale: vec![0.02],
2146 zero_point: Some(vec![-5, -7]),
2147 axis: None,
2148 dtype: Some(DType::Int8),
2149 })) {
2150 Err(InferError::UnsupportedQuantization(msg)) => {
2151 assert!(msg.contains("zero point"), "{msg}");
2152 }
2153 other => panic!("expected UnsupportedQuantization, got {other:?}"),
2154 }
2155
2156 assert!(infer_ultralytics_schema(&build(Quantization {
2158 scale: vec![0.02],
2159 zero_point: None,
2160 axis: None,
2161 dtype: Some(DType::Int8),
2162 }))
2163 .is_ok());
2164 }
2165
2166 #[test]
2167 fn infer_pre_nms_is_independent_of_output_order() {
2168 let det = TensorInfo {
2174 name: "output0".into(),
2175 shape: vec![1, 84, 8400],
2176 dtype: DType::Float32,
2177 quantization: None,
2178 };
2179 let aux = TensorInfo {
2180 name: "boxes".into(),
2181 shape: vec![1, 4, 8400],
2182 dtype: DType::Float32,
2183 quantization: None,
2184 };
2185 let build = |outputs: Vec<TensorInfo>| ModelSignals {
2186 source: ModelSource::Onnx,
2187 inputs: vec![TensorInfo {
2188 name: "images".into(),
2189 shape: vec![1, 3, 640, 640],
2190 dtype: DType::Float32,
2191 quantization: None,
2192 }],
2193 outputs,
2194 metadata: synthetic_metadata(80, "detect", "False"),
2195 };
2196
2197 let det_first = infer_ultralytics_schema(&build(vec![det.clone(), aux.clone()]))
2198 .expect("detection tensor first must classify");
2199 let aux_first = infer_ultralytics_schema(&build(vec![aux, det]))
2200 .expect("an auxiliary anchor-shaped tensor first must not shadow the detection tensor");
2201 assert_eq!(det_first.schema.outputs[0].name, Some("output0".into()));
2202 assert_eq!(aux_first.schema.outputs[0].name, Some("output0".into()));
2203 assert_eq!(det_first.schema, aux_first.schema);
2204 }
2205
2206 #[test]
2207 fn infer_class_count_mismatch_survives_a_full_scan() {
2208 let s = ModelSignals {
2211 source: ModelSource::Onnx,
2212 inputs: vec![TensorInfo {
2213 name: "images".into(),
2214 shape: vec![1, 3, 640, 640],
2215 dtype: DType::Float32,
2216 quantization: None,
2217 }],
2218 outputs: vec![TensorInfo {
2219 name: "output0".into(),
2220 shape: vec![1, 84, 8400],
2221 dtype: DType::Float32,
2222 quantization: None,
2223 }],
2224 metadata: synthetic_metadata(10, "detect", "False"),
2226 };
2227 assert!(matches!(
2228 infer_ultralytics_schema(&s),
2229 Err(InferError::ClassCountMismatch {
2230 expected: 10,
2231 found: 80
2232 })
2233 ));
2234 }
2235
2236 struct FixtureExpectation {
2246 name: &'static str,
2247 version: DecoderVersion,
2248 det_type: LogicalType,
2249 det_dshape: &'static [(DimName, usize)],
2251 normalized: bool,
2252 nms: Option<NmsMode>,
2253 proto: Option<(usize, usize, usize)>,
2255 }
2256
2257 const A: usize = 8400; const NC: usize = 80;
2259
2260 fn fixture_expectations() -> Vec<FixtureExpectation> {
2261 use DimName::{Batch, NumBoxes, NumFeatures};
2262 const DET: &[(DimName, usize)] = &[(Batch, 1), (NumFeatures, 4 + NC), (NumBoxes, A)];
2266 const SEG: &[(DimName, usize)] = &[(Batch, 1), (NumFeatures, 4 + NC + 32), (NumBoxes, A)];
2267 const E2E: &[(DimName, usize)] = &[(Batch, 1), (NumBoxes, 300), (NumFeatures, 6)];
2269 const E2E_SEG: &[(DimName, usize)] = &[(Batch, 1), (NumBoxes, 300), (NumFeatures, 6 + 32)];
2270
2271 let pre_nms = |name, det_dshape, normalized, proto| FixtureExpectation {
2272 name,
2273 version: DecoderVersion::Yolov8,
2274 det_type: LogicalType::Detection,
2275 det_dshape,
2276 normalized,
2277 nms: Some(NmsMode::ClassAware),
2278 proto,
2279 };
2280 let e2e = |name, det_dshape, normalized, proto| FixtureExpectation {
2281 name,
2282 version: DecoderVersion::Yolo26,
2283 det_type: LogicalType::Detections,
2284 det_dshape,
2285 normalized,
2286 nms: None,
2288 proto,
2289 };
2290 const P: Option<(usize, usize, usize)> = Some((32, 160, 160));
2293 vec![
2294 pre_nms("yolov8n", DET, false, None),
2295 pre_nms("yolo11n", DET, false, None),
2296 pre_nms("yolov8n-seg", SEG, false, P),
2297 pre_nms("yolo11n-seg", SEG, false, P),
2298 pre_nms("yolov8n_float32", DET, true, None),
2299 pre_nms("yolov8n_int8", DET, true, None),
2300 pre_nms("yolov8n-seg_float32", SEG, true, P),
2301 pre_nms("yolov8n-seg_int8", SEG, true, P),
2302 e2e("yolo26n", E2E, false, None),
2303 e2e("yolo26n-seg", E2E_SEG, false, P),
2304 e2e("yolo26n_float32", E2E, true, None),
2305 pre_nms("yolo26n_coreml", DET, false, None),
2313 ]
2314 }
2315
2316 #[test]
2317 fn every_captured_fixture_infers_the_expected_schema() {
2318 for want in fixture_expectations() {
2319 let name = want.name;
2320 let r = infer_ultralytics_schema(&signals_from_fixture(name))
2321 .unwrap_or_else(|e| panic!("{name}: inference failed: {e}"));
2322
2323 assert_eq!(r.labels.len(), NC, "{name}: class count");
2324 assert_eq!(
2325 r.labels[0], "person",
2326 "{name}: labels must be index-ordered"
2327 );
2328 assert_eq!(r.labels[NC - 1], "toothbrush", "{name}: labels index order");
2329 assert_eq!(
2330 r.schema.decoder_version,
2331 Some(want.version),
2332 "{name}: version"
2333 );
2334 assert_eq!(r.schema.nms, want.nms, "{name}: nms mode");
2335
2336 let det = &r.schema.outputs[0];
2337 assert_eq!(det.type_, Some(want.det_type), "{name}: detection type");
2338 assert_eq!(det.dshape, want.det_dshape, "{name}: detection dshape");
2339 assert_eq!(det.normalized, Some(want.normalized), "{name}: normalized");
2340 assert_eq!(
2341 det.decoder,
2342 Some(DecoderKind::Ultralytics),
2343 "{name}: decoder"
2344 );
2345 assert_eq!(det.encoding, Some(BoxEncoding::Direct), "{name}: encoding");
2346 assert_eq!(
2347 det.score_format,
2348 Some(ScoreFormat::PerClass),
2349 "{name}: scores"
2350 );
2351
2352 match want.proto {
2353 None => {
2354 assert_eq!(r.schema.outputs.len(), 1, "{name}: detection-only");
2355 }
2356 Some((k, ph, pw)) => {
2357 assert_eq!(r.schema.outputs.len(), 2, "{name}: detection + proto");
2358 let proto = &r.schema.outputs[1];
2359 assert_eq!(proto.type_, Some(LogicalType::Protos), "{name}: proto type");
2360 assert_eq!(
2361 proto.dshape,
2362 vec![
2363 (DimName::Batch, 1),
2364 (DimName::NumProtos, k),
2365 (DimName::Height, ph),
2366 (DimName::Width, pw),
2367 ],
2368 "{name}: proto dshape"
2369 );
2370 }
2371 }
2372
2373 let task = if want.proto.is_some() {
2374 "segment"
2375 } else {
2376 "detect"
2377 };
2378 assert!(
2379 r.description.contains(task) && r.description.contains("Ultralytics"),
2380 "{name}: description was `{}`",
2381 r.description
2382 );
2383 }
2384 }
2385
2386 #[test]
2387 fn every_fixture_is_asserted() {
2388 let dir = infer_fixture_dir();
2392 let mut on_disk: Vec<String> = std::fs::read_dir(&dir)
2393 .unwrap_or_else(|e| panic!("{} exists: {e}", dir.display()))
2394 .filter_map(|e| {
2395 let name = e.ok()?.file_name().into_string().ok()?;
2396 name.strip_suffix(".signals.json").map(str::to_string)
2397 })
2398 .collect();
2399 on_disk.sort();
2400
2401 let mut asserted: Vec<String> = fixture_expectations()
2402 .iter()
2403 .map(|f| f.name.to_string())
2404 .collect();
2405 asserted.sort();
2406
2407 assert_eq!(
2408 on_disk, asserted,
2409 "every *.signals.json fixture must appear in fixture_expectations()"
2410 );
2411 }
2412
2413 #[test]
2414 fn infer_other_source_refuses_rather_than_guessing_normalization() {
2415 let s = ModelSignals {
2422 source: ModelSource::Other,
2423 inputs: vec![TensorInfo {
2424 name: "images".into(),
2425 shape: vec![1, 3, 640, 640],
2426 dtype: DType::Float32,
2427 quantization: None,
2428 }],
2429 outputs: vec![TensorInfo {
2430 name: "output0".into(),
2431 shape: vec![1, 84, 8400],
2432 dtype: DType::Float32,
2433 quantization: None,
2434 }],
2435 metadata: synthetic_metadata(80, "detect", "False"),
2436 };
2437 assert!(matches!(
2438 infer_ultralytics_schema(&s),
2439 Err(InferError::UnknownBoxConvention)
2440 ));
2441
2442 let ok = ModelSignals {
2445 source: ModelSource::Onnx,
2446 ..s
2447 };
2448 assert_eq!(
2449 infer_ultralytics_schema(&ok).unwrap().schema.outputs[0].normalized,
2450 Some(false)
2451 );
2452 }
2453
2454 #[test]
2455 fn infer_per_channel_quantization_rejected() {
2456 let s = ModelSignals {
2465 source: ModelSource::TfLite,
2466 inputs: vec![TensorInfo {
2467 name: "images".into(),
2468 shape: vec![1, 640, 640, 3],
2469 dtype: DType::Float32,
2470 quantization: None,
2471 }],
2472 outputs: vec![TensorInfo {
2473 name: "output0".into(),
2474 shape: vec![1, 84, 8400],
2475 dtype: DType::Int8,
2476 quantization: Some(Quantization {
2477 scale: vec![0.02, 0.03],
2478 zero_point: Some(vec![-5, -7]),
2479 axis: Some(1),
2480 dtype: Some(DType::Int8),
2481 }),
2482 }],
2483 metadata: synthetic_metadata(80, "detect", "False"),
2484 };
2485 match infer_ultralytics_schema(&s) {
2486 Err(InferError::UnsupportedQuantization(msg)) => {
2487 assert!(msg.contains("per-channel"), "unexpected message: {msg}");
2488 assert!(
2489 msg.contains("output0"),
2490 "message must name the tensor: {msg}"
2491 );
2492 }
2493 other => panic!("expected UnsupportedQuantization, got {other:?}"),
2494 }
2495 }
2496
2497 #[test]
2498 fn infer_per_channel_proto_quantization_rejected() {
2499 let s = ModelSignals {
2502 source: ModelSource::TfLite,
2503 inputs: vec![TensorInfo {
2504 name: "images".into(),
2505 shape: vec![1, 640, 640, 3],
2506 dtype: DType::Float32,
2507 quantization: None,
2508 }],
2509 outputs: vec![
2510 TensorInfo {
2511 name: "output0".into(),
2512 shape: vec![1, 116, 8400],
2513 dtype: DType::Float32,
2514 quantization: None,
2515 },
2516 TensorInfo {
2517 name: "output1".into(),
2518 shape: vec![1, 32, 160, 160],
2519 dtype: DType::Int8,
2520 quantization: Some(Quantization {
2521 scale: vec![0.02, 0.03],
2522 zero_point: Some(vec![-5, -7]),
2523 axis: Some(1),
2524 dtype: Some(DType::Int8),
2525 }),
2526 },
2527 ],
2528 metadata: synthetic_metadata(80, "segment", "False"),
2529 };
2530 match infer_ultralytics_schema(&s) {
2531 Err(InferError::UnsupportedQuantization(msg)) => {
2532 assert!(
2533 msg.contains("output1"),
2534 "message must name the proto: {msg}"
2535 );
2536 }
2537 other => panic!("expected UnsupportedQuantization, got {other:?}"),
2538 }
2539 }
2540
2541 #[test]
2542 fn infer_integer_output_without_quant_rejected() {
2543 let s = ModelSignals {
2547 source: ModelSource::TfLite,
2548 inputs: vec![TensorInfo {
2549 name: "images".into(),
2550 shape: vec![1, 640, 640, 3],
2551 dtype: DType::Float32,
2552 quantization: None,
2553 }],
2554 outputs: vec![TensorInfo {
2555 name: "output0".into(),
2556 shape: vec![1, 84, 8400],
2557 dtype: DType::Int8,
2558 quantization: None,
2559 }],
2560 metadata: synthetic_metadata(80, "detect", "False"),
2561 };
2562 match infer_ultralytics_schema(&s) {
2563 Err(InferError::UnsupportedLayout(msg)) => {
2564 assert!(
2565 msg.contains("quantization"),
2566 "unexpected UnsupportedLayout message: {msg}"
2567 );
2568 }
2569 other => panic!("expected UnsupportedLayout, got {other:?}"),
2570 }
2571 }
2572
2573 #[test]
2574 fn infer_pre_nms_rejects_non_unit_batch() {
2575 let mut s = signals_from_fixture("yolov8n");
2580 s.outputs[0].shape = vec![2, 84, 8400];
2581 assert!(matches!(
2582 infer_ultralytics_schema(&s),
2583 Err(InferError::UnsupportedLayout(_))
2584 ));
2585 }
2586
2587 #[test]
2588 fn coreml_source_resolves_a_box_convention() {
2589 let s = ModelSignals {
2592 source: ModelSource::CoreMl,
2593 inputs: vec![TensorInfo {
2594 name: "images".into(),
2595 shape: vec![1, 3, 640, 640],
2596 dtype: DType::Float16,
2597 quantization: None,
2598 }],
2599 outputs: vec![TensorInfo {
2600 name: "output0".into(),
2601 shape: vec![1, 84, 8400],
2602 dtype: DType::Float16,
2603 quantization: None,
2604 }],
2605 metadata: synthetic_metadata(80, "detect", "False"),
2606 };
2607 let r = infer_ultralytics_schema(&s)
2608 .expect("CoreML signals must infer, not raise UnknownBoxConvention");
2609 let o = &r.schema.outputs[0];
2610 assert_eq!(
2611 o.normalized,
2612 Some(false),
2613 "Ultralytics CoreML exports trace the raw model, so boxes are \
2614 pixel-space exactly as in the ONNX export"
2615 );
2616 }
2617
2618 #[test]
2619 fn coreml_nms_pipeline_export_is_refused_not_classified() {
2620 let s = ModelSignals {
2634 source: ModelSource::CoreMl,
2635 inputs: vec![TensorInfo {
2636 name: "image".into(),
2637 shape: vec![1, 3, 640, 640],
2638 dtype: DType::Float16,
2639 quantization: None,
2640 }],
2641 outputs: vec![
2642 TensorInfo {
2643 name: "confidence".into(),
2644 shape: vec![],
2645 dtype: DType::Float32,
2646 quantization: None,
2647 },
2648 TensorInfo {
2649 name: "coordinates".into(),
2650 shape: vec![],
2651 dtype: DType::Float32,
2652 quantization: None,
2653 },
2654 ],
2655 metadata: synthetic_metadata(80, "detect", "False"),
2656 };
2657 assert!(matches!(
2658 infer_ultralytics_schema(&s),
2659 Err(InferError::UnsupportedLayout(_))
2660 ));
2661 }
2662}