feat(theme): add perceptual color interpolation
- commit
- 914adb7bf44dc3252bd8a0503f68fad744a7e3d4
- author
- brice · · 2026-07-18 07:41 UTC
- parent
- 445049f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +209 -0
A internal/usecase/theme/color_oklab.go +108 -0
| @@ -0,0 +1,108 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // oklab is the perceptually uniform OKLab representation of an sRGB color. | |
| 10 | + | type 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. | |
| 17 | + | func 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. | |
| 35 | + | func 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 | + | ||
| 47 | + | func 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 | + | ||
| 51 | + | func okLabChroma(color oklab) float64 { | |
| 52 | + | return math.Hypot(color.A, color.B) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // okLabLerp interpolates sRGB endpoints in OKLab. Exact endpoints avoid | |
| 56 | + | // round-trip quantization changing terminal-provided colors. | |
| 57 | + | func okLabLerp(a, b renderer.RGB, weight float64) renderer.RGB { | |
| 58 | + | weight = clampUnit(weight) | |
| 59 | + | if weight == 0 { | |
| 60 | + | return a | |
| 61 | + | } | |
| 62 | + | if weight == 1 { | |
| 63 | + | return b | |
| 64 | + | } | |
| 65 | + | start := rgbToOKLab(a) | |
| 66 | + | end := rgbToOKLab(b) | |
| 67 | + | return okLabToRGB(oklab{ | |
| 68 | + | L: start.L + (end.L-start.L)*weight, | |
| 69 | + | A: start.A + (end.A-start.A)*weight, | |
| 70 | + | B: start.B + (end.B-start.B)*weight, | |
| 71 | + | }) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func srgbToLinear(channel uint8) float64 { | |
| 75 | + | value := float64(channel) / 255 | |
| 76 | + | if value <= 0.04045 { | |
| 77 | + | return value / 12.92 | |
| 78 | + | } | |
| 79 | + | return math.Pow((value+0.055)/1.055, 2.4) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func linearToSRGB(value float64) uint8 { | |
| 83 | + | value = clampUnit(value) | |
| 84 | + | if value <= 0.0031308 { | |
| 85 | + | value *= 12.92 | |
| 86 | + | } else { | |
| 87 | + | value = 1.055*math.Pow(value, 1/2.4) - 0.055 | |
| 88 | + | } | |
| 89 | + | return uint8(value*255 + 0.5) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func clampUnit(value float64) float64 { | |
| 93 | + | if math.IsNaN(value) || value <= 0 { | |
| 94 | + | return 0 | |
| 95 | + | } | |
| 96 | + | if value >= 1 { | |
| 97 | + | return 1 | |
| 98 | + | } | |
| 99 | + | return value | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func square(value float64) float64 { | |
| 103 | + | return value * value | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func cube(value float64) float64 { | |
| 107 | + | return value * value * value | |
| 108 | + | } | |
| @@ -0,0 +1,108 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // oklab is the perceptually uniform OKLab representation of an sRGB color. | |||
| 10 | + | type 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. | |||
| 17 | + | func 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. | |||
| 35 | + | func 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 | + | ||||
| 47 | + | func 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 | + | ||||
| 51 | + | func okLabChroma(color oklab) float64 { | |||
| 52 | + | return math.Hypot(color.A, color.B) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // okLabLerp interpolates sRGB endpoints in OKLab. Exact endpoints avoid | |||
| 56 | + | // round-trip quantization changing terminal-provided colors. | |||
| 57 | + | func okLabLerp(a, b renderer.RGB, weight float64) renderer.RGB { | |||
| 58 | + | weight = clampUnit(weight) | |||
| 59 | + | if weight == 0 { | |||
| 60 | + | return a | |||
| 61 | + | } | |||
| 62 | + | if weight == 1 { | |||
| 63 | + | return b | |||
| 64 | + | } | |||
| 65 | + | start := rgbToOKLab(a) | |||
| 66 | + | end := rgbToOKLab(b) | |||
| 67 | + | return okLabToRGB(oklab{ | |||
| 68 | + | L: start.L + (end.L-start.L)*weight, | |||
| 69 | + | A: start.A + (end.A-start.A)*weight, | |||
| 70 | + | B: start.B + (end.B-start.B)*weight, | |||
| 71 | + | }) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func srgbToLinear(channel uint8) float64 { | |||
| 75 | + | value := float64(channel) / 255 | |||
| 76 | + | if value <= 0.04045 { | |||
| 77 | + | return value / 12.92 | |||
| 78 | + | } | |||
| 79 | + | return math.Pow((value+0.055)/1.055, 2.4) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func linearToSRGB(value float64) uint8 { | |||
| 83 | + | value = clampUnit(value) | |||
| 84 | + | if value <= 0.0031308 { | |||
| 85 | + | value *= 12.92 | |||
| 86 | + | } else { | |||
| 87 | + | value = 1.055*math.Pow(value, 1/2.4) - 0.055 | |||
| 88 | + | } | |||
| 89 | + | return uint8(value*255 + 0.5) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func clampUnit(value float64) float64 { | |||
| 93 | + | if math.IsNaN(value) || value <= 0 { | |||
| 94 | + | return 0 | |||
| 95 | + | } | |||
| 96 | + | if value >= 1 { | |||
| 97 | + | return 1 | |||
| 98 | + | } | |||
| 99 | + | return value | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func square(value float64) float64 { | |||
| 103 | + | return value * value | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func cube(value float64) float64 { | |||
| 107 | + | return value * value * value | |||
| 108 | + | } | |||
A internal/usecase/theme/color_oklab_test.go +101 -0
| @@ -0,0 +1,101 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestRGBToOKLabKnownValues(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | rgb renderer.RGB | |
| 14 | + | want oklab | |
| 15 | + | }{ | |
| 16 | + | {name: "black", rgb: renderer.RGB{}, want: oklab{}}, | |
| 17 | + | {name: "white", rgb: renderer.RGB{R: 255, G: 255, B: 255}, want: oklab{L: 1}}, | |
| 18 | + | {name: "red", rgb: renderer.RGB{R: 255}, want: oklab{L: 0.627955, A: 0.224863, B: 0.125846}}, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | for _, tt := range tests { | |
| 22 | + | t.Run(tt.name, func(t *testing.T) { | |
| 23 | + | got := rgbToOKLab(tt.rgb) | |
| 24 | + | if math.Abs(got.L-tt.want.L) > 0.000001 || math.Abs(got.A-tt.want.A) > 0.000001 || math.Abs(got.B-tt.want.B) > 0.000001 { | |
| 25 | + | t.Fatalf("rgbToOKLab(%+v) = %+v, want %+v", tt.rgb, got, tt.want) | |
| 26 | + | } | |
| 27 | + | }) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestOKLabRGBRoundTrip(t *testing.T) { | |
| 32 | + | colors := []renderer.RGB{ | |
| 33 | + | {}, | |
| 34 | + | {R: 255, G: 255, B: 255}, | |
| 35 | + | {R: 255}, | |
| 36 | + | {G: 255}, | |
| 37 | + | {B: 255}, | |
| 38 | + | {R: 0x7d, G: 0xb5, B: 0xb5}, | |
| 39 | + | {R: 0x6c, G: 0x9b, B: 0xd9}, | |
| 40 | + | } | |
| 41 | + | for _, color := range colors { | |
| 42 | + | t.Run("round_trip", func(t *testing.T) { | |
| 43 | + | got := okLabToRGB(rgbToOKLab(color)) | |
| 44 | + | if channelDelta(got.R, color.R) > 1 || channelDelta(got.G, color.G) > 1 || channelDelta(got.B, color.B) > 1 { | |
| 45 | + | t.Fatalf("round trip %v = %v", color, got) | |
| 46 | + | } | |
| 47 | + | }) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestOKLabDistance(t *testing.T) { | |
| 52 | + | a := rgbToOKLab(renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}) | |
| 53 | + | b := rgbToOKLab(renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}) | |
| 54 | + | if got := okLabDistance(a, a); got != 0 { | |
| 55 | + | t.Fatalf("self distance = %v, want 0", got) | |
| 56 | + | } | |
| 57 | + | if got, want := okLabDistance(a, b), okLabDistance(b, a); math.Abs(got-want) > 1e-15 || got <= 0 { | |
| 58 | + | t.Fatalf("distance symmetry: %v != %v", got, want) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestOKLabLerpEndpointsAndMonotonicity(t *testing.T) { | |
| 63 | + | background := renderer.RGB{R: 0x08, G: 0x09, B: 0x0a} | |
| 64 | + | accent := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 65 | + | ||
| 66 | + | if got := okLabLerp(background, accent, 0); got != background { | |
| 67 | + | t.Fatalf("weight 0 = %v, want %v", got, background) | |
| 68 | + | } | |
| 69 | + | if got := okLabLerp(background, accent, 1); got != accent { | |
| 70 | + | t.Fatalf("weight 1 = %v, want %v", got, accent) | |
| 71 | + | } | |
| 72 | + | if got := okLabLerp(background, accent, -1); got != background { | |
| 73 | + | t.Fatalf("clamped low weight = %v, want %v", got, background) | |
| 74 | + | } | |
| 75 | + | if got := okLabLerp(background, accent, 2); got != accent { | |
| 76 | + | t.Fatalf("clamped high weight = %v, want %v", got, accent) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | previous := background | |
| 80 | + | for _, weight := range []float64{0.25, 0.5, 0.75, 1} { | |
| 81 | + | got := okLabLerp(background, accent, weight) | |
| 82 | + | if got.R < previous.R || got.G < previous.G || got.B < previous.B { | |
| 83 | + | t.Fatalf("weight %v = %v is not monotonic after %v", weight, got, previous) | |
| 84 | + | } | |
| 85 | + | previous = got | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestOKLabToRGBClampsOutOfGamutChannels(t *testing.T) { | |
| 90 | + | got := okLabToRGB(oklab{L: 0.7, A: 0.5, B: 0.5}) | |
| 91 | + | if got.R != 255 || got.G != 0 || got.B != 0 { | |
| 92 | + | t.Fatalf("out-of-gamut color = %v, want deterministic per-channel clamp to {255 0 0}", got) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func channelDelta(a, b uint8) uint8 { | |
| 97 | + | if a > b { | |
| 98 | + | return a - b | |
| 99 | + | } | |
| 100 | + | return b - a | |
| 101 | + | } | |
| @@ -0,0 +1,101 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestRGBToOKLabKnownValues(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | rgb renderer.RGB | |||
| 14 | + | want oklab | |||
| 15 | + | }{ | |||
| 16 | + | {name: "black", rgb: renderer.RGB{}, want: oklab{}}, | |||
| 17 | + | {name: "white", rgb: renderer.RGB{R: 255, G: 255, B: 255}, want: oklab{L: 1}}, | |||
| 18 | + | {name: "red", rgb: renderer.RGB{R: 255}, want: oklab{L: 0.627955, A: 0.224863, B: 0.125846}}, | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | for _, tt := range tests { | |||
| 22 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 23 | + | got := rgbToOKLab(tt.rgb) | |||
| 24 | + | if math.Abs(got.L-tt.want.L) > 0.000001 || math.Abs(got.A-tt.want.A) > 0.000001 || math.Abs(got.B-tt.want.B) > 0.000001 { | |||
| 25 | + | t.Fatalf("rgbToOKLab(%+v) = %+v, want %+v", tt.rgb, got, tt.want) | |||
| 26 | + | } | |||
| 27 | + | }) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestOKLabRGBRoundTrip(t *testing.T) { | |||
| 32 | + | colors := []renderer.RGB{ | |||
| 33 | + | {}, | |||
| 34 | + | {R: 255, G: 255, B: 255}, | |||
| 35 | + | {R: 255}, | |||
| 36 | + | {G: 255}, | |||
| 37 | + | {B: 255}, | |||
| 38 | + | {R: 0x7d, G: 0xb5, B: 0xb5}, | |||
| 39 | + | {R: 0x6c, G: 0x9b, B: 0xd9}, | |||
| 40 | + | } | |||
| 41 | + | for _, color := range colors { | |||
| 42 | + | t.Run("round_trip", func(t *testing.T) { | |||
| 43 | + | got := okLabToRGB(rgbToOKLab(color)) | |||
| 44 | + | if channelDelta(got.R, color.R) > 1 || channelDelta(got.G, color.G) > 1 || channelDelta(got.B, color.B) > 1 { | |||
| 45 | + | t.Fatalf("round trip %v = %v", color, got) | |||
| 46 | + | } | |||
| 47 | + | }) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestOKLabDistance(t *testing.T) { | |||
| 52 | + | a := rgbToOKLab(renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}) | |||
| 53 | + | b := rgbToOKLab(renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}) | |||
| 54 | + | if got := okLabDistance(a, a); got != 0 { | |||
| 55 | + | t.Fatalf("self distance = %v, want 0", got) | |||
| 56 | + | } | |||
| 57 | + | if got, want := okLabDistance(a, b), okLabDistance(b, a); math.Abs(got-want) > 1e-15 || got <= 0 { | |||
| 58 | + | t.Fatalf("distance symmetry: %v != %v", got, want) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestOKLabLerpEndpointsAndMonotonicity(t *testing.T) { | |||
| 63 | + | background := renderer.RGB{R: 0x08, G: 0x09, B: 0x0a} | |||
| 64 | + | accent := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |||
| 65 | + | ||||
| 66 | + | if got := okLabLerp(background, accent, 0); got != background { | |||
| 67 | + | t.Fatalf("weight 0 = %v, want %v", got, background) | |||
| 68 | + | } | |||
| 69 | + | if got := okLabLerp(background, accent, 1); got != accent { | |||
| 70 | + | t.Fatalf("weight 1 = %v, want %v", got, accent) | |||
| 71 | + | } | |||
| 72 | + | if got := okLabLerp(background, accent, -1); got != background { | |||
| 73 | + | t.Fatalf("clamped low weight = %v, want %v", got, background) | |||
| 74 | + | } | |||
| 75 | + | if got := okLabLerp(background, accent, 2); got != accent { | |||
| 76 | + | t.Fatalf("clamped high weight = %v, want %v", got, accent) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | previous := background | |||
| 80 | + | for _, weight := range []float64{0.25, 0.5, 0.75, 1} { | |||
| 81 | + | got := okLabLerp(background, accent, weight) | |||
| 82 | + | if got.R < previous.R || got.G < previous.G || got.B < previous.B { | |||
| 83 | + | t.Fatalf("weight %v = %v is not monotonic after %v", weight, got, previous) | |||
| 84 | + | } | |||
| 85 | + | previous = got | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestOKLabToRGBClampsOutOfGamutChannels(t *testing.T) { | |||
| 90 | + | got := okLabToRGB(oklab{L: 0.7, A: 0.5, B: 0.5}) | |||
| 91 | + | if got.R != 255 || got.G != 0 || got.B != 0 { | |||
| 92 | + | t.Fatalf("out-of-gamut color = %v, want deterministic per-channel clamp to {255 0 0}", got) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func channelDelta(a, b uint8) uint8 { | |||
| 97 | + | if a > b { | |||
| 98 | + | return a - b | |||
| 99 | + | } | |||
| 100 | + | return b - a | |||
| 101 | + | } | |||