# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false """Metadata-free photo classifier: Model 1 then gated Model 2. This is not provenance and is not a cleanup command. ``identify``, ``has_invisible_target``, and ``all`` must not import this module. Call :func:`classify_pixels` explicitly. """ from __future__ import annotations import json import logging import os import threading from dataclasses import dataclass from pathlib import Path from typing import Any, Literal log = logging.getLogger(__name__) CLASSIFY_EXTRA = "'remove-ai-watermarks[classify]'" CLASSIFY_ONNX_EXTRA = "'remove-ai-watermarks[classify-onnx]'" WEIGHTS_ENV = "RAIW_CLASSIFY_WEIGHTS" WEIGHTS_REPO = "wiltodelta/raiw-photo-classify" WEIGHTS_REVISION = "de668f0d8a53412923478cc21382a56890748310" CLIP_FILE = "clip-l-ft.pt" CLIP_ONNX_FILE = "clip-l-ft-vision-fp32.onnx" PROBE_FILE = "probe-weights-clip-l-ft.npz" DETECTOR_FILE = "detector.pt" PROVIDER_FILE = "provider.pt" OPERATING_POINT_FILE = "operating-point.json" _COMMON_WEIGHT_FILES = (PROBE_FILE, DETECTOR_FILE, PROVIDER_FILE) _WEIGHT_FILES = (CLIP_FILE, *_COMMON_WEIGHT_FILES) # 2026-09-02 receipt-document gate: linear head on the same CLIP-L-ft # vector, trained on CORD-v2 train (800, CC BY 4.0) plus 200 synthetic # capture-style receipts against 2,400 ai_train negatives. Threshold is the # min of the 1st-percentile scores on CORD validation and a synthetic # holdout; no field-receipt pixels were used for training or the threshold. # The head versions with the model (Hub snapshot or RAIW_CLASSIFY_WEIGHTS # directory); the package asset is the fallback for pre-gate freezes. # 2026-09-07: the gate is MODEL-SIDE. The weights directory carries the # artifact under the STABLE name ``receipt-gate.npz`` with the threshold # inside; the dated name below stays readable as the legacy spelling, and # the bundled package asset is the last-resort fallback. Head and # threshold always come from ONE artifact, so a model-side gate update # needs no lib release; ``RECEIPT_GATE_THRESHOLD`` is only the legacy # pinned value kept for the package-asset fallback and its pinned tests. RECEIPT_GATE_STABLE_FILE = "receipt-gate.npz" RECEIPT_GATE_FILE = "receipt-gate-2026-09-02.npz" RECEIPT_GATE_THRESHOLD = 2.0432573877459697 # The complete file set the runtime's snapshot_download may request. Deploy-time # pre-caches in offline environments (HF_HUB_OFFLINE=1) must install exactly # this list: a subset leaves the runtime resolution unsatisfiable and every # classify call fails on weights that are four-fifths present. Exported so a # pre-cache imports one name instead of re-listing the files and silently # drifting the next time a release adds one. WEIGHTS_ALLOW_PATTERNS: tuple[str, ...] = ( *_WEIGHT_FILES, CLIP_ONNX_FILE, OPERATING_POINT_FILE, RECEIPT_GATE_STABLE_FILE, RECEIPT_GATE_FILE, ) # 2026-08-31 freeze operating point. Ridge threshold is also in the probe file; # the runtime prefers the file when weights load. MLP_THRESHOLD = 5.9586493237495395 RIDGE_THRESHOLD = 0.3056212276800537 PROVIDER_MARGIN = 0.30 CLIP_WIDTH = 768 # Checkpoint keys in provider.pt, the SUPERSET across snapshot generations. # openai/google are provider classes; meta_muse_image is Muse Image; # bytedance is the shared ByteDance generator lineage (Doubao and Jimeng # render with the same model family -- measured 2026-09-07: separate heads # cross-fire 32/22 ways even at 3.5x train mass, the union holds 83.9% # on the frozen test cell). tc260 is NOT one class of anything: it covers # the REST of China's generator ecosystem (Qwen, Yuanbao, Kling, ..., # producers that are peers of openai/google/meta), whose heads do not # exist yet at honest mass, so its argmax win abstains (provider=None). # The loader tolerates older snapshots that predate bytedance: a head # absent from provider.pt is skipped, and those rows fall to the veto. # Measured on the shipped weights: deleting the veto from the argmax # would falsely name 207/379 China test rows openai/google/muse-image # (153 of them muse-image); the veto leaves every other cell # byte-identical (openai 345/380, google 339/373, meta v3 177/198). PROVIDER_LABELS = ("openai", "google", "bytedance", "tc260", "meta_muse_image", "no_ai") DetectorLevel = Literal["definitely", "possibly", "likely_human"] PixelLabel = Literal["ai", "human", "unknown"] PixelDomain = Literal["photo"] PixelProvider = Literal["openai", "google", "muse-image", "bytedance"] ClassifierBackend = Literal["torch", "onnx"] PUBLIC_PROVIDER: dict[str, PixelProvider | None] = { "openai": "openai", "google": "google", "bytedance": "bytedance", "tc260": None, "meta_muse_image": "muse-image", } _runtime: _Runtime | None = None _runtime_lock = threading.Lock() _receipt_gate_cache: dict[str, dict[str, Any]] = {} def _load_receipt_gate(folder: Path | None = None) -> dict[str, Any]: """Load the receipt-gate head, preferring the weights directory. Model-side first: the stable ``receipt-gate.npz`` in the Hub snapshot or ``RAIW_CLASSIFY_WEIGHTS`` directory wins, then the legacy dated spelling, with the bundled package asset as the fallback for weights directories frozen before the gate existed. The head is fitted on the freeze CLIP-L-ft embedding space, which is why it versions with the model, not with the code; the threshold travels inside the artifact. """ import numpy as np key = str(folder) if folder is not None else "" if key in _receipt_gate_cache: return _receipt_gate_cache[key] source: Path | None = None if folder is not None: source = _find_weight(folder, RECEIPT_GATE_STABLE_FILE) or _find_weight(folder, RECEIPT_GATE_FILE) if source is None: source = Path(__file__).parent / "assets" / RECEIPT_GATE_FILE payload = np.load(source) gate = { "w": np.asarray(payload["w"], dtype=np.float64), "b": float(payload["b"]), "mu": np.asarray(payload["mu"], dtype=np.float64), "sd": np.asarray(payload["sd"], dtype=np.float64), "threshold": float(payload["threshold"]), } _receipt_gate_cache[key] = gate return gate def receipt_gate_score(clip_vector: Any, gate: dict[str, Any] | None = None) -> float: """Receipt-document score of a CLIP-L-ft vector; higher is more receipt-like. ``gate`` must be the SAME artifact whose threshold decides the hit (``classify_pixels`` passes the weights-directory gate, so head and threshold can never come from different files); the default loads the package-fallback artifact for standalone probing. """ import numpy as np g = _load_receipt_gate() if gate is None else gate v = np.asarray(clip_vector, dtype=np.float64).ravel() v = v / (np.linalg.norm(v) + 1e-12) return float((((v - g["mu"]) / g["sd"]) @ g["w"]) + g["b"]) @dataclass(frozen=True) class PixelClassification: """Combined Model 1 and Model 2 result for one image.""" label: PixelLabel domain: PixelDomain detector: DetectorLevel provider: PixelProvider | None def to_dict(self) -> dict[str, Any]: return { "label": self.label, "domain": self.domain, "detector": self.detector, "provider": self.provider, } def is_available() -> bool: """True when the ``classify`` extra can import torch, transformers, numpy, and cv2.""" from remove_ai_watermarks.optional_deps import module_available return module_available("torch", "transformers", "numpy", "cv2") def detector_level( ridge_score: float, mlp_score: float, *, ridge_threshold: float = RIDGE_THRESHOLD, mlp_threshold: float = MLP_THRESHOLD, ) -> DetectorLevel: """Map the freeze AND/OR rule onto definitely / possibly / likely_human.""" ridge = ridge_score > ridge_threshold mlp = mlp_score > mlp_threshold if ridge and mlp: return "definitely" if ridge or mlp: return "possibly" return "likely_human" def label_for(level: DetectorLevel) -> PixelLabel: """Public label: only DEFINITELY is ``ai``. POSSIBLY abstains.""" if level == "definitely": return "ai" if level == "likely_human": return "human" return "unknown" def provider_from_scores(scores: dict[str, float], *, margin: float = PROVIDER_MARGIN) -> PixelProvider | None: """Argmax among the named classes that beat ``no_ai`` by ``margin``. Candidates are the heads the LOADED snapshot actually carries, so older provider.pt files without a generation's newest class keep working (their rows fall to the tc260 veto and abstain). The tc260 head joins the argmax only as a group veto: it covers the rest of China's generator ecosystem, producers that are peers of openai/google/meta, so until per-producer classes exist its win abstains and ``None`` is published instead of a false company claim. """ no_ai = scores["no_ai"] ai_names = [name for name in scores if name != "no_ai"] passed = [name for name in ai_names if scores[name] > no_ai + margin] if not passed: return None best = max(passed, key=lambda name: scores[name]) return PUBLIC_PROVIDER[best] def classify_from_scores( ridge_score: float, mlp_score: float, provider_scores: dict[str, float] | None, *, ridge_threshold: float = RIDGE_THRESHOLD, mlp_threshold: float = MLP_THRESHOLD, provider_margin: float = PROVIDER_MARGIN, receipt_score: float | None = None, receipt_threshold: float | None = None, ) -> PixelClassification: """Pure gate: detector AND provider, no file I/O. A DEFINITELY verdict whose ``receipt_score`` meets ``receipt_threshold`` downgrades the public label to ``unknown`` (document-domain abstain): the raw detector level stays ``definitely`` and no provider is read. ``receipt_threshold=None`` (the default) resolves from the loaded gate artifact, so the operating point versions with the model. """ if receipt_threshold is None: receipt_threshold = float(_load_receipt_gate()["threshold"]) level = detector_level( ridge_score, mlp_score, ridge_threshold=ridge_threshold, mlp_threshold=mlp_threshold, ) label = label_for(level) if level == "definitely" and receipt_score is not None and receipt_score >= receipt_threshold: return PixelClassification(label="unknown", domain="photo", detector=level, provider=None) provider: PixelProvider | None = None if label == "ai" and provider_scores is not None: provider = provider_from_scores(provider_scores, margin=provider_margin) return PixelClassification(label=label, domain="photo", detector=level, provider=provider) def classify_pixels( path: Path, *, device: str | None = None, backend: ClassifierBackend = "torch", ) -> PixelClassification: """Run Model 1 then, on DEFINITELY, the receipt gate and Model 2. Never called by ``identify``. A DEFINITELY verdict that the receipt gate accepts is published as ``unknown`` before the 124-d provider pass. """ if not is_available(): raise RuntimeError(f"pixel classification requires the classify extra. Install: pip install {CLASSIFY_EXTRA}") if backend == "onnx": from remove_ai_watermarks.optional_deps import module_available if not module_available("onnxruntime"): raise RuntimeError( f"the ONNX photo-classifier backend requires ONNX Runtime. Install: pip install {CLASSIFY_ONNX_EXTRA}" ) runtime = _get_runtime(device, backend) from PIL import Image with Image.open(path) as image: rgb = image.convert("RGB") clip_vector = _embed(runtime, rgb) ridge = float(((clip_vector - runtime.ridge_mean) / runtime.ridge_scale) @ runtime.ridge_weights) mlp = _mlp_score(runtime, clip_vector) level = detector_level( ridge, mlp, ridge_threshold=runtime.ridge_threshold, mlp_threshold=runtime.mlp_threshold, ) gate = _load_receipt_gate(runtime.weights_dir) r_score = receipt_gate_score(clip_vector, gate) if level == "definitely" else None provider_scores = None if r_score is not None and r_score < gate["threshold"]: forensic = _forensic(rgb) if forensic is not None: provider_scores = _provider_scores(runtime, forensic) else: log.info("124-d features unavailable for %s, provider abstains", path) result = classify_from_scores( ridge, mlp, provider_scores, ridge_threshold=runtime.ridge_threshold, mlp_threshold=runtime.mlp_threshold, provider_margin=runtime.provider_margin, receipt_score=r_score, receipt_threshold=gate["threshold"], ) if result.label == "unknown" and r_score is not None: log.info("receipt-document gate abstained on %s (score %.2f)", path, r_score) return result @dataclass class _Runtime: device: Any backend: ClassifierBackend clip_model: Any processor: Any detector: Any provider_heads: dict[str, Any] ridge_mean: Any ridge_scale: Any ridge_weights: Any ridge_threshold: float mlp_threshold: float provider_margin: float weights_dir: Path | None def _resolve_device(device: str | None) -> Any: import torch if device in (None, "auto"): return torch.device("cuda" if torch.cuda.is_available() else "cpu") if device == "cpu": return torch.device("cpu") if device == "cuda": if not torch.cuda.is_available(): raise ValueError("cuda was requested but is not available") return torch.device("cuda") raise ValueError(f"unsupported classify device {device!r}") def _resolve_backend(backend: ClassifierBackend, device: Any) -> ClassifierBackend: if backend == "onnx" and device.type != "cpu": raise ValueError("the ONNX photo-classifier backend supports CPU only") if backend not in ("torch", "onnx"): raise ValueError(f"unsupported classify backend {backend!r}") return backend def _backend_allow_patterns(backend: ClassifierBackend) -> tuple[str, ...]: model_file = CLIP_ONNX_FILE if backend == "onnx" else CLIP_FILE return ( model_file, *_COMMON_WEIGHT_FILES, OPERATING_POINT_FILE, RECEIPT_GATE_STABLE_FILE, RECEIPT_GATE_FILE, ) def _weights_dir(backend: ClassifierBackend = "torch") -> Path: override = os.environ.get(WEIGHTS_ENV) if override: path = Path(override) if not path.is_dir(): raise RuntimeError(f"{WEIGHTS_ENV} is not a directory: {path}") return path from huggingface_hub import snapshot_download try: return Path( snapshot_download( repo_id=WEIGHTS_REPO, revision=WEIGHTS_REVISION, allow_patterns=list(_backend_allow_patterns(backend)), ) ) except Exception as exc: raise RuntimeError( "pixel classification weights are not installed. Set " f"{WEIGHTS_ENV} to a directory with {', '.join(_WEIGHT_FILES)}, " f"or install {CLASSIFY_EXTRA} and download {WEIGHTS_REPO}@{WEIGHTS_REVISION}." ) from exc def _find_weight(folder: Path, name: str) -> Path | None: direct = folder / name nested = folder / "run1" / name if direct.is_file(): return direct if nested.is_file(): return nested return None def _weight_path(folder: Path, name: str) -> Path: found = _find_weight(folder, name) if found is None: raise RuntimeError(f"pixel classification weights are missing ({name}) in {folder}") return found def _require_files(folder: Path, *, backend: ClassifierBackend = "torch") -> None: model_file = CLIP_ONNX_FILE if backend == "onnx" else CLIP_FILE required = (model_file, *_COMMON_WEIGHT_FILES) missing = [name for name in required if _find_weight(folder, name) is None] if missing: raise RuntimeError( "pixel classification weights are missing " f"({', '.join(missing)}). Set {WEIGHTS_ENV} to a directory with the " f"2026-08-31 freeze files, or install {CLASSIFY_EXTRA} and allow the " f"Hugging Face download from {WEIGHTS_REPO}." ) def _operating_point(folder: Path) -> tuple[float, float]: path = folder / OPERATING_POINT_FILE if not path.is_file(): return MLP_THRESHOLD, PROVIDER_MARGIN payload = json.loads(path.read_text()) return float(payload["model1"]["mlp_threshold"]), float(payload["model2"]["margin"]) def _build_detector() -> Any: from torch import nn return nn.Sequential( nn.Linear(CLIP_WIDTH, 512), nn.ReLU(), nn.Dropout(0.3), nn.Linear(512, 128), nn.ReLU(), nn.Dropout(0.1), nn.Linear(128, 1), ) def _build_provider_head() -> Any: from torch import nn return nn.Sequential( nn.Linear(124, 64), nn.ReLU(), nn.Dropout(0.2), nn.Linear(64, 1), ) def _load_runtime(device: Any, backend: ClassifierBackend = "torch") -> _Runtime: import numpy as np import torch from remove_ai_watermarks._internal.clip_l_ft import load_headed_clip, load_onnx_vision, load_processor folder = _weights_dir(backend) _require_files(folder, backend=backend) probe = np.load(_weight_path(folder, PROBE_FILE)) detector = _build_detector() detector.load_state_dict(torch.load(_weight_path(folder, DETECTOR_FILE), map_location="cpu", weights_only=True)) detector.to(device).eval() packed = torch.load(_weight_path(folder, PROVIDER_FILE), map_location="cpu", weights_only=True) heads = {} for name in PROVIDER_LABELS: if name not in packed: log.info("provider head %s absent from this snapshot; rows it would name abstain", name) continue head = _build_provider_head() head.load_state_dict(packed[name]) heads[name] = head.to(device).eval() ridge_threshold = float(probe["thr_oi_1pct"]) mlp_threshold, provider_margin = _operating_point(folder) log.info("loaded photo-classify freeze from %s", folder) clip_model = ( load_onnx_vision(_weight_path(folder, CLIP_ONNX_FILE)) if backend == "onnx" else load_headed_clip(_weight_path(folder, CLIP_FILE), device) ) return _Runtime( device=device, backend=backend, clip_model=clip_model, processor=load_processor(), detector=detector, provider_heads=heads, ridge_mean=np.asarray(probe["mean"], dtype=np.float64), ridge_scale=np.asarray(probe["scale"], dtype=np.float64), ridge_weights=np.asarray(probe["weights"], dtype=np.float64), ridge_threshold=ridge_threshold, mlp_threshold=mlp_threshold, provider_margin=provider_margin, weights_dir=folder, ) def _get_runtime(device: str | None, backend: ClassifierBackend = "torch") -> _Runtime: global _runtime resolved = _resolve_device(device) resolved_backend = _resolve_backend(backend, resolved) with _runtime_lock: if _runtime is None or _runtime.device != resolved or _runtime.backend != resolved_backend: _runtime = _load_runtime(resolved, resolved_backend) return _runtime def _embed(runtime: _Runtime, image: Any) -> Any: from remove_ai_watermarks._internal.clip_l_ft import embed_image, embed_image_onnx if runtime.backend == "onnx": return embed_image_onnx(runtime.clip_model, runtime.processor, image) return embed_image(runtime.clip_model, runtime.processor, image, runtime.device) def _forensic(image: Any) -> Any: import numpy as np from remove_ai_watermarks._internal.forensic_124d import image_features pixels = np.asarray(image, dtype=np.uint8) return image_features(pixels) def _mlp_score(runtime: _Runtime, clip_vector: Any) -> float: import torch tensor = torch.from_numpy(clip_vector.astype("float32")).unsqueeze(0).to(runtime.device) with torch.inference_mode(): return float(runtime.detector(tensor).squeeze().cpu()) def _provider_scores(runtime: _Runtime, forensic: Any) -> dict[str, float]: import torch tensor = torch.from_numpy(forensic.astype("float32")).unsqueeze(0).to(runtime.device) with torch.inference_mode(): return {name: float(head(tensor).squeeze().cpu()) for name, head in runtime.provider_heads.items()}