1#![allow(dead_code)]
7#![allow(clippy::cast_precision_loss)]
8
9#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct Point2D {
12 pub x: f32,
14 pub y: f32,
16}
17
18impl Point2D {
19 #[must_use]
21 pub fn new(x: f32, y: f32) -> Self {
22 Self { x, y }
23 }
24
25 #[must_use]
27 pub fn distance_to(&self, other: &Point2D) -> f32 {
28 let dx = self.x - other.x;
29 let dy = self.y - other.y;
30 (dx * dx + dy * dy).sqrt()
31 }
32
33 #[must_use]
35 pub fn translate(&self, dx: f32, dy: f32) -> Point2D {
36 Point2D {
37 x: self.x + dx,
38 y: self.y + dy,
39 }
40 }
41}
42
43#[must_use]
49pub fn find_closest_point<'a>(query: &Point2D, candidates: &'a [Point2D]) -> &'a Point2D {
50 candidates
51 .iter()
52 .min_by(|a, b| {
53 query
54 .distance_to(a)
55 .partial_cmp(&query.distance_to(b))
56 .unwrap_or(std::cmp::Ordering::Equal)
57 })
58 .expect("candidates must not be empty")
59}
60
61#[must_use]
65pub fn compute_centroid(points: &[Point2D]) -> Point2D {
66 if points.is_empty() {
67 return Point2D::new(0.0, 0.0);
68 }
69 let n = points.len() as f32;
70 let sum_x: f32 = points.iter().map(|p| p.x).sum();
71 let sum_y: f32 = points.iter().map(|p| p.y).sum();
72 Point2D::new(sum_x / n, sum_y / n)
73}
74
75#[derive(Debug, Clone, Copy)]
77pub struct IcpConfig {
78 pub max_iterations: u32,
80 pub convergence_threshold: f32,
82}
83
84impl Default for IcpConfig {
85 fn default() -> Self {
86 Self {
87 max_iterations: 50,
88 convergence_threshold: 1e-4,
89 }
90 }
91}
92
93#[derive(Debug, Clone, Copy)]
95pub struct IcpResult {
96 pub translation: (f32, f32),
98 pub rmse: f32,
100 pub iterations: u32,
102 pub converged: bool,
104}
105
106impl IcpResult {
107 #[must_use]
109 pub fn is_good(&self) -> bool {
110 self.converged && self.rmse < 1.0
111 }
112}
113
114#[must_use]
124pub fn icp_align(source: &[Point2D], target: &[Point2D], config: &IcpConfig) -> IcpResult {
125 if source.is_empty() || target.is_empty() {
126 return IcpResult {
127 translation: (0.0, 0.0),
128 rmse: f32::MAX,
129 iterations: 0,
130 converged: false,
131 };
132 }
133
134 let mut tx = 0.0_f32;
135 let mut ty = 0.0_f32;
136 let mut prev_rmse = f32::MAX;
137 let mut converged = false;
138 let mut final_rmse = f32::MAX;
139 let mut final_iter = 0_u32;
140
141 for iter in 0..config.max_iterations {
142 let translated: Vec<Point2D> = source.iter().map(|p| p.translate(tx, ty)).collect();
144
145 let mut sum_sq = 0.0_f32;
147 let mut src_centroid_x = 0.0_f32;
148 let mut src_centroid_y = 0.0_f32;
149 let mut tgt_centroid_x = 0.0_f32;
150 let mut tgt_centroid_y = 0.0_f32;
151
152 for sp in &translated {
153 let closest = find_closest_point(sp, target);
154 let dx = closest.x - sp.x;
155 let dy = closest.y - sp.y;
156 sum_sq += dx * dx + dy * dy;
157 src_centroid_x += sp.x;
158 src_centroid_y += sp.y;
159 tgt_centroid_x += closest.x;
160 tgt_centroid_y += closest.y;
161 }
162
163 let n = translated.len() as f32;
164 let rmse = (sum_sq / n).sqrt();
165
166 tx += (tgt_centroid_x - src_centroid_x) / n;
168 ty += (tgt_centroid_y - src_centroid_y) / n;
169
170 final_rmse = rmse;
171 final_iter = iter + 1;
172
173 let improvement = (prev_rmse - rmse).abs();
175 if improvement < config.convergence_threshold {
176 converged = true;
177 break;
178 }
179 prev_rmse = rmse;
180 }
181
182 IcpResult {
183 translation: (tx, ty),
184 rmse: final_rmse,
185 iterations: final_iter,
186 converged,
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 #[test]
195 fn test_point2d_distance_to_self() {
196 let p = Point2D::new(3.0, 4.0);
197 assert_eq!(p.distance_to(&p), 0.0);
198 }
199
200 #[test]
201 fn test_point2d_distance_to_pythagorean() {
202 let p1 = Point2D::new(0.0, 0.0);
203 let p2 = Point2D::new(3.0, 4.0);
204 assert!((p1.distance_to(&p2) - 5.0).abs() < 1e-5);
205 }
206
207 #[test]
208 fn test_point2d_translate() {
209 let p = Point2D::new(1.0, 2.0);
210 let q = p.translate(3.0, -1.0);
211 assert!((q.x - 4.0).abs() < 1e-6);
212 assert!((q.y - 1.0).abs() < 1e-6);
213 }
214
215 #[test]
216 fn test_find_closest_point_single_candidate() {
217 let query = Point2D::new(0.0, 0.0);
218 let candidates = vec![Point2D::new(1.0, 1.0)];
219 let closest = find_closest_point(&query, &candidates);
220 assert_eq!(closest.x, 1.0);
221 assert_eq!(closest.y, 1.0);
222 }
223
224 #[test]
225 fn test_find_closest_point_multiple() {
226 let query = Point2D::new(0.0, 0.0);
227 let candidates = vec![
228 Point2D::new(10.0, 10.0),
229 Point2D::new(1.0, 0.0),
230 Point2D::new(5.0, 5.0),
231 ];
232 let closest = find_closest_point(&query, &candidates);
233 assert!((closest.x - 1.0).abs() < 1e-6);
234 assert!((closest.y - 0.0).abs() < 1e-6);
235 }
236
237 #[test]
238 fn test_compute_centroid_empty() {
239 let c = compute_centroid(&[]);
240 assert_eq!(c.x, 0.0);
241 assert_eq!(c.y, 0.0);
242 }
243
244 #[test]
245 fn test_compute_centroid_single() {
246 let pts = vec![Point2D::new(4.0, -2.0)];
247 let c = compute_centroid(&pts);
248 assert!((c.x - 4.0).abs() < 1e-6);
249 assert!((c.y + 2.0).abs() < 1e-6);
250 }
251
252 #[test]
253 fn test_compute_centroid_multiple() {
254 let pts = vec![
255 Point2D::new(0.0, 0.0),
256 Point2D::new(2.0, 0.0),
257 Point2D::new(1.0, 3.0),
258 ];
259 let c = compute_centroid(&pts);
260 assert!((c.x - 1.0).abs() < 1e-5);
261 assert!((c.y - 1.0).abs() < 1e-5);
262 }
263
264 #[test]
265 fn test_icp_config_default() {
266 let cfg = IcpConfig::default();
267 assert_eq!(cfg.max_iterations, 50);
268 assert!(cfg.convergence_threshold > 0.0);
269 }
270
271 #[test]
272 fn test_icp_result_is_good() {
273 let r = IcpResult {
274 translation: (0.0, 0.0),
275 rmse: 0.5,
276 iterations: 3,
277 converged: true,
278 };
279 assert!(r.is_good());
280 }
281
282 #[test]
283 fn test_icp_result_not_good_high_rmse() {
284 let r = IcpResult {
285 translation: (0.0, 0.0),
286 rmse: 5.0,
287 iterations: 50,
288 converged: true,
289 };
290 assert!(!r.is_good());
291 }
292
293 #[test]
294 fn test_icp_result_not_good_not_converged() {
295 let r = IcpResult {
296 translation: (0.0, 0.0),
297 rmse: 0.1,
298 iterations: 50,
299 converged: false,
300 };
301 assert!(!r.is_good());
302 }
303
304 #[test]
305 fn test_icp_align_empty_source() {
306 let target = vec![Point2D::new(1.0, 1.0)];
307 let cfg = IcpConfig::default();
308 let result = icp_align(&[], &target, &cfg);
309 assert!(!result.converged);
310 }
311
312 #[test]
313 fn test_icp_align_identical_sets() {
314 let pts = vec![
315 Point2D::new(0.0, 0.0),
316 Point2D::new(1.0, 0.0),
317 Point2D::new(0.0, 1.0),
318 ];
319 let cfg = IcpConfig::default();
320 let result = icp_align(&pts, &pts, &cfg);
321 assert!(result.translation.0.abs() < 0.1);
323 assert!(result.translation.1.abs() < 0.1);
324 }
325
326 #[test]
327 fn test_icp_align_pure_x_translation() {
328 let source = vec![
331 Point2D::new(0.0, 0.0),
332 Point2D::new(3.0, 0.0),
333 Point2D::new(0.0, 3.0),
334 Point2D::new(3.0, 3.0),
335 ];
336 let target = vec![
337 Point2D::new(1.0, 0.0),
338 Point2D::new(4.0, 0.0),
339 Point2D::new(1.0, 3.0),
340 Point2D::new(4.0, 3.0),
341 ];
342 let cfg = IcpConfig {
343 max_iterations: 100,
344 convergence_threshold: 1e-6,
345 };
346 let result = icp_align(&source, &target, &cfg);
347 assert!((result.translation.0 - 1.0).abs() < 0.1);
349 assert!(result.translation.1.abs() < 0.1);
350 }
351}