1use spatialrust_core::{HasNormals3, HasPositions3, PointCloud, SpatialError, SpatialResult};
2use spatialrust_math::{
3 solve_linear_system, Isometry3, LeastSquaresResult, Quat, TransformPoint, Vec3,
4};
5use spatialrust_search::{KdTree, NearestNeighborIndex};
6
7use crate::registration::{PointCloudRegistration, RegistrationResult};
8
9#[derive(Clone, Copy, Debug, PartialEq)]
16pub struct PointToPlaneIcpConfig {
17 pub max_iterations: usize,
19 pub max_correspondence_distance: f32,
21 pub transformation_epsilon: f64,
23 pub fitness_epsilon: f64,
25 pub min_correspondences: usize,
27 pub initial_guess: Isometry3<f32>,
29}
30
31impl Default for PointToPlaneIcpConfig {
32 fn default() -> Self {
33 Self {
34 max_iterations: 50,
35 max_correspondence_distance: 1.0,
36 transformation_epsilon: 1e-8,
37 fitness_epsilon: 1e-6,
38 min_correspondences: 6,
39 initial_guess: Isometry3::identity(),
40 }
41 }
42}
43
44impl PointToPlaneIcpConfig {
45 #[must_use]
47 pub fn with_correspondence_distance(max_correspondence_distance: f32) -> Self {
48 Self { max_correspondence_distance, ..Self::default() }
49 }
50}
51
52#[derive(Clone, Copy, Debug, PartialEq)]
54pub struct PointToPlaneIcp {
55 config: PointToPlaneIcpConfig,
56}
57
58impl PointToPlaneIcp {
59 #[must_use]
61 pub const fn new(config: PointToPlaneIcpConfig) -> Self {
62 Self { config }
63 }
64
65 #[must_use]
67 pub const fn config(&self) -> PointToPlaneIcpConfig {
68 self.config
69 }
70
71 pub fn align_with_diagnostics(
75 &self,
76 source: &PointCloud,
77 target: &PointCloud,
78 ) -> SpatialResult<RegistrationResult> {
79 if source.is_empty() || target.is_empty() {
80 return Err(SpatialError::InvalidArgument(
81 "ICP requires non-empty source and target point clouds".to_owned(),
82 ));
83 }
84
85 let (source_x, source_y, source_z) = source.positions3()?;
86 let (target_x, target_y, target_z) = target.positions3()?;
87 let (normal_x, normal_y, normal_z) = target.normals3()?;
88 let tree = KdTree::from_slices(target_x, target_y, target_z);
89 let max_distance_squared =
90 self.config.max_correspondence_distance * self.config.max_correspondence_distance;
91
92 let mut transform = self.config.initial_guess;
93 let mut transformed = vec![Vec3::new(0.0, 0.0, 0.0); source.len()];
94 apply_transform(&mut transformed, source_x, source_y, source_z, transform);
95
96 let mut iterations = 0usize;
97 let mut converged = false;
98
99 for _ in 0..self.config.max_iterations {
100 iterations += 1;
101
102 let mut ata = [[0.0_f64; 6]; 6];
104 let mut atb = [0.0_f64; 6];
105 let mut count = 0usize;
106
107 for point in &transformed {
108 let Some(neighbor) = tree.nearest_one(point.x, point.y, point.z) else {
109 continue;
110 };
111 if neighbor.distance_squared > max_distance_squared {
112 continue;
113 }
114 let q = Vec3::new(
115 target_x[neighbor.index],
116 target_y[neighbor.index],
117 target_z[neighbor.index],
118 );
119 let n = Vec3::new(
120 normal_x[neighbor.index],
121 normal_y[neighbor.index],
122 normal_z[neighbor.index],
123 );
124
125 let c = point.cross(n);
127 let row = [
128 f64::from(c.x),
129 f64::from(c.y),
130 f64::from(c.z),
131 f64::from(n.x),
132 f64::from(n.y),
133 f64::from(n.z),
134 ];
135 let residual = f64::from((*point - q).dot(n));
136
137 for i in 0..6 {
138 atb[i] -= row[i] * residual;
139 for j in 0..6 {
140 ata[i][j] += row[i] * row[j];
141 }
142 }
143 count += 1;
144 }
145
146 if count < self.config.min_correspondences {
147 return Err(SpatialError::InvalidArgument(format!(
148 "point-to-plane ICP found only {} correspondences, minimum is {}",
149 count, self.config.min_correspondences
150 )));
151 }
152
153 let a_rows: Vec<Vec<f64>> = ata.iter().map(|row| row.to_vec()).collect();
154 let LeastSquaresResult::Solved(solution) = solve_linear_system(a_rows, atb.to_vec())
155 else {
156 return Err(SpatialError::InvalidArgument(
157 "point-to-plane ICP normal equations were singular".to_owned(),
158 ));
159 };
160
161 let delta = delta_from_solution(&solution);
162 transform = delta.compose(transform);
163 apply_transform(&mut transformed, source_x, source_y, source_z, transform);
164
165 if update_magnitude(&solution) < self.config.transformation_epsilon {
166 converged = true;
167 break;
168 }
169 let fitness = point_to_plane_fitness(
170 &transformed,
171 target_x,
172 target_y,
173 target_z,
174 normal_x,
175 normal_y,
176 normal_z,
177 &tree,
178 max_distance_squared,
179 );
180 if fitness < self.config.fitness_epsilon {
181 converged = true;
182 break;
183 }
184 }
185
186 Ok(RegistrationResult {
187 transform,
188 fitness: point_to_plane_fitness(
189 &transformed,
190 target_x,
191 target_y,
192 target_z,
193 normal_x,
194 normal_y,
195 normal_z,
196 &tree,
197 max_distance_squared,
198 ),
199 iterations,
200 converged,
201 })
202 }
203}
204
205impl PointCloudRegistration for PointToPlaneIcp {
206 fn name(&self) -> &'static str {
207 "PointToPlaneIcp"
208 }
209
210 fn align(&self, source: &PointCloud, target: &PointCloud) -> SpatialResult<RegistrationResult> {
211 self.align_with_diagnostics(source, target)
212 }
213}
214
215fn apply_transform(
216 transformed: &mut [Vec3<f32>],
217 source_x: &[f32],
218 source_y: &[f32],
219 source_z: &[f32],
220 transform: Isometry3<f32>,
221) {
222 for (index, point) in transformed.iter_mut().enumerate() {
223 *point =
224 transform.transform_point(Vec3::new(source_x[index], source_y[index], source_z[index]));
225 }
226}
227
228fn delta_from_solution(solution: &[f64]) -> Isometry3<f32> {
230 let (rx, ry, rz) = (solution[0], solution[1], solution[2]);
231 let angle = (rx * rx + ry * ry + rz * rz).sqrt();
232 let rotation = if angle > 1e-12 {
233 let axis = Vec3::new((rx / angle) as f32, (ry / angle) as f32, (rz / angle) as f32);
234 Quat::from_axis_angle(axis, angle as f32)
235 } else {
236 Quat::<f32>::identity()
237 };
238 let translation = Vec3::new(solution[3] as f32, solution[4] as f32, solution[5] as f32);
239 Isometry3::new(rotation, translation)
240}
241
242fn update_magnitude(solution: &[f64]) -> f64 {
243 solution.iter().map(|value| value * value).sum::<f64>().sqrt()
244}
245
246#[allow(clippy::too_many_arguments)]
247fn point_to_plane_fitness(
248 transformed: &[Vec3<f32>],
249 target_x: &[f32],
250 target_y: &[f32],
251 target_z: &[f32],
252 normal_x: &[f32],
253 normal_y: &[f32],
254 normal_z: &[f32],
255 tree: &KdTree,
256 max_distance_squared: f32,
257) -> f64 {
258 let mut sum = 0.0_f64;
259 let mut count = 0usize;
260 for point in transformed {
261 let Some(neighbor) = tree.nearest_one(point.x, point.y, point.z) else {
262 continue;
263 };
264 if neighbor.distance_squared > max_distance_squared {
265 continue;
266 }
267 let q =
268 Vec3::new(target_x[neighbor.index], target_y[neighbor.index], target_z[neighbor.index]);
269 let n =
270 Vec3::new(normal_x[neighbor.index], normal_y[neighbor.index], normal_z[neighbor.index]);
271 let residual = f64::from((*point - q).dot(n));
272 sum += residual * residual;
273 count += 1;
274 }
275 if count == 0 {
276 return f64::MAX;
277 }
278 sum / count as f64
279}
280
281#[cfg(test)]
282mod tests {
283 use super::{PointToPlaneIcp, PointToPlaneIcpConfig};
284 use crate::registration::PointCloudRegistration;
285 use crate::transform::transform_point_cloud;
286 use spatialrust_core::{DType, FieldSemantic, PointCloudBuilder, PointField, PointSchema};
287 use spatialrust_math::{Isometry3, Quat, TransformPoint, Vec3};
288
289 fn schema_with_normals() -> PointSchema {
290 PointSchema::new()
291 .with_field(PointField::scalar("x", FieldSemantic::PositionX, DType::F32))
292 .with_field(PointField::scalar("y", FieldSemantic::PositionY, DType::F32))
293 .with_field(PointField::scalar("z", FieldSemantic::PositionZ, DType::F32))
294 .with_field(PointField::scalar("normal_x", FieldSemantic::NormalX, DType::F32))
295 .with_field(PointField::scalar("normal_y", FieldSemantic::NormalY, DType::F32))
296 .with_field(PointField::scalar("normal_z", FieldSemantic::NormalZ, DType::F32))
297 }
298
299 fn box_corner() -> spatialrust_core::PointCloud {
301 let mut builder = PointCloudBuilder::new(schema_with_normals());
302 for i in 0..8 {
303 for j in 0..8 {
304 let (a, b) = (i as f32 * 0.1, j as f32 * 0.1);
305 builder.push_point([a, b, 0.0, 0.0, 0.0, 1.0]).unwrap();
307 builder.push_point([a, 0.0, b + 0.05, 0.0, 1.0, 0.0]).unwrap();
309 builder.push_point([0.0, a + 0.05, b + 0.05, 1.0, 0.0, 0.0]).unwrap();
311 }
312 }
313 builder.build().unwrap()
314 }
315
316 #[test]
317 fn aligns_rotated_and_translated_source() {
318 let target = box_corner();
319 let misalignment = Isometry3::new(
320 Quat::from_axis_angle(Vec3::new(0.0, 0.0, 1.0), 0.08),
321 Vec3::new(0.02, -0.015, 0.01),
322 );
323 let source = transform_point_cloud(&target, misalignment).unwrap();
324
325 let icp = PointToPlaneIcp::new(PointToPlaneIcpConfig {
326 max_correspondence_distance: 0.2,
327 max_iterations: 40,
328 ..PointToPlaneIcpConfig::default()
329 });
330 let result = icp.align(&source, &target).unwrap();
331 assert!(result.fitness < 1e-5, "fitness too high: {}", result.fitness);
332
333 let composed = result.transform.compose(misalignment);
335 let probe = Vec3::new(0.3, 0.4, 0.2);
336 let restored = composed.transform_point(probe);
337 assert!((restored.x - probe.x).abs() < 5e-3);
338 assert!((restored.y - probe.y).abs() < 5e-3);
339 assert!((restored.z - probe.z).abs() < 5e-3);
340 }
341
342 #[test]
343 fn requires_target_normals() {
344 let mut builder = PointCloudBuilder::xyz();
345 builder.push_point([0.0, 0.0, 0.0]).unwrap();
346 let cloud = builder.build().unwrap();
347 let icp = PointToPlaneIcp::new(PointToPlaneIcpConfig::default());
348 assert!(icp.align(&cloud, &cloud).is_err());
349 }
350}