Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

118 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Test License: MIT

Image Cull

Self-hosted, phone-shaped triage for Stable Diffusion output. Point it at the folder ComfyUI, A1111, or SwarmUI writes into, then swipe through the backlog — keep, trash, or star — against the real files on disk.

A long generation run leaves you with hundreds of images and no good way to sort them. A file explorer makes you open each one to see what prompt made it, and a grid of thumbnails from a 20-image batch of near-identical variants tells you almost nothing. Sorting them where they were generated also means sitting at that desk.

cull runs on the machine holding the images — a NAS, a spare box, your desktop — and serves an installable web app. One image fills the screen. Swipe right to keep, left to trash, tap to see the prompt and parameters. Later, search the whole library by prompt text, model, sampler, or tag.

Trash is a move, not a delete. Rejected files go to a _trash folder beside the originals and undo puts them back. cull never unlinks your images.

The triage stack on a phone: one image fills the screen, with up-next previews along the bottom and reject/star/undo/keep controls    The metadata sheet: prompt, negative prompt, tag suggestions pulled from the prompt, and the generation parameters

The same stack on desktop, with up-next previews in a column beside the viewer

Screenshots use a generated demo library, not real output.

Important

cull has no authentication and binds 0.0.0.0, so anything on your network can reach it. That is deliberate — it's built to sit on a Tailscale tailnet or home LAN, where the network is the boundary. Anyone who reaches the port can browse your library, read embedded prompts, and move files to _trash. Don't port-forward it. See SECURITY.md for the threat model and what to do if you need access from outside your home.

Contents

Quick start

Docker is the shortest path — it pins Python, Pillow, and the SQLite build for you.

git clone https://github.com/socrasteeze/image-cull.git
cd image-cull
cp config.example.yaml config.yaml
cp .env.example .env

Edit .env so CULL_RAW_DIR_HOST, CULL_STATE_DIR_HOST, and CULL_STARRED_DIR_HOST point at your library (editing docker-compose.yml directly still works too — .env just keeps your paths out of a git pull), then:

docker compose up -d --build

Open http://localhost:8080 (or http://<machine-name>:8080 from your phone on the same network). The first visit kicks off a background index; the swipe stack fills in as it goes.

Three things worth getting right the first time:

  1. config.yaml must exist before up. Docker creates a directory in its place otherwise, and startup fails with a confusing error.
  2. Mount the real output folder read/write. That is the canonical copy — cull moves files inside it, so a read-only mount breaks triage.
  3. Match APP_UID/APP_GID in the compose build.args to whoever owns those files (ls -n will tell you). The image runs non-root by default; "0" falls back to root, which is what most NAS volumes need.

Install it to your phone. Open the URL and use "Add to Home Screen" — it's a PWA, so it runs full-screen with an offline action queue. On iOS this must be done from Safari; every iOS browser is WebKit underneath, but only Safari can install a PWA.

If tapping keep/trash repeatedly makes the whole screen slide down and stay down, that's iOS Reachability, not the app — it can trigger from a double-tap of the home indicator area as well as the documented gesture, and has a separate binding under Accessibility → Touch → AssistiveTouch that's easy to forget about. Check both if Settings → Accessibility → Touch → Reachability is already off.

Without Docker
cd backend
python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install -r requirements.txt

CULL_RAW_DIR=/path/to/images CULL_MANIFEST_PATH=./.cull/manifest.json \
  CULL_ACTIONLOG_PATH=./.cull/actionlog.jsonl CULL_THUMB_CACHE_DIR=./.cull/thumbs \
  python -m app.main

deactivate when done; rm -rf .venv to remove it.

On Windows there are batch helpers in the repo root: start.bat (creates the venv on first run, pulls latest, installs deps, starts the server), stop.bat [port], restart.bat [port], and make-shortcut.bat to drop a cull shortcut with an icon on your Desktop.

Using it

Triage

One card at a time. Swipe right to keep, left to trash, or use the buttons. Tap the image for the metadata sheet — prompt, negative, model, sampler, seed, and the raw generation JSON.

Keeping leaves the file exactly where it is (the default) or moves it to a _keep folder, if you prefer an explicit destination — set keep_mode in config. Trashing moves it to _trash. Both are reversible, and cull remembers what you kept so it doesn't show it again.

