[orx-color] Add OKLab color space

This commit is contained in:
Edwin Jakobs
2021-03-12 07:33:05 +01:00
parent a9a12811c4
commit 56dd22a94e
11 changed files with 512 additions and 2 deletions

View File

@@ -0,0 +1,95 @@
import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.draw.*
import org.openrndr.extensions.SingleScreenshot
import org.openrndr.extras.camera.Orbital
import org.openrndr.extras.meshgenerators.sphereMesh
import org.openrndr.math.Vector3
import spaces.ColorOKLCHa
import kotlin.math.cos
fun main() {
application {
configure {
width = 800
height = 800
}
program {
// -- this block is for automation purposes only
if (System.getProperty("takeScreenshot") == "true") {
extend(SingleScreenshot()) {
this.outputFile = System.getProperty("screenshotPath")
}
}
val mesh = sphereMesh(8, 8, radius = 0.1)
val instanceData = vertexBuffer(
vertexFormat {
attribute("instanceColor", VertexElementType.VECTOR4_FLOAT32)
attribute("instancePosition", VertexElementType.VECTOR3_FLOAT32)
},
90 * 100
)
extend(Orbital())
extend {
drawer.clear(ColorRGBa.WHITE)
drawer.stroke = null
drawer.fontMap = loadFont("demo-data/fonts/IBMPlexMono-Regular.ttf", 16.0)
instanceData.put {
for (hue in 0 until 360 step 4) {
for (chroma in 0 until 100 step 1) {
val lch = ColorOKLCHa(cos(seconds * 0.1) * 0.5 + 0.5, chroma / 100.0, hue.toDouble())
val srgb = lch.toRGBa().toSRGB().saturated
write(srgb)
write(Vector3((srgb.r - 0.5) * 10.0, (srgb.g - 0.5) * 10.0, (srgb.b - 0.5) * 10.0))
}
}
}
drawer.isolated {
drawer.shadeStyle = shadeStyle {
vertexTransform = """
x_position += i_instancePosition;
""".trimIndent()
fragmentTransform = """
x_fill = vi_instanceColor;
""".trimIndent()
}
drawer.vertexBufferInstances(listOf(mesh), listOf(instanceData), DrawPrimitive.TRIANGLES, 90 * 100)
}
drawer.stroke = ColorRGBa.BLACK.opacify(0.25)
drawer.strokeWeight = 10.0
drawer.lineSegments(
listOf(
Vector3(-5.0, -5.0, -5.0), Vector3(5.0, -5.0, -5.0),
Vector3(-5.0, -5.0, 5.0), Vector3(5.0, -5.0, 5.0),
Vector3(-5.0, 5.0, -5.0), Vector3(5.0, 5.0, -5.0),
Vector3(-5.0, 5.0, 5.0), Vector3(5.0, 5.0, 5.0),
Vector3(-5.0, -5.0, -5.0), Vector3(-5.0, 5.0, -5.0),
Vector3(5.0, -5.0, -5.0), Vector3(5.0, 5.0, -5.0),
Vector3(-5.0, -5.0, 5.0), Vector3(-5.0, 5.0, 5.0),
Vector3(5.0, -5.0, 5.0), Vector3(5.0, 5.0, 5.0),
Vector3(-5.0, -5.0, -5.0), Vector3(-5.0, -5.0, 5.0),
Vector3(5.0, -5.0, -5.0), Vector3(5.0, -5.0, 5.0),
Vector3(-5.0, 5.0, -5.0), Vector3(-5.0, 5.0, 5.0),
Vector3(5.0, 5.0, -5.0), Vector3(5.0, 5.0, 5.0),
)
)
}
}
}
}

View File

