Skip to content

Repository files navigation

Lychee NSFW Classification

NSFW content moderation microservice for Lychee.

Analyses photos for explicit content using NudeNet and reports results to the Lychee PHP backend via a REST callback.

Build Status Code Coverage CII Best Practices Summary OpenSSF Scorecard
Website Documentation Changelog Discord


Caution

License — GNU Affero General Public License v3 (AGPL-3.0)

This repository is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0).

This is different from all other LycheeOrg repositories, which are released under the MIT License.

Why AGPL-3.0?

This service uses NudeNet, which is itself licensed under the AGPL-3.0. Because AGPL-3.0 is a strong copyleft license, any software that links to or distributes NudeNet must adopt the same license. This entire repository is therefore AGPL-3.0.

See the LICENSE file for the full license text.


How it works

  1. Lychee sends a POST /api/nsfw/detect request with a photo ID and its path on the shared volume.
  2. The service returns 202 Accepted immediately and enqueues the job.
  3. A background worker runs NudeNet inference on the image.
  4. Results are POSTed back to Lychee's callback endpoint (/api/v2/NsfwDetection/results).

The callback payload classifies detections into three tiers:

Field Meaning
should_block One or more detections matched the block label set — hide the photo.
should_review One or more detections matched the review label set — send for human moderation.
is_sensitive One or more detections matched the sensitive label set — mark the photo but keep it visible.

All three tiers are independent: a photo can be both should_block and is_sensitive if labels from both sets are detected.

Tech stack

Concern Library
Web framework FastAPI + Uvicorn
Inference NudeNet v3 (ONNX)
Image loading Pillow
HTTP client httpx
Config Pydantic BaseSettings

Directory layout

Lychee-NSFW-Classification/
├── app/
│   ├── __init__.py
│   ├── main.py            # FastAPI app factory + lifespan
│   ├── config/
│   │   ├── __init__.py    # Re-exports all public names
│   │   ├── labels.py      # VALID_LABELS frozenset
│   │   ├── models.py      # LabelThreshold, LabelSetConfig
│   │   ├── presets.py     # Named presets (strict, moderation, …)
│   │   └── settings.py    # AppSettings (Pydantic BaseSettings)
│   ├── api/
│   │   ├── dependencies.py  # API key authentication
│   │   ├── routes.py        # /detect, /health, /config, /queue
│   │   └── schemas.py       # Pydantic request/response/callback models
│   ├── detection/
│   │   └── detector.py      # NudeNet wrapper + classify()
│   └── queue/
│       ├── base.py          # JobQueue ABC
│       ├── factory.py       # Queue backend selection
│       └── worker.py        # Background job runner
├── tests/
├── docs/                  # Developer documentation (Diátaxis)
├── Dockerfile
├── Makefile
└── pyproject.toml

Quick start — choose a preset

The fastest way to configure the service is to pick a preset that matches your use case:

Preset VISION_NSFW_PRESET= Block Review Sensitive
Strict strict All exposed nudity incl. male chest Covered intimate parts Belly, armpits, feet
Moderation moderation (nothing) All exposed nudity Covered intimate parts
Nude female nude_female Male genitalia, anus Female genitalia Female breast/buttocks + covered parts
Permissive permissive Genitalia + anus only (nothing) Female/male breast, buttocks
Social media social_media Female breast, all genitalia, anus Buttocks, male chest Covered intimate parts

Set it in .env:

VISION_NSFW_PRESET=strict

Individual tier settings (VISION_NSFW_BLOCK, VISION_NSFW_REVIEW, VISION_NSFW_SENSITIVE) always override the preset, so you can start from a preset and refine from there.

You can also fine-tune each preset independently at startup and let callers select a preset per request:

# Raise the confidence bar for the strict preset's block tier
VISION_NSFW_STRICT__BLOCK__CONFIDENCE=0.9

# Require detections to cover ≥ 5 % of the image for nude_female's review tier
VISION_NSFW_NUDE_FEMALE__REVIEW__AREA_RATIO=0.05
{ "photo_id": "42", "photo_path": "2024/01/photo.jpg", "preset": "strict" }

See Choose a preset and Configure classification tiers for details.

Environment variables

All variables are prefixed VISION_NSFW_. Copy .env.example to .env and fill in the required values.

Required

Variable Description
VISION_NSFW_API_KEY Shared secret — validated on inbound requests via X-API-Key and sent on outbound callbacks. Must match AI_VISION_NSFW_API_KEY in Lychee's .env.
VISION_NSFW_LYCHEE_API_URL Lychee base URL for callbacks, no trailing slash. Example: https://lychee.example.com.

Classification

