Repository: cogentcore/core
Pull request #1597 (2025-07-19 19:56:44)

imaagejson: JSON wrapper around image.Image that auto-encodes images
need for copy/paste of images in svg

also added missing tests for imagex, and renamed some functions there to be more accurate.




Patch:
diff --git a/base/iox/imagex/base64.go b/base/iox/imagex/base64.go
index 297514d2a..9571b1e1a 100644
--- a/base/iox/imagex/base64.go
+++ b/base/iox/imagex/base64.go
@@ -7,34 +7,21 @@ package imagex
 import (
 	"bytes"
 	"encoding/base64"
-	"errors"
 	"image"
-	"image/jpeg"
-	"image/png"
-	"log"
 	"strings"
-)
 
-// ToBase64PNG returns bytes of image encoded as a PNG in Base64 format
-// with "image/png" mimetype returned
-func ToBase64PNG(img image.Image) ([]byte, string) {
-	ibuf := &bytes.Buffer{}
-	png.Encode(ibuf, img)
-	ib := ibuf.Bytes()
-	eb := make([]byte, base64.StdEncoding.EncodedLen(len(ib)))
-	base64.StdEncoding.Encode(eb, ib)
-	return eb, "image/png"
-}
+	"cogentcore.org/core/base/errors"
+)
 
-// ToBase64JPG returns bytes image encoded as a JPG in Base64 format
-// with "image/jpeg" mimetype returned
-func ToBase64JPG(img image.Image) ([]byte, string) {
+// ToBase64 returns bytes of image encoded in given format,
+// in Base64 encoding with "image/format" mimetype returned
+func ToBase64(img image.Image, f Formats) ([]byte, string) {
 	ibuf := &bytes.Buffer{}
-	jpeg.Encode(ibuf, img, &jpeg.Options{Quality: 90})
+	Write(img, ibuf, f)
 	ib := ibuf.Bytes()
 	eb := make([]byte, base64.StdEncoding.EncodedLen(len(ib)))
 	base64.StdEncoding.Encode(eb, ib)
-	return eb, "image/jpeg"
+	return eb, "image/" + strings.ToLower(f.String())
 }
 
 // Base64SplitLines splits the encoded Base64 bytes into standard lines of 76
@@ -58,44 +45,15 @@ func Base64SplitLines(b []byte) []byte {
 	return rb
 }
 
-// FromBase64PNG returns image from Base64-encoded bytes in PNG format
-func FromBase64PNG(eb []byte) (image.Image, error) {
-	if eb[76] == ' ' {
-		eb = bytes.ReplaceAll(eb, []byte(" "), []byte("\n"))
-	}
-	db := make([]byte, base64.StdEncoding.DecodedLen(len(eb)))
-	_, err := base64.StdEncoding.Decode(db, eb)
-	if err != nil {
-		log.Println(err)
-		return nil, err
-	}
-	rb := bytes.NewReader(db)
-	return png.Decode(rb)
-}
-
-// FromBase64JPG returns image from Base64-encoded bytes in PNG format
-func FromBase64JPG(eb []byte) (image.Image, error) {
+// FromBase64 returns image from Base64-encoded bytes
+func FromBase64(eb []byte) (image.Image, Formats, error) {
 	if eb[76] == ' ' {
 		eb = bytes.ReplaceAll(eb, []byte(" "), []byte("\n"))
 	}
 	db := make([]byte, base64.StdEncoding.DecodedLen(len(eb)))
 	_, err := base64.StdEncoding.Decode(db, eb)
 	if err != nil {
-		log.Println(err)
-		return nil, err
-	}
-	rb := bytes.NewReader(db)
-	return jpeg.Decode(rb)
-}
-
-// FromBase64 returns image from Base64-encoded bytes in either PNG or JPEG format
-// based on fmt which must end in either png, jpg, or jpeg
-func FromBase64(fmt string, eb []byte) (image.Image, error) {
-	if strings.HasSuffix(fmt, "png") {
-		return FromBase64PNG(eb)
-	}
-	if strings.HasSuffix(fmt, "jpg") || strings.HasSuffix(fmt, "jpeg") {
-		return FromBase64JPG(eb)
+		return nil, None, errors.Log(err)
 	}
-	return nil, errors.New("image format must be either png or jpeg")
+	return Read(bytes.NewReader(db))
 }
diff --git a/base/iox/imagex/json.go b/base/iox/imagex/json.go
new file mode 100644
index 000000000..612b7400c
--- /dev/null
+++ b/base/iox/imagex/json.go
@@ -0,0 +1,67 @@
+// Copyright (c) 2025, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package imagex
+
+import (
+	"bytes"
+	"encoding/json"
+	"image"
+)
+
+// JSON is a wrapper around an [image.Image] that defines JSON
+// Marshal and Unmarshal methods, so that the image will automatically
+// be properly saved / loaded when used as a struct field, for example.
+// Must be a pointer type to support custom unmarshal function.
+// The original image is not anonymously embedded so that you have to
+// extract it, otherwise it will be processed inefficiently.
+type JSON struct {
+	Image image.Image
+}
+
+// JSONEncoded is a representation of an image encoded into a byte stream,
+// using the PNG encoder. This can be Marshal and Unmarshal'd directly.
+type JSONEncoded struct {
+	Width  int
+	Height int
+
+	// Image is the encoded byte stream, which will be encoded in JSON
+	// using Base64
+	Image []byte
+}
+
+// NewJSON returns a new JSON wrapper around given image,
+// to support automatic wrapping and unwrapping.
+func NewJSON(im image.Image) *JSON {
+	return &JSON{Image: im}
+}
+
+func (js *JSON) MarshalJSON() ([]byte, error) {
+	id := &JSONEncoded{}
+	if js.Image != nil {
+		sz := js.Image.Bounds().Size()
+		id.Width = sz.X
+		id.Height = sz.Y
+		ibuf := &bytes.Buffer{}
+		Write(js.Image, ibuf, PNG)
+		id.Image = ibuf.Bytes()
+	}
+	return json.Marshal(id)
+}
+
+func (js *JSON) UnmarshalJSON(b []byte) error {
+	id := &JSONEncoded{}
+	err := json.Unmarshal(b, id)
+	if err != nil || (id.Width == 0 && id.Height == 0) {
+		js.Image = nil
+		return err
+	}
+	im, _, err := image.Decode(bytes.NewReader(id.Image))
+	if err != nil {
+		js.Image = nil
+		return err
+	}
+	js.Image = im
+	return nil
+}
diff --git a/svg/cmd/svg/svg.go b/svg/cmd/svg/svg.go
index cb8fe41f4..0476a23a3 100644
--- a/svg/cmd/svg/svg.go
+++ b/svg/cmd/svg/svg.go
@@ -81,7 +81,7 @@ func EmbedImage(c *Config) error {
 	if err != nil {
 		return err
 	}
-	sz := img.Pixels.Bounds().Size()
+	sz := img.Pixels.Image.Bounds().Size()
 	sv.Root.ViewBox.Size.SetPoint(sz)
 	if c.Output == "" {
 		c.Output = strings.TrimSuffix(c.Input, filepath.Ext(c.Input)) + ".svg"
diff --git a/svg/image.go b/svg/image.go
index 38773aacc..345d88c3a 100644
--- a/svg/image.go
+++ b/svg/image.go
@@ -31,8 +31,9 @@ type Image struct {
 	// how to scale and align the image
 	ViewBox ViewBox `xml:"viewbox"`
 
-	// Pixels are the image pixels, which has imagex.WrapJS already applied.
-	Pixels image.Image `xml:"-" json:"-" display:"-"`
+	// Pixels are the image pixels, which has imagex.WrapJS already applied,
+	// and has JSON marshal / unmarshal abilities for copy / paste of element.
+	Pixels *imagex.JSON `xml:"-" display:"-" set:"-"`
 }
 
 func (g *Image) SVGName() string { return "image" }
@@ -51,11 +52,11 @@ func (g *Image) pixelsOfSize(nwsz image.Point) image.Image {
 	if nwsz.X == 0 || nwsz.Y == 0 {
 		return nil
 	}
-	if g.Pixels != nil && g.Pixels.Bounds().Size() == nwsz {
-		return g.Pixels
+	if g.Pixels != nil && g.Pixels.Image != nil && g.Pixels.Image.Bounds().Size() == nwsz {
+		return g.Pixels.Image
 	}
-	g.Pixels = imagex.WrapJS(image.NewRGBA(image.Rectangle{Max: nwsz}))
-	return g.Pixels
+	g.Pixels = imagex.NewJSON(imagex.WrapJS(image.NewRGBA(image.Rectangle{Max: nwsz})))
+	return g.Pixels.Image
 }
 
 // SetImage sets an image for the bitmap, and resizes to the size of the image
@@ -69,7 +70,7 @@ func (g *Image) SetImage(img image.Image, width, height float32) {
 	sz := img.Bounds().Size()
 	if width <= 0 && height <= 0 {
 		cp := imagex.CloneAsRGBA(img)
-		g.Pixels = imagex.WrapJS(cp)
+		g.Pixels = imagex.NewJSON(imagex.WrapJS(cp))
 		if g.Size.X == 0 && g.Size.Y == 0 {
 			g.Size = math32.FromPoint(sz)
 		}
@@ -98,11 +99,11 @@ func (g *Image) SetImage(img image.Image, width, height float32) {
 }
 
 func (g *Image) DrawImage(sv *SVG) {
-	if g.Pixels == nil {
+	if g.Pixels == nil || g.Pixels.Image == nil {
 		return
 	}
 	pc := g.Painter(sv)
-	pc.DrawImageScaled(g.Pixels, g.Pos.X, g.Pos.Y, g.Size.X, g.Size.Y)
+	pc.DrawImageScaled(g.Pixels.Image, g.Pos.X, g.Pos.Y, g.Size.X, g.Size.Y)
 }
 
 func (g *Image) LocalBBox(sv *SVG) math32.Box2 {
@@ -151,8 +152,8 @@ func (g *Image) OpenImage(filename string, width, height float32) error {
 
 // SaveImage saves current image to a file
 func (g *Image) SaveImage(filename string) error {
-	if g.Pixels == nil {
+	if g.Pixels == nil || g.Pixels.Image == nil {
 		return errors.New("svg.SaveImage Pixels is nil")
 	}
-	return imagex.Save(g.Pixels, filename)
+	return imagex.Save(g.Pixels.Image, filename)
 }
diff --git a/svg/io.go b/svg/io.go
index 9a28379ca..a727ffe21 100644
--- a/svg/io.go
+++ b/svg/io.go
@@ -435,13 +435,13 @@ func (sv *SVG) UnmarshalXML(decoder *xml.Decoder, se xml.StartElement) error {
 						if len(attr.Value) > 11 && attr.Value[:11] == "data:image/" {
 							es := attr.Value[11:]
 							fmti := strings.Index(es, ";")
-							fm := es[:fmti]
+							// fm := es[:fmti]
 							bs64 := es[fmti+1 : fmti+8]
 							if bs64 != "base64," {
 								log.Printf("image base64 encoding string not properly formatted: %s\n", bs64)
 							}
 							eb := []byte(es[fmti+8:])
-							im, err := imagex.FromBase64(fm, eb)
+							im, _, err := imagex.FromBase64(eb)
 							if err != nil {
 								log.Println(err)
 							} else {
@@ -1035,7 +1035,7 @@ func MarshalXML(n tree.Node, enc *XMLEncoder, setName string) string {
 		XMLAddAttr(&se.Attr, "y", fmt.Sprintf("%g", nd.Pos.Y))
 		text = nd.Text
 	case *Image:
-		if nd.Pixels == nil {
+		if nd.Pixels == nil || nd.Pixels.Image == nil {
 			return ""
 		}
 		nm = "image"
@@ -1044,7 +1044,7 @@ func MarshalXML(n tree.Node, enc *XMLEncoder, setName string) string {
 		XMLAddAttr(&se.Attr, "width", fmt.Sprintf("%g", nd.Size.X))
 		XMLAddAttr(&se.Attr, "height", fmt.Sprintf("%g", nd.Size.Y))
 		XMLAddAttr(&se.Attr, "preserveAspectRatio", nd.ViewBox.PreserveAspectRatio.String())
-		ib, fmt := imagex.ToBase64PNG(nd.Pixels)
+		ib, fmt := imagex.ToBase64(nd.Pixels.Image, imagex.PNG)
 		XMLAddAttr(&se.Attr, "href", "data:"+fmt+";base64,"+string(imagex.Base64SplitLines(ib)))
 	case *MetaData:
 		if strings.HasPrefix(nd.Name, "namedview") {
diff --git a/svg/typegen.go b/svg/typegen.go
index fb59a5f24..afb0b0eab 100644
--- a/svg/typegen.go
+++ b/svg/typegen.go
@@ -3,8 +3,6 @@
 package svg
 
 import (
-	"image"
-
 	"cogentcore.org/core/colors/gradient"
 	"cogentcore.org/core/math32"
 	"cogentcore.org/core/text/shaped"
@@ -108,7 +106,7 @@ var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/svg.Group", IDName:
 // and shared style properties.
 func NewGroup(parent ...tree.Node) *Group { return tree.New[Group](parent...) }
 
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/svg.Image", IDName: "image", Doc: "Image is an SVG image (bitmap)", Embeds: []types.Field{{Name: "NodeBase"}}, Fields: []types.Field{{Name: "Pos", Doc: "position of the top-left of the image"}, {Name: "Size", Doc: "rendered size of the image (imposes a scaling on image when it is rendered)"}, {Name: "Filename", Doc: "file name of image loaded -- set by OpenImage"}, {Name: "ViewBox", Doc: "how to scale and align the image"}, {Name: "Pixels", Doc: "Pixels are the image pixels, which has imagex.WrapJS already applied."}}})
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/svg.Image", IDName: "image", Doc: "Image is an SVG image (bitmap)", Embeds: []types.Field{{Name: "NodeBase"}}, Fields: []types.Field{{Name: "Pos", Doc: "position of the top-left of the image"}, {Name: "Size", Doc: "rendered size of the image (imposes a scaling on image when it is rendered)"}, {Name: "Filename", Doc: "file name of image loaded -- set by OpenImage"}, {Name: "ViewBox", Doc: "how to scale and align the image"}, {Name: "Pixels", Doc: "Pixels are the image pixels, which has imagex.WrapJS already applied,\nand has JSON marshal / unmarshal abilities for copy / paste of element."}}})
 
 // NewImage returns a new [Image] with the given optional parent:
 // Image is an SVG image (bitmap)
@@ -130,10 +128,6 @@ func (t *Image) SetFilename(v string) *Image { t.Filename = v; return t }
 // how to scale and align the image
 func (t *Image) SetViewBox(v ViewBox) *Image { t.ViewBox = v; return t }
 
-// SetPixels sets the [Image.Pixels]:
-// Pixels are the image pixels, which has imagex.WrapJS already applied.
-func (t *Image) SetPixels(v image.Image) *Image { t.Pixels = v; return t }
-
 var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/svg.Line", IDName: "line", Doc: "Line is a SVG line", Embeds: []types.Field{{Name: "NodeBase"}}, Fields: []types.Field{{Name: "Start", Doc: "position of the start of the line"}, {Name: "End", Doc: "position of the end of the line"}}})
 
 // NewLine returns a new [Line] with the given optional parent:
diff --git a/yaegicore/coresymbols/cogentcore_org-core-base-iox-imagex.go b/yaegicore/coresymbols/cogentcore_org-core-base-iox-imagex.go
index 2154d9d8b..e7a527e75 100644
--- a/yaegicore/coresymbols/cogentcore_org-core-base-iox-imagex.go
+++ b/yaegicore/coresymbols/cogentcore_org-core-base-iox-imagex.go
@@ -17,18 +17,17 @@ func init() {
 		"BMP":              reflect.ValueOf(imagex.BMP),
 		"Base64SplitLines": reflect.ValueOf(imagex.Base64SplitLines),
 		"CloneAsRGBA":      reflect.ValueOf(imagex.CloneAsRGBA),
-		"CompareColors":    reflect.ValueOf(imagex.CompareColors),
-		"CompareUint8":     reflect.ValueOf(imagex.CompareUint8),
+		"ColorsEqual":      reflect.ValueOf(imagex.ColorsEqual),
 		"Crop":             reflect.ValueOf(imagex.Crop),
 		"DiffImage":        reflect.ValueOf(imagex.DiffImage),
 		"ExtToFormat":      reflect.ValueOf(imagex.ExtToFormat),
 		"FormatsN":         reflect.ValueOf(imagex.FormatsN),
 		"FormatsValues":    reflect.ValueOf(imagex.FormatsValues),
 		"FromBase64":       reflect.ValueOf(imagex.FromBase64),
-		"FromBase64JPG":    reflect.ValueOf(imagex.FromBase64JPG),
-		"FromBase64PNG":    reflect.ValueOf(imagex.FromBase64PNG),
 		"GIF":              reflect.ValueOf(imagex.GIF),
+		"ImagesEqual":      reflect.ValueOf(imagex.ImagesEqual),
 		"JPEG":             reflect.ValueOf(imagex.JPEG),
+		"NewJSON":          reflect.ValueOf(imagex.NewJSON),
 		"None":             reflect.ValueOf(imagex.None),
 		"Open":             reflect.ValueOf(imagex.Open),
 		"OpenFS":           reflect.ValueOf(imagex.OpenFS),
@@ -37,8 +36,8 @@ func init() {
 		"Resize":           reflect.ValueOf(imagex.Resize),
 		"Save":             reflect.ValueOf(imagex.Save),
 		"TIFF":             reflect.ValueOf(imagex.TIFF),
-		"ToBase64JPG":      reflect.ValueOf(imagex.ToBase64JPG),
-		"ToBase64PNG":      reflect.ValueOf(imagex.ToBase64PNG),
+		"ToBase64":         reflect.ValueOf(imagex.ToBase64),
+		"Uint8Equal":       reflect.ValueOf(imagex.Uint8Equal),
 		"Unwrap":           reflect.ValueOf(imagex.Unwrap),
 		"Update":           reflect.ValueOf(imagex.Update),
 		"UpdateTestImages": reflect.ValueOf(&imagex.UpdateTestImages).Elem(),
@@ -47,9 +46,11 @@ func init() {
 		"Write":            reflect.ValueOf(imagex.Write),
 
 		// type definitions
-		"Formats":  reflect.ValueOf((*imagex.Formats)(nil)),
-		"TestingT": reflect.ValueOf((*imagex.TestingT)(nil)),
-		"Wrapped":  reflect.ValueOf((*imagex.Wrapped)(nil)),
+		"Formats":     reflect.ValueOf((*imagex.Formats)(nil)),
+		"JSON":        reflect.ValueOf((*imagex.JSON)(nil)),
+		"JSONEncoded": reflect.ValueOf((*imagex.JSONEncoded)(nil)),
+		"TestingT":    reflect.ValueOf((*imagex.TestingT)(nil)),
+		"Wrapped":     reflect.ValueOf((*imagex.Wrapped)(nil)),
 
 		// interface wrapper definitions
 		"_TestingT": reflect.ValueOf((*_cogentcore_org_core_base_iox_imagex_TestingT)(nil)),


Test patch:
diff --git a/base/iox/imagex/imagex_test.go b/base/iox/imagex/imagex_test.go
new file mode 100644
index 000000000..2b53c2546
--- /dev/null
+++ b/base/iox/imagex/imagex_test.go
@@ -0,0 +1,97 @@
+// Copyright (c) 2025, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package imagex
+
+import (
+	"encoding/json"
+	"image"
+	"image/color"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+type testObj struct {
+	Name    string
+	Image   *JSON
+	Another string
+}
+
+func testImage() *image.RGBA {
+	im := image.NewRGBA(image.Rect(0, 0, 16, 16))
+	for y := range 16 {
+		for x := range 16 {
+			im.Set(x, y, color.RGBA{uint8(x * 16), uint8(y * 16), 128, 255})
+		}
+	}
+	return im
+}
+
+func TestSave(t *testing.T) {
+	im := testImage()
+	// this tests Save and Open etc for all formats
+	Assert(t, im, "test.png", 1)  // should be exact
+	Assert(t, im, "test.jpg", 20) // quite bad
+	Assert(t, im, "test.gif", 50) // even worse
+	Assert(t, im, "test.tif", 1)
+	Assert(t, im, "test.bmp", 1)
+	// Assert(t, im, "test.webp") // only for reading, not writing
+}
+
+func TestBase64(t *testing.T) {
+	im := testImage()
+	b, mime := ToBase64(im, PNG)
+	assert.Equal(t, "image/png", mime)
+	bim, f, err := FromBase64(b)
+	assert.NoError(t, err)
+	assert.Equal(t, PNG, f)
+	bounds, content, _, _, _, _ := ImagesEqual(im, bim, 1)
+	assert.True(t, bounds)
+	assert.True(t, content)
+
+	b, mime = ToBase64(im, JPEG)
+	assert.Equal(t, "image/jpeg", mime)
+	bim, f, err = FromBase64(b)
+	assert.NoError(t, err)
+	assert.Equal(t, JPEG, f)
+	bounds, content, _, _, _, _ = ImagesEqual(im, bim, 20)
+	assert.True(t, bounds)
+	assert.True(t, content)
+}
+
+func TestJSON(t *testing.T) {
+	im := testImage()
+	jsi := &JSON{Image: im}
+
+	b, err := json.Marshal(jsi)
+	assert.NoError(t, err)
+
+	nsi := &JSON{}
+	err = json.Unmarshal(b, nsi)
+	assert.NoError(t, err)
+
+	ri := nsi.Image.(*image.RGBA)
+
+	assert.Equal(t, im, ri)
+
+	bounds, content, _, _, _, _ := ImagesEqual(im, ri, 1)
+	assert.True(t, bounds)
+	assert.True(t, content)
+
+	jo := &testObj{Name: "testy", Another: "guy"}
+	jo.Image = NewJSON(im)
+
+	b, err = json.Marshal(jo)
+	assert.NoError(t, err)
+
+	no := &testObj{}
+	err = json.Unmarshal(b, no)
+	assert.NoError(t, err)
+
+	assert.Equal(t, jo, no)
+	bounds, content, _, _, _, _ = ImagesEqual(jo.Image.Image, no.Image.Image, 1)
+	assert.True(t, bounds)
+	assert.True(t, content)
+}
diff --git a/base/iox/imagex/testing.go b/base/iox/imagex/testing.go
index ada3526f6..0c7e371cc 100644
--- a/base/iox/imagex/testing.go
+++ b/base/iox/imagex/testing.go
@@ -30,8 +30,8 @@ type TestingT interface {
 // and it should only be set once and then turned back off.
 var UpdateTestImages = updateTestImages
 
-// CompareUint8 returns true if two numbers are more different than tol
-func CompareUint8(cc, ic uint8, tol int) bool {
+// Uint8Equal returns true if two numbers are within tol
+func Uint8Equal(cc, ic uint8, tol int) bool {
 	d := int(cc) - int(ic)
 	if d < -tol {
 		return false
@@ -42,18 +42,18 @@ func CompareUint8(cc, ic uint8, tol int) bool {
 	return true
 }
 
-// CompareColors returns true if two colors are more different than tol
-func CompareColors(cc, ic color.RGBA, tol int) bool {
-	if !CompareUint8(cc.R, ic.R, tol) {
+// ColorsEqual returns true if two colors equal within tol
+func ColorsEqual(cc, ic color.RGBA, tol int) bool {
+	if !Uint8Equal(cc.R, ic.R, tol) {
 		return false
 	}
-	if !CompareUint8(cc.G, ic.G, tol) {
+	if !Uint8Equal(cc.G, ic.G, tol) {
 		return false
 	}
-	if !CompareUint8(cc.B, ic.B, tol) {
+	if !Uint8Equal(cc.B, ic.B, tol) {
 		return false
 	}
-	if !CompareUint8(cc.A, ic.A, tol) {
+	if !Uint8Equal(cc.A, ic.A, tol) {
 		return false
 	}
 	return true
@@ -61,6 +61,7 @@ func CompareColors(cc, ic color.RGBA, tol int) bool {
 
 // DiffImage returns the difference between two images,
 // with pixels having the abs of the difference between pixels.
+// Images must have the same bounds.
 func DiffImage(a, b image.Image) image.Image {
 	ab := a.Bounds()
 	di := image.NewRGBA(ab)
@@ -78,6 +79,31 @@ func DiffImage(a, b image.Image) image.Image {
 	return di
 }
 
+// ImagesEqual compares two images, returns false if not equal,
+// based on bounds or content. If a content difference, then
+// the colors and pixel where they first differ are returned.
+// The first image is considered the reference, correct one.
+// Tol is the color tolerance.
+func ImagesEqual(a, b image.Image, tol int) (bounds, content bool, ac, bc color.RGBA, x, y int) {
+	abounds := a.Bounds()
+	bbounds := b.Bounds()
+	if bbounds != abounds {
+		return
+	}
+	bounds = true
+	for y = abounds.Min.Y; y < abounds.Max.Y; y++ {
+		for x = abounds.Min.X; x < abounds.Max.X; x++ {
+			ac = color.RGBAModel.Convert(a.At(x, y)).(color.RGBA)
+			bc = color.RGBAModel.Convert(b.At(x, y)).(color.RGBA)
+			if !ColorsEqual(ac, bc, tol) {
+				return
+			}
+		}
+	}
+	content = true
+	return
+}
+
 // Assert asserts that the given image is equivalent
 // to the image stored at the given filename in the testdata directory,
 // with ".png" added to the filename if there is no extension
@@ -86,17 +112,23 @@ func DiffImage(a, b image.Image) image.Image {
 // If it is not, it fails the test with an error, but continues its
 // execution. If there is no image at the given filename in the testdata
 // directory, it creates the image.
-func Assert(t TestingT, img image.Image, filename string) {
+// optional tolerance argument specifies the maximum color difference,
+// which defaults to 10.
+func Assert(t TestingT, img image.Image, filename string, tols ...int) {
 	filename = filepath.Join("testdata", filename)
 	if filepath.Ext(filename) == "" {
 		filename += ".png"
 	}
-
 	err := os.MkdirAll(filepath.Dir(filename), 0750)
 	if err != nil {
 		t.Errorf("error making testdata directory: %v", err)
 	}
 
+	tol := 10
+	if len(tols) == 1 {
+		tol = tols[0]
+	}
+
 	ext := filepath.Ext(filename)
 	failFilename := strings.TrimSuffix(filename, ext) + ".fail" + ext
 	diffFilename := strings.TrimSuffix(filename, ext) + ".diff" + ext
@@ -128,32 +160,16 @@ func Assert(t TestingT, img image.Image, filename string) {
 		return
 	}
 
-	failed := false
+	// TODO(#1456): reduce tolerance to 1 after we fix rendering inconsistencies
+	bounds, content, ac, bc, x, y := ImagesEqual(fimg, img, tol)
 
-	ibounds := img.Bounds()
-	fbounds := fimg.Bounds()
-	if ibounds != fbounds {
-		t.Errorf("AssertImage: expected bounds %v for image for %s, but got bounds %v; see %s", fbounds, filename, ibounds, failFilename)
-		failed = true
-	} else {
-		for y := ibounds.Min.Y; y < ibounds.Max.Y; y++ {
-			for x := ibounds.Min.X; x < ibounds.Max.X; x++ {
-				cc := color.RGBAModel.Convert(img.At(x, y)).(color.RGBA)
-				ic := color.RGBAModel.Convert(fimg.At(x, y)).(color.RGBA)
-				// TODO(#1456): reduce tolerance to 1 after we fix rendering inconsistencies
-				if !CompareColors(cc, ic, 10) {
-					t.Errorf("AssertImage: image for %s is not the same as expected; see %s; expected color %v at (%d, %d), but got %v", filename, failFilename, ic, x, y, cc)
-					failed = true
-					break
-				}
-			}
-			if failed {
-				break
-			}
-		}
-	}
+	if !bounds || !content {
+		if !bounds {
+			t.Errorf("AssertImage: expected bounds %v for image for %s, but got bounds %v; see %s", fimg.Bounds(), filename, img.Bounds(), failFilename)
+		} else {
+			t.Errorf("AssertImage: image for %s is not the same as expected; see %s; expected color %v at (%d, %d), but got %v", filename, failFilename, ac, x, y, bc)
 
-	if failed {
+		}
 		err := Save(img, failFilename)
 		if err != nil {
 			t.Errorf("AssertImage: error saving fail image: %v", err)