Skip to content

[css-color-4][css-color-5] rgb-to-hwb algorithm disagrees indirectly with relative-color-out-of-gamut and color-mix-out-of-gamut expectations #10695

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

Closed
squelart opened this issue Aug 6, 2024 · 12 comments

Comments

@squelart
Copy link
Contributor

squelart commented Aug 6, 2024

Bias warning: I've made the title as neutral as possible, but I personally think the specs are at fault and the tests are correct (though additional tests would be welcome).
Note that I'm not an expert in colorspaces and CSS and specs, so my bias may be wrong; also I may use incorrect terminology below.

tl;dr: The rgb-to-hwb algorithm uses rgb-to-hsl as-is, which produces a rotated hue for some out-of-gamut colors with negative saturation, while some relative-color tests like hwb(from lab(100 104.3 -50.9) h w b) (if they internally convert via rgb) expect non-rotated hue.
I will argue that the function definition in https://drafts.csswg.org/css-color-4/#rgb-to-hwb needs to be tweaked.

First, about the "indirect" tests: Safari fails tests like hwb(from lab(100 104.3 -50.9) h w b) because internally it converts from lab to rgb first, before using the rgb-to-hwb algorithm to compute the final hwb color.
However, I think this internal implementation converting via rgb is not at issue here, because we could add another simple test case that goes directly from rgb to hwb, e.g.:

  fuzzy_test_computed_color(
    `hwb(from color(srgb 1.59343 0.58802 1.40564) h w b / alpha)`,
    `color(srgb 1.59343 0.58802 1.40564)`);

This also fails in Safari with an actual output color(srgb 0.587758 1.593503 0.775713).

Next, about the test expectations.
Focusing on this relative-color-out-of-gamut.html test case:

  fuzzy_test_computed_color(
    `hwb(from lab(100 104.3 -50.9) h w b)`,
    `color(srgb 1.59343 0.58802 1.40564)`);

I believe the expectation from lab to srgb is correct, because I can reproduce it in a number of ways:

Assuming the test expectations are correct, I'll now focus on the rgb-to-hwb conversion:
https://drafts.csswg.org/css-color-4/#rgb-to-hwb defines the rgbToHwb function, and its first step is to call rgbToHsl.
https://drafts.csswg.org/css-color-4/#rgb-to-hsl defines rgbToHsl. Just above it there's this note:

Special care is taken to deal with intermediate negative values of saturation, which can be produced by colors far outside the sRGB gamut.

And in the rgbToHsl function definition this is reflected in this bit of code:

    // Very out of gamut colors can produce negative saturation
    // If so, just rotate the hue by 180 and use a positive saturation
    // see https://github.com/w3c/csswg-drafts/issues/9222
    if (sat < 0) {
        hue += 180;
        sat = Math.abs(sat);
    }

Notice how the hue is rotated 180 degrees, and the saturation is negated. And it probably makes sense to normalize HSL colors that way.

However, back in rgbToHwb, only the resulting hue component is kept, the saturation and lightness are simply ignored.
I believe that this is where things go wrong, because rgbToHsl could have flipped the hue and negated the saturation, but we only use that flipped hue and have lost the corresponding information stored in the saturation.
And this explains why we get something like rgb(0.6, 1.6, 0.8), which seems to have an opposite hue to the expected rgb(1.6, 0.6, 1.4).

My proposed solution would be to compute the hue in a similar way, except that we wouldn't need to compute the saturation (nor lightness), and the hue would never be rotated/flipped.

To illustrate this, I've written this codepen: https://codepen.io/squelart/pen/MWMoeoB
It uses the function definitions straight from https://drafts.csswg.org/css-color-4 , and tests rgb -> hwb -> rgb.
We can notice how our hero test color doesn't survive the round trip. I've added a few test cases with a decreasing red component, it looks like there's a cut-off between 1.4 and 1.5. [edit: I've added more output to the codepen, cut-off around red>1.41198, with the HSL saturation dipping into big negative numbers.]

In the codepen I've also added a modified rgb-to-hwb function that implements my suggested rgb-to-hue sub-function (which derives from rgb-to-hsl). And this one shows a working round trip.
(And I'm working on a similar fix in WebKit: WebKit/WebKit#31636 , but I'll wait for this discussion here to be resolved first.)

In conclusion:

  • I believe the function definition in https://drafts.csswg.org/css-color-4/#rgb-to-hwb needs to be tweaked to avoid flipping the hue.
  • More out-of-gamut test cases (related to css-colors-5) could be useful, to cover more colorspace pairs, and more colors.

To be complete: There's the possibility that my argument is incorrect, and in fact the css-color-4 conversion functions should be taken as authoritative and correct, in which case the test expectations should be updated.

@nt1m
Copy link
Member

nt1m commented Aug 6, 2024

cc @mysteryDate @weinig @svgeesus @tiaanl

@squelart
Copy link
Contributor Author

squelart commented Aug 6, 2024

As seen on https://codepen.io/squelart/pen/MWMoeoB , the resulting HWB color has a negative blackness, so #10368 may be related, but it seems more focused on hwb-to-rgb.

@nt1m nt1m added the css-color-4 Current Work label Aug 6, 2024
@facelessuser
Copy link

Since the hue in HWB is used without saturation context, you definitely don't want the corrected hue because whiteness and blackness are not calculated with the adjusted saturation but are calculated directly from the sRGB values. I would agree that this is a bug to use the adjusted hue in HWB. This is a good catch.

@romainmenke
Copy link
Member

I also think you analysis is correct :)

The flipping of the hue was introduced only for hsl if I recall correctly.
It was discussed here: #9222
As far as I can see hwb was not mentioned there.

So I think it was an oversight that the algorithm was changed without also considering the effects it would have on conversions to hwb.

I've tested your suggested fix and it seems fine to me.


@squelart This variant is based on the WebKit code, after your proposed changes would be applied

/**
 * @param {number} red - Red component 0..1
 * @param {number} green - Green component 0..1
 * @param {number} blue - Blue component 0..1
 * @return {number[]} Array of HWB values: Hue as degrees 0..360, Whiteness and Blackness in reference range [0,100]
 */
function rgbToHwb(red, green, blue) {
    var white = Math.min(red, green, blue);
    var black = 1 - Math.max(red, green, blue);
    return([rgbToHue(red, green, blue), white*100, black*100]);
}

/**
 * @param {number} red - Red component 0..1
 * @param {number} green - Green component 0..1
 * @param {number} blue - Blue component 0..1
 * @return {number} Hue as degrees 0..360
 */
function rgbToHue(red, green, blue) {
	const max = Math.max(red, green, blue);
	const min = Math.min(red, green, blue);
	let hue = NaN;
	const d = max - min;

	if (d !== 0) {
		switch (max) {
			case red: hue = ((green - blue) / d); break;
			case green: hue = (blue - red) / d + 2; break;
			case blue: hue = (red - green) / d + 4;
		}

		hue = hue * 60;
	}

	if (hue >= 360) {
		hue -= 360;
	} else if (hue < 0) {
		hue += 360;
	}

	return hue;
}

@squelart
Copy link
Contributor Author

squelart commented Aug 6, 2024

Thank you @facelessuser and @romainmenke . Is that enough agreement here to proceed?

What's the process for updating the specs? (I'd be fine if someone wanted to go ahead and do it, otherwise I can have a go if appropriate.)

And any thoughts on adding more coverage in out-of-gamut tests? Any process to follow for that?

@facelessuser
Copy link

facelessuser commented Aug 7, 2024

As the person who suggested the hue flip for HSL in the first place, I can confidently say HWB should not flip the hue unless whiteness and blackness account for it, and since there is not currently a way to do so, the hue should not be flipped for HWB. So, I would say a change for HWB needs to be made to avoid the hue flip for good round tripping. I would be surprised if there was push back.

@svgeesus
Copy link
Contributor

svgeesus commented Aug 7, 2024

