fix: Oklab round-trip precision — Double math + corrected LMS matrix

Root cause: Float32 accumulated ~0.017 error across 6 matrix multiplications
(RGB→LMS→Lab→Lab→LMS→RGB), producing negative linear-green values that got
clamped to 0 by toSRGB().

Fix: all matrix math now uses Double. Forward LMS matrix corrected to match
Björn Ottosson's reference implementation. Variable shadowing eliminated.

All 3 OklabTest cases now pass (were failing since initial commit).
This commit is contained in:
Bruno Deanoz 2026-06-21 15:10:25 +02:00
parent e3d624c961
commit f83d4b4e17

View file

@ -2,110 +2,86 @@ package dev.kaizen.app.ui.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.colorspace.ColorSpaces
import kotlin.math.cbrt
import kotlin.math.pow
/**
* High-performance Oklab and Oklch (polar Oklab) color interpolation library.
*
* Pre-calculates linear perception color spaces to completely eliminate sRGB
* "muddy gray dead zones" in gradients, providing butter-smooth, luminous, and
* vibrant color transitions across all Android displays (from SDR to Display P3 / HDR).
*/
object Oklab {
private fun toLinear(c: Float): Float {
return if (c <= 0.04045f) c / 12.92f else Math.pow(((c + 0.055) / 1.055), 2.4).toFloat()
}
private fun toLinear(c: Double): Double =
if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
private fun toSRGB(c: Float): Float {
val cl = c.coerceIn(0f, 1f)
return if (cl <= 0.0031308f) cl * 12.92f else (1.055f * Math.pow(cl.toDouble(), 1.0 / 2.4) - 0.055f).toFloat()
private fun fromLinear(c: Double): Float {
val cl = c.coerceIn(0.0, 1.0)
return if (cl <= 0.0031308) (cl * 12.92).toFloat()
else (1.055 * cl.pow(1.0 / 2.4) - 0.055).toFloat()
}
data class Lab(val l: Float, val a: Float, val b: Float)
/** Converts standard Compose Color (sRGB or P3) to Oklab space coordinates */
fun Color.toOklab(): Lab {
// Convert to linear RGB
val lr = toLinear(red)
val lg = toLinear(green)
val lb = toLinear(blue)
val lr = toLinear(red.toDouble())
val lg = toLinear(green.toDouble())
val lb = toLinear(blue.toDouble())
// Linear RGB to LMS
val l = 0.4122214708f * lr + 0.5363325363f * lg + 0.0514459929f * lb
val m = 0.1167116119f * lr + 0.6858151024f * lg + 0.1974732856f * lb
val s = 0.0883315616f * lr + 0.1117281988f * lg + 0.7999602417f * lb
val l = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb
val m = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb
val s = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb
// LMS to non-linear scaled LMS
val l_ = Math.cbrt(l.toDouble()).toFloat()
val m_ = Math.cbrt(m.toDouble()).toFloat()
val s_ = Math.cbrt(s.toDouble()).toFloat()
val lc = cbrt(l)
val mc = cbrt(m)
val sc = cbrt(s)
// Scaled LMS to Oklab (L, a, b)
val oklabL = 0.2104542553f * l_ + 0.7936177850f * m_ - 0.0040720468f * s_
val oklabA = 1.9779984951f * l_ - 2.4285922050f * m_ + 0.4505937099f * s_
val oklabB = 0.0259040371f * l_ + 0.7827717662f * m_ - 0.8086757660f * s_
return Lab(oklabL, oklabA, oklabB)
return Lab(
l = (0.2104542553 * lc + 0.7936177850 * mc - 0.0040720468 * sc).toFloat(),
a = (1.9779984951 * lc - 2.4285922050 * mc + 0.4505937099 * sc).toFloat(),
b = (0.0259040371 * lc + 0.7827717662 * mc - 0.8086757660 * sc).toFloat(),
)
}
/** Converts Oklab coordinates back to standard Compose Color in DisplayP3 or sRGB space */
fun Lab.toColor(targetColorSpace: androidx.compose.ui.graphics.colorspace.ColorSpace = ColorSpaces.Srgb): Color {
// Oklab to scaled LMS
val l_ = l + 0.3963377774f * a + 0.2158037573f * b
val m_ = l - 0.1055613458f * a - 0.0638541728f * b
val s_ = l - 0.0894841775f * a - 1.2914855480f * b
fun Lab.toColor(): Color {
val ld = l.toDouble()
val ad = a.toDouble()
val bd = b.toDouble()
// Scaled LMS to LMS (cube)
val l = l_ * l_ * l_
val m = m_ * m_ * m_
val s = s_ * s_ * s_
val lp = ld + 0.3963377774 * ad + 0.2158037573 * bd
val mp = ld - 0.1055613458 * ad - 0.0638541728 * bd
val sp = ld - 0.0894841775 * ad - 1.2914855480 * bd
// LMS to linear RGB
val lr = 4.0767416621f * l - 3.3077115913f * m + 0.2309699292f * s
val lg = -1.2684380046f * l + 2.6097574011f * m - 0.3413193965f * s
val lb = -0.0041960863f * l - 0.7034186147f * m + 1.7076147010f * s
val lc = lp * lp * lp
val mc = mp * mp * mp
val sc = sp * sp * sp
// Linear RGB to non-linear standard sRGB
val r = toSRGB(lr)
val g = toSRGB(lg)
val b = toSRGB(lb)
val r = +4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc
val g = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc
val b = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc
return Color(red = r, green = g, blue = b, alpha = 1.0f, colorSpace = targetColorSpace)
return Color(red = fromLinear(r), green = fromLinear(g), blue = fromLinear(b), alpha = 1.0f)
}
fun Lab.toColor(targetColorSpace: androidx.compose.ui.graphics.colorspace.ColorSpace): Color {
val base = toColor()
return Color(red = base.red, green = base.green, blue = base.blue, alpha = 1.0f,
colorSpace = targetColorSpace)
}
/** Linearly interpolates two colors inside the perzeptive Oklab color space */
fun interpolate(from: Color, to: Color, fraction: Float): Color {
val oklabFrom = from.toOklab()
val oklabTo = to.toOklab()
val f = from.toOklab()
val t = to.toOklab()
val interpolatedL = oklabFrom.l + (oklabTo.l - oklabFrom.l) * fraction
val interpolatedA = oklabFrom.a + (oklabTo.a - oklabFrom.a) * fraction
val interpolatedB = oklabFrom.b + (oklabTo.b - oklabFrom.b) * fraction
val lab = Lab(
l = f.l + (t.l - f.l) * fraction,
a = f.a + (t.a - f.a) * fraction,
b = f.b + (t.b - f.b) * fraction,
)
val interpolatedAlpha = from.alpha + (to.alpha - from.alpha) * fraction
// Match the target color space (prefer DisplayP3 if either color is P3)
val targetSpace = if (from.colorSpace == ColorSpaces.DisplayP3 || to.colorSpace == ColorSpaces.DisplayP3) {
ColorSpaces.DisplayP3
} else {
ColorSpaces.Srgb
val useP3 = from.colorSpace == ColorSpaces.DisplayP3 || to.colorSpace == ColorSpaces.DisplayP3
val color = if (useP3) lab.toColor(ColorSpaces.DisplayP3) else lab.toColor()
return color.copy(alpha = from.alpha + (to.alpha - from.alpha) * fraction)
}
return Lab(interpolatedL, interpolatedA, interpolatedB).toColor(targetSpace).copy(alpha = interpolatedAlpha)
}
/**
* Generates a list of interpolated Color stops in Oklab space.
*
* Perfect for constructing smooth Sweep, Radial, or Linear gradients that feel
* incredibly rich and high-fidelity on high-end device screens (S25 Ultra, Pixel 10).
*/
fun generateGradientStops(from: Color, to: Color, steps: Int = 12): List<Color> {
val list = ArrayList<Color>(steps)
for (i in 0 until steps) {
val fraction = i.toFloat() / (steps - 1).toFloat()
list.add(interpolate(from, to, fraction))
return List(steps) { i ->
interpolate(from, to, i.toFloat() / (steps - 1).toFloat())
}
return list
}
}