← Complete Projects

阅读中文版本

Build a Perceptual Duplicate Image Finder

A CLI that scans an image folder, computes a perceptual hash for each file, groups near-duplicates by Hamming distance, and reports clusters without deleting anything.

intermediate~5 hnodejsimageshashingcli

Project goal

Build a CLI that computes a perceptual hash for every image in a directory, groups images whose hashes fall within a distance threshold, and prints clusters of likely duplicates.

Final features

  • Compute a perceptual hash for each image using a downscaled grayscale comparison.
  • Compare hashes by Hamming distance and cluster images under a threshold.
  • Tune the threshold from the command line and explain its effect.
  • Report clusters with image counts and sizes, never deleting files.
  • Skip non-image files and report how many were considered.

Architecture

A hasher produces a compact bit string for an image; a comparator computes Hamming distance and clusters by union-find; a reporter prints groups; and the CLI wires discovery to hashing to clustering. Hashing is isolated behind the image adapter so clustering is tested with synthetic hashes and no real decoding.

Prerequisites

  • Node.js 20 LTS or later
  • Familiarity with binary data and bit operations
  • Basic understanding of perceptual hashing

Dependencies

  • node >=20 — File traversal and the CLI entry point
  • sharp ^0.33.0 — Decoding and downscaling images for hashing
  • vitest ^2.1.0 — Clustering and distance tests

Directory structure

dup-images/
├── src/
│   ├── discover.js
│   ├── hash.js
│   ├── cluster.js
│   ├── report.js
│   └── cli.js
├── test/
│   ├── cluster.test.js
│   └── hash.test.js
└── package.json

Phases

1. Discovery

Walk the input directory and collect image files with their sizes and extensions.

Files: src/discover.js

import { readdir, stat } from 'node:fs/promises';
import { join, extname, relative } from 'node:path';

const SUPPORTED = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);

export async function discover(root) {
  const found = [];
  async function walk(dir) {
    for (const entry of await readdir(dir, { withFileTypes: true })) {
      const full = join(dir, entry.name);
      if (entry.isDirectory()) await walk(full);
      else if (SUPPORTED.has(extname(entry.name).toLowerCase())) {
        const info = await stat(full);
        found.push({ path: full, rel: relative(root, full).replace(/\\/g, '/'), size: info.size });
      }
    }
  }
  await walk(root);
  return found.sort((a, b) => a.rel.localeCompare(b.rel));
}

Expected behavior: Nested directories yield supported images in a deterministic order, and an unsupported extension is ignored.

2. Perceptual hashing

Downscale an image to a small grayscale grid, compare against the mean, and emit a bit signature.

Files: src/hash.js

import sharp from 'sharp';

export async function hashImage(path, { size = 8 } = {}) {
  const pixels = await sharp(path)
    .greyscale()
    .resize(size, size, { fit: 'fill' })
    .raw()
    .toBuffer();
  const mean = pixels.reduce((sum, v) => sum + v, 0) / pixels.length;
  let bits = '';
  for (const value of pixels) bits += value >= mean ? '1' : '0';
  return { bits, hex: BigInt('0b' + bits).toString(16).padStart((size * size) / 4, '0') };
}

Expected behavior: The same image produces the same signature on repeated runs, and a re-saved copy at slightly lower quality produces the same or a very close signature.

3. Hamming distance and clustering

Compute Hamming distance between signatures and cluster near matches with union-find.

Files: src/cluster.js

export function hamming(a, b) {
  let distance = 0;
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) distance++;
  return distance;
}

export function cluster(hashes, threshold) {
  const parent = hashes.map((_, i) => i);
  const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i])));
  const union = (a, b) => { parent[find(a)] = find(b); };
  for (let i = 0; i < hashes.length; i++) {
    for (let j = i + 1; j < hashes.length; j++) {
      if (hamming(hashes[i].bits, hashes[j].bits) <= threshold) union(i, j);
    }
  }
  const groups = new Map();
  hashes.forEach((h, i) => {
    const root = find(i);
    if (!groups.has(root)) groups.set(root, []);
    groups.get(root).push(h);
  });
  return [...groups.values()].filter((g) => g.length > 1);
}

Expected behavior: Identical signatures have distance zero, and three mutually-near signatures end up in a single cluster.

4. Threshold selection

Map a user-facing similarity percentage to a Hamming threshold and validate the range.

Files: src/threshold.js

