Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

14 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

shutter-cull

CI MIT License Python 3.11+

English | Espanol

shutter-cull demo: scan, dry-run cull plan, and XMP sidecar writeback

A local-first, non-destructive photo culling engine. Point it at a shoot folder of RAW and JPEG files: it groups burst frames, scores every frame for blur, eye-openness, and aesthetics, picks the keepers, flags the clear rejects, and writes the decisions as XMP sidecar files that Lightroom Classic reads natively. Nothing ever leaves your machine.

Why this exists

AI culling tools are dominated by paid subscriptions that upload your shoots to someone else's cloud: Aftershoot, Imagen, FilterPixel, Narrative Select. Photographers are rightly cautious about pushing client work through third-party servers, and a per-month fee for what is fundamentally a batch scoring job never sat right. shutter-cull is the missing option: free, open source, fully local. Your images are read from your disk, scored on your CPU, and the only thing ever written is a tiny XMP sidecar next to each flagged frame.

What it does

ingest  ->  cluster  ->  score  ->  composite  ->  decide  ->  write
  1. Ingest. Walks the shoot folder (symlink-safe, capped at 50,000 files) and discovers RAW plus JPEG frames. RAW files are read via their embedded preview, no slow full demosaic.
  2. Cluster. Groups bursts by EXIF timestamp proximity (chained 2 second window) confirmed by perceptual hash similarity, so a pan to a new subject inside the window still splits the group.
  3. Score. Every frame gets three scores: sharpness (variance of the Laplacian), eye-openness (YuNet face detection plus an eye-crop detail heuristic), and a heuristic aesthetic score (exposure clipping, sharpness, rule-of-thirds energy).
  4. Composite. Scores are percentile-ranked within the current scan, then weighted: blur 0.5, eyes 0.3, aesthetic 0.2 by default. Ranking within the scan means no brittle absolute thresholds; every shoot is judged against itself.
  5. Decide. The top frame of each burst becomes a Pick. The scanwide bottom decile becomes Rejects. Everything in between is left alone: the tool flags clear calls and leaves ambiguity to you. Singletons are never picked, and a burst top that lands in the bottom decile is left unflagged rather than starred or rejected.
  6. Write. Only with --write. Picks get a 5-star rating and a Green label, Rejects get a Red label. All of it goes into .xmp sidecar files; original image bytes are never opened for writing, the same non-destructive posture Lightroom itself uses for RAW metadata.

Frames the pipeline cannot decode at all score worst-case and surface in the rejects, which is exactly where a corrupt file belongs.

Install

Requires Python 3.11+ and exiftool (only needed for --write).

brew install exiftool          # macOS
sudo apt install libimage-exiftool-perl   # Debian/Ubuntu

Not on PyPI yet (see Roadmap). Install from source:

git clone https://github.com/keivanmalhani/shutter-cull.git
cd shutter-cull
python -m venv .venv && source .venv/bin/activate
pip install -e .

Usage

Report what is in a folder. Read-only, never scores, never touches the network:

shutter-cull scan ~/Pictures/2026-08-02-street-shoot

Run the full pipeline and print the pick/reject plan. Dry run is the default; this writes nothing:

shutter-cull cull ~/Pictures/2026-08-02-street-shoot

Apply the plan as XMP sidecars:

shutter-cull cull ~/Pictures/2026-08-02-street-shoot --write

Then open the folder in Lightroom Classic (or re-read metadata on an existing catalog): picks show up as 5-star Green-labeled frames, rejects carry a Red label, filter and act on them however you like.

Options

Flag Default What it does
--write off Actually write sidecars. Without it, cull is always a dry run.
--allow-download off Permit the one-time, sha256-verified face model download.
--picks-per-cluster N 1 Top N frames per burst get a Pick.
--weight-blur W 0.5 Composite weight for sharpness.
--weight-eyes W 0.3 Composite weight for eye-openness.
--weight-aesthetic W 0.2 Composite weight for the aesthetic heuristic.

Model weights policy

No model weights are vendored in this repo and nothing is fetched silently. Eye scoring uses the YuNet face detector (opencv_zoo, MIT). The first cull --allow-download fetches it once, verifies the bytes against a sha256 pinned in the source, and caches it under ~/.cache/shutter-cull/models. Without the flag, and without a cached model, eye scoring falls back to neutral and the rest of the pipeline works normally: zero downloads are required to use the tool.

Honest limitation: open-vs-closed eye state is currently a detail heuristic on the eye crop (closed lids carry less high-frequency detail than lashes, iris edges, and catchlights), not a trained classifier. It is labeled as such in the code and a real eye-state model is on the roadmap.

Format support

Capability Extensions
RAW ingest via embedded preview dng, arw, raf, nef, cr2, cr3
JPEG ingest jpg, jpeg
XMP sidecar writeback all of the above (sidecar next to the frame, same stem)

Security model

  • Local only. No uploads, no accounts, no telemetry, no analytics.
  • No network calls at runtime. The single exception is the explicit, opt-in, checksum-verified model download above.
  • Originals are never modified. Writes go to .xmp sidecars only; original image paths are never passed to exiftool in a write call.
  • Dry run by default. The only path to disk writes is the --write flag.
  • Symlink-safe walks. Symlinked files and directories are skipped, so a planted symlink cannot pull the scan outside the root you named.
  • Battle-tested writer. Metadata writes go through exiftool, not a hand-rolled XMP serializer.

Development

pip install -e ".[dev]"
pytest

86 tests, no committed binary fixtures: test images are generated with Pillow at run time. Tests that need exiftool skip cleanly when it is not installed. CI runs the suite on Python 3.11 and 3.12 with exiftool present.

Roadmap

  • Real eye-state classification (MediaPipe Face Landmarker blink blendshapes is the current lead)
  • NIMA-style ONNX aesthetic model to replace the heuristic
  • Optional full RAW demosaic decode for maximum scoring accuracy
  • PyPI publish
  • MCP agent interface (shutter-cull-mcp) so agents can propose and apply culls conversationally, a sibling of shutter-mcp
  • Desktop UI

License

MIT, see LICENSE.

About

Local-first AI photo culling: burst clustering, blur/eye/aesthetic scoring, XMP writeback for Lightroom

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages