odiffr logo: a pixel-art camel walking a desert dune at night
odiffr

Fast pixel-by-pixel image comparison for R.
Catch visual regressions in plots, Shiny apps, screenshots and PDFs, powered by odiff.

Get started Documentation Function reference Changelog


A chart, a changed version of the chart, and the diff image odiffr makes from them, with the changed line drawn in red

Real odiffr output: the diff image comes from the call in step 2 below.

Why odiffr

Fast
odiff is written in Zig with SIMD, about 6x faster than ImageMagick in its benchmarks.
Takes what you have
PNG, JPEG, WEBP, TIFF and BMP files, magick images, ggplot2, base and grid plots, and PDF pages.
Fits testthat
expect_images_match(), expect_snapshot_image() and a comparison function for shinytest2 screenshots.
Ignores the noise
Thresholds, antialiasing detection, ignore regions and an overall tolerance, with presets for screenshots and cross-platform runs.
Made for CI
HTML, Markdown and JUnit reports, a ready-made GitHub Actions workflow, and approve_changes() to accept new baselines.
Auditable
Pin a validated binary and write JSON or CSV audit records with file hashes. Core functions depend only on base R.

Get started in three steps

  1. Install odiffr and the odiff binary. install_odiff() downloads odiff (>= 4.1.1) for your platform, with no Node.js needed.

    install.packages("odiffr")
    # or the development version: # pak::pak("BenWolst/odiffr")
    install.packages("odiffr", repos = c('https://benwolst.r-universe.dev', 'https://cloud.r-project.org'))
    odiffr::install_odiff()
    
  2. Compare two images. You get the result as a data frame and the changes drawn in red in diff.png.

    library(odiffr)
    result <- compare_images("baseline.png", "current.png", diff_output = "diff.png",
                             antialiasing = TRUE, diff_overlay = 0.75)
    result$diff_count
    #> [1] 3537
    
  3. Make it a test. testthat stores the first image as the baseline and fails when a later run differs.

    test_that("the usage chart is unchanged", {
      # usage_chart: a ggplot, a recorded plot or a function that draws one
      expect_snapshot_image(usage_chart)
    })
    

What do you want to do?

Task Start with
Compare two images, plots or magick images compare_images()
Test plots with testthat expect_snapshot_image(), expect_images_match()
Test a Shiny app compare_file_odiff() and the shinytest2 guide
Test web pages, reports and htmlwidgets The web pages guide
Compare PDF reports page by page compare_pdfs() and the PDF guide
Compare folders and accept changes compare_image_dirs(), approve_changes()
Report results in CI use_odiffr_ci(), batch_report()
Keep an audit trail audit_record()

Examples

Comparison options: sensitivity, antialiasing, ignore regions, tolerance
# Sensitivity (0-1, lower is stricter) and antialiasing detection
compare_images("before.png", "after.png", threshold = 0.05, antialiasing = TRUE)

# Fail straight away when the dimensions differ
compare_images("before.png", "after.png", fail_on_layout = TRUE)

# Skip areas with dynamic content, such as a timestamp
compare_images("before.png", "after.png",
  ignore_regions = list(ignore_region(0, 0, 200, 50))
)

# Pass when up to 1% of pixels differ (development version)
compare_images("before.png", "after.png", max_diff_percent = 1)$reason
#> [1] "within-tolerance"

plot(odiff_run(...)) shows the baseline, current and diff images side by side, and diff_image() returns the diff as a magick image or raster.

testthat expectations and snapshots
test_that("the dashboard renders correctly", {
  expect_images_match("screenshots/current.png", "screenshots/baseline.png")
})

test_that("dark mode changes the page", {
  expect_images_differ("screenshots/dark.png", "screenshots/light.png")
})

test_that("plots are stable", {
  p <- ggplot2::ggplot(mtcars, ggplot2::aes(wt, mpg)) + ggplot2::geom_point()
  expect_snapshot_image(p)
  expect_snapshot_image(function() hist(mtcars$mpg), name = "mpg-hist",
                        preset = "screenshot")
})

# After an intended change
testthat::snapshot_review()
testthat::snapshot_accept()

Plots are rendered with ragg when it is installed, and a failing expect_images_match() saves a diff image to tests/testthat/_odiffr/. odiff_preset() has calibrated settings: "strict", "default", "screenshot" and "cross_platform". Image snapshots are skipped on CRAN, like other file snapshots. On CI, snapshot_report() writes an HTML, Markdown or JUnit report of the snapshots that changed.

Shiny apps with shinytest2
app$expect_screenshot(compare = compare_file_odiff(preset = "screenshot"))

odiff tolerates browser antialiasing noise and leaves a diff image when a screenshot changes.

Folders, reports and approving changes
results <- compare_image_dirs("baseline/", "current/", recursive = TRUE,
                              diff_dir = "diffs/")
summary(results)
failed_pairs(results)

batch_report(results, "diffs/report.html", images = "all", embed = TRUE)

approve_changes(results, dry_run = TRUE)
approve_changes(results)

# Or compare and write the report in one call
compare_dirs_report("baseline/", "current/")

Files missing from current/ fail as "missing", and pairs that can't be compared become "error" rows instead of stopping the run. compare_images_batch() compares an explicit list of pairs, optionally in parallel.

PDF reports
res <- compare_pdfs("before/report.pdf", "after/report.pdf", dpi = 150,
                    diff_dir = "pdf-diffs")
failed_pairs(res)[, c("page", "reason", "diff_percentage")]

compare_pdf_dirs("outputs-old/", "outputs-new/", diff_dir = "pdf-diffs")

Needs the pdftools package.

GitHub Actions and other CI
odiffr::use_odiffr_ci()

This adds .github/workflows/odiffr.yaml, which installs odiff, runs your tests, summarises changed snapshots on the job page and uploads diff images when tests fail. For your own workflows:

- name: Compare images
  run: |
    library(odiffr)
    results <- compare_image_dirs("baseline/", "current/", diff_dir = "diffs/")
    batch_markdown(results)                    # GitHub job summary
    batch_junit(results, "odiffr-junit.xml")   # test report
    if (any(!results$match)) stop("Visual regression detected")
  shell: Rscript {0}
Validated environments
options(odiffr.path = "/validated/bin/odiff-4.5.0")

result <- odiff_run("baseline.png", "current.png", "diff.png", threshold = 0.05)
audit_record(result, file = "audit.json")

Audit records hold input and output file hashes, the odiff version and binary hash, parameters, platform and a UTC timestamp. Core functions depend only on base R.

Managing the odiff binary
odiff_available()   # is odiff installed?
odiff_info()        # path, version and source
install_odiff()     # download odiff to the user cache

In interactive sessions odiffr offers to install odiff the first time it is needed, and never downloads anything without asking. It looks for odiff in options(odiffr.path), then on the PATH, then in the cache that install_odiff() uses. You can also install it with npm install -g odiff-bin. For npm installs of odiff >= 4.4, odiffr calls the native binary directly rather than the Node.js launcher, which makes each comparison around 5x faster.

Good to know

Related


MIT licensed ยท Built on odiff by Dmitriy Kovalenko