Skip to content

Commit

Permalink
Add Lab and Lch color spaces
Browse files Browse the repository at this point in the history
Adds the colorspaces and associated logic.

Also fix `map_lightness` implementations, I had forgotten that these don't
have the same bounds as Oklab and Oklch.
  • Loading branch information
raphlinus committed Nov 9, 2024
1 parent 8b0a3f3 commit b68860b
Show file tree
Hide file tree
Showing 7 changed files with 198 additions and 37 deletions.
4 changes: 3 additions & 1 deletion color/examples/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
//! cargo run --example parse 'oklab(0.5 0.2 0)'
//! ```
use color::{AlphaColor, CssColor, Srgb};
use color::{AlphaColor, CssColor, Lab, Srgb};

fn main() {
let arg = std::env::args().nth(1).expect("give color as arg");
Expand All @@ -22,6 +22,8 @@ fn main() {
let tagged = CssColor::to_tagged_color(color);
let srgba: AlphaColor<Srgb> = tagged.to_alpha_color();
println!("{srgba:?}");
let lab: AlphaColor<Lab> = color.to_alpha_color();
println!("{lab:?}");
}
Err(e) => println!("error: {e}"),
}
Expand Down
20 changes: 12 additions & 8 deletions color/src/color.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,12 @@ impl<CS: ColorSpace> OpaqueColor<CS> {
#[must_use]
pub fn map_lightness(self, f: impl Fn(f32) -> f32) -> Self {
match CS::TAG {
Some(ColorSpaceTag::Oklab)
| Some(ColorSpaceTag::Oklch)
| Some(ColorSpaceTag::Lab)
| Some(ColorSpaceTag::Lch) => self.map(|l, c1, c2| [f(l), c1, c2]),
Some(ColorSpaceTag::Lab) | Some(ColorSpaceTag::Lch) => {
self.map(|l, c1, c2| [100.0 * f(l * 0.01), c1, c2])
}
Some(ColorSpaceTag::Oklab) | Some(ColorSpaceTag::Oklch) => {
self.map(|l, c1, c2| [f(l), c1, c2])
}
Some(ColorSpaceTag::Hsl) => self.map(|h, s, l| [h, s, 100.0 * f(l * 0.01)]),
_ => self.map_in::<Oklab>(|l, a, b| [f(l), a, b]),
}
Expand Down Expand Up @@ -302,10 +304,12 @@ impl<CS: ColorSpace> AlphaColor<CS> {
#[must_use]
pub fn map_lightness(self, f: impl Fn(f32) -> f32) -> Self {
match CS::TAG {
Some(ColorSpaceTag::Oklab)
| Some(ColorSpaceTag::Oklch)
| Some(ColorSpaceTag::Lab)
| Some(ColorSpaceTag::Lch) => self.map(|l, c1, c2, a| [f(l), c1, c2, a]),
Some(ColorSpaceTag::Lab) | Some(ColorSpaceTag::Lch) => {
self.map(|l, c1, c2, a| [100.0 * f(l * 0.01), c1, c2, a])
}
Some(ColorSpaceTag::Oklab) | Some(ColorSpaceTag::Oklch) => {
self.map(|l, c1, c2, a| [f(l), c1, c2, a])
}
Some(ColorSpaceTag::Hsl) => self.map(|h, s, l, a| [h, s, 100.0 * f(l * 0.01), a]),
_ => self.map_in::<Oklab>(|l, a, b, alpha| [f(l), a, b, alpha]),
}
Expand Down
155 changes: 151 additions & 4 deletions color/src/colorspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,8 +304,8 @@ impl ColorSpace for Oklab {
matmul(&OKLAB_LMS_TO_LAB, lms)
}

fn scale_chroma(src: [f32; 3], scale: f32) -> [f32; 3] {
[src[0], src[1] * scale, src[2] * scale]
fn scale_chroma([l, a, b]: [f32; 3], scale: f32) -> [f32; 3] {
[l, a * scale, b * scale]
}

fn convert<TargetCS: ColorSpace>(src: [f32; 3]) -> [f32; 3] {
Expand Down Expand Up @@ -358,8 +358,8 @@ impl ColorSpace for Oklch {
Oklab::to_linear_srgb(lch_to_lab(src))
}

fn scale_chroma(src: [f32; 3], scale: f32) -> [f32; 3] {
[src[0], src[1] * scale, src[2]]
fn scale_chroma([l, c, h]: [f32; 3], scale: f32) -> [f32; 3] {
[l, c * scale, h]
}

fn convert<TargetCS: ColorSpace>(src: [f32; 3]) -> [f32; 3] {
Expand All @@ -377,3 +377,150 @@ impl ColorSpace for Oklch {
[l.clamp(0., 1.), c.max(0.), h]
}
}

/// The CIELAB color space
///
/// The CIE L*a*b* color space was created in 1976 to be more perceptually
/// uniform than RGB color spaces, and is both widely used and the basis of
/// other efforts to express colors, including [FreieFarbe].
///
/// Its components are `[L, a, b]` with
/// - `L` - the lightness with a natural bound between 0 and 100, where 0 represents pure black and 100
/// represents the lightness of white;
/// - `a` - how green/red the color is; and
/// - `b` - how blue/yellow the color is.
///
/// `a` and `b` are unbounded, but are usually between -160 and 160.
///
/// The color space has poor hue linearity and hue uniformity compared with
/// [Oklab], though superior lightness uniformity.
///
/// The CIE La*b* color space is defined in terms of a D50 white point. For
/// conversion between color spaces with other illuminants (especially D65
/// as in sRGB), the standard Bradform linear chromatic adaptation transform
/// is used.
///
/// This corresponds to the color space in [CSS Color Module Level 4 § 9.1 ][css-sec].
///
/// Lab has a cylindrical counterpart: [Lch].
///
/// [FreieFarbe]: https://freiefarbe.de/en/
/// [css-sec]: https://www.w3.org/TR/css-color-4/#cie-lab
#[derive(Clone, Copy, Debug)]
pub struct Lab;

// Matrices computed from CSS Color 4 spec, then used `cargo clippy --fix`
// to reduce precision to f32 and add underscores.

// This is D65_to_D50 * lin_sRGB_to_XYZ, then rows scaled by 1 / D50[i].
const LAB_SRGB_TO_XYZ: [[f32; 3]; 3] = [
[0.452_211_65, 0.399_412_24, 0.148_376_09],
[0.222_493_17, 0.716_887, 0.060_619_81],
[0.016_875_342, 0.117_659_41, 0.865_465_2],
];

// This is XYZ_to_lin_sRGB * D50_to_D65, then columns scaled by D50[i].
const LAB_XYZ_TO_SRGB: [[f32; 3]; 3] = [
[3.022_233_7, -1.617_386, -0.404_847_65],
[-0.943_848_25, 1.916_254_4, 0.027_593_868],
[0.069_386_27, -0.228_976_76, 1.159_590_5],
];

const EPSILON: f32 = 216. / 24389.;
const KAPPA: f32 = 24389. / 27.;

impl ColorSpace for Lab {
const TAG: Option<ColorSpaceTag> = Some(ColorSpaceTag::Lab);

fn to_linear_srgb([l, a, b]: [f32; 3]) -> [f32; 3] {
let f1 = l * (1. / 116.) + (16. / 116.);
let f0 = a * (1. / 500.) + f1;
let f2 = f1 - b * (1. / 200.);
let xyz = [f0, f1, f2].map(|value| {
const EPSILON_CBRT: f32 = 0.206_896_56;
if value > EPSILON_CBRT {
value * value * value
} else {
(116. / KAPPA) * value - (16. / KAPPA)
}
});
matmul(&LAB_XYZ_TO_SRGB, xyz)
}

fn from_linear_srgb(src: [f32; 3]) -> [f32; 3] {
let xyz = matmul(&LAB_SRGB_TO_XYZ, src);
let f = xyz.map(|value| {
if value > EPSILON {
value.cbrt()
} else {
(KAPPA / 116.) * value + (16. / 116.)
}
});
let l = 116. * f[1] - 16.;
let a = 500. * (f[0] - f[1]);
let b = 200. * (f[1] - f[2]);
[l, a, b]
}

fn scale_chroma([l, a, b]: [f32; 3], scale: f32) -> [f32; 3] {
[l, a * scale, b * scale]
}

fn convert<TargetCS: ColorSpace>(src: [f32; 3]) -> [f32; 3] {
if TypeId::of::<Self>() == TypeId::of::<TargetCS>() {
src
} else if TypeId::of::<TargetCS>() == TypeId::of::<Lch>() {
lab_to_lch(src)
} else {
let lin_rgb = Self::to_linear_srgb(src);
TargetCS::from_linear_srgb(lin_rgb)
}
}

fn clip([l, a, b]: [f32; 3]) -> [f32; 3] {
[l.clamp(0., 100.), a, b]
}
}

/// The cylindrical version of the [Lab] color space.
///
/// Its components are `[L, C, h]` with
/// - `L` - the lightness as in [`Lab`];
/// - `C` - the chromatic intensity, the natural lower bound of 0 being achromatic, usually not
/// exceeding 160; and
/// - `h` - the hue angle in degrees.
#[derive(Clone, Copy, Debug)]
pub struct Lch;

impl ColorSpace for Lch {
const TAG: Option<ColorSpaceTag> = Some(ColorSpaceTag::Lch);

const LAYOUT: ColorSpaceLayout = ColorSpaceLayout::HueThird;

fn from_linear_srgb(src: [f32; 3]) -> [f32; 3] {
lab_to_lch(Lab::from_linear_srgb(src))
}

fn to_linear_srgb(src: [f32; 3]) -> [f32; 3] {
Lab::to_linear_srgb(lch_to_lab(src))
}

fn scale_chroma([l, c, h]: [f32; 3], scale: f32) -> [f32; 3] {
[l, c * scale, h]
}

fn convert<TargetCS: ColorSpace>(src: [f32; 3]) -> [f32; 3] {
if TypeId::of::<Self>() == TypeId::of::<TargetCS>() {
src
} else if TypeId::of::<TargetCS>() == TypeId::of::<Lab>() {
lch_to_lab(src)
} else {
let lin_rgb = Self::to_linear_srgb(src);
TargetCS::from_linear_srgb(lin_rgb)
}
}

fn clip([l, c, h]: [f32; 3]) -> [f32; 3] {
[l.clamp(0., 100.), c.max(0.), h]
}
}
10 changes: 6 additions & 4 deletions color/src/css.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,12 @@ impl CssColor {
#[must_use]
pub fn map_lightness(self, f: impl Fn(f32) -> f32) -> Self {
match self.cs {
ColorSpaceTag::Oklab
| ColorSpaceTag::Oklch
| ColorSpaceTag::Lab
| ColorSpaceTag::Lch => self.map(|l, c1, c2, a| [f(l), c1, c2, a]),
ColorSpaceTag::Lab | ColorSpaceTag::Lch => {
self.map(|l, c1, c2, a| [100.0 * f(l * 0.01), c1, c2, a])
}
ColorSpaceTag::Oklab | ColorSpaceTag::Oklch => {
self.map(|l, c1, c2, a| [f(l), c1, c2, a])
}
ColorSpaceTag::Hsl => self.map(|h, s, l, a| [h, s, 100.0 * f(l * 0.01), a]),
_ => self.map_in(ColorSpaceTag::Oklab, |l, a, b, alpha| [f(l), a, b, alpha]),
}
Expand Down
2 changes: 1 addition & 1 deletion color/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ mod floatfuncs;
pub use bitset::Bitset;
pub use color::{AlphaColor, HueDirection, OpaqueColor, PremulColor};
pub use colorspace::{
ColorSpace, ColorSpaceLayout, DisplayP3, LinearSrgb, Oklab, Oklch, Srgb, XyzD65,
ColorSpace, ColorSpaceLayout, DisplayP3, Lab, Lch, LinearSrgb, Oklab, Oklch, Srgb, XyzD65,
};
pub use css::{CssColor, Interpolator};
pub use gradient::{gradient, GradientIter};
Expand Down
34 changes: 17 additions & 17 deletions color/src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,38 +298,36 @@ impl<'a> Parser<'a> {
Ok(alpha)
}

fn oklab(&mut self) -> Result<CssColor, Error> {
fn lab(&mut self, lmax: f64, c: f64, tag: ColorSpaceTag) -> Result<CssColor, Error> {
if !self.raw_ch(b'(') {
return Err("expected arguments");
}
let l = self.scaled_component(1., 0.01)?.map(|x| x.clamp(0., 1.));
let a = self.scaled_component(1., 0.004)?;
let b = self.scaled_component(1., 0.004)?;
let l = self
.scaled_component(1., 0.01 * lmax)?
.map(|x| x.clamp(0., lmax));
let a = self.scaled_component(1., c)?;
let b = self.scaled_component(1., c)?;
let alpha = self.optional_alpha()?;
if !self.ch(b')') {
return Err("expected closing parenthesis");
}
Ok(color_from_components(
[l, a, b, alpha],
ColorSpaceTag::Oklab,
))
Ok(color_from_components([l, a, b, alpha], tag))
}

fn oklch(&mut self) -> Result<CssColor, Error> {
fn lch(&mut self, lmax: f64, c: f64, tag: ColorSpaceTag) -> Result<CssColor, Error> {
if !self.raw_ch(b'(') {
return Err("expected arguments");
}
let l = self.scaled_component(1., 0.01)?.map(|x| x.clamp(0., 1.));
let c = self.scaled_component(1., 0.004)?.map(|x| x.max(0.));
let l = self
.scaled_component(1., 0.01 * lmax)?
.map(|x| x.clamp(0., lmax));
let c = self.scaled_component(1., c)?.map(|x| x.max(0.));
let h = self.angle()?;
let alpha = self.optional_alpha()?;
if !self.ch(b')') {
return Err("expected closing parenthesis");
}
Ok(color_from_components(
[l, c, h, alpha],
ColorSpaceTag::Oklch,
))
Ok(color_from_components([l, c, h, alpha], tag))
}

fn color(&mut self) -> Result<CssColor, Error> {
Expand Down Expand Up @@ -375,8 +373,10 @@ pub fn parse_color(s: &str) -> Result<CssColor, Error> {
if let Some(id) = parser.ident() {
match id {
"rgb" | "rgba" => parser.rgb(),
"oklab" => parser.oklab(),
"oklch" => parser.oklch(),
"lab" => parser.lab(100.0, 1.25, ColorSpaceTag::Lab),
"lch" => parser.lch(100.0, 1.25, ColorSpaceTag::Lch),
"oklab" => parser.lab(1.0, 0.004, ColorSpaceTag::Oklab),
"oklch" => parser.lch(1.0, 0.004, ColorSpaceTag::Oklab),
"transparent" => Ok(color_from_components([Some(0.); 4], ColorSpaceTag::Srgb)),
"color" => parser.color(),
_ => {
Expand Down
10 changes: 8 additions & 2 deletions color/src/tagged.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
use crate::{
color::{add_alpha, split_alpha},
AlphaColor, Bitset, ColorSpace, ColorSpaceLayout, DisplayP3, LinearSrgb, Oklab, Oklch, Srgb,
XyzD65,
AlphaColor, Bitset, ColorSpace, ColorSpaceLayout, DisplayP3, Lab, Lch, LinearSrgb, Oklab,
Oklch, Srgb, XyzD65,
};

/// The color space tag for tagged colors.
Expand Down Expand Up @@ -128,6 +128,8 @@ impl ColorSpaceTag {
match self {
Self::Srgb => Srgb::from_linear_srgb(rgb),
Self::LinearSrgb => rgb,
Self::Lab => Lab::from_linear_srgb(rgb),
Self::Lch => Lch::from_linear_srgb(rgb),
Self::Oklab => Oklab::from_linear_srgb(rgb),
Self::Oklch => Oklch::from_linear_srgb(rgb),
Self::DisplayP3 => DisplayP3::from_linear_srgb(rgb),
Expand All @@ -143,6 +145,8 @@ impl ColorSpaceTag {
match self {
Self::Srgb => Srgb::to_linear_srgb(src),
Self::LinearSrgb => src,
Self::Lab => Lab::to_linear_srgb(src),
Self::Lch => Lch::to_linear_srgb(src),
Self::Oklab => Oklab::to_linear_srgb(src),
Self::Oklch => Oklch::to_linear_srgb(src),
Self::DisplayP3 => DisplayP3::to_linear_srgb(src),
Expand Down Expand Up @@ -186,6 +190,8 @@ impl ColorSpaceTag {
match self {
Self::Srgb => Srgb::clip(src),
Self::LinearSrgb => LinearSrgb::clip(src),
Self::Lab => Lab::clip(src),
Self::Lch => Lch::clip(src),
Self::Oklab => Oklab::clip(src),
Self::Oklch => Oklch::clip(src),
Self::DisplayP3 => DisplayP3::clip(src),
Expand Down

0 comments on commit b68860b

Please sign in to comment.