util_test.go raw

   1  // SPDX-License-Identifier: Unlicense OR MIT
   2  
   3  package rendertest
   4  
   5  import (
   6  	"bytes"
   7  	"flag"
   8  	"fmt"
   9  	"image"
  10  	"image/color"
  11  	"image/draw"
  12  	"image/png"
  13  	"io/ioutil"
  14  	"path/filepath"
  15  	"strconv"
  16  	"testing"
  17  
  18  	"golang.org/x/image/colornames"
  19  
  20  	"github.com/p9c/p9/pkg/gel/gio/f32"
  21  	"github.com/p9c/p9/pkg/gel/gio/gpu/headless"
  22  	"github.com/p9c/p9/pkg/gel/gio/internal/f32color"
  23  	"github.com/p9c/p9/pkg/gel/gio/op"
  24  	"github.com/p9c/p9/pkg/gel/gio/op/paint"
  25  )
  26  
  27  var (
  28  	dumpImages   = flag.Bool("saveimages", false, "save test images")
  29  	squares      paint.ImageOp
  30  	smallSquares paint.ImageOp
  31  )
  32  
  33  var (
  34  	red         = f32color.RGBAToNRGBA(colornames.Red)
  35  	green       = f32color.RGBAToNRGBA(colornames.Green)
  36  	blue        = f32color.RGBAToNRGBA(colornames.Blue)
  37  	magenta     = f32color.RGBAToNRGBA(colornames.Magenta)
  38  	black       = f32color.RGBAToNRGBA(colornames.Black)
  39  	white       = f32color.RGBAToNRGBA(colornames.White)
  40  	transparent = color.RGBA{}
  41  )
  42  
  43  func init() {
  44  	squares = buildSquares(512)
  45  	smallSquares = buildSquares(50)
  46  }
  47  
  48  func buildSquares(size int) paint.ImageOp {
  49  	sub := size / 4
  50  	im := image.NewNRGBA(image.Rect(0, 0, size, size))
  51  	c1, c2 := image.NewUniform(colornames.Green), image.NewUniform(colornames.Blue)
  52  	for r := 0; r < 4; r++ {
  53  		for c := 0; c < 4; c++ {
  54  			c1, c2 = c2, c1
  55  			draw.Draw(im, image.Rect(r*sub, c*sub, r*sub+sub, c*sub+sub), c1, image.Point{}, draw.Over)
  56  		}
  57  		c1, c2 = c2, c1
  58  	}
  59  	return paint.NewImageOp(im)
  60  }
  61  
  62  func drawImage(t *testing.T, size int, ops *op.Ops, draw func(o *op.Ops)) (im *image.RGBA, err error) {
  63  	sz := image.Point{X: size, Y: size}
  64  	w := newWindow(t, sz.X, sz.Y)
  65  	draw(ops)
  66  	if err := w.Frame(ops); err != nil {
  67  		return nil, err
  68  	}
  69  	return w.Screenshot()
  70  }
  71  
  72  func run(t *testing.T, f func(o *op.Ops), c func(r result)) {
  73  	// draw a few times and check that it is correct each time, to
  74  	// ensure any caching effects still generate the correct images.
  75  	var img *image.RGBA
  76  	var err error
  77  	ops := new(op.Ops)
  78  	for i := 0; i < 3; i++ {
  79  		ops.Reset()
  80  		img, err = drawImage(t, 128, ops, f)
  81  		if err != nil {
  82  			t.Error("error rendering:", err)
  83  			return
  84  		}
  85  		// check for a reference image and make sure we are identical.
  86  		if !verifyRef(t, img, 0) {
  87  			name := fmt.Sprintf("%s-%d-bad.png", t.Name(), i)
  88  			if err := saveImage(name, img); err != nil {
  89  				t.Error(err)
  90  			}
  91  		}
  92  		c(result{t: t, img: img})
  93  	}
  94  
  95  	if *dumpImages {
  96  		if err := saveImage(t.Name()+".png", img); err != nil {
  97  			t.Error(err)
  98  		}
  99  	}
 100  }
 101  
 102  func frame(f func(o *op.Ops), c func(r result)) frameT {
 103  	return frameT{f: f, c: c}
 104  }
 105  
 106  type frameT struct {
 107  	f func(o *op.Ops)
 108  	c func(r result)
 109  }
 110  
 111  // multiRun is used to run test cases over multiple frames, typically
 112  // to test caching interactions.
 113  func multiRun(t *testing.T, frames ...frameT) {
 114  	// draw a few times and check that it is correct each time, to
 115  	// ensure any caching effects still generate the correct images.
 116  	var img *image.RGBA
 117  	var err error
 118  	sz := image.Point{X: 128, Y: 128}
 119  	w := newWindow(t, sz.X, sz.Y)
 120  	ops := new(op.Ops)
 121  	for i := range frames {
 122  		ops.Reset()
 123  		frames[i].f(ops)
 124  		if err := w.Frame(ops); err != nil {
 125  			t.Errorf("rendering failed: %v", err)
 126  			continue
 127  		}
 128  		img, err = w.Screenshot()
 129  		if err != nil {
 130  			t.Errorf("screenshot failed: %v", err)
 131  			continue
 132  		}
 133  		// Check for a reference image and make sure they are identical.
 134  		ok := verifyRef(t, img, i)
 135  		if frames[i].c != nil {
 136  			frames[i].c(result{t: t, img: img})
 137  		}
 138  		if *dumpImages || !ok {
 139  			name := t.Name() + ".png"
 140  			if i != 0 {
 141  				name = t.Name() + "_" + strconv.Itoa(i) + ".png"
 142  			}
 143  			if err := saveImage(name, img); err != nil {
 144  				t.Error(err)
 145  			}
 146  		}
 147  	}
 148  
 149  }
 150  
 151  func verifyRef(t *testing.T, img *image.RGBA, frame int) (ok bool) {
 152  	// ensure identical to ref data
 153  	path := filepath.Join("refs", t.Name()+".png")
 154  	if frame != 0 {
 155  		path = filepath.Join("refs", t.Name()+"_"+strconv.Itoa(frame)+".png")
 156  	}
 157  	b, err := ioutil.ReadFile(path)
 158  	if err != nil {
 159  		t.Error("could not open ref:", err)
 160  		return
 161  	}
 162  	r, err := png.Decode(bytes.NewReader(b))
 163  	if err != nil {
 164  		t.Error("could not decode ref:", err)
 165  		return
 166  	}
 167  	if img.Bounds() != r.Bounds() {
 168  		t.Errorf("reference image is %v, expected %v", r.Bounds(), img.Bounds())
 169  		return false
 170  	}
 171  	var ref *image.RGBA
 172  	switch r := r.(type) {
 173  	case *image.RGBA:
 174  		ref = r
 175  	case *image.NRGBA:
 176  		ref = image.NewRGBA(r.Bounds())
 177  		bnd := r.Bounds()
 178  		for x := bnd.Min.X; x < bnd.Max.X; x++ {
 179  			for y := bnd.Min.Y; y < bnd.Max.Y; y++ {
 180  				ref.SetRGBA(x, y, f32color.NRGBAToRGBA(r.NRGBAAt(x, y)))
 181  			}
 182  		}
 183  	default:
 184  		t.Fatalf("reference image is a %T, expected *image.NRGBA or *image.RGBA", r)
 185  	}
 186  	bnd := img.Bounds()
 187  	for x := bnd.Min.X; x < bnd.Max.X; x++ {
 188  		for y := bnd.Min.Y; y < bnd.Max.Y; y++ {
 189  			exp := ref.RGBAAt(x, y)
 190  			got := img.RGBAAt(x, y)
 191  			if !colorsClose(exp, got) {
 192  				t.Error("not equal to ref at", x, y, " ", got, exp)
 193  				return false
 194  			}
 195  		}
 196  	}
 197  	return true
 198  }
 199  
 200  func colorsClose(c1, c2 color.RGBA) bool {
 201  	const delta = 0.01 // magic value obtained from experimentation.
 202  	return yiqEqApprox(c1, c2, delta)
 203  }
 204  
 205  // yiqEqApprox compares the colors of 2 pixels, in the NTSC YIQ color space,
 206  // as described in:
 207  //
 208  //   Measuring perceived color difference using YIQ NTSC
 209  //   transmission color space in mobile applications.
 210  //   Yuriy Kotsarenko, Fernando Ramos.
 211  //
 212  // An electronic version is available at:
 213  //
 214  // - http://www.progmat.uaem.mx:8080/artVol2Num2/Articulo3Vol2Num2.pdf
 215  func yiqEqApprox(c1, c2 color.RGBA, d2 float64) bool {
 216  	const max = 35215.0 // difference between 2 maximally different pixels.
 217  
 218  	var (
 219  		r1 = float64(c1.R)
 220  		g1 = float64(c1.G)
 221  		b1 = float64(c1.B)
 222  
 223  		r2 = float64(c2.R)
 224  		g2 = float64(c2.G)
 225  		b2 = float64(c2.B)
 226  
 227  		y1 = r1*0.29889531 + g1*0.58662247 + b1*0.11448223
 228  		i1 = r1*0.59597799 - g1*0.27417610 - b1*0.32180189
 229  		q1 = r1*0.21147017 - g1*0.52261711 + b1*0.31114694
 230  
 231  		y2 = r2*0.29889531 + g2*0.58662247 + b2*0.11448223
 232  		i2 = r2*0.59597799 - g2*0.27417610 - b2*0.32180189
 233  		q2 = r2*0.21147017 - g2*0.52261711 + b2*0.31114694
 234  
 235  		y = y1 - y2
 236  		i = i1 - i2
 237  		q = q1 - q2
 238  
 239  		diff = 0.5053*y*y + 0.299*i*i + 0.1957*q*q
 240  	)
 241  	return diff <= max*d2
 242  }
 243  
 244  func (r result) expect(x, y int, col color.RGBA) {
 245  	r.t.Helper()
 246  	if r.img == nil {
 247  		return
 248  	}
 249  	c := r.img.RGBAAt(x, y)
 250  	if !colorsClose(c, col) {
 251  		r.t.Error("expected ", col, " at ", "(", x, ",", y, ") but got ", c)
 252  	}
 253  }
 254  
 255  type result struct {
 256  	t   *testing.T
 257  	img *image.RGBA
 258  }
 259  
 260  func saveImage(file string, img *image.RGBA) error {
 261  	// Only NRGBA images are losslessly encoded by png.Encode.
 262  	nrgba := image.NewNRGBA(img.Bounds())
 263  	bnd := img.Bounds()
 264  	for x := bnd.Min.X; x < bnd.Max.X; x++ {
 265  		for y := bnd.Min.Y; y < bnd.Max.Y; y++ {
 266  			nrgba.SetNRGBA(x, y, f32color.RGBAToNRGBA(img.RGBAAt(x, y)))
 267  		}
 268  	}
 269  	var buf bytes.Buffer
 270  	if err := png.Encode(&buf, nrgba); err != nil {
 271  		return err
 272  	}
 273  	return ioutil.WriteFile(file, buf.Bytes(), 0666)
 274  }
 275  
 276  func newWindow(t testing.TB, width, height int) *headless.Window {
 277  	w, err := headless.NewWindow(width, height)
 278  	if err != nil {
 279  		t.Skipf("failed to create headless window, skipping: %v", err)
 280  	}
 281  	t.Cleanup(w.Release)
 282  	return w
 283  }
 284  
 285  func scale(sx, sy float32) op.TransformOp {
 286  	return op.Affine(f32.Affine2D{}.Scale(f32.Point{}, f32.Pt(sx, sy)))
 287  }
 288