Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
File size: 19,688 Bytes
cd24b4a | 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 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 | """SOTA image-generation backends for the live detector-comparison demo.
Each generator takes a source image + a prompt and returns PNG bytes of a freshly generated image. Two modes:
* "recreate" (default): a captioner describes the source photo and the generator creates a NEW image from that
description alone (image_png=None), so every pixel is synthesized. This is how detection benchmarks build fakes.
* "edit": the generator edits the source photo itself (image-conditioned). The result keeps the original's layout.
The result is then scored by the SynthScope detectors, which is then scored by the SynthScope detectors so a
user can see how today's detectors hold up against today's generators. API keys come from env vars;
if a key is absent the generator is reported unavailable (the UI greys it out).
"""
from __future__ import annotations
import base64, io, os, re, subprocess, sys, tempfile, threading
import _synthscope_env # noqa: F401 (maps legacy AGID_* variables to SYNTHSCOPE_*)
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
import requests
from PIL import Image
DEFAULT_PROMPT = "make another image conceptually similar to this"
@dataclass
class Generator:
key: str
name: str
env_key: str # API-key env var (hosted generators); HF token for the gated download (local)
uses_image: bool # True = image-conditioned (edit); False = text-to-image only
call: Callable[..., bytes] # call(image_png | None, prompt, size=None); image_png=None = text-only
note: str = ""
local: bool = False # True = open-weight, runs locally (no paid API key)
avail: Optional[Callable[[], bool]] = None # custom availability check (overrides the defaults)
heavy: bool = False # True = needs (most of) the GPU: the web engine releases its detectors first
edit_only: bool = False # True = an editing model that cannot create an image from text alone
@property
def modes(self) -> list[str]:
if not self.uses_image:
return ["recreate"]
return ["edit"] if self.edit_only else ["recreate", "edit"]
def needs_gpu(self) -> bool:
"""True when this local model cannot run here because the server has no GPU (on a CPU, even the smallest one,
FLUX.2 [klein] 4B, takes over 30 minutes per image)."""
return self.local and not _has_gpu()
def available(self) -> bool:
if self.needs_gpu():
return False
if self.avail is not None:
return self.avail()
if self.local: # local = available if the inference pipeline is importable
try:
from diffusers import Ideogram4Pipeline # noqa: F401
return True
except Exception:
return False
return bool(os.environ.get(self.env_key))
def _has_gpu() -> bool:
try:
import torch
return torch.cuda.is_available()
except Exception:
return False
def _png_bytes(img: Image.Image) -> bytes:
buf = io.BytesIO(); img.convert("RGB").save(buf, "PNG"); return buf.getvalue()
# --- Image captioning (BLIP) for text-to-image generators ----------------------------------------
# Text-to-image generators (Grok, Ideogram) can't see the source image, so the edit-style default
# prompt ("make another image conceptually similar to this") is meaningless to them and tends to
# produce garbage or trip the model's safety filter. We caption the source image and feed THAT, so
# the result is genuinely "conceptually similar" and the prompt is a normal scene description.
_BLIP = None
def _caption(image_png: bytes) -> Optional[str]:
"""Best-effort caption of the source image. Returns None on any failure (callers fall back)."""
global _BLIP
try:
import torch
if _BLIP is None:
from transformers import BlipForConditionalGeneration, BlipProcessor
name = "Salesforce/blip-image-captioning-base"
_BLIP = (BlipProcessor.from_pretrained(name),
BlipForConditionalGeneration.from_pretrained(name).eval())
proc, model = _BLIP
img = Image.open(io.BytesIO(image_png)).convert("RGB")
with torch.no_grad():
out = model.generate(**proc(img, return_tensors="pt"), max_new_tokens=30)
cap = proc.decode(out[0], skip_special_tokens=True).strip()
return cap or None
except Exception:
return None
STYLE_SUFFIX = ", photorealistic photograph, natural lighting, high detail"
def describe_prompt(image_png: bytes) -> dict:
"""Caption the source photo and turn it into a text-to-image prompt (shown to the user, who can edit it)."""
cap = _caption(image_png)
return {"caption": cap or "", "prompt": (cap + STYLE_SUFFIX) if cap else "a photorealistic natural scene, high detail"}
def effective_prompt(gen_key: str, image_png: bytes, prompt: str) -> str:
"""The prompt actually sent to the generator. For text-to-image generators left on the default
edit instruction, derive a scene description from the source image (caption) instead."""
g = GENERATORS.get(gen_key)
if g and not g.uses_image and prompt.strip() == DEFAULT_PROMPT:
cap = _caption(image_png)
return f"{cap}, photorealistic, high detail" if cap else "a photorealistic natural scene, high detail"
return prompt
# --- Google Gemini 2.5 Flash Image ("Nano Banana") β image-conditioned ---------------------------
def _gemini(image_png: Optional[bytes], prompt: str, size=None) -> bytes:
key = os.environ["GEMINI_API_KEY"]
url = ("https://generativelanguage.googleapis.com/v1beta/models/"
"gemini-2.5-flash-image:generateContent?key=" + key)
parts = [{"text": prompt}]
if image_png is not None:
parts.append({"inline_data": {"mime_type": "image/png", "data": base64.b64encode(image_png).decode()}})
body = {"contents": [{"parts": parts}]}
r = requests.post(url, json=body, timeout=120); r.raise_for_status()
for part in r.json()["candidates"][0]["content"]["parts"]:
if "inline_data" in part or "inlineData" in part:
data = (part.get("inline_data") or part.get("inlineData"))["data"]
return base64.b64decode(data)
raise RuntimeError("Gemini returned no image part")
# --- OpenAI gpt-image-1 β image edit --------------------------------------------------------------
def _openai(image_png: Optional[bytes], prompt: str, size=None) -> bytes:
key = os.environ["OPENAI_API_KEY"]
if image_png is None: # text-only: plain image generation
r = requests.post("https://api.openai.com/v1/images/generations",
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
json={"model": "gpt-image-1", "prompt": prompt, "size": "1024x1024"}, timeout=180)
r.raise_for_status()
return base64.b64decode(r.json()["data"][0]["b64_json"])
r = requests.post("https://api.openai.com/v1/images/edits",
headers={"Authorization": f"Bearer {key}"},
data={"model": "gpt-image-1", "prompt": prompt, "size": "1024x1024"},
files={"image": ("src.png", image_png, "image/png")}, timeout=180)
r.raise_for_status()
return base64.b64decode(r.json()["data"][0]["b64_json"])
# --- xAI Grok (grok-2-image) β text-to-image (no image input; uses the prompt) --------------------
def _grok(image_png: Optional[bytes], prompt: str, size=None) -> bytes:
key = os.environ["XAI_API_KEY"]
r = requests.post("https://api.x.ai/v1/images/generations",
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
json={"model": "grok-2-image", "prompt": prompt, "response_format": "b64_json"},
timeout=180)
r.raise_for_status()
return base64.b64decode(r.json()["data"][0]["b64_json"])
# --- Black Forest Labs FLUX.1.1 (via fal.ai) β image-to-image -------------------------------------
def _flux(image_png: Optional[bytes], prompt: str, size=None) -> bytes:
key = os.environ["FAL_KEY"]
if image_png is None: # text-only: FLUX.1 [dev] text-to-image
r = requests.post("https://fal.run/fal-ai/flux/dev",
headers={"Authorization": f"Key {key}", "Content-Type": "application/json"},
json={"prompt": prompt, "image_size": "square_hd"}, timeout=180)
r.raise_for_status()
return requests.get(r.json()["images"][0]["url"], timeout=120).content
# fal.ai accepts a data-URI image_url for image-to-image
data_uri = "data:image/png;base64," + base64.b64encode(image_png).decode()
r = requests.post("https://fal.run/fal-ai/flux/dev/image-to-image",
headers={"Authorization": f"Key {key}", "Content-Type": "application/json"},
json={"prompt": prompt, "image_url": data_uri, "strength": 0.85}, timeout=180)
r.raise_for_status()
img_url = r.json()["images"][0]["url"]
return requests.get(img_url, timeout=120).content
# --- Ideogram 4.0 β OPEN-WEIGHT, runs LOCALLY (no paid API key) -----------------------------------
# Ideogram 4.0 (June 2026) is open-weight (9.3B DiT; Apache-2.0 inference code). The nf4 checkpoint
# (`ideogram-ai/ideogram-4-nf4-diffusers`) fits on a single 24 GB GPU. We load it via diffusers and
# run inference locally β no Ideogram API key. A free HF token (HF_TOKEN) is only needed ONCE to
# download the license-gated weights; after that it runs offline. Text-to-image (flow-matching DiT),
# so it generates from the prompt rather than conditioning on the source image.
_IDEOGRAM_PIPE = None
def _ideogram_local(image_png: Optional[bytes], prompt: str, size=None) -> bytes:
global _IDEOGRAM_PIPE
if _IDEOGRAM_PIPE is None:
import torch
from diffusers import Ideogram4Pipeline
pipe = Ideogram4Pipeline.from_pretrained(
"ideogram-ai/ideogram-4-nf4-diffusers", torch_dtype=torch.bfloat16,
token=os.environ.get("HF_TOKEN"))
_IDEOGRAM_PIPE = pipe.to("cuda" if __import__("torch").cuda.is_available() else "cpu")
out = _IDEOGRAM_PIPE(prompt=prompt, height=1024, width=1024).images[0]
return _png_bytes(out)
# --- Open-weight image-conditioned generators (FLUX.2 [klein], Qwen-Image-2.1), run in a SUBPROCESS ------
# These are 20-60 GB repos that need a newer diffusers than the shared SynthScope env and would not fit on the
# web UI's 24 GB GPU next to the detectors, so each image is produced by interface/backend/local_gen.py in a
# separate process (model CPU-offload, memory fully released afterwards). Configure with:
# SYNTHSCOPE_GEN_PYTHONPATH dir holding diffusers main + huggingface_hub 1.x (prepended to the child PYTHONPATH)
# SYNTHSCOPE_GEN_HF_HOME node-local HF cache for the child (keeps tens of GB off the shared /ivi quota)
_LOCAL_GEN = Path(__file__).with_name("local_gen.py")
_LOCAL_LOCK = threading.Lock() # one heavy local generation at a time
def _local_gen_available() -> bool:
"""The isolated generator environment is configured (the GPU requirement is checked per model)."""
extra = os.environ.get("SYNTHSCOPE_GEN_PYTHONPATH", "")
return bool(extra) and Path(extra).is_dir()
def _local_size(size) -> Optional[str]:
"""'WxH' for the child: the source's aspect ratio at about one megapixel, sides in multiples of 16."""
if not size:
return None
w, h = size
k = (1024 * 1024 / (w * h)) ** 0.5
return f"{max(256, int(w * k) // 16 * 16)}x{max(256, int(h * k) // 16 * 16)}"
def _local_env() -> dict:
env = dict(os.environ)
env["PYTHONPATH"] = os.pathsep.join(p for p in (os.environ["SYNTHSCOPE_GEN_PYTHONPATH"], env.get("PYTHONPATH", "")) if p)
if os.environ.get("SYNTHSCOPE_GEN_HF_HOME"):
env["HF_HOME"] = os.environ["SYNTHSCOPE_GEN_HF_HOME"]
return env
def local_batch(model_key: str, items: list[dict], timeout: int = 6 * 3600) -> None:
"""Generate many text-to-image items ({prompt, out, seed, size}) with ONE model load (used by the generator
benchmark). Outputs that already exist are skipped, so an interrupted run can be repeated."""
import json
with _LOCAL_LOCK, tempfile.TemporaryDirectory() as tmp:
spec = Path(tmp) / "batch.json"
spec.write_text(json.dumps(items))
r = subprocess.run([sys.executable, str(_LOCAL_GEN), "--model", model_key, "--batch", str(spec)],
env=_local_env(), capture_output=True, text=True, timeout=timeout)
if r.returncode != 0:
tail = (r.stderr or r.stdout or "").strip().splitlines()[-3:]
raise RuntimeError(f"{model_key} batch failed (exit {r.returncode}): " + " | ".join(tail))
def _local_subprocess(model_key: str) -> Callable[..., bytes]:
def call(image_png: Optional[bytes], prompt: str, size=None) -> bytes:
env = _local_env()
with _LOCAL_LOCK, tempfile.TemporaryDirectory() as tmp:
src, dst = Path(tmp) / "in.png", Path(tmp) / "out.png"
cmd = [sys.executable, str(_LOCAL_GEN), "--model", model_key, "--prompt", prompt, "--out", str(dst)]
if image_png is None:
cmd.append("--text-only")
if _local_size(size):
cmd += ["--size", _local_size(size)]
else:
src.write_bytes(image_png)
cmd += ["--image", str(src)]
r = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=3600)
if r.returncode != 0 or not dst.exists():
tail = (r.stderr or r.stdout or "").strip().splitlines()[-3:]
raise RuntimeError(f"{model_key} failed (exit {r.returncode}): " + " | ".join(tail))
return dst.read_bytes()
call.model_key = model_key # lets the benchmark find the local model behind a generator
return call
GENERATORS = {
g.key: g for g in [
Generator("nanobanana", "Nano Banana (Gemini 2.5 Flash Image)", "GEMINI_API_KEY", True, _gemini,
"Google's image-conditioned editor; respects the source image."),
Generator("gpt_image", "GPT Image (gpt-image-1)", "OPENAI_API_KEY", True, _openai,
"OpenAI image edit from the source."),
Generator("grok", "Grok (grok-2-image)", "XAI_API_KEY", False, _grok,
"xAI text-to-image; can't see the image, so it generates from an auto-caption of the source."),
Generator("flux", "FLUX.1 (img2img, via fal.ai)", "FAL_KEY", True, _flux,
"Black Forest Labs FLUX image-to-image."),
Generator("ideogram", "Ideogram 4.0 (open-weight, local)", "HF_TOKEN", False, _ideogram_local,
"Open-weight 9.3B DiT, nf4 on a 24GB GPU β runs locally via diffusers, NO paid API "
"key. HF_TOKEN only to download the license-gated weights once. Text-to-image: "
"generates from an auto-caption of the source (~4 min/image).", local=True),
Generator("flux2_klein", "FLUX.2 [klein] 4B (open-weight, local)", "SYNTHSCOPE_GEN_PYTHONPATH", True,
_local_subprocess("flux2_klein_4b"),
"Black Forest Labs' distilled FLUX.2 [klein] 4B (Apache-2.0), edits the source photo into a "
"new image locally on the GPU (4 steps); no API key.", local=True,
avail=_local_gen_available, heavy=True),
Generator("flux2_klein_9b", "FLUX.2 [klein] 9B (open-weight, local)", "SYNTHSCOPE_GEN_PYTHONPATH", True,
_local_subprocess("flux2_klein_9b"),
"Black Forest Labs' distilled FLUX.2 [klein] 9B (FLUX non-commercial license; gated - accept the "
"terms on the Hub), edits the source photo locally on the GPU (4 steps); no API key.", local=True,
avail=_local_gen_available, heavy=True),
Generator("flux1_kontext", "FLUX.1 Kontext [dev] (open-weight, local)", "SYNTHSCOPE_GEN_PYTHONPATH", True,
_local_subprocess("flux1_kontext_dev"),
"Black Forest Labs' 12B in-context image editor (FLUX non-commercial license; gated - accept the "
"terms on the Hub), edits the source photo locally on the GPU (28 steps); no API key. "
"Edit-only: it cannot create an image from text.", local=True,
avail=_local_gen_available, heavy=True, edit_only=True),
Generator("qwen_image_2_1", "Qwen-Image-2.1 (open-weight, local)", "SYNTHSCOPE_GEN_PYTHONPATH", True,
_local_subprocess("qwen_image_2_1"),
"Alibaba Qwen-Image-2.1 (7B unified generation + editing; Qwen Research License - research use "
"only), edits the source photo locally on the GPU; no API key.", local=True,
avail=_local_gen_available, heavy=True),
]
}
def list_generators() -> list[dict]:
return [{"key": g.key, "name": g.name, "uses_image": g.uses_image, "modes": g.modes,
"available": g.available(), "needs_gpu": g.needs_gpu(), "local": g.local,
"env_key": g.env_key, "note": g.note}
for g in GENERATORS.values()]
# Text-to-image generators with a baked-in content policy (Ideogram) refuse photorealistic PEOPLE and
# return a flat gray "Image blocked by safety filter" placeholder. We pre-empt the obvious case (person
# in the caption) before wasting a full ~4-min generation, and detect the placeholder as a backstop.
_PERSON_RE = re.compile(
r"\b(person|people|man|men|woman|women|boy|girl|child|children|baby|lady|human|portrait|selfie|crowd|couple)\b",
re.I)
_STEER = "Pick a non-person image, or use an image-conditioned generator (Nano Banana / FLUX)."
def _looks_blocked(img: Image.Image) -> bool:
"""A safety placeholder is near-uniform (text on a solid background): pixel std ~12 vs ~50 for a real image."""
try:
import numpy as np
return float(np.asarray(img.convert("RGB"), dtype="float32").std()) < 22.0
except Exception:
return False
def generate(gen_key: str, image_png: bytes, prompt: str = DEFAULT_PROMPT, mode: str = "recreate") -> bytes:
"""mode "recreate": generate a new image from the text prompt alone; "edit": edit ``image_png`` itself."""
if gen_key not in GENERATORS:
raise KeyError(f"unknown generator '{gen_key}'")
g = GENERATORS[gen_key]
if not g.available():
raise RuntimeError(f"{g.name} unavailable: set {g.env_key}")
if mode not in ("recreate", "edit"):
raise RuntimeError(f"unknown mode '{mode}'")
if mode not in g.modes:
raise RuntimeError(f"{g.name} does not support the '{mode}' mode (it supports: {', '.join(g.modes)}).")
if not g.uses_image and _PERSON_RE.search(prompt or ""):
raise RuntimeError(f"{g.name} blocks photorealistic people, and this image reads as a photo of a "
f"person (β{prompt[:70]}β). {_STEER}")
size = Image.open(io.BytesIO(image_png)).size
out = g.call(image_png if mode == "edit" else None, prompt, size)
# normalize to PNG, cap size
img = Image.open(io.BytesIO(out)).convert("RGB")
if _looks_blocked(img):
raise RuntimeError(f"{g.name} returned a safety-filter placeholder β it blocked this content. {_STEER}")
if max(img.size) > 1024:
img.thumbnail((1024, 1024))
return _png_bytes(img)
|