Fast pixel-level image comparison library for MoonBit/WASM
Dependencies
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 JSONdiff:100/2500(96%match)
..........
.XX.......
.XX.......
..........
..........
..........
..........
..........
..........
..........
regions:5,5,10x10diff: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)| 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 | ◎ |
| Pattern | Without Hints | With Hints |
|---|---|---|
| Donut/Ring | ○ "notched rectangle" | ◎ (HAS_HOLE) |
| Arrow | ○ "wedge" | ◎ (DIRECTIONAL) |
| Checkerboard | △ "stripes" | ◎ (REPEATING) |
| Border/Frame | ○ | ◎ (IS_BORDER) |
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) |
| 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 |
| 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 |
| 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 |
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)| Size | Pixels | Heatmap readback | Diff-count readback | Overhead |
|---|---|---|---|---|
| 1920x1080 | 2.07MP | 8MB | 4B | +1.7ms |
| 3840x2160 | 8.29MP | 32MB | 4B | +6.5ms |
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)| 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 |
pub(all) struct DiffRegion {
x : Int
y : Int
width : Int
height : Int
diff_pixels : Int
region_type : String
}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
}pub(all) struct Image {
width : Int
height : Int
data : FixedArray[Int]
}fn compensated_diff(img1 : Image, img2 : Image, shift_regions : Array[ShiftRegion], threshold : Double) -> Intfn detect_global_shift(profile1 : FixedArray[Double], profile2 : FixedArray[Double], max_shift : Int) -> Intfn detect_piecewise_shift(profile1 : FixedArray[Double], profile2 : FixedArray[Double], max_shift : Int, window_size? : Int) -> Array[ShiftRegion]Fast pixel-level image comparison library for MoonBit/WASM
Dependencies