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