Skip to content

Instantly share code, notes, and snippets.

@facelessuser
Last active April 26, 2024 17:59
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save facelessuser/0235cb0fecc35c4e06a8195d5e18947b to your computer and use it in GitHub Desktop.
Save facelessuser/0235cb0fecc35c4e06a8195d5e18947b to your computer and use it in GitHub Desktop.
Exploring Tonal Palettes

Exploring Tonal Palettes

HCT

HCT is a color model developed by Google. It aims to solve a problem related to generating color palettes with good contrast. While HCT may seem like a revolutionary color model, the idea behind it is quite simple, take the perceptually uniform color model CAM16 and combine it with the CIE Lab's lightness.

Upside of HCT

When constructing HCT, Google chose CAM16 as it is more perceptually uniform than CIE Lab, but also chose CIE Lab's lightness as it is more ideal for contrast. Combining these two models creates the best of both worlds. With this new color model, you pick a color and just adjust the tone (lightness) and get palettes with much better contrast.

For similar results to Google, we need to take the HCT color model, generate colors with different tones and gamut map them in HCT fairly tight in sRGB.

def hct_tonal_palette(c):
    """HCT tonal palettes."""

    c = Color(c).convert('hct')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('tone', tone).fit('srgb', method='hct-chroma', jnd=0.0).convert('srgb') for tone in tones]


colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

for color in colors:
    Steps(hct_tonal_palette(color))

It was determined that when using this model, that CIE Lab lightness could be the sole deciding factor to determine contrast between these colors.

Downside of HCT

CAM16 is an expensive color model to calculate. Combining two disparate color models means calculating back out of the space is now more difficult and requires more complex calculations to approximate back out of the color model. And while CAM16 is "perceptually accurate", there is no perfectly perceptual model. CAM16 still suffers from purple shifts in the blue region as an example.

What About OkLCh?

Some people may be interested in other solutions. CSS already makes Oklab/OkLCh available, it is much easier and far less expensive to calculate. It also has much better hue preservation in the blue region. But if we try it, we can see the lightness is not so desirable.

Scaling the tone pattern from [0, 100] down to [0, 1] (Oklab's lightness scaling) and gamut mapping the colors to sRGB tightly using OkLCh, we can see that the lightness poses an issue.

def oklch_tonal_palette(c):
    """OkLCh tonal palettes."""

    c = Color(c).convert('oklch')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('l', tone / 100).fit('srgb', method='oklch-chroma', jnd=0.0).convert('srgb') for tone in tones]

colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

for color in colors:
    Steps(oklch_tonal_palette(color))

But Björn Ottosson, in his blog post about Okhsl and Okhsv provided an alternative lightness for Oklab/OkLCh. This alternative lightness was used to create Okhsl and Okhsv with a lightness response similar to what people expect with CIE Lab. Using a toe function to adjust the black level, he was able to better approximate CIE Lab lightness.

So what happens if we try to use this lightness to generate tonal maps in OkLCh? Let's find out!

To do so, we can use the existing OkLCh color space, but when setting the tone, we will approximate CIE Lab lightness by using the inverse toe to translate the tone values back to normal Oklab and OkLCh lightness.

K_1 = 0.206
K_2 = 0.03
K_3 = (1.0 + K_1) / (1.0 + K_2)

def toe_inv(x: float) -> float:
    """Inverse toe function for L_r."""

    return (x ** 2 + K_1 * x) / (K_3 * (x + K_2))

def oklch_tonal_palette(c):
    """OkLCh tonal palettes."""

    c = Color(c).convert('oklch')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.0) for tone in tones]

colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

for color in colors:
    Steps(oklch_tonal_palette(color))

The results seem to have pretty decent contrast, and our blue palette doesn't have a purple shift. But let's compare and see how OkLCh looks next to HCT.

K_1 = 0.206
K_2 = 0.03
K_3 = (1.0 + K_1) / (1.0 + K_2)

def toe_inv(x: float) -> float:
    """Inverse toe function for L_r."""

    return (x ** 2 + K_1 * x) / (K_3 * (x + K_2))

def oklch_tonal_palette(c):
    """OkLCh tonal palettes."""

    c = Color(c).convert('oklch')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.0).convert('srgb') for tone in tones]

