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 }