8000 feat(nsfw): classify NSFW pictures with a local model, per namespace by adeelahmad · Pull Request #306 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content

feat(nsfw): classify NSFW pictures with a local model, per namespace - #306

Merged
adeelahmad merged 3 commits into
mainfrom
claude/project-thread-idmy8v
Oct 15, 2026
Merged

adeelahmad merged 3 commits into
mainfrom
claude/project-thread-idmy8v

Conversation

@adeelahmad
Copy link
Copy Markdown
Owner

Requested by Adeel · project thread

Description

Before: Lens had no way to tell which videos, documents or images contain NSFW pictures.

After: an admin turns on Settings → NSFW for every namespace, or an owner turns it on from the namespace's page for theirs. It is off by default. A new nsfw step then scores a video's sampled frames, a document's pages or an image with a model that runs on the server. When a picture scores at least the threshold, the resource is flagged.

Flagged resources follow the namespace's display setting:

  • blur (the default): pictures are blurred on the resource page (the video, frames, faces, pages) and in photo search, until someone clicks Show pictures;
  • hide: they are left out of the Library unless the new NSFW filter asks for them;
  • show: they get a label.

Editors can mark a resource NSFW or Not NSFW, with Undo. Classify existing classifies what was there before. Agents can do all of this through the API and MCP.

How it works:

  • The model is AdamCodd/vit-base-nsfw-detector (Apache-2.0), using its own 8-bit ONNX export (88 MB). It is pinned to one revision and checked against its SHA-256. The components keeper fetches it on first use, together with ONNX Runtime (the existing objects extra), and only when some namespace has classification turned on.
  • Settings: nsfw.enabled, threshold (0.8), display (blur, hide or show) and max_frames (300). A namespace's own enabled, threshold and display are stored on the space record and override the archive's; null follows the archive.
  • Changing a threshold, or moving a resource to another namespace, re-flags what was already classified without running the model again. A person's own mark always wins over the score.
  • API:
    • GET/PUT /namespaces/{name}/nsfw
    • POST /namespaces/{name}/nsfw/classify
    • PUT /resources/{rid}/nsfw
    • GET /resources/nsfw
    • the sensitive filter on GET /resources
    • nsfw on resources, players, Library rows, search hits and photo hits
  • MCP tools: nsfw_settings, set_nsfw and mark_nsfw.
  • Web app:
    • the step appears in the pipeline editor, reprocess and run-on lists;
    • an NSFW settings page;
    • an NSFW section on the namespace page;
    • blur and a notice on the resource page;
    • an NSFW badge and filter in the Library (/library?namespace=…&nsfw=only);
    • blurred photo search hits.

Motivation and Context

Adeel asked to "classify nsfw with a local model if enabled, or enabled for a namespace".

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change; ! in the title)

Checklist:

  • The title is a Conventional Commit.
  • My code follows the code style of this project (ruff, mypy, eslint, prettier, tsc all clean).
  • I added or updated tests: tests/api/test_nsfw.py, with a fake model, a tiny real ONNX model, and the real model behind LENS_TEST_NSFW_MODEL, which passed locally with red ≈ 0.93. Also __tests__/nsfw.test.tsx.
  • My change requires documentation updates, and I have updated docs/ accordingly: new docs/nsfw.md, plus api, mcp, components and configuration. mkdocs build --strict is clean.
  • Users need more than the title to understand it, so I wrote it under ## Unreleased in CHANGELOG.md.

Done questions (AGENTS.md)

  • Goal: a local-model classifier, on globally or per namespace, with blur and hide. It merges on green CI.
  • Agents: the REST endpoints above and the MCP tools nsfw_settings, set_nsfw and mark_nsfw.
  • Zero setup: the model and ONNX Runtime are fetched by the components keeper on first use. No .env edit or manual step is needed.
  • Fewest clicks: one switch turns it on; the threshold saves on Enter or blur; marks use Undo instead of a confirm dialog; a link opens the Library already filtered.
  • Configurable: Settings → NSFW and the namespace page, each with defaults. Nothing was added to the setup wizard.
  • Light: off by default, and nothing is downloaded until a namespace turns it on, so a Raspberry Pi stays small.
  • Private and secure: pictures never leave the server. New endpoints use the existing role checks (owners for settings, editors to classify or mark). The threat model gains N-NSFW (blur and hide are display, not access control) and ci/validate_threat_model.py passes.
  • Cost: it runs locally with no per-picture cost, at most max_frames pictures per resource. The work runs inside the job's run row in the activity ledger, like the objects step.
  • User decides: off until an admin or owner turns it on. Existing defaults and pipelines are unchanged except for a step that skips itself while classification is off.
  • Upgrades: only optional fields are added (recording.nsfw, space.nsfw, the nsfw settings section), and a missing field means "not classified" or "follow the archive". Older workers.steps lists that include objects also run nsfw.
  • Honest: the docs describe what is built, with model limits (strict on skin, less sure on generated pictures). "Refine later" items are written down.
  • Tests, docs and generated files: the generated files come from make openapi.
  • Open: the model is Apache-2.0 and ONNX Runtime is MIT. No new required dependency.
  • Reported: in the project thread. Refine later (in docs/nsfw.md):
    • blur for visitors on public pages, embeds and IIIF;
    • skip the cloud describe step for flagged pictures;
    • offer other models;
    • per-picture scores in the player;
    • keep the NSFW filter in saved views.

🤖 Generated with Claude Code

https://claude.ai/code/session_01T7gfZuuw4gDJ7sYnKsLKbW


Generated by Claude Code

Off by default. An admin turns it on for every namespace (Settings → NSFW) or
an owner for theirs (the namespace's page, the API or MCP). The new nsfw step
scores a video's sampled frames, a document's pages or an image with
AdamCodd/vit-base-nsfw-detector (Apache-2.0, 8-bit ONNX, pinned and checked
against its SHA-256), fetched on first use with ONNX Runtime, and flags the
resource when a picture scores at least the namespace's threshold.

- Settings section nsfw {enabled, threshold, display, max_frames}; a
  namespace's own enabled, threshold and display win over it.
- Flagged resources are blurred until someone shows them, hidden from the
  Library, or shown with a label; editors mark them NSFW or not, with Undo.
- API: /namespaces/{name}/nsfw (+ classify), /resources/{rid}/nsfw,
  /resources/nsfw, the Library's sensitive filter; MCP nsfw_settings,
  set_nsfw, mark_nsfw.
- Threat model: N-NSFW (blur and hide are display, not access control).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7gfZuuw4gDJ7sYnKsLKbW
@adeelahmad adeelahmad self-assigned this Oct 10, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7gfZuuw4gDJ7sYnKsLKbW
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7gfZuuw4gDJ7sYnKsLKbW
@adeelahmad
adeelahmad merged commit d89a2ec into main Oct 15, 2026
11 checks passed
@adeelahmad
adeelahmad deleted the claude/project-thread-idmy8v branch October 15, 2026 00:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

0