image-dedup-ai
Find duplicate and near-duplicate images across a large folder, and keep the hashes in an index file so the next scan only reads new or edited files. Matching uses perceptual hashes, which are a heuristic: they find resized and re-compressed copies, but not rotated, flipped or heavily cropped ones (an embed= hook covers those).
Install
pip install image-dedup-ai
Needs only numpy and Pillow. No OpenCV, no torch, no model downloads, no network access.
Quickstart
import image_dedup_ai
from PIL import Image
photo = Image.effect_mandelbrot((512, 512), (-2.0, -1.5, 1.0, 1.5), 100)
images = {"photo": photo, "photo_half_size": photo.resize((256, 256)), "gradient": Image.linear_gradient("L")}
result = image_dedup_ai.find_duplicates(images) # or a folder: find_duplicates("D:/Photos")
print(result.summary()) # 1 group: keep "photo", "photo_half_size" is a copy
What it does
- Hashes each image once. Folders are scanned recursively (links to other folders are not followed). Each
file is read and decoded once for hashing (large JPEGs at reduced size), turned into a small greyscale square,
and three perceptual hashes are stored in the index, a SQLite file (or memory when no path is given):
phash(default): the DCT (computed with numpy) of a 32x32 downscale, low frequencies compared with their median. Robust to resizing, re-compression and contrast changes.dhash: whether each pixel is brighter than its neighbour. Fast, but it calls more look-alike images duplicates thanphash; use a stricterthresholdsuch as 0.95.ahash: whether each cell is brighter than the mean. The fastest and the weakest.
- Similarity is
1 - hamming / bits(64 bits with the defaulthash_size=8). Images at or abovethresholdare linked, and the connected sets of links are the duplicate groups. - Re-checks instantly. Each entry is keyed on the file's path, size and modification time. Adding the same folder again skips unchanged files (and files already known to be unreadable), rehashes edited ones and drops entries whose files are gone.
- Scales. Large indexes are searched with multi-index hashing: the hash is split into chunks so only images that agree exactly on some chunk are compared. The result is identical to comparing every pair. In testing, grouping 100,000 indexed images (well-spread hashes, default threshold) took under a second; collections full of look-alike images take longer.
- Keeps one per group: the largest resolution, then the largest file, then the shortest path (so
photo.jpgis kept overphoto - Copy.jpg).wasted_bytesis the size of all the other members. - Never crashes a scan on a bad file. Unreadable, truncated, empty or non-image files are skipped with a
reason and reported in
result.skipped. Inside a folder, only image extensions are read; other files are counted, not opened. EXIF orientation is honoured; transparency is flattened onto white; 16-bit images are scaled, not clipped. - Never modifies anything. Files are opened read-only and PIL images passed in are left exactly as they were.
Nothing is deleted;
result.dropis a list for you to act on.
What perceptual hashing cannot catch
- Rotated or flipped copies are not found: a mirrored photo has a very different hash. This is the honest limit of perceptual hashing, and the tests assert it rather than hide it.
- Crops of more than a small margin (in the tests a 2% trim per edge is still found and a 10% trim is not), and heavy edits such as strong brightening that clips highlights, filters, overlays or collages, change the hash too much.
- Near-blank images (solid colours, empty pages, black frames) carry no detail, so their hash is noise. They are grouped only when their content is identical, never by hash.
- Different photos of the same scene (burst shots, a second take) usually differ by more than a copy does.
To catch those, pass an embedding: Index(path, embed=fn) calls fn(image) with an upright RGB PIL.Image
for every added image and stores the vector it returns. find_duplicates(method="embed") then groups by cosine
similarity. Any model works (CLIP, a colour histogram, your own); this package does not ship or download one.
The embedding search compares every pair, which is practical up to a few tens of thousands of images.
API
image_dedup_ai.find_duplicates(folder, *, threshold=0.9, method="phash", hash_size=8, recursive=True,
index=None, embed=None, workers=None) -> DedupeResult
folder is anything Index.add accepts. index is an optional file path that keeps the hashes between calls.
For a real folder, keep the index so the second run only reads new or edited files:
with image_dedup_ai.Index("photos.idx") as idx:
idx.add("D:/Photos")
result = idx.find_duplicates(threshold=0.9)
print(result.summary()) # result.keep, result.drop and result.wasted_bytes say what to do next
Index(path=None, *, hash_size=8, embed=None): path is the SQLite file (None keeps everything in
memory). hash_size is 4 to 32 (hash_size ** 2 bits). A file is tied to its hash_size; opening it with
another raises a ValueError that says which to use.
Supports with Index(...) as idx:.
.add(images, *, recursive=True, workers=None) -> int: a folder, an image path, aPIL.Image, a{name: PIL.Image}dict, or a list mixing them. Returns how many of them are now indexed. Unnamed PIL images are named<image 1>,<image 2>, ...workersis the number of decoding threads (default up to 8).idx.last_addis anAddReportwithhashed,unchanged,embedded,removed,skipped(list of(path, reason)),ignored(non-image files) and.summary()..find_duplicates(*, threshold=0.9, method="phash") -> DedupeResult:methodis"phash","dhash","ahash"or"embed".thresholdis in(0, 1];1.0means identical hashes only..near(image, *, k=5, method="phash") -> list[Match(path, similarity)]: thekmost similar indexed images to a path orPIL.Image, most similar first. The query is not added, and is left out of its own results..remove(path) -> int: remove one entry, or every entry under a folder..count(),len(idx),.close().
image_dedup_ai.hash_image(image, method="phash", *, hash_size=8) -> str returns one hash as hex.
DedupeResult
.groups-list[list[str]], each group's paths with the kept image first; largest reclaimable size first.keep-list[str], the kept path of each group;.drop- every other member (the copies).pairs-list[(a, b, similarity)], the links that formed the groups (a < b). Byte-identical files, and files with identical hashes, are each linked once to the best copy among them rather than to every other member, so the list grows linearly with the number of copies..details- per group, aMember(path, width, height, bytes, similarity, identical, kept)for each image, wheresimilarityandidenticalare relative to the kept image.wasted_bytes,.n_groups,.n_duplicates,.n_images,.method,.threshold,.bits.skipped-list[(path, reason)];.notes- caveats that apply to this result.summary(max_groups=10)- plain-text report;.to_dict()- JSON-safe dict
CLI
image-dedup-ai PATH [PATH ...] [--index FILE] [--method phash|dhash|ahash] [--threshold 0.9]
[--hash-size 8] [--no-recursive] [--workers N] [--near IMAGE] [-k 5]
[--json] [--output FILE]
image-dedup-ai D:/Photosprints the summary.image-dedup-ai D:/Photos --index photos.idx --output dupes.csvkeeps the hashes and writes one CSV row per image in a group (group, action, path, width, height, bytes, similarity_to_keep, identical_to_keep);--output dupes.jsonwrites the full result.image-dedup-ai --index photos.idx --near holiday.jpg -k 10lists the nearest indexed images.--jsonprintsto_dict()(plus ascansection) as JSON. Output is UTF-8, so non-ASCII file names are safe in pipes and redirects.
Nothing is ever deleted or moved by the CLI.
License
MIT
Metadata
Release files for image-dedup-ai 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| image_dedup_ai-0.1.0.tar.gz | 40.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| image_dedup_ai-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 70.7 kB
Release files / image_dedup_ai-0.1.0.tar.gz
| Download URL | image_dedup_ai-0.1.0.tar.gz |
|---|---|
| Size | 40.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c3f4934d15869a9c095a34a1c3cbd5e23a56bd19c794749d594a66f4b4cab49d
|
|
BLAKE2b-256 checksum How to use checksums |
c713e19f5483d6bb0abf1f11c03f88b307592303f7b57c62a6baee026fd36bce
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / image_dedup_ai-0.1.0-py3-none-any.whl
| Download URL | image_dedup_ai-0.1.0-py3-none-any.whl |
|---|---|
| Size | 30.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
5bd3497296471bd7e6cc91e7dad52c00a5ca2f12d4dfd18fee8f8bb7cb30a1ce
|
|
BLAKE2b-256 checksum How to use checksums |
1f976972f7e4fac02b75512a9b06d82545920acdc0655eac53198ed230d838e2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|