1.0 Migration Notes
ColorAide has been a constantly evolving project. As we've pushed for a stable release, the 1.0 milestone was no exception. For any of the early adopters, there are a number of things to be aware of when migrating to 1.0. In this guide, we'll cover the changes most likely to impact users.
Plugins
Plugins have gone through a number of reworks. In 1.0, we now require all plugins to be registered as instances. Prior to 1.0 plugins were just passed in un-instantiated. Some plugins were used as static classes almost, and some (color spaces) were instantiated on the creation of every color.
In 1.0, all plugins are required to be instantiated prior to registration. This gives the user the opportunity to specify any alternative defaults if desired.
>>> Color('red').delta_e('blue', method='cmc')
108.56925233888809
>>> from coloraide.distance.delta_e_cmc import DECMC
>>> class Custom(Color):
... DELTA_E = "cmc"
...
>>> Custom.register(DECMC(l=1, c=1), overwrite=True)
>>> Custom('red').delta_e('blue')
109.7597278634398
Plugin Renames
ColorAide had some inconsistencies when it came to some plugin names. For instance, we would use variations of Lch
, LCH
, etc. This made it more difficult to predict how a plugin was named, and remember it. In this case appropriate casing is usually LCh
. All plugins referring to LCh
were renamed to be more consistent.
Outside of LCh related classes, there were a few additional renames to better match the color space of interest's real name.
Old Name | New Name |
---|---|
Lch | LCh |
LchD65 | LChD65 |
Oklch | OkLCh |
Lchuv | LChuv |
Lch99o | LCh99o |
LchChroma | LChChroma |
OklchChroma | OkLChchroma |
Lchish | LChish |
XyY | xyY |
Din99o | DIN99o |
SRGB | sRGB |
SRGBLinear | sRGBLinear |
ORGB | oRGB |
Default Plugins
Over the course of development, we've added a good number of color spaces. With the 1.0 release, it was decided to have the default Color
object not register all available color spaces out of the box as the amount of color spaces has grown quite substantially.
As not all color spaces are registered by default, ∆E plugins tied to color spaces no longer registered by default will also not be registered by default.
Lastly, since the bradford
CAT is the default, it was deemed unnecessary to register all the other CAT plugins by default.
With all of that said, all of the exiting plugins are still available and can be included if/when needed. If any of the plugins that are no longer registered by default are needed, there are a couple of options:
-
The recommended way is to just subclass the
Color
object and cherry pick the plugins that are needed. When classing, all the plugins registered in the base will be copied over to the derived class. Then we can just pass in the instantiated plugins.>>> from coloraide import Color as Base >>> from coloraide.spaces.jzazbz import Jzazbz >>> from coloraide.distance.delta_e_z import DEZ >>> class Color(Base): ... ... >>> Color.register([Jzazbz(), DEZ()], silent=True) >>> Color('red').convert('jzazbz') color(jzazbz 0.13438 0.11789 0.11188 / 1) >>> Color('red').delta_e('blue', method='jz') 0.33960388420164006
-
We also provide a new color object derived from
Color
that includes all color spaces calledColorAll
. This object won't be as light, but will provide quick and easy access to everything ColorAide offers. By default, it registers every plugin. It can be found undercoloraide.everything
.>>> from coloraide.everything import ColorAll as Color >>> Color('purple').convert('hunter-lab') color(--hunter-lab 24.796 50.842 -35.444 / 1)
Dynamic Properties/Functions and Coordinate Access
Prior to 1.0, ColorAide's Color
object had color channel properties that would magically mutate based on what the current color space was that the object currently held. While cool, this added overhead to every class attribute access. In an effort to dramatically reduce unnecessary overhead, this feature had to be rethought.
Additionally, ∆E methods were also added dynamically. For instance, if we had the ∆E 2000 distancing plugin registered, we'd have access to ∆E via Color.delta_e_2000
or Color.delta_e(color, method='2000')
. Again, the overhead to magically provide these properties the way we were posed the same problem as what was seen with dynamic color channel properties
Additionally, the Color
object used to have a coords()
function to get all the non-alpha color channels. This function was not really problematic, but as we decided a solution for the dynamic properties, it became apparent that we would no longer need such a function.
1.0 removed the overhead of magic dynamic properties and functions. In particular, we decided to approach color channel access in a new and different way.
Moving forward, the Color
object is now iterable and indexable. Channels can be directly indexed via channel names or numerical indexes. You can even use slices:
>>> color = Color('orange')
>>> color
color(srgb 1 0.64706 0 / 1)
>>> color['blue']
0.0
>>> color[0]
1.0
>>> color[:-1]
[1.0, 0.6470588235294118, 0.0]
Color
objects are also iterable, so you can just loop them as well, or cast the object as a list.
>>> for channel in Color('orange'):
... print(channel)
...
1.0
0.6470588235294118
0.0
1.0
>>> list(Color('green'))
[0.0, 0.5019607843137255, 0.0, 1.0]
Setting channels is just as easy and can be done by indexing channels with names, numerical indexes, or even slices.
>>> color = Color('transparent')
>>> color[:] = [1, 0, 0, 0.5]
>>> color
color(srgb 1 0 0 / 0.5)
As far as ∆E methods are concerned, we already had two different ways to approach this, so we simply removed the dynamic functions. To access any of the different ∆E methods, simply call the generic delta_e
function and provide the method
.
>>> Color('red').delta_e('green', method='2000')
72.18053591241998
Gamut Mapping and Clipping
During our path to 1.0, we noticed that when performing gamut mapping and clipping, in most cases, we were performing them "in place" instead of the default which generated new Color
instances. There are times when we occasionally wanted a new instance of the color when fitting a color to its gamut, but that turned out to not be the norm.
Generating new instances obviously will create more overhead, and in some cases, such as color mixing, returning a new color opposed to mutating the existing one makes a lot more sense, but with gamut mapping and clipping, for efficiency, we were often forcing "in place" operations.
1.0 now does gamut mapping and clipping in place by default. With this change, the in_place
parameter is not longer available for fit()
and clip()
.
So, if migrating to 1.0, if you were calling fit()
and clip()
directly, a few changes will need to be made. If you'd like to do an in place gamut correction, simply call the function. If you'd like to generate a new instance, clone the color first.
>>> color1 = Color('display-p3', [1, 1, 0])
>>> color1.fit('srgb')
color(display-p3 0.9986 0.99232 0.32855 / 1)
>>> color1
color(display-p3 0.9986 0.99232 0.32855 / 1)
>>> color2 = Color('display-p3', [0, 1, 0])
>>> color3 = color2.clone().fit('srgb')
>>> color2, color3
(color(display-p3 0 1 0 / 1), color(display-p3 0.45754 0.98353 0.2977 / 1))
Dictionary Output
The dictionary format for input and output as been simplified for the 1.0 release. Prior to 1.0, the Color
object used to export color dictionaries with the space name and each channel under an individually named key:
{'space': 'srgb', 'r': 1, 'g': 0, 'b': 0, 'alpha': 1}
This required more overhead, particularly when parsing to handle channel alias and the like. For 1.0, we've streamlined the format to export the data with all color coordinates under coords
and the alpha channel still under alpha
. This makes streamlines the process of handling dictionaries as inputs and outputting them when requested, in turn, improving performance.
>>> d = Color('rebeccapurple').to_dict()
>>> d
{'space': 'srgb', 'coords': [0.4, 0.2, 0.6], 'alpha': 1.0}
>>> Color(d)
color(srgb 0.4 0.2 0.6 / 1)
Interpolation
Interpolation was an area we were generally unhappy with, so it was majorly overhauled.
Prior to 1.0, interpolation could be a bit awkward. Interpolation used to require the first color in the interpolation to be the calling object, and all the rest had to be fed in.
Color('red').interpolate(['blue', 'green', 'orange'])
When performing a simple mix, this felt natural and made sense:
Color('red').mix('blue', 0.25)
But with long chains of colors, this just felt cumbersome. To remedy this, we changed the interpolate
and steps
methods to @classmethods
. We left mix
as is since with two colors it feels natural.
So moving forward, interpolate
and steps
will execute interpolations from class methods.
>>> Color.interpolate(['red', 'blue', 'green', 'orange'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x10828d4f0>
>>> Color.steps(['red', 'blue', 'green', 'orange'], steps=10)
[color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.56931 0.13909 -0.01995 / 1), color(--oklab 0.51066 0.05332 -0.16574 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.47459 -0.06841 -0.17179 / 1), color(--oklab 0.49717 -0.10435 -0.03206 / 1), color(--oklab 0.51975 -0.1403 0.10768 / 1), color(--oklab 0.61073 -0.07466 0.12558 / 1), color(--oklab 0.70171 -0.00903 0.14348 / 1), color(--oklab 0.79269 0.05661 0.16138 / 1)]
This means that you do not have to call the function from an instantiated object, and if you do, the instantiated color that is making the call will not be included in the interpolation. Only the colors in the list are considered during the interpolation.
>>> Color('white').interpolate(['red', 'blue', 'green', 'orange'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x10828db80>
This will make even more sense as we highlight the other changes.
Another problem we faced was the awkwardness of color stops and easing functions. Before we used to have a Piecewise
object that you'd wrap a channel in to create color stops or inject easing functions and other various behaviors between colors, but it had to be applied on the second color in the chain, and this didn't quite work for the first color. If you wanted to add a stop to the first color, you then had to use a special stop
parameter…it was unintuitive.
from coloraide import Piecewise
Color('red').interpolate(['blue', Piecewise('orange', 0.75, progress=lambda t: t * 3), 'purple'], stop=0.25)
In 1.0, we simplified things greatly. Since interpolate
and steps
now require that all colors must be in the input list if they are to be considered for interpolation, we can process them all in a consistent and more intuitive manner.
As before, steps
and interpolate
allow you to set function parameters to generally control the behavior for the entire interpolation across all colors. You can also still add easing functions via progress
which will also affect the entire interpolation by default, but now you can inject easing functions directly between colors which will only be applied between those two colors.
>>> Color.interpolate(['red', lambda t: t * 3, 'orange', 'purple'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x10828db80>
You can also directly wrap any color in the list with stop
to change the color stop position. Since the first color is now treated like all the other colors, there is no need for the stop
function parameter either.
>>> from coloraide import stop
>>> Color.interpolate([stop('red', 0.25), stop('orange', 0.75), 'purple'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x10828db80>
And if you are familiar with CSS color hinting, which essentially alters the midpoint between two color stops, we've added a hint
function which takes a new relative midpoint and returns a midpoint easing function which essentially acts the same as CSS interpolation hints.
>>> from coloraide import hint
>>> Color.interpolate(['yellow', 'pink'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x10828d4f0>
>>> Color.interpolate(['yellow', hint(0.25), 'pink'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x10828db80>
All of this makes for a less confusing experience when using interpolation. Additionally, all of the changes simplified the logic allowing us to even add a new interpolation method!
>>> Color.interpolate(['red', 'blue', 'green', 'orange'], method='bspline')
<coloraide.interpolate.bspline.InterpolatorBSpline object at 0x108101480>
Color Space Filters
In the beginning, the Color
space object was created with a naive filtering system. It added a little overhead, but the real issue was the fact that it only filtered inputs through new
, match
, and through normal instantiation. It did not filter through almost any other method that accepted inputs. It was decided to leave color filtering up to the user.
>>> c = Color('display-p3', [1, 1, 0])
>>> try:
... if c.space() not in ['srgb', 'hsl', 'hwb']:
... raise ValueError('Invalid Color Space')
... except ValueError as e:
... print(e)
...
Invalid Color Space