1use nalgebra::{DMatrix, DVector, Matrix2, Matrix3, Vector2, Vector3};
12use std::f64::consts::PI;
13
14#[derive(Debug, Clone, Copy)]
16pub struct PoseGraphConfig {
17 pub max_iterations: usize,
19 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#[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 pub fn new(x: f64, y: f64, yaw: f64) -> Self {
43 Self { x, y, yaw }
44 }
45}
46
47#[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#[derive(Debug, Clone)]
58pub struct PoseGraphResult {
59 pub poses: Vec<Pose2DNode>,
60 pub iterations: usize,
61 pub converged: bool,
62}
63
64pub 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}