# 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.