Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[css-color-4] Expected behavior around lightness 0 or 100% in Oklab/Oklch/Lab/LCH? #10109

Open
foolip opened this issue Mar 21, 2024 · 24 comments
Labels

Comments

@foolip
Copy link
Member

foolip commented Mar 21, 2024

When adjusting lightness in Oklab/Oklch/Lab/LCH, what is expected behavior?

This is related to handling out-of-gamut colors, because:

  • As lightness decreases we reach imaginary colors with no physical meaning.
  • As lightness increases we reach real colors that cannot be shown on most displays.

I'm filing a new issue, because since Chrome 120, lightness exactly equalling 0 or 100% are mapped to black and white respectively. This led to a discontinuity and browsers now handle these cases differently:

https://wpt.live/css/css-color/oklab-l-almost-0.html (subtle difference)
https://wpt.live/css/css-color/oklab-l-almost-1.html (very apparent difference)

See also results on wpt.fyi

In web-platform-tests/wpt#45073 (comment), @svgeesus explained that the spec previously "effectively mandated a discontinuity", but that's no longer the case, so Chrome appropriate fails these tests now.

Oklab gradients with endpoints close to 0/100% are also affected. Demo at https://codepen.io/foolip/pen/zYXZPYr, with Chrome rendering inlined here:

Lightness 0-100%:
zero-to-hundred

Lightness 0-99.9%:
zero-to-almost

Lightness 0.1-100%:
almost-to-hundred

Lightness 0.1-99.9%:
almost-to-almost

In Firefox and Safari, all gradients look like the last case.

The fastest way to get back to an interoperable state would be to revert the Chrome change, so that all browsers do channel clipping, but ideally the behavior would only change once. I'm filing this issue as background reading for the breakout session planned on March 27.

@foolip foolip added the css-color-4 Current Work label Mar 21, 2024
@romainmenke
Copy link
Member

romainmenke commented Mar 21, 2024

If I recall correctly it should actually be displayed equivalent to linear-gradient(to right, black, oklab(0.5 0.15 0.15), white)

Screenshot 2024-03-21 at 11 07 46

Linear interpolation between oklab(0 0.15 0.15) and oklab(1 0.15 0.15) is oklab(0.5 0.15 0.15) It is not oklab(0.5 0 0).

oklab(0 0.15 0.15) should only be displayed as black as a result of gamut mapping.
It is not interpolated as if it were black. Same for white.

If correct, according to the specification, then it would be good to add this to the examples. Best if confirmed by others.

@romainmenke
Copy link
Member

romainmenke commented Mar 21, 2024

Also worth mentioning that the problems in implementations aren't limited to oklab, oklch, lab, lch.

linear-gradient(to right, color(display-p3 0 -1 0), color(display-p3 2 1 1))

Becomes really funcky:

Screenshot 2024-03-21 at 11 30 51

I know these specific values aren't mathematically around 0 or 100%, but these values are 0% and 100% lightness in sdr. This is all the same underlying problem. If you do not implement gamut mapping, various parts of the specification fall apart.

I don't think it is that interesting to investigate all the ways implementations are broken because they did not follow the specification.

If eventually it is decided to not do gamut mapping then we have to take a very large step back and revisit everything in css-color-4 and css-color-5 and try to get working features of these. So either we do get gamut mapping and this issue goes away, or we don't get gamut mapping and then we have a much larger issue.

@foolip
Copy link
Member Author

foolip commented Mar 21, 2024

My focus here is on the desired behavior of the syntax that has already shipped, and how to get back to an interoperable state ASAP. I understand that chroma reduction is one possible answer to the problem, but I'd like to discuss desired behavior of Oklab/Oklch/Lab/LCH independent of how it's achieved.

To visualize the space these gradients are in, here are Oklch hue slices where I've marked the line between oklab(0 0.15 0.15) and oklab(1 0.15 0.15) with a straight line. It's all outside the sRGB gamut, so the results depend...

With channel clipping:
Oklch slice at hue 45 with channel clipping

With chroma reduction:
Oklch slice at hue 45 with chrome reduction

