Skip to content
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

[css-color] Add OKLab, OKLCH #6642

Closed
svgeesus opened this issue Sep 20, 2021 · 65 comments
Closed

[css-color] Add OKLab, OKLCH #6642

svgeesus opened this issue Sep 20, 2021 · 65 comments

Comments

@svgeesus
Copy link
Contributor

svgeesus commented Sep 20, 2021

For the background and explanation of why to add this feature, please see this Color Workshop presentation which has a video presentation, plus associated slides and transcript. You can just skim the slides/transcript if you are pressed for time.

Having done so, and given the rationale presented, the specific proposal is to add OKLab as follows: Edit: items re-ordered so highest priority is first.

CSS Color 4

  1. in the gamut mapping section, require gamut reduction to use OKLCH chroma reduction and deltaE OK, rather than CIE LCH chroma reduction and deltaE 2000. This is both better and faster. If we don't do this, I am not confident of being able to write a satisfactory gamut mapping section in CSS Color 4. See examples below
  2. in the interpolation section, for non-legacy color formats, if the host syntax does not specify a colorspace, change lab to oklab as the default. If we don't do this, color interpolation in gradients, animations and transitions will not be as good, and will sometimes give surprising hue shifts.
  3. add oklab() and oklch() (as well as the existing lab() and lch() which are still useful) and add their implementation to the sample code. Not doing this means we have a colorspace of proven usefulness, implemented inside the browser, but we deny users access to it.

CSS Color 5

  1. add oklab and oklch as defined color spaces which can be used in color-mix(). Depends on 3.
  2. add oklab() and oklch() to Relative Color Syntax. Depends on 3.

@argyleink @una @tabatkins @LeaVerou

@svgeesus svgeesus added css-color-4 Current Work Agenda+ F2F css-color-5 Color modification labels Sep 20, 2021
@svgeesus
Copy link
Contributor Author

Note - the gamut mapping section does not yet exist, because gamut mapping in CIE LCH gave unacceptable results in the 270-330 degree hue range and also because deltaE 2000 is complex, but is needed for good results and deltaE 76 is fast but not good enough.

Using OKLCH and deltaEOK (which is the simple root-rum-of-squares) gives better results and is computationally simple.

@argyleink
Copy link
Contributor

lgtm, nice attention to detail

@facelessuser
Copy link

Curious, would it use the new lightness discussed here? https://bottosson.github.io/posts/colorpicker/#intermission---a-new-lightness-estimate-for-oklab Or would it be based on the original Oklab work that doesn't adjust the lightness?

@svgeesus
Copy link
Contributor Author

The new Lr lightness estimator brings in a dependency on viewing conditions, which the original OK L did not have; and which is an advantage of the original OK L especially if it allows extension to HDR or at least EDR. Lr also adds a little computational complexity.

I would like to examine it some more and also see how it impacts deltaE, but my initial impression is to stick with the original OK L. I'm very open to thoughts on which would be the better option though.

@facelessuser
Copy link

I would like to examine it some more and also see how it impacts deltaE, but my initial impression is to stick with the original OK L. I'm very open to thoughts on which would be the better option though.

Makes sense. I've only played with Lr to experiment with Okhsl and Okhsv, but use it in no other way. So there may be good reasons to avoid it if using it for distancing. I also hadn't thought of implications in regards to HDR. Something to experiment with though.

@tabatkins
Copy link
Member

I quickly read thru the slides, and didn't catch this detail - can you elaborate on why we'd want to add these in addition to the existing lab()/lch(), rather than just changing those functions to use the OKLab space? Is there a good reason to keep the existing Lab functions?

@svgeesus
Copy link
Contributor Author

why we'd want to add these in addition to the existing lab()/lch(), rather than just changing those functions to use the OKLab space? Is there a good reason to keep the existing Lab functions?

Good question, and the main reason I suggest keeping them around is because there is lots of hardware (instrumentation, like spectrophotometers) and software which generates Lab (or LCH, or both) values. The secondary reason is that Lab and LCH have decades of user experience with them, while OKLab/OKLCH have less than a year.

So:

  • to specify a color, people can continue to use lab() and lch() because that is convenient for them. They can read out a value from Photoshop or Krita or whatever and use it directly. In much the same way they can continue to use a98rgb() despite display-p3 being a more popular choice nowadays.
  • for general interpolation, if the host syntax doesn't specify, then the default changes from Lab to oklab because that will generally give better results; but you can still get lab if you want it, by specifying explicitly.
  • for gamut mapping, we just go straight to oklch because it is strictly better.

