palette/
okhsl.rs

1//! Types for the Okhsl color space.
2
3pub use alpha::Okhsla;
4
5use crate::{
6    angle::FromAngle,
7    convert::{FromColorUnclamped, IntoColorUnclamped},
8    num::{Arithmetics, Cbrt, Hypot, IsValidDivisor, MinMax, One, Powi, Real, Sqrt, Zero},
9    ok_utils::{toe, ChromaValues},
10    stimulus::{FromStimulus, Stimulus},
11    white_point::D65,
12    GetHue, HasBoolMask, LinSrgb, Oklab, OklabHue,
13};
14
15pub use self::properties::Iter;
16
17#[cfg(feature = "random")]
18pub use self::random::UniformOkhsl;
19
20mod alpha;
21mod properties;
22#[cfg(feature = "random")]
23mod random;
24#[cfg(test)]
25#[cfg(feature = "approx")]
26mod visual_eq;
27
28/// A Hue/Saturation/Lightness representation of [`Oklab`] in the `sRGB` color space.
29///
30/// Allows
31/// * changing hue/chroma/saturation, while keeping perceived lightness constant (like HSLuv)
32/// * changing lightness/chroma/saturation, while keeping perceived hue constant
33/// * changing the perceived saturation (more or less) proportionally with the numerical
34/// amount of change (unlike HSLuv)
35#[derive(Debug, Copy, Clone, ArrayCast, FromColorUnclamped, WithAlpha)]
36#[cfg_attr(feature = "serializing", derive(Serialize, Deserialize))]
37#[palette(
38    palette_internal,
39    white_point = "D65",
40    component = "T",
41    skip_derives(Oklab)
42)]
43#[repr(C)]
44pub struct Okhsl<T = f32> {
45    /// The hue of the color, in degrees of a circle.
46    ///
47    /// For fully saturated, bright colors
48    /// * 0° corresponds to a kind of magenta-pink (RBG #ff0188),
49    /// * 90° to a kind of yellow (RBG RGB #ffcb00)
50    /// * 180° to a kind of cyan (RBG #00ffe1) and
51    /// * 240° to a kind of blue (RBG #00aefe).
52    ///
53    /// For s == 0 or v == 0, the hue is irrelevant.
54    #[palette(unsafe_same_layout_as = "T")]
55    pub hue: OklabHue<T>,
56
57    /// The saturation (freedom of black or white) of the color.
58    ///
59    /// * `0.0` corresponds to pure mixture of black and white without any color.
60    /// The black to white relation depends on v.
61    /// * `1.0` to a fully saturated color without any white.
62    ///
63    /// For v == 0 the saturation is irrelevant.
64    pub saturation: T,
65
66    /// The relative luminance of the color, where
67    /// * `0.0` corresponds to pure black
68    /// * `1.0` corresponds to white
69    ///
70    /// This luminance is visually similar to [Cielab](crate::Lab)'s luminance for a
71    /// `D65` reference white point.
72    ///
73    /// `Okhsv`'s `value` component goes from black to non-black
74    /// -- a maximally bright color in the `sRGB` gamut.
75    ///
76    /// `Okhsl`'s `lightness` component goes from black to white in the `sRGB` color space.
77    pub lightness: T,
78}
79
80impl<T> Okhsl<T> {
81    /// Create an Okhsl color.
82    pub fn new<H: Into<OklabHue<T>>>(hue: H, saturation: T, lightness: T) -> Self {
83        Self {
84            hue: hue.into(),
85            saturation,
86            lightness,
87        }
88    }
89
90    /// Create an `Okhsl` color. This is the same as `Okhsl::new` without the
91    /// generic hue type. It's temporary until `const fn` supports traits.
92    pub const fn new_const(hue: OklabHue<T>, saturation: T, lightness: T) -> Self {
93        Self {
94            hue,
95            saturation,
96            lightness,
97        }
98    }
99
100    /// Convert into another component type.
101    pub fn into_format<U>(self) -> Okhsl<U>
102    where
103        U: FromStimulus<T> + FromAngle<T>,
104    {
105        Okhsl {
106            hue: self.hue.into_format(),
107            saturation: U::from_stimulus(self.saturation),
108            lightness: U::from_stimulus(self.lightness),
109        }
110    }
111
112    /// Convert from another component type.
113    pub fn from_format<U>(color: Okhsl<U>) -> Self
114    where
115        T: FromStimulus<U> + FromAngle<U>,
116    {
117        color.into_format()
118    }
119
120    /// Convert to a `(h, s, l)` tuple.
121    pub fn into_components(self) -> (OklabHue<T>, T, T) {
122        (self.hue, self.saturation, self.lightness)
123    }
124
125    /// Convert from a `(h, s, l)` tuple.
126    pub fn from_components<H: Into<OklabHue<T>>>((hue, saturation, lightness): (H, T, T)) -> Self {
127        Self::new(hue, saturation, lightness)
128    }
129}
130
131impl<T> Okhsl<T>
132where
133    T: Stimulus,
134{
135    /// Return the `saturation` value minimum.
136    pub fn min_saturation() -> T {
137        T::zero()
138    }
139
140    /// Return the `saturation` value maximum.
141    pub fn max_saturation() -> T {
142        T::max_intensity()
143    }
144
145    /// Return the `lightness` value minimum.
146    pub fn min_lightness() -> T {
147        T::zero()
148    }
149
150    /// Return the `lightness` value maximum.
151    pub fn max_lightness() -> T {
152        T::max_intensity()
153    }
154}
155
156impl_reference_component_methods_hue!(Okhsl, [saturation, lightness]);
157impl_struct_of_arrays_methods_hue!(Okhsl, [saturation, lightness]);
158
159/// # See
160/// See [`srgb_to_okhsl`](https://bottosson.github.io/posts/colorpicker/#hsl-2)
161impl<T> FromColorUnclamped<Oklab<T>> for Okhsl<T>
162where
163    T: Real
164        + One
165        + Zero
166        + Arithmetics
167        + Powi
168        + Sqrt
169        + Hypot
170        + MinMax
171        + Cbrt
172        + IsValidDivisor<Mask = bool>
173        + HasBoolMask<Mask = bool>
174        + PartialOrd
175        + Clone,
176    Oklab<T>: GetHue<Hue = OklabHue<T>> + IntoColorUnclamped<LinSrgb<T>>,
177{
178    fn from_color_unclamped(lab: Oklab<T>) -> Self {
179        // refer to the SRGB reference-white-based lightness L_r as l for consistency with HSL
180        let l = toe(lab.l.clone());
181        let chroma = lab.get_chroma();
182
183        // Not part of the reference implementation. Added to prevent
184        // https://github.com/Ogeon/palette/issues/368 and other cases of NaN.
185        if !chroma.is_valid_divisor() || lab.l == T::one() || !lab.l.is_valid_divisor() {
186            return Self::new(T::zero(), T::zero(), l);
187        }
188
189        let hue = lab.get_hue();
190        let cs = ChromaValues::from_normalized(lab.l, lab.a / &chroma, lab.b / &chroma);
191
192        // Inverse of the interpolation in okhsl_to_srgb:
193
194        let mid = T::from_f64(0.8);
195        let mid_inv = T::from_f64(1.25);
196
197        let s = if chroma < cs.mid {
198            let k_1 = mid.clone() * cs.zero;
199            let k_2 = T::one() - k_1.clone() / cs.mid;
200
201            let t = chroma.clone() / (k_1 + k_2 * chroma);
202            t * mid
203        } else {
204            let k_0 = cs.mid.clone();
205            let k_1 = (T::one() - &mid) * (cs.mid.clone() * mid_inv).powi(2) / cs.zero;
206            let k_2 = T::one() - k_1.clone() / (cs.max - cs.mid);
207
208            let t = (chroma.clone() - &k_0) / (k_1 + k_2 * (chroma - k_0));
209            mid.clone() + (T::one() - mid) * t
210        };
211
212        Self::new(hue, s, l)
213    }
214}
215
216impl<T> HasBoolMask for Okhsl<T>
217where
218    T: HasBoolMask,
219{
220    type Mask = T::Mask;
221}
222
223impl<T> Default for Okhsl<T>
224where
225    T: Stimulus,
226    OklabHue<T>: Default,
227{
228    fn default() -> Okhsl<T> {
229        Okhsl::new(
230            OklabHue::default(),
231            Self::min_saturation(),
232            Self::min_lightness(),
233        )
234    }
235}
236
237#[cfg(feature = "bytemuck")]
238unsafe impl<T> bytemuck::Zeroable for Okhsl<T> where T: bytemuck::Zeroable {}
239
240#[cfg(feature = "bytemuck")]
241unsafe impl<T> bytemuck::Pod for Okhsl<T> where T: bytemuck::Pod {}
242
243#[cfg(test)]
244mod tests {
245    use crate::{
246        convert::{FromColorUnclamped, IntoColorUnclamped},
247        encoding,
248        rgb::Rgb,
249        Okhsl, Oklab, Srgb,
250    };
251
252    test_convert_into_from_xyz!(Okhsl);
253
254    #[cfg(feature = "approx")]
255    mod conversion {
256        use core::str::FromStr;
257
258        use crate::{
259            convert::FromColorUnclamped,
260            visual::{VisualColor, VisuallyEqual},
261            LinSrgb, Okhsl, Oklab, Srgb,
262        };
263
264        #[cfg_attr(miri, ignore)]
265        #[test]
266        fn test_roundtrip_okhsl_oklab_is_original() {
267            let colors = [
268                (
269                    "red",
270                    Oklab::from_color_unclamped(LinSrgb::new(1.0, 0.0, 0.0)),
271                ),
272                (
273                    "green",
274                    Oklab::from_color_unclamped(LinSrgb::new(0.0, 1.0, 0.0)),
275                ),
276                (
277                    "cyan",
278                    Oklab::from_color_unclamped(LinSrgb::new(0.0, 1.0, 1.0)),
279                ),
280                (
281                    "magenta",
282                    Oklab::from_color_unclamped(LinSrgb::new(1.0, 0.0, 1.0)),
283                ),
284                (
285                    "black",
286                    Oklab::from_color_unclamped(LinSrgb::new(0.0, 0.0, 0.0)),
287                ),
288                (
289                    "grey",
290                    Oklab::from_color_unclamped(LinSrgb::new(0.5, 0.5, 0.5)),
291                ),
292                (
293                    "yellow",
294                    Oklab::from_color_unclamped(LinSrgb::new(1.0, 1.0, 0.0)),
295                ),
296                (
297                    "blue",
298                    Oklab::from_color_unclamped(LinSrgb::new(0.0, 0.0, 1.0)),
299                ),
300                (
301                    "white",
302                    Oklab::from_color_unclamped(LinSrgb::new(1.0, 1.0, 1.0)),
303                ),
304            ];
305
306            // unlike in okhwb we are using f64 here, which actually works.
307            // So we can afford a small tolerance.
308            // For some reason the roundtrip of Okhsl seems to produce a greater
309            // divergence than the round trip of Okhsv (1e-8 vs 1e-10)
310            const EPSILON: f64 = 1e-8;
311
312            for (name, color) in colors {
313                let rgb: Srgb<u8> = Srgb::<f64>::from_color_unclamped(color).into_format();
314                println!(
315                    "\n\
316                    roundtrip of {} (#{:x} / {:?})\n\
317                    =================================================",
318                    name, rgb, color
319                );
320
321                println!("Color is white: {}", color.is_white(EPSILON));
322
323                let okhsl = Okhsl::from_color_unclamped(color);
324                println!("Okhsl: {:?}", okhsl);
325                let roundtrip_color = Oklab::from_color_unclamped(okhsl);
326                assert!(
327                    Oklab::visually_eq(roundtrip_color, color, EPSILON),
328                    "'{}' failed.\n{:?}\n!=\n{:?}",
329                    name,
330                    roundtrip_color,
331                    color
332                );
333            }
334        }
335
336        #[test]
337        fn test_blue() {
338            let lab = Oklab::new(
339                0.45201371519623734_f64,
340                -0.03245697990291002,
341                -0.3115281336419824,
342            );
343            let okhsl = Okhsl::<f64>::from_color_unclamped(lab);
344            assert!(
345                abs_diff_eq!(
346                    okhsl.hue.into_raw_degrees(),
347                    360.0 * 0.7334778365225699,
348                    epsilon = 1e-10
349                ),
350                "{}\n!=\n{}",
351                okhsl.hue.into_raw_degrees(),
352                360.0 * 0.7334778365225699
353            );
354            assert!(
355                abs_diff_eq!(okhsl.saturation, 0.9999999897262261, epsilon = 1e-8),
356                "{}\n!=\n{}",
357                okhsl.saturation,
358                0.9999999897262261
359            );
360            assert!(
361                abs_diff_eq!(okhsl.lightness, 0.366565335813274, epsilon = 1e-10),
362                "{}\n!=\n{}",
363                okhsl.lightness,
364                0.366565335813274
365            );
366        }
367
368        #[test]
369        fn test_srgb_to_okhsl() {
370            let red_hex = "#834941";
371            let rgb: Srgb<f64> = Srgb::from_str(red_hex).unwrap().into_format();
372            let lin_rgb = LinSrgb::<f64>::from_color_unclamped(rgb);
373            let oklab = Oklab::from_color_unclamped(lin_rgb);
374            println!(
375                "RGB: {:?}\n\
376            LinRgb: {:?}\n\
377            Oklab: {:?}",
378                rgb, lin_rgb, oklab
379            );
380            let okhsl = Okhsl::from_color_unclamped(oklab);
381
382            // test data from Ok Color picker
383            assert_relative_eq!(
384                okhsl.hue.into_raw_degrees(),
385                360.0 * 0.07992730371382328,
386                epsilon = 1e-10,
387                max_relative = 1e-13
388            );
389            assert_relative_eq!(okhsl.saturation, 0.4629217183454986, epsilon = 1e-10);
390            assert_relative_eq!(okhsl.lightness, 0.3900998146147427, epsilon = 1e-10);
391        }
392    }
393
394    #[test]
395    fn test_okhsl_to_srgb() {
396        let okhsl = Okhsl::new(0.0_f32, 0.5, 0.5);
397        let rgb = Srgb::from_color_unclamped(okhsl);
398        let rgb8: Rgb<encoding::Srgb, u8> = rgb.into_format();
399        let hex_str = format!("{:x}", rgb8);
400        assert_eq!(hex_str, "aa5a74");
401    }
402
403    #[test]
404    fn test_okhsl_to_srgb_saturated_black() {
405        let okhsl = Okhsl::new(0.0_f32, 1.0, 0.0);
406        let rgb = Srgb::from_color_unclamped(okhsl);
407        assert_eq!(rgb, Srgb::new(0.0, 0.0, 0.0));
408    }
409
410    #[test]
411    fn test_oklab_to_okhsl_saturated_white() {
412        // Minimized check for the case in
413        // https://github.com/Ogeon/palette/issues/368. It ended up resulting in
414        // an Oklab value where a or b was larger than 0, which bypassed the
415        // chroma check.
416        let oklab = Oklab::new(1.0, 1.0, 0.0);
417        let okhsl: Okhsl = oklab.into_color_unclamped();
418        assert_eq!(okhsl, Okhsl::new(0.0, 0.0, 1.0));
419    }
420
421    #[test]
422    fn test_oklab_to_okhsl_saturated_black() {
423        // Minimized check for the case in
424        // https://github.com/Ogeon/palette/issues/368. This wasn't the reported
425        // case, but another variant of it.
426        let oklab = Oklab::new(0.0, 1.0, 0.0);
427        let okhsl: Okhsl = oklab.into_color_unclamped();
428        assert_eq!(okhsl, Okhsl::new(0.0, 0.0, 0.0));
429    }
430
431    struct_of_arrays_tests!(
432        Okhsl[hue, saturation, lightness],
433        super::Okhsla::new(0.1f32, 0.2, 0.3, 0.4),
434        super::Okhsla::new(0.2, 0.3, 0.4, 0.5),
435        super::Okhsla::new(0.3, 0.4, 0.5, 0.6)
436    );
437}