FIMGDUPES(1) User Commands FIMGDUPES(1)

fimgdupes - find visually similar images using perceptual hashing

fimgdupes [OPTIONS] PATH...
fimgdupes --exact [OPTIONS] PATH...
fimgdupes --query FILE [OPTIONS] PATH...

fimgdupes scans directories (or individual image files) and groups visually similar images together using perceptual hashing. It compares images by their visual content, not filenames or metadata, so it can detect duplicates even across different resolutions, crops, quality levels, and file formats.

Matching is two-stage. Each image is reduced to a perceptual hash and to a small grayscale thumbnail (EXIF orientation is applied first, so a photo stored rotated with an orientation tag matches its upright duplicate). The hash is only a fast gate that decides which pairs are worth scoring; the similarity percentage shown and compared against --threshold is the normalized correlation of the two thumbnails. That score is continuous and independent of brightness and contrast: the same picture resized, recompressed, converted or brightened scores 99–100, small crops and rotations the low-to-mid 90s, and unrelated photos well under 85. Every match in a group is directly similar to its group head — there is no transitive chaining (see GROUPING below).

Two further modes reuse the same filters, output and actions. --exact reports only byte-identical files (same SHA-256) and never decodes an image, so it is much faster on a first run; think fdupes(1) restricted to images. --query asks "is this picture already in here?": the named file is compared against the scanned paths, and only its matches are printed. Byte-identical copies are marked (identical file) in every mode, because a visual 100% can still be a different resolution or format while an identical file is safe to drop unseen.

Each group has a head (the "best" file by the sort criterion) and one or more matches. Actions like --move-matches, --delete-matches, and --trash-matches operate on the matches, leaving heads untouched.

Files the filters leave out (below --min-size, above --max-size, outside the dimension limits, image-like extensions not in --extensions, and directories pruned by the skip list or hidden-directory rule) are counted and reported on standard error as a "Not scanned:" line, so a duplicate that was never examined is not mistaken for one the hash failed to match. --verbose also lists the skipped directories. The comparison step reports the thumbnail size and hash gate in use.

Hashes are cached to disk so repeat scans are fast. Byte-identical files are detected up front and share a single decoded hash, so exact copies are grouped at 100% without redundant decoding.

Following grep(1) convention:

0
Duplicates found.
1
No duplicates found.
2
Error occurred.

Options can also be set in a config file (see CONFIG FILE). CLI flags always override config file values. For boolean options set in the config, use the corresponding --no- flag to override them back to off.

Show size, pixel dimensions, and modification date for each file.
Disable --long (overrides config).
Criterion for selecting the group head and ordering matches. Default: size.
Largest file is head (default).
Newest file (by modification time) is head.
Alphabetically first path is head.
Discovery order; fastest but non-deterministic.
Force-enable ANSI color output.
Force-disable ANSI color output. By default, color is auto-detected based on whether stdout is a terminal.
Show extra detail: config file source, cache location and hit/miss statistics, hash algorithm and grid size in use.
Suppress all informational messages and progress bars. Only results, warnings, and errors are shown.

Recurse into subdirectories. Off by default.
Disable --recursive (overrides config).
Skip files smaller than SIZE. Default: 50K. See SIZE SUFFIXES below.
Skip files larger than SIZE.
Disable the default 50K minimum size filter entirely.
Skip images whose width < W or height < H. See DIMENSION FORMAT below.
Skip images whose width > W or height > H.
Comma-separated list of file extensions to scan. Case-insensitive. Default: jpg,jpeg,png,gif,webp,tiff,tif,bmp,avif. HEIC/HEIF are not included by default as they require external decoder support.
Re-include a directory name from the default skip list. May be specified multiple times.
Disable the built-in skip list entirely.
Include hidden directories (those beginning with a dot).
Disable --hidden (overrides config).
Treat additional hard links to an already-listed file as separate files. By default a second path to the same inode is skipped (and counted in the "Not scanned:" line), as fdupes(1) does without -H: removing one hard link frees nothing, and it stops the copies made by --link-matches from being reported again on every later run.

Minimum similarity percentage to consider two images a match. Integer 0–100. Default: 95.

Similarity is the zero-mean normalized cross-correlation of the two images' scoring thumbnails (see --hash-size), mapped to 0–100. Measured on real photographs at the default size:

99–100
The same picture: resized (down to 100 px), recompressed (JPEG q25), converted (PNG, grayscale), brightened, gamma-adjusted, blurred, shifted by a few pixels.
93–97
Light edits: a 0.5° rotation, a 4% border crop.
Unrelated photographs (the closest pair in the test set scored 77).

