@recreatejs/jasmine-pixelmatch
Version:
HTML5 canvas visual regression tests for Jasmine using pixelmatch
415 lines (337 loc) • 14 kB
JavaScript
var jasminePixelmatch = (function (exports) {
'use strict';
function getCanvas(width, height) {
var canvas = document.createElement("canvas");
if (width) canvas.width = width;
if (height) canvas.height = height;
return canvas;
}
function createCanvasFromImageData(imageData) {
let canvas = getCanvas(imageData.width, imageData.height);
let context = canvas.getContext("2d");
context.putImageData(imageData, 0, 0);
return canvas;
}
/** globals jasmineRequire */
function buildError(message, actualCanvas, expectedCanvas = null, diffCanvas = null) {
if (!isHTMLReport()) {
let output = message;
if (actualCanvas) {
output += "\nActual: \n" + actualCanvas.toDataURL();
}
if (expectedCanvas) {
output += "\nExpected: \n" + expectedCanvas.toDataURL();
}
if (diffCanvas) {
output += "\nDiff: \n" + diffCanvas.toDataURL();
}
return output;
}
let html = buildEl("div", message);
styleCanvas(actualCanvas);
let actualDiv = buildEl("div", "Actual:<br/> ");
actualDiv.appendChild(actualCanvas);
html.appendChild(actualDiv);
if (expectedCanvas) {
styleCanvas(expectedCanvas);
let expectedDiv = buildEl("div", "Expected:<br/> ");
expectedDiv.appendChild(expectedCanvas);
html.appendChild(expectedDiv);
}
if (diffCanvas) {
styleCanvas(diffCanvas);
let diffDiv = buildEl("div", "Diff:<br/> ");
diffDiv.appendChild(diffCanvas);
html.appendChild(diffDiv);
}
return html;
}
function isHTMLReport() {
return !!jasmineRequire.html;
}
function buildEl(element, content) {
element = document.createElement(element);
if (element && content) {
element.innerHTML = content;
}
return element;
}
function styleCanvas(canvas) {
canvas.style.border = "1px solid #ddd";
canvas.style.background = "white";
}
var pixelmatch_1 = pixelmatch;
const defaultOptions = {
threshold: 0.1, // matching threshold (0 to 1); smaller is more sensitive
includeAA: false, // whether to skip anti-aliasing detection
alpha: 0.1, // opacity of original image in diff ouput
aaColor: [255, 255, 0], // color of anti-aliased pixels in diff output
diffColor: [255, 0, 0], // color of different pixels in diff output
diffMask: false // draw the diff over a transparent background (a mask)
};
function pixelmatch(img1, img2, output, width, height, options) {
if (!isPixelData(img1) || !isPixelData(img2) || (output && !isPixelData(output)))
throw new Error('Image data: Uint8Array, Uint8ClampedArray or Buffer expected.');
if (img1.length !== img2.length || (output && output.length !== img1.length))
throw new Error('Image sizes do not match.');
if (img1.length !== width * height * 4) throw new Error('Image data size does not match width/height.');
options = Object.assign({}, defaultOptions, options);
// check if images are identical
const len = width * height;
const a32 = new Uint32Array(img1.buffer, img1.byteOffset, len);
const b32 = new Uint32Array(img2.buffer, img2.byteOffset, len);
let identical = true;
for (let i = 0; i < len; i++) {
if (a32[i] !== b32[i]) { identical = false; break; }
}
if (identical) { // fast path if identical
if (output && !options.diffMask) {
for (let i = 0; i < len; i++) drawGrayPixel(img1, 4 * i, options.alpha, output);
}
return 0;
}
// maximum acceptable square distance between two colors;
// 35215 is the maximum possible value for the YIQ difference metric
const maxDelta = 35215 * options.threshold * options.threshold;
let diff = 0;
const [aaR, aaG, aaB] = options.aaColor;
const [diffR, diffG, diffB] = options.diffColor;
// compare each pixel of one image against the other one
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const pos = (y * width + x) * 4;
// squared YUV distance between colors at this pixel position
const delta = colorDelta(img1, img2, pos, pos);
// the color difference is above the threshold
if (delta > maxDelta) {
// check it's a real rendering difference or just anti-aliasing
if (!options.includeAA && (antialiased(img1, x, y, width, height, img2) ||
antialiased(img2, x, y, width, height, img1))) {
// one of the pixels is anti-aliasing; draw as yellow and do not count as difference
// note that we do not include such pixels in a mask
if (output && !options.diffMask) drawPixel(output, pos, aaR, aaG, aaB);
} else {
// found substantial difference not caused by anti-aliasing; draw it as red
if (output) drawPixel(output, pos, diffR, diffG, diffB);
diff++;
}
} else if (output) {
// pixels are similar; draw background as grayscale image blended with white
if (!options.diffMask) drawGrayPixel(img1, pos, options.alpha, output);
}
}
}
// return the number of different pixels
return diff;
}
function isPixelData(arr) {
// work around instanceof Uint8Array not working properly in some Jest environments
return ArrayBuffer.isView(arr) && arr.constructor.BYTES_PER_ELEMENT === 1;
}
// check if a pixel is likely a part of anti-aliasing;
// based on "Anti-aliased Pixel and Intensity Slope Detector" paper by V. Vysniauskas, 2009
function antialiased(img, x1, y1, width, height, img2) {
const x0 = Math.max(x1 - 1, 0);
const y0 = Math.max(y1 - 1, 0);
const x2 = Math.min(x1 + 1, width - 1);
const y2 = Math.min(y1 + 1, height - 1);
const pos = (y1 * width + x1) * 4;
let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0;
let min = 0;
let max = 0;
let minX, minY, maxX, maxY;
// go through 8 adjacent pixels
for (let x = x0; x <= x2; x++) {
for (let y = y0; y <= y2; y++) {
if (x === x1 && y === y1) continue;
// brightness delta between the center pixel and adjacent one
const delta = colorDelta(img, img, pos, (y * width + x) * 4, true);
// count the number of equal, darker and brighter adjacent pixels
if (delta === 0) {
zeroes++;
// if found more than 2 equal siblings, it's definitely not anti-aliasing
if (zeroes > 2) return false;
// remember the darkest pixel
} else if (delta < min) {
min = delta;
minX = x;
minY = y;
// remember the brightest pixel
} else if (delta > max) {
max = delta;
maxX = x;
maxY = y;
}
}
}
// if there are no both darker and brighter pixels among siblings, it's not anti-aliasing
if (min === 0 || max === 0) return false;
// if either the darkest or the brightest pixel has 3+ equal siblings in both images
// (definitely not anti-aliased), this pixel is anti-aliased
return (hasManySiblings(img, minX, minY, width, height) && hasManySiblings(img2, minX, minY, width, height)) ||
(hasManySiblings(img, maxX, maxY, width, height) && hasManySiblings(img2, maxX, maxY, width, height));
}
// check if a pixel has 3+ adjacent pixels of the same color.
function hasManySiblings(img, x1, y1, width, height) {
const x0 = Math.max(x1 - 1, 0);
const y0 = Math.max(y1 - 1, 0);
const x2 = Math.min(x1 + 1, width - 1);
const y2 = Math.min(y1 + 1, height - 1);
const pos = (y1 * width + x1) * 4;
let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0;
// go through 8 adjacent pixels
for (let x = x0; x <= x2; x++) {
for (let y = y0; y <= y2; y++) {
if (x === x1 && y === y1) continue;
const pos2 = (y * width + x) * 4;
if (img[pos] === img[pos2] &&
img[pos + 1] === img[pos2 + 1] &&
img[pos + 2] === img[pos2 + 2] &&
img[pos + 3] === img[pos2 + 3]) zeroes++;
if (zeroes > 2) return true;
}
}
return false;
}
// calculate color difference according to the paper "Measuring perceived color difference
// using YIQ NTSC transmission color space in mobile applications" by Y. Kotsarenko and F. Ramos
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 y = rgb2y(r1, g1, b1) - rgb2y(r2, g2, b2);
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);
return 0.5053 * y * y + 0.299 * i * i + 0.1957 * q * q;
}
function rgb2y(r, g, b) { return r * 0.29889531 + g * 0.58662247 + b * 0.11448223; }
function rgb2i(r, g, b) { return r * 0.59597799 - g * 0.27417610 - b * 0.32180189; }
function rgb2q(r, g, b) { return r * 0.21147017 - g * 0.52261711 + b * 0.31114694; }
// blend semi-transparent color with white
function blend(c, a) {
return 255 + (c - 255) * a;
}
function drawPixel(output, pos, r, g, b) {
output[pos + 0] = r;
output[pos + 1] = g;
output[pos + 2] = b;
output[pos + 3] = 255;
}
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);
}
function toVisuallyEqual(util, customEqualityTesters) {
return {
compare(actual, expected) {
var result = {};
if (!expected) {
let actualCanvas = createCanvasFromImageData(actual);
result.message = buildError("No expected image defined", actualCanvas);
result.pass = false;
return result;
}
let width = expected.width;
let height = expected.height;
let diffCanvas = getCanvas(width, height);
let diffContext = diffCanvas.getContext("2d");
const diff = diffContext.createImageData(width, height);
try {
let differingPixels = pixelmatch_1(expected.data, actual.data, diff.data, width, height, {
threshold: 0.2,
diffMask: true,
alpha: 1,
includeAA: false
});
diffContext.putImageData(diff, 0, 0);
result.pass = differingPixels === 0;
if (!result.pass) {
let actualCanvas = createCanvasFromImageData(actual);
let expectedCanvas = createCanvasFromImageData(expected);
let totalPixels = width * height;
let percentDifference = (differingPixels / totalPixels * 100).toFixed(2);
let message = `Images have ${differingPixels}/${totalPixels} (${percentDifference}%) differing pixels`;
result.message = buildError(message, actualCanvas, expectedCanvas, diffCanvas);
}
} catch (error) {
let actualCanvas = createCanvasFromImageData(actual);
let expectedCanvas = createCanvasFromImageData(expected);
result.pass = false;
result.message = buildError(error.message, actualCanvas, expectedCanvas);
}
return result;
}
};
}
/**
* Jasmine custom equality tester to see if two ImageData objects are the same.
* @see https://developer.mozilla.org/en-US/docs/Web/API/ImageData
*
* @param {ImageData} first
* @param {ImageData} second
*/
function imageDataEquality(first, second) {
if (first instanceof ImageData && second instanceof ImageData) {
return first.width === second.width && first.height === second.height && pixelmatch_1(first.data, second.data, null, first.width, first.height) === 0;
}
}
/**
* @param {HTMLImageElement} img
* @returns {ImageData}
*/
function imgToImageData(img) {
var canvas = document.createElement("canvas");
var context = canvas.getContext("2d");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
context.drawImage(img, 0, 0);
return context.getImageData(0, 0, canvas.width, canvas.height);
}
/**
* @param {String} url
* @returns {Promise}
*/
function loadImage(url) {
var img = new Image();
img.src = url;
return new Promise(resolve => {
img.onload = () => resolve(img);
});
}
function fetchImageData(url) {
return loadImage(url).then(img => imgToImageData(img));
}
beforeEach(function () {
jasmine.addMatchers({
toVisuallyEqual
});
jasmine.addCustomEqualityTester(imageDataEquality);
});
exports.fetchImageData = fetchImageData;
exports.imgToImageData = imgToImageData;
exports.loadImage = loadImage;
return exports;
}({}));
//# sourceMappingURL=jasmine-pixelmatch.js.map