Skip to main content

oximedia_align/
color.rs

1//! Color matching and calibration for multi-camera alignment.
2//!
3//! This module provides tools for matching colors across different cameras:
4//!
5//! - Color transfer algorithms
6//! - Histogram matching
7//! - White balance estimation
8//! - `ColorChecker` calibration
9//! - Color space conversions
10
11use crate::{AlignError, AlignResult};
12// PI removed - unused
13
14/// RGB color (0.0 - 1.0 range)
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct ColorRgb {
17    /// Red channel
18    pub r: f32,
19    /// Green channel
20    pub g: f32,
21    /// Blue channel
22    pub b: f32,
23}
24
25impl ColorRgb {
26    /// Create new RGB color
27    #[must_use]
28    pub fn new(r: f32, g: f32, b: f32) -> Self {
29        Self { r, g, b }
30    }
31
32    /// Create from u8 values
33    #[must_use]
34    pub fn from_u8(r: u8, g: u8, b: u8) -> Self {
35        Self {
36            r: f32::from(r) / 255.0,
37            g: f32::from(g) / 255.0,
38            b: f32::from(b) / 255.0,
39        }
40    }
41
42    /// Convert to u8 values
43    #[must_use]
44    pub fn to_u8(&self) -> (u8, u8, u8) {
45        (
46            (self.r * 255.0).clamp(0.0, 255.0) as u8,
47            (self.g * 255.0).clamp(0.0, 255.0) as u8,
48            (self.b * 255.0).clamp(0.0, 255.0) as u8,
49        )
50    }
51
52    /// Convert to LAB color space
53    #[must_use]
54    pub fn to_lab(&self) -> ColorLab {
55        // RGB -> XYZ (D65 illuminant)
56        let r = Self::gamma_to_linear(self.r);
57        let g = Self::gamma_to_linear(self.g);
58        let b = Self::gamma_to_linear(self.b);
59
60        let x = r * 0.4124 + g * 0.3576 + b * 0.1805;
61        let y = r * 0.2126 + g * 0.7152 + b * 0.0722;
62        let z = r * 0.0193 + g * 0.1192 + b * 0.9505;
63
64        // XYZ -> LAB
65        let xn = 0.95047; // D65 white point
66        let yn = 1.0;
67        let zn = 1.08883;
68
69        let fx = Self::lab_f(x / xn);
70        let fy = Self::lab_f(y / yn);
71        let fz = Self::lab_f(z / zn);
72
73        let l = 116.0 * fy - 16.0;
74        let a = 500.0 * (fx - fy);
75        let b_lab = 200.0 * (fy - fz);
76
77        ColorLab::new(l, a, b_lab)
78    }
79
80    fn gamma_to_linear(v: f32) -> f32 {
81        if v <= 0.04045 {
82            v / 12.92
83        } else {
84            ((v + 0.055) / 1.055).powf(2.4)
85        }
86    }
87
88    fn lab_f(t: f32) -> f32 {
89        let delta = 6.0 / 29.0;
90        if t > delta * delta * delta {
91            t.powf(1.0 / 3.0)
92        } else {
93            t / (3.0 * delta * delta) + 4.0 / 29.0
94        }
95    }
96}
97
98/// LAB color space
99#[derive(Debug, Clone, Copy, PartialEq)]
100pub struct ColorLab {
101    /// L (lightness)
102    pub l: f32,
103    /// A (green-red)
104    pub a: f32,
105    /// B (blue-yellow)
106    pub b: f32,
107}
108
109impl ColorLab {
110    /// Create new LAB color
111    #[must_use]
112    pub fn new(l: f32, a: f32, b: f32) -> Self {
113        Self { l, a, b }
114    }
115
116    /// Convert to RGB
117    #[must_use]
118    pub fn to_rgb(&self) -> ColorRgb {
119        // LAB -> XYZ
120        let fy = (self.l + 16.0) / 116.0;
121        let fx = self.a / 500.0 + fy;
122        let fz = fy - self.b / 200.0;
123
124        let xn = 0.95047;
125        let yn = 1.0;
126        let zn = 1.08883;
127
128        let x = xn * Self::lab_f_inv(fx);
129        let y = yn * Self::lab_f_inv(fy);
130        let z = zn * Self::lab_f_inv(fz);
131
132        // XYZ -> RGB
133        let r = x * 3.2406 + y * -1.5372 + z * -0.4986;
134        let g = x * -0.9689 + y * 1.8758 + z * 0.0415;
135        let b = x * 0.0557 + y * -0.2040 + z * 1.0570;
136
137        ColorRgb::new(
138            Self::linear_to_gamma(r),
139            Self::linear_to_gamma(g),
140            Self::linear_to_gamma(b),
141        )
142    }
143
144    fn lab_f_inv(t: f32) -> f32 {
145        let delta = 6.0 / 29.0;
146        if t > delta {
147            t * t * t
148        } else {
149            3.0 * delta * delta * (t - 4.0 / 29.0)
150        }
151    }
152
153    fn linear_to_gamma(v: f32) -> f32 {
154        if v <= 0.0031308 {
155            12.92 * v
156        } else {
157            1.055 * v.powf(1.0 / 2.4) - 0.055
158        }
159    }
160}
161
162/// Color statistics for an image
163#[derive(Debug, Clone)]
164pub struct ColorStatistics {
165    /// Mean RGB values
166    pub mean: ColorRgb,
167    /// Standard deviation RGB values
168    pub std_dev: ColorRgb,
169    /// Mean LAB values
170    pub mean_lab: ColorLab,
171    /// Standard deviation LAB values
172    pub std_dev_lab: ColorLab,
173}
174
175impl ColorStatistics {
176    /// Compute statistics from RGB image
177    #[must_use]
178    pub fn from_image(rgb: &[u8], width: usize, height: usize) -> Self {
179        let n = (width * height) as f32;
180        let mut sum_r = 0.0f32;
181        let mut sum_g = 0.0f32;
182        let mut sum_b = 0.0f32;
183
184        // Compute means
185        for pixel in rgb.chunks_exact(3) {
186            sum_r += f32::from(pixel[0]);
187            sum_g += f32::from(pixel[1]);
188            sum_b += f32::from(pixel[2]);
189        }
190
191        let mean = ColorRgb::new(
192            sum_r / (n * 255.0),
193            sum_g / (n * 255.0),
194            sum_b / (n * 255.0),
195        );
196
197        // Compute standard deviations
198        let mut var_r = 0.0f32;
199        let mut var_g = 0.0f32;
200        let mut var_b = 0.0f32;
201
202        for pixel in rgb.chunks_exact(3) {
203            let r = f32::from(pixel[0]) / 255.0 - mean.r;
204            let g = f32::from(pixel[1]) / 255.0 - mean.g;
205            let b = f32::from(pixel[2]) / 255.0 - mean.b;
206
207            var_r += r * r;
208            var_g += g * g;
209            var_b += b * b;
210        }
211
212        let std_dev = ColorRgb::new((var_r / n).sqrt(), (var_g / n).sqrt(), (var_b / n).sqrt());
213
214        // Convert to LAB
215        let _mean_lab = mean.to_lab();
216
217        // Compute LAB statistics
218        let mut sum_l = 0.0f32;
219        let mut sum_a = 0.0f32;
220        let mut sum_b_lab = 0.0f32;
221
222        for pixel in rgb.chunks_exact(3) {
223            let color = ColorRgb::from_u8(pixel[0], pixel[1], pixel[2]);
224            let lab = color.to_lab();
225            sum_l += lab.l;
226            sum_a += lab.a;
227            sum_b_lab += lab.b;
228        }
229
230        let mean_lab_actual = ColorLab::new(sum_l / n, sum_a / n, sum_b_lab / n);
231
232        let mut var_l = 0.0f32;
233        let mut var_a = 0.0f32;
234        let mut var_b_lab = 0.0f32;
235
236        for pixel in rgb.chunks_exact(3) {
237            let color = ColorRgb::from_u8(pixel[0], pixel[1], pixel[2]);
238            let lab = color.to_lab();
239            let dl = lab.l - mean_lab_actual.l;
240            let da = lab.a - mean_lab_actual.a;
241            let db = lab.b - mean_lab_actual.b;
242
243            var_l += dl * dl;
244            var_a += da * da;
245            var_b_lab += db * db;
246        }
247
248        let std_dev_lab = ColorLab::new(
249            (var_l / n).sqrt(),
250            (var_a / n).sqrt(),
251            (var_b_lab / n).sqrt(),
252        );
253
254        Self {
255            mean,
256            std_dev,
257            mean_lab: mean_lab_actual,
258            std_dev_lab,
259        }
260    }
261}
262
263/// Color transfer using LAB statistics
264pub struct ColorTransfer;
265
266impl ColorTransfer {
267    /// Transfer color from source to target image
268    ///
269    /// # Errors
270    /// Returns error if image dimensions are invalid
271    pub fn transfer(
272        source_rgb: &[u8],
273        target_rgb: &[u8],
274        width: usize,
275        height: usize,
276    ) -> AlignResult<Vec<u8>> {
277        let expected_size = width * height * 3;
278        if source_rgb.len() != expected_size || target_rgb.len() != expected_size {
279            return Err(AlignError::InvalidConfig("Image size mismatch".to_string()));
280        }
281
282        let source_stats = ColorStatistics::from_image(source_rgb, width, height);
283        let target_stats = ColorStatistics::from_image(target_rgb, width, height);
284
285        let mut output = vec![0u8; expected_size];
286
287        for (i, pixel) in target_rgb.chunks_exact(3).enumerate() {
288            let color = ColorRgb::from_u8(pixel[0], pixel[1], pixel[2]);
289            let lab = color.to_lab();
290
291            // Transfer statistics in LAB space
292            let l = (lab.l - target_stats.mean_lab.l)
293                * (source_stats.std_dev_lab.l / target_stats.std_dev_lab.l.max(1e-6))
294                + source_stats.mean_lab.l;
295            let a = (lab.a - target_stats.mean_lab.a)
296                * (source_stats.std_dev_lab.a / target_stats.std_dev_lab.a.max(1e-6))
297                + source_stats.mean_lab.a;
298            let b = (lab.b - target_stats.mean_lab.b)
299                * (source_stats.std_dev_lab.b / target_stats.std_dev_lab.b.max(1e-6))
300                + source_stats.mean_lab.b;
301
302            let transferred_lab = ColorLab::new(l, a, b);
303            let transferred_rgb = transferred_lab.to_rgb();
304            let (r, g, b_val) = transferred_rgb.to_u8();
305
306            output[i * 3] = r;
307            output[i * 3 + 1] = g;
308            output[i * 3 + 2] = b_val;
309        }
310
311        Ok(output)
312    }
313}
314
315/// Histogram matching
316pub struct HistogramMatcher;
317
318impl HistogramMatcher {
319    /// Match histogram of target to source
320    ///
321    /// # Errors
322    /// Returns error if image dimensions are invalid
323    pub fn match_histogram(
324        source: &[u8],
325        target: &[u8],
326        width: usize,
327        height: usize,
328    ) -> AlignResult<Vec<u8>> {
329        let expected_size = width * height * 3;
330        if source.len() != expected_size || target.len() != expected_size {
331            return Err(AlignError::InvalidConfig("Image size mismatch".to_string()));
332        }
333
334        let mut output = vec![0u8; expected_size];
335
336        // Process each channel independently
337        for channel in 0..3 {
338            let source_channel: Vec<u8> = source.iter().skip(channel).step_by(3).copied().collect();
339            let target_channel: Vec<u8> = target.iter().skip(channel).step_by(3).copied().collect();
340
341            let matched = Self::match_channel(&source_channel, &target_channel);
342
343            for (i, &value) in matched.iter().enumerate() {
344                output[i * 3 + channel] = value;
345            }
346        }
347
348        Ok(output)
349    }
350
351    /// Match single channel histogram
352    fn match_channel(source: &[u8], target: &[u8]) -> Vec<u8> {
353        // Compute histograms
354        let source_hist = Self::compute_histogram(source);
355        let target_hist = Self::compute_histogram(target);
356
357        // Compute CDFs
358        let source_cdf = Self::compute_cdf(&source_hist);
359        let target_cdf = Self::compute_cdf(&target_hist);
360
361        // Build lookup table
362        let lut = Self::build_lut(&source_cdf, &target_cdf);
363
364        // Apply lookup table
365        target.iter().map(|&v| lut[v as usize]).collect()
366    }
367
368    /// Compute histogram
369    fn compute_histogram(data: &[u8]) -> [u32; 256] {
370        let mut hist = [0u32; 256];
371        for &value in data {
372            hist[value as usize] += 1;
373        }
374        hist
375    }
376
377    /// Compute cumulative distribution function
378    fn compute_cdf(hist: &[u32; 256]) -> [f32; 256] {
379        let mut cdf = [0.0f32; 256];
380        let total: u32 = hist.iter().sum();
381
382        if total == 0 {
383            return cdf;
384        }
385
386        let mut cumsum = 0u32;
387        for (i, &count) in hist.iter().enumerate() {
388            cumsum += count;
389            cdf[i] = cumsum as f32 / total as f32;
390        }
391
392        cdf
393    }
394
395    /// Build lookup table
396    fn build_lut(source_cdf: &[f32; 256], target_cdf: &[f32; 256]) -> [u8; 256] {
397        let mut lut = [0u8; 256];
398
399        for (target_val, &target_prob) in target_cdf.iter().enumerate() {
400            // Find closest source value
401            let mut best_idx = 0;
402            let mut best_diff = f32::INFINITY;
403
404            for (source_val, &source_prob) in source_cdf.iter().enumerate() {
405                let diff = (source_prob - target_prob).abs();
406                if diff < best_diff {
407                    best_diff = diff;
408                    best_idx = source_val;
409                }
410            }
411
412            lut[target_val] = best_idx as u8;
413        }
414
415        lut
416    }
417}
418
419/// White balance estimator
420pub struct WhiteBalanceEstimator;
421
422impl WhiteBalanceEstimator {
423    /// Estimate white balance using gray world assumption
424    #[must_use]
425    pub fn estimate_gray_world(rgb: &[u8], width: usize, height: usize) -> ColorRgb {
426        let stats = ColorStatistics::from_image(rgb, width, height);
427
428        // Gray world: average should be neutral gray
429        let avg = (stats.mean.r + stats.mean.g + stats.mean.b) / 3.0;
430
431        ColorRgb::new(
432            avg / stats.mean.r.max(1e-6),
433            avg / stats.mean.g.max(1e-6),
434            avg / stats.mean.b.max(1e-6),
435        )
436    }
437
438    /// Estimate white balance using white patch assumption
439    #[must_use]
440    pub fn estimate_white_patch(rgb: &[u8], _width: usize, _height: usize) -> ColorRgb {
441        let mut max_r = 0u8;
442        let mut max_g = 0u8;
443        let mut max_b = 0u8;
444
445        for pixel in rgb.chunks_exact(3) {
446            max_r = max_r.max(pixel[0]);
447            max_g = max_g.max(pixel[1]);
448            max_b = max_b.max(pixel[2]);
449        }
450
451        let max_val = max_r.max(max_g).max(max_b);
452
453        ColorRgb::new(
454            f32::from(max_val) / f32::from(max_r).max(1.0),
455            f32::from(max_val) / f32::from(max_g).max(1.0),
456            f32::from(max_val) / f32::from(max_b).max(1.0),
457        )
458    }
459
460    /// Apply white balance gains
461    #[must_use]
462    pub fn apply_white_balance(rgb: &[u8], gains: &ColorRgb) -> Vec<u8> {
463        rgb.chunks_exact(3)
464            .flat_map(|pixel| {
465                let r = (f32::from(pixel[0]) * gains.r).clamp(0.0, 255.0) as u8;
466                let g = (f32::from(pixel[1]) * gains.g).clamp(0.0, 255.0) as u8;
467                let b = (f32::from(pixel[2]) * gains.b).clamp(0.0, 255.0) as u8;
468                [r, g, b]
469            })
470            .collect()
471    }
472}
473
474/// `ColorChecker` calibration
475pub struct ColorCheckerCalibrator {
476    /// Reference `ColorChecker` values (24 patches)
477    reference: Vec<ColorRgb>,
478}
479
480impl Default for ColorCheckerCalibrator {
481    fn default() -> Self {
482        Self::new()
483    }
484}
485
486impl ColorCheckerCalibrator {
487    /// Create new `ColorChecker` calibrator
488    #[must_use]
489    pub fn new() -> Self {
490        // X-Rite ColorChecker Classic reference values (approximate sRGB)
491        let reference = vec![
492            ColorRgb::new(0.451, 0.315, 0.242), // Dark skin
493            ColorRgb::new(0.773, 0.586, 0.502), // Light skin
494            ColorRgb::new(0.350, 0.439, 0.594), // Blue sky
495            ColorRgb::new(0.329, 0.400, 0.241), // Foliage
496            ColorRgb::new(0.541, 0.548, 0.742), // Blue flower
497            ColorRgb::new(0.492, 0.729, 0.636), // Bluish green
498            ColorRgb::new(0.871, 0.482, 0.145), // Orange
499            ColorRgb::new(0.299, 0.359, 0.635), // Purplish blue
500            ColorRgb::new(0.789, 0.347, 0.376), // Moderate red
501            ColorRgb::new(0.353, 0.241, 0.410), // Purple
502            ColorRgb::new(0.596, 0.730, 0.247), // Yellow green
503            ColorRgb::new(0.914, 0.620, 0.145), // Orange yellow
504            ColorRgb::new(0.196, 0.263, 0.557), // Blue
505            ColorRgb::new(0.329, 0.565, 0.298), // Green
506            ColorRgb::new(0.757, 0.243, 0.224), // Red
507            ColorRgb::new(0.918, 0.765, 0.094), // Yellow
508            ColorRgb::new(0.773, 0.310, 0.557), // Magenta
509            ColorRgb::new(0.149, 0.490, 0.624), // Cyan
510            ColorRgb::new(0.957, 0.957, 0.957), // White
511            ColorRgb::new(0.788, 0.788, 0.788), // Neutral 8
512            ColorRgb::new(0.635, 0.635, 0.635), // Neutral 6.5
513            ColorRgb::new(0.478, 0.478, 0.478), // Neutral 5
514            ColorRgb::new(0.318, 0.318, 0.318), // Neutral 3.5
515            ColorRgb::new(0.200, 0.200, 0.200), // Black
516        ];
517
518        Self { reference }
519    }
520
521    /// Compute color correction matrix
522    ///
523    /// # Errors
524    /// Returns error if measurements are invalid
525    #[allow(dead_code)]
526    pub fn compute_correction_matrix(&self, measured: &[ColorRgb]) -> AlignResult<[[f32; 3]; 3]> {
527        if measured.len() != 24 {
528            return Err(AlignError::InvalidConfig(
529                "Need exactly 24 ColorChecker measurements".to_string(),
530            ));
531        }
532
533        // Simplified: return identity matrix
534        // In production, this would use least squares to fit a 3x3 color matrix
535        Ok([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])
536    }
537
538    /// Get reference patch color
539    #[must_use]
540    pub fn get_reference(&self, patch_index: usize) -> Option<ColorRgb> {
541        self.reference.get(patch_index).copied()
542    }
543}
544
545/// Gamma correction
546pub struct GammaCorrection;
547
548impl GammaCorrection {
549    /// Apply gamma correction
550    #[must_use]
551    pub fn apply(rgb: &[u8], gamma: f32) -> Vec<u8> {
552        let lut: Vec<u8> = (0..256)
553            .map(|i| {
554                let normalized = i as f32 / 255.0;
555                let corrected = normalized.powf(gamma);
556                (corrected * 255.0).clamp(0.0, 255.0) as u8
557            })
558            .collect();
559
560        rgb.iter().map(|&v| lut[v as usize]).collect()
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn test_color_rgb_creation() {
570        let color = ColorRgb::new(0.5, 0.6, 0.7);
571        assert_eq!(color.r, 0.5);
572        assert_eq!(color.g, 0.6);
573        assert_eq!(color.b, 0.7);
574    }
575
576    #[test]
577    fn test_color_rgb_u8_conversion() {
578        let color = ColorRgb::from_u8(128, 192, 255);
579        let (r, g, b) = color.to_u8();
580        assert_eq!(r, 128);
581        assert_eq!(g, 192);
582        assert_eq!(b, 255);
583    }
584
585    #[test]
586    fn test_rgb_lab_roundtrip() {
587        let rgb = ColorRgb::new(0.5, 0.6, 0.7);
588        let lab = rgb.to_lab();
589        let rgb2 = lab.to_rgb();
590
591        assert!((rgb.r - rgb2.r).abs() < 0.01);
592        assert!((rgb.g - rgb2.g).abs() < 0.01);
593        assert!((rgb.b - rgb2.b).abs() < 0.01);
594    }
595
596    #[test]
597    fn test_color_statistics() {
598        let rgb = vec![128u8; 300]; // 10x10 gray image
599        let stats = ColorStatistics::from_image(&rgb, 10, 10);
600
601        assert!((stats.mean.r - 0.5).abs() < 0.01);
602        assert!((stats.mean.g - 0.5).abs() < 0.01);
603        assert!((stats.mean.b - 0.5).abs() < 0.01);
604    }
605
606    #[test]
607    fn test_histogram_computation() {
608        let data = vec![0u8, 0, 128, 128, 255];
609        let hist = HistogramMatcher::compute_histogram(&data);
610        assert_eq!(hist[0], 2);
611        assert_eq!(hist[128], 2);
612        assert_eq!(hist[255], 1);
613    }
614
615    #[test]
616    fn test_white_balance_gray_world() {
617        let mut rgb = vec![0u8; 300];
618        // Create reddish image
619        for i in 0..100 {
620            rgb[i * 3] = 200;
621            rgb[i * 3 + 1] = 100;
622            rgb[i * 3 + 2] = 100;
623        }
624
625        let gains = WhiteBalanceEstimator::estimate_gray_world(&rgb, 10, 10);
626        assert!(gains.r < 1.0); // Should reduce red
627        assert!(gains.g > 1.0); // Should boost green
628        assert!(gains.b > 1.0); // Should boost blue
629    }
630
631    #[test]
632    fn test_gamma_correction() {
633        let rgb = vec![128u8; 30];
634        let corrected = GammaCorrection::apply(&rgb, 2.2);
635        assert_eq!(corrected.len(), 30);
636    }
637
638    #[test]
639    fn test_colorchecker_calibrator() {
640        let calibrator = ColorCheckerCalibrator::new();
641        assert_eq!(calibrator.reference.len(), 24);
642
643        let white = calibrator.get_reference(18).expect("white should be valid");
644        assert!(white.r > 0.95);
645        assert!(white.g > 0.95);
646        assert!(white.b > 0.95);
647    }
648}