palette/
ok_utils.rs

1//! Traits and functions used in Ok* color spaces
2
3#[cfg(feature = "approx")]
4#[cfg(test)]
5use crate::{angle::RealAngle, num::Trigonometry, OklabHue};
6
7use crate::{
8    convert::IntoColorUnclamped,
9    num::{Arithmetics, Cbrt, MinMax, One, Powi, Real, Sqrt, Zero},
10    HasBoolMask, LinSrgb, Oklab,
11};
12
13/// Finds intersection of the line defined by
14///
15/// L = l0 * (1 - t) + t * l1;
16///
17/// C = t * c1;
18///
19/// a and b must be normalized so a² + b² == 1
20fn find_gamut_intersection<T>(a: T, b: T, l1: T, c1: T, l0: T, cusp: LC<T>) -> T
21where
22    T: Real + One + Zero + Arithmetics + MinMax + HasBoolMask<Mask = bool> + PartialOrd + Clone,
23{
24    // Find the intersection for upper and lower half separately
25    if ((l1.clone() - &l0) * &cusp.chroma - (cusp.lightness.clone() - &l0) * &c1) <= T::zero() {
26        // Lower half
27
28        cusp.chroma.clone() * &l0 / (c1 * cusp.lightness + cusp.chroma * (l0 - l1))
29    } else {
30        // Upper half
31
32        // First intersect with triangle
33        let t = cusp.chroma.clone() * (l0.clone() - T::one())
34            / (c1.clone() * (cusp.lightness - T::one()) + cusp.chroma * (l0.clone() - &l1));
35
36        // Then one step Halley's method
37        {
38            let dl = l1.clone() - &l0;
39            let dc = c1.clone();
40
41            let k_l = T::from_f64(0.3963377774) * &a + T::from_f64(0.2158037573) * &b;
42            let k_m = -T::from_f64(0.1055613458) * &a - T::from_f64(0.0638541728) * &b;
43            let k_s = -T::from_f64(0.0894841775) * a - T::from_f64(1.2914855480) * b;
44
45            let l_dt = dl.clone() + dc.clone() * &k_l;
46            let m_dt = dl.clone() + dc.clone() * &k_m;
47            let s_dt = dl + dc * &k_s;
48
49            // If higher accuracy is required, 2 or 3 iterations of the following block can be used:
50            {
51                let lightness = l0 * (T::one() - &t) + t.clone() * l1;
52                let chroma = t.clone() * c1;
53
54                let l_ = lightness.clone() + chroma.clone() * k_l;
55                let m_ = lightness.clone() + chroma.clone() * k_m;
56                let s_ = lightness + chroma * k_s;
57
58                let l = l_.clone() * &l_ * &l_;
59                let m = m_.clone() * &m_ * &m_;
60                let s = s_.clone() * &s_ * &s_;
61
62                let ldt = T::from_f64(3.0) * &l_dt * &l_ * &l_;
63                let mdt = T::from_f64(3.0) * &m_dt * &m_ * &m_;
64                let sdt = T::from_f64(3.0) * &s_dt * &s_ * &s_;
65
66                let ldt2 = T::from_f64(6.0) * &l_dt * l_dt * l_;
67                let mdt2 = T::from_f64(6.0) * &m_dt * m_dt * m_;
68                let sdt2 = T::from_f64(6.0) * &s_dt * s_dt * s_;
69
70                let r = T::from_f64(4.0767416621) * &l - T::from_f64(3.3077115913) * &m
71                    + T::from_f64(0.2309699292) * &s
72                    - T::one();
73                let r1 = T::from_f64(4.0767416621) * &ldt - T::from_f64(3.3077115913) * &mdt
74                    + T::from_f64(0.2309699292) * &sdt;
75                let r2 = T::from_f64(4.0767416621) * &ldt2 - T::from_f64(3.3077115913) * &mdt2
76                    + T::from_f64(0.2309699292) * &sdt2;
77
78                let u_r = r1.clone() / (r1.clone() * r1 - T::from_f64(0.5) * &r * r2);
79                let mut t_r = -r * &u_r;
80
81                let g = -T::from_f64(1.2684380046) * &l + T::from_f64(2.6097574011) * &m
82                    - T::from_f64(0.3413193965) * &s
83                    - T::one();
84                let g1 = -T::from_f64(1.2684380046) * &ldt + T::from_f64(2.6097574011) * &mdt
85                    - T::from_f64(0.3413193965) * &sdt;
86                let g2 = -T::from_f64(1.2684380046) * &ldt2 + T::from_f64(2.6097574011) * &mdt2
87                    - T::from_f64(0.3413193965) * &sdt2;
88
89                let u_g = g1.clone() / (g1.clone() * g1 - T::from_f64(0.5) * &g * g2);
90                let mut t_g = -g * &u_g;
91
92                let b = -T::from_f64(0.0041960863) * l - T::from_f64(0.7034186147) * m
93                    + T::from_f64(1.7076147010) * s
94                    - T::one();
95                let b1 = -T::from_f64(0.0041960863) * ldt - T::from_f64(0.7034186147) * mdt
96                    + T::from_f64(1.7076147010) * sdt;
97                let b2 = -T::from_f64(0.0041960863) * ldt2 - T::from_f64(0.7034186147) * mdt2
98                    + T::from_f64(1.7076147010) * sdt2;
99
100                let u_b = b1.clone() / (b1.clone() * b1 - T::from_f64(0.5) * &b * b2);
101                let mut t_b = -b * &u_b;
102
103                // flt_max really is a constant, but cannot be defined as one due to the T::from_f64 function
104                let flt_max = T::from_f64(10e5);
105
106                t_r = if u_r >= T::zero() {
107                    t_r
108                } else {
109                    flt_max.clone()
110                };
111                t_g = if u_g >= T::zero() {
112                    t_g
113                } else {
114                    flt_max.clone()
115                };
116                t_b = if u_b >= T::zero() { t_b } else { flt_max };
117
118                t + T::min(t_r, T::min(t_g, t_b))
119            }
120        }
121    }
122}
123
124pub struct ChromaValues<T> {
125    pub zero: T,
126    pub mid: T,
127    pub max: T,
128}
129
130impl<T> ChromaValues<T>
131where
132    T: Real
133        + One
134        + Zero
135        + Arithmetics
136        + MinMax
137        + Cbrt
138        + Sqrt
139        + Powi
140        + Clone
141        + HasBoolMask<Mask = bool>
142        + PartialOrd,
143    Oklab<T>: IntoColorUnclamped<LinSrgb<T>>,
144{
145    // Corresponds to `get_Cs` in the reference implementation. Assumes that
146    // `lightness != 1.0` and `lightness != 0.0`.
147    pub fn from_normalized(lightness: T, a_: T, b_: T) -> Self {
148        let cusp = LC::find_cusp(a_.clone(), b_.clone());
149
150        let max_chroma = find_gamut_intersection(
151            a_.clone(),
152            b_.clone(),
153            lightness.clone(),
154            T::one(),
155            lightness.clone(),
156            cusp.clone(),
157        );
158        let st_max = ST::from(cusp);
159
160        // Scale factor to compensate for the curved part of gamut shape:
161        let k = max_chroma.clone()
162            / T::min(
163                lightness.clone() * st_max.s,
164                (T::one() - &lightness) * st_max.t,
165            );
166
167        let c_mid = {
168            let st_mid = ST::mid(a_, b_);
169
170            // Use a soft minimum function, instead of a sharp triangle shape to get a smooth value for chroma.
171            let c_a = lightness.clone() * st_mid.s;
172            let c_b = (T::one() - &lightness) * st_mid.t;
173            T::from_f64(0.9)
174                * k
175                * T::sqrt(T::sqrt(
176                    T::one()
177                        / (T::one() / (c_a.clone() * &c_a * &c_a * &c_a)
178                            + T::one() / (c_b.clone() * &c_b * &c_b * &c_b)),
179                ))
180        };
181
182        let c_0 = {
183            // for C_0, the shape is independent of hue, so ST are constant.
184            // Values picked to roughly be the average values of ST.
185            let c_a = lightness.clone() * T::from_f64(0.4);
186            let c_b = (T::one() - lightness) * T::from_f64(0.8);
187
188            // Use a soft minimum function, instead of a sharp triangle shape to get a smooth value for chroma.
189            T::sqrt(T::one() / (T::one() / (c_a.clone() * c_a) + T::one() / (c_b.clone() * c_b)))
190        };
191        Self {
192            zero: c_0,
193            mid: c_mid,
194            max: max_chroma,
195        }
196    }
197}
198
199/// A `lightness`-`chroma` representation of a point in the `sRGB` gamut for a fixed hue.
200///
201/// Gamut is the range of representable colors of a color space. In this case the
202/// `sRGB` color space.
203///
204/// Only together are `lightness` and `chroma` guaranteed to be inside the `sRGB` gamut.
205/// While a color with lower `chroma` will  always stay in the gamut, a color of raised
206/// *and lowered* lightness might move the point outside the gamut.
207///
208///# See
209/// [LC diagram samples](https://bottosson.github.io/posts/gamutclipping/#gamut-clipping)
210#[derive(Debug, Copy, Clone)]
211pub(crate) struct LC<T> {
212    /// The lightness of the color. 0 corresponds to black. 1 corresponds to white
213    pub lightness: T,
214    /// The chroma of the color. 0 corresponds to totally desaturated (white, grey or black).
215    /// Larger values correspond to colorful values.
216    ///
217    ///Note: the maximum representable value depends on the lightness and the hue.
218    pub chroma: T,
219}
220
221/// The number of iterations used for optimizing the result of [`LC::max_saturation`].
222///
223/// Must match [`MAX_SRGB_SATURATION_INACCURACY`]
224pub(crate) const MAX_SRGB_SATURATION_SEARCH_MAX_ITER: usize = 1;
225/// The expected inaccuracy of the result of [`LC::max_saturation`], optimized with
226/// [`MAX_SRGB_SATURATION_SEARCH_MAX_ITER`] iterations
227pub(crate) const MAX_SRGB_SATURATION_INACCURACY: f64 = 1e-6;
228
229impl<T> LC<T>
230where
231    T: Real + One + Arithmetics + Powi + HasBoolMask<Mask = bool> + PartialOrd + Clone,
232{
233    /// Returns the cusp of the geometrical shape of representable `sRGB` colors for
234    /// normalized `a` and `b` values of an `OKlabHue`, where "normalized" means, `a² + b² == 1`.
235    ///
236    /// The cusp solely depends on the maximum saturation of the hue, but is expressed as a
237    /// combination of lightness and chroma.
238    pub fn find_cusp(a: T, b: T) -> Self
239    where
240        T: MinMax + Cbrt,
241        Oklab<T>: IntoColorUnclamped<LinSrgb<T>>,
242    {
243        // First, find the maximum saturation (saturation S = C/L)
244        let max_saturation = Self::max_saturation(a.clone(), b.clone());
245        // Convert to linear sRGB to find the first point where at least one of r,g or b >= 1:
246        let rgb_at_max: LinSrgb<T> = Oklab::new(
247            T::one(),
248            max_saturation.clone() * a,
249            max_saturation.clone() * b,
250        )
251        .into_color_unclamped();
252
253        let max_lightness =
254            T::cbrt(T::one() / T::max(T::max(rgb_at_max.red, rgb_at_max.green), rgb_at_max.blue));
255        Self {
256            lightness: max_lightness.clone(),
257            chroma: max_lightness * max_saturation,
258        }
259    }
260
261    /// Returns the maximum `sRGB`-saturation (chroma / lightness) for the hue (`a` and `b`).
262    ///
263    /// # Arguments
264    /// * `a` - the green/redness of the hue
265    /// * `b` -  the blue/yellowness of the hue
266    ///
267    ///  `a` and `b` must be normalized to a chroma (`a²+b²`) of `1`.
268    /// # See
269    /// [Original C-Version](https://bottosson.github.io/posts/gamutclipping/#intersection-with-srgb-gamut)
270    fn max_saturation(a: T, b: T) -> T {
271        // Max saturation will be reached, when one of r, g or b goes below zero.
272        // Select different coefficients depending on which component goes below zero first
273        // wl, wm and ws are coefficients for https://en.wikipedia.org/wiki/LMS_color_space
274        // -- the color space modelling human perception.
275        let (k0, k1, k2, k3, k4, wl, wm, ws) =
276            if T::from_f64(-1.88170328) * &a - T::from_f64(0.80936493) * &b > T::one() {
277                // red component at zero first
278                (
279                    T::from_f64(1.19086277),
280                    T::from_f64(1.76576728),
281                    T::from_f64(0.59662641),
282                    T::from_f64(0.75515197),
283                    T::from_f64(0.56771245),
284                    T::from_f64(4.0767416621),
285                    T::from_f64(-3.3077115913),
286                    T::from_f64(0.2309699292),
287                )
288            } else if T::from_f64(1.81444104) * &a - T::from_f64(1.19445276) * &b > T::one() {
289                //green component at zero first
290                (
291                    T::from_f64(0.73956515),
292                    T::from_f64(-0.45954404),
293                    T::from_f64(0.08285427),
294                    T::from_f64(0.12541070),
295                    T::from_f64(0.14503204),
296                    T::from_f64(-1.2684380046),
297                    T::from_f64(2.6097574011),
298                    T::from_f64(-0.3413193965),
299                )
300            } else {
301                //blue component at zero first
302                (
303                    T::from_f64(1.35733652),
304                    T::from_f64(-0.00915799),
305                    T::from_f64(-1.15130210),
306                    T::from_f64(-0.50559606),
307                    T::from_f64(0.00692167),
308                    T::from_f64(-0.0041960863),
309                    T::from_f64(-0.7034186147),
310                    T::from_f64(1.7076147010),
311                )
312            };
313
314        // Approximate max saturation using a polynomial
315        let mut approx_max_saturation =
316            k0 + k1 * &a + k2 * &b + k3 * a.clone().powi(2) + k4 * &a * &b;
317        // Get closer with Halley's method
318        let k_l = T::from_f64(0.3963377774) * &a + T::from_f64(0.2158037573) * &b;
319        let k_m = T::from_f64(-0.1055613458) * &a - T::from_f64(0.0638541728) * &b;
320        let k_s = T::from_f64(-0.0894841775) * a - T::from_f64(1.2914855480) * b;
321
322        for _i in 0..MAX_SRGB_SATURATION_SEARCH_MAX_ITER {
323            let l_ = T::one() + approx_max_saturation.clone() * &k_l;
324            let m_ = T::one() + approx_max_saturation.clone() * &k_m;
325            let s_ = T::one() + approx_max_saturation.clone() * &k_s;
326
327            let l = l_.clone().powi(3);
328            let m = m_.clone().powi(3);
329            let s = s_.clone().powi(3);
330
331            // first derivative components
332            let l_ds = T::from_f64(3.0) * &k_l * l_.clone().powi(2);
333            let m_ds = T::from_f64(3.0) * &k_m * m_.clone().powi(2);
334            let s_ds = T::from_f64(3.0) * &k_s * s_.clone().powi(2);
335
336            // second derivative components
337            let l_ds2 = T::from_f64(6.0) * k_l.clone().powi(2) * l_;
338            let m_ds2 = T::from_f64(6.0) * k_m.clone().powi(2) * m_;
339            let s_ds2 = T::from_f64(6.0) * k_s.clone().powi(2) * s_;
340
341            // let x be the approximate maximum saturation and
342            // i the current iteration
343            // f = f(x_i), f1 = f'(x_i), f2 = f''(x_i) for
344            let f = wl.clone() * l + wm.clone() * m + ws.clone() * s;
345            let f1 = wl.clone() * l_ds + wm.clone() * m_ds + ws.clone() * s_ds;
346            let f2 = wl.clone() * l_ds2 + wm.clone() * m_ds2 + ws.clone() * s_ds2;
347
348            approx_max_saturation =
349                approx_max_saturation - f.clone() * &f1 / (f1.powi(2) - T::from_f64(0.5) * f * f2);
350        }
351        approx_max_saturation
352    }
353}
354
355#[cfg(feature = "approx")]
356#[cfg(test)]
357impl<T> OklabHue<T>
358where
359    T: RealAngle
360        + One
361        + Arithmetics
362        + Trigonometry
363        + MinMax
364        + Cbrt
365        + Powi
366        + HasBoolMask<Mask = bool>
367        + PartialOrd
368        + Clone,
369    Oklab<T>: IntoColorUnclamped<LinSrgb<T>>,
370{
371    pub(crate) fn srgb_limits(self) -> (LC<T>, T, T) {
372        let normalized_hue_vector = self.into_cartesian();
373        let lc = LC::find_cusp(
374            normalized_hue_vector.0.clone(),
375            normalized_hue_vector.1.clone(),
376        );
377        let a = lc.chroma.clone() * normalized_hue_vector.0;
378        let b = lc.chroma.clone() * normalized_hue_vector.1;
379        (lc, a, b)
380    }
381}
382
383/// A representation of [`LC`], that allows computing the maximum chroma `C`
384/// for a given lightness `L` in the gamut triangle of a hue as
385/// ```text
386/// C
387///   = min(S*L, T*(1-L))
388///   = min(lc.chroma / lc.lightness * L, lc.chroma / (T::one() - lc.lightness) * (1-L))
389/// ```
390#[derive(Debug, Copy, Clone)]
391pub(crate) struct ST<T> {
392    /// `lc.chroma / lc.lightness`
393    pub s: T,
394    /// `lc.chroma / (T::one() - lc.lightness)`
395    pub t: T,
396}
397
398impl<T> From<LC<T>> for ST<T>
399where
400    T: Arithmetics + One + Clone,
401{
402    fn from(lc: LC<T>) -> Self {
403        ST {
404            s: lc.chroma.clone() / &lc.lightness,
405            t: lc.chroma / (T::one() - lc.lightness),
406        }
407    }
408}
409
410impl<T> ST<T>
411where
412    T: Real + Arithmetics + One + Clone,
413{
414    /// Returns a smooth approximation of the location of the cusp.
415    ///
416    /// This polynomial was created by an optimization process.
417    /// It has been designed so that
418    ///
419    ///   `S_mid < S_max` and
420    ///
421    ///   `T_mid < T_max`
422    #[rustfmt::skip]
423    fn mid(a_: T, b_: T) -> ST<T> {
424        let s = T::from_f64(0.11516993) + T::one() / (
425            T::from_f64(7.44778970)
426                + T::from_f64(4.15901240) * &b_
427                + a_.clone() * (T::from_f64(-2.19557347)+ T::from_f64(1.75198401) * &b_
428                + a_.clone() * (T::from_f64(-2.13704948) - T::from_f64(10.02301043) * &b_
429                + a_.clone() * (T::from_f64(-4.24894561) + T::from_f64(5.38770819) * &b_+ T::from_f64(4.69891013) * &a_
430            )))
431        );
432
433        let t = T::from_f64(0.11239642)+ T::one()/ (
434            T::from_f64(1.61320320) - T::from_f64(0.68124379) * &b_
435                + a_.clone() * (T::from_f64(0.40370612)
436                + T::from_f64(0.90148123) * &b_
437                + a_.clone() * (T::from_f64(-0.27087943) + T::from_f64(0.61223990) * &b_
438                + a_.clone() * (T::from_f64(0.00299215) - T::from_f64(0.45399568) * b_ - T::from_f64(0.14661872) * a_
439            )))
440        );
441        ST { s, t }
442    }
443}
444
445/// Maps an `oklab_lightness` to an *sRGB* reference-white based lightness `L_r`.
446///
447/// The `Oklab` lightness is relative, i.e. `0` is black, `1` is pure white, but
448/// `Oklab` is scale independent -- i.e. the luminosity of `luminance == 1.0` is undefined.
449/// Lightness values may mean different things in different contexts (maximum display
450/// luminosity, background brightness and other viewing conditions).
451///
452/// *sRGB* however has a well defined dynamic range and a
453/// [D65](https://en.wikipedia.org/wiki/Illuminant_D65) reference white luminance.
454/// Mapping `1` to that luminance is just a matter of definition. But is say `0.8` `Oklab`
455/// lightness equal to `0.5` or `0.9` `sRGB` luminance?
456///
457/// The shape and weights of `L_r` are chosen to closely matches the lightness estimate of
458/// the `CIELab` color space and be nearly equal at `0.5`.
459///
460/// Inverse of [`toe_inv`]
461///
462/// # See
463/// https://bottosson.github.io/posts/colorpicker/#intermission---a-new-lightness-estimate-for-oklab
464pub(crate) fn toe<T>(oklab_lightness: T) -> T
465where
466    T: Real + Powi + Sqrt + Arithmetics + One + Clone,
467{
468    let k_1 = T::from_f64(0.206);
469    let k_2 = T::from_f64(0.03);
470    let k_3 = (T::one() + &k_1) / (T::one() + &k_2);
471    T::from_f64(0.5)
472        * (k_3.clone() * &oklab_lightness - &k_1
473            + T::sqrt(
474                (k_3.clone() * &oklab_lightness - k_1).powi(2)
475                    + T::from_f64(4.0) * k_2 * k_3 * oklab_lightness,
476            ))
477}
478
479/// Maps a *sRGB* reference-white based lightness to `Oklab`s scale-independent luminance.
480///
481/// Inverse of [`toe`]
482pub(crate) fn toe_inv<T>(l_r: T) -> T
483where
484    T: Real + Powi + Arithmetics + One + Clone,
485{
486    let k_1 = T::from_f64(0.206);
487    let k_2 = T::from_f64(0.03);
488    let k_3 = (T::one() + &k_1) / (T::one() + &k_2);
489    (l_r.clone().powi(2) + k_1 * &l_r) / (k_3 * (l_r + k_2))
490}
491
492#[cfg(feature = "approx")]
493#[cfg(test)]
494mod tests {
495
496    use super::*;
497    use crate::convert::FromColorUnclamped;
498    use crate::rgb::Rgb;
499    use crate::{encoding, Oklab, OklabHue, Srgb};
500    use core::str::FromStr;
501
502    #[cfg_attr(miri, ignore)]
503    #[test]
504    fn test_roundtrip_toe_is_original() {
505        let n = 500;
506        for i in 0..n {
507            let x = i as f64 / n as f64;
508            assert_ulps_eq!(toe_inv(toe(x)), x);
509        }
510
511        let x = 1000.0;
512        assert_ulps_eq!(toe_inv(toe(x)), x);
513    }
514
515    #[test]
516    fn test_toe() {
517        assert_eq!(toe(0.0), 0.0);
518        assert_eq!(toe(1.0), 1.0);
519        let grey50srgb: Srgb = Rgb::<encoding::Srgb, u8>::from_str("#777777")
520            .unwrap()
521            .into_format();
522        let grey50oklab = Oklab::from_color_unclamped(grey50srgb);
523        println!("grey 50% oklab lightness: {}", grey50oklab.l);
524        assert_relative_eq!(toe(grey50oklab.l), 0.5, epsilon = 1e-3);
525    }
526
527    #[cfg_attr(miri, ignore)]
528    #[test]
529    fn print_min_max_srgb_chroma_of_all_hues() {
530        struct HueLc<T: Real> {
531            hue: OklabHue<T>,
532            lc: LC<T>,
533        }
534
535        let mut min_chroma: HueLc<f64> = HueLc {
536            hue: OklabHue::new(f64::NAN),
537            lc: LC {
538                lightness: 0.0,
539                chroma: f64::INFINITY,
540            },
541        };
542        let mut max_chroma: HueLc<f64> = HueLc {
543            hue: OklabHue::new(f64::NAN),
544            lc: LC {
545                lightness: 0.0,
546                chroma: 0.0,
547            },
548        };
549        let mut min_a = f64::INFINITY;
550        let mut min_b = f64::INFINITY;
551        let mut max_a = -f64::INFINITY;
552        let mut max_b = -f64::INFINITY;
553
554        // use 300000 for actually computing values (takes < 10 seconds)
555        const SAMPLE_RESOLUTION: usize = 3;
556
557        for i in 0..SAMPLE_RESOLUTION * 360 {
558            let hue: OklabHue<f64> = OklabHue::new(i as f64 / (SAMPLE_RESOLUTION as f64));
559            let (lc, a, b) = hue.srgb_limits();
560            if lc.chroma < min_chroma.lc.chroma {
561                min_chroma = HueLc { hue, lc };
562            }
563            if lc.chroma > max_chroma.lc.chroma {
564                max_chroma = HueLc { hue, lc };
565            }
566            max_a = f64::max(max_a, a);
567            min_a = f64::min(min_a, a);
568            max_b = f64::max(max_b, b);
569            min_b = f64::min(min_b, b);
570        }
571
572        let (normalized_a, normalized_b) = max_chroma.hue.into_cartesian();
573        let (max_chroma_a, max_chroma_b) = (
574            normalized_a * max_chroma.lc.chroma,
575            normalized_b * max_chroma.lc.chroma,
576        );
577
578        println!(
579            "Min chroma {} at hue {:?}°.",
580            min_chroma.lc.chroma, min_chroma.hue,
581        );
582
583        println!(
584            "Max chroma {} at hue {:?}° (Oklab a and b {}, {}).",
585            max_chroma.lc.chroma, max_chroma.hue, max_chroma_a, max_chroma_b
586        );
587        println!("{} <= a <= {}", min_a, max_a);
588        println!("{} <= b <= {}", min_b, max_b);
589    }
590
591    #[test]
592    fn max_saturation_f64_eq_f32() {
593        let lin_srgb = LinSrgb::new(0.0, 0.0, 1.0);
594        let oklab_64 = Oklab::<f64>::from_color_unclamped(lin_srgb);
595        let (normalized_a, normalized_b) = (
596            oklab_64.a / oklab_64.get_chroma(),
597            oklab_64.b / oklab_64.get_chroma(),
598        );
599        let saturation_64 = LC::max_saturation(normalized_a, normalized_b);
600        let saturation_32 = LC::max_saturation(normalized_a as f32, normalized_b as f32);
601
602        // EPSILON should be 1e-6. See issue https://github.com/Ogeon/palette/issues/296
603        const EPSILON: f32 = 3e-1;
604        assert_relative_eq!(
605            saturation_32,
606            saturation_64 as f32,
607            epsilon = EPSILON,
608            max_relative = EPSILON
609        );
610    }
611}