@@ -0,0 +1,86 @@
import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.draw.*
import org.openrndr.extensions.SingleScreenshot
import org.openrndr.extras.camera.Orbital
import org.openrndr.extras.meshgenerators.sphereMesh
import org.openrndr.math.Vector3
import spaces.ColorOKLCHa
import kotlin.math.cos
fun main() {
application {
configure {
width = 800
height = 800
}
program {
// -- this block is for automation purposes only
if (System.getProperty("takeScreenshot") == "true") {
extend(SingleScreenshot()) {
this.outputFile = System.getProperty("screenshotPath")
}
}
val mesh = sphereMesh(8, 8, radius = 0.1)
val instanceData = vertexBuffer(
vertexFormat {
attribute("instanceColor", VertexElementType.VECTOR4_FLOAT32)
attribute("instancePosition", VertexElementType.VECTOR3_FLOAT32)
},
100 * 100
)
extend(Orbital())
extend {
drawer.clear(ColorRGBa.WHITE)
drawer.stroke = null
drawer.fontMap = loadFont("demo-data/fonts/IBMPlexMono-Regular.ttf", 16.0)
instanceData.put {
for (lumo in 0 until 100 step 1) {
for (chroma in 0 until 100 step 1) {
val lch = ColorOKLCHa(lumo / 100.0, chroma / 100.0, cos(seconds * 0.1) * 360.0)
val srgb = lch.toRGBa().toSRGB().saturated
write(srgb)
write(Vector3((srgb.r - 0.5) * 10.0, (srgb.g - 0.5) * 10.0, (srgb.b - 0.5) * 10.0))
}
}
}
drawer.isolated {
drawer.shadeStyle = shadeStyle {
vertexTransform = """
x_position += i_instancePosition;
""".trimIndent()
fragmentTransform = """
x_fill = vi_instanceColor;
""".trimIndent()
}
drawer.vertexBufferInstances(listOf(mesh), listOf(instanceData), DrawPrimitive.TRIANGLES, 90 * 100)
}
drawer.stroke = ColorRGBa.BLACK.opacify(0.25)
drawer.strokeWeight = 10.0
drawer.lineSegments(
listOf(
Vector3(-5.0, -5.0, -5.0), Vector3(5.0, -5.0, -5.0),
Vector3(-5.0, -5.0, 5.0), Vector3(5.0, -5.0, 5.0),
Vector3(-5.0, 5.0, -5.0), Vector3(5.0, 5.0, -5.0),
Vector3(-5.0, 5.0, 5.0), Vector3(5.0, 5.0, 5.0),
Vector3(-5.0, -5.0, -5.0), Vector3(-5.0, 5.0, -5.0),
Vector3(5.0, -5.0, -5.0), Vector3(5.0, 5.0, -5.0),
Vector3(-5.0, -5.0, 5.0), Vector3(-5.0, 5.0, 5.0),
Vector3(5.0, -5.0, 5.0), Vector3(5.0, 5.0, 5.0),
Vector3(-5.0, -5.0, -5.0), Vector3(-5.0, -5.0, 5.0),
Vector3(5.0, -5.0, -5.0), Vector3(5.0, -5.0, 5.0),
Vector3(-5.0, 5.0, -5.0), Vector3(-5.0, 5.0, 5.0),
Vector3(5.0, 5.0, -5.0), Vector3(5.0, 5.0, 5.0),
)
)
}
}
}
}

View File

@@ -0,0 +1,57 @@
import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.draw.loadFont
import org.openrndr.extensions.SingleScreenshot
import org.openrndr.extras.color.palettes.rangeTo
import org.openrndr.extras.color.spaces.toHSLUVa
import org.openrndr.extras.color.spaces.toXSLUVa
import spaces.toOKLABa
import spaces.toOKLCHa
fun main() {
application {
program {
// -- this block is for automation purposes only
if (System.getProperty("takeScreenshot") == "true") {
extend(SingleScreenshot()) {
this.outputFile = System.getProperty("screenshotPath")
}
}
extend {
drawer.clear(ColorRGBa.WHITE)
val colorA = ColorRGBa.BLUE
val colorB = ColorRGBa.PINK
val stepCount = 25
val allSteps = listOf(
"RGB" to (colorA..colorB blend stepCount),
"RGB linear" to (colorA.toLinear()..colorB.toLinear() blend stepCount),
"HSV" to (colorA..colorB.toHSVa() blend stepCount),
"Lab" to (colorA.toLABa()..colorB.toLABa() blend stepCount),
"LCh(ab)" to (colorA.toLCHABa()..colorB.toLCHABa() blend stepCount),
"OKLab" to (colorA.toOKLABa()..colorB.toOKLABa() blend stepCount),
"OKLCh" to (colorA.toOKLCHa()..colorB.toOKLCHa() blend stepCount),
"HSLUV" to (colorA.toHSLUVa()..colorB.toHSLUVa() blend stepCount),
"XSLUV" to (colorA.toXSLUVa()..colorB.toXSLUVa() blend stepCount),
)
drawer.stroke = null
drawer.fontMap = loadFont("demo-data/fonts/IBMPlexMono-Regular.ttf", 16.0)
drawer.translate(20.0, 20.0)
for ((label, steps) in allSteps) {
drawer.fill = ColorRGBa.GRAY.shade(0.25)
drawer.text(label, 0.0, 24.0)
for (i in steps.indices) {
drawer.fill = steps[i].toSRGB()
drawer.rectangle(100.0 + i * 20.0, 0.0, 20.0, 40.0)
}
drawer.translate(0.0, 50.0)
}
}
}
}
}