In short: 99 finds only copies of the same picture; the default 95 also catches small rotations and some light edits; crops and heavier edits need --hash-size 8 or a threshold of 90. 100 requires byte-identical thumbnails, which resized copies rarely are, so use 99 for "same picture". See CHOOSING A THRESHOLD below for recipes. Percentages from other tools (image viewers with duplicate search) are on different scales and not comparable.

Perceptual hash used as the candidate gate before thumbnail scoring. Default: phash. You rarely need to change this: it affects speed, not which pairs match. Pairs whose hashes differ by more than the gate are never scored; the gate widens as the threshold is lowered (12.5% of the bits at 99%, 20% at 95%, 40% at 90%, capped at 50%), and on real photos copies of the same picture differ by at most about 6%, so the gate is a speed device, not a precision one.
Discrete Cosine Transform (DCT) based. Best general-purpose accuracy. Works well for detecting duplicates across different resolutions, quality levels, and minor edits. This is the recommended default.
Gradient difference. Compares brightness changes between adjacent pixels. Fast and effective for near-exact duplicates. Less robust than phash for images with significant cropping or color changes.
Average brightness. The simplest and fastest algorithm. Each bit indicates whether a pixel is above or below the image's mean brightness. Most prone to false positives but useful when speed matters more than precision.
--hash-size N
Hash grid size: 8, 16, or 32. Default: 16.

Images are reduced to an N×N grid for the hash and to a 2N×2N grayscale thumbnail for scoring (16×16, 32×32 or 64×64 pixels). Larger sizes compare finer visual detail — and fine detail is exactly what differs between near-duplicates, so the same pair of images scores lower at a larger size. Copies of the same picture score 99–100 at every size; what changes is how far below that edits fall (worst case over the test set, original versus variant):

variant size 8 size 16 size 32
0.5° rotation 98.8 96.4 90.1
4% border crop 98.5 93.6 84.9
closest unrelated photo 83.4 77.1 72.1
8
Loosest. A 16×16 thumbnail; small crops and rotations still score above 98, so use it with a threshold of 95–99 to sweep up edited copies as well.
16
The default. Cleanly separates the same picture (99.9+) from light edits (93–97) and unrelated images (under 80).
32
Strictest. Only copies of the same picture stay above 95; fine detail such as text in a screenshot or scan starts to count. Larger caches (about 8 KB per image).

See CHOOSING A THRESHOLD for which size and threshold to combine.

Report only byte-identical files, grouped by SHA-256 of their contents. No image is decoded, and only files that share a size with another scanned file (or with a --query file) are read at all; checksums are stored in the cache, so a repeat run only stats files. --threshold, --hash and --hash-size are ignored. Every match is an identical file.
Look FILE up in the scanned paths instead of comparing them with each other. One group is printed per query that has matches: the query is the head, the files it matches are listed below it with their scores, exact copies marked (identical file). FILE need not be inside the scanned paths and is exempt from the size, dimension and extension filters; if it is inside them it does not match itself. May be repeated. With --exact only byte-identical copies are reported and only files of the same size as a query are read. Exit status is 0 if any query was found and 1 otherwise, so a script can test for it. Actions are not allowed with --query.

At most one action flag may be given. Without an action flag, fimgdupes prints results and exits without modifying any files.

Print what would happen without executing any action.
Move all non-head files into DIR. The directory is created if it does not exist. Filename collisions are resolved with _1, _2, etc. suffixes.
Move group heads into ./a/ and their matches into ./b/. Useful for side-by-side review with tools like feh(1).
Permanently delete all non-head files. Irreversible. Requires --yes.
Move all non-head files to the system trash (XDG freedesktop spec). Recoverable via file manager. Requires --yes.
Replace non-head files with hardlinks to the group head, saving disk space while preserving the apparent file structure. Requires --yes.
Run a command for each group or each match. The command template supports three placeholders:
{head}
Replaced with the group head path.
{match}
Replaced with a single match path. The command runs once per match in each group.
{matches}
Replaced with all match paths in the group. The command runs once per group.

Using both {match} and {matches} in the same template is an error.

Paths are passed as separate process arguments, not through a shell. This prevents filename injection attacks — filenames containing shell metacharacters (semicolons, backticks, etc.) are treated as opaque strings. Requires --yes.

Examples:

--exec "feh {head} {matches}"     # view group in feh
--exec "diff {head} {match}"      # diff each match against head
--exec "cp {match} /backup/"      # copy each match somewhere

Path to the hash cache file. Overrides both the default location and --cache-local.
Disable reading from and writing to the hash cache.
--cache-local
Store the cache file in the current working directory instead of ~/.cache/. The file is named fimgdupes.bin or fimgdupes.json depending on --cache-format.
--cache-format FORMAT
Cache file format. Default: bin.
Binary format (bincode). Fast and compact. Default.
Human-readable JSON. Useful for debugging or interoperability.

Confirm destructive actions without an interactive prompt. Required by --delete-matches, --trash-matches, and --link-matches.
Print group head paths separated by null bytes instead of the tree display. For use with xargs -0 or other null-aware tools. Cannot be combined with --fdupes.
Print groups the way fdupes(1) does: one path per line, groups separated by a blank line, no similarity scores, tree characters, or --long details. The head is the first line of each group. Unlike the default display, this listing uses the same groups that actions use, so every file appears at most once and a head is never printed as a non-first line; the output is safe to feed to rm, xargs, or an fdupes-style review script. Informational messages still go to standard error.
With --fdupes, leave out the first file (the head) of every group, so the output is exactly the set of files a cleanup would remove. Same as fdupes' --omitfirst, which is accepted as an alias.
Ignore the config file for this invocation. All options use their built-in defaults unless overridden on the command line.

fimgdupes reads default option values from a TOML config file at:

~/.config/fimgdupes/config.toml

The XDG_CONFIG_HOME environment variable is respected. All fields are optional; only set what you want to override from the built-in defaults. CLI flags always take precedence over config values.

If the config file contains syntax errors, unknown keys (including typos), or out-of-range values, fimgdupes will print a specific error message and exit without running. This prevents accidental data loss from running with unexpected defaults. Use --ignore-config to bypass a broken config file.

Example config:

recursive = true
long = true
threshold = 95
hash = "phash"
hash-size = 8
min-size = "100K"
extensions = "jpg,jpeg,png,gif,webp,tiff,tif,bmp,avif"
sort = "size"
cache-format = "bin"
cache-local = false
no-cache = false

Available config keys:

Key Type Built-in default
recursive bool false
long bool false
hidden bool false
color bool false (auto-detect)
no-color bool false
threshold integer 95
hash string "phash"
hash-size integer 16
min-size string "50K"
max-size string (none)
min-dimensions string (none)
max-dimensions string (none)
extensions string "jpg,jpeg,..."
sort string "size"
no-cache bool false
cache-local bool false
cache-format string "bin"
no-min-size bool false
verbose bool false
quiet bool false

To override a boolean config value for a single invocation, use the corresponding --no- flag (e.g. --no-recursive overrides recursive = true). To ignore the entire config file, use --ignore-config.

Used by --min-size and --max-size. Suffixes are case-insensitive. All sizes use powers of 1024 (binary prefixes). A bare number without a suffix is an error.

B bytes
K, KB kibibytes (1024 B)
M, MB mebibytes (1024 KB)
G, GB gibibytes (1024 MB)

Decimal values are accepted (e.g. 1.5M).

Used by --min-dimensions and --max-dimensions. Format is WxH where W and H are non-negative integers representing pixel counts. The separator "x" is case-insensitive. An optional "px" suffix is accepted and ignored.

Either W or H (but not both) may be omitted to leave that axis unconstrained:

2560x1440 both axes constrained
2560x width only
x1440 height only

When --recursive is used, the following directory names are skipped by default. Use --include-dir to re-include specific entries, or --no-skip-dirs to disable the list entirely.

thumbs, thumbnails, thumb, .thumbnails,
.cache, cache, .trash, Trash,
tmp, temp, .tmp, @eaDir, __MACOSX

Hidden directories (names beginning with ".") are also skipped unless --hidden is given.

Directories named explicitly on the command line are always scanned, even if their own name is hidden or on the skip list; the rules above apply only to directories discovered during traversal.

The similarity percentage is printed next to every match, so the practical method is: run once with the defaults, look at the scores of the pairs you do and do not consider duplicates, then tighten or loosen. Scores cluster: copies of the same picture sit at 99–100, light edits in the 90s, unrelated images far below. Start from whichever of these fits the job:

fimgdupes --threshold 99 ~/photos
fimgdupes ~/photos
fimgdupes --hash-size 8 --threshold 95 ~/photos
fimgdupes --hash-size 8 --threshold 90 ~/photos

