Find near-duplicate images with a 64-bit difference hash.
dhash turns an image into a perceptual fingerprint, then compares two
fingerprints by Hamming distance. It can identify many resized, recompressed,
watermarked, and lightly edited variants without comparing the original files
byte-for-byte.
Runs in Bun, Node.js, and Deno through Sharp.
Try the live image comparison demo
| Reference | Watermark | Stickers | Heavy watermark | Crop |
|---|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
| distance 0 | distance 2 | distance 9 | distance 15 | distance 22 |
These are distances from the checked-in test fixtures. Lower means fewer differing hash bits. The crop shows an important limitation - dHash is not crop invariant. The source is NASA's public-domain Earthrise photograph, and its provenance and generated edits are documented in the repository.
npm:
npm install @claudiu-ceia/dhashBun:
bun add @claudiu-ceia/dhashDeno:
deno add jsr:@claudiu-ceia/dhashImport the npm package in Bun or Node.js:
import { compare, dhash } from "@claudiu-ceia/dhash";Import the JSR package in Deno:
import { compare, dhash } from "jsr:@claudiu-ceia/dhash";import { compare, dhash } from "@claudiu-ceia/dhash";
const [reference, candidate] = await Promise.all([
dhash("./reference.jpg"),
dhash("./candidate.jpg"),
]);
const distance = compare(reference, candidate);
console.log({
reference,
candidate,
distance,
});dhash() returns a 16-character lowercase hexadecimal hash. compare() returns
the number of differing bits. For hashes produced by dhash(), the distance is
between 0 and 64.
A distance of 0 means the two images produced the same perceptual fingerprint.
It does not mean their bytes are identical.
Runnable versions are in
examples/compare.ts
and
examples/readme.test.ts.
A dHash contains 64 bits. compare() counts how many bits differ between two
hashes.
0means the fingerprints are equal- A lower value means more of the horizontal brightness structure is shared
- A higher value means more of that structure differs
64is the maximum distance for hashes returned bydhash()
The distance is not a probability or percentage.
There is no universal duplicate threshold. The useful value depends on image content, expected edits, preprocessing, and the cost of false matches.
Start with labelled examples from the application:
- Collect pairs that should and should not match.
- Compute their distances.
- Choose a threshold that separates the two groups acceptably.
- Keep reviewing values close to the threshold.
In this repository's fixture set, the small watermark produces a distance of
2, stickers produce 9, the heavier watermark produces 15, and the cropped
variant produces 22. Those values describe these fixtures, not all images.
The application owns the policy:
const threshold = config.nearDuplicateDistance;
const likelyDuplicate = compare(reference, candidate) <= threshold;dHash is intended for variants that preserve most of the image's horizontal brightness structure, including many cases of:
- Resizing
- Recompression
- Format conversion
- Metadata changes
- Moderate overlays or watermarks
- Light edits
dHash is not designed for:
- Crops
- Translations
- Arbitrary rotation
- Major composition changes
- Semantic similarity between different images
- Exact byte equality
- File integrity or security checks
Use a cryptographic hash for byte equality or integrity. Use a model designed for visual embeddings when different images with similar semantic content should match.
dhash() applies EXIF orientation before hashing. That does not make the
algorithm invariant to arbitrary image rotation.
Before computing the hash, dhash():
- Decodes the first image frame.
- Applies EXIF orientation.
- Composites transparency over white.
- Converts the image to grayscale.
- Resizes the complete image to
9x8with no crop. - Compares each pixel with the pixel immediately to its right.
- Encodes the resulting 64 bits as 16 lowercase hexadecimal characters.
The resize uses the full image and can change its aspect ratio.
The implementation follows the dHash approach described in Neal Krawetz's Kind of Like That.
dhash(source: string | Uint8Array, options?: DHashOptions): Promise<string>- A string is treated as a filesystem path.
Uint8Arraycontains encoded image bytes.- Node.js
Bufferworks because it is aUint8Array. - URLs are not fetched automatically.
- Supported image formats depend on the installed Sharp build.
- Animated or multi-page input uses the first frame.
Fetch remote input separately, check the response, and pass the encoded bytes:
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(`Image request failed with ${response.status}`);
}
const bytes = new Uint8Array(await response.arrayBuffer());
const hash = await dhash(bytes);See
examples/compare-bytes.ts
for a checked byte-input example. Treat remote image input as untrusted even
when resource limits are enabled.
This implementation sets a bit to 1 when brightness increases from the left
pixel to the right pixel:
left < right
Some implementations use the opposite convention. Use:
const compatible = await dhash(source, { invert: true });or:
const compatible = invertHash(existingHash);Compare hashes only when they use the same convention and preprocessing rules.
compare() requires equal textual hash lengths.
The normal output from dhash() is always 16 hexadecimal characters.
dhash() limits both encoded and decoded input by default:
| Option | Default | Purpose |
|---|---|---|
maxInputBytes |
64 MiB | Limits encoded bytes read from a path or passed directly |
limitInputPixels |
64 megapixels | Limits the decoded image size passed to Sharp |
Either limit can be set to false:
await dhash(bytes, {
maxInputBytes: false,
limitInputPixels: false,
});Disable a limit only when the caller applies an equivalent restriction. Path input is read through the encoded-size bound instead of being read fully before the size is checked.
- dHash fingerprints are 64 bits, so collisions are expected.
- Equal hashes do not prove that files or decoded pixels are identical.
- The hash is not suitable for integrity, authentication, or security decisions.
- Cropping, translation, rotation, and larger edits can change the distance substantially.
- Thresholds must be selected against representative application data.
- Image decoding uses Sharp and should remain behind encoded-byte and decoded-pixel limits for untrusted input.
dhash(source, options?);
compare(left, right);type DHashOptions = {
invert?: boolean;
maxInputBytes?: number | false;
limitInputPixels?: number | false;
};invertHash(hash);toAscii(hash, chars?);
raw(hash);
save(hash, filePath);raw() returns PNG-encoded bytes for an 8x8 black-and-white rendering of the
hash. It does not return unencoded pixel bytes.
save(hash, "./fingerprint") writes ./fingerprint.png.
Hash helpers accept 1 to 16 case-insensitive hexadecimal characters.
The npm package is tested with:
- Bun
1.4.0and the current Bun release on Linux - Node.js
22,24, and26on Linux - Node.js
24on macOS and Windows
The JSR package is checked with Deno 2.6.8 and the current Deno 2 release.
Bun is the primary development toolchain. The package uses Sharp for image decoding and resizing, so it is a server-side package with a native dependency.
The browser demo uploads images to a server-side Deno Deploy application. The
dhash package itself does not run image decoding in the browser.
Sharp requires FFI and environment access in Deno. Path input also requires read
access, while save() requires write access. Grant only the paths and
permissions the application needs.
- Live comparison demo for visual exploration
- JSR API documentation for complete declarations and method comments
src/dhash.tsfor implementation details
Use Bun for the main contributor loop:
bun install
bun run check
bun run check:npmCheck Deno compatibility and preview the JSR package:
bun run check:deno
deno publish --dry-runThe TypeScript 7 native checker remains experimental and can be run with
bun run check:ts7.
MIT © Claudiu Ceia




