Spectral Interpolation
Description
Light, on its own, doesn't mix like pigments due to the way pigments absorb and scatter light. Kubelka-Munk theory is a fundamental approach to modelling the appearance of paint films and predicting this absorption and scattering. Utilizing Kubelka-Munk theory, colors can be simulated to mix more like paints.
>>> red = Color('rgb(128, 2, 46)').mix('white', 0.25, method='spectral')
>>> yellow = Color('rgb(252, 211, 0)').mix('white', 0.25, method='spectral')
>>> blue = Color('rgb(13, 27, 68)').mix('white', 0.25, method='spectral')
>>> Wheel(Color.steps([red, yellow, blue, red], steps=13, method='spectral', out_space='srgb')[:-1])
[color(srgb 0.68459 0.08735 0.31955 / 1), color(srgb 0.7543 0.17036 0.26065 / 1), color(srgb 0.8936 0.36522 0.22245 / 1), color(srgb 0.97449 0.64166 0.17958 / 1), color(srgb 0.98697 0.84035 0.07272 / 1), color(srgb 0.78095 0.80101 0.15149 / 1), color(srgb 0.49557 0.64967 0.23953 / 1), color(srgb 0.25206 0.4481 0.33743 / 1), color(srgb 0.21092 0.33944 0.59221 / 1), color(srgb 0.26255 0.26619 0.53197 / 1), color(srgb 0.3538 0.16982 0.4043 / 1), color(srgb 0.53734 0.12637 0.3366 / 1)]
>>> Steps(Color.steps([red, yellow, blue, red], steps=13, method='spectral', out_space='srgb')[:-1])
[color(srgb 0.68459 0.08735 0.31955 / 1), color(srgb 0.7543 0.17036 0.26065 / 1), color(srgb 0.8936 0.36522 0.22245 / 1), color(srgb 0.97449 0.64166 0.17958 / 1), color(srgb 0.98697 0.84035 0.07272 / 1), color(srgb 0.78095 0.80101 0.15149 / 1), color(srgb 0.49557 0.64967 0.23953 / 1), color(srgb 0.25206 0.4481 0.33743 / 1), color(srgb 0.21092 0.33944 0.59221 / 1), color(srgb 0.26255 0.26619 0.53197 / 1), color(srgb 0.3538 0.16982 0.4043 / 1), color(srgb 0.53734 0.12637 0.3366 / 1)]
The "spectral" interpolation method is based on Kubelka-Munk theory and, more specifically, follows after the approach implemented in the Spectral.js project. Spectral.js approximates paint mixing by using spectral data to generate reflectance curves and uses them to mix colors by applying Kubelka-Munk theory. This approach is also based off the work that was done during the development of another project, Mixbox. More specifically, it is based on the paper that the Mixbox folks published.
While Mixbox uses real paint data and tries to model these paints as close as it can, the "spectral" approach tries more to capture the feel of mixing paints without specifically basing it off real paint data.
>>> c1 = Color('#002185')
>>> c2 = Color('#FCD200')
>>> Color.interpolate([c1, c2], method='spectral')
<coloraide_extras.interpolate.spectral.InterpolatorSpectralLinear object at 0x1072516d0>
>>> Steps(Color.steps([c1, c2], method='spectral', steps=9))
[color(xyz-d65 0.04777 0.02781 0.22476 / 1), color(xyz-d65 0.02702 0.03256 0.0952 / 1), color(xyz-d65 0.03708 0.06349 0.07065 / 1), color(xyz-d65 0.07141 0.12697 0.07348 / 1), color(xyz-d65 0.13374 0.22374 0.08054 / 1), color(xyz-d65 0.22802 0.34531 0.08724 / 1), color(xyz-d65 0.35287 0.47483 0.09216 / 1), color(xyz-d65 0.49749 0.59023 0.09487 / 1), color(xyz-d65 0.6319 0.6679 0.09564 / 1)]
>>> c1 = Color('#002185')
>>> c2 = Color('#FCD200')
>>> Color.interpolate([c1, c2], space='srgb')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x107164ec0>
>>> Steps(Color.steps([c1, c2], space='srgb', steps=9))
[color(srgb 0 0.12941 0.52157 / 1), color(srgb 0.12353 0.21618 0.45637 / 1), color(srgb 0.24706 0.30294 0.39118 / 1), color(srgb 0.37059 0.38971 0.32598 / 1), color(srgb 0.49412 0.47647 0.26078 / 1), color(srgb 0.61765 0.56324 0.19559 / 1), color(srgb 0.74118 0.65 0.13039 / 1), color(srgb 0.86471 0.73676 0.0652 / 1), color(srgb 0.98824 0.82353 0 / 1)]
How It Works
The idea is simple enough. Create a palette of primary colors from which you can mix and get all the colors within your desired gamut, which in our case is sRGB. Once the colors are selected, reflectance curves need to be generated for those primary colors. There are various ways in which such curves could be created, but the chosen approach that was settled on involves applying applying the research of Scott Burns. His research details a way to use spectral data to approximate reflectance curves for any color within the sRGB gamut.
With our primary colors selected and the reflectance curves created for each one, we can use these curves to create any color within our gamut. More interestingly, we can take a color and deconstruct it into concentrations of these primary reflectance curves and then construct a new curve that represents the color.
Figure 1. Orange decomposed into the red, green, blue reflectance curves and then reconstructed into its own curve.
With the ability to represent any color within our gamut as a reflectance curve, we then can mix colors by identifying what the curves are and then applying Kubelka-Munk theory, converting those curves into absorption and scattering data and mixing them. Once mixed, we can transform them back to a reflectance curve and then back to our target color space.
Figure 2. Combining a blue and red color to and getting green.
Kubelka-Munk theory can be used in a couple of ways, one that utilizes absorption and scattering data independent of each other, which can be referred to as the two-constant approach, and one that treats the absorption and scattering as a single constant, which we will call the single-constant approach. Generally, for paint, the two-constant approach is probably more accurate, but since we generate the reflectance curves without knowing specifically what the absorption vs scattering properties are, especially since this is not based off real paint data, the "spectral" mixing uses the single-constant approach.
Lastly, because the single-constant approach we are using produces colors a bit more darkly, Spectral.js applies an easing function to the interpolation progress that favors the more dominant luminance when mixing, biasing the color more towards the color with more intense luminance. This is applied to give a more aesthetically pleasing mix that appears more like what you wound have when using something like Mixbox.
Differences
It should be noted that we do deviate a bit from the Spectral.js implementation. As we explored this approach we found a few things that we found to be unnecessary, things we could improve upon, or just things we approached slightly different.
-
Following the approach outlined by Scott Burns, we regenerated all the data at higher precision and ensured that it was done with the same transformation matrices and white points that we use within our library. This was done just to ensure we have more precise transforms within our library.
-
Spectral.js uses primary colors of:
rgb(255 255 255)
rgb(255 0 0)
rgb(0 255 0)
rgb(0 0 255)
rgb(0 255 255)
rgb(255 0 255)
rgb(255 255 0)
During our evaluation, we found that only the following were needed.
rgb(255 0 0)
rgb(0 255 0)
rgb(0 0 255)
These three colors are sufficient to cover the entire gamut. The additional colors used by Spectral.js seem to be unnecessary and provided no noticeable improvements, at least as observed during our tests.
-
Since we are just using R, G, and B, decomposition to concentrations is the literal translation of XYZ to linear sRGB, though we must constrained the concentrations to be between 0 and 1. This trimming of the concentrations can attenuate the intensity of out-of-gamut colors, but we've also added a solution to compensate for this later.
-
To better handle colors outside the sRGB gamut, once we've decomposed the out-of-gamut color to a reflectance curve, we convert it to XYZ and get the difference between it and the original and save this residual. Residuals occur when a color can't quite be represented with our primary colors. The identified residual XYZ values will be mixed separately from the reflectance curves and then added in at the end. This approach is very similar to what Mixbox describes in their paper and helps to provide more sane color mixing for colors outside the sRGB gamut.
-
Spectral.js generally clips the mixed colors before returning them. We do not clip any colors that are out-of-gamut due to mixing in case the user is within a gamut that can accommodate them. Additionally, we allow colors outside of sRGB to be mixed as well.
>>> c1 = Color('color(display-p3 0 0 1)') >>> c2 = Color('color(display-p3 1 1 0)') >>> Color.interpolate([c1, c2], method='spectral') <coloraide_extras.interpolate.spectral.InterpolatorSpectralLinear object at 0x107255150> >>> Steps(Color.steps([c1, c2], method='spectral', steps=9)) [color(xyz-d65 0.19822 0.07929 1.0439 / 1), color(xyz-d65 0.05913 0.05924 0.27848 / 1), color(xyz-d65 0.04009 0.07143 0.11446 / 1), color(xyz-d65 0.05604 0.1143 0.05516 / 1), color(xyz-d65 0.10782 0.19497 0.03586 / 1), color(xyz-d65 0.20468 0.32142 0.03274 / 1), color(xyz-d65 0.35183 0.49531 0.03585 / 1), color(xyz-d65 0.54204 0.70661 0.04088 / 1), color(xyz-d65 0.73354 0.91145 0.04511 / 1)]
Users are free to clip the returned colors or gamut map them in any way they see fit.
Registering
Spectral mixing comes in two flavors, one which operations in normal piecewise linear, the other which uses the "continuous" approach when handling undefined channels.
from coloraide import Color as Base
from coloraide_extras.interpolate.spectral import Spectral, SpectralContinuous
class Color(Base): ...
Color.register(Spectral())
Color.register(SpectralContinuous())