colr 0.1.0

Type-safe, zero-cost color science library with compile-time color space transforms
Documentation
//! Perceptual color spaces.
//!
//! These spaces model human color perception rather than device characteristics.
//! All route through CIE XYZ for conversion.
//!
//! CIELab and CIELCh are parameterized by reference white W. Oklab and
//! Oklch have D65 baked into the specification.

use core::marker::PhantomData;

use crate::illuminant::{D65, Illuminant};
use crate::math::{DefaultMath, Mat3, MathState};
use crate::xyz::Xyz;
use crate::{Asserts, Color, ColorSpace, Transform};

/// CIELab f(t) cube-root compression. Uses cbrt for correct negative handling.
/// powf(t, 1/3) produces NaN for negative t; cbrt(-x) = -cbrt(x).
#[inline(always)]
fn lab_f(t: f32) -> f32 {
    const DELTA: f32 = 6.0 / 29.0;
    const DELTA2: f32 = DELTA * DELTA;
    if t > DELTA * DELTA2 {
        DefaultMath::cbrt(t)
    } else {
        t / (3.0 * DELTA2) + 4.0 / 29.0
    }
}

#[inline(always)]
fn lab_f_inv(t: f32) -> f32 {
    const DELTA: f32 = 6.0 / 29.0;
    const DELTA2: f32 = DELTA * DELTA;
    if t > DELTA {
        t * t * t
    } else {
        3.0 * DELTA2 * (t - 4.0 / 29.0)
    }
}

/// CIE 1976 L*a*b* color space under reference white W.
///
/// Perceptually uniform for small color differences. L* is lightness
/// [0, 100], a* is green-red [-128, 127], b* is blue-yellow [-128, 127].
/// Reference: CIE 015:2018.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Lab<W: Illuminant = D65>(PhantomData<W>);

impl<W: Illuminant> ColorSpace for Lab<W> {
    const CHANNELS: usize = 3;
    const LUMINANCE_WEIGHTS: Option<[f32; 3]> = None;
}

impl<W: Illuminant> Asserts<[f32; 3]> for Lab<W> {}
impl<W: Illuminant> Asserts<[f32; 4]> for Lab<W> {}

impl<W: Illuminant> Transform<Color<[f32; 3], Xyz<W>>> for Color<[f32; 3], Lab<W>> {
    fn transform_from(src: Color<[f32; 3], Xyz<W>>, _: &()) -> Self {
        let [x, y, z] = src.inner();
        let [xn, yn, zn] = W::WHITE_POINT_XYZ;
        let fx = lab_f(x / xn);
        let fy = lab_f(y / yn);
        let fz = lab_f(z / zn);
        Color::new_unchecked([116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)])
    }
}

impl<W: Illuminant> Transform<Color<[f32; 3], Lab<W>>> for Color<[f32; 3], Xyz<W>> {
    fn transform_from(src: Color<[f32; 3], Lab<W>>, _: &()) -> Self {
        let [l, a, b] = src.inner();
        let [xn, yn, zn] = W::WHITE_POINT_XYZ;
        let fy = (l + 16.0) / 116.0;
        Color::new_unchecked([
            xn * lab_f_inv(a / 500.0 + fy),
            yn * lab_f_inv(fy),
            zn * lab_f_inv(fy - b / 200.0),
        ])
    }
}

/// CIE L*C*h*, the polar form of CIELab.
///
/// L* is lightness, C* is chroma, h is hue angle in degrees [0, 360).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct LCh<W: Illuminant = D65>(PhantomData<W>);

impl<W: Illuminant> ColorSpace for LCh<W> {
    const CHANNELS: usize = 3;
    const LUMINANCE_WEIGHTS: Option<[f32; 3]> = None;
}

impl<W: Illuminant> Asserts<[f32; 3]> for LCh<W> {}
impl<W: Illuminant> Asserts<[f32; 4]> for LCh<W> {}

impl<W: Illuminant> Transform<Color<[f32; 3], Lab<W>>> for Color<[f32; 3], LCh<W>> {
    fn transform_from(src: Color<[f32; 3], Lab<W>>, _: &()) -> Self {
        let [l, a, b] = src.inner();
        let c = DefaultMath::sqrt(a * a + b * b);
        let h = DefaultMath::atan2(b, a).to_degrees().rem_euclid(360.0);
        Color::new_unchecked([l, c, h])
    }
}

impl<W: Illuminant> Transform<Color<[f32; 3], LCh<W>>> for Color<[f32; 3], Lab<W>> {
    fn transform_from(src: Color<[f32; 3], LCh<W>>, _: &()) -> Self {
        let [l, c, h] = src.inner();
        let h_rad = h.to_radians();
        Color::new_unchecked([l, c * DefaultMath::cos(h_rad), c * DefaultMath::sin(h_rad)])
    }
}

