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

irregular sRGB gamut boundary in OKLCH

Open svgeesus opened this issue 4 years ago • 12 comments

When plotting L,C gamut slices I noticed an odd irregularity in the gamut boundary, in OK LCH. I didn't see the same irregularity in CIE LCH. The code is

        let swatch = new Color("oklch", [lightness, chroma, hue]);
        let rgb = swatch.to("sRGB");
        // console.log({swatch, rgb});
        if (rgb.inGamut("srgb")) {
            fill = rgb.toString();  // .to("srgb");
        }

I suspect this is due to the inGamut() call, rather than being an irregularity in OKLab/ OK LCH itself.

image

svgeesus avatar Mar 23 '21 23:03 svgeesus

I can confirm that it's supposed to look smoother than that, as oklab's author himself has shown in https://bottosson.github.io/posts/gamutclipping/. The inGamut code looks pretty normal except for its handling of epsilon: maybe try something like rgb.inGamut("srgb", {epsilon: 0}) instead? The way darker colors break surely suggest such a cause.

Artoria2e5 avatar Jun 27 '21 13:06 Artoria2e5

I can 100% confirm this is not a bug in Oklch or Color.js. This, as @Artoria2e5 pointed out, is related to checking the gamut without a strict epsilon. I've been doing a number of graphs related to color recently and sat down to test out this scenario as I was curious.

I was able to recreate the issue (looks slightly different only because I draw a border around the slice). This issue goes away by applying a strict tolerance when gamut checking. Below I show the bad slice and the good slice.

dirty

clean

I think this issue can be closed.

facelessuser avatar Oct 05 '21 19:10 facelessuser

I'm seeing the same irregularity in my shader based implementation in the hue range 264.053 - 264.208. The Evil Martians oklch color picker which is using Culori has the same irregularity in the 264ish hue range. https://oklch.evilmartians.io/#40,0.22,264.2,100

My implementation showing a graph of chroma vs lightness oscillating through the hue range 264.0 - 264.22.

https://user-images.githubusercontent.com/65072/184817932-784c296b-a0a7-42e7-b954-34f66f954174.mp4

Here is some oklch -> srgb conversion data for a lightness of 40%, hue of 264.1 and chroma in the range of 0.22 - 0.285 generated using color.js.

  • 0.22 - 0.238 we're in gamut
  • 0.239 - 0.273 the red channel is negative so we're no longer in gamut
  • 0.274 - 0.277 the red channel switches back to positive so we're back in gamut
  • 0.278 - ...the red channel is increasing but the green channel is now negative so we're no longer in gamut

I think this correlates with the irregularity that you can see in the video.

