All pages

Nudity detection (NudeNet)

argus-curator 0.15.2.dev2+g97021d6 @ 97021d6PyPI (opens in new tab)Source (opens in new tab)Changelog (opens in new tab)

Imported from docs/nudity.md on argus-curator's dev branch at that commit. Fixes go upstream, not here.

Curator can check every scanned image with NudeNet (opens in new tab), a detector for 18 body-part classes (FEMALE_BREAST_EXPOSED, BUTTOCKS_COVERED, FACE_FEMALE, …). It talks to a NudeNet server over HTTP; curator never imports NudeNet, so it gains no dependency and stays MIT. NudeNet itself is AGPL-3.0, so running its server is a choice for whoever deploys it.

Configuration

Configure the server in the environment (or .env), never per request. <LAN_HOST> is a placeholder for the host that runs the server:

ARGUS_CURATOR_NUDENET_URL=http://<LAN_HOST>:8080   # its origin; /infer is appended
ARGUS_CURATOR_NUDENET_TIMEOUT=30                     # seconds per image (default 30)
ARGUS_CURATOR_NUDENET_WORKERS=4                      # concurrent requests (default 4)
ARGUS_CURATOR_ERAX_URL=http://<LAN_HOST>:8081      # optional; empty turns EraX off
ARGUS_CURATOR_ERAX_TIMEOUT=30
ARGUS_CURATOR_ERAX_WORKERS=4

Any host speaking NudeNet's /infer API works, including the official ghcr.io/notai-tech/nudenet image (320n) or a 640m sidecar. docker compose up in argus-cull starts a 640m server and uses it unless .env says otherwise.

Opt-out by default

It is opt-out. When a URL is configured, every scan checks its images, including ones that failed the quality filters, unless the scan opts out (--no-nsfw, or "nsfw": {"enabled": false}). --nsfw / "enabled": true makes the check mandatory: the scan is refused if no server is configured. Set the URL empty to switch the check off for a deployment. The scan summary records whether it ran (nsfw_config.enabled). Images are sent to that server, so keep it on infrastructure you control.

Scores and filters

Each image records every detection, the highest score per class (nsfw.labels), and an exposed score: the highest score among the five exposed-nudity classes (BUTTOCKS_EXPOSED, FEMALE_BREAST_EXPOSED, FEMALE_GENITALIA_EXPOSED, MALE_GENITALIA_EXPOSED, ANUS_EXPOSED). Faces and covered body parts are detected too but never count towards it. The CSV report has nsfw_exposed_score and nsfw_labels.

Filters drop an image when a score is at or above its threshold:

# Total: drop anything whose exposed score is 0.5 or more
argus-curator scan /data/inbox --nsfw-threshold 0.5 --copy-to /data/clean

# Per class, alongside or instead of the total
argus-curator scan /data/inbox \
    --nsfw-label FEMALE_BREAST_EXPOSED=0.4,MALE_GENITALIA_EXPOSED=0.3,BELLY_EXPOSED=0.8 \
    --copy-to /data/clean

Over HTTP these are nsfw_threshold, nsfw_label_thresholds ({"CLASS": score}) and nsfw_exposed_labels (which classes the total is taken over). The server reports nothing under 0.2, so thresholds must be 0.2–1. An image that was never checked, or whose check failed, is excluded whenever an NSFW filter is set (nsfw-unchecked): the filter fails closed. Other reasons are nsfw-exposed and nsfw-<class> (e.g. nsfw-belly-exposed). Exporting with NSFW thresholds from a scan that did not run NudeNet is refused (400) rather than excluding everything. GET /detectors reports nudenet when a URL is configured.

EraX: a second detector

An optional EraX-NSFW (opens in new tab) server (ARGUS_CURATOR_ERAX_URL) is a second detector for frames NudeNet does not score. It speaks the same /infer API with its own classes (anus, make_love, nipple, penis, vagina). When NudeNet's exposed score is 0 or the check failed, those hits are mapped onto NudeNet's exposed labels (nipple → FEMALE_BREAST_EXPOSED, make_love → both genitalia classes) and recorded on nsfw, with the raw EraX result on erax and erax_filled: true. An image NudeNet already scored as exposed is left alone. A nipple inside a box NudeNet called MALE_BREAST_EXPOSED is not mapped (a bare male chest is fine); one NudeNet did not box still counts. When NudeNet's check failed, the filled image also gets nudenet_failed: true and the NudeNet filters exclude it as nsfw-unchecked unless the EraX hits reach a threshold (EraX has no buttocks class). EraX runs beside NudeNet on every image that passed the hard filters, and each nsfw row is streamed as soon as it is decided: at once when NudeNet scored it exposed, otherwise when EraX has answered too. --no-nsfw turns both off. GET /detectors reports erax.

Content rating

Each checked image also carries a content_rating, read off the same detections: explicit when an exposed class was reported (the five above, from NudeNet's 0.2 floor), else suggestive when a covered intimate class was (lingerie, swimwear: covered breast, genitalia, buttocks or anus), else safe. An unchecked image has none. It is on every result and in the CSV report, and content_ratings (--content-ratings safe,suggestive) keeps only those ratings; unchecked images are excluded (nsfw-unchecked), others get content-rating:<rating>. The rating always uses the five default exposed classes; --nsfw-exposed-labels / --nsfw-label can still drop an image the rating calls safe (for example a high BELLY_EXPOSED).

The content rating is also written into XMP sidecars as argus:ContentRating (see XMP sidecars). For a whole-policy verdict rather than per-part scores, see Policy check (SingGuard).