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(), ¶ms)?;
17//! let fixed = prepare("target.ply".as_ref(), ¶ms)?;
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 ¶ms.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}