View File

@@ -0,0 +1,96 @@
import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.draw.DrawPrimitive
import org.openrndr.draw.isolated
import org.openrndr.draw.loadFont
import org.openrndr.extensions.SingleScreenshot
import org.openrndr.extras.camera.Orbital
import org.openrndr.extras.color.palettes.rangeTo
import org.openrndr.extras.color.spaces.toHSLUVa
import org.openrndr.extras.color.spaces.toXSLUVa
import org.openrndr.extras.meshgenerators.sphereMesh
import org.openrndr.math.Vector3
import spaces.toOKLABa
import spaces.toOKLCHa
fun main() {
application {
configure {
width = 800
height = 800
}
program {
// -- this block is for automation purposes only
if (System.getProperty("takeScreenshot") == "true") {
extend(SingleScreenshot()) {
this.outputFile = System.getProperty("screenshotPath")
}
}
val mesh = sphereMesh(8, 8, radius = 0.1)
extend(Orbital())
extend {
drawer.clear(ColorRGBa.WHITE)
val colorA = ColorRGBa.BLUE.toHSVa().shiftHue(seconds * 40.0).toRGBa()
val colorB = ColorRGBa.PINK.toHSVa().shiftHue(-seconds * 34.0).toRGBa()
val stepCount = 25
val allSteps = listOf(
"RGB" to (colorA..colorB blend stepCount),
"RGB linear" to (colorA.toLinear()..colorB.toLinear() blend stepCount),
"HSV" to (colorA..colorB.toHSVa() blend stepCount),
"Lab" to (colorA.toLABa()..colorB.toLABa() blend stepCount),
"LCh(ab)" to (colorA.toLCHABa()..colorB.toLCHABa() blend stepCount),
"OKLab" to (colorA.toOKLABa()..colorB.toOKLABa() blend stepCount),
"OKLCh" to (colorA.toOKLCHa()..colorB.toOKLCHa() blend stepCount),
"HSLUV" to (colorA.toHSLUVa()..colorB.toHSLUVa() blend stepCount),
"XSLUV" to (colorA.toXSLUVa()..colorB.toXSLUVa() blend stepCount),
)
drawer.stroke = null
drawer.fontMap = loadFont("demo-data/fonts/IBMPlexMono-Regular.ttf", 16.0)
for ((_, steps) in allSteps) {
for (i in steps.indices) {
val srgb = steps[i].toSRGB().saturated
drawer.fill = srgb
drawer.isolated {
drawer.translate((srgb.r - 0.5) * 10.0, (srgb.g - 0.5) * 10.0, (srgb.b - 0.5) * 10.0)
drawer.vertexBuffer(mesh, DrawPrimitive.TRIANGLES)
}
}
val positions = steps.map {
val l = it.toSRGB().saturated
Vector3((l.r - 0.5) * 10.0, (l.g - 0.5) * 10.0, (l.b - 0.5) * 10.0)
}
drawer.stroke = ColorRGBa.BLACK.opacify(0.25)
drawer.strokeWeight = 10.0
drawer.lineStrip(positions)
}
drawer.lineSegments(
listOf(
Vector3(-5.0, -5.0, -5.0), Vector3(5.0, -5.0, -5.0),
Vector3(-5.0, -5.0, 5.0), Vector3(5.0, -5.0, 5.0),
Vector3(-5.0, 5.0, -5.0), Vector3(5.0, 5.0, -5.0),
Vector3(-5.0, 5.0, 5.0), Vector3(5.0, 5.0, 5.0),
Vector3(-5.0, -5.0, -5.0), Vector3(-5.0, 5.0, -5.0),
Vector3(5.0, -5.0, -5.0), Vector3(5.0, 5.0, -5.0),
Vector3(-5.0, -5.0, 5.0), Vector3(-5.0, 5.0, 5.0),
Vector3(5.0, -5.0, 5.0), Vector3(5.0, 5.0, 5.0),
Vector3(-5.0, -5.0, -5.0), Vector3(-5.0, -5.0, 5.0),
Vector3(5.0, -5.0, -5.0), Vector3(5.0, -5.0, 5.0),
Vector3(-5.0, 5.0, -5.0), Vector3(-5.0, 5.0, 5.0),
Vector3(5.0, 5.0, -5.0), Vector3(5.0, 5.0, 5.0),
)
)
}
}
}
}

