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.68582 0.08547 0.31995 / 1), color(srgb 0.75585 0.16984 0.25944 / 1), color(srgb 0.89806 0.36096 0.22515 / 1), color(srgb 0.98212 0.636 0.18225 / 1), color(srgb 0.98896 0.84036 0.07636 / 1), color(srgb 0.78045 0.80098 0.15516 / 1), color(srgb 0.48919 0.65041 0.24401 / 1), color(srgb 0.24505 0.4497 0.34033 / 1), color(srgb 0.20956 0.3397 0.59235 / 1), color(srgb 0.26307 0.26283 0.53214 / 1), color(srgb 0.34787 0.16819 0.4047 / 1), color(srgb 0.52824 0.12714 0.33725 / 1)]
>>> Steps(Color.steps([red, yellow, blue, red], steps=13, method='spectral', out_space='srgb')[:-1])
[color(srgb 0.68582 0.08547 0.31995 / 1), color(srgb 0.75585 0.16984 0.25944 / 1), color(srgb 0.89806 0.36096 0.22515 / 1), color(srgb 0.98212 0.636 0.18225 / 1), color(srgb 0.98896 0.84036 0.07636 / 1), color(srgb 0.78045 0.80098 0.15516 / 1), color(srgb 0.48919 0.65041 0.24401 / 1), color(srgb 0.24505 0.4497 0.34033 / 1), color(srgb 0.20956 0.3397 0.59235 / 1), color(srgb 0.26307 0.26283 0.53214 / 1), color(srgb 0.34787 0.16819 0.4047 / 1), color(srgb 0.52824 0.12714 0.33725 / 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 0x7f59453b3bf0>
>>> Steps(Color.steps([c1, c2], method='spectral', steps=9))
[color(xyz-d65 0.04777 0.02781 0.22476 / 1), color(xyz-d65 0.02667 0.03298 0.0951 / 1), color(xyz-d65 0.03708 0.06387 0.07248 / 1), color(xyz-d65 0.07117 0.12699 0.07519 / 1), color(xyz-d65 0.13265 0.22305 0.08176 / 1), color(xyz-d65 0.22548 0.34363 0.08795 / 1), color(xyz-d65 0.34991 0.47284 0.09246 / 1), color(xyz-d65 0.49784 0.59012 0.09493 / 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 0x7f594532a510>
>>> 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 cyan, magenta, yellow, red, green, and blue reflectance curves and then reconstructed into its own curve.
With the ability to represent almost 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. Mixing a blue and yellow color with Kubelka-Munk theory.
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
rgb(255 255 255)
was not needed and sufficient coverage can be obtained without it. The white reflectance curve also exceeded the range appropriate for the Kubelka-Munk functions, and additional logic is present to compensate for this. -
During decomposition of colors we constrain concentrations to be between 0 and 1. We also constrain the final composite reflectance curve to be between a very small value and 1 as the Kubelka-Munk functions expect reflectance to not be zero and not exceed 1.
This trimming of the concentrations and the reflectance curve means some out-of-gamut colors can be attenuated. To better handle these colors, and even some colors in the sRGB gamut that can't quite be covered, once we've decomposed a color to a reflectance curve, we convert it to XYZ and get the difference between it and the original and save the residual. The identified residual XYZ values will be mixed separately using normal normal linear interpolation. These residuals are then added at the end to the reflectance curves that were mixed using Kubelka-Munk theory. 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 0x7f59441a6360> >>> Steps(Color.steps([c1, c2], method='spectral', steps=9)) [color(xyz-d65 0.19822 0.07929 1.0439 / 1), color(xyz-d65 0.06051 0.0615 0.29103 / 1), color(xyz-d65 0.05131 0.07666 0.17683 / 1), color(xyz-d65 0.07157 0.12065 0.13913 / 1), color(xyz-d65 0.12297 0.20065 0.11708 / 1), color(xyz-d65 0.21721 0.32545 0.09921 / 1), color(xyz-d65 0.36095 0.49724 0.08216 / 1), color(xyz-d65 0.54818 0.70664 0.06461 / 1), color(xyz-d65 0.75224 0.92071 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())