w3c / csswg-drafts

CSS Working Group Editor Drafts
https://drafts.csswg.org/
Other
4.52k stars 673 forks source link

[css-color-4] Are a and b in lab( 0%, a, b ) really powerless? [css-color-4] #6758

Closed dd8 closed 2 years ago

dd8 commented 3 years ago

If the lightness of a Lab color is 0%, both the a and b components are powerless

https://drafts.csswg.org/css-color-4/#specifying-lab-lch

Is this true, or is this implementation dependent when the Lab color is mapped to the display color space.

lab( 0%, -100, 100 ) produces an out of gamut sRGB color, which is mapped to a dark color (but not the same dark color) when converted using various convertors:

http://www.brucelindbloom.com/iPhone/ColorConv.html http://colorizer.org/ https://rufflewind.com/_urandom/colorpicker/

color.js does map lab( 0%, -100, 100 ) to rgb(0,0,0)

Assuming gamut mapping is defined for CSS (https://github.com/w3c/csswg-drafts/issues/5191) won't there still be an implementation dependency if conversion uses ICC profiles? They don't guarantee identical results for out of gamut colors: https://drafts.csswg.org/css-color-4/#oklab-lab-to-predefined

svgeesus commented 3 years ago

That will, as you say, depend on the gamut mapping algorithm. Unfortunately the three converters you linked to don't state how they map out of gamut colors. They may simply be clamping out of range components.

Using color.js, which uses constant-hue constant-lightness reduction in LCH Chroma, I get the oog color color(srgb -0.2519 0.16198 -0.306) which gamut maps to rgb(0% 0.465% 0%) which is very nearly, but not quote, pure black. The deltaE200 is 0.75, so it is visually indistinguishable from pure black.

The gamut mapping section is indeed still to be written but we have resolved

  1. To do Chroma reduction in OKLCH
  2. To use the powerless concept.

Taking those one at a time: lab(0% -100 100) in OKLCH is oklch(-17.24% 0.29655 146.962) which is indeed oog as color(srgb -0.2519 0.16197 -0.306). Interesting that we get a negative OKLCH Lightness!

With the powerless concept, on color conversion powerless components become missing which means they are treated as 0, so we are converting lab(0% 0 0) which is pure black, and is oklch(0 0 0) to six significant figures.

You mention ICC profiles and the most likely reason an implementation would use them is to convert to CMYK (or CMYKOGV). In that case, lab(0% -100 100) would map to a lighter color than black, but so would lab(0% 0 0) simply because ink-based spaces cannot produce an infinite density black.

dd8 commented 3 years ago

Ok, that makes sense if I've understood - the powerless concept is used to normalise all the lab( 0% a b ) blacks to lab( 0% 0 0 ) which then converts to pure black and avoids the transform matrices adding in non-zero a and b components and producing nearly black.

Another good reason to use ICC profiles is if they're hardware accelerated. macOS has methods to access GPU accelerated conversions: https://developer.apple.com/documentation/coregraphics/cgcolorconversioninfo?language=objc If image colors are converted in GPU hardware using ICC profiles, an implementor might want to convert CSS colors using the same ICC profiles for rendering consistency.

Finally this might be useful - the Safari color space conversion code: Mostly they have their own implementations for color space conversions, but for some cases they fall back to macOS Core Graphics: https://github.com/WebKit/WebKit/blob/main/Source/WebCore/platform/graphics/ColorConversion.cpp https://github.com/WebKit/WebKit/blob/043c907c97abdb8ebee06c6e3830f3165a7c6de2/Source/WebCore/platform/graphics/cg/ColorCG.cpp#L213

tabatkins commented 3 years ago

Powerless components becoming zero is an incidental effect; it's not meant to mean anything, they just become missing in certain circumstances (such as color space conversions) and we need to render something for missing components if such a color is rendered directly (which is an error on the author's part). Substituting in a zero is just the most obvious "default value" to sub in for this, and since this is an error case in the first place, it doesn't really matter what we choose. If that ends up becoming convenient for implementations for some reason, great, but it's not the point.

The point of powerless/missing components is to give good transition/animation behavior, so authors don't have to provide dummy values for the components that don't affect the starting color, and instead they get the most obvious "correct" behavior by default of taking the missing component from the other side, similar to what premultiplied alpha provides for rectangular coordinate systems.

