Last active
October 22, 2025 15:52
-
-
Save facelessuser/0235cb0fecc35c4e06a8195d5e18947b to your computer and use it in GitHub Desktop.
Revisions
-
facelessuser revised this gist
Feb 12, 2024 . 1 changed file with 1 addition and 4 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -14,10 +14,7 @@ lightness as it is more ideal for contrast. Combining these two models creates t 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. ```py play def hct_tonal_palette(c): -
facelessuser revised this gist
Dec 28, 2023 . 1 changed file with 3 additions and 45 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -25,21 +25,7 @@ def hct_tonal_palette(c): 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'] @@ -140,21 +126,7 @@ def hct_tonal_palette(c): 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'] @@ -190,21 +162,7 @@ def hct_tonal_palette(c): 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'] -
facelessuser revised this gist
Dec 23, 2023 . 1 changed file with 7 additions and 7 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -38,7 +38,7 @@ def hct_tonal_palette(c): # Cut the chroma in half so we can work with the color. if abs(c2[1] - c1[1]) > 1: c1[1] *= 0.5 colors.append(c1.fit('srgb', method='hct-chroma', jnd=0.0).convert('srgb')) return colors @@ -73,7 +73,7 @@ def oklch_tonal_palette(c): 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'] @@ -106,7 +106,7 @@ def oklch_tonal_palette(c): 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'] @@ -133,7 +133,7 @@ def oklch_tonal_palette(c): 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.""" @@ -153,7 +153,7 @@ def hct_tonal_palette(c): # Cut the chroma in half so we can work with the color. if abs(c2[1] - c1[1]) > 1: c1[1] *= 0.5 colors.append(c1.fit('srgb', method='hct-chroma', jnd=0.0).convert('srgb')) return colors colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'] @@ -183,7 +183,7 @@ def oklch_tonal_palette(c): 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.""" @@ -203,7 +203,7 @@ def hct_tonal_palette(c): # Cut the chroma in half so we can work with the color. if abs(c2[1] - c1[1]) > 1: c1[1] *= 0.5 colors.append(c1.fit('srgb', method='hct-chroma', jnd=0.0).convert('srgb')) return colors colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'] -
facelessuser revised this gist
Dec 23, 2023 . 1 changed file with 5 additions and 4 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -164,12 +164,13 @@ for color in colors: ``` 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. ```py play K_1 = 0.173 K_2 = 0.004 K_3 = (1.0 + K_1) / (1.0 + K_2) def toe_inv(x: float) -> float: -
facelessuser revised this gist
Dec 22, 2023 . 1 changed file with 2 additions and 2 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -165,11 +165,11 @@ for color in colors: 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 cut the `K2` value in half from `0.03` to `0.0095`. This gives us results that can be more difficult to distinguish against, except for the different blue results. ```py play K_1 = 0.206 K_2 = 0.0095 K_3 = (1.0 + K_1) / (1.0 + K_2) def toe_inv(x: float) -> float: -
facelessuser revised this gist
Dec 22, 2023 . 1 changed file with 7 additions and 7 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -24,7 +24,7 @@ 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] colors = [] for tone in tones: c1 = c.clone().set('t', tone) @@ -72,7 +72,7 @@ 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.002).convert('srgb') for tone in tones] colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'] @@ -105,7 +105,7 @@ 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.002) for tone in tones] colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'] @@ -132,14 +132,14 @@ 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.002).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] colors = [] for tone in tones: c1 = c.clone().set('t', tone) @@ -181,14 +181,14 @@ 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.002).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] colors = [] for tone in tones: c1 = c.clone().set('t', tone) -
facelessuser revised this gist
Dec 22, 2023 . 1 changed file with 53 additions and 7 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -14,15 +14,33 @@ lightness as it is more ideal for contrast. Combining these two models creates t 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. The caveat is that, especially in low light, it is easy to end up with a chroma that is proportionally higher than the lightness that the algorithm can't quite handle things. This is more a limitation of CAM16. To account for this, we check if the round trip gives us a diffrent chroma and hue, if this happens, we cut the chroma in half so we have a color we can work with. The function assumes you are giving it reasonable colors in your gamut. ```py play def hct_tonal_palette(c): """HCT tonal palettes.""" c = Color(c).convert('hct') tones = [0, 2, 5, 10, 15, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100] colors = [] for tone in tones: c1 = c.clone().set('t', tone) # Any color in CAM16 that has too high of chroma can cause issues, # but this is most likely to occur in low lightness. if tone < 20: c2 = c1.convert('xyz-d65').convert('hct', in_place=True) # We are either using a seed color too far out of gamut # or the seed color's chroma is grossly over what the conversion can handle. # The indicator is round trip conversion gives you a different chroma and hue. # Cut the chroma in half so we can work with the color. if abs(c2[1] - c1[1]) > 1: c1[1] *= 0.5 colors.append(c1.fit('srgb', method='hct-chroma', jnd=0.02).convert('srgb')) return colors colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'] @@ -121,8 +139,22 @@ def hct_tonal_palette(c): """HCT tonal palettes.""" c = Color(c).convert('hct') tones = [0, 2, 5, 10, 15, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100] colors = [] for tone in tones: c1 = c.clone().set('t', tone) # Any color in CAM16 that has too high of chroma can cause issues, # but this is most likely to occur in low lightness. if tone < 20: c2 = c1.convert('xyz-d65').convert('hct', in_place=True) # We are either using a seed color too far out of gamut # or the seed color's chroma is grossly over what the conversion can handle. # The indicator is round trip conversion gives you a different chroma and hue. # Cut the chroma in half so we can work with the color. if abs(c2[1] - c1[1]) > 1: c1[1] *= 0.5 colors.append(c1.fit('srgb', method='hct-chroma', jnd=0.02).convert('srgb')) return colors colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'] @@ -156,8 +188,22 @@ def hct_tonal_palette(c): """HCT tonal palettes.""" c = Color(c).convert('hct') tones = [0, 2, 5, 10, 15, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100] colors = [] for tone in tones: c1 = c.clone().set('t', tone) # Any color in CAM16 that has too high of chroma can cause issues, # but this is most likely to occur in low lightness. if tone < 20: c2 = c1.convert('xyz-d65').convert('hct', in_place=True) # We are either using a seed color too far out of gamut # or the seed color's chroma is grossly over what the conversion can handle. # The indicator is round trip conversion gives you a different chroma and hue. # Cut the chroma in half so we can work with the color. if abs(c2[1] - c1[1]) > 1: c1[1] *= 0.5 colors.append(c1.fit('srgb', method='hct-chroma', jnd=0.02).convert('srgb')) return colors colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'] -
facelessuser revised this gist
Dec 21, 2023 . 1 changed file with 1 addition and 1 deletion.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -148,7 +148,7 @@ def toe_inv(x: float) -> float: def oklch_tonal_palette(c): """OkLCh tonal palettes.""" c = Color(c).convert('oklch') tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100] return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.002).convert('srgb') for tone in tones] -
facelessuser created this gist
Dec 21, 2023 .There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,175 @@ # Exploring Tonal Palettes ## HCT HCT is a color model developed by [Google][material-hct]. 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. ```py play def hct_tonal_palette(c): """HCT tonal palettes.""" c = Color(c).convert('hct') tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100] return [c.clone().set('t', tone).fit('srgb', method='hct-chroma', jnd=0.02).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. ```py play def oklch_tonal_palette(c): """OkLCh tonal palettes.""" c = Color(c).convert('oklch') tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100] return [c.clone().set('l', tone / 100).fit('srgb', method='oklch-chroma', jnd=0.002).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][L_r] 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. ```py play 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, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100] return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.002) 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. ```py play 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, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100] return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.002).convert('srgb') for tone in tones] def hct_tonal_palette(c): """HCT tonal palettes.""" c = Color(c).convert('hct') tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100] return [c.clone().set('t', tone).fit('srgb', method='hct-chroma', jnd=0.02).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 cut the `K2` value in half from `0.03` to `0.015`. This gives us results that can be more difficult to distinguish against, except for the different blue results. ```py play K_1 = 0.206 K_2 = 0.015 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('oklab') tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100] return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.002).convert('srgb') for tone in tones] def hct_tonal_palette(c): """HCT tonal palettes.""" c = Color(c).convert('hct') tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100] return [c.clone().set('t', tone).fit('srgb', method='hct-chroma', jnd=0.02).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. [L_r]: https://bottosson.github.io/posts/colorpicker/#intermission---a-new-lightness-estimate-for-oklab [material-hct]: https://material.io/blog/science-of-color-design