However, back in rgbToHwb, only the resulting hue component is kept, the saturation and lightness are simply ignored.
I believe that this is where things go wrong, because rgbToHsl could have flipped the hue and negated the saturation, but we only use that flipped hue and have lost the corresponding information stored in the saturation.

Yes, spot on. I agree that HWB should not flip the hue.

@atanassov atanassov changed the title [css-colors-4][css-colors-5] rgb-to-hwb algorithm disagrees indirectly with relative-color-out-of-gamut and color-mix-out-of-gamut expectations [css-color-4][css-color-5] rgb-to-hwb algorithm disagrees indirectly with relative-color-out-of-gamut and color-mix-out-of-gamut expectations Aug 7, 2024
@svgeesus
Copy link
Contributor

svgeesus commented Aug 7, 2024

Starting from CSS Color 4 rgbToHSL and removing the negative saturation check, to create an rgbToHue, I end up with

case red:   hue = (green - blue) / d + (green < blue ? 6 : 0); break;

while the codepen from @squelart has

case red:   hue = (green - blue) / d; break;

@squelart
Copy link
Contributor Author

squelart commented Aug 7, 2024

Good eye @svgeesus !
I actually implemented my proposed fix before diving deeper into root causes, and followed what another correct-looking implementation was doing. It didn't have this + (green < blue ? 6 : 0) trick, but instead a later catch-all if (hue < 0) { hue += 360; }, which seemed a bit clearer, and safer to me because it could catch other potential negative hues from other branches.
But if you think that this rgbToHsl-derived code is 100% safe and preferable, I'd be happy to go with that optimization.

Back to my rambling about tests, it'd be nice to have more test cases that would cover all possible code paths (in both versions), to ensure that this optimization doesn't break anything.

@svgeesus
Copy link
Contributor

svgeesus commented Aug 7, 2024

which seemed a bit clearer, and safer to me because it could catch other potential negative hues from other branches.

Thanks for the explanation. I agree it is clearer to simply test for hues greater than 360.

squelart added a commit to squelart/csswg-drafts that referenced this issue Aug 9, 2024
…nly computes the hue

Tests related to out-of-gamut relative colors [css-color-5] already implicitly match this corrected behavior.
squelart added a commit to squelart/csswg-drafts that referenced this issue Aug 9, 2024
…nly computes the hue

Tests related to out-of-gamut relative colors [css-color-5] already implicitly match the corrected behavior.
@squelart
Copy link
Contributor Author

squelart commented Aug 9, 2024

My first PR! #10718
I can't add reviewers or set labels myself, hopefully people here can help. (And of course please let me know if I've done anything incorrectly.)

svgeesus pushed a commit that referenced this issue Aug 9, 2024
… computes the hue

Tests related to out-of-gamut relative colors [css-color-5] already implicitly match the corrected behavior.
@svgeesus
Copy link
Contributor

svgeesus commented Aug 9, 2024

closed by ead8668

@svgeesus svgeesus closed this as completed Aug 9, 2024
raphlinus added a commit to linebender/color that referenced this issue Nov 10, 2024
See w3c/csswg-drafts#10695 for an explanation why we need this. This brings the code in line with color.js and the editor's draft of the CSS spec.
aarongable pushed a commit to chromium/chromium that referenced this issue Mar 10, 2025
The rgbToHwb is updated in [3], while the rgbToHsl is updated in [4]

[1]: https://drafts.csswg.org/css-color-4/#rgb-to-hsl
[2]: https://drafts.csswg.org/css-color-4/#rgb-to-hwb
[3]: w3c/csswg-drafts#10695
[4]: w3c/csswg-drafts@905e7cd

Bug: 329301908
Change-Id: Ia1246ad1ad2524e5ff13c82ecd1d15874763306f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6294543
Commit-Queue: Jason Leo <m.jason.liu@gmail.com>
Reviewed-by: Mitsuru Oshima <oshima@chromium.org>
Auto-Submit: Jason Leo <m.jason.liu@gmail.com>
Reviewed-by: Aaron Krajeski <aaronhk@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1430325}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

5 participants