US02-01: Extract Safety and Existing Web UI Donors (#54)

This commit was merged in pull request #54.
This commit is contained in:
2026-07-15 22:37:47 +02:00
parent 19d4ce3785
commit 22558a4efa
12 changed files with 441 additions and 24 deletions

View File

@@ -0,0 +1,5 @@
"""External-tool adapters (exiftool, vision, NSFW model, immich-go).
Integrations own all subprocess and network I/O; services depend on these
interfaces, never on the archived CLI entry points.
"""

View File

@@ -0,0 +1,70 @@
"""exiftool adapter: read and apply EXIF keywords.
Only the ``Keywords`` and ``Subject`` fields are read or written, so every other
tag (caption, dates, GPS, camera, ratings) is preserved. Reading is batched over
stdin; writing uses the idempotent ``-=``/``+=`` pattern so re-running never
duplicates a keyword.
Extracted from nsfwtag.exif (_read_keywords / write_keyword / remove_keyword) and
photo_analyzer's batched keyword read (donor_ledger.yaml: nt-exif-marks,
nt-apply-list, pa-nsfw-filter). No dependency on the archived entry points.
"""
from __future__ import annotations
import json
import os
import subprocess
from collections.abc import Iterable
def read_keyword_sets(paths: Iterable[str]) -> dict[str, set[str]]:
"""Map each path to its lowercased set of ``Keywords`` + ``Subject`` values.
One batched exiftool call (paths fed via stdin). Returns ``{}`` if exiftool is
unavailable — callers decide how to fail (analysis fails open, safety fails safe).
"""
paths = list(paths)
if not paths:
return {}
want = {os.path.normpath(p): p for p in paths}
try:
result = subprocess.run(
["exiftool", "-m", "-j", "-Keywords", "-Subject", "-@", "-"],
input="\n".join(paths),
capture_output=True,
text=True,
)
except FileNotFoundError:
return {}
out: dict[str, set[str]] = {}
try:
records = json.loads(result.stdout or "[]")
except ValueError:
return {}
for record in records:
values: list = []
for field in ("Keywords", "Subject"):
value = record.get(field)
if isinstance(value, list):
values += value
elif value is not None:
values.append(value)
key = want.get(os.path.normpath(record.get("SourceFile", "")))
if key is not None:
out[key] = {str(v).strip().lower() for v in values}
return out
def apply_keywords(path: str, *, add: Iterable[str] = (), remove: Iterable[str] = ()) -> bool:
"""Idempotently add/remove keywords in Keywords + Subject; preserve all else."""
args = ["exiftool", "-m", "-overwrite_original"]
for kw in remove:
args += [f"-Keywords-={kw}", f"-Subject-={kw}"]
for kw in add:
# remove-then-add makes the add idempotent (no duplicate on re-run).
args += [f"-Keywords-={kw}", f"-Keywords+={kw}", f"-Subject-={kw}", f"-Subject+={kw}"]
if len(args) == 3:
return True
args.append(path)
return subprocess.run(args, capture_output=True, text=True).returncode == 0

View File

@@ -0,0 +1,84 @@
"""Local NSFW model adapter.
Wraps the on-device ViT classifier behind a small interface so the safety service
depends on ``NsfwModel.score`` rather than on torch/transformers or the archived
CLI. Heavy imports are lazy: importing this module never loads the model, so the
rest of the app (and its tests) stay light. Scoring itself is non-deterministic
across hardware and is exercised in dedicated model tests, not the unit suite.
Extracted from nsfwtag.scoring.score_images (donor_ledger.yaml: nt-score-model).
The donor's CSV cache is dropped here — scores are persisted in the database by
asset ID (safety repository), not a path-keyed CSV.
"""
from __future__ import annotations
from pathlib import Path
MODEL_ID = "AdamCodd/vit-base-nsfw-detector"
BATCH = 16
class NsfwModel:
def __init__(self, model_id: str = MODEL_ID, batch: int = BATCH) -> None:
self.model_id = model_id
self.batch = batch
self._model = None
self._device = None
self._size = None
self._nsfw_idx = None
def _ensure_loaded(self) -> None:
if self._model is not None:
return
import torch
from transformers import AutoModelForImageClassification
self._device = "mps" if torch.backends.mps.is_available() else "cpu"
try:
model = AutoModelForImageClassification.from_pretrained(
self.model_id, local_files_only=True
)
except Exception:
model = AutoModelForImageClassification.from_pretrained(self.model_id)
self._model = model.to(self._device).eval()
self._nsfw_idx = next(
i for i, label in model.config.id2label.items() if label.lower() == "nsfw"
)
self._size = getattr(model.config, "image_size", 224)
def score(self, paths: list[Path | str]) -> list[tuple[str, float]]:
"""Return ``[(path, nsfw_probability)]`` for each readable image."""
if not paths:
return []
self._ensure_loaded()
import numpy as np
import torch
from PIL import Image, ImageFile
ImageFile.LOAD_TRUNCATED_IMAGES = True
def preprocess(image):
image = image.convert("RGB").resize((self._size, self._size), Image.BILINEAR)
array = (np.asarray(image, dtype="float32") / 255.0 - 0.5) / 0.5
return torch.from_numpy(array).permute(2, 0, 1)
results: list[tuple[str, float]] = []
items = [str(p) for p in paths]
for start in range(0, len(items), self.batch):
tensors, batch_paths = [], []
for path in items[start : start + self.batch]:
try:
tensors.append(preprocess(Image.open(path)))
batch_paths.append(path)
except Exception:
continue
if not tensors:
continue
with torch.no_grad():
probs = self._model(
pixel_values=torch.stack(tensors).to(self._device)
).logits.softmax(-1)
for path, prob in zip(batch_paths, probs):
results.append((path, float(prob[self._nsfw_idx].item())))
return results