color_oklab.go RawHistoryFolder
1package theme
2
3import (
4 "math"
5
6 renderer "github.com/bnema/vev-vt"
7)
8
9// oklab is the perceptually uniform OKLab representation of an sRGB color.
10type oklab struct {
11 L float64
12 A float64
13 B float64
14}
15
16// rgbToOKLab converts an 8-bit sRGB color to OKLab via linear sRGB and LMS.
17func rgbToOKLab(color renderer.RGB) oklab {
18 r := srgbToLinear(color.R)
19 g := srgbToLinear(color.G)
20 b := srgbToLinear(color.B)
21
22 l := math.Cbrt(0.4122214708*r + 0.5363325363*g + 0.0514459929*b)
23 m := math.Cbrt(0.2119034982*r + 0.6806995451*g + 0.1073969566*b)
24 s := math.Cbrt(0.0883024619*r + 0.2817188376*g + 0.6299787005*b)
25
26 return oklab{
27 L: 0.2104542553*l + 0.7936177850*m - 0.0040720468*s,
28 A: 1.9779984951*l - 2.4285922050*m + 0.4505937099*s,
29 B: 0.0259040371*l + 0.7827717662*m - 0.8086757660*s,
30 }
31}
32
33// okLabToRGB converts OKLab to sRGB. Linear channels are clamped before
34// transfer encoding so out-of-gamut intermediate values are deterministic.
35func okLabToRGB(color oklab) renderer.RGB {
36 l := cube(color.L + 0.3963377774*color.A + 0.2158037573*color.B)
37 m := cube(color.L - 0.1055613458*color.A - 0.0638541728*color.B)
38 s := cube(color.L - 0.0894841775*color.A - 1.2914855480*color.B)
39
40 return renderer.RGB{
41 R: linearToSRGB(4.0767416621*l - 3.3077115913*m + 0.2309699292*s),
42 G: linearToSRGB(-1.2684380046*l + 2.6097574011*m - 0.3413193965*s),
43 B: linearToSRGB(-0.0041960863*l - 0.7034186147*m + 1.7076147010*s),
44 }
45}
46
47func okLabDistance(a, b oklab) float64 {
48 return math.Sqrt(square(a.L-b.L) + square(a.A-b.A) + square(a.B-b.B))
49}
50
51func okLabChroma(color oklab) float64 {
52 return math.Hypot(color.A, color.B)
53}
54
55// oklch is the polar (lightness, chroma, hue) form of oklab. Hue is degrees
56// in [0, 360).
57type oklch struct {
58 L float64
59 C float64
60 H float64
61}
62
63// oklabToOKLCh converts to the polar form used to isolate hue from
64// lightness and chroma.
65func oklabToOKLCh(color oklab) oklch {
66 hue := math.Atan2(color.B, color.A) * 180 / math.Pi
67 if hue < 0 {
68 hue += 360
69 }
70 return oklch{L: color.L, C: okLabChroma(color), H: hue}
71}
72
73// oklchToOKLab converts back from the polar form.
74func oklchToOKLab(color oklch) oklab {
75 rad := color.H * math.Pi / 180
76 return oklab{L: color.L, A: color.C * math.Cos(rad), B: color.C * math.Sin(rad)}
77}
78
79// shiftHue replaces color's OKLCh hue with targetDegrees while preserving
80// its lightness and chroma, so the result stays in the same value/intensity
81// band as the source. A near-neutral source (chroma close to zero) yields a
82// near-neutral result regardless of the target: hue is not meaningful there.
83func shiftHue(color renderer.RGB, targetDegrees float64) renderer.RGB {
84 lch := oklabToOKLCh(rgbToOKLab(color))
85 lch.H = targetDegrees
86 return okLabToRGB(oklchToOKLab(lch))
87}
88
89// okLabLerp interpolates sRGB endpoints in OKLab. Exact endpoints avoid
90// round-trip quantization changing terminal-provided colors.
91func okLabLerp(a, b renderer.RGB, weight float64) renderer.RGB {
92 weight = clampUnit(weight)
93 if weight == 0 {
94 return a
95 }
96 if weight == 1 {
97 return b
98 }
99 start := rgbToOKLab(a)
100 end := rgbToOKLab(b)
101 return okLabToRGB(oklab{
102 L: start.L + (end.L-start.L)*weight,
103 A: start.A + (end.A-start.A)*weight,
104 B: start.B + (end.B-start.B)*weight,
105 })
106}
107
108func srgbToLinear(channel uint8) float64 {
109 value := float64(channel) / 255
110 if value <= 0.04045 {
111 return value / 12.92
112 }
113 return math.Pow((value+0.055)/1.055, 2.4)
114}
115
116func linearToSRGB(value float64) uint8 {
117 value = clampUnit(value)
118 if value <= 0.0031308 {
119 value *= 12.92
120 } else {
121 value = 1.055*math.Pow(value, 1/2.4) - 0.055
122 }
123 return uint8(value*255 + 0.5)
124}
125
126func clampUnit(value float64) float64 {
127 if math.IsNaN(value) || value <= 0 {
128 return 0
129 }
130 if value >= 1 {
131 return 1
132 }
133 return value
134}
135
136func square(value float64) float64 {
137 return value * value
138}
139
140func cube(value float64) float64 {
141 return value * value * value
142}