Skip to content

Manipulating Colors

Once a Color object is created, you have access to all the color channels. Color channels can be read individually or extracted all at once. Getting and setting color channels is flexible and easy, allowing for intuitive access.

Accessing Coordinates

There are various ways to get and set the current values of color coordinates. Colors can be accessed by channel name or numerical index directly. We can also manipulate colors within different color spaces.

Access By Channel Name

One of the more intuitive ways to access color values is by channel name. Each color space defines the name of each of the available channels. alpha is the one channel name that is always constant no matter the color space.

>>> color = Color("orange")
>>> color
color(srgb 1 0.64706 0 / 1)
>>> color['r']
1.0
>>> color['g']
0.6470588235294118
>>> color['b']
0.0
>>> color['alpha']
1.0

Some channels may be also be recognized using an alias. Check the color space's documentation to learn the recognized channel names and aliases.

>>> color = Color("orange")
>>> color
color(srgb 1 0.64706 0 / 1)
>>> color['red'] = 0
>>> color['green'] = 0
>>> color['blue'] = 1
>>> color
color(srgb 0 0 1 / 1)

Access By Index

Color channels can also be read or set by index. Channels are always in logical order. This means, for instance, an RGB color space will have its channel in the order of r, g, b, and alpha. Thealpha channel always being the last channel in any color space. Check out the color space's documentation to learn more about available channels and the order in which they are stored.

>>> color = Color("orange")
>>> color
color(srgb 1 0.64706 0 / 1)
>>> color[0]
1.0
>>> color[1]
0.6470588235294118
>>> color[2]
0.0
>>> color[3]
1.0

Because a Color object essentially operates similar to a list, negative values are also allowed.

>>> color = Color("orange")
>>> color
color(srgb 1 0.64706 0 / 1)
>>> color[-1] = 0.5
>>> color
color(srgb 1 0.64706 0 / 0.5)

Access By Iteration

Color objects can also be treated as an iterable object. This allows us to simply loop through the values.

>>> color = Color("orange")
>>> color
color(srgb 1 0.64706 0 / 1)
>>> [c for c in color]
[1.0, 0.6470588235294118, 0.0, 1.0]

Access By Slicing

As previously mentioned, Color objects operate very similar to lists, and as such, can also be read or set via slicing.

>>> color = Color("orange")
>>> color
color(srgb 1 0.64706 0 / 1)
>>> color[:-1]
[1.0, 0.6470588235294118, 0.0]
>>> color[:-1] = [0, 0, 1]
>>> color
color(srgb 0 0 1 / 1)

Access by Type

New 2.0

When dealing with colors, you have two types of channels: color channels and an alpha channel. These values can be accessed and separated by slicing as mentioned earlier, but some convenience functions have been added to make this easier. coords() and alpha() will retrieve the color channels and the alpha channel respectively.

>>> color = Color("srgb", [1, 0, 1], 0.5)
>>> color
color(srgb 1 0 1 / 0.5)
>>> color.alpha()
0.5
>>> color.coords()
[1.0, 0.0, 1.0]

In addition, both of these functions offer a special parameter nans that controls whether undefined values are returned as specified or whether they are resolved to defined values.

>>> color = Color("hsl", [NaN, 0, 0.75], 0.5)
>>> color
color(--hsl none 0 0.75 / 0.5)
>>> color.coords()
[nan, 0.0, 0.75]
>>> color.coords(nans=False)
[0.0, 0.0, 0.75]

Access By Functions

Colors can also be accessed and modified in more advanced ways with special access functions get() and set().

get() provides access to any channel via the channel name for a given color space, but what sets it apart from other channel access methods is that it can indirectly access channels in other color spaces as well.

>>> color = Color("pink")
>>> color
color(srgb 1 0.75294 0.79608 / 1)
>>> color.get('red')
1.0
>>> color.get('oklch.hue')
7.085489349755127

Numerical values for channels can be used as well, but the input should still be a string.

>>> color = Color("pink")
>>> color
color(srgb 1 0.75294 0.79608 / 1)
>>> color.get('0')
1.0
>>> color.get('oklch.0')
0.8677384508411227

New in 2.14

Parsing numerical representations of channels is new in 2.14

