github.com/utopiagio/gio@v0.0.8/gpu/internal/rendertest/util_test.go (about)

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