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

Real odiffr output: the diff image comes from the call in step 2 below.
|
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 testthatexpect_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. |
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()
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
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)
})
| 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() |
# 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.
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.
app$expect_screenshot(compare = compare_file_odiff(preset = "screenshot"))
odiff tolerates browser antialiasing noise and leaves a diff image when a screenshot 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.
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.
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}
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.
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.
.tiff, not .tif) and BMP in, PNG
diffs out. Different formats can be compared with each other.odiff_run(enable_asm = TRUE) makes
comparisons about 12% faster.MIT licensed ยท Built on odiff by Dmitriy Kovalenko