#![allow(clippy::unwrap_used)]
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use hotcoco::convert::{
coco_to_cvat, coco_to_dota, coco_to_oid, coco_to_voc, coco_to_yolo, cvat_to_coco, dota_to_coco,
oid_results_to_anns, oid_to_coco, voc_to_coco, yolo_to_coco,
};
use hotcoco::params::IouType;
use hotcoco::report::Provenance;
use hotcoco::types::{Annotation, Category, Dataset, Image, Segmentation};
use hotcoco::{COCO, COCOeval, Hierarchy, quality};
fn fixtures_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
}
fn ann(id: u64, bbox: [f64; 4]) -> Annotation {
Annotation {
id,
image_id: 1,
category_id: 1,
bbox: Some(bbox),
area: Some(bbox[2] * bbox[3]),
..Default::default()
}
}
fn det(id: u64, bbox: [f64; 4], score: f64) -> Annotation {
Annotation {
score: Some(score),
..ann(id, bbox)
}
}
trait AnnExt {
fn group_of(self) -> Self;
fn crowd(self) -> Self;
fn in_img(self, img_id: u64) -> Self;
fn in_cat(self, cat_id: u64) -> Self;
fn mask(self, seg: Segmentation) -> Self;
fn kpts(self, kpts: Vec<f64>) -> Self;
fn with_area(self, area: f64) -> Self;
}
impl AnnExt for Annotation {
fn group_of(mut self) -> Self {
self.is_group_of = Some(true);
self
}
fn crowd(mut self) -> Self {
self.iscrowd = true;
self
}
fn in_img(mut self, img_id: u64) -> Self {
self.image_id = img_id;
self
}
fn in_cat(mut self, cat_id: u64) -> Self {
self.category_id = cat_id;
self
}
fn mask(mut self, seg: Segmentation) -> Self {
self.segmentation = Some(seg);
self
}
fn kpts(mut self, kpts: Vec<f64>) -> Self {
let visible = kpts.iter().skip(2).step_by(3).filter(|&&v| v > 0.0).count();
self.num_keypoints = Some(visible as u32);
self.keypoints = Some(kpts);
self
}
fn with_area(mut self, area: f64) -> Self {
self.area = Some(area);
self
}
}
fn img(id: u64) -> Image {
Image {
id,
file_name: format!("img{id}.jpg"),
height: 640,
width: 640,
..Default::default()
}
}
fn cat(id: u64, name: &str) -> Category {
Category {
id,
name: name.into(),
..Default::default()
}
}
fn dataset(images: Vec<Image>, categories: Vec<Category>, annotations: Vec<Annotation>) -> Dataset {
Dataset {
info: None,
images,
annotations,
categories,
licenses: vec![],
}
}
fn iou_of(a: [f64; 4], b: [f64; 4]) -> f64 {
let (ax2, ay2) = (a[0] + a[2], a[1] + a[3]);
let (bx2, by2) = (b[0] + b[2], b[1] + b[3]);
let iw = (ax2.min(bx2) - a[0].max(b[0])).max(0.0);
let ih = (ay2.min(by2) - a[1].max(b[1])).max(0.0);
let inter = iw * ih;
let union = a[2] * a[3] + b[2] * b[3] - inter;
if union > 0.0 { inter / union } else { 0.0 }
}
fn ioa_of(a: [f64; 4], b: [f64; 4]) -> f64 {
let (ax2, ay2) = (a[0] + a[2], a[1] + a[3]);
let (bx2, by2) = (b[0] + b[2], b[1] + b[3]);
let iw = (ax2.min(bx2) - a[0].max(b[0])).max(0.0);
let ih = (ay2.min(by2) - a[1].max(b[1])).max(0.0);
let area_a = a[2] * a[3];
if area_a > 0.0 { iw * ih / area_a } else { 0.0 }
}
fn rect_mask(h: u32, w: u32, x: u32, y: u32, rw: u32, rh: u32) -> Segmentation {
assert!(x + rw <= w && y + rh <= h, "rectangle must fit the image");
assert!(rw > 0 && rh > 0, "rectangle must have pixels");
let mut counts = vec![x * h + y];
for col in 0..rw {
counts.push(rh);
if col + 1 < rw {
counts.push(h - rh);
}
}
counts.push((h - y - rh) + (w - x - rw) * h);
Segmentation::UncompressedRle {
size: [h, w],
counts,
}
}
#[test]
fn test_load_gt() {
let gt_path = fixtures_dir().join("gt.json");
let coco = COCO::new(>_path).expect("Failed to load GT");
assert_eq!(coco.dataset.images.len(), 3);
assert_eq!(coco.dataset.annotations.len(), 5);
assert_eq!(coco.dataset.categories.len(), 2);
}
#[test]
fn test_load_res() {
let gt_path = fixtures_dir().join("gt.json");
let dt_path = fixtures_dir().join("dt.json");
let coco_gt = COCO::new(>_path).expect("Failed to load GT");
let coco_dt = coco_gt.load_res(&dt_path).expect("Failed to load DT");
assert_eq!(coco_dt.dataset.annotations.len(), 7);
for ann in &coco_dt.dataset.annotations {
assert!(ann.score.is_some());
}
}
#[test]
fn test_load_gt_tolerates_non_finite_floats() {
let json = r#"{
"images": [
{"id": 1, "width": 100, "height": 100, "file_name": "Infinity_scan.jpg"}
],
"annotations": [
{"id": 1, "image_id": 1, "category_id": 1, "bbox": [10.0, 20.0, 30.0, 40.0], "area": NaN, "score": Infinity},
{"id": 2, "image_id": 1, "category_id": 1, "bbox": [1.0, 2.0, 3.0, 4.0], "area": 12.0, "score": -Infinity}
],
"categories": [{"id": 1, "name": "thing"}]
}"#;
let path = std::env::temp_dir().join("hotcoco_nan_gt_test.json");
std::fs::write(&path, json).expect("write temp fixture");
let coco = COCO::new(&path).expect("Failed to load GT with non-finite floats");
assert_eq!(coco.dataset.images.len(), 1);
assert_eq!(coco.dataset.annotations.len(), 2);
assert_eq!(coco.dataset.categories.len(), 1);
let a0 = &coco.dataset.annotations[0];
assert_eq!(a0.area, None, "NaN area should become None");
assert_eq!(a0.score, None, "Infinity score should become None");
let a1 = &coco.dataset.annotations[1];
assert_eq!(a1.area, Some(12.0), "finite area must be preserved");
assert_eq!(a1.score, None, "-Infinity score should become None");
assert_eq!(coco.dataset.images[0].file_name, "Infinity_scan.jpg");
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_bbox_evaluation_runs() {
let gt_path = fixtures_dir().join("gt.json");
let dt_path = fixtures_dir().join("dt.json");
let coco_gt = COCO::new(>_path).expect("Failed to load GT");
let coco_dt = coco_gt.load_res(&dt_path).expect("Failed to load DT");
let mut coco_eval = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
coco_eval.evaluate();
coco_eval.accumulate();
let eval = coco_eval.accumulated().expect("Accumulate should set eval");
assert_eq!(eval.shape.t, 10); assert_eq!(eval.shape.r, 101); assert_eq!(eval.shape.k, 2); assert_eq!(eval.shape.a, 4); assert_eq!(eval.shape.m, 3);
let has_valid = eval.precision.iter().any(|&v| v >= 0.0);
assert!(has_valid, "Should have some valid precision values");
let has_recall = eval.recall.iter().any(|&v| v >= 0.0);
assert!(has_recall, "Should have some valid recall values");
let ap_50_idx = eval.precision_idx(0, 0, 0, 0, 2); let ap_50 = eval.precision[ap_50_idx];
assert!(
ap_50 > 0.0,
"AP@0.5 for category 'cat' should be positive, got {}",
ap_50
);
}
#[test]
fn test_get_ann_ids_filtering() {
let gt_path = fixtures_dir().join("gt.json");
let coco = COCO::new(>_path).expect("Failed to load GT");
let ids = coco.get_ann_ids(&[1], &[], None, None);
assert_eq!(ids.len(), 2);
let ids = coco.get_ann_ids(&[], &[1], None, None);
assert_eq!(ids.len(), 3);
let ids = coco.get_ann_ids(&[2], &[1], None, None);
assert_eq!(ids.len(), 2);
let ids = coco.get_ann_ids(&[], &[], Some([500.0, 2000.0]), None);
assert_eq!(ids.len(), 2); }
#[test]
fn test_get_ann_ids_iscrowd_filter() {
let coco = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "thing")],
vec![
ann(1, [0.0, 0.0, 10.0, 10.0]),
ann(2, [20.0, 20.0, 10.0, 10.0]).crowd(),
ann(3, [40.0, 40.0, 10.0, 10.0]),
],
));
assert_eq!(coco.get_ann_ids(&[], &[], None, Some(true)), vec![2]);
assert_eq!(coco.get_ann_ids(&[], &[], None, Some(false)), vec![1, 3]);
assert_eq!(coco.get_ann_ids(&[], &[], None, None).len(), 3);
assert_eq!(coco.get_ann_ids(&[1], &[], None, Some(true)), vec![2]);
}
#[test]
fn test_summarize_prints() {
let gt_path = fixtures_dir().join("gt.json");
let dt_path = fixtures_dir().join("dt.json");
let coco_gt = COCO::new(>_path).expect("Failed to load GT");
let coco_dt = coco_gt.load_res(&dt_path).expect("Failed to load DT");
let mut coco_eval = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
coco_eval.evaluate();
coco_eval.accumulate();
let lines = coco_eval.summarize_lines();
assert_eq!(
lines.len(),
12,
"bbox summary is 12 lines, got:\n{}",
lines.join("\n")
);
for line in &lines {
assert!(
line.starts_with(" Average Precision") || line.starts_with(" Average Recall"),
"unexpected summary line: {line:?}"
);
assert!(
line.contains("IoU=") && line.contains("area=") && line.contains("maxDets="),
"summary line is missing its parameter annotation: {line:?}"
);
let value = line
.rsplit('=')
.next()
.and_then(|v| v.trim().parse::<f64>().ok())
.unwrap_or_else(|| panic!("summary line has no parseable value: {line:?}"));
assert!(
value == -1.0 || (0.0..=1.0).contains(&value),
"summary value {value} is neither the -1.0 sentinel nor in [0, 1]: {line:?}"
);
}
assert_eq!(coco_eval.stats().expect("summarize sets stats").len(), 12);
}
#[test]
fn test_area_ignored_gt_does_not_absorb_multiple_detections() {
let gt_dataset = dataset(
vec![img(1)],
vec![cat(1, "thing")],
vec![
ann(1, [10.0, 10.0, 20.0, 20.0]), ann(2, [50.0, 50.0, 100.0, 100.0]), ],
);
assert!((iou_of([10.0, 10.0, 25.0, 20.0], [10.0, 10.0, 20.0, 20.0]) - 0.8).abs() < 1e-12);
let dt_dataset = dataset(
vec![img(1)],
vec![cat(1, "thing")],
vec![
det(101, [10.0, 10.0, 20.0, 20.0], 0.9),
det(102, [10.0, 10.0, 25.0, 20.0], 0.8),
det(103, [50.0, 50.0, 100.0, 100.0], 0.7),
],
);
let coco_gt = COCO::from_dataset(gt_dataset);
let coco_dt = COCO::from_dataset(dt_dataset);
let mut coco_eval = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
coco_eval.params.area_ranges = vec![hotcoco::AreaRange {
label: "custom".into(),
range: [500.0, 1e10],
}];
coco_eval.evaluate();
coco_eval.accumulate();
let eval = coco_eval.accumulated().unwrap();
let m_idx = eval.shape.m - 1;
let ap_sum: f64 = (0..eval.shape.r)
.map(|r| {
let idx = eval.precision_idx(0, r, 0, 0, m_idx);
let p = eval.precision[idx];
if p < 0.0 { 0.0 } else { p }
})
.sum();
let ap = ap_sum / eval.shape.r as f64;
assert!(
ap < 0.9,
"AP should be ~0.5 (with FP counted), got {ap:.4}. \
If AP ≈ 1.0, area-ignored non-crowd GT is incorrectly absorbing multiple detections."
);
assert!(ap > 0.3, "AP should be ~0.5, got {ap:.4}");
}
fn run_bbox_eval(coco_gt: COCO, coco_dt: COCO) -> Vec<f64> {
let mut coco_eval = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
coco_eval.evaluate();
coco_eval.accumulate();
coco_eval.summarize();
coco_eval
.stats()
.expect("summarize should set stats")
.to_vec()
}
#[test]
fn test_edge_cases() {
let gt_path = fixtures_dir().join("edge_gt.json");
let dt_path = fixtures_dir().join("edge_dt.json");
let coco_gt = COCO::new(>_path).expect("Failed to load edge GT");
let coco_dt = coco_gt.load_res(&dt_path).expect("Failed to load edge DT");
let stats = run_bbox_eval(coco_gt, coco_dt);
assert_eq!(stats.len(), 12);
#[rustfmt::skip]
let expected: &[f64] = &[
0.712871, 0.712871, 0.712871, 0.663366, 1.000000, 1.000000, 0.428571, 0.714286, 0.714286, 0.666667, 1.000000, 1.000000, ];
let tol = 1e-4;
for (i, (&got, &exp)) in stats.iter().zip(expected.iter()).enumerate() {
assert!(
(got - exp).abs() < tol,
"stats[{i}] mismatch: got {got:.6}, expected {exp:.6}"
);
}
}
#[test]
fn test_crowd_rematching() {
let gt_dataset = dataset(
vec![img(1)],
vec![cat(1, "thing")],
vec![ann(1, [10.0, 10.0, 50.0, 50.0]).crowd()],
);
let dt_dataset = dataset(
vec![img(1)],
vec![cat(1, "thing")],
vec![
det(101, [10.0, 10.0, 50.0, 50.0], 0.9),
det(102, [12.0, 12.0, 48.0, 48.0], 0.8),
det(103, [15.0, 15.0, 45.0, 45.0], 0.7),
],
);
let coco_gt = COCO::from_dataset(gt_dataset);
let coco_dt = COCO::from_dataset(dt_dataset);
let mut coco_eval = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
coco_eval.evaluate();
coco_eval.accumulate();
let eval = coco_eval.accumulated().unwrap();
let m_idx = eval.shape.m - 1;
let recall_idx = eval.recall_idx(0, 0, 0, m_idx); let recall = eval.recall[recall_idx];
assert!(
recall < 0.0,
"Recall should be -1 (no non-crowd GT), got {recall:.4}"
);
let all_neg = (0..eval.shape.r).all(|r| {
let idx = eval.precision_idx(0, r, 0, 0, m_idx);
eval.precision[idx] < 0.0
});
assert!(
all_neg,
"All precision values should be -1 (no non-crowd GT), \
but some are non-negative — crowd re-matching may be broken"
);
}
#[test]
fn test_zero_based_ids() {
let gt_path = fixtures_dir().join("zero_gt.json");
let dt_path = fixtures_dir().join("zero_dt.json");
let coco_gt = COCO::new(>_path).expect("Failed to load zero GT");
let coco_dt = coco_gt.load_res(&dt_path).expect("Failed to load zero DT");
let stats = run_bbox_eval(coco_gt, coco_dt);
let ap = stats[0]; let ap50 = stats[1]; let ar100 = stats[8]; assert!(
(ap - 1.0).abs() < 1e-6,
"AP should be 1.0 for perfect detections with 0-based IDs, got {ap:.6}"
);
assert!(
(ap50 - 1.0).abs() < 1e-6,
"AP@0.5 should be 1.0, got {ap50:.6}"
);
assert!(
(ar100 - 1.0).abs() < 1e-6,
"AR@100 should be 1.0, got {ar100:.6}"
);
}
#[test]
fn test_dataset_stats() {
let gt_path = fixtures_dir().join("gt.json");
let coco = COCO::new(>_path).expect("Failed to load GT");
let stats = coco.stats();
assert_eq!(stats.image_count, 3);
assert_eq!(stats.annotation_count, 5);
assert_eq!(stats.category_count, 2);
assert_eq!(stats.crowd_count, 0);
assert_eq!(stats.per_category.len(), 2);
assert_eq!(stats.per_category[0].name, "cat");
assert_eq!(stats.per_category[0].ann_count, 3);
assert_eq!(stats.per_category[0].img_count, 2); assert_eq!(stats.per_category[0].crowd_count, 0);
assert_eq!(stats.per_category[1].name, "dog");
assert_eq!(stats.per_category[1].ann_count, 2);
assert_eq!(stats.per_category[1].img_count, 2); assert_eq!(stats.per_category[1].crowd_count, 0);
assert_eq!(stats.image_width.min, 100.0);
assert_eq!(stats.image_width.max, 100.0);
assert_eq!(stats.image_width.mean, 100.0);
assert_eq!(stats.image_width.median, 100.0);
assert_eq!(stats.image_height.min, 100.0);
assert_eq!(stats.image_height.max, 100.0);
assert_eq!(stats.annotation_area.min, 400.0);
assert_eq!(stats.annotation_area.max, 2500.0);
let expected_mean = (400.0 + 900.0 + 1600.0 + 400.0 + 2500.0) / 5.0;
assert!((stats.annotation_area.mean - expected_mean).abs() < 1e-9);
assert_eq!(stats.annotation_area.median, 900.0); }
#[test]
fn test_zero_based_ids_load_res() {
let gt_path = fixtures_dir().join("zero_gt.json");
let dt_path = fixtures_dir().join("zero_dt.json");
let coco_gt = COCO::new(>_path).expect("Failed to load zero GT");
let coco_dt = coco_gt.load_res(&dt_path).expect("Failed to load zero DT");
let ids: Vec<u64> = coco_dt.dataset.annotations.iter().map(|a| a.id).collect();
assert_eq!(
ids,
vec![1, 2, 3],
"load_res should assign 1-indexed IDs unconditionally"
);
}
#[test]
fn test_filter_by_cat() {
let gt_path = fixtures_dir().join("gt.json");
let coco = COCO::new(>_path).expect("Failed to load GT");
let filtered = coco.filter(Some(&[1]), None, None, true);
assert_eq!(filtered.annotations.len(), 3);
assert_eq!(filtered.images.len(), 2);
let img_ids: HashSet<u64> = filtered.images.iter().map(|i| i.id).collect();
assert!(img_ids.contains(&1));
assert!(img_ids.contains(&2));
assert_eq!(filtered.categories.len(), 1);
assert_eq!(filtered.categories[0].name, "cat");
}
#[test]
fn test_filter_drop_vs_keep_empty() {
let gt_path = fixtures_dir().join("gt.json");
let coco = COCO::new(>_path).expect("Failed to load GT");
let kept = coco.filter(Some(&[1]), None, None, false);
assert_eq!(kept.images.len(), 3);
assert_eq!(kept.annotations.len(), 3);
let dropped = coco.filter(Some(&[1]), None, None, true);
assert_eq!(dropped.images.len(), 2);
assert_eq!(dropped.annotations.len(), 3);
}
#[test]
fn test_filter_area_rng() {
let gt_path = fixtures_dir().join("gt.json");
let coco = COCO::new(>_path).expect("Failed to load GT");
let filtered = coco.filter(None, None, Some([500.0, 2000.0]), true);
assert_eq!(filtered.annotations.len(), 2);
for ann in &filtered.annotations {
let area = ann.area.unwrap_or(0.0);
assert!((500.0..=2000.0).contains(&area), "area {area} out of range");
}
}
#[test]
fn test_merge_same_cats() {
let gt_path = fixtures_dir().join("gt.json");
let coco = COCO::new(>_path).expect("Failed to load GT");
let ds1 = &coco.dataset;
let ds2 = &coco.dataset;
let merged = COCO::merge(&[ds1, ds2]).expect("Merge should succeed");
assert_eq!(merged.images.len(), ds1.images.len() * 2);
assert_eq!(merged.annotations.len(), ds1.annotations.len() * 2);
let img_ids: HashSet<u64> = merged.images.iter().map(|i| i.id).collect();
assert_eq!(
img_ids.len(),
merged.images.len(),
"Image IDs must be unique"
);
let ann_ids: HashSet<u64> = merged.annotations.iter().map(|a| a.id).collect();
assert_eq!(
ann_ids.len(),
merged.annotations.len(),
"Ann IDs must be unique"
);
assert_eq!(merged.categories.len(), ds1.categories.len());
}
#[test]
fn test_merge_different_cats_error() {
let gt_path = fixtures_dir().join("gt.json");
let coco = COCO::new(>_path).expect("Failed to load GT");
let mut ds2 = coco.dataset.clone();
ds2.categories[0].name = "horse".into();
let result = COCO::merge(&[&coco.dataset, &ds2]);
assert!(result.is_err(), "Merging different taxonomies should fail");
}
#[test]
fn test_split_coverage() {
let gt_path = fixtures_dir().join("gt.json");
let coco = COCO::new(>_path).expect("Failed to load GT");
let (train, val, test) = coco.split(0.33, None, 42);
assert!(test.is_none());
let all_ids: HashSet<u64> = coco.dataset.images.iter().map(|i| i.id).collect();
let train_ids: HashSet<u64> = train.images.iter().map(|i| i.id).collect();
let val_ids: HashSet<u64> = val.images.iter().map(|i| i.id).collect();
assert!(
train_ids.is_disjoint(&val_ids),
"train and val must not overlap"
);
let union: HashSet<u64> = train_ids.union(&val_ids).copied().collect();
assert_eq!(union, all_ids, "train+val must cover all images");
for ann in &train.annotations {
assert!(train_ids.contains(&ann.image_id));
}
for ann in &val.annotations {
assert!(val_ids.contains(&ann.image_id));
}
}
#[test]
fn test_split_determinism() {
let gt_path = fixtures_dir().join("gt.json");
let coco = COCO::new(>_path).expect("Failed to load GT");
let (train1, val1, _) = coco.split(0.33, None, 42);
let (train2, val2, _) = coco.split(0.33, None, 42);
let train1_ids: Vec<u64> = {
let mut v: Vec<u64> = train1.images.iter().map(|i| i.id).collect();
v.sort_unstable();
v
};
let train2_ids: Vec<u64> = {
let mut v: Vec<u64> = train2.images.iter().map(|i| i.id).collect();
v.sort_unstable();
v
};
assert_eq!(train1_ids, train2_ids, "Same seed must produce same split");
let val1_ids: Vec<u64> = {
let mut v: Vec<u64> = val1.images.iter().map(|i| i.id).collect();
v.sort_unstable();
v
};
let val2_ids: Vec<u64> = {
let mut v: Vec<u64> = val2.images.iter().map(|i| i.id).collect();
v.sort_unstable();
v
};
assert_eq!(val1_ids, val2_ids, "Same seed must produce same split");
let images: Vec<Image> = (1..=12).map(img).collect();
let big = COCO::from_dataset(dataset(images, vec![cat(1, "thing")], vec![]));
let val_ids = |seed: u64| -> HashSet<u64> {
let (_, val, _) = big.split(0.5, None, seed);
val.images.iter().map(|i| i.id).collect()
};
let baseline = val_ids(0);
assert!(
(1..8).any(|seed| val_ids(seed) != baseline),
"seeds 1..8 all reproduced seed 0's split — the seed is being ignored"
);
}
#[test]
fn test_sample_n() {
let gt_path = fixtures_dir().join("gt.json");
let coco = COCO::new(>_path).expect("Failed to load GT");
let sampled = coco.sample(Some(2), None, 42);
assert_eq!(sampled.images.len(), 2);
let img_ids: HashSet<u64> = sampled.images.iter().map(|i| i.id).collect();
for ann in &sampled.annotations {
assert!(img_ids.contains(&ann.image_id));
}
assert_eq!(sampled.categories.len(), coco.dataset.categories.len());
}
#[test]
fn empty_image_category_pairs_do_not_change_metrics() {
let coco_gt = COCO::new(&fixtures_dir().join("edge_gt.json")).expect("Failed to load edge GT");
let coco_dt = coco_gt
.load_res(&fixtures_dir().join("edge_dt.json"))
.expect("Failed to load edge DT");
let baseline = run_bbox_eval(coco_gt, coco_dt);
let mut gt = COCO::new(&fixtures_dir().join("edge_gt.json"))
.expect("Failed to load edge GT")
.dataset
.clone();
let next_img_id = gt.images.iter().map(|i| i.id).max().unwrap_or(0) + 1;
for k in 0..5 {
gt.images.push(img(next_img_id + k));
}
let next_cat_id = gt.categories.iter().map(|c| c.id).max().unwrap_or(0) + 1;
gt.categories.push(cat(next_cat_id, "never_annotated"));
let padded_gt = COCO::from_dataset(gt);
let padded_dt = padded_gt
.load_res(&fixtures_dir().join("edge_dt.json"))
.expect("Failed to load edge DT against padded GT");
let padded = run_bbox_eval(padded_gt, padded_dt);
assert_eq!(baseline.len(), padded.len());
for (i, (&base, &pad)) in baseline.iter().zip(padded.iter()).enumerate() {
assert_eq!(
base, pad,
"stats[{i}] changed when empty (image, category) pairs were added: \
{base} -> {pad}"
);
}
}
#[test]
fn test_sample_determinism() {
let gt_path = fixtures_dir().join("gt.json");
let coco = COCO::new(>_path).expect("Failed to load GT");
let s1 = coco.sample(Some(2), None, 42);
let s2 = coco.sample(Some(2), None, 42);
let ids1: HashSet<u64> = s1.images.iter().map(|i| i.id).collect();
let ids2: HashSet<u64> = s2.images.iter().map(|i| i.id).collect();
assert_eq!(ids1, ids2, "Same seed must produce same sample");
}
fn ten_image_coco() -> COCO {
let images: Vec<Image> = (1..=10).map(img).collect();
let annotations: Vec<Annotation> = (1..=10)
.map(|i| ann(i, [0.0, 0.0, 10.0, 10.0]).in_img(i))
.collect();
COCO::from_dataset(dataset(images, vec![cat(1, "thing")], annotations))
}
#[test]
fn test_split_three_way() {
let coco = ten_image_coco();
let (train, val, test) = coco.split(0.2, Some(0.3), 7);
let test = test.expect("test_frac requested a third split");
assert_eq!(val.images.len(), 2);
assert_eq!(test.images.len(), 3);
assert_eq!(train.images.len(), 5);
let ids = |d: &Dataset| -> HashSet<u64> { d.images.iter().map(|i| i.id).collect() };
let (tr, va, te) = (ids(&train), ids(&val), ids(&test));
assert!(tr.is_disjoint(&va) && tr.is_disjoint(&te) && va.is_disjoint(&te));
let mut all = tr.clone();
all.extend(&va);
all.extend(&te);
assert_eq!(all.len(), 10, "the three splits must cover every image");
for (split, split_ids) in [(&train, &tr), (&val, &va), (&test, &te)] {
for a in &split.annotations {
assert!(split_ids.contains(&a.image_id));
}
assert_eq!(split.categories.len(), 1);
}
}
#[test]
fn test_sample_frac() {
let coco = ten_image_coco();
let half = coco.sample(None, Some(0.5), 42);
assert_eq!(half.images.len(), 5);
let img_ids: HashSet<u64> = half.images.iter().map(|i| i.id).collect();
for a in &half.annotations {
assert!(img_ids.contains(&a.image_id));
}
assert_eq!(half.categories.len(), coco.dataset.categories.len());
assert_eq!(coco.sample(None, Some(0.35), 42).images.len(), 3);
assert_eq!(coco.sample(None, Some(1.5), 42).images.len(), 10);
}
#[test]
fn test_lvis_neg_category_counts_as_fp() {
let gt_ds = dataset(
vec![
img(1),
Image {
neg_category_ids: vec![1],
..img(2)
},
],
vec![Category {
frequency: Some("r".into()),
..cat(1, "cat1")
}],
vec![ann(1, [0.0, 0.0, 20.0, 20.0])],
);
let dt_ds = dataset(
vec![img(1), img(2)],
vec![cat(1, "cat1")],
vec![
det(101, [0.0, 0.0, 20.0, 20.0], 0.9), det(102, [0.0, 0.0, 20.0, 20.0], 0.95).in_img(2), ],
);
let coco_gt = COCO::from_dataset(gt_ds);
let coco_dt = COCO::from_dataset(dt_ds);
let mut ev = COCOeval::new_lvis(coco_gt, coco_dt, IouType::Bbox);
ev.run();
let results = ev.get_results(None, false);
let ap = results["AP"];
assert!(
(ap - 0.5).abs() < 1e-9,
"the neg-category FP must halve AP to exactly 0.5 \
(1.0 means it was dropped; -1.0 means nothing was computed): got {ap}"
);
}
#[test]
fn test_lvis_unlisted_category_not_penalized() {
let gt_ds = dataset(
vec![img(1), img(2)], vec![Category {
frequency: Some("f".into()),
..cat(1, "cat1")
}],
vec![ann(1, [0.0, 0.0, 20.0, 20.0])], );
let dt_ds = dataset(
vec![img(1), img(2)],
vec![cat(1, "cat1")],
vec![
det(101, [0.0, 0.0, 20.0, 20.0], 0.9), det(102, [0.0, 0.0, 20.0, 20.0], 0.8).in_img(2), ],
);
let coco_gt_two = COCO::from_dataset(gt_ds.clone());
let coco_dt_two = COCO::from_dataset(dt_ds);
let mut ev_two = COCOeval::new_lvis(coco_gt_two, coco_dt_two, IouType::Bbox);
ev_two.run();
let gt_ds_one = dataset(
vec![img(1)],
vec![Category {
frequency: Some("f".into()),
..cat(1, "cat1")
}],
vec![ann(1, [0.0, 0.0, 20.0, 20.0])],
);
let dt_ds_one = dataset(
vec![img(1)],
vec![cat(1, "cat1")],
vec![det(101, [0.0, 0.0, 20.0, 20.0], 0.9)],
);
let mut ev_one = COCOeval::new_lvis(
COCO::from_dataset(gt_ds_one),
COCO::from_dataset(dt_ds_one),
IouType::Bbox,
);
ev_one.run();
let ap_two = ev_two.get_results(None, false)["AP"];
let ap_one = ev_one.get_results(None, false)["AP"];
assert!(
(ap_two - ap_one).abs() < 1e-6,
"Unlisted DT on image B should not change AP: two-image AP={ap_two:.6}, one-image AP={ap_one:.6}"
);
}
#[test]
fn test_lvis_not_exhaustive_unmatched_ignored() {
let gt_ds = dataset(
vec![Image {
not_exhaustive_category_ids: vec![1], ..img(1)
}],
vec![Category {
frequency: Some("c".into()),
..cat(1, "cat1")
}],
vec![ann(1, [0.0, 0.0, 20.0, 20.0])], );
let dt_ds = dataset(
vec![img(1)],
vec![cat(1, "cat1")],
vec![
det(101, [0.0, 0.0, 20.0, 20.0], 0.9), det(102, [0.0, 0.0, 10.0, 10.0], 0.5), ],
);
let mut ev = COCOeval::new_lvis(
COCO::from_dataset(gt_ds),
COCO::from_dataset(dt_ds),
IouType::Bbox,
);
ev.run();
let ap = ev.get_results(None, false)["AP"];
assert!(
(ap - 1.0).abs() < 1e-6,
"Unmatched DT in not_exhaustive image should be ignored; AP should be 1.0, got {ap}"
);
}
#[test]
fn test_confusion_matrix_perfect() {
let coco_gt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat"), cat(2, "dog")],
vec![
ann(1, [0.0, 0.0, 50.0, 50.0]), ann(2, [60.0, 0.0, 50.0, 50.0]).in_cat(2), ],
));
let coco_dt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat"), cat(2, "dog")],
vec![
det(101, [0.0, 0.0, 50.0, 50.0], 0.9), det(102, [60.0, 0.0, 50.0, 50.0], 0.8).in_cat(2), ],
));
let ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
let cm = ev.confusion_matrix(0.5, None, None);
assert_eq!(cm.num_cats, 2);
assert_eq!(cm.cat_ids, vec![1, 2]);
assert_eq!(cm.get(0, 0), 1, "cat→cat TP should be 1");
assert_eq!(cm.get(1, 1), 1, "dog→dog TP should be 1");
assert_eq!(cm.get(0, 1), 0, "cat should not be predicted as dog");
assert_eq!(cm.get(1, 0), 0, "dog should not be predicted as cat");
assert_eq!(cm.get(0, 2), 0, "no missed cats");
assert_eq!(cm.get(1, 2), 0, "no missed dogs");
assert_eq!(cm.get(2, 0), 0, "no spurious cat predictions");
assert_eq!(cm.get(2, 1), 0, "no spurious dog predictions");
}
#[test]
fn test_confusion_matrix_class_confusion() {
let coco_gt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat"), cat(2, "dog")],
vec![ann(1, [0.0, 0.0, 50.0, 50.0])],
));
let coco_dt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat"), cat(2, "dog")],
vec![det(101, [0.0, 0.0, 50.0, 50.0], 0.9).in_cat(2)],
));
let ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
let cm = ev.confusion_matrix(0.5, None, None);
assert_eq!(cm.get(0, 1), 1, "GT cat predicted as dog should be 1");
assert_eq!(cm.get(0, 2), 0, "GT cat should not be a missed FN");
assert_eq!(cm.get(2, 1), 0, "dog DT should not be a spurious FP");
assert_eq!(cm.get(0, 0), 0);
}
#[test]
fn test_confusion_matrix_fp_background() {
let coco_gt = COCO::from_dataset(dataset(vec![img(1)], vec![cat(1, "cat")], vec![]));
let coco_dt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat")],
vec![det(101, [0.0, 0.0, 50.0, 50.0], 0.9)],
));
let ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
let cm = ev.confusion_matrix(0.5, None, None);
assert_eq!(cm.num_cats, 1);
assert_eq!(
cm.get(1, 0),
1,
"spurious cat DT should count as FP (background row)"
);
assert_eq!(cm.get(0, 1), 0);
}
#[test]
fn test_confusion_matrix_fn_missed() {
let coco_gt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat")],
vec![ann(1, [0.0, 0.0, 50.0, 50.0])],
));
let coco_dt = COCO::from_dataset(dataset(vec![img(1)], vec![cat(1, "cat")], vec![]));
let ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
let cm = ev.confusion_matrix(0.5, None, None);
assert_eq!(
cm.get(0, 1),
1,
"missed cat GT should count as FN (background col)"
);
assert_eq!(cm.get(1, 0), 0);
}
#[test]
fn test_confusion_matrix_iou_threshold() {
let coco_gt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat")],
vec![ann(1, [0.0, 0.0, 100.0, 100.0])],
));
let coco_dt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat")],
vec![det(101, [50.0, 0.0, 50.0, 100.0], 0.9)],
));
let ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
let cm_50 = ev.confusion_matrix(0.5, None, None);
assert_eq!(cm_50.get(0, 0), 1, "should match at iou_thr=0.5");
assert_eq!(cm_50.get(0, 1), 0);
assert_eq!(cm_50.get(1, 0), 0);
let cm_90 = ev.confusion_matrix(0.9, None, None);
assert_eq!(cm_90.get(0, 0), 0, "should not match at iou_thr=0.9");
assert_eq!(cm_90.get(0, 1), 1, "GT should be FN");
assert_eq!(cm_90.get(1, 0), 1, "DT should be FP");
}
#[test]
fn test_confusion_matrix_min_score() {
let coco_gt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat")],
vec![ann(1, [0.0, 0.0, 50.0, 50.0])],
));
let coco_dt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat")],
vec![det(101, [0.0, 0.0, 50.0, 50.0], 0.3)],
));
let ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
let cm_no_filter = ev.confusion_matrix(0.5, None, None);
assert_eq!(cm_no_filter.get(0, 0), 1, "should TP without score filter");
assert_eq!(cm_no_filter.get(0, 1), 0, "no FN without score filter");
let cm_filtered = ev.confusion_matrix(0.5, None, Some(0.5));
assert_eq!(
cm_filtered.get(0, 0),
0,
"DT below min_score should be dropped"
);
assert_eq!(
cm_filtered.get(0, 1),
1,
"GT should become FN when DT is filtered out"
);
assert_eq!(cm_filtered.get(1, 0), 0, "no FP when DT is filtered out");
}
#[test]
fn test_confusion_matrix_max_det() {
let coco_gt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat"), cat(2, "dog")],
vec![
ann(1, [0.0, 0.0, 50.0, 50.0]),
ann(2, [60.0, 0.0, 50.0, 50.0]).in_cat(2),
],
));
let coco_dt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat"), cat(2, "dog")],
vec![
det(101, [0.0, 0.0, 50.0, 50.0], 0.9),
det(102, [60.0, 0.0, 50.0, 50.0], 0.5).in_cat(2),
],
));
let ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
let cm_full = ev.confusion_matrix(0.5, Some(2), None);
assert_eq!(cm_full.get(0, 0), 1, "cat TP with max_det=2");
assert_eq!(cm_full.get(1, 1), 1, "dog TP with max_det=2");
assert_eq!(cm_full.get(0, 2), 0, "no missed cat with max_det=2");
assert_eq!(cm_full.get(1, 2), 0, "no missed dog with max_det=2");
let cm_1det = ev.confusion_matrix(0.5, Some(1), None);
assert_eq!(cm_1det.get(0, 0), 1, "cat GT matches cat DT → TP");
assert_eq!(
cm_1det.get(1, 2),
1,
"dog GT has no DT → FN (background col)"
);
assert_eq!(
cm_1det.get(2, 1),
0,
"no spurious dog FP (DT was truncated)"
);
}
fn run_tide(coco_gt: COCO, coco_dt: COCO) -> hotcoco::TideErrors {
let mut ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
ev.evaluate();
ev.tide_errors(0.5, 0.1).expect("tide_errors failed")
}
#[test]
fn test_tide_all_correct() {
let coco_gt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat")],
vec![ann(1, [0.0, 0.0, 50.0, 50.0])],
));
let coco_dt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat")],
vec![det(101, [0.0, 0.0, 50.0, 50.0], 0.9)],
));
let te = run_tide(coco_gt, coco_dt);
for (key, &val) in &te.delta_ap {
assert!(
val.abs() < 1e-6,
"delta_ap[{key}] should be 0 for perfect detections, got {val}"
);
}
for (key, &val) in &te.counts {
assert_eq!(val, 0, "counts[{key}] should be 0 for perfect detections");
}
assert!(
te.ap_base > 0.99,
"ap_base should be ~1.0, got {}",
te.ap_base
);
}
#[test]
fn test_tide_cls_error() {
let coco_gt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat"), cat(2, "dog")],
vec![
ann(1, [0.0, 0.0, 50.0, 50.0]), ann(2, [60.0, 0.0, 50.0, 50.0]).in_cat(2), ],
));
let coco_dt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat"), cat(2, "dog")],
vec![det(101, [0.0, 0.0, 50.0, 50.0], 0.9).in_cat(2)],
));
let te = run_tide(coco_gt, coco_dt);
assert_eq!(te.counts["Cls"], 1, "should be 1 Cls error");
assert_eq!(te.counts["Loc"], 0);
assert_eq!(te.counts["Bkg"], 0);
assert!(
te.delta_ap["Cls"] > 0.0,
"fixing Cls should improve AP (dog AP goes 0→1), got {}",
te.delta_ap["Cls"]
);
}
#[test]
fn test_tide_loc_error() {
let coco_gt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat")],
vec![ann(1, [0.0, 0.0, 50.0, 50.0])],
));
let coco_dt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat")],
vec![det(101, [25.0, 0.0, 50.0, 50.0], 0.9)],
));
let te = run_tide(coco_gt, coco_dt);
assert_eq!(te.counts["Loc"], 1, "should be 1 Loc error");
assert_eq!(te.counts["Cls"], 0);
assert_eq!(te.counts["Bkg"], 0);
assert!(
te.delta_ap["Loc"] > 0.0,
"fixing Loc should improve AP, got {}",
te.delta_ap["Loc"]
);
}
#[test]
fn test_tide_both_error() {
let coco_gt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat"), cat(2, "dog")],
vec![ann(1, [0.0, 0.0, 50.0, 50.0])],
));
let coco_dt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat"), cat(2, "dog")],
vec![det(101, [25.0, 0.0, 50.0, 50.0], 0.9).in_cat(2)],
));
let te = run_tide(coco_gt, coco_dt);
assert_eq!(te.counts["Both"], 1, "should be 1 Both error");
assert_eq!(te.counts["Cls"], 0, "not Cls because IoU < pos_thr");
assert_eq!(te.counts["Loc"], 0, "not Loc because different class");
}
#[test]
fn test_tide_dupe_error() {
let coco_gt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat")],
vec![ann(1, [0.0, 0.0, 50.0, 50.0])],
));
let coco_dt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat")],
vec![
det(101, [0.0, 0.0, 50.0, 50.0], 0.9), det(102, [0.0, 0.0, 50.0, 50.0], 0.7), ],
));
let te = run_tide(coco_gt, coco_dt);
assert_eq!(te.counts["Dupe"], 1, "second DT should be Dupe");
assert_eq!(te.counts["Bkg"], 0);
assert_eq!(te.counts["Cls"], 0);
}
#[test]
fn test_tide_bkg_error() {
let coco_gt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat")],
vec![ann(1, [0.0, 0.0, 10.0, 10.0])],
));
let coco_dt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat")],
vec![det(101, [90.0, 90.0, 10.0, 10.0], 0.9)],
));
let te = run_tide(coco_gt, coco_dt);
assert_eq!(te.counts["Bkg"], 1, "far-away DT should be Bkg error");
assert_eq!(te.counts["Loc"], 0);
assert_eq!(te.counts["Cls"], 0);
}
#[test]
fn test_tide_miss_error() {
let coco_gt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat")],
vec![ann(1, [0.0, 0.0, 50.0, 50.0])],
));
let coco_dt = COCO::from_dataset(dataset(vec![img(1)], vec![cat(1, "cat")], vec![]));
let te = run_tide(coco_gt, coco_dt);
assert_eq!(te.counts["Miss"], 1, "GT with no DT should be Miss");
assert!(
te.delta_ap["Miss"] > 0.0,
"fixing Miss should improve AP, got {}",
te.delta_ap["Miss"]
);
assert!(
(te.delta_ap["Miss"] - 1.0).abs() < 1e-6,
"injecting 1 perfect TP should give AP=1, delta=1.0, got {}",
te.delta_ap["Miss"]
);
}
#[test]
fn test_tide_priority_loc_over_cls() {
let coco_gt = COCO::from_dataset(dataset(
vec![img(1), img(2)],
vec![cat(1, "cat"), cat(2, "dog")],
vec![
ann(1, [0.0, 0.0, 50.0, 50.0]), ann(2, [0.0, 0.0, 30.0, 30.0]).in_img(2), ann(3, [0.0, 0.0, 50.0, 50.0]).in_img(2).in_cat(2), ],
));
let coco_dt = COCO::from_dataset(dataset(
vec![img(1), img(2)],
vec![cat(1, "cat"), cat(2, "dog")],
vec![
det(101, [0.0, 0.0, 50.0, 50.0], 0.95), det(102, [0.0, 0.0, 50.0, 50.0], 0.9).in_img(2), ],
));
let te = run_tide(coco_gt, coco_dt);
assert_eq!(
te.counts["Loc"], 1,
"Loc should win over Cls by priority (tidecv: BoxError first)"
);
assert_eq!(
te.counts["Cls"], 0,
"Cls should not fire when same-class IoU ≥ bg_thr triggers Loc first"
);
}
#[test]
fn test_tide_priority_loc_over_both() {
let coco_gt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat"), cat(2, "dog")],
vec![
ann(1, [0.0, 0.0, 50.0, 50.0]), ann(2, [60.0, 0.0, 50.0, 50.0]).in_cat(2), ],
));
let coco_dt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat"), cat(2, "dog")],
vec![det(101, [25.0, 0.0, 50.0, 50.0], 0.9)],
));
let te = run_tide(coco_gt, coco_dt);
assert_eq!(te.counts["Loc"], 1, "same-class overlap ≥ bg_thr → Loc");
assert_eq!(te.counts["Both"], 0);
}
#[test]
fn test_tide_delta_ap_fp_ge_individuals() {
let coco_gt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat"), cat(2, "dog")],
vec![
ann(1, [0.0, 0.0, 50.0, 50.0]),
ann(2, [60.0, 0.0, 50.0, 50.0]).in_cat(2),
],
));
let coco_dt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat"), cat(2, "dog")],
vec![
det(101, [0.0, 0.0, 50.0, 50.0], 0.95),
det(102, [150.0, 150.0, 10.0, 10.0], 0.8).in_cat(2),
det(103, [60.0, 0.0, 50.0, 50.0], 0.7),
],
));
let te = run_tide(coco_gt, coco_dt);
let fp_delta = te.delta_ap["FP"];
let max_individual = te.delta_ap["Cls"]
.max(te.delta_ap["Loc"])
.max(te.delta_ap["Both"])
.max(te.delta_ap["Dupe"])
.max(te.delta_ap["Bkg"]);
assert!(
fp_delta >= max_individual - 1e-9,
"ΔAP[FP]={fp_delta:.4} should be ≥ max individual={max_individual:.4}"
);
}
#[test]
fn test_tide_empty_category() {
let coco_gt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "cat")],
vec![ann(1, [0.0, 0.0, 50.0, 50.0])],
));
let coco_dt = COCO::from_dataset(dataset(vec![img(1)], vec![cat(1, "cat")], vec![]));
let te = run_tide(coco_gt, coco_dt);
assert_eq!(te.counts["Miss"], 1, "one missed GT");
assert_eq!(te.counts["Bkg"], 0);
for (key, &val) in &te.delta_ap {
assert!(
val.is_finite(),
"delta_ap[{key}] should be finite, got {val}"
);
assert!(
val >= 0.0,
"delta_ap[{key}] should be non-negative, got {val}"
);
}
assert!(
(te.delta_ap["Miss"] - 1.0).abs() < 1e-6,
"delta_ap[Miss] should be 1.0, got {}",
te.delta_ap["Miss"]
);
}
fn make_test_dataset_basic() -> Dataset {
Dataset {
info: None,
images: vec![
Image {
id: 1,
file_name: "img1.jpg".into(),
width: 100,
height: 200,
..Default::default()
},
Image {
id: 2,
file_name: "img2.jpg".into(),
width: 400,
height: 300,
..Default::default()
},
],
annotations: vec![
Annotation {
id: 1,
image_id: 1,
category_id: 1,
bbox: Some([10.0, 20.0, 30.0, 40.0]),
area: Some(1200.0),
..Default::default()
},
Annotation {
id: 2,
image_id: 1,
category_id: 2,
bbox: Some([50.0, 60.0, 20.0, 25.0]),
area: Some(500.0),
..Default::default()
},
Annotation {
id: 3,
image_id: 2,
category_id: 1,
bbox: Some([0.0, 0.0, 200.0, 150.0]),
area: Some(30000.0),
..Default::default()
},
],
categories: vec![
Category {
id: 1,
name: "cat".into(),
..Default::default()
},
Category {
id: 2,
name: "dog".into(),
..Default::default()
},
],
licenses: vec![],
}
}
fn assert_geometry_round_trip<const N: usize>(
original: &Dataset,
recovered: &Dataset,
img_key: fn(&Image) -> String,
geom: fn(&Annotation) -> [f64; N],
tol: [f64; N],
) {
assert_eq!(recovered.images.len(), original.images.len(), "image count");
assert_eq!(
recovered.annotations.len(),
original.annotations.len(),
"annotation count"
);
assert_eq!(
recovered.categories.len(),
original.categories.len(),
"category count"
);
let keyed = |ds: &Dataset| -> Vec<(String, String, [f64; N])> {
let cat_name: HashMap<u64, &str> = ds
.categories
.iter()
.map(|c| (c.id, c.name.as_str()))
.collect();
let img_name: HashMap<u64, String> = ds.images.iter().map(|i| (i.id, img_key(i))).collect();
let mut rows: Vec<(String, String, [f64; N])> = ds
.annotations
.iter()
.map(|a| {
(
img_name[&a.image_id].clone(),
cat_name[&a.category_id].to_string(),
geom(a),
)
})
.collect();
rows.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
rows
};
for ((o_img, o_cat, o_geo), (r_img, r_cat, r_geo)) in
keyed(original).iter().zip(keyed(recovered).iter())
{
assert_eq!(o_img, r_img, "image mismatch");
assert_eq!(o_cat, r_cat, "category mismatch");
for i in 0..N {
assert!(
(o_geo[i] - r_geo[i]).abs() <= tol[i],
"geom[{i}] mismatch for {o_img}/{o_cat}: orig={} recovered={}",
o_geo[i],
r_geo[i]
);
}
}
}
fn file_stem_key(img: &Image) -> String {
std::path::Path::new(&img.file_name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(img.file_name.as_str())
.to_string()
}
fn file_name_key(img: &Image) -> String {
img.file_name.clone()
}
fn bbox_of(a: &Annotation) -> [f64; 4] {
a.bbox.expect("annotation should carry a bbox")
}
fn obb_of(a: &Annotation) -> [f64; 5] {
a.obb.expect("annotation should carry an obb")
}
#[test]
fn test_coco_to_yolo_basic() {
let dataset = make_test_dataset_basic();
let dir = tempfile::tempdir().expect("tempdir");
let stats = coco_to_yolo(&dataset, dir.path()).expect("coco_to_yolo");
assert_eq!(stats.images, 2);
assert_eq!(stats.annotations, 3);
assert_eq!(stats.skipped_crowd, 0);
assert_eq!(stats.skipped_no_bbox, 0);
let yaml = std::fs::read_to_string(dir.path().join("data.yaml")).expect("data.yaml");
assert!(yaml.contains("nc: 2"), "yaml: {yaml}");
let dims: HashMap<String, (u32, u32)> = [
("img1".to_string(), (100u32, 200u32)),
("img2".to_string(), (400u32, 300u32)),
]
.into_iter()
.collect();
let parsed = yolo_to_coco(dir.path(), &dims).expect("re-import of our own export");
let names: Vec<&str> = parsed.categories.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["cat", "dog"], "yaml was: {yaml}");
let txt1 = std::fs::read_to_string(dir.path().join("img1.txt")).expect("img1.txt");
let lines1: Vec<&str> = txt1.lines().collect();
assert_eq!(lines1.len(), 2, "img1.txt should have 2 lines");
let txt2 = std::fs::read_to_string(dir.path().join("img2.txt")).expect("img2.txt");
let lines2: Vec<&str> = txt2.lines().collect();
assert_eq!(lines2.len(), 1, "img2.txt should have 1 line");
let first_line = lines1[0];
let parts: Vec<f64> = first_line
.split_whitespace()
.skip(1)
.map(|s| s.parse().unwrap())
.collect();
assert!((parts[0] - 0.25).abs() < 1e-5, "cx mismatch: {}", parts[0]);
assert!((parts[1] - 0.2).abs() < 1e-5, "cy mismatch: {}", parts[1]);
assert!((parts[2] - 0.3).abs() < 1e-5, "nw mismatch: {}", parts[2]);
assert!((parts[3] - 0.2).abs() < 1e-5, "nh mismatch: {}", parts[3]);
}
#[test]
fn test_coco_to_yolo_category_remapping() {
let dataset = Dataset {
info: None,
images: vec![Image {
id: 1,
file_name: "img.jpg".into(),
width: 200,
height: 200,
..Default::default()
}],
annotations: vec![
Annotation {
id: 1,
image_id: 1,
category_id: 7,
bbox: Some([10.0, 10.0, 40.0, 40.0]),
area: Some(1600.0),
..Default::default()
},
Annotation {
id: 2,
image_id: 1,
category_id: 3,
bbox: Some([60.0, 60.0, 20.0, 20.0]),
area: Some(400.0),
..Default::default()
},
],
categories: vec![
Category {
id: 7,
name: "bird".into(),
..Default::default()
},
Category {
id: 1,
name: "cat".into(),
..Default::default()
},
Category {
id: 3,
name: "dog".into(),
..Default::default()
},
],
licenses: vec![],
};
let dir = tempfile::tempdir().expect("tempdir");
coco_to_yolo(&dataset, dir.path()).expect("coco_to_yolo");
let dims: HashMap<String, (u32, u32)> = [("img".to_string(), (200u32, 200u32))]
.into_iter()
.collect();
let parsed = yolo_to_coco(dir.path(), &dims).expect("re-import of our own export");
let names: Vec<&str> = parsed.categories.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["cat", "dog", "bird"]);
let txt = std::fs::read_to_string(dir.path().join("img.txt")).expect("img.txt");
let lines: Vec<&str> = txt.lines().collect();
assert_eq!(lines.len(), 2);
let class0: usize = lines[0].split_whitespace().next().unwrap().parse().unwrap();
assert_eq!(
class0, 2,
"cat_id=7 should map to class_idx=2, got {class0}"
);
let class1: usize = lines[1].split_whitespace().next().unwrap().parse().unwrap();
assert_eq!(
class1, 1,
"cat_id=3 should map to class_idx=1, got {class1}"
);
}
#[test]
fn test_coco_to_yolo_crowd_skipped() {
let dataset = Dataset {
info: None,
images: vec![Image {
id: 1,
file_name: "img.jpg".into(),
width: 100,
height: 100,
..Default::default()
}],
annotations: vec![
Annotation {
id: 1,
image_id: 1,
category_id: 1,
bbox: Some([10.0, 10.0, 20.0, 20.0]),
area: Some(400.0),
iscrowd: true, ..Default::default()
},
Annotation {
id: 2,
image_id: 1,
category_id: 1,
bbox: Some([50.0, 50.0, 20.0, 20.0]),
area: Some(400.0),
..Default::default()
},
],
categories: vec![Category {
id: 1,
name: "thing".into(),
..Default::default()
}],
licenses: vec![],
};
let dir = tempfile::tempdir().expect("tempdir");
let stats = coco_to_yolo(&dataset, dir.path()).expect("coco_to_yolo");
assert_eq!(stats.skipped_crowd, 1);
assert_eq!(stats.annotations, 1);
let txt = std::fs::read_to_string(dir.path().join("img.txt")).expect("img.txt");
assert_eq!(txt.lines().count(), 1, "only one non-crowd annotation");
}
#[test]
fn test_coco_to_yolo_missing_bbox() {
let dataset = Dataset {
info: None,
images: vec![Image {
id: 1,
file_name: "img.jpg".into(),
width: 100,
height: 100,
..Default::default()
}],
annotations: vec![
Annotation {
id: 1,
image_id: 1,
category_id: 1,
bbox: None, area: Some(400.0),
..Default::default()
},
Annotation {
id: 2,
image_id: 1,
category_id: 1,
bbox: Some([50.0, 50.0, 20.0, 20.0]),
area: Some(400.0),
..Default::default()
},
],
categories: vec![Category {
id: 1,
name: "thing".into(),
..Default::default()
}],
licenses: vec![],
};
let dir = tempfile::tempdir().expect("tempdir");
let stats = coco_to_yolo(&dataset, dir.path()).expect("coco_to_yolo");
assert_eq!(stats.skipped_no_bbox, 1);
assert_eq!(stats.annotations, 1);
}
#[test]
fn test_coco_to_yolo_empty_image() {
let dataset = Dataset {
info: None,
images: vec![Image {
id: 1,
file_name: "empty.jpg".into(),
width: 640,
height: 480,
..Default::default()
}],
annotations: vec![],
categories: vec![Category {
id: 1,
name: "thing".into(),
..Default::default()
}],
licenses: vec![],
};
let dir = tempfile::tempdir().expect("tempdir");
let stats = coco_to_yolo(&dataset, dir.path()).expect("coco_to_yolo");
assert_eq!(stats.images, 1);
assert_eq!(stats.annotations, 0);
let txt_path = dir.path().join("empty.txt");
assert!(txt_path.exists(), "empty.txt should be created");
let content = std::fs::read_to_string(&txt_path).expect("empty.txt");
assert!(content.is_empty(), "empty.txt should have no content");
}
#[test]
fn test_yolo_to_coco_basic() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("data.yaml"), "nc: 2\nnames: [cat, dog]\n")
.expect("write data.yaml");
std::fs::write(
dir.path().join("img1.txt"),
"0 0.250000 0.200000 0.300000 0.200000\n1 0.700000 0.725000 0.200000 0.250000\n",
)
.expect("write img1.txt");
std::fs::write(
dir.path().join("img2.txt"),
"0 0.250000 0.250000 0.500000 0.500000\n",
)
.expect("write img2.txt");
let dims: HashMap<String, (u32, u32)> = [
("img1".to_string(), (100u32, 200u32)),
("img2".to_string(), (400u32, 300u32)),
]
.into_iter()
.collect();
let dataset = yolo_to_coco(dir.path(), &dims).expect("yolo_to_coco");
assert_eq!(dataset.images.len(), 2);
assert_eq!(dataset.annotations.len(), 3);
assert_eq!(dataset.categories.len(), 2);
assert_eq!(dataset.categories[0].id, 1);
assert_eq!(dataset.categories[0].name, "cat");
assert_eq!(dataset.categories[1].id, 2);
assert_eq!(dataset.categories[1].name, "dog");
let img1 = dataset
.images
.iter()
.find(|i| i.file_name == "img1")
.unwrap();
assert_eq!(img1.width, 100);
assert_eq!(img1.height, 200);
let ann = dataset
.annotations
.iter()
.find(|a| a.image_id == img1.id && a.category_id == 1)
.unwrap();
let bbox = ann.bbox.unwrap();
assert!((bbox[0] - 10.0).abs() < 1e-4, "x: {}", bbox[0]);
assert!((bbox[1] - 20.0).abs() < 1e-4, "y: {}", bbox[1]);
assert!((bbox[2] - 30.0).abs() < 1e-4, "w: {}", bbox[2]);
assert!((bbox[3] - 40.0).abs() < 1e-4, "h: {}", bbox[3]);
}
#[test]
fn test_yolo_round_trip() {
let original = make_test_dataset_basic();
let dir = tempfile::tempdir().expect("tempdir");
coco_to_yolo(&original, dir.path()).expect("coco_to_yolo");
let dims: HashMap<String, (u32, u32)> = original
.images
.iter()
.map(|img| {
let stem = std::path::Path::new(&img.file_name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(img.file_name.as_str())
.to_string();
(stem, (img.width, img.height))
})
.collect();
let recovered = yolo_to_coco(dir.path(), &dims).expect("yolo_to_coco");
assert_geometry_round_trip(&original, &recovered, file_stem_key, bbox_of, [1e-4; 4]);
}
#[test]
fn test_coco_to_voc_basic() {
let dataset = make_test_dataset_basic();
let dir = tempfile::tempdir().expect("tempdir");
let stats = coco_to_voc(&dataset, dir.path()).expect("coco_to_voc");
assert_eq!(stats.images, 2);
assert_eq!(stats.annotations, 3);
assert_eq!(stats.crowd_as_difficult, 0);
assert_eq!(stats.skipped_no_bbox, 0);
let ann_dir = dir.path().join("Annotations");
assert!(ann_dir.is_dir(), "Annotations/ directory should exist");
let labels = std::fs::read_to_string(dir.path().join("labels.txt")).expect("labels.txt");
assert_eq!(labels.trim(), "cat\ndog");
let xml1 = std::fs::read_to_string(ann_dir.join("img1.xml")).expect("img1.xml");
assert!(xml1.contains("<filename>img1.jpg</filename>"), "filename");
assert!(xml1.contains("<width>100</width>"), "width");
assert!(xml1.contains("<height>200</height>"), "height");
assert!(xml1.contains("<xmin>11</xmin>"), "xmin");
assert!(xml1.contains("<ymin>21</ymin>"), "ymin");
assert!(xml1.contains("<xmax>40</xmax>"), "xmax");
assert!(xml1.contains("<ymax>60</ymax>"), "ymax");
assert!(xml1.contains("<name>cat</name>"), "cat object");
assert!(xml1.contains("<name>dog</name>"), "dog object");
let xml2 = std::fs::read_to_string(ann_dir.join("img2.xml")).expect("img2.xml");
assert!(xml2.contains("<name>cat</name>"), "cat object in img2");
assert!(xml2.contains("<xmax>200</xmax>"), "xmax img2");
assert!(xml2.contains("<ymax>150</ymax>"), "ymax img2");
}
#[test]
fn test_voc_part_elements_do_not_overwrite_the_object() {
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>person.jpg</filename>
<size>
<width>640</width>
<height>480</height>
</size>
<object>
<name>person</name>
<bndbox>
<xmin>100</xmin>
<ymin>50</ymin>
<xmax>300</xmax>
<ymax>400</ymax>
</bndbox>
<part>
<name>head</name>
<bndbox>
<xmin>150</xmin>
<ymin>60</ymin>
<xmax>220</xmax>
<ymax>130</ymax>
</bndbox>
</part>
<part>
<name>foot</name>
<bndbox>
<xmin>110</xmin>
<ymin>360</ymin>
<xmax>180</xmax>
<ymax>400</ymax>
</bndbox>
</part>
</object>
</annotation>";
std::fs::write(ann_dir.join("person.xml"), xml).expect("write xml");
let dataset = voc_to_coco(dir.path()).expect("voc_to_coco");
assert_eq!(
dataset.categories.len(),
1,
"only `person` is an object; `head` and `foot` are parts. Got: {:?}",
dataset
.categories
.iter()
.map(|c| &c.name)
.collect::<Vec<_>>()
);
assert_eq!(dataset.categories[0].name, "person");
assert_eq!(dataset.annotations.len(), 1);
assert_eq!(
dataset.annotations[0].bbox,
Some([99.0, 49.0, 201.0, 351.0]),
"the person's own box, not the last part's"
);
}
#[test]
fn test_voc_to_coco_basic() {
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>
<folder>Annotations</folder>
<filename>test.jpg</filename>
<size>
<width>640</width>
<height>480</height>
<depth>3</depth>
</size>
<segmented>0</segmented>
<object>
<name>person</name>
<pose>Unspecified</pose>
<truncated>0</truncated>
<difficult>0</difficult>
<bndbox>
<xmin>100</xmin>
<ymin>50</ymin>
<xmax>300</xmax>
<ymax>400</ymax>
</bndbox>
</object>
<object>
<name>car</name>
<pose>Left</pose>
<truncated>1</truncated>
<difficult>1</difficult>
<bndbox>
<xmin>400</xmin>
<ymin>200</ymin>
<xmax>600</xmax>
<ymax>450</ymax>
</bndbox>
</object>
</annotation>";
std::fs::write(ann_dir.join("test.xml"), xml).expect("write xml");
let dataset = voc_to_coco(dir.path()).expect("voc_to_coco");
assert_eq!(dataset.images.len(), 1);
assert_eq!(dataset.images[0].file_name, "test.jpg");
assert_eq!(dataset.images[0].width, 640);
assert_eq!(dataset.images[0].height, 480);
assert_eq!(dataset.annotations.len(), 2);
assert_eq!(dataset.categories.len(), 2);
assert_eq!(dataset.categories[0].name, "car");
assert_eq!(dataset.categories[1].name, "person");
let person_ann = dataset
.annotations
.iter()
.find(|a| {
a.category_id
== dataset
.categories
.iter()
.find(|c| c.name == "person")
.unwrap()
.id
})
.expect("person annotation");
let bbox = person_ann.bbox.unwrap();
assert_eq!(bbox, [99.0, 49.0, 201.0, 351.0]);
let car_ann = dataset
.annotations
.iter()
.find(|a| {
a.category_id
== dataset
.categories
.iter()
.find(|c| c.name == "car")
.unwrap()
.id
})
.expect("car annotation");
let bbox = car_ann.bbox.unwrap();
assert_eq!(bbox, [399.0, 199.0, 201.0, 251.0]);
assert!(!person_ann.iscrowd, "difficult=0 → iscrowd=false");
assert!(car_ann.iscrowd, "difficult=1 → iscrowd=true");
}
#[test]
fn test_voc_round_trip() {
let original = make_test_dataset_basic();
let dir = tempfile::tempdir().expect("tempdir");
coco_to_voc(&original, dir.path()).expect("coco_to_voc");
let recovered = voc_to_coco(dir.path()).expect("voc_to_coco");
assert_geometry_round_trip(&original, &recovered, file_name_key, bbox_of, [1.0; 4]);
}
#[test]
fn test_coco_to_voc_crowd_as_difficult() {
let dataset = Dataset {
info: None,
images: vec![Image {
id: 1,
file_name: "img.jpg".into(),
width: 100,
height: 100,
..Default::default()
}],
annotations: vec![
Annotation {
id: 1,
image_id: 1,
category_id: 1,
bbox: Some([10.0, 20.0, 30.0, 40.0]),
area: Some(1200.0),
iscrowd: true,
..Default::default()
},
Annotation {
id: 2,
image_id: 1,
category_id: 1,
bbox: Some([50.0, 60.0, 10.0, 10.0]),
area: Some(100.0),
..Default::default()
},
],
categories: vec![Category {
id: 1,
name: "thing".into(),
..Default::default()
}],
licenses: vec![],
};
let dir = tempfile::tempdir().expect("tempdir");
let stats = coco_to_voc(&dataset, dir.path()).expect("coco_to_voc");
assert_eq!(stats.annotations, 2, "both annotations should be written");
assert_eq!(stats.crowd_as_difficult, 1, "one crowd annotation");
let xml = std::fs::read_to_string(dir.path().join("Annotations/img.xml")).expect("img.xml");
assert!(
xml.contains("<difficult>1</difficult>"),
"crowd → difficult=1"
);
assert!(
xml.contains("<difficult>0</difficult>"),
"non-crowd → difficult=0"
);
}
#[test]
fn test_voc_labels_txt_ordering() {
let dir = tempfile::tempdir().expect("tempdir");
let ann_dir = dir.path().join("Annotations");
std::fs::create_dir_all(&ann_dir).expect("mkdir");
std::fs::write(dir.path().join("labels.txt"), "zebra\napple\n").expect("labels.txt");
let xml = r"<annotation>
<filename>img.jpg</filename>
<size><width>100</width><height>100</height><depth>3</depth></size>
<object>
<name>apple</name>
<bndbox><xmin>0</xmin><ymin>0</ymin><xmax>50</xmax><ymax>50</ymax></bndbox>
</object>
<object>
<name>zebra</name>
<bndbox><xmin>50</xmin><ymin>50</ymin><xmax>100</xmax><ymax>100</ymax></bndbox>
</object>
</annotation>";
std::fs::write(ann_dir.join("img.xml"), xml).expect("write xml");
let dataset = voc_to_coco(dir.path()).expect("voc_to_coco");
assert_eq!(dataset.categories[0].name, "zebra");
assert_eq!(dataset.categories[0].id, 1);
assert_eq!(dataset.categories[1].name, "apple");
assert_eq!(dataset.categories[1].id, 2);
}
#[test]
fn test_coco_to_cvat_basic() {
let dataset = make_test_dataset_basic();
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("annotations.xml");
let stats = coco_to_cvat(&dataset, &out).expect("coco_to_cvat");
assert_eq!(stats.images, 2);
assert_eq!(stats.boxes, 3);
assert_eq!(stats.polygons, 0);
assert_eq!(stats.skipped_no_geometry, 0);
let xml = std::fs::read_to_string(&out).expect("read xml");
assert!(xml.contains("<version>1.1</version>"), "version");
assert!(xml.contains("<name>cat</name>"), "cat label");
assert!(xml.contains("<name>dog</name>"), "dog label");
assert!(xml.contains("name=\"img1.jpg\""), "image name");
assert!(xml.contains("xtl=\"10.00\""), "xtl");
assert!(xml.contains("ytl=\"20.00\""), "ytl");
assert!(xml.contains("xbr=\"40.00\""), "xbr");
assert!(xml.contains("ybr=\"60.00\""), "ybr");
}
#[test]
fn test_cvat_to_coco_basic() {
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>car</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"/>
<box label="car" xtl="400" ytl="200" xbr="600" ybr="450" occluded="0"/>
</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!(stats.images, 1);
assert_eq!(stats.boxes, 2);
assert_eq!(dataset.images.len(), 1);
assert_eq!(dataset.images[0].file_name, "test.jpg");
assert_eq!(dataset.images[0].width, 640);
assert_eq!(dataset.images[0].height, 480);
assert_eq!(dataset.annotations.len(), 2);
assert_eq!(dataset.categories[0].name, "person");
assert_eq!(dataset.categories[1].name, "car");
let person_cat = dataset
.categories
.iter()
.find(|c| c.name == "person")
.unwrap();
let person_ann = dataset
.annotations
.iter()
.find(|a| a.category_id == person_cat.id)
.expect("person annotation");
let bbox = person_ann.bbox.unwrap();
assert_eq!(bbox, [100.0, 50.0, 200.0, 350.0]);
}
#[test]
fn test_cvat_round_trip_boxes() {
let original = make_test_dataset_basic();
let dir = tempfile::tempdir().expect("tempdir");
let xml_path = dir.path().join("output.xml");
coco_to_cvat(&original, &xml_path).expect("coco_to_cvat");
let (recovered, _stats) = cvat_to_coco(&xml_path).expect("cvat_to_coco");
assert_geometry_round_trip(&original, &recovered, file_name_key, bbox_of, [0.01; 4]);
}
#[test]
fn test_cvat_polygons() {
let dir = tempfile::tempdir().expect("tempdir");
let xml_path = dir.path().join("poly.xml");
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
<annotations>
<version>1.1</version>
<meta><task><labels><label><name>shape</name></label></labels></task></meta>
<image id="0" name="img.jpg" width="100" height="100">
<polygon label="shape" points="10.0,20.0;50.0,20.0;50.0,80.0;10.0,80.0" occluded="0"/>
</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(), 1);
let ann = &dataset.annotations[0];
let bbox = ann.bbox.unwrap();
assert!((bbox[0] - 10.0).abs() < 1e-6, "x");
assert!((bbox[1] - 20.0).abs() < 1e-6, "y");
assert!((bbox[2] - 40.0).abs() < 1e-6, "w");
assert!((bbox[3] - 60.0).abs() < 1e-6, "h");
assert!((ann.area.unwrap() - 2400.0).abs() < 1e-6, "area");
match &ann.segmentation {
Some(hotcoco::types::Segmentation::Polygon(polys)) => {
assert_eq!(polys.len(), 1);
assert_eq!(
polys[0],
vec![10.0, 20.0, 50.0, 20.0, 50.0, 80.0, 10.0, 80.0]
);
}
other => panic!("expected Polygon segmentation, got: {other:?}"),
}
}
#[test]
fn test_cvat_skips_unsupported() {
let dir = tempfile::tempdir().expect("tempdir");
let xml_path = dir.path().join("mixed.xml");
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
<annotations>
<version>1.1</version>
<meta><task><labels><label><name>thing</name></label></labels></task></meta>
<image id="0" name="img.jpg" width="100" height="100">
<box label="thing" xtl="10" ytl="10" xbr="50" ybr="50" occluded="0"/>
<polyline label="thing" points="10,10;50,50" occluded="0"/>
<points label="thing" points="25,25" occluded="0"/>
</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(), 1);
assert_eq!(stats.skipped_unsupported, 2);
assert_eq!(
dataset.annotations[0].bbox.unwrap(),
[10.0, 10.0, 40.0, 40.0]
);
}
fn make_perfect_eval() -> COCOeval {
let bbox = [10.0, 10.0, 50.0, 50.0];
let coco_gt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "thing")],
vec![ann(1, bbox)],
));
let coco_dt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "thing")],
vec![det(1, bbox, 1.0)],
));
let mut ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
ev.evaluate();
ev.accumulate();
ev
}
#[test]
fn test_f_scores_empty_before_accumulate() {
let gt_path = fixtures_dir().join("gt.json");
let dt_path = fixtures_dir().join("dt.json");
let coco_gt = COCO::new(>_path).expect("load GT");
let coco_dt = coco_gt.load_res(&dt_path).expect("load DT");
let mut ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
ev.evaluate();
assert!(ev.f_scores(1.0).is_empty());
}
#[test]
fn test_f_scores_keys_and_range() {
let gt_path = fixtures_dir().join("gt.json");
let dt_path = fixtures_dir().join("dt.json");
let coco_gt = COCO::new(>_path).expect("load GT");
let coco_dt = coco_gt.load_res(&dt_path).expect("load DT");
let mut ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
ev.evaluate();
ev.accumulate();
let f1 = ev.f_scores(1.0);
assert_eq!(f1.len(), 3);
assert!(f1.contains_key("F1") && f1.contains_key("F1_50") && f1.contains_key("F1_75"));
for (k, v) in &f1 {
assert!((0.0..=1.0).contains(v), "{k} = {v} outside [0, 1]");
}
let fb = ev.f_scores(0.5);
assert!(fb.contains_key("F0.5") && fb.contains_key("F0.5_50") && fb.contains_key("F0.5_75"));
}
#[test]
fn test_f_scores_perfect_detection() {
let scores = make_perfect_eval().f_scores(1.0);
assert!((scores["F1"] - 1.0).abs() < 1e-9, "F1={}", scores["F1"]);
assert!(
(scores["F1_50"] - 1.0).abs() < 1e-9,
"F1_50={}",
scores["F1_50"]
);
}
#[test]
fn test_results_returns_metrics() {
let gt_path = fixtures_dir().join("gt.json");
let dt_path = fixtures_dir().join("dt.json");
let coco_gt = COCO::new(>_path).expect("Failed to load GT");
let coco_dt = coco_gt.load_res(&dt_path).expect("Failed to load DT");
let mut ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
ev.run();
let results = ev
.results(false)
.expect("results() should succeed after run()");
assert_eq!(results.metrics.len(), 12);
assert!(results.per_class.is_none());
assert_eq!(results.params.iou_thresholds.len(), 10);
assert_eq!(results.params.max_dets, vec![1, 10, 100]);
assert_eq!(results.params.eval_mode, "coco");
let results = ev.results(true).expect("results() should succeed");
assert!(results.per_class.is_some());
assert!(!results.per_class.as_ref().unwrap().is_empty());
}
#[test]
fn test_results_errors_before_summarize() {
let gt_path = fixtures_dir().join("gt.json");
let dt_path = fixtures_dir().join("dt.json");
let coco_gt = COCO::new(>_path).expect("Failed to load GT");
let coco_dt = coco_gt.load_res(&dt_path).expect("Failed to load DT");
let ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
assert!(ev.results(false).is_err());
}
#[test]
fn test_results_save_roundtrip() {
let gt_path = fixtures_dir().join("gt.json");
let dt_path = fixtures_dir().join("dt.json");
let coco_gt = COCO::new(>_path).expect("Failed to load GT");
let coco_dt = coco_gt.load_res(&dt_path).expect("Failed to load DT");
let mut ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
ev.run();
let results = ev.results(true).unwrap();
let json = results.to_json().unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed["params"]["iou_type"].is_string());
assert!(parsed["metrics"]["AP"].is_number());
assert!(parsed["per_class"].is_object());
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("results.json");
results.save(&path).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
let file_parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
assert_eq!(parsed, file_parsed);
}
#[test]
fn test_healthcheck_structural_errors() {
let path = fixtures_dir().join("healthcheck_bad.json");
let dataset: hotcoco::Dataset =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let report = quality::healthcheck(&dataset);
let codes: Vec<&str> = report.errors.iter().map(|f| f.code).collect();
assert!(
codes.contains(&"duplicate_image_id"),
"should detect duplicate image IDs"
);
assert!(
codes.contains(&"duplicate_ann_id"),
"should detect duplicate annotation IDs"
);
assert!(
codes.contains(&"orphan_image_id"),
"should detect orphan image_id in annotations"
);
assert!(
codes.contains(&"orphan_category_id"),
"should detect orphan category_id in annotations"
);
assert!(
codes.contains(&"zero_dimensions"),
"should detect zero height/width on images"
);
}
#[test]
fn test_healthcheck_clean_dataset() {
let path = fixtures_dir().join("gt.json");
let dataset: hotcoco::Dataset =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let report = quality::healthcheck(&dataset);
assert!(
report.errors.is_empty(),
"clean dataset should have no errors: {:?}",
report.errors
);
}
#[test]
fn test_healthcheck_quality_warnings() {
let path = fixtures_dir().join("healthcheck_quality.json");
let dataset: hotcoco::Dataset =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let report = quality::healthcheck(&dataset);
let codes: Vec<&str> = report.warnings.iter().map(|f| f.code).collect();
assert!(
codes.contains(&"degenerate_bbox"),
"should detect zero-width bbox"
);
assert!(
codes.contains(&"bbox_out_of_bounds"),
"should detect bbox extending outside image"
);
assert!(
codes.contains(&"extreme_aspect_ratio"),
"should detect extreme aspect ratio"
);
assert!(
codes.contains(&"near_duplicate"),
"should detect near-duplicate overlapping annotations"
);
}
#[test]
fn test_healthcheck_summary() {
let path = fixtures_dir().join("gt.json");
let dataset: hotcoco::Dataset =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let report = quality::healthcheck(&dataset);
assert_eq!(report.summary.num_images, 3);
assert_eq!(report.summary.num_annotations, 5);
assert_eq!(report.summary.num_categories, 2);
assert_eq!(report.summary.images_without_annotations, 0);
assert_eq!(report.summary.category_counts[0].0, "cat");
assert_eq!(report.summary.category_counts[0].1, 3);
assert_eq!(report.summary.category_counts[1].0, "dog");
assert_eq!(report.summary.category_counts[1].1, 2);
assert!((report.summary.imbalance_ratio - 1.5).abs() < 1e-9);
}
#[test]
fn test_healthcheck_compatibility() {
let gt: hotcoco::Dataset = serde_json::from_str(
r#"{
"images": [
{"id": 1, "file_name": "a.jpg", "height": 100, "width": 100}
],
"annotations": [
{"id": 1, "image_id": 1, "category_id": 1, "bbox": [10,10,20,20], "area": 400, "iscrowd": 0}
],
"categories": [{"id": 1, "name": "cat"}]
}"#,
)
.unwrap();
let dt: hotcoco::Dataset = serde_json::from_str(
r#"{
"images": [
{"id": 1, "file_name": "a.jpg", "height": 100, "width": 100}
],
"annotations": [
{"id": 1, "image_id": 999, "category_id": 1, "bbox": [10,10,20,20], "area": 400, "iscrowd": 0, "score": 0.9},
{"id": 2, "image_id": 1, "category_id": 999, "bbox": [10,10,20,20], "area": 400, "iscrowd": 0, "score": 0.8},
{"id": 3, "image_id": 1, "category_id": 1, "bbox": [10,10,20,20], "area": 400, "iscrowd": 0},
{"id": 4, "image_id": 1, "category_id": 1, "bbox": [10,10,20,20], "area": 400, "iscrowd": 0, "score": 1.5}
],
"categories": [{"id": 1, "name": "cat"}]
}"#,
)
.unwrap();
let report = quality::healthcheck_compatibility(>, &dt);
let codes: Vec<&str> = report
.errors
.iter()
.map(|f| f.code)
.chain(report.warnings.iter().map(|f| f.code))
.collect();
assert!(
codes.contains(&"dt_orphan_image_id"),
"should detect DT referencing unknown image ID"
);
assert!(
codes.contains(&"dt_orphan_category_id"),
"should detect DT referencing unknown category ID"
);
assert!(
codes.contains(&"dt_missing_score"),
"should detect DT missing score"
);
assert!(
codes.contains(&"dt_score_out_of_range"),
"should detect DT with score > 1.0"
);
}
#[test]
fn test_accumulate_unchanged_after_refactor() {
let gt_path = fixtures_dir().join("gt.json");
let dt_path = fixtures_dir().join("dt.json");
let coco_gt = COCO::new(>_path).unwrap();
let coco_dt = coco_gt.load_res(&dt_path).unwrap();
let mut ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
ev.evaluate();
ev.accumulate();
ev.summarize();
let stats = ev.stats().expect("summarize sets stats");
#[rustfmt::skip]
let expected: &[f64] = &[
0.908416, 1.000000, 1.000000, 0.925743, 0.900000, -1.000000, 0.791667, 0.908333, 0.908333, 0.925000, 0.900000, -1.000000, ];
assert_eq!(stats.len(), expected.len());
for (i, (&got, &exp)) in stats.iter().zip(expected.iter()).enumerate() {
assert!(
(got - exp).abs() < 1e-6,
"stats[{i}]: got {got:.6}, expected {exp:.6}"
);
}
let eval = ev.accumulated().unwrap();
assert_eq!(eval.shape.t, 10);
assert_eq!(eval.shape.k, 2);
}
#[test]
fn test_slice_by_full_dataset_matches_normal_eval() {
let gt_path = fixtures_dir().join("gt.json");
let dt_path = fixtures_dir().join("dt.json");
let coco_gt1 = COCO::new(>_path).unwrap();
let coco_dt1 = coco_gt1.load_res(&dt_path).unwrap();
let coco_gt2 = COCO::new(>_path).unwrap();
let coco_dt2 = coco_gt2.load_res(&dt_path).unwrap();
let all_img_ids: Vec<u64> = coco_gt1.dataset.images.iter().map(|i| i.id).collect();
let mut ev = COCOeval::new(coco_gt1, coco_dt1, IouType::Bbox);
ev.evaluate();
ev.accumulate();
ev.summarize();
let normal_results = ev.get_results(None, false);
let mut ev2 = COCOeval::new(coco_gt2, coco_dt2, IouType::Bbox);
ev2.evaluate();
let sliced = ev2
.slice_by(
vec![("all_images".to_string(), all_img_ids)]
.into_iter()
.collect(),
)
.unwrap();
for (key, &val) in &sliced.overall.metrics {
let normal_val = normal_results.get(key).copied().unwrap();
assert!(
(val - normal_val).abs() < 1e-12,
"overall {} mismatch: {} vs {}",
key,
val,
normal_val
);
}
let all_slice = sliced
.slices
.iter()
.find(|s| s.name == "all_images")
.unwrap();
for (key, &val) in &all_slice.metrics {
let normal_val = normal_results.get(key).copied().unwrap();
assert!(
(val - normal_val).abs() < 1e-12,
"all_images slice {} mismatch: {} vs {}",
key,
val,
normal_val
);
}
for &d in all_slice.delta.values() {
assert!(d.abs() < 1e-12, "delta should be zero for full dataset");
}
}
#[test]
fn test_slice_by_disjoint_halves() {
let gt_path = fixtures_dir().join("gt.json");
let dt_path = fixtures_dir().join("dt.json");
let coco_gt = COCO::new(>_path).unwrap();
let coco_dt = coco_gt.load_res(&dt_path).unwrap();
let mut ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
ev.evaluate();
let sliced = ev
.slice_by(
vec![
("first_two".to_string(), vec![1, 2]),
("last_one".to_string(), vec![3]),
]
.into_iter()
.collect(),
)
.unwrap();
assert_eq!(sliced.slices.len(), 2);
let first = sliced
.slices
.iter()
.find(|s| s.name == "first_two")
.unwrap();
let last = sliced.slices.iter().find(|s| s.name == "last_one").unwrap();
assert_eq!(first.num_images, 2);
assert_eq!(last.num_images, 1);
let eval_subset = |img_ids: Vec<u64>| -> std::collections::BTreeMap<String, f64> {
let gt = COCO::new(>_path).unwrap();
let dt = gt.load_res(&dt_path).unwrap();
let mut e = COCOeval::new(gt, dt, IouType::Bbox);
e.params.img_ids = img_ids;
e.run();
e.get_results(None, false)
};
for (slice, ids) in [(first, vec![1, 2]), (last, vec![3])] {
let expected = eval_subset(ids);
assert_eq!(
slice.metrics.len(),
expected.len(),
"{}: metric key sets must match",
slice.name
);
for (key, &val) in &slice.metrics {
let exp = expected[key];
assert!(
(val - exp).abs() < 1e-12,
"{} {key}: slice reported {val}, independent eval of the same \
images gives {exp}",
slice.name
);
}
}
assert!(
first
.metrics
.iter()
.any(|(k, v)| (v - last.metrics[k]).abs() > 1e-12),
"disjoint halves reported identical metrics: {:?}",
first.metrics
);
}
#[test]
fn test_slice_by_reserved_name_rejected() {
let gt_path = fixtures_dir().join("gt.json");
let dt_path = fixtures_dir().join("dt.json");
let coco_gt = COCO::new(>_path).unwrap();
let coco_dt = coco_gt.load_res(&dt_path).unwrap();
let mut ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
ev.evaluate();
let result = ev.slice_by(
vec![("_overall".to_string(), vec![1])]
.into_iter()
.collect(),
);
assert!(result.is_err(), "_overall should be a reserved name");
}
#[test]
fn test_slice_by_requires_evaluate() {
let gt_path = fixtures_dir().join("gt.json");
let dt_path = fixtures_dir().join("dt.json");
let coco_gt = COCO::new(>_path).unwrap();
let coco_dt = coco_gt.load_res(&dt_path).unwrap();
let ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
let result = ev.slice_by(vec![("slice".to_string(), vec![1])].into_iter().collect());
assert!(result.is_err(), "should error when evaluate() not called");
}
#[test]
fn test_is_group_of_deserialization() {
let json = r#"{
"id": 1, "image_id": 1, "category_id": 1,
"bbox": [0,0,10,10], "area": 100, "iscrowd": 0,
"is_group_of": true
}"#;
let ann: Annotation = serde_json::from_str(json).unwrap();
assert_eq!(ann.is_group_of, Some(true));
let json2 = r#"{
"id": 2, "image_id": 1, "category_id": 1,
"bbox": [0,0,10,10], "area": 100, "iscrowd": 0
}"#;
let ann2: Annotation = serde_json::from_str(json2).unwrap();
assert_eq!(ann2.is_group_of, None);
let json3 = r#"{
"id": 3, "image_id": 1, "category_id": 1,
"bbox": [0,0,10,10], "area": 100, "iscrowd": 0,
"is_group_of": false
}"#;
let ann3: Annotation = serde_json::from_str(json3).unwrap();
assert_eq!(ann3.is_group_of, Some(false));
}
#[test]
fn test_hierarchy_from_parent_map() {
let mut pm: HashMap<u64, u64> = HashMap::new();
pm.insert(1, 2); pm.insert(4, 2); pm.insert(2, 3);
let h = Hierarchy::from_parent_map(pm);
let dog_ancestors = h.ancestors(1);
assert_eq!(dog_ancestors.len(), 3);
assert_eq!(dog_ancestors[0], 1);
assert_eq!(dog_ancestors[1], 2);
assert_eq!(dog_ancestors[2], 3);
let cat_ancestors = h.ancestors(4);
assert_eq!(cat_ancestors.len(), 3);
assert_eq!(cat_ancestors[0], 4);
assert_eq!(cat_ancestors[1], 2);
assert_eq!(cat_ancestors[2], 3);
let animal_ancestors = h.ancestors(2);
assert_eq!(animal_ancestors.len(), 2);
let entity_ancestors = h.ancestors(3);
assert_eq!(entity_ancestors.len(), 1);
assert_eq!(entity_ancestors[0], 3);
let animal_children = h.children(2);
assert_eq!(animal_children.len(), 2);
assert!(animal_children.contains(&1)); assert!(animal_children.contains(&4));
assert_eq!(h.parent(1), Some(2));
assert_eq!(h.parent(3), None); }
#[test]
fn test_hierarchy_cyclic_parent_map_terminates() {
let mut pm: HashMap<u64, u64> = HashMap::new();
pm.insert(1, 2);
pm.insert(2, 1);
let h = Hierarchy::from_parent_map(pm);
assert!(
h.ancestors(1).len() <= 2,
"a 2-node cycle cannot yield more than 2 ancestors: {:?}",
h.ancestors(1)
);
}
#[test]
fn test_hierarchy_from_categories_supercategory() {
let cats = vec![
Category {
id: 1,
name: "dog".into(),
supercategory: Some("animal".into()),
..Default::default()
},
Category {
id: 2,
name: "animal".into(),
..Default::default()
},
Category {
id: 3,
name: "cat".into(),
supercategory: Some("animal".into()),
..Default::default()
},
];
let h = Hierarchy::from_categories(&cats);
assert_eq!(h.parent(1), Some(2));
assert_eq!(h.parent(3), Some(2));
assert_eq!(h.parent(2), None);
let dog_ancestors = h.ancestors(1);
assert_eq!(dog_ancestors.len(), 2);
assert_eq!(dog_ancestors[0], 1);
assert_eq!(dog_ancestors[1], 2);
}
#[test]
fn test_hierarchy_virtual_nodes() {
let cats = vec![
Category {
id: 1,
name: "car".into(),
supercategory: Some("vehicle".into()),
..Default::default()
},
Category {
id: 2,
name: "truck".into(),
supercategory: Some("vehicle".into()),
..Default::default()
},
];
let h = Hierarchy::from_categories(&cats);
let car_parent = h.parent(1).unwrap();
let truck_parent = h.parent(2).unwrap();
assert_eq!(car_parent, truck_parent);
assert!(
![1, 2].contains(&car_parent),
"virtual node id {car_parent} collides with a real category id"
);
assert_eq!(
h.virtual_names.get(&car_parent).map(String::as_str),
Some("vehicle"),
"virtual node should be named after the unmatched supercategory"
);
assert_eq!(h.ancestors(1).len(), 2);
}
#[test]
fn test_hierarchy_from_oid_json() {
let label_to_id: HashMap<String, u64> = vec![
("/m/entity".to_string(), 100),
("/m/animal".to_string(), 200),
("/m/dog".to_string(), 300),
("/m/cat".to_string(), 400),
]
.into_iter()
.collect();
let json = r#"{
"LabelName": "/m/entity",
"Subcategory": [
{
"LabelName": "/m/animal",
"Subcategory": [
{ "LabelName": "/m/dog" },
{ "LabelName": "/m/cat" }
]
}
]
}"#;
let h = Hierarchy::from_oid_json(json, &label_to_id).unwrap();
assert_eq!(h.parent(300), Some(200));
assert_eq!(h.parent(200), Some(100));
assert_eq!(h.parent(100), None);
let dog_ancestors = h.ancestors(300);
assert_eq!(dog_ancestors.len(), 3);
assert_eq!(dog_ancestors[0], 300);
assert_eq!(dog_ancestors[1], 200);
assert_eq!(dog_ancestors[2], 100);
}
#[test]
fn test_hierarchy_from_oid_json_unknown_labels_skipped() {
let label_to_id: HashMap<String, u64> = vec![("/m/dog".to_string(), 1)].into_iter().collect();
let json = r#"{
"LabelName": "/m/entity",
"Subcategory": [
{
"LabelName": "/m/animal",
"Subcategory": [
{ "LabelName": "/m/dog" }
]
}
]
}"#;
let h = Hierarchy::from_oid_json(json, &label_to_id).unwrap();
let dog_parent = h.parent(1).unwrap();
assert_ne!(dog_parent, 1, "parent must not collide with the real id");
assert_eq!(
h.virtual_names.get(&dog_parent).map(String::as_str),
Some("/m/animal"),
"parent should be the virtual node for the unknown /m/animal label"
);
assert_eq!(h.ancestors(1).len(), 3);
}
#[test]
fn test_gt_expansion_basic() {
let gt_dataset = Dataset {
info: None,
images: vec![Image {
id: 1,
file_name: "img.jpg".into(),
height: 100,
width: 100,
..Default::default()
}],
annotations: vec![Annotation {
id: 1,
image_id: 1,
category_id: 1, bbox: Some([10.0, 10.0, 20.0, 20.0]),
area: Some(400.0),
..Default::default()
}],
categories: vec![
Category {
id: 1,
name: "dog".into(),
..Default::default()
},
Category {
id: 2,
name: "animal".into(),
..Default::default()
},
],
licenses: vec![],
};
let coco = COCO::from_dataset(gt_dataset);
let mut pm: HashMap<u64, u64> = HashMap::new();
pm.insert(1, 2); let hierarchy = Hierarchy::from_parent_map(pm);
let expanded = hotcoco::detection::expand::expand_annotations(&coco, &hierarchy);
assert_eq!(
expanded.dataset.annotations.len(),
2,
"Dog GT should expand to Dog + Animal"
);
let cat_ids: HashSet<u64> = expanded
.dataset
.annotations
.iter()
.map(|a| a.category_id)
.collect();
assert!(cat_ids.contains(&1), "should contain Dog");
assert!(cat_ids.contains(&2), "should contain Animal");
for ann in &expanded.dataset.annotations {
assert_eq!(ann.bbox, Some([10.0, 10.0, 20.0, 20.0]));
assert_eq!(ann.image_id, 1);
}
}
#[test]
fn test_gt_expansion_idempotent() {
let gt_dataset = Dataset {
info: None,
images: vec![Image {
id: 1,
file_name: "img.jpg".into(),
height: 100,
width: 100,
..Default::default()
}],
annotations: vec![
Annotation {
id: 1,
image_id: 1,
category_id: 1, bbox: Some([10.0, 10.0, 20.0, 20.0]),
area: Some(400.0),
..Default::default()
},
Annotation {
id: 2,
image_id: 1,
category_id: 2, bbox: Some([10.0, 10.0, 20.0, 20.0]),
area: Some(400.0),
..Default::default()
},
],
categories: vec![
Category {
id: 1,
name: "dog".into(),
..Default::default()
},
Category {
id: 2,
name: "animal".into(),
..Default::default()
},
],
licenses: vec![],
};
let coco = COCO::from_dataset(gt_dataset);
let mut pm: HashMap<u64, u64> = HashMap::new();
pm.insert(1, 2); let hierarchy = Hierarchy::from_parent_map(pm);
let expanded = hotcoco::detection::expand::expand_annotations(&coco, &hierarchy);
assert_eq!(
expanded.dataset.annotations.len(),
2,
"pre-expanded input should stay at 2 annotations"
);
}
#[test]
fn test_oid_group_of_multi_match() {
const ORDINARY_GT: [f64; 4] = [300.0, 300.0, 100.0, 100.0];
const GROUP_OF_GT: [f64; 4] = [0.0, 0.0, 200.0, 200.0];
const ABSORBED_A: [f64; 4] = [0.0, 0.0, 200.0, 200.0];
const ABSORBED_B: [f64; 4] = [10.0, 10.0, 80.0, 80.0];
assert!(ioa_of(ABSORBED_A, GROUP_OF_GT) >= 0.5);
assert!(ioa_of(ABSORBED_B, GROUP_OF_GT) >= 0.5);
assert!(
iou_of(ABSORBED_B, GROUP_OF_GT) < 0.5,
"DT3 must fail plain IoU, or this test cannot detect a regression to it"
);
assert!(ioa_of(ORDINARY_GT, GROUP_OF_GT) < 0.5);
let cats = vec![cat(1, "person")];
let gt_dataset = dataset(
vec![img(1)],
cats.clone(),
vec![
ann(1, ORDINARY_GT), ann(2, GROUP_OF_GT).group_of(), ],
);
let dt_dataset = dataset(
vec![img(1)],
cats,
vec![
det(1, ORDINARY_GT, 0.9), det(2, ABSORBED_A, 0.8), det(3, ABSORBED_B, 0.7), ],
);
let coco_gt = COCO::from_dataset(gt_dataset);
let coco_dt = COCO::from_dataset(dt_dataset);
let mut ev = COCOeval::new_oid(coco_gt, coco_dt, None);
ev.evaluate();
ev.accumulate();
ev.summarize();
let stats = ev.stats().unwrap();
assert!(
stats[0] > 0.99,
"AP should be ~1.0: both GTs found, surplus group-of detection ignored, got {:.4}",
stats[0]
);
let e = ev
.eval_imgs()
.iter()
.flatten()
.next()
.expect("one image/category cell");
let ignored: Vec<u64> = e
.dt_ids
.iter()
.zip(e.dt_ignore.row(0))
.filter(|&(_, &ig)| ig)
.map(|(&id, _)| id)
.collect();
assert_eq!(
ignored,
vec![3],
"only the surplus detection is ignored; the best one scores the box"
);
assert_eq!(
e.dt_matches[(0, 1)],
2,
"DT2 should be paired with the group-of GT (id 2)"
);
assert_eq!(
e.gt_in_denominator.iter().filter(|&&x| x).count(),
2,
"ordinary GT + group-of box = 2 ground truths in the denominator"
);
let acc = ev.accumulated().expect("accumulate() was called");
let a_idx = ev.params.all_area_idx();
let m_idx = acc.shape.m - 1;
for t in 0..acc.shape.t {
for k in 0..acc.shape.k {
let r = acc.recall[acc.shape.recall_idx(t, k, a_idx, m_idx)];
assert!(
r <= 1.0,
"recall must not exceed 1.0 (t={t}, k={k}), got {r}"
);
}
}
}
#[test]
fn test_oid_group_of_matches_on_ioa_not_iou() {
const ORDINARY_GT: [f64; 4] = [300.0, 300.0, 100.0, 100.0];
const GROUP_OF_GT: [f64; 4] = [0.0, 0.0, 200.0, 200.0];
const INSIDE: [f64; 4] = [10.0, 10.0, 80.0, 80.0];
assert!(
ioa_of(INSIDE, GROUP_OF_GT) >= 0.5,
"the protocol's measure must accept this detection"
);
assert!(
iou_of(INSIDE, GROUP_OF_GT) < 0.5,
"and plain IoU must reject it, or the test cannot tell the two apart"
);
let cats = vec![cat(1, "person")];
let coco_gt = COCO::from_dataset(dataset(
vec![img(1)],
cats.clone(),
vec![ann(1, ORDINARY_GT), ann(2, GROUP_OF_GT).group_of()],
));
let coco_dt = COCO::from_dataset(dataset(
vec![img(1)],
cats,
vec![
det(1, INSIDE, 0.9), det(2, ORDINARY_GT, 0.8), ],
));
let mut ev = COCOeval::new_oid(coco_gt, coco_dt, None);
ev.run();
let stats = ev.stats().unwrap();
assert!(
stats[0] > 0.99,
"detection inside a group-of box must be absorbed, not counted FP; got AP {:.4}",
stats[0]
);
}
#[test]
fn test_oid_undetected_group_of_is_a_miss() {
const FOUND_GT: [f64; 4] = [0.0, 0.0, 100.0, 100.0];
const MISSED_GROUP_OF: [f64; 4] = [400.0, 400.0, 100.0, 100.0];
assert!(ioa_of(FOUND_GT, MISSED_GROUP_OF) < 0.5);
let cats = vec![cat(1, "person")];
let coco_gt = COCO::from_dataset(dataset(
vec![img(1)],
cats.clone(),
vec![ann(1, FOUND_GT), ann(2, MISSED_GROUP_OF).group_of()],
));
let coco_dt = COCO::from_dataset(dataset(vec![img(1)], cats, vec![det(1, FOUND_GT, 0.9)]));
let mut ev = COCOeval::new_oid(coco_gt, coco_dt, None);
ev.run();
let stats = ev.stats().unwrap();
assert!(
(stats[0] - 0.5).abs() < 1e-9,
"AP should be 0.5: the undetected group-of box is a miss, got {:.6}",
stats[0]
);
let e = ev
.eval_imgs()
.iter()
.flatten()
.next()
.expect("one image/category cell");
assert_eq!(
e.num_gt_in_denominator(),
2,
"the group-of box counts toward the denominator even though nothing hit it"
);
assert!(
!e.gt_matched[(0, 1)],
"the group-of box should be unmatched — that is what makes it a miss"
);
}
#[test]
fn test_oid_hierarchy_evaluation() {
let mut parent_map = HashMap::new();
parent_map.insert(1, 2);
parent_map.insert(2, 3);
let hierarchy = Hierarchy::from_parent_map(parent_map);
let gt_dataset = Dataset {
info: None,
images: vec![Image {
id: 1,
file_name: "img1.jpg".into(),
height: 640,
width: 640,
..Default::default()
}],
annotations: vec![Annotation {
id: 1,
image_id: 1,
category_id: 1, bbox: Some([10.0, 10.0, 100.0, 100.0]),
area: Some(10000.0),
..Default::default()
}],
categories: vec![
Category {
id: 1,
name: "poodle".into(),
supercategory: Some("dog".into()),
..Default::default()
},
Category {
id: 2,
name: "dog".into(),
supercategory: Some("animal".into()),
..Default::default()
},
Category {
id: 3,
name: "animal".into(),
..Default::default()
},
],
licenses: vec![],
};
let dt_dataset = Dataset {
info: None,
images: gt_dataset.images.clone(),
annotations: vec![Annotation {
id: 1,
image_id: 1,
category_id: 2, bbox: Some([10.0, 10.0, 100.0, 100.0]),
area: Some(10000.0),
score: Some(0.9),
..Default::default()
}],
categories: gt_dataset.categories.clone(),
licenses: vec![],
};
let coco_gt = COCO::from_dataset(gt_dataset);
let coco_dt = COCO::from_dataset(dt_dataset);
let mut ev = COCOeval::new_oid(coco_gt, coco_dt, Some(hierarchy));
ev.evaluate();
ev.accumulate();
ev.summarize();
let results = ev.results(true).unwrap();
let per_class = results.per_class.as_ref().unwrap();
let dog_ap = per_class
.iter()
.find(|(k, _)| k.contains("dog"))
.map(|(_, &v)| v)
.expect("dog should be in per-class results");
assert!(
(dog_ap - 1.0).abs() < 1e-6,
"Dog AP should be 1.0, got {dog_ap:.6}"
);
}
#[test]
fn test_oid_dt_expansion() {
let mut parent_map = HashMap::new();
parent_map.insert(1, 2);
let hierarchy = Hierarchy::from_parent_map(parent_map);
let img = Image {
id: 1,
file_name: "img1.jpg".into(),
height: 640,
width: 640,
..Default::default()
};
let cats = vec![
Category {
id: 1,
name: "dog".into(),
supercategory: Some("animal".into()),
..Default::default()
},
Category {
id: 2,
name: "animal".into(),
..Default::default()
},
];
let gt_dataset = Dataset {
info: None,
images: vec![img.clone()],
annotations: vec![Annotation {
id: 1,
image_id: 1,
category_id: 2, bbox: Some([10.0, 10.0, 100.0, 100.0]),
area: Some(10000.0),
..Default::default()
}],
categories: cats.clone(),
licenses: vec![],
};
let dt_dataset = Dataset {
info: None,
images: vec![img],
annotations: vec![Annotation {
id: 1,
image_id: 1,
category_id: 1, bbox: Some([10.0, 10.0, 100.0, 100.0]),
area: Some(10000.0),
score: Some(0.9),
..Default::default()
}],
categories: cats,
licenses: vec![],
};
let coco_gt = COCO::from_dataset(gt_dataset.clone());
let coco_dt = COCO::from_dataset(dt_dataset.clone());
let mut ev1 = COCOeval::new_oid(coco_gt, coco_dt, Some(hierarchy.clone()));
ev1.evaluate();
ev1.accumulate();
ev1.summarize();
let stats_no_expand = ev1.stats().unwrap().to_vec();
let coco_gt2 = COCO::from_dataset(gt_dataset);
let coco_dt2 = COCO::from_dataset(dt_dataset);
let mut ev2 = COCOeval::new_oid(coco_gt2, coco_dt2, Some(hierarchy));
ev2.params.expand_dt = true;
ev2.evaluate();
ev2.accumulate();
ev2.summarize();
let stats_expand = ev2.stats().unwrap().to_vec();
assert!(
stats_expand[0] > stats_no_expand[0],
"DT expansion should improve AP: expand={:.4} vs no_expand={:.4}",
stats_expand[0],
stats_no_expand[0]
);
}
#[test]
fn test_oid_auto_derive_hierarchy() {
let gt_dataset = Dataset {
info: None,
images: vec![Image {
id: 1,
file_name: "img1.jpg".into(),
height: 640,
width: 640,
..Default::default()
}],
annotations: vec![Annotation {
id: 1,
image_id: 1,
category_id: 1,
bbox: Some([10.0, 10.0, 100.0, 100.0]),
area: Some(10000.0),
..Default::default()
}],
categories: vec![
Category {
id: 1,
name: "dog".into(),
supercategory: Some("animal".into()),
..Default::default()
},
Category {
id: 2,
name: "animal".into(),
..Default::default()
},
],
licenses: vec![],
};
let dt_dataset = Dataset {
info: None,
images: gt_dataset.images.clone(),
annotations: vec![Annotation {
id: 1,
image_id: 1,
category_id: 2, bbox: Some([10.0, 10.0, 100.0, 100.0]),
area: Some(10000.0),
score: Some(0.9),
..Default::default()
}],
categories: gt_dataset.categories.clone(),
licenses: vec![],
};
let coco_gt = COCO::from_dataset(gt_dataset);
let coco_dt = COCO::from_dataset(dt_dataset);
let mut ev = COCOeval::new_oid(coco_gt, coco_dt, None);
ev.evaluate();
ev.accumulate();
ev.summarize();
let stats = ev.stats().unwrap();
assert!(
stats[0] > 0.0,
"Auto-derived hierarchy should enable hierarchical matching, got AP={:.4}",
stats[0]
);
}
#[test]
fn test_calibration_basic() {
let gt_path = fixtures_dir().join("gt.json");
let dt_path = fixtures_dir().join("dt.json");
let coco_gt = COCO::new(>_path).expect("Failed to load GT");
let coco_dt = coco_gt.load_res(&dt_path).expect("Failed to load DT");
let mut ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
ev.evaluate();
let cal = ev.calibration(10, 0.5).expect("calibration should succeed");
assert_eq!(cal.n_bins, 10);
assert_eq!(cal.bins.len(), 10);
assert!((cal.iou_threshold - 0.5).abs() < 1e-9);
assert!(cal.num_detections > 0, "should have some detections");
assert!(cal.ece >= 0.0, "ECE must be non-negative");
assert!(cal.mce >= 0.0, "MCE must be non-negative");
assert!(cal.mce >= cal.ece, "MCE must be >= ECE");
assert!(!cal.per_category.is_empty());
let total: usize = cal.bins.iter().map(|b| b.count).sum();
assert_eq!(total, cal.num_detections);
}
#[test]
fn test_calibration_requires_evaluate() {
let gt_path = fixtures_dir().join("gt.json");
let dt_path = fixtures_dir().join("dt.json");
let coco_gt = COCO::new(>_path).expect("Failed to load GT");
let coco_dt = coco_gt.load_res(&dt_path).expect("Failed to load DT");
let ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
let result = ev.calibration(10, 0.5);
assert!(result.is_err(), "should fail before evaluate()");
}
#[test]
fn test_calibration_invalid_iou_threshold() {
let gt_path = fixtures_dir().join("gt.json");
let dt_path = fixtures_dir().join("dt.json");
let coco_gt = COCO::new(>_path).expect("Failed to load GT");
let coco_dt = coco_gt.load_res(&dt_path).expect("Failed to load DT");
let mut ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
ev.evaluate();
let result = ev.calibration(10, 0.42);
assert!(
result.is_err(),
"should fail with non-standard IoU threshold"
);
}
#[test]
fn test_calibration_rejects_unnormalized_scores() {
let cats = vec![cat(1, "person")];
let gt = dataset(
vec![img(1)],
cats.clone(),
vec![ann(1, [10.0, 10.0, 50.0, 50.0])],
);
let dt = dataset(
vec![img(1)],
cats,
vec![det(1, [10.0, 10.0, 50.0, 50.0], 7.4)],
);
let mut ev = COCOeval::new(
COCO::from_dataset(gt),
COCO::from_dataset(dt),
IouType::Bbox,
);
ev.evaluate();
let err = ev
.calibration(10, 0.5)
.expect_err("a score of 7.4 is not a confidence and must be rejected");
let msg = err.to_string();
assert!(
msg.contains("7.4") && msg.contains("[0, 1]"),
"error should name the offending score and the required range, got: {msg}"
);
}
#[test]
fn test_calibration_known_values() {
let gt_dataset = Dataset {
info: None,
images: vec![Image {
id: 1,
file_name: "a.jpg".into(),
height: 100,
width: 100,
..Default::default()
}],
annotations: vec![
Annotation {
id: 1,
image_id: 1,
category_id: 1,
bbox: Some([10.0, 10.0, 30.0, 30.0]),
area: Some(900.0),
..Default::default()
},
Annotation {
id: 2,
image_id: 1,
category_id: 1,
bbox: Some([60.0, 60.0, 30.0, 30.0]),
area: Some(900.0),
..Default::default()
},
],
categories: vec![Category {
id: 1,
name: "obj".into(),
supercategory: Some(String::new()),
..Default::default()
}],
licenses: vec![],
};
let dt_dataset = Dataset {
info: None,
images: vec![],
annotations: vec![
Annotation {
id: 1,
image_id: 1,
category_id: 1,
bbox: Some([10.0, 10.0, 30.0, 30.0]),
area: Some(900.0),
score: Some(0.9),
..Default::default()
},
Annotation {
id: 2,
image_id: 1,
category_id: 1,
bbox: Some([60.0, 60.0, 30.0, 30.0]),
area: Some(900.0),
score: Some(0.9),
..Default::default()
},
Annotation {
id: 3,
image_id: 1,
category_id: 1,
bbox: Some([0.0, 0.0, 5.0, 5.0]),
area: Some(25.0),
score: Some(0.2),
..Default::default()
},
Annotation {
id: 4,
image_id: 1,
category_id: 1,
bbox: Some([90.0, 90.0, 5.0, 5.0]),
area: Some(25.0),
score: Some(0.2),
..Default::default()
},
],
categories: vec![],
licenses: vec![],
};
let coco_gt = COCO::from_dataset(gt_dataset);
let coco_dt = COCO::from_dataset(dt_dataset);
let mut ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
ev.evaluate();
let cal = ev.calibration(10, 0.5).expect("calibration should succeed");
assert_eq!(cal.num_detections, 4);
let low_bin = &cal.bins[2]; assert_eq!(low_bin.count, 2);
assert!((low_bin.avg_accuracy - 0.0).abs() < 1e-9);
let high_bin = &cal.bins[9]; assert_eq!(high_bin.count, 2);
assert!((high_bin.avg_accuracy - 1.0).abs() < 1e-9);
assert!(
(cal.ece - 0.15).abs() < 1e-9,
"Expected ECE=0.15, got {:.6}",
cal.ece
);
assert!(
(cal.mce - 0.2).abs() < 1e-9,
"Expected MCE=0.2, got {:.6}",
cal.mce
);
}
fn make_compare_fixtures(n: usize) -> (Dataset, Dataset, Dataset) {
let cat = Category {
id: 1,
name: "thing".into(),
..Default::default()
};
let images: Vec<Image> = (1..=n)
.map(|i| Image {
id: i as u64,
file_name: format!("img{i}.jpg"),
height: 200,
width: 200,
..Default::default()
})
.collect();
let gt_anns: Vec<Annotation> = (1..=n)
.map(|i| Annotation {
id: i as u64,
image_id: i as u64,
category_id: 1,
bbox: Some([10.0, 10.0, 50.0, 50.0]),
area: Some(2500.0),
..Default::default()
})
.collect();
let make_ann = |id: u64, image_id: u64, bbox: [f64; 4], score: f64| Annotation {
id,
image_id,
category_id: 1,
bbox: Some(bbox),
area: Some(bbox[2] * bbox[3]),
score: Some(score),
..Default::default()
};
let dt_good_anns: Vec<Annotation> = (1..=n)
.map(|i| make_ann(i as u64, i as u64, [10.0, 10.0, 50.0, 50.0], 0.9))
.collect();
let half = n / 2;
let mut dt_weak_anns: Vec<Annotation> = Vec::new();
for i in 1..=half {
dt_weak_anns.push(make_ann(i as u64, i as u64, [10.0, 10.0, 50.0, 50.0], 0.8));
dt_weak_anns.push(make_ann(
(n + i) as u64,
i as u64,
[120.0, 120.0, 30.0, 30.0],
0.6,
));
}
let dt_good = Dataset {
info: None,
images: images.clone(),
annotations: dt_good_anns,
categories: vec![cat.clone()],
licenses: vec![],
};
let dt_weak = Dataset {
info: None,
images: images.clone(),
annotations: dt_weak_anns,
categories: vec![cat.clone()],
licenses: vec![],
};
let gt = Dataset {
info: None,
images,
annotations: gt_anns,
categories: vec![cat],
licenses: vec![],
};
(gt, dt_good, dt_weak)
}
#[test]
fn test_compare_bootstrap_ci_contains_point_estimate() {
let (gt_ds, dt_good_ds, dt_weak_ds) = make_compare_fixtures(30);
let gt_a = COCO::from_dataset(gt_ds.clone());
let dt_a = COCO::from_dataset(dt_good_ds);
let mut ev_a = COCOeval::new(gt_a, dt_a, IouType::Bbox);
ev_a.evaluate();
let gt_b = COCO::from_dataset(gt_ds);
let dt_b = COCO::from_dataset(dt_weak_ds);
let mut ev_b = COCOeval::new(gt_b, dt_b, IouType::Bbox);
ev_b.evaluate();
let opts = hotcoco::CompareOpts {
n_bootstrap: 200,
seed: 42,
confidence: 0.95,
};
let result = hotcoco::compare(&ev_a, &ev_b, &opts).unwrap();
let ap_delta = result.deltas["AP"];
assert!(
ap_delta < -0.01,
"Expected negative AP delta (weak vs good), got {ap_delta}"
);
let ci = result.ci.as_ref().unwrap();
for (key, boot_ci) in ci {
let delta = result.deltas[key];
if delta.abs() < 1e-15 {
continue;
}
assert!(
boot_ci.lower <= delta && delta <= boot_ci.upper,
"{key}: point estimate {delta:.6} outside CI [{:.6}, {:.6}]",
boot_ci.lower,
boot_ci.upper,
);
assert!(
boot_ci.upper >= boot_ci.lower,
"{key}: inverted CI [{:.6}, {:.6}]",
boot_ci.lower,
boot_ci.upper,
);
assert!(boot_ci.std_err >= 0.0, "{key}: negative std_err");
}
let ap_ci = &ci["AP"];
assert!(
ap_ci.upper < 0.0,
"Expected AP CI entirely below zero for weak-vs-good, got [{:.6}, {:.6}]",
ap_ci.lower,
ap_ci.upper,
);
assert!(
ap_ci.prob_positive < 0.1,
"Expected low prob_positive for negative delta, got {:.3}",
ap_ci.prob_positive,
);
}
#[test]
fn test_compare_bootstrap_coverage() {
let (gt_ds, dt_good_ds, dt_weak_ds) = make_compare_fixtures(30);
let gt_a = COCO::from_dataset(gt_ds.clone());
let dt_a = COCO::from_dataset(dt_good_ds.clone());
let mut ev_a = COCOeval::new(gt_a, dt_a, IouType::Bbox);
ev_a.evaluate();
let gt_b = COCO::from_dataset(gt_ds.clone());
let dt_b = COCO::from_dataset(dt_weak_ds.clone());
let mut ev_b = COCOeval::new(gt_b, dt_b, IouType::Bbox);
ev_b.evaluate();
let baseline = hotcoco::compare(&ev_a, &ev_b, &hotcoco::CompareOpts::default()).unwrap();
let true_ap_delta = baseline.deltas["AP"];
let n_trials = 50;
let mut covers = 0;
for seed in 0..n_trials {
let gt_a = COCO::from_dataset(gt_ds.clone());
let dt_a = COCO::from_dataset(dt_good_ds.clone());
let mut ev_a = COCOeval::new(gt_a, dt_a, IouType::Bbox);
ev_a.evaluate();
let gt_b = COCO::from_dataset(gt_ds.clone());
let dt_b = COCO::from_dataset(dt_weak_ds.clone());
let mut ev_b = COCOeval::new(gt_b, dt_b, IouType::Bbox);
ev_b.evaluate();
let opts = hotcoco::CompareOpts {
n_bootstrap: 200,
seed,
confidence: 0.95,
};
let result = hotcoco::compare(&ev_a, &ev_b, &opts).unwrap();
let ci = result.ci.as_ref().unwrap();
let ap_ci = &ci["AP"];
if ap_ci.lower <= true_ap_delta && true_ap_delta <= ap_ci.upper {
covers += 1;
}
let width = ap_ci.upper - ap_ci.lower;
assert!(
width.is_finite() && width >= 0.0,
"seed {seed}: CI [{:.6}, {:.6}] is not a finite interval",
ap_ci.lower,
ap_ci.upper
);
assert!(
width < 1.0,
"seed {seed}: CI width {width:.4} spans most of the possible delta \
range — an uninformative interval that coverage alone cannot catch"
);
}
let coverage = covers as f64 / n_trials as f64;
assert!(
coverage >= 0.80,
"Bootstrap coverage {:.1}% ({covers}/{n_trials}) is too low — expected ≥80% for 95% CI",
coverage * 100.0
);
}
#[test]
fn test_obb_eval_basic() {
let gt_dataset = Dataset {
info: None,
images: vec![img(1)],
annotations: vec![Annotation {
id: 1,
image_id: 1,
category_id: 1,
bbox: Some([90.0, 90.0, 220.0, 120.0]),
area: Some(20000.0),
obb: Some([200.0, 150.0, 200.0, 100.0, 0.3]),
..Default::default()
}],
categories: vec![cat(1, "vehicle")],
licenses: vec![],
};
let dt_dataset = Dataset {
info: None,
images: gt_dataset.images.clone(),
annotations: vec![Annotation {
id: 1,
image_id: 1,
category_id: 1,
bbox: Some([90.0, 90.0, 220.0, 120.0]),
area: Some(20000.0),
obb: Some([200.0, 150.0, 200.0, 100.0, 0.3]),
score: Some(0.99),
..Default::default()
}],
categories: gt_dataset.categories.clone(),
licenses: vec![],
};
let coco_gt = COCO::from_dataset(gt_dataset);
let coco_dt = COCO::from_dataset(dt_dataset);
let mut ev = COCOeval::new(coco_gt, coco_dt, IouType::Obb);
ev.evaluate();
ev.accumulate();
ev.summarize();
let stats = ev.stats().unwrap();
assert_eq!(stats.len(), 12, "OBB should produce 12 metrics");
assert!(
stats[0] > 0.99,
"AP should be ~1.0 for identical OBBs, got {}",
stats[0]
);
}
#[test]
fn test_obb_eval_no_overlap() {
let gt_dataset = Dataset {
info: None,
images: vec![img(1)],
annotations: vec![Annotation {
id: 1,
image_id: 1,
category_id: 1,
bbox: Some([0.0, 0.0, 50.0, 50.0]),
area: Some(2500.0),
obb: Some([25.0, 25.0, 50.0, 50.0, 0.0]),
..Default::default()
}],
categories: vec![cat(1, "vehicle")],
licenses: vec![],
};
let dt_dataset = Dataset {
info: None,
images: gt_dataset.images.clone(),
annotations: vec![Annotation {
id: 1,
image_id: 1,
category_id: 1,
bbox: Some([700.0, 500.0, 50.0, 50.0]),
area: Some(2500.0),
obb: Some([725.0, 525.0, 50.0, 50.0, 0.0]),
score: Some(0.9),
..Default::default()
}],
categories: gt_dataset.categories.clone(),
licenses: vec![],
};
let coco_gt = COCO::from_dataset(gt_dataset);
let coco_dt = COCO::from_dataset(dt_dataset);
let mut ev = COCOeval::new(coco_gt, coco_dt, IouType::Obb);
ev.evaluate();
ev.accumulate();
ev.summarize();
let stats = ev.stats().unwrap();
assert_eq!(stats.len(), 12);
assert_eq!(
stats[0], 0.0,
"AP must be exactly 0.0 for non-overlapping OBBs (-1.0 would mean the \
cell was never computed)"
);
assert_eq!(stats[4], 0.0, "APm: the medium GT was evaluated and missed");
assert_eq!(stats[3], -1.0, "APs: no small GT, so the sentinel");
}
#[test]
fn test_dota_round_trip_integration() {
let dataset = Dataset {
info: None,
images: vec![img(1)],
annotations: vec![Annotation {
id: 1,
image_id: 1,
category_id: 1,
bbox: Some([90.0, 90.0, 220.0, 120.0]),
area: Some(20000.0),
obb: Some([200.0, 150.0, 200.0, 100.0, 0.0]),
..Default::default()
}],
categories: vec![cat(1, "vehicle")],
licenses: vec![],
};
let tmp = tempfile::TempDir::new().unwrap();
let label_dir = tmp.path().join("labels");
let stats = coco_to_dota(&dataset, &label_dir).unwrap();
assert_eq!(stats.images, 1);
assert_eq!(stats.annotations, 1);
let mut dims = HashMap::new();
dims.insert("obb_test".into(), (800u32, 600u32));
let result = dota_to_coco(&label_dir, None, &dims).unwrap();
assert_eq!(result.categories[0].name, "vehicle");
assert_geometry_round_trip(
&dataset,
&result,
file_stem_key,
obb_of,
[0.2, 0.2, 0.2, 0.2, 0.01],
);
}
#[test]
fn test_empty_max_dets_degrades_gracefully() {
let gt_path = fixtures_dir().join("gt.json");
let dt_path = fixtures_dir().join("dt.json");
let coco_gt = COCO::new(>_path).expect("Failed to load GT");
let coco_dt = coco_gt.load_res(&dt_path).expect("Failed to load DT");
let mut coco_eval = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
coco_eval.params.max_dets = vec![];
coco_eval.run();
let stats = coco_eval.stats().expect("summarize ran");
assert!(!stats.is_empty());
assert!(
stats.iter().all(|&v| v == -1.0),
"no max-det slots means nothing is computable: {stats:?}"
);
}
fn one_box_pair(
image_id: u64,
gt_bbox: [f64; 4],
dt_bbox: [f64; 4],
) -> (Image, Annotation, Annotation) {
let img = Image {
id: image_id,
file_name: format!("img{image_id}.jpg"),
height: 200,
width: 200,
..Default::default()
};
let gt = Annotation {
id: image_id * 10,
image_id,
category_id: 1,
bbox: Some(gt_bbox),
area: Some(gt_bbox[2] * gt_bbox[3]),
..Default::default()
};
let dt = Annotation {
id: image_id * 10 + 1,
image_id,
category_id: 1,
bbox: Some(dt_bbox),
area: Some(dt_bbox[2] * dt_bbox[3]),
score: Some(0.9),
..Default::default()
};
(img, gt, dt)
}
#[test]
fn test_match_floor_clamped_at_iou_threshold_one() {
let (img1, gt1, dt1) = one_box_pair(
1,
[0.0, 0.0, 100.0, 100.0],
[0.0, 0.0, 100.0, 100.000000005],
);
let (img2, gt2, dt2) = one_box_pair(2, [0.0, 0.0, 100.0, 100.0], [0.0, 0.0, 100.0, 100.0]);
let categories = vec![Category {
id: 1,
name: "thing".into(),
..Default::default()
}];
let gt_dataset = Dataset {
info: None,
images: vec![img1.clone(), img2.clone()],
annotations: vec![gt1, gt2],
categories: categories.clone(),
licenses: vec![],
};
let dt_dataset = Dataset {
info: None,
images: vec![img1, img2],
annotations: vec![dt1, dt2],
categories,
licenses: vec![],
};
let coco_gt = COCO::from_dataset(gt_dataset);
let coco_dt = COCO::from_dataset(dt_dataset);
let mut ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
ev.params.iou_thrs = vec![1.0];
ev.evaluate();
ev.accumulate();
ev.summarize();
let ap = ev.stats().expect("stats")[0];
assert!(
(ap - 1.0).abs() < 1e-12,
"AP at iou_thr=1.0 should be 1.0 with the pycocotools match floor \
(got {ap}); 0.5 means the near-identical pair was not matched, i.e. \
the min(t, 1-1e-10) clamp is missing"
);
}
#[test]
fn test_match_floor_is_inert_below_one() {
use hotcoco::primitives::greedy::coco_match_floor;
for t in [0.0, 0.1, 0.5, 0.75, 0.95, 0.99, 0.999999] {
assert_eq!(coco_match_floor(t), t, "clamp must not fire at t={t}");
}
assert_eq!(coco_match_floor(1.0), 1.0 - 1e-10);
}
#[test]
fn crate_root_api_surface_resolves() {
let gt_path = fixtures_dir().join("gt.json");
let coco = COCO::new(>_path).expect("Failed to load GT");
let hierarchy = Hierarchy::from_categories(&coco.dataset.categories);
let _expanded = hotcoco::detection::expand::expand_annotations(&coco, &hierarchy);
let _: Option<hotcoco::COCOeval> = None;
let _: Option<hotcoco::EvalImg> = None;
let _: Option<hotcoco::AccumulatedEval> = None;
let _: Option<hotcoco::EvalShape> = None;
let _: Option<hotcoco::EvalMode> = None;
let _: Option<hotcoco::EvalParams> = None;
let _: Option<hotcoco::EvalResults> = None;
let _: Option<hotcoco::ConfusionMatrix> = None;
let _: Option<hotcoco::TideErrors> = None;
let _: Option<hotcoco::SliceResult> = None;
let _: Option<hotcoco::ComparisonResult> = None;
let _: Option<hotcoco::BootstrapCI> = None;
let _: Option<hotcoco::CalibrationBin> = None;
let _: Option<hotcoco::Hierarchy> = None;
let _: Option<hotcoco::HealthReport> = None;
let _: Option<hotcoco::Finding> = None;
let _: Option<hotcoco::Layer> = None;
let _: Option<hotcoco::DatasetSummary> = None;
let _: Option<hotcoco::SummaryStats> = None;
let _: Option<hotcoco::CategoryStats> = None;
let _: Option<hotcoco::DatasetStats> = None;
let _: Option<hotcoco::EvalReport> = None;
let _: Option<hotcoco::Provenance> = None;
let ap = hotcoco::metrics::counts::average_precision(&[0.9], &[true], None, 1, &[0.0, 1.0]);
assert!(ap.is_finite());
let bins = hotcoco::metrics::calibration::calibration_curve(&[0.9], &[true], 4);
assert_eq!(bins.len(), 4);
let cm = hotcoco::metrics::confusion::confusion_matrix(&[Some(0)], &[Some(0)], 2);
assert_eq!(cm.len(), 9);
let (rows, _) = hotcoco::primitives::assign::lsap(&[1.0, 2.0, 3.0, 4.0], 2, 2, false);
assert_eq!(rows.len(), 2);
}
fn load_bbox_fixtures() -> COCOeval {
let coco_gt = COCO::new(&fixtures_dir().join("gt.json")).expect("Failed to load GT");
let coco_dt = coco_gt
.load_res(&fixtures_dir().join("dt.json"))
.expect("Failed to load DT");
COCOeval::new(coco_gt, coco_dt, IouType::Bbox)
}
fn bbox_eval_on_fixtures() -> COCOeval {
let mut ev = load_bbox_fixtures();
ev.run();
ev
}
#[test]
fn test_report_and_results_agree_on_metrics() {
let ev = bbox_eval_on_fixtures();
let report = ev.report().expect("report");
let results = ev.results(true).expect("results");
assert_eq!(report.metrics.len(), results.metrics.len());
for (key, value) in &results.metrics {
assert_eq!(
report.metric(key),
Some(*value),
"metric {key} differs between report() and results()"
);
}
let per_class = results.per_class.expect("per_class requested");
assert_eq!(per_class.len(), report.per_class.len());
for (name, ap) in &per_class {
assert_eq!(report.per_class[name]["AP"], *ap, "per-class AP for {name}");
}
}
#[test]
fn test_eval_results_json_shape_is_stable() {
let ev = bbox_eval_on_fixtures();
let parsed: serde_json::Value =
serde_json::from_str(&ev.results(true).expect("results").to_json().expect("json"))
.expect("valid json");
let obj = parsed.as_object().expect("top level is an object");
let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(
keys,
[
"hotcoco_version",
"metrics",
"params",
"per_class",
"provenance"
],
"EvalResults gained or lost a top-level key"
);
assert_eq!(
parsed["provenance"], "parity_verified",
"default bbox params on the standard fixture are reference-comparable"
);
let mut param_keys: Vec<&str> = parsed["params"]
.as_object()
.expect("params object")
.keys()
.map(String::as_str)
.collect();
param_keys.sort_unstable();
assert_eq!(
param_keys,
[
"area_ranges",
"eval_mode",
"iou_thresholds",
"iou_type",
"kpt_oks_sigmas",
"max_dets",
"recall_thresholds",
"reference_deviations",
"use_cats"
],
"EvalParams gained or lost a key"
);
assert_eq!(
parsed["params"]["reference_deviations"],
serde_json::json!([]),
"default bbox params must archive an empty deviation list"
);
let without: serde_json::Value =
serde_json::from_str(&ev.results(false).expect("results").to_json().expect("json"))
.expect("valid json");
assert!(
without
.as_object()
.expect("object")
.get("per_class")
.is_none(),
"per_class must be omitted, not null, when not requested"
);
}
#[test]
fn test_report_provenance_marks_obb_as_extension() {
let ev = bbox_eval_on_fixtures();
let report = ev.report().expect("report");
assert_eq!(report.task, "detection");
assert_eq!(report.provenance, hotcoco::Provenance::ParityVerified);
assert!(report.provenance.is_benchmark_standard());
let coco_gt = COCO::new(&fixtures_dir().join("gt.json")).expect("GT");
let coco_dt = coco_gt
.load_res(&fixtures_dir().join("dt.json"))
.expect("DT");
let mut obb = COCOeval::new(coco_gt, coco_dt, IouType::Obb);
obb.run();
let obb_report = obb.report().expect("report");
assert_eq!(obb_report.provenance, hotcoco::Provenance::Extension);
assert!(
!obb_report.provenance.is_benchmark_standard(),
"OBB has no reference implementation and must not read as leaderboard-comparable"
);
}
#[test]
fn test_report_curves_are_the_aggregate_slice() {
let ev = bbox_eval_on_fixtures();
let report = ev.report().expect("report");
let acc = ev.accumulated().expect("accumulated");
assert_eq!(
report.curves.len(),
ev.params.iou_thrs.len() + 1,
"one curve per IoU threshold, plus rec_thrs"
);
let rec_thrs = &report.curves["rec_thrs"];
assert_eq!(rec_thrs.len(), acc.shape.r);
for thr in &ev.params.iou_thrs {
let curve = &report.curves[&format!("pr@{thr:.2}")];
assert_eq!(
curve.len(),
acc.shape.r,
"curve is indexed by recall threshold"
);
}
for (name, curve) in &report.curves {
if name == "rec_thrs" {
continue;
}
for &v in curve {
assert!((0.0..=1.0).contains(&v) || v == -1.0, "{name} has {v}");
}
}
}
#[test]
fn test_report_requires_summarize() {
let coco_gt = COCO::new(&fixtures_dir().join("gt.json")).expect("GT");
let coco_dt = coco_gt
.load_res(&fixtures_dir().join("dt.json"))
.expect("DT");
let ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
assert!(
ev.report().is_err(),
"report() before summarize() must error"
);
}
fn assert_eval_invariants(ev: &COCOeval, label: &str) {
let acc = ev
.accumulated()
.unwrap_or_else(|| panic!("{label}: accumulate() must have been called"));
let a_idx = ev.params.all_area_idx();
for (i, &r) in acc.recall.iter().enumerate() {
assert!(
r == -1.0 || (0.0..=1.0).contains(&r),
"{label}: recall {r} outside [0,1] at flat index {i}"
);
}
for (i, &p) in acc.precision.iter().enumerate() {
assert!(
p == -1.0 || (0.0..=1.0).contains(&p),
"{label}: precision {p} outside [0,1] at flat index {i}"
);
}
for t in 0..acc.shape.t {
for k in 0..acc.shape.k {
let at = |m: usize| acc.recall[acc.shape.recall_idx(t, k, a_idx, m)];
for m in 1..acc.shape.m {
let (prev, cur) = (at(m - 1), at(m));
if prev == -1.0 || cur == -1.0 {
continue;
}
assert!(
cur >= prev - 1e-12,
"{label}: recall fell from {prev} to {cur} when raising maxDets \
(t={t}, k={k}, m={} -> {m})",
m - 1
);
}
}
}
}
#[test]
fn eval_invariants_hold_across_fixtures() {
for (gt_name, dt_name) in [
("gt.json", "dt.json"),
("edge_gt.json", "edge_dt.json"),
("zero_gt.json", "zero_dt.json"),
] {
let gt = COCO::new(&fixtures_dir().join(gt_name)).unwrap();
let dt = gt.load_res(&fixtures_dir().join(dt_name)).unwrap();
let mut ev = COCOeval::new(gt, dt, IouType::Bbox);
ev.run();
assert_eval_invariants(&ev, gt_name);
for (i, &v) in ev.stats().unwrap().iter().enumerate() {
assert!(
v == -1.0 || (0.0..=1.0).contains(&v),
"{gt_name}: stats[{i}] = {v} outside [0,1] and not the -1.0 sentinel"
);
}
}
}
#[test]
fn eval_invariants_hold_for_open_images() {
const ORDINARY_GT: [f64; 4] = [300.0, 300.0, 100.0, 100.0];
const GROUP_OF_GT: [f64; 4] = [0.0, 0.0, 200.0, 200.0];
let cats = vec![cat(1, "person")];
let gt = dataset(
vec![img(1)],
cats.clone(),
vec![ann(1, ORDINARY_GT), ann(2, GROUP_OF_GT).group_of()],
);
let dt = dataset(
vec![img(1)],
cats,
vec![
det(1, ORDINARY_GT, 0.9),
det(2, [0.0, 0.0, 200.0, 200.0], 0.85),
det(3, [10.0, 10.0, 190.0, 190.0], 0.8),
det(4, [5.0, 5.0, 195.0, 195.0], 0.75),
],
);
let mut ev = COCOeval::new_oid(COCO::from_dataset(gt), COCO::from_dataset(dt), None);
ev.run();
assert_eval_invariants(&ev, "open images group-of");
}
fn provenance_of(mut ev: COCOeval) -> Provenance {
ev.run();
ev.report().expect("report() succeeds").provenance
}
#[test]
fn default_params_are_parity_verified() {
assert_eq!(
provenance_of(load_bbox_fixtures()),
Provenance::ParityVerified
);
}
#[test]
fn provenance_accessor_agrees_with_report() {
let mut verified = load_bbox_fixtures();
verified.run();
assert_eq!(
verified.provenance(),
verified.report().expect("report() succeeds").provenance,
"accessor and report disagree on a default run"
);
assert_eq!(verified.provenance(), Provenance::ParityVerified);
let mut extension = load_bbox_fixtures();
extension.params.iou_thrs = vec![0.5, 0.75];
extension.run();
assert_eq!(
extension.provenance(),
extension.report().expect("report() succeeds").provenance,
"accessor and report disagree on a custom-parameter run"
);
assert_eq!(extension.provenance(), Provenance::Extension);
}
#[test]
fn custom_iou_thrs_downgrade_to_extension() {
let mut ev = load_bbox_fixtures();
ev.params.iou_thrs = vec![0.5, 0.75];
assert_eq!(provenance_of(ev), Provenance::Extension);
}
#[test]
fn custom_max_dets_downgrade_to_extension() {
let mut ev = load_bbox_fixtures();
ev.params.max_dets = vec![1, 10, 50];
assert_eq!(provenance_of(ev), Provenance::Extension);
}
#[test]
fn custom_area_range_labels_downgrade_to_extension() {
let mut ev = load_bbox_fixtures();
for (i, ar) in ev.params.area_ranges.iter_mut().enumerate() {
ar.label = format!("bucket{i}");
}
assert_eq!(provenance_of(ev), Provenance::Extension);
}
#[test]
fn custom_area_range_bounds_downgrade_even_with_default_labels() {
let mut ev = load_bbox_fixtures();
let labels_before: Vec<String> = ev
.params
.area_ranges
.iter()
.map(|ar| ar.label.clone())
.collect();
for ar in &mut ev.params.area_ranges {
if ar.label == "small" {
ar.range = [0.0, 100.0];
}
}
let labels_after: Vec<String> = ev
.params
.area_ranges
.iter()
.map(|ar| ar.label.clone())
.collect();
assert_eq!(
labels_before, labels_after,
"the point of this test is that labels are unchanged"
);
assert_eq!(provenance_of(ev), Provenance::Extension);
}
#[test]
fn custom_rec_thrs_downgrade_to_extension() {
let mut ev = load_bbox_fixtures();
ev.params.rec_thrs = (0..=10).map(|i| f64::from(i) / 10.0).collect();
assert_eq!(provenance_of(ev), Provenance::Extension);
}
#[test]
fn class_agnostic_pooling_downgrades_to_extension() {
let mut ev = load_bbox_fixtures();
ev.params.use_cats = false;
assert_eq!(provenance_of(ev), Provenance::Extension);
}
#[test]
fn custom_kpt_oks_sigmas_downgrade_to_extension() {
let gt = COCO::new(&fixtures_dir().join("gt.json")).unwrap();
let dt = gt.load_res(&fixtures_dir().join("dt.json")).unwrap();
let mut ev = COCOeval::new(gt, dt, IouType::Keypoints);
ev.params.kpt_oks_sigmas = vec![0.05; ev.params.kpt_oks_sigmas.len()];
assert_eq!(provenance_of(ev), Provenance::Extension);
}
#[test]
fn open_images_downgrades_to_extension() {
let gt = COCO::new(&fixtures_dir().join("gt.json")).unwrap();
let dt = gt.load_res(&fixtures_dir().join("dt.json")).unwrap();
assert_eq!(
provenance_of(COCOeval::new_oid(gt, dt, None)),
Provenance::Extension
);
}
#[test]
fn evaluation_is_independent_of_thread_count() {
fn eval_on(threads: usize) -> Vec<f64> {
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.build()
.expect("thread pool");
pool.install(|| {
let gt = COCO::new(&fixtures_dir().join("edge_gt.json")).unwrap();
let dt = gt.load_res(&fixtures_dir().join("edge_dt.json")).unwrap();
run_bbox_eval(gt, dt)
})
}
let single = eval_on(1);
for threads in [2usize, 3, 4, 8] {
let many = eval_on(threads);
assert_eq!(single.len(), many.len());
for (i, (&a, &b)) in single.iter().zip(many.iter()).enumerate() {
assert_eq!(
a.to_bits(),
b.to_bits(),
"stats[{i}] differs between 1 thread ({a}) and {threads} threads ({b})"
);
}
}
}
#[test]
fn tide_errors_is_deterministic_across_thread_counts_with_tied_scores() {
fn build_gt() -> COCO {
COCO::from_dataset(dataset(
(1..=7).map(img).collect(),
vec![cat(1, "cat1"), cat(2, "cat2")],
vec![
ann(1, [0.0, 0.0, 50.0, 50.0]).in_img(1).in_cat(1),
ann(2, [0.0, 0.0, 50.0, 50.0]).in_img(3).in_cat(1),
ann(3, [0.0, 0.0, 50.0, 50.0]).in_img(5).in_cat(1),
ann(4, [0.0, 0.0, 50.0, 50.0]).in_img(7).in_cat(1),
ann(5, [0.0, 0.0, 50.0, 50.0]).in_img(2).in_cat(2),
ann(6, [0.0, 0.0, 50.0, 50.0]).in_img(4).in_cat(2),
ann(7, [0.0, 0.0, 50.0, 50.0]).in_img(6).in_cat(2),
],
))
}
fn build_dt() -> COCO {
COCO::from_dataset(dataset(
(1..=7).map(img).collect(),
vec![cat(1, "cat1"), cat(2, "cat2")],
vec![
det(101, [0.0, 0.0, 50.0, 50.0], 0.5).in_img(1).in_cat(1),
det(102, [200.0, 200.0, 10.0, 10.0], 0.5)
.in_img(3)
.in_cat(1),
det(103, [0.0, 0.0, 50.0, 50.0], 0.5).in_img(5).in_cat(1),
det(104, [0.0, 0.0, 50.0, 50.0], 0.7).in_img(2).in_cat(2),
det(105, [300.0, 300.0, 10.0, 10.0], 0.7)
.in_img(4)
.in_cat(2),
det(106, [0.0, 0.0, 50.0, 50.0], 0.7).in_img(6).in_cat(2),
],
))
}
fn tide_on(threads: usize) -> hotcoco::TideErrors {
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.build()
.expect("thread pool");
pool.install(|| {
let mut ev = COCOeval::new(build_gt(), build_dt(), IouType::Bbox);
ev.evaluate();
ev.tide_errors(0.5, 0.1).expect("tide_errors failed")
})
}
let baseline = tide_on(1);
for threads in [2usize, 4] {
let many = tide_on(threads);
assert_eq!(
baseline.ap_base.to_bits(),
many.ap_base.to_bits(),
"ap_base differs between 1 thread ({}) and {threads} threads ({})",
baseline.ap_base,
many.ap_base
);
assert_eq!(
baseline.delta_ap.keys().collect::<Vec<_>>(),
many.delta_ap.keys().collect::<Vec<_>>(),
"delta_ap keys differ between 1 thread and {threads} threads"
);
for (key, &a) in &baseline.delta_ap {
let b = many.delta_ap[key];
assert_eq!(
a.to_bits(),
b.to_bits(),
"delta_ap[{key}] differs between 1 thread ({a}) and {threads} threads ({b})"
);
}
assert_eq!(
baseline.counts, many.counts,
"counts differ between 1 thread and {threads} threads"
);
}
}
#[test]
fn nan_detection_score_is_rejected() {
let cats = vec![cat(1, "person")];
let gt = dataset(
vec![img(1)],
cats.clone(),
vec![ann(1, [10.0, 10.0, 50.0, 50.0])],
);
let coco_gt = COCO::from_dataset(gt);
let mut bad = det(1, [10.0, 10.0, 50.0, 50.0], 0.9);
bad.score = Some(f64::NAN);
let msg = match coco_gt.load_res_anns(vec![bad]) {
Ok(_) => panic!("a NaN score must be rejected"),
Err(e) => e.to_string(),
};
assert!(
msg.contains("NaN score"),
"error should name the problem, got: {msg}"
);
assert!(
coco_gt
.load_res_anns(vec![det(1, [10.0, 10.0, 50.0, 50.0], 0.9)])
.is_ok(),
"finite scores must still load"
);
}
#[test]
fn tide_fp_types_partition_the_false_positives() {
const FP_TYPES: [&str; 5] = ["Cls", "Loc", "Both", "Dupe", "Bkg"];
let gt = COCO::new(&fixtures_dir().join("edge_gt.json")).unwrap();
let dt = gt.load_res(&fixtures_dir().join("edge_dt.json")).unwrap();
let mut ev = COCOeval::new(gt, dt, IouType::Bbox);
ev.evaluate();
let te = ev.tide_errors(0.5, 0.1).expect("tide_errors");
const ALL_KEYS: [&str; 6] = ["Cls", "Loc", "Both", "Dupe", "Bkg", "Miss"];
for key in te.counts.keys() {
assert!(
ALL_KEYS.contains(&key.as_str()),
"unexpected error-type key {key:?} in counts; the enum and the map have drifted"
);
}
let t_idx = ev
.params
.iou_thr_idx(0.5)
.expect("0.5 is in the default grid");
let target_area = ev.params.area_ranges[ev.params.all_area_idx()].range;
let mut expected_fps = 0u64;
for e in ev.eval_imgs().iter().flatten() {
if e.area_rng != target_area {
continue;
}
for d in 0..e.dt_ids.len() {
if !e.dt_matched[(t_idx, d)] && !e.dt_ignore[(t_idx, d)] {
expected_fps += 1;
}
}
}
let classified: u64 = FP_TYPES
.iter()
.map(|k| te.counts.get(*k).copied().unwrap_or(0))
.sum();
assert_eq!(
classified, expected_fps,
"FP types sum to {classified} but there are {expected_fps} unmatched, \
non-ignored detections; counts = {:?}",
te.counts
);
let fp = te.delta_ap["FP"];
let fnv = te.delta_ap["FN"];
assert!(fp >= 0.0, "FP oracle must not lower AP, got {fp}");
assert!(fnv >= 0.0, "FN oracle must not lower AP, got {fnv}");
for k in ["Bkg", "Both", "Dupe"] {
let per_type = te.delta_ap[k];
assert!(
fp >= per_type - 1e-12,
"suppressing all FPs must dominate suppressing only {k}: {fp} < {per_type}"
);
}
}
#[test]
fn test_max_dets_order_is_irrelevant() {
let gt_path = fixtures_dir().join("gt.json");
let dt_path = fixtures_dir().join("dt.json");
let run = |max_dets: Vec<usize>| {
let coco_gt = COCO::new(>_path).expect("Failed to load GT");
let coco_dt = coco_gt.load_res(&dt_path).expect("Failed to load DT");
let mut ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
ev.params.max_dets = max_dets;
ev.evaluate();
ev.accumulate();
ev.summarize();
ev
};
let sorted = run(vec![1, 10, 100]);
let unsorted = run(vec![100, 10, 1]);
assert_eq!(
sorted.stats().expect("summarize sets stats"),
unsorted.stats().expect("summarize sets stats"),
"stats must not depend on max_dets order"
);
let diag_sorted = sorted
.image_diagnostics(0.5, 0.5)
.expect("diagnostics on sorted max_dets");
let diag_unsorted = unsorted
.image_diagnostics(0.5, 0.5)
.expect("diagnostics on unsorted max_dets");
assert!(
!diag_unsorted.images.is_empty(),
"diagnostics must see eval_imgs regardless of max_dets order"
);
assert_eq!(
diag_sorted.images.len(),
diag_unsorted.images.len(),
"diagnostics coverage must not depend on max_dets order"
);
}
#[test]
fn test_gt_annotations_keep_json_array_order() {
let gt_json = r#"{
"images": [{"id": 1, "width": 100, "height": 100}],
"categories": [{"id": 1, "name": "thing"}],
"annotations": [
{"id": 2, "image_id": 1, "category_id": 1, "bbox": [10, 10, 20, 20], "area": 400, "iscrowd": 0},
{"id": 1, "image_id": 1, "category_id": 1, "bbox": [10, 10, 20, 20], "area": 400, "iscrowd": 0}
]
}"#;
let dt_json = r#"[
{"image_id": 1, "category_id": 1, "bbox": [10, 10, 20, 20], "score": 0.9}
]"#;
let dir = tempfile::tempdir().expect("create temp dir");
let gt_path = dir.path().join("gt.json");
let dt_path = dir.path().join("dt.json");
std::fs::write(>_path, gt_json).expect("write GT fixture");
std::fs::write(&dt_path, dt_json).expect("write DT fixture");
let coco_gt = COCO::new(>_path).expect("load GT");
let coco_dt = coco_gt.load_res(&dt_path).expect("load DT");
let mut ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
ev.evaluate();
let all_idx = ev.params.all_area_idx();
let all_rng = ev.params.area_ranges[all_idx].range;
let cell = ev
.eval_imgs()
.iter()
.flatten()
.find(|e| e.area_rng == all_rng)
.expect("one populated cell at area=all");
assert_eq!(
cell.gt_ids,
vec![2, 1],
"GT ids must stay in JSON array order"
);
assert!(cell.dt_matched[(0, 0)], "detection must match at IoU 0.5");
assert_eq!(
cell.dt_matches[(0, 0)],
1,
"tie must resolve to the later GT in array order, as pycocotools does"
);
}
#[test]
fn per_class_metrics_follow_max_det_not_the_last_slot() {
let gt_ds = dataset(
vec![img(1)],
vec![cat(1, "a"), cat(2, "b")],
vec![
ann(1, [0.0, 0.0, 10.0, 10.0]),
ann(2, [20.0, 0.0, 10.0, 10.0]),
ann(3, [0.0, 20.0, 10.0, 10.0]).in_cat(2),
ann(4, [20.0, 20.0, 10.0, 10.0]).in_cat(2),
],
);
let dets = vec![
det(1, [0.0, 0.0, 10.0, 10.0], 0.9),
det(2, [20.0, 0.0, 10.0, 10.0], 0.8),
det(3, [0.0, 20.0, 10.0, 10.0], 0.7).in_cat(2),
det(4, [20.0, 20.0, 10.0, 10.0], 0.6).in_cat(2),
];
let run = |max_dets: Vec<usize>| {
let gt = COCO::from_dataset(gt_ds.clone());
let dt = gt.load_res_anns(dets.clone()).unwrap();
let mut ev = COCOeval::new(gt, dt, IouType::Bbox);
ev.params.max_dets = max_dets;
ev.run();
let report = ev.report().unwrap();
let per_class: Vec<f64> = report
.per_class
.values()
.filter_map(|m| m.get("AP").copied())
.collect();
let f1 = ev.f_scores(1.0).get("F1").copied().unwrap();
let curve = report.curves["pr@0.50"].clone();
(report.metrics["AP"], per_class, f1, curve)
};
let (ap_sorted, per_class_sorted, f1_sorted, curve_sorted) = run(vec![1, 10, 100]);
let (ap_unsorted, per_class_unsorted, f1_unsorted, curve_unsorted) = run(vec![100, 10, 1]);
assert_eq!(
ap_sorted, ap_unsorted,
"headline AP must not depend on order"
);
assert_eq!(
per_class_sorted, per_class_unsorted,
"per-class AP diverged"
);
assert_eq!(f1_sorted, f1_unsorted, "F1 diverged");
assert_eq!(curve_sorted, curve_unsorted, "PR curve diverged");
assert!(
!per_class_unsorted.is_empty(),
"no per-class AP was reported"
);
for ap in &per_class_unsorted {
assert!(
(ap - ap_unsorted).abs() < 1e-12,
"per-class AP {ap} disagrees with headline AP {ap_unsorted}"
);
}
}
#[test]
fn metric_defs_align_with_metric_keys_and_stats() {
let gt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "a")],
vec![ann(1, [0.0, 0.0, 10.0, 10.0])],
));
let dt = gt
.load_res_anns(vec![det(1, [0.0, 0.0, 10.0, 10.0], 0.9)])
.unwrap();
let mut ev = COCOeval::new(gt, dt, IouType::Bbox);
ev.run();
let defs = ev.metric_defs();
let keys = ev.metric_keys();
let stats = ev.stats().unwrap();
assert_eq!(defs.len(), keys.len());
assert_eq!(defs.len(), stats.len());
for (d, &k) in defs.iter().zip(&keys) {
assert_eq!(d.name, k);
}
let ap50 = defs.iter().find(|d| d.name == "AP50").unwrap();
assert!(ap50.ap);
assert_eq!(ap50.iou_thr, Some(0.5));
assert_eq!(ap50.area_lbl, "all");
assert_eq!(ap50.max_det, 100);
assert!(ap50.freq_group.is_none());
let gt = COCO::from_dataset(dataset(
vec![img(1)],
vec![cat(1, "a")],
vec![ann(1, [0.0, 0.0, 10.0, 10.0])],
));
let dt = gt.load_res_anns(vec![]).unwrap();
let lvis = COCOeval::new_lvis(gt, dt, IouType::Bbox);
let apr = lvis
.metric_defs()
.into_iter()
.find(|d| d.name == "APr")
.unwrap();
assert_eq!(apr.freq_group, Some(hotcoco::FreqGroup::Rare));
}
fn write_csv(dir: &std::path::Path, name: &str, contents: &str) -> std::path::PathBuf {
let path = dir.join(name);
std::fs::write(&path, contents).expect("write csv");
path
}
fn oid_dims(entries: &[(&str, (u32, u32))]) -> HashMap<String, (u32, u32)> {
entries
.iter()
.map(|(k, v)| ((*k).to_string(), *v))
.collect()
}
#[test]
fn test_oid_to_coco_column_order() {
let tmp = tempfile::tempdir().expect("tempdir");
let csv = write_csv(
tmp.path(),
"boxes.csv",
"ImageID,LabelName,XMin,XMax,YMin,YMax,IsGroupOf\n\
abc123,/m/01,0.1,0.5,0.3,0.4,0\n",
);
let dims = oid_dims(&[("abc123", (1000, 500))]);
let ds = oid_to_coco(&csv, None, &dims).expect("oid_to_coco");
assert_eq!(ds.images.len(), 1);
assert_eq!(ds.annotations.len(), 1);
let bbox = ds.annotations[0].bbox.expect("bbox");
assert!((bbox[0] - 100.0).abs() < 1e-6, "x: {}", bbox[0]);
assert!((bbox[1] - 150.0).abs() < 1e-6, "y: {}", bbox[1]);
assert!((bbox[2] - 400.0).abs() < 1e-6, "w: {}", bbox[2]);
assert!((bbox[3] - 50.0).abs() < 1e-6, "h: {}", bbox[3]);
}
#[test]
fn test_oid_header_drives_parsing_not_position() {
let tmp = tempfile::tempdir().expect("tempdir");
let dims = oid_dims(&[("abc123", (1000, 500))]);
let v6 = write_csv(
tmp.path(),
"v6.csv",
"ImageID,Source,LabelName,Confidence,XMin,XMax,YMin,YMax,\
IsOccluded,IsTruncated,IsGroupOf,IsDepiction,IsInside\n\
abc123,xclick,/m/01,1,0.1,0.5,0.3,0.4,0,0,0,0,0\n",
);
let shuffled = write_csv(
tmp.path(),
"shuffled.csv",
"IsGroupOf,YMax,LabelName,XMin,ImageID,YMin,XMax\n\
0,0.4,/m/01,0.1,abc123,0.3,0.5\n",
);
let a = oid_to_coco(&v6, None, &dims).expect("v6");
let b = oid_to_coco(&shuffled, None, &dims).expect("shuffled");
assert_eq!(a.annotations[0].bbox, b.annotations[0].bbox);
assert_eq!(a.annotations[0].score, None);
}
#[test]
fn test_oid_missing_required_column_errors() {
let tmp = tempfile::tempdir().expect("tempdir");
let csv = write_csv(
tmp.path(),
"bad.csv",
"ImageID,LabelName,XMin,YMin,YMax\nabc,/m/01,0.1,0.3,0.4\n",
);
let err = oid_to_coco(&csv, None, &HashMap::new()).expect_err("must reject missing XMax");
assert!(err.to_string().contains("xmax"), "error was: {err}");
}
#[test]
fn test_oid_group_of_becomes_is_group_of() {
let tmp = tempfile::tempdir().expect("tempdir");
let csv = write_csv(
tmp.path(),
"boxes.csv",
"ImageID,LabelName,XMin,XMax,YMin,YMax,IsGroupOf\n\
img1,/m/01,0.0,0.5,0.0,0.5,1\n\
img1,/m/01,0.5,1.0,0.5,1.0,0\n",
);
let ds = oid_to_coco(&csv, None, &HashMap::new()).expect("oid_to_coco");
assert_eq!(ds.annotations[0].is_group_of, Some(true));
assert_eq!(ds.annotations[1].is_group_of, Some(false));
assert!(!ds.annotations[0].iscrowd);
}
#[test]
fn test_oid_class_descriptions_resolve_mids() {
let tmp = tempfile::tempdir().expect("tempdir");
let csv = write_csv(
tmp.path(),
"boxes.csv",
"ImageID,LabelName,XMin,XMax,YMin,YMax\nimg1,/m/0cmf2,0.1,0.2,0.1,0.2\n",
);
let desc = write_csv(
tmp.path(),
"class-descriptions-boxable.csv",
"/m/0cmf2,Beer\n/m/0dv5r,\"Camera, still\"\n",
);
let ds = oid_to_coco(&csv, Some(&desc), &HashMap::new()).expect("oid_to_coco");
assert_eq!(ds.categories.len(), 1);
assert_eq!(ds.categories[0].name, "Beer");
let plain = oid_to_coco(&csv, None, &HashMap::new()).expect("oid_to_coco");
assert_eq!(plain.categories[0].name, "/m/0cmf2");
}
#[test]
fn test_oid_without_dims_keeps_normalized_coords() {
let tmp = tempfile::tempdir().expect("tempdir");
let csv = write_csv(
tmp.path(),
"boxes.csv",
"ImageID,LabelName,XMin,XMax,YMin,YMax\nimg1,/m/01,0.25,0.75,0.25,0.75\n",
);
let ds = oid_to_coco(&csv, None, &HashMap::new()).expect("oid_to_coco");
assert_eq!(ds.images[0].width, 1);
assert_eq!(ds.images[0].height, 1);
let bbox = ds.annotations[0].bbox.expect("bbox");
assert!((bbox[2] - 0.5).abs() < 1e-9, "w: {}", bbox[2]);
assert!((bbox[3] - 0.5).abs() < 1e-9, "h: {}", bbox[3]);
}
#[test]
fn test_oid_ids_are_deterministic() {
let tmp = tempfile::tempdir().expect("tempdir");
let header = "ImageID,LabelName,XMin,XMax,YMin,YMax\n";
let row_a = "aaa,/m/02,0.1,0.2,0.1,0.2\n";
let row_b = "bbb,/m/01,0.3,0.4,0.3,0.4\n";
let fwd = write_csv(tmp.path(), "fwd.csv", &format!("{header}{row_a}{row_b}"));
let rev = write_csv(tmp.path(), "rev.csv", &format!("{header}{row_b}{row_a}"));
let a = oid_to_coco(&fwd, None, &HashMap::new()).expect("fwd");
let b = oid_to_coco(&rev, None, &HashMap::new()).expect("rev");
let names = |ds: &hotcoco::Dataset| -> Vec<(u64, String)> {
ds.categories
.iter()
.map(|c| (c.id, c.name.clone()))
.collect()
};
assert_eq!(names(&a), names(&b));
let files = |ds: &hotcoco::Dataset| -> Vec<(u64, String)> {
ds.images
.iter()
.map(|i| (i.id, i.file_name.clone()))
.collect()
};
assert_eq!(files(&a), files(&b));
}
#[test]
fn test_oid_round_trip() {
let tmp = tempfile::tempdir().expect("tempdir");
let csv = write_csv(
tmp.path(),
"boxes.csv",
"ImageID,LabelName,XMin,XMax,YMin,YMax,IsGroupOf\n\
img1,/m/01,0.100000,0.500000,0.300000,0.400000,1\n\
img2,/m/02,0.200000,0.600000,0.100000,0.900000,0\n",
);
let dims = oid_dims(&[("img1", (1000, 500)), ("img2", (640, 480))]);
let ds = oid_to_coco(&csv, None, &dims).expect("oid_to_coco");
let out = tmp.path().join("out.csv");
let stats = coco_to_oid(&ds, &out).expect("coco_to_oid");
assert_eq!(stats.images, 2);
assert_eq!(stats.annotations, 2);
assert_eq!(stats.group_of, 1);
assert_eq!(stats.skipped_no_bbox, 0);
let back = oid_to_coco(&out, None, &dims).expect("re-import");
assert_geometry_round_trip(&ds, &back, file_name_key, bbox_of, [1e-3; 4]);
for (before, after) in ds.annotations.iter().zip(&back.annotations) {
assert_eq!(before.is_group_of, after.is_group_of);
}
}
#[test]
fn test_oid_round_trips_category_names_containing_commas() {
let tmp = tempfile::tempdir().expect("tempdir");
let csv = write_csv(
tmp.path(),
"boxes.csv",
"ImageID,LabelName,XMin,XMax,YMin,YMax\nimg1,/m/0dv5r,0.1,0.5,0.3,0.4\n",
);
let desc = write_csv(tmp.path(), "desc.csv", "/m/0dv5r,\"Camera, still\"\n");
let ds = oid_to_coco(&csv, Some(&desc), &HashMap::new()).expect("oid_to_coco");
assert_eq!(ds.categories[0].name, "Camera, still");
let out = tmp.path().join("out.csv");
coco_to_oid(&ds, &out).expect("coco_to_oid");
let back = oid_to_coco(&out, None, &HashMap::new()).expect("re-import");
assert_eq!(back.categories.len(), 1);
assert_eq!(back.categories[0].name, "Camera, still");
assert_eq!(back.annotations.len(), 1);
let bbox = back.annotations[0].bbox.expect("bbox");
assert!(
(bbox[2] - 0.4).abs() < 1e-5,
"w survived quoting: {}",
bbox[2]
);
}
#[test]
fn test_oid_results_align_with_ground_truth_ids() {
let tmp = tempfile::tempdir().expect("tempdir");
let gt_csv = write_csv(
tmp.path(),
"gt.csv",
"ImageID,LabelName,XMin,XMax,YMin,YMax,IsGroupOf\n\
zzz,/m/01,0.1,0.5,0.3,0.4,0\n\
aaa,/m/02,0.1,0.5,0.3,0.4,0\n",
);
let dims = oid_dims(&[("zzz", (1000, 500)), ("aaa", (1000, 500))]);
let gt = oid_to_coco(>_csv, None, &dims).expect("gt");
let dt_csv = write_csv(
tmp.path(),
"dt.csv",
"ImageID,LabelName,Score,XMin,XMax,YMin,YMax\nzzz,/m/01,0.9,0.1,0.5,0.3,0.4\n",
);
let anns = oid_results_to_anns(>, &dt_csv, None).expect("results");
assert_eq!(anns.len(), 1);
let gt_img = gt
.images
.iter()
.find(|i| i.file_name == "zzz")
.expect("gt image");
assert_eq!(anns[0].image_id, gt_img.id);
assert_eq!(anns[0].score, Some(0.9));
let bbox = anns[0].bbox.expect("bbox");
assert!((bbox[0] - 100.0).abs() < 1e-6, "x: {}", bbox[0]);
assert!((bbox[3] - 50.0).abs() < 1e-6, "h: {}", bbox[3]);
}
#[test]
fn test_oid_results_reject_unknown_references() {
let tmp = tempfile::tempdir().expect("tempdir");
let gt_csv = write_csv(
tmp.path(),
"gt.csv",
"ImageID,LabelName,XMin,XMax,YMin,YMax\nimg1,/m/01,0.1,0.5,0.3,0.4\n",
);
let gt = oid_to_coco(>_csv, None, &HashMap::new()).expect("gt");
let bad_img = write_csv(
tmp.path(),
"bad_img.csv",
"ImageID,LabelName,Score,XMin,XMax,YMin,YMax\nnope,/m/01,0.9,0.1,0.5,0.3,0.4\n",
);
let err = oid_results_to_anns(>, &bad_img, None).expect_err("unknown ImageID");
assert!(err.to_string().contains("nope"), "error was: {err}");
let bad_cat = write_csv(
tmp.path(),
"bad_cat.csv",
"ImageID,LabelName,Score,XMin,XMax,YMin,YMax\nimg1,/m/99,0.9,0.1,0.5,0.3,0.4\n",
);
let err = oid_results_to_anns(>, &bad_cat, None).expect_err("unknown LabelName");
assert!(err.to_string().contains("/m/99"), "error was: {err}");
}
#[test]
fn test_oid_end_to_end_open_images_eval() {
let tmp = tempfile::tempdir().expect("tempdir");
let gt_csv = write_csv(
tmp.path(),
"gt.csv",
"ImageID,LabelName,XMin,XMax,YMin,YMax,IsGroupOf\n\
img1,/m/01,0.1,0.5,0.1,0.5,0\n\
img2,/m/01,0.2,0.6,0.2,0.6,0\n",
);
let dims = oid_dims(&[("img1", (640, 480)), ("img2", (640, 480))]);
let gt = oid_to_coco(>_csv, None, &dims).expect("gt");
let dt_csv = write_csv(
tmp.path(),
"dt.csv",
"ImageID,LabelName,Score,XMin,XMax,YMin,YMax\n\
img1,/m/01,0.9,0.1,0.5,0.1,0.5\n\
img2,/m/01,0.8,0.2,0.6,0.2,0.6\n",
);
let dt_anns = oid_results_to_anns(>, &dt_csv, None).expect("dt");
let coco_gt = COCO::from_dataset(gt);
let coco_dt = coco_gt.load_res_anns(dt_anns).expect("load_res_anns");
let mut ev = COCOeval::new_oid(coco_gt, coco_dt, None);
ev.run();
let stats = ev.stats().expect("stats after run");
assert!(
stats[0] > 0.99,
"perfect detections should score ~1.0: {stats:?}"
);
}
const AP_FULL_SWEEP: f64 = (101.0 + 9.0 * 51.0) / 1010.0;
const AP_AT_075: f64 = 51.0 / 101.0;
const AR_FULL_SWEEP: f64 = 0.55;
fn assert_stats(stats: &[f64], expected: &[f64], names: &[&str]) {
assert_eq!(stats.len(), expected.len(), "stats length");
for (i, (&got, &exp)) in stats.iter().zip(expected.iter()).enumerate() {
let name = names[i];
if exp == -1.0 {
assert_eq!(got, -1.0, "{name}: expected the -1.0 sentinel, got {got}");
} else {
assert!(
(got - exp).abs() < 1e-9,
"{name}: got {got:.9}, expected {exp:.9}"
);
assert_ne!(got, -1.0, "{name}: real score expected, got the sentinel");
}
}
}
const BBOX_SEGM_KEYS: [&str; 12] = [
"AP", "AP50", "AP75", "APs", "APm", "APl", "AR1", "AR10", "AR100", "ARs", "ARm", "ARl",
];
#[test]
fn test_segm_eval_end_to_end() {
let gt = dataset(
vec![img(1)],
vec![cat(1, "thing")],
vec![
ann(1, [0.0, 0.0, 20.0, 20.0]).mask(rect_mask(640, 640, 0, 0, 20, 20)),
ann(2, [50.0, 50.0, 20.0, 20.0]).mask(rect_mask(640, 640, 50, 50, 20, 20)),
],
);
let dt = dataset(
vec![img(1)],
vec![cat(1, "thing")],
vec![
det(101, [0.0, 0.0, 20.0, 20.0], 0.9).mask(rect_mask(640, 640, 0, 0, 20, 20)),
det(102, [50.0, 50.0, 20.0, 20.0], 0.8)
.mask(rect_mask(640, 640, 50, 50, 10, 20))
.with_area(200.0),
],
);
let mut ev = COCOeval::new(
COCO::from_dataset(gt),
COCO::from_dataset(dt),
IouType::Segm,
);
ev.evaluate();
ev.accumulate();
ev.summarize();
let stats = ev.stats().expect("summarize sets stats");
let expected = [
AP_FULL_SWEEP, 1.0, AP_AT_075, AP_FULL_SWEEP, -1.0, -1.0, 0.5, AR_FULL_SWEEP, AR_FULL_SWEEP, AR_FULL_SWEEP, -1.0, -1.0, ];
assert_stats(stats, &expected, &BBOX_SEGM_KEYS);
}
#[test]
fn test_segm_eval_crowd_uses_ioa() {
assert!((ioa_of([0.0, 0.0, 10.0, 10.0], [0.0, 0.0, 50.0, 50.0]) - 1.0).abs() < 1e-12);
assert!(iou_of([0.0, 0.0, 10.0, 10.0], [0.0, 0.0, 50.0, 50.0]) < 0.05);
let gt = dataset(
vec![img(1)],
vec![cat(1, "thing")],
vec![
ann(1, [0.0, 0.0, 50.0, 50.0])
.crowd()
.mask(rect_mask(640, 640, 0, 0, 50, 50)),
ann(2, [60.0, 60.0, 20.0, 20.0]).mask(rect_mask(640, 640, 60, 60, 20, 20)),
],
);
let dt = dataset(
vec![img(1)],
vec![cat(1, "thing")],
vec![
det(101, [0.0, 0.0, 10.0, 10.0], 0.9).mask(rect_mask(640, 640, 0, 0, 10, 10)),
det(102, [60.0, 60.0, 20.0, 20.0], 0.8).mask(rect_mask(640, 640, 60, 60, 20, 20)),
],
);
let mut ev = COCOeval::new(
COCO::from_dataset(gt),
COCO::from_dataset(dt),
IouType::Segm,
);
ev.evaluate();
ev.accumulate();
ev.summarize();
let stats = ev.stats().expect("summarize sets stats");
let expected = [
1.0, 1.0, 1.0, 1.0, -1.0, -1.0, 0.0, 1.0, 1.0, 1.0, -1.0, -1.0, ];
assert_stats(stats, &expected, &BBOX_SEGM_KEYS);
}
#[test]
fn test_segm_eval_polygon_end_to_end() {
let poly = || Segmentation::Polygon(vec![vec![10.0, 10.0, 60.0, 10.0, 60.0, 50.0, 10.0, 50.0]]);
let gt = dataset(
vec![img(1)],
vec![cat(1, "thing")],
vec![ann(1, [10.0, 10.0, 50.0, 40.0]).mask(poly())],
);
let dt = dataset(
vec![img(1)],
vec![cat(1, "thing")],
vec![det(101, [10.0, 10.0, 50.0, 40.0], 0.9).mask(poly())],
);
let mut ev = COCOeval::new(
COCO::from_dataset(gt),
COCO::from_dataset(dt),
IouType::Segm,
);
ev.evaluate();
ev.accumulate();
ev.summarize();
let stats = ev.stats().expect("summarize sets stats");
assert!(
(stats[0] - 1.0).abs() < 1e-9,
"AP for identical polygons: {}",
stats[0]
);
assert_eq!(stats[3], -1.0, "APs: no small GT → sentinel");
assert!(
(stats[4] - 1.0).abs() < 1e-9,
"APm: the 2000 px² GT is medium"
);
}
#[test]
fn test_keypoints_eval_end_to_end() {
let e = (4.2_f64 * 4.2) / (0.052_f64 * 0.052) / 5000.0 / 2.0;
let oks = (-e).exp();
assert!(
oks > 0.51 && oks < 0.54,
"fixture OKS must sit between the 0.50 and 0.55 thresholds: {oks}"
);
let mut gt1_kpts = Vec::with_capacity(51);
for i in 0..17 {
gt1_kpts.extend_from_slice(&[100.0 + i as f64, 100.0, 2.0]);
}
let mut gt2_kpts = vec![0.0; 51];
gt2_kpts[0] = 300.0;
gt2_kpts[1] = 300.0;
gt2_kpts[2] = 2.0;
let mut dt2_kpts = vec![0.0; 51];
dt2_kpts[0] = 304.2; dt2_kpts[1] = 300.0;
let gt = dataset(
vec![img(1)],
vec![cat(1, "person")],
vec![
ann(1, [80.0, 80.0, 100.0, 50.0]).kpts(gt1_kpts.clone()),
ann(2, [280.0, 280.0, 100.0, 50.0]).kpts(gt2_kpts),
],
);
let dt = dataset(
vec![img(1)],
vec![cat(1, "person")],
vec![
det(101, [80.0, 80.0, 100.0, 50.0], 0.9).kpts(gt1_kpts),
det(102, [280.0, 280.0, 100.0, 50.0], 0.8).kpts(dt2_kpts),
],
);
let mut ev = COCOeval::new(
COCO::from_dataset(gt),
COCO::from_dataset(dt),
IouType::Keypoints,
);
ev.evaluate();
ev.accumulate();
ev.summarize();
let stats = ev.stats().expect("summarize sets stats");
let keys = [
"AP", "AP50", "AP75", "APm", "APl", "AR", "AR50", "AR75", "ARm", "ARl",
];
let expected = [
AP_FULL_SWEEP, 1.0, AP_AT_075, AP_FULL_SWEEP, -1.0, AR_FULL_SWEEP, 1.0, 0.5, AR_FULL_SWEEP, -1.0, ];
assert_stats(stats, &expected, &keys);
}