Like get(), set() is a method that allows for the setting of any color channel via the color channel names. The value can be set via numerical values or functions with more complex logic.

>>> color = Color("pink")
>>> color
color(srgb 1 0.75294 0.79608 / 1)
>>> color.set('blue', 0.5)
color(srgb 1 0.75294 0.5 / 1)
>>> color.set('green', lambda g: g * 1.3)
color(srgb 1 0.97882 0.5 / 1)
>>> color.set('2', 0.0)
color(srgb 1 0.97882 0 / 1)

Since set() returns a reference to the current color object, we can also chain multiple set() operations.

>>> color = Color('black')
>>> color
color(srgb 0 0 0 / 1)
>>> color.set('red', 1).set('green', 1)
color(srgb 1 1 0 / 1)

Even more interesting is that, like get(), you can modify a channel in another color space indirectly.

>>> color = Color("orange")
>>> color
color(srgb 1 0.64706 0 / 1)
>>> color.set('oklab.lightness', 0.50)
color(srgb 0.61518 0.28886 -0.22143 / 1)
>>> color.set('oklab.0', 0.2)
color(srgb 0.24502 -0.06492 -0.05538 / 1)

When getting/setting a color channel in a different color space than the current color space, the underlying color must be converted to the target color space in order to access the channel. When doing this to get/set multiple channels, this can be a bit inefficient. In order to make such operations more efficient, both get() and set() allow for bulk operations. When performing bulk channel operations, the channels operations are performed in the order they are specified; therefore, it is important to group together channels of the same color space to ensure they are accessed with a single conversion.

To get multiple channels, simply provide a list of channels.

>>> color = Color('orange')
>>> color
color(srgb 1 0.64706 0 / 1)
>>> color.get(['oklch.lightness', 'oklch.hue', 'alpha'])
[0.7926884361521512, 70.66991620195026, 1.0]

To set multiple channels, pass a single dictionary containing the channel names and values.

>>> color = Color('orange')
>>> color
color(srgb 1 0.64706 0 / 1)
>>> color.set(
...     {
...         'oklch.lightness': lambda l: l - l * 0.25,
...         'oklch.hue': 270
...     }
... )
color(srgb 0.34573 0.45438 0.89059 / 1)

Indirect Channel Modifications

Indirect channel modification is very useful, but keep in mind that it may give you access to color spaces that are incompatible due to gamut size. Additionally, the feature converts the color to the target color space, modifies it, and then converts it back making it susceptible to any possible round trip errors.

New in 1.5: Getting/Setting Multiple Channels

Undefined Values

Colors can sometimes have undefined channels. This can actually happen in a number of ways. In almost all cases, undefined values are generated or manually inserted in order to help out with interpolation.

  1. Hues can naturally become undefined if the color is achromatic.

    >>> color = Color('white').convert('hsl')
    >>> color[:]
    [nan, 0.0, 1.0, 1.0]
    
  2. When specifying raw data, channels can be explicitly set to undefined, and when an insufficient amount of channel data is provided, the missing channels will be assumed as undefined, the exception is the alpha channel which is assumed to be 1 unless explicitly defined or explicitly set as undefined.

    >>> Color('srgb', [1])[:]
    [1.0, nan, nan, 1.0]
    >>> Color('srgb', [1, 0, 0], NaN)[:]
    [1.0, 0.0, 0.0, nan]
    
  3. Undefined values can also occur when a user specifies a channel with the none keyword in CSS syntax.

    >>> from coloraide import NaN
    >>> color = Color("srgb", [0.3, NaN, 0.4])
    >>> color[:]
    [0.3, nan, 0.4, 1.0]
    >>> color = Color('rgb(30% none 40%)')
    >>> color[:]
    [0.3, nan, 0.4, 1.0]
    
  4. Lastly, a user can use the mask method which is a quick way to set one or multiple channels as undefined. Additionally, it returns a clone leaving the original untouched by default.

    >>> Color('white')[:]
    [1.0, 1.0, 1.0, 1.0]
    >>> Color('white').mask(['red', 'green'])[:]
    [nan, nan, 1.0, 1.0]
    

    The alpha channel can also be masked:

    >>> Color('white').mask('alpha')[-1]
    nan
    

    You can also do inverse masks, or masks that apply to every channel not specified.

    >>> c = Color('white').mask('blue', invert=True)
    >>> c[:]
    [nan, nan, 1.0, nan]
    

