color.js icon indicating copy to clipboard operation
color.js copied to clipboard

inGamut check for HSL and other sRGB cylindrical spaces

Open facelessuser opened this issue 4 years ago • 12 comments
trafficstars

Problem Description

I don't think the conversion is a bug, but I am more questioning the inGamut check, though I understand what it is doing, and technically it isn't wrong. I am more curious if this is known and accepted, or unknown and not yet considered. Consider the following:

> let color = new Color("lab(100% 0 0)").to('hsl')
undefined
> color.toString()
'hsl(32.624 493.346% 99.998%)'
> color.inGamut()
true

We can see in this example that saturation, after conversion, is well past the accepted constraints, and yet, this HSL color is considered "in gamut".

Now, I know that HSL is not checked for "in gamut" by comparing its own coords, but is actually checked in the sRGB space with an allowable threshold. So, on the one hand, this makes sense:

> let color2 = new Color("lab(100% 0 0)").to('srgb')
undefined
> color2.toString()
'rgb(100.01% 99.999% 99.989%)'

As can be noted above, the conversion directly to sRGB is far less jarring.

Now, I understand that the conversion algorithm for HSL is designed for values within the constraints of a color that is in gamut, and once colors are out of gamut (even if they are only slightly out of gamut) may not yield values a user expects, and that is not necessarily wrong. I am more interested in the idea of whether the HSL value should actually be considered "in gamut" even if it is technically not too far out of the sRGB gamut. In HSL, the color is wildly out of range.

The reason why this seems problematic is if I am trying to well form my cylindrical sRGB values, they do not actually register as out of bounds with the inGamut check. If you run toGamut('srgb'), the value is fit without the "tolerance" allowed before, and you will actually get a sane value. You just have no way of testing that it is out of bounds without manual introspection:

> color.toString()
'hsl(32.624 493.346% 99.998%)'
> color.inGamut()
true
> color.toGamut('srgb').toString()
'hsl(54.251 100% 99.995%)'

Surprisingly, toGamut('hsl') does not seem to redirect to the srgb logic either.

> color.toGamut('hsl').toString()
'hsl(32.624 493.346% 99.998%)'

Question

So my question is: should HSL also evaluate its own coords in addition to the sRGB constraint check? Or is this a known and accepted behavior?

I know that HSL is simply a representation of the sRGB color, and the gamut is the same, but it also seems that values that are wildly out of range should also be flagged as "out of gamut". I also think that toString should gamut map the HSL color in this case.

facelessuser avatar Mar 02 '21 14:03 facelessuser

I originally thought that if a strict inGamut with no tolerance was used that HSL would always be constrained when sRGB proved "in gamut". But this example has made me think otherwise:

> var color = new Color('hsl(100 300% 100%)')
undefined
> color
Color$1 {
  _spaceId: 'hsl',
  toString: [Function: toString],
  coords: [ 100, 300, 100 ],
  alpha: 1
}
> color.to('srgb')
Color$1 {
  _spaceId: 'srgb',
  toString: [Function: toString],
  coords: [ 1, 1, 1 ],
  alpha: 1
}
> color.to('srgb').coords
[ 1, 1, 1 ]

It seems you'd almost have to check the HSL constraints just due to how the algorithm operates. The above converts to a white with no decimals at all.

facelessuser avatar Mar 02 '21 19:03 facelessuser

I will admit that the example of hsl(100 300% 100%) is a bit contrived, and probably wouldn't occur via normal conversion, but probably only through direct manipulation. Maybe a strict "in gamut" check (or at least the option of a strict "in gamut" check), coupled with color spaces like HSL also evaluating their own constraints would be enough.

facelessuser avatar Mar 02 '21 23:03 facelessuser

Sanity checking for HSL (and HWB) colors seems different to gamut checks. The resulting color isn't out of gamut, it can't be plotted for example on a chromaticity diagram.

That said this recent change Do not check gamut in polar spaces makes clear that in-gamut checks on polar spaces are conducted in the base, rectilinear space from which the polar space is derived.

Which is also what CSS Color 4 12. Converting colors now says, too. There is an explicit gamut mapping step before conversion from sRGB to HSL or HWB.

svgeesus avatar Jun 21 '22 15:06 svgeesus

Okay, so I imagine, from a color.js perspective, that a strict, zero-tolerance check would be preferrable when converting from sRGB to HSL as it is possible that an out-of-gamut sRGB color that is within the gamut check's threshold could give a wildly out of spec HSL value, even if it is technically only just out of gamut for the rectangular space.

For instance:

let color = new Color("color(srgb 1 1 1.00001)").to('hsl');

yields: hsl(0 -100% 100%).

Or is this not really a concern?

