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
34edgefirst_tensor::ef_vocabulary! {
35 pub enum ModelSource {
41 Onnx = 0, "onnx", ONNX,
44 TfLite = 1, "tflite", TFLITE,
47 CoreMl = 3, "coreml", COREML,
67 Other = 2, "other", OTHER,
75 }
76 #[doc(hidden)]
77 pub mod model_source_code;
78}
79
80#[derive(Debug, Clone, PartialEq)]
83pub struct ModelSignals {
84 pub source: ModelSource,
86 pub inputs: Vec<TensorInfo>,
88 pub outputs: Vec<TensorInfo>,
90 pub metadata: BTreeMap<String, String>,
94}
95
96#[derive(Debug, Clone, PartialEq)]
98pub struct InferredSchema {
99 pub schema: SchemaV2,
101 pub labels: Vec<String>,
103 pub description: String,
105}
106
107#[derive(Debug)]
109pub enum InferError {
110 NotUltralytics(String),
112 BadNames(String),
114 BadMetadata(String),
118 UnsupportedTask(String),
120 ClassCountMismatch { expected: usize, found: usize },
123 UnsupportedLayout(String),
126 AmbiguousLayout(String),
130 UnsupportedQuantization(String),
134 UnknownBoxConvention,
140}
141
142impl fmt::Display for InferError {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 match self {
145 InferError::NotUltralytics(s) => {
146 write!(f, "model metadata carries no Ultralytics signature: {s}")
147 }
148 InferError::BadNames(s) => {
149 write!(f, "cannot parse Ultralytics `names` metadata: {s}")
150 }
151 InferError::BadMetadata(s) => {
152 write!(f, "malformed Ultralytics metadata: {s}")
153 }
154 InferError::UnsupportedTask(s) => write!(
155 f,
156 "unsupported Ultralytics task `{s}` (supported: detect, segment)"
157 ),
158 InferError::ClassCountMismatch { expected, found } => write!(
159 f,
160 "class-count mismatch: names give {expected} classes but output features imply {found}"
161 ),
162 InferError::UnsupportedLayout(s) => write!(f, "unsupported output layout: {s}"),
163 InferError::AmbiguousLayout(s) => write!(f, "ambiguous output layout: {s}"),
164 InferError::UnsupportedQuantization(s) => {
165 write!(f, "unsupported quantization: {s}")
166 }
167 InferError::UnknownBoxConvention => write!(
168 f,
169 "box coordinate convention is unknown for this container; only \
170 onnx (pixel-space), tflite ([0,1]), and coreml (pixel-space) \
171 are characterized"
172 ),
173 }
174 }
175}
176
177impl std::error::Error for InferError {}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181enum Task {
182 Detect,
183 Segment,
184}
185
186impl Task {
187 fn parse(s: &str) -> Result<Self, InferError> {
188 match s.trim().to_ascii_lowercase().as_str() {
189 "detect" => Ok(Task::Detect),
190 "segment" => Ok(Task::Segment),
191 other => Err(InferError::UnsupportedTask(other.to_string())),
192 }
193 }
194}
195
196#[derive(Debug, Clone, PartialEq)]
199struct UltralyticsMeta {
200 names: Vec<String>,
201 task: Option<Task>,
202 end2end: Option<bool>,
206}
207
208fn reject_foreign_vendor(author: Option<&String>, docs: Option<&String>) -> Result<(), InferError> {
224 let names_ultralytics = |v: &String| v.to_ascii_lowercase().contains("ultralytics");
225 for (field, value) in [("author", author), ("docs", docs)] {
226 if let Some(v) = value {
227 if !v.trim().is_empty() && !names_ultralytics(v) {
228 return Err(InferError::NotUltralytics(format!(
229 "metadata `{field}` is `{v}`, not Ultralytics"
230 )));
231 }
232 }
233 }
234 Ok(())
235}
236
237impl UltralyticsMeta {
238 fn from_metadata(meta: &BTreeMap<String, String>) -> Result<Self, InferError> {
244 if let Some(names_raw) = meta.get("names") {
245 reject_foreign_vendor(meta.get("author"), meta.get("docs"))?;
246 return Self::from_flat_props(meta, names_raw);
247 }
248
249 for value in meta.values() {
252 if let Ok(serde_json::Value::Object(obj)) = serde_json::from_str(value) {
253 if let Some(names) = obj.get("names") {
254 let author = obj
255 .get("author")
256 .and_then(|v| v.as_str())
257 .map(str::to_string);
258 let docs = obj.get("docs").and_then(|v| v.as_str()).map(str::to_string);
259 reject_foreign_vendor(author.as_ref(), docs.as_ref())?;
260 return Self::from_json_envelope(&obj, names);
261 }
262 }
263 }
264
265 Err(InferError::NotUltralytics(
266 "no `names` metadata field or metadata.json envelope found".into(),
267 ))
268 }
269
270 fn from_flat_props(
271 meta: &BTreeMap<String, String>,
272 names_raw: &str,
273 ) -> Result<Self, InferError> {
274 let names = parse_names(names_raw)?;
275 let task = meta.get("task").map(|t| Task::parse(t)).transpose()?;
276 let end2end = meta.get("end2end").map(|s| parse_bool_str(s)).transpose()?;
277 Ok(UltralyticsMeta {
278 names,
279 task,
280 end2end,
281 })
282 }
283
284 fn from_json_envelope(
288 obj: &serde_json::Map<String, serde_json::Value>,
289 names_value: &serde_json::Value,
290 ) -> Result<Self, InferError> {
291 let names = names_from_json_value(names_value)?;
292 let task = obj
293 .get("task")
294 .and_then(|v| v.as_str())
295 .map(Task::parse)
296 .transpose()?;
297 let end2end = match obj.get("end2end") {
300 None | Some(serde_json::Value::Null) => None,
301 Some(serde_json::Value::Bool(b)) => Some(*b),
302 Some(other) => {
303 return Err(InferError::BadMetadata(format!(
304 "`end2end` is {other}, expected a boolean"
305 )))
306 }
307 };
308 Ok(UltralyticsMeta {
309 names,
310 task,
311 end2end,
312 })
313 }
314}
315
316fn parse_bool_str(s: &str) -> Result<bool, InferError> {
324 match s.trim().to_ascii_lowercase().as_str() {
325 "true" => Ok(true),
326 "false" => Ok(false),
327 other => Err(InferError::BadMetadata(format!(
328 "`end2end` is `{other}`, expected true or false"
329 ))),
330 }
331}
332
333fn parse_names(s: &str) -> Result<Vec<String>, InferError> {
338 let trimmed = s.trim();
339
340 if let Ok(map) = serde_json::from_str::<BTreeMap<String, String>>(trimmed) {
341 return names_from_str_keyed_map(map);
342 }
343
344 let body = trimmed
345 .strip_prefix('{')
346 .and_then(|s| s.strip_suffix('}'))
347 .ok_or_else(|| InferError::BadNames(format!("not a dict: {trimmed}")))?;
348
349 let mut indexed: BTreeMap<usize, String> = BTreeMap::new();
350 for item in split_top_level_commas(body) {
351 let item = item.trim();
352 if item.is_empty() {
353 continue;
354 }
355 let (key, value) = item
356 .split_once(':')
357 .ok_or_else(|| InferError::BadNames(format!("malformed entry `{item}`")))?;
358 let idx: usize = key
359 .trim()
360 .parse()
361 .map_err(|_| InferError::BadNames(format!("non-numeric key `{key}`")))?;
362 let value = unquote(value.trim())
363 .ok_or_else(|| InferError::BadNames(format!("unquoted value `{value}`")))?;
364 indexed.insert(idx, value);
365 }
366 contiguous_values(indexed)
367}
368
369fn names_from_json_value(v: &serde_json::Value) -> Result<Vec<String>, InferError> {
372 let obj = v
373 .as_object()
374 .ok_or_else(|| InferError::BadNames("`names` is not a JSON object".into()))?;
375 let mut map = BTreeMap::new();
376 for (k, val) in obj {
377 let s = val
378 .as_str()
379 .ok_or_else(|| InferError::BadNames(format!("non-string name for key `{k}`")))?;
380 map.insert(k.clone(), s.to_string());
381 }
382 names_from_str_keyed_map(map)
383}
384
385fn names_from_str_keyed_map(map: BTreeMap<String, String>) -> Result<Vec<String>, InferError> {
389 let mut indexed: BTreeMap<usize, String> = BTreeMap::new();
390 for (k, v) in map {
391 let idx: usize = k
392 .parse()
393 .map_err(|_| InferError::BadNames(format!("non-numeric key `{k}`")))?;
394 indexed.insert(idx, v);
395 }
396 contiguous_values(indexed)
397}
398
399fn contiguous_values(indexed: BTreeMap<usize, String>) -> Result<Vec<String>, InferError> {
402 if indexed.is_empty() {
407 return Err(InferError::BadNames("class map is empty".into()));
408 }
409 let n = indexed.len();
410 for i in 0..n {
411 if !indexed.contains_key(&i) {
412 return Err(InferError::BadNames("non-contiguous class indices".into()));
413 }
414 }
415 Ok(indexed.into_values().collect())
416}
417
418fn split_top_level_commas(s: &str) -> Vec<String> {
421 let mut parts = Vec::new();
422 let mut current = String::new();
423 let mut quote: Option<char> = None;
424 for c in s.chars() {
425 match quote {
426 Some(q) => {
427 current.push(c);
428 if c == q {
429 quote = None;
430 }
431 }
432 None => match c {
433 '\'' | '"' => {
434 quote = Some(c);
435 current.push(c);
436 }
437 ',' => parts.push(std::mem::take(&mut current)),
438 _ => current.push(c),
439 },
440 }
441 }
442 if !current.is_empty() {
443 parts.push(current);
444 }
445 parts
446}
447
448fn unquote(s: &str) -> Option<String> {
451 let bytes = s.as_bytes();
452 if s.len() >= 2 {
453 let first = bytes[0];
454 let last = bytes[s.len() - 1];
455 if (first == b'\'' && last == b'\'') || (first == b'"' && last == b'"') {
456 return Some(s[1..s.len() - 1].to_string());
457 }
458 }
459 None
460}
461
462#[allow(clippy::type_complexity)]
473fn classify_input_layout(
474 input: &TensorInfo,
475) -> Result<(usize, usize, Vec<(DimName, usize)>), InferError> {
476 debug_assert_eq!(
477 input.shape.len(),
478 4,
479 "caller selects the input by rank-4; see infer_ultralytics_schema"
480 );
481 if input.shape[0] != 1 {
482 return Err(InferError::UnsupportedLayout(format!(
483 "model input `{}` has batch {} but every supported output layout \
484 is batch-1; shape {:?}",
485 input.name, input.shape[0], input.shape
486 )));
487 }
488 if input.shape[1] == 3 {
489 let (h, w) = (input.shape[2], input.shape[3]);
490 Ok((
491 h,
492 w,
493 vec![
494 (DimName::Batch, input.shape[0]),
495 (DimName::NumFeatures, 3),
496 (DimName::Height, h),
497 (DimName::Width, w),
498 ],
499 ))
500 } else if input.shape[3] == 3 {
501 let (h, w) = (input.shape[1], input.shape[2]);
502 Ok((
503 h,
504 w,
505 vec![
506 (DimName::Batch, input.shape[0]),
507 (DimName::Height, h),
508 (DimName::Width, w),
509 (DimName::NumFeatures, 3),
510 ],
511 ))
512 } else {
513 Err(InferError::UnsupportedLayout(format!(
514 "cannot locate the channel dimension (=3) in input shape {:?}",
515 input.shape
516 )))
517 }
518}
519
520fn find_anchors_dim(shape: &[usize], expected_anchors: usize) -> Option<(usize, usize)> {
527 if shape.len() != 3 || shape[0] != 1 {
528 return None;
529 }
530 if shape[2] == expected_anchors {
531 Some((shape[1], 1)) } else if shape[1] == expected_anchors {
533 Some((shape[2], 2)) } else {
535 None
536 }
537}
538
539#[allow(clippy::type_complexity)]
555fn classify_pre_nms<'a>(
556 rank3_outputs: &[&'a TensorInfo],
557 expected_anchors: usize,
558 feat: usize,
559 nc: usize,
560) -> Result<(&'a TensorInfo, Vec<(DimName, usize)>), InferError> {
561 let mut mismatch: Option<InferError> = None;
562 let mut matched: Option<(&'a TensorInfo, Vec<(DimName, usize)>)> = None;
563 for out in rank3_outputs {
564 let Some((other_dim, feat_idx)) = find_anchors_dim(&out.shape, expected_anchors) else {
565 continue;
566 };
567 if other_dim == feat {
568 let dshape = if feat_idx == 1 {
569 vec![
570 (DimName::Batch, 1),
571 (DimName::NumFeatures, feat),
572 (DimName::NumBoxes, expected_anchors),
573 ]
574 } else {
575 vec![
576 (DimName::Batch, 1),
577 (DimName::NumBoxes, expected_anchors),
578 (DimName::NumFeatures, feat),
579 ]
580 };
581 if let Some((first, _)) = &matched {
584 return Err(InferError::AmbiguousLayout(format!(
585 "outputs `{}` and `{}` both match the pre-NMS detection layout \
586 ({:?}); cannot tell which carries the detections",
587 first.name, out.name, out.shape
588 )));
589 }
590 matched = Some((out, dshape));
591 continue;
592 }
593 mismatch.get_or_insert_with(|| InferError::ClassCountMismatch {
594 expected: nc,
595 found: other_dim.saturating_sub(feat.saturating_sub(nc)),
598 });
599 }
600 if let Some(found) = matched {
601 return Ok(found);
602 }
603 Err(mismatch.unwrap_or_else(|| {
604 InferError::UnsupportedLayout(format!(
605 "no output matches the Ultralytics pre-NMS layout (expected a dim == \
606 {expected_anchors} anchors and a dim == {feat} features [4 + {nc} classes]); \
607 outputs seen: {:?}",
608 rank3_outputs.iter().map(|o| &o.shape).collect::<Vec<_>>()
609 ))
610 }))
611}
612
613fn find_e2e_candidate<'a>(
628 rank3_outputs: &[&'a TensorInfo],
629 expected_anchors: usize,
630 feat: usize,
631) -> Result<Option<(&'a TensorInfo, usize)>, InferError> {
632 let matches: Vec<(&'a TensorInfo, usize)> = rank3_outputs
633 .iter()
634 .filter_map(|o| {
635 let s = &o.shape;
636 debug_assert_eq!(s.len(), 3, "caller filters to rank-3 outputs");
637 (s[0] == 1 && s[2] == feat && s[1] > 0 && s[1] <= expected_anchors)
640 .then_some((*o, s[1]))
641 })
642 .collect();
643 if matches.len() > 1 {
647 return Err(InferError::AmbiguousLayout(format!(
648 "{} outputs match the end-to-end layout [1, N, {feat}]: {:?}; \
649 cannot tell which carries the detections",
650 matches.len(),
651 matches.iter().map(|(o, _)| &o.name).collect::<Vec<_>>()
652 )));
653 }
654 Ok(matches.into_iter().next())
655}
656
657fn classify_proto(
670 proto: &TensorInfo,
671 h: usize,
672 w: usize,
673) -> Result<(usize, Vec<(DimName, usize)>), InferError> {
674 let (eh, ew) = (h / 4, w / 4);
675 let s = &proto.shape;
676 if s.len() != 4 || s[0] != 1 {
677 return Err(InferError::UnsupportedLayout(format!(
678 "expected a rank-4 proto tensor with batch 1, found shape {s:?}"
679 )));
680 }
681 if s[2] == eh && s[3] == ew {
682 let k = s[1];
683 Ok((
684 k,
685 vec![
686 (DimName::Batch, 1),
687 (DimName::NumProtos, k),
688 (DimName::Height, eh),
689 (DimName::Width, ew),
690 ],
691 ))
692 } else if s[1] == eh && s[2] == ew {
693 let k = s[3];
694 Ok((
695 k,
696 vec![
697 (DimName::Batch, 1),
698 (DimName::Height, eh),
699 (DimName::Width, ew),
700 (DimName::NumProtos, k),
701 ],
702 ))
703 } else {
704 Err(InferError::UnsupportedLayout(format!(
705 "proto tensor shape {s:?} doesn't match the expected ({eh}, {ew}) spatial dims"
706 )))
707 }
708}
709
710fn default_logical() -> LogicalOutput {
713 LogicalOutput {
714 name: None,
715 type_: None,
716 shape: Vec::new(),
717 dshape: Vec::new(),
718 decoder: None,
719 encoding: None,
720 score_format: None,
721 normalized: None,
722 anchors: None,
723 stride: None,
724 dtype: None,
725 quantization: None,
726 outputs: Vec::new(),
727 activation_applied: None,
728 activation_required: None,
729 semantic: Default::default(),
730 }
731}
732
733fn check_quantized_boundary(tensor: &TensorInfo) -> Result<(), InferError> {
749 if tensor.dtype.is_integer() && tensor.quantization.is_none() {
750 return Err(InferError::UnsupportedLayout(format!(
751 "output `{}` has integer dtype {:?} but no quantization params",
752 tensor.name, tensor.dtype
753 )));
754 }
755 if let Some(q) = &tensor.quantization {
756 if q.scale.len() > 1 {
757 return Err(InferError::UnsupportedQuantization(format!(
758 "output `{}` carries per-channel quantization ({} scales); \
759 the decoder supports per-tensor quantization only",
760 tensor.name,
761 q.scale.len()
762 )));
763 }
764 if q.scale.is_empty() {
768 return Err(InferError::UnsupportedQuantization(format!(
769 "output `{}` is quantized but carries no scale",
770 tensor.name
771 )));
772 }
773 if let Some(zp) = &q.zero_point {
777 if zp.len() != q.scale.len() {
778 return Err(InferError::UnsupportedQuantization(format!(
779 "output `{}` has {} scale(s) but {} zero point(s)",
780 tensor.name,
781 q.scale.len(),
782 zp.len()
783 )));
784 }
785 }
786 }
787 Ok(())
788}
789
790pub fn infer_ultralytics_schema(signals: &ModelSignals) -> Result<InferredSchema, InferError> {
867 let meta = UltralyticsMeta::from_metadata(&signals.metadata)?;
868
869 let proto = signals.outputs.iter().find(|o| o.shape.len() == 4);
870 match (meta.task, proto.is_some()) {
871 (Some(Task::Segment), false) => {
872 return Err(InferError::UnsupportedLayout(
873 "segment task but no proto tensor".into(),
874 ));
875 }
876 (Some(Task::Detect), true) => {
877 return Err(InferError::UnsupportedLayout(
878 "detect task but proto tensor present".into(),
879 ));
880 }
881 _ => {}
882 }
883 let is_segment = meta.task == Some(Task::Segment) || proto.is_some();
884
885 let input = signals
886 .inputs
887 .iter()
888 .find(|t| t.shape.len() == 4)
889 .ok_or_else(|| {
890 InferError::UnsupportedLayout(format!(
891 "no rank-4 model input found; inputs seen: {:?}",
892 signals.inputs.iter().map(|t| &t.shape).collect::<Vec<_>>()
893 ))
894 })?;
895 let (h, w, input_dshape) = classify_input_layout(input)?;
896
897 let expected_anchors = (h / 8) * (w / 8) + (h / 16) * (w / 16) + (h / 32) * (w / 32);
898 let nc = meta.names.len();
899
900 let (k, proto_dshape) = match proto {
908 Some(proto) => {
909 let (k, dshape) = classify_proto(proto, h, w)?;
910 (k, Some(dshape))
911 }
912 None => (0, None),
913 };
914 let feat = 4 + nc + k;
915 let e2e_feat = 6 + k;
916
917 let rank3_outputs: Vec<&TensorInfo> = signals
918 .outputs
919 .iter()
920 .filter(|o| o.shape.len() == 3)
921 .collect();
922
923 let (out, dshape, version) = match meta.end2end {
927 Some(true) => {
928 let (out, n) = find_e2e_candidate(&rank3_outputs, expected_anchors, e2e_feat)?
929 .ok_or_else(|| {
930 InferError::UnsupportedLayout(format!(
931 "metadata declares end2end=true but no output matches the YOLO26 \
932 end-to-end layout [1, N<={expected_anchors}, {e2e_feat}]; \
933 outputs seen: {:?}",
934 rank3_outputs.iter().map(|o| &o.shape).collect::<Vec<_>>()
935 ))
936 })?;
937 (
938 out,
939 vec![
940 (DimName::Batch, 1),
941 (DimName::NumBoxes, n),
942 (DimName::NumFeatures, e2e_feat),
943 ],
944 DecoderVersion::Yolo26,
945 )
946 }
947 Some(false) => {
948 let (out, dshape) = classify_pre_nms(&rank3_outputs, expected_anchors, feat, nc)?;
949 (out, dshape, DecoderVersion::Yolov8)
950 }
951 None => match classify_pre_nms(&rank3_outputs, expected_anchors, feat, nc) {
952 Ok((out, dshape)) => (out, dshape, DecoderVersion::Yolov8),
953 Err(err @ InferError::ClassCountMismatch { .. }) => return Err(err),
954 Err(_) => {
955 let (out, n) = find_e2e_candidate(&rank3_outputs, expected_anchors, e2e_feat)?
956 .ok_or_else(|| {
957 InferError::UnsupportedLayout(format!(
958 "no output matches a known Ultralytics detection layout \
959 (pre-NMS or end-to-end); outputs seen: {:?}",
960 rank3_outputs.iter().map(|o| &o.shape).collect::<Vec<_>>()
961 ))
962 })?;
963 (
964 out,
965 vec![
966 (DimName::Batch, 1),
967 (DimName::NumBoxes, n),
968 (DimName::NumFeatures, e2e_feat),
969 ],
970 DecoderVersion::Yolo26,
971 )
972 }
973 },
974 };
975
976 check_quantized_boundary(out)?;
977 if let Some(proto) = proto {
978 check_quantized_boundary(proto)?;
979 }
980
981 let normalized = match signals.source {
998 ModelSource::TfLite => true,
999 ModelSource::Onnx | ModelSource::CoreMl => false,
1000 ModelSource::Other => {
1001 return Err(InferError::UnknownBoxConvention);
1002 }
1003 };
1004 let det = LogicalOutput {
1005 name: Some(out.name.clone()),
1006 type_: Some(match version {
1013 DecoderVersion::Yolo26 => LogicalType::Detections,
1014 _ => LogicalType::Detection,
1015 }),
1016 shape: out.shape.clone(),
1017 dshape,
1018 decoder: Some(DecoderKind::Ultralytics),
1019 encoding: Some(BoxEncoding::Direct), score_format: Some(ScoreFormat::PerClass),
1021 normalized: Some(normalized),
1022 anchors: None,
1023 stride: None,
1024 dtype: Some(out.dtype),
1025 quantization: out.quantization.clone(),
1026 outputs: Vec::new(),
1027 activation_applied: None,
1028 activation_required: None,
1029 semantic: Default::default(),
1030 };
1031
1032 let mut outputs = vec![det];
1033 if let (Some(proto), Some(proto_dshape)) = (proto, proto_dshape) {
1034 outputs.push(LogicalOutput {
1035 name: Some(proto.name.clone()),
1036 type_: Some(LogicalType::Protos),
1037 shape: proto.shape.clone(),
1038 dshape: proto_dshape,
1039 decoder: Some(DecoderKind::Ultralytics),
1040 dtype: Some(proto.dtype),
1041 quantization: proto.quantization.clone(),
1042 ..default_logical()
1043 });
1044 }
1045
1046 let schema = SchemaV2 {
1047 schema_version: 2,
1048 input: Some(InputSpec {
1049 shape: input.shape.clone(),
1050 dshape: input_dshape,
1051 cameraadaptor: Some("rgb".into()),
1052 preprocessing: None,
1053 }),
1054 outputs,
1055 nms: match version {
1062 DecoderVersion::Yolo26 => None,
1063 _ => Some(NmsMode::ClassAware),
1064 },
1065 nms_multi_label: None,
1066 decoder_version: Some(version),
1067 };
1068
1069 let version_label = if version == DecoderVersion::Yolo26 {
1070 "YOLO26"
1071 } else {
1072 "YOLOv8/11"
1073 };
1074 let task_label = if is_segment { "segment" } else { "detect" };
1075 let description = format!("Ultralytics {version_label} {task_label}, {nc} classes");
1076
1077 Ok(InferredSchema {
1078 schema,
1079 labels: meta.names,
1080 description,
1081 })
1082}
1083
1084#[cfg(test)]
1085mod tests {
1086 use super::*;
1087
1088 fn infer_fixture_dir() -> std::path::PathBuf {
1100 std::env::var("EDGEFIRST_TESTDATA_DIR")
1101 .map(std::path::PathBuf::from)
1102 .unwrap_or_else(|_| {
1103 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata")
1104 })
1105 .join("infer")
1106 }
1107
1108 #[test]
1109 fn parse_names_python_dict_repr() {
1110 let s = "{0: 'person', 1: 'bicycle', 2: \"fire hydrant\"}";
1111 assert_eq!(
1112 parse_names(s).unwrap(),
1113 vec!["person", "bicycle", "fire hydrant"]
1114 );
1115 }
1116
1117 #[test]
1118 fn parse_names_json_object() {
1119 let s = r#"{"0": "person", "1": "bicycle"}"#;
1120 assert_eq!(parse_names(s).unwrap(), vec!["person", "bicycle"]);
1121 }
1122
1123 #[test]
1124 fn parse_names_orders_by_index_not_insertion() {
1125 let s = "{1: 'b', 0: 'a'}";
1126 assert_eq!(parse_names(s).unwrap(), vec!["a", "b"]);
1127 }
1128
1129 #[test]
1130 fn parse_names_rejects_garbage() {
1131 assert!(matches!(
1132 parse_names("not a dict"),
1133 Err(InferError::BadNames(_))
1134 ));
1135 }
1136
1137 #[test]
1138 fn parse_names_rejects_non_contiguous_indices() {
1139 match parse_names("{0: 'a', 2: 'b'}") {
1141 Err(InferError::BadNames(msg)) => {
1142 assert!(
1143 msg.contains("non-contiguous"),
1144 "unexpected BadNames message: {msg}"
1145 );
1146 }
1147 other => panic!("expected BadNames, got {other:?}"),
1148 }
1149
1150 match parse_names(r#"{"0": "a", "2": "b"}"#) {
1152 Err(InferError::BadNames(msg)) => {
1153 assert!(
1154 msg.contains("non-contiguous"),
1155 "unexpected BadNames message: {msg}"
1156 );
1157 }
1158 other => panic!("expected BadNames, got {other:?}"),
1159 }
1160 }
1161
1162 #[test]
1163 fn meta_from_onnx_props() {
1164 let mut m = BTreeMap::new();
1165 m.insert("names".into(), "{0: 'person'}".into());
1166 m.insert("task".into(), "detect".into());
1167 let u = UltralyticsMeta::from_metadata(&m).unwrap();
1168 assert_eq!(u.names, vec!["person"]);
1169 assert_eq!(u.task, Some(Task::Detect));
1170 }
1171
1172 #[test]
1173 fn meta_task_pose_rejected() {
1174 let mut m = BTreeMap::new();
1175 m.insert("names".into(), "{0: 'person'}".into());
1176 m.insert("task".into(), "pose".into());
1177 assert!(matches!(
1178 UltralyticsMeta::from_metadata(&m),
1179 Err(InferError::UnsupportedTask(_))
1180 ));
1181 }
1182
1183 #[test]
1184 fn meta_missing_names_is_not_ultralytics() {
1185 assert!(matches!(
1186 UltralyticsMeta::from_metadata(&BTreeMap::new()),
1187 Err(InferError::NotUltralytics(_))
1188 ));
1189 }
1190
1191 #[test]
1192 fn meta_end2end_true_or_false_from_onnx_flat_props() {
1193 let mut m = BTreeMap::new();
1194 m.insert("names".into(), "{0: 'person'}".into());
1195 m.insert("end2end".into(), "True".into());
1196 assert_eq!(
1197 UltralyticsMeta::from_metadata(&m).unwrap().end2end,
1198 Some(true)
1199 );
1200
1201 m.insert("end2end".into(), "False".into());
1202 assert_eq!(
1203 UltralyticsMeta::from_metadata(&m).unwrap().end2end,
1204 Some(false)
1205 );
1206 }
1207
1208 fn load_fixture_metadata(name: &str) -> BTreeMap<String, String> {
1213 let path = infer_fixture_dir().join(format!("{name}.signals.json"));
1214 let content = std::fs::read_to_string(&path)
1215 .unwrap_or_else(|e| panic!("failed to read fixture {}: {e}", path.display()));
1216 let json: serde_json::Value =
1217 serde_json::from_str(&content).expect("fixture is valid JSON");
1218 json["metadata"]
1219 .as_object()
1220 .expect("fixture has a metadata object")
1221 .iter()
1222 .map(|(k, v)| {
1223 (
1224 k.clone(),
1225 v.as_str().expect("metadata value is a string").to_string(),
1226 )
1227 })
1228 .collect()
1229 }
1230
1231 #[test]
1232 fn meta_from_real_tflite_metadata_json_envelope() {
1233 let meta = load_fixture_metadata("yolov8n_float32");
1234 let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1235 assert_eq!(u.names.len(), 80);
1236 assert_eq!(u.task, Some(Task::Detect));
1237 }
1238
1239 #[test]
1240 fn meta_end2end_true_on_real_yolo26_tflite_fixture() {
1241 let meta = load_fixture_metadata("yolo26n_float32");
1242 let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1243 assert_eq!(u.end2end, Some(true));
1244 }
1245
1246 #[test]
1247 fn meta_end2end_false_on_real_yolov8_onnx_fixture() {
1248 let meta = load_fixture_metadata("yolov8n");
1249 let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1250 assert_eq!(u.end2end, Some(false));
1251 }
1252
1253 fn signals_from_fixture(name: &str) -> ModelSignals {
1257 let path = infer_fixture_dir().join(format!("{name}.signals.json"));
1258 let content = std::fs::read_to_string(&path)
1259 .unwrap_or_else(|e| panic!("failed to read fixture {}: {e}", path.display()));
1260 let json: serde_json::Value =
1261 serde_json::from_str(&content).expect("fixture is valid JSON");
1262
1263 let source = match json["source"]
1264 .as_str()
1265 .expect("fixture `source` is a string")
1266 {
1267 "onnx" => ModelSource::Onnx,
1268 "tflite" => ModelSource::TfLite,
1269 "coreml" => ModelSource::CoreMl,
1270 other => panic!("fixture {name}: unknown source `{other}`"),
1271 };
1272
1273 fn parse_dtype(s: &str) -> DType {
1274 match s {
1275 "float32" => DType::Float32,
1276 "float16" => DType::Float16,
1277 "int8" => DType::Int8,
1278 "uint8" => DType::Uint8,
1279 "int16" => DType::Int16,
1280 "uint16" => DType::Uint16,
1281 "int32" => DType::Int32,
1282 "uint32" => DType::Uint32,
1283 other => panic!("unknown dtype `{other}`"),
1284 }
1285 }
1286
1287 fn parse_tensor(v: &serde_json::Value) -> TensorInfo {
1288 TensorInfo {
1289 name: v["name"]
1290 .as_str()
1291 .expect("tensor `name` is a string")
1292 .to_string(),
1293 shape: v["shape"]
1294 .as_array()
1295 .expect("tensor `shape` is an array")
1296 .iter()
1297 .map(|d| d.as_u64().expect("shape dim is a number") as usize)
1298 .collect(),
1299 dtype: parse_dtype(v["dtype"].as_str().expect("tensor `dtype` is a string")),
1300 quantization: None,
1304 }
1305 }
1306
1307 let inputs = json["inputs"]
1308 .as_array()
1309 .expect("fixture `inputs` is an array")
1310 .iter()
1311 .map(parse_tensor)
1312 .collect();
1313 let outputs = json["outputs"]
1314 .as_array()
1315 .expect("fixture `outputs` is an array")
1316 .iter()
1317 .map(parse_tensor)
1318 .collect();
1319 let metadata = json["metadata"]
1320 .as_object()
1321 .expect("fixture `metadata` is an object")
1322 .iter()
1323 .map(|(k, v)| {
1324 (
1325 k.clone(),
1326 v.as_str().expect("metadata value is a string").to_string(),
1327 )
1328 })
1329 .collect();
1330
1331 ModelSignals {
1332 source,
1333 inputs,
1334 outputs,
1335 metadata,
1336 }
1337 }
1338
1339 fn synthetic_metadata(nc: usize, task: &str, end2end: &str) -> BTreeMap<String, String> {
1343 let names = (0..nc)
1344 .map(|i| format!("{i}: 'c{i}'"))
1345 .collect::<Vec<_>>()
1346 .join(", ");
1347 let mut m = BTreeMap::new();
1348 m.insert("names".into(), format!("{{{names}}}"));
1349 m.insert("task".into(), task.into());
1350 m.insert("end2end".into(), end2end.into());
1351 m
1352 }
1353
1354 #[test]
1355 fn infer_transposed_det_layout_classified() {
1356 let s = ModelSignals {
1362 source: ModelSource::TfLite,
1363 inputs: vec![TensorInfo {
1364 name: "images".into(),
1365 shape: vec![1, 640, 640, 3],
1366 dtype: DType::Float32,
1367 quantization: None,
1368 }],
1369 outputs: vec![TensorInfo {
1370 name: "output0".into(),
1371 shape: vec![1, 8400, 84],
1372 dtype: DType::Float32,
1373 quantization: None,
1374 }],
1375 metadata: synthetic_metadata(80, "detect", "False"),
1376 };
1377 let r = infer_ultralytics_schema(&s).unwrap();
1378 let o = &r.schema.outputs[0];
1379 assert_eq!(
1380 o.dshape,
1381 vec![
1382 (DimName::Batch, 1),
1383 (DimName::NumBoxes, 8400),
1384 (DimName::NumFeatures, 84),
1385 ]
1386 );
1387 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1388 }
1389
1390 #[test]
1391 fn infer_yolo26n_onnx_end_to_end() {
1392 let s = signals_from_fixture("yolo26n");
1393 let r = infer_ultralytics_schema(&s).unwrap();
1394 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1395 assert_eq!(r.schema.outputs[0].shape[2], 6);
1396 }
1397
1398 #[test]
1399 fn infer_class_count_mismatch_rejected() {
1400 let mut s = signals_from_fixture("yolov8n");
1401 s.metadata.insert("names".into(), "{0: 'only-one'}".into()); assert!(matches!(
1403 infer_ultralytics_schema(&s),
1404 Err(InferError::ClassCountMismatch {
1405 expected: 1,
1406 found: 80
1407 })
1408 ));
1409 }
1410
1411 #[test]
1412 fn infer_seg_class_count_mismatch_rejected() {
1413 let mut s = signals_from_fixture("yolov8n-seg");
1422 s.metadata.insert("names".into(), "{0: 'only-one'}".into()); assert!(matches!(
1424 infer_ultralytics_schema(&s),
1425 Err(InferError::ClassCountMismatch {
1426 expected: 1,
1427 found: 80
1428 })
1429 ));
1430 }
1431
1432 #[test]
1433 fn infer_end2end_flag_shape_conflict_rejected() {
1434 let mut s = signals_from_fixture("yolov8n");
1439 s.metadata.insert("end2end".into(), "True".into());
1440 assert!(matches!(
1441 infer_ultralytics_schema(&s),
1442 Err(InferError::UnsupportedLayout(_))
1443 ));
1444 }
1445
1446 #[test]
1447 fn infer_yolov8n_seg_onnx() {
1448 let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n-seg")).unwrap();
1449 assert_eq!(r.schema.outputs.len(), 2);
1450 let proto = r
1451 .schema
1452 .outputs
1453 .iter()
1454 .find(|o| o.type_ == Some(LogicalType::Protos))
1455 .unwrap();
1456 assert_eq!(proto.dshape[1].0, DimName::NumProtos); let det = r
1458 .schema
1459 .outputs
1460 .iter()
1461 .find(|o| o.type_ == Some(LogicalType::Detection))
1462 .unwrap();
1463 assert!(det.shape.contains(&116)); }
1465
1466 #[test]
1467 fn infer_yolov8n_seg_tflite_proto_nchw() {
1468 let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n-seg_float32")).unwrap();
1475 let proto = r
1476 .schema
1477 .outputs
1478 .iter()
1479 .find(|o| o.type_ == Some(LogicalType::Protos))
1480 .unwrap();
1481 assert_eq!(proto.dshape[1].0, DimName::NumProtos); assert_eq!(proto.dshape[1].1, 32);
1483 }
1484
1485 #[test]
1486 fn infer_yolo26n_seg_end_to_end() {
1487 let r = infer_ultralytics_schema(&signals_from_fixture("yolo26n-seg")).unwrap();
1488 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1489 assert_eq!(r.schema.outputs.len(), 2);
1490 }
1491
1492 #[test]
1493 fn infer_nhwc_proto_layout_classified() {
1494 let s = ModelSignals {
1498 source: ModelSource::TfLite,
1499 inputs: vec![TensorInfo {
1500 name: "images".into(),
1501 shape: vec![1, 640, 640, 3],
1502 dtype: DType::Float32,
1503 quantization: None,
1504 }],
1505 outputs: vec![
1506 TensorInfo {
1507 name: "output0".into(),
1508 shape: vec![1, 116, 8400],
1509 dtype: DType::Float32,
1510 quantization: None,
1511 },
1512 TensorInfo {
1513 name: "output1".into(),
1514 shape: vec![1, 160, 160, 32],
1515 dtype: DType::Float32,
1516 quantization: None,
1517 },
1518 ],
1519 metadata: synthetic_metadata(80, "segment", "False"),
1520 };
1521 let r = infer_ultralytics_schema(&s).unwrap();
1522 let proto = r
1523 .schema
1524 .outputs
1525 .iter()
1526 .find(|o| o.type_ == Some(LogicalType::Protos))
1527 .unwrap();
1528 assert_eq!(proto.dshape[3].0, DimName::NumProtos); assert_eq!(proto.dshape[3].1, 32);
1530 }
1531
1532 #[test]
1536 fn infer_proto_output_carries_its_name_decoder_dtype_and_quantization() {
1537 let quant = Quantization {
1538 scale: vec![0.0125],
1539 zero_point: Some(vec![-7]),
1540 axis: None,
1541 dtype: Some(DType::Int8),
1542 };
1543 let s = ModelSignals {
1544 source: ModelSource::TfLite,
1545 inputs: vec![TensorInfo {
1546 name: "images".into(),
1547 shape: vec![1, 640, 640, 3],
1548 dtype: DType::Int8,
1549 quantization: None,
1550 }],
1551 outputs: vec![
1552 TensorInfo {
1553 name: "output0".into(),
1554 shape: vec![1, 116, 8400],
1555 dtype: DType::Int8,
1556 quantization: Some(Quantization {
1557 scale: vec![0.003_921_6],
1558 zero_point: Some(vec![0]),
1559 axis: None,
1560 dtype: Some(DType::Int8),
1561 }),
1562 },
1563 TensorInfo {
1564 name: "protos".into(),
1565 shape: vec![1, 160, 160, 32],
1566 dtype: DType::Int8,
1567 quantization: Some(quant.clone()),
1568 },
1569 ],
1570 metadata: synthetic_metadata(80, "segment", "False"),
1571 };
1572 let r = infer_ultralytics_schema(&s).unwrap();
1573 let proto = r
1574 .schema
1575 .outputs
1576 .iter()
1577 .find(|o| o.type_ == Some(LogicalType::Protos))
1578 .unwrap();
1579 assert_eq!(proto.name.as_deref(), Some("protos"));
1580 assert_eq!(proto.decoder, Some(DecoderKind::Ultralytics));
1581 assert_eq!(proto.dtype, Some(DType::Int8));
1582 assert_eq!(proto.quantization.as_ref(), Some(&quant));
1583 }
1584
1585 #[test]
1589 fn infer_treats_a_proto_tensor_without_a_task_as_segmentation() {
1590 let mut metadata = synthetic_metadata(80, "segment", "False");
1591 metadata.remove("task");
1592 let s = ModelSignals {
1593 source: ModelSource::TfLite,
1594 inputs: vec![TensorInfo {
1595 name: "images".into(),
1596 shape: vec![1, 640, 640, 3],
1597 dtype: DType::Float32,
1598 quantization: None,
1599 }],
1600 outputs: vec![
1601 TensorInfo {
1602 name: "output0".into(),
1603 shape: vec![1, 116, 8400],
1604 dtype: DType::Float32,
1605 quantization: None,
1606 },
1607 TensorInfo {
1608 name: "output1".into(),
1609 shape: vec![1, 160, 160, 32],
1610 dtype: DType::Float32,
1611 quantization: None,
1612 },
1613 ],
1614 metadata,
1615 };
1616 let r = infer_ultralytics_schema(&s).unwrap();
1617 assert!(
1618 r.schema
1619 .outputs
1620 .iter()
1621 .any(|o| o.type_ == Some(LogicalType::Protos)),
1622 "proto tensor present but no protos output was emitted"
1623 );
1624 assert!(r.description.contains("segment"), "{}", r.description);
1627 }
1628
1629 fn proto_tensor(shape: Vec<usize>) -> TensorInfo {
1630 TensorInfo {
1631 name: "protos".into(),
1632 shape,
1633 dtype: DType::Float32,
1634 quantization: None,
1635 }
1636 }
1637
1638 #[test]
1641 fn classify_proto_rejects_wrong_rank_or_batch() {
1642 for shape in [vec![1, 32, 160], vec![2, 32, 160, 160], vec![1, 160, 160]] {
1643 let err = classify_proto(&proto_tensor(shape.clone()), 640, 640).unwrap_err();
1644 assert!(
1645 matches!(err, InferError::UnsupportedLayout(_)),
1646 "shape {shape:?} gave {err:?}"
1647 );
1648 }
1649 }
1650
1651 #[test]
1654 fn classify_proto_rejects_a_shape_matching_only_one_spatial_dim() {
1655 let err = classify_proto(&proto_tensor(vec![1, 160, 7, 9]), 640, 640).unwrap_err();
1657 assert!(matches!(err, InferError::UnsupportedLayout(_)), "{err:?}");
1658
1659 let (k, dshape) = classify_proto(&proto_tensor(vec![1, 160, 160, 32]), 640, 640).unwrap();
1661 assert_eq!(k, 32);
1662 assert_eq!(dshape[3], (DimName::NumProtos, 32));
1663 }
1664
1665 #[test]
1668 fn unquote_requires_a_matching_pair_of_quotes() {
1669 assert_eq!(unquote("'abc'").as_deref(), Some("abc"));
1670 assert_eq!(unquote("\"abc\"").as_deref(), Some("abc"));
1671 assert_eq!(unquote("'abc"), None);
1672 assert_eq!(unquote("abc'"), None);
1673 assert_eq!(unquote("\"abc"), None);
1674 assert_eq!(unquote("abc\""), None);
1675 assert_eq!(unquote("abc"), None);
1676 }
1677
1678 #[test]
1682 fn split_top_level_commas_keeps_quoted_commas_together() {
1683 let parts = split_top_level_commas("a='x,y', b=2");
1684 assert_eq!(parts, vec!["a='x,y'".to_string(), " b=2".to_string()]);
1685
1686 let parts = split_top_level_commas("a=\"x,y\",b=\"p,q\"");
1687 assert_eq!(
1688 parts,
1689 vec!["a=\"x,y\"".to_string(), "b=\"p,q\"".to_string()]
1690 );
1691 }
1692
1693 #[test]
1696 fn infer_error_display_states_the_reason() {
1697 let err = InferError::NotUltralytics("no names key".into());
1698 let shown = err.to_string();
1699 assert!(shown.contains("no names key"), "{shown}");
1700 assert!(shown.contains("Ultralytics"), "{shown}");
1701
1702 let err = InferError::UnsupportedLayout("rank-3 proto".into());
1703 assert!(err.to_string().contains("rank-3 proto"));
1704 }
1705
1706 #[test]
1707 fn infer_segment_task_without_proto_rejected() {
1708 let s = ModelSignals {
1709 source: ModelSource::Onnx,
1710 inputs: vec![TensorInfo {
1711 name: "images".into(),
1712 shape: vec![1, 3, 640, 640],
1713 dtype: DType::Float32,
1714 quantization: None,
1715 }],
1716 outputs: vec![TensorInfo {
1717 name: "output0".into(),
1718 shape: vec![1, 84, 8400],
1719 dtype: DType::Float32,
1720 quantization: None,
1721 }],
1722 metadata: synthetic_metadata(80, "segment", "False"),
1723 };
1724 assert!(matches!(
1725 infer_ultralytics_schema(&s),
1726 Err(InferError::UnsupportedLayout(_))
1727 ));
1728 }
1729
1730 #[test]
1731 fn infer_detect_task_with_proto_rejected() {
1732 let s = ModelSignals {
1733 source: ModelSource::Onnx,
1734 inputs: vec![TensorInfo {
1735 name: "images".into(),
1736 shape: vec![1, 3, 640, 640],
1737 dtype: DType::Float32,
1738 quantization: None,
1739 }],
1740 outputs: vec![
1741 TensorInfo {
1742 name: "output0".into(),
1743 shape: vec![1, 84, 8400],
1744 dtype: DType::Float32,
1745 quantization: None,
1746 },
1747 TensorInfo {
1748 name: "output1".into(),
1749 shape: vec![1, 32, 160, 160],
1750 dtype: DType::Float32,
1751 quantization: None,
1752 },
1753 ],
1754 metadata: synthetic_metadata(80, "detect", "False"),
1755 };
1756 assert!(matches!(
1757 infer_ultralytics_schema(&s),
1758 Err(InferError::UnsupportedLayout(_))
1759 ));
1760 }
1761
1762 #[test]
1763 fn infer_int8_tflite_classifies_like_float32() {
1764 let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n_int8")).unwrap();
1770 let det = &r.schema.outputs[0];
1771 assert_eq!(det.type_, Some(LogicalType::Detection));
1772 assert_eq!(det.dtype, Some(DType::Float32));
1773 assert_eq!(det.quantization, None);
1774 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1775 assert_eq!(det.normalized, Some(true));
1776 }
1777
1778 #[test]
1779 fn infer_quantized_boundary_carries_quantization() {
1780 let quant = Quantization {
1785 scale: vec![0.02],
1786 zero_point: Some(vec![-5]),
1787 axis: None,
1788 dtype: Some(DType::Int8),
1789 };
1790 let s = ModelSignals {
1791 source: ModelSource::TfLite,
1792 inputs: vec![TensorInfo {
1793 name: "images".into(),
1794 shape: vec![1, 640, 640, 3],
1795 dtype: DType::Float32,
1796 quantization: None,
1797 }],
1798 outputs: vec![TensorInfo {
1799 name: "output0".into(),
1800 shape: vec![1, 84, 8400],
1801 dtype: DType::Int8,
1802 quantization: Some(quant.clone()),
1803 }],
1804 metadata: synthetic_metadata(80, "detect", "False"),
1805 };
1806 let r = infer_ultralytics_schema(&s).unwrap();
1807 let det = &r.schema.outputs[0];
1808 assert_eq!(det.dtype, Some(DType::Int8));
1809 assert_eq!(det.quantization, Some(quant));
1810 }
1811
1812 #[test]
1813 fn infer_rejects_batched_input() {
1814 let s = ModelSignals {
1819 source: ModelSource::Onnx,
1820 inputs: vec![TensorInfo {
1821 name: "images".into(),
1822 shape: vec![4, 3, 640, 640],
1823 dtype: DType::Float32,
1824 quantization: None,
1825 }],
1826 outputs: vec![TensorInfo {
1827 name: "output0".into(),
1828 shape: vec![1, 84, 8400],
1829 dtype: DType::Float32,
1830 quantization: None,
1831 }],
1832 metadata: synthetic_metadata(80, "detect", "False"),
1833 };
1834 match infer_ultralytics_schema(&s) {
1835 Err(InferError::UnsupportedLayout(msg)) => {
1836 assert!(msg.contains("batch 4"), "unexpected message: {msg}");
1837 }
1838 other => panic!("expected UnsupportedLayout, got {other:?}"),
1839 }
1840 }
1841
1842 #[test]
1843 fn infer_rejects_input_without_a_channel_dim() {
1844 let s = ModelSignals {
1845 source: ModelSource::Onnx,
1846 inputs: vec![TensorInfo {
1847 name: "images".into(),
1848 shape: vec![1, 4, 640, 640],
1849 dtype: DType::Float32,
1850 quantization: None,
1851 }],
1852 outputs: vec![TensorInfo {
1853 name: "output0".into(),
1854 shape: vec![1, 84, 8400],
1855 dtype: DType::Float32,
1856 quantization: None,
1857 }],
1858 metadata: synthetic_metadata(80, "detect", "False"),
1859 };
1860 match infer_ultralytics_schema(&s) {
1861 Err(InferError::UnsupportedLayout(msg)) => {
1862 assert!(msg.contains("channel dimension"), "unexpected: {msg}");
1863 }
1864 other => panic!("expected UnsupportedLayout, got {other:?}"),
1865 }
1866 }
1867
1868 #[test]
1869 fn infer_rejects_model_with_no_rank4_input() {
1870 let s = ModelSignals {
1871 source: ModelSource::Onnx,
1872 inputs: vec![TensorInfo {
1873 name: "images".into(),
1874 shape: vec![1, 3],
1875 dtype: DType::Float32,
1876 quantization: None,
1877 }],
1878 outputs: vec![TensorInfo {
1879 name: "output0".into(),
1880 shape: vec![1, 84, 8400],
1881 dtype: DType::Float32,
1882 quantization: None,
1883 }],
1884 metadata: synthetic_metadata(80, "detect", "False"),
1885 };
1886 match infer_ultralytics_schema(&s) {
1887 Err(InferError::UnsupportedLayout(msg)) => {
1888 assert!(msg.contains("no rank-4 model input"), "unexpected: {msg}");
1889 }
1890 other => panic!("expected UnsupportedLayout, got {other:?}"),
1891 }
1892 }
1893
1894 fn det_signals(nc: usize, out_shape: Vec<usize>, end2end: &str) -> ModelSignals {
1896 ModelSignals {
1897 source: ModelSource::Onnx,
1898 inputs: vec![TensorInfo {
1899 name: "images".into(),
1900 shape: vec![1, 3, 640, 640],
1901 dtype: DType::Float32,
1902 quantization: None,
1903 }],
1904 outputs: vec![TensorInfo {
1905 name: "output0".into(),
1906 shape: out_shape,
1907 dtype: DType::Float32,
1908 quantization: None,
1909 }],
1910 metadata: synthetic_metadata(nc, "detect", end2end),
1911 }
1912 }
1913
1914 #[test]
1915 fn infer_end_to_end_accepts_any_max_det_up_to_the_anchor_count() {
1916 for n in [1, 300, 2000, 8400] {
1928 let r = infer_ultralytics_schema(&det_signals(80, vec![1, n, 6], "True"))
1929 .unwrap_or_else(|e| panic!("max_det={n} must infer: {e}"));
1930 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1931 assert_eq!(r.schema.outputs[0].type_, Some(LogicalType::Detections));
1932 assert_eq!(
1933 r.schema.outputs[0].dshape,
1934 vec![
1935 (DimName::Batch, 1),
1936 (DimName::NumBoxes, n),
1937 (DimName::NumFeatures, 6),
1938 ],
1939 "max_det={n}"
1940 );
1941 assert_eq!(r.schema.nms, None, "max_det={n}");
1943 }
1944 }
1945
1946 #[test]
1947 fn infer_end_to_end_rejects_more_rows_than_anchors() {
1948 match infer_ultralytics_schema(&det_signals(80, vec![1, 8401, 6], "True")) {
1952 Err(InferError::UnsupportedLayout(msg)) => {
1953 assert!(msg.contains("N<=8400"), "unexpected message: {msg}");
1954 }
1955 other => panic!("expected UnsupportedLayout, got {other:?}"),
1956 }
1957 }
1958
1959 #[test]
1960 fn infer_legacy_nms_model_is_unaffected_by_max_det() {
1961 let r = infer_ultralytics_schema(&det_signals(80, vec![1, 84, 8400], "False")).unwrap();
1967 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1968 assert_eq!(r.schema.outputs[0].type_, Some(LogicalType::Detection));
1969 assert_eq!(
1970 r.schema.outputs[0].dshape,
1971 vec![
1972 (DimName::Batch, 1),
1973 (DimName::NumFeatures, 84),
1974 (DimName::NumBoxes, 8400),
1975 ]
1976 );
1977 assert_eq!(r.schema.nms, Some(NmsMode::ClassAware));
1981
1982 let t = infer_ultralytics_schema(&det_signals(80, vec![1, 8400, 84], "False")).unwrap();
1985 assert_eq!(t.schema.decoder_version, Some(DecoderVersion::Yolov8));
1986 assert_eq!(
1987 t.schema.outputs[0].dshape,
1988 vec![
1989 (DimName::Batch, 1),
1990 (DimName::NumBoxes, 8400),
1991 (DimName::NumFeatures, 84),
1992 ]
1993 );
1994 }
1995
1996 #[test]
1997 fn infer_pre_nms_wins_when_a_shape_reads_as_both() {
1998 let mut m = synthetic_metadata(2, "detect", "False");
2003 m.remove("end2end");
2004 let s = ModelSignals {
2005 metadata: m,
2006 ..det_signals(2, vec![1, 8400, 6], "False")
2007 };
2008 let r = infer_ultralytics_schema(&s).unwrap();
2009 assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
2010 }
2011
2012 #[test]
2013 fn meta_empty_names_rejected() {
2014 let mut m = BTreeMap::new();
2017 m.insert("names".into(), "{}".into());
2018 match UltralyticsMeta::from_metadata(&m) {
2019 Err(InferError::BadNames(msg)) => assert!(msg.contains("empty"), "{msg}"),
2020 other => panic!("expected BadNames, got {other:?}"),
2021 }
2022 }
2023
2024 #[test]
2025 fn meta_malformed_end2end_is_an_error_not_an_absent_key() {
2026 let mut m = synthetic_metadata(80, "detect", "False");
2030 m.insert("end2end".into(), "yes".into());
2031 match UltralyticsMeta::from_metadata(&m) {
2032 Err(InferError::BadMetadata(msg)) => assert!(msg.contains("end2end"), "{msg}"),
2033 other => panic!("expected BadMetadata, got {other:?}"),
2034 }
2035
2036 m.remove("end2end");
2038 assert_eq!(UltralyticsMeta::from_metadata(&m).unwrap().end2end, None);
2039 }
2040
2041 #[test]
2042 fn meta_foreign_vendor_rejected_but_missing_provenance_allowed() {
2043 let mut m = synthetic_metadata(80, "detect", "False");
2047 m.insert("author".into(), "SomeOtherVendor".into());
2048 assert!(matches!(
2049 UltralyticsMeta::from_metadata(&m),
2050 Err(InferError::NotUltralytics(_))
2051 ));
2052
2053 m.insert("author".into(), "Ultralytics".into());
2055 m.insert("docs".into(), "https://docs.ultralytics.com".into());
2056 assert!(UltralyticsMeta::from_metadata(&m).is_ok());
2057
2058 m.remove("author");
2061 m.remove("docs");
2062 assert!(UltralyticsMeta::from_metadata(&m).is_ok());
2063 }
2064
2065 #[test]
2066 fn infer_zero_row_end_to_end_rejected() {
2067 assert!(matches!(
2070 infer_ultralytics_schema(&det_signals(80, vec![1, 0, 6], "True")),
2071 Err(InferError::UnsupportedLayout(_))
2072 ));
2073 }
2074
2075 #[test]
2076 fn infer_duplicate_matching_outputs_are_ambiguous() {
2077 let input = TensorInfo {
2078 name: "images".into(),
2079 shape: vec![1, 3, 640, 640],
2080 dtype: DType::Float32,
2081 quantization: None,
2082 };
2083 let t = |name: &str, shape: Vec<usize>| TensorInfo {
2084 name: name.into(),
2085 shape,
2086 dtype: DType::Float32,
2087 quantization: None,
2088 };
2089
2090 let pre = ModelSignals {
2093 source: ModelSource::Onnx,
2094 inputs: vec![input.clone()],
2095 outputs: vec![t("a", vec![1, 84, 8400]), t("b", vec![1, 84, 8400])],
2096 metadata: synthetic_metadata(80, "detect", "False"),
2097 };
2098 match infer_ultralytics_schema(&pre) {
2099 Err(InferError::AmbiguousLayout(msg)) => {
2100 assert!(msg.contains("`a`") && msg.contains("`b`"), "{msg}");
2101 }
2102 other => panic!("expected AmbiguousLayout, got {other:?}"),
2103 }
2104
2105 let e2e = ModelSignals {
2107 source: ModelSource::Onnx,
2108 inputs: vec![input],
2109 outputs: vec![t("a", vec![1, 300, 6]), t("b", vec![1, 200, 6])],
2110 metadata: synthetic_metadata(80, "detect", "True"),
2111 };
2112 assert!(matches!(
2113 infer_ultralytics_schema(&e2e),
2114 Err(InferError::AmbiguousLayout(_))
2115 ));
2116 }
2117
2118 #[test]
2119 fn infer_malformed_per_tensor_quantization_rejected() {
2120 let build = |q: Quantization| ModelSignals {
2121 source: ModelSource::TfLite,
2122 inputs: vec![TensorInfo {
2123 name: "images".into(),
2124 shape: vec![1, 640, 640, 3],
2125 dtype: DType::Float32,
2126 quantization: None,
2127 }],
2128 outputs: vec![TensorInfo {
2129 name: "output0".into(),
2130 shape: vec![1, 84, 8400],
2131 dtype: DType::Int8,
2132 quantization: Some(q),
2133 }],
2134 metadata: synthetic_metadata(80, "detect", "False"),
2135 };
2136
2137 match infer_ultralytics_schema(&build(Quantization {
2140 scale: vec![],
2141 zero_point: Some(vec![]),
2142 axis: None,
2143 dtype: Some(DType::Int8),
2144 })) {
2145 Err(InferError::UnsupportedQuantization(msg)) => {
2146 assert!(msg.contains("no scale"), "{msg}");
2147 }
2148 other => panic!("expected UnsupportedQuantization, got {other:?}"),
2149 }
2150
2151 match infer_ultralytics_schema(&build(Quantization {
2154 scale: vec![0.02],
2155 zero_point: Some(vec![-5, -7]),
2156 axis: None,
2157 dtype: Some(DType::Int8),
2158 })) {
2159 Err(InferError::UnsupportedQuantization(msg)) => {
2160 assert!(msg.contains("zero point"), "{msg}");
2161 }
2162 other => panic!("expected UnsupportedQuantization, got {other:?}"),
2163 }
2164
2165 assert!(infer_ultralytics_schema(&build(Quantization {
2167 scale: vec![0.02],
2168 zero_point: None,
2169 axis: None,
2170 dtype: Some(DType::Int8),
2171 }))
2172 .is_ok());
2173 }
2174
2175 #[test]
2176 fn infer_pre_nms_is_independent_of_output_order() {
2177 let det = TensorInfo {
2183 name: "output0".into(),
2184 shape: vec![1, 84, 8400],
2185 dtype: DType::Float32,
2186 quantization: None,
2187 };
2188 let aux = TensorInfo {
2189 name: "boxes".into(),
2190 shape: vec![1, 4, 8400],
2191 dtype: DType::Float32,
2192 quantization: None,
2193 };
2194 let build = |outputs: Vec<TensorInfo>| ModelSignals {
2195 source: ModelSource::Onnx,
2196 inputs: vec![TensorInfo {
2197 name: "images".into(),
2198 shape: vec![1, 3, 640, 640],
2199 dtype: DType::Float32,
2200 quantization: None,
2201 }],
2202 outputs,
2203 metadata: synthetic_metadata(80, "detect", "False"),
2204 };
2205
2206 let det_first = infer_ultralytics_schema(&build(vec![det.clone(), aux.clone()]))
2207 .expect("detection tensor first must classify");
2208 let aux_first = infer_ultralytics_schema(&build(vec![aux, det]))
2209 .expect("an auxiliary anchor-shaped tensor first must not shadow the detection tensor");
2210 assert_eq!(det_first.schema.outputs[0].name, Some("output0".into()));
2211 assert_eq!(aux_first.schema.outputs[0].name, Some("output0".into()));
2212 assert_eq!(det_first.schema, aux_first.schema);
2213 }
2214
2215 #[test]
2216 fn infer_class_count_mismatch_survives_a_full_scan() {
2217 let s = ModelSignals {
2220 source: ModelSource::Onnx,
2221 inputs: vec![TensorInfo {
2222 name: "images".into(),
2223 shape: vec![1, 3, 640, 640],
2224 dtype: DType::Float32,
2225 quantization: None,
2226 }],
2227 outputs: vec![TensorInfo {
2228 name: "output0".into(),
2229 shape: vec![1, 84, 8400],
2230 dtype: DType::Float32,
2231 quantization: None,
2232 }],
2233 metadata: synthetic_metadata(10, "detect", "False"),
2235 };
2236 assert!(matches!(
2237 infer_ultralytics_schema(&s),
2238 Err(InferError::ClassCountMismatch {
2239 expected: 10,
2240 found: 80
2241 })
2242 ));
2243 }
2244
2245 struct FixtureExpectation {
2255 name: &'static str,
2256 version: DecoderVersion,
2257 det_type: LogicalType,
2258 det_dshape: &'static [(DimName, usize)],
2260 normalized: bool,
2261 nms: Option<NmsMode>,
2262 proto: Option<(usize, usize, usize)>,
2264 }
2265
2266 const A: usize = 8400; const NC: usize = 80;
2268
2269 fn fixture_expectations() -> Vec<FixtureExpectation> {
2270 use DimName::{Batch, NumBoxes, NumFeatures};
2271 const DET: &[(DimName, usize)] = &[(Batch, 1), (NumFeatures, 4 + NC), (NumBoxes, A)];
2275 const SEG: &[(DimName, usize)] = &[(Batch, 1), (NumFeatures, 4 + NC + 32), (NumBoxes, A)];
2276 const E2E: &[(DimName, usize)] = &[(Batch, 1), (NumBoxes, 300), (NumFeatures, 6)];
2278 const E2E_SEG: &[(DimName, usize)] = &[(Batch, 1), (NumBoxes, 300), (NumFeatures, 6 + 32)];
2279
2280 let pre_nms = |name, det_dshape, normalized, proto| FixtureExpectation {
2281 name,
2282 version: DecoderVersion::Yolov8,
2283 det_type: LogicalType::Detection,
2284 det_dshape,
2285 normalized,
2286 nms: Some(NmsMode::ClassAware),
2287 proto,
2288 };
2289 let e2e = |name, det_dshape, normalized, proto| FixtureExpectation {
2290 name,
2291 version: DecoderVersion::Yolo26,
2292 det_type: LogicalType::Detections,
2293 det_dshape,
2294 normalized,
2295 nms: None,
2297 proto,
2298 };
2299 const P: Option<(usize, usize, usize)> = Some((32, 160, 160));
2302 vec![
2303 pre_nms("yolov8n", DET, false, None),
2304 pre_nms("yolo11n", DET, false, None),
2305 pre_nms("yolov8n-seg", SEG, false, P),
2306 pre_nms("yolo11n-seg", SEG, false, P),
2307 pre_nms("yolov8n_float32", DET, true, None),
2308 pre_nms("yolov8n_int8", DET, true, None),
2309 pre_nms("yolov8n-seg_float32", SEG, true, P),
2310 pre_nms("yolov8n-seg_int8", SEG, true, P),
2311 e2e("yolo26n", E2E, false, None),
2312 e2e("yolo26n-seg", E2E_SEG, false, P),
2313 e2e("yolo26n_float32", E2E, true, None),
2314 pre_nms("yolo26n_coreml", DET, false, None),
2322 ]
2323 }
2324
2325 #[test]
2326 fn every_captured_fixture_infers_the_expected_schema() {
2327 for want in fixture_expectations() {
2328 let name = want.name;
2329 let r = infer_ultralytics_schema(&signals_from_fixture(name))
2330 .unwrap_or_else(|e| panic!("{name}: inference failed: {e}"));
2331
2332 assert_eq!(r.labels.len(), NC, "{name}: class count");
2333 assert_eq!(
2334 r.labels[0], "person",
2335 "{name}: labels must be index-ordered"
2336 );
2337 assert_eq!(r.labels[NC - 1], "toothbrush", "{name}: labels index order");
2338 assert_eq!(
2339 r.schema.decoder_version,
2340 Some(want.version),
2341 "{name}: version"
2342 );
2343 assert_eq!(r.schema.nms, want.nms, "{name}: nms mode");
2344
2345 let det = &r.schema.outputs[0];
2346 assert_eq!(det.type_, Some(want.det_type), "{name}: detection type");
2347 assert_eq!(det.dshape, want.det_dshape, "{name}: detection dshape");
2348 assert_eq!(det.normalized, Some(want.normalized), "{name}: normalized");
2349 assert_eq!(
2350 det.decoder,
2351 Some(DecoderKind::Ultralytics),
2352 "{name}: decoder"
2353 );
2354 assert_eq!(det.encoding, Some(BoxEncoding::Direct), "{name}: encoding");
2355 assert_eq!(
2356 det.score_format,
2357 Some(ScoreFormat::PerClass),
2358 "{name}: scores"
2359 );
2360
2361 match want.proto {
2362 None => {
2363 assert_eq!(r.schema.outputs.len(), 1, "{name}: detection-only");
2364 }
2365 Some((k, ph, pw)) => {
2366 assert_eq!(r.schema.outputs.len(), 2, "{name}: detection + proto");
2367 let proto = &r.schema.outputs[1];
2368 assert_eq!(proto.type_, Some(LogicalType::Protos), "{name}: proto type");
2369 assert_eq!(
2370 proto.dshape,
2371 vec![
2372 (DimName::Batch, 1),
2373 (DimName::NumProtos, k),
2374 (DimName::Height, ph),
2375 (DimName::Width, pw),
2376 ],
2377 "{name}: proto dshape"
2378 );
2379 }
2380 }
2381
2382 let task = if want.proto.is_some() {
2383 "segment"
2384 } else {
2385 "detect"
2386 };
2387 assert!(
2388 r.description.contains(task) && r.description.contains("Ultralytics"),
2389 "{name}: description was `{}`",
2390 r.description
2391 );
2392 }
2393 }
2394
2395 #[test]
2396 fn every_fixture_is_asserted() {
2397 let dir = infer_fixture_dir();
2401 let mut on_disk: Vec<String> = std::fs::read_dir(&dir)
2402 .unwrap_or_else(|e| panic!("{} exists: {e}", dir.display()))
2403 .filter_map(|e| {
2404 let name = e.ok()?.file_name().into_string().ok()?;
2405 name.strip_suffix(".signals.json").map(str::to_string)
2406 })
2407 .collect();
2408 on_disk.sort();
2409
2410 let mut asserted: Vec<String> = fixture_expectations()
2411 .iter()
2412 .map(|f| f.name.to_string())
2413 .collect();
2414 asserted.sort();
2415
2416 assert_eq!(
2417 on_disk, asserted,
2418 "every *.signals.json fixture must appear in fixture_expectations()"
2419 );
2420 }
2421
2422 #[test]
2423 fn infer_other_source_refuses_rather_than_guessing_normalization() {
2424 let s = ModelSignals {
2431 source: ModelSource::Other,
2432 inputs: vec![TensorInfo {
2433 name: "images".into(),
2434 shape: vec![1, 3, 640, 640],
2435 dtype: DType::Float32,
2436 quantization: None,
2437 }],
2438 outputs: vec![TensorInfo {
2439 name: "output0".into(),
2440 shape: vec![1, 84, 8400],
2441 dtype: DType::Float32,
2442 quantization: None,
2443 }],
2444 metadata: synthetic_metadata(80, "detect", "False"),
2445 };
2446 assert!(matches!(
2447 infer_ultralytics_schema(&s),
2448 Err(InferError::UnknownBoxConvention)
2449 ));
2450
2451 let ok = ModelSignals {
2454 source: ModelSource::Onnx,
2455 ..s
2456 };
2457 assert_eq!(
2458 infer_ultralytics_schema(&ok).unwrap().schema.outputs[0].normalized,
2459 Some(false)
2460 );
2461 }
2462
2463 #[test]
2464 fn infer_per_channel_quantization_rejected() {
2465 let s = ModelSignals {
2474 source: ModelSource::TfLite,
2475 inputs: vec![TensorInfo {
2476 name: "images".into(),
2477 shape: vec![1, 640, 640, 3],
2478 dtype: DType::Float32,
2479 quantization: None,
2480 }],
2481 outputs: vec![TensorInfo {
2482 name: "output0".into(),
2483 shape: vec![1, 84, 8400],
2484 dtype: DType::Int8,
2485 quantization: Some(Quantization {
2486 scale: vec![0.02, 0.03],
2487 zero_point: Some(vec![-5, -7]),
2488 axis: Some(1),
2489 dtype: Some(DType::Int8),
2490 }),
2491 }],
2492 metadata: synthetic_metadata(80, "detect", "False"),
2493 };
2494 match infer_ultralytics_schema(&s) {
2495 Err(InferError::UnsupportedQuantization(msg)) => {
2496 assert!(msg.contains("per-channel"), "unexpected message: {msg}");
2497 assert!(
2498 msg.contains("output0"),
2499 "message must name the tensor: {msg}"
2500 );
2501 }
2502 other => panic!("expected UnsupportedQuantization, got {other:?}"),
2503 }
2504 }
2505
2506 #[test]
2507 fn infer_per_channel_proto_quantization_rejected() {
2508 let s = ModelSignals {
2511 source: ModelSource::TfLite,
2512 inputs: vec![TensorInfo {
2513 name: "images".into(),
2514 shape: vec![1, 640, 640, 3],
2515 dtype: DType::Float32,
2516 quantization: None,
2517 }],
2518 outputs: vec![
2519 TensorInfo {
2520 name: "output0".into(),
2521 shape: vec![1, 116, 8400],
2522 dtype: DType::Float32,
2523 quantization: None,
2524 },
2525 TensorInfo {
2526 name: "output1".into(),
2527 shape: vec![1, 32, 160, 160],
2528 dtype: DType::Int8,
2529 quantization: Some(Quantization {
2530 scale: vec![0.02, 0.03],
2531 zero_point: Some(vec![-5, -7]),
2532 axis: Some(1),
2533 dtype: Some(DType::Int8),
2534 }),
2535 },
2536 ],
2537 metadata: synthetic_metadata(80, "segment", "False"),
2538 };
2539 match infer_ultralytics_schema(&s) {
2540 Err(InferError::UnsupportedQuantization(msg)) => {
2541 assert!(
2542 msg.contains("output1"),
2543 "message must name the proto: {msg}"
2544 );
2545 }
2546 other => panic!("expected UnsupportedQuantization, got {other:?}"),
2547 }
2548 }
2549
2550 #[test]
2551 fn infer_integer_output_without_quant_rejected() {
2552 let s = ModelSignals {
2556 source: ModelSource::TfLite,
2557 inputs: vec![TensorInfo {
2558 name: "images".into(),
2559 shape: vec![1, 640, 640, 3],
2560 dtype: DType::Float32,
2561 quantization: None,
2562 }],
2563 outputs: vec![TensorInfo {
2564 name: "output0".into(),
2565 shape: vec![1, 84, 8400],
2566 dtype: DType::Int8,
2567 quantization: None,
2568 }],
2569 metadata: synthetic_metadata(80, "detect", "False"),
2570 };
2571 match infer_ultralytics_schema(&s) {
2572 Err(InferError::UnsupportedLayout(msg)) => {
2573 assert!(
2574 msg.contains("quantization"),
2575 "unexpected UnsupportedLayout message: {msg}"
2576 );
2577 }
2578 other => panic!("expected UnsupportedLayout, got {other:?}"),
2579 }
2580 }
2581
2582 #[test]
2583 fn infer_pre_nms_rejects_non_unit_batch() {
2584 let mut s = signals_from_fixture("yolov8n");
2589 s.outputs[0].shape = vec![2, 84, 8400];
2590 assert!(matches!(
2591 infer_ultralytics_schema(&s),
2592 Err(InferError::UnsupportedLayout(_))
2593 ));
2594 }
2595
2596 #[test]
2597 fn coreml_source_resolves_a_box_convention() {
2598 let s = ModelSignals {
2601 source: ModelSource::CoreMl,
2602 inputs: vec![TensorInfo {
2603 name: "images".into(),
2604 shape: vec![1, 3, 640, 640],
2605 dtype: DType::Float16,
2606 quantization: None,
2607 }],
2608 outputs: vec![TensorInfo {
2609 name: "output0".into(),
2610 shape: vec![1, 84, 8400],
2611 dtype: DType::Float16,
2612 quantization: None,
2613 }],
2614 metadata: synthetic_metadata(80, "detect", "False"),
2615 };
2616 let r = infer_ultralytics_schema(&s)
2617 .expect("CoreML signals must infer, not raise UnknownBoxConvention");
2618 let o = &r.schema.outputs[0];
2619 assert_eq!(
2620 o.normalized,
2621 Some(false),
2622 "Ultralytics CoreML exports trace the raw model, so boxes are \
2623 pixel-space exactly as in the ONNX export"
2624 );
2625 }
2626
2627 #[test]
2628 fn coreml_nms_pipeline_export_is_refused_not_classified() {
2629 let s = ModelSignals {
2643 source: ModelSource::CoreMl,
2644 inputs: vec![TensorInfo {
2645 name: "image".into(),
2646 shape: vec![1, 3, 640, 640],
2647 dtype: DType::Float16,
2648 quantization: None,
2649 }],
2650 outputs: vec![
2651 TensorInfo {
2652 name: "confidence".into(),
2653 shape: vec![],
2654 dtype: DType::Float32,
2655 quantization: None,
2656 },
2657 TensorInfo {
2658 name: "coordinates".into(),
2659 shape: vec![],
2660 dtype: DType::Float32,
2661 quantization: None,
2662 },
2663 ],
2664 metadata: synthetic_metadata(80, "detect", "False"),
2665 };
2666 assert!(matches!(
2667 infer_ultralytics_schema(&s),
2668 Err(InferError::UnsupportedLayout(_))
2669 ));
2670 }
2671}