Bootstrap project and safe work-item workflow
This commit is contained in:
282
nsfwtag/README.md
Normal file
282
nsfwtag/README.md
Normal file
@@ -0,0 +1,282 @@
|
||||
# nsfwtag
|
||||
|
||||
Local, on-device NSFW image review and tagging for large photo libraries — built to feed [Immich](https://immich.app) 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](#the-idea)
|
||||
- [Features](#features)
|
||||
- [How it works](#how-it-works)
|
||||
- [Model choice](#model-choice)
|
||||
- [Requirements & install](#requirements--install)
|
||||
- [CLI usage](#cli-usage)
|
||||
- [The review app](#the-review-app)
|
||||
- [EXIF / review-state model](#exif--review-state-model)
|
||||
- [HTTP API](#http-api)
|
||||
- [Data & caching](#data--caching)
|
||||
- [Project layout](#project-layout)
|
||||
- [Design decisions](#design-decisions)
|
||||
- [Immich integration](#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](https://immich.app) 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 (see `bench.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`/`Subject` are modified, so Caption, dates, GPS, camera info, etc. are always preserved.
|
||||
- **Persistent decisions**: marking an image safe writes an `sfw` keyword, 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)
|
||||
```
|
||||
|
||||
1. **Discover** — walk the folder (optionally recursive), keep supported image types, de-duplicate by resolved path.
|
||||
2. **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 `nsfw` class. Merge and persist.
|
||||
3. **Review** — candidates (score ≥ `--review-min`) are served as a single-page app. A lazy per-folder scan reads existing `nsfw`/`sfw` keywords and colors each card.
|
||||
4. **Tag** — your actions POST to the local server, which calls `exiftool` to 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:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
# 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, `Space` toggle 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 `nsfw` removes any `sfw`, and vice-versa — a file can hold **at most one**.
|
||||
- On read, **`nsfw` takes precedence**: a stray file with both is reported as NSFW, never as safe.
|
||||
- **Only `Keywords` and `Subject` are 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).
|
||||
- `exiftool` runs with `-overwrite_original` (no sidecar `_original` files) 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 `--rescore` to 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 `exiftool` scan 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.
|
||||
Reference in New Issue
Block a user