Skip to main content

trueno_image/
color.rs

1//! Color space conversions and connected component labeling.
2//!
3//! # Contract: image-color-v1.yaml
4//!
5//! Provides RGB→Grayscale, RGB→HSV, HSV→RGB, and connected component labeling.
6//!
7//! ## Proof obligations
8//! - HSV roundtrip: RGB→HSV→RGB = identity within tolerance
9//! - Grayscale luminance: uses ITU-R BT.601 weights
10//! - Connected components: each label is 4-connected
11
12use crate::error::ImageError;
13
14/// Convert RGB image to grayscale using ITU-R BT.601 weights.
15///
16/// Input: interleaved RGB (3 channels), length = width * height * 3.
17/// Output: grayscale, length = width * height.
18///
19/// # Errors
20///
21/// Returns error if buffer lengths don't match dimensions.
22pub fn rgb_to_gray(rgb: &[f32], width: usize, height: usize) -> Result<Vec<f32>, ImageError> {
23    let pixels = width * height;
24    if rgb.len() != pixels * 3 {
25        return Err(ImageError::BufferLengthMismatch {
26            expected: pixels * 3,
27            got: rgb.len(),
28            width,
29            height,
30        });
31    }
32
33    let mut gray = Vec::with_capacity(pixels);
34    for i in 0..pixels {
35        let r = rgb[i * 3];
36        let g = rgb[i * 3 + 1];
37        let b = rgb[i * 3 + 2];
38        // ITU-R BT.601 luma
39        gray.push(0.299 * r + 0.587 * g + 0.114 * b);
40    }
41    Ok(gray)
42}
43
44/// Convert RGB to HSV color space.
45///
46/// Input/output: interleaved 3-channel, length = width * height * 3.
47/// H in [0, 360), S in [0, 1], V in [0, 1]. RGB in [0, 1].
48///
49/// # Errors
50///
51/// Returns error if buffer length doesn't match dimensions.
52pub fn rgb_to_hsv(rgb: &[f32], width: usize, height: usize) -> Result<Vec<f32>, ImageError> {
53    let pixels = width * height;
54    if rgb.len() != pixels * 3 {
55        return Err(ImageError::BufferLengthMismatch {
56            expected: pixels * 3,
57            got: rgb.len(),
58            width,
59            height,
60        });
61    }
62
63    let mut hsv = vec![0.0_f32; pixels * 3];
64    for i in 0..pixels {
65        let (h, s, v) = rgb_pixel_to_hsv(rgb[i * 3], rgb[i * 3 + 1], rgb[i * 3 + 2]);
66        hsv[i * 3] = h;
67        hsv[i * 3 + 1] = s;
68        hsv[i * 3 + 2] = v;
69    }
70    Ok(hsv)
71}
72
73/// Convert a single RGB pixel to HSV.
74fn rgb_pixel_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
75    let max = r.max(g).max(b);
76    let min = r.min(g).min(b);
77    let delta = max - min;
78
79    let v = max;
80    let s = if max > f32::EPSILON { delta / max } else { 0.0 };
81
82    let h = if delta < f32::EPSILON {
83        0.0
84    } else if (max - r).abs() < f32::EPSILON {
85        60.0 * (((g - b) / delta) % 6.0)
86    } else if (max - g).abs() < f32::EPSILON {
87        60.0 * ((b - r) / delta + 2.0)
88    } else {
89        60.0 * ((r - g) / delta + 4.0)
90    };
91
92    let h = if h < 0.0 { h + 360.0 } else { h };
93    (h, s, v)
94}
95
96/// Convert HSV to RGB color space.
97///
98/// H in [0, 360), S in [0, 1], V in [0, 1]. Output RGB in [0, 1].
99///
100/// # Errors
101///
102/// Returns error if buffer length doesn't match dimensions.
103pub fn hsv_to_rgb(hsv: &[f32], width: usize, height: usize) -> Result<Vec<f32>, ImageError> {
104    let pixels = width * height;
105    if hsv.len() != pixels * 3 {
106        return Err(ImageError::BufferLengthMismatch {
107            expected: pixels * 3,
108            got: hsv.len(),
109            width,
110            height,
111        });
112    }
113
114    let mut rgb = vec![0.0_f32; pixels * 3];
115    for i in 0..pixels {
116        let (r, g, b) = hsv_pixel_to_rgb(hsv[i * 3], hsv[i * 3 + 1], hsv[i * 3 + 2]);
117        rgb[i * 3] = r;
118        rgb[i * 3 + 1] = g;
119        rgb[i * 3 + 2] = b;
120    }
121    Ok(rgb)
122}
123
124/// Convert a single HSV pixel to RGB.
125fn hsv_pixel_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) {
126    if s < f32::EPSILON {
127        return (v, v, v);
128    }
129
130    let h = h % 360.0;
131    let c = v * s;
132    let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
133    let m = v - c;
134
135    let (r1, g1, b1) = match (h / 60.0) as u32 {
136        0 => (c, x, 0.0),
137        1 => (x, c, 0.0),
138        2 => (0.0, c, x),
139        3 => (0.0, x, c),
140        4 => (x, 0.0, c),
141        _ => (c, 0.0, x),
142    };
143
144    (r1 + m, g1 + m, b1 + m)
145}
146
147/// Connected component labeling using union-find (4-connectivity).
148///
149/// Input: binary image (0.0 = background, nonzero = foreground).
150/// Output: label array where each connected region has a unique ID (0 = background).
151///
152/// # Errors
153///
154/// Returns error if buffer length doesn't match dimensions.
155pub fn connected_components(
156    image: &[f32],
157    width: usize,
158    height: usize,
159) -> Result<Vec<u32>, ImageError> {
160    if image.len() != width * height {
161        return Err(ImageError::BufferLengthMismatch {
162            expected: width * height,
163            got: image.len(),
164            width,
165            height,
166        });
167    }
168
169    let pixels = width * height;
170    let mut labels = vec![0u32; pixels];
171    let mut parent = Vec::with_capacity(pixels / 4);
172    parent.push(0); // label 0 = background
173
174    // First pass: assign provisional labels
175    first_pass(image, &mut labels, &mut parent, width, height);
176
177    // Flatten all paths
178    for i in 0..parent.len() {
179        parent[i] = find(&parent, i as u32);
180    }
181
182    // Second pass: relabel with canonical labels
183    let mut remap = vec![0u32; parent.len()];
184    let mut next_label = 1u32;
185    for i in 1..parent.len() {
186        let root = parent[i] as usize;
187        if remap[root] == 0 {
188            remap[root] = next_label;
189            next_label += 1;
190        }
191        remap[i] = remap[root];
192    }
193
194    for label in &mut labels {
195        if *label > 0 {
196            *label = remap[*label as usize];
197        }
198    }
199
200    Ok(labels)
201}
202
203/// First pass: scan pixels, assign labels, merge with union-find.
204fn first_pass(
205    image: &[f32],
206    labels: &mut [u32],
207    parent: &mut Vec<u32>,
208    width: usize,
209    height: usize,
210) {
211    for y in 0..height {
212        for x in 0..width {
213            let idx = y * width + x;
214            if image[idx].abs() < f32::EPSILON {
215                continue; // background
216            }
217
218            let left = if x > 0 { labels[idx - 1] } else { 0 };
219            let above = if y > 0 { labels[idx - width] } else { 0 };
220
221            match (left > 0, above > 0) {
222                (false, false) => {
223                    // New label
224                    let new_label = parent.len() as u32;
225                    parent.push(new_label);
226                    labels[idx] = new_label;
227                }
228                (true, false) => labels[idx] = left,
229                (false, true) => labels[idx] = above,
230                (true, true) => {
231                    labels[idx] = left.min(above);
232                    union(parent, left, above);
233                }
234            }
235        }
236    }
237}
238
239/// Find root with path compression (iterative).
240fn find(parent: &[u32], mut x: u32) -> u32 {
241    while parent[x as usize] != x {
242        x = parent[x as usize];
243    }
244    x
245}
246
247/// Union two labels.
248fn union(parent: &mut [u32], a: u32, b: u32) {
249    let ra = find(parent, a);
250    let rb = find(parent, b);
251    if ra != rb {
252        let min = ra.min(rb);
253        let max = ra.max(rb);
254        parent[max as usize] = min;
255    }
256}