Skip to content

Image diff

rich_art::imagediff compares two images the way a person would: it reports how much of the picture changed perceptibly, and ranks the regions that changed. It is the library behind rich diff before.png after.png. It needs the image feature.

Use it for visual regression tests: comparing a rendered screenshot, chart or UI capture against a baseline, where a byte-for-byte comparison would fail on every anti-aliasing or re-encoding difference.

This page covers the Rust API. For the command line — the output, the drawing modes and the CI gate — see Comparing images.

The smallest example

The examples draw a pair of "screenshots" in code. The second adds a white badge:

/// A 160×100 "screenshot": a dark gradient, a disc, and optionally a badge in
/// the given colour. Adding or recolouring the badge is the change to find.
fn screenshot(badge: Option<[u8; 3]>) -> DynamicImage {
    DynamicImage::ImageRgb8(RgbImage::from_fn(160, 100, |x, y| {
        let (dx, dy) = (x as f32 - 45.0, y as f32 - 50.0);
        if dx * dx + dy * dy <= 28.0 * 28.0 {
            return Rgb([250, 205, 40]); // the disc: identical in both
        }
        if let Some(colour) = badge {
            if (100..140).contains(&x) && (30..70).contains(&y) {
                return Rgb(colour); // the badge
            }
        }
        Rgb([
            (20 + x * 60 / 159) as u8,
            (30 + y * 50 / 99) as u8,
            (90 - x * 40 / 159) as u8,
        ])
    }))
}

Compare them and print the report:

let before = screenshot(None);
let after = screenshot(Some([245, 245, 245]));
let report = diff(&before, &after, &DiffSettings::default()).expect("same size");

console.print_str(&format!(
    "{:.1}% changed perceptibly (a plain pixel diff would say {:.1}%)",
    report.changed_fraction * 100.0,
    report.naive_changed_fraction * 100.0,
));
let mut table = Table::new();
table.add_column("Region");
table.add_column_justify("Share", Justify::Right);
table.add_column_justify("Mean ΔE", Justify::Right);
for region in &report.regions {
    table.add_row(&[
        &format!(
            "{}×{} at ({}, {})",
            region.width, region.height, region.x, region.y
        ),
        &format!("{:.0}%", region.share_of_change * 100.0),
        &format!("{:.1}", region.mean_delta_e),
    ]);
}
console.print(&table);

5.0% changed perceptibly, one region at 100% of the change

A plain pixel comparison counts every pixel of the badge: 10% of the canvas. The perceptual figure is lower, 5%, because blurring softens the badge's edges below the ΔE threshold and only its core counts. Either way there is exactly one region, and it holds all of the change. On real screenshots the gap is much wider: anti-aliasing and re-encoding noise inflate the plain figure, and the blur removes them.

How it decides

  1. Blur both images (blur, default radius 6), so sub-pixel noise and re-encoding artefacts stop counting as change.
  2. Convert to CIELAB, where distance approximates perceived difference.
  3. ΔE per pixel (CIE76), and mark pixels above threshold (default 60).
  4. Morphological open (erode, then dilate) with an open_kernel×open_kernel square (default 11), dropping speckle.
  5. Label connected regions, drop those under min_region pixels (default 400), and rank the rest by area × severity. Keep the top (default 3).

Comparing images explains why each step is there.

The report

diff(&before, &after, &settings) returns a DiffReport:

Field Meaning
width, height The image size.
changed_fraction Share of pixels (0–1) above the ΔE threshold, measured before the open.
naive_changed_fraction Share a plain byte comparison would call changed (any channel off by more than 32), on the unblurred images.
mean_delta_e, max_delta_e Over the whole image.
regions Ranked Regions: x, y, width, height, area_px (after the open), share_of_change (0–1) and mean_delta_e.
delta_e Per-pixel ΔE, row-major, width * height long.

share_of_change is the region's part of all changed pixels, including regions too small to list, so the shares need not add up to 100%. mean_delta_e says how strong a change is, independent of its size.

Pictures

Two helpers turn a report into an image you can draw with ImageArt or save with the image crate:

let (before, after) = (screenshot(None), screenshot(Some([245, 245, 245])));
let report = diff(&before, &after, &DiffSettings::default()).expect("same size");
let view = |image: DynamicImage| ImageArt::new(image).mode(ImageMode::Blocks).width(30);

let mut grid = Table::grid().padding(0, 2, 0, 0);
for _ in 0..3 {
    grid.add_column("");
    grid.column_width(30);
}
grid.add_row_cells(
    ["after", "report.heatmap()", "report.highlight(&after)"]
        .into_iter()
        .map(|label| Cell::from(Text::styled(label, "bold")))
        .collect(),
);
grid.add_row_cells(vec![
    Cell::Renderable(Arc::new(view(after.clone()))),
    Cell::Renderable(Arc::new(view(report.heatmap()))),
    Cell::Renderable(Arc::new(view(report.highlight(&after)))),
]);
console.print(&grid);

The after image, the ΔE heatmap, and the highlighted region

  • heatmap() maps ΔE from dark blue through magenta to yellow. The scale is normalised to this image's range, so a small intense change is still visible.
  • highlight(&after) dims the after image except inside the reported regions: the "where" at a glance.

Gates and tuning

A gate compares changed_fraction with a limit:

// A CI gate: fail when more than 2% of the canvas changed perceptibly.
let before = screenshot(None);
let after = screenshot(Some([245, 245, 245]));
let report = diff(&before, &after, &DiffSettings::default()).expect("same size");
let limit = 2.0;
let changed = report.changed_fraction * 100.0;
if changed > limit {
    console.print_str(&format!(
        "[red]FAIL[/] {changed:.1}% changed, limit {limit:.1}%"
    ));
} else {
    console.print_str(&format!("[green]OK[/] {changed:.1}% changed"));
}

The defaults were tuned on regenerated artwork, which is noisy. Screenshots are much cleaner, so a lower threshold and a smaller min_region catch subtler changes:

// Screenshots are far cleaner than regenerated artwork, so a lower ΔE
// threshold and a smaller minimum region suit them.
let strict = DiffSettings {
    threshold: 10.0,
    min_region: 50,
    top: 5,
    ..DiffSettings::default()
};
// A subtle recolour: light grey to white.
let (grey, white) = (
    screenshot(Some([200, 200, 200])),
    screenshot(Some([245, 245, 245])),
);
for (label, settings) in [("default", DiffSettings::default()), ("strict", strict)] {
    let report = diff(&grey, &white, &settings).expect("same size");
    console.print_str(&format!(
        "{label:>7}: {:.1}% changed, {} region(s)",
        report.changed_fraction * 100.0,
        report.regions.len()
    ));
}

Images of different sizes are an error rather than a meaningless result:

let small = DynamicImage::ImageRgb8(RgbImage::new(80, 50));
match diff(&screenshot(None), &small, &DiffSettings::default()) {
    Err(DiffError::SizeMismatch { before, after }) => {
        console.print_str(&format!("cannot compare {before:?} with {after:?}"))
    }
    Ok(_) => unreachable!("sizes differ"),
}

A failing gate, default versus strict settings, and a size mismatch

Gotchas

  • Align and crop both images to the same size first; diff will not resize.
  • changed_fraction and regions measure different things (before and after the open), so a small change can have a non-zero fraction and no regions.
  • The CLI rounds both percentages to one decimal before applying --threshold. Round the same way if your gate must agree with it.

See also

  • Comparing images — rich diff for images, and why a perceptual diff
  • Images — drawing the heatmap and highlight
  • The diff_report example prints a report as JSON: cargo run -p rs-rich-art --features image --example diff_report -- before.png after.png