~shabbyrobe/imgx

401d24dfe27af9f141d2c3f981523edab6143d89 — Blake Williams 6 months ago 19bee02 master
imgconv: Image conversion functions
A imgconv/LICENSE => imgconv/LICENSE +19 -0
@@ 0,0 1,19 @@
Copyright (c) 2023 Blake Williams <code@shabbyrobe.org>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

A imgconv/README.md => imgconv/README.md +15 -0
@@ 0,0 1,15 @@
# Image conversion functions

## Expectation management

This is a library I hack on for my own amusement in an ad-hoc fashion. *No
stability guarantees are made*, the code is *not guaranteed to work*, and
anything may be changed, renamed or removed at any time as I see fit.

If you wish to use any of this, I strongly recommend you copy-paste pieces
as-needed (including tests and license/attribution) into your own project,
or fork it for your own purposes.

Bug reports are welcome, feature requests discouraged, and code contributions
will not be accepted.


A imgconv/clone.go => imgconv/clone.go +58 -0
@@ 0,0 1,58 @@
package imgconv

import (
	"fmt"
	"image"
)

// Clone an image. If the image is a subimage, the underlying pixel data will
// not be identical to the source, as it is often not possible to retrieve the
// original pixels prior to subimage being called, but calls to At(x, y) will be
// identical for the original and result.
func Clone(img image.Image) (image.Image, error) {
	switch img := img.(type) {
	case *image.RGBA:
		return CloneRGBA(img), nil
	case interface{ CloneRGBA() *image.RGBA }:
		return img.CloneRGBA(), nil
	case interface{ CloneImage() image.Image }:
		return img.CloneImage(), nil
	default:
		return nil, &ErrCloneUnsupported{Type: fmt.Sprintf("%T", img)}
	}
}

func CloneRGBA(img *image.RGBA) (out *image.RGBA) {
	rect := img.Bounds()
	out = image.NewRGBA(rect)

	if img.Stride != out.Stride {
		// If it's a subimage but the width of the new rect doesn't match the stride of the
		// original pixel buffer, we have to clone differently. image.RGBA.SubImage() slices
		// the pixel buffer, so we can't rewind, but doesn't restride it.
		inIdx := 0
		outIdx := 0

		inw, inh := rect.Dx()*4, rect.Dy()
		for y := 0; y < inh; y++ {
			for x := 0; x < inw; x++ {
				out.Pix[outIdx] = img.Pix[inIdx+x]
				outIdx++
			}
			inIdx += img.Stride
		}

	} else {
		copy(out.Pix, img.Pix)
	}

	return out
}

type ErrCloneUnsupported struct {
	Type string
}

func (err *ErrCloneUnsupported) Error() string {
	return fmt.Sprintf("imgconv: clone unsupported for image type %s", err.Type)
}

A imgconv/clone_test.go => imgconv/clone_test.go +52 -0
@@ 0,0 1,52 @@
package imgconv

import (
	"fmt"
	"image"
	"math/rand"
	"testing"

	"go.shabbyrobe.org/imgx/testimg"
)

func TestCloneRGBA(t *testing.T) {
	rng := rand.New(rand.NewSource(0))
	gen := testimg.RandBlocks{W: 512, H: 512, BlockW: 1, BlockH: 1}

	t.Run("image", func(t *testing.T) {
		img := gen.RGBA(rng)
		cloned := CloneRGBA(img)
		if !testimg.EqualVisibleViaRGBA(img, cloned) {
			t.Fatal()
		}
	})

	t.Run("subimage-spam", func(t *testing.T) {
		for _, bounds := range []image.Rectangle{
			image.Rect(0, 0, 1, 1),
			image.Rect(1, 1, 2, 2),
			image.Rect(0, 0, gen.W, 1),
			image.Rect(0, 0, gen.W, gen.H-100),
			image.Rect(1, 1, gen.W, gen.H-100),
			image.Rect(0, 0, gen.W, gen.H-1),
			image.Rect(0, 0, gen.W, gen.H),
			image.Rect(0, 0, gen.W-1, gen.W),
			image.Rect(0, 0, 1, gen.H),
			image.Rect(0, 0, gen.H-1, gen.H),
			image.Rect(gen.W-100, gen.H-100, gen.W, gen.H),
		} {
			name := fmt.Sprintf("subimage-spam-%d,%d-%dx%d", bounds.Min.X, bounds.Min.Y, bounds.Dx(), bounds.Dy())
			t.Run(name, func(t *testing.T) {
				img := gen.RGBA(rng)
				img = img.SubImage(bounds).(*image.RGBA)
				cloned := CloneRGBA(img)

				if !testimg.EqualVisibleViaRGBA(img, cloned) {
					f1 := dumpTempImage(img, name+"-a.png")
					f2 := dumpTempImage(img, name+"-b.png")
					t.Fatalf("images not equal, see %q and %q", f1, f2)
				}
			})
		}
	})
}

A imgconv/go.mod => imgconv/go.mod +7 -0
@@ 0,0 1,7 @@
module go.shabbyrobe.org/imgx/imgconv

go 1.21

require go.shabbyrobe.org/imgx/testimg v0.0.0-20221103115235-259bdc850323

replace go.shabbyrobe.org/imgx/testimg => ../testimg

A imgconv/go.sum => imgconv/go.sum +2 -0
@@ 0,0 1,2 @@
go.shabbyrobe.org/imgx/testimg v0.0.0-20221103115235-259bdc850323 h1:fNQoqFqstx9P3I5FhJp2Dtr4lHi7/V5ERo3FAziG5O0=
go.shabbyrobe.org/imgx/testimg v0.0.0-20221103115235-259bdc850323/go.mod h1:K/H3cNwQRBiELqOc3soTv4iYOp92IqlpWpheFsI67Uc=

A imgconv/init_test.go => imgconv/init_test.go +25 -0
@@ 0,0 1,25 @@
package imgconv

import (
	"bytes"
	"image"
	"image/png"
	"os"
	"path/filepath"
)

func dumpTempImage(img image.Image, name string) string {
	fname := filepath.Join(os.TempDir(), name)
	dumpImage(img, fname)
	return fname
}

func dumpImage(img image.Image, path string) {
	var buf bytes.Buffer
	if err := png.Encode(&buf, img); err != nil {
		panic(err)
	}
	if err := os.WriteFile(path, buf.Bytes(), 0600); err != nil {
		panic(err)
	}
}

A imgconv/rgba.go => imgconv/rgba.go +247 -0
@@ 0,0 1,247 @@
package imgconv

import (
	"image"
	"image/color"
)

// Convert an image.Image into an *image.RGBA.
//
// This will attempt to cast the image first, and if that succeeds, 'copied' will be
// false. If you require a copy of the image, call CloneDeep() on the output:
//
//	var img image.Image
//	rimg, copied := rgba.Convert(img)
//	if !copied {
//		rimg = rimg.CloneDeep()
//	}
//
// If 'copied' is false, the original image will share memory with the returned one
// and you will need to clone if that's not desired.
//
// Most of the image formats from the stdlib have a "fast" conversion path in here, but if
// one does not exist it should be added. If a fast path is unavailable, there are slow
// paths that attempt to use image.RGBAAt(), then finally image.At().
//
// .
func ConvertToRGBA(img image.Image) (out *image.RGBA, copied bool) {
	switch img := img.(type) {
	case *image.RGBA:
		return img, false
	case *image.CMYK:
		return ConvertCMYKToRGBA(img), true
	case *image.NRGBA:
		return ConvertNRGBAToRGBA(img), true
	case *image.NRGBA64:
		return ConvertNRGBA64ToRGBA(img), true
	case *image.Paletted:
		return ConvertPalettedToRGBA(img), true
	case *image.RGBA64:
		return ConvertRGBA64ToRGBA(img), true
	case *image.YCbCr:
		return ConvertYCbCrToRGBA(img), true
	case rgbaAtImage:
		return ConvertRGBAAtToRGBA(img), true
	default:
		return ConvertImageToRGBA(img), true
	}
}

func ConvertCMYKToRGBA(img *image.CMYK) *image.RGBA {
	out := image.NewRGBA(img.Bounds())
	inPix, outPix := img.Pix, out.Pix

	for i := 0; i < len(inPix); i += 4 {
		// Seems like it might be unnecessary to go from 8-bit to 16-bit to
		// 8-bit again, but I'm not quite sure yet and haven't looked further:
		w := 0xffff - uint32(inPix[i+3])*0x101
		outPix[i] = uint8(((0xffff - uint32(inPix[i+0])*0x101) * w / 0xffff) >> 8)
		outPix[i+1] = uint8(((0xffff - uint32(inPix[i+1])*0x101) * w / 0xffff) >> 8)
		outPix[i+2] = uint8(((0xffff - uint32(inPix[i+2])*0x101) * w / 0xffff) >> 8)
		outPix[i+3] = 0xff
	}

	return out
}

func ConvertPalettedToRGBA(img *image.Paletted) *image.RGBA {
	// FIXME: if img.Palette is too big, it might be worth just using
	// the generic converter:
	pal := make([]color.RGBA, len(img.Palette))

	for idx, col := range img.Palette {
		r, g, b, a := col.RGBA()
		pal[idx] = color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: uint8(a >> 8)}
	}

	out := image.NewRGBA(img.Bounds())
	inPix, outPix := img.Pix, out.Pix
	j := 0
	for _, pidx := range inPix {
		c := pal[pidx]
		outPix[j] = c.R
		outPix[j+1] = c.G
		outPix[j+2] = c.B
		outPix[j+3] = c.A
		j += 4
	}
	return out
}

func ConvertYCbCrToRGBA(img *image.YCbCr) *image.RGBA {
	bounds := img.Bounds()
	size := bounds.Size()
	out := image.NewRGBA(bounds)

	var idx int
	var accumYOffset int
	for y := 0; y < size.Y; y++ {
		for x := 0; x < size.X; x++ {
			// image:YCbCr.YOffset():
			var yOffset = accumYOffset + x
			var cOffset int

			// {{{ image.YCbCr.COffset():
			switch img.SubsampleRatio {
			case image.YCbCrSubsampleRatio422:
				cOffset = (y)*img.CStride + (x / 2)
			case image.YCbCrSubsampleRatio420:
				cOffset = (y/2)*img.CStride + (x / 2)
			case image.YCbCrSubsampleRatio440:
				cOffset = (y/2)*img.CStride + (x)
			case image.YCbCrSubsampleRatio411:
				cOffset = (y)*img.CStride + (x / 4)
			case image.YCbCrSubsampleRatio410:
				cOffset = (y/2)*img.CStride + (x / 4)
			default:
				cOffset = (y)*img.CStride + (x)
			}
			// }}}

			y, cb, cr := img.Y[yOffset], img.Cb[cOffset], img.Cr[cOffset]

			// {{{ image.YCbCrToRGB():
			yy1 := int32(y) * 0x10101
			cb1 := int32(cb) - 128
			cr1 := int32(cr) - 128

			r := yy1 + 91881*cr1
			if uint32(r)&0xff000000 == 0 {
				r >>= 16
			} else {
				r = ^(r >> 31)
			}

			g := yy1 - 22554*cb1 - 46802*cr1
			if uint32(g)&0xff000000 == 0 {
				g >>= 16
			} else {
				g = ^(g >> 31)
			}

			b := yy1 + 116130*cb1
			if uint32(b)&0xff000000 == 0 {
				b >>= 16
			} else {
				b = ^(b >> 31)
			}
			// }}}

			out.Pix[idx] = uint8(r)
			out.Pix[idx+1] = uint8(g)
			out.Pix[idx+2] = uint8(b)
			out.Pix[idx+3] = 0xff
			idx += 4
		}
		accumYOffset += img.YStride
	}

	return out
}

func ConvertNRGBA64ToRGBA(img *image.NRGBA64) *image.RGBA {
	out := image.NewRGBA(img.Bounds())
	inPix, outPix := img.Pix, out.Pix
	inLen := len(inPix)

	for ip, op := 0, 0; ip < inLen; ip, op = ip+8, op+4 {
		a := (uint32(inPix[ip+6]) << 8) | uint32(inPix[ip+7])

		outPix[op] = uint8(((uint32(inPix[ip+0]) << 8) | uint32(inPix[ip+1])*a/0xffff) >> 8)
		outPix[op+1] = uint8(((uint32(inPix[ip+2]) << 8) | uint32(inPix[ip+3])*a/0xffff) >> 8)
		outPix[op+2] = uint8(((uint32(inPix[ip+4]) << 8) | uint32(inPix[ip+5])*a/0xffff) >> 8)
		outPix[op+3] = uint8(a >> 8)
	}

	return out
}

func ConvertNRGBAToRGBA(img *image.NRGBA) *image.RGBA {
	out := image.NewRGBA(img.Bounds())
	inPix, outPix := img.Pix, out.Pix

	for idx := 0; idx < len(inPix); idx += 4 {
		a := uint32(inPix[idx+3])
		outPix[idx] = uint8(uint32(inPix[idx+0]) * a / 0xff)
		outPix[idx+1] = uint8(uint32(inPix[idx+1]) * a / 0xff)
		outPix[idx+2] = uint8(uint32(inPix[idx+2]) * a / 0xff)
		outPix[idx+3] = uint8(a)
	}

	return out
}

func ConvertRGBA64ToRGBA(img *image.RGBA64) *image.RGBA {
	out := image.NewRGBA(img.Bounds())
	inPix, outPix := img.Pix, out.Pix

	for ip, op := 0, 0; ip < len(inPix); ip, op = ip+8, op+4 {
		// RGBA64 stores pixels in big-endian pairs. We only need the big end:
		outPix[op] = inPix[ip+0]
		outPix[op+1] = inPix[ip+2]
		outPix[op+2] = inPix[ip+4]
		outPix[op+3] = inPix[ip+6]
	}

	return out
}

// ConvertRGBAAtToRGBA is hopefully a less grim fallback slow-path than the
// CPU-warmer ConvertImageToRGBA.
func ConvertRGBAAtToRGBA(img rgbaAtImage) *image.RGBA {
	bounds := img.Bounds()
	out := image.NewRGBA(bounds)

	var pix int
	for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
		for x := bounds.Min.X; x < bounds.Max.X; x++ {
			c := img.RGBAAt(x, y)
			out.Pix[pix] = c.R
			out.Pix[pix+1] = c.G
			out.Pix[pix+2] = c.B
			out.Pix[pix+3] = c.A
			pix += 4
		}
	}

	return out
}

func ConvertImageToRGBA(img image.Image) *image.RGBA {
	bounds := img.Bounds()
	out := image.NewRGBA(bounds)

	var pix int
	for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
		for x := bounds.Min.X; x < bounds.Max.X; x++ {
			r, g, b, a := img.At(x, y).RGBA()
			out.Pix[pix] = uint8(r >> 8)
			out.Pix[pix+1] = uint8(g >> 8)
			out.Pix[pix+2] = uint8(b >> 8)
			out.Pix[pix+3] = uint8(a >> 8)
			pix += 4
		}
	}

	return out
}

A imgconv/util.go => imgconv/util.go +11 -0
@@ 0,0 1,11 @@
package imgconv

import (
	"image"
	"image/color"
)

type rgbaAtImage interface {
	image.Image
	RGBAAt(x, y int) color.RGBA
}