Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions color/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType

plugins {
alias(libs.plugins.library)
// Tests only: they decode their reference data from JSON files under src/commonTest/resources.
Expand All @@ -10,6 +13,19 @@ kotlin {
namespace = "codes.side.color"
}

// JVM and Android keep a thread's gamut memo in one java.lang.ThreadLocal, in jvmAndAndroidMain. A group in the
// default template rather than a dependsOn edge: an explicit dependsOn switches the template off, and iosMain
// goes with it.
@OptIn(ExperimentalKotlinGradlePluginApi::class)
applyDefaultHierarchyTemplate {
common {
group("jvmAndAndroid") {
withJvm()
withCompilations { it.platformType == KotlinPlatformType.androidJvm }
}
}
}

sourceSets {
commonMain.dependencies {
// @Immutable only: the annotation artifact, not the Compose runtime.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import codes.side.color.internal.fuse
public class ColorConverter internal constructor(
public val source: ColorSpace,
public val target: ColorSpace,
private val steps: Array<Step>,
internal val steps: Array<Step>,
) {
private val sourceSize = source.channels.size
private val targetSize = target.channels.size
Expand Down
30 changes: 28 additions & 2 deletions color/src/commonMain/kotlin/codes/side/color/GamutMapper.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package codes.side.color

import codes.side.color.internal.MAX_COMPONENTS
import codes.side.color.internal.Step
import codes.side.color.internal.checkBulk

/**
Expand All @@ -21,6 +22,13 @@ public class GamutMapper internal constructor(
private val toSpace = source.converterTo(gamut.space)
private val toOklab = source.converterTo(Oklab)

// The route into the gamut's space short of its last step, the sRGB curve, when it ends in that: a color the
// curve leaves outside the cube lies outside it in linear light too, so most colors to be mapped are found
// without paying for the curve. Only the library's own curve, whose signs and ends are known.
private val toLinear: Array<Step>? = toSpace.steps
.takeIf { gamut.space.transfer === TransferFunction.Srgb && it.lastOrNull() === gamut.space.encodeStep }
?.let { it.copyOfRange(0, it.size - 1) }

/** Maps one color from [src] into [dst], which may be the same array. */
public fun convert(src: DoubleArray, dst: DoubleArray) {
val buffer = DoubleArray(2 * MAX_COMPONENTS)
Expand Down Expand Up @@ -71,12 +79,30 @@ public class GamutMapper internal constructor(
// Maps the source color held twice in [buffer], at 0 and at MAX_COMPONENTS, to encoded RGB in
// buffer[0..2]: straight through when the gamut holds it, as toGamut does, else through Oklab.
private fun map(buffer: DoubleArray) {
toSpace.convertInPlace(buffer)
if (inCube(buffer)) return
val linear = toLinear
if (linear == null) {
toSpace.convertInPlace(buffer)
if (inCube(buffer)) return
} else {
for (step in linear) step.apply(buffer)
// Within rounding of the cube the curve decides, as it does without this: encoding 1 gives
// 0.9999999999999999, so a channel a few ulps past 1 can come back inside.
if (!clearlyOutside(buffer)) {
gamut.space.encodeStep.apply(buffer)
if (inCube(buffer)) return
}
}
buffer.copyInto(buffer, 0, MAX_COMPONENTS, 2 * MAX_COMPONENTS)
toOklab.convertInPlace(buffer)
method.map(gamut, buffer[0], buffer[1], buffer[2], buffer)
val transfer = gamut.space.transfer
for (i in 0..2) buffer[i] = transfer.encode(buffer[i])
}
}

// Whether linear RGB [v] lies so far outside the 0..1 cube that the sRGB curve keeps it outside: below 0, which the
// curve keeps negative, or more than a trillionth past 1.
private fun clearlyOutside(v: DoubleArray): Boolean {
for (i in 0..2) if (v[i] < 0.0 || v[i] > 1.0 + 1e-12) return true
return false
}
23 changes: 13 additions & 10 deletions color/src/commonMain/kotlin/codes/side/color/OkhsxSpaces.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ package codes.side.color

import codes.side.color.internal.ColorRules
import codes.side.color.internal.LMS_TO_SRGB_LINEAR
import codes.side.color.internal.cuspLightness
import codes.side.color.internal.gamutMemo
import codes.side.color.internal.highestLinear
import codes.side.color.internal.maxChroma
import codes.side.color.internal.maxSaturation
import kotlin.math.PI
import kotlin.math.atan2
import kotlin.math.cbrt
Expand Down Expand Up @@ -60,8 +59,9 @@ public object Okhsl : ColorSpace(
val a = cos(radians)
val b = sin(radians)
val l = toeInverse(lightness)
val sMax = maxSaturation(LMS_TO_SRGB_LINEAR, a, b)
val lCusp = cuspLightness(LMS_TO_SRGB_LINEAR, a, b, sMax)
val cusp = gamutMemo().cusp(LMS_TO_SRGB_LINEAR, a, b)
val sMax = cusp.cuspSaturation
val lCusp = cusp.cuspLightness
val cMax = maxChroma(LMS_TO_SRGB_LINEAR, l, a, b, sMax, lCusp)
val c0 = lowChroma(l)
val cMid = midChroma(l, a, b, sMax, lCusp, cMax)
Expand Down Expand Up @@ -95,8 +95,9 @@ public object Okhsl : ColorSpace(
}
val a = src[1] / chroma
val b = src[2] / chroma
val sMax = maxSaturation(LMS_TO_SRGB_LINEAR, a, b)
val lCusp = cuspLightness(LMS_TO_SRGB_LINEAR, a, b, sMax)
val cusp = gamutMemo().cusp(LMS_TO_SRGB_LINEAR, a, b)
val sMax = cusp.cuspSaturation
val lCusp = cusp.cuspLightness
val cMax = maxChroma(LMS_TO_SRGB_LINEAR, l, a, b, sMax, lCusp)
val c0 = lowChroma(l)
val cMid = midChroma(l, a, b, sMax, lCusp, cMax)
Expand Down Expand Up @@ -188,8 +189,9 @@ public object Okhsv : ColorSpace(
val radians = hue * PI / 180.0
val a = cos(radians)
val b = sin(radians)
val sMax = maxSaturation(LMS_TO_SRGB_LINEAR, a, b)
val tMax = cuspT(sMax, cuspLightness(LMS_TO_SRGB_LINEAR, a, b, sMax))
val cusp = gamutMemo().cusp(LMS_TO_SRGB_LINEAR, a, b)
val sMax = cusp.cuspSaturation
val tMax = cuspT(sMax, cusp.cuspLightness)
val k = 1.0 - S0 / sMax
val lv = 1.0 - saturation * S0 / (S0 + tMax - tMax * k * saturation)
val cv = saturation * tMax * S0 / (S0 + tMax - tMax * k * saturation)
Expand Down Expand Up @@ -221,8 +223,9 @@ public object Okhsv : ColorSpace(
}
val a = if (chroma == 0.0) 1.0 else src[1] / chroma
val b = if (chroma == 0.0) 0.0 else src[2] / chroma
val sMax = maxSaturation(LMS_TO_SRGB_LINEAR, a, b)
val lCusp = cuspLightness(LMS_TO_SRGB_LINEAR, a, b, sMax)
val cusp = gamutMemo().cusp(LMS_TO_SRGB_LINEAR, a, b)
val sMax = cusp.cuspSaturation
val lCusp = cusp.cuspLightness
val cMax = maxChroma(LMS_TO_SRGB_LINEAR, l, a, b, sMax, lCusp)
if (chroma > cMax) chroma = cMax
val tMax = cuspT(sMax, lCusp)
Expand Down
3 changes: 3 additions & 0 deletions color/src/commonMain/kotlin/codes/side/color/RgbColorSpace.kt
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ public open class RgbColorSpace internal constructor(
}
private val isLinear = transfer === TransferFunction.Linear

/** The last step of every route into this space from outside it, unless its curve is [TransferFunction.Linear]. */
internal val encodeStep: Step get() = encode

// With a linear twin as its base, this space is only the curve; otherwise it is the curve
// and the matrix to XYZ-D65.
private val toBaseSteps: List<Step> = when {
Expand Down
12 changes: 5 additions & 7 deletions color/src/commonMain/kotlin/codes/side/color/RgbGamut.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
package codes.side.color

import codes.side.color.internal.cuspLightness
import codes.side.color.internal.gamutMemo
import codes.side.color.internal.maxChroma
import codes.side.color.internal.maxSaturation
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
Expand Down Expand Up @@ -38,8 +37,8 @@ public class RgbGamut internal constructor(
val radians = hue * PI / 180.0
val a = cos(radians)
val b = sin(radians)
val sMax = maxSaturation(lmsToLinear, a, b)
return maxChroma(lmsToLinear, lightness, a, b, sMax, cuspLightness(lmsToLinear, a, b, sMax))
val cusp = gamutMemo().cusp(lmsToLinear, a, b)
return maxChroma(lmsToLinear, lightness, a, b, cusp.cuspSaturation, cusp.cuspLightness)
}

/** The most colorful color of [hue], in degrees, that this gamut holds, in [OkLch]. */
Expand All @@ -48,9 +47,8 @@ public class RgbGamut internal constructor(
val radians = hue * PI / 180.0
val a = cos(radians)
val b = sin(radians)
val sMax = maxSaturation(lmsToLinear, a, b)
val lightness = cuspLightness(lmsToLinear, a, b, sMax)
return OkLch(lightness, lightness * sMax, hue)
val cusp = gamutMemo().cusp(lmsToLinear, a, b)
return OkLch(cusp.cuspLightness, cusp.cuspLightness * cusp.cuspSaturation, hue)
}

/**
Expand Down
81 changes: 75 additions & 6 deletions color/src/commonMain/kotlin/codes/side/color/internal/OkGamut.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,21 +63,90 @@ internal fun cuspLightness(t: DoubleArray, a: Double, b: Double, saturation: Dou
* in, and the edge is where it leaves for good.
*/
internal fun maxChroma(t: DoubleArray, l: Double, a: Double, b: Double, sMax: Double, lCusp: Double): Double =
if (l <= lCusp) l * sMax else largestEdge(t, l, a, b, withCeiling = true, limit = Double.POSITIVE_INFINITY)
if (l <= lCusp) l * sMax else gamutMemo().outerEdge(t, l, a, b)

/**
* The largest chroma up to [chroma] that the gamut of [t] holds at Oklab lightness [l], strictly
* between 0 and 1, and hue ([a], [b]): [chroma] itself when the color is inside, else the nearest
* edge below it. Inside the sliver past pure blue that is the edge before the sliver, not the one
* beyond it.
*/
internal fun chromaWithin(t: DoubleArray, l: Double, a: Double, b: Double, chroma: Double): Double =
if (inside(t, l, chroma * a, chroma * b, 1.0 + EDGE_TOLERANCE)) {
chroma
} else {
largestEdge(t, l, a, b, withCeiling = true, limit = chroma)
internal fun chromaWithin(t: DoubleArray, l: Double, a: Double, b: Double, chroma: Double): Double {
if (inside(t, l, chroma * a, chroma * b, 1.0 + EDGE_TOLERANCE)) return chroma
// At or past the outer edge, the nearest edge below is the outer edge itself, which is what the search would
// find. Only a chroma inside the sliver, short of the outer edge, needs the search for the edge before it.
val outer = gamutMemo().outerEdge(t, l, a, b)
return if (chroma >= outer) outer else largestEdge(t, l, a, b, withCeiling = true, limit = chroma)
}

/** This thread's [GamutMemo]. */
internal expect fun gamutMemo(): GamutMemo

/**
* One thread's last cusp and last few outer edges. A plane or a gradient asks the same question once per color, and
* the answer depends on the matrix, the hue and, for an edge, the lightness alone. Keyed by the exact bits asked
* with, so a remembered answer is the one a fresh solve gives. Each thread keeps its own and overwrites it in place,
* so remembering allocates nothing, and no thread reads an entry another is halfway through writing.
*/
internal class GamutMemo {
private var cuspT: DoubleArray? = null
private var cuspA = 0.0
private var cuspB = 0.0

/** The S of the cusp last asked for by [cusp]. */
var cuspSaturation: Double = 0.0
private set

/** The Oklab lightness of the cusp last asked for by [cusp]. */
var cuspLightness: Double = 0.0
private set

// Enough for a plane's row, whose colors reach the gamut along a few hue directions that differ in their last
// bits. Overwritten oldest first.
private val edgeT = arrayOfNulls<DoubleArray>(EDGES_KEPT)
private val edgeKeys = DoubleArray(EDGES_KEPT * 3)
private val edgeChroma = DoubleArray(EDGES_KEPT)
private var nextEdge = 0

/** The cusp of hue ([a], [b]) in the gamut of [t], as [maxSaturation] and [cuspLightness] give it, into [cuspSaturation] and [cuspLightness]. */
fun cusp(t: DoubleArray, a: Double, b: Double): GamutMemo {
if (cuspT !== t || !cuspA.sameBits(a) || !cuspB.sameBits(b)) {
val saturation = maxSaturation(t, a, b)
cuspLightness = cuspLightness(t, a, b, saturation)
cuspSaturation = saturation
cuspT = t
cuspA = a
cuspB = b
}
return this
}

/** The largest chroma at which the line of lightness [l] and hue ([a], [b]) leaves the gamut of [t] for good. */
fun outerEdge(t: DoubleArray, l: Double, a: Double, b: Double): Double {
for (i in 0 until EDGES_KEPT) {
val key = i * 3
if (edgeT[i] === t && edgeKeys[key].sameBits(l) && edgeKeys[key + 1].sameBits(a) && edgeKeys[key + 2].sameBits(b)) {
return edgeChroma[i]
}
}
val chroma = largestEdge(t, l, a, b, withCeiling = true, limit = Double.POSITIVE_INFINITY)
val i = nextEdge
edgeT[i] = t
edgeKeys[i * 3] = l
edgeKeys[i * 3 + 1] = a
edgeKeys[i * 3 + 2] = b
edgeChroma[i] = chroma
nextEdge = (i + 1) % EDGES_KEPT
return chroma
}

private companion object {
const val EDGES_KEPT = 16
}
}

private fun Double.sameBits(other: Double): Boolean = toRawBits() == other.toRawBits()

// The largest positive x up to [limit] at which a channel along (l, x·a, x·b) reaches 0, or 1
// [withCeiling], while every channel stays in 0..1 (0..∞ without the ceiling), give or take
// EDGE_TOLERANCE. Every line out of grey leaves the gamut somewhere, so some root always qualifies.
Expand Down
22 changes: 22 additions & 0 deletions color/src/commonTest/kotlin/codes/side/color/GamutMapperTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package codes.side.color

import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertSame

Expand Down Expand Up @@ -56,4 +57,25 @@ class GamutMapperTest {
val same = DoubleArray(8)
mapper.convert(same, 0, same, 0, 2)
}

@Test
fun colorsOnTheCubesSurfaceMapAsToGamutMapsThem() {
// On sRGB's surface a linear channel lands within a few ulps of 0 or 1, where encoding can round a hair past
// 1 back inside: however the mapper tells inside from out, such a color must come back as toGamut gives it.
val colors = ArrayList<ColorValue>()
for (step in 0 until 360) {
for (lightness in listOf(0.1, 0.3, 0.5, 0.7, 0.9, 0.99)) {
val edge = Srgb.gamut.maxChroma(lightness, step.toDouble())
for (scale in listOf(1.0 - 1e-15, 1.0, 1.0 + 1e-15, 1.0 + 1e-12, 1.0 + 1e-9)) colors += OkLch(lightness, edge * scale, step.toDouble())
}
}
for (gamut in listOf(Srgb.gamut, DisplayP3.gamut)) {
val mapper = gamut.mapper(OkLch, GamutMapping.ChromaReduction)
val mapped = DoubleArray(3)
for (color in colors) {
mapper.convert(color.components(), mapped)
assertEquals(color.toGamut(gamut, GamutMapping.ChromaReduction).components().toList(), mapped.toList(), "$color into $gamut")
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package codes.side.color

import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertEquals

// A gamut's cusp and edges are remembered between calls. What a call returns must not depend on what was asked
// before it: the same colors, asked again in another order among other hues, lightnesses and gamuts, come back
// bit for bit alike.
class RememberedGamutTest {

// Among them hues in sRGB's sliver past pure blue, where one lightness has two edges.
private val hues = listOf(0.0, 42.0, 110.0, 200.0, 264.05, 264.1, 264.2, 330.0)

@Test
fun okhslAndOkhsvGiveTheSameInAnyOrder() {
val random = Random(20260927)
val colors = List(800) { k ->
val hue = hues[k % hues.size]
if (k % 2 == 0) Okhsl(hue, random.nextDouble(), random.nextDouble()) else Okhsv(hue, random.nextDouble(), random.nextDouble())
}
val labs = colors.map { it.to(Oklab) }
val inOrder = colors.sortedBy { it.components()[0] }.associateWith { it.to(Oklab).components().toList() }
val back = labs.sortedBy { it.components()[1] }.associateWith { listOf(it.to(Okhsl).components().toList(), it.to(Okhsv).components().toList()) }
for (k in colors.indices.shuffled(random)) {
// Another gamut's cusp and edge at the same hue and lightness, asked in between.
DisplayP3.gamut.maxChroma(random.nextDouble(0.05, 0.95), hues[k % hues.size])
assertEquals(inOrder.getValue(colors[k]), colors[k].to(Oklab).components().toList(), "${colors[k]}")
assertEquals(back.getValue(labs[k]), listOf(labs[k].to(Okhsl).components().toList(), labs[k].to(Okhsv).components().toList()), "${labs[k]}")
}
}

@Test
fun aGamutsEdgeAndCuspAreItsOwn() {
val random = Random(20260928)
val asked = List(400) { random.nextDouble(0.02, 0.98) to hues[it % hues.size] }
val alone = listOf(Srgb.gamut, DisplayP3.gamut).associateWith { gamut ->
asked.map { (lightness, hue) -> gamut.maxChroma(lightness, hue) to gamut.cusp(hue).components().toList() }
}
for (k in asked.indices.shuffled(random)) {
val (lightness, hue) = asked[k]
for (gamut in listOf(DisplayP3.gamut, Srgb.gamut)) {
assertEquals(alone.getValue(gamut)[k], gamut.maxChroma(lightness, hue) to gamut.cusp(hue).components().toList(), "$gamut at $lightness, $hue")
}
}
}

@Test
fun chromaReductionGivesTheSameInAnyOrder() {
// A plane's rows, chroma across and lightness down, beyond sRGB for most of them.
val random = Random(20260929)
val colors = hues.flatMap { hue -> List(24) { row -> List(32) { column -> OkLch(0.04 + row * 0.04, column * 0.4 / 31, hue) } }.flatten() }
val inRows = colors.associateWith { it.toGamut(Srgb.gamut, GamutMapping.ChromaReduction).components().toList() }
for (k in colors.indices.shuffled(random)) {
DisplayP3.gamut.maxChroma(random.nextDouble(0.05, 0.95), hues[k % hues.size])
val color = colors[k]
assertEquals(inRows.getValue(color), color.toGamut(Srgb.gamut, GamutMapping.ChromaReduction).components().toList(), "$color")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package codes.side.color.internal

import kotlin.native.concurrent.ThreadLocal

@ThreadLocal
private val memo = GamutMemo()

internal actual fun gamutMemo(): GamutMemo = memo
Loading
Loading