oklch to srgb conversion data
oklch(40% 0.22 264.1) -> rgb(1.367578% 18.025375% 73.342104%), in gamut: true
oklch(40% 0.221 264.1) -> rgb(1.2695388% 17.87871% 73.545683%), in gamut: true
oklch(40% 0.222 264.1) -> rgb(1.1742048% 17.729837% 73.749302%), in gamut: true
oklch(40% 0.223 264.1) -> rgb(1.0815793% 17.578696% 73.952961%), in gamut: true
oklch(40% 0.224 264.1) -> rgb(0.9916656% 17.425228% 74.156661%), in gamut: true
oklch(40% 0.225 264.1) -> rgb(0.904467% 17.269368% 74.360401%), in gamut: true
oklch(40% 0.226 264.1) -> rgb(0.819987% 17.111048% 74.564182%), in gamut: true
oklch(40% 0.227 264.1) -> rgb(0.7382288% 16.9502% 74.768005%), in gamut: true
oklch(40% 0.228 264.1) -> rgb(0.6591958% 16.786749% 74.971869%), in gamut: true
oklch(40% 0.229 264.1) -> rgb(0.5828914% 16.620616% 75.175775%), in gamut: true
oklch(40% 0.23 264.1) -> rgb(0.5093188% 16.45172% 75.379722%), in gamut: true
oklch(40% 0.231 264.1) -> rgb(0.4384814% 16.279974% 75.583712%), in gamut: true
oklch(40% 0.232 264.1) -> rgb(0.3703826% 16.105288% 75.787744%), in gamut: true
oklch(40% 0.233 264.1) -> rgb(0.3050257% 15.927564% 75.991819%), in gamut: true
oklch(40% 0.234 264.1) -> rgb(0.242414% 15.7467% 76.195937%), in gamut: true
oklch(40% 0.235 264.1) -> rgb(0.1825509% 15.562589% 76.400098%), in gamut: true
oklch(40% 0.236 264.1) -> rgb(0.1254397% 15.375115% 76.604302%), in gamut: true
oklch(40% 0.237 264.1) -> rgb(0.0710838% 15.184157% 76.80855%), in gamut: true
oklch(40% 0.238 264.1) -> rgb(0.0194865% 14.989584% 77.012842%), in gamut: true
oklch(40% 0.239 264.1) -> rgb(-0.029349% 14.791259% 77.217178%), in gamut: false
oklch(40% 0.24 264.1) -> rgb(-0.075419% 14.589033% 77.421558%), in gamut: false
oklch(40% 0.241 264.1) -> rgb(-0.11872% 14.382748% 77.625982%), in gamut: false
oklch(40% 0.242 264.1) -> rgb(-0.15925% 14.172236% 77.830452%), in gamut: false
oklch(40% 0.243 264.1) -> rgb(-0.197004% 13.957314% 78.034966%), in gamut: false
oklch(40% 0.244 264.1) -> rgb(-0.23198% 13.737788% 78.239525%), in gamut: false
oklch(40% 0.245 264.1) -> rgb(-0.264173% 13.513445% 78.444129%), in gamut: false
oklch(40% 0.246 264.1) -> rgb(-0.293582% 13.28406% 78.648779%), in gamut: false
oklch(40% 0.247 264.1) -> rgb(-0.320201% 13.049384% 78.853474%), in gamut: false
oklch(40% 0.248 264.1) -> rgb(-0.344029% 12.809151% 79.058216%), in gamut: false
oklch(40% 0.249 264.1) -> rgb(-0.365061% 12.563068% 79.263003%), in gamut: false
oklch(40% 0.25 264.1) -> rgb(-0.383294% 12.310817% 79.467837%), in gamut: false
oklch(40% 0.251 264.1) -> rgb(-0.398726% 12.05205% 79.672717%), in gamut: false
oklch(40% 0.252 264.1) -> rgb(-0.411352% 11.786381% 79.877644%), in gamut: false
oklch(40% 0.253 264.1) -> rgb(-0.421169% 11.513389% 80.082617%), in gamut: false
oklch(40% 0.254 264.1) -> rgb(-0.428175% 11.232603% 80.287637%), in gamut: false
oklch(40% 0.255 264.1) -> rgb(-0.432365% 10.943501% 80.492705%), in gamut: false
oklch(40% 0.256 264.1) -> rgb(-0.433736% 10.645501% 80.69782%), in gamut: false
oklch(40% 0.257 264.1) -> rgb(-0.432285% 10.337945% 80.902982%), in gamut: false
oklch(40% 0.258 264.1) -> rgb(-0.428009% 10.020094% 81.108192%), in gamut: false
oklch(40% 0.259 264.1) -> rgb(-0.420904% 9.6911045% 81.31345%), in gamut: false
oklch(40% 0.26 264.1) -> rgb(-0.410966% 9.350012% 81.518756%), in gamut: false
oklch(40% 0.261 264.1) -> rgb(-0.398194% 8.9957044% 81.72411%), in gamut: false
oklch(40% 0.262 264.1) -> rgb(-0.382582% 8.6268874% 81.929512%), in gamut: false
oklch(40% 0.263 264.1) -> rgb(-0.364129% 8.2420413% 82.134963%), in gamut: false
oklch(40% 0.264 264.1) -> rgb(-0.34283% 7.8393616% 82.340462%), in gamut: false
oklch(40% 0.265 264.1) -> rgb(-0.318682% 7.4166795% 82.54601%), in gamut: false
oklch(40% 0.266 264.1) -> rgb(-0.291682% 6.97135% 82.751607%), in gamut: false
oklch(40% 0.267 264.1) -> rgb(-0.261827% 6.5000934% 82.957254%), in gamut: false
oklch(40% 0.268 264.1) -> rgb(-0.229112% 5.9987611% 83.162949%), in gamut: false
oklch(40% 0.269 264.1) -> rgb(-0.193536% 5.4619784% 83.368694%), in gamut: false
oklch(40% 0.27 264.1) -> rgb(-0.155094% 4.8825739% 83.574488%), in gamut: false
oklch(40% 0.271 264.1) -> rgb(-0.113783% 4.2506181% 83.780331%), in gamut: false
oklch(40% 0.272 264.1) -> rgb(-0.0696% 3.5612887% 83.986225%), in gamut: false
oklch(40% 0.273 264.1) -> rgb(-0.022542% 2.8617867% 84.192168%), in gamut: false
oklch(40% 0.274 264.1) -> rgb(0.0273959% 2.1587837% 84.398162%), in gamut: true
oklch(40% 0.275 264.1) -> rgb(0.0802156% 1.452274% 84.604206%), in gamut: true
oklch(40% 0.276 264.1) -> rgb(0.1359208% 0.7422521% 84.8103%), in gamut: true
oklch(40% 0.277 264.1) -> rgb(0.1945149% 0.0287125% 85.016444%), in gamut: true
oklch(40% 0.278 264.1) -> rgb(0.2560012% -0.68835% 85.222639%), in gamut: false
oklch(40% 0.279 264.1) -> rgb(0.3203831% -1.408942% 85.428885%), in gamut: false
oklch(40% 0.28 264.1) -> rgb(0.387664% -2.133068% 85.635181%), in gamut: false
oklch(40% 0.281 264.1) -> rgb(0.4578471% -2.860734% 85.841529%), in gamut: false
oklch(40% 0.282 264.1) -> rgb(0.5309358% -3.591946% 86.047927%), in gamut: false
oklch(40% 0.283 264.1) -> rgb(0.6069335% -4.316548% 86.254377%), in gamut: false
oklch(40% 0.284 264.1) -> rgb(0.6858435% -4.982596% 86.460878%), in gamut: false

