Skip to main content

threecrate_algorithms/
ground_segmentation.rs

1//! Ground segmentation for outdoor LiDAR point clouds.
2//!
3//! Implements a Patchwork++-style algorithm that splits a point cloud into
4//! ground and non-ground subsets. The approach uses a Concentric Zone Model
5//! (CZM): the XY plane around the sensor is divided into concentric rings and
6//! angular sectors, producing a grid of patches whose size adapts with range.
7//! Each patch is fit with a Region-wise Ground Plane Fit (R-GPF) — seed points
8//! near the patch minimum are selected, a plane is fit by PCA, then inliers
9//! are extracted and the plane is refit iteratively. Each candidate plane is
10//! then validated by three criteria: **uprightness** (normal nearly vertical),
11//! **elevation** (within an allowed height band per zone), and **flatness**
12//! (smallest eigenvalue ratio).
13//!
14//! Reference: Lee et al., "Patchwork++: Fast and Robust Ground Segmentation
15//! Solving Partial Under-Segmentation Using 3D Point Cloud", IROS 2022.
16
17use nalgebra::{Matrix3, Vector3};
18use rayon::prelude::*;
19use std::f32::consts::PI;
20use threecrate_core::{Error, Point3f, PointCloud, Result, Vector3f};
21
22/// Configuration for the Patchwork++ ground segmentation algorithm.
23#[derive(Debug, Clone)]
24pub struct PatchworkConfig {
25    /// Height of the LiDAR sensor above the expected ground plane (meters).
26    pub sensor_height: f32,
27    /// Concentric zone boundary radii in meters, length = num_zones + 1.
28    /// Each adjacent pair defines one zone (inner_radius, outer_radius].
29    pub zone_radii: Vec<f32>,
30    /// Number of concentric rings per zone (length = num_zones).
31    pub num_rings_per_zone: Vec<usize>,
32    /// Number of angular sectors per zone (length = num_zones).
33    pub num_sectors_per_zone: Vec<usize>,
34    /// Maximum sensing range (meters); points beyond are treated as non-ground.
35    pub max_range: f32,
36    /// Minimum number of points required in a patch to attempt ground fitting.
37    pub min_points_per_patch: usize,
38    /// Minimum number of seed points used in the initial PCA fit.
39    pub num_seed_points: usize,
40    /// A point is a seed candidate if its z is within this distance of the
41    /// patch's minimum z value.
42    pub seed_selection_threshold: f32,
43    /// Inlier distance threshold (meters) used when refining the plane fit.
44    pub dist_threshold: f32,
45    /// Number of refit iterations of the R-GPF inner loop.
46    pub num_iterations: usize,
47    /// Minimum |n_z| required for a patch's plane to count as ground
48    /// (cos of maximum allowed slope; 0.707 ≈ 45°).
49    pub uprightness_threshold: f32,
50    /// Maximum allowed flatness ratio = lambda_min / (lambda_0 + lambda_1 + lambda_min);
51    /// smaller means the patch must be flatter.
52    pub flatness_threshold: f32,
53    /// Maximum signed deviation of the patch mean z from `-sensor_height`
54    /// (meters). Patches sitting unreasonably high or low are rejected.
55    pub elevation_threshold: f32,
56}
57
58impl Default for PatchworkConfig {
59    fn default() -> Self {
60        // Defaults follow the Patchwork++ reference implementation.
61        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/// Result of ground segmentation.
80#[derive(Debug, Clone)]
81pub struct GroundSegmentationResult {
82    /// Points classified as ground.
83    pub ground: PointCloud<Point3f>,
84    /// Points classified as non-ground (obstacles, vegetation above ground, etc.).
85    pub nonground: PointCloud<Point3f>,
86    /// Per-input-point labels: true if classified as ground.
87    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
128/// Decide which zone a (range) value falls into; returns `None` if it's
129/// outside the configured zone range.
130fn 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
142/// Group point indices into CZM patches keyed by (zone, ring, sector).
143fn bucket_points(
144    points: &[Point3f],
145    cfg: &PatchworkConfig,
146) -> (Vec<Vec<Vec<Vec<usize>>>>, Vec<bool>) {
147    // patches[zone][ring][sector] = Vec<point_index>
148    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        // NaN/inf points can't be placed in a patch; treat them as out of range.
164        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
193/// PCA on a set of points; returns (mean, eigenvalues sorted ascending,
194/// corresponding eigenvectors as columns of the matrix).
195fn 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    // Symmetric eigendecomposition.
214    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
229/// Fit a ground plane to a single patch using R-GPF; returns plane parameters
230/// (normal, d) and the inlier indices (into the original `points` slice) if a
231/// valid plane was found.
232fn 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    // Initial seeds: points whose z is within seed_selection_threshold of min z.
242    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, &current)?;
270        // Smallest-eigenvalue eigenvector = surface normal.
271        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        // Re-collect inliers from the full patch.
278        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
300/// Validate a fitted patch under uprightness, elevation, and flatness criteria.
301fn validate_patch(
302    points: &[Point3f],
303    inliers: &[usize],
304    normal: Vector3<f32>,
305    cfg: &PatchworkConfig,
306) -> bool {
307    // Uprightness: normal must be close to +z.
308    if normal.z.abs() < cfg.uprightness_threshold {
309        return false;
310    }
311
312    // Mean z elevation: expected near -sensor_height.
313    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    // Flatness: smallest eigenvalue should be small compared to the others.
323    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
336/// Run Patchwork++-style ground segmentation on a point cloud.
337pub 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    // Flatten patches so we can process them in parallel.
356    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    // Anything out of range is non-ground.
387    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
410/// Convenience wrapper using default configuration with a given sensor height.
411pub 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        // Flat ground at z = -sensor_height over a 60×60 area, with mild noise.
443        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            // Skip points right under the sensor (too close to origin).
449            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            // Tall vertical "wall" / obstacle cluster.
457            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            // A pole.
464            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        // Both classes should be non-empty.
503        assert!(result.ground.len() > 0);
504        assert!(result.nonground.len() > 0);
505        // ground + nonground must equal input.
506        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}