Posts canvas像素对比测试用例
Post
Cancel

canvas像素对比测试用例

最近在做服务端 canvas-webgl 渲染,探索下怎么通过自动化的方案来保证渲染效果。

探索背景

网络上对 Canvas 的渲染测试用例方案归纳有三种:

  • 方案一:Node 记录 Canvas 的 API 操作记录,验证执行,例如:jest-mock-canvas
  • 方案二:真实浏览器渲染比对,例如 cypress
  • 方案三:学习 Three.js 的 e2e 测试用例,使用 puppeteer 进行截图比对

方案一没有太大意义,因为服务端渲染业务逻辑是跟前端完全一致的,影响渲染效果在更底层的 C++ 模块

方案二需要开启浏览器验证,而我们的开发环境全是在容器中进行,没有可视化环境

方案三主要借助 pixelmatch 库实现像素级对比,提供了很好的解决思路

截取原图:

01.jpeg

和原图进行对比:

02.jpeg

单图比对

因为我们的渲染方案本身就是在服务端操作数据,通过 gl.readPixels 来读取像素,天然就是数据操作,因此无需上述 puppeteer 截图流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 生成原图或比对图
// const canvasBuffer = app.view.toBuffer();
// await jimp.read(canvasBuffer).bitmap;

const fs = require('fs');
const path = require('path');

const jimp = require('jimp');
const pixelmatch = require('pixelmatch');

const snapshotPic1Path = path.join(__dirname, '../snapshot', 'pixi02-SimHei_0_0.png');
const snapshotPic2Path = path.join(__dirname, '../snapshot', 'pixi02-SimHei_1_0.png');
const diffPicPath = path.join(__dirname, '../snapshot', 'diff.png');

(async () => {
  const expectDiffRate = 0.001;
  const actual = (await jimp.read(fs.readFileSync(snapshotPic1Path))).bitmap;
  const expected = (await jimp.read(fs.readFileSync(snapshotPic2Path))).bitmap;
  const diff = actual;
  const { width, height } = actual;
  const failPixel = pixelmatch(expected.data, actual.data, diff.data, width, height, {
    diffMask: true,
  });

  const failRate = failPixel / (width * height);

  if (failRate >= expectDiffRate) {
    (await jimp.read(diff)).scale(1).quality(100).write(diffPicPath);
    console.log(`create diff image at: ${diffPicPath}`);
  } else {
    console.log('pass');
  }
})();

比对效果:

03.jpg

视频比对&文本结论

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 1、将两段视频分别抽帧到两个文件夹
async function extractFramesFromVideo(videoPath, distDir) {
  return new Promise((resolve, reject) => {
    ffmpeg()
      .input(videoPath)
      .outputOptions(['-r 25', '-q:v 2', '-f image2'])
      .output(`${distDir}/%08d.png`)
      .on('end', () => {
        resolve('Finished processing');
      })
      .on('progress', (progress) => {
        console.log(`Processing: ${progress.percent}% done`);
      })
      .on('start', (commandLine) => {
        console.log(`Spawned Ffmpeg with command: ${commandLine}`);
      })
      .on('error', (err) => {
        reject(err);
      })
      .run();
  });
}
1
2
// 2、按照上面单图的逻辑,逐帧比对逻辑
// 省略...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 3、输出比对文本结论

// 单帧比对不同的像素点个数
const diffPixelCount = pixelmatch(expectedImg.data, actualImg.data, diff.data, width, height, {
  threshold: 0.5,
  includeAA: true,
  alpha: 0.5,
  diffMask: false, // 背景是否空白
});

// 单帧比对像素点不同的个数占总像素点的比例
const diffPixelPercent = `${diffPixelCount / (width * height) * 100}%`;

// 写入文件,最后求平均值
fs.appendFileSync(reportTxtPath, `${fileName} ${diffPixelPercent}${os.EOL}`);

单图对比逻辑探索

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// 1、判断入参是否为像素点数据
function isPixelData(arr) {
    // work around instanceof Uint8Array not working properly in some Jest environments
    return ArrayBuffer.isView(arr) && arr.constructor.BYTES_PER_ELEMENT === 1;
}

// 2、判断两张图片分辨率是否一致
if (img1.length !== img2.length || (output && output.length !== img1.length))
    throw new Error('Image sizes do not match.');

// 3、设置两种颜色之间可接受的最大平方距离;35215 是 YIQ 差异度量的最大可能值
const maxDelta = 35215 * options.threshold * options.threshold;

// 4、计算两图在某位置处颜色之间的平方 YUV 距离 delta,如果 img2 像素较暗,则为负值
// 逐像素点遍历
// for (let y = 0; y < height; y++) {
//     for (let x = 0; x < width; x++) {
// ...
const pos = (y * width + x) * 4;
const delta = colorDelta(img1, img2, pos, pos);
function colorDelta(img1, img2, k, m, yOnly) {
    let r1 = img1[k + 0];
    let g1 = img1[k + 1];
    let b1 = img1[k + 2];
    let a1 = img1[k + 3];

    let r2 = img2[m + 0];
    let g2 = img2[m + 1];
    let b2 = img2[m + 2];
    let a2 = img2[m + 3];

    if (a1 === a2 && r1 === r2 && g1 === g2 && b1 === b2) return 0;

    if (a1 < 255) {
        a1 /= 255;
        r1 = blend(r1, a1);
        g1 = blend(g1, a1);
        b1 = blend(b1, a1);
    }

    if (a2 < 255) {
        a2 /= 255;
        r2 = blend(r2, a2);
        g2 = blend(g2, a2);
        b2 = blend(b2, a2);
    }

    const y1 = rgb2y(r1, g1, b1);
    const y2 = rgb2y(r2, g2, b2);
    const y = y1 - y2;

    if (yOnly) return y; // brightness difference only

    const i = rgb2i(r1, g1, b1) - rgb2i(r2, g2, b2);
    const q = rgb2q(r1, g1, b1) - rgb2q(r2, g2, b2);

    const delta = 0.5053 * y * y + 0.299 * i * i + 0.1957 * q * q;

    // encode whether the pixel lightens or darkens in the sign
    return y1 > y2 ? -delta : delta;
}

// 5、判断 delta 与最大可接受阈值的对比
if (Math.abs(delta) > maxDelta) {
// 检查是否只是抗锯齿导致的变化:antialiased(img1, x, y, width, height, img2)
// 非抗锯齿的变化,将差异点通过颜色标注在图上
function drawPixel(output, pos, r, g, b) {
    output[pos + 0] = r;
    output[pos + 1] = g;
    output[pos + 2] = b;
    output[pos + 3] = 255;
}

// 6、通过比对的像素置灰略带灰色像素显示
// 将半透明色与白色混合
function blend(c, a) {
    return 255 + (c - 255) * a;
}
function drawGrayPixel(img, i, alpha, output) {
    const r = img[i + 0];
    const g = img[i + 1];
    const b = img[i + 2];
    const val = blend(rgb2y(r, g, b), alpha * img[i + 3] / 255);
    drawPixel(output, i, val, val, val);
}

总结

这个比对方案用在效果视频的自动化测试上

  • 将一个全特效视频,隔10帧生成一张基准图片
  • 后续开发功能在提交代码前跑此用例,观察渲染逻辑的改动是否影响到原有逻辑
This post is licensed under CC BY 4.0 by the author.
Trending Tags
Contents

Trending Tags