Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Lab and Lch color spaces #22

Merged
merged 4 commits into from
Nov 9, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
26 changes: 16 additions & 10 deletions color/src/color.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,14 +202,17 @@ impl<CS: ColorSpace> OpaqueColor<CS> {
///
/// In a color space that naturally has a lightness component, map that value.
/// Otherwise, do the mapping in Oklab. The lightness range is normalized so
/// that 1.0 is white.
/// that 1.0 is white. That is the normal range for Oklab but differs from the
/// range in Lab, Lch, and HSL.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, but can these be links?

#[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 @@ -298,14 +301,17 @@ impl<CS: ColorSpace> AlphaColor<CS> {
///
/// In a color space that naturally has a lightness component, map that value.
/// Otherwise, do the mapping in Oklab. The lightness range is normalized so
/// that 1.0 is white.
/// that 1.0 is white. That is the normal range for Oklab but differs from the
/// range in Lab, Lch, and HSL.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Links here too?

#[must_use]
pub fn map_lightness(self, f: impl Fn(f32) -> f32) -> Self {
waywardmonkeys marked this conversation as resolved.
Show resolved Hide resolved
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
159 changes: 155 additions & 4 deletions color/src/colorspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,8 +407,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 @@ -468,8 +468,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 @@ -487,3 +487,154 @@ 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. Note that the lightness
/// range differs from Oklab as well; in Oklab white has a lightness of 1.
///
/// The CIE L\*a\*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| {
// This is EPSILON.cbrt() but that function isn't const (yet)
const EPSILON_CBRT: f32 = 0.206_896_56;
waywardmonkeys marked this conversation as resolved.
Show resolved Hide resolved
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
waywardmonkeys marked this conversation as resolved.
Show resolved Hide resolved
/// - `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.
///
/// See [`Oklch`] for a similar color space but with better hue linearity.
#[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::Oklch),
"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 @@ -142,6 +142,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 @@ -157,6 +159,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 @@ -200,6 +204,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