spatialrust_voxelize/
range_image.rs1use spatialrust_core::{HasPositions3, PointCloud, SpatialError, SpatialResult};
8
9#[derive(Clone, Copy, Debug, PartialEq)]
11pub struct RangeImageConfig {
12 pub width: usize,
14 pub height: usize,
16 pub fov_up_deg: f32,
18 pub fov_down_deg: f32,
20}
21
22impl Default for RangeImageConfig {
23 fn default() -> Self {
24 Self { width: 1024, height: 64, fov_up_deg: 3.0, fov_down_deg: -25.0 }
26 }
27}
28
29impl RangeImageConfig {
30 #[must_use]
32 pub fn new(width: usize, height: usize) -> Self {
33 Self { width, height, ..Self::default() }
34 }
35}
36
37#[derive(Clone, Debug, PartialEq)]
39pub struct RangeImage {
40 pub width: usize,
42 pub height: usize,
44 pub data: Vec<f32>,
46}
47
48impl RangeImage {
49 #[must_use]
51 pub fn len(&self) -> usize {
52 self.data.len()
53 }
54
55 #[must_use]
57 pub fn is_empty(&self) -> bool {
58 self.data.is_empty()
59 }
60
61 #[must_use]
63 pub fn filled_count(&self) -> usize {
64 self.data.iter().filter(|&&r| r > 0.0).count()
65 }
66
67 #[must_use]
69 pub fn get(&self, row: usize, col: usize) -> Option<f32> {
70 if row >= self.height || col >= self.width {
71 return None;
72 }
73 Some(self.data[row * self.width + col])
74 }
75}
76
77pub fn range_image(cloud: &PointCloud, config: RangeImageConfig) -> SpatialResult<RangeImage> {
80 if config.width == 0 || config.height == 0 {
81 return Err(SpatialError::InvalidArgument(
82 "range image dimensions must be non-zero".to_owned(),
83 ));
84 }
85 if config.fov_up_deg <= config.fov_down_deg {
86 return Err(SpatialError::InvalidArgument(
87 "fov_up must be greater than fov_down".to_owned(),
88 ));
89 }
90
91 let (x, y, z) = cloud.positions3()?;
92 let fov_up = config.fov_up_deg.to_radians();
93 let fov_down = config.fov_down_deg.to_radians();
94 let fov = fov_up - fov_down;
95
96 let mut data = vec![0.0_f32; config.width * config.height];
97 for i in 0..cloud.len() {
98 let (px, py, pz) = (x[i], y[i], z[i]);
99 let range = (px * px + py * py + pz * pz).sqrt();
100 if range < 1e-6 {
101 continue;
102 }
103 let azimuth = py.atan2(px); let elevation = (pz / range).asin(); let u = 0.5 * (azimuth / std::f32::consts::PI + 1.0);
108 let v = 1.0 - (elevation - fov_down) / fov;
109 let col = (u * config.width as f32).floor();
110 let row = (v * config.height as f32).floor();
111 if row < 0.0 || col < 0.0 {
112 continue;
113 }
114 let (row, col) = (row as usize, col as usize);
115 if row >= config.height || col >= config.width {
116 continue;
117 }
118 let idx = row * config.width + col;
119 if data[idx] == 0.0 || range < data[idx] {
121 data[idx] = range;
122 }
123 }
124
125 Ok(RangeImage { width: config.width, height: config.height, data })
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use spatialrust_core::{PointCloudBuilder, StandardSchemas};
132
133 fn cloud(points: &[[f32; 3]]) -> PointCloud {
134 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyz());
135 for p in points {
136 builder.push_point(*p).unwrap();
137 }
138 builder.build().unwrap()
139 }
140
141 #[test]
142 fn projects_points_into_pixels() {
143 let c = cloud(&[[5.0, 0.0, 0.0], [0.0, 5.0, 0.0]]);
146 let img = range_image(&c, RangeImageConfig::new(360, 64)).unwrap();
147 assert_eq!(img.filled_count(), 2);
148 let total: f32 = img.data.iter().filter(|&&r| r > 0.0).sum();
150 assert!((total - 10.0).abs() < 1e-3);
151 }
152
153 #[test]
154 fn keeps_nearest_range_per_pixel() {
155 let c = cloud(&[[10.0, 0.0, 0.0], [3.0, 0.0, 0.0]]);
157 let img = range_image(&c, RangeImageConfig::new(16, 8)).unwrap();
158 assert_eq!(img.filled_count(), 1);
159 let nearest = img.data.iter().copied().find(|&r| r > 0.0).unwrap();
160 assert!((nearest - 3.0).abs() < 1e-3, "did not keep nearest range: {nearest}");
161 }
162
163 #[test]
164 fn drops_points_outside_vertical_fov() {
165 let c = cloud(&[[0.0, 0.0, 5.0]]);
167 let config = RangeImageConfig { fov_up_deg: 2.0, fov_down_deg: -2.0, width: 64, height: 8 };
168 let img = range_image(&c, config).unwrap();
169 assert_eq!(img.filled_count(), 0);
170 }
171
172 #[test]
173 fn rejects_bad_config() {
174 let c = cloud(&[[1.0, 0.0, 0.0]]);
175 assert!(range_image(&c, RangeImageConfig::new(0, 8)).is_err());
176 let bad_fov = RangeImageConfig { fov_up_deg: -5.0, fov_down_deg: 5.0, width: 8, height: 8 };
177 assert!(range_image(&c, bad_fov).is_err());
178 }
179}