Skip to content

Instantly share code, notes, and snippets.

@facelessuser
Last active April 26, 2024 17:59
Show Gist options
  • 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.

@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