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}