1use crate::core::image::RgbImage;
11use crate::error::{RawError, RawResult};
12
13pub use crate::processing::color::{
15 apply_color_matrix, apply_white_balance, apply_white_balance_raw,
16};
17
18pub 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
24fn 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
30fn 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
50fn 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
65pub 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
77pub struct ColorSpaceTransform {
82 pub wb_coeffs: (f32, f32, f32),
84 pub color_matrix: [f32; 9],
88}
89
90impl ColorSpaceTransform {
91 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 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
110pub type ColorTemperature = f32;
118
119pub 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
167pub 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
189pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let m1 = mat3(1.0);
495 let m2 = mat3(3.0);
496 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 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 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 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 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}