Files
remove-ai-watermarks/docs/photo-classify.md
T
Victor Kuznetsov 7ddd2097bd Export WEIGHTS_ALLOW_PATTERNS for offline pre-caches
The runtime's _weights_dir resolves the Hub snapshot offline through an
inline allow_patterns list, so a deployment that pre-caches the snapshot
for HF_HUB_OFFLINE=1 had to re-list the files by hand and silently
drifted when a release added one: the offline resolution then failed on
a four-fifths-present cache even though every missing piece had its own
fallback. Export the exact runtime request list as one public name, use
it in _weights_dir, and pin the seam with a test that captures the
snapshot_download call. Document the contract in photo-classify.md and
module-internals.md.
2026-09-07 11:31:08 -07:00

7.1 KiB

Photo pixel classification

classify gives a pixel opinion on a photograph: camera-like, generated, or unknown, and optionally which renderer it resembles. It is a separate command from identify. Metadata inspection never starts it.

This page is the library guide. The Hugging Face model card is the same freeze written for Hub publication: photo-classify-hf/README.md. Campaign notes and rejected variants live in AI-generated image classifiers.

When to use it

Use identify for C2PA, IPTC, visible marks, and other provenance. Use classify when those signals are gone and the file is still a photograph.

Do not use classify to prove a file is clean. Do not use it on UI or digital art. Receipt photographs are detected by the receipt-document gate and abstain to unknown instead of a false ai; they are still not a supported verdict. Do not treat provider=openai as a decoded SynthID payload.

Install and run

uv tool install --force "remove-ai-watermarks[classify]"
remove-ai-watermarks classify image.png
remove-ai-watermarks classify image.png --json
from pathlib import Path
from remove_ai_watermarks.classify import classify_pixels

result = classify_pixels(Path("image.png"))
print(result.label, result.detector, result.provider)

Missing extra raises RuntimeError with pip install 'remove-ai-watermarks[classify]'. device is a library parameter (None / "auto" / "cpu" / "cuda"), not a CLI option.

Weights are not in git. First call downloads wiltodelta/raiw-photo-classify at the freeze revision pinned in classify.py, or reads RAIW_CLASSIFY_WEIGHTS if that directory already has clip-l-ft.pt, probe-weights-clip-l-ft.npz, detector.pt, and provider.pt. The training catalog is not published with the package. Deployments that pre-cache the snapshot for offline resolution must request exactly classify.WEIGHTS_ALLOW_PATTERNS (the runtime resolves that same list under HF_HUB_OFFLINE=1; a partial pre-cache fails the whole resolution, even for files the runtime would fall back on).

What one call returns

One request runs both heads. The receipt gate and Model 2 run only after Model 1 is DEFINITELY.

flowchart TD
  image[Photograph] --> m1[Model 1 CLIP-L-ft ridge AND MLP]
  m1 -->|likely_human| human["label human, provider none"]
  m1 -->|possibly| unk["label unknown, provider none"]
  m1 -->|definitely| rg[Receipt gate on the same CLIP vector]
  rg -->|receipt document| rgu["label unknown, detector definitely, provider none"]
  rg -->|not a receipt| m2[Model 2 124-d focal heads]
  m2 -->|openai google muse-image tc260| named["label ai plus provider"]
  m2 -->|no_ai or extract fail| aiOnly["label ai, provider none"]
Field Values Meaning
label ai, human, unknown Public verdict. ai only on DEFINITELY
domain photo This freeze is photographic only
detector definitely, possibly, likely_human Raw Model 1 gate
provider openai, google, muse-image, tc260, or None Model 2, only if label is ai

unknown with detector=definitely is the receipt-document gate abstaining: the file looked AI-generated to Model 1 and like a receipt photograph to the gate, so no verdict is published and no provider is read.

human is camera-like under this contract, not a proof of authorship. unknown is POSSIBLY, or a domain this freeze does not support. provider=None on an ai file is often FLUX or another generator that is not one of the four heads, or a 124-d extract refusal.

Approach

CLIP-L content embeddings separate generated photographs from camera photographs. They do not separate generated photographs from receipts or UI. A ridge on fine-tuned CLIP-L (last two vision blocks, 224 letterbox) is Model

  1. A small MLP on the same vectors, ANDed with the ridge, is the freeze DEFINITELY gate.

Provider attribution uses a different feature: 124-d residual ratios on 256 px patches. OpenAI versus Gemini is a strong split there. "AI or not" is the wrong job on that bank, which is why Model 2 is gated.

Provider names the renderer, not the product UI. Bing Image Creator signed Microsoft, OpenAI scores openai. Designer signed Microsoft, Google LLC scores google. There is no Microsoft pixel class. openai and google are provider classes. muse-image is Muse Image output, not a general Meta class. tc260 is the China AIGC label standard, not one producer: Doubao, Jimeng, Qwen, Kling, and others share that residual class.

Evaluation

Two CPU retrains were byte-identical. DEFINITELY is the shipped cut.

Head Cell Result
Detector DEFINITELY AI-test 92.4% (n=1,847)
Detector DEFINITELY Open Images fresh 1.3% (n=3,000)
Detector DEFINITELY Kodak 0/24
Detector DEFINITELY FLUX hold 83.0% (n=300)
Class OpenAI 90.8% (345/380 of 381)
Class Google 90.9% (339/373 of 377)
Class TC260 78.6% (298/379 of 384)
Class Muse Image hold-out v3 89.4% (177/198)
Class Muse Image hold-out pooled 88.4% (243/275 listed 277)
Class meme templates, ungated 29.1% leak

The ungated meme row is why Model 2 never runs on human or unknown.

Receipt-document gate

Added 2026-09-02. A linear head on the CLIP-L-ft vector Model 1 already computes, trained on CORD-v2 train (800, CC BY 4.0) plus 200 synthetic capture-style receipts against 2,400 ai_train negatives. The threshold is certified on CORD validation plus a synthetic holdout; no field-receipt pixels were used for training or the threshold. On DEFINITELY only: a hit publishes unknown and skips the 124-d provider pass.

Cell Result
Field phone receipts, false ai before 19/59 -> 2/59
Field phone receipts, gated to unknown 57/59
CORD photographs (99) 0/99 false ai
ai_test DEFINITELY cost 7/1,847 (0.38%)

Artifacts: receipt-gate-shipped-2026-09-02/report.json (private research tree). The head ships with the model (receipt-gate-2026-09-02.npz in the Hub snapshot, package asset as the fallback for pre-gate freezes); the operating point records it in photo-classify-hf/operating-point.json.

Limits

  • Receipts abstain through the 2026-09-02 gate (below); a photorealistic AI receipt can still score as human. UI, scans, maps, and community art remain out of contract.
  • Not SynthID, not C2PA, not is_ai_generated.
  • Images under 256 px cannot yield 124-d features, so provider abstains.
  • FLUX is a hold-out at 83% DEF, not a named provider class.
  • tc260 is mixed producers under one label standard. A later retrain should split it by manufacturer.

identify, has_invisible_target, all, and invisible do not import this module. A no-signal provenance result stays unknown until you call classify yourself.

Training data, the retrain pack (caches only, no images), and scripts/retrain_photo_classify.py are in photo-classify-training.md.