Shot 2,000 frames at an event? Point this at the folder and it throws out the soft, shaky, and blown-out ones — then optionally ranks what's left by how good it looks — so you start editing from the keepers instead of the dump.
A command-line pipeline that culls large batches of camera RAW files (primarily
Canon .CR3/.CR2, plus .ARW/.DNG/.NEF) — and standard JPEG/PNG images — on two
independent axes:
- Technical quality — focus (Tenengrad), motion blur (FFT), exposure clipping.
- Aesthetic quality — a pretrained NIMA neural score trained on the AVA dataset (~1–10, higher is better).
Both passes can coordinate through a shared analysis_log.csv and physically
move keepers into subdirectories. The aesthetic pass uses a GPU (CUDA) when one
is available and falls back to CPU otherwise.
The log is optional: it is created on demand and never required. Every
command runs standalone — aesthetic-score builds the log itself (recording
files the technical pass never logged), technical --no-log skips it entirely,
and cull-from-log simply reports that there's nothing to do if no log exists.
The technical pass has no heavyweight dependencies:
uv syncThe aesthetic pass is optional and lives behind the aesthetic extra,
because it pulls in pyiqa + the
PyTorch stack:
uv sync --extra aesthetic # or: pip install 'winnow[aesthetic]'The pretrained NIMA weights are downloaded and cached automatically by pyiqa
on first use (into ~/.cache/torch/hub/pyiqa), so the first aesthetic-score
run needs network access; subsequent runs are fully offline.
License note: pyiqa and its AVA-trained NIMA weights are under a noncommercial license (PolyForm Noncommercial 1.0.0 + NTU S-Lab). The core technical-culling pipeline is unaffected — only the optional aesthetic pass inherits that restriction.
After uv sync, the pipeline is available as the winnow command (or via
python main.py / python -m winnow.cli):
uv run winnow --help| Command | What it does |
|---|---|
technical DIR |
Compute focus/shake/exposure for every supported image (RAW + JPEG/PNG), append to DIR/analysis_log.csv (skip with --no-log), and move sharp keepers into DIR/keepers. |
aesthetic-score DIR |
Write NIMA scores into the log's aesthetic column (plus a perceptual hash for dedupe), creating/extending the log as needed (resumable — skips already-scored files). |
aesthetic-filter DIR |
Move the top --top-percent N (default 10) images into DIR/aesthetic_keepers, the rest into DIR/others. Use --threshold T for a fixed cutoff instead. Add --dedupe to first collapse near-duplicate frames (see below). |
cull-from-log DIR |
Re-run the keep/reject decision from logged metrics without re-decoding RAWs — use this to tune thresholds. |
prepare-log |
Add the aesthetic column to a pre-existing log. |
Examples:
uv run winnow technical ./data
uv run winnow technical ./data --min-focus 400 --min-shake 18
uv run winnow aesthetic-score ./data
uv run winnow aesthetic-filter ./data --top-percent 5
uv run winnow aesthetic-filter ./data --threshold 6.5
uv run winnow aesthetic-filter ./data --top-percent 5 --dedupe
uv run winnow cull-from-log ./data --focus-gt 350 --shake-gt 19All thresholds have sensible defaults (see winnow/config.py); every one
is overridable via flags.
aesthetic-filter --dedupe collapses visual near-duplicates — burst frames,
brackets, minor re-crops — before the percentile/threshold decision. Each
image gets a perceptual difference hash;
images whose hashes differ by at most --hash-threshold bits (default 5 of
64) are grouped, and only the highest aesthetic score in each group is kept.
The losers are moved to DIR/duplicates and excluded from the percentile math,
so a 30-frame burst counts once — as its single best frame — rather than
crowding out the rest of the shoot.
# Keep the top 5% of unique shots; larger threshold = more aggressive grouping.
uv run winnow aesthetic-filter ./shoot/keepers --top-percent 5 --dedupe --hash-threshold 6The hash uses only Pillow + numpy (core deps), so --dedupe works even without
the optional aesthetic extra's model download already in place — though
scoring itself still needs it.
aesthetic-score also records each image's perceptual hash into the log's
hash column (as a 0x-prefixed hex string), so it is captured once during
scoring and available for inspection or downstream tooling.
$ uv run winnow technical ./shoot
Analyzing 1,842 *.CR3 files...
Scoring: 100%|████████████████████████| 1842/1842 [04:11<00:00, 7.3it/s]
Kept 1,196 sharp frames -> shoot/keepers (646 rejected: soft/shaky/clipped)
Wrote shoot/analysis_log.csv
$ uv run winnow aesthetic-filter ./shoot/keepers --top-percent 10
Loading NIMA model...
Critiquing 1196 files...
Scoring: 100%|████████████████████████| 1196/1196 [02:38<00:00, 7.5it/s]
--- Best Image Found ---
File: 3B9A4471.CR3
Aesthetic Score: 6.83
--- Analysis Summary ---
Calculated Threshold for Top 10%: 5.71
Moving 120 files to 'aesthetic_keepers', 1076 to 'others'.
winnow/
config.py # thresholds, file patterns, log schema (single source of truth)
io_utils.py # RAW/image decode + file discovery
metrics.py # technical metrics (Tenengrad, FFT shake, exposure)
nima.py # NimaEstimator — pretrained NIMA via pyiqa (optional extra)
technical.py # technical culling pass
aesthetic.py # aesthetic scoring + percentile/threshold filters
logtools.py # log helpers (prepare, re-cull from log)
cli.py # `winnow` argparse entry point
main.py # thin CLI shim