color.js icon indicating copy to clipboard operation
color.js copied to clipboard

High chroma `oklch` conversions to `srgb` can have significant hue and/or saturation loss

Open argyleink opened this issue 1 year ago • 17 comments

reduced case https://codepen.io/argyleink/pen/QWZEqeM

  • squares on the left are the control, an oklch high chroma color
  • the ~Expected example is a color i've quickly hand authored in rgb()
  • the ColorJS converted color is the result of new Color(c).to('srgb').toString({format: 'hex'})

the results of the conversion are much better when chroma is something more reasonable like .2, but the point here is the converted color math is struggling to find a reasonably close rgb() color for high chroma oklch.

argyleink avatar Apr 17 '23 16:04 argyleink

This is because color.js is still gamut mapping, by default, with CIE LCh. So, we convert oklch(35% .5 310) over to lch(-5.2 288 325) and then gamut map it. We can see due to the geometry why we get something close to black:

gamut_lch

Now, if we were to gamut map in OkLCh, we would get something different:

Figure_1

Browsers on the other hand just use simple clipping, which you are comparing against. In that case, you want to clip the color to whatever the display gamut is and then output the value.

facelessuser avatar Apr 17 '23 17:04 facelessuser

Browsers on the other hand just use simple clipping, which you are comparing against

i hand authored all the colors, nothing came from browser clipping? i'm just comparing visually, that i expected a yellow to stay yellow and a purple to stay purple. so i hand authored the rgb color i could create close to it.

are there ways for me to work around this? can i map in oklch with a parameter? i'm building a tool and the "fallback" srgb colorspace that's currently generated is often unusable (like the demo codepen showed with purple and yellow).

argyleink avatar Apr 17 '23 18:04 argyleink

i hand authored all the colors, nothing came from browser clipping? i'm just comparing visually, that i expected a yellow to stay yellow and a purple to stay purple. so i hand authored the rgb color i could create close to it.

Sorry, I made an assumption.

are there ways for me to work around this?

So, you can change the gamut mapping approach:

color = new Color("oklch(35% .5 310)").toGamut({space: 'srgb', method: 'oklch.c'});

facelessuser avatar Apr 17 '23 18:04 facelessuser

i see ty. this pacified the issue, but the conversions still seem off https://codepen.io/argyleink/pen/oNaLEPV

  • the converted yellow is pale
  • the purple is too dark

argyleink avatar Apr 17 '23 18:04 argyleink

@argyleink, then let me ask, how are you coming to your calculated result? I expressed earlier I thought you were clipping based on your values.

Screenshot 2023-04-17 at 12 47 52 PM

facelessuser avatar Apr 17 '23 18:04 facelessuser

the calculated result uses this library. the estimated result is my hand written rgb color where i very quickly changed rgb values until i got something close enough to make the point.

maybe i'll clip then.. oh yes, just tested, that's great! https://codepen.io/argyleink/pen/LYgZQvv

argyleink avatar Apr 17 '23 18:04 argyleink

wow, i thought mapping was going to be better.. but clipping is giving me the results i expect.

where will i see edge cases where mapping would be better than clipping? most my tests are very much favoring clipping right now, its making mapping look like a wild child.

argyleink avatar Apr 17 '23 18:04 argyleink

Yeah, keep in mind that clipping does not preserve...well anything. It simply truncates the values. The fact that you got something you think makes more sense does not necessarily mean it does, though I guess that is subjective :).

Clipping not only shifts the hue but lightens and chroma of the color. It's a very large change in the characteristics of the color. Now, it is debatable as to what is better. Some may adjust lightness and chroma for instance but preserve hue. This approach (oklch.c) tries to preserve both hue and lightness within OkLCh. Clipping just truncates and you get what you get, and in this case, a different hue, lightness, and chroma, essentially a very different color. I don't know what way is the "best" gamut mapping method, but clipping surely is not, but you will match what browsers currently do.

facelessuser avatar Apr 17 '23 19:04 facelessuser

For reference, the first example in this article shows how bad clipping is in the context of an image: https://bottosson.github.io/posts/gamutclipping/

This demonstrates roughly what oklch.c is doing: https://bottosson.github.io/posts/gamutclipping/#keep-lightness-constant%2C-only-compress-chroma. Honestly, it looks much better.

The article shows the results of other experimentations as well. With that said, there are probably quirks of gamut mapping in various color spaces. I know LCh has some and even OkLCh has some.

facelessuser avatar Apr 17 '23 19:04 facelessuser

I don't think method: "oklch.c" is actually working as expected (i.e. just reducing chroma till the color is in gamut - correct me if my expectation is wrong)

In the OP's case, the resultant hex color by color.js conversion is #580482, which, converted back to oklch, is oklch(0.36 0.18 308.86), when it should be oklch(0.35 0.18 310). I have been seeing this behavior (adjustments other than to chroma) consistently across various gamut mapping conversions with this library.

Now in this particular case, that doesn't make a huge different to the percieved color, but in some conversions i have noticed this discrepency resulting in pretty significant differences.

To clear up some confusion in the above thread - the color discrepancy in the OP's codepen is due to clipping, but not of the converted color. It's the original color that is being clipped upon display because it is far outside the valid colors for even p3 displays. If you change the original color to oklch(0.35 0.18 310) (valid in p3, but needs a fallback for rgb), you'll see that the converted color is indeed very close to the original color.