View File

@@ -2,6 +2,10 @@ package org.openrndr.extras.color.palettes
import org.openrndr.color.*
import org.openrndr.extras.color.spaces.*
import spaces.ColorOKLABa
import spaces.ColorOKLCHa
import spaces.toOKLABa
import spaces.toOKLCHa
fun <T> colorSequence(vararg offsets: Pair<Double, T>): ColorSequence
@@ -51,6 +55,8 @@ class ColorSequence(val colors: List<Pair<Double, ConvertibleToColorRGBa>>) {
is ColorXSLUVa -> right.second.toRGBa().toXSLUVa().mix(l, nt).toRGBa()
is ColorLCHUVa -> right.second.toRGBa().toLCHUVa().mix(l, nt).toRGBa()
is ColorLCHABa -> right.second.toRGBa().toLCHABa().mix(l, nt).toRGBa()
is ColorOKLABa -> right.second.toRGBa().toOKLABa().mix(l, nt).toRGBa()
is ColorOKLCHa -> right.second.toRGBa().toOKLCHa().mix(l, nt).toRGBa()
else -> error("unsupported color space: ${l::class}")
}.toSRGB()
}

View File

@@ -77,6 +77,9 @@ fun maxChromaForLH(L100: Double, H: Double): Double {
return min
}
/**
* HSLUV color space
*/
data class ColorHSLUVa(val h: Double, val s: Double, val l: Double, val a: Double = 1.0) :
ConvertibleToColorRGBa,
HueShiftableColor<ColorHSLUVa>,
@@ -85,7 +88,6 @@ data class ColorHSLUVa(val h: Double, val s: Double, val l: Double, val a: Doubl
OpacifiableColor<ColorHSLUVa>,
AlgebraicColor<ColorHSLUVa> {
fun toLCHUVa(): ColorLCHUVa {
val l100 = l * 100.0

View File

@@ -0,0 +1,62 @@
package spaces
import org.openrndr.color.*
import kotlin.math.pow
/**
* Color in OKLab color space
*/
data class ColorOKLABa(val l: Double, val a: Double, val b: Double, val alpha: Double = 1.0) :
ConvertibleToColorRGBa,
ShadableColor<ColorOKLABa>,
OpacifiableColor<ColorOKLABa>,
AlgebraicColor<ColorOKLABa> {
companion object {
fun fromRGBa(rgba: ColorRGBa): ColorOKLABa {
// based on https://bottosson.github.io/posts/oklab/
val c = rgba.toLinear()
val l = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929f * c.b
val m = 0.2119034982 * c.r + 0.6806995451 * c.g + 0.1073969566f * c.b
val s = 0.0883024619 * c.r + 0.2817188376 * c.g + 0.6299787005f * c.b
val lnl = l.pow(1.0 / 3.0)
val mnl = m.pow(1.0 / 3.0)
val snl = s.pow(1.0 / 3.0)
val L = 0.2104542553f * lnl + 0.7936177850f * mnl - 0.0040720468f * snl
val a = 1.9779984951f * lnl - 2.4285922050f * mnl + 0.4505937099f * snl
val b = 0.0259040371f * lnl + 0.7827717662f * mnl - 0.8086757660f * snl
return ColorOKLABa(L, a, b, alpha = c.a)
}
}
override fun toRGBa(): ColorRGBa {
// based on https://bottosson.github.io/posts/oklab/
val lnl = l + 0.3963377774 * a + 0.2158037573 * b
val mnl = l - 0.1055613458 * a - 0.0638541728 * b
val snl = l - 0.0894841775 * a - 1.2914855480 * b
val l = lnl * lnl * lnl
val m = mnl * mnl * mnl
val s = snl * snl * snl
return ColorRGBa(
4.0767416621 * l - 3.3077115913 * m + 0.2309699292f * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965f * s,
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010f * s,
alpha, linearity = Linearity.LINEAR
)
}
fun toOKLCHa() = ColorOKLCHa.fromColorOKLABa(this)
override fun shade(factor: Double) = ColorOKLABa(l * factor, a, b, alpha)
override fun opacify(factor: Double) = ColorOKLABa(l, a, b, alpha * factor)
override fun minus(right: ColorOKLABa) = ColorOKLABa(l - right.l, a - right.a, b - right.b, alpha - right.alpha)
override fun plus(right: ColorOKLABa) = ColorOKLABa(l + right.l, a + right.a, b + right.b, alpha + right.alpha)
override fun times(scale: Double) = ColorOKLABa(l * scale, a * scale, b * scale, alpha * scale)
}
fun ColorRGBa.toOKLABa() = ColorOKLABa.fromRGBa(this)

View File

@@ -0,0 +1,61 @@
package spaces
import org.openrndr.color.*
import org.openrndr.math.mixAngle
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Color in cylindrical OKLab space
*/
data class ColorOKLCHa(val l: Double, val c: Double, val h: Double, val a: Double = 1.0) : ConvertibleToColorRGBa,
OpacifiableColor<ColorOKLCHa>,
ShadableColor<ColorOKLCHa>,
HueShiftableColor<ColorOKLCHa>,
AlgebraicColor<ColorOKLCHa> {
companion object {
fun fromColorOKLABa(oklaba: ColorOKLABa): ColorOKLCHa {
val l = oklaba.l
val c = sqrt(oklaba.a * oklaba.a + oklaba.b * oklaba.b)
var h = atan2(oklaba.b, oklaba.a)
if (h < 0) {
h += Math.PI * 2
}
h = Math.toDegrees(h)
return ColorOKLCHa(l, c, h, oklaba.alpha)
}
}
override fun opacify(factor: Double) = copy(a = a * factor)
override fun shade(factor: Double) = copy(l = l * factor)
override fun shiftHue(shiftInDegrees: Double) = copy(h = h + shiftInDegrees)
override fun plus(right: ColorOKLCHa) = copy(l = l + right.l, c = c + right.c, h = h + right.h, a = a + right.a)
override fun minus(right: ColorOKLCHa) = copy(l = l - right.l, c = c - right.c, h = h - right.h, a = a - right.a)
override fun times(scale: Double) = copy(l = l * scale, c = c * scale, h = h * scale, a = a * scale)
override fun mix(other: ColorOKLCHa, factor: Double) = mix(this, other, factor)
fun toOKLABa(): ColorOKLABa {
val a = c * cos(Math.toRadians(h))
val b = c * sin(Math.toRadians(h))
return ColorOKLABa(l, a, b, alpha = this.a)
}
override fun toRGBa() = toOKLABa().toRGBa()
}
fun mix(left: ColorOKLCHa, right: ColorOKLCHa, x: Double): ColorOKLCHa {
val sx = x.coerceIn(0.0, 1.0)
return ColorOKLCHa(
(1.0 - sx) * left.l + sx * right.l,
(1.0 - sx) * left.c + sx * right.c,
mixAngle(left.h, right.h, sx),
(1.0 - sx) * left.a + sx * right.a
)
}
fun ColorRGBa.toOKLCHa() = ColorOKLABa.fromRGBa(this).toOKLCHa()

View File

@@ -0,0 +1,42 @@
import org.amshove.kluent.`should be equal to`
import org.openrndr.color.ColorRGBa
import org.openrndr.color.Linearity
import org.openrndr.extras.color.palettes.rangeTo
import org.spekframework.spek2.Spek
import org.spekframework.spek2.style.specification.describe
object TestMix : Spek({
describe("two srgb colors") {
val a = ColorRGBa.BLUE
val b = ColorRGBa.RED
a.linearity `should be equal to` Linearity.SRGB
it("should mix properly") {
a.mix(b, 0.0) `should be equal to` a
a.mix(b, 1.0) `should be equal to` b
}
}
describe("two linear rgb colors") {
val a = ColorRGBa.BLUE.toLinear()
val b = ColorRGBa.RED.toLinear()
it("should mix properly") {
a.mix(b, 0.0) `should be equal to` a
a.mix(b, 1.0) `should be equal to` b
}
}
describe("a 2-step range of colors") {
val a = ColorRGBa.BLUE
val b = ColorRGBa.RED
val blend = a..b blend 2
blend.size `should be equal to` 2
blend[0] `should be equal to` a
blend[1] `should be equal to` b
}
})