pixelmatch

Fast pixel-level image comparison library for MoonBit/WASM

image
comparison
diff
testing
moon add mizchi/pixelmatch@0.6.1
Download zip
Author
Version
0.6.1
License
Apache-2.0
Last updated
2 months ago
Downloads
29K

Dependencies

README

#pixelmatch

Pixelmatch for Humans and AI - Fast pixel-level image comparison library for MoonBit/WASM.

Port of mapbox/pixelmatch to MoonBit.

#Features

  • YIQ color space for perceptual color difference
  • Anti-aliasing detection
  • Configurable threshold
  • Diff image generation
  • AI-friendly diff reports with automatic shape hints
  • Simple and fast API

#Installation

moon add mizchi/pixelmatch

#Usage

let img1 = @pixelmatch.Image::new(100, 100)
let img2 = @pixelmatch.Image::new(100, 100)

// Fill images with pixel data...

// Simple comparison
let diff_count = @pixelmatch.pixelmatch_simple(img1, img2, 0.1)

// Full comparison with options
let options = @pixelmatch.Options::default()
let output = @pixelmatch.Image::new(100, 100)
let diff_count = @pixelmatch.pixelmatch(img1, img2, Some(output), options)

// Get match ratio (0.0 to 1.0)
let ratio = @pixelmatch.match_ratio(img1, img2, options)

// Generate AI-friendly diff report
let report = @pixelmatch.diff_report(img1, img2, options)
println(report.to_compact()) // Minimal tokens for AI
println(report.to_compact_with_hints()) // With shape hints (recommended)
println(report.to_text()) // Verbose for humans
println(report.to_json()) // Structured JSON

#AI-Friendly Diff Report

#Compact Format

The to_compact() method generates a minimal-token format optimized for AI:

diff:100/2500(96%match) .......... .XX....... .XX....... .......... .......... .......... .......... .......... .......... .......... regions:5,5,10x10

Format:
  • Line 1: diff:count/total(match%)
  • Lines 2-11: 10x10 binary heatmap (. = no diff, X = diff)
  • Last line: regions:x,y,WxH;... (semicolon-separated)

#Shape Hints

The to_compact_with_hints() method adds automatic shape detection:

diff:952/2500(61%match) .......... ..XXXXXX.. .XXXXXXXX. .XXXXXXXX. .XXX..XXX. .XXX...XXX .XXXX.XXX. .XXXXXXXX. ..XXXXXX.. .......... regions:5,5,41x41 hints:HAS_HOLE: shape may have empty center (ring/donut/frame)

Available hints:

HintDescription
HAS_HOLEShape has empty center (ring, donut, frame)
IS_BORDERChanges only on edges (frame pattern)
DIRECTIONALAsymmetric shape (top/bottom/left/right heavy)
MULTI_REGIONMultiple separate diff areas
REPEATINGSimilar-sized regions (grid/checkerboard)

#Format Comparison

FormatTokensUse case
to_compact()~80AI agents, automated pipelines
to_compact_with_hints()~100AI with complex shapes
to_text()~400Human review, debugging
to_json()~300Programmatic access

#AI Interpretation Accuracy

#Simple Patterns (10x10 grid)

PatternAccuracy
Identical images
Rectangle added/removed
Border/frame
Circle in center
Horizontal/vertical line
Scattered dots
Half image changed
Diagonal stripe

Result: 90% accuracy

#Complex Patterns

PatternWithout HintsWith Hints
Donut/Ring○ "notched rectangle"◎ (HAS_HOLE)
Arrow○ "wedge"◎ (DIRECTIONAL)
Checkerboard△ "stripes"◎ (REPEATING)
Border/Frame◎ (IS_BORDER)

Result: 60% → 95% accuracy with hints

#High-Resolution Mode

For complex shapes, use grid_size=20:

let report = diff_report(img1, img2, options, grid_size=20)

ResolutionTokensAccuracy
10x10~8060% (complex)
20x20~32080% (complex)
10x10 + hints~10095% (complex)