Variable Default Description
VISION_NSFW_PRESET (none) Named preset: strict, moderation, nude_female, permissive, social_media. Populates block/review/sensitive defaults; explicit tier settings override it.
VISION_NSFW_CONFIDENCE_THRESHOLD 0.1 Global fallback minimum confidence (0.0–1.0) for a detection to trigger any tier.
VISION_NSFW_AREA_RATIO_THRESHOLD 0.0 Global fallback minimum area fraction (0.0–1.0) a detection must cover. 0.0 = no area filter.
VISION_NSFW_BLOCK (see defaults) JSON object configuring the block tier. See Configuration Reference.
VISION_NSFW_REVIEW (see defaults) JSON object configuring the review tier.
VISION_NSFW_SENSITIVE (see defaults) JSON object configuring the sensitive tier.
VISION_NSFW_<PRESET>__<TIER>__<FIELD> (none) Per-preset threshold override. Tunes a specific preset in isolation so all presets are ready for per-request selection. Example: VISION_NSFW_STRICT__BLOCK__CONFIDENCE=0.9.

Full reference: docs/3-reference/configuration.md

API endpoints

Base path: /api/nsfw

Method Path Auth Description
GET /api/nsfw/health No Service health check
POST /api/nsfw/detect Yes Enqueue an NSFW detection job
GET /api/nsfw/config Yes Show active configuration (secrets redacted)
GET /api/nsfw/queue Yes Number of pending jobs
DELETE /api/nsfw/queue Yes Purge all pending jobs
GET /api/nsfw/queue/{photo_id} Yes Queue position of a specific job

Interactive docs: http://localhost:8000/docs

POST /api/nsfw/detect — request

{
  "photo_id": "42",
  "photo_path": "2024/01/photo.jpg",
  "preset": "strict"
}
Field Required Description
photo_id Yes Lychee photo identifier, echoed back in the callback.
photo_path Yes Path relative to VISION_NSFW_PHOTOS_PATH. Validated to stay within that root.
preset No Override the service-level preset for this job. See Choose a preset.

The endpoint returns 202 Accepted immediately. Results arrive via callback.

Callback payload (POSTed to {VISION_NSFW_LYCHEE_API_URL}/api/v2/NsfwDetection/results)

{
  "photo_id": "42",
  "status": "success",
  "should_block": true,
  "should_review": false,
  "is_sensitive": true,
  "all_detected": [
    {
      "label": "FEMALE_GENITALIA_EXPOSED",
      "confidence": 0.91,
      "bbox": {"x": 120, "y": 200, "width": 300, "height": 280},
      "area_pixels": 84000,
      "area_ratio": 0.175
    },
    {
      "label": "FEMALE_BREAST_COVERED",
      "confidence": 0.74,
      "bbox": {"x": 50, "y": 80, "width": 150, "height": 140},
      "area_pixels": 21000,
      "area_ratio": 0.044
    }
  ],
  "block_detected": [
    {
      "label": "FEMALE_GENITALIA_EXPOSED",
      "confidence": 0.91,
      "bbox": {"x": 120, "y": 200, "width": 300, "height": 280},
      "area_pixels": 84000,
      "area_ratio": 0.175
    }
  ],
  "review_detected": [],
  "sensitive_detected": [
    {
      "label": "FEMALE_BREAST_COVERED",
      "confidence": 0.74,
      "bbox": {"x": 50, "y": 80, "width": 150, "height": 140},
      "area_pixels": 21000,
      "area_ratio": 0.044
    }
  ]
}

On failure, the callback contains "status": "error" with error_code and message fields.

Full API reference: docs/3-reference/api.md

Development

Setup

# Install uv (https://docs.astral.sh/uv/getting-started/installation/)
curl -LsSf https://astral.sh/uv/install.sh | sh

# Install all dependencies (including dev)
uv sync

# Copy and edit the env file
cp .env.example .env

Makefile

make lint          # ruff check + ruff format --check + ty check
make format        # ruff format + ruff check --fix
make test          # pytest
make run           # uvicorn with --reload (local dev)
make docker-build  # docker build
make docker-run    # docker run --env-file .env

The service will be available at http://localhost:8000.

Running tests with coverage

uv run pytest --cov=app --cov-report=html

Docker

# Build
make docker-build

# Run using .env for configuration
make docker-run

# Or manually, mounting the Lychee uploads volume
docker run --rm \
  --env-file .env \
  -v /path/to/lychee/public/uploads:/data/photos:ro \
  -p 8000:8000 \
  lychee-nsfw-classification

Sensitive values (VISION_NSFW_API_KEY, DB/Redis passwords) can also be supplied as Docker/Swarm secrets files (/run/secrets/<VARIABLE_NAME>) instead of plain env vars.

See Deploy with Docker for a full deployment guide.

About

No description, website, or topics provided.

Resources

Security policy

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages