Skip to main content

rust_robotics_slam/
pose_graph_optimization.rs

1//! 2D pose graph optimization with Gauss-Newton iteration.
2//!
3//! The optimizer refines a set of initial poses from relative pose
4//! constraints represented as edges in an SE(2) graph.
5//!
6//! Reference:
7//! - Giorgio Grisetti, Rainer Kümmerle, Cyrill Stachniss, Wolfram Burgard,
8//!   "A Tutorial on Graph-Based SLAM":
9//!   <https://www.ipb.uni-bonn.de/wp-content/papercite-data/pdf/grisetti10titsmag.pdf>
10
11use nalgebra::{DMatrix, DVector, Matrix2, Matrix3, Vector2, Vector3};
12use std::f64::consts::PI;
13
14/// Configuration for pose graph optimization.
15#[derive(Debug, Clone, Copy)]
16pub struct PoseGraphConfig {
17    /// Maximum number of Gauss-Newton iterations.
18    pub max_iterations: usize,
19    /// Convergence threshold on the update vector norm.
20    pub tolerance: f64,
21}
22
23impl Default for PoseGraphConfig {
24    fn default() -> Self {
25        Self {
26            max_iterations: 20,
27            tolerance: 1.0e-5,
28        }
29    }
30}
31
32/// A 2D pose node.
33#[derive(Debug, Clone, Copy, PartialEq)]
34pub struct Pose2DNode {
35    pub x: f64,
36    pub y: f64,
37    pub yaw: f64,
38}
39
40impl Pose2DNode {
41    /// Creates a pose node.
42    pub fn new(x: f64, y: f64, yaw: f64) -> Self {
43        Self { x, y, yaw }
44    }
45}
46
47/// A relative pose constraint between two nodes.
48#[derive(Debug, Clone)]
49pub struct Edge2D {
50    pub from: usize,
51    pub to: usize,
52    pub measurement: Pose2DNode,
53    pub information: Matrix3<f64>,
54}
55
56/// Optimization result.
57#[derive(Debug, Clone)]
58pub struct PoseGraphResult {
59    pub poses: Vec<Pose2DNode>,
60    pub iterations: usize,
61    pub converged: bool,
62}
63
64/// Optimizes a 2D pose graph using Gauss-Newton iteration.
65pub fn optimize_pose_graph(
66    initial_poses: &[Pose2DNode],
67    edges: &[Edge2D],
68    config: &PoseGraphConfig,
69) -> PoseGraphResult {
70    let mut poses = initial_poses.to_vec();
71
72    if poses.len() <= 1 || edges.is_empty() {
73        return PoseGraphResult {
74            poses,
75            iterations: 0,
76            converged: true,
77        };
78    }
79
80    let state_size = 3 * poses.len();
81    let anchor_weight = 1.0e6;
82    let mut converged = false;
83    let mut iterations = 0;
84
85    for iter in 0..config.max_iterations {
86        iterations = iter + 1;
87        let mut h = DMatrix::<f64>::zeros(state_size, state_size);
88        let mut b = DVector::<f64>::zeros(state_size);
89
90        for edge in edges {
91            if edge.from >= poses.len() || edge.to >= poses.len() {
92                continue;
93            }
94
95            let (error, j_i, j_j) =
96                edge_error_and_jacobians(&poses[edge.from], &poses[edge.to], &edge.measurement);
97
98            accumulate_block(&mut h, &mut b, edge, &error, &j_i, &j_j);
99        }
100
101        for index in 0..3 {
102            h[(index, index)] += anchor_weight;
103        }
104
105        let rhs = -&b;
106        let Some(delta) = h.lu().solve(&rhs) else {
107            return PoseGraphResult {
108                poses,
109                iterations,
110                converged: false,
111            };
112        };
113
114        for (node_index, pose) in poses.iter_mut().enumerate() {
115            let base = 3 * node_index;
116            pose.x += delta[base];
117            pose.y += delta[base + 1];
118            pose.yaw = normalize_angle(pose.yaw + delta[base + 2]);
119        }
120
121        if delta.norm() < config.tolerance {
122            converged = true;
123            break;
124        }
125    }
126
127    PoseGraphResult {
128        poses,
129        iterations,
130        converged,
131    }
132}
133
134fn accumulate_block(
135    h: &mut DMatrix<f64>,
136    b: &mut DVector<f64>,
137    edge: &Edge2D,
138    error: &Vector3<f64>,
139    j_i: &Matrix3<f64>,
140    j_j: &Matrix3<f64>,
141) {
142    let from_offset = 3 * edge.from;
143    let to_offset = 3 * edge.to;
144
145    let h_ii = j_i.transpose() * edge.information * j_i;
146    let h_ij = j_i.transpose() * edge.information * j_j;
147    let h_ji = j_j.transpose() * edge.information * j_i;
148    let h_jj = j_j.transpose() * edge.information * j_j;
149
150    let b_i = j_i.transpose() * edge.information * error;
151    let b_j = j_j.transpose() * edge.information * error;
152
153    for row in 0..3 {
154        b[from_offset + row] += b_i[row];
155        b[to_offset + row] += b_j[row];
156        for col in 0..3 {
157            h[(from_offset + row, from_offset + col)] += h_ii[(row, col)];
158            h[(from_offset + row, to_offset + col)] += h_ij[(row, col)];
159            h[(to_offset + row, from_offset + col)] += h_ji[(row, col)];
160            h[(to_offset + row, to_offset + col)] += h_jj[(row, col)];
161        }
162    }
163}
164
165fn edge_error_and_jacobians(
166    from: &Pose2DNode,
167    to: &Pose2DNode,
168    measurement: &Pose2DNode,
169) -> (Vector3<f64>, Matrix3<f64>, Matrix3<f64>) {
170    let t_i = Vector2::new(from.x, from.y);
171    let t_j = Vector2::new(to.x, to.y);
172    let t_ij = Vector2::new(measurement.x, measurement.y);
173
174    let r_i_t = rotation_matrix(from.yaw).transpose();
175    let r_ij_t = rotation_matrix(measurement.yaw).transpose();
176    let delta_t = t_j - t_i;
177
178    let translational_error = r_ij_t * (r_i_t * delta_t - t_ij);
179    let error = Vector3::new(
180        translational_error[0],
181        translational_error[1],
182        normalize_angle(to.yaw - from.yaw - measurement.yaw),
183    );
184
185    let dr_i_t = rotation_matrix_transpose_derivative(from.yaw);
186    let a_translation = -r_ij_t * r_i_t;
187    let a_heading = r_ij_t * (dr_i_t * delta_t);
188    let b_translation = r_ij_t * r_i_t;
189
190    let j_i = Matrix3::new(
191        a_translation[(0, 0)],
192        a_translation[(0, 1)],
193        a_heading[0],
194        a_translation[(1, 0)],
195        a_translation[(1, 1)],
196        a_heading[1],
197        0.0,
198        0.0,
199        -1.0,
200    );
201    let j_j = Matrix3::new(
202        b_translation[(0, 0)],
203        b_translation[(0, 1)],
204        0.0,
205        b_translation[(1, 0)],
206        b_translation[(1, 1)],
207        0.0,
208        0.0,
209        0.0,
210        1.0,
211    );
212
213    (error, j_i, j_j)
214}
215
216fn normalize_angle(mut angle: f64) -> f64 {
217    while angle > PI {
218        angle -= 2.0 * PI;
219    }
220    while angle < -PI {
221        angle += 2.0 * PI;
222    }
223    angle
224}
225
226#[cfg(test)]
227fn relative_transform(from: &Pose2DNode, to: &Pose2DNode) -> Pose2DNode {
228    let dx = to.x - from.x;
229    let dy = to.y - from.y;
230    let cos_yaw = from.yaw.cos();
231    let sin_yaw = from.yaw.sin();
232
233    Pose2DNode {
234        x: cos_yaw * dx + sin_yaw * dy,
235        y: -sin_yaw * dx + cos_yaw * dy,
236        yaw: normalize_angle(to.yaw - from.yaw),
237    }
238}
239
240fn rotation_matrix(yaw: f64) -> Matrix2<f64> {
241    let cos_yaw = yaw.cos();
242    let sin_yaw = yaw.sin();
243    Matrix2::new(cos_yaw, -sin_yaw, sin_yaw, cos_yaw)
244}
245
246fn rotation_matrix_transpose_derivative(yaw: f64) -> Matrix2<f64> {
247    let cos_yaw = yaw.cos();
248    let sin_yaw = yaw.sin();
249    Matrix2::new(-sin_yaw, cos_yaw, -cos_yaw, -sin_yaw)
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    fn edge_from_truth(poses: &[Pose2DNode], from: usize, to: usize) -> Edge2D {
257        Edge2D {
258            from,
259            to,
260            measurement: relative_transform(&poses[from], &poses[to]),
261            information: Matrix3::identity(),
262        }
263    }
264
265    fn perturb_pose(pose: &Pose2DNode, axis: usize, delta: f64) -> Pose2DNode {
266        let mut perturbed = *pose;
267        match axis {
268            0 => perturbed.x += delta,
269            1 => perturbed.y += delta,
270            2 => perturbed.yaw = normalize_angle(perturbed.yaw + delta),
271            _ => unreachable!("axis out of bounds"),
272        }
273        perturbed
274    }
275
276    fn error_difference(a: &Vector3<f64>, b: &Vector3<f64>) -> Vector3<f64> {
277        Vector3::new(a[0] - b[0], a[1] - b[1], normalize_angle(a[2] - b[2]))
278    }
279
280    fn numerical_jacobian(
281        from: &Pose2DNode,
282        to: &Pose2DNode,
283        measurement: &Pose2DNode,
284        perturb_from: bool,
285    ) -> Matrix3<f64> {
286        let epsilon = 1.0e-6;
287        let mut jacobian = Matrix3::zeros();
288
289        for axis in 0..3 {
290            let (from_plus, to_plus, from_minus, to_minus) = if perturb_from {
291                (
292                    perturb_pose(from, axis, epsilon),
293                    *to,
294                    perturb_pose(from, axis, -epsilon),
295                    *to,
296                )
297            } else {
298                (
299                    *from,
300                    perturb_pose(to, axis, epsilon),
301                    *from,
302                    perturb_pose(to, axis, -epsilon),
303                )
304            };
305
306            let error_plus = edge_error_and_jacobians(&from_plus, &to_plus, measurement).0;
307            let error_minus = edge_error_and_jacobians(&from_minus, &to_minus, measurement).0;
308            let column = error_difference(&error_plus, &error_minus) / (2.0 * epsilon);
309            jacobian.set_column(axis, &column);
310        }
311
312        jacobian
313    }
314
315    #[test]
316    fn test_pose_graph_config_defaults() {
317        let config = PoseGraphConfig::default();
318        assert_eq!(config.max_iterations, 20);
319        assert_eq!(config.tolerance, 1.0e-5);
320    }
321
322    #[test]
323    fn test_pose_graph_identity_solution_stays_unchanged() {
324        let poses = vec![
325            Pose2DNode::new(0.0, 0.0, 0.0),
326            Pose2DNode::new(1.0, 0.0, 0.0),
327            Pose2DNode::new(1.0, 1.0, PI / 2.0),
328        ];
329        let edges = vec![
330            edge_from_truth(&poses, 0, 1),
331            edge_from_truth(&poses, 1, 2),
332            edge_from_truth(&poses, 0, 2),
333        ];
334
335        let result = optimize_pose_graph(&poses, &edges, &PoseGraphConfig::default());
336
337        assert!(result.converged);
338        for (expected, actual) in poses.iter().zip(result.poses.iter()) {
339            assert!((expected.x - actual.x).abs() < 1.0e-9);
340            assert!((expected.y - actual.y).abs() < 1.0e-9);
341            assert!(normalize_angle(expected.yaw - actual.yaw).abs() < 1.0e-9);
342        }
343    }
344
345    #[test]
346    fn test_pose_graph_triangle_optimization_converges() {
347        let truth = vec![
348            Pose2DNode::new(0.0, 0.0, 0.0),
349            Pose2DNode::new(1.0, 0.0, 0.0),
350            Pose2DNode::new(1.0, 1.0, PI / 2.0),
351        ];
352        let initial = vec![
353            Pose2DNode::new(0.0, 0.0, 0.0),
354            Pose2DNode::new(1.15, -0.1, 0.08),
355            Pose2DNode::new(0.85, 1.2, PI / 2.0 - 0.12),
356        ];
357        let edges = vec![
358            edge_from_truth(&truth, 0, 1),
359            edge_from_truth(&truth, 1, 2),
360            edge_from_truth(&truth, 0, 2),
361        ];
362
363        let result = optimize_pose_graph(&initial, &edges, &PoseGraphConfig::default());
364
365        assert!(result.converged);
366        for (expected, actual) in truth.iter().zip(result.poses.iter()) {
367            assert!((expected.x - actual.x).abs() < 5.0e-2);
368            assert!((expected.y - actual.y).abs() < 5.0e-2);
369            assert!(normalize_angle(expected.yaw - actual.yaw).abs() < 5.0e-2);
370        }
371    }
372
373    #[test]
374    fn test_pose_graph_jacobians_match_finite_difference() {
375        let from = Pose2DNode::new(0.3, -0.4, 0.35);
376        let to = Pose2DNode::new(1.1, 0.7, -0.25);
377        let measurement = Pose2DNode::new(0.9, 0.8, -0.5);
378
379        let (_, j_i, j_j) = edge_error_and_jacobians(&from, &to, &measurement);
380        let numerical_i = numerical_jacobian(&from, &to, &measurement, true);
381        let numerical_j = numerical_jacobian(&from, &to, &measurement, false);
382
383        for row in 0..3 {
384            for col in 0..3 {
385                assert!((j_i[(row, col)] - numerical_i[(row, col)]).abs() < 1.0e-5);
386                assert!((j_j[(row, col)] - numerical_j[(row, col)]).abs() < 1.0e-5);
387            }
388        }
389    }
390}