use ndarray::Array2;
use crate::core::Detections;
#[derive(Debug, Clone)]
pub struct ConfusionMatrix {
num_classes: usize,
iou_threshold: f32,
matrix: Array2<usize>,
}
impl ConfusionMatrix {
pub fn new(num_classes: usize, iou_threshold: f32) -> Self {
assert!(
num_classes > 0,
"a confusion matrix needs at least one class"
);
Self {
num_classes,
iou_threshold,
matrix: Array2::zeros((num_classes + 1, num_classes + 1)),
}
}
fn background(&self) -> usize {
self.num_classes
}
pub fn update(&mut self, predictions: &Detections, targets: &Detections) -> &mut Self {
let mut matched = vec![false; targets.len()];
let mut order: Vec<usize> = (0..predictions.len()).collect();
order.sort_by(|&a, &b| {
predictions.detections[b]
.confidence
.total_cmp(&predictions.detections[a].confidence)
});
for i in order {
let pred = &predictions.detections[i];
let mut best_iou = 0.0;
let mut best_j = None;
for (j, gt) in targets.detections.iter().enumerate() {
if matched[j] {
continue;
}
let iou = pred.iou(gt);
if iou > best_iou {
best_iou = iou;
best_j = Some(j);
}
}
let predicted_row = pred.class_id.min(self.num_classes - 1);
if best_iou >= self.iou_threshold {
if let Some(j) = best_j {
matched[j] = true;
let actual_col = targets.detections[j].class_id.min(self.num_classes - 1);
self.matrix[[predicted_row, actual_col]] += 1;
continue;
}
}
let background = self.background();
self.matrix[[predicted_row, background]] += 1;
}
let background = self.background();
for (j, gt) in targets.detections.iter().enumerate() {
if !matched[j] {
let actual_col = gt.class_id.min(self.num_classes - 1);
self.matrix[[background, actual_col]] += 1;
}
}
self
}
pub fn matrix(&self) -> &Array2<usize> {
&self.matrix
}
pub fn precision_per_class(&self) -> Vec<f32> {
(0..self.num_classes)
.map(|c| {
let row_sum: usize = self.matrix.row(c).sum();
if row_sum == 0 {
0.0
} else {
self.matrix[[c, c]] as f32 / row_sum as f32
}
})
.collect()
}
pub fn recall_per_class(&self) -> Vec<f32> {
(0..self.num_classes)
.map(|c| {
let col_sum: usize = self.matrix.column(c).sum();
if col_sum == 0 {
0.0
} else {
self.matrix[[c, c]] as f32 / col_sum as f32
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Detection;
fn det(bbox: [f32; 4], confidence: f32, class_id: usize) -> Detection {
Detection::new(bbox, confidence, class_id)
}
#[test]
#[should_panic]
fn zero_classes_panics() {
ConfusionMatrix::new(0, 0.5);
}
#[test]
fn correct_prediction_lands_on_the_diagonal() {
let mut cm = ConfusionMatrix::new(2, 0.5);
let predictions = Detections::new(vec![det([0.0, 0.0, 10.0, 10.0], 0.9, 1)]);
let targets = Detections::new(vec![det([0.0, 0.0, 10.0, 10.0], 1.0, 1)]);
cm.update(&predictions, &targets);
assert_eq!(cm.matrix()[[1, 1]], 1);
}
#[test]
fn wrong_class_lands_off_diagonal_not_in_background() {
let mut cm = ConfusionMatrix::new(2, 0.5);
let predictions = Detections::new(vec![det([0.0, 0.0, 10.0, 10.0], 0.9, 0)]);
let targets = Detections::new(vec![det([0.0, 0.0, 10.0, 10.0], 1.0, 1)]);
cm.update(&predictions, &targets);
assert_eq!(cm.matrix()[[0, 1]], 1);
assert_eq!(cm.matrix()[[0, 2]], 0);
assert_eq!(cm.matrix()[[2, 1]], 0);
}
#[test]
fn unmatched_prediction_is_a_background_column_entry() {
let mut cm = ConfusionMatrix::new(2, 0.5);
let predictions = Detections::new(vec![det([0.0, 0.0, 10.0, 10.0], 0.9, 0)]);
let targets = Detections::empty();
cm.update(&predictions, &targets);
assert_eq!(cm.matrix()[[0, 2]], 1);
}
#[test]
fn unmatched_target_is_a_background_row_entry() {
let mut cm = ConfusionMatrix::new(2, 0.5);
let predictions = Detections::empty();
let targets = Detections::new(vec![det([0.0, 0.0, 10.0, 10.0], 1.0, 0)]);
cm.update(&predictions, &targets);
assert_eq!(cm.matrix()[[2, 0]], 1);
}
#[test]
fn precision_and_recall_per_class_match_expected_ratios() {
let mut cm = ConfusionMatrix::new(1, 0.5);
let predictions = Detections::new(vec![
det([0.0, 0.0, 10.0, 10.0], 0.9, 0),
det([20.0, 20.0, 30.0, 30.0], 0.8, 0),
det([50.0, 50.0, 60.0, 60.0], 0.7, 0),
]);
let targets = Detections::new(vec![
det([0.0, 0.0, 10.0, 10.0], 1.0, 0),
det([20.0, 20.0, 30.0, 30.0], 1.0, 0),
det([100.0, 100.0, 110.0, 110.0], 1.0, 0),
]);
cm.update(&predictions, &targets);
assert!((cm.precision_per_class()[0] - 2.0 / 3.0).abs() < 1e-4);
assert!((cm.recall_per_class()[0] - 2.0 / 3.0).abs() < 1e-4);
}
}