Skip to main content

threecrate_algorithms/
colorization.rs

1//! Point cloud colorization from registered RGB images.
2//!
3//! A "registered" RGB image is one whose camera pose (position and orientation
4//! relative to the point cloud coordinate frame) is known.  Given the camera
5//! intrinsics and extrinsics, every 3-D point can be projected onto the image
6//! plane and the corresponding pixel color assigned to it.
7//!
8//! # Pipeline
9//!
10//! 1. Transform each 3-D point from world space into camera space using the
11//!    provided world-to-camera isometry.
12//! 2. Project the camera-space point onto the image plane with the pinhole
13//!    camera model.
14//! 3. Discard points that lie behind the camera (negative depth) or whose
15//!    projected pixel falls outside the image.
16//! 4. Sample the pixel color — nearest-neighbour or bilinear — and write it
17//!    into the output [`ColoredPoint3f`].
18//!
19//! # Multi-image support
20//!
21//! [`colorize_from_images`] accepts a slice of `(image, intrinsics, pose)`
22//! triples.  Each 3-D point is colored by the *first* image that can see it.
23//! Images should therefore be supplied in order of preference (e.g. most
24//! frontal camera first).
25
26use nalgebra::Isometry3;
27use rayon::prelude::*;
28use threecrate_core::{ColoredPoint3f, Error, Point3f, PointCloud, Result};
29
30// ---------------------------------------------------------------------------
31// Public types
32// ---------------------------------------------------------------------------
33
34/// Pinhole camera intrinsic parameters.
35#[derive(Debug, Clone, Copy)]
36pub struct CameraIntrinsics {
37    /// Horizontal focal length in pixels.
38    pub fx: f32,
39    /// Vertical focal length in pixels.
40    pub fy: f32,
41    /// Horizontal principal-point offset (pixels from left edge).
42    pub cx: f32,
43    /// Vertical principal-point offset (pixels from top edge).
44    pub cy: f32,
45}
46
47/// A borrowed, tightly-packed RGB image (row-major, 3 bytes per pixel).
48#[derive(Debug, Clone, Copy)]
49pub struct RgbImageView<'a> {
50    /// Raw pixel data: `[R, G, B, R, G, B, …]`, row-major.
51    pub data: &'a [u8],
52    /// Image width in pixels.
53    pub width: u32,
54    /// Image height in pixels.
55    pub height: u32,
56}
57
58impl<'a> RgbImageView<'a> {
59    /// Construct a new view, validating that `data` is large enough.
60    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    /// Sample pixel color using nearest-neighbour interpolation.
79    #[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    /// Sample pixel color using bilinear interpolation.
91    #[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            // Fall back to nearest when the bilinear neighbourhood clips the border.
103            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/// Interpolation strategy when sampling pixel colors.
133#[derive(Debug, Clone, Copy, Default)]
134pub enum InterpolationMode {
135    /// Round projected coordinates to the nearest integer pixel.
136    NearestNeighbor,
137    /// Bilinear interpolation across the four surrounding pixels.
138    #[default]
139    Bilinear,
140}
141
142/// Configuration for [`colorize_point_cloud`].
143#[derive(Debug, Clone)]
144pub struct ColorizationConfig {
145    /// Color assigned to points that cannot be projected into any image.
146    /// Defaults to `[128, 128, 128]` (mid-grey).
147    pub default_color: [u8; 3],
148    /// Pixel interpolation method.
149    pub interpolation: InterpolationMode,
150    /// Minimum depth (in camera space, metres) for a point to be considered
151    /// visible.  Points closer than this are treated as behind the camera.
152    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/// Summary statistics returned by [`colorize_point_cloud`].
166#[derive(Debug, Clone)]
167pub struct ColorizationResult {
168    /// Colored point cloud.
169    pub cloud: PointCloud<ColoredPoint3f>,
170    /// Number of points that were successfully colored from an image.
171    pub colored_count: usize,
172    /// Number of points that received the default color (no image coverage).
173    pub uncolored_count: usize,
174}
175
176// ---------------------------------------------------------------------------
177// Core projection helpers
178// ---------------------------------------------------------------------------
179
180/// Project a single world-space point into a camera and return the image-space
181/// coordinates `(u, v)`.  Returns `None` if the point is behind the camera.
182#[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
199// ---------------------------------------------------------------------------
200// Public API
201// ---------------------------------------------------------------------------
202
203/// Colorize a point cloud from a single registered RGB image.
204///
205/// # Arguments
206///
207/// * `cloud`            – Input point cloud (position only).
208/// * `image`            – Registered RGB image.
209/// * `intrinsics`       – Pinhole camera intrinsics.
210/// * `world_to_camera`  – Rigid transform that maps world-space points into
211///                        the camera coordinate frame (X right, Y down, Z forward).
212/// * `config`           – Colorization options.
213///
214/// # Returns
215///
216/// A [`ColorizationResult`] containing the colored cloud and coverage statistics.
217pub 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
251/// Colorize a point cloud from multiple registered RGB images.
252///
253/// Each point is colored by the **first** image in `sources` that can see it.
254/// Supply images in decreasing priority order (most preferred camera first).
255///
256/// # Arguments
257///
258/// * `cloud`   – Input point cloud.
259/// * `sources` – Slice of `(image, intrinsics, world_to_camera)` triples.
260/// * `config`  – Colorization options.
261pub 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// ---------------------------------------------------------------------------
310// Internal helper
311// ---------------------------------------------------------------------------
312
313/// Project `point` into `image` and return the sampled color, or
314/// `config.default_color` if the point is not visible.
315#[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// ---------------------------------------------------------------------------
334// Tests
335// ---------------------------------------------------------------------------
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use nalgebra::{Isometry3, Translation3, UnitQuaternion};
341
342    /// Identity pose: camera sits at the origin looking down +Z.
343    fn identity_pose() -> Isometry3<f32> {
344        Isometry3::identity()
345    }
346
347    /// Simple 4×4 all-red image.
348    fn red_image() -> Vec<u8> {
349        vec![255u8, 0, 0].repeat(4 * 4)
350    }
351
352    fn intrinsics_4x4() -> CameraIntrinsics {
353        // For a 4×4 image, place the principal point at the centre (1.5, 1.5).
354        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        // A point at (0,0,1) should project to the centre of a 4×4 red image.
365        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        // A point at z=-1 should be invisible and receive the default color.
381        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        // A point far to the side should project outside the 4×4 image.
400        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        // Two images: red and blue.  The first (red) should win for a visible point.
419        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        // Camera moved 1 unit to the right in world space.
437        // The world-to-camera transform must account for this translation.
438        let data = red_image();
439        let image = RgbImageView::new(&data, 4, 4).unwrap();
440        let intrinsics = intrinsics_4x4();
441
442        // Camera at world position (1, 0, 0) looking down +Z.
443        // world_to_camera = inverse of camera_to_world
444        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        // A point at (1, 0, 1) is directly in front of this camera.
449        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}