use std::collections::BTreeMap;
use ndarray::{Array1, Array2};
use crate::metrics_core::{
bounding_box_diagonal, CANON_LEFT_HIP, CANON_RIGHT_HIP,
};
const VISIBILITY_THRESHOLD: f32 = 0.5;
const MIN_REFERENCE_EXTENT: f32 = 1e-6;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PckNormalization {
TorsoDiameter,
BoundingBoxDiagonal,
AbsolutePixels(f32),
}
impl PckNormalization {
pub fn label(&self) -> String {
match self {
PckNormalization::TorsoDiameter => "torso-diameter".to_string(),
PckNormalization::BoundingBoxDiagonal => "bbox-diagonal".to_string(),
PckNormalization::AbsolutePixels(t) => format!("absolute-px({t})"),
}
}
fn tolerance(&self, gt_kpts: &Array2<f32>, visibility: &Array1<f32>, k: u8) -> Option<f32> {
let n = gt_kpts.shape()[0].min(visibility.len());
match self {
PckNormalization::AbsolutePixels(threshold) => {
if *threshold > 0.0 {
Some(*threshold)
} else {
None
}
}
PckNormalization::TorsoDiameter => {
let d = torso_diameter(gt_kpts, visibility, n)?;
Some((k as f32 / 100.0) * d)
}
PckNormalization::BoundingBoxDiagonal => {
let d = bounding_box_diagonal(gt_kpts, visibility, n);
if d > MIN_REFERENCE_EXTENT {
Some((k as f32 / 100.0) * d)
} else {
None
}
}
}
}
}
fn torso_diameter(gt_kpts: &Array2<f32>, visibility: &Array1<f32>, n: usize) -> Option<f32> {
if CANON_LEFT_HIP < n
&& CANON_RIGHT_HIP < n
&& visibility[CANON_LEFT_HIP] >= VISIBILITY_THRESHOLD
&& visibility[CANON_RIGHT_HIP] >= VISIBILITY_THRESHOLD
{
let dx = gt_kpts[[CANON_LEFT_HIP, 0]] - gt_kpts[[CANON_RIGHT_HIP, 0]];
let dy = gt_kpts[[CANON_LEFT_HIP, 1]] - gt_kpts[[CANON_RIGHT_HIP, 1]];
let torso = (dx * dx + dy * dy).sqrt();
if torso > MIN_REFERENCE_EXTENT {
return Some(torso);
}
}
let diag = bounding_box_diagonal(gt_kpts, visibility, n);
if diag > MIN_REFERENCE_EXTENT {
Some(diag)
} else {
None
}
}
pub fn pck_at(
pred_kpts: &Array2<f32>,
gt_kpts: &Array2<f32>,
visibility: &Array1<f32>,
k: u8,
normalization: PckNormalization,
) -> (usize, usize, f32) {
let n = pred_kpts.shape()[0]
.min(gt_kpts.shape()[0])
.min(visibility.len());
let tol = match normalization.tolerance(gt_kpts, visibility, k) {
Some(t) => t,
None => return (0, 0, 0.0),
};
let mut correct = 0usize;
let mut total = 0usize;
for j in 0..n {
if visibility[j] < VISIBILITY_THRESHOLD {
continue;
}
total += 1;
let dx = pred_kpts[[j, 0]] - gt_kpts[[j, 0]];
let dy = pred_kpts[[j, 1]] - gt_kpts[[j, 1]];
let dist = (dx * dx + dy * dy).sqrt();
if dist <= tol {
correct += 1;
}
}
let pck = if total > 0 {
correct as f32 / total as f32
} else {
0.0
};
(correct, total, pck)
}
pub fn mpjpe(pred: &Array2<f32>, gt: &Array2<f32>, visibility: &Array1<f32>) -> f32 {
let n = pred.shape()[0].min(gt.shape()[0]).min(visibility.len());
let d = pred.shape()[1].min(gt.shape()[1]);
let mut sum = 0.0f32;
let mut count = 0usize;
for j in 0..n {
if visibility[j] < VISIBILITY_THRESHOLD {
continue;
}
let mut sq = 0.0f32;
for c in 0..d {
let diff = pred[[j, c]] - gt[[j, c]];
sq += diff * diff;
}
sum += sq.sqrt();
count += 1;
}
if count > 0 {
sum / count as f32
} else {
0.0
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PoseAccuracy {
pub pck_at: BTreeMap<u8, f32>,
pub mpjpe: f32,
pub normalization: PckNormalization,
pub n_keypoints: usize,
pub n_frames: usize,
}
impl PoseAccuracy {
pub fn pck(&self, k: u8) -> Option<f32> {
self.pck_at.get(&k).copied()
}
pub fn summary(&self) -> String {
let pcks: Vec<String> = self
.pck_at
.iter()
.map(|(k, v)| format!("PCK@{k}={v:.3}"))
.collect();
format!(
"{} ({}, {}kp, {} frames) MPJPE={:.4}",
pcks.join(" "),
self.normalization.label(),
self.n_keypoints,
self.n_frames,
self.mpjpe
)
}
}
#[derive(Debug, Clone)]
pub struct PoseFrame {
pub pred: Array2<f32>,
pub gt: Array2<f32>,
pub visibility: Array1<f32>,
}
pub fn accuracy_report(
frames: &[PoseFrame],
ks: &[u8],
normalization: PckNormalization,
) -> PoseAccuracy {
let n_keypoints = frames.first().map(|f| f.gt.shape()[0]).unwrap_or(0);
let mut pck_acc: BTreeMap<u8, (usize, usize)> = ks.iter().map(|&k| (k, (0, 0))).collect();
let mut mpjpe_sum = 0.0f32;
let mut mpjpe_count = 0usize;
for frame in frames {
for &k in ks {
let (c, t, _) = pck_at(&frame.pred, &frame.gt, &frame.visibility, k, normalization);
let entry = pck_acc.entry(k).or_insert((0, 0));
entry.0 += c;
entry.1 += t;
}
let n = frame.pred.shape()[0].min(frame.gt.shape()[0]).min(frame.visibility.len());
let d = frame.pred.shape()[1].min(frame.gt.shape()[1]);
for j in 0..n {
if frame.visibility[j] < VISIBILITY_THRESHOLD {
continue;
}
let mut sq = 0.0f32;
for c in 0..d {
let diff = frame.pred[[j, c]] - frame.gt[[j, c]];
sq += diff * diff;
}
mpjpe_sum += sq.sqrt();
mpjpe_count += 1;
}
}
let pck_at: BTreeMap<u8, f32> = pck_acc
.into_iter()
.map(|(k, (c, t))| {
let v = if t > 0 { c as f32 / t as f32 } else { 0.0 };
(k, v)
})
.collect();
let mpjpe = if mpjpe_count > 0 {
mpjpe_sum / mpjpe_count as f32
} else {
0.0
};
PoseAccuracy {
pck_at,
mpjpe,
normalization,
n_keypoints,
n_frames: frames.len(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pose17(joints: &[(usize, f32, f32)]) -> Array2<f32> {
let mut a = Array2::<f32>::zeros((17, 2));
for &(j, x, y) in joints {
a[[j, 0]] = x;
a[[j, 1]] = y;
}
a
}
fn vis17(visible: &[usize]) -> Array1<f32> {
let mut v = Array1::<f32>::zeros(17);
for &j in visible {
v[j] = 2.0;
}
v
}
#[test]
fn accuracy_consts_unchanged() {
assert_eq!(VISIBILITY_THRESHOLD, 0.5_f32);
assert_eq!(MIN_REFERENCE_EXTENT, 1e-6_f32);
}
#[test]
fn perfect_prediction_pck_one_mpjpe_zero() {
let gt = pose17(&[
(5, 0.35, 0.35),
(CANON_LEFT_HIP, 0.40, 0.50),
(CANON_RIGHT_HIP, 0.60, 0.50),
]);
let vis = vis17(&[5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
for norm in [
PckNormalization::TorsoDiameter,
PckNormalization::BoundingBoxDiagonal,
PckNormalization::AbsolutePixels(0.01),
] {
let (c, t, pck) = pck_at(>, >, &vis, 20, norm);
assert_eq!((c, t), (3, 3), "{norm:?}");
assert!((pck - 1.0).abs() < 1e-6, "{norm:?} perfect PCK must be 1.0");
}
assert_eq!(mpjpe(>, >, &vis), 0.0);
}
#[test]
fn all_just_outside_threshold_pck_zero() {
let gt = pose17(&[
(5, 0.50, 0.50),
(CANON_LEFT_HIP, 0.40, 0.50),
(CANON_RIGHT_HIP, 0.60, 0.50),
]);
let pred = pose17(&[
(5, 0.55, 0.50),
(CANON_LEFT_HIP, 0.45, 0.50),
(CANON_RIGHT_HIP, 0.65, 0.50),
]);
let vis = vis17(&[5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
let (c, t, pck) = pck_at(&pred, >, &vis, 20, PckNormalization::TorsoDiameter);
assert_eq!(t, 3);
assert_eq!(c, 0, "all errors 0.05 > τ 0.04 ⇒ none correct");
assert_eq!(pck, 0.0);
}
#[test]
fn half_in_half_out_pck_half() {
let gt = pose17(&[
(0, 0.50, 0.20),
(5, 0.50, 0.50),
(CANON_LEFT_HIP, 0.40, 0.50),
(CANON_RIGHT_HIP, 0.60, 0.50),
]);
let pred = pose17(&[
(0, 0.50, 0.20), (5, 0.55, 0.50), (CANON_LEFT_HIP, 0.40, 0.50), (CANON_RIGHT_HIP, 0.65, 0.50), ]);
let vis = vis17(&[0, 5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
let (c, t, pck) = pck_at(&pred, >, &vis, 20, PckNormalization::TorsoDiameter);
assert_eq!((c, t), (2, 4));
assert!((pck - 0.5).abs() < 1e-6, "expected 0.5, got {pck}");
}
#[test]
fn three_normalizations_give_different_pck_on_identical_input() {
let gt = pose17(&[
(0, 0.50, 0.10), (5, 0.50, 0.30), (CANON_LEFT_HIP, 0.40, 0.90),
(CANON_RIGHT_HIP, 0.60, 0.90),
]);
let pred = pose17(&[
(0, 0.56, 0.10), (5, 0.60, 0.30), (CANON_LEFT_HIP, 0.40, 0.90), (CANON_RIGHT_HIP, 0.60, 0.90), ]);
let vis = vis17(&[0, 5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
let (_, _, torso) = pck_at(&pred, >, &vis, 20, PckNormalization::TorsoDiameter);
let (_, _, bbox) = pck_at(&pred, >, &vis, 20, PckNormalization::BoundingBoxDiagonal);
let (_, _, abs) = pck_at(&pred, >, &vis, 20, PckNormalization::AbsolutePixels(0.08));
assert!((torso - 0.5).abs() < 1e-6, "torso PCK expected 0.5, got {torso}");
assert!((bbox - 1.0).abs() < 1e-6, "bbox PCK expected 1.0, got {bbox}");
assert!((abs - 0.75).abs() < 1e-6, "abs(0.08) PCK expected 0.75, got {abs}");
assert!(torso != bbox && bbox != abs && torso != abs,
"normalizations must give distinct PCK: torso={torso}, bbox={bbox}, abs={abs}");
}
#[test]
fn absolute_pixels_ignores_threshold_percentage() {
let gt = pose17(&[(5, 0.50, 0.50), (CANON_LEFT_HIP, 0.40, 0.50), (CANON_RIGHT_HIP, 0.60, 0.50)]);
let pred = pose17(&[(5, 0.53, 0.50), (CANON_LEFT_HIP, 0.40, 0.50), (CANON_RIGHT_HIP, 0.60, 0.50)]);
let vis = vis17(&[5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
let (_, _, p5) = pck_at(&pred, >, &vis, 5, PckNormalization::AbsolutePixels(0.05));
let (_, _, p99) = pck_at(&pred, >, &vis, 99, PckNormalization::AbsolutePixels(0.05));
assert_eq!(p5, p99, "AbsolutePixels must ignore the k percentage");
assert!((p5 - 1.0).abs() < 1e-6, "all three within 0.05, got {p5}");
}
#[test]
fn mpjpe_hand_computed_2d() {
let gt = Array2::from_shape_vec((2, 2), vec![0.0, 0.0, 1.0, 1.0]).unwrap();
let pred = Array2::from_shape_vec((2, 2), vec![3.0, 4.0, 1.0, 1.0]).unwrap();
let vis = Array1::from(vec![2.0, 2.0]);
assert!((mpjpe(&pred, >, &vis) - 2.5).abs() < 1e-6);
}
#[test]
fn mpjpe_hand_computed_3d() {
let gt = Array2::from_shape_vec((1, 3), vec![0.0, 0.0, 0.0]).unwrap();
let pred = Array2::from_shape_vec((1, 3), vec![1.0, 2.0, 2.0]).unwrap();
let vis = Array1::from(vec![2.0]);
assert!((mpjpe(&pred, >, &vis) - 3.0).abs() < 1e-6);
}
#[test]
fn mpjpe_excludes_invisible_joints() {
let gt = Array2::from_shape_vec((2, 2), vec![0.0, 0.0, 0.0, 0.0]).unwrap();
let pred = Array2::from_shape_vec((2, 2), vec![3.0, 4.0, 100.0, 0.0]).unwrap();
let vis = Array1::from(vec![2.0, 0.0]);
assert!((mpjpe(&pred, >, &vis) - 5.0).abs() < 1e-6);
}
#[test]
fn zero_torso_is_unscoreable_not_perfect() {
let gt = pose17(&[(CANON_LEFT_HIP, 0.5, 0.5), (CANON_RIGHT_HIP, 0.5, 0.5)]);
let vis = vis17(&[CANON_LEFT_HIP, CANON_RIGHT_HIP]);
assert_eq!(pck_at(>, >, &vis, 20, PckNormalization::TorsoDiameter), (0, 0, 0.0));
assert_eq!(pck_at(>, >, &vis, 20, PckNormalization::BoundingBoxDiagonal), (0, 0, 0.0));
}
#[test]
fn no_visible_keypoints_scores_zero() {
let gt = pose17(&[(CANON_LEFT_HIP, 0.4, 0.5), (CANON_RIGHT_HIP, 0.6, 0.5)]);
let vis = vis17(&[]); let (c, t, pck) = pck_at(>, >, &vis, 20, PckNormalization::TorsoDiameter);
assert_eq!((c, t, pck), (0, 0, 0.0));
assert_eq!(mpjpe(>, >, &vis), 0.0);
}
#[test]
fn nan_coords_do_not_panic_and_count_wrong() {
let gt = pose17(&[(5, 0.5, 0.5), (CANON_LEFT_HIP, 0.4, 0.5), (CANON_RIGHT_HIP, 0.6, 0.5)]);
let mut pred = gt.clone();
pred[[5, 0]] = f32::NAN; let vis = vis17(&[5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
let (c, t, pck) = pck_at(&pred, >, &vis, 20, PckNormalization::TorsoDiameter);
assert_eq!(t, 3);
assert_eq!(c, 2, "NaN joint must count as wrong, hips correct ⇒ 2/3");
assert!((pck - 2.0 / 3.0).abs() < 1e-6);
assert!(mpjpe(&pred, >, &vis).is_nan());
}
#[test]
fn accuracy_report_micro_averages_and_carries_definition() {
let gt = pose17(&[(CANON_LEFT_HIP, 0.40, 0.50), (CANON_RIGHT_HIP, 0.60, 0.50)]);
let vis = vis17(&[CANON_LEFT_HIP, CANON_RIGHT_HIP]);
let frame_a = PoseFrame { pred: gt.clone(), gt: gt.clone(), visibility: vis.clone() };
let pred_b = pose17(&[(CANON_LEFT_HIP, 0.45, 0.50), (CANON_RIGHT_HIP, 0.65, 0.50)]);
let frame_b = PoseFrame { pred: pred_b, gt: gt.clone(), visibility: vis.clone() };
let report = accuracy_report(
&[frame_a, frame_b],
&[20, 50],
PckNormalization::TorsoDiameter,
);
assert_eq!(report.n_frames, 2);
assert_eq!(report.n_keypoints, 17);
assert_eq!(report.normalization, PckNormalization::TorsoDiameter);
assert!((report.pck(20).unwrap() - 0.5).abs() < 1e-6);
assert!((report.pck(50).unwrap() - 1.0).abs() < 1e-6);
assert!(report.summary().contains("torso-diameter"));
}
#[test]
fn accuracy_report_empty_is_zero_not_nan() {
let report = accuracy_report(&[], &[20], PckNormalization::BoundingBoxDiagonal);
assert_eq!(report.n_frames, 0);
assert_eq!(report.pck(20), Some(0.0));
assert_eq!(report.mpjpe, 0.0);
assert!(!report.mpjpe.is_nan());
}
#[test]
fn bbox_norm_scores_at_least_torso_norm() {
let gt = pose17(&[
(0, 0.50, 0.10),
(5, 0.50, 0.40),
(CANON_LEFT_HIP, 0.40, 0.90),
(CANON_RIGHT_HIP, 0.60, 0.90),
]);
let pred = pose17(&[
(0, 0.55, 0.10),
(5, 0.58, 0.40),
(CANON_LEFT_HIP, 0.42, 0.90),
(CANON_RIGHT_HIP, 0.62, 0.90),
]);
let vis = vis17(&[0, 5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
let frame = PoseFrame { pred, gt, visibility: vis };
let torso = accuracy_report(std::slice::from_ref(&frame), &[20], PckNormalization::TorsoDiameter);
let bbox = accuracy_report(std::slice::from_ref(&frame), &[20], PckNormalization::BoundingBoxDiagonal);
assert!(
bbox.pck(20).unwrap() >= torso.pck(20).unwrap(),
"bbox-norm (looser) must be >= torso-norm: bbox={:?} torso={:?}",
bbox.pck(20), torso.pck(20)
);
}
}