Skip to main content

scirs2_vision/color/
mod.rs

1//! Color transformation module
2//!
3//! This module provides functionality for working with different color spaces
4//! and performing color transformations.
5
6pub mod octree_quantization;
7pub mod quantization;
8
9use crate::error::Result;
10use image::{DynamicImage, ImageBuffer, Rgb};
11// Note: Array2 might be needed in future implementations
12
13pub use octree_quantization::{adaptive_octree_quantize, extract_palette, octree_quantize};
14pub use quantization::{kmeans_quantize, median_cut_quantize, InitMethod, KMeansParams};
15
16/// Represents a color space
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub enum ColorSpace {
19    /// RGB color space
20    RGB,
21    /// HSV (Hue, Saturation, Value) color space
22    HSV,
23    /// HSL (Hue, Saturation, Lightness) color space
24    HSL,
25    /// LAB color space (CIE L*a*b*)
26    LAB,
27    /// CIE XYZ color space
28    XYZ,
29    /// YCbCr color space (ITU-R BT.601)
30    YCbCr,
31    /// Grayscale
32    Gray,
33}
34
35/// Convert an image from RGB to HSV
36///
37/// # Arguments
38///
39/// * `img` - Input RGB image
40///
41/// # Returns
42///
43/// * Result containing an HSV image
44#[allow(dead_code)]
45pub fn rgb_to_hsv(img: &DynamicImage) -> Result<DynamicImage> {
46    // Ensure input is RGB
47    let rgb_img = img.to_rgb8();
48    let (width, height) = rgb_img.dimensions();
49
50    // Create output buffer
51    let mut hsv_img = ImageBuffer::new(width, height);
52
53    for y in 0..height {
54        for x in 0..width {
55            let rgb = rgb_img.get_pixel(x, y);
56            let r = rgb[0] as f32 / 255.0;
57            let g = rgb[1] as f32 / 255.0;
58            let b = rgb[2] as f32 / 255.0;
59
60            let max = r.max(g).max(b);
61            let min = r.min(g).min(b);
62            let delta = max - min;
63
64            // Hue calculation
65            let h = if delta == 0.0 {
66                0.0
67            } else if max == r {
68                60.0 * (((g - b) / delta) % 6.0)
69            } else if max == g {
70                60.0 * (((b - r) / delta) + 2.0)
71            } else {
72                60.0 * (((r - g) / delta) + 4.0)
73            };
74
75            // Normalize hue to [0, 360)
76            let h = if h < 0.0 { h + 360.0 } else { h };
77
78            // Saturation calculation
79            let s = if max == 0.0 { 0.0 } else { delta / max };
80
81            // Value calculation
82            let v = max;
83
84            // Store HSV as RGB values for visualization
85            // Hue [0, 360) -> [0, 255]
86            // Saturation [0, 1] -> [0, 255]
87            // Value [0, 1] -> [0, 255]
88            hsv_img.put_pixel(
89                x,
90                y,
91                Rgb([
92                    (h / 360.0 * 255.0) as u8,
93                    (s * 255.0) as u8,
94                    (v * 255.0) as u8,
95                ]),
96            );
97        }
98    }
99
100    Ok(DynamicImage::ImageRgb8(hsv_img))
101}
102
103/// Convert an image from HSV to RGB
104///
105/// # Arguments
106///
107/// * `img` - Input HSV image (represented as RGB buffer where channels are H, S, V)
108///
109/// # Returns
110///
111/// * Result containing an RGB image
112#[allow(dead_code)]
113pub fn hsv_to_rgb(_hsvimg: &DynamicImage) -> Result<DynamicImage> {
114    let hsv = _hsvimg.to_rgb8();
115    let (width, height) = hsv.dimensions();
116
117    let mut rgb_img = ImageBuffer::new(width, height);
118
119    for y in 0..height {
120        for x in 0..width {
121            let hsv_pixel = hsv.get_pixel(x, y);
122
123            // Convert back to HSV range
124            let h = hsv_pixel[0] as f32 / 255.0 * 360.0;
125            let s = hsv_pixel[1] as f32 / 255.0;
126            let v = hsv_pixel[2] as f32 / 255.0;
127
128            // HSV to RGB conversion
129            let c = v * s;
130            let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
131            let m = v - c;
132
133            let (r1, g1, b1) = if h < 60.0 {
134                (c, x, 0.0)
135            } else if h < 120.0 {
136                (x, c, 0.0)
137            } else if h < 180.0 {
138                (0.0, c, x)
139            } else if h < 240.0 {
140                (0.0, x, c)
141            } else if h < 300.0 {
142                (x, 0.0, c)
143            } else {
144                (c, 0.0, x)
145            };
146
147            let r = ((r1 + m) * 255.0) as u8;
148            let g = ((g1 + m) * 255.0) as u8;
149            let b = ((b1 + m) * 255.0) as u8;
150
151            #[allow(clippy::unnecessary_cast)]
152            rgb_img.put_pixel(x as u32, y as u32, Rgb([r, g, b]));
153        }
154    }
155
156    Ok(DynamicImage::ImageRgb8(rgb_img))
157}
158
159/// Convert RGB to grayscale using weighted average
160///
161/// # Arguments
162///
163/// * `img` - Input RGB image
164/// * `weights` - Optional RGB weights (default: [0.2989, 0.5870, 0.1140] - standard luminance)
165///
166/// # Returns
167///
168/// * Result containing a grayscale image
169#[allow(dead_code)]
170pub fn rgb_to_grayscale(img: &DynamicImage, weights: Option<[f32; 3]>) -> Result<DynamicImage> {
171    // Default weights based on human perception of color
172    let weights = weights.unwrap_or([0.2989, 0.5870, 0.1140]);
173
174    // Get RGB image
175    let rgb_img = img.to_rgb8();
176    let (width, height) = rgb_img.dimensions();
177
178    // Create grayscale image
179    let mut gray_img = ImageBuffer::new(width, height);
180
181    for y in 0..height {
182        for x in 0..width {
183            let rgb = rgb_img.get_pixel(x, y);
184
185            // Apply weighted average
186            let gray_value = (weights[0] * rgb[0] as f32
187                + weights[1] * rgb[1] as f32
188                + weights[2] * rgb[2] as f32)
189                .clamp(0.0, 255.0) as u8;
190
191            gray_img.put_pixel(x, y, image::Luma([gray_value]));
192        }
193    }
194
195    Ok(DynamicImage::ImageLuma8(gray_img))
196}
197
198/// Convert RGB to LAB color space
199///
200/// # Arguments
201///
202/// * `img` - Input RGB image
203///
204/// # Returns
205///
206/// * Result containing a LAB image (represented as RGB buffer where channels are L, a, b)
207#[allow(dead_code)]
208pub fn rgb_to_lab(img: &DynamicImage) -> Result<DynamicImage> {
209    let rgb_img = img.to_rgb8();
210    let (width, height) = rgb_img.dimensions();
211
212    let mut lab_img = ImageBuffer::new(width, height);
213
214    for y in 0..height {
215        for x in 0..width {
216            let rgb = rgb_img.get_pixel(x, y);
217
218            // Convert RGB to XYZ
219            let r = rgb[0] as f32 / 255.0;
220            let g = rgb[1] as f32 / 255.0;
221            let b = rgb[2] as f32 / 255.0;
222
223            // Gamma correction (sRGB to linear RGB)
224            let r_lin = if r > 0.04045 {
225                ((r + 0.055) / 1.055).powf(2.4)
226            } else {
227                r / 12.92
228            };
229            let g_lin = if g > 0.04045 {
230                ((g + 0.055) / 1.055).powf(2.4)
231            } else {
232                g / 12.92
233            };
234            let b_lin = if b > 0.04045 {
235                ((b + 0.055) / 1.055).powf(2.4)
236            } else {
237                b / 12.92
238            };
239
240            // RGB to XYZ conversion (using sRGB D65 matrix)
241            let x = r_lin * 0.4124564 + g_lin * 0.3575761 + b_lin * 0.1804375;
242            let y = r_lin * 0.2126729 + g_lin * 0.7151522 + b_lin * 0.0721750;
243            let z = r_lin * 0.0193339 + g_lin * 0.119192 + b_lin * 0.9503041;
244
245            // XYZ to LAB
246            // Reference white point (D65)
247            let x_n = 0.95047;
248            let y_n = 1.0;
249            let z_n = 1.08883;
250
251            // Scale XYZ values relative to reference white
252            let x_r = x / x_n;
253            let y_r = y / y_n;
254            let z_r = z / z_n;
255
256            // XYZ to LAB helper function
257            let f = |t: f32| -> f32 {
258                if t > 0.008856 {
259                    t.powf(1.0 / 3.0)
260                } else {
261                    (7.787 * t) + (16.0 / 116.0)
262                }
263            };
264
265            let fx = f(x_r);
266            let fy = f(y_r);
267            let fz = f(z_r);
268
269            // Calculate LAB values
270            let l = (116.0 * fy) - 16.0;
271            let a = 500.0 * (fx - fy);
272            let b_val = 200.0 * (fy - fz);
273
274            // Scale to fit in 8-bit channels
275            // L: [0, 100] -> [0, 255]
276            // a: [-128, 127] -> [0, 255]
277            // b: [-128, 127] -> [0, 255]
278            let l_scaled = (l * 2.55).clamp(0.0, 255.0) as u8;
279            let a_scaled = ((a + 128.0).clamp(0.0, 255.0)) as u8;
280            let b_scaled = ((b_val + 128.0).clamp(0.0, 255.0)) as u8;
281
282            lab_img.put_pixel(x as u32, y as u32, Rgb([l_scaled, a_scaled, b_scaled]));
283        }
284    }
285
286    Ok(DynamicImage::ImageRgb8(lab_img))
287}
288
289/// Convert LAB to RGB color space
290///
291/// # Arguments
292///
293/// * `img` - Input LAB image (represented as RGB buffer where channels are L, a, b)
294///
295/// # Returns
296///
297/// * Result containing an RGB image
298#[allow(dead_code)]
299pub fn lab_to_rgb(_labimg: &DynamicImage) -> Result<DynamicImage> {
300    let lab = _labimg.to_rgb8();
301    let (width, height) = lab.dimensions();
302
303    let mut rgb_img = ImageBuffer::new(width, height);
304
305    for y in 0..height {
306        for x in 0..width {
307            let lab_pixel = lab.get_pixel(x, y);
308
309            // Scale back from 8-bit to LAB range
310            let l = lab_pixel[0] as f32 / 2.55; // [0, 255] -> [0, 100]
311            let a = lab_pixel[1] as f32 - 128.0; // [0, 255] -> [-128, 127]
312            let b = lab_pixel[2] as f32 - 128.0; // [0, 255] -> [-128, 127]
313
314            // LAB to XYZ
315            let fy = (l + 16.0) / 116.0;
316            let fx = a / 500.0 + fy;
317            let fz = fy - b / 200.0;
318
319            // Reference white point (D65)
320            let x_n = 0.95047;
321            let y_n = 1.0;
322            let z_n = 1.08883;
323
324            // LAB to XYZ helper function
325            let f = |t: f32| -> f32 {
326                if t > 0.206893 {
327                    t.powi(3)
328                } else {
329                    (t - 16.0 / 116.0) / 7.787
330                }
331            };
332
333            let x = x_n * f(fx);
334            let y = y_n * f(fy);
335            let z = z_n * f(fz);
336
337            // XYZ to linear RGB (using inverse sRGB D65 matrix)
338            let r_lin = x * 3.2404542 - y * 1.5371385 - z * 0.4985314;
339            let g_lin = -x * 0.969266 + y * 1.8760108 + z * 0.0415560;
340            let b_lin = x * 0.0556434 - y * 0.2040259 + z * 1.0572252;
341
342            // Linear RGB to sRGB
343            let r = if r_lin > 0.0031308 {
344                1.055 * r_lin.powf(1.0 / 2.4) - 0.055
345            } else {
346                12.92 * r_lin
347            };
348
349            let g = if g_lin > 0.0031308 {
350                1.055 * g_lin.powf(1.0 / 2.4) - 0.055
351            } else {
352                12.92 * g_lin
353            };
354
355            let b = if b_lin > 0.0031308 {
356                1.055 * b_lin.powf(1.0 / 2.4) - 0.055
357            } else {
358                12.92 * b_lin
359            };
360
361            // Convert to 8-bit and clamp to valid range
362            let r = (r * 255.0).clamp(0.0, 255.0) as u8;
363            let g = (g * 255.0).clamp(0.0, 255.0) as u8;
364            let b = (b * 255.0).clamp(0.0, 255.0) as u8;
365
366            #[allow(clippy::unnecessary_cast)]
367            rgb_img.put_pixel(x as u32, y as u32, Rgb([r, g, b]));
368        }
369    }
370
371    Ok(DynamicImage::ImageRgb8(rgb_img))
372}
373
374/// Split an RGB image into separate channels
375///
376/// # Arguments
377///
378/// * `img` - Input RGB image
379///
380/// # Returns
381///
382/// * Result containing a tuple of grayscale images (r, g, b)
383#[allow(dead_code)]
384pub fn split_channels(img: &DynamicImage) -> Result<(DynamicImage, DynamicImage, DynamicImage)> {
385    let rgb_img = img.to_rgb8();
386    let (width, height) = rgb_img.dimensions();
387
388    let mut r_channel = ImageBuffer::new(width, height);
389    let mut g_channel = ImageBuffer::new(width, height);
390    let mut b_channel = ImageBuffer::new(width, height);
391
392    for y in 0..height {
393        for x in 0..width {
394            let rgb = rgb_img.get_pixel(x, y);
395
396            r_channel.put_pixel(x, y, image::Luma([rgb[0]]));
397            g_channel.put_pixel(x, y, image::Luma([rgb[1]]));
398            b_channel.put_pixel(x, y, image::Luma([rgb[2]]));
399        }
400    }
401
402    Ok((
403        DynamicImage::ImageLuma8(r_channel),
404        DynamicImage::ImageLuma8(g_channel),
405        DynamicImage::ImageLuma8(b_channel),
406    ))
407}
408
409/// Merge separate channels into an RGB image
410///
411/// # Arguments
412///
413/// * `r_channel` - Red channel image
414/// * `g_channel` - Green channel image
415/// * `b_channel` - Blue channel image
416///
417/// # Returns
418///
419/// * Result containing an RGB image
420#[allow(dead_code)]
421pub fn merge_channels(
422    r_channel: &DynamicImage,
423    g_channel: &DynamicImage,
424    b_channel: &DynamicImage,
425) -> Result<DynamicImage> {
426    let r_img = r_channel.to_luma8();
427    let g_img = g_channel.to_luma8();
428    let b_img = b_channel.to_luma8();
429
430    let (width, height) = r_img.dimensions();
431
432    // Check dimensions
433    if g_img.dimensions() != (width, height) || b_img.dimensions() != (width, height) {
434        return Err(crate::error::VisionError::InvalidParameter(
435            "Channel dimensions do not match".to_string(),
436        ));
437    }
438
439    let mut rgb_img = ImageBuffer::new(width, height);
440
441    for y in 0..height {
442        for x in 0..width {
443            let r = r_img.get_pixel(x, y)[0];
444            let g = g_img.get_pixel(x, y)[0];
445            let b = b_img.get_pixel(x, y)[0];
446
447            #[allow(clippy::unnecessary_cast)]
448            rgb_img.put_pixel(x as u32, y as u32, Rgb([r, g, b]));
449        }
450    }
451
452    Ok(DynamicImage::ImageRgb8(rgb_img))
453}
454
455// ---------------------------------------------------------------------------
456// RGB <-> HSL
457// ---------------------------------------------------------------------------
458
459/// Convert an image from RGB to HSL
460///
461/// Channels are packed into an RGB buffer as:
462/// - Channel 0: Hue `[0,360)` mapped to `[0,255]`
463/// - Channel 1: Saturation `[0,1]` mapped to `[0,255]`
464/// - Channel 2: Lightness `[0,1]` mapped to `[0,255]`
465#[allow(dead_code)]
466pub fn rgb_to_hsl(img: &DynamicImage) -> Result<DynamicImage> {
467    let rgb_img = img.to_rgb8();
468    let (width, height) = rgb_img.dimensions();
469    let mut hsl_img = ImageBuffer::new(width, height);
470
471    for y in 0..height {
472        for x in 0..width {
473            let px = rgb_img.get_pixel(x, y);
474            let r = px[0] as f32 / 255.0;
475            let g = px[1] as f32 / 255.0;
476            let b = px[2] as f32 / 255.0;
477
478            let max_c = r.max(g).max(b);
479            let min_c = r.min(g).min(b);
480            let delta = max_c - min_c;
481            let l = (max_c + min_c) / 2.0;
482
483            let s = if delta < 1e-6 {
484                0.0
485            } else {
486                delta / (1.0 - (2.0 * l - 1.0).abs())
487            };
488
489            let h = if delta < 1e-6 {
490                0.0
491            } else if (max_c - r).abs() < 1e-6 {
492                let mut hh = 60.0 * ((g - b) / delta);
493                if hh < 0.0 {
494                    hh += 360.0;
495                }
496                hh
497            } else if (max_c - g).abs() < 1e-6 {
498                60.0 * ((b - r) / delta + 2.0)
499            } else {
500                60.0 * ((r - g) / delta + 4.0)
501            };
502
503            hsl_img.put_pixel(
504                x,
505                y,
506                Rgb([
507                    (h / 360.0 * 255.0).clamp(0.0, 255.0) as u8,
508                    (s * 255.0).clamp(0.0, 255.0) as u8,
509                    (l * 255.0).clamp(0.0, 255.0) as u8,
510                ]),
511            );
512        }
513    }
514
515    Ok(DynamicImage::ImageRgb8(hsl_img))
516}
517
518/// Convert an image from HSL to RGB
519///
520/// Expects an HSL image packed in an RGB buffer (see `rgb_to_hsl`).
521#[allow(dead_code)]
522pub fn hsl_to_rgb(hsl_img: &DynamicImage) -> Result<DynamicImage> {
523    let hsl = hsl_img.to_rgb8();
524    let (width, height) = hsl.dimensions();
525    let mut rgb_out = ImageBuffer::new(width, height);
526
527    for y in 0..height {
528        for x in 0..width {
529            let px = hsl.get_pixel(x, y);
530            let h = px[0] as f32 / 255.0 * 360.0;
531            let s = px[1] as f32 / 255.0;
532            let l = px[2] as f32 / 255.0;
533
534            let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
535            let hp = h / 60.0;
536            let x_val = c * (1.0 - (hp % 2.0 - 1.0).abs());
537            let m = l - c / 2.0;
538
539            let (r1, g1, b1) = if hp < 1.0 {
540                (c, x_val, 0.0)
541            } else if hp < 2.0 {
542                (x_val, c, 0.0)
543            } else if hp < 3.0 {
544                (0.0, c, x_val)
545            } else if hp < 4.0 {
546                (0.0, x_val, c)
547            } else if hp < 5.0 {
548                (x_val, 0.0, c)
549            } else {
550                (c, 0.0, x_val)
551            };
552
553            rgb_out.put_pixel(
554                x,
555                y,
556                Rgb([
557                    ((r1 + m) * 255.0).clamp(0.0, 255.0) as u8,
558                    ((g1 + m) * 255.0).clamp(0.0, 255.0) as u8,
559                    ((b1 + m) * 255.0).clamp(0.0, 255.0) as u8,
560                ]),
561            );
562        }
563    }
564
565    Ok(DynamicImage::ImageRgb8(rgb_out))
566}
567
568// ---------------------------------------------------------------------------
569// RGB <-> YCbCr (ITU-R BT.601)
570// ---------------------------------------------------------------------------
571
572/// Convert an image from RGB to YCbCr (ITU-R BT.601)
573#[allow(dead_code)]
574pub fn rgb_to_ycbcr(img: &DynamicImage) -> Result<DynamicImage> {
575    let rgb = img.to_rgb8();
576    let (width, height) = rgb.dimensions();
577    let mut out = ImageBuffer::new(width, height);
578
579    for y in 0..height {
580        for x in 0..width {
581            let px = rgb.get_pixel(x, y);
582            let r = px[0] as f32;
583            let g = px[1] as f32;
584            let b = px[2] as f32;
585
586            let y_val = 0.299 * r + 0.587 * g + 0.114 * b;
587            let cb = 128.0 + (-0.168736 * r - 0.331264 * g + 0.5 * b);
588            let cr = 128.0 + (0.5 * r - 0.418688 * g - 0.081312 * b);
589
590            out.put_pixel(
591                x,
592                y,
593                Rgb([
594                    y_val.clamp(0.0, 255.0) as u8,
595                    cb.clamp(0.0, 255.0) as u8,
596                    cr.clamp(0.0, 255.0) as u8,
597                ]),
598            );
599        }
600    }
601
602    Ok(DynamicImage::ImageRgb8(out))
603}
604
605/// Convert an image from YCbCr to RGB
606#[allow(dead_code)]
607pub fn ycbcr_to_rgb(ycbcr_img: &DynamicImage) -> Result<DynamicImage> {
608    let ycbcr = ycbcr_img.to_rgb8();
609    let (width, height) = ycbcr.dimensions();
610    let mut out = ImageBuffer::new(width, height);
611
612    for y in 0..height {
613        for x in 0..width {
614            let px = ycbcr.get_pixel(x, y);
615            let y_val = px[0] as f32;
616            let cb = px[1] as f32 - 128.0;
617            let cr = px[2] as f32 - 128.0;
618
619            let r = y_val + 1.402 * cr;
620            let g = y_val - 0.344136 * cb - 0.714136 * cr;
621            let b = y_val + 1.772 * cb;
622
623            out.put_pixel(
624                x,
625                y,
626                Rgb([
627                    r.clamp(0.0, 255.0) as u8,
628                    g.clamp(0.0, 255.0) as u8,
629                    b.clamp(0.0, 255.0) as u8,
630                ]),
631            );
632        }
633    }
634
635    Ok(DynamicImage::ImageRgb8(out))
636}
637
638// ---------------------------------------------------------------------------
639// RGB <-> CIE XYZ
640// ---------------------------------------------------------------------------
641
642/// sRGB gamma linearisation helper
643#[inline]
644fn srgb_to_linear(v: f32) -> f32 {
645    if v > 0.04045 {
646        ((v + 0.055) / 1.055).powf(2.4)
647    } else {
648        v / 12.92
649    }
650}
651
652/// sRGB inverse gamma helper
653#[inline]
654fn linear_to_srgb(v: f32) -> f32 {
655    if v > 0.0031308 {
656        1.055 * v.powf(1.0 / 2.4) - 0.055
657    } else {
658        12.92 * v
659    }
660}
661
662/// Convert an image from RGB to CIE XYZ (D65 illuminant)
663#[allow(dead_code)]
664pub fn rgb_to_xyz(img: &DynamicImage) -> Result<DynamicImage> {
665    let rgb = img.to_rgb8();
666    let (width, height) = rgb.dimensions();
667    let mut out = ImageBuffer::new(width, height);
668
669    for y in 0..height {
670        for x in 0..width {
671            let px = rgb.get_pixel(x, y);
672            let r = srgb_to_linear(px[0] as f32 / 255.0);
673            let g = srgb_to_linear(px[1] as f32 / 255.0);
674            let b = srgb_to_linear(px[2] as f32 / 255.0);
675
676            let x_val = r * 0.4124564 + g * 0.3575761 + b * 0.1804375;
677            let y_val = r * 0.2126729 + g * 0.7151522 + b * 0.0721750;
678            let z_val = r * 0.0193339 + g * 0.119_192 + b * 0.9503041;
679
680            out.put_pixel(
681                x,
682                y,
683                Rgb([
684                    (x_val * 255.0).clamp(0.0, 255.0) as u8,
685                    (y_val * 255.0).clamp(0.0, 255.0) as u8,
686                    (z_val * 255.0).clamp(0.0, 255.0) as u8,
687                ]),
688            );
689        }
690    }
691
692    Ok(DynamicImage::ImageRgb8(out))
693}
694
695/// Convert an image from CIE XYZ to RGB
696#[allow(dead_code)]
697pub fn xyz_to_rgb(xyz_img: &DynamicImage) -> Result<DynamicImage> {
698    let xyz = xyz_img.to_rgb8();
699    let (width, height) = xyz.dimensions();
700    let mut out = ImageBuffer::new(width, height);
701
702    for y in 0..height {
703        for x in 0..width {
704            let px = xyz.get_pixel(x, y);
705            let x_val = px[0] as f32 / 255.0;
706            let y_val = px[1] as f32 / 255.0;
707            let z_val = px[2] as f32 / 255.0;
708
709            let r_lin = x_val * 3.2404542 - y_val * 1.5371385 - z_val * 0.4985314;
710            let g_lin = -x_val * 0.969_266 + y_val * 1.8760108 + z_val * 0.0415560;
711            let b_lin = x_val * 0.0556434 - y_val * 0.2040259 + z_val * 1.0572252;
712
713            let r = linear_to_srgb(r_lin);
714            let g = linear_to_srgb(g_lin);
715            let b = linear_to_srgb(b_lin);
716
717            out.put_pixel(
718                x,
719                y,
720                Rgb([
721                    (r * 255.0).clamp(0.0, 255.0) as u8,
722                    (g * 255.0).clamp(0.0, 255.0) as u8,
723                    (b * 255.0).clamp(0.0, 255.0) as u8,
724                ]),
725            );
726        }
727    }
728
729    Ok(DynamicImage::ImageRgb8(out))
730}
731
732// ---------------------------------------------------------------------------
733// Dominant color extraction
734// ---------------------------------------------------------------------------
735
736/// A dominant color with its weight (proportion of pixels)
737#[derive(Debug, Clone)]
738pub struct DominantColor {
739    /// RGB values
740    pub rgb: [u8; 3],
741    /// Proportion of pixels represented by this colour (0.0 to 1.0)
742    pub weight: f32,
743}
744
745/// Extract dominant colours from an image using median-cut quantisation
746///
747/// # Arguments
748///
749/// * `img` - Input image
750/// * `num_colors` - Number of dominant colours to extract (typically 3-8)
751///
752/// # Returns
753///
754/// * Result containing a vector of `DominantColor` sorted by weight (most dominant first)
755pub fn extract_dominant_colors(
756    img: &DynamicImage,
757    num_colors: usize,
758) -> Result<Vec<DominantColor>> {
759    if num_colors == 0 {
760        return Err(crate::error::VisionError::InvalidParameter(
761            "Number of colors must be positive".to_string(),
762        ));
763    }
764
765    let rgb = img.to_rgb8();
766    let total_pixels = (rgb.width() as usize) * (rgb.height() as usize);
767    if total_pixels == 0 {
768        return Ok(Vec::new());
769    }
770
771    let mut pixels: Vec<[u8; 3]> = Vec::with_capacity(total_pixels);
772    for pixel in rgb.pixels() {
773        pixels.push([pixel[0], pixel[1], pixel[2]]);
774    }
775
776    let mut boxes: Vec<Vec<[u8; 3]>> = vec![pixels];
777
778    while boxes.len() < num_colors {
779        let mut best_idx = 0;
780        let mut best_range = 0u16;
781        for (i, bx) in boxes.iter().enumerate() {
782            if bx.len() < 2 {
783                continue;
784            }
785            let range = box_max_range(bx);
786            if range > best_range {
787                best_range = range;
788                best_idx = i;
789            }
790        }
791        if best_range == 0 {
792            break;
793        }
794
795        let bx = boxes.remove(best_idx);
796        let (a, b) = split_box(bx);
797        boxes.push(a);
798        boxes.push(b);
799    }
800
801    let mut result: Vec<DominantColor> = boxes
802        .iter()
803        .filter(|bx| !bx.is_empty())
804        .map(|bx| {
805            let count = bx.len();
806            let mut r_sum = 0u64;
807            let mut g_sum = 0u64;
808            let mut b_sum = 0u64;
809            for px in bx {
810                r_sum += px[0] as u64;
811                g_sum += px[1] as u64;
812                b_sum += px[2] as u64;
813            }
814            DominantColor {
815                rgb: [
816                    (r_sum / count as u64) as u8,
817                    (g_sum / count as u64) as u8,
818                    (b_sum / count as u64) as u8,
819                ],
820                weight: count as f32 / total_pixels as f32,
821            }
822        })
823        .collect();
824
825    result.sort_by(|a, b| {
826        b.weight
827            .partial_cmp(&a.weight)
828            .unwrap_or(std::cmp::Ordering::Equal)
829    });
830    Ok(result)
831}
832
833/// Compute max colour-space range of a pixel box across R, G, B
834fn box_max_range(pixels: &[[u8; 3]]) -> u16 {
835    let mut ranges = [0u16; 3];
836    for ch in 0..3 {
837        let min_v = pixels.iter().map(|p| p[ch]).min().unwrap_or(0);
838        let max_v = pixels.iter().map(|p| p[ch]).max().unwrap_or(0);
839        ranges[ch] = (max_v as u16).saturating_sub(min_v as u16);
840    }
841    *ranges.iter().max().unwrap_or(&0)
842}
843
844/// Split a pixel box along the channel with the greatest range
845fn split_box(mut pixels: Vec<[u8; 3]>) -> (Vec<[u8; 3]>, Vec<[u8; 3]>) {
846    let mut best_ch = 0usize;
847    let mut best_range = 0u16;
848    for ch in 0..3 {
849        let min_v = pixels.iter().map(|p| p[ch]).min().unwrap_or(0);
850        let max_v = pixels.iter().map(|p| p[ch]).max().unwrap_or(0);
851        let range = (max_v as u16).saturating_sub(min_v as u16);
852        if range > best_range {
853            best_range = range;
854            best_ch = ch;
855        }
856    }
857
858    pixels.sort_by_key(|p| p[best_ch]);
859    let mid = pixels.len() / 2;
860    let second = pixels.split_off(mid);
861    (pixels, second)
862}
863
864// ---------------------------------------------------------------------------
865// Tests
866// ---------------------------------------------------------------------------
867
868#[cfg(test)]
869mod tests {
870    use super::*;
871
872    fn make_rgb(width: u32, height: u32, r: u8, g: u8, b: u8) -> DynamicImage {
873        let mut img = image::RgbImage::new(width, height);
874        for pixel in img.pixels_mut() {
875            *pixel = Rgb([r, g, b]);
876        }
877        DynamicImage::ImageRgb8(img)
878    }
879
880    #[test]
881    fn test_rgb_to_hsl_and_back() {
882        let img = make_rgb(10, 10, 200, 100, 50);
883        let hsl = rgb_to_hsl(&img).expect("rgb_to_hsl failed");
884        let recovered = hsl_to_rgb(&hsl).expect("hsl_to_rgb failed");
885        let orig = img.to_rgb8();
886        let rec = recovered.to_rgb8();
887        let px_o = orig.get_pixel(0, 0);
888        let px_r = rec.get_pixel(0, 0);
889        for ch in 0..3 {
890            assert!(
891                (px_o[ch] as i16 - px_r[ch] as i16).unsigned_abs() <= 3,
892                "Channel {ch} mismatch: orig={}, recovered={}",
893                px_o[ch],
894                px_r[ch]
895            );
896        }
897    }
898
899    #[test]
900    fn test_rgb_to_hsl_gray() {
901        let img = make_rgb(5, 5, 128, 128, 128);
902        let hsl = rgb_to_hsl(&img).expect("rgb_to_hsl failed");
903        let px = hsl.to_rgb8().get_pixel(0, 0).0;
904        assert_eq!(px[1], 0, "Saturation should be 0 for gray");
905    }
906
907    #[test]
908    fn test_rgb_to_ycbcr_and_back() {
909        let img = make_rgb(10, 10, 180, 90, 40);
910        let ycbcr = rgb_to_ycbcr(&img).expect("rgb_to_ycbcr failed");
911        let recovered = ycbcr_to_rgb(&ycbcr).expect("ycbcr_to_rgb failed");
912        let orig = img.to_rgb8();
913        let rec = recovered.to_rgb8();
914        let px_o = orig.get_pixel(0, 0);
915        let px_r = rec.get_pixel(0, 0);
916        for ch in 0..3 {
917            assert!(
918                (px_o[ch] as i16 - px_r[ch] as i16).unsigned_abs() <= 2,
919                "Channel {ch} mismatch: orig={}, recovered={}",
920                px_o[ch],
921                px_r[ch]
922            );
923        }
924    }
925
926    #[test]
927    fn test_rgb_to_ycbcr_white() {
928        let img = make_rgb(5, 5, 255, 255, 255);
929        let ycbcr = rgb_to_ycbcr(&img).expect("rgb_to_ycbcr failed");
930        let px = ycbcr.to_rgb8().get_pixel(0, 0).0;
931        assert!(px[0] >= 250, "Y of white should be near 255, got {}", px[0]);
932        assert!(
933            (px[1] as i16 - 128).unsigned_abs() <= 2,
934            "Cb of white should be ~128, got {}",
935            px[1]
936        );
937    }
938
939    #[test]
940    fn test_rgb_to_xyz_and_back() {
941        let img = make_rgb(10, 10, 150, 100, 80);
942        let xyz = rgb_to_xyz(&img).expect("rgb_to_xyz failed");
943        let recovered = xyz_to_rgb(&xyz).expect("xyz_to_rgb failed");
944        let orig = img.to_rgb8();
945        let rec = recovered.to_rgb8();
946        let px_o = orig.get_pixel(0, 0);
947        let px_r = rec.get_pixel(0, 0);
948        for ch in 0..3 {
949            assert!(
950                (px_o[ch] as i16 - px_r[ch] as i16).unsigned_abs() <= 3,
951                "Channel {ch} mismatch: orig={}, recovered={}",
952                px_o[ch],
953                px_r[ch]
954            );
955        }
956    }
957
958    #[test]
959    fn test_rgb_to_xyz_black() {
960        let img = make_rgb(5, 5, 0, 0, 0);
961        let xyz = rgb_to_xyz(&img).expect("rgb_to_xyz failed");
962        let px = xyz.to_rgb8().get_pixel(0, 0).0;
963        assert_eq!(px[0], 0);
964        assert_eq!(px[1], 0);
965        assert_eq!(px[2], 0);
966    }
967
968    #[test]
969    fn test_extract_dominant_colors_uniform() {
970        let img = make_rgb(20, 20, 100, 50, 200);
971        let colors = extract_dominant_colors(&img, 3).expect("dominant extraction failed");
972        assert!(!colors.is_empty());
973        let dc = &colors[0];
974        assert!((dc.rgb[0] as i16 - 100).unsigned_abs() <= 1);
975        assert!((dc.rgb[1] as i16 - 50).unsigned_abs() <= 1);
976        assert!((dc.rgb[2] as i16 - 200).unsigned_abs() <= 1);
977    }
978
979    #[test]
980    fn test_extract_dominant_colors_two_regions() {
981        let mut img = image::RgbImage::new(20, 10);
982        for y in 0..10 {
983            for x in 0..10 {
984                img.put_pixel(x, y, Rgb([255, 0, 0]));
985            }
986            for x in 10..20 {
987                img.put_pixel(x, y, Rgb([0, 0, 255]));
988            }
989        }
990        let dyn_img = DynamicImage::ImageRgb8(img);
991        let colors = extract_dominant_colors(&dyn_img, 2).expect("dominant extraction failed");
992        assert_eq!(colors.len(), 2);
993        for c in &colors {
994            assert!((c.weight - 0.5).abs() < 0.05);
995        }
996    }
997
998    #[test]
999    fn test_extract_dominant_colors_invalid() {
1000        let img = make_rgb(5, 5, 100, 100, 100);
1001        assert!(extract_dominant_colors(&img, 0).is_err());
1002    }
1003
1004    #[test]
1005    fn test_rgb_to_hsv_and_back() {
1006        let img = make_rgb(10, 10, 200, 100, 50);
1007        let hsv = rgb_to_hsv(&img).expect("rgb_to_hsv failed");
1008        let recovered = hsv_to_rgb(&hsv).expect("hsv_to_rgb failed");
1009        let orig = img.to_rgb8();
1010        let rec = recovered.to_rgb8();
1011        let px_o = orig.get_pixel(0, 0);
1012        let px_r = rec.get_pixel(0, 0);
1013        for ch in 0..3 {
1014            assert!(
1015                (px_o[ch] as i16 - px_r[ch] as i16).unsigned_abs() <= 5,
1016                "Channel {ch} mismatch: orig={}, recovered={}",
1017                px_o[ch],
1018                px_r[ch]
1019            );
1020        }
1021    }
1022
1023    #[test]
1024    fn test_rgb_to_grayscale_test() {
1025        let img = make_rgb(10, 10, 100, 100, 100);
1026        let gray = rgb_to_grayscale(&img, None).expect("grayscale failed");
1027        let luma = gray.to_luma8();
1028        let val = luma.get_pixel(0, 0)[0];
1029        assert!((val as i16 - 100).unsigned_abs() <= 2);
1030    }
1031
1032    #[test]
1033    fn test_split_merge_channels_roundtrip() {
1034        let img = make_rgb(5, 5, 100, 150, 200);
1035        let (r, g, b) = split_channels(&img).expect("split failed");
1036        let merged = merge_channels(&r, &g, &b).expect("merge failed");
1037        let orig = img.to_rgb8();
1038        let rec = merged.to_rgb8();
1039        for yy in 0..5u32 {
1040            for xx in 0..5u32 {
1041                assert_eq!(orig.get_pixel(xx, yy), rec.get_pixel(xx, yy));
1042            }
1043        }
1044    }
1045}