Skip to main content

esoc_color/
oklab.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! `OKLab` and `OKLCH` color spaces — perceptual color math.
3//!
4//! Reference: Björn Ottosson, "A perceptual color space for image processing"
5//! <https://bottosson.github.io/posts/oklab/>
6
7use crate::Color;
8
9/// A color in `OKLab` perceptual color space.
10#[derive(Clone, Copy, Debug, PartialEq)]
11pub struct OkLab {
12    /// Lightness `[0, 1]`.
13    pub l: f32,
14    /// Green-red axis.
15    pub a: f32,
16    /// Blue-yellow axis.
17    pub b: f32,
18}
19
20/// A color in `OKLCH` (cylindrical `OKLab`).
21#[derive(Clone, Copy, Debug, PartialEq)]
22pub struct OkLch {
23    /// Lightness `[0, 1]`.
24    pub l: f32,
25    /// Chroma (saturation).
26    pub c: f32,
27    /// Hue in degrees `[0, 360)`.
28    pub h: f32,
29}
30
31// Linear RGB → LMS (M1 matrix from Ottosson)
32#[inline]
33fn linear_rgb_to_lms(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
34    let l = 0.412_221_5 * r + 0.536_332_55 * g + 0.051_445_94 * b;
35    let m = 0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b;
36    let s = 0.088_302_46 * r + 0.281_718_85 * g + 0.629_978_7 * b;
37    (l, m, s)
38}
39
40// LMS^(1/3) → OKLab (M2 matrix)
41#[inline]
42fn lms_to_oklab(l: f32, m: f32, s: f32) -> (f32, f32, f32) {
43    let l_ = l.cbrt();
44    let m_ = m.cbrt();
45    let s_ = s.cbrt();
46
47    let lab_l = 0.210_454_26 * l_ + 0.793_617_8 * m_ - 0.004_072_047 * s_;
48    let lab_a = 1.977_998_5 * l_ - 2.428_592_2 * m_ + 0.450_593_7 * s_;
49    let lab_b = 0.025_904_037 * l_ + 0.782_771_8 * m_ - 0.808_675_77 * s_;
50    (lab_l, lab_a, lab_b)
51}
52
53// OKLab → LMS^(1/3) (inverse M2)
54#[inline]
55fn oklab_to_lms_cubed(l: f32, a: f32, b: f32) -> (f32, f32, f32) {
56    let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b;
57    let m_ = l - 0.105_561_346 * a - 0.063_854_17 * b;
58    let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b;
59
60    (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_)
61}
62
63// LMS → linear RGB (inverse M1)
64#[inline]
65fn lms_to_linear_rgb(l: f32, m: f32, s: f32) -> (f32, f32, f32) {
66    let r = 4.076_741_7 * l - 3.307_711_6 * m + 0.230_969_94 * s;
67    let g = -1.268_438 * l + 2.609_757_4 * m - 0.341_319_38 * s;
68    let b = -0.004_196_086_3 * l - 0.703_418_6 * m + 1.707_614_7 * s;
69    (r, g, b)
70}
71
72impl OkLab {
73    /// Create a new `OKLab` color.
74    pub fn new(l: f32, a: f32, b: f32) -> Self {
75        Self { l, a, b }
76    }
77
78    /// Convert from linear RGB.
79    pub fn from_linear_rgb(c: Color) -> Self {
80        let (l, m, s) = linear_rgb_to_lms(c.r, c.g, c.b);
81        let (lab_l, lab_a, lab_b) = lms_to_oklab(l, m, s);
82        Self {
83            l: lab_l,
84            a: lab_a,
85            b: lab_b,
86        }
87    }
88
89    /// Convert to linear RGB.
90    pub fn to_linear_rgb(self) -> Color {
91        let (l, m, s) = oklab_to_lms_cubed(self.l, self.a, self.b);
92        let (r, g, b) = lms_to_linear_rgb(l, m, s);
93        Color::new(r, g, b, 1.0)
94    }
95
96    /// Convert to OKLCH.
97    pub fn to_oklch(self) -> OkLch {
98        let c = self.a.hypot(self.b);
99        let h = if c < 1e-8 {
100            0.0
101        } else {
102            self.b.atan2(self.a).to_degrees().rem_euclid(360.0)
103        };
104        OkLch { l: self.l, c, h }
105    }
106
107    /// Linearly interpolate in `OKLab` space.
108    pub fn lerp(self, other: Self, t: f32) -> Self {
109        let t = t.clamp(0.0, 1.0);
110        Self {
111            l: self.l + (other.l - self.l) * t,
112            a: self.a + (other.a - self.a) * t,
113            b: self.b + (other.b - self.b) * t,
114        }
115    }
116}
117
118impl OkLch {
119    /// Create a new OKLCH color.
120    pub fn new(l: f32, c: f32, h: f32) -> Self {
121        Self { l, c, h }
122    }
123
124    /// Convert to `OKLab`.
125    pub fn to_oklab(self) -> OkLab {
126        let h_rad = self.h.to_radians();
127        OkLab {
128            l: self.l,
129            a: self.c * h_rad.cos(),
130            b: self.c * h_rad.sin(),
131        }
132    }
133
134    /// Convert to linear RGB.
135    pub fn to_linear_rgb(self) -> Color {
136        self.to_oklab().to_linear_rgb()
137    }
138
139    /// Interpolate in OKLCH with shortest-arc hue interpolation.
140    pub fn lerp(self, other: Self, t: f32) -> Self {
141        let t = t.clamp(0.0, 1.0);
142
143        // Shortest-arc hue interpolation
144        let mut dh = other.h - self.h;
145        if dh > 180.0 {
146            dh -= 360.0;
147        } else if dh < -180.0 {
148            dh += 360.0;
149        }
150
151        Self {
152            l: self.l + (other.l - self.l) * t,
153            c: self.c + (other.c - self.c) * t,
154            h: (self.h + dh * t).rem_euclid(360.0),
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn black_roundtrip() {
165        let lab = OkLab::from_linear_rgb(Color::BLACK);
166        assert!(lab.l.abs() < 1e-4);
167        let back = lab.to_linear_rgb();
168        assert!(back.r.abs() < 1e-4);
169    }
170
171    #[test]
172    fn white_roundtrip() {
173        let lab = OkLab::from_linear_rgb(Color::WHITE);
174        assert!((lab.l - 1.0).abs() < 1e-3);
175        let back = lab.to_linear_rgb();
176        assert!((back.r - 1.0).abs() < 1e-3);
177        assert!((back.g - 1.0).abs() < 1e-3);
178        assert!((back.b - 1.0).abs() < 1e-3);
179    }
180
181    #[test]
182    fn oklch_hue_wrapping() {
183        let a = OkLch::new(0.7, 0.15, 350.0);
184        let b = OkLch::new(0.7, 0.15, 10.0);
185        let mid = a.lerp(b, 0.5);
186        // Should go through 0° not 180°
187        assert!(mid.h < 10.0 || mid.h > 350.0);
188    }
189
190    #[test]
191    fn color_rgb_roundtrip() {
192        let colors = [
193            Color::from_hex("#ff0000").unwrap(),
194            Color::from_hex("#00ff00").unwrap(),
195            Color::from_hex("#0000ff").unwrap(),
196            Color::from_hex("#1f77b4").unwrap(),
197        ];
198        for c in colors {
199            let lab = OkLab::from_linear_rgb(c);
200            let back = lab.to_linear_rgb();
201            assert!(
202                (c.r - back.r).abs() < 1e-3,
203                "r: {} vs {} for {:?}",
204                c.r,
205                back.r,
206                c
207            );
208            assert!(
209                (c.g - back.g).abs() < 1e-3,
210                "g: {} vs {} for {:?}",
211                c.g,
212                back.g,
213                c
214            );
215            assert!(
216                (c.b - back.b).abs() < 1e-3,
217                "b: {} vs {} for {:?}",
218                c.b,
219                back.b,
220                c
221            );
222        }
223    }
224
225    #[test]
226    fn oklch_roundtrip() {
227        let c = Color::from_hex("#1f77b4").unwrap();
228        let lch = c.to_oklch();
229        let lab = lch.to_oklab();
230        let original_lab = c.to_oklab();
231        assert!((lab.l - original_lab.l).abs() < 1e-5);
232        assert!((lab.a - original_lab.a).abs() < 1e-5);
233        assert!((lab.b - original_lab.b).abs() < 1e-5);
234    }
235}