Image comparison for visual regression testing, written in Rust and distributed through npm. It decodes two PNGs and reports how many pixels differ perceptually, using the same YIQ color metric and anti-aliasing detector as pixelmatch, so an existing threshold keeps its meaning. Beyond a pixel count it groups the differing pixels into clusters and estimates how far each one moved, which separates a shifted element from a change spread across the screen.
pixeldelta ships three things.
- A Node library.
import { compare } from 'pixeldelta'— two paths or two buffers to a verdict, a diff pixel count, and clusters. Each platform gets a prebuilt binary; a host that no prebuild matches runs the same engine as WebAssembly, in Node or in a browser. The API and the WebAssembly differences are in packages/pixeldelta/README.md. - A command line.
pixeldelta comparefor two images andpixeldelta runfor two directories, with HTML, JSON and JUnit output. The exit code carries the verdict. - A CI workflow.
pixeldelta cifinds the baseline commit from the git history, fetches its snapshot from object storage, compares, publishes the report, and writes the result to a pull request comment or a job summary.
Input is PNG. Other formats are not decoded.
npm install pixeldeltaThe prebuilds cover macOS and Linux on x64 and arm64, Linux on musl at x64, and
Windows on x64. The command comes with them. On a host that none of them match,
the library still runs through npm install pixeldelta pixeldelta-wasm; the
command does not, because it runs git and opens network connections, and WASI
has no sockets.
Measured on an Apple M1 with 8 cores, macOS on arm64, rustc 1.97.1, release
build, at threshold 0.1 against the fixtures in
crates/pixeldelta-core/benches/fixtures. Numbers from another machine are not
comparable with these.
Engine only — decoded RGBA buffers to a diff pixel count, median of 50 runs, in ms. pixeldelta and pixelmatch return the same count on every row.
| size | anti-aliasing | pixeldelta | pixelmatch |
|---|---|---|---|
| 2 Mpixel | off | 0.518 | 9.429 |
| 2 Mpixel | on | 1.833 | 17.435 |
| 8 Mpixel | off | 1.355 | 26.474 |
| 8 Mpixel | on | 8.865 | 72.557 |
| 18 Mpixel | off | 3.035 | 59.278 |
| 18 Mpixel | on | 14.425 | 161.955 |
End to end — two PNG files to a verdict, decode included, no diff image, as the wall-clock time of the process, median of 10 runs, in ms. Decode dominates at these sizes, so this measures the decoder as much as the engine. The startup floor on a one-pixel pair is 2.1 ms for pixeldelta, 36.3 ms for pixelmatch and 2.5 ms for odiff; subtract it to read the comparison itself.
| size | anti-aliasing | pixeldelta | pixelmatch | odiff |
|---|---|---|---|---|
| 2 Mpixel | off | 27.5 | 151.3 | 30.4 |
| 2 Mpixel | on | 29.1 | 177.1 | 41.3 |
| 8 Mpixel | off | 105.8 | 471.9 | 117.5 |
| 8 Mpixel | on | 108.1 | 506.1 | 151.6 |
| 18 Mpixel | off | 222.7 | 939.7 | 247.4 |
| 18 Mpixel | on | 236.7 | 1059.5 | 337.3 |
odiff matches the same counts with detection off and differs by under half a percent with it on, from its own anti-aliasing detector.
Every recorded run, with its machine and toolchain, is in
BENCHMARKS.md; the newest entry is the source of the tables
above. To take the measurement yourself, see
tools/bench/README.md for the comparison against
pixelmatch and odiff, and crates/pixeldelta-core/benches for the engine on its
own.
pixeldelta compare base.png head.png --output diff.pngdiffer: 190034 pixels (9.1644%)
--threshold T sets the color delta a pixel must exceed to count (default
0.1), --no-antialiasing counts anti-aliasing differences instead of
excluding them, and --ignore-region X,Y,W,H leaves a rectangle out of the
comparison and its ratio. No diff image is drawn or encoded unless --output
asks for one.
pixeldelta run ./expected ./actual --report ./report --json result.jsonfail: 1 changed, 0 added, 0 removed, 0 size mismatch, 0 tolerated, 1 matched
Both trees are walked recursively and their .png files are paired by relative
path. Each pair becomes one of six categories: matched, tolerated, changed,
sizeMismatch, added, removed.
--report DIR writes DIR/index.html with the images it references under
DIR/images/, so the directory opens as it is once carried elsewhere. --json
and --junit write a file each. Nothing is written unless asked for.
--tolerance-ratio R moves an entry whose differing pixels are at most R of
the image into tolerated, which keeps it out of the verdict while leaving its
diff pixel count, clusters and diff image in the report. The default is 0, so
a single differing pixel is a change. --max-clusters N bounds how many
clusters one entry lists, largest first (default 100, 0 for all); the rest are
reported as a count.
A CI step fails on a difference without reading the output.
| Code | compare |
run and ci |
|---|---|---|
0 |
the images match | every entry matched or was tolerated |
1 |
the images differ | an entry changed, was added, removed, or changed size |
2 |
the sizes differ | not used |
3 |
a runtime error | a runtime error |
pixeldelta ci ./actual --storage s3://bucket/prefix --report ./reportci runs the same comparison as run, against a baseline it works out itself:
git merge-base HEAD <base>gives the merge base.<base>comes from--base-branchand defaults tomain; on a runner, pass a name that resolves there, such asorigin/main.- It walks back from there, up to
--history-limitcommits (default 50), and takes the newest commit that has a snapshot stored. - It fetches that snapshot, compares
ACTUALagainst it, writes whichever reports were asked for, and storesACTUALunder the current commit's SHA.
With no baseline it stores the snapshot and exits 0; nothing to compare
against is not a regression, so the first run of a new setup is not a failure.
--storage selects where snapshots live: a string without a scheme is a local
directory, and s3://bucket/prefix goes to the S3 API, which also covers R2 and
MinIO. Credentials come from the environment — AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN when the credentials are temporary,
AWS_REGION (default us-east-1), and AWS_ENDPOINT_URL to point at a service
other than AWS.
--markdown FILE appends the result as Markdown, and --comment posts the same
body as a pull request comment, replacing the one the previous run left rather
than adding to the thread. Both carry the category counts, the comparison
conditions, a link to the report when its URL is known, and a per-entry list of
what changed, including how the clusters split between moved and altered and the
lowest SSIM. Commenting reads GITHUB_TOKEN, GITHUB_REPOSITORY and
GITHUB_API_URL, and takes the pull request number from the workflow event
unless --pr gives it. A token from a forked pull request cannot comment; that
prints a warning and does not fail the run.
The report URL comes from the storage when it can serve one. When the workflow
publishes the report somewhere else, --report-url supplies the address that
goes into the comment.
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # the baseline search walks the history
- run: npm ci
- run: npm run screenshots # writes ./actual
- run: >
npx pixeldelta ci ./actual
--storage s3://my-bucket/pixeldelta
--base-branch origin/main
--report ./report
--markdown "$GITHUB_STEP_SUMMARY"
--comment
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: us-east-1
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}What replaces a reg-suit setup is the ci subcommand. Its three plugin
boundaries — the baseline key, the storage, the notification — are all covered
by that one command.
reg-keygen-git-hash-plugin→ the baseline search above, keyed by commit SHA.reg-publish-s3-plugin→--storage s3://bucket/prefix, withAWS_ENDPOINT_URLfor R2 and MinIO.reg-notify-github-plugin→--commentand--markdown, on aGITHUB_TOKENwithpull-requests: write. There is no GitHub App to install.regconfig.jsonthresholds → the flags above andpixeldelta.config.json.
Snapshots go to S3 compatible storage or to a local directory, and results are reported to GitHub. No other storage or forge is supported.
run and ci read a config file when the same threshold or the same excluded
region does not suit every screenshot. --ignore-region on the command line
applies to every image; the file is what changes a setting for some paths and
not others. compare does not read it, since a path pattern means nothing for a
single pair.
{
"threshold": 0.1,
"toleranceRatio": 0,
"ignoreRegions": [{ "x": 0, "y": 0, "width": 1280, "height": 64 }],
"overrides": [
{
"paths": ["dashboard/**", "**/clock-*.png"],
"toleranceRatio": 0.001,
"ignoreRegions": [{ "x": 980, "y": 120, "width": 220, "height": 48 }]
}
]
}Those four keys are all the file accepts, and an unknown key is an error rather
than a setting that silently does nothing. --config PATH names the file;
without it, pixeldelta.config.json in the working directory is read when it is
there. A path given with --config that does not exist is an error. Parent
directories are not searched.
Patterns. paths is matched against the relative path used to pair the two
directories, with separators normalized to / so one pattern works on Windows
too. The syntax is three constructs, and every other character is a literal:
*: zero or more characters, not crossing a/.**: zero or more whole path segments, when written as an entire segment (dashboard/**,a/**/b.png). Written inside a segment, as ina**b, it behaves as*.?: one character other than/.
There is no {a,b} or [a-z].
Precedence. threshold and toleranceRatio are overwritten in the order:
default, top level of the file, command-line flag, matching overrides entry.
The narrowest scope wins, so an override beats a flag; among several matching
overrides, the last one written wins. ignoreRegions is unioned instead — the
flag, the top level and every matching override are all excluded — so a status
bar common to every screen and a region specific to one screen can be given
together.
The JSON report carries no schema version field. Its compatibility is the version of pixeldelta itself:
- Adding a field is a compatible change and ships in a minor release. A reader is expected to ignore fields it does not know.
- Removing a field, changing its type, or changing what an existing name means happens only in a major release.
MIT.