diff --git a/image.go b/image.go new file mode 100644 index 0000000..65a3a9a --- /dev/null +++ b/image.go @@ -0,0 +1,164 @@ +package oklab + +import ( + "encoding/binary" + "image" + "image/color" + "image/draw" + "math" +) + +// Not many images can say they have 256-bit color; +// I've definitely gone overboard with this. +const bytesPerPixel = 8 * 4 + +// Image represents an image that encodes its pixels using OKLab color. +// +// Each pixel is 32 bytes in size, encoding the Lightness, ChromaA, ChromaB, and Alpha components +// of each pixel in that order as big-endian float64 values. +type Image struct { + Pix []uint8 + Stride int + Rect image.Rectangle +} + +// ColorModel implements the [image.Image] interface. +func (p *Image) ColorModel() color.Model { + return Model +} + +// Bounds implements the [image.Image] interface. +func (p *Image) Bounds() image.Rectangle { + return p.Rect +} + +// At implements the [image.Image] interface. +func (p *Image) At(x, y int) color.Color { + return p.OkLabAt(x, y) +} + +// RGBA64At implements the [image.RGBA64Image] interface. +func (p *Image) RGBA64At(x, y int) color.RGBA64 { + r, g, b, a := p.OkLabAt(x, y).RGBA() + return color.RGBA64{uint16(r), uint16(g), uint16(b), uint16(a)} +} + +// OkLabAt decodes the pixel at the given coordinates into an OkLab color. +// +// The zero value is returned for coordinates outside of the image. +func (p *Image) OkLabAt(x, y int) Color { + if !image.Pt(x, y).In(p.Rect) { + return Color{} + } + + i := p.PixOffset(x, y) + in := p.Pix[i : i+bytesPerPixel] + + return Color{ + Lightness: math.Float64frombits(binary.BigEndian.Uint64(in[0:8])), + ChromaA: math.Float64frombits(binary.BigEndian.Uint64(in[8:16])), + ChromaB: math.Float64frombits(binary.BigEndian.Uint64(in[16:24])), + A: math.Float64frombits(binary.BigEndian.Uint64(in[24:32])), + } +} + +// PixOffset returns the offset into the [Pix] slice a pixel begins. +// +// The given coordinates must be inside the image. +func (p *Image) PixOffset(x, y int) int { + return (y-p.Rect.Min.Y)*p.Stride + (x-p.Rect.Min.X)*bytesPerPixel +} + +// Set implements the [draw.Image] interface. +func (p *Image) Set(x, y int, c color.Color) { + if !image.Pt(x, y).In(p.Rect) { + return + } + + p.setOkLab(x, y, FromColor(c)) +} + +// Set implements the [draw.RGBA64Image] interface. +func (p *Image) SetRGBA64(x, y int, c color.RGBA64) { + if !image.Pt(x, y).In(p.Rect) { + return + } + + p.setOkLab(x, y, FromRGBA(uint32(c.R), uint32(c.G), uint32(c.B), uint32(c.A))) +} + +func (p *Image) setOkLab(x, y int, c Color) { + i := p.PixOffset(x, y) + out := p.Pix[i : i+bytesPerPixel] + + binary.BigEndian.PutUint64(out[0:8], math.Float64bits(c.Lightness)) + binary.BigEndian.PutUint64(out[8:16], math.Float64bits(c.ChromaA)) + binary.BigEndian.PutUint64(out[16:24], math.Float64bits(c.ChromaB)) + binary.BigEndian.PutUint64(out[24:32], math.Float64bits(c.A)) +} + +// SetOkLab encodes the given color into the pixel at the given coordinates. +// +// Does nothing if the coordinates are outside the image. +func (p *Image) SetOkLab(x, y int, c Color) { + if !image.Pt(x, y).In(p.Rect) { + return + } + + p.setOkLab(x, y, c) +} + +// SubImage returns a new [Image] that shares the same underlying pixel data. +func (p *Image) SubImage(r image.Rectangle) *Image { + r = r.Intersect(p.Rect) + + if r.Empty() { + return &Image{} + } + + start := p.PixOffset(r.Min.X, r.Min.Y) + end := p.PixOffset(r.Max.X-1, r.Max.Y-1) + bytesPerPixel + + return &Image{ + Pix: p.Pix[start:end:end], + Stride: p.Stride, + Rect: r, + } +} + +// Opaque returns true if every pixel in the image would have an alpha value of 0xffff when +// converted to [color.RGBA64]. +func (p *Image) Opaque() bool { + w, h := p.Rect.Dx(), p.Rect.Dy() + for y := 0; y < h; y++ { + row := p.Pix[y*p.Stride : y*p.Stride+w*bytesPerPixel] + for i, stop := 0, w*bytesPerPixel; i < stop; i += bytesPerPixel { + // we consider a pixel opaque if the NRGBA/RGBA methods + // would have returned 0xffff. + if uint32(math.Float64frombits(binary.BigEndian.Uint64(row[i+24:i+32]))*0xffff+0.5) != 0xffff { + return false + } + } + } + + return true +} + +// NewImage returns a new [Image] of the specified size. +func NewImage(r image.Rectangle) *Image { + return &Image{ + Pix: make([]uint8, r.Dx()*r.Dy()*bytesPerPixel), + Stride: r.Dx() * bytesPerPixel, + Rect: r, + } +} + +var ( + // type assertions + _ interface { + image.Image + draw.Image + image.RGBA64Image + draw.RGBA64Image + } = (*Image)(nil) +) diff --git a/image_test.go b/image_test.go new file mode 100644 index 0000000..1958355 --- /dev/null +++ b/image_test.go @@ -0,0 +1,245 @@ +package oklab + +import ( + "image" + "image/color" + "image/draw" + "testing" +) + +func TestNewImage(t *testing.T) { + r := image.Rect(13, 21, 34, 55) + img := NewImage(r) + + if img.Rect != r { + t.Errorf("img.Rect = %v, want %v", img.Rect, r) + } + + if got := img.Bounds(); got != r { + t.Errorf("img.Bounds() = %v, want %v", got, r) + } + + if got := img.ColorModel(); got != Model { + t.Errorf("img.ColorModel() = %v, want %v", got, Model) + } + + if got, want := img.Stride, r.Dx()*bytesPerPixel; got != want { + t.Errorf("img.Stride = %v, want %v", got, want) + } + + if got, want := len(img.Pix), (r.Dy()-1)*img.Stride+r.Dx()*bytesPerPixel; got != want { + t.Errorf("len(img.Pix) = %v, want %v", got, want) + } +} + +func testColor(x, y int) Color { + return Color{ + float64(x), + float64(y), + float64(x + y), + float64(x ^ y), + } +} + +func mod(a int, m int) int { + a = a % m + if a < 0 { + // fucking intel. + a += m + } + return a +} + +func testRGBA64Color(x, y int) color.RGBA64 { + a := mod(x^y, 0xffff) + + return color.RGBA64{ + uint16(mod(x, a+1)), + uint16(mod(y, a+1)), + uint16(mod(x+y, a+1)), + uint16(a), + } +} + +func TestImage_Set(t *testing.T) { + r := image.Rect(13, 21, 34, 55) + img := NewImage(r) + + // intentionally invoking SetOkLab beyond the boundaries of the image, here. + for y := r.Min.Y - 1; y <= r.Max.Y; y++ { + for x := r.Min.X - 1; x <= r.Max.X; x++ { + img.Set(x, y, testColor(x, y)) + } + } + + for y := r.Min.Y - 1; y <= r.Max.Y; y++ { + for x := r.Min.X - 1; x <= r.Max.X; x++ { + want := Color{} + + if image.Pt(x, y).In(r) { + want = testColor(x, y) + } + + got := img.At(x, y).(Color) + + if got != want { + t.Errorf("img.At(%d, %d) = %v, want %v", x, y, got, want) + // stop at the first error, please. + return + } + } + } +} + +func TestImage_SetRGBA64(t *testing.T) { + r := image.Rect(13, 21, 34, 55) + img := NewImage(r) + + // intentionally invoking SetOkLab beyond the boundaries of the image, here. + for y := r.Min.Y - 1; y <= r.Max.Y; y++ { + for x := r.Min.X - 1; x <= r.Max.X; x++ { + img.SetRGBA64(x, y, testRGBA64Color(x, y)) + } + } + + for y := r.Min.Y - 1; y <= r.Max.Y; y++ { + for x := r.Min.X - 1; x <= r.Max.X; x++ { + want := color.RGBA64{} + + if image.Pt(x, y).In(r) { + want = testRGBA64Color(x, y) + } + + got := img.RGBA64At(x, y) + + if got != want { + t.Errorf("img.RGBA64At(%d, %d) = %v, want %v", x, y, got, want) + // stop at the first error, please. + return + } + } + } +} + +func setPixels(img *Image) { + for y := img.Rect.Min.Y - 1; y <= img.Rect.Max.Y; y++ { + for x := img.Rect.Min.X - 1; x <= img.Rect.Max.X; x++ { + img.SetOkLab(x, y, testColor(x, y)) + } + } +} + +func checkPixels(t *testing.T, img *Image) { + for y := img.Rect.Min.Y - 1; y <= img.Rect.Max.Y; y++ { + for x := img.Rect.Min.X - 1; x <= img.Rect.Max.X; x++ { + want := Color{} + + if image.Pt(x, y).In(img.Rect) { + want = testColor(x, y) + } + + got := img.OkLabAt(x, y) + + if got != want { + t.Errorf("checkPixels: img.OkLabAt(%d, %d) = %v, want %v", x, y, got, want) + return + } + } + } +} + +func TestImage_SetOkLab(t *testing.T) { + r := image.Rect(13, 21, 34, 55) + img := NewImage(r) + + setPixels(img) + checkPixels(t, img) +} + +func TestImage_SubImage(t *testing.T) { + r := image.Rect(13, 21, 34, 55) + img := NewImage(r) + + setPixels(img) + + for _, tt := range []struct { + name string + r image.Rectangle + }{ + {"identity", r}, + {"inset", r.Inset(1)}, + {"outset", r.Inset(-1)}, + {"left", r.Add(image.Pt(-1, 0))}, + {"right", r.Add(image.Pt(1, 0))}, + {"up", r.Add(image.Pt(0, -1))}, + {"down", r.Add(image.Pt(0, 1))}, + {"outside", image.Rect(-100, -100, 1, 1)}, + } { + t.Run(tt.name, func(t *testing.T) { + subImage := img.SubImage(tt.r) + + if got, want := subImage.Rect, img.Rect.Intersect(tt.r); got != want { + t.Errorf("subImage.Rect = %v, want %v", got, want) + return + } + + if got, want := subImage.Stride, subImage.Rect.Dx()*bytesPerPixel; got < want { + t.Errorf("subImage.Stride = %v, want >= %v", got, want) + } + + if got, want := len(subImage.Pix), (subImage.Rect.Dy()-1)*subImage.Stride+subImage.Rect.Dx()*bytesPerPixel; got != want { + t.Errorf("len(img.Pix) = %v, want %v", got, want) + } + + checkPixels(t, subImage) + }) + } +} + +func TestImage_Opaque(t *testing.T) { + t.Run("empty", func(t *testing.T) { + // an empty image is opaque by virtue of not containing any pixels, opaque or otherwise. + got, want := NewImage(image.Rect(0, 0, 0, 0)).Opaque(), true + if got != want { + t.Errorf("Opaque() = %v, want %v", got, want) + } + }) + + t.Run("non-empty", func(t *testing.T) { + img := NewImage(image.Rect(13, 21, 34, 55)) + + // the pixels of a non-empty image will have an alpha value of zero, + // make it completely transparent. + got, want := img.Opaque(), false + if got != want { + t.Errorf("img.Opaque() = %v, want %v", got, want) + } + + t.Run("explicitly transparent", func(t *testing.T) { + // if we explicitly set every pixel to be transparent, the result should be the same. + draw.Draw(img, img.Rect, image.Transparent, image.Point{}, draw.Src) + got, want := img.Opaque(), false + + if got != want { + t.Errorf("img.Opaque() = %v, want %v", got, want) + } + }) + + t.Run("opaque", func(t *testing.T) { + // make every pixel opaque. + draw.Draw(img, img.Rect, image.Opaque, image.Point{}, draw.Src) + got, want := img.Opaque(), true + if got != want { + t.Errorf("img.Opaque() = %v, want %v", got, want) + } + + t.Run("except for a single transparent pixel", func(t *testing.T) { + img.Set(14, 23, color.Transparent) + got, want := img.Opaque(), false + if got != want { + t.Errorf("img.Opaque() = %v, want %v", got, want) + } + }) + }) + }) +}