File size: 9,027 Bytes
b664492 | 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 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 | """Docker Gradio app for the ai-image-detector model.
Loads the merged CLIP ViT-B/16 LoRA weights from ``model.safetensors`` (next to
this file, or under the directory pointed to by ``MODEL_DIR``) and exposes a
Gradio interface for uploading an image and getting a real/fake/uncertain
prediction. Mirrors the Hugging Face Space ``app.py`` but is parametrised so it
can run from a container that downloads the model at runtime.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
import gradio as gr
import torch
from PIL import Image
APP_CSS = """
.gradio-container { max-width: 1040px !important; }
.detector-header { align-items: center; display: flex; justify-content: space-between; margin-bottom: 16px; }
.detector-title { font-size: 26px; font-weight: 700; line-height: 1.15; }
.detector-meta { color: var(--body-text-color-subdued); font-size: 13px; text-align: right; }
.result-card { background: var(--background-fill-secondary); border-radius: 8px; padding: 14px 16px; }
.result-heading { display: flex; gap: 12px; justify-content: space-between; margin-bottom: 10px; }
.result-label { font-size: 24px; font-weight: 800; line-height: 1.1; }
.result-pill { border-radius: 999px; color: white; font-size: 12px; font-weight: 800; height: fit-content; padding: 5px 10px; }
.result-real { background: #15803d; }
.result-fake { background: #b91c1c; }
.result-uncertain { background: #b45309; }
.metric-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); margin-top: 8px; }
.metric { display: flex; flex-direction: column; gap: 2px; }
.metric-name { color: var(--body-text-color-subdued); font-size: 12px; }
.metric-value { font-size: 18px; font-weight: 700; }
"""
def _resolve_model_dir() -> Path:
"""Resolve where model.safetensors + config.json live.
Priority: MODEL_DIR env var > directory of this file.
"""
env_dir = os.environ.get("MODEL_DIR")
if env_dir:
return Path(env_dir).expanduser().resolve()
return Path(__file__).resolve().parent
def _load_model(model_dir: Path):
"""Load merged weights + config from ``model_dir``."""
import timm
from safetensors.torch import load_file
weights_path = model_dir / "model.safetensors"
config_path = model_dir / "config.json"
if not weights_path.exists():
raise FileNotFoundError(
f"Model weights not found at {weights_path}. "
"Set MODEL_DIR or run the download_model.py entrypoint first."
)
if not config_path.exists():
raise FileNotFoundError(f"config.json not found at {config_path}.")
cfg = json.loads(config_path.read_text())
model = timm.create_model(
cfg["backbone"], pretrained=False, num_classes=1, img_size=cfg["image_size"]
)
state = load_file(str(weights_path))
missing, unexpected = model.load_state_dict(state, strict=False)
if unexpected:
print(f"[app] Unexpected keys in safetensors (ignored): {len(unexpected)}")
if missing:
print(f"[app] Missing keys when loading weights: {len(missing)}")
model.eval()
return model, cfg
class DockerPredictor:
"""Lightweight predictor that loads the merged model from safetensors."""
def __init__(self, model_dir: Path | None = None):
model_dir = model_dir or _resolve_model_dir()
self.device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"[app] Loading model from {model_dir} on {self.device}")
self.model, self.cfg = _load_model(model_dir)
self.model = self.model.to(self.device)
self.real_threshold = float(self.cfg.get("real_threshold", 0.93))
self.fake_threshold = float(self.cfg.get("fake_threshold", 0.91))
self.temperature = float(self.cfg.get("temperature", 1.0))
self.img_size = int(self.cfg.get("image_size", 256))
mean = self.cfg.get("normalization_mean", [0.481, 0.458, 0.408])
std = self.cfg.get("normalization_std", [0.269, 0.261, 0.276])
from torchvision import transforms
self.transform = transforms.Compose(
[
transforms.Resize((self.img_size, self.img_size)),
transforms.ToTensor(),
transforms.Normalize(mean=mean, std=std),
]
)
@torch.inference_mode()
def predict(self, image: Image.Image) -> dict[str, Any]:
if image is None:
return {}
image = image.convert("RGB")
x = self.transform(image).unsqueeze(0).to(self.device)
logit = self.model(x).reshape(-1)
if self.temperature and self.temperature > 0:
logit = logit / self.temperature
p_real = float(torch.sigmoid(logit).item())
if p_real < self.fake_threshold:
prediction, confidence = "fake", 1.0 - p_real
elif p_real >= self.real_threshold:
prediction, confidence = "real", p_real
else:
prediction, confidence = "uncertain", max(
p_real - self.fake_threshold, self.real_threshold - p_real
)
return {
"prediction": prediction,
"confidence": confidence,
"real_probability": p_real,
"fake_probability": 1.0 - p_real,
"fake_threshold": self.fake_threshold,
"real_threshold": self.real_threshold,
"img_size": self.img_size,
"temperature": self.temperature,
}
def _result_card(m: dict[str, Any]) -> str:
prediction = m.get("prediction", "unknown")
pill = {"real": "result-real", "fake": "result-fake"}.get(prediction, "result-uncertain")
return f"""
<div class="result-card">
<div class="result-heading">
<div>
<div class="metric-name">Prediction</div>
<div class="result-label">{prediction.upper()}</div>
</div>
<div class="result-pill {pill}">{prediction.upper()}</div>
</div>
<div class="metric-grid">
<div class="metric"><div class="metric-name">Confidence</div><div class="metric-value">{m.get('confidence', 0):.4f}</div></div>
<div class="metric"><div class="metric-name">p(real)</div><div class="metric-value">{m.get('real_probability', 0):.4f}</div></div>
<div class="metric"><div class="metric-name">p(fake)</div><div class="metric-value">{m.get('fake_probability', 0):.4f}</div></div>
<div class="metric"><div class="metric-name">Fake threshold</div><div class="metric-value">{m.get('fake_threshold', 0)}</div></div>
<div class="metric"><div class="metric-name">Real threshold</div><div class="metric-value">{m.get('real_threshold', 0)}</div></div>
</div>
</div>
"""
def _make_predict_fn(predictor: DockerPredictor):
"""Bind the predictor into a single-arg Gradio handler."""
def _predict(image: Image.Image | None):
if image is None:
return '<div class="result-card">Lütfen bir görsel yükleyin.</div>', {}
m = predictor.predict(image)
return _result_card(m), m
return _predict
def build_demo(predictor: DockerPredictor, examples_dir: Path | None = None) -> gr.Blocks:
predict_fn = _make_predict_fn(predictor)
with gr.Blocks(title="AI Image Detector", css=APP_CSS) as demo:
gr.HTML(
f"""
<div class="detector-header">
<div class="detector-title">AI Image Detector</div>
<div class="detector-meta">threshold {predictor.real_threshold:.2f}<br>CLIP ViT-B/16 + LoRA</div>
</div>
"""
)
with gr.Tab("Tek görsel"):
with gr.Row():
with gr.Column():
img_in = gr.Image(type="pil", label="Görsel")
btn = gr.Button("Tahmin et")
with gr.Column():
out_card = gr.HTML(label="Sonuç")
raw = gr.Json(label="Ham skorlar")
btn.click(predict_fn, inputs=[img_in], outputs=[out_card, raw])
if examples_dir and examples_dir.exists():
examples = sorted(examples_dir.glob("*.jpg"))
if examples:
gr.Examples(
examples=[[str(p)] for p in examples],
inputs=[img_in],
outputs=[out_card, raw],
fn=predict_fn,
cache_examples=False,
)
return demo
def main() -> int:
model_dir = _resolve_model_dir()
examples_dir = model_dir / "examples"
predictor = DockerPredictor(model_dir=model_dir)
server_name = os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0")
server_port = int(os.environ.get("GRADIO_SERVER_PORT", "7860"))
demo = build_demo(predictor, examples_dir=examples_dir)
# share=False: we never want a public tunnel from inside a container.
demo.launch(
server_name=server_name,
server_port=server_port,
share=False,
show_error=True,
inbrowser=False,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
|