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.is_nan() || r > cfg.max_range || !p.z.is_finite() {
165 out_of_range[idx] = true;
166 continue;
167 }
168 let zone = match find_zone(r, &cfg.zone_radii) {
169 Some(z) => z,
170 None => {
171 out_of_range[idx] = true;
172 continue;
173 }
174 };
175 let r_inner = cfg.zone_radii[zone];
176 let r_outer = cfg.zone_radii[zone + 1];
177 let ring_width = (r_outer - r_inner) / cfg.num_rings_per_zone[zone] as f32;
178 let ring = (((r - r_inner) / ring_width) as usize).min(cfg.num_rings_per_zone[zone] - 1);
179
180 let mut theta = p.y.atan2(p.x);
181 if theta < 0.0 {
182 theta += 2.0 * PI;
183 }
184 let sector_width = 2.0 * PI / cfg.num_sectors_per_zone[zone] as f32;
185 let sector = ((theta / sector_width) as usize).min(cfg.num_sectors_per_zone[zone] - 1);
186
187 patches[zone][ring][sector].push(idx);
188 }
189
190 (patches, out_of_range)
191}
192
193fn pca(points: &[Point3f], indices: &[usize]) -> Option<(Vector3<f32>, [f32; 3], Matrix3<f32>)> {
196 if indices.len() < 3 {
197 return None;
198 }
199 let n = indices.len() as f32;
200 let mut mean = Vector3::<f32>::zeros();
201 for &i in indices {
202 mean += points[i].coords;
203 }
204 mean /= n;
205
206 let mut cov = Matrix3::<f32>::zeros();
207 for &i in indices {
208 let d = points[i].coords - mean;
209 cov += d * d.transpose();
210 }
211 cov /= n;
212
213 let eig = cov.symmetric_eigen();
215 let mut idx = [0usize, 1, 2];
216 idx.sort_by(|&a, &b| eig.eigenvalues[a].total_cmp(&eig.eigenvalues[b]));
217 let vals = [
218 eig.eigenvalues[idx[0]],
219 eig.eigenvalues[idx[1]],
220 eig.eigenvalues[idx[2]],
221 ];
222 let mut vecs = Matrix3::<f32>::zeros();
223 for k in 0..3 {
224 vecs.set_column(k, &eig.eigenvectors.column(idx[k]));
225 }
226 Some((mean, vals, vecs))
227}
228
229fn fit_patch(
233 points: &[Point3f],
234 patch: &[usize],
235 cfg: &PatchworkConfig,
236) -> Option<(Vector3<f32>, f32, Vec<usize>)> {
237 if patch.len() < cfg.min_points_per_patch {
238 return None;
239 }
240
241 let mut sorted_by_z: Vec<usize> = patch.to_vec();
243 sorted_by_z.sort_by(|&a, &b| points[a].z.total_cmp(&points[b].z));
244
245 let seed_count = cfg.num_seed_points.min(sorted_by_z.len());
246 let z_min_mean = {
247 let n = seed_count.min(sorted_by_z.len());
248 if n == 0 {
249 return None;
250 }
251 let mut s = 0.0;
252 for &i in &sorted_by_z[..n] {
253 s += points[i].z;
254 }
255 s / n as f32
256 };
257 let cutoff = z_min_mean + cfg.seed_selection_threshold;
258 let mut current: Vec<usize> = sorted_by_z
259 .iter()
260 .copied()
261 .take_while(|&i| points[i].z <= cutoff)
262 .collect();
263 if current.len() < 3 {
264 return None;
265 }
266
267 let mut last: Option<(Vector3<f32>, f32)> = None;
268 for _ in 0..cfg.num_iterations {
269 let (mean, _vals, vecs) = pca(points, ¤t)?;
270 let mut normal = Vector3::new(vecs[(0, 0)], vecs[(1, 0)], vecs[(2, 0)]);
272 if normal.z < 0.0 {
273 normal = -normal;
274 }
275 let d = -normal.dot(&mean);
276
277 let mut new_inliers = Vec::with_capacity(patch.len());
279 for &i in patch {
280 let dist = (normal.dot(&points[i].coords) + d).abs();
281 if dist <= cfg.dist_threshold {
282 new_inliers.push(i);
283 }
284 }
285 if new_inliers.len() < 3 {
286 return None;
287 }
288 last = Some((normal, d));
289 if new_inliers.len() == current.len() {
290 current = new_inliers;
291 break;
292 }
293 current = new_inliers;
294 }
295
296 let (normal, d) = last?;
297 Some((normal, d, current))
298}
299
300fn validate_patch(
302 points: &[Point3f],
303 inliers: &[usize],
304 normal: Vector3<f32>,
305 cfg: &PatchworkConfig,
306) -> bool {
307 if normal.z.abs() < cfg.uprightness_threshold {
309 return false;
310 }
311
312 let mut mean_z = 0.0;
314 for &i in inliers {
315 mean_z += points[i].z;
316 }
317 mean_z /= inliers.len() as f32;
318 if (mean_z + cfg.sensor_height).abs() > cfg.elevation_threshold {
319 return false;
320 }
321
322 if let Some((_mean, vals, _vecs)) = pca(points, inliers) {
324 let sum = vals[0] + vals[1] + vals[2];
325 if sum > 0.0 {
326 let ratio = vals[0] / sum;
327 if ratio > cfg.flatness_threshold {
328 return false;
329 }
330 }
331 }
332
333 true
334}
335
336pub fn patchwork_plus_plus(
338 cloud: &PointCloud<Point3f>,
339 config: PatchworkConfig,
340) -> Result<GroundSegmentationResult> {
341 validate_config(&config)?;
342 let points = &cloud.points;
343 let mut labels = vec![false; points.len()];
344
345 if points.is_empty() {
346 return Ok(GroundSegmentationResult {
347 ground: PointCloud::new(),
348 nonground: PointCloud::new(),
349 labels,
350 });
351 }
352
353 let (patches, out_of_range) = bucket_points(points, &config);
354
355 let mut flat: Vec<&Vec<usize>> = Vec::new();
357 for zone in &patches {
358 for ring in zone {
359 for sector in ring {
360 if !sector.is_empty() {
361 flat.push(sector);
362 }
363 }
364 }
365 }
366
367 let cfg_ref = &config;
368 let ground_index_sets: Vec<Vec<usize>> = flat
369 .par_iter()
370 .filter_map(|patch| {
371 let (normal, _d, inliers) = fit_patch(points, patch, cfg_ref)?;
372 if validate_patch(points, &inliers, normal, cfg_ref) {
373 Some(inliers)
374 } else {
375 None
376 }
377 })
378 .collect();
379
380 for set in ground_index_sets {
381 for i in set {
382 labels[i] = true;
383 }
384 }
385
386 for (i, oor) in out_of_range.iter().enumerate() {
388 if *oor {
389 labels[i] = false;
390 }
391 }
392
393 let mut ground = PointCloud::with_capacity(labels.iter().filter(|b| **b).count());
394 let mut nonground = PointCloud::with_capacity(labels.iter().filter(|b| !**b).count());
395 for (i, p) in points.iter().enumerate() {
396 if labels[i] {
397 ground.push(*p);
398 } else {
399 nonground.push(*p);
400 }
401 }
402
403 Ok(GroundSegmentationResult {
404 ground,
405 nonground,
406 labels,
407 })
408}
409
410pub fn segment_ground(
412 cloud: &PointCloud<Point3f>,
413 sensor_height: f32,
414) -> Result<GroundSegmentationResult> {
415 let config = PatchworkConfig {
416 sensor_height,
417 ..Default::default()
418 };
419 patchwork_plus_plus(cloud, config)
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425 use rand::prelude::*;
426
427 #[test]
428 fn nan_points_are_not_ground_and_do_not_panic() {
429 let mut cloud = build_scene(1.723, false);
430 let n = cloud.points.len();
431 cloud.points.push(Point3f::new(3.5, 3.0, f32::NAN));
432 cloud.points.push(Point3f::new(f32::NAN, 1.0, -1.7));
433 let result = segment_ground(&cloud, 1.723).unwrap();
434 assert!(!result.labels[n]);
435 assert!(!result.labels[n + 1]);
436 }
437
438 fn build_scene(sensor_height: f32, with_obstacles: bool) -> PointCloud<Point3f> {
439 let mut cloud = PointCloud::new();
440 let mut rng = StdRng::seed_from_u64(42);
441
442 let z_ground = -sensor_height;
444 for _ in 0..8000 {
445 let x: f32 = rng.random_range(-30.0..30.0);
446 let y: f32 = rng.random_range(-30.0..30.0);
447 let z = z_ground + rng.random_range(-0.02..0.02);
448 if x * x + y * y < 0.25 {
450 continue;
451 }
452 cloud.push(Point3f::new(x, y, z));
453 }
454
455 if with_obstacles {
456 for _ in 0..1500 {
458 let x = 8.0 + rng.random_range(-0.4..0.4);
459 let y = rng.random_range(-3.0..3.0);
460 let z = z_ground + rng.random_range(0.5..3.0);
461 cloud.push(Point3f::new(x, y, z));
462 }
463 for _ in 0..400 {
465 let x = -5.0 + rng.random_range(-0.1..0.1);
466 let y = -5.0 + rng.random_range(-0.1..0.1);
467 let z = z_ground + rng.random_range(0.0..4.0);
468 cloud.push(Point3f::new(x, y, z));
469 }
470 }
471
472 cloud
473 }
474
475 #[test]
476 fn flat_ground_is_mostly_ground() {
477 let sensor_h = 1.8;
478 let cloud = build_scene(sensor_h, false);
479 let n = cloud.len();
480 let result = segment_ground(&cloud, sensor_h).unwrap();
481 let ground_frac = result.ground.len() as f32 / n as f32;
482 assert!(
483 ground_frac > 0.85,
484 "expected >85% ground on a flat scene, got {:.2}%",
485 ground_frac * 100.0
486 );
487 }
488
489 #[test]
490 fn obstacles_are_separated() {
491 let sensor_h = 1.8;
492 let cloud = build_scene(sensor_h, true);
493 let result = segment_ground(&cloud, sensor_h).unwrap();
494 let ground_z_mean = mean_z(&result.ground.points);
495 let nonground_z_mean = mean_z(&result.nonground.points);
496 assert!(
497 nonground_z_mean > ground_z_mean + 0.3,
498 "obstacles should sit above the ground band: ng={:.3} g={:.3}",
499 nonground_z_mean,
500 ground_z_mean
501 );
502 assert!(result.ground.len() > 0);
504 assert!(result.nonground.len() > 0);
505 assert_eq!(result.ground.len() + result.nonground.len(), cloud.len());
507 }
508
509 fn mean_z(pts: &[Point3f]) -> f32 {
510 if pts.is_empty() {
511 return 0.0;
512 }
513 pts.iter().map(|p| p.z).sum::<f32>() / pts.len() as f32
514 }
515
516 #[test]
517 fn empty_cloud_is_handled() {
518 let cloud: PointCloud<Point3f> = PointCloud::new();
519 let result = segment_ground(&cloud, 1.8).unwrap();
520 assert_eq!(result.ground.len(), 0);
521 assert_eq!(result.nonground.len(), 0);
522 }
523
524 #[test]
525 fn invalid_config_is_rejected() {
526 let cloud = build_scene(1.8, false);
527 let bad = PatchworkConfig {
528 zone_radii: vec![0.0, 10.0],
529 num_rings_per_zone: vec![2, 2],
530 num_sectors_per_zone: vec![8, 8],
531 ..Default::default()
532 };
533 assert!(patchwork_plus_plus(&cloud, bad).is_err());
534 }
535
536 #[test]
537 fn labels_match_partition() {
538 let cloud = build_scene(1.8, true);
539 let result = segment_ground(&cloud, 1.8).unwrap();
540 assert_eq!(result.labels.len(), cloud.len());
541 let ground_count = result.labels.iter().filter(|b| **b).count();
542 assert_eq!(ground_count, result.ground.len());
543 }
544}