Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/fix-ignore-colors-parity.md
Original file line number Diff line number Diff line change
@@ -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))
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
16 changes: 16 additions & 0 deletions packages/image-comparison-core/src/pixelmatch/compareBrightness.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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 () => {
Expand Down
14 changes: 3 additions & 11 deletions packages/image-comparison-core/src/pixelmatch/compareImages.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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
Expand Down Expand Up @@ -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')) {
Expand Down
Loading