palette/
okhsv.rs

1//! Types for the Okhsv color space.
2
3use core::fmt::Debug;
4
5pub use alpha::Okhsva;
6#[cfg(feature = "random")]
7pub use random::UniformOkhsv;
8
9use crate::{
10    angle::FromAngle,
11    bool_mask::LazySelect,
12    convert::{FromColorUnclamped, IntoColorUnclamped},
13    num::{
14        Arithmetics, Cbrt, Hypot, IsValidDivisor, MinMax, One, Powi, Real, Sqrt, Trigonometry, Zero,
15    },
16    ok_utils::{self, LC, ST},
17    stimulus::{FromStimulus, Stimulus},
18    white_point::D65,
19    GetHue, HasBoolMask, LinSrgb, Okhwb, Oklab, OklabHue,
20};
21
22pub use self::properties::Iter;
23
24mod alpha;
25mod properties;
26#[cfg(feature = "random")]
27mod random;
28#[cfg(test)]
29#[cfg(feature = "approx")]
30mod visual_eq;
31
32/// A Hue/Saturation/Value representation of [`Oklab`] in the `sRGB` color space.
33///
34/// Allows
35/// * changing lightness/chroma/saturation while keeping perceived Hue constant
36///   (like HSV promises but delivers only partially)
37/// * finding the strongest color (maximum chroma) at s == 1 (like HSV)
38#[derive(Debug, Copy, Clone, ArrayCast, FromColorUnclamped, WithAlpha)]
39#[cfg_attr(feature = "serializing", derive(Serialize, Deserialize))]
40#[palette(
41    palette_internal,
42    white_point = "D65",
43    component = "T",
44    skip_derives(Oklab, Okhwb)
45)]
46#[repr(C)]
47pub struct Okhsv<T = f32> {
48    /// The hue of the color, in degrees of a circle.
49    ///
50    /// For fully saturated, bright colors
51    /// * 0° corresponds to a kind of magenta-pink (RBG #ff0188),
52    /// * 90° to a kind of yellow (RBG RGB #ffcb00)
53    /// * 180° to a kind of cyan (RBG #00ffe1) and
54    /// * 240° to a kind of blue (RBG #00aefe).
55    ///
56    /// For s == 0 or v == 0, the hue is irrelevant.
57    #[palette(unsafe_same_layout_as = "T")]
58    pub hue: OklabHue<T>,
59
60    /// The saturation (freedom of whitishness) of the color.
61    ///
62    /// * `0.0` corresponds to pure mixture of black and white without any color.
63    ///   The black to white relation depends on v.
64    /// * `1.0` to a fully saturated color without any white.
65    ///
66    /// For v == 0 the saturation is irrelevant.
67    pub saturation: T,
68
69    /// The monochromatic brightness of the color.
70    /// * `0.0` corresponds to pure black
71    /// * `1.0` corresponds to a maximally bright colour -- be it very colorful or very  white
72    ///
73    /// `Okhsl`'s `lightness` component goes from black to white.
74    /// `Okhsv`'s `value` component goes from black to non-black -- a maximally bright color..
75    pub value: T,
76}
77
78impl_tuple_conversion_hue!(Okhsv as (H, T, T), OklabHue);
79
80impl<T> HasBoolMask for Okhsv<T>
81where
82    T: HasBoolMask,
83{
84    type Mask = T::Mask;
85}
86
87impl<T> Default for Okhsv<T>
88where
89    T: Stimulus,
90    OklabHue<T>: Default,
91{
92    fn default() -> Okhsv<T> {
93        Okhsv::new(
94            OklabHue::default(),
95            Self::min_saturation(),
96            Self::min_value(),
97        )
98    }
99}
100
101impl<T> Okhsv<T>
102where
103    T: Stimulus,
104{
105    /// Return the `saturation` value minimum.
106    pub fn min_saturation() -> T {
107        T::zero()
108    }
109
110    /// Return the `saturation` value maximum.
111    pub fn max_saturation() -> T {
112        T::max_intensity()
113    }
114
115    /// Return the `value` value minimum.
116    pub fn min_value() -> T {
117        T::zero()
118    }
119
120    /// Return the `value` value maximum.
121    pub fn max_value() -> T {
122        T::max_intensity()
123    }
124}
125
126impl_reference_component_methods_hue!(Okhsv, [saturation, value]);
127impl_struct_of_arrays_methods_hue!(Okhsv, [saturation, value]);
128
129impl<T> Okhsv<T> {
130    /// Create an `Okhsv` color.
131    pub fn new<H: Into<OklabHue<T>>>(hue: H, saturation: T, value: T) -> Self {
132        Self {
133            hue: hue.into(),
134            saturation,
135            value,
136        }
137    }
138
139    /// Create an `Okhsv` color. This is the same as `Okhsv::new` without the
140    /// generic hue type. It's temporary until `const fn` supports traits.
141    pub const fn new_const(hue: OklabHue<T>, saturation: T, value: T) -> Self {
142        Self {
143            hue,
144            saturation,
145            value,
146        }
147    }
148
149    /// Convert into another component type.
150    pub fn into_format<U>(self) -> Okhsv<U>
151    where
152        U: FromStimulus<T> + FromAngle<T>,
153    {
154        Okhsv {
155            hue: self.hue.into_format(),
156            saturation: U::from_stimulus(self.saturation),
157            value: U::from_stimulus(self.value),
158        }
159    }
160
161    /// Convert to a `(h, s, v)` tuple.
162    pub fn into_components(self) -> (OklabHue<T>, T, T) {
163        (self.hue, self.saturation, self.value)
164    }
165
166    /// Convert from a `(h, s, v)` tuple.
167    pub fn from_components<H: Into<OklabHue<T>>>((hue, saturation, value): (H, T, T)) -> Self {
168        Self::new(hue, saturation, value)
169    }
170}
171
172/// Converts `lab` to `Okhsv` in the bounds of sRGB.
173///
174/// # See
175/// See [`srgb_to_okhsv`](https://bottosson.github.io/posts/colorpicker/#hsv-2).
176/// This implementation differs from srgb_to_okhsv in that it starts with the `lab`
177/// value and produces hues in degrees, whereas `srgb_to_okhsv` produces degree/360.
178impl<T> FromColorUnclamped<Oklab<T>> for Okhsv<T>
179where
180    T: Real
181        + MinMax
182        + Clone
183        + Powi
184        + Sqrt
185        + Cbrt
186        + Arithmetics
187        + Trigonometry
188        + Zero
189        + Hypot
190        + One
191        + IsValidDivisor<Mask = bool>
192        + HasBoolMask<Mask = bool>
193        + PartialOrd,
194    Oklab<T>: GetHue<Hue = OklabHue<T>> + IntoColorUnclamped<LinSrgb<T>>,
195{
196    fn from_color_unclamped(lab: Oklab<T>) -> Self {
197        if lab.l == T::zero() {
198            // the color is pure black
199            return Self::new(T::zero(), T::zero(), T::zero());
200        }
201
202        let chroma = lab.get_chroma();
203        let hue = lab.get_hue();
204        if chroma.is_valid_divisor() {
205            let (a_, b_) = (lab.a / &chroma, lab.b / &chroma);
206
207            // For each hue the sRGB gamut can be drawn on a 2-dimensional space.
208            // Let L_r, the lightness in relation to the possible luminance of sRGB, be spread
209            // along the y-axis (bottom is black, top is bright) and Chroma along the x-axis
210            // (left is desaturated, right is colorful). The gamut then takes a triangular shape,
211            // with a concave top side and a cusp to the right.
212            // To use saturation and brightness values, the gamut must be mapped to a square.
213            // The lower point of the triangle is expanded to the lower side of the square.
214            // The left side remains unchanged and the cusp of the triangle moves to the upper right.
215            let cusp = LC::find_cusp(a_.clone(), b_.clone());
216            let st_max = ST::<T>::from(cusp);
217
218            let s_0 = T::from_f64(0.5);
219            let k = T::one() - s_0.clone() / st_max.s;
220
221            // first we find L_v, C_v, L_vt and C_vt
222            let t = st_max.t.clone() / (chroma.clone() + lab.l.clone() * &st_max.t);
223            let l_v = t.clone() * &lab.l;
224            let c_v = t * chroma;
225
226            let l_vt = ok_utils::toe_inv(l_v.clone());
227            let c_vt = c_v.clone() * &l_vt / &l_v;
228
229            // we can then use these to invert the step that compensates for the toe and the curved top part of the triangle:
230            let rgb_scale: LinSrgb<T> =
231                Oklab::new(l_vt, a_ * &c_vt, b_ * c_vt).into_color_unclamped();
232            let lightness_scale_factor = T::cbrt(
233                T::one()
234                    / T::max(
235                        T::max(rgb_scale.red, rgb_scale.green),
236                        T::max(rgb_scale.blue, T::zero()),
237                    ),
238            );
239
240            //chroma = chroma / lightness_scale_factor;
241
242            // use L_r instead of L and also scale C by L_r/L
243            let l_r = ok_utils::toe(lab.l / lightness_scale_factor);
244            //chroma = chroma * l_r / (lab.l / lightness_scale_factor);
245
246            // we can now compute v and s:
247            let v = l_r / l_v;
248            let s =
249                (s_0.clone() + &st_max.t) * &c_v / ((st_max.t.clone() * s_0) + st_max.t * k * c_v);
250
251            Self::new(hue, s, v)
252        } else {
253            // the color is totally desaturated.
254            let v = ok_utils::toe(lab.l);
255            Self::new(T::zero(), T::zero(), v)
256        }
257    }
258}
259impl<T> FromColorUnclamped<Okhwb<T>> for Okhsv<T>
260where
261    T: One + Zero + IsValidDivisor + Arithmetics,
262    T::Mask: LazySelect<T>,
263{
264    fn from_color_unclamped(hwb: Okhwb<T>) -> Self {
265        let Okhwb {
266            hue,
267            whiteness,
268            blackness,
269        } = hwb;
270
271        let value = T::one() - blackness;
272
273        // avoid divide by zero
274        let saturation = lazy_select! {
275            if value.is_valid_divisor() => T::one() - (whiteness / &value),
276            else => T::zero(),
277        };
278
279        Self {
280            hue,
281            saturation,
282            value,
283        }
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use crate::{convert::FromColorUnclamped, Clamp, IsWithinBounds, LinSrgb, Okhsv, Oklab};
290
291    test_convert_into_from_xyz!(Okhsv);
292
293    #[cfg(feature = "approx")]
294    mod conversion {
295        use crate::{
296            convert::FromColorUnclamped, encoding, rgb::Rgb, visual::VisuallyEqual, LinSrgb, Okhsv,
297            Oklab, OklabHue, Srgb,
298        };
299
300        #[cfg_attr(miri, ignore)]
301        #[test]
302        fn test_roundtrip_okhsv_oklab_is_original() {
303            let colors = [
304                (
305                    "red",
306                    Oklab::from_color_unclamped(LinSrgb::new(1.0, 0.0, 0.0)),
307                ),
308                (
309                    "green",
310                    Oklab::from_color_unclamped(LinSrgb::new(0.0, 1.0, 0.0)),
311                ),
312                (
313                    "cyan",
314                    Oklab::from_color_unclamped(LinSrgb::new(0.0, 1.0, 1.0)),
315                ),
316                (
317                    "magenta",
318                    Oklab::from_color_unclamped(LinSrgb::new(1.0, 0.0, 1.0)),
319                ),
320                (
321                    "white",
322                    Oklab::from_color_unclamped(LinSrgb::new(1.0, 1.0, 1.0)),
323                ),
324                (
325                    "black",
326                    Oklab::from_color_unclamped(LinSrgb::new(0.0, 0.0, 0.0)),
327                ),
328                (
329                    "grey",
330                    Oklab::from_color_unclamped(LinSrgb::new(0.5, 0.5, 0.5)),
331                ),
332                (
333                    "yellow",
334                    Oklab::from_color_unclamped(LinSrgb::new(1.0, 1.0, 0.0)),
335                ),
336                (
337                    "blue",
338                    Oklab::from_color_unclamped(LinSrgb::new(0.0, 0.0, 1.0)),
339                ),
340            ];
341
342            // unlike in okhwb we are using f64 here, which actually works.
343            // So we can afford a small tolerance
344            const EPSILON: f64 = 1e-10;
345
346            for (name, color) in colors {
347                let rgb: Rgb<encoding::Srgb, u8> =
348                    crate::Srgb::<f64>::from_color_unclamped(color).into_format();
349                println!(
350                    "\n\
351                    roundtrip of {name} (#{rgb:x} / {color:?})\n\
352                    ================================================="
353                );
354
355                let okhsv = Okhsv::from_color_unclamped(color);
356                println!("Okhsv: {okhsv:?}");
357                let roundtrip_color = Oklab::from_color_unclamped(okhsv);
358                assert!(
359                    Oklab::visually_eq(roundtrip_color, color, EPSILON),
360                    "'{name}' failed.\n{roundtrip_color:?}\n!=\n{color:?}"
361                );
362            }
363        }
364
365        /// Compares results to results for a run of
366        /// https://github.com/bottosson/bottosson.github.io/blob/3d3f17644d7f346e1ce1ca08eb8b01782eea97af/misc/ok_color.h
367        /// Not to the ideal values, which should be
368        /// hue: as is
369        /// saturation: 1.0
370        /// value: 1.0
371        #[test]
372        fn blue() {
373            let lin_srgb_blue = LinSrgb::new(0.0, 0.0, 1.0);
374            let oklab_blue_64 = Oklab::<f64>::from_color_unclamped(lin_srgb_blue);
375            let okhsv_blue_64 = Okhsv::from_color_unclamped(oklab_blue_64);
376
377            println!("Okhsv f64: {okhsv_blue_64:?}\n");
378            // HSV values of the reference implementation (in C)
379            // 1 iteration : 264.0520206380550121, 0.9999910912349018, 0.9999999646150918
380            // 2 iterations: 264.0520206380550121, 0.9999999869716002, 0.9999999646150844
381            // 3 iterations: 264.0520206380550121, 0.9999999869716024, 0.9999999646150842
382            #[allow(clippy::excessive_precision)]
383            let expected_hue = OklabHue::new(264.0520206380550121);
384            let expected_saturation = 0.9999910912349018;
385            let expected_value = 0.9999999646150918;
386
387            // compare to the reference implementation values
388            assert_abs_diff_eq!(okhsv_blue_64.hue, expected_hue, epsilon = 1e-12);
389            assert_abs_diff_eq!(
390                okhsv_blue_64.saturation,
391                expected_saturation,
392                epsilon = 1e-12
393            );
394            assert_abs_diff_eq!(okhsv_blue_64.value, expected_value, epsilon = 1e-12);
395        }
396
397        #[test]
398        fn test_srgb_to_okhsv() {
399            let red_hex = "#ff0004";
400            let rgb: Srgb = red_hex.parse().unwrap();
401            let okhsv = Okhsv::from_color_unclamped(rgb);
402            assert_relative_eq!(okhsv.saturation, 1.0, epsilon = 1e-3);
403            assert_relative_eq!(okhsv.value, 1.0, epsilon = 1e-3);
404            assert_relative_eq!(
405                okhsv.hue.into_raw_degrees(),
406                29.0,
407                epsilon = 1e-3,
408                max_relative = 1e-3
409            );
410        }
411
412        #[test]
413        fn test_okhsv_to_srgb() {
414            let okhsv = Okhsv::new(0.0_f32, 0.5, 0.5);
415            let rgb = Srgb::from_color_unclamped(okhsv);
416            let rgb8: Rgb<encoding::Srgb, u8> = rgb.into_format();
417            let hex_str = format!("{rgb8:x}");
418            assert_eq!(hex_str, "7a4355");
419        }
420
421        #[test]
422        fn test_okhsv_to_srgb_saturated_black() {
423            let okhsv = Okhsv::new(0.0_f32, 1.0, 0.0);
424            let rgb = Srgb::from_color_unclamped(okhsv);
425            assert_relative_eq!(rgb, Srgb::new(0.0, 0.0, 0.0));
426        }
427
428        #[test]
429        fn black_eq_different_black() {
430            assert!(Okhsv::visually_eq(
431                Okhsv::from_color_unclamped(Oklab::new(0.0, 1.0, 0.0)),
432                Okhsv::from_color_unclamped(Oklab::new(0.0, 0.0, 1.0)),
433                1e-12
434            ));
435        }
436    }
437
438    #[cfg(feature = "approx")]
439    mod visual_eq {
440        use crate::{visual::VisuallyEqual, Okhsv};
441
442        #[test]
443        fn white_eq_different_white() {
444            assert!(Okhsv::visually_eq(
445                Okhsv::new(240.0, 0.0, 1.0),
446                Okhsv::new(24.0, 0.0, 1.0),
447                1e-12
448            ));
449        }
450
451        #[test]
452        fn white_ne_grey_or_black() {
453            assert!(!Okhsv::visually_eq(
454                Okhsv::new(0.0, 0.0, 0.0),
455                Okhsv::new(0.0, 0.0, 1.0),
456                1e-12
457            ));
458            assert!(!Okhsv::visually_eq(
459                Okhsv::new(0.0, 0.0, 0.3),
460                Okhsv::new(0.0, 0.0, 1.0),
461                1e-12
462            ));
463        }
464
465        #[test]
466        fn color_neq_different_color() {
467            assert!(!Okhsv::visually_eq(
468                Okhsv::new(10.0, 0.01, 0.5),
469                Okhsv::new(11.0, 0.01, 0.5),
470                1e-12
471            ));
472            assert!(!Okhsv::visually_eq(
473                Okhsv::new(10.0, 0.01, 0.5),
474                Okhsv::new(10.0, 0.02, 0.5),
475                1e-12
476            ));
477            assert!(!Okhsv::visually_eq(
478                Okhsv::new(10.0, 0.01, 0.5),
479                Okhsv::new(10.0, 0.01, 0.6),
480                1e-12
481            ));
482        }
483
484        #[test]
485        fn grey_vs_grey() {
486            // greys of different lightness are not equal
487            assert!(!Okhsv::visually_eq(
488                Okhsv::new(0.0, 0.0, 0.3),
489                Okhsv::new(0.0, 0.0, 0.4),
490                1e-12
491            ));
492            // greys of same lightness but different hue are equal
493            assert!(Okhsv::visually_eq(
494                Okhsv::new(0.0, 0.0, 0.3),
495                Okhsv::new(12.0, 0.0, 0.3),
496                1e-12
497            ));
498        }
499    }
500
501    #[test]
502    fn srgb_gamut_containment() {
503        {
504            println!("sRGB Red");
505            let oklab = Oklab::from_color_unclamped(LinSrgb::new(1.0, 0.0, 0.0));
506            println!("{oklab:?}");
507            let okhsv: Okhsv<f64> = Okhsv::from_color_unclamped(oklab);
508            println!("{okhsv:?}");
509            assert!(okhsv.is_within_bounds());
510        }
511
512        {
513            println!("Double sRGB Red");
514            let oklab = Oklab::from_color_unclamped(LinSrgb::new(2.0, 0.0, 0.0));
515            println!("{oklab:?}");
516            let okhsv: Okhsv<f64> = Okhsv::from_color_unclamped(oklab);
517            println!("{okhsv:?}");
518            assert!(!okhsv.is_within_bounds());
519            let clamped_okhsv = okhsv.clamp();
520            println!("Clamped: {clamped_okhsv:?}");
521            assert!(clamped_okhsv.is_within_bounds());
522            let linsrgb = LinSrgb::from_color_unclamped(clamped_okhsv);
523            println!("Clamped as unclamped Linear sRGB: {linsrgb:?}");
524        }
525
526        {
527            println!("P3 Yellow");
528            // display P3 yellow according to https://colorjs.io/apps/convert/?color=color(display-p3%201%201%200)&precision=17
529            let oklab = Oklab::from_color_unclamped(LinSrgb::new(1.0, 1.0, -0.098273600140966));
530            println!("{oklab:?}");
531            let okhsv: Okhsv<f64> = Okhsv::from_color_unclamped(oklab);
532            println!("{okhsv:?}");
533            assert!(!okhsv.is_within_bounds());
534            let clamped_okhsv = okhsv.clamp();
535            println!("Clamped: {clamped_okhsv:?}");
536            assert!(clamped_okhsv.is_within_bounds());
537            let linsrgb = LinSrgb::from_color_unclamped(clamped_okhsv);
538            println!(
539                "Clamped as unclamped Linear sRGB: {linsrgb:?}\n\
540                May be different, but should be visually indistinguishable from\n\
541                color.js' gamut mapping red: 1 green: 0.9876530763223166 blue: 0"
542            );
543        }
544    }
545
546    struct_of_arrays_tests!(
547        Okhsv[hue, saturation, value],
548        super::Okhsva::new(0.1f32, 0.2, 0.3, 0.4),
549        super::Okhsva::new(0.2, 0.3, 0.4, 0.5),
550        super::Okhsva::new(0.3, 0.4, 0.5, 0.6)
551    );
552}