diff --git a/orx-color/src/commonMain/kotlin/mixing/Spectral.kt b/orx-color/src/commonMain/kotlin/mixing/Spectral.kt new file mode 100644 index 00000000..b87b4866 --- /dev/null +++ b/orx-color/src/commonMain/kotlin/mixing/Spectral.kt @@ -0,0 +1,477 @@ +package org.openrndr.extra.color.mixing + +import org.openrndr.color.ColorRGBa +import org.openrndr.color.ColorXYZa +import org.openrndr.extra.color.tools.matchLinearity + +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.sqrt + +/* +Direct port of https://github.com/rvanwijnen/spectral.js + */ + +private val SPD_C = doubleArrayOf( + 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 +) + +private val SPD_M = doubleArrayOf( + 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 +) + +private val SPD_Y = doubleArrayOf( + 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 +) + +private val SPD_R = doubleArrayOf( + 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 +) + +private val SPD_G = doubleArrayOf( + 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 +) + +private val SPD_B = doubleArrayOf( + 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 +) + +private val CIE_CMF_X = doubleArrayOf( + 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 +) + +private val CIE_CMF_Y = doubleArrayOf( + 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 +) + +private val CIE_CMF_Z = doubleArrayOf( + 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 +) + +private fun spectralUpsampling(rgb: ColorRGBa): DoubleArray { + var lrgb = rgb.toLinear() + val w = min(min(lrgb.r, lrgb.g), lrgb.b) + + lrgb = lrgb.copy(r = lrgb.r - w, g = lrgb.g - w, lrgb.b - w) + + val c = min(lrgb.g, lrgb.b) + val m = min(lrgb.r, lrgb.b) + val y = min(lrgb.r, lrgb.g) + val r = max(0.0, min(lrgb.r - lrgb.b, lrgb.r - lrgb.g)) + val g = max(0.0, min(lrgb.g - lrgb.b, lrgb.g - lrgb.r)) + val b = max(0.0, min(lrgb.b - lrgb.g, lrgb.b - lrgb.r)) + + return doubleArrayOf(w, c, m, y, r, g, b) +} + + +internal fun linearToReflectance(rgb: ColorRGBa): DoubleArray { + val eps = 0.00000001 + val weights = spectralUpsampling(rgb) + val R = DoubleArray(38) + + for (i in 0 until 38) { + R[i] = max( + eps, + weights[0] + + weights[1] * SPD_C[i] + + weights[2] * SPD_M[i] + + weights[3] * SPD_Y[i] + + weights[4] * SPD_R[i] + + weights[5] * SPD_G[i] + + weights[6] * SPD_B[i] + ) + } + return R +} + +private fun linearToConcentration(l1: Double, l2: Double, t: Double): Double { + val t1 = l1 * (1 - t).pow(2.0) + val t2 = l2 * t.pow(2.0) + + return t2 / (t1 + t2) +} + +private fun DoubleArray.dot(other: DoubleArray): Double { + var d = 0.0 + for (i in indices) { + d += this[i] * other[i] + } + return d +} + +internal fun reflectanceToXYZ(R: DoubleArray): ColorXYZa { + val x = R.dot(CIE_CMF_X) + val y = R.dot(CIE_CMF_Y) + val z = R.dot(CIE_CMF_Z) + return ColorXYZa(x, y, z) +} + +private fun pow(x: Double, y: Double): Double = x.pow(y) + +internal fun saundersonCorrection(rInf: Double, k1: Double, k2: Double): Double { + return k1 + ((1 - k1) * (1 - k2) * rInf) / (1 - (k2 * rInf)) +} + +fun mixSpectral(color1: ColorRGBa, color2: ColorRGBa, t: Double, k1: Double = 0.0, k2: Double = 0.0): ColorRGBa { + val lrgb1 = color1.toLinear() + val lrgb2 = color2.toLinear() + + val ref1 = linearToReflectance(lrgb1) + val ref2 = linearToReflectance(lrgb2) + + val l1 = ref1.dot(CIE_CMF_Y) + val l2 = ref2.dot(CIE_CMF_Y) + + val c = linearToConcentration(l1, l2, t) + + val reflectance = DoubleArray(38) + + for (i in 0 until 38) { + val ks = + (1.0 - c) * (pow(1.0 - ref1[i], 2.0) / (2.0 * ref1[i])) + c * (pow( + 1.0 - ref2[i], + 2.0 + ) / (2.0 * ref2[i])) + val km = (1.0 + ks - sqrt(pow(ks, 2.0) + 2.0 * ks)) + + reflectance[i] = saundersonCorrection(km, k1, k2) + } + + val xyz = reflectanceToXYZ(reflectance) + return xyz.toRGBa().matchLinearity(color1) +} \ No newline at end of file diff --git a/orx-color/src/commonTest/kotlin/mixing/TestSpectral.kt b/orx-color/src/commonTest/kotlin/mixing/TestSpectral.kt new file mode 100644 index 00000000..906a0217 --- /dev/null +++ b/orx-color/src/commonTest/kotlin/mixing/TestSpectral.kt @@ -0,0 +1,30 @@ +package org.openrndr.extra.color.mixing + +import org.openrndr.color.ColorRGBa + +import org.openrndr.extra.color.tools.matchLinearity +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TestSpectral { + @Test + fun testIdentity() { + val colors = listOf(ColorRGBa.RED, ColorRGBa.GREEN, ColorRGBa.BLUE) + + for (c in colors) { + val r = linearToReflectance(c) + assertEquals(r.size, 38) + val xyz = reflectanceToXYZ(r) + val cp = xyz.toRGBa().matchLinearity(c) + assertTrue(cp.toVector4().distanceTo(c.toVector4()) < 5E-3) + } + } + + @Test + fun testSaunderson() { + val c = saundersonCorrection(10.15, 0.0, 0.0) + println(c) + } + +} \ No newline at end of file diff --git a/orx-color/src/jvmDemo/kotlin/DemoMixSpectral01.kt b/orx-color/src/jvmDemo/kotlin/DemoMixSpectral01.kt new file mode 100644 index 00000000..43397fe2 --- /dev/null +++ b/orx-color/src/jvmDemo/kotlin/DemoMixSpectral01.kt @@ -0,0 +1,43 @@ +import org.openrndr.application +import org.openrndr.color.ColorRGBa +import org.openrndr.draw.isolated +import org.openrndr.extra.color.mixing.mixSpectral +import org.openrndr.extra.color.spaces.OKHSV +import org.openrndr.extra.color.tools.shiftHue + +fun main() { + application { + configure { + width = 800 + height = 800 + } + program { + + extend { + + for (j in 0 until 60) { + val p = 3.0 + val a = ColorRGBa.BLUE.shiftHue(j * p) + val b = ColorRGBa.BLUE.shiftHue(j * p + 180.0) + + drawer.isolated { + for (i in 0 until 60) { + + val c = mixSpectral(a, b, i / 59.0, 0.0, 0.0).toSRGB() + drawer.fill = c + drawer.stroke = null + drawer.rectangle(0.0, 0.0, width/60.0, 0.5 * height/60.0) + + drawer.fill = a.mix(b, i / 59.0) + drawer.rectangle(0.0, 0.5 * height, width/60.0, 0.5 * height/60.0) + + drawer.translate(width/60.0, 0.0) + + } + } + drawer.translate(0.0, 0.5 * height/60.0) + } + } + } + } +} \ No newline at end of file