Skip to main content

rigidity_core/
voxel.rs

1//! Voxel downsampling with a bit-for-bit reproducible result.
2
3use nalgebra::Vector3;
4use rayon::prelude::*;
5
6use crate::cloud::{CloudError, PointCloud};
7
8/// A voxel key. `i64` rather than `i32`: with a fine voxel and
9/// georeferenced coordinates 32 bits overflow, and an overflow here
10/// silently merges distant points into a single cell.
11type VoxelKey = [i64; 3];
12
13/// Downsamples a cloud onto a grid of cells with edge `voxel_size`,
14/// replacing the points of each occupied cell by their centroid.
15///
16/// # Determinism
17///
18/// The result is bit-for-bit identical at any thread count, both in values
19/// and in point order. This is a requirement, not a side effect: further up
20/// the stack these points assemble the Jacobian, and a floating order of
21/// summation would make the smallest singular value float — that is, the
22/// degeneracy detector itself.
23///
24/// Three decisions achieve it:
25/// 1. points are sorted by the key `(cell, original index)`, which is a
26///    total order, so the sorted sequence is unique regardless of the
27///    sorting algorithm;
28/// 2. within a cell, accumulation runs sequentially in increasing original
29///    index, so the order of summation is fixed;
30/// 3. parallelism happens only between cells, which are independent.
31///
32/// A hash table instead of the sort would be faster, but neither its
33/// traversal order nor its order of merging partial sums is defined.
34///
35/// # Attributes
36///
37/// Attributes are not carried over: averaging depends on what the column
38/// means — colour averages, a class label does not. The returned cloud has
39/// coordinates only.
40pub fn voxel_downsample(cloud: &PointCloud, voxel_size: f64) -> Result<PointCloud, CloudError> {
41    voxel_downsample_observed(cloud, voxel_size, |_, _| {})
42}
43
44/// The same, reporting progress after each internal phase.
45///
46/// `progress` receives `(completed, total)` in phases, not in points:
47/// the work is a sequence of whole-cloud passes — keys, sort, cell
48/// boundaries, centroids — and none of them can report a fraction of
49/// itself without giving up the parallelism that makes it fast.
50///
51/// Nothing guarantees the callback ever runs: a cloud that is empty or
52/// rejected returns before the first phase. Completion is signalled by the
53/// function returning, not by a final call.
54pub fn voxel_downsample_observed<F>(
55    cloud: &PointCloud,
56    voxel_size: f64,
57    mut progress: F,
58) -> Result<PointCloud, CloudError>
59where
60    F: FnMut(usize, usize),
61{
62    /// Keys, sort, cell boundaries, centroids.
63    const PHASES: usize = 4;
64
65    if !(voxel_size.is_finite() && voxel_size > 0.0) {
66        return Err(CloudError::InvalidVoxelSize(voxel_size));
67    }
68    cloud.check_finite()?;
69
70    let count = cloud.len();
71    if count == 0 {
72        return Ok(PointCloud::with_origin(cloud.origin()));
73    }
74
75    let inverse_size = 1.0 / voxel_size;
76    let keys: Vec<VoxelKey> = (0..count)
77        .into_par_iter()
78        .map(|i| {
79            let p = cloud.local(i);
80            [
81                (p.x * inverse_size).floor() as i64,
82                (p.y * inverse_size).floor() as i64,
83                (p.z * inverse_size).floor() as i64,
84            ]
85        })
86        .collect();
87    progress(1, PHASES);
88
89    // The sort key includes the original index, so it is strict and the
90    // ordered sequence is unique.
91    let mut order: Vec<u32> = (0..count as u32).collect();
92    order.par_sort_unstable_by_key(|&i| (keys[i as usize], i));
93    progress(2, PHASES);
94
95    // Boundaries of the runs of equal keys.
96    let mut segments: Vec<(usize, usize)> = Vec::new();
97    let mut start = 0usize;
98    for i in 1..count {
99        if keys[order[i] as usize] != keys[order[start] as usize] {
100            segments.push((start, i));
101            start = i;
102        }
103    }
104    segments.push((start, count));
105    progress(3, PHASES);
106
107    // Across cells in parallel; within a cell strictly in order.
108    let centroids: Vec<Vector3<f64>> = segments
109        .par_iter()
110        .map(|&(begin, end)| {
111            let mut sum = Vector3::zeros();
112            for &index in &order[begin..end] {
113                sum += cloud.local(index as usize);
114            }
115            sum / (end - begin) as f64
116        })
117        .collect();
118
119    let mut result = PointCloud::with_capacity(centroids.len());
120    result.rebase(cloud.origin());
121    for centroid in centroids {
122        result.push(cloud.origin() + centroid);
123    }
124    progress(4, PHASES);
125    Ok(result)
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn grid_cloud(side: usize) -> PointCloud {
133        let mut cloud = PointCloud::with_capacity(side * side * side);
134        for i in 0..side {
135            for j in 0..side {
136                for k in 0..side {
137                    cloud.push(Vector3::new(i as f64 * 0.1, j as f64 * 0.1, k as f64 * 0.1));
138                }
139            }
140        }
141        cloud
142    }
143
144    #[test]
145    fn rejects_bad_voxel_size() {
146        let cloud = grid_cloud(2);
147        assert!(voxel_downsample(&cloud, 0.0).is_err());
148        assert!(voxel_downsample(&cloud, -1.0).is_err());
149        assert!(voxel_downsample(&cloud, f64::NAN).is_err());
150    }
151
152    #[test]
153    fn empty_cloud_stays_empty() {
154        let cloud = PointCloud::new();
155        assert!(voxel_downsample(&cloud, 1.0).unwrap().is_empty());
156    }
157
158    /// A cell larger than the whole cloud collapses it to a single point,
159    /// the centroid of the original coordinates.
160    #[test]
161    fn single_voxel_gives_centroid() {
162        let cloud = grid_cloud(4);
163        let reduced = voxel_downsample(&cloud, 100.0).unwrap();
164        assert_eq!(reduced.len(), 1);
165        let expected = Vector3::new(0.15, 0.15, 0.15);
166        // The tolerance is set by f32 storage, not by the algorithm: 0.1
167        // is not exactly representable, and the mean of four coordinates
168        // drifts from 0.15 by 4.1e-9. Nothing here can go below that floor
169        // by construction — accumulating in f64 removes the summation
170        // error, not the storage error.
171        let error = (reduced.point(0) - expected).norm();
172        assert!(error < 1e-6, "centroid deviation {error:.3e}");
173        assert!(
174            error > 1e-10,
175            "the f32 floor disappeared — check whether storage became f64"
176        );
177    }
178
179    /// The point count never grows, and every original point lies within
180    /// a cell diagonal of some output point.
181    #[test]
182    fn output_covers_input() {
183        let cloud = grid_cloud(6);
184        let size = 0.25;
185        let reduced = voxel_downsample(&cloud, size).unwrap();
186        assert!(reduced.len() < cloud.len());
187        let radius = size * 3f64.sqrt();
188        for i in 0..cloud.len() {
189            let p = cloud.point(i);
190            let nearest = (0..reduced.len())
191                .map(|j| (reduced.point(j) - p).norm())
192                .fold(f64::INFINITY, f64::min);
193            assert!(nearest <= radius, "point {i}: nearest at {nearest}");
194        }
195    }
196}