Recommendation: Use to_compact_with_hints() for best accuracy/token ratio.

#Performance

#pixelmatch_fast — Unified API (200x200, Apple M5)

ScenarioJS (V8)WASMNative (C FFI)
Identical313µs225µs15µs
5% diff307µs235µs17µs
All different312µs394µs54µs
500x500 identical1880µs1430µs93µs

#All Implementations (200x200, identical, Apple M5)

ImplementationJS (V8)WASMNative
pixelmatch_simple451µs402µs1190µs
pixelmatch_simple_prefilter313µs225µs281µs
pixelmatch_native (C FFI)N/AN/A15µs

#E2E Pipeline with PNG (200x200, mizchi/image, Apple M5)

StepJS (V8)WASM
PNG decode18.6ms7.4ms
pixelmatch_fast1.1ms0.24ms
PNG encode29.4ms12.5ms
Full pipeline95ms31ms

PNG encode/decode dominates the pipeline (97% on WASM, 99% on JS). Optimizing the codec (e.g., native zlib C FFI) has far more impact than pixelmatch itself in E2E scenarios.

#Rust Benchmark (1920x1080, 5% diff, Apple M5)

ImplementationTimevs CPU baseline
CPU simple2326µs1.0x
CPU prefilter2338µs1.0x
Rayon (10 threads)550µs4.2x
Rayon + prefilter400µs5.8x
GPU (wgpu, incl. transfer)4182µs0.6x

#GPU vs CPU Crossover (Apple M5, wgpu/Metal)

GPU compute becomes faster than Rayon+prefilter only when buffers are already on GPU (no upload cost). With per-frame upload, CPU always wins.

ScenarioGPU wins atNotes
Dispatch only, 5% diff~4MP (2048x2048)GPU 1354µs vs Rayon 2682µs
Dispatch only, 100% diff~8MP (3840x2160)GPU 1286µs vs Rayon 1834µs
Dispatch only, 0% diffNeverPrefilter's memcmp skips identical rows; GPU can't
With uploadNeverTransfer overhead (>10ms at 4K) negates compute gains

Detailed crossover (5% diff, dispatch only vs Rayon+prefilter):

size pixels rayon+pf gpu_dispatch 256x256 0.07MP 55µs 1329µs ← Rayon 24x faster 1024x1024 1.05MP 306µs 1350µs ← Rayon 4.4x faster 1920x1080 2.07MP 674µs 1355µs ← Rayon 2.0x faster 2048x2048 4.19MP 2682µs 1354µs ← GPU wins (2.0x) 3840x2160 8.29MP 2179µs 1510µs ← GPU wins (1.4x) 4000x4000 16.00MP 3988µs 2790µs ← GPU wins (1.4x)

#GPU Readback Overhead

The crossover above assumes diff count only (4-byte atomic counter readback). If you need a per-pixel diff heatmap, the readback cost changes drastically:

SizePixelsHeatmap readbackDiff-count readbackOverhead
1920x10802.07MP8MB4B+1.7ms
3840x21608.29MP32MB4B+6.5ms

Effective GPU time with heatmap readback (dispatch only, prealloc):

1920x1080: dispatch 1.35ms + heatmap readback 1.7ms = ~3.0ms (vs Rayon 674µs → Rayon wins) 3840x2160: dispatch 1.51ms + heatmap readback 6.5ms = ~8.0ms (vs Rayon 2179µs → Rayon wins)

Heatmap readback erases GPU's compute advantage at all tested sizes. Practical strategies:

  1. Two-stage pipeline: GPU dispatch for fast diff-count triage (4B readback). Only generate heatmaps on CPU (Rayon) for the few pairs with diff > 0. In typical VRT, 90%+ of pairs are identical.
  2. GPU-side rendering: Keep heatmap on GPU as a texture and render directly (WebGPU in browser). Zero readback cost.
  3. Partial readback: Read back only a grid summary (e.g., 64x36 block counts = 9KB) instead of per-pixel data.

