diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index f09abbf..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index 026de17..24242ef 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,207 @@ dist/ *.log *.tsbuildinfo coverage/ + +# Created by https://www.toptal.com/developers/gitignore/api/node,macos,windows +# Edit at https://www.toptal.com/developers/gitignore?templates=node,macos,windows + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/node,macos,windows diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b1a046..c49a8d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @sitepager/pixel-match +## 0.2.0 + +### Minor Changes + +- 3bc5a84: updated cli with params + ## 0.1.2 ### Patch Changes diff --git a/README.md b/README.md index 9a35740..d2042ce 100644 --- a/README.md +++ b/README.md @@ -15,43 +15,51 @@ The package includes a command-line interface for comparing images directly from ### Basic Usage ```bash -npx @sitepager/pixel-match [diff.png] [threshold] [includeAA] [horizontalShiftPixels] [verticalShiftPixels] +npx @sitepager/pixel-match [options] ``` -### Parameters +### Options -- `image1.png`: First image to compare -- `image2.png`: Second image to compare -- `diff.png`: (Optional) Output path for the diff image -- `threshold`: (Optional) Color difference threshold (0 to 1) -- `includeAA`: (Optional) Whether to detect and ignore anti-aliasing (true/false) -- `horizontalShiftPixels`: (Optional) Number of pixels to check horizontally for similar pixels -- `verticalShiftPixels`: (Optional) Number of pixels to check vertically for similar pixels +- `-o, --output `: Output diff PNG image path +- `-t, --threshold `: Matching threshold (0-1) +- `--include-aa`: Detect and ignore anti-aliased pixels +- `--no-include-aa`: Do not ignore anti-aliased pixels +- `--horizontal-shift `: Horizontal shift in pixels +- `--vertical-shift `: Vertical shift in pixels +- `--alpha `: Alpha value for diff mask (0-1) +- `--diff-mask`: Output only the diff mask +- `--help-options`: Show all pixelmatch options and exit ### Examples Basic comparison with diff output: ```bash -npx @sitepager/pixel-match before.png after.png diff.png +npx @sitepager/pixel-match before.png after.png --output diff.png ``` With custom threshold: ```bash -npx @sitepager/pixel-match before.png after.png diff.png 0.05 +npx @sitepager/pixel-match before.png after.png --output diff.png --threshold 0.05 ``` With threshold and anti-aliasing detection: ```bash -npx @sitepager/pixel-match before.png after.png diff.png 0.05 true +npx @sitepager/pixel-match before.png after.png --output diff.png --threshold 0.05 --include-aa ``` With threshold, anti-aliasing, and pixel shift tolerance: ```bash -npx @sitepager/pixel-match before.png after.png diff.png 0.05 true 7 6 +npx @sitepager/pixel-match before.png after.png --output diff.png --threshold 0.05 --include-aa --horizontal-shift 7 --vertical-shift 6 +``` + +With diff mask and custom alpha: + +```bash +npx @sitepager/pixel-match before.png after.png --output diff.png --diff-mask --alpha 0.5 ``` ### Output @@ -66,6 +74,9 @@ The CLI will output: ### Exit Codes - `0`: Images match (no differences found) +- `1`: Failed to read image1 +- `2`: Image dimensions do not match +- `3`: Error running pixelmatch - `64`: Invalid usage (missing required arguments) - `65`: Image dimensions do not match - `66`: Images differ (differences found) @@ -98,6 +109,79 @@ const diff = pixelmatch( The number of different pixels found between the images. +### Browser vs Node.js Usage + +The library provides two different entry points for browser and Node.js environments: + +#### Browser Usage + +```typescript +import pixelmatch from '@sitepager/pixel-match/browser'; + +// Example with canvas elements +const canvas1 = document.getElementById('canvas1') as HTMLCanvasElement; +const canvas2 = document.getElementById('canvas2') as HTMLCanvasElement; +const outputCanvas = document.getElementById('output') as HTMLCanvasElement; + +const ctx1 = canvas1.getContext('2d')!; +const ctx2 = canvas2.getContext('2d')!; +const outputCtx = outputCanvas.getContext('2d')!; + +const img1 = ctx1.getImageData(0, 0, width, height); +const img2 = ctx2.getImageData(0, 0, width, height); +const output = outputCtx.createImageData(width, height); + +const diff = pixelmatch(img1.data, img2.data, output.data, width, height, { + threshold: 0.1, +}); + +outputCtx.putImageData(output, 0, 0); +``` + +#### Node.js Usage + +```typescript +import pixelmatch from '@sitepager/pixel-match/node'; +import { readFileSync } from 'fs'; +import { PNG } from 'pngjs'; + +// Read images +const img1 = PNG.sync.read(readFileSync('before.png')); +const img2 = PNG.sync.read(readFileSync('after.png')); +const { width, height } = img1; +const output = new PNG({ width, height }); + +// Compare images +const diff = await pixelmatch( + img1.data, + img2.data, + output.data, + width, + height, + { threshold: 0.1 }, +); + +// Save diff image +output.pack().pipe(createWriteStream('diff.png')); +``` + +### When to Use Each Pattern + +1. **Browser Pattern** (`@sitepager/pixel-match/browser`): + + - Use when working with canvas elements in a web browser + - Ideal for real-time image comparison in web applications + - Works with `ImageData` objects from canvas contexts + - Synchronous operation (no async/await needed) + - Best for client-side visual diff tools or image processing applications + +2. **Node.js Pattern** (`@sitepager/pixel-match/node`): + - Use when working with image files on the server + - Ideal for automated testing, CI/CD pipelines, or server-side image processing + - Works with raw image data from file systems + - Asynchronous operation (requires async/await) + - Best for automated visual regression testing or server-side image processing + ## Options ```typescript @@ -137,7 +221,7 @@ const defaultOptions = { ### Basic Comparison ```typescript -import pixelmatch from '@sitepager/pixel-match'; +import { pixelmatch } from '@sitepager/pixel-match/browser'; const diff = pixelmatch(img1, img2, output, width, height); ``` @@ -168,6 +252,45 @@ const options = { const diff = pixelmatch(img1, img2, output, width, height, options); ``` +### With Custom Anti-Aliased Pixel Color + +```typescript +const options = { + aaColor: [0, 255, 255], // Cyan for anti-aliased pixels +}; + +const diff = pixelmatch(img1, img2, output, width, height, options); +``` + +### With Alternative Diff Color for Darker Pixels + +```typescript +const options = { + diffColor: [255, 0, 0], // Red for differences (default) + diffColorAlt: [0, 0, 255], // Blue when img2 is darker +}; + +const diff = pixelmatch(img1, img2, output, width, height, options); +``` + +### Full Example with All Options + +```typescript +const options = { + threshold: 0.07, // Color difference threshold + includeAA: true, // Detect and ignore anti-aliasing + alpha: 0.3, // Opacity of unchanged pixels in diff + aaColor: [0, 255, 255], // Cyan for anti-aliased pixels + diffColor: [255, 0, 0], // Red for differences + diffColorAlt: [0, 0, 255], // Blue for darker pixels in img2 + diffMask: true, // Output only the diff mask + horizontalShiftPixels: 2, // Allow 2px horizontal shift + verticalShiftPixels: 2, // Allow 2px vertical shift +}; + +const diff = pixelmatch(img1, img2, output, width, height, options); +``` + > ⚠️ **Beta Feature Warning**: The `horizontalShiftPixels` and `verticalShiftPixels` options are currently in beta. These features can significantly impact performance as they require additional pixel comparisons. Use with caution in production environments. We are actively working on performance optimizations for these features in an upcoming release. ## Error Cases @@ -191,7 +314,7 @@ The library will throw errors in the following cases: 3. **Invalid Dimensions** ```typescript // Error: "Image data size does not match width/height" - pixelmatch(img1, img2, null, width, height); // where width * height * 4 !== img1.length + pixelmatch(img1, img2, null, width, height); ``` ## Best Practices diff --git a/bench/bench-browser.ts b/bench/bench-browser.ts new file mode 100644 index 0000000..b9ccede --- /dev/null +++ b/bench/bench-browser.ts @@ -0,0 +1,46 @@ +import { pixelmatch } from '../dist/browser/index.mjs'; +import { readImage } from './utils'; + +const caseOne = { + img1: readImage('10main'), + img2: readImage('10baseline'), + options: { + horizontalShiftPixels: 0, + verticalShiftPixels: 50, + threshold: 0.1, + }, +}; + +const caseTwo = { + img1: readImage('10main'), + img2: readImage('10baseline'), + options: { + horizontalShiftPixels: 0, + verticalShiftPixels: 0, + threshold: 0.1, + }, +}; + +const cases = [caseOne, caseTwo]; + +function runBench() { + console.time('match'); + let pixelDiffs: number[] = []; + for (let i = 0; i < 10; i++) { + for (const { img1, img2, options } of cases) { + const pixelDiff = pixelmatch( + img1.data, + img2.data, + null, + img1.width, + img1.height, + options, + ); + pixelDiffs.push(pixelDiff); + } + } + console.timeEnd('match'); + console.log(pixelDiffs); +} + +runBench(); diff --git a/bench/bench-node.ts b/bench/bench-node.ts new file mode 100644 index 0000000..d225ce9 --- /dev/null +++ b/bench/bench-node.ts @@ -0,0 +1,48 @@ +import { pixelmatch } from '../dist/node/index.mjs'; +import { readImage } from './utils'; + +const caseOne = { + img1: readImage('10main'), + img2: readImage('10baseline'), + options: { + horizontalShiftPixels: 0, + verticalShiftPixels: 50, + threshold: 0.1, + }, +}; + +const caseTwo = { + img1: readImage('10main'), + img2: readImage('10baseline'), + options: { + horizontalShiftPixels: 0, + verticalShiftPixels: 0, + threshold: 0.1, + }, +}; + +const cases = [caseOne, caseTwo]; + +async function runBench() { + console.time('match'); + let pixelDiffs: number[] = []; + for (let i = 0; i < 10; i++) { + for (const { img1, img2, options } of cases) { + const pixelDiff = await pixelmatch( + img1.data, + img2.data, + null, + img1.width, + img1.height, + options, + ); + pixelDiffs.push(pixelDiff); + } + } + console.timeEnd('match'); + console.log(pixelDiffs); +} + +runBench().finally(() => { + process.exit(0); +}); diff --git a/bench/bench.ts b/bench/bench.ts index ce28d8b..479e7b6 100644 --- a/bench/bench.ts +++ b/bench/bench.ts @@ -1,14 +1,7 @@ -import pixelmatch from '../src'; +import { pixelmatch } from '../dist/browser/index.mjs'; import { PNG } from 'pngjs'; -import fs from 'node:fs'; -function readImage(name: string): PNG { - return PNG.sync.read( - fs.readFileSync( - new URL('../test/fixtures/' + name + '.png', import.meta.url), - ), - ); -} +import { readImage } from './utils'; const data: [PNG, PNG][] = [1, 2, 3, 4, 5, 6, 7, 9].map((i) => [ readImage(`${i}a`), diff --git a/bench/utils.ts b/bench/utils.ts new file mode 100644 index 0000000..8d9f22d --- /dev/null +++ b/bench/utils.ts @@ -0,0 +1,13 @@ +import { PNG } from 'pngjs'; +import fs from 'node:fs'; + +export function readImage(name: string): PNG { + console.time('readImage'); + const val = PNG.sync.read( + fs.readFileSync( + new URL('../test/fixtures/' + name + '.png', import.meta.url), + ), + ); + console.timeEnd('readImage'); + return val; +} diff --git a/bin/pixelmatch.ts b/bin/pixelmatch.ts index 0dd8fea..65737d1 100644 --- a/bin/pixelmatch.ts +++ b/bin/pixelmatch.ts @@ -1,64 +1,116 @@ +#!/usr/bin/env node +import { Command } from 'commander'; import { PNG } from 'pngjs'; import fs from 'node:fs'; -import pixelmatch, { PixelmatchOptions } from '../src/index.js'; +import { pixelmatch, PixelmatchOptions } from '../src/node/index.js'; +import { version } from '../package.json'; -if (process.argv.length < 4) { - console.log( - 'Usage: pixelmatch image1.png image2.png [diff.png] [threshold] [includeAA] [horizontalShiftPixels] [verticalShiftPixels]', - ); - process.exit(64); -} +const program = new Command(); -const [ - , - , - img1Path, - img2Path, - diffPath, - threshold, - includeAA, - horizontalShiftPixels, - verticalShiftPixels, -] = process.argv; -const options: PixelmatchOptions = {}; -if (threshold !== undefined) options.threshold = +threshold; -if (includeAA !== undefined) options.includeAA = includeAA !== 'false'; -if (horizontalShiftPixels !== undefined) - options.horizontalShiftPixels = +horizontalShiftPixels; -if (verticalShiftPixels !== undefined) - options.verticalShiftPixels = +verticalShiftPixels; +program + .name('pixelmatch') + .description('Compare two PNG images and optionally output a diff image.') + .version(version) + .argument('', 'First PNG image path') + .argument('', 'Second PNG image path') + .option('-o, --output ', 'Output diff PNG image path') + .option('-t, --threshold ', 'Matching threshold (0-1)', parseFloat) + .option('--include-aa', 'Detect and ignore anti-aliased pixels', false) + .option('--no-include-aa', 'Do not ignore anti-aliased pixels') + .option( + '--horizontal-shift ', + 'Horizontal shift in pixels', + parseInt, + ) + .option('--vertical-shift ', 'Vertical shift in pixels', parseInt) + .option('--alpha ', 'Alpha value for diff mask (0-1)', parseFloat) + .option('--diff-mask', 'Output only the diff mask', false) + .option('--help-options', 'Show all pixelmatch options and exit') + .showHelpAfterError(); -const img1 = PNG.sync.read(fs.readFileSync(img1Path)); -const img2 = PNG.sync.read(fs.readFileSync(img2Path)); +program.action(async (image1, image2, options) => { + if (options.helpOptions) { + console.log('Pixelmatch options:'); + console.log( + ' -t, --threshold Matching threshold (0-1)', + ); + console.log( + ' --include-aa/--no-include-aa Detect and ignore anti-aliased pixels', + ); + console.log( + ' --horizontal-shift Horizontal shift in pixels', + ); + console.log( + ' --vertical-shift Vertical shift in pixels', + ); + console.log( + ' --alpha Alpha value for diff mask (0-1)', + ); + console.log( + ' --diff-mask Output only the diff mask', + ); + process.exit(0); + } -const { width, height } = img1; + let img1, img2; + try { + img1 = PNG.sync.read(fs.readFileSync(image1)); + } catch (e) { + console.error(`Failed to read image1: ${image1}`); + process.exit(1); + } + try { + img2 = PNG.sync.read(fs.readFileSync(image2)); + } catch (e) { + console.error(`Failed to read image2: ${image2}`); + process.exit(1); + } -if (img2.width !== width || img2.height !== height) { - console.log( - `Image dimensions do not match: ${width}x${height} vs ${img2.width}x${img2.height}`, - ); - process.exit(65); -} + const { width, height } = img1; + if (img2.width !== width || img2.height !== height) { + console.error( + `Image dimensions do not match: ${width}x${height} vs ${img2.width}x${img2.height}`, + ); + process.exit(2); + } -const diff = diffPath ? new PNG({ width, height }) : null; + const diff = options.output ? new PNG({ width, height }) : null; + const pmOptions: PixelmatchOptions = {}; + if (options.threshold !== undefined) + pmOptions.threshold = options.threshold; + if (options.includeAa !== undefined) + pmOptions.includeAA = options.includeAa; + if (options.horizontalShift !== undefined) + pmOptions.horizontalShiftPixels = options.horizontalShift; + if (options.verticalShift !== undefined) + pmOptions.verticalShiftPixels = options.verticalShift; + if (options.alpha !== undefined) pmOptions.alpha = options.alpha; + if (options.diffMask !== undefined) pmOptions.diffMask = options.diffMask; -console.time('matched in'); -const diffs = pixelmatch( - img1.data, - img2.data, - diff ? diff.data : null, - width, - height, - options, -); -console.timeEnd('matched in'); + try { + console.time('matched in'); + const diffs = await pixelmatch( + img1.data, + img2.data, + diff ? diff.data : null, + width, + height, + pmOptions, + ); + console.timeEnd('matched in'); + console.log(`pixel diff count: ${diffs}`); + console.log( + `pixel diff percentage: ${Math.round((100 * 100 * diffs) / (width * height)) / 100}%`, + ); + if (diff && options.output) { + fs.writeFileSync(options.output, PNG.sync.write(diff)); + console.log(`Diff image written to ${options.output}`); + } + process.exit(diffs ? 66 : 0); + } catch (err) { + console.error('Error running pixelmatch:', err); + process.exit(3); + } +}); -console.log(`different pixels: ${diffs}`); -console.log( - `error: ${Math.round((100 * 100 * diffs) / (width * height)) / 100}%`, -); - -if (diff && diffPath) { - fs.writeFileSync(diffPath, PNG.sync.write(diff)); -} -process.exit(diffs ? 66 : 0); +program.parseAsync(process.argv); diff --git a/package-lock.json b/package-lock.json index 371f325..4ad5238 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "@sitepager/pixel-match", - "version": "0.1.2", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sitepager/pixel-match", - "version": "0.1.2", + "version": "0.2.0", "license": "ISC", "dependencies": { + "commander": "^14.0.0", "pngjs": "^7.0.0" }, "bin": { @@ -2095,13 +2096,12 @@ "license": "MIT" }, "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", + "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=20" } }, "node_modules/confbox": { @@ -3959,6 +3959,16 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index e95f3c1..17d8104 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,35 @@ { "name": "@sitepager/pixel-match", - "version": "0.1.2", + "version": "0.2.0", "description": "A fast, accurate, and configurable pixel-level image comparison library for Node.js, written in TypeScript. This is a drop-in replacement for the original pixelmatch library, maintaining full API compatibility while adding new features and improvements.", "keywords": [], "license": "ISC", "author": "rajat kumar", "exports": { ".": { - "import": "./dist/index.mjs", - "require": "./dist/index.js", - "types": "./dist/index.d.ts" + "browser": "./dist/browser/index.js", + "node": "./dist/node/index.js", + "import": "./dist/browser/index.mjs", + "require": "./dist/node/index.js", + "types": "./dist/browser/index.d.ts", + "default": "./dist/browser/index.mjs" + }, + "./browser": { + "import": "./dist/browser/index.mjs", + "require": "./dist/browser/index.js", + "types": "./dist/browser/index.d.mts", + "default": "./dist/browser/index.mjs" + }, + "./node": { + "import": "./dist/node/index.mjs", + "require": "./dist/node/index.js", + "types": "./dist/node/index.d.mts", + "default": "./dist/node/index.mjs" } }, - "main": "dist/index.js", - "module": "dist/index.mjs", + "main": "dist/node/index.js", + "module": "dist/browser/index.mjs", + "browser": "dist/browser/index.mjs", "types": "dist/index.d.ts", "bin": { "pixelmatch": "dist/bin/pixelmatch.js" @@ -22,10 +38,12 @@ "dist" ], "scripts": { - "bench": "tsx bench/bench.ts", - "build": "tsup", + "bench": "npm run build && tsx bench/bench.ts", + "bench:browser": "npm run build && tsx bench/bench-browser.ts", + "bench:node": "npm run build && tsx bench/bench-node.ts", + "build": "npm run clean && tsup", "changeset": "changeset", - "check-exports": "attw --pack .", + "check-exports": "attw --pack . --profile node16", "check-format": "prettier --check .", "ci": "npm run lint && npm run check-format && npm run build && npm run check-exports && npm run test", "clean": "rm -rf dist", @@ -33,12 +51,11 @@ "lint": "tsc --noEmit", "local-release": "changeset version && changeset publish", "prepublishOnly": "npm run ci", - "test": "vitest run test/pixelmatch.test.ts", - "test:coverage": "vitest run --coverage", - "test:lg": "vitest run test/pixelmatch-lg.test.ts", + "test": "npm run build && vitest run test/pixelmatch.test.ts", "version": "changeset version && npm run build" }, "dependencies": { + "commander": "^14.0.0", "pngjs": "^7.0.0" }, "devDependencies": { diff --git a/src/browser/index.ts b/src/browser/index.ts new file mode 100644 index 0000000..f1404dc --- /dev/null +++ b/src/browser/index.ts @@ -0,0 +1,150 @@ +import { + isPixelData, + colorDelta, + drawPixel, + drawGrayPixel, + antialiased, + findBestMatchingPixel, +} from '../core-utils/utils'; + +export interface PixelmatchOptions { + threshold?: number; + includeAA?: boolean; + alpha?: number; + aaColor?: [number, number, number]; + diffColor?: [number, number, number]; + diffColorAlt?: [number, number, number]; + diffMask?: boolean; + horizontalShiftPixels?: number; + verticalShiftPixels?: number; +} + +export function pixelmatch( + img1: Uint8Array | Uint8ClampedArray, + img2: Uint8Array | Uint8ClampedArray, + output: Uint8Array | Uint8ClampedArray | null, + width: number, + height: number, + options: PixelmatchOptions = {}, +): number { + const { + threshold = 0.1, + alpha = 0.1, + aaColor = [255, 255, 0], + diffColor = [255, 0, 0], + includeAA, + diffColorAlt, + diffMask, + horizontalShiftPixels = 0, + verticalShiftPixels = 0, + } = options; + + if (!isPixelData(img1) || !isPixelData(img2)) { + throw new Error( + 'Image data: Uint8Array, Uint8ClampedArray or Buffer expected.', + ); + } + + if (output && !isPixelData(output)) { + throw new Error( + 'Image data: Uint8Array, Uint8ClampedArray or Buffer expected. (Output)', + ); + } + + if ( + img1.length !== img2.length || + (output && output.length !== img1.length) + ) + throw new Error('Image sizes do not match.'); + + if (img1.length !== width * height * 4) + throw new Error('Image data size does not match width/height.'); + + // check if images are identical + 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) { + // fast path if identical + if (output && !diffMask) { + for (let i = 0; i < len; i++) + drawGrayPixel(img1, 4 * i, alpha, output); + } + return 0; + } + + // maximum acceptable square distance between two colors; + // 35215 is the maximum possible value for the YIQ difference metric + const maxDelta = 35215 * threshold * threshold; + const [aaR, aaG, aaB] = aaColor; + const [diffR, diffG, diffB] = diffColor; + const [altR, altG, altB] = diffColorAlt || diffColor; + let diff = 0; + + // compare each pixel of one image against the other one + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = y * width + x; + const pos = i * 4; + + // squared YUV distance between colors at this pixel position, negative if the img2 pixel is darker + let delta = + a32[i] === b32[i] ? 0 : colorDelta(img1, img2, pos, pos, false); + + if ( + (delta > maxDelta || delta < -1 * maxDelta) && + (horizontalShiftPixels > 0 || verticalShiftPixels > 0) + ) { + delta = findBestMatchingPixel( + img1, + img2, + x, + y, + width, + height, + pos, + horizontalShiftPixels, + verticalShiftPixels, + ); + } + + // the color difference is above the threshold + if (Math.abs(delta) > maxDelta) { + // check it's a real rendering difference or just anti-aliasing + const isAA = + antialiased(img1, x, y, width, height, a32, b32) || + antialiased(img2, x, y, width, height, b32, a32); + if (!includeAA && isAA) { + // one of the pixels is anti-aliasing; draw as yellow and do not count as difference + // note that we do not include such pixels in a mask + if (output && !diffMask) + drawPixel(output, pos, aaR, aaG, aaB); + } else { + // found substantial difference not caused by anti-aliasing; draw it as such + if (output) { + if (delta < 0) { + drawPixel(output, pos, altR, altG, altB); + } else { + drawPixel(output, pos, diffR, diffG, diffB); + } + } + diff++; + } + } else if (output && !diffMask) { + // pixels are similar; draw background as grayscale image blended with white + drawGrayPixel(img1, pos, alpha, output); + } + } + } + + // return the number of different pixels + return diff; +} diff --git a/src/core-utils/utils.ts b/src/core-utils/utils.ts new file mode 100644 index 0000000..5a08b62 --- /dev/null +++ b/src/core-utils/utils.ts @@ -0,0 +1,262 @@ +// Utility functions shared between pixelmatch and worker + +/** + * Checks if the given array is a valid pixel data array (Uint8Array or Uint8ClampedArray). + * @param arr - The array to check + * @returns True if the array is a valid pixel data array, false otherwise + */ +export function isPixelData(arr: any): arr is Uint8Array | Uint8ClampedArray { + return ( + ArrayBuffer.isView(arr) && + typeof (arr as any).BYTES_PER_ELEMENT === 'number' && + (arr as any).BYTES_PER_ELEMENT === 1 + ); +} + +/** + * Calculates the color difference between two pixels in the YIQ color space. + * @param img1 - First image data array + * @param img2 - Second image data array + * @param k - Index of the first pixel in img1 + * @param m - Index of the second pixel in img2 + * @param yOnly - If true, only calculates the Y component difference + * @returns The color difference value. Positive values indicate img1 is brighter, negative values indicate img2 is brighter + */ +export function colorDelta( + img1: Uint8Array | Uint8ClampedArray, + img2: Uint8Array | Uint8ClampedArray, + k: number, + m: number, + yOnly: boolean, +): number { + 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; + if (!dr && !dg && !db && !da) return 0; + 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; + } + const y = dr * 0.29889531 + dg * 0.58662247 + db * 0.11448223; + if (yOnly) return y; + const i = dr * 0.59597799 - dg * 0.2741761 - 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; +} + +/** + * Draws a pixel with the specified RGB color values to the output array. + * @param output - The output array to draw to + * @param pos - The position in the output array to draw the pixel + * @param r - Red component (0-255) + * @param g - Green component (0-255) + * @param b - Blue component (0-255) + */ +export function drawPixel( + output: Uint8Array | Uint8ClampedArray, + pos: number, + r: number, + g: number, + b: number, +): void { + output[pos + 0] = r; + output[pos + 1] = g; + output[pos + 2] = b; + output[pos + 3] = 255; +} + +/** + * Draws a grayscale pixel to the output array based on the input image's luminance. + * @param img - Source image data array + * @param i - Index of the pixel in the source image + * @param alpha - Alpha value for the grayscale effect (0-1) + * @param output - The output array to draw to + */ +export function drawGrayPixel( + img: Uint8Array | Uint8ClampedArray, + i: number, + alpha: number, + output: Uint8Array | Uint8ClampedArray, +): void { + 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); +} + +/** + * Checks if a pixel is likely to be part of an antialiased edge. + * @param img - Image data array + * @param x1 - X coordinate of the pixel to check + * @param y1 - Y coordinate of the pixel to check + * @param width - Width of the image + * @param height - Height of the image + * @param a32 - First image data as Uint32Array + * @param b32 - Second image data as Uint32Array + * @returns True if the pixel is likely part of an antialiased edge + */ +export function antialiased( + img: Uint8Array | Uint8ClampedArray, + x1: number, + y1: number, + width: number, + height: number, + a32: Uint32Array, + b32: Uint32Array, +): boolean { + 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; + for (let x = x0; x <= x2; x++) { + for (let y = y0; y <= y2; y++) { + if (x === x1 && y === y1) continue; + const delta = colorDelta( + img, + img, + pos * 4, + (y * width + x) * 4, + true, + ); + if (delta === 0) { + zeroes++; + if (zeroes > 2) return false; + } else if (delta < min) { + min = delta; + minX = x; + minY = y; + } else if (delta > max) { + max = delta; + maxX = x; + maxY = y; + } + } + } + if (min === 0 || max === 0) return false; + 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)) + ); +} + +/** + * Checks if a pixel has many similar neighboring pixels. + * @param img - Image data as Uint32Array + * @param x1 - X coordinate of the pixel to check + * @param y1 - Y coordinate of the pixel to check + * @param width - Width of the image + * @param height - Height of the image + * @returns True if the pixel has many similar neighbors + */ +export function hasManySiblings( + img: Uint32Array, + x1: number, + y1: number, + width: number, + height: number, +): boolean { + 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; + 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; +} + +/** + * Finds the best matching pixel between two images within a specified shift range. + * @param img1 - First image data array + * @param img2 - Second image data array + * @param x - X coordinate of the pixel to match + * @param y - Y coordinate of the pixel to match + * @param width - Width of the images + * @param height - Height of the images + * @param pos - Position of the pixel in the first image + * @param horizontalShiftPixels - Maximum number of pixels to shift horizontally + * @param verticalShiftPixels - Maximum number of pixels to shift vertically + * @returns The color difference value of the best matching pixel + */ +export function findBestMatchingPixel( + img1: Uint8Array | Uint8ClampedArray, + img2: Uint8Array | Uint8ClampedArray, + x: number, + y: number, + width: number, + height: number, + pos: number, + horizontalShiftPixels: number, + verticalShiftPixels: number, +): number { + let minAbsDelta = Infinity; + let minOtherDelta = Infinity; + for ( + let hShift = -horizontalShiftPixels; + hShift <= horizontalShiftPixels; + ++hShift + ) { + for ( + let vShift = -verticalShiftPixels; + vShift <= verticalShiftPixels; + ++vShift + ) { + if ( + x + hShift < 0 || + x + hShift >= width || + y + vShift < 0 || + y + vShift >= height + ) { + continue; + } + const shiftedPos = pos + (width * vShift + hShift) * 4; + const currDelta = colorDelta(img1, img2, pos, shiftedPos, false); + const otherDelta = colorDelta(img1, img2, shiftedPos, pos, false); + if (Math.abs(currDelta) < Math.abs(minAbsDelta)) { + minAbsDelta = currDelta; + } + if (Math.abs(otherDelta) < Math.abs(minOtherDelta)) { + minOtherDelta = otherDelta; + } + } + } + return Math.abs(minAbsDelta) > Math.abs(minOtherDelta) + ? minAbsDelta + : minOtherDelta; +} diff --git a/src/index.ts b/src/index.ts index ab144c0..21900d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,492 +1,4 @@ -/** - * Compare two equally sized images, pixel by pixel. - * - * @param img1 First image data. - * @param img2 Second image data. - * @param output Image data to write the diff to, if provided. - * @param width Input images width. - * @param height Input images height. - * @param options Options for comparison. - * @returns The number of mismatched pixels. - */ -export interface PixelmatchOptions { - threshold?: number; - includeAA?: boolean; - alpha?: number; - aaColor?: [number, number, number]; - diffColor?: [number, number, number]; - diffColorAlt?: [number, number, number]; - diffMask?: boolean; - horizontalShiftPixels?: number; - verticalShiftPixels?: number; -} - -/** - * Compares two images pixel by pixel and generates a diff image highlighting the differences. - * This function is useful for visual regression testing and image comparison. - * - * @param img1 - First image data as a Uint8Array or Uint8ClampedArray in RGBA format - * @param img2 - Second image data as a Uint8Array or Uint8ClampedArray in RGBA format - * @param output - Optional output image data to write the diff to. If null, only the difference count is returned - * @param width - Width of the images in pixels - * @param height - Height of the images in pixels - * @param options - Configuration options for the comparison - * @param options.threshold - Color difference threshold (0 to 1). Default: 0.1 - * @param options.alpha - Opacity of unchanged pixels in the diff output. Default: 0.1 - * @param options.aaColor - Color of anti-aliased pixels in the diff output [r, g, b]. Default: [255, 255, 0] (yellow) - * @param options.diffColor - Color of different pixels in the diff output [r, g, b]. Default: [255, 0, 0] (red) - * @param options.includeAA - Whether to detect and ignore anti-aliasing. Default: false - * @param options.diffColorAlt - Alternative color for different pixels when img2 is darker [r, g, b] - * @param options.diffMask - Whether to draw the diff over a transparent background (true) or over the original image (false) - * @param options.horizontalShiftPixels - Number of pixels to check horizontally for similar pixels. Default: 0 - * @param options.verticalShiftPixels - Number of pixels to check vertically for similar pixels. Default: 0 - * - * @returns The number of different pixels found between the images - * - * @throws {Error} If image data is not in the correct format (Uint8Array or Uint8ClampedArray) - * @throws {Error} If image sizes do not match - * @throws {Error} If image data size does not match width/height - */ -export default function pixelmatch( - img1: Uint8Array | Uint8ClampedArray, - img2: Uint8Array | Uint8ClampedArray, - output: Uint8Array | Uint8ClampedArray | null, - width: number, - height: number, - options: PixelmatchOptions = {}, -): number { - const { - threshold = 0.1, - alpha = 0.1, - aaColor = [255, 255, 0], - diffColor = [255, 0, 0], - includeAA, - diffColorAlt, - diffMask, - horizontalShiftPixels = 0, - verticalShiftPixels = 0, - } = options; - - if ( - !isPixelData(img1) || - !isPixelData(img2) || - (output && !isPixelData(output)) - ) - throw new Error( - 'Image data: Uint8Array, Uint8ClampedArray or Buffer expected.', - ); - - if ( - img1.length !== img2.length || - (output && output.length !== img1.length) - ) - throw new Error('Image sizes do not match.'); - - if (img1.length !== width * height * 4) - throw new Error('Image data size does not match width/height.'); - - // check if images are identical - 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) { - // fast path if identical - if (output && !diffMask) { - for (let i = 0; i < len; i++) - drawGrayPixel(img1, 4 * i, alpha, output); - } - return 0; - } - - // maximum acceptable square distance between two colors; - // 35215 is the maximum possible value for the YIQ difference metric - const maxDelta = 35215 * threshold * threshold; - const [aaR, aaG, aaB] = aaColor; - const [diffR, diffG, diffB] = diffColor; - const [altR, altG, altB] = diffColorAlt || diffColor; - let diff = 0; - - // compare each pixel of one image against the other one - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const i = y * width + x; - const pos = i * 4; - - // squared YUV distance between colors at this pixel position, negative if the img2 pixel is darker - let delta = - a32[i] === b32[i] ? 0 : colorDelta(img1, img2, pos, pos, false); - - if ( - (delta > maxDelta || delta < -1 * maxDelta) && - (horizontalShiftPixels > 0 || verticalShiftPixels > 0) - ) { - delta = findBestMatchingPixel( - img1, - img2, - x, - y, - width, - height, - pos, - horizontalShiftPixels, - verticalShiftPixels, - ); - } - - // the color difference is above the threshold - if (Math.abs(delta) > maxDelta) { - // check it's a real rendering difference or just anti-aliasing - const isAA = - antialiased(img1, x, y, width, height, a32, b32) || - antialiased(img2, x, y, width, height, b32, a32); - if (!includeAA && isAA) { - // one of the pixels is anti-aliasing; draw as yellow and do not count as difference - // note that we do not include such pixels in a mask - if (output && !diffMask) - drawPixel(output, pos, aaR, aaG, aaB); - } else { - // found substantial difference not caused by anti-aliasing; draw it as such - if (output) { - if (delta < 0) { - drawPixel(output, pos, altR, altG, altB); - } else { - drawPixel(output, pos, diffR, diffG, diffB); - } - } - diff++; - } - } else if (output && !diffMask) { - // pixels are similar; draw background as grayscale image blended with white - drawGrayPixel(img1, pos, alpha, output); - } - } - } - - // return the number of different pixels - return diff; -} - -function isPixelData(arr: any): arr is Uint8Array | Uint8ClampedArray { - // work around instanceof Uint8Array not working properly in some Jest environments - return ( - ArrayBuffer.isView(arr) && - typeof (arr as any).BYTES_PER_ELEMENT === 'number' && - (arr as any).BYTES_PER_ELEMENT === 1 - ); -} - -/** - * Determines if a pixel at (x1, y1) is likely to be anti-aliased by examining its neighbors in two images. - * - * This helps distinguish real differences from those caused by anti-aliasing artifacts. - * - * @param img - The image data array (RGBA, Uint8Array or Uint8ClampedArray). - * @param x1 - The x-coordinate of the pixel. - * @param y1 - The y-coordinate of the pixel. - * @param width - The width of the image in pixels. - * @param height - The height of the image in pixels. - * @param a32 - The first image data as a Uint32Array (one value per pixel). - * @param b32 - The second image data as a Uint32Array (one value per pixel). - * @returns True if the pixel is likely anti-aliased, false otherwise. - */ -function antialiased( - img: Uint8Array | Uint8ClampedArray, - x1: number, - y1: number, - width: number, - height: number, - a32: Uint32Array, - b32: Uint32Array, -): boolean { - 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)) - ); -} - -/** - * Checks if a pixel at (x1, y1) in the image has more than two adjacent pixels with the same value. - * - * Used to help determine if a pixel is anti-aliased by checking for clusters of similar pixels. - * - * @param img - The image data as a Uint32Array (one value per pixel). - * @param x1 - The x-coordinate of the pixel. - * @param y1 - The y-coordinate of the pixel. - * @param width - The width of the image in pixels. - * @param height - The height of the image in pixels. - * @returns True if the pixel has more than two identical neighbors, false otherwise. - */ -function hasManySiblings( - img: Uint32Array, - x1: number, - y1: number, - width: number, - height: number, -): boolean { - 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; -} - -/** - * Computes the color difference between two pixels using a YIQ color space metric. - * - * The difference is positive if the second pixel is lighter, negative if darker. Optionally, only the brightness (Y) difference can be returned. - * - * @param img1 - The first image data array (RGBA, Uint8Array or Uint8ClampedArray). - * @param img2 - The second image data array (RGBA, Uint8Array or Uint8ClampedArray). - * @param k - The starting index of the pixel in img1 (should point to the red channel). - * @param m - The starting index of the pixel in img2 (should point to the red channel). - * @param yOnly - If true, only the brightness (Y) difference is returned; otherwise, a full color difference metric is computed. - * @returns The color difference metric (positive if img2 is lighter, negative if darker). - */ -function colorDelta( - img1: Uint8Array | Uint8ClampedArray, - img2: Uint8Array | Uint8ClampedArray, - k: number, - m: number, - yOnly: boolean, -): number { - 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; - - if (!dr && !dg && !db && !da) return 0; - - if (a1 < 255 || a2 < 255) { - // blend pixels with background - 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; - } - - const y = dr * 0.29889531 + dg * 0.58662247 + db * 0.11448223; - - if (yOnly) return y; // brightness difference only - - const i = dr * 0.59597799 - dg * 0.2741761 - 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; - - // encode whether the pixel lightens or darkens in the sign - return y > 0 ? -delta : delta; -} - -/** - * Writes an RGB pixel with full opacity to the output image data array at the specified position. - * - * @param output - The output image data array (RGBA, Uint8Array or Uint8ClampedArray). - * @param pos - The starting index of the pixel in the output array (should point to the red channel of the pixel). - * @param r - The red channel value (0-255). - * @param g - The green channel value (0-255). - * @param b - The blue channel value (0-255). - */ -function drawPixel( - output: Uint8Array | Uint8ClampedArray, - pos: number, - r: number, - g: number, - b: number, -): void { - output[pos + 0] = r; - output[pos + 1] = g; - output[pos + 2] = b; - output[pos + 3] = 255; -} - -/** - * Draws a grayscale version of a pixel from the input image onto the output image, blending it with white based on the given alpha value. - * - * This function is typically used to render background pixels in image diff outputs, where pixels are considered similar between two images. - * The grayscale value is computed using the standard luminance formula, then blended with white according to the alpha and the pixel's own alpha channel. - * - * @param img - The source image data array (RGBA, Uint8Array or Uint8ClampedArray). - * @param i - The starting index of the pixel in the image data array (should point to the red channel of the pixel). - * @param alpha - The blending factor (0 to 1) controlling how much of the grayscale value is blended with white. - * @param output - The output image data array where the resulting grayscale pixel will be written. - */ -function drawGrayPixel( - img: Uint8Array | Uint8ClampedArray, - i: number, - alpha: number, - output: Uint8Array | Uint8ClampedArray, -): void { - 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); -} - -/** - * Finds the best matching pixel within a shift range by comparing color differences. - * - * @param img1 - First image data - * @param img2 - Second image data - * @param x - Current x coordinate - * @param y - Current y coordinate - * @param width - Image width - * @param height - Image height - * @param pos - Current pixel position in the image data - * @param horizontalShiftPixels - Maximum horizontal shift to check - * @param verticalShiftPixels - Maximum vertical shift to check - * @returns The best matching color delta found - */ -function findBestMatchingPixel( - img1: Uint8Array | Uint8ClampedArray, - img2: Uint8Array | Uint8ClampedArray, - x: number, - y: number, - width: number, - height: number, - pos: number, - horizontalShiftPixels: number, - verticalShiftPixels: number, -): number { - let minAbsDelta = 9999; - let minOtherDelta = 9999; - - // Check all positions within the shift range - for ( - let hShift = -horizontalShiftPixels; - hShift <= horizontalShiftPixels; - ++hShift - ) { - for ( - let vShift = -verticalShiftPixels; - vShift <= verticalShiftPixels; - ++vShift - ) { - // Skip positions outside image bounds - if (isOutOfBounds(x + hShift, y + vShift, width, height)) { - continue; - } - - const shiftedPos = pos + (width * vShift + hShift) * 4; - - // Calculate color differences in both directions - const currDelta = colorDelta(img1, img2, pos, shiftedPos, false); - const otherDelta = colorDelta(img1, img2, shiftedPos, pos, false); - - // Update minimum differences if better matches found - if (Math.abs(currDelta) < Math.abs(minAbsDelta)) { - minAbsDelta = currDelta; - } - if (Math.abs(otherDelta) < Math.abs(minOtherDelta)) { - minOtherDelta = otherDelta; - } - } - } - - // Return the delta with larger absolute value - return Math.abs(minAbsDelta) > Math.abs(minOtherDelta) - ? minAbsDelta - : minOtherDelta; -} - -/** - * Checks if coordinates are outside image bounds - */ -function isOutOfBounds( - x: number, - y: number, - width: number, - height: number, -): boolean { - return x < 0 || x >= width || y < 0 || y >= height; -} +// Main entry point: re-export browser version by default +export * from './browser'; +// For Node.js, use src/node/index.ts as the entry point +// For browser, use src/browser/index.ts as the entry point diff --git a/src/node/index.ts b/src/node/index.ts new file mode 100644 index 0000000..8432fcc --- /dev/null +++ b/src/node/index.ts @@ -0,0 +1,152 @@ +import { + pixelmatch as _pixelmatch_browser, + PixelmatchOptions, +} from '../browser'; +import { isPixelData, drawGrayPixel } from '../core-utils/utils'; +import { Worker } from 'worker_threads'; +import { cpus } from 'os'; +import path from 'path'; + +export { PixelmatchOptions }; + +export async function pixelmatch( + img1: Uint8Array | Uint8ClampedArray, + img2: Uint8Array | Uint8ClampedArray, + output: Uint8Array | Uint8ClampedArray | null, + width: number, + height: number, + options: PixelmatchOptions = {}, +): Promise { + if (!isPixelData(img1) || !isPixelData(img2)) { + throw new Error( + 'Image data: Uint8Array, Uint8ClampedArray or Buffer expected.', + ); + } + + if (output && !isPixelData(output)) { + throw new Error( + 'Image data: Uint8Array, Uint8ClampedArray or Buffer expected. (Output)', + ); + } + if ( + img1.length !== img2.length || + (output && output.length !== img1.length) + ) { + throw new Error('Image sizes do not match.'); + } + if (img1.length !== width * height * 4) { + throw new Error('Image data size does not match width/height.'); + } + + // console.log('width * height', width * height); + + const isLargeImage = width * height > 1000000; + const hasShift = + options.horizontalShiftPixels || options.verticalShiftPixels; + let useWorkers = false; + + if (isLargeImage) { + useWorkers = true; + } + + if (!isLargeImage && hasShift) { + useWorkers = true; + } + + if (!useWorkers) { + // console.log('running browser version'); + return Promise.resolve( + _pixelmatch_browser(img1, img2, output, width, height, options), + ); + } + // console.log('running node version'); + return await _pixelmatch_node_workers( + img1, + img2, + output, + width, + height, + options, + ); +} + +async function _pixelmatch_node_workers( + img1: Uint8Array | Uint8ClampedArray, + img2: Uint8Array | Uint8ClampedArray, + output: Uint8Array | Uint8ClampedArray | null, + width: number, + height: number, + options: PixelmatchOptions = {}, +): Promise { + // Check if images are identical (fast path) + 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 && !options.diffMask) { + for (let i = 0; i < len; i++) { + drawGrayPixel(img1, 4 * i, options.alpha || 0.1, output); + } + } + return 0; + } + + // Determine number of workers (use number of CPU cores - 1) + const numWorkers = Math.max(1, cpus().length - 1); + const chunkHeight = Math.ceil(height / numWorkers); + const workers: Worker[] = []; + const workerPromises: Promise[] = []; + + // Path to the worker file (assume src/node/worker.js after build) + const workerPath = path.join(__dirname, 'worker.js'); + + // If output is provided, create a SharedArrayBuffer for it + let sharedOutputBuffer: SharedArrayBuffer | null = null; + if (output) { + sharedOutputBuffer = new SharedArrayBuffer(output.length); + } + + for (let i = 0; i < numWorkers; i++) { + const startY = i * chunkHeight; + const endY = Math.min((i + 1) * chunkHeight, height); + const worker = new Worker(workerPath, { + workerData: { + img1, + img2, + output: sharedOutputBuffer, // pass SharedArrayBuffer or null + width, + height, + startY, + endY, + options, + }, + }); + workers.push(worker); + const workerPromise = new Promise((resolve) => { + worker.on('message', (diff) => { + resolve(diff); + }); + worker.on('error', (err) => { + console.error('Worker error:', err); + resolve(0); + }); + }); + workerPromises.push(workerPromise); + } + const results = await Promise.all(workerPromises); + workers.forEach((worker) => worker.terminate()); + + // If output is provided, copy the SharedArrayBuffer back to the output buffer + if (output && sharedOutputBuffer) { + const sharedView = new Uint8Array(sharedOutputBuffer); + output.set(sharedView); + } + return results.reduce((sum, diff) => sum + diff, 0); +} diff --git a/src/node/worker.ts b/src/node/worker.ts new file mode 100644 index 0000000..ec0c92c --- /dev/null +++ b/src/node/worker.ts @@ -0,0 +1,120 @@ +// This worker processes a chunk of an image comparison task using pixel matching logic. +// It receives image data and options, compares pixels, and writes the diff result to a shared buffer. +import { parentPort, workerData } from 'worker_threads'; +import type { PixelmatchOptions } from '../browser'; +import { + colorDelta, // Calculates color difference between two pixels + drawPixel, // Draws a colored pixel in the output buffer + drawGrayPixel, // Draws a grayscale pixel in the output buffer + antialiased, // Checks if a pixel is antialiased + findBestMatchingPixel, // Finds the best matching pixel within a shift window +} from '../core-utils/utils'; + +if (parentPort) { + // Destructure the data sent to the worker + const { + img1, + img2, + output, + width, + height, + startY, + endY, + options, + }: { + img1: Uint8Array | Uint8ClampedArray; // First image data + img2: Uint8Array | Uint8ClampedArray; // Second image data + output: SharedArrayBuffer | null; // Output buffer for diff image + width: number; // Image width + height: number; // Image height + startY: number; // Start row for this worker + endY: number; // End row for this worker + options: PixelmatchOptions; // Comparison options + } = workerData; + + // Extract options with defaults + const { + threshold = 0.1, // Sensitivity threshold for pixel difference + alpha = 0.1, // Alpha for unchanged pixels in diff + aaColor = [255, 255, 0], // Color for antialiased pixels + diffColor = [255, 0, 0], // Color for different pixels + includeAA, // Whether to include antialiased pixels in diff count + diffColorAlt, // Alternate color for negative delta + diffMask, // Whether to output a mask instead of a diff image + horizontalShiftPixels = 0, // Horizontal shift window for matching + verticalShiftPixels = 0, // Vertical shift window for matching + } = options; + + // Calculate the maximum allowed color delta for a pixel to be considered the same + const maxDelta = 35215 * threshold * threshold; + const [aaR, aaG, aaB] = aaColor; + const [diffR, diffG, diffB] = diffColor; + const [altR, altG, altB] = diffColorAlt || diffColor; + let diff = 0; // Counter for number of different pixels + + // Create 32-bit views for fast pixel comparison + const a32 = new Uint32Array(img1.buffer, img1.byteOffset, width * height); + const b32 = new Uint32Array(img2.buffer, img2.byteOffset, width * height); + + // If output is provided, create a Uint8Array view over the SharedArrayBuffer + let outputView: Uint8Array | null = null; + if (output) { + outputView = new Uint8Array(output); + } + + // Main loop: iterate over the assigned rows and columns + for (let y = startY; y < endY; y++) { + for (let x = 0; x < width; x++) { + const i = y * width + x; // Pixel index + const pos = i * 4; // Byte position in RGBA array + // Fast path: if pixels are identical, delta is 0 + let delta = + a32[i] === b32[i] ? 0 : colorDelta(img1, img2, pos, pos, false); + // If pixels are different and shifting is allowed, try to find a better match nearby + if ( + (delta > maxDelta || delta < -1 * maxDelta) && + (horizontalShiftPixels > 0 || verticalShiftPixels > 0) + ) { + delta = findBestMatchingPixel( + img1, + img2, + x, + y, + width, + height, + pos, + horizontalShiftPixels, + verticalShiftPixels, + ); + } + // If the color difference exceeds the threshold, mark as different + if (Math.abs(delta) > maxDelta) { + // Check if the pixel is antialiased in either image + const isAA = + antialiased(img1, x, y, width, height, a32, b32) || + antialiased(img2, x, y, width, height, b32, a32); + if (!includeAA && isAA) { + // If not including AA pixels, color them with aaColor + if (outputView && !diffMask) { + drawPixel(outputView, pos, aaR, aaG, aaB); + } + } else { + // Otherwise, color the diff pixel (use alt color for negative delta) + if (outputView) { + if (delta < 0) { + drawPixel(outputView, pos, altR, altG, altB); + } else { + drawPixel(outputView, pos, diffR, diffG, diffB); + } + } + diff++; // Increment diff count + } + } else if (outputView && !diffMask) { + // If pixels are similar, draw a faded grayscale pixel + drawGrayPixel(img1, pos, alpha, outputView); + } + } + } + // Send the number of different pixels back to the parent thread + parentPort.postMessage(diff); +} diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..3e50125 --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,106 @@ +import { parentPort, workerData } from 'worker_threads'; +import type { PixelmatchOptions } from './index'; +import { + colorDelta, + drawPixel, + drawGrayPixel, + antialiased, + findBestMatchingPixel, +} from './core-utils/utils'; + +// Worker main logic +if (parentPort) { + const { + img1, + img2, + output, + width, + height, + startY, + endY, + options, + }: { + img1: Uint8Array | Uint8ClampedArray; + img2: Uint8Array | Uint8ClampedArray; + output: SharedArrayBuffer | null; + width: number; + height: number; + startY: number; + endY: number; + options: PixelmatchOptions; + } = workerData; + + const { + threshold = 0.1, + alpha = 0.1, + aaColor = [255, 255, 0], + diffColor = [255, 0, 0], + includeAA, + diffColorAlt, + diffMask, + horizontalShiftPixels = 0, + verticalShiftPixels = 0, + } = options; + + const maxDelta = 35215 * threshold * threshold; + const [aaR, aaG, aaB] = aaColor; + const [diffR, diffG, diffB] = diffColor; + const [altR, altG, altB] = diffColorAlt || diffColor; + let diff = 0; + + const a32 = new Uint32Array(img1.buffer, img1.byteOffset, width * height); + const b32 = new Uint32Array(img2.buffer, img2.byteOffset, width * height); + + // If output is provided, create a Uint8Array view over the SharedArrayBuffer + let outputView: Uint8Array | null = null; + if (output) { + outputView = new Uint8Array(output); + } + + for (let y = startY; y < endY; y++) { + for (let x = 0; x < width; x++) { + const i = y * width + x; + const pos = i * 4; + let delta = + a32[i] === b32[i] ? 0 : colorDelta(img1, img2, pos, pos, false); + if ( + (delta > maxDelta || delta < -1 * maxDelta) && + (horizontalShiftPixels > 0 || verticalShiftPixels > 0) + ) { + delta = findBestMatchingPixel( + img1, + img2, + x, + y, + width, + height, + pos, + horizontalShiftPixels, + verticalShiftPixels, + ); + } + 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 (outputView && !diffMask) { + drawPixel(outputView, pos, aaR, aaG, aaB); + } + } else { + if (outputView) { + if (delta < 0) { + drawPixel(outputView, pos, altR, altG, altB); + } else { + drawPixel(outputView, pos, diffR, diffG, diffB); + } + } + diff++; + } + } else if (outputView && !diffMask) { + drawGrayPixel(img1, pos, alpha, outputView); + } + } + } + parentPort.postMessage(diff); +} diff --git a/test/.DS_Store b/test/.DS_Store index cd0ea56..e78d07d 100644 Binary files a/test/.DS_Store and b/test/.DS_Store differ diff --git a/test/pixelmatch-lg-browser.test.ts b/test/pixelmatch-lg-browser.test.ts new file mode 100644 index 0000000..5fd326c --- /dev/null +++ b/test/pixelmatch-lg-browser.test.ts @@ -0,0 +1,6 @@ +import { runTestSuite, testCases } from './test-utils'; + +runTestSuite( + testCases.filter((tc) => tc.isLargeFile), + 'browser', +); diff --git a/test/pixelmatch-lg-node.test.ts b/test/pixelmatch-lg-node.test.ts new file mode 100644 index 0000000..cff9a90 --- /dev/null +++ b/test/pixelmatch-lg-node.test.ts @@ -0,0 +1,6 @@ +import { runTestSuite, testCases } from './test-utils'; + +runTestSuite( + testCases.filter((tc) => tc.isLargeFile), + 'node', +); diff --git a/test/pixelmatch-lg.test.ts b/test/pixelmatch-lg.test.ts deleted file mode 100644 index 3f4f5a6..0000000 --- a/test/pixelmatch-lg.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import fs from 'node:fs'; -import { PNG } from 'pngjs'; -import pixelmatch, { PixelmatchOptions } from '../src'; - -function runAllTests() { - diffTest( - '10main', - '10baseline', - '10main-diff', - { horizontalShiftPixels: 0, verticalShiftPixels: 50, threshold: 0.1 }, - 70240, - false, - ); - - diffTest( - '10main', - '10baseline', - '10main-original-diff', - { horizontalShiftPixels: 0, verticalShiftPixels: 0, threshold: 0.1 }, - 1935304, - false, - ); -} - -describe('pixelmatch errors', () => { - it('throws error if image sizes do not match', () => { - expect(() => - pixelmatch(new Uint8Array(8), new Uint8Array(9), null, 2, 1), - ).toThrow('Image sizes do not match'); - }); - - it('throws error if image sizes do not match width and height', () => { - expect(() => - pixelmatch(new Uint8Array(9), new Uint8Array(9), null, 2, 1), - ).toThrow('Image data size does not match width/height'); - }); - - it('throws error if provided wrong image data format', () => { - const err = - 'Image data: Uint8Array, Uint8ClampedArray or Buffer expected'; - const arr = new Uint8Array(4 * 20 * 20); - const bad = new Array(arr.length).fill(0); - expect(() => pixelmatch(bad as any, arr, null, 20, 20)).toThrow(err); - expect(() => pixelmatch(arr, bad as any, null, 20, 20)).toThrow(err); - expect(() => pixelmatch(arr, arr, bad as any, 20, 20)).toThrow(err); - }); -}); - -// --- run tests on small files --- -runAllTests(); - -// --- utils --- - -function readImage(name: string): PNG { - return PNG.sync.read( - fs.readFileSync(new URL(`./fixtures/${name}.png`, import.meta.url)), - ); -} -function writeImage(name: string, image: PNG) { - fs.writeFileSync( - new URL(`./fixtures/${name}.png`, import.meta.url), - PNG.sync.write(image), - ); - console.log('wrote image', name); -} - -function diffTest( - imgPath1: string, - imgPath2: string, - diffPath: string, - options: PixelmatchOptions, - expectedMismatch: number, - write: boolean = false, -) { - describe(`comparing ${imgPath1} to ${imgPath2}, ${JSON.stringify(options)}`, () => { - it('matches expected diff and mismatch count', () => { - const img1 = readImage(imgPath1); - const img2 = readImage(imgPath2); - const { width, height } = img1; - const diff = new PNG({ width, height }); - - const mismatch = pixelmatch( - img1.data, - img2.data, - diff.data, - width, - height, - options, - ); - const mismatch2 = pixelmatch( - img1.data, - img2.data, - null, - width, - height, - options, - ); - - if (process.env.UPDATE || write) { - writeImage(diffPath, diff); - } else { - const expectedDiff = readImage(diffPath); - expect( - Buffer.from(diff.data).equals( - Buffer.from(expectedDiff.data), - ), - ).toBe(true); - } - expect(mismatch).toBe(expectedMismatch); - expect(mismatch).toBe(mismatch2); - }); - }, 600000); -} diff --git a/test/pixelmatch.test.ts b/test/pixelmatch.test.ts index 93139d1..388e21d 100644 --- a/test/pixelmatch.test.ts +++ b/test/pixelmatch.test.ts @@ -1,57 +1,6 @@ import { describe, it, expect } from 'vitest'; -import fs from 'node:fs'; -import { PNG } from 'pngjs'; -import pixelmatch, { PixelmatchOptions } from '../src'; - -// 10 minutes - -const options: PixelmatchOptions = { threshold: 0.05 }; - -function runAllTests() { - diffTest('1a', '1b', '1diff', options, 143); - diffTest( - '1a', - '1b', - '1diffdefaultthreshold', - { threshold: undefined }, - 106, - ); - diffTest( - '1a', - '1b', - '1diffmask', - { threshold: 0.05, includeAA: false, diffMask: true }, - 143, - ); - diffTest('1a', '1a', '1emptydiffmask', { threshold: 0, diffMask: true }, 0); - diffTest( - '2a', - '2b', - '2diff', - { - threshold: 0.05, - alpha: 0.5, - aaColor: [0, 192, 0], - diffColor: [255, 0, 255], - }, - 12437, - ); - diffTest('3a', '3b', '3diff', options, 212); - diffTest('4a', '4b', '4diff', options, 36049); - diffTest('5a', '5b', '5diff', options, 6); - diffTest('6a', '6b', '6diff', options, 51); - diffTest('6a', '6a', '6empty', { threshold: 0 }, 0); - diffTest('7a', '7b', '7diff', { diffColorAlt: [0, 255, 0] }, 2448); - diffTest('8a', '5b', '8diff', options, 32896); - diffTest('9a', '9b', '9diff', { threshold: 0.1 }, 2944); - diffTest( - '9a', - '9b', - '9empty', - { horizontalShiftPixels: 7, verticalShiftPixels: 6, threshold: 0.1 }, - 0, - ); -} +import { pixelmatch } from '../src/index.js'; +import { runTestSuite, testCases } from './test-utils.js'; describe('pixelmatch errors', () => { it('throws error if image sizes do not match', () => { @@ -77,68 +26,5 @@ describe('pixelmatch errors', () => { }); }); -// --- run tests on small files --- -runAllTests(); - -// --- utils --- - -function readImage(name: string): PNG { - return PNG.sync.read( - fs.readFileSync(new URL(`./fixtures/${name}.png`, import.meta.url)), - ); -} -function writeImage(name: string, image: PNG) { - fs.writeFileSync( - new URL(`./fixtures/${name}.png`, import.meta.url), - PNG.sync.write(image), - ); - console.log('wrote image', name); -} - -function diffTest( - imgPath1: string, - imgPath2: string, - diffPath: string, - options: PixelmatchOptions, - expectedMismatch: number, - write: boolean = false, -) { - describe(`comparing ${imgPath1} to ${imgPath2}, ${JSON.stringify(options)}`, () => { - it('matches expected diff and mismatch count', () => { - const img1 = readImage(imgPath1); - const img2 = readImage(imgPath2); - const { width, height } = img1; - const diff = new PNG({ width, height }); - - const mismatch = pixelmatch( - img1.data, - img2.data, - diff.data, - width, - height, - options, - ); - const mismatch2 = pixelmatch( - img1.data, - img2.data, - null, - width, - height, - options, - ); - - if (process.env.UPDATE || write) { - writeImage(diffPath, diff); - } else { - const expectedDiff = readImage(diffPath); - expect( - Buffer.from(diff.data).equals( - Buffer.from(expectedDiff.data), - ), - ).toBe(true); - } - expect(mismatch).toBe(expectedMismatch); - expect(mismatch).toBe(mismatch2); - }); - }, 600000); -} +runTestSuite(testCases, 'browser'); +runTestSuite(testCases, 'node'); diff --git a/test/test-utils.ts b/test/test-utils.ts new file mode 100644 index 0000000..d06e186 --- /dev/null +++ b/test/test-utils.ts @@ -0,0 +1,259 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import { PNG } from 'pngjs'; +import { pixelmatch, PixelmatchOptions } from '../dist/browser/index.mjs'; +import { pixelmatch as pixelmatchNode } from '../dist/node/index.mjs'; + +// Cache for storing read images +const imageCache = new Map(); + +export function readImage(name: string): PNG { + if (imageCache.has(name)) { + return imageCache.get(name)!; + } + const image = PNG.sync.read( + fs.readFileSync(new URL(`./fixtures/${name}.png`, import.meta.url)), + ); + imageCache.set(name, image); + return image; +} + +export function writeImage(name: string, image: PNG) { + fs.writeFileSync( + new URL(`./fixtures/${name}.png`, import.meta.url), + PNG.sync.write(image), + ); + console.log('wrote image', name); +} + +export interface PixelMatchTestCase { + name: string; + img1: string; + img2: string; + diffOutput: string; + options: PixelmatchOptions; + expectedMismatch: number; + isLargeFile?: boolean; +} + +export const testCases: PixelMatchTestCase[] = [ + { + name: 'Basic threshold test', + img1: '1a', + img2: '1b', + diffOutput: '1diff', + options: { threshold: 0.05 }, + expectedMismatch: 143, + }, + { + name: 'Default threshold test', + img1: '1a', + img2: '1b', + diffOutput: '1diffdefaultthreshold', + options: { threshold: undefined }, + expectedMismatch: 106, + }, + { + name: 'Diff mask test', + img1: '1a', + img2: '1b', + diffOutput: '1diffmask', + options: { threshold: 0.05, includeAA: false, diffMask: true }, + expectedMismatch: 143, + }, + { + name: 'Empty diff mask', + img1: '1a', + img2: '1a', + diffOutput: '1emptydiffmask', + options: { threshold: 0, diffMask: true }, + expectedMismatch: 0, + }, + { + name: 'Alpha and color options', + img1: '2a', + img2: '2b', + diffOutput: '2diff', + options: { + threshold: 0.05, + alpha: 0.5, + aaColor: [0, 192, 0], + diffColor: [255, 0, 255], + }, + expectedMismatch: 12437, + }, + { + name: 'Test 3', + img1: '3a', + img2: '3b', + diffOutput: '3diff', + options: { threshold: 0.05 }, + expectedMismatch: 212, + }, + { + name: 'Test 4', + img1: '4a', + img2: '4b', + diffOutput: '4diff', + options: { threshold: 0.05 }, + expectedMismatch: 36049, + }, + { + name: 'Test 5', + img1: '5a', + img2: '5b', + diffOutput: '5diff', + options: { threshold: 0.05 }, + expectedMismatch: 6, + }, + { + name: 'Test 6', + img1: '6a', + img2: '6b', + diffOutput: '6diff', + options: { threshold: 0.05 }, + expectedMismatch: 51, + }, + { + name: 'Test 6 empty', + img1: '6a', + img2: '6a', + diffOutput: '6empty', + options: { threshold: 0 }, + expectedMismatch: 0, + }, + { + name: 'Diff color alt', + img1: '7a', + img2: '7b', + diffOutput: '7diff', + options: { diffColorAlt: [0, 255, 0] }, + expectedMismatch: 2448, + }, + { + name: 'Test 8', + img1: '8a', + img2: '5b', + diffOutput: '8diff', + options: { threshold: 0.05 }, + expectedMismatch: 32896, + }, + { + name: 'Test 9', + img1: '9a', + img2: '9b', + diffOutput: '9diff', + options: { threshold: 0.1 }, + expectedMismatch: 2944, + }, + { + name: 'Test 9 empty', + img1: '9a', + img2: '9b', + diffOutput: '9empty', + options: { + horizontalShiftPixels: 7, + verticalShiftPixels: 6, + threshold: 0.1, + }, + expectedMismatch: 0, + }, + // Large file tests + { + name: 'Large file with shift', + img1: '10main', + img2: '10baseline', + diffOutput: '10main-diff', + options: { + horizontalShiftPixels: 0, + verticalShiftPixels: 50, + threshold: 0.1, + }, + expectedMismatch: 70240, + isLargeFile: true, + }, + { + name: 'Large file original', + img1: '10main', + img2: '10baseline', + diffOutput: '10main-original-diff', + options: { + horizontalShiftPixels: 0, + verticalShiftPixels: 0, + threshold: 0.1, + }, + expectedMismatch: 1935304, + isLargeFile: true, + }, +]; + +export function runTestSuite( + testCases: PixelMatchTestCase[], + implementation: 'browser' | 'node', +) { + describe(`${implementation} implementation tests`, () => { + testCases.forEach((testCase) => { + it( + testCase.name, + async () => { + const img1 = readImage(testCase.img1); + const img2 = readImage(testCase.img2); + const { width, height } = img1; + const diff = new PNG({ width, height }); + + let mismatch: number; + let mismatch2: number; + if (implementation === 'node') { + mismatch = await pixelmatchNode( + img1.data, + img2.data, + diff.data, + width, + height, + testCase.options, + ); + mismatch2 = await pixelmatchNode( + img1.data, + img2.data, + null, + width, + height, + testCase.options, + ); + } else { + mismatch = pixelmatch( + img1.data, + img2.data, + diff.data, + width, + height, + testCase.options, + ); + mismatch2 = pixelmatch( + img1.data, + img2.data, + null, + width, + height, + testCase.options, + ); + } + + if (process.env.UPDATE) { + writeImage(testCase.diffOutput, diff); + } else { + const expectedDiff = readImage(testCase.diffOutput); + expect( + Buffer.from(diff.data).equals( + Buffer.from(expectedDiff.data), + ), + ).toBe(true); + } + expect(mismatch).toBe(testCase.expectedMismatch); + expect(mismatch).toBe(mismatch2); + }, + 60_000, + ); + }); + }); +} diff --git a/tsup.config.ts b/tsup.config.ts index 52c81fd..1cb9221 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,15 +1,40 @@ import { defineConfig } from 'tsup'; export default defineConfig([ + // Browser build { - entry: ['src/index.ts'], - format: ['cjs', 'esm'], + entry: ['src/browser/index.ts'], + outDir: 'dist/browser', + format: ['esm', 'cjs'], dts: true, sourcemap: true, clean: true, splitting: false, target: 'es2020', }, + // Node build (main and worker) + { + entry: ['src/node/index.ts', 'src/node/worker.ts'], + outDir: 'dist/node', + format: ['cjs', 'esm'], + dts: true, + sourcemap: true, + clean: false, + splitting: false, + target: 'es2020', + }, + // Main entry point (re-export) + { + entry: ['src/index.ts'], + outDir: 'dist', + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: false, + splitting: false, + target: 'es2020', + }, + // CLI bin build { entry: ['bin/pixelmatch.ts'], format: ['cjs'], @@ -21,7 +46,7 @@ export default defineConfig([ sourcemap: false, skipNodeModulesBundle: true, banner: { - js: '#! /usr/bin/env node', + // js: '#! /usr/bin/env node', }, }, ]);