Lowering the threshold at the default size and switching to --hash-size 8 both admit more edits; size 8 does it by comparing a coarser thumbnail, so it keeps "same picture" pairs near 100 while pulling crops up with them, which usually reads more cleanly than a lower threshold at size 16. Put the setting you settle on in the config file (see CONFIG FILE).

If an expected duplicate is missing, check the "Not scanned:" line first: a small copy under --min-size (50K by default), a large original over a configured --max-size, or a .heic file was never compared at all.

fimgdupes --exact and fdupes(1) agree on byte-identical files; both skip symbolic links and second hard links, both pair empty files, and both separate same-size files that differ in content. Every difference comes from scope, and each one is announced on the "Not scanned:" line (except the first, which is the tool's purpose):

fimgdupes only looks at files with an enabled extension (see --extensions). fdupes reports every kind of file.
Files under --min-size (50K by default, so thumbnails and icons stay out) are never scanned. Use --no-min-size to match fdupes.
Directories named tmp, cache, thumbs and so on (see DEFAULT SKIP LIST) and hidden directories are pruned. Use --no-skip-dirs --hidden to match fdupes.
A .heic copy of a .heic original is a duplicate to fdupes but invisible here until heic is added to --extensions.
Identical by default in both tools: skipped. fdupes -H corresponds to --hardlinks.

With --no-min-size --no-skip-dirs --hidden and all image extensions enabled, the two tools produce the same groups for image files; the script tests/exact-vs-fdupes.sh in the source tree checks this against a tree built to exercise every case above. In the default visual mode fimgdupes also reports resized, recompressed and converted copies, which no checksum tool can find.

Groups are formed using a greedy star model. Every match in a group is directly above the --threshold to the group head — there is no transitive chaining.

Images are sorted by the --sort criterion (e.g. largest first). The first unclaimed image becomes a group head and claims all unclaimed images that are directly similar enough to it. This repeats until all images are either a head or claimed.

A claimed image (match) can still have its own direct matches that were claimed by a different group. These secondary relationships are shown in the display output so you can see the full picture. For example:

a.jpg

└─ 91% b.jpg b.jpg
└─ 92% c.jpg

Here, b is a match of a (91%), and c is a match of b (92%), but a and c are not directly similar enough to be grouped together.

Display vs. actions: when no action is specified, all groups are shown including secondary ones. When an action flag is used (e.g. --move-matches), only groups whose head is not itself a match in another group are acted upon. In the example above, only b would be moved (as a match of a). c would not be moved because its head (b) is itself a match.

fimgdupes ~/photos
fimgdupes --recursive --long --threshold 99 ~/photos
fimgdupes a.jpg b.jpg c.png
fimgdupes --recursive --move-matches ~/review --dry-run ~/photos
fimgdupes --recursive --move-ab ~/photos
feh a/ b/
fimgdupes --recursive --trash-matches --yes ~/photos
fimgdupes --recursive --null ~/photos | xargs -0 some-tool
fimgdupes --recursive --fdupes ~/photos > dupes.txt
fimgdupes --recursive --fdupes --omit-first ~/photos | tr '\n' '\0' | xargs -0 ls -l
fimgdupes --recursive --exact ~/photos
fimgdupes --query ~/Downloads/new.jpg --recursive ~/photos
fimgdupes --query new.jpg --quiet --recursive ~/photos && echo already have it
fimgdupes --exact --query a.jpg --query b.jpg --recursive ~/photos
fimgdupes --cache-local --cache-format json ~/photos
fimgdupes --ignore-config ~/photos

Each cache entry holds the hash, the scoring thumbnail (about 2 KB at hash size 16) and the SHA-256 of the file bytes. Entries written by versions before 0.2.0 carry no thumbnail or checksum and are recomputed automatically on first use. A --exact run on a file that was never fingerprinted stores a checksum-only entry, which a later normal run completes. With --cache-format json the checksums are plain text, so sha256sum output can be grepped against the cache without running fimgdupes at all.

Hash results are cached to ~/.cache/fimgdupes.bin by default. If a fimgdupes.bin (or fimgdupes.json, per --cache-format) already exists in the current directory, it is preferred automatically, so a directory once scanned with --cache-local keeps using its local cache. The cache is keyed by file path, size, and modification time, so changed files are automatically re-hashed. Use --no-cache to disable, --cache-local to store the cache in the current directory, or --cache-format json for a human-readable format.

fdupes(1), feh(1), xargs(1)

Written using Rust.

2026-10-23 fimgdupes 0.2.0