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 { width, height, points, is_dense: false }
66    }
67
68    /// Construct from a flat row-major buffer of `Option<T>`.
69    ///
70    /// Returns `None` if `points.len() != width * height`.
71    pub fn from_points(width: usize, height: usize, points: Vec<Option<T>>) -> Option<Self> {
72        if points.len() != width * height {
73            return None;
74        }
75        let is_dense = points.iter().all(|p| p.is_some());
76        Some(Self { width, height, points, is_dense })
77    }
78
79    /// Total number of grid cells (`width * height`), including empty ones.
80    #[inline]
81    pub fn len(&self) -> usize {
82        self.points.len()
83    }
84
85    /// `true` iff the grid has no cells.
86    #[inline]
87    pub fn is_empty(&self) -> bool {
88        self.points.is_empty()
89    }
90
91    /// Number of `Some` cells.
92    pub fn valid_count(&self) -> usize {
93        self.points.iter().filter(|p| p.is_some()).count()
94    }
95
96    /// Convert a `(row, col)` pair into a linear row-major index.
97    #[inline]
98    fn idx(&self, row: usize, col: usize) -> Option<usize> {
99        if row < self.height && col < self.width {
100            Some(row * self.width + col)
101        } else {
102            None
103        }
104    }
105
106    /// Borrow the point at `(row, col)`, if any. Returns `None` for both
107    /// out-of-bounds indices and empty cells.
108    pub fn get(&self, row: usize, col: usize) -> Option<&T> {
109        self.idx(row, col).and_then(|i| self.points[i].as_ref())
110    }
111
112    /// Mutable borrow of the point at `(row, col)`.
113    pub fn get_mut(&mut self, row: usize, col: usize) -> Option<&mut T> {
114        let i = self.idx(row, col)?;
115        self.points[i].as_mut()
116    }
117
118    /// Set a point at `(row, col)`. Returns `false` if the indices are out of bounds.
119    /// Updates `is_dense` conservatively (set to `false` whenever a `None` is written).
120    pub fn set(&mut self, row: usize, col: usize, value: Option<T>) -> bool {
121        let Some(i) = self.idx(row, col) else { return false };
122        if value.is_none() {
123            self.is_dense = false;
124        }
125        self.points[i] = value;
126        true
127    }
128
129    /// Slice of all cells in the given row (out-of-bounds → empty slice).
130    pub fn row(&self, row: usize) -> &[Option<T>] {
131        if row >= self.height {
132            return &[];
133        }
134        let start = row * self.width;
135        &self.points[start..start + self.width]
136    }
137
138    /// Alias for [`row`] — LiDAR users typically call rows "rings".
139    pub fn ring(&self, ring_index: usize) -> &[Option<T>] {
140        self.row(ring_index)
141    }
142
143    /// Iterator over `(row, col, &T)` for all populated cells.
144    pub fn iter_valid(&self) -> impl Iterator<Item = (usize, usize, &T)> {
145        let width = self.width;
146        self.points.iter().enumerate().filter_map(move |(i, opt)| {
147            opt.as_ref().map(|p| (i / width, i % width, p))
148        })
149    }
150
151    /// Recompute `is_dense` from the current cell contents.
152    pub fn refresh_dense_flag(&mut self) {
153        self.is_dense = self.points.iter().all(|p| p.is_some());
154    }
155}
156
157impl<T: Clone> OrganizedPointCloud<T> {
158    /// Drop `None` cells and return an unorganized cloud preserving point order.
159    pub fn to_unorganized(&self) -> PointCloud<T> {
160        let pts: Vec<T> = self.points.iter().filter_map(|p| p.clone()).collect();
161        PointCloud::from_points(pts)
162    }
163}
164
165impl OrganizedPointCloud<Point3f> {
166    /// Build an organized cloud by back-projecting a row-major depth image
167    /// through pinhole intrinsics. Pixels with a depth of `0` (or NaN after
168    /// scaling) are stored as `None`.
169    ///
170    /// Returns `None` if `depth.len() != width * height`.
171    pub fn from_depth_image(
172        depth: &[u16],
173        width: usize,
174        height: usize,
175        intrinsics: &CameraIntrinsics,
176        depth_scale: f32,
177    ) -> Option<Self> {
178        if depth.len() != width * height {
179            return None;
180        }
181        let mut points: Vec<Option<Point3f>> = Vec::with_capacity(width * height);
182        let mut any_invalid = false;
183        for row in 0..height {
184            for col in 0..width {
185                let d = depth[row * width + col];
186                if d == 0 {
187                    points.push(None);
188                    any_invalid = true;
189                    continue;
190                }
191                let z = d as f32 * depth_scale;
192                if !z.is_finite() || z <= 0.0 {
193                    points.push(None);
194                    any_invalid = true;
195                    continue;
196                }
197                let x = (col as f32 - intrinsics.cx) * z / intrinsics.fx;
198                let y = (row as f32 - intrinsics.cy) * z / intrinsics.fy;
199                points.push(Some(Point3f::new(x, y, z)));
200            }
201        }
202        Some(Self { width, height, points, is_dense: !any_invalid })
203    }
204}
205
206impl<T> Default for OrganizedPointCloud<T> {
207    fn default() -> Self {
208        Self { width: 0, height: 0, points: Vec::new(), is_dense: true }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn new_grid_is_empty_and_not_dense() {
218        let cloud: OrganizedPointCloud<Point3f> = OrganizedPointCloud::new(4, 3);
219        assert_eq!(cloud.width, 4);
220        assert_eq!(cloud.height, 3);
221        assert_eq!(cloud.len(), 12);
222        assert_eq!(cloud.valid_count(), 0);
223        assert!(!cloud.is_dense);
224    }
225
226    #[test]
227    fn get_set_round_trip() {
228        let mut cloud: OrganizedPointCloud<Point3f> = OrganizedPointCloud::new(3, 2);
229        assert!(cloud.set(1, 2, Some(Point3f::new(1.0, 2.0, 3.0))));
230        let p = cloud.get(1, 2).unwrap();
231        assert_eq!(*p, Point3f::new(1.0, 2.0, 3.0));
232        // Out of bounds:
233        assert!(cloud.get(2, 0).is_none());
234        assert!(!cloud.set(5, 5, Some(Point3f::new(0.0, 0.0, 0.0))));
235    }
236
237    #[test]
238    fn row_alias_ring() {
239        let mut cloud: OrganizedPointCloud<Point3f> = OrganizedPointCloud::new(3, 2);
240        cloud.set(0, 0, Some(Point3f::new(0.0, 0.0, 0.0)));
241        cloud.set(0, 1, Some(Point3f::new(1.0, 0.0, 0.0)));
242        cloud.set(0, 2, Some(Point3f::new(2.0, 0.0, 0.0)));
243        let r = cloud.row(0);
244        assert_eq!(r.len(), 3);
245        assert!(r.iter().all(|p| p.is_some()));
246        let ring = cloud.ring(0);
247        assert_eq!(ring.len(), 3);
248        // Out of bounds row → empty slice.
249        assert_eq!(cloud.row(99).len(), 0);
250    }
251
252    #[test]
253    fn to_unorganized_drops_none() {
254        let mut cloud: OrganizedPointCloud<Point3f> = OrganizedPointCloud::new(2, 2);
255        cloud.set(0, 0, Some(Point3f::new(1.0, 0.0, 0.0)));
256        cloud.set(1, 1, Some(Point3f::new(0.0, 1.0, 0.0)));
257        let flat = cloud.to_unorganized();
258        assert_eq!(flat.len(), 2);
259    }
260
261    #[test]
262    fn from_points_dense_flag() {
263        let pts = vec![
264            Some(Point3f::new(0.0, 0.0, 0.0)),
265            Some(Point3f::new(1.0, 0.0, 0.0)),
266            Some(Point3f::new(0.0, 1.0, 0.0)),
267            Some(Point3f::new(0.0, 0.0, 1.0)),
268        ];
269        let cloud = OrganizedPointCloud::from_points(2, 2, pts).unwrap();
270        assert!(cloud.is_dense);
271        assert_eq!(cloud.valid_count(), 4);
272
273        let bad = OrganizedPointCloud::<Point3f>::from_points(2, 2, vec![None; 3]);
274        assert!(bad.is_none());
275    }
276
277    #[test]
278    fn from_depth_image_basic() {
279        // 2×2 depth image with one missing pixel.
280        let depth: Vec<u16> = vec![1000, 0, 2000, 1500];
281        let intr = CameraIntrinsics::new(525.0, 525.0, 0.5, 0.5);
282        let cloud = OrganizedPointCloud::from_depth_image(&depth, 2, 2, &intr, 0.001).unwrap();
283        assert_eq!(cloud.width, 2);
284        assert_eq!(cloud.height, 2);
285        assert!(!cloud.is_dense);
286        // Pixel (0, 1) had depth 0 → None.
287        assert!(cloud.get(0, 1).is_none());
288        // Pixel (0, 0) depth 1.0 m, x = (0-0.5)*1/525, y = (0-0.5)*1/525, z = 1.0
289        let p = cloud.get(0, 0).unwrap();
290        assert!((p.z - 1.0).abs() < 1e-6);
291        assert!((p.x + 0.5 / 525.0).abs() < 1e-6);
292    }
293
294    #[test]
295    fn iter_valid_yields_indexed_points() {
296        let mut cloud: OrganizedPointCloud<Point3f> = OrganizedPointCloud::new(2, 2);
297        cloud.set(1, 0, Some(Point3f::new(7.0, 0.0, 0.0)));
298        let collected: Vec<(usize, usize)> = cloud.iter_valid().map(|(r, c, _)| (r, c)).collect();
299        assert_eq!(collected, vec![(1, 0)]);
300    }
301}