pixel-buffer-diff
Version:
Pbd (Pixel Buffer Diff) is a pixel buffer diff library designed for visual regression tests. Pbd is 8-10x faster than Pixelmatch and works as a drop-in replacement. Update your package.json, and import, to save significant time and money on your visual re
253 lines (252 loc) • 12 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.diff = exports.diffImageDatas = void 0;
const DiffOnly = 1;
const SideBySide = 2;
const MINIMAP_SCALE = 128;
const COLOR32_ADDED = 0x03f00cc00;
const COLOR32_REMOVED = 0x03f0000ff;
const COLOR32_MINIMAP = 0x0207f0000;
const HASH_SPREAD = 0x0f0731337;
const diffImageDatas = (baseline, candidate, diff, options) => {
const threshold = options.threshold ?? 0.03;
const cumulatedThreshold = options.cumulatedThreshold ?? 0.5;
const enableMinimap = options.enableMinimap ?? false;
const mode = options.mode ?? "basic";
const { width, height } = baseline;
const area = width * height;
const baseline8 = baseline.data;
const candidate8 = candidate.data;
const diff8 = diff.data;
const b8l = baseline8.length;
const c8l = candidate8.length;
const d8l = diff8.length;
if (width !== candidate.width || height !== candidate.height || area * 4 !== b8l || b8l !== c8l) {
throw new Error("Different baseline and candidate ImageData dimensions");
}
if (mode === "ssim" && (width | height) & 15) {
throw new Error("Invalid ImageData dimensions for ssim mode");
}
const wRatio = diff.width / width;
const lRatio = d8l / b8l;
if (diff.height !== height || wRatio !== lRatio || (wRatio !== 1 && wRatio !== 3)) {
throw new Error("Invalid diff ImageData dimensions");
}
const diffType = d8l === b8l ? DiffOnly : SideBySide;
const bBuffer = baseline8.buffer;
const cBuffer = candidate8.buffer;
const dBuffer = diff8.buffer;
const baseline32 = new Uint32Array(bBuffer, 0, b8l >> 2);
const candidate32 = new Uint32Array(cBuffer, 0, b8l >> 2);
const diff32 = new Uint32Array(dBuffer, 0, d8l >> 2);
const deltaThreshold = threshold * threshold * 35215;
let b8i = 0;
let b32i = 0;
let d32i = 0;
let diffCount = 0;
let hash = 0;
let hashStart = 0;
let cumulatedDiff = 0;
let averageBrightness = 0;
const brightnessSamples = Math.ceil(Math.sqrt(area) / 128);
const b8iStep = (b8l / brightnessSamples) & -4;
for (let i = 0; i < brightnessSamples; i++) {
averageBrightness +=
(0.299 * (baseline8[b8i] + candidate8[b8i]) +
0.587 * (baseline8[b8i + 1] + candidate8[b8i + 1]) +
0.114 * (baseline8[b8i + 2] + candidate8[b8i + 2])) /
brightnessSamples /
2;
b8i += b8iStep;
}
const isDarkTheme = averageBrightness < 128;
const color32Added = isDarkTheme ? COLOR32_ADDED : COLOR32_REMOVED;
const color32Removed = isDarkTheme ? COLOR32_REMOVED : COLOR32_ADDED;
const color32Minimap = enableMinimap ? COLOR32_MINIMAP : 0;
const d32iPadding = diffType === SideBySide ? width : 0;
const d32iWidth = d32iPadding * 2 + width;
if (mode === "ssim") {
const ssimPad = 64;
const ssimPad2 = ssimPad * ssimPad;
const ssimPad2_1 = ssimPad2 - 1;
if (diffType === SideBySide) {
for (let y = 0; y < height; y++) {
const index32 = y * width;
const diffIndex32 = index32 * 3;
const baseline32Span = new Uint32Array(baseline32.buffer, y * width * 4, width);
diff32.set(baseline32Span, diffIndex32);
const candidate32Span = new Uint32Array(candidate32.buffer, y * width * 4, width);
diff32.set(candidate32Span, diffIndex32 + width + width);
}
}
const patchOffsets8 = [];
const patchOffsets32 = [];
const patchOffsetsDiff32 = [];
const patchDiff = [];
;
for (let y = 0; y < ssimPad; y++) {
for (let x = 0; x < ssimPad; x++) {
patchOffsetsDiff32.push(x + y * d32iWidth);
patchOffsets32.push(x + y * width);
patchOffsets8.push((x + y * width) * 4);
}
}
const K1 = 0.01;
const K2 = 0.03;
const C1 = (K1 * 255) ** 2;
const C2 = (K2 * 255) ** 2;
let lowestSsim = 1;
let diffCount = 0;
const threshold255 = threshold * 255;
for (let y = 0; y < height; y += ssimPad) {
for (let x = 0; x < width; x += ssimPad) {
const index32 = x + y * width;
let breakAndComputeSsim = false;
for (let i = 0; i < ssimPad2 && !breakAndComputeSsim; i++) {
const indexPlusOffset32 = index32 + patchOffsets32[i];
breakAndComputeSsim = baseline32[indexPlusOffset32] != candidate32[indexPlusOffset32];
}
if (breakAndComputeSsim) {
const index8 = index32 * 4;
let sumB = 0;
let sumC = 0;
let sumBB = 0;
let sumCC = 0;
let sumBC = 0;
for (let i = 0; i < ssimPad2; i++) {
const indexPlusOffset8 = index8 + patchOffsets8[i];
const b = baseline8[indexPlusOffset8] * 0.299 + baseline8[indexPlusOffset8 + 1] * 0.587 + baseline8[indexPlusOffset8 + 2] * 0.114;
const c = candidate8[indexPlusOffset8] * 0.299 + candidate8[indexPlusOffset8 + 1] * 0.587 + candidate8[indexPlusOffset8 + 2] * 0.114;
patchDiff[i] = c - b;
sumB += b;
sumC += c;
sumBB += b * b;
sumCC += c * c;
sumBC += b * c;
}
const meanB = sumB / ssimPad2;
const meanC = sumC / ssimPad2;
const varB = (sumBB - ssimPad2 * meanB * meanB) / ssimPad2_1;
const varC = (sumCC - ssimPad2 * meanC * meanC) / ssimPad2_1;
const covBC = (sumBC - ssimPad2 * meanB * meanC) / ssimPad2_1;
const numerator = (2 * meanB * meanC + C1) * (2 * covBC + C2);
const denominator = (meanB * meanB + meanC * meanC + C1) * (varB + varC + C2);
const ssim = numerator / denominator;
if (ssim < 1 - threshold) {
const diffIndex32 = x + y * d32iWidth + d32iPadding;
diffCount++;
for (let i = 0; i < ssimPad2; i++) {
const dy = patchDiff[i];
const dyAbs = Math.abs(dy);
if (dyAbs > threshold255) {
diffCount++;
diff32[diffIndex32 + patchOffsetsDiff32[i]] = ((dy > 0 ? color32Added : color32Removed) +
(Math.min(192, dyAbs * 2) << 24)) | color32Minimap;
const hashIndex = (y ^ HASH_SPREAD) * HASH_SPREAD + (x ^ HASH_SPREAD) + i;
if (hash === 0) {
hashStart = hashIndex;
}
hash += hashIndex;
}
else {
diff32[diffIndex32 + patchOffsetsDiff32[i]] = color32Minimap;
}
}
if (ssim < lowestSsim) {
lowestSsim = ssim;
}
}
}
}
}
if (lowestSsim < 1) {
hash -= hashStart;
return { diff: diffCount, cumulatedDiff: lowestSsim, hash };
}
}
else {
b8i = 0;
const miniHeight = Math.ceil(height / MINIMAP_SCALE);
const miniWidth = Math.ceil(width / MINIMAP_SCALE);
const miniMap = new Uint8ClampedArray(miniWidth * miniHeight);
const maxDimension = Math.max(width, height);
const maxMiniDimension = Math.max(miniWidth, miniHeight);
const axisMiniIndex = new Uint32Array(maxDimension);
let miniIndex = 0;
for (let i = 0; i < maxMiniDimension; i++) {
axisMiniIndex.fill(i, miniIndex, Math.min(miniIndex + MINIMAP_SCALE, maxDimension));
miniIndex += MINIMAP_SCALE;
}
for (let y = 0; y < height; y++) {
const miniIndexY = axisMiniIndex[y] * miniWidth;
if (d32iPadding > 0) {
diff32.set(new Uint32Array(bBuffer, b8i, width), d32i);
d32i += width;
diff32.set(new Uint32Array(cBuffer, b8i, width), d32i + width);
}
let hashIndex = (y ^ HASH_SPREAD) * HASH_SPREAD;
for (let x = 0; x < width; x++, d32i++, b32i++, b8i += 4, hashIndex++) {
if (baseline32[b32i] === candidate32[b32i]) {
continue;
}
const dr = candidate8[b8i] - baseline8[b8i];
const dg = candidate8[b8i + 1] - baseline8[b8i + 1];
const db = candidate8[b8i + 2] - baseline8[b8i + 2];
const dy = dr * 0.29889531 + dg * 0.58662247 + db * 0.11448223;
const di = dr * 0.59597799 - dg * 0.27417610 - db * 0.32180189;
const dq = dr * 0.21147017 - dg * 0.52261711 + db * 0.31114694;
const delta = dy * dy * 0.5053 + di * di * 0.299 + dq * dq * 0.1957;
if (delta > deltaThreshold) {
const miniIndex = miniIndexY + axisMiniIndex[x];
miniMap[miniIndex]++;
diffCount++;
const dyAbs = Math.abs(dy);
cumulatedDiff += dyAbs;
diff32[d32i] =
(dy > 0 ? color32Added : color32Removed) +
(Math.min(192, dyAbs * 8) << 24);
if (hash === 0) {
hashStart = hashIndex;
}
hash += hashIndex;
}
}
d32i += d32iPadding;
}
hash -= hashStart;
cumulatedDiff /= 256;
if (enableMinimap) {
for (let i = 0; i < miniWidth * miniHeight; i++) {
const value = miniMap[i];
if (value > 0) {
const miniX = i % miniWidth;
const miniY = i / miniWidth | 0;
const x0 = miniX * MINIMAP_SCALE;
const x1 = Math.min(x0 + MINIMAP_SCALE, width);
const y0 = miniY * MINIMAP_SCALE;
const y1 = Math.min(y0 + MINIMAP_SCALE, height);
d32i = x0 + y0 * d32iWidth + d32iPadding;
const d32iYinc = d32iWidth - x1 + x0;
for (let y = y0; y < y1; y++) {
for (let x = x0; x < x1; x++) {
diff32[d32i++] |= COLOR32_MINIMAP;
}
d32i += d32iYinc;
}
}
}
}
if (cumulatedDiff > cumulatedThreshold) {
return { diff: diffCount, cumulatedDiff, hash };
}
}
return { diff: 0, cumulatedDiff: 0, hash: 0 };
};
exports.diffImageDatas = diffImageDatas;
const diff = (baseline8, candidate8, diff8, width, height, options = {
threshold: 0.03,
cumulatedThreshold: 0.5,
enableMinimap: false,
mode: "basic"
}) => (0, exports.diffImageDatas)({ width, height, data: baseline8 }, { width, height, data: candidate8 }, { width: width * diff8.length / baseline8.length, height, data: diff8 }, options);
exports.diff = diff;