1use threecrate_core::{PointCloud, Result, Point3f, NormalPoint3f, Error};
4use rayon::prelude::*;
5use std::cmp::Ordering;
6
7const FPFH_BINS: usize = 11;
9
10pub const FPFH_DIM: usize = 33;
12
13#[derive(Debug, Clone)]
15pub struct FpfhConfig {
16 pub search_radius: f32,
18 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
31fn 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; let u = n_s; let v_unnorm = u.cross(&d); let v_mag = v_unnorm.magnitude();
52
53 if v_mag < 1e-10 {
54 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); let phi = u.dot(&d); let theta = w.dot(&n_t).atan2(u.dot(&n_t)); Some((alpha, phi, theta))
66}
67
68#[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
76fn 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 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
119fn 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 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
166pub 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 let neighbors_per_point: Vec<Vec<usize>> =
197 (0..n).map(|i| find_neighbors(points, i, config)).collect();
198
199 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 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 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
258pub 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 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 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}