Skip to main content

rigidity_pipeline/
lib.rs

1//! From files on disk to a conditioning report.
2//!
3//! The core provides the pieces — downsampling, normals, an index, ICP,
4//! conditioning — and every front end has to assemble them in the same
5//! order, with the same defaults, or its numbers stop being comparable
6//! with anyone else's. While that assembly lived inside the binary the
7//! second front end had to copy it, and two copies drift. It lives here
8//! instead, and the command line and the viewer call the same code.
9//!
10//! Nothing here knows about argument parsing or about drawing.
11//!
12//! ```no_run
13//! use rigidity_pipeline::{PrepareParams, RegisterParams, prepare, register_pair};
14//!
15//! let params = PrepareParams::default();
16//! let moving = prepare("source.ply".as_ref(), &params)?;
17//! let fixed = prepare("target.ply".as_ref(), &params)?;
18//! let result = register_pair(&moving, &fixed, &RegisterParams::default());
19//! println!("{:.5} m", result.rmse);
20//! # Ok::<(), rigidity_pipeline::PipelineError>(())
21//! ```
22
23use std::ops::ControlFlow;
24use std::path::{Path, PathBuf};
25
26use rigidity_core::icp::{
27    IcpConfig, IcpResult, IterationReport, Kernel, Surface, register_observed, surface,
28};
29use rigidity_core::lie::Se3;
30use rigidity_core::nalgebra::Vector3;
31use rigidity_core::normals::estimate_normals_observed;
32use rigidity_core::observability::{Analysis, Correspondence, ObservabilityCriteria, analyse};
33use rigidity_core::voxel::voxel_downsample_observed;
34use rigidity_core::{NeighborSearch, PointCloud};
35use rigidity_spatial::KdTree;
36
37/// Anything that can go wrong between a file and a report.
38#[derive(Debug, thiserror::Error)]
39pub enum PipelineError {
40    /// The file could not be read.
41    #[error("{}: {source}", path.display())]
42    Read {
43        /// The file that was being read.
44        path: PathBuf,
45        /// What the reader said.
46        source: rigidity_io::IoError,
47    },
48    /// Preparation of a named cloud failed.
49    ///
50    /// It exists so that a message keeps the file it belongs to: the
51    /// inner error is the same one [`prepare_cloud`] returns for a cloud
52    /// held in memory, which has no name to report.
53    #[error("{}: {source}", path.display())]
54    Prepare {
55        /// The file the cloud came from.
56        path: PathBuf,
57        /// What went wrong once it was read.
58        source: Box<PipelineError>,
59    },
60    /// The voxel grid was coarse enough to leave nothing behind.
61    #[error("no points left after downsampling")]
62    EmptyAfterDownsampling,
63    /// There is nothing to analyse.
64    #[error("the cloud is empty")]
65    EmptyCloud,
66    /// Registration ended with no pair close enough to say anything about.
67    #[error("no correspondences left")]
68    NoCorrespondences,
69    /// The cloud itself is malformed.
70    #[error(transparent)]
71    Cloud(#[from] rigidity_core::CloudError),
72    /// The index could not be built.
73    #[error(transparent)]
74    Spatial(#[from] rigidity_spatial::SpatialError),
75}
76
77/// Which part of the work a [`Progress`] report is about.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum Stage {
80    /// Reading the file.
81    Reading,
82    /// Voxel downsampling.
83    Downsampling,
84    /// Building the spatial index.
85    Indexing,
86    /// Estimating normals.
87    Normals,
88}
89
90impl Stage {
91    /// A label fit to be shown to a person.
92    pub fn label(self) -> &'static str {
93        match self {
94            Self::Reading => "reading",
95            Self::Downsampling => "downsampling",
96            Self::Indexing => "indexing",
97            Self::Normals => "normals",
98        }
99    }
100}
101
102/// How far along one stage is.
103///
104/// Every stage reports `done == 0` as it begins and `done == total` as it
105/// ends, so a caller can show the name of the stage before any of its work
106/// has happened. The unit of `total` belongs to the stage and is not
107/// comparable between them: points for the normals, internal phases for
108/// the rest — see the functions in the core for why.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct Progress {
111    /// What is running.
112    pub stage: Stage,
113    /// How much of it is finished.
114    pub done: usize,
115    /// How much there is.
116    pub total: usize,
117}
118
119/// Settings shared by everything that turns a raw cloud into a surface.
120#[derive(Debug, Clone, Copy, PartialEq)]
121pub struct PrepareParams {
122    /// Edge of the downsampling voxel, metres. Zero disables it.
123    pub voxel: f64,
124    /// Neighbours used for normal estimation.
125    pub neighbours: usize,
126}
127
128impl Default for PrepareParams {
129    fn default() -> Self {
130        Self {
131            voxel: 0.05,
132            neighbours: 16,
133        }
134    }
135}
136
137/// A cloud ready to be registered: points, normals and an index over them.
138///
139/// Deliberately not `Debug`: printing one would dump a million
140/// coordinates and the whole index behind them. It is a handle to a
141/// working set, not a value to inspect.
142pub struct Prepared {
143    /// The points, after downsampling.
144    pub cloud: PointCloud,
145    /// One normal per point.
146    pub normals: Vec<Vector3<f64>>,
147    /// An index over [`cloud`](Self::cloud).
148    pub tree: KdTree,
149}
150
151impl Prepared {
152    /// The pair the ICP takes.
153    pub fn surface(&self) -> Surface<'_> {
154        surface(&self.cloud, &self.normals)
155    }
156
157    /// How many points survived downsampling.
158    pub fn len(&self) -> usize {
159        self.cloud.len()
160    }
161
162    /// Whether anything survived.
163    pub fn is_empty(&self) -> bool {
164        self.cloud.is_empty()
165    }
166}
167
168/// Reads a cloud and prepares it.
169///
170/// Whatever format the extension names: both front ends get every reader
171/// the io crate has, and neither has a list of its own to fall behind.
172pub fn prepare(path: &Path, params: &PrepareParams) -> Result<Prepared, PipelineError> {
173    prepare_observed(path, params, |_| {})
174}
175
176/// The same, reporting progress.
177pub fn prepare_observed<F>(
178    path: &Path,
179    params: &PrepareParams,
180    mut observer: F,
181) -> Result<Prepared, PipelineError>
182where
183    F: FnMut(Progress),
184{
185    // The reader has no progress of its own to report, so the stage is
186    // announced and then confirmed. Naming it still matters: on a large
187    // file this is where the wait is, and silence there reads as a hang.
188    observer(Progress {
189        stage: Stage::Reading,
190        done: 0,
191        total: 1,
192    });
193    let raw = rigidity_io::read(path).map_err(|source| PipelineError::Read {
194        path: path.to_path_buf(),
195        source,
196    })?;
197    observer(Progress {
198        stage: Stage::Reading,
199        done: 1,
200        total: 1,
201    });
202
203    prepare_cloud_observed(&raw, params, observer).map_err(|source| PipelineError::Prepare {
204        path: path.to_path_buf(),
205        source: Box::new(source),
206    })
207}
208
209/// Prepares a cloud that is already in memory.
210///
211/// The viewer needs it twice over: scenes are generated rather than read,
212/// and changing the voxel size must not re-read a file that has not
213/// changed.
214pub fn prepare_cloud(
215    cloud: &PointCloud,
216    params: &PrepareParams,
217) -> Result<Prepared, PipelineError> {
218    prepare_cloud_observed(cloud, params, |_| {})
219}
220
221/// The same, reporting progress.
222///
223/// The cloud is borrowed rather than consumed: the caller usually holds it
224/// behind a handle it cannot give up — a viewer is still drawing it — and
225/// the common path never needs to own it anyway, since downsampling reads
226/// the input and writes a new cloud. Only a disabled voxel grid copies, and
227/// that is a copy the old signature merely moved somewhere else.
228pub fn prepare_cloud_observed<F>(
229    cloud: &PointCloud,
230    params: &PrepareParams,
231    mut observer: F,
232) -> Result<Prepared, PipelineError>
233where
234    F: FnMut(Progress),
235{
236    let cloud = if params.voxel > 0.0 {
237        voxel_downsample_observed(cloud, params.voxel, |done, total| {
238            observer(Progress {
239                stage: Stage::Downsampling,
240                done,
241                total,
242            })
243        })?
244    } else {
245        cloud.clone()
246    };
247    if cloud.is_empty() {
248        return Err(PipelineError::EmptyAfterDownsampling);
249    }
250
251    let tree = KdTree::build_observed(&cloud, |done, total| {
252        observer(Progress {
253            stage: Stage::Indexing,
254            done,
255            total,
256        })
257    })?;
258
259    observer(Progress {
260        stage: Stage::Normals,
261        done: 0,
262        total: cloud.len(),
263    });
264    let normals = estimate_normals_observed(&cloud, &tree, params.neighbours, |done, total| {
265        observer(Progress {
266            stage: Stage::Normals,
267            done,
268            total,
269        })
270    });
271
272    Ok(Prepared {
273        cloud,
274        normals,
275        tree,
276    })
277}
278
279/// Settings of the registration itself.
280#[derive(Debug, Clone, Copy, PartialEq)]
281pub struct RegisterParams {
282    /// Correspondences farther apart than this are discarded, metres.
283    pub max_distance: f64,
284    /// Threshold of the robust Huber kernel, metres.
285    pub huber: f64,
286    /// Cap on the number of iterations.
287    pub max_iterations: usize,
288    /// Minimum `|cos|` between normals for a pair to be kept.
289    ///
290    /// Zero — accept any — is the default here rather than the core's
291    /// 0.8: the pairs this rejects are exactly the ones that constrain the
292    /// directions the report is about, and dropping them flatters the
293    /// spectrum.
294    pub min_normal_cosine: f64,
295}
296
297impl Default for RegisterParams {
298    fn default() -> Self {
299        Self {
300            max_distance: 0.5,
301            huber: 0.1,
302            max_iterations: IcpConfig::default().max_iterations,
303            min_normal_cosine: 0.0,
304        }
305    }
306}
307
308impl RegisterParams {
309    /// The loss function these settings imply.
310    pub fn kernel(&self) -> Kernel {
311        Kernel::Huber(self.huber)
312    }
313
314    /// The core's configuration these settings imply.
315    pub fn icp_config(&self) -> IcpConfig {
316        IcpConfig {
317            kernel: self.kernel(),
318            max_correspondence_distance: self.max_distance,
319            min_normal_cosine: self.min_normal_cosine,
320            max_iterations: self.max_iterations,
321            ..IcpConfig::default()
322        }
323    }
324}
325
326/// Registers `moving` onto `fixed`, starting from the identity.
327pub fn register_pair(moving: &Prepared, fixed: &Prepared, params: &RegisterParams) -> IcpResult {
328    register_pair_observed(moving, fixed, Se3::identity(), params, |_| {
329        ControlFlow::Continue(())
330    })
331}
332
333/// The same, from a given initial pose and with an observer.
334///
335/// Returning [`ControlFlow::Break`] from the observer stops the run; see
336/// [`rigidity_core::icp::register_observed`] for what that costs in
337/// latency and what the result then means.
338pub fn register_pair_observed<F>(
339    moving: &Prepared,
340    fixed: &Prepared,
341    initial: Se3,
342    params: &RegisterParams,
343    observer: F,
344) -> IcpResult
345where
346    F: FnMut(&IterationReport) -> ControlFlow<()>,
347{
348    register_observed(
349        &moving.surface(),
350        &fixed.surface(),
351        &fixed.tree,
352        initial,
353        &params.icp_config(),
354        observer,
355    )
356}
357
358/// The conditioning of a single surface.
359///
360/// This is the question asked before a scan rather than after one: if
361/// anything were registered against this cloud, which degrees of freedom
362/// would it determine? Every point is its own correspondence at zero
363/// residual, so the answer describes the geometry alone.
364pub fn analyse_cloud(prepared: &Prepared) -> Result<Analysis, PipelineError> {
365    analyse(prepared.cloud.len(), Kernel::Squared, |index| {
366        Some(Correspondence {
367            point: prepared.cloud.point(index),
368            normal: prepared.normals[index],
369            residual: 0.0,
370        })
371    })
372    .ok_or(PipelineError::EmptyCloud)
373}
374
375/// The conditioning of a registration, at a given pose.
376///
377/// The pose is a parameter rather than an [`IcpResult`] on purpose: the
378/// viewer asks this question while scrubbing a recorded run, and the
379/// answer at iteration twelve is as legitimate as the answer at the end.
380pub fn analyse_registration(
381    moving: &Prepared,
382    fixed: &Prepared,
383    pose: &Se3,
384    params: &RegisterParams,
385) -> Result<Analysis, PipelineError> {
386    let limit = params.max_distance * params.max_distance;
387    analyse(moving.cloud.len(), params.kernel(), |index| {
388        let point = pose.transform_point(&moving.cloud.point(index));
389        let mut found = Vec::with_capacity(1);
390        fixed.tree.knn_into(&point, 1, &mut found);
391        let nearest = found.first()?;
392        if nearest.distance_squared > limit {
393            return None;
394        }
395        let matched = nearest.index as usize;
396        Some(Correspondence {
397            point,
398            normal: fixed.normals[matched],
399            residual: fixed.normals[matched].dot(&(point - fixed.cloud.point(matched))),
400        })
401    })
402    .ok_or(PipelineError::NoCorrespondences)
403}
404
405/// What the numbers in a report are measured against.
406#[derive(Debug, Clone, Copy, PartialEq)]
407pub struct ReportParams {
408    /// Standard deviation of the sensor noise, metres.
409    pub noise: f64,
410    /// Required pose accuracy, metres.
411    pub tolerance: f64,
412    /// Empirical correction applied to the predicted spread.
413    ///
414    /// On real data the formula understates the spread by roughly a factor
415    /// of 17: it treats measurements as independent while they are
416    /// correlated. See the README for the measurement.
417    pub calibration: f64,
418}
419
420impl Default for ReportParams {
421    fn default() -> Self {
422        Self {
423            noise: 0.01,
424            tolerance: 0.001,
425            calibration: 1.0,
426        }
427    }
428}
429
430impl ReportParams {
431    /// The criteria the classification uses.
432    ///
433    /// The correction multiplies the noise, which is the only place it
434    /// may be applied: it is a statement about how many measurements are
435    /// really independent, not about how accurate the application needs
436    /// to be.
437    pub fn criteria(&self) -> ObservabilityCriteria {
438        ObservabilityCriteria {
439            noise_sigma: self.noise * self.calibration,
440            tolerance: self.tolerance,
441        }
442    }
443}
444
445/// Applies a pose to every point of a cloud.
446///
447/// Attributes are not carried over, for the same reason downsampling
448/// drops them: what a column means decides whether it survives a
449/// transform, and the cloud does not know.
450pub fn transform_cloud(cloud: &PointCloud, pose: &Se3) -> PointCloud {
451    let mut moved = PointCloud::with_capacity(cloud.len());
452    for index in 0..cloud.len() {
453        moved.push(pose.transform_point(&cloud.point(index)));
454    }
455    moved
456}