Closed nex3 closed 3 years ago
It looks like floating-point errors aren't limited to grayscale cases. hwb(30, 0%, 80%)
is listed as #331900
because
hwbToRgb(30/60, 0, 0.8).map(c => c * 255)
= [50.999999999999986, 25.499999999999993, 0]
_.map(Math.round) = [51, 25, 0]
But mathematically it should be [51, 25.5,0]
which rounds to [51, 26, 0]
. This is harder to fix in the example function because each step of the arithmetic is unavoidable, but I think it probably still makes sense to use mathematically correct example colors.
I just ran into another interesting example. hwb(150, 0%, 0%)
is listed as #00ff7f
, but should be #00ff80
. It's odd because the given JS implementation of hwbToRgb(150/60, 0, 0).map(c => c * 255)
returns [0, 255, 127.5]
, which would round to #00ff80
. In general, for some reason the "Green-Cyans" section seems to have a lot of errors where X.5
is rounded down instead of up, which other sections (at least those prior to "Green-Cyans") do not.
Edit: Cyan-Blues and Blue-Magentas have the same issue. It looks like Green-Cyans have rounding errors in the blue channel, Cyan-Blues in the green channel, and Blue-Magentas in the red channel.
Thank you so much for taking the time to report these numerical precision errors!
We will re-do the calculations for the table in the spec.
Yes, it is worth correcting the sample code and mentioning the numerical instability in the specification itself.
It may be worth solving this issue for implementers who copy the hwbToRgb() function by including the whiteness/blackness normalization logic in the function itself:
Agreed. Currently the spec handwaves here: The following Javascript implementation of the algorithm assumes that the white and black components have already been normalized, so their sum is no larger than 100%, and have been converted into numbers in the range [0,1].
I tend to agree that it is better to do the normalization in the sample code.
Here is what color.js does:
from: {
srgb (rgb) {
let h = Color.spaces.hsl.from.srgb(rgb)[0];
// calculate white and black
let w = Math.min(...rgb);
let b = 1 - Math.max(...rgb);
w *= 100;
b *= 100;
return [h, w, b];
},
hsv (hsv) {
let [h, s, v] = hsv;
return [h, v * (100 - s) / 100, 100 - v];
}
},
and
to: {
srgb (hwb) {
let [h, w, b] = hwb;
// Now convert percentages to [0..1]
w /=100;
b /= 100;
// Normalize so white plus black is no larger than 100
let sum = w + b;
if (sum > 1) {
w /= sum;
b /= sum;
}
// From https://drafts.csswg.org/css-color-4/#hwb-to-rgb
let rgb = Color.spaces.hsl.to.srgb([h, 100, 50]);
for (var i = 0; i < 3; i++) {
rgb[i] *= (1 - w - b);
rgb[i] += w;
}
return rgb;
}
},
Note that color.js
's normalization still doesn't avoid the issue. Testing on their notebook,
new Color("hwb(30deg 100% 20%)").to('srgb').toString({format:'hex'});
returns #d4d5d5
. Similarly, it translates hwb(30deg 0% 80%)
into #331900
, but interestingly it gets hwb(150deg 0% 0%)
correct as #00ff80
.
I added the achromatic tests to color.js hwb code.
let color = new Color("hwb(150, 0%, 0%)");
color.to("sRGB").hex;
gives #00ff80
let color = new Color("hwb(30deg 100% 20%)");
color.to("sRGB").hex;
gives #d5d5d5
I'm now going to change the CSS Color 4 sample code as you suggested.
In section 8.3, some of the example RGB color values that correspond to HWB colors appear to be incorrect. For example,
hwb(0, 100%, 20%)
is translated to#d4d5d5
when in fact (becausewhiteness + blackness > 100%
) it should be the grayscale value#d5d5d5
.I think this is caused by floating-point rounding errors. If you naively normalize the input arguments and pass them to the
hwbToRgb
function, you get:This is because floating-point inaccuracies cause the term
1 - white - black
to return-5.551115123125783e-17
rather than0
, so thehslToRgb()
result's red channel isn't fully canceled out and ends up skewing the result. In pure math, though, the correct channel values would all be212.5
which would all round to213
, producing the color#d5d5d5
.It may be worth solving this issue for implementers who copy the
hwbToRgb()
function by including the whiteness/blackness normalization logic in the function itself: