├── .gitignore ├── LICENSE ├── README.md ├── go.mod ├── gradient_test.go ├── hsv-comparison.png ├── oklab.go └── oklab_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | gradient.png 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Tom Lieber 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Oklab/Oklch color space for Go 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/alltom/oklab.svg)](https://pkg.go.dev/github.com/alltom/oklab) 4 | 5 | Oklab is a more perceptually-accurate color space than HSV. Oklch is the same space with polar coordinates. 6 | 7 | Great compatibility with Go's image library: 8 | 9 | * oklab.Oklab and oklab.Oklch implement image.Color 10 | * oklab.OklabModel and oklab.OklchModel implement image.Model 11 | 12 | See https://bottosson.github.io/posts/oklab/ for the details, but here's the tl;dr: 13 | 14 | ![Comparison of gradients created in Oklab and HSV color spaces with lightness and chroma held fixed—the Oklab image is significantly smoother](hsv-comparison.png) 15 | 16 | [This library is fully-documented, with examples](https://pkg.go.dev/github.com/alltom/oklab), but here's how to convert Oklab to RGB: 17 | 18 | ``` 19 | oklabc := oklab.Oklab{L: 0.9322421414586456, A: 0.03673270292094283, B: 0.0006123556644819055} 20 | r, g, b, _ := oklabc.RGBA() 21 | ``` 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/alltom/oklab 2 | 3 | go 1.15 4 | -------------------------------------------------------------------------------- /gradient_test.go: -------------------------------------------------------------------------------- 1 | package oklab_test 2 | 3 | import ( 4 | "fmt" 5 | "github.com/alltom/oklab" 6 | "image" 7 | "image/color" 8 | "image/png" 9 | "os" 10 | ) 11 | 12 | func Example_gradientImage() { 13 | f, _ := os.Create("gradient.png") 14 | png.Encode(f, GradientImage{}) 15 | fmt.Println("err =", f.Close()) 16 | // Output: err = 17 | } 18 | 19 | type GradientImage struct{} 20 | 21 | func (g GradientImage) ColorModel() color.Model { 22 | return oklab.OklabModel 23 | } 24 | 25 | func (g GradientImage) Bounds() image.Rectangle { 26 | return image.Rect(0, 0, 1200, 600) 27 | } 28 | 29 | func (g GradientImage) At(x, y int) color.Color { 30 | a := lerp(float64(x)/float64(g.Bounds().Dx()), -0.233888, 0.276216) 31 | b := lerp(float64(y)/float64(g.Bounds().Dy()), -0.311528, 0.198570) 32 | return oklab.Oklab{0.8, a, b} 33 | } 34 | 35 | func lerp(x, min, max float64) float64 { 36 | return x*(max-min) + min 37 | } 38 | -------------------------------------------------------------------------------- /hsv-comparison.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alltom/oklab/6f20a55ef9406acbbb05c5d44b53cb4a1c6303c7/hsv-comparison.png -------------------------------------------------------------------------------- /oklab.go: -------------------------------------------------------------------------------- 1 | // Package oklab implements the Oklab color space, as described at https://bottosson.github.io/posts/oklab/ 2 | package oklab 3 | 4 | // L: 0.000000–1.000000 5 | // A: -0.233888–0.276216 6 | // B: -0.311528–0.198570 7 | 8 | // L: 0.000000–1.000000 9 | // C: 0.000000–0.322491 10 | // H: -3.141592–3.141592 11 | 12 | import ( 13 | "image/color" 14 | "math" 15 | ) 16 | 17 | type Oklab struct { 18 | L float64 // Perceived lightness 19 | A float64 // How green/red the color is 20 | B float64 // How blue/yellow the color is 21 | } 22 | 23 | type Oklch struct { 24 | L float64 // Perceived lightness 25 | C float64 // Chroma 26 | H float64 // Hue 27 | } 28 | 29 | var OklabModel = color.ModelFunc(oklabModel) 30 | var OklchModel = color.ModelFunc(oklchModel) 31 | 32 | // See image.Color. 33 | func (c Oklab) RGBA() (uint32, uint32, uint32, uint32) { 34 | r, g, b := c.SRGB() 35 | r, g, b = clampf(r), clampf(g), clampf(b) 36 | return (uint32(0x1fffe*r) + 1) >> 1, (uint32(0x1fffe*g) + 1) >> 1, (uint32(0x1fffe*b) + 1) >> 1, 0xffff 37 | } 38 | 39 | // Convert to linear sRGB. 40 | // See https://bottosson.github.io/posts/oklab/ 41 | func (c Oklab) LinearSRGB() (float64, float64, float64) { 42 | l_ := c.L + 0.3963377774*c.A + 0.2158037573*c.B 43 | m_ := c.L - 0.1055613458*c.A - 0.0638541728*c.B 44 | s_ := c.L - 0.0894841775*c.A - 1.2914855480*c.B 45 | 46 | l := l_ * l_ * l_ 47 | m := m_ * m_ * m_ 48 | s := s_ * s_ * s_ 49 | 50 | r := 4.0767416621*l - 3.3077115913*m + 0.2309699292*s 51 | g := -1.2684380046*l + 2.6097574011*m - 0.3413193965*s 52 | b := -0.0041960863*l - 0.7034186147*m + 1.7076147010*s 53 | 54 | return r, g, b 55 | } 56 | 57 | // Convert to sRGB. 58 | // See https://bottosson.github.io/posts/colorwrong/#what-can-we-do%3F 59 | func (c Oklab) SRGB() (float64, float64, float64) { 60 | r, g, b := c.LinearSRGB() 61 | return linearSrgbToSrgb(r), linearSrgbToSrgb(g), linearSrgbToSrgb(b) 62 | } 63 | 64 | // Convert to LCh, which is Oklab in polar. 65 | func (c Oklab) Oklch() Oklch { 66 | return Oklch{ 67 | L: c.L, 68 | C: math.Sqrt(c.A*c.A + c.B*c.B), 69 | H: math.Atan2(c.B, c.A), 70 | } 71 | } 72 | 73 | // See image.Color. 74 | func (c Oklch) RGBA() (uint32, uint32, uint32, uint32) { 75 | return c.Oklab().RGBA() 76 | } 77 | 78 | // Convert to Oklab. 79 | func (c Oklch) Oklab() Oklab { 80 | return Oklab{ 81 | L: c.L, 82 | A: c.C * math.Cos(c.H), 83 | B: c.C * math.Sin(c.H), 84 | } 85 | } 86 | 87 | func oklabModel(c color.Color) color.Color { 88 | r8, g8, b8, a8 := c.RGBA() 89 | r := float64(r8) / float64(a8) 90 | g := float64(g8) / float64(a8) 91 | b := float64(b8) / float64(a8) 92 | 93 | r, g, b = srgbToLinearSrgb(r), srgbToLinearSrgb(g), srgbToLinearSrgb(b) 94 | 95 | l := 0.4122214708*r + 0.5363325363*g + 0.0514459929*b 96 | m := 0.2119034982*r + 0.6806995451*g + 0.1073969566*b 97 | s := 0.0883024619*r + 0.2817188376*g + 0.6299787005*b 98 | 99 | l_ := math.Cbrt(l) 100 | m_ := math.Cbrt(m) 101 | s_ := math.Cbrt(s) 102 | 103 | return Oklab{ 104 | L: 0.2104542553*l_ + 0.7936177850*m_ - 0.0040720468*s_, 105 | A: 1.9779984951*l_ - 2.4285922050*m_ + 0.4505937099*s_, 106 | B: 0.0259040371*l_ + 0.7827717662*m_ - 0.8086757660*s_, 107 | } 108 | } 109 | 110 | func oklchModel(c color.Color) color.Color { 111 | return oklabModel(c).(Oklab).Oklch() 112 | } 113 | 114 | // Convert a linear sRGB color component to sRGB. 115 | // See https://bottosson.github.io/posts/colorwrong/#what-can-we-do%3F 116 | func linearSrgbToSrgb(x float64) float64 { 117 | if x >= 0.0031308 { 118 | return 1.055*math.Pow(x, 1.0/2.4) - 0.055 119 | } 120 | return 12.92 * x 121 | } 122 | 123 | // Convert an sRGB color component to linear sRGB. 124 | // See https://bottosson.github.io/posts/colorwrong/#what-can-we-do%3F 125 | func srgbToLinearSrgb(x float64) float64 { 126 | if x >= 0.04045 { 127 | return math.Pow((x+0.055)/(1+0.055), 2.4) 128 | } 129 | return x / 12.92 130 | } 131 | 132 | func clampf(x float64) float64 { 133 | if x < 0 { 134 | return 0 135 | } else if x > 1 { 136 | return 1 137 | } 138 | return x 139 | } 140 | -------------------------------------------------------------------------------- /oklab_test.go: -------------------------------------------------------------------------------- 1 | package oklab_test 2 | 3 | import ( 4 | "fmt" 5 | "github.com/alltom/oklab" 6 | "image/color" 7 | "testing" 8 | ) 9 | 10 | func Example_convertToOklab() { 11 | rgbc := color.RGBA{0xff, 0xdf, 0xe7, 0xff} 12 | oklabc := oklab.OklabModel.Convert(rgbc).(oklab.Oklab) 13 | fmt.Printf("L: %.2f, a: %.2f, b: %.2f\n", oklabc.L, oklabc.A, oklabc.B) 14 | // Output: L: 0.93, a: 0.04, b: 0.00 15 | } 16 | 17 | func Example_convertOklabToRGB() { 18 | oklabc := oklab.Oklab{L: 0.9322421414586456, A: 0.03673270292094283, B: 0.0006123556644819055} 19 | r, g, b, _ := oklabc.RGBA() 20 | fmt.Printf("R: 0x%x, G: 0x%x, B: 0x%x\n", r>>8, g>>8, b>>8) 21 | // Output: R: 0xff, G: 0xdf, B: 0xe7 22 | } 23 | 24 | func TestOklabConversion(t *testing.T) { 25 | if testing.Short() { 26 | t.Skip("skipping testing in short mode") 27 | } 28 | 29 | var minL, maxL, minA, maxA, minB, maxB float64 30 | for r := 0; r <= 0xff; r++ { 31 | for g := 0; g <= 0xff; g++ { 32 | for b := 0; b <= 0xff; b++ { 33 | start := color.NRGBA{uint8(r), uint8(g), uint8(b), 0xff} 34 | oklab := oklab.OklabModel.Convert(start).(oklab.Oklab) 35 | final := color.NRGBAModel.Convert(oklab) 36 | if start != final { 37 | t.Errorf("rgb(oklab(%v)) = %v; want %v", start, final, start) 38 | } 39 | if r == 0 && g == 0 && b == 0 { 40 | minL, maxL = oklab.L, oklab.L 41 | minA, maxA = oklab.A, oklab.A 42 | minB, maxB = oklab.B, oklab.B 43 | } 44 | if oklab.L < minL { 45 | minL = oklab.L 46 | } 47 | if oklab.L > maxL { 48 | maxL = oklab.L 49 | } 50 | if oklab.A < minA { 51 | minA = oklab.A 52 | } 53 | if oklab.A > maxA { 54 | maxA = oklab.A 55 | } 56 | if oklab.B < minB { 57 | minB = oklab.B 58 | } 59 | if oklab.B > maxB { 60 | maxB = oklab.B 61 | } 62 | } 63 | } 64 | } 65 | t.Logf("L: %f–%f", minL, maxL) 66 | t.Logf("A: %f–%f", minA, maxA) 67 | t.Logf("B: %f–%f", minB, maxB) 68 | } 69 | 70 | func TestOklchConversion(t *testing.T) { 71 | if testing.Short() { 72 | t.Skip("skipping testing in short mode") 73 | } 74 | 75 | var minL, maxL, minC, maxC, minH, maxH float64 76 | for r := 0; r <= 0xff; r++ { 77 | for g := 0; g <= 0xff; g++ { 78 | for b := 0; b <= 0xff; b++ { 79 | start := color.NRGBA{uint8(r), uint8(g), uint8(b), 0xff} 80 | oklch := oklab.OklchModel.Convert(start).(oklab.Oklch) 81 | final := color.NRGBAModel.Convert(oklch) 82 | if start != final { 83 | t.Errorf("rgb(oklch(%v)) = %v; want %v", start, final, start) 84 | } 85 | if r == 0 && g == 0 && b == 0 { 86 | minL, maxL = oklch.L, oklch.L 87 | minC, maxC = oklch.C, oklch.C 88 | minH, maxH = oklch.H, oklch.H 89 | } 90 | if oklch.L < minL { 91 | minL = oklch.L 92 | } 93 | if oklch.L > maxL { 94 | maxL = oklch.L 95 | } 96 | if oklch.C < minC { 97 | minC = oklch.C 98 | } 99 | if oklch.C > maxC { 100 | maxC = oklch.C 101 | } 102 | if oklch.H < minH { 103 | minH = oklch.H 104 | } 105 | if oklch.H > maxH { 106 | maxH = oklch.H 107 | } 108 | } 109 | } 110 | } 111 | t.Logf("L: %f–%f", minL, maxL) 112 | t.Logf("C: %f–%f", minC, maxC) 113 | t.Logf("H: %f–%f", minH, maxH) 114 | } 115 | --------------------------------------------------------------------------------