Skip to main content

oxigdal_algorithms/raster/
point_cloud_thin.rs

1//! Point-cloud thinning algorithms: grid (voxel), random (LCG), Poisson disk.
2//!
3//! Point-cloud thinning is the task of reducing the cardinality of a 3D point
4//! set while preserving some structural property of the cloud. Three classical
5//! strategies are provided here:
6//!
7//! - **Grid (voxel) thinning** ([`thin_grid`]) partitions space into cubic
8//!   voxels of side `cell_size` and keeps the *first* point that falls into
9//!   each voxel (in input order). This produces a roughly uniform sub-sample
10//!   at the resolution of the voxel grid and is the standard approach used by
11//!   PDAL's `filters.sample` and CloudCompare's "subsample with octree".
12//! - **Random thinning** ([`thin_random`]) uniformly draws `target_count`
13//!   indices without replacement via a deterministic Fisher-Yates shuffle.
14//!   The shuffle is driven by a linear congruential generator (LCG) using
15//!   Knuth's MMIX constants — this satisfies the project policy of *no
16//!   `rand`-crate dependency* while remaining reproducible across runs and
17//!   platforms.
18//! - **Poisson-disk thinning** ([`thin_poisson_disk`]) implements Bridson's
19//!   spatial-hash dart-throwing in 3D: every candidate point is accepted only
20//!   if no previously kept point lies within `min_distance` of it. The spatial
21//!   hash uses cubic buckets of side `min_distance`, so each acceptance test
22//!   examines at most the 27 neighbouring buckets — giving expected O(N)
23//!   runtime, an order of magnitude faster than a naive O(N^2) sweep.
24//!
25//! All three operators are deterministic functions of their inputs (the random
26//! and Poisson-disk variants additionally take a `seed`), preserve input order
27//! in their output and return owned `Vec<ThinPoint3>` so the caller may freely
28//! mutate the result. Thinning statistics ([`ThinningStats`]) can be obtained
29//! in one call via [`thin_with_stats`].
30//!
31//! # Examples
32//!
33//! ```
34//! use oxigdal_algorithms::raster::{ThinPoint3, ThinningMethod, thin_with_stats};
35//!
36//! let points = vec![
37//!     ThinPoint3::new(0.0, 0.0, 0.0),
38//!     ThinPoint3::new(0.1, 0.2, 0.3),
39//!     ThinPoint3::new(5.0, 5.0, 5.0),
40//! ];
41//! let (kept, stats) = thin_with_stats(
42//!     &points,
43//!     ThinningMethod::Grid { cell_size: 1.0 },
44//! );
45//! assert_eq!(stats.input_count, 3);
46//! assert_eq!(stats.kept_count, kept.len());
47//! ```
48
49use std::collections::HashMap;
50
51// ---------------------------------------------------------------------------
52// Public types
53// ---------------------------------------------------------------------------
54
55/// A 3D sample point used as input to point-cloud thinning operators.
56///
57/// `ThinPoint3` is intentionally a Plain-Old-Data struct: it carries no
58/// auxiliary attributes (intensity, classification, …) because the thinning
59/// operators only depend on the geometric coordinates. Callers that need to
60/// preserve attributes typically build a parallel `Vec<Attr>` indexed in the
61/// same order as their `Vec<ThinPoint3>` and re-index it using the order of
62/// the returned point vector.
63#[derive(Debug, Clone, Copy, PartialEq)]
64pub struct ThinPoint3 {
65    /// X coordinate (typically easting / planimetric east).
66    pub x: f64,
67    /// Y coordinate (typically northing / planimetric north).
68    pub y: f64,
69    /// Z coordinate (typically elevation).
70    pub z: f64,
71}
72
73impl ThinPoint3 {
74    /// Construct a new [`ThinPoint3`] from its three coordinates.
75    pub fn new(x: f64, y: f64, z: f64) -> Self {
76        Self { x, y, z }
77    }
78}
79
80/// Selection of thinning algorithm and its parameters.
81///
82/// This enum is the input to [`thin_with_stats`]; each variant carries the
83/// parameters needed by exactly one of the public thinning functions.
84#[derive(Debug, Clone, Copy)]
85pub enum ThinningMethod {
86    /// Voxel-bucket thinning with cubic voxels of side `cell_size`. Equivalent
87    /// to PDAL's `filters.sample` with a uniform grid.
88    Grid {
89        /// Voxel side length in the same units as the input coordinates. Must
90        /// be strictly positive; non-positive values cause the input to be
91        /// returned unchanged.
92        cell_size: f64,
93    },
94    /// Random sub-sampling that keeps exactly `target_count` points (or fewer
95    /// when the input is smaller) via a deterministic Fisher-Yates shuffle.
96    Random {
97        /// Desired number of output points. If `target_count >= points.len()`
98        /// the input is returned unchanged.
99        target_count: usize,
100        /// LCG seed; identical seeds produce identical outputs.
101        seed: u64,
102    },
103    /// Poisson-disk thinning ensuring every pair of kept points is at least
104    /// `min_distance` apart in 3D Euclidean distance.
105    PoissonDisk {
106        /// Minimum Euclidean separation between any two kept points. Must be
107        /// strictly positive; non-positive values cause the input to be
108        /// returned unchanged.
109        min_distance: f64,
110        /// LCG seed used to permute input order before greedy acceptance.
111        seed: u64,
112    },
113}
114
115/// Summary statistics for a single thinning invocation.
116#[derive(Debug, Clone, Copy, Default)]
117pub struct ThinningStats {
118    /// Number of points in the input cloud.
119    pub input_count: usize,
120    /// Number of points in the output cloud after thinning.
121    pub kept_count: usize,
122    /// Fraction of input points discarded, in `[0, 1]`. Defined as
123    /// `1 - kept_count / input_count`; zero when `input_count == 0`.
124    pub reduction_ratio: f64,
125}
126
127impl ThinningStats {
128    /// Construct a [`ThinningStats`] from the input/output cardinalities,
129    /// computing the reduction ratio.
130    ///
131    /// `reduction_ratio` is `0.0` when `input_count == 0` to avoid a
132    /// division-by-zero, and is otherwise clamped to `[0, 1]` by construction
133    /// (because `kept_count <= input_count` for every operator in this
134    /// module).
135    pub fn new(input_count: usize, kept_count: usize) -> Self {
136        let reduction_ratio = if input_count == 0 {
137            0.0
138        } else {
139            1.0 - (kept_count as f64 / input_count as f64)
140        };
141        Self {
142            input_count,
143            kept_count,
144            reduction_ratio,
145        }
146    }
147}
148
149// ---------------------------------------------------------------------------
150// Deterministic LCG (no `rand` crate dependency)
151// ---------------------------------------------------------------------------
152
153/// One step of Knuth's MMIX 64-bit linear congruential generator.
154///
155/// `x_{n+1} = a * x_n + c` with `a = 6364136223846793005` and
156/// `c = 1442695040888963407`. Both constants are taken from Knuth's *Art of
157/// Computer Programming* Vol. 2 §3.3.4, Table 1, line 26 ("MMIX") and yield a
158/// full period of 2^64. Wrapping arithmetic is intentional: modular reduction
159/// modulo 2^64 is part of the LCG definition.
160#[inline]
161fn lcg_next(state: u64) -> u64 {
162    state
163        .wrapping_mul(6364136223846793005)
164        .wrapping_add(1442695040888963407)
165}
166
167/// In-place Fisher-Yates shuffle driven by [`lcg_next`].
168///
169/// The top 32 bits of the LCG state are used to select the swap target, since
170/// the low-order bits of a power-of-two-modulus LCG have short periods (this
171/// is a well-known weakness of LCGs documented by L'Ecuyer 1990 and others).
172/// The `seed.wrapping_add(1)` step prevents the degenerate fixed point at
173/// `state == 0`, which would otherwise collapse the shuffle to the identity.
174fn lcg_shuffle<T>(slice: &mut [T], seed: u64) {
175    if slice.len() < 2 {
176        return;
177    }
178    let mut state = seed.wrapping_add(1);
179    for i in (1..slice.len()).rev() {
180        state = lcg_next(state);
181        let j = ((state >> 32) as usize) % (i + 1);
182        slice.swap(i, j);
183    }
184}
185
186// ---------------------------------------------------------------------------
187// Grid (voxel) thinning
188// ---------------------------------------------------------------------------
189
190/// Grid (voxel) thinning: keep one point per cubic voxel of side `cell_size`.
191///
192/// The voxel containing a point `(x, y, z)` is identified by the integer
193/// triple `(floor(x / s), floor(y / s), floor(z / s))` where `s == cell_size`.
194/// The *first* point (in input order) that lands in each voxel is kept; later
195/// arrivals are silently discarded. This is the same convention used by
196/// PDAL's `filters.sample` and is preferred over "voxel centroid" thinning
197/// when the caller needs to preserve original point identities (for example
198/// to carry classification or RGB attributes through the thinning step).
199///
200/// Edge cases:
201///
202/// - Empty input returns an empty `Vec`.
203/// - `cell_size <= 0.0` returns a copy of the input (no thinning).
204/// - Points with non-finite coordinates are bucketed by `floor(NaN) as i64`,
205///   which the standard library defines as `i64::MIN` for NaN — so all NaN
206///   points map into the same degenerate voxel and only the first is kept.
207///
208/// # Examples
209///
210/// ```
211/// use oxigdal_algorithms::raster::{ThinPoint3, thin_grid};
212///
213/// // Three points in the same unit voxel collapse to one.
214/// let pts = vec![
215///     ThinPoint3::new(0.1, 0.1, 0.1),
216///     ThinPoint3::new(0.5, 0.5, 0.5),
217///     ThinPoint3::new(0.9, 0.9, 0.9),
218/// ];
219/// let kept = thin_grid(&pts, 1.0);
220/// assert_eq!(kept.len(), 1);
221/// assert_eq!(kept[0], ThinPoint3::new(0.1, 0.1, 0.1));
222/// ```
223pub fn thin_grid(points: &[ThinPoint3], cell_size: f64) -> Vec<ThinPoint3> {
224    if points.is_empty() || cell_size <= 0.0 || !cell_size.is_finite() {
225        return points.to_vec();
226    }
227    let mut buckets: HashMap<(i64, i64, i64), usize> = HashMap::with_capacity(points.len());
228    let mut keep_indices: Vec<usize> = Vec::new();
229    for (i, p) in points.iter().enumerate() {
230        let key = voxel_key(p, cell_size);
231        if let std::collections::hash_map::Entry::Vacant(e) = buckets.entry(key) {
232            e.insert(i);
233            keep_indices.push(i);
234        }
235    }
236    keep_indices.into_iter().map(|i| points[i]).collect()
237}
238
239/// Map a point to its containing voxel under voxel side `cell_size`.
240#[inline]
241fn voxel_key(p: &ThinPoint3, cell_size: f64) -> (i64, i64, i64) {
242    (
243        (p.x / cell_size).floor() as i64,
244        (p.y / cell_size).floor() as i64,
245        (p.z / cell_size).floor() as i64,
246    )
247}
248
249// ---------------------------------------------------------------------------
250// Random thinning
251// ---------------------------------------------------------------------------
252
253/// Random thinning: keep `target_count` points selected uniformly without
254/// replacement via a deterministic Fisher-Yates shuffle.
255///
256/// The shuffle is driven by an internal LCG (`lcg_next`) rather than the
257/// `rand` crate, in keeping with the project's *no `rand`* dependency policy.
258/// Identical `seed` values produce identical outputs across runs and across
259/// platforms (the LCG state is a `u64` and only `wrapping_*` arithmetic is
260/// used). Output points are returned in input order — the shuffle is applied
261/// to an index vector, the first `target_count` indices are retained, and the
262/// final point vector is materialised in the original order.
263///
264/// Edge cases:
265///
266/// - Empty input returns an empty `Vec`.
267/// - `target_count >= points.len()` returns a copy of the input (no thinning).
268/// - `target_count == 0` returns an empty `Vec`.
269///
270/// # Examples
271///
272/// ```
273/// use oxigdal_algorithms::raster::{ThinPoint3, thin_random};
274///
275/// let pts: Vec<_> = (0..100)
276///     .map(|i| ThinPoint3::new(i as f64, 0.0, 0.0))
277///     .collect();
278/// let a = thin_random(&pts, 10, 0xC0FFEE);
279/// let b = thin_random(&pts, 10, 0xC0FFEE);
280/// assert_eq!(a, b); // deterministic for a given seed
281/// assert_eq!(a.len(), 10);
282/// ```
283pub fn thin_random(points: &[ThinPoint3], target_count: usize, seed: u64) -> Vec<ThinPoint3> {
284    if points.is_empty() {
285        return Vec::new();
286    }
287    if target_count >= points.len() {
288        return points.to_vec();
289    }
290    if target_count == 0 {
291        return Vec::new();
292    }
293
294    let mut indices: Vec<usize> = (0..points.len()).collect();
295    lcg_shuffle(&mut indices, seed);
296    indices.truncate(target_count);
297    indices.sort_unstable();
298    indices.into_iter().map(|i| points[i]).collect()
299}
300
301// ---------------------------------------------------------------------------
302// Poisson-disk thinning
303// ---------------------------------------------------------------------------
304
305/// Poisson-disk thinning: keep a maximal greedy subset such that every pair
306/// of kept points is at least `min_distance` apart in 3D Euclidean distance.
307///
308/// This is the spatial-hash variant of Bridson's 2007 dart-throwing
309/// algorithm specialised to the case where the candidate pool is a fixed,
310/// pre-existing point set (so the "active list" reduces to the shuffled
311/// input order). The spatial hash uses cubic buckets of side `min_distance`:
312/// any kept point within `min_distance` of a candidate must lie in one of the
313/// 27 buckets adjacent to the candidate's own bucket (including the
314/// candidate's bucket itself), so each acceptance test inspects at most 27
315/// buckets of constant expected occupancy — yielding expected `O(N)`
316/// runtime on uniformly distributed inputs.
317///
318/// The input order is randomised by an LCG-driven Fisher-Yates shuffle
319/// (see `lcg_shuffle`) before greedy acceptance, so identical `seed`
320/// values produce identical outputs while avoiding the directional bias
321/// of always favouring early input points. Output points are returned in
322/// original input order: after the greedy pass the kept indices are
323/// sorted ascending and the point vector is materialised from that order.
324///
325/// Edge cases:
326///
327/// - Empty input returns an empty `Vec`.
328/// - `min_distance <= 0.0` returns a copy of the input (no thinning).
329///
330/// # Complexity
331///
332/// Expected `O(N)` time and `O(N)` extra space (the spatial hash and kept-index
333/// vector together). Worst case (all points colliding into one bucket) is
334/// `O(N^2)`, but this requires pathological clustering at the bucket scale.
335///
336/// # Examples
337///
338/// ```
339/// use oxigdal_algorithms::raster::{ThinPoint3, thin_poisson_disk};
340///
341/// // Eight points along a line, separated by 0.1: at min_distance=0.25 we
342/// // keep one in three (roughly).
343/// let pts: Vec<_> = (0..8)
344///     .map(|i| ThinPoint3::new(0.1 * i as f64, 0.0, 0.0))
345///     .collect();
346/// let kept = thin_poisson_disk(&pts, 0.25, 42);
347/// assert!(kept.len() < pts.len());
348/// // Every pair is at least 0.25 apart.
349/// for (i, p) in kept.iter().enumerate() {
350///     for q in &kept[i + 1..] {
351///         let d = ((p.x - q.x).powi(2) + (p.y - q.y).powi(2) + (p.z - q.z).powi(2)).sqrt();
352///         assert!(d >= 0.25);
353///     }
354/// }
355/// ```
356pub fn thin_poisson_disk(points: &[ThinPoint3], min_distance: f64, seed: u64) -> Vec<ThinPoint3> {
357    if points.is_empty() {
358        return Vec::new();
359    }
360    if min_distance <= 0.0 || !min_distance.is_finite() {
361        return points.to_vec();
362    }
363
364    let cell_size = min_distance;
365    let min_dist_sq = min_distance * min_distance;
366
367    let mut shuffled_indices: Vec<usize> = (0..points.len()).collect();
368    lcg_shuffle(&mut shuffled_indices, seed);
369
370    let mut buckets: HashMap<(i64, i64, i64), Vec<usize>> = HashMap::new();
371    let mut kept: Vec<usize> = Vec::new();
372
373    for &i in &shuffled_indices {
374        let p = points[i];
375        let (kx, ky, kz) = voxel_key(&p, cell_size);
376
377        // Check the 27 buckets adjacent to (kx, ky, kz) (including itself) for
378        // any kept point closer than `min_distance`.
379        let mut too_close = false;
380        'outer: for dx in -1..=1i64 {
381            for dy in -1..=1i64 {
382                for dz in -1..=1i64 {
383                    if let Some(bucket) = buckets.get(&(kx + dx, ky + dy, kz + dz)) {
384                        for &j in bucket {
385                            let q = points[j];
386                            let ex = p.x - q.x;
387                            let ey = p.y - q.y;
388                            let ez = p.z - q.z;
389                            if ex * ex + ey * ey + ez * ez < min_dist_sq {
390                                too_close = true;
391                                break 'outer;
392                            }
393                        }
394                    }
395                }
396            }
397        }
398
399        if !too_close {
400            buckets.entry((kx, ky, kz)).or_default().push(i);
401            kept.push(i);
402        }
403    }
404
405    // Preserve the original input order in the output.
406    kept.sort_unstable();
407    kept.into_iter().map(|i| points[i]).collect()
408}
409
410// ---------------------------------------------------------------------------
411// Dispatcher with stats
412// ---------------------------------------------------------------------------
413
414/// Dispatch [`ThinningMethod`] and return both the thinned point set and a
415/// [`ThinningStats`] summary.
416///
417/// This is the recommended entry point for callers that need to report on
418/// how aggressive the thinning was, or that want to switch algorithms based
419/// on configuration without duplicating the dispatching boilerplate.
420///
421/// # Examples
422///
423/// ```
424/// use oxigdal_algorithms::raster::{ThinPoint3, ThinningMethod, thin_with_stats};
425///
426/// let pts: Vec<_> = (0..1000)
427///     .map(|i| ThinPoint3::new(i as f64, 0.0, 0.0))
428///     .collect();
429/// let (kept, stats) = thin_with_stats(
430///     &pts,
431///     ThinningMethod::Random { target_count: 100, seed: 7 },
432/// );
433/// assert_eq!(stats.input_count, 1000);
434/// assert_eq!(stats.kept_count, 100);
435/// assert!((stats.reduction_ratio - 0.9).abs() < 1e-12);
436/// assert_eq!(kept.len(), 100);
437/// ```
438pub fn thin_with_stats(
439    points: &[ThinPoint3],
440    method: ThinningMethod,
441) -> (Vec<ThinPoint3>, ThinningStats) {
442    let out = match method {
443        ThinningMethod::Grid { cell_size } => thin_grid(points, cell_size),
444        ThinningMethod::Random { target_count, seed } => thin_random(points, target_count, seed),
445        ThinningMethod::PoissonDisk { min_distance, seed } => {
446            thin_poisson_disk(points, min_distance, seed)
447        }
448    };
449    let stats = ThinningStats::new(points.len(), out.len());
450    (out, stats)
451}
452
453// ---------------------------------------------------------------------------
454// Unit tests
455// ---------------------------------------------------------------------------
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    #[test]
462    fn lcg_next_is_deterministic() {
463        let a = lcg_next(42);
464        let b = lcg_next(42);
465        assert_eq!(a, b);
466    }
467
468    #[test]
469    fn lcg_shuffle_is_seed_deterministic() {
470        let mut a: Vec<usize> = (0..100).collect();
471        let mut b: Vec<usize> = (0..100).collect();
472        lcg_shuffle(&mut a, 0xDEADBEEF);
473        lcg_shuffle(&mut b, 0xDEADBEEF);
474        assert_eq!(a, b);
475    }
476
477    #[test]
478    fn lcg_shuffle_permutes_values() {
479        let mut a: Vec<usize> = (0..100).collect();
480        lcg_shuffle(&mut a, 7);
481        let mut sorted = a.clone();
482        sorted.sort_unstable();
483        assert_eq!(sorted, (0..100).collect::<Vec<_>>());
484    }
485
486    #[test]
487    fn thinning_stats_zero_input() {
488        let s = ThinningStats::new(0, 0);
489        assert_eq!(s.input_count, 0);
490        assert_eq!(s.kept_count, 0);
491        assert_eq!(s.reduction_ratio, 0.0);
492    }
493
494    #[test]
495    fn thinning_stats_half_kept() {
496        let s = ThinningStats::new(100, 50);
497        assert!((s.reduction_ratio - 0.5).abs() < 1e-12);
498    }
499
500    #[test]
501    fn grid_negative_cell_size_returns_input() {
502        let pts = vec![
503            ThinPoint3::new(0.0, 0.0, 0.0),
504            ThinPoint3::new(1.0, 1.0, 1.0),
505        ];
506        let out = thin_grid(&pts, -1.0);
507        assert_eq!(out, pts);
508    }
509}