(These are from https://foolip.github.io/okplay/slice/ with editing.)

My observations:

  • There are very visible lines in both cases, so gradients won't be smoothly varying
  • Channel clipping makes the space more HSV-like and chroma reduction makes it more HSL-like

The feedback I've heard from @argyleink is that the HSV-like effect of channel clipping makes it easier to make vibrant gradients, which makes sense since the top right will always be very saturated. It does result in visible hue shifts however.

@foolip
Copy link
Member Author

foolip commented Mar 21, 2024

If I recall correctly it should actually be displayed equivalent to linear-gradient(to right, black, oklab(0.5 0.15 0.15), white)

@romainmenke Do you know where this is spec'd? That wouldn't be a straight line in Oklab space, it would be two line segments.

@romainmenke
Copy link
Member

How is this not a straight line?

  • start oklab(0 0.15 0.15)
  • mid oklab(0.5 0.15 0.15)
  • end oklab(1 0.15 0.15)

Because gamut mapping (as specified) happens after, it is only then that oklab(0 0.15 0.15) becomes black and same for white.

That is also why I wrote displayed equivalent and did not write that that would be the actual or computed value.

@romainmenke
Copy link
Member

Channel clipping makes the space more HSV-like and chroma reduction makes it more HSL-like

The feedback I've heard from @argyleink is that the HSV-like effect of channel clipping makes it easier to make vibrant gradients, which makes sense since the top right will always be very saturated. It does result in visible hue shifts however.

Yes, it makes it easier, but gamut mapping doesn't make vibrant gradients impossible.

We really have to be careful to keep priorities in order here.
Needs of the end user outweigh those of the author, ...


There are very visible lines in both cases, so gradients won't be smoothly varying

Can you elaborate on the visible lines when using gamut mapping?

Do you have source code that could be verified by others.
Maybe there is a bug somewhere, would be good to rule that out :)

@argyleink
Copy link
Contributor

the HSV-like effect of channel clipping makes it easier to make vibrant gradients

it's also as screens get better, the gradient's vibrance gets better too. it feels like a future proofed vibrant gradient / color request, rather than one I have to hold its hand for each gamut increase.

it's nice to request a super vibrant color and let devices grow into it, define once instead of multiple times. the author intent is, i want a vibrant color for this screen, do what you can, and do it as vibrant as you can (since i asked for vibrance).

@foolip
Copy link
Member Author

foolip commented Mar 21, 2024

@romainmenke

How is this not a straight line?

I took linear-gradient(to right, black, oklab(0.5 0.15 0.15), white) to mean oklab(0 0 0) for black and oklab(1 0 0) for white.

Can you elaborate on the visible lines when using gamut mapping?

If you drag around the hue in https://foolip.github.io/okplay/slice/ with "reduce chroma" you'll be able to see a triangle shape moving around, which corresponds to the edge of the sRGB gamut.

The source for that demo is https://github.com/foolip/okplay/blob/main/slice/slice.js. I've also added Color.js as an option and it also has these effects. (The difference is that "reduce chroma" just bisects for 10 iterations which should get closer to the gamut hull than with the JND stop condition.)

@romainmenke
Copy link
Member

you'll be able to see a triangle shape moving around, which corresponds to the edge of the sRGB gamut.

I see it now :)

To me this feels more like a bug, something that could/should go away by improving the specified gamut mapping algorithm.

Whereas clipping is very crude and can easily give you weird results.
And clipping doesn't leave any room for improvement.

Gamut mapped:

Screenshot 2024-03-21 at 18 18 46

Clipped:

Screenshot 2024-03-21 at 18 18 39

@facelessuser
Copy link

I think you will always be able to pick out a triangle using this method, how could you not? There is a region where, suddenly, the chroma no longer changes. If you zoom out like this, how are your eyes not going to see this?

You are plotting in OkLCh, and any gamut in this space produces a triangle hue slice. Within that triangle, the chroma and lightness changes, but then outside the gamut, only lightness changes.

When you plot things like this, your eye will pick that out. Even if you smooth the edges, you'll just make a blurry triangle. We are zoomed so far out that we can see everything, and we want to tell our eyes to not see where the chroma stops changing, it just not possible.

@facelessuser
Copy link

I do think that better chroma reduction techniques can reduce this. I'm not sure if it can perfectly be eliminated. Especially when you take a large bird's eye view like this. It is easier to see the imperfections this far out.

@facelessuser
Copy link

As an example, here we use a little more accurate chroma reduction approach than CSS MINDE which reduces the triangle more, but we still get a triangle.

raytrace-red

But again, we are so zoomed out that we can easily pick out the changes. But let's take a slice, now it is hard to tell.

