tinyimg: An R Package for Compressing Images

Yihui Xie 2026-03-29

Last month, @bastistician opened an issue on the litedown repo pointing out that knitr has a hook_pngquant() function for compressing PNG plots from code chunks, but litedown lacks such a feature. He included a reasonable workaround—calling system2("pngquant", ...) with litedown::get_context("plot_files") in a chunk at the end of the vignette. It shrank his vignette from 80 KB to 54 KB, which is a 33% reduction. Not bad.

The catch, of course, is that it requires pngquant to be installed on the system. For R users, installing a system binary is more friction than it sounds: it is brew install pngquant on macOS, a separate package manager invocation on Linux, and hunting down a standalone executable on Windows. If you maintain a package that others will build, you are now asking all of them to do this—for every machine they use. By contrast, install.packages("tinyimg") works the same way everywhere, which is the kind of simplicity that makes a tool actually get used.

This is why I created tinyimg.

What it does

tinyimg compresses PNG files using two Rust crates bundled inside the package itself—no external tools needed:

The main function is tinypng(). At its simplest:

library(tinyimg)

tmp = tempfile(fileext = ".png")
png(tmp, width = 800, height = 600)
plot(1:100, main = "A perfectly ordinary plot")
dev.off()

tinypng(tmp)  # optimize in-place

You will see output like:

/tmp/Rtmpxyz/file123.png -> /tmp/Rtmpxyz/file123.png | 52.8 KB -> 39.5 KB (-25.2%)

That is the lossless path at the default optimization level of 2. If you want to go harder, you can set level anywhere from 0 (fast, minimal gain) to 6 (slow, maximum compression). Level 2 usually captures most of the low-hanging fruit, whereas level 6 typically squeezes out only a few more percentage points while taking 10–15x longer.1

Lossy compression

If lossless is not enough, you can enable lossy palette reduction via the lossy argument. The value is a color-difference threshold in CIELAB space—specifically $\Delta E_{76}$. A threshold of 2.3 is the traditional “just noticeable difference” (JND), meaning the color error introduced by the quantization is theoretically imperceptible to the human eye:

tinypng(tmp, paste0(tmp, "-lossy.png"), lossy = 2.3)

In practice, for the kind of statistical graphics R produces—flat backgrounds, solid colored points, clean lines—a lossy = 2.3 compression is nearly indistinguishable from the original while cutting file sizes by 50–70%. Aggressive settings like lossy = 32 can push reduction past 80%, but at that point you may start to see banding in smooth gradients.2

To be honest, I am not sure 2.3 is a practically reasonable general-purpose threshold. From my own experiments with the benchmark plots, it feels rather conservative—the compressed images at somewhat higher values still look perfectly fine to me. Users will likely need to experiment on their own images to find the sweet spot. If you do systematic experiments and arrive at better guidance on what thresholds work well for typical R graphics, I would love to hear about your findings.

Using it with litedown

To answer the original issue: if you use litedown and want to compress all the plots in your vignette, just add a chunk like this at the end (assuming you use the PNG format for all plots):

if (requireNamespace("tinyimg"))
  tinyimg::tinypng(litedown::get_context("plot_files"), lossy = 2.3)

One line. No system2(), no pngquant on the PATH, no CRAN environment concerns. The lossy argument is optional—leave it out if you only want lossless compression.

For knitr users, the situation is similar. You can pass the output directory to tinypng() directly:

# if the chunk option fig.path is a dir used for all plots
tinypng(knitr::opts_chunk$get("fig.path"))

# or perhaps you can optimize the current working directory
tinypng(".")

It will recursively find and compress every PNG in the directory.

Installation

Since tinyimg uses Rust internally, building from source requires Cargo. The easiest route is to install a precompiled binary from CRAN or r-universe, which handles the Rust compilation on its end:

# CRAN version
install.packages("tinyimg", repos = "https://cloud.r-project.org")

# dev version
install.packages("tinyimg", repos = "https://yihui.r-universe.dev")

I have had a long-standing interest in Rust and the interface between Rust and R, but for years it stayed on the “someday” list. This GitHub issue gave me a good excuse to finally try it out, so tinyimg is my first real experiment with the combination. I am happy with how it turned out. The goal is not just PNG—JPEG support is on the roadmap, and the name “tinyimg” rather than “tinypng” was deliberate.


  1. The benchmark page at https://pkg.yihui.org/tinyimg/examples/benchmark-png.html has the full numbers. For a complex 3D perspective plot, level 2 reduces file size by 34.7% while level 6 reaches 35.2%. The extra 0.5% is not worth tripling the runtime, in my opinion. ↩︎

  2. For a scatterplot with the penguins dataset, lossy = 64 brings a 52.8 KB file down to 6.2 KB—an 88% reduction. Whether the result still looks acceptable at that level is very much “it depends.” Try it and see. ↩︎