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}