On desktop, the arrows mirror the swipe directions, with letter keys layered on top:

Action Keys
keep K Enter
trash X Delete
undo Z Backspace
skip to end of stack
star F S
zoom 2× hold Space

/ route through swipe_map, so remapping a swipe direction in config remaps its arrow with it.

Search and tags

The search box queries prompt, negative prompt, model, sampler, and filename via SQLite FTS5 (with a LIKE fallback if your SQLite lacks FTS5). Tags are booru-style (lowercase, underscores) and searchable the same way. Search, tag, folder, and sort filters all combine.

Starring to your phone

Starring copies the image into a starred_dir — the original stays put, unlike keep and trash which move it. Point starred_dir at a folder your photo sync already watches (an iCloud for Windows folder, say) and starred images land on your phone's camera roll on their own.

Unstarring removes the copy, or only clears the flag if you set star_one_way: true — the right choice when your sync propagates deletes and you don't want an unstar reaching back into the phone.

Folders and batches

The 📁 panel browses your library tree and filters the stack to one or more folders, which is how you review a single batch without touching the rest. Multi-select, direct-children-only scope, and pinning a folder to the front of the full stack are all supported. Filtering never moves or re-indexes anything.

To keep folders out entirely, either list names in excluded_dir_names (matched by exact name, at any depth) or drop an empty .cullignore file into any folder to skip it and everything under it. cull's own _trash/_keep/_starred/_duplicates/.cull folders are reserved and always excluded in code, so trimming them from the config list can't index starred or quarantined copies back into the stack.

Duplicates

The indexer records every physical path per content hash, so when two paths share a hash they are byte-identical. The stack shows one card per hash with a copies count, and the ⧉ panel lists the groups.

Collapsing does not delete. One copy stays put (the "keeper") and the rest move to _duplicates, preserving each file's relative subpath so a false positive can be restored. Detection is automatic on re-index; moving anything is always something you initiate.

Action Meaning
Move extras (UI: "Collapse") Keep one file; quarantine the rest
Not duplicates (UI: "Keep both") Permanently ignore the group; both files stay
Quarantine → Restore Put the file back, and never flag that group again

The keeper today is the shortest relative path; largest-file-size and newest-mtime are planned, and you can already pick a keeper when collapsing in bulk. Perceptual-hash clustering for near-duplicates — re-encodes, resizes, light crops, none of which an exact hash catches — is the next significant feature; see TODO.md.

WD auto-tagger (optional)

Fills in tags for images that have no generation metadata and no existing tags — scraped references, screenshots, anything without an embedded prompt. Images with SD metadata are never sent to the tagger.

  1. Install the extra: pip install 'cull[tagger]'. For Docker, build with --build-arg INSTALL_TAGGER=true (or set it in the compose build.args) so onnxruntime and numpy are baked into the image.
  2. Download the ONNX model into your state dir:
    ./scripts/download_wd_model.sh /data/cull/models/wd
    Defaults to wd-eva02-large-tagger-v3 (best accuracy, ~1.3 GB, slower on CPU). For something lighter, pass a repo as the second argument, e.g. SmilingWolf/wd-swinv2-tagger-v3. The whole WD family (v1.4 through v3: convnext/vit/swinv2/eva02) shares a file layout and preprocessing, so any of them drop in with no other change.
  3. Enable it:
    auto_tag_enabled: true
    auto_tag_model_dir: /data/cull/models/wd
    auto_tag_threshold: 0.35
    auto_tag_max_tags: 30
    auto_tag_after_scan: true   # run automatically when indexing finishes

The 🏷 button opens the tagger panel with a live progress bar and Start/Stop. Stopping is durable and starting resumes: each tag is written as it is produced and the tagger only looks at images with no tags yet, so an interrupted pass picks up where it left off and never re-tags finished work.

skipped in the status counts corrupt or unreadable files — those don't halt a run or count as errors; error is reserved for genuine model-call failures.

Configuration

Copy config.example.yaml to config.yaml — it documents every option inline. Most options also have an environment-variable override named after them (raw_dirCULL_RAW_DIR), which is what the Docker setup uses; the mapping is explicit, so check backend/app/config.py if you need one that isn't listed there.

