1use nalgebra::{Matrix3, Vector3};
18use rayon::prelude::*;
19use std::f32::consts::PI;
20use threecrate_core::{Error, Point3f, PointCloud, Result, Vector3f};
21
22#[derive(Debug, Clone)]
24pub struct PatchworkConfig {
25 pub sensor_height: f32,
27 pub zone_radii: Vec<f32>,
30 pub num_rings_per_zone: Vec<usize>,
32 pub num_sectors_per_zone: Vec<usize>,
34 pub max_range: f32,
36 pub min_points_per_patch: usize,
38 pub num_seed_points: usize,
40 pub seed_selection_threshold: f32,
43 pub dist_threshold: f32,
45 pub num_iterations: usize,
47 pub uprightness_threshold: f32,
50 pub flatness_threshold: f32,
53 pub elevation_threshold: f32,
56}
57
58impl Default for PatchworkConfig {
59 fn default() -> Self {
60 Self {
62 sensor_height: 1.723,
63 zone_radii: vec![0.0, 2.7, 12.3625, 22.025, 80.0],
64 num_rings_per_zone: vec![2, 4, 4, 4],
65 num_sectors_per_zone: vec![16, 32, 54, 32],
66 max_range: 80.0,
67 min_points_per_patch: 10,
68 num_seed_points: 20,
69 seed_selection_threshold: 0.5,
70 dist_threshold: 0.125,
71 num_iterations: 3,
72 uprightness_threshold: 0.707,
73 flatness_threshold: 0.05,
74 elevation_threshold: 1.0,
75 }
76 }
77}
78
79#[derive(Debug, Clone)]
81pub struct GroundSegmentationResult {
82 pub ground: PointCloud<Point3f>,
84 pub nonground: PointCloud<Point3f>,
86 pub labels: Vec<bool>,
88}
89
90fn validate_config(cfg: &PatchworkConfig) -> Result<()> {
91 let nz = cfg.num_rings_per_zone.len();
92 if nz == 0 {
93 return Err(Error::InvalidData(
94 "num_rings_per_zone must be non-empty".into(),
95 ));
96 }
97 if cfg.zone_radii.len() != nz + 1 {
98 return Err(Error::InvalidData(
99 "zone_radii.len() must equal num_rings_per_zone.len() + 1".into(),
100 ));
101 }
102 if cfg.num_sectors_per_zone.len() != nz {
103 return Err(Error::InvalidData(
104 "num_sectors_per_zone.len() must equal num_rings_per_zone.len()".into(),
105 ));
106 }
107 if cfg.zone_radii.windows(2).any(|w| w[0] >= w[1]) {
108 return Err(Error::InvalidData(
109 "zone_radii must be strictly increasing".into(),
110 ));
111 }
112 if cfg.dist_threshold <= 0.0 {
113 return Err(Error::InvalidData("dist_threshold must be positive".into()));
114 }
115 if cfg.num_seed_points == 0 {
116 return Err(Error::InvalidData(
117 "num_seed_points must be at least 1".into(),
118 ));
119 }
120 if cfg.uprightness_threshold <= 0.0 || cfg.uprightness_threshold > 1.0 {
121 return Err(Error::InvalidData(
122 "uprightness_threshold must be in (0, 1]".into(),
123 ));
124 }
125 Ok(())
126}
127
128fn find_zone(radius: f32, zone_radii: &[f32]) -> Option<usize> {
131 if radius < zone_radii[0] || radius >= *zone_radii.last().unwrap() {
132 return None;
133 }
134 for i in 0..zone_radii.len() - 1 {
135 if radius >= zone_radii[i] && radius < zone_radii[i + 1] {
136 return Some(i);
137 }
138 }
139 None
140}
141
142fn bucket_points(
144 points: &[Point3f],
145 cfg: &PatchworkConfig,
146) -> (Vec<Vec<Vec<Vec<usize>>>>, Vec<bool>) {
147 let mut patches: Vec<Vec<Vec<Vec<usize>>>> = (0..cfg.num_rings_per_zone.len())
149 .map(|z| {
150 (0..cfg.num_rings_per_zone[z])
151 .map(|_| {
152 (0..cfg.num_sectors_per_zone[z])
153 .map(|_| Vec::new())
154 .collect()
155 })
156 .collect()
157 })
158 .collect();
159 let mut out_of_range = vec![false; points.len()];
160
161 for (idx, p) in points.iter().enumerate() {
162 let r = (p.x * p.x + p.y * p.y).sqrt();
163 if r > cfg.max_range {
164 out_of_range[idx] = true;
165 continue;
166 }
167 let zone = match find_zone(r, &cfg.zone_radii) {
168 Some(z) => z,
169 None => {
170 out_of_range[idx] = true;
171 continue;
172 }
173 };
174 let r_inner = cfg.zone_radii[zone];
175 let r_outer = cfg.zone_radii[zone + 1];
176 let ring_width = (r_outer - r_inner) / cfg.num_rings_per_zone[zone] as f32;
177 let ring = (((r - r_inner) / ring_width) as usize).min(cfg.num_rings_per_zone[zone] - 1);
178
179 let mut theta = p.y.atan2(p.x);
180 if theta < 0.0 {
181 theta += 2.0 * PI;
182 }
183 let sector_width = 2.0 * PI / cfg.num_sectors_per_zone[zone] as f32;
184 let sector = ((theta / sector_width) as usize).min(cfg.num_sectors_per_zone[zone] - 1);
185
186 patches[zone][ring][sector].push(idx);
187 }
188
189 (patches, out_of_range)
190}
191
192fn pca(points: &[Point3f], indices: &[usize]) -> Option<(Vector3<f32>, [f32; 3], Matrix3<f32>)> {
195 if indices.len() < 3 {
196 return None;
197 }
198 let n = indices.len() as f32;
199 let mut mean = Vector3::<f32>::zeros();
200 for &i in indices {
201 mean += points[i].coords;
202 }
203 mean /= n;
204
205 let mut cov = Matrix3::<f32>::zeros();
206 for &i in indices {
207 let d = points[i].coords - mean;
208 cov += d * d.transpose();
209 }
210 cov /= n;
211
212 let eig = cov.symmetric_eigen();
214 let mut idx = [0usize, 1, 2];
215 idx.sort_by(|&a, &b| eig.eigenvalues[a].partial_cmp(&eig.eigenvalues[b]).unwrap());
216 let vals = [
217 eig.eigenvalues[idx[0]],
218 eig.eigenvalues[idx[1]],
219 eig.eigenvalues[idx[2]],
220 ];
221 let mut vecs = Matrix3::<f32>::zeros();
222 for k in 0..3 {
223 vecs.set_column(k, &eig.eigenvectors.column(idx[k]));
224 }
225 Some((mean, vals, vecs))
226}
227
228fn fit_patch(
232 points: &[Point3f],
233 patch: &[usize],
234 cfg: &PatchworkConfig,
235) -> Option<(Vector3<f32>, f32, Vec<usize>)> {
236 if patch.len() < cfg.min_points_per_patch {
237 return None;
238 }
239
240 let mut sorted_by_z: Vec<usize> = patch.to_vec();
242 sorted_by_z.sort_by(|&a, &b| points[a].z.partial_cmp(&points[b].z).unwrap());
243
244 let seed_count = cfg.num_seed_points.min(sorted_by_z.len());
245 let z_min_mean = {
246 let n = seed_count.min(sorted_by_z.len());
247 if n == 0 {
248 return None;
249 }
250 let mut s = 0.0;
251 for &i in &sorted_by_z[..n] {
252 s += points[i].z;
253 }
254 s / n as f32
255 };
256 let cutoff = z_min_mean + cfg.seed_selection_threshold;
257 let mut current: Vec<usize> = sorted_by_z
258 .iter()
259 .copied()
260 .take_while(|&i| points[i].z <= cutoff)
261 .collect();
262 if current.len() < 3 {
263 return None;
264 }
265
266 let mut last: Option<(Vector3<f32>, f32)> = None;
267 for _ in 0..cfg.num_iterations {
268 let (mean, _vals, vecs) = pca(points, ¤t)?;
269 let mut normal = Vector3::new(vecs[(0, 0)], vecs[(1, 0)], vecs[(2, 0)]);
271 if normal.z < 0.0 {
272 normal = -normal;
273 }
274 let d = -normal.dot(&mean);
275
276 let mut new_inliers = Vec::with_capacity(patch.len());
278 for &i in patch {
279 let dist = (normal.dot(&points[i].coords) + d).abs();
280 if dist <= cfg.dist_threshold {
281 new_inliers.push(i);
282 }
283 }
284 if new_inliers.len() < 3 {
285 return None;
286 }
287 last = Some((normal, d));
288 if new_inliers.len() == current.len() {
289 current = new_inliers;
290 break;
291 }
292 current = new_inliers;
293 }
294
295 let (normal, d) = last?;
296 Some((normal, d, current))
297}
298
299fn validate_patch(
301 points: &[Point3f],
302 inliers: &[usize],
303 normal: Vector3<f32>,
304 cfg: &PatchworkConfig,
305) -> bool {
306 if normal.z.abs() < cfg.uprightness_threshold {
308 return false;
309 }
310
311 let mut mean_z = 0.0;
313 for &i in inliers {
314 mean_z += points[i].z;
315 }
316 mean_z /= inliers.len() as f32;
317 if (mean_z + cfg.sensor_height).abs() > cfg.elevation_threshold {
318 return false;
319 }
320
321 if let Some((_mean, vals, _vecs)) = pca(points, inliers) {
323 let sum = vals[0] + vals[1] + vals[2];
324 if sum > 0.0 {
325 let ratio = vals[0] / sum;
326 if ratio > cfg.flatness_threshold {
327 return false;
328 }
329 }
330 }
331
332 true
333}
334
335pub fn patchwork_plus_plus(
337 cloud: &PointCloud<Point3f>,
338 config: PatchworkConfig,
339) -> Result<GroundSegmentationResult> {
340 validate_config(&config)?;
341 let points = &cloud.points;
342 let mut labels = vec![false; points.len()];
343
344 if points.is_empty() {
345 return Ok(GroundSegmentationResult {
346 ground: PointCloud::new(),
347 nonground: PointCloud::new(),
348 labels,
349 });
350 }
351
352 let (patches, out_of_range) = bucket_points(points, &config);
353
354 let mut flat: Vec<&Vec<usize>> = Vec::new();
356 for zone in &patches {
357 for ring in zone {
358 for sector in ring {
359 if !sector.is_empty() {
360 flat.push(sector);
361 }
362 }
363 }
364 }
365
366 let cfg_ref = &config;
367 let ground_index_sets: Vec<Vec<usize>> = flat
368 .par_iter()
369 .filter_map(|patch| {
370 let (normal, _d, inliers) = fit_patch(points, patch, cfg_ref)?;
371 if validate_patch(points, &inliers, normal, cfg_ref) {
372 Some(inliers)
373 } else {
374 None
375 }
376 })
377 .collect();
378
379 for set in ground_index_sets {
380 for i in set {
381 labels[i] = true;
382 }
383 }
384
385 for (i, oor) in out_of_range.iter().enumerate() {
387 if *oor {
388 labels[i] = false;
389 }
390 }
391
392 let mut ground = PointCloud::with_capacity(labels.iter().filter(|b| **b).count());
393 let mut nonground = PointCloud::with_capacity(labels.iter().filter(|b| !**b).count());
394 for (i, p) in points.iter().enumerate() {
395 if labels[i] {
396 ground.push(*p);
397 } else {
398 nonground.push(*p);
399 }
400 }
401
402 Ok(GroundSegmentationResult {
403 ground,
404 nonground,
405 labels,
406 })
407}
408
409pub fn segment_ground(
411 cloud: &PointCloud<Point3f>,
412 sensor_height: f32,
413) -> Result<GroundSegmentationResult> {
414 let config = PatchworkConfig {
415 sensor_height,
416 ..Default::default()
417 };
418 patchwork_plus_plus(cloud, config)
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424 use rand::prelude::*;
425
426 fn build_scene(sensor_height: f32, with_obstacles: bool) -> PointCloud<Point3f> {
427 let mut cloud = PointCloud::new();
428 let mut rng = StdRng::seed_from_u64(42);
429
430 let z_ground = -sensor_height;
432 for _ in 0..8000 {
433 let x: f32 = rng.random_range(-30.0..30.0);
434 let y: f32 = rng.random_range(-30.0..30.0);
435 let z = z_ground + rng.random_range(-0.02..0.02);
436 if x * x + y * y < 0.25 {
438 continue;
439 }
440 cloud.push(Point3f::new(x, y, z));
441 }
442
443 if with_obstacles {
444 for _ in 0..1500 {
446 let x = 8.0 + rng.random_range(-0.4..0.4);
447 let y = rng.random_range(-3.0..3.0);
448 let z = z_ground + rng.random_range(0.5..3.0);
449 cloud.push(Point3f::new(x, y, z));
450 }
451 for _ in 0..400 {
453 let x = -5.0 + rng.random_range(-0.1..0.1);
454 let y = -5.0 + rng.random_range(-0.1..0.1);
455 let z = z_ground + rng.random_range(0.0..4.0);
456 cloud.push(Point3f::new(x, y, z));
457 }
458 }
459
460 cloud
461 }
462
463 #[test]
464 fn flat_ground_is_mostly_ground() {
465 let sensor_h = 1.8;
466 let cloud = build_scene(sensor_h, false);
467 let n = cloud.len();
468 let result = segment_ground(&cloud, sensor_h).unwrap();
469 let ground_frac = result.ground.len() as f32 / n as f32;
470 assert!(
471 ground_frac > 0.85,
472 "expected >85% ground on a flat scene, got {:.2}%",
473 ground_frac * 100.0
474 );
475 }
476
477 #[test]
478 fn obstacles_are_separated() {
479 let sensor_h = 1.8;
480 let cloud = build_scene(sensor_h, true);
481 let result = segment_ground(&cloud, sensor_h).unwrap();
482 let ground_z_mean = mean_z(&result.ground.points);
483 let nonground_z_mean = mean_z(&result.nonground.points);
484 assert!(
485 nonground_z_mean > ground_z_mean + 0.3,
486 "obstacles should sit above the ground band: ng={:.3} g={:.3}",
487 nonground_z_mean,
488 ground_z_mean
489 );
490 assert!(result.ground.len() > 0);
492 assert!(result.nonground.len() > 0);
493 assert_eq!(result.ground.len() + result.nonground.len(), cloud.len());
495 }
496
497 fn mean_z(pts: &[Point3f]) -> f32 {
498 if pts.is_empty() {
499 return 0.0;
500 }
501 pts.iter().map(|p| p.z).sum::<f32>() / pts.len() as f32
502 }
503
504 #[test]
505 fn empty_cloud_is_handled() {
506 let cloud: PointCloud<Point3f> = PointCloud::new();
507 let result = segment_ground(&cloud, 1.8).unwrap();
508 assert_eq!(result.ground.len(), 0);
509 assert_eq!(result.nonground.len(), 0);
510 }
511
512 #[test]
513 fn invalid_config_is_rejected() {
514 let cloud = build_scene(1.8, false);
515 let bad = PatchworkConfig {
516 zone_radii: vec![0.0, 10.0],
517 num_rings_per_zone: vec![2, 2],
518 num_sectors_per_zone: vec![8, 8],
519 ..Default::default()
520 };
521 assert!(patchwork_plus_plus(&cloud, bad).is_err());
522 }
523
524 #[test]
525 fn labels_match_partition() {
526 let cloud = build_scene(1.8, true);
527 let result = segment_ground(&cloud, 1.8).unwrap();
528 assert_eq!(result.labels.len(), cloud.len());
529 let ground_count = result.labels.iter().filter(|b| **b).count();
530 assert_eq!(ground_count, result.ground.len());
531 }
532}