use std::collections::HashMap;
use serde::Serialize;
use super::COCOeval;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum DtStatus {
Tp,
Fp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum GtStatus {
Matched,
Fn,
}
#[derive(Debug, Clone, Serialize)]
pub struct AnnotationIndex {
pub dt_status: HashMap<u64, DtStatus>,
pub gt_status: HashMap<u64, GtStatus>,
pub dt_match: HashMap<u64, u64>,
pub gt_match: HashMap<u64, u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum ErrorProfile {
Perfect,
FpHeavy,
FnHeavy,
Mixed,
}
#[derive(Debug, Clone, Serialize)]
pub struct ImageSummary {
pub tp: u32,
pub fp: u32,
pub fn_count: u32,
pub f1: f64,
pub ap: f64,
pub error_profile: ErrorProfile,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum LabelErrorType {
WrongLabel,
MissingAnnotation,
}
#[derive(Debug, Clone, Serialize)]
pub struct LabelError {
pub image_id: u64,
pub dt_id: u64,
pub dt_score: f64,
pub dt_category_id: u64,
pub gt_id: Option<u64>,
pub gt_category_id: Option<u64>,
pub iou: f64,
pub error_type: LabelErrorType,
}
#[derive(Debug, Clone, Serialize)]
pub struct ImageDiagnostics {
pub annotations: AnnotationIndex,
pub images: HashMap<u64, ImageSummary>,
pub label_errors: Vec<LabelError>,
pub iou_thr: f64,
}
fn bbox_iou_plain(a: [f64; 4], b: [f64; 4]) -> f64 {
crate::primitives::sim::bbox_iou_pair(a, b, false)
}
fn compute_image_ap(detections: &[(f64, bool)], n_gt: u32) -> f64 {
if n_gt == 0 {
return if detections.is_empty() { 1.0 } else { 0.0 };
}
let scores: Vec<f64> = detections.iter().map(|&(score, _)| score).collect();
let matched: Vec<bool> = detections.iter().map(|&(_, is_tp)| is_tp).collect();
let rec_thrs: Vec<f64> = (0..=100).map(|i| i as f64 / 100.0).collect();
crate::primitives::counts::average_precision(&scores, &matched, None, n_gt as usize, &rec_thrs)
}
impl COCOeval {
pub fn image_diagnostics(
&self,
iou_thr: f64,
score_thr: f64,
) -> crate::error::Result<ImageDiagnostics> {
if self.eval_imgs.is_empty() {
return Err("image_diagnostics() requires evaluate() to be called first".into());
}
let t_idx = self
.params
.iou_thrs
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| {
((**a - iou_thr).abs())
.partial_cmp(&((**b - iou_thr).abs()))
.unwrap_or(std::cmp::Ordering::Equal)
})
.map_or(0, |(i, _)| i);
let actual_iou_thr = self.params.iou_thrs[t_idx];
let target_area_idx = self.params.area_range_idx("all").unwrap_or(0);
let target_area = self.params.area_ranges[target_area_idx].range;
let target_max_det = self.params.max_dets.iter().copied().max().unwrap_or(100);
let mut dt_status: HashMap<u64, DtStatus> = HashMap::new();
let mut gt_status: HashMap<u64, GtStatus> = HashMap::new();
let mut dt_match_map: HashMap<u64, u64> = HashMap::new();
let mut gt_match_map: HashMap<u64, u64> = HashMap::new();
let mut img_detections: HashMap<u64, Vec<(f64, bool)>> = HashMap::new();
let mut img_counts: HashMap<u64, (u32, u32, u32)> = HashMap::new();
for eval_img in self.eval_imgs.iter().flatten() {
if eval_img.area_rng != target_area || eval_img.max_det != target_max_det {
continue;
}
if t_idx >= eval_img.dt_matched.len() {
continue;
}
let img_id = eval_img.image_id;
let matched = &eval_img.dt_matched[t_idx];
let ignored = &eval_img.dt_ignore[t_idx];
let matches = &eval_img.dt_matches[t_idx];
debug_assert_eq!(matched.len(), matches.len());
let counts = img_counts.entry(img_id).or_insert((0, 0, 0));
for (d, &did) in eval_img.dt_ids.iter().enumerate() {
if d >= ignored.len() || ignored[d] {
continue;
}
if dt_status.contains_key(&did) {
continue;
}
let is_tp = d < matched.len() && matched[d];
if is_tp {
dt_status.insert(did, DtStatus::Tp);
let gt_id = matches[d];
dt_match_map.insert(did, gt_id);
gt_match_map.insert(gt_id, did);
counts.0 += 1;
} else {
dt_status.insert(did, DtStatus::Fp);
counts.1 += 1;
}
img_detections
.entry(img_id)
.or_default()
.push((eval_img.dt_scores[d], is_tp));
}
let gt_matched_at_t = &eval_img.gt_matched[t_idx];
for (g, &gid) in eval_img.gt_ids.iter().enumerate() {
if gt_status.contains_key(&gid) {
continue;
}
if g < eval_img.gt_ignore.len() && eval_img.gt_ignore[g] {
continue;
}
let is_matched = g < gt_matched_at_t.len() && gt_matched_at_t[g];
if is_matched {
gt_status.insert(gid, GtStatus::Matched);
} else {
gt_status.insert(gid, GtStatus::Fn);
counts.2 += 1;
}
}
}
let mut images: HashMap<u64, ImageSummary> = HashMap::new();
for (&img_id, &(tp, fp, fn_count)) in &img_counts {
let denom = 2 * tp + fp + fn_count;
let f1 = if denom == 0 {
1.0
} else {
(2 * tp) as f64 / denom as f64
};
let mut dets = img_detections.remove(&img_id).unwrap_or_default();
dets.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let n_gt = tp + fn_count; let ap = compute_image_ap(&dets, n_gt);
let error_profile = match (fp, fn_count) {
(0, 0) => ErrorProfile::Perfect,
(f, n) if f > 2 * n => ErrorProfile::FpHeavy,
(f, n) if n > 2 * f => ErrorProfile::FnHeavy,
_ => ErrorProfile::Mixed,
};
images.insert(
img_id,
ImageSummary {
tp,
fp,
fn_count,
f1,
ap,
error_profile,
},
);
}
let mut label_errors = Vec::new();
struct FpDt {
dt_id: u64,
score: f64,
cat_id: u64,
bbox: [f64; 4],
}
let mut img_fp_dts: HashMap<u64, Vec<FpDt>> = HashMap::new();
struct FnGt {
gt_id: u64,
cat_id: u64,
bbox: [f64; 4],
}
let mut img_fn_gts: HashMap<u64, Vec<FnGt>> = HashMap::new();
let mut img_all_gts: HashMap<u64, Vec<[f64; 4]>> = HashMap::new();
for (&dt_id, &status) in &dt_status {
if status != DtStatus::Fp {
continue;
}
if let Some(ann) = self.coco_dt.get_ann(dt_id) {
if let Some(score) = ann.score {
if score < score_thr {
continue;
}
if let Some(bbox) = ann.bbox {
img_fp_dts.entry(ann.image_id).or_default().push(FpDt {
dt_id,
score,
cat_id: ann.category_id,
bbox,
});
}
}
}
}
for (>_id, &status) in >_status {
if let Some(ann) = self.coco_gt.get_ann(gt_id) {
if let Some(bbox) = ann.bbox {
img_all_gts.entry(ann.image_id).or_default().push(bbox);
if status == GtStatus::Fn {
img_fn_gts.entry(ann.image_id).or_default().push(FnGt {
gt_id,
cat_id: ann.category_id,
bbox,
});
}
}
}
}
for (&img_id, fp_dts) in &img_fp_dts {
let fn_gts = img_fn_gts.get(&img_id);
let all_gts = img_all_gts.get(&img_id);
for fp in fp_dts {
let (dt_id, dt_score, dt_cat, dt_bbox) = (fp.dt_id, fp.score, fp.cat_id, fp.bbox);
let mut best_fn_iou = 0.0f64;
let mut best_fn_gt: Option<(u64, u64)> = None;
if let Some(fn_gts) = fn_gts {
for fg in fn_gts {
if fg.cat_id == dt_cat {
continue; }
let iou = bbox_iou_plain(dt_bbox, fg.bbox);
if iou > best_fn_iou {
best_fn_iou = iou;
best_fn_gt = Some((fg.gt_id, fg.cat_id));
}
}
}
if best_fn_iou >= 0.5 {
if let Some((gt_id, gt_cat)) = best_fn_gt {
label_errors.push(LabelError {
image_id: img_id,
dt_id,
dt_score,
dt_category_id: dt_cat,
gt_id: Some(gt_id),
gt_category_id: Some(gt_cat),
iou: best_fn_iou,
error_type: LabelErrorType::WrongLabel,
});
continue; }
}
let max_iou_any_gt = all_gts.map_or(0.0, |gts| {
gts.iter()
.map(|>_bbox| bbox_iou_plain(dt_bbox, gt_bbox))
.fold(0.0f64, f64::max)
});
if max_iou_any_gt < 0.1 {
label_errors.push(LabelError {
image_id: img_id,
dt_id,
dt_score,
dt_category_id: dt_cat,
gt_id: None,
gt_category_id: None,
iou: 0.0,
error_type: LabelErrorType::MissingAnnotation,
});
}
}
}
label_errors.sort_by(|a, b| {
b.dt_score
.partial_cmp(&a.dt_score)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.dt_id.cmp(&b.dt_id))
});
Ok(ImageDiagnostics {
annotations: AnnotationIndex {
dt_status,
gt_status,
dt_match: dt_match_map,
gt_match: gt_match_map,
},
images,
label_errors,
iou_thr: actual_iou_thr,
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::coco::COCO;
use crate::eval::COCOeval;
use crate::params::IouType;
use crate::types::{Annotation, Dataset};
fn make_gt(json: serde_json::Value) -> COCO {
let ds: Dataset = serde_json::from_value(json).unwrap();
COCO::from_dataset(ds)
}
fn make_dt(gt: &COCO, anns_json: serde_json::Value) -> COCO {
let anns: Vec<Annotation> = serde_json::from_value(anns_json).unwrap();
gt.load_res_anns(anns).unwrap()
}
fn make_gt_dt() -> (COCO, COCO) {
let gt = make_gt(serde_json::json!({
"images": [
{"id": 1, "width": 100, "height": 100},
{"id": 2, "width": 100, "height": 100}
],
"annotations": [
{"id": 1, "image_id": 1, "category_id": 1, "bbox": [10, 10, 20, 20], "area": 400, "iscrowd": 0},
{"id": 2, "image_id": 1, "category_id": 2, "bbox": [50, 50, 20, 20], "area": 400, "iscrowd": 0},
{"id": 3, "image_id": 2, "category_id": 1, "bbox": [10, 10, 30, 30], "area": 900, "iscrowd": 0}
],
"categories": [
{"id": 1, "name": "cat"},
{"id": 2, "name": "dog"}
]
}));
let dt = make_dt(
>,
serde_json::json!([
{"image_id": 1, "category_id": 1, "bbox": [10, 10, 20, 20], "score": 0.9},
{"image_id": 1, "category_id": 2, "bbox": [50, 50, 20, 20], "score": 0.8},
{"image_id": 2, "category_id": 1, "bbox": [10, 10, 30, 30], "score": 0.7}
]),
);
(gt, dt)
}
#[test]
fn test_diagnostics_perfect_detection() {
let (gt, dt) = make_gt_dt();
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_status.len(), 3);
for status in diag.annotations.dt_status.values() {
assert_eq!(*status, DtStatus::Tp);
}
assert_eq!(diag.annotations.gt_status.len(), 3);
for status in diag.annotations.gt_status.values() {
assert_eq!(*status, GtStatus::Matched);
}
let img1 = &diag.images[&1];
assert_eq!(img1.tp, 2);
assert_eq!(img1.fp, 0);
assert_eq!(img1.fn_count, 0);
assert!((img1.f1 - 1.0).abs() < 1e-9);
assert_eq!(img1.error_profile, ErrorProfile::Perfect);
let img2 = &diag.images[&2];
assert_eq!(img2.tp, 1);
assert!((img2.f1 - 1.0).abs() < 1e-9);
assert!(diag.label_errors.is_empty());
}
#[test]
fn test_diagnostics_with_fp_and_fn() {
let gt = make_gt(serde_json::json!({
"images": [{"id": 1, "width": 100, "height": 100}],
"annotations": [
{"id": 1, "image_id": 1, "category_id": 1, "bbox": [10, 10, 20, 20], "area": 400, "iscrowd": 0},
{"id": 2, "image_id": 1, "category_id": 1, "bbox": [60, 60, 20, 20], "area": 400, "iscrowd": 0}
],
"categories": [{"id": 1, "name": "cat"}]
}));
let dt = make_dt(
>,
serde_json::json!([
{"image_id": 1, "category_id": 1, "bbox": [10, 10, 20, 20], "score": 0.9},
{"image_id": 1, "category_id": 1, "bbox": [80, 80, 10, 10], "score": 0.6}
]),
);
let mut ev = COCOeval::new(gt, dt, IouType::Bbox);
ev.evaluate();
let diag = ev.image_diagnostics(0.5, 0.5).unwrap();
let img = &diag.images[&1];
assert_eq!(img.tp, 1);
assert_eq!(img.fp, 1);
assert_eq!(img.fn_count, 1);
assert!((img.f1 - 0.5).abs() < 1e-9);
assert_eq!(img.error_profile, ErrorProfile::Mixed);
}
#[test]
fn test_diagnostics_wrong_label() {
let gt = make_gt(serde_json::json!({
"images": [{"id": 1, "width": 100, "height": 100}],
"annotations": [
{"id": 1, "image_id": 1, "category_id": 1, "bbox": [10, 10, 20, 20], "area": 400, "iscrowd": 0}
],
"categories": [
{"id": 1, "name": "cat"},
{"id": 2, "name": "dog"}
]
}));
let dt = make_dt(
>,
serde_json::json!([
{"image_id": 1, "category_id": 2, "bbox": [10, 10, 20, 20], "score": 0.95}
]),
);
let mut ev = COCOeval::new(gt, dt, IouType::Bbox);
ev.evaluate();
let diag = ev.image_diagnostics(0.5, 0.5).unwrap();
assert_eq!(diag.images[&1].fp, 1);
assert_eq!(diag.images[&1].fn_count, 1);
assert_eq!(diag.label_errors.len(), 1);
let err = &diag.label_errors[0];
assert_eq!(err.error_type, LabelErrorType::WrongLabel);
assert_eq!(err.dt_category_id, 2); assert_eq!(err.gt_category_id, Some(1)); assert!(err.iou > 0.9); }
#[test]
fn test_diagnostics_missing_annotation() {
let gt = make_gt(serde_json::json!({
"images": [{"id": 1, "width": 200, "height": 200}],
"annotations": [
{"id": 1, "image_id": 1, "category_id": 1, "bbox": [10, 10, 20, 20], "area": 400, "iscrowd": 0}
],
"categories": [{"id": 1, "name": "cat"}]
}));
let dt = make_dt(
>,
serde_json::json!([
{"image_id": 1, "category_id": 1, "bbox": [10, 10, 20, 20], "score": 0.9},
{"image_id": 1, "category_id": 1, "bbox": [150, 150, 20, 20], "score": 0.85}
]),
);
let mut ev = COCOeval::new(gt, dt, IouType::Bbox);
ev.evaluate();
let diag = ev.image_diagnostics(0.5, 0.5).unwrap();
assert_eq!(diag.images[&1].tp, 1);
assert_eq!(diag.images[&1].fp, 1);
assert_eq!(diag.label_errors.len(), 1);
let err = &diag.label_errors[0];
assert_eq!(err.error_type, LabelErrorType::MissingAnnotation);
assert!(err.gt_id.is_none());
}
#[test]
fn test_diagnostics_requires_evaluate() {
let gt = make_gt(serde_json::json!({
"images": [{"id": 1, "width": 100, "height": 100}],
"annotations": [],
"categories": [{"id": 1, "name": "cat"}]
}));
let dt = make_dt(>, serde_json::json!([]));
let ev = COCOeval::new(gt, dt, IouType::Bbox);
assert!(ev.image_diagnostics(0.5, 0.5).is_err());
}
#[test]
fn test_bbox_iou_exact_overlap() {
let a = [10.0, 10.0, 20.0, 20.0];
assert!((bbox_iou_plain(a, a) - 1.0).abs() < 1e-9);
}
#[test]
fn test_bbox_iou_no_overlap() {
let a = [0.0, 0.0, 10.0, 10.0];
let b = [50.0, 50.0, 10.0, 10.0];
assert_eq!(bbox_iou_plain(a, b), 0.0);
}
}