Fraunhofer-SIT/WACV2027-OneClassPornDetection

0

stars

1

commits

Python

primary language

Aug 29, 2026

updated

README

One-Class Porn Detection

One-class detection of pornographic images using vision embeddings and outlier detection.

How It Works

  1. Image Embedding — Images are converted to high-dimensional embeddings using a vision model (CLIP, SigLIP2, DINOv3, C-RADIOv4 (incl. distilled), EUPE, AIMv2, MetaCLIP2, SAM3, Sapiens2, or TIPSv2).

  2. One-Class / Outlier Detection — Embeddings are fed to PyOD models trained on normal (pornographic) images. A reject-rate sweep evaluates each model at multiple thresholds to optimize accuracy (or another metric).

Installation

Requires uv and Python 3.13.

git clone <repository-url>
cd one-class-porn-detection
uv sync

CLI Entry Points

Train

uv run train \
  --train-dir <path> \
  --val-dir <path> \
  --embedding-model clip

Sweeps over all configured PyOD models × reject rates, writes a sweep CSV and summary JSON to a timestamped subdirectory under trained_models/<embedding_model>/<reducer>/.

Test

uv run test \
  --test-dir <path> \
  --results-dir <path> \
  --sweep-csv <path/to/sweep.csv> \
  --embedding-model clip

By default, selects the best config from the sweep CSV by --select-metric (default: accuracy, validation metric from training) and evaluates only that single config on the test set. Use --all to test every config in the sweep CSV.

For the evaluated config(s), writes to a timestamped subdirectory under --results-dir:

FileDescription
{tag}_predictions.csvOne row per test file: filename, y_true, score, y_pred, model, reject_rate, threshold. score is the raw outlier score (higher = more anomalous = label 1); y_pred is the hard label at the trained threshold.
{tag}_metrics.txtAggregate metrics (accuracy, BAC, F1, macro F1, precision, recall). The F1(0)/Precision(0)/Recall(0) labels denote the inlier (Porn) as the positive class.
{tag}_conf_matrix.pngConfusion matrix
sweep_test_results.csvSummary table (one row per evaluated config), includes predictions_csv column

Metric convention. The stored score is the raw outlier score (higher = more anomalous = Non_porn/label 1), but Porn (the inlier, label 0) is the positive class for all metrics. Threshold metrics (F1(0)/Precision(0)/ Recall(0)) and average precision treat Porn as positive; average precision and the PR/ROC curves negate the scores at evaluation time to orient Porn as the detection target. ROC-AUC is invariant to the positive-class choice, so it is computed on the raw scores (numerically equal to the Porn-positive AUC). This aligns average precision with the threshold metrics, which already frame Porn (label 0) as the detection target.

Key CLI Arguments

Train (uv run train)

ArgumentDefaultDescription
--train-dirrequiredTraining image directory
--val-dirrequiredValidation image directory
--embedding-modelrequiredaimv2_1b_224, aimv2_large_224, aimv2_huge_224, clip, clip_large, metaclip2_b16, metaclip2_l14, sam3, sapiens2_0_1b, sapiens2_0_4b, sapiens2_0_8b, sapiens2_1_0b, siglip2, siglip2_so400m_384, dinov3_vitl16, dinov3_vith16plus, cradiov4_so400m, cradiov4_h, cradiov4_h_distilled_vitb16, eupe_vit_b, eupe_convnext_b, tipsv2_b14, tipsv2_l14, or tipsv2_so400m14
--modelsiforest,copod,ecod,hbos,loda,inne,dif,ocsvm,pyod_pca,fb_pcaComma-separated PyOD models to evaluate
--reject-rates0.001,0.005,0.01,0.05,0.1,0.2Comma-separated reject rates to sweep
--reducernonenone, pca, grp, or kpca
--reducer-dim256Target dimension for the reducer
--kpca-kernelrbfKernelPCA kernel (linear, poly, rbf, sigmoid, cosine)
--kpca-gammaoffKernelPCA gamma
--kpca-degree3KernelPCA degree
--kpca-coef01.0KernelPCA coef0
--grp-epsoffIf set, GRP uses n_components='auto' with this eps
--batch-size32Batch size for embedding extraction
--no-cacheoffSkip NPZ cache, extract embeddings fresh
--cache-dircacheNPZ embedding cache directory
--deviceauto0, 1 for CUDA, or cpu
--classes-configbundled classes.tomlPath to custom classes.toml
--out-dirtrained_modelsOutput directory
--max-train-samplesoffSubsample training set to N samples (nested with --seed)
--seed42Seed for --max-train-samples subsampling permutation

Test (uv run test)

ArgumentDefaultDescription
--test-dirrequiredTest image directory
--results-dirrequiredOutput directory for test results
--sweep-csvrequiredPath to training sweep CSV
--embedding-modelrequiredSame choices as train
--select-metricaccuracyValidation metric from sweep CSV used to select the best config (accuracy, bac, f1, macro_f1, precision, recall)
--alloffTest every config in the sweep CSV (default: only the best config)
--batch-size32Batch size for embedding extraction
--no-cacheoffSkip NPZ cache, extract embeddings fresh
--cache-dircacheNPZ embedding cache directory
--deviceauto0, 1 for CUDA, or cpu
--classes-configbundled classes.tomlPath to custom classes.toml

Embedding Models

NameSourceParams
aimv2_1b_224apple/aimv2-1B-patch14-2241.0B
aimv2_large_224apple/aimv2-large-patch14-2240.3B
aimv2_huge_224apple/aimv2-huge-patch14-2241.2B
clipopenai/clip-vit-base-patch320.1B
clip_largeopenai/clip-vit-large-patch140.3B
metaclip2_b16facebook/metaclip-2-worldwide-b160.1B
metaclip2_l14facebook/metaclip-2-worldwide-l140.3B
sam3facebook/sam3 (requires HF_TOKEN)0.9B
sapiens2_0_1bfacebook/sapiens2-pretrain-0.1b0.1B
sapiens2_0_4bfacebook/sapiens2-pretrain-0.4b0.4B
sapiens2_0_8bfacebook/sapiens2-pretrain-0.8b0.8B
sapiens2_1_0bfacebook/sapiens2-pretrain-1b1.5B
siglip2google/siglip2-base-patch16-2240.4B
siglip2_so400m_384google/siglip2-so400m-patch14-3841.0B
dinov3_vitl16facebook/dinov3-vitl16-pretrain-lvd1689m (requires HF_TOKEN)0.3B
dinov3_vith16plusfacebook/dinov3-vith16plus-pretrain-lvd1689m (requires HF_TOKEN)0.8B
cradiov4_so400mnvidia/C-RADIOv4-SO400M0.4B
cradiov4_hnvidia/C-RADIOv4-H0.7B
cradiov4_h_distilled_vitb16vit_base_patch16_224 (timm) + local checkpoint (requires CRADIOV4_H_DISTILLED_CKPT)0.1B
eupe_vit_bfacebook/EUPE-ViT-B0.1B
eupe_convnext_bfacebook/EUPE-ConvNeXt-B0.1B
tipsv2_b14google/tipsv2-b140.1B
tipsv2_l14google/tipsv2-l140.3B
tipsv2_so400m14google/tipsv2-so400m140.4B

Detection Methods

10 PyOD models are configured by default:

KeyModel
iforestIsolation Forest
copodCopula-Based Outlier Detection
ecodEmpirical-Cumulative-distribution Outlier Detection
hbosHistogram-Based Outlier Score
lodaLightweight On-line Detector of Anomalies
inneIsolation-based Nearest Neighbor
difDeep Isolation Forest
ocsvmOne-Class SVM
pyod_pcaPyOD PCA
fb_pcaFeature Bagging with PyOD PCA

Embedding Cache

Embeddings are cached as .npz files (keyed by MD5 hash of each image). Subsequent runs only compute embeddings for new images. Use --no-cache to bypass.

A standalone scripts/cleanup_cache.py script is provided for removing cached entries matching a path filter.

Using Trained Models in Python

The OneClassDetector class provides an sklearn-like interface for inference:

from one_class_porn_detection import OneClassDetector

# Load from a sweep CSV row
detector = OneClassDetector.from_sweep_row(
    "trained_models/clip/none/sweep.csv",
    model="iforest",
    reject_rate=0.01,
)

# Or load from individual artifact files
detector = OneClassDetector.load(
    model_path="trained_models/clip/none/iforest_clip_none_base.joblib",
    threshold=0.123456,
    embedding_model="clip",
)

