use std::sync::OnceLock;
const WHITE: [f32; 3] = [0.950_47, 1.0, 1.088_83];
const EPSILON: f32 = 216.0 / 24389.0;
const KAPPA: f32 = 24389.0 / 27.0;
fn transfer_table() -> &'static [f32; 256] {
static TABLE: OnceLock<[f32; 256]> = OnceLock::new();
TABLE.get_or_init(|| {
std::array::from_fn(|value| {
let encoded = value as f32 / 255.0;
if encoded <= 0.040_45 {
encoded / 12.92
} else {
((encoded + 0.055) / 1.055).powf(2.4)
}
})
})
}
pub(crate) struct LinearRgb {
pub red: Vec<f32>,
pub green: Vec<f32>,
pub blue: Vec<f32>,
}
impl LinearRgb {
pub(crate) fn zeroed(capacity: usize) -> Self {
Self {
red: vec![0f32; capacity],
green: vec![0f32; capacity],
blue: vec![0f32; capacity],
}
}
pub(crate) fn halve_into(
&self,
width: usize,
height: usize,
target: &mut LinearRgb,
) -> (usize, usize) {
let size = super::convolve::halve_into(&self.red, width, height, &mut target.red);
super::convolve::halve_into(&self.green, width, height, &mut target.green);
super::convolve::halve_into(&self.blue, width, height, &mut target.blue);
size
}
}
pub(crate) fn to_linear_rgb(rgba: &[u8]) -> LinearRgb {
let table = transfer_table();
let channel = |offset: usize| -> Vec<f32> {
rgba.chunks_exact(4)
.map(|pixel| table[pixel[offset] as usize])
.collect()
};
LinearRgb {
red: channel(0),
green: channel(1),
blue: channel(2),
}
}
pub(crate) fn linear_rgb_to_lab_into(
linear: &LinearRgb,
len: usize,
lightness: &mut [f32],
green_red: &mut [f32],
blue_yellow: &mut [f32],
) {
for i in 0..len {
let (r, g, b) = (linear.red[i], linear.green[i], linear.blue[i]);
let x = 0.412_456_4 * r + 0.357_576_1 * g + 0.180_437_5 * b;
let y = 0.212_672_9 * r + 0.715_152_2 * g + 0.072_175_0 * b;
let z = 0.019_333_9 * r + 0.119_192 * g + 0.950_304_1 * b;
let fx = lab_transfer(x / WHITE[0]);
let fy = lab_transfer(y / WHITE[1]);
let fz = lab_transfer(z / WHITE[2]);
lightness[i] = (116.0 * fy - 16.0) * 2.55;
green_red[i] = 500.0 * (fx - fy) + 128.0;
blue_yellow[i] = 200.0 * (fy - fz) + 128.0;
}
}
#[inline]
fn lab_transfer(ratio: f32) -> f32 {
let root = cube_root(ratio);
let knee = (KAPPA * ratio + 16.0) / 116.0;
if ratio > EPSILON {
root
} else {
knee
}
}
const INVERSE_CUBE_ROOT_SEED: u32 = 1_419_967_081;
#[inline]
fn cube_root(x: f32) -> f32 {
let mut estimate = f32::from_bits(INVERSE_CUBE_ROOT_SEED - x.to_bits() / 3);
estimate *= (4.0 - x * estimate * estimate * estimate) * (1.0 / 3.0);
estimate *= (4.0 - x * estimate * estimate * estimate) * (1.0 / 3.0);
let wide = x as f64;
let mut root = estimate as f64;
root *= (4.0 - wide * root * root * root) * (1.0 / 3.0);
root *= (4.0 - wide * root * root * root) * (1.0 / 3.0);
(wide * root * root) as f32
}
#[cfg(test)]
mod tests {
use super::*;
fn lab_of(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
let linear = to_linear_rgb(&[r, g, b, 255]);
let (mut lightness, mut green_red, mut blue_yellow) = ([0f32], [0f32], [0f32]);
linear_rgb_to_lab_into(&linear, 1, &mut lightness, &mut green_red, &mut blue_yellow);
(
lightness[0] / 2.55,
green_red[0] - 128.0,
blue_yellow[0] - 128.0,
)
}
const MAX_RATIO: f32 = 1.000_001;
#[test]
#[ignore = "exhaustive: ~67M values, run with --release --ignored"]
fn the_cube_root_matches_libm_over_the_whole_lab_domain() {
let mut mismatches = 0u64;
for bits in EPSILON.to_bits()..=MAX_RATIO.to_bits() {
let value = f32::from_bits(bits);
if cube_root(value) != value.cbrt() {
mismatches += 1;
if mismatches < 8 {
eprintln!("{value:e}: {:e} vs {:e}", cube_root(value), value.cbrt());
}
}
}
assert_eq!(
mismatches,
0,
"of {} values",
MAX_RATIO.to_bits() - EPSILON.to_bits()
);
}
#[test]
fn the_cube_root_matches_libm_across_the_lab_domain() {
for bits in (EPSILON.to_bits()..=MAX_RATIO.to_bits()).step_by(509) {
let value = f32::from_bits(bits);
assert_eq!(cube_root(value), value.cbrt(), "at {value:e}");
}
for value in [0.0f32, f32::MIN_POSITIVE, EPSILON, 1.0, 8.0, 1e30] {
assert_eq!(cube_root(value), value.cbrt(), "at {value:e}");
}
}
#[test]
fn the_transfer_function_hits_its_endpoints() {
let table = transfer_table();
assert_eq!(table[0], 0.0);
assert!((table[255] - 1.0).abs() < 1e-6);
assert!(table[128] < 0.25, "got {}", table[128]);
}
#[test]
fn white_and_black_land_on_the_lightness_axis() {
let (l, a, b) = lab_of(255, 255, 255);
assert!((l - 100.0).abs() < 0.05, "L* {l}");
assert!(a.abs() < 0.05 && b.abs() < 0.05, "a* {a} b* {b}");
let (l, a, b) = lab_of(0, 0, 0);
assert!(l.abs() < 1e-4, "L* {l}");
assert!(a.abs() < 1e-4 && b.abs() < 1e-4);
}
#[test]
fn primaries_land_in_the_right_quadrants() {
let (l, a, b) = lab_of(255, 0, 0);
assert!((l - 53.2).abs() < 0.5 && (a - 80.1).abs() < 0.5 && (b - 67.2).abs() < 0.5);
let (l, a, b) = lab_of(0, 255, 0);
assert!((l - 87.7).abs() < 0.5 && (a + 86.2).abs() < 0.5 && (b - 83.2).abs() < 0.5);
let (l, a, b) = lab_of(0, 0, 255);
assert!((l - 32.3).abs() < 0.5 && (a - 79.2).abs() < 0.5 && (b + 107.9).abs() < 0.5);
}
#[test]
fn equal_luma_colours_separate_in_chroma() {
let (l1, a1, b1) = lab_of(200, 30, 60);
let (l2, a2, b2) = lab_of(111, 39, 247);
let chroma_distance = ((a1 - a2).powi(2) + (b1 - b2).powi(2)).sqrt();
assert!(
chroma_distance > 50.0,
"a*b* distance {chroma_distance} should be large; L* {l1} vs {l2}"
);
}
}