use std::ops::ControlFlow;
use std::path::{Path, PathBuf};
use rigidity_core::icp::{
IcpConfig, IcpResult, IterationReport, Kernel, Surface, register_observed, surface,
};
use rigidity_core::lie::Se3;
use rigidity_core::nalgebra::Vector3;
use rigidity_core::normals::estimate_normals_observed;
use rigidity_core::observability::{Analysis, Correspondence, ObservabilityCriteria, analyse};
use rigidity_core::voxel::voxel_downsample_observed;
use rigidity_core::{NeighborSearch, PointCloud};
use rigidity_spatial::KdTree;
#[derive(Debug, thiserror::Error)]
pub enum PipelineError {
#[error("{}: {source}", path.display())]
Read {
path: PathBuf,
source: rigidity_io::IoError,
},
#[error("{}: {source}", path.display())]
Prepare {
path: PathBuf,
source: Box<PipelineError>,
},
#[error("no points left after downsampling")]
EmptyAfterDownsampling,
#[error("the cloud is empty")]
EmptyCloud,
#[error("no correspondences left")]
NoCorrespondences,
#[error(transparent)]
Cloud(#[from] rigidity_core::CloudError),
#[error(transparent)]
Spatial(#[from] rigidity_spatial::SpatialError),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stage {
Reading,
Downsampling,
Indexing,
Normals,
}
impl Stage {
pub fn label(self) -> &'static str {
match self {
Self::Reading => "reading",
Self::Downsampling => "downsampling",
Self::Indexing => "indexing",
Self::Normals => "normals",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Progress {
pub stage: Stage,
pub done: usize,
pub total: usize,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PrepareParams {
pub voxel: f64,
pub neighbours: usize,
}
impl Default for PrepareParams {
fn default() -> Self {
Self {
voxel: 0.05,
neighbours: 16,
}
}
}
pub struct Prepared {
pub cloud: PointCloud,
pub normals: Vec<Vector3<f64>>,
pub tree: KdTree,
}
impl Prepared {
pub fn surface(&self) -> Surface<'_> {
surface(&self.cloud, &self.normals)
}
pub fn len(&self) -> usize {
self.cloud.len()
}
pub fn is_empty(&self) -> bool {
self.cloud.is_empty()
}
}
pub fn prepare(path: &Path, params: &PrepareParams) -> Result<Prepared, PipelineError> {
prepare_observed(path, params, |_| {})
}
pub fn prepare_observed<F>(
path: &Path,
params: &PrepareParams,
mut observer: F,
) -> Result<Prepared, PipelineError>
where
F: FnMut(Progress),
{
observer(Progress {
stage: Stage::Reading,
done: 0,
total: 1,
});
let raw = rigidity_io::read(path).map_err(|source| PipelineError::Read {
path: path.to_path_buf(),
source,
})?;
observer(Progress {
stage: Stage::Reading,
done: 1,
total: 1,
});
prepare_cloud_observed(&raw, params, observer).map_err(|source| PipelineError::Prepare {
path: path.to_path_buf(),
source: Box::new(source),
})
}
pub fn prepare_cloud(
cloud: &PointCloud,
params: &PrepareParams,
) -> Result<Prepared, PipelineError> {
prepare_cloud_observed(cloud, params, |_| {})
}
pub fn prepare_cloud_observed<F>(
cloud: &PointCloud,
params: &PrepareParams,
mut observer: F,
) -> Result<Prepared, PipelineError>
where
F: FnMut(Progress),
{
let cloud = if params.voxel > 0.0 {
voxel_downsample_observed(cloud, params.voxel, |done, total| {
observer(Progress {
stage: Stage::Downsampling,
done,
total,
})
})?
} else {
cloud.clone()
};
if cloud.is_empty() {
return Err(PipelineError::EmptyAfterDownsampling);
}
let tree = KdTree::build_observed(&cloud, |done, total| {
observer(Progress {
stage: Stage::Indexing,
done,
total,
})
})?;
observer(Progress {
stage: Stage::Normals,
done: 0,
total: cloud.len(),
});
let normals = estimate_normals_observed(&cloud, &tree, params.neighbours, |done, total| {
observer(Progress {
stage: Stage::Normals,
done,
total,
})
});
Ok(Prepared {
cloud,
normals,
tree,
})
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RegisterParams {
pub max_distance: f64,
pub huber: f64,
pub max_iterations: usize,
pub min_normal_cosine: f64,
}
impl Default for RegisterParams {
fn default() -> Self {
Self {
max_distance: 0.5,
huber: 0.1,
max_iterations: IcpConfig::default().max_iterations,
min_normal_cosine: 0.0,
}
}
}
impl RegisterParams {
pub fn kernel(&self) -> Kernel {
Kernel::Huber(self.huber)
}
pub fn icp_config(&self) -> IcpConfig {
IcpConfig {
kernel: self.kernel(),
max_correspondence_distance: self.max_distance,
min_normal_cosine: self.min_normal_cosine,
max_iterations: self.max_iterations,
..IcpConfig::default()
}
}
}
pub fn register_pair(moving: &Prepared, fixed: &Prepared, params: &RegisterParams) -> IcpResult {
register_pair_observed(moving, fixed, Se3::identity(), params, |_| {
ControlFlow::Continue(())
})
}
pub fn register_pair_observed<F>(
moving: &Prepared,
fixed: &Prepared,
initial: Se3,
params: &RegisterParams,
observer: F,
) -> IcpResult
where
F: FnMut(&IterationReport) -> ControlFlow<()>,
{
register_observed(
&moving.surface(),
&fixed.surface(),
&fixed.tree,
initial,
¶ms.icp_config(),
observer,
)
}
pub fn analyse_cloud(prepared: &Prepared) -> Result<Analysis, PipelineError> {
analyse(prepared.cloud.len(), Kernel::Squared, |index| {
Some(Correspondence {
point: prepared.cloud.point(index),
normal: prepared.normals[index],
residual: 0.0,
})
})
.ok_or(PipelineError::EmptyCloud)
}
pub fn analyse_registration(
moving: &Prepared,
fixed: &Prepared,
pose: &Se3,
params: &RegisterParams,
) -> Result<Analysis, PipelineError> {
let limit = params.max_distance * params.max_distance;
analyse(moving.cloud.len(), params.kernel(), |index| {
let point = pose.transform_point(&moving.cloud.point(index));
let mut found = Vec::with_capacity(1);
fixed.tree.knn_into(&point, 1, &mut found);
let nearest = found.first()?;
if nearest.distance_squared > limit {
return None;
}
let matched = nearest.index as usize;
Some(Correspondence {
point,
normal: fixed.normals[matched],
residual: fixed.normals[matched].dot(&(point - fixed.cloud.point(matched))),
})
})
.ok_or(PipelineError::NoCorrespondences)
}
pub fn median_absolute_residual(
moving: &Prepared,
fixed: &Prepared,
pose: &Se3,
params: &RegisterParams,
) -> Option<f64> {
let limit = params.max_distance * params.max_distance;
let mut residuals: Vec<f64> = Vec::new();
let mut found = Vec::with_capacity(1);
for index in 0..moving.cloud.len() {
let point = pose.transform_point(&moving.cloud.point(index));
fixed.tree.knn_into(&point, 1, &mut found);
let Some(nearest) = found.first() else {
continue;
};
if nearest.distance_squared > limit {
continue;
}
let matched = nearest.index as usize;
let normal = fixed.normals[matched];
residuals.push(normal.dot(&(point - fixed.cloud.point(matched))).abs());
}
if residuals.is_empty() {
return None;
}
let middle = residuals.len() / 2;
let (_, median, _) = residuals.select_nth_unstable_by(middle, f64::total_cmp);
Some(*median)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ReportParams {
pub noise: f64,
pub tolerance: f64,
pub calibration: f64,
}
impl Default for ReportParams {
fn default() -> Self {
Self {
noise: 0.01,
tolerance: 0.001,
calibration: 1.0,
}
}
}
impl ReportParams {
pub fn criteria(&self) -> ObservabilityCriteria {
ObservabilityCriteria {
noise_sigma: self.noise * self.calibration,
tolerance: self.tolerance,
}
}
}
pub fn transform_cloud(cloud: &PointCloud, pose: &Se3) -> PointCloud {
let mut moved = PointCloud::with_capacity(cloud.len());
for index in 0..cloud.len() {
moved.push(pose.transform_point(&cloud.point(index)));
}
moved
}
#[cfg(test)]
mod tests {
use super::*;
use rigidity_core::PointCloud;
use rigidity_core::lie::So3;
use rigidity_core::nalgebra::Vector6;
fn corner() -> PointCloud {
let mut cloud = PointCloud::new();
for i in 0..60 {
for j in 0..60 {
let jitter = ((i * 37 + j * 17) % 13) as f64 * 0.003;
let a = i as f64 * 0.05 + jitter;
let b = j as f64 * 0.05 - jitter;
cloud.push(Vector3::new(a, 0.0, b));
cloud.push(Vector3::new(0.0, b, a));
}
}
cloud
}
#[test]
fn a_displaced_pose_shows_in_the_median_residual() {
const NOISE: f64 = 0.01;
let params = PrepareParams {
voxel: 0.0,
neighbours: 12,
};
let prepared = prepare_cloud(&corner(), ¶ms).expect("the corner prepares");
let right = median_absolute_residual(
&prepared,
&prepared,
&Se3::identity(),
&RegisterParams::default(),
)
.expect("the cloud matches itself");
assert!(
right < 0.1 * NOISE,
"at the true pose the median residual is {right} m"
);
let displaced = Se3::exp(&Vector6::new(0.07, 0.07, 0.0, 0.0, 0.0, 0.0));
let wrong =
median_absolute_residual(&prepared, &prepared, &displaced, &RegisterParams::default())
.expect("the displaced cloud still matches");
assert!(
wrong > NOISE,
"displaced by 0.1 m the median residual is only {wrong} m"
);
let turned = Se3::from_parts(So3::exp(&Vector3::new(0.0, 0.0, 0.02)), Vector3::zeros());
let turned =
median_absolute_residual(&prepared, &prepared, &turned, &RegisterParams::default())
.expect("the turned cloud still matches");
assert!(
turned > NOISE,
"turned by 0.02 rad the median residual is only {turned} m"
);
}
}