Closed romainmenke closed 8 months ago
I will note that not every algorithm for a color space is limitless, sometimes there are simply inherent limits to a color space algorithm, and what you've stumbled on is simply the limit of HSL. HSL cannot properly handle such a color. It simply cannot round trip it.
>>> Color('srgb', [1.5, 1, 0.5]).convert('hsl').convert('srgb')
color(srgb 1 1 1 / 1)
>>> Color('srgb', [1.49, 1, 0.5]).convert('hsl').convert('srgb')
color(srgb 1.49 1 0.5 / 1)
Negative saturation is how HSL deals with colors with a lightness that exceeds 100%. Large saturation is how it generally deals with colors saturated beyond its gamut. Now, none of this is by a design as it is only designed for in gamut colors, but more incidentally.
The example you've given is actually outside the visible gamut. I don't think such a value is a real world concern. If you are talking about a gamut such as Rec. 2020, I believe it should handle all colors in the gamut fine, but the shape will be far from a cylinder 🙂.
Yes, 1.5
is a more extreme example :)
It was this test that triggered a failure on my end : https://github.com/web-platform-tests/wpt/blob/00e27884bfe6426de9c3e7c33e32db76bc1faed8/css/css-color/parsing/relative-color-out-of-gamut.html#L52
hsl(from lab(100 104.3 -50.9) h s l)
This eventually becomes rgb(255, 246, 244)
and to me this seemed to be caused by the negative saturation returned by the sample algorithm for hsl
.
When clamping the saturation to 0
, I get the correct result (rgb(255, 255, 255)
)
Ah, I haven't looked as closely at the sequencing of steps for relative color syntax, so I can't speak to whether the tests accurately represent the spec. I'm not sure exactly when things get clamped and/or gamut mapped during the process.
Previously we did a gamut mapping step when converting lab(100 104.3 -50.9)
to hsl
.
But after https://github.com/w3c/csswg-drafts/issues/8444 we removed this step.
(maybe my understanding of that resolution was wrong :) )
Gamut mapping helped here because it ensured that any input color would have sane value ranges for a given color model/space.
Gamut mapping helped here because it ensured that any input color would have sane value ranges for a given color model/space.
Well, define sane. They were restrained to sRGB. I don't know if the tests are doing what they should or not, but I know that lab(100 104.3 -50.9)
perfectly round trips through HSL. It doesn't appear that the negative saturation is having an adverse effect and altering the color incorrectly.
>>> hsl = Color('lab(100 104.3 -50.9)').convert('hsl')
>>> hsl
color(--hsl 311.21 -5.5486 1.0906 / 1)
>>> hsl.convert('lab')
color(--lab 100 104.3 -50.9 / 1)
I also know if you gamut map in OkLCh referencing the color as HSL vs sRGB that you get different results. I'm not exactly sure what CSS does here. Does it use sRGB when gamut mapping HSL or does it gamut map HSL colors keeping them as HSL? I assume HSL.
>>> hsl = Color('lab(100 104.3 -50.9)').convert('hsl')
>>> hsl.clone().fit('hsl', fit='oklch-chroma').convert('srgb').to_string()
'rgb(255 255 255)'
>>> hsl.clone().fit('srgb', fit='oklch-chroma').convert('srgb').to_string()
'rgb(255 253.18 255)'
In neither case did I get rgb(255, 246, 244)
, but Colorjs.io matches mine as well for what it's worth.
> new Color('lab(100 104.3 -50.9)').to('srgb').toGamut({method: 'oklch.chroma'})
Color { coords: [ 1, 1, 1 ], alpha: 1 }
> new Color('lab(100 104.3 -50.9)').to('srgb').toGamut('srgb', {method: 'oklch.chroma'})
Color { coords: [ 1, 0.9951746276359744, 1 ], alpha: 1 }
It is possible there is something about RCS that I don't understand, some step(s) that I'm not executing. I haven't really given CSS color level 5 the same critical eye that I've given level 4.
🤔 After a bit of poking I think it's something completely different. @facelessuser thank you for those results 🙇
Most color functions describe clipping of values outside certain ranges.
https://www.w3.org/TR/css-color-3/#hsl-color
If saturation is less than 0%, implementations must clip it to 0%. If the resulting value is outside the device gamut, implementations must clip it to the device gamut. This clipping should preserve the hue when possible, but is otherwise undefined. (In other words, the clipping is different from applying the rules for clipping of RGB colors after applying the algorithm below for converting HSL to RGB.)
But this should be done only during parsing.
In our implementation we always do this step, but after https://github.com/w3c/csswg-drafts/issues/8444 I think it becomes clear that we must only do it when parsing, and not when computing.
Are these the same or different colors?
hsl(from lab(100 104.3 -50.9) h s l)
hsl(from lab(100 104.3 -50.9) 311.21deg -5.5486% 1.0906%)
hsl(from white 311.21deg -5.5486% 1.0906%)
hsl(311.21deg -5.5486% 1.0906%)
Closing and filing a new issue to avoid confusion :)
Re-opening because clipping on parsing negative saturation in hsl()
is:
It may be possible that the HSL conversion algorithm can be altered to ensure that negative saturation is never returned and still have completely accurate results. Then negative saturation can be clamped at zero with no ceiling. When negative saturation occurs, it just rotates the hue by 180. So if you rotate the hue by 180, you can set the saturation to a positive value during conversion. Then there is no need to allow parsing negative saturation.
Going with this earlier example, we can correct the saturation and hue. Then when we convert back, we get the same value we had before.
>>> c1 = Color('lab(100 104.3 -50.9)').convert('hsl')
>>> c1
color(--hsl 311.21 -5.5486 1.0906 / 1)
>>> c1.set('s', lambda x: abs(x)).set('h', lambda x: x + 180).convert('lab')
color(--lab 100 104.3 -50.9 / 1)
I suspect this is generally true, but more experiments would need to be performed to ensure this is the case. I assume if there are outliers, the reasons would need to be explored as HSL has hard limits. Once the limits are exceeded, the results will never round trip back. But if you are using such extreme values, which are well outside the visible spectrum, with HSL, you get what you get 🙂.
>>> Color('color(srgb 1.5 1 0.5)').convert('hsl').convert('srgb')
color(srgb 1 1 1 / 1)
>>> Color('color(srgb 1.5 1 0.5)').convert('xyz-d65')
color(xyz-d65 1.4425 1.2701 0.37169 / 1)
As a note, I used the rec2100-pq HDR color space to generate 1,030,301 colors which I then converted to HSL. 363,630 returned negative saturation, every single one was able to successfully round trip to the original color after correcting the negative saturation and adjusting the hue.
So it seems HSL algorithm can be altered successfully to correct the negative saturation without introducing issues if negative saturation is a problem.
So it seems HSL algorithm can be altered successfully to correct the negative saturation without introducing issues if negative saturation is a problem.
We now have:
/**
* @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 HSL values: Hue as degrees 0..360, Saturation and Lightness in reference range [0,100]
*/
function rgbToHsl (red, green, blue) {
let max = Math.max(red, green, blue);
let min = Math.min(red, green, blue);
let [hue, sat, light] = [NaN, 0, (min + max)/2];
let d = max - min;
if (d !== 0) {
sat = (light === 0 || light === 1)
? 0
: (max - light) / Math.min(light, 1 - light);
switch (max) {
case red: hue = (green - blue) / d + (green < blue ? 6 : 0); break;
case green: hue = (blue - red) / d + 2; break;
case blue: hue = (red - green) / d + 4;
}
hue = hue * 60;
}
// 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);
}
if (hue >= 360) {
hue -= 360;
}
return [hue, sat * 100, light * 100];
}
So we no longer return negative saturation.
I'm wondering whether it is better to
Tending towards the belt-and-braces option 2, opinions welcome
Thank you for the update 🙇 The change above works well on my end.
Option 2 seems fine to me, I can't think of any case where you would need the uncorrected saturation. But I don't have a strong opinion on this :)
Keep in mind this approach works for most cylindrical color spaces, OkLCh and LCh included. We could, theoretically, better handle OkLCh and LCh negative chroma when converting back to Oklab and Lab without having to hard clamp to zero.
As I side note, I did mention most cylindrical spaces. Depending on the model, this may not always be true, as a non CSS example CAM16 does not work this way due to the complex way in which chroma is calculated for a given lightness and hue. The only way to resolve such saturation is to convert it back to the XYZ base and then convert it forward to CAM16 again, though once you are out of the visible spectrum, you can still get negative chroma no matter what you do. The algorithm just doesn't handle colors so far out. This is true for some other spaces like HSV as well. It is better to convert HSV back to sRGB and then back to HSV, and if the colors happen to resolve, great, if not 🤷🏻.
I mainly mention this for anyone thinking this works for every cylindrical space, without exception.
I will note that since OkLCh and LCh never return negative chroma, the only way this could occur is through manually specifying it or maybe calc()
? I don't recall the current rules on how that is handled.
In general the approach now is to do some sanitizing on input, and try not to do any on intermediate values during color conversion and interpolation; and we leave "make this make sense as a color" to display gamut mapping.
Okay, then if CSS wanted to not clamp chroma in a manually input chroma value of oklch(0.5 -0.1 270)
, it could instead just accept it as oklch(0.5 0.1 90)
. But one could argue people shouldn't be using negative chroma inputs and leave it at that :). I'm not arguing for it, more just saying it could be handled sanely if it was desirable.
I can see merit in both approaches; either treat a specified negative chroma as an error (and clamp it to zero) or fix up the hue and absolutize chroma.
But as you say, unlike HSL, the conversion code is not producing negative values so I feel the best thing is to not fix up the hue.
CSS Color 3 said to clamp negative saturation at parse time (and was silent about generated values, since it was sRGB-only). This is tested in and browsers are interoperable.
Looks like that parse-time check should be added back to CSSColor 4, for interoperability.
@dbaron @tabatkins looking for reviewers of WPT update that tests negative saturation clamping with modern HSL syntax (legacy was already tested).
It looks like, in modern syntax, Firefox clamps negative saturation while Chrome does not (test). Both clamp, per CSS Color 3, in legacy syntax.
I think there is a recent regression in Chrome. Chrome 121 shipped support for mixed numbers and percentages.
Chrome 120 with hsl(120 -100% 25.1%)
Chrome 121 with hsl(120 -100% 25.1%)
I also checked a bunch of older versions and they all consistently have a full grey square when not mixing numbers and percentages.
It looks like, in modern syntax, Firefox clamps negative saturation while Chrome does not (test). Both clamp, per CSS Color 3, in legacy syntax.
So, what do we want the spec to say? I lean slightly towards not clamping in modern syntax, which has no back-compat worries, and we can't change the clamp in legacy stntax.
Also, this is only at parse time; intermediate results from color conversion will not be clamped.
@emilio @dbaron @weinig
Once we resolve, I can either continue the WPT PR that requires clamping in modern syntax or close it, as appropriate
(Title updated to cover the one remaining issue)
Oh, my. In CSS Color 3, for hsl()
it says:
If saturation is less than 0%, implementations must clip it to 0%. If the resulting value is outside the device gamut, implementations must clip it to the device gamut. This clipping should preserve the hue when possible, but is otherwise undefined. (In other words, the clipping is different from applying the rules for clipping of RGB colors after applying the algorithm below for converting HSL to RGB.)
(No mention of clipping lightness). And for hsla()
it says:
Implementations must clip the hue, saturation, and lightness components of HSLA color values to the device gamut according to the rules for the HSL color value composed of those components.
The wording in both is somewhat confusing, as it mixes up parse-time clipping and used-value mapping to the display gamut.
The CSS Working Group just discussed [css-color-4] Parse-time clip of HSL negative saturation for modern syntax?
, and agreed to the following:
RESOLVED: All hsl() clip to non-negative numbers for saturation
The current specification already conforms to this resolution (it does not distinguish legacy and modern syntax):
For historical reasons, if the saturation is less than 0% it is clamped to 0% at parsed-value time, before being converted to an sRGB color.
So this does not need spec edits, but does need review of my WPR PR which tests this in modern syntax.
WPT updated, thanks @dbaron for swift re-review
Should it be considered a bug if saturation is also capped at 100% before conversion to RGB?
to8Bit(hslToRgb(0, 150, 40)) // [255,-51,-51]
to8Bit(hslToRgb(0, 100, 40)) // [204,0,0]
element.style.color = 'hsl(0%, 150%, 40%)'
element.style.color // rgb(204, 0, 0) in Chrome and FF
But I cannot find where clamping values outside of [0,255]
after conversion to RGB, is defined.
Should it be considered a bug if saturation is also capped at 100% before conversion to RGB?
Yes, and that should be observable via color-mix()
when output to color(srgb ...)
. I mean clamping will happen during conversion to rgb()
but not before conversion.
But I cannot find where clamping values outside of [0,255] after conversion to RGB, is defined.
It is in 5.1 The RGB functions: rgb()
and rgba()
:
Values outside these ranges are not invalid, but are clamped to the ranges defined here at parsed-value time.
and, hmm,
Also for historical reasons, when calc() is simplified down to a single value, the color values are clamped to [0.0, 255.0].
This clamping also takes care of values such as Infinity, -Infiinity, and NaN which will clamp at 255, 0 and 0 respectively.
I agree that it doesn't say explicitly about after conversion. In particular,
Colors may be converted from one color space to another and, provided that there is no gamut mapping and that each color space can represent out of gamut colors, (for RGB spaces, this means that the transfer function is defined over the extended range) then (subject to numerical precision and round-off error) the two colors will look the same and represent the same color sensation.
Which is true, but does not cover the case where the color space (in this case, sRGB) is defined over the extended range but a serialization form (rgb()
) does not allow extended range values.
I would definitely expect channel values to not be clamped before mixing colors (unless otherwise specified at parse time).
The definition of hslToRgb()
says:
It returns an array of three numbers [...] normalized to the range [0, 1].
And its saturation/lightness argument should be in reference range [0,100].
Now, hslToRgb(0, 150, 40)
returns [1, -0.2, -0.2]
. It would serialize as rgb(255, 0, 0)
assuming values outside of [0,255]
are clamped, which is only specified to apply at parse time for rgb()
.
It returns [0.8, 0, 0]
if saturation is clamped before, which would serialize as rgb(204, 0, 0)
.
And the specified/computed value of hsl(0, 150%, 40%)
is currently rgb(204, 0, 0)
, not rgb(255, 0, 0)
. The following examples shows some differences between legacy/modern syntax, between Chrome and FF.
element.style.color = 'hsl(0, 150%, 40%)'
getComputedStyle(element).color; // Chrome-FF -> rgb(204, 0, 0)
element.style.color = 'hsl(0 150% 40%)'
getComputedStyle(element).color; // Chrome-FF -> rgb(204, 0, 0)
element.style.color = 'color-mix(in srgb, hsl(0, 150%, 40%), hsl(0, 150%, 40%))'
getComputedStyle(element).color; // Chrome-FF -> color(srgb 0.8 0 0)
element.style.color = 'color-mix(in srgb, hsl(0 150% 40%), hsl(0 150% 40%))'
getComputedStyle(element).color; // Chrome -> color(srgb 1 -0.2 -0.2) ; FF -> color(srgb 0.8 0 0)
element.style.color = 'rgb(from hsl(0, 150%, 40%) r g b)'
getComputedStyle(element).color; // Chrome-FF -> color(srgb 0.8 0 0)
element.style.color = 'rgb(from hsl(0 150% 40%) r g b)'
getComputedStyle(element).color; // Chrome -> color(srgb 1 -0.2 -0.2) ; FF -> color(srgb 0.8 0 0)
Unfortunately, I cannot find any test on WPT with a saturation greater than 100% in hsl()
.
And the specified/computed value of
hsl(0, 150%, 40%)
is currentlyrgb(204, 0, 0)
, notrgb(255, 0, 0)
. The following examples shows some differences between legacy/modern syntax, between Chrome and FF.element.style.color = 'hsl(0, 150%, 40%)' getComputedStyle(element).color; // Chrome-FF -> rgb(204, 0, 0) element.style.color = 'hsl(0 150% 40%)' getComputedStyle(element).color; // Chrome-FF -> rgb(204, 0, 0) element.style.color = 'color-mix(in srgb, hsl(0, 150%, 40%), hsl(0, 150%, 40%))' getComputedStyle(element).color; // Chrome-FF -> color(srgb 0.8 0 0) element.style.color = 'color-mix(in srgb, hsl(0 150% 40%), hsl(0 150% 40%))' getComputedStyle(element).color; // Chrome -> color(srgb 1 -0.2 -0.2) ; FF -> color(srgb 0.8 0 0) element.style.color = 'rgb(from hsl(0, 150%, 40%) r g b)' getComputedStyle(element).color; // Chrome-FF -> color(srgb 0.8 0 0) element.style.color = 'rgb(from hsl(0 150% 40%) r g b)' getComputedStyle(element).color; // Chrome -> color(srgb 1 -0.2 -0.2) ; FF -> color(srgb 0.8 0 0)
Confirmed with Chrome Canary Version 123.0.6312.0 (Official Build) canary (64-bit)
$0.style.color = 'hsl(0, 150%, 40%)'
'hsl(0, 150%, 40%)'
getComputedStyle($0).color
'rgb(204, 0, 0)'
$0.style.color ='color-mix(in srgb, hsl(0, 150%, 40%), hsl(0, 150%, 40%))'
'color-mix(in srgb, hsl(0, 150%, 40%), hsl(0, 150%, 40%))'
getComputedStyle($0).color
'color(srgb 0.8 0 0)'
$0.style.color ='color-mix(in srgb, hsl(0 150% 40%), hsl(0, 150%, 40%))'
'color-mix(in srgb, hsl(0 150% 40%), hsl(0, 150%, 40%))'
getComputedStyle($0).color
'color(srgb 0.9 -0.1 -0.1)'
$0.style.color ='color-mix(in srgb, hsl(0 150% 40%), hsl(0 150% 40%))'
'color-mix(in srgb, hsl(0 150% 40%), hsl(0 150% 40%))'
getComputedStyle($0).color
'color(srgb 1 -0.2 -0.2)'
Unfortunately, I cannot find any test on WPT with a saturation greater than 100% in
hsl()
.
I will add some to css/css-color/parsing/color-computed-hsl.html and css/css-color/parsing/color-mix-out-of-gamut.html
"normalized to the range [0, 1]" used to be true but now needs to be restated. "Colors in the sRGB gamut will be in the range [0, 1]" perhaps.
Sorry, I am still confused... so what should be the serialized value of hsl(0 150% 40%)
?
rgb(204, 0, 0)
(current browser output)rgb(255, 0, 0)
rgb(255, -51, -51)
Answer 3 is eliminated because it does not round-trip (rgb channel values must be clamped at parse time).
Should it be considered a bug if saturation is also capped at 100% before conversion to RGB?
Yes, and that should be observable via
color-mix()
when output tocolor(srgb ...)
. I mean clamping will happen during conversion torgb()
but not before conversion.
I conclude that answer 1 is eliminated because the saturation is capped before conversion... unless I am missing how it happens during conversion to rgb()
but the conversion algorithm does not clamp the saturation or the resulting rgb channel values: the result of hslToRgb(0, 150, 40)
is currently [1, -0.2, -0.2]
, which corresponds to [255, -51, -51]
.
Assuming answer 2 is expected:
But I cannot find where clamping values outside of [0,255] after conversion to RGB, is defined.
It is in 5.1 The RGB functions:
rgb()
andrgba()
:Values outside these ranges are not invalid, but are clamped to the ranges defined here at parsed-value time.
The only way I see for this to apply would be to convert hsl()
at parse time (unless it is nested in another color function), which is unspecified, and to consider the resulting rgb()
as if it was present in the input and clamp its channel values at parse time, as specified.
https://github.com/w3c/csswg-drafts/blob/61da22ed62e83bf41caa5bde73912209ff45cc2e/css-color-4/rgbToHsl.js#L16
This only happens when the sum of all channels exceeds
3
.rgbToHsl(1.5, 1, 0.5)
-> negative saturationrgbToHsl(1.499999, 1, 0.5)
-> very high, but positive saturation of99999900
I get better results when I clamp saturation to
0
.I don't know if clamping to
0
had unintended side effects here.