1use threecrate_core::{PointCloud, Result, Point3f, NormalPoint3f, Vector3f, Error};
4use nalgebra::Matrix3;
5use rayon::prelude::*;
6use std::cmp::Ordering;
7
8const FPFH_BINS: usize = 11;
10
11pub const FPFH_DIM: usize = 33;
13
14#[derive(Debug, Clone)]
16pub struct FpfhConfig {
17 pub search_radius: f32,
19 pub k_neighbors: usize,
21}
22
23impl Default for FpfhConfig {
24 fn default() -> Self {
25 Self {
26 search_radius: 0.1,
27 k_neighbors: 10,
28 }
29 }
30}
31
32fn compute_pair_features(
36 p_s: Point3f,
37 n_s: threecrate_core::Vector3f,
38 p_t: Point3f,
39 n_t: threecrate_core::Vector3f,
40) -> Option<(f32, f32, f32)> {
41 let delta = p_t - p_s;
42 let dist = delta.magnitude();
43
44 if dist < 1e-10 {
45 return None;
46 }
47
48 let d = delta / dist; let u = n_s; let v_unnorm = u.cross(&d); let v_mag = v_unnorm.magnitude();
53
54 if v_mag < 1e-10 {
55 return None;
57 }
58
59 let v = v_unnorm / v_mag;
60 let w = u.cross(&v);
61
62 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))
67}
68
69#[inline]
71fn to_bin(value: f32, lo: f32, hi: f32, n_bins: usize) -> usize {
72 let normalised = (value - lo) / (hi - lo);
73 let bin = (normalised * n_bins as f32) as usize;
74 bin.min(n_bins - 1)
75}
76
77fn compute_spfh(
79 query_idx: usize,
80 points: &[NormalPoint3f],
81 neighbor_indices: &[usize],
82) -> [f32; FPFH_DIM] {
83 let mut histogram = [0.0f32; FPFH_DIM];
84 let mut count = 0usize;
85
86 let p_s = points[query_idx].position;
87 let n_s = points[query_idx].normal;
88
89 for &nb_idx in neighbor_indices {
90 if nb_idx == query_idx {
91 continue;
92 }
93 let p_t = points[nb_idx].position;
94 let n_t = points[nb_idx].normal;
95
96 if let Some((alpha, phi, theta)) = compute_pair_features(p_s, n_s, p_t, n_t) {
97 let bin_alpha = to_bin(alpha, -1.0, 1.0, FPFH_BINS);
98 let bin_phi = to_bin(phi, -1.0, 1.0, FPFH_BINS);
99 let bin_theta =
100 to_bin(theta, -std::f32::consts::PI, std::f32::consts::PI, FPFH_BINS);
101
102 histogram[bin_alpha] += 1.0;
103 histogram[FPFH_BINS + bin_phi] += 1.0;
104 histogram[2 * FPFH_BINS + bin_theta] += 1.0;
105 count += 1;
106 }
107 }
108
109 if count > 0 {
111 let scale = 1.0 / count as f32;
112 for h in histogram.iter_mut() {
113 *h *= scale;
114 }
115 }
116
117 histogram
118}
119
120fn find_neighbors(points: &[NormalPoint3f], query_idx: usize, config: &FpfhConfig) -> Vec<usize> {
125 let query = &points[query_idx].position;
126 let radius_sq = config.search_radius * config.search_radius;
127
128 let mut within_radius: Vec<(usize, f32)> = points
129 .iter()
130 .enumerate()
131 .filter_map(|(i, p)| {
132 if i == query_idx {
133 return None;
134 }
135 let d_sq = (p.position - query).magnitude_squared();
136 if d_sq <= radius_sq {
137 Some((i, d_sq))
138 } else {
139 None
140 }
141 })
142 .collect();
143
144 if within_radius.len() >= config.k_neighbors {
145 within_radius
146 .sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
147 return within_radius.into_iter().map(|(i, _)| i).collect();
148 }
149
150 let mut all: Vec<(usize, f32)> = points
152 .iter()
153 .enumerate()
154 .filter_map(|(i, p)| {
155 if i == query_idx {
156 return None;
157 }
158 Some((i, (p.position - query).magnitude_squared()))
159 })
160 .collect();
161
162 all.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
163 all.truncate(config.k_neighbors);
164 all.into_iter().map(|(i, _)| i).collect()
165}
166
167pub fn extract_fpfh_features_with_normals(
180 cloud: &PointCloud<NormalPoint3f>,
181 config: &FpfhConfig,
182) -> Result<Vec<[f32; FPFH_DIM]>> {
183 if cloud.is_empty() {
184 return Ok(Vec::new());
185 }
186
187 if config.search_radius <= 0.0 {
188 return Err(Error::InvalidData(
189 "search_radius must be positive".to_string(),
190 ));
191 }
192
193 let points = &cloud.points;
194 let n = points.len();
195
196 let neighbors_per_point: Vec<Vec<usize>> =
198 (0..n).map(|i| find_neighbors(points, i, config)).collect();
199
200 let spfh: Vec<[f32; FPFH_DIM]> = (0..n)
202 .into_par_iter()
203 .map(|i| compute_spfh(i, points, &neighbors_per_point[i]))
204 .collect();
205
206 let fpfh: Vec<[f32; FPFH_DIM]> = (0..n)
208 .into_par_iter()
209 .map(|i| {
210 let query_pos = &points[i].position;
211 let mut descriptor = spfh[i];
212
213 let neighbors = &neighbors_per_point[i];
214 if neighbors.is_empty() {
215 return descriptor;
216 }
217
218 let mut weight_sum = 0.0f32;
219 let mut weighted = [0.0f32; FPFH_DIM];
220
221 for &nb_idx in neighbors {
222 let dist = (points[nb_idx].position - query_pos).magnitude();
223 if dist < 1e-10 {
224 continue;
225 }
226 let w = 1.0 / dist;
227 weight_sum += w;
228 for (j, val) in spfh[nb_idx].iter().enumerate() {
229 weighted[j] += w * val;
230 }
231 }
232
233 if weight_sum > 0.0 {
234 let inv_w = 1.0 / weight_sum;
235 for j in 0..FPFH_DIM {
236 descriptor[j] += inv_w * weighted[j];
237 }
238
239 for part in 0..3usize {
241 let start = part * FPFH_BINS;
242 let end = start + FPFH_BINS;
243 let sum: f32 = descriptor[start..end].iter().sum();
244 if sum > 0.0 {
245 for h in &mut descriptor[start..end] {
246 *h /= sum;
247 }
248 }
249 }
250 }
251
252 descriptor
253 })
254 .collect();
255
256 Ok(fpfh)
257}
258
259pub fn extract_fpfh_features(cloud: &PointCloud<Point3f>) -> Result<Vec<Vec<f32>>> {
267 use crate::normals::estimate_normals;
268
269 if cloud.is_empty() {
270 return Ok(Vec::new());
271 }
272
273 if cloud.len() < 3 {
274 return Err(Error::InvalidData(
275 "At least 3 points are required to estimate normals for FPFH".to_string(),
276 ));
277 }
278
279 let cloud_with_normals = estimate_normals(cloud, 10)?;
280 let config = FpfhConfig::default();
281 let features = extract_fpfh_features_with_normals(&cloud_with_normals, &config)?;
282 Ok(features.into_iter().map(|f| f.to_vec()).collect())
283}
284
285const SHOT_N_AZIMUTH: usize = 8;
293const SHOT_N_ELEVATION: usize = 2;
295const SHOT_N_RADIAL: usize = 2;
297const SHOT_N_BINS: usize = 11;
299const SHOT_N_VOLUMES: usize = SHOT_N_AZIMUTH * SHOT_N_ELEVATION * SHOT_N_RADIAL;
301pub const SHOT_DIM: usize = SHOT_N_VOLUMES * SHOT_N_BINS;
303
304const USC_N_AZIMUTH: usize = 8;
306const USC_N_ELEVATION: usize = 4;
308const USC_N_RADIAL: usize = 4;
310pub const USC_DIM: usize = USC_N_AZIMUTH * USC_N_ELEVATION * USC_N_RADIAL;
312
313#[derive(Debug, Clone, Copy, PartialEq, Default)]
315pub enum ShotVariant {
316 #[default]
318 Standard,
319 UniqueShapeContext,
321}
322
323#[derive(Debug, Clone)]
325pub struct ShotConfig {
326 pub search_radius: f32,
328 pub k_neighbors: usize,
330 pub variant: ShotVariant,
332}
333
334impl Default for ShotConfig {
335 fn default() -> Self {
336 Self {
337 search_radius: 0.2,
338 k_neighbors: 10,
339 variant: ShotVariant::Standard,
340 }
341 }
342}
343
344fn find_shot_neighbors(
350 points: &[NormalPoint3f],
351 query_idx: usize,
352 config: &ShotConfig,
353) -> Vec<usize> {
354 let query = &points[query_idx].position;
355 let r_sq = config.search_radius * config.search_radius;
356
357 let mut within: Vec<(usize, f32)> = points
358 .iter()
359 .enumerate()
360 .filter_map(|(i, p)| {
361 if i == query_idx {
362 return None;
363 }
364 let d_sq = (p.position - query).magnitude_squared();
365 if d_sq <= r_sq { Some((i, d_sq)) } else { None }
366 })
367 .collect();
368
369 if within.len() >= config.k_neighbors {
370 within.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
371 return within.into_iter().map(|(i, _)| i).collect();
372 }
373
374 let mut all: Vec<(usize, f32)> = points
376 .iter()
377 .enumerate()
378 .filter_map(|(i, p)| {
379 if i == query_idx { None }
380 else { Some((i, (p.position - query).magnitude_squared())) }
381 })
382 .collect();
383 all.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
384 all.truncate(config.k_neighbors);
385 all.into_iter().map(|(i, _)| i).collect()
386}
387
388fn compute_shot_lrf(
394 query_pos: threecrate_core::Point3f,
395 query_normal: Vector3f,
396 neighbors: &[usize],
397 points: &[NormalPoint3f],
398 radius: f32,
399) -> (Vector3f, Vector3f, Vector3f) {
400 let mut z_axis = if query_normal.magnitude() > 1e-10 {
402 query_normal.normalize()
403 } else {
404 Vector3f::new(0.0, 0.0, 1.0)
405 };
406
407 let n_pos_z = neighbors.iter()
408 .filter(|&&i| z_axis.dot(&(points[i].position - query_pos)) >= 0.0)
409 .count();
410 if n_pos_z * 2 < neighbors.len() {
411 z_axis = -z_axis;
412 }
413
414 let mut cov = Matrix3::<f32>::zeros();
416 for &i in neighbors {
417 let dv = points[i].position - query_pos; let dist = dv.magnitude();
419 let w = (radius - dist).max(0.0);
420 cov += w * dv * dv.transpose();
421 }
422
423 let eig = cov.symmetric_eigen();
424 let (max_idx, _) = eig.eigenvalues.iter().enumerate()
425 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(Ordering::Equal))
426 .unwrap_or((0, &0.0));
427 let col = eig.eigenvectors.column(max_idx);
428 let mut x_axis = Vector3f::new(col[0], col[1], col[2]);
429
430 let n_pos_x = neighbors.iter()
432 .filter(|&&i| x_axis.dot(&(points[i].position - query_pos)) >= 0.0)
433 .count();
434 if n_pos_x * 2 < neighbors.len() {
435 x_axis = -x_axis;
436 }
437
438 let x_proj = x_axis - z_axis * z_axis.dot(&x_axis);
440 let x_axis = if x_proj.magnitude() > 1e-10 {
441 x_proj.normalize()
442 } else {
443 let c = Vector3f::new(1.0, 0.0, 0.0);
444 let p = c - z_axis * z_axis.dot(&c);
445 if p.magnitude() > 1e-10 {
446 p.normalize()
447 } else {
448 let c2 = Vector3f::new(0.0, 1.0, 0.0);
449 (c2 - z_axis * z_axis.dot(&c2)).normalize()
450 }
451 };
452
453 let y_axis = z_axis.cross(&x_axis);
454 (x_axis, y_axis, z_axis)
455}
456
457fn compute_shot_impl(
459 query_idx: usize,
460 points: &[NormalPoint3f],
461 neighbors: &[usize],
462 radius: f32,
463) -> [f32; SHOT_DIM] {
464 let mut desc = [0.0f32; SHOT_DIM];
465 if neighbors.is_empty() {
466 return desc;
467 }
468
469 let qp = points[query_idx].position;
470 let (x_axis, y_axis, z_axis) =
471 compute_shot_lrf(qp, points[query_idx].normal, neighbors, points, radius);
472
473 let mut vol_counts = [0u32; SHOT_N_VOLUMES];
474
475 for &ni in neighbors {
476 if ni == query_idx { continue; }
477 let dv = points[ni].position - qp;
478 let dist = dv.magnitude();
479 if dist < 1e-10 || dist > radius { continue; }
480
481 let lx = x_axis.dot(&dv);
482 let ly = y_axis.dot(&dv);
483 let lz = z_axis.dot(&dv);
484
485 let r_bin = if dist <= radius * 0.5 { 0 } else { 1 };
486 let e_bin = if lz < 0.0 { 0 } else { 1 };
487 let az_norm = (ly.atan2(lx) + std::f32::consts::PI) / (2.0 * std::f32::consts::PI);
488 let a_bin = ((az_norm * SHOT_N_AZIMUTH as f32) as usize).min(SHOT_N_AZIMUTH - 1);
489
490 let vol = r_bin * (SHOT_N_ELEVATION * SHOT_N_AZIMUTH) + e_bin * SHOT_N_AZIMUTH + a_bin;
491
492 let cos_theta = z_axis.dot(&points[ni].normal).clamp(-1.0, 1.0);
493 let n_bin = to_bin(cos_theta, -1.0, 1.0, SHOT_N_BINS);
494
495 desc[vol * SHOT_N_BINS + n_bin] += 1.0;
496 vol_counts[vol] += 1;
497 }
498
499 for vol in 0..SHOT_N_VOLUMES {
501 let c = vol_counts[vol] as f32;
502 if c > 0.0 {
503 for v in &mut desc[vol * SHOT_N_BINS..(vol + 1) * SHOT_N_BINS] {
504 *v /= c;
505 }
506 }
507 }
508
509 let norm: f32 = desc.iter().map(|&v| v * v).sum::<f32>().sqrt();
511 if norm > 1e-10 {
512 for v in &mut desc { *v /= norm; }
513 }
514 desc
515}
516
517fn compute_usc_impl(
522 query_idx: usize,
523 points: &[NormalPoint3f],
524 neighbors: &[usize],
525 radius: f32,
526) -> [f32; USC_DIM] {
527 let mut desc = [0.0f32; USC_DIM];
528 if neighbors.is_empty() {
529 return desc;
530 }
531
532 let qp = points[query_idx].position;
533 let (x_axis, y_axis, z_axis) =
534 compute_shot_lrf(qp, points[query_idx].normal, neighbors, points, radius);
535
536 let mut total = 0usize;
537 for &ni in neighbors {
538 if ni == query_idx { continue; }
539 let dv = points[ni].position - qp;
540 let dist = dv.magnitude();
541 if dist < 1e-10 || dist > radius { continue; }
542
543 let lx = x_axis.dot(&dv);
544 let ly = y_axis.dot(&dv);
545 let lz = z_axis.dot(&dv);
546
547 let az_norm = (ly.atan2(lx) + std::f32::consts::PI) / (2.0 * std::f32::consts::PI);
549 let a_bin = ((az_norm * USC_N_AZIMUTH as f32) as usize).min(USC_N_AZIMUTH - 1);
550
551 let cos_el = (lz / dist).clamp(-1.0, 1.0);
553 let e_bin = to_bin(cos_el, -1.0, 1.0, USC_N_ELEVATION);
554
555 let r_bin = ((dist / radius * USC_N_RADIAL as f32) as usize).min(USC_N_RADIAL - 1);
557
558 let bin = a_bin * (USC_N_ELEVATION * USC_N_RADIAL) + e_bin * USC_N_RADIAL + r_bin;
559 desc[bin] += 1.0;
560 total += 1;
561 }
562
563 if total > 0 {
564 let scale = 1.0 / total as f32;
565 for v in &mut desc { *v *= scale; }
566 }
567
568 let norm: f32 = desc.iter().map(|&v| v * v).sum::<f32>().sqrt();
570 if norm > 1e-10 {
571 for v in &mut desc { *v /= norm; }
572 }
573 desc
574}
575
576pub fn extract_shot_features_with_normals(
595 cloud: &PointCloud<NormalPoint3f>,
596 config: &ShotConfig,
597) -> Result<Vec<Vec<f32>>> {
598 if cloud.is_empty() {
599 return Ok(Vec::new());
600 }
601 if config.search_radius <= 0.0 {
602 return Err(Error::InvalidData("search_radius must be positive".into()));
603 }
604
605 let points = &cloud.points;
606 let n = points.len();
607
608 let neighbors: Vec<Vec<usize>> =
610 (0..n).map(|i| find_shot_neighbors(points, i, config)).collect();
611
612 let descriptors: Vec<Vec<f32>> = (0..n)
614 .into_par_iter()
615 .map(|i| match config.variant {
616 ShotVariant::Standard =>
617 compute_shot_impl(i, points, &neighbors[i], config.search_radius).to_vec(),
618 ShotVariant::UniqueShapeContext =>
619 compute_usc_impl(i, points, &neighbors[i], config.search_radius).to_vec(),
620 })
621 .collect();
622
623 Ok(descriptors)
624}
625
626#[cfg(test)]
627mod shot_tests {
628 use super::*;
629 use threecrate_core::{NormalPoint3f, Vector3f, Point3f, PointCloud};
630
631 fn make_plane(n: usize) -> PointCloud<NormalPoint3f> {
632 let side = (n as f64).sqrt().ceil() as usize;
633 let step = 1.0 / side as f32;
634 let mut cloud = PointCloud::new();
635 'outer: for i in 0..side {
636 for j in 0..side {
637 if cloud.len() == n { break 'outer; }
638 cloud.push(NormalPoint3f {
639 position: Point3f::new(i as f32 * step, j as f32 * step, 0.0),
640 normal: Vector3f::new(0.0, 0.0, 1.0),
641 });
642 }
643 }
644 cloud
645 }
646
647 #[test]
648 fn test_shot_empty_cloud() {
649 let cloud = PointCloud::<NormalPoint3f>::new();
650 let result = extract_shot_features_with_normals(&cloud, &ShotConfig::default()).unwrap();
651 assert!(result.is_empty());
652 }
653
654 #[test]
655 fn test_shot_descriptor_dim() {
656 let cloud = make_plane(25);
657 let config = ShotConfig { search_radius: 0.5, k_neighbors: 5, variant: ShotVariant::Standard };
658 let result = extract_shot_features_with_normals(&cloud, &config).unwrap();
659 assert_eq!(result.len(), 25);
660 for d in &result { assert_eq!(d.len(), SHOT_DIM); }
661 }
662
663 #[test]
664 fn test_usc_descriptor_dim() {
665 let cloud = make_plane(25);
666 let config = ShotConfig { search_radius: 0.5, k_neighbors: 5, variant: ShotVariant::UniqueShapeContext };
667 let result = extract_shot_features_with_normals(&cloud, &config).unwrap();
668 assert_eq!(result.len(), 25);
669 for d in &result { assert_eq!(d.len(), USC_DIM); }
670 }
671
672 #[test]
673 fn test_shot_non_negative() {
674 let cloud = make_plane(25);
675 let config = ShotConfig { search_radius: 0.5, k_neighbors: 5, variant: ShotVariant::Standard };
676 let result = extract_shot_features_with_normals(&cloud, &config).unwrap();
677 for d in &result {
678 for &v in d { assert!(v >= 0.0, "negative value: {v}"); }
679 }
680 }
681
682 #[test]
683 fn test_shot_l2_normalized_or_zero() {
684 let cloud = make_plane(25);
685 let config = ShotConfig { search_radius: 0.5, k_neighbors: 5, variant: ShotVariant::Standard };
686 let result = extract_shot_features_with_normals(&cloud, &config).unwrap();
687 for d in &result {
688 let norm: f32 = d.iter().map(|&v| v * v).sum::<f32>().sqrt();
689 assert!(norm < 1e-6 || (norm - 1.0).abs() < 1e-4, "norm={norm}");
690 }
691 }
692
693 #[test]
694 fn test_shot_invalid_radius() {
695 let cloud = make_plane(9);
696 let config = ShotConfig { search_radius: -0.1, k_neighbors: 5, variant: ShotVariant::Standard };
697 assert!(extract_shot_features_with_normals(&cloud, &config).is_err());
698 }
699
700 #[test]
701 fn test_shot_deterministic() {
702 let cloud = make_plane(25);
703 let config = ShotConfig { search_radius: 0.5, k_neighbors: 5, variant: ShotVariant::Standard };
704 let r1 = extract_shot_features_with_normals(&cloud, &config).unwrap();
705 let r2 = extract_shot_features_with_normals(&cloud, &config).unwrap();
706 for (d1, d2) in r1.iter().zip(&r2) {
707 for (&v1, &v2) in d1.iter().zip(d2) {
708 assert!((v1 - v2).abs() < 1e-6);
709 }
710 }
711 }
712
713 #[test]
714 fn test_shot_plane_vs_sphere_differ() {
715 let plane = make_plane(25);
716 let config = ShotConfig { search_radius: 0.6, k_neighbors: 8, variant: ShotVariant::Standard };
717 let plane_descs = extract_shot_features_with_normals(&plane, &config).unwrap();
718
719 let mut sphere = PointCloud::<NormalPoint3f>::new();
720 for i in 0..5usize {
721 for j in 0..5usize {
722 let theta = std::f32::consts::PI * i as f32 / 4.0;
723 let phi = 2.0 * std::f32::consts::PI * j as f32 / 5.0;
724 let x = theta.sin() * phi.cos();
725 let y = theta.sin() * phi.sin();
726 let z = theta.cos();
727 sphere.push(NormalPoint3f {
728 position: Point3f::new(x, y, z),
729 normal: Vector3f::new(x, y, z),
730 });
731 }
732 }
733 let sphere_descs = extract_shot_features_with_normals(&sphere, &config).unwrap();
734
735 let any_different = plane_descs.iter().any(|pd| {
736 sphere_descs.iter().any(|sd| {
737 pd.iter().zip(sd).map(|(a, b)| (a - b).abs()).sum::<f32>() > 0.05
738 })
739 });
740 assert!(any_different, "plane and sphere descriptors should differ");
741 }
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747 use threecrate_core::{NormalPoint3f, Vector3f};
748
749 fn make_plane_cloud(n: usize) -> PointCloud<NormalPoint3f> {
751 let side = (n as f64).sqrt().ceil() as usize;
752 let step = 1.0 / side as f32;
753 let mut cloud = PointCloud::new();
754 'outer: for i in 0..side {
755 for j in 0..side {
756 if cloud.len() == n {
757 break 'outer;
758 }
759 cloud.push(NormalPoint3f {
760 position: Point3f::new(i as f32 * step, j as f32 * step, 0.0),
761 normal: Vector3f::new(0.0, 0.0, 1.0),
762 });
763 }
764 }
765 cloud
766 }
767
768 #[test]
769 fn test_fpfh_empty_cloud() {
770 let cloud = PointCloud::<NormalPoint3f>::new();
771 let config = FpfhConfig::default();
772 let result = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
773 assert!(result.is_empty());
774 }
775
776 #[test]
777 fn test_fpfh_descriptor_dimension() {
778 let cloud = make_plane_cloud(25);
779 let config = FpfhConfig {
780 search_radius: 0.5,
781 k_neighbors: 5,
782 };
783 let result = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
784 assert_eq!(result.len(), cloud.len());
785 for desc in &result {
786 assert_eq!(desc.len(), FPFH_DIM);
787 }
788 }
789
790 #[test]
791 fn test_fpfh_descriptor_non_negative() {
792 let cloud = make_plane_cloud(25);
793 let config = FpfhConfig {
794 search_radius: 0.5,
795 k_neighbors: 5,
796 };
797 let result = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
798 for desc in &result {
799 for &v in desc.iter() {
800 assert!(v >= 0.0, "Descriptor value must be non-negative, got {}", v);
801 }
802 }
803 }
804
805 #[test]
806 fn test_fpfh_sub_histograms_normalised() {
807 let cloud = make_plane_cloud(36);
808 let config = FpfhConfig {
809 search_radius: 0.5,
810 k_neighbors: 8,
811 };
812 let result = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
813 for desc in &result {
814 for part in 0..3 {
815 let start = part * FPFH_BINS;
816 let end = start + FPFH_BINS;
817 let sum: f32 = desc[start..end].iter().sum();
818 assert!(
819 sum < 1e-6 || (sum - 1.0).abs() < 1e-4,
820 "Sub-histogram {} sum = {}, expected 0 or ~1.0",
821 part,
822 sum
823 );
824 }
825 }
826 }
827
828 #[test]
829 fn test_fpfh_identical_clouds_same_descriptors() {
830 let cloud = make_plane_cloud(25);
831 let config = FpfhConfig {
832 search_radius: 0.5,
833 k_neighbors: 5,
834 };
835 let r1 = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
836 let r2 = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
837 for (d1, d2) in r1.iter().zip(r2.iter()) {
838 for (&v1, &v2) in d1.iter().zip(d2.iter()) {
839 assert!((v1 - v2).abs() < 1e-6);
840 }
841 }
842 }
843
844 #[test]
845 fn test_fpfh_plane_vs_sphere_differ() {
846 let plane = make_plane_cloud(25);
847 let config = FpfhConfig {
848 search_radius: 0.5,
849 k_neighbors: 5,
850 };
851 let plane_desc = extract_fpfh_features_with_normals(&plane, &config).unwrap();
852
853 let mut sphere = PointCloud::<NormalPoint3f>::new();
855 let steps = 5usize;
856 for i in 0..steps {
857 for j in 0..steps {
858 let theta = std::f32::consts::PI * i as f32 / (steps - 1) as f32;
859 let phi = 2.0 * std::f32::consts::PI * j as f32 / steps as f32;
860 let x = theta.sin() * phi.cos();
861 let y = theta.sin() * phi.sin();
862 let z = theta.cos();
863 sphere.push(NormalPoint3f {
864 position: Point3f::new(x, y, z),
865 normal: Vector3f::new(x, y, z),
866 });
867 }
868 }
869
870 let sphere_desc = extract_fpfh_features_with_normals(&sphere, &config).unwrap();
871
872 let mut any_different = false;
873 'outer: for pd in &plane_desc {
874 for sd in &sphere_desc {
875 let diff: f32 = pd.iter().zip(sd.iter()).map(|(a, b)| (a - b).abs()).sum();
876 if diff > 0.1 {
877 any_different = true;
878 break 'outer;
879 }
880 }
881 }
882 assert!(
883 any_different,
884 "Plane and sphere descriptors should differ significantly"
885 );
886 }
887
888 #[test]
889 fn test_fpfh_from_xyz() {
890 let mut cloud = PointCloud::<Point3f>::new();
891 for i in 0..5 {
892 for j in 0..5 {
893 cloud.push(Point3f::new(i as f32 * 0.1, j as f32 * 0.1, 0.0));
894 }
895 }
896 let result = extract_fpfh_features(&cloud).unwrap();
897 assert_eq!(result.len(), cloud.len());
898 for desc in &result {
899 assert_eq!(desc.len(), FPFH_DIM);
900 }
901 }
902
903 #[test]
904 fn test_fpfh_invalid_radius() {
905 let cloud = make_plane_cloud(9);
906 let config = FpfhConfig {
907 search_radius: -1.0,
908 k_neighbors: 5,
909 };
910 let result = extract_fpfh_features_with_normals(&cloud, &config);
911 assert!(result.is_err());
912 }
913
914 #[test]
915 fn test_fpfh_single_point_all_zero() {
916 let mut cloud = PointCloud::<NormalPoint3f>::new();
917 cloud.push(NormalPoint3f {
918 position: Point3f::new(0.0, 0.0, 0.0),
919 normal: Vector3f::new(0.0, 0.0, 1.0),
920 });
921 let config = FpfhConfig {
922 search_radius: 1.0,
923 k_neighbors: 1,
924 };
925 let result = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
926 assert_eq!(result.len(), 1);
927 assert!(
928 result[0].iter().all(|&v| v == 0.0),
929 "Single-point descriptor should be all zeros"
930 );
931 }
932}