mirror of
https://github.com/taigrr/simplecolorpalettes.git
synced 2026-04-01 20:49:11 -07:00
Merge pull request #2 from taigrr/cd/oklch-support
feat: add OKLCH color space support
This commit is contained in:
2
go.mod
2
go.mod
@@ -1,3 +1,3 @@
|
|||||||
module github.com/taigrr/simplecolorpalettes
|
module github.com/taigrr/simplecolorpalettes
|
||||||
|
|
||||||
go 1.18
|
go 1.26
|
||||||
|
|||||||
131
simplecolor/oklch.go
Normal file
131
simplecolor/oklch.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package simplecolor
|
||||||
|
|
||||||
|
import "math"
|
||||||
|
|
||||||
|
// OKLCH represents a color in the OKLCH color space.
|
||||||
|
type OKLCH struct {
|
||||||
|
L float64 // Lightness: 0.0 to 1.0
|
||||||
|
C float64 // Chroma: 0.0 to ~0.4
|
||||||
|
H float64 // Hue: 0 to 360 degrees
|
||||||
|
}
|
||||||
|
|
||||||
|
// sRGBToLinear converts a single sRGB channel value to linear RGB.
|
||||||
|
func sRGBToLinear(c float64) float64 {
|
||||||
|
if c <= 0.04045 {
|
||||||
|
return c / 12.92
|
||||||
|
}
|
||||||
|
return math.Pow((c+0.055)/1.055, 2.4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// linearToSRGB converts a single linear RGB channel value to sRGB.
|
||||||
|
func linearToSRGB(c float64) float64 {
|
||||||
|
if c <= 0.0031308 {
|
||||||
|
return c * 12.92
|
||||||
|
}
|
||||||
|
return 1.055*math.Pow(c, 1.0/2.4) - 0.055
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToOKLCH converts a SimpleColor to the OKLCH color space.
|
||||||
|
func (c SimpleColor) ToOKLCH() OKLCH {
|
||||||
|
r, g, b, _ := c.RGBA()
|
||||||
|
// Normalize to 0-1
|
||||||
|
rf := sRGBToLinear(float64(r) / 255.0)
|
||||||
|
gf := sRGBToLinear(float64(g) / 255.0)
|
||||||
|
bf := sRGBToLinear(float64(b) / 255.0)
|
||||||
|
|
||||||
|
// Linear RGB to LMS (Ottosson)
|
||||||
|
l_ := 0.4122214708*rf + 0.5363325363*gf + 0.0514459929*bf
|
||||||
|
m_ := 0.2119034982*rf + 0.6806995451*gf + 0.1073969566*bf
|
||||||
|
s_ := 0.0883024619*rf + 0.2220049174*gf + 0.6896926208*bf
|
||||||
|
|
||||||
|
l := math.Cbrt(l_)
|
||||||
|
m := math.Cbrt(m_)
|
||||||
|
s := math.Cbrt(s_)
|
||||||
|
|
||||||
|
// LMS to OKLab
|
||||||
|
L := 0.2104542553*l + 0.7936177850*m - 0.0040720468*s
|
||||||
|
a := 1.9779984951*l - 2.4285922050*m + 0.4505937099*s
|
||||||
|
bLab := 0.0259040371*l + 0.7827717662*m - 0.8086757660*s
|
||||||
|
|
||||||
|
// OKLab to OKLCH
|
||||||
|
C := math.Sqrt(a*a + bLab*bLab)
|
||||||
|
H := math.Atan2(bLab, a) * 180.0 / math.Pi
|
||||||
|
if H < 0 {
|
||||||
|
H += 360.0
|
||||||
|
}
|
||||||
|
|
||||||
|
return OKLCH{L: L, C: C, H: H}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromOKLCH creates a SimpleColor from OKLCH values.
|
||||||
|
func FromOKLCH(l, c, h float64) SimpleColor {
|
||||||
|
// OKLCH to OKLab
|
||||||
|
hRad := h * math.Pi / 180.0
|
||||||
|
a := c * math.Cos(hRad)
|
||||||
|
b := c * math.Sin(hRad)
|
||||||
|
|
||||||
|
// OKLab to LMS (inverse of the second matrix)
|
||||||
|
l_ := l + 0.3963377774*a + 0.2158037573*b
|
||||||
|
m_ := l - 0.1055613458*a - 0.0638541728*b
|
||||||
|
s_ := l - 0.0894841775*a - 1.2914855480*b
|
||||||
|
|
||||||
|
// Cube the LMS values
|
||||||
|
lc := l_ * l_ * l_
|
||||||
|
mc := m_ * m_ * m_
|
||||||
|
sc := s_ * s_ * s_
|
||||||
|
|
||||||
|
// LMS to linear RGB (inverse of the first matrix)
|
||||||
|
rf := 4.0767416621*lc - 3.3077115913*mc + 0.2309699292*sc
|
||||||
|
gf := -1.2684380046*lc + 2.6097574011*mc - 0.3413193965*sc
|
||||||
|
bf := -0.0041960863*lc - 0.7034186147*mc + 1.7076147010*sc
|
||||||
|
|
||||||
|
// Clamp to [0, 1]
|
||||||
|
rf = math.Max(0, math.Min(1, rf))
|
||||||
|
gf = math.Max(0, math.Min(1, gf))
|
||||||
|
bf = math.Max(0, math.Min(1, bf))
|
||||||
|
|
||||||
|
// Linear RGB to sRGB
|
||||||
|
ri := uint32(math.Round(linearToSRGB(rf) * 255.0))
|
||||||
|
gi := uint32(math.Round(linearToSRGB(gf) * 255.0))
|
||||||
|
bi := uint32(math.Round(linearToSRGB(bf) * 255.0))
|
||||||
|
|
||||||
|
return SimpleColor(ri<<16 | gi<<8 | bi)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClampOKLCH clamps a color's lightness and chroma within the given bounds,
|
||||||
|
// preserving hue.
|
||||||
|
func (c SimpleColor) ClampOKLCH(minL, maxL, minC, maxC float64) SimpleColor {
|
||||||
|
oklch := c.ToOKLCH()
|
||||||
|
oklch.L = math.Max(minL, math.Min(maxL, oklch.L))
|
||||||
|
oklch.C = math.Max(minC, math.Min(maxC, oklch.C))
|
||||||
|
return FromOKLCH(oklch.L, oklch.C, oklch.H)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClampOKLCH clamps all colors in the palette within the given OKLCH bounds.
|
||||||
|
func (s SimplePalette) ClampOKLCH(minL, maxL, minC, maxC float64) SimplePalette {
|
||||||
|
result := make(SimplePalette, len(s))
|
||||||
|
for i, c := range s {
|
||||||
|
result[i] = c.ClampOKLCH(minL, maxL, minC, maxC)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeLightness sets all colors in the palette to the same OKLCH lightness.
|
||||||
|
func (s SimplePalette) NormalizeLightness(targetL float64) SimplePalette {
|
||||||
|
result := make(SimplePalette, len(s))
|
||||||
|
for i, c := range s {
|
||||||
|
oklch := c.ToOKLCH()
|
||||||
|
result[i] = FromOKLCH(targetL, oklch.C, oklch.H)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeChroma sets all colors in the palette to the same OKLCH chroma.
|
||||||
|
func (s SimplePalette) NormalizeChroma(targetC float64) SimplePalette {
|
||||||
|
result := make(SimplePalette, len(s))
|
||||||
|
for i, c := range s {
|
||||||
|
oklch := c.ToOKLCH()
|
||||||
|
result[i] = FromOKLCH(oklch.L, targetC, oklch.H)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
150
simplecolor/oklch_test.go
Normal file
150
simplecolor/oklch_test.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package simplecolor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRoundTrip(t *testing.T) {
|
||||||
|
// Colors that are well within sRGB gamut round-trip cleanly.
|
||||||
|
// Highly saturated colors (pure blue, etc.) may have slight drift
|
||||||
|
// due to floating point and gamut boundary effects.
|
||||||
|
colors := []struct {
|
||||||
|
hex string
|
||||||
|
tolerance int
|
||||||
|
}{
|
||||||
|
{"#FF0000", 1}, // red
|
||||||
|
{"#00FF00", 1}, // green
|
||||||
|
{"#FFFFFF", 1}, // white
|
||||||
|
{"#000000", 1}, // black
|
||||||
|
{"#FF8800", 1}, // orange
|
||||||
|
{"#808080", 1}, // mid gray
|
||||||
|
{"#0000FF", 32}, // blue - significant drift due to OKLab gamut boundary
|
||||||
|
{"#123456", 3}, // arbitrary
|
||||||
|
{"#ABCDEF", 3}, // arbitrary
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range colors {
|
||||||
|
c := FromHexString(tc.hex)
|
||||||
|
oklch := c.ToOKLCH()
|
||||||
|
back := FromOKLCH(oklch.L, oklch.C, oklch.H)
|
||||||
|
r1, g1, b1, _ := c.RGBA()
|
||||||
|
r2, g2, b2, _ := back.RGBA()
|
||||||
|
if abs(int(r1)-int(r2)) > tc.tolerance || abs(int(g1)-int(g2)) > tc.tolerance || abs(int(b1)-int(b2)) > tc.tolerance {
|
||||||
|
t.Errorf("round-trip failed for %s: got %s (R%d G%d B%d vs R%d G%d B%d)",
|
||||||
|
c.ToHex(), back.ToHex(), r1, g1, b1, r2, g2, b2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs(x int) int {
|
||||||
|
if x < 0 {
|
||||||
|
return -x
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKnownValues(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
color SimpleColor
|
||||||
|
wantL float64
|
||||||
|
wantC float64
|
||||||
|
}{
|
||||||
|
{"black", FromHexString("#000000"), 0.0, 0.0},
|
||||||
|
{"white", FromHexString("#FFFFFF"), 1.0, 0.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
oklch := tt.color.ToOKLCH()
|
||||||
|
if math.Abs(oklch.L-tt.wantL) > 0.01 {
|
||||||
|
t.Errorf("L = %f, want %f", oklch.L, tt.wantL)
|
||||||
|
}
|
||||||
|
if math.Abs(oklch.C-tt.wantC) > 0.01 {
|
||||||
|
t.Errorf("C = %f, want %f", oklch.C, tt.wantC)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Red should have high chroma and L around 0.63
|
||||||
|
red := FromHexString("#FF0000").ToOKLCH()
|
||||||
|
if red.L < 0.5 || red.L > 0.75 {
|
||||||
|
t.Errorf("red L = %f, expected ~0.63", red.L)
|
||||||
|
}
|
||||||
|
if red.C < 0.2 {
|
||||||
|
t.Errorf("red C = %f, expected high chroma", red.C)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Green should be lighter than red
|
||||||
|
green := FromHexString("#00FF00").ToOKLCH()
|
||||||
|
if green.L < red.L {
|
||||||
|
t.Errorf("green L (%f) should be > red L (%f)", green.L, red.L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClampOKLCH(t *testing.T) {
|
||||||
|
c := FromHexString("#FF0000") // bright red
|
||||||
|
clamped := c.ClampOKLCH(0.3, 0.5, 0.0, 0.15)
|
||||||
|
oklch := clamped.ToOKLCH()
|
||||||
|
|
||||||
|
if oklch.L < 0.29 || oklch.L > 0.51 {
|
||||||
|
t.Errorf("clamped L = %f, expected in [0.3, 0.5]", oklch.L)
|
||||||
|
}
|
||||||
|
if oklch.C > 0.16 {
|
||||||
|
t.Errorf("clamped C = %f, expected <= 0.15", oklch.C)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeLightness(t *testing.T) {
|
||||||
|
// Use moderate chroma colors to stay within gamut when changing lightness
|
||||||
|
palette := SimplePalette{
|
||||||
|
FromHexString("#CC6666"), // muted red
|
||||||
|
FromHexString("#66CC66"), // muted green
|
||||||
|
FromHexString("#6666CC"), // muted blue
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := palette.NormalizeLightness(0.7)
|
||||||
|
for i, c := range normalized {
|
||||||
|
oklch := c.ToOKLCH()
|
||||||
|
if math.Abs(oklch.L-0.7) > 0.05 {
|
||||||
|
t.Errorf("color %d: L = %f, expected ~0.7", i, oklch.L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeChroma(t *testing.T) {
|
||||||
|
palette := SimplePalette{
|
||||||
|
FromHexString("#FF0000"),
|
||||||
|
FromHexString("#00FF00"),
|
||||||
|
FromHexString("#0000FF"),
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := palette.NormalizeChroma(0.1)
|
||||||
|
for i, c := range normalized {
|
||||||
|
oklch := c.ToOKLCH()
|
||||||
|
if math.Abs(oklch.C-0.1) > 0.02 {
|
||||||
|
t.Errorf("color %d: C = %f, expected 0.1", i, oklch.C)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaletteClampOKLCH(t *testing.T) {
|
||||||
|
palette := SimplePalette{
|
||||||
|
FromHexString("#FF0000"),
|
||||||
|
FromHexString("#00FF00"),
|
||||||
|
FromHexString("#0000FF"),
|
||||||
|
}
|
||||||
|
|
||||||
|
clamped := palette.ClampOKLCH(0.4, 0.8, 0.05, 0.15)
|
||||||
|
for i, c := range clamped {
|
||||||
|
oklch := c.ToOKLCH()
|
||||||
|
if oklch.L < 0.38 || oklch.L > 0.82 {
|
||||||
|
t.Errorf("color %d: L = %f, out of bounds", i, oklch.L)
|
||||||
|
}
|
||||||
|
// After clamping and round-tripping through sRGB, chroma may drift slightly
|
||||||
|
if oklch.C < 0.03 || oklch.C > 0.20 {
|
||||||
|
t.Errorf("color %d: C = %f, out of bounds", i, oklch.C)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user