Pure image comparison for visual regression testing - pixel-level diff analysis without AI/LLM
npm install @yofix/comparator> Pure image comparison for visual regression testing - pixel-level diff analysis without AI/LLM
Pure TypeScript library for comparing baseline vs current screenshots. Generates pixel-perfect diff images, calculates similarity metrics, and detects visual regression regions.
✅ Pure image comparison - No AI/LLM dependencies
✅ Multiple diff formats - Raw, side-by-side, overlay
✅ Parallel processing - Configurable concurrency for batch comparisons
✅ Perceptual hashing - Fast similarity detection
✅ Quality metrics - MSE, PSNR calculations
✅ Region detection - Identify areas with differences
✅ Auto-detect sources - Supports Buffer, file paths, and URLs
✅ Storage-agnostic - Works with any image source
``bash`
npm install @yofix/comparatoror
yarn add @yofix/comparator
`typescript
import { compareBaselines } from '@yofix/comparator'
const result = await compareBaselines({
comparisons: [
{
route: '/dashboard',
viewport: 'desktop',
current: './screenshots/current/dashboard-desktop.png',
baseline: './screenshots/baseline/dashboard-desktop.png'
}
],
options: {
threshold: 0.01,
diffFormat: 'side-by-side',
parallel: { enabled: true, concurrency: 3 },
generateHash: true,
detectRegions: true,
verbose: true
}
})
if (result.success) {
console.log(Overall similarity: ${(result.summary.overallSimilarity * 100).toFixed(2)}%)Differences found: ${result.metadata.differences}
console.log()
result.comparisons.forEach(comp => {
console.log(${comp.route}: ${comp.match ? 'Match ✅' : 'Diff ⚠️'}) Diff image available: ${comp.diff.buffer.length} bytes
if (comp.diff) {
console.log() Regions: ${comp.diff.regions?.length || 0}
console.log()`
}
})
}
Main comparison function.
#### Input
`typescript
interface CompareBaselinesInput {
comparisons: ImagePair[]
options?: CompareOptions
}
interface ImagePair {
route: string // e.g., "/dashboard"
viewport: string // e.g., "desktop", "1920x1080"
current: ImageSource // Buffer | string (path or URL)
baseline: ImageSource // Buffer | string (path or URL)
}
interface CompareOptions {
threshold?: number // Diff threshold (0-1, default: 0.01)
diffFormat?: DiffFormat // 'side-by-side' | 'overlay' | 'raw'
parallel?: {
enabled: boolean
concurrency: number // Default: 3
}
optimization?: {
enabled: boolean
quality: number // 0-100
format: 'png' | 'webp'
}
generateHash?: boolean // Perceptual hash (default: true)
detectRegions?: boolean // Find diff regions (default: false)
verbose?: boolean // Logging (default: false)
}
`
#### Output
`typescript
interface ComparisonResult {
success: boolean
metadata: {
timestamp: number
totalComparisons: number
matches: number
differences: number
duration: number
}
comparisons: Comparison[]
summary: {
overallSimilarity: number // 0-1 (1 = identical)
totalPixelDifference: number
categorizedDiffs: {
critical: number // > 30% diff
moderate: number // 10-30% diff
minor: number // < 10% diff
}
}
errors?: ComparisonError[]
}
interface Comparison {
route: string
viewport: string
current: ImageMetadata
baseline: ImageMetadata
match: boolean
similarity: number // 0-1 (1 = identical)
pixelDifference: number
diffPercentage: number
diff?: {
buffer: Buffer // Diff image buffer
format: DiffFormat
dimensions: { width: number; height: number }
regions?: DiffRegion[] // Diff areas
}
metrics: {
perceptualHash?: {
current: string
baseline: string
hammingDistance: number
}
mse?: number // Mean squared error
psnr?: number // Peak signal-to-noise ratio
}
duration: number
error?: string
}
`
`typescript`
const result = await compareBaselines({
comparisons: [
{
route: '/home',
viewport: 'desktop',
current: './screenshots/current/home-desktop.png',
baseline: './screenshots/baseline/home-desktop.png'
}
]
})
`typescript
import { readFile } from 'fs/promises'
const currentBuffer = await readFile('./current.png')
const baselineBuffer = await readFile('./baseline.png')
const result = await compareBaselines({
comparisons: [
{
route: '/home',
viewport: 'mobile',
current: currentBuffer,
baseline: baselineBuffer
}
]
})
`
`typescript`
const result = await compareBaselines({
comparisons: [
{
route: '/home',
viewport: 'desktop',
current: 'https://example.com/screenshots/current.png',
baseline: 'https://example.com/screenshots/baseline.png'
}
]
})
`typescript`
const result = await compareBaselines({
comparisons: [
{
route: '/home',
viewport: 'desktop',
current: './current/home-desktop.png',
baseline: './baseline/home-desktop.png'
},
{
route: '/dashboard',
viewport: 'desktop',
current: './current/dashboard-desktop.png',
baseline: './baseline/dashboard-desktop.png'
},
{
route: '/settings',
viewport: 'mobile',
current: './current/settings-mobile.png',
baseline: './baseline/settings-mobile.png'
}
],
options: {
parallel: {
enabled: true,
concurrency: 3 // Process 3 at a time
},
verbose: true
}
})
`typescript
import { writeFile } from 'fs/promises'
const result = await compareBaselines({
comparisons: [
{
route: '/home',
viewport: 'desktop',
current: './current/home.png',
baseline: './baseline/home.png'
}
],
options: {
diffFormat: 'side-by-side',
detectRegions: true
}
})
// Save diff image
if (result.comparisons[0].diff) {
await writeFile(
'./diffs/home-diff.png',
result.comparisons[0].diff.buffer
)
console.log('Diff regions:', result.comparisons[0].diff.regions)
}
`
`typescript
import { compareBaselines } from '@yofix/comparator'
import { downloadFiles, uploadFiles } from '@yofix/storage'
// 1. Download baselines from storage
const baselines = await downloadFiles({
storage: {
provider: 'firebase',
config: {
bucket: 'my-bucket',
credentials: process.env.FIREBASE_CREDENTIALS
}
},
files: ['baselines/dashboard/desktop.png']
})
// 2. Compare
const result = await compareBaselines({
comparisons: [
{
route: '/dashboard',
viewport: 'desktop',
current: './screenshots/dashboard-desktop.png',
baseline: baselines[0].buffer
}
],
options: {
diffFormat: 'side-by-side',
detectRegions: true
}
})
// 3. Upload diffs to storage
if (result.comparisons[0].diff) {
await uploadFiles({
storage: {
provider: 'firebase',
config: {
bucket: 'my-bucket',
credentials: process.env.FIREBASE_CREDENTIALS,
basePath: 'diffs'
}
},
files: [{
path: result.comparisons[0].diff.buffer,
destination: 'dashboard/desktop-diff.png'
}]
})
}
`
typescript
diffFormat: 'raw'
`$3
Baseline | Diff | Current
`typescript
diffFormat: 'side-by-side'
`$3
Baseline with current overlaid at 50% opacity
`typescript
diffFormat: 'overlay'
`Metrics
$3
Fast similarity check using average hash algorithm. Returns binary string and Hamming distance.`typescript
metrics.perceptualHash: {
current: '1010110110101...',
baseline: '1010110110101...',
hammingDistance: 2 // Number of different bits
}
`$3
Lower is better. 0 = identical.`typescript
metrics.mse: 12.45
`$3
Higher is better. Infinity = identical.`typescript
metrics.psnr: 35.2 // dB
`PSNR Interpretation:
-
> 40 dB: Excellent (virtually identical)
- 30-40 dB: Good (minor differences)
- 20-30 dB: Fair (noticeable differences)
- < 20 dB: Poor (significant differences)Region Detection
Detects contiguous areas with differences and categorizes by severity:
`typescript
diff.regions: [
{
x: 120,
y: 45,
width: 200,
height: 150,
severity: 'critical', // > 1000 pixels
pixelCount: 1250
}
]
`Severity Levels:
-
critical: > 1000 pixels different
- moderate: 500-1000 pixels different
- minor: < 500 pixels differentError Handling
`typescript
const result = await compareBaselines({...})if (!result.success) {
result.errors?.forEach(error => {
console.error(
${error.code}: ${error.message})
console.error('Route:', error.route)
console.error('Phase:', error.phase)
})
}
`Error Codes:
-
VALIDATION_ERROR: Invalid input
- IMAGE_LOAD_ERROR: Failed to load image
- IMAGE_DIMENSION_ERROR: Image dimensions don't match
- COMPARISON_ERROR: Pixel comparison failed
- DIFF_GENERATION_ERROR: Diff image creation failed
- NETWORK_ERROR: Download failed (for URLs)Advanced Usage
$3
`typescript
import { Comparator } from '@yofix/comparator'const comparator = new Comparator()
const comparison = await comparator.compareImages(
{
route: '/home',
viewport: 'desktop',
current: './current.png',
baseline: './baseline.png'
},
{
threshold: 0.01,
diffFormat: 'raw',
generateHash: true
}
)
`Performance
- Parallel processing: Configurable concurrency (default: 3)
- Fast perceptual hashing: 8x8 average hash
- Optimized pixel diff: Uses
pixelmatch library
- Memory efficient: Streams large imagesBenchmark (1920x1080 images):
- Load: ~50ms per image
- Compare: ~100ms per pair
- Diff generation: ~150ms
- Perceptual hash: ~20ms
Dependencies
-
sharp: Image processing
- pixelmatch: Pixel-level comparison
- pngjs: PNG manipulation
- zod: Input validationRelated Packages
-
@yofix/browser: Screenshot capture
- @yofix/storage: Multi-provider storage
- @yofix/analyzer`: Route impact analysisMIT
Issues and PRs welcome at https://github.com/yofix/yofix