w3c / csswg-drafts

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

[css-color-4] Some example HWB-to-RGB translations are incorrect #5531

Closed nex3 closed 3 years ago

nex3 commented 4 years ago

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 (because whiteness + 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:

hwbToRgb(0, 1/1.2, 0.2/1.2)
= [0.8333333333333333, 0.8333333333333334, 0.8333333333333334]
hwbToRgb(0, 1/1.2, 0.2/1.2).map(c => c * 255)
= [212.49999999999997, 212.5, 212.5]
hwbToRgb(0, 1/1.2, 0.2/1.2).map(c => c * 255).map(Math.round)
= [212, 213, 213]

This is because floating-point inaccuracies cause the term 1 - white - black to return -5.551115123125783e-17 rather than 0, so the hslToRgb() 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 be 212.5 which would all round to 213, 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:

function hwbToRgb(hue, white, black) {
  if (white + black >= 1) {
    var gray = white / (white + black);
    return [gray, gray, gray];
  }

  var rgb = hslToRgb(hue, 1, .5);
  for(var i = 0; i < 3; i++) {
    rgb[i] *= (1 - white - black);
    rgb[i] += white;
  }
  return rgb;
}
nex3 commented 4 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.

nex3 commented 4 years ago

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.

svgeesus commented 3 years ago

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.

svgeesus commented 3 years ago

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;
        }
    },
nex3 commented 3 years ago

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.

svgeesus commented 3 years ago

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.

svgeesus commented 3 years ago

changed the CSS Color 4 sample code