# Predict on a directory of images (handles embedding + normalization)
labels = detector.predict(image_dir="path/to/images/")
# 0 = inlier, 1 = outlier (as defined in classes.toml)

# Or on pre-computed embeddings
labels = detector.predict(embeddings=X)

# Get raw outlier scores
scores = detector.score_samples(image_dir="path/to/images/")

Plotting Results

A standalone script, scripts/plot_results.py, generates diagnostic plots from the *_predictions.csv files produced by test:

uv run python scripts/plot_results.py --results-dir <path>
# or single CSV
uv run python scripts/plot_results.py --predictions-csv <path>

Per-config plots (6 files per config):

PlotDescription
{tag}_roc.pngROC curve with AUC (Porn = positive class)
{tag}_pr.pngPrecision-recall curve with average precision (Porn = positive class)
{tag}_score_dist.pngScore distribution by class (KDE + histogram), trained threshold marked
{tag}_threshold_sweep.pngAccuracy/BAC/F1/precision/recall vs swept threshold
{tag}_confusion_matrix.pngConfusion matrix heatmap (counts + row-normalized)
{tag}_metrics_bar.pngBar chart of 6 aggregate metrics

Combined overlays (when multiple CSVs are found, unless --no-multi):

PlotDescription
combined_roc.pngAll configs overlaid, ranked by AUC
combined_pr.pngAll configs overlaid, ranked by AP
FlagDefaultDescription
--results-dirrequired*Directory containing *_predictions.csv files
--predictions-csvrequired*Single predictions CSV to plot
--out-dir<results-dir>/plotsOutput directory for plots
--classes-configbundled classes.tomlPath to custom classes.toml
--dpi150Output resolution
--no-multioffSkip combined overlay plots

Selecting Extreme Samples

A standalone script, scripts/select_samples.py, copies the highest- and lowest-scoring images per class from a predictions CSV into named subdirectories (e.g. outdir/porn/min, outdir/porn/max, outdir/non_porn/min, outdir/non_porn/max). Useful for inspecting which images the model is most/least confident about.

uv run python scripts/select_samples.py results.csv --data-dir datasets/test_easy -n 10
uv run python scripts/select_samples.py results.csv --data-dir datasets/test_easy \
    -n 10 --outdir selected_samples

Scores follow the repo convention: higher = more anomalous = outlier.

ArgumentDefaultDescription
predictions (positional)requiredPath to a *_predictions.csv file (columns: filename, y_true, score, ...)
--data-dirrequiredDirectory containing the images (matched by basename)
-n / --n-samplesrequiredNumber of highest- and lowest-scoring samples to copy per class
--outdirselected_samplesRoot output directory
--classes-configbundled classes.tomlPath to custom classes.toml (used only for naming subdirectories)

Baseline: ShieldGemma 2 (VLM)

A zero-shot baseline that is separate from the trained one-class pipeline. Instead of learning embeddings + outlier detection, it asks the google/shieldgemma-2-4b-it vision-language model to classify each image against a sexually-explicit safety policy and records the model's probability that the image violates the policy (i.e. is pornographic). The model is gated, so HF_TOKEN must be set.

Usage

uv run python scripts/eval_shieldgemma.py \
  --test-dir datasets/test_easy \
  --results-dir baseline_results

Score convention

ShieldGemma returns probabilities[:,0] = P(violates policy) = P(porn) and [:,1] = P(safe). To stay compatible with the one-class pipeline (where a higher score = label 1 = Non_porn/outlier), the score column is score = 1 - P(violates) = P(safe). Predictions use y_pred = (score > threshold) with --threshold (default 0.5), so an image the model deems pornographic (P(violates) > 0.5) is predicted as inlier (0).

Outputs

Writes a timestamped subdirectory under --results-dir with the same schema as the test CLI, consumable by scripts/plot_results.py:

FileDescription
{tag}_predictions.csvOne row per test file: filename, y_true, score, y_pred, model, reject_rate, threshold (reject_rate is always 0.0 for the baseline)
{tag}_metrics.txtAggregate metrics (accuracy, BAC, F1, macro F1, precision, recall)
{tag}_conf_matrix.pngConfusion matrix
sweep_test_results.csvOne-row summary table

Flags

FlagDefaultDescription
--test-dirrequiredDirectory containing test images
--results-dirrequiredOutput directory for results
--classes-configbundled classes.tomlPath to custom classes.toml
--deviceauto0, 1 for CUDA, or cpu
--batch-size8Batch size for VLM inference
--cache-dircacheDirectory for the per-image scores cache
--no-cacheoffScore every image fresh, ignoring the cache
--policysexually-explicit descriptionOverride the policy description text
--threshold0.5Decision threshold on score (score > thr -> Non_porn)
--model-nameshieldgemma2_4b_itTag used in output filenames / model column
--max-samplesoffSubsample to N images (seed=--seed) before scoring
--seed42Random seed for subsampling

VLM inference is slow, so a lightweight per-image results cache is kept under --cache-dir as <model>_<policy-hash>_scores.csv (keyed by image MD5 + policy text). Re-runs skip already-scored images; use --no-cache to bypass. The default policy is ShieldGemma's "No Sexually Explicit content" description; use --policy to supply a custom one (this changes the cache file so different policies are scored independently).

Baseline: LlavaGuard (VLM, SGLang)

A zero-shot baseline that is separate from the trained one-class pipeline. Instead of learning embeddings + outlier detection, it asks the AIML-TUDA/LlavaGuard-v1.2-0.5B-OV vision-language model to assess each image against the O3 (Sexual Content) and O4 (Nudity Content) safety-policy categories and records the model's Safe/Unsafe rating.

This script is a thin HTTP client that talks to a running SGLang server (OpenAI-compatible /v1/chat/completions vision endpoint). It does not load the model itself — no sglang/torch/transformers dependency — only requests + pillow + eval libs (declared via PEP 723 inline metadata). Launch the server first (from the model card):

CUDA_VISIBLE_DEVICES=0 python -m sglang.launch_server \
    --model-path AIML-TUDA/LlavaGuard-v1.2-0.5B-OV --port 10000

then run the evaluation script:

uv run python scripts/eval_llavaguard.py \
  --test-dir datasets/test_easy \
  --results-dir baseline_results

If you customised --served-model-name on the server, pass the same value to --served-model-name here. The non-HF variant (LlavaQwenForCausalLM, model_type: "llava") is the one SGLang implements natively; the -hf variant is not SGLang-compatible.

LlavaGuard is a generative VLM: it emits a JSON {"rating": "Safe"|"Unsafe", ...} and has no probability head. The rating is therefore mapped to a discrete binary score (1.0 for Safe, 0.0 for Unsafe). Because of this, ROC/PR curves and threshold sweeps are degenerate by design (there is no continuous ranking between images). Generation uses the official SGLang model-card hyperparameters (temperature=0.2, top_p=0.95, top_k=50, max_new_tokens=500; no beam search); sampling_seed=--seed is passed server-side for reproducibility.

Score convention

LlavaGuard returns a Safe/Unsafe rating, where Unsafe means the image violates the sexual/nudity policy (i.e. is pornographic). To stay compatible with the one-class pipeline (where a higher score = label 1 = Non_porn/outlier), the score column is score = 1 - P(Unsafe) = P(Safe) (so Safe -> 1.0, Unsafe -> 0.0). Predictions use a fixed boundary y_pred = (score > 0.5) (not configurable), so an image rated Unsafe (pornographic, score 0) is predicted as inlier (0) and Safe (non-pornographic, score 1) as outlier (1).

Partial results are checkpointed to {tag}_checkpoint.csv every --checkpoint-interval images (default 1000) so an interrupted inference run can be resumed with --resume (pass the exact previous run directory as --results-dir). Without --resume every run scores all (subsampled) images fresh, overwriting any existing checkpoint in the output directory. Images whose response does not contain a parseable Safe/Unsafe rating are logged and excluded from evaluation.

Outputs

Writes a timestamped subdirectory under --results-dir with the same schema as the test CLI, consumable by scripts/plot_results.py:

FileDescription
{tag}_predictions.csvOne row per test file: filename, y_true, score, y_pred, model, reject_rate, threshold (reject_rate is always 0.0 for the baseline)
{tag}_metrics.txtAggregate metrics (accuracy, BAC, F1, macro F1, precision, recall)
{tag}_conf_matrix.pngConfusion matrix
{tag}_raw_responses.csvPer-image debug aid: filename, y_true, rating, raw_response (the full model text)
{tag}_checkpoint.csvIncremental checkpoint: filename, raw_response (enables --resume)
sweep_test_results.csvOne-row summary table

