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] Computing lch/lab to srgb with lightness = 0% or 100% #8794

Closed
yiyix opened this issue May 4, 2023 · 23 comments
Closed

[css-color] Computing lch/lab to srgb with lightness = 0% or 100% #8794

yiyix opened this issue May 4, 2023 · 23 comments
Labels
Closed Accepted as Obvious Bugfix Closed as Question Answered Used when the issue is more of a question than a problem, and it's been answered. css-color-4 Current Work

Comments

@yiyix
Copy link

yiyix commented May 4, 2023

According to the spec, "The first argument specifies the CIE Lightness, L. This is a number between 0% or 0 (representing black) and 100% or 100 (representing white), Values less than 0% or 0 must be clamped to 0% at parsed-value time; values greater than 100% or 100 are clamped to 100% at parsed-value time"

With the current color conversion algorithm, both Chrome and Safari are still using a and b (or c and h) when lightness is at 0% or 100%. example: https://jsfiddle.net/orah1qd2/ lab(0, 100, 100) is showing as some shade of red instead of black.

I am trying to understand how the color conversion works for lightness at 0% or 100%.

  • Does it follow the current conversion algorithm and convert to White/Black when lightness is at 0% or 100%? The issue is it will not create a nice color spectrum and users might be surprised to see this when the color doesn't change with a and b (or c and h) for l at 0 (or 100).
  • Or our current color conversion algorithm is wrong, and we should rewrite and convert to black and white smoothly as lightness hit 0% or 100%? I think the issue here is that color spectrum would look very different.

Thank you for helping.

@svgeesus svgeesus added the css-color-4 Current Work label May 4, 2023
@facelessuser
Copy link

The requirement to force colors to white or black by setting chroma/hue and a/b to zero when the lightness is 0 or 100 during the conversion can seem jarring in these situations, and I wonder if such requirements are better suited in gamut mapping and not conversion. These requirements in conversion are essentially gamut mapping these extreme "lightness" cases, but nothing else.

I believe the idea is that when lightness is at these extremes, said lightness will essentially overpower any contribution to the color that chroma and hue are providing making them meaningless. While I don't disagree with this assertion, I do wonder if conversion is the right place to make these corrections.

I don't know if browsers intentionally or unintentionally have left out this CSS requirement, but I know that I've currently left out this requirement in my color library as the results feel surprising. Granted, my goal isn't necessarily to strictly follow what CSS does, but to simply handle CSS color formats as inputs and outputs.

Consider the example below. We interpolate between lch(0 100 100) and lch(100 100 100). The first does as browsers do and clips any out-of-gamut color. The second does as CSS suggests and sets chroma/hue (or a/b for lab) to zero if lightness is at its extremes. The last uses a more proper gamut mapping.

Screenshot 2023-05-05 at 6 27 53 AM

We can see that just clipping, as browsers currently do, gives a consistent transition, but does not resolve these extremes to white or black. With the added requirement by CSS (which browsers are not currently doing), we see that we get a jarring discontinuity in color. Lastly, when using proper gamut mapping, we will naturally resolve these extreme cases as black or white and provide a more consistent transition to them.

For the above reasons, I wonder if such resolutions of colors should be left solely up to the gamut mapping method being used. I do admit that there may be non-obvious reasons (to me) why CSS desires these additions.

@mysteryDate
Copy link

mysteryDate commented May 9, 2023

I think the underlying issue is that there are two versions of the spec. https://drafts.csswg.org/css-color/#specifying-lab-lch makes no mention of a and b being powerless, but https://www.w3.org/TR/css-color-4/#specifying-lab-lch (the official version) does:

If the lightness of a Lab color is 0%, or 100% both the a and b components are powerless and the color represents black, or white, respectively.

That seems pretty clear to me that this is a special case.

Testcases are missing in wpt.

@svgeesus
Copy link
Contributor

svgeesus commented May 9, 2023

Yes the /TR version needs to be republished, there have been a lot of changes since the last one (and that list is not fully up to date) list now updated.

@svgeesus
Copy link
Contributor

svgeesus commented May 9, 2023