Here is the color.js code that was used to generate the data:

const hue = 264.1;
const lightness = 0.4;

for(let chroma = 0.220; chroma < 0.285; chroma += .001) {
    let color = new Color("oklch", [lightness, chroma, hue]) 
    let converted = color.to('srgb')
    console.log(`${color} -> ${converted.toString({precision: 8, inGamut: false})}, in gamut: ${converted.inGamut('srgb', {epsilon: 0})}`)
}

Looking at the data it doesn't look like the irregularity is caused by gamut checking without a strict epsilon?

lloydk avatar Aug 16 '22 08:08 lloydk

Looking at the data it doesn't look like the irregularity is caused by gamut checking without a strict epsilon?

In this case, it doesn't appear to be due to checking without a script epsilon, it seems the opposite is true. I can also reproduce this.

It seems that there is a range of chroma that, for whatever reason, causes the sRGB conversion to slip out of the sRGB gamut, and then as you increase it, it comes back into the gamut.

>>> Color('oklch(0.3 0.2 264.1)').convert('srgb')
color(srgb -0.00115 0.03051 0.56191 / 1)
>>> Color('oklch(0.3 0.207 264.1)').convert('srgb')
color(srgb 0.00057 0.00313 0.57532 / 1)

Now, I don't think this is an issue with how any of these libraries actually implement Oklab and Oklch or gamut checks. They are literally following exactly what the author of Oklab/Oklch laid out. The only difference might be related to adapting the matrix to a different white point, which the author has mentioned can and should be done if using a different white point, so this indicates that there is no assumption this should fundamentally change anything. I guess it would be interesting to see if this exists in the author's implementation if using the exact same white point assumptions that they did.

facelessuser avatar Aug 16 '22 13:08 facelessuser

Yeah, I don't think it matters what white point is being used. Even if we match the white space exactly as in the official article, we still get this behavior:

oklch_srgb_gamut

It seems to be an unintended behavior built into the model.

facelessuser avatar Aug 16 '22 14:08 facelessuser

I dug into this more, and this issue can be reproduced using the author of Oklab's code found here: https://github.com/bottosson/bottosson.github.io/tree/master/misc/colorpicker. Interestingly, this doesn't happen if you were to render a slice in display-p3, at least not that I observed with specific hue of 264.1.

