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 core::str::FromStr;
296
297        use crate::{
298            convert::FromColorUnclamped, encoding, rgb::Rgb, visual::VisuallyEqual, LinSrgb, Okhsv,
299            Oklab, OklabHue, Srgb,
300        };
301
302        #[cfg_attr(miri, ignore)]
303        #[test]
304        fn test_roundtrip_okhsv_oklab_is_original() {
305            let colors = [
306                (
307                    "red",
308                    Oklab::from_color_unclamped(LinSrgb::new(1.0, 0.0, 0.0)),
309                ),
310                (
311                    "green",
312                    Oklab::from_color_unclamped(LinSrgb::new(0.0, 1.0, 0.0)),
313                ),
314                (
315                    "cyan",
316                    Oklab::from_color_unclamped(LinSrgb::new(0.0, 1.0, 1.0)),
317                ),
318                (
319                    "magenta",
320                    Oklab::from_color_unclamped(LinSrgb::new(1.0, 0.0, 1.0)),
321                ),
322                (
323                    "white",
324                    Oklab::from_color_unclamped(LinSrgb::new(1.0, 1.0, 1.0)),
325                ),
326                (
327                    "black",
328                    Oklab::from_color_unclamped(LinSrgb::new(0.0, 0.0, 0.0)),
329                ),
330                (
331                    "grey",
332                    Oklab::from_color_unclamped(LinSrgb::new(0.5, 0.5, 0.5)),
333                ),
334                (
335                    "yellow",
336                    Oklab::from_color_unclamped(LinSrgb::new(1.0, 1.0, 0.0)),
337                ),
338                (
339                    "blue",
340                    Oklab::from_color_unclamped(LinSrgb::new(0.0, 0.0, 1.0)),
341                ),
342            ];
343
344            // unlike in okhwb we are using f64 here, which actually works.
345            // So we can afford a small tolerance
346            const EPSILON: f64 = 1e-10;
347
348            for (name, color) in colors {
349                let rgb: Rgb<encoding::Srgb, u8> =
350                    crate::Srgb::<f64>::from_color_unclamped(color).into_format();
351                println!(
352                    "\n\
353                    roundtrip of {} (#{:x} / {:?})\n\
354                    =================================================",
355                    name, rgb, color
356                );
357
358                let okhsv = Okhsv::from_color_unclamped(color);
359                println!("Okhsv: {:?}", okhsv);
360                let roundtrip_color = Oklab::from_color_unclamped(okhsv);
361                assert!(
362                    Oklab::visually_eq(roundtrip_color, color, EPSILON),
363                    "'{}' failed.\n{:?}\n!=\n{:?}",
364                    name,
365                    roundtrip_color,
366                    color
367                );
368            }
369        }
370
371        /// Compares results to results for a run of
372        /// https://github.com/bottosson/bottosson.github.io/blob/3d3f17644d7f346e1ce1ca08eb8b01782eea97af/misc/ok_color.h
373        /// Not to the ideal values, which should be
374        /// hue: as is
375        /// saturation: 1.0
376        /// value: 1.0
377        #[test]
378        fn blue() {
379            let lin_srgb_blue = LinSrgb::new(0.0, 0.0, 1.0);
380            let oklab_blue_64 = Oklab::<f64>::from_color_unclamped(lin_srgb_blue);
381            let okhsv_blue_64 = Okhsv::from_color_unclamped(oklab_blue_64);
382
383            println!("Okhsv f64: {:?}\n", okhsv_blue_64);
384            // HSV values of the reference implementation (in C)
385            // 1 iteration : 264.0520206380550121, 0.9999910912349018, 0.9999999646150918
386            // 2 iterations: 264.0520206380550121, 0.9999999869716002, 0.9999999646150844
387            // 3 iterations: 264.0520206380550121, 0.9999999869716024, 0.9999999646150842
388            #[allow(clippy::excessive_precision)]
389            let expected_hue = OklabHue::new(264.0520206380550121);
390            let expected_saturation = 0.9999910912349018;
391            let expected_value = 0.9999999646150918;
392
393            // compare to the reference implementation values
394            assert_abs_diff_eq!(okhsv_blue_64.hue, expected_hue, epsilon = 1e-12);
395            assert_abs_diff_eq!(
396                okhsv_blue_64.saturation,
397                expected_saturation,
398                epsilon = 1e-12
399            );
400            assert_abs_diff_eq!(okhsv_blue_64.value, expected_value, epsilon = 1e-12);
401        }
402
403        #[test]
404        fn test_srgb_to_okhsv() {
405            let red_hex = "#ff0004";
406            let rgb: Srgb = Rgb::<encoding::Srgb, _>::from_str(red_hex)
407                .unwrap()
408                .into_format();
409            let okhsv = Okhsv::from_color_unclamped(rgb);
410            assert_relative_eq!(okhsv.saturation, 1.0, epsilon = 1e-3);
411            assert_relative_eq!(okhsv.value, 1.0, epsilon = 1e-3);
412            assert_relative_eq!(
413                okhsv.hue.into_raw_degrees(),
414                29.0,
415                epsilon = 1e-3,
416                max_relative = 1e-3
417            );
418        }
419
420        #[test]
421        fn test_okhsv_to_srgb() {
422            let okhsv = Okhsv::new(0.0_f32, 0.5, 0.5);
423            let rgb = Srgb::from_color_unclamped(okhsv);
424            let rgb8: Rgb<encoding::Srgb, u8> = rgb.into_format();
425            let hex_str = format!("{:x}", rgb8);
426            assert_eq!(hex_str, "7a4355");
427        }
428
429        #[test]
430        fn test_okhsv_to_srgb_saturated_black() {
431            let okhsv = Okhsv::new(0.0_f32, 1.0, 0.0);
432            let rgb = Srgb::from_color_unclamped(okhsv);
433            assert_relative_eq!(rgb, Srgb::new(0.0, 0.0, 0.0));
434        }
435
436        #[test]
437        fn black_eq_different_black() {
438            assert!(Okhsv::visually_eq(
439                Okhsv::from_color_unclamped(Oklab::new(0.0, 1.0, 0.0)),
440                Okhsv::from_color_unclamped(Oklab::new(0.0, 0.0, 1.0)),
441                1e-12
442            ));
443        }
444    }
445
446    #[cfg(feature = "approx")]
447    mod visual_eq {
448        use crate::{visual::VisuallyEqual, Okhsv};
449
450        #[test]
451        fn white_eq_different_white() {
452            assert!(Okhsv::visually_eq(
453                Okhsv::new(240.0, 0.0, 1.0),
454                Okhsv::new(24.0, 0.0, 1.0),
455                1e-12
456            ));
457        }
458
459        #[test]
460        fn white_ne_grey_or_black() {
461            assert!(!Okhsv::visually_eq(
462                Okhsv::new(0.0, 0.0, 0.0),
463                Okhsv::new(0.0, 0.0, 1.0),
464                1e-12
465            ));
466            assert!(!Okhsv::visually_eq(
467                Okhsv::new(0.0, 0.0, 0.3),
468                Okhsv::new(0.0, 0.0, 1.0),
469                1e-12
470            ));
471        }
472
473        #[test]
474        fn color_neq_different_color() {
475            assert!(!Okhsv::visually_eq(
476                Okhsv::new(10.0, 0.01, 0.5),
477                Okhsv::new(11.0, 0.01, 0.5),
478                1e-12
479            ));
480            assert!(!Okhsv::visually_eq(
481                Okhsv::new(10.0, 0.01, 0.5),
482                Okhsv::new(10.0, 0.02, 0.5),
483                1e-12
484            ));
485            assert!(!Okhsv::visually_eq(
486                Okhsv::new(10.0, 0.01, 0.5),
487                Okhsv::new(10.0, 0.01, 0.6),
488                1e-12
489            ));
490        }
491
492        #[test]
493        fn grey_vs_grey() {
494            // greys of different lightness are not equal
495            assert!(!Okhsv::visually_eq(
496                Okhsv::new(0.0, 0.0, 0.3),
497                Okhsv::new(0.0, 0.0, 0.4),
498                1e-12
499            ));
500            // greys of same lightness but different hue are equal
501            assert!(Okhsv::visually_eq(
502                Okhsv::new(0.0, 0.0, 0.3),
503                Okhsv::new(12.0, 0.0, 0.3),
504                1e-12
505            ));
506        }
507    }
508
509    #[test]
510    fn srgb_gamut_containment() {
511        {
512            println!("sRGB Red");
513            let oklab = Oklab::from_color_unclamped(LinSrgb::new(1.0, 0.0, 0.0));
514            println!("{:?}", oklab);
515            let okhsv: Okhsv<f64> = Okhsv::from_color_unclamped(oklab);
516            println!("{:?}", okhsv);
517            assert!(okhsv.is_within_bounds());
518        }
519
520        {
521            println!("Double sRGB Red");
522            let oklab = Oklab::from_color_unclamped(LinSrgb::new(2.0, 0.0, 0.0));
523            println!("{:?}", oklab);
524            let okhsv: Okhsv<f64> = Okhsv::from_color_unclamped(oklab);
525            println!("{:?}", okhsv);
526            assert!(!okhsv.is_within_bounds());
527            let clamped_okhsv = okhsv.clamp();
528            println!("Clamped: {:?}", clamped_okhsv);
529            assert!(clamped_okhsv.is_within_bounds());
530            let linsrgb = LinSrgb::from_color_unclamped(clamped_okhsv);
531            println!("Clamped as unclamped Linear sRGB: {:?}", linsrgb);
532        }
533
534        {
535            println!("P3 Yellow");
536            // display P3 yellow according to https://colorjs.io/apps/convert/?color=color(display-p3%201%201%200)&precision=17
537            let oklab = Oklab::from_color_unclamped(LinSrgb::new(1.0, 1.0, -0.098273600140966));
538            println!("{:?}", oklab);
539            let okhsv: Okhsv<f64> = Okhsv::from_color_unclamped(oklab);
540            println!("{:?}", okhsv);
541            assert!(!okhsv.is_within_bounds());
542            let clamped_okhsv = okhsv.clamp();
543            println!("Clamped: {:?}", clamped_okhsv);
544            assert!(clamped_okhsv.is_within_bounds());
545            let linsrgb = LinSrgb::from_color_unclamped(clamped_okhsv);
546            println!(
547                "Clamped as unclamped Linear sRGB: {:?}\n\
548                May be different, but should be visually indistinguishable from\n\
549                color.js' gamut mapping red: 1 green: 0.9876530763223166 blue: 0",
550                linsrgb
551            );
552        }
553    }
554
555    struct_of_arrays_tests!(
556        Okhsv[hue, saturation, value],
557        super::Okhsva::new(0.1f32, 0.2, 0.3, 0.4),
558        super::Okhsva::new(0.2, 0.3, 0.4, 0.5),
559        super::Okhsva::new(0.3, 0.4, 0.5, 0.6)
560    );
561}