The requirement to force colors to white or black by setting chroma/hue and a/b to zero when the lightness is 0 or 100 during the conversion can seem jarring in these situations, and I wonder if such requirements are better suited in gamut mapping and not conversion.

That might indeed be a better way to express the requirement. Assuming that gamut mapping (rather than naive clipping) happens for display, so that there is consistency in color appearance on displays of different gamuts.

@pfaffe
Copy link

pfaffe commented May 10, 2023

Looks like I misread the spec, sorry for the noise. The latest version doesn't say a and b are powerless, but that they must be set to 0, which is even stronger.

I wasn't aware of the changes in the latest version. However, the lightness spec looks ambiguous now, it now says both:

The first argument specifies the CIE Lightness, L. This is a number between 0% or 0 (representing black) and 100% or 100 (representing white) [...]

If the lightness of a Lab color (after clamping) is 0%, or 100% both the a and b components are 0 and the color represents black, or white, respectively

@yiyix
Copy link
Author

yiyix commented May 10, 2023

I believe the idea is that when lightness is at these extremes, said lightness will essentially overpower any contribution to the color that chroma and hue are providing making them meaningless. While I don't disagree with this assertion, I do wonder if conversion is the right place to make these corrections.

I second that, I believe conversion may not be right place to make these corrections.

Furthermore, adding this requirement also contradicts with the following test:
https://github.com/web-platform-tests/wpt/blob/master/css/css-color/xyz-d50-004.html
What would be the lab representation of color(xyz-d50 0 1 0), if not lab(100% -431.0345 172.4138)?

Could we change the spec to make it more clear?

@facelessuser
Copy link

facelessuser commented May 10, 2023

What would be the lab representation of color(xyz-d50 0 1 0), if not lab(100% -431.0345 172.4138)?

If the powerless logic in CSS is to be followed, I imagine that the Lab value for this would be lab(100% 0 0). Keep in mind that once a color is that bright, you really aren't seeing the chroma, just the lightness. If you clip that color in sRGB, you may get a color, but that is just because you are loosing color information in the clip. The reality is that these high chroma colors are out of gamut, and I agree that the most appropriate "in gamut" color for this is white. What I find odd about the CSS requirement is that it imposes a partial gamut correction, but only when lightness is 0 or 1, and before the final, displayable color is ready to be gamut mapped/clipped. I guess, my feeling is simply why bother correcting in these two scenarios, but nowhere else? This is really a gamut problem which is why I think leaving it to gamut mapping/clipping makes the most sense instead of a partial solution in the conversion algorithm.

I also don't think it completely harmless if we consider some interpolation cases. Consider this example where we attempt to interpolate only lightness of two Lab colors in Oklab. In the first example, we do not enforce the CSS powerless requirement during conversion and get a colored gradient (gamut mapping the final results), but if we use the CSS requirement, we get a grayscale interpolation.

interpolate

If we impose this requirement now, will it be relaxed when/if we get true gamut mapping (if not in level 4 maybe Level 5)? Or once it is in the spec, will it remain for backwards compatibility? Do we even care about the above interpolation use cases? While I personally think it makes sense to keep a separation between conversion and gamut mapping/clipping, this may not be CSS's goal, but I figured it is worth bringing up as a talking point before a decision is forever in place.

In reality, this is probably a very minor issue.

@svgeesus
Copy link
Contributor

What I find odd about the CSS requirement is that it imposes a partial gamut correction, but only when lightness is 0 or 1, and before the final, displayable color is ready to be gamut mapped/clipped. I guess, my feeling is simply why bother correcting in these two scenarios, but nowhere else?

I agree, the discontinuity (L=0 vs. L=0.01, etc) is not helpful and the desired result would occur because of gamut mapping to the display in any case.

@tabatkins
Copy link
Member

Yes, the discontinuity of powerlessness has been brought up before. It's an unfortunate but unavoidable consequence, if you want to keep solving the problems that powerlessness is designed to solve (which is: don't hallucinate a component value that the author didn't and couldn't state in the original color space, just because a conversion shifted the color into a space where that component requires a value.

@svgeesus
Copy link
Contributor

Powerlessness of hue angle applies to all achromatic colors though, not just white and black.

