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> unless --files-from Folder (recursive) or a single file to analyze

Options

Option Default Description
--files-from <list> — Analyze exactly the files listed in a text file, one path per line (# comments allowed). Missing or non-image paths are reported as errors. Not combinable with --dest or --mark
--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
--mark off Record the verdict as a triage mark instead of moving anything
--mark-label <name> reject Semantic label for rejects: reject/select/review/second-pass
--mark-keepers <name> — Also label the keepers
--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> logical CPUs, capped at 16 Max parallel analyses
--no-cache off Re-measure every frame instead of reusing what a previous run worked out (shoots cache)
--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 Deprecated — review the verdicts in the shoots app instead (below); still works inside the shell

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
(nothing) Report only. Nothing is moved or copied.
--mark The verdict is recorded, not acted on. No file moves.
--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.

Marking instead of moving

Relocating rejects reorganizes your catalog to record a decision. --mark records the decision and leaves the catalog alone:

shoots cull ./shoot --mark --mark-keepers select
482 analyzed @ threshold 100: 431 sharp (18 rescued), 51 blurry, 0 failed
marked 482 files (rejects 'reject', keepers 'select') — `shoots develop edit` or `shoots triage apply` writes them into sidecars

Nothing is written next to the photographs. The verdict waits under ~/.shoots/triage until a sidecar writer picks it up and renders it as a colour label in your editor's own vocabulary — see triage.

This is what composes: rate --mark afterwards adds stars to the same records instead of fighting over the same sidecar, and develop edit writes all of it out at once.

The two are not exclusive. --mark --dest <dir> both records the verdict and moves the rejects; the marks follow the files to their new location.

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

Mark instead of moving (composes with rate and develop)

shoots cull ./shoot --mark
shoots rate ./shoot --mark
shoots develop edit ./shoot        # writes crs + label + stars in one sidecar

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

Reviewing the verdicts

A cull verdict is a proposal, not a decision: a soft frame can be soft on purpose. The review lives in the shoots app, which you open from Lightroom Classic with Library › Plug-in Extras › Open shoots:

  1. Open the folder in Library and choose Find blurry. The focus-aware analysis runs over the whole folder and nothing is written.
  2. Frames the focus rescue kept (soft overall, sharp in one area, as with a wide aperture) are marked Review, and Review walks you through only those.
  3. Every verdict can be changed. Open any photograph and flag it yourself: Pick a frame shoots rejected, Reject one it kept, or leave it Unflagged.

The cull is optional: any photograph can be flagged, rated and given a colour label by hand, with or without it. What you set always wins over what shoots proposed, field by field, and Use the cull's verdict / Use shoots' rating hand a field back.

The viewer shows the global score, the focus peak, the aperture and a focus map over the photograph (soft → sharp) marking where focus actually landed. The keys are Lightroom's:

Key Action
P (or K) Pick
X (or D) Reject
U Unflag
1–5 Stars; 0 removes them
6 7 8 9 Red, yellow, green, blue label; the same key again removes it. Purple is on the panel.
S Skip — decide later
H Show or hide the focus map
← → Previous / next photograph
Esc Finish

Your marks are kept on this computer, survive a re-run of the analysis, and reach Lightroom with Send to Lightroom. It works the other way too: flags, stars and labels you change in Lightroom show up in the app within seconds, and the latest change wins.

Reset folder… (and Reset this photo… in the viewer) forgets any of: the blur check, shoots' ratings, your marks, the send history. A reset happens in the app only — photos and Lightroom are not touched, and anything already sent stays in the catalog. Settings › Clear app memory forgets everything at once.

--review in the shell is deprecated. It still works inside the interactive shell (/cull <path> --review --mark), and is removed together with the shell. In batch mode it exits 2 with a pointer to the shoots app.


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",
      "focusMap": { "cols": 8, "rows": 8, "tiles": [412, 388, 97, "…"] }
    }
  ],
  "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
focusMap Sharpness per tile, row-major, turned upright like the photograph — where focus landed
relocated Present only with --dest. planned: true under --dry-run.
marked Present only with --mark: the labels used and how many records were written.

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

  • triage — where --mark puts the verdict, and how it gets out
  • 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