Skip to main content

braid_mvg/
align_points.rs

1// Copyright (C) The Strand-Braid Authors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use nalgebra::{
5    DefaultAllocator, Dyn, Matrix, Matrix3, Matrix3x1, OMatrix, RealField, U1, U3, VecStorage,
6    allocator::Allocator,
7};
8use num_traits::float::TotalOrder;
9
10use crate::{MvgError, Result};
11
12/// Algorithm selection for point cloud alignment.
13///
14/// This enum specifies which algorithm to use when aligning two sets of 3D points.
15/// Different algorithms have different robustness characteristics and computational
16/// requirements.
17pub enum Algorithm {
18    /// The Kabsch-Umeyama algorithm for point set alignment.
19    ///
20    /// This is a classic algorithm that finds the optimal similarity transformation
21    /// (scale, rotation, translation) between two point sets. It's mathematically
22    /// elegant but can be sensitive to outliers.
23    KabschUmeyama,
24    /// A robustly-scaled variant of the Arun, Huang, and Blostein algorithm.
25    ///
26    /// This algorithm provides more robust scaling estimation compared to
27    /// Kabsch-Umeyama, making it more suitable for real-world data that may
28    /// contain noise or outliers.
29    RobustArun,
30}
31
32/// Find the linear transformation that converts 3D points `x` as close as
33/// possible to points `y`.
34///
35/// The best (scale, rotation, translation) are returned.
36///
37/// The Kabsch-Umeyama implementation is based on that in
38/// <https://github.com/clementinboittiaux/umeyama-python/blob/main/umeyama.py>.
39///
40/// The robust Arun implementation is based on that in
41/// <https://github.com/strawlab/MultiCamSelfCal/blob/main/MultiCamSelfCal/CoreFunctions/estsimt.m>.
42/// That code claims to be an implementation of the Arun, Huang, and Blostein
43/// algorithm, but contains an extra bit to determine scaling which works
44/// differently, and in my experience is more robust than, the Kabsch-Umeyama
45/// algorithm.
46pub fn align_points<T>(
47    x: &OMatrix<T, U3, Dyn>,
48    y: &OMatrix<T, U3, Dyn>,
49    algorithm: Algorithm,
50) -> Result<(T, Matrix3<T>, Matrix3x1<T>)>
51where
52    T: RealField + Copy + TotalOrder,
53{
54    let n = x.ncols();
55
56    if n != y.ncols() {
57        return Err(MvgError::InvalidShape);
58    }
59    if n < 1 {
60        return Err(MvgError::InvalidShape);
61    }
62
63    // Find centroids.
64    let mu_x = x.column_mean();
65    let mu_y = y.column_mean();
66
67    // Move points to center.
68    let x_center = x - bcast(&mu_x, n);
69    let y_center = y - bcast(&mu_y, n);
70
71    // Covariance of X,Y
72    let (robust_scale, cov_xy) = match algorithm {
73        Algorithm::RobustArun => {
74            let dx = x.columns(1, n - 1) - x.columns(0, n - 1);
75            let dy = y.columns(1, n - 1) - y.columns(0, n - 1);
76            let dx = sqrt(&square(&dx).row_sum());
77            let dy = sqrt(&square(&dy).row_sum());
78            let scales = dy.component_div(&dx);
79
80            let scale = median(&scales).unwrap();
81
82            let x_centered_scaled = &x_center * scale;
83
84            let cov_xy = &x_centered_scaled * y_center.transpose();
85            (Some(scale), cov_xy)
86        }
87        Algorithm::KabschUmeyama => {
88            let cov_xy = (y_center * x_center.transpose()) / nalgebra::convert::<_, T>(n as f64);
89            (None, cov_xy)
90        }
91    };
92
93    // Decomposition of covariance matrix.
94    const SVD_MAX_ITERATIONS: usize = 1_000_000;
95
96    let svd = if let Some(svd) = nalgebra::linalg::SVD::try_new(
97        cov_xy,
98        true,
99        true,
100        nalgebra::convert(1e-7),
101        SVD_MAX_ITERATIONS,
102    ) {
103        svd
104    } else {
105        return Err(MvgError::SvdFailed);
106    };
107    let u = svd.u.unwrap();
108    let d = svd.singular_values;
109    let vh = svd.v_t.unwrap();
110
111    // Generate rotation matrix
112    let (c, r) = if let Some(scale) = robust_scale {
113        let v = vh.transpose();
114        let ut = u.transpose();
115        (scale, v * ut)
116    } else {
117        let mut s = nalgebra::Matrix3::<T>::identity();
118
119        // Are the points reflected?
120        if u.determinant() * vh.determinant() < nalgebra::convert(0.0) {
121            s[(2, 2)] = nalgebra::convert(-1.0);
122        }
123
124        // Variance of X
125        let var_x = square(&x_center).row_sum().mean();
126        let c = (nalgebra::Matrix3::from_diagonal(&d) * s).trace() / var_x;
127        (c, u * s * vh)
128    };
129
130    // Translation
131    let t = mu_y - (r * mu_x) * c;
132
133    Ok((c, r, t))
134}
135
136fn bcast<T, R>(m: &OMatrix<T, R, U1>, n: usize) -> OMatrix<T, R, Dyn>
137where
138    T: RealField + Copy,
139    R: nalgebra::DimName,
140    DefaultAllocator: Allocator<R>,
141{
142    // this is far from efficient
143    let mut result = OMatrix::<T, R, Dyn>::zeros(n);
144    for i in 0..R::dim() {
145        for j in 0..n {
146            result[(i, j)] = m[(i, 0)];
147        }
148    }
149    result
150}
151
152fn sqrt<T, R, C>(m: &OMatrix<T, R, C>) -> OMatrix<T, R, C>
153where
154    T: RealField + Copy,
155    R: nalgebra::Dim,
156    C: nalgebra::Dim,
157    DefaultAllocator: Allocator<R, C>,
158{
159    let mut result = m.clone();
160    sqrt_in_place(&mut result);
161    result
162}
163
164fn sqrt_in_place<T, R, C>(m: &mut OMatrix<T, R, C>)
165where
166    T: RealField + Copy,
167    R: nalgebra::Dim,
168    C: nalgebra::Dim,
169    DefaultAllocator: Allocator<R, C>,
170{
171    for el in m.iter_mut() {
172        let val: T = *el;
173        *el = val.sqrt();
174    }
175}
176
177fn square<T, R, C>(m: &OMatrix<T, R, C>) -> OMatrix<T, R, C>
178where
179    T: RealField + Copy,
180    R: nalgebra::Dim,
181    C: nalgebra::Dim,
182    DefaultAllocator: Allocator<R, C>,
183{
184    m.component_mul(m)
185}
186
187fn median<T, C>(scales: &Matrix<T, U1, C, VecStorage<T, U1, C>>) -> Option<T>
188where
189    T: RealField + Copy + TotalOrder,
190    C: nalgebra::Dim,
191    DefaultAllocator: Allocator<U1, C>,
192{
193    let mut scales = scales.data.as_slice().to_vec(); // clone data to vec
194
195    scales.as_mut_slice().sort_by(|a, b| a.total_cmp(b));
196
197    let n = scales.len();
198    if n == 0 {
199        None
200    } else if n == 1 {
201        Some(scales[0])
202    } else if n.is_multiple_of(2) {
203        let s1 = scales[n / 2 - 1];
204        let s2 = scales[n / 2];
205        Some((s1 + s2) * nalgebra::convert(0.5))
206    } else {
207        // odd
208        Some(scales[n / 2])
209    }
210}
211
212#[test]
213fn test_median() {
214    let mut a = OMatrix::<f64, U1, Dyn>::zeros(3);
215    a[(0, 0)] = 1.0;
216    a[(0, 1)] = 2.0;
217    a[(0, 2)] = 3.0;
218    assert_eq!(median(&a), Some(2.0));
219
220    let mut a = OMatrix::<f64, U1, Dyn>::zeros(2);
221    a[(0, 0)] = 1.0;
222    a[(0, 1)] = 2.0;
223    assert_eq!(median(&a), Some(1.5));
224}
225
226#[test]
227fn test_square() {
228    let a = nalgebra::Matrix2::new(0., 1., 2., 3.);
229    let b = square(&a);
230    assert_eq!(b, nalgebra::Matrix2::new(0., 1., 4., 9.));
231}
232
233#[test]
234fn test_align_points() {
235    use nalgebra::{Matrix3, Vector3};
236
237    #[rustfmt::skip]
238    // This is transposed because we are using `from_column_slice()`.
239    let x1 = nalgebra::base::Matrix3xX::from_column_slice(&[
240        3.36748406,1.61036404,3.55147255,
241        3.58702265,0.06676394,3.64695356,
242        0.28452026,-0.11188296,3.78947735,
243        0.25482713,1.57828256,3.6900808,
244        3.54938525,1.74057692,5.13329681,
245        3.6855626,0.10335229,5.26344841,
246        0.25025385,-0.06146044,5.57085135,
247        0.20742481,1.71073272,5.41823085]);
248
249    #[rustfmt::skip]
250    let x2_noisy = nalgebra::base::Matrix3xX::from_column_slice(&[
251        3.048,1.524,1.524,
252        3.048,0.0,1.524,
253        0.0,0.0,1.524,
254        0.0,1.524,1.524,
255        3.048,1.524,0.0,
256        3.048,0.0,0.0,
257        0.0,0.0,0.0,
258        0.0,1.524,0.0]);
259
260    for algorithm in [Algorithm::KabschUmeyama, Algorithm::RobustArun] {
261        // Test in noise-free conditions with generated data.
262        let c_expected = 0.1;
263        let r_expected = *nalgebra::geometry::Rotation3::from_euler_angles(
264            std::f64::consts::FRAC_PI_4,
265            0.0,
266            0.0,
267        )
268        .matrix();
269        let t_expected = Vector3::new(-0.2, 0.3, -0.4);
270
271        let x2 = c_expected * r_expected * &x1 + bcast(&t_expected, 8);
272
273        let (c, r, t) = align_points(&x1, &x2, algorithm).unwrap();
274
275        approx::assert_abs_diff_eq!(c, c_expected);
276        approx::assert_abs_diff_eq!(r, r_expected, epsilon = 1e-10);
277        approx::assert_abs_diff_eq!(t, t_expected, epsilon = 1e-10);
278    }
279
280    // Test on some real data which seems problematic for Kabsch-Umeyama using
281    // the robust scale option.
282    let (c, r, t) = align_points(&x1, &x2_noisy, Algorithm::RobustArun).unwrap();
283
284    // These values were generated by running on this data using `estsimt()` in
285    // `align.py` from flydra.
286    let c_expected = 0.920734586302497;
287    #[rustfmt::skip]
288        let r_expected = {
289            Matrix3::new(
290                0.997554805278945, 0.03689676080610408, -0.05935519780863721,
291                -0.04056669686950421, 0.9972599534887207, -0.06186217158144404,
292                -0.05691004805816868, -0.06411875084319214, -0.9963182384260189,
293            )
294        };
295    let t_expected = Vector3::new(
296        -0.0013862645696010034,
297        0.3279319869522358,
298        5.0458138154244985,
299    );
300
301    approx::assert_abs_diff_eq!(c, c_expected);
302    approx::assert_abs_diff_eq!(r, r_expected, epsilon = 1e-10);
303    approx::assert_abs_diff_eq!(t, t_expected, epsilon = 1e-10);
304
305    // let xformed = c * r * x1 + bcast(&t, 8);
306    // println!("xformed{xformed}");
307    // let p = c * r;
308    // let mut pp = nalgebra::Matrix4::zeros();
309    // let mut ul = pp.fixed_view_mut::<3, 3>(0, 0);
310    // ul.set_row(0, &p.row(0));
311    // ul.set_row(1, &p.row(1));
312    // ul.set_row(2, &p.row(2));
313    // pp[(0, 3)] = t[0];
314    // pp[(1, 3)] = t[1];
315    // pp[(2, 3)] = t[2];
316
317    // println!("pp\n{}", &pp);
318}