Skip to main content

rust_robotics_slam/
fastslam2.rs

1#![allow(dead_code)]
2
3//! FastSLAM 2.0
4//!
5//! Improves on FastSLAM 1.0 by incorporating observations into the proposal
6//! distribution for particle pose sampling, resulting in better particle
7//! efficiency.
8//!
9//! author: Atsushi Sakai (@Atsushi_twi)
10//!         Ryohei Sasaki (@rsasaki0109)
11//!         Rust port
12
13use nalgebra::{Matrix2, Matrix2x3, Matrix3, Vector2, Vector3};
14use rand::Rng;
15use rand_distr::{Distribution, Normal, Uniform};
16use std::f64::consts::PI;
17
18// Simulation parameters
19const DT: f64 = 0.1;
20const MAX_RANGE: f64 = 20.0;
21
22// Particle filter parameters
23const N_PARTICLE: usize = 100;
24const NTH: f64 = N_PARTICLE as f64 / 1.5;
25
26// Noise parameters
27const Q_SIM: [[f64; 2]; 2] = [[0.3, 0.0], [0.0, 0.0305]];
28const R_SIM: [[f64; 2]; 2] = [[0.5, 0.0], [0.0, 0.0305]];
29
30// Motion noise (for proposal distribution)
31const MOTION_COV: [[f64; 3]; 3] = [[0.1, 0.0, 0.0], [0.0, 0.1, 0.0], [0.0, 0.0, 0.01]];
32
33#[derive(Clone)]
34pub struct Landmark {
35    pub x: f64,
36    pub y: f64,
37    pub cov: Matrix2<f64>,
38}
39
40impl Landmark {
41    fn new() -> Self {
42        Landmark {
43            x: 0.0,
44            y: 0.0,
45            cov: Matrix2::identity() * 1000.0,
46        }
47    }
48
49    fn is_initialized(&self) -> bool {
50        self.cov[(0, 0)] < 100.0
51    }
52}
53
54#[derive(Clone)]
55pub struct Particle {
56    pub weight: f64,
57    pub x: f64,
58    pub y: f64,
59    pub yaw: f64,
60    pub landmarks: Vec<Landmark>,
61}
62
63impl Particle {
64    fn new(n_landmarks: usize) -> Self {
65        Particle {
66            weight: 1.0 / N_PARTICLE as f64,
67            x: 0.0,
68            y: 0.0,
69            yaw: 0.0,
70            landmarks: vec![Landmark::new(); n_landmarks],
71        }
72    }
73
74    fn pose(&self) -> Vector3<f64> {
75        Vector3::new(self.x, self.y, self.yaw)
76    }
77
78    fn set_pose(&mut self, pose: &Vector3<f64>) {
79        self.x = pose[0];
80        self.y = pose[1];
81        self.yaw = normalize_angle(pose[2]);
82    }
83}
84
85fn normalize_angle(angle: f64) -> f64 {
86    let mut a = angle;
87    while a > PI {
88        a -= 2.0 * PI;
89    }
90    while a < -PI {
91        a += 2.0 * PI;
92    }
93    a
94}
95
96fn motion_model(x: Vector3<f64>, u: Vector2<f64>) -> Vector3<f64> {
97    let yaw = x[2];
98    Vector3::new(
99        x[0] + u[0] * DT * yaw.cos(),
100        x[1] + u[0] * DT * yaw.sin(),
101        normalize_angle(x[2] + u[1] * DT),
102    )
103}
104
105/// Jacobian of motion model w.r.t. state
106fn motion_jacobian(x: &Vector3<f64>, u: &Vector2<f64>) -> Matrix3<f64> {
107    let yaw = x[2];
108    let v = u[0];
109    Matrix3::new(
110        1.0,
111        0.0,
112        -v * DT * yaw.sin(),
113        0.0,
114        1.0,
115        v * DT * yaw.cos(),
116        0.0,
117        0.0,
118        1.0,
119    )
120}
121
122fn observation_model(px: f64, py: f64, pyaw: f64, lm_x: f64, lm_y: f64) -> Vector2<f64> {
123    let dx = lm_x - px;
124    let dy = lm_y - py;
125    let d = (dx * dx + dy * dy).sqrt();
126    let angle = normalize_angle(dy.atan2(dx) - pyaw);
127    Vector2::new(d, angle)
128}
129
130/// Jacobian of observation model w.r.t. landmark position
131fn obs_jacobian_landmark(px: f64, py: f64, lm_x: f64, lm_y: f64) -> Matrix2<f64> {
132    let dx = lm_x - px;
133    let dy = lm_y - py;
134    let d2 = dx * dx + dy * dy;
135    let d = d2.sqrt();
136    Matrix2::new(dx / d, dy / d, -dy / d2, dx / d2)
137}
138
139/// Jacobian of observation model w.r.t. particle pose [x, y, yaw]
140fn obs_jacobian_pose(px: f64, py: f64, pyaw: f64, lm_x: f64, lm_y: f64) -> Matrix2x3<f64> {
141    let dx = lm_x - px;
142    let dy = lm_y - py;
143    let d2 = dx * dx + dy * dy;
144    let d = d2.sqrt();
145    let _ = pyaw; // yaw only affects angle via subtraction
146    Matrix2x3::new(-dx / d, -dy / d, 0.0, dy / d2, -dx / d2, -1.0)
147}
148
149fn get_q() -> Matrix2<f64> {
150    Matrix2::new(Q_SIM[0][0], Q_SIM[0][1], Q_SIM[1][0], Q_SIM[1][1])
151}
152
153fn get_r() -> Matrix2<f64> {
154    Matrix2::new(R_SIM[0][0], R_SIM[0][1], R_SIM[1][0], R_SIM[1][1])
155}
156
157fn get_motion_cov() -> Matrix3<f64> {
158    Matrix3::new(
159        MOTION_COV[0][0],
160        MOTION_COV[0][1],
161        MOTION_COV[0][2],
162        MOTION_COV[1][0],
163        MOTION_COV[1][1],
164        MOTION_COV[1][2],
165        MOTION_COV[2][0],
166        MOTION_COV[2][1],
167        MOTION_COV[2][2],
168    )
169}
170
171/// FastSLAM 2.0: compute the improved proposal distribution for a particle
172/// given an observation of a known landmark, and sample from it.
173fn compute_proposal(
174    particle: &Particle,
175    u: &Vector2<f64>,
176    z: &Vector2<f64>,
177    lm_id: usize,
178    r: &Matrix2<f64>,
179) -> (Vector3<f64>, Matrix3<f64>) {
180    let lm = &particle.landmarks[lm_id];
181
182    // Prior: motion model prediction
183    let x_pred = motion_model(particle.pose(), *u);
184    let g = motion_jacobian(&particle.pose(), u);
185    let motion_cov = get_motion_cov();
186    let p_pred = g * motion_cov * g.transpose();
187
188    if !lm.is_initialized() {
189        // No observation update possible for uninitialized landmark
190        return (x_pred, p_pred);
191    }
192
193    // Observation Jacobian w.r.t. pose at predicted pose
194    let h_pose = obs_jacobian_pose(x_pred[0], x_pred[1], x_pred[2], lm.x, lm.y);
195
196    // Innovation covariance contribution from landmark
197    let h_lm = obs_jacobian_landmark(x_pred[0], x_pred[1], lm.x, lm.y);
198    let q_obs = h_lm * lm.cov * h_lm.transpose() + r;
199
200    // Fuse prior (motion) with observation likelihood
201    // Posterior precision = prior precision + observation precision
202    let h_pose_t = h_pose.transpose();
203    let q_obs_inv = q_obs.try_inverse().unwrap_or(Matrix2::identity());
204
205    let p_pred_inv = p_pred.try_inverse().unwrap_or(Matrix3::identity() * 1e-6);
206    let p_post_inv = p_pred_inv + h_pose_t * q_obs_inv * h_pose;
207    let p_post = p_post_inv.try_inverse().unwrap_or(p_pred);
208
209    // Posterior mean
210    let z_pred = observation_model(x_pred[0], x_pred[1], x_pred[2], lm.x, lm.y);
211    let innovation = Vector2::new(z[0] - z_pred[0], normalize_angle(z[1] - z_pred[1]));
212
213    let x_post = x_pred + p_post * h_pose_t * q_obs_inv * innovation;
214
215    (x_post, p_post)
216}
217
218/// Sample a 3D pose from a Gaussian distribution
219fn sample_pose_with_rng<R: Rng + ?Sized>(
220    mean: &Vector3<f64>,
221    cov: &Matrix3<f64>,
222    rng: &mut R,
223) -> Vector3<f64> {
224    let normal = Normal::new(0.0, 1.0).unwrap();
225
226    // Cholesky decomposition for sampling
227    let l = cov.cholesky().map(|c| c.l()).unwrap_or_else(|| {
228        // Fallback: use diagonal sqrt
229        Matrix3::from_diagonal(&Vector3::new(
230            cov[(0, 0)].max(0.0).sqrt(),
231            cov[(1, 1)].max(0.0).sqrt(),
232            cov[(2, 2)].max(0.0).sqrt(),
233        ))
234    });
235
236    let noise = Vector3::new(normal.sample(rng), normal.sample(rng), normal.sample(rng));
237
238    mean + l * noise
239}
240
241/// Update landmark EKF and compute weight (FastSLAM 2.0 version)
242fn update_landmark_and_weight(
243    particle: &mut Particle,
244    z: &Vector2<f64>,
245    lm_id: usize,
246    r: &Matrix2<f64>,
247) -> f64 {
248    let lm = &particle.landmarks[lm_id];
249
250    if !lm.is_initialized() {
251        // Initialize landmark
252        particle.landmarks[lm_id].x = particle.x + z[0] * (particle.yaw + z[1]).cos();
253        particle.landmarks[lm_id].y = particle.y + z[0] * (particle.yaw + z[1]).sin();
254        particle.landmarks[lm_id].cov = Matrix2::identity() * 10.0;
255        return 1.0; // neutral weight for new landmark
256    }
257
258    let z_pred = observation_model(particle.x, particle.y, particle.yaw, lm.x, lm.y);
259    let innovation = Vector2::new(z[0] - z_pred[0], normalize_angle(z[1] - z_pred[1]));
260
261    let h = obs_jacobian_landmark(particle.x, particle.y, lm.x, lm.y);
262    let s = h * particle.landmarks[lm_id].cov * h.transpose() + r;
263    let s_inv = s.try_inverse().unwrap_or(Matrix2::identity());
264    let k = particle.landmarks[lm_id].cov * h.transpose() * s_inv;
265
266    // Update landmark
267    let delta = k * innovation;
268    particle.landmarks[lm_id].x += delta[0];
269    particle.landmarks[lm_id].y += delta[1];
270    particle.landmarks[lm_id].cov = (Matrix2::identity() - k * h) * particle.landmarks[lm_id].cov;
271
272    // Compute weight from likelihood
273    let det_s = s.determinant();
274    if det_s > 0.0 {
275        let mahal = innovation.transpose() * s_inv * innovation;
276        (-0.5 * mahal[(0, 0)]).exp() / (2.0 * PI * det_s.sqrt())
277    } else {
278        1e-10
279    }
280}
281
282fn compute_neff(particles: &[Particle]) -> f64 {
283    let sum_w2: f64 = particles.iter().map(|p| p.weight * p.weight).sum();
284    if sum_w2 > 0.0 {
285        1.0 / sum_w2
286    } else {
287        0.0
288    }
289}
290
291fn normalize_weights(particles: &mut [Particle]) {
292    let sum_w: f64 = particles.iter().map(|p| p.weight).sum();
293    if sum_w > 0.0 {
294        for p in particles.iter_mut() {
295            p.weight /= sum_w;
296        }
297    }
298}
299
300fn resample_with_rng<R: Rng + ?Sized>(particles: &mut Vec<Particle>, rng: &mut R) {
301    normalize_weights(particles);
302    let n = particles.len();
303    let mut new_particles = Vec::with_capacity(n);
304
305    let mut cum_sum = vec![0.0; n + 1];
306    for (i, p) in particles.iter().enumerate() {
307        cum_sum[i + 1] = cum_sum[i] + p.weight;
308    }
309
310    let uniform = Uniform::new(0.0, 1.0 / n as f64).expect("valid resampling range");
311    let mut r = uniform.sample(rng);
312    let mut j = 0;
313    for _ in 0..n {
314        while r > cum_sum[j + 1] && j < n - 1 {
315            j += 1;
316        }
317        let mut new_p = particles[j].clone();
318        new_p.weight = 1.0 / n as f64;
319        new_particles.push(new_p);
320        r += 1.0 / n as f64;
321    }
322
323    *particles = new_particles;
324}
325
326/// FastSLAM 2.0 update step.
327///
328/// Key difference from FastSLAM 1.0: the particle pose is sampled from a
329/// proposal distribution that incorporates the observation, not just the
330/// motion model.
331fn fastslam2_update_with_rng<R: Rng + ?Sized>(
332    particles: &mut Vec<Particle>,
333    u: Vector2<f64>,
334    z: &[(f64, f64, usize)], // (distance, angle, landmark_id)
335    rng: &mut R,
336) {
337    let r = get_r();
338
339    for particle in particles.iter_mut() {
340        // FastSLAM 2.0: compute proposal using first observation (if any)
341        if let Some(&(d, angle, lm_id)) = z.first() {
342            let z_vec = Vector2::new(d, angle);
343            let (proposal_mean, proposal_cov) = compute_proposal(particle, &u, &z_vec, lm_id, &r);
344
345            // Sample pose from improved proposal
346            let sampled_pose = sample_pose_with_rng(&proposal_mean, &proposal_cov, rng);
347            particle.set_pose(&sampled_pose);
348        } else {
349            // No observations: fall back to motion model sampling
350            let normal = Normal::new(0.0, 1.0).unwrap();
351            let q = get_q();
352            let u_noisy = Vector2::new(
353                u[0] + normal.sample(rng) * q[(0, 0)].sqrt(),
354                u[1] + normal.sample(rng) * q[(1, 1)].sqrt(),
355            );
356            let pose = motion_model(particle.pose(), u_noisy);
357            particle.set_pose(&pose);
358        }
359
360        // Update landmarks and compute weights
361        for &(d, angle, lm_id) in z {
362            let z_vec = Vector2::new(d, angle);
363            let w = update_landmark_and_weight(particle, &z_vec, lm_id, &r);
364            particle.weight *= w;
365        }
366    }
367
368    normalize_weights(particles);
369
370    let neff = compute_neff(particles);
371    if neff < NTH {
372        resample_with_rng(particles, rng);
373    }
374}
375
376pub fn fastslam2_update(
377    particles: &mut Vec<Particle>,
378    u: Vector2<f64>,
379    z: &[(f64, f64, usize)], // (distance, angle, landmark_id)
380) {
381    let mut rng = rand::rng();
382    fastslam2_update_with_rng(particles, u, z, &mut rng);
383}
384
385pub fn get_best_particle(particles: &[Particle]) -> &Particle {
386    particles
387        .iter()
388        .max_by(|a, b| a.weight.partial_cmp(&b.weight).unwrap())
389        .unwrap()
390}
391
392fn get_observations_with_rng<R: Rng + ?Sized>(
393    x_true: &Vector3<f64>,
394    landmarks: &[(f64, f64)],
395    rng: &mut R,
396) -> Vec<(f64, f64, usize)> {
397    let normal = Normal::new(0.0, 1.0).unwrap();
398    let r = get_r();
399    let mut z = Vec::new();
400
401    for (lm_id, (lx, ly)) in landmarks.iter().enumerate() {
402        let dx = lx - x_true[0];
403        let dy = ly - x_true[1];
404        let d = (dx * dx + dy * dy).sqrt();
405
406        if d <= MAX_RANGE {
407            let angle = normalize_angle(dy.atan2(dx) - x_true[2]);
408            let d_noisy = d + normal.sample(rng) * r[(0, 0)].sqrt();
409            let angle_noisy = angle + normal.sample(rng) * r[(1, 1)].sqrt();
410            z.push((d_noisy, angle_noisy, lm_id));
411        }
412    }
413
414    z
415}
416
417pub fn get_observations(x_true: &Vector3<f64>, landmarks: &[(f64, f64)]) -> Vec<(f64, f64, usize)> {
418    let mut rng = rand::rng();
419    get_observations_with_rng(x_true, landmarks, &mut rng)
420}
421
422pub fn create_particles(n_particles: usize, n_landmarks: usize) -> Vec<Particle> {
423    (0..n_particles)
424        .map(|_| Particle::new(n_landmarks))
425        .collect()
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use rand::{rngs::StdRng, SeedableRng};
432
433    #[test]
434    fn test_create_particles() {
435        let particles = create_particles(50, 5);
436        assert_eq!(particles.len(), 50);
437        for p in &particles {
438            assert_eq!(p.landmarks.len(), 5);
439        }
440    }
441
442    #[test]
443    fn test_fastslam2_update_does_not_panic() {
444        let mut particles = create_particles(20, 3);
445        let u = Vector2::new(1.0, 0.1);
446        let landmarks = vec![(10.0, 0.0), (0.0, 10.0), (10.0, 10.0)];
447        let x_true = Vector3::new(0.0, 0.0, 0.0);
448        let mut rng = StdRng::seed_from_u64(7);
449
450        for _ in 0..5 {
451            let z = get_observations_with_rng(&x_true, &landmarks, &mut rng);
452            fastslam2_update_with_rng(&mut particles, u, &z, &mut rng);
453        }
454
455        assert_eq!(particles.len(), 20);
456    }
457
458    #[test]
459    fn test_proposal_improves_with_observation() {
460        let mut p = Particle::new(1);
461        // Initialize a landmark at (5, 0)
462        p.landmarks[0].x = 5.0;
463        p.landmarks[0].y = 0.0;
464        p.landmarks[0].cov = Matrix2::identity() * 0.5;
465
466        let u = Vector2::new(1.0, 0.0);
467        let z = Vector2::new(5.0, 0.0); // observe landmark at distance=5, angle=0
468        let r = get_r();
469
470        let (mean, cov) = compute_proposal(&p, &u, &z, 0, &r);
471
472        // Proposal covariance should be smaller than motion-only covariance
473        let g = motion_jacobian(&p.pose(), &u);
474        let motion_only_cov = g * get_motion_cov() * g.transpose();
475
476        assert!(
477            cov.determinant() < motion_only_cov.determinant(),
478            "proposal should have less uncertainty than motion-only"
479        );
480
481        // Mean should be close to predicted (no large deviation for consistent obs)
482        let x_pred = motion_model(p.pose(), u);
483        let diff = (mean - x_pred).norm();
484        assert!(
485            diff < 1.0,
486            "proposal mean should be near prediction: diff={diff}"
487        );
488    }
489
490    #[test]
491    fn test_landmark_convergence() {
492        let mut particles = create_particles(120, 1);
493        let landmarks = vec![(5.0, 5.0)];
494        let mut x_true = Vector3::new(0.0, 0.0, PI / 4.0);
495        let u = Vector2::new(0.5, 0.0);
496        let mut rng = StdRng::seed_from_u64(17);
497
498        for _ in 0..60 {
499            x_true = motion_model(x_true, u);
500            let z = get_observations_with_rng(&x_true, &landmarks, &mut rng);
501            fastslam2_update_with_rng(&mut particles, u, &z, &mut rng);
502        }
503
504        let initialized: Vec<&Particle> = particles
505            .iter()
506            .filter(|particle| particle.landmarks[0].is_initialized())
507            .collect();
508        assert!(
509            !initialized.is_empty(),
510            "at least one particle should initialize the landmark"
511        );
512
513        let total_weight: f64 = initialized.iter().map(|particle| particle.weight).sum();
514        let (mean_x, mean_y) = if total_weight > 0.0 {
515            let weighted_x = initialized
516                .iter()
517                .map(|particle| particle.weight * particle.landmarks[0].x)
518                .sum::<f64>()
519                / total_weight;
520            let weighted_y = initialized
521                .iter()
522                .map(|particle| particle.weight * particle.landmarks[0].y)
523                .sum::<f64>()
524                / total_weight;
525            (weighted_x, weighted_y)
526        } else {
527            let avg_x = initialized
528                .iter()
529                .map(|particle| particle.landmarks[0].x)
530                .sum::<f64>()
531                / initialized.len() as f64;
532            let avg_y = initialized
533                .iter()
534                .map(|particle| particle.landmarks[0].y)
535                .sum::<f64>()
536                / initialized.len() as f64;
537            (avg_x, avg_y)
538        };
539
540        let lm_err = ((mean_x - 5.0).powi(2) + (mean_y - 5.0).powi(2)).sqrt();
541        assert!(
542            lm_err < 6.0,
543            "landmark estimate should converge: err={lm_err}"
544        );
545    }
546}