Skip to main content

ogeom_algo/
register.rs

1//! Rigid registration: the rotation and translation that best carry one set
2//! of points onto another, onto a mesh, or a part onto its own mirror image.
3//!
4//! - [`absolute_orientation`] is the closed form for paired points.
5//! - [`icp`] registers points onto a mesh through its [`Bvh`], pairing each
6//!   point with its nearest point on the mesh and solving again until the
7//!   pairs stop moving.
8//! - [`symmetry_plane`] finds the plane a part is the mirror image of
9//!   itself in.
10//!
11//! The linear algebra is `nalgebra`'s: a symmetric eigen-decomposition for
12//! the quaternion of the closed form and for the normal matrix of each
13//! point-to-plane step. Every sum runs over the pairs in their order and the
14//! nearest-point queries are [`to_mesh`](crate::distance::to_mesh)'s, so
15//! every answer is the same bit for bit on any thread count.
16//!
17//! # Iterative closest points
18//!
19//! Each round moves the samples by the transform so far, finds each one's
20//! nearest point on the mesh (within [`Icp::max_distance`], and with
21//! [`Icp::facing_limit`] among the triangles facing within the limit of the
22//! sample's own normal), keeps the pairs within [`Icp::cutoff`]'s threshold
23//! over the round's distances, and solves for an increment from the kept
24//! pairs:
25//!
26//! - **point to plane** (the default): the small rotation and translation
27//!   that minimise the squared distances of the samples to the planes
28//!   through their nearest points, square to the mesh's normal there (the
29//!   face's, or the pseudonormal at an edge or a corner). The increment is
30//!   solved about the kept samples' centroid with the rotation measured in
31//!   lengths at their RMS radius, so its six unknowns are commensurate, and
32//!   its rotation is applied exactly;
33//! - **point to point**: the [`absolute_orientation`] of the kept pairs.
34//!
35//! The rounds stop once an increment moves no kept sample further than the
36//! confusion distance, which is convergence, or after [`Icp::max_iter`]
37//! rounds. Each start of [`Icp::starts`] is run so, and the one with the
38//! least [`Registration::score`] wins, ties going to the earlier start. The
39//! score is the mean distance of *every* sample, each capped (at
40//! [`Icp::max_distance`], or the mesh's diagonal where there is none) and a
41//! sample with no pair counting as the cap, so a start that pairs a few
42//! samples closely and leaves the rest does not beat one that pairs them
43//! all.
44//!
45//! # Free motions
46//!
47//! Where the pairs cannot hold a motion (the samples of one flat face can
48//! slide in its plane and spin about its normal), the point-to-plane normal
49//! matrix is singular along it. A step moves only along the directions whose
50//! eigenvalues stand above what rounding of the matrix's sums could make of
51//! zero, and the registration reports the rest as
52//! [`Registration::free_directions`]: the eigenvectors whose eigenvalues are
53//! no larger than the largest times `(σ/r)²`, `σ` the RMS point-to-plane
54//! residual and `r` the samples' RMS radius. Moving along such a direction
55//! by the whole radius changes the residuals no more than moving along the
56//! best-held direction by the noise does.
57
58mod icp;
59mod symmetry;
60
61use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
62use ogeom_math::{Plane, Point, Quaternion, Transform, Vector};
63#[cfg(doc)]
64use ogeom_mesh::Bvh;
65
66use crate::Cutoff;
67
68pub use icp::icp;
69pub use symmetry::symmetry_plane;
70
71/// Where [`icp`] starts from.
72#[derive(Debug, Clone, PartialEq, Default)]
73pub enum Starts {
74    /// The points as they are.
75    #[default]
76    Identity,
77    /// The 24 rotations that carry the samples' principal axes onto the
78    /// mesh's (every way of matching the three axes with their senses that
79    /// keeps a right hand), each moving the samples' centroid onto the
80    /// mesh's area centroid.
81    ///
82    /// Each start runs its own rounds over every sample, the starts in
83    /// parallel. Starts far from the answer run all [`Icp::max_iter`]
84    /// rounds, so a coarse search over a small [`Icp::sample`] refined
85    /// from its answer by [`Starts::Given`] costs a fraction of one over
86    /// many samples.
87    PrincipalAxes24,
88    /// The rigid transforms given, in their order.
89    Given(Vec<Transform>),
90}
91
92/// How [`icp`] and [`symmetry_plane`] register.
93#[derive(Debug, Clone, PartialEq)]
94pub struct Icp {
95    /// Whether a step minimises the distances to the planes through the
96    /// nearest points rather than to the points themselves.
97    pub point_to_plane: bool,
98    /// Which pairs a round keeps, by their distances.
99    pub cutoff: Cutoff,
100    /// The farthest a sample's nearest point may be for the two to pair;
101    /// `None` pairs at any distance.
102    pub max_distance: Option<f64>,
103    /// The greatest angle, in radians, between a sample's own normal and
104    /// the triangles it may pair on, in `[0, π]`; `None` pairs on all of
105    /// them.
106    pub facing_limit: Option<f64>,
107    /// Where to start from.
108    pub starts: Starts,
109    /// The most rounds per start.
110    pub max_iter: u32,
111    /// How many of the points to register by, chosen by a generator seeded
112    /// with [`Icp::seed`]; zero, or at least the points' count, takes every
113    /// point.
114    pub sample: usize,
115    /// The seed the sample is chosen with.
116    pub seed: u64,
117    /// The tolerances: an increment moving no sample further than their
118    /// confusion distance has converged.
119    pub tol: Tolerances,
120}
121
122impl Default for Icp {
123    /// Point to plane, keeping the pairs within three times the median
124    /// distance, at any distance and facing, from the identity, for up to
125    /// a hundred rounds, every point, in millimetres.
126    fn default() -> Self {
127        Self {
128            point_to_plane: true,
129            cutoff: Cutoff::Median(3.0),
130            max_distance: None,
131            facing_limit: None,
132            starts: Starts::Identity,
133            max_iter: 100,
134            sample: 0,
135            seed: 0x5eed_1c9e,
136            tol: Tolerances::millimetres(),
137        }
138    }
139}
140
141impl Icp {
142    /// The options checked: the cutoff's own number, a positive farthest
143    /// distance, a facing limit in `[0, π]`, and given starts that are
144    /// there and rigid.
145    ///
146    /// # Errors
147    ///
148    /// As [`Cutoff::check`];
149    /// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) for any of the
150    /// others.
151    pub fn check(&self) -> OgeomResult<()> {
152        self.cutoff.check()?;
153        if let Some(d) = self.max_distance
154            && (d.is_nan() || d <= 0.0)
155        {
156            ogeom_bail!(Domain, "the farthest pairing distance {d} must be positive");
157        }
158        if let Some(limit) = self.facing_limit
159            && !(0.0..=core::f64::consts::PI).contains(&limit)
160        {
161            ogeom_bail!(Domain, "the facing limit {limit} must be in [0, π]");
162        }
163        if let Starts::Given(starts) = &self.starts {
164            if starts.is_empty() {
165                ogeom_bail!(Domain, "no starts are given");
166            }
167            for start in starts {
168                if !is_rigid(start) {
169                    ogeom_bail!(Domain, "a start is not a rigid motion: {start:?}");
170                }
171            }
172        }
173        Ok(())
174    }
175}
176
177/// Whether a transform is a rotation and a translation: unit scale, and
178/// a linear part that keeps a right hand. A transform composed of rigid
179/// ones has a scale of exactly one.
180fn is_rigid(t: &Transform) -> bool {
181    t.scale_factor() == 1.0 && t.preserves_handedness()
182}
183
184/// What [`icp`] found.
185#[derive(Debug, Clone, PartialEq)]
186pub struct Registration {
187    /// The rigid transform carrying the points onto the mesh.
188    pub transform: Transform,
189    /// The RMS distance of the kept samples to their nearest points, once
190    /// moved.
191    pub rms: f64,
192    /// How many samples the last round kept.
193    pub inliers: usize,
194    /// How many samples the registration was made by.
195    pub sampled: usize,
196    /// Whether the last increment moved no kept sample further than the
197    /// confusion distance.
198    pub converged: bool,
199    /// The capped mean distance of every sample, once moved, the starts
200    /// were ranked by.
201    pub score: f64,
202    /// The motions the kept pairs do not hold, the most translational
203    /// first. Empty where they hold all six.
204    pub free_directions: Vec<FreeMotion>,
205}
206
207/// A rigid motion pairs do not hold: a turn at [`FreeMotion::rotation`]
208/// about an axis through [`FreeMotion::about`], with a slide of `about` at
209/// [`FreeMotion::translation`].
210///
211/// Scaled so that `|rotation| · radius` and `|translation|` have a unit
212/// root sum of squares. Where the motions free are a subspace, they are
213/// the basis of it that splits rotation from translation as far as it
214/// allows: a selection free to slide and to spin reports pure slides and
215/// a pure spin.
216#[derive(Debug, Clone, Copy, PartialEq)]
217pub struct FreeMotion {
218    /// The kept samples' centroid, once moved.
219    pub about: Point,
220    /// The kept samples' RMS distance from [`FreeMotion::about`].
221    pub radius: f64,
222    /// The angular velocity: the axis's direction, times the rate.
223    pub rotation: Vector,
224    /// The velocity of [`FreeMotion::about`].
225    pub translation: Vector,
226}
227
228/// What [`symmetry_plane`] found.
229#[derive(Debug, Clone, PartialEq)]
230pub struct SymmetryFit {
231    /// The plane the part is its own mirror image in.
232    pub plane: Plane,
233    /// The mirror residual: the RMS distance to the mesh of the kept
234    /// samples reflected in the plane.
235    pub rms: f64,
236    /// How many reflected samples were kept.
237    pub matched: usize,
238    /// How many samples were reflected.
239    pub sampled: usize,
240    /// The capped mean distance of every reflected sample to the mesh, the
241    /// seeds were ranked by.
242    pub score: f64,
243    /// Whether the last bisection moved no sample's reflection further
244    /// than the confusion distance.
245    pub converged: bool,
246}
247
248/// The rigid transform carrying the first point of each pair onto the
249/// second with the least weighted sum of squared distances, in closed form.
250///
251/// The rotation is the unit quaternion of the largest eigenvalue of the
252/// symmetric 4×4 matrix of the pairs' weighted cross-covariance about their
253/// weighted centroids, and the translation then carries the first centroid
254/// onto the second. A quaternion is a rotation by construction, so a set a
255/// mirror would fit better (coplanar points, or noisy ones) still gets the
256/// best proper rotation, never a reflection. Pairs that hold no rotation
257/// (all one point) get none; pairs on one line get one about it that
258/// carries them exactly. Equal weights where `weights` is `None`.
259///
260/// The cross-covariance holds the turn about a set's long axis in its
261/// spread off that axis squared, so pairs spread by `δ` off a line of
262/// length `L` are carried to within about `ε · L² / δ`: within `1e-12 · L`
263/// for `δ` above some `2e-4 · L`, and exactly on the line itself.
264///
265/// # Errors
266///
267/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) for no pairs, a
268/// point or weight not finite, a negative weight, or weights summing to
269/// zero; [`OgeomError::Dimension`](ogeom_core::OgeomError::Dimension) for
270/// weights not one per pair.
271pub fn absolute_orientation(
272    pairs: &[(Point, Point)],
273    weights: Option<&[f64]>,
274) -> OgeomResult<Transform> {
275    if pairs.is_empty() {
276        ogeom_bail!(Domain, "no pairs to orient");
277    }
278    if let Some(w) = weights
279        && w.len() != pairs.len()
280    {
281        ogeom_bail!(Dimension, "{} weights for {} pairs", w.len(), pairs.len());
282    }
283    if let Some(w) = weights
284        && w.iter().any(|w| !(w.is_finite() && *w >= 0.0))
285    {
286        ogeom_bail!(Domain, "a weight is negative or not finite");
287    }
288    if pairs.iter().any(|(a, b)| !(is_finite(*a) && is_finite(*b))) {
289        ogeom_bail!(Domain, "a paired point is not finite");
290    }
291    let weight = |i: usize| weights.map_or(1.0, |w| w[i]);
292    let Some((q, t)) = orientation(
293        pairs
294            .iter()
295            .copied()
296            .enumerate()
297            .map(|(i, (a, b))| (a, b, weight(i))),
298    ) else {
299        ogeom_bail!(Domain, "the weights sum to zero");
300    };
301    Ok(rigid(q, t))
302}
303
304fn is_finite(p: Point) -> bool {
305    p.x.is_finite() && p.y.is_finite() && p.z.is_finite()
306}
307
308/// The rigid transform rotating by the unit quaternion `q` about the
309/// origin, then translating by `t`.
310fn rigid(q: Quaternion, t: Vector) -> Transform {
311    Transform::translation(t) * Transform::from_quaternion(q)
312}
313
314/// The closed form over weighted pairs: the unit quaternion and the
315/// translation, `p ↦ R p + t`. `None` where the weights sum to zero.
316fn orientation(
317    pairs: impl Iterator<Item = (Point, Point, f64)> + Clone,
318) -> Option<(Quaternion, Vector)> {
319    // The centroids are measured from the first pair's points, so far from
320    // the origin the sums hold the spread rather than the offset.
321    let (a0, b0, _) = pairs.clone().next()?;
322    let mut total = 0.0;
323    let (mut sa, mut sb) = (Vector::ZERO, Vector::ZERO);
324    for (a, b, w) in pairs.clone() {
325        total += w;
326        sa += (a - a0) * w;
327        sb += (b - b0) * w;
328    }
329    if total.is_nan() || total <= 0.0 {
330        return None;
331    }
332    let (ca, cb) = (a0 + sa / total, b0 + sb / total);
333    let mut s = [[0.0; 3]; 3];
334    for (a, b, w) in pairs {
335        let (u, v) = (a - ca, b - cb);
336        let (u, v) = ([u.x, u.y, u.z], [v.x, v.y, v.z]);
337        for (row, ui) in s.iter_mut().zip(u) {
338            for (cell, vj) in row.iter_mut().zip(v) {
339                *cell += w * ui * vj;
340            }
341        }
342    }
343    let q = if s.iter().flatten().all(|&x| x == 0.0) {
344        Quaternion::IDENTITY
345    } else {
346        let [[sxx, sxy, sxz], [syx, syy, syz], [szx, szy, szz]] = s;
347        let n = nalgebra::Matrix4::new(
348            sxx + syy + szz,
349            syz - szy,
350            szx - sxz,
351            sxy - syx,
352            syz - szy,
353            sxx - syy - szz,
354            sxy + syx,
355            szx + sxz,
356            szx - sxz,
357            sxy + syx,
358            -sxx + syy - szz,
359            syz + szy,
360            sxy - syx,
361            szx + sxz,
362            syz + szy,
363            -sxx - syy + szz,
364        );
365        let eigen = nalgebra::SymmetricEigen::new(n);
366        let top = (0..4)
367            .max_by(|&i, &j| {
368                eigen.eigenvalues[i]
369                    .total_cmp(&eigen.eigenvalues[j])
370                    .then(j.cmp(&i))
371            })
372            .unwrap_or(0);
373        let v = eigen.eigenvectors.column(top);
374        let q = Quaternion::new(v[0], v[1], v[2], v[3]);
375        let q = q * (1.0 / q.norm());
376        // One sign of the two that are the same rotation, so the answer
377        // does not depend on which the solver returned.
378        if q.w < 0.0 { q * -1.0 } else { q }
379    };
380    let rotated = q.to_matrix() * ca.to_vector();
381    Some((q, cb.to_vector() - rotated))
382}