One-class detection of pornographic images using vision embeddings and outlier detection.
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).
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).
Requires uv and Python 3.13.
git clone <repository-url>
cd one-class-porn-detection
uv sync
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>/.
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:
| File | Description |
|---|---|
{tag}_predictions.csv | One 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.txt | Aggregate 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.png | Confusion matrix |
sweep_test_results.csv | Summary table (one row per evaluated config), includes predictions_csv column |
Metric convention. The stored
scoreis 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.
uv run train)| Argument | Default | Description |
|---|---|---|
--train-dir | required | Training image directory |
--val-dir | required | Validation image directory |
--embedding-model | required | aimv2_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 |
--models | iforest,copod,ecod,hbos,loda,inne,dif,ocsvm,pyod_pca,fb_pca | Comma-separated PyOD models to evaluate |
--reject-rates | 0.001,0.005,0.01,0.05,0.1,0.2 | Comma-separated reject rates to sweep |
--reducer | none | none, pca, grp, or kpca |
--reducer-dim | 256 | Target dimension for the reducer |
--kpca-kernel | rbf | KernelPCA kernel (linear, poly, rbf, sigmoid, cosine) |
--kpca-gamma | off | KernelPCA gamma |
--kpca-degree | 3 | KernelPCA degree |
--kpca-coef0 | 1.0 | KernelPCA coef0 |
--grp-eps | off | If set, GRP uses n_components='auto' with this eps |
--batch-size | 32 | Batch size for embedding extraction |
--no-cache | off | Skip NPZ cache, extract embeddings fresh |
--cache-dir | cache | NPZ embedding cache directory |
--device | auto | 0, 1 for CUDA, or cpu |
--classes-config | bundled classes.toml | Path to custom classes.toml |
--out-dir | trained_models | Output directory |
--max-train-samples | off | Subsample training set to N samples (nested with --seed) |
--seed | 42 | Seed for --max-train-samples subsampling permutation |
uv run test)| Argument | Default | Description |
|---|---|---|
--test-dir | required | Test image directory |
--results-dir | required | Output directory for test results |
--sweep-csv | required | Path to training sweep CSV |
--embedding-model | required | Same choices as train |
--select-metric | accuracy | Validation metric from sweep CSV used to select the best config (accuracy, bac, f1, macro_f1, precision, recall) |
--all | off | Test every config in the sweep CSV (default: only the best config) |
--batch-size | 32 | Batch size for embedding extraction |
--no-cache | off | Skip NPZ cache, extract embeddings fresh |
--cache-dir | cache | NPZ embedding cache directory |
--device | auto | 0, 1 for CUDA, or cpu |
--classes-config | bundled classes.toml | Path to custom classes.toml |
| Name | Source | Params |
|---|---|---|
aimv2_1b_224 | apple/aimv2-1B-patch14-224 | 1.0B |
aimv2_large_224 | apple/aimv2-large-patch14-224 | 0.3B |
aimv2_huge_224 | apple/aimv2-huge-patch14-224 | 1.2B |
clip | openai/clip-vit-base-patch32 | 0.1B |
clip_large | openai/clip-vit-large-patch14 | 0.3B |
metaclip2_b16 | facebook/metaclip-2-worldwide-b16 | 0.1B |
metaclip2_l14 | facebook/metaclip-2-worldwide-l14 | 0.3B |
sam3 | facebook/sam3 (requires HF_TOKEN) | 0.9B |
sapiens2_0_1b | facebook/sapiens2-pretrain-0.1b | 0.1B |
sapiens2_0_4b | facebook/sapiens2-pretrain-0.4b | 0.4B |
sapiens2_0_8b | facebook/sapiens2-pretrain-0.8b | 0.8B |
sapiens2_1_0b | facebook/sapiens2-pretrain-1b | 1.5B |
siglip2 | google/siglip2-base-patch16-224 | 0.4B |
siglip2_so400m_384 | google/siglip2-so400m-patch14-384 | 1.0B |
dinov3_vitl16 | facebook/dinov3-vitl16-pretrain-lvd1689m (requires HF_TOKEN) | 0.3B |
dinov3_vith16plus | facebook/dinov3-vith16plus-pretrain-lvd1689m (requires HF_TOKEN) | 0.8B |
cradiov4_so400m | nvidia/C-RADIOv4-SO400M | 0.4B |
cradiov4_h | nvidia/C-RADIOv4-H | 0.7B |
cradiov4_h_distilled_vitb16 | vit_base_patch16_224 (timm) + local checkpoint (requires CRADIOV4_H_DISTILLED_CKPT) | 0.1B |
eupe_vit_b | facebook/EUPE-ViT-B | 0.1B |
eupe_convnext_b | facebook/EUPE-ConvNeXt-B | 0.1B |
tipsv2_b14 | google/tipsv2-b14 | 0.1B |
tipsv2_l14 | google/tipsv2-l14 | 0.3B |
tipsv2_so400m14 | google/tipsv2-so400m14 | 0.4B |
10 PyOD models are configured by default:
| Key | Model |
|---|---|
iforest | Isolation Forest |
copod | Copula-Based Outlier Detection |
ecod | Empirical-Cumulative-distribution Outlier Detection |
hbos | Histogram-Based Outlier Score |
loda | Lightweight On-line Detector of Anomalies |
inne | Isolation-based Nearest Neighbor |
dif | Deep Isolation Forest |
ocsvm | One-Class SVM |
pyod_pca | PyOD PCA |
fb_pca | Feature Bagging with PyOD PCA |
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.
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/")
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):
| Plot | Description |
|---|---|
{tag}_roc.png | ROC curve with AUC (Porn = positive class) |
{tag}_pr.png | Precision-recall curve with average precision (Porn = positive class) |
{tag}_score_dist.png | Score distribution by class (KDE + histogram), trained threshold marked |
{tag}_threshold_sweep.png | Accuracy/BAC/F1/precision/recall vs swept threshold |
{tag}_confusion_matrix.png | Confusion matrix heatmap (counts + row-normalized) |
{tag}_metrics_bar.png | Bar chart of 6 aggregate metrics |
Combined overlays (when multiple CSVs are found, unless --no-multi):
| Plot | Description |
|---|---|
combined_roc.png | All configs overlaid, ranked by AUC |
combined_pr.png | All configs overlaid, ranked by AP |
| Flag | Default | Description |
|---|---|---|
--results-dir | required* | Directory containing *_predictions.csv files |
--predictions-csv | required* | Single predictions CSV to plot |
--out-dir | <results-dir>/plots | Output directory for plots |
--classes-config | bundled classes.toml | Path to custom classes.toml |
--dpi | 150 | Output resolution |
--no-multi | off | Skip combined overlay plots |
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.
| Argument | Default | Description |
|---|---|---|
predictions (positional) | required | Path to a *_predictions.csv file (columns: filename, y_true, score, ...) |
--data-dir | required | Directory containing the images (matched by basename) |
-n / --n-samples | required | Number of highest- and lowest-scoring samples to copy per class |
--outdir | selected_samples | Root output directory |
--classes-config | bundled classes.toml | Path to custom classes.toml (used only for naming subdirectories) |
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.
uv run python scripts/eval_shieldgemma.py \
--test-dir datasets/test_easy \
--results-dir baseline_results
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).
Writes a timestamped subdirectory under --results-dir with the same schema as
the test CLI, consumable by scripts/plot_results.py:
| File | Description |
|---|---|
{tag}_predictions.csv | One 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.txt | Aggregate metrics (accuracy, BAC, F1, macro F1, precision, recall) |
{tag}_conf_matrix.png | Confusion matrix |
sweep_test_results.csv | One-row summary table |
| Flag | Default | Description |
|---|---|---|
--test-dir | required | Directory containing test images |
--results-dir | required | Output directory for results |
--classes-config | bundled classes.toml | Path to custom classes.toml |
--device | auto | 0, 1 for CUDA, or cpu |
--batch-size | 8 | Batch size for VLM inference |
--cache-dir | cache | Directory for the per-image scores cache |
--no-cache | off | Score every image fresh, ignoring the cache |
--policy | sexually-explicit description | Override the policy description text |
--threshold | 0.5 | Decision threshold on score (score > thr -> Non_porn) |
--model-name | shieldgemma2_4b_it | Tag used in output filenames / model column |
--max-samples | off | Subsample to N images (seed=--seed) before scoring |
--seed | 42 | Random 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).
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.
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.
Writes a timestamped subdirectory under --results-dir with the same schema as
the test CLI, consumable by scripts/plot_results.py:
| File | Description |
|---|---|
{tag}_predictions.csv | One 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.txt | Aggregate metrics (accuracy, BAC, F1, macro F1, precision, recall) |
{tag}_conf_matrix.png | Confusion matrix |
{tag}_raw_responses.csv | Per-image debug aid: filename, y_true, rating, raw_response (the full model text) |
{tag}_checkpoint.csv | Incremental checkpoint: filename, raw_response (enables --resume) |
sweep_test_results.csv | One-row summary table |
| Flag | Default | Description |
|---|---|---|
--test-dir | required | Directory containing test images |
--results-dir | required | Output directory for results (pass the exact run subdir for --resume) |
--classes-config | bundled classes.toml | Path to custom classes.toml |
--base-url | http://localhost:10000 | Base URL of the running SGLang server (OpenAI-compatible API) |
--served-model-name | AIML-TUDA/LlavaGuard-v1.2-0.5B-OV | Model name accepted by the server (its --served-model-name) |
--batch-size | 8 | Number of images scored concurrently per chunk (server batches continuously) |
--policy | O3 Sexual Content + O4 Nudity | Override the safety policy description text |
--model-name | llavaguard_v1_2_0_5b_ov | Tag used in output filenames / model column |
--max-samples | off | Subsample to N images (seed=--seed) before scoring |
--seed | 42 | Random seed for subsampling and generation (sampling_seed passed to the server) |
--request-timeout | 120 | Per-request HTTP timeout in seconds |
--checkpoint-interval | 1000 | Save a checkpoint of scored images every N images |
--resume | off | Resume an incomplete run from the checkpoint in --results-dir |
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.
uv run python scripts/eval_llavaguard_hf.py \
--test-dir datasets/test_easy \
--results-dir baseline_results
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.
Writes a timestamped subdirectory under --results-dir with the same schema as
the test CLI, consumable by scripts/plot_results.py:
| File | Description |
|---|---|
{tag}_predictions.csv | One 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.txt | Aggregate metrics (accuracy, BAC, F1, macro F1, precision, recall) |
{tag}_conf_matrix.png | Confusion matrix |
sweep_test_results.csv | One-row summary table |
| Flag | Default | Description |
|---|---|---|
--test-dir | required | Directory containing test images |
--results-dir | required | Output directory for results |
--classes-config | bundled classes.toml | Path to custom classes.toml |
--device | auto | 0, 1 for CUDA, or cpu |
--batch-size | 8 | Batch size for VLM inference |
--policy | O3 Sexual Content + O4 Nudity | Override the safety policy description text |
--threshold | 0.5 | Decision threshold on score (score > thr -> Non_porn) |
--model-name | llavaguard_hf_v1_2_0_5b_ov | Tag used in output filenames / model column |
--max-samples | off | Subsample to N images (seed=--seed) before scoring |
--seed | 42 | Random seed for subsampling and generation |
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:
| Selector | Source | Notes |
|---|---|---|
nsfw_image_detection (default) | Falconsai/nsfw_image_detection | Public; trained on ~80k images |
nsfw_image_detection_26 | Falconsai/nsfw_image_detection_26 | Gated; 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).
# 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
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).
Writes a timestamped subdirectory under --results-dir with the same schema as
the test CLI, consumable by scripts/plot_results.py:
| File | Description |
|---|---|
{tag}_predictions.csv | One 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.txt | Aggregate metrics (accuracy, BAC, F1, macro F1, precision, recall) |
{tag}_conf_matrix.png | Confusion matrix |
sweep_test_results.csv | One-row summary table |
| Flag | Default | Description |
|---|---|---|
--model | nsfw_image_detection | nsfw_image_detection or nsfw_image_detection_26 |
--test-dir | required | Directory containing test images |
--results-dir | required | Output directory for results |
--classes-config | bundled classes.toml | Path to custom classes.toml |
--device | auto | 0, 1 for CUDA, or cpu |
--batch-size | 32 | Batch size for ViT inference |
--cache-dir | cache | Directory for the per-image scores cache |
--no-cache | off | Score every image fresh, ignoring the cache |
--threshold | 0.5 | Decision threshold on score (score > thr -> Non_porn) |
--model-name | falconsai_nsfw / falconsai_nsfw_26 | Tag used in output filenames / model column |
--max-samples | off | Subsample to N images (seed=--seed) before scoring |
--seed | 42 | Random 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.
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.
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.
Splits and sources are declared in a fixed TOML recipe (scripts/data_recipe.toml). Each [[source]] maps a raw folder to a role (inlier → Porn_ prefix, outlier → Non_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.
# 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
| Flag | Default | Description |
|---|---|---|
--recipe | scripts/data_recipe.toml | Path to the recipe TOML |
--out-root | datasets | Root directory for prepared split folders |
--manifest-dir | manifests/ next to --out-root | Directory for manifests |
--link | copy (or recipe default) | copy, symlink, or hardlink |
--seed | 42 (or recipe default) | Random seed for reproducible sampling |
--classes-config | bundled classes.toml | Path to custom classes.toml |
--strict-verify | off | Fully decode each image to catch truncated files (slower) |
--dry-run | off | Validate and allocate without writing files |
root for image files.PIL.Image.verify(); --strict-verify adds a full decode to catch truncated files. Corrupted images are skipped and logged.group). If a source can't satisfy a requested count, the script raises a clear error.--out-root/<split>/ directories with the appropriate filename prefix. Collision-safe renaming prevents overwrites.source_path, output_name, prefix, source, role, group), a summary.json, and a corrupted.log are written next to the output.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
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 var | Default | Description |
|---|---|---|
EMBEDDING_MODEL | cradiov4_h | Embedding model |
DETECTOR_MODEL | hbos | PyOD detector |
REDUCER | none | Reducer |
REJECT_RATES | 0.001,0.005,0.01,0.05,0.1,0.15,0.2,0.25,0.3,0.4,0.5 | Comma-separated reject rates |
BATCH_SIZE | 32 | Batch size for embedding extraction |
SELECT_METRIC | accuracy | Metric for best-config selection |
DEVICE | (empty) | 0, 1 for CUDA, or cpu |
FULL_SIZE | 100000 | Actual full-train count (label only) |
SUBSETS_STR | 10 50 100 500 1000 2000 5000 10000 25000 50000 | Space-separated subset sizes |
SEEDS_STR | 42 24 11 12 300 543 678 888 975 9001 | Seeds for subset sampling (full runs once, seed-independent) |
TRAIN_DIR | …/images/train | Training image directory |
VAL_DIR | …/images/val | Validation image directory |
TEST_DIR | …/images/test_easy | One test split (sibling splits used for TEST_SPLITS) |
TEST_SPLITS_STR | test_easy test_hard test_ood | Space-separated test splits |
TRAIN_OUT | …/train_ablation_<model> | Training output root |
TEST_OUT | …/test_ablation_<model> | Test output root |
MANIFEST | ablation_<model>_manifest.csv | Train manifest path |
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
| Flag | Default | Description |
|---|---|---|
--manifest | required | One or more manifest CSVs from run_ablation.sh (one → val+test; multiple → combined overlay) |
--metrics | accuracy,macro_f1,bac | Comma-separated metrics to plot (roc_auc, avg_prec also supported) |
--select-metric | accuracy | Metric to select best config from sweep CSV (should match test run) |
--split-labels | off | Comma-separated labels for each manifest in combined mode |
--no-val | off | Combined mode only: omit the validation reference curve |
--annotate | off | Annotate each point with its value |
--orientation | horizontal | Stack subplots horizontal or vertical |
--out-dir | plots/ablation | Output directory for plots |
--out-name | ablation_scaling | Output file stem (without extension) |
--dpi | 150 | PNG output resolution |
--title | off | Figure title |
--csv | off | Also write the collected metrics as CSV |
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:
test_easy (shared with test_hard via the
test_inlier group, so taken from test_easy)test_easytest_hardtest_oodtest_oodThe 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
| Flag | Default | Description |
|---|---|---|
--predictions-csv | off | Three explicit predictions CSVs in fixed order: TEST_EASY TEST_HARD TEST_OOD |
--manifest | off | Three per-split ablation manifests in fixed order (best-model row auto-selected) |
--select-metric | accuracy | Metric to pick the best config from sweep_test_results.csv |
--out-dir | plots/score_dist_5way | Output directory for plots |
--out-name | score_dist_5way_cradiov4_h_hbos | Output file stem (without extension) |
--dpi | 150 | PNG output resolution |
--title | off | Figure title |
--ylim | off | Y-axis limits as min max (e.g. --ylim 0 5) |
--legend | inside | Legend placement: inside, below, or none |
1 commits
Python
96.8%
Shell
3.2%
One-class detection of pornographic images using vision embeddings and outlier detection.
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).
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).
Requires uv and Python 3.13.
git clone <repository-url>
cd one-class-porn-detection
uv sync
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>/.
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:
| File | Description |
|---|---|
{tag}_predictions.csv | One 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.txt | Aggregate 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.png | Confusion matrix |
sweep_test_results.csv | Summary table (one row per evaluated config), includes predictions_csv column |
Metric convention. The stored
scoreis 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.
uv run train)| Argument | Default | Description |
|---|---|---|
--train-dir | required | Training image directory |
--val-dir | required | Validation image directory |
--embedding-model | required | aimv2_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 |
--models | iforest,copod,ecod,hbos,loda,inne,dif,ocsvm,pyod_pca,fb_pca | Comma-separated PyOD models to evaluate |
--reject-rates | 0.001,0.005,0.01,0.05,0.1,0.2 | Comma-separated reject rates to sweep |
--reducer | none | none, pca, grp, or kpca |
--reducer-dim | 256 | Target dimension for the reducer |
--kpca-kernel | rbf | KernelPCA kernel (linear, poly, rbf, sigmoid, cosine) |
--kpca-gamma | off | KernelPCA gamma |
--kpca-degree | 3 | KernelPCA degree |
--kpca-coef0 | 1.0 | KernelPCA coef0 |
--grp-eps | off | If set, GRP uses n_components='auto' with this eps |
--batch-size | 32 | Batch size for embedding extraction |
--no-cache | off | Skip NPZ cache, extract embeddings fresh |
--cache-dir | cache | NPZ embedding cache directory |
--device | auto | 0, 1 for CUDA, or cpu |
--classes-config | bundled classes.toml | Path to custom classes.toml |
--out-dir | trained_models | Output directory |
--max-train-samples | off | Subsample training set to N samples (nested with --seed) |
--seed | 42 | Seed for --max-train-samples subsampling permutation |
uv run test)| Argument | Default | Description |
|---|---|---|
--test-dir | required | Test image directory |
--results-dir | required | Output directory for test results |
--sweep-csv | required | Path to training sweep CSV |
--embedding-model | required | Same choices as train |
--select-metric | accuracy | Validation metric from sweep CSV used to select the best config (accuracy, bac, f1, macro_f1, precision, recall) |
--all | off | Test every config in the sweep CSV (default: only the best config) |
--batch-size | 32 | Batch size for embedding extraction |
--no-cache | off | Skip NPZ cache, extract embeddings fresh |
--cache-dir | cache | NPZ embedding cache directory |
--device | auto | 0, 1 for CUDA, or cpu |
--classes-config | bundled classes.toml | Path to custom classes.toml |
| Name | Source | Params |
|---|---|---|
aimv2_1b_224 | apple/aimv2-1B-patch14-224 | 1.0B |
aimv2_large_224 | apple/aimv2-large-patch14-224 | 0.3B |
aimv2_huge_224 | apple/aimv2-huge-patch14-224 | 1.2B |
clip | openai/clip-vit-base-patch32 | 0.1B |
clip_large | openai/clip-vit-large-patch14 | 0.3B |
metaclip2_b16 | facebook/metaclip-2-worldwide-b16 | 0.1B |
metaclip2_l14 | facebook/metaclip-2-worldwide-l14 | 0.3B |
sam3 | facebook/sam3 (requires HF_TOKEN) | 0.9B |
sapiens2_0_1b | facebook/sapiens2-pretrain-0.1b | 0.1B |
sapiens2_0_4b | facebook/sapiens2-pretrain-0.4b | 0.4B |
sapiens2_0_8b | facebook/sapiens2-pretrain-0.8b | 0.8B |
sapiens2_1_0b | facebook/sapiens2-pretrain-1b | 1.5B |
siglip2 | google/siglip2-base-patch16-224 | 0.4B |
siglip2_so400m_384 | google/siglip2-so400m-patch14-384 | 1.0B |
dinov3_vitl16 | facebook/dinov3-vitl16-pretrain-lvd1689m (requires HF_TOKEN) | 0.3B |
dinov3_vith16plus | facebook/dinov3-vith16plus-pretrain-lvd1689m (requires HF_TOKEN) | 0.8B |
cradiov4_so400m | nvidia/C-RADIOv4-SO400M | 0.4B |
cradiov4_h | nvidia/C-RADIOv4-H | 0.7B |
cradiov4_h_distilled_vitb16 | vit_base_patch16_224 (timm) + local checkpoint (requires CRADIOV4_H_DISTILLED_CKPT) | 0.1B |
eupe_vit_b | facebook/EUPE-ViT-B | 0.1B |
eupe_convnext_b | facebook/EUPE-ConvNeXt-B | 0.1B |
tipsv2_b14 | google/tipsv2-b14 | 0.1B |
tipsv2_l14 | google/tipsv2-l14 | 0.3B |
tipsv2_so400m14 | google/tipsv2-so400m14 | 0.4B |
10 PyOD models are configured by default:
| Key | Model |
|---|---|
iforest | Isolation Forest |
copod | Copula-Based Outlier Detection |
ecod | Empirical-Cumulative-distribution Outlier Detection |
hbos | Histogram-Based Outlier Score |
loda | Lightweight On-line Detector of Anomalies |
inne | Isolation-based Nearest Neighbor |
dif | Deep Isolation Forest |
ocsvm | One-Class SVM |
pyod_pca | PyOD PCA |
fb_pca | Feature Bagging with PyOD PCA |
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.
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/")
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):
| Plot | Description |
|---|---|
{tag}_roc.png | ROC curve with AUC (Porn = positive class) |
{tag}_pr.png | Precision-recall curve with average precision (Porn = positive class) |
{tag}_score_dist.png | Score distribution by class (KDE + histogram), trained threshold marked |
{tag}_threshold_sweep.png | Accuracy/BAC/F1/precision/recall vs swept threshold |
{tag}_confusion_matrix.png | Confusion matrix heatmap (counts + row-normalized) |
{tag}_metrics_bar.png | Bar chart of 6 aggregate metrics |
Combined overlays (when multiple CSVs are found, unless --no-multi):
| Plot | Description |
|---|---|
combined_roc.png | All configs overlaid, ranked by AUC |
combined_pr.png | All configs overlaid, ranked by AP |
| Flag | Default | Description |
|---|---|---|
--results-dir | required* | Directory containing *_predictions.csv files |
--predictions-csv | required* | Single predictions CSV to plot |
--out-dir | <results-dir>/plots | Output directory for plots |
--classes-config | bundled classes.toml | Path to custom classes.toml |
--dpi | 150 | Output resolution |
--no-multi | off | Skip combined overlay plots |
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.
| Argument | Default | Description |
|---|---|---|
predictions (positional) | required | Path to a *_predictions.csv file (columns: filename, y_true, score, ...) |
--data-dir | required | Directory containing the images (matched by basename) |
-n / --n-samples | required | Number of highest- and lowest-scoring samples to copy per class |
--outdir | selected_samples | Root output directory |
--classes-config | bundled classes.toml | Path to custom classes.toml (used only for naming subdirectories) |
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.
uv run python scripts/eval_shieldgemma.py \
--test-dir datasets/test_easy \
--results-dir baseline_results
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).
Writes a timestamped subdirectory under --results-dir with the same schema as
the test CLI, consumable by scripts/plot_results.py:
| File | Description |
|---|---|
{tag}_predictions.csv | One 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.txt | Aggregate metrics (accuracy, BAC, F1, macro F1, precision, recall) |
{tag}_conf_matrix.png | Confusion matrix |
sweep_test_results.csv | One-row summary table |
| Flag | Default | Description |
|---|---|---|
--test-dir | required | Directory containing test images |
--results-dir | required | Output directory for results |
--classes-config | bundled classes.toml | Path to custom classes.toml |
--device | auto | 0, 1 for CUDA, or cpu |
--batch-size | 8 | Batch size for VLM inference |
--cache-dir | cache | Directory for the per-image scores cache |
--no-cache | off | Score every image fresh, ignoring the cache |
--policy | sexually-explicit description | Override the policy description text |
--threshold | 0.5 | Decision threshold on score (score > thr -> Non_porn) |
--model-name | shieldgemma2_4b_it | Tag used in output filenames / model column |
--max-samples | off | Subsample to N images (seed=--seed) before scoring |
--seed | 42 | Random 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).
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.
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.
Writes a timestamped subdirectory under --results-dir with the same schema as
the test CLI, consumable by scripts/plot_results.py:
| File | Description |
|---|---|
{tag}_predictions.csv | One 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.txt | Aggregate metrics (accuracy, BAC, F1, macro F1, precision, recall) |
{tag}_conf_matrix.png | Confusion matrix |
{tag}_raw_responses.csv | Per-image debug aid: filename, y_true, rating, raw_response (the full model text) |
{tag}_checkpoint.csv | Incremental checkpoint: filename, raw_response (enables --resume) |
sweep_test_results.csv | One-row summary table |
| Flag | Default | Description |
|---|---|---|
--test-dir | required | Directory containing test images |
--results-dir | required | Output directory for results (pass the exact run subdir for --resume) |
--classes-config | bundled classes.toml | Path to custom classes.toml |
--base-url | http://localhost:10000 | Base URL of the running SGLang server (OpenAI-compatible API) |
--served-model-name | AIML-TUDA/LlavaGuard-v1.2-0.5B-OV | Model name accepted by the server (its --served-model-name) |
--batch-size | 8 | Number of images scored concurrently per chunk (server batches continuously) |
--policy | O3 Sexual Content + O4 Nudity | Override the safety policy description text |
--model-name | llavaguard_v1_2_0_5b_ov | Tag used in output filenames / model column |
--max-samples | off | Subsample to N images (seed=--seed) before scoring |
--seed | 42 | Random seed for subsampling and generation (sampling_seed passed to the server) |
--request-timeout | 120 | Per-request HTTP timeout in seconds |
--checkpoint-interval | 1000 | Save a checkpoint of scored images every N images |
--resume | off | Resume an incomplete run from the checkpoint in --results-dir |
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.
uv run python scripts/eval_llavaguard_hf.py \
--test-dir datasets/test_easy \
--results-dir baseline_results
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.
Writes a timestamped subdirectory under --results-dir with the same schema as
the test CLI, consumable by scripts/plot_results.py:
| File | Description |
|---|---|
{tag}_predictions.csv | One 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.txt | Aggregate metrics (accuracy, BAC, F1, macro F1, precision, recall) |
{tag}_conf_matrix.png | Confusion matrix |
sweep_test_results.csv | One-row summary table |
| Flag | Default | Description |
|---|---|---|
--test-dir | required | Directory containing test images |
--results-dir | required | Output directory for results |
--classes-config | bundled classes.toml | Path to custom classes.toml |
--device | auto | 0, 1 for CUDA, or cpu |
--batch-size | 8 | Batch size for VLM inference |
--policy | O3 Sexual Content + O4 Nudity | Override the safety policy description text |
--threshold | 0.5 | Decision threshold on score (score > thr -> Non_porn) |
--model-name | llavaguard_hf_v1_2_0_5b_ov | Tag used in output filenames / model column |
--max-samples | off | Subsample to N images (seed=--seed) before scoring |
--seed | 42 | Random seed for subsampling and generation |
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:
| Selector | Source | Notes |
|---|---|---|
nsfw_image_detection (default) | Falconsai/nsfw_image_detection | Public; trained on ~80k images |
nsfw_image_detection_26 | Falconsai/nsfw_image_detection_26 | Gated; 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).
# 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
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).
Writes a timestamped subdirectory under --results-dir with the same schema as
the test CLI, consumable by scripts/plot_results.py:
| File | Description |
|---|---|
{tag}_predictions.csv | One 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.txt | Aggregate metrics (accuracy, BAC, F1, macro F1, precision, recall) |
{tag}_conf_matrix.png | Confusion matrix |
sweep_test_results.csv | One-row summary table |
| Flag | Default | Description |
|---|---|---|
--model | nsfw_image_detection | nsfw_image_detection or nsfw_image_detection_26 |
--test-dir | required | Directory containing test images |
--results-dir | required | Output directory for results |
--classes-config | bundled classes.toml | Path to custom classes.toml |
--device | auto | 0, 1 for CUDA, or cpu |
--batch-size | 32 | Batch size for ViT inference |
--cache-dir | cache | Directory for the per-image scores cache |
--no-cache | off | Score every image fresh, ignoring the cache |
--threshold | 0.5 | Decision threshold on score (score > thr -> Non_porn) |
--model-name | falconsai_nsfw / falconsai_nsfw_26 | Tag used in output filenames / model column |
--max-samples | off | Subsample to N images (seed=--seed) before scoring |
--seed | 42 | Random 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.
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.
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.
Splits and sources are declared in a fixed TOML recipe (scripts/data_recipe.toml). Each [[source]] maps a raw folder to a role (inlier → Porn_ prefix, outlier → Non_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.
# 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
| Flag | Default | Description |
|---|---|---|
--recipe | scripts/data_recipe.toml | Path to the recipe TOML |
--out-root | datasets | Root directory for prepared split folders |
--manifest-dir | manifests/ next to --out-root | Directory for manifests |
--link | copy (or recipe default) | copy, symlink, or hardlink |
--seed | 42 (or recipe default) | Random seed for reproducible sampling |
--classes-config | bundled classes.toml | Path to custom classes.toml |
--strict-verify | off | Fully decode each image to catch truncated files (slower) |
--dry-run | off | Validate and allocate without writing files |
root for image files.PIL.Image.verify(); --strict-verify adds a full decode to catch truncated files. Corrupted images are skipped and logged.group). If a source can't satisfy a requested count, the script raises a clear error.--out-root/<split>/ directories with the appropriate filename prefix. Collision-safe renaming prevents overwrites.source_path, output_name, prefix, source, role, group), a summary.json, and a corrupted.log are written next to the output.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
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 var | Default | Description |
|---|---|---|
EMBEDDING_MODEL | cradiov4_h | Embedding model |
DETECTOR_MODEL | hbos | PyOD detector |
REDUCER | none | Reducer |
REJECT_RATES | 0.001,0.005,0.01,0.05,0.1,0.15,0.2,0.25,0.3,0.4,0.5 | Comma-separated reject rates |
BATCH_SIZE | 32 | Batch size for embedding extraction |
SELECT_METRIC | accuracy | Metric for best-config selection |
DEVICE | (empty) | 0, 1 for CUDA, or cpu |
FULL_SIZE | 100000 | Actual full-train count (label only) |
SUBSETS_STR | 10 50 100 500 1000 2000 5000 10000 25000 50000 | Space-separated subset sizes |
SEEDS_STR | 42 24 11 12 300 543 678 888 975 9001 | Seeds for subset sampling (full runs once, seed-independent) |
TRAIN_DIR | …/images/train | Training image directory |
VAL_DIR | …/images/val | Validation image directory |
TEST_DIR | …/images/test_easy | One test split (sibling splits used for TEST_SPLITS) |
TEST_SPLITS_STR | test_easy test_hard test_ood | Space-separated test splits |
TRAIN_OUT | …/train_ablation_<model> | Training output root |
TEST_OUT | …/test_ablation_<model> | Test output root |
MANIFEST | ablation_<model>_manifest.csv | Train manifest path |
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
| Flag | Default | Description |
|---|---|---|
--manifest | required | One or more manifest CSVs from run_ablation.sh (one → val+test; multiple → combined overlay) |
--metrics | accuracy,macro_f1,bac | Comma-separated metrics to plot (roc_auc, avg_prec also supported) |
--select-metric | accuracy | Metric to select best config from sweep CSV (should match test run) |
--split-labels | off | Comma-separated labels for each manifest in combined mode |
--no-val | off | Combined mode only: omit the validation reference curve |
--annotate | off | Annotate each point with its value |
--orientation | horizontal | Stack subplots horizontal or vertical |
--out-dir | plots/ablation | Output directory for plots |
--out-name | ablation_scaling | Output file stem (without extension) |
--dpi | 150 | PNG output resolution |
--title | off | Figure title |
--csv | off | Also write the collected metrics as CSV |
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:
test_easy (shared with test_hard via the
test_inlier group, so taken from test_easy)test_easytest_hardtest_oodtest_oodThe 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
| Flag | Default | Description |
|---|---|---|
--predictions-csv | off | Three explicit predictions CSVs in fixed order: TEST_EASY TEST_HARD TEST_OOD |
--manifest | off | Three per-split ablation manifests in fixed order (best-model row auto-selected) |
--select-metric | accuracy | Metric to pick the best config from sweep_test_results.csv |
--out-dir | plots/score_dist_5way | Output directory for plots |
--out-name | score_dist_5way_cradiov4_h_hbos | Output file stem (without extension) |
--dpi | 150 | PNG output resolution |
--title | off | Figure title |
--ylim | off | Y-axis limits as min max (e.g. --ylim 0 5) |
--legend | inside | Legend placement: inside, below, or none |
1 commits
Python
96.8%
Shell
3.2%