I imagine this is partially due to the approach by which the Oklab matrices were created. I'm sure it was tested, but unless you were testing this specific hue value, you'd never see this. I think in almost any other case, the colors stay within the sRGB gamut and map close to the reference data set that was used to tune the matrix, but for some reason, in this very specific case, the red channel's value dip just a little below zero in sRGB causing this out of gamut strip. If you gamut map it, or even just clip the noise, you'd probably never know.

It wouldn't surprise me if the author of Oklab is not aware of this quirk.

facelessuser avatar Aug 17 '22 01:08 facelessuser

I dug into this more, and this issue can be reproduced using the author of Oklab's code found here: https://github.com/bottosson/bottosson.github.io/tree/master/misc/colorpicker. Interestingly, this doesn't happen if you were to render a slice in display-p3, at least not that I observed with specific hue of 264.1.

You can also see this using the Evil Martians color picker and turning on Show P3. The graph also draws the correct boundary lines between display-p3 and sRGB.

lloydk avatar Aug 17 '22 01:08 lloydk

Yeah, the big take away though is that this is not a bug in any of the libraries' implementation of the color space, but an inherent issue in the color space itself. There isn't much a library can do except maybe raise up the issue and see if the model gets tweaked to resolve it (or not 🤷🏻).

facelessuser avatar Aug 17 '22 03:08 facelessuser

Woah, thank you for your research! I've spent way too much time trying to debug huetone.ardov.me and didn't even think that the problem may be in the model itself 🙈

Potentially gamut clipping with a binary search can be inaccurate due to this issue.

ardov avatar Sep 07 '22 15:09 ardov

I'm not sure how big an effect this will have on gamut mapping accuracy per se. I would expect you'd still get a proper color within gamut. It would be interesting to actually explore how minor or major a color could be off due to this. I haven't personally experimented with how off this could cause a gamut mapped color to be. I imagine you may get a very small region that just doesn't have much variation (like they all map to the same color).

facelessuser avatar Sep 07 '22 15:09 facelessuser

Yep, it's definitely not a big deal. I tried it with oklch(30.57% 0.22 264.09). The binary search chroma reduction algorithm jumps over #000296 and stops at #001788. So it reduces chroma to 0.18 instead of 0.21. The difference is so subtle I can hardly see it 😅

ardov avatar Sep 07 '22 16:09 ardov

Yeah, it seems confined to a very small hue range and a small band within that very small hue range, so probably maybe not a huge deal, but yeah, not ideal either though.

I've personally constrained my color library to use LCh by default (for now), only because its weaknesses are well known in relation to gamut mapping. OkLCh is kind of new in this area, and I don't think it has been stressed enough to know where all its weaknesses are. I know I created another issue over at the CSSWG related to some unexpected behavior with OkLCh and gamut mapping: https://github.com/w3c/csswg-drafts/issues/7071. This also may not be a huge deal in most cases, but I did find it unexpected.

With that said, I think Oklab works pretty well for color interpolation at least 🙂.

facelessuser avatar Sep 07 '22 17:09 facelessuser

This fascinating discussion has concluded that yes, at this small range of hue angles, Oklch does indeed behave like that.

Given that this isn't a bug in color.js, I'm closing the issue.

svgeesus avatar Jan 16 '23 17:01 svgeesus

Similar situation in Rec2020 (hue=245.25):

OKLch                    Rec2020
[0.34, 0.303, 245.25] => [ 0.0005037267530816625,   0.005913365956266201, 0.7103168192699174]
[0.35, 0.303, 245.25] => [ 0.000003361178037647461, 0.021532914591694808, 0.7251290528636488]
[0.36, 0.303, 245.25] => [-0.0001843454136228606,   0.03826449797794927,  0.7399914514925159]
​[0.37, 0.303, 245.25] => [-0.00003239302259740173,  0.056135116115469244, 0.7549036260379635]
[0.38, 0.303, 245.25] => [ 0.0004862183504174833,   0.07517176900469433,  0.7698652017059366]
[0.39, 0.303, 245.25] => [ 0.0013984887047246292,   0.09477795017250498,  0.7848758169526304]

dom1n1k avatar Feb 25 '23 17:02 dom1n1k

This isn't surprising. Any color space that translates into this small range will exhibit the same issue.

facelessuser avatar Feb 25 '23 18:02 facelessuser