Skip to content

HCT

The HCT color space is not registered in Color by default

Properties

Name: hct

White Point: D65 / 2˚

Coordinates:

Name Range*
h [0, 360)
c [0, 145]
t [0, 100]

* Space is not bound to the range and is only used as a reference to define percentage inputs/outputs in relation to the Display P3 color space.

HCT

The sRGB gamut represented within the HCT color space.

The HCT color space is Google's attempt at a perceptually accurate color system. Essentially, it is two color spaces glued together: 'H' (hue) and 'C' (chroma) come from the CAM16 color appearance model and 'T' (tone) is the lightness from the CIELAB (D65) color space. The space was created to take the more consistent perceptual hues from CAM16 and use the better lightness prediction found in CIELAB. The color space has the advantage of being well suited for creating color schemes with decent contrast and makes it easy to create nice tonal palettes, but the downside is that it is expensive to translate to and from compared to other color spaces.

Since HCT is partly based on CAM16, it inherits the expensive operations used to translate color to and from the CAM16 color model. In the forward direction (to HCT) color conversions are only marginally more expensive than CAM16, but in the reverse direction (from HCT) the conversions are much more expensive. This is because the CAM16 color model needs the context of chroma, hue, and lightness in order to translate any of its components, but HCT throws away CAM16 lightness and uses CIELAB lightness which has no direct relation to the other components. In order to translate color from HCT, more complex methods are needed to approximate the missing CAM16 lightness in order for a good round trip conversion.

Google implements the HCT color space in their "Material Color Utilities" library, but in that library it is restricted to sRGB and only to 8 bit precision. Wide gamut colors such as Display P3 cannot be used.

ColorAide's goal was not to port Material's Color Utilities, but to implement HCT as a proper color space that can be used in sRGB and other wide gamut color spaces. In ColorAide we implement the HCT color space exactly as described and create the space from both CIELAB and CAM16. We then provide a generic approximation back out of HCT at a higher precision to better support not only sRGB, but other wide gamut color spaces such as: Display P3, Rec. 2020, A98 RGB, etc.

Conversion Limitations

Extreme colors, like those in ProPhoto RGB that fall outside the visible spectrum, may be difficult to round trip with the same high accuracy as other colors well inside the visible spectrum. These colors naturally stress the CAM16 color model and make approximation from HCT even more difficult. With that said, most color spaces within the visible spectrum should convert reasonably well.

Learn more.

Channel Aliases

Channels Aliases
h hue
c chroma
t tone, lightness

Input/Output

The HCT space is not currently supported in the CSS spec, the parsed input and string output formats use the color() function format using the custom name --hct:

color(--hct h c t / a)  // Color function

The string representation of the color object and the default string output use the color(--hct h c t / a) form.

>>> Color("hct", [27.41, 113.36, 53.237], 1)
color(--hct 27.41 113.36 53.237 / 1)
>>> Color("hct", [71.257, 60.528, 74.934], 1).to_string()
'color(--hct 71.257 60.528 74.934)'

Registering

from coloraide import Color as Base
from coloraide.spaces.hct import HCT

class Color(Base): ...

Color.register(HCT())

Tonal Palettes

One of the applications of HCT is generating tonal palettes. By applying gamut mapping that focuses on chroma reduction, we can produce tonal palettes just like in Material Color Utilities. Specifically, we will use the ray trace approach within the HCT space to reduce the chroma very close to the gamut boundary.

The basic idea with generating tonal palettes is to pick a reasonable color, change the tone via a good distance, and make sure the color fits the target gamut by reducing the chroma until the color is within the gamut. We can quickly demonstrate that this works by generating a simple tonal palette as shown below.

>>> c = Color('hct', [325, 24, 50])
>>> tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
>>> Steps([c.clone().set('tone', tone).convert('srgb').to_string(hex=True, fit={'method': 'raytrace', 'pspace': 'hct'}) for tone in tones])
['#000000', '#29132e', '#3f2844', '#573e5b', '#705574', '#8a6d8d', '#a587a8', '#c1a1c3', '#debcdf', '#fbd7fc', '#ffebfd', '#ffffff']

Material Color Utilities, as they currently implement it, only works within the sRGB color space, but ColorAide implements HCT such that it can be used in various wide gamuts as well.

