From 2bd0f7a6f1927920027b24eb672d687343fbdff7 Mon Sep 17 00:00:00 2001 From: wswebcreation Date: Sat, 4 Jul 2026 08:09:32 +0200 Subject: [PATCH] fix: align ignoreColors with resemble brightness comparison - replace BT.601 grayscale preprocessing in compareImages - add unit and integration tests for ignoreColors parity - add patch changeset for image-comparison-core and visual-service --- .changeset/fix-ignore-colors-parity.md | 21 ++++++++++ .../src/pixelmatch/compareBrightness.test.ts | 29 ++++++++++++++ .../src/pixelmatch/compareBrightness.ts | 16 ++++++++ .../compareImages.ignoreColors.test.ts | 38 +++++++++++++++++++ .../src/pixelmatch/compareImages.test.ts | 13 +++++-- .../src/pixelmatch/compareImages.ts | 14 ++----- 6 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 .changeset/fix-ignore-colors-parity.md create mode 100644 packages/image-comparison-core/src/pixelmatch/compareBrightness.test.ts create mode 100644 packages/image-comparison-core/src/pixelmatch/compareBrightness.ts create mode 100644 packages/image-comparison-core/src/pixelmatch/compareImages.ignoreColors.test.ts diff --git a/.changeset/fix-ignore-colors-parity.md b/.changeset/fix-ignore-colors-parity.md new file mode 100644 index 00000000..96311927 --- /dev/null +++ b/.changeset/fix-ignore-colors-parity.md @@ -0,0 +1,21 @@ +--- +"@wdio/image-comparison-core": patch +"@wdio/visual-service": patch +--- + +fix: align ignoreColors with resemble brightness comparison + +In v10, `ignoreColors` grayscaled images with BT.601 weights before pixelmatch, which does not match resemble.js brightness-only comparison (`0.3/0.59/0.11` luma). + +**What changed** + +- Grayscale preprocessing now uses resemble luma coefficients via `applyResembleGrayscale` +- Added unit and integration tests for same-brightness pass and brightness-diff fail cases + +**Migration** + +- No action needed unless you rely on `ignoreColors` tolerating hue shifts; results may differ slightly from v10 but align with resemble v9 + +### Committers: 1 + +- Wim Selles ([@wswebcreation](https://github.com/wswebcreation)) diff --git a/packages/image-comparison-core/src/pixelmatch/compareBrightness.test.ts b/packages/image-comparison-core/src/pixelmatch/compareBrightness.test.ts new file mode 100644 index 00000000..50c5f97c --- /dev/null +++ b/packages/image-comparison-core/src/pixelmatch/compareBrightness.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest' +import { toResembleBrightness, applyResembleGrayscale } from './compareBrightness.js' + +describe('compareBrightness', () => { + describe('toResembleBrightness', () => { + it('uses resemble coefficients 0.3/0.59/0.11', () => { + expect(toResembleBrightness(180, 60, 60)).toBe(96) + expect(toResembleBrightness(96, 96, 96)).toBe(96) + expect(toResembleBrightness(85, 0, 0)).toBe(26) + }) + + it('differs from BT.601 weights for some RGB values', () => { + // BT.601 would round 0.299 * 85 to 25; resemble uses 0.3 → 26 + expect(toResembleBrightness(85, 0, 0)).not.toBe(Math.round(0.299 * 85 + 0.587 * 0 + 0.114 * 0)) + }) + }) + + describe('applyResembleGrayscale', () => { + it('writes equal R/G/B channels using resemble luma', () => { + const pixels = Buffer.from([180, 60, 60, 255]) + applyResembleGrayscale(pixels, 1) + + expect(pixels[0]).toBe(96) + expect(pixels[1]).toBe(96) + expect(pixels[2]).toBe(96) + expect(pixels[3]).toBe(255) + }) + }) +}) diff --git a/packages/image-comparison-core/src/pixelmatch/compareBrightness.ts b/packages/image-comparison-core/src/pixelmatch/compareBrightness.ts new file mode 100644 index 00000000..69f2f261 --- /dev/null +++ b/packages/image-comparison-core/src/pixelmatch/compareBrightness.ts @@ -0,0 +1,16 @@ +/** Resemble.js ITU-R BT.601 luma weights used for ignoreColors brightness comparison. */ +export const RESEMBLE_LUMA_WEIGHTS = { r: 0.3, g: 0.59, b: 0.11 } as const + +export function toResembleBrightness(r: number, g: number, b: number): number { + const { r: redWeight, g: greenWeight, b: blueWeight } = RESEMBLE_LUMA_WEIGHTS + return Math.round(redWeight * r + greenWeight * g + blueWeight * b) +} + +export function applyResembleGrayscale(pixels: Buffer, totalPixels: number): void { + for (let i = 0; i < totalPixels * 4; i += 4) { + const luma = toResembleBrightness(pixels[i], pixels[i + 1], pixels[i + 2]) + pixels[i] = luma + pixels[i + 1] = luma + pixels[i + 2] = luma + } +} diff --git a/packages/image-comparison-core/src/pixelmatch/compareImages.ignoreColors.test.ts b/packages/image-comparison-core/src/pixelmatch/compareImages.ignoreColors.test.ts new file mode 100644 index 00000000..c9e4c7bc --- /dev/null +++ b/packages/image-comparison-core/src/pixelmatch/compareImages.ignoreColors.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest' +import compareImages from './compareImages.js' +import { createCanvas, encodeImage } from '../utils/imageUtils.js' + +function solidColorImage(r: number, g: number, b: number): Buffer { + return encodeImage(createCanvas(1, 1, r, g, b, 255)) +} + +describe('compareImages ignoreColors parity', () => { + it('passes when hue changes but resemble brightness matches', async () => { + const baseline = solidColorImage(180, 60, 60) + const actual = solidColorImage(96, 96, 96) + + const result = await compareImages(baseline, actual, { ignore: 'colors' }) + + expect(result.rawMisMatchPercentage).toBe(0) + expect(result.diffPixels).toHaveLength(0) + }) + + it('fails when brightness differs under ignoreColors', async () => { + const baseline = solidColorImage(96, 96, 96) + const actual = solidColorImage(120, 120, 120) + + const result = await compareImages(baseline, actual, { ignore: 'colors' }) + + expect(result.rawMisMatchPercentage).toBeGreaterThan(0) + expect(result.diffPixels.length).toBeGreaterThan(0) + }) + + it('fails for a color-only diff without ignoreColors', async () => { + const baseline = solidColorImage(180, 60, 60) + const actual = solidColorImage(96, 96, 96) + + const result = await compareImages(baseline, actual, {}) + + expect(result.rawMisMatchPercentage).toBeGreaterThan(0) + }) +}) diff --git a/packages/image-comparison-core/src/pixelmatch/compareImages.test.ts b/packages/image-comparison-core/src/pixelmatch/compareImages.test.ts index 6cb3cdc9..bfd98ce8 100644 --- a/packages/image-comparison-core/src/pixelmatch/compareImages.test.ts +++ b/packages/image-comparison-core/src/pixelmatch/compareImages.test.ts @@ -243,7 +243,12 @@ describe('pixelmatch adapter - compareImages', () => { }) describe('pixel transformations', () => { - it('grayscales both pixel arrays when ignore includes colors', async () => { + it('grayscales both pixel arrays with resemble luma when ignore includes colors', async () => { + decodeImageFn.mockReturnValue({ + data: Uint8Array.from([180, 60, 60, 255]), + width: 1, + height: 1, + }) let capturedPixels1: Uint8Array | undefined pixelmatchFn.mockImplementation((img1: Uint8Array) => { @@ -253,9 +258,9 @@ describe('pixelmatch adapter - compareImages', () => { await compareImages(Buffer.from('img1'), Buffer.from('img2'), { ignore: 'colors' }) - // After grayscale, R=G=B for every pixel (luma of 128,128,128 = 128) - expect(capturedPixels1![0]).toBe(capturedPixels1![1]) - expect(capturedPixels1![1]).toBe(capturedPixels1![2]) + expect(capturedPixels1![0]).toBe(96) + expect(capturedPixels1![1]).toBe(96) + expect(capturedPixels1![2]).toBe(96) }) it('sets all alpha channels to 255 when ignore includes alpha', async () => { diff --git a/packages/image-comparison-core/src/pixelmatch/compareImages.ts b/packages/image-comparison-core/src/pixelmatch/compareImages.ts index 536ffc88..9ac06180 100644 --- a/packages/image-comparison-core/src/pixelmatch/compareImages.ts +++ b/packages/image-comparison-core/src/pixelmatch/compareImages.ts @@ -1,5 +1,6 @@ import pixelmatch from 'pixelmatch' import { resolveComparePreset } from '../helpers/options.js' +import { applyResembleGrayscale } from './compareBrightness.js' import { decodeImage, resizeBilinear, encodeImage, type RawImage } from '../utils/imageUtils.js' import type { CompareData, ComparisonOptions, ComparisonIgnoreOption } from './compare.interfaces.js' @@ -11,15 +12,6 @@ function resolveIgnoreList(ignore: ComparisonOptions['ignore']): ComparisonIgnor return Array.isArray(ignore) ? ignore : [ignore] } -function grayscalePixels(pixels: Buffer, totalPixels: number): void { - for (let i = 0; i < totalPixels * 4; i += 4) { - const luma = Math.round(0.299 * pixels[i] + 0.587 * pixels[i + 1] + 0.114 * pixels[i + 2]) - pixels[i] = luma - pixels[i + 1] = luma - pixels[i + 2] = luma - } -} - function opaqueAlphaChannel(pixels: Buffer, totalPixels: number): void { for (let i = 3; i < totalPixels * 4; i += 4) { pixels[i] = 255 @@ -97,8 +89,8 @@ export default async function compareImages( const ignoreList = resolveIgnoreList(options.ignore) if (ignoreList.includes('colors')) { - grayscalePixels(pixels1, totalPixels) - grayscalePixels(pixels2, totalPixels) + applyResembleGrayscale(pixels1, totalPixels) + applyResembleGrayscale(pixels2, totalPixels) } if (ignoreList.includes('alpha')) {