/// Oklab perceptual color space (Björn Ottosson, 2020).
///
/// Better hue linearity than CIELab. L is lightness [0, 1], a and b are
/// opponent axes. D65 adaptation is baked into the specification.
///
/// Reference: Ottosson, B., "A perceptual color space for image processing", 2020.
/// Adopted in CSS Color Level 4 (W3C).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Oklab;

impl ColorSpace for Oklab {
    const CHANNELS: usize = 3;
    const LUMINANCE_WEIGHTS: Option<[f32; 3]> = None;
}

impl Asserts<[f32; 3]> for Oklab {}
impl Asserts<[f32; 4]> for Oklab {}

/// Oklch, the polar form of Oklab.
///
/// L is lightness [0, 1], C is chroma, h is hue angle in degrees [0, 360).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Oklch;

impl ColorSpace for Oklch {
    const CHANNELS: usize = 3;
    const LUMINANCE_WEIGHTS: Option<[f32; 3]> = None;
}

impl Asserts<[f32; 3]> for Oklch {}
impl Asserts<[f32; 4]> for Oklch {}

// Oklab matrices in column-major Mat3 to match the rest of the codebase.
// Source: https://bottosson.github.io/posts/oklab/
// Values kept verbatim from the specification; they exceed f32 precision
// but document the spec intent. f32 rounds them at compile time.
#[allow(clippy::excessive_precision)]
const OKLAB_M1: Mat3 = Mat3 {
    col0: [0.8189330101, 0.0329845436, 0.0482003018, 0.0],
    col1: [0.3618667424, 0.9293118715, 0.2643662691, 0.0],
    col2: [-0.1288597137, 0.0361456387, 0.6338517070, 0.0],
};

#[allow(clippy::excessive_precision)]
const OKLAB_M2: Mat3 = Mat3 {
    col0: [0.2104542553, 1.9779984951, 0.0259040371, 0.0],
    col1: [0.7936177850, -2.4285922050, 0.7827717662, 0.0],
    col2: [-0.0040720468, 0.4505937099, -0.8086757660, 0.0],
};

#[allow(clippy::excessive_precision)]
const OKLAB_M1_INV: Mat3 = Mat3 {
    col0: [1.2270138511, -0.0405801784, -0.0763812845, 0.0],
    col1: [-0.5577999807, 1.1122568696, -0.4214819784, 0.0],
    col2: [0.2812561490, -0.0716766787, 1.5861632204, 0.0],
};

#[allow(clippy::excessive_precision)]
const OKLAB_M2_INV: Mat3 = Mat3 {
    col0: [1.0, 1.0, 1.0, 0.0],
    col1: [0.3963377774, -0.1055613458, -0.0894841775, 0.0],
    col2: [0.2158037573, -0.0638541728, -1.2914855480, 0.0],
};

impl Transform<Color<[f32; 3], Xyz<D65>>> for Color<[f32; 3], Oklab> {
    fn transform_from(src: Color<[f32; 3], Xyz<D65>>, _: &()) -> Self {
        let xyz = src.inner();
        let lms = OKLAB_M1.apply(xyz);
        let lms_g = [
            DefaultMath::cbrt(lms[0]),
            DefaultMath::cbrt(lms[1]),
            DefaultMath::cbrt(lms[2]),
        ];
        Color::new_unchecked(OKLAB_M2.apply(lms_g))
    }
}

impl Transform<Color<[f32; 3], Oklab>> for Color<[f32; 3], Xyz<D65>> {
    fn transform_from(src: Color<[f32; 3], Oklab>, _: &()) -> Self {
        let lab = src.inner();
        let lms_g = OKLAB_M2_INV.apply(lab);
        let lms = [lms_g[0].powi(3), lms_g[1].powi(3), lms_g[2].powi(3)];
        Color::new_unchecked(OKLAB_M1_INV.apply(lms))
    }
}

impl Transform<Color<[f32; 3], Oklab>> for Color<[f32; 3], Oklch> {
    fn transform_from(src: Color<[f32; 3], Oklab>, _: &()) -> Self {
        let [l, a, b] = src.inner();
        let c = DefaultMath::sqrt(a * a + b * b);
        let h = DefaultMath::atan2(b, a).to_degrees().rem_euclid(360.0);
        Color::new_unchecked([l, c, h])
    }
}

impl Transform<Color<[f32; 3], Oklch>> for Color<[f32; 3], Oklab> {
    fn transform_from(src: Color<[f32; 3], Oklch>, _: &()) -> Self {
        let [l, c, h] = src.inner();
        let h_rad = h.to_radians();
        Color::new_unchecked([l, c * DefaultMath::cos(h_rad), c * DefaultMath::sin(h_rad)])
    }
}

/// CIELab under D65. The most common reference white for Lab work.
pub type LabD65 = Lab<D65>;
/// CIELCh under D65. Polar form of LabD65.
pub type LChD65 = LCh<D65>;