0%

JavaScript 像素级图像比较库 pixelmatch 源码解读

背景

pixelmatch是一个JavaScript像素级图像比较库,最初创建是为了比较测试中的屏幕截图。具有准确的抗锯齿像素检测和感知色差指标。非常小(大约有 150 行代码)可以在浏览器和nodejs中运行。

本文简析pixelmatch的工作过程及原理。

函数参数

  • img1img2: 两个待比较的图像数据,以 Uint8ArrayUint8ClampedArray 格式表示。
  • output: 可选参数,用于存储差异图像的数据。
  • widthheight: 输入图像的宽度和高度。
  • options: 可选参数对象,包含以下属性:
    • threshold: 匹配阈值,默认值为 0.1,值越小越敏感。
    • includeAA: 是否包括抗锯齿检测,默认不包括。
    • alpha: 差异输出图像中原始图像的透明度。
    • aaColor: 抗锯齿像素在差异输出中的颜色,默认是黄色 [255, 255, 0]
    • diffColor: 不同像素在差异输出中的颜色,默认是红色 [255, 0, 0]
    • diffColorAlt: 用于区分图像1和图像2之间的暗亮差异的备用颜色。
    • diffMask: 是否在透明背景上绘制差异(即掩码)。

函数逻辑

  1. 输入验证:

    • 确保输入数据类型正确。
    • 确保图像尺寸匹配。
  2. 快速路径检查:

    • 使用 Uint32Array 快速比较两个图像是否完全相同。如果相同且需要输出,则绘制灰度图像。
  3. 像素比较:

    • 遍历每个像素,计算颜色差异。
    • 使用 YIQ 颜色空间来测量颜色差异。
    • 如果差异超过阈值,检查是否由于抗锯齿造成。
    • 根据差异类型,使用不同颜色绘制到输出图像中。
  4. 返回结果:

    • 返回不匹配的像素数量。

匹配阈值处理

colorDelta 方法通过 YIQ 色彩空间计算两个像素的颜色差异,支持亮度差异和完整颜色差异的计算。它在处理透明像素时会与背景色混合,确保计算结果的准确性。

  1. 计算分量差值

    • 计算两个像素的红、绿、蓝和透明度分量的差值。
  2. 透明度处理

    • 如果像素的透明度小于 255(不完全不透明),将像素与背景色混合后重新计算分量差值。
  3. 亮度差异

    • 使用 YIQ 色彩空间的公式计算亮度差异(Y 分量)。
  4. 色度差异

    • 如果需要完整的颜色差异,进一步计算色度差异(I 和 Q 分量)。
  5. 综合差异

    • 使用加权公式综合亮度和色度差异,得到最终的颜色差异值。
  6. 符号编码

    • 根据亮度差异的正负,返回正或负的颜色差异值,用于表示像素变亮或变暗。

源码

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
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
/**
* 比较两个大小相同的图像,逐像素进行比较。
*
* @param {Uint8Array | Uint8ClampedArray} img1 第一个图像数据。
* @param {Uint8Array | Uint8ClampedArray} img2 第二个图像数据。
* @param {Uint8Array | Uint8ClampedArray | void} output 如果提供,写入差异的图像数据。
* @param {number} width 输入图像的宽度。
* @param {number} height 输入图像的高度。
*
* @param {Object} [options]
* @param {number} [options.threshold=0.1] 匹配阈值(0到1);值越小越敏感。
* @param {boolean} [options.includeAA=false] 是否跳过抗锯齿检测。
* @param {number} [options.alpha=0.1] 差异输出中原始图像的不透明度。
* @param {[number, number, number]} [options.aaColor=[255, 255, 0]] 差异输出中抗锯齿像素的颜色。
* @param {[number, number, number]} [options.diffColor=[255, 0, 0]] 差异输出中不同像素的颜色。
* @param {[number, number, number]} [options.diffColorAlt=options.diffColor] 检测图像1和图像2之间的暗对亮差异,并设置替代颜色以区分两者。
* @param {boolean} [options.diffMask=false] 在透明背景上绘制差异(遮罩)。
*
* @return {number} 不匹配的像素数量。
*/
export default function pixelmatch(img1, img2, output, width, height, options = {}) {
const {
threshold = 0.1, // 匹配阈值,默认值为0.1
alpha = 0.1, // 原始图像在差异输出中的不透明度
aaColor = [255, 255, 0], // 抗锯齿像素的颜色
diffColor = [255, 0, 0], // 不同像素的颜色
includeAA, diffColorAlt, diffMask
} = options;

// 检查输入图像数据是否有效
if (!isPixelData(img1) || !isPixelData(img2) || (output && !isPixelData(output)))
throw new Error('图像数据:需要Uint8Array、Uint8ClampedArray或Buffer。');

// 检查图像大小是否匹配
if (img1.length !== img2.length || (output && output.length !== img1.length))
throw new Error('图像大小不匹配。');

if (img1.length !== width * height * 4) throw new Error('图像数据大小与宽度/高度不匹配。');

// 检查图像是否完全相同
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) { // 如果完全相同,快速返回
if (output && !diffMask) {
for (let i = 0; i < len; i++) drawGrayPixel(img1, 4 * i, alpha, output);
}
return 0;
}

// 最大可接受的颜色平方距离
const maxDelta = 35215 * threshold * threshold;
const [aaR, aaG, aaB] = aaColor;
const [diffR, diffG, diffB] = diffColor;
const [altR, altG, altB] = diffColorAlt || diffColor;
let diff = 0;

// 比较每个像素
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {

const i = y * width + x;
const pos = i * 4;

// 计算颜色差异
const delta = a32[i] === b32[i] ? 0 : colorDelta(img1, img2, pos, pos, false);

// 如果颜色差异超过阈值
if (Math.abs(delta) > maxDelta) {
// 检查是否是抗锯齿
const isAA = antialiased(img1, x, y, width, height, a32, b32) || antialiased(img2, x, y, width, height, b32, a32);
if (!includeAA && isAA) {
// 如果是抗锯齿像素,绘制为黄色,不计入差异
if (output && !diffMask) drawPixel(output, pos, aaR, aaG, aaB);

} else {
// 找到显著差异,绘制差异
if (output) {
if (delta < 0) {
drawPixel(output, pos, altR, altG, altB);
} else {
drawPixel(output, pos, diffR, diffG, diffB);
}
}
diff++;
}

} else if (output && !diffMask) {
// 像素相似,绘制为灰度背景
drawGrayPixel(img1, pos, alpha, output);
}
}
}

// 返回不同像素的数量
return diff;
}

// 检查数组是否为像素数据
function isPixelData(arr) {
return ArrayBuffer.isView(arr) && arr.BYTES_PER_ELEMENT === 1;
}

/**
* 根据论文《Measuring perceived color difference using YIQ NTSC transmission color space in mobile applications》
* 计算两个像素之间的颜色差异。
*
* @param {Uint8Array | Uint8ClampedArray} img1 - 第一个图像的像素数据(RGBA 格式)。
* @param {Uint8Array | Uint8ClampedArray} img2 - 第二个图像的像素数据(RGBA 格式)。
* @param {number} k - 第一个图像中像素的起始索引。
* @param {number} m - 第二个图像中像素的起始索引。
* @param {boolean} yOnly - 是否仅计算亮度差异(Y 分量)。
* @returns {number} - 返回两个像素之间的颜色差异值。
*/
function colorDelta(img1, img2, k, m, yOnly) {
const r1 = img1[k]; // 第一个图像的红色分量
const g1 = img1[k + 1]; // 第一个图像的绿色分量
const b1 = img1[k + 2]; // 第一个图像的蓝色分量
const a1 = img1[k + 3]; // 第一个图像的透明度分量

const r2 = img2[m]; // 第二个图像的红色分量
const g2 = img2[m + 1]; // 第二个图像的绿色分量
const b2 = img2[m + 2]; // 第二个图像的蓝色分量
const a2 = img2[m + 3]; // 第二个图像的透明度分量

let dr = r1 - r2; // 红色分量的差值
let dg = g1 - g2; // 绿色分量的差值
let db = b1 - b2; // 蓝色分量的差值
const da = a1 - a2; // 透明度分量的差值

// 如果所有分量的差值都为 0,直接返回 0(像素完全相同)
if (!dr && !dg && !db && !da) return 0;

// 如果透明度小于 255,混合像素与背景色
if (a1 < 255 || a2 < 255) {
const rb = 48 + 159 * (k % 2); // 背景色的红色分量
const gb = 48 + 159 * ((k / 1.618033988749895 | 0) % 2); // 背景色的绿色分量
const bb = 48 + 159 * ((k / 2.618033988749895 | 0) % 2); // 背景色的蓝色分量
dr = (r1 * a1 - r2 * a2 - rb * da) / 255; // 混合后的红色分量差值
dg = (g1 * a1 - g2 * a2 - gb * da) / 255; // 混合后的绿色分量差值
db = (b1 * a1 - b2 * a2 - bb * da) / 255; // 混合后的蓝色分量差值
}

// 计算亮度差异(Y 分量)
const y = dr * 0.29889531 + dg * 0.58662247 + db * 0.11448223;

// 如果只需要亮度差异,直接返回 Y 分量
if (yOnly) return y;

// 计算色度差异(I 和 Q 分量)
const i = dr * 0.59597799 - dg * 0.27417610 - db * 0.32180189;
const q = dr * 0.21147017 - dg * 0.52261711 + db * 0.31114694;

// 综合亮度和色度差异,计算最终的颜色差异
const delta = 0.5053 * y * y + 0.299 * i * i + 0.1957 * q * q;

// 根据亮度差异的符号,返回正或负的颜色差异值
return y > 0 ? -delta : delta;
}

/**
* 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
* @param {Uint8Array | Uint8ClampedArray} img
* @param {number} x1
* @param {number} y1
* @param {number} width
* @param {number} height
* @param {Uint32Array} a32
* @param {Uint32Array} b32
*/
function antialiased(img, x1, y1, width, height, a32, b32) {
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;
let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0;
let min = 0;
let max = 0;
let minX = 0;
let minY = 0;
let maxX = 0;
let maxY = 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;

// brightness delta between the center pixel and adjacent one
const delta = colorDelta(img, img, pos * 4, (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(a32, minX, minY, width, height) && hasManySiblings(b32, minX, minY, width, height)) ||
(hasManySiblings(a32, maxX, maxY, width, height) && hasManySiblings(b32, maxX, maxY, width, height));
}

/**
* Check if a pixel has 3+ adjacent pixels of the same color.
* @param {Uint32Array} img
* @param {number} x1
* @param {number} y1
* @param {number} width
* @param {number} height
*/
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 val = img[y1 * width + x1];
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;
zeroes += +(val === img[y * width + x]);
if (zeroes > 2) return true;
}
}
return false;
}


/**
* @param {Uint8Array | Uint8ClampedArray} output
* @param {number} pos
* @param {number} r
* @param {number} g
* @param {number} b
*/
function drawPixel(output, pos, r, g, b) {
output[pos + 0] = r;
output[pos + 1] = g;
output[pos + 2] = b;
output[pos + 3] = 255;
}

/**
* @param {Uint8Array | Uint8ClampedArray} img
* @param {number} i
* @param {number} alpha
* @param {Uint8Array | Uint8ClampedArray} output
*/
function drawGrayPixel(img, i, alpha, output) {
const val = 255 + (img[i] * 0.29889531 + img[i + 1] * 0.58662247 + img[i + 2] * 0.11448223 - 255) * alpha * img[i + 3] / 255;
drawPixel(output, i, val, val, val);
}