Flags

FlagDefaultDescription
--test-dirrequiredDirectory containing test images
--results-dirrequiredOutput directory for results (pass the exact run subdir for --resume)
--classes-configbundled classes.tomlPath to custom classes.toml
--base-urlhttp://localhost:10000Base URL of the running SGLang server (OpenAI-compatible API)
--served-model-nameAIML-TUDA/LlavaGuard-v1.2-0.5B-OVModel name accepted by the server (its --served-model-name)
--batch-size8Number of images scored concurrently per chunk (server batches continuously)
--policyO3 Sexual Content + O4 NudityOverride the safety policy description text
--model-namellavaguard_v1_2_0_5b_ovTag used in output filenames / model column
--max-samplesoffSubsample to N images (seed=--seed) before scoring
--seed42Random seed for subsampling and generation (sampling_seed passed to the server)
--request-timeout120Per-request HTTP timeout in seconds
--checkpoint-interval1000Save a checkpoint of scored images every N images
--resumeoffResume an incomplete run from the checkpoint in --results-dir

Baseline: LlavaGuard (VLM, HuggingFace)

A zero-shot baseline that is separate from the trained one-class pipeline. Instead of learning embeddings + outlier detection, it asks the AIML-TUDA/LlavaGuard-v1.2-0.5B-OV-hf vision-language model to assess each image against the O3 (Sexual Content) and O4 (Nudity Content) safety-policy categories and records the model's Safe/Unsafe rating.

Unlike the SGLang variant above, this script loads the model locally via transformers (LlavaOnevisionForConditionalGeneration / AutoModelForImageTextToText) — no external server is needed, but it requires a CUDA-capable environment with the full HuggingFace stack installed.

LlavaGuard is a generative VLM: it emits a JSON {"rating": "Safe"|"Unsafe", ...} and has no probability head. The rating is therefore mapped to a discrete binary score (1.0 for Safe, 0.0 for Unsafe). Because of this, ROC/PR curves and threshold sweeps are degenerate by design (there is no continuous ranking between images). Generation uses stochastic sampling (do_sample=True, temperature=0.2, top_p=0.95, top_k=50, num_beams=2, max_new_tokens=200); --seed governs both subsampling and the PyTorch/TorchVision RNG for reproducibility.

Usage

uv run python scripts/eval_llavaguard_hf.py \
  --test-dir datasets/test_easy \
  --results-dir baseline_results

Score convention

LlavaGuard returns a Safe/Unsafe rating, where Unsafe means the image violates the sexual/nudity policy (i.e. is pornographic). To stay compatible with the one-class pipeline (where a higher score = label 1 = Non_porn/outlier), the score column is score = 1 - P(Unsafe) = P(Safe) (so Safe -> 1.0, Unsafe -> 0.0). Predictions use y_pred = (score > threshold) with --threshold (default 0.5), so an image rated Unsafe (pornographic) is predicted as inlier (0).

There is no results cache: every run scores all (subsampled) images fresh. Images whose response does not contain a parseable Safe/Unsafe rating are logged and excluded from evaluation.

Outputs

Writes a timestamped subdirectory under --results-dir with the same schema as the test CLI, consumable by scripts/plot_results.py:

FileDescription
{tag}_predictions.csvOne row per test file: filename, y_true, score, y_pred, model, reject_rate, threshold (reject_rate is always 0.0 for the baseline)
{tag}_metrics.txtAggregate metrics (accuracy, BAC, F1, macro F1, precision, recall)
{tag}_conf_matrix.pngConfusion matrix
sweep_test_results.csvOne-row summary table

Flags

FlagDefaultDescription
--test-dirrequiredDirectory containing test images
--results-dirrequiredOutput directory for results
--classes-configbundled classes.tomlPath to custom classes.toml
--deviceauto0, 1 for CUDA, or cpu
--batch-size8Batch size for VLM inference
--policyO3 Sexual Content + O4 NudityOverride the safety policy description text
--threshold0.5Decision threshold on score (score > thr -> Non_porn)
--model-namellavaguard_hf_v1_2_0_5b_ovTag used in output filenames / model column
--max-samplesoffSubsample to N images (seed=--seed) before scoring
--seed42Random seed for subsampling and generation

Baseline: Falconsai NSFW Image Detection (ViT)

A zero-shot baseline that is separate from the trained one-class pipeline. Instead of learning embeddings + outlier detection, it asks a Falconsai fine-tuned Vision Transformer to classify each image as normal or nsfw and records the model's probability that the image is NSFW (i.e. pornographic). Two models are selectable via --model:

SelectorSourceNotes
nsfw_image_detection (default)Falconsai/nsfw_image_detectionPublic; trained on ~80k images
nsfw_image_detection_26Falconsai/nsfw_image_detection_26Gated; HF_TOKEN must be set and terms accepted; 2026 retrain on ~1.2M images

Both are ViTForImageClassification (ViT-base, 224×224, patch16, 85.8M params).

Usage

# Legacy public model (default)
uv run python scripts/eval_falconsai.py \
  --test-dir datasets/test_easy \
  --results-dir baseline_results

# 2026 gated model (requires HF_TOKEN)
uv run python scripts/eval_falconsai.py \
  --model nsfw_image_detection_26 \
  --test-dir datasets/test_easy \
  --results-dir baseline_results

Score convention

Falconsai returns P(nsfw) = P(porn) (label index resolved from model.config.id2label). To stay compatible with the one-class pipeline (where a higher score = label 1 = Non_porn/outlier), the score column is score = 1 - P(nsfw) = P(normal). Predictions use y_pred = (score > threshold) with --threshold (default 0.5), so an image the model deems pornographic (P(nsfw) > 0.5) is predicted as inlier (0).

Outputs

Writes a timestamped subdirectory under --results-dir with the same schema as the test CLI, consumable by scripts/plot_results.py:

FileDescription
{tag}_predictions.csvOne row per test file: filename, y_true, score, y_pred, model, reject_rate, threshold (reject_rate is always 0.0 for the baseline)
{tag}_metrics.txtAggregate metrics (accuracy, BAC, F1, macro F1, precision, recall)
{tag}_conf_matrix.pngConfusion matrix
sweep_test_results.csvOne-row summary table

Flags

FlagDefaultDescription
--modelnsfw_image_detectionnsfw_image_detection or nsfw_image_detection_26
--test-dirrequiredDirectory containing test images
--results-dirrequiredOutput directory for results
--classes-configbundled classes.tomlPath to custom classes.toml
--deviceauto0, 1 for CUDA, or cpu
--batch-size32Batch size for ViT inference
--cache-dircacheDirectory for the per-image scores cache
--no-cacheoffScore every image fresh, ignoring the cache
--threshold0.5Decision threshold on score (score > thr -> Non_porn)
--model-namefalconsai_nsfw / falconsai_nsfw_26Tag used in output filenames / model column
--max-samplesoffSubsample to N images (seed=--seed) before scoring
--seed42Random seed for subsampling

A lightweight per-image results cache is kept under --cache-dir as <model>_scores.csv (keyed by image MD5). Re-runs skip already-scored images; use --no-cache to bypass. The cache file is per --model-name, so the two models are scored and cached independently.

Data Convention

Class-to-label mappings are defined in src/one_class_porn_detection/classes.toml. The default config:

[[classes]]
name = "Porn"
prefix = "Porn"
label = 0
role = "inlier"

[[classes]]
name = "Non_porn"
prefix = "Non_porn"
label = 1
role = "outlier"

Image filenames must be prefixed with the corresponding prefix field:

  • Porn_* → inlier (label 0)
  • Non_porn_* → outlier (label 1)

The video test pipeline (test-video) applies the same prefix convention to video filenames (e.g. Porn_clip.mp4, Non_porn_scene.mp4).

Override with --classes-config <path> to use a custom mapping.

Dataset Preparation

Raw images typically live in source folders (possibly with subfolders) organized by class. A standalone script, scripts/prepare_data.py, discovers, validates, splits, and prefix-tags images so they are consumable by the train/test CLIs described above.

Recipe

Splits and sources are declared in a fixed TOML recipe (scripts/data_recipe.toml). Each [[source]] maps a raw folder to a role (inlierPorn_ prefix, outlierNon_porn_ prefix), and each [[split]] lists one or more [[split.sample]] entries specifying a source and count:

[defaults]
seed = 42
link = "copy"

[[source]]
name = "porn"
role = "inlier"
root = "data/LSPD/porn"

[[source]]
name = "normal"
role = "outlier"
root = "data/LSPD/normal"

