Add robust ICP with M-estimators, multi-resolution matching, and RANSAC line extraction

Major improvements to scan matching accuracy and convergence:

- Add Levenberg-Marquardt damping to Gauss-Newton solver for robust convergence
  from poor initial guesses (replaces pure Gauss-Newton)
- Add IRLS (Iteratively Reweighted Least Squares) with M-estimators:
  Huber (default), Cauchy, Tukey, Geman-McClure for soft outlier rejection
- Add multi-resolution ICP (coarse-to-fine) for better convergence
- Add corner correspondences for point-to-point constraints
- Add DOF-corrected covariance computation for accurate uncertainty

Line extraction improvements:
- Add RANSAC-based line extraction for robustness to outliers
- Add hybrid extraction mode (RANSAC + split-merge)

New modules:
- src/matching/robust.rs: M-estimator cost functions
- src/motion_model.rs: Constant-velocity motion model for pose prediction
- src/extraction/ransac_lines.rs: RANSAC line extraction

Test harness:
- Add fast mode (~22x speedup) for integration tests
- Simplify observation records
This commit is contained in:
codetiger
2025-12-20 22:39:27 +05:30
parent 17cb7b9128
commit a21dfcdd3e
18 changed files with 2391 additions and 322 deletions
+77
View File
@@ -7,6 +7,8 @@
//!
//! - **Line Fitting**: Total Least Squares (orthogonal regression), with weighted variant
//! - **Line Extraction**: Split-and-Merge algorithm for ordered point sequences
//! - **RANSAC Lines**: Random Sample Consensus for robust line extraction
//! - **Hybrid Extraction**: RANSAC for dominant lines, then split-merge for remaining
//! - **Corner Detection**: Find corners at line intersections
//!
//! # Weighted Line Fitting
@@ -22,9 +24,26 @@
//! let points = vec![/* lidar hits */];
//! let line = fit_line_from_sensor(&points, sensor_pos, None);
//! ```
//!
//! # Hybrid Extraction
//!
//! The hybrid approach combines RANSAC's robustness with split-merge's
//! ability to handle structured point sequences:
//!
//! ```rust,ignore
//! use vastu_map::extraction::{extract_lines_hybrid, RansacLineConfig, SplitMergeConfig};
//!
//! let ransac_config = RansacLineConfig::default();
//! let split_merge_config = SplitMergeConfig::default();
//! let lines = extract_lines_hybrid(&points, &ransac_config, &split_merge_config);
//! ```
use crate::core::Point2D;
use crate::features::Line2D;
pub mod corner_detection;
pub mod line_fitting;
pub mod ransac_lines;
pub mod split_merge;
pub mod traits;
@@ -36,9 +55,67 @@ pub use line_fitting::{
compute_range_weights, fit_line, fit_line_from_sensor, fit_line_segment, fit_line_weighted,
fitting_error, fitting_error_weighted, max_distance_point,
};
// Re-export from ransac_lines module
pub use ransac_lines::{RansacLineConfig, extract_lines_ransac};
// Re-export from split_merge submodule
pub use split_merge::{
SplitMergeConfig, adaptive_split_threshold, extract_lines, extract_lines_from_sensor,
};
// Re-export traits
pub use traits::{LineExtractor, SplitMergeExtractor};
/// Extract lines using hybrid RANSAC + split-merge approach.
///
/// This method combines RANSAC's robustness to outliers with split-merge's
/// ability to handle structured point sequences:
///
/// 1. **RANSAC first**: Extracts dominant lines that may span across gaps
/// 2. **Split-merge second**: Processes remaining points for additional structure
///
/// This is useful when:
/// - Point cloud has significant outliers (RANSAC handles these)
/// - Some features are weak/fragmented (split-merge catches these)
/// - You want the best of both algorithms
///
/// # Arguments
/// * `points` - Input point cloud
/// * `ransac_config` - Configuration for RANSAC extraction
/// * `split_merge_config` - Configuration for split-merge extraction
///
/// # Returns
/// Combined list of extracted lines from both algorithms.
///
/// # Example
/// ```rust,ignore
/// use vastu_map::extraction::{extract_lines_hybrid, RansacLineConfig, SplitMergeConfig};
///
/// let ransac_config = RansacLineConfig::default()
/// .with_max_lines(5) // Limit RANSAC to 5 dominant lines
/// .with_inlier_threshold(0.02);
///
/// let split_merge_config = SplitMergeConfig::default();
///
/// let all_lines = extract_lines_hybrid(&points, &ransac_config, &split_merge_config);
/// ```
pub fn extract_lines_hybrid(
points: &[Point2D],
ransac_config: &RansacLineConfig,
split_merge_config: &SplitMergeConfig,
) -> Vec<Line2D> {
if points.is_empty() {
return Vec::new();
}
// Step 1: RANSAC for dominant lines
let (mut lines, remaining_indices) = extract_lines_ransac(points, ransac_config);
// Step 2: Split-merge on remaining points
if remaining_indices.len() >= 3 {
let remaining_points: Vec<Point2D> = remaining_indices.iter().map(|&i| points[i]).collect();
let additional_lines = extract_lines(&remaining_points, split_merge_config);
lines.extend(additional_lines);
}
lines
}
+598
View File
@@ -0,0 +1,598 @@
//! RANSAC-based line extraction.
//!
//! This module provides RANSAC (Random Sample Consensus) for extracting
//! dominant lines from point clouds. RANSAC is robust to outliers and
//! can find strong lines even in cluttered environments.
//!
//! # Usage
//!
//! ```rust,ignore
//! use vastu_map::extraction::{extract_lines_ransac, RansacLineConfig};
//!
//! let config = RansacLineConfig::default();
//! let (lines, remaining) = extract_lines_ransac(&points, &config);
//!
//! // `lines` contains extracted lines
//! // `remaining` contains indices of points not matched to any line
//! ```
use crate::core::Point2D;
use crate::features::Line2D;
use super::line_fitting::fit_line;
/// Configuration for RANSAC line extraction.
#[derive(Clone, Debug)]
pub struct RansacLineConfig {
/// Number of RANSAC iterations per line.
/// More iterations = better chance of finding optimal line.
/// Default: 100
pub iterations: usize,
/// Inlier distance threshold (meters).
/// Points within this distance of a line are considered inliers.
/// Default: 0.02m (2cm)
pub inlier_threshold: f32,
/// Minimum inliers for a valid line.
/// Lines with fewer inliers are rejected.
/// Default: 5
pub min_inliers: usize,
/// Minimum line length (meters).
/// Lines shorter than this are rejected.
/// Default: 0.1m (10cm)
pub min_line_length: f32,
/// Maximum gap between consecutive inliers along a line (meters).
/// If inliers have a gap larger than this, the line is split.
/// This prevents connecting collinear walls separated by doorways.
/// Default: 0.10m (10cm)
pub max_point_gap: f32,
/// Maximum lines to extract (0 = unlimited).
/// Stops after extracting this many lines.
/// Default: 0 (unlimited)
pub max_lines: usize,
/// Random seed for reproducibility.
/// 0 = use entropy-based seed (non-deterministic).
/// Default: 0
pub seed: u64,
}
impl Default for RansacLineConfig {
fn default() -> Self {
Self {
iterations: 100,
inlier_threshold: 0.02,
min_inliers: 5,
min_line_length: 0.1,
max_point_gap: 0.10,
max_lines: 0,
seed: 0,
}
}
}
impl RansacLineConfig {
/// Create a new config with default values.
pub fn new() -> Self {
Self::default()
}
/// Builder-style setter for iterations.
pub fn with_iterations(mut self, iterations: usize) -> Self {
self.iterations = iterations;
self
}
/// Builder-style setter for inlier threshold.
pub fn with_inlier_threshold(mut self, threshold: f32) -> Self {
self.inlier_threshold = threshold;
self
}
/// Builder-style setter for minimum inliers.
pub fn with_min_inliers(mut self, min_inliers: usize) -> Self {
self.min_inliers = min_inliers;
self
}
/// Builder-style setter for minimum line length.
pub fn with_min_line_length(mut self, length: f32) -> Self {
self.min_line_length = length;
self
}
/// Builder-style setter for maximum lines.
pub fn with_max_lines(mut self, max_lines: usize) -> Self {
self.max_lines = max_lines;
self
}
/// Builder-style setter for maximum point gap.
pub fn with_max_point_gap(mut self, gap: f32) -> Self {
self.max_point_gap = gap;
self
}
/// Builder-style setter for random seed.
pub fn with_seed(mut self, seed: u64) -> Self {
self.seed = seed;
self
}
}
/// Simple LCG random number generator for deterministic behavior.
struct SimpleRng {
state: u64,
}
impl SimpleRng {
fn new(seed: u64) -> Self {
// Use a non-zero seed
Self {
state: if seed == 0 {
// Use a simple time-based seed
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(12345)
} else {
seed
},
}
}
fn next(&mut self) -> u64 {
// LCG parameters from Numerical Recipes
self.state = self.state.wrapping_mul(6364136223846793005).wrapping_add(1);
self.state
}
fn gen_range(&mut self, max: usize) -> usize {
if max == 0 {
return 0;
}
(self.next() % (max as u64)) as usize
}
}
/// Split inliers at gaps to prevent connecting separate wall segments.
///
/// Points are sorted along the line direction and split wherever
/// consecutive points have a gap larger than `max_point_gap`.
fn split_at_gaps(
inlier_points: &[Point2D],
inlier_indices: &[usize],
config: &RansacLineConfig,
) -> Vec<(Vec<Point2D>, Vec<usize>)> {
if inlier_points.len() < 2 {
return vec![(inlier_points.to_vec(), inlier_indices.to_vec())];
}
// Compute principal direction using PCA-like approach
let n = inlier_points.len() as f32;
let mean_x: f32 = inlier_points.iter().map(|p| p.x).sum::<f32>() / n;
let mean_y: f32 = inlier_points.iter().map(|p| p.y).sum::<f32>() / n;
// Compute covariance matrix elements
let mut cxx = 0.0f32;
let mut cxy = 0.0f32;
let mut cyy = 0.0f32;
for p in inlier_points {
let dx = p.x - mean_x;
let dy = p.y - mean_y;
cxx += dx * dx;
cxy += dx * dy;
cyy += dy * dy;
}
// Principal direction from dominant eigenvector
let theta = 0.5 * (2.0 * cxy).atan2(cxx - cyy);
let dir_x = theta.cos();
let dir_y = theta.sin();
// Project points onto principal direction and sort
let mut indexed: Vec<(usize, f32)> = inlier_points
.iter()
.enumerate()
.map(|(i, p)| {
let proj = (p.x - mean_x) * dir_x + (p.y - mean_y) * dir_y;
(i, proj)
})
.collect();
indexed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
// Split at gaps
let mut segments = Vec::new();
let mut current_points = Vec::new();
let mut current_indices = Vec::new();
for (i, &(orig_idx, _proj)) in indexed.iter().enumerate() {
current_points.push(inlier_points[orig_idx]);
current_indices.push(inlier_indices[orig_idx]);
// Check if there's a gap to the next point
if i + 1 < indexed.len() {
let next_orig_idx = indexed[i + 1].0;
let dist = inlier_points[orig_idx].distance(inlier_points[next_orig_idx]);
if dist > config.max_point_gap {
// Gap found - save current segment and start new one
if !current_points.is_empty() {
segments.push((
std::mem::take(&mut current_points),
std::mem::take(&mut current_indices),
));
}
}
}
}
// Add final segment
if !current_points.is_empty() {
segments.push((current_points, current_indices));
}
segments
}
/// Extract lines using RANSAC.
///
/// Returns extracted lines and indices of points not matched to any line.
///
/// # Arguments
/// * `points` - Input point cloud
/// * `config` - RANSAC configuration
///
/// # Returns
/// Tuple of (extracted_lines, remaining_point_indices)
pub fn extract_lines_ransac(
points: &[Point2D],
config: &RansacLineConfig,
) -> (Vec<Line2D>, Vec<usize>) {
if points.len() < config.min_inliers {
return (Vec::new(), (0..points.len()).collect());
}
let mut remaining_mask = vec![true; points.len()];
let mut lines = Vec::new();
let mut rng = SimpleRng::new(config.seed);
loop {
// Check max lines limit
if config.max_lines > 0 && lines.len() >= config.max_lines {
break;
}
// Collect remaining point indices
let remaining: Vec<usize> = remaining_mask
.iter()
.enumerate()
.filter_map(|(i, &active)| if active { Some(i) } else { None })
.collect();
if remaining.len() < config.min_inliers {
break;
}
// Run RANSAC to find best line
let mut best_inliers: Vec<usize> = Vec::new();
for _ in 0..config.iterations {
// Random sample of 2 distinct points
let idx1 = remaining[rng.gen_range(remaining.len())];
let idx2 = remaining[rng.gen_range(remaining.len())];
if idx1 == idx2 {
continue;
}
let p1 = points[idx1];
let p2 = points[idx2];
// Check minimum distance between sample points
let sample_dist = p1.distance(p2);
if sample_dist < config.min_line_length * 0.3 {
continue;
}
// Create candidate line
let line = Line2D::new(p1, p2);
// Count inliers
let inliers: Vec<usize> = remaining
.iter()
.copied()
.filter(|&i| line.distance_to_point(points[i]) < config.inlier_threshold)
.collect();
if inliers.len() > best_inliers.len() {
best_inliers = inliers;
}
}
// Check if we found enough inliers
if best_inliers.len() < config.min_inliers {
break;
}
// Get inlier points
let inlier_points: Vec<Point2D> = best_inliers.iter().map(|&i| points[i]).collect();
// Split inliers at gaps to avoid connecting separate wall segments
let segments = split_at_gaps(&inlier_points, &best_inliers, config);
let mut added_any = false;
for (segment_points, segment_indices) in segments {
if segment_points.len() < config.min_inliers {
continue;
}
if let Some(refined_line) = fit_line(&segment_points) {
// Check minimum length
if refined_line.length() >= config.min_line_length {
lines.push(refined_line);
added_any = true;
// Mark segment inliers as used
for &i in &segment_indices {
remaining_mask[i] = false;
}
}
}
}
// If we couldn't add any valid lines from this iteration, stop
if !added_any {
break;
}
}
// Collect remaining point indices
let remaining: Vec<usize> = remaining_mask
.iter()
.enumerate()
.filter_map(|(i, &active)| if active { Some(i) } else { None })
.collect();
(lines, remaining)
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn test_config_default() {
let config = RansacLineConfig::default();
assert_eq!(config.iterations, 100);
assert_relative_eq!(config.inlier_threshold, 0.02);
assert_eq!(config.min_inliers, 5);
}
#[test]
fn test_config_builder() {
let config = RansacLineConfig::new()
.with_iterations(50)
.with_inlier_threshold(0.05)
.with_min_inliers(3)
.with_seed(42);
assert_eq!(config.iterations, 50);
assert_relative_eq!(config.inlier_threshold, 0.05);
assert_eq!(config.min_inliers, 3);
assert_eq!(config.seed, 42);
}
#[test]
fn test_extract_empty_points() {
let config = RansacLineConfig::default();
let (lines, remaining) = extract_lines_ransac(&[], &config);
assert!(lines.is_empty());
assert!(remaining.is_empty());
}
#[test]
fn test_extract_too_few_points() {
let points = vec![Point2D::new(0.0, 0.0), Point2D::new(1.0, 0.0)];
let config = RansacLineConfig::default().with_min_inliers(5);
let (lines, remaining) = extract_lines_ransac(&points, &config);
assert!(lines.is_empty());
assert_eq!(remaining.len(), 2);
}
#[test]
fn test_extract_single_line() {
// Points along a horizontal line (5cm spacing, under 10cm gap threshold)
let points: Vec<Point2D> = (0..20)
.map(|i| Point2D::new(i as f32 * 0.05, 0.0))
.collect();
let config = RansacLineConfig::default()
.with_seed(42)
.with_min_inliers(5);
let (lines, remaining) = extract_lines_ransac(&points, &config);
assert_eq!(lines.len(), 1);
// Most points should be consumed
assert!(remaining.len() < 5);
// Line should be roughly horizontal
let angle = lines[0].angle().abs();
assert!(angle < 0.1 || (std::f32::consts::PI - angle).abs() < 0.1);
}
#[test]
fn test_extract_two_lines() {
// Points along a horizontal line (5cm spacing)
let mut points: Vec<Point2D> = (0..15)
.map(|i| Point2D::new(i as f32 * 0.05, 0.0))
.collect();
// Points along a vertical line (5cm spacing)
for i in 0..15 {
points.push(Point2D::new(0.0, i as f32 * 0.05));
}
let config = RansacLineConfig::default()
.with_seed(42)
.with_min_inliers(5)
.with_max_lines(2);
let (lines, _remaining) = extract_lines_ransac(&points, &config);
assert_eq!(lines.len(), 2);
}
#[test]
fn test_extract_with_noise() {
// Points along a horizontal line with some noise (5cm spacing)
let mut points: Vec<Point2D> = (0..20)
.map(|i| Point2D::new(i as f32 * 0.05, (i as f32 * 0.05).sin() * 0.01))
.collect();
// Add some outliers
points.push(Point2D::new(0.5, 1.0));
points.push(Point2D::new(1.0, -1.0));
let config = RansacLineConfig::default()
.with_seed(42)
.with_inlier_threshold(0.03)
.with_min_inliers(5);
let (lines, _remaining) = extract_lines_ransac(&points, &config);
// Should find the main line despite outliers
assert!(!lines.is_empty());
}
#[test]
fn test_max_lines_limit() {
// Many points that could form multiple lines (5cm spacing)
let mut points: Vec<Point2D> = Vec::new();
for j in 0..4 {
for i in 0..10 {
points.push(Point2D::new(i as f32 * 0.05, j as f32 * 0.5));
}
}
let config = RansacLineConfig::default()
.with_seed(42)
.with_min_inliers(5)
.with_max_lines(2);
let (lines, _remaining) = extract_lines_ransac(&points, &config);
assert!(lines.len() <= 2);
}
#[test]
fn test_gap_splitting_no_gap() {
// Continuous line with no gaps (3cm spacing, well under 10cm threshold)
let points: Vec<Point2D> = (0..20)
.map(|i| Point2D::new(i as f32 * 0.03, 0.0)) // 3cm spacing
.collect();
let indices: Vec<usize> = (0..20).collect();
let config = RansacLineConfig::default().with_max_point_gap(0.10);
let segments = split_at_gaps(&points, &indices, &config);
// Should result in a single segment (all points within gap threshold)
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].0.len(), 20);
}
#[test]
fn test_gap_splitting_with_gap() {
// Two wall segments separated by a doorway (gap > 10cm)
let mut points = Vec::new();
// First wall segment: 5 points with 3cm spacing
for i in 0..5 {
points.push(Point2D::new(i as f32 * 0.03, 0.0));
}
// Second wall segment: starts at 0.35m (gap of ~23cm from last point)
for i in 0..5 {
points.push(Point2D::new(0.35 + i as f32 * 0.03, 0.0));
}
let indices: Vec<usize> = (0..10).collect();
let config = RansacLineConfig::default().with_max_point_gap(0.10);
let segments = split_at_gaps(&points, &indices, &config);
// Should split into two segments due to gap
assert_eq!(segments.len(), 2);
assert_eq!(segments[0].0.len(), 5);
assert_eq!(segments[1].0.len(), 5);
}
#[test]
fn test_gap_splitting_multiple_gaps() {
// Three wall segments with gaps
let mut points = Vec::new();
// Segment 1: 3 points with 3cm spacing (0.0 to 0.06m)
for i in 0..3 {
points.push(Point2D::new(i as f32 * 0.03, 0.0));
}
// Gap of ~19cm
// Segment 2: 3 points starting at 0.25m
for i in 0..3 {
points.push(Point2D::new(0.25 + i as f32 * 0.03, 0.0));
}
// Gap of ~19cm
// Segment 3: 3 points starting at 0.50m
for i in 0..3 {
points.push(Point2D::new(0.50 + i as f32 * 0.03, 0.0));
}
let indices: Vec<usize> = (0..9).collect();
let config = RansacLineConfig::default().with_max_point_gap(0.10);
let segments = split_at_gaps(&points, &indices, &config);
// Should split into three segments
assert_eq!(segments.len(), 3);
for segment in &segments {
assert_eq!(segment.0.len(), 3);
}
}
#[test]
fn test_extract_lines_respects_gap() {
// Collinear points with a gap (simulating doorway between walls)
let mut points = Vec::new();
// Left wall segment: 10 points with 3cm spacing (0.27m length)
for i in 0..10 {
points.push(Point2D::new(i as f32 * 0.03, 0.0));
}
// Gap of ~25cm
// Right wall segment: 10 points with 3cm spacing starting at 0.52m
for i in 0..10 {
points.push(Point2D::new(0.52 + i as f32 * 0.03, 0.0));
}
let config = RansacLineConfig::default()
.with_seed(42)
.with_min_inliers(5)
.with_max_point_gap(0.10);
let (lines, _remaining) = extract_lines_ransac(&points, &config);
// Should find two separate lines, not one long line
assert_eq!(lines.len(), 2);
// Check each line is approximately the expected length
for line in &lines {
// Each wall segment is ~0.27m (10 points * 0.03m spacing)
assert!(line.length() < 0.35, "Line too long: {}", line.length());
assert!(line.length() > 0.15, "Line too short: {}", line.length());
}
}
}
+2
View File
@@ -115,11 +115,13 @@ pub mod features;
pub mod integration;
pub mod loop_closure;
pub mod matching;
pub mod motion_model;
pub mod query;
pub mod simd;
pub mod vector_map;
// Re-export main types at crate root
pub use motion_model::MotionModel;
pub use vector_map::{VectorMap, VectorMapConfig};
// Re-export extensibility traits
+273
View File
@@ -220,6 +220,11 @@ impl CorrespondenceSet {
self.correspondences.iter()
}
/// Iterate over correspondences mutably.
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut Correspondence> {
self.correspondences.iter_mut()
}
/// Apply weights to correspondences based on range using a noise model.
///
/// The range for each correspondence is computed from the original robot-frame points
@@ -255,6 +260,166 @@ impl CorrespondenceSet {
}
}
/// A correspondence between a scan point and a map corner.
///
/// Used for point-to-point constraints in ICP, which provide
/// better angular accuracy than point-to-line constraints alone.
#[derive(Clone, Copy, Debug)]
pub struct CornerCorrespondence {
/// Index of the point in the scan.
pub point_idx: usize,
/// Index of the corner in the map.
pub corner_idx: usize,
/// The scan point position (in world frame after initial transform).
pub point: Point2D,
/// The map corner position.
pub corner: Point2D,
/// Distance from point to corner.
pub distance: f32,
/// Weight for this correspondence (1/σ² based on measurement uncertainty).
pub weight: f32,
}
impl CornerCorrespondence {
/// Create a new corner correspondence with default weight (1.0).
#[inline]
pub fn new(
point_idx: usize,
corner_idx: usize,
point: Point2D,
corner: Point2D,
distance: f32,
) -> Self {
Self {
point_idx,
corner_idx,
point,
corner,
distance,
weight: 1.0,
}
}
/// Create a new corner correspondence with explicit weight.
#[inline]
pub fn with_weight(
point_idx: usize,
corner_idx: usize,
point: Point2D,
corner: Point2D,
distance: f32,
weight: f32,
) -> Self {
Self {
point_idx,
corner_idx,
point,
corner,
distance,
weight,
}
}
/// Check if this is a valid correspondence (within distance threshold).
#[inline]
pub fn is_valid(&self, max_distance: f32) -> bool {
self.distance <= max_distance
}
}
/// Collection of corner correspondences for a scan match.
#[derive(Clone, Debug, Default)]
pub struct CornerCorrespondenceSet {
/// All corner correspondences found.
pub correspondences: Vec<CornerCorrespondence>,
}
impl CornerCorrespondenceSet {
/// Create a new empty corner correspondence set.
pub fn new() -> Self {
Self::default()
}
/// Create with capacity.
pub fn with_capacity(capacity: usize) -> Self {
Self {
correspondences: Vec::with_capacity(capacity),
}
}
/// Add a correspondence.
#[inline]
pub fn push(&mut self, corr: CornerCorrespondence) {
self.correspondences.push(corr);
}
/// Number of correspondences.
#[inline]
pub fn len(&self) -> usize {
self.correspondences.len()
}
/// Check if empty.
#[inline]
pub fn is_empty(&self) -> bool {
self.correspondences.is_empty()
}
/// Filter correspondences by maximum distance.
pub fn filter_by_distance(&self, max_distance: f32) -> Self {
Self {
correspondences: self
.correspondences
.iter()
.filter(|c| c.distance <= max_distance)
.copied()
.collect(),
}
}
/// Get the mean correspondence distance.
pub fn mean_distance(&self) -> f32 {
if self.is_empty() {
return 0.0;
}
let sum: f32 = self.correspondences.iter().map(|c| c.distance).sum();
sum / self.len() as f32
}
/// Get the RMS (root mean square) correspondence distance.
pub fn rms_distance(&self) -> f32 {
if self.is_empty() {
return 0.0;
}
let sum_sq: f32 = self
.correspondences
.iter()
.map(|c| c.distance * c.distance)
.sum();
(sum_sq / self.len() as f32).sqrt()
}
/// Get the total weight of all correspondences.
pub fn total_weight(&self) -> f32 {
self.correspondences.iter().map(|c| c.weight).sum()
}
/// Clear all correspondences.
pub fn clear(&mut self) {
self.correspondences.clear();
}
/// Iterate over correspondences.
pub fn iter(&self) -> impl Iterator<Item = &CornerCorrespondence> {
self.correspondences.iter()
}
/// Iterate over correspondences mutably.
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut CornerCorrespondence> {
self.correspondences.iter_mut()
}
}
/// 3x3 covariance matrix for pose uncertainty [x, y, theta].
pub type PoseCovariance = [[f32; 3]; 3];
@@ -464,4 +629,112 @@ mod tests {
assert!(result.confidence > 0.5);
assert!(result.is_good_match(0.3));
}
#[test]
fn test_corner_correspondence_new() {
let point = Point2D::new(1.0, 2.0);
let corner = Point2D::new(1.1, 2.05);
let corr = CornerCorrespondence::new(0, 1, point, corner, 0.1);
assert_eq!(corr.point_idx, 0);
assert_eq!(corr.corner_idx, 1);
assert_eq!(corr.distance, 0.1);
assert_eq!(corr.weight, 1.0);
assert!(corr.is_valid(0.15));
assert!(!corr.is_valid(0.05));
}
#[test]
fn test_corner_correspondence_with_weight() {
let point = Point2D::new(1.0, 2.0);
let corner = Point2D::new(1.1, 2.05);
let corr = CornerCorrespondence::with_weight(0, 1, point, corner, 0.1, 0.5);
assert_eq!(corr.weight, 0.5);
}
#[test]
fn test_corner_correspondence_set_statistics() {
let mut set = CornerCorrespondenceSet::new();
set.push(CornerCorrespondence::new(
0,
0,
Point2D::zero(),
Point2D::new(0.1, 0.0),
0.1,
));
set.push(CornerCorrespondence::new(
1,
1,
Point2D::zero(),
Point2D::new(0.2, 0.0),
0.2,
));
set.push(CornerCorrespondence::new(
2,
2,
Point2D::zero(),
Point2D::new(0.3, 0.0),
0.3,
));
assert_eq!(set.len(), 3);
assert!(!set.is_empty());
assert!((set.mean_distance() - 0.2).abs() < 1e-6);
// RMS = sqrt((0.01 + 0.04 + 0.09) / 3) = sqrt(0.14/3) ≈ 0.216
assert!((set.rms_distance() - 0.216).abs() < 0.01);
}
#[test]
fn test_corner_correspondence_set_filter() {
let mut set = CornerCorrespondenceSet::new();
set.push(CornerCorrespondence::new(
0,
0,
Point2D::zero(),
Point2D::new(0.05, 0.0),
0.05,
));
set.push(CornerCorrespondence::new(
1,
1,
Point2D::zero(),
Point2D::new(0.15, 0.0),
0.15,
));
set.push(CornerCorrespondence::new(
2,
2,
Point2D::zero(),
Point2D::new(0.25, 0.0),
0.25,
));
let filtered = set.filter_by_distance(0.1);
assert_eq!(filtered.len(), 1);
}
#[test]
fn test_corner_correspondence_set_total_weight() {
let mut set = CornerCorrespondenceSet::new();
set.push(CornerCorrespondence::with_weight(
0,
0,
Point2D::zero(),
Point2D::new(0.1, 0.0),
0.1,
0.5,
));
set.push(CornerCorrespondence::with_weight(
1,
1,
Point2D::zero(),
Point2D::new(0.2, 0.0),
0.2,
0.3,
));
assert!((set.total_weight() - 0.8).abs() < 1e-6);
}
}
+217 -36
View File
@@ -1,8 +1,13 @@
//! Gauss-Newton solver for point-to-line ICP.
//! Levenberg-Marquardt solver for point-to-line ICP.
//!
//! Solves the nonlinear least squares problem of finding the pose
//! that minimizes the sum of squared point-to-line distances.
//!
//! Uses Levenberg-Marquardt algorithm for robust convergence. LM blends
//! between Gauss-Newton (fast convergence near solution) and gradient
//! descent (robust far from solution). Set `lm_initial_lambda = 0.0`
//! to disable LM damping and use pure Gauss-Newton.
//!
//! # Performance
//!
//! Two versions are provided:
@@ -17,7 +22,10 @@ use crate::features::{Line2D, LineCollection};
use super::correspondence::CorrespondenceSet;
/// Configuration for Gauss-Newton solver.
/// Configuration for Levenberg-Marquardt solver.
///
/// LM blends between Gauss-Newton and gradient descent for robust convergence.
/// Set `lm_initial_lambda = 0.0` to disable LM damping (pure Gauss-Newton).
#[derive(Clone, Debug)]
pub struct GaussNewtonConfig {
/// Maximum number of iterations.
@@ -41,6 +49,23 @@ pub struct GaussNewtonConfig {
/// Regularization factor for ill-conditioned systems.
/// Default: 1e-4
pub regularization: f32,
/// Initial LM damping factor (λ).
/// Set to 0.0 to disable LM and use pure Gauss-Newton.
/// Default: 0.001
pub lm_initial_lambda: f32,
/// Factor to scale λ up (on bad step) or down (on good step).
/// Default: 10.0
pub lm_lambda_factor: f32,
/// Minimum λ value (floor after successful steps).
/// Default: 1e-7
pub lm_min_lambda: f32,
/// Maximum λ value (triggers failure if exceeded).
/// Default: 1e7
pub lm_max_lambda: f32,
}
impl Default for GaussNewtonConfig {
@@ -52,6 +77,11 @@ impl Default for GaussNewtonConfig {
convergence_threshold_sq: convergence_threshold * convergence_threshold,
min_eigenvalue_ratio: 1e-6,
regularization: 1e-4,
// LM parameters
lm_initial_lambda: 0.001,
lm_lambda_factor: 10.0,
lm_min_lambda: 1e-7,
lm_max_lambda: 1e7,
}
}
}
@@ -74,6 +104,19 @@ impl GaussNewtonConfig {
self.convergence_threshold_sq = threshold * threshold;
self
}
/// Builder-style setter for initial LM lambda.
/// Set to 0.0 to disable LM (pure Gauss-Newton).
pub fn with_lm_lambda(mut self, lambda: f32) -> Self {
self.lm_initial_lambda = lambda;
self
}
/// Disable LM damping (use pure Gauss-Newton).
pub fn without_lm(mut self) -> Self {
self.lm_initial_lambda = 0.0;
self
}
}
use super::correspondence::PoseCovariance;
@@ -343,6 +386,91 @@ fn compute_covariance(
(covariance, condition_number)
}
/// Compute covariance matrix with degrees-of-freedom correction.
///
/// Uses unbiased variance estimate: σ² = Σw·r² / (n - 3)
/// where n is the number of correspondences and 3 is the number of parameters.
fn compute_covariance_with_dof(
hessian: &[[f32; 3]; 3],
sum_weighted_sq_residuals: f32,
num_correspondences: usize,
regularization: f32,
) -> (PoseCovariance, f32) {
// Degrees of freedom correction: n - 3 (3 parameters: x, y, theta)
let n = num_correspondences as f32;
let dof = (n - 3.0).max(1.0);
// Unbiased variance estimate
let sigma_sq = sum_weighted_sq_residuals / dof;
// Add regularization to diagonal
let mut h = *hessian;
h[0][0] += regularization;
h[1][1] += regularization;
h[2][2] += regularization;
// Compute determinant
let det = h[0][0] * (h[1][1] * h[2][2] - h[1][2] * h[2][1])
- h[0][1] * (h[1][0] * h[2][2] - h[1][2] * h[2][0])
+ h[0][2] * (h[1][0] * h[2][1] - h[1][1] * h[2][0]);
if det.abs() < 1e-10 {
// Singular matrix - return identity covariance with high condition number
return (IDENTITY_COVARIANCE, f32::MAX);
}
let inv_det = 1.0 / det;
// Compute inverse (H⁻¹)
let inv = [
[
(h[1][1] * h[2][2] - h[1][2] * h[2][1]) * inv_det,
(h[0][2] * h[2][1] - h[0][1] * h[2][2]) * inv_det,
(h[0][1] * h[1][2] - h[0][2] * h[1][1]) * inv_det,
],
[
(h[1][2] * h[2][0] - h[1][0] * h[2][2]) * inv_det,
(h[0][0] * h[2][2] - h[0][2] * h[2][0]) * inv_det,
(h[0][2] * h[1][0] - h[0][0] * h[1][2]) * inv_det,
],
[
(h[1][0] * h[2][1] - h[1][1] * h[2][0]) * inv_det,
(h[0][1] * h[2][0] - h[0][0] * h[2][1]) * inv_det,
(h[0][0] * h[1][1] - h[0][1] * h[1][0]) * inv_det,
],
];
// Scale by unbiased variance to get covariance: Cov = σ² × H⁻¹
let covariance = [
[
sigma_sq * inv[0][0],
sigma_sq * inv[0][1],
sigma_sq * inv[0][2],
],
[
sigma_sq * inv[1][0],
sigma_sq * inv[1][1],
sigma_sq * inv[1][2],
],
[
sigma_sq * inv[2][0],
sigma_sq * inv[2][1],
sigma_sq * inv[2][2],
],
];
// Compute condition number
let diag_max = h[0][0].max(h[1][1]).max(h[2][2]);
let diag_min = h[0][0].min(h[1][1]).min(h[2][2]);
let condition_number = if diag_min > 1e-10 {
diag_max / diag_min
} else {
f32::MAX
};
(covariance, condition_number)
}
/// Solve a 3x3 linear system Ax = b using Cramer's rule.
///
/// Returns None if the matrix is singular.
@@ -421,11 +549,19 @@ fn compute_residual(correspondences: &CorrespondenceSet, lines: &[Line2D], pose:
// Fast versions using pre-computed LineCollection normals
// ============================================================================
/// Optimize pose using pre-computed normals (faster hot-path version).
/// Optimize pose using Levenberg-Marquardt with pre-computed normals.
///
/// This version uses `LineCollection` with pre-computed normals and inverse
/// lengths, avoiding redundant sqrt computations in the inner loop.
/// Also uses weighted least squares when correspondences have weights.
/// Uses LM damping for robust convergence from poor initial guesses.
///
/// # Algorithm
///
/// LM adds damping (λI) to the Hessian diagonal:
/// - Good step (error decreases): accept step, decrease λ
/// - Bad step (error increases): reject step, increase λ
///
/// Set `config.lm_initial_lambda = 0.0` to disable LM (pure Gauss-Newton).
///
/// # Performance
///
@@ -447,20 +583,30 @@ pub fn optimize_pose_fast(
};
}
// Pre-fetch normals for all lines used in correspondences
let (normal_xs, normal_ys) = lines.normals();
let inv_lengths = lines.inv_lengths();
let mut pose = initial_pose;
let mut converged = false;
let mut iterations = 0;
let mut final_hessian = [[0.0f32; 3]; 3];
let mut final_sum_weighted_sq = 0.0f32;
// Pre-fetch normals for all lines used in correspondences
let (normal_xs, normal_ys) = lines.normals();
let inv_lengths = lines.inv_lengths();
// LM state
let use_lm = config.lm_initial_lambda > 0.0;
let mut lambda = config.lm_initial_lambda;
let mut prev_residual = if use_lm {
compute_residual_fast(correspondences, lines, inv_lengths, &pose)
} else {
f32::MAX
};
for iter in 0..config.max_iterations {
iterations = iter + 1;
// Build weighted linear system using pre-computed normals
let (h, g, residual) = build_linear_system_fast_weighted(
let (h, g, sum_weighted_sq, _sum_weights) = build_linear_system_fast_weighted_extended(
correspondences,
lines,
normal_xs,
@@ -469,37 +615,77 @@ pub fn optimize_pose_fast(
&pose,
);
final_hessian = h;
final_sum_weighted_sq = sum_weighted_sq;
// Add LM damping to Hessian diagonal
let mut h_damped = h;
if use_lm {
h_damped[0][0] += lambda;
h_damped[1][1] += lambda;
h_damped[2][2] += lambda;
}
// Solve for Δx using direct 3x3 solve
let Some(delta) = solve_3x3(&h, &g, config.regularization) else {
let Some(delta) = solve_3x3(&h_damped, &g, config.regularization) else {
break;
};
// Update pose
pose.x += delta[0];
pose.y += delta[1];
pose.theta += delta[2];
pose.theta = crate::core::math::normalize_angle(pose.theta);
// Compute trial pose
let trial_pose = Pose2D::new(
pose.x + delta[0],
pose.y + delta[1],
crate::core::math::normalize_angle(pose.theta + delta[2]),
);
// Check convergence using squared comparison to avoid sqrt()
let delta_norm_sq = delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2];
if delta_norm_sq < config.convergence_threshold_sq {
converged = true;
break;
}
if use_lm {
// Evaluate step quality
let trial_residual =
compute_residual_fast(correspondences, lines, inv_lengths, &trial_pose);
if residual < config.convergence_threshold {
converged = true;
break;
if trial_residual < prev_residual {
// Good step - accept and decrease lambda
pose = trial_pose;
prev_residual = trial_residual;
lambda = (lambda / config.lm_lambda_factor).max(config.lm_min_lambda);
// Check convergence
let delta_norm_sq = delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2];
if delta_norm_sq < config.convergence_threshold_sq {
converged = true;
break;
}
} else {
// Bad step - reject and increase lambda
lambda *= config.lm_lambda_factor;
if lambda > config.lm_max_lambda {
// Failed to converge
break;
}
// Don't update pose, try again with higher damping
continue;
}
} else {
// Pure Gauss-Newton (no LM damping)
pose = trial_pose;
// Check convergence
let delta_norm_sq = delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2];
if delta_norm_sq < config.convergence_threshold_sq {
converged = true;
break;
}
}
}
let final_residual = compute_residual_fast(correspondences, lines, inv_lengths, &pose);
// Compute covariance from inverse Hessian
let residual_variance = final_residual * final_residual;
let (covariance, condition_number) =
compute_covariance(&final_hessian, residual_variance, config.regularization);
// Compute covariance with DOF correction
let (covariance, condition_number) = compute_covariance_with_dof(
&final_hessian,
final_sum_weighted_sq,
correspondences.len(),
config.regularization,
);
GaussNewtonResult {
pose,
@@ -512,14 +698,15 @@ pub fn optimize_pose_fast(
}
/// Build weighted linear system using pre-computed normals.
fn build_linear_system_fast_weighted(
/// Returns (H, g, sum_weighted_sq_error, sum_weights) for covariance computation.
fn build_linear_system_fast_weighted_extended(
correspondences: &CorrespondenceSet,
lines: &LineCollection,
normal_xs: &[f32],
normal_ys: &[f32],
inv_lengths: &[f32],
pose: &Pose2D,
) -> ([[f32; 3]; 3], [f32; 3], f32) {
) -> ([[f32; 3]; 3], [f32; 3], f32, f32) {
let (sin, cos) = pose.theta.sin_cos();
// Pre-compute -sin and -cos for Jacobian
@@ -585,13 +772,7 @@ fn build_linear_system_fast_weighted(
h[2][0] = h[0][2];
h[2][1] = h[1][2];
let rms_error = if sum_weights > 0.0 {
(sum_weighted_sq_error / sum_weights).sqrt()
} else {
0.0
};
(h, g, rms_error)
(h, g, sum_weighted_sq_error, sum_weights)
}
/// Compute RMS residual using pre-computed inverse lengths.
+139 -20
View File
@@ -2,7 +2,7 @@
use crate::config::LidarNoiseModel;
use super::super::ransac::RansacConfig;
use super::super::robust::RobustCostFunction;
/// Configuration for Point-to-Line ICP.
#[derive(Clone, Debug)]
@@ -25,9 +25,16 @@ pub struct IcpConfig {
/// Default: 0.5m
pub max_correspondence_distance: f32,
/// Outlier rejection method.
/// Default: DistanceThreshold
pub outlier_rejection: OutlierRejection,
/// Robust cost function for outlier handling.
/// Uses M-estimators to down-weight outliers based on residual magnitude.
/// Default: Huber with delta = 0.03 (3cm)
pub robust_cost: RobustCostFunction,
/// Enable Iteratively Reweighted Least Squares (IRLS).
/// When true, correspondence weights are updated each iteration
/// based on residuals and the robust cost function.
/// Default: true
pub use_irls: bool,
/// Minimum number of correspondences required.
/// Default: 10
@@ -38,7 +45,7 @@ pub struct IcpConfig {
pub min_confidence: f32,
/// Whether to use RANSAC for initial pose estimate.
/// Default: false
/// Default: true
pub use_ransac_init: bool,
/// Whether to use batch (SIMD) correspondence search.
@@ -57,17 +64,11 @@ pub struct IcpConfig {
/// Only used if use_coarse_search is true.
/// Default: 0.5
pub coarse_search_confidence_threshold: f32,
}
/// Outlier rejection method.
#[derive(Clone, Debug)]
pub enum OutlierRejection {
/// Reject by distance threshold (fast).
DistanceThreshold(f32),
/// Use RANSAC for robust rejection (slower but more robust).
Ransac(RansacConfig),
/// No outlier rejection.
None,
/// Multi-resolution ICP configuration (coarse-to-fine matching).
/// When enabled, matching starts with subsampled points and refines
/// with progressively more points for better convergence.
pub multi_resolution: MultiResolutionConfig,
}
impl Default for IcpConfig {
@@ -78,14 +79,16 @@ impl Default for IcpConfig {
convergence_threshold,
convergence_threshold_sq: convergence_threshold * convergence_threshold,
max_correspondence_distance: 0.5,
outlier_rejection: OutlierRejection::DistanceThreshold(0.15),
robust_cost: RobustCostFunction::default(), // Huber with delta = 0.03
use_irls: true,
min_correspondences: 10,
min_confidence: 0.3,
use_ransac_init: false,
use_ransac_init: true,
use_batch_search: true,
noise_model: LidarNoiseModel::default(),
use_coarse_search: true, // Enabled: triggers when previous match confidence < threshold
coarse_search_confidence_threshold: 0.5,
multi_resolution: MultiResolutionConfig::default(),
}
}
}
@@ -115,9 +118,21 @@ impl IcpConfig {
self
}
/// Builder-style setter for outlier rejection method.
pub fn with_outlier_rejection(mut self, method: OutlierRejection) -> Self {
self.outlier_rejection = method;
/// Builder-style setter for robust cost function.
pub fn with_robust_cost(mut self, cost: RobustCostFunction) -> Self {
self.robust_cost = cost;
self
}
/// Builder-style setter for IRLS (Iteratively Reweighted Least Squares).
pub fn with_irls(mut self, enabled: bool) -> Self {
self.use_irls = enabled;
self
}
/// Builder-style setter for batch search.
pub fn with_batch_search(mut self, enabled: bool) -> Self {
self.use_batch_search = enabled;
self
}
@@ -150,6 +165,18 @@ impl IcpConfig {
self.coarse_search_confidence_threshold = threshold;
self
}
/// Builder-style setter for multi-resolution configuration.
pub fn with_multi_resolution(mut self, config: MultiResolutionConfig) -> Self {
self.multi_resolution = config;
self
}
/// Disable multi-resolution matching.
pub fn without_multi_resolution(mut self) -> Self {
self.multi_resolution.enabled = false;
self
}
}
/// Configuration for coarse search initialization.
@@ -190,3 +217,95 @@ impl Default for CoarseSearchConfig {
}
}
}
/// Configuration for multi-resolution ICP (coarse-to-fine matching).
///
/// Multi-resolution ICP improves convergence by starting with subsampled
/// points (coarse) and progressively refining with more points (fine).
/// This helps escape local minima when the initial guess is poor.
#[derive(Clone, Debug)]
pub struct MultiResolutionConfig {
/// Enable multi-resolution matching.
/// Default: true
pub enabled: bool,
/// Number of pyramid levels.
/// Level 0 is coarsest, level (levels-1) is finest (full resolution).
/// Default: 3
pub levels: usize,
/// Subsampling factor between levels.
/// Points at level i use every (subsample_factor^(levels-1-i)) points.
/// Example with levels=3, factor=2: coarse=1/4, medium=1/2, fine=full
/// Default: 2
pub subsample_factor: usize,
/// ICP iterations at each level (from coarsest to finest).
/// Length should match `levels`. If shorter, last value is repeated.
/// Default: [3, 5, 10]
pub iterations_per_level: Vec<usize>,
}
impl Default for MultiResolutionConfig {
fn default() -> Self {
Self {
enabled: true,
levels: 3,
subsample_factor: 2,
iterations_per_level: vec![3, 5, 10],
}
}
}
impl MultiResolutionConfig {
/// Create a new multi-resolution config with default values.
pub fn new() -> Self {
Self::default()
}
/// Create an enabled config with default pyramid settings.
pub fn enabled() -> Self {
Self {
enabled: true,
..Self::default()
}
}
/// Builder-style setter for number of levels.
pub fn with_levels(mut self, levels: usize) -> Self {
self.levels = levels.max(1);
self
}
/// Builder-style setter for subsample factor.
pub fn with_subsample_factor(mut self, factor: usize) -> Self {
self.subsample_factor = factor.max(1);
self
}
/// Builder-style setter for iterations per level.
pub fn with_iterations(mut self, iterations: Vec<usize>) -> Self {
self.iterations_per_level = iterations;
self
}
/// Get iterations for a given level (0 = coarsest).
pub fn iterations_for_level(&self, level: usize) -> usize {
if level < self.iterations_per_level.len() {
self.iterations_per_level[level]
} else {
// Use last value if not enough entries
*self.iterations_per_level.last().unwrap_or(&10)
}
}
/// Get subsample rate for a given level (0 = coarsest).
///
/// Returns the step size for point sampling at this level.
pub fn subsample_for_level(&self, level: usize) -> usize {
if level >= self.levels {
return 1;
}
self.subsample_factor.pow((self.levels - 1 - level) as u32)
}
}
+145 -37
View File
@@ -13,7 +13,7 @@ use super::super::nearest_neighbor::{
use super::super::ransac::RansacConfig;
use super::super::ransac::estimate_pose_ransac;
use super::super::scratch::IcpScratchSpace;
use super::config::{CoarseSearchConfig, IcpConfig, OutlierRejection};
use super::config::{CoarseSearchConfig, IcpConfig};
/// Point-to-Line ICP matcher.
///
@@ -222,8 +222,9 @@ impl PointToLineIcp {
);
}
// 2. Reject outliers
let filtered = self.reject_outliers(&correspondences, lines);
// 2. Filter by max distance and apply IRLS weights
let mut filtered = self.filter_by_max_distance(&correspondences);
self.apply_irls_weights(&mut filtered);
if filtered.len() < self.config.min_correspondences {
return MatchResult::with_covariance(
@@ -237,7 +238,7 @@ impl PointToLineIcp {
);
}
// 3. Optimize pose using Gauss-Newton (with covariance output)
// 3. Optimize pose using Gauss-Newton with weighted correspondences
let gn_result = optimize_pose(&filtered, lines, Pose2D::identity(), &self.gn_config);
final_covariance = gn_result.covariance;
final_condition_number = gn_result.condition_number;
@@ -309,6 +310,17 @@ impl PointToLineIcp {
return MatchResult::new(initial_pose, CorrespondenceSet::new(), 0, false);
}
// Use multi-resolution ICP if enabled
if self.config.multi_resolution.enabled {
return match_scan_multiresolution(
robot_frame_points,
lines,
initial_pose,
&self.config.multi_resolution,
&self.config,
);
}
// Convert lines to collection for batch operations
let line_collection = LineCollection::from_lines(lines);
@@ -383,8 +395,10 @@ impl PointToLineIcp {
);
}
// 2. Reject outliers
let filtered = self.reject_outliers(&correspondences, lines);
// 2. Filter by max distance and apply IRLS weights
// Note: Range-based weights already applied above via apply_weights()
let mut filtered = self.filter_by_max_distance(&correspondences);
self.apply_irls_weights(&mut filtered);
if filtered.len() < self.config.min_correspondences {
return MatchResult::with_covariance(
@@ -398,7 +412,7 @@ impl PointToLineIcp {
);
}
// 3. Optimize pose using Gauss-Newton (uses weighted correspondences)
// 3. Optimize pose using Gauss-Newton with weighted correspondences
let gn_result = optimize_pose(&filtered, lines, Pose2D::identity(), &self.gn_config);
final_covariance = gn_result.covariance;
final_condition_number = gn_result.condition_number;
@@ -474,8 +488,6 @@ impl PointToLineIcp {
return MatchResult::new(initial_pose, CorrespondenceSet::new(), 0, false);
}
// Convert to Line2D for outlier rejection (uses Line2D methods)
let lines_vec = lines.to_lines();
let mut pose = initial_pose;
let mut prev_pose = pose;
let mut final_correspondences = CorrespondenceSet::new();
@@ -513,9 +525,10 @@ impl PointToLineIcp {
);
}
// 2. Get correspondences and reject outliers
// 2. Get correspondences, filter by distance, and apply IRLS weights
let correspondences = scratch.correspondences();
let filtered = self.reject_outliers(correspondences, &lines_vec);
let mut filtered = self.filter_by_max_distance(correspondences);
self.apply_irls_weights(&mut filtered);
if filtered.len() < self.config.min_correspondences {
return MatchResult::with_covariance(
@@ -529,7 +542,7 @@ impl PointToLineIcp {
);
}
// 3. Optimize pose using fast Gauss-Newton (uses pre-computed normals)
// 3. Optimize pose using fast Gauss-Newton with weighted correspondences
let gn_result =
optimize_pose_fast(&filtered, lines, Pose2D::identity(), &self.gn_config);
final_covariance = gn_result.covariance;
@@ -596,24 +609,28 @@ impl PointToLineIcp {
}
}
/// Reject outliers from correspondences.
fn reject_outliers(
&self,
correspondences: &CorrespondenceSet,
lines: &[Line2D],
) -> CorrespondenceSet {
match &self.config.outlier_rejection {
OutlierRejection::None => correspondences.clone(),
OutlierRejection::DistanceThreshold(threshold) => {
correspondences.filter_by_distance(*threshold)
}
OutlierRejection::Ransac(config) => {
match estimate_pose_ransac(correspondences, lines, config) {
Some(result) => result.inliers,
None => correspondences.filter_by_distance(0.2),
}
}
/// Apply IRLS robust weights to correspondences.
///
/// Multiplies each correspondence weight by a robust weight computed
/// from its residual (distance). This down-weights outliers softly
/// rather than removing them entirely.
fn apply_irls_weights(&self, correspondences: &mut CorrespondenceSet) {
if !self.config.use_irls || self.config.robust_cost.is_none() {
return;
}
for corr in correspondences.iter_mut() {
let robust_weight = self.config.robust_cost.weight(corr.distance);
corr.weight *= robust_weight; // Multiply range-based weight with robust weight
}
}
/// Filter correspondences by max correspondence distance.
///
/// This is a hard filter applied before IRLS weighting to remove
/// obviously bad correspondences (beyond max search distance).
fn filter_by_max_distance(&self, correspondences: &CorrespondenceSet) -> CorrespondenceSet {
correspondences.filter_by_distance(self.config.max_correspondence_distance)
}
}
@@ -648,10 +665,97 @@ pub fn match_scan_with_config(
icp.match_scan(points, lines, initial_pose)
}
/// Match scan using multi-resolution (coarse-to-fine) ICP.
///
/// This method improves convergence by starting with subsampled points
/// (coarse level) and progressively refining with more points (fine level).
/// Useful when the initial pose guess may be poor.
///
/// # Arguments
/// * `points` - Scan points in robot frame
/// * `lines` - Map lines in world frame
/// * `initial_pose` - Initial pose estimate
/// * `multi_config` - Multi-resolution pyramid configuration
/// * `icp_config` - Base ICP configuration (iterations overridden per level)
///
/// # Returns
/// Match result with optimized pose from finest level.
///
/// # Example
/// ```rust,ignore
/// use vastu_map::matching::{match_scan_multiresolution, MultiResolutionConfig, IcpConfig};
///
/// let multi_config = MultiResolutionConfig::enabled()
/// .with_levels(3)
/// .with_iterations(vec![3, 5, 10]);
/// let icp_config = IcpConfig::default();
///
/// let result = match_scan_multiresolution(
/// &points, &lines, initial_pose, &multi_config, &icp_config
/// );
/// ```
pub fn match_scan_multiresolution(
points: &[Point2D],
lines: &[Line2D],
initial_pose: Pose2D,
multi_config: &super::config::MultiResolutionConfig,
icp_config: &IcpConfig,
) -> MatchResult {
if points.is_empty() || lines.is_empty() {
return MatchResult::new(initial_pose, CorrespondenceSet::new(), 0, false);
}
// If multi-resolution disabled, fall back to standard ICP
if !multi_config.enabled || multi_config.levels == 0 {
return match_scan_with_config(points, lines, initial_pose, icp_config.clone());
}
let mut pose = initial_pose;
let mut total_iterations = 0;
let mut final_result = None;
// Process from coarsest (level 0) to finest (level N-1)
for level in 0..multi_config.levels {
let subsample = multi_config.subsample_for_level(level);
let level_iterations = multi_config.iterations_for_level(level);
// Subsample points deterministically using step_by
let subsampled: Vec<Point2D> = points.iter().step_by(subsample.max(1)).copied().collect();
// Skip if too few points after subsampling
if subsampled.len() < icp_config.min_correspondences {
continue;
}
// Configure ICP for this level
let mut level_config = icp_config.clone();
level_config.max_iterations = level_iterations;
// Run ICP at this level
let icp = PointToLineIcp::with_config(level_config);
let result = icp.match_scan(&subsampled, lines, pose);
// Update pose for next level
pose = result.pose;
total_iterations += result.iterations;
final_result = Some(result);
}
// Return result from finest level (or initial if no levels ran)
match final_result {
Some(mut result) => {
result.iterations = total_iterations;
result
}
None => MatchResult::new(pose, CorrespondenceSet::new(), 0, false),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Point2D;
use crate::matching::RobustCostFunction;
use approx::assert_relative_eq;
fn make_room_lines() -> Vec<Line2D> {
@@ -764,7 +868,7 @@ mod tests {
}
#[test]
fn test_icp_with_ransac() {
fn test_icp_with_tukey_robust_cost() {
let lines = make_room_lines();
let mut points = make_scan_points_at_origin();
@@ -773,33 +877,37 @@ mod tests {
points.push(Point2D::new(-10.0, -10.0));
points.push(Point2D::new(5.0, -5.0));
// Use Tukey for aggressive outlier rejection (zero weight beyond cutoff)
let config = IcpConfig::default()
.with_outlier_rejection(OutlierRejection::Ransac(RansacConfig::default()));
.with_robust_cost(RobustCostFunction::Tukey { c: 0.1 })
.with_irls(true);
let icp = PointToLineIcp::with_config(config);
let result = icp.match_scan(&points, &lines, Pose2D::identity());
// Should still converge despite outliers
// Should still converge despite outliers (they get zero weight)
assert!(result.converged);
}
#[test]
fn test_icp_distance_threshold() {
fn test_icp_with_huber_robust_cost() {
let lines = make_room_lines();
let mut points = make_scan_points_at_origin();
// Add outliers far from lines
points.push(Point2D::new(0.0, 0.0)); // Center - far from all walls
let config =
IcpConfig::default().with_outlier_rejection(OutlierRejection::DistanceThreshold(0.5));
// Use Huber for soft outlier down-weighting
let config = IcpConfig::default()
.with_robust_cost(RobustCostFunction::Huber { delta: 0.03 })
.with_irls(true);
let icp = PointToLineIcp::with_config(config);
let result = icp.match_scan(&points, &lines, Pose2D::identity());
assert!(result.converged);
// Center point should be filtered out
assert!(result.correspondences.len() < points.len());
// Outlier should have low weight but still be included
assert!(!result.correspondences.is_empty());
}
#[test]
+12 -4
View File
@@ -3,8 +3,8 @@
//! This module implements the full scan-to-map matching pipeline:
//!
//! 1. **Correspondence Search**: Find nearest map lines for each scan point
//! 2. **Outlier Rejection**: Use RANSAC or distance threshold
//! 3. **Pose Optimization**: Gauss-Newton solver for point-to-line error
//! 2. **Robust Weighting**: Apply M-estimator weights via IRLS to down-weight outliers
//! 3. **Pose Optimization**: Levenberg-Marquardt solver for weighted point-to-line error
//! 4. **Iteration**: Repeat until convergence
//!
//! # Point-to-Line vs Point-to-Point
@@ -13,10 +13,18 @@
//! - Points can slide along lines in the tangent direction
//! - Only the perpendicular distance matters
//! - Better handles structured environments (walls, edges)
//!
//! # Robust Cost Functions
//!
//! Use `RobustCostFunction` with IRLS for soft outlier rejection:
//! - **Huber** (default): Good balance between robustness and efficiency
//! - **Cauchy**: Heavy-tailed, good for cluttered environments
//! - **Tukey**: Zero weight for large residuals (aggressive rejection)
//! - **Geman-McClure**: Redescending M-estimator for multimodal errors
mod config;
mod matcher;
// Re-export public API
pub use config::{CoarseSearchConfig, IcpConfig, OutlierRejection};
pub use matcher::{PointToLineIcp, match_scan, match_scan_with_config};
pub use config::{CoarseSearchConfig, IcpConfig, MultiResolutionConfig};
pub use matcher::{PointToLineIcp, match_scan, match_scan_multiresolution, match_scan_with_config};
+11 -6
View File
@@ -8,8 +8,9 @@
//! - **Correspondence**: Types for point-to-line associations
//! - **Nearest Neighbor**: Find correspondences by distance
//! - **RANSAC**: Robust pose estimation with outlier rejection
//! - **Gauss-Newton**: Nonlinear least squares optimization
//! - **Point-to-Line ICP**: Full scan matching pipeline
//! - **Gauss-Newton/Levenberg-Marquardt**: Nonlinear least squares optimization
//! - **Robust Cost Functions**: M-estimators for outlier rejection (Huber, Cauchy, Tukey)
//! - **Point-to-Line ICP**: Full scan matching pipeline with IRLS support
//! - **Scratch Space**: Pre-allocated buffers for zero-allocation ICP
//!
//! # Usage
@@ -63,24 +64,28 @@ pub mod gauss_newton;
pub mod icp;
pub mod nearest_neighbor;
pub mod ransac;
pub mod robust;
pub mod scratch;
pub mod traits;
// Re-export main types
pub use correspondence::{
Correspondence, CorrespondenceSet, IDENTITY_COVARIANCE, MatchResult, PoseCovariance,
CornerCorrespondence, CornerCorrespondenceSet, Correspondence, CorrespondenceSet,
IDENTITY_COVARIANCE, MatchResult, PoseCovariance,
};
pub use gauss_newton::{GaussNewtonConfig, GaussNewtonResult, optimize_pose_fast};
pub use nearest_neighbor::{
NearestNeighborConfig, find_correspondences, find_correspondences_batch,
CornerMatchConfig, NearestNeighborConfig, find_corner_correspondences,
find_corner_correspondences_weighted, find_correspondences, find_correspondences_batch,
find_correspondences_spatial, find_correspondences_weighted, find_correspondences_with_angle,
};
// Re-export from icp submodule
pub use icp::{
CoarseSearchConfig, IcpConfig, OutlierRejection, PointToLineIcp, match_scan,
match_scan_with_config,
CoarseSearchConfig, IcpConfig, MultiResolutionConfig, PointToLineIcp, match_scan,
match_scan_multiresolution, match_scan_with_config,
};
pub use ransac::{RansacConfig, RansacResult, estimate_pose_ransac};
pub use robust::RobustCostFunction;
pub use scratch::IcpScratchSpace;
// Re-export traits
pub use traits::ScanMatcher;
+238 -2
View File
@@ -19,10 +19,12 @@
use crate::config::LidarNoiseModel;
use crate::core::Point2D;
use crate::features::{Line2D, LineCollection};
use crate::features::{Corner2D, Line2D, LineCollection};
use crate::integration::SpatialIndex;
use super::correspondence::{Correspondence, CorrespondenceSet};
use super::correspondence::{
CornerCorrespondence, CornerCorrespondenceSet, Correspondence, CorrespondenceSet,
};
/// Configuration for nearest neighbor matching.
///
@@ -505,6 +507,151 @@ pub fn find_correspondences_with_angle(
result
}
/// Configuration for corner correspondence matching.
#[derive(Clone, Debug)]
pub struct CornerMatchConfig {
/// Maximum distance from point to corner for a valid correspondence (meters).
/// Default: 0.15m (15cm)
pub max_distance: f32,
/// Whether to only keep the best correspondence per point.
/// Default: true
pub unique_per_point: bool,
}
impl Default for CornerMatchConfig {
fn default() -> Self {
Self {
max_distance: 0.15,
unique_per_point: true,
}
}
}
impl CornerMatchConfig {
/// Create a new configuration with default values.
pub fn new() -> Self {
Self::default()
}
/// Builder-style setter for maximum distance.
pub fn with_max_distance(mut self, max_distance: f32) -> Self {
self.max_distance = max_distance;
self
}
/// Builder-style setter for unique per point.
pub fn with_unique_per_point(mut self, unique: bool) -> Self {
self.unique_per_point = unique;
self
}
}
/// Find correspondences between scan points and map corners.
///
/// Uses simple nearest-neighbor matching to associate scan points with
/// map corners within a distance threshold.
///
/// # Arguments
/// * `points` - Scan points in world frame (after initial pose transform)
/// * `corners` - Map corners to match against
/// * `config` - Matching configuration
///
/// # Returns
/// Set of corner correspondences, where each point is matched to its
/// nearest corner within the distance threshold.
///
/// # Example
/// ```rust,ignore
/// use vastu_map::matching::{find_corner_correspondences, CornerMatchConfig};
///
/// let corners = vec![/* map corners */];
/// let points = vec![/* scan points in world frame */];
/// let config = CornerMatchConfig::default();
///
/// let corrs = find_corner_correspondences(&points, &corners, &config);
/// println!("Found {} corner matches", corrs.len());
/// ```
pub fn find_corner_correspondences(
points: &[Point2D],
corners: &[Corner2D],
config: &CornerMatchConfig,
) -> CornerCorrespondenceSet {
if points.is_empty() || corners.is_empty() {
return CornerCorrespondenceSet::new();
}
let mut result = CornerCorrespondenceSet::with_capacity(corners.len().min(points.len()));
let max_dist_sq = config.max_distance * config.max_distance;
for (point_idx, &point) in points.iter().enumerate() {
let mut best_distance_sq = max_dist_sq;
let mut best_corner_idx: Option<usize> = None;
for (corner_idx, corner) in corners.iter().enumerate() {
let dx = point.x - corner.position.x;
let dy = point.y - corner.position.y;
let distance_sq = dx * dx + dy * dy;
if distance_sq < best_distance_sq {
best_distance_sq = distance_sq;
best_corner_idx = Some(corner_idx);
// Early exit if we find a very close match
if distance_sq < 0.001 {
break;
}
}
}
if let Some(corner_idx) = best_corner_idx {
let distance = best_distance_sq.sqrt();
let corner = &corners[corner_idx];
result.push(CornerCorrespondence::new(
point_idx,
corner_idx,
point,
corner.position,
distance,
));
}
}
result
}
/// Find corner correspondences with weighting based on range.
///
/// Similar to `find_corner_correspondences`, but applies weights based on
/// the range from the sensor to each point using a lidar noise model.
///
/// # Arguments
/// * `points` - Scan points in world frame
/// * `robot_frame_points` - Original scan points in robot frame (for range computation)
/// * `corners` - Map corners to match against
/// * `config` - Matching configuration
/// * `noise_model` - Lidar noise model for weight computation
pub fn find_corner_correspondences_weighted(
points: &[Point2D],
robot_frame_points: &[Point2D],
corners: &[Corner2D],
config: &CornerMatchConfig,
noise_model: &LidarNoiseModel,
) -> CornerCorrespondenceSet {
let mut result = find_corner_correspondences(points, corners, config);
// Apply weights based on range
for corr in result.iter_mut() {
if corr.point_idx < robot_frame_points.len() {
let p = robot_frame_points[corr.point_idx];
let range = (p.x * p.x + p.y * p.y).sqrt();
corr.weight = noise_model.weight(range);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
@@ -909,4 +1056,93 @@ mod tests {
// Should match all three lines (distances: 0.1, 0.0, 0.1)
assert_eq!(corrs.len(), 3, "Should match all 3 parallel lines");
}
// === Corner correspondence tests ===
fn make_corner(x: f32, y: f32) -> Corner2D {
Corner2D::new(Point2D::new(x, y), 0, 1, std::f32::consts::FRAC_PI_2)
}
#[test]
fn test_find_corner_correspondences_basic() {
let corners = vec![
make_corner(0.0, 0.0),
make_corner(5.0, 0.0),
make_corner(5.0, 5.0),
make_corner(0.0, 5.0),
];
// Point near corner 0
let points = vec![Point2D::new(0.05, 0.02)];
let config = CornerMatchConfig::default();
let corrs = find_corner_correspondences(&points, &corners, &config);
assert_eq!(corrs.len(), 1);
assert_eq!(corrs.correspondences[0].corner_idx, 0);
// Distance should be sqrt(0.05^2 + 0.02^2) ≈ 0.054
assert!(corrs.correspondences[0].distance < 0.06);
}
#[test]
fn test_find_corner_correspondences_multiple_points() {
let corners = vec![
make_corner(0.0, 0.0),
make_corner(5.0, 0.0),
make_corner(5.0, 5.0),
make_corner(0.0, 5.0),
];
// Points near each corner
let points = vec![
Point2D::new(0.05, 0.02), // Near corner 0
Point2D::new(4.98, 0.01), // Near corner 1
Point2D::new(5.02, 4.97), // Near corner 2
Point2D::new(0.01, 5.03), // Near corner 3
];
let config = CornerMatchConfig::default();
let corrs = find_corner_correspondences(&points, &corners, &config);
assert_eq!(corrs.len(), 4);
// Check that each point matched the expected corner
assert_eq!(corrs.correspondences[0].corner_idx, 0);
assert_eq!(corrs.correspondences[1].corner_idx, 1);
assert_eq!(corrs.correspondences[2].corner_idx, 2);
assert_eq!(corrs.correspondences[3].corner_idx, 3);
}
#[test]
fn test_find_corner_correspondences_no_match() {
let corners = vec![make_corner(0.0, 0.0)];
let points = vec![Point2D::new(1.0, 1.0)]; // Too far from corner
let config = CornerMatchConfig::default().with_max_distance(0.1);
let corrs = find_corner_correspondences(&points, &corners, &config);
assert!(corrs.is_empty());
}
#[test]
fn test_find_corner_correspondences_empty_inputs() {
let config = CornerMatchConfig::default();
// Empty points
let corrs1 = find_corner_correspondences(&[], &[make_corner(0.0, 0.0)], &config);
assert!(corrs1.is_empty());
// Empty corners
let corrs2 = find_corner_correspondences(&[Point2D::new(0.0, 0.0)], &[], &config);
assert!(corrs2.is_empty());
}
#[test]
fn test_corner_match_config_builder() {
let config = CornerMatchConfig::new()
.with_max_distance(0.2)
.with_unique_per_point(false);
assert_eq!(config.max_distance, 0.2);
assert!(!config.unique_per_point);
}
}
+297
View File
@@ -0,0 +1,297 @@
//! Robust cost functions for outlier rejection in ICP.
//!
//! These M-estimators compute weights that down-weight outliers based on
//! residual magnitude, enabling soft outlier rejection without hard thresholds.
//!
//! # Usage
//!
//! ```rust,ignore
//! use vastu_map::matching::RobustCostFunction;
//!
//! // Huber is recommended for most cases
//! let cost = RobustCostFunction::Huber { delta: 0.03 };
//!
//! // Compute weight for a residual
//! let weight = cost.weight(0.05); // Returns ~0.6 (down-weighted)
//! ```
//!
//! # M-Estimator Comparison
//!
//! | Estimator | Behavior | Best For |
//! |-----------|----------|----------|
//! | Huber | Linear beyond delta | General use, balanced |
//! | Cauchy | Heavy-tailed | Cluttered environments |
//! | Tukey | Zero weight for outliers | Aggressive rejection |
//! | Geman-McClure | Redescending | Multimodal errors |
/// Robust cost function for weighted least squares (IRLS).
///
/// Computes weights that down-weight outliers based on residual magnitude.
/// Use with ICP to improve accuracy in presence of noise and clutter.
#[derive(Clone, Debug, PartialEq)]
pub enum RobustCostFunction {
/// No robust weighting (standard least squares).
/// Weight is always 1.0 regardless of residual.
None,
/// Huber loss: quadratic near zero, linear beyond delta.
///
/// Good balance between robustness and efficiency.
/// Recommended as default choice.
///
/// Weight: 1.0 if |r| ≤ δ, else δ/|r|
Huber {
/// Threshold where loss transitions from quadratic to linear.
/// Default: 0.03 (3cm)
delta: f32,
},
/// Cauchy loss (Lorentzian): heavy-tailed distribution.
///
/// More aggressive outlier rejection than Huber.
/// Good for cluttered environments with many outliers.
///
/// Weight: 1 / (1 + (r/scale)²)
Cauchy {
/// Scale parameter controlling tail heaviness.
/// Default: 0.05
scale: f32,
},
/// Tukey biweight: zero weight for large residuals.
///
/// Most aggressive - completely ignores outliers beyond threshold.
/// Use when outliers are clearly distinguishable.
///
/// Weight: (1 - (r/c)²)² if |r| ≤ c, else 0
Tukey {
/// Cutoff threshold. Residuals beyond this get zero weight.
/// Default: 0.1
c: f32,
},
/// Geman-McClure: redescending M-estimator.
///
/// Good for multimodal error distributions.
/// Weights decrease rapidly for large residuals.
///
/// Weight: 1 / (1 + (r/scale)²)²
GemanMcClure {
/// Scale parameter.
/// Default: 0.05
scale: f32,
},
}
impl RobustCostFunction {
/// Compute weight for IRLS (Iteratively Reweighted Least Squares).
///
/// Returns a weight in [0, 1] that should multiply the correspondence weight.
/// Small residuals get weight ≈ 1, large residuals get down-weighted.
///
/// # Arguments
/// * `residual` - The point-to-line distance (can be negative for signed distance)
///
/// # Returns
/// Weight in [0, 1] to apply to this correspondence
#[inline]
pub fn weight(&self, residual: f32) -> f32 {
let r = residual.abs();
match self {
Self::None => 1.0,
Self::Huber { delta } => {
if r <= *delta {
1.0
} else {
*delta / r
}
}
Self::Cauchy { scale } => {
let u = r / scale;
1.0 / (1.0 + u * u)
}
Self::Tukey { c } => {
if r <= *c {
let u = r / c;
let v = 1.0 - u * u;
v * v
} else {
0.0
}
}
Self::GemanMcClure { scale } => {
let u = r / scale;
let v = 1.0 + u * u;
1.0 / (v * v)
}
}
}
/// Create Huber cost with default threshold (3cm).
#[inline]
pub fn default_huber() -> Self {
Self::Huber { delta: 0.03 }
}
/// Create Cauchy cost with default scale (5cm).
#[inline]
pub fn default_cauchy() -> Self {
Self::Cauchy { scale: 0.05 }
}
/// Create Tukey cost with default cutoff (10cm).
#[inline]
pub fn default_tukey() -> Self {
Self::Tukey { c: 0.1 }
}
/// Create Geman-McClure cost with default scale (5cm).
#[inline]
pub fn default_geman_mcclure() -> Self {
Self::GemanMcClure { scale: 0.05 }
}
/// Check if this is the None variant (no robust weighting).
#[inline]
pub fn is_none(&self) -> bool {
matches!(self, Self::None)
}
/// Check if this cost function is enabled (not None).
#[inline]
pub fn is_enabled(&self) -> bool {
!self.is_none()
}
}
impl Default for RobustCostFunction {
/// Default to Huber with 3cm threshold.
fn default() -> Self {
Self::default_huber()
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn test_none_always_returns_one() {
let cost = RobustCostFunction::None;
assert_eq!(cost.weight(0.0), 1.0);
assert_eq!(cost.weight(0.1), 1.0);
assert_eq!(cost.weight(1.0), 1.0);
assert_eq!(cost.weight(-0.5), 1.0);
}
#[test]
fn test_huber_within_delta() {
let cost = RobustCostFunction::Huber { delta: 0.05 };
assert_eq!(cost.weight(0.0), 1.0);
assert_eq!(cost.weight(0.03), 1.0);
assert_eq!(cost.weight(0.05), 1.0);
assert_eq!(cost.weight(-0.03), 1.0);
}
#[test]
fn test_huber_beyond_delta() {
let cost = RobustCostFunction::Huber { delta: 0.05 };
// At r = 0.1, weight = 0.05 / 0.1 = 0.5
assert_relative_eq!(cost.weight(0.1), 0.5, epsilon = 1e-6);
// At r = 0.2, weight = 0.05 / 0.2 = 0.25
assert_relative_eq!(cost.weight(0.2), 0.25, epsilon = 1e-6);
}
#[test]
fn test_cauchy() {
let cost = RobustCostFunction::Cauchy { scale: 0.05 };
// At r = 0, weight = 1
assert_eq!(cost.weight(0.0), 1.0);
// At r = scale, weight = 1 / (1 + 1) = 0.5
assert_relative_eq!(cost.weight(0.05), 0.5, epsilon = 1e-6);
// At r = 2*scale, weight = 1 / (1 + 4) = 0.2
assert_relative_eq!(cost.weight(0.1), 0.2, epsilon = 1e-6);
}
#[test]
fn test_tukey_within_cutoff() {
let cost = RobustCostFunction::Tukey { c: 0.1 };
// At r = 0, weight = 1
assert_eq!(cost.weight(0.0), 1.0);
// At r = c/2, weight = (1 - 0.25)^2 = 0.5625
assert_relative_eq!(cost.weight(0.05), 0.5625, epsilon = 1e-6);
}
#[test]
fn test_tukey_beyond_cutoff() {
let cost = RobustCostFunction::Tukey { c: 0.1 };
// Beyond cutoff, weight = 0
assert_eq!(cost.weight(0.1), 0.0);
assert_eq!(cost.weight(0.2), 0.0);
assert_eq!(cost.weight(1.0), 0.0);
}
#[test]
fn test_geman_mcclure() {
let cost = RobustCostFunction::GemanMcClure { scale: 0.05 };
// At r = 0, weight = 1
assert_eq!(cost.weight(0.0), 1.0);
// At r = scale, weight = 1 / (1 + 1)^2 = 0.25
assert_relative_eq!(cost.weight(0.05), 0.25, epsilon = 1e-6);
}
#[test]
fn test_default_is_huber() {
let cost = RobustCostFunction::default();
assert!(matches!(cost, RobustCostFunction::Huber { delta } if (delta - 0.03).abs() < 1e-6));
}
#[test]
fn test_is_none() {
assert!(RobustCostFunction::None.is_none());
assert!(!RobustCostFunction::default_huber().is_none());
}
#[test]
fn test_is_enabled() {
assert!(!RobustCostFunction::None.is_enabled());
assert!(RobustCostFunction::default_huber().is_enabled());
}
#[test]
fn test_weights_are_symmetric() {
let costs = [
RobustCostFunction::None,
RobustCostFunction::default_huber(),
RobustCostFunction::default_cauchy(),
RobustCostFunction::default_tukey(),
RobustCostFunction::default_geman_mcclure(),
];
for cost in &costs {
assert_eq!(cost.weight(0.03), cost.weight(-0.03));
assert_eq!(cost.weight(0.1), cost.weight(-0.1));
}
}
#[test]
fn test_weights_decrease_with_residual() {
let costs = [
RobustCostFunction::default_huber(),
RobustCostFunction::default_cauchy(),
RobustCostFunction::default_geman_mcclure(),
];
for cost in &costs {
let w0 = cost.weight(0.0);
let w1 = cost.weight(0.05);
let w2 = cost.weight(0.1);
let w3 = cost.weight(0.2);
assert!(w0 >= w1, "Weight should decrease: {} >= {}", w0, w1);
assert!(w1 >= w2, "Weight should decrease: {} >= {}", w1, w2);
assert!(w2 >= w3, "Weight should decrease: {} >= {}", w2, w3);
}
}
}
+290
View File
@@ -0,0 +1,290 @@
//! Motion model for pose prediction.
//!
//! This is a utility struct for callers (e.g., dhruva-slam) to use when
//! computing initial pose estimates for ICP when odometry is unavailable
//! or unreliable.
//!
//! # Usage
//!
//! ```rust,ignore
//! use vastu_map::MotionModel;
//!
//! let mut motion = MotionModel::new();
//!
//! // After each successful ICP:
//! motion.update(icp_result.pose, current_time);
//!
//! // For next scan, if odometry unavailable:
//! let predicted = motion.predict(next_time);
//! let result = icp.match_scan(&points, &lines, predicted, &config);
//! ```
use crate::core::{Pose2D, math::angle_diff};
/// Constant-velocity motion model for pose prediction.
///
/// Maintains an exponentially-smoothed velocity estimate based on
/// observed pose changes, and uses this to predict future poses.
#[derive(Clone, Debug)]
pub struct MotionModel {
/// Last observed pose.
last_pose: Pose2D,
/// Smoothed velocity estimate (vx, vy, omega).
velocity: (f32, f32, f32),
/// Timestamp of last observation.
last_time: f64,
/// Smoothing factor for velocity updates (0-1).
/// Higher = more smoothing (slower response to changes).
smoothing: f32,
/// Whether the model has been initialized with at least one observation.
initialized: bool,
}
impl MotionModel {
/// Create a new motion model with default smoothing (0.7).
pub fn new() -> Self {
Self {
last_pose: Pose2D::identity(),
velocity: (0.0, 0.0, 0.0),
last_time: 0.0,
smoothing: 0.7,
initialized: false,
}
}
/// Create a motion model with custom smoothing factor.
///
/// # Arguments
/// * `smoothing` - Smoothing factor in [0, 1).
/// - 0.0 = no smoothing (velocity tracks instantly)
/// - 0.9 = high smoothing (slow response to velocity changes)
/// Default is 0.7 for a balance of responsiveness and stability.
pub fn with_smoothing(smoothing: f32) -> Self {
Self {
smoothing: smoothing.clamp(0.0, 0.99),
..Self::new()
}
}
/// Predict pose at a given time based on current velocity estimate.
///
/// Returns identity pose if not yet initialized.
///
/// # Arguments
/// * `current_time` - Time at which to predict (seconds)
pub fn predict(&self, current_time: f64) -> Pose2D {
if !self.initialized {
return Pose2D::identity();
}
let dt = (current_time - self.last_time) as f32;
if dt <= 0.0 {
return self.last_pose;
}
Pose2D::new(
self.last_pose.x + self.velocity.0 * dt,
self.last_pose.y + self.velocity.1 * dt,
self.last_pose.theta + self.velocity.2 * dt,
)
}
/// Update the motion model with a new pose observation.
///
/// This updates the internal velocity estimate using exponential smoothing.
///
/// # Arguments
/// * `new_pose` - Observed pose (typically from ICP result)
/// * `current_time` - Time of observation (seconds)
pub fn update(&mut self, new_pose: Pose2D, current_time: f64) {
if self.initialized {
let dt = (current_time - self.last_time) as f32;
if dt > 0.001 {
// Compute instantaneous velocity
let new_vx = (new_pose.x - self.last_pose.x) / dt;
let new_vy = (new_pose.y - self.last_pose.y) / dt;
let new_omega = angle_diff(self.last_pose.theta, new_pose.theta) / dt;
// Apply exponential smoothing
let s = self.smoothing;
self.velocity.0 = s * self.velocity.0 + (1.0 - s) * new_vx;
self.velocity.1 = s * self.velocity.1 + (1.0 - s) * new_vy;
self.velocity.2 = s * self.velocity.2 + (1.0 - s) * new_omega;
}
}
self.last_pose = new_pose;
self.last_time = current_time;
self.initialized = true;
}
/// Reset the motion model to uninitialized state.
pub fn reset(&mut self) {
*self = Self::with_smoothing(self.smoothing);
}
/// Get the current velocity estimate (vx, vy, omega).
///
/// Returns (0, 0, 0) if not initialized.
pub fn velocity(&self) -> (f32, f32, f32) {
if self.initialized {
self.velocity
} else {
(0.0, 0.0, 0.0)
}
}
/// Get the linear speed (magnitude of translational velocity).
pub fn speed(&self) -> f32 {
let (vx, vy, _) = self.velocity();
(vx * vx + vy * vy).sqrt()
}
/// Get the angular velocity (omega).
pub fn angular_velocity(&self) -> f32 {
self.velocity().2
}
/// Check if the model has been initialized.
pub fn is_initialized(&self) -> bool {
self.initialized
}
/// Get the last observed pose.
pub fn last_pose(&self) -> Pose2D {
self.last_pose
}
/// Get the timestamp of the last observation.
pub fn last_time(&self) -> f64 {
self.last_time
}
/// Set the smoothing factor.
pub fn set_smoothing(&mut self, smoothing: f32) {
self.smoothing = smoothing.clamp(0.0, 0.99);
}
}
impl Default for MotionModel {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn test_new_model_not_initialized() {
let model = MotionModel::new();
assert!(!model.is_initialized());
assert_eq!(model.velocity(), (0.0, 0.0, 0.0));
}
#[test]
fn test_predict_uninitialized_returns_identity() {
let model = MotionModel::new();
let predicted = model.predict(1.0);
assert_relative_eq!(predicted.x, 0.0);
assert_relative_eq!(predicted.y, 0.0);
assert_relative_eq!(predicted.theta, 0.0);
}
#[test]
fn test_first_update_initializes() {
let mut model = MotionModel::new();
model.update(Pose2D::new(1.0, 2.0, 0.5), 0.0);
assert!(model.is_initialized());
assert_relative_eq!(model.last_pose().x, 1.0);
assert_relative_eq!(model.last_pose().y, 2.0);
// First update has no velocity estimate
assert_eq!(model.velocity(), (0.0, 0.0, 0.0));
}
#[test]
fn test_velocity_estimation() {
let mut model = MotionModel::with_smoothing(0.0); // No smoothing for predictable test
// First observation
model.update(Pose2D::new(0.0, 0.0, 0.0), 0.0);
// Second observation: moved 1m in x over 1 second
model.update(Pose2D::new(1.0, 0.0, 0.0), 1.0);
let (vx, vy, omega) = model.velocity();
assert_relative_eq!(vx, 1.0, epsilon = 0.01);
assert_relative_eq!(vy, 0.0, epsilon = 0.01);
assert_relative_eq!(omega, 0.0, epsilon = 0.01);
}
#[test]
fn test_prediction() {
let mut model = MotionModel::with_smoothing(0.0);
model.update(Pose2D::new(0.0, 0.0, 0.0), 0.0);
model.update(Pose2D::new(1.0, 0.0, 0.0), 1.0);
// Predict 1 second ahead
let predicted = model.predict(2.0);
assert_relative_eq!(predicted.x, 2.0, epsilon = 0.01);
assert_relative_eq!(predicted.y, 0.0, epsilon = 0.01);
}
#[test]
fn test_smoothing_effect() {
let mut model = MotionModel::with_smoothing(0.5);
model.update(Pose2D::new(0.0, 0.0, 0.0), 0.0);
model.update(Pose2D::new(1.0, 0.0, 0.0), 1.0); // vx = 1.0
// With smoothing=0.5: new_vx = 0.5 * 0 + 0.5 * 1.0 = 0.5
let (vx, _, _) = model.velocity();
assert_relative_eq!(vx, 0.5, epsilon = 0.01);
// Another update with same velocity
model.update(Pose2D::new(2.0, 0.0, 0.0), 2.0); // instantaneous vx = 1.0
// new_vx = 0.5 * 0.5 + 0.5 * 1.0 = 0.75
let (vx, _, _) = model.velocity();
assert_relative_eq!(vx, 0.75, epsilon = 0.01);
}
#[test]
fn test_reset() {
let mut model = MotionModel::with_smoothing(0.8);
model.update(Pose2D::new(1.0, 1.0, 1.0), 1.0);
model.update(Pose2D::new(2.0, 2.0, 2.0), 2.0);
model.reset();
assert!(!model.is_initialized());
assert_eq!(model.velocity(), (0.0, 0.0, 0.0));
// Smoothing should be preserved
assert_relative_eq!(model.smoothing, 0.8);
}
#[test]
fn test_speed() {
let mut model = MotionModel::with_smoothing(0.0);
model.update(Pose2D::new(0.0, 0.0, 0.0), 0.0);
model.update(Pose2D::new(3.0, 4.0, 0.0), 1.0);
// Speed should be sqrt(3^2 + 4^2) = 5
assert_relative_eq!(model.speed(), 5.0, epsilon = 0.01);
}
#[test]
fn test_angular_velocity() {
let mut model = MotionModel::with_smoothing(0.0);
model.update(Pose2D::new(0.0, 0.0, 0.0), 0.0);
model.update(Pose2D::new(0.0, 0.0, 0.5), 1.0);
assert_relative_eq!(model.angular_velocity(), 0.5, epsilon = 0.01);
}
}
+33 -2
View File
@@ -1,6 +1,6 @@
//! Configuration for VectorMap.
use crate::extraction::{CornerConfig, SplitMergeConfig};
use crate::extraction::{CornerConfig, RansacLineConfig, SplitMergeConfig};
use crate::integration::{AssociationConfig, MergerConfig};
use crate::loop_closure::LoopClosureConfig;
use crate::matching::IcpConfig;
@@ -9,9 +9,19 @@ use crate::query::{FrontierConfig, OccupancyConfig};
/// Configuration for VectorMap.
#[derive(Clone, Debug)]
pub struct VectorMapConfig {
/// Configuration for line extraction.
/// Configuration for line extraction (split-merge).
pub extraction: SplitMergeConfig,
/// Configuration for RANSAC line extraction.
/// Used when `use_hybrid_extraction` is true.
pub ransac_extraction: RansacLineConfig,
/// Whether to use hybrid RANSAC + split-merge extraction.
/// When true, RANSAC extracts dominant lines first, then split-merge
/// processes remaining points. This is more robust to outliers.
/// Default: true
pub use_hybrid_extraction: bool,
/// Configuration for corner detection.
pub corner: CornerConfig,
@@ -52,6 +62,8 @@ impl Default for VectorMapConfig {
fn default() -> Self {
Self {
extraction: SplitMergeConfig::default(),
ransac_extraction: RansacLineConfig::default(),
use_hybrid_extraction: true,
corner: CornerConfig::default(),
matching: IcpConfig::default(),
association: AssociationConfig::default(),
@@ -192,4 +204,23 @@ impl VectorMapConfig {
self.loop_closure_enabled = enabled;
self
}
/// Builder-style setter for hybrid RANSAC + split-merge extraction.
///
/// When enabled, line extraction uses RANSAC to find dominant lines first,
/// then split-merge to process remaining points. This is more robust to
/// outliers and cluttered environments.
pub fn with_hybrid_extraction(mut self, enabled: bool) -> Self {
self.use_hybrid_extraction = enabled;
self
}
/// Builder-style setter for multi-resolution ICP.
///
/// When enabled, scan matching uses coarse-to-fine matching with
/// subsampled point clouds for faster convergence from poor initial guesses.
pub fn with_multi_resolution_icp(mut self, enabled: bool) -> Self {
self.matching.multi_resolution.enabled = enabled;
self
}
}
+13 -3
View File
@@ -1,7 +1,7 @@
//! VectorMap: The main SLAM map implementation.
use crate::core::{Bounds, Point2D, PointCloud2D, Pose2D};
use crate::extraction::{detect_corners, extract_lines};
use crate::extraction::{detect_corners, extract_lines, extract_lines_hybrid};
use crate::features::{Corner2D, FeatureSet, Line2D};
use crate::integration::{
batch_merge, create_new_line, find_associations, find_unmatched_scan_lines,
@@ -322,8 +322,18 @@ impl VectorMap {
fn extract_features(&self, scan_robot_frame: &PointCloud2D, pose: &Pose2D) -> FeatureSet {
let points: Vec<Point2D> = scan_robot_frame.iter().collect();
// Extract lines in robot frame (preserves angular ordering for split-merge)
let lines_robot = extract_lines(&points, &self.config.extraction);
// Extract lines in robot frame
let lines_robot = if self.config.use_hybrid_extraction {
// Hybrid: RANSAC for dominant lines, then split-merge for remaining
extract_lines_hybrid(
&points,
&self.config.ransac_extraction,
&self.config.extraction,
)
} else {
// Default: split-merge (preserves angular ordering)
extract_lines(&points, &self.config.extraction)
};
// Transform lines to world frame
let lines_world: Vec<Line2D> = lines_robot.iter().map(|l| l.transform(pose)).collect();
+1 -6
View File
@@ -3,7 +3,7 @@
//! sangam-io's lidar simulator produces `Vec<(f32, f32, u8)>` which maps
//! directly to vastu-map's `PolarScan.points` format.
use vastu_map::core::{PointCloud2D, PolarScan, Pose2D};
use vastu_map::core::{PointCloud2D, PolarScan};
/// Convert lidar scan data from sangam-io format to vastu-map PointCloud2D.
///
@@ -44,11 +44,6 @@ pub fn lidar_to_point_cloud_full(
polar.to_cartesian(min_quality, min_range, max_range)
}
/// Create a vastu-map Pose2D from physics state.
pub fn physics_to_pose(x: f32, y: f32, theta: f32) -> Pose2D {
Pose2D::new(x, y, theta)
}
#[cfg(test)]
mod tests {
use super::*;
+43 -72
View File
@@ -46,6 +46,8 @@ pub struct HarnessConfig {
pub dt: f32,
/// Lidar scan interval (in simulation steps)
pub lidar_interval: usize,
/// Fast mode: skip intermediate physics, run only at lidar rate (~22x speedup)
pub fast_mode: bool,
}
impl Default for HarnessConfig {
@@ -75,6 +77,7 @@ impl Default for HarnessConfig {
max_range: 8.0,
dt: 1.0 / 110.0, // 110Hz simulation
lidar_interval: 22, // ~5Hz lidar (110/22 = 5)
fast_mode: true, // Skip physics substeps for faster tests
}
}
}
@@ -135,20 +138,10 @@ pub struct ObservationRecord {
pub truth_pose: Pose2D,
/// Estimated pose from SLAM.
pub slam_pose: Pose2D,
/// Simulation time at this observation.
pub sim_time: f32,
/// Observation index (0-based).
pub observation_idx: usize,
/// ICP iterations for this observation.
pub icp_iterations: usize,
/// Whether ICP converged.
pub icp_converged: bool,
/// Match confidence (0.0-1.0).
pub confidence: f32,
/// Drift magnitude (translation error in meters).
pub drift_translation: f32,
/// Drift magnitude (rotation error in radians).
pub drift_rotation: f32,
/// Lidar scan at this observation (robot frame) - stored at intervals for visualization.
pub scan: Option<vastu_map::core::PointCloud2D>,
}
@@ -171,57 +164,20 @@ impl TrajectoryHistory {
&mut self,
truth_pose: Pose2D,
slam_pose: Pose2D,
sim_time: f32,
icp_iterations: usize,
icp_converged: bool,
confidence: f32,
scan: Option<vastu_map::core::PointCloud2D>,
) {
let drift_translation =
((truth_pose.x - slam_pose.x).powi(2) + (truth_pose.y - slam_pose.y).powi(2)).sqrt();
let drift_rotation = normalize_angle(truth_pose.theta - slam_pose.theta).abs();
self.observations.push(ObservationRecord {
truth_pose,
slam_pose,
sim_time,
observation_idx: self.observations.len(),
icp_iterations,
icp_converged,
confidence,
drift_translation,
drift_rotation,
scan,
});
}
/// Get ground truth path as a list of poses.
pub fn ground_truth_path(&self) -> Vec<Pose2D> {
self.observations.iter().map(|o| o.truth_pose).collect()
}
/// Get estimated path as a list of poses.
pub fn estimated_path(&self) -> Vec<Pose2D> {
self.observations.iter().map(|o| o.slam_pose).collect()
}
/// Get maximum drift (translation error) across all observations.
pub fn max_drift(&self) -> f32 {
self.observations
.iter()
.map(|o| o.drift_translation)
.fold(0.0, f32::max)
}
/// Check if trajectory is empty.
pub fn is_empty(&self) -> bool {
self.observations.is_empty()
}
/// Get number of observations.
pub fn len(&self) -> usize {
self.observations.len()
}
}
/// Main test harness for running integration tests.
@@ -306,26 +262,48 @@ impl TestHarness {
/// Run a single path segment.
fn run_segment(&mut self, segment: &PathSegment) {
let steps = (segment.duration / self.config.dt) as usize;
if self.config.fast_mode {
// Fast mode: skip intermediate physics, run only at lidar rate (~22x speedup)
// Each step covers the time of one lidar interval
let lidar_dt = self.config.dt * self.config.lidar_interval as f32;
let lidar_steps = (segment.duration / lidar_dt).max(1.0) as usize;
for _ in 0..steps {
// Update physics
self.physics.update(
self.config.dt,
segment.linear_vel,
segment.angular_vel,
&self.map,
&self.config.robot,
);
for _ in 0..lidar_steps {
// Single physics update covering the full lidar interval
self.physics.update(
lidar_dt,
segment.linear_vel,
segment.angular_vel,
&self.map,
&self.config.robot,
);
self.tick_count += 1;
self.sim_time += self.config.dt;
// Generate lidar scan at configured interval
if self.tick_count >= self.config.lidar_interval {
self.tick_count = 0;
self.sim_time += lidar_dt;
self.process_lidar_scan();
}
} else {
// Normal mode: full physics simulation at configured rate
let steps = (segment.duration / self.config.dt) as usize;
for _ in 0..steps {
// Update physics
self.physics.update(
self.config.dt,
segment.linear_vel,
segment.angular_vel,
&self.map,
&self.config.robot,
);
self.tick_count += 1;
self.sim_time += self.config.dt;
// Generate lidar scan at configured interval
if self.tick_count >= self.config.lidar_interval {
self.tick_count = 0;
self.process_lidar_scan();
}
}
}
}
@@ -385,15 +363,8 @@ impl TestHarness {
None
};
self.trajectory.record(
current_truth,
result.pose,
self.sim_time,
result.icp_iterations,
result.icp_converged,
result.confidence,
scan_to_store,
);
self.trajectory
.record(current_truth, result.pose, result.confidence, scan_to_store);
log::debug!(
"Observation {}: truth=({:.2}, {:.2}, {:.1}°), slam=({:.2}, {:.2}, {:.1}°), conf={:.2}, icp_iters={}",
+2 -50
View File
@@ -5,17 +5,13 @@
use vastu_map::core::Pose2D;
/// Odometry noise/drift configuration.
/// Odometry drift configuration.
#[derive(Clone, Debug)]
pub struct OdometryConfig {
/// Systematic drift per meter traveled (e.g., 0.02 = 2% drift)
pub drift_per_meter: f32,
/// Angular drift per radian rotated
pub angular_drift_per_rad: f32,
/// Random position noise standard deviation (meters)
pub position_noise_stddev: f32,
/// Random angular noise standard deviation (radians)
pub angular_noise_stddev: f32,
}
impl Default for OdometryConfig {
@@ -23,27 +19,15 @@ impl Default for OdometryConfig {
Self {
drift_per_meter: 0.0,
angular_drift_per_rad: 0.0,
position_noise_stddev: 0.0,
angular_noise_stddev: 0.0,
}
}
}
impl OdometryConfig {
/// Create a clean odometry config with no drift or noise.
/// Create a clean odometry config with no drift.
pub fn clean() -> Self {
Self::default()
}
/// Create a noisy odometry config for testing robustness.
pub fn noisy() -> Self {
Self {
drift_per_meter: 0.02, // 2% systematic drift
angular_drift_per_rad: 0.01, // 1% angular drift
position_noise_stddev: 0.005, // 5mm random noise
angular_noise_stddev: 0.002, // ~0.1 degree noise
}
}
}
/// Odometry estimator that tracks robot pose from encoder ticks.
@@ -147,38 +131,6 @@ impl OdometryEstimator {
self.accumulated_pose
}
/// Get the relative pose change since the last call to `relative_update`.
///
/// This is useful for feeding to VectorMap::observe() which expects
/// relative odometry.
pub fn relative_update(&mut self, left_ticks: u16, right_ticks: u16) -> Pose2D {
let prev_pose = self.accumulated_pose;
let new_pose = self.update(left_ticks, right_ticks);
// Compute relative transformation
let dx = new_pose.x - prev_pose.x;
let dy = new_pose.y - prev_pose.y;
let dtheta = normalize_angle(new_pose.theta - prev_pose.theta);
// Transform back to robot frame (inverse rotation)
let cos_theta = prev_pose.theta.cos();
let sin_theta = prev_pose.theta.sin();
let dx_robot = dx * cos_theta + dy * sin_theta;
let dy_robot = -dx * sin_theta + dy * cos_theta;
Pose2D::new(dx_robot, dy_robot, dtheta)
}
/// Get the current accumulated pose estimate.
pub fn pose(&self) -> Pose2D {
self.accumulated_pose
}
/// Reset the odometry to a given pose.
pub fn reset(&mut self, pose: Pose2D) {
self.accumulated_pose = pose;
}
}
/// Normalize angle to [-PI, PI] range.
@@ -38,10 +38,6 @@ mod colors {
pub const LIDAR_SCAN: &str = "#56B4E9";
/// Lidar scan history - lighter, semi-transparent
pub const LIDAR_SCAN_HISTORY: &str = "#56B4E9";
/// High confidence - green
pub const CONFIDENCE_HIGH: &str = "#00FF00";
/// Low confidence - red
pub const CONFIDENCE_LOW: &str = "#FF0000";
}
/// Interval for displaying drift vectors (every Nth observation).
@@ -462,69 +458,6 @@ impl Visualizer {
group
}
/// Render the SLAM result to an SVG file (legacy method for backward compatibility).
pub fn render(
&self,
slam: &VectorMap,
map: &SimulationMap,
final_pose: Pose2D,
truth_pose: Pose2D,
last_scan: Option<&PointCloud2D>,
) {
// Compute bounds from map
let map_width_m = map.width() as f32 * map.resolution();
let map_height_m = map.height() as f32 * map.resolution();
let width = (map_width_m * self.scale + 2.0 * self.margin) as i32;
let height = (map_height_m * self.scale + 2.0 * self.margin) as i32;
// Create document
let mut doc = Document::new()
.set("width", width)
.set("height", height)
.set("viewBox", (0, 0, width, height));
// Background
doc = doc.add(
Rectangle::new()
.set("x", 0)
.set("y", 0)
.set("width", width)
.set("height", height)
.set("fill", "white"),
);
// Map walls (gray)
let map_group = self.render_map_walls(map, height as f32);
doc = doc.add(map_group);
// Lidar scan points (cyan) - transformed to world frame using final pose
if let Some(scan) = last_scan {
let scan_group = self.render_lidar_scan(scan, final_pose, height as f32);
doc = doc.add(scan_group);
}
// Detected walls (blue solid)
let detected_group = self.render_detected(slam, height as f32);
doc = doc.add(detected_group);
// Poses
let pose_group = self.render_poses(final_pose, truth_pose, height as f32);
doc = doc.add(pose_group);
// Legend
let legend = self.render_legend(last_scan.is_some());
doc = doc.add(legend);
// Ensure output directory exists
if let Some(parent) = std::path::Path::new(&self.output_path).parent() {
std::fs::create_dir_all(parent).ok();
}
// Save
svg::save(&self.output_path, &doc).expect("Failed to save SVG");
}
/// Render map walls by sampling the occupancy grid.
fn render_map_walls(&self, map: &SimulationMap, svg_height: f32) -> Group {
let mut group = Group::new().set("id", "map_walls");
@@ -627,23 +560,6 @@ impl Visualizer {
group
}
fn render_legend(&self, has_scan: bool) -> Group {
let legend_text = if has_scan {
"Blue=Detected, Cyan=LidarScan, Gray=MapWalls, Green=TruePose, Red=EstimatedPose"
} else {
"Blue=Detected, Gray=MapWalls, Green=TruePose, Red=EstimatedPose"
};
Group::new().set("id", "legend").add(
Text::new(legend_text)
.set("x", 10)
.set("y", 20)
.set("font-size", 12)
.set("font-family", "monospace")
.set("fill", "black"),
)
}
fn transform_point(
&self,
p: Point2D,