From 0a30be17a5a55886eb1ed257fdd95d230bb53927 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Thu, 8 Oct 2026 19:56:22 -0400 Subject: [PATCH 1/2] feat: compare images with pixelmatch 8 pixelmatch 8 measures color differences with OKLab and HyAB instead of YIQ. The API, the threshold scale and its default did not change, so the ignore* presets keep their values; the comments and the README that described them in YIQ terms are updated. Mismatch numbers can change a little (about -3 % to +3 % on real screenshots), so the changeset is a major for v11. The public checkerboard option keeps working (pixelmatch 8 supports it). Co-Authored-By: Claude Opus 5.5 --- .changeset/pixelmatch-8.md | 10 ++++++++++ packages/image-comparison-core/README.md | 4 ++-- packages/image-comparison-core/package.json | 6 +++--- packages/image-comparison-core/src/base.interfaces.ts | 2 +- .../src/helpers/options.interfaces.ts | 4 ++-- packages/image-comparison-core/src/helpers/options.ts | 6 ++++-- pnpm-lock.yaml | 10 +++++----- 7 files changed, 27 insertions(+), 15 deletions(-) create mode 100644 .changeset/pixelmatch-8.md diff --git a/.changeset/pixelmatch-8.md b/.changeset/pixelmatch-8.md new file mode 100644 index 000000000..1fae7066f --- /dev/null +++ b/.changeset/pixelmatch-8.md @@ -0,0 +1,10 @@ +--- +"@wdio/image-comparison-core": major +"@wdio/visual-service": major +--- + +feat: compare images with pixelmatch 8 (OKLab/HyAB color distance) + +The comparison engine is now pixelmatch 8. It measures color differences in the OKLab color space with the HyAB distance instead of YIQ, which is closer to how people see colors: fewer false positives and fewer missed changes. + +The threshold scale did not change (`0` to `1`, where `1` is black vs white), so the `ignore*` presets keep their values. But **mismatch percentages can differ a little** from v10 for the same images. On real screenshots the number of different pixels changed by about −3 % to +3 % (for a 1-pixel shift of a full page with `ignoreAntialiasing`: 0.002 % → 0.003 %). If a check depends on an exact mismatch percentage or a tight tolerance, check it again after the upgrade. diff --git a/packages/image-comparison-core/README.md b/packages/image-comparison-core/README.md index 331f611e3..f097cbbf7 100644 --- a/packages/image-comparison-core/README.md +++ b/packages/image-comparison-core/README.md @@ -21,7 +21,7 @@ v10 uses [pixelmatch](https://github.com/mapbox/pixelmatch) instead of resemble. |---|---|---| | AA forgiveness | opt-in (`ignoreAntialiasing: true`) | on by default (`ignoreAntialiasing: true`) | | Strict comparison | default | set `ignoreAntialiasing: false` | -| Engine | resemble RGB/brightness | pixelmatch YIQ perceptual distance | +| Engine | resemble RGB/brightness | pixelmatch perceptual distance (OKLab/HyAB since v11, YIQ in v10) | No config change is needed if you rely on forgiving comparison behaviour. @@ -36,7 +36,7 @@ No config change is needed if you rely on forgiving comparison behaviour. | `ignoreColors` | resemble luma grayscale | ~16/255 (`0.063`) | no | | `ignoreNothing` | - | `0` | no | -Thresholds are calibrated to resemble outcomes; the underlying algorithm is YIQ perceptual distance, not resemble's RGB math. +Thresholds are calibrated to resemble outcomes; the underlying algorithm is pixelmatch's perceptual color distance, not resemble's RGB math. Since v11 this is pixelmatch 8 (OKLab color space and HyAB distance; v10 used YIQ). pixelmatch 8 keeps the threshold scale (`0` to `1`, where `1` is black vs white), so the thresholds above stay the same, but mismatch numbers can differ a little from v10. ### Last-wins semantics diff --git a/packages/image-comparison-core/package.json b/packages/image-comparison-core/package.json index 8c9ddd146..72e22744f 100644 --- a/packages/image-comparison-core/package.json +++ b/packages/image-comparison-core/package.json @@ -37,10 +37,10 @@ "watch:tsc": "pnpm run build:tsc -w" }, "dependencies": { - "fast-png": "^8.0.0", - "pixelmatch": "^7.2.0", "@wdio/logger": "^10.0.0", - "@wdio/types": "^10.0.0" + "@wdio/types": "^10.0.0", + "fast-png": "^8.0.0", + "pixelmatch": "^8.0.0" }, "devDependencies": { "webdriverio": "^10.0.0" diff --git a/packages/image-comparison-core/src/base.interfaces.ts b/packages/image-comparison-core/src/base.interfaces.ts index d917e4995..d0c7b8a0d 100644 --- a/packages/image-comparison-core/src/base.interfaces.ts +++ b/packages/image-comparison-core/src/base.interfaces.ts @@ -114,7 +114,7 @@ export interface BaseImageCompareOptions { */ ignoreColors?: boolean; /** - * Use a relaxed RGB tolerance (~16/255 per channel in YIQ space). + * Use a relaxed color tolerance (pixelmatch threshold `0.063`). * Preset: strict threshold, AA not forgiven (does not inherit default AA forgiveness). * @default false */ diff --git a/packages/image-comparison-core/src/helpers/options.interfaces.ts b/packages/image-comparison-core/src/helpers/options.interfaces.ts index 563bc8763..26a04ac78 100644 --- a/packages/image-comparison-core/src/helpers/options.interfaces.ts +++ b/packages/image-comparison-core/src/helpers/options.interfaces.ts @@ -184,7 +184,7 @@ export interface ClassOptions { ignoreColors?: boolean; /** - * Use a relaxed RGB tolerance (~16/255 per channel in YIQ space). + * Use a relaxed color tolerance (pixelmatch threshold `0.063`). * Preset: strict threshold, AA not forgiven (does not inherit default AA forgiveness). */ ignoreLess?: boolean; @@ -496,7 +496,7 @@ export interface IgnorePresetCompareOptions { ignoreColors: boolean; /** - * Use a relaxed RGB tolerance (~16/255 per channel in YIQ space). + * Use a relaxed color tolerance (pixelmatch threshold `0.063`). * Preset: strict threshold, AA not forgiven (does not inherit default AA forgiveness). */ ignoreLess: boolean; diff --git a/packages/image-comparison-core/src/helpers/options.ts b/packages/image-comparison-core/src/helpers/options.ts index 7fdb665b0..da3c96629 100644 --- a/packages/image-comparison-core/src/helpers/options.ts +++ b/packages/image-comparison-core/src/helpers/options.ts @@ -504,7 +504,8 @@ const COMPARE_PRESET_SETTINGS: Record Date: Thu, 8 Oct 2026 20:07:18 -0400 Subject: [PATCH 2/2] test: run the real pixelmatch with checkerboard on and off The other checkerboard tests mock pixelmatch, so they only check that the option is passed. A fully transparent image against an opaque white image: with checkerboard false pixelmatch blends the transparent pixels with white (no difference), with true it blends them with a checker pattern (all 256 pixels differ). The first test fails when compareImages stops passing the option; pixelmatch 7 gives the same results. Co-Authored-By: Claude Opus 5.5 --- .../compareImages.checkerboard.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 packages/image-comparison-core/src/pixelmatch/compareImages.checkerboard.test.ts diff --git a/packages/image-comparison-core/src/pixelmatch/compareImages.checkerboard.test.ts b/packages/image-comparison-core/src/pixelmatch/compareImages.checkerboard.test.ts new file mode 100644 index 000000000..1f5d32174 --- /dev/null +++ b/packages/image-comparison-core/src/pixelmatch/compareImages.checkerboard.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import compareImages from './compareImages.js' +import { DEFAULT_PIXELMATCH_OPTIONS } from '../helpers/constants.js' +import { createCanvas, encodeImage } from '../utils/imageUtils.js' + +// Runs the real pixelmatch (no mock): `checkerboard` only changes how pixelmatch blends semi-transparent pixels +const SIZE = 16 +const transparent = encodeImage(createCanvas(SIZE, SIZE, 0, 0, 0, 0)) +const opaqueWhite = encodeImage(createCanvas(SIZE, SIZE, 255, 255, 255, 255)) + +function compareWithCheckerboard(checkerboard: boolean) { + return compareImages(transparent, opaqueWhite, { + pixelmatch: { ...DEFAULT_PIXELMATCH_OPTIONS, threshold: 0.063, includeAA: true, checkerboard }, + }) +} + +describe('compareImages checkerboard', () => { + it('blends transparent pixels with white when checkerboard is false, so they match white', async () => { + const result = await compareWithCheckerboard(false) + + expect(result.rawMisMatchPercentage).toBe(0) + expect(result.diffPixels).toHaveLength(0) + }) + + it('blends transparent pixels with a checker pattern when checkerboard is true, so they differ from white', async () => { + const result = await compareWithCheckerboard(true) + + expect(result.rawMisMatchPercentage).toBeGreaterThan(0) + expect(result.diffPixels.length).toBeGreaterThan(0) + }) +})