Pixelmatch for Humans and AI - Fast pixel-level image comparison library for MoonBit/WASM.
Port of mapbox/pixelmatch to MoonBit.
- 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
moon add mizchi/pixelmatchlet 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 JSONThe 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)
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:
| Hint | Description |
|---|---|
HAS_HOLE |
Shape has empty center (ring, donut, frame) |
IS_BORDER |
Changes only on edges (frame pattern) |
DIRECTIONAL |
Asymmetric shape (top/bottom/left/right heavy) |
MULTI_REGION |
Multiple separate diff areas |
REPEATING |
Similar-sized regions (grid/checkerboard) |
| Format | Tokens | Use case |
|---|---|---|
to_compact() |
~80 | AI agents, automated pipelines |
to_compact_with_hints() |
~100 | AI with complex shapes |
to_text() |
~400 | Human review, debugging |
to_json() |
~300 | Programmatic access |
| Pattern | Accuracy |
|---|---|
| Identical images | ◎ |
| Rectangle added/removed | ◎ |
| Border/frame | ◎ |
| Circle in center | ◎ |
| Horizontal/vertical line | ◎ |
| Scattered dots | ◎ |
| Half image changed | ◎ |
| Diagonal stripe | ◎ |
Result: 90% accuracy
| Pattern | Without Hints | With Hints |
|---|---|---|
| Donut/Ring | ○ "notched rectangle" | ◎ (HAS_HOLE) |
| Arrow | ○ "wedge" | ◎ (DIRECTIONAL) |
| Checkerboard | △ "stripes" | ◎ (REPEATING) |
| Border/Frame | ○ | ◎ (IS_BORDER) |
Result: 60% → 95% accuracy with hints
For complex shapes, use grid_size=20:
let report = diff_report(img1, img2, options, grid_size=20)| Resolution | Tokens | Accuracy |
|---|---|---|
| 10x10 | ~80 | 60% (complex) |
| 20x20 | ~320 | 80% (complex) |
| 10x10 + hints | ~100 | 95% (complex) |
Recommendation: Use to_compact_with_hints() for best accuracy/token ratio.
| Scenario | JS (V8) | WASM | Native (C FFI) |
|---|---|---|---|
| Identical | 313µs | 225µs | 15µs |
| 5% diff | 307µs | 235µs | 17µs |
| All different | 312µs | 394µs | 54µs |
| 500x500 identical | 1880µs | 1430µs | 93µs |
| Implementation | JS (V8) | WASM | Native |
|---|---|---|---|
pixelmatch_simple |
451µs | 402µs | 1190µs |
pixelmatch_simple_prefilter |
313µs | 225µs | 281µs |
pixelmatch_native (C FFI) |
N/A | N/A | 15µs |
| Step | JS (V8) | WASM |
|---|---|---|
| PNG decode | 18.6ms | 7.4ms |
pixelmatch_fast |
1.1ms | 0.24ms |
| PNG encode | 29.4ms | 12.5ms |
| Full pipeline | 95ms | 31ms |
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.
| Implementation | Time | vs CPU baseline |
|---|---|---|
| CPU simple | 2326µs | 1.0x |
| CPU prefilter | 2338µs | 1.0x |
| Rayon (10 threads) | 550µs | 4.2x |
| Rayon + prefilter | 400µs | 5.8x |
| GPU (wgpu, incl. transfer) | 4182µs | 0.6x |
GPU compute becomes faster than Rayon+prefilter only when buffers are already on GPU (no upload cost). With per-frame upload, CPU always wins.
| Scenario | GPU wins at | Notes |
|---|---|---|
| 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% diff | Never | Prefilter's memcmp skips identical rows; GPU can't |
| With upload | Never | Transfer 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)
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:
| Size | Pixels | Heatmap readback | Diff-count readback | Overhead |
|---|---|---|---|---|
| 1920x1080 | 2.07MP | 8MB | 4B | +1.7ms |
| 3840x2160 | 8.29MP | 32MB | 4B | +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:
- 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.
- GPU-side rendering: Keep heatmap on GPU as a texture and render directly (WebGPU in browser). Zero readback cost.
- Partial readback: Read back only a grid summary (e.g., 64x36 block counts = 9KB) instead of per-pixel data.
- 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)
Compare two images and return the number of different pixels.
Simple comparison without anti-aliasing detection.
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)
Simple comparison with row-level prefilter. Skips identical rows using fast comparison.
C FFI optimized comparison. Called internally by pixelmatch_fast on native target.
Calculate match ratio (0.0 = completely different, 1.0 = identical).
Generate comprehensive diff report with statistics, heatmap, and regions.
| Method | Description |
|---|---|
to_compact() |
Minimal binary heatmap |
to_compact_with_hints() |
With automatic shape hints |
to_text() |
Verbose human-readable |
to_json() |
Structured JSON |
| Field | Type | Default | Description |
|---|---|---|---|
threshold |
Double |
0.1 |
Matching threshold (0-1). Smaller = more sensitive |
include_aa |
Bool |
false |
Include anti-aliased pixels in diff count |
alpha |
Double |
0.1 |
Blending factor for unchanged pixels |
aa_color |
Color |
Yellow | Color for anti-aliased pixels in diff |
diff_color |
Color |
Red | Color for different pixels in diff |
diff_mask |
Bool |
false |
Only draw changed pixels |
Apache-2.0