Add Cartographer-style evaluation framework with benchmarks and tests

- Add evaluation module with accuracy, quality, and performance metrics
- Add ground truth handling and relations metrics for SLAM evaluation
- Add criterion benchmarks for scan matching, grid operations, and evaluation
- Add integration tests for accuracy, quality, and performance
- Add Serialize/Deserialize derives to CellCounts for evaluation output
This commit is contained in:
codetiger
2025-12-31 20:35:48 +05:30
parent 66202ef074
commit 6505a4110f
16 changed files with 3668 additions and 1 deletions
+13
View File
@@ -16,6 +16,19 @@ serde_yaml = "0.9" # YAML config files
env_logger = "0.11" # Test logging
approx = "0.5" # Approximate comparisons (tests only)
image = { version = "0.25", default-features = false, features = ["png", "pnm"] }
criterion = "0.5" # Benchmarking
[[bench]]
name = "scan_matching"
harness = false
[[bench]]
name = "grid_operations"
harness = false
[[bench]]
name = "evaluation"
harness = false
[profile.release]
opt-level = 3
+121
View File
@@ -0,0 +1,121 @@
//! Benchmark evaluation metrics performance.
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use vastu_slam::Pose2D;
use vastu_slam::evaluation::{
AbsoluteTrajectoryError, GroundTruthConfig, GroundTruthRelations, RelationsMetrics,
RelativePoseError,
};
fn create_trajectory(n: usize, spacing: f32) -> Vec<Pose2D> {
(0..n)
.map(|i| Pose2D::new(i as f32 * spacing, 0.0, 0.0))
.collect()
}
fn add_noise(trajectory: &[Pose2D], trans_std: f32) -> Vec<Pose2D> {
trajectory
.iter()
.enumerate()
.map(|(i, p)| {
// Simple deterministic "noise"
let noise = (
(i as f32 * 0.7).sin() * trans_std,
(i as f32 * 1.3).cos() * trans_std,
);
Pose2D::new(p.x + noise.0, p.y + noise.1, p.theta)
})
.collect()
}
fn bench_ground_truth_generation(c: &mut Criterion) {
let mut group = c.benchmark_group("ground_truth_generation");
for n in [50, 100, 500].iter() {
let trajectory = create_trajectory(*n, 0.1);
let config = GroundTruthConfig::default();
group.bench_with_input(BenchmarkId::from_parameter(n), n, |b, _| {
b.iter(|| {
let gt =
GroundTruthRelations::generate(black_box(&trajectory), &[], config.clone());
black_box(gt)
})
});
}
group.finish();
}
fn bench_relations_metrics(c: &mut Criterion) {
let mut group = c.benchmark_group("relations_metrics");
for n in [50, 100, 500].iter() {
let ground_trajectory = create_trajectory(*n, 0.1);
let config = GroundTruthConfig::default();
let ground_truth = GroundTruthRelations::generate(&ground_trajectory, &[], config);
let test_trajectory = add_noise(&ground_trajectory, 0.001);
group.bench_with_input(BenchmarkId::from_parameter(n), n, |b, _| {
b.iter(|| {
let metrics = RelationsMetrics::compute(black_box(&test_trajectory), &ground_truth);
black_box(metrics)
})
});
}
group.finish();
}
fn bench_ate(c: &mut Criterion) {
let mut group = c.benchmark_group("ate");
for n in [50, 100, 500].iter() {
let ground_truth = create_trajectory(*n, 0.1);
let estimated = add_noise(&ground_truth, 0.001);
group.bench_with_input(BenchmarkId::from_parameter(n), n, |b, _| {
b.iter(|| {
let ate = AbsoluteTrajectoryError::compute(
black_box(&estimated),
black_box(&ground_truth),
);
black_box(ate)
})
});
}
group.finish();
}
fn bench_rpe(c: &mut Criterion) {
let mut group = c.benchmark_group("rpe");
for n in [50, 100, 500].iter() {
let ground_truth = create_trajectory(*n, 0.1);
let estimated = add_noise(&ground_truth, 0.001);
group.bench_with_input(BenchmarkId::from_parameter(n), n, |b, _| {
b.iter(|| {
let rpe = RelativePoseError::compute(
black_box(&estimated),
black_box(&ground_truth),
1.0,
);
black_box(rpe)
})
});
}
group.finish();
}
criterion_group!(
benches,
bench_ground_truth_generation,
bench_relations_metrics,
bench_ate,
bench_rpe
);
criterion_main!(benches);
+149
View File
@@ -0,0 +1,149 @@
//! Benchmark grid operations performance.
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use std::f32::consts::PI;
use vastu_slam::{LidarScan, MapConfig, OccupancyGridMap, Pose2D};
/// Create a room scan for benchmarking.
fn room_scan(
room_width: f32,
room_height: f32,
robot_x: f32,
robot_y: f32,
num_points: usize,
) -> LidarScan {
let angle_increment = 2.0 * PI / num_points as f32;
let max_range = (room_width * room_width + room_height * room_height).sqrt();
let mut ranges = Vec::with_capacity(num_points);
let mut angles = Vec::with_capacity(num_points);
for i in 0..num_points {
let angle = i as f32 * angle_increment - PI;
angles.push(angle);
let cos_a = angle.cos();
let sin_a = angle.sin();
let mut range = max_range;
// Simple room ray casting
if cos_a > 0.0 {
let t = (room_width - robot_x) / cos_a;
if t > 0.0 && t < range {
let y = robot_y + t * sin_a;
if y >= 0.0 && y <= room_height {
range = t;
}
}
}
if cos_a < 0.0 {
let t = -robot_x / cos_a;
if t > 0.0 && t < range {
let y = robot_y + t * sin_a;
if y >= 0.0 && y <= room_height {
range = t;
}
}
}
if sin_a > 0.0 {
let t = (room_height - robot_y) / sin_a;
if t > 0.0 && t < range {
let x = robot_x + t * cos_a;
if x >= 0.0 && x <= room_width {
range = t;
}
}
}
if sin_a < 0.0 {
let t = -robot_y / sin_a;
if t > 0.0 && t < range {
let x = robot_x + t * cos_a;
if x >= 0.0 && x <= room_width {
range = t;
}
}
}
ranges.push(range.min(max_range));
}
LidarScan::new(ranges, angles, 0.15, max_range)
}
fn bench_map_update(c: &mut Criterion) {
let config = MapConfig::default();
let mut map = OccupancyGridMap::new(config);
let scan = room_scan(6.0, 6.0, 3.0, 3.0, 360);
let pose = Pose2D::new(3.0, 3.0, 0.0);
// Warm up
for _ in 0..5 {
map.observe_lidar(&scan, pose);
}
c.bench_function("grid_update_360pts", |b| {
b.iter(|| {
let result = map.observe_lidar(black_box(&scan), black_box(pose));
black_box(result)
})
});
}
fn bench_map_update_resolutions(c: &mut Criterion) {
let mut group = c.benchmark_group("grid_update_resolution");
for num_points in [180, 360, 720].iter() {
let config = MapConfig::default();
let mut map = OccupancyGridMap::new(config);
let scan = room_scan(6.0, 6.0, 3.0, 3.0, *num_points);
let pose = Pose2D::new(3.0, 3.0, 0.0);
// Warm up
for _ in 0..5 {
map.observe_lidar(&scan, pose);
}
group.bench_with_input(
BenchmarkId::from_parameter(num_points),
num_points,
|b, _| {
b.iter(|| {
let result = map.observe_lidar(black_box(&scan), black_box(pose));
black_box(result)
})
},
);
}
group.finish();
}
fn bench_coverage_stats(c: &mut Criterion) {
let config = MapConfig::default();
let mut map = OccupancyGridMap::new(config);
// Build up a map
for i in 0..20 {
let angle = i as f32 * 0.2;
let pose = Pose2D::new(3.0, 3.0, angle);
let scan = room_scan(6.0, 6.0, 3.0, 3.0, 360);
map.observe_lidar(&scan, pose);
}
c.bench_function("coverage_stats", |b| {
b.iter(|| {
let stats = map.coverage_stats();
black_box(stats)
})
});
}
criterion_group!(
benches,
bench_map_update,
bench_map_update_resolutions,
bench_coverage_stats
);
criterion_main!(benches);
+154
View File
@@ -0,0 +1,154 @@
//! Benchmark scan matching performance.
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use std::f32::consts::PI;
use vastu_slam::{
CorrelativeMatcher, CorrelativeMatcherConfig, LidarScan, MapConfig, OccupancyGridMap, Pose2D,
};
/// Create a room scan for benchmarking.
fn room_scan(
room_width: f32,
room_height: f32,
robot_x: f32,
robot_y: f32,
num_points: usize,
) -> LidarScan {
let angle_increment = 2.0 * PI / num_points as f32;
let max_range = (room_width * room_width + room_height * room_height).sqrt();
let mut ranges = Vec::with_capacity(num_points);
let mut angles = Vec::with_capacity(num_points);
for i in 0..num_points {
let angle = i as f32 * angle_increment - PI;
angles.push(angle);
let cos_a = angle.cos();
let sin_a = angle.sin();
let mut range = max_range;
// Right wall
if cos_a > 0.0 {
let t = (room_width - robot_x) / cos_a;
if t > 0.0 && t < range {
let y = robot_y + t * sin_a;
if y >= 0.0 && y <= room_height {
range = t;
}
}
}
// Left wall
if cos_a < 0.0 {
let t = -robot_x / cos_a;
if t > 0.0 && t < range {
let y = robot_y + t * sin_a;
if y >= 0.0 && y <= room_height {
range = t;
}
}
}
// Top wall
if sin_a > 0.0 {
let t = (room_height - robot_y) / sin_a;
if t > 0.0 && t < range {
let x = robot_x + t * cos_a;
if x >= 0.0 && x <= room_width {
range = t;
}
}
}
// Bottom wall
if sin_a < 0.0 {
let t = -robot_y / sin_a;
if t > 0.0 && t < range {
let x = robot_x + t * cos_a;
if x >= 0.0 && x <= room_width {
range = t;
}
}
}
ranges.push(range.min(max_range));
}
LidarScan::new(ranges, angles, 0.15, max_range)
}
fn setup_map() -> (OccupancyGridMap, LidarScan) {
let config = MapConfig::default();
let mut map = OccupancyGridMap::new(config);
// Initialize map
for i in 0..10 {
let angle = i as f32 * 0.1;
let pose = Pose2D::new(3.0, 3.0, angle);
let scan = room_scan(6.0, 6.0, 3.0, 3.0, 360);
map.observe_lidar(&scan, pose);
}
let scan = room_scan(6.0, 6.0, 3.1, 3.0, 360);
(map, scan)
}
fn bench_scan_matching(c: &mut Criterion) {
let (map, scan) = setup_map();
let initial_pose = Pose2D::new(3.0, 3.0, 0.0);
let config = CorrelativeMatcherConfig::default();
let matcher = CorrelativeMatcher::new(config);
c.bench_function("scan_matching_360pts", |b| {
b.iter(|| {
let result =
matcher.match_scan(black_box(&scan), black_box(initial_pose), map.storage());
black_box(result)
})
});
}
fn bench_scan_matching_resolutions(c: &mut Criterion) {
let mut group = c.benchmark_group("scan_matching_resolution");
for num_points in [180, 360, 720].iter() {
let config = MapConfig::default();
let mut map = OccupancyGridMap::new(config);
for i in 0..10 {
let angle = i as f32 * 0.1;
let pose = Pose2D::new(3.0, 3.0, angle);
let scan = room_scan(6.0, 6.0, 3.0, 3.0, *num_points);
map.observe_lidar(&scan, pose);
}
let scan = room_scan(6.0, 6.0, 3.1, 3.0, *num_points);
let initial_pose = Pose2D::new(3.0, 3.0, 0.0);
let matcher_config = CorrelativeMatcherConfig::default();
let matcher = CorrelativeMatcher::new(matcher_config);
group.bench_with_input(
BenchmarkId::from_parameter(num_points),
num_points,
|b, _| {
b.iter(|| {
let result = matcher.match_scan(
black_box(&scan),
black_box(initial_pose),
map.storage(),
);
black_box(result)
})
},
);
}
group.finish();
}
criterion_group!(
benches,
bench_scan_matching,
bench_scan_matching_resolutions
);
criterion_main!(benches);
+504
View File
@@ -0,0 +1,504 @@
//! Absolute and Relative Trajectory Error metrics.
//!
//! This module provides ATE (Absolute Trajectory Error) and RPE (Relative Pose Error)
//! metrics that are compatible with the [evo](https://github.com/MichaelGrupp/evo)
//! trajectory evaluation tool.
//!
//! ## When to Use
//!
//! - **Pose Relations** (primary): Use for self-evaluation without external ground truth
//! - **ATE/RPE** (secondary): Use for cross-validation when external ground truth is available
//!
//! ## Metrics
//!
//! - **ATE**: Global accuracy after SE(2) alignment. Measures overall trajectory quality.
//! - **RPE**: Local drift over fixed distances. Measures odometry consistency.
use crate::Pose2D;
use serde::{Deserialize, Serialize};
/// Container for all accuracy metrics.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct AccuracyMetrics {
/// Absolute Trajectory Error
pub ate: AbsoluteTrajectoryError,
/// Relative Pose Error
pub rpe: RelativePoseError,
}
impl AccuracyMetrics {
/// Compute all accuracy metrics.
///
/// # Arguments
///
/// * `estimated` - Estimated trajectory from SLAM
/// * `ground_truth` - Ground truth trajectory (from simulation or external sensor)
/// * `delta_m` - Distance delta for RPE computation (meters)
pub fn compute(estimated: &[Pose2D], ground_truth: &[Pose2D], delta_m: f32) -> Self {
Self {
ate: AbsoluteTrajectoryError::compute(estimated, ground_truth),
rpe: RelativePoseError::compute(estimated, ground_truth, delta_m),
}
}
/// Print all metrics.
pub fn print(&self) {
self.ate.print();
println!();
self.rpe.print();
}
}
/// Error statistics for trajectory comparison.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct TrajectoryError {
/// Root mean square error
pub rmse: f32,
/// Mean error
pub mean: f32,
/// Standard deviation
pub std: f32,
/// Minimum error
pub min: f32,
/// Maximum error
pub max: f32,
/// Median error
pub median: f32,
/// Number of samples
pub count: usize,
}
impl TrajectoryError {
/// Compute statistics from a list of errors.
pub fn from_errors(errors: &[f32]) -> Self {
if errors.is_empty() {
return Self::default();
}
let count = errors.len();
let n = count as f32;
let sum: f32 = errors.iter().sum();
let mean = sum / n;
let sum_sq: f32 = errors.iter().map(|e| e * e).sum();
let rmse = (sum_sq / n).sqrt();
let variance = errors.iter().map(|e| (e - mean).powi(2)).sum::<f32>() / n;
let std = variance.sqrt();
let min = errors.iter().cloned().fold(f32::INFINITY, f32::min);
let max = errors.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let mut sorted = errors.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median = if count.is_multiple_of(2) {
(sorted[count / 2 - 1] + sorted[count / 2]) / 2.0
} else {
sorted[count / 2]
};
Self {
rmse,
mean,
std,
min,
max,
median,
count,
}
}
/// Format as a single-line summary.
pub fn summary(&self) -> String {
format!(
"rmse: {:.4}, mean: {:.4}, std: {:.4}, min: {:.4}, max: {:.4}",
self.rmse, self.mean, self.std, self.min, self.max
)
}
}
/// Absolute Trajectory Error (ATE).
///
/// Measures the global accuracy of a trajectory by computing the RMSE of position
/// errors after SE(2) alignment (translation + rotation).
///
/// This is the standard metric used by evo's `evo_ape` command.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct AbsoluteTrajectoryError {
/// Translational error statistics (meters)
pub translation: TrajectoryError,
/// Rotational error statistics (radians)
pub rotation: TrajectoryError,
/// Whether SE(2) alignment was applied
pub aligned: bool,
}
impl AbsoluteTrajectoryError {
/// Compute ATE with SE(2) alignment.
///
/// # Arguments
///
/// * `estimated` - Estimated trajectory from SLAM
/// * `ground_truth` - Ground truth trajectory
///
/// # Returns
///
/// ATE metrics after aligning trajectories.
pub fn compute(estimated: &[Pose2D], ground_truth: &[Pose2D]) -> Self {
Self::compute_with_options(estimated, ground_truth, true)
}
/// Compute ATE with optional alignment.
pub fn compute_with_options(
estimated: &[Pose2D],
ground_truth: &[Pose2D],
align: bool,
) -> Self {
let n = estimated.len().min(ground_truth.len());
if n == 0 {
return Self::default();
}
// Optionally align trajectories
let aligned_estimated = if align {
align_trajectory_se2(estimated, ground_truth)
} else {
estimated.to_vec()
};
// Compute per-pose errors
let mut trans_errors = Vec::with_capacity(n);
let mut rot_errors = Vec::with_capacity(n);
for i in 0..n {
let est = &aligned_estimated[i];
let gt = &ground_truth[i];
let trans_error = est.distance(gt);
let rot_error = (est.theta - gt.theta).abs();
trans_errors.push(trans_error);
rot_errors.push(rot_error);
}
Self {
translation: TrajectoryError::from_errors(&trans_errors),
rotation: TrajectoryError::from_errors(&rot_errors),
aligned: align,
}
}
/// Print ATE metrics.
pub fn print(&self) {
println!("=== Absolute Trajectory Error (ATE) ===");
println!("Alignment: {}", if self.aligned { "SE(2)" } else { "None" });
println!(
"Translation RMSE: {:.6} m (mean: {:.6}, std: {:.6})",
self.translation.rmse, self.translation.mean, self.translation.std
);
println!(
"Rotation RMSE: {:.6} rad (mean: {:.6}, std: {:.6})",
self.rotation.rmse, self.rotation.mean, self.rotation.std
);
}
}
/// Relative Pose Error (RPE).
///
/// Measures local drift by comparing relative poses over fixed distances.
/// This is useful for evaluating odometry quality independently of global drift.
///
/// This is the standard metric used by evo's `evo_rpe` command.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct RelativePoseError {
/// Translational error statistics (meters)
pub translation: TrajectoryError,
/// Rotational error statistics (radians)
pub rotation: TrajectoryError,
/// Distance delta used for computation (meters)
pub delta_m: f32,
/// Number of pairs evaluated
pub pairs_evaluated: usize,
}
impl RelativePoseError {
/// Compute RPE with a fixed distance delta.
///
/// # Arguments
///
/// * `estimated` - Estimated trajectory from SLAM
/// * `ground_truth` - Ground truth trajectory
/// * `delta_m` - Distance between pose pairs (meters)
///
/// # Returns
///
/// RPE metrics.
pub fn compute(estimated: &[Pose2D], ground_truth: &[Pose2D], delta_m: f32) -> Self {
let n = estimated.len().min(ground_truth.len());
if n < 2 || delta_m <= 0.0 {
return Self {
delta_m,
..Default::default()
};
}
// Compute cumulative distances in ground truth
let mut cumulative_dist = vec![0.0f32; n];
for i in 1..n {
cumulative_dist[i] =
cumulative_dist[i - 1] + ground_truth[i].distance(&ground_truth[i - 1]);
}
let mut trans_errors = Vec::new();
let mut rot_errors = Vec::new();
// For each pose, find the next pose that's delta_m away
for i in 0..n {
// Binary search for pose at distance delta_m
let target_dist = cumulative_dist[i] + delta_m;
// Find j such that cumulative_dist[j] >= target_dist
let j = match cumulative_dist[i..].binary_search_by(|d| {
d.partial_cmp(&target_dist)
.unwrap_or(std::cmp::Ordering::Less)
}) {
Ok(idx) => i + idx,
Err(idx) => {
if i + idx >= n {
continue;
}
i + idx
}
};
if j >= n {
continue;
}
// Compute relative poses
let est_rel = estimated[i].inverse().compose(&estimated[j]);
let gt_rel = ground_truth[i].inverse().compose(&ground_truth[j]);
// Error in relative pose
let error_rel = gt_rel.inverse().compose(&est_rel);
trans_errors.push(error_rel.position().length());
rot_errors.push(error_rel.theta.abs());
}
Self {
translation: TrajectoryError::from_errors(&trans_errors),
rotation: TrajectoryError::from_errors(&rot_errors),
delta_m,
pairs_evaluated: trans_errors.len(),
}
}
/// Print RPE metrics.
pub fn print(&self) {
println!("=== Relative Pose Error (RPE) ===");
println!("Delta: {} m, Pairs: {}", self.delta_m, self.pairs_evaluated);
println!(
"Translation RMSE: {:.6} m (mean: {:.6}, std: {:.6})",
self.translation.rmse, self.translation.mean, self.translation.std
);
println!(
"Rotation RMSE: {:.6} rad (mean: {:.6}, std: {:.6})",
self.rotation.rmse, self.rotation.mean, self.rotation.std
);
}
}
/// Align a trajectory to a reference using SE(2) transformation.
///
/// Finds the optimal translation and rotation to minimize position error.
/// Uses Horn's method for 2D point cloud registration.
///
/// # Arguments
///
/// * `estimated` - Trajectory to align
/// * `reference` - Reference trajectory (ground truth)
///
/// # Returns
///
/// Aligned trajectory.
pub fn align_trajectory_se2(estimated: &[Pose2D], reference: &[Pose2D]) -> Vec<Pose2D> {
let n = estimated.len().min(reference.len());
if n == 0 {
return Vec::new();
}
// Compute centroids
let est_centroid = compute_centroid(estimated);
let ref_centroid = compute_centroid(reference);
// Center the point clouds
let est_centered: Vec<(f32, f32)> = estimated
.iter()
.take(n)
.map(|p| (p.x - est_centroid.0, p.y - est_centroid.1))
.collect();
let ref_centered: Vec<(f32, f32)> = reference
.iter()
.take(n)
.map(|p| (p.x - ref_centroid.0, p.y - ref_centroid.1))
.collect();
// Compute optimal rotation using SVD-like approach for 2D
// For 2D, we can compute this directly without full SVD
let mut sxx = 0.0f32;
let mut sxy = 0.0f32;
let mut syx = 0.0f32;
let mut syy = 0.0f32;
for i in 0..n {
sxx += est_centered[i].0 * ref_centered[i].0;
sxy += est_centered[i].0 * ref_centered[i].1;
syx += est_centered[i].1 * ref_centered[i].0;
syy += est_centered[i].1 * ref_centered[i].1;
}
// Optimal rotation angle
let theta = (sxy - syx).atan2(sxx + syy);
let cos_t = theta.cos();
let sin_t = theta.sin();
// Compute translation
let tx = ref_centroid.0 - (cos_t * est_centroid.0 - sin_t * est_centroid.1);
let ty = ref_centroid.1 - (sin_t * est_centroid.0 + cos_t * est_centroid.1);
// Apply transformation
estimated
.iter()
.map(|p| {
Pose2D::new(
cos_t * p.x - sin_t * p.y + tx,
sin_t * p.x + cos_t * p.y + ty,
p.theta + theta,
)
})
.collect()
}
fn compute_centroid(poses: &[Pose2D]) -> (f32, f32) {
if poses.is_empty() {
return (0.0, 0.0);
}
let n = poses.len() as f32;
let sum_x: f32 = poses.iter().map(|p| p.x).sum();
let sum_y: f32 = poses.iter().map(|p| p.y).sum();
(sum_x / n, sum_y / n)
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::FRAC_PI_4;
fn create_straight_trajectory(n: usize, spacing: f32) -> Vec<Pose2D> {
(0..n)
.map(|i| Pose2D::new(i as f32 * spacing, 0.0, 0.0))
.collect()
}
#[test]
fn test_trajectory_error_stats() {
let errors = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let stats = TrajectoryError::from_errors(&errors);
assert_eq!(stats.count, 5);
assert!((stats.mean - 3.0).abs() < 1e-5);
assert!((stats.min - 1.0).abs() < 1e-5);
assert!((stats.max - 5.0).abs() < 1e-5);
assert!((stats.median - 3.0).abs() < 1e-5);
}
#[test]
fn test_ate_perfect_trajectory() {
let trajectory = create_straight_trajectory(10, 0.5);
let ate = AbsoluteTrajectoryError::compute(&trajectory, &trajectory);
assert!(ate.translation.rmse < 1e-5);
assert!(ate.rotation.rmse < 1e-5);
}
#[test]
fn test_ate_with_offset() {
let ground_truth = create_straight_trajectory(10, 0.5);
// Estimated with constant offset
let estimated: Vec<Pose2D> = ground_truth
.iter()
.map(|p| Pose2D::new(p.x + 0.1, p.y + 0.1, p.theta))
.collect();
// Without alignment
let ate_no_align =
AbsoluteTrajectoryError::compute_with_options(&estimated, &ground_truth, false);
assert!((ate_no_align.translation.rmse - 0.1414).abs() < 0.01);
// With alignment, error should be near zero
let ate_aligned = AbsoluteTrajectoryError::compute(&estimated, &ground_truth);
assert!(ate_aligned.translation.rmse < 0.01);
}
#[test]
fn test_rpe_perfect_trajectory() {
let trajectory = create_straight_trajectory(10, 0.5);
let rpe = RelativePoseError::compute(&trajectory, &trajectory, 1.0);
assert!(rpe.translation.rmse < 1e-5);
assert!(rpe.rotation.rmse < 1e-5);
assert!(rpe.pairs_evaluated > 0);
}
#[test]
fn test_align_trajectory() {
let reference = create_straight_trajectory(5, 1.0);
// Estimated with rotation and translation
let estimated: Vec<Pose2D> = reference
.iter()
.map(|p| {
let cos_t = FRAC_PI_4.cos();
let sin_t = FRAC_PI_4.sin();
Pose2D::new(
cos_t * p.x - sin_t * p.y + 1.0,
sin_t * p.x + cos_t * p.y + 2.0,
p.theta + FRAC_PI_4,
)
})
.collect();
let aligned = align_trajectory_se2(&estimated, &reference);
// After alignment, should be close to reference
for (a, r) in aligned.iter().zip(reference.iter()) {
assert!(a.distance(r) < 0.01);
}
}
#[test]
fn test_accuracy_metrics_compute() {
let trajectory = create_straight_trajectory(10, 0.5);
let metrics = AccuracyMetrics::compute(&trajectory, &trajectory, 1.0);
assert!(metrics.ate.translation.rmse < 1e-5);
assert!(metrics.rpe.translation.rmse < 1e-5);
}
}
+329
View File
@@ -0,0 +1,329 @@
//! Ground truth generation for Cartographer-style evaluation.
//!
//! This module provides the [`GroundTruthRelations`] type for extracting pose relations
//! from an optimized trajectory, equivalent to Cartographer's `autogenerate_ground_truth`.
//!
//! ## Concept
//!
//! Instead of requiring external ground truth (GPS, motion capture), we extract
//! **relative pose constraints** from an optimized trajectory that has had loop
//! closures applied. These relations can then be used to evaluate other trajectories.
use crate::Pose2D;
use serde::{Deserialize, Serialize};
/// Configuration for ground truth generation.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GroundTruthConfig {
/// Minimum distance covered between nodes to create a relation (meters).
/// Shorter distances are less meaningful for evaluation.
pub min_covered_distance: f32,
/// Maximum translational error to consider a relation valid (meters).
/// Relations with larger errors are treated as outliers.
pub outlier_threshold_meters: f32,
/// Maximum rotational error to consider a relation valid (radians).
pub outlier_threshold_radians: f32,
/// Sample every N nodes to create relations.
/// Higher values reduce computation but may miss details.
pub node_sampling_interval: usize,
}
impl Default for GroundTruthConfig {
fn default() -> Self {
Self {
min_covered_distance: 0.5, // 50cm minimum
outlier_threshold_meters: 0.05, // 5cm threshold
outlier_threshold_radians: 0.035, // ~2 degrees
node_sampling_interval: 1,
}
}
}
/// A pose relation constraint between two trajectory nodes.
///
/// This represents the relative transform from one node to another,
/// along with the distance traveled between them (for weighting).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PoseRelation {
/// Index of the starting node.
pub from_node: usize,
/// Index of the ending node.
pub to_node: usize,
/// Relative pose: transform from `from_node` to `to_node`.
/// Apply as: to_pose = from_pose.compose(&relative_pose)
pub relative_pose: Pose2D,
/// Distance covered between nodes (cumulative path length).
/// Used for weighting in metric computation.
pub covered_distance: f32,
}
impl PoseRelation {
/// Create a new pose relation.
pub fn new(
from_node: usize,
to_node: usize,
relative_pose: Pose2D,
covered_distance: f32,
) -> Self {
Self {
from_node,
to_node,
relative_pose,
covered_distance,
}
}
/// Compute the relative pose from two absolute poses.
pub fn from_poses(
from_node: usize,
to_node: usize,
from_pose: &Pose2D,
to_pose: &Pose2D,
covered_distance: f32,
) -> Self {
// relative_pose = from_pose.inverse().compose(to_pose)
let relative_pose = from_pose.inverse().compose(to_pose);
Self::new(from_node, to_node, relative_pose, covered_distance)
}
}
/// Ground truth relations extracted from an optimized trajectory.
///
/// This is the Cartographer-style ground truth format. It stores relative
/// pose constraints between trajectory nodes, which can be compared against
/// a test trajectory to compute accuracy metrics.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GroundTruthRelations {
/// The pose relations extracted from the optimized trajectory.
pub relations: Vec<PoseRelation>,
/// Configuration used for generation.
pub config: GroundTruthConfig,
/// Total trajectory length (for reference).
pub trajectory_length: f32,
/// Number of nodes in the original trajectory.
pub num_nodes: usize,
}
impl GroundTruthRelations {
/// Generate ground truth relations from an optimized trajectory.
///
/// This is equivalent to Cartographer's `autogenerate_ground_truth`.
/// It extracts relative pose constraints between nodes in the trajectory.
///
/// # Arguments
///
/// * `optimized_trajectory` - Poses from an optimized trajectory (with loop closures)
/// * `loop_closures` - Pairs of node indices that form loop closures (optional)
/// * `config` - Configuration for relation generation
///
/// # Returns
///
/// Ground truth relations that can be used for evaluation.
pub fn generate(
optimized_trajectory: &[Pose2D],
loop_closures: &[(usize, usize)],
config: GroundTruthConfig,
) -> Self {
let num_nodes = optimized_trajectory.len();
if num_nodes < 2 {
return Self {
relations: Vec::new(),
config,
trajectory_length: 0.0,
num_nodes,
};
}
// Compute cumulative distances along trajectory
let mut cumulative_distances = vec![0.0f32; num_nodes];
for i in 1..num_nodes {
let dist = optimized_trajectory[i].distance(&optimized_trajectory[i - 1]);
cumulative_distances[i] = cumulative_distances[i - 1] + dist;
}
let trajectory_length = cumulative_distances[num_nodes - 1];
let mut relations = Vec::new();
// Generate sequential relations
let interval = config.node_sampling_interval.max(1);
for i in (0..num_nodes).step_by(interval) {
for j in ((i + 1)..num_nodes).step_by(interval) {
let covered_distance = cumulative_distances[j] - cumulative_distances[i];
// Skip if distance is too short
if covered_distance < config.min_covered_distance {
continue;
}
let relation = PoseRelation::from_poses(
i,
j,
&optimized_trajectory[i],
&optimized_trajectory[j],
covered_distance,
);
relations.push(relation);
}
}
// Add loop closure relations with high weight
for &(from, to) in loop_closures {
if from < num_nodes && to < num_nodes && from != to {
// For loop closures, covered distance is the full loop
let covered_distance = if from < to {
cumulative_distances[to] - cumulative_distances[from]
} else {
cumulative_distances[from] - cumulative_distances[to]
};
let relation = PoseRelation::from_poses(
from.min(to),
from.max(to),
&optimized_trajectory[from.min(to)],
&optimized_trajectory[from.max(to)],
covered_distance,
);
// Avoid duplicates
if !relations
.iter()
.any(|r| r.from_node == relation.from_node && r.to_node == relation.to_node)
{
relations.push(relation);
}
}
}
Self {
relations,
config,
trajectory_length,
num_nodes,
}
}
/// Generate ground truth from a trajectory with timestamps.
///
/// Convenience method that accepts timestamped poses.
pub fn generate_timestamped(
trajectory: &[(f64, Pose2D)],
loop_closures: &[(usize, usize)],
config: GroundTruthConfig,
) -> Self {
let poses: Vec<Pose2D> = trajectory.iter().map(|(_, pose)| *pose).collect();
Self::generate(&poses, loop_closures, config)
}
/// Get the number of relations.
pub fn len(&self) -> usize {
self.relations.len()
}
/// Check if there are no relations.
pub fn is_empty(&self) -> bool {
self.relations.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::FRAC_PI_2;
#[test]
fn test_pose_relation_from_poses() {
let from_pose = Pose2D::new(0.0, 0.0, 0.0);
let to_pose = Pose2D::new(1.0, 0.0, 0.0);
let relation = PoseRelation::from_poses(0, 1, &from_pose, &to_pose, 1.0);
assert_eq!(relation.from_node, 0);
assert_eq!(relation.to_node, 1);
assert!((relation.relative_pose.x - 1.0).abs() < 1e-5);
assert!((relation.relative_pose.y - 0.0).abs() < 1e-5);
}
#[test]
fn test_pose_relation_with_rotation() {
let from_pose = Pose2D::new(0.0, 0.0, 0.0);
let to_pose = Pose2D::new(1.0, 1.0, FRAC_PI_2);
let relation = PoseRelation::from_poses(0, 1, &from_pose, &to_pose, 1.414);
// Relative pose should encode the transform
let reconstructed = from_pose.compose(&relation.relative_pose);
assert!((reconstructed.x - to_pose.x).abs() < 1e-5);
assert!((reconstructed.y - to_pose.y).abs() < 1e-5);
assert!((reconstructed.theta - to_pose.theta).abs() < 1e-5);
}
#[test]
fn test_ground_truth_generation() {
// Simple straight-line trajectory
let trajectory: Vec<Pose2D> = (0..10)
.map(|i| Pose2D::new(i as f32 * 0.5, 0.0, 0.0))
.collect();
let config = GroundTruthConfig {
min_covered_distance: 0.5,
node_sampling_interval: 1,
..Default::default()
};
let ground_truth = GroundTruthRelations::generate(&trajectory, &[], config);
assert!(ground_truth.len() > 0);
assert_eq!(ground_truth.num_nodes, 10);
assert!((ground_truth.trajectory_length - 4.5).abs() < 1e-5);
}
#[test]
fn test_ground_truth_with_loop_closures() {
// Square trajectory
let trajectory = vec![
Pose2D::new(0.0, 0.0, 0.0),
Pose2D::new(1.0, 0.0, 0.0),
Pose2D::new(1.0, 1.0, FRAC_PI_2),
Pose2D::new(0.0, 1.0, std::f32::consts::PI),
Pose2D::new(0.0, 0.0, -FRAC_PI_2), // Back to start
];
let loop_closures = vec![(0, 4)]; // Loop closure between first and last
let config = GroundTruthConfig {
min_covered_distance: 0.5,
..Default::default()
};
let ground_truth = GroundTruthRelations::generate(&trajectory, &loop_closures, config);
// Should have relations including the loop closure
assert!(!ground_truth.is_empty());
assert!(
ground_truth
.relations
.iter()
.any(|r| (r.from_node == 0 && r.to_node == 4)
|| (r.from_node == 4 && r.to_node == 0))
);
}
#[test]
fn test_empty_trajectory() {
let config = GroundTruthConfig::default();
let ground_truth = GroundTruthRelations::generate(&[], &[], config);
assert!(ground_truth.is_empty());
assert_eq!(ground_truth.trajectory_length, 0.0);
}
}
+56
View File
@@ -0,0 +1,56 @@
//! Cartographer-style evaluation framework for VastuSLAM.
//!
//! This module provides tools for evaluating SLAM accuracy, performance, and quality
//! following the approach used by Google Cartographer.
//!
//! ## Overview
//!
//! The evaluation framework is built on three pillars:
//!
//! 1. **Accuracy** - Pose relations metric, ATE, RPE
//! 2. **Performance** - Latency benchmarks, resource usage
//! 3. **Quality** - Map consistency, loop closure precision/recall
//!
//! ## Cartographer-style Evaluation
//!
//! The core idea is to evaluate using **relative poses** between trajectory nodes,
//! which works without external ground truth (GPS, motion capture).
//!
//! ### Two-step process:
//!
//! 1. **Generate ground truth**: Extract pose relations from an optimized trajectory
//! (with loop closures applied)
//! 2. **Evaluate**: Compare a test trajectory against these ground truth relations
//!
//! ## Example
//!
//! ```rust,ignore
//! use vastu_slam::evaluation::{GroundTruthRelations, RelationsMetrics, GroundTruthConfig};
//!
//! // Step 1: Generate ground truth from optimized trajectory
//! let config = GroundTruthConfig::default();
//! let ground_truth = GroundTruthRelations::generate(
//! &optimized_poses,
//! &loop_closures,
//! config,
//! );
//!
//! // Step 2: Evaluate test trajectory
//! let metrics = RelationsMetrics::compute(&test_poses, &ground_truth);
//! metrics.print();
//! // Output:
//! // Abs translational error 0.00832 +/- 0.00421 m
//! // Abs rotational error 0.15234 +/- 0.08123 deg
//! ```
mod accuracy;
mod ground_truth;
mod performance;
mod quality;
mod relations_metrics;
pub use accuracy::{AbsoluteTrajectoryError, AccuracyMetrics, RelativePoseError, TrajectoryError};
pub use ground_truth::{GroundTruthConfig, GroundTruthRelations, PoseRelation};
pub use performance::{OperationTiming, PerformanceMetrics, PerformanceTracker};
pub use quality::{LoopClosureGroundTruth, LoopClosurePR, MapQualityMetrics};
pub use relations_metrics::{RelationError, RelationsMetrics};
+389
View File
@@ -0,0 +1,389 @@
//! Performance metrics for SLAM evaluation.
//!
//! This module provides timing and resource tracking for benchmarking SLAM operations.
//! Unlike accuracy metrics, performance metrics are not part of Cartographer but are
//! essential for maintaining real-time performance.
//!
//! ## Usage
//!
//! ```rust,ignore
//! use vastu_slam::evaluation::PerformanceTracker;
//!
//! let mut tracker = PerformanceTracker::new();
//!
//! // Time an operation
//! let start = std::time::Instant::now();
//! // ... do scan matching ...
//! tracker.record("scan_matching", start.elapsed());
//!
//! // Get metrics
//! let metrics = tracker.metrics();
//! metrics.print();
//! ```
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::{Duration, Instant};
/// Timing statistics for a single operation.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct OperationTiming {
/// Operation name.
pub name: String,
/// Mean duration (milliseconds).
pub mean_ms: f32,
/// Standard deviation (milliseconds).
pub std_ms: f32,
/// Minimum duration (milliseconds).
pub min_ms: f32,
/// Maximum duration (milliseconds).
pub max_ms: f32,
/// Median duration (milliseconds).
pub median_ms: f32,
/// Total number of samples.
pub count: usize,
/// Total time spent (milliseconds).
pub total_ms: f32,
}
impl OperationTiming {
/// Create from a list of durations.
pub fn from_durations(name: &str, durations: &[Duration]) -> Self {
if durations.is_empty() {
return Self {
name: name.to_string(),
..Default::default()
};
}
let ms_values: Vec<f32> = durations.iter().map(|d| d.as_secs_f32() * 1000.0).collect();
let n = ms_values.len() as f32;
let total_ms = ms_values.iter().sum::<f32>();
let mean_ms = total_ms / n;
let variance = ms_values.iter().map(|v| (v - mean_ms).powi(2)).sum::<f32>() / n;
let std_ms = variance.sqrt();
let min_ms = ms_values.iter().cloned().fold(f32::INFINITY, f32::min);
let max_ms = ms_values.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let mut sorted = ms_values.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median_ms = if sorted.len().is_multiple_of(2) {
(sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) / 2.0
} else {
sorted[sorted.len() / 2]
};
Self {
name: name.to_string(),
mean_ms,
std_ms,
min_ms,
max_ms,
median_ms,
count: durations.len(),
total_ms,
}
}
/// Check if the operation exceeds a threshold (for regression testing).
pub fn exceeds_threshold(&self, threshold_ms: f32) -> bool {
self.mean_ms > threshold_ms
}
/// Format as a single line.
pub fn summary(&self) -> String {
format!(
"{}: {:.2} ± {:.2} ms (min: {:.2}, max: {:.2}, n={})",
self.name, self.mean_ms, self.std_ms, self.min_ms, self.max_ms, self.count
)
}
}
/// Aggregate performance metrics.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct PerformanceMetrics {
/// Per-operation timing.
pub operations: HashMap<String, OperationTiming>,
/// Scans processed per second.
pub scans_per_second: f32,
/// Real-time factor (1.0 = real-time, >1.0 = faster than real-time).
pub real_time_factor: f32,
/// Total processing time (milliseconds).
pub total_time_ms: f32,
/// Number of scans processed.
pub num_scans: usize,
}
impl PerformanceMetrics {
/// Print all metrics.
pub fn print(&self) {
println!("=== Performance Metrics ===");
println!("Scans processed: {}", self.num_scans);
println!("Scans per second: {:.2}", self.scans_per_second);
println!("Real-time factor: {:.2}x", self.real_time_factor);
println!("Total time: {:.2} ms", self.total_time_ms);
println!();
println!("Operation timings:");
let mut ops: Vec<_> = self.operations.values().collect();
ops.sort_by(|a, b| {
b.total_ms
.partial_cmp(&a.total_ms)
.unwrap_or(std::cmp::Ordering::Equal)
});
for op in ops {
println!(" {}", op.summary());
}
}
/// Check if any operation exceeds its threshold.
pub fn check_thresholds(&self, thresholds: &HashMap<String, f32>) -> Vec<String> {
let mut violations = Vec::new();
for (name, threshold) in thresholds {
if let Some(op) = self.operations.get(name)
&& op.exceeds_threshold(*threshold)
{
violations.push(format!(
"{}: {:.2}ms exceeds threshold of {}ms",
name, op.mean_ms, threshold
));
}
}
violations
}
}
/// Performance tracker for recording operation timings.
#[derive(Clone, Debug, Default)]
pub struct PerformanceTracker {
/// Recorded durations for each operation.
durations: HashMap<String, Vec<Duration>>,
/// Start time of tracking.
start_time: Option<Instant>,
/// Number of scans tracked.
scan_count: usize,
/// Expected scan interval (for real-time factor calculation).
expected_scan_interval: Duration,
}
impl PerformanceTracker {
/// Create a new performance tracker.
pub fn new() -> Self {
Self {
durations: HashMap::new(),
start_time: None,
scan_count: 0,
expected_scan_interval: Duration::from_millis(200), // 5Hz default
}
}
/// Create with a custom expected scan interval.
pub fn with_scan_interval(interval: Duration) -> Self {
Self {
expected_scan_interval: interval,
..Self::new()
}
}
/// Start tracking (call at beginning of processing).
pub fn start(&mut self) {
self.start_time = Some(Instant::now());
}
/// Record a timed operation.
pub fn record(&mut self, operation: &str, duration: Duration) {
self.durations
.entry(operation.to_string())
.or_default()
.push(duration);
}
/// Record the start of an operation and return a guard for automatic timing.
pub fn time(&mut self, operation: &str) -> TimingGuard {
TimingGuard {
operation: operation.to_string(),
start: Instant::now(),
tracker: self as *mut Self,
}
}
/// Increment scan count.
pub fn record_scan(&mut self) {
self.scan_count += 1;
}
/// Get the number of recorded scans.
pub fn scan_count(&self) -> usize {
self.scan_count
}
/// Compute aggregate metrics.
pub fn metrics(&self) -> PerformanceMetrics {
let operations: HashMap<String, OperationTiming> = self
.durations
.iter()
.map(|(name, durs)| (name.clone(), OperationTiming::from_durations(name, durs)))
.collect();
let total_time_ms = self
.start_time
.map(|s| s.elapsed().as_secs_f32() * 1000.0)
.unwrap_or(0.0);
let scans_per_second = if total_time_ms > 0.0 {
self.scan_count as f32 / (total_time_ms / 1000.0)
} else {
0.0
};
// Real-time factor: how fast are we compared to expected scan rate
let expected_scans_per_second = 1.0 / self.expected_scan_interval.as_secs_f32();
let real_time_factor = if expected_scans_per_second > 0.0 {
scans_per_second / expected_scans_per_second
} else {
0.0
};
PerformanceMetrics {
operations,
scans_per_second,
real_time_factor,
total_time_ms,
num_scans: self.scan_count,
}
}
/// Reset all tracking data.
pub fn reset(&mut self) {
self.durations.clear();
self.start_time = None;
self.scan_count = 0;
}
}
/// Guard for automatic timing of operations.
///
/// When dropped, records the elapsed time to the tracker.
pub struct TimingGuard {
operation: String,
start: Instant,
tracker: *mut PerformanceTracker,
}
impl Drop for TimingGuard {
fn drop(&mut self) {
let duration = self.start.elapsed();
// SAFETY: The guard is always created with a valid mutable reference
// and should not outlive the tracker.
unsafe {
(*self.tracker).record(&self.operation, duration);
}
}
}
/// Convenience macro for timing a block.
#[macro_export]
macro_rules! time_operation {
($tracker:expr, $name:expr, $block:block) => {{
let _guard = $tracker.time($name);
$block
}};
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
#[test]
fn test_operation_timing() {
let durations = vec![
Duration::from_millis(10),
Duration::from_millis(20),
Duration::from_millis(30),
];
let timing = OperationTiming::from_durations("test", &durations);
assert_eq!(timing.count, 3);
assert!((timing.mean_ms - 20.0).abs() < 0.1);
assert!((timing.min_ms - 10.0).abs() < 0.1);
assert!((timing.max_ms - 30.0).abs() < 0.1);
}
#[test]
fn test_performance_tracker() {
let mut tracker = PerformanceTracker::new();
tracker.start();
// Record some operations
tracker.record("scan_matching", Duration::from_millis(15));
tracker.record("scan_matching", Duration::from_millis(12));
tracker.record("map_update", Duration::from_millis(5));
tracker.record_scan();
tracker.record_scan();
let metrics = tracker.metrics();
assert_eq!(metrics.num_scans, 2);
assert!(metrics.operations.contains_key("scan_matching"));
assert!(metrics.operations.contains_key("map_update"));
let scan_matching = metrics.operations.get("scan_matching").unwrap();
assert_eq!(scan_matching.count, 2);
assert!((scan_matching.mean_ms - 13.5).abs() < 0.1);
}
#[test]
fn test_timing_guard() {
let mut tracker = PerformanceTracker::new();
{
let _guard = tracker.time("test_op");
thread::sleep(Duration::from_millis(10));
}
let metrics = tracker.metrics();
let test_op = metrics.operations.get("test_op").unwrap();
assert_eq!(test_op.count, 1);
assert!(test_op.mean_ms >= 10.0); // At least 10ms
}
#[test]
fn test_threshold_checking() {
let mut tracker = PerformanceTracker::new();
tracker.record("fast_op", Duration::from_millis(5));
tracker.record("slow_op", Duration::from_millis(50));
let metrics = tracker.metrics();
let mut thresholds = HashMap::new();
thresholds.insert("fast_op".to_string(), 10.0);
thresholds.insert("slow_op".to_string(), 20.0);
let violations = metrics.check_thresholds(&thresholds);
assert_eq!(violations.len(), 1);
assert!(violations[0].contains("slow_op"));
}
}
+511
View File
@@ -0,0 +1,511 @@
//! Map and loop closure quality metrics.
//!
//! This module provides quality metrics that extend beyond accuracy and performance:
//!
//! - **Loop Closure Quality**: Precision/Recall for loop closure detection (RTAB-Map style)
//! - **Map Quality**: Consistency, coverage, and sharpness metrics
use crate::{CellCounts, GridStorage, Pose2D};
use serde::{Deserialize, Serialize};
/// Loop closure precision/recall metrics.
///
/// Evaluates the quality of loop closure detection by comparing against ground truth.
/// Based on RTAB-Map's benchmark methodology.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct LoopClosurePR {
/// True positives: correctly detected loop closures.
pub true_positives: usize,
/// False positives: incorrectly detected loop closures.
pub false_positives: usize,
/// False negatives: missed loop closures.
pub false_negatives: usize,
/// Precision: TP / (TP + FP).
pub precision: f32,
/// Recall: TP / (TP + FN).
pub recall: f32,
/// F1 score: harmonic mean of precision and recall.
pub f1_score: f32,
/// Maximum recall at 100% precision (key benchmark metric).
pub max_recall_at_100_precision: f32,
}
impl LoopClosurePR {
/// Compute precision/recall from detections and ground truth.
///
/// # Arguments
///
/// * `detections` - Detected loop closures: (from_node, to_node, score)
/// * `ground_truth` - Ground truth loop closures
/// * `tolerance` - Position tolerance for matching (meters)
pub fn compute(
detections: &[(usize, usize, f32)],
ground_truth: &LoopClosureGroundTruth,
tolerance: f32,
) -> Self {
if ground_truth.closures.is_empty() && detections.is_empty() {
return Self {
precision: 1.0,
recall: 1.0,
f1_score: 1.0,
max_recall_at_100_precision: 1.0,
..Default::default()
};
}
// Sort detections by score (descending) for PR curve
let mut sorted_detections: Vec<_> = detections.to_vec();
sorted_detections
.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
let mut tp = 0usize;
let mut fp = 0usize;
let mut matched_gt: Vec<bool> = vec![false; ground_truth.closures.len()];
// Match detections to ground truth
for (from, to, _score) in &sorted_detections {
let mut is_tp = false;
// Check if this detection matches any ground truth
for (i, (gt_from, gt_to)) in ground_truth.closures.iter().enumerate() {
if matched_gt[i] {
continue;
}
// Check if nodes match (with some tolerance for node indices)
let from_matches = (*from as i32 - *gt_from as i32).abs() <= 1;
let to_matches = (*to as i32 - *gt_to as i32).abs() <= 1;
// If we have poses, also check spatial proximity
let spatially_close =
if let (Some(poses), _) = (&ground_truth.poses, tolerance > 0.0) {
if *from < poses.len() && *gt_from < poses.len() {
poses[*from].distance(&poses[*gt_from]) < tolerance
} else {
true
}
} else {
true
};
if (from_matches && to_matches) || spatially_close {
matched_gt[i] = true;
is_tp = true;
break;
}
}
if is_tp {
tp += 1;
} else {
fp += 1;
}
}
let fn_count = matched_gt.iter().filter(|&&m| !m).count();
let precision = if tp + fp > 0 {
tp as f32 / (tp + fp) as f32
} else {
1.0
};
let recall = if tp + fn_count > 0 {
tp as f32 / (tp + fn_count) as f32
} else {
1.0
};
let f1_score = if precision + recall > 0.0 {
2.0 * precision * recall / (precision + recall)
} else {
0.0
};
// Compute max recall at 100% precision
// Walk through sorted detections until we hit a false positive
let max_recall_at_100_precision = {
let mut running_tp = 0usize;
let running_fp = 0usize;
let mut best_recall = 0.0f32;
let mut matched: Vec<bool> = vec![false; ground_truth.closures.len()];
for (from, to, _score) in &sorted_detections {
let mut is_tp = false;
for (i, (gt_from, gt_to)) in ground_truth.closures.iter().enumerate() {
if matched[i] {
continue;
}
let from_matches = (*from as i32 - *gt_from as i32).abs() <= 1;
let to_matches = (*to as i32 - *gt_to as i32).abs() <= 1;
if from_matches && to_matches {
matched[i] = true;
is_tp = true;
break;
}
}
if is_tp {
running_tp += 1;
let current_recall = running_tp as f32 / ground_truth.closures.len() as f32;
let current_precision = running_tp as f32 / (running_tp + running_fp) as f32;
if current_precision >= 0.9999 {
best_recall = current_recall;
}
} else {
// Once we hit a FP, precision < 100%, so stop
break;
}
}
best_recall
};
Self {
true_positives: tp,
false_positives: fp,
false_negatives: fn_count,
precision,
recall,
f1_score,
max_recall_at_100_precision,
}
}
/// Print metrics.
pub fn print(&self) {
println!("=== Loop Closure Quality ===");
println!("True Positives: {}", self.true_positives);
println!("False Positives: {}", self.false_positives);
println!("False Negatives: {}", self.false_negatives);
println!("Precision: {:.2}%", self.precision * 100.0);
println!("Recall: {:.2}%", self.recall * 100.0);
println!("F1 Score: {:.2}%", self.f1_score * 100.0);
println!(
"Max Recall @100% Precision: {:.2}%",
self.max_recall_at_100_precision * 100.0
);
}
}
/// Ground truth for loop closure evaluation.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct LoopClosureGroundTruth {
/// List of (from_node, to_node) pairs that should match.
pub closures: Vec<(usize, usize)>,
/// Optional: poses for spatial matching.
#[serde(skip)]
pub poses: Option<Vec<Pose2D>>,
}
impl LoopClosureGroundTruth {
/// Create from a list of closure pairs.
pub fn from_pairs(closures: Vec<(usize, usize)>) -> Self {
Self {
closures,
poses: None,
}
}
/// Create with poses for spatial matching.
pub fn with_poses(closures: Vec<(usize, usize)>, poses: Vec<Pose2D>) -> Self {
Self {
closures,
poses: Some(poses),
}
}
/// Generate ground truth from trajectory based on spatial proximity.
///
/// Two nodes form a loop closure if:
/// 1. They are at least `min_separation` nodes apart in the sequence
/// 2. They are within `max_distance` meters of each other
pub fn from_trajectory(poses: &[Pose2D], min_separation: usize, max_distance: f32) -> Self {
let mut closures = Vec::new();
for i in 0..poses.len() {
for j in (i + min_separation)..poses.len() {
if poses[i].distance(&poses[j]) < max_distance {
closures.push((i, j));
}
}
}
Self {
closures,
poses: Some(poses.to_vec()),
}
}
}
/// Map quality metrics.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct MapQualityMetrics {
/// Explored area in square meters.
pub coverage_m2: f32,
/// Wall sharpness (0-1, higher is better).
/// Measures how well-defined wall boundaries are.
pub wall_sharpness: f32,
/// Submap consistency (0-1, higher is better).
/// Measures agreement at submap boundaries.
pub submap_consistency: f32,
/// Cell type distribution.
pub cell_counts: CellCounts,
/// Percentage of cells that are known.
pub known_percentage: f32,
/// Average obstacle thickness (cells).
pub avg_wall_thickness: f32,
}
impl MapQualityMetrics {
/// Compute quality metrics from a grid.
pub fn compute(grid: &GridStorage) -> Self {
let cell_counts = grid.count_by_type();
let resolution = grid.resolution();
let total_cells = cell_counts.total();
let known_cells = cell_counts.known();
let known_percentage = if total_cells > 0 {
known_cells as f32 / total_cells as f32 * 100.0
} else {
0.0
};
let coverage_m2 = known_cells as f32 * resolution * resolution;
// Compute wall sharpness
let wall_sharpness = Self::compute_wall_sharpness(grid);
// Compute average wall thickness
let avg_wall_thickness = Self::compute_avg_wall_thickness(grid);
Self {
coverage_m2,
wall_sharpness,
submap_consistency: 1.0, // Placeholder - would need submap data
cell_counts,
known_percentage,
avg_wall_thickness,
}
}
/// Compute wall sharpness by looking at transitions between wall and floor.
fn compute_wall_sharpness(grid: &GridStorage) -> f32 {
use crate::CellType;
let width = grid.width();
let height = grid.height();
if width < 3 || height < 3 {
return 1.0;
}
let mut sharp_transitions = 0usize;
let mut total_wall_cells = 0usize;
for y in 1..(height - 1) {
for x in 1..(width - 1) {
let coord = crate::GridCoord::new(x as i32, y as i32);
let cell = grid.get(coord);
if let Some(c) = cell
&& c.cell_type == CellType::Wall
{
total_wall_cells += 1;
// Check 4-connected neighbors
let neighbors = [
crate::GridCoord::new(x as i32 - 1, y as i32),
crate::GridCoord::new(x as i32 + 1, y as i32),
crate::GridCoord::new(x as i32, y as i32 - 1),
crate::GridCoord::new(x as i32, y as i32 + 1),
];
let floor_neighbors = neighbors
.iter()
.filter_map(|&nc| grid.get(nc))
.filter(|nc| nc.cell_type == CellType::Floor)
.count();
// Wall cell with floor neighbor = sharp boundary
if floor_neighbors > 0 && floor_neighbors < 4 {
sharp_transitions += 1;
}
}
}
}
if total_wall_cells == 0 {
return 1.0;
}
// Ratio of wall cells that form sharp boundaries
sharp_transitions as f32 / total_wall_cells as f32
}
/// Compute average wall thickness.
fn compute_avg_wall_thickness(grid: &GridStorage) -> f32 {
use crate::CellType;
let width = grid.width();
let height = grid.height();
let mut thicknesses = Vec::new();
// Scan horizontally
for y in 0..height {
let mut wall_run = 0;
for x in 0..width {
let coord = crate::GridCoord::new(x as i32, y as i32);
let is_wall = grid
.get(coord)
.map(|c| c.cell_type == CellType::Wall)
.unwrap_or(false);
if is_wall {
wall_run += 1;
} else if wall_run > 0 {
thicknesses.push(wall_run as f32);
wall_run = 0;
}
}
if wall_run > 0 {
thicknesses.push(wall_run as f32);
}
}
// Scan vertically
for x in 0..width {
let mut wall_run = 0;
for y in 0..height {
let coord = crate::GridCoord::new(x as i32, y as i32);
let is_wall = grid
.get(coord)
.map(|c| c.cell_type == CellType::Wall)
.unwrap_or(false);
if is_wall {
wall_run += 1;
} else if wall_run > 0 {
thicknesses.push(wall_run as f32);
wall_run = 0;
}
}
if wall_run > 0 {
thicknesses.push(wall_run as f32);
}
}
if thicknesses.is_empty() {
return 0.0;
}
thicknesses.iter().sum::<f32>() / thicknesses.len() as f32
}
/// Print metrics.
pub fn print(&self) {
println!("=== Map Quality Metrics ===");
println!("Coverage: {:.2} m²", self.coverage_m2);
println!("Known cells: {:.1}%", self.known_percentage);
println!("Wall sharpness: {:.2}", self.wall_sharpness);
println!("Avg wall thickness: {:.2} cells", self.avg_wall_thickness);
println!();
println!("Cell distribution:");
println!(" Floor: {}", self.cell_counts.floor);
println!(" Wall: {}", self.cell_counts.wall);
println!(" Cliff: {}", self.cell_counts.cliff);
println!(" Bump: {}", self.cell_counts.bump);
println!(" Unknown: {}", self.cell_counts.unknown);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_loop_closure_pr_perfect() {
let ground_truth = LoopClosureGroundTruth::from_pairs(vec![(0, 10), (5, 15)]);
let detections = vec![(0, 10, 0.9), (5, 15, 0.8)];
let pr = LoopClosurePR::compute(&detections, &ground_truth, 1.0);
assert_eq!(pr.true_positives, 2);
assert_eq!(pr.false_positives, 0);
assert_eq!(pr.false_negatives, 0);
assert!((pr.precision - 1.0).abs() < 1e-5);
assert!((pr.recall - 1.0).abs() < 1e-5);
}
#[test]
fn test_loop_closure_pr_with_fp() {
let ground_truth = LoopClosureGroundTruth::from_pairs(vec![(0, 10)]);
let detections = vec![(0, 10, 0.9), (1, 20, 0.5)]; // Second is FP
let pr = LoopClosurePR::compute(&detections, &ground_truth, 1.0);
assert_eq!(pr.true_positives, 1);
assert_eq!(pr.false_positives, 1);
assert!((pr.precision - 0.5).abs() < 1e-5);
assert!((pr.recall - 1.0).abs() < 1e-5);
}
#[test]
fn test_loop_closure_pr_with_fn() {
let ground_truth = LoopClosureGroundTruth::from_pairs(vec![(0, 10), (5, 15)]);
let detections = vec![(0, 10, 0.9)]; // Missing second closure
let pr = LoopClosurePR::compute(&detections, &ground_truth, 1.0);
assert_eq!(pr.true_positives, 1);
assert_eq!(pr.false_negatives, 1);
assert!((pr.precision - 1.0).abs() < 1e-5);
assert!((pr.recall - 0.5).abs() < 1e-5);
}
#[test]
fn test_ground_truth_from_trajectory() {
let poses = vec![
Pose2D::new(0.0, 0.0, 0.0),
Pose2D::new(1.0, 0.0, 0.0),
Pose2D::new(2.0, 0.0, 0.0),
Pose2D::new(2.0, 1.0, 0.0),
Pose2D::new(1.0, 1.0, 0.0),
Pose2D::new(0.0, 1.0, 0.0),
Pose2D::new(0.0, 0.5, 0.0), // Close to pose[0]
];
let gt = LoopClosureGroundTruth::from_trajectory(&poses, 3, 1.0);
// Should detect closure between pose[0] and pose[6]
assert!(!gt.closures.is_empty());
assert!(gt.closures.iter().any(|&(a, b)| a == 0 && b == 6));
}
#[test]
fn test_empty_metrics() {
let ground_truth = LoopClosureGroundTruth::from_pairs(vec![]);
let detections: Vec<(usize, usize, f32)> = vec![];
let pr = LoopClosurePR::compute(&detections, &ground_truth, 1.0);
assert!((pr.precision - 1.0).abs() < 1e-5);
assert!((pr.recall - 1.0).abs() < 1e-5);
}
}
@@ -0,0 +1,367 @@
//! Pose relations metrics for Cartographer-style evaluation.
//!
//! This module provides the core accuracy evaluation following Google Cartographer's
//! `compute_relations_metrics` approach.
//!
//! ## Concept
//!
//! For each pose relation in the ground truth, we:
//! 1. Look up the corresponding poses in the test trajectory
//! 2. Compute the relative pose from the test trajectory
//! 3. Compare against the ground truth relative pose
//! 4. Aggregate errors into statistics
//!
//! This approach evaluates trajectory **consistency** rather than absolute accuracy,
//! which works without external ground truth.
use super::ground_truth::{GroundTruthRelations, PoseRelation};
use crate::Pose2D;
use crate::core::normalize_angle;
use serde::{Deserialize, Serialize};
/// Error for a single pose relation.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct RelationError {
/// Translational error squared (meters²).
pub translational_squared: f32,
/// Rotational error squared (radians²).
pub rotational_squared: f32,
/// Covered distance for this relation (for weighting).
pub covered_distance: f32,
}
impl RelationError {
/// Compute the error between a ground truth relation and test trajectory.
pub fn compute(ground_truth: &PoseRelation, test_trajectory: &[Pose2D]) -> Option<Self> {
let from_idx = ground_truth.from_node;
let to_idx = ground_truth.to_node;
if from_idx >= test_trajectory.len() || to_idx >= test_trajectory.len() {
return None;
}
let test_from = &test_trajectory[from_idx];
let test_to = &test_trajectory[to_idx];
// Compute relative pose in test trajectory
let test_relative = test_from.inverse().compose(test_to);
// Compare with ground truth relative pose
let trans_error_x = test_relative.x - ground_truth.relative_pose.x;
let trans_error_y = test_relative.y - ground_truth.relative_pose.y;
let rot_error = normalize_angle(test_relative.theta - ground_truth.relative_pose.theta);
Some(Self {
translational_squared: trans_error_x * trans_error_x + trans_error_y * trans_error_y,
rotational_squared: rot_error * rot_error,
covered_distance: ground_truth.covered_distance,
})
}
/// Get translational error in meters.
pub fn translational(&self) -> f32 {
self.translational_squared.sqrt()
}
/// Get rotational error in radians.
pub fn rotational(&self) -> f32 {
self.rotational_squared.sqrt()
}
}
/// Aggregated metrics from pose relations (Cartographer output format).
///
/// This struct contains the same metrics that Cartographer's
/// `compute_relations_metrics` outputs.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct RelationsMetrics {
// === Translational error ===
/// Mean absolute translational error (meters).
pub abs_trans_error: f32,
/// Standard deviation of translational error (meters).
pub abs_trans_std: f32,
/// Mean squared translational error (m²).
pub sqr_trans_error: f32,
/// Standard deviation of squared translational error (m²).
pub sqr_trans_std: f32,
// === Rotational error ===
/// Mean absolute rotational error (degrees).
pub abs_rot_error: f32,
/// Standard deviation of rotational error (degrees).
pub abs_rot_std: f32,
/// Mean squared rotational error (deg²).
pub sqr_rot_error: f32,
/// Standard deviation of squared rotational error (deg²).
pub sqr_rot_std: f32,
// === Summary ===
/// Number of relations evaluated.
pub num_relations: usize,
/// Number of outliers (errors exceeding threshold).
pub num_outliers: usize,
/// Whether the metrics pass the target threshold.
pub passed_threshold: bool,
/// Threshold used for pass/fail (meters).
pub threshold_meters: f32,
}
impl RelationsMetrics {
/// Compute metrics by comparing test trajectory to ground truth relations.
///
/// This is equivalent to Cartographer's `compute_relations_metrics`.
///
/// # Arguments
///
/// * `test_trajectory` - The trajectory to evaluate
/// * `ground_truth` - Ground truth relations from an optimized trajectory
///
/// # Returns
///
/// Aggregated accuracy metrics.
pub fn compute(test_trajectory: &[Pose2D], ground_truth: &GroundTruthRelations) -> Self {
Self::compute_with_threshold(test_trajectory, ground_truth, 0.01) // 1cm default
}
/// Compute metrics with a custom pass/fail threshold.
pub fn compute_with_threshold(
test_trajectory: &[Pose2D],
ground_truth: &GroundTruthRelations,
threshold_meters: f32,
) -> Self {
if ground_truth.is_empty() {
return Self {
threshold_meters,
passed_threshold: true,
..Default::default()
};
}
// Compute errors for all relations
let errors: Vec<RelationError> = ground_truth
.relations
.iter()
.filter_map(|rel| RelationError::compute(rel, test_trajectory))
.collect();
if errors.is_empty() {
return Self {
threshold_meters,
passed_threshold: true,
..Default::default()
};
}
let n = errors.len() as f32;
// Compute translational statistics
let trans_errors: Vec<f32> = errors.iter().map(|e| e.translational()).collect();
let sqr_trans: Vec<f32> = errors.iter().map(|e| e.translational_squared).collect();
let abs_trans_error = trans_errors.iter().sum::<f32>() / n;
let sqr_trans_error = sqr_trans.iter().sum::<f32>() / n;
let abs_trans_std = std_dev(&trans_errors, abs_trans_error);
let sqr_trans_std = std_dev(&sqr_trans, sqr_trans_error);
// Compute rotational statistics (convert to degrees)
let rot_errors: Vec<f32> = errors.iter().map(|e| e.rotational().to_degrees()).collect();
let sqr_rot: Vec<f32> = errors
.iter()
.map(|e| e.rotational_squared.to_degrees().powi(2))
.collect();
let abs_rot_error = rot_errors.iter().sum::<f32>() / n;
let sqr_rot_error = sqr_rot.iter().sum::<f32>() / n;
let abs_rot_std = std_dev(&rot_errors, abs_rot_error);
let sqr_rot_std = std_dev(&sqr_rot, sqr_rot_error);
// Count outliers
let num_outliers = trans_errors
.iter()
.filter(|&&e| e > ground_truth.config.outlier_threshold_meters)
.count();
Self {
abs_trans_error,
abs_trans_std,
sqr_trans_error,
sqr_trans_std,
abs_rot_error,
abs_rot_std,
sqr_rot_error,
sqr_rot_std,
num_relations: errors.len(),
num_outliers,
passed_threshold: abs_trans_error < threshold_meters,
threshold_meters,
}
}
/// Pretty print metrics in Cartographer format.
pub fn print(&self) {
println!("=== Pose Relations Metrics ===");
println!(
"Abs translational error {:.5} +/- {:.5} m",
self.abs_trans_error, self.abs_trans_std
);
println!(
"Sqr translational error {:.5} +/- {:.5} m²",
self.sqr_trans_error, self.sqr_trans_std
);
println!(
"Abs rotational error {:.5} +/- {:.5} deg",
self.abs_rot_error, self.abs_rot_std
);
println!(
"Sqr rotational error {:.5} +/- {:.5} deg²",
self.sqr_rot_error, self.sqr_rot_std
);
println!("Relations evaluated: {}", self.num_relations);
println!("Outliers: {}", self.num_outliers);
println!(
"Status: {} (threshold: {}m)",
if self.passed_threshold {
"PASSED"
} else {
"FAILED"
},
self.threshold_meters
);
}
/// Format as a single-line summary.
pub fn summary(&self) -> String {
format!(
"trans: {:.4}±{:.4}m, rot: {:.2}±{:.2}°, {} relations, {}",
self.abs_trans_error,
self.abs_trans_std,
self.abs_rot_error,
self.abs_rot_std,
self.num_relations,
if self.passed_threshold {
"PASS"
} else {
"FAIL"
}
)
}
}
/// Compute standard deviation given values and their mean.
fn std_dev(values: &[f32], mean: f32) -> f32 {
if values.len() < 2 {
return 0.0;
}
let variance =
values.iter().map(|v| (v - mean).powi(2)).sum::<f32>() / (values.len() - 1) as f32;
variance.sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::evaluation::ground_truth::GroundTruthConfig;
fn create_straight_trajectory(n: usize, spacing: f32) -> Vec<Pose2D> {
(0..n)
.map(|i| Pose2D::new(i as f32 * spacing, 0.0, 0.0))
.collect()
}
#[test]
fn test_perfect_trajectory() {
let trajectory = create_straight_trajectory(10, 0.5);
let config = GroundTruthConfig {
min_covered_distance: 0.5,
..Default::default()
};
let ground_truth = GroundTruthRelations::generate(&trajectory, &[], config);
// Same trajectory should have zero error
let metrics = RelationsMetrics::compute(&trajectory, &ground_truth);
assert!(metrics.abs_trans_error < 1e-5);
assert!(metrics.abs_rot_error < 1e-5);
assert!(metrics.passed_threshold);
}
#[test]
fn test_trajectory_with_drift() {
let ground_trajectory = create_straight_trajectory(10, 0.5);
let config = GroundTruthConfig {
min_covered_distance: 0.5,
..Default::default()
};
let ground_truth = GroundTruthRelations::generate(&ground_trajectory, &[], config);
// Test trajectory with small drift
let test_trajectory: Vec<Pose2D> = ground_trajectory
.iter()
.enumerate()
.map(|(i, p)| Pose2D::new(p.x + 0.001 * i as f32, p.y, p.theta))
.collect();
let metrics = RelationsMetrics::compute(&test_trajectory, &ground_truth);
// Should have small but non-zero error
assert!(metrics.abs_trans_error > 0.0);
assert!(metrics.abs_trans_error < 0.01); // Less than 1cm
}
#[test]
fn test_relation_error_computation() {
let from_pose = Pose2D::new(0.0, 0.0, 0.0);
let to_pose = Pose2D::new(1.0, 0.0, 0.0);
let relation = PoseRelation::from_poses(0, 1, &from_pose, &to_pose, 1.0);
// Perfect match
let test_trajectory = vec![from_pose, to_pose];
let error = RelationError::compute(&relation, &test_trajectory).unwrap();
assert!(error.translational() < 1e-5);
assert!(error.rotational() < 1e-5);
}
#[test]
fn test_std_dev() {
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let mean = 3.0;
let std = std_dev(&values, mean);
// Standard deviation should be sqrt(2.5) ≈ 1.58
assert!((std - 1.5811).abs() < 0.01);
}
#[test]
fn test_empty_ground_truth() {
let trajectory = create_straight_trajectory(10, 0.5);
let config = GroundTruthConfig {
min_covered_distance: 100.0, // Very high, so no relations
..Default::default()
};
let ground_truth = GroundTruthRelations::generate(&trajectory, &[], config);
let metrics = RelationsMetrics::compute(&trajectory, &ground_truth);
assert_eq!(metrics.num_relations, 0);
assert!(metrics.passed_threshold);
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
//! Types used by grid storage.
use crate::core::CellType;
use serde::{Deserialize, Serialize};
/// Mutable reference to a single cell's fields in the SoA storage.
///
@@ -84,7 +85,7 @@ impl<'a> CellMut<'a> {
}
/// Cell counts by type.
#[derive(Clone, Copy, Debug, Default)]
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
pub struct CellCounts {
/// Unknown cells (not yet observed).
pub unknown: usize,
+3
View File
@@ -67,6 +67,9 @@ pub mod modes;
// Persistence (save/load)
pub mod io;
// Evaluation framework (Cartographer-style)
pub mod evaluation;
// Re-export commonly used types
pub use core::{
BumperSensors, Cell, CellType, CliffSensors, GridCoord, LidarScan, MotionModel, Odometry,
+273
View File
@@ -0,0 +1,273 @@
//! Accuracy integration tests for VastuSLAM.
//!
//! These tests verify SLAM accuracy using the Cartographer-style pose relations metric.
mod common;
use vastu_slam::Pose2D;
use vastu_slam::evaluation::{
AbsoluteTrajectoryError, AccuracyMetrics, GroundTruthConfig, GroundTruthRelations,
RelationsMetrics, RelativePoseError,
};
/// Target accuracy: < 1cm translational error.
const TARGET_TRANS_ERROR_M: f32 = 0.01;
/// Target accuracy: < 0.5 degrees rotational error.
const TARGET_ROT_ERROR_DEG: f32 = 0.5;
// ============================================================================
// Pose Relations Metric Tests (Cartographer-style)
// ============================================================================
#[test]
fn test_relations_metric_perfect_trajectory() {
// Ground truth: straight trajectory
let trajectory = common::straight_trajectory(20, 0.5);
let config = GroundTruthConfig {
min_covered_distance: 0.5,
node_sampling_interval: 1,
..Default::default()
};
let ground_truth = GroundTruthRelations::generate(&trajectory, &[], config);
// Test same trajectory - should have zero error
let metrics = RelationsMetrics::compute(&trajectory, &ground_truth);
assert!(
metrics.abs_trans_error < 1e-5,
"Perfect trajectory should have zero translational error, got: {}",
metrics.abs_trans_error
);
assert!(
metrics.abs_rot_error < 1e-5,
"Perfect trajectory should have zero rotational error, got: {}",
metrics.abs_rot_error
);
assert!(metrics.passed_threshold);
}
#[test]
fn test_relations_metric_with_noise() {
let ground_trajectory = common::straight_trajectory(50, 0.2);
let config = GroundTruthConfig {
min_covered_distance: 0.5,
..Default::default()
};
let ground_truth = GroundTruthRelations::generate(&ground_trajectory, &[], config);
// Add small noise (< 1mm)
let noisy_trajectory = common::add_noise(&ground_trajectory, 0.0005, 0.001, 42);
let metrics = RelationsMetrics::compute(&noisy_trajectory, &ground_truth);
println!("Noisy trajectory metrics: {}", metrics.summary());
// Should still be under target with small noise
assert!(
metrics.abs_trans_error < TARGET_TRANS_ERROR_M,
"Trans error {} should be < {}m",
metrics.abs_trans_error,
TARGET_TRANS_ERROR_M
);
}
#[test]
fn test_relations_metric_with_drift() {
let ground_trajectory = common::straight_trajectory(50, 0.2);
let config = GroundTruthConfig {
min_covered_distance: 0.5,
..Default::default()
};
let ground_truth = GroundTruthRelations::generate(&ground_trajectory, &[], config);
// Add drift (1mm per meter traveled)
let drifted_trajectory = common::add_drift(&ground_trajectory, 0.001);
let metrics = RelationsMetrics::compute(&drifted_trajectory, &ground_truth);
println!("Drifted trajectory metrics: {}", metrics.summary());
// With 1mm/m drift over 10m, expect about 5mm average error
assert!(
metrics.abs_trans_error < 0.01,
"Trans error {} with small drift should be < 1cm",
metrics.abs_trans_error
);
}
#[test]
fn test_relations_metric_square_loop() {
// Square trajectory with loop closure
let trajectory = common::square_trajectory(4.0, 10);
// Mark loop closure between first and last poses
let loop_closures = vec![(0, trajectory.len() - 1)];
let config = GroundTruthConfig {
min_covered_distance: 0.5,
..Default::default()
};
let ground_truth = GroundTruthRelations::generate(&trajectory, &loop_closures, config);
// Verify ground truth contains loop closure relation
assert!(
ground_truth
.relations
.iter()
.any(|r| r.covered_distance > 10.0),
"Should have at least one long-distance relation from loop closure"
);
// Test with perfect trajectory
let metrics = RelationsMetrics::compute(&trajectory, &ground_truth);
assert!(metrics.passed_threshold);
}
// ============================================================================
// ATE/RPE Tests (evo-compatible)
// ============================================================================
#[test]
fn test_ate_perfect_trajectory() {
let trajectory = common::straight_trajectory(20, 0.5);
let ate = AbsoluteTrajectoryError::compute(&trajectory, &trajectory);
assert!(
ate.translation.rmse < 1e-5,
"Perfect trajectory should have zero ATE"
);
}
#[test]
fn test_ate_with_constant_offset() {
let ground_truth = common::straight_trajectory(20, 0.5);
// Add constant offset
let estimated: Vec<Pose2D> = ground_truth
.iter()
.map(|p| Pose2D::new(p.x + 0.5, p.y + 0.3, p.theta))
.collect();
// Without alignment, should have significant error
let ate_no_align =
AbsoluteTrajectoryError::compute_with_options(&estimated, &ground_truth, false);
assert!(
ate_no_align.translation.rmse > 0.5,
"Without alignment, should have large error"
);
// With alignment, should be near zero
let ate_aligned = AbsoluteTrajectoryError::compute(&estimated, &ground_truth);
assert!(
ate_aligned.translation.rmse < 0.01,
"With SE(2) alignment, constant offset should be corrected"
);
}
#[test]
fn test_rpe_with_local_errors() {
let ground_truth = common::straight_trajectory(50, 0.2);
// Add noise to simulate local errors
let estimated = common::add_noise(&ground_truth, 0.002, 0.005, 123);
let rpe = RelativePoseError::compute(&estimated, &ground_truth, 1.0);
println!(
"RPE with noise: trans={:.4}m, rot={:.4}rad, pairs={}",
rpe.translation.rmse, rpe.rotation.rmse, rpe.pairs_evaluated
);
// RPE should detect local errors
assert!(rpe.pairs_evaluated > 0, "Should evaluate some pairs");
assert!(
rpe.translation.rmse < 0.01,
"With 2mm noise, RPE should be small"
);
}
#[test]
fn test_accuracy_metrics_all() {
let ground_truth = common::figure_eight_trajectory(2.0, 40);
let estimated = common::add_noise(&ground_truth, 0.001, 0.002, 456);
let metrics = AccuracyMetrics::compute(&estimated, &ground_truth, 1.0);
println!("=== Full Accuracy Report ===");
metrics.print();
assert!(
metrics.ate.translation.rmse < 0.02,
"ATE should be small with minor noise"
);
}
// ============================================================================
// Regression Tests
// ============================================================================
#[test]
fn test_accuracy_regression_straight_line() {
// This test ensures accuracy doesn't regress over time
let ground_truth = common::straight_trajectory(100, 0.1);
let config = GroundTruthConfig::default();
let gt_relations = GroundTruthRelations::generate(&ground_truth, &[], config);
// Simulate a reasonable SLAM output (small noise + small drift)
let estimated = common::add_drift(
&common::add_noise(&ground_truth, 0.001, 0.001, 789),
0.0005, // 0.5mm/m drift
);
let metrics =
RelationsMetrics::compute_with_threshold(&estimated, &gt_relations, TARGET_TRANS_ERROR_M);
println!(
"Regression test - Trans error: {:.4}m (target: <{}m)",
metrics.abs_trans_error, TARGET_TRANS_ERROR_M
);
println!(
"Regression test - Rot error: {:.4}deg (target: <{}deg)",
metrics.abs_rot_error, TARGET_ROT_ERROR_DEG
);
assert!(
metrics.passed_threshold,
"REGRESSION: Trans error {} exceeds target {}m",
metrics.abs_trans_error, TARGET_TRANS_ERROR_M
);
}
#[test]
fn test_accuracy_regression_square_loop() {
let ground_truth = common::square_trajectory(5.0, 20);
let loop_closures = vec![(0, ground_truth.len() - 1)];
let config = GroundTruthConfig::default();
let gt_relations = GroundTruthRelations::generate(&ground_truth, &loop_closures, config);
// Simulate SLAM with small errors (1mm position noise)
let estimated = common::add_noise(&ground_truth, 0.001, 0.002, 101);
let metrics =
RelationsMetrics::compute_with_threshold(&estimated, &gt_relations, TARGET_TRANS_ERROR_M);
println!(
"Square loop - Trans error: {:.4}m, Rot error: {:.4}deg",
metrics.abs_trans_error, metrics.abs_rot_error
);
assert!(
metrics.passed_threshold,
"REGRESSION: Square loop trans error {} exceeds target",
metrics.abs_trans_error
);
}
+250
View File
@@ -0,0 +1,250 @@
//! Test utilities for VastuSLAM evaluation.
//!
//! This module provides helpers for creating test trajectories, maps, and scenarios.
#![allow(dead_code)]
use std::f32::consts::FRAC_PI_2;
use vastu_slam::{LidarScan, Pose2D};
/// Create a straight-line trajectory.
pub fn straight_trajectory(n: usize, spacing: f32) -> Vec<Pose2D> {
(0..n)
.map(|i| Pose2D::new(i as f32 * spacing, 0.0, 0.0))
.collect()
}
/// Create a square loop trajectory.
pub fn square_trajectory(side_length: f32, points_per_side: usize) -> Vec<Pose2D> {
let mut poses = Vec::new();
let spacing = side_length / points_per_side as f32;
// Bottom side (left to right)
for i in 0..points_per_side {
poses.push(Pose2D::new(i as f32 * spacing, 0.0, 0.0));
}
// Right side (bottom to top)
for i in 0..points_per_side {
poses.push(Pose2D::new(side_length, i as f32 * spacing, FRAC_PI_2));
}
// Top side (right to left)
for i in 0..points_per_side {
poses.push(Pose2D::new(
side_length - i as f32 * spacing,
side_length,
std::f32::consts::PI,
));
}
// Left side (top to bottom, back to start)
for i in 0..points_per_side {
poses.push(Pose2D::new(
0.0,
side_length - i as f32 * spacing,
-FRAC_PI_2,
));
}
poses
}
/// Create a figure-8 trajectory.
pub fn figure_eight_trajectory(radius: f32, points: usize) -> Vec<Pose2D> {
let mut poses = Vec::new();
let half = points / 2;
// First circle (counter-clockwise)
for i in 0..half {
let angle = 2.0 * std::f32::consts::PI * i as f32 / half as f32;
let x = radius * (1.0 - angle.cos());
let y = radius * angle.sin();
let theta = angle + FRAC_PI_2;
poses.push(Pose2D::new(x, y, theta));
}
// Second circle (clockwise)
for i in 0..half {
let angle = 2.0 * std::f32::consts::PI * i as f32 / half as f32;
let x = 2.0 * radius + radius * (angle.cos() - 1.0);
let y = -radius * angle.sin();
let theta = -angle - FRAC_PI_2;
poses.push(Pose2D::new(x, y, theta));
}
poses
}
/// Add Gaussian noise to a trajectory.
pub fn add_noise(trajectory: &[Pose2D], trans_std: f32, rot_std: f32, seed: u64) -> Vec<Pose2D> {
use std::num::Wrapping;
// Simple LCG PRNG for reproducibility
let mut state = Wrapping(seed);
let a = Wrapping(1664525u64);
let c = Wrapping(1013904223u64);
let mut random = || -> f32 {
state = a * state + c;
// Box-Muller transform for Gaussian
let u1 = (state.0 & 0xFFFF) as f32 / 65536.0 + 0.0001;
state = a * state + c;
let u2 = (state.0 & 0xFFFF) as f32 / 65536.0;
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f32::consts::PI * u2).cos()
};
trajectory
.iter()
.map(|p| {
Pose2D::new(
p.x + random() * trans_std,
p.y + random() * trans_std,
p.theta + random() * rot_std,
)
})
.collect()
}
/// Add drift to a trajectory (cumulative error).
pub fn add_drift(trajectory: &[Pose2D], drift_per_m: f32) -> Vec<Pose2D> {
if trajectory.is_empty() {
return Vec::new();
}
let mut result = Vec::with_capacity(trajectory.len());
result.push(trajectory[0]);
let mut cumulative_drift = 0.0f32;
for i in 1..trajectory.len() {
let dist = trajectory[i].distance(&trajectory[i - 1]);
cumulative_drift += dist * drift_per_m;
let p = &trajectory[i];
result.push(Pose2D::new(p.x + cumulative_drift, p.y, p.theta));
}
result
}
/// Create a simple lidar scan for testing.
pub fn simple_scan(num_points: usize, max_range: f32) -> LidarScan {
let angle_increment = 2.0 * std::f32::consts::PI / num_points as f32;
let angles: Vec<f32> = (0..num_points)
.map(|i| i as f32 * angle_increment - std::f32::consts::PI)
.collect();
// Simple pattern: range varies with angle
let ranges: Vec<f32> = angles
.iter()
.map(|a| max_range * 0.5 * (1.0 + a.cos().abs()))
.collect();
LidarScan::new(ranges, angles, 0.15, max_range)
}
/// Create a scan that hits walls in a square room.
pub fn room_scan(
room_width: f32,
room_height: f32,
robot_x: f32,
robot_y: f32,
num_points: usize,
) -> LidarScan {
let angle_increment = 2.0 * std::f32::consts::PI / num_points as f32;
let max_range = (room_width * room_width + room_height * room_height).sqrt();
let mut ranges = Vec::with_capacity(num_points);
let mut angles = Vec::with_capacity(num_points);
for i in 0..num_points {
let angle = i as f32 * angle_increment - std::f32::consts::PI;
angles.push(angle);
// Ray cast to find wall intersection
let cos_a = angle.cos();
let sin_a = angle.sin();
let mut range = max_range;
// Check intersection with each wall
// Right wall (x = room_width)
if cos_a > 0.0 {
let t = (room_width - robot_x) / cos_a;
if t > 0.0 && t < range {
let y = robot_y + t * sin_a;
if y >= 0.0 && y <= room_height {
range = t;
}
}
}
// Left wall (x = 0)
if cos_a < 0.0 {
let t = -robot_x / cos_a;
if t > 0.0 && t < range {
let y = robot_y + t * sin_a;
if y >= 0.0 && y <= room_height {
range = t;
}
}
}
// Top wall (y = room_height)
if sin_a > 0.0 {
let t = (room_height - robot_y) / sin_a;
if t > 0.0 && t < range {
let x = robot_x + t * cos_a;
if x >= 0.0 && x <= room_width {
range = t;
}
}
}
// Bottom wall (y = 0)
if sin_a < 0.0 {
let t = -robot_y / sin_a;
if t > 0.0 && t < range {
let x = robot_x + t * cos_a;
if x >= 0.0 && x <= room_width {
range = t;
}
}
}
ranges.push(range.min(max_range));
}
LidarScan::new(ranges, angles, 0.15, max_range)
}
/// Assert two poses are approximately equal.
pub fn assert_poses_close(a: &Pose2D, b: &Pose2D, trans_tol: f32, rot_tol: f32) {
let trans_error = a.distance(b);
let rot_error = (a.theta - b.theta).abs();
assert!(
trans_error < trans_tol,
"Translation error {} exceeds tolerance {}",
trans_error,
trans_tol
);
assert!(
rot_error < rot_tol,
"Rotation error {} exceeds tolerance {}",
rot_error,
rot_tol
);
}
/// Assert trajectory ATE is under threshold.
pub fn assert_ate_under(estimated: &[Pose2D], ground_truth: &[Pose2D], threshold: f32) {
use vastu_slam::evaluation::AbsoluteTrajectoryError;
let ate = AbsoluteTrajectoryError::compute(estimated, ground_truth);
assert!(
ate.translation.rmse < threshold,
"ATE RMSE {} exceeds threshold {}",
ate.translation.rmse,
threshold
);
}
+280
View File
@@ -0,0 +1,280 @@
//! Performance integration tests for VastuSLAM.
//!
//! These tests verify that SLAM operations meet performance targets.
mod common;
use std::time::{Duration, Instant};
use vastu_slam::evaluation::{OperationTiming, PerformanceTracker};
use vastu_slam::{
CorrelativeMatcher, CorrelativeMatcherConfig, MapConfig, OccupancyGridMap, Pose2D,
};
/// Performance thresholds (in milliseconds) for release builds.
/// Debug builds are expected to be ~5-10x slower.
const SCAN_MATCHING_THRESHOLD_MS: f32 = 15.0;
const MAP_UPDATE_THRESHOLD_MS: f32 = 2.0;
/// Multiplier for debug mode thresholds.
#[cfg(debug_assertions)]
const THRESHOLD_MULTIPLIER: f32 = 10.0;
#[cfg(not(debug_assertions))]
const THRESHOLD_MULTIPLIER: f32 = 1.0;
// ============================================================================
// Operation Timing Tests
// ============================================================================
#[test]
fn test_scan_matching_performance() {
let config = MapConfig::default();
let mut map = OccupancyGridMap::new(config);
// Initialize map with some data
for _ in 0..10 {
let pose = Pose2D::new(0.0, 0.0, 0.0);
let scan = common::room_scan(6.0, 6.0, 3.0, 3.0, 360);
map.observe_lidar(&scan, pose);
}
let matcher_config = CorrelativeMatcherConfig::default();
let matcher = CorrelativeMatcher::new(matcher_config);
let scan = common::room_scan(6.0, 6.0, 3.1, 3.0, 360);
let initial_pose = Pose2D::new(3.0, 3.0, 0.0);
// Warm-up
for _ in 0..3 {
let _ = matcher.match_scan(&scan, initial_pose, map.storage());
}
// Measure
let mut times = Vec::new();
for _ in 0..20 {
let start = Instant::now();
let _ = matcher.match_scan(&scan, initial_pose, map.storage());
times.push(start.elapsed());
}
let timing = OperationTiming::from_durations("scan_matching", &times);
println!("Scan matching: {}", timing.summary());
let threshold = SCAN_MATCHING_THRESHOLD_MS * THRESHOLD_MULTIPLIER;
assert!(
timing.mean_ms < threshold,
"Scan matching mean {:.2}ms exceeds threshold {}ms",
timing.mean_ms,
threshold
);
}
#[test]
fn test_map_update_performance() {
let config = MapConfig::default();
let mut map = OccupancyGridMap::new(config);
let scan = common::room_scan(10.0, 10.0, 5.0, 5.0, 360);
let pose = Pose2D::new(5.0, 5.0, 0.0);
// Warm-up
for _ in 0..3 {
map.observe_lidar(&scan, pose);
}
map.clear();
// Measure
let mut times = Vec::new();
for i in 0..50 {
let p = Pose2D::new(5.0 + (i as f32 * 0.01), 5.0, 0.0);
let start = Instant::now();
map.observe_lidar(&scan, p);
times.push(start.elapsed());
}
let timing = OperationTiming::from_durations("map_update", &times);
println!("Map update: {}", timing.summary());
let threshold = MAP_UPDATE_THRESHOLD_MS * THRESHOLD_MULTIPLIER;
assert!(
timing.mean_ms < threshold,
"Map update mean {:.2}ms exceeds threshold {}ms",
timing.mean_ms,
threshold
);
}
// ============================================================================
// Performance Tracker Tests
// ============================================================================
#[test]
fn test_performance_tracker_integration() {
let mut tracker = PerformanceTracker::new();
tracker.start();
let config = MapConfig::default();
let mut map = OccupancyGridMap::new(config);
let matcher_config = CorrelativeMatcherConfig::default();
let matcher = CorrelativeMatcher::new(matcher_config);
// Simulate a SLAM session
for i in 0..20 {
let pose = Pose2D::new(i as f32 * 0.2, 0.0, 0.0);
let scan = common::room_scan(8.0, 8.0, 4.0 + i as f32 * 0.2, 4.0, 360);
// Time scan matching
let match_start = Instant::now();
let _ = matcher.match_scan(&scan, pose, map.storage());
tracker.record("scan_matching", match_start.elapsed());
// Time map update
let update_start = Instant::now();
map.observe_lidar(&scan, pose);
tracker.record("map_update", update_start.elapsed());
tracker.record_scan();
}
let metrics = tracker.metrics();
println!("\n=== Performance Report ===");
metrics.print();
// Verify we have timing data
assert!(
metrics.operations.contains_key("scan_matching"),
"Should have scan matching timing"
);
assert!(
metrics.operations.contains_key("map_update"),
"Should have map update timing"
);
assert_eq!(metrics.num_scans, 20);
assert!(metrics.scans_per_second > 0.0);
}
#[test]
fn test_threshold_violations() {
let mut tracker = PerformanceTracker::new();
// Simulate some operations
tracker.record("fast_op", Duration::from_millis(5));
tracker.record("slow_op", Duration::from_millis(50));
let metrics = tracker.metrics();
let mut thresholds = std::collections::HashMap::new();
thresholds.insert("fast_op".to_string(), 10.0);
thresholds.insert("slow_op".to_string(), 20.0);
let violations = metrics.check_thresholds(&thresholds);
assert_eq!(violations.len(), 1, "Should have one violation");
assert!(
violations[0].contains("slow_op"),
"Violation should be for slow_op"
);
}
// ============================================================================
// Real-time Factor Tests
// ============================================================================
#[test]
fn test_realtime_factor() {
let mut tracker = PerformanceTracker::with_scan_interval(Duration::from_millis(200)); // 5Hz
tracker.start();
let config = MapConfig::default();
let mut map = OccupancyGridMap::new(config);
// Simulate processing faster than real-time
for i in 0..50 {
let pose = Pose2D::new(i as f32 * 0.1, 0.0, 0.0);
let scan = common::simple_scan(360, 8.0);
let start = Instant::now();
map.observe_lidar(&scan, pose);
tracker.record("total", start.elapsed());
tracker.record_scan();
}
let metrics = tracker.metrics();
println!(
"Real-time factor: {:.2}x (scans/sec: {:.1})",
metrics.real_time_factor, metrics.scans_per_second
);
// Should be faster than real-time
assert!(
metrics.real_time_factor > 1.0,
"Should process faster than real-time, got {:.2}x",
metrics.real_time_factor
);
}
// ============================================================================
// Stress Tests
// ============================================================================
#[test]
fn test_sustained_performance() {
let mut tracker = PerformanceTracker::new();
tracker.start();
let config = MapConfig::default();
let mut map = OccupancyGridMap::new(config);
// Process many scans to check for performance degradation
let num_scans = 100;
let mut early_times = Vec::new();
let mut late_times = Vec::new();
for i in 0..num_scans {
let angle = i as f32 * 0.1;
let x = 5.0 + 3.0 * angle.cos();
let y = 5.0 + 3.0 * angle.sin();
let pose = Pose2D::new(x, y, angle);
let scan = common::room_scan(10.0, 10.0, x, y, 360);
let start = Instant::now();
map.observe_lidar(&scan, pose);
let elapsed = start.elapsed();
tracker.record("map_update", elapsed);
tracker.record_scan();
if i < 10 {
early_times.push(elapsed);
} else if i >= num_scans - 10 {
late_times.push(elapsed);
}
}
let early_avg =
early_times.iter().sum::<Duration>().as_secs_f32() / early_times.len() as f32 * 1000.0;
let late_avg =
late_times.iter().sum::<Duration>().as_secs_f32() / late_times.len() as f32 * 1000.0;
println!(
"Early avg: {:.3}ms, Late avg: {:.3}ms (ratio: {:.2}x)",
early_avg,
late_avg,
late_avg / early_avg
);
// Performance shouldn't degrade by more than 2x
assert!(
late_avg < early_avg * 2.0,
"Performance degradation too high: early={:.2}ms, late={:.2}ms",
early_avg,
late_avg
);
let metrics = tracker.metrics();
println!("\nSustained performance report:");
metrics.print();
}
+267
View File
@@ -0,0 +1,267 @@
//! Quality integration tests for VastuSLAM.
//!
//! These tests verify map quality and loop closure detection quality.
mod common;
use vastu_slam::evaluation::{LoopClosureGroundTruth, LoopClosurePR, MapQualityMetrics};
use vastu_slam::{MapConfig, OccupancyGridMap, Pose2D};
// ============================================================================
// Loop Closure Quality Tests
// ============================================================================
#[test]
fn test_loop_closure_pr_perfect_detection() {
// Ground truth: these are the actual loop closures
let ground_truth = LoopClosureGroundTruth::from_pairs(vec![(0, 50), (10, 60), (20, 70)]);
// Detections: perfectly detected all loop closures
let detections = vec![
(0, 50, 0.95), // Correct
(10, 60, 0.90), // Correct
(20, 70, 0.85), // Correct
];
let pr = LoopClosurePR::compute(&detections, &ground_truth, 1.0);
println!("Perfect detection PR:");
pr.print();
assert_eq!(pr.true_positives, 3);
assert_eq!(pr.false_positives, 0);
assert_eq!(pr.false_negatives, 0);
assert!((pr.precision - 1.0).abs() < 1e-5);
assert!((pr.recall - 1.0).abs() < 1e-5);
}
#[test]
fn test_loop_closure_pr_with_false_positives() {
let ground_truth = LoopClosureGroundTruth::from_pairs(vec![(0, 50), (10, 60)]);
// Detections include a false positive
let detections = vec![
(0, 50, 0.95), // Correct
(10, 60, 0.90), // Correct
(30, 80, 0.70), // False positive
];
let pr = LoopClosurePR::compute(&detections, &ground_truth, 1.0);
println!("With false positive PR:");
pr.print();
assert_eq!(pr.true_positives, 2);
assert_eq!(pr.false_positives, 1);
assert!((pr.precision - 2.0 / 3.0).abs() < 1e-5);
assert!((pr.recall - 1.0).abs() < 1e-5);
}
#[test]
fn test_loop_closure_pr_with_false_negatives() {
let ground_truth = LoopClosureGroundTruth::from_pairs(vec![(0, 50), (10, 60), (20, 70)]);
// Only detected 2 out of 3 closures
let detections = vec![
(0, 50, 0.95),
(10, 60, 0.90),
// Missing (20, 70)
];
let pr = LoopClosurePR::compute(&detections, &ground_truth, 1.0);
println!("With false negative PR:");
pr.print();
assert_eq!(pr.true_positives, 2);
assert_eq!(pr.false_negatives, 1);
assert!((pr.precision - 1.0).abs() < 1e-5);
assert!((pr.recall - 2.0 / 3.0).abs() < 1e-5);
}
#[test]
fn test_loop_closure_ground_truth_from_trajectory() {
// Create a trajectory that returns to start
let trajectory = common::square_trajectory(4.0, 10);
// Generate ground truth based on spatial proximity
let ground_truth = LoopClosureGroundTruth::from_trajectory(&trajectory, 20, 0.5);
println!(
"Generated {} loop closure ground truths from trajectory",
ground_truth.closures.len()
);
// Should find at least one closure (start and end are close)
assert!(
!ground_truth.closures.is_empty(),
"Should find loop closures in square trajectory"
);
}
#[test]
fn test_max_recall_at_100_precision() {
let ground_truth =
LoopClosureGroundTruth::from_pairs(vec![(0, 50), (10, 60), (20, 70), (30, 80)]);
// Detections sorted by score - first 2 are correct, 3rd is FP
let detections = vec![
(0, 50, 0.99), // Correct
(10, 60, 0.95), // Correct
(999, 888, 0.90), // False positive
(20, 70, 0.85), // Correct but after FP
];
let pr = LoopClosurePR::compute(&detections, &ground_truth, 1.0);
println!(
"Max recall @ 100% precision: {:.2}%",
pr.max_recall_at_100_precision * 100.0
);
// Max recall at 100% precision is 2/4 = 50% (before the FP)
assert!((pr.max_recall_at_100_precision - 0.5).abs() < 1e-5);
}
// ============================================================================
// Map Quality Tests
// ============================================================================
#[test]
fn test_map_quality_empty_map() {
let config = MapConfig::default();
let map = OccupancyGridMap::new(config);
let quality = MapQualityMetrics::compute(map.storage());
assert_eq!(quality.coverage_m2, 0.0);
assert_eq!(quality.known_percentage, 0.0);
}
#[test]
fn test_map_quality_with_room() {
let config = MapConfig::default();
let mut map = OccupancyGridMap::new(config);
// Create a simple room map
let scan = common::room_scan(6.0, 6.0, 3.0, 3.0, 360);
// Multiple observations for better quality
for i in 0..10 {
let angle = i as f32 * 0.1;
let pose = Pose2D::new(3.0, 3.0, angle);
map.observe_lidar(&scan, pose);
}
let quality = MapQualityMetrics::compute(map.storage());
println!("Room map quality:");
quality.print();
// Should have explored some area
assert!(quality.coverage_m2 > 0.0, "Should have explored area");
assert!(quality.known_percentage > 0.0, "Should have known cells");
assert!(quality.cell_counts.floor > 0, "Should have floor cells");
assert!(quality.cell_counts.wall > 0, "Should have wall cells");
}
#[test]
fn test_map_quality_wall_sharpness() {
let config = MapConfig::default();
let mut map = OccupancyGridMap::new(config);
// Create clean room with sharp walls
let scan = common::room_scan(6.0, 6.0, 3.0, 3.0, 720); // Higher resolution
let _base_pose = Pose2D::new(3.0, 3.0, 0.0);
for i in 0..20 {
let angle = i as f32 * 0.05;
let p = Pose2D::new(3.0, 3.0, angle);
map.observe_lidar(&scan, p);
}
let quality = MapQualityMetrics::compute(map.storage());
println!("Wall sharpness: {:.3}", quality.wall_sharpness);
println!(
"Avg wall thickness: {:.2} cells",
quality.avg_wall_thickness
);
// Clean scans should produce relatively sharp walls
// This is more of a baseline than a strict requirement
assert!(quality.wall_sharpness >= 0.0 && quality.wall_sharpness <= 1.0);
}
// ============================================================================
// Combined Quality Tests
// ============================================================================
#[test]
fn test_quality_trajectory_and_map() {
let config = MapConfig::default();
let mut map = OccupancyGridMap::new(config);
// Simulate robot moving through a room
let trajectory = common::square_trajectory(4.0, 10);
for (i, pose) in trajectory.iter().enumerate() {
// Robot at center of 8x8 room
let scan = common::room_scan(8.0, 8.0, pose.x + 4.0, pose.y + 4.0, 360);
map.observe_lidar(&scan, Pose2D::new(pose.x + 4.0, pose.y + 4.0, pose.theta));
// Check quality periodically
if i % 10 == 9 {
let quality = MapQualityMetrics::compute(map.storage());
println!(
"Step {}: coverage={:.2}m², known={:.1}%",
i + 1,
quality.coverage_m2,
quality.known_percentage
);
}
}
let final_quality = MapQualityMetrics::compute(map.storage());
println!("\n=== Final Map Quality ===");
final_quality.print();
// After full trajectory, should have good coverage
assert!(
final_quality.coverage_m2 > 10.0,
"Should have significant coverage after full trajectory"
);
}
// ============================================================================
// Regression Tests
// ============================================================================
#[test]
fn test_quality_regression_basic_room() {
let config = MapConfig::default();
let mut map = OccupancyGridMap::new(config);
// Standard room scan
let scan = common::room_scan(6.0, 6.0, 3.0, 3.0, 360);
let robot_pose = Pose2D::new(3.0, 3.0, 0.0);
for _ in 0..5 {
map.observe_lidar(&scan, robot_pose);
}
let quality = MapQualityMetrics::compute(map.storage());
// Baseline expectations for a 6x6 room
assert!(
quality.cell_counts.wall > 100,
"Should detect sufficient wall cells, got {}",
quality.cell_counts.wall
);
assert!(
quality.cell_counts.floor > 1000,
"Should detect sufficient floor cells, got {}",
quality.cell_counts.floor
);
}