"""AI Image Detector — Gradio demo. Upload any image; get REAL vs AI-GENERATED with a calibrated confidence and a heatmap of what the model looked at. Three things here exist specifically so the demo behaves on arbitrary uploads, and all three are easy to get wrong (CLAUDE.md R9/R10): 1. **Identical preprocessing to training.** The exact same canonical pipeline is imported from the code repo — not reimplemented. A demo that resizes differently from training silently loses several points of accuracy. 2. **5-crop averaging.** A single centre crop of a 4000px photo throws away most of the evidence. Averaging logits over four corners plus the centre is markedly steadier. 3. **Temperature scaling + a tuned threshold.** Raw softmax from a fine-tuned ViT is badly overconfident (Run A measured T=2.37) and its best operating point was 0.34, not 0.5. Announcing "99.8% AI" about a holiday snap is worse than being quietly wrong. """ from __future__ import annotations import json import os import sys import gradio as gr import numpy as np import spaces # ZeroGPU: must be imported BEFORE torch import torch from huggingface_hub import hf_hub_download, snapshot_download from PIL import Image MODEL_REPO = os.environ.get("MODEL_REPO", "husseinelsaadi/aidetect-vit-b16") CODE_REPO = os.environ.get("CODE_REPO", "husseinelsaadi/aidetect-code") RUN = os.environ.get("RUN", "runC") # runC wins on unseen generators AND reals # The threshold in summary.json was fitted on IN-DISTRIBUTION validation, where real # images look familiar; it flagged ~1 in 4 genuine photos from unseen sources as AI. # 0.71 was refitted on half the OOD set and validated on the held-out half, cutting the # false-positive rate to ~9% for a modest cost in recall. For a live demo, wrongly # accusing a real photograph is the failure that matters. THRESHOLD_OVERRIDE = float(os.environ.get("THRESHOLD", "0.71")) TOKEN = os.environ.get("HF_TOKEN") sys.path.insert(0, snapshot_download(CODE_REPO, repo_type="dataset", token=TOKEN)) from preprocess import canonical_pil, whole_frame_tiles # noqa: E402 from transforms import CLIP_MEAN, CLIP_STD # noqa: E402 from models import build_model, load_checkpoint_into # noqa: E402 # ZeroGPU allocates a GPU only while a @spaces.GPU function is executing, so at import # time there is no CUDA device. Everything loads on CPU and moves to GPU inside the # decorated call. A free personal account may host up to 2 Gradio Spaces this way — # plain CPU Gradio Spaces now require a paid plan. DEVICE = "cpu" def _load(): ck = hf_hub_download(MODEL_REPO, f"{RUN}/checkpoints/best.pt", repo_type="model", token=TOKEN) blob = torch.load(ck, map_location="cpu", weights_only=False) cfg = blob.get("extra", {}).get("config", {}) model = build_model(cfg.get("model", "clip-vit-b16")) load_checkpoint_into(model, blob["model"]) model.eval() temperature, threshold = 1.0, 0.5 try: s = json.load(open(hf_hub_download(MODEL_REPO, f"{RUN}/summary.json", repo_type="model", token=TOKEN))) temperature = s.get("temperature", 1.0) threshold = s.get("threshold", 0.5) except Exception as e: print(f"no calibration found ({e}) — falling back to raw logits at 0.5") if THRESHOLD_OVERRIDE > 0: threshold = THRESHOLD_OVERRIDE return model, temperature, threshold MODEL, TEMPERATURE, THRESHOLD = _load() print(f"loaded {RUN} | T={TEMPERATURE:.3f} threshold={THRESHOLD:.3f}") # Attention rollout needs the per-layer attention matrices, and the SDPA kernel that # recent transformers selects by default does not return them — `output_attentions=True` # then yields None and the heatmap silently disappears. Eager attention is slower, but # this is five 224px crops, not a training loop. try: MODEL.backbone.config._attn_implementation = "eager" except Exception as e: # pragma: no cover print(f"could not force eager attention ({e}) — heatmap may be unavailable") _norm_mean = torch.tensor(CLIP_MEAN).view(3, 1, 1) _norm_std = torch.tensor(CLIP_STD).view(3, 1, 1) def _to_tensor(img: Image.Image) -> torch.Tensor: t = torch.from_numpy(np.array(img, dtype=np.float32) / 255.0).permute(2, 0, 1) return (t - _norm_mean) / _norm_std @torch.no_grad() def _attention_rollout(img: Image.Image, dev: str = "cpu") -> np.ndarray | None: """Attention rollout (Abnar & Zuidema): multiply per-layer attention across all blocks, with a residual term, to see which patches actually drove the decision.""" try: x = _to_tensor(img).unsqueeze(0).to(dev) out = MODEL.backbone(pixel_values=x, output_attentions=True) attns = out.attentions if not attns: return None result = torch.eye(attns[0].size(-1), device=dev) for a in attns: a = a.mean(dim=1)[0] # average the heads a = a + torch.eye(a.size(-1), device=dev) # residual connection a = a / a.sum(dim=-1, keepdim=True) result = a @ result mask = result[0, 1:] # CLS row, patch columns side = int(mask.numel() ** 0.5) mask = mask[: side * side].reshape(side, side).cpu().numpy() # np.ptp(), not mask.ptp() — the ndarray method was removed in NumPy 2.0 and # this whole function is wrapped in a try/except, so it failed invisibly. mask = (mask - mask.min()) / (np.ptp(mask) + 1e-8) return mask except Exception as e: print(f"rollout failed: {e}") return None def _overlay(img: Image.Image, mask: np.ndarray) -> Image.Image: import matplotlib.cm as cm m = np.array(Image.fromarray((mask * 255).astype(np.uint8)).resize(img.size, Image.BILINEAR)) heat = (cm.inferno(m / 255.0)[..., :3] * 255).astype(np.uint8) return Image.fromarray((0.55 * np.array(img.convert("RGB")) + 0.45 * heat) .astype(np.uint8)) def _banner(verdict: str, p_ai: float) -> str: """One unmissable line. A demo is read across a room, not squinted at.""" ai = verdict.startswith("AI") bg, fg = ("#3b0d0d", "#ff8f8f") if ai else ("#0d2e1a", "#7ee2a8") icon = "AI-GENERATED" if ai else "REAL PHOTOGRAPH" conf = p_ai if ai else 1 - p_ai return ( f"
Upload an image and find out whether a camera took it or a generative "
"model made it.
Trained on 1,100+ generators — and measured on generators "
"it has never seen.