Skip to main content

wifi_densepose_train/
accuracy.rs

1//! Metric-locked pose-accuracy harness (ADR-155 §Tier-1.2; needs ADR slot 173).
2//!
3//! # Why this module exists
4//!
5//! Three PCK\@20 numbers float around this project and **cannot be lined up**
6//! because each silently uses a *different* PCK definition:
7//!
8//! | Number | Source | PCK normalization |
9//! |--------|--------|-------------------|
10//! | 96.09 %  | WiFlow-STD reproduction | image / bounding-box normalized (looser) |
11//! | 81.63 %  | AetherArena MM-Fi (ADR-150) | torso-diameter (standard MM-Fi / GraphPose-Fi) |
12//! | 61.1 %   | GraphPose-Fi (preprint) | torso-diameter, 3D, mm-scale (harder) |
13//!
14//! The project was burned **twice** by metric ambiguity (a now-retracted "92.9 %
15//! PCK\@20" used *absolute* pixel thresholds, not torso normalization). The fix
16//! is to make the normalizer **explicit, selectable, and carried with every
17//! reported number** so an unlabeled PCK figure is structurally impossible.
18//!
19//! [`metrics_core`](crate::metrics_core) already pins the *canonical*
20//! torso-normalized PCK ([`pck_canonical`](crate::metrics_core::pck_canonical)).
21//! This module generalizes it to a [`PckNormalization`] enum covering all three
22//! conventions the SOTA brief names, adds [`mpjpe`] (mm), and bundles results
23//! into a self-describing [`PoseAccuracy`] struct. It **reuses** the
24//! `metrics_core` primitives (hip distance, bounding-box diagonal) — there is
25//! still exactly one implementation of each geometric reference.
26//!
27//! # This is measurement infrastructure, not an accuracy claim
28//!
29//! Nothing here asserts any project model is good. The unit tests prove the
30//! *harness* is arithmetically correct against hand-computed fixtures (no GPU,
31//! no datasets), including the key demonstration that the **same predictions
32//! score different PCK under the three normalizations** — proof the ambiguity is
33//! real and the definitions are genuinely distinct.
34//!
35//! # Literature
36//!
37//! - Torso-diameter PCK is the MM-Fi / GraphPose-Fi convention (Yang et al.,
38//!   *GraphPose-Fi*, arXiv:2511.19105): a keypoint is correct iff its error is
39//!   within `k · d_torso`, with `d_torso` the hip↔hip (or shoulder↔hip) span.
40//! - Bounding-box / image-normalized PCK is the WiFlow-STD-style looser
41//!   convention (arXiv:2602.08661) — normalize by the GT pose bbox diagonal.
42//! - MPJPE (mean per-joint position error, mm) is reported by GraphPose-Fi and
43//!   Person-in-WiFi-3D (Yan et al., CVPR 2024).
44
45use std::collections::BTreeMap;
46
47use ndarray::{Array1, Array2};
48
49use crate::metrics_core::{
50    bounding_box_diagonal, CANON_LEFT_HIP, CANON_RIGHT_HIP,
51};
52
53/// Visibility cutoff: a keypoint counts as *visible* iff `visibility[j] >= 0.5`
54/// (COCO convention; matches [`crate::metrics_core`]).
55const VISIBILITY_THRESHOLD: f32 = 0.5;
56
57/// Minimum positive normalizer extent. Below this the reference scale is
58/// considered degenerate (zero torso, collapsed bbox) and the frame is reported
59/// unscoreable rather than dividing by ≈0.
60const MIN_REFERENCE_EXTENT: f32 = 1e-6;
61
62// ===========================================================================
63// PCK normalization — the explicit, selectable definition
64// ===========================================================================
65
66/// The PCK normalization basis — **the single knob that made three project
67/// numbers non-comparable**, now explicit and carried with every result.
68///
69/// A keypoint `j` (with `visibility[j] >= 0.5`) is *correct* iff
70/// `‖pred_j − gt_j‖₂ ≤ τ`, where the **distance tolerance `τ`** is derived from
71/// the chosen normalization and the PCK threshold `k` (given as a percentage,
72/// e.g. `20` for PCK\@20):
73///
74/// | Variant | `τ` (tolerance in coordinate units) |
75/// |---------|--------------------------------------|
76/// | [`TorsoDiameter`](Self::TorsoDiameter)        | `(k/100) · d_torso` |
77/// | [`BoundingBoxDiagonal`](Self::BoundingBoxDiagonal) | `(k/100) · d_bbox`  |
78/// | [`AbsolutePixels`](Self::AbsolutePixels)      | `threshold` (k ignored) |
79///
80/// `d_torso` is the hip↔hip span (COCO joints 11↔12), falling back to the bbox
81/// diagonal when both hips are not visible — identical to
82/// [`crate::metrics_core::canonical_torso_size`]. `d_bbox` is the diagonal of
83/// the axis-aligned bounding box of all visible GT keypoints.
84///
85/// These yield **different** PCK on the *same* predictions whenever
86/// `d_torso ≠ d_bbox` (always true for a real pose: the bbox is larger than the
87/// hip span), which is exactly why the 96 / 81.6 / 61 numbers cannot be lined
88/// up without declaring this enum.
89#[derive(Debug, Clone, Copy, PartialEq)]
90pub enum PckNormalization {
91    /// **Torso-diameter** (hip↔hip span). The standard MM-Fi / GraphPose-Fi
92    /// convention and the *stricter* of the two relative normalizers. This is
93    /// the canonical default ([`crate::metrics_core::pck_canonical`]).
94    TorsoDiameter,
95    /// **Bounding-box diagonal** (a.k.a. image-normalized). The looser
96    /// WiFlow-STD-style convention: normalize by the GT pose bbox diagonal,
97    /// which is larger than the torso span ⇒ a more forgiving threshold ⇒ a
98    /// higher PCK on identical predictions.
99    BoundingBoxDiagonal,
100    /// **Absolute pixel/coordinate threshold** — no pose-relative
101    /// normalization. The PCK `k` percentage is ignored; the held `threshold`
102    /// is the raw distance tolerance directly. Included so historical
103    /// retracted-style numbers are reproducible, and **clearly labeled as
104    /// non-comparable** to the relative variants (it does not scale with body
105    /// size or camera distance).
106    AbsolutePixels(f32),
107}
108
109impl PckNormalization {
110    /// Human-readable, *self-documenting* label for a reported number — so a
111    /// `PoseAccuracy` printed anywhere always carries its definition.
112    pub fn label(&self) -> String {
113        match self {
114            PckNormalization::TorsoDiameter => "torso-diameter".to_string(),
115            PckNormalization::BoundingBoxDiagonal => "bbox-diagonal".to_string(),
116            PckNormalization::AbsolutePixels(t) => format!("absolute-px({t})"),
117        }
118    }
119
120    /// Compute the per-frame distance tolerance `τ` for PCK threshold `k`
121    /// (percentage). Returns `None` when the (relative) normalizer is degenerate
122    /// — the frame cannot be scored.
123    ///
124    /// `gt_kpts` is `[n, 2]` (or `[n, ≥2]`, only x/y used); `visibility` is `[n]`.
125    fn tolerance(&self, gt_kpts: &Array2<f32>, visibility: &Array1<f32>, k: u8) -> Option<f32> {
126        let n = gt_kpts.shape()[0].min(visibility.len());
127        match self {
128            PckNormalization::AbsolutePixels(threshold) => {
129                // Raw tolerance, independent of pose scale and of `k`.
130                if *threshold > 0.0 {
131                    Some(*threshold)
132                } else {
133                    None
134                }
135            }
136            PckNormalization::TorsoDiameter => {
137                let d = torso_diameter(gt_kpts, visibility, n)?;
138                Some((k as f32 / 100.0) * d)
139            }
140            PckNormalization::BoundingBoxDiagonal => {
141                let d = bounding_box_diagonal(gt_kpts, visibility, n);
142                if d > MIN_REFERENCE_EXTENT {
143                    Some((k as f32 / 100.0) * d)
144                } else {
145                    None
146                }
147            }
148        }
149    }
150}
151
152/// Hip↔hip torso diameter with a bbox-diagonal fallback — the relative
153/// normalizer shared by `TorsoDiameter` PCK and
154/// [`crate::metrics_core::canonical_torso_size`]. Returns `None` when no
155/// positive-extent reference exists.
156fn torso_diameter(gt_kpts: &Array2<f32>, visibility: &Array1<f32>, n: usize) -> Option<f32> {
157    if CANON_LEFT_HIP < n
158        && CANON_RIGHT_HIP < n
159        && visibility[CANON_LEFT_HIP] >= VISIBILITY_THRESHOLD
160        && visibility[CANON_RIGHT_HIP] >= VISIBILITY_THRESHOLD
161    {
162        let dx = gt_kpts[[CANON_LEFT_HIP, 0]] - gt_kpts[[CANON_RIGHT_HIP, 0]];
163        let dy = gt_kpts[[CANON_LEFT_HIP, 1]] - gt_kpts[[CANON_RIGHT_HIP, 1]];
164        let torso = (dx * dx + dy * dy).sqrt();
165        if torso > MIN_REFERENCE_EXTENT {
166            return Some(torso);
167        }
168    }
169    let diag = bounding_box_diagonal(gt_kpts, visibility, n);
170    if diag > MIN_REFERENCE_EXTENT {
171        Some(diag)
172    } else {
173        None
174    }
175}
176
177// ===========================================================================
178// Single-frame PCK / MPJPE
179// ===========================================================================
180
181/// Per-frame **PCK\@`k`** under the selected `normalization`.
182///
183/// A keypoint `j` with `visibility[j] >= 0.5` is correct iff
184/// `‖pred_j − gt_j‖₂ ≤ τ`, with `τ` from
185/// [`PckNormalization::tolerance`]. Only x/y are used (2D PCK is the standard
186/// keypoint-PCK definition; pass 2-column arrays).
187///
188/// # Returns
189/// `(correct, total, pck)` with `pck ∈ [0,1]`. **`(0, 0, 0.0)`** when no
190/// keypoint is visible, or (for the relative normalizers) the reference scale is
191/// degenerate — a frame with no measurable evidence scores 0, never 1.
192/// NaN-valued coordinates make a keypoint *incorrect* (the `<=` comparison is
193/// false for NaN) rather than panicking.
194pub fn pck_at(
195    pred_kpts: &Array2<f32>,
196    gt_kpts: &Array2<f32>,
197    visibility: &Array1<f32>,
198    k: u8,
199    normalization: PckNormalization,
200) -> (usize, usize, f32) {
201    let n = pred_kpts.shape()[0]
202        .min(gt_kpts.shape()[0])
203        .min(visibility.len());
204    let tol = match normalization.tolerance(gt_kpts, visibility, k) {
205        Some(t) => t,
206        None => return (0, 0, 0.0),
207    };
208
209    let mut correct = 0usize;
210    let mut total = 0usize;
211    for j in 0..n {
212        if visibility[j] < VISIBILITY_THRESHOLD {
213            continue;
214        }
215        total += 1;
216        let dx = pred_kpts[[j, 0]] - gt_kpts[[j, 0]];
217        let dy = pred_kpts[[j, 1]] - gt_kpts[[j, 1]];
218        let dist = (dx * dx + dy * dy).sqrt();
219        // NaN-safe: `NaN <= tol` is false, so a NaN coordinate counts as wrong.
220        if dist <= tol {
221            correct += 1;
222        }
223    }
224    let pck = if total > 0 {
225        correct as f32 / total as f32
226    } else {
227        0.0
228    };
229    (correct, total, pck)
230}
231
232/// Per-frame **MPJPE** (mean per-joint position error) over visible keypoints,
233/// in the coordinate units of the inputs (report as mm when inputs are mm).
234///
235/// `pred`/`gt` are `[n, D]` with `D ∈ {2, 3}` (2D or 3D pose); all `D` columns
236/// are used. Joints with `visibility[j] < 0.5` are excluded.
237///
238/// Returns `0.0` when no keypoint is visible (no evidence). A NaN coordinate
239/// propagates into the returned mean (callers filter NaN frames upstream); it
240/// does not panic.
241pub fn mpjpe(pred: &Array2<f32>, gt: &Array2<f32>, visibility: &Array1<f32>) -> f32 {
242    let n = pred.shape()[0].min(gt.shape()[0]).min(visibility.len());
243    let d = pred.shape()[1].min(gt.shape()[1]);
244    let mut sum = 0.0f32;
245    let mut count = 0usize;
246    for j in 0..n {
247        if visibility[j] < VISIBILITY_THRESHOLD {
248            continue;
249        }
250        let mut sq = 0.0f32;
251        for c in 0..d {
252            let diff = pred[[j, c]] - gt[[j, c]];
253            sq += diff * diff;
254        }
255        sum += sq.sqrt();
256        count += 1;
257    }
258    if count > 0 {
259        sum / count as f32
260    } else {
261        0.0
262    }
263}
264
265// ===========================================================================
266// Self-describing result struct + batch report
267// ===========================================================================
268
269/// A pose-accuracy result that **always carries the definition it was computed
270/// under** — making an unlabeled PCK number structurally impossible.
271///
272/// Built by [`accuracy_report`] over a set of frames. `pck_at` maps each
273/// requested threshold `k` (percentage, e.g. `20`) to its PCK in `[0,1]`. The
274/// `normalization` field records *which* PCK definition produced those numbers,
275/// so two `PoseAccuracy` values can only be compared when their `normalization`
276/// matches (the comparability check the project lacked).
277#[derive(Debug, Clone, PartialEq)]
278pub struct PoseAccuracy {
279    /// PCK\@k for each requested threshold percentage `k`, in `[0,1]`.
280    pub pck_at: BTreeMap<u8, f32>,
281    /// Mean per-joint position error in coordinate units (mm for mm inputs).
282    pub mpjpe: f32,
283    /// The normalization basis under which `pck_at` was computed — the label a
284    /// reported number must always carry.
285    pub normalization: PckNormalization,
286    /// Number of keypoints per frame (the pose convention, e.g. 17 for COCO).
287    pub n_keypoints: usize,
288    /// Number of frames aggregated into this result.
289    pub n_frames: usize,
290}
291
292impl PoseAccuracy {
293    /// Convenience accessor for a single threshold, returning `None` when that
294    /// `k` was not requested.
295    pub fn pck(&self, k: u8) -> Option<f32> {
296        self.pck_at.get(&k).copied()
297    }
298
299    /// A one-line, self-documenting summary suitable for logs / RESULTS.md, e.g.
300    /// `PCK@20=0.750 (torso-diameter, 17kp, 1 frames) MPJPE=0.030`.
301    pub fn summary(&self) -> String {
302        let pcks: Vec<String> = self
303            .pck_at
304            .iter()
305            .map(|(k, v)| format!("PCK@{k}={v:.3}"))
306            .collect();
307        format!(
308            "{} ({}, {}kp, {} frames) MPJPE={:.4}",
309            pcks.join(" "),
310            self.normalization.label(),
311            self.n_keypoints,
312            self.n_frames,
313            self.mpjpe
314        )
315    }
316}
317
318/// One frame's prediction + ground truth + visibility for batch scoring.
319///
320/// All three arrays share row count `n_keypoints`; `pred`/`gt` are `[n, D]`
321/// (`D ∈ {2,3}`), `visibility` is `[n]`.
322#[derive(Debug, Clone)]
323pub struct PoseFrame {
324    /// Predicted keypoints `[n, D]`.
325    pub pred: Array2<f32>,
326    /// Ground-truth keypoints `[n, D]`.
327    pub gt: Array2<f32>,
328    /// Per-keypoint visibility `[n]` (`>= 0.5` ⇒ visible).
329    pub visibility: Array1<f32>,
330}
331
332/// Aggregate [`PoseAccuracy`] over a batch of frames under **one** explicit
333/// `normalization`, for the requested PCK thresholds `ks` (percentages).
334///
335/// PCK is micro-averaged over keypoints (sum of correct ÷ sum of visible across
336/// all frames — the standard keypoint-PCK aggregation), so frames with more
337/// visible joints contribute proportionally. MPJPE is micro-averaged over
338/// visible joints likewise. Unscoreable frames (no visible joints, degenerate
339/// relative normalizer) contribute `(0, 0)` and so are excluded from the
340/// denominator rather than scored as perfect.
341///
342/// An **empty** `frames` slice yields all-zero PCK and `0.0` MPJPE — never a
343/// panic or NaN.
344pub fn accuracy_report(
345    frames: &[PoseFrame],
346    ks: &[u8],
347    normalization: PckNormalization,
348) -> PoseAccuracy {
349    let n_keypoints = frames.first().map(|f| f.gt.shape()[0]).unwrap_or(0);
350
351    // PCK: per-threshold (correct, total) accumulators across frames.
352    let mut pck_acc: BTreeMap<u8, (usize, usize)> = ks.iter().map(|&k| (k, (0, 0))).collect();
353    // MPJPE: sum of per-joint distances and visible-joint count.
354    let mut mpjpe_sum = 0.0f32;
355    let mut mpjpe_count = 0usize;
356
357    for frame in frames {
358        for &k in ks {
359            let (c, t, _) = pck_at(&frame.pred, &frame.gt, &frame.visibility, k, normalization);
360            let entry = pck_acc.entry(k).or_insert((0, 0));
361            entry.0 += c;
362            entry.1 += t;
363        }
364        // Per-frame MPJPE re-derived as a (sum, count) contribution so the
365        // batch value is a true micro-average over joints.
366        let n = frame.pred.shape()[0].min(frame.gt.shape()[0]).min(frame.visibility.len());
367        let d = frame.pred.shape()[1].min(frame.gt.shape()[1]);
368        for j in 0..n {
369            if frame.visibility[j] < VISIBILITY_THRESHOLD {
370                continue;
371            }
372            let mut sq = 0.0f32;
373            for c in 0..d {
374                let diff = frame.pred[[j, c]] - frame.gt[[j, c]];
375                sq += diff * diff;
376            }
377            mpjpe_sum += sq.sqrt();
378            mpjpe_count += 1;
379        }
380    }
381
382    let pck_at: BTreeMap<u8, f32> = pck_acc
383        .into_iter()
384        .map(|(k, (c, t))| {
385            let v = if t > 0 { c as f32 / t as f32 } else { 0.0 };
386            (k, v)
387        })
388        .collect();
389
390    let mpjpe = if mpjpe_count > 0 {
391        mpjpe_sum / mpjpe_count as f32
392    } else {
393        0.0
394    };
395
396    PoseAccuracy {
397        pck_at,
398        mpjpe,
399        normalization,
400        n_keypoints,
401        n_frames: frames.len(),
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    /// Build a 17-joint `[17, 2]` pose from `(joint, x, y)` triples.
410    fn pose17(joints: &[(usize, f32, f32)]) -> Array2<f32> {
411        let mut a = Array2::<f32>::zeros((17, 2));
412        for &(j, x, y) in joints {
413            a[[j, 0]] = x;
414            a[[j, 1]] = y;
415        }
416        a
417    }
418
419    fn vis17(visible: &[usize]) -> Array1<f32> {
420        let mut v = Array1::<f32>::zeros(17);
421        for &j in visible {
422            v[j] = 2.0;
423        }
424        v
425    }
426
427    // -------- consts pinned (no silent metric drift) --------
428    #[test]
429    fn accuracy_consts_unchanged() {
430        assert_eq!(VISIBILITY_THRESHOLD, 0.5_f32);
431        assert_eq!(MIN_REFERENCE_EXTENT, 1e-6_f32);
432    }
433
434    // -------- perfect prediction ⇒ PCK = 1.0, MPJPE = 0 --------
435    #[test]
436    fn perfect_prediction_pck_one_mpjpe_zero() {
437        let gt = pose17(&[
438            (5, 0.35, 0.35),
439            (CANON_LEFT_HIP, 0.40, 0.50),
440            (CANON_RIGHT_HIP, 0.60, 0.50),
441        ]);
442        let vis = vis17(&[5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
443        for norm in [
444            PckNormalization::TorsoDiameter,
445            PckNormalization::BoundingBoxDiagonal,
446            PckNormalization::AbsolutePixels(0.01),
447        ] {
448            let (c, t, pck) = pck_at(&gt, &gt, &vis, 20, norm);
449            assert_eq!((c, t), (3, 3), "{norm:?}");
450            assert!((pck - 1.0).abs() < 1e-6, "{norm:?} perfect PCK must be 1.0");
451        }
452        assert_eq!(mpjpe(&gt, &gt, &vis), 0.0);
453    }
454
455    // -------- all keypoints just OUTSIDE threshold ⇒ PCK = 0.0 --------
456    //
457    // Hand calc (torso): hips at (0.40,0.50)/(0.60,0.50) ⇒ torso = 0.20.
458    // threshold k=20 ⇒ τ = 0.20·0.20 = 0.04. Push every scored joint to an
459    // error of 0.05 (> 0.04) ⇒ all wrong. To avoid the hips themselves being
460    // "correct", we displace the hips too (their displaced positions still
461    // define the torso from GT, which is unchanged).
462    #[test]
463    fn all_just_outside_threshold_pck_zero() {
464        let gt = pose17(&[
465            (5, 0.50, 0.50),
466            (CANON_LEFT_HIP, 0.40, 0.50),
467            (CANON_RIGHT_HIP, 0.60, 0.50),
468        ]);
469        // GT torso = 0.20, τ@20 = 0.04. Displace each scored joint by dx=0.05.
470        let pred = pose17(&[
471            (5, 0.55, 0.50),
472            (CANON_LEFT_HIP, 0.45, 0.50),
473            (CANON_RIGHT_HIP, 0.65, 0.50),
474        ]);
475        let vis = vis17(&[5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
476        let (c, t, pck) = pck_at(&pred, &gt, &vis, 20, PckNormalization::TorsoDiameter);
477        assert_eq!(t, 3);
478        assert_eq!(c, 0, "all errors 0.05 > τ 0.04 ⇒ none correct");
479        assert_eq!(pck, 0.0);
480    }
481
482    // -------- half-in / half-out ⇒ PCK = 0.5 --------
483    //
484    // Hand calc (torso): torso = 0.20, τ@20 = 0.04. Four visible joints; two
485    // exact (dist 0 ≤ 0.04, correct), two displaced 0.05 (> 0.04, wrong)
486    // ⇒ 2/4 = 0.5.
487    #[test]
488    fn half_in_half_out_pck_half() {
489        let gt = pose17(&[
490            (0, 0.50, 0.20),
491            (5, 0.50, 0.50),
492            (CANON_LEFT_HIP, 0.40, 0.50),
493            (CANON_RIGHT_HIP, 0.60, 0.50),
494        ]);
495        let pred = pose17(&[
496            (0, 0.50, 0.20),          // exact ⇒ correct
497            (5, 0.55, 0.50),          // err 0.05 ⇒ wrong
498            (CANON_LEFT_HIP, 0.40, 0.50),  // exact ⇒ correct
499            (CANON_RIGHT_HIP, 0.65, 0.50), // err 0.05 ⇒ wrong
500        ]);
501        let vis = vis17(&[0, 5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
502        let (c, t, pck) = pck_at(&pred, &gt, &vis, 20, PckNormalization::TorsoDiameter);
503        assert_eq!((c, t), (2, 4));
504        assert!((pck - 0.5).abs() < 1e-6, "expected 0.5, got {pck}");
505    }
506
507    // -------- THE KEY PROOF: same predictions, three normalizations, three PCK --------
508    //
509    // One construction scored three ways. Hand calc:
510    //   GT: nose(0)=(0.50,0.10), l_sh(5)=(0.50,0.30),
511    //       l_hip(11)=(0.40,0.90), r_hip(12)=(0.60,0.90).
512    //   Visible = {0,5,11,12}, all four.
513    //   torso  = |0.60-0.40| = 0.20  (hips, y equal).
514    //   bbox: x∈[0.40,0.60] (w=0.20), y∈[0.10,0.90] (h=0.80)
515    //         ⇒ diag = sqrt(0.20² + 0.80²) = sqrt(0.04+0.64)=sqrt(0.68)=0.8246…
516    //
517    //   Pred errors (pure dx): nose 0.00, l_sh 0.10, l_hip 0.00, r_hip 0.00.
518    //   (Only joint 5 is displaced, by 0.10.)
519    //
520    //   k = 20:
521    //   • Torso  τ = 0.20·0.20 = 0.040 → joint5 err 0.10 > 0.040 ⇒ WRONG
522    //       ⇒ 3 correct / 4 = 0.75
523    //   • Bbox   τ = 0.20·0.8246 = 0.16492 → joint5 err 0.10 ≤ 0.16492 ⇒ CORRECT
524    //       ⇒ 4 correct / 4 = 1.00
525    //   • Abs(0.05) τ = 0.05 → joint5 err 0.10 > 0.05 ⇒ WRONG
526    //       ⇒ 3 correct / 4 = 0.75   (same count as torso HERE by coincidence)
527    //
528    //   To make ALL THREE differ, also test Abs(0.08): τ=0.08, joint5 0.10>0.08
529    //   ⇒ still 0.75. So we additionally displace nose by 0.06 (between 0.05 and
530    //   0.08) to separate the two absolute thresholds — see below.
531    #[test]
532    fn three_normalizations_give_different_pck_on_identical_input() {
533        let gt = pose17(&[
534            (0, 0.50, 0.10),  // nose
535            (5, 0.50, 0.30),  // left_shoulder
536            (CANON_LEFT_HIP, 0.40, 0.90),
537            (CANON_RIGHT_HIP, 0.60, 0.90),
538        ]);
539        // nose displaced 0.06, shoulder displaced 0.10, hips exact.
540        let pred = pose17(&[
541            (0, 0.56, 0.10),  // err 0.06
542            (5, 0.60, 0.30),  // err 0.10
543            (CANON_LEFT_HIP, 0.40, 0.90),  // exact
544            (CANON_RIGHT_HIP, 0.60, 0.90), // exact
545        ]);
546        let vis = vis17(&[0, 5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
547
548        // Torso τ@20 = 0.04: nose 0.06>0.04 wrong, sh 0.10>0.04 wrong,
549        //   hips exact ⇒ 2/4 = 0.5.
550        let (_, _, torso) = pck_at(&pred, &gt, &vis, 20, PckNormalization::TorsoDiameter);
551        // Bbox diag = sqrt(0.68)=0.82462; τ@20 = 0.164924:
552        //   nose 0.06 ≤ τ correct, sh 0.10 ≤ τ correct, hips exact ⇒ 4/4 = 1.0.
553        let (_, _, bbox) = pck_at(&pred, &gt, &vis, 20, PckNormalization::BoundingBoxDiagonal);
554        // Abs(0.08): nose 0.06 ≤ 0.08 correct, sh 0.10 > 0.08 wrong, hips exact
555        //   ⇒ 3/4 = 0.75.
556        let (_, _, abs) = pck_at(&pred, &gt, &vis, 20, PckNormalization::AbsolutePixels(0.08));
557
558        assert!((torso - 0.5).abs() < 1e-6, "torso PCK expected 0.5, got {torso}");
559        assert!((bbox - 1.0).abs() < 1e-6, "bbox PCK expected 1.0, got {bbox}");
560        assert!((abs - 0.75).abs() < 1e-6, "abs(0.08) PCK expected 0.75, got {abs}");
561
562        // The whole point: identical predictions, three DISTINCT PCK values.
563        assert!(torso != bbox && bbox != abs && torso != abs,
564            "normalizations must give distinct PCK: torso={torso}, bbox={bbox}, abs={abs}");
565    }
566
567    // -------- AbsolutePixels ignores k (raw threshold) --------
568    #[test]
569    fn absolute_pixels_ignores_threshold_percentage() {
570        let gt = pose17(&[(5, 0.50, 0.50), (CANON_LEFT_HIP, 0.40, 0.50), (CANON_RIGHT_HIP, 0.60, 0.50)]);
571        let pred = pose17(&[(5, 0.53, 0.50), (CANON_LEFT_HIP, 0.40, 0.50), (CANON_RIGHT_HIP, 0.60, 0.50)]);
572        let vis = vis17(&[5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
573        // τ = 0.05 raw; joint5 err 0.03 ≤ 0.05 correct. k=5 and k=99 must agree.
574        let (_, _, p5) = pck_at(&pred, &gt, &vis, 5, PckNormalization::AbsolutePixels(0.05));
575        let (_, _, p99) = pck_at(&pred, &gt, &vis, 99, PckNormalization::AbsolutePixels(0.05));
576        assert_eq!(p5, p99, "AbsolutePixels must ignore the k percentage");
577        assert!((p5 - 1.0).abs() < 1e-6, "all three within 0.05, got {p5}");
578    }
579
580    // -------- MPJPE hand-computed (2D and 3D) --------
581    #[test]
582    fn mpjpe_hand_computed_2d() {
583        // joint0 err (3,4)->5, joint1 exact->0 ⇒ mean (5+0)/2 = 2.5.
584        let gt = Array2::from_shape_vec((2, 2), vec![0.0, 0.0, 1.0, 1.0]).unwrap();
585        let pred = Array2::from_shape_vec((2, 2), vec![3.0, 4.0, 1.0, 1.0]).unwrap();
586        let vis = Array1::from(vec![2.0, 2.0]);
587        assert!((mpjpe(&pred, &gt, &vis) - 2.5).abs() < 1e-6);
588    }
589
590    #[test]
591    fn mpjpe_hand_computed_3d() {
592        // single joint err (1,2,2) -> sqrt(1+4+4)=3.0.
593        let gt = Array2::from_shape_vec((1, 3), vec![0.0, 0.0, 0.0]).unwrap();
594        let pred = Array2::from_shape_vec((1, 3), vec![1.0, 2.0, 2.0]).unwrap();
595        let vis = Array1::from(vec![2.0]);
596        assert!((mpjpe(&pred, &gt, &vis) - 3.0).abs() < 1e-6);
597    }
598
599    #[test]
600    fn mpjpe_excludes_invisible_joints() {
601        // joint0 visible err 5, joint1 INVISIBLE err 100 ⇒ mean = 5 (joint1 dropped).
602        let gt = Array2::from_shape_vec((2, 2), vec![0.0, 0.0, 0.0, 0.0]).unwrap();
603        let pred = Array2::from_shape_vec((2, 2), vec![3.0, 4.0, 100.0, 0.0]).unwrap();
604        let vis = Array1::from(vec![2.0, 0.0]);
605        assert!((mpjpe(&pred, &gt, &vis) - 5.0).abs() < 1e-6);
606    }
607
608    // -------- degenerate inputs: no panic --------
609    #[test]
610    fn zero_torso_is_unscoreable_not_perfect() {
611        // Both hips coincident ⇒ torso ≈ 0; bbox also collapses ⇒ None.
612        let gt = pose17(&[(CANON_LEFT_HIP, 0.5, 0.5), (CANON_RIGHT_HIP, 0.5, 0.5)]);
613        let vis = vis17(&[CANON_LEFT_HIP, CANON_RIGHT_HIP]);
614        assert_eq!(pck_at(&gt, &gt, &vis, 20, PckNormalization::TorsoDiameter), (0, 0, 0.0));
615        assert_eq!(pck_at(&gt, &gt, &vis, 20, PckNormalization::BoundingBoxDiagonal), (0, 0, 0.0));
616    }
617
618    #[test]
619    fn no_visible_keypoints_scores_zero() {
620        let gt = pose17(&[(CANON_LEFT_HIP, 0.4, 0.5), (CANON_RIGHT_HIP, 0.6, 0.5)]);
621        let vis = vis17(&[]); // nothing visible
622        let (c, t, pck) = pck_at(&gt, &gt, &vis, 20, PckNormalization::TorsoDiameter);
623        assert_eq!((c, t, pck), (0, 0, 0.0));
624        assert_eq!(mpjpe(&gt, &gt, &vis), 0.0);
625    }
626
627    #[test]
628    fn nan_coords_do_not_panic_and_count_wrong() {
629        let gt = pose17(&[(5, 0.5, 0.5), (CANON_LEFT_HIP, 0.4, 0.5), (CANON_RIGHT_HIP, 0.6, 0.5)]);
630        let mut pred = gt.clone();
631        pred[[5, 0]] = f32::NAN; // joint 5 prediction is NaN
632        let vis = vis17(&[5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
633        let (c, t, pck) = pck_at(&pred, &gt, &vis, 20, PckNormalization::TorsoDiameter);
634        assert_eq!(t, 3);
635        assert_eq!(c, 2, "NaN joint must count as wrong, hips correct ⇒ 2/3");
636        assert!((pck - 2.0 / 3.0).abs() < 1e-6);
637        // mpjpe with a NaN joint yields NaN (caller filters) but must not panic.
638        assert!(mpjpe(&pred, &gt, &vis).is_nan());
639    }
640
641    // -------- batch report: micro-average + self-describing struct --------
642    #[test]
643    fn accuracy_report_micro_averages_and_carries_definition() {
644        // Frame A: 2 visible, both correct (2/2). Frame B: 2 visible, both wrong (0/2).
645        // Micro-average over joints: 2 correct / 4 = 0.5 (NOT mean-of-frame-PCK,
646        // which would be (1.0+0.0)/2 = 0.5 here too, but the accumulator is the
647        // joint-level one).
648        let gt = pose17(&[(CANON_LEFT_HIP, 0.40, 0.50), (CANON_RIGHT_HIP, 0.60, 0.50)]);
649        let vis = vis17(&[CANON_LEFT_HIP, CANON_RIGHT_HIP]);
650        let frame_a = PoseFrame { pred: gt.clone(), gt: gt.clone(), visibility: vis.clone() };
651        // Frame B: displace both hips by 0.05 (> τ 0.04) ⇒ both wrong.
652        let pred_b = pose17(&[(CANON_LEFT_HIP, 0.45, 0.50), (CANON_RIGHT_HIP, 0.65, 0.50)]);
653        let frame_b = PoseFrame { pred: pred_b, gt: gt.clone(), visibility: vis.clone() };
654
655        let report = accuracy_report(
656            &[frame_a, frame_b],
657            &[20, 50],
658            PckNormalization::TorsoDiameter,
659        );
660        assert_eq!(report.n_frames, 2);
661        assert_eq!(report.n_keypoints, 17);
662        assert_eq!(report.normalization, PckNormalization::TorsoDiameter);
663        // PCK@20: 2 correct / 4 visible = 0.5.
664        assert!((report.pck(20).unwrap() - 0.5).abs() < 1e-6);
665        // PCK@50: τ = 0.5·0.20 = 0.10, frame B err 0.05 ≤ 0.10 ⇒ all correct
666        //   ⇒ 4/4 = 1.0.
667        assert!((report.pck(50).unwrap() - 1.0).abs() < 1e-6);
668        // A reported number always carries its definition in the summary.
669        assert!(report.summary().contains("torso-diameter"));
670    }
671
672    #[test]
673    fn accuracy_report_empty_is_zero_not_nan() {
674        let report = accuracy_report(&[], &[20], PckNormalization::BoundingBoxDiagonal);
675        assert_eq!(report.n_frames, 0);
676        assert_eq!(report.pck(20), Some(0.0));
677        assert_eq!(report.mpjpe, 0.0);
678        assert!(!report.mpjpe.is_nan());
679    }
680
681    // -------- bbox-norm is looser than torso-norm (sanity, on a batch) --------
682    #[test]
683    fn bbox_norm_scores_at_least_torso_norm() {
684        // bbox diagonal >= torso span always (bbox encloses the hips), so for the
685        // SAME frames bbox-PCK >= torso-PCK at the same k. Pin this ordering.
686        let gt = pose17(&[
687            (0, 0.50, 0.10),
688            (5, 0.50, 0.40),
689            (CANON_LEFT_HIP, 0.40, 0.90),
690            (CANON_RIGHT_HIP, 0.60, 0.90),
691        ]);
692        let pred = pose17(&[
693            (0, 0.55, 0.10),
694            (5, 0.58, 0.40),
695            (CANON_LEFT_HIP, 0.42, 0.90),
696            (CANON_RIGHT_HIP, 0.62, 0.90),
697        ]);
698        let vis = vis17(&[0, 5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
699        let frame = PoseFrame { pred, gt, visibility: vis };
700        let torso = accuracy_report(std::slice::from_ref(&frame), &[20], PckNormalization::TorsoDiameter);
701        let bbox = accuracy_report(std::slice::from_ref(&frame), &[20], PckNormalization::BoundingBoxDiagonal);
702        assert!(
703            bbox.pck(20).unwrap() >= torso.pck(20).unwrap(),
704            "bbox-norm (looser) must be >= torso-norm: bbox={:?} torso={:?}",
705            bbox.pck(20), torso.pck(20)
706        );
707    }
708}