From 2410cce3427ae7ac982be5be197e8b1cb4da06c0 Mon Sep 17 00:00:00 2001 From: "Amy G. Dalin" Date: Thu, 27 Mar 2025 09:17:35 -0400 Subject: [PATCH] Added packages for linear grayscale colour varients. --- README.md | 3 + internal/helper/distance.go | 4 +- internal/helper/distance_test.go | 12 +-- internal/helper/enum.go | 66 ++++++++++++----- internal/helper/enum_test.go | 48 ++++++------ internal/helper/gamma.go | 14 +++- internal/helper/gamma_test.go | 56 ++++++++++++++ internal/helper/model.go | 20 ++--- internal/helper/model_test.go | 14 ++-- internal/helper/oklab_test.go | 6 +- internal/helper/test_test.go | 22 ------ internal/helper/xyz_test.go | 2 +- lgray/lgray.go | 82 +++++++++++++++++++++ lgray/lgray_test.go | 47 ++++++++++++ lgraya/lgraya.go | 123 +++++++++++++++++++++++++++++++ lgraya/lgraya_test.go | 53 +++++++++++++ lrgb/lrgb_test.go | 4 +- lrgba/lrgba_test.go | 4 +- nlgraya/nlgraya.go | 111 ++++++++++++++++++++++++++++ nlgraya/nlgraya_test.go | 53 +++++++++++++ nlrgba/nlrgba_test.go | 4 +- noklaba/noklaba_test.go | 4 +- oklab/oklab_test.go | 4 +- oklaba/oklaba_test.go | 4 +- 24 files changed, 654 insertions(+), 106 deletions(-) create mode 100644 lgray/lgray.go create mode 100644 lgray/lgray_test.go create mode 100644 lgraya/lgraya.go create mode 100644 lgraya/lgraya_test.go create mode 100644 nlgraya/nlgraya.go create mode 100644 nlgraya/nlgraya_test.go diff --git a/README.md b/README.md index b077bcf..c9cf73b 100644 --- a/README.md +++ b/README.md @@ -22,3 +22,6 @@ You can find the documentation at [pkg.go.dev](https://pkg.go.dev/smariot.com/co * *[smariot.com/color/oklab](https://pkg.go.dev/smariot.com/color/oklab)*: OkLab colour, no alpha. * *[smariot.com/color/oklaba](https://pkg.go.dev/smariot.com/color/oklaba)*: Premultiplied OkLab+Alpha color. * *[smariot.com/color/noklaba](https://pkg.go.dev/smariot.com/color/noklaba)*: Non-premultiplied OkLab+Alpha colour. +* *[smariot.com/color/lgray](https://pkg.go.dev/smariot.com/color/lgray)*: Linear grayscale colour, no alpha. +* *[smariot.com/color/lgraya](https://pkg.go.dev/smariot.com/color/lgraya)*: Premultiplied linear grayscale+alpha colour. +* *[smariot.com/color/nlgraya](https://pkg.go.dev/smariot.com/color/nlgraya)*: Non-premultiplied linear grayscale+alpha colour. diff --git a/internal/helper/distance.go b/internal/helper/distance.go index 9596744..4448a83 100644 --- a/internal/helper/distance.go +++ b/internal/helper/distance.go @@ -6,8 +6,8 @@ import ( "slices" ) -func TestDistance[T tester[T], C color.Color](t T, alpha bool, midpoint func(c0, c1 C) C, f func(c0, c1 C) float64, m color.Model) { - colors := slices.Collect(EnumColor[C](alpha, false, m)) +func TestDistance[T tester[T], C color.Color](t T, color, alpha bool, midpoint func(c0, c1 C) C, f func(c0, c1 C) float64, m color.Model) { + colors := slices.Collect(EnumColor[C](color, alpha, false, m)) for i, c0 := range colors { // a colour should have a distance of zero to itself. diff --git a/internal/helper/distance_test.go b/internal/helper/distance_test.go index 586ce0d..9702f60 100644 --- a/internal/helper/distance_test.go +++ b/internal/helper/distance_test.go @@ -20,13 +20,13 @@ func TestTestDistance(t *testing.T) { } mt.run("non-zero distance for identical colours", func(t *mockTest) { - TestDistance(t, true, midpoint, func(c0, c1 color.RGBA) float64 { + TestDistance(t, true, true, midpoint, func(c0, c1 color.RGBA) float64 { return 1 }, color.RGBAModel) }) mt.run("NaN distance", func(t *mockTest) { - TestDistance(t, true, midpoint, func(c0, c1 color.RGBA) float64 { + TestDistance(t, true, true, midpoint, func(c0, c1 color.RGBA) float64 { if c0 == c1 { return 0 } @@ -36,7 +36,7 @@ func TestTestDistance(t *testing.T) { }) mt.run("negative distance", func(t *mockTest) { - TestDistance(t, true, midpoint, func(c0, c1 color.RGBA) float64 { + TestDistance(t, true, true, midpoint, func(c0, c1 color.RGBA) float64 { if c0 == c1 { return 0 } @@ -46,7 +46,7 @@ func TestTestDistance(t *testing.T) { }) mt.run("asymmetric distance", func(t *mockTest) { - TestDistance(t, true, midpoint, func(c0, c1 color.RGBA) float64 { + TestDistance(t, true, true, midpoint, func(c0, c1 color.RGBA) float64 { if c0 == c1 { return 0 } @@ -60,7 +60,7 @@ func TestTestDistance(t *testing.T) { }) mt.run("triangle inequality", func(t *mockTest) { - TestDistance(t, true, midpoint, func(c0, c1 color.RGBA) float64 { + TestDistance(t, true, true, midpoint, func(c0, c1 color.RGBA) float64 { dR := int(c0.R) - int(c1.R) dG := int(c0.G) - int(c1.G) dB := int(c0.B) - int(c1.B) @@ -73,7 +73,7 @@ func TestTestDistance(t *testing.T) { }) mt.run("euclidean distance", func(t *mockTest) { - TestDistance(t, true, midpoint, func(c0, c1 color.RGBA) float64 { + TestDistance(t, true, true, midpoint, func(c0, c1 color.RGBA) float64 { dR := int(c0.R) - int(c1.R) dG := int(c0.G) - int(c1.G) dB := int(c0.B) - int(c1.B) diff --git a/internal/helper/enum.go b/internal/helper/enum.go index d7bc69b..d892ca8 100644 --- a/internal/helper/enum.go +++ b/internal/helper/enum.go @@ -1,47 +1,73 @@ package helper import ( - "image/color" + _color "image/color" "iter" ) // Enum iterates over a sparse sample of the RGBA colour space. // +// If color is true, the colours will have distinct RGB components. +// otherwise, the colours will have identical RGB components. +// // If alpha is true, the colours will include transparency, // otherwise the returned colours will be fully opaque. // // If slow is false, an even smaller number of samples will be returned // making this suitable for use in a nested loop. // -// alpha=true slow=true: 87481 samples. -// alpha=false slow=true: 140608 samples. -// alpha=true slow=false: 649 samples. -// alpha=false slow=false: 216 samples. -func Enum(alpha, slow bool) iter.Seq[color.RGBA64] { +// color=true alpha=true slow=true: 87481 samples. +// color=true alpha=false slow=true: 140608 samples. +// color=true alpha=true slow=false: 649 samples. +// color=true alpha=false slow=false: 216 samples. +func Enum(color, alpha, slow bool) iter.Seq[_color.RGBA64] { var aStart, aStep, cDiv uint32 switch { - case alpha && slow: + case color && alpha && slow: aStart, aStep, cDiv = 0, 0xffff/15, 17 - case alpha: // alpha && !slow + case color && alpha: // color && alpha && !slow aStart, aStep, cDiv = 0, 0xffff/3, 5 - case slow: // !alpha && slow + case color && slow: // color && !alpha && slow aStart, aStep, cDiv = 0xffff, 1, 51 - default: // !alpha && !slow + case color: // color && !alpha && !slow aStart, aStep, cDiv = 0xffff, 1, 5 + + case alpha && slow: // !color && alpha && slow + aStart, aStep, cDiv = 0, 0xffff/15, 4369 + case alpha: // !color && alpha && !slow + aStart, aStep, cDiv = 0, 0xffff/15, 17 + case slow: // !color && !alpha && slow + aStart, aStep, cDiv = 0xffff, 1, 65535 + default: // !color && !alpha && !slow + aStart, aStep, cDiv = 0xffff, 1, 257 } - return func(yield func(color.RGBA64) bool) { + if color { + return func(yield func(_color.RGBA64) bool) { + for a := aStart; a <= 0xffff; a += aStep { + cStep := max(1, a/cDiv) + + for b := uint32(0); b <= a; b += cStep { + for g := uint32(0); g <= a; g += cStep { + for r := uint32(0); r <= a; r += cStep { + if !yield(_color.RGBA64{uint16(r), uint16(g), uint16(b), uint16(a)}) { + return + } + } + } + } + } + } + } + + return func(yield func(_color.RGBA64) bool) { for a := aStart; a <= 0xffff; a += aStep { cStep := max(1, a/cDiv) - for b := uint32(0); b <= a; b += cStep { - for g := uint32(0); g <= a; g += cStep { - for r := uint32(0); r <= a; r += cStep { - if !yield(color.RGBA64{uint16(r), uint16(g), uint16(b), uint16(a)}) { - return - } - } + for c := uint32(0); c <= a; c += cStep { + if !yield(_color.RGBA64{uint16(c), uint16(c), uint16(c), uint16(a)}) { + return } } } @@ -49,9 +75,9 @@ func Enum(alpha, slow bool) iter.Seq[color.RGBA64] { } // EnumColor is identical to [Enum], but invokes a [color.Model] to return a concrete colour type. -func EnumColor[C color.Color](alpha, slow bool, m color.Model) iter.Seq[C] { +func EnumColor[C _color.Color](color, alpha, slow bool, m _color.Model) iter.Seq[C] { return func(yield func(C) bool) { - for rgba := range Enum(alpha, slow) { + for rgba := range Enum(color, alpha, slow) { if !yield(m.Convert(rgba).(C)) { return } diff --git a/internal/helper/enum_test.go b/internal/helper/enum_test.go index e12600a..b6a61a4 100644 --- a/internal/helper/enum_test.go +++ b/internal/helper/enum_test.go @@ -24,35 +24,39 @@ func eqRGBA64(a, b color.RGBA64) bool { func TestEnum(t *testing.T) { tests := []struct { - alpha, slow bool - expectedCount int + color, alpha, slow bool + expectedCount int }{ - {true, true, 87481}, - {false, true, 140608}, - {true, false, 649}, - {false, false, 216}, + {true, true, true, 87481}, + {true, false, true, 140608}, + {true, true, false, 649}, + {true, false, false, 216}, + {false, true, true, 65551}, + {false, false, true, 65536}, + {false, true, false, 271}, + {false, false, false, 258}, } for _, tt := range tests { - t.Run(fmt.Sprintf("alpha=%v, slow=%v", tt.alpha, tt.slow), func(t *testing.T) { + t.Run(fmt.Sprintf("color=%v, alpha=%v, slow=%v", tt.color, tt.alpha, tt.slow), func(t *testing.T) { t.Run("sequence meets expected criteria", func(t *testing.T) { - list := slices.Collect(Enum(tt.alpha, tt.slow)) + list := slices.Collect(Enum(tt.color, tt.alpha, tt.slow)) gotCount := len(list) if gotCount != tt.expectedCount { - t.Errorf("Enum(%v, %v) returned %d items, wanted %d", tt.alpha, tt.slow, gotCount, tt.expectedCount) + t.Errorf("Enum(%v, %v, %v) returned %d items, wanted %d", tt.color, tt.alpha, tt.slow, gotCount, tt.expectedCount) } slices.SortFunc(list, cmpRGBA64) list = slices.CompactFunc(list, eqRGBA64) if len(list) != gotCount { - t.Errorf("Enum(%v, %v) returned %d duplicate items", tt.alpha, tt.slow, gotCount-len(list)) + t.Errorf("Enum(%v, %v, %v) returned %d duplicate items", tt.color, tt.alpha, tt.slow, gotCount-len(list)) } listHasAlpha := false for _, c := range list { if c.A != 0xffff { if !tt.alpha { - t.Errorf("Enum(%v, %v) returned non-opaque color: %v", tt.alpha, tt.slow, c) + t.Errorf("Enum(%v, %v, %v) returned non-opaque color: %v", tt.color, tt.alpha, tt.slow, c) } listHasAlpha = true @@ -61,14 +65,14 @@ func TestEnum(t *testing.T) { } if !listHasAlpha && tt.alpha { - t.Errorf("Enum(%v, %v) didn't return non-opaque colors", tt.alpha, tt.slow) + t.Errorf("Enum(%v, %v, %v) didn't return non-opaque colors", tt.color, tt.alpha, tt.slow) } }) t.Run("cancel", func(t *testing.T) { // make sure cancelling the iteration doesn't panic. // But mostly, we want that sweet, sweet test coverage. - next, stop := iter.Pull(Enum(tt.alpha, tt.slow)) + next, stop := iter.Pull(Enum(tt.color, tt.alpha, tt.slow)) // need to invoke next to actually have the generated be started. if _, ok := next(); !ok { t.Error("iteration stopped before we could cancel it") @@ -82,19 +86,19 @@ func TestEnum(t *testing.T) { func TestEnumColor(t *testing.T) { tests := []struct { - alpha, slow bool + color, alpha, slow bool }{ - {true, true}, - {false, true}, - {true, false}, - {false, false}, + {true, true, true}, + {true, false, true}, + {true, true, false}, + {true, false, false}, } for _, tt := range tests { - t.Run(fmt.Sprintf("alpha=%v, slow=%v", tt.alpha, tt.slow), func(t *testing.T) { + t.Run(fmt.Sprintf("color=%v, alpha=%v, slow=%v", tt.color, tt.alpha, tt.slow), func(t *testing.T) { t.Run("sequence equivalence", func(t *testing.T) { - nextRGBA64, stop1 := iter.Pull(Enum(tt.alpha, tt.slow)) + nextRGBA64, stop1 := iter.Pull(Enum(tt.color, tt.alpha, tt.slow)) defer stop1() - nextNRGBA, stop2 := iter.Pull(EnumColor[color.NRGBA](tt.alpha, tt.slow, color.NRGBAModel)) + nextNRGBA, stop2 := iter.Pull(EnumColor[color.NRGBA](tt.color, tt.alpha, tt.slow, color.NRGBAModel)) defer stop2() for i := 0; ; i++ { @@ -119,7 +123,7 @@ func TestEnumColor(t *testing.T) { t.Run("cancel", func(t *testing.T) { // make sure cancelling the iteration doesn't panic. // But mostly, we want that sweet, sweet test coverage. - next, stop := iter.Pull(EnumColor[color.NRGBA](tt.alpha, tt.slow, color.NRGBAModel)) + next, stop := iter.Pull(EnumColor[color.NRGBA](tt.color, tt.alpha, tt.slow, color.NRGBAModel)) // need to invoke next to actually have the generated be started. if _, ok := next(); !ok { t.Error("iteration stopped before we could cancel it") diff --git a/internal/helper/gamma.go b/internal/helper/gamma.go index ee9d931..66d7360 100644 --- a/internal/helper/gamma.go +++ b/internal/helper/gamma.go @@ -4,8 +4,11 @@ import "math" // Linearize converts an sRGB component in the range [0, 0xffff] to a linearRGB component in the range [0, 1]. func Linearize(c uint32) float64 { - l := float64(c) / 0xffff + return LinearizeF(float64(c) / 0xffff) +} +// LinearizeF converts an sRGB component in the range [0, 1] to a linearRGB component in the range [0, 1]. +func LinearizeF(l float64) float64 { if l <= 0.039285714285714285714285714285714 { return l / 12.923210180787861094641554898407 } @@ -26,3 +29,12 @@ func Delinearize(l float64) uint32 { return uint32(69139.425*math.Pow(l, 1/2.4) - 3603.925) } } + +// Delinearize converts a linearRGB component in the range [0, 1] to an sRGB component in the range [0, 1]. +func DelinearizeF(l float64) float64 { + if l <= 0.0030399346397784299969770436366690 { + return l * 12.923210180787861094641554898407 + } + + return 1.055*math.Pow(l, 1/2.4) - 0.055 +} diff --git a/internal/helper/gamma_test.go b/internal/helper/gamma_test.go index 1e9541c..c85b15f 100644 --- a/internal/helper/gamma_test.go +++ b/internal/helper/gamma_test.go @@ -46,6 +46,30 @@ func TestLinearize(t *testing.T) { }) } +func TestLinearizeF(t *testing.T) { + // the positive finites were indirectly tested by TestLinearize, so we'll just test the + // negative and non-finite inputs. + tests := []struct { + value float64 + want float64 + }{ + {-1, -1 / 12.923210180787861094641554898407}, + {0, 0}, + {1, 1}, + {2, 4.9538457515920408157613451180477}, + {math.Inf(-1), math.Inf(-1)}, + {math.Inf(1), math.Inf(1)}, + {math.NaN(), math.NaN()}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("%x", tt.value), func(t *testing.T) { + if got := LinearizeF(tt.value); !EqFloat64Fuzzy(got, tt.want) { + t.Errorf("LinearizeF(%x) = %x: want %x", tt.value, got, tt.want) + } + }) + } +} + func TestDelinearize(t *testing.T) { tests := []struct { value float64 @@ -82,3 +106,35 @@ func TestDelinearize(t *testing.T) { } }) } + +func TestDelinearizeF(t *testing.T) { + // values that would have been impossible for Delinearize to represent. + tests := []struct { + value float64 + want float64 + }{ + {-1, -12.923210180787861094641554898407}, + {math.Inf(-1), math.Inf(-1)}, + {math.Inf(1), math.Inf(1)}, + {math.NaN(), math.NaN()}, + {2, 1.3532560461493862548965276346496}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("%x", tt.value), func(t *testing.T) { + if got := DelinearizeF(tt.value); !EqFloat64Fuzzy(got, tt.want) { + t.Errorf("DelinearizeF(%x) = %x: want %x", tt.value, got, tt.want) + } + }) + } + + t.Run("roundtrip conversions", func(t *testing.T) { + for c := -0x1000; c < 0x11000; c++ { + f := float64(c) / 0x10000 + got := DelinearizeF(LinearizeF(f)) + if !EqFloat64Fuzzy(got, f) { + t.Errorf("DelinearizeF(LinearizeF(%x)) != %x", c, got) + return + } + } + }) +} diff --git a/internal/helper/model.go b/internal/helper/model.go index a3cfe07..a67d10a 100644 --- a/internal/helper/model.go +++ b/internal/helper/model.go @@ -1,11 +1,11 @@ package helper import ( - "image/color" + _color "image/color" ) -func Model[C color.Color](fromColor func(color.Color) C) color.Model { - return color.ModelFunc(func(c color.Color) color.Color { +func Model[C _color.Color](fromColor func(_color.Color) C) _color.Model { + return _color.ModelFunc(func(c _color.Color) _color.Color { return fromColor(c) }) } @@ -13,7 +13,7 @@ func Model[C color.Color](fromColor func(color.Color) C) color.Model { // Interface that the colours used in this package are expected to implement. type Color interface { comparable - color.Color + _color.Color NRGBA() (r, g, b, a uint32) NLRGBA() (r, g, b, a float64) NXYZA() (x, y, z, a float64) @@ -22,13 +22,13 @@ type Color interface { type ConvertTest[C Color] struct { Name string - In color.Color + In _color.Color Out C } -func TestModel[T tester[T], C Color](t T, alpha bool, m color.Model, eq func(c0, c1 C) bool, extra []ConvertTest[C]) { +func TestModel[T tester[T], C Color](t T, color, alpha bool, m _color.Model, eq func(c0, c1 C) bool, extra []ConvertTest[C]) { t.Run("legal colours", func(t T) { - for wantRGBA := range Enum(alpha, true) { + for wantRGBA := range Enum(color, alpha, true) { _gotC := m.Convert(wantRGBA) gotC, ok := _gotC.(C) @@ -38,16 +38,16 @@ func TestModel[T tester[T], C Color](t T, alpha bool, m color.Model, eq func(c0, } r, g, b, a := gotC.RGBA() - gotRGBA := color.RGBA64{uint16(r), uint16(g), uint16(b), uint16(a)} + gotRGBA := _color.RGBA64{uint16(r), uint16(g), uint16(b), uint16(a)} if gotRGBA != wantRGBA { t.Errorf("%#+v.RGBA() = %v, want %v", gotC, gotRGBA, wantRGBA) return } - wantNRGBA := color.NRGBA64Model.Convert(wantRGBA) + wantNRGBA := _color.NRGBA64Model.Convert(wantRGBA) r, g, b, a = gotC.NRGBA() - gotNRGBA := color.NRGBA64{uint16(r), uint16(g), uint16(b), uint16(a)} + gotNRGBA := _color.NRGBA64{uint16(r), uint16(g), uint16(b), uint16(a)} if gotNRGBA != wantNRGBA { t.Errorf("%#+v.NRGBA() = %v, want %v", gotC, gotNRGBA, wantNRGBA) return diff --git a/internal/helper/model_test.go b/internal/helper/model_test.go index be3eef9..ce4c18f 100644 --- a/internal/helper/model_test.go +++ b/internal/helper/model_test.go @@ -90,31 +90,31 @@ func TestTestModel(t *testing.T) { mt := mockTester{t: t} mt.run("wrong colour type", func(t *mockTest) { - TestModel(t, true, color.RGBAModel, eq[nrgba64], nil) + TestModel(t, true, true, color.RGBAModel, eq[nrgba64], nil) }) mt.run("bad RGBA", func(t *mockTest) { - TestModel(t, false, Model(convert[nrgba64BadRGBA]), eq[nrgba64BadRGBA], nil) + TestModel(t, true, false, Model(convert[nrgba64BadRGBA]), eq[nrgba64BadRGBA], nil) }) mt.run("bad NRGBA", func(t *mockTest) { - TestModel(t, false, Model(convert[nrgba64BadNRGBA]), eq[nrgba64BadNRGBA], nil) + TestModel(t, true, false, Model(convert[nrgba64BadNRGBA]), eq[nrgba64BadNRGBA], nil) }) mt.run("bad NLRGBA", func(t *mockTest) { - TestModel(t, false, Model(convert[nrgba64BadNLRGBA]), eq[nrgba64BadNLRGBA], nil) + TestModel(t, true, false, Model(convert[nrgba64BadNLRGBA]), eq[nrgba64BadNLRGBA], nil) }) mt.run("bad NXYZA", func(t *mockTest) { - TestModel(t, false, Model(convert[nrgba64BadNXYZA]), eq[nrgba64BadNXYZA], nil) + TestModel(t, true, false, Model(convert[nrgba64BadNXYZA]), eq[nrgba64BadNXYZA], nil) }) mt.run("bad NOkLabA", func(t *mockTest) { - TestModel(t, false, Model(convert[nrgba64BadNOkLabA]), eq[nrgba64BadNOkLabA], nil) + TestModel(t, true, false, Model(convert[nrgba64BadNOkLabA]), eq[nrgba64BadNOkLabA], nil) }) mt.run("working model", func(t *mockTest) { - TestModel(t, true, Model(convert[nrgba64]), eq[nrgba64], []ConvertTest[nrgba64]{ + TestModel(t, true, true, Model(convert[nrgba64]), eq[nrgba64], []ConvertTest[nrgba64]{ {"good", color.NRGBA64{0x0123, 0x4567, 0x89ab, 0xcdef}, nrgba64{color.NRGBA64{0x0123, 0x4567, 0x89ab, 0xcdef}}}, {"bad", color.NRGBA64{0xcafe, 0xf00d, 0x54ac, 0xce55}, nrgba64{color.NRGBA64{0x0123, 0x4567, 0x89ab, 0xcdef}}}, }) diff --git a/internal/helper/oklab_test.go b/internal/helper/oklab_test.go index 8c27584..45fcf56 100644 --- a/internal/helper/oklab_test.go +++ b/internal/helper/oklab_test.go @@ -8,7 +8,7 @@ import ( ) func TestLMSToXYZ(t *testing.T) { - for c := range Enum(false, true) { + for c := range Enum(true, false, true) { want := collect3(LRGBtoXYZ(RGBtoLRGB(uint32(c.R), uint32(c.G), uint32(c.B)))) if got := collect3(LMStoXYZ(XYZtoLMS(want[0], want[1], want[2]))); !EqFloat64SliceFuzzy(want[:], got[:]) { @@ -19,7 +19,7 @@ func TestLMSToXYZ(t *testing.T) { } func TestLMSToLRGB(t *testing.T) { - for c := range Enum(false, true) { + for c := range Enum(true, false, true) { want := collect3(RGBtoLRGB(uint32(c.R), uint32(c.G), uint32(c.B))) l, m, s := LRGBtoLMS(want[0], want[1], want[2]) @@ -39,7 +39,7 @@ func TestLMSToLRGB(t *testing.T) { } func TestOKLabToLMS(t *testing.T) { - for c := range Enum(false, true) { + for c := range Enum(true, false, true) { want := collect3(LRGBtoLMS(RGBtoLRGB(uint32(c.R), uint32(c.G), uint32(c.B)))) if got := collect3(OkLabToLMS(LMStoOkLab(want[0], want[1], want[2]))); !EqFloat64SliceFuzzy(want[:], got[:]) { t.Errorf("OkLabToLMS(LMStoOKLab(%v)) = %v, want unchanged", want, got) diff --git a/internal/helper/test_test.go b/internal/helper/test_test.go index c78035b..e25df45 100644 --- a/internal/helper/test_test.go +++ b/internal/helper/test_test.go @@ -61,12 +61,6 @@ func (s *testStatus) setPanic(v any) { s.panicValue = v } -func (s *testStatus) getPanic() any { - s.m.Lock() - defer s.m.Unlock() - return s.panicValue -} - func (s *testStatus) setHandled() { s.m.Lock() defer s.m.Unlock() @@ -217,22 +211,6 @@ func (m *mockTester) expectFailedChildren(name string) { } } -// panic if the named test doesn't exist or didn't panic (and returns the panic value) -func (m *mockTester) expectPanic(name string) any { - if s := m.get(name); s != nil { - s.setHandled() - - if r := s.getPanic(); r != nil { - return r - } - - m.t.Errorf("%s: didn't panic", name) - s.log(m.t, name) - } - - return nil -} - // panic if the named test doesn't exist or has failed. func (m *mockTester) expectSuccess(name string) { if s := m.get(name); s != nil { diff --git a/internal/helper/xyz_test.go b/internal/helper/xyz_test.go index 39b5090..a6bc9d7 100644 --- a/internal/helper/xyz_test.go +++ b/internal/helper/xyz_test.go @@ -5,7 +5,7 @@ import ( ) func TestXYZtoLRGB(t *testing.T) { - for c := range Enum(false, true) { + for c := range Enum(true, false, true) { want := collect3(RGBtoLRGB(uint32(c.R), uint32(c.G), uint32(c.B))) if got := collect3(XYZtoLRGB(LRGBtoXYZ(want[0], want[1], want[2]))); !EqFloat64SliceFuzzy(want[:], got[:]) { t.Errorf("XYZtoLRGB(LRGBtoXYZ(%v)) = %v, want unchanged", want, got) diff --git a/lgray/lgray.go b/lgray/lgray.go new file mode 100644 index 0000000..fc2ce51 --- /dev/null +++ b/lgray/lgray.go @@ -0,0 +1,82 @@ +// Provides a [color.Color] type for dealing with linear grayscale colours without alpha. +package lgray + +import ( + "image/color" + "math" + + "smariot.com/color/internal/helper" +) + +// Color is a linear grayscale [color.Color]. +type Color struct { + Y float64 +} + +// DistanceSqr returns the euclidean distance squared between two colours. +func DistanceSqr(a, b Color) float64 { + dY := a.Y - b.Y + return dY * dY +} + +// Distance returns the euclidean distance between two colours, +// +// If you just want to compare relative distances, use [DistanceSqr] instead. +func Distance(a, b Color) float64 { + return math.Abs(a.Y - b.Y) +} + +// RGBA converts to premultiplied RGBA, implementing the [color.Color] interface. +func (c Color) RGBA() (r, g, b, a uint32) { + _y := helper.Delinearize(c.Y) + return _y, _y, _y, 0xffff +} + +// NRGBA converts to non-premultiplied RGBA. +func (c Color) NRGBA() (r, g, b, a uint32) { + _y := helper.Delinearize(c.Y) + return _y, _y, _y, 0xffff +} + +// NLRGBA converts to non-premultiplied linear RGBA. +func (c Color) NLRGBA() (r, g, b, a float64) { + return c.Y, c.Y, c.Y, 1 +} + +// NXYZA converts to non-premultiplied XYZ+Alpha. +func (c Color) NXYZA() (x, y, z, a float64) { + x, y, z = helper.LRGBtoXYZ(c.Y, c.Y, c.Y) + return x, y, z, 1 +} + +// NOkLabA converts to non-premultiplied OkLab+Alpha. +func (c Color) NOkLabA() (lightness, chromaA, chromaB, a float64) { + lightness, chromaA, chromaB = helper.LMStoOkLab(helper.LRGBtoLMS(c.Y, c.Y, c.Y)) + return lightness, chromaA, chromaB, 1 +} + +// Convert converts an arbitrary colour type to a linear RGB [Color]. +func Convert(c color.Color) Color { + if c, ok := c.(Color); ok { + return c + } + + r, g, b, _ := helper.ColorToNLRGBA(c) + + // the color.Gray16Model documents that it uses the coefficients 0.299, 0.5867, and 0.114. + // however, it does this using integer arithmetic, so the actual coefficients are effectively rounded to the nearest 1/0x10000. + + return Color{Y: helper.DelinearizeF( + helper.LinearizeF(r)*0x0.4c8bp0 + + helper.LinearizeF(g)*0x0.9646p0 + + helper.LinearizeF(b)*0x0.1d2fp0, + )} +} + +// A [color.Model] for converting arbitrary colours to a linear RGB [Color]. +// +// Wraps the [Convert] function, returning a [color.Color] interface rather than the [Color] type. +var Model = helper.Model(Convert) + +// Type assertion. +var _ color.Color = Color{} diff --git a/lgray/lgray_test.go b/lgray/lgray_test.go new file mode 100644 index 0000000..4f28ae7 --- /dev/null +++ b/lgray/lgray_test.go @@ -0,0 +1,47 @@ +package lgray + +import ( + "math" + "testing" + + "smariot.com/color/internal/helper" +) + +func eq(c0, c1 Color) bool { + return helper.EqFloat64Fuzzy(c0.Y, c1.Y) +} + +func midpoint(c0, c1 Color) Color { + return Color{(c0.Y + c1.Y) / 2} +} + +func TestModel(t *testing.T) { + helper.TestModel(t, false, false, Model, eq, []helper.ConvertTest[Color]{ + { + Name: "passthrough +inf", + In: Color{math.Inf(1)}, + Out: Color{math.Inf(1)}, + }, { + Name: "passthrough -inf", + In: Color{math.Inf(-1)}, + Out: Color{math.Inf(-1)}, + }, { + Name: "passthrough nan", + In: Color{math.NaN()}, + Out: Color{math.NaN()}, + }, + }) +} + +func distance(a, b Color) float64 { + d := Distance(a, b) + dSqr := DistanceSqr(a, b) + if !helper.EqFloat64Fuzzy(d*d, dSqr) { + panic("Distance and DistanceSqr are not equivalent") + } + return d +} + +func TestDistance(t *testing.T) { + helper.TestDistance(t, false, false, midpoint, distance, Model) +} diff --git a/lgraya/lgraya.go b/lgraya/lgraya.go new file mode 100644 index 0000000..1888897 --- /dev/null +++ b/lgraya/lgraya.go @@ -0,0 +1,123 @@ +// Provides a [color.Color] type for dealing with premultiplied linear grayscale+alpha colours. +package lgraya + +import ( + "image/color" + "math" + + "smariot.com/color/internal/helper" +) + +// Color is a pre-multiplied linear grayscale+alpha [color.Color]. +type Color struct { + Y, A float64 +} + +func sqr(a float64) float64 { + return a * a +} + +// DistanceSqr returns the maximum possible euclidean distance squared between two colours, +// accounting for the possible backgrounds they might be composited over. +func DistanceSqr(a, b Color) float64 { + dY := a.Y - b.Y + dA := a.A - b.A + return max(sqr(dY), sqr(dY+dA)) +} + +// Distance returns the maximum possible euclidean distance between two colours, +// accounting for the possible backgrounds they might be composited over. +func Distance(a, b Color) float64 { + dY := a.Y - b.Y + dA := a.A - b.A + return max(math.Abs(dY), math.Abs(dY+dA)) +} + +// YA converts to premultiplied sRGB grayscale+alpha. +func (c Color) YA() (y, a uint32) { + y, a = c.NYA() + y = y * a / 0xffff + return +} + +// NYA converts to non-premultiplied sRGB grayscale+alpha. +func (c Color) NYA() (y, a uint32) { + _y, _a := c.NLYA() + return helper.Delinearize(_y), uint32(_a*0xffff + 0.5) +} + +// NLYA converts to non-premultiplied linear grayscale+alpha. +func (c Color) NLYA() (y, a float64) { + if c.A <= 0 { + return 0, 0 + } + + return c.Y / c.A, c.A +} + +// RGBA converts to premultiplied RGBA, implementing the [color.Color] interface. +func (c Color) RGBA() (r, g, b, a uint32) { + y, a := c.YA() + return y, y, y, a +} + +// NRGBA converts to non-premultiplied RGBA. +func (c Color) NRGBA() (r, g, b, a uint32) { + y, a := c.NYA() + return y, y, y, a +} + +// NLRGBA converts to non-premultiplied linear RGBA. +func (c Color) NLRGBA() (r, g, b, a float64) { + y, a := c.NLYA() + return y, y, y, a +} + +// NXYZA converts to non-premultiplied XYZ+Alpha. +func (c Color) NXYZA() (x, y, z, a float64) { + if c.A <= 0 { + return 0, 0, 0, 0 + } + + x, y, z = helper.LRGBtoXYZ(c.Y/c.A, c.Y/c.A, c.Y/c.A) + return x, y, z, c.A +} + +// NOkLabA converts to non-premultiplied OkLab+Alpha. +func (c Color) NOkLabA() (lightness, chromaA, chromaB, a float64) { + if c.A <= 0 { + return 0, 0, 0, 0 + } + + lightness, chromaA, chromaB = helper.LMStoOkLab(helper.LRGBtoLMS(c.Y/c.A, c.Y/c.A, c.Y/c.A)) + return lightness, chromaA, chromaB, c.A +} + +// Convert converts an arbitrary colour type to a premultiplied linear RGBA [Color]. +func Convert(c color.Color) Color { + if c, ok := c.(Color); ok { + return c + } + + r, g, b, a := helper.ColorToNLRGBA(c) + + // the color.Gray16Model documents that it uses the coefficients 0.299, 0.5867, and 0.114. + // however, it does this using integer arithmetic, so the actual coefficients are effectively rounded to the nearest 1/0x10000. + + return Color{ + Y: helper.DelinearizeF( + helper.LinearizeF(r)*0x0.4c8bp0+ + helper.LinearizeF(g)*0x0.9646p0+ + helper.LinearizeF(b)*0x0.1d2fp0, + ) * a, + A: a, + } +} + +// A [color.Model] for converting arbitrary colours to a premultiplied linear RGBA [Color]. +// +// Wraps the [Convert] function, returning a [color.Color] interface rather than the [Color] type. +var Model = helper.Model(Convert) + +// Type assertion. +var _ color.Color = Color{} diff --git a/lgraya/lgraya_test.go b/lgraya/lgraya_test.go new file mode 100644 index 0000000..463da0e --- /dev/null +++ b/lgraya/lgraya_test.go @@ -0,0 +1,53 @@ +package lgraya + +import ( + "math" + "testing" + + "smariot.com/color/internal/helper" +) + +func eq(c0, c1 Color) bool { + return helper.EqFloat64SliceFuzzy( + []float64{c0.Y, c0.A}, + []float64{c1.Y, c1.A}, + ) +} + +func midpoint(c0, c1 Color) Color { + return Color{(c0.Y + c1.Y) / 2, (c0.A + c1.A) / 2} +} + +func TestModel(t *testing.T) { + helper.TestModel(t, false, true, Model, eq, []helper.ConvertTest[Color]{ + // These is a very illegal colour. If it makes it through + // unchanged, we can be reasonably confident no colour space conversions were + // attempted. + { + Name: "passthrough +inf", + In: Color{math.Inf(1), 0}, + Out: Color{math.Inf(1), 0}, + }, { + Name: "passthrough +inf", + In: Color{math.Inf(-1), math.NaN()}, + Out: Color{math.Inf(-1), math.NaN()}, + }, { + Name: "passthrough nan", + In: Color{math.NaN(), math.Inf(1)}, + Out: Color{math.NaN(), math.Inf(1)}, + }, + }) +} + +func distance(a, b Color) float64 { + d := Distance(a, b) + dSqr := DistanceSqr(a, b) + if !helper.EqFloat64Fuzzy(d*d, dSqr) { + panic("Distance and DistanceSqr are not equivalent") + } + return d +} + +func TestDistance(t *testing.T) { + helper.TestDistance(t, false, true, midpoint, distance, Model) +} diff --git a/lrgb/lrgb_test.go b/lrgb/lrgb_test.go index 03c09ce..44e0b75 100644 --- a/lrgb/lrgb_test.go +++ b/lrgb/lrgb_test.go @@ -19,7 +19,7 @@ func midpoint(c0, c1 Color) Color { } func TestModel(t *testing.T) { - helper.TestModel(t, false, Model, eq, []helper.ConvertTest[Color]{ + helper.TestModel(t, true, false, Model, eq, []helper.ConvertTest[Color]{ { Name: "passthrough", In: Color{math.Inf(1), math.Inf(-1), math.NaN()}, @@ -29,5 +29,5 @@ func TestModel(t *testing.T) { } func TestDistance(t *testing.T) { - helper.TestDistance(t, false, midpoint, Distance, Model) + helper.TestDistance(t, true, false, midpoint, Distance, Model) } diff --git a/lrgba/lrgba_test.go b/lrgba/lrgba_test.go index 266878f..96ea218 100644 --- a/lrgba/lrgba_test.go +++ b/lrgba/lrgba_test.go @@ -19,7 +19,7 @@ func midpoint(c0, c1 Color) Color { } func TestModel(t *testing.T) { - helper.TestModel(t, true, Model, eq, []helper.ConvertTest[Color]{ + helper.TestModel(t, true, true, Model, eq, []helper.ConvertTest[Color]{ { Name: "passthrough", // These is a very illegal colour. If it makes it through @@ -32,5 +32,5 @@ func TestModel(t *testing.T) { } func TestDistance(t *testing.T) { - helper.TestDistance(t, true, midpoint, Distance, Model) + helper.TestDistance(t, true, true, midpoint, Distance, Model) } diff --git a/nlgraya/nlgraya.go b/nlgraya/nlgraya.go new file mode 100644 index 0000000..ceaaf04 --- /dev/null +++ b/nlgraya/nlgraya.go @@ -0,0 +1,111 @@ +// Provides a [color.Color] type for dealing with non-premultiplied linear grayscale+alpha colours. +package nlgraya + +import ( + "image/color" + "math" + + "smariot.com/color/internal/helper" +) + +// Color is a non-premultiplied linear grayscale+alpha [color.Color]. +type Color struct { + Y, A float64 +} + +func sqr(a float64) float64 { + return a * a +} + +// DistanceSqr returns the maximum possible euclidean distance squared between two colours, +// accounting for the possible backgrounds they might be composited over. +func DistanceSqr(a, b Color) float64 { + dY := a.Y*a.A - b.Y*b.A + dA := a.A - b.A + return max(sqr(dY), sqr(dY+dA)) +} + +// Distance returns the maximum possible euclidean distance between two colours, +// accounting for the possible backgrounds they might be composited over. +func Distance(a, b Color) float64 { + dY := a.Y*a.A - b.Y*b.A + dA := a.A - b.A + return max(math.Abs(dY), math.Abs(dY+dA)) +} + +// YA converts to premultiplied sRGB grayscale+alpha. +func (c Color) YA() (y, a uint32) { + y, a = c.NYA() + y = y * a / 0xffff + return +} + +// NYA converts to non-premultiplied sRGB grayscale+alpha. +func (c Color) NYA() (y, a uint32) { + _y, _a := c.NLYA() + return helper.Delinearize(_y), uint32(_a*0xffff + 0.5) +} + +// NLYA converts to non-premultiplied linear grayscale+alpha. +func (c Color) NLYA() (y, a float64) { + return c.Y, c.A +} + +// RGBA converts to premultiplied RGBA, implementing the [color.Color] interface. +func (c Color) RGBA() (r, g, b, a uint32) { + y, a := c.YA() + return y, y, y, a +} + +// NRGBA converts to non-premultiplied RGBA. +func (c Color) NRGBA() (r, g, b, a uint32) { + y, a := c.NYA() + return y, y, y, a +} + +// NLRGBA converts to non-premultiplied linear RGBA. +func (c Color) NLRGBA() (r, g, b, a float64) { + y, a := c.NLYA() + return y, y, y, a +} + +// NXYZA converts to non-premultiplied XYZ+Alpha. +func (c Color) NXYZA() (x, y, z, a float64) { + x, y, z = helper.LRGBtoXYZ(c.Y, c.Y, c.Y) + return x, y, z, c.A +} + +// NOkLabA converts to non-premultiplied OkLab+Alpha. +func (c Color) NOkLabA() (lightness, chromaA, chromaB, a float64) { + lightness, chromaA, chromaB = helper.LMStoOkLab(helper.LRGBtoLMS(c.Y, c.Y, c.Y)) + return lightness, chromaA, chromaB, c.A +} + +// Convert converts an arbitrary colour type to a premultiplied linear RGBA [Color]. +func Convert(c color.Color) Color { + if c, ok := c.(Color); ok { + return c + } + + r, g, b, a := helper.ColorToNLRGBA(c) + + // the color.Gray16Model documents that it uses the coefficients 0.299, 0.5867, and 0.114. + // however, it does this using integer arithmetic, so the actual coefficients are effectively rounded to the nearest 1/0x10000. + + return Color{ + Y: helper.DelinearizeF( + helper.LinearizeF(r)*0x0.4c8bp0 + + helper.LinearizeF(g)*0x0.9646p0 + + helper.LinearizeF(b)*0x0.1d2fp0, + ), + A: a, + } +} + +// A [color.Model] for converting arbitrary colours to a premultiplied linear RGBA [Color]. +// +// Wraps the [Convert] function, returning a [color.Color] interface rather than the [Color] type. +var Model = helper.Model(Convert) + +// Type assertion. +var _ color.Color = Color{} diff --git a/nlgraya/nlgraya_test.go b/nlgraya/nlgraya_test.go new file mode 100644 index 0000000..b608056 --- /dev/null +++ b/nlgraya/nlgraya_test.go @@ -0,0 +1,53 @@ +package nlgraya + +import ( + "math" + "testing" + + "smariot.com/color/internal/helper" +) + +func eq(c0, c1 Color) bool { + return helper.EqFloat64SliceFuzzy( + []float64{c0.Y, c0.A}, + []float64{c1.Y, c1.A}, + ) +} + +func midpoint(c0, c1 Color) Color { + return Color{(c0.Y + c1.Y) / 2, (c0.A + c1.A) / 2} +} + +func TestModel(t *testing.T) { + helper.TestModel(t, false, true, Model, eq, []helper.ConvertTest[Color]{ + // These is a very illegal colour. If it makes it through + // unchanged, we can be reasonably confident no colour space conversions were + // attempted. + { + Name: "passthrough +inf", + In: Color{math.Inf(1), 0}, + Out: Color{math.Inf(1), 0}, + }, { + Name: "passthrough +inf", + In: Color{math.Inf(-1), math.NaN()}, + Out: Color{math.Inf(-1), math.NaN()}, + }, { + Name: "passthrough nan", + In: Color{math.NaN(), math.Inf(1)}, + Out: Color{math.NaN(), math.Inf(1)}, + }, + }) +} + +func distance(a, b Color) float64 { + d := Distance(a, b) + dSqr := DistanceSqr(a, b) + if !helper.EqFloat64Fuzzy(d*d, dSqr) { + panic("Distance and DistanceSqr are not equivalent") + } + return d +} + +func TestDistance(t *testing.T) { + helper.TestDistance(t, false, true, midpoint, distance, Model) +} diff --git a/nlrgba/nlrgba_test.go b/nlrgba/nlrgba_test.go index 19ea2bb..248a85e 100644 --- a/nlrgba/nlrgba_test.go +++ b/nlrgba/nlrgba_test.go @@ -19,7 +19,7 @@ func midpoint(c0, c1 Color) Color { } func TestModel(t *testing.T) { - helper.TestModel(t, true, Model, eq, []helper.ConvertTest[Color]{ + helper.TestModel(t, true, true, Model, eq, []helper.ConvertTest[Color]{ { Name: "passthrough", // These is a very illegal colour. If it makes it through @@ -32,5 +32,5 @@ func TestModel(t *testing.T) { } func TestDistance(t *testing.T) { - helper.TestDistance(t, true, midpoint, Distance, Model) + helper.TestDistance(t, true, true, midpoint, Distance, Model) } diff --git a/noklaba/noklaba_test.go b/noklaba/noklaba_test.go index d2eb2c4..96fc5eb 100644 --- a/noklaba/noklaba_test.go +++ b/noklaba/noklaba_test.go @@ -19,7 +19,7 @@ func midpoint(c0, c1 Color) Color { } func TestModel(t *testing.T) { - helper.TestModel(t, true, Model, eq, []helper.ConvertTest[Color]{ + helper.TestModel(t, true, true, Model, eq, []helper.ConvertTest[Color]{ { Name: "passthrough", // These is a very illegal colour. If it makes it through @@ -32,5 +32,5 @@ func TestModel(t *testing.T) { } func TestDistance(t *testing.T) { - helper.TestDistance(t, true, midpoint, Distance, Model) + helper.TestDistance(t, true, true, midpoint, Distance, Model) } diff --git a/oklab/oklab_test.go b/oklab/oklab_test.go index 42b72d1..132115a 100644 --- a/oklab/oklab_test.go +++ b/oklab/oklab_test.go @@ -19,7 +19,7 @@ func midpoint(c0, c1 Color) Color { } func TestModel(t *testing.T) { - helper.TestModel(t, false, Model, eq, []helper.ConvertTest[Color]{ + helper.TestModel(t, true, false, Model, eq, []helper.ConvertTest[Color]{ { Name: "passthrough", In: Color{math.Inf(1), math.Inf(-1), math.NaN()}, @@ -29,5 +29,5 @@ func TestModel(t *testing.T) { } func TestDistance(t *testing.T) { - helper.TestDistance(t, false, midpoint, Distance, Model) + helper.TestDistance(t, true, false, midpoint, Distance, Model) } diff --git a/oklaba/oklaba_test.go b/oklaba/oklaba_test.go index 180aeaa..4ec8c85 100644 --- a/oklaba/oklaba_test.go +++ b/oklaba/oklaba_test.go @@ -19,7 +19,7 @@ func midpoint(c0, c1 Color) Color { } func TestModel(t *testing.T) { - helper.TestModel(t, true, Model, eq, []helper.ConvertTest[Color]{ + helper.TestModel(t, true, true, Model, eq, []helper.ConvertTest[Color]{ { Name: "passthrough", // These is a very illegal colour. If it makes it through @@ -32,5 +32,5 @@ func TestModel(t *testing.T) { } func TestDistance(t *testing.T) { - helper.TestDistance(t, true, midpoint, Distance, Model) + helper.TestDistance(t, true, true, midpoint, Distance, Model) }