shoots cull

DocsCommands

shoots cull

Focus-aware blur detection over a folder of RAW/JPEG/PNG/TIFF files. Reports sharpness, and optionally relocates the out-of-focus rejects out of your catalog.

shoots cull <path> [options]

This is classic computer vision, not ML: Laplacian variance plus a tile-based focus map. It is fast, deterministic and needs no model.


Arguments

Argument Required Description
<path> yes Folder (recursive) or a single file to analyze

Options

Option Default Description
--threshold <n> 100 Global Laplacian variance below this = blurry
--focus-threshold <n> 250 Keep a globally-soft frame if its sharpest region scores above this
--no-focus-rescue rescue on Disable the shallow-DoF rescue; classify purely on the global score
--dest <dir> Relocate blurry rejects here, mirroring the source structure
--copy move Copy rejects to --dest instead of moving them
--format <fmt> json Report format: json or csv
--out <file> stdout Write the report to a file
--concurrency <n> 4 Max parallel analyses
--dry-run off Analyze and report, but relocate nothing and write no report file
--json off Machine-readable JSON on stdout
--verbose off Verbose logging on stderr
--review off Interactive human-in-the-loop review — shell only

How the scoring works

The global score

Each frame is reduced to a single Laplacian variance — a standard measure of edge energy. Lots of crisp edges → high variance → sharp. Motion blur or missed focus smears edges → low variance → blurry.

RAW files are scored from their embedded JPEG preview (extracted with exiftool), so no demosaicing is involved. The JSON reports which pixels were used as pixelSource.

Why a global score alone is wrong

A single global number misjudges wide-aperture work. A shallow-depth-of-field portrait at f/1.4 is mostly bokeh — the global variance is low even though the eyes are tack-sharp. Cull naively and you throw away your best frames.

The focus rescue

Alongside the global score, cull builds a focus map over a tile grid and takes a robust peak: the sharpness of the sharpest region in the frame.

A frame whose global score is below --threshold is still kept as sharp (marked sharp*, rescued: true) when that focus peak clears --focus-threshold.

The reasoning is simple: a motion-blurred or missed-focus frame is soft everywhere. If any region is genuinely sharp, focus landed somewhere on purpose.

The rescue only ever moves a frame blurry → sharp, never the reverse. Disable it with --no-focus-rescue.

Aperture context

When exiftool is available, the report carries each frame's FNumber. A low global score at f/11 means something very different than at f/1.4. This is report context only — it never influences the verdict. If exiftool is unavailable the column is simply omitted rather than failing the cull.


What moves where

You pass What happens
(no --dest) Report only. Nothing is moved or copied.
--dest <dir> Blurry rejects are moved into <dir>; keepers stay put.
--dest <dir> --copy Rejects are copied; originals stay in place too.

Keepers — including sharp* rescues — are never touched. Nothing is ever deleted.

Structure is mirrored, not flattened

<catalog>/2026-07-19/x.cr3   →   <dest>/2026-07-19/x.cr3
<catalog>/2026-07-20/y.cr3   →   <dest>/2026-07-20/y.cr3

A catalog/date layout survives into the rejects pile, so you can inspect (or restore) rejects with their context intact.

--dest is excluded from the scan

If --dest sits inside the target folder, it is filtered out of the scan — a second run will not re-analyze already-relocated rejects.


Tuning the thresholds

Thresholds are camera-, lens- and subject-dependent. There is no universal value. The workflow is: report first, look at the numbers, then set a threshold.

# 1. Report only, as CSV, so you can look at the distribution
shoots cull ./raw --format csv --out scores.csv

# 2. Inspect
sort -t, -k2 -n scores.csv | head -30     # the softest frames

# 3. Pick a threshold that puts the boundary where you'd put it, then apply
shoots cull ./raw --threshold 85 --dest ./rejects --dry-run

Rough starting points:

Situation --threshold --focus-threshold
Default 100 250
High-res bodies (45MP+), lots of detail 120–180 300+
Shallow DoF portrait / wide-open work 60–100 200–250
Low-light, high-ISO (noise inflates variance) higher higher
Deep-DoF landscape (rescue rarely needed) 100–150 --no-focus-rescue

Examples

Report only

shoots cull ./raw
verdict       score       focus    aper  file
sharp        412.83      891.20    f/2.8  D:/raw/IMG_0001.CR3
sharp*        78.14      623.55    f/1.4  D:/raw/IMG_0002.CR3
blurry        31.02       44.18    f/2.8  D:/raw/IMG_0003.CR3

