CoolFace
Apppublic

huggingface-projects/diffusiongemma-3d-gen

sourceHugging Faceupdated 4mo agoView on Hugging Face
8likes
app.py389 linesDownload Raw Back to root
1"""2Gemma Diffusion — text → 3D asset builder (gradio.Server backend + custom frontend).3 4ZeroGPU port. The block-diffusion model designs a standalone SVG illustration of a5described asset; the custom frontend extrudes that SVG into a live, spinning Three.js63D scene. `gradio.Server` (a FastAPI subclass) provides Gradio's queue + SSE streaming7under our hand-written HTML/CSS/JS frontend. The single streaming endpoint `/generate`8yields one JSON frame per denoising step: the raw SVG canvas diffusing on the left, the9extruded 3D object rendering on the right.10 11ZeroGPU specifics:12- `import spaces` happens before `torch`.13- The model is loaded once at module scope with `.to("cuda")` (ZeroGPU registers it).14- The actual `model.generate` call lives inside the `@spaces.GPU` function `_gpu_stream`;15  the `gradio.Server` endpoint only marshals picklable CPU tensors in/out of it.16 17Refs:18- https://huggingface.co/blog/introducing-gradio-server19- https://huggingface.co/docs/hub/spaces-zerogpu20"""21 22import glob23import os24import subprocess25import sys26 27# Set before torch is imported (transformers pulls torch in).28os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")29 30import spaces  # must precede torch so ZeroGPU can patch it31 32 33def _ensure_transformers():34    """Install the bundled custom DiffusionGemma `transformers` wheel at runtime.35 36    Spaces installs `requirements.txt` *before* copying the repo files into the image,37    so the wheel can't be referenced by local path there. By the time this app runs the38    file is present in the working directory, so we install it here (only if a stock /39    no transformers is importable) before importing torch/transformers below.40    """41    try:42        import transformers  # noqa: F40143 44        if hasattr(transformers, "DiffusionGemmaForBlockDiffusion") or hasattr(45            getattr(transformers, "models", object), "diffusion_gemma"46        ):47            return48    except Exception:49        pass50    wheels = sorted(glob.glob(os.path.join(os.path.dirname(os.path.abspath(__file__)), "transformers-*.whl")))51    if not wheels:52        return53    print(f"[gdiff] Installing bundled transformers wheel: {os.path.basename(wheels[0])}", flush=True)54    subprocess.check_call([sys.executable, "-m", "pip", "install", "--no-cache-dir", wheels[0]])55    import importlib56 57    importlib.invalidate_caches()58 59 60_ensure_transformers()61 62import json63import queue as queue_lib64import re65import threading66import time as _time67 68import torch69from fastapi.responses import HTMLResponse70from gradio import Server71from transformers import AutoTokenizer, DiffusionGemmaForBlockDiffusion72from transformers.generation.streamers import BaseStreamer73 74HERE = os.path.dirname(os.path.abspath(__file__))75MODEL_PATH = os.environ.get("GDIFF_MODEL_PATH", "google/diffusiongemma-26B-A4B-it")76HF_TOKEN = os.environ.get("HF_TOKEN")77MAX_ITERS_CAP = 120  # hard cap on denoising steps per block78# ZeroGPU: the 26B checkpoint (~49 GB bf16) needs the full backing card.79GPU_SIZE = os.environ.get("GDIFF_GPU_SIZE", "xlarge")80 81SYSTEM_PROMPT = (82    "You are an expert vector artist. Given a TEXT description (usually a game asset — a "83    "sword, shield, potion, coin, treasure chest, spaceship, robot, mushroom, key, gem, "84    "etc.) you design an original, polished SVG illustration of it. The SVG will be extruded "85    "into a spinning 3D object, so design it with clean, solid, extrudable shapes.\n"86    "\n"87    "Requirements:\n"88    "- Output ONLY a single standalone SVG document: start your response with `<svg` and end "89    "with `</svg>`. No HTML wrapper, no <?xml?> prologue, no markdown code fences, no "90    "explanation.\n"91    '- The opening tag must include xmlns="http://www.w3.org/2000/svg" and a square viewBox '92    '(e.g. "0 0 100 100").\n'93    "- Draw in a bold, readable, flat 'game asset / icon' style: several distinct shapes "94    "(<path>, <rect>, <circle>, <polygon>) each with a SOLID fill color (the `fill` "95    "attribute) and a coherent, attractive palette. Layer shapes to suggest detail (outline, "96    "body, highlights, shading).\n"97    "- Do NOT add a full-bleed background rectangle — keep the background transparent so each "98    "shape becomes its own clean 3D piece against the dark scene.\n"99    "- Use only solid filled shapes. Avoid gradients, filters, <text>, images, and "100    "stroke-only / fill=\"none\" shapes — they do not extrude.\n"101    "- Use enough shapes to look great while staying clean (roughly 6-16 shapes).\n"102    "- When asked to modify the artwork, return the FULL updated SVG with the change applied, "103    "keeping the same subject unless asked to change it.\n"104)105 106_MARKER_RE = re.compile(107    r"<\|?(?:channel|turn|think|image|audio|video|tool(?:_call|_response)?)\|?>"108)109_FENCE_RE = re.compile(r"```(?:html|svg|xml)?\s*(.*?)\s*```", re.DOTALL)110_SVG_CHILD_RE = re.compile(111    r"<(?:path|rect|circle|ellipse|polygon|polyline|line|g|defs)\b", re.I112)113_SVG_OPEN = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">\n'114 115 116# --------------------------------------------------------------------------- #117# Model (loaded once at module scope; ZeroGPU registers .to("cuda") tensors)118# --------------------------------------------------------------------------- #119DEVICE = "cuda" if torch.cuda.is_available() else "cpu"120print(f"[gdiff] Loading model from {MODEL_PATH} on {DEVICE} ...", flush=True)121tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, token=HF_TOKEN)122model = DiffusionGemmaForBlockDiffusion.from_pretrained(123    MODEL_PATH,124    dtype=torch.bfloat16,125    low_cpu_mem_usage=True,126    token=HF_TOKEN,127).to(DEVICE)128model.eval()129CANVAS_LEN = model.config.canvas_length130PAD_ID = tokenizer.pad_token_id or 0131print(f"[gdiff] Model ready | canvas_length={CANVAS_LEN}", flush=True)132 133# Cache of the last *cleaned* SVG so a follow-up tweak can warm-start in place.134model._last_clean_html = None135 136 137# --------------------------------------------------------------------------- #138# Helpers (CPU-only; safe to run in the gradio.Server main process)139# --------------------------------------------------------------------------- #140def warm_canvas_from_cache():141    """Starting canvas (first block) built from the previous *cleaned* SVG.142 143    Returns a CPU tensor (it is pickled across the ZeroGPU process boundary and moved144    to CUDA inside the GPU worker). We re-tokenize the cleaned SVG rather than reuse145    raw output tokens so a mangled ``<svg`` header can't compound across tweaks.146    """147    svg = getattr(model, "_last_clean_html", None)148    if not svg:149        return None150    ids = tokenizer(svg, add_special_tokens=False).input_ids[:CANVAS_LEN]151    if not ids:152        return None153    if len(ids) < CANVAS_LEN:154        ids = ids + [PAD_ID] * (CANVAS_LEN - len(ids))155    return torch.tensor(ids, dtype=torch.long).unsqueeze(0)156 157 158def last_assistant_html(history_json: str):159    try:160        history = json.loads(history_json) if history_json else []161    except json.JSONDecodeError:162        return None163    for turn in reversed(history):164        if turn.get("role") == "assistant" and turn.get("content"):165            return turn["content"]166    return None167 168 169def clean_text(text: str) -> str:170    return _MARKER_RE.sub("", text).lstrip()171 172 173def extract_svg(text: str) -> str:174    """Pull a clean standalone <svg>…</svg> out of the (possibly mangled) model output.175 176    Warm-start diffusion frequently chews the very front of the document — the opening177    ``<svg`` loses its ``<`` (``svg viewBox=…``) or a few more chars. If we can't find an178    intact ``<svg``, we rebuild a canonical wrapper around the first real child element so179    the output is always valid (the 3D viewer auto-fits the camera, so a default viewBox is180    fine). Repairing here is essential: the cleaned result is what we cache for the next181    tweak's warm-start, which stops corruption from compounding across tweaks.182    """183    text = clean_text(text)184    fenced = _FENCE_RE.search(text)185    if fenced:186        text = fenced.group(1)187 188    lower = text.lower()189    s = lower.find("<svg")190    if s != -1:191        text = text[s:]192    else:193        m = _SVG_CHILD_RE.search(text)194        if m:195            text = _SVG_OPEN + text[m.start():]196        # else: nothing salvageable; fall through and just trim/close it197 198    lower = text.lower()199    e = lower.rfind("</svg>")200    if e != -1:201        text = text[: e + len("</svg>")]202    else:203        text = text.rstrip() + "\n</svg>"  # tail eaten mid-stream; close it204    return text.strip()205 206 207class QueueDiffusionStreamer(BaseStreamer):208    def __init__(self, tok, q: "queue_lib.Queue"):209        self.tok = tok210        self.q = q211        self.confirmed_ids: list[int] = []212        self.prompt_skipped = False213        self.block = 0214        self.step = 0215 216    def _decode(self, ids):217        return self.tok.decode(ids, skip_special_tokens=True)218 219    def put(self, value):220        ids = value[0].tolist() if value.dim() > 1 else value.tolist()221        if not self.prompt_skipped:222            self.prompt_skipped = True223            return224        self.confirmed_ids.extend(ids)225        self.block += 1226        self.step = 0227        self.q.put(("commit", self._decode(self.confirmed_ids), self.block, self.step))228 229    def put_draft(self, value):230        self.step += 1231        ids = value[0].tolist() if value.dim() > 1 else value.tolist()232        self.q.put(("draft", self._decode(self.confirmed_ids + ids), self.block + 1, self.step))233 234    def end(self):235        self.q.put(("end", self._decode(self.confirmed_ids), self.block, self.step))236 237 238def build_messages(history_json: str, prompt: str):239    try:240        history = json.loads(history_json) if history_json else []241    except json.JSONDecodeError:242        history = []243    messages = [{"role": "system", "content": SYSTEM_PROMPT}]244    for turn in history:245        role = turn.get("role")246        content = turn.get("content", "")247        if role in ("user", "assistant") and content:248            messages.append({"role": role, "content": content})249    messages.append({"role": "user", "content": prompt})250    return messages251 252 253# --------------------------------------------------------------------------- #254# GPU work — runs in a forked ZeroGPU worker process.255# Inputs/outputs cross the boundary via pickle, so only CPU tensors / plain256# Python objects go in and out (no CUDA tensors are returned).257# --------------------------------------------------------------------------- #258def _estimate_duration(input_ids, max_new_tokens=2048, max_iters=64, full_denoise=False, canvas_ids=None):259    blocks = max(1, int(max_new_tokens) // max(1, CANVAS_LEN))260    secs = 30 + blocks * int(max_iters) * 0.3261    return int(min(120, secs))  # xlarge internally doubles this for the quota check262 263 264@spaces.GPU(duration=_estimate_duration, size=GPU_SIZE)265def _gpu_stream(input_ids, max_new_tokens, max_iters, full_denoise, canvas_ids):266    input_ids = input_ids.to(model.device)267    gen_kwargs = dict(max_new_tokens=int(max_new_tokens), max_denoising_steps=int(max_iters))268    if full_denoise:269        gen_kwargs["confidence_threshold"] = 1e-9270        gen_kwargs["stability_threshold"] = int(max_iters)271    if canvas_ids is not None:272        gen_kwargs["canvas_ids"] = canvas_ids.to(model.device)273 274    q: "queue_lib.Queue" = queue_lib.Queue()275    streamer = QueueDiffusionStreamer(tokenizer, q)276    err = {}277 278    def worker():279        try:280            with torch.inference_mode():281                model.generate(input_ids, streamer=streamer, **gen_kwargs)282        except Exception as exc:  # surface to the endpoint283            err["msg"] = f"{type(exc).__name__}: {exc}"284            q.put(("error", str(exc), 0, 0))285        finally:286            q.put(("end", "", 0, 0))  # always unblock the consumer287 288    thread = threading.Thread(target=worker)289    thread.start()290    try:291        while True:292            kind, text, block, step = q.get()293            if kind == "error":294                yield ("error", err.get("msg", text), 0, 0)295                return296            if kind == "end":297                return298            yield (kind, text, block, step)299    finally:300        thread.join()301 302 303# --------------------------------------------------------------------------- #304# Server305# --------------------------------------------------------------------------- #306app = Server(title="Gemma Diffusion 3D Asset Builder")307 308 309@app.api(name="generate", concurrency_limit=1, time_limit=600, stream_every=0.05)310def generate(311    prompt: str,312    history_json: str = "[]",313    max_new_tokens: int = 2048,314    max_iters: int = 64,315    full_denoise: bool = False,316    anim_delay: float = 0.0,317    warm_start: bool = True,318) -> str:319    """Stream the diffusion generation as JSON frames (one per denoising step).320 321    The model emits a raw SVG illustration; the frontend extrudes it into 3D with Three.js.322    """323    prompt = (prompt or "").strip()324    if not prompt:325        yield json.dumps({"kind": "error", "message": "Empty prompt."})326        return327 328    messages = build_messages(history_json, prompt)329    max_iters = max(1, min(int(max_iters), MAX_ITERS_CAP))330 331    # Tweak warm-start: seed the diffusion's first canvas with the previous artwork's own332    # tokens (native `canvas_ids` API) so the model edits the existing SVG in place.333    is_tweak = bool(last_assistant_html(history_json))334    canvas_ids = warm_canvas_from_cache() if (warm_start and is_tweak) else None335    warming = canvas_ids is not None336 337    input_ids = tokenizer.apply_chat_template(338        messages,339        tokenize=True,340        add_generation_prompt=True,341        return_tensors="pt",342        return_dict=True,343    )["input_ids"]344 345    last_text = ""346    for kind, text, block, step in _gpu_stream(347        input_ids, int(max_new_tokens), max_iters, bool(full_denoise), canvas_ids348    ):349        if kind == "error":350            yield json.dumps({"kind": "error", "message": text})351            return352        last_text = text353        yield json.dumps(354            {355                "kind": "draft" if kind == "draft" else "commit",356                "source": clean_text(text),357                "block": block,358                "step": step,359                "canvas": CANVAS_LEN,360                "max_iters": max_iters,361                "warming": warming,362            }363        )364        if anim_delay and kind == "draft":365            _time.sleep(float(anim_delay))366 367    final_source = extract_svg(last_text)368    # Cache the *cleaned* SVG so the next tweak warm-starts from a valid header.369    if final_source.strip():370        model._last_clean_html = final_source371    yield json.dumps({"kind": "done", "source": final_source})372 373 374@app.get("/", response_class=HTMLResponse)375async def homepage():376    with open(os.path.join(HERE, "index.html"), "r", encoding="utf-8") as f:377        return f.read()378 379 380# HF Spaces' gradio runtime looks for a top-level `demo` (or `app`) to launch.381demo = app382 383if __name__ == "__main__":384    app.launch(385        server_name=os.environ.get("GDIFF_HOST", "0.0.0.0"),386        server_port=int(os.environ.get("GDIFF_PORT", "7860")),387        show_error=True,388    )389