export function thresholdFromPercent(percent, bits = 64) {
  const value = Number(percent);
  if (!Number.isFinite(value) || value < 0 || value > 100) throw new Error('percent must be 0..100');
  return Math.round((value / 100) * bits);
}

export function explain(threshold, bits = 64) {
  return 'matching images may differ in up to ' + threshold + ' of ' + bits + ' bits';
}

Expected behavior: A threshold of 10 percent on 64 bits returns 6, and a percent outside the range throws a clear error.

5. Reporter

Print clusters with their members, sizes, and relative paths, and summarise scan totals.

Files: src/report.js

export function formatClusters(clusters) {
  return clusters.map((group, i) => {
    const head = 'cluster ' + (i + 1) + ' (' + group.length + ' images)';
    const items = group.map((g) => '  ' + g.rel + '  ' + g.size + ' bytes');
    return [head, ...items].join('\n');
  }).join('\n\n');
}

export function summarize(scanned, clusters) {
  return 'scanned ' + scanned + ' images, found ' + clusters.length + ' duplicate clusters';
}

Expected behavior: Each cluster prints a header with the member count followed by its files, and the summary reports the scanned total.

6. CLI and tests

Wire discovery, hashing, clustering, and reporting, and cover distance and clustering with tests.

Files: src/cli.js

import { discover } from './discover.js';
import { hashImage } from './hash.js';
import { cluster } from './cluster.js';
import { thresholdFromPercent, explain } from './threshold.js';
import { formatClusters, summarize } from './report.js';

const args = parse(process.argv.slice(2));
if (!args.dir) { console.error('usage: scan --dir <folder> [--similarity 10]'); process.exit(1); }
const files = await discover(args.dir);
const hashes = [];
for (const f of files) hashes.push({ ...f, ...(await hashImage(f.path)) });
const threshold = thresholdFromPercent(args.similarity ?? 10);
const clusters = cluster(hashes, threshold);
console.log(formatClusters(clusters));
console.log(summarize(files.length, clusters));
console.log(explain(threshold));

Expected behavior: Scanning a folder with two near-duplicates prints one cluster and the summary, and a missing directory exits with code 1.

Testing

Run npx vitest. Distance tests assert that identical hashes have distance zero and a one-bit difference has distance one. Cluster tests feed synthetic hashes and assert that three mutually-near hashes form one cluster and a distant hash stays alone. A hash test runs against a fixture and asserts a stable 64-bit signature.

Troubleshooting

Resaved copies do not cluster together

Raise the similarity threshold a little and confirm the image is decoded before hashing; a threshold of zero only catches byte-identical files.

Unrelated images cluster together

Lower the threshold and consider a larger hash; with an 8x8 grid, visually different images can share many bits, so the threshold trades misses against false groupings.

Hashing is slow on a large collection

Cache hashes keyed by file size and modification time so unchanged files are not re-hashed on every run.

Deployment

Run as a maintenance job that reports clusters to a log or a file for review. The tool deliberately stops at reporting so that the decision to remove or move duplicates stays with a human.

Known limitations

  • Perceptual hashes are good at catching resaves but miss images that were cropped or rotated.
  • The pairwise comparison is quadratic in the number of images, which is fine for thousands but not millions.

Project overview

Exact duplicate detection is trivial; near-duplicate detection is the interesting problem, because the same photo resaved at a different quality is byte-different yet visually identical. Perceptual hashing collapses that difference into a small signature.

This tool computes a hash per image, compares hashes by Hamming distance, and clusters images that fall within a threshold. It only reports; it never deletes, which keeps the destructive step a human decision.

The project is deliberately sized so that every stage ships something you can run: the first phase produces a working command or endpoint, and each later phase adds one capability without forcing a rewrite of what came before. That property — always runnable, never half-migrated — is what makes the tutorial usable as a weekend build rather than a sketch that only works once the final commit lands.

The finished tool carries no hidden services and no hosted dependencies. Everything it needs lives in the repository and a machine with the stated runtime, so a reader can clone, install, run the tests, and trust that the behaviour they see locally is the behaviour the tutorial promised. Where a real deployment needs an external system, that seam is called out explicitly rather than smuggled in through a default.

It is written for a working developer who wants the whole shape of a real tool in one readable pass rather than a tour of a framework. Each phase states the decision it makes and why, then shows the code that encodes it, so the reader can stop after any phase with something that runs and resume later without relearning the whole design. The prose is deliberately honest about trade-offs: where a simpler approach would break at scale, that ceiling is named instead of hidden behind a benchmark.

