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.
Caution
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.
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.
- Lychee sends a
POST /api/nsfw/detectrequest with a photo ID and its path on the shared volume. - The service returns
202 Acceptedimmediately and enqueues the job. - A background worker runs NudeNet inference on the image.
- 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.
| Concern | Library |
|---|---|
| Web framework | FastAPI + Uvicorn |
| Inference | NudeNet v3 (ONNX) |
| Image loading | Pillow |
| HTTP client | httpx |
| Config | Pydantic BaseSettings |
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
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=strictIndividual 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.
All variables are prefixed VISION_NSFW_. Copy .env.example to .env and fill in the required values.
| 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. |
| 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
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
{
"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.
{
"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
# 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 .envmake 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 .envThe service will be available at http://localhost:8000.
uv run pytest --cov=app --cov-report=html# 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-classificationSee Deploy with Docker for a full deployment guide.