maybe i'll clip then.. oh yes, just tested, that's great! https://codepen.io/argyleink/pen/LYgZQvv

The reason this "works" is because now both your colors are clipped (both the original and converted colors are deviating from the source color in the same flawed way) - not because clipping in principle is likely to produce good results even a moderate amount of the time.

acalvino4 avatar May 07 '24 16:05 acalvino4

I don't think method: "oklch.c" is actually working as expected (i.e. just reducing chroma till the color is in gamut - correct me if my expectation is wrong)

In the OP's case, the resultant hex color by color.js conversion is #580482, which, converted back to oklch, is oklch(0.36 0.18 308.86), when it should be oklch(0.35 0.18 310). I have been seeing this behavior (adjustments other than to chroma) consistently across various gamut mapping conversions with this library.

No, that is incorrect. The current algorithm used uses MINDE and chroma reduction. It reduces chroma until the chroma reduced color, when clipped, has a ∆E below the JND. So hue and lightness can shift and is expected. Shifts should not be noticeable by the eye. You can often see a greater shift at extreme lightness (close to white or black) as the eye cannot detect the shift as much.

facelessuser avatar May 07 '24 18:05 facelessuser

Almost all methods are using an approximation to calculate the reduced chroma color. So, unless you over reduce chroma, you will have to clean up the result with a clip which will cause some shift to hue and lightness.

There is a page demoing a number of approaches, not all specifically aim at reducing chroma, but generally most do: https://colorjs.io/apps/gamut-mapping/?.

The most accurate of all the approaches is done with a LUT (look up table). This is because there is far less approximation as the table has been populated with very close values.

You can still do a tighter chroma reduction with even less hue and lightness shift and do it faster than the default MINDE chroma reduction approach, but there is still some shift to hue and lightness, but far less shift overall. The linked raytrace approach does this in about 4 passes with a shift in hue generally less than 1 or 2 degrees.

facelessuser avatar May 07 '24 18:05 facelessuser

ok, i see, thanks for the clarification. Looks like the method I was expecting is what is labeled as 'edge seeker' in the linked app.

I was basing my misunderstanding on this excerpt from the docs:

method ... was "lch.c" which means LCH hue and lightness remain constant while chroma is reduced until the color fits in gamut.

though further down I do see a bit more of an explanation - tbh, this portion was a little beyond my technical understanding, hence my continued misunderstanding:

the "lch.c" method reduces chroma by binary search ... and at each stage calculates the deltaE ... if the deltaE is less than 2, the clipped color is displayed

Anyway, the approximate methods, like you said, do generally get close enough to be imperceptible. However, I've run into some cases where the differences are very noticeable between the css color 4 algorithm and edge seeker algorithm: i.e. - https://colorjs.io/apps/gamut-mapping/?color=+oklch%2898%25+0.19+110%29

In these cases is there any way in color js to specify an alternate algorithm (such as edge seeker)?

acalvino4 avatar May 07 '24 19:05 acalvino4

However, I've run into some cases where the differences are very noticeable between the css color 4 algorithm and edge seeker algorithm: i.e. -

Again, it is only not noticeable between the current chroma reduced color and the clipped version of it, not the ideal chroma reduced color.

Each step of the current algorithm uses bisection, that chroma reduced color is compared to a clipped version of the same color. Yes, it is different than the edge seeker algorithm that uses a LUT.

The edge seeker algorithm is an approach that creates a sizeable table in memory that tracks very close points to the surface of a gamut, but a LUT must be generated for every gamut you wish to target. Currently, the edge seeker gamut is only available as demo.

facelessuser avatar May 07 '24 20:05 facelessuser

I think what is not clear to me is what the meaning of deltaE00 is when comparing a visible color to an invisible one (such as oklch(0.35 0.8 310)) - i.e. when the original color is not displayable, how can we say which algorithm calculates a 'closer' result?

Conceptually, I'm coming at this from the standpoint of taking a source color and generating a palette of similar colors that vary only in lightness. In this case, the 'best' algorithm is the one that produces a converted color that maintains the correct hue from my source and makes the lightness exactly what I specify - i.e. pure chroma reduction. I understand algorithmic and performance considerations may prevent this from being the default, or maybe even from being an option; I'm merely trying to articulate why, to me, the edge-seeker algorithm is what I want in the yellow example I linked above, even though it has a higher deltaE00 than the css level 4 algorithm.

Edit: Looks like we added to the conversation at roughly the same time - I see you said that the deltaE is calculated from the difference between the adjusted color and clipped version, not the original.

acalvino4 avatar May 07 '24 20:05 acalvino4

I think what is not clear to me is what the meaning of deltaE00 is when comparing a visible color to an invisible one (such as oklch(0.35 0.1 310)) - i.e. when the original color is not displayable, how can we say which algorithm calculates a 'closer' result?

One thing you could do is decrease the JND. This will force the chroma reduction to approach the surface much closer before performing the final clip

> new Color('oklch(35% .5 310)').toGamut({space: 'srgb', method: 'oklch.c', jnd: 0.0}).toString()
'oklch(35% 0.17578 310)'

facelessuser avatar May 07 '24 20:05 facelessuser

ah, thanks, that should do it! I'm only running this at build time so performance and memory aren't a huge concern.

acalvino4 avatar May 07 '24 20:05 acalvino4