Skip to main content

cumulus3d_align/
umeyama.rs

1/*
2 * umeyama.rs
3 *
4 * Copyright (c) 2026 ParkSangWoo
5 *
6 * Author(s):
7 *
8 *      ParkSangWoo <dev.parksangwoo@gmail.com>
9 *
10 *
11 * This file is part of cumulus3d.
12 *
13 * Licensed under the Apache License, Version 2.0 (the "License");
14 * you may not use this file except in compliance with the License.
15 * You may obtain a copy of the License at
16 *
17 *      http://www.apache.org/licenses/LICENSE-2.0
18 *
19 * Unless required by applicable law or agreed to in writing, software
20 * distributed under the License is distributed on an "AS IS" BASIS,
21 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
22 * See the License for the specific language governing permissions and
23 * limitations under the License.
24 *
25 * Third-party notices: parts of the algorithms, default parameters and data
26 * formats in this file follow other open-source projects. Their copyright
27 * notices and licenses are reproduced in THIRD_PARTY_NOTICES.md.
28 *
29 * SPDX-License-Identifier: Apache-2.0
30 */
31
32//! Umeyama Sim3 and robust estimation.
33
34use cumulus3d_core::ransac::{lo_ransac, Estimator, RansacParams, RansacReport};
35use cumulus3d_core::{Mat3, Mat3x4, Sim3, Vec3};
36use nalgebra::Matrix3xX;
37
38/// Degeneracy test method.
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
40pub enum RankCheck {
41    /// Default behavior: reject if the rank of the uncentered 3×n coordinate matrix is < 3
42    /// (points on a plane through the origin may also be rejected).
43    #[default]
44    Uncentered,
45    /// Improved: reject if the rank of the centered coordinates is < 2 (rejects only collinear or single-point configurations).
46    Centered,
47    /// No test (only checks the SVD solution).
48    None,
49}
50
51fn numerical_rank(m: &Matrix3xX<f64>) -> usize {
52    let n = m.ncols();
53    if n == 0 {
54        return 0;
55    }
56    let sv = m.clone().svd(false, false).singular_values;
57    let max = sv.iter().cloned().fold(0.0f64, f64::max);
58    if max <= 0.0 {
59        return 0;
60    }
61    // Design decision: rank decision threshold. Uses the standard numerical rank criterion σ > σ_max·ε·max(3, n).
62    let tol = max * f64::EPSILON * (n.max(3) as f64);
63    sv.iter().filter(|s| **s > tol).count()
64}
65
66fn passes_rank(pts: &[Vec3], check: RankCheck) -> bool {
67    match check {
68        RankCheck::None => true,
69        RankCheck::Uncentered => numerical_rank(&Matrix3xX::from_columns(pts)) >= 3,
70        RankCheck::Centered => {
71            let mu = pts.iter().sum::<Vec3>() / pts.len() as f64;
72            let c: Vec<Vec3> = pts.iter().map(|p| p - mu).collect();
73            numerical_rank(&Matrix3xX::from_columns(&c)) >= 2
74        }
75    }
76}
77
78/// Umeyama solver (unweighted). Sim3 with `dst ≈ s R src + t` (new_from_old = src_to_dst).
79/// s = 1 if `estimate_scale = false`. None if there are fewer than 3 points or the solution is NaN/degenerate.
80pub fn umeyama(src: &[Vec3], dst: &[Vec3], estimate_scale: bool) -> Option<Sim3> {
81    let n = src.len();
82    if n < 3 || n != dst.len() {
83        return None;
84    }
85    let inv_n = 1.0 / n as f64;
86    let mx = src.iter().sum::<Vec3>() * inv_n;
87    let my = dst.iter().sum::<Vec3>() * inv_n;
88    let mut var_x = 0.0;
89    let mut cov = Mat3::zeros();
90    for (x, y) in src.iter().zip(dst) {
91        let dx = x - mx;
92        var_x += dx.norm_squared();
93        cov += (y - my) * dx.transpose();
94    }
95    var_x *= inv_n;
96    cov *= inv_n;
97    // The nalgebra 3×3 SVD can be wrong for repeated singular values, so the one-sided Jacobi SVD from core is used.
98    let (u, d, v) = cumulus3d_core::linalg::svd3(&cov)?;
99    let vt = v.transpose();
100    let mut s_diag = Vec3::new(1.0, 1.0, 1.0);
101    if u.determinant() * vt.determinant() < 0.0 {
102        s_diag.z = -1.0;
103    }
104    let r = u * Mat3::from_diagonal(&s_diag) * vt;
105    let scale = if estimate_scale {
106        if var_x <= 0.0 {
107            return None;
108        }
109        d.dot(&s_diag) / var_x
110    } else {
111        1.0
112    };
113    let t = my - scale * r * mx;
114    let mut m = Mat3x4::zeros();
115    m.fixed_view_mut::<3, 3>(0, 0).copy_from(&(scale * r));
116    m.set_column(3, &t);
117    if m.iter().any(|v| !v.is_finite()) || scale <= 0.0 {
118        return None;
119    }
120    // Convert the [sR|t] matrix to a Sim3 (s = norm of the first column, normalize the quaternion).
121    Some(Sim3::from_matrix(&m))
122}
123
124/// Sim3 estimator for RANSAC (at least 3 pairs, residual = ‖y − T x‖²).
125#[derive(Clone, Copy, Debug, Default)]
126pub struct Sim3Estimator {
127    /// Minimal-sample degeneracy test method.
128    pub rank_check: RankCheck,
129}
130
131impl Estimator for Sim3Estimator {
132    type X = Vec3;
133    type Y = Vec3;
134    type Model = Sim3;
135    fn min_num_samples(&self) -> usize {
136        3
137    }
138    fn estimate(&self, x: &[Vec3], y: &[Vec3], models: &mut Vec<Sim3>) {
139        if !passes_rank(x, self.rank_check) || !passes_rank(y, self.rank_check) {
140            return;
141        }
142        if let Some(m) = umeyama(x, y, true) {
143            models.push(m);
144        }
145    }
146    fn residuals(&self, x: &[Vec3], y: &[Vec3], model: &Sim3, residuals: &mut Vec<f64>) {
147        let sr = model.rotation.to_rotation_matrix() * model.scale;
148        residuals.clear();
149        residuals.extend(x.iter().zip(y).map(|(a, b)| (b - (sr * a + model.translation)).norm_squared()));
150    }
151}
152
153/// LO-RANSAC robust Sim3 (src → dst). `opts.max_error` is the distance threshold (before squaring).
154pub fn estimate_sim3_ransac(src: &[Vec3], dst: &[Vec3], opts: &RansacParams, rank_check: RankCheck) -> RansacReport<Sim3> {
155    let est = Sim3Estimator { rank_check };
156    lo_ransac(&est, &est, opts, src, dst)
157}
158
159/// Robust Umeyama (user post-processing) options.
160#[derive(Clone, Debug, PartialEq)]
161pub struct RobustUmeyamaOptions {
162    /// Number of refit iterations.
163    pub iterations: usize,
164    /// Threshold = `max(factor · median(r[keep]), min_threshold)`.
165    pub factor: f64,
166    /// Lower bound of the inlier threshold (distance).
167    pub min_threshold: f64,
168    /// Whether to estimate scale (if false, a rigid transform).
169    pub estimate_scale: bool,
170}
171
172impl Default for RobustUmeyamaOptions {
173    fn default() -> Self {
174        Self { iterations: 5, factor: 3.0, min_threshold: 0.3, estimate_scale: true }
175    }
176}
177
178/// Robust Umeyama result.
179#[derive(Clone, Debug)]
180pub struct RobustUmeyamaResult {
181    /// src_to_dst.
182    pub sim3: Sim3,
183    /// Median of the residuals (distances) of the final inliers.
184    pub median_residual: f64,
185    /// Number of final inliers.
186    pub num_inliers: usize,
187    /// Per-pair inlier flags.
188    pub inlier_mask: Vec<bool>,
189    /// Final residuals (distances) of all pairs.
190    pub residuals: Vec<f64>,
191}
192
193/// Median (mean of the middle two for an even count). NaN for empty input.
194pub(crate) fn median(v: &mut [f64]) -> f64 {
195    let n = v.len();
196    if n == 0 {
197        return f64::NAN;
198    }
199    v.sort_unstable_by(|a, b| a.total_cmp(b));
200    if n % 2 == 1 {
201        v[n / 2]
202    } else {
203        0.5 * (v[n / 2 - 1] + v[n / 2])
204    }
205}
206
207/// Iterative refit: each round Umeyama on keep, then all residuals r, then `keep = r < max(factor·median(r[keep]), min_threshold)`.
208/// The returned Sim3 is the last fit; inliers and median are based on the last updated keep.
209pub fn robust_umeyama(src: &[Vec3], dst: &[Vec3], opts: &RobustUmeyamaOptions) -> Option<RobustUmeyamaResult> {
210    let n = src.len();
211    if n < 3 || n != dst.len() {
212        return None;
213    }
214    let mut keep = vec![true; n];
215    let mut best: Option<(Sim3, Vec<f64>)> = None;
216    let mut xs = Vec::with_capacity(n);
217    let mut ys = Vec::with_capacity(n);
218    for _ in 0..opts.iterations.max(1) {
219        xs.clear();
220        ys.clear();
221        for i in 0..n {
222            if keep[i] {
223                xs.push(src[i]);
224                ys.push(dst[i]);
225            }
226        }
227        // Design decision: if inliers drop below 3, keep the previous fit and stop.
228        let Some(sim) = umeyama(&xs, &ys, opts.estimate_scale) else { break };
229        let r: Vec<f64> = src.iter().zip(dst).map(|(a, b)| (b - sim.transform_point(a)).norm()).collect();
230        let mut rk: Vec<f64> = r.iter().zip(&keep).filter(|(_, k)| **k).map(|(v, _)| *v).collect();
231        let thr = (opts.factor * median(&mut rk)).max(opts.min_threshold);
232        let new_keep: Vec<bool> = r.iter().map(|v| *v < thr).collect();
233        best = Some((sim, r));
234        if new_keep.iter().filter(|k| **k).count() < 3 {
235            break;
236        }
237        keep = new_keep;
238    }
239    let (sim3, residuals) = best?;
240    let mut rk: Vec<f64> = residuals.iter().zip(&keep).filter(|(_, k)| **k).map(|(v, _)| *v).collect();
241    Some(RobustUmeyamaResult {
242        sim3,
243        median_residual: median(&mut rk),
244        num_inliers: keep.iter().filter(|k| **k).count(),
245        inlier_mask: keep,
246        residuals,
247    })
248}
249
250#[cfg(test)]
251pub(crate) mod tests {
252    use super::*;
253    use cumulus3d_core::Quat;
254    use rand::RngExt;
255    use rand_pcg::Pcg64;
256
257    pub fn random_sim3(rng: &mut Pcg64) -> Sim3 {
258        let axis = Vec3::new(rng.random_range(-1.0..1.0), rng.random_range(-1.0..1.0), rng.random_range(-1.0..1.0));
259        let s = 10f64.powf(rng.random_range(-1.0..1.0));
260        Sim3::new(
261            s,
262            Quat::from_axis_angle(&axis.normalize(), rng.random_range(-3.0..3.0)),
263            Vec3::new(rng.random_range(-100.0..100.0), rng.random_range(-100.0..100.0), rng.random_range(-100.0..100.0)),
264        )
265    }
266    pub fn rand_vec(rng: &mut Pcg64, r: f64) -> Vec3 {
267        Vec3::new(rng.random_range(-r..r), rng.random_range(-r..r), rng.random_range(-r..r))
268    }
269    pub fn gauss(rng: &mut Pcg64) -> f64 {
270        let u1: f64 = rng.random_range(1e-12..1.0);
271        let u2: f64 = rng.random::<f64>();
272        (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
273    }
274    pub fn sim_close(a: &Sim3, b: &Sim3, tol: f64) {
275        assert!((a.scale - b.scale).abs() / b.scale < tol, "s {} vs {}", a.scale, b.scale);
276        let ra = a.rotation.to_rotation_matrix();
277        let rb = b.rotation.to_rotation_matrix();
278        assert!((ra - rb).norm() < tol, "R");
279        assert!((a.translation - b.translation).norm() / b.translation.norm().max(1.0) < tol, "t");
280    }
281
282    #[test]
283    fn umeyama_exact() {
284        let mut rng = cumulus3d_core::ransac::make_rng(Some(1));
285        for _ in 0..50 {
286            let t = random_sim3(&mut rng);
287            let src: Vec<Vec3> = (0..10).map(|_| rand_vec(&mut rng, 10.0)).collect();
288            let dst: Vec<Vec3> = src.iter().map(|p| t.transform_point(p)).collect();
289            let e = umeyama(&src, &dst, true).unwrap();
290            sim_close(&e, &t, 1e-9);
291            assert!((e.rotation.to_rotation_matrix().determinant() - 1.0).abs() < 1e-9);
292        }
293    }
294
295    #[test]
296    fn umeyama_isotropic_points() {
297        // Octahedron vertices: all three covariance singular values are equal (the case where the nalgebra 3×3 SVD was wrong).
298        let mut rng = cumulus3d_core::ransac::make_rng(Some(7));
299        let src: Vec<Vec3> = [Vec3::x(), -Vec3::x(), Vec3::y(), -Vec3::y(), Vec3::z(), -Vec3::z()].into();
300        for _ in 0..50 {
301            let t = random_sim3(&mut rng);
302            let dst: Vec<Vec3> = src.iter().map(|p| t.transform_point(p)).collect();
303            sim_close(&umeyama(&src, &dst, true).unwrap(), &t, 1e-9);
304        }
305    }
306
307    #[test]
308    fn umeyama_reflection_gives_rotation() {
309        // Target is a mirror image: the optimal solution must be a rotation, not a reflection.
310        let mut rng = cumulus3d_core::ransac::make_rng(Some(2));
311        let src: Vec<Vec3> = (0..10).map(|_| rand_vec(&mut rng, 10.0)).collect();
312        let dst: Vec<Vec3> = src.iter().map(|p| Vec3::new(p.x, p.y, -p.z)).collect();
313        let e = umeyama(&src, &dst, true).unwrap();
314        assert!((e.rotation.to_rotation_matrix().determinant() - 1.0).abs() < 1e-9);
315        assert!(e.scale > 0.0);
316    }
317
318    #[test]
319    fn rank_checks() {
320        // Points on a plane through the origin (z=0): the uncentered test rejects, the centered test passes.
321        let p = vec![Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0), Vec3::new(1.0, 1.0, 0.0)];
322        assert!(!passes_rank(&p, RankCheck::Uncentered));
323        assert!(passes_rank(&p, RankCheck::Centered));
324        let q: Vec<Vec3> = p.iter().map(|v| v + Vec3::new(0.0, 0.0, 5.0)).collect();
325        assert!(passes_rank(&q, RankCheck::Uncentered));
326        let line = vec![Vec3::new(1.0, 1.0, 1.0), Vec3::new(2.0, 2.0, 2.0), Vec3::new(3.0, 3.0, 3.0)];
327        assert!(!passes_rank(&line, RankCheck::Centered));
328    }
329
330    /// 30% synthetic outliers: both LO-RANSAC and robust Umeyama recover the original Sim3.
331    #[test]
332    fn sim3_recovery_with_outliers() {
333        let mut rng = cumulus3d_core::ransac::make_rng(Some(3));
334        let truth = random_sim3(&mut rng);
335        let n = 200;
336        let src: Vec<Vec3> = (0..n).map(|_| rand_vec(&mut rng, 50.0)).collect();
337        let mut dst: Vec<Vec3> = src.iter().map(|p| truth.transform_point(p)).collect();
338        let mut is_out = vec![false; n];
339        for i in 0..n {
340            if i % 10 < 3 {
341                is_out[i] = true;
342                dst[i] += rand_vec(&mut rng, 1.0).normalize() * (truth.scale * rng.random_range(30.0..200.0));
343            }
344        }
345        let opts = RansacParams { max_error: 1e-3 * truth.scale, random_seed: Some(9), ..Default::default() };
346        let rep = estimate_sim3_ransac(&src, &dst, &opts, RankCheck::Uncentered);
347        assert!(rep.success);
348        sim_close(rep.model.as_ref().unwrap(), &truth, 1e-8);
349        let want: Vec<bool> = is_out.iter().map(|o| !o).collect();
350        assert_eq!(rep.inlier_mask, want);
351        let r = robust_umeyama(&src, &dst, &RobustUmeyamaOptions { min_threshold: 1e-6, ..Default::default() }).unwrap();
352        sim_close(&r.sim3, &truth, 1e-8);
353        assert_eq!(r.num_inliers, n - is_out.iter().filter(|b| **b).count());
354        assert!(r.median_residual < 1e-6);
355    }
356
357    #[test]
358    fn median_even_odd() {
359        assert_eq!(median(&mut [3.0, 1.0, 2.0]), 2.0);
360        assert_eq!(median(&mut [4.0, 1.0, 2.0, 3.0]), 2.5);
361    }
362}