use nalgebra::{Matrix3, Translation3, UnitQuaternion};
use threecrate_core::{Error, Isometry3, NearestNeighborSearch, Point3f, PointCloud, Result};
use crate::filtering::voxel_grid_filter;
use crate::nearest_neighbor::KdTree;
use crate::registration::ICPResult;
#[derive(Debug, Clone)]
pub struct KissIcpConfig {
pub voxel_size: f32,
pub max_range: f32,
pub min_range: f32,
pub max_iterations: usize,
}
impl Default for KissIcpConfig {
fn default() -> Self {
Self {
voxel_size: 1.0,
max_range: 100.0,
min_range: 0.5,
max_iterations: 50,
}
}
}
fn range_filter(cloud: &PointCloud<Point3f>, min_r: f32, max_r: f32) -> PointCloud<Point3f> {
let min_sq = min_r * min_r;
let max_sq = max_r * max_r;
PointCloud::from_points(
cloud
.points
.iter()
.filter(|p| {
let r2 = p.coords.magnitude_squared();
r2 >= min_sq && r2 <= max_sq
})
.copied()
.collect(),
)
}
fn adaptive_threshold(init: &Isometry3<f32>, voxel_size: f32) -> f32 {
let trans = init.translation.vector.magnitude();
let rot_disp = 2.0 * init.rotation.imag().magnitude() * voxel_size;
let motion = trans + rot_disp;
(3.0 * motion)
.max(3.0 * voxel_size)
.min(10.0 * voxel_size)
}
fn svd_transform(src: &[Point3f], tgt: &[Point3f]) -> Result<Isometry3<f32>> {
debug_assert_eq!(src.len(), tgt.len());
if src.len() < 3 {
return Err(Error::Algorithm(
"KISS-ICP SVD: need at least 3 point pairs for a rigid transform".into(),
));
}
let n = src.len() as f32;
let src_mean = src.iter().fold(nalgebra::Vector3::zeros(), |a, p| a + p.coords) / n;
let tgt_mean = tgt.iter().fold(nalgebra::Vector3::zeros(), |a, p| a + p.coords) / n;
let mut h = Matrix3::<f32>::zeros();
for (s, t) in src.iter().zip(tgt.iter()) {
h += (s.coords - src_mean) * (t.coords - tgt_mean).transpose();
}
if h.norm() < 1e-10 {
return Err(Error::Algorithm(
"KISS-ICP SVD: cross-covariance matrix H is near-zero — \
all correspondence points are identical or collinear".into(),
));
}
let svd = h.svd(true, true);
let u = svd
.u
.ok_or_else(|| Error::Algorithm("KISS-ICP SVD failed (U)".into()))?;
let vt = svd
.v_t
.ok_or_else(|| Error::Algorithm("KISS-ICP SVD failed (V^T)".into()))?;
let mut r = vt.transpose() * u.transpose();
if r.determinant() < 0.0 {
let mut vt_fix = vt;
vt_fix.set_row(2, &(-vt.row(2)));
r = vt_fix.transpose() * u.transpose();
}
let rotation = UnitQuaternion::from_matrix(&r);
let translation = tgt_mean - rotation * src_mean;
Ok(Isometry3::from_parts(
Translation3::new(translation.x, translation.y, translation.z),
rotation,
))
}
pub fn kiss_icp(
source: &PointCloud<Point3f>,
target: &PointCloud<Point3f>,
init: Isometry3<f32>,
config: KissIcpConfig,
) -> Result<ICPResult> {
if source.is_empty() || target.is_empty() {
return Err(Error::InvalidData(
"KISS-ICP: source or target point cloud is empty".into(),
));
}
if config.max_iterations == 0 {
return Err(Error::InvalidData(
"KISS-ICP: max_iterations must be > 0".into(),
));
}
if config.voxel_size <= 0.0 {
return Err(Error::InvalidData(
"KISS-ICP: voxel_size must be > 0".into(),
));
}
let source_ranged = range_filter(source, config.min_range, config.max_range);
if source_ranged.is_empty() {
return Err(Error::InvalidData(
"KISS-ICP: no source points remain after range filtering — \
check min_range / max_range relative to your data".into(),
));
}
let source_down = voxel_grid_filter(&source_ranged, config.voxel_size)?;
if source_down.is_empty() {
return Err(Error::InvalidData(
"KISS-ICP: no source points remain after voxel downsampling".into(),
));
}
let sigma = adaptive_threshold(&init, config.voxel_size);
let target_tree = KdTree::new(&target.points)?;
let mut current_transform = init;
let mut prev_mse = f32::INFINITY;
let mut final_corr: Vec<(usize, usize)> = Vec::new();
for iteration in 0..config.max_iterations {
let transformed: Vec<Point3f> =
source_down.points.iter().map(|p| current_transform * p).collect();
let mut src_matched: Vec<Point3f> = Vec::new();
let mut tgt_matched: Vec<Point3f> = Vec::new();
let mut corr_pairs: Vec<(usize, usize)> = Vec::new();
for (src_idx, ts) in transformed.iter().enumerate() {
let neighbours = target_tree.find_k_nearest(ts, 1);
if neighbours.is_empty() {
continue;
}
let (tgt_idx, dist) = neighbours[0];
if dist > sigma {
continue;
}
src_matched.push(*ts);
tgt_matched.push(target.points[tgt_idx]);
corr_pairs.push((src_idx, tgt_idx));
}
if src_matched.len() < 3 {
return Err(Error::Algorithm(
"KISS-ICP: too few correspondences within the adaptive threshold — \
try increasing voxel_size or check point cloud overlap".into(),
));
}
let delta = svd_transform(&src_matched, &tgt_matched)?;
current_transform = delta * current_transform;
let mse: f32 = src_matched
.iter()
.zip(tgt_matched.iter())
.map(|(s, t)| (delta * s - t).magnitude_squared())
.sum::<f32>()
/ src_matched.len() as f32;
if (prev_mse - mse).abs() < 1e-6 {
return Ok(ICPResult {
transformation: current_transform,
mse,
iterations: iteration + 1,
converged: true,
correspondences: corr_pairs,
});
}
prev_mse = mse;
final_corr = corr_pairs;
}
Ok(ICPResult {
transformation: current_transform,
mse: prev_mse,
iterations: config.max_iterations,
converged: false,
correspondences: final_corr,
})
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
use threecrate_core::Vector3f;
fn make_sphere(n: usize, radius: f32) -> PointCloud<Point3f> {
let golden = std::f32::consts::PI * (3.0 - 5.0_f32.sqrt());
PointCloud::from_points(
(0..n).map(|i| {
let y = 1.0 - (i as f32 / (n as f32 - 1.0).max(1.0)) * 2.0;
let r = (1.0 - y * y).max(0.0_f32).sqrt();
let theta = golden * i as f32;
Point3f::new(theta.cos() * r * radius, y * radius, theta.sin() * r * radius)
}).collect(),
)
}
fn make_grid(n: usize, spacing: f32, z_offset: f32) -> PointCloud<Point3f> {
let side = (n as f32).sqrt().ceil() as usize;
PointCloud::from_points(
(0..side)
.flat_map(|i| {
(0..side).map(move |j| {
Point3f::new(i as f32 * spacing, j as f32 * spacing, z_offset)
})
})
.take(n)
.collect(),
)
}
fn make_ring(n: usize, range: f32) -> PointCloud<Point3f> {
PointCloud::from_points(
(0..n)
.map(|i| {
let angle = 2.0 * std::f32::consts::PI * i as f32 / n as f32;
Point3f::new(angle.cos() * range, angle.sin() * range, 0.0)
})
.collect(),
)
}
#[test]
fn kiss_icp_identity_converges() {
let cloud = make_ring(200, 5.0);
let config = KissIcpConfig {
voxel_size: 0.2,
max_range: 50.0,
min_range: 0.1,
max_iterations: 30,
};
let result = kiss_icp(&cloud, &cloud, Isometry3::identity(), config).unwrap();
assert!(result.converged || result.mse < 1e-4, "mse={}", result.mse);
}
#[test]
fn kiss_icp_recovers_small_translation() {
let source = make_grid(100, 0.5, 5.0); let shift = Vector3f::new(0.1, 0.0, 0.0);
let target =
PointCloud::from_points(source.points.iter().map(|p| p + shift).collect());
let config = KissIcpConfig {
voxel_size: 0.2,
max_range: 50.0,
min_range: 0.1,
max_iterations: 50,
};
let result = kiss_icp(&source, &target, Isometry3::identity(), config).unwrap();
let t_err = (result.transformation.translation.vector - shift).magnitude();
assert!(t_err < 0.05, "translation error={}", t_err);
assert!(result.mse < 0.1, "mse={}", result.mse);
}
#[test]
fn kiss_icp_empty_source_errors() {
let empty: PointCloud<Point3f> = PointCloud::new();
let cloud = make_ring(30, 5.0);
assert!(
kiss_icp(&empty, &cloud, Isometry3::identity(), KissIcpConfig::default()).is_err()
);
}
#[test]
fn kiss_icp_all_points_outside_range_errors() {
let cloud = make_ring(50, 0.05);
let config = KissIcpConfig {
min_range: 0.5,
..Default::default()
};
assert!(kiss_icp(&cloud, &cloud, Isometry3::identity(), config).is_err());
}
#[test]
fn kiss_icp_zero_voxel_size_errors() {
let cloud = make_ring(30, 5.0);
let config = KissIcpConfig { voxel_size: 0.0, ..Default::default() };
assert!(kiss_icp(&cloud, &cloud, Isometry3::identity(), config).is_err());
}
#[test]
fn kiss_icp_result_fields_populated() {
let cloud = make_ring(60, 5.0);
let config = KissIcpConfig {
voxel_size: 0.3,
max_range: 50.0,
min_range: 0.1,
max_iterations: 10,
};
let result = kiss_icp(&cloud, &cloud, Isometry3::identity(), config).unwrap();
assert!(result.iterations > 0);
assert!(!result.correspondences.is_empty());
}
#[test]
fn adaptive_threshold_no_prior() {
let sigma = adaptive_threshold(&Isometry3::identity(), 1.0);
assert_relative_eq!(sigma, 3.0, epsilon = 1e-5);
}
#[test]
fn range_filter_removes_out_of_range() {
let pts = vec![
Point3f::new(0.1, 0.0, 0.0), Point3f::new(5.0, 0.0, 0.0), Point3f::new(200.0, 0.0, 0.0), ];
let cloud = PointCloud::from_points(pts);
let filtered = range_filter(&cloud, 0.5, 100.0);
assert_eq!(filtered.len(), 1);
assert_relative_eq!(filtered.points[0].x, 5.0, epsilon = 1e-5);
}
#[test]
fn adaptive_threshold_large_rotation_stays_bounded() {
let rot90 = Isometry3::from_parts(
nalgebra::Translation3::identity(),
nalgebra::UnitQuaternion::from_axis_angle(&nalgebra::Vector3::z_axis(), std::f32::consts::FRAC_PI_2),
);
let sigma = adaptive_threshold(&rot90, 1.0);
assert!(sigma >= 3.0, "sigma below lower bound: {}", sigma);
assert!(sigma <= 10.0, "sigma above upper bound: {}", sigma);
assert!(sigma < 4.5, "sigma suspiciously large — possible unit mixing: {}", sigma);
}
#[test]
fn adaptive_threshold_180_deg_saturates_at_voxel_scale() {
let rot180 = Isometry3::from_parts(
nalgebra::Translation3::identity(),
nalgebra::UnitQuaternion::from_axis_angle(&nalgebra::Vector3::z_axis(), std::f32::consts::PI),
);
let sigma = adaptive_threshold(&rot180, 1.0);
assert!(sigma <= 10.0, "sigma exceeds 10·voxel_size: {}", sigma);
assert!(sigma >= 3.0);
}
#[test]
fn kiss_icp_recovers_tiny_rotation_from_identity() {
let source = make_sphere(300, 5.0);
let angle = 3_f32.to_radians();
let rot = nalgebra::UnitQuaternion::from_axis_angle(&nalgebra::Vector3::z_axis(), angle);
let target = PointCloud::from_points(
source.points.iter().map(|p| rot * p).collect(),
);
let config = KissIcpConfig {
voxel_size: 0.5,
max_range: 50.0,
min_range: 0.1,
max_iterations: 60,
};
let result = kiss_icp(&source, &target, Isometry3::identity(), config).unwrap();
let rot_err = result.transformation.rotation.angle_to(&rot);
assert!(
rot_err < 1_f32.to_radians(),
"rotation error = {:.2}°",
rot_err.to_degrees()
);
let t_err = result.transformation.translation.vector.magnitude();
assert!(t_err < 0.1, "spurious translation={}", t_err);
}
#[test]
fn kiss_icp_refines_rotation_from_near_correct_init() {
let source = make_sphere(300, 5.0);
let true_angle = 8_f32.to_radians();
let init_angle = 6_f32.to_radians();
let true_rot = nalgebra::UnitQuaternion::from_axis_angle(&nalgebra::Vector3::z_axis(), true_angle);
let target = PointCloud::from_points(
source.points.iter().map(|p| true_rot * p).collect(),
);
let init = Isometry3::from_parts(
nalgebra::Translation3::identity(),
nalgebra::UnitQuaternion::from_axis_angle(&nalgebra::Vector3::z_axis(), init_angle),
);
let config = KissIcpConfig {
voxel_size: 0.5,
max_range: 50.0,
min_range: 0.1,
max_iterations: 60,
};
let result = kiss_icp(&source, &target, init, config).unwrap();
let rot_err = result.transformation.rotation.angle_to(&true_rot);
assert!(
rot_err < 1_f32.to_radians(),
"rotation error = {:.2}°",
rot_err.to_degrees()
);
}
#[test]
fn kiss_icp_robust_to_gaussian_noise() {
let base = make_ring(200, 5.0);
let noise_half = 0.05_f32;
let source = PointCloud::from_points(
base.points.iter().enumerate().map(|(i, p)| {
let t = (i as f32 * 1.6180339887_f32).sin() * noise_half;
let u = (i as f32 * 2.7182818284_f32).cos() * noise_half;
let v = (i as f32 * 3.1415926535_f32).sin() * noise_half;
Point3f::new(p.x + t, p.y + u, p.z + v)
}).collect(),
);
let config = KissIcpConfig {
voxel_size: 0.2,
max_range: 50.0,
min_range: 0.1,
max_iterations: 40,
};
let result = kiss_icp(&source, &base, Isometry3::identity(), config).unwrap();
assert!(result.mse < 0.05, "mse={}", result.mse);
let t_err = result.transformation.translation.vector.magnitude();
assert!(t_err < 0.1, "spurious drift={}", t_err);
}
#[test]
fn kiss_icp_collinear_source_recovers_translation() {
let source = PointCloud::from_points(
(0..30).map(|i| Point3f::new(i as f32 * 0.3 + 1.0, 0.0, 5.0)).collect(),
);
let shift_x = 0.05_f32;
let target = PointCloud::from_points(
source.points.iter().map(|p| Point3f::new(p.x + shift_x, p.y, p.z)).collect(),
);
let config = KissIcpConfig {
voxel_size: 0.1,
max_range: 50.0,
min_range: 0.1,
max_iterations: 30,
};
let result = kiss_icp(&source, &target, Isometry3::identity(), config).unwrap();
let tx = result.transformation.translation.vector.x;
assert!((tx - shift_x).abs() < 0.02, "x translation error={}", (tx - shift_x).abs());
}
#[test]
fn kiss_icp_identical_points_reduced_to_one_errors() {
let source = PointCloud::from_points(
std::iter::repeat(Point3f::new(5.0, 0.0, 0.0)).take(20).collect(),
);
let config = KissIcpConfig {
min_range: 0.1,
max_range: 50.0,
voxel_size: 1.0,
max_iterations: 5,
};
assert!(
kiss_icp(&source, &source, Isometry3::identity(), config).is_err(),
"expected error for single-point cloud after downsampling"
);
}
}