slice

Let's take another slice from the far right.

slice2

Doesn't look so bad now as we can't take everything in all at once.

@foolip
Copy link
Member Author

foolip commented Mar 22, 2024

The main question remains of what behavior is desirable at or close to 100%. That area is mostly real colors (bright and colorful) with physical meaning, although some imaginary colors around yellow-green too. I think the options are:

  • Whatever channel clipping results in, which is very saturated but can also have a visible hue shift. This is what has already shipped across browsers, modulo the special case at exactly 100% in Chrome.
  • Reducing only chroma to preserve lightness. This results in very desaturated colors to achieve that gradient to white a la HSL. The colors can be far from the closest possible by any kind of color distance metric.
  • Reducing only lightness to preserve chroma. Only sometimes possible. For imaginary colors, reducing lightness never reaches a real color, much less an in-gamut color.
  • Some combination of chroma and lightness reduction to give a saturated top right corner like HSV and similar to channel clipping.

To be clear, I'm not talking about gamut mapping in general, between all possible color spaces, just how Oklab and Lab should ideally behave. I'm also not sure of the options are practical to implement in practice in a browser engine.

@romainmenke
Copy link
Member

The specification has always been very clear on this.
At or above lightness of 100% is white and at or below lightness of 0% is black.

It used to be a result of clamping which led to many surprising results and discontinuities. Making it a result of gamut mapping was a better way of achieving the desired result.

So now we are questioning that original premise, that a lightness of 100% is white and a lightness of 0% is black. Right?

@foolip
Copy link
Member Author

foolip commented Mar 22, 2024

That the spec is clear doesn't by itself solve the problem at hand. oklab(1 0.25 0) describes a bright pink color. Assuming sRGB, what do we get?

  • The spec says oklab(1 0 0) as an effect of gamut mapping
  • Chrome gets oklab(1 0 0) because lightness 100% is special cased
  • Firefox and Safari get roughly oklab(0.85 0.12 -0.07) due to channel clipping

The answers are different just below 100%, for oklab(0.99 0.25 0):

  • The spec says roughly oklab(0.98 0.01 0) as an effect of gamut mapping
  • Chrome, Firefox and Safari get roughly oklab(0.84 0.12 -0.07) due to channel clipping

How do we get out of this situation?

@romainmenke
Copy link
Member

oklab(1 0.25 0) describes a bright pink color.

Does it?

The specification was always very clear that it should be displayed as white.
I am assuming that this wasn't a random choice?

Getting consensus on this seems like the most important first step :)

@foolip
Copy link
Member Author

foolip commented Mar 22, 2024

What the CSS syntax oklab(1 0.25 0) should do is the very question of this issue, I'm referring to what those coordinates mean in Oklab as defined in https://bottosson.github.io/posts/oklab/.

@facelessuser
Copy link

What the CSS syntax oklab(1 0.25 0) should do is the very question of this issue, I'm referring to what those coordinates mean in Oklab as defined in https://bottosson.github.io/posts/oklab/.

It is both a very colorful color, but also a very bright color, neither of which can be represented in the SDR gamuts we are talking about. In the SDR gamuts, it is an impossible color. Whatever it means, we can never capture it, we can only capture parts of it. You can extend the coordinates of the SDR gamut to allow round-trip calculations, but you will never be able to display it in that gamut.

The main question remains of what behavior is desirable at or close to 100%.

Well, what are you doing?

No matter what you do in terms of gamut mapping, you are wrong. All you can do is find the "best" possible color based on how you are working and what attributes you deem most important because you can't represent all the attributes of that color.

You can accurately represent the lightness in this case and preserve the integrity of the hue, sacrificing its chroma, which is what CSS currently recommends, and it gives you white, but yes, it is still wrong. This doesn't make white a special case, the SDR gamut cannot represent any colors with that lightness level except white. It's a shortcut that skips reducing the chroma, which will give you white anyway. Now, clamping it at parse time may be a special case.

You can try and show a darker color that will sacrifice hue and/or lightness to find the closest color that retains as much chroma as possible, it will also be wrong.

The point is, whatever you choose, you can never capture that color, only specific attributes of that color. Depending on how you are working with the color, some attributes of that color are more important to you. For contrast with text (a big CSS use case) or trying to create tones, the lightness attribute, preserving the true hue may be the most important.

