Sign in

    svgdiff-pixelmatch

    Fast pixel-level image comparison library for MoonBit/WASM

    image
    comparison
    diff
    testing
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    9 days ago
    Downloads
    14

    #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::equal

    fn Color::equal(Color, Color) -> Bool

    Color::not_equal

    fn Color::not_equal(x : Color, y : Color) -> Bool

    Color::output

    fn Color::output(self : Color, logger : &Logger) -> Unit

    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

    Color::to_repr

    Color::to_string

    fn Color::to_string(self : Color) -> String

    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.