The settings you're most likely to touch:

Option What it does
raw_dir the library to triage (required)
keep_mode in_place (default) or move into keep_dir
swipe_map which direction means which verb
starred_dir where stars are copied; point it at a synced folder
store_backend indexed (default, SQLite + search) or filesystem
display_size long edge of the image the stack loads (2048; 0 = originals)
excluded_dir_names folder names to skip entirely, at any depth
auto_tag_enabled turn the WD tagger on
update_enabled allow the in-app update button (see below)

Tuning for a big library: index_cache_mb sets SQLite's RAM cache and index_workers the scan parallelism.

Running it on a NAS

This is what cull was built for. Two supported paths:

Docker Compose — as in the quick start. Note that volume mount conventions differ by vendor: TerraMaster (TOS) mounts at /Volume1 with a capital V, Synology at /volume1. Check with df -h before copying paths between boxes; a case mismatch silently mounts an empty directory.

nas-update.sh — for TerraMaster and anything else without git. It downloads the branch tarball over HTTPS, rebuilds the image, and restarts the container:

# one-time: settings live outside the repo, since the script rsyncs over itself
cat > /root/.cull-deploy.conf <<'EOF'
REPO="youruser/image-cull"
APP_DIR="/home/youruser/image-cull"
RAW_DIR="/volume1/yourshare/pictures"
STATE_DIR="/volume1/yourshare/.cull"
STARRED_DIR="/volume1/yourshare/starred"
EOF
chmod 600 /root/.cull-deploy.conf

# only if your fork is private: add a repo-read token
echo "ghp_yourtoken" > /root/.cull-token && chmod 600 /root/.cull-token

# every update:
sh nas-update.sh

config.yaml and the index persist across updates, so there's no re-scan. From Windows, nas-refresh.bat SSHes in and runs it remotely, making "pull, rebuild, restart" one double-click. Full setup — SSH keys, tokens, Docker details, and how to reuse the pattern for other apps — is in NAS_DEPLOY.md.

Updating from the app (optional). The ⚙ panel has an "Update from GitHub" button so you can update from your phone without SSH. cull can't run the update itself — the script removes the container serving the request — so a small watcher runs on the NAS:

# in config.yaml
update_enabled: true
# on the NAS, as root (it needs Docker), once:
nohup sh /home/youruser/image-cull/nas-update-watch.sh >/dev/null 2>&1 &

The app writes a request into the state dir, the watcher runs the update, and progress comes back the same way — the panel shows the live phase and log tail, then reloads itself onto the new build. Only one watcher may run at a time; a second exits and tells you which pid holds the lock.

Because this makes the NAS download and execute code, it is off by default. Everything else works without it.

Sync interaction, if your library is synced. cull only moves files on the canonical side (the one named in config), and _trash/_keep live inside the synced tree on that side. A cull therefore shows up to your sync tool as an ordinary move rather than a delete-vs-move race that would spawn conflict copies.

How it works

The file is the state

A file's location on disk is its triage status. That keeps cull in sync with whatever else touches the folder — moving something in Explorer is a first-class action, not drift to be reconciled.

  • trash moves the file into a _trash sibling (reversible)
  • keep leaves it in place, or moves it to _keep
  • an action log keyed on content hash records decisions, for undo and keep-in-place memory

The SQLite index powers search, tags, and duplicate detection, but triage is still real file moves. State with no natural home on disk — tags, favorites — lives in the index and action log behind the same interface.

Ordering and indexing

Indexing walks raw_dir recursively, hashes each image, parses SD metadata, and stores a lean row in SQLite. Re-scans only touch files whose mtime or size changed.

Item ids are content hashes, not paths (blake3 by default, xxh128 optional), so identity survives a move, rename, or reorg. Undo, history, and future lineage all key on it.

Stack order defaults to alphabetical by relative path; sort=mtime gives newest-file-first and sort=added newest-indexed-first. The cursor encodes the sort order and is written so SQLite can seek it, which keeps deep pagination flat instead of degrading as you go.

Cards load a WEBP capped at display_size rather than the original — over a tailnet that's the difference between a few hundred KB and several MB per card. Listing a page also warms the next page's renders in the background, so the encode is usually already paid by the time you swipe. The untouched original is one tap away in the metadata sheet.

