
Fast pixel-by-pixel image comparison for R, powered by odiff.
expect_snapshot_image(), and a compare function for
shinytest2 screenshotsInstall odiffr:
install.packages("odiffr")
# Or the development version from GitHub
# install.packages("pak")
pak::pak("BenWolst/odiffr")Odiffr requires the odiff binary (>= 4.1.1). The easiest way to get it is from R, which downloads the binary for your platform to your user cache (no Node.js needed):
odiffr::install_odiff()In interactive sessions, odiffr also offers to do this the first time odiff is needed; it never downloads anything without asking. Alternatively, install odiff system-wide:
# npm (cross-platform)
npm install -g odiff-bin
# Or download binaries from https://github.com/dmtrKovalenko/odiff/releaseslibrary(odiffr)
result <- compare_images("baseline.png", "current.png", diff_output = "diff.png")
result$match
#> [1] FALSE
result$diff_percentage
#> [1] 2.45# Adjust sensitivity (0-1, lower = stricter) and ignore antialiased pixels
compare_images("img1.png", "img2.png", threshold = 0.05, antialiasing = TRUE)
# Fail immediately if dimensions differ
compare_images("img1.png", "img2.png", fail_on_layout = TRUE)
# Ignore areas with dynamic content, e.g. timestamps
compare_images("img1.png", "img2.png",
ignore_regions = list(
ignore_region(0, 0, 200, 50), # header
ignore_region(0, 500, 800, 600) # footer
)
)
# magick-image objects and plots work too
compare_images(magick::image_read("baseline.png"), "current.png")
# Low-level interface with every odiff option
odiff_run("img1.png", "img2.png", diff_lines = TRUE)When a comparison fails, 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("dashboard renders correctly", {
expect_images_match("screenshots/current.png", "screenshots/baseline.png")
})
test_that("plot matches its baseline", {
p <- ggplot2::ggplot(mtcars, ggplot2::aes(wt, mpg)) + ggplot2::geom_point()
expect_images_match(p, test_path("baselines/scatter.png"),
plot_options = plot_options(width = 6, height = 4))
})Plots (ggplot objects, functions that draw a plot, and recorded
plots) are rendered to PNG with ragg if installed. On failure, a diff
image is saved to tests/testthat/_odiffr/.
expect_snapshot_image() lets testthat manage the
baselines in tests/testthat/_snaps/, while odiff does the
comparison, so differences below the threshold don’t fail:
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()Like other file snapshots, these are skipped on CRAN.
odiff_preset() gives calibrated settings
("strict", "default",
"screenshot", "cross_platform"). On CI,
snapshot_report() writes an HTML, Markdown or JUnit report
of all changed snapshots.
Use odiff for shinytest2 screenshots to tolerate browser antialiasing noise and get a diff image when a screenshot changes:
app$expect_screenshot(compare = compare_file_odiff(preset = "screenshot"))See vignette("shinytest2", package = "odiffr"), and
vignette("web-pages", package = "odiffr") for web pages and
htmlwidgets.
# Compare two directories (matched by relative path)
results <- compare_image_dirs("baseline/", "current/", recursive = TRUE,
diff_dir = "diffs/")
summary(results)
failed_pairs(results)
# HTML report with baseline, current and diff thumbnails
batch_report(results, "diffs/report.html", images = "all", embed = TRUE)
# Or both steps in one call
compare_dirs_report("baseline/", "current/")
# Accept intended changes as the new baselines
approve_changes(results, dry_run = TRUE)
approve_changes(results)Files missing from current/ are reported as failing
"missing" rows, and a pair that can’t be compared becomes
an "error" row with the message in the error
column. 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")]
# Every PDF in two directories, e.g. outputs before/after an R upgrade
res <- compare_pdf_dirs("outputs-old/", "outputs-new/", diff_dir = "pdf-diffs")Requires the pdftools package. See
vignette("pdf-outputs", package = "odiffr").
use_odiffr_ci() adds a ready-to-use GitHub Actions
workflow to your package (.github/workflows/odiffr.yaml)
that installs odiff with install_odiff(), runs your tests,
summarises changed image snapshots with snapshot_report()
on the job summary page and uploads the new snapshots and diff images
when tests fail:
odiffr::use_odiffr_ci()Batch results can be written in formats CI systems display natively:
- 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
batch_report(results, "diffs/report.html", images = "all", embed = TRUE)
if (any(!results$match)) stop("Visual regression detected!")
shell: Rscript {0}
- name: Upload diffs
if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-diffs
path: diffs/odiff_available() # is odiff installed?
odiff_info() # path, version and source
install_odiff() # download odiff to the user cache
odiffr_update() # same, lower-level (e.g. to update between releases)
# Use a specific binary
options(odiffr.path = "/path/to/odiff")odiff is found via options(odiffr.path), then the system
PATH, then the binary downloaded by install_odiff(). For
npm installs (odiff >= 4.4), odiffr calls the native binary directly
rather than the Node.js launcher on the PATH, which makes each
comparison around 5x faster; set
options(odiffr.resolve_npm = FALSE) to disable this.
| Type | Formats |
|---|---|
| Input | PNG, JPEG, WEBP, TIFF (.tiff), BMP; PDF via
compare_pdfs() |
| Output | PNG only |
Cross-format comparison is supported (e.g. JPEG against PNG). odiff
does not accept the .tif extension.
options(odiffr.path = ...)audit_record() writes a
JSON or CSV record of comparisons, with input and output file hashes,
the odiff version and binary hash, parameters, platform and a UTC
timestampoptions(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")odiff is approximately 6x faster than ImageMagick for pixel
comparison, thanks to SIMD optimisations. On x86_64 systems with
AVX-512, pass enable_asm = TRUE to odiff_run()
for ~12% faster comparisons.
MIT