A local-first desktop app for culling wildlife photography. Import a card, decide what to keep, identify what you photographed, and get the keepers out — without uploading anything or moving your originals.
Built for one photographer, one machine, one library. No accounts, no cloud, no telemetry. The catalog is a SQLite file you own.
Reject never deletes. Verdicts are marks; removing a file from disk is a separate, deliberate act behind a dry run and an exact confirmation string.
Status. A personal tool, shared in case it is useful. Developed and tested on Windows against a Nikon D850 library of a few thousand frames. The worker is portable Python, but the desktop shell has only ever been built and run on Windows — macOS and Linux are unverified rather than unsupported.
Bug reports are welcome. Feature requests are too, when they suit the workflow and keep the safety guarantees intact — see CONTRIBUTING.md for what that means in practice. Fixes may be slow.
- Import a card without moving or copying originals — only a compressed preview enters the library. RAW files use the embedded JPEG; nothing is demosaiced.
- Cull with the keyboard, the way Lightroom does it: arrow keys to walk,
pkeep,xreject,uunrate,1-5to rate and0to clear,ffavourite,ccolour label,lfor a 1:1 loupe,/to search,Ctrl+Zto undo.j/kwork too. Every cull key is rebindable in Settings. - Compare a burst: every frame on screen at once with pan and zoom synced across them. Mark frames one at a time, or mark the keepers and reject the rest in a stroke, or flag only the duds and keep the rest.
- Outings — the library groups by capture day, so a session is a trip rather than a wall of thumbnails.
- Identify species with a local vision model or an API key — optional, see Identification; typing names by hand works fine too.
- Life list — one plate per species, your best frame of it, numbered in the order you first saw them.
- Slideshow — full-screen review of the keepers (or the rejects, for a second opinion) in the current view.
- Export keepers to a folder with a metadata CSV, then optionally offload the originals once the copy is verified.
The library groups by outing. A burst collapses to one cell with the rest stacked behind it, and the panel carries the identification, the field marks and what the focus score thought.
Compare puts every frame of a burst on one wall, pan and 1:1 zoom synced across all of them. Mark frames one at a time, or mark the two worth having and reject the rest in a stroke.
The life list: one plate per species, numbered in the order you first saw them. The automatic pick is a starting point — click a plate to open that frame and overrule it.
Everything carrying GPS, on a basemap dimmed to match (there is a light one for reading terrain). A place lists the different species found there rather than the same one twenty times.
This feature is optional and the app is fully useful without it. Culling, bursts, outings, the life list, export — none of it needs a model. Without one, type the common and scientific name into the detail panel, which is what you'd be doing from a field guide anyway. The life list fills up either way.
If you do want it, there are two paths.
Free, private, and nothing leaves your machine. Install Ollama, then pull any vision model:
ollama pull llama3.2-vision # the default; ~8GB, needs a reasonable GPU
ollama pull moondream # ~1.7GB, runs on modest hardware
ollama pull llava # ~4.7GB, a middle optionSet which one under Settings → Identify, or per library in
<library>/identify.json. Any Ollama model that accepts images works — larger
ones are better at species, smaller ones are faster and run on less.
Settings → Identify → xAI, and paste a key. The preview image is sent to the provider; nothing else is. The key is stored in your library folder, not in this repository.
Vision models are a first guess, not an authority. They are reasonably good at common, distinctive species and confidently wrong about hard ones — subspecies, juveniles, anything where the field marks are subtle. Everything a model writes is editable, and low-confidence results are worth treating as a prompt to look it up rather than an answer.
Download Field-Catalog-x.y.z-x64-setup.exe from the
latest release and run it.
The Python worker is bundled, so nothing else needs installing.
Windows will warn that the installer is unsigned — More info → Run anyway.
It installs per-user, so there is no admin prompt, and it lands in
%LOCALAPPDATA%/Field Catalog with a Start menu entry. A newer installer
upgrades in place and leaves your library alone — close the app first, because
Windows cannot replace a running executable.
Requirements: Windows. Optionally exiftool on
PATH for better RAW GPS and metadata, and — only for automatic species
identification — Ollama with a vision model or an xAI API
key. See Identification; it is optional.
Needs Python 3.10+, Node 20+ and Rust.
git clone https://github.com/reedos/field-catalog.git
cd field-catalog
python -m venv .venv
.venv/Scripts/activate # Unix: source .venv/bin/activate
pip install -e ".[dev]" # ".[raw]" adds rawpy for NEF thumbnails
npm install && npm --prefix ui installRun the desktop app with Run Field Catalog.bat, or:
npm run tauri -- dev # desktop window
npm run dev # browser-only UI loop (scripts/dev-browser.bat)Your library lives at %USERPROFILE%/FieldCatalog ($FIELDCATALOG_LIBRARY to
put it elsewhere): catalog.sqlite, previews/, backups/, audit.jsonl.
Nothing is written anywhere else.
npm run build:installerThat builds the standalone worker with PyInstaller and bundles it into an NSIS
installer under src-tauri/target/release/bundle/nsis/. The worker exe is a
build artifact, so the bundling settings live in
src-tauri/tauri.bundle.conf.json and are merged only for that command — a
fresh clone can run tauri dev without building the worker first.
To cut a full release — version bump across all four files, tests, build, tag,
and a GitHub release with the installer attached — use
scripts/release.ps1 -Version x.y.z.
All catalog logic lives in a Python CLI that prints JSON on stdout. The desktop app is a thin shell over it, and everything the app can do is scriptable.
fieldcatalog --library ~/FieldCatalog init
fieldcatalog --library ~/FieldCatalog import --source /path/to/card
fieldcatalog --library ~/FieldCatalog list --summary
fieldcatalog --library ~/FieldCatalog set-verdict --id <id> --verdict reject
fieldcatalog --library ~/FieldCatalog bursts
fieldcatalog --library ~/FieldCatalog identify --id <id>
fieldcatalog --library ~/FieldCatalog export-originals --dest /path/to/handoff
fieldcatalog --library ~/FieldCatalog backup
fieldcatalog --library ~/FieldCatalog doctor --fixEvery command prints {"ok": true|false, ...}. Progress goes to stderr, so
stdout stays parseable. --pretty indents it for reading by hand.
fieldcatalog serve is the persistent mode the desktop app uses: one JSON
request per stdin line, one response per line, matched by id.
Two steps, always, and the first one is a rehearsal:
# 1. See exactly what would go, with paths and sizes
fieldcatalog --library ~/FieldCatalog delete-originals --ids id1,id2 --confirm DELETE_ORIGINALS
# 2. Do it
fieldcatalog --library ~/FieldCatalog delete-originals --ids id1,id2 --confirm DELETE_ORIGINALS --executeKeepers already copied elsewhere use offload-originals with
OFFLOAD_ORIGINALS. Both route through the recycle bin unless --permanent is
passed, back the catalog up first, and append to audit.jsonl.
These are safety invariants, not preferences. They are enforced in the worker, not just the UI.
- Reject is not delete. A verdict marks a shot; it never removes a file.
- Never invent GPS. Coordinates come from file EXIF only. A typed place is a label, and it never overwrites coordinates the camera recorded. There is no default location.
- Dry run before execute.
--executeis refused without the exact confirmation string, and the UI never offers it without showing the file list first. - Delete expects
reject, offload expectskeep, overridable only with an explicit--allow-any-verdict. - Previews always survive. The worker refuses to unlink a preview, a file inside the preview folder, the database, or a missing original.
- Originals stay where they are. Import copies nothing but a preview; export copies rather than moves.
- The catalog is backed up before every executed removal, and a failed backup aborts the operation.
pytest -q # worker tests
npm --prefix ui run build # typecheck and build the UI
cargo test --manifest-path src-tauri/Cargo.tomlpytest sets pythonpath = ["src"], so it runs from the repo root without
installing. Use the project .venv rather than a system Python — the tests
need send2trash.
BACKLOG.md tracks what is known-broken and known-missing. CLAUDE.md briefs coding agents on the conventions and invariants.
Functional Source License 1.1, MIT Future (FSL-1.1-MIT).
In plain terms: use it for anything you like, including your paid work. Read it, modify it, run it on client shoots, share your changes. The one thing you may not do is turn it into a competing commercial product.
Each release becomes plain MIT two years after it is published, automatically and irrevocably — so nothing here is ever locked away for good.
Source-available rather than open source, deliberately: the commercial rights stay with the author for now.
GitHub's sidebar reports this as "Other". Its detector only matches the standard open source licenses, and none of those restrict commercial competition — so any license that does what this one does shows up the same way. The terms are in LICENSE.md and at fsl.software.