Checking for Undefined Values

As previously mentioned, a color channel can be undefined for a number of reasons. And in cases such as interpolation, undefined values can even be useful. On the other hand, sometimes knowing there is an undefined value or being able to ignore it can be useful.

Undefined values are represented as the float value NaN. And since NaN values are not numbers – hence the name "not a number" – they don't quite work the same as normal numbers. They don't contribute to math operations like add, multiply, and divide. Any math operation performed with a NaN will simply yield NaN. NaN values are essentially infectious.

At first glance, the behavior of NaN values can seem confusing, but it is actually pretty intuitive. If we define a color with an undefined channel, and try to add to that value, what should we get? In reality, if the value is undefined, how could we possibly add to it? The only sane answer is to return NaN again.

>>> color = Color('color(srgb 1 none 1)')
>>> color['green'] + 0.5
nan

Because a NaN (or undefined value) may cause surprising results, it can be useful to check if a hue (or any channel) is undefined before applying certain operations where such a value may be undesirable, especially if the color potentially came from an unknown source. To make checking for undefined values easy, the convenience function is_nan has been made available. You can simply give is_nan the property you wish to check, and it will return either True or False.

>>> Color('hsl(none 0% 100%)').is_nan('hue')
True

This is equivalent to using the math library and comparing the value directly:

>>> import math
>>> math.isnan(Color('hsl(none 0% 100%)')['hue'])
True

Forcing Defined Values

Another way to deal with NaN values is to just ignore them. get(), set(), coords(), and alpha() all can use the nans option to ensure read operations return a defined value.

>>> c = Color('srgb', [])
>>> c
color(srgb none none none / 1)
>>> c.get('red', nans=False)
0.0
>>> c.set('green', lambda x: x + 3, nans=False)
color(srgb none 3 none / 1)

set()

In the context of set(), nans specifically ensures that when a callback function is provided that the input value is transformed into a real value opposed to an undefined value.

We can also use normalize() to just set all channels to defined values, but keep mind, when an achromatic color has a real hue, they will then influence interpolation results if interpolating in that same color space.

>>> c = Color('srgb', [])
>>> c.normalize(nans=False)
color(srgb 0 0 0 / 1)

How are Undefined Values Resolved?

ColorAide will resolve undefined values when necessary. Resolving undefined values may be needed to compute color distance, convert a color, serialize a color, or various other reasons.

Normally, an undefined value defaults to 0 when forced to be defined, but there are a few cases where this may not always be true.

  1. When a color is achromatic the hue becomes meaningless in most cylindrical color spaces. This makes sense as achromatic colors have no hues, but this is also because the algorithms usually work out this way. When chroma is small enough, it usually makes the hue mathematically insignificant. In these cases, when a hue must be defined, we will generally assume 0 as an arbitrary default, but there are some color spaces who have algorithms where the hue actually becomes more important for precise conversions.

    The color spaces CAM16 JMh and HCT are color models that allow you to set the viewing environment. One of the options determines whether the eye is adapted to the illuminant or not. If not adapted, which is our default for both CAM16 JMh and HCT, you can get an achromatic response where grayscale colors lean heavily into one specific hue. Additionally, achromatic chroma may grow to a value much greater than 0 as lightness increases. If we were to use 0 as a default for chroma and/or hue, we'd actually not convert back to a real achromatic color.

    We can see in the example below that using 0 for an undefined hue in CAM16 JMh will not convert gray back to sRGB properly, but using the one calculated for the color space gets us much closer.

    >>> srgb = Color('gray')
    >>> srgb
    color(srgb 0.50196 0.50196 0.50196 / 1)
    >>> jmh = srgb.convert('cam16-jmh')
    >>> jmh.coords(nans=False)
    [43.042092459543426, 1.4670107518796203, 209.53509858059104]
    >>> jmh.convert('srgb')
    color(srgb 0.50196 0.50196 0.50196 / 1)
    >>> jmh.set('hue', 0).convert('srgb')
    color(srgb 0.51857 0.49597 0.49766 / 1)
    

    Some color spaces, due to things like adapting luminance and background luminance, will have their own achromatic response, meaning that achromatic colors are neutral, but may not be pure white, gray, etc.

  2. Most of the time, if you set all color channels to undefined, when resolved, the color will be black (or white in the case of CMYK). Unfortunately, using 0 for undefined channels in some color spaces can create colors outside the viewable gamut. One such example is ACEScct (a logarithmic encoding of ACES) which has a greater value than zero for black. In this case, setting undefined channels to zero will cause nonsense colors. In this specific case, we use ACEScct's value for black instead of 0 for more a more practical default.

    >>> aces = Color('black').convert('acescct')
    >>> aces
    color(--acescct 0.07291 0.07291 0.07291 / 1)
    >>> aces.mask(['alpha'], invert=True, in_place=True)
    color(--acescct none none none / 1)
    >>> aces.coords(nans=False)
    [0.0729055341958355, 0.0729055341958355, 0.0729055341958355]
    >>> aces.in_gamut()
    True
    >>> aces[:] = [0] * 3
    >>> aces.in_gamut()
    False
    

    New 2.3

    ACEScc, another color space that performs a logarithmic encoding of ACES, will also resolve undefined channels to a non-zero value, not because zero is out of gamut, but to ensure consistency across all ACES color spaces which resolve to zero or non-zero equivalent values.

