Lightweight, CPU-only detector that scores the probability that an image was generated by AI — no GPU, no PyTorch/TensorFlow. The goal is a cheap baseline that runs anywhere: the default pipeline is pure classical image forensics (NumPy/SciPy), and an optional, still-CPU CLIP probe (ONNX) adds a first lightweight neural signal for better generalization across unseen image sources — off by default.
Under the hood it is a reproducible, evolvable calibration pipeline: pluggable labeled data sources, training provenance (git SHA + library versions + data manifest) baked into every artifact, seeded determinism, and honest cross-generator / degradation benchmarks. Swapping or stacking a stronger model is just re-running the pipeline — so richer neural detectors can be folded in as they become cheap enough to run on CPU. Treat the score as a probabilistic aid, not a verdict (see Known limitations).
Two layers:
-
Features (
aidetect.features) — pure NumPy/SciPy/Pillow functions, each turning an image into a scalar:- Azimuthally-averaged power spectrum (high-frequency ratio, log-log slope, residual spikes) — the strongest signal; generators that upsample via up-convolution fail to reproduce the high-frequency tail of natural images (Durall et al., CVPR 2020).
- FFT magnitude uniformity.
- Error Level Analysis and 8×8 JPEG block variance (low weight: these reflect compression history more than generative origin).
- High-frequency (Laplacian) energy.
- Mean per-channel color entropy.
- Texture patch-variance consistency.
- Chromatic aberration (channel edge correlation).
- EXIF/C2PA AI-provenance metadata flag.
- SRM high-pass noise-residual statistics (std and excess kurtosis) — real photos carry rich, near-Gaussian sensor noise; many generators leave a depleted or distorted residual (Fridrich & Kodovský, 2012).
- Patch-based spatial-consistency dispersion of the residual and Laplacian maps — camera noise is spatially uniform, while synthesis can leave high-frequency energy unevenly distributed across the image.
The feature vector is a fixed, append-only sequence of 15 scalars (
FEATURE_NAMESinfeatures/extract.py). -
Scoring (
aidetect.scoring):- default — hand-set, uncalibrated weights. Works with zero setup, but scores are crude. Clearly labeled as such.
- calibrated — a
StandardScaler + LogisticRegressionpipeline loaded frommodels/calibrated.joblib. Used automatically when present. - clip (optional, off by default) — a logistic probe on CLIP image embeddings
(ViT-B/32 vision tower, ONNX, CPU). Generalizes far better across unseen
real-image domains than the forensic features. Enable with
--clip/AIDETECT_CLIP=1; see Optional CLIP probe.
pip install ".[api,calibrate,dev]"Core dependencies are numpy, scipy, Pillow, piexif. The API and
calibration dependencies are optional extras. There is no torch/torchvision.
The optional clip extra (pip install ".[clip]") adds onnxruntime for the
CLIP probe — still CPU-only, and off by default.
aidetect path/to/image.png # human-readable report
aidetect path/to/image.png --json # machine-readable JSON
aidetect path/to/image.png --verbose # per-feature values and contributions
aidetect path/to/image.png --model models/calibrated.joblib
aidetect path/to/image.png --clip # blend in the optional CLIP probe (off by default)
aidetect --model-info # loaded model's metrics + provenance (no image)
aidetect --model-info --json # the same, machine-readablefrom aidetect import analyze
result = analyze("path/to/image.png")
print(result.ai_score, result.probability, result.verdict, result.mode)Each result carries both a precise probability in [0, 1] and a coarse integer
ai_score from 1 to 100 (min(100, max(1, round(probability * 100)))). probability
is the authoritative value; ai_score is a display convenience, and its floor of 1
means "very unlikely", not "impossible" — use probability when you need precision.
uvicorn aidetect.api:app --host 0.0.0.0 --port 8000GET /→ a minimal browser UI for manually uploading one image and viewing the verdict/score (drag-and-drop, image preview, raw JSON). Openhttp://localhost:8000/.GET /health→{"status": "ok", "mode": ..., "version": ..., "max_concurrency": N}GET /model→{"mode": ..., "model": {...}}— the loaded model's metrics, data manifest and training provenance (git SHA, library versions);modelisnullin default mode. Excludes the pickled estimators.POST /analyze(multipart upload fieldfile) → JSON analysis resultPOST /analyze/batch(repeated multipart fieldfiles) →{"results": [...]}, one entry per image (1:1, order-preserving). A bad image yields a per-item{"filename", "error"}entry instead of failing the whole batch.
curl -F "file=@image.png" http://localhost:8000/analyze
# Batch: repeat the `files` field once per image.
curl -F "files=@a.png" -F "files=@b.png" http://localhost:8000/analyze/batchThe analysis is CPU-bound and runs in a worker thread so the event loop stays free. A global concurrency cap provides backpressure, and oversized uploads are rejected before decoding. Responses:
413— upload exceeds the byte limit or pixel budget, or a batch has too many files. Images are not downscaled, since that would distort the frequency-domain signal.429— all analysis slots are busy, or the per-IP rate limit was exceeded; retry shortly (Retry-Afterheader).
Tunables (environment variables):
| Variable | Default | Meaning |
|---|---|---|
AIDETECT_MAX_UPLOAD_BYTES |
10485760 (10 MiB) |
Max upload size |
AIDETECT_MAX_PIXELS |
24000000 (24 MP) |
Max width×height |
AIDETECT_MAX_CONCURRENCY |
4 |
Max simultaneous analyses (per worker) |
AIDETECT_MAX_BATCH |
16 |
Max files per /analyze/batch request |
AIDETECT_RATE_LIMIT_PER_MIN |
0 (off) |
Per-IP requests/minute on the analyze endpoints |
AIDETECT_WORKERS |
1 |
uvicorn worker processes (Docker) |
The rate limit is in-process and best-effort (single worker), keyed on the direct
client IP — X-Forwarded-For is not trusted, so behind a reverse proxy it limits by
the proxy's address.
Total simultaneous analyses = AIDETECT_WORKERS × AIDETECT_MAX_CONCURRENCY. Keep
AIDETECT_WORKERS=1 for a single, predictable global cap; raise it to use more CPU
cores at the cost of multiplying the effective cap.
docker build -t aidetect .
# Serve the API (default command):
docker run --rm -p 8000:8000 aidetect
# Or with compose (mounts ./models so a calibrated model is picked up):
docker compose up --build
# Run the CLI inside the container against a mounted file:
docker run --rm -v "$PWD:/data" aidetect aidetect /data/image.pngThe repository ships a calibrated model at models/calibrated.joblib (calibrated on
CIFAKE), so the CLI, API and Docker image run in calibrated mode out of the box.
The model loads only if scikit-learn/joblib are installed (the calibrate extra; the
Docker image includes them); if loading fails for any reason — including a
scikit-learn version mismatch — the detector falls back to default mode rather than
erroring. Re-run the command below to regenerate it for your environment.
To (re)calibrate on a labeled dataset, install the optional extra
(pip install ".[calibrate]", which pulls in scikit-learn, joblib and Hugging Face
datasets).
Start with CIFAKE, downloaded automatically from Hugging Face:
python -m aidetect.calibrate.train --dataset cifake_hf --subset 2000Or point at a local CIFAKE-style folder (REAL/ and FAKE/ subdirectories):
python -m aidetect.calibrate.train --dataset folder --data data/cifake/trainWhat this does:
- Splits at the source-image level first (no augmented variant leaks across the train/test boundary).
- Extracts features; the training set is augmented (mild JPEG recompression, resize and blur) for robustness, while the holdout stays clean.
- Fits
StandardScaler + LogisticRegression, then calibrates probabilities withCalibratedClassifierCV. - Reports accuracy, ROC-AUC, precision/recall/F1, confusion matrix and Brier score
(calibration quality), and saves
models/calibrated.joblib(kilobytes).
The detector, CLI and API then run in calibrated mode automatically.
Useful flags:
--no-augment— disable augmentation (use to compare clean vs augmented metrics).--aug-copies N— augmented variants per training image (default 1, i.e. original- 1 variant, which doubles the training set).
--test-dataset <name>/--test-data <dir>— evaluate on a separate set (useful later for cross-generator testing).
Caveat: CIFAKE images are 32×32 and from one generator (Stable Diffusion 1.4),
so a model calibrated only on it generalizes less to high-resolution
Midjourney/DALL·E outputs. --dataset is repeatable to combine several sources in
one run (e.g. --dataset defactify_hf --dataset coco_real_hf), with train-fold
balancing (--balance) and cross-source real dedup (--dedup-reals); see
docs/datasets.md for the available sources. The generalization
study that motivated the diverse-data and CLIP work — including the finding that
forensic features struggle to cross real-image domains — is in
docs/roadmap-2.md.
The forensic features are cheap but partly encode real-image-domain statistics (resolution, compression, sensor noise), so a model trained on one real domain can misrank photos from another. On a held-out cross-dataset benchmark the heuristic collapses to ROC-AUC ≈ 0.37 (worse than chance), while a linear probe on CLIP image embeddings stays at ≈ 0.84 (≈ 0.98 in-domain). CLIP is the more robust path, at the cost of an ONNX model download and heavier per-image compute.
It is off by default, keeping the lightweight core intact:
- Install the extra:
pip install ".[clip]"(addsonnxruntime; still CPU-only, no torch). - Enable per call with
aidetect image.png --clip, or setAIDETECT_CLIP=1(also honored by the API). - A small probe ships at
models/clip_probe.joblib; the CLIP ViT-B/32 vision ONNX (~350 MB) is fetched on first use into the Hugging Face cache. PointAIDETECT_CLIP_DIRat a pre-placedmodel.onnxfor offline use. - If
onnxruntime, the probe, or the model are missing, the detector silently falls back to heuristic-only — enabling--clipnever breaks a run.
When active, CLIP carries the score (the heuristic probability is still reported as
clip_probability vs the blended probability). Rebuild the probe with
python -m aidetect.calibrate.clip_calibrate (a CLIP probe plus a multi-domain sigmoid
recalibration); details and benchmarks in docs/roadmap-2.md.
This is a probabilistic aid, not a reliable classifier. The two detectors have opposite, complementary failure modes, and on some inputs neither is right:
- The CLIP probe is blind to photorealistic AI of people. It works on semantic CLIP embeddings (what is in the image, not how it was made), so a modern photorealistic render of a person (e.g. a CivitAI full-body portrait) lands deep in "real" space and is under-scored. CLIP's strength is the opposite case: it does not false-flag genuine photos when the real domain shifts.
- The heuristic forensic features over-flag heavily processed real photos. They key on generation fingerprints (high-frequency residuals, frequency falloff), which a smooth, recompressed real photo (WebP, phone "beauty" processing) can mimic — so a real image can score as AI.
- Some cases are unseparable with these signals. We have observed a real photo that scores higher than a genuinely AI image on both the heuristic and the CLIP signal at once. No combination of the two (max, average, or a learned blend) can label both correctly — this is a discriminative ceiling of CLIP ViT-B/32 plus classical forensic features against modern generators, not a tuning bug.
Practically: the detector is strongest on stylized or older-Stable-Diffusion content and
weakest on photorealistic AI people. The training fakes currently cover SD/MJ/DALL·E
(defactify_hf, julienlucas_hf); the planned lever for modern coverage is adding fakes
from the actual threat distribution (CivitAI / Flux / Pony photorealistic generators).
Treat the probability as a signal to weigh, not a verdict.
pytest tests/
pytest tests/ --cov=aidetect --cov-report=term-missing # with coverage
mypy # static type checkReleased under the MIT License — Copyright (c) 2026 André Zaiats.