use std::collections::HashMap;
use hotcoco::convert::{
ConvertError, coco_to_dota, coco_to_oid, coco_to_voc, coco_to_yolo, cvat_to_coco, dota_to_coco,
voc_to_coco, yolo_to_coco,
};
use hotcoco::types::{Annotation, Category, Dataset, Image, Segmentation};
fn one_image_dataset(file_name: &str, cats: &[&str], bboxes: &[(u64, [f64; 4])]) -> Dataset {
Dataset {
info: None,
images: vec![Image {
id: 1,
file_name: file_name.into(),
width: 640,
height: 480,
..Default::default()
}],
annotations: bboxes
.iter()
.enumerate()
.map(|(i, &(cat_id, bbox))| Annotation {
id: (i + 1) as u64,
image_id: 1,
category_id: cat_id,
bbox: Some(bbox),
area: Some(bbox[2] * bbox[3]),
..Default::default()
})
.collect(),
categories: cats
.iter()
.enumerate()
.map(|(i, name)| Category {
id: (i + 1) as u64,
name: (*name).into(),
..Default::default()
})
.collect(),
licenses: vec![],
}
}
#[test]
fn cvat_start_event_shapes_with_attribute_children_import() {
let dir = tempfile::tempdir().expect("tempdir");
let xml_path = dir.path().join("annotations.xml");
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
<annotations>
<version>1.1</version>
<meta><task><labels>
<label><name>person</name></label>
<label><name>region</name></label>
</labels></task></meta>
<image id="0" name="test.jpg" width="640" height="480">
<box label="person" xtl="100" ytl="50" xbr="300" ybr="400" occluded="0">
<attribute name="pose">standing</attribute>
<attribute name="verified">true</attribute>
</box>
<polygon label="region" points="10.0,20.0;50.0,20.0;50.0,80.0" occluded="0">
<attribute name="kind">roi</attribute>
</polygon>
</image>
</annotations>"#;
std::fs::write(&xml_path, xml).expect("write xml");
let (dataset, stats) = cvat_to_coco(&xml_path).expect("cvat_to_coco");
assert_eq!(dataset.annotations.len(), 2, "both pair-form shapes import");
assert_eq!(stats.boxes, 1);
assert_eq!(stats.polygons, 1);
assert_eq!(stats.skipped_unsupported, 0, "<attribute> is not a shape");
let box_ann = &dataset.annotations[0];
assert_eq!(box_ann.bbox.expect("bbox"), [100.0, 50.0, 200.0, 350.0]);
let poly_ann = &dataset.annotations[1];
assert!(matches!(
poly_ann.segmentation,
Some(Segmentation::Polygon(_))
));
}
#[test]
fn cvat_missing_coordinate_attribute_is_an_error() {
let dir = tempfile::tempdir().expect("tempdir");
let xml_path = dir.path().join("annotations.xml");
let xml = r#"<annotations>
<image id="0" name="a.jpg" width="10" height="10">
<box label="thing" xtl="1" ytl="2" xbr="3" occluded="0"/>
</image>
</annotations>"#;
std::fs::write(&xml_path, xml).expect("write xml");
let err = cvat_to_coco(&xml_path).expect_err("missing ybr must fail");
let msg = err.to_string();
assert!(msg.contains("ybr"), "error names the attribute: {msg}");
assert!(
msg.contains("annotations.xml"),
"error names the file: {msg}"
);
}
#[test]
fn cvat_degenerate_polygon_is_skipped_and_counted() {
let dir = tempfile::tempdir().expect("tempdir");
let xml_path = dir.path().join("annotations.xml");
let xml = r#"<annotations>
<image id="0" name="a.jpg" width="100" height="100">
<polygon label="thing" points="10,10;50,50" occluded="0"/>
<box label="thing" xtl="10" ytl="10" xbr="50" ybr="50" occluded="0"/>
</image>
</annotations>"#;
std::fs::write(&xml_path, xml).expect("write xml");
let (dataset, stats) = cvat_to_coco(&xml_path).expect("file must survive");
assert_eq!(dataset.annotations.len(), 1, "the box still imports");
assert_eq!(
stats.skipped_degenerate, 1,
"the 2-point polygon is counted"
);
let no_points = r#"<annotations>
<image id="0" name="a.jpg" width="100" height="100">
<polygon label="thing" occluded="0"/>
</image>
</annotations>"#;
std::fs::write(&xml_path, no_points).expect("write xml");
let err = cvat_to_coco(&xml_path).expect_err("missing points attribute");
assert!(err.to_string().contains("points"), "error was: {err}");
}
#[test]
fn cvat_video_export_is_a_clear_error() {
let dir = tempfile::tempdir().expect("tempdir");
let xml_path = dir.path().join("annotations.xml");
let xml = r#"<annotations>
<version>1.1</version>
<track id="0" label="person">
<box frame="0" xtl="1" ytl="2" xbr="3" ybr="4" outside="0"/>
</track>
</annotations>"#;
std::fs::write(&xml_path, xml).expect("write xml");
let err = cvat_to_coco(&xml_path).expect_err("video export must not import as empty");
assert!(err.to_string().contains("video"), "error was: {err}");
}
#[test]
fn cvat_export_counts_degenerate_polygons_and_falls_back_to_bbox() {
let mut dataset = one_image_dataset("a.jpg", &["thing"], &[(1, [10.0, 10.0, 20.0, 20.0])]);
dataset.annotations[0].segmentation =
Some(Segmentation::Polygon(vec![vec![1.0, 1.0, 2.0, 2.0]]));
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("out.xml");
let stats = hotcoco::convert::coco_to_cvat(&dataset, &out).expect("coco_to_cvat");
assert_eq!(stats.skipped_degenerate, 1);
assert_eq!(stats.boxes, 1, "bbox fallback");
assert_eq!(stats.polygons, 0);
assert_eq!(stats.skipped_no_geometry, 0);
let (recovered, _) = cvat_to_coco(&out).expect("re-import");
assert_eq!(recovered.annotations.len(), 1);
assert_eq!(
recovered.annotations[0].bbox.expect("bbox"),
[10.0, 10.0, 20.0, 20.0]
);
}
#[test]
fn yolo_block_mapping_names_parse() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("data.yaml"),
"path: ../datasets/coco\ntrain: images/train\nnc: 2\nnames:\n 0: person\n 1: bicycle\nextra: 1\n",
)
.expect("write data.yaml");
std::fs::write(dir.path().join("img.txt"), "1 0.5 0.5 0.2 0.2\n").expect("write label");
let dims: HashMap<String, (u32, u32)> = [("img".to_string(), (100u32, 100u32))].into();
let ds = yolo_to_coco(dir.path(), &dims).expect("yolo_to_coco");
assert_eq!(ds.categories.len(), 2);
assert_eq!(ds.categories[0].name, "person");
assert_eq!(ds.categories[1].name, "bicycle");
assert_eq!(ds.annotations[0].category_id, 2, "class 1 → bicycle");
}
#[test]
fn yolo_block_list_names_parse() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("data.yaml"),
"nc: 2\nnames:\n - person\n - bicycle\n",
)
.expect("write data.yaml");
std::fs::write(dir.path().join("img.txt"), "0 0.5 0.5 0.2 0.2\n").expect("write label");
let dims: HashMap<String, (u32, u32)> = [("img".to_string(), (100u32, 100u32))].into();
let ds = yolo_to_coco(dir.path(), &dims).expect("yolo_to_coco");
assert_eq!(ds.categories.len(), 2);
assert_eq!(ds.categories[0].name, "person");
}
#[test]
fn yolo_block_mapping_with_gap_is_an_error() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("data.yaml"),
"names:\n 0: person\n 2: bicycle\n",
)
.expect("write data.yaml");
let err = yolo_to_coco(dir.path(), &HashMap::new()).expect_err("gap in indices");
assert!(err.to_string().contains("gaps"), "error was: {err}");
}
#[test]
fn yolo_names_containing_commas_round_trip() {
let dataset = one_image_dataset(
"img.jpg",
&["Camera, still", "dog"],
&[(1, [10.0, 10.0, 20.0, 20.0]), (2, [40.0, 40.0, 20.0, 20.0])],
);
let dir = tempfile::tempdir().expect("tempdir");
coco_to_yolo(&dataset, dir.path()).expect("coco_to_yolo");
let yaml = std::fs::read_to_string(dir.path().join("data.yaml")).expect("data.yaml");
assert!(
yaml.contains("'Camera, still'"),
"comma name is quoted: {yaml}"
);
let dims: HashMap<String, (u32, u32)> = [("img".to_string(), (640u32, 480u32))].into();
let recovered = yolo_to_coco(dir.path(), &dims).expect("yolo_to_coco");
assert_eq!(
recovered.categories.len(),
2,
"comma did not split the list"
);
assert_eq!(recovered.categories[0].name, "Camera, still");
assert_eq!(recovered.categories[1].name, "dog");
}
#[test]
fn yolo_import_missing_dims_is_an_error() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("data.yaml"), "nc: 1\nnames: [thing]\n")
.expect("write data.yaml");
std::fs::write(dir.path().join("img.txt"), "0 0.5 0.5 0.2 0.2\n").expect("write label");
let err = yolo_to_coco(dir.path(), &HashMap::new()).expect_err("no dims for img");
assert!(
matches!(err, ConvertError::MissingImageDimensions(_)),
"got: {err}"
);
}
#[test]
fn yolo_label_errors_name_file_and_line() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("data.yaml"), "nc: 1\nnames: [thing]\n")
.expect("write data.yaml");
std::fs::write(
dir.path().join("img.txt"),
"0 0.5 0.5 0.2 0.2\n0 0.5 oops 0.2 0.2\n",
)
.expect("write label");
let dims: HashMap<String, (u32, u32)> = [("img".to_string(), (100u32, 100u32))].into();
let err = yolo_to_coco(dir.path(), &dims).expect_err("bad cy");
let msg = err.to_string();
assert!(msg.contains("img.txt"), "names the file: {msg}");
assert!(msg.contains("line 2"), "names the line: {msg}");
}
#[test]
fn voc_float_coordinates_parse() {
let dir = tempfile::tempdir().expect("tempdir");
let ann_dir = dir.path().join("Annotations");
std::fs::create_dir_all(&ann_dir).expect("mkdir");
let xml = r"<annotation>
<filename>a.jpg</filename>
<size><width>640</width><height>480</height></size>
<object>
<name>person</name>
<bndbox><xmin>156.00</xmin><ymin>50.5</ymin><xmax>300.00</xmax><ymax>400.0</ymax></bndbox>
</object>
</annotation>";
std::fs::write(ann_dir.join("a.xml"), xml).expect("write xml");
let ds = voc_to_coco(dir.path()).expect("float coords must parse");
assert_eq!(ds.annotations.len(), 1);
let bbox = ds.annotations[0].bbox.expect("bbox");
assert!((bbox[0] - 155.0).abs() < 1e-9, "x: {}", bbox[0]);
assert!((bbox[2] - 145.0).abs() < 1e-9, "w: {}", bbox[2]);
}
#[test]
fn voc_round_trip_is_exact_and_keeps_iscrowd() {
let mut dataset = one_image_dataset(
"img.jpg",
&["thing"],
&[(1, [10.0, 20.0, 30.0, 40.0]), (1, [0.0, 0.0, 200.0, 150.0])],
);
dataset.annotations[0].iscrowd = true;
let dir = tempfile::tempdir().expect("tempdir");
coco_to_voc(&dataset, dir.path()).expect("coco_to_voc");
let recovered = voc_to_coco(dir.path()).expect("voc_to_coco");
assert_eq!(recovered.annotations.len(), 2);
assert_eq!(
recovered.annotations[0].bbox.expect("bbox"),
[10.0, 20.0, 30.0, 40.0],
"integer boxes round-trip exactly"
);
assert_eq!(
recovered.annotations[1].bbox.expect("bbox"),
[0.0, 0.0, 200.0, 150.0]
);
assert!(recovered.annotations[0].iscrowd, "iscrowd survives");
assert!(!recovered.annotations[1].iscrowd);
}
#[test]
fn voc_errors_name_the_file() {
let dir = tempfile::tempdir().expect("tempdir");
let ann_dir = dir.path().join("Annotations");
std::fs::create_dir_all(&ann_dir).expect("mkdir");
let xml = r"<annotation>
<filename>a.jpg</filename>
<size><width>640</width><height>480</height></size>
<object>
<name>person</name>
<bndbox><xmin>oops</xmin><ymin>1</ymin><xmax>2</xmax><ymax>2</ymax></bndbox>
</object>
</annotation>";
std::fs::write(ann_dir.join("bad.xml"), xml).expect("write xml");
let err = voc_to_coco(dir.path()).expect_err("bad coordinate");
assert!(err.to_string().contains("bad.xml"), "error was: {err}");
}
#[test]
fn exporters_reject_colliding_file_stems() {
let mut dataset = one_image_dataset("train/img.jpg", &["thing"], &[(1, [1.0, 1.0, 2.0, 2.0])]);
dataset.images.push(Image {
id: 2,
file_name: "val/img.jpg".into(),
width: 640,
height: 480,
..Default::default()
});
let dir = tempfile::tempdir().expect("tempdir");
let is_collision = |e: &ConvertError| matches!(e, ConvertError::StemCollision { .. });
let err = coco_to_yolo(&dataset, dir.path()).expect_err("yolo");
assert!(is_collision(&err), "yolo: {err}");
let err = coco_to_voc(&dataset, dir.path()).expect_err("voc");
assert!(is_collision(&err), "voc: {err}");
let err = coco_to_dota(&dataset, dir.path()).expect_err("dota");
assert!(is_collision(&err), "dota: {err}");
let err = coco_to_oid(&dataset, &dir.path().join("out.csv")).expect_err("oid");
assert!(is_collision(&err), "oid: {err}");
}
#[test]
fn dota_malformed_line_is_an_error_with_context() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("P0001.txt"),
"0.0 0.0 4.0 0.0 4.0 3.0 0.0 3.0 plane 0\n1.0 2.0 nonsense\n",
)
.expect("write label");
let err = dota_to_coco(dir.path(), None, &HashMap::new()).expect_err("short line");
let msg = err.to_string();
assert!(msg.contains("P0001.txt"), "names the file: {msg}");
assert!(msg.contains("line 2"), "names the line: {msg}");
std::fs::write(
dir.path().join("P0001.txt"),
"0.0 0.0 4.0 0.0 4.0 3.0 0.0 3.0 plane notanumber\n",
)
.expect("write label");
let err = dota_to_coco(dir.path(), None, &HashMap::new()).expect_err("bad difficulty");
assert!(err.to_string().contains("difficulty"), "error was: {err}");
}
#[test]
fn dota_metadata_header_lines_are_skipped() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("P0001.txt"),
"imagesource:GoogleEarth\ngsd:0.146343\n0.0 0.0 4.0 0.0 4.0 3.0 0.0 3.0 plane 0\n",
)
.expect("write label");
let ds = dota_to_coco(dir.path(), None, &HashMap::new()).expect("metadata lines skipped");
assert_eq!(ds.annotations.len(), 1);
assert_eq!(ds.categories[0].name, "plane");
assert_eq!(ds.images[0].file_name, "P0001");
assert_eq!((ds.images[0].width, ds.images[0].height), (0, 0));
}
#[test]
fn oid_errors_name_the_file() {
let dir = tempfile::tempdir().expect("tempdir");
let csv = dir.path().join("boxes.csv");
std::fs::write(
&csv,
"ImageID,LabelName,XMin,XMax,YMin,YMax\nimg1,/m/01,oops,0.5,0.3,0.4\n",
)
.expect("write csv");
let err = hotcoco::convert::oid_to_coco(&csv, None, &HashMap::new()).expect_err("bad XMin");
let msg = err.to_string();
assert!(msg.contains("boxes.csv"), "names the file: {msg}");
assert!(msg.contains("line 2"), "names the line: {msg}");
}
#[test]
fn coco_to_oid_missing_dims_is_an_error() {
let mut dataset = one_image_dataset("img.jpg", &["thing"], &[(1, [1.0, 1.0, 2.0, 2.0])]);
dataset.images[0].width = 0;
let dir = tempfile::tempdir().expect("tempdir");
let err = coco_to_oid(&dataset, &dir.path().join("out.csv")).expect_err("zero width");
assert!(
matches!(err, ConvertError::MissingImageDimensions(_)),
"got: {err}"
);
}
#[test]
fn exporters_reject_unknown_category_ids() {
let mut dataset = one_image_dataset("img.jpg", &["thing"], &[(1, [1.0, 1.0, 2.0, 2.0])]);
dataset.annotations[0].category_id = 99;
let dir = tempfile::tempdir().expect("tempdir");
let err = coco_to_yolo(&dataset, dir.path()).expect_err("dangling category");
assert!(
matches!(
err,
ConvertError::UnknownCategory {
ann_id: 1,
category_id: 99
}
),
"got: {err}"
);
let err = coco_to_voc(&dataset, dir.path()).expect_err("dangling category");
assert!(
matches!(err, ConvertError::UnknownCategory { .. }),
"voc: {err}"
);
let err = coco_to_oid(&dataset, &dir.path().join("out.csv")).expect_err("dangling category");
assert!(
matches!(err, ConvertError::UnknownCategory { .. }),
"oid: {err}"
);
}