[[split]]
name = "train"
[[split.sample]]
source = "porn"
count = 100000

[[split]]
name = "val"
[[split.sample]]
source = "porn"
count = 50000
[[split.sample]]
source = "normal"
count = 50000

count = "all" uses every valid image from that source (e.g., for APD-2M). A group key reuses the same image set across multiple splits — used by test_easy and test_hard to share the same porn images while pairing them with different outlier classes:

[[split]]
name = "test_easy"
[[split.sample]]
source = "porn"
count = 50000
group = "test_inlier"
[[split.sample]]
source = "normal"
count = 50000

[[split]]
name = "test_hard"
[[split.sample]]
source = "porn"
count = 50000
group = "test_inlier"
[[split.sample]]
source = "sexy"
count = 50000

Prefixes (Porn_, Non_porn_) are read from classes.toml via LabelConfig, so the script never hardcodes them.

Usage

# Dry-run: validate counts against your data without copying
uv run python scripts/prepare_data.py --dry-run

# Full run
uv run python scripts/prepare_data.py \
  --recipe scripts/data_recipe.toml \
  --out-root datasets
FlagDefaultDescription
--recipescripts/data_recipe.tomlPath to the recipe TOML
--out-rootdatasetsRoot directory for prepared split folders
--manifest-dirmanifests/ next to --out-rootDirectory for manifests
--linkcopy (or recipe default)copy, symlink, or hardlink
--seed42 (or recipe default)Random seed for reproducible sampling
--classes-configbundled classes.tomlPath to custom classes.toml
--strict-verifyoffFully decode each image to catch truncated files (slower)
--dry-runoffValidate and allocate without writing files

What the script does

  1. Discover — recursively scans each source root for image files.
  2. Validate — each image is checked with PIL.Image.verify(); --strict-verify adds a full decode to catch truncated files. Corrupted images are skipped and logged.
  3. Allocate — per-source valid images are shuffled deterministically, then exact counts are drawn split-by-split with a global disjointness guarantee (no image appears in two splits unless explicitly shared via group). If a source can't satisfy a requested count, the script raises a clear error.
  4. Write — images are copied (or linked) into flat --out-root/<split>/ directories with the appropriate filename prefix. Collision-safe renaming prevents overwrites.
  5. Manifests — per-split CSV files (source_path, output_name, prefix, source, role, group), a summary.json, and a corrupted.log are written next to the output.

Output layout

datasets/
  train/        Porn_*         (inlier only)
  val/          Porn_* + Non_porn_*
  test_easy/    Porn_* + Non_porn_*
  test_hard/    Porn_* + Non_porn_*
  test_ood/     Porn_* + Non_porn_*
  apd2m_test/   Porn_* + Non_porn_*
manifests/
  summary.json
  <split>.csv
  corrupted.log

These split directories plug directly into the train/test CLIs:

uv run train --train-dir datasets/train --val-dir datasets/val --embedding-model clip
uv run test  --test-dir datasets/test_easy --results-dir results --sweep-csv <path> --embedding-model clip

Data-Scaling Ablation

A shell driver, scripts/run_ablation.sh, trains on nested subsets of the inlier training set (permutation with --seed, so 100 ⊂ 500 ⊂ … ⊂ full within each seed), then tests each resulting model. Writes a manifest CSV linking seed + subset size → sweep CSV → test-results CSV, consumable by scripts/plot_ablation.py.

The first run extracts all train+val embeddings (slow); subsequent subset runs read from the NPZ cache and only fit the detector (fast). Val and test sets are fixed across all runs.

# train + test (default)
bash scripts/run_ablation.sh

# train only
STAGE=train bash scripts/run_ablation.sh

# test only (needs prior train)
STAGE=test bash scripts/run_ablation.sh

Testing runs against every split in TEST_SPLITS (default: test_easy, test_hard, test_ood), each expected as a sibling of TEST_DIR. Each split gets its own manifest and results directory.

Env varDefaultDescription
EMBEDDING_MODELcradiov4_hEmbedding model
DETECTOR_MODELhbosPyOD detector
REDUCERnoneReducer
REJECT_RATES0.001,0.005,0.01,0.05,0.1,0.15,0.2,0.25,0.3,0.4,0.5Comma-separated reject rates
BATCH_SIZE32Batch size for embedding extraction
SELECT_METRICaccuracyMetric for best-config selection
DEVICE(empty)0, 1 for CUDA, or cpu
FULL_SIZE100000Actual full-train count (label only)
SUBSETS_STR10 50 100 500 1000 2000 5000 10000 25000 50000Space-separated subset sizes
SEEDS_STR42 24 11 12 300 543 678 888 975 9001Seeds for subset sampling (full runs once, seed-independent)
TRAIN_DIR…/images/trainTraining image directory
VAL_DIR…/images/valValidation image directory
TEST_DIR…/images/test_easyOne test split (sibling splits used for TEST_SPLITS)
TEST_SPLITS_STRtest_easy test_hard test_oodSpace-separated test splits
TRAIN_OUT…/train_ablation_<model>Training output root
TEST_OUT…/test_ablation_<model>Test output root
MANIFESTablation_<model>_manifest.csvTrain manifest path

Plotting Ablation Results

A standalone script, scripts/plot_ablation.py, plots metrics vs training-set size (log x-axis) from one or more manifest CSVs. ROC-AUC and average precision are recomputed post-hoc from each config's predictions CSV so test runs before and after the Porn-positive reframe plot on a consistent basis. Both PNG and SVG are written.

# single manifest (val + test)
uv run python scripts/plot_ablation.py --manifest ablation_cradiov4_h_test_easy_manifest.csv

# combined overlay across test splits
uv run python scripts/plot_ablation.py \
    --manifest ablation_cradiov4_h_test_easy_manifest.csv \
               ablation_cradiov4_h_test_hard_manifest.csv \
               ablation_cradiov4_h_test_ood_manifest.csv \
    --split-labels test_easy,test_hard,test_ood \
    --metrics accuracy,macro_f1,bac,roc_auc --csv
FlagDefaultDescription
--manifestrequiredOne or more manifest CSVs from run_ablation.sh (one → val+test; multiple → combined overlay)
--metricsaccuracy,macro_f1,bacComma-separated metrics to plot (roc_auc, avg_prec also supported)
--select-metricaccuracyMetric to select best config from sweep CSV (should match test run)
--split-labelsoffComma-separated labels for each manifest in combined mode
--no-valoffCombined mode only: omit the validation reference curve
--annotateoffAnnotate each point with its value
--orientationhorizontalStack subplots horizontal or vertical
--out-dirplots/ablationOutput directory for plots
--out-nameablation_scalingOutput file stem (without extension)
--dpi150PNG output resolution
--titleoffFigure title
--csvoffAlso write the collected metrics as CSV

Five-Way Score Distribution

A standalone script, scripts/plot_score_dist_5way.py, produces a single figure (PNG + SVG) showing outlier-score distributions for five categories drawn from three test splits:

  1. porn — inlier of test_easy (shared with test_hard via the test_inlier group, so taken from test_easy)
  2. normal — outlier of test_easy
  3. sexy — outlier of test_hard
  4. drawing — outlier of test_ood
  5. hentai — inlier of test_ood

The predictions CSV carries no sub-category column, but each split has a fixed inlier/outlier category pair, so split + y_true deterministically recovers the category. The trained threshold is identical across splits (same sweep CSV) and is marked on the plot.

# default: resolve from the three default ablation manifests
uv run python scripts/plot_score_dist_5way.py

# explicit predictions CSVs (fixed order: test_easy, test_hard, test_ood)
uv run python scripts/plot_score_dist_5way.py \
    --predictions-csv easy/hbos_rr0p01_predictions.csv \
                     hard/hbos_rr0p01_predictions.csv \
                     ood/hbos_rr0p01_predictions.csv
FlagDefaultDescription
--predictions-csvoffThree explicit predictions CSVs in fixed order: TEST_EASY TEST_HARD TEST_OOD
--manifestoffThree per-split ablation manifests in fixed order (best-model row auto-selected)
--select-metricaccuracyMetric to pick the best config from sweep_test_results.csv
--out-dirplots/score_dist_5wayOutput directory for plots
--out-namescore_dist_5way_cradiov4_h_hbosOutput file stem (without extension)
--dpi150PNG output resolution
--titleoffFigure title
--ylimoffY-axis limits as min max (e.g. --ylim 0 5)
--legendinsideLegend placement: inside, below, or none

Contributors

StevenArzt

1 commits

