Skip to main content

ogeom_algo/register/
icp.rs

1//! Iterative closest points onto a mesh, from one start or many.
2
3use nalgebra::{Matrix6, SymmetricEigen, Vector6};
4use ogeom_core::{OgeomResult, ogeom_bail, parallel};
5use ogeom_math::{Matrix3, Point, Quaternion, Transform, Vector};
6use ogeom_mesh::Bvh;
7use ogeom_topo::Triangulation;
8
9use super::{FreeMotion, Icp, Registration, Starts, is_finite, orientation, rigid};
10use crate::distance::{DistanceOptions, Pseudonormals, to_mesh_with};
11
12/// Register `source` onto the mesh `target` was built over: the rigid
13/// transform carrying the points onto it, from each of [`Icp::starts`],
14/// the best scored. See the [module](super) for the rounds, the score and
15/// the free motions.
16///
17/// `source_normals`, one per point, are needed with a facing limit and
18/// used for nothing else.
19///
20/// # Errors
21///
22/// As [`Icp::check`];
23/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) for no points, a
24/// point not finite, an empty mesh, or a facing limit without normals;
25/// [`OgeomError::Dimension`](ogeom_core::OgeomError::Dimension) for normals
26/// not one per point; and as
27/// [`principal_frame`](crate::orient::principal_frame) for the principal
28/// axes of a mesh with no area.
29pub fn icp(
30    source: &[Point],
31    source_normals: Option<&[Vector]>,
32    target: &Bvh,
33    opts: &Icp,
34) -> OgeomResult<Registration> {
35    let mesh = Mesh::of(target)?;
36    let samples = Samples::of(source, source_normals, opts)?;
37    let starts = starts(&samples, target, opts)?;
38    // The starts run in parallel, each on one worker, and are ranked in
39    // their order.
40    let runs = parallel::map_ordered(&starts, |_, &start| {
41        mesh.register(&samples, opts, start)
42            .map(|run| run.registration(&samples))
43    });
44    let mut best: Option<Registration> = None;
45    for found in runs {
46        let found = found?;
47        if best.as_ref().is_none_or(|b| found.score < b.score) {
48            best = Some(found);
49        }
50    }
51    best.map_or_else(|| ogeom_bail!(Domain, "no start to register from"), Ok)
52}
53
54/// The mesh registered onto: its hierarchy, its normals and the cap on a
55/// sample's distance in a score.
56pub(super) struct Mesh<'a> {
57    index: &'a Bvh,
58    normals: Pseudonormals,
59    cap: f64,
60}
61
62impl<'a> Mesh<'a> {
63    pub(super) fn of(index: &'a Bvh) -> OgeomResult<Self> {
64        if index.is_empty() {
65            ogeom_bail!(Domain, "an empty mesh registers nothing");
66        }
67        Ok(Self {
68            index,
69            normals: Pseudonormals::of(index),
70            cap: index.bounds().diagonal(),
71        })
72    }
73
74    /// The score's cap: the farthest pairing distance, or the diagonal of
75    /// the mesh's box, past which no point of the mesh lies from a sample
76    /// inside it.
77    fn cap(&self, opts: &Icp) -> f64 {
78        opts.max_distance.unwrap_or(self.cap)
79    }
80
81    /// The samples moved by `pose`, paired with the mesh, and the pairs
82    /// within the cutoff's threshold over them kept.
83    pub(super) fn pair(&self, samples: &Samples, opts: &Icp, pose: &Pose) -> OgeomResult<Paired> {
84        let m = pose.q.to_matrix();
85        let moved: Vec<Point> = samples.points.iter().map(|&p| pose.apply(&m, p)).collect();
86        let normals: Option<Vec<Vector>> = samples
87            .normals
88            .as_ref()
89            .map(|n| n.iter().map(|&v| m * v).collect());
90        let options = DistanceOptions {
91            max: opts.max_distance.unwrap_or(f64::INFINITY),
92            facing_limit: opts.facing_limit,
93            signed: false,
94        };
95        let found = to_mesh_with(
96            self.index,
97            &self.normals,
98            &moved,
99            normals.as_deref(),
100            &options,
101        )?;
102        let cap = self.cap(opts);
103        let mut capped = 0.0;
104        let mut pairs = Vec::with_capacity(found.len());
105        for (i, closest) in found.into_iter().enumerate() {
106            match closest {
107                Some(c) => {
108                    capped += c.distance.min(cap);
109                    pairs.push(Pair {
110                        sample: i,
111                        at: moved[i],
112                        foot: c.point,
113                        normal: c.normal,
114                        distance: c.distance,
115                    });
116                }
117                None => capped += cap,
118            }
119        }
120        let distances: Vec<f64> = pairs.iter().map(|p| p.distance).collect();
121        let threshold = opts.cutoff.threshold(&distances);
122        pairs.retain(|p| p.distance <= threshold);
123        #[allow(
124            clippy::cast_precision_loss,
125            reason = "sample counts are far below 2^52"
126        )]
127        let score = capped / samples.points.len() as f64;
128        Ok(Paired { kept: pairs, score })
129    }
130
131    /// The rounds from `start`, and the pairs at the pose they end at.
132    pub(super) fn register(&self, samples: &Samples, opts: &Icp, start: Pose) -> OgeomResult<Run> {
133        let reach = opts.tol.confusion();
134        let mut pose = start;
135        let mut converged = false;
136        for _ in 0..opts.max_iter {
137            let paired = self.pair(samples, opts, &pose)?;
138            if paired.kept.is_empty() {
139                break;
140            }
141            let step = if opts.point_to_plane {
142                NormalMatrix::of(&paired.kept).step()
143            } else {
144                let pairs = paired.kept.iter().map(|p| (p.at, p.foot, 1.0));
145                orientation(pairs).map_or(Pose::IDENTITY, |(q, t)| Pose { q, t })
146            };
147            let m = step.q.to_matrix();
148            let moves = paired
149                .kept
150                .iter()
151                .map(|p| (step.apply(&m, p.at) - p.at).magnitude())
152                .fold(0.0, f64::max);
153            pose = step.then(&pose);
154            if moves <= reach {
155                converged = true;
156                break;
157            }
158        }
159        let paired = self.pair(samples, opts, &pose)?;
160        Ok(Run {
161            pose,
162            converged,
163            paired,
164        })
165    }
166}
167
168/// The points registered by, and their normals where there are some.
169pub(super) struct Samples {
170    pub(super) points: Vec<Point>,
171    pub(super) normals: Option<Vec<Vector>>,
172}
173
174impl Samples {
175    /// The points chosen by `opts`' sample count and seed, in their order,
176    /// with their normals.
177    pub(super) fn of(
178        points: &[Point],
179        normals: Option<&[Vector]>,
180        opts: &Icp,
181    ) -> OgeomResult<Self> {
182        opts.check()?;
183        if points.is_empty() {
184            ogeom_bail!(Domain, "no points to register");
185        }
186        if points.iter().any(|&p| !is_finite(p)) {
187            ogeom_bail!(Domain, "a point to register is not finite");
188        }
189        if let Some(n) = normals
190            && n.len() != points.len()
191        {
192            ogeom_bail!(Dimension, "{} normals for {} points", n.len(), points.len());
193        }
194        if opts.facing_limit.is_some() && normals.is_none() {
195            ogeom_bail!(Domain, "a facing limit needs the points' normals");
196        }
197        let chosen = chosen(points.len(), opts.sample, opts.seed);
198        Ok(Self {
199            points: chosen.iter().map(|&i| points[i]).collect(),
200            normals: normals.map(|n| chosen.iter().map(|&i| n[i]).collect()),
201        })
202    }
203
204    /// The same samples moved by `f`, their normals by `g`.
205    pub(super) fn mapped(&self, f: impl Fn(Point) -> Point, g: impl Fn(Vector) -> Vector) -> Self {
206        Self {
207            points: self.points.iter().map(|&p| f(p)).collect(),
208            normals: self
209                .normals
210                .as_ref()
211                .map(|n| n.iter().map(|&v| g(v)).collect()),
212        }
213    }
214}
215
216/// `want` of the indices below `count`, in increasing order, chosen
217/// uniformly by Floyd's method from a generator seeded with `seed`; every
218/// index where `want` is zero or not below `count`.
219fn chosen(count: usize, want: usize, seed: u64) -> Vec<usize> {
220    if want == 0 || want >= count {
221        return (0..count).collect();
222    }
223    let mut state = seed;
224    let mut next = move || {
225        // SplitMix64.
226        state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
227        let mut z = state;
228        z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
229        z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
230        z ^ (z >> 31)
231    };
232    let mut taken = vec![false; count];
233    for j in count - want..count {
234        let bound = j as u64 + 1;
235        let t = usize::try_from(next() % bound).unwrap_or(j);
236        let pick = if taken[t] { j } else { t };
237        taken[pick] = true;
238    }
239    (0..count).filter(|&i| taken[i]).collect()
240}
241
242/// A rigid motion, `p ↦ R p + t` with `R` the unit quaternion's rotation.
243#[derive(Debug, Clone, Copy)]
244pub(super) struct Pose {
245    pub(super) q: Quaternion,
246    pub(super) t: Vector,
247}
248
249impl Pose {
250    pub(super) const IDENTITY: Self = Self {
251        q: Quaternion::IDENTITY,
252        t: Vector::ZERO,
253    };
254
255    /// The motion a rigid transform makes.
256    fn of(t: &Transform, opts: &Icp) -> OgeomResult<Self> {
257        Ok(Self {
258            q: Quaternion::from_matrix(&t.linear(), opts.tol)?,
259            t: t.translation_vector(),
260        })
261    }
262
263    /// `p` moved, `m` being the rotation's matrix.
264    pub(super) fn apply(&self, m: &Matrix3, p: Point) -> Point {
265        Point::from_vector(*m * p.to_vector() + self.t)
266    }
267
268    /// This motion after `first`.
269    fn then(&self, first: &Self) -> Self {
270        let q = self.q * first.q;
271        Self {
272            q: q * (1.0 / q.norm()),
273            t: self.q.to_matrix() * first.t + self.t,
274        }
275    }
276
277    pub(super) fn transform(&self) -> Transform {
278        rigid(self.q, self.t)
279    }
280}
281
282/// A moved sample and its nearest point on the mesh.
283#[derive(Debug, Clone, Copy)]
284pub(super) struct Pair {
285    pub(super) sample: usize,
286    pub(super) at: Point,
287    pub(super) foot: Point,
288    /// The mesh's unit normal at the foot; zero where it has none.
289    normal: Vector,
290    pub(super) distance: f64,
291}
292
293/// One round's pairs.
294pub(super) struct Paired {
295    /// The pairs the cutoff kept, in the samples' order.
296    pub(super) kept: Vec<Pair>,
297    /// The capped mean distance of every sample.
298    pub(super) score: f64,
299}
300
301/// The rounds from one start, ended.
302pub(super) struct Run {
303    pub(super) pose: Pose,
304    pub(super) converged: bool,
305    pub(super) paired: Paired,
306}
307
308impl Run {
309    fn registration(self, samples: &Samples) -> Registration {
310        let kept = &self.paired.kept;
311        let free_directions = if kept.is_empty() {
312            Vec::new()
313        } else {
314            NormalMatrix::of(kept).free()
315        };
316        Registration {
317            transform: self.pose.transform(),
318            rms: rms(kept),
319            inliers: kept.len(),
320            sampled: samples.points.len(),
321            converged: self.converged,
322            score: self.paired.score,
323            free_directions,
324        }
325    }
326}
327
328/// The RMS distance of the pairs; zero for none.
329pub(super) fn rms(pairs: &[Pair]) -> f64 {
330    if pairs.is_empty() {
331        return 0.0;
332    }
333    #[allow(clippy::cast_precision_loss, reason = "pair counts are far below 2^52")]
334    let count = pairs.len() as f64;
335    (pairs.iter().map(|p| p.distance * p.distance).sum::<f64>() / count).sqrt()
336}
337
338/// The point-to-plane least squares of a round's kept pairs, linearised
339/// about their centroid: the unknowns are the rotation scaled by the RMS
340/// radius and the translation, so each moves the samples by lengths.
341struct NormalMatrix {
342    a: Matrix6<f64>,
343    g: Vector6<f64>,
344    centre: Point,
345    radius: f64,
346    /// The RMS point-to-plane residual.
347    noise: f64,
348    count: usize,
349}
350
351impl NormalMatrix {
352    fn of(pairs: &[Pair]) -> Self {
353        let first = pairs[0].at;
354        #[allow(clippy::cast_precision_loss, reason = "pair counts are far below 2^52")]
355        let count = pairs.len() as f64;
356        let shift = pairs.iter().fold(Vector::ZERO, |s, p| s + (p.at - first)) / count;
357        let centre = first + shift;
358        let spread = pairs
359            .iter()
360            .map(|p| (p.at - centre).square_magnitude())
361            .sum::<f64>()
362            / count;
363        let radius = spread.sqrt();
364        // A set all at one point turns no differently at any scale.
365        let scale = if radius > 0.0 { radius } else { 1.0 };
366        let mut a = Matrix6::zeros();
367        let mut g = Vector6::zeros();
368        let mut squares = 0.0;
369        for p in pairs {
370            let turn = (p.at - centre).cross(p.normal) / scale;
371            let row = Vector6::new(turn.x, turn.y, turn.z, p.normal.x, p.normal.y, p.normal.z);
372            let b = (p.foot - p.at).dot(p.normal);
373            a += row * row.transpose();
374            g += row * b;
375            squares += b * b;
376        }
377        Self {
378            a,
379            g,
380            centre,
381            radius: scale,
382            noise: (squares / count).sqrt(),
383            count: pairs.len(),
384        }
385    }
386
387    /// The eigenvalue below which a direction is zero to the rounding of
388    /// the matrix's sums: each entry sums `count` terms, so its rounding
389    /// is within `count · ε` of the largest eigenvalue.
390    fn rounding(&self, largest: f64) -> f64 {
391        #[allow(clippy::cast_precision_loss, reason = "pair counts are far below 2^52")]
392        let count = self.count as f64;
393        largest * count * f64::EPSILON
394    }
395
396    /// The increment minimising the linearised residuals, along the
397    /// directions the pairs hold above rounding; the rotation applied
398    /// exactly, about the centroid.
399    fn step(&self) -> Pose {
400        let eigen = SymmetricEigen::new(self.a);
401        let largest = eigen.eigenvalues.max();
402        let floor = self.rounding(largest);
403        let mut x = Vector6::zeros();
404        for (j, &lambda) in eigen.eigenvalues.iter().enumerate() {
405            if lambda > floor && lambda > 0.0 {
406                let v = eigen.eigenvectors.column(j);
407                x += v * (v.dot(&self.g) / lambda);
408            }
409        }
410        let omega = Vector::new(x[0], x[1], x[2]) / self.radius;
411        let t = Vector::new(x[3], x[4], x[5]);
412        let angle = omega.magnitude();
413        let q = if angle > 0.0 {
414            let axis = omega / angle;
415            let (s, c) = (angle * 0.5).sin_cos();
416            Quaternion::new(c, axis.x * s, axis.y * s, axis.z * s)
417        } else {
418            Quaternion::IDENTITY
419        };
420        // About the centroid: p ↦ c + R (p − c) + t.
421        let c = self.centre.to_vector();
422        Pose {
423            q,
424            t: c - q.to_matrix() * c + t,
425        }
426    }
427
428    /// The motions the pairs do not hold: the eigenvectors whose
429    /// eigenvalues are no larger than the largest times `(σ/r)²` or its
430    /// rounding, in the basis that diagonalises their rotation parts, the
431    /// smallest rotation first.
432    fn free(&self) -> Vec<FreeMotion> {
433        let eigen = SymmetricEigen::new(self.a);
434        let largest = eigen.eigenvalues.max();
435        let relative = self.noise / self.radius;
436        let threshold = (largest * relative * relative).max(self.rounding(largest));
437        let free: Vec<Vector6<f64>> = (0..6)
438            .filter(|&j| eigen.eigenvalues[j] <= threshold)
439            .map(|j| eigen.eigenvectors.column(j).into_owned())
440            .collect();
441        if free.is_empty() {
442            return Vec::new();
443        }
444        let k = free.len();
445        let turn = |v: &Vector6<f64>| nalgebra::Vector3::new(v[0], v[1], v[2]);
446        let gram = nalgebra::DMatrix::from_fn(k, k, |i, j| turn(&free[i]).dot(&turn(&free[j])));
447        let split = SymmetricEigen::new(gram);
448        let mut order: Vec<usize> = (0..k).collect();
449        order.sort_by(|&i, &j| {
450            split.eigenvalues[i]
451                .total_cmp(&split.eigenvalues[j])
452                .then(i.cmp(&j))
453        });
454        order
455            .into_iter()
456            .map(|c| {
457                let w = split.eigenvectors.column(c);
458                let mut v = free
459                    .iter()
460                    .zip(w.iter())
461                    .fold(Vector6::zeros(), |s, (f, &wi)| s + f * wi);
462                // The sense whose largest component is positive.
463                let big = (0..6)
464                    .max_by(|&i, &j| v[i].abs().total_cmp(&v[j].abs()).then(j.cmp(&i)))
465                    .unwrap_or(0);
466                if v[big] < 0.0 {
467                    v = -v;
468                }
469                FreeMotion {
470                    about: self.centre,
471                    radius: self.radius,
472                    rotation: Vector::new(v[0], v[1], v[2]) / self.radius,
473                    translation: Vector::new(v[3], v[4], v[5]),
474                }
475            })
476            .collect()
477    }
478}
479
480/// The poses `opts.starts` names.
481fn starts(samples: &Samples, target: &Bvh, opts: &Icp) -> OgeomResult<Vec<Pose>> {
482    match &opts.starts {
483        Starts::Identity => Ok(vec![Pose::IDENTITY]),
484        Starts::Given(given) => given.iter().map(|t| Pose::of(t, opts)).collect(),
485        Starts::PrincipalAxes24 => principal_starts(samples, target, opts),
486    }
487}
488
489/// The 24 rotations carrying the samples' principal axes onto the mesh's,
490/// each with the samples' centroid moved onto the mesh's area centroid.
491fn principal_starts(samples: &Samples, target: &Bvh, opts: &Icp) -> OgeomResult<Vec<Pose>> {
492    let mut soup = Triangulation::new();
493    for t in 0..u32::try_from(target.triangle_count()).unwrap_or(u32::MAX) {
494        if let Some(corners) = target.triangle(t) {
495            let first = u32::try_from(soup.positions.len()).unwrap_or(u32::MAX);
496            soup.positions.extend(corners);
497            soup.triangles.push([first, first + 1, first + 2]);
498        }
499    }
500    let frame = crate::orient::principal_frame(&soup, opts.tol)?;
501    let to = frame.to_matrix();
502    let (centre, from) = point_axes(&samples.points);
503    let mut poses = Vec::with_capacity(24);
504    for perm in [
505        [0, 1, 2],
506        [0, 2, 1],
507        [1, 0, 2],
508        [1, 2, 0],
509        [2, 0, 1],
510        [2, 1, 0],
511    ] {
512        for signs in 0..8_u8 {
513            let mut q = [[0.0; 3]; 3];
514            for (row, &col) in perm.iter().enumerate() {
515                q[row][col] = if signs >> row & 1 == 0 { 1.0 } else { -1.0 };
516            }
517            let q = Matrix3::new(q);
518            if q.determinant() < 0.0 {
519                continue;
520            }
521            let r = to * q * from.transposed();
522            let rotation = Quaternion::from_matrix(&r, opts.tol)?;
523            poses.push(Pose {
524                q: rotation,
525                t: frame.origin().to_vector() - rotation.to_matrix() * centre.to_vector(),
526            });
527        }
528    }
529    Ok(poses)
530}
531
532/// The points' centroid and their principal axes, the columns of a
533/// rotation: the eigenvectors of their covariance, the third the cross of
534/// the first two.
535fn point_axes(points: &[Point]) -> (Point, Matrix3) {
536    let first = points[0];
537    #[allow(
538        clippy::cast_precision_loss,
539        reason = "point counts are far below 2^52"
540    )]
541    let count = points.len() as f64;
542    let centre = first + points.iter().fold(Vector::ZERO, |s, &p| s + (p - first)) / count;
543    let mut c = nalgebra::Matrix3::<f64>::zeros();
544    for &p in points {
545        let d = p - centre;
546        let d = nalgebra::Vector3::new(d.x, d.y, d.z);
547        c += d * d.transpose();
548    }
549    let eigen = SymmetricEigen::new(c);
550    let column = |i: usize| {
551        let v = eigen.eigenvectors.column(i);
552        Vector::new(v[0], v[1], v[2])
553    };
554    let (x, y) = (column(0), column(1));
555    (centre, Matrix3::from_columns(x, y, x.cross(y)))
556}