@yiyix
Copy link
Author

yiyix commented May 15, 2023

What I find odd about the CSS requirement is that it imposes a partial gamut correction, but only when lightness is 0 or 1, and before the final, displayable color is ready to be gamut mapped/clipped. I guess, my feeling is simply why bother correcting in these two scenarios, but nowhere else?

I agree, the discontinuity (L=0 vs. L=0.01, etc) is not helpful and the desired result would occur because of gamut mapping to the display in any case.

It seems that we agree that forcing the color to be black or white is not a desired user experience. Could we change the spec to reflect it?

The current spec is as follows:

The first argument specifies the CIE Lightness, L. This is a number between 0% or 0 (representing black) and 100% or 100 (representing white), Values less than 0% or 0 must be clamped to 0% at parsed-value time; values greater than 100% or 100 are clamped to 100% at parsed-value time.

Could we change to something like:

The first argument specifies the CIE Lightness, L. This is a number between 0% or 0 and 100% or 100, values less than 0% or 0 must be clamped to 0% at parsed-value time; values greater than 100% or 100 are clamped to 100% at parsed-value time.

I found lab(100%, 0, 0)/lch(100%, 0, 0) maps to white and lab(0%, 0, 0)/lch(0%, 0, 0) maps to black is explained later in the spec: (https://drafts.csswg.org/css-color/#specifying-lab-lch)

If the lightness of a Lab color (after clamping) is 0%, or 100% both the a and b components are 0 and the color represents black, or white, respectively.

@mysteryDate
Copy link

mysteryDate commented Jul 27, 2023

Yes, the discontinuity of powerlessness has been brought up before. It's an unfortunate but unavoidable consequence, if you want to keep solving the problems that powerlessness is designed to solve (which is: don't hallucinate a component value that the author didn't and couldn't state in the original color space, just because a conversion shifted the color into a space where that component requires a value.

Is this to say that this powerlessness is really only an issue for conversions into the color spaces with lightness as a component? It's an interesting thing to try to design around, in my opinion, because there are many different whites that a user could be trying to express:

color(srgb 1 1 1) = lab(99.9988 0.0188053 -0.00110865) or lch(99.9988 0.0188379 356.626)
color(xyz-d65 1 1 1) = lab(100 9.06837 5.79284) or lch(100 10.7607 32.5703)
color(xyz-d50 1 1 1) = lab(100 6.11317 -13.2363) or lch(100 14.5798 294.79)

lab(100 0 0) is yet a different white, quite similar to srgb white, but not quite. For the xyz whites the way they can exist in the lab/lch color spaces is with non-zero a, b, and c values. If a user asks for xyz-d65 in lch, it doesn't seem to me that the c value is "hallucinated".

Also, what is to be done for intermediate values? Should a and b be zeroed out before interpolation, or at the end result?

color-mix(in lab, color(xyz 1 1 1), black) = lab(50 0 0) or lab(50.0576 4.53418 2.89642) ?

@svgeesus
Copy link
Contributor

svgeesus commented Jul 27, 2023

Is this to say that this powerlessness is really only an issue for conversions into the color spaces with lightness as a component?

It is primarily an issue for conversions into a space with hue as a component.

color(srgb 1 1 1)

I get lab(100 -0.000008 0.0000068) or lch(100 0.0000103 0) where that final 0 hue is actually computed as NaN.

color(xyz-d65 1 1 1)

I'm getting lab(100.11544 9.0644801 5.8017679) and lch(100.11544 10.762217 32.621488) which is similar to yours. Chromatic adaptation is the reason for that.

color(xyz-d50 1 1 1)

I get lab(100 6.0964191 -13.2359) and lch(100 14.572419 294.73067) so given the D50 I am surprised the chroma is that high.

@mysteryDate
Copy link

It is primarily an issue for conversions into a space with hue as a component.

So is there a particular reason to also single out a and b for lab?

For the conversions, I'm just using chromium. What are you using? I'm getting almost exactly the same results in FF and Safari, with the exception of the lch hue for srgb white, actually:

https://codepen.io/mysterydate/pen/QWJJXOX

For the implementation, should a and b become zero at parsed/computed or used time?

@yiyix
Copy link
Author

yiyix commented Aug 4, 2023

@svgeesus

I am a bit confused. As color(xyz-d50 1 1 1) is mapped to lab(100 6.0964191 -13.2359), does it mean is mapped to outside of the lab color space color gamut and appear white on screen?

For color-mix with values outside of gamut, how does it work? ex: color-mix(in hsl, lab(0 104.3 -50.9) 100%, rgb(0, 0, 0) 0%) should return color(srgb 0.351376 -0.213938 0.299501) or color(srgb 0 0 0)?

Thank you again for the clarification.

@svgeesus
Copy link
Contributor

I am a bit confused. As color(xyz-d50 1 1 1) is mapped to lab(100 6.0964191 -13.2359), does it mean is mapped to outside of the lab color space color gamut and appear white on screen?

lab doesn't really have out of gamut as a concept, although you can certainly specify colors that are not physically realizable.

It displays as white on screen because the lightness is 100.

@svgeesus
Copy link
Contributor

(I suspect there may be an assumption that color(xyz-d65 1 1 1) is white. It isn't; that would be color(xyz-d65 0.95046 1 1.08906). )

@mysteryDate
Copy link

For the implementation, should a and b become zero at parsed/computed or used time?

Looks like the language in the spec changed again?

"""
If the lightness of a Lab color (after clamping) is 0%, or 100% the color will be displayed as black, or white, respectively due to gamut mapping to the display.
"""

So this would imply that the mapping should be done at used value time?

@svgeesus
Copy link
Contributor

Yes, this doesn't affect the computed value and we are avoiding clamping so that colors round-trip without loss. So yep, used value.

@svgeesus
Copy link
Contributor

Looking around this thread for points which have not already been addressed, or where the spec has not already been updated, I wanted to comment on

Is this to say that this powerlessness is really only an issue for conversions into the color spaces with lightness as a component? It's an interesting thing to try to design around, in my opinion, because there are many different whites that a user could be trying to express:

color(srgb 1 1 1) = lab(99.9988 0.0188053 -0.00110865) or lch(99.9988 0.0188379 356.626)
color(xyz-d65 1 1 1) = lab(100 9.06837 5.79284) or lch(100 10.7607 32.5703)
color(xyz-d50 1 1 1) = lab(100 6.11317 -13.2363) or lch(100 14.5798 294.79)

lab(100 0 0) is yet a different white, quite similar to srgb white, but not quite.

There aren't many different whites; sRGB white is the same as P3 white or rec2020 white, or media white in any SDR colorspace that has a D65 whitepoint.

color(srgb 1 1 1) is oklab(100% 0 0) or oklch(1 0 none) or color(xyz-d65 0.95046 1 1.08906) - note that white in CIE XYZ is not "1, 1, 1". The conversions quoted above seem to have some small numerical inaccuracies which it would be worth chasing down as small errors can compount when many conversions are chained together.

Problems arise when chromatic adaptation comes into play, because now we are computing corresponding colors (a color that looks the same as the original color did, after our eyes have adapted to a new white point). This affects prophoto-rgb which has a D50 white point, and also CIE Lab and LCH which also conventionally use a D50 white point due to their printing industry background. (This is another reason why operations like interpolation or gamut mapping are better done in Oklch rather than CIE LCH, it avoids two chromatic adaptation steps in the intermediate calculations). Note too that in a printing situation, our eyes are indeed adapted to D50 because the print to be evaluated will be in a viewing booth with lights which are exactly D50; and if there is a soft proof then the monitor displaying it will also have been adjusted to display a D50 white. On the web, the white of the page is D65 and that is what our eyes are adapted to; our eyes are not adapted to D50.

Given those caveats,

color(srgb 1 1 1) is, to 6 significant figures, still color(prophoto-rgb 1 1 1) and to full precision the coordinates are [0.9999999886663737, 1.0000000327777285, 0.9999999636791804].

color(srgb 1 1 1) is, to 6 significant figures, lab(100 0 0.00001) or to full precision [00.00000139649632, -0.000007807961277528364, 0.000006766250648659877] and in LCH it is lch(100 0.00001 none) or to full precision [0.9999999934735462, 3.727399553519285e-8, NaN].

So the chromatic adaptation steps there have introduced a small error, but only visible in the numbers at 8 significant figures.

Looking now at the color(xyz-d65 1 1 1) which was mentioned, it is not a white and is out of gamut for all D65-based RGB color spaces (for example, it is rgb(108.523% 97.6912% 95.8708%) or color(rec2020 1.05174 0.9828 0.95795). Gamut mapping for display will pull that much closer to white, though.

Looks like the language in the spec changed again?

Yes, because there was agreement that those colors displaying as white or black was a result of gamut mapping to the display so the spec changed to say that explicitly.

"""
If the lightness of a Lab color (after clamping) is 0%, or 100% the color will be displayed as black, or white, respectively due to gamut mapping to the display.
"""

So this would imply that the mapping should be done at used value time?

Yes. See step 7 in converting colors. The only stage in color conversions where gamut mapping happens is now for actual display (previously it was also used for conversions into HSL or HWB); all other stages of color conversions aim to preserve lossless round-tripping.

@svgeesus
Copy link
Contributor

svgeesus commented Nov 16, 2023

I am a bit confused. As color(xyz-d50 1 1 1) is mapped to lab(100 6.0964191 -13.2359), does it mean is mapped to outside of the lab color space color gamut and appear white on screen?

color(xyz-d50 1 1 1) is lab(100 6.09642 -13.236). Lab doesn't have a gamut boundary. But we also don't have Lab displays so, for display on an RGB monitor, as that color is out of gamut for the monitor it will be gamut mapped. For example on a P3 monitor, color(display-p3 1.00713 0.98792 1.09289) is out of gamut. The gamut mapping is in Oklch, so we have oklch(1.00362 0.03919 295.358) and looking at step 3 of the gamut mapping algorithm:

if the Lightness of origin_Oklch is greater than or equal to 100%, return { 1 1 1 origin.alpha } in destination

so we return color(display-p3 1 1 1) which is white.

For color-mix with values outside of gamut, how does it work? ex: color-mix(in hsl, lab(0 104.3 -50.9) 100%, rgb(0, 0, 0) 0%) should return color(srgb 0.351376 -0.213938 0.299501) or color(srgb 0 0 0)?

lab(0 104.3 -50.9) is a very high chroma but is as light as black. In HSL (which has a D65 white point as it is derived from sRGB) it will be hsl(305.51 411.277% 6.87231%) and the computed value is in sRGB which is color(srgb 0.351365 -0.2139 0.299453).

But if we try to display that color it is out of gamut on an sRGB screen (indeed, on any screen) so we convert to Oklch for gamut mapping: oklch(0.18314 0.3973 336.648) and the lightness is not over 1 or under 0 so we gamut map it. Notice the lightness has gone up to 18% as a result of chromatic adaptation, in Lab it is, after all these conversions, lab(0.00004 104.3 -50.9) which is super close to the originally specified color.

@facelessuser
Copy link

color(srgb 1 1 1) is, to 6 significant figures, still color(prophoto-rgb 1 1 1) and to full precision the coordinates are [0.9999999886663737, 1.0000000327777285, 0.9999999636791804].

color(srgb 1 1 1) is, to 6 significant figures, lab(100 0 0.00001) or to full precision [00.00000139649632, -0.000007807961277528364, 0.000006766250648659877] and in LCH it is lch(100 0.00001 none) or to full precision [0.9999999934735462, 3.727399553519285e-8, NaN].

This inaccuracy is due to a less precise Bradford inverse matrix. I go into more details here: color-js/color.js#352 (comment). I plan to issue a PR for color.js when I get a chance.

@svgeesus
Copy link
Contributor

The Oklab matrices are now 64bit accurate and were copied over from this color.js PR

Bradford matrices were updated as a result of

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Closed Accepted as Obvious Bugfix Closed as Question Answered Used when the issue is more of a question than a problem, and it's been answered. css-color-4 Current Work
Projects
None yet
Development

No branches or pull requests

6 participants