Skip to main content

rigidity_core/icp/
lm.rs

1//! The point-to-plane ICP solver: Levenberg–Marquardt over IRLS.
2
3use std::ops::ControlFlow;
4
5use nalgebra::{Matrix6, Vector3, Vector6};
6use rayon::prelude::*;
7
8use crate::cloud::PointCloud;
9use crate::icp::kernel::Kernel;
10use crate::icp::residual::point_to_plane_row;
11use crate::lie::Se3;
12use crate::neighbors::{Neighbor, NeighborSearch};
13
14/// Block size of the deterministic reduction.
15///
16/// Block boundaries follow from this constant and the point count alone —
17/// not from the thread count, and not from how the scheduler happened to
18/// divide the work. Within a block, summation runs in increasing index
19/// order; blocks are added in order. The reduction tree is fixed, so `H`
20/// is bit-for-bit the same at any thread count.
21///
22/// `rayon::reduce` does not give this: its combination order depends on
23/// how the worker threads split the range.
24const REDUCTION_CHUNK: usize = 4_096;
25
26/// A surface: a cloud together with its normals.
27#[derive(Debug, Clone, Copy)]
28pub struct Surface<'a> {
29    /// The points.
30    pub cloud: &'a PointCloud,
31    /// The normal at each point; its length equals the point count.
32    pub normals: &'a [Vector3<f64>],
33}
34
35/// Registration settings.
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct IcpConfig {
38    /// Cap on the number of iterations.
39    pub max_iterations: usize,
40    /// Correspondences farther apart than this are discarded.
41    pub max_correspondence_distance: f64,
42    /// Minimum `|cos|` of the angle between normals.
43    ///
44    /// The absolute value, not the cosine itself: the sign a PCA estimate
45    /// assigns to a normal is arbitrary and carries no meaning.
46    pub min_normal_cosine: f64,
47    /// The loss function.
48    pub kernel: Kernel,
49    /// Convergence threshold on the translational part of the step, metres.
50    pub translation_tolerance: f64,
51    /// Convergence threshold on the rotational part of the step, radians.
52    pub rotation_tolerance: f64,
53    /// Initial Levenberg–Marquardt damping.
54    pub initial_damping: f64,
55}
56
57impl Default for IcpConfig {
58    fn default() -> Self {
59        Self {
60            max_iterations: 50,
61            max_correspondence_distance: 1.0,
62            min_normal_cosine: 0.8,
63            kernel: Kernel::Huber(0.1),
64            translation_tolerance: 1e-8,
65            rotation_tolerance: 1e-8,
66            initial_damping: 1e-4,
67        }
68    }
69}
70
71/// Solver state after an accepted iteration.
72#[derive(Debug, Clone, Copy)]
73pub struct IterationReport {
74    /// Iteration number, counting from one.
75    pub iteration: usize,
76    /// The pose after the iteration.
77    pub pose: Se3,
78    /// Root-mean-square residual.
79    pub rmse: f64,
80    /// How many correspondences survived rejection.
81    pub correspondences: usize,
82}
83
84/// The result of a registration.
85#[derive(Debug, Clone)]
86pub struct IcpResult {
87    /// The transform found: it carries the source into the target frame.
88    pub pose: Se3,
89    /// How many iterations ran.
90    pub iterations: usize,
91    /// Whether the step fell below the thresholds.
92    pub converged: bool,
93    /// Root-mean-square residual over the accepted correspondences.
94    pub rmse: f64,
95    /// How many correspondences survived rejection.
96    pub correspondences: usize,
97    /// The matrix `H = JᵀWJ` at the final pose.
98    ///
99    /// Keeping it in the result is a deliberate choice rather than a
100    /// convenience: this matrix is what the whole project is about. It is
101    /// what the TSQR path is compared against, and what the conditioning
102    /// analysis is derived from.
103    pub information: Matrix6<f64>,
104}
105
106/// A partial sum of the normal equations.
107#[derive(Debug, Clone, Copy)]
108struct SystemBlock {
109    hessian: Matrix6<f64>,
110    gradient: Vector6<f64>,
111    cost: f64,
112    squared_residual: f64,
113    count: usize,
114}
115
116impl SystemBlock {
117    fn zero() -> Self {
118        Self {
119            hessian: Matrix6::zeros(),
120            gradient: Vector6::zeros(),
121            cost: 0.0,
122            squared_residual: 0.0,
123            count: 0,
124        }
125    }
126
127    fn absorb(&mut self, other: &Self) {
128        self.hessian += other.hessian;
129        self.gradient += other.gradient;
130        self.cost += other.cost;
131        self.squared_residual += other.squared_residual;
132        self.count += other.count;
133    }
134
135    /// Mean robust cost per correspondence.
136    ///
137    /// The mean is what gets compared, not the sum: a sum drops merely by
138    /// losing correspondences, so a step that threw away half the points
139    /// would look like an improvement.
140    fn mean_cost(&self) -> f64 {
141        if self.count == 0 {
142            f64::INFINITY
143        } else {
144            self.cost / self.count as f64
145        }
146    }
147}
148
149fn assemble<S>(
150    source: &Surface<'_>,
151    target: &Surface<'_>,
152    search: &S,
153    pose: &Se3,
154    config: &IcpConfig,
155) -> SystemBlock
156where
157    S: NeighborSearch + Sync,
158{
159    let count = source.cloud.len();
160    if count == 0 {
161        return SystemBlock::zero();
162    }
163    let rotation = *pose.rotation().matrix();
164    let max_distance_squared =
165        config.max_correspondence_distance * config.max_correspondence_distance;
166    let chunks = count.div_ceil(REDUCTION_CHUNK);
167
168    let blocks: Vec<SystemBlock> = (0..chunks)
169        .into_par_iter()
170        .map(|chunk| {
171            let begin = chunk * REDUCTION_CHUNK;
172            let end = ((chunk + 1) * REDUCTION_CHUNK).min(count);
173            let mut block = SystemBlock::zero();
174            let mut found: Vec<Neighbor> = Vec::with_capacity(1);
175
176            for index in begin..end {
177                let transformed = pose.transform_point(&source.cloud.point(index));
178                search.knn_into(&transformed, 1, &mut found);
179                let Some(nearest) = found.first() else {
180                    continue;
181                };
182                if nearest.distance_squared > max_distance_squared {
183                    continue;
184                }
185
186                let matched = nearest.index as usize;
187                let target_normal = target.normals[matched];
188                let source_normal = rotation * source.normals[index];
189                if source_normal.dot(&target_normal).abs() < config.min_normal_cosine {
190                    continue;
191                }
192
193                let residual = target_normal.dot(&(transformed - target.cloud.point(matched)));
194                let weight = config.kernel.weight(residual);
195                let row = point_to_plane_row(&transformed, &target_normal);
196
197                block.hessian += (row * row.transpose()) * weight;
198                block.gradient += row * (weight * residual);
199                block.cost += config.kernel.loss(residual);
200                block.squared_residual += residual * residual;
201                block.count += 1;
202            }
203            block
204        })
205        .collect();
206
207    let mut total = SystemBlock::zero();
208    for block in &blocks {
209        total.absorb(block);
210    }
211    total
212}
213
214/// Solves `(H + λ·D)·Δξ = −b`.
215///
216/// Marquardt damping, proportional to the diagonal rather than to the
217/// identity. The reason is units: the translational block of `H` has
218/// dimension 1/m² while the rotational block is dimensionless, and a
219/// `λ·I` term would mix them.
220///
221/// The diagonal is floored at a fraction of its own maximum. Without that
222/// floor a degenerate scene is not regularised at all: for the plane
223/// `z = 0` the diagonal entry for `ρx` is identically zero, and `λ·diag`
224/// never touches it. Degenerate scenes are the main case here, not the
225/// exception.
226fn solve_step(
227    hessian: &Matrix6<f64>,
228    gradient: &Vector6<f64>,
229    damping: f64,
230) -> Option<Vector6<f64>> {
231    const DIAGONAL_FLOOR: f64 = 1e-6;
232    let diagonal = hessian.diagonal();
233    let largest = diagonal.max();
234    // An explicit test rather than `!(largest > 0.0)`: a NaN must lead to
235    // refusal too, not slip through the comparison.
236    if !largest.is_finite() || largest <= 0.0 {
237        return None;
238    }
239    let floor = largest * DIAGONAL_FLOOR;
240
241    let mut damped = *hessian;
242    for axis in 0..6 {
243        damped[(axis, axis)] += damping * diagonal[axis].max(floor);
244    }
245    nalgebra::Cholesky::new(damped).map(|factorisation| factorisation.solve(&(-gradient)))
246}
247
248/// Registers `source` against `target`.
249///
250/// `search` must have been built over the `target` cloud. The returned
251/// pose carries source points into the target frame: `q ≈ T · p`.
252///
253/// The update is left-multiplied, `T ← exp(Δξ)·T`, so the Jacobian row is
254/// `[nᵀ | (p' × n)ᵀ]` with `p'` the already-transformed source point. The
255/// convention runs through the project: the null spaces of the synthetic
256/// scenes and the conditioning analysis assume the same one.
257pub fn register<S>(
258    source: &Surface<'_>,
259    target: &Surface<'_>,
260    search: &S,
261    initial: Se3,
262    config: &IcpConfig,
263) -> IcpResult
264where
265    S: NeighborSearch + Sync,
266{
267    register_observed(source, target, search, initial, config, |_| {
268        ControlFlow::Continue(())
269    })
270}
271
272/// The same, with an observer called after every accepted iteration.
273///
274/// The iso-accuracy protocol needs it: comparing "time to a given
275/// accuracy" rather than "time to one's own stopping criterion" requires
276/// seeing the intermediate poses. Comparing stopping criteria measures a
277/// difference in settings, not in code.
278///
279/// # Stopping early
280///
281/// Returning [`ControlFlow::Break`] ends the run. The result then carries
282/// the last accepted pose with `converged: false`: an interrupted run is
283/// not a converged one, and nothing in the result may suggest otherwise.
284///
285/// The observer only sees *accepted* iterations, so a caller watching a
286/// cancellation flag is answered after the next accepted step rather than
287/// at once — a rejected step raises the damping and retries without
288/// reporting. The delay is a few assemblies of the system and is bounded
289/// by `max_iterations`. Making it immediate would mean reporting rejected
290/// steps too, which would change what an `IterationReport` means for every
291/// existing consumer.
292pub fn register_observed<S, F>(
293    source: &Surface<'_>,
294    target: &Surface<'_>,
295    search: &S,
296    initial: Se3,
297    config: &IcpConfig,
298    mut observer: F,
299) -> IcpResult
300where
301    S: NeighborSearch + Sync,
302    F: FnMut(&IterationReport) -> ControlFlow<()>,
303{
304    assert_eq!(
305        source.cloud.len(),
306        source.normals.len(),
307        "the source has a different number of normals than points"
308    );
309    assert_eq!(
310        target.cloud.len(),
311        target.normals.len(),
312        "the target has a different number of normals than points"
313    );
314
315    let mut pose = initial;
316    let mut damping = config.initial_damping;
317    let mut current = assemble(source, target, search, &pose, config);
318    let mut iterations = 0;
319    let mut converged = false;
320
321    while iterations < config.max_iterations {
322        iterations += 1;
323
324        let Some(step) = solve_step(&current.hessian, &current.gradient, damping) else {
325            damping *= 10.0;
326            if damping > 1e12 {
327                break;
328            }
329            continue;
330        };
331
332        let candidate_pose = Se3::exp(&step) * pose;
333        let candidate = assemble(source, target, search, &candidate_pose, config);
334
335        if candidate.mean_cost() <= current.mean_cost() {
336            pose = candidate_pose;
337            current = candidate;
338            damping = (damping * 0.1).max(1e-12);
339            let flow = observer(&IterationReport {
340                iteration: iterations,
341                pose,
342                rmse: if current.count == 0 {
343                    f64::INFINITY
344                } else {
345                    (current.squared_residual / current.count as f64).sqrt()
346                },
347                correspondences: current.count,
348            });
349
350            // Convergence is checked first: it is a fact about the step
351            // that stays true whether or not the caller asked to stop.
352            let translation_step = step.fixed_rows::<3>(0).norm();
353            let rotation_step = step.fixed_rows::<3>(3).norm();
354            if translation_step < config.translation_tolerance
355                && rotation_step < config.rotation_tolerance
356            {
357                converged = true;
358                break;
359            }
360            if flow.is_break() {
361                break;
362            }
363        } else {
364            damping *= 10.0;
365            if damping > 1e12 {
366                break;
367            }
368        }
369    }
370
371    let rmse = if current.count == 0 {
372        f64::INFINITY
373    } else {
374        (current.squared_residual / current.count as f64).sqrt()
375    };
376
377    IcpResult {
378        pose,
379        iterations,
380        converged,
381        rmse,
382        correspondences: current.count,
383        information: current.hessian,
384    }
385}
386
387/// Builds a [`Surface`] from an existing cloud and its normals.
388///
389/// It exists for readability at call sites: a literal
390/// `Surface { cloud, normals }` gets lost in a chain of arguments.
391pub fn surface<'a>(cloud: &'a PointCloud, normals: &'a [Vector3<f64>]) -> Surface<'a> {
392    Surface { cloud, normals }
393}