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 {
1057 DecoderVersion::Yolo26 => None,
1058 _ => Some(NmsMode::ClassAware),
1059 },
1060 decoder_version: Some(version),
1061 };
1062
1063 let version_label = if version == DecoderVersion::Yolo26 {
1064 "YOLO26"
1065 } else {
1066 "YOLOv8/11"
1067 };
1068 let task_label = if is_segment { "segment" } else { "detect" };
1069 let description = format!("Ultralytics {version_label} {task_label}, {nc} classes");
1070
1071 Ok(InferredSchema {
1072 schema,
1073 labels: meta.names,
1074 description,
1075 })
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use super::*;
1081
1082 fn infer_fixture_dir() -> std::path::PathBuf {
1094 std::env::var("EDGEFIRST_TESTDATA_DIR")
1095 .map(std::path::PathBuf::from)
1096 .unwrap_or_else(|_| {
1097 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata")
1098 })
1099 .join("infer")
1100 }
1101
1102 #[test]
1103 fn parse_names_python_dict_repr() {
1104 let s = "{0: 'person', 1: 'bicycle', 2: \"fire hydrant\"}";
1105 assert_eq!(
1106 parse_names(s).unwrap(),
1107 vec!["person", "bicycle", "fire hydrant"]
1108 );
1109 }
1110
1111 #[test]
1112 fn parse_names_json_object() {
1113 let s = r#"{"0": "person", "1": "bicycle"}"#;
1114 assert_eq!(parse_names(s).unwrap(), vec!["person", "bicycle"]);
1115 }
1116
1117 #[test]
1118 fn parse_names_orders_by_index_not_insertion() {
1119 let s = "{1: 'b', 0: 'a'}";
1120 assert_eq!(parse_names(s).unwrap(), vec!["a", "b"]);
1121 }
1122
1123 #[test]
1124 fn parse_names_rejects_garbage() {
1125 assert!(matches!(
1126 parse_names("not a dict"),
1127 Err(InferError::BadNames(_))
1128 ));
1129 }
1130
1131 #[test]
1132 fn parse_names_rejects_non_contiguous_indices() {
1133 match parse_names("{0: 'a', 2: 'b'}") {
1135 Err(InferError::BadNames(msg)) => {
1136 assert!(
1137 msg.contains("non-contiguous"),
1138 "unexpected BadNames message: {msg}"
1139 );
1140 }
1141 other => panic!("expected BadNames, got {other:?}"),
1142 }
1143
1144 match parse_names(r#"{"0": "a", "2": "b"}"#) {
1146 Err(InferError::BadNames(msg)) => {
1147 assert!(
1148 msg.contains("non-contiguous"),
1149 "unexpected BadNames message: {msg}"
1150 );
1151 }
1152 other => panic!("expected BadNames, got {other:?}"),
1153 }
1154 }
1155
1156 #[test]
1157 fn meta_from_onnx_props() {
1158 let mut m = BTreeMap::new();
1159 m.insert("names".into(), "{0: 'person'}".into());
1160 m.insert("task".into(), "detect".into());
1161 let u = UltralyticsMeta::from_metadata(&m).unwrap();
1162 assert_eq!(u.names, vec!["person"]);
1163 assert_eq!(u.task, Some(Task::Detect));
1164 }
1165
1166 #[test]
1167 fn meta_task_pose_rejected() {
1168 let mut m = BTreeMap::new();
1169 m.insert("names".into(), "{0: 'person'}".into());
1170 m.insert("task".into(), "pose".into());
1171 assert!(matches!(
1172 UltralyticsMeta::from_metadata(&m),
1173 Err(InferError::UnsupportedTask(_))
1174 ));
1175 }
1176
1177 #[test]
1178 fn meta_missing_names_is_not_ultralytics() {
1179 assert!(matches!(
1180 UltralyticsMeta::from_metadata(&BTreeMap::new()),
1181 Err(InferError::NotUltralytics(_))
1182 ));
1183 }
1184
1185 #[test]
1186 fn meta_end2end_true_or_false_from_onnx_flat_props() {
1187 let mut m = BTreeMap::new();
1188 m.insert("names".into(), "{0: 'person'}".into());
1189 m.insert("end2end".into(), "True".into());
1190 assert_eq!(
1191 UltralyticsMeta::from_metadata(&m).unwrap().end2end,
1192 Some(true)
1193 );
1194
1195 m.insert("end2end".into(), "False".into());
1196 assert_eq!(
1197 UltralyticsMeta::from_metadata(&m).unwrap().end2end,
1198 Some(false)
1199 );
1200 }
1201
1202 fn load_fixture_metadata(name: &str) -> BTreeMap<String, String> {
1207 let path = infer_fixture_dir().join(format!("{name}.signals.json"));
1208 let content = std::fs::read_to_string(&path)
1209 .unwrap_or_else(|e| panic!("failed to read fixture {}: {e}", path.display()));
1210 let json: serde_json::Value =
1211 serde_json::from_str(&content).expect("fixture is valid JSON");
1212 json["metadata"]
1213 .as_object()
1214 .expect("fixture has a metadata object")
1215 .iter()
1216 .map(|(k, v)| {
1217 (
1218 k.clone(),
1219 v.as_str().expect("metadata value is a string").to_string(),
1220 )
1221 })
1222 .collect()
1223 }
1224
1225 #[test]
1226 fn meta_from_real_tflite_metadata_json_envelope() {
1227 let meta = load_fixture_metadata("yolov8n_float32");
1228 let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1229 assert_eq!(u.names.len(), 80);
1230 assert_eq!(u.task, Some(Task::Detect));
1231 }
1232
1233 #[test]
1234 fn meta_end2end_true_on_real_yolo26_tflite_fixture() {
1235 let meta = load_fixture_metadata("yolo26n_float32");
1236 let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1237 assert_eq!(u.end2end, Some(true));
1238 }
1239
1240 #[test]
1241 fn meta_end2end_false_on_real_yolov8_onnx_fixture() {
1242 let meta = load_fixture_metadata("yolov8n");
1243 let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1244 assert_eq!(u.end2end, Some(false));
1245 }
1246
1247 fn signals_from_fixture(name: &str) -> ModelSignals {
1251 let path = infer_fixture_dir().join(format!("{name}.signals.json"));
1252 let content = std::fs::read_to_string(&path)
1253 .unwrap_or_else(|e| panic!("failed to read fixture {}: {e}", path.display()));
1254 let json: serde_json::Value =
1255 serde_json::from_str(&content).expect("fixture is valid JSON");
1256
1257 let source = match json["source"]
1258 .as_str()
1259 .expect("fixture `source` is a string")
1260 {
1261 "onnx" => ModelSource::Onnx,
1262 "tflite" => ModelSource::TfLite,
1263 "coreml" => ModelSource::CoreMl,
1264 other => panic!("fixture {name}: unknown source `{other}`"),
1265 };
1266
1267 fn parse_dtype(s: &str) -> DType {
1268 match s {
1269 "float32" => DType::Float32,
1270 "float16" => DType::Float16,
1271 "int8" => DType::Int8,
1272 "uint8" => DType::Uint8,
1273 "int16" => DType::Int16,
1274 "uint16" => DType::Uint16,
1275 "int32" => DType::Int32,
1276 "uint32" => DType::Uint32,
1277 other => panic!("unknown dtype `{other}`"),
1278 }
1279 }
1280
1281 fn parse_tensor(v: &serde_json::Value) -> TensorInfo {
1282 TensorInfo {
1283 name: v["name"]
1284 .as_str()
1285 .expect("tensor `name` is a string")
1286 .to_string(),
1287 shape: v["shape"]
1288 .as_array()
1289 .expect("tensor `shape` is an array")
1290 .iter()
1291 .map(|d| d.as_u64().expect("shape dim is a number") as usize)
1292 .collect(),
1293 dtype: parse_dtype(v["dtype"].as_str().expect("tensor `dtype` is a string")),
1294 quantization: None,
1298 }
1299 }
1300
1301 let inputs = json["inputs"]
1302 .as_array()
1303 .expect("fixture `inputs` is an array")
1304 .iter()
1305 .map(parse_tensor)
1306 .collect();
1307 let outputs = json["outputs"]
1308 .as_array()
1309 .expect("fixture `outputs` is an array")
1310 .iter()
1311 .map(parse_tensor)
1312 .collect();
1313 let metadata = json["metadata"]
1314 .as_object()
1315 .expect("fixture `metadata` is an object")
1316 .iter()
1317 .map(|(k, v)| {
1318 (
1319 k.clone(),
1320 v.as_str().expect("metadata value is a string").to_string(),
1321 )
1322 })
1323 .collect();
1324
1325 ModelSignals {
1326 source,
1327 inputs,
1328 outputs,
1329 metadata,
1330 }
1331 }
1332
1333 fn synthetic_metadata(nc: usize, task: &str, end2end: &str) -> BTreeMap<String, String> {
1337 let names = (0..nc)
1338 .map(|i| format!("{i}: 'c{i}'"))
1339 .collect::<Vec<_>>()
1340 .join(", ");
1341 let mut m = BTreeMap::new();
1342 m.insert("names".into(), format!("{{{names}}}"));
1343 m.insert("task".into(), task.into());
1344 m.insert("end2end".into(), end2end.into());
1345 m
1346 }
1347
1348 #[test]
1349 fn infer_transposed_det_layout_classified() {
1350 let s = ModelSignals {
1356 source: ModelSource::TfLite,
1357 inputs: vec![TensorInfo {
1358 name: "images".into(),
1359 shape: vec![1, 640, 640, 3],
1360 dtype: DType::Float32,
1361 quantization: None,
1362 }],
1363 outputs: vec![TensorInfo {
1364 name: "output0".into(),
1365 shape: vec![1, 8400, 84],
1366 dtype: DType::Float32,
1367 quantization: None,
1368 }],
1369 metadata: synthetic_metadata(80, "detect", "False"),
1370 };
1371 let r = infer_ultralytics_schema(&s).unwrap();
1372 let o = &r.schema.outputs[0];
1373 assert_eq!(
1374 o.dshape,
1375 vec![
1376 (DimName::Batch, 1),
1377 (DimName::NumBoxes, 8400),
1378 (DimName::NumFeatures, 84),
1379 ]
1380 );
1381 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1382 }
1383
1384 #[test]
1385 fn infer_yolo26n_onnx_end_to_end() {
1386 let s = signals_from_fixture("yolo26n");
1387 let r = infer_ultralytics_schema(&s).unwrap();
1388 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1389 assert_eq!(r.schema.outputs[0].shape[2], 6);
1390 }
1391
1392 #[test]
1393 fn infer_class_count_mismatch_rejected() {
1394 let mut s = signals_from_fixture("yolov8n");
1395 s.metadata.insert("names".into(), "{0: 'only-one'}".into()); assert!(matches!(
1397 infer_ultralytics_schema(&s),
1398 Err(InferError::ClassCountMismatch {
1399 expected: 1,
1400 found: 80
1401 })
1402 ));
1403 }
1404
1405 #[test]
1406 fn infer_seg_class_count_mismatch_rejected() {
1407 let mut s = signals_from_fixture("yolov8n-seg");
1416 s.metadata.insert("names".into(), "{0: 'only-one'}".into()); assert!(matches!(
1418 infer_ultralytics_schema(&s),
1419 Err(InferError::ClassCountMismatch {
1420 expected: 1,
1421 found: 80
1422 })
1423 ));
1424 }
1425
1426 #[test]
1427 fn infer_end2end_flag_shape_conflict_rejected() {
1428 let mut s = signals_from_fixture("yolov8n");
1433 s.metadata.insert("end2end".into(), "True".into());
1434 assert!(matches!(
1435 infer_ultralytics_schema(&s),
1436 Err(InferError::UnsupportedLayout(_))
1437 ));
1438 }
1439
1440 #[test]
1441 fn infer_yolov8n_seg_onnx() {
1442 let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n-seg")).unwrap();
1443 assert_eq!(r.schema.outputs.len(), 2);
1444 let proto = r
1445 .schema
1446 .outputs
1447 .iter()
1448 .find(|o| o.type_ == Some(LogicalType::Protos))
1449 .unwrap();
1450 assert_eq!(proto.dshape[1].0, DimName::NumProtos); let det = r
1452 .schema
1453 .outputs
1454 .iter()
1455 .find(|o| o.type_ == Some(LogicalType::Detection))
1456 .unwrap();
1457 assert!(det.shape.contains(&116)); }
1459
1460 #[test]
1461 fn infer_yolov8n_seg_tflite_proto_nchw() {
1462 let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n-seg_float32")).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[1].0, DimName::NumProtos); assert_eq!(proto.dshape[1].1, 32);
1477 }
1478
1479 #[test]
1480 fn infer_yolo26n_seg_end_to_end() {
1481 let r = infer_ultralytics_schema(&signals_from_fixture("yolo26n-seg")).unwrap();
1482 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1483 assert_eq!(r.schema.outputs.len(), 2);
1484 }
1485
1486 #[test]
1487 fn infer_nhwc_proto_layout_classified() {
1488 let s = ModelSignals {
1492 source: ModelSource::TfLite,
1493 inputs: vec![TensorInfo {
1494 name: "images".into(),
1495 shape: vec![1, 640, 640, 3],
1496 dtype: DType::Float32,
1497 quantization: None,
1498 }],
1499 outputs: vec![
1500 TensorInfo {
1501 name: "output0".into(),
1502 shape: vec![1, 116, 8400],
1503 dtype: DType::Float32,
1504 quantization: None,
1505 },
1506 TensorInfo {
1507 name: "output1".into(),
1508 shape: vec![1, 160, 160, 32],
1509 dtype: DType::Float32,
1510 quantization: None,
1511 },
1512 ],
1513 metadata: synthetic_metadata(80, "segment", "False"),
1514 };
1515 let r = infer_ultralytics_schema(&s).unwrap();
1516 let proto = r
1517 .schema
1518 .outputs
1519 .iter()
1520 .find(|o| o.type_ == Some(LogicalType::Protos))
1521 .unwrap();
1522 assert_eq!(proto.dshape[3].0, DimName::NumProtos); assert_eq!(proto.dshape[3].1, 32);
1524 }
1525
1526 #[test]
1530 fn infer_proto_output_carries_its_name_decoder_dtype_and_quantization() {
1531 let quant = Quantization {
1532 scale: vec![0.0125],
1533 zero_point: Some(vec![-7]),
1534 axis: None,
1535 dtype: Some(DType::Int8),
1536 };
1537 let s = ModelSignals {
1538 source: ModelSource::TfLite,
1539 inputs: vec![TensorInfo {
1540 name: "images".into(),
1541 shape: vec![1, 640, 640, 3],
1542 dtype: DType::Int8,
1543 quantization: None,
1544 }],
1545 outputs: vec![
1546 TensorInfo {
1547 name: "output0".into(),
1548 shape: vec![1, 116, 8400],
1549 dtype: DType::Int8,
1550 quantization: Some(Quantization {
1551 scale: vec![0.003_921_6],
1552 zero_point: Some(vec![0]),
1553 axis: None,
1554 dtype: Some(DType::Int8),
1555 }),
1556 },
1557 TensorInfo {
1558 name: "protos".into(),
1559 shape: vec![1, 160, 160, 32],
1560 dtype: DType::Int8,
1561 quantization: Some(quant.clone()),
1562 },
1563 ],
1564 metadata: synthetic_metadata(80, "segment", "False"),
1565 };
1566 let r = infer_ultralytics_schema(&s).unwrap();
1567 let proto = r
1568 .schema
1569 .outputs
1570 .iter()
1571 .find(|o| o.type_ == Some(LogicalType::Protos))
1572 .unwrap();
1573 assert_eq!(proto.name.as_deref(), Some("protos"));
1574 assert_eq!(proto.decoder, Some(DecoderKind::Ultralytics));
1575 assert_eq!(proto.dtype, Some(DType::Int8));
1576 assert_eq!(proto.quantization.as_ref(), Some(&quant));
1577 }
1578
1579 #[test]
1583 fn infer_treats_a_proto_tensor_without_a_task_as_segmentation() {
1584 let mut metadata = synthetic_metadata(80, "segment", "False");
1585 metadata.remove("task");
1586 let s = ModelSignals {
1587 source: ModelSource::TfLite,
1588 inputs: vec![TensorInfo {
1589 name: "images".into(),
1590 shape: vec![1, 640, 640, 3],
1591 dtype: DType::Float32,
1592 quantization: None,
1593 }],
1594 outputs: vec![
1595 TensorInfo {
1596 name: "output0".into(),
1597 shape: vec![1, 116, 8400],
1598 dtype: DType::Float32,
1599 quantization: None,
1600 },
1601 TensorInfo {
1602 name: "output1".into(),
1603 shape: vec![1, 160, 160, 32],
1604 dtype: DType::Float32,
1605 quantization: None,
1606 },
1607 ],
1608 metadata,
1609 };
1610 let r = infer_ultralytics_schema(&s).unwrap();
1611 assert!(
1612 r.schema
1613 .outputs
1614 .iter()
1615 .any(|o| o.type_ == Some(LogicalType::Protos)),
1616 "proto tensor present but no protos output was emitted"
1617 );
1618 assert!(r.description.contains("segment"), "{}", r.description);
1621 }
1622
1623 fn proto_tensor(shape: Vec<usize>) -> TensorInfo {
1624 TensorInfo {
1625 name: "protos".into(),
1626 shape,
1627 dtype: DType::Float32,
1628 quantization: None,
1629 }
1630 }
1631
1632 #[test]
1635 fn classify_proto_rejects_wrong_rank_or_batch() {
1636 for shape in [vec![1, 32, 160], vec![2, 32, 160, 160], vec![1, 160, 160]] {
1637 let err = classify_proto(&proto_tensor(shape.clone()), 640, 640).unwrap_err();
1638 assert!(
1639 matches!(err, InferError::UnsupportedLayout(_)),
1640 "shape {shape:?} gave {err:?}"
1641 );
1642 }
1643 }
1644
1645 #[test]
1648 fn classify_proto_rejects_a_shape_matching_only_one_spatial_dim() {
1649 let err = classify_proto(&proto_tensor(vec![1, 160, 7, 9]), 640, 640).unwrap_err();
1651 assert!(matches!(err, InferError::UnsupportedLayout(_)), "{err:?}");
1652
1653 let (k, dshape) = classify_proto(&proto_tensor(vec![1, 160, 160, 32]), 640, 640).unwrap();
1655 assert_eq!(k, 32);
1656 assert_eq!(dshape[3], (DimName::NumProtos, 32));
1657 }
1658
1659 #[test]
1662 fn unquote_requires_a_matching_pair_of_quotes() {
1663 assert_eq!(unquote("'abc'").as_deref(), Some("abc"));
1664 assert_eq!(unquote("\"abc\"").as_deref(), Some("abc"));
1665 assert_eq!(unquote("'abc"), None);
1666 assert_eq!(unquote("abc'"), None);
1667 assert_eq!(unquote("\"abc"), None);
1668 assert_eq!(unquote("abc\""), None);
1669 assert_eq!(unquote("abc"), None);
1670 }
1671
1672 #[test]
1676 fn split_top_level_commas_keeps_quoted_commas_together() {
1677 let parts = split_top_level_commas("a='x,y', b=2");
1678 assert_eq!(parts, vec!["a='x,y'".to_string(), " b=2".to_string()]);
1679
1680 let parts = split_top_level_commas("a=\"x,y\",b=\"p,q\"");
1681 assert_eq!(
1682 parts,
1683 vec!["a=\"x,y\"".to_string(), "b=\"p,q\"".to_string()]
1684 );
1685 }
1686
1687 #[test]
1690 fn infer_error_display_states_the_reason() {
1691 let err = InferError::NotUltralytics("no names key".into());
1692 let shown = err.to_string();
1693 assert!(shown.contains("no names key"), "{shown}");
1694 assert!(shown.contains("Ultralytics"), "{shown}");
1695
1696 let err = InferError::UnsupportedLayout("rank-3 proto".into());
1697 assert!(err.to_string().contains("rank-3 proto"));
1698 }
1699
1700 #[test]
1701 fn infer_segment_task_without_proto_rejected() {
1702 let s = ModelSignals {
1703 source: ModelSource::Onnx,
1704 inputs: vec![TensorInfo {
1705 name: "images".into(),
1706 shape: vec![1, 3, 640, 640],
1707 dtype: DType::Float32,
1708 quantization: None,
1709 }],
1710 outputs: vec![TensorInfo {
1711 name: "output0".into(),
1712 shape: vec![1, 84, 8400],
1713 dtype: DType::Float32,
1714 quantization: None,
1715 }],
1716 metadata: synthetic_metadata(80, "segment", "False"),
1717 };
1718 assert!(matches!(
1719 infer_ultralytics_schema(&s),
1720 Err(InferError::UnsupportedLayout(_))
1721 ));
1722 }
1723
1724 #[test]
1725 fn infer_detect_task_with_proto_rejected() {
1726 let s = ModelSignals {
1727 source: ModelSource::Onnx,
1728 inputs: vec![TensorInfo {
1729 name: "images".into(),
1730 shape: vec![1, 3, 640, 640],
1731 dtype: DType::Float32,
1732 quantization: None,
1733 }],
1734 outputs: vec![
1735 TensorInfo {
1736 name: "output0".into(),
1737 shape: vec![1, 84, 8400],
1738 dtype: DType::Float32,
1739 quantization: None,
1740 },
1741 TensorInfo {
1742 name: "output1".into(),
1743 shape: vec![1, 32, 160, 160],
1744 dtype: DType::Float32,
1745 quantization: None,
1746 },
1747 ],
1748 metadata: synthetic_metadata(80, "detect", "False"),
1749 };
1750 assert!(matches!(
1751 infer_ultralytics_schema(&s),
1752 Err(InferError::UnsupportedLayout(_))
1753 ));
1754 }
1755
1756 #[test]
1757 fn infer_int8_tflite_classifies_like_float32() {
1758 let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n_int8")).unwrap();
1764 let det = &r.schema.outputs[0];
1765 assert_eq!(det.type_, Some(LogicalType::Detection));
1766 assert_eq!(det.dtype, Some(DType::Float32));
1767 assert_eq!(det.quantization, None);
1768 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1769 assert_eq!(det.normalized, Some(true));
1770 }
1771
1772 #[test]
1773 fn infer_quantized_boundary_carries_quantization() {
1774 let quant = Quantization {
1779 scale: vec![0.02],
1780 zero_point: Some(vec![-5]),
1781 axis: None,
1782 dtype: Some(DType::Int8),
1783 };
1784 let s = ModelSignals {
1785 source: ModelSource::TfLite,
1786 inputs: vec![TensorInfo {
1787 name: "images".into(),
1788 shape: vec![1, 640, 640, 3],
1789 dtype: DType::Float32,
1790 quantization: None,
1791 }],
1792 outputs: vec![TensorInfo {
1793 name: "output0".into(),
1794 shape: vec![1, 84, 8400],
1795 dtype: DType::Int8,
1796 quantization: Some(quant.clone()),
1797 }],
1798 metadata: synthetic_metadata(80, "detect", "False"),
1799 };
1800 let r = infer_ultralytics_schema(&s).unwrap();
1801 let det = &r.schema.outputs[0];
1802 assert_eq!(det.dtype, Some(DType::Int8));
1803 assert_eq!(det.quantization, Some(quant));
1804 }
1805
1806 #[test]
1807 fn infer_rejects_batched_input() {
1808 let s = ModelSignals {
1813 source: ModelSource::Onnx,
1814 inputs: vec![TensorInfo {
1815 name: "images".into(),
1816 shape: vec![4, 3, 640, 640],
1817 dtype: DType::Float32,
1818 quantization: None,
1819 }],
1820 outputs: vec![TensorInfo {
1821 name: "output0".into(),
1822 shape: vec![1, 84, 8400],
1823 dtype: DType::Float32,
1824 quantization: None,
1825 }],
1826 metadata: synthetic_metadata(80, "detect", "False"),
1827 };
1828 match infer_ultralytics_schema(&s) {
1829 Err(InferError::UnsupportedLayout(msg)) => {
1830 assert!(msg.contains("batch 4"), "unexpected message: {msg}");
1831 }
1832 other => panic!("expected UnsupportedLayout, got {other:?}"),
1833 }
1834 }
1835
1836 #[test]
1837 fn infer_rejects_input_without_a_channel_dim() {
1838 let s = ModelSignals {
1839 source: ModelSource::Onnx,
1840 inputs: vec![TensorInfo {
1841 name: "images".into(),
1842 shape: vec![1, 4, 640, 640],
1843 dtype: DType::Float32,
1844 quantization: None,
1845 }],
1846 outputs: vec![TensorInfo {
1847 name: "output0".into(),
1848 shape: vec![1, 84, 8400],
1849 dtype: DType::Float32,
1850 quantization: None,
1851 }],
1852 metadata: synthetic_metadata(80, "detect", "False"),
1853 };
1854 match infer_ultralytics_schema(&s) {
1855 Err(InferError::UnsupportedLayout(msg)) => {
1856 assert!(msg.contains("channel dimension"), "unexpected: {msg}");
1857 }
1858 other => panic!("expected UnsupportedLayout, got {other:?}"),
1859 }
1860 }
1861
1862 #[test]
1863 fn infer_rejects_model_with_no_rank4_input() {
1864 let s = ModelSignals {
1865 source: ModelSource::Onnx,
1866 inputs: vec![TensorInfo {
1867 name: "images".into(),
1868 shape: vec![1, 3],
1869 dtype: DType::Float32,
1870 quantization: None,
1871 }],
1872 outputs: vec![TensorInfo {
1873 name: "output0".into(),
1874 shape: vec![1, 84, 8400],
1875 dtype: DType::Float32,
1876 quantization: None,
1877 }],
1878 metadata: synthetic_metadata(80, "detect", "False"),
1879 };
1880 match infer_ultralytics_schema(&s) {
1881 Err(InferError::UnsupportedLayout(msg)) => {
1882 assert!(msg.contains("no rank-4 model input"), "unexpected: {msg}");
1883 }
1884 other => panic!("expected UnsupportedLayout, got {other:?}"),
1885 }
1886 }
1887
1888 fn det_signals(nc: usize, out_shape: Vec<usize>, end2end: &str) -> ModelSignals {
1890 ModelSignals {
1891 source: ModelSource::Onnx,
1892 inputs: vec![TensorInfo {
1893 name: "images".into(),
1894 shape: vec![1, 3, 640, 640],
1895 dtype: DType::Float32,
1896 quantization: None,
1897 }],
1898 outputs: vec![TensorInfo {
1899 name: "output0".into(),
1900 shape: out_shape,
1901 dtype: DType::Float32,
1902 quantization: None,
1903 }],
1904 metadata: synthetic_metadata(nc, "detect", end2end),
1905 }
1906 }
1907
1908 #[test]
1909 fn infer_end_to_end_accepts_any_max_det_up_to_the_anchor_count() {
1910 for n in [1, 300, 2000, 8400] {
1922 let r = infer_ultralytics_schema(&det_signals(80, vec![1, n, 6], "True"))
1923 .unwrap_or_else(|e| panic!("max_det={n} must infer: {e}"));
1924 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1925 assert_eq!(r.schema.outputs[0].type_, Some(LogicalType::Detections));
1926 assert_eq!(
1927 r.schema.outputs[0].dshape,
1928 vec![
1929 (DimName::Batch, 1),
1930 (DimName::NumBoxes, n),
1931 (DimName::NumFeatures, 6),
1932 ],
1933 "max_det={n}"
1934 );
1935 assert_eq!(r.schema.nms, None, "max_det={n}");
1937 }
1938 }
1939
1940 #[test]
1941 fn infer_end_to_end_rejects_more_rows_than_anchors() {
1942 match infer_ultralytics_schema(&det_signals(80, vec![1, 8401, 6], "True")) {
1946 Err(InferError::UnsupportedLayout(msg)) => {
1947 assert!(msg.contains("N<=8400"), "unexpected message: {msg}");
1948 }
1949 other => panic!("expected UnsupportedLayout, got {other:?}"),
1950 }
1951 }
1952
1953 #[test]
1954 fn infer_legacy_nms_model_is_unaffected_by_max_det() {
1955 let r = infer_ultralytics_schema(&det_signals(80, vec![1, 84, 8400], "False")).unwrap();
1961 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1962 assert_eq!(r.schema.outputs[0].type_, Some(LogicalType::Detection));
1963 assert_eq!(
1964 r.schema.outputs[0].dshape,
1965 vec![
1966 (DimName::Batch, 1),
1967 (DimName::NumFeatures, 84),
1968 (DimName::NumBoxes, 8400),
1969 ]
1970 );
1971 assert_eq!(r.schema.nms, Some(NmsMode::ClassAware));
1975
1976 let t = infer_ultralytics_schema(&det_signals(80, vec![1, 8400, 84], "False")).unwrap();
1979 assert_eq!(t.schema.decoder_version, Some(DecoderVersion::Yolov8));
1980 assert_eq!(
1981 t.schema.outputs[0].dshape,
1982 vec![
1983 (DimName::Batch, 1),
1984 (DimName::NumBoxes, 8400),
1985 (DimName::NumFeatures, 84),
1986 ]
1987 );
1988 }
1989
1990 #[test]
1991 fn infer_pre_nms_wins_when_a_shape_reads_as_both() {
1992 let mut m = synthetic_metadata(2, "detect", "False");
1997 m.remove("end2end");
1998 let s = ModelSignals {
1999 metadata: m,
2000 ..det_signals(2, vec![1, 8400, 6], "False")
2001 };
2002 let r = infer_ultralytics_schema(&s).unwrap();
2003 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
2004 }
2005
2006 #[test]
2007 fn meta_empty_names_rejected() {
2008 let mut m = BTreeMap::new();
2011 m.insert("names".into(), "{}".into());
2012 match UltralyticsMeta::from_metadata(&m) {
2013 Err(InferError::BadNames(msg)) => assert!(msg.contains("empty"), "{msg}"),
2014 other => panic!("expected BadNames, got {other:?}"),
2015 }
2016 }
2017
2018 #[test]
2019 fn meta_malformed_end2end_is_an_error_not_an_absent_key() {
2020 let mut m = synthetic_metadata(80, "detect", "False");
2024 m.insert("end2end".into(), "yes".into());
2025 match UltralyticsMeta::from_metadata(&m) {
2026 Err(InferError::BadMetadata(msg)) => assert!(msg.contains("end2end"), "{msg}"),
2027 other => panic!("expected BadMetadata, got {other:?}"),
2028 }
2029
2030 m.remove("end2end");
2032 assert_eq!(UltralyticsMeta::from_metadata(&m).unwrap().end2end, None);
2033 }
2034
2035 #[test]
2036 fn meta_foreign_vendor_rejected_but_missing_provenance_allowed() {
2037 let mut m = synthetic_metadata(80, "detect", "False");
2041 m.insert("author".into(), "SomeOtherVendor".into());
2042 assert!(matches!(
2043 UltralyticsMeta::from_metadata(&m),
2044 Err(InferError::NotUltralytics(_))
2045 ));
2046
2047 m.insert("author".into(), "Ultralytics".into());
2049 m.insert("docs".into(), "https://docs.ultralytics.com".into());
2050 assert!(UltralyticsMeta::from_metadata(&m).is_ok());
2051
2052 m.remove("author");
2055 m.remove("docs");
2056 assert!(UltralyticsMeta::from_metadata(&m).is_ok());
2057 }
2058
2059 #[test]
2060 fn infer_zero_row_end_to_end_rejected() {
2061 assert!(matches!(
2064 infer_ultralytics_schema(&det_signals(80, vec![1, 0, 6], "True")),
2065 Err(InferError::UnsupportedLayout(_))
2066 ));
2067 }
2068
2069 #[test]
2070 fn infer_duplicate_matching_outputs_are_ambiguous() {
2071 let input = TensorInfo {
2072 name: "images".into(),
2073 shape: vec![1, 3, 640, 640],
2074 dtype: DType::Float32,
2075 quantization: None,
2076 };
2077 let t = |name: &str, shape: Vec<usize>| TensorInfo {
2078 name: name.into(),
2079 shape,
2080 dtype: DType::Float32,
2081 quantization: None,
2082 };
2083
2084 let pre = ModelSignals {
2087 source: ModelSource::Onnx,
2088 inputs: vec![input.clone()],
2089 outputs: vec![t("a", vec![1, 84, 8400]), t("b", vec![1, 84, 8400])],
2090 metadata: synthetic_metadata(80, "detect", "False"),
2091 };
2092 match infer_ultralytics_schema(&pre) {
2093 Err(InferError::AmbiguousLayout(msg)) => {
2094 assert!(msg.contains("`a`") && msg.contains("`b`"), "{msg}");
2095 }
2096 other => panic!("expected AmbiguousLayout, got {other:?}"),
2097 }
2098
2099 let e2e = ModelSignals {
2101 source: ModelSource::Onnx,
2102 inputs: vec![input],
2103 outputs: vec![t("a", vec![1, 300, 6]), t("b", vec![1, 200, 6])],
2104 metadata: synthetic_metadata(80, "detect", "True"),
2105 };
2106 assert!(matches!(
2107 infer_ultralytics_schema(&e2e),
2108 Err(InferError::AmbiguousLayout(_))
2109 ));
2110 }
2111
2112 #[test]
2113 fn infer_malformed_per_tensor_quantization_rejected() {
2114 let build = |q: Quantization| ModelSignals {
2115 source: ModelSource::TfLite,
2116 inputs: vec![TensorInfo {
2117 name: "images".into(),
2118 shape: vec![1, 640, 640, 3],
2119 dtype: DType::Float32,
2120 quantization: None,
2121 }],
2122 outputs: vec![TensorInfo {
2123 name: "output0".into(),
2124 shape: vec![1, 84, 8400],
2125 dtype: DType::Int8,
2126 quantization: Some(q),
2127 }],
2128 metadata: synthetic_metadata(80, "detect", "False"),
2129 };
2130
2131 match infer_ultralytics_schema(&build(Quantization {
2134 scale: vec![],
2135 zero_point: Some(vec![]),
2136 axis: None,
2137 dtype: Some(DType::Int8),
2138 })) {
2139 Err(InferError::UnsupportedQuantization(msg)) => {
2140 assert!(msg.contains("no scale"), "{msg}");
2141 }
2142 other => panic!("expected UnsupportedQuantization, got {other:?}"),
2143 }
2144
2145 match infer_ultralytics_schema(&build(Quantization {
2148 scale: vec![0.02],
2149 zero_point: Some(vec![-5, -7]),
2150 axis: None,
2151 dtype: Some(DType::Int8),
2152 })) {
2153 Err(InferError::UnsupportedQuantization(msg)) => {
2154 assert!(msg.contains("zero point"), "{msg}");
2155 }
2156 other => panic!("expected UnsupportedQuantization, got {other:?}"),
2157 }
2158
2159 assert!(infer_ultralytics_schema(&build(Quantization {
2161 scale: vec![0.02],
2162 zero_point: None,
2163 axis: None,
2164 dtype: Some(DType::Int8),
2165 }))
2166 .is_ok());
2167 }
2168
2169 #[test]
2170 fn infer_pre_nms_is_independent_of_output_order() {
2171 let det = TensorInfo {
2177 name: "output0".into(),
2178 shape: vec![1, 84, 8400],
2179 dtype: DType::Float32,
2180 quantization: None,
2181 };
2182 let aux = TensorInfo {
2183 name: "boxes".into(),
2184 shape: vec![1, 4, 8400],
2185 dtype: DType::Float32,
2186 quantization: None,
2187 };
2188 let build = |outputs: Vec<TensorInfo>| ModelSignals {
2189 source: ModelSource::Onnx,
2190 inputs: vec![TensorInfo {
2191 name: "images".into(),
2192 shape: vec![1, 3, 640, 640],
2193 dtype: DType::Float32,
2194 quantization: None,
2195 }],
2196 outputs,
2197 metadata: synthetic_metadata(80, "detect", "False"),
2198 };
2199
2200 let det_first = infer_ultralytics_schema(&build(vec![det.clone(), aux.clone()]))
2201 .expect("detection tensor first must classify");
2202 let aux_first = infer_ultralytics_schema(&build(vec![aux, det]))
2203 .expect("an auxiliary anchor-shaped tensor first must not shadow the detection tensor");
2204 assert_eq!(det_first.schema.outputs[0].name, Some("output0".into()));
2205 assert_eq!(aux_first.schema.outputs[0].name, Some("output0".into()));
2206 assert_eq!(det_first.schema, aux_first.schema);
2207 }
2208
2209 #[test]
2210 fn infer_class_count_mismatch_survives_a_full_scan() {
2211 let s = ModelSignals {
2214 source: ModelSource::Onnx,
2215 inputs: vec![TensorInfo {
2216 name: "images".into(),
2217 shape: vec![1, 3, 640, 640],
2218 dtype: DType::Float32,
2219 quantization: None,
2220 }],
2221 outputs: vec![TensorInfo {
2222 name: "output0".into(),
2223 shape: vec![1, 84, 8400],
2224 dtype: DType::Float32,
2225 quantization: None,
2226 }],
2227 metadata: synthetic_metadata(10, "detect", "False"),
2229 };
2230 assert!(matches!(
2231 infer_ultralytics_schema(&s),
2232 Err(InferError::ClassCountMismatch {
2233 expected: 10,
2234 found: 80
2235 })
2236 ));
2237 }
2238
2239 struct FixtureExpectation {
2249 name: &'static str,
2250 version: DecoderVersion,
2251 det_type: LogicalType,
2252 det_dshape: &'static [(DimName, usize)],
2254 normalized: bool,
2255 nms: Option<NmsMode>,
2256 proto: Option<(usize, usize, usize)>,
2258 }
2259
2260 const A: usize = 8400; const NC: usize = 80;
2262
2263 fn fixture_expectations() -> Vec<FixtureExpectation> {
2264 use DimName::{Batch, NumBoxes, NumFeatures};
2265 const DET: &[(DimName, usize)] = &[(Batch, 1), (NumFeatures, 4 + NC), (NumBoxes, A)];
2269 const SEG: &[(DimName, usize)] = &[(Batch, 1), (NumFeatures, 4 + NC + 32), (NumBoxes, A)];
2270 const E2E: &[(DimName, usize)] = &[(Batch, 1), (NumBoxes, 300), (NumFeatures, 6)];
2272 const E2E_SEG: &[(DimName, usize)] = &[(Batch, 1), (NumBoxes, 300), (NumFeatures, 6 + 32)];
2273
2274 let pre_nms = |name, det_dshape, normalized, proto| FixtureExpectation {
2275 name,
2276 version: DecoderVersion::Yolov8,
2277 det_type: LogicalType::Detection,
2278 det_dshape,
2279 normalized,
2280 nms: Some(NmsMode::ClassAware),
2281 proto,
2282 };
2283 let e2e = |name, det_dshape, normalized, proto| FixtureExpectation {
2284 name,
2285 version: DecoderVersion::Yolo26,
2286 det_type: LogicalType::Detections,
2287 det_dshape,
2288 normalized,
2289 nms: None,
2291 proto,
2292 };
2293 const P: Option<(usize, usize, usize)> = Some((32, 160, 160));
2296 vec![
2297 pre_nms("yolov8n", DET, false, None),
2298 pre_nms("yolo11n", DET, false, None),
2299 pre_nms("yolov8n-seg", SEG, false, P),
2300 pre_nms("yolo11n-seg", SEG, false, P),
2301 pre_nms("yolov8n_float32", DET, true, None),
2302 pre_nms("yolov8n_int8", DET, true, None),
2303 pre_nms("yolov8n-seg_float32", SEG, true, P),
2304 pre_nms("yolov8n-seg_int8", SEG, true, P),
2305 e2e("yolo26n", E2E, false, None),
2306 e2e("yolo26n-seg", E2E_SEG, false, P),
2307 e2e("yolo26n_float32", E2E, true, None),
2308 pre_nms("yolo26n_coreml", DET, false, None),
2316 ]
2317 }
2318
2319 #[test]
2320 fn every_captured_fixture_infers_the_expected_schema() {
2321 for want in fixture_expectations() {
2322 let name = want.name;
2323 let r = infer_ultralytics_schema(&signals_from_fixture(name))
2324 .unwrap_or_else(|e| panic!("{name}: inference failed: {e}"));
2325
2326 assert_eq!(r.labels.len(), NC, "{name}: class count");
2327 assert_eq!(
2328 r.labels[0], "person",
2329 "{name}: labels must be index-ordered"
2330 );
2331 assert_eq!(r.labels[NC - 1], "toothbrush", "{name}: labels index order");
2332 assert_eq!(
2333 r.schema.decoder_version,
2334 Some(want.version),
2335 "{name}: version"
2336 );
2337 assert_eq!(r.schema.nms, want.nms, "{name}: nms mode");
2338
2339 let det = &r.schema.outputs[0];
2340 assert_eq!(det.type_, Some(want.det_type), "{name}: detection type");
2341 assert_eq!(det.dshape, want.det_dshape, "{name}: detection dshape");
2342 assert_eq!(det.normalized, Some(want.normalized), "{name}: normalized");
2343 assert_eq!(
2344 det.decoder,
2345 Some(DecoderKind::Ultralytics),
2346 "{name}: decoder"
2347 );
2348 assert_eq!(det.encoding, Some(BoxEncoding::Direct), "{name}: encoding");
2349 assert_eq!(
2350 det.score_format,
2351 Some(ScoreFormat::PerClass),
2352 "{name}: scores"
2353 );
2354
2355 match want.proto {
2356 None => {
2357 assert_eq!(r.schema.outputs.len(), 1, "{name}: detection-only");
2358 }
2359 Some((k, ph, pw)) => {
2360 assert_eq!(r.schema.outputs.len(), 2, "{name}: detection + proto");
2361 let proto = &r.schema.outputs[1];
2362 assert_eq!(proto.type_, Some(LogicalType::Protos), "{name}: proto type");
2363 assert_eq!(
2364 proto.dshape,
2365 vec![
2366 (DimName::Batch, 1),
2367 (DimName::NumProtos, k),
2368 (DimName::Height, ph),
2369 (DimName::Width, pw),
2370 ],
2371 "{name}: proto dshape"
2372 );
2373 }
2374 }
2375
2376 let task = if want.proto.is_some() {
2377 "segment"
2378 } else {
2379 "detect"
2380 };
2381 assert!(
2382 r.description.contains(task) && r.description.contains("Ultralytics"),
2383 "{name}: description was `{}`",
2384 r.description
2385 );
2386 }
2387 }
2388
2389 #[test]
2390 fn every_fixture_is_asserted() {
2391 let dir = infer_fixture_dir();
2395 let mut on_disk: Vec<String> = std::fs::read_dir(&dir)
2396 .unwrap_or_else(|e| panic!("{} exists: {e}", dir.display()))
2397 .filter_map(|e| {
2398 let name = e.ok()?.file_name().into_string().ok()?;
2399 name.strip_suffix(".signals.json").map(str::to_string)
2400 })
2401 .collect();
2402 on_disk.sort();
2403
2404 let mut asserted: Vec<String> = fixture_expectations()
2405 .iter()
2406 .map(|f| f.name.to_string())
2407 .collect();
2408 asserted.sort();
2409
2410 assert_eq!(
2411 on_disk, asserted,
2412 "every *.signals.json fixture must appear in fixture_expectations()"
2413 );
2414 }
2415
2416 #[test]
2417 fn infer_other_source_refuses_rather_than_guessing_normalization() {
2418 let s = ModelSignals {
2425 source: ModelSource::Other,
2426 inputs: vec![TensorInfo {
2427 name: "images".into(),
2428 shape: vec![1, 3, 640, 640],
2429 dtype: DType::Float32,
2430 quantization: None,
2431 }],
2432 outputs: vec![TensorInfo {
2433 name: "output0".into(),
2434 shape: vec![1, 84, 8400],
2435 dtype: DType::Float32,
2436 quantization: None,
2437 }],
2438 metadata: synthetic_metadata(80, "detect", "False"),
2439 };
2440 assert!(matches!(
2441 infer_ultralytics_schema(&s),
2442 Err(InferError::UnknownBoxConvention)
2443 ));
2444
2445 let ok = ModelSignals {
2448 source: ModelSource::Onnx,
2449 ..s
2450 };
2451 assert_eq!(
2452 infer_ultralytics_schema(&ok).unwrap().schema.outputs[0].normalized,
2453 Some(false)
2454 );
2455 }
2456
2457 #[test]
2458 fn infer_per_channel_quantization_rejected() {
2459 let s = ModelSignals {
2468 source: ModelSource::TfLite,
2469 inputs: vec![TensorInfo {
2470 name: "images".into(),
2471 shape: vec![1, 640, 640, 3],
2472 dtype: DType::Float32,
2473 quantization: None,
2474 }],
2475 outputs: vec![TensorInfo {
2476 name: "output0".into(),
2477 shape: vec![1, 84, 8400],
2478 dtype: DType::Int8,
2479 quantization: Some(Quantization {
2480 scale: vec![0.02, 0.03],
2481 zero_point: Some(vec![-5, -7]),
2482 axis: Some(1),
2483 dtype: Some(DType::Int8),
2484 }),
2485 }],
2486 metadata: synthetic_metadata(80, "detect", "False"),
2487 };
2488 match infer_ultralytics_schema(&s) {
2489 Err(InferError::UnsupportedQuantization(msg)) => {
2490 assert!(msg.contains("per-channel"), "unexpected message: {msg}");
2491 assert!(
2492 msg.contains("output0"),
2493 "message must name the tensor: {msg}"
2494 );
2495 }
2496 other => panic!("expected UnsupportedQuantization, got {other:?}"),
2497 }
2498 }
2499
2500 #[test]
2501 fn infer_per_channel_proto_quantization_rejected() {
2502 let s = ModelSignals {
2505 source: ModelSource::TfLite,
2506 inputs: vec![TensorInfo {
2507 name: "images".into(),
2508 shape: vec![1, 640, 640, 3],
2509 dtype: DType::Float32,
2510 quantization: None,
2511 }],
2512 outputs: vec![
2513 TensorInfo {
2514 name: "output0".into(),
2515 shape: vec![1, 116, 8400],
2516 dtype: DType::Float32,
2517 quantization: None,
2518 },
2519 TensorInfo {
2520 name: "output1".into(),
2521 shape: vec![1, 32, 160, 160],
2522 dtype: DType::Int8,
2523 quantization: Some(Quantization {
2524 scale: vec![0.02, 0.03],
2525 zero_point: Some(vec![-5, -7]),
2526 axis: Some(1),
2527 dtype: Some(DType::Int8),
2528 }),
2529 },
2530 ],
2531 metadata: synthetic_metadata(80, "segment", "False"),
2532 };
2533 match infer_ultralytics_schema(&s) {
2534 Err(InferError::UnsupportedQuantization(msg)) => {
2535 assert!(
2536 msg.contains("output1"),
2537 "message must name the proto: {msg}"
2538 );
2539 }
2540 other => panic!("expected UnsupportedQuantization, got {other:?}"),
2541 }
2542 }
2543
2544 #[test]
2545 fn infer_integer_output_without_quant_rejected() {
2546 let s = ModelSignals {
2550 source: ModelSource::TfLite,
2551 inputs: vec![TensorInfo {
2552 name: "images".into(),
2553 shape: vec![1, 640, 640, 3],
2554 dtype: DType::Float32,
2555 quantization: None,
2556 }],
2557 outputs: vec![TensorInfo {
2558 name: "output0".into(),
2559 shape: vec![1, 84, 8400],
2560 dtype: DType::Int8,
2561 quantization: None,
2562 }],
2563 metadata: synthetic_metadata(80, "detect", "False"),
2564 };
2565 match infer_ultralytics_schema(&s) {
2566 Err(InferError::UnsupportedLayout(msg)) => {
2567 assert!(
2568 msg.contains("quantization"),
2569 "unexpected UnsupportedLayout message: {msg}"
2570 );
2571 }
2572 other => panic!("expected UnsupportedLayout, got {other:?}"),
2573 }
2574 }
2575
2576 #[test]
2577 fn infer_pre_nms_rejects_non_unit_batch() {
2578 let mut s = signals_from_fixture("yolov8n");
2583 s.outputs[0].shape = vec![2, 84, 8400];
2584 assert!(matches!(
2585 infer_ultralytics_schema(&s),
2586 Err(InferError::UnsupportedLayout(_))
2587 ));
2588 }
2589
2590 #[test]
2591 fn coreml_source_resolves_a_box_convention() {
2592 let s = ModelSignals {
2595 source: ModelSource::CoreMl,
2596 inputs: vec![TensorInfo {
2597 name: "images".into(),
2598 shape: vec![1, 3, 640, 640],
2599 dtype: DType::Float16,
2600 quantization: None,
2601 }],
2602 outputs: vec![TensorInfo {
2603 name: "output0".into(),
2604 shape: vec![1, 84, 8400],
2605 dtype: DType::Float16,
2606 quantization: None,
2607 }],
2608 metadata: synthetic_metadata(80, "detect", "False"),
2609 };
2610 let r = infer_ultralytics_schema(&s)
2611 .expect("CoreML signals must infer, not raise UnknownBoxConvention");
2612 let o = &r.schema.outputs[0];
2613 assert_eq!(
2614 o.normalized,
2615 Some(false),
2616 "Ultralytics CoreML exports trace the raw model, so boxes are \
2617 pixel-space exactly as in the ONNX export"
2618 );
2619 }
2620
2621 #[test]
2622 fn coreml_nms_pipeline_export_is_refused_not_classified() {
2623 let s = ModelSignals {
2637 source: ModelSource::CoreMl,
2638 inputs: vec![TensorInfo {
2639 name: "image".into(),
2640 shape: vec![1, 3, 640, 640],
2641 dtype: DType::Float16,
2642 quantization: None,
2643 }],
2644 outputs: vec![
2645 TensorInfo {
2646 name: "confidence".into(),
2647 shape: vec![],
2648 dtype: DType::Float32,
2649 quantization: None,
2650 },
2651 TensorInfo {
2652 name: "coordinates".into(),
2653 shape: vec![],
2654 dtype: DType::Float32,
2655 quantization: None,
2656 },
2657 ],
2658 metadata: synthetic_metadata(80, "detect", "False"),
2659 };
2660 assert!(matches!(
2661 infer_ultralytics_schema(&s),
2662 Err(InferError::UnsupportedLayout(_))
2663 ));
2664 }
2665}