Clustering is union-find over distance comparisons, which is what makes it robust to chains where A matches B and B matches C:

export function cluster(hashes, threshold) {
  const parent = hashes.map((_, i) => i);
  const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i])));
  const union = (a, b) => { parent[find(a)] = find(b); };
  for (let i = 0; i < hashes.length; i++) {
    for (let j = i + 1; j < hashes.length; j++) {
      if (hamming(hashes[i].hash, hashes[j].hash) <= threshold) union(i, j);
    }
  }
  const groups = new Map();
  hashes.forEach((h, i) => {
    const root = find(i);
    if (!groups.has(root)) groups.set(root, []);
    groups.get(root).push(h);
  });
  return [...groups.values()].filter((g) => g.length > 1);
}

The chain-tolerance of union-find is the property that matters: a naive greedy grouping would split three related images into two clusters depending on order.

Setup

sharp is pinned because perceptual hashing depends on consistent downscaling, and a different resampling implementation could shift the hash.

Set up the workspace before writing any logic. The table below pins every dependency to a version so the behaviour you observe matches the tutorial; floating versions are the most common reason a reader’s output diverges from the text. Install exactly what the table lists, then confirm the toolchain reports the expected version before continuing.

Two configuration choices deserve attention up front. First, the project keeps its runtime entry and its test entry as separate scripts so that running tests never starts a server or writes to a data directory. Second, all tunables are read from one place — a small config module or environment variables — so that nothing depends on the working directory a command happens to be launched from.

Dependency Version Why it is here
node >=20 File traversal and the CLI entry point
sharp ^0.33.0 Decoding and downscaling images for hashing
vitest ^2.1.0 Clustering and distance tests

Create the project and install:

mkdir dup-images && cd dup-images
npm init -y
npm install sharp@^0.33.0
npm install -D vitest@^2.1.0

Configure the CLI and tests in package.json:

{
  "name": "dup-images",
  "type": "module",
  "scripts": {
    "scan": "node src/cli.js",
    "test": "vitest run"
  }
}

Commit a few tiny generated fixtures so hash tests run without large binaries in the repository.

Phase 1: Discovery

Walk the input directory and collect image files with their sizes and extensions. The scope is chosen so the project is genuinely finishable, yet every phase has a real decision to make rather than busywork.

The first hard decision is where the boundary between parsing and doing sits. Input arrives as raw text — argv, environment variables, a file on disk — and this phase converts it exactly once into a typed object that the rest of the program trusts. Confining that conversion to a single module means every later layer receives clean, already-validated input and never re-checks a string, which removes a whole class of bugs where two modules quietly disagree about what a flag or a field means. The second decision is failure visibility: invalid input must produce an actionable message and a non-zero exit rather than a stack trace surfacing from three layers down. Getting these two boundaries right early is what lets the remaining phases stay small.

import { readdir, stat } from 'node:fs/promises';
import { join, extname, relative } from 'node:path';

const SUPPORTED = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);

export async function discover(root) {
  const found = [];
  async function walk(dir) {
    for (const entry of await readdir(dir, { withFileTypes: true })) {
      const full = join(dir, entry.name);
      if (entry.isDirectory()) await walk(full);
      else if (SUPPORTED.has(extname(entry.name).toLowerCase())) {
        const info = await stat(full);
        found.push({ path: full, rel: relative(root, full).replace(/\\/g, '/'), size: info.size });
      }
    }
  }
  await walk(root);
  return found.sort((a, b) => a.rel.localeCompare(b.rel));
}

Acceptance for this phase: Nested directories yield supported images in a deterministic order, and an unsupported extension is ignored.

Phase 2: Perceptual hashing

Downscale an image to a small grayscale grid, compare against the mean, and emit a bit signature. The scope is chosen so the project is genuinely finishable, yet every phase has a real decision to make rather than busywork.

This phase defines the data model that every other module speaks. The choice of shape matters more than it looks: flat records are easy to serialize and diff, nested structures model relationships but complicate equality checks, and identifiers must be stable across edits so that references never silently rebind. The code here stays pure — functions take data and return data, with no clocks, no disk, and no printing — because pure functions are the only kind that can be tested exhaustively without a fixture harness. When the model is right the surrounding code almost writes itself; when it is wrong every later phase pays interest.

import sharp from 'sharp';

