butteraugli [](https://github.com/imazen/butteraugli/actions/workflows/ci.yml) [](https://crates.io/crates/butteraugli) [](https://lib.rs/crates/butteraugli) [](https://docs.rs/butteraugli) [](#license) [](https://codecov.io/gh/imazen/butteraugli)

June 28, 2026 · View on GitHub

A pure-Rust port of butteraugli, the perceptual image-difference metric from Google's libjxl. It models human vision — opsin dynamics, an opponent XYB color space, visual masking, and multi-scale frequency analysis — to estimate how different two images look, where pixel-wise metrics like PSNR and MSE do not. Scores are validated against libjxl's butteraugli_main to within FMA rounding noise. No C dependencies; runtime SIMD dispatch (AVX-512 / AVX2 / SSE4.2 / NEON / WASM) via archmage; safe Rust by default (unsafe-performance is opt-in).

Quick start

[dependencies]
butteraugli = "0.9.4"
use butteraugli::{butteraugli, ButteraugliParams, Img, RGB8};

// Two equally-sized images as packed RGB8 pixels (sRGB, gamma-encoded).
let reference: Vec<RGB8> = decode_reference();
let distorted: Vec<RGB8> = decode_distorted();

let r = Img::new(reference, width, height);
let d = Img::new(distorted, width, height);

let result = butteraugli(r.as_ref(), d.as_ref(), &ButteraugliParams::default())?;

println!("butteraugli score: {:.4}", result.score); // < 1.0 good, > 2.0 visible

Img, ImgRef, ImgVec, RGB, and RGB8 are re-exported from the crate root, so you don't need to add imgref or rgb to your own Cargo.toml. If your decoder hands you a flat Vec<u8>, see From a flat Vec<u8> below.

What is butteraugli?

Butteraugli estimates the perceived difference between two images using a model of human vision. Unlike simple pixel-wise metrics (PSNR, MSE), it accounts for:

  • Opsin dynamics: photosensitive chemical responses in the retina
  • XYB color space: a hybrid opponent / trichromatic representation
  • Visual masking: how image features hide or reveal differences
  • Multi-scale analysis: UHF, HF, MF, and LF frequency bands

Quality thresholds

ScoreInterpretation
< 1.0Images appear identical to most viewers
1.0 – 2.0Subtle differences may be noticeable
> 2.0Visible differences between images

The score is the max-norm (worst-region, p = ∞) distance: 0.0 for identical input, lower is better, unbounded above. Because it models absolute luminance and a fixed pixels-per-degree, scores are not comparable across different image resolutions. The public constants BUTTERAUGLI_GOOD (1.0) and BUTTERAUGLI_BAD (2.0) name the two thresholds above.

Command-line tool

The butteraugli-cli crate installs a butteraugli binary:

cargo install butteraugli-cli
# Compare two images (REFERENCE first, then DISTORTED; same dimensions required)
butteraugli original.png compressed.jpg
# Butteraugli score: 1.2345

butteraugli -q original.png compressed.jpg          # add a quality rating
butteraugli --json original.png compressed.jpg      # machine-readable output
butteraugli --pnorm original.png compressed.jpg     # also print the libjxl 3-norm
butteraugli --diffmap diff.png original.png compressed.jpg   # save a heatmap
butteraugli --quiet original.png compressed.jpg     # just the number

butteraugli --intensity-target 250 hdr_a.png hdr_b.png   # HDR (display nits)
butteraugli --hf-asymmetry 1.5 original.png compressed.jpg  # penalize ringing > blur
butteraugli --help

Library usage

[dependencies]
butteraugli = "0.9.4"

Input formats

FunctionInput typeColor spaceUse case
butteraugliImgRef<RGB8>sRGB (gamma-encoded)Standard 8-bit images
butteraugli_linearImgRef<RGB<f32>>Linear RGBHDR, 16-bit, float pipelines

Both APIs support stride (padding). Images smaller than 8×8 (down to 1×1) are reflect (mirror)-padded up to butteraugli's 8×8 floor and scored; the diffmap is cropped back to the input size. The strip APIs below still require at least 8×8.

Scaling contract: linear 1.0 maps to intensity_target nits (default 80.0 — the SDR convention used by libjxl's butteraugli_main). Values above 1.0 are accepted and map proportionally above intensity_target. For HDR, scale your linear data so 1.0 is the mastering/display peak and set .with_intensity_target(peak_nits). The sRGB u8 path decodes with the sRGB EOTF to linear before the same scaling. Negative values are clamped to 0.0 inside the opsin stage; NaN/Inf inputs are rejected with ButteraugliError::NonFiniteResult.

Example

use butteraugli::{butteraugli, ButteraugliParams, Img, RGB8};

let original: Vec<RGB8> = load_image();
let compressed: Vec<RGB8> = load_compressed();

let img1 = Img::new(original, width, height);
let img2 = Img::new(compressed, width, height);

let result = butteraugli(img1.as_ref(), img2.as_ref(), &ButteraugliParams::default())?;

println!("Score: {:.4}", result.score);

From a flat Vec<u8> of RGB bytes

Most decoders hand you a packed row-major Vec<u8> (3 bytes per pixel), not a Vec<RGB8>. Turn the bytes into RGB8 pixels with RGB8::new and wrap them with Img::new:

use butteraugli::{butteraugli, ButteraugliParams, Img, RGB8};

// `orig` and `dist` are `&[u8]`, row-major, exactly `width * height * 3` bytes.
fn score(orig: &[u8], dist: &[u8], width: usize, height: usize)
    -> Result<f64, butteraugli::ButteraugliError>
{
    let to_pixels = |bytes: &[u8]| -> Vec<RGB8> {
        bytes
            .chunks_exact(3)
            .map(|px| RGB8::new(px[0], px[1], px[2]))
            .collect()
    };

    let img1 = Img::new(to_pixels(orig), width, height);
    let img2 = Img::new(to_pixels(dist), width, height);

    let result = butteraugli(img1.as_ref(), img2.as_ref(), &ButteraugliParams::default())?;
    Ok(result.score)
}

If your rows carry padding (a row stride larger than width * 3), keep the flat buffer and build a borrowed view with ImgRef::new_stride(buf, width, height, stride_in_pixels) instead of copying into a packed Vec — the linear path has a matching ImgRef<RGB<f32>> form. Both the sRGB and linear APIs walk stride natively at no extra cost on the tightly-packed path.

RGBA input

There is no RGBA entry point — butteraugli compares three color channels and ignores alpha. If your buffer is 4 bytes per pixel, drop the alpha while building the pixels with chunks_exact(4) and keep the first three lanes:

use butteraugli::RGB8;
let to_pixels = |bytes: &[u8]| -> Vec<RGB8> {
    bytes
        .chunks_exact(4) // RGBA in, RGB8 out
        .map(|px| RGB8::new(px[0], px[1], px[2]))
        .collect()
};

Using chunks_exact(3) on RGBA bytes does not fail — it silently misaligns every pixel after the first and produces a meaningless score, so match the chunk size to your actual layout. (Premultiplied alpha is not unpremultiplied for you; convert to straight RGB first if your pipeline premultiplies.)

Difference map

let params = ButteraugliParams::default().with_compute_diffmap(true);
let result = butteraugli(img1.as_ref(), img2.as_ref(), &params)?;

if let Some(diffmap) = result.diffmap {
    let max_diff = diffmap.buf().iter().fold(0.0f32, |a, &b| a.max(b));
    println!("Maximum local difference: {:.4}", max_diff);
}

Aggregations

result.score is the max-norm distance — the historical butteraugli score behind the <1.0 = good, >2.0 = bad thresholds. result.pnorm_3 is the libjxl 3-norm aggregation reported by butteraugli_main --pnorm and used in the Cloudinary CID22 paper — useful for codec rate-distortion sweeps where averaging tail and bulk distortion is more informative than max alone. Both are produced in a single fused reduction pass and available regardless of whether compute_diffmap was enabled (no extra allocation).

println!("max-norm:  {:.4}", result.score);     // or result.max_norm()
println!("3-norm:    {:.4}", result.pnorm_3);
println!("p=4 norm:  {:?}", result.pnorm(4.0)); // requires compute_diffmap = true

The CLI exposes the 3-norm via butteraugli --pnorm; JSON output always includes a pnorm_3 field.

Custom parameters

let params = ButteraugliParams::new()
    .with_hf_asymmetry(1.5)        // penalize artifacts > blur
    .with_intensity_target(250.0)  // HDR display (nits)
    .with_compute_diffmap(true);

Repeated comparisons and bounded memory

For comparing many distorted images against one reference, precompute the reference once with ButteraugliReference, then call a compare method per candidate — the reference-side XYB pyramid and masks are reused.

Build the referenceCompare a candidate
new(&[u8], w, h, params) (sRGB)compare(&[u8])
new_linear(&[f32], w, h, params)compare_linear(&[f32])
new_linear_planar(r, g, b, w, h, stride, params)compare_linear_planar(r, g, b, stride)
from_srgb(ImgRef<RGB8>, params)compare_srgb(ImgRef<RGB8>)
from_linear(ImgRef<RGB<f32>>, params)compare_linear_imgref(ImgRef<RGB<f32>>)
use butteraugli::{ButteraugliParams, ButteraugliReference};

let reference = ButteraugliReference::new(&ref_rgb, width, height, ButteraugliParams::default())?;
for candidate in candidates {
    let result = reference.compare(&candidate)?;
    println!("{:.4}", result.score);
}

Budgeting memory. ButteraugliReference::estimated_reference_bytes(width, height, &params) returns the a-priori heap cost of a reference's persistent precompute before you build it — so a caller on a memory budget (e.g. an encoder's quantization loop) can reserve or reject up front instead of risking an OOM. On a live reference, precompute_bytes() reports the persistent precompute footprint and memory_bytes() the full retained footprint (precompute + strip source + idle buffer pool). width(), height(), and params() read back the reference's configuration.

Reclaiming memory. shrink_to_fit() drains the cached buffer pool (one re-allocation on the next compare, no loss of the warm-reference speedup); drop_strip_source() releases the retained reference-side source when you know no strip comparison will follow (subsequent compare_strip* calls then return InvalidParameter).

Strip mode for very large images. The strip API bounds peak memory by walking the image in horizontal strips, at equivalent wall time:

  • One-shot: butteraugli_strip / butteraugli_linear_strip (plus *_with_config and *_with_stop variants).
  • Cached reference: ButteraugliReference::compare_strip / compare_linear_strip / compare_strip_srgb / compare_strip_linear_imgref (and their *_with_config / *_with_stop twins). These require a reference built via new / new_linear / from_srgb / from_linear — the planar constructor doesn't retain interleaved source.

Strip scores match the full-image path to within ~1e-2 (the strip_parity test tolerance; the FIR finite support makes interior diffmaps bit-identical, so the small residual is only f64 sum-associativity in the cross-strip reduction). At 40 MP a heaptrack run measured peak heap dropping from 7.43 GB to 1.94 GB (3.8×; butteraugli 0.9.3 notes), and a committed max-RSS A/B (benchmarks/strip_vs_full_mem_2026-06-23.tsv) shows ~2.8–3.0× lower peak RSS across 16–36 MP with bit-identical scores. Tune the halo overlap via ButteraugliStripConfig::with_halo_rows (HALO_ROWS_DEFAULT = 64; MIN_STRIP_HEIGHT = 8).

Cooperative cancellation

Every slow entry point has a *_with_stop twin that takes a trailing stop: &dyn enough::Stop token. The token is polled at the outermost per-scale boundary (one-shot) and once per strip at the top of the strip loop — never inside the per-pixel opsin / blur / Malta / masking kernels — so a cancellation is honored at scale / strip granularity with zero hot-loop overhead. When the token fires, the call returns Err(ButteraugliError::Cancelled(reason)).

Non-cancellableCancellable twin
butterauglibutteraugli_with_stop
butteraugli_linearbutteraugli_linear_with_stop
butteraugli_stripbutteraugli_strip_with_stop
butteraugli_linear_stripbutteraugli_linear_strip_with_stop
ButteraugliReference::comparecompare_with_stop
ButteraugliReference::compare_linearcompare_linear_with_stop
ButteraugliReference::compare_srgbcompare_srgb_with_stop
ButteraugliReference::compare_linear_imgrefcompare_linear_imgref_with_stop
ButteraugliReference::compare_stripcompare_strip_with_stop
ButteraugliReference::compare_linear_stripcompare_linear_strip_with_stop
ButteraugliReference::compare_strip_srgbcompare_strip_srgb_with_stop
ButteraugliReference::compare_strip_linear_imgrefcompare_strip_linear_imgref_with_stop

The non-_with_stop functions are exactly their _with_stop twin called with enough::Unstoppable, the no-op token — so there is no behavioral or perf difference between butteraugli(a, b, &p) and butteraugli_with_stop(a, b, &p, &enough::Unstoppable). The enough crate is re-exported as butteraugli::enough, so you can name Stop / Unstoppable / StopReason without adding it to your own Cargo.toml.

For a token you can actually trip from another thread (a deadline, a "newer request arrived" signal, a Ctrl-C handler), pull in almost-enough, which provides Stopper — an Arc-backed, Clone-able, Send + Sync cancellation flag. Hand a clone to the worker, keep one for yourself, and call .cancel() when you want the comparison to bail:

[dependencies]
butteraugli = "0.9.4"
enough = "0.4.4"          # only if you name the Stop trait directly
almost-enough = "0.4.4"   # the Stopper / SyncStopper cancellation tokens
use almost_enough::Stopper;
use butteraugli::{butteraugli_with_stop, ButteraugliParams, ButteraugliError};

let stop = Stopper::new();
let worker_stop = stop.clone(); // clones share one flag; any clone can cancel

let handle = std::thread::spawn(move || {
    butteraugli_with_stop(img1.as_ref(), img2.as_ref(),
        &ButteraugliParams::default(), &worker_stop)
});

// ... later, from elsewhere — e.g. a newer request landed, or a deadline hit:
stop.cancel();

match handle.join().unwrap() {
    Ok(result)                            => println!("score {:.4}", result.score),
    Err(ButteraugliError::Cancelled(why)) => eprintln!("cancelled: {why:?}"),
    Err(e)                                => eprintln!("error: {e}"),
}

Stopper uses Relaxed atomics; reach for almost_enough::SyncStopper (same shape) if you need Release/Acquire ordering to publish other writes alongside the cancel. Both satisfy &dyn enough::Stop. A pre-cancelled token (Stopper::cancelled()) makes the very first per-scale check bail before any per-pixel work runs.

Errors and result types

Every fallible entry point returns Result<ButteraugliResult, ButteraugliError>.

ButteraugliError is #[non_exhaustive] and implements both Display and std::error::Error, so it slots into ?, anyhow, thiserror, and friends. Its variants cover dimension mismatch / too-small / overflow inputs, a NonFiniteResult for NaN/Inf pixels, an InvalidParameter for out-of-range params or strip misuse, and Cancelled(enough::StopReason) from the cancellation tokens above. New variants may be added in future releases — match with a _ => … arm.

ButteraugliResult carries:

Field / methodTypeMeaning
scoref64Max-norm distance (the historical score; <1.0 good, >2.0 bad).
max_norm()f64Same value as score, named for unambiguous call sites.
pnorm_3f64libjxl 3-norm aggregation (butteraugli_main --pnorm). Always populated.
pnorm(p)f64 -> Option<f64>libjxl p-norm of the diffmap. p == 3.0 reuses pnorm_3 for free; other p returns None unless compute_diffmap was enabled.
diffmapOption<ImgVec<f32>>Per-pixel difference map; Some only when compute_diffmap was set.

The struct is also #[non_exhaustive]; construct it only via the comparison functions, and read it by field/method.

Features

  • rayon (default): multi-threaded blur and Malta passes.
  • avx512 (default): AVX-512 runtime dispatch (used only when the CPU supports it).
  • iir-blur: O(N) recursive Gaussian instead of FIR convolution; faster on non-AVX-512 hardware but not score-parity with libjxl — off by default.
  • unsafe-performance: unchecked indexing in hot loops (~6% fewer instructions; each function pre-validates the full access range).
  • internals: expose internal modules for testing/benchmarking (unstable API).

Performance

SIMD-accelerated via archmage with runtime dispatch — the binary picks the widest ISA the CPU supports at run time, so the same build is fast everywhere:

TargetCPU support
x86-64-v4AVX-512 (Skylake-X, Zen 4+)
x86-64-v3AVX2/FMA (Haswell+, Zen 1+)
x86-64-v2SSE4.2 (Nehalem+)
ARM64NEON (Apple Silicon, Cortex-A75+)
WASM SIMD128Browser / WASI runtimes with SIMD

No C dependencies. Safe Rust by default (unsafe-performance is opt-in).

Speed vs libjxl C++

A/B harness (butteraugli-bench, zenbench) comparing this crate against libjxl::ButteraugliDiffmap through an FFI shim. Both sides receive identical pre-linearized planar f32 buffers — only the butteraugli pipeline is timed, I/O and sRGB→linear are outside the measured region. AMD Ryzen 9 7950X (Zen 4), synthetic gradient pair, commit af27826:

ImageRust (rayon, 32T)Rust (1 thread)libjxl C++ (1 thread)
512×51217.8 ms23.4 ms46.3 ms
1280×72066.9 ms84.6 ms255.8 ms
1920×1080197.1 ms228.9 ms594.0 ms
3840×2160998.0 ms845.4 ms2629.2 ms

Single-threaded, this crate runs ~2–3× faster than the C++ pipeline on this hardware; rayon adds further headroom at HD and below. Full methodology, build flags, and reproduction steps are in benchmarks/README.md. Numbers were produced without -C target-cpu=native (runtime dispatch is what ships).

Accuracy

Validated against libjxl's butteraugli_main on sRGB photographs (no ICC profiles or gAMA/cHRM chunks) across multiple sizes and JPEG quality levels. The tests/reference_parity.rs suite gates every commit against a corpus of >100 reference cases; representative samples:

ImageSizeQualityC++ libjxlRustRelative diff
baby576×576Q753.08733.08730.0000%
bulb576×576Q752.31742.31740.0003%
city576×576Q753.85113.85110.0000%
guitar576×576Q756.53996.53990.0000%
photo A1024×1024Q2511.368611.36860.0000%
photo A1024×1024Q504.96634.96630.0000%
photo A1024×1024Q901.81611.81610.0007%
photo B1024×1024Q752.96282.96280.0003%
photo C1024×1024Q503.15023.15020.0000%
photo D1022×818Q505.41995.41990.0000%

All test pairs: < 0.001% relative difference vs libjxl butteraugli_main. Residual differences are FMA rounding noise from hardware fused multiply-add instructions.

ICC profiles: this crate assumes sRGB input. libjxl's butteraugli_main applies ICC profile and gAMA/cHRM transforms via its CMS before scoring. Images with non-sRGB ICC profiles (Adobe RGB, Display P3, ProPhoto RGB) will produce different scores between the two implementations. Strip ICC profiles or convert to sRGB before comparing.

API comparison with C++ libjxl

FeatureC++ butteraugliThis crate
Input formatLinear RGB floatsRGB u8 or linear RGB f32
Color spaceLinear RGB onlysRGB (auto-converted) or linear
ICC profilesCMS transforms to linear sRGBAssumes sRGB (profiles ignored)
Channel layoutPlanarInterleaved RGB via imgref (planar also supported)
Stride supportManualBuilt-in via ImgRef::new_stride()

XYB note

Butteraugli's internal "XYB" is not the same color space as JPEG XL / jpegli XYB. Key differences:

  • Matrix coefficients: different opsin absorbance weights (e.g. row 2 is [0.02, 0.02, 0.205] vs jpegli's [0.243, 0.205, 0.552]).
  • Nonlinearity: log-based Gamma function (FastLog2f), not cube root.
  • Dynamic adaptation: blurs the input, computes per-pixel sensitivity ratios, and modulates the opsin-transformed signal — jpegli XYB has no equivalent step.
  • Biases: large additive biases (~1.76, ~1.76, ~12.23) vs jpegli's small bias (~0.0038).

Always provide RGB input (sRGB u8 or linear f32); butteraugli handles the conversion internally. Pre-converted XYB cannot be used because the dynamic sensitivity adaptation requires raw linear RGB.

References

This crate ports from libjxl's lib/extras/butteraugli.cc, where butteraugli development continued after the original google/butteraugli repository was archived. Scores are validated against libjxl's butteraugli_main, not the archived standalone version. With deep thanks to Jyrki Alakuijala and the JPEG XL / libjxl authors at Google, and to Cloudinary, whose CID22 work popularized the 3-norm aggregation.

Development

cargo fmt --all -- --check
cargo clippy --lib --tests -- -D warnings
cargo test --lib
cargo test --test conformance

See CHANGELOG.md for release history and benchmarks/README.md for benchmark methodology.

AI-generated code notice

Developed with Claude (Anthropic) and validated against C++ libjxl butteraugli_main to within FMA rounding noise on real photographs.

License

BSD-3-Clause — the same license as the upstream libjxl butteraugli implementation this port is derived from. Copyright the JPEG XL Project Authors; see LICENSE for the full text.

Image tech I maintain

Codecs ¹zenjpeg · zenpng · zenwebp · zengif · zenavif · zenjxl · zenbitmaps · heic · zentiff · zenpdf · zensvg · zenjp2 · zenraw · ultrahdr
Codec internalszenjxl-decoder · jxl-encoder · zenrav1e · rav1d-safe · zenavif-parse · zenavif-serialize
Compressionzenflate · zenzop · zenzstd
Processingzenresize · zenquant · zenblend · zenfilters · zensally · zentone
Pixels & colorzenpixels · zenpixels-convert · linear-srgb · garb
Pipeline & frameworkzenpipe · zencodec · zencodecs · zenlayout · zennode · zenwasm · zentract
Metricszensim · fast-ssim2 · butteraugli · zenmetrics · resamplescope-rs
Pickers & MLzenanalyze · zenpredict · zenpicker
ProductsImageflow image engine (.NET · Node · Go) · Imageflow Server · ImageResizer (C#)

¹ pure-Rust, #![forbid(unsafe_code)] codecs, as of 2026

General Rust awesomeness

zenbench · archmage · magetypes · enough · whereat · cargo-copter

Open source · @imazen · @lilith · lib.rs/~lilith