In pictures, the closest color that has chroma close to the original (sacrificing both lightness and hue) may be more important as the colors need to maintain chroma relative to their neighbor pixels to be less jarring.

These are two different use cases, and neither solution is always better.

Consider Google's HCT color space in their Material Color Utilities. They've developed a color system to create palettes for apps and sites. For this to work, they wanted to have to have relatively predictable contrast and have decent hue preservation. They mashed together CIELab (D65) lightness, which provides good contrast between lightness levels, and used CAM16 chroma and hue, which while not the best at preserving hue, it does ok. When it is creating its tonal palettes, it is absolutely reducing chroma to gamut map those colors when you specify lightness levels for a color. That is how it builds its tonal palettes. I know this because I've been able to replicate HCT outside of their Material Color Utilities that expands its functionality to wide gamuts, while replicating its ability to return equivalent tonal palettes: https://facelessuser.github.io/coloraide/colors/hct/#tonal-palettes. It's the only way to give consistent hues and control lightness from destroying contrast.

Neither approach in terms of gamut mapping is wrong, but they provide different utility. Neither will capture the "true definition" of the color. As far as an SDR spec that wants to focus on text on background colors, reducing chroma makes the most sense (at least to me).

Now, how do you represent HDR colors? I feel this is getting into topics that should be in the HDR color spec. Not everyone will always want to represent HDR colors at all times.

@foolip
Copy link
Member Author

foolip commented Mar 22, 2024

@facelessuser certainly something needs to be sacrificed and there are tradeoffs. Unfortunately the answer cannot be "it depends", as oklab(1 0.25 0) can only do one thing by default. I agree that it should not express an HDR color which will be brighter on an HDR screen, it should be one specific SDR color that the spec and all implementations align on.

I've requested to discuss this topic in the breakout session March 27.

@facelessuser
Copy link

Unfortunately the answer cannot be "it depends", as oklab(1 0.25 0) can only do one thing by default.

The reality is that it does depend, and since only one thing can be done, you need to decide on what is most important and do that. That is my point. And CSS is trying to target CSS-defined colors, not images. These two things should not be confused.

@jamesnw
Copy link

jamesnw commented Mar 22, 2024

Here are some relevant issues where the meaning of colors with lightness at 0 or 1 were discussed to reach the current spec decisions-

#8794
#9651

@ccameron-chromium
Copy link

@facelessuser wrote:

The main question remains of what behavior is desirable at or close to 100%.

No matter what you do in terms of gamut mapping, you are wrong. All you can do is find the "best" possible color based on how you are working and what attributes you deem most important because you can't represent all the attributes of that color.

This is a very important observation. This is an "impossible problem", of the form "how do we make sense out of nonsensical inputs?".

The true plane of L=100% in oklch is something that is indeed not white. Here's an image of what this plane looks like next to the sRGB gamut (produced using this demo that I brought to show at TPAC to try to move this conversation forward). The background color of the image is white, and only the portions of the plane that can be produced on HDR P3 displays are shown. What is notable is that the L=100% plane is indeed bright and extremely colorful.
srgb-gamut-L1-plane

The true plane of L=0% in oklch consists entirely of imaginary colors (nonsensical color values that cannot ever be produced in reality), except at the point (0,0,0) (which is indeed black).

I would like to focus our efforts on providing a space like okhsl/okhsl-p3/okhsl-rec2020 as you've worked out over in #8659. In those spaces L=0% is indeed black and L=100% is indeed white. That way we can avoid nonsensical inputs, rather than encourage nonsensical inputs and then argue over how best to interpret nonsense inputs.

@facelessuser
Copy link

One thing to consider, Okhsl, while fine for selecting colors, is not a good color space to work in for things like gradients. There is still utility in using OkLCh, even if defining nonsense inputs is difficult.

Okhsl will keep you, roughly, in gamut if you operate in its bounds, but the useable results are not great. I've taken out GMA, and will just use naive clipping. This example shows the lack luster results of Okhsl is in regard to gradients. OkLCh just does better, and even if you only operate in the non-polar OkLab, you still go out of gamut at times, though admittedly less severe.

Example

Screenshot 2024-03-26 at 10 36 53 AM

@ccameron-chromium
Copy link

Agree, okhsl does have pathological behavior near blue, so it would need some work in order to be generally useful. If we want a parameter space that can be safely used in this way it will need some work to define (and I'm very eager for us to put work into this direction).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

6 participants