Fraunhofer-SIT/WACV2027-OneClassPornDetection

0

stars

1

commits

Python

primary language

Aug 29, 2026

updated

README

One-Class Porn Detection

One-class detection of pornographic images using vision embeddings and outlier detection.

How It Works

  1. Image Embedding — Images are converted to high-dimensional embeddings using a vision model (CLIP, SigLIP2, DINOv3, C-RADIOv4 (incl. distilled), EUPE, AIMv2, MetaCLIP2, SAM3, Sapiens2, or TIPSv2).

  2. One-Class / Outlier Detection — Embeddings are fed to PyOD models trained on normal (pornographic) images. A reject-rate sweep evaluates each model at multiple thresholds to optimize accuracy (or another metric).

Installation

Requires uv and Python 3.13.

git clone <repository-url>
cd one-class-porn-detection
uv sync

CLI Entry Points

Train

uv run train \
  --train-dir <path> \
  --val-dir <path> \
  --embedding-model clip

Sweeps over all configured PyOD models × reject rates, writes a sweep CSV and summary JSON to a timestamped subdirectory under trained_models/<embedding_model>/<reducer>/.

Test

uv run test \
  --test-dir <path> \
  --results-dir <path> \
  --sweep-csv <path/to/sweep.csv> \
  --embedding-model clip

By default, selects the best config from the sweep CSV by --select-metric (default: accuracy, validation metric from training) and evaluates only that single config on the test set. Use --all to test every config in the sweep CSV.

For the evaluated config(s), writes to a timestamped subdirectory under --results-dir:

FileDescription
{tag}_predictions.csvOne row per test file: filename, y_true, score, y_pred, model, reject_rate, threshold. score is the raw outlier score (higher = more anomalous = label 1); y_pred is the hard label at the trained threshold.
{tag}_metrics.txtAggregate metrics (accuracy, BAC, F1, macro F1, precision, recall). The F1(0)/Precision(0)/Recall(0) labels denote the inlier (Porn) as the positive class.
{tag}_conf_matrix.pngConfusion matrix
sweep_test_results.csvSummary table (one row per evaluated config), includes predictions_csv column

Metric convention. The stored score is the raw outlier score (higher = more anomalous = Non_porn/label 1), but Porn (the inlier, label 0) is the positive class for all metrics. Threshold metrics (F1(0)/Precision(0)/ Recall(0)) and average precision treat Porn as positive; average precision and the PR/ROC curves negate the scores at evaluation time to orient Porn as the detection target. ROC-AUC is invariant to the positive-class choice, so it is computed on the raw scores (numerically equal to the Porn-positive AUC). This aligns average precision with the threshold metrics, which already frame Porn (label 0) as the detection target.

Key CLI Arguments

Train (uv run train)

ArgumentDefaultDescription
--train-dirrequiredTraining image directory
--val-dirrequiredValidation image directory
--embedding-modelrequiredaimv2_1b_224, aimv2_large_224, aimv2_huge_224, clip, clip_large, metaclip2_b16, metaclip2_l14, sam3, sapiens2_0_1b, sapiens2_0_4b, sapiens2_0_8b, sapiens2_1_0b, siglip2, siglip2_so400m_384, dinov3_vitl16, dinov3_vith16plus, cradiov4_so400m, cradiov4_h, cradiov4_h_distilled_vitb16, eupe_vit_b, eupe_convnext_b, tipsv2_b14, tipsv2_l14, or tipsv2_so400m14
--modelsiforest,copod,ecod,hbos,loda,inne,dif,ocsvm,pyod_pca,fb_pcaComma-separated PyOD models to evaluate
--reject-rates0.001,0.005,0.01,0.05,0.1,0.2Comma-separated reject rates to sweep
--reducernonenone, pca, grp, or kpca
--reducer-dim256Target dimension for the reducer
--kpca-kernelrbfKernelPCA kernel (linear, poly, rbf, sigmoid, cosine)
--kpca-gammaoffKernelPCA gamma
--kpca-degree3KernelPCA degree
--kpca-coef01.0KernelPCA coef0
--grp-epsoffIf set, GRP uses n_components='auto' with this eps
--batch-size32Batch size for embedding extraction
--no-cacheoffSkip NPZ cache, extract embeddings fresh
--cache-dircacheNPZ embedding cache directory
--deviceauto0, 1 for CUDA, or cpu
--classes-configbundled classes.tomlPath to custom classes.toml
--out-dirtrained_modelsOutput directory
--max-train-samplesoffSubsample training set to N samples (nested with --seed)
--seed42Seed for --max-train-samples subsampling permutation

Test (uv run test)

ArgumentDefaultDescription
--test-dirrequiredTest image directory
--results-dirrequiredOutput directory for test results
--sweep-csvrequiredPath to training sweep CSV
--embedding-modelrequiredSame choices as train
--select-metricaccuracyValidation metric from sweep CSV used to select the best config (accuracy, bac, f1, macro_f1, precision, recall)
--alloffTest every config in the sweep CSV (default: only the best config)
--batch-size32Batch size for embedding extraction
--no-cacheoffSkip NPZ cache, extract embeddings fresh
--cache-dircacheNPZ embedding cache directory
--deviceauto0, 1 for CUDA, or cpu
--classes-configbundled classes.tomlPath to custom classes.toml

Embedding Models

NameSourceParams
aimv2_1b_224apple/aimv2-1B-patch14-2241.0B
aimv2_large_224apple/aimv2-large-patch14-2240.3B
aimv2_huge_224apple/aimv2-huge-patch14-2241.2B
clipopenai/clip-vit-base-patch320.1B
clip_largeopenai/clip-vit-large-patch140.3B
metaclip2_b16facebook/metaclip-2-worldwide-b160.1B
metaclip2_l14facebook/metaclip-2-worldwide-l140.3B
sam3facebook/sam3 (requires HF_TOKEN)0.9B
sapiens2_0_1bfacebook/sapiens2-pretrain-0.1b0.1B
sapiens2_0_4bfacebook/sapiens2-pretrain-0.4b0.4B
sapiens2_0_8bfacebook/sapiens2-pretrain-0.8b0.8B
sapiens2_1_0bfacebook/sapiens2-pretrain-1b1.5B
siglip2google/siglip2-base-patch16-2240.4B
siglip2_so400m_384google/siglip2-so400m-patch14-3841.0B
dinov3_vitl16facebook/dinov3-vitl16-pretrain-lvd1689m (requires HF_TOKEN)0.3B
dinov3_vith16plusfacebook/dinov3-vith16plus-pretrain-lvd1689m (requires HF_TOKEN)0.8B
cradiov4_so400mnvidia/C-RADIOv4-SO400M0.4B
cradiov4_hnvidia/C-RADIOv4-H0.7B
cradiov4_h_distilled_vitb16vit_base_patch16_224 (timm) + local checkpoint (requires CRADIOV4_H_DISTILLED_CKPT)0.1B
eupe_vit_bfacebook/EUPE-ViT-B0.1B
eupe_convnext_bfacebook/EUPE-ConvNeXt-B0.1B
tipsv2_b14google/tipsv2-b140.1B
tipsv2_l14google/tipsv2-l140.3B
tipsv2_so400m14google/tipsv2-so400m140.4B

Detection Methods

10 PyOD models are configured by default:

KeyModel
iforestIsolation Forest
copodCopula-Based Outlier Detection
ecodEmpirical-Cumulative-distribution Outlier Detection
hbosHistogram-Based Outlier Score
lodaLightweight On-line Detector of Anomalies
inneIsolation-based Nearest Neighbor
difDeep Isolation Forest
ocsvmOne-Class SVM
pyod_pcaPyOD PCA
fb_pcaFeature Bagging with PyOD PCA

Embedding Cache

Embeddings are cached as .npz files (keyed by MD5 hash of each image). Subsequent runs only compute embeddings for new images. Use --no-cache to bypass.

A standalone scripts/cleanup_cache.py script is provided for removing cached entries matching a path filter.

Using Trained Models in Python

The OneClassDetector class provides an sklearn-like interface for inference:

from one_class_porn_detection import OneClassDetector

# Load from a sweep CSV row
detector = OneClassDetector.from_sweep_row(
    "trained_models/clip/none/sweep.csv",
    model="iforest",
    reject_rate=0.01,
)

# Or load from individual artifact files
detector = OneClassDetector.load(
    model_path="trained_models/clip/none/iforest_clip_none_base.joblib",
    threshold=0.123456,
    embedding_model="clip",
)