export async function hashImage(path, { size = 8 } = {}) {
  const pixels = await sharp(path)
    .greyscale()
    .resize(size, size, { fit: 'fill' })
    .raw()
    .toBuffer();
  const mean = pixels.reduce((sum, v) => sum + v, 0) / pixels.length;
  let bits = '';
  for (const value of pixels) bits += value >= mean ? '1' : '0';
  return { bits, hex: BigInt('0b' + bits).toString(16).padStart((size * size) / 4, '0') };
}

Acceptance for this phase: The same image produces the same signature on repeated runs, and a re-saved copy at slightly lower quality produces the same or a very close signature.

Phase 3: Hamming distance and clustering

Compute Hamming distance between signatures and cluster near matches with union-find. The scope is chosen so the project is genuinely finishable, yet every phase has a real decision to make rather than busywork.

The core operation is where the project delivers its value, and the temptation is to cram every option into one function. Resist it. The main path should read as a short, obvious sequence — load, transform, emit — with the branching behaviour factored into small decisions that can be tested on their own. This phase also fixes the error contract: which failures are fatal, which are recoverable, and how a partial result is reported. A tool that can explain what it did and why it stopped is far easier to trust in a pipeline than one that only signals success or silence.

export function hamming(a, b) {
  let distance = 0;
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) distance++;
  return distance;
}

export function cluster(hashes, threshold) {
  const parent = hashes.map((_, i) => i);
  const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i])));
  const union = (a, b) => { parent[find(a)] = find(b); };
  for (let i = 0; i < hashes.length; i++) {
    for (let j = i + 1; j < hashes.length; j++) {
      if (hamming(hashes[i].bits, hashes[j].bits) <= threshold) union(i, j);
    }
  }
  const groups = new Map();
  hashes.forEach((h, i) => {
    const root = find(i);
    if (!groups.has(root)) groups.set(root, []);
    groups.get(root).push(h);
  });
  return [...groups.values()].filter((g) => g.length > 1);
}

Acceptance for this phase: Identical signatures have distance zero, and three mutually-near signatures end up in a single cluster.

Phase 4: Threshold selection

Map a user-facing similarity percentage to a Hamming threshold and validate the range. The scope is chosen so the project is genuinely finishable, yet every phase has a real decision to make rather than busywork.

Integration is where the project stops being a toy. Input may arrive in a format an external system produced, output may need to match a contract another system consumes, and the two rarely line up without adjustment. This phase owns the adapters — the small translation functions between the outside world’s shape and the internal model — and it owns the boundary decisions about what to do when the two disagree: coerce, reject, or warn and continue. Keeping adapters thin and explicit means a change to an external format touches one file, and the internal model stays stable as the ecosystem around it moves.

export function thresholdFromPercent(percent, bits = 64) {
  const value = Number(percent);
  if (!Number.isFinite(value) || value < 0 || value > 100) throw new Error('percent must be 0..100');
  return Math.round((value / 100) * bits);
}

export function explain(threshold, bits = 64) {
  return 'matching images may differ in up to ' + threshold + ' of ' + bits + ' bits';
}

Acceptance for this phase: A threshold of 10 percent on 64 bits returns 6, and a percent outside the range throws a clear error.

Phase 5: Reporter

Print clusters with their members, sizes, and relative paths, and summarise scan totals. The scope is chosen so the project is genuinely finishable, yet every phase has a real decision to make rather than busywork.

The user-facing surface determines whether the project feels finished. A command-line tool owes its caller predictable stdout, meaningful stderr, and honest exit codes; a service owes a stable request and response schema plus consistent status handling. This phase wires the internal modules to that surface and nothing else — it contains no business logic of its own, only translation between the outside contract and the internal calls. That separation is what lets the same core serve a CLI today and an HTTP endpoint tomorrow without duplication, and it is why the tests for this layer assert process-level behaviour rather than poking at internals.

export function formatClusters(clusters) {
  return clusters.map((group, i) => {
    const head = 'cluster ' + (i + 1) + ' (' + group.length + ' images)';
    const items = group.map((g) => '  ' + g.rel + '  ' + g.size + ' bytes');
    return [head, ...items].join('\n');
  }).join('\n\n');
}

export function summarize(scanned, clusters) {
  return 'scanned ' + scanned + ' images, found ' + clusters.length + ' duplicate clusters';
}

Acceptance for this phase: Each cluster prints a header with the member count followed by its files, and the summary reports the scanned total.

Phase 6: CLI and tests

