Pigment / Spectral / Kubelka-Munk Color Mixing

Hi all,

I ported a JavaScript library called spectral.js by Ronald van Wijnen to Lua under the MIT license. The library’s aim is to more closely simulate how color mixes when working in paint based on theory developed by Kubelka and Munk.

It should go without saying, but a “painterly look” depends on more than color alone, so best case scenario this is addressing only one aspect of the look.

I didn’t feel like maintaining another repo for an Aseprite dialog wrapper, so I’m posting the Lua here, with a demo Aseprite-specific script below. People can add features, or research further, if interested.

Core Logic
-- MIT License
--
-- Copyright (c) 2023 Ronald van Wijnen
--
-- Permission is hereby granted, free of charge, to any person obtaining a
-- copy of this software and associated documentation files (the "Software"),
-- to deal in the Software without restriction, including without limitation
-- the rights to use, copy, modify, merge, publish, distribute, sublicense,
-- and/or sell copies of the Software, and to permit persons to whom the
-- Software is furnished to do so, subject to the following conditions:
--
-- The above copyright notice and this permission notice shall be included in
-- all copies or substantial portions of the Software.
--
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-- FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-- DEALINGS IN THE SOFTWARE.

spectral = {}
spectral.__index = spectral

function spectral.new()
    local inst = setmetatable({}, spectral)
    return inst
end

spectral.SIZE = 32
spectral.GAMMA = 2.4
spectral.GAMMA_INV = 1.0 / 2.4
spectral.EPSILON = 0.00000001

---@type number[]
spectral.CIE_CMF_X = {
    0.00006469, 0.00021941, 0.00112057, 0.00376661, 0.01188055,
    0.02328644, 0.03455942, 0.03722379, 0.03241838,
    0.02123321, 0.01049099, 0.00329584, 0.00050704, 0.00094867,
    0.00627372, 0.01686462, 0.02868965, 0.04267481, 0.05625475,
    0.0694704, 0.08305315, 0.0861261, 0.09046614, 0.08500387,
    0.07090667, 0.05062889, 0.03547396, 0.02146821,
    0.01251646, 0.00680458, 0.00346457, 0.00149761, 0.0007697,
    0.00040737, 0.00016901, 0.00009522, 0.00004903, 0.00002
}

---@type number[]
spectral.CIE_CMF_Y = {
    0.00000184, 0.00000621, 0.00003101, 0.00010475, 0.00035364,
    0.00095147, 0.00228226, 0.00420733, 0.0066888, 0.0098884,
    0.01524945, 0.02141831, 0.03342293, 0.05131001,
    0.07040208, 0.08783871, 0.09424905, 0.09795667, 0.09415219,
    0.08678102, 0.07885653, 0.0635267, 0.05374142,
    0.04264606, 0.03161735, 0.02088521, 0.01386011, 0.00810264,
    0.0046301, 0.00249138, 0.0012593, 0.00054165, 0.00027795,
    0.00014711, 0.00006103, 0.00003439, 0.00001771, 0.00000722
}

---@type number[]
spectral.CIE_CMF_Z = {
    0.00030502, 0.00103681, 0.00531314, 0.01795439, 0.05707758,
    0.11365162, 0.17335873, 0.19620658, 0.18608237,
    0.13995048, 0.08917453, 0.04789621, 0.02814563, 0.01613766,
    0.0077591, 0.00429615, 0.00200551, 0.00086147, 0.00036904,
    0.00019143, 0.00014956, 0.00009231, 0.00006813,
    0.00002883, 0.00001577, 0.00000394, 0.00000158,
    0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0
}

---@type number[]
spectral.SPD_C = {
    0.96853629, 0.96855103, 0.96859338, 0.96877345, 0.96942204,
    0.97143709, 0.97541862, 0.98074186, 0.98580992,
    0.98971194, 0.99238027, 0.99409844, 0.995172, 0.99576545,
    0.99593552, 0.99564041, 0.99464769, 0.99229579, 0.98638762,
    0.96829712, 0.89228016, 0.53740239, 0.15360445,
    0.05705719, 0.03126539, 0.02205445, 0.01802271, 0.0161346,
    0.01520947, 0.01475977, 0.01454263, 0.01444459, 0.01439897,
    0.0143762, 0.01436343, 0.01435687, 0.0143537, 0.01435408
}