>>> tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
>>> c1 = Color('display-p3', [1, 0, 1]).convert('hct')
>>> Steps([c1.clone().set('tone', tone).convert('display-p3').to_string(fit={'method': 'raytrace', 'pspace': 'hct'}) for tone in tones])
['color(display-p3 0 0 0)', 'color(display-p3 0.21082 0 0.21026)', 'color(display-p3 0.34421 0 0.34351)', 'color(display-p3 0.48729 0 0.48655)', 'color(display-p3 0.63838 0 0.63771)', 'color(display-p3 0.79637 0 0.79591)', 'color(display-p3 0.96045 0 0.96034)', 'color(display-p3 1 0.42475 0.97096)', 'color(display-p3 1 0.65344 0.95759)', 'color(display-p3 1 0.83592 0.96434)', 'color(display-p3 1 0.92011 0.97354)', 'color(display-p3 1 1 1)']
>>> c2 = Color('rec2020', [0, 0, 1]).convert('hct')
>>> Steps([c2.clone().set('tone', tone).convert('rec2020').to_string(fit={'method': 'raytrace', 'pspace': 'hct'}) for tone in tones])
['color(rec2020 0 0 0)', 'color(rec2020 0 0.00145 0.39259)', 'color(rec2020 0 0.00209 0.70451)', 'color(rec2020 0.00901 0.0168 1)', 'color(rec2020 0.15828 0.21769 1)', 'color(rec2020 0.2965 0.36054 1)', 'color(rec2020 0.43188 0.49019 1)', 'color(rec2020 0.56968 0.6162 1)', 'color(rec2020 0.71125 0.74186 1)', 'color(rec2020 0.85698 0.86865 1)', 'color(rec2020 0.93142 0.93272 1)', 'color(rec2020 0.99991 0.99948 1)']

Due to differences in approximation techniques, general precision differences, and gamut mapping specifics of the two implementations internally, ColorAide may return colors slightly different from Material Color Utilities. These differences are extremely small and not perceptible to the eye.

Below we have two examples. We've taken the results from Material's tests and we've generated the same tonal palettes and output both as HCT. We can compare which hues stay overall more constant, which chroma gets reduced more than others, and which hue and tone are less affected by the gamut mapping. Can you definitively say that one looks more correct than the other? Can you say there is notable, visual difference?

>>> def 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('tone', tone).fit('srgb', method='raytrace', pspace='hct') for tone in tones]
... 
>>> material1 = ['#000000', '#00006e', '#0001ac',
...              '#0000ef', '#343dff', '#5a64ff',
...              '#7c84ff', '#9da3ff', '#bec2ff',
...              '#e0e0ff', '#f1efff', '#ffffff']
>>> c = Color('blue').convert('hct')
>>> Steps([x.to_string() for x in tonal_palette(c)])
['color(--hct 0 0 0)', 'color(--hct 282.75 51.516 9.9999)', 'color(--hct 282.75 68.103 20)', 'color(--hct 282.76 83.739 30)', 'color(--hct 282.76 82.336 40)', 'color(--hct 282.76 73.341 50)', 'color(--hct 282.76 62.065 60)', 'color(--hct 282.76 49.085 70)', 'color(--hct 282.76 34.771 80)', 'color(--hct 282.76 19.211 90)', 'color(--hct 282.76 10.817 95)', 'color(--hct 209.54 2.8716 100)']
>>> Steps([Color(x).convert('hct').to_string() for x in material1])
['color(--hct 0 0 0)', 'color(--hct 282.84 51.709 9.9973)', 'color(--hct 282.74 68.127 20.044)', 'color(--hct 282.77 83.756 29.989)', 'color(--hct 282.81 82.297 40.059)', 'color(--hct 282.79 73.236 50.106)', 'color(--hct 283.04 62.214 59.895)', 'color(--hct 282.95 49.257 69.882)', 'color(--hct 282.15 34.694 80.039)', 'color(--hct 282.23 19.146 90.035)', 'color(--hct 282.07 10.786 95.015)', 'color(--hct 209.54 2.8716 100)']
>>> material2 = ['#000000', '#191a2c', '#2e2f42',
...              '#444559', '#5c5d72', '#75758b',
...              '#8f8fa6', '#a9a9c1', '#c5c4dd',
...              '#e1e0f9', '#f1efff', '#ffffff']
>>> c['chroma'] = 16
>>> Steps([x.to_string() for x in tonal_palette(c)])
['color(--hct 0 0 0)', 'color(--hct 282.76 16 10)', 'color(--hct 282.76 16 20)', 'color(--hct 282.76 16 30)', 'color(--hct 282.76 16 40)', 'color(--hct 282.76 16 50)', 'color(--hct 282.76 16 60)', 'color(--hct 282.76 16 70)', 'color(--hct 282.76 16 80)', 'color(--hct 282.76 16 90)', 'color(--hct 282.76 10.817 95)', 'color(--hct 209.54 2.8716 100)']
>>> Steps([Color(x).convert('hct').to_string() for x in material2])
['color(--hct 0 0 0)', 'color(--hct 283.31 16.104 10.01)', 'color(--hct 282.9 16.074 20.073)', 'color(--hct 282.41 16.065 29.927)', 'color(--hct 281.87 16.078 40.112)', 'color(--hct 283.49 16.042 49.94)', 'color(--hct 282.85 16.13 60.103)', 'color(--hct 282.27 16.258 69.938)', 'color(--hct 283.58 16.297 79.937)', 'color(--hct 282.75 15.918 89.933)', 'color(--hct 282.07 10.786 95.015)', 'color(--hct 209.54 2.8716 100)']