I guess gamut checking in general though could, if clearly noted, still yield in gamut for hsl(0 -100% 100%) if testing it with inGamut as technically it is within the rectangular space's threshold, but it seems during conversion at least, that it would be preferable to use a zero-tolerance check to ensure the conversion is not wildly out of spec. At least that is what I'm thinking, curious about your thoughts?

facelessuser avatar Jun 21 '22 16:06 facelessuser

Also what CSS Color 4 says about valid HSL values:

For saturation, 100% is a fully-saturated, bright color, and 0% is a fully-unsaturated gray. For lightness, 50% represents the "normal" color, while 100% is white and 0% is black. If the saturation or lightness are less than 0% or greater than 100%, they are clamped to those values at computed value time, before being converted to an sRGB color.

So S = 300% is fine, but becomes S = 100% before any conversion to another color space.

svgeesus avatar Jun 29 '22 18:06 svgeesus

And we should do that clip in color.js, which we currently do not for S and L

svgeesus avatar Jun 29 '22 18:06 svgeesus

Compare hsl(120 0% 50%) with hsl(120 -100% 50%) for example. One is a grey, the other is a saturated magenta - opposite color to the specified hue :)

svgeesus avatar Jun 29 '22 18:06 svgeesus

And we should do that clip in color.js, which we currently do not for S and L

Yeah, that is kind of why I was asking. I've seen it thrown around a bit on what should happen, but not seeing it actually done yet gave me pause. I was aware the CSS spec mentioned clamping these values, but I've also seen it mentioned outside the spec that they should be gamut mapped. That then brought up the question about the loose threshold by which a cylindrical space was mapped in the rectangular spaces as that little wiggle room, at least for the spec'd values, could give weirdly out of spec values, even if they convert back fine.

I don't think it is strongly restricted in either direction. For instance, this example sidesteps the saturation of zero simply because lightness never exactly equals 1:

> let color = new Color("color(srgb 1 1 1.00001)").to('hsl').coords.toString();
"NaN,-100,100.0005"

It allows for better round-tripping by not clamping them, but the values are kind of nonsensical for a user. Especially if they are attempting to work with the color under assumptions it is in gamut; therefore, having sane values.

Compare hsl(120 0% 50%) with hsl(120 -100% 50%) for example. One is a grey, the other is a saturated magenta - opposite color to the specified hue :)

Ha, that is interesting. In a weird way, it kind of makes sense :). Though, this clearly makes a case that clipping vs gamut mapping in this space would give very different results,

facelessuser avatar Jun 29 '22 19:06 facelessuser

I suspect this bug (look at the -92000 saturation value when the coords are inspected, but this is hidden by gamut mapping prior to serialization) is caused simply by the method for sRGB TO HSL do no gamut mapping or gamut clipping but instead, passing values greater than 1 or less than 0 directly to the hokey HSL algorithm which was never designed to accommodate them.

The solution is to follow CSS Color 4 and put sRGB values in-gamut before conversion to HSL.

svgeesus avatar Jun 30 '22 13:06 svgeesus

I was aware the CSS spec mentioned clamping these values, but I've also seen it mentioned outside the spec that they should be gamut mapped.

Two different things. The spec says that out-of-range HSL values must be clamped, before they are converted to sRGB. It also says that out of gamut sRGB values must be gamut mapped, before they are converted to HSL (or HWB).

svgeesus avatar Jun 30 '22 13:06 svgeesus

I suspect this bug (look at the -92000 saturation value when the coords are inspected, but this is hidden by gamut mapping prior to serialization) is caused simply by the method for sRGB TO HSL do no gamut mapping or gamut clipping but instead, passing values greater than 1 or less than 0 directly to the hokey HSL algorithm which was never designed to accommodate them.

The solution is to follow CSS Color 4 and put sRGB values in-gamut before conversion to HSL.

That still doesn't prevent people creating colors with HSL values out of range.

LeaVerou avatar Jun 30 '22 13:06 LeaVerou

That still doesn't prevent people creating colors with HSL values out of range.

No my earlier commit, since reverted for debugging of the other problem, is what did that.

svgeesus avatar Jun 30 '22 14:06 svgeesus

The spec says that out-of-range HSL values must be clamped, before they are converted to sRGB. It also says that out of gamut sRGB values must be gamut mapped, before they are converted to HSL (or HWB).

Neither of those statements are now true; hsl() in CSS Color 4 is mostly clamping free, to allow round-tripping of out of gamut values, so there is no built-in gamut mapping either. (For web compat and CSS Color 3 alignment,, legacy syntax hsl() clamps negative saturation).

svgeesus avatar Feb 09 '24 22:02 svgeesus