Add Cartographer-style IMU fusion and motion filtering

Implement pose extrapolation with odometry + IMU blending and scan
insertion throttling for improved localization accuracy:

- ImuTracker: gravity estimation and gyroscope integration
- PoseExtrapolator: odometry-primary pose prediction with IMU blending
- MotionFilter: distance/rotation/time-based scan throttling
- ImuMeasurement: 6-axis sensor type with raw i16 conversion
- LocalizerConfig: optional motion filtering configuration
- Localizer: add_imu() and localize_with_timestamp() methods
This commit is contained in:
codetiger
2026-01-01 18:26:33 +05:30
parent e66268db0f
commit 5eff1eef55
11 changed files with 2140 additions and 7 deletions
+36 -1
View File
@@ -16,6 +16,9 @@
//! // Convert to runtime configs
//! let map_config = config.to_map_config();
//! let matcher_config = config.correlative_matcher_config();
//!
//! // Create localizer config with motion filtering (if configured)
//! let localizer_config = config.to_localizer_config();
//! ```
//!
//! ## Configuration Sections
@@ -24,7 +27,7 @@
//! |---------|-------------|
//! | [`GridSection`] | Grid dimensions, resolution, origin |
//! | [`SensorSection`] | Robot geometry, LiDAR, cliff, bumper |
//! | [`SlamSection`] | Matcher, loop closure, pose graph settings |
//! | [`SlamSection`] | Matcher, loop closure, pose graph, motion filtering |
//! | [`PersistenceSection`] | Output format and directory |
//!
//! ## Example YAML
@@ -45,6 +48,38 @@
//! correlative:
//! enabled: true
//! multi_resolution: true
//!
//! # Optional: IMU fusion for improved pose estimation
//! pose_extrapolator:
//! pose_queue_duration: 0.5 # seconds
//! odom_queue_duration: 0.5 # seconds
//! imu_gravity_time_constant: 10.0 # seconds
//! imu_rotation_weight: 0.3 # 0.0=odom only, 1.0=IMU only
//!
//! # Optional: Scan insertion throttling
//! motion_filter:
//! max_time_seconds: 5.0 # insert at least every 5s
//! max_distance_meters: 0.2 # insert if moved 20cm
//! max_angle_radians: 0.035 # insert if rotated ~2°
//! ```
//!
//! ## Motion Filtering
//!
//! Enable Cartographer-style motion filtering for improved accuracy:
//!
//! ```rust,ignore
//! use vastu_slam::config::VastuConfig;
//!
//! let config = VastuConfig::load_default()?;
//!
//! // Check if motion filtering is enabled
//! if config.has_motion_filtering() {
//! let extrapolator = config.pose_extrapolator_config();
//! let filter = config.motion_filter_config();
//! }
//!
//! // Create complete localizer config
//! let localizer_config = config.to_localizer_config();
//! ```
mod defaults;
+15
View File
@@ -2,6 +2,7 @@
use serde::{Deserialize, Serialize};
use crate::core::{MotionFilterConfig, PoseExtrapolatorConfig};
use crate::grid::LogOddsConfig;
use crate::matching::{
BackgroundOptimizerConfig, CorrelativeMatcherConfig, LoopClosureConfig, PoseGraphConfig,
@@ -29,4 +30,18 @@ pub struct SlamSection {
/// Background optimizer configuration
#[serde(default)]
pub background_optimizer: BackgroundOptimizerConfig,
/// Pose extrapolator configuration for IMU fusion.
///
/// When set, enables Cartographer-style pose extrapolation with
/// odometry (primary) and IMU (secondary) fusion.
#[serde(default)]
pub pose_extrapolator: Option<PoseExtrapolatorConfig>,
/// Motion filter configuration for scan insertion throttling.
///
/// When set, only processes scans when the robot has moved enough
/// (distance, rotation, or time since last scan).
#[serde(default)]
pub motion_filter: Option<MotionFilterConfig>,
}
+85
View File
@@ -4,10 +4,12 @@ use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::core::{MotionFilterConfig, PoseExtrapolatorConfig};
use crate::grid::MapConfig;
use crate::matching::{
BackgroundOptimizerConfig, CorrelativeMatcherConfig, LoopClosureConfig, PoseGraphConfig,
};
use crate::modes::LocalizerConfig;
use super::error::ConfigLoadError;
use super::grid::GridSection;
@@ -91,6 +93,37 @@ impl VastuConfig {
pub fn robot_radius(&self) -> f32 {
self.sensor.robot.radius
}
/// Get the pose extrapolator config (for IMU fusion).
///
/// Returns `None` if IMU fusion is not configured.
pub fn pose_extrapolator_config(&self) -> Option<PoseExtrapolatorConfig> {
self.slam.pose_extrapolator.clone()
}
/// Get the motion filter config (for scan throttling).
///
/// Returns `None` if motion filtering is not configured.
pub fn motion_filter_config(&self) -> Option<MotionFilterConfig> {
self.slam.motion_filter.clone()
}
/// Check if motion filtering (IMU fusion + scan throttling) is enabled.
pub fn has_motion_filtering(&self) -> bool {
self.slam.pose_extrapolator.is_some() || self.slam.motion_filter.is_some()
}
/// Create a LocalizerConfig from the SLAM settings.
///
/// This combines correlative matcher settings with optional motion filtering.
pub fn to_localizer_config(&self) -> LocalizerConfig {
LocalizerConfig {
matcher: self.slam.correlative.clone(),
pose_extrapolator: self.slam.pose_extrapolator.clone(),
motion_filter: self.slam.motion_filter.clone(),
..Default::default()
}
}
}
#[cfg(test)]
@@ -119,4 +152,56 @@ mod tests {
assert_eq!(map_config.grid.resolution, 0.025);
assert_eq!(map_config.sensor.robot_radius, 0.17);
}
#[test]
fn test_motion_filtering_disabled_by_default() {
let config = VastuConfig::default();
assert!(!config.has_motion_filtering());
assert!(config.pose_extrapolator_config().is_none());
assert!(config.motion_filter_config().is_none());
}
#[test]
fn test_motion_filtering_from_yaml() {
let yaml = r#"
slam:
pose_extrapolator:
pose_queue_duration: 0.5
imu_gravity_time_constant: 10.0
imu_rotation_weight: 0.3
motion_filter:
max_time_seconds: 5.0
max_distance_meters: 0.2
max_angle_radians: 0.035
"#;
let config = VastuConfig::from_yaml(yaml).unwrap();
assert!(config.has_motion_filtering());
let extrapolator = config.pose_extrapolator_config().unwrap();
assert!((extrapolator.imu_rotation_weight - 0.3).abs() < 0.01);
let filter = config.motion_filter_config().unwrap();
assert!((filter.max_distance_meters - 0.2).abs() < 0.01);
}
#[test]
fn test_to_localizer_config() {
let yaml = r#"
slam:
correlative:
enabled: true
linear_search_window: 0.5
pose_extrapolator:
imu_rotation_weight: 0.4
motion_filter:
max_distance_meters: 0.15
"#;
let config = VastuConfig::from_yaml(yaml).unwrap();
let localizer_config = config.to_localizer_config();
assert!(localizer_config.matcher.enabled);
assert!(localizer_config.pose_extrapolator.is_some());
assert!(localizer_config.motion_filter.is_some());
assert!((localizer_config.motion_filter.unwrap().max_distance_meters - 0.15).abs() < 0.01);
}
}
+454
View File
@@ -0,0 +1,454 @@
//! IMU tracking with gravity estimation.
//!
//! This module implements Cartographer-style IMU tracking using an
//! exponential filter for gravity estimation and gyroscope integration
//! for orientation tracking.
//!
//! # Algorithm
//!
//! The ImuTracker uses two main components:
//!
//! 1. **Gravity Estimation**: Exponential smoothing filter on accelerometer
//! readings to estimate the gravity direction. This allows detecting
//! when the robot is tilted.
//!
//! 2. **Orientation Integration**: Gyroscope readings are integrated over
//! time to track the robot's yaw angle.
//!
//! # Cartographer Approach
//!
//! This follows Google Cartographer's approach:
//! - Uses exponential filter instead of Kalman filter (simpler, works well)
//! - Time constant balances noise rejection vs responsiveness
//! - For 2D SLAM, primarily tracks yaw (rotation around gravity axis)
//!
//! # Example
//!
//! ```rust,ignore
//! use vastu_slam::core::{ImuTracker, ImuTrackerConfig, ImuMeasurement};
//!
//! let config = ImuTrackerConfig::default();
//! let mut tracker = ImuTracker::new(config);
//!
//! // Feed IMU measurements at ~110 Hz
//! tracker.add_imu(&imu_measurement);
//!
//! // Get current yaw estimate
//! let yaw = tracker.yaw();
//! ```
use serde::{Deserialize, Serialize};
use super::ImuMeasurement;
/// Configuration for the IMU tracker.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ImuTrackerConfig {
/// Time constant for gravity estimation filter (seconds).
///
/// Higher values make the filter slower to respond but more stable.
/// Lower values make it faster but noisier.
///
/// Default: 10.0 seconds (Cartographer default)
pub gravity_time_constant: f32,
/// Initial gravity vector assumption [x, y, z] in m/s².
///
/// For a level robot: [0, 0, 9.81]
/// Default: [0.0, 0.0, 9.81]
pub initial_gravity: [f32; 3],
}
impl Default for ImuTrackerConfig {
fn default() -> Self {
Self {
gravity_time_constant: 10.0,
initial_gravity: [0.0, 0.0, 9.81],
}
}
}
/// IMU tracker for gravity estimation and orientation tracking.
///
/// Uses Cartographer-style exponential smoothing for gravity and
/// gyroscope integration for orientation.
#[derive(Clone, Debug)]
pub struct ImuTracker {
/// Configuration
config: ImuTrackerConfig,
/// Estimated gravity vector (not normalized, in m/s²)
gravity: [f32; 3],
/// Current yaw angle (radians, CCW positive from initial heading)
yaw: f32,
/// Integrated pitch from gyroscope (for tilt compensation)
pitch: f32,
/// Integrated roll from gyroscope (for tilt compensation)
roll: f32,
/// Last IMU timestamp for delta calculation
last_timestamp_us: Option<u64>,
/// Number of IMU measurements processed
measurement_count: u64,
}
impl ImuTracker {
/// Create a new IMU tracker with the given configuration.
pub fn new(config: ImuTrackerConfig) -> Self {
let gravity = config.initial_gravity;
Self {
config,
gravity,
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
last_timestamp_us: None,
measurement_count: 0,
}
}
/// Process an IMU measurement.
///
/// Updates the gravity estimate using exponential smoothing and
/// integrates the gyroscope for orientation tracking.
pub fn add_imu(&mut self, imu: &ImuMeasurement) {
// Calculate time delta
let dt = if let Some(last_ts) = self.last_timestamp_us {
if imu.timestamp_us <= last_ts {
// Non-monotonic timestamp, skip
return;
}
(imu.timestamp_us - last_ts) as f32 / 1_000_000.0 // microseconds to seconds
} else {
// First measurement, just initialize
self.last_timestamp_us = Some(imu.timestamp_us);
self.gravity = imu.accel();
self.measurement_count += 1;
return;
};
// Sanity check on dt (reject unreasonably large gaps)
if dt > 1.0 {
// Gap too large, reset tracking
self.last_timestamp_us = Some(imu.timestamp_us);
self.gravity = imu.accel();
self.measurement_count += 1;
return;
}
// Update gravity using exponential filter
// alpha = exp(-dt / time_constant)
// gravity = alpha * gravity + (1 - alpha) * accel
let alpha = (-dt / self.config.gravity_time_constant).exp();
let accel = imu.accel();
self.gravity[0] = alpha * self.gravity[0] + (1.0 - alpha) * accel[0];
self.gravity[1] = alpha * self.gravity[1] + (1.0 - alpha) * accel[1];
self.gravity[2] = alpha * self.gravity[2] + (1.0 - alpha) * accel[2];
// Integrate gyroscope for orientation
// Simple Euler integration (sufficient for small dt)
self.roll += imu.gyro_x * dt;
self.pitch += imu.gyro_y * dt;
self.yaw += imu.gyro_z * dt;
// Normalize yaw to [-PI, PI]
self.yaw = normalize_angle(self.yaw);
self.last_timestamp_us = Some(imu.timestamp_us);
self.measurement_count += 1;
}
/// Get the current yaw angle estimate (radians, CCW positive).
///
/// This is the primary output for 2D SLAM - the rotation around
/// the gravity axis.
#[inline]
pub fn yaw(&self) -> f32 {
self.yaw
}
/// Get the current pitch angle estimate (radians).
#[inline]
pub fn pitch(&self) -> f32 {
self.pitch
}
/// Get the current roll angle estimate (radians).
#[inline]
pub fn roll(&self) -> f32 {
self.roll
}
/// Get the estimated gravity vector [x, y, z] in m/s².
///
/// For a level robot, this should be approximately [0, 0, 9.81].
#[inline]
pub fn gravity(&self) -> [f32; 3] {
self.gravity
}
/// Get the normalized gravity direction vector.
///
/// Returns a unit vector pointing in the gravity direction.
pub fn gravity_direction(&self) -> [f32; 3] {
let mag = (self.gravity[0] * self.gravity[0]
+ self.gravity[1] * self.gravity[1]
+ self.gravity[2] * self.gravity[2])
.sqrt();
if mag > 0.001 {
[
self.gravity[0] / mag,
self.gravity[1] / mag,
self.gravity[2] / mag,
]
} else {
[0.0, 0.0, 1.0] // Default to vertical if magnitude is too small
}
}
/// Get the tilt angle (radians) - angle between gravity and vertical.
///
/// Returns 0.0 when the robot is level, increases as it tilts.
pub fn tilt_angle(&self) -> f32 {
let dir = self.gravity_direction();
// Tilt is the angle between gravity direction and Z axis
// cos(theta) = dot(gravity_dir, [0,0,1]) = gravity_dir[2]
dir[2].clamp(-1.0, 1.0).acos()
}
/// Check if the robot is approximately level.
///
/// Returns true if tilt angle is less than the given threshold (radians).
pub fn is_level(&self, threshold_radians: f32) -> bool {
self.tilt_angle() < threshold_radians
}
/// Get the number of IMU measurements processed.
#[inline]
pub fn measurement_count(&self) -> u64 {
self.measurement_count
}
/// Reset the tracker to initial state.
pub fn reset(&mut self) {
self.gravity = self.config.initial_gravity;
self.yaw = 0.0;
self.pitch = 0.0;
self.roll = 0.0;
self.last_timestamp_us = None;
self.measurement_count = 0;
}
/// Reset only the yaw angle (useful when aligning with odometry).
pub fn reset_yaw(&mut self, new_yaw: f32) {
self.yaw = normalize_angle(new_yaw);
}
/// Advance the orientation estimate without a new IMU measurement.
///
/// Uses the last known angular velocity to extrapolate forward.
/// Useful for predicting pose between IMU samples.
pub fn advance(&mut self, dt_seconds: f32, angular_velocity_z: f32) {
self.yaw += angular_velocity_z * dt_seconds;
self.yaw = normalize_angle(self.yaw);
}
/// Get the last timestamp processed.
pub fn last_timestamp_us(&self) -> Option<u64> {
self.last_timestamp_us
}
}
/// Normalize angle to [-PI, PI] range.
#[inline]
fn normalize_angle(angle: f32) -> f32 {
let mut a = angle;
while a > std::f32::consts::PI {
a -= 2.0 * std::f32::consts::PI;
}
while a < -std::f32::consts::PI {
a += 2.0 * std::f32::consts::PI;
}
a
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_imu_tracker_creation() {
let tracker = ImuTracker::new(ImuTrackerConfig::default());
assert_eq!(tracker.yaw(), 0.0);
assert_eq!(tracker.measurement_count(), 0);
}
#[test]
fn test_gravity_estimation_level() {
let mut tracker = ImuTracker::new(ImuTrackerConfig::default());
// Feed level readings (gravity in Z)
for i in 0..100 {
let imu = ImuMeasurement::new(
i * 10000, // 10ms intervals
[0.0, 0.0, 9.81],
[0.0, 0.0, 0.0],
);
tracker.add_imu(&imu);
}
let gravity = tracker.gravity();
assert!((gravity[0]).abs() < 0.1);
assert!((gravity[1]).abs() < 0.1);
assert!((gravity[2] - 9.81).abs() < 0.1);
assert!(tracker.is_level(0.1)); // Less than 0.1 rad tilt
}
#[test]
fn test_yaw_integration() {
let mut tracker = ImuTracker::new(ImuTrackerConfig::default());
// Rotate at 1 rad/s for 1 second (100 samples at 10ms)
for i in 0..100 {
let imu = ImuMeasurement::new(
i * 10000, // 10ms intervals
[0.0, 0.0, 9.81],
[0.0, 0.0, 1.0], // 1 rad/s CCW
);
tracker.add_imu(&imu);
}
// Should have rotated about 1 radian (minus first sample)
let expected_yaw = 0.99; // 99 * 0.01s * 1 rad/s
assert!(
(tracker.yaw() - expected_yaw).abs() < 0.1,
"yaw = {}, expected ~{}",
tracker.yaw(),
expected_yaw
);
}
#[test]
fn test_yaw_integration_negative() {
let mut tracker = ImuTracker::new(ImuTrackerConfig::default());
// Rotate at -0.5 rad/s (CW) for 1 second
for i in 0..100 {
let imu = ImuMeasurement::new(
i * 10000,
[0.0, 0.0, 9.81],
[0.0, 0.0, -0.5], // -0.5 rad/s CW
);
tracker.add_imu(&imu);
}
let expected_yaw = -0.495; // 99 * 0.01s * -0.5 rad/s
assert!(
(tracker.yaw() - expected_yaw).abs() < 0.1,
"yaw = {}, expected ~{}",
tracker.yaw(),
expected_yaw
);
}
#[test]
fn test_reset_yaw() {
let mut tracker = ImuTracker::new(ImuTrackerConfig::default());
// Integrate some yaw
for i in 0..50 {
let imu = ImuMeasurement::new(i * 10000, [0.0, 0.0, 9.81], [0.0, 0.0, 1.0]);
tracker.add_imu(&imu);
}
assert!(tracker.yaw().abs() > 0.1);
// Reset yaw
tracker.reset_yaw(0.0);
assert_eq!(tracker.yaw(), 0.0);
}
#[test]
fn test_advance() {
let mut tracker = ImuTracker::new(ImuTrackerConfig::default());
// Initialize with one sample
let imu = ImuMeasurement::new(0, [0.0, 0.0, 9.81], [0.0, 0.0, 0.0]);
tracker.add_imu(&imu);
// Advance by 0.5 seconds at 1 rad/s
tracker.advance(0.5, 1.0);
assert!((tracker.yaw() - 0.5).abs() < 0.01);
}
#[test]
fn test_tilt_detection() {
let mut tracker = ImuTracker::new(ImuTrackerConfig {
gravity_time_constant: 0.1, // Fast response for test
initial_gravity: [0.0, 0.0, 9.81],
});
// Feed tilted readings (gravity in Y direction = 90 degree tilt)
for i in 0..100 {
let imu = ImuMeasurement::new(i * 10000, [0.0, 9.81, 0.0], [0.0, 0.0, 0.0]);
tracker.add_imu(&imu);
}
// Should detect significant tilt
assert!(tracker.tilt_angle() > 1.0); // More than 1 radian tilt
assert!(!tracker.is_level(0.5));
}
#[test]
fn test_normalize_angle() {
assert!((normalize_angle(0.0) - 0.0).abs() < 1e-6);
assert!((normalize_angle(std::f32::consts::PI) - std::f32::consts::PI).abs() < 1e-6);
assert!(
(normalize_angle(2.0 * std::f32::consts::PI) - 0.0).abs() < 1e-6,
"2PI should normalize to 0"
);
assert!(
(normalize_angle(-2.0 * std::f32::consts::PI) - 0.0).abs() < 1e-6,
"-2PI should normalize to 0"
);
assert!(
(normalize_angle(3.0 * std::f32::consts::PI) - std::f32::consts::PI).abs() < 1e-6,
"3PI should normalize to PI"
);
}
#[test]
fn test_non_monotonic_timestamp() {
let mut tracker = ImuTracker::new(ImuTrackerConfig::default());
tracker.add_imu(&ImuMeasurement::new(
100000,
[0.0, 0.0, 9.81],
[0.0, 0.0, 1.0],
));
tracker.add_imu(&ImuMeasurement::new(
200000,
[0.0, 0.0, 9.81],
[0.0, 0.0, 1.0],
));
let yaw_before = tracker.yaw();
let count_before = tracker.measurement_count();
// Non-monotonic timestamp should be ignored
tracker.add_imu(&ImuMeasurement::new(
150000,
[0.0, 0.0, 9.81],
[0.0, 0.0, 10.0],
));
assert_eq!(tracker.yaw(), yaw_before);
assert_eq!(tracker.measurement_count(), count_before);
}
}
+52 -2
View File
@@ -25,14 +25,20 @@
//! - [`LidarScan`]: 360° range measurements in polar coordinates
//! - [`CliffSensors`]: Four IR floor drop-off detectors
//! - [`BumperSensors`]: Two mechanical collision sensors
//! - [`ImuMeasurement`]: 6-axis IMU readings (accelerometer + gyroscope)
//! - [`SensorObservation`]: Combined sensor snapshot at a timestamp
//!
//! ### Motion Filtering (Cartographer-style)
//! - [`ImuTracker`]: Gravity estimation and orientation tracking from IMU
//! - [`PoseExtrapolator`]: Pose prediction with odometry + IMU fusion
//! - [`MotionFilter`]: Scan insertion throttling based on motion
//!
//! ### SIMD Operations
//! - [`PointCloud`]: Structure-of-Arrays point storage for vectorized operations
//! - [`RotationMatrix4`]: Pre-computed rotation for batch transforms
//! - [`GridCoordBatch`]: Batch of grid coordinates for SIMD processing
//!
//! ## Example
//! ## Example: Basic Types
//!
//! ```rust,ignore
//! use vastu_slam::core::{Pose2D, WorldPoint, LidarScan};
@@ -50,6 +56,40 @@
//! let points = PointCloud::from_scan(&scan);
//! let world_points = transform_points(&points, &pose);
//! ```
//!
//! ## Example: Motion Filtering with IMU
//!
//! ```rust,ignore
//! use vastu_slam::core::{
//! ImuMeasurement, ImuTracker, ImuTrackerConfig,
//! PoseExtrapolator, PoseExtrapolatorConfig,
//! MotionFilter, MotionFilterConfig,
//! Pose2D,
//! };
//!
//! // Create components with default configs
//! let mut imu_tracker = ImuTracker::new(ImuTrackerConfig::default());
//! let mut extrapolator = PoseExtrapolator::new(PoseExtrapolatorConfig::default());
//! let mut filter = MotionFilter::new(MotionFilterConfig::default());
//!
//! // Feed IMU at high rate (~110 Hz)
//! let imu = ImuMeasurement::from_raw(timestamp_us, [0, 0, 100], [0, 0, 16384]);
//! imu_tracker.add_imu(&imu);
//! extrapolator.add_imu(&imu);
//!
//! // Feed odometry
//! let odom_delta = Pose2D::new(0.01, 0.0, 0.001); // 1cm forward, 0.06° rotation
//! extrapolator.add_odometry(odom_delta, timestamp_us);
//!
//! // Get extrapolated pose
//! let predicted = extrapolator.extrapolate_pose(timestamp_us + 50_000);
//!
//! // Check if we should process a scan
//! if filter.should_insert(predicted, timestamp_us) {
//! // Process lidar scan
//! filter.accept(predicted, timestamp_us);
//! }
//! ```
mod cell;
mod motion;
@@ -57,11 +97,21 @@ mod point;
mod pose;
mod sensors;
// Motion filtering modules (Cartographer-style)
mod imu_tracker;
mod motion_filter;
mod pose_extrapolator;
pub mod simd;
pub use cell::{Cell, CellType};
pub use motion::{MotionModel, Odometry};
pub use point::{GridCoord, WorldPoint};
pub use pose::{Pose2D, interpolate_pose, normalize_angle};
pub use sensors::{BumperSensors, CliffSensors, LidarScan, SensorObservation};
pub use sensors::{BumperSensors, CliffSensors, ImuMeasurement, LidarScan, SensorObservation};
pub use simd::{GridCoordBatch, PointCloud, RotationMatrix4};
// Motion filtering exports
pub use imu_tracker::{ImuTracker, ImuTrackerConfig};
pub use motion_filter::{MotionFilter, MotionFilterConfig};
pub use pose_extrapolator::{PoseExtrapolator, PoseExtrapolatorConfig, TimedOdometry, TimedPose};
+450
View File
@@ -0,0 +1,450 @@
//! Motion filter for scan insertion throttling.
//!
//! This module implements Cartographer-style motion filtering that controls
//! when lidar scans are inserted into the map. By requiring minimum motion
//! between insertions, we reduce computational load and prevent redundant
//! map updates.
//!
//! # Insertion Criteria
//!
//! A scan is inserted when ANY of these conditions are met:
//!
//! 1. **Time**: Maximum time since last insertion exceeded
//! 2. **Distance**: Robot has moved beyond distance threshold
//! 3. **Rotation**: Robot has rotated beyond angle threshold
//!
//! # Cartographer Defaults
//!
//! The default values are based on Cartographer's 2D SLAM configuration:
//! - Max time: 5.0 seconds (insert at least every 5s)
//! - Max distance: 0.2 meters (20cm)
//! - Max angle: 0.035 radians (~2°)
//!
//! # Example
//!
//! ```rust,ignore
//! use vastu_slam::core::{MotionFilter, MotionFilterConfig, Pose2D};
//!
//! let config = MotionFilterConfig::default();
//! let mut filter = MotionFilter::new(config);
//!
//! // Check if scan should be inserted
//! if filter.should_insert(current_pose, timestamp_us) {
//! // Process and insert the scan
//! map.insert_scan(&scan);
//!
//! // Mark as accepted
//! filter.accept(current_pose, timestamp_us);
//! }
//! ```
use serde::{Deserialize, Serialize};
use super::Pose2D;
/// Configuration for the motion filter.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MotionFilterConfig {
/// Maximum time between scan insertions (seconds).
///
/// If this much time has passed since the last insertion,
/// insert a new scan regardless of motion.
///
/// Default: 5.0 seconds
#[serde(default = "default_max_time_seconds")]
pub max_time_seconds: f32,
/// Minimum distance to trigger scan insertion (meters).
///
/// If the robot has moved this far since the last insertion,
/// insert a new scan.
///
/// Default: 0.2 meters (20cm)
#[serde(default = "default_max_distance_meters")]
pub max_distance_meters: f32,
/// Minimum rotation to trigger scan insertion (radians).
///
/// If the robot has rotated this much since the last insertion,
/// insert a new scan.
///
/// Default: 0.035 radians (~2°)
#[serde(default = "default_max_angle_radians")]
pub max_angle_radians: f32,
}
fn default_max_time_seconds() -> f32 {
5.0
}
fn default_max_distance_meters() -> f32 {
0.2
}
fn default_max_angle_radians() -> f32 {
0.035
}
impl Default for MotionFilterConfig {
fn default() -> Self {
Self {
max_time_seconds: 5.0,
max_distance_meters: 0.2,
max_angle_radians: 0.035, // ~2 degrees
}
}
}
impl MotionFilterConfig {
/// Create a config for high-frequency insertion (more scans).
///
/// Useful for detailed mapping or when computational resources allow.
pub fn high_frequency() -> Self {
Self {
max_time_seconds: 2.0,
max_distance_meters: 0.1,
max_angle_radians: 0.02,
}
}
/// Create a config for low-frequency insertion (fewer scans).
///
/// Useful for resource-constrained systems or large environments.
pub fn low_frequency() -> Self {
Self {
max_time_seconds: 10.0,
max_distance_meters: 0.5,
max_angle_radians: 0.1,
}
}
}
/// Motion filter for controlling scan insertion rate.
///
/// Tracks robot motion and decides when to insert scans based on
/// distance, rotation, and time thresholds.
#[derive(Clone, Debug)]
pub struct MotionFilter {
/// Configuration
config: MotionFilterConfig,
/// Last accepted pose
last_pose: Option<Pose2D>,
/// Last accepted timestamp (microseconds)
last_timestamp_us: Option<u64>,
/// Number of scans accepted
accepted_count: u64,
/// Number of scans rejected
rejected_count: u64,
}
impl MotionFilter {
/// Create a new motion filter with the given configuration.
pub fn new(config: MotionFilterConfig) -> Self {
Self {
config,
last_pose: None,
last_timestamp_us: None,
accepted_count: 0,
rejected_count: 0,
}
}
/// Check if a scan should be inserted.
///
/// Returns `true` if any of the following conditions are met:
/// - This is the first scan
/// - Time since last insertion exceeds `max_time_seconds`
/// - Distance moved exceeds `max_distance_meters`
/// - Rotation exceeds `max_angle_radians`
pub fn should_insert(&self, pose: Pose2D, timestamp_us: u64) -> bool {
// Always accept first scan
let (last_pose, last_ts) = match (self.last_pose, self.last_timestamp_us) {
(Some(p), Some(t)) => (p, t),
_ => return true,
};
// Check time threshold
let dt_seconds = (timestamp_us.saturating_sub(last_ts)) as f32 / 1_000_000.0;
if dt_seconds >= self.config.max_time_seconds {
return true;
}
// Check distance threshold
let dx = pose.x - last_pose.x;
let dy = pose.y - last_pose.y;
let distance = (dx * dx + dy * dy).sqrt();
if distance >= self.config.max_distance_meters {
return true;
}
// Check angle threshold
let dtheta = normalize_angle(pose.theta - last_pose.theta).abs();
if dtheta >= self.config.max_angle_radians {
return true;
}
false
}
/// Mark a pose as accepted (scan was inserted).
///
/// Call this after successfully inserting a scan to update
/// the filter's state.
pub fn accept(&mut self, pose: Pose2D, timestamp_us: u64) {
self.last_pose = Some(pose);
self.last_timestamp_us = Some(timestamp_us);
self.accepted_count += 1;
}
/// Mark a scan as rejected (not inserted).
///
/// Call this when `should_insert` returns false to track statistics.
pub fn reject(&mut self) {
self.rejected_count += 1;
}
/// Get the number of accepted scans.
#[inline]
pub fn accepted_count(&self) -> u64 {
self.accepted_count
}
/// Get the number of rejected scans.
#[inline]
pub fn rejected_count(&self) -> u64 {
self.rejected_count
}
/// Get the total number of scans considered.
#[inline]
pub fn total_count(&self) -> u64 {
self.accepted_count + self.rejected_count
}
/// Get the acceptance rate (0.0 - 1.0).
pub fn acceptance_rate(&self) -> f32 {
let total = self.total_count();
if total == 0 {
1.0
} else {
self.accepted_count as f32 / total as f32
}
}
/// Get the last accepted pose.
#[inline]
pub fn last_pose(&self) -> Option<Pose2D> {
self.last_pose
}
/// Get the last accepted timestamp.
#[inline]
pub fn last_timestamp_us(&self) -> Option<u64> {
self.last_timestamp_us
}
/// Get time since last insertion (seconds).
///
/// Returns `None` if no scan has been accepted yet.
pub fn time_since_last(&self, current_timestamp_us: u64) -> Option<f32> {
self.last_timestamp_us
.map(|last_ts| (current_timestamp_us.saturating_sub(last_ts)) as f32 / 1_000_000.0)
}
/// Get distance since last insertion (meters).
///
/// Returns `None` if no scan has been accepted yet.
pub fn distance_since_last(&self, current_pose: Pose2D) -> Option<f32> {
self.last_pose.map(|last_pose| {
let dx = current_pose.x - last_pose.x;
let dy = current_pose.y - last_pose.y;
(dx * dx + dy * dy).sqrt()
})
}
/// Get rotation since last insertion (radians, absolute).
///
/// Returns `None` if no scan has been accepted yet.
pub fn rotation_since_last(&self, current_pose: Pose2D) -> Option<f32> {
self.last_pose
.map(|last_pose| normalize_angle(current_pose.theta - last_pose.theta).abs())
}
/// Reset the filter state.
pub fn reset(&mut self) {
self.last_pose = None;
self.last_timestamp_us = None;
self.accepted_count = 0;
self.rejected_count = 0;
}
/// Reset only the statistics, keeping the last pose/timestamp.
pub fn reset_stats(&mut self) {
self.accepted_count = 0;
self.rejected_count = 0;
}
}
/// Normalize angle to [-PI, PI] range.
#[inline]
fn normalize_angle(angle: f32) -> f32 {
let mut a = angle;
while a > std::f32::consts::PI {
a -= 2.0 * std::f32::consts::PI;
}
while a < -std::f32::consts::PI {
a += 2.0 * std::f32::consts::PI;
}
a
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_motion_filter_creation() {
let filter = MotionFilter::new(MotionFilterConfig::default());
assert_eq!(filter.accepted_count(), 0);
assert_eq!(filter.rejected_count(), 0);
assert!(filter.last_pose().is_none());
}
#[test]
fn test_first_scan_always_accepted() {
let filter = MotionFilter::new(MotionFilterConfig::default());
let pose = Pose2D::new(0.0, 0.0, 0.0);
assert!(filter.should_insert(pose, 0));
}
#[test]
fn test_no_motion_rejected() {
let mut filter = MotionFilter::new(MotionFilterConfig::default());
let pose = Pose2D::new(0.0, 0.0, 0.0);
// Accept first scan
filter.accept(pose, 0);
// Same pose, short time later - should be rejected
assert!(!filter.should_insert(pose, 100000)); // 0.1 seconds later
}
#[test]
fn test_distance_threshold() {
let config = MotionFilterConfig {
max_distance_meters: 0.2,
max_time_seconds: 1000.0, // Disable time trigger
max_angle_radians: 1000.0, // Disable angle trigger
};
let mut filter = MotionFilter::new(config);
let pose1 = Pose2D::new(0.0, 0.0, 0.0);
filter.accept(pose1, 0);
// Small movement - rejected
let pose2 = Pose2D::new(0.1, 0.0, 0.0);
assert!(!filter.should_insert(pose2, 100000));
// Large movement - accepted
let pose3 = Pose2D::new(0.25, 0.0, 0.0);
assert!(filter.should_insert(pose3, 200000));
}
#[test]
fn test_angle_threshold() {
let config = MotionFilterConfig {
max_distance_meters: 1000.0, // Disable distance trigger
max_time_seconds: 1000.0, // Disable time trigger
max_angle_radians: 0.1,
};
let mut filter = MotionFilter::new(config);
let pose1 = Pose2D::new(0.0, 0.0, 0.0);
filter.accept(pose1, 0);
// Small rotation - rejected
let pose2 = Pose2D::new(0.0, 0.0, 0.05);
assert!(!filter.should_insert(pose2, 100000));
// Large rotation - accepted
let pose3 = Pose2D::new(0.0, 0.0, 0.15);
assert!(filter.should_insert(pose3, 200000));
}
#[test]
fn test_time_threshold() {
let config = MotionFilterConfig {
max_distance_meters: 1000.0, // Disable distance trigger
max_time_seconds: 1.0,
max_angle_radians: 1000.0, // Disable angle trigger
};
let mut filter = MotionFilter::new(config);
let pose = Pose2D::new(0.0, 0.0, 0.0);
filter.accept(pose, 0);
// Short time - rejected
assert!(!filter.should_insert(pose, 500000)); // 0.5 seconds
// Long time - accepted
assert!(filter.should_insert(pose, 1500000)); // 1.5 seconds
}
#[test]
fn test_statistics() {
let mut filter = MotionFilter::new(MotionFilterConfig::default());
let pose = Pose2D::new(0.0, 0.0, 0.0);
filter.accept(pose, 0);
filter.reject();
filter.reject();
filter.accept(Pose2D::new(1.0, 0.0, 0.0), 1000000);
assert_eq!(filter.accepted_count(), 2);
assert_eq!(filter.rejected_count(), 2);
assert_eq!(filter.total_count(), 4);
assert!((filter.acceptance_rate() - 0.5).abs() < 0.01);
}
#[test]
fn test_distance_since_last() {
let mut filter = MotionFilter::new(MotionFilterConfig::default());
let pose1 = Pose2D::new(0.0, 0.0, 0.0);
filter.accept(pose1, 0);
let pose2 = Pose2D::new(3.0, 4.0, 0.0);
let dist = filter.distance_since_last(pose2).unwrap();
assert!((dist - 5.0).abs() < 0.01); // 3-4-5 triangle
}
#[test]
fn test_reset() {
let mut filter = MotionFilter::new(MotionFilterConfig::default());
filter.accept(Pose2D::new(1.0, 2.0, 0.5), 1000000);
filter.reject();
filter.reset();
assert!(filter.last_pose().is_none());
assert_eq!(filter.accepted_count(), 0);
assert_eq!(filter.rejected_count(), 0);
}
#[test]
fn test_high_frequency_config() {
let config = MotionFilterConfig::high_frequency();
assert!(config.max_distance_meters < 0.2);
assert!(config.max_time_seconds < 5.0);
}
#[test]
fn test_low_frequency_config() {
let config = MotionFilterConfig::low_frequency();
assert!(config.max_distance_meters > 0.2);
assert!(config.max_time_seconds > 5.0);
}
}
+552
View File
@@ -0,0 +1,552 @@
//! Pose extrapolation with odometry and IMU fusion.
//!
//! This module implements Cartographer-style pose extrapolation, combining
//! wheel odometry (primary) with IMU data (secondary) for improved pose
//! estimation.
//!
//! # Algorithm
//!
//! The PoseExtrapolator maintains:
//!
//! 1. **Odometry Queue**: Recent odometry measurements for velocity estimation
//! 2. **Pose Queue**: Recent scan-matched poses for drift correction
//! 3. **IMU Tracker**: Gyroscope-based orientation tracking
//!
//! # Priority Scheme
//!
//! - **Translation**: 100% from wheel odometry (most accurate for ground robots)
//! - **Rotation**: Weighted blend of odometry and IMU
//! - Default: 70% odometry + 30% IMU
//! - Configurable via `imu_rotation_weight`
//!
//! # Example
//!
//! ```rust,ignore
//! use vastu_slam::core::{PoseExtrapolator, PoseExtrapolatorConfig, ImuMeasurement, Pose2D};
//!
//! let config = PoseExtrapolatorConfig::default();
//! let mut extrapolator = PoseExtrapolator::new(config);
//!
//! // Feed sensor data at high rate
//! extrapolator.add_odometry(odom_delta, timestamp_us);
//! extrapolator.add_imu(&imu);
//!
//! // Extrapolate pose when needed (e.g., at lidar scan time)
//! let predicted_pose = extrapolator.extrapolate_pose(scan_timestamp_us);
//!
//! // After scan matching, add corrected pose
//! extrapolator.add_pose(matched_pose, timestamp_us);
//! ```
use std::collections::VecDeque;
use serde::{Deserialize, Serialize};
use super::imu_tracker::{ImuTracker, ImuTrackerConfig};
use super::{ImuMeasurement, Pose2D};
/// Configuration for the pose extrapolator.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PoseExtrapolatorConfig {
/// Duration to keep poses in the queue for velocity estimation (seconds).
///
/// Longer durations give smoother velocity estimates but may be less
/// responsive to changes.
///
/// Default: 0.5 seconds
#[serde(default = "default_pose_queue_duration")]
pub pose_queue_duration: f32,
/// Duration to keep odometry in the queue (seconds).
///
/// Default: 0.5 seconds
#[serde(default = "default_odom_queue_duration")]
pub odom_queue_duration: f32,
/// IMU gravity time constant (seconds).
///
/// Passed to the internal ImuTracker.
///
/// Default: 10.0 seconds
#[serde(default = "default_imu_gravity_time_constant")]
pub imu_gravity_time_constant: f32,
/// Weight for IMU rotation vs odometry rotation [0.0 - 1.0].
///
/// - 0.0: Use only odometry for rotation
/// - 1.0: Use only IMU for rotation
/// - 0.3: 70% odometry + 30% IMU (default, odometry primary)
///
/// Default: 0.3
#[serde(default = "default_imu_rotation_weight")]
pub imu_rotation_weight: f32,
}
fn default_pose_queue_duration() -> f32 {
0.5
}
fn default_odom_queue_duration() -> f32 {
0.5
}
fn default_imu_gravity_time_constant() -> f32 {
10.0
}
fn default_imu_rotation_weight() -> f32 {
0.3
}
impl Default for PoseExtrapolatorConfig {
fn default() -> Self {
Self {
pose_queue_duration: 0.5,
odom_queue_duration: 0.5,
imu_gravity_time_constant: 10.0,
imu_rotation_weight: 0.3,
}
}
}
/// A pose with timestamp.
#[derive(Clone, Copy, Debug)]
pub struct TimedPose {
/// The pose in world frame.
pub pose: Pose2D,
/// Timestamp in microseconds when this pose was recorded.
pub timestamp_us: u64,
}
/// An odometry delta with timestamp.
#[derive(Clone, Copy, Debug)]
pub struct TimedOdometry {
/// Pose delta in robot frame (dx forward, dy left, dtheta CCW)
pub delta: Pose2D,
/// Timestamp when this odometry was measured
pub timestamp_us: u64,
}
/// Pose extrapolator with odometry and IMU fusion.
///
/// Combines wheel odometry (primary for translation) with IMU
/// (secondary for rotation) using Cartographer-style algorithms.
#[derive(Clone, Debug)]
pub struct PoseExtrapolator {
/// Configuration
config: PoseExtrapolatorConfig,
/// IMU tracker for orientation
imu_tracker: ImuTracker,
/// Recent poses from scan matching (for velocity estimation)
pose_queue: VecDeque<TimedPose>,
/// Recent odometry deltas
odom_queue: VecDeque<TimedOdometry>,
/// Current pose estimate
current_pose: Pose2D,
/// Last pose timestamp
last_pose_timestamp_us: Option<u64>,
/// Cumulative odometry since last pose update
cumulative_odom: Pose2D,
/// Last odometry timestamp
last_odom_timestamp_us: Option<u64>,
/// Estimated linear velocity (m/s) in world frame [vx, vy]
linear_velocity: [f32; 2],
/// Estimated angular velocity (rad/s)
angular_velocity: f32,
/// IMU yaw at last pose update (for relative tracking)
imu_yaw_at_last_pose: f32,
}
impl PoseExtrapolator {
/// Create a new pose extrapolator with the given configuration.
pub fn new(config: PoseExtrapolatorConfig) -> Self {
let imu_config = ImuTrackerConfig {
gravity_time_constant: config.imu_gravity_time_constant,
initial_gravity: [0.0, 0.0, 9.81],
};
Self {
config,
imu_tracker: ImuTracker::new(imu_config),
pose_queue: VecDeque::new(),
odom_queue: VecDeque::new(),
current_pose: Pose2D::default(),
last_pose_timestamp_us: None,
cumulative_odom: Pose2D::default(),
last_odom_timestamp_us: None,
linear_velocity: [0.0, 0.0],
angular_velocity: 0.0,
imu_yaw_at_last_pose: 0.0,
}
}
/// Add an IMU measurement (secondary for rotation).
pub fn add_imu(&mut self, imu: &ImuMeasurement) {
self.imu_tracker.add_imu(imu);
}
/// Add an odometry delta (primary for translation).
///
/// The delta should be in robot frame (dx forward, dy left, dtheta CCW).
pub fn add_odometry(&mut self, delta: Pose2D, timestamp_us: u64) {
// Store in queue
self.odom_queue.push_back(TimedOdometry {
delta,
timestamp_us,
});
// Trim old entries
let cutoff_us =
timestamp_us.saturating_sub((self.config.odom_queue_duration * 1_000_000.0) as u64);
while self
.odom_queue
.front()
.is_some_and(|o| o.timestamp_us < cutoff_us)
{
self.odom_queue.pop_front();
}
// Accumulate odometry
self.cumulative_odom = self.cumulative_odom.compose(&delta);
self.last_odom_timestamp_us = Some(timestamp_us);
// Update velocity estimate from odometry
self.update_velocity_from_odometry();
}
/// Add a pose from scan matching (for velocity estimation and drift correction).
///
/// Call this after successful scan matching to update the extrapolator
/// with the corrected pose.
pub fn add_pose(&mut self, pose: Pose2D, timestamp_us: u64) {
// Store in queue
self.pose_queue.push_back(TimedPose { pose, timestamp_us });
// Trim old entries
let cutoff_us =
timestamp_us.saturating_sub((self.config.pose_queue_duration * 1_000_000.0) as u64);
while self
.pose_queue
.front()
.is_some_and(|p| p.timestamp_us < cutoff_us)
{
self.pose_queue.pop_front();
}
// Update current pose
self.current_pose = pose;
self.last_pose_timestamp_us = Some(timestamp_us);
// Reset cumulative odometry
self.cumulative_odom = Pose2D::default();
// Record IMU yaw at this pose
self.imu_yaw_at_last_pose = self.imu_tracker.yaw();
// Update velocity estimate from pose history
self.update_velocity_from_poses();
}
/// Extrapolate the pose to the given timestamp.
///
/// Uses odometry for translation and a weighted blend of odometry
/// and IMU for rotation.
pub fn extrapolate_pose(&self, timestamp_us: u64) -> Pose2D {
// Start with current pose
let mut pose = self.current_pose;
// Apply cumulative odometry (translation from odometry, rotation blended)
let odom_theta = self.cumulative_odom.theta;
// Get IMU rotation delta since last pose
let imu_theta_delta = self.imu_tracker.yaw() - self.imu_yaw_at_last_pose;
// Blend odometry and IMU rotation
let blended_theta = (1.0 - self.config.imu_rotation_weight) * odom_theta
+ self.config.imu_rotation_weight * imu_theta_delta;
// Create blended delta
let blended_delta = Pose2D::new(
self.cumulative_odom.x,
self.cumulative_odom.y,
blended_theta,
);
// Apply delta to pose
pose = pose.compose(&blended_delta);
// Extrapolate beyond last odometry using velocity
if let Some(last_odom_ts) = self.last_odom_timestamp_us
&& timestamp_us > last_odom_ts
{
let dt = (timestamp_us - last_odom_ts) as f32 / 1_000_000.0;
if dt < 1.0 {
// Reasonable extrapolation window
let dx = self.linear_velocity[0] * dt;
let dy = self.linear_velocity[1] * dt;
let dtheta = self.angular_velocity * dt;
pose = pose.compose(&Pose2D::new(dx, dy, dtheta));
}
}
pose
}
/// Get the current pose estimate (without extrapolation).
#[inline]
pub fn current_pose(&self) -> Pose2D {
self.current_pose
}
/// Get the estimated linear velocity [vx, vy] in m/s (world frame).
#[inline]
pub fn linear_velocity(&self) -> [f32; 2] {
self.linear_velocity
}
/// Get the estimated angular velocity in rad/s.
#[inline]
pub fn angular_velocity(&self) -> f32 {
self.angular_velocity
}
/// Get a reference to the internal IMU tracker.
#[inline]
pub fn imu_tracker(&self) -> &ImuTracker {
&self.imu_tracker
}
/// Get a mutable reference to the internal IMU tracker.
#[inline]
pub fn imu_tracker_mut(&mut self) -> &mut ImuTracker {
&mut self.imu_tracker
}
/// Set the initial pose.
///
/// Call this before starting to set the initial robot position.
pub fn set_initial_pose(&mut self, pose: Pose2D, timestamp_us: u64) {
self.current_pose = pose;
self.last_pose_timestamp_us = Some(timestamp_us);
self.cumulative_odom = Pose2D::default();
self.imu_yaw_at_last_pose = self.imu_tracker.yaw();
self.pose_queue.clear();
self.odom_queue.clear();
}
/// Reset the extrapolator to initial state.
pub fn reset(&mut self) {
self.imu_tracker.reset();
self.pose_queue.clear();
self.odom_queue.clear();
self.current_pose = Pose2D::default();
self.last_pose_timestamp_us = None;
self.cumulative_odom = Pose2D::default();
self.last_odom_timestamp_us = None;
self.linear_velocity = [0.0, 0.0];
self.angular_velocity = 0.0;
self.imu_yaw_at_last_pose = 0.0;
}
/// Update velocity estimate from odometry queue.
fn update_velocity_from_odometry(&mut self) {
if self.odom_queue.len() < 2 {
return;
}
let first = self.odom_queue.front().unwrap();
let last = self.odom_queue.back().unwrap();
let dt = (last.timestamp_us.saturating_sub(first.timestamp_us)) as f32 / 1_000_000.0;
if dt < 0.01 {
return; // Not enough time span
}
// Sum all deltas in the queue
let mut total_delta = Pose2D::default();
for odom in &self.odom_queue {
total_delta = total_delta.compose(&odom.delta);
}
// Compute velocity (in robot frame, then rotate to world frame)
let vx_robot = total_delta.x / dt;
let vy_robot = total_delta.y / dt;
self.angular_velocity = total_delta.theta / dt;
// Rotate to world frame using current heading
let cos_theta = self.current_pose.theta.cos();
let sin_theta = self.current_pose.theta.sin();
self.linear_velocity[0] = vx_robot * cos_theta - vy_robot * sin_theta;
self.linear_velocity[1] = vx_robot * sin_theta + vy_robot * cos_theta;
}
/// Update velocity estimate from pose queue.
fn update_velocity_from_poses(&mut self) {
if self.pose_queue.len() < 2 {
return;
}
let first = self.pose_queue.front().unwrap();
let last = self.pose_queue.back().unwrap();
let dt = (last.timestamp_us.saturating_sub(first.timestamp_us)) as f32 / 1_000_000.0;
if dt < 0.01 {
return; // Not enough time span
}
// Compute velocity from pose difference
let dx = last.pose.x - first.pose.x;
let dy = last.pose.y - first.pose.y;
let dtheta = normalize_angle(last.pose.theta - first.pose.theta);
self.linear_velocity[0] = dx / dt;
self.linear_velocity[1] = dy / dt;
self.angular_velocity = dtheta / dt;
}
}
/// Normalize angle to [-PI, PI] range.
#[inline]
fn normalize_angle(angle: f32) -> f32 {
let mut a = angle;
while a > std::f32::consts::PI {
a -= 2.0 * std::f32::consts::PI;
}
while a < -std::f32::consts::PI {
a += 2.0 * std::f32::consts::PI;
}
a
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pose_extrapolator_creation() {
let config = PoseExtrapolatorConfig::default();
let extrapolator = PoseExtrapolator::new(config);
assert_eq!(extrapolator.current_pose(), Pose2D::default());
assert_eq!(extrapolator.linear_velocity(), [0.0, 0.0]);
assert_eq!(extrapolator.angular_velocity(), 0.0);
}
#[test]
fn test_set_initial_pose() {
let mut extrapolator = PoseExtrapolator::new(PoseExtrapolatorConfig::default());
let initial = Pose2D::new(1.0, 2.0, 0.5);
extrapolator.set_initial_pose(initial, 1000000);
assert_eq!(extrapolator.current_pose().x, 1.0);
assert_eq!(extrapolator.current_pose().y, 2.0);
assert!((extrapolator.current_pose().theta - 0.5).abs() < 1e-6);
}
#[test]
fn test_odometry_accumulation() {
let mut extrapolator = PoseExtrapolator::new(PoseExtrapolatorConfig::default());
extrapolator.set_initial_pose(Pose2D::default(), 0);
// Add odometry: move forward 1m
extrapolator.add_odometry(Pose2D::new(0.5, 0.0, 0.0), 100000);
extrapolator.add_odometry(Pose2D::new(0.5, 0.0, 0.0), 200000);
let predicted = extrapolator.extrapolate_pose(200000);
assert!((predicted.x - 1.0).abs() < 0.1, "x = {}", predicted.x);
assert!((predicted.y - 0.0).abs() < 0.1, "y = {}", predicted.y);
}
#[test]
fn test_pose_update() {
let mut extrapolator = PoseExtrapolator::new(PoseExtrapolatorConfig::default());
extrapolator.set_initial_pose(Pose2D::default(), 0);
// Add some odometry
extrapolator.add_odometry(Pose2D::new(1.0, 0.0, 0.1), 100000);
// Add corrected pose from scan matching
let matched_pose = Pose2D::new(0.95, 0.05, 0.08); // Slightly different
extrapolator.add_pose(matched_pose, 100000);
// Current pose should be the matched pose
assert_eq!(extrapolator.current_pose().x, 0.95);
assert_eq!(extrapolator.current_pose().y, 0.05);
}
#[test]
fn test_imu_rotation_blending() {
let config = PoseExtrapolatorConfig {
imu_rotation_weight: 0.5, // 50% IMU, 50% odometry
..Default::default()
};
let mut extrapolator = PoseExtrapolator::new(config);
extrapolator.set_initial_pose(Pose2D::default(), 0);
// Add IMU readings with rotation
for i in 0..10 {
let imu = ImuMeasurement::new(
i * 10000, // 10ms intervals
[0.0, 0.0, 9.81],
[0.0, 0.0, 1.0], // 1 rad/s yaw
);
extrapolator.add_imu(&imu);
}
// Add odometry with different rotation
extrapolator.add_odometry(Pose2D::new(0.1, 0.0, 0.2), 100000); // 0.2 rad from odom
let predicted = extrapolator.extrapolate_pose(100000);
// IMU would give ~0.09 rad (9 samples * 0.01s * 1 rad/s)
// Odom gives 0.2 rad
// Blended: 0.5 * 0.2 + 0.5 * 0.09 ≈ 0.145
// This is approximate due to timing
assert!(predicted.theta.abs() > 0.05, "theta = {}", predicted.theta);
assert!(predicted.theta.abs() < 0.3, "theta = {}", predicted.theta);
}
#[test]
fn test_velocity_estimation() {
let mut extrapolator = PoseExtrapolator::new(PoseExtrapolatorConfig::default());
extrapolator.set_initial_pose(Pose2D::default(), 0);
// Add several odometry samples (moving at 1 m/s forward)
for i in 1..=10 {
// 0.1m every 100ms = 1 m/s
extrapolator.add_odometry(Pose2D::new(0.1, 0.0, 0.0), i * 100000);
}
let vel = extrapolator.linear_velocity();
// Should be approximately 1 m/s in x (allowing some tolerance for edge effects)
assert!(vel[0] > 0.8, "vx = {}", vel[0]);
assert!(vel[0] < 1.5, "vx = {}", vel[0]);
assert!(vel[1].abs() < 0.1, "vy = {}", vel[1]);
}
#[test]
fn test_reset() {
let mut extrapolator = PoseExtrapolator::new(PoseExtrapolatorConfig::default());
extrapolator.set_initial_pose(Pose2D::new(5.0, 5.0, 1.0), 1000000);
extrapolator.add_odometry(Pose2D::new(1.0, 0.0, 0.1), 2000000);
extrapolator.reset();
assert_eq!(extrapolator.current_pose(), Pose2D::default());
assert_eq!(extrapolator.linear_velocity(), [0.0, 0.0]);
}
}
+215
View File
@@ -355,6 +355,125 @@ impl BumperSensors {
}
}
/// IMU measurement from inertial measurement unit (6-axis).
///
/// All values are in SI units (m/s², rad/s). The coordinate frame follows
/// ROS REP-103: X forward, Y left, Z up.
///
/// # Conversion from SangamIO raw i16 values
///
/// SangamIO streams raw i16 values that need conversion:
/// - gyro: `raw_i16 / 1000.0` → rad/s
/// - accel: `raw_i16 / 16384.0 * 9.81` → m/s²
///
/// Use [`ImuMeasurement::from_raw`] for automatic conversion.
///
/// # Usage for 2D SLAM
///
/// For 2D ground robots, primarily use:
/// - `gyro_z`: Yaw rate (rotation around vertical axis)
/// - `accel_x`, `accel_y`: For gravity estimation (tilt detection)
///
/// # Example
///
/// ```rust,ignore
/// use vastu_slam::core::ImuMeasurement;
///
/// // From SangamIO raw values
/// let imu = ImuMeasurement::from_raw(
/// timestamp_us,
/// [gyro_x_raw, gyro_y_raw, gyro_z_raw],
/// [accel_x_raw, accel_y_raw, accel_z_raw],
/// );
///
/// // Yaw rate for rotation estimation
/// let yaw_rate = imu.gyro_z; // rad/s, CCW positive
/// ```
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ImuMeasurement {
/// Timestamp in microseconds since epoch
pub timestamp_us: u64,
/// Linear acceleration X in m/s² (forward)
pub accel_x: f32,
/// Linear acceleration Y in m/s² (left)
pub accel_y: f32,
/// Linear acceleration Z in m/s² (up, includes gravity: ~+9.81 when level)
pub accel_z: f32,
/// Angular velocity X in rad/s (roll rate, rotation around forward axis)
pub gyro_x: f32,
/// Angular velocity Y in rad/s (pitch rate, rotation around left axis)
pub gyro_y: f32,
/// Angular velocity Z in rad/s (yaw rate, rotation around up axis, CCW positive)
/// This is the PRIMARY value for 2D SLAM rotation estimation.
pub gyro_z: f32,
}
/// Scale factor for converting raw gyroscope i16 to rad/s
const GYRO_SCALE: f32 = 1000.0;
/// Scale factor for converting raw accelerometer i16 to g units
const ACCEL_SCALE: f32 = 16384.0;
/// Standard gravity in m/s²
const G: f32 = 9.81;
impl ImuMeasurement {
/// Create a new IMU measurement with SI units
pub fn new(timestamp_us: u64, accel: [f32; 3], gyro: [f32; 3]) -> Self {
Self {
timestamp_us,
accel_x: accel[0],
accel_y: accel[1],
accel_z: accel[2],
gyro_x: gyro[0],
gyro_y: gyro[1],
gyro_z: gyro[2],
}
}
/// Create from SangamIO raw i16 values
///
/// Converts raw sensor values to SI units:
/// - gyro: raw / 1000.0 → rad/s
/// - accel: raw / 16384.0 * 9.81 → m/s²
pub fn from_raw(timestamp_us: u64, gyro: [i16; 3], accel: [i16; 3]) -> Self {
Self {
timestamp_us,
gyro_x: gyro[0] as f32 / GYRO_SCALE,
gyro_y: gyro[1] as f32 / GYRO_SCALE,
gyro_z: gyro[2] as f32 / GYRO_SCALE,
accel_x: accel[0] as f32 / ACCEL_SCALE * G,
accel_y: accel[1] as f32 / ACCEL_SCALE * G,
accel_z: accel[2] as f32 / ACCEL_SCALE * G,
}
}
/// Get acceleration as array [x, y, z] in m/s²
#[inline]
pub fn accel(&self) -> [f32; 3] {
[self.accel_x, self.accel_y, self.accel_z]
}
/// Get angular velocity as array [x, y, z] in rad/s
#[inline]
pub fn gyro(&self) -> [f32; 3] {
[self.gyro_x, self.gyro_y, self.gyro_z]
}
/// Get acceleration magnitude in m/s²
#[inline]
pub fn accel_magnitude(&self) -> f32 {
(self.accel_x * self.accel_x + self.accel_y * self.accel_y + self.accel_z * self.accel_z)
.sqrt()
}
/// Get angular velocity magnitude in rad/s
#[inline]
pub fn gyro_magnitude(&self) -> f32 {
(self.gyro_x * self.gyro_x + self.gyro_y * self.gyro_y + self.gyro_z * self.gyro_z).sqrt()
}
}
/// Combined sensor observation for a single timestep.
///
/// Bundles all sensor readings taken at approximately the same time,
@@ -365,6 +484,7 @@ impl BumperSensors {
///
/// Not all sensors report at the same rate:
/// - **Lidar**: ~5 Hz (every ~200ms) - may be `None` on most ticks
/// - **IMU**: ~110 Hz - optional, for motion filtering
/// - **Cliff/Bumper**: ~110 Hz - always available
///
/// # Processing Order
@@ -383,6 +503,8 @@ pub struct SensorObservation {
pub cliffs: CliffSensors,
/// Bumper sensor states (event-driven, only true on collision)
pub bumpers: BumperSensors,
/// IMU measurement (optional, ~110 Hz, for motion filtering)
pub imu: Option<ImuMeasurement>,
/// Timestamp in microseconds since epoch (for temporal ordering)
pub timestamp_us: u64,
}
@@ -395,6 +517,7 @@ impl SensorObservation {
lidar: None,
cliffs: CliffSensors::SAFE,
bumpers: BumperSensors::CLEAR,
imu: None,
timestamp_us,
}
}
@@ -406,6 +529,36 @@ impl SensorObservation {
lidar: Some(lidar),
cliffs: CliffSensors::SAFE,
bumpers: BumperSensors::CLEAR,
imu: None,
timestamp_us,
}
}
/// Create an observation with lidar and IMU
pub fn with_lidar_and_imu(
pose: Pose2D,
lidar: LidarScan,
imu: ImuMeasurement,
timestamp_us: u64,
) -> Self {
Self {
pose,
lidar: Some(lidar),
cliffs: CliffSensors::SAFE,
bumpers: BumperSensors::CLEAR,
imu: Some(imu),
timestamp_us,
}
}
/// Create an observation with IMU only (no lidar)
pub fn with_imu(pose: Pose2D, imu: ImuMeasurement, timestamp_us: u64) -> Self {
Self {
pose,
lidar: None,
cliffs: CliffSensors::SAFE,
bumpers: BumperSensors::CLEAR,
imu: Some(imu),
timestamp_us,
}
}
@@ -414,6 +567,11 @@ impl SensorObservation {
pub fn any_triggered(&self) -> bool {
self.cliffs.any_triggered() || self.bumpers.any_triggered()
}
/// Check if IMU data is available
pub fn has_imu(&self) -> bool {
self.imu.is_some()
}
}
#[cfg(test)]
@@ -512,4 +670,61 @@ mod tests {
assert!((world_points[0].x - 0.0).abs() < 1e-5);
assert!((world_points[0].y - 1.0).abs() < 1e-5);
}
#[test]
fn test_imu_measurement_from_raw() {
// Test conversion from raw i16 values
let imu = ImuMeasurement::from_raw(
1000000, // 1 second timestamp
[1000, 0, -500], // gyro raw: 1 rad/s X, 0 Y, -0.5 rad/s Z
[0, 0, 16384], // accel raw: 0, 0, 1g (level)
);
// Check gyro conversion: raw / 1000.0 = rad/s
assert!((imu.gyro_x - 1.0).abs() < 1e-6);
assert!((imu.gyro_y - 0.0).abs() < 1e-6);
assert!((imu.gyro_z - (-0.5)).abs() < 1e-6);
// Check accel conversion: raw / 16384.0 * 9.81 = m/s²
assert!((imu.accel_x - 0.0).abs() < 1e-6);
assert!((imu.accel_y - 0.0).abs() < 1e-6);
assert!((imu.accel_z - 9.81).abs() < 0.01);
}
#[test]
fn test_imu_measurement_new() {
let imu = ImuMeasurement::new(
500000,
[0.0, 0.0, 9.81], // accel
[0.1, 0.0, -0.2], // gyro
);
assert_eq!(imu.timestamp_us, 500000);
assert_eq!(imu.accel(), [0.0, 0.0, 9.81]);
assert_eq!(imu.gyro(), [0.1, 0.0, -0.2]);
}
#[test]
fn test_imu_measurement_magnitudes() {
let imu = ImuMeasurement::new(
0,
[3.0, 4.0, 0.0], // accel: magnitude = 5.0
[0.0, 0.0, 1.0], // gyro: magnitude = 1.0
);
assert!((imu.accel_magnitude() - 5.0).abs() < 1e-6);
assert!((imu.gyro_magnitude() - 1.0).abs() < 1e-6);
}
#[test]
fn test_sensor_observation_with_imu() {
let pose = Pose2D::new(1.0, 2.0, 0.5);
let imu = ImuMeasurement::default();
let obs = SensorObservation::with_imu(pose, imu, 123456);
assert!(obs.has_imu());
assert!(obs.lidar.is_none());
assert_eq!(obs.timestamp_us, 123456);
}
}
+26 -2
View File
@@ -18,6 +18,8 @@
//! - **Scan Matching**: Correlative and branch-and-bound scan-to-map alignment
//! - **Loop Closure**: LiDAR-IRIS descriptors for place recognition
//! - **Submap Architecture**: Cartographer-style reversible map updates
//! - **IMU Fusion**: Cartographer-style pose extrapolation with odometry + IMU blending
//! - **Motion Filtering**: Scan insertion throttling based on distance/rotation/time
//!
//! ## Quick Start
//!
@@ -36,6 +38,27 @@
//! println!("Updated {} cells", result.cells_updated);
//! ```
//!
//! ## Localization with IMU Fusion
//!
//! ```rust,ignore
//! use vastu_slam::modes::{Localizer, LocalizerConfig};
//! use vastu_slam::core::ImuMeasurement;
//!
//! // Create localizer with motion filtering enabled
//! let config = LocalizerConfig::with_motion_filtering();
//! let mut localizer = Localizer::new(map, config);
//! localizer.set_initial_pose(Pose2D::new(0.0, 0.0, 0.0));
//!
//! // Feed IMU at high rate (~110 Hz)
//! localizer.add_imu(&ImuMeasurement::from_raw(timestamp_us, gyro, accel));
//!
//! // Localize with motion filtering
//! let result = localizer.localize_with_timestamp(&scan, Some(odom_delta), timestamp_us);
//! if !result.skipped && result.converged {
//! println!("Localized at {:?}", result.pose);
//! }
//! ```
//!
//! ## Coordinate System
//!
//! Uses ROS REP-103 convention:
@@ -72,8 +95,9 @@ pub mod evaluation;
// Re-export commonly used types
pub use core::{
BumperSensors, Cell, CellType, CliffSensors, GridCoord, LidarScan, MotionModel, Odometry,
Pose2D, SensorObservation, WorldPoint,
BumperSensors, Cell, CellType, CliffSensors, GridCoord, ImuMeasurement, ImuTracker,
ImuTrackerConfig, LidarScan, MotionFilter, MotionFilterConfig, MotionModel, Odometry, Pose2D,
PoseExtrapolator, PoseExtrapolatorConfig, SensorObservation, WorldPoint,
};
pub use grid::{CellCounts, ConfigError, GridConfig, GridStorage, MapConfig, SensorConfig};
+213 -2
View File
@@ -41,7 +41,10 @@
use serde::{Deserialize, Serialize};
use crate::core::{LidarScan, Pose2D};
use crate::core::{
ImuMeasurement, LidarScan, MotionFilter, MotionFilterConfig, Pose2D, PoseExtrapolator,
PoseExtrapolatorConfig,
};
use crate::grid::GridStorage;
use crate::matching::{CorrelativeMatcher, CorrelativeMatcherConfig, ScanMatchResult};
@@ -91,6 +94,20 @@ pub struct LocalizerConfig {
/// Default: 0.5 rad (~28.6°)
#[serde(default = "default_max_angular_deviation")]
pub max_angular_deviation: f32,
/// Optional pose extrapolator configuration for IMU fusion.
///
/// When set, enables Cartographer-style pose extrapolation with
/// odometry (primary) and IMU (secondary) fusion.
#[serde(default)]
pub pose_extrapolator: Option<PoseExtrapolatorConfig>,
/// Optional motion filter configuration for scan insertion throttling.
///
/// When set, only processes scans when the robot has moved enough
/// (distance, rotation, or time since last scan).
#[serde(default)]
pub motion_filter: Option<MotionFilterConfig>,
}
fn default_min_score() -> f32 {
@@ -110,6 +127,23 @@ impl Default for LocalizerConfig {
min_score: default_min_score(),
max_linear_deviation: default_max_linear_deviation(),
max_angular_deviation: default_max_angular_deviation(),
pose_extrapolator: None,
motion_filter: None,
}
}
}
impl LocalizerConfig {
/// Create a config with motion filtering enabled (Cartographer-style).
///
/// Uses default values for pose extrapolation and motion filtering:
/// - IMU rotation weight: 0.3 (70% odometry, 30% IMU)
/// - Motion filter: insert when moved 20cm, rotated 2°, or 5s elapsed
pub fn with_motion_filtering() -> Self {
Self {
pose_extrapolator: Some(PoseExtrapolatorConfig::default()),
motion_filter: Some(MotionFilterConfig::default()),
..Default::default()
}
}
}
@@ -126,6 +160,7 @@ impl Default for LocalizerConfig {
///
/// - `converged=true`: Match accepted, `pose` is scan-corrected
/// - `converged=false`: Match rejected, `pose` is odometry fallback
/// - `skipped=true`: Scan was skipped by motion filter (not enough motion)
#[derive(Clone, Debug)]
pub struct LocalizationResult {
/// Estimated robot pose in world frame.
@@ -138,6 +173,7 @@ pub struct LocalizationResult {
///
/// Higher values indicate better alignment with the map.
/// Typical good matches: 0.6 - 0.9
/// Returns 0.0 if skipped.
pub score: f32,
/// Whether the localization converged successfully.
@@ -154,10 +190,31 @@ pub struct LocalizationResult {
/// which may indicate a false match or robot kidnapping.
pub within_bounds: bool,
/// Whether the scan was skipped by the motion filter.
///
/// True if the robot hasn't moved enough since the last scan
/// to warrant processing this scan.
pub skipped: bool,
/// Raw scan match result for detailed diagnostics.
/// Contains a default value if skipped.
pub match_result: ScanMatchResult,
}
impl LocalizationResult {
/// Create a result indicating the scan was skipped due to motion filter.
pub fn skipped(pose: Pose2D) -> Self {
Self {
pose,
score: 0.0,
converged: false,
within_bounds: true,
skipped: true,
match_result: ScanMatchResult::failed(pose),
}
}
}
/// Localizer for known maps.
///
/// Uses scan matching to estimate robot pose without modifying the map.
@@ -208,6 +265,12 @@ pub struct Localizer {
/// Whether we have a valid initial pose.
initialized: bool,
/// Optional pose extrapolator for IMU fusion (Cartographer-style).
pose_extrapolator: Option<PoseExtrapolator>,
/// Optional motion filter for scan insertion throttling.
motion_filter: Option<MotionFilter>,
}
impl Localizer {
@@ -215,12 +278,25 @@ impl Localizer {
pub fn new(map: GridStorage, config: LocalizerConfig) -> Self {
let matcher = CorrelativeMatcher::new(config.matcher.clone());
// Create optional motion filtering components
let pose_extrapolator = config
.pose_extrapolator
.as_ref()
.map(|c| PoseExtrapolator::new(c.clone()));
let motion_filter = config
.motion_filter
.as_ref()
.map(|c| MotionFilter::new(c.clone()));
Self {
map,
matcher,
config,
pose: Pose2D::default(),
initialized: false,
pose_extrapolator,
motion_filter,
}
}
@@ -228,6 +304,22 @@ impl Localizer {
pub fn set_initial_pose(&mut self, pose: Pose2D) {
self.pose = pose;
self.initialized = true;
// Initialize pose extrapolator if present
if let Some(ref mut extrapolator) = self.pose_extrapolator {
extrapolator.set_initial_pose(pose, 0);
}
}
/// Set the initial pose estimate with timestamp
pub fn set_initial_pose_with_timestamp(&mut self, pose: Pose2D, timestamp_us: u64) {
self.pose = pose;
self.initialized = true;
// Initialize pose extrapolator if present
if let Some(ref mut extrapolator) = self.pose_extrapolator {
extrapolator.set_initial_pose(pose, timestamp_us);
}
}
/// Get current pose estimate
@@ -245,7 +337,37 @@ impl Localizer {
&self.map
}
/// Localize using a lidar scan
/// Check if motion filtering is enabled
pub fn has_motion_filtering(&self) -> bool {
self.pose_extrapolator.is_some() || self.motion_filter.is_some()
}
/// Get a reference to the pose extrapolator (if enabled)
pub fn pose_extrapolator(&self) -> Option<&PoseExtrapolator> {
self.pose_extrapolator.as_ref()
}
/// Get a mutable reference to the pose extrapolator (if enabled)
pub fn pose_extrapolator_mut(&mut self) -> Option<&mut PoseExtrapolator> {
self.pose_extrapolator.as_mut()
}
/// Get a reference to the motion filter (if enabled)
pub fn motion_filter(&self) -> Option<&MotionFilter> {
self.motion_filter.as_ref()
}
/// Feed an IMU measurement to the pose extrapolator.
///
/// Call this at high rate (~110 Hz) to keep the IMU tracker updated.
/// Does nothing if pose extrapolator is not configured.
pub fn add_imu(&mut self, imu: &ImuMeasurement) {
if let Some(ref mut extrapolator) = self.pose_extrapolator {
extrapolator.add_imu(imu);
}
}
/// Localize using a lidar scan (basic version, backward compatible)
///
/// # Arguments
/// * `scan` - The lidar scan to match
@@ -285,6 +407,95 @@ impl Localizer {
score: match_result.score,
converged,
within_bounds,
skipped: false,
match_result,
}
}
/// Localize using a lidar scan with IMU fusion and motion filtering.
///
/// This is the full-featured version that uses:
/// - Pose extrapolation with IMU fusion for better prediction
/// - Motion filtering to skip redundant scans
///
/// # Arguments
/// * `scan` - The lidar scan to match
/// * `odom_delta` - Optional odometry delta since last localization
/// * `timestamp_us` - Timestamp in microseconds
///
/// # Returns
/// Localization result with pose estimate and confidence.
/// Returns `skipped=true` if motion filter rejected the scan.
///
/// # Note
/// Call `add_imu()` at high rate (~110 Hz) before calling this method
/// to keep the IMU tracker updated.
pub fn localize_with_timestamp(
&mut self,
scan: &LidarScan,
odom_delta: Option<Pose2D>,
timestamp_us: u64,
) -> LocalizationResult {
// Feed odometry to pose extrapolator if available
if let Some(ref mut extrapolator) = self.pose_extrapolator
&& let Some(delta) = odom_delta
{
extrapolator.add_odometry(delta, timestamp_us);
}
// Get predicted pose (uses filtered motion if extrapolator is available)
let predicted_pose = if let Some(ref extrapolator) = self.pose_extrapolator {
extrapolator.extrapolate_pose(timestamp_us)
} else if let Some(delta) = odom_delta {
self.pose.compose(&delta)
} else {
self.pose
};
// Check motion filter (should we process this scan?)
if let Some(ref filter) = self.motion_filter
&& !filter.should_insert(predicted_pose, timestamp_us)
{
// Update pose with prediction even if skipped
self.pose = predicted_pose;
return LocalizationResult::skipped(predicted_pose);
}
// Match scan against map
let match_result = self.matcher.match_scan(scan, predicted_pose, &self.map);
// Check if match is within acceptable bounds
let within_bounds = self.check_deviation(&predicted_pose, &match_result.pose);
// Determine if we accept this match
let converged =
match_result.converged && match_result.score >= self.config.min_score && within_bounds;
// Update pose if converged
if converged {
self.pose = match_result.pose;
self.initialized = true;
// Update pose extrapolator with matched pose
if let Some(ref mut extrapolator) = self.pose_extrapolator {
extrapolator.add_pose(match_result.pose, timestamp_us);
}
} else if odom_delta.is_some() {
// Fall back to prediction
self.pose = predicted_pose;
}
// Update motion filter
if let Some(ref mut filter) = self.motion_filter {
filter.accept(self.pose, timestamp_us);
}
LocalizationResult {
pose: self.pose,
score: match_result.score,
converged,
within_bounds,
skipped: false,
match_result,
}
}
+42
View File
@@ -40,6 +40,48 @@
//! }
//! ```
//!
//! # Localization with IMU Fusion
//!
//! For improved accuracy, enable Cartographer-style motion filtering with IMU:
//!
//! ```ignore
//! use vastu_slam::modes::{Localizer, LocalizerConfig};
//! use vastu_slam::core::ImuMeasurement;
//!
//! // Enable motion filtering (includes pose extrapolator + motion filter)
//! let config = LocalizerConfig::with_motion_filtering();
//! let mut localizer = Localizer::new(map, config);
//! localizer.set_initial_pose_with_timestamp(Pose2D::new(0.0, 0.0, 0.0), 0);
//!
//! // Feed IMU at high rate (~110 Hz) - call this frequently
//! localizer.add_imu(&ImuMeasurement::from_raw(timestamp_us, gyro, accel));
//!
//! // Localize with timestamp for motion filtering
//! let result = localizer.localize_with_timestamp(&scan, Some(odom_delta), timestamp_us);
//!
//! if result.skipped {
//! // Motion filter rejected scan (not enough motion)
//! println!("Scan skipped, pose: {:?}", result.pose);
//! } else if result.converged {
//! println!("Localized at: {:?}", result.pose);
//! }
//! ```
//!
//! ## Motion Filtering Algorithm
//!
//! The motion filter uses Cartographer-style thresholds:
//! - **Distance**: Insert scan if moved > 0.2m (default)
//! - **Rotation**: Insert scan if rotated > 2° (default)
//! - **Time**: Insert scan if > 5 seconds since last (default)
//!
//! ## IMU Fusion Algorithm
//!
//! The pose extrapolator combines odometry and IMU:
//! - **Translation**: 100% from wheel odometry (most accurate for ground robots)
//! - **Rotation**: Weighted blend (default: 70% odometry + 30% IMU)
//!
//! This prioritizes wheel encoders while using IMU to smooth rotation estimates.
//!
//! # Choosing a Mode
//!
//! **Use Localization Mode when:**