Wire discovery, hashing, clustering, and reporting, and cover distance and clustering with tests. The scope is chosen so the project is genuinely finishable, yet every phase has a real decision to make rather than busywork.

The final phase turns a working program into a dependable one. Tests are the visible part, but the discipline behind them is deeper: every claim the earlier phases made is pinned by an executable check, and every failure mode the tutorial mentioned has a test that reproduces it before the fix. Edge cases — empty input, malformed records, concurrent invocations, interrupted writes — are enumerated and asserted rather than assumed away. The payoff is a suite that fails loudly and precisely when someone changes a decision, which is exactly the signal a maintainer wants the moment they touch the code.

import { discover } from './discover.js';
import { hashImage } from './hash.js';
import { cluster } from './cluster.js';
import { thresholdFromPercent, explain } from './threshold.js';
import { formatClusters, summarize } from './report.js';

const args = parse(process.argv.slice(2));
if (!args.dir) { console.error('usage: scan --dir <folder> [--similarity 10]'); process.exit(1); }
const files = await discover(args.dir);
const hashes = [];
for (const f of files) hashes.push({ ...f, ...(await hashImage(f.path)) });
const threshold = thresholdFromPercent(args.similarity ?? 10);
const clusters = cluster(hashes, threshold);
console.log(formatClusters(clusters));
console.log(summarize(files.length, clusters));
console.log(explain(threshold));

Acceptance for this phase: Scanning a folder with two near-duplicates prints one cluster and the summary, and a missing directory exits with code 1.

Design trade-offs

Minimal dependencies over framework convenience. A framework would hand over plumbing for free, but it would also own the interesting decisions this project exists to teach. Keeping the dependency surface small means the lifecycle is visible end to end, the install is instant, and the tool runs anywhere the stated runtime does. The cost is owning the edge cases yourself — which is precisely why each of them gets a test.

Explicit contracts over clever conventions. Where a convention would infer behaviour from a filename or a folder, the project asks for a named export or a declared field instead. It reads as slightly more verbose and is far easier to review: a reader sees the intent on the line that owns it rather than reconstructing it from directory layout. Debugging also gets cheaper, because the failure points at the declaration instead of the convention that silently produced it.

Deterministic behaviour over convenience defaults. Anything that could vary between machines — time, ordering, encoding, locale — is injected or pinned rather than read from the ambient environment. That choice costs a little ceremony and buys reproducible output, which is the property that makes the output diffable in review and the tests trustworthy across the team’s machines.

Testing strategy

Run npx vitest. Distance tests assert that identical hashes have distance zero and a one-bit difference has distance one. Cluster tests feed synthetic hashes and assert that three mutually-near hashes form one cluster and a distant hash stays alone. A hash test runs against a fixture and asserts a stable 64-bit signature.

Tests are organised by layer so a failure points at the decision that broke. Pure unit tests cover the transformation logic without touching disk or network and run in milliseconds; integration tests exercise the real entry point against a temporary working directory and assert the observable contract; a small end-to-end tier proves the whole path produces the documented output. Keeping the fast tiers large and the slow tier thin is what lets the suite run on every save while still catching integration mistakes.

Every test asserts a specific promise from the phases above rather than merely covering lines. A passing suite therefore means the tool kept its word: filters compose, identifiers stay stable, failures exit non-zero with a readable message, and the output matches the format a downstream consumer expects. When someone changes a contract the matching test fails with an exact diff, turning a silent regression into a line in the pull request.

Running the suite in CI is the last line of defence, but the same command runs locally before every commit, which is what keeps the feedback loop short. A green run means the documented behaviour still holds; a red run names the exact promise that broke. Treat the suite as executable documentation and it stays current, because a stale assertion fails the moment the code and the text disagree.

The transitive-cluster test is the one that catches naive implementations: the chain case is exactly where a pairwise greedy grouping gives the wrong answer.

Extending the project

A larger hash, colour hashing, and a cache of hashes keyed by file size and mtime are additions around the same pipeline. A move-to-duplicates action would be a distinct, explicit command rather than part of scanning.

The structure leaves obvious seams for growth. Each capability that could be added — a new output format, an extra filter, an interactive mode — lands in one layer because the boundaries between parsing, logic, and presentation are already drawn. Extensions that would break the current design are equally visible: the moment a feature needs cross-process coordination or a database, the storage interface is where the change belongs, and its tests tell you immediately whether the surrounding code survives the swap.