Skip to content

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 0x7f423d16d7f0>
>>> 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 0x7f423f669160>
>>> 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.

Decomposition of Color Reflectance Concentrations

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.

Reflectance Mix

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.

  1. 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.

  2. 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 including rgb(255 255 255) provided no significant improvements as the other primary colors provide sufficient coverage with comparable results.

  3. 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. We compensate for this by calculating the residual (the difference between the expected XYZ value and the recreated XYZ value) and mixing it separately and then adding the results back into the final result. This allows us to reasonably represent colors that may exceed the actual gamut that the primary reflectance curves can actually reproduce and even colors that exceed the sRGB gamut entirely, though colors within the sRGB gamut should be considered to have more accurate Kubelka-Munk mixing.

    Our approach differs from Spectral.js which does not clamp the high end values and does not utilize residuals which causes inaccuracies in round tripping of colors through the Kubelka-Munk functions if the color's reflectance curve has values that exceed 1. Better stated, the Spectral.js approach can process most of the colors in the sRGB gamut accurately, but not all. It should be noted though that Spectral.js clamps their final results to the course resolution of sRGB hexadecimal values, and because of they are limited to the sRGB gamut only, and to such a low resolution, inaccuracies are not easily observable. Since we allow for the expectation of great precision and larger gamuts, we cannot get away with the same approach and require some mitigation.

  4. Spectral.js generally clips the mixed colors to sRGB hexadecimal resolution 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. We also do not force hexadecimal resolution. We let the user chose how they will gamut map their colors and to what resolution they wish to round the their values to. This means we allow colors that exceed the sRGB gamut if that is desired. In short, users are free to clip the returned colors or gamut map them in any way they see fit.

    >>> 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 0x7f423d112e00>
    >>> 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)]
    
  5. Lastly, Spectral.js has the concept of "tinting strength". It is essentially parameter to manually adjust the weight of a color when mixing. It is a fiddly way to subjectively adjust the interpolation between individual colors. We do not implement this and all interpolations essentially perform as if the "tinting strength" is set to 1 which causes this variable to drop out.

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())