Skip to main content

rust_robotics_slam/
correlative_scan_matching.rs

1//! Brute-force correlative scan matching.
2//!
3//! The matcher scores candidate poses by transforming a query scan into the
4//! reference frame and counting occupancy hits in a discretized grid.
5//!
6//! Reference:
7//! - Edwin Olson, "Real-Time Correlative Scan Matching":
8//!   <https://april.eecs.umich.edu/pdfs/olson2009icra.pdf>
9//!
10//! The lookup table follows the rasterization idea in Olson's paper: each
11//! reference point contributes a radially symmetric score field and the grid
12//! stores the maximum value contributed at each cell.
13
14use std::collections::HashMap;
15use std::f64::consts::PI;
16
17/// Configuration for correlative scan matching.
18#[derive(Debug, Clone, Copy)]
19pub struct CorrelativeScanMatcherConfig {
20    /// Search window for x and y offsets \[m\].
21    pub linear_search_range: f64,
22    /// Search window for yaw offsets \[rad\].
23    pub angular_search_range: f64,
24    /// Search step for x and y offsets \[m\].
25    pub linear_step: f64,
26    /// Search step for yaw offsets \[rad\].
27    pub angular_step: f64,
28    /// Occupancy lookup grid resolution \[m\].
29    pub grid_resolution: f64,
30}
31
32impl Default for CorrelativeScanMatcherConfig {
33    fn default() -> Self {
34        Self {
35            linear_search_range: 1.0,
36            angular_search_range: 0.2,
37            linear_step: 0.1,
38            angular_step: 0.02,
39            grid_resolution: 0.05,
40        }
41    }
42}
43
44/// Best scan matching result found in the search window.
45#[derive(Debug, Clone, Copy)]
46pub struct ScanMatchResult {
47    pub x: f64,
48    pub y: f64,
49    pub yaw: f64,
50    pub score: f64,
51    pub converged: bool,
52}
53
54/// Performs brute-force correlative scan matching.
55pub fn correlative_scan_match(
56    reference_x: &[f64],
57    reference_y: &[f64],
58    query_x: &[f64],
59    query_y: &[f64],
60    initial_pose: (f64, f64, f64),
61    config: &CorrelativeScanMatcherConfig,
62) -> ScanMatchResult {
63    if reference_x.len() != reference_y.len()
64        || query_x.len() != query_y.len()
65        || reference_x.is_empty()
66        || query_x.is_empty()
67        || config.linear_step <= 0.0
68        || config.angular_step <= 0.0
69        || config.grid_resolution <= 0.0
70    {
71        return ScanMatchResult {
72            x: initial_pose.0,
73            y: initial_pose.1,
74            yaw: initial_pose.2,
75            score: 0.0,
76            converged: false,
77        };
78    }
79
80    let grid = build_lookup_table(reference_x, reference_y, config.grid_resolution);
81    let linear_offsets = enumerate_offsets(config.linear_search_range, config.linear_step);
82    let angular_offsets = enumerate_offsets(config.angular_search_range, config.angular_step);
83
84    let mut best = ScanMatchResult {
85        x: initial_pose.0,
86        y: initial_pose.1,
87        yaw: normalize_angle(initial_pose.2),
88        score: -1.0,
89        converged: false,
90    };
91    let mut best_penalty = f64::INFINITY;
92
93    for dx in &linear_offsets {
94        for dy in &linear_offsets {
95            for dyaw in &angular_offsets {
96                let candidate = (
97                    initial_pose.0 + dx,
98                    initial_pose.1 + dy,
99                    normalize_angle(initial_pose.2 + dyaw),
100                );
101                let score =
102                    score_candidate(&grid, query_x, query_y, candidate, config.grid_resolution);
103                let penalty = dx * dx + dy * dy + dyaw * dyaw;
104
105                if score > best.score || (score == best.score && penalty < best_penalty) {
106                    best = ScanMatchResult {
107                        x: candidate.0,
108                        y: candidate.1,
109                        yaw: candidate.2,
110                        score,
111                        converged: score > 0.0,
112                    };
113                    best_penalty = penalty;
114                }
115            }
116        }
117    }
118
119    best
120}
121
122fn enumerate_offsets(range: f64, step: f64) -> Vec<f64> {
123    let n_steps = (range / step).round() as i32;
124    (-n_steps..=n_steps)
125        .map(|index| index as f64 * step)
126        .collect()
127}
128
129fn build_lookup_table(
130    reference_x: &[f64],
131    reference_y: &[f64],
132    resolution: f64,
133) -> HashMap<(i32, i32), f64> {
134    let mut grid: HashMap<(i32, i32), f64> = HashMap::new();
135    let sigma = resolution;
136    let cutoff_radius = (3.0 * sigma / resolution).ceil() as i32;
137    let inv_two_sigma_sq = 0.5 / (sigma * sigma);
138
139    for (&x, &y) in reference_x.iter().zip(reference_y.iter()) {
140        let (cx, cy) = cell_index(x, y, resolution);
141        for ix in (cx - cutoff_radius)..=(cx + cutoff_radius) {
142            for iy in (cy - cutoff_radius)..=(cy + cutoff_radius) {
143                let gx = ix as f64 * resolution;
144                let gy = iy as f64 * resolution;
145                let squared_distance = (gx - x).powi(2) + (gy - y).powi(2);
146                let weight = (-squared_distance * inv_two_sigma_sq).exp();
147                if weight < 1.0e-6 {
148                    continue;
149                }
150
151                grid.entry((ix, iy))
152                    .and_modify(|score| *score = (*score).max(weight))
153                    .or_insert(weight);
154            }
155        }
156    }
157
158    grid
159}
160
161fn score_candidate(
162    grid: &HashMap<(i32, i32), f64>,
163    query_x: &[f64],
164    query_y: &[f64],
165    pose: (f64, f64, f64),
166    resolution: f64,
167) -> f64 {
168    let cos_yaw = pose.2.cos();
169    let sin_yaw = pose.2.sin();
170    let mut score = 0.0;
171
172    for (&x, &y) in query_x.iter().zip(query_y.iter()) {
173        let world_x = cos_yaw * x - sin_yaw * y + pose.0;
174        let world_y = sin_yaw * x + cos_yaw * y + pose.1;
175        let cell = cell_index(world_x, world_y, resolution);
176        score += grid.get(&cell).copied().unwrap_or(0.0);
177    }
178
179    score
180}
181
182fn cell_index(x: f64, y: f64, resolution: f64) -> (i32, i32) {
183    (
184        (x / resolution).round() as i32,
185        (y / resolution).round() as i32,
186    )
187}
188
189fn normalize_angle(mut angle: f64) -> f64 {
190    while angle > PI {
191        angle -= 2.0 * PI;
192    }
193    while angle < -PI {
194        angle += 2.0 * PI;
195    }
196    angle
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    fn inverse_transform_points(
204        points: &[(f64, f64)],
205        pose: (f64, f64, f64),
206    ) -> (Vec<f64>, Vec<f64>) {
207        let cos_yaw = pose.2.cos();
208        let sin_yaw = pose.2.sin();
209        let mut xs = Vec::with_capacity(points.len());
210        let mut ys = Vec::with_capacity(points.len());
211
212        for &(x, y) in points {
213            let dx = x - pose.0;
214            let dy = y - pose.1;
215            xs.push(cos_yaw * dx + sin_yaw * dy);
216            ys.push(-sin_yaw * dx + cos_yaw * dy);
217        }
218
219        (xs, ys)
220    }
221
222    fn fixture_points() -> Vec<(f64, f64)> {
223        vec![
224            (0.0, 0.0),
225            (1.0, 0.0),
226            (2.0, 0.0),
227            (0.0, 1.0),
228            (0.0, 2.0),
229            (1.0, 1.0),
230            (1.5, 2.0),
231        ]
232    }
233
234    #[test]
235    fn test_identity_match() {
236        let points = fixture_points();
237        let (reference_x, reference_y): (Vec<_>, Vec<_>) = points.iter().copied().unzip();
238        let config = CorrelativeScanMatcherConfig::default();
239
240        let result = correlative_scan_match(
241            &reference_x,
242            &reference_y,
243            &reference_x,
244            &reference_y,
245            (0.0, 0.0, 0.0),
246            &config,
247        );
248
249        assert!(result.converged);
250        assert_eq!(result.x, 0.0);
251        assert_eq!(result.y, 0.0);
252        assert_eq!(result.yaw, 0.0);
253        assert_eq!(result.score, points.len() as f64);
254    }
255
256    #[test]
257    fn test_known_translation_recovery() {
258        let points = fixture_points();
259        let (reference_x, reference_y): (Vec<_>, Vec<_>) = points.iter().copied().unzip();
260        let true_pose = (0.4, -0.3, 0.0);
261        let (query_x, query_y) = inverse_transform_points(&points, true_pose);
262        let config = CorrelativeScanMatcherConfig {
263            linear_search_range: 0.6,
264            angular_search_range: 0.1,
265            linear_step: 0.1,
266            angular_step: 0.05,
267            grid_resolution: 0.05,
268        };
269
270        let result = correlative_scan_match(
271            &reference_x,
272            &reference_y,
273            &query_x,
274            &query_y,
275            (0.0, 0.0, 0.0),
276            &config,
277        );
278
279        assert!(result.converged);
280        assert!((result.x - true_pose.0).abs() < 1.0e-9);
281        assert!((result.y - true_pose.1).abs() < 1.0e-9);
282        assert!(result.yaw.abs() < 1.0e-9);
283    }
284
285    #[test]
286    fn test_known_rotation_recovery() {
287        let points = fixture_points();
288        let (reference_x, reference_y): (Vec<_>, Vec<_>) = points.iter().copied().unzip();
289        let true_pose = (0.0, 0.0, 0.16);
290        let (query_x, query_y) = inverse_transform_points(&points, true_pose);
291        let config = CorrelativeScanMatcherConfig {
292            linear_search_range: 0.2,
293            angular_search_range: 0.3,
294            linear_step: 0.1,
295            angular_step: 0.02,
296            grid_resolution: 0.05,
297        };
298
299        let result = correlative_scan_match(
300            &reference_x,
301            &reference_y,
302            &query_x,
303            &query_y,
304            (0.0, 0.0, 0.0),
305            &config,
306        );
307
308        assert!(result.converged);
309        assert!(result.x.abs() < 1.0e-9);
310        assert!(result.y.abs() < 1.0e-9);
311        assert!((result.yaw - true_pose.2).abs() < 1.0e-9);
312    }
313
314    #[test]
315    fn test_lookup_table_prefers_exact_alignment() {
316        let points = fixture_points();
317        let (reference_x, reference_y): (Vec<_>, Vec<_>) = points.iter().copied().unzip();
318        let resolution = 0.05;
319        let grid = build_lookup_table(&reference_x, &reference_y, resolution);
320
321        let aligned = score_candidate(
322            &grid,
323            &reference_x,
324            &reference_y,
325            (0.0, 0.0, 0.0),
326            resolution,
327        );
328        let translated = score_candidate(
329            &grid,
330            &reference_x,
331            &reference_y,
332            (0.15, 0.0, 0.0),
333            resolution,
334        );
335        let rotated = score_candidate(
336            &grid,
337            &reference_x,
338            &reference_y,
339            (0.0, 0.0, 0.15),
340            resolution,
341        );
342
343        assert!(aligned > translated);
344        assert!(aligned > rotated);
345    }
346}