Skip to content

[css-color-4] Evaluate static gamut mapping algorithms for oklab/oklch spaces #10579

Open

Description

@ccameron-chromium

Separating the action item "ccameron: review gamut mapping algorithms" from here into a new issue. Tagging in @foolip @LeaVerou @svgeesus for the more limited discussion here (please feel free to include anyone else).

From the linked resolution in the issue, these algorithms are not to be applied to the various RGB spaces. I'd like to scope my comments to just "gamut mapping to apply to oklab/oklch" spaces, since I feel a bit differently about XYZ and Lab spaces (I would want XYZ and Lab to follow RGB, because they are the canonical profile connection spaces used in ICC based color matching, and don't want to mix this discussion with those issues).

The properties that I'd like for the algorithm to apply to just-oklab/oklch are much looser. An iterative algorithm with quadratic convergence is quite fine.

There were lots of algorithms listed in issue 9449, so I'd like to collect a reduced list of candidates here to discuss.

To provide an extremely short list:

  • Projecting in oklab [edit, fixed typo] to the Rec2020 gamut boundary along constant L
    • This is what's proposed in the spec (modulo termination conditions and iteration method)
    • We can write out the details for an exact solution that quadraticly converges (it's a bit of work, but I can do it if desired)
    • An exact solution for the exact Rec2020 boundary may not be desirable
      • There is some concave nastiness around the blue corner of the gamut that it would be nice to avoid.
      • There is also chrome reduction as discussed in the spec
      • Creating a "nicer-shaped" surface very close to the Rec2020 gamut might be a good solution here
  • Projecting in Rec2020 YUV along constant Y
    • This is extremely fast and probably quite good.

Activity

  1. svgeesus commented on Jul 16, 2024

    @svgeesus
    Collaborator

    Thanks for starting this.

    • Projecting in Lab to the Rec2020 gamut boundary along constant L

    Why Lab and not Oklab, given the hue curvature of Lab (especially in blues, but also in orange to a lesser extent).

    I agree that mapping to a smoothed gamut boundary may well be better.

    • Projecting in Rec2020 YUV along constant Y

    That is video luma, right? Where Y is computed without bothering to undo gamma-encoding first?

  2. ccameron-chromium commented on Jul 16, 2024

    @ccameron-chromium
    ContributorAuthor

    Thanks for starting this.

    • Projecting in Lab to the Rec2020 gamut boundary along constant L

    Why Lab and not Oklab, given the hue curvature of Lab (especially in blues, but also in orange to a lesser extent).

    Sorry, that was a typo. I fixed up the original comment.

    I agree that mapping to a smoothed gamut boundary may well be better.

    • Projecting in Rec2020 YUV along constant Y

    That is video luma, right? Where Y is computed without bothering to undo gamma-encoding first?

    In the experiments that I did (which was not very exhaustive), I was doing this in linear space, but that was just because it was easier to plug into the pipeline at that stage.

    Another option was to find the nearest point in the L2 sense in YUV space (either linear or gamma encoded). I haven't investigated that thoroughly.

  3. svgeesus commented on Jul 16, 2024

    @svgeesus
    Collaborator

    Sorry, that was a typo. I fixed up the original comment.

    Ah okay, makes sense.

    I was doing this in linear space

    That makes the calculated lightness accurate, so good. It doesn't make the UV plane perceptually uniform, of course.

  4. svgeesus commented on Jul 24, 2024

    @svgeesus
    Collaborator

    There is some concave nastiness around the blue corner of the gamut that it would be nice to avoid.

    This is clearly seen in CSS Color 4, Figure 2.

    sRGB-DisplayP3-rec2020-in-Oklab

  5. svgeesus commented on Jun 5, 2025

    @svgeesus
    Collaborator

    Looking again at but this time for desaturated colors which are actually in gamut, I see unacceptable results for the Scale and ScaleLH options:

    Image

    Image

    Image

  6. svgeesus commented on Jun 5, 2025

    @svgeesus
    Collaborator

    Taking the results for "CSS Rec2020" as the consensus target (CSS gamut map to the BT.2020 gamut, then clip from there to the display) the four contenders are thus:

    • Chromium
    • Björn Ottosson
    • Raytrace
    • Edge Seeker
  7. svgeesus commented on Jun 5, 2025

    @svgeesus
    Collaborator

    Looking now at out of gamut colors (including colors even outside ProPhoto) all four give similar results to "CSS Rec2020" and often slightly better results.

    For example, for oklch(80% 109% 311) CSS Rec2020 has a ΔH of 4.3, Chromium ΔH 1.7, Edge Seeker ΔH 0.03 while Björn Ottosson and Raytrace both have ΔH 0, which is ideal.

    Image

    Image

    I think the next task is to capture timing information on those four.

  8. svgeesus commented on Jun 5, 2025

    @svgeesus
    Collaborator

    Looking at gamut mapping each position in a 361-stop gradient from oklch(90% .6 250) to oklch(40% .3 20) (set max DeltaE to 3, to get lots of stops) the timings in that case, sorted from slowest to fastest:

    • CSS Rec2020 21.3ms
    • CSS 17ms
    • Raytrace 12.9ms
    • Scale LH 9.1ms
    • Björn Ottosson 6.4ms
    • Chromium 5.5ms
    • Edge Seeker 3.7ms
    • Clip 2.9ms
  9. svgeesus commented on Jun 5, 2025

    @svgeesus
    Collaborator

    Calculating mean runtime over 100 iterations gives similar speed ranking:

    • CSS 39.285ms
    • CSS Rec2020 37.438ms
    • Raytrace 25.618ms
    • Scale LH 21.532ms
    • Björn Ottosson 12.361ms
    • Chromium 10.203ms
    • Edge Seeker 7.488ms
    • Clip 5.062ms

    The code for edge seeker calculates the maximum chroma for a given lightness and hue. The gamut slice is approximated with a linear segment for the lower (dark) part and an arc for the upper (light) part. This implementation populates a 400-element LUT.

  10. jamesnw commented on Jun 9, 2025

    @jamesnw

    I generated colors in the XYZ space, and for each color outside of Rec2020, I applied the GMA, and then clipped to P3 and sRGB to approximate what color might be displayed. I then calculated average deltas for each channel in OkLCh, taking the absolute value since I was interested in magnitude and not directionality. A drawback is that this obscures how the GMAs behave in different ranges, as well as how much variation there is.

    Method Destination Gamut ΔL ΔC Δh ΔDelta2000
    Edge Seeker p3 p3 0.0004 0.3101 0.0007 17.4262
    Edge Seeker p3 srgb 1.4090 0.3329 2.2804 19.2775
    Edge Seeker Rec2020 p3 2.1310 0.2910 2.9192 16.2893
    Edge Seeker Rec2020 srgb 3.3636 0.3172 5.2885 18.7126
    Björn Ottosson p3 0.000008 0.3101 0.00002 17.4203
    Björn Ottosson srgb 1.4105 0.3328 2.2829 19.2729
    Chromium p3 2.3165 0.2858 3.2774 15.8680
    Chromium srgb 3.4980 0.3139 5.6398 18.4112
    Raytrace p3 0.00002 0.3101 0.0025 17.4188
    Raytrace srgb 1.4106 0.3328 2.2853 19.2720
    Clipping p3 7.2 0.244 8.72074 16.607

    Scales:

    • L = 0-100 (So a 1 would be equivalent to 0.01 or 1%)
    • C = 0-0.4 (Most of the mapped colors had very high chroma to start with, so we're seeing large shifts here, as expected)
    • h = 0-360

    Some notes-

    • All are improvements over Clipping
    • Edge Seeker performs well. Given its speed and performance, I would support this being the method for getting to rec2020.
    • Björn Ottosson performs much better than Edge Seeker when clipping to p3, but the advantage isn't there when clipping to srgb (Although the implementation is using p3 coefficients, and we could use separate sRGB coefficients, I think?). Is this performance boost worth the almost 2x longer compute time?
    • I omitted Raytrace since it appears to be slower (added)
    • The implementations of both Edge Seeker and Björn Ottosson target a displayP3 output in how they are calculated- would we want to target a rec2020 output? I assume that would negatively impact their performance.
      • UPDATE June 10 - Added Edge Seeker with rec2020-based LUT.
  11. facelessuser commented on Jun 9, 2025

    @facelessuser
    Contributor

    I omitted Raytrace since it appears to be slower.

    It should be noted that you can decrease the raytrace iteration from 4 to 3 and reduce its speed and still likely have pretty good results, though I wouldn't go lower than 3. I'd suspect with high loads that Björn's method would still prove overall faster.

    Raytrace is decent as a generic approach for various RGB gamuts as you don't need to hard code color space specific coefficients, but since it sounds like the plan is to just target a single gamut, Rec. 2020, and clip to everything else, that doesn't matter so much and something like like Björn Ottosson's approach is definitely faster, and Edge Seeker even more so if storing a LUT is acceptable.

  12. facelessuser commented on Jun 9, 2025

    @facelessuser
    Contributor

    EDIT: This test is done with the full 4 iterations of raytrace, no reduction.

    Okay, one thing that was bothering me about the Color.js testing of GMAs is that it isn't 100% fair as some algorithms are getting hit heavier by certain API calls of Color.js.

    Björn and Edgeseeker bypass the Color.js conversion pipeline which has a lot of overhead. For instance, Björn's converts to linear RGB outside of of the Color.js API.

    So optimizing Raytrace to do the same, we actually get faster results than Björn's.

    Forcing 1000 gradient steps, ray-trace is shown to be noticeably faster.

    Image

    Here's the optimized preview: https://deploy-preview-21--color-apps.netlify.app/gamut-mapping/gradients?from=oklch%2890%25+.4+250%29&to=oklch%2840%25+.1+20%29

    The optimizations can be seen here: color-js/apps#21.

    I imagine even the CSS approach could be sped up doing something similar.

  13. jamesnw commented on Jun 9, 2025

    @jamesnw

    It should be noted that you can decrease the raytrace iteration from 4 to 3 and reduce its speed and still likely have pretty good results, though I wouldn't go lower than 3. I'd suspect with high loads that Björn's method would still prove overall faster.

    I added in Raytrace, and it looks like it outperforms Edge Seeker on Lightness, but not hue. It also outperforms Björn's method on Delta2000.

  14. facelessuser commented on Jun 9, 2025

    @facelessuser
    Contributor

    I added in Raytrace, and it looks like it outperforms Edge Seeker on Lightness, but not hue. It also outperforms Björn's method on Delta2000.

    Yep, that's about what I would expect. It'll get hue reasonably close, but it can't beat a LUT 🙂.

  15. 69 remaining items

  16. jparnzen commented on Jun 10, 2026

    @jparnzen

    @jamesnw : thanks for the clarification on the Chromium GMA implementation in color.js! And glad to know it's not a showstopper here--that's what I was hoping for :) Let me or the Firefox team know if you run into any other cases where either Firefox GMA shows weirdness.

    @facelessuser : I'm glad it was a simple one-and-done bug too. Thanks for catching that again. I've been implementing the GMAs separately in my own projects and hadn't considered the precision difference since mine use f64 just like ColorAide, color.js, and the draft assumption. Good to know for future work in Firefox since the CSS code uses f32. And I'll add a note to the raytracing code when I submit some reftests to Firefox hopefully later today.

  17. romainmenke commented on Jun 11, 2026

    @romainmenke
    Member

    @jparnzen Super cool to see an implementation! Thank you for working on this 🙇
    Is it correct that gamut mapping in the Firefox Nightly always uses srgb as the destination and not rec2020 (#9449) or the device gamut?

  18. jparnzen commented on Jun 11, 2026

    @jparnzen

    @romainmenke : You are correct. To my understanding, Firefox uses sRGB as the destination display color space, and that's the destination space I pass to the GMAs. I saw in some earlier discussions here that there was mention of using Rec2020 during gamut mapping, but I don't think I saw it mentioned in the GMAs themselves in the drafts. Please correct me if I'm wrong.

    And many thanks! It's been fun getting these implemented, and great to be able use them and OKLCH in the browser without having to go through an extra layer of a library or manual calculation. And very happy to help the team here work through any implementation details to make the spec more bullet-proof.

    BTW, I submitted the fix for the raytracing bugs that were found, and it autolanded in Nightly on 06-09. Not sure if it's gone into the main branch of Nightly yet or not, but should very soon. I'm working on WPT tests for it now. And if it helps you all out, I added initial WPT tests for local MINDE gamut mapping as part of the GMA implementations; I started raytracing WPTs but the patch landed before I could finish them at the time :)

  19. facelessuser commented on Jun 11, 2026

    @facelessuser
    Contributor

    I saw in some earlier discussions here that there was mention of using Rec2020 during gamut mapping, but I don't think I saw it mentioned in the GMAs themselves in the drafts.

    That was for a proposal of gamut mapping to Rec2020 and then clipping to the display gamut instead of gamut mapping directlytly to the display gamut. That is not specifically part of the Raytrace or MINDE algorithm (though they could be used to do that). I believe that sRGB, Display P3, and Rec. 2020 are the only supported display gamuts in CSS.

    I'm not sure if Firefox yet displays colors in the display gamut (by default at least). I know on my Display P3 monitor that only Safari and Chrome show colors in my display's native capability, Firefox has always only clipped everything to sRGB, but maybe there is a setting that would allow it to 🤷🏻.

  20. romainmenke commented on Jun 13, 2026

    @romainmenke
    Member

    There is a compromise to be made between wanting to show as many colors as possible on any given device and avoiding clipping. And at the same time another compromise between showing as many colors on any device and having consistency between various devices.

    Gamut mapping to srgb:

    as currently implemented in Firefox

    • avoid clipping entirely on devices that can display all of srgb
    • makes it impossible to display wider gamut colors (display-p3 and beyond) when using notations like oklch

    This forces authors to pick between wider gamut colors and gradients/color transforms in perceptually uniform color spaces.

    If we assume that most devices can display all or most of srgb then this method does give consistency between devices.

    I don't think this is a good compromise. Especially as screens continue to improve and move beyond srgb.

    Gamut mapping to display-p3, rec2020, ...:

    there is a resolution to always gamut map to rec2020

    This has the same issues as gamut mapping to srgb. But will result in more clipping while making it possible to also display wide gamut colors.

    Exactly how much clipping or mapping you get depends on the device.

    I think this is marginally better than gamut mapping to srgb but that this not what authors want. The unpredictability of when you get clipping and what the outcome of that clipping will be just isn't great.

    Gamut mapping to what the device can actually display

    • removes all clipping entirely
    • allows wider gamut colors and will display more of them over time as displays become better

    Given that clipped colors tend have a larger difference from the original than gamut mapped colors it should also make colors more consistent between devices if we always gamut map to what the device is capable of.

    (this last one is my preferred method)


    @jparnzen Is it possible to add another flag in Firefox to control the destination color space?
    Can this be a toggle between:

    • srgb
    • display-p3
    • rec2020
    • the actual display/device

    This will give better insights on what is the best approach 🙇

  21. jparnzen commented on Jun 16, 2026

    @jparnzen

    @romainmenke : as of today, from what I see in the code, Firefox hardcodes queries to get the screen color gamut to return sRGB (in the nsScreen and Screen widget code, for reference).

    I think I may have seen a ticket in bugzilla to add support for P3 and maybe Rec2020, but I'll have to go back and look, and I don't think it's gotten any traction as yet. I will say that as part of my work on the GMAs, the function/method signatures for the GMAs take a destination color gamut, with the anticipation that someone will want to pass something other than sRGB at some point.

    Thanks for the analysis and breakdown of the 3 possible approaches! I will offer that as a designer and dev, I expect that the colors I specify in CSS should display as close as possible to my expectation, especially with lightness. So clipping to sRGB from a wider gamut space wouldn't sit well, unless the difference between clipped and mapped-to-screen-gamut values are within some unnoticeable threshold. Colors with high lightness levels were a big reason I started on the GMA implementations--trying to get colors at 95%-99% lightness (with some sense of chroma or saturation) in OKLCH to use as things like backgrounds or text color on dark backgrounds is problematic without the GMAs. And while I agree with your take and would like the colors mapped to end-display gamuts, at the very least getting them to sRGB for now seems acceptable as that's effectively the baseline for monitors (I still found monitors for sale with less than 98% sRGB coverage when shopping for a new one recently). Just my anecdotal take :)

  22. LeaVerou commented on Jun 19, 2026

    @LeaVerou
    Member

    Btw I just added a benchmark that compares the existing GMAs for both accuracy and speed, using min/max/avg/median error (which is a weighed sum of l, c, h deltas weighing ΔH 8x, ΔL 4x, and ΔC 1x). It also allows customizing the range and granularity of the patches via URL params.

    I also added a new GMA called OKLCh Cubic. It is based on the observation that at fixed L and H each linear-P3 channel is exactly a cubic equation in chroma (l' is affine in C, then cubed), so the C boundary is the smallest chroma at which any channel reaches 0 or 1 — found via standard closed form cubic equation solving formulas and optimized further through some observations about the types of equations produced and a lazily populated hue cache (which is not necessary, just an additional optimization).

    Because of this:

    • it is much simpler than the other leading methods (< 150 loc)
    • does not depend on a LUT
    • it is not iterative

    In the benchmark it consistently scores at the top for all accuracy metrics (min, max, avg, med, iqm) alongside EdgeSeeker, and seems to be 5-30% faster than EdgeSeeker, both of which are even faster than clip!

    Image
  23. facelessuser commented on Jun 19, 2026

    @facelessuser
    Contributor

    This is very interesting. But does this only hold true for P3 and not sRGB or Rec. 2020? That would, unfortunately, limit it to P3 only.

    Small note, it seems the algorithm kicks out if !(C > 0) and doesn't check lightness at all. This would need to be fixed.

    I do think these benchmarks don't completely tell the whole story. The way it is being tested is highly advantageous to the cubic approach as it bypasses all conversions; it never leaves the OkLCh color space. That doesn't discredit its speed, only that the extreme advantage it is showing comes specifically from never having to convert out of OkLCh. Give it a P3 color to start, and then it must incur the conversion cost, dramatically dropping its performance gain. It's still very fast, but not 30% faster. But, if you are in OkLCh, technically, it would be even faster as it skips the conversions. I think in browsers, we would always be converting to the actual gamut at the end, though, for display.

    Image
  24. facelessuser commented on Jun 19, 2026

    @facelessuser
    Contributor

    I've updated the algorithms for a fairer comparison. GMAs all return the gamut mapped colors consistently in the origin space. I also fixed the oklch-cubic method so that it actually checks lightness for gamut mapping. color-js/apps#36 (comment).

    OkLCh-cubic is still usually the fastest, but results seem more fair now, instead of each algorithm returning the color in inconsistent color spaces.

    Image
  25. facelessuser commented on Jun 20, 2026

    @facelessuser
    Contributor

    I implemented the new cubic algorithm on an experimental branch in the ColorAide library to see how it performed in a real-world environment with similar overhead and abstraction for generalization within the library.

    Compared against Ray Trace (as I don't have the LUT method implemented), it was the fastest by far.

    This was processing an image with varying ranges of lightness and hue with maximum saturation in Rec. 2020. We gamut-mapped them to sRGB.

    ➜  coloraide git:(feature/fit-oklch-cubic) ✗ python3 tools/apply_gamut.py -i test.jpg -o test-map4.jpg --gamut rec2020 --gmap 'raytrace'
    Pixels: 1048576
    > 100%
    Completed in: 7.003060333 sec
    ➜  coloraide git:(feature/fit-oklch-cubic) ✗ python3 tools/apply_gamut.py -i test.jpg -o test-map4.jpg --gamut rec2020 --gmap 'oklch-cubic'
    Pixels: 1048576
    > 100%
    Completed in: 3.657383834 sec

    I didn't see it overpower clip in my library, and that may be for any number of design reasons, but Ray Trace vs Cubic was the most apples-to-apples comparison when it came to generalized abstraction and overhead. Admittedly, our version of Ray Trace has some extra stuff outside of what's specified in the CSS spec that adds a little more overhead, but even if we were to strip those out, there is no way it could overtake the Cubic approach.

    Generally, it also provided fewer deviations in lightness and hue. While the difference wasn't visibly noticeable, the fact that it can reduce the chroma that fast and more accurately is impressive.

  26. facelessuser commented on Jun 22, 2026

    @facelessuser
    Contributor

    After abstracting the OkLCh Cubic algorithm to work for any linear RGB color space, and evaluating it, I do think it is worth considering, as it seems fast and accurate for sRGB, Display P3, and Rec. 2020.

    With or without the additional hue data cache, it is still faster than Ray Trace. If a cache were used, I would probably think having some bounds would make sense, but I think the algorithm is extremely competitive even without the cache. So if keeping a LUT of hue data was not desired, it is still very fast and accurate.

    I do not think it is actually faster than clip, and further, I would state I believe it needs a clip itself at the end of the algorithm, as it can produce some values that do need clipping in some circumstances.

    I found it impressive enough to implement it and release it in ColorAide as an experimental GMA to play with.

  27. facelessuser commented on Jun 22, 2026

    @facelessuser
    Contributor

    As a side note, I've issued a PR with some corrections to the Ray Trace algorithm: #14080. It addresses:

    1. It addresses the issue here and now notes values for both 64-bit and 32-bit.
    2. It addresses the issues noted here.
    3. And one other improvement is that it notes optimal values for the distance threshold from the gamut surface. Previously, this was specified as 1e-6, and it has been updated to 1e-12 (for 64-bit) and noted that 1e-6 should be used for 32-bit. This reduces hue deltas in some cases.
  28. facelessuser commented on Jun 24, 2026

    @facelessuser
    Contributor

    Small update to the benchmarks that were posted earlier using ColorAide. The ColorAide version allows swapping out OkLCh for any perceptual color space (as the approach can be used for most other perceptual spaces).

    Here's a fairer comparison with a Ray Trace purpose-built for OkLCh and linear CSS RGB spaces. The Cubic one is still shown to be faster, but the performance is much closer. Now

    ➜  coloraide git:(main) ✗ python3 tools/benchmark_gma.py -t oklch -m 'raytrace-oklch'
    Colors: 250000
    > 100%
    Completed in: 4.91770125 sec
    ➜  coloraide git:(main) ✗ python3 tools/benchmark_gma.py -t oklch -m 'oklch-cubic'
    Colors: 250000
    > 100%
    Completed in: 4.384709042 sec

    Now, add the cache, and the Cubic approach pulls further ahead:

    ➜  coloraide git:(main) ✗ python3 tools/benchmark_gma.py -t oklch -m 'oklch-cubic:{"cache": true}'
    Colors: 250000
    > 100%
    Completed in: 3.021533917 sec
  29. ardov commented on Jul 12, 2026

    @ardov

    I added another LUT-less algorithm based on bracketed Halley method (color-js/apps#44 (comment))

    It is similar to OKLCh cubic but there are a few differences:

    • It uses Halley iterations which seem to work faster (not using expensive acos/cbrt). In Color JS benchmark it's usually performs 10-20% faster than OKLCh cubic.
    • It takes into account the problematic "blue fold" that happens in sRGB and Rec.2020. Algorithms like binary search tend to jump over more vivid blues and return noticeably duller colors. Originally, it was the main reason for me to create the Edge Seeker.
    Image

    Oh and I also wanted to share the quality measuring approach I used for the algorithms.

    The problem with a random out-of-gamut color is that we don't have a ground truth to compare with.
    So I took RGB colors on the surface of the gamut → converted to OKLCh → increased chroma. I was feeding the algorithm with the increased-chroma versions. And then I compared the results with the known original RGB colors to see how well it performed. I did it for all ~320K hexes on the surface of the gamut.

  30. svgeesus commented on Sep 1, 2026

    @svgeesus
    Collaborator

    Review welcome on a PR to add Edge Seeker pseudocode (it seems correct but I would value other eyes on it):

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions