1use nalgebra::Isometry3;
27use rayon::prelude::*;
28use threecrate_core::{ColoredPoint3f, Error, Point3f, PointCloud, Result};
29
30#[derive(Debug, Clone, Copy)]
36pub struct CameraIntrinsics {
37 pub fx: f32,
39 pub fy: f32,
41 pub cx: f32,
43 pub cy: f32,
45}
46
47#[derive(Debug, Clone, Copy)]
49pub struct RgbImageView<'a> {
50 pub data: &'a [u8],
52 pub width: u32,
54 pub height: u32,
56}
57
58impl<'a> RgbImageView<'a> {
59 pub fn new(data: &'a [u8], width: u32, height: u32) -> Result<Self> {
61 let expected = (width as usize) * (height as usize) * 3;
62 if data.len() < expected {
63 return Err(Error::InvalidData(format!(
64 "RgbImageView: buffer too small — expected {} bytes for {}×{} RGB image, got {}",
65 expected,
66 width,
67 height,
68 data.len()
69 )));
70 }
71 Ok(Self {
72 data,
73 width,
74 height,
75 })
76 }
77
78 #[inline]
80 fn sample_nearest(&self, u: f32, v: f32) -> Option<[u8; 3]> {
81 let x = u.round() as i32;
82 let y = v.round() as i32;
83 if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 {
84 return None;
85 }
86 let idx = (y as usize * self.width as usize + x as usize) * 3;
87 Some([self.data[idx], self.data[idx + 1], self.data[idx + 2]])
88 }
89
90 #[inline]
92 fn sample_bilinear(&self, u: f32, v: f32) -> Option<[u8; 3]> {
93 let w = self.width as i32;
94 let h = self.height as i32;
95
96 let x0 = u.floor() as i32;
97 let y0 = v.floor() as i32;
98 let x1 = x0 + 1;
99 let y1 = y0 + 1;
100
101 if x0 < 0 || y0 < 0 || x1 >= w || y1 >= h {
102 return self.sample_nearest(u, v);
104 }
105
106 let tx = u - u.floor();
107 let ty = v - v.floor();
108
109 let fetch = |x: i32, y: i32| -> [f32; 3] {
110 let idx = (y as usize * self.width as usize + x as usize) * 3;
111 [
112 self.data[idx] as f32,
113 self.data[idx + 1] as f32,
114 self.data[idx + 2] as f32,
115 ]
116 };
117
118 let c00 = fetch(x0, y0);
119 let c10 = fetch(x1, y0);
120 let c01 = fetch(x0, y1);
121 let c11 = fetch(x1, y1);
122
123 let lerp = |a: f32, b: f32, t: f32| a + (b - a) * t;
124 let r = lerp(lerp(c00[0], c10[0], tx), lerp(c01[0], c11[0], tx), ty);
125 let g = lerp(lerp(c00[1], c10[1], tx), lerp(c01[1], c11[1], tx), ty);
126 let b = lerp(lerp(c00[2], c10[2], tx), lerp(c01[2], c11[2], tx), ty);
127
128 Some([r.round() as u8, g.round() as u8, b.round() as u8])
129 }
130}
131
132#[derive(Debug, Clone, Copy, Default)]
134pub enum InterpolationMode {
135 NearestNeighbor,
137 #[default]
139 Bilinear,
140}
141
142#[derive(Debug, Clone)]
144pub struct ColorizationConfig {
145 pub default_color: [u8; 3],
148 pub interpolation: InterpolationMode,
150 pub min_depth: f32,
153}
154
155impl Default for ColorizationConfig {
156 fn default() -> Self {
157 Self {
158 default_color: [128, 128, 128],
159 interpolation: InterpolationMode::default(),
160 min_depth: 1e-4,
161 }
162 }
163}
164
165#[derive(Debug, Clone)]
167pub struct ColorizationResult {
168 pub cloud: PointCloud<ColoredPoint3f>,
170 pub colored_count: usize,
172 pub uncolored_count: usize,
174}
175
176#[inline]
183fn project_point(
184 point: &Point3f,
185 world_to_camera: &Isometry3<f32>,
186 intrinsics: &CameraIntrinsics,
187 min_depth: f32,
188) -> Option<(f32, f32)> {
189 let p_cam = world_to_camera * point;
190 let z = p_cam.z;
191 if z < min_depth {
192 return None;
193 }
194 let u = intrinsics.fx * p_cam.x / z + intrinsics.cx;
195 let v = intrinsics.fy * p_cam.y / z + intrinsics.cy;
196 Some((u, v))
197}
198
199pub fn colorize_point_cloud(
218 cloud: &PointCloud<Point3f>,
219 image: &RgbImageView<'_>,
220 intrinsics: &CameraIntrinsics,
221 world_to_camera: &Isometry3<f32>,
222 config: &ColorizationConfig,
223) -> Result<ColorizationResult> {
224 let colored_points: Vec<(ColoredPoint3f, bool)> = cloud
225 .points
226 .par_iter()
227 .map(|point| {
228 let color = project_and_sample(point, image, intrinsics, world_to_camera, config);
229 let hit = color != config.default_color;
230 (
231 ColoredPoint3f {
232 position: *point,
233 color,
234 },
235 hit,
236 )
237 })
238 .collect();
239
240 let colored_count = colored_points.iter().filter(|(_, hit)| *hit).count();
241 let uncolored_count = colored_points.len() - colored_count;
242 let cloud = PointCloud::from_points(colored_points.into_iter().map(|(p, _)| p).collect());
243
244 Ok(ColorizationResult {
245 cloud,
246 colored_count,
247 uncolored_count,
248 })
249}
250
251pub fn colorize_from_images(
262 cloud: &PointCloud<Point3f>,
263 sources: &[(RgbImageView<'_>, CameraIntrinsics, Isometry3<f32>)],
264 config: &ColorizationConfig,
265) -> Result<ColorizationResult> {
266 if sources.is_empty() {
267 return Err(Error::InvalidData(
268 "colorize_from_images: sources slice must not be empty".into(),
269 ));
270 }
271
272 let colored_points: Vec<(ColoredPoint3f, bool)> = cloud
273 .points
274 .par_iter()
275 .map(|point| {
276 for (image, intrinsics, world_to_camera) in sources {
277 let color = project_and_sample(point, image, intrinsics, world_to_camera, config);
278 if color != config.default_color {
279 return (
280 ColoredPoint3f {
281 position: *point,
282 color,
283 },
284 true,
285 );
286 }
287 }
288 (
289 ColoredPoint3f {
290 position: *point,
291 color: config.default_color,
292 },
293 false,
294 )
295 })
296 .collect();
297
298 let colored_count = colored_points.iter().filter(|(_, hit)| *hit).count();
299 let uncolored_count = colored_points.len() - colored_count;
300 let cloud = PointCloud::from_points(colored_points.into_iter().map(|(p, _)| p).collect());
301
302 Ok(ColorizationResult {
303 cloud,
304 colored_count,
305 uncolored_count,
306 })
307}
308
309#[inline]
316fn project_and_sample(
317 point: &Point3f,
318 image: &RgbImageView<'_>,
319 intrinsics: &CameraIntrinsics,
320 world_to_camera: &Isometry3<f32>,
321 config: &ColorizationConfig,
322) -> [u8; 3] {
323 let Some((u, v)) = project_point(point, world_to_camera, intrinsics, config.min_depth) else {
324 return config.default_color;
325 };
326 let sampled = match config.interpolation {
327 InterpolationMode::NearestNeighbor => image.sample_nearest(u, v),
328 InterpolationMode::Bilinear => image.sample_bilinear(u, v),
329 };
330 sampled.unwrap_or(config.default_color)
331}
332
333#[cfg(test)]
338mod tests {
339 use super::*;
340 use nalgebra::{Isometry3, Translation3, UnitQuaternion};
341
342 fn identity_pose() -> Isometry3<f32> {
344 Isometry3::identity()
345 }
346
347 fn red_image() -> Vec<u8> {
349 vec![255u8, 0, 0].repeat(4 * 4)
350 }
351
352 fn intrinsics_4x4() -> CameraIntrinsics {
353 CameraIntrinsics {
355 fx: 2.0,
356 fy: 2.0,
357 cx: 1.5,
358 cy: 1.5,
359 }
360 }
361
362 #[test]
363 fn test_single_point_in_view() {
364 let data = red_image();
366 let image = RgbImageView::new(&data, 4, 4).unwrap();
367 let intrinsics = intrinsics_4x4();
368 let pose = identity_pose();
369
370 let cloud = PointCloud::from_points(vec![Point3f::new(0.0, 0.0, 1.0)]);
371 let config = ColorizationConfig::default();
372 let result = colorize_point_cloud(&cloud, &image, &intrinsics, &pose, &config).unwrap();
373
374 assert_eq!(result.colored_count, 1);
375 assert_eq!(result.cloud.points[0].color, [255, 0, 0]);
376 }
377
378 #[test]
379 fn test_point_behind_camera() {
380 let data = red_image();
382 let image = RgbImageView::new(&data, 4, 4).unwrap();
383 let intrinsics = intrinsics_4x4();
384 let pose = identity_pose();
385
386 let cloud = PointCloud::from_points(vec![Point3f::new(0.0, 0.0, -1.0)]);
387 let config = ColorizationConfig {
388 default_color: [0, 0, 0],
389 ..Default::default()
390 };
391 let result = colorize_point_cloud(&cloud, &image, &intrinsics, &pose, &config).unwrap();
392
393 assert_eq!(result.uncolored_count, 1);
394 assert_eq!(result.cloud.points[0].color, [0, 0, 0]);
395 }
396
397 #[test]
398 fn test_point_out_of_frame() {
399 let data = red_image();
401 let image = RgbImageView::new(&data, 4, 4).unwrap();
402 let intrinsics = intrinsics_4x4();
403 let pose = identity_pose();
404
405 let cloud = PointCloud::from_points(vec![Point3f::new(1000.0, 0.0, 1.0)]);
406 let config = ColorizationConfig {
407 default_color: [1, 2, 3],
408 ..Default::default()
409 };
410 let result = colorize_point_cloud(&cloud, &image, &intrinsics, &pose, &config).unwrap();
411
412 assert_eq!(result.uncolored_count, 1);
413 assert_eq!(result.cloud.points[0].color, [1, 2, 3]);
414 }
415
416 #[test]
417 fn test_multi_image_first_wins() {
418 let red = red_image();
420 let blue = vec![0u8, 0, 255].repeat(4 * 4);
421 let intrinsics = intrinsics_4x4();
422 let pose = identity_pose();
423
424 let img_red = RgbImageView::new(&red, 4, 4).unwrap();
425 let img_blue = RgbImageView::new(&blue, 4, 4).unwrap();
426
427 let cloud = PointCloud::from_points(vec![Point3f::new(0.0, 0.0, 1.0)]);
428 let config = ColorizationConfig::default();
429 let sources = vec![(img_red, intrinsics, pose), (img_blue, intrinsics, pose)];
430 let result = colorize_from_images(&cloud, &sources, &config).unwrap();
431 assert_eq!(result.cloud.points[0].color, [255, 0, 0]);
432 }
433
434 #[test]
435 fn test_camera_translated_along_x() {
436 let data = red_image();
439 let image = RgbImageView::new(&data, 4, 4).unwrap();
440 let intrinsics = intrinsics_4x4();
441
442 let camera_to_world =
445 Isometry3::from_parts(Translation3::new(1.0, 0.0, 0.0), UnitQuaternion::identity());
446 let world_to_camera = camera_to_world.inverse();
447
448 let cloud = PointCloud::from_points(vec![Point3f::new(1.0, 0.0, 1.0)]);
450 let config = ColorizationConfig::default();
451 let result =
452 colorize_point_cloud(&cloud, &image, &intrinsics, &world_to_camera, &config).unwrap();
453
454 assert_eq!(result.colored_count, 1);
455 assert_eq!(result.cloud.points[0].color, [255, 0, 0]);
456 }
457
458 #[test]
459 fn test_rgb_image_view_too_small() {
460 let result = RgbImageView::new(&[0u8; 10], 4, 4);
461 assert!(result.is_err());
462 }
463}