# PhotoSweep (CLI prototype)
PhotoSweep is a working prototype that finds near-duplicate / visually-similar
photos in a folder tree -- the same photo re-exported at a different size or
quality, a burst-shot near-duplicate, a slightly-cropped re-save -- using a
real perceptual hashing technique (average-hash / aHash) plus Hamming-distance
grouping, and the same safe, quarantine-based cleanup workflow as the sibling
tool DupePilot. It differs from DupePilot in exactly one, deliberate way:
DupePilot finds files that are byte-identical (SHA-256 hash match), which
catches only true duplicate files; PhotoSweep finds files that LOOK the same
even when their bytes, dimensions, or format differ, which is the much more
common real-world case (a phone that saves both a full-res photo and a
resized share copy, a photo re-exported from two different apps, a burst of
five nearly-identical shots). The two tools are complementary, not
overlapping -- PhotoSweep does not reimplement exact-duplicate detection.
Be clear-eyed about what this is: average-hashing is a real, working, and
genuinely effective technique for catching resizes, re-compressions, format
conversions, and minor edits, but it is a basic perceptual hash. It is not as
robust as DCT-based perceptual hashes (pHash/dHash), which handle rotation
and heavier crops much better, and it has no concept of visual semantics the
way an ML embedding model would. Those, along with a proper nearest-neighbor
index for huge photo libraries (this prototype uses straightforward O(n^2)
pairwise comparison, which is fine for a folder of hundreds to a few thousand
photos) and a WinUI 3 review-set UI for confirming groups visually before
cleanup, are on the roadmap. See ../plan.md for the full product plan.
## Build from source
Requires Go 1.24+, no external dependencies (stdlib image/jpeg, image/png,
and image/gif decoders only).
go build -o photosweep .
Cross-compile for another platform:
GOOS=windows GOARCH=amd64 go build -o photosweep.exe .
GOOS=darwin GOARCH=arm64 go build -o photosweep .
## Usage
photosweep scan
[ ...] [--threshold 5] [--min-size 10KB] [--json]
photosweep clean [ ...] --quarantine [--threshold 5] [--min-size 10KB] [--apply]
photosweep help
### scan
Walks the given directories, looks at files with recognized image extensions
(.jpg, .jpeg, .png, .gif), and for every file that actually decodes as an
image, computes a 64-bit perceptual hash. Files that fail to decode (a
corrupt file, or a non-image file that happens to have an image extension)
are skipped with a warning on stderr -- the scan keeps going, it never
crashes on bad input.
Images are grouped into near-duplicate sets using simple pairwise
Hamming-distance comparison: for each image, PhotoSweep compares its hash
against every hash seen so far; if it is within --threshold bits of any
image already in a group, it joins that group, otherwise it starts a new
one. Only groups with 2 or more members are reported. For each group,
PhotoSweep prints every member's path, its Hamming distance from the
group's first (reference) member, its pixel dimensions, and its file size,
followed by a summary: files scanned, groups found, and the total cleanup
candidate count (each group's size minus one -- i.e. how many files you
could remove if you kept exactly one copy per group).
--threshold N max Hamming distance (0-64) to count as near-duplicate.
Default 5. Lower = stricter (fewer, more certain
matches). Higher = looser (catches more edits, but
more risk of false positives on genuinely different
photos that happen to share overall brightness/layout).
--min-size S ignore files smaller than S (e.g. 10KB, 2MB) -- useful
for skipping thumbnails/icons. Default: no minimum.
--json emit the same results as machine-readable JSON.
### clean
Runs the exact same scan-and-group logic as `scan`. For every reported
group, the FIRST member found (by scan order) is treated as the keeper;
every other member in that group is a cleanup candidate. Like DupePilot,
this is safety-first: without --apply, clean only prints the plan (which
file would be KEPT, which would be QUARANTINED) and touches nothing on
disk. With --apply, every non-kept file is MOVED -- never deleted -- into
--quarantine, preserving its path relative to the root directory it was
found under, so you can review or restore anything before removing it for
good. The kept file is never touched. A final summary reports how many
files were moved and how many bytes were reclaimed from their original
locations.
--quarantine D destination directory for quarantined files (required)
--threshold N same meaning as in scan (default 5)
--min-size S same meaning as in scan
--apply actually move files (default: dry run, no changes made)
### How the perceptual hash works (aHash), in plain language
1. Decode the image.
2. Shrink it down to an 8x8 grid of 64 cells (a simple box-average
downsample -- each cell's value is the average color of the block of
source pixels it covers, not just one sampled pixel, so the hash is
robust to noise and resizing).
3. Convert each cell's average color to a single brightness value using
the standard luminance formula (0.299*R + 0.587*G + 0.114*B).
4. Compute the average brightness across all 64 cells.
5. Build a 64-bit fingerprint: for each cell, the bit is 1 if that cell is
at least as bright as the average, 0 if it is darker.
Two images are "near-duplicates" if their fingerprints differ in only a
few bits -- that's the Hamming distance, and --threshold is the cutoff.
Because the fingerprint only cares about the coarse pattern of light and
dark across the image, it survives resizing, re-compression, and format
changes almost perfectly, while still telling genuinely different photos
apart.
## Prebuilt binaries
See ../downloads/ for prebuilt binaries (Windows/macOS/Linux) and
CHECKSUMS.txt for their SHA-256 hashes. These are unsigned indie builds --
Windows SmartScreen and macOS Gatekeeper will warn on first run, which is
expected until a code-signing certificate is in place.