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        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
192/// PCA on a set of points; returns (mean, eigenvalues sorted ascending,
193/// corresponding eigenvectors as columns of the matrix).
194fn 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    // Symmetric eigendecomposition.
213    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
228/// Fit a ground plane to a single patch using R-GPF; returns plane parameters
229/// (normal, d) and the inlier indices (into the original `points` slice) if a
230/// valid plane was found.
231fn 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    // Initial seeds: points whose z is within seed_selection_threshold of min z.
241    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, &current)?;
269        // Smallest-eigenvalue eigenvector = surface normal.
270        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        // Re-collect inliers from the full patch.
277        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
299/// Validate a fitted patch under uprightness, elevation, and flatness criteria.
300fn validate_patch(
301    points: &[Point3f],
302    inliers: &[usize],
303    normal: Vector3<f32>,
304    cfg: &PatchworkConfig,
305) -> bool {
306    // Uprightness: normal must be close to +z.
307    if normal.z.abs() < cfg.uprightness_threshold {
308        return false;
309    }
310
311    // Mean z elevation: expected near -sensor_height.
312    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    // Flatness: smallest eigenvalue should be small compared to the others.
322    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
335/// Run Patchwork++-style ground segmentation on a point cloud.
336pub 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    // Flatten patches so we can process them in parallel.
355    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    // Anything out of range is non-ground.
386    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
409/// Convenience wrapper using default configuration with a given sensor height.
410pub 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        // Flat ground at z = -sensor_height over a 60×60 area, with mild noise.
431        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            // Skip points right under the sensor (too close to origin).
437            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            // Tall vertical "wall" / obstacle cluster.
445            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            // A pole.
452            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        // Both classes should be non-empty.
491        assert!(result.ground.len() > 0);
492        assert!(result.nonground.len() > 0);
493        // ground + nonground must equal input.
494        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}