---@type number[]
spectral.SPD_M = {
    0.51567122, 0.5401552, 0.62645502, 0.75595012, 0.92826996,
    0.97223624, 0.98616174, 0.98955255, 0.98676237,
    0.97312575, 0.91944277, 0.32564851, 0.13820628, 0.05015143,
    0.02912336, 0.02421691, 0.02660696, 0.03407586, 0.04835936,
    0.0001172, 0.00008554, 0.85267882, 0.93188793,
    0.94810268, 0.94200977, 0.91478045, 0.87065445, 0.78827548,
    0.65738359, 0.59909403, 0.56817268, 0.54031997, 0.52110241,
    0.51041094, 0.50526577, 0.5025508, 0.50126452, 0.50083021
}

---@type number[]
spectral.SPD_Y = {
    0.02055257, 0.02059936, 0.02062723, 0.02073387, 0.02114202,
    0.02233154, 0.02556857, 0.03330189, 0.05185294,
    0.10087639, 0.24000413, 0.53589066, 0.79874659, 0.91186529,
    0.95399623, 0.97137099, 0.97939505, 0.98345207, 0.98553736,
    0.98648905, 0.98674535, 0.98657555, 0.98611877,
    0.98559942, 0.98507063, 0.98460039, 0.98425301, 0.98403909,
    0.98388535, 0.98376116, 0.98368246, 0.98365023, 0.98361309,
    0.98357259, 0.98353856, 0.98351247, 0.98350101, 0.98350852
}

---@type number[]
spectral.SPD_R = {
    0.03147571, 0.03146636, 0.03140624, 0.03119611, 0.03053888,
    0.02856855, 0.02459485, 0.0192952, 0.01423112,
    0.01033111, 0.00765876, 0.00593693, 0.00485616, 0.00426186,
    0.00409039, 0.00438375, 0.00537525, 0.00772962, 0.0136612,
    0.03181352, 0.10791525, 0.46249516, 0.84604333,
    0.94275572, 0.96860996, 0.97783966, 0.98187757, 0.98377315,
    0.98470202, 0.98515481, 0.98537114, 0.98546685, 0.98550011,
    0.98551031, 0.98550741, 0.98551323, 0.98551563, 0.98551547
}

---@type number[]
spectral.SPD_G = {
    0.49108579, 0.46944057, 0.4016578, 0.2449042, 0.0682688,
    0.02732883, 0.013606, 0.01000187, 0.01284127, 0.02636635,
    0.07058713, 0.70421692, 0.85473994, 0.95081565, 0.9717037,
    0.97651888, 0.97429245, 0.97012917, 0.9425863, 0.99989207,
    0.99989891, 0.13823139, 0.06968113, 0.05628787,
    0.06111561, 0.08987709, 0.13656016, 0.22169624, 0.32176956,
    0.36157329, 0.4836192, 0.46488579, 0.47440306, 0.4857699,
    0.49267971, 0.49625685, 0.49807754, 0.49889859
}

---@type number[]
spectral.SPD_B = {
    0.97901834, 0.97901649, 0.97901118, 0.97892146, 0.97858555,
    0.97743705, 0.97428075, 0.96663223, 0.94822893,
    0.89937713, 0.76070164, 0.4642044, 0.20123039, 0.08808402,
    0.04592894, 0.02860373, 0.02060067, 0.01656701, 0.01451549,
    0.01357964, 0.01331243, 0.01347661, 0.01387181,
    0.01435472, 0.01479836, 0.0151525, 0.01540513, 0.01557233,
    0.0156571, 0.01571025, 0.01571916, 0.01572133, 0.01572502,
    0.01571717, 0.01571905, 0.01571059, 0.01569728, 0.0157002
}