#Key Findings

  • Use pixelmatch_fast — automatically picks the best implementation per target
  • WASM is the fastest portable target — 1.5-2.5x faster than JS across all benchmarks
  • Native C FFI is 15-30x faster than WASM for pixelmatch itself (15µs vs 225µs @200x200 identical)
  • E2E bottleneck is PNG codec, not pixelmatch — encode/decode is 97%+ of pipeline time on WASM
  • Row prefilter skips identical rows with fast memcmp — up to 25x for nearly-identical images
  • Rust Rayon + prefilter is the fastest CPU option at 5.8x over single-threaded
  • GPU (wgpu) wins at 4MP+ with pre-allocated buffers for diff-count only, but heatmap readback negates the advantage
  • Best batch VRT architecture: GPU for triage (diff/no-diff), CPU Rayon for heatmap on flagged pairs

Run benchmarks: just bench (MoonBit) / just bench-rs (Rust)

#API

#pixelmatch(img1, img2, output?, options) -> Int

Compare two images and return the number of different pixels.

#pixelmatch_simple(img1, img2, threshold) -> Int

Simple comparison without anti-aliasing detection.

#pixelmatch_fast(img1, img2, threshold) -> Int

Recommended for most use cases. Unified API that dispatches to the fastest implementation per target:
  • Native: C FFI with hardware memcmp + LLVM auto-vectorized YIQ delta
  • JS/WASM: Row-level prefilter (memcmp-style skip)

#pixelmatch_simple_prefilter(img1, img2, threshold) -> Int

Simple comparison with row-level prefilter. Skips identical rows using fast comparison.

#pixelmatch_native(img1, img2, threshold) -> Int (native target only)

C FFI optimized comparison. Called internally by pixelmatch_fast on native target.

#match_ratio(img1, img2, options) -> Double

Calculate match ratio (0.0 = completely different, 1.0 = identical).

#diff_report(img1, img2, options, grid_size~) -> DiffReport

Generate comprehensive diff report with statistics, heatmap, and regions.

#DiffReport Methods

MethodDescription
to_compact()Minimal binary heatmap
to_compact_with_hints()With automatic shape hints
to_text()Verbose human-readable
to_json()Structured JSON

#Options

FieldTypeDefaultDescription
thresholdDouble0.1Matching threshold (0-1). Smaller = more sensitive
include_aaBoolfalseInclude anti-aliased pixels in diff count
alphaDouble0.1Blending factor for unchanged pixels
aa_colorColorYellowColor for anti-aliased pixels in diff
diff_colorColorRedColor for different pixels in diff
diff_maskBoolfalseOnly draw changed pixels

#License

Apache-2.0

#
Color

pub(all) struct Color {
r : Int
g : Int
b : Int
a : Int
} derive(Eq,
Debug
)

RGBA color representation
impl Show for Color

#
Color::rgb

fn Color::rgb(r : Int, g : Int, b : Int) -> Color

#
Color::rgba

fn Color::rgba(r : Int, g : Int, b : Int, a : Int) -> Color

#
DiffRegion

pub(all) struct DiffRegion {
x : Int
y : Int
width : Int
height : Int
diff_pixels : Int
region_type : String
}

Bounding box for a diff region

#
DiffReport

pub(all) struct DiffReport {
width : Int
height : Int
total_pixels : Int
diff_count : Int
aa_count : Int
match_ratio : Double
grid : Array[Array[Int]]
grid_cols : Int
grid_rows : Int
regions : Array[DiffRegion]
shift_only : Bool
content_change_count : Int
global_shift : Int
shift_regions : Array[ShiftRegion]
compensated_diff_count : Int
}

Comprehensive diff report for AI consumption

#
DiffReport::to_compact

fn DiffReport::to_compact(self : DiffReport) -> String

Convert DiffReport to compact AI format Minimal tokens, maximum spatial information

