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)))