Color Interpolation
Interpolation is a type of estimation that finds new data points based on the range of a discrete set of known data points. When used in the context of color, it is finding one or more colors that reside between any two given colors. This is often used to simulate mixing colors, creating gradients, or even create color palettes.
ColorAide provides a number of useful utilities based on interpolation.
Linear Interpolation
Linear interpolation is registered in Color
by Default
One of the most common, and easiest ways to interpolate data between two points is to use linear interpolation. An easy way of thinking about this concept is to imagine drawing a straight line that connects two colors within a color space. We could then navigate along that line and return colors at different points to simulate mixing colors at various percentages or return the whole range and create a continuous, smooth gradient.
To further illustrate this point, the example below shows a slice of the Oklab color space at a lightness of 70%. On this 2D plane, we select two colors: oklab(0.7 0.15 0.1)
and oklab(0.7 -0.03 -0.12)
. We then connect these two colors with a line. We can then select any point on the line to simulate the mixing of these colors. 0% would yield the first color, 100% would yield the second color, and 50% would yield a new color: oklab(0.7 0.06 -0.01)
.
The interpolate
method allows a user to create a linear interpolation function using two or more colors. By default, a returned interpolation function accepts numerical input in the domain of [0, 1] and will cause a new color between the specified colors to be returned.
By default, colors are interpolated in the perceptually uniform Oklab color space, though any supported color space can be used instead. This also applies to all methods that use interpolation, such as discrete
, steps
, mix
, etc.
As an example, below we create an interpolation between rebeccapurple
and lch(85% 100 85)
. We then step through values of 0.0
, 0.1
, 0.2
, etc. This returns colors at various positions on the line that connects the two colors, 0
returning rebeccapurple
and 1
returning lch(85% 100 85)
.
>>> i = Color.interpolate(["rebeccapurple", "lch(85% 100 85)"], space='lch')
>>> [i(x / 10).to_string() for x in range(10 + 1)]
['lch(32.393 61.244 308.86)', 'lch(37.653 65.119 322.47)', 'lch(42.914 68.995 336.09)', 'lch(48.175 72.87 349.7)', 'lch(53.436 76.746 3.3143)', 'lch(58.696 80.622 16.929)', 'lch(63.957 84.497 30.543)', 'lch(69.218 88.373 44.157)', 'lch(74.479 92.249 57.771)', 'lch(79.739 96.124 71.386)', 'lch(85 100 85)']
If we create enough steps, we can create a gradient.
>>> i = Color.interpolate(
... ["rebeccapurple", "lch(85% 100 85)"],
... space='lch'
... )
CSS Linear Interpolation
New 2.11
CSS linear interpolation is registered in Color
by Default
While ColorAide supports CSS color syntax, it's goal is not to necessarily mirror CSS in all aspects, though often we do provide ways to emulate the behavior.
The default linear interpolation that ColorAide uses by default deviates from how CSS handles interpolation. More specifically, it deviates in how undefined hues are resolved during the interpolation steps which directly affects achromatic interpolation results. The difference in handling is subtle, but becomes quite observable when using the longer
hue fix-up.
Hue Interpolation
Hue interpolation, along with fix-ups, is more generally covered in Hue Interpolation.
Normally, two colors with defined hues will have a shorter and longer arc length between the two hue angles.
>>> Color.interpolate(['red', 'blue'], space='hsl', hue='shorter')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x108049dd0>
>>> Color.interpolate(['red', 'blue'], space='hsl', hue='longer')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x1080414d0>
ColorAide and CSS apply hue fix-ups to ensure proper interpolation occurs along a given hue arc in the desired way. ColorAide's default linear interpolation waits until right before the actual interpolation takes place to resolve any and all undefined channels. This means that hue fix-ups are applied while hues are still undefined making it impossible to define a shorter or longer arc. This means that when a hue is undefined, whether longer
or shorter
is chosen, the result will be the same. Ultimately, ColorAide takes the stance that undefined hues cannot have an arc between itself and another hue as their is no hue to compare against.
CSS, on the other hand, resolves undefined hues before hue fix-ups are applied. This means that as long as the other hue is defined, the undefined hue will take on the value of the defined hue before the fix-up. With both hues defined this creates pseudo arc lengths (both shorter and longer) between the two hues. This subtle difference makes a large impact when evaluating longer
hue interpolations. Instead of interpolating an undefined hue and a defined hue, CSS actually interpolates between either a shorter angel difference of 0Ëš or a longer angle difference of 360Ëš.
>>> Color.interpolate(['hsl(0 75 50)', 'hsl(none 0 50)'], space='hsl', method='css-linear', hue='longer')
<coloraide.interpolate.css_linear.InterpolatorCSSLinear object at 0x10804d510>
>>> Color.interpolate(['hsl(0 75 50)', 'hsl(none 0 50)'], space='hsl', method='linear', hue='longer')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x10783ea50>
There may be arguments as to why some feel CSS's approach is more or less appropriate, but our desire is only to clarify the differences and make known why our default is the way it is. If a CSS compatible linear interpolation is needed, then css-linear
can be specified as the interpolation method
. If css-linear
is desired as the default approach, the Color
object can be subclassed and configured to do so.
>>> from coloraide import Color as Base
>>> class Color(Base):
... INTERPOLATOR = 'css-linear'
...
>>> Color.interpolate(['red', 'transparent', 'blue'], space='hsl', hue='longer')
<coloraide.interpolate.css_linear.InterpolatorCSSLinear object at 0x1080f0f50>
Piecewise Interpolation
Piecewise interpolation takes the idea of linear interpolation and then applies it to multiple colors. As drawing a straight line through a series of points greater than two can be difficult to achieve, piecewise interpolation creates straight lines between each color in a chain of colors.
When the interpolate
method receives more that two colors, the interpolation will utilize piecewise interpolation and interpolation will be broken up between each pair of colors. The function, just like when interpolating between two colors, still operates by default in the domain of [0, 1], only it will now apply to the entire range of colors.
Piecewise interpolation simply breaks up a series of data points into segments in order to apply interpolation individually on each segment.
>>> Color.interpolate(['black', 'red', 'white'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x108041990>
This approach generally works well, but since the placement of colors may not be in a straight line, you will often have pivot points and the transition may not be quite as smooth at these locations.
Continuous Interpolation
Continuous interpolation is registered in Color
by Default
In this document, we use the term "continuous" in two ways when talking about interpolation: continuous vs discrete and the interpolation method whose literal name is continuous
.
The continuous
interpolation approach, is simply a piecewise, linear interpolation method that interpolates defined channels continuously across two or more segments with undefined channels, essentially taking into account all the segments in the piecewise chain when processing undefined channels. This differs from the default CSS piecewise interpolation approach which only takes into context two segments at any given time. What this means is that if you have multiple colors, and one or more of the colors have the same channel undefined, the colors with that channel defined will be interpolated across the undefined gaps spanning one or more segments in the chain.
In this example, we have 3 colors. The end colors both define lightness, but the middle color is undefined. We can see that when we use normal, linear piecewise interpolation that we get a discontinuity. But with continuous
interpolation, we get a smooth interpolation of the lightness through the undefined channel.
>>> colors = [
... Color('oklab', [0, 0, 0]),
... Color('oklab', [NaN, -0.03246, -0.31153]),
... Color('oklab', [1, 0, 0])
... ]
>>> Color.interpolate(colors, space='oklab', method='linear')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x10808d7d0>
>>> Color.interpolate(colors, space='oklab', method='continuous')
<coloraide.interpolate.continuous.InterpolatorContinuous object at 0x1080494d0>
Now, if have colors on the side that are not between two defined colors, all those colors will adopt the defined value of the first color on either the right or left that is defined. In the example below, we have a single color with all components defined, but all the colors to the left are missing the lightness. Again, in the normal linear
approach, we see a discontinuity, but with the continous
approach, all colors with the undefined lightness will assume the lightness of the defined color.
>>> colors = [
... Color('oklab', [NaN, 0.22486, 0.12585]),
... Color('oklab', [NaN, -0.1403, 0.10768]),
... Color('oklab', [0.45201, -0.03246, -0.31153])
... ]
>>> Color.interpolate(colors, space='oklab', method='linear')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x108079cd0>
>>> Color.interpolate(colors, space='oklab', method='continuous')
<coloraide.interpolate.continuous.InterpolatorContinuous object at 0x107f9ff10>
This approach is useful for more natural, multi-color interpolations and is used as foundation for our spline interpolation approaches which often require the context of at least four colors when applying their logic.
Cubic Spline Interpolation
Linear interpolation is nice because it is easy to implement, and due to its straight forward nature, pretty fast. With that said, it doesn't always have the smoothest transitions. It turns out that there are other piecewise ways to interpolate that can yield smoother results.
Inspired by some efforts seen on the web and in the great JavaScript library Culori, ColorAide implements a number of spline based interpolation methods.
Because splines require taking into account more than two colors at a time, all spline based interpolation methods are built off of the continuous
interpolation approach of handling undefined values.
B-Spline
B-Spline interpolation is registered in Color
by Default
B-spline is a piecewise spline similar to Bezier curves. It utilizes "control points" that help shape the interpolation path through a series of colors. Like Bezier Curves, the path does not pass through the control points, but it is clamped at the start and end. Essentially, the interpolation path passes through both end colors and bends that path along the way towards the other colors being used as control points.
It can be used by specifying bspline
as the interpolation method.
>>> Color.interpolate(['red', 'green', 'blue', 'orange'], method='bspline')
<coloraide.interpolate.bspline.InterpolatorBSpline object at 0x107faf150>
Natural
Natural interpolation is registered in Color
by Default
The "natural" spline is the same as the B-spline approach except an algorithm is applied that uses the colors as data points and calculates new control points such that the interpolation passes through all the data points. This means that the path will pass through all the colors. The resultant spline has the continuity and properties of a natural spline, hence the name.
One down side is that it can overshoot or undershoot a bit, and can occasionally cause the interpolation path to pass out of gamut if interpolating on an edge.
It can be used by specifying natural
as the interpolation method.
>>> Color.interpolate(['red', 'green', 'blue', 'orange'], method='natural')
<coloraide.interpolate.bspline_natural.InterpolatorNaturalBSpline object at 0x107c27f50>
Monotone
Monotone interpolation is registered in Color
by Default
The "monotone" spline is a piecewise interpolation spline that passes through all its data points and helps to preserve monotonicity. As far as we are concerned, the important thing to note is that it greatly reduces any overshoot or undershoot in the interpolation.
>>> Color.interpolate(['red', 'green', 'blue', 'orange'], method='monotone')
<coloraide.interpolate.monotone.InterpolatorMonotone object at 0x107c27d90>
Catmull-Rom
Catmull-Rom interpolation is not registered in Color
by Default
Lastly, the Catmull-Rom spline is another "interpolating" spline that passes through all of its data points, similar to the "natural" spline, but it but does not share the same continuity and properties of a "natural" spline.
Much like the "natural" spline, it can overshoot or undershoot.
Catmull-Rom is not registered by default, but can be registered as shown below and then used by specifying catrom
as the interpolation method.
>>> from coloraide import Color
>>> from coloraide.interpolate.catmull_rom import CatmullRom
>>> class Custom(Color): ...
...
>>> Custom.register(CatmullRom())
>>> Custom.interpolate(['red', 'green', 'blue', 'orange'], method='catrom')
<coloraide.interpolate.catmull_rom.InterpolatorCatmullRom object at 0x107faf810>
Discrete Interpolation
New 2.5
So far, we've only shown examples of continuous interpolation methods. To clarify, we are using "continuous" in a slightly different way than we discussed earlier. When we say "continuous" here, we simply mean that the colors in the interpolation smoothly transition from one color to the other. But when creating charts or graphs, some times you'd like to categorize data such that a range of values correspond to a specific color. For this, we can use discrete
, which like intrpolate
, returns an interpolation object, but the ranges will be discrete.
By default, ranges are calculated directly form the input colors. So if you had three colors, the interpolation would be broken up into 3 ranges. Compare this with the "continuous" interpolation we methods we showed earlier.
>>> Color.discrete(['red', 'green', 'blue'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107f9c950>
>>> Color.interpolate(['red', 'green', 'blue'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107fdcad0>
If we specify step
, we can create a larger or smaller color scale using the input colors to interpolate the new color scale. And we can use any of the aforementioned interpolation methods to help generate this new discrete scale.
>>> Color.discrete(['red', 'green', 'blue'], steps=5, method='catrom')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x10801ae50>
What makes this really useful is if you combine it with custom domains to process data. By default, the domain is [0, 1]
, but we can change this to directly correlate the data with our quantized color samples. For instance, let's use a series of discrete colors to represent temperature. Additionally, let's use domain
to associate a temperature ranges with the given colors. Now when we input a temperature value, it will align with our discrete color scale.
>>> i = Color.discrete(['blue', 'green', 'yellow', 'orange', 'red'], domain=[-32, 32, 60, 85, 95])
>>> i(-32)
color(--oklab 0.45201 -0.03246 -0.31153 / 1)
>>> i(40)
color(--oklab 0.51975 -0.1403 0.10768 / 1)
>>> i(87)
color(--oklab 0.79269 0.05661 0.16138 / 1)
>>> i(100)
color(--oklab 0.62796 0.22486 0.12585 / 1)
>>> i
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107faf810>
Additionally, color scales can be limited using the padding
parameter.
>>> Color.discrete(['blue', 'green', 'yellow', 'orange', 'red'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107fe39d0>
>>> Color.discrete(['blue', 'green', 'yellow', 'orange', 'red'], padding=[0.25, 0])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107fbc290>
As discrete()
is built on steps()
, it can take all the same arguments. Check out steps()
to learn more.
Hue Interpolation
In interpolation, hues are handled special allowing us to control the way in which hues are evaluated. By default, the shortest angle between two hues is targeted for interpolation, but the hue
option allows us to redefine this behavior in a number of interesting ways: shorter
, longer
, increasing
, decreasing
, and specified
. These hue "fix-ups" identify all possible ways in which we can interpolate a hue and come from the CSS level 4 specification.
Specified
The specified
fix-up was at one time specified in the CSS Color Level 4 specification, but is no longer mentioned there. While CSS no longer supports this hue fix-up, we still do. specified
simply does not apply any hue fix-up and will use hues as specified, hence the name.
To help visualize the different hue methods, consider the following evaluation between hsl(270 50 40)
and hsl(780 100 40)
. Below we will demonstrate each of the different hue evaluations and explain what it is that they do.
shorter
interpolates along the shortest arc length after normalizing the hues.
>>> Color.interpolate(
... ["hsl(270 50 40)", "hsl(780 100 40)"],
... space='hsl',
... hue="shorter"
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x104a51150>
longer
interpolates along the longest arc length after normalizing the hues.
>>> Color.interpolate(
... ["hsl(270 50 40)", "hsl(780 100 40)"],
... space='hsl',
... hue="longer"
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107ff2010>
increasing
interpolates counter clockwise (after normalizing the hues), such that the hues are increasing.
>>> Color.interpolate(
... ["hsl(270 50 40)", "hsl(780 100 40)"],
... space='hsl',
... hue="increasing"
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107779990>
decreasing
interpolates clockwise (after normalizing the hues), such that the hues are decreasing.
>>> Color.interpolate(
... ["hsl(270 50 40)", "hsl(780 100 40)"],
... space='hsl',
... hue="decreasing"
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107fafed0>
specified
interpolates from the first color to the second color without applying any hue fix-ups.
>>> Color.interpolate(
... ["hsl(270 50 40)", "hsl(780 100 40)"],
... space='hsl',
... hue="specified"
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107f9fbd0>
In general, achromatic colors cannot have an arc length between them and other color. When applying linear interpolation between an achromatic color (or a color which simply does not define the hue), the applied hue fix-up will have little effect. With that said, when using CSS linear interpolation, the algorithm is a little different and it essentially creates pseudo arcs between color pairs with undefined hues and defined hues. Only with the longer
hue fix-up does this become apparent. Below we compare CSS linear interpolation to our default linear interpolation.
>>> Color.interpolate(['hsl(0 75 50)', 'hsl(none 0 50)'], space='hsl', method='css-linear', hue='longer')
<coloraide.interpolate.css_linear.InterpolatorCSSLinear object at 0x10801a850>
>>> Color.interpolate(['hsl(0 75 50)', 'hsl(none 0 50)'], space='hsl', method='linear', hue='longer')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x10807a810>
Interpolating with Alpha
Interpolating color channels is pretty straight forward and uses traditional linear interpolation logic, but when introducing transparency to a color, interpolation uses a concept known as premultiplication which alters the normal interpolation process.
Premultiplication is a technique that tends to produce better results when two colors have differing transparency. It essentially accounts for the transparency and uses it to weight how may a given color channel will contribute to the interpolation. A more transparent color's channels will naturally contribute less.
Consider the following example. Normally, when transitioning to a "transparent" color, the colors will be more gray during the transition. This is because transparent
is actually black. But when using premultiplication, the transition looks just as one would expect as the transparent color's channels are weighted less due to the high transparency.
>>> Color.interpolate(['white', 'transparent'], space='srgb', premultiplied=False)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x1080781d0>
>>> Color.interpolate(['white', 'transparent'], space='srgb')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107fe7350>
As a final example, below we have an opaque orange and a blue that is quite transparent. Logically, the blue shouldn't have as big an affect on the overall color as it is so faint, and yet, in the un-premultiplied example, when mixing the colors equally, we see that the resultant color is also equally influenced by the hue of both colors. In the premultiplied example, we see that orange is still quite dominant at 50% as it is fully opaque.
>>> Color('orange').mix(Color('blue').set('alpha', 0.25), space='srgb', premultiplied=False)
color(srgb 0.5 0.32353 0.5 / 0.625)
>>> Color('orange').mix(Color('blue').set('alpha', 0.25), space='srgb')
color(srgb 0.8 0.51765 0.2 / 0.625)
If we interpolate it, we can see the difference in transition.
>>> Color.interpolate(['orange', Color('blue').set('alpha', 0.25)], space='srgb', premultiplied=False)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107fc0610>
>>> Color.interpolate(['orange', Color('blue').set('alpha', 0.25)], space='srgb')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107f9e190>
There may be some cases where it is desired to use no premultiplication in alpha blending. One could simply be that you need to mimic the same behavior of a system that does not use premultiplied interpolation. If so, simply set premultiplied
to False
as shown above.
Mixing
Interpolation Options
Any options not consumed by mix
will be passed to the underlying interpolation
function. This includes options like hue
, progress
, etc.
The mix
function is built on top of the interpolate
function and provides a simple, quick, and intuitive simple mixing of two colors. Just pass in a color to mix with the base color, and you'll get an equal mix of the two.
>>> Color("red").mix(Color("blue"))
color(--oklab 0.53998 0.0962 -0.09284 / 1)
By default, colors are mixed at 50%, but the percentage can be controlled. Here we mix the color blue
into the color red
at 20%. With blue
at 20% and red
at 80%, this gives us a more reddish color.
>>> Color("red").mix(Color("blue"), 0.2)
color(--oklab 0.59277 0.1734 0.03837 / 1)
As with all interpolation based functions, if needed, a different color space can be specified with the space
parameter or even a different interpolation method via method
. mix
accepts all the same parameters used in interpolate
, though concepts like stops and hints are not allowed with mixing.
>>> Color("red").mix(Color("blue"), space="hsl", method='bspline')
color(--hsl -60 1 0.5 / 1)
Mix can also accept a string and will create the color for us which is great if we don't need to work with the second color afterwards.
>>> Color("red").mix("blue", 0.2)
color(--oklab 0.59277 0.1734 0.03837 / 1)
Mixing will always return a new color unless in_place
is set True
.
Steps
Interpolation Options
Any options not consumed by mix
will be passed to the underlying interpolation
function. This includes options like hue
, progress
, etc.
The steps
method provides an intuitive interface to create lists of discrete colors. Like mixing, it is also built on interpolate
. Just provide two or more colors, and specify how many steps
are wanted.
>>> Color.steps(["red", "blue"], steps=10)
[color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.60841 0.19627 0.07725 / 1), color(--oklab 0.58886 0.16768 0.02865 / 1), color(--oklab 0.56931 0.13909 -0.01995 / 1), color(--oklab 0.54976 0.1105 -0.06854 / 1), color(--oklab 0.53021 0.08191 -0.11714 / 1), color(--oklab 0.51066 0.05332 -0.16574 / 1), color(--oklab 0.49111 0.02473 -0.21433 / 1), color(--oklab 0.47156 -0.00387 -0.26293 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1)]
If desired, multiple colors can be provided, and steps will be returned for all the interpolated segments. When interpolating multiple colors, piecewise interpolation is used (which is covered in more detail later).
>>> Color.steps(["red", "orange", "yellow", "green"], steps=10)
[color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.68287 0.16878 0.13769 / 1), color(--oklab 0.73778 0.1127 0.14954 / 1), color(--oklab 0.79269 0.05661 0.16138 / 1), color(--oklab 0.85112 0.01395 0.17378 / 1), color(--oklab 0.90955 -0.02871 0.18617 / 1), color(--oklab 0.96798 -0.07137 0.19857 / 1), color(--oklab 0.81857 -0.09435 0.16827 / 1), color(--oklab 0.66916 -0.11732 0.13797 / 1), color(--oklab 0.51975 -0.1403 0.10768 / 1)]
Steps can also be configured to return colors based on a maximum Delta E distance. This means you can ensure the distance between all colors is no greater than a certain value.
In this example, we specify the color color(display-p3 0 1 0)
and interpolate steps between red
. The result gives us an array of colors, where the distance between any two colors should be no greater than the Delta E result of 10.
>>> Color.steps(
... [Color("display-p3", [0, 1, 0]), "red"],
... space="lch",
... out_space="srgb",
... max_delta_e=10
... )
[color(srgb -0.5116 1.0183 -0.31067 / 1), color(srgb -0.4504 0.99903 -0.32673 / 1), color(srgb -0.37655 0.97943 -0.33694 / 1), color(srgb -0.27847 0.95946 -0.34286 / 1), color(srgb -0.09291 0.9391 -0.34554 / 1), color(srgb 0.23528 0.91833 -0.34574 / 1), color(srgb 0.34809 0.89715 -0.34401 / 1), color(srgb 0.42823 0.87552 -0.34098 / 1), color(srgb 0.49308 0.85343 -0.33745 / 1), color(srgb 0.54849 0.83088 -0.33349 / 1), color(srgb 0.59727 0.80784 -0.32909 / 1), color(srgb 0.64097 0.7843 -0.32423 / 1), color(srgb 0.6806 0.76025 -0.3189 / 1), color(srgb 0.71679 0.73568 -0.3131 / 1), color(srgb 0.74999 0.71057 -0.30681 / 1), color(srgb 0.78053 0.6849 -0.30002 / 1), color(srgb 0.80865 0.65865 -0.29271 / 1), color(srgb 0.83451 0.6318 -0.28486 / 1), color(srgb 0.85826 0.60433 -0.27644 / 1), color(srgb 0.87999 0.57619 -0.26744 / 1), color(srgb 0.89978 0.54736 -0.25782 / 1), color(srgb 0.9177 0.51777 -0.24752 / 1), color(srgb 0.9338 0.48735 -0.2365 / 1), color(srgb 0.9481 0.456 -0.22467 / 1), color(srgb 0.96064 0.4236 -0.21195 / 1), color(srgb 0.97145 0.38994 -0.19821 / 1), color(srgb 0.98055 0.35476 -0.18326 / 1), color(srgb 0.98796 0.31761 -0.16684 / 1), color(srgb 0.99369 0.27779 -0.14854 / 1), color(srgb 0.99777 0.23405 -0.12767 / 1), color(srgb 1.0002 0.18378 -0.10158 / 1), color(srgb 1.0009 0.11978 -0.06417 / 1), color(srgb 1 0 0 / 1)]
max_steps
can be used to limit the results of max_delta_e
in case result balloons to an unexpected size. Obviously, this affects the Delta E between the colors inversely. It should be noted that steps are injected equally between every color when satisfying a max Delta E limit in order to avoid shifting the midpoint. In some cases, in order to satisfy both the max_delta_e
and the max_steps
requirement, the number of steps may even be clipped such that they are less than the max_steps
limit. max_steps
is set to 1000
by default, but can be set to None
if no limit is desired.
>>> Color.steps(
... [Color("display-p3", [0, 1, 0]), "red"],
... space="lch",
... out_space="srgb",
... max_delta_e=10,
... max_steps=10
... )
[color(srgb -0.5116 1.0183 -0.31067 / 1), color(srgb -0.09291 0.9391 -0.34554 / 1), color(srgb 0.49308 0.85343 -0.33745 / 1), color(srgb 0.6806 0.76025 -0.3189 / 1), color(srgb 0.80865 0.65865 -0.29271 / 1), color(srgb 0.89978 0.54736 -0.25782 / 1), color(srgb 0.96064 0.4236 -0.21195 / 1), color(srgb 0.99369 0.27779 -0.14854 / 1), color(srgb 1 0 0 / 1)]
When specifying a max_delta_e
, steps
will function as a minimum required steps and will push the delta even smaller if the required steps is greater than the calculated steps via the maximum Delta E limit.
>>> Color.steps(
... [Color("display-p3", [0, 1, 0]), "red"],
... space="lch",
... out_space="srgb",
... max_delta_e=10,
... steps=50
... )
[color(srgb -0.5116 1.0183 -0.31067 / 1), color(srgb -0.47276 1.0057 -0.32192 / 1), color(srgb -0.42946 0.99307 -0.33039 / 1), color(srgb -0.37992 0.98024 -0.33661 / 1), color(srgb -0.32082 0.96725 -0.341 / 1), color(srgb -0.24447 0.9541 -0.34385 / 1), color(srgb -0.1198 0.94078 -0.34542 / 1), color(srgb 0.15996 0.92729 -0.34592 / 1), color(srgb 0.26558 0.91362 -0.3455 / 1), color(srgb 0.33665 0.89976 -0.34431 / 1), color(srgb 0.3931 0.88572 -0.34248 / 1), color(srgb 0.44104 0.8715 -0.34036 / 1), color(srgb 0.48324 0.85707 -0.33806 / 1), color(srgb 0.52118 0.84244 -0.33557 / 1), color(srgb 0.55582 0.82761 -0.33289 / 1), color(srgb 0.58776 0.81258 -0.33002 / 1), color(srgb 0.61745 0.79733 -0.32696 / 1), color(srgb 0.64519 0.78187 -0.32371 / 1), color(srgb 0.67123 0.76619 -0.32025 / 1), color(srgb 0.69575 0.75029 -0.31659 / 1), color(srgb 0.7189 0.73416 -0.31273 / 1), color(srgb 0.74079 0.7178 -0.30866 / 1), color(srgb 0.76151 0.7012 -0.30437 / 1), color(srgb 0.78113 0.68437 -0.29987 / 1), color(srgb 0.79972 0.66729 -0.29515 / 1), color(srgb 0.81733 0.64995 -0.2902 / 1), color(srgb 0.834 0.63236 -0.28502 / 1), color(srgb 0.84977 0.6145 -0.2796 / 1), color(srgb 0.86467 0.59636 -0.27393 / 1), color(srgb 0.87872 0.57794 -0.26801 / 1), color(srgb 0.89194 0.55922 -0.26182 / 1), color(srgb 0.90434 0.54018 -0.25536 / 1), color(srgb 0.91596 0.52082 -0.2486 / 1), color(srgb 0.92679 0.50111 -0.24154 / 1), color(srgb 0.93686 0.48103 -0.23415 / 1), color(srgb 0.94616 0.46054 -0.22641 / 1), color(srgb 0.95471 0.43961 -0.2183 / 1), color(srgb 0.96252 0.41819 -0.20979 / 1), color(srgb 0.96959 0.39623 -0.20082 / 1), color(srgb 0.97593 0.37364 -0.19136 / 1), color(srgb 0.98155 0.35032 -0.18134 / 1), color(srgb 0.98644 0.32615 -0.17067 / 1), color(srgb 0.99062 0.30093 -0.15926 / 1), color(srgb 0.99409 0.27439 -0.14694 / 1), color(srgb 0.99685 0.24615 -0.13353 / 1), color(srgb 0.99891 0.21556 -0.11841 / 1), color(srgb 1.0002 0.18152 -0.10034 / 1), color(srgb 1.0009 0.14177 -0.07755 / 1), color(srgb 1.0008 0.09013 -0.04535 / 1), color(srgb 1 0 0 / 1)]
steps
uses the color class's default ∆E method to calculate max ∆E, the current default ∆E being ∆E*ab. While using something like ∆E*00 is far more accurate, it is a much more expensive operation. If desired, the class's default ∆E can be changed via subclassing the color object and changing DELTA_E
class variable or by manually specifying the method via the delta_e
parameter.
>>> Color.steps(
... [Color("display-p3", [0, 1, 0]), "red"],
... space="lch",
... out_space="srgb",
... max_delta_e=10,
... delta_e="76"
... )
[color(srgb -0.5116 1.0183 -0.31067 / 1), color(srgb -0.4504 0.99903 -0.32673 / 1), color(srgb -0.37655 0.97943 -0.33694 / 1), color(srgb -0.27847 0.95946 -0.34286 / 1), color(srgb -0.09291 0.9391 -0.34554 / 1), color(srgb 0.23528 0.91833 -0.34574 / 1), color(srgb 0.34809 0.89715 -0.34401 / 1), color(srgb 0.42823 0.87552 -0.34098 / 1), color(srgb 0.49308 0.85343 -0.33745 / 1), color(srgb 0.54849 0.83088 -0.33349 / 1), color(srgb 0.59727 0.80784 -0.32909 / 1), color(srgb 0.64097 0.7843 -0.32423 / 1), color(srgb 0.6806 0.76025 -0.3189 / 1), color(srgb 0.71679 0.73568 -0.3131 / 1), color(srgb 0.74999 0.71057 -0.30681 / 1), color(srgb 0.78053 0.6849 -0.30002 / 1), color(srgb 0.80865 0.65865 -0.29271 / 1), color(srgb 0.83451 0.6318 -0.28486 / 1), color(srgb 0.85826 0.60433 -0.27644 / 1), color(srgb 0.87999 0.57619 -0.26744 / 1), color(srgb 0.89978 0.54736 -0.25782 / 1), color(srgb 0.9177 0.51777 -0.24752 / 1), color(srgb 0.9338 0.48735 -0.2365 / 1), color(srgb 0.9481 0.456 -0.22467 / 1), color(srgb 0.96064 0.4236 -0.21195 / 1), color(srgb 0.97145 0.38994 -0.19821 / 1), color(srgb 0.98055 0.35476 -0.18326 / 1), color(srgb 0.98796 0.31761 -0.16684 / 1), color(srgb 0.99369 0.27779 -0.14854 / 1), color(srgb 0.99777 0.23405 -0.12767 / 1), color(srgb 1.0002 0.18378 -0.10158 / 1), color(srgb 1.0009 0.11978 -0.06417 / 1), color(srgb 1 0 0 / 1)]
>>> Color.steps(
... [Color("display-p3", [0, 1, 0]), "red"],
... space="lch",
... out_space="srgb",
... max_delta_e=10,
... delta_e="2000"
... )
[color(srgb -0.5116 1.0183 -0.31067 / 1), color(srgb -0.37655 0.97943 -0.33694 / 1), color(srgb -0.09291 0.9391 -0.34554 / 1), color(srgb 0.34809 0.89715 -0.34401 / 1), color(srgb 0.49308 0.85343 -0.33745 / 1), color(srgb 0.59727 0.80784 -0.32909 / 1), color(srgb 0.6806 0.76025 -0.3189 / 1), color(srgb 0.74999 0.71057 -0.30681 / 1), color(srgb 0.80865 0.65865 -0.29271 / 1), color(srgb 0.85826 0.60433 -0.27644 / 1), color(srgb 0.89978 0.54736 -0.25782 / 1), color(srgb 0.9338 0.48735 -0.2365 / 1), color(srgb 0.96064 0.4236 -0.21195 / 1), color(srgb 0.98055 0.35476 -0.18326 / 1), color(srgb 0.99369 0.27779 -0.14854 / 1), color(srgb 1.0002 0.18378 -0.10158 / 1), color(srgb 1 0 0 / 1)]
And much like interpolate
, we can use stops
and hints
and any of the other supported interpolate
features as well.
>>> Color.steps(['orange', stop('purple', 0.25), 'green'], method='bspline', steps=10)
[color(--oklab 0.79269 0.05661 0.16138 / 1), color(--oklab 0.63434 0.09861 0.05147 / 1), color(--oklab 0.51731 0.10434 -0.01701 / 1), color(--oklab 0.48698 0.08246 -0.02298 / 1), color(--oklab 0.47842 0.05764 -0.01527 / 1), color(--oklab 0.4775 0.02611 0.00011 / 1), color(--oklab 0.48271 -0.01079 0.02163 / 1), color(--oklab 0.49251 -0.05172 0.04775 / 1), color(--oklab 0.50536 -0.09534 0.07695 / 1), color(--oklab 0.51975 -0.1403 0.10768 / 1)]
Masking
If desired, we can mask off specific channels that we do not wish to interpolate. Masking works by cloning the color and setting the specified channels as undefined (internally set to NaN
). When interpolating, if one color's channel has a NaN
, the other color's channel will be used as the result, keeping that channel at a constant value. If both colors have a NaN
for the same channel, then NaN
will be returned.
Magic Behind NaN
There are times when NaN
values can happen naturally, such as with achromatic colors with hues. To learn more, check out Undefined Handling/NaN Handling.
In the following example, we have a base color of lch(52% 58.1 22.7)
which we then interpolate with lch(56% 49.1 257.1)
. We then mask off the second color's channels except for hue
. Applying this logic, we will end up with a range of colors that maintains the same lightness and chroma as the first color, but with different hues. We can see as we step through the colors that only the hue is interpolated.
>>> i = Color.interpolate(
... ["lch(52% 58.1 22.7)", Color("lch(56% 49.1 257.1)").mask(['lightness', 'chroma', 'alpha'])],
... space="lch"
... )
>>> [i(x/10).to_string() for x in range(10)]
['lch(52 58.1 22.7)', 'lch(52 58.1 10.14)', 'lch(52 58.1 357.58)', 'lch(52 58.1 345.02)', 'lch(52 58.1 332.46)', 'lch(52 58.1 319.9)', 'lch(52 58.1 307.34)', 'lch(52 58.1 294.78)', 'lch(52 58.1 282.22)', 'lch(52 58.1 269.66)']
You can also create inverted masks. An inverted mask will mask all except the specified channel.
>>> i = Color.interpolate(
... ["lch(52% 58.1 22.7)", Color("lch(56% 49.1 257.1)").mask('hue', invert=True)],
... space="lch"
... )
>>> [i(x/10).to_string() for x in range(10)]
['lch(52 58.1 22.7)', 'lch(52 58.1 10.14)', 'lch(52 58.1 357.58)', 'lch(52 58.1 345.02)', 'lch(52 58.1 332.46)', 'lch(52 58.1 319.9)', 'lch(52 58.1 307.34)', 'lch(52 58.1 294.78)', 'lch(52 58.1 282.22)', 'lch(52 58.1 269.66)']
Easing Functions
When interpolating, whether using linear interpolation or something like B-Spline interpolation, the transitioning between colors is always linear in time, even if the path to those colors is not. For example, if you are interpolating between 2 colors and you request a 0.5
point on that line, it will always be in the middle. This is because, no matter how crooked the path, the rate of change on that path is always linear.
By default, ColorAide uses linear transitions when interpolating, but there are times that a different, more dynamic transition may be desired. This can be achieved by using the progress
parameter on any of the interpolation related functions provided by ColorAide.
progress
accepts an easing function that takes a single time
input and returns a new time
input. This allows for a user to augment the rate of change when transitioning from one color to another. Inputs are almost always between 0 - 1 unless extrapolate
is enabled and the user has manually input a range beyond 0 - 1. Even a change in domain will not affect the range as once the domain is accounted for, internally the domain [0, 1] is used.
ColorAide provides 5 basic easing functions out of the box along with cubic_bezier
which is used to create all of the aforementioned easing function except linear
, which simply returns what is given as an input.
Create Your Own Cubic Bezier Easings Online: https://cubic-bezier.com
More Common Cubic Bezier Easings
The following were all acquired from https://matthewlein.com/tools/ceaser.js.
ease_in_quad = cubic_bezier(0.550, 0.085, 0.680, 0.530)
ease_in_cubic = cubic_bezier(0.550, 0.055, 0.675, 0.190)
ease_in_quart = cubic_bezier(0.895, 0.030, 0.685, 0.220)
ease_in_quint = cubic_bezier(0.755, 0.050, 0.855, 0.060)
ease_in_sine = cubic_bezier(0.470, 0.000, 0.745, 0.715)
ease_in_expo = cubic_bezier(0.950, 0.050, 0.795, 0.035)
ease_in_circ = cubic_bezier(0.600, 0.040, 0.980, 0.335)
ease_in_back = cubic_bezier(0.600, -0.280, 0.735, 0.045)
ease_out_quad = cubic_bezier(0.250, 0.460, 0.450, 0.940)
ease_out_cubic = cubic_bezier(0.215, 0.610, 0.355, 1.000)
ease_out_quart = cubic_bezier(0.165, 0.840, 0.440, 1.000)
ease_out_quint = cubic_bezier(0.230, 1.000, 0.320, 1.000)
ease_out_sine = cubic_bezier(0.390, 0.575, 0.565, 1.000)
ease_out_expo = cubic_bezier(0.190, 1.000, 0.220, 1.000)
ease_out_circ = cubic_bezier(0.075, 0.820, 0.165, 1.000)
ease_out_back = cubic_bezier(0.175, 0.885, 0.320, 1.275)
ease_in_out_quad = cubic_bezier(0.455, 0.030, 0.515, 0.955)
ease_in_out_cubic = cubic_bezier(0.645, 0.045, 0.355, 1.000)
ease_in_out_quart = cubic_bezier(0.770, 0.000, 0.175, 1.000)
ease_in_out_quint = cubic_bezier(0.860, 0.000, 0.070, 1.000)
ease_in_out_sine = cubic_bezier(0.445, 0.050, 0.550, 0.950)
ease_in_out_expo = cubic_bezier(1.000, 0.000, 0.000, 1.000)
ease_in_out_circ = cubic_bezier(0.785, 0.135, 0.150, 0.860)
ease_in_out_back = cubic_bezier(0.680, -0.550, 0.265, 1.550)
Here, we are using the default "ease in" and "ease out" easing functions provided by ColorAide.
>>> from coloraide import ease_in, ease_out
>>> Color.interpolate(
... ["green", "blue"],
... progress=ease_in
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107fe58d0>
>>> Color.interpolate(
... ["green", "blue"]
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x108001210>
>>> Color.interpolate(
... ["green", "blue"],
... progress=ease_out
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107fc28d0>
Additionally, easing functions can be injected inline which allows a user to control how easing is performed between specific sub-interpolations within piecewise interpolation.
>>> Color.interpolate(["red", "green", ease_out, "blue"])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107f9eed0>
ColorAide even lets you apply easing functions to specific channels, though they can only be done this way for the entire operation. This can be done to one or more channels at a time. Below, we apply an exponential "ease in" to alpha
while allowing all other channels to interpolate normally.
>>> ease_in_expo = cubic_bezier(0.950, 0.050, 0.795, 0.035)
>>> Color.interpolate(
... ["lch(50% 50 0)", "lch(90% 50 260 / 0.5)"],
... progress={
... 'alpha': ease_in_expo
... }
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x108001e10>
We can also set all the channels to an easing function via all
and then override specific channels. In this case, we exponentially "ease out" on all channels except the red channel, which we then force to be linear.
>>> ease_out_expo = cubic_bezier(0.190, 1.000, 0.220, 1.000)
>>> Color.interpolate(
... ["color(srgb 0 1 1)", "color(srgb 1 0 0)"],
... progress={
... 'all': ease_out_expo,
... 'r': linear
... },
... space='srgb'
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107f9e910>
Color Stops and Hints
Color stops are the position where the transition to and from a color starts and ends. By default, color stops are evenly distributed within the domain of [0, 1], but if desired, these color stops can be shifted.
To specify color stops, simply wrap a color in a coloraide.stop
object and specify the stop position. Stop positions will then cause the transition of the targeted color to be moved.
>>> from coloraide import stop
>>> Color.interpolate(['orange', 'purple', 'green'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x10807a650>
>>> Color.interpolate(['orange', stop('purple', 0.25), 'green'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x108019850>
Color stops follow the rules as laid out in the CSS spec.
CSS gradients also have a concept of "hints". Hints essentially define the midpoint between two colors. Instead of reinventing the wheel, and further complicating the interface, we've decided to just demonstrate color hints with easing functions. The logic comes directly from the CSS spec.
Using the hint
function, we can generate a midpoint easing method that moves the middle of the interpolation transition to the specified point which is relative to the two color stops it is between.
>>> from coloraide import hint
>>> Color.interpolate(['orange', 'purple', 'green'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x108002050>
>>> Color.interpolate(['orange', hint(0.75), 'purple', 'green'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107fc27d0>
Padding
New 2.6
Particularly when interpolating a color scale, it can be useful to "resize" the area of the color scale being evaluated. This can generally be done using the padding
parameter. Consider the following example using the ColorBrewer scale OrRd
.
>>> scale = ['#fff7ec', '#fee8c8', '#fdd49e', '#fdbb84', '#fc8d59', '#ef6548', '#d7301f', '#b30000', '#7f0000']
>>> Color.interpolate(scale, space='srgb')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107fbe0d0>
>>> Color.discrete(scale, space='srgb', steps=5)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107fe6050>
>>> Color.interpolate(scale, space='srgb', padding=0.25)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x108010bd0>
>>> Color.discrete(scale, space='srgb', steps=5, padding=0.25)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x1080137d0>
Padding can be applied to both sides by specifying a single number, or it can be controlled per side by sending in a sequence of two values.
>>> scale = ['#fff7ec', '#fee8c8', '#fdd49e', '#fdbb84', '#fc8d59', '#ef6548', '#d7301f', '#b30000', '#7f0000']
>>> Color.discrete(scale, space='srgb', steps=5, padding=[0.25, 0])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107faccd0>
Negative padding is allowed as well.
>>> scale = ['#fff7ec', '#fee8c8', '#fdd49e', '#fdbb84', '#fc8d59', '#ef6548', '#d7301f', '#b30000', '#7f0000']
>>> Color.discrete(scale, space='srgb', steps=5, padding=[-0.25, 0])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107f91650>
If the result extends past the limits, extrapolate
needs to be enabled or the values will be clamped to the ends.
>>> scale = ['#fff7ec', '#fee8c8', '#fdd49e', '#fdbb84', '#fc8d59', '#ef6548', '#d7301f', '#b30000', '#7f0000']
>>> Color.discrete(scale, space='srgb', steps=5, padding=[1, 1])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107fde950>
>>> Color.discrete(scale, space='srgb', steps=5, padding=[1, 1], extrapolate=True)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107fdc910>
Domains
By default, interpolation has an input domain of [0, 1]. This domain applies to an entire interpolation, even ones that span multiple colors. Generally, this is sufficient and can be used to generate color scales, mixes, and steps in any way that a user needs. When generating colors that should align with data, custom domains can be quite helpful.
For instance, associating colors with temperature.
>>> i = Color.interpolate(
... ['blue', 'green', 'yellow', 'orange', 'red'],
... domain=[-32, 32, 60, 85, 95]
... )
>>> i(-32)
color(--oklab 0.45201 -0.03246 -0.31153 / 1)
>>> i(47)
color(--oklab 0.75988 -0.10337 0.15637 / 1)
>>> i(89)
color(--oklab 0.7268 0.12391 0.14717 / 1)
>>> i
<coloraide.interpolate.linear.InterpolatorLinear object at 0x108017450>
It should be noted that you are not constrained to provide the exact same amount of domain values as you have colors and can have differing amounts, but if you want to align specific colors to certain data points, then it helps.
>>> Color.interpolate(
... ['blue', 'green', 'yellow', 'orange', 'red'],
... domain=[-32, 32, 60, 85, 95]
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107f92f50>
>>> Color.interpolate(
... ['blue', 'green', 'yellow', 'orange', 'red'],
... domain=[-32, 95]
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x1080104d0>
Lastly, domains must be specified in ascending order of values. If a value decreases in magnitude, it will assume the value that comes right before it. This means you cannot put a domain in reverse. If you need to reverse the order, just flip the color order and setup the domain accordingly.
>>> i = Color.interpolate(
... ['blue', 'green', 'yellow', 'orange', 'red'],
... domain=[-32, 32, 60, 20, 95]
... )
>>> i.domain
<bound method Interpolator.domain of <coloraide.interpolate.linear.InterpolatorLinear object at 0x107fdea10>>
>>> i
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107fdea10>
Custom domains are most useful when working with discrete
or interpolate
directly, but you can use it in other methods like steps
as well. As steps
does not take data point inputs like interpolate
, we do not need to use the temperature data as an input except to set the domain, but the steps will be generated with the same alignment relative to the domain range.
>>> Color.interpolate(
... ['blue', 'green', 'yellow', 'orange', 'red'],
... domain=[-32, 32, 60, 85, 95]
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107ff9a10>
>>> Color.discrete(
... ['blue', 'green', 'yellow', 'orange', 'red'],
... domain=[-32, 32, 60, 85, 95]
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107f6c850>
>>> Color.steps(
... ['blue', 'green', 'yellow', 'orange', 'red'],
... steps=11,
... domain=[-32, 32, 60, 85, 95]
... )
[color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.46546 -0.05386 -0.22834 / 1), color(--oklab 0.4789 -0.07526 -0.14516 / 1), color(--oklab 0.49234 -0.09666 -0.06197 / 1), color(--oklab 0.50578 -0.11806 0.02122 / 1), color(--oklab 0.51922 -0.13946 0.1044 / 1), color(--oklab 0.71505 -0.11027 0.14728 / 1), color(--oklab 0.91836 -0.079 0.18851 / 1), color(--oklab 0.90067 -0.02222 0.18429 / 1), color(--oklab 0.81162 0.04279 0.1654 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1)]
Wile you can technically feed domain
into mix
, it is probably not as useful. It will respect the domain alignment, but mix always accepts a percentage of [0, 1], regardless of the underlying domain.
Extrapolation
By default, ColorAide clamps the entire progress of an interpolation to always be within the domain ([0, 1] by default). In most cases, this is more what most user expects and why this is the default. It should be noted that this does not affect easing functions, as the clamping is done prior to any easing function calls.
If it is desired to extrapolate past 0 and 1, extrapolate
can set to True
on all interpolation methods.
>>> Color('red').mix('blue', 0.5)
color(--oklab 0.53998 0.0962 -0.09284 / 1)
>>> Color('red').mix('blue', -0.5, extrapolate=True)
color(--oklab 0.71593 0.35352 0.34453 / 1)
As a larger example, we can purposely interpolate over a range with values beyond 0 and 1. Here we extended the range to -0.5 and 1.5.
>>> offset, factor = 0.25, 1.5
>>> i = Color.interpolate(['red', 'blue'])
>>> Ramp([i((r * factor / 100) - offset) for r in range(101)])
[color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62708 0.22358 0.12366 / 1), color(--oklab 0.62444 0.21972 0.1171 / 1), color(--oklab 0.6218 0.21586 0.11054 / 1), color(--oklab 0.61916 0.212 0.10398 / 1), color(--oklab 0.61652 0.20814 0.09742 / 1), color(--oklab 0.61388 0.20428 0.09086 / 1), color(--oklab 0.61124 0.20042 0.0843 / 1), color(--oklab 0.6086 0.19656 0.07774 / 1), color(--oklab 0.60596 0.1927 0.07117 / 1), color(--oklab 0.60332 0.18884 0.06461 / 1), color(--oklab 0.60068 0.18498 0.05805 / 1), color(--oklab 0.59805 0.18112 0.05149 / 1), color(--oklab 0.59541 0.17726 0.04493 / 1), color(--oklab 0.59277 0.1734 0.03837 / 1), color(--oklab 0.59013 0.16954 0.03181 / 1), color(--oklab 0.58749 0.16568 0.02525 / 1), color(--oklab 0.58485 0.16182 0.01869 / 1), color(--oklab 0.58221 0.15796 0.01213 / 1), color(--oklab 0.57957 0.1541 0.00557 / 1), color(--oklab 0.57693 0.15024 -0.00099 / 1), color(--oklab 0.57429 0.14638 -0.00755 / 1), color(--oklab 0.57165 0.14252 -0.01411 / 1), color(--oklab 0.56901 0.13866 -0.02067 / 1), color(--oklab 0.56638 0.1348 -0.02723 / 1), color(--oklab 0.56374 0.13094 -0.0338 / 1), color(--oklab 0.5611 0.12708 -0.04036 / 1), color(--oklab 0.55846 0.12322 -0.04692 / 1), color(--oklab 0.55582 0.11936 -0.05348 / 1), color(--oklab 0.55318 0.1155 -0.06004 / 1), color(--oklab 0.55054 0.11164 -0.0666 / 1), color(--oklab 0.5479 0.10778 -0.07316 / 1), color(--oklab 0.54526 0.10392 -0.07972 / 1), color(--oklab 0.54262 0.10006 -0.08628 / 1), color(--oklab 0.53998 0.0962 -0.09284 / 1), color(--oklab 0.53735 0.09234 -0.0994 / 1), color(--oklab 0.53471 0.08848 -0.10596 / 1), color(--oklab 0.53207 0.08462 -0.11252 / 1), color(--oklab 0.52943 0.08076 -0.11908 / 1), color(--oklab 0.52679 0.0769 -0.12564 / 1), color(--oklab 0.52415 0.07304 -0.1322 / 1), color(--oklab 0.52151 0.06918 -0.13877 / 1), color(--oklab 0.51887 0.06532 -0.14533 / 1), color(--oklab 0.51623 0.06146 -0.15189 / 1), color(--oklab 0.51359 0.05761 -0.15845 / 1), color(--oklab 0.51095 0.05375 -0.16501 / 1), color(--oklab 0.50832 0.04989 -0.17157 / 1), color(--oklab 0.50568 0.04603 -0.17813 / 1), color(--oklab 0.50304 0.04217 -0.18469 / 1), color(--oklab 0.5004 0.03831 -0.19125 / 1), color(--oklab 0.49776 0.03445 -0.19781 / 1), color(--oklab 0.49512 0.03059 -0.20437 / 1), color(--oklab 0.49248 0.02673 -0.21093 / 1), color(--oklab 0.48984 0.02287 -0.21749 / 1), color(--oklab 0.4872 0.01901 -0.22405 / 1), color(--oklab 0.48456 0.01515 -0.23061 / 1), color(--oklab 0.48192 0.01129 -0.23717 / 1), color(--oklab 0.47928 0.00743 -0.24374 / 1), color(--oklab 0.47665 0.00357 -0.2503 / 1), color(--oklab 0.47401 -0.00029 -0.25686 / 1), color(--oklab 0.47137 -0.00415 -0.26342 / 1), color(--oklab 0.46873 -0.00801 -0.26998 / 1), color(--oklab 0.46609 -0.01187 -0.27654 / 1), color(--oklab 0.46345 -0.01573 -0.2831 / 1), color(--oklab 0.46081 -0.01959 -0.28966 / 1), color(--oklab 0.45817 -0.02345 -0.29622 / 1), color(--oklab 0.45553 -0.02731 -0.30278 / 1), color(--oklab 0.45289 -0.03117 -0.30934 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1)]
>>> i = Color.interpolate(['red', 'blue'], extrapolate=True)
>>> Ramp([i((r * factor / 100) - offset) for r in range(101)])
[color(--oklab 0.67194 0.28919 0.23519 / 1), color(--oklab 0.6693 0.28533 0.22863 / 1), color(--oklab 0.66666 0.28147 0.22207 / 1), color(--oklab 0.66402 0.27761 0.21551 / 1), color(--oklab 0.66138 0.27375 0.20895 / 1), color(--oklab 0.65875 0.26989 0.20239 / 1), color(--oklab 0.65611 0.26603 0.19583 / 1), color(--oklab 0.65347 0.26217 0.18927 / 1), color(--oklab 0.65083 0.25831 0.1827 / 1), color(--oklab 0.64819 0.25445 0.17614 / 1), color(--oklab 0.64555 0.2506 0.16958 / 1), color(--oklab 0.64291 0.24674 0.16302 / 1), color(--oklab 0.64027 0.24288 0.15646 / 1), color(--oklab 0.63763 0.23902 0.1499 / 1), color(--oklab 0.63499 0.23516 0.14334 / 1), color(--oklab 0.63235 0.2313 0.13678 / 1), color(--oklab 0.62971 0.22744 0.13022 / 1), color(--oklab 0.62708 0.22358 0.12366 / 1), color(--oklab 0.62444 0.21972 0.1171 / 1), color(--oklab 0.6218 0.21586 0.11054 / 1), color(--oklab 0.61916 0.212 0.10398 / 1), color(--oklab 0.61652 0.20814 0.09742 / 1), color(--oklab 0.61388 0.20428 0.09086 / 1), color(--oklab 0.61124 0.20042 0.0843 / 1), color(--oklab 0.6086 0.19656 0.07774 / 1), color(--oklab 0.60596 0.1927 0.07117 / 1), color(--oklab 0.60332 0.18884 0.06461 / 1), color(--oklab 0.60068 0.18498 0.05805 / 1), color(--oklab 0.59805 0.18112 0.05149 / 1), color(--oklab 0.59541 0.17726 0.04493 / 1), color(--oklab 0.59277 0.1734 0.03837 / 1), color(--oklab 0.59013 0.16954 0.03181 / 1), color(--oklab 0.58749 0.16568 0.02525 / 1), color(--oklab 0.58485 0.16182 0.01869 / 1), color(--oklab 0.58221 0.15796 0.01213 / 1), color(--oklab 0.57957 0.1541 0.00557 / 1), color(--oklab 0.57693 0.15024 -0.00099 / 1), color(--oklab 0.57429 0.14638 -0.00755 / 1), color(--oklab 0.57165 0.14252 -0.01411 / 1), color(--oklab 0.56901 0.13866 -0.02067 / 1), color(--oklab 0.56638 0.1348 -0.02723 / 1), color(--oklab 0.56374 0.13094 -0.0338 / 1), color(--oklab 0.5611 0.12708 -0.04036 / 1), color(--oklab 0.55846 0.12322 -0.04692 / 1), color(--oklab 0.55582 0.11936 -0.05348 / 1), color(--oklab 0.55318 0.1155 -0.06004 / 1), color(--oklab 0.55054 0.11164 -0.0666 / 1), color(--oklab 0.5479 0.10778 -0.07316 / 1), color(--oklab 0.54526 0.10392 -0.07972 / 1), color(--oklab 0.54262 0.10006 -0.08628 / 1), color(--oklab 0.53998 0.0962 -0.09284 / 1), color(--oklab 0.53735 0.09234 -0.0994 / 1), color(--oklab 0.53471 0.08848 -0.10596 / 1), color(--oklab 0.53207 0.08462 -0.11252 / 1), color(--oklab 0.52943 0.08076 -0.11908 / 1), color(--oklab 0.52679 0.0769 -0.12564 / 1), color(--oklab 0.52415 0.07304 -0.1322 / 1), color(--oklab 0.52151 0.06918 -0.13877 / 1), color(--oklab 0.51887 0.06532 -0.14533 / 1), color(--oklab 0.51623 0.06146 -0.15189 / 1), color(--oklab 0.51359 0.05761 -0.15845 / 1), color(--oklab 0.51095 0.05375 -0.16501 / 1), color(--oklab 0.50832 0.04989 -0.17157 / 1), color(--oklab 0.50568 0.04603 -0.17813 / 1), color(--oklab 0.50304 0.04217 -0.18469 / 1), color(--oklab 0.5004 0.03831 -0.19125 / 1), color(--oklab 0.49776 0.03445 -0.19781 / 1), color(--oklab 0.49512 0.03059 -0.20437 / 1), color(--oklab 0.49248 0.02673 -0.21093 / 1), color(--oklab 0.48984 0.02287 -0.21749 / 1), color(--oklab 0.4872 0.01901 -0.22405 / 1), color(--oklab 0.48456 0.01515 -0.23061 / 1), color(--oklab 0.48192 0.01129 -0.23717 / 1), color(--oklab 0.47928 0.00743 -0.24374 / 1), color(--oklab 0.47665 0.00357 -0.2503 / 1), color(--oklab 0.47401 -0.00029 -0.25686 / 1), color(--oklab 0.47137 -0.00415 -0.26342 / 1), color(--oklab 0.46873 -0.00801 -0.26998 / 1), color(--oklab 0.46609 -0.01187 -0.27654 / 1), color(--oklab 0.46345 -0.01573 -0.2831 / 1), color(--oklab 0.46081 -0.01959 -0.28966 / 1), color(--oklab 0.45817 -0.02345 -0.29622 / 1), color(--oklab 0.45553 -0.02731 -0.30278 / 1), color(--oklab 0.45289 -0.03117 -0.30934 / 1), color(--oklab 0.45025 -0.03503 -0.3159 / 1), color(--oklab 0.44762 -0.03889 -0.32246 / 1), color(--oklab 0.44498 -0.04275 -0.32902 / 1), color(--oklab 0.44234 -0.04661 -0.33558 / 1), color(--oklab 0.4397 -0.05047 -0.34214 / 1), color(--oklab 0.43706 -0.05433 -0.3487 / 1), color(--oklab 0.43442 -0.05819 -0.35527 / 1), color(--oklab 0.43178 -0.06205 -0.36183 / 1), color(--oklab 0.42914 -0.06591 -0.36839 / 1), color(--oklab 0.4265 -0.06977 -0.37495 / 1), color(--oklab 0.42386 -0.07363 -0.38151 / 1), color(--oklab 0.42122 -0.07749 -0.38807 / 1), color(--oklab 0.41858 -0.08135 -0.39463 / 1), color(--oklab 0.41595 -0.08521 -0.40119 / 1), color(--oklab 0.41331 -0.08907 -0.40775 / 1), color(--oklab 0.41067 -0.09293 -0.41431 / 1), color(--oklab 0.40803 -0.09679 -0.42087 / 1)]
Lastly, it is important to note that this affects stops as well, mainly stops applied to interpolation endpoints. When an endpoint is moved inwards via a color stop, the end range of the interpolation is clamped, extending the star and end color. But when extrapolation is enabled, a color stop on an endpoint essentially moves the start and end interpolation. And since there are no other colors on either end to interpolate with, extrapolation occurs.
>>> Color.interpolate([stop('red', 0.25), stop('blue', 0.75)])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107c2b490>
>>> Color.interpolate([stop('red', 0.25), stop('blue', 0.75)], extrapolate=True)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107fe3890>
Undefined/NaN Handling
Color spaces that have hue coordinates often have rules about when the hue is considered relevant. For instance, in the HSL color space, if saturation is zero, the hue is essentially powerless. This is because the color is "without color" or achromatic; therefore, the hue can have no affect on the actual color.
ColorAide will generally respect the values a user provides, so if an achromatic HSL color is given a hue of 270 degrees, ColorAide will accept it, but the hue will not affect the color in any meaningful way.
During conversions, such context is lost, and if an achromatic color is converted to the color space like HSL, the resultant color will have a hue that is noted as undefined. This is simply because there is no good hue for achromatic colors as they play no part in the color. Any hue is actually incorrect as achromatic colors have no real hue. Instead, colors will be returned with a value that represents that the hue is missing or undefined, or maybe better worded, could not be defined.
Many libraries, like d3-color, chroma.js, and color.js, represent null hues with NaN
(not a number). This is usually done to make color interpolation easier. Some, like d3-color, are a bit more liberal with NaN
and will target special cases that are above and beyond the normal rules to help ensure good interpolation. For instance, they not only mark hue undefined on HSL colors when saturation is zero, but they'll mark saturation as NaN
when lightness indicates "black" or "white".
ColorAide also uses NaN
, or in Python float('nan')
, to represent undefined channels. In certain situations, when a hue is deemed undefined, the hue value will be set to coloraide.NaN
, which is just a constant containing float('nan')
.
When performing linear interpolation, where only two color's channels are ever being evaluated together at a given time, if one color's channel has a NaN
, the other color's channel will be used as the result. If both colors have a NaN
for the same channel, then NaN
will be returned.
Continuous NaN Handling
NaN
handling is a bit different for the Continuous and Cubic Spline interpolation approaches. Linear only evaluates colors at a given time, while the others will take into consideration more than two colors. Because the context is much wider and more complicated, NaN
values will often get context from both sides.
Notice that in this example, because white's saturation is zero, the hue is undefined. Because the hue is undefined, when the color is mixed with a second color (green
), the hue of the second color is used.
>>> color = Color('white').convert('hsl')
>>> color[:-1]
[nan, 0.0, 1.0]
>>> color2 = Color('green').convert('hsl')
>>> color2[:-1]
[120.0, 1.0, 0.25098039215686274]
>>> color.mix(color2, space="hsl")
color(--hsl 120 0.5 0.62549 / 1)
But if we manually set the hue to 0
instead of NaN
, we can see that the mixing goes quite differently.
>>> color = Color('white').convert('hsl').set('hue', 0)
>>> color[:-1]
[0.0, 0.0, 1.0]
>>> color2 = Color('green').convert('hsl')
>>> color2[:-1]
[120.0, 1.0, 0.25098039215686274]
>>> color.mix(color2, space="hsl")
color(--hsl 60 0.5 0.62549 / 1)
Technically, any channel can be set to NaN
. And there are various ways to do this. The Color Manipulation documentation goes into the details of how these NaN
values naturally occur and the various ways a user and manipulate them.
Carrying-Forward
Experimental
This feature is provided to give parity with CSS behavior. As the spec is still in flux, behavior is subject to change or feature could be removed entirely. Use at your own risk.
CSS introduces the concept of carrying-forward undefined channels of like color spaces during conversion to the interpolating color space. The idea is to provide a sane handling to users who specified undefined channels for interpolation, but did not account for the conversion to the interpolating color space.
If a color has undefined channels, and is converting to a like color space, after conversion the new color will have the same undefined channels, assuming the channels support carrying-forward. The example below demonstrates the concept.
>>> rgb = Color('srgb', [0.5, NaN, 0.8])
>>> p3 = rgb.convert('display-p3').set('green', NaN)
>>> rgb, p3
(color(srgb 0.5 none 0.8 / 1), color(display-p3 0.45659 none 0.76952 / 1))
ColorAide, by default, expects the user to be aware that undefined values are lost if conversion is required for interpolation. This is mainly because the intent of the color can be changed during this process, but some users may find the automatic carrying-forward more convenient. For this reason, ColorAide has implemented carrying-forward as an optional feature via the carryforward
option.
In this example, interpolating without carrying-forward results in an interpolation between a purplish color and white. Using carrying-forward, we get a purplish color with an undefined green channel. The green channel takes on the white's green channel giving us an interpolation between a more greenish color and white.
>>> Color.interpolate(['color(srgb 0.5 none 0.8)', 'white'], space='display-p3')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107faf290>
>>> Color.interpolate(['color(srgb 0.5 none 0.8)', 'white'], space='display-p3', carryforward=True)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107fe0a10>
Depending on the color space, carrying-forward may have better or worse results.
The following table shows channel components supported for carryforward
. Spaces may use different names for their channels, but if they are derived from the related space classes, their channels are supported. For instance, xyz
is derived from RGBish
, so x
, y
, and z
is treated like super saturated r
, g
, and b
.
Space Type | Channel Equivalents |
---|---|
RGBish | r , g , b |
LABish | l |
LCHish | l , c , h |
HSLish | h , s , l |
HSVish | h , s , v |
Cylindrical | h |
Carrying-forward is applied within categories.
Category | Components |
---|---|
Reds | r |
Greens | g |
Blues | b |
Lightness | l |
Colorfulness | c , s |
Hue | h |
Opponent a | a |
Opponent b | b |
Value | v |
Powerless Hues
Experimental
This feature is provided to give parity with CSS behavior. As the spec is still in flux, behavior is subject to change or feature could be removed entirely. Use at your own risk.
Normally, ColorAide respects the user's explicitly defined hues. This gives the user power to do things like masking off all channels but the hue in order to interpolate only the hue channel.
>>> Color.interpolate(['oklch(none none 0)', 'oklch(0.75 0.2 360)'], space='oklch', hue='specified')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107fe2050>
But when doing this, a user must explicitly define the hue as achromatic if they want the hue to be ignored. Conversions of achromatic colors to a cylindrical space will, in most cases, have the hue automatically set to undefined.
>>> Color.interpolate(['oklch(1 0 0)', 'oklch(0.75 0.2 180)'], space='oklch')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107ff1750>
>>> Color.interpolate(['oklch(1 0 None)', 'oklch(0.75 0.2 180)'], space='oklch')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x108003a90>
CSS has the concept of powerless hues which causes explicitly defined hues to be powerless (or act as undefined) when a color is considered achromatic. This means a user never has to think about achromatic hues, so even if they erroneously define a hue, the hue will automatically be treated as undefined when interpolating. ColorAide implements this behavior via the powerless
option.
>>> Color.interpolate(['oklch(1 0 0)', 'oklch(0.75 0.2 180)'], space='oklch', powerless=True)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107c28610>
>>> Color.interpolate(['oklch(1 0 None)', 'oklch(0.75 0.2 180)'], space='oklch', powerless=True)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x108002fd0>
The one downside is that control over the hue will be diminished to some degree as ColorAide will no longer respect a user's explicit hue if the color is determined to be achromatic.
>>> Color.interpolate(['oklch(none none 0)', 'oklch(0.75 0.2 360)'], space='oklch', hue='specified')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x108000310>
>>> Color.interpolate(['oklch(none none 0)', 'oklch(0.75 0.2 360)'], space='oklch', hue='specified', powerless=True)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107fcdcd0>