w3c / csswg-drafts

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

[css-color] gray() function produces a warm, D50 white #4622

Closed svgeesus closed 4 years ago

svgeesus commented 4 years ago

The gray() function is defined in terms of CIE Lab, with the a and b chromatic axes set to 0. In the spec currently, Lab is defined relative to a D50 whitepoint, in accordance with typical (print) industry practice and for compatibility with most spectrophotometer default settings.

In consequence, gray(100) will give an Lab value of 100 0 0

D50 = [100, 0, 0];  // Lab 100 0 0
Array(3) [ 100, 0, 0 ]
D50_XYZ = Lab_to_XYZ(D50);  //Convert to CIE XYZ
Array(3) [ 0.96422, 1, 0.82521 ]
D50_XYZ_D65 = D50_to_D65(D50_XYZ); // Bradford chromatic adaptation transform from D50 to D65
Array(3) [ 0.9504700036079999, 1.0000000624270002, 1.088830036462 ]
D50_RGB = gam_sRGB(XYZ_to_lin_sRGB(D50_XYZ_D65));  // convert to sRGB
Array(3) [ 0.9999999809955643, 1.000000034987241, 1.0000000100781432 ]

this gives an sRGB value of rgb(99.99999809955643% 100.0000034987241% 100.00000100781432%) which will be clipped on g and b to 100% but is a warm white.

Converting the D65-adapted white back to Lab:

wrong = XYZ_to_Lab(D50_XYZ_D65);  // convert unadapted XYZ to Lab
Array(3) [ 100.00000241384396, -2.388102770071454, -19.362232628332656 ]

The difference is certainly noticeable (ΔE 2000 is 13.64).

jrus commented 4 years ago

but is a warm white.

1 part in 20 million doesn’t seem like an unreasonable rounding error. All three of those numbers are going to round to 255/255 (or whatever). For some values of L* you’ll get unlucky in quantization from floats to 8-bit integers and end up with one channel rounding up and another rounding down, but occasional stray pixels where RGB channels are off by 1 shouldn’t be too noticeable in practice.

If you want to take a shortcut and avoid stray rounding errors you could explicitly implement a univariate function taking in L* and spitting out three identical values for sRGB.

Converting the D65-adapted white back to Lab:

You didn’t do the reverse chromatic adaptation from D65 back to D50 so this is expected.


Overall the topic title here seems inaccurate, unless you’re commenting on some concrete (buggy) implementation.

jrus commented 4 years ago

If you want to short circuit it and avoid stray rounding errors you could explicitly implement a univariate function taking in L* and spitting out three identical values for sRGB.

To be explicit, you can first convert L* to XYZ Y (still D50 whitepoint), then you can combine the steps of taking Y ↦ XYZ, i.e. Y ↦ [0.34567Y / 0.35850, Y, 0.29583Y / 0.35850], whatever chromatic adaptation matrix you like, and a conversion from XYZ ↦ RGB; this can be distilled down to a single scalar to multiply by the D50 Y value to get all three of the D65 RGB values. Finally you can apply your RGB gamma function.

Edit: thinking a bit about it, for purely neutral values the combination of Y ↦ XYZ and the chromatic adaptation transformation is going to be the same as just pretending your Y value was D65 to begin with, and choosing the appropriate X and Z based on that. Then since our new white point is the white point for the RGB space, converting to R, G, or B is just a multiplication by 1.

So in short: this transformation boils down to 2 steps:

  1. Apply the nonlinear transformation L* ↦ Y
  2. Letting linear R = G = B = Y from step 1, apply the gamma encoding to convert RGB ↦ R′G′B′.

We can write this in JavaScript as:

function gamma (R) {
  return (
    (R > 0.0031308) * (1.055 * Math.pow(R, 0.4166666666666667) - 0.055) +
    (R <= 0.0031308) * 12.92 * R);
}

function Lstar_to_Y(L) {
  return (
    (L > 8) * 6.406576735413506e-7 * (L + 16) * (L + 16) * (L + 16) +
    (L <= 8) * 1.1070564598794537e-3 * L);
}

function gray_to_sRGB (L) {
  const Rg = gamma(Lstar_to_Y(L));
  return [Rg, Rg, Rg];
}
tabatkins commented 4 years ago

Sorry to say this in a way that is going to sound rude, but: what's your point here, Chris? ^_^

As @jrus says, the deviation here from pure sRGB white is ridiculously small, far below the threshold which would produce rendering differences.

Even if it did produce something that would round to a unit of difference between the channels, is that an issue?

jrus commented 4 years ago

It might however be worth explicitly pointing out in the spec that you can simplify this down to two nonlinear functions applied to a single value and then spread across three outputs, cutting out the matrix multiplications and redundant gamma function invocations.

svgeesus commented 4 years ago

Sorry to say this in a way that is going to sound rude, but: what's your point here, Chris? ^_^

My points were twofold:

svgeesus commented 4 years ago

So in short: this transformation boils down to 2 steps:

Apply the nonlinear transformation L* ↦ Y Letting linear R = G = B = Y from step 1, apply the gamma encoding to convert RGB ↦ R′G′B′.

