Skip to main content

rawshift_image/transforms/
color.rs

1//! Color space transformations.
2//!
3//! This transform module provides the canonical entry points for color processing:
4//! - White Balance application
5//! - Color Matrix application (Camera RGB -> Output RGB)
6//!
7//! It re-exports the optimized primitives from [`crate::processing::color`] and
8//! provides the [`ColorSpaceTransform`] struct for bundled pipeline steps.
9
10use crate::core::image::RgbImage;
11use crate::error::{RawError, RawResult};
12
13// Re-export processing primitives as canonical transform entry points.
14pub use crate::processing::color::{
15    apply_color_matrix, apply_white_balance, apply_white_balance_raw,
16};
17
18/// Matrix to convert from CIE XYZ to sRGB (D65).
19pub const XYZ_TO_SRGB_D65: [f64; 9] = [
20    3.2404542, -1.5371385, -0.4985314, -0.9692660, 1.8760108, 0.0415560, 0.0556434, -0.2040259,
21    1.0572252,
22];
23
24/// Compute the determinant of a 3x3 row-major matrix.
25fn det_3x3(m: &[f64; 9]) -> f64 {
26    m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6])
27        + m[2] * (m[3] * m[7] - m[4] * m[6])
28}
29
30/// Invert a 3x3 row-major matrix. Returns `None` if the matrix is singular.
31fn invert_3x3(m: &[f64; 9]) -> Option<[f64; 9]> {
32    let det = det_3x3(m);
33    if det.abs() < 1e-12 {
34        return None;
35    }
36    let inv_det = 1.0 / det;
37    Some([
38        (m[4] * m[8] - m[5] * m[7]) * inv_det,
39        (m[2] * m[7] - m[1] * m[8]) * inv_det,
40        (m[1] * m[5] - m[2] * m[4]) * inv_det,
41        (m[5] * m[6] - m[3] * m[8]) * inv_det,
42        (m[0] * m[8] - m[2] * m[6]) * inv_det,
43        (m[2] * m[3] - m[0] * m[5]) * inv_det,
44        (m[3] * m[7] - m[4] * m[6]) * inv_det,
45        (m[1] * m[6] - m[0] * m[7]) * inv_det,
46        (m[0] * m[4] - m[1] * m[3]) * inv_det,
47    ])
48}
49
50/// Multiply two 3x3 row-major matrices: result = a * b.
51fn multiply_3x3(a: &[f64; 9], b: &[f64; 9]) -> [f64; 9] {
52    [
53        a[0] * b[0] + a[1] * b[3] + a[2] * b[6],
54        a[0] * b[1] + a[1] * b[4] + a[2] * b[7],
55        a[0] * b[2] + a[1] * b[5] + a[2] * b[8],
56        a[3] * b[0] + a[4] * b[3] + a[5] * b[6],
57        a[3] * b[1] + a[4] * b[4] + a[5] * b[7],
58        a[3] * b[2] + a[4] * b[5] + a[5] * b[8],
59        a[6] * b[0] + a[7] * b[3] + a[8] * b[6],
60        a[6] * b[1] + a[7] * b[4] + a[8] * b[7],
61        a[6] * b[2] + a[7] * b[5] + a[8] * b[8],
62    ]
63}
64
65/// Compute a Camera→sRGB color matrix from a DNG-style XYZ→Camera color matrix.
66///
67/// The DNG `ColorMatrix` maps CIE XYZ to camera-native RGB. To convert camera
68/// data to sRGB we need: `sRGB = XYZ_TO_SRGB * inverse(ColorMatrix)`.
69///
70/// Returns `None` if the color matrix is singular.
71pub fn compute_camera_to_srgb(xyz_to_camera: &[f64; 9]) -> Option<[f32; 9]> {
72    let camera_to_xyz = invert_3x3(xyz_to_camera)?;
73    let camera_to_srgb = multiply_3x3(&XYZ_TO_SRGB_D65, &camera_to_xyz);
74    Some(camera_to_srgb.map(|v| v as f32))
75}
76
77/// Pipeline step for color space corrections.
78///
79/// Bundles white balance and color matrix into a single transform step.
80/// Tone mapping / gamma is handled separately by [`crate::transforms::tonemap`].
81pub struct ColorSpaceTransform {
82    /// White balance multipliers (R, G, B)
83    pub wb_coeffs: (f32, f32, f32),
84    /// Color matrix (Camera RGB -> Output RGB)
85    /// This should be the pre-calculated product of:
86    /// XYZ->Output * Camera->XYZ
87    pub color_matrix: [f32; 9],
88}
89
90impl ColorSpaceTransform {
91    /// Create a new color transform with specific settings.
92    pub fn new(wb_coeffs: (f32, f32, f32), color_matrix: [f32; 9]) -> Self {
93        Self {
94            wb_coeffs,
95            color_matrix,
96        }
97    }
98
99    /// Apply the color transformation pipeline to an image in-place.
100    ///
101    /// 1. Apply White Balance (Linear -> Linear)
102    /// 2. Apply Color Matrix (Camera Linear -> Output Linear)
103    pub fn apply(&self, image: &mut RgbImage) -> RawResult<()> {
104        apply_white_balance(image, self.wb_coeffs);
105        apply_color_matrix(image, &self.color_matrix);
106        Ok(())
107    }
108}
109
110// =============================================================================
111// Dual-illuminant colour matrix interpolation
112// =============================================================================
113
114/// Correlated colour temperature (CCT) in Kelvin.
115///
116/// Used to select the interpolation weight between dual-illuminant matrices.
117pub type ColorTemperature = f32;
118
119/// Interpolate between two colour matrices based on colour temperature.
120///
121/// DNG specifies two calibration matrices for different standard illuminants
122/// (e.g. Standard A at 2856 K and D65 at 6500 K). This function blends
123/// between them for the measured scene colour temperature using the reciprocal
124/// CCT (mired) scale, which is the industry-standard approach.
125///
126/// The interpolation parameter `t` is:
127/// ```text
128/// t = (1/cct_scene − 1/cct_1) / (1/cct_2 − 1/cct_1)
129/// ```
130/// clamped to `[0, 1]`. The result is then:
131/// ```text
132/// matrix = (1 − t) * matrix_1 + t * matrix_2
133/// ```
134///
135/// # Arguments
136/// * `matrix_1`  - Colour matrix for illuminant 1 (row-major 3×3, e.g. Standard A / 2856 K).
137/// * `cct_1`     - Colour temperature for `matrix_1` in Kelvin (e.g. 2856.0).
138/// * `matrix_2`  - Colour matrix for illuminant 2 (row-major 3×3, e.g. D65 / 6500 K).
139/// * `cct_2`     - Colour temperature for `matrix_2` in Kelvin (e.g. 6500.0).
140/// * `scene_cct` - Estimated scene colour temperature in Kelvin.
141///
142/// Returns the interpolated 3×3 row-major matrix.
143pub fn interpolate_color_matrix(
144    matrix_1: &[[f64; 3]; 3],
145    cct_1: ColorTemperature,
146    matrix_2: &[[f64; 3]; 3],
147    cct_2: ColorTemperature,
148    scene_cct: ColorTemperature,
149) -> [[f64; 3]; 3] {
150    let denom = (1.0 / cct_2 as f64) - (1.0 / cct_1 as f64);
151    let t = if denom.abs() < 1e-12 {
152        0.0_f64
153    } else {
154        let numer = (1.0 / scene_cct as f64) - (1.0 / cct_1 as f64);
155        (numer / denom).clamp(0.0, 1.0)
156    };
157
158    let mut result = [[0.0_f64; 3]; 3];
159    for row in 0..3 {
160        for col in 0..3 {
161            result[row][col] = (1.0 - t) * matrix_1[row][col] + t * matrix_2[row][col];
162        }
163    }
164    result
165}
166
167/// Estimate scene colour temperature from as-shot neutral (white balance) values.
168///
169/// The as-shot neutral vector records the reciprocal gain applied to each
170/// camera channel so that a neutral (white) object renders as equal R, G, B.
171/// This function uses the B/R ratio as a proxy for colour temperature:
172///
173/// - Warm (tungsten, ~2800 K): high R, low B → low B/R ratio.
174/// - Cool (daylight, ~6500 K): balanced R/B → B/R ≈ 1.
175///
176/// The approximation `CCT ≈ 3000 + 9000 × (B/R)` is clamped to [2000, 10000] K.
177///
178/// # Arguments
179/// * `as_shot_neutral` - `[R, G, B]` neutral gain vector (values in (0, 1]).
180///
181/// Returns an approximate CCT in Kelvin.
182pub fn estimate_cct_from_as_shot_neutral(as_shot_neutral: [f64; 3]) -> ColorTemperature {
183    let r = as_shot_neutral[0].max(1e-6);
184    let b = as_shot_neutral[2].max(1e-6);
185    let rb = b / r;
186    (3000.0 + 9000.0 * rb).clamp(2000.0, 10000.0) as f32
187}
188
189/// Convert an [`RgbImage`] into sRGB-encoded color space, in place.
190///
191/// Behaviour depends on the image's current [`ColorSpace`](crate::core::ColorSpace):
192/// - `Srgb` / `Unknown` — no-op (`Unknown` is assumed to be sRGB already).
193/// - `LinearSrgb` — applies the sRGB transfer function (OETF).
194/// - `DisplayP3` / `Rec2020` / `AdobeRgb` / `ProPhotoRgb` — not yet supported;
195///   returns [`RawError::Unsupported`]. Wide-gamut conversion needs a
196///   color-management engine, which is planned follow-up work.
197///
198/// On success the image's color-space tag is updated to `Srgb`.
199pub fn convert_to_srgb(image: &mut RgbImage) -> RawResult<()> {
200    use crate::core::ColorSpace;
201    use crate::transforms::tonemap::srgb_encode;
202
203    match image.color_space() {
204        ColorSpace::Srgb | ColorSpace::Unknown => {}
205        ColorSpace::LinearSrgb => {
206            for sample in &mut image.data {
207                let linear = *sample as f32 / 65535.0;
208                *sample = (srgb_encode(linear) * 65535.0 + 0.5) as u16;
209            }
210        }
211        other => {
212            return Err(RawError::Unsupported(format!(
213                "conversion from {} to sRGB requires a color-management engine \
214                 (not yet implemented)",
215                other.name()
216            )));
217        }
218    }
219    image.set_color_space(ColorSpace::Srgb);
220    Ok(())
221}
222
223#[cfg(test)]
224mod convert_srgb_tests {
225    use super::*;
226    use crate::core::ColorSpace;
227
228    #[test]
229    fn linear_srgb_is_oetf_encoded() {
230        // The sRGB OETF lifts linear mid-grey above 0.5.
231        let mut img =
232            RgbImage::with_color_space(1, 1, vec![32768, 32768, 32768], ColorSpace::LinearSrgb);
233        convert_to_srgb(&mut img).expect("LinearSrgb conversion");
234        assert_eq!(img.color_space(), ColorSpace::Srgb);
235        assert!(img.data.iter().all(|&v| v > 32768));
236    }
237
238    #[test]
239    fn srgb_and_unknown_are_noops() {
240        for cs in [ColorSpace::Srgb, ColorSpace::Unknown] {
241            let original = vec![100u16, 200, 300];
242            let mut img = RgbImage::with_color_space(1, 1, original.clone(), cs);
243            convert_to_srgb(&mut img).expect("no-op conversion");
244            assert_eq!(img.data, original);
245            assert_eq!(img.color_space(), ColorSpace::Srgb);
246        }
247    }
248
249    #[test]
250    fn wide_gamut_is_rejected() {
251        let mut img = RgbImage::with_color_space(1, 1, vec![0, 0, 0], ColorSpace::DisplayP3);
252        assert!(convert_to_srgb(&mut img).is_err());
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    const EPSILON: f64 = 1e-6;
261
262    fn assert_matrix_near(a: &[f64; 9], b: &[f64; 9], eps: f64) {
263        for (i, (&x, &y)) in a.iter().zip(b.iter()).enumerate() {
264            assert!(
265                (x - y).abs() < eps,
266                "element [{}] differs: {} vs {} (diff {})",
267                i,
268                x,
269                y,
270                (x - y).abs()
271            );
272        }
273    }
274
275    #[test]
276    fn test_identity_inverse() {
277        let identity = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
278        let inv = invert_3x3(&identity).unwrap();
279        assert_matrix_near(&inv, &identity, EPSILON);
280    }
281
282    #[test]
283    fn test_inverse_roundtrip() {
284        // Use a known non-singular matrix (Sony ILCE-7RM5 ColorMatrix2)
285        let cm = [
286            0.8200, -0.2976, -0.0719, -0.4296, 1.2053, 0.2532, -0.0429, 0.1282, 0.5774,
287        ];
288        let inv = invert_3x3(&cm).unwrap();
289        let product = multiply_3x3(&cm, &inv);
290        let identity = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
291        assert_matrix_near(&product, &identity, 1e-10);
292    }
293
294    #[test]
295    fn test_singular_matrix_returns_none() {
296        // All-zero row → singular
297        let singular = [1.0, 2.0, 3.0, 0.0, 0.0, 0.0, 4.0, 5.0, 6.0];
298        assert!(invert_3x3(&singular).is_none());
299    }
300
301    #[test]
302    fn test_multiply_identity() {
303        let identity = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
304        let m = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
305        let result = multiply_3x3(&identity, &m);
306        assert_matrix_near(&result, &m, EPSILON);
307    }
308
309    #[test]
310    fn test_compute_camera_to_srgb_produces_valid_matrix() {
311        // Sony ILCE-7RM5 ColorMatrix2 (D65)
312        let cm = [
313            0.8200, -0.2976, -0.0719, -0.4296, 1.2053, 0.2532, -0.0429, 0.1282, 0.5774,
314        ];
315        let result = compute_camera_to_srgb(&cm).unwrap();
316
317        // The camera-to-sRGB matrix should have reasonable values:
318        // - Diagonal should be positive (each channel maps mostly to itself)
319        assert!(result[0] > 0.0, "R→R should be positive: {}", result[0]);
320        assert!(result[4] > 0.0, "G→G should be positive: {}", result[4]);
321        assert!(result[8] > 0.0, "B→B should be positive: {}", result[8]);
322
323        // - No element should be wildly large (indicates numerical instability)
324        for (i, &v) in result.iter().enumerate() {
325            assert!(
326                v.abs() < 20.0,
327                "element [{}] is unreasonably large: {}",
328                i,
329                v
330            );
331        }
332    }
333
334    #[test]
335    fn test_compute_camera_to_srgb_singular_returns_none() {
336        let singular = [1.0, 2.0, 3.0, 2.0, 4.0, 6.0, 1.0, 2.0, 3.0];
337        assert!(compute_camera_to_srgb(&singular).is_none());
338    }
339
340    #[test]
341    fn test_all_camera_db_matrices_are_invertible() {
342        for cam in crate::data::cameras::all_cameras() {
343            if let Some(cm) = &cam.color_matrix_1 {
344                assert!(
345                    compute_camera_to_srgb(cm).is_some(),
346                    "ColorMatrix1 for {} is singular",
347                    cam.model
348                );
349            }
350            if let Some(cm) = &cam.color_matrix_2 {
351                assert!(
352                    compute_camera_to_srgb(cm).is_some(),
353                    "ColorMatrix2 for {} is singular",
354                    cam.model
355                );
356            }
357        }
358    }
359
360    #[test]
361    fn test_apply_white_balance_clamps_at_white_level() {
362        use crate::core::image::RgbImage;
363        use crate::processing::color::apply_white_balance;
364
365        // Pixel near max with a large gain should clamp at 65535
366        let mut img = RgbImage::new(1, 1, vec![60000u16, 60000, 60000]);
367        apply_white_balance(&mut img, (3.0, 3.0, 3.0));
368        assert_eq!(img.data[0], 65535, "R should clamp at 65535");
369        assert_eq!(img.data[1], 65535, "G should clamp at 65535");
370        assert_eq!(img.data[2], 65535, "B should clamp at 65535");
371    }
372
373    #[test]
374    fn test_compute_camera_to_srgb_identity() {
375        // The identity matrix for XYZ->Camera is the identity itself.
376        // camera_to_xyz = inv(identity) = identity
377        // camera_to_srgb = XYZ_TO_SRGB * identity = XYZ_TO_SRGB
378        let identity = [1.0f64, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
379        let result = compute_camera_to_srgb(&identity).unwrap();
380        // Result should equal XYZ_TO_SRGB_D65 (cast to f32)
381        for (i, (&got, &expected)) in result.iter().zip(XYZ_TO_SRGB_D65.iter()).enumerate() {
382            assert!(
383                (got - expected as f32).abs() < 1e-4,
384                "Element [{}]: got {} expected {}",
385                i,
386                got,
387                expected
388            );
389        }
390    }
391
392    #[test]
393    fn test_apply_color_matrix_zero_input() {
394        use crate::core::image::RgbImage;
395        use crate::processing::color::apply_color_matrix;
396
397        let any_matrix: [f32; 9] = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
398        let mut img = RgbImage::new(2, 1, vec![0u16; 6]);
399        apply_color_matrix(&mut img, &any_matrix);
400        for v in &img.data {
401            assert_eq!(*v, 0, "Zero input should produce zero output");
402        }
403    }
404
405    #[test]
406    fn test_apply_color_matrix_roundtrip() {
407        use crate::core::image::RgbImage;
408        use crate::processing::color::apply_color_matrix;
409
410        // Use a known camera matrix and its inverse for a round-trip test.
411        let cm_f64 = [
412            0.8200f64, -0.2976, -0.0719, -0.4296, 1.2053, 0.2532, -0.0429, 0.1282, 0.5774,
413        ];
414        let inv_f64 = invert_3x3(&cm_f64).unwrap();
415
416        let cm: [f32; 9] = cm_f64.map(|v| v as f32);
417        let inv: [f32; 9] = inv_f64.map(|v| v as f32);
418
419        let original = vec![10000u16, 20000, 30000];
420        let mut img = RgbImage::new(1, 1, original.clone());
421
422        apply_color_matrix(&mut img, &cm);
423        apply_color_matrix(&mut img, &inv);
424
425        // After applying matrix then its inverse, values should be close to original
426        for (i, (&got, &expected)) in img.data.iter().zip(original.iter()).enumerate() {
427            let diff = (got as i32 - expected as i32).abs();
428            assert!(
429                diff < 500,
430                "Channel [{}]: roundtrip value {} differs from original {} by {}",
431                i,
432                got,
433                expected,
434                diff
435            );
436        }
437    }
438
439    #[test]
440    fn test_gamma_lut_endpoint_values() {
441        use crate::processing::color::GammaLut;
442
443        let lut = GammaLut::new(2.2);
444        // Create a minimal RgbImage with 0 and 65535
445        use crate::core::image::RgbImage;
446        let mut img = RgbImage::new(1, 1, vec![0u16, 0, 65535]);
447        lut.apply(&mut img);
448        assert_eq!(img.data[0], 0, "0 should map to 0");
449        assert_eq!(img.data[1], 0, "0 should map to 0");
450        assert_eq!(img.data[2], 65535, "65535 should map to 65535");
451    }
452
453    // -------------------------------------------------------------------------
454    // Dual-illuminant interpolation tests
455    // -------------------------------------------------------------------------
456
457    fn mat3(v: f64) -> [[f64; 3]; 3] {
458        [[v; 3]; 3]
459    }
460
461    #[test]
462    fn test_interpolate_at_cct1_returns_matrix1() {
463        let m1 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
464        let m2 = [[2.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 2.0]];
465        let result = interpolate_color_matrix(&m1, 2856.0, &m2, 6500.0, 2856.0);
466        for (row, row_r) in m1.iter().zip(result.iter()) {
467            for (&expected, &got) in row.iter().zip(row_r.iter()) {
468                assert!(
469                    (expected - got).abs() < 1e-6,
470                    "at cct1 should return matrix1"
471                );
472            }
473        }
474    }
475
476    #[test]
477    fn test_interpolate_at_cct2_returns_matrix2() {
478        let m1 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
479        let m2 = [[3.0, 0.0, 0.0], [0.0, 3.0, 0.0], [0.0, 0.0, 3.0]];
480        let result = interpolate_color_matrix(&m1, 2856.0, &m2, 6500.0, 6500.0);
481        for (row, row_r) in m2.iter().zip(result.iter()) {
482            for (&expected, &got) in row.iter().zip(row_r.iter()) {
483                assert!(
484                    (expected - got).abs() < 1e-6,
485                    "at cct2 should return matrix2"
486                );
487            }
488        }
489    }
490
491    #[test]
492    fn test_interpolate_midpoint() {
493        // Matrices of all-1.0 and all-3.0; midpoint should be all-2.0.
494        let m1 = mat3(1.0);
495        let m2 = mat3(3.0);
496        // Mired midpoint between 2856 K and 6500 K:
497        // 1/2856 ≈ 350.2 mireds,  1/6500 ≈ 153.8 mireds
498        // midpoint mired ≈ 252.0 → CCT ≈ 3968 K
499        let mid_cct = 1.0 / ((0.5 / 2856.0) + (0.5 / 6500.0)) as f32;
500        let result = interpolate_color_matrix(&m1, 2856.0, &m2, 6500.0, mid_cct);
501        for row in &result {
502            for &v in row {
503                assert!(
504                    (v - 2.0).abs() < 1e-6,
505                    "midpoint should give average: got {v}"
506                );
507            }
508        }
509    }
510
511    #[test]
512    fn test_interpolate_clamps_below_cct1() {
513        let m1 = mat3(0.0);
514        let m2 = mat3(1.0);
515        // Scene temperature well below cct_1 → t should clamp to 0 → matrix_1.
516        let result = interpolate_color_matrix(&m1, 2856.0, &m2, 6500.0, 1000.0);
517        for row in &result {
518            for &v in row {
519                assert!(
520                    (v - 0.0).abs() < 1e-6,
521                    "clamped below cct1 should return matrix1"
522                );
523            }
524        }
525    }
526
527    #[test]
528    fn test_interpolate_clamps_above_cct2() {
529        let m1 = mat3(0.0);
530        let m2 = mat3(1.0);
531        // Scene temperature well above cct_2 → t should clamp to 1 → matrix_2.
532        let result = interpolate_color_matrix(&m1, 2856.0, &m2, 6500.0, 20000.0);
533        for row in &result {
534            for &v in row {
535                assert!(
536                    (v - 1.0).abs() < 1e-6,
537                    "clamped above cct2 should return matrix2"
538                );
539            }
540        }
541    }
542
543    #[test]
544    fn test_estimate_cct_warm() {
545        // Warm tungsten light: high R neutral, very low B neutral.
546        // B/R = 0.1 / 0.95 ≈ 0.1053 → CCT ≈ 3000 + 9000*0.1053 ≈ 3947 K.
547        let cct = estimate_cct_from_as_shot_neutral([0.95, 1.0, 0.1]);
548        assert!(cct < 4000.0, "warm WB should give CCT < 4000 K, got {cct}");
549    }
550
551    #[test]
552    fn test_estimate_cct_daylight() {
553        // Neutral/daylight: R ≈ G ≈ B → B/R ≈ 1 → CCT ≈ 3000 + 9000 = 12000 clamped to 10000.
554        // With [0.8, 1.0, 0.8] → B/R = 1.0 → CCT = 12000 → clamped to 10000.
555        // For a more realistic daylight value use slightly less B:
556        let cct = estimate_cct_from_as_shot_neutral([0.6, 1.0, 0.55]);
557        assert!(
558            (4000.0..=10000.0).contains(&cct),
559            "daylight WB should give CCT 4000–10000 K, got {cct}"
560        );
561    }
562}