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
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,24 @@ data class ColorRamp(
* Sample the ramp at t in [0, 1], linearly interpolating between stops.
*/
fun sample(t: Float): NeutralColor {
return sampleWith(t, NeutralColor::lerp)
}

/**
* Sample the ramp at t in [0, 1], interpolating between stops in OKLab.
*
* Prefer [sample] for nearby hues and performance-sensitive render-loop
* paths. Use [sampleOklab] for ramps that bridge distant hues where
* perceptual uniformity matters more than the extra conversion cost.
*/
fun sampleOklab(t: Float): NeutralColor {
return sampleWith(t, NeutralColor::lerpOklab)
}

private inline fun sampleWith(
t: Float,
interpolate: (NeutralColor, NeutralColor, Float) -> NeutralColor,
): NeutralColor {
val clamped = t.coerceIn(0f, 1f)
if (clamped <= 0f) return stops.first()
if (clamped >= 1f) return stops.last()
Expand All @@ -27,7 +45,7 @@ data class ColorRamp(
if (lower == upper) return stops[lower]

val localT = scaled - lower
return NeutralColor.lerp(stops[lower], stops[upper], localT)
return interpolate(stops[lower], stops[upper], localT)
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,37 @@ value class NeutralColor private constructor(
)
}

/**
* Interpolate between two sRGB colors in OKLab perceptual space.
*
* This is opt-in because it performs extra color-space conversions. Use
* [lerp] for nearby hues and performance-sensitive render-loop paths; use
* [lerpOklab] for distant hues where linear RGB would pass through muddy
* or hue-biased midpoints.
*/
fun lerpOklab(
start: NeutralColor,
end: NeutralColor,
t: Float,
): NeutralColor {
val clamped = t.coerceIn(0f, 1f)
val color =
OklabColor
.lerp(
start = OklabColor.fromSrgb(start),
end = OklabColor.fromSrgb(end),
t = clamped,
)
.toSrgb(alpha = interpolate(start.alpha, end.alpha, clamped))

return fromRgba(
red = color.red.coerceIn(0f, 1f),
green = color.green.coerceIn(0f, 1f),
blue = color.blue.coerceIn(0f, 1f),
alpha = color.alpha.coerceIn(0f, 1f),
)
}

private fun interpolate(
start: Float,
end: Float,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package link.socket.phosphor.color

import kotlin.math.pow

/**
* Linear RGB color with normalized channels.
*
* This is the linear-light form of Phosphor's sRGB [NeutralColor]. Use it as
* the bridge between packed sRGB and [OklabColor] when a caller needs explicit
* color-space conversion. For interpolation, prefer [NeutralColor.lerp] for
* nearby hues and performance-sensitive paths; prefer [NeutralColor.lerpOklab]
* for distant hues where perceptual uniformity matters.
*/
data class LinearRgbColor(
val red: Float,
val green: Float,
val blue: Float,
) {
/**
* Convert this linear RGB color to packed sRGB.
*
* Channels outside the displayable sRGB gamut are clamped by [NeutralColor].
*/
fun toSrgb(alpha: Float = 1f): NeutralColor =
NeutralColor.fromRgba(
red = linearToSrgb(red),
green = linearToSrgb(green),
blue = linearToSrgb(blue),
alpha = alpha,
)

/**
* Convert this linear RGB color to OKLab using Bjorn Ottosson's published matrices.
*/
fun toOklab(): OklabColor = OklabColor.fromLinearRgb(this)

companion object {
/**
* Decode a packed sRGB [NeutralColor] into linear-light RGB.
*/
fun fromSrgb(color: NeutralColor): LinearRgbColor =
LinearRgbColor(
red = srgbToLinear(color.red),
green = srgbToLinear(color.green),
blue = srgbToLinear(color.blue),
)
}
}

/**
* OKLab color in perceptually uniform L, a, b coordinates.
*
* OKLab interpolation is useful when moving between distant hues because equal
* steps better match perceived color movement. Keep using [NeutralColor.lerp]
* or [ColorRamp.sample] for nearby hues and hot render-loop code; use
* [NeutralColor.lerpOklab] or [ColorRamp.sampleOklab] when clean perceptual
* handoffs matter more than the extra conversion cost.
*/
data class OklabColor(
val lightness: Float,
val a: Float,
val b: Float,
) {
/**
* Convert this OKLab color to linear RGB using Bjorn Ottosson's published matrices.
*/
fun toLinearRgb(): LinearRgbColor {
val lightness = lightness.toDouble()
val a = a.toDouble()
val b = b.toDouble()

val lPrime = lightness + (0.3963377774 * a) + (0.2158037573 * b)
val mPrime = lightness - (0.1055613458 * a) - (0.0638541728 * b)
val sPrime = lightness - (0.0894841775 * a) - (1.2914855480 * b)

val l = lPrime * lPrime * lPrime
val m = mPrime * mPrime * mPrime
val s = sPrime * sPrime * sPrime

return LinearRgbColor(
red = ((4.0767416621 * l) - (3.3077115913 * m) + (0.2309699292 * s)).toFloat(),
green = ((-1.2684380046 * l) + (2.6097574011 * m) - (0.3413193965 * s)).toFloat(),
blue = ((-0.0041960863 * l) - (0.7034186147 * m) + (1.7076147010 * s)).toFloat(),
)
}

/**
* Convert this OKLab color directly to packed sRGB.
*/
fun toSrgb(alpha: Float = 1f): NeutralColor = toLinearRgb().toSrgb(alpha)

companion object {
/**
* Convert packed sRGB to OKLab through linear RGB.
*/
fun fromSrgb(color: NeutralColor): OklabColor = LinearRgbColor.fromSrgb(color).toOklab()

/**
* Convert linear RGB to OKLab using Bjorn Ottosson's published matrices.
*/
fun fromLinearRgb(color: LinearRgbColor): OklabColor {
val red = color.red.toDouble()
val green = color.green.toDouble()
val blue = color.blue.toDouble()

val l = (0.4122214708 * red) + (0.5363325363 * green) + (0.0514459929 * blue)
val m = (0.2119034982 * red) + (0.6806995451 * green) + (0.1073969566 * blue)
val s = (0.0883024619 * red) + (0.2817188376 * green) + (0.6299787005 * blue)

val lPrime = signedCubeRoot(l)
val mPrime = signedCubeRoot(m)
val sPrime = signedCubeRoot(s)

return OklabColor(
lightness = ((0.2104542553 * lPrime) + (0.7936177850 * mPrime) - (0.0040720468 * sPrime)).toFloat(),
a = ((1.9779984951 * lPrime) - (2.4285922050 * mPrime) + (0.4505937099 * sPrime)).toFloat(),
b = ((0.0259040371 * lPrime) + (0.7827717662 * mPrime) - (0.8086757660 * sPrime)).toFloat(),
)
}

/**
* Interpolate between two OKLab colors in perceptual coordinates.
*/
fun lerp(
start: OklabColor,
end: OklabColor,
t: Float,
): OklabColor {
val clamped = t.coerceIn(0f, 1f)
return OklabColor(
lightness = interpolate(start.lightness, end.lightness, clamped),
a = interpolate(start.a, end.a, clamped),
b = interpolate(start.b, end.b, clamped),
)
}
}
}

private fun srgbToLinear(channel: Float): Float {
val value = channel.coerceIn(0f, 1f).toDouble()
return if (value <= 0.04045) {
(value / 12.92).toFloat()
} else {
(((value + 0.055) / 1.055).pow(2.4)).toFloat()
}
}

private fun linearToSrgb(channel: Float): Float {
val value = channel.toDouble()
return if (value <= 0.0031308) {
(12.92 * value).toFloat()
} else {
((1.055 * value.pow(1.0 / 2.4)) - 0.055).toFloat()
}
}

private fun signedCubeRoot(value: Double): Double =
when {
value < 0.0 -> -((-value).pow(1.0 / 3.0))
value > 0.0 -> value.pow(1.0 / 3.0)
else -> 0.0
}

private fun interpolate(
start: Float,
end: Float,
t: Float,
): Float = start + ((end - start) * t)
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package link.socket.phosphor.color

import kotlin.math.abs
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
import link.socket.phosphor.signal.CognitivePhase

class OklabColorTest {
@Test
fun `srgb to oklab to srgb preserves sampled colors`() {
val colors =
listOf(
NeutralColor.BLACK,
NeutralColor.WHITE,
NeutralColor.fromHex("#FF0000"),
NeutralColor.fromHex("#00FF00"),
NeutralColor.fromHex("#0000FF"),
NeutralColor.fromHex("#3366CC"),
NeutralColor.fromHex("#C89A65"),
NeutralColor.fromHex("#8C46AF"),
NeutralColor.fromHex("#22334480"),
)

colors.forEach { color ->
val roundTripped = OklabColor.fromSrgb(color).toSrgb(alpha = color.alpha)

assertColorClose(color, roundTripped, tolerance = 1e-4f)
}
}

@Test
fun `oklab midpoint avoids linear rgb amber to purple midpoint`() {
val amber = NeutralColor.fromHex("#C89A65")
val purple = NeutralColor.fromHex("#8C46AF")

val linearMidpoint = NeutralColor.lerp(amber, purple, 0.5f)
val perceptualMidpoint = NeutralColor.lerpOklab(amber, purple, 0.5f)

assertEquals("#AA708AFF", linearMidpoint.toHex())
assertEquals("#A87592FF", perceptualMidpoint.toHex())
assertNotEquals(linearMidpoint, perceptualMidpoint)
}

@Test
fun `oklab lerp preserves identity color`() {
val red = NeutralColor.fromHex("#FF0000")

assertEquals(red, NeutralColor.lerpOklab(red, red, 0.5f))
}

@Test
fun `oklab lerp from black to white produces perceptual neutral grey`() {
val midpoint = NeutralColor.lerpOklab(NeutralColor.BLACK, NeutralColor.WHITE, 0.5f)

assertEquals(midpoint.redInt, midpoint.greenInt)
assertEquals(midpoint.greenInt, midpoint.blueInt)
assertEquals("#636363FF", midpoint.toHex())
}

@Test
fun `ColorRamp sample remains linear rgb for existing ramps`() {
val ramps =
listOf(
CognitiveColorModel.phaseRampFor(CognitivePhase.PERCEIVE),
CognitiveColorModel.phaseRampFor(CognitivePhase.EXECUTE),
CognitiveColorModel.confidenceRamp,
CognitiveColorModel.flowIntensityRamp,
)
val samples = listOf(-1f, 0f, 0.125f, 0.5f, 0.875f, 1f, 2f)

ramps.forEach { ramp ->
samples.forEach { t ->
assertEquals(expectedLinearSample(ramp, t), ramp.sample(t))
}
}
}

@Test
fun `ColorRamp sampleOklab uses perceptual interpolation`() {
val ramp =
ColorRamp(
stops =
listOf(
NeutralColor.fromHex("#C89A65"),
NeutralColor.fromHex("#8C46AF"),
),
)

assertEquals(NeutralColor.lerpOklab(ramp.stops.first(), ramp.stops.last(), 0.5f), ramp.sampleOklab(0.5f))
}

private fun expectedLinearSample(
ramp: ColorRamp,
t: Float,
): NeutralColor {
val clamped = t.coerceIn(0f, 1f)
if (clamped <= 0f) return ramp.stops.first()
if (clamped >= 1f) return ramp.stops.last()

val lastIndex = ramp.stops.lastIndex
val scaled = clamped * lastIndex
val lower = scaled.toInt().coerceIn(0, lastIndex)
val upper = (lower + 1).coerceIn(0, lastIndex)

if (lower == upper) return ramp.stops[lower]

val localT = scaled - lower
val start = ramp.stops[lower]
val end = ramp.stops[upper]

return NeutralColor.fromRgba(
red = interpolate(start.red, end.red, localT),
green = interpolate(start.green, end.green, localT),
blue = interpolate(start.blue, end.blue, localT),
alpha = interpolate(start.alpha, end.alpha, localT),
)
}

private fun interpolate(
start: Float,
end: Float,
t: Float,
): Float = start + ((end - start) * t)

private fun assertColorClose(
expected: NeutralColor,
actual: NeutralColor,
tolerance: Float,
) {
assertTrue(abs(expected.red - actual.red) <= tolerance, "red expected ${expected.red}, got ${actual.red}")
assertTrue(
abs(expected.green - actual.green) <= tolerance,
"green expected ${expected.green}, got ${actual.green}",
)
assertTrue(abs(expected.blue - actual.blue) <= tolerance, "blue expected ${expected.blue}, got ${actual.blue}")
assertTrue(
abs(expected.alpha - actual.alpha) <= tolerance,
"alpha expected ${expected.alpha}, got ${actual.alpha}",
)
}
}
Loading