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

Add the HCT color space #380

Merged
merged 5 commits into from
Jan 24, 2024
Merged

Conversation

facelessuser
Copy link
Collaborator

Implements the HCT color space.

Copy link

netlify bot commented Dec 19, 2023

Deploy Preview for colorjs ready!

Name Link
🔨 Latest commit 516d450
🔍 Latest deploy log https://app.netlify.com/sites/colorjs/deploys/65a6914780757f0008f463fb
😎 Deploy Preview https://deploy-preview-380--colorjs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

@facelessuser
Copy link
Collaborator Author

As a note, this does not port Google's Material Color Utility implementation. This implements the color space as described in https://material.io/blog/science-of-color-design. It combines CAM16 JCh and Lab D65.

I did an experiment with Google's lib, and they snap conversions to HCT to chroma bands and only convert back to about 8 bits worth of precision (hex sRGB). This implements the full color space and is not restricted to sRGB. It generally does well when converting colors within the visible spectrum, but pushing out beyond that, conversion precision will drop. Even CAM16's geometry gets really weird as you push outside the visible gamut, so this isn't much of a surprise.

The last PR should implement a color distancing algorithm while in HCT and a way to gamut map in HCT such that tonal palettes can be produced (similar to Google's). An example of what should be achievable: https://facelessuser.github.io/coloraide/colors/hct/#tonal-palettes.

@facelessuser
Copy link
Collaborator Author

HCT rendered in the Rec. 2020 gamut

hct

@facelessuser
Copy link
Collaborator Author

I want to investigate a couple of extreme cases, so putting this in draft.

@facelessuser
Copy link
Collaborator Author

facelessuser commented Dec 22, 2023

I think edge cases are understood. Because the only way to reverse transform this space is via approximation, it is simply impossible to perfectly convert every color value that may be thrown at it.

I ran a script for a while and it would randomly create a color within some of the largest spaces: Rec2020, Rec2100 PQ, Rec. 2100 HLG, ACEScg. It would then convert them to HCT and then back and see if the colors were the same up to about 5 digits. After 6 million +, I only had 3 cases, all of which were barely outside of the target:

acescg: [0.5006725107598761, 0.4440632949467216, 0.7342250000017175] != [0.5006725107581568, 0.44406329494514485, 0.7342249999994983]

rec2100-pq: [0.055817905259596734, 0.011004999987581199, 0.9368191851190308] != [0.055817905272786385, 0.011005000156002375, 0.9368191851190375]

acescg: [0.9119609101495745, 0.14183407744178544, 0.8107950000003002] != [0.9119609101461479, 0.14183407744074283, 0.8107949999973072]

I don't think any of these are worth spending time on more iterations or more complex code.

There are of course some more extreme cases. When you push HCT (which is basically CAM16) past the visible spectrum, you can get some harder to calculate colors. The algorithm will have a more trouble.

  1. Sometimes, Newton's method just doesn't converge fast enough.
  2. Sometimes Newton's method will start converging in one direction and then change direction.

I've been able to implement code that can actually steer it in the right direction in both of these cases, but even then, it can't fix all cases, and not always in a reasonable amount of iterations. To explain why this happens, we can look at CAM16 JMh.

Consider the low light color with a chroma way larger than a color at that lightness has any right to have. If we try and round trip the CAM16 color, it ends up with an inverse saturation and a rotated hue. It is out of the visible gamut. This is not unique to our implementation, this behavior has been confirmed in other CAM16 implementations as well.

>>> Color('blue').convert('cam16-jmh').set('j', 2)
color(--cam16-jmh 2 62.442 282.75 / 1)
>>> Color('blue').convert('cam16-jmh').set('j', 2).convert('srgb').convert('cam16-jmh')
color(--cam16-jmh 2 -42.607 102.75 / 1)
>>> Color('blue').convert('cam16-jmh').set('j', 2).convert('xyz-d65')
color(xyz-d65 -0.04159 0.00375 -0.31127 / 1)

This of course affects our ability to approximate colors in HCT to the same accuracy. While some workarounds can help these converge better, it doesn't change the behavior because this is how CAM16 works with some of these extreme colors.

So, does this affect tonal palettes? Yes, but it can be managed. This explains why Google forces their implementation to keep everything in sRGB, because it is easier to handle. But really, when generating tone maps, I found that this is mainly problematic in low lightness situations. You can generally identify the cases simply by testing to see if the round trip flips the saturation and hue. You can assume in these cases the chroma is massively too large for the lightness and simply cut it and generate and fit a color with a more workable chroma. It appears it may not as long as you keep the HCT color in its original form when gamut mapping. My library was converting the color to the target gamut and then converting it back to HCT which exposed this issue. This was unnecessary and has now been fixed.

We are able to generate tonal palettes that pretty much align with Material's results:

Screenshot 2023-12-22 at 12 53 05 PM

Results can be viewed via the link below. I was experimenting with generating OkLCh tonal palettes vs HCT. Spoiler, you can actually generate some pretty good tonal palettes with OkLCh without all of the complicated overhead of HCT, it just requires toeing the lightness and tightening the gamut mapping: https://facelessuser.github.io/coloraide/playground/?notebook=https%3A%2F%2Fgist.githubusercontent.com%2Ffacelessuser%2F0235cb0fecc35c4e06a8195d5e18947b%2Fraw%2F735d97fa895a0f9f1610189a31028b7bb1bbc2bb%2Fexploring-tonal-palettes.md

I think with this analysis, I am confident to say that I feel the conversion algorithm is good enough. As I mentioned there are maybe two things that could additionally be done to improve the accuracy of some already bad cases of colors outside the visible spectrum, but they are still inherently bad just due to how CAM16 handles these extreme colors.

I will move this back into the review state.

@facelessuser facelessuser marked this pull request as ready for review December 22, 2023 20:09
Copy link
Member

@svgeesus svgeesus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great overall, wasn't sure about the tests though.

},
tests: [
{
name: "sRGB white to CAM16 JMh",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to CAM16 JMh or to HCT?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, missed this. I copied the CAM16 JMh tests tests and altered them for HCT. I forgot to change the names though.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in latest commit

@svgeesus
Copy link
Member

Does the Google implementation map to the sRGB gamut in CAM16 JMh or in some other space?

@facelessuser
Copy link
Collaborator Author

Does the Google implementation map to the sRGB gamut in CAM16 JMh or in some other space?

I'm not quite sure I understand the question, so I'll answer as best I can. The Material library is restricted to the sRGB gamut. That's an implementation detail not a limitation of the space. Their interface allows you to specify 8bit, sRGB hex codes and it converts them to HCT. Conversion back gives you 8 bit, sRGB hex codes. I think their white point is a slightly different, rounded D65 white point than what CSS uses: 95.047, 100.0, 108.883; we use the CSS white point. Their logic is optimized to return 8 bit sRGB. If you alter an HCT color in their library, it snaps to some sRGB color, if I recall correctly. I assume they clamp to sRGB to give consistent results across all monitors, but this also currently limits them to the sRGB gamut. Specifically, for HCT CAM16 JCh is used, not JMh.

We do not clamp this implementation. It gives full resolution of the space. In order to convert back, we must convert both the LCh part and the CAM16 parts. Since the LCh Y part is easy to convert back to XYZ with no additional context, the bulk of the conversion part is trying to figure out the missing CAM16 J part in order to convert the chroma and hue parts of CAM16 JCh, which is required for the algorithm to accurately convert back to XYZ and then to any other gamut.

The accuracy of the algorithm is limited by time. The Newton algorithm's success is also influenced by the initial guess. There are probably more time-consuming ways to get a better initial guess, but for 99% of the gamuts that fall within the visible spectrum, we are able to hit our accuracy target with a "close enough guess" using our simple polynomial. The CAM16 JMh, JCh, etc. algorithm breaks down a bit with colors outside the visible spectrum, so HCT will also start to distort when the visible spectrum is exceeded.

You can see CAM16 wrapping blue back into itself with ProPhoto which pushes these colors outside the visible spectrum.

cam16-jmh-prophoto

HCT does the same and will have less accuracy in these regions which will push Newton's method harder.

hct-prophoto

@svgeesus svgeesus merged commit a5c0159 into color-js:main Jan 24, 2024
4 checks passed
jgerigmeyer added a commit to oddbird/color.js that referenced this pull request Jan 30, 2024
* main:
  Fix toPrecition (was off by one for fractional inputs) (color-js#384)
  Add the HCT color space (color-js#380)
  Add gamutSpace to ColorSpace
  Add CJS type defs for node16 resolution. (color-js#383)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants