2022-02-18 12:21:58 -07:00
|
|
|
/**
|
|
|
|
* Copyright 2017 Google Inc. All rights reserved.
|
|
|
|
* Modifications copyright (c) Microsoft Corporation.
|
|
|
|
*
|
|
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
* you may not use this file except in compliance with the License.
|
|
|
|
* You may obtain a copy of the License at
|
|
|
|
*
|
|
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
*
|
|
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
* See the License for the specific language governing permissions and
|
|
|
|
* limitations under the License.
|
|
|
|
*/
|
|
|
|
|
2022-04-18 19:20:49 -08:00
|
|
|
import { colors, jpegjs } from '../utilsBundle';
|
2022-12-06 15:46:19 -08:00
|
|
|
const pixelmatch = require('../third_party/pixelmatch');
|
feat: implement a new image comparison function (#19166)
This patch implements a new image comparison function, codenamed
"ssim-cie94". The goal of the new comparison function is to cancel out
browser non-determenistic rendering.
To use the new comparison function:
```ts
await expect(page).toHaveScreenshot({
comparator: 'ssim-cie94',
});
```
As of Nov 30, 2022, we identified the following sources of
non-determenistic rendering for Chromium:
- Anti-aliasing for certain shapes might be different due to the
way skia rasterizes certain shapes.
- Color blending might be different on `x86` and `aarch64`
architectures.
The new function employs a few heuristics to fight these
differences.
Consider two non-equal image pixels `(r1, g1, b1)` and `(r2, g2, b2)`:
1. If the [CIE94] metric is less then 1.0, then we consider these pixels
**EQUAL**. (The value `1.0` is the [just-noticeable difference] for
[CIE94].). Otherwise, proceed to next step.
1. If all the 8 neighbors of the first pixel match its color, or
if the 8 neighbors of the second pixel match its color, then these
pixels are **DIFFERENT**. (In case of anti-aliasing, some of the
direct neighbors have to be blended up or down.) Otherwise, proceed
to next step.
1. If SSIM in some locality around the different pixels is more than
0.99, then consider this pixels to be **EQUAL**. Otherwise, mark them
as **DIFFERENT**. (Local SSIM for anti-aliased pixels turns out to be
very close to 1.0).
[CIE94]: https://en.wikipedia.org/wiki/Color_difference#CIE94
[just-noticeable difference]:
https://en.wikipedia.org/wiki/Just-noticeable_difference
2022-12-02 15:22:05 -08:00
|
|
|
import { compare } from '../image_tools/compare';
|
2022-12-06 15:46:19 -08:00
|
|
|
const { diff_match_patch, DIFF_INSERT, DIFF_DELETE, DIFF_EQUAL } = require('../third_party/diff_match_patch');
|
2022-04-18 19:20:49 -08:00
|
|
|
import { PNG } from '../utilsBundle';
|
2022-02-18 12:21:58 -07:00
|
|
|
|
2024-02-05 19:07:30 -08:00
|
|
|
export type ImageComparatorOptions = { threshold?: number, maxDiffPixels?: number, maxDiffPixelRatio?: number, comparator?: string };
|
2022-03-21 16:10:33 -06:00
|
|
|
export type ComparatorResult = { diff?: Buffer; errorMessage: string; } | null;
|
2022-02-18 12:21:58 -07:00
|
|
|
export type Comparator = (actualBuffer: Buffer | string, expectedBuffer: Buffer, options?: any) => ComparatorResult;
|
2022-03-21 17:42:21 -06:00
|
|
|
|
|
|
|
export function getComparator(mimeType: string): Comparator {
|
|
|
|
if (mimeType === 'image/png')
|
feat: implement a new image comparison function (#19166)
This patch implements a new image comparison function, codenamed
"ssim-cie94". The goal of the new comparison function is to cancel out
browser non-determenistic rendering.
To use the new comparison function:
```ts
await expect(page).toHaveScreenshot({
comparator: 'ssim-cie94',
});
```
As of Nov 30, 2022, we identified the following sources of
non-determenistic rendering for Chromium:
- Anti-aliasing for certain shapes might be different due to the
way skia rasterizes certain shapes.
- Color blending might be different on `x86` and `aarch64`
architectures.
The new function employs a few heuristics to fight these
differences.
Consider two non-equal image pixels `(r1, g1, b1)` and `(r2, g2, b2)`:
1. If the [CIE94] metric is less then 1.0, then we consider these pixels
**EQUAL**. (The value `1.0` is the [just-noticeable difference] for
[CIE94].). Otherwise, proceed to next step.
1. If all the 8 neighbors of the first pixel match its color, or
if the 8 neighbors of the second pixel match its color, then these
pixels are **DIFFERENT**. (In case of anti-aliasing, some of the
direct neighbors have to be blended up or down.) Otherwise, proceed
to next step.
1. If SSIM in some locality around the different pixels is more than
0.99, then consider this pixels to be **EQUAL**. Otherwise, mark them
as **DIFFERENT**. (Local SSIM for anti-aliased pixels turns out to be
very close to 1.0).
[CIE94]: https://en.wikipedia.org/wiki/Color_difference#CIE94
[just-noticeable difference]:
https://en.wikipedia.org/wiki/Just-noticeable_difference
2022-12-02 15:22:05 -08:00
|
|
|
return compareImages.bind(null, 'image/png');
|
2022-03-21 17:42:21 -06:00
|
|
|
if (mimeType === 'image/jpeg')
|
|
|
|
return compareImages.bind(null, 'image/jpeg');
|
|
|
|
if (mimeType === 'text/plain')
|
|
|
|
return compareText;
|
|
|
|
return compareBuffersOrStrings;
|
|
|
|
}
|
2022-02-18 12:21:58 -07:00
|
|
|
|
2022-07-05 13:25:54 -07:00
|
|
|
const JPEG_JS_MAX_BUFFER_SIZE_IN_MB = 5 * 1024; // ~5 GB
|
|
|
|
|
2022-02-18 12:21:58 -07:00
|
|
|
function compareBuffersOrStrings(actualBuffer: Buffer | string, expectedBuffer: Buffer): ComparatorResult {
|
|
|
|
if (typeof actualBuffer === 'string')
|
|
|
|
return compareText(actualBuffer, expectedBuffer);
|
|
|
|
if (!actualBuffer || !(actualBuffer instanceof Buffer))
|
|
|
|
return { errorMessage: 'Actual result should be a Buffer or a string.' };
|
|
|
|
if (Buffer.compare(actualBuffer, expectedBuffer))
|
|
|
|
return { errorMessage: 'Buffers differ' };
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2023-01-20 19:41:43 -08:00
|
|
|
type ImageData = { width: number, height: number, data: Buffer };
|
|
|
|
|
2022-02-18 12:21:58 -07:00
|
|
|
function compareImages(mimeType: string, actualBuffer: Buffer | string, expectedBuffer: Buffer, options: ImageComparatorOptions = {}): ComparatorResult {
|
|
|
|
if (!actualBuffer || !(actualBuffer instanceof Buffer))
|
|
|
|
return { errorMessage: 'Actual result should be a Buffer.' };
|
2023-05-15 23:32:16 +02:00
|
|
|
validateBuffer(expectedBuffer, mimeType);
|
2022-02-18 12:21:58 -07:00
|
|
|
|
2023-01-20 19:41:43 -08:00
|
|
|
let actual: ImageData = mimeType === 'image/png' ? PNG.sync.read(actualBuffer) : jpegjs.decode(actualBuffer, { maxMemoryUsageInMB: JPEG_JS_MAX_BUFFER_SIZE_IN_MB });
|
|
|
|
let expected: ImageData = mimeType === 'image/png' ? PNG.sync.read(expectedBuffer) : jpegjs.decode(expectedBuffer, { maxMemoryUsageInMB: JPEG_JS_MAX_BUFFER_SIZE_IN_MB });
|
|
|
|
const size = { width: Math.max(expected.width, actual.width), height: Math.max(expected.height, actual.height) };
|
|
|
|
let sizesMismatchError = '';
|
2022-02-18 12:21:58 -07:00
|
|
|
if (expected.width !== actual.width || expected.height !== actual.height) {
|
2023-01-20 19:41:43 -08:00
|
|
|
sizesMismatchError = `Expected an image ${expected.width}px by ${expected.height}px, received ${actual.width}px by ${actual.height}px. `;
|
|
|
|
actual = resizeImage(actual, size);
|
|
|
|
expected = resizeImage(expected, size);
|
2022-02-18 12:21:58 -07:00
|
|
|
}
|
2023-01-20 19:41:43 -08:00
|
|
|
const diff = new PNG({ width: size.width, height: size.height });
|
feat: implement a new image comparison function (#19166)
This patch implements a new image comparison function, codenamed
"ssim-cie94". The goal of the new comparison function is to cancel out
browser non-determenistic rendering.
To use the new comparison function:
```ts
await expect(page).toHaveScreenshot({
comparator: 'ssim-cie94',
});
```
As of Nov 30, 2022, we identified the following sources of
non-determenistic rendering for Chromium:
- Anti-aliasing for certain shapes might be different due to the
way skia rasterizes certain shapes.
- Color blending might be different on `x86` and `aarch64`
architectures.
The new function employs a few heuristics to fight these
differences.
Consider two non-equal image pixels `(r1, g1, b1)` and `(r2, g2, b2)`:
1. If the [CIE94] metric is less then 1.0, then we consider these pixels
**EQUAL**. (The value `1.0` is the [just-noticeable difference] for
[CIE94].). Otherwise, proceed to next step.
1. If all the 8 neighbors of the first pixel match its color, or
if the 8 neighbors of the second pixel match its color, then these
pixels are **DIFFERENT**. (In case of anti-aliasing, some of the
direct neighbors have to be blended up or down.) Otherwise, proceed
to next step.
1. If SSIM in some locality around the different pixels is more than
0.99, then consider this pixels to be **EQUAL**. Otherwise, mark them
as **DIFFERENT**. (Local SSIM for anti-aliased pixels turns out to be
very close to 1.0).
[CIE94]: https://en.wikipedia.org/wiki/Color_difference#CIE94
[just-noticeable difference]:
https://en.wikipedia.org/wiki/Just-noticeable_difference
2022-12-02 15:22:05 -08:00
|
|
|
let count;
|
2024-02-05 19:07:30 -08:00
|
|
|
if (options.comparator === 'ssim-cie94') {
|
2023-01-20 19:41:43 -08:00
|
|
|
count = compare(expected.data, actual.data, diff.data, size.width, size.height, {
|
2022-12-06 17:03:13 -08:00
|
|
|
// All ΔE* formulae are originally designed to have the difference of 1.0 stand for a "just noticeable difference" (JND).
|
|
|
|
// See https://en.wikipedia.org/wiki/Color_difference#CIELAB_%CE%94E*
|
|
|
|
maxColorDeltaE94: 1.0,
|
feat: implement a new image comparison function (#19166)
This patch implements a new image comparison function, codenamed
"ssim-cie94". The goal of the new comparison function is to cancel out
browser non-determenistic rendering.
To use the new comparison function:
```ts
await expect(page).toHaveScreenshot({
comparator: 'ssim-cie94',
});
```
As of Nov 30, 2022, we identified the following sources of
non-determenistic rendering for Chromium:
- Anti-aliasing for certain shapes might be different due to the
way skia rasterizes certain shapes.
- Color blending might be different on `x86` and `aarch64`
architectures.
The new function employs a few heuristics to fight these
differences.
Consider two non-equal image pixels `(r1, g1, b1)` and `(r2, g2, b2)`:
1. If the [CIE94] metric is less then 1.0, then we consider these pixels
**EQUAL**. (The value `1.0` is the [just-noticeable difference] for
[CIE94].). Otherwise, proceed to next step.
1. If all the 8 neighbors of the first pixel match its color, or
if the 8 neighbors of the second pixel match its color, then these
pixels are **DIFFERENT**. (In case of anti-aliasing, some of the
direct neighbors have to be blended up or down.) Otherwise, proceed
to next step.
1. If SSIM in some locality around the different pixels is more than
0.99, then consider this pixels to be **EQUAL**. Otherwise, mark them
as **DIFFERENT**. (Local SSIM for anti-aliased pixels turns out to be
very close to 1.0).
[CIE94]: https://en.wikipedia.org/wiki/Color_difference#CIE94
[just-noticeable difference]:
https://en.wikipedia.org/wiki/Just-noticeable_difference
2022-12-02 15:22:05 -08:00
|
|
|
});
|
2024-02-05 19:07:30 -08:00
|
|
|
} else if ((options.comparator ?? 'pixelmatch') === 'pixelmatch') {
|
2023-01-20 19:41:43 -08:00
|
|
|
count = pixelmatch(expected.data, actual.data, diff.data, size.width, size.height, {
|
feat: implement a new image comparison function (#19166)
This patch implements a new image comparison function, codenamed
"ssim-cie94". The goal of the new comparison function is to cancel out
browser non-determenistic rendering.
To use the new comparison function:
```ts
await expect(page).toHaveScreenshot({
comparator: 'ssim-cie94',
});
```
As of Nov 30, 2022, we identified the following sources of
non-determenistic rendering for Chromium:
- Anti-aliasing for certain shapes might be different due to the
way skia rasterizes certain shapes.
- Color blending might be different on `x86` and `aarch64`
architectures.
The new function employs a few heuristics to fight these
differences.
Consider two non-equal image pixels `(r1, g1, b1)` and `(r2, g2, b2)`:
1. If the [CIE94] metric is less then 1.0, then we consider these pixels
**EQUAL**. (The value `1.0` is the [just-noticeable difference] for
[CIE94].). Otherwise, proceed to next step.
1. If all the 8 neighbors of the first pixel match its color, or
if the 8 neighbors of the second pixel match its color, then these
pixels are **DIFFERENT**. (In case of anti-aliasing, some of the
direct neighbors have to be blended up or down.) Otherwise, proceed
to next step.
1. If SSIM in some locality around the different pixels is more than
0.99, then consider this pixels to be **EQUAL**. Otherwise, mark them
as **DIFFERENT**. (Local SSIM for anti-aliased pixels turns out to be
very close to 1.0).
[CIE94]: https://en.wikipedia.org/wiki/Color_difference#CIE94
[just-noticeable difference]:
https://en.wikipedia.org/wiki/Just-noticeable_difference
2022-12-02 15:22:05 -08:00
|
|
|
threshold: options.threshold ?? 0.2,
|
|
|
|
});
|
|
|
|
} else {
|
2024-02-05 19:07:30 -08:00
|
|
|
throw new Error(`Configuration specifies unknown comparator "${options.comparator}"`);
|
feat: implement a new image comparison function (#19166)
This patch implements a new image comparison function, codenamed
"ssim-cie94". The goal of the new comparison function is to cancel out
browser non-determenistic rendering.
To use the new comparison function:
```ts
await expect(page).toHaveScreenshot({
comparator: 'ssim-cie94',
});
```
As of Nov 30, 2022, we identified the following sources of
non-determenistic rendering for Chromium:
- Anti-aliasing for certain shapes might be different due to the
way skia rasterizes certain shapes.
- Color blending might be different on `x86` and `aarch64`
architectures.
The new function employs a few heuristics to fight these
differences.
Consider two non-equal image pixels `(r1, g1, b1)` and `(r2, g2, b2)`:
1. If the [CIE94] metric is less then 1.0, then we consider these pixels
**EQUAL**. (The value `1.0` is the [just-noticeable difference] for
[CIE94].). Otherwise, proceed to next step.
1. If all the 8 neighbors of the first pixel match its color, or
if the 8 neighbors of the second pixel match its color, then these
pixels are **DIFFERENT**. (In case of anti-aliasing, some of the
direct neighbors have to be blended up or down.) Otherwise, proceed
to next step.
1. If SSIM in some locality around the different pixels is more than
0.99, then consider this pixels to be **EQUAL**. Otherwise, mark them
as **DIFFERENT**. (Local SSIM for anti-aliased pixels turns out to be
very close to 1.0).
[CIE94]: https://en.wikipedia.org/wiki/Color_difference#CIE94
[just-noticeable difference]:
https://en.wikipedia.org/wiki/Just-noticeable_difference
2022-12-02 15:22:05 -08:00
|
|
|
}
|
2022-02-18 12:21:58 -07:00
|
|
|
|
2022-03-04 00:17:31 -07:00
|
|
|
const maxDiffPixels1 = options.maxDiffPixels;
|
|
|
|
const maxDiffPixels2 = options.maxDiffPixelRatio !== undefined ? expected.width * expected.height * options.maxDiffPixelRatio : undefined;
|
|
|
|
let maxDiffPixels;
|
|
|
|
if (maxDiffPixels1 !== undefined && maxDiffPixels2 !== undefined)
|
|
|
|
maxDiffPixels = Math.min(maxDiffPixels1, maxDiffPixels2);
|
2022-02-18 12:21:58 -07:00
|
|
|
else
|
2022-03-04 00:17:31 -07:00
|
|
|
maxDiffPixels = maxDiffPixels1 ?? maxDiffPixels2 ?? 0;
|
2022-03-04 19:55:48 -07:00
|
|
|
const ratio = Math.ceil(count / (expected.width * expected.height) * 100) / 100;
|
2023-01-20 19:41:43 -08:00
|
|
|
const pixelsMismatchError = count > maxDiffPixels ? `${count} pixels (ratio ${ratio.toFixed(2)} of all image pixels) are different.` : '';
|
|
|
|
if (pixelsMismatchError || sizesMismatchError)
|
|
|
|
return { errorMessage: sizesMismatchError + pixelsMismatchError, diff: PNG.sync.write(diff) };
|
|
|
|
return null;
|
2022-02-18 12:21:58 -07:00
|
|
|
}
|
|
|
|
|
2023-05-15 23:32:16 +02:00
|
|
|
function validateBuffer(buffer: Buffer, mimeType: string): void {
|
|
|
|
if (mimeType === 'image/png') {
|
|
|
|
const pngMagicNumber = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
|
|
if (buffer.length < pngMagicNumber.length || !pngMagicNumber.every((byte, index) => buffer[index] === byte))
|
|
|
|
throw new Error('could not decode image as PNG.');
|
|
|
|
} else if (mimeType === 'image/jpeg') {
|
|
|
|
const jpegMagicNumber = [255, 216];
|
|
|
|
if (buffer.length < jpegMagicNumber.length || !jpegMagicNumber.every((byte, index) => buffer[index] === byte))
|
|
|
|
throw new Error('could not decode image as JPEG.');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-02-18 12:21:58 -07:00
|
|
|
function compareText(actual: Buffer | string, expectedBuffer: Buffer): ComparatorResult {
|
|
|
|
if (typeof actual !== 'string')
|
|
|
|
return { errorMessage: 'Actual result should be a string' };
|
|
|
|
const expected = expectedBuffer.toString('utf-8');
|
|
|
|
if (expected === actual)
|
|
|
|
return null;
|
|
|
|
const dmp = new diff_match_patch();
|
|
|
|
const d = dmp.diff_main(expected, actual);
|
|
|
|
dmp.diff_cleanupSemantic(d);
|
|
|
|
return {
|
|
|
|
errorMessage: diff_prettyTerminal(d)
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2022-12-06 15:46:19 -08:00
|
|
|
function diff_prettyTerminal(diffs: [number, string][]) {
|
2022-02-18 12:21:58 -07:00
|
|
|
const html = [];
|
|
|
|
for (let x = 0; x < diffs.length; x++) {
|
|
|
|
const op = diffs[x][0]; // Operation (insert, delete, equal)
|
|
|
|
const data = diffs[x][1]; // Text of change.
|
|
|
|
const text = data;
|
|
|
|
switch (op) {
|
|
|
|
case DIFF_INSERT:
|
|
|
|
html[x] = colors.green(text);
|
|
|
|
break;
|
|
|
|
case DIFF_DELETE:
|
|
|
|
html[x] = colors.reset(colors.strikethrough(colors.red(text)));
|
|
|
|
break;
|
|
|
|
case DIFF_EQUAL:
|
|
|
|
html[x] = text;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return html.join('');
|
|
|
|
}
|
2023-01-20 19:41:43 -08:00
|
|
|
|
|
|
|
function resizeImage(image: ImageData, size: { width: number, height: number }): ImageData {
|
|
|
|
if (image.width === size.width && image.height === size.height)
|
|
|
|
return image;
|
|
|
|
const buffer = new Uint8Array(size.width * size.height * 4);
|
|
|
|
for (let y = 0; y < size.height; y++) {
|
|
|
|
for (let x = 0; x < size.width; x++) {
|
|
|
|
const to = (y * size.width + x) * 4;
|
|
|
|
if (y < image.height && x < image.width) {
|
|
|
|
const from = (y * image.width + x) * 4;
|
|
|
|
buffer[to] = image.data[from];
|
|
|
|
buffer[to + 1] = image.data[from + 1];
|
|
|
|
buffer[to + 2] = image.data[from + 2];
|
|
|
|
buffer[to + 3] = image.data[from + 3];
|
|
|
|
} else {
|
|
|
|
buffer[to] = 0;
|
|
|
|
buffer[to + 1] = 0;
|
|
|
|
buffer[to + 2] = 0;
|
|
|
|
buffer[to + 3] = 0;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return { data: Buffer.from(buffer), width: size.width, height: size.height };
|
|
|
|
}
|