# Predict on a directory of images (handles embedding + normalization)
labels = detector.predict(image_dir="path/to/images/")
# 0 = inlier, 1 = outlier (as defined in classes.toml)

# Or on pre-computed embeddings
labels = detector.predict(embeddings=X)

# Get raw outlier scores
scores = detector.score_samples(image_dir="path/to/images/")

Plotting Results

A standalone script, scripts/plot_results.py, generates diagnostic plots from the *_predictions.csv files produced by test:

uv run python scripts/plot_results.py --results-dir <path>
# or single CSV
uv run python scripts/plot_results.py --predictions-csv <path>

Per-config plots (6 files per config):

PlotDescription
{tag}_roc.pngROC curve with AUC (Porn = positive class)
{tag}_pr.pngPrecision-recall curve with average precision (Porn = positive class)
{tag}_score_dist.pngScore distribution by class (KDE + histogram), trained threshold marked
{tag}_threshold_sweep.pngAccuracy/BAC/F1/precision/recall vs swept threshold
{tag}_confusion_matrix.pngConfusion matrix heatmap (counts + row-normalized)
{tag}_metrics_bar.pngBar chart of 6 aggregate metrics

Combined overlays (when multiple CSVs are found, unless --no-multi):

PlotDescription
combined_roc.pngAll configs overlaid, ranked by AUC
combined_pr.pngAll configs overlaid, ranked by AP
FlagDefaultDescription
--results-dirrequired*Directory containing *_predictions.csv files
--predictions-csvrequired*Single predictions CSV to plot
--out-dir<results-dir>/plotsOutput directory for plots
--classes-configbundled classes.tomlPath to custom classes.toml
--dpi150Output resolution
--no-multioffSkip combined overlay plots

Selecting Extreme Samples

A standalone script, scripts/select_samples.py, copies the highest- and lowest-scoring images per class from a predictions CSV into named subdirectories (e.g. outdir/porn/min, outdir/porn/max, outdir/non_porn/min, outdir/non_porn/max). Useful for inspecting which images the model is most/least confident about.

uv run python scripts/select_samples.py results.csv --data-dir datasets/test_easy -n 10
uv run python scripts/select_samples.py results.csv --data-dir datasets/test_easy \
    -n 10 --outdir selected_samples

Scores follow the repo convention: higher = more anomalous = outlier.

ArgumentDefaultDescription
predictions (positional)requiredPath to a *_predictions.csv file (columns: filename, y_true, score, ...)
--data-dirrequiredDirectory containing the images (matched by basename)
-n / --n-samplesrequiredNumber of highest- and lowest-scoring samples to copy per class
--outdirselected_samplesRoot output directory
--classes-configbundled classes.tomlPath to custom classes.toml (used only for naming subdirectories)

Baseline: ShieldGemma 2 (VLM)

A zero-shot baseline that is separate from the trained one-class pipeline. Instead of learning embeddings + outlier detection, it asks the google/shieldgemma-2-4b-it vision-language model to classify each image against a sexually-explicit safety policy and records the model's probability that the image violates the policy (i.e. is pornographic). The model is gated, so HF_TOKEN must be set.

Usage

uv run python scripts/eval_shieldgemma.py \
  --test-dir datasets/test_easy \
  --results-dir baseline_results

Score convention

ShieldGemma returns probabilities[:,0] = P(violates policy) = P(porn) and [:,1] = P(safe). To stay compatible with the one-class pipeline (where a higher score = label 1 = Non_porn/outlier), the score column is score = 1 - P(violates) = P(safe). Predictions use y_pred = (score > threshold) with --threshold (default 0.5), so an image the model deems pornographic (P(violates) > 0.5) is predicted as inlier (0).

Outputs

Writes a timestamped subdirectory under --results-dir with the same schema as the test CLI, consumable by scripts/plot_results.py:

FileDescription
{tag}_predictions.csvOne row per test file: filename, y_true, score, y_pred, model, reject_rate, threshold (reject_rate is always 0.0 for the baseline)
{tag}_metrics.txtAggregate metrics (accuracy, BAC, F1, macro F1, precision, recall)
{tag}_conf_matrix.pngConfusion matrix
sweep_test_results.csvOne-row summary table

Flags

FlagDefaultDescription
--test-dirrequiredDirectory containing test images
--results-dirrequiredOutput directory for results
--classes-configbundled classes.tomlPath to custom classes.toml
--deviceauto0, 1 for CUDA, or cpu
--batch-size8Batch size for VLM inference
--cache-dircacheDirectory for the per-image scores cache
--no-cacheoffScore every image fresh, ignoring the cache
--policysexually-explicit descriptionOverride the policy description text
--threshold0.5Decision threshold on score (score > thr -> Non_porn)
--model-nameshieldgemma2_4b_itTag used in output filenames / model column
--max-samplesoffSubsample to N images (seed=--seed) before scoring
--seed42Random seed for subsampling

VLM inference is slow, so a lightweight per-image results cache is kept under --cache-dir as <model>_<policy-hash>_scores.csv (keyed by image MD5 + policy text). Re-runs skip already-scored images; use --no-cache to bypass. The default policy is ShieldGemma's "No Sexually Explicit content" description; use --policy to supply a custom one (this changes the cache file so different policies are scored independently).

Baseline: LlavaGuard (VLM, SGLang)

A zero-shot baseline that is separate from the trained one-class pipeline. Instead of learning embeddings + outlier detection, it asks the AIML-TUDA/LlavaGuard-v1.2-0.5B-OV vision-language model to assess each image against the O3 (Sexual Content) and O4 (Nudity Content) safety-policy categories and records the model's Safe/Unsafe rating.

This script is a thin HTTP client that talks to a running SGLang server (OpenAI-compatible /v1/chat/completions vision endpoint). It does not load the model itself — no sglang/torch/transformers dependency — only requests + pillow + eval libs (declared via PEP 723 inline metadata). Launch the server first (from the model card):

CUDA_VISIBLE_DEVICES=0 python -m sglang.launch_server \
    --model-path AIML-TUDA/LlavaGuard-v1.2-0.5B-OV --port 10000

then run the evaluation script:

uv run python scripts/eval_llavaguard.py \
  --test-dir datasets/test_easy \
  --results-dir baseline_results

If you customised --served-model-name on the server, pass the same value to --served-model-name here. The non-HF variant (LlavaQwenForCausalLM, model_type: "llava") is the one SGLang implements natively; the -hf variant is not SGLang-compatible.

LlavaGuard is a generative VLM: it emits a JSON {"rating": "Safe"|"Unsafe", ...} and has no probability head. The rating is therefore mapped to a discrete binary score (1.0 for Safe, 0.0 for Unsafe). Because of this, ROC/PR curves and threshold sweeps are degenerate by design (there is no continuous ranking between images). Generation uses the official SGLang model-card hyperparameters (temperature=0.2, top_p=0.95, top_k=50, max_new_tokens=500; no beam search); sampling_seed=--seed is passed server-side for reproducibility.

Score convention

LlavaGuard returns a Safe/Unsafe rating, where Unsafe means the image violates the sexual/nudity policy (i.e. is pornographic). To stay compatible with the one-class pipeline (where a higher score = label 1 = Non_porn/outlier), the score column is score = 1 - P(Unsafe) = P(Safe) (so Safe -> 1.0, Unsafe -> 0.0). Predictions use a fixed boundary y_pred = (score > 0.5) (not configurable), so an image rated Unsafe (pornographic, score 0) is predicted as inlier (0) and Safe (non-pornographic, score 1) as outlier (1).

Partial results are checkpointed to {tag}_checkpoint.csv every --checkpoint-interval images (default 1000) so an interrupted inference run can be resumed with --resume (pass the exact previous run directory as --results-dir). Without --resume every run scores all (subsampled) images fresh, overwriting any existing checkpoint in the output directory. Images whose response does not contain a parseable Safe/Unsafe rating are logged and excluded from evaluation.

Outputs

Writes a timestamped subdirectory under --results-dir with the same schema as the test CLI, consumable by scripts/plot_results.py:

FileDescription
{tag}_predictions.csvOne row per test file: filename, y_true, score, y_pred, model, reject_rate, threshold (reject_rate is always 0.0 for the baseline)
{tag}_metrics.txtAggregate metrics (accuracy, BAC, F1, macro F1, precision, recall)
{tag}_conf_matrix.pngConfusion matrix
{tag}_raw_responses.csvPer-image debug aid: filename, y_true, rating, raw_response (the full model text)
{tag}_checkpoint.csvIncremental checkpoint: filename, raw_response (enables --resume)
sweep_test_results.csvOne-row summary table