That is a transformation in XYZ, which is a von Kries chromatic adaptation.

tabatkins commented 4 years ago

My points were twofold:

kk, these are reasonable then. I'm still somewhat curious about how a deviation from triple-100% by ~a millionth of a percent is something to worry about, but if it's just a technical bugaboo you want to fix, sounds fine.

Does the D65 white give exactly-100% answers?

svgeesus commented 4 years ago

Does the D65 white give exactly-100% answers?

Yes.

smfr commented 4 years ago

If we change gray() to use a D65 white point, are we also going to change lab() and lch() to use the same white point?

svgeesus commented 4 years ago

Yes, we would.

My only hesitation is that spectrophotometers give an Lab readout relative to a D50 white (as they are primarily used in the print industry, and by designers working with print, which has had reliable color management for a couple of decades).

I have asked a contact at X-Rite whether their spectros can be optionally set to give a D65 readout. I'm currently traveling and don't have access to my i1Pro 2 to try it out.

jrus commented 4 years ago

If we change gray() to use a D65 white point, are we also going to change lab() and lch() to use the same white point?

It doesn’t matter either way as far as gray() per se is concerned. The end result of gray() in any arbitrary whitepoint composed with a Bradford-style CAT is going to result in exactly the same output (except for trivial rounding errors on the order of whatever rounding you applied to your matrix coefficients).

That was the point of my previous comment.

I would consider adding a comment in the spec explaining that if desired implementors can implement gray() in just one channel by doing an L*-to-Y transformation (scaled so Y ranges from 0..1 rather than 0..100) composed with the sRGB gamma transformation, and then spread that result across all three RGB channels.

The following unfancy javascript code will yield the output of gray() relative to any white point you like:

function gamma (R) {
  return (
    (R > 0.0031308) * (1.055 * Math.pow(R, 0.4166666666666667) - 0.055) +
    (R <= 0.0031308) * 12.92 * R);
}

function Lstar_to_Y(L) {
  return (
    (L > 8) * 6.406576735413506e-7 * (L + 16) * (L + 16) * (L + 16) +
    (L <= 8) * 1.1070564598794537e-3 * L);
}

function gray_to_sRGB (L) {
  const Rg = gamma(Lstar_to_Y(L));
  return [Rg, Rg, Rg];
}

My points were twofold: its wrong, and undesirable, and doesn't give rgb(100% 100% 100%) at maximum

It is not worth worrying about rounding errors this trivial in a context of color reproduction. None of the systems involved are anywhere close to precise enough for a rounding error of 1 part in 20 million to be meaningful. Even in the vanishingly rare case that subsequent quantization makes one 8-bit channel differ by 1 from the other two channels, the difference is not going to be apparent to human viewers.

But again, if you care about this, you can easily add a recommendation like my code above to the spec.

That is a transformation in XYZ, which is a von Kries chromatic adaptation.

I do not understand what you mean with this comment. Your current CSS spec of both lab and gray directly recommends using the Bradford CAT, a “von Kries” transformation.

If you skip the Bradford CAT and just apply the CIELAB model to un-adapted XYZ values taken relative to multiple different white points and then try to compare them, that includes within the CIELAB model itself (in the division X/Xn, etc.) what is called a “wrong von Kries” transformation, which will have very poor results, as you discovered for yourself in your original post. The lesson is: don’t ever do that.


Inre the tangential question of whether CIELAB color modes should be done relative to D65 or D50 whitepoint, with colors taken relative to other white points being transformed to that target whitepoint via a Bradford-style CAT:

Using a D65 whitepoint for CIELAB mode is the most sensible if you only care about screens and don’t care about interoperability with other code. For the dominant use cases of screen-to-screen types of transformations this will slightly simplify your code.

If you care about interoperability with existing ICC profiles, Photoshop, print workflows, hardware spectrophotometers, etc., then you should use a D50 whitepoint.

Either choice seems defensible to me.

css-meeting-bot commented 4 years ago

The CSS Working Group just discussed gray() function produces a warm, D50 white.

The full IRC log of that discussion <dael> Topic: gray() function produces a warm, D50 white
<dael> github: https://github.com/w3c/csswg-drafts/issues/4622
<dael> chris: When I wrote the topic I thought it was worse than it is. The thing you get is round off error which is as TabAtkins said one part in a millino. I should close. I think I was unnec worried
<dael> smfr: Agree. All references use D50 so not using would cause confusion
<dael> chris: If it's ICC workflow it would cause confusion. ICC5 allows more but not widely deployed
<dael> chris: I'll close no change
svgeesus commented 4 years ago

(except for trivial rounding errors on the order of whatever rounding you applied to your matrix coefficients).

Yes, the rounding errors are what I was seeing.

Today, CSSWG decided to continue to use D50 Lab, primarily for interop with ICC PCS and existing spectrophotometers.

@jrus thanks for your thoughtful comments which are much appreciated. This particular issue is now closed, because the CSS WG decided today to remove the gray() function entirely, but I encourage you to send further review comments on this specification. I would also encourage you to join the Color on the Web Community Group.