#![allow(clippy::unwrap_used)]
use std::path::PathBuf;
use hotcoco::detection::{CompareOpts, compare};
use hotcoco::params::IouType;
use hotcoco::types::{Annotation, Category, Dataset, Image};
use hotcoco::{AreaRange, COCO, COCOeval};
fn fixtures_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
}
fn image(id: u64) -> Image {
Image {
id,
width: 200,
height: 200,
..Default::default()
}
}
fn cat(id: u64, name: &str) -> Category {
Category {
id,
name: name.to_string(),
..Default::default()
}
}
fn ann(id: u64, bbox: Option<[f64; 4]>) -> Annotation {
Annotation {
id,
image_id: 1,
category_id: 1,
bbox,
area: bbox.map(|b| b[2] * b[3]),
..Default::default()
}
}
fn det(id: u64, bbox: Option<[f64; 4]>, score: f64) -> Annotation {
Annotation {
score: Some(score),
..ann(id, bbox)
}
}
fn coco_from(anns: Vec<Annotation>) -> COCO {
COCO::from_dataset(Dataset {
images: vec![image(1)],
annotations: anns,
categories: vec![cat(1, "thing")],
..Default::default()
})
}
fn fixture_eval() -> COCOeval {
let gt = COCO::new(&fixtures_dir().join("gt.json")).unwrap();
let dt = gt.load_res(&fixtures_dir().join("dt.json")).unwrap();
COCOeval::new(gt, dt, IouType::Bbox)
}
#[test]
fn bboxless_gt_between_valid_gts_does_not_shift_iou_columns() {
let gt = coco_from(vec![
ann(1, Some([0.0, 0.0, 10.0, 10.0])),
ann(2, None), ann(3, Some([50.0, 50.0, 10.0, 10.0])),
]);
let dt = coco_from(vec![
det(101, Some([0.0, 0.0, 10.0, 10.0]), 0.9), det(102, Some([50.0, 50.0, 10.0, 10.0]), 0.8), ]);
let mut ev = COCOeval::new(gt, dt, IouType::Bbox);
ev.evaluate();
let diag = ev.image_diagnostics(0.5, 0.5).unwrap();
assert_eq!(
diag.annotations.dt_match.get(&101),
Some(&1),
"detection 101 sits exactly on GT 1"
);
assert_eq!(
diag.annotations.dt_match.get(&102),
Some(&3),
"detection 102 sits exactly on GT 3 and must not read GT 2's missing column"
);
assert_eq!(
diag.annotations.gt_status.len(),
3,
"all three ground truths are classified"
);
}
#[test]
fn bboxless_dt_between_valid_dts_does_not_shift_iou_rows() {
let gt = coco_from(vec![
ann(1, Some([0.0, 0.0, 10.0, 10.0])),
ann(3, Some([50.0, 50.0, 10.0, 10.0])),
]);
let dt = coco_from(vec![
det(101, Some([0.0, 0.0, 10.0, 10.0]), 0.9), det(102, None, 0.85), det(103, Some([50.0, 50.0, 10.0, 10.0]), 0.8), ]);
let mut ev = COCOeval::new(gt, dt, IouType::Bbox);
ev.evaluate();
let diag = ev.image_diagnostics(0.5, 0.5).unwrap();
assert_eq!(diag.annotations.dt_match.get(&101), Some(&1));
assert_eq!(
diag.annotations.dt_match.get(&103),
Some(&3),
"detection 103's row must not shift onto detection 102's missing slot"
);
use hotcoco::detection::DtStatus;
assert_eq!(
diag.annotations.dt_status.get(&102),
Some(&DtStatus::Fp),
"a geometry-less detection is an unmatched detection, not a shift"
);
}
#[test]
fn eval_imgs_matches_are_aligned_with_geometry_gaps() {
let gt = coco_from(vec![
ann(1, Some([0.0, 0.0, 10.0, 10.0])),
ann(2, None),
ann(3, Some([50.0, 50.0, 10.0, 10.0])),
]);
let dt = coco_from(vec![
det(101, Some([0.0, 0.0, 10.0, 10.0]), 0.9),
det(102, Some([50.0, 50.0, 10.0, 10.0]), 0.8),
]);
let mut ev = COCOeval::new(gt, dt, IouType::Bbox);
ev.evaluate();
let cell = ev
.eval_imgs()
.iter()
.flatten()
.find(|e| e.area_rng == [0.0, 1e10])
.expect("the all-area cell exists");
for t_idx in 0..10 {
assert!(cell.dt_matched[(t_idx, 0)], "dt 101 matched at t={t_idx}");
assert!(cell.dt_matched[(t_idx, 1)], "dt 102 matched at t={t_idx}");
assert_eq!(cell.dt_matches[(t_idx, 0)], 1);
assert_eq!(cell.dt_matches[(t_idx, 1)], 3);
}
}
#[test]
fn missing_area_label_reports_sentinel_not_all_slice() {
let mut ev = fixture_eval();
for ar in &mut ev.params.area_ranges {
if ar.label == "small" {
ar.label = "tiny".to_string();
}
}
ev.run();
let results = ev.get_results(None, false);
assert!(
results["AP"] >= 0.0,
"headline AP is computable on the fixture"
);
assert_eq!(
results["APs"], -1.0,
"no 'small' range exists, so APs is not computed — it must not report the 'all' slice"
);
assert_eq!(results["ARs"], -1.0);
assert_ne!(results["APs"], results["AP"]);
}
#[test]
fn empty_max_dets_degrades_to_sentinels_without_panicking() {
let mut ev = fixture_eval();
ev.params.max_dets = Vec::new();
ev.run();
let stats = ev.stats().expect("summarize ran");
assert!(!stats.is_empty());
assert!(
stats.iter().all(|&v| v == -1.0),
"with no max-det slots nothing is computable; got {stats:?}"
);
}
#[test]
fn compare_rejects_mismatched_grids_and_ranges() {
let mut ev_a = fixture_eval();
ev_a.evaluate();
let mut ev_b = fixture_eval();
ev_b.params.iou_thrs = vec![0.5];
ev_b.evaluate();
let err = compare(&ev_a, &ev_b, &CompareOpts::default()).unwrap_err();
assert!(err.to_string().contains("iou_thrs"), "got: {err}");
let mut ev_b = fixture_eval();
ev_b.params.rec_thrs = vec![0.0, 0.5, 1.0];
ev_b.evaluate();
let err = compare(&ev_a, &ev_b, &CompareOpts::default()).unwrap_err();
assert!(err.to_string().contains("rec_thrs"), "got: {err}");
let mut ev_b = fixture_eval();
ev_b.params.max_dets = vec![50];
ev_b.evaluate();
let err = compare(&ev_a, &ev_b, &CompareOpts::default()).unwrap_err();
assert!(err.to_string().contains("max_dets"), "got: {err}");
let mut ev_b = fixture_eval();
ev_b.params.area_ranges[1] = AreaRange {
label: "small".to_string(),
range: [0.0, 100.0],
};
ev_b.evaluate();
let err = compare(&ev_a, &ev_b, &CompareOpts::default()).unwrap_err();
assert!(err.to_string().contains("area_ranges"), "got: {err}");
let mut ev_b = fixture_eval();
ev_b.evaluate();
assert!(compare(&ev_a, &ev_b, &CompareOpts::default()).is_ok());
}
#[test]
fn f_score_keys_use_minimal_digits() {
let mut ev = fixture_eval();
ev.run();
let f2 = ev.f_scores(2.0);
assert!(
f2.contains_key("F2") && f2.contains_key("F2_50") && f2.contains_key("F2_75"),
"integer beta must print undecorated; got keys {:?}",
f2.keys().collect::<Vec<_>>()
);
let fh = ev.f_scores(0.5);
assert!(fh.contains_key("F0.5") && fh.contains_key("F0.5_50"));
}
#[test]
fn eval_params_archive_is_self_explaining() {
let mut ev = fixture_eval();
ev.params.iou_thrs = vec![0.25, 0.75]; ev.run();
let results = ev.results(false).unwrap();
assert_eq!(results.params.recall_thresholds, ev.params.rec_thrs);
assert!(results.params.use_cats);
assert_eq!(results.params.kpt_oks_sigmas, ev.params.kpt_oks_sigmas);
assert!(
!results.params.reference_deviations.is_empty(),
"custom iou_thrs is a deviation and the archive must say so"
);
assert!(
results
.params
.reference_deviations
.iter()
.any(|d| d.contains("iou_thrs")),
"the deviation names the parameter: {:?}",
results.params.reference_deviations
);
assert_eq!(
results.params.reference_deviations,
ev.reference_deviations()
);
}