nsfwtag
Local, on-device NSFW image review and tagging for large photo libraries — built to feed Immich search and to complement the photo_analyzer.py captioning pipeline.
It scores a photo library with a local vision model (Apple GPU via MPS), opens a fast browser review app, and writes an nsfw (or sfw) keyword into each photo's EXIF for the ones you confirm — preserving every other tag. Nothing ever leaves the machine: no API calls, no uploads, no telemetry.
python -m nsfwtag "pictures/" -r
Table of contents
- The idea
- Features
- How it works
- Model choice
- Requirements & install
- CLI usage
- The review app
- EXIF / review-state model
- HTTP API
- Data & caching
- Project layout
- Design decisions
- Immich integration
The idea
A large personal photo library has a handful of sensitive images scattered through thousands of holiday snaps. To filter them in Immich you need a searchable marker on each one — but going through every photo by hand is infeasible, and sending a private library to a cloud moderation API is a non-starter.
The model does recall; you do precision. A local classifier is good at narrowing thousands of photos down to a few dozen candidates, but it is not trustworthy as the final word — it over-flags swimwear, art, medical shots. So this is a human-in-the-loop tool: the model proposes, you confirm in a fast visual review app, and only your confirmed decisions are written to disk. Nothing ever leaves the machine — no API, no upload, no telemetry.
The decision lives in the file's own EXIF as an nsfw/sfw keyword, not in a
side database, so it survives across tools, backups and restarts, and Immich indexes it
for free at ingest.
Features
- On-device scoring with
AdamCodd/vit-base-nsfw-detector(ViT, ~350 MB, downloaded once then fully offline). Chosen over Falconsai after benchmarking against the library — better recall, fewer false positives (seebench.py). - Resume-safe: scores are cached to a CSV; a rerun reuses them and only scores newly-added files — skipping the model load entirely when nothing is new.
- Browser review app (dark OLED UI, zero web dependencies — served by a stdlib HTTP server):
- Per-image checkmark to mark NSFW / SFW; frame colors show state at a glance.
- Folder browser sidebar, live search, a single Show filter (All / To-handle / NSFW / SFW), sort, NSFW-score threshold, and a contextual action group that appears only when images are selected.
- Lightbox with prev/next, keyboard control, and an EXIF panel (incl. a clickable Google Maps link for geotagged photos).
- Activity log tab with a per-file audit trail of every EXIF write.
- Lazy, per-folder tag scan with a progress ring.
- Reversible & non-destructive: tag, untag, or toggle at any time; only
Keywords/Subjectare modified, so Caption, dates, GPS, camera info, etc. are always preserved. - Persistent decisions: marking an image safe writes an
sfwkeyword, so your review survives restarts.
How it works
discover images score (cached) review in browser write EXIF
folder ─────────────────▶ [*.jpg …] ───────────────▶ nsfw_scores.csv ─────────────────▶ exiftool: Keywords/Subject += nsfw|sfw
(recursive, de-duped) supported local ViT on MPS (path,score) local HTTP server + review.html (all other tags preserved)
- Discover — walk the folder (optionally recursive), keep supported image types, de-duplicate by resolved path.
- Score — load cached scores; run the model only on files not in the cache; each image is resized to 224×224 and scored 0..1 for the
nsfwclass. Merge and persist. - Review — candidates (score ≥
--review-min) are served as a single-page app. A lazy per-folder scan reads existingnsfw/sfwkeywords and colors each card. - Tag — your actions POST to the local server, which calls
exiftoolto add/remove the keyword. EXIF is written before the file is later uploaded to Immich.
Model choice
The tool ships with AdamCodd/vit-base-nsfw-detector — a ~350 MB ViT binary
classifier that runs offline on Apple MPS. It was picked by benchmarking candidates
against your own library, using the photos you'd already hand-tagged nsfw/sfw
as ground truth (bench.py reads those EXIF marks). A generic public benchmark wouldn't
predict how a model behaves on these photos, so the comparison is run on the real data.
Candidates compared (nsfwtag/bench.py):
| Model | Notes |
|---|---|
| AdamCodd/vit-base-nsfw-detector ✅ | Chosen — best recall with the fewest false positives on this library |
| Falconsai/nsfw_image_detection | Previous default; missed more true positives and needed a high 0.85 cutoff |
| Marqo/nsfw-image-detection-384 | Tiny/fast, but noisier on borderline shots |
| OwenElliott/image-safety-classifier-xs | 3-class (SFW/NSFW/NSFL); useful signal, weaker overall here |
Selection criterion — recall first. Because a human confirms every candidate, a false
positive costs one extra glance, but a false negative means a sensitive photo silently
slips through untagged. So the model is judged on catching everything at a threshold low
enough to be safe without burying you in noise. AdamCodd's sweet spot is 0.6 (vs
Falconsai's 0.85) — that is the shipped default.
Reproduce it on any sample folder:
python -m nsfwtag.bench "pictures/SomeFolder" -r
# writes bench_scores.csv + bench_report.html (thumbnails, per-model scores,
# disagreements highlighted) and prints precision/recall vs your manual tags.
To switch models, change MODEL_ID in nsfwtag/__init__.py (any HF image-classification
id) and re-tune DEFAULT_THRESHOLD.
Requirements & install
- Python 3.10+
- exiftool on
PATH—brew install exiftool(macOS) /apt install libimage-exiftool-perl(Debian/Ubuntu) - Python packages:
pip install torch transformers pillow numpy - A GPU is optional — uses Apple MPS if available, else CPU.
The model downloads automatically on first run (~350 MB) and is cached in ~/.cache/huggingface; every run after that is offline (local_files_only=True, which also skips the slow HF Hub update check).
CLI usage
Run from the directory that contains the nsfwtag/ package:
# Score + open the review app
python -m nsfwtag "pictures/" # this folder only
python -m nsfwtag "pictures/" -r # recurse into subfolders
python nsfw_tag.py "pictures/" -r # identical (backwards-compat shim)
# Tag directly by score, no review UI
python -m nsfwtag "pictures/" -r --apply
# Tag every path listed in a text file (one path per line)
python -m nsfwtag confirmed.txt [more.txt ...]
| Flag | Default | Description |
|---|---|---|
-r, --recursive |
off | Descend into every subfolder |
--threshold |
0.6 |
Score ≥ this is treated as "NSFW to review" (and tagged directly in --apply) |
--review-min |
0.20 |
Lowest score shown in the review app |
--limit N |
0 |
Only the first N images (calibration) |
--apply |
off | Tag score ≥ threshold directly, skip the review UI |
--rescore |
off | Ignore the score cache and re-run the model on everything |
The argument is auto-detected: a folder opens the review app; one or more .txt files tag the listed paths directly.
The review app
Served on http://127.0.0.1:<random-free-port>/ and opened in your browser automatically. Press Ctrl-C in the terminal when done.
There are two independent channels per card: the decision (the EXIF tag, shown as the frame + tick color) and the selection (a blue ring, for bulk actions). A card can be both selected and decided.
Frame colors (the decision)
| Color | Meaning | EXIF |
|---|---|---|
| 🩷 pink | tagged NSFW | nsfw keyword |
| 🟢 green | tagged safe | sfw keyword |
| 🔴 red | undecided and selected — a bulk candidate, nothing written yet | none |
| ⚪ neutral | undecided, not selected | none |
| 🔵 blue ring | selected for a bulk action (independent of the frame color) | — |
Each card has a tick (bottom-right circle) that makes a per-image decision and writes EXIF on every click, cycling: undecided → NSFW (pink) → SFW (green) → undecided. One click is enough — it no longer depends on the selection state. Clicking elsewhere on a card does nothing (no accidental toggles).
Controls
The toolbar separates viewing (always visible) from acting (contextual). It reads left-to-right:
🔍 Search… Show ▾ Sort ▾ NSFW ≥ 0.6 ●───── Select flagged Select all ⟨ N selected · Deselect · Remove · Mark safe · Mark NSFW ⟩
- Folder browser (left sidebar, toggleable): filter to a folder + its subfolders; counts per folder.
- Search: filter by filename or folder.
- Show (single filter): All / To-handle (no keyword yet — what's left to decide) / NSFW (tagged nsfw) / SFW (tagged sfw).
- Sort: score / name / folder.
- NSFW ≥ slider: score threshold used by Select flagged and the red "identified" coloring.
- Select flagged: selects visible, untagged images at/above the threshold.
- Select all: selects every currently-visible image (respects the folder/filter). Pair with Deselect to clear. Use this to build a selection after a filter where Select flagged matches nothing.
- Per-image tick: one click writes EXIF and cycles undecided → NSFW → SFW → undecided; the tick and frame colors show the current decision.
- Action group (appears only when ≥1 image is selected): Mark NSFW / Mark safe / Remove (clears either keyword) / Deselect. Counts and actions are scoped to the current view (folder/filter), so the count matches what you see.
- Lightbox (⤢ on a thumbnail): full-res image, EXIF side panel (incl. Google Maps link),
←/→navigate,Spacetoggle selection,Esc/backdrop close. - Activity log (header button): timestamped, per-file record of every EXIF write.
EXIF / review-state model
The review decision is stored in the file's EXIF as one of two mutually-exclusive keywords, written to both Keywords (IPTC) and Subject (XMP):
| Keyword | Meaning |
|---|---|
nsfw |
confirmed NSFW |
sfw |
confirmed safe |
| (neither) | not yet reviewed |
Rules:
- Writing
nsfwremoves anysfw, and vice-versa — a file can hold at most one. - On read,
nsfwtakes precedence: a stray file with both is reported as NSFW, never as safe. - Only
KeywordsandSubjectare ever modified. All other metadata —ImageDescription/Caption, GPS, capture dates, camera make/model, ratings — is left byte-for-byte intact in both directions (add and remove). exiftoolruns with-overwrite_original(no sidecar_originalfiles) and-m(ignore minor warnings so writes still apply to slightly-nonconforming files).
Immich indexes Keywords, so tagged photos become searchable there (search nsfw).
HTTP API
Stdlib ThreadingHTTPServer bound to 127.0.0.1 on an OS-assigned free port. Every path argument is validated against the scanned candidate set — the server can't be pointed at arbitrary files.
| Method | Route | Purpose |
|---|---|---|
| GET | / |
The review page |
| GET | /img?path= |
Thumbnail bytes for a candidate image |
| GET | /exif?path= |
Filtered EXIF for the lightbox panel (decimal GPS + Google Maps URL) |
| GET | /log |
Activity-log entries [{t, m}, …] |
| POST | /tagged |
Body: [path,…] → {nsfw:[…], sfw:[…]} (batched keyword read) |
| POST | /apply |
Body: [path,…] → add nsfw (strips sfw) → {tagged:n} — "Mark NSFW" |
| POST | /mark?state=sfw |
Body: [path,…] → add sfw (strips nsfw) — "Mark safe" / checkmark→green |
| POST | /untag |
Body: [path,…] → remove both keywords (un-review) → {untagged:n} — "Remove" |
| POST | /mark?state=clear |
Body: [path,…] → remove sfw only (checkmark→red) |
| POST | /toggle |
Body: [path,…] → flip nsfw per file → {tagged:[…]} (endpoint retained; not used by the current UI) |
Data & caching
nsfw_scores.csv(written in the scanned folder):path,nsfw_score. This is the resume cache — reruns reuse it and only score new files. Delete it or pass--rescoreto force a full re-score.- Review state is not in the CSV; it lives in each file's EXIF (see above), so it persists independently of the score cache and survives restarts.
- The activity log is in-memory (last 2000 events) and resets when the server stops; messages are also echoed to stderr.
Project layout
nsfwtag/
├── __init__.py # constants (model id, extensions, defaults), warning suppression
├── exif.py # read/write/remove nsfw|sfw via exiftool; read_marks, read_exif (+GPS)
├── scoring.py # discover images, cached model scoring
├── webapp.py # render the page: fill review.html tokens (cards, folder tree, totals)
├── review.html # the frontend — HTML/CSS/JS (no build step, no dependencies)
├── server.py # local HTTP server + routes + activity log
├── __main__.py # CLI entry point (python -m nsfwtag)
└── README.md
nsfw_tag.py # thin shim: `python nsfw_tag.py` == `python -m nsfwtag`
The frontend lives in a real review.html (dynamic parts injected via %%TOKEN%% replacement) rather than a Python string, so it edits with normal HTML/CSS/JS tooling.
Design decisions
- No pre-selection at render. Cards render unselected; the lazy per-folder scan decides each card's state from its EXIF keywords (pink/green) or score (red). This is deliberate: it guarantees a photo you marked safe never reappears pre-selected-red after a restart, and keeps startup instant (the browser opens before any
exiftoolscan runs). - Lazy, chunked, per-folder scanning with a progress ring, instead of one blocking scan at startup — so switching into a folder shows real progress and huge libraries stay responsive.
- Score cache first, model second — the model (and its load) is skipped entirely on reruns with no new files.
- View-scoped counters/actions — Apply/Toggle/Remove and their counts act on the folder/filter you're viewing, so the number always matches what's on screen.
- Stdlib-only server + dependency-free frontend — nothing to build or install for the UI; the whole app is one HTML file plus a ~120-line Python server.
Immich integration
Order matters: write EXIF first, then upload. immich-go deduplicates on the file's SHA-1 content hash, so writing EXIF after upload would create duplicates. This tool only writes metadata in place; ingest into Immich is a separate, deliberate immich-go upload step. Once uploaded, Keywords (including nsfw) are searchable in Immich alongside its own ML pipeline. Do not also point Immich at the folder as an External Library.