482 analyzed @ threshold 100: 431 sharp (18 rescued), 51 blurry, 0 failed
  sharp* = subject in focus despite a soft frame (shallow DoF), kept via --focus-threshold 250

CSV report to a file

shoots cull ./raw --format csv --out cull-report.csv
file,score,focus_peak,verdict,rescued,aperture,pixel_source
D:/raw/IMG_0001.CR3,412.83,891.2,sharp,false,2.8,embedded-preview
D:/raw/IMG_0002.CR3,78.14,623.55,sharp,true,1.4,embedded-preview
D:/raw/IMG_0003.CR3,31.02,44.18,blurry,false,2.8,embedded-preview

Clean the catalog: move rejects out

shoots cull ./catalog --dest ./rejects --dry-run
(dry run) 51 rejects would be moved into D:\rejects (mirroring structure); keepers stay put
shoots cull ./catalog --dest ./rejects
moved 51 rejects into D:\rejects (mirroring structure); keepers left in place

Copy instead of move (keep the catalog complete)

shoots cull ./catalog --dest ./review-blurry --copy

Tuned for wide-open portrait work

shoots cull ./portraits --threshold 70 --focus-threshold 220 --dest ./rejects

Strict: no rescue, global score only

shoots cull ./raw --no-focus-rescue --threshold 150 --dest ./rejects

Fast, on many cores

shoots cull ./raw --concurrency 12 --json --out cull.json

Interactive review (--review)

Available only inside the interactive shell — it drives a live UI. In batch mode it exits 2 with a pointer to the shell.

❯ /cull ./catalog --review --dest ./rejects

The flow is designed to not waste your time:

  1. The focus-aware analysis runs over the whole folder.
  2. Confident rejects are relocated immediately. Keepers stay in place.
  3. You are handed only the uncertain shallow-DoF rescues — one card at a time.

Each review card shows the global score, the focus peak, the aperture, and a focus heatmap with a legend (soft → sharp) marking where focus actually landed.

Key Action
K Keep — the file stays where it is
D Discard — relocate it to --dest
P Preview — open the frame in your system image viewer
S Skip — decide later
Esc Finish the review

Requirements and flags:

  • --dest is required (that is where discards go).
  • --copy copies discards instead of moving them.
  • --dry-run walks the entire flow without touching a single file — ideal for learning the interface.

Every other flag behaves identically in batch, so shoots cull stays fully scriptable. Only the review UI is interactive.


JSON output

{
  "command": "cull",
  "threshold": 100,
  "focusThreshold": 250,
  "focusRescue": true,
  "dryRun": false,
  "results": [
    {
      "file": "D:/raw/IMG_0002.CR3",
      "score": 78.14,
      "focusPeak": 623.55,
      "verdict": "sharp",
      "rescued": true,
      "aperture": 1.4,
      "pixelSource": "embedded-preview"
    }
  ],
  "errors": [],
  "relocated": {
    "dest": "D:\\rejects",
    "mode": "move",
    "count": 51,
    "planned": false
  },
  "summary": { "total": 482, "sharp": 431, "blurry": 51, "rescued": 18, "failed": 0 }
}
Field Meaning
score Global Laplacian variance
focusPeak Robust peak of the tile focus map — the sharpest region
verdict "sharp" or "blurry" (post-rescue)
rescued true when the focus rescue flipped it to sharp (sharp* in the table)
aperture EXIF FNumber, or null when unavailable
pixelSource Which pixels were scored — e.g. embedded-preview for RAW
relocated Present only with --dest. planned: true under --dry-run.

Useful queries

# List the rejects
shoots cull ./raw --json | jq -r '.results[] | select(.verdict=="blurry") | .file'

# List only the rescues — the frames worth eyeballing
shoots cull ./raw --json | jq -r '.results[] | select(.rescued) | "\(.score)\t\(.focusPeak)\t\(.file)"'

# Reject rate as a CI gate
rate=$(shoots cull ./raw --json | jq '.summary.blurry / .summary.total')

Exit codes

Code When
0 Analysis completed with no per-file failures
1 At least one file failed to analyze or relocate
2 Invalid --threshold / --focus-threshold / --format, --out points at a directory, or --review used outside the shell

See also

  • rate — aesthetic and star ratings (ML), complementary to blur culling
  • Interactive shell — where --review lives
  • Recipes — culling inside a full workflow

Edit this page on GitHub