---@type number[][]
spectral.XYZ_RGB = {
    { 3.24306333,  -1.53837619, -0.49893282 },
    { -0.96896309, 1.87542451,  0.04154303 },
    { 0.05568392,  -0.20417438, 1.05799454 }
}

---Clamps a number between a lower and upper bound.
---@param v number value
---@param mn number minimum
---@param mx number maximum
---@return number
function spectral.clamp(v, mn, mx)
    return math.min(math.max(v, mn), mx)
end

---Converts a color channel from linear sRGB
---to standard RGB. x is expected to be in [0.0, 1.0].
---@param x number channel
---@return number
function spectral.compand(x)
    if x < 0.0031308 then return x * 12.92 end
    return 1.055 * x ^ spectral.GAMMA_INV - 0.055
end

---Finds the dot product of number arrays a and b,
---or the sum of the product of each element in
---a with that in b.
---@param a number[] a
---@param b number[] b
---@return number
function spectral.dot_product(a, b)
    local aLen = #a
    local bLen = #b
    local mnLen = math.min(aLen, bLen)
    local sum = 0
    local i = 0
    while i < mnLen do
        i = i + 1
        sum = sum + a[i] * b[i]
    end
    return sum
end

---
---@param l1 number l1
---@param l2 number l2
---@param t number t
---@return number
function spectral.linear_to_concentration(l1, l2, t)
    local t1 = l1 * (1 - t) ^ 2
    local t2 = l2 * t ^ 2

    return t2 / (t1 + t2)
end

---
---@param lrgb number[] lrgb table
---@return number[]
function spectral.linear_to_reflectance(lrgb)
    local weights = spectral.spectral_upsampling(lrgb)
    local R = {}

    local i = 0
    while i < spectral.SIZE do
        i = i + 1
        R[i] = math.max(
            spectral.EPSILON,
            weights[1]
            + weights[2] * spectral.SPD_C[i]
            + weights[3] * spectral.SPD_M[i]
            + weights[4] * spectral.SPD_Y[i]
            + weights[5] * spectral.SPD_R[i]
            + weights[6] * spectral.SPD_G[i]
            + weights[7] * spectral.SPD_B[i])
    end

    return R
end

---Linear table is expected to be in [0.0, 1.0]. Return
---table is in [0, 255].
---@param lrgb number[] lrgb table
---@return integer[]
function spectral.linear_to_srgb(lrgb)
    local r = spectral.compand(lrgb[1])
    local g = spectral.compand(lrgb[2])
    local b = spectral.compand(lrgb[3])

    return {
        math.floor(spectral.clamp(r, 0.0, 1.0) * 255 + 0.5),
        math.floor(spectral.clamp(g, 0.0, 1.0) * 255 + 0.5),
        math.floor(spectral.clamp(b, 0.0, 1.0) * 255 + 0.5)
    }
end

---Mixes an origin sRGB table to a destination
---according to a factor. Expects tables to be
---in the range [0, 255].
---@param srgb1 integer[] origin
---@param srgb2 integer[] destination
---@param t number factor
---@return integer[]
function spectral.mix(srgb1, srgb2, t)
    local lrgb1 = spectral.srgb_to_linear(srgb1)
    local lrgb2 = spectral.srgb_to_linear(srgb2)

    local R1 = spectral.linear_to_reflectance(lrgb1)
    local R2 = spectral.linear_to_reflectance(lrgb2)

    local l1 = spectral.dot_product(R1, spectral.CIE_CMF_Y)
    local l2 = spectral.dot_product(R2, spectral.CIE_CMF_Y)

    local t2 = spectral.linear_to_concentration(l1, l2, t)

    local R = {}
    local i = 0
    while i < spectral.SIZE do
        i = i + 1

        local KS = (1 - t2) * ((1 - R1[i]) ^ 2 / (2 * R1[i]))
            + t2 * ((1 - R2[i]) ^ 2 / (2 * R2[i]))
        local KM = 1 + KS - math.sqrt(KS ^ 2 + 2 * KS)

        -- Saunderson correction
        -- S = ((1.0 - K1) * (1.0 - K2) * KM) / (1.0 - K2 * KM)
        R[i] = KM
    end

    local rgb = spectral.xyz_to_srgb(
        spectral.reflectance_to_xyz(R))
    return rgb
end

---Creates a ramp of mixed colors from the origin
---to the destination with the given number of steps.
---Includes the origin and destination colors at indices
---1 and size.
---@param srgb1 integer[] origin
---@param srgb2 integer[] destination
---@param size integer size
---@return integer[][]
function spectral.palette(srgb1, srgb2, size)
    local g = {}
    local i = 0
    local toFac = 0.0
    if size > 1 then toFac = 1.0 / (size - 1) end
    while i < size do
        i = i + 1
        g[i] = spectral.mix(srgb1, srgb2, (i - 1) * toFac)
    end
    return g
end

---
---@param R number[] R
---@return number[]
function spectral.reflectance_to_xyz(R)
    local x = spectral.dot_product(R, spectral.CIE_CMF_X)
    local y = spectral.dot_product(R, spectral.CIE_CMF_Y)
    local z = spectral.dot_product(R, spectral.CIE_CMF_Z)

    return { x, y, z }
end

---
---@param lrgb number[] lrgb table
---@return number[]
function spectral.spectral_upsampling(lrgb)
    local w = math.min(math.min(lrgb[1], lrgb[2]), lrgb[3])
    local lrgbnw = { lrgb[1] - w, lrgb[2] - w, lrgb[3] - w }

    local c = math.min(lrgbnw[2], lrgbnw[3])
    local m = math.min(lrgbnw[1], lrgbnw[3])
    local y = math.min(lrgbnw[1], lrgbnw[2])
    local r = math.max(0, math.min(lrgbnw[1] - lrgbnw[2], lrgbnw[1] - lrgbnw[3]))
    local g = math.max(0, math.min(lrgbnw[2] - lrgbnw[1], lrgbnw[2] - lrgbnw[3]))
    local b = math.max(0, math.min(lrgbnw[3] - lrgbnw[1], lrgbnw[3] - lrgbnw[2]))

    return { w, c, m, y, r, g, b }
end

---SRGB is expected to be in [0, 255]. Return table
---is in [0.0, 1.0].
---@param srgb integer[] srgb table
---@return number[]
function spectral.srgb_to_linear(srgb)
    local r = spectral.uncompand(srgb[1] / 255.0)
    local g = spectral.uncompand(srgb[2] / 255.0)
    local b = spectral.uncompand(srgb[3] / 255.0)

    return { r, g, b }
end

---Converts a color channel from standard RGB
---to linear sRGB. x is expected to be in [0.0, 1.0].
---@param x number channel
---@return number
function spectral.uncompand(x)
    if x < 0.04045 then return x / 12.92 end
    return ((x + 0.055) / 1.055) ^ spectral.GAMMA
end

---
---@param xyz number[] xyz
---@return integer[]
function spectral.xyz_to_srgb(xyz)
    local r = spectral.dot_product(spectral.XYZ_RGB[1], xyz)
    local g = spectral.dot_product(spectral.XYZ_RGB[2], xyz)
    local b = spectral.dot_product(spectral.XYZ_RGB[3], xyz)

    return spectral.linear_to_srgb({ r, g, b })
end

return spectral

JavaScript specific code, such as how to convert to and from a web-friendly hexadecimal code or CSS string, were omitted.

The demo code displays an Aseprite dialog that creates a new sprite with a horizontal gradient and palette swatches.

Demo
dofile('./spectral.lua')

local dialog = Dialog { title = "Spectral Demo" }

dialog:color {
    id = "origColor",
    label = "Orig:",
    color = app.preferences.color_bar.fg_color
}

