Skip to main content

threecrate_core/
organized_point_cloud.rs

1//! Organized (grid-structured) point clouds.
2//!
3//! Most LiDAR sensors and depth cameras produce points laid out in a 2D
4//! `width × height` grid: rotating LiDARs give one row per laser ring, depth
5//! cameras give one row per pixel row. Preserving that structure makes a
6//! number of algorithms trivial — scan-line normal estimation, range-image
7//! segmentation, ring-based ground segmentation, O(1) neighbor lookup by
8//! `(row±1, col±1)`. This module provides [`OrganizedPointCloud`], a
9//! row-major grid of `Option<T>` where `None` represents missing/invalid
10//! returns (e.g. NaN points in a `sensor_msgs/PointCloud2`).
11//!
12//! `OrganizedPointCloud` is additive — the existing [`PointCloud`] remains
13//! the primary container for unorganized data, and you can drop down to it
14//! via [`OrganizedPointCloud::to_unorganized`].
15
16use crate::point::Point3f;
17use crate::point_cloud::PointCloud;
18use serde::{Deserialize, Serialize};
19
20/// A point cloud whose points sit in a `width × height` grid in row-major order.
21///
22/// Cells holding `None` represent missing returns. Iteration via
23/// [`OrganizedPointCloud::iter_valid`] yields only the populated cells.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct OrganizedPointCloud<T> {
26    /// Points per row.
27    pub width: usize,
28    /// Number of rows (LiDAR rings or image rows).
29    pub height: usize,
30    /// Row-major storage of length `width * height`.
31    pub points: Vec<Option<T>>,
32    /// `true` iff every cell is `Some`. Mirrors `sensor_msgs/PointCloud2::is_dense`.
33    pub is_dense: bool,
34}
35
36/// Pinhole camera intrinsics for depth-image projection.
37#[derive(Debug, Clone, Copy)]
38pub struct CameraIntrinsics {
39    /// Focal length in pixels (x).
40    pub fx: f32,
41    /// Focal length in pixels (y).
42    pub fy: f32,
43    /// Principal point x (pixels).
44    pub cx: f32,
45    /// Principal point y (pixels).
46    pub cy: f32,
47}
48
49impl CameraIntrinsics {
50    /// Construct from four values.
51    pub fn new(fx: f32, fy: f32, cx: f32, cy: f32) -> Self {
52        Self { fx, fy, cx, cy }
53    }
54}
55
56impl<T> OrganizedPointCloud<T> {
57    /// Create an empty organized cloud with the given dimensions; every cell
58    /// starts as `None` and `is_dense = false`.
59    pub fn new(width: usize, height: usize) -> Self
60    where
61        T: Clone,
62    {
63        let mut points = Vec::with_capacity(width * height);
64        points.resize_with(width * height, || None);
65        Self {
66            width,
67            height,
68            points,
69            is_dense: false,
70        }
71    }
72
73    /// Construct from a flat row-major buffer of `Option<T>`.
74    ///
75    /// Returns `None` if `points.len() != width * height`.
76    pub fn from_points(width: usize, height: usize, points: Vec<Option<T>>) -> Option<Self> {
77        if points.len() != width * height {
78            return None;
79        }
80        let is_dense = points.iter().all(|p| p.is_some());
81        Some(Self {
82            width,
83            height,
84            points,
85            is_dense,
86        })
87    }
88
89    /// Total number of grid cells (`width * height`), including empty ones.
90    #[inline]
91    pub fn len(&self) -> usize {
92        self.points.len()
93    }
94
95    /// `true` iff the grid has no cells.
96    #[inline]
97    pub fn is_empty(&self) -> bool {
98        self.points.is_empty()
99    }
100
101    /// Number of `Some` cells.
102    pub fn valid_count(&self) -> usize {
103        self.points.iter().filter(|p| p.is_some()).count()
104    }
105
106    /// Convert a `(row, col)` pair into a linear row-major index.
107    #[inline]
108    fn idx(&self, row: usize, col: usize) -> Option<usize> {
109        if row < self.height && col < self.width {
110            Some(row * self.width + col)
111        } else {
112            None
113        }
114    }
115
116    /// Borrow the point at `(row, col)`, if any. Returns `None` for both
117    /// out-of-bounds indices and empty cells.
118    pub fn get(&self, row: usize, col: usize) -> Option<&T> {
119        self.idx(row, col).and_then(|i| self.points[i].as_ref())
120    }
121
122    /// Mutable borrow of the point at `(row, col)`.
123    pub fn get_mut(&mut self, row: usize, col: usize) -> Option<&mut T> {
124        let i = self.idx(row, col)?;
125        self.points[i].as_mut()
126    }
127
128    /// Set a point at `(row, col)`. Returns `false` if the indices are out of bounds.
129    /// Updates `is_dense` conservatively (set to `false` whenever a `None` is written).
130    pub fn set(&mut self, row: usize, col: usize, value: Option<T>) -> bool {
131        let Some(i) = self.idx(row, col) else {
132            return false;
133        };
134        if value.is_none() {
135            self.is_dense = false;
136        }
137        self.points[i] = value;
138        true
139    }
140
141    /// Slice of all cells in the given row (out-of-bounds → empty slice).
142    pub fn row(&self, row: usize) -> &[Option<T>] {
143        if row >= self.height {
144            return &[];
145        }
146        let start = row * self.width;
147        &self.points[start..start + self.width]
148    }
149
150    /// Alias for [`row`] — LiDAR users typically call rows "rings".
151    pub fn ring(&self, ring_index: usize) -> &[Option<T>] {
152        self.row(ring_index)
153    }
154
155    /// Iterator over `(row, col, &T)` for all populated cells.
156    pub fn iter_valid(&self) -> impl Iterator<Item = (usize, usize, &T)> {
157        let width = self.width;
158        self.points
159            .iter()
160            .enumerate()
161            .filter_map(move |(i, opt)| opt.as_ref().map(|p| (i / width, i % width, p)))
162    }
163
164    /// Recompute `is_dense` from the current cell contents.
165    pub fn refresh_dense_flag(&mut self) {
166        self.is_dense = self.points.iter().all(|p| p.is_some());
167    }
168}
169
170impl<T: Clone> OrganizedPointCloud<T> {
171    /// Drop `None` cells and return an unorganized cloud preserving point order.
172    pub fn to_unorganized(&self) -> PointCloud<T> {
173        let pts: Vec<T> = self.points.iter().filter_map(|p| p.clone()).collect();
174        PointCloud::from_points(pts)
175    }
176}
177
178impl OrganizedPointCloud<Point3f> {
179    /// Build an organized cloud by back-projecting a row-major depth image
180    /// through pinhole intrinsics. Pixels with a depth of `0` (or NaN after
181    /// scaling) are stored as `None`.
182    ///
183    /// Returns `None` if `depth.len() != width * height`.
184    pub fn from_depth_image(
185        depth: &[u16],
186        width: usize,
187        height: usize,
188        intrinsics: &CameraIntrinsics,
189        depth_scale: f32,
190    ) -> Option<Self> {
191        if depth.len() != width * height {
192            return None;
193        }
194        let mut points: Vec<Option<Point3f>> = Vec::with_capacity(width * height);
195        let mut any_invalid = false;
196        for row in 0..height {
197            for col in 0..width {
198                let d = depth[row * width + col];
199                if d == 0 {
200                    points.push(None);
201                    any_invalid = true;
202                    continue;
203                }
204                let z = d as f32 * depth_scale;
205                if !z.is_finite() || z <= 0.0 {
206                    points.push(None);
207                    any_invalid = true;
208                    continue;
209                }
210                let x = (col as f32 - intrinsics.cx) * z / intrinsics.fx;
211                let y = (row as f32 - intrinsics.cy) * z / intrinsics.fy;
212                points.push(Some(Point3f::new(x, y, z)));
213            }
214        }
215        Some(Self {
216            width,
217            height,
218            points,
219            is_dense: !any_invalid,
220        })
221    }
222}
223
224impl<T> Default for OrganizedPointCloud<T> {
225    fn default() -> Self {
226        Self {
227            width: 0,
228            height: 0,
229            points: Vec::new(),
230            is_dense: true,
231        }
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn new_grid_is_empty_and_not_dense() {
241        let cloud: OrganizedPointCloud<Point3f> = OrganizedPointCloud::new(4, 3);
242        assert_eq!(cloud.width, 4);
243        assert_eq!(cloud.height, 3);
244        assert_eq!(cloud.len(), 12);
245        assert_eq!(cloud.valid_count(), 0);
246        assert!(!cloud.is_dense);
247    }
248
249    #[test]
250    fn get_set_round_trip() {
251        let mut cloud: OrganizedPointCloud<Point3f> = OrganizedPointCloud::new(3, 2);
252        assert!(cloud.set(1, 2, Some(Point3f::new(1.0, 2.0, 3.0))));
253        let p = cloud.get(1, 2).unwrap();
254        assert_eq!(*p, Point3f::new(1.0, 2.0, 3.0));
255        // Out of bounds:
256        assert!(cloud.get(2, 0).is_none());
257        assert!(!cloud.set(5, 5, Some(Point3f::new(0.0, 0.0, 0.0))));
258    }
259
260    #[test]
261    fn row_alias_ring() {
262        let mut cloud: OrganizedPointCloud<Point3f> = OrganizedPointCloud::new(3, 2);
263        cloud.set(0, 0, Some(Point3f::new(0.0, 0.0, 0.0)));
264        cloud.set(0, 1, Some(Point3f::new(1.0, 0.0, 0.0)));
265        cloud.set(0, 2, Some(Point3f::new(2.0, 0.0, 0.0)));
266        let r = cloud.row(0);
267        assert_eq!(r.len(), 3);
268        assert!(r.iter().all(|p| p.is_some()));
269        let ring = cloud.ring(0);
270        assert_eq!(ring.len(), 3);
271        // Out of bounds row → empty slice.
272        assert_eq!(cloud.row(99).len(), 0);
273    }
274
275    #[test]
276    fn to_unorganized_drops_none() {
277        let mut cloud: OrganizedPointCloud<Point3f> = OrganizedPointCloud::new(2, 2);
278        cloud.set(0, 0, Some(Point3f::new(1.0, 0.0, 0.0)));
279        cloud.set(1, 1, Some(Point3f::new(0.0, 1.0, 0.0)));
280        let flat = cloud.to_unorganized();
281        assert_eq!(flat.len(), 2);
282    }
283
284    #[test]
285    fn from_points_dense_flag() {
286        let pts = vec![
287            Some(Point3f::new(0.0, 0.0, 0.0)),
288            Some(Point3f::new(1.0, 0.0, 0.0)),
289            Some(Point3f::new(0.0, 1.0, 0.0)),
290            Some(Point3f::new(0.0, 0.0, 1.0)),
291        ];
292        let cloud = OrganizedPointCloud::from_points(2, 2, pts).unwrap();
293        assert!(cloud.is_dense);
294        assert_eq!(cloud.valid_count(), 4);
295
296        let bad = OrganizedPointCloud::<Point3f>::from_points(2, 2, vec![None; 3]);
297        assert!(bad.is_none());
298    }
299
300    #[test]
301    fn from_depth_image_basic() {
302        // 2×2 depth image with one missing pixel.
303        let depth: Vec<u16> = vec![1000, 0, 2000, 1500];
304        let intr = CameraIntrinsics::new(525.0, 525.0, 0.5, 0.5);
305        let cloud = OrganizedPointCloud::from_depth_image(&depth, 2, 2, &intr, 0.001).unwrap();
306        assert_eq!(cloud.width, 2);
307        assert_eq!(cloud.height, 2);
308        assert!(!cloud.is_dense);
309        // Pixel (0, 1) had depth 0 → None.
310        assert!(cloud.get(0, 1).is_none());
311        // Pixel (0, 0) depth 1.0 m, x = (0-0.5)*1/525, y = (0-0.5)*1/525, z = 1.0
312        let p = cloud.get(0, 0).unwrap();
313        assert!((p.z - 1.0).abs() < 1e-6);
314        assert!((p.x + 0.5 / 525.0).abs() < 1e-6);
315    }
316
317    #[test]
318    fn iter_valid_yields_indexed_points() {
319        let mut cloud: OrganizedPointCloud<Point3f> = OrganizedPointCloud::new(2, 2);
320        cloud.set(1, 0, Some(Point3f::new(7.0, 0.0, 0.0)));
321        let collected: Vec<(usize, usize)> = cloud.iter_valid().map(|(r, c, _)| (r, c)).collect();
322        assert_eq!(collected, vec![(1, 0)]);
323    }
324}