Achromatic Colors

An achromatic color is a color without any real hue. Essentially, it is devoid of color leaving only shades of gray. Different color spaces represent achromatic colors in different ways.

ColorAide has some special handling of achromatic colors and a few ways to test if a color is achromatic.

Checking For Achromatic Colors

New 2.0

ColorAide generally respects an input color's defined channels, but during conversion, or if normalize() is called, cylindrical color spaces will have their hue set to undefined if the color is achromatic (or very close to achromatic). One easy way to check for achromatic colors is simply to check if the hue is undefined with is_nan().

>>> c = Color('gray').convert('hsl')
>>> c.is_nan('hue')
True

Unfortunately, this assumes that the hue has not been manually altered and this doesn't work with non cylindrical colors. Luckily, ColorAide has a universal way to check if any color is achromatic by using is_achromatic().

>>> color1 = Color('orange')
>>> color1
color(srgb 1 0.64706 0 / 1)
>>> color1.is_achromatic()
False
>>> color2 = Color('gray').convert('lab')
>>> color2
color(--lab 53.585 0 0 / 1)
>>> color2.is_achromatic()
True
>>> color3 = Color('darkgray').convert('hsl').set('hue', 270)
>>> color3
color(--hsl 270 0 0.66275 / 1)
>>> color3.is_achromatic()
True

is_achromatic() tries to use a reasonable threshold to determine achromatic colors. The method used is usually specific to the color space as it is fastest to test in the color space being evaluated.

Normalizing Achromatic Colors

When ColorAide converts to a cylindrical color, if the color is achromatic, the hue will get set as undefined. This is mainly because when a color gets very close to achromatic, the hues can become nonsensical. Many cylindrical spaces, as chroma (or saturation) approaches zero, the color approaches being achromatic. And as chroma gets smaller, the impact of the hue becomes smaller and smaller. In these cases, when we get very close to achromatic, we don't care what the hue is, so it gets set as undefined. Additionally, having hue set to undefined allows us to interpolate achromatic colors in a sane way, see Interpolation for more info.

There are times that a color can be defined such that it is not in this normalized achromatic state. We can manually define a color not in this state, and we can also force a color out of this state.

Here we can disable the normalization when converting. We can do this with convert() and update()

>>> Color('white').convert('lch')
color(--lch 100 0 none / 1)
>>> Color('white').convert('lch', norm=False)
color(--lch 100 0 0 / 1)

We can also remove the normalization by setting nans to False when using normalize().

>>> Color('white').convert('lch').normalize(nans=False)
color(--lch 100 0 0 / 1)

If we want to force a color back into this normalized state, we can just call normalize() without any parameters. Normalize will remove any existing undefined channels and set achromatic hues to undefined.

>>> c = Color('lch', [1, 0, 0])
>>> c
color(--lch 1 0 0 / 1)
>>> c.normalize()
color(--lch 1 0 none / 1)