@tabatkins
Copy link
Member

My issue is just that, given a choice between "lab" and "oklab", where the two look practically identical in most cases, people will naturally reach for "lab". We should bless the shortest, most convenient names with the most preferred syntaxes, even if we allow other types in other ways. (And in particular, we've accidentally set a precedent of all the specialized color functions have three-letter names. That probably can't persist forever, but for now, at least, "oklab" would join "color" as looking a little out-of-place and possibly "advanced".

Presumably we'd be able to offer the old Lab via color()? (Not LCH, tho.)

@Crissov
Copy link
Contributor

Crissov commented Sep 29, 2021

Three letters you say?

  • oklab => okl(<percentage> <number> <number>)
  • oklch => okl(<percentage> <percentage> <angle>)

But this idea was already dismissed for cie(). #4481

cie() = cie( /* Lab */ [<percentage> && <number>{2}] 
           | /* Luv */  <percentage>{3} 
           | /* LCH */ [<number>{2} && <angle>] 
           [ / <alpha-value> ]? 
           ) 

okl() = okl( /* Lab */ [<percentage> && <number>{2}] 
           | /* LCH */ [<number>{2} && <angle>] 
           [ / <alpha-value> ]? 
           ) 

@svgeesus
Copy link
Contributor Author

we've accidentally set a precedent of all the specialized color functions have three-letter names

I don't think any precedent has been set.

@facelessuser
Copy link

So, I am curious. It seems there is talk about using chroma reduction using Oklch for gamut mapping/reduction. I was playing around with this and was noticing some interesting things when gamut mapping using this method. Granted, maybe I'm doing something wrong here. This is based on the same algorithm used in color.js for Lch chroma and using the currently specified ∆Eok in that library.

When using something like display-p3, things get mapped very similar as they do with Lch vs Oklch, granted I think their blue range is very similar. But when using something like rec2020 which has a wider blue range, the mapping gets dull blues. (oklch chroma reduction on top).

Screen Shot 2021-09-29 at 7 16 57 AM

This doesn't happen as much with greens and lesser with reds. Now granted there doesn't seem to be an official algorithm posted yet, and so I'm more curious if all these edge cases have been evaluated. Maybe the actual algorithm that is to be used compensates for this. I'm more curious if it improves in some places, but lesser in others, or if there is a better algorithm (different from the CIELCH one used on colorjs.io) that is planned to be used that works well with Oklch?

It's possible I'm just not implementing correctly, and in that case, I will wait patiently to better understand how the reduction is actually done, or keep plugging away to see what I'm doing wrong 🙂.

@svgeesus
Copy link
Contributor Author

Thanks for investigating. Any chance you could post your test code?
In the diagram, are the colors being displayed in sRGB? Is it just the portion of the line from neutral to the sRGB gamut boundary, being displayed? (so Rec2020 blue to sRGB gamut boundary is not on that diagram)? It would be good to know what colors are at the boundary, for the two methods.

@facelessuser
Copy link

Yep, let me throw something together publicly that you can play around with.

@tabatkins
Copy link
Member

we've accidentally set a precedent of all the specialized color functions have three-letter names

I don't think any precedent has been set.

rgb(), hsl(), hwb(), lab(), lch(). I specifically said it was accidental, but it's still there, and people pattern-match. color() is the exception, and it is an exceptional function compared to the others. This doesn't mean we're forced into this, but it does, I think, imply that people will reach for the 3-latter lab() over oklab().

Regardless, tho, that wasn't my point. My point is just that when having two names that are variants of each other, one of which is longer than the other, people will naturally reach for the shorter one by default; this is a relatively standard principle of language-design UX. If we think that OKLab is better than Lab for authors, then we'll be doing them a disservice by having lab() and oklab() as the naming. Having lab() be OKLab, and letting people still access Lab with color(cielab, ...) would enable people to have access to both when needed, but give authors the more preferable functionality with the easier syntax.

@svgeesus
Copy link
Contributor Author

I do think that rec2020 gamut mapping needs more investigation (as noted on the next steps slide) and in particular I want to do some rec2020 to display-p3 mapping (with swatches output in display-p3).

I notice that OKLab and CIE Lab have different lightness estimation for high-chroma blues.

Here is rec2020 blue with OKLCH chroma reduced to zero without clip and with clip on deltaEOK < 0.02. In both, the colors on the upper part are the sRGB color (if in gamut) or salmon (if in display-p3) or red (if outside display-p3) while the lower part shows the linear-light display-p3 component values.

I can easily put the same thing together where the upper part is display-p3 and the gamut clip is to the display-p3 gamut (but it will currently only display on Safari TP and BFO Publisher)

@svgeesus
Copy link
Contributor Author

Having lab() be OKLab, and letting people still access Lab with color(cielab, ...) would enable people to have access to both when needed, but give authors the more preferable functionality with the easier syntax.

oof. That would be super confusing, surely.

However, renaming lab() to cielab() and lch() to cielch() would be doable, and more self-describing, and would make oklab() the shorter one. I guess. (I'm not a fan of bikeshedding churn, which hurts early adopters).

@facelessuser
Copy link

So, here is a link to a working example with code. You can edit it and play around with it: https://facelessuser.github.io/coloraide/playground/?source=https%3A%2F%2Fgist.githubusercontent.com%2Ffacelessuser%2F26ff61052f1e4a180d2f98a499d08595%2Fraw%2F454c4a9911e626e537f184b281f51382e83025aa%2Fokfit.py

Basically, I am just interpolating between black and blue in the rec2020 space. Then I convert it to sRGB and fit it in that space and display it directly.

I scale the ∆Eok to by 100 as the difference between black and white is ~0 - 1 and ∆E2000 is ~0-100. I've done it without the scaling, adjusting the limits and such, and the results are basically the same. It seems green starts getting mixed in as chroma reduces.

Again. Maybe there are some assumptions here that just aren't right. This is the exact same algorithm used for the CIELCH fitting.

@LeaVerou
Copy link
Member

LeaVerou commented Oct 6, 2021

I have been debating this with myself for a good while, hence the lack of comment.

I did watch @svgeesus' talk (and I really recommend watching it to anyone who wants to have an opinion on this), and I do agree there are significant advantages in Oklab over Lab. However, Lab has been established for 45 years. It has a ton of tooling around it. It has drawbacks, yes, but those are widely studied. Same as all the color formats currently in CSS, they all have drawbacks, but are all old and established, and their drawbacks studied and known. Oklab on the other hand is very new. It has not yet been fully explored, and it has almost no tooling that supports it. Its advantages are known, but its drawbacks are still being researched. It seems a little premature to add it to CSS, especially as a named function. I’m not saying we shouldn't, but it gives me pause.

As a secondary point, we need to decide when we add things as a named function and when as a color() keyword. Right now we have both, and some color spaces can only be expressed via color() and others via named functions. From an author point of view, the distinction is unclear. The spec lists color() as "Profiled, device dependent colors". Does that mean that any color space we add with a restricted gamut is under color() and anything else is not?

I disagree that authors will always reach for the shortest syntax. Firstly, a lot of the time colors are reused via variables, not re-specified. Secondly, if that were true, #RGB would be the most popular color syntax, which is not the case.

One issue with Oklab is that it uses different coordinates than Lab, namely 0-1 ranges instead of 0-100. If we do add such a syntax in CSS, I wonder if there is value in trying to harmonize the two a little.

@tabatkins
Copy link
Member

tabatkins commented Oct 6, 2021

Secondly, if that were true, #RGB would be the most popular color syntax, which is not the case.

I made my point pithily in the middle of a longer argument, but to be more specific: given two seemingly identical or very similar pieces of functionality, people will tend to reach for the one with shorter name/syntax, both because it's easier, and because (partially due to this effect) API designers usually give the preferred solution a shorter name. Hex notation, especially 3-char hex, is substantially different in both abilities and syntax to any other color function, so it doesn't necessarily fit into this. On the other hand, lab() and oklab() functions, which take essentially identical arguments and output approximately identical colors, are exactly the pattern I'm talking about.

(Also who would choose "oklab" over "lab"? If it was betterlab(), sure, but just ok? Pass. ^_^)

(Also, for the generic case of "I need a quick color and I know very roughly what it should be but don't care about the details right now", I personally do reach for 3-char hex first precisely because it is the shortest color syntax that satisfies that use-case.)

One issue with Oklab is that it uses different coordinates than Lab, namely 0-1 ranges instead of 0-100. If we do add such a syntax in CSS, I wonder if there is value in trying to harmonize the two a little.

Our lab() doesn't use a 0-100 syntax anyway, it uses 0%-100%. I don't see why we wouldn't use exactly the same thing here. Whoops I misremembered. Well anyway, yeah, if we did add an oklab() I'd match the coordinate ranges; whether a given paper uses 0-100, 0-1, or 0%-100% is completely arbitrary and up to the whims of the author, but CSS needs to be more predictable than that.

@LeaVerou
Copy link
Member

LeaVerou commented Oct 6, 2021

@svgeesus made the point that we need OkLab to be able to specify a reasonable gamut mapping algorithm in Color 4, and as an interpolation space for gradients etc, so it doesn't actually matter whether authors use it, because we can still use it as an interpolation and gamut mapping space.

For me that tips the scales towards yes, let's add this. My question regarding named functions vs color() still stands.

@Ptico
Copy link

Ptico commented Mar 29, 2023

Hi, small question out of curiosity, feel free to just ignore or delete it:

I'm an not a color expert, just an average web-developer, like, you know, 90% of those who use CSS daily. For almost 18 years in a web, I never ever seen a LAB or LCH in a production cycle, except some extremely rare cases of dealing with clunky printed brandbooks back in 2000s or the posts of some clever people on css-tricks. At the same time I've seen some pretty good adoption of HSL, because you don't need to be a Will Hunting to decipher on the fly that 100 100 50 is a toxic green or 360 50 30 is a color of the lipstick of your history teacher.

Last couple of years, thanks to @LeaVerou and others, I also see a huge progress in an implementation of a good practices in a a11y area, including proper color contrasts. And here is new perceptually uniform color spaces in CSS: easy to maintain color contrast, create accessible palettes, happy days! Except the fact we now should bring back a solar powered Casio calculator back to the working table and deal with acceptable values or AB mixture.

So the question is: oklab and oklch is great, but why we can't have an okhsl/okhsv for regular people who just want to add some color for their wordpress blog?

Thanks

@LeaVerou
Copy link
Member

LeaVerou commented Mar 29, 2023

because you don't need to be a Will Hunting to decipher on the fly that 100 100 50 is a toxic green or 360 50 30 is a color of the lipstick of your history teacher

Both LCH and OkLCH have this property, just with different ranges. If anything, they have this property more because you know that e.g. lch(50% 60 30) is medium lightness due to the 50% regardless of the rest of the values of the other coordinates. HSL does not provide this guarantee at all: hsl(60 100% 50%) is a very light color (#ffff00) and hsl(240 100% 50%) a very dark one (#0000ff) despite having the same lightness. That's the exact problem these new color spaces are trying to solve: making things more predictable.

So the question is: oklab and oklch is great, but why we can't have an okhsl/okhsv for regular people who just want to add some color for their wordpress blog?

What would these do? How do you envision they'd work? (I'm not asking for implementation details, just trying to understand what it is you're asking for)

@Ptico
Copy link

Ptico commented Mar 29, 2023

What would these do? How do you envision they'd work? (I'm not asking for implementation details, just trying to understand what it is you're asking for)

OkLAB have an accompanying color spaces named OkHSL and OkHSV (https://bottosson.github.io/posts/colorpicker/) which address an issue (human-friendliness) I'm talking about in exchange of some trade-offs. Which is good enough for most of the use-cases. I thought if we can have a traditional HSL in a CSS Color 3 and OkLAB in Color 4 it would be natural to have at least OkHSL at some point

@LeaVerou
Copy link
Member

@Ptico Fascinating, I didn't know Bjorn also created OkHSL and OkHSV spaces. A shame they are also restricted to sRGB. Anyhow, you should open another issue so we can discuss this properly.

@svgeesus
Copy link
Contributor Author

And here is new perceptually uniform color spaces in CSS: easy to maintain color contrast, create accessible palettes, happy days! Except the fact we now should bring back a solar powered Casio calculator back to the working table and deal with acceptable values or AB mixture.

No you would just use an online tool, like everyone else.

@svgeesus
Copy link
Contributor Author

address an issue (human-friendliness) I'm talking about in exchange of some trade-offs. Which is good enough for most of the use-cases.

The trade-offs are that they are no longer perceptually uniform, and that they are restricted to sRGB. These are very significant limitations; the former is the whole point of Oklab and the latter is deeply restricting when the Web has moved to display-p3 and is headed to Rec BT.2100.

@Ptico
Copy link

Ptico commented Mar 29, 2023

No you would just use an online tool, like everyone else.

I know, I'm exaggerating a bit, but jokes aside: I tried all of them and had almost memetic experience I wish to forget

@JamesEggleton
Copy link

JamesEggleton commented Mar 30, 2023

@svgeesus I only just became aware of these efforts to improve the behaviour of the OKLab matrices when used with reference white of xy=(0.3127, 0.3290). This is work I have also done independently, so I thought I should share my results.

I have adjusted the four matrices such that they are accurate to float64 (IEEE 754 double precision), see code fragment below.

As far as I can tell the XYZtoLMS/LMStoXYZ/LMStoOKLab/OKLabtoLMS matrices currently in use (see here) are accurate to float32 precision. You'll notice that they match my numbers to somewhere between 6 and 8 significant figures, i.e. the expected precision of float32, or thereabouts.
You should find that that my proposed values are slightly better behaved due to their additional precision, i.e. a D65 grey will map to LMS with components L=M=S, and OKLab with components a=0 and b=0.

My methodology has much in common to those proposed by @bottosson (see #6642 (comment)) and @facelessuser (see #6642 (comment)).

The M1 matrix fix (which relates XYZtoLMS and LMStoXYZ) involves applying gains in LMS space such that a chromaticity of xy=(0.3127, 0.3290) is exactly mapped to LMS=(1,1,1).

The M2 matrix fix (which relates to LMStoOKLab and OKLabtoLMS) involves interpreting the originally published numbers as float32 numbers, and then emitting the resulting matrix and its inverse in float64 precision. While this might sound like a bad idea, it is the correct approach because Bjorn's original optimisation emitted M1 and M2 at float32 precision, printed to 10 d.p. . Therefore it is correct to interpret them as float32 values. The result of handling the numbers in their native datatype is that my OKLabtoLMS matrix ends up with exact values (1.0, 1.0, 1.0) for its first column, and the rows of LMStoOKLab sum to (1, 0, 0). Note that due to the inherent precision of float64 there may be discrepencies in the order of 1e-15 when summing matrix rows, which is unavoidable and of no real world consequence.

Note that my knowledge of M1 and M2 matrices being defined to float32 precision comes from private communications with Bjorn in February 2021.

Note that all my intermediate calculations were carried out in "infinite precision" using a rational number library before being rounded to float64. The numeric values in the code snippet below have sufficient decimal places to exactly map to the intended float64 value, generally 16 or 17 significant figures.

function XYZ_to_OKLab(XYZ) {
	// Given XYZ relative to D65, convert to OKLab
	var XYZtoLMS = [
		[ 0.819022442647055,   0.3619062604571286, -0.12887378595068213 ],
		[ 0.0329836558550745,  0.9292868602921457,  0.03614466586101669 ],
		[ 0.04817719382823899, 0.2642395276821537,  0.6335478283072317  ]
	];
	var LMStoOKLab = [
		[ 0.21045426824930336,   0.7936177747759865, -0.00407204302528986 ],
		[ 1.977998539071675,    -2.4285922502018127,  0.4505937111301374  ],
		[ 0.025904030547901084,  0.7827717270900503, -0.8086757576379514  ]
	];

	var LMS = multiplyMatrices(XYZtoLMS, XYZ);
	return multiplyMatrices(LMStoOKLab, LMS.map(c => Math.cbrt(c)));
	// L in range [0,1]. For use in CSS, multiply by 100 and add a percent
}

function OKLab_to_XYZ(OKLab) {
	// Given OKLab, convert to XYZ relative to D65
	var LMStoXYZ =  [
		[  1.226879868224423,   -0.5578149934498884,   0.281391052277137   ],
		[ -0.04057574728813353,  1.1122868053350754,  -0.07171105804694196 ],
		[ -0.0763729441641797,  -0.42149332236303205,  1.5869240172870902  ]
	];
	var OKLabtoLMS = [
		[ 1.0,  0.3963377773761749,   0.21580375730991364 ],
		[ 1.0, -0.10556134581565857, -0.0638541728258133  ],
		[ 1.0, -0.08948418498039246, -1.2914855480194092  ]
	];

	var LMSnl = multiplyMatrices(OKLabtoLMS, OKLab);
	return multiplyMatrices(LMStoXYZ, LMSnl.map(c => c ** 3));
}

I can share my workings if desired, but it will take a bit of time to make it work as a standalone code fragment that is decoupled from proprietary libraries. In the mean time please feel free to try out these refined numbers.

@facelessuser
Copy link

@JamesEggleton I'd love to see the work! One of the issues with the current matrices is that Oklab and OkLCh just don't get chroma as close to zero when colors are achromatic. That leads to messy results when assuming zero for chroma or zero for a and b in Oklab, especially as lightness increases. This also makes the hue have more influence in good conversions as chroma is not quite small enough to completely drown out its influence.

>>> Color('white').convert('oklch', norm=False).set('hue', 0).set('chroma', 0).convert('srgb')[:]
[0.9999999610079292, 0.9999999999787738, 1.0000001063154036, 1.0]

But your matrices actually yield some pretty good results and have a "tightness" in the conversion that is more inline with the current Lab and LCh conversions:

>>> Color('white').convert('oklch', norm=False).set('hue', 0).set('chroma', 0).convert('srgb')[:]
[1.0000000000000004, 0.9999999999999997, 0.9999999999999999, 1.0]
>>> Color('white').convert('lch', norm=False).set('hue', 0).set('chroma', 0).convert('srgb')[:]
[0.9999999999999999, 0.9999999999999999, 0.9999999999999997, 1.0]

@JamesEggleton
Copy link

@facelessuser I'm happy to see you achieved decent results using these matrices. I'll clean up and share my workings as soon as I get a chunk of free time, hopefully in the next week or so...

@facelessuser
Copy link

Objectively, as someone that supports Oklab in a library outside of CSS, I can generally say these matrices seem to give values that you would expect while giving a better response for chroma and pretty much eliminating the effect of hue on achromatic colors.


This has nothing to do with CSS

With that said, and this would not specifically be a problem for CSS, I also support Okhsl and Okhsv. Using both the XYZtoLMS and LMStoOKLab matrix seems to have a noticeable impact on those two color spaces. I've tried using the workbook provided by Björn in his blog, updating the values to give better Okhsl and Okhsv support with these matrices, but there seems to be some tuning in the translation algorithm's not quite explained. Blue was pushed further out of the gamut, and then the algorithm would generate values too large for the language I was in and cause an overflow (probably due to the high precision of the values).

What I did find was that by simply using the more accurate LMStoOKLab matrix, I was able to get the same great response driving chroma to zero without causing a disparity between Oklab and Okhsl/Okhsv. I did not increase the precision of matrix calculations in Okhsl/Okhsv. Round trip was excellent between all these spaces.

I am most interested in getting the float 64 representation of this matrix.

[
    [0.77849780, 0.34399940, -0.12249720],
    [0.03303601, 0.93076195, 0.03620204],
    [0.05092917, 0.27933344, 0.66973739]
]

I wonder if the fact that I use a non-rational XYZtoRGB matrix everywhere else and that the calculation as you suggested used a rational number calculated XYZtoRGB matrix causes some disparity. Or if the Okhsl and Okhsv algorithm just isn't tuned well for these high-precision matrices and a slight shift to the response.

Regardless, using the high-precision LMStoOKLab matrix is more than sufficient to fix achromatic issues and provide good translations.

@facelessuser
Copy link

Okay, I was more systematic and was able to get Okhsl/Okhsv working with higher precision matrices but only if using the newly calculated LMStoOKLab matrix. I was able to use the Okhsl/Okhsv notebook provided in the related blog to update the tuning and no longer get any of the issues I did previously. It even gave better conversions for these spaces as well.

When I added the newly calculated LMS matrix in addition to the Oklab matrix, it seems to throw things a little off (pushing blue further out of gamut as an example), so for now, I'll probably stick with just the improved Oklab matrix until I can verify the LMS matrix. Considering how much better the calculations are with just the OKLAB matrix, I think the LMS matrix at slightly lower accuracy wasn't the driving force in bad chroma calculations.

>>> Color('gray').convert('oklab')[:]
[0.5998708056221469, -5.551115123125783e-17, -5.551115123125783e-17, 1.0]
>>> Color('white').convert('oklab')[:]
[1.0, -2.7755575615628914e-16, 0.0, 1.0]
>>> Color('darkgray').convert('oklab')[:]
[0.7348085828066983, -1.1102230246251565e-16, -1.1102230246251565e-16, 1.0]

I do wonder if creating the matrices with rational numbers (which also I assume includes the white point) causes the calculation to be different as everywhere else in my code, the white point is applied with floating point math, which likely leads to a disparity in white points causing calculations to not be quite right. It's also very likely that the Okhsl/Okhsv algorithm just needs some more manual tuning at whatever extreme the new matrix puts it at. I'll look into this more once the work is posted.

Regardless, even with just the improved Oklab matrix, things are actually no worse than when including the LMS matrix. And great conversion back to sRGB:

>>> Color('white').convert('oklab').convert('srgb')[:]
[0.9999999999999999, 0.9999999999999999, 0.9999999999999997, 1.0]

@jessestricker
Copy link

@JamesEggleton Did you find some time to publish your work yet? Looking forward to it! 🙂

nex3 added a commit to sass/dart-sass that referenced this issue Oct 2, 2024
This recomputes all existing LMS-related matrices based on the
improved XYZD65/LMS matrices in
w3c/csswg-drafts#6642#issuecomment-1490068959.

See sass/sass-spec#2024
nex3 added a commit to sass/dart-sass that referenced this issue Oct 2, 2024
This recomputes all existing LMS-related matrices based on the
improved XYZD65/LMS matrices in
w3c/csswg-drafts#6642#issuecomment-1490068959.

See sass/sass-spec#2024
@nex3
Copy link
Contributor

nex3 commented Oct 2, 2024

What are considered the best set of XYZD65/LMS matrices to use now? The ones from #6642 (comment) or the ones from color-js/color.js#357?

@facelessuser
Copy link

@nex3 The CSS spec has been updated with the ones from color.js, I would just follow the spec.

@nex3
Copy link
Contributor

nex3 commented Oct 2, 2024

What's the reasoning behind the differences between the spec's values and @JamesEggleton's?

@facelessuser
Copy link

There's no specific insight into exactly what was done with the James'. There was a claim to have a 64 bit version of the initial matrix of

[
    [0.77849780, 0.34399940, -0.12249720],
    [0.03303601, 0.93076195, 0.03620204],
    [0.05092917, 0.27933344, 0.66973739]
]

But it was never posted. My personal interest was to see the approach and verify it. I had noticed an issue using Jame's matrices when applied to Okhsl as well, so I was hesitant to use them without being able to see the process and understand it more. I won't speculate as to exactly what the issue is, only that I had no further insight without being able to evaluate what was actually done. This, of course, does not specifically affect CSS as it does not currently use Okhsl, but it was a concern for me.

I make no claims on the correctness or incorrectness of what James provided. It is likely that there is absolutely nothing wrong with the approach and that Okhsl simply did not handle the 64 bit version of the XYZ_to_LMS portion of the algorithm.


With that out of the way, I can give details as to what is done in Color.js and provide a reproducible example of how it was generated.

The problem with the Oklab matrices as previously defined is that they simply do not resolve oklab(1 0 0) to LMS [1, 1, 1] in 64 bit. This is due to how the 32 bit matrices are applied in a 64 bit data type environment, which Color.js uses. Because of this, as lightness increases, achromatic values for a and b deviate further from zero. When trying to apply CSS logic for powerless values, a larger and larger threshold would need to be used as lightness increases.

CSS simply took the LMS_to_Oklab matrix (the forward transform) and generated the 64 bit inverse matrix for the reverse transform. The problem is that the forward transform does not give you oklab(1 0 0) == LMS [1, 1, 1] in a 64 bit environment.

It turns out the 32 bit inverse matrix does support oklab(1 0 0) == LMS [1, 1, 1], so if you use reverse transform to generate the forward transform for LMS_to_Oklab, you resolve the disparity for achromatic values. You can further emit the reverse transform as 64 bit before inverting the matrix which cleaned up the results a bit more. That is all that was needed and all that was done.

These matrices also did not cause adverse effects when used in Okhsl.

You can recreate them yourself with this script: https://github.com/color-js/color.js/blob/main/scripts/oklab_matrix_maker.py. This makes the changes transparent.

@nex3
Copy link
Contributor

nex3 commented Oct 3, 2024

Ah thanks, that's really helpful! I can re-run that with my own lossless numerics to get even more precise intermediate matrices.

@facelessuser
Copy link

Sure! Having something reproducible makes such things possible.

nex3 added a commit to sass/dart-sass that referenced this issue Oct 10, 2024
This recomputes all existing LMS-related matrices based on losslessly applying the logic described in w3c/csswg-drafts#6642 (comment).

See sass/sass-spec#2024
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