Flags

FlagDefaultDescription
--test-dirrequiredDirectory containing test images
--results-dirrequiredOutput directory for results (pass the exact run subdir for --resume)
--classes-configbundled classes.tomlPath to custom classes.toml
--base-urlhttp://localhost:10000Base URL of the running SGLang server (OpenAI-compatible API)
--served-model-nameAIML-TUDA/LlavaGuard-v1.2-0.5B-OVModel name accepted by the server (its --served-model-name)
--batch-size8Number of images scored concurrently per chunk (server batches continuously)
--policyO3 Sexual Content + O4 NudityOverride the safety policy description text
--model-namellavaguard_v1_2_0_5b_ovTag used in output filenames / model column
--max-samplesoffSubsample to N images (seed=--seed) before scoring
--seed42Random seed for subsampling and generation (sampling_seed passed to the server)
--request-timeout120Per-request HTTP timeout in seconds
--checkpoint-interval1000Save a checkpoint of scored images every N images
--resumeoffResume an incomplete run from the checkpoint in --results-dir

Baseline: LlavaGuard (VLM, HuggingFace)

A zero-shot baseline that is separate from the trained one-class pipeline. Instead of learning embeddings + outlier detection, it asks the AIML-TUDA/LlavaGuard-v1.2-0.5B-OV-hf vision-language model to assess each image against the O3 (Sexual Content) and O4 (Nudity Content) safety-policy categories and records the model's Safe/Unsafe rating.

Unlike the SGLang variant above, this script loads the model locally via transformers (LlavaOnevisionForConditionalGeneration / AutoModelForImageTextToText) — no external server is needed, but it requires a CUDA-capable environment with the full HuggingFace stack installed.

LlavaGuard is a generative VLM: it emits a JSON {"rating": "Safe"|"Unsafe", ...} and has no probability head. The rating is therefore mapped to a discrete binary score (1.0 for Safe, 0.0 for Unsafe). Because of this, ROC/PR curves and threshold sweeps are degenerate by design (there is no continuous ranking between images). Generation uses stochastic sampling (do_sample=True, temperature=0.2, top_p=0.95, top_k=50, num_beams=2, max_new_tokens=200); --seed governs both subsampling and the PyTorch/TorchVision RNG for reproducibility.

Usage

uv run python scripts/eval_llavaguard_hf.py \
  --test-dir datasets/test_easy \
  --results-dir baseline_results

Score convention

LlavaGuard returns a Safe/Unsafe rating, where Unsafe means the image violates the sexual/nudity policy (i.e. is pornographic). To stay compatible with the one-class pipeline (where a higher score = label 1 = Non_porn/outlier), the score column is score = 1 - P(Unsafe) = P(Safe) (so Safe -> 1.0, Unsafe -> 0.0). Predictions use y_pred = (score > threshold) with --threshold (default 0.5), so an image rated Unsafe (pornographic) is predicted as inlier (0).

There is no results cache: every run scores all (subsampled) images fresh. Images whose response does not contain a parseable Safe/Unsafe rating are logged and excluded from evaluation.

Outputs

Writes a timestamped subdirectory under --results-dir with the same schema as the test CLI, consumable by scripts/plot_results.py:

FileDescription
{tag}_predictions.csvOne row per test file: filename, y_true, score, y_pred, model, reject_rate, threshold (reject_rate is always 0.0 for the baseline)
{tag}_metrics.txtAggregate metrics (accuracy, BAC, F1, macro F1, precision, recall)
{tag}_conf_matrix.pngConfusion matrix
sweep_test_results.csvOne-row summary table

Flags

FlagDefaultDescription
--test-dirrequiredDirectory containing test images
--results-dirrequiredOutput directory for results
--classes-configbundled classes.tomlPath to custom classes.toml
--deviceauto0, 1 for CUDA, or cpu
--batch-size8Batch size for VLM inference
--policyO3 Sexual Content + O4 NudityOverride the safety policy description text
--threshold0.5Decision threshold on score (score > thr -> Non_porn)
--model-namellavaguard_hf_v1_2_0_5b_ovTag used in output filenames / model column
--max-samplesoffSubsample to N images (seed=--seed) before scoring
--seed42Random seed for subsampling and generation

Baseline: Falconsai NSFW Image Detection (ViT)

A zero-shot baseline that is separate from the trained one-class pipeline. Instead of learning embeddings + outlier detection, it asks a Falconsai fine-tuned Vision Transformer to classify each image as normal or nsfw and records the model's probability that the image is NSFW (i.e. pornographic). Two models are selectable via --model:

SelectorSourceNotes
nsfw_image_detection (default)Falconsai/nsfw_image_detectionPublic; trained on ~80k images
nsfw_image_detection_26Falconsai/nsfw_image_detection_26Gated; HF_TOKEN must be set and terms accepted; 2026 retrain on ~1.2M images

Both are ViTForImageClassification (ViT-base, 224×224, patch16, 85.8M params).

Usage

# Legacy public model (default)
uv run python scripts/eval_falconsai.py \
  --test-dir datasets/test_easy \
  --results-dir baseline_results

# 2026 gated model (requires HF_TOKEN)
uv run python scripts/eval_falconsai.py \
  --model nsfw_image_detection_26 \
  --test-dir datasets/test_easy \
  --results-dir baseline_results

Score convention

Falconsai returns P(nsfw) = P(porn) (label index resolved from model.config.id2label). To stay compatible with the one-class pipeline (where a higher score = label 1 = Non_porn/outlier), the score column is score = 1 - P(nsfw) = P(normal). Predictions use y_pred = (score > threshold) with --threshold (default 0.5), so an image the model deems pornographic (P(nsfw) > 0.5) is predicted as inlier (0).

Outputs

Writes a timestamped subdirectory under --results-dir with the same schema as the test CLI, consumable by scripts/plot_results.py:

FileDescription
{tag}_predictions.csvOne row per test file: filename, y_true, score, y_pred, model, reject_rate, threshold (reject_rate is always 0.0 for the baseline)
{tag}_metrics.txtAggregate metrics (accuracy, BAC, F1, macro F1, precision, recall)
{tag}_conf_matrix.pngConfusion matrix
sweep_test_results.csvOne-row summary table

Flags

FlagDefaultDescription
--modelnsfw_image_detectionnsfw_image_detection or nsfw_image_detection_26
--test-dirrequiredDirectory containing test images
--results-dirrequiredOutput directory for results
--classes-configbundled classes.tomlPath to custom classes.toml
--deviceauto0, 1 for CUDA, or cpu
--batch-size32Batch size for ViT inference
--cache-dircacheDirectory for the per-image scores cache
--no-cacheoffScore every image fresh, ignoring the cache
--threshold0.5Decision threshold on score (score > thr -> Non_porn)
--model-namefalconsai_nsfw / falconsai_nsfw_26Tag used in output filenames / model column
--max-samplesoffSubsample to N images (seed=--seed) before scoring
--seed42Random seed for subsampling

A lightweight per-image results cache is kept under --cache-dir as <model>_scores.csv (keyed by image MD5). Re-runs skip already-scored images; use --no-cache to bypass. The cache file is per --model-name, so the two models are scored and cached independently.

Data Convention

Class-to-label mappings are defined in src/one_class_porn_detection/classes.toml. The default config:

[[classes]]
name = "Porn"
prefix = "Porn"
label = 0
role = "inlier"

[[classes]]
name = "Non_porn"
prefix = "Non_porn"
label = 1
role = "outlier"

Image filenames must be prefixed with the corresponding prefix field:

  • Porn_* → inlier (label 0)
  • Non_porn_* → outlier (label 1)

The video test pipeline (test-video) applies the same prefix convention to video filenames (e.g. Porn_clip.mp4, Non_porn_scene.mp4).

Override with --classes-config <path> to use a custom mapping.

Dataset Preparation

Raw images typically live in source folders (possibly with subfolders) organized by class. A standalone script, scripts/prepare_data.py, discovers, validates, splits, and prefix-tags images so they are consumable by the train/test CLIs described above.

Recipe

Splits and sources are declared in a fixed TOML recipe (scripts/data_recipe.toml). Each [[source]] maps a raw folder to a role (inlierPorn_ prefix, outlierNon_porn_ prefix), and each [[split]] lists one or more [[split.sample]] entries specifying a source and count:

[defaults]
seed = 42
link = "copy"

[[source]]
name = "porn"
role = "inlier"
root = "data/LSPD/porn"

[[source]]
name = "normal"
role = "outlier"
root = "data/LSPD/normal"