Without this behavior, animating from lch(50% 50 180deg) to black would give frustrating results: black would (correctly) convert into lch(0% 0 0deg), then the animation would show a greenish color darkening (expected, good), desaturating (may or may not be expected), and wildly swinging hue around to red (definitely not expected, very bad); to work around it authors would have to manually create a "green black" like lch(0% 0 180deg). This was the case in sRGB before we specified premultiplied alpha, too; authors would have to create a "correctly-colored transparent" to have a good gradient or animation that didn't go grayish as the color faded.

svgeesus commented 3 years ago

@dd8 does that answer your question?

dd8 commented 3 years ago

Yes, excellent answer - thanks both.

We're implementing lab() / lch() support in our CSS engine. Most stuff straightforward, but a couple of things looked like errors in our implementation until I watched your presentation. The light bulb moment was this, particularly the bit about math:

Out of gamut just means that a particular color can't be displayed (one of the components is below zero, or a bit above maximum). This can still be a real color, and you can still do math (like mixing) with this color, it is just out of range of a particular device.

There is wording along these lines in the color() section, but I think this is more general than just the color() function so maybe better in the terminology section? Also, if out-of-gamut colors can be produced that don't have physical meaning on any display it's worth mentioning that (I can easily imagine a lightness greater than 100% of the white point, but have a much harder time with lightness less than 0%)

svgeesus commented 3 years ago

There is wording along these lines in the color() section, but I think this is more general than just the color() function so maybe better in the terminology section?

I went looking for this but don't see it. Could you give me a quote I can search for?

I agree putting that sort of explanation into the terminology section would be helpful.

dd8 commented 3 years ago

The text which seems more generally applicable is:

Any color which is not an invalid color is a valid color.

A color may be a valid color but still be outside the range of colors that can be produced by an output device (a screen, projector, or printer). It is said to be out of gamut for that color space.

An out of gamut color has component values less than 0 or 0%, or greater than 1 or 100%. These are not invalid; instead, for display, they are gamut-mapped using a relative colorimetric intent which brings the values within the range 0/0% to 1/100% at computed-value time.

https://drafts.csswg.org/css-color/#color-function

A few more examples of out-of-gamut colors would be very useful - especially if they're the result of mapping from in-gamut colors in other color spaces - and produce results that seem intuitively wrong:

lab( 0%, -100, 100 ) = oklch(-17.24% 0.29655 146.962)

For me "intuitively wrong" happens with color components that are related to physical concepts like lightness. On the other hand, I have absolutely no expectations about color angles, since I can't relate them to anything physical (but that might not be true for other people).

svgeesus commented 2 years ago

Re-opening as there is a suggestion for better wording, which I missed because this issue was closed.

svgeesus commented 2 years ago

@dd8 wrote:

lab( 0%, -100, 100 ) = oklch(-17.24% 0.29655 146.962)

OK let's pick that one apart to see what is happening. The a,b values put it off in the corner of the a,b plane so it is a highly saturated color, and L is 0 so this is the same lightness as black. (This color is not physically realizable, and is also outside the spectral locus) so we can't see it, either. But we can reason about it.

First lets set the lightness to 50% so we can see what color it is. OK it is a very saturated green, outside the gamut of sRGB, and display-p3, and even Rec.2020 and ProPhoto-RGB. In OKLCH it is oklch(53.87% 0.315 148.4) so lets see if that hue angle is preserved.

Now change the Lightness to 1% and see what we get.

Remembering that Lab is adapted to a D50 whitepoint while most RGB spaces use D65, lets see the D65 value:

lab(1%, -100, 100) = lab-d65(0.628% -109 100.6)

Notice that as a consequence of chromatic adaptation, a and b no longer have equal values, the ab plane has tilted. And because this color is off in the corner, that tilt has reduced the Lightness as well.

Now the same for lab(0%, -100, 100) = lab-d65(-0.37% -109 100.6)

The tilt has actually made the Lightness negative. And, given that OKLab is also D65, we can see why we get a negative Lightness there, too. oklch(-17.2% 0.297 147) and the hue angle is very close to what we got before so seems legit.

(Remember that OKLab exposes some deviations from perceptual uniformity in CIE Lab, particularly for highly-saturated colors).

dd8 commented 2 years ago

Example 4 in [https://github.com/w3c/csswg-drafts/commit/a8b37be95e8a131b7497923215ec5dd48f911084] was exactly what I was looking for.

The explanation for lab( 0%, -100, 100 ) = oklch(-17.24% 0.29655 146.962) was excellent.

Thanks!