Skip to main content

rigidity_core/lie/
align.rs

1//! Absolute orientation: the rigid motion that best carries one set of
2//! points onto another.
3//!
4//! Given correspondences that are *known* rather than searched for — a
5//! person clicking the same corner in two scans — the answer is a closed
6//! form, not an iteration. Centre both sets, take the singular value
7//! decomposition of their correlation, and the rotation falls out. Horn
8//! (1987) and Kabsch (1976) reached it independently and it has not needed
9//! improving since.
10//!
11//! This is what stands between a registration and a wrong local minimum.
12//! ICP started from the identity on a pair thirty degrees apart converges
13//! confidently to nonsense, and no amount of conditioning analysis will
14//! say so — the spectrum describes the shape of the cost function around
15//! wherever the solver stopped, not whether it stopped in the right place.
16//! Three clicked pairs put it in the right basin, and then the
17//! conditioning report means what it says.
18
19use nalgebra::{Matrix3, Vector3};
20
21use crate::lie::{Se3, So3};
22
23/// The rigid motion carrying `from` onto `to`, in the least-squares sense.
24///
25/// Returns `None` when there is nothing to solve: fewer than three pairs,
26/// mismatched lengths, or points so nearly collinear that the rotation
27/// about their common line is not determined. That last case is not a
28/// numerical accident — three points on a line genuinely do not fix a
29/// rigid motion, and answering anyway would be inventing the missing
30/// degree of freedom.
31///
32/// # Reflections
33///
34/// The decomposition can produce an improper rotation — a reflection —
35/// when the points are coplanar and the noise happens to favour it.
36/// Flipping the sign of the least significant singular direction gives
37/// the best *proper* rotation instead. Without that correction the result
38/// mirrors the cloud, which looks almost right and is entirely wrong.
39pub fn absolute_orientation(from: &[Vector3<f64>], to: &[Vector3<f64>]) -> Option<Se3> {
40    /// How much smaller the second singular value may be than the first
41    /// before the points count as collinear.
42    const DEGENERATE: f64 = 1e-8;
43
44    if from.len() < 3 || from.len() != to.len() {
45        return None;
46    }
47
48    let count = from.len() as f64;
49    let centre = |points: &[Vector3<f64>]| points.iter().sum::<Vector3<f64>>() / count;
50    let (from_centre, to_centre) = (centre(from), centre(to));
51
52    let mut correlation = Matrix3::zeros();
53    for (a, b) in from.iter().zip(to) {
54        correlation += (b - to_centre) * (a - from_centre).transpose();
55    }
56
57    let svd = correlation.svd(true, true);
58    let (u, v_t) = (svd.u?, svd.v_t?);
59    let values = svd.singular_values;
60    if values[0] <= 0.0 || values[1] <= values[0] * DEGENERATE {
61        return None;
62    }
63
64    let mut rotation = u * v_t;
65    if rotation.determinant() < 0.0 {
66        // The best orthogonal matrix here is a reflection. The best
67        // *rotation* is what you get by flipping the direction that
68        // contributed least, which is the last column.
69        let mut flip = Matrix3::identity();
70        flip[(2, 2)] = -1.0;
71        rotation = u * flip * v_t;
72    }
73
74    let rotation = So3::from_matrix_unchecked(rotation);
75    let translation = to_centre - rotation.matrix() * from_centre;
76    Some(Se3::from_parts(rotation, translation))
77}
78
79#[cfg(test)]
80mod tests {
81    use approx::assert_relative_eq;
82    use nalgebra::Vector6;
83
84    use super::*;
85
86    /// Points spread over three dimensions, so nothing is degenerate by
87    /// accident.
88    fn cloud() -> Vec<Vector3<f64>> {
89        vec![
90            Vector3::new(0.0, 0.0, 0.0),
91            Vector3::new(1.0, 0.0, 0.0),
92            Vector3::new(0.0, 1.0, 0.0),
93            Vector3::new(0.0, 0.0, 1.0),
94            Vector3::new(0.7, -0.3, 0.2),
95        ]
96    }
97
98    /// A known motion is recovered exactly from exact correspondences.
99    #[test]
100    fn a_known_motion_comes_back() {
101        let truth = Se3::exp(&Vector6::new(0.4, -0.2, 0.1, 0.3, -0.5, 0.9));
102        let from = cloud();
103        let to: Vec<_> = from.iter().map(|p| truth.transform_point(p)).collect();
104
105        let found = absolute_orientation(&from, &to).expect("three points are enough");
106        assert_relative_eq!(found.matrix(), truth.matrix(), epsilon = 1e-12);
107    }
108
109    /// Three pairs are the fewest that fix a motion, and they suffice.
110    #[test]
111    fn three_pairs_are_enough() {
112        let truth = Se3::exp(&Vector6::new(-1.0, 2.0, 0.5, 0.0, 0.0, 1.2));
113        let from = cloud()[..3].to_vec();
114        let to: Vec<_> = from.iter().map(|p| truth.transform_point(p)).collect();
115
116        let found = absolute_orientation(&from, &to).expect("three points are enough");
117        assert_relative_eq!(found.matrix(), truth.matrix(), epsilon = 1e-12);
118    }
119
120    /// Two pairs are not, and the answer is that rather than a guess.
121    #[test]
122    fn two_pairs_are_not_enough() {
123        let from = cloud()[..2].to_vec();
124        let to = from.clone();
125        assert!(absolute_orientation(&from, &to).is_none());
126    }
127
128    /// Neither are three on a line: the rotation about it is free, and
129    /// inventing a value for it would be worse than saying so.
130    #[test]
131    fn collinear_points_determine_nothing() {
132        let from = vec![
133            Vector3::new(0.0, 0.0, 0.0),
134            Vector3::new(1.0, 0.0, 0.0),
135            Vector3::new(2.0, 0.0, 0.0),
136            Vector3::new(3.0, 0.0, 0.0),
137        ];
138        let to = from.clone();
139        assert!(absolute_orientation(&from, &to).is_none());
140    }
141
142    /// Coplanar points still give a rotation and never a reflection.
143    ///
144    /// This is the case that produces one if the determinant is not
145    /// checked: the mirrored answer fits the points exactly and is not the
146    /// motion that happened.
147    #[test]
148    fn a_coplanar_set_does_not_come_back_mirrored() {
149        let truth = Se3::exp(&Vector6::new(0.1, 0.2, -0.3, 0.0, 0.0, 2.0));
150        let from = vec![
151            Vector3::new(0.0, 0.0, 0.0),
152            Vector3::new(1.0, 0.0, 0.0),
153            Vector3::new(0.0, 1.0, 0.0),
154            Vector3::new(1.0, 1.0, 0.0),
155        ];
156        let to: Vec<_> = from.iter().map(|p| truth.transform_point(p)).collect();
157
158        let found = absolute_orientation(&from, &to).expect("four coplanar points are enough");
159        assert_relative_eq!(
160            found.rotation().matrix().determinant(),
161            1.0,
162            epsilon = 1e-12
163        );
164        assert_relative_eq!(found.matrix(), truth.matrix(), epsilon = 1e-12);
165    }
166
167    /// Noise on the correspondences moves the answer a little and not a lot.
168    #[test]
169    fn noise_perturbs_the_answer_in_proportion() {
170        let truth = Se3::exp(&Vector6::new(0.5, 0.0, 0.0, 0.0, 0.4, 0.0));
171        let from = cloud();
172        let mut to: Vec<_> = from.iter().map(|p| truth.transform_point(p)).collect();
173        // A millimetre on one point of a metre-sized set.
174        to[2] += Vector3::new(0.001, -0.001, 0.001);
175
176        let found = absolute_orientation(&from, &to).expect("three points are enough");
177        let error = (found * truth.inverse()).log().norm();
178        assert!(error < 0.01, "a millimetre moved the answer by {error}");
179    }
180}