spatialrust_segmentation/
multi_plane.rs1use spatialrust_core::{HasPositions3, PointCloud, SpatialError, SpatialResult};
9
10use crate::cloud::{extract_indices, with_labels};
11use crate::plane::{PlaneModel, RansacPlaneConfig, RansacPlaneSegmenter};
12use crate::segmenter::PointCloudSegmenter;
13
14#[derive(Clone, Copy, Debug, PartialEq)]
16pub struct MultiPlaneConfig {
17 pub max_planes: usize,
19 pub distance_threshold: f32,
21 pub min_inliers: usize,
23 pub max_iterations: usize,
25 pub seed: u64,
27}
28
29impl Default for MultiPlaneConfig {
30 fn default() -> Self {
31 Self {
32 max_planes: 4,
33 distance_threshold: 0.02,
34 min_inliers: 100,
35 max_iterations: 1_000,
36 seed: 42,
37 }
38 }
39}
40
41impl MultiPlaneConfig {
42 #[must_use]
44 pub const fn new(max_planes: usize, distance_threshold: f32) -> Self {
45 Self { max_planes, distance_threshold, min_inliers: 100, max_iterations: 1_000, seed: 42 }
46 }
47}
48
49#[derive(Clone, Debug, PartialEq)]
51pub struct MultiPlaneSegmentation {
52 pub planes: Vec<PlaneModel>,
54 pub labeled: PointCloud,
57 pub plane_sizes: Vec<usize>,
59}
60
61#[derive(Clone, Copy, Debug, PartialEq)]
63pub struct MultiPlaneSegmenter {
64 config: MultiPlaneConfig,
65}
66
67impl MultiPlaneSegmenter {
68 #[must_use]
70 pub const fn new(config: MultiPlaneConfig) -> Self {
71 Self { config }
72 }
73
74 #[must_use]
76 pub const fn config(&self) -> MultiPlaneConfig {
77 self.config
78 }
79
80 pub fn segment(&self, input: &PointCloud) -> SpatialResult<MultiPlaneSegmentation> {
82 if self.config.distance_threshold < 0.0 {
83 return Err(SpatialError::InvalidArgument(
84 "distance_threshold must be non-negative".to_owned(),
85 ));
86 }
87 let len = input.len();
88 let (x, y, z) = input.positions3()?;
89
90 let mut labels = vec![-1_i32; len];
91 let mut remaining: Vec<usize> = (0..len).collect();
92 let mut planes = Vec::new();
93 let mut plane_sizes = Vec::new();
94
95 for plane_index in 0..self.config.max_planes {
96 if remaining.len() < 3 || remaining.len() < self.config.min_inliers {
97 break;
98 }
99
100 let sub = extract_indices(input, &remaining)?;
102 let config = RansacPlaneConfig {
103 distance_threshold: self.config.distance_threshold,
104 max_iterations: self.config.max_iterations,
105 min_inliers: self.config.min_inliers,
106 seed: self.config.seed.wrapping_add(plane_index as u64),
108 };
109 let Ok(result) = RansacPlaneSegmenter::new(config).segment(&sub) else {
110 break;
112 };
113
114 let model = result.model;
117 let mut next_remaining = Vec::with_capacity(remaining.len());
118 let mut assigned = 0_usize;
119 for &orig in &remaining {
120 if model.distance_xyz(x[orig], y[orig], z[orig]) <= self.config.distance_threshold {
121 labels[orig] = plane_index as i32;
122 assigned += 1;
123 } else {
124 next_remaining.push(orig);
125 }
126 }
127
128 if assigned < self.config.min_inliers {
129 for &orig in &remaining {
131 if labels[orig] == plane_index as i32 {
132 labels[orig] = -1;
133 }
134 }
135 break;
136 }
137
138 planes.push(model);
139 plane_sizes.push(assigned);
140 remaining = next_remaining;
141 }
142
143 Ok(MultiPlaneSegmentation {
144 labeled: with_labels(input, "label", labels)?,
145 planes,
146 plane_sizes,
147 })
148 }
149}
150
151impl PointCloudSegmenter for MultiPlaneSegmenter {
152 fn name(&self) -> &'static str {
153 "MultiPlaneSegmenter"
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::{MultiPlaneConfig, MultiPlaneSegmenter};
160 use spatialrust_core::{PointCloudBuilder, StandardSchemas};
161
162 fn room() -> spatialrust_core::PointCloud {
164 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyz());
165 for i in 0..20 {
166 for j in 0..20 {
167 let (a, b) = (i as f32 * 0.1, j as f32 * 0.1);
168 builder.push_point([a, b, 0.0]).unwrap(); builder.push_point([a, 0.0, b]).unwrap(); builder.push_point([a, b, 2.0]).unwrap(); }
172 }
173 builder.build().unwrap()
174 }
175
176 #[test]
177 fn extracts_three_room_planes() {
178 let cloud = room();
179 let seg = MultiPlaneSegmenter::new(MultiPlaneConfig {
180 max_planes: 4,
181 distance_threshold: 0.02,
182 min_inliers: 100,
183 max_iterations: 500,
184 seed: 7,
185 })
186 .segment(&cloud)
187 .unwrap();
188
189 assert_eq!(seg.planes.len(), 3, "expected floor, wall, ceiling");
190 assert!(seg.labeled.field("label").is_ok());
191 let covered: usize = seg.plane_sizes.iter().sum();
193 assert!(covered as f32 > cloud.len() as f32 * 0.95);
194 }
195
196 #[test]
197 fn stops_when_no_dominant_plane_remains() {
198 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyz());
200 let mut seed = 99_u64;
201 let mut rng = || {
202 seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
203 (seed >> 40) as f32 / (1u64 << 24) as f32
204 };
205 for _ in 0..400 {
206 builder.push_point([rng() * 5.0, rng() * 5.0, rng() * 5.0]).unwrap();
207 }
208 let cloud = builder.build().unwrap();
209 let seg = MultiPlaneSegmenter::new(MultiPlaneConfig::new(3, 0.01)).segment(&cloud).unwrap();
210 assert!(seg.planes.is_empty(), "noise should yield no planes");
211 }
212
213 #[test]
214 fn rejects_bad_threshold() {
215 let cloud = room();
216 let config = MultiPlaneConfig { distance_threshold: -1.0, ..MultiPlaneConfig::default() };
217 assert!(MultiPlaneSegmenter::new(config).segment(&cloud).is_err());
218 }
219}