def hct_tonal_palette(c):
    """HCT tonal palettes."""

    c = Color(c).convert('hct')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('tone', tone).fit('srgb', method='hct-chroma', jnd=0.0).convert('srgb') for tone in tones]

colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

for color in colors:
    Steps(hct_tonal_palette(color))
    Steps(oklch_tonal_palette(color))

The results are surprisingly similar, but there is still a noticeable difference in lighting in the dark region. Is it possible to tweak the toe to get a little closer to HCT results? Here we change the K_1 value to 0.173 and the K-2 value to 0.004. This changes achromatic lightness to more closely match CIE Lab and gives us results that appear closer to the HCT results.

K_1 = 0.173
K_2 = 0.004
K_3 = (1.0 + K_1) / (1.0 + K_2)

def toe_inv(x: float) -> float:
    """Inverse toe function for L_r."""

    return (x ** 2 + K_1 * x) / (K_3 * (x + K_2))

def oklch_tonal_palette(c):
    """OkLCh tonal palettes."""

    c = Color(c).convert('oklch')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.0).convert('srgb') for tone in tones]

def hct_tonal_palette(c):
    """HCT tonal palettes."""

    c = Color(c).convert('hct')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('tone', tone).fit('srgb', method='hct-chroma', jnd=0.0).convert('srgb') for tone in tones]

colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

for color in colors:
    Steps(hct_tonal_palette(color))
    Steps(oklch_tonal_palette(color))

Conclusion

While HCT does make it easier to create palettes with decent contrast, there may be less computationally expensive approaches to get similar results.

@TaigaYamada
Copy link

I just stumbled on your gist!
I was wondering how OKLrCH (the improved OKLCH) stacks up to HCT ever since Google's blog post compared HCT against OKLAB instead, so this analysis is extremely interesting. Thank you for implementing HCT in color.js as well. I can't wait to get my hands on it once the feature releases.

Based on these comparisons, you said

While HCT does make it easier to create palettes with decent contrast, there may be less computationally expensive approaches to get similar results.

Would you say that if computational load is not an issue, HCT is better than OKLrCH in terms of perceptual uniformity?

I guess this is a problem of purple shift in the blues vs lightness accuracy, so its subjective, but I wanted to hear your opinion.

@facelessuser
Copy link
Author

Would you say that if computational load is not an issue, HCT is better than OKLrCH in terms of perceptual uniformity?

@TaigaYamada I'm not sure the lightness difference is a perceptual issue. It is more an issue related to the lightness response in creating tonal maps with good contrast. I believe the lightness, even in OkLCh proper was perceptually fine, its response was just not well defined for good contrast when specifying in percentages of 10, 20, etc.

I personally think the OkLCh probably does a better job with perceptual uniformity relating to hues than CAM16 (which HCT is based on ).

As far as tonal maps are concerned, OkLrCh does better than OkLCh, but it was not sufficient to match HCT, I had to then modify the toe function further (as described in the gist) to then be comparable to HCT.

Try looking at the live rendered document to see actual color comparisons: https://facelessuser.github.io/coloraide/playground/?notebook=https%3A%2F%2Fgist.githubusercontent.com%2Ffacelessuser%2F0235cb0fecc35c4e06a8195d5e18947b%2Fraw%2F3ca56c388735535de080f1974bfa810f4934adcd%2Fexploring-tonal-palettes.md.

@facelessuser
Copy link
Author

In general, in relation to tonal maps, I make no claims of which is better or worse, only that you can get compareable results to HCT by adjusting the lightness via a toe function, similar, but not same to OkLrCh.

@TaigaYamada
Copy link

Ah, I didn't realize that there was a live rendered version of the document, thank you!

I see, the lightness issue in OKLrCh is less about perception, and more about how the scale is mapped.
I'll try to play around a bit myself to deepen the understanding.

Again, thanks!

@facelessuser
Copy link
Author

Yeah, it isn't clear that I use this gist as a dynamic notebook, I really didn't think about people stumbling on it naturally, but I think seeing it rendered, or even interacting with it makes everything make more sense.

Glad you find it interesting. I thought was a neat experiment.

@juanmacuevas
Copy link

This is a great essay. Thanks!

@facelessuser
Copy link
Author

@juanmacuevas I'm glad you found it interesting!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment