use crate::{Matrix3x3, Xyz};
pub const CAT16_MATRIX: Matrix3x3 = [
[0.401_288, 0.650_173, -0.051_461],
[-0.250_268, 1.204_414, 0.045_854],
[-0.002_079, 0.048_952, 0.953_127],
];
pub const CAT16_INVERSE: Matrix3x3 = [
[1.862_067_855_087, -1.011_254_630_532, 0.149_186_775_444],
[0.387_526_543_236, 0.621_447_441_931, -0.008_973_985_168],
[-0.015_841_498_849, -0.034_122_938_029, 1.049_964_436_878],
];
#[inline]
#[must_use]
fn mat_vec(m: &Matrix3x3, v: &[f64; 3]) -> [f64; 3] {
[
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
]
}
pub struct Cat16Adapter;
impl Cat16Adapter {
#[must_use]
pub fn adapt(xyz: &[f64; 3], src_white: &[f64; 3], dst_white: &[f64; 3]) -> [f64; 3] {
let src_lms = mat_vec(&CAT16_MATRIX, src_white);
let dst_lms = mat_vec(&CAT16_MATRIX, dst_white);
let gain = [
if src_lms[0].abs() > f64::EPSILON {
dst_lms[0] / src_lms[0]
} else {
1.0
},
if src_lms[1].abs() > f64::EPSILON {
dst_lms[1] / src_lms[1]
} else {
1.0
},
if src_lms[2].abs() > f64::EPSILON {
dst_lms[2] / src_lms[2]
} else {
1.0
},
];
let lms = mat_vec(&CAT16_MATRIX, xyz);
let lms_adapted = [lms[0] * gain[0], lms[1] * gain[1], lms[2] * gain[2]];
mat_vec(&CAT16_INVERSE, &lms_adapted)
}
#[must_use]
pub fn xyz_to_cat16(xyz: &Xyz) -> [f64; 3] {
mat_vec(&CAT16_MATRIX, xyz)
}
#[must_use]
pub fn cat16_to_xyz(lms: &[f64; 3]) -> Xyz {
mat_vec(&CAT16_INVERSE, lms)
}
#[must_use]
pub fn compute_adaptation_matrix(src_white: &[f64; 3], dst_white: &[f64; 3]) -> Matrix3x3 {
let src_lms = mat_vec(&CAT16_MATRIX, src_white);
let dst_lms = mat_vec(&CAT16_MATRIX, dst_white);
let gain = [
if src_lms[0].abs() > f64::EPSILON {
dst_lms[0] / src_lms[0]
} else {
1.0
},
if src_lms[1].abs() > f64::EPSILON {
dst_lms[1] / src_lms[1]
} else {
1.0
},
if src_lms[2].abs() > f64::EPSILON {
dst_lms[2] / src_lms[2]
} else {
1.0
},
];
let diag: Matrix3x3 = [
[gain[0], 0.0, 0.0],
[0.0, gain[1], 0.0],
[0.0, 0.0, gain[2]],
];
let tmp = mat3x3_mul(&diag, &CAT16_MATRIX);
mat3x3_mul(&CAT16_INVERSE, &tmp)
}
}
#[must_use]
fn mat3x3_mul(a: &Matrix3x3, b: &Matrix3x3) -> Matrix3x3 {
let mut out = [[0.0_f64; 3]; 3];
for i in 0..3 {
for j in 0..3 {
for k in 0..3 {
out[i][j] += a[i][k] * b[k][j];
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Illuminant;
fn approx(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() < tol
}
fn xyz_approx(a: &[f64; 3], b: &[f64; 3], tol: f64) -> bool {
approx(a[0], b[0], tol) && approx(a[1], b[1], tol) && approx(a[2], b[2], tol)
}
#[test]
fn test_adapt_white_d65_to_d50_maps_white() {
let d65 = Illuminant::D65.xyz();
let d50 = Illuminant::D50.xyz();
let result = Cat16Adapter::adapt(&d65, &d65, &d50);
assert!(
xyz_approx(&result, &d50, 1e-6),
"result={result:?}, expected={d50:?}"
);
}
#[test]
fn test_adapt_white_d50_to_d65_maps_white() {
let d50 = Illuminant::D50.xyz();
let d65 = Illuminant::D65.xyz();
let result = Cat16Adapter::adapt(&d50, &d50, &d65);
assert!(
xyz_approx(&result, &d65, 1e-6),
"result={result:?}, expected={d65:?}"
);
}
#[test]
fn test_adapt_same_illuminant_is_identity() {
let d65 = Illuminant::D65.xyz();
let color = [0.3127, 0.3290, 0.3583];
let result = Cat16Adapter::adapt(&color, &d65, &d65);
assert!(
xyz_approx(&result, &color, 1e-6),
"result={result:?}, expected={color:?}"
);
}
#[test]
fn test_round_trip_d65_d50_d65() {
let d65 = Illuminant::D65.xyz();
let d50 = Illuminant::D50.xyz();
let original = [0.5, 0.4, 0.3];
let forward = Cat16Adapter::adapt(&original, &d65, &d50);
let back = Cat16Adapter::adapt(&forward, &d50, &d65);
assert!(
xyz_approx(&back, &original, 1e-9),
"back={back:?}, original={original:?}"
);
}
#[test]
fn test_xyz_to_cat16_round_trip() {
let xyz = [0.4, 0.3, 0.6];
let lms = Cat16Adapter::xyz_to_cat16(&xyz);
let back = Cat16Adapter::cat16_to_xyz(&lms);
assert!(
xyz_approx(&back, &xyz, 1e-9),
"back={back:?}, original={xyz:?}"
);
}
#[test]
fn test_xyz_to_cat16_equal_energy() {
let e = Illuminant::E.xyz();
let lms = Cat16Adapter::xyz_to_cat16(&e);
assert!(lms[0] > 0.0 && lms[1] > 0.0 && lms[2] > 0.0, "lms={lms:?}");
}
#[test]
fn test_adapt_non_negative_output() {
let d65 = Illuminant::D65.xyz();
let d50 = Illuminant::D50.xyz();
let grey = [0.5 * d65[0], 0.5 * d65[1], 0.5 * d65[2]];
let result = Cat16Adapter::adapt(&grey, &d65, &d50);
assert!(
result[1] >= 0.0,
"Luminance should be non-negative: {result:?}"
);
}
#[test]
fn test_adaptation_matrix_maps_white() {
let d65 = Illuminant::D65.xyz();
let d50 = Illuminant::D50.xyz();
let m = Cat16Adapter::compute_adaptation_matrix(&d65, &d50);
let result = [
m[0][0] * d65[0] + m[0][1] * d65[1] + m[0][2] * d65[2],
m[1][0] * d65[0] + m[1][1] * d65[1] + m[1][2] * d65[2],
m[2][0] * d65[0] + m[2][1] * d65[1] + m[2][2] * d65[2],
];
assert!(
xyz_approx(&result, &d50, 1e-6),
"result={result:?}, expected={d50:?}"
);
}
#[test]
fn test_round_trip_d65_a_d65() {
let d65 = Illuminant::D65.xyz();
let a = Illuminant::A.xyz();
let original = [0.2, 0.5, 0.8];
let forward = Cat16Adapter::adapt(&original, &d65, &a);
let back = Cat16Adapter::adapt(&forward, &a, &d65);
assert!(
xyz_approx(&back, &original, 1e-9),
"back={back:?}, original={original:?}"
);
}
#[test]
fn test_cat16_matrix_times_inverse_is_identity() {
let m = &CAT16_MATRIX;
let inv = &CAT16_INVERSE;
for i in 0..3 {
for j in 0..3 {
let val: f64 = (0..3).map(|k| m[i][k] * inv[k][j]).sum();
let expected = if i == j { 1.0 } else { 0.0 };
assert!(
(val - expected).abs() < 1e-4,
"M*M_inv[{i}][{j}] = {val}, expected {expected}"
);
}
}
}
#[test]
fn test_adapt_zero_colour() {
let d65 = Illuminant::D65.xyz();
let d50 = Illuminant::D50.xyz();
let black = [0.0_f64, 0.0, 0.0];
let result = Cat16Adapter::adapt(&black, &d65, &d50);
assert!(
xyz_approx(&result, &black, 1e-12),
"Black should map to black: {result:?}"
);
}
#[test]
fn test_adapt_d65_d65_preserves_all_channels() {
let d65 = Illuminant::D65.xyz();
let test_colors: &[[f64; 3]] = &[
[0.1, 0.05, 0.15],
[0.9, 0.85, 0.95],
[0.5, 0.5, 0.5],
[0.2, 0.4, 0.6],
];
for &c in test_colors {
let result = Cat16Adapter::adapt(&c, &d65, &d65);
assert!(
xyz_approx(&result, &c, 1e-9),
"Identity failed for {c:?}: got {result:?}"
);
}
}
}