Skip to main content

braid_sim/
truth.rs

1// Copyright (C) The Strand-Braid Authors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Ground-truth oracle for the simulation harness.
5//!
6//! Unlike [`crate::score`], which compares a live recording against an offline
7//! retrack of *itself*, this module scores a `.braidz` against the **known
8//! ground truth** of the [`Scenario`] that generated it. Because the world is a
9//! pure function of time ([`World::state_at`]), every reconstructed track can be
10//! compared to where its insect actually was.
11//!
12//! It answers: how accurately, how completely, and how stably did Braid track
13//! the simulated insects?
14//!
15//! - **Accuracy**: position RMSE / max error over matched object-frames.
16//! - **Completeness** (coverage): the fraction of object-frames where an insect
17//!   was present and some track was within the association gate.
18//! - **Stability**: ID switches and track fragmentation — how many distinct
19//!   Braid `obj_id`s ended up assigned to a single ground-truth insect. A
20//!   perfectly stable run has one `obj_id` per insect (mean fragments = 1, zero
21//!   switches); the live-vs-retrack fragmentation bug shows up here as many
22//!   fragments per insect.
23
24use std::collections::{BTreeMap, BTreeSet};
25use std::path::Path;
26
27use braidz_parser::braidz_parse_path;
28
29use crate::scenario::Scenario;
30use crate::world::World;
31
32/// Result of scoring a `.braidz` against ground truth.
33#[derive(Debug, Clone, PartialEq)]
34pub struct GroundTruthScore {
35    /// Number of ground-truth insects in the scenario.
36    pub num_truth: usize,
37    /// Number of distinct Braid `obj_id`s (track fragments) in the recording.
38    pub num_tracks: usize,
39    /// Number of matched object-frames (a row associated to a present insect
40    /// within the gate).
41    pub num_matched: usize,
42    /// Position root-mean-square error over matched object-frames, meters.
43    pub rmse_m: f64,
44    /// Worst single-frame position error over matched object-frames, meters.
45    pub max_err_m: f64,
46    /// Fraction in `[0, 1]` of present object-frames (insect present and the
47    /// recording covers that frame) for which some track matched within the
48    /// gate.
49    pub coverage: f64,
50    /// Total number of ID switches summed over insects: a switch is counted each
51    /// time the `obj_id` matched to an insect changes from one matched frame to
52    /// the next.
53    pub id_switches: usize,
54    /// Mean number of distinct `obj_id`s assigned to a single ground-truth
55    /// insect. `1.0` is ideal (one unbroken track per insect); larger means more
56    /// fragmentation.
57    pub mean_fragments: f64,
58    /// The frame offset (truth frame = braidz frame + offset) that best aligned
59    /// the recording to ground truth. Usually `0`; nonzero absorbs any
60    /// constant sync-establishment offset.
61    pub frame_offset: i64,
62}
63
64/// One Kalman-estimate row reduced to what the oracle needs.
65struct Row {
66    frame: i64,
67    obj_id: u32,
68    pos: [f64; 3],
69}
70
71impl Row {
72    fn new(frame: i64, obj_id: u32, pos: [f64; 3]) -> Self {
73        Row { frame, obj_id, pos }
74    }
75}
76
77/// Score `braidz_path` against the ground truth of `scenario`.
78///
79/// `gate_m` is the maximum 3D distance (meters) at which a reconstructed point
80/// is associated to a ground-truth insect. `max_frame_offset` bounds the search
81/// for a constant frame offset between the recording's synchronized frame
82/// numbers and simulation time (`t = frame / fps`); pass `0` to disable the
83/// search and assume exact alignment.
84pub fn score_against_truth(
85    braidz_path: &Path,
86    scenario: &Scenario,
87    gate_m: f64,
88    max_frame_offset: i64,
89) -> eyre::Result<GroundTruthScore> {
90    let archive = braidz_parse_path(braidz_path)
91        .map_err(|e| eyre::eyre!("opening braidz {}: {e}", braidz_path.display()))?;
92    let krows = archive
93        .kalman_estimates_table
94        .as_ref()
95        .ok_or_else(|| eyre::eyre!("braidz {} has no kalman_estimates", braidz_path.display()))?;
96
97    let rows: Vec<Row> = krows
98        .iter()
99        .map(|r| Row::new(r.frame.0 as i64, r.obj_id, [r.x, r.y, r.z]))
100        .collect();
101
102    Ok(score_rows(&rows, scenario, gate_m, max_frame_offset))
103}
104
105/// Core scoring over already-extracted rows. Separated from
106/// [`score_against_truth`] so it can be unit-tested with synthetic rows.
107fn score_rows(
108    rows: &[Row],
109    scenario: &Scenario,
110    gate_m: f64,
111    max_frame_offset: i64,
112) -> GroundTruthScore {
113    let world = World::new(scenario.clone());
114    let fps = scenario.fps;
115    let num_tracks = rows.iter().map(|r| r.obj_id).collect::<BTreeSet<_>>().len();
116
117    // Pick the integer frame offset minimizing mean matched error. Position
118    // changes slowly between frames, so even a coarse search robustly absorbs a
119    // constant sync-establishment offset; ties (and the zero-match case) keep
120    // the smallest |offset|.
121    let mut best: Option<(f64, i64)> = None; // (mean_err, offset)
122    for offset in -max_frame_offset..=max_frame_offset {
123        let mut sum = 0.0;
124        let mut n = 0usize;
125        for row in rows {
126            let t = (row.frame + offset) as f64 / fps;
127            if let Some((_id, d)) = nearest_truth(&world, t, &row.pos, gate_m) {
128                sum += d;
129                n += 1;
130            }
131        }
132        if n == 0 {
133            continue;
134        }
135        let mean = sum / n as f64;
136        let better = match best {
137            None => true,
138            Some((bmean, boff)) => {
139                mean < bmean - 1e-12 || ((mean - bmean).abs() <= 1e-12 && offset.abs() < boff.abs())
140            }
141        };
142        if better {
143            best = Some((mean, offset));
144        }
145    }
146    let frame_offset = best.map(|(_, o)| o).unwrap_or(0);
147
148    // Final pass at the chosen offset: gather matches and accuracy.
149    let mut sq_sum = 0.0;
150    let mut max_err = 0.0f64;
151    let mut num_matched = 0usize;
152    // Per insect, ordered by frame, the matched obj_id (nearest wins per frame).
153    let mut per_insect: BTreeMap<u32, BTreeMap<i64, (u32, f64)>> = BTreeMap::new();
154    for row in rows {
155        let t = (row.frame + frame_offset) as f64 / fps;
156        if let Some((id, d)) = nearest_truth(&world, t, &row.pos, gate_m) {
157            num_matched += 1;
158            sq_sum += d * d;
159            max_err = max_err.max(d);
160            let slot = per_insect.entry(id).or_default().entry(row.frame);
161            slot.and_modify(|(oid, best_d)| {
162                if d < *best_d {
163                    *oid = row.obj_id;
164                    *best_d = d;
165                }
166            })
167            .or_insert((row.obj_id, d));
168        }
169    }
170    let rmse_m = if num_matched > 0 {
171        (sq_sum / num_matched as f64).sqrt()
172    } else {
173        0.0
174    };
175
176    // Stability: ID switches and fragments per insect.
177    let mut id_switches = 0usize;
178    let mut frag_total = 0usize;
179    for assignments in per_insect.values() {
180        let mut prev: Option<u32> = None;
181        let mut distinct: BTreeSet<u32> = BTreeSet::new();
182        for (oid, _d) in assignments.values() {
183            distinct.insert(*oid);
184            if let Some(p) = prev
185                && p != *oid
186            {
187                id_switches += 1;
188            }
189            prev = Some(*oid);
190        }
191        frag_total += distinct.len();
192    }
193
194    // Coverage: matched object-frames over present object-frames within the
195    // recording's frame range.
196    let coverage = if rows.is_empty() {
197        0.0
198    } else {
199        let min_f = rows.iter().map(|r| r.frame).min().unwrap();
200        let max_f = rows.iter().map(|r| r.frame).max().unwrap();
201        let mut present = 0usize;
202        for f in min_f..=max_f {
203            let t = (f + frame_offset) as f64 / fps;
204            present += world.state_at(t).len();
205        }
206        // matched object-frames = number of (insect, frame) pairs we matched.
207        let matched_obj_frames: usize = per_insect.values().map(|m| m.len()).sum();
208        if present > 0 {
209            (matched_obj_frames as f64 / present as f64).min(1.0)
210        } else {
211            0.0
212        }
213    };
214
215    let num_truth = scenario.insects.len();
216    let mean_fragments = if per_insect.is_empty() {
217        0.0
218    } else {
219        frag_total as f64 / per_insect.len() as f64
220    };
221
222    GroundTruthScore {
223        num_truth,
224        num_tracks,
225        num_matched,
226        rmse_m,
227        max_err_m: max_err,
228        coverage,
229        id_switches,
230        mean_fragments,
231        frame_offset,
232    }
233}
234
235/// The nearest present insect to `pos` at time `t`, and its distance, if within
236/// `gate_m`.
237fn nearest_truth(world: &World, t: f64, pos: &[f64; 3], gate_m: f64) -> Option<(u32, f64)> {
238    let mut best: Option<(u32, f64)> = None;
239    for ins in world.state_at(t) {
240        let c = &ins.pos.coords;
241        let dx = c.x - pos[0];
242        let dy = c.y - pos[1];
243        let dz = c.z - pos[2];
244        let d = (dx * dx + dy * dy + dz * dz).sqrt();
245        if d <= gate_m && best.is_none_or(|(_, bd)| d < bd) {
246            best = Some((ins.id, d));
247        }
248    }
249    best
250}
251
252impl GroundTruthScore {
253    /// A human-readable summary.
254    pub fn report(&self) -> String {
255        format!(
256            "ground-truth oracle:\n\
257             \x20 truth insects   {}\n\
258             \x20 braid tracks    {}\n\
259             \x20 matched frames  {}\n\
260             \x20 frame offset    {}\n\
261             \x20 position RMSE   {:.4} m\n\
262             \x20 position max    {:.4} m\n\
263             \x20 coverage        {:.1}%\n\
264             \x20 id switches     {}\n\
265             \x20 frags / insect  {:.2}",
266            self.num_truth,
267            self.num_tracks,
268            self.num_matched,
269            self.frame_offset,
270            self.rmse_m,
271            self.max_err_m,
272            100.0 * self.coverage,
273            self.id_switches,
274            self.mean_fragments,
275        )
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::scenario::{
283        Arena, BlobParams, CameraRig, InsectSpec, Lissajous, ObservationModel, TimingModel,
284    };
285
286    /// A two-insect scenario whose insects follow distinct (out-of-phase) paths.
287    fn two_insect_scenario() -> Scenario {
288        let motion = |phase: [f64; 3]| Lissajous {
289            freq_hz: [0.11, 0.13, 0.07],
290            phase,
291            fill: 0.7,
292            maneuver_amp_m: 0.0,
293            maneuver_freq_hz: 0.0,
294        };
295        Scenario {
296            seed: 1,
297            fps: 100.0,
298            arena: Arena {
299                min: [-0.5, -0.5, -0.2],
300                max: [0.5, 0.5, 0.2],
301            },
302            cameras: CameraRig {
303                count: 4,
304                radius_m: 2.0,
305                height_m: 0.5,
306                focal_length_px: 800.0,
307                image_width: 640,
308                image_height: 480,
309            },
310            insects: vec![
311                InsectSpec {
312                    id: 0,
313                    enter_t: 0.0,
314                    exit_t: None,
315                    motion: motion([0.0, 0.0, 0.0]),
316                },
317                InsectSpec {
318                    id: 1,
319                    enter_t: 0.0,
320                    exit_t: None,
321                    motion: motion([2.0, 0.5, 1.0]),
322                },
323            ],
324            blob: BlobParams::default(),
325            bg_warmup_frames: 0,
326            timing: TimingModel::default(),
327            observation: ObservationModel::default(),
328            reported_fps: None,
329            calibration_perturbation: Default::default(),
330        }
331    }
332
333    /// Synthesize `.braidz`-style rows that track each insect exactly, mapping
334    /// ground-truth `insect_id` to a Braid `obj_id` via `obj_id_for`. Returns one
335    /// row per (present insect, frame) for `frames` consecutive frames starting
336    /// at `start_frame`.
337    fn rows_from_truth(
338        scenario: &Scenario,
339        start_frame: i64,
340        frames: i64,
341        mut obj_id_for: impl FnMut(u32, i64) -> u32,
342    ) -> Vec<Row> {
343        let world = World::new(scenario.clone());
344        let mut out = Vec::new();
345        for k in 0..frames {
346            let frame = start_frame + k;
347            let t = frame as f64 / scenario.fps;
348            for ins in world.state_at(t) {
349                let c = &ins.pos.coords;
350                out.push(Row::new(frame, obj_id_for(ins.id, frame), [c.x, c.y, c.z]));
351            }
352        }
353        out
354    }
355
356    #[test]
357    fn perfect_tracking_scores_perfectly() {
358        let s = two_insect_scenario();
359        // Each insect tracked by one stable obj_id (10 and 11).
360        let rows = rows_from_truth(&s, 0, 200, |id, _f| 10 + id);
361        let score = score_rows(&rows, &s, 0.01, 0);
362        assert_eq!(score.num_truth, 2);
363        assert_eq!(score.num_tracks, 2);
364        assert!(score.rmse_m < 1e-9, "rmse {}", score.rmse_m);
365        assert!(score.coverage > 0.999, "coverage {}", score.coverage);
366        assert_eq!(score.id_switches, 0);
367        assert!(
368            (score.mean_fragments - 1.0).abs() < 1e-9,
369            "frags {}",
370            score.mean_fragments
371        );
372    }
373
374    #[test]
375    fn fragmentation_is_detected() {
376        let s = two_insect_scenario();
377        // Break each insect's track into a new obj_id every 50 frames: 4 frags
378        // per insect over 200 frames, with a switch at each break.
379        let rows = rows_from_truth(&s, 0, 200, |id, f| id * 100 + (f / 50) as u32);
380        let score = score_rows(&rows, &s, 0.01, 0);
381        assert!(
382            (score.mean_fragments - 4.0).abs() < 1e-9,
383            "frags {}",
384            score.mean_fragments
385        );
386        // 3 switches per insect * 2 insects.
387        assert_eq!(score.id_switches, 6);
388        assert!(score.coverage > 0.999, "coverage {}", score.coverage);
389    }
390
391    #[test]
392    fn missed_frames_lower_coverage() {
393        let s = two_insect_scenario();
394        // Only emit even frames: ~half of object-frames are present-but-unmatched.
395        let mut rows = rows_from_truth(&s, 0, 200, |id, _f| 10 + id);
396        rows.retain(|r| r.frame % 2 == 0);
397        let score = score_rows(&rows, &s, 0.01, 0);
398        assert!(
399            (0.45..0.6).contains(&score.coverage),
400            "coverage {}",
401            score.coverage
402        );
403    }
404
405    #[test]
406    fn frame_offset_is_recovered() {
407        let s = two_insect_scenario();
408        // Rows are labeled with frames shifted +7 from the truth they depict.
409        // With max_frame_offset >= 7, the search should recover offset = -7 and
410        // still score perfectly.
411        let world = World::new(s.clone());
412        let mut rows = Vec::new();
413        for k in 0..200i64 {
414            let truth_frame = k;
415            let t = truth_frame as f64 / s.fps;
416            for ins in world.state_at(t) {
417                let c = &ins.pos.coords;
418                rows.push(Row::new(truth_frame + 7, 10 + ins.id, [c.x, c.y, c.z]));
419            }
420        }
421        let score = score_rows(&rows, &s, 0.01, 12);
422        assert_eq!(score.frame_offset, -7);
423        assert!(score.rmse_m < 1e-9, "rmse {}", score.rmse_m);
424    }
425
426    #[test]
427    fn unmatched_points_are_not_credited() {
428        let s = two_insect_scenario();
429        // All points far outside the arena -> nothing within the gate.
430        let rows: Vec<Row> = (0..50)
431            .map(|f| Row::new(f, 1, [100.0, 100.0, 100.0]))
432            .collect();
433        let score = score_rows(&rows, &s, 0.05, 0);
434        assert_eq!(score.num_matched, 0);
435        assert_eq!(score.coverage, 0.0);
436        assert_eq!(score.id_switches, 0);
437    }
438}