dialog:color {
    id = "destColor",
    label = "Dest:",
    color = app.preferences.color_bar.bg_color
}

dialog:slider {
    id = "swatchCount",
    label = "Swatches:",
    value = 7,
    min = 3,
    max = 32
}

dialog:button {
    id = "confirm",
    text = "&OK",
    focus = true,
    onclick = function()
        local args = dialog.data
        local origColor = args.origColor --[[@as Color]]
        local destColor = args.destColor --[[@as Color]]
        local swatchCount = args.swatchCount --[[@as integer]]

        local origSrgb = {
            origColor.red,
            origColor.green,
            origColor.blue
        }
        local destSrgb = {
            destColor.red,
            destColor.green,
            destColor.blue
        }

        local w = 256
        local h = 16
        local toFac = 1.0 / (w - 1)
        local gradient = Image(w, h)
        for pixel in gradient:pixels() do
            local fac = pixel.x * toFac
            local trgSrgb = spectral.mix(origSrgb, destSrgb, fac)
            pixel(app.pixelColor.rgba(trgSrgb[1], trgSrgb[2], trgSrgb[3], 255))
        end

        local sprite = Sprite(w, h)
        local cel = sprite.cels[1]
        cel.image = gradient

        local palette = sprite.palettes[1]
        palette:resize(swatchCount + 1)
        local swatchRgbs = spectral.palette(origSrgb, destSrgb, swatchCount)
        local i = 0
        while i < swatchCount do
            i = i + 1
            local tb = swatchRgbs[i]
            palette:setColor(i, Color { r = tb[1], g = tb[2], b = tb[3] })
        end
        palette:setColor(0, Color { r = 0, g = 0, b = 0, a = 0 })

        app.command.FitScreen()
        app.refresh()
    end
}

dialog:button {
    id = "cancel",
    text = "&CANCEL",
    focus = false,
    onclick = function()
        dialog:close()
    end
}

dialog:show { wait = false }

As with any special color mixing script, I’d assume that it was designed for sRGB, not other color profiles, e.g., AdobeRGB.

I first saw this way of color mixing from Mixbox by Secret Weapons, which is referenced in the spectral.js readme. If anyone’s interested in the technical details, which are beyond me, I recommend checking out Mixbox, too. Here is a video talk:

van Wijnen has also participated in a thread on the subject on the Krita forum here.

For a comparison, here are gradients with the same end point colors in SR LAB2, which is similar to CIE LAB and OK LAB.

srLab2Comp2

srLab2Comp3

srLab2Comp

LCH version with nearest-hue easing is on top. Out of bounds colors are clipped to gamut.

Here are indexed color mode gradients using 5 samples from the spectral script and Bayer2x2 dithering.

dither3

dither2

dither1

Thanks for your time,
Jeremy

6 Likes

If you’re still active, i’ve been trying to install this script into asperite but i’ve been getting a whole slew of errors. I tried tweaking the code, copy and pasting, as well as rearranging some things. Sadly none of them seem to be working…

Hi @kappamono,

Below are some troubleshooting questions to try to help:

  • Does the file path of your scripts folder contain any characters beyond UTF-8, such as é or ö? (See Script folder path "cannot open no such file or directory" .)
  • What version of Aseprite are you using?
  • What operating system are you using?
  • What file name and extension are you giving to the files? Are they in subfolders within the script folder? The dofile command at the top of the demo file, for example, expects spectral.lua.
  • What do the errors say?

Here is a screen cap of what I tested, Aseprite version 1.2.40-x64 on Windows 10:

Jeremy

It works now, thanks!

That’s really cool, I know one of the programs I use called rebelle uses that code. It worked different then I thought it would, didn’t look close enough and thought the examples you showed were sliders. Thanks so much for sharing.
Core-spectral.lua - Google Drive kubelka - kubelka.lua - Google Drive
edit: I hope it’s okay post links for the files incase someone doesn’t know how to save the code in the lua format. If it’s no okay please let me know for future sake.