[[split]]
name = "train"
[[split.sample]]
source = "porn"
count = 100000

[[split]]
name = "val"
[[split.sample]]
source = "porn"
count = 50000
[[split.sample]]
source = "normal"
count = 50000

count = "all" uses every valid image from that source (e.g., for APD-2M). A group key reuses the same image set across multiple splits — used by test_easy and test_hard to share the same porn images while pairing them with different outlier classes:

[[split]]
name = "test_easy"
[[split.sample]]
source = "porn"
count = 50000
group = "test_inlier"
[[split.sample]]
source = "normal"
count = 50000

[[split]]
name = "test_hard"
[[split.sample]]
source = "porn"
count = 50000
group = "test_inlier"
[[split.sample]]
source = "sexy"
count = 50000

Prefixes (Porn_, Non_porn_) are read from classes.toml via LabelConfig, so the script never hardcodes them.

Usage

# Dry-run: validate counts against your data without copying
uv run python scripts/prepare_data.py --dry-run

# Full run
uv run python scripts/prepare_data.py \
  --recipe scripts/data_recipe.toml \
  --out-root datasets
FlagDefaultDescription
--recipescripts/data_recipe.tomlPath to the recipe TOML
--out-rootdatasetsRoot directory for prepared split folders
--manifest-dirmanifests/ next to --out-rootDirectory for manifests
--linkcopy (or recipe default)copy, symlink, or hardlink
--seed42 (or recipe default)Random seed for reproducible sampling
--classes-configbundled classes.tomlPath to custom classes.toml
--strict-verifyoffFully decode each image to catch truncated files (slower)
--dry-runoffValidate and allocate without writing files

What the script does

  1. Discover — recursively scans each source root for image files.
  2. Validate — each image is checked with PIL.Image.verify(); --strict-verify adds a full decode to catch truncated files. Corrupted images are skipped and logged.
  3. Allocate — per-source valid images are shuffled deterministically, then exact counts are drawn split-by-split with a global disjointness guarantee (no image appears in two splits unless explicitly shared via group). If a source can't satisfy a requested count, the script raises a clear error.
  4. Write — images are copied (or linked) into flat --out-root/<split>/ directories with the appropriate filename prefix. Collision-safe renaming prevents overwrites.
  5. Manifests — per-split CSV files (source_path, output_name, prefix, source, role, group), a summary.json, and a corrupted.log are written next to the output.

Output layout

datasets/
  train/        Porn_*         (inlier only)
  val/          Porn_* + Non_porn_*
  test_easy/    Porn_* + Non_porn_*
  test_hard/    Porn_* + Non_porn_*
  test_ood/     Porn_* + Non_porn_*
  apd2m_test/   Porn_* + Non_porn_*
manifests/
  summary.json
  <split>.csv
  corrupted.log

These split directories plug directly into the train/test CLIs:

uv run train --train-dir datasets/train --val-dir datasets/val --embedding-model clip
uv run test  --test-dir datasets/test_easy --results-dir results --sweep-csv <path> --embedding-model clip

Data-Scaling Ablation

A shell driver, scripts/run_ablation.sh, trains on nested subsets of the inlier training set (permutation with --seed, so 100 ⊂ 500 ⊂ … ⊂ full within each seed), then tests each resulting model. Writes a manifest CSV linking seed + subset size → sweep CSV → test-results CSV, consumable by scripts/plot_ablation.py.

The first run extracts all train+val embeddings (slow); subsequent subset runs read from the NPZ cache and only fit the detector (fast). Val and test sets are fixed across all runs.

# train + test (default)
bash scripts/run_ablation.sh

# train only
STAGE=train bash scripts/run_ablation.sh

# test only (needs prior train)
STAGE=test bash scripts/run_ablation.sh

Testing runs against every split in TEST_SPLITS (default: test_easy, test_hard, test_ood), each expected as a sibling of TEST_DIR. Each split gets its own manifest and results directory.

Env varDefaultDescription
EMBEDDING_MODELcradiov4_hEmbedding model
DETECTOR_MODELhbosPyOD detector
REDUCERnoneReducer
REJECT_RATES0.001,0.005,0.01,0.05,0.1,0.15,0.2,0.25,0.3,0.4,0.5Comma-separated reject rates
BATCH_SIZE32Batch size for embedding extraction
SELECT_METRICaccuracyMetric for best-config selection
DEVICE(empty)0, 1 for CUDA, or cpu
FULL_SIZE100000Actual full-train count (label only)
SUBSETS_STR10 50 100 500 1000 2000 5000 10000 25000 50000Space-separated subset sizes
SEEDS_STR42 24 11 12 300 543 678 888 975 9001Seeds for subset sampling (full runs once, seed-independent)
TRAIN_DIR…/images/trainTraining image directory
VAL_DIR…/images/valValidation image directory
TEST_DIR…/images/test_easyOne test split (sibling splits used for TEST_SPLITS)
TEST_SPLITS_STRtest_easy test_hard test_oodSpace-separated test splits
TRAIN_OUT…/train_ablation_<model>Training output root
TEST_OUT…/test_ablation_<model>Test output root
MANIFESTablation_<model>_manifest.csvTrain manifest path

Plotting Ablation Results

A standalone script, scripts/plot_ablation.py, plots metrics vs training-set size (log x-axis) from one or more manifest CSVs. ROC-AUC and average precision are recomputed post-hoc from each config's predictions CSV so test runs before and after the Porn-positive reframe plot on a consistent basis. Both PNG and SVG are written.

# single manifest (val + test)
uv run python scripts/plot_ablation.py --manifest ablation_cradiov4_h_test_easy_manifest.csv

# combined overlay across test splits
uv run python scripts/plot_ablation.py \
    --manifest ablation_cradiov4_h_test_easy_manifest.csv \
               ablation_cradiov4_h_test_hard_manifest.csv \
               ablation_cradiov4_h_test_ood_manifest.csv \
    --split-labels test_easy,test_hard,test_ood \
    --metrics accuracy,macro_f1,bac,roc_auc --csv
FlagDefaultDescription
--manifestrequiredOne or more manifest CSVs from run_ablation.sh (one → val+test; multiple → combined overlay)
--metricsaccuracy,macro_f1,bacComma-separated metrics to plot (roc_auc, avg_prec also supported)
--select-metricaccuracyMetric to select best config from sweep CSV (should match test run)
--split-labelsoffComma-separated labels for each manifest in combined mode
--no-valoffCombined mode only: omit the validation reference curve
--annotateoffAnnotate each point with its value
--orientationhorizontalStack subplots horizontal or vertical
--out-dirplots/ablationOutput directory for plots
--out-nameablation_scalingOutput file stem (without extension)
--dpi150PNG output resolution
--titleoffFigure title
--csvoffAlso write the collected metrics as CSV

Five-Way Score Distribution

A standalone script, scripts/plot_score_dist_5way.py, produces a single figure (PNG + SVG) showing outlier-score distributions for five categories drawn from three test splits:

  1. porn — inlier of test_easy (shared with test_hard via the test_inlier group, so taken from test_easy)
  2. normal — outlier of test_easy
  3. sexy — outlier of test_hard
  4. drawing — outlier of test_ood
  5. hentai — inlier of test_ood

The predictions CSV carries no sub-category column, but each split has a fixed inlier/outlier category pair, so split + y_true deterministically recovers the category. The trained threshold is identical across splits (same sweep CSV) and is marked on the plot.

# default: resolve from the three default ablation manifests
uv run python scripts/plot_score_dist_5way.py

# explicit predictions CSVs (fixed order: test_easy, test_hard, test_ood)
uv run python scripts/plot_score_dist_5way.py \
    --predictions-csv easy/hbos_rr0p01_predictions.csv \
                     hard/hbos_rr0p01_predictions.csv \
                     ood/hbos_rr0p01_predictions.csv
FlagDefaultDescription
--predictions-csvoffThree explicit predictions CSVs in fixed order: TEST_EASY TEST_HARD TEST_OOD
--manifestoffThree per-split ablation manifests in fixed order (best-model row auto-selected)
--select-metricaccuracyMetric to pick the best config from sweep_test_results.csv
--out-dirplots/score_dist_5wayOutput directory for plots
--out-namescore_dist_5way_cradiov4_h_hbosOutput file stem (without extension)
--dpi150PNG output resolution
--titleoffFigure title
--ylimoffY-axis limits as min max (e.g. --ylim 0 5)
--legendinsideLegend placement: inside, below, or none

Contributors

StevenArzt

1 commits

Languages

Python

96.8%

Shell

3.2%