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}