pub(super) fn mat3(m: &[[f32; 3]; 3], (a, b, c): (f32, f32, f32)) -> (f32, f32, f32) {
let row = |r: &[f32; 3]| c.mul_add(r[2], b.mul_add(r[1], a * r[0]));
(row(&m[0]), row(&m[1]), row(&m[2]))
}
pub(super) fn srgb(red: f32, green: f32, blue: f32) -> (u8, u8, u8) {
let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8;
(channel(red), channel(green), channel(blue))
}
pub(super) fn linear_srgb(red: f32, green: f32, blue: f32) -> (u8, u8, u8) {
let (red, green, blue) = gamut_map((red, green, blue));
srgb(srgb_encode(red), srgb_encode(green), srgb_encode(blue))
}
fn srgb_encode(value: f32) -> f32 {
let magnitude = value.abs();
let encoded = if magnitude > 0.003_130_8 {
1.055f32.mul_add(magnitude.powf(1.0 / 2.4), -0.055)
} else {
12.92 * magnitude
};
encoded.copysign(value)
}
pub(super) fn srgb_decode(value: f32) -> f32 {
let magnitude = value.abs();
let decoded = if magnitude > 0.040_45 {
((magnitude + 0.055) / 1.055).powf(2.4)
} else {
magnitude / 12.92
};
decoded.copysign(value)
}
pub(super) fn xyz_d65_to_linear_srgb(xyz: (f32, f32, f32)) -> (f32, f32, f32) {
const TO_SRGB: [[f32; 3]; 3] = [
[3.240_97, -1.537_383_2, -0.498_610_76],
[-0.969_243_65, 1.875_967_5, 0.041_555_06],
[0.055_630_08, -0.203_976_96, 1.056_971_5],
];
mat3(&TO_SRGB, xyz)
}
pub(super) fn xyz_d50_to_d65(xyz: (f32, f32, f32)) -> (f32, f32, f32) {
const ADAPT: [[f32; 3]; 3] = [
[0.955_473_4, -0.023_098_536, 0.063_259_31],
[-0.028_369_707, 1.009_995_5, 0.021_041_399],
[0.012_314_002, -0.020_507_696, 1.330_365_9],
];
mat3(&ADAPT, xyz)
}
pub(super) fn oklab_to_linear_srgb(lab: (f32, f32, f32)) -> (f32, f32, f32) {
const OKLAB_TO_LMS_ROOT: [[f32; 3]; 3] = [
[1.0, 0.396_337_78, 0.215_803_76],
[1.0, -0.105_561_346, -0.063_854_17],
[1.0, -0.089_484_18, -1.291_485_5],
];
const LMS_TO_LINEAR_SRGB: [[f32; 3]; 3] = [
[4.076_741_7, -3.307_711_6, 0.230_969_94],
[-1.268_438, 2.609_757_4, -0.341_319_38],
[-0.004_196_086_4, -0.703_418_6, 1.707_614_7],
];
let (l, m, s) = mat3(&OKLAB_TO_LMS_ROOT, lab);
mat3(&LMS_TO_LINEAR_SRGB, (l * l * l, m * m * m, s * s * s))
}
fn linear_srgb_to_oklab(rgb: (f32, f32, f32)) -> (f32, f32, f32) {
const LINEAR_SRGB_TO_LMS: [[f32; 3]; 3] = [
[0.412_221_46, 0.536_332_55, 0.051_445_995],
[0.211_903_5, 0.680_699_5, 0.107_396_96],
[0.088_302_46, 0.281_718_85, 0.629_978_7],
];
const LMS_ROOT_TO_OKLAB: [[f32; 3]; 3] = [
[0.210_454_26, 0.793_617_8, -0.004_072_047],
[1.977_998_5, -2.428_592_2, 0.450_593_7],
[0.025_904_037, 0.782_771_77, -0.808_675_77],
];
let (l, m, s) = mat3(&LINEAR_SRGB_TO_LMS, rgb);
mat3(&LMS_ROOT_TO_OKLAB, (l.cbrt(), m.cbrt(), s.cbrt()))
}
fn gamut_map(rgb: (f32, f32, f32)) -> (f32, f32, f32) {
const JND: f32 = 0.02;
const EPSILON: f32 = 1e-4;
if in_gamut(rgb) {
return clip(rgb);
}
let (lightness, a, b) = linear_srgb_to_oklab(rgb);
if lightness >= 1.0 {
return (1.0, 1.0, 1.0);
}
if lightness <= 0.0 {
return (0.0, 0.0, 0.0);
}
let mut clipped = clip(rgb);
if delta_eok(clipped, rgb) < JND {
return clipped;
}
let (sin, cos) = b.atan2(a).sin_cos();
let mut low = 0.0_f32;
let mut high = a.hypot(b);
let mut low_in_gamut = true;
for _ in 0..64 {
if high - low <= EPSILON {
break;
}
let chroma = f32::midpoint(low, high);
let current = oklab_to_linear_srgb((lightness, chroma * cos, chroma * sin));
if low_in_gamut && in_gamut(current) {
low = chroma;
continue;
}
clipped = clip(current);
let delta = delta_eok(clipped, current);
if delta < JND {
if JND - delta < EPSILON {
return clipped;
}
low_in_gamut = false;
low = chroma;
} else {
high = chroma;
}
}
clipped
}
fn in_gamut((red, green, blue): (f32, f32, f32)) -> bool {
let ok = |value: f32| (0.0..=1.0).contains(&value);
ok(red) && ok(green) && ok(blue)
}
const fn clip((red, green, blue): (f32, f32, f32)) -> (f32, f32, f32) {
(red.clamp(0.0, 1.0), green.clamp(0.0, 1.0), blue.clamp(0.0, 1.0))
}
fn delta_eok(left: (f32, f32, f32), right: (f32, f32, f32)) -> f32 {
let (l1, a1, b1) = linear_srgb_to_oklab(left);
let (l2, a2, b2) = linear_srgb_to_oklab(right);
let (dl, da, db) = (l1 - l2, a1 - a2, b1 - b2);
db.mul_add(db, da.mul_add(da, dl * dl)).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
const D65_WHITE: (f32, f32, f32) = (0.950_47, 1.0, 1.088_83);
const D50_WHITE: (f32, f32, f32) = (0.964_22, 1.0, 0.825_21);
#[test]
fn d65_white_is_srgb_white() {
let (r, g, b) = xyz_d65_to_linear_srgb(D65_WHITE);
assert!((r - 1.0).abs() < 2e-3 && (g - 1.0).abs() < 2e-3 && (b - 1.0).abs() < 2e-3);
assert_eq!(linear_srgb(r, g, b), (255, 255, 255));
}
#[test]
fn srgb_green_primary_round_trips_from_xyz() {
let (r, g, b) = xyz_d65_to_linear_srgb((0.357_584_34, 0.715_168_68, 0.119_194_78));
assert_eq!(linear_srgb(r, g, b), (0, 255, 0));
}
#[test]
fn bradford_adaptation_maps_d50_white_to_d65_white() {
let (r, g, b) = xyz_d65_to_linear_srgb(xyz_d50_to_d65(D50_WHITE));
assert_eq!(linear_srgb(r, g, b), (255, 255, 255));
}
#[test]
fn transfer_functions_invert_each_other() {
for value in [0.0, 0.001, 0.04, 0.5, 1.0] {
let round = srgb_decode(srgb_encode(value));
assert!((round - value).abs() < 1e-5, "{value} -> {round}");
}
}
#[test]
fn oklab_transforms_invert_each_other() {
let rgb = (0.3, 0.2, 0.6);
let (r, g, b) = oklab_to_linear_srgb(linear_srgb_to_oklab(rgb));
assert!((r - 0.3).abs() < 1e-4 && (g - 0.2).abs() < 1e-4 && (b - 0.6).abs() < 1e-4);
}
#[test]
fn srgb_quantizer_clamps_stray_channels() {
assert_eq!(srgb(1.2, -0.1, 0.5), (255, 0, 128));
}
#[test]
fn in_gamut_colors_pass_through_unmapped() {
assert_eq!(linear_srgb(0.5, 0.5, 0.5), (188, 188, 188));
assert_eq!(linear_srgb(1.0, 0.0, 0.0), (255, 0, 0));
}
#[test]
fn gamut_mapping_preserves_hue_where_clipping_would_not() {
let (r, g, b) = linear_srgb(1.224_94, -0.042_057, -0.019_638);
assert_eq!(r, 255);
assert!(g.abs_diff(11) <= 2, "green: {g}");
assert!(b.abs_diff(12) <= 2, "blue: {b}");
}
type GamutVector = ((f32, f32, f32), (u8, u8, u8));
#[test]
fn gamut_mapping_matches_the_colorjs_reference() {
#[rustfmt::skip]
const VECTORS: &[GamutVector] = &[
((1.224_94, -0.042_057, -0.019_638), (255, 11, 12)), ((-0.224_94, 1.042_057, -0.078_636), (0, 251, 41)), ((0.0, -0.0, 1.098_274), (0, 0, 255)), ((1.0, 1.0, -0.098_274), (254, 255, 0)), ((-0.177_121, 0.820_528, 0.802_875), (0, 229, 226)), ((1.660_491, -0.124_55, -0.018_151), (255, 73, 79)), ((-0.587_641, 1.132_9, -0.100_579), (0, 242, 114)), ((-0.072_85, -0.008_349, 1.118_73), (0, 81, 147)), ((1.398_356, -0.0, 0.0), (255, 90, 72)), ((-0.727_636, 1.231_743, -0.153_267), (0, 245, 117)), ((0.673_088, -0.118_526, 1.969_633), (187, 77, 255)), ((-0.140_498, 0.614_978, -0.034_898), (0, 199, 41)), ((0.537_705, -0.080_673, 0.009_181), (156, 0, 49)), ((-0.372_064, 0.948_801, -0.073_975), (0, 231, 90)), ((0.999_872, -0.000_049, -0.000_006), (255, 0, 0)), ((-0.024_647, -0.007_729, 1.484_238), (0, 69, 254)), ((1.147_387, 0.706_108, -0.249_376), (255, 223, 0)), ((0.181_605, -0.029_117, 0.176_684), (104, 0, 101)), ((1.518_808, -0.056_034, 0.050_831), (255, 84, 100)), ((1.01, 0.5, 0.5), (255, 188, 188)), ((0.5, -0.01, 0.2), (188, 0, 124)), ((1.005, 1.002, 0.998), (255, 255, 255)), ((0.0, 0.003, 1.02), (0, 10, 255)), ];
for &((red, green, blue), want) in VECTORS {
let got = linear_srgb(red, green, blue);
for (channel, (got, want)) in [(got.0, want.0), (got.1, want.1), (got.2, want.2)]
.into_iter()
.enumerate()
{
assert!(
got.abs_diff(want) <= 2,
"channel {channel}: {got} vs {want} for ({red}, {green}, {blue})"
);
}
}
}
#[test]
fn gamut_check_uses_exact_bounds_like_the_reference() {
assert_eq!(linear_srgb(0.001_268, -0.000_184, -0.000_563), (1, 0, 0));
}
#[test]
fn gamut_mapping_pins_lightness_extremes() {
assert_eq!(linear_srgb(1.8, 1.9, 2.0), (255, 255, 255));
assert_eq!(linear_srgb(-0.4, -0.5, -0.2), (0, 0, 0));
}
}