Spaces:
Running on Zero
Running on Zero
File size: 6,924 Bytes
67f58f2 72d14b5 67f58f2 bffb209 67f58f2 bffb209 67f58f2 72d14b5 67f58f2 bffb209 a2cfcc7 bffb209 67f58f2 72d14b5 bffb209 67f58f2 72d14b5 67f58f2 72d14b5 a2cfcc7 67f58f2 72d14b5 67f58f2 72d14b5 67f58f2 72d14b5 67f58f2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 | # app.py β Hugging Face Spaces demo (Gradio) over the shipped detector.
# Same model, preprocessing and evidence-map rendering as `predict.py --serve`;
# this file only adds the hosted UI. Run locally: `python app.py`.
import json
import math
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src"))
import gradio as gr # noqa: E402
import torch # noqa: E402
try: # Hugging Face ZeroGPU: the GPU is attached only inside
import spaces # @spaces.GPU-decorated calls; a no-op elsewhere.
except ImportError: # local / plain-CPU runs
class spaces: # noqa: N801
@staticmethod
def GPU(fn=None, **_): # noqa: N802
return fn if fn is not None else (lambda f: f)
from inference.predict import _resolve_device # noqa: E402
from inference.server import render_overlay, score_image # noqa: E402
from train.checkpoint import load_ensemble_checkpoint # noqa: E402
ROOT = os.path.dirname(os.path.abspath(__file__))
WEIGHTS = os.environ.get("WEIGHTS", os.path.join(ROOT, "weights.pt"))
# On ZeroGPU torch.cuda.is_available() is True at import time even though no GPU is
# attached yet; .to("cuda") here is what the spaces runtime intercepts and replays.
# INFER_DEVICE=cpu forces CPU inference (no GPU quota consumed, no ZeroGPU queue).
DEVICE = _resolve_device(os.environ.get("INFER_DEVICE", "auto"))
GPU_SECONDS = 8 if DEVICE == "cuda" else 0
MEMBERS = load_ensemble_checkpoint(WEIGHTS, map_location="cpu")
for m, _ in MEMBERS:
m.to(DEVICE).eval()
torch.set_num_threads(max(1, os.cpu_count() or 1))
SAMPLE_DIR = os.path.join(ROOT, "samples")
with open(os.path.join(SAMPLE_DIR, "samples.json")) as f:
SAMPLES = json.load(f) # see samples/ATTRIBUTION.md
SAMPLE_PATHS = [os.path.join(SAMPLE_DIR, s["file"]) for s in SAMPLES]
# Display-only operating point. 0.62 is the balanced-error point (FPR == FNR, ~2%) of
# the ensemble on the clean holdout and the lowest total error on every corruption
# family; 0.5 over-flags real photos ~2:1. Neither the CLI nor the JSON output moves.
VERDICT_THRESHOLD = 0.62
_THR_LOGIT = math.log(VERDICT_THRESHOLD / (1.0 - VERDICT_THRESHOLD))
def decision_score(p):
"""Recentre the raw probability so the verdict boundary sits at 0.5: a shift in
logit space (identical to moving the classifier's bias term). Monotone, kink-free,
0 -> 0, VERDICT_THRESHOLD -> 0.5, 1 -> 1; the extremes barely move."""
p = min(max(p, 1e-6), 1.0 - 1e-6)
return 1.0 / (1.0 + math.exp(-(math.log(p / (1.0 - p)) - _THR_LOGIT)))
def _score(img):
# Timed INSIDE the GPU-scoped call, so on ZeroGPU this is the model's own time
# (preprocess + two forwards; score_image's float()/.cpu() force the CUDA sync),
# excluding queue wait and the runtime's replay of the .to("cuda") moves.
t0 = time.perf_counter()
entry, evid = score_image(MEMBERS, img, DEVICE, explain=True)
entry["infer_s"] = time.perf_counter() - t0
return entry, evid
if GPU_SECONDS: # only the forward pass holds the GPU; overlay rendering is CPU work
_score = spaces.GPU(duration=GPU_SECONDS)(_score)
def detect(img):
if img is None:
return "", None, None, ""
t0 = time.perf_counter()
entry, evid = _score(img)
dt = time.perf_counter() - t0
p = entry["pred"]
s = decision_score(p)
overlay = render_overlay(img, evid, max_side=640)
if s >= 0.5:
verdict = f"## π€ AI-generated Β· score {s:.2f}"
else:
verdict = f"## π· Authentic Β· score {s:.2f}"
# explain_prediction's rationale opens with its own 0.5-threshold verdict; keep only
# the "the detector's evidence is ..." clause so the two verdicts can't disagree.
where = entry["rationale"].split("; ", 1)[-1]
return (verdict, {"AI-generated": s, "authentic": 1.0 - s}, overlay,
f"{where[0].upper()}{where[1:]} Raw ensemble p(AI) = {p:.3f}; the score "
f"recentres the {VERDICT_THRESHOLD} operating point to 0.5. "
f"[{DEVICE}: model {entry['infer_s']:.2f}s, {dt:.2f}s incl. queue]")
def pick_sample(evt: gr.SelectData):
return SAMPLE_PATHS[evt.index] # path, not PIL: no lossy re-encode in transit
DESCRIPTION = """
Upload, drag-drop or paste an image β or pick one of the samples in the sidebar. The
detector returns the probability the image is AI-generated and a signed evidence map
(**red** = fake evidence, **blue** = real evidence, grayscale = neutral). The score is
the ensemble probability recentred so that 0.5 is the decision boundary: the raw model
balances its two error rates at p = 0.62 on the held-out set, and the score shifts that
point to 0.5 (a logit offset β the ranking of images is unchanged; the raw p is in the
rationale).
Frozen DINOv2-B/14 read at blocks 8 and 10, two small transformer heads, logits
averaged β the exact weights from the submission repo. Source, weights and full evaluation:
[github.com/samt41/techjam_aigsee](https://github.com/samt41/techjam_aigsee).
"""
with gr.Blocks(title="AIGC image detector β TechJam 2026 Track 5") as demo:
with gr.Sidebar(label="Samples", width=300):
gr.Markdown("### Try a sample\nClick a thumbnail to load it. Half are AI-generated, "
"half are real photographs β labels are revealed in the caption.")
gallery = gr.Gallery(value=[(p, s["label"]) for p, s in zip(SAMPLE_PATHS, SAMPLES)],
columns=2, height="auto", object_fit="cover",
allow_preview=False, show_label=False)
gr.Markdown("AI samples: Community Forensics (CC-BY-4.0). Real photos: Wikimedia "
"Commons (CC0). Full credits in `samples/ATTRIBUTION.md`.")
gr.Markdown("# AIGC image detector β TechJam 2026 Track 5")
gr.Markdown(DESCRIPTION)
with gr.Row():
with gr.Column():
# format="png": gradio's default is lossy WebP, which would re-encode
# every upload before the model sees it.
inp = gr.Image(type="pil", format="png",
label="Image (upload, drag-drop or paste)",
sources=["upload", "clipboard"])
btn = gr.Button("Detect", variant="primary")
with gr.Column():
ver = gr.Markdown("")
lab = gr.Label(label="Score (0.5 = decision boundary)", num_top_classes=2)
ovl = gr.Image(label="Evidence map (red = fake, blue = real)")
rat = gr.Textbox(label="Rationale", lines=2)
outputs = [ver, lab, ovl, rat]
btn.click(detect, inputs=inp, outputs=outputs)
inp.change(detect, inputs=inp, outputs=outputs)
gallery.select(pick_sample, inputs=None, outputs=inp)
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)))
|