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