1use 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#[derive(Debug, Clone, PartialEq)]
34pub struct GroundTruthScore {
35 pub num_truth: usize,
37 pub num_tracks: usize,
39 pub num_matched: usize,
42 pub rmse_m: f64,
44 pub max_err_m: f64,
46 pub coverage: f64,
50 pub id_switches: usize,
54 pub mean_fragments: f64,
58 pub frame_offset: i64,
62}
63
64struct 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
77pub 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
105fn 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 let mut best: Option<(f64, i64)> = None; 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 let mut sq_sum = 0.0;
150 let mut max_err = 0.0f64;
151 let mut num_matched = 0usize;
152 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 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 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 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
235fn 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 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 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 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 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 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 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 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 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 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}