#
DiffReport::to_compact_with_hints

fn DiffReport::to_compact_with_hints(self : DiffReport) -> String

Convert DiffReport to compact format with shape hints Adds contextual hints to help AI interpretation

#
DiffReport::to_json

fn DiffReport::to_json(self : DiffReport) -> String

Convert DiffReport to JSON string

#
DiffReport::to_text

fn DiffReport::to_text(self : DiffReport) -> String

Convert DiffReport to AI-readable text format

#
Image

pub(all) struct Image {
width : Int
height : Int
data : FixedArray[Int]
}

Image data (RGBA pixels in row-major order)

#
Image::from_pixels

fn Image::from_pixels(width : Int, height : Int, pixels : Array[Color]) -> Image

#
Image::get_pixel

fn Image::get_pixel(self : Image, x : Int, y : Int) -> Color

#
Image::new

fn Image::new(width : Int, height : Int) -> Image

#
Image::set_pixel

fn Image::set_pixel(self : Image, x : Int, y : Int, c : Color) -> Unit

#
Options

pub(all) struct Options {
threshold : Double
include_aa : Bool
alpha : Double
aa_color : Color
diff_color : Color
diff_color_alt : Color?
diff_mask : Bool
detect_shift : Bool
}

Pixelmatch options

#
Options::default

fn Options::default() -> Options

#
ShiftRegion

pub(all) struct ShiftRegion {
y_start : Int
y_end : Int
shift : Int
}

#
compensated_diff

fn compensated_diff(img1 : Image, img2 : Image, shift_regions : Array[ShiftRegion], threshold : Double) -> Int

#
detect_global_shift

fn detect_global_shift(profile1 : FixedArray[Double], profile2 : FixedArray[Double], max_shift : Int) -> Int

Detect global vertical shift via two-phase cross-correlation Phase 1: coarse search with stride, Phase 2: refine around best Returns offset (positive = img2 shifted down relative to img1)

#
detect_piecewise_shift

fn detect_piecewise_shift(profile1 : FixedArray[Double], profile2 : FixedArray[Double], max_shift : Int, window_size? : Int) -> Array[ShiftRegion]

Detect piecewise vertical shifts using sliding window cross-correlation

#
diff_report

fn diff_report(img1 : Image, img2 : Image, options : Options, grid_size? : Int) -> DiffReport

Generate a comprehensive diff report Optimized: pre-allocate arrays, cache references

#
luminance_profile

fn luminance_profile(img : Image) -> FixedArray[Double]

Compute average luminance (Y channel) per row

#
match_ratio

fn match_ratio(img1 : Image, img2 : Image, options : Options) -> Double

Calculate match ratio (0.0 = completely different, 1.0 = identical)

#
pixelmatch

fn pixelmatch(img1 : Image, img2 : Image, output : Image?, options : Options) -> Int

Compare two images and return the number of different pixels Optionally writes diff image to output

#
pixelmatch_fast

fn pixelmatch_fast(img1 : Image, img2 : Image, threshold : Double) -> Int

Fastest available simple comparison for this target. On native: C FFI with hardware memcmp + LLVM auto-vectorized YIQ delta. On JS/WASM: falls back to pixelmatch_simple_prefilter (defined in lib_nonjs.mbt).

#
pixelmatch_native

fn pixelmatch_native(img1 : Image, img2 : Image, threshold : Double) -> Int

#
pixelmatch_simple

fn pixelmatch_simple(img1 : Image, img2 : Image, threshold : Double) -> Int

Simple comparison without anti-aliasing detection Returns the number of different pixels Optimized: inline delta calculation, minimize function calls

#
pixelmatch_simple_prefilter

fn pixelmatch_simple_prefilter(img1 : Image, img2 : Image, threshold : Double) -> Int

Simple comparison with row-level prefilter optimization. Skips entire rows where all pixels are identical (common in VRT screenshots). Falls back to per-pixel delta for rows with differences.