The three seams

Everything downstream depends on these interfaces, not on concrete classes.

1. StoreInterface (backend/app/store/base.py) — where items come from and how they move.

list(cursor, limit) -> ListResult     # cursor-paged from day one
move(item_id, verb) -> MoveOutcome    # keep | trash | restore (idempotent)
read_metadata(item_id) -> Metadata
resolve(item_id) -> Path              # hash -> current path

FilesystemStore is a paged directory scan with atomic os.rename and a flat-file manifest, no search. IndexedStore (the default) backs the same methods with SQLite: indexed queries, FTS5 search, a files table tracking every physical path per hash, background indexing with resumable batched commits, and parallel hash+parse. Hash ids are identical across both, so switching backends keeps every id.

The index stays lean — only small searchable fields are stored, and bulky raw metadata like ComfyUI workflow JSON is re-read from the file on demand.

2. MetadataParser registry (backend/app/metadata/) — one parser per format.

detect(raw_chunk) -> bool
parse(raw_chunk)  -> dict

One each for comfy, a1111, and swarm. Detection sniffs PNG tEXt/iTXt keys plus the EXIF UserComment where A1111 puts parameters in JPEG/WebP exports. A new format is one new parser.

3. The action dispatcher (backend/app/actions/dispatcher.py) — one verb-dispatched endpoint rather than a route per verb. New verbs register without an endpoint change, and swipe direction maps to verb in config, not in code.

Layout

backend/app/
  config.py            operator config (YAML + env, validated at load)
  hashing.py           content hash (blake3 / xxh128)
  models.py            API request/response models
  walk.py              recursive image walk (excluded names + .cullignore)
  folders.py           folder-prefix filter helpers (SQL + python)
  list_cursor.py       opaque pagination cursor across sort orders
  tags.py              booru-style tag normalization + prompt suggestions
  updater.py           one-tap update handshake (writes a request; runs nothing)
  sample_index.py      dry-run index-time estimator
  store/
    base.py            StoreInterface
    filesystem.py      FilesystemStore (flat manifest, no search)
    indexed.py         IndexedStore (SQLite + FTS5 search, dups, tags)
    manifest.py        flat-file hash<->path + rescan cache
    actionlog.py       undo + keep-in-place memory (self-compacting)
    favorites.py       star/unstar copy logic (both backends)
  metadata/
    base.py            MetadataParser interface
    registry.py        detection + PNG chunk / EXIF UserComment reader
    comfy.py a1111.py swarm.py
  actions/
    dispatcher.py      the verb-dispatched endpoint
  tagger/
    wd.py              WD ONNX auto-tagger (optional extra)
  thumbnails.py        lazy, content-hash-keyed thumbnail cache
  main.py              FastAPI app + static PWA mount + auto-versioned sw.js
frontend/              installable PWA (vanilla JS swipe stack)
e2e/                   Playwright browser tests (seeded temp library)
UI_SPEC.md             Dark Studio UI spec the frontend implements

The frontend is vanilla JS with no build step — the backend serves frontend/ directly. It follows the Dark Studio spec in UI_SPEC.md: two theme tiers (Charcoal default, Black for OLED, switchable in ⚙) and decision colors reserved for keep and reject alone. On desktop up to six up-next previews stack in a column beside the viewer; on phones up to five sit along the bottom.

API

The API is the integration surface — everything the PWA does goes through it.

GET  /items?cursor=&limit=&q=&tag=&favorited=&folder=&folder_direct=&sort=&pin=
     -> { items: [{..., favorited, copies, tags}], next_cursor }
GET  /folders?parent=          -> { parent, total, direct, children: [{name, path, count}] }
GET  /items/{id}/metadata      -> { format, prompt, ..., tags, auto_tagged, suggested_tags }
GET  /items/{id}/thumb         -> cached WEBP thumbnail
GET  /items/{id}/display       -> WEBP capped at display_size (what the stack shows)
GET  /items/{id}/full          -> full-resolution image
POST /action  {item_id, verb, value?} -> { ok, item_id, new_location, verb, tags? }
     # verbs: trash | keep | restore | favorite | unfavorite | tag | untag
GET  /tags/known?limit=        -> { tags: [...] }   # most-used first, for autocomplete
POST /rescan?force=            -> reindex (background on the indexed backend)
GET  /index/status             -> { running, total, done, indexed, error }
POST /index/sample?count=      -> dry-run index ETA (times a sample, writes nothing)
GET  /anomalies                -> oversized files to shrink/delete -> { items, threshold_mb }
GET  /duplicates               -> { groups: [{id, copies, paths}] }   # exact dups
POST /duplicates/collapse-all  -> keep one per group, extras to _duplicates -> { groups, collapsed }
POST /duplicates/collapse      -> {ids, keepers?} collapse selected groups (keeper per hash)
POST /duplicates/ignore        -> {ids} mark groups as false positives (never flag again)
POST /duplicates/{id}/collapse -> collapse one group (keepers: bulk only today)
GET  /duplicates/quarantined          -> files in _duplicates awaiting review
POST /duplicates/quarantined/restore  -> {rel} restore a false positive (then ignored)
POST /duplicates/quarantined/purge    -> move the quarantine to _trash
# Planned: GET /near-duplicates, POST /near-duplicates/collapse, ... (see TODO.md)
POST /autotag                  -> start (or resume) a WD auto-tag pass (indexed backend)
POST /autotag/stop             -> pause a running pass; resume with POST /autotag
GET  /autotag/status           -> { running, total, done, tagged, skipped, pending, stopped, enabled }
POST /update                   -> ask the NAS-side watcher to run nas-update.sh
GET  /update/status            -> { enabled, watcher_ok, running, phase, ok, log_tail, ... }
GET  /config                   -> { swipe_map, verbs, keep_mode, thumb_size, auto_tag_enabled,
                                    update_enabled }
GET  /metrics                  -> { items, pending, kept, trashed, favorited, duplicate_groups }
GET  /healthz                  -> { status: "ok" }

Actions are idempotent: trashing an already-trashed item returns ok, not a 500. restore reverses the last move using the action log.

Development

See CONTRIBUTING.md for setup and conventions. The short version:

cd backend
python -m venv .venv
source .venv/bin/activate
pip install -e ".[test]"            # pytest + httpx + ruff
ruff check app tests
python -m pytest

Browser tests (Playwright) seed a temp library, boot the app, and drive keep/undo/trash/search/star:

cd e2e
npm install && npx playwright install chromium
npx playwright test

CI runs lint plus the backend suite on Python 3.11 and 3.12, a frontend syntax check, a Docker build, and the e2e suite on every push and PR to main.

Estimate index time before a big first run. Times a sample and projects the full-library ETA without writing anything:

python -m app.sample_index 5000
# in Docker: docker exec -it cull python -m app.sample_index

The sample may be OS-cached, so a cold first pass on spinning disks can run slower than the estimate.

Doing QA on a fresh pull? SMOKE_TEST.md is a step-by-step verification checklist with a sign-off table.

Roadmap

Phase Adds Status
1 Swipe cull, on-tap metadata, undo done
2 Prompt/param search (SQLite + FTS5) done
3 Stars, tags + WD auto-tagger, duplicate review stars, tags, exact dups done; near-dups planned; rating iceboxed
4 External integration open — the API is already the surface

Near-duplicate clustering is the next significant piece. Open decisions and the full backlog live in TODO.md.

License

MIT — see LICENSE. Use it, fork it, ship it; just keep the copyright notice.

Credits and third-party components

  • WD tagger models (SmilingWolf) — the optional auto-tagger downloads wd-eva02-large-tagger-v3 (or another model in the same family) from Hugging Face at setup time. The weights are not bundled here and carry their own license terms on the model card; review them before using the tagger in anything commercial.
  • FastAPI, Uvicorn, Pillow, and blake3 carry their own licenses; see backend/pyproject.toml for the dependency set.
  • Metadata parsing targets the formats written by ComfyUI, AUTOMATIC1111, and SwarmUI. cull is not affiliated with any of them.

About

Self-hosted, phone-shaped triage for Stable Diffusion output. Swipe to keep or trash against the real files, search by prompt, star to your camera roll.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages