Improved LCH support

This commit is contained in:
Eduard Urbach 2024-03-06 19:23:49 +01:00
parent 30838ca0b6
commit dae9993a96
Signed by: akyoto
GPG Key ID: C874F672B1AF20C0
5 changed files with 173 additions and 80 deletions

View File

@ -1,7 +1,6 @@
package color_test package color_test
import ( import (
"fmt"
"io" "io"
"testing" "testing"
@ -9,64 +8,71 @@ import (
"git.akyoto.dev/go/color" "git.akyoto.dev/go/color"
) )
func TestNoColors(t *testing.T) { func TestFprint(t *testing.T) {
color.Terminal = true
color.RGB(1, 0, 0).Fprint(io.Discard, "red")
color.RGB(0, 1, 0).Fprint(io.Discard, "green")
color.RGB(0, 0, 1).Fprint(io.Discard, "blue")
color.Terminal = false color.Terminal = false
testRGBColors(t)
testLCHColors(t) color.RGB(1, 0, 0).Fprint(io.Discard, "red")
color.RGB(0, 1, 0).Fprint(io.Discard, "green")
color.RGB(0, 0, 1).Fprint(io.Discard, "blue")
} }
func TestColors(t *testing.T) { func TestPrint(t *testing.T) {
color.Terminal = true
testRGBColors(t)
testLCHColors(t)
}
func TestRainbow(t *testing.T) {
color.Terminal = true color.Terminal = true
for chroma := range 5 { color.RGB(1, 0, 0).Print("red\n")
for lightness := range 21 { color.RGB(0, 1, 0).Print("green\n")
for hue := range 80 { color.RGB(0, 0, 1).Print("blue\n")
c := color.LCH(color.Value(lightness)*0.05, color.Value(chroma)*0.05, color.Value(hue)*4.5)
c.Print("█")
}
fmt.Println() color.Terminal = false
}
fmt.Println() color.RGB(1, 0, 0).Print("red\n")
} color.RGB(0, 1, 0).Print("green\n")
color.RGB(0, 0, 1).Print("blue\n")
} }
func testRGBColors(t *testing.T) { func TestPrintln(t *testing.T) {
color.Terminal = true
color.RGB(1, 0, 0).Println("red")
color.RGB(0, 1, 0).Println("green")
color.RGB(0, 0, 1).Println("blue")
color.Terminal = false
color.RGB(1, 0, 0).Println("red")
color.RGB(0, 1, 0).Println("green")
color.RGB(0, 0, 1).Println("blue")
}
func TestRGB(t *testing.T) {
color.Terminal = true
rgbColors := map[string]color.Color{ rgbColors := map[string]color.Color{
"RGB.black": color.RGB(0.0, 0.0, 0.0), "black": color.RGB(0, 0, 0),
"RGB.white": color.RGB(1.0, 1.0, 1.0), "white": color.RGB(1, 1, 1),
"RGB.red": color.RGB(1.0, 0.0, 0.0), "gray": color.RGB(0.5, 0.5, 0.5),
"RGB.green": color.RGB(0.0, 1.0, 0.0), "red": color.RGB(1, 0, 0),
"RGB.blue": color.RGB(0.0, 0.0, 1.0), "green": color.RGB(0, 1, 0),
"blue": color.RGB(0, 0, 1),
"cyan": color.RGB(0, 1, 1),
"yellow": color.RGB(1, 1, 0),
"orange": color.RGB(1, 0.5, 0),
"magenta": color.RGB(1, 0, 1),
} }
for name, c := range rgbColors { for name, c := range rgbColors {
testColor(t, c, name) testColorRange(t, c)
c.Println("█ " + name)
} }
} }
func testLCHColors(t *testing.T) { func testColorRange(t *testing.T, c color.Color) {
lchColors := map[string]color.Color{
"LCH.black": color.LCH(0.0, 0.0, 0.0),
"LCH.white": color.LCH(1.0, 0.0, 0.0),
"LCH.red": color.LCH(0.6, 0.5, 20.0),
"LCH.green": color.LCH(0.6, 0.5, 161.0),
"LCH.blue": color.LCH(0.6, 0.5, 240.0),
}
for name, c := range lchColors {
testColor(t, c, name)
}
}
func testColor(t *testing.T, c color.Color, name string) {
assert.True(t, c.R >= 0.0) assert.True(t, c.R >= 0.0)
assert.True(t, c.G >= 0.0) assert.True(t, c.G >= 0.0)
assert.True(t, c.B >= 0.0) assert.True(t, c.B >= 0.0)
@ -74,8 +80,4 @@ func testColor(t *testing.T, c color.Color, name string) {
assert.True(t, c.R <= 1.0) assert.True(t, c.R <= 1.0)
assert.True(t, c.G <= 1.0) assert.True(t, c.G <= 1.0)
assert.True(t, c.B <= 1.0) assert.True(t, c.B <= 1.0)
c.Fprint(io.Discard, name)
c.Print(name)
c.Println(name)
} }

65
LCH.go
View File

@ -4,24 +4,51 @@ import (
"math" "math"
) )
// LCH represents a color using lightness, chroma and hue (oklch). // LCH represents a color using lightness, chroma and hue.
func LCH(lightness Value, chroma Value, hue Value) Color { func LCH(lightness Value, chroma Value, hue Value) Color {
hue *= math.Pi / 180.0 lightness = min(max(lightness, 0), 1)
a := chroma * math.Cos(hue) hue *= math.Pi / 180
b := chroma * math.Sin(hue) a := math.Cos(hue)
b := math.Sin(hue)
r, g, b := labToRGB(lightness, a, b) if !inSRGB(lightness, a, b, chroma) {
chroma = findChromaInSRGB(chroma, lightness, a, b, 0.01)
}
r = gammaCorrection(r) a *= chroma
g = gammaCorrection(g) b *= chroma
b = gammaCorrection(b)
r, g, b := oklabToLinearRGB(lightness, a, b)
r = sRGB(r)
g = sRGB(g)
b = sRGB(b)
return Color{r, g, b} return Color{r, g, b}
} }
// LAB to linear sRGB // findChromaInSRGB tries to find the closest chroma that can be represented in sRGB color space.
func findChromaInSRGB(chroma Value, lightness Value, a Value, b Value, precision Value) Value {
high := chroma
low := 0.0
chroma *= 0.5
for high-low > precision {
if inSRGB(lightness, a, b, chroma) {
low = chroma
} else {
high = chroma
}
chroma = (high + low) * 0.5
}
return chroma
}
// OKLAB to linear RGB.
// Source: https://bottosson.github.io/posts/oklab/ // Source: https://bottosson.github.io/posts/oklab/
func labToRGB(lightness Value, a Value, b Value) (Value, Value, Value) { func oklabToLinearRGB(lightness Value, a Value, b Value) (Value, Value, Value) {
l := lightness + 0.3963377773761749*a + 0.21580375730991364*b l := lightness + 0.3963377773761749*a + 0.21580375730991364*b
m := lightness - 0.10556134581565857*a - 0.0638541728258133*b m := lightness - 0.10556134581565857*a - 0.0638541728258133*b
s := lightness - 0.08948418498039246*a - 1.2914855480194092*b s := lightness - 0.08948418498039246*a - 1.2914855480194092*b
@ -36,21 +63,3 @@ func labToRGB(lightness Value, a Value, b Value) (Value, Value, Value) {
return red, green, blue return red, green, blue
} }
// Gamma correction
// Source: IEC 61966-2-2
func gammaCorrection(x Value) Value {
if x < 0 {
return 0
}
if x <= 0.0031308 {
return 12.92 * x
}
if x < 1.0 {
return 1.055*math.Pow(x, 1/2.4) - 0.055
}
return 1.0
}

49
LCH_test.go Normal file
View File

@ -0,0 +1,49 @@
package color_test
import (
"fmt"
"testing"
"git.akyoto.dev/go/color"
)
func TestLCH(t *testing.T) {
color.Terminal = true
lchColors := map[string]color.Color{
"black": color.LCH(0.0, 0.0, 0),
"white": color.LCH(1.0, 0.0, 0),
"gray": color.LCH(0.5, 0.0, 0),
"pink": color.LCH(0.75, 1.0, 0),
"red": color.LCH(0.75, 1.0, 40),
"orange": color.LCH(0.75, 1.0, 60),
"yellow": color.LCH(0.9, 1.0, 100),
"green": color.LCH(0.75, 1.0, 150),
"blue": color.LCH(0.75, 1.0, 260),
"cyan": color.LCH(0.75, 1.0, 210),
"magenta": color.LCH(0.75, 1.0, 320),
}
for name, c := range lchColors {
testColorRange(t, c)
c.Println("█ " + name)
}
}
func TestLCHSpectrum(t *testing.T) {
color.Terminal = true
for chroma := range 4 {
for lightness := range 21 {
for hue := range 80 {
c := color.LCH(color.Value(lightness)*0.05, color.Value(chroma)*0.05, color.Value(hue)*4.4)
testColorRange(t, c)
c.Print("█")
}
fmt.Println()
}
fmt.Println()
}
}

View File

@ -5,7 +5,7 @@ Adds color to your terminal output.
## Features ## Features
- RGB color space - RGB color space
- OKLCH color space - LCH color space (oklch)
- Truecolor terminal output - Truecolor terminal output
## Installation ## Installation
@ -19,14 +19,20 @@ go get git.akyoto.dev/go/color
```go ```go
red := color.RGB(1.0, 0.0, 0.0) red := color.RGB(1.0, 0.0, 0.0)
red.Println("red text") red.Println("red text")
orange := color.LCH(0.7, 1.0, 65)
orange.Println("orange text")
``` ```
## Tests ## Tests
``` ```
PASS: TestNoColors PASS: TestFprint
PASS: TestColors PASS: TestPrint
PASS: TestRainbow PASS: TestPrintln
PASS: TestRGB
PASS: TestLCH
PASS: TestLCHSpectrum
coverage: 100.0% of statements coverage: 100.0% of statements
``` ```
@ -34,7 +40,7 @@ coverage: 100.0% of statements
``` ```
BenchmarkRGB-12 1000000000 0.3132 ns/op 0 B/op 0 allocs/op BenchmarkRGB-12 1000000000 0.3132 ns/op 0 B/op 0 allocs/op
BenchmarkLCH-12 11214220 107.1 ns/op 0 B/op 0 allocs/op BenchmarkLCH-12 4802006 249.8 ns/op 0 B/op 0 allocs/op
BenchmarkFprintColorized-12 6356535 188.4 ns/op 0 B/op 0 allocs/op BenchmarkFprintColorized-12 6356535 188.4 ns/op 0 B/op 0 allocs/op
BenchmarkFprintRaw-12 27374659 43.76 ns/op 0 B/op 0 allocs/op BenchmarkFprintRaw-12 27374659 43.76 ns/op 0 B/op 0 allocs/op
``` ```

27
sRGB.go Normal file
View File

@ -0,0 +1,27 @@
package color
import "math"
// inSRGB indicates whether the given color can be mapped to the sRGB color space.
func inSRGB(l Value, a Value, b Value, chroma Value) bool {
r, g, b := oklabToLinearRGB(l, a*chroma, b*chroma)
return r >= 0 && g >= 0 && b >= 0 && r <= 1 && g <= 1 && b <= 1
}
// sRGB performs gamma correction to convert linear RGB to sRGB.
// Source: IEC 61966-2-2
func sRGB(x Value) Value {
if x < 0 {
return 0
}
if x < 0.0031308 {
return 12.92 * x
}
if x < 1.0 {
return 1.055*math.Pow(x, 1/2.4) - 0.055
}
return 1.0
}