1use crate::accumulate::Accumulated;
15use crate::dataset::{Bbox, CocoDataset, CocoDetections, EvalDataset};
16use crate::error::EvalError;
17use crate::evaluate::{EvalGrid, COLLAPSED_CATEGORY_SENTINEL};
18use crate::summarize::{pairwise_sum, IOU_LOOKUP_TOL};
19use ndarray::{Array2, ArrayView2, Axis};
20use std::collections::HashMap;
21
22#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
29pub struct TablesRequest {
30 pub per_image: bool,
32 pub per_class: bool,
34 pub per_detection: bool,
36 pub per_pair: bool,
38}
39
40impl TablesRequest {
41 pub const NONE: Self = Self {
44 per_image: false,
45 per_class: false,
46 per_detection: false,
47 per_pair: false,
48 };
49
50 pub const CHEAP: Self = Self {
54 per_image: true,
55 per_class: true,
56 per_detection: false,
57 per_pair: false,
58 };
59
60 pub const ALL: Self = Self {
63 per_image: true,
64 per_class: true,
65 per_detection: true,
66 per_pair: true,
67 };
68
69 pub fn requires_iou_retention(&self) -> bool {
72 self.per_pair || self.per_detection
73 }
74}
75
76#[derive(Debug, Clone)]
79pub struct TablesConfig {
80 pub per_pair_iou_floor: f64,
83 pub per_pair_max_rows: usize,
86 pub per_detection_with_geometry: bool,
90}
91
92impl Default for TablesConfig {
93 fn default() -> Self {
94 Self {
95 per_pair_iou_floor: 0.1,
96 per_pair_max_rows: 10_000_000,
97 per_detection_with_geometry: false,
98 }
99 }
100}
101
102#[derive(Debug, Clone)]
110pub struct PerClassTable {
111 pub category_id: Vec<i64>,
114 pub category_name: Vec<String>,
118 pub ap: Vec<Option<f64>>,
121 pub ap50: Vec<Option<f64>>,
123 pub ap75: Vec<Option<f64>>,
125 pub ap_s: Vec<Option<f64>>,
127 pub ap_m: Vec<Option<f64>>,
129 pub ap_l: Vec<Option<f64>>,
131 pub ar_max_1: Vec<Option<f64>>,
133 pub ar_max_10: Vec<Option<f64>>,
135 pub ar_max_100: Vec<Option<f64>>,
137 pub n_gt: Vec<u32>,
141 pub n_dt: Vec<u32>,
146}
147
148impl PerClassTable {
149 pub fn len(&self) -> usize {
151 self.category_id.len()
152 }
153
154 pub fn is_empty(&self) -> bool {
156 self.category_id.is_empty()
157 }
158
159 pub const COLUMN_NAMES: &'static [&'static str] = &[
162 "category_id",
163 "category_name",
164 "ap",
165 "ap50",
166 "ap75",
167 "ap_s",
168 "ap_m",
169 "ap_l",
170 "ar_max_1",
171 "ar_max_10",
172 "ar_max_100",
173 "n_gt",
174 "n_dt",
175 ];
176}
177
178#[derive(Debug, Clone, Default)]
184pub struct PerClassSupport {
185 pub n_gt: Vec<u32>,
188 pub n_dt: Vec<u32>,
190}
191
192impl PerClassSupport {
193 pub fn zeros(n_categories: usize) -> Self {
197 Self {
198 n_gt: vec![0; n_categories],
199 n_dt: vec![0; n_categories],
200 }
201 }
202}
203
204pub fn build_per_class(
226 accum: &Accumulated,
227 dataset: &CocoDataset,
228 iou_thresholds: &[f64],
229 max_dets: &[usize],
230 support: &PerClassSupport,
231) -> Result<PerClassTable, EvalError> {
232 let p_shape = accum.precision.shape();
233 let r_shape = accum.recall.shape();
234 let n_t = p_shape[0];
235 let n_k = p_shape[2];
236 let n_a = p_shape[3];
237 let n_m = p_shape[4];
238
239 if n_t != iou_thresholds.len() {
240 return Err(EvalError::DimensionMismatch {
241 detail: format!(
242 "precision T-axis {} != iou_thresholds len {}",
243 n_t,
244 iou_thresholds.len()
245 ),
246 });
247 }
248 if n_m != max_dets.len() {
249 return Err(EvalError::DimensionMismatch {
250 detail: format!(
251 "precision M-axis {} != max_dets len {}",
252 n_m,
253 max_dets.len()
254 ),
255 });
256 }
257 if r_shape[0] != n_t || r_shape[1] != n_k || r_shape[2] != n_a || r_shape[3] != n_m {
258 return Err(EvalError::DimensionMismatch {
259 detail: format!("recall {r_shape:?} disagrees with precision {p_shape:?}"),
260 });
261 }
262 if n_a != 4 {
263 return Err(EvalError::DimensionMismatch {
264 detail: format!(
265 "per_class requires the COCO detection area grid (4 buckets); got {n_a}"
266 ),
267 });
268 }
269 if support.n_gt.len() != n_k || support.n_dt.len() != n_k {
270 return Err(EvalError::DimensionMismatch {
271 detail: format!(
272 "support counts (n_gt={}, n_dt={}) disagree with K-axis {}",
273 support.n_gt.len(),
274 support.n_dt.len(),
275 n_k
276 ),
277 });
278 }
279
280 let t50 = find_iou_index(iou_thresholds, 0.5)?;
281 let t75 = find_iou_index(iou_thresholds, 0.75)?;
282 let m1 = find_max_dets_index(max_dets, 1)?;
283 let m10 = find_max_dets_index(max_dets, 10)?;
284 let m100 = find_max_dets_index(max_dets, 100)?;
285 let m_last = n_m - 1;
286
287 const A_ALL: usize = 0;
288 const A_SMALL: usize = 1;
289 const A_MEDIUM: usize = 2;
290 const A_LARGE: usize = 3;
291
292 let (category_ids, category_names): (Vec<i64>, Vec<String>) = if n_k == 1
295 && dataset.categories().len() != 1
296 {
297 (
298 vec![COLLAPSED_CATEGORY_SENTINEL],
299 vec!["(all categories)".to_string()],
300 )
301 } else {
302 if n_k != dataset.categories().len() {
303 return Err(EvalError::InvalidConfig {
304 detail: format!(
305 "K-axis size {} disagrees with dataset.categories().len() {}",
306 n_k,
307 dataset.categories().len()
308 ),
309 });
310 }
311 let mut sorted: Vec<&crate::dataset::CategoryMeta> = dataset.categories().iter().collect();
312 sorted.sort_unstable_by_key(|c| c.id.0);
313 (
314 sorted.iter().map(|c| c.id.0).collect(),
315 sorted.iter().map(|c| c.name.clone()).collect(),
316 )
317 };
318
319 let mut ap = Vec::with_capacity(n_k);
320 let mut ap50 = Vec::with_capacity(n_k);
321 let mut ap75 = Vec::with_capacity(n_k);
322 let mut ap_s = Vec::with_capacity(n_k);
323 let mut ap_m = Vec::with_capacity(n_k);
324 let mut ap_l = Vec::with_capacity(n_k);
325 let mut ar_max_1 = Vec::with_capacity(n_k);
326 let mut ar_max_10 = Vec::with_capacity(n_k);
327 let mut ar_max_100 = Vec::with_capacity(n_k);
328
329 for k in 0..n_k {
330 ap.push(mean_precision(accum, 0..n_t, k, A_ALL, m_last));
331 ap50.push(mean_precision(accum, t50..t50 + 1, k, A_ALL, m_last));
332 ap75.push(mean_precision(accum, t75..t75 + 1, k, A_ALL, m_last));
333 ap_s.push(mean_precision(accum, 0..n_t, k, A_SMALL, m_last));
334 ap_m.push(mean_precision(accum, 0..n_t, k, A_MEDIUM, m_last));
335 ap_l.push(mean_precision(accum, 0..n_t, k, A_LARGE, m_last));
336 ar_max_1.push(mean_recall(accum, 0..n_t, k, A_ALL, m1));
337 ar_max_10.push(mean_recall(accum, 0..n_t, k, A_ALL, m10));
338 ar_max_100.push(mean_recall(accum, 0..n_t, k, A_ALL, m100));
339 }
340
341 Ok(PerClassTable {
342 category_id: category_ids,
343 category_name: category_names,
344 ap,
345 ap50,
346 ap75,
347 ap_s,
348 ap_m,
349 ap_l,
350 ar_max_1,
351 ar_max_10,
352 ar_max_100,
353 n_gt: support.n_gt.clone(),
354 n_dt: support.n_dt.clone(),
355 })
356}
357
358fn mean_precision(
365 accum: &Accumulated,
366 t_range: std::ops::Range<usize>,
367 k_idx: usize,
368 area_idx: usize,
369 m_idx: usize,
370) -> Option<f64> {
371 let r = accum.precision.shape()[1];
372 let mut filtered: Vec<f64> = Vec::with_capacity(t_range.len() * r);
373 for t in t_range {
374 accum
375 .precision
376 .index_axis(Axis(0), t)
377 .index_axis(Axis(1), k_idx)
378 .index_axis(Axis(1), area_idx)
379 .index_axis(Axis(1), m_idx)
380 .iter()
381 .copied()
382 .for_each(|v| {
383 if v > -1.0 {
384 filtered.push(v);
385 }
386 });
387 }
388 if filtered.is_empty() {
389 None
390 } else {
391 Some(pairwise_sum(&filtered) / filtered.len() as f64)
392 }
393}
394
395fn mean_recall(
398 accum: &Accumulated,
399 t_range: std::ops::Range<usize>,
400 k_idx: usize,
401 area_idx: usize,
402 m_idx: usize,
403) -> Option<f64> {
404 let mut filtered: Vec<f64> = Vec::with_capacity(t_range.len());
405 for t in t_range {
406 let v = accum.recall[[t, k_idx, area_idx, m_idx]];
407 if v > -1.0 {
408 filtered.push(v);
409 }
410 }
411 if filtered.is_empty() {
412 None
413 } else {
414 Some(pairwise_sum(&filtered) / filtered.len() as f64)
415 }
416}
417
418fn find_iou_index(iou_thresholds: &[f64], target: f64) -> Result<usize, EvalError> {
419 iou_thresholds
420 .iter()
421 .position(|&v| (v - target).abs() < IOU_LOOKUP_TOL)
422 .ok_or_else(|| EvalError::InvalidConfig {
423 detail: format!("iou_thresholds missing required value {target}"),
424 })
425}
426
427fn find_max_dets_index(max_dets: &[usize], target: usize) -> Result<usize, EvalError> {
428 max_dets
429 .iter()
430 .position(|&v| v == target)
431 .ok_or_else(|| EvalError::InvalidConfig {
432 detail: format!("max_dets missing required value {target}"),
433 })
434}
435
436pub fn aggregate_per_class_support(
450 grid: &crate::evaluate::EvalGrid,
451 area_index_all: usize,
452) -> PerClassSupport {
453 let mut support = PerClassSupport::zeros(grid.n_categories);
454 if area_index_all >= grid.n_area_ranges {
455 return support;
456 }
457 for k in 0..grid.n_categories {
458 let mut n_gt = 0u32;
459 let mut n_dt = 0u32;
460 for i in 0..grid.n_images {
461 if let Some(cell) = grid.cell(k, area_index_all, i) {
462 n_gt = n_gt.saturating_add(
463 cell.gt_ignore
464 .iter()
465 .filter(|&&ignored| !ignored)
466 .count()
467 .try_into()
468 .unwrap_or(u32::MAX),
469 );
470 n_dt = n_dt.saturating_add(cell.dt_scores.len().try_into().unwrap_or(u32::MAX));
471 }
472 }
473 support.n_gt[k] = n_gt;
474 support.n_dt[k] = n_dt;
475 }
476 support
477}
478
479#[derive(Debug, Clone)]
493pub struct PerImageTable {
494 pub image_id: Vec<i64>,
497 pub n_gt: Vec<u32>,
499 pub n_dt: Vec<u32>,
502 pub tp_at_50: Vec<u32>,
504 pub fp_at_50: Vec<u32>,
506 pub fn_at_50: Vec<u32>,
508 pub tp_at_75: Vec<u32>,
510 pub fp_at_75: Vec<u32>,
512 pub fn_at_75: Vec<u32>,
514 pub tp_mean_iou: Vec<u32>,
516}
517
518impl PerImageTable {
519 pub fn len(&self) -> usize {
521 self.image_id.len()
522 }
523
524 pub fn is_empty(&self) -> bool {
526 self.image_id.is_empty()
527 }
528
529 pub const COLUMN_NAMES: &'static [&'static str] = &[
532 "image_id",
533 "n_gt",
534 "n_dt",
535 "tp_at_50",
536 "fp_at_50",
537 "fn_at_50",
538 "tp_at_75",
539 "fp_at_75",
540 "fn_at_75",
541 "tp_mean_iou",
542 ];
543}
544
545pub fn build_per_image(
556 grid: &EvalGrid,
557 dataset: &CocoDataset,
558 iou_thresholds: &[f64],
559) -> Result<PerImageTable, EvalError> {
560 if grid.n_area_ranges != 4 {
561 return Err(EvalError::DimensionMismatch {
562 detail: format!(
563 "per_image requires the COCO detection area grid (4 buckets); got {}",
564 grid.n_area_ranges
565 ),
566 });
567 }
568 let n_t = iou_thresholds.len();
569 if n_t == 0 {
570 return Err(EvalError::DimensionMismatch {
571 detail: "iou_thresholds empty".into(),
572 });
573 }
574 let t50 = find_iou_index(iou_thresholds, 0.5)?;
575 let t75 = find_iou_index(iou_thresholds, 0.75)?;
576 const A_ALL: usize = 0;
577
578 let mut images: Vec<&crate::dataset::ImageMeta> = dataset.images().iter().collect();
580 images.sort_unstable_by_key(|im| im.id.0);
581 if images.len() != grid.n_images {
582 return Err(EvalError::InvalidConfig {
583 detail: format!(
584 "dataset image count {} disagrees with grid I-axis {}",
585 images.len(),
586 grid.n_images
587 ),
588 });
589 }
590 let n_i = grid.n_images;
591
592 let mut image_id = Vec::with_capacity(n_i);
593 let mut n_gt = vec![0u32; n_i];
594 let mut n_dt = vec![0u32; n_i];
595 let mut tp_at_50 = vec![0u32; n_i];
596 let mut fp_at_50 = vec![0u32; n_i];
597 let mut tp_at_75 = vec![0u32; n_i];
598 let mut fp_at_75 = vec![0u32; n_i];
599 let mut tp_t_sum = vec![0u64; n_i];
602
603 for im in &images {
604 image_id.push(im.id.0);
605 }
606
607 for k in 0..grid.n_categories {
608 for i in 0..n_i {
609 let Some(cell) = grid.cell(k, A_ALL, i) else {
610 continue;
611 };
612 n_gt[i] = n_gt[i].saturating_add(saturating_u32_count(
614 cell.gt_ignore.iter().filter(|&&b| !b).count(),
615 ));
616 let n_dt_cell = cell.dt_scores.len();
617 n_dt[i] = n_dt[i].saturating_add(saturating_u32_count(n_dt_cell));
618
619 for d in 0..n_dt_cell {
623 if !cell.dt_ignore[[t50, d]] {
624 if cell.dt_matched[[t50, d]] {
625 tp_at_50[i] = tp_at_50[i].saturating_add(1);
626 } else {
627 fp_at_50[i] = fp_at_50[i].saturating_add(1);
628 }
629 }
630 if !cell.dt_ignore[[t75, d]] {
631 if cell.dt_matched[[t75, d]] {
632 tp_at_75[i] = tp_at_75[i].saturating_add(1);
633 } else {
634 fp_at_75[i] = fp_at_75[i].saturating_add(1);
635 }
636 }
637 }
638
639 for t in 0..n_t {
640 let mut tp_t = 0u64;
641 for d in 0..n_dt_cell {
642 if !cell.dt_ignore[[t, d]] && cell.dt_matched[[t, d]] {
643 tp_t += 1;
644 }
645 }
646 tp_t_sum[i] = tp_t_sum[i].saturating_add(tp_t);
647 }
648 }
649 }
650
651 let fn_at_50: Vec<u32> = (0..n_i)
652 .map(|i| n_gt[i].saturating_sub(tp_at_50[i]))
653 .collect();
654 let fn_at_75: Vec<u32> = (0..n_i)
655 .map(|i| n_gt[i].saturating_sub(tp_at_75[i]))
656 .collect();
657 let tp_mean_iou: Vec<u32> = (0..n_i)
658 .map(|i| {
659 let mean = tp_t_sum[i] / n_t as u64;
660 mean.try_into().unwrap_or(u32::MAX)
661 })
662 .collect();
663
664 Ok(PerImageTable {
665 image_id,
666 n_gt,
667 n_dt,
668 tp_at_50,
669 fp_at_50,
670 fn_at_50,
671 tp_at_75,
672 fp_at_75,
673 fn_at_75,
674 tp_mean_iou,
675 })
676}
677
678fn saturating_u32_count(n: usize) -> u32 {
679 n.try_into().unwrap_or(u32::MAX)
680}
681
682#[derive(Debug, Clone, Default)]
691pub struct RetainedIous {
692 inner: HashMap<(usize, usize), Array2<f64>>,
693}
694
695impl RetainedIous {
696 pub(crate) fn new() -> Self {
698 Self::default()
699 }
700
701 pub(crate) fn from_map(map: HashMap<(usize, usize), Array2<f64>>) -> Self {
705 Self { inner: map }
706 }
707
708 pub fn len(&self) -> usize {
710 self.inner.len()
711 }
712
713 pub fn is_empty(&self) -> bool {
715 self.inner.is_empty()
716 }
717
718 pub(crate) fn insert(&mut self, k: usize, i: usize, iou: Array2<f64>) {
720 self.inner.insert((k, i), iou);
721 }
722
723 pub fn get(&self, k: usize, i: usize) -> Option<ArrayView2<'_, f64>> {
725 self.inner.get(&(k, i)).map(|m| m.view())
726 }
727
728 pub(crate) fn remove(&mut self, k: usize, i: usize) -> Option<Array2<f64>> {
731 self.inner.remove(&(k, i))
732 }
733
734 pub(crate) fn iter(&self) -> impl Iterator<Item = (usize, usize, ArrayView2<'_, f64>)> + '_ {
738 self.inner.iter().map(|(&(k, i), arr)| (k, i, arr.view()))
739 }
740}
741
742#[derive(Debug, Clone, Default)]
773pub struct CrossClassIous {
774 inner: HashMap<usize, Array2<f64>>,
775 dt_classes: HashMap<usize, Vec<usize>>,
776 gt_classes: HashMap<usize, Vec<usize>>,
777}
778
779impl CrossClassIous {
780 pub fn new() -> Self {
782 Self::default()
783 }
784
785 pub fn len(&self) -> usize {
787 self.inner.len()
788 }
789
790 pub fn is_empty(&self) -> bool {
792 self.inner.is_empty()
793 }
794
795 pub(crate) fn insert(
802 &mut self,
803 image_idx: usize,
804 iou: Array2<f64>,
805 dt_classes: Vec<usize>,
806 gt_classes: Vec<usize>,
807 ) {
808 self.inner.insert(image_idx, iou);
809 self.dt_classes.insert(image_idx, dt_classes);
810 self.gt_classes.insert(image_idx, gt_classes);
811 }
812
813 pub fn get(&self, image_idx: usize) -> Option<ArrayView2<'_, f64>> {
817 self.inner.get(&image_idx).map(|m| m.view())
818 }
819
820 pub fn dt_classes(&self, image_idx: usize) -> Option<&[usize]> {
822 self.dt_classes.get(&image_idx).map(Vec::as_slice)
823 }
824
825 pub fn gt_classes(&self, image_idx: usize) -> Option<&[usize]> {
827 self.gt_classes.get(&image_idx).map(Vec::as_slice)
828 }
829}
830
831#[derive(Debug, Clone, Copy, PartialEq, Eq)]
837pub enum MatchStatus {
838 TruePositive,
840 FalsePositive,
842 Ignored,
845}
846
847impl MatchStatus {
848 pub fn dict_index(self) -> u32 {
850 match self {
851 Self::TruePositive => 0,
852 Self::FalsePositive => 1,
853 Self::Ignored => 2,
854 }
855 }
856
857 pub const DICT_VALUES: &'static [&'static str] = &["tp", "fp", "ignored"];
859}
860
861#[derive(Debug, Clone, Default)]
864pub struct BboxColumns {
865 pub xywh: Vec<[f64; 4]>,
868}
869
870#[derive(Debug, Clone)]
875pub struct PerDetectionTable {
876 pub detection_id: Vec<i64>,
878 pub image_id: Vec<i64>,
880 pub category_id: Vec<i64>,
882 pub score: Vec<f64>,
884 pub area: Vec<f64>,
886 pub match_status_at_50: Vec<MatchStatus>,
888 pub matched_gt_id_at_50: Vec<Option<i64>>,
890 pub best_iou: Vec<Option<f64>>,
894 pub bbox: Option<BboxColumns>,
897}
898
899impl PerDetectionTable {
900 pub fn len(&self) -> usize {
902 self.detection_id.len()
903 }
904 pub fn is_empty(&self) -> bool {
906 self.detection_id.is_empty()
907 }
908}
909
910#[derive(Debug, Clone, Default)]
914pub struct PerPairTable {
915 pub detection_id: Vec<i64>,
917 pub ground_truth_id: Vec<i64>,
919 pub image_id: Vec<i64>,
921 pub category_id: Vec<i64>,
923 pub iou: Vec<f64>,
925}
926
927impl PerPairTable {
928 pub fn len(&self) -> usize {
930 self.detection_id.len()
931 }
932 pub fn is_empty(&self) -> bool {
934 self.detection_id.is_empty()
935 }
936}
937
938pub fn build_per_detection(
948 grid: &EvalGrid,
949 detections: &CocoDetections,
950 iou_thresholds: &[f64],
951 retained_ious: Option<&RetainedIous>,
952 config: &TablesConfig,
953) -> Result<PerDetectionTable, EvalError> {
954 if grid.n_area_ranges == 0 {
955 return Err(EvalError::DimensionMismatch {
956 detail: "per_detection requires at least one area range".into(),
957 });
958 }
959 let t50 = find_iou_index(iou_thresholds, 0.5)?;
960 const A_ALL: usize = 0;
961
962 let det_index: HashMap<i64, &crate::dataset::CocoDetection> = detections
966 .detections()
967 .iter()
968 .map(|d| (d.id.0, d))
969 .collect();
970
971 let with_geometry = config.per_detection_with_geometry;
972 let mut detection_id = Vec::new();
973 let mut image_id = Vec::new();
974 let mut category_id = Vec::new();
975 let mut score = Vec::new();
976 let mut area = Vec::new();
977 let mut match_status_at_50 = Vec::new();
978 let mut matched_gt_id_at_50 = Vec::new();
979 let mut best_iou = Vec::new();
980 let mut bbox_xywh: Vec<[f64; 4]> = Vec::new();
981
982 for k in 0..grid.n_categories {
983 for i in 0..grid.n_images {
984 let Some(cell) = grid.cell(k, A_ALL, i) else {
985 continue;
986 };
987 let Some(meta) = grid.cell_meta(k, A_ALL, i) else {
988 continue;
989 };
990 let iou_view = retained_ious.and_then(|r| r.get(k, i));
991
992 for d in 0..cell.dt_scores.len() {
993 let dt_id = meta.dt_ids[d];
994 detection_id.push(dt_id);
995 image_id.push(meta.image_id);
996 category_id.push(meta.category_id);
997 score.push(cell.dt_scores[d]);
998
999 let det = det_index.get(&dt_id);
1000 area.push(det.map(|d| d.area).unwrap_or(f64::NAN));
1001 if with_geometry {
1002 let b = det
1003 .map(|d| d.bbox)
1004 .unwrap_or_else(|| Bbox::from([f64::NAN; 4]));
1005 bbox_xywh.push([b.x, b.y, b.w, b.h]);
1006 }
1007
1008 let dt_ignored = cell.dt_ignore[[t50, d]];
1009 let dt_matched_flag = cell.dt_matched[[t50, d]];
1010 let status = if dt_ignored {
1011 MatchStatus::Ignored
1012 } else if dt_matched_flag {
1013 MatchStatus::TruePositive
1014 } else {
1015 MatchStatus::FalsePositive
1016 };
1017 match_status_at_50.push(status);
1018 let matched_gt = meta.dt_matches[[t50, d]];
1019 matched_gt_id_at_50.push(if matched_gt == 0 || dt_ignored {
1020 None
1021 } else {
1022 Some(matched_gt)
1023 });
1024
1025 let bi = iou_view.and_then(|view| {
1028 if view.ncols() == 0 || view.nrows() == 0 || d >= view.ncols() {
1029 return None;
1030 }
1031 let mut best: Option<f64> = None;
1032 for g in 0..view.nrows() {
1033 let v = view[[g, d]];
1034 if best.is_none_or(|b| v > b) {
1035 best = Some(v);
1036 }
1037 }
1038 best
1039 });
1040 best_iou.push(bi);
1041 }
1042 }
1043 }
1044
1045 let bbox = if with_geometry {
1046 Some(BboxColumns { xywh: bbox_xywh })
1047 } else {
1048 None
1049 };
1050 Ok(PerDetectionTable {
1051 detection_id,
1052 image_id,
1053 category_id,
1054 score,
1055 area,
1056 match_status_at_50,
1057 matched_gt_id_at_50,
1058 best_iou,
1059 bbox,
1060 })
1061}
1062
1063pub fn build_per_pair(
1073 grid: &EvalGrid,
1074 retained_ious: &RetainedIous,
1075 config: &TablesConfig,
1076) -> Result<PerPairTable, EvalError> {
1077 if grid.n_area_ranges == 0 {
1078 return Err(EvalError::DimensionMismatch {
1079 detail: "per_pair requires at least one area range".into(),
1080 });
1081 }
1082 const A_ALL: usize = 0;
1083 let mut out = PerPairTable::default();
1084 let cap = config.per_pair_max_rows;
1085 let floor = config.per_pair_iou_floor;
1086
1087 for k in 0..grid.n_categories {
1088 for i in 0..grid.n_images {
1089 let Some(meta) = grid.cell_meta(k, A_ALL, i) else {
1090 continue;
1091 };
1092 let Some(view) = retained_ious.get(k, i) else {
1093 continue;
1094 };
1095 let n_gt_use = view.nrows().min(meta.gt_ids.len());
1099 let n_dt_use = view.ncols().min(meta.dt_ids.len());
1100 for g in 0..n_gt_use {
1101 for d in 0..n_dt_use {
1102 let v = view[[g, d]];
1103 if v < floor {
1104 continue;
1105 }
1106 if out.detection_id.len() >= cap {
1107 return Err(EvalError::PerPairOverflow {
1108 observed: out.detection_id.len() + 1,
1109 cap,
1110 });
1111 }
1112 out.detection_id.push(meta.dt_ids[d]);
1113 out.ground_truth_id.push(meta.gt_ids[g]);
1114 out.image_id.push(meta.image_id);
1115 out.category_id.push(meta.category_id);
1116 out.iou.push(v);
1117 }
1118 }
1119 }
1120 }
1121 Ok(out)
1122}
1123
1124#[derive(Debug, Clone, Default)]
1129pub struct Tables {
1130 pub per_image: Option<PerImageTable>,
1132 pub per_class: Option<PerClassTable>,
1134 pub per_detection: Option<PerDetectionTable>,
1136 pub per_pair: Option<PerPairTable>,
1138}
1139
1140#[allow(clippy::too_many_arguments)]
1149pub fn build_tables(
1150 grid: &EvalGrid,
1151 accum: &Accumulated,
1152 dataset: &CocoDataset,
1153 detections: Option<&CocoDetections>,
1154 retained_ious: Option<&RetainedIous>,
1155 iou_thresholds: &[f64],
1156 max_dets: &[usize],
1157 request: TablesRequest,
1158 config: &TablesConfig,
1159) -> Result<Tables, EvalError> {
1160 let mut out = Tables::default();
1161 if request.per_class {
1162 let support = aggregate_per_class_support(grid, 0);
1163 out.per_class = Some(build_per_class(
1164 accum,
1165 dataset,
1166 iou_thresholds,
1167 max_dets,
1168 &support,
1169 )?);
1170 }
1171 if request.per_image {
1172 out.per_image = Some(build_per_image(grid, dataset, iou_thresholds)?);
1173 }
1174 if request.per_detection {
1175 let dets = detections.ok_or_else(|| EvalError::InvalidConfig {
1176 detail: "per_detection requires detections to be threaded through \
1177 build_tables; pass Some(&CocoDetections)"
1178 .into(),
1179 })?;
1180 out.per_detection = Some(build_per_detection(
1181 grid,
1182 dets,
1183 iou_thresholds,
1184 retained_ious,
1185 config,
1186 )?);
1187 }
1188 if request.per_pair {
1189 let ious = retained_ious.ok_or_else(|| EvalError::InvalidConfig {
1190 detail: "per_pair requires retained IoU matrices; build the upstream \
1191 evaluator with EvaluateParams::retain_iou=true (or pass \
1192 retain_iou=True at StreamingEvaluator construction)"
1193 .into(),
1194 })?;
1195 out.per_pair = Some(build_per_pair(grid, ious, config)?);
1196 }
1197 Ok(out)
1198}
1199
1200#[cfg(test)]
1201mod tests {
1202 use super::*;
1203 use crate::accumulate::Accumulated;
1204 use crate::dataset::{CategoryId, CategoryMeta, CocoDataset, ImageMeta};
1205 use crate::parity::iou_thresholds;
1206 use ndarray::{Array4, Array5};
1207
1208 fn dataset_with_two_categories() -> CocoDataset {
1209 let images = vec![ImageMeta {
1210 id: crate::dataset::ImageId(1),
1211 width: 100,
1212 height: 100,
1213 file_name: None,
1214 }];
1215 let categories = vec![
1216 CategoryMeta {
1217 id: CategoryId(2),
1218 name: "cat".into(),
1219 supercategory: None,
1220 },
1221 CategoryMeta {
1222 id: CategoryId(1),
1223 name: "dog".into(),
1224 supercategory: None,
1225 },
1226 ];
1227 CocoDataset::from_parts(images, Vec::new(), categories).unwrap()
1228 }
1229
1230 #[test]
1231 fn tables_request_requires_iou_retention_only_for_dt_pair() {
1232 assert!(!TablesRequest::CHEAP.requires_iou_retention());
1233 assert!(TablesRequest {
1234 per_detection: true,
1235 ..TablesRequest::default()
1236 }
1237 .requires_iou_retention());
1238 assert!(TablesRequest {
1239 per_pair: true,
1240 ..TablesRequest::default()
1241 }
1242 .requires_iou_retention());
1243 }
1244
1245 #[test]
1246 fn build_per_class_emits_one_row_per_category_in_id_ascending_order() {
1247 let dataset = dataset_with_two_categories();
1252 let iou_thr = iou_thresholds();
1253 let max_dets = [1usize, 10, 100];
1254 let n_t = iou_thr.len();
1255 let n_r = 101;
1256 let n_k = 2;
1257 let n_a = 4;
1258 let n_m = 3;
1259
1260 let mut precision = Array5::<f64>::from_elem((n_t, n_r, n_k, n_a, n_m), -1.0);
1261 let mut recall = Array4::<f64>::from_elem((n_t, n_k, n_a, n_m), -1.0);
1262
1263 precision
1265 .index_axis_mut(Axis(2), 0)
1266 .index_axis_mut(Axis(2), 0) .index_axis_mut(Axis(2), 2) .fill(0.6);
1269 precision
1270 .index_axis_mut(Axis(2), 1)
1271 .index_axis_mut(Axis(2), 0)
1272 .index_axis_mut(Axis(2), 2)
1273 .fill(0.8);
1274
1275 recall[[0, 0, 0, 2]] = 0.5;
1277 recall[[0, 1, 0, 2]] = 0.9;
1278
1279 let accum = Accumulated {
1280 precision,
1281 recall,
1282 scores: Array5::<f64>::from_elem((n_t, n_r, n_k, n_a, n_m), -1.0),
1283 };
1284 let support = PerClassSupport {
1285 n_gt: vec![3, 4],
1286 n_dt: vec![5, 6],
1287 };
1288
1289 let table = build_per_class(&accum, &dataset, iou_thr, &max_dets, &support).unwrap();
1290 assert_eq!(table.len(), 2);
1291
1292 assert_eq!(table.category_id, vec![1, 2]);
1294 assert_eq!(
1295 table.category_name,
1296 vec!["dog".to_string(), "cat".to_string()]
1297 );
1298 assert!((table.ap[0].unwrap() - 0.6).abs() < 1e-12);
1299 assert!((table.ap[1].unwrap() - 0.8).abs() < 1e-12);
1300 assert_eq!(table.n_gt, vec![3, 4]);
1301 assert_eq!(table.n_dt, vec![5, 6]);
1302
1303 assert!((table.ar_max_100[0].unwrap() - 0.5).abs() < 1e-12);
1306 assert!((table.ar_max_100[1].unwrap() - 0.9).abs() < 1e-12);
1307 }
1308
1309 #[test]
1310 fn build_per_class_emits_null_for_all_sentinel_cells() {
1311 let dataset = dataset_with_two_categories();
1312 let iou_thr = iou_thresholds();
1313 let max_dets = [1usize, 10, 100];
1314 let n_t = iou_thr.len();
1315 let accum = Accumulated {
1316 precision: Array5::<f64>::from_elem((n_t, 101, 2, 4, 3), -1.0),
1317 recall: Array4::<f64>::from_elem((n_t, 2, 4, 3), -1.0),
1318 scores: Array5::<f64>::from_elem((n_t, 101, 2, 4, 3), -1.0),
1319 };
1320 let support = PerClassSupport::zeros(2);
1321 let table = build_per_class(&accum, &dataset, iou_thr, &max_dets, &support).unwrap();
1322 assert!(table.ap.iter().all(Option::is_none));
1323 assert!(table.ar_max_100.iter().all(Option::is_none));
1324 }
1325
1326 #[test]
1327 fn build_per_class_collapsed_use_cats_false_returns_single_row() {
1328 let dataset = dataset_with_two_categories();
1329 let iou_thr = iou_thresholds();
1330 let max_dets = [1usize, 10, 100];
1331 let n_t = iou_thr.len();
1332 let accum = Accumulated {
1334 precision: Array5::<f64>::from_elem((n_t, 101, 1, 4, 3), 0.7),
1335 recall: Array4::<f64>::from_elem((n_t, 1, 4, 3), 0.7),
1336 scores: Array5::<f64>::from_elem((n_t, 101, 1, 4, 3), 0.7),
1337 };
1338 let support = PerClassSupport {
1339 n_gt: vec![100],
1340 n_dt: vec![200],
1341 };
1342 let table = build_per_class(&accum, &dataset, iou_thr, &max_dets, &support).unwrap();
1343 assert_eq!(table.len(), 1);
1344 assert_eq!(table.category_id, vec![COLLAPSED_CATEGORY_SENTINEL]);
1345 assert_eq!(table.category_name, vec!["(all categories)".to_string()]);
1346 assert!((table.ap[0].unwrap() - 0.7).abs() < 1e-12);
1347 }
1348
1349 #[test]
1350 fn build_per_class_rejects_a_axis_size_other_than_4() {
1351 let dataset = dataset_with_two_categories();
1352 let iou_thr = iou_thresholds();
1353 let max_dets = [20usize];
1354 let n_t = iou_thr.len();
1355 let accum = Accumulated {
1357 precision: Array5::<f64>::from_elem((n_t, 101, 2, 3, 1), 0.5),
1358 recall: Array4::<f64>::from_elem((n_t, 2, 3, 1), 0.5),
1359 scores: Array5::<f64>::from_elem((n_t, 101, 2, 3, 1), 0.5),
1360 };
1361 let support = PerClassSupport::zeros(2);
1362 let err = build_per_class(&accum, &dataset, iou_thr, &max_dets, &support).unwrap_err();
1363 assert!(matches!(err, EvalError::DimensionMismatch { .. }));
1364 }
1365
1366 fn perfect_match_grid_two_images() -> (EvalGrid, CocoDataset) {
1367 use crate::dataset::{Bbox as DsBbox, CocoAnnotation, DetectionInput};
1371 use crate::evaluate::{evaluate_bbox, AreaRange, EvaluateParams};
1372 use crate::parity::{iou_thresholds, ParityMode};
1373 let images = vec![
1374 ImageMeta {
1375 id: crate::dataset::ImageId(1),
1376 width: 100,
1377 height: 100,
1378 file_name: None,
1379 },
1380 ImageMeta {
1381 id: crate::dataset::ImageId(2),
1382 width: 100,
1383 height: 100,
1384 file_name: None,
1385 },
1386 ];
1387 let categories = vec![CategoryMeta {
1388 id: CategoryId(1),
1389 name: "thing".into(),
1390 supercategory: None,
1391 }];
1392 let anns = vec![
1393 CocoAnnotation {
1394 id: crate::dataset::AnnId(1),
1395 image_id: crate::dataset::ImageId(1),
1396 category_id: CategoryId(1),
1397 area: 100.0,
1398 is_crowd: false,
1399 ignore_flag: None,
1400 bbox: DsBbox {
1401 x: 0.0,
1402 y: 0.0,
1403 w: 10.0,
1404 h: 10.0,
1405 },
1406 segmentation: None,
1407 keypoints: None,
1408 num_keypoints: None,
1409 },
1410 CocoAnnotation {
1411 id: crate::dataset::AnnId(2),
1412 image_id: crate::dataset::ImageId(2),
1413 category_id: CategoryId(1),
1414 area: 100.0,
1415 is_crowd: false,
1416 ignore_flag: None,
1417 bbox: DsBbox {
1418 x: 0.0,
1419 y: 0.0,
1420 w: 10.0,
1421 h: 10.0,
1422 },
1423 segmentation: None,
1424 keypoints: None,
1425 num_keypoints: None,
1426 },
1427 ];
1428 let dataset = CocoDataset::from_parts(images, anns, categories).unwrap();
1429
1430 let dt_inputs = vec![
1431 DetectionInput {
1432 id: None,
1433 image_id: crate::dataset::ImageId(1),
1434 category_id: CategoryId(1),
1435 score: 0.9,
1436 bbox: DsBbox {
1437 x: 0.0,
1438 y: 0.0,
1439 w: 10.0,
1440 h: 10.0,
1441 },
1442 segmentation: None,
1443 keypoints: None,
1444 num_keypoints: None,
1445 },
1446 DetectionInput {
1447 id: None,
1448 image_id: crate::dataset::ImageId(2),
1449 category_id: CategoryId(1),
1450 score: 0.8,
1451 bbox: DsBbox {
1453 x: 50.0,
1454 y: 50.0,
1455 w: 10.0,
1456 h: 10.0,
1457 },
1458 segmentation: None,
1459 keypoints: None,
1460 num_keypoints: None,
1461 },
1462 ];
1463 let detections = crate::dataset::CocoDetections::from_inputs(dt_inputs).unwrap();
1464 let area = AreaRange::coco_default();
1465 let grid = evaluate_bbox(
1466 &dataset,
1467 &detections,
1468 EvaluateParams {
1469 iou_thresholds: iou_thresholds(),
1470 area_ranges: &area,
1471 max_dets_per_image: 100,
1472 use_cats: true,
1473 retain_iou: false,
1474 },
1475 ParityMode::Corrected,
1476 )
1477 .unwrap();
1478 (grid, dataset)
1479 }
1480
1481 #[test]
1482 fn build_per_image_counts_tp_fp_fn_against_perfect_and_unmatched_pairs() {
1483 let (grid, dataset) = perfect_match_grid_two_images();
1484 let table = build_per_image(&grid, &dataset, crate::parity::iou_thresholds()).unwrap();
1485 assert_eq!(table.len(), 2);
1486 assert_eq!(table.image_id, vec![1, 2]);
1487 assert_eq!(table.n_gt, vec![1, 1]);
1489 assert_eq!(table.n_dt, vec![1, 1]);
1491 assert_eq!(table.tp_at_50, vec![1, 0]);
1492 assert_eq!(table.fp_at_50, vec![0, 1]);
1493 assert_eq!(table.fn_at_50, vec![0, 1]);
1494 assert_eq!(table.tp_at_75, vec![1, 0]);
1495 assert_eq!(table.fp_at_75, vec![0, 1]);
1496 assert_eq!(table.fn_at_75, vec![0, 1]);
1497 assert_eq!(table.tp_mean_iou, vec![1, 0]);
1500 }
1501
1502 #[test]
1503 fn build_per_image_excludes_crowd_matched_dts_from_tp() {
1504 use crate::dataset::{Bbox as DsBbox, CocoAnnotation, DetectionInput};
1508 use crate::evaluate::{evaluate_bbox, AreaRange, EvaluateParams};
1509 use crate::parity::{iou_thresholds, ParityMode};
1510 let images = vec![ImageMeta {
1511 id: crate::dataset::ImageId(1),
1512 width: 100,
1513 height: 100,
1514 file_name: None,
1515 }];
1516 let categories = vec![CategoryMeta {
1517 id: CategoryId(1),
1518 name: "thing".into(),
1519 supercategory: None,
1520 }];
1521 let anns = vec![CocoAnnotation {
1522 id: crate::dataset::AnnId(1),
1523 image_id: crate::dataset::ImageId(1),
1524 category_id: CategoryId(1),
1525 area: 100.0,
1526 is_crowd: true,
1528 ignore_flag: None,
1529 bbox: DsBbox {
1530 x: 0.0,
1531 y: 0.0,
1532 w: 10.0,
1533 h: 10.0,
1534 },
1535 segmentation: None,
1536 keypoints: None,
1537 num_keypoints: None,
1538 }];
1539 let dataset = CocoDataset::from_parts(images, anns, categories).unwrap();
1540 let dt_inputs = vec![DetectionInput {
1541 id: None,
1542 image_id: crate::dataset::ImageId(1),
1543 category_id: CategoryId(1),
1544 score: 0.9,
1545 bbox: DsBbox {
1546 x: 0.0,
1547 y: 0.0,
1548 w: 10.0,
1549 h: 10.0,
1550 },
1551 segmentation: None,
1552 keypoints: None,
1553 num_keypoints: None,
1554 }];
1555 let detections = crate::dataset::CocoDetections::from_inputs(dt_inputs).unwrap();
1556 let area = AreaRange::coco_default();
1557 let grid = evaluate_bbox(
1558 &dataset,
1559 &detections,
1560 EvaluateParams {
1561 iou_thresholds: iou_thresholds(),
1562 area_ranges: &area,
1563 max_dets_per_image: 100,
1564 use_cats: true,
1565 retain_iou: false,
1566 },
1567 ParityMode::Corrected,
1568 )
1569 .unwrap();
1570 let table = build_per_image(&grid, &dataset, iou_thresholds()).unwrap();
1571 assert_eq!(table.n_gt, vec![0]);
1572 assert_eq!(table.tp_at_50, vec![0]);
1573 assert_eq!(table.fp_at_50, vec![0]);
1574 assert_eq!(table.fn_at_50, vec![0]);
1575 }
1576
1577 #[test]
1578 fn build_per_image_rejects_non_detection_grid() {
1579 let grid = EvalGrid {
1581 eval_imgs: vec![None; 3],
1582 eval_imgs_meta: vec![None; 3],
1583 n_categories: 1,
1584 n_area_ranges: 3,
1585 n_images: 1,
1586 retained_ious: None,
1587 };
1588 let dataset = dataset_with_two_categories();
1589 let err = build_per_image(&grid, &dataset, crate::parity::iou_thresholds()).unwrap_err();
1590 assert!(matches!(err, EvalError::DimensionMismatch { .. }));
1591 }
1592
1593 #[test]
1594 fn build_tables_dispatches_per_image_and_per_class() {
1595 let (grid, dataset) = perfect_match_grid_two_images();
1596 let max_dets = [1usize, 10, 100];
1597 let p = crate::accumulate::AccumulateParams {
1599 iou_thresholds: crate::parity::iou_thresholds(),
1600 recall_thresholds: crate::parity::recall_thresholds(),
1601 max_dets: &max_dets,
1602 n_categories: grid.n_categories,
1603 n_area_ranges: grid.n_area_ranges,
1604 n_images: grid.n_images,
1605 };
1606 let accum =
1607 crate::accumulate::accumulate(&grid.eval_imgs, p, crate::parity::ParityMode::Corrected)
1608 .unwrap();
1609
1610 let tables = build_tables(
1611 &grid,
1612 &accum,
1613 &dataset,
1614 None,
1615 None,
1616 crate::parity::iou_thresholds(),
1617 &max_dets,
1618 TablesRequest::CHEAP,
1619 &TablesConfig::default(),
1620 )
1621 .unwrap();
1622 assert!(tables.per_image.is_some());
1623 assert!(tables.per_class.is_some());
1624 }
1625
1626 #[test]
1627 fn retain_iou_flag_does_not_perturb_the_summary() {
1628 use crate::dataset::{Bbox as DsBbox, CocoAnnotation, DetectionInput};
1633 use crate::evaluate::{evaluate_bbox, AreaRange, EvaluateParams};
1634 use crate::parity::{iou_thresholds, ParityMode};
1635 let images = vec![ImageMeta {
1636 id: crate::dataset::ImageId(1),
1637 width: 100,
1638 height: 100,
1639 file_name: None,
1640 }];
1641 let categories = vec![CategoryMeta {
1642 id: CategoryId(1),
1643 name: "thing".into(),
1644 supercategory: None,
1645 }];
1646 let anns = vec![CocoAnnotation {
1647 id: crate::dataset::AnnId(1),
1648 image_id: crate::dataset::ImageId(1),
1649 category_id: CategoryId(1),
1650 area: 100.0,
1651 is_crowd: false,
1652 ignore_flag: None,
1653 bbox: DsBbox {
1654 x: 0.0,
1655 y: 0.0,
1656 w: 10.0,
1657 h: 10.0,
1658 },
1659 segmentation: None,
1660 keypoints: None,
1661 num_keypoints: None,
1662 }];
1663 let dataset = CocoDataset::from_parts(images, anns, categories).unwrap();
1664 let dt_inputs = vec![DetectionInput {
1665 id: None,
1666 image_id: crate::dataset::ImageId(1),
1667 category_id: CategoryId(1),
1668 score: 0.9,
1669 bbox: DsBbox {
1670 x: 0.0,
1671 y: 0.0,
1672 w: 10.0,
1673 h: 10.0,
1674 },
1675 segmentation: None,
1676 keypoints: None,
1677 num_keypoints: None,
1678 }];
1679 let detections = crate::dataset::CocoDetections::from_inputs(dt_inputs).unwrap();
1680 let area = AreaRange::coco_default();
1681 let max_dets = [1usize, 10, 100];
1682
1683 let mut params_off = EvaluateParams {
1684 iou_thresholds: iou_thresholds(),
1685 area_ranges: &area,
1686 max_dets_per_image: 100,
1687 use_cats: true,
1688 retain_iou: false,
1689 };
1690 let grid_off =
1691 evaluate_bbox(&dataset, &detections, params_off, ParityMode::Corrected).unwrap();
1692 params_off.retain_iou = true;
1693 let grid_on =
1694 evaluate_bbox(&dataset, &detections, params_off, ParityMode::Corrected).unwrap();
1695
1696 assert_eq!(grid_off.eval_imgs.len(), grid_on.eval_imgs.len());
1700 assert!(grid_off.retained_ious.is_none());
1701 assert!(grid_on.retained_ious.is_some());
1702 let retained = grid_on.retained_ious.as_ref().unwrap();
1703 assert_eq!(retained.len(), 1);
1704 assert!(retained.get(0, 0).is_some());
1705
1706 let p = crate::accumulate::AccumulateParams {
1710 iou_thresholds: iou_thresholds(),
1711 recall_thresholds: crate::parity::recall_thresholds(),
1712 max_dets: &max_dets,
1713 n_categories: grid_off.n_categories,
1714 n_area_ranges: grid_off.n_area_ranges,
1715 n_images: grid_off.n_images,
1716 };
1717 let acc_off =
1718 crate::accumulate::accumulate(&grid_off.eval_imgs, p, ParityMode::Corrected).unwrap();
1719 let acc_on =
1720 crate::accumulate::accumulate(&grid_on.eval_imgs, p, ParityMode::Corrected).unwrap();
1721 let sum_off =
1722 crate::summarize::summarize_detection(&acc_off, iou_thresholds(), &max_dets).unwrap();
1723 let sum_on =
1724 crate::summarize::summarize_detection(&acc_on, iou_thresholds(), &max_dets).unwrap();
1725 for (a, b) in sum_off.stats().iter().zip(sum_on.stats().iter()) {
1726 assert_eq!(a.to_bits(), b.to_bits(), "stat drift: off={a} on={b}");
1727 }
1728 }
1729
1730 #[test]
1731 fn build_tables_per_detection_without_detections_returns_invalid_config() {
1732 let (grid, dataset) = perfect_match_grid_two_images();
1735 let max_dets = [1usize, 10, 100];
1736 let p = crate::accumulate::AccumulateParams {
1737 iou_thresholds: crate::parity::iou_thresholds(),
1738 recall_thresholds: crate::parity::recall_thresholds(),
1739 max_dets: &max_dets,
1740 n_categories: grid.n_categories,
1741 n_area_ranges: grid.n_area_ranges,
1742 n_images: grid.n_images,
1743 };
1744 let accum =
1745 crate::accumulate::accumulate(&grid.eval_imgs, p, crate::parity::ParityMode::Corrected)
1746 .unwrap();
1747 let request = TablesRequest {
1748 per_detection: true,
1749 ..TablesRequest::default()
1750 };
1751 let err = build_tables(
1752 &grid,
1753 &accum,
1754 &dataset,
1755 None,
1756 None,
1757 crate::parity::iou_thresholds(),
1758 &max_dets,
1759 request,
1760 &TablesConfig::default(),
1761 )
1762 .unwrap_err();
1763 assert!(matches!(err, EvalError::InvalidConfig { .. }));
1764 }
1765
1766 #[test]
1767 fn build_tables_per_pair_without_retention_returns_invalid_config() {
1768 let (grid, dataset) = perfect_match_grid_two_images();
1769 let max_dets = [1usize, 10, 100];
1770 let p = crate::accumulate::AccumulateParams {
1771 iou_thresholds: crate::parity::iou_thresholds(),
1772 recall_thresholds: crate::parity::recall_thresholds(),
1773 max_dets: &max_dets,
1774 n_categories: grid.n_categories,
1775 n_area_ranges: grid.n_area_ranges,
1776 n_images: grid.n_images,
1777 };
1778 let accum =
1779 crate::accumulate::accumulate(&grid.eval_imgs, p, crate::parity::ParityMode::Corrected)
1780 .unwrap();
1781 let request = TablesRequest {
1782 per_pair: true,
1783 ..TablesRequest::default()
1784 };
1785 let err = build_tables(
1786 &grid,
1787 &accum,
1788 &dataset,
1789 None,
1790 None,
1791 crate::parity::iou_thresholds(),
1792 &max_dets,
1793 request,
1794 &TablesConfig::default(),
1795 )
1796 .unwrap_err();
1797 let msg = format!("{err}");
1798 assert!(matches!(err, EvalError::InvalidConfig { .. }));
1799 assert!(
1800 msg.contains("retain_iou"),
1801 "error must name retain_iou: {msg}"
1802 );
1803 }
1804
1805 #[test]
1806 fn build_per_pair_overflow_fires_inside_push_loop() {
1807 let mut store = RetainedIous::new();
1812 let iou = ndarray::Array2::<f64>::from_shape_vec((2, 2), vec![0.5, 0.6, 0.7, 0.8]).unwrap();
1813 store.insert(0, 0, iou);
1814 let grid = EvalGrid {
1815 eval_imgs: vec![None],
1816 eval_imgs_meta: vec![Some(Box::new(crate::evaluate::EvalImageMeta {
1817 image_id: 1,
1818 category_id: 1,
1819 area_rng: [0.0, f64::INFINITY],
1820 max_det: 100,
1821 dt_ids: vec![10, 20],
1822 gt_ids: vec![100, 200],
1823 dt_matches: ndarray::Array2::<i64>::zeros((10, 2)),
1824 gt_matches: ndarray::Array2::<i64>::zeros((10, 2)),
1825 }))],
1826 n_categories: 1,
1827 n_area_ranges: 1,
1828 n_images: 1,
1829 retained_ious: Some(store.clone()),
1830 };
1831 let cfg = TablesConfig {
1832 per_pair_iou_floor: 0.0,
1833 per_pair_max_rows: 2,
1834 ..TablesConfig::default()
1835 };
1836 let err = build_per_pair(&grid, &store, &cfg).unwrap_err();
1837 assert!(matches!(
1838 err,
1839 EvalError::PerPairOverflow {
1840 observed: 3,
1841 cap: 2
1842 }
1843 ));
1844 }
1845
1846 #[test]
1847 fn build_per_pair_filters_below_iou_floor_and_emits_above() {
1848 let mut store = RetainedIous::new();
1851 let iou = ndarray::Array2::<f64>::from_shape_vec((2, 2), vec![0.5, 0.6, 0.7, 0.8]).unwrap();
1852 store.insert(0, 0, iou);
1853 let grid = EvalGrid {
1854 eval_imgs: vec![None],
1855 eval_imgs_meta: vec![Some(Box::new(crate::evaluate::EvalImageMeta {
1856 image_id: 1,
1857 category_id: 1,
1858 area_rng: [0.0, f64::INFINITY],
1859 max_det: 100,
1860 dt_ids: vec![10, 20],
1861 gt_ids: vec![100, 200],
1862 dt_matches: ndarray::Array2::<i64>::zeros((10, 2)),
1863 gt_matches: ndarray::Array2::<i64>::zeros((10, 2)),
1864 }))],
1865 n_categories: 1,
1866 n_area_ranges: 1,
1867 n_images: 1,
1868 retained_ious: Some(store.clone()),
1869 };
1870 let cfg = TablesConfig {
1871 per_pair_iou_floor: 0.65,
1872 ..TablesConfig::default()
1873 };
1874 let table = build_per_pair(&grid, &store, &cfg).unwrap();
1875 assert_eq!(table.len(), 2);
1876 assert_eq!(table.iou.to_vec(), vec![0.7, 0.8]);
1877 assert_eq!(table.detection_id, vec![10, 20]);
1879 assert_eq!(table.ground_truth_id, vec![200, 200]);
1880 }
1881
1882 #[test]
1883 fn build_per_detection_marks_perfect_match_as_tp_and_unmatched_as_fp() {
1884 let (grid, dataset) = perfect_match_grid_two_images();
1885 let dt_inputs = vec![
1886 crate::dataset::DetectionInput {
1887 id: None,
1888 image_id: crate::dataset::ImageId(1),
1889 category_id: CategoryId(1),
1890 score: 0.9,
1891 bbox: crate::dataset::Bbox {
1892 x: 0.0,
1893 y: 0.0,
1894 w: 10.0,
1895 h: 10.0,
1896 },
1897 segmentation: None,
1898 keypoints: None,
1899 num_keypoints: None,
1900 },
1901 crate::dataset::DetectionInput {
1902 id: None,
1903 image_id: crate::dataset::ImageId(2),
1904 category_id: CategoryId(1),
1905 score: 0.8,
1906 bbox: crate::dataset::Bbox {
1907 x: 50.0,
1908 y: 50.0,
1909 w: 10.0,
1910 h: 10.0,
1911 },
1912 segmentation: None,
1913 keypoints: None,
1914 num_keypoints: None,
1915 },
1916 ];
1917 let detections = crate::dataset::CocoDetections::from_inputs(dt_inputs).unwrap();
1918 let _ = dataset; let table = build_per_detection(
1920 &grid,
1921 &detections,
1922 crate::parity::iou_thresholds(),
1923 None,
1924 &TablesConfig::default(),
1925 )
1926 .unwrap();
1927 assert_eq!(table.len(), 2);
1928 let statuses: Vec<MatchStatus> = table.match_status_at_50.clone();
1932 let tp_count = statuses
1934 .iter()
1935 .filter(|s| **s == MatchStatus::TruePositive)
1936 .count();
1937 let fp_count = statuses
1938 .iter()
1939 .filter(|s| **s == MatchStatus::FalsePositive)
1940 .count();
1941 assert_eq!(tp_count, 1);
1942 assert_eq!(fp_count, 1);
1943 assert!(table.best_iou.iter().all(Option::is_none));
1945 assert!(table.bbox.is_none());
1947 }
1948
1949 #[test]
1950 fn build_per_class_rejects_max_dets_missing_canonical_ladder() {
1951 let dataset = dataset_with_two_categories();
1952 let iou_thr = iou_thresholds();
1953 let max_dets = [10usize, 100]; let n_t = iou_thr.len();
1955 let accum = Accumulated {
1956 precision: Array5::<f64>::from_elem((n_t, 101, 2, 4, 2), 0.5),
1957 recall: Array4::<f64>::from_elem((n_t, 2, 4, 2), 0.5),
1958 scores: Array5::<f64>::from_elem((n_t, 101, 2, 4, 2), 0.5),
1959 };
1960 let support = PerClassSupport::zeros(2);
1961 let err = build_per_class(&accum, &dataset, iou_thr, &max_dets, &support).unwrap_err();
1962 assert!(matches!(err, EvalError::InvalidConfig { .. }));
1963 }
1964}