Skip to main content

oximedia_align/
icp.rs

1//! Iterative Closest Point (ICP) algorithm for point cloud / contour alignment.
2//!
3//! Provides a centroid-based translation estimation approach to align a source
4//! point set to a target point set.
5
6#![allow(dead_code)]
7#![allow(clippy::cast_precision_loss)]
8
9/// A 2D point with single-precision coordinates.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct Point2D {
12    /// Horizontal coordinate.
13    pub x: f32,
14    /// Vertical coordinate.
15    pub y: f32,
16}
17
18impl Point2D {
19    /// Create a new 2D point.
20    #[must_use]
21    pub fn new(x: f32, y: f32) -> Self {
22        Self { x, y }
23    }
24
25    /// Euclidean distance from this point to `other`.
26    #[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    /// Return a new point translated by `(dx, dy)`.
34    #[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/// Find the closest point in `candidates` to `query`.
44///
45/// # Panics
46///
47/// Panics if `candidates` is empty.
48#[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/// Compute the centroid (mean position) of a slice of points.
62///
63/// Returns `Point2D { x: 0.0, y: 0.0 }` if the slice is empty.
64#[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/// Configuration for the ICP algorithm.
76#[derive(Debug, Clone, Copy)]
77pub struct IcpConfig {
78    /// Maximum number of ICP iterations.
79    pub max_iterations: u32,
80    /// Stop iterating once the RMSE improvement falls below this threshold.
81    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/// Result of an ICP alignment run.
94#[derive(Debug, Clone, Copy)]
95pub struct IcpResult {
96    /// Estimated translation `(tx, ty)` to align source onto target.
97    pub translation: (f32, f32),
98    /// Root-mean-square error after alignment.
99    pub rmse: f32,
100    /// Number of iterations performed.
101    pub iterations: u32,
102    /// Whether the algorithm converged within the allowed iterations.
103    pub converged: bool,
104}
105
106impl IcpResult {
107    /// Return `true` if the result is "good": converged and RMSE below 1.0.
108    #[must_use]
109    pub fn is_good(&self) -> bool {
110        self.converged && self.rmse < 1.0
111    }
112}
113
114/// Align `source` onto `target` using a centroid-based ICP approach.
115///
116/// At each iteration the algorithm:
117/// 1. Finds the closest target point for every source point.
118/// 2. Computes the centroid of matched source/target pairs.
119/// 3. Updates the translation estimate.
120/// 4. Checks for convergence.
121///
122/// Returns [`IcpResult`] with the best-found translation and quality metrics.
123#[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        // Translate source by current estimate
143        let translated: Vec<Point2D> = source.iter().map(|p| p.translate(tx, ty)).collect();
144
145        // For each translated source point find its closest target point
146        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        // Update translation by the centroid difference
167        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        // Check convergence
174        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        // When source == target, translation should be near zero
322        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        // Use a small translation (< half the point spacing) so nearest-neighbour
329        // assignments are correct from the first iteration.
330        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        // The estimated tx should be approximately 1.0
348        assert!((result.translation.0 - 1.0).abs() < 0.1);
349        assert!(result.translation.1.abs() < 0.1);
350    }
351}