Skip to main content

threecrate_algorithms/
features.rs

1//! Feature extraction algorithms
2
3use threecrate_core::{PointCloud, Result, Point3f, NormalPoint3f, Error};
4use rayon::prelude::*;
5use std::cmp::Ordering;
6
7/// Number of bins per angular feature sub-histogram
8const FPFH_BINS: usize = 11;
9
10/// Total FPFH descriptor dimensionality (3 sub-histograms × 11 bins)
11pub const FPFH_DIM: usize = 33;
12
13/// Configuration for FPFH feature extraction
14#[derive(Debug, Clone)]
15pub struct FpfhConfig {
16    /// Radius for neighbor search. Points within this distance contribute to the descriptor.
17    pub search_radius: f32,
18    /// Fallback number of nearest neighbors when radius search yields fewer than this many points.
19    pub k_neighbors: usize,
20}
21
22impl Default for FpfhConfig {
23    fn default() -> Self {
24        Self {
25            search_radius: 0.1,
26            k_neighbors: 10,
27        }
28    }
29}
30
31/// Compute the Darboux frame angular features (α, φ, θ) for a point pair.
32///
33/// Based on Rusu et al. (2009) "Fast Point Feature Histograms (FPFH) for 3D Registration"
34fn compute_pair_features(
35    p_s: Point3f,
36    n_s: threecrate_core::Vector3f,
37    p_t: Point3f,
38    n_t: threecrate_core::Vector3f,
39) -> Option<(f32, f32, f32)> {
40    let delta = p_t - p_s;
41    let dist = delta.magnitude();
42
43    if dist < 1e-10 {
44        return None;
45    }
46
47    let d = delta / dist; // unit direction vector from p_s to p_t
48
49    let u = n_s; // u = n_s
50    let v_unnorm = u.cross(&d); // v = u × d
51    let v_mag = v_unnorm.magnitude();
52
53    if v_mag < 1e-10 {
54        // n_s and d are parallel — degenerate pair, skip
55        return None;
56    }
57
58    let v = v_unnorm / v_mag;
59    let w = u.cross(&v);
60
61    let alpha = v.dot(&n_t); // α = v · n_t  ∈ [-1, 1]
62    let phi = u.dot(&d); // φ = u · d    ∈ [-1, 1]
63    let theta = w.dot(&n_t).atan2(u.dot(&n_t)); // θ = atan2(w·n_t, u·n_t) ∈ [-π, π]
64
65    Some((alpha, phi, theta))
66}
67
68/// Map a value in `[lo, hi]` to a bin index in `[0, n_bins)`.
69#[inline]
70fn to_bin(value: f32, lo: f32, hi: f32, n_bins: usize) -> usize {
71    let normalised = (value - lo) / (hi - lo);
72    let bin = (normalised * n_bins as f32) as usize;
73    bin.min(n_bins - 1)
74}
75
76/// Compute the SPFH (Simplified Point Feature Histogram) for a single point.
77fn compute_spfh(
78    query_idx: usize,
79    points: &[NormalPoint3f],
80    neighbor_indices: &[usize],
81) -> [f32; FPFH_DIM] {
82    let mut histogram = [0.0f32; FPFH_DIM];
83    let mut count = 0usize;
84
85    let p_s = points[query_idx].position;
86    let n_s = points[query_idx].normal;
87
88    for &nb_idx in neighbor_indices {
89        if nb_idx == query_idx {
90            continue;
91        }
92        let p_t = points[nb_idx].position;
93        let n_t = points[nb_idx].normal;
94
95        if let Some((alpha, phi, theta)) = compute_pair_features(p_s, n_s, p_t, n_t) {
96            let bin_alpha = to_bin(alpha, -1.0, 1.0, FPFH_BINS);
97            let bin_phi = to_bin(phi, -1.0, 1.0, FPFH_BINS);
98            let bin_theta =
99                to_bin(theta, -std::f32::consts::PI, std::f32::consts::PI, FPFH_BINS);
100
101            histogram[bin_alpha] += 1.0;
102            histogram[FPFH_BINS + bin_phi] += 1.0;
103            histogram[2 * FPFH_BINS + bin_theta] += 1.0;
104            count += 1;
105        }
106    }
107
108    // Normalise each sub-histogram by the number of valid pairs
109    if count > 0 {
110        let scale = 1.0 / count as f32;
111        for h in histogram.iter_mut() {
112            *h *= scale;
113        }
114    }
115
116    histogram
117}
118
119/// Find indices of neighbors for a given query point.
120///
121/// First tries radius-based search. Falls back to k-NN when radius yields
122/// fewer than `config.k_neighbors` points.
123fn find_neighbors(points: &[NormalPoint3f], query_idx: usize, config: &FpfhConfig) -> Vec<usize> {
124    let query = &points[query_idx].position;
125    let radius_sq = config.search_radius * config.search_radius;
126
127    let mut within_radius: Vec<(usize, f32)> = points
128        .iter()
129        .enumerate()
130        .filter_map(|(i, p)| {
131            if i == query_idx {
132                return None;
133            }
134            let d_sq = (p.position - query).magnitude_squared();
135            if d_sq <= radius_sq {
136                Some((i, d_sq))
137            } else {
138                None
139            }
140        })
141        .collect();
142
143    if within_radius.len() >= config.k_neighbors {
144        within_radius
145            .sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
146        return within_radius.into_iter().map(|(i, _)| i).collect();
147    }
148
149    // Fallback: k-NN over all points
150    let mut all: Vec<(usize, f32)> = points
151        .iter()
152        .enumerate()
153        .filter_map(|(i, p)| {
154            if i == query_idx {
155                return None;
156            }
157            Some((i, (p.position - query).magnitude_squared()))
158        })
159        .collect();
160
161    all.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
162    all.truncate(config.k_neighbors);
163    all.into_iter().map(|(i, _)| i).collect()
164}
165
166/// Extract FPFH (Fast Point Feature Histograms) features from a point cloud
167/// with pre-computed normals.
168///
169/// Returns a 33-dimensional descriptor `[f32; 33]` per point consisting of
170/// three 11-bin sub-histograms for the angular features α, φ and θ between
171/// each point and its neighbours.
172///
173/// Reference: Rusu et al. (2009) "Fast Point Feature Histograms (FPFH) for 3D Registration"
174///
175/// # Arguments
176/// * `cloud`  - Point cloud with pre-computed unit normals.
177/// * `config` - Search radius and k-NN fallback configuration.
178pub fn extract_fpfh_features_with_normals(
179    cloud: &PointCloud<NormalPoint3f>,
180    config: &FpfhConfig,
181) -> Result<Vec<[f32; FPFH_DIM]>> {
182    if cloud.is_empty() {
183        return Ok(Vec::new());
184    }
185
186    if config.search_radius <= 0.0 {
187        return Err(Error::InvalidData(
188            "search_radius must be positive".to_string(),
189        ));
190    }
191
192    let points = &cloud.points;
193    let n = points.len();
194
195    // Step 1: collect neighbours for every point
196    let neighbors_per_point: Vec<Vec<usize>> =
197        (0..n).map(|i| find_neighbors(points, i, config)).collect();
198
199    // Step 2: compute SPFH for every point in parallel
200    let spfh: Vec<[f32; FPFH_DIM]> = (0..n)
201        .into_par_iter()
202        .map(|i| compute_spfh(i, points, &neighbors_per_point[i]))
203        .collect();
204
205    // Step 3: FPFH(p) = SPFH(p) + (1/k) Σ (1/dist_i · SPFH(p_i))
206    let fpfh: Vec<[f32; FPFH_DIM]> = (0..n)
207        .into_par_iter()
208        .map(|i| {
209            let query_pos = &points[i].position;
210            let mut descriptor = spfh[i];
211
212            let neighbors = &neighbors_per_point[i];
213            if neighbors.is_empty() {
214                return descriptor;
215            }
216
217            let mut weight_sum = 0.0f32;
218            let mut weighted = [0.0f32; FPFH_DIM];
219
220            for &nb_idx in neighbors {
221                let dist = (points[nb_idx].position - query_pos).magnitude();
222                if dist < 1e-10 {
223                    continue;
224                }
225                let w = 1.0 / dist;
226                weight_sum += w;
227                for (j, val) in spfh[nb_idx].iter().enumerate() {
228                    weighted[j] += w * val;
229                }
230            }
231
232            if weight_sum > 0.0 {
233                let inv_w = 1.0 / weight_sum;
234                for j in 0..FPFH_DIM {
235                    descriptor[j] += inv_w * weighted[j];
236                }
237
238                // Renormalise each 11-bin sub-histogram so values sum to 1
239                for part in 0..3usize {
240                    let start = part * FPFH_BINS;
241                    let end = start + FPFH_BINS;
242                    let sum: f32 = descriptor[start..end].iter().sum();
243                    if sum > 0.0 {
244                        for h in &mut descriptor[start..end] {
245                            *h /= sum;
246                        }
247                    }
248                }
249            }
250
251            descriptor
252        })
253        .collect();
254
255    Ok(fpfh)
256}
257
258/// Extract FPFH features from a plain point cloud, estimating normals first.
259///
260/// Normals are estimated using k = 10 nearest neighbours. For better control,
261/// pre-compute normals with [`crate::normals::estimate_normals`] and call
262/// [`extract_fpfh_features_with_normals`] directly.
263///
264/// Returns a 33-element `Vec<f32>` descriptor per point.
265pub fn extract_fpfh_features(cloud: &PointCloud<Point3f>) -> Result<Vec<Vec<f32>>> {
266    use crate::normals::estimate_normals;
267
268    if cloud.is_empty() {
269        return Ok(Vec::new());
270    }
271
272    if cloud.len() < 3 {
273        return Err(Error::InvalidData(
274            "At least 3 points are required to estimate normals for FPFH".to_string(),
275        ));
276    }
277
278    let cloud_with_normals = estimate_normals(cloud, 10)?;
279    let config = FpfhConfig::default();
280    let features = extract_fpfh_features_with_normals(&cloud_with_normals, &config)?;
281    Ok(features.into_iter().map(|f| f.to_vec()).collect())
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use threecrate_core::{NormalPoint3f, Vector3f};
288
289    /// Build a flat XY-plane point cloud with upward normals.
290    fn make_plane_cloud(n: usize) -> PointCloud<NormalPoint3f> {
291        let side = (n as f64).sqrt().ceil() as usize;
292        let step = 1.0 / side as f32;
293        let mut cloud = PointCloud::new();
294        'outer: for i in 0..side {
295            for j in 0..side {
296                if cloud.len() == n {
297                    break 'outer;
298                }
299                cloud.push(NormalPoint3f {
300                    position: Point3f::new(i as f32 * step, j as f32 * step, 0.0),
301                    normal: Vector3f::new(0.0, 0.0, 1.0),
302                });
303            }
304        }
305        cloud
306    }
307
308    #[test]
309    fn test_fpfh_empty_cloud() {
310        let cloud = PointCloud::<NormalPoint3f>::new();
311        let config = FpfhConfig::default();
312        let result = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
313        assert!(result.is_empty());
314    }
315
316    #[test]
317    fn test_fpfh_descriptor_dimension() {
318        let cloud = make_plane_cloud(25);
319        let config = FpfhConfig {
320            search_radius: 0.5,
321            k_neighbors: 5,
322        };
323        let result = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
324        assert_eq!(result.len(), cloud.len());
325        for desc in &result {
326            assert_eq!(desc.len(), FPFH_DIM);
327        }
328    }
329
330    #[test]
331    fn test_fpfh_descriptor_non_negative() {
332        let cloud = make_plane_cloud(25);
333        let config = FpfhConfig {
334            search_radius: 0.5,
335            k_neighbors: 5,
336        };
337        let result = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
338        for desc in &result {
339            for &v in desc.iter() {
340                assert!(v >= 0.0, "Descriptor value must be non-negative, got {}", v);
341            }
342        }
343    }
344
345    #[test]
346    fn test_fpfh_sub_histograms_normalised() {
347        let cloud = make_plane_cloud(36);
348        let config = FpfhConfig {
349            search_radius: 0.5,
350            k_neighbors: 8,
351        };
352        let result = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
353        for desc in &result {
354            for part in 0..3 {
355                let start = part * FPFH_BINS;
356                let end = start + FPFH_BINS;
357                let sum: f32 = desc[start..end].iter().sum();
358                assert!(
359                    sum < 1e-6 || (sum - 1.0).abs() < 1e-4,
360                    "Sub-histogram {} sum = {}, expected 0 or ~1.0",
361                    part,
362                    sum
363                );
364            }
365        }
366    }
367
368    #[test]
369    fn test_fpfh_identical_clouds_same_descriptors() {
370        let cloud = make_plane_cloud(25);
371        let config = FpfhConfig {
372            search_radius: 0.5,
373            k_neighbors: 5,
374        };
375        let r1 = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
376        let r2 = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
377        for (d1, d2) in r1.iter().zip(r2.iter()) {
378            for (&v1, &v2) in d1.iter().zip(d2.iter()) {
379                assert!((v1 - v2).abs() < 1e-6);
380            }
381        }
382    }
383
384    #[test]
385    fn test_fpfh_plane_vs_sphere_differ() {
386        let plane = make_plane_cloud(25);
387        let config = FpfhConfig {
388            search_radius: 0.5,
389            k_neighbors: 5,
390        };
391        let plane_desc = extract_fpfh_features_with_normals(&plane, &config).unwrap();
392
393        // Build a spherical cloud
394        let mut sphere = PointCloud::<NormalPoint3f>::new();
395        let steps = 5usize;
396        for i in 0..steps {
397            for j in 0..steps {
398                let theta = std::f32::consts::PI * i as f32 / (steps - 1) as f32;
399                let phi = 2.0 * std::f32::consts::PI * j as f32 / steps as f32;
400                let x = theta.sin() * phi.cos();
401                let y = theta.sin() * phi.sin();
402                let z = theta.cos();
403                sphere.push(NormalPoint3f {
404                    position: Point3f::new(x, y, z),
405                    normal: Vector3f::new(x, y, z),
406                });
407            }
408        }
409
410        let sphere_desc = extract_fpfh_features_with_normals(&sphere, &config).unwrap();
411
412        let mut any_different = false;
413        'outer: for pd in &plane_desc {
414            for sd in &sphere_desc {
415                let diff: f32 = pd.iter().zip(sd.iter()).map(|(a, b)| (a - b).abs()).sum();
416                if diff > 0.1 {
417                    any_different = true;
418                    break 'outer;
419                }
420            }
421        }
422        assert!(
423            any_different,
424            "Plane and sphere descriptors should differ significantly"
425        );
426    }
427
428    #[test]
429    fn test_fpfh_from_xyz() {
430        let mut cloud = PointCloud::<Point3f>::new();
431        for i in 0..5 {
432            for j in 0..5 {
433                cloud.push(Point3f::new(i as f32 * 0.1, j as f32 * 0.1, 0.0));
434            }
435        }
436        let result = extract_fpfh_features(&cloud).unwrap();
437        assert_eq!(result.len(), cloud.len());
438        for desc in &result {
439            assert_eq!(desc.len(), FPFH_DIM);
440        }
441    }
442
443    #[test]
444    fn test_fpfh_invalid_radius() {
445        let cloud = make_plane_cloud(9);
446        let config = FpfhConfig {
447            search_radius: -1.0,
448            k_neighbors: 5,
449        };
450        let result = extract_fpfh_features_with_normals(&cloud, &config);
451        assert!(result.is_err());
452    }
453
454    #[test]
455    fn test_fpfh_single_point_all_zero() {
456        let mut cloud = PointCloud::<NormalPoint3f>::new();
457        cloud.push(NormalPoint3f {
458            position: Point3f::new(0.0, 0.0, 0.0),
459            normal: Vector3f::new(0.0, 0.0, 1.0),
460        });
461        let config = FpfhConfig {
462            search_radius: 1.0,
463            k_neighbors: 1,
464        };
465        let result = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
466        assert_eq!(result.len(), 1);
467        assert!(
468            result[0].iter().all(|&v| v == 0.0),
469            "Single-point descriptor should be all zeros"
470        );
471    }
472}