"""
DiffusionGemma · Radiology VQA & interactive report infill.
ZeroGPU Gradio demo for the paper "Discrete Diffusion Language Models for
Interactive Radiology Report Drafting" (https://huggingface.co/papers/2607.01436).
It serves the LoRA finetunes from `gevaertlab/diffusiongemma-radiology-vqa` on top of
the image-conditioned discrete-diffusion backbone `google/diffusiongemma-26B-A4B-it`
(`DiffusionGemmaForBlockDiffusion`).
Two capabilities, matching the reference implementation (github.com/mxvp/discrete_diffusion_RRG):
* VQA — image + question -> answer (models/generate.py::generate_report).
* Bidirectional infill — fill a masked span in a report using both sides of context,
via the fixed-position canvas-clamping sampler hook (models/infill.py).
ZeroGPU specifics:
* `import spaces` before torch.
* Base model + all three adapters loaded once at module scope, `.to("cuda")` eagerly.
* `model.generate` runs only inside the `@spaces.GPU` functions.
* A custom DiffusionGemma `transformers` wheel is bundled and installed at runtime.
"""
import glob
import os
import subprocess
import sys
# Set before torch is imported (transformers pulls torch in).
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
import spaces # must precede torch so ZeroGPU can patch torch.cuda.*
def _ensure_transformers():
"""Install the bundled custom DiffusionGemma `transformers` wheel at runtime.
Spaces installs `requirements.txt` before copying repo files into the image, so the
wheel can't be referenced by local path there. By the time this app runs the file is
present, so we install it here (only if DiffusionGemma isn't already importable).
"""
try:
import transformers # noqa: F401
if hasattr(transformers, "DiffusionGemmaForBlockDiffusion") or hasattr(
getattr(transformers, "models", object), "diffusion_gemma"
):
return
except Exception:
pass
wheels = sorted(glob.glob(os.path.join(os.path.dirname(os.path.abspath(__file__)), "transformers-*.whl")))
if not wheels:
print("[dgemma] no bundled transformers wheel found", flush=True)
return
print(f"[dgemma] Installing bundled transformers wheel: {os.path.basename(wheels[0])}", flush=True)
subprocess.check_call([sys.executable, "-m", "pip", "install", "--no-cache-dir", wheels[0]])
import importlib
importlib.invalidate_caches()
_ensure_transformers()
import contextlib
import re
import torch
import gradio as gr
from PIL import Image
from transformers import AutoProcessor, DiffusionGemmaForBlockDiffusion
HERE = os.path.dirname(os.path.abspath(__file__))
BASE_MODEL = os.environ.get("DGEMMA_BASE", "google/diffusiongemma-26B-A4B-it")
ADAPTER_REPO = os.environ.get("DGEMMA_ADAPTERS", "gevaertlab/diffusiongemma-radiology-vqa")
HF_TOKEN = os.environ.get("HF_TOKEN")
MAX_NEW_TOKENS = 256
# ZeroGPU slice: the 26B checkpoint (~49 GB bf16) needs the full backing card.
GPU_SIZE = os.environ.get("GDIFF_GPU_SIZE", "xlarge")
# LoRA adapters: label -> subfolder in the adapter repo (diffusion backbone only).
ADAPTERS = {
"VQA-RAD (mixed X-ray/CT/MRI)": "diffusion-vqarad",
"SLAKE (X-ray/CT/MRI + organs)": "diffusion-slake",
"VQA-Med (radiology QA)": "diffusion-vqamed",
}
_NAME = {label: sub.replace("diffusion-", "") for label, sub in ADAPTERS.items()}
# CoT system prompt used by the finetunes (models/report_format.py).
COT_SYSTEM_PROMPT = (
"A conversation between User and Assistant. The user asks a question, and the Assistant "
"solves it. The assistant first thinks about the findings in the image and then provides the "
"user with the final impression. The findings and answer are enclosed within "
"and tags, respectively, i.e., findings here "
" impression here "
)
# ---------------------------------------------------------------------------
# Model load (module scope, eager .to("cuda") for ZeroGPU)
# ---------------------------------------------------------------------------
print(f"[dgemma] loading processor + base model {BASE_MODEL} ...", flush=True)
processor = AutoProcessor.from_pretrained(BASE_MODEL, token=HF_TOKEN)
model = DiffusionGemmaForBlockDiffusion.from_pretrained(
BASE_MODEL, dtype=torch.bfloat16, token=HF_TOKEN
)
model.eval()
# Attach all three diffusion LoRA adapters onto the single base model.
from peft import PeftModel
# PEFT derives the load device from peft.infer_device(), which checks
# torch.cuda.is_available(). Under the ZeroGPU module-scope hijack that returns True with
# no real GPU attached, so safetensors' get_tensors() dispatches a real CUDA op and raises
# "No CUDA GPUs are available". Force is_available() False during the adapter load so the
# adapter weights land on CPU; we then move the whole PeftModel to CUDA eagerly (below),
# which the ZeroGPU hijack intercepts and streams into VRAM on the first GPU call.
_orig_cuda_avail = torch.cuda.is_available
torch.cuda.is_available = lambda: False
try:
_first_label = next(iter(ADAPTERS))
model = PeftModel.from_pretrained(
model, ADAPTER_REPO, subfolder=ADAPTERS[_first_label],
adapter_name=_NAME[_first_label], token=HF_TOKEN,
)
for label, sub in ADAPTERS.items():
if label == _first_label:
continue
model.load_adapter(ADAPTER_REPO, subfolder=sub, adapter_name=_NAME[label],
token=HF_TOKEN)
finally:
torch.cuda.is_available = _orig_cuda_avail
model.eval()
print("[dgemma] adapters loaded:", list(_NAME.values()), flush=True)
# Eager move to CUDA at module scope — the ZeroGPU hijack intercepts this, packs the
# weights to disk, and streams them into VRAM on the first @spaces.GPU entry.
model.to("cuda")
print("[dgemma] model registered on cuda (ZeroGPU will stream on first call)", flush=True)
# The discrete-diffusion backbone denoises a fixed-length canvas whose size is set by the
# base config (canvas_length=256), independent of the `max_new_tokens` request. Infill masks
# MUST be sized to this or the canvas-clamp hook mismatches the model's internal tensor.
def _canvas_length():
for obj in (model, getattr(model, "base_model", None)):
cfg = getattr(obj, "config", None)
v = getattr(cfg, "canvas_length", None)
if v:
return int(v)
return 256
CANVAS_LEN = _canvas_length()
print(f"[dgemma] canvas length = {CANVAS_LEN}", flush=True)
_THINK = re.compile(r"(.*?)", re.S)
_ANSWER = re.compile(r"(.*?)", re.S)
# Some finetunes emit an OpenAI-Harmony-style channel prefix, e.g. "<|channel|>thought\n..."
# or a bare "thought\n..." / "analysis\n..." lead-in before the actual answer. Strip it.
_CHANNEL = re.compile(r"^\s*(?:<\|[^>]*\|>)*\s*(?:thought|analysis|final|channel)\s*\n+", re.I)
def _strip_cot(text: str) -> str:
"""Pull the (and findings) out of a CoT response."""
t = _THINK.search(text)
a = _ANSWER.search(text)
if t or a:
findings = t.group(1).strip() if t else ""
impression = a.group(1).strip() if a else ""
return (findings + "\n" + impression).strip()
# No explicit CoT tags — clean up channel prefixes and any stray tag tokens.
t = re.sub(r"<\|[^>]*\|>", " ", text)
t = _CHANNEL.sub("", t)
t = (t.replace("", "").replace("", "")
.replace("", "").replace("", ""))
return t.strip()
def _prompt_inputs(image, instruction):
"""Chat-template an (image, text) turn — image first, per model best practices."""
msg = [{"role": "user", "content": [
{"type": "image", "image": image},
{"type": "text", "text": instruction},
]}]
inp = processor.apply_chat_template(
msg, tokenize=True, add_generation_prompt=True,
return_dict=True, return_tensors="pt")
return {k: (v.to("cuda") if hasattr(v, "to") else v) for k, v in inp.items()}
def _canvas_ids(out, plen):
seq = getattr(out, "sequences", None)
if seq is None:
seq = out[0]
ids = seq.reshape(-1).tolist()
return ids[plen:] if len(ids) > plen else ids
def _decode_canvas(out, plen):
ids = _canvas_ids(out, plen)
eos = processor.tokenizer.eos_token_id
eos_ids = set(eos) if isinstance(eos, (list, tuple)) else ({eos} if eos is not None else set())
for j, t in enumerate(ids):
if t in eos_ids:
ids = ids[:j]
break
return processor.tokenizer.decode(ids, skip_special_tokens=True).strip()
# ---------------------------------------------------------------------------
# VQA
# ---------------------------------------------------------------------------
@spaces.GPU(duration=120, size=GPU_SIZE)
def answer_question(image: Image.Image, question: str, adapter_label: str,
max_new_tokens: int = MAX_NEW_TOKENS) -> str:
"""Answer a question about a medical image with the DiffusionGemma radiology model.
Args:
image: the medical scan (X-ray, CT or MRI slice).
question: a natural-language question about the image.
adapter_label: which dataset-specific LoRA adapter to use.
max_new_tokens: canvas length / max tokens to denoise.
Returns:
The model's answer as text.
"""
if image is None:
return "Please upload a medical image first."
if not question or not question.strip():
return "Please enter a question."
model.set_adapter(_NAME.get(adapter_label, next(iter(_NAME.values()))))
image = image.convert("RGB")
inp = _prompt_inputs(image, question.strip())
plen = inp["input_ids"].shape[1]
with torch.no_grad():
out = model.generate(**inp, max_new_tokens=int(max_new_tokens))
return _strip_cot(_decode_canvas(out, plen))
# ---------------------------------------------------------------------------
# Bidirectional infill
# ---------------------------------------------------------------------------
@contextlib.contextmanager
def _fixed_canvas_positions(fixed_tokens: torch.Tensor, fixed_mask: torch.Tensor):
"""Clamp known canvas positions to fixed tokens every denoising step.
Port of models/infill.py from the reference implementation: patches the diffusion
generation mixin's `_denoising_step` so masked positions stay fixed while the model
bidirectionally fills the rest.
"""
from transformers.models.diffusion_gemma import generation_diffusion_gemma as G
if not hasattr(G, "DiffusionGemmaGenerationMixin"):
raise RuntimeError(
"DiffusionGemmaGenerationMixin not found -- the generate API drifted; "
"re-verify the canvas-update hook.")
Mixin = G.DiffusionGemmaGenerationMixin
orig_step = Mixin._denoising_step
ft = fixed_tokens
fm = fixed_mask.bool()
def _clamp(x):
return torch.where(fm.to(x.device), ft.to(x.device), x)
def patched_step(self, *args, **kwargs):
if "current_canvas" in kwargs and kwargs["current_canvas"] is not None:
kwargs["current_canvas"] = _clamp(kwargs["current_canvas"])
out = orig_step(self, *args, **kwargs)
cur, argmax = _clamp(out[0]), _clamp(out[1])
return (cur, argmax) + tuple(out[2:])
Mixin._denoising_step = patched_step
try:
yield
finally:
Mixin._denoising_step = orig_step
BLANK = "[BLANK]"
INFILL_INSTRUCTION = "Write the radiology report for this medical image."
@spaces.GPU(duration=150, size=GPU_SIZE)
def infill_report(image: Image.Image, template: str, adapter_label: str,
bidirectional: bool = True,
max_new_tokens: int = MAX_NEW_TOKENS) -> str:
"""Fill a [BLANK] span in a partial radiology report using the diffusion canvas.
The known text becomes fixed canvas positions; the [BLANK] span is denoised. With
`bidirectional=True` the model sees text on both sides of the hole; with it False,
only the left context is kept (the autoregressive-style baseline).
Args:
image: the medical scan the report describes.
template: a report with exactly one [BLANK] marking the span to fill.
adapter_label: which dataset-specific LoRA adapter to use.
bidirectional: use both-sides context (True) or left-only (False).
max_new_tokens: canvas length.
Returns:
Just the text the model wrote into the [BLANK].
"""
if image is None:
return "Please upload a medical image first."
if not template or BLANK not in template:
return f"Please provide a report template containing exactly one {BLANK} marker."
model.set_adapter(_NAME.get(adapter_label, next(iter(_NAME.values()))))
image = image.convert("RGB")
tok = processor.tokenizer
# The model's denoising canvas is always CANVAS_LEN long, regardless of the
# max_new_tokens request; the clamp mask must match that exact size.
L = CANVAS_LEN
pad = tok.pad_token_id or 0
# Split around the blank and locate its token span on the canvas.
before, after = template.split(BLANK, 1)
before_ids = tok(before, add_special_tokens=False)["input_ids"]
after_ids = tok(after, add_special_tokens=False)["input_ids"]
# Reserve room in the middle for the fill; cap by the user's requested span length
# and by the remaining canvas space.
max_fill = max(1, min(int(max_new_tokens), 96))
hole_len = max(1, min(max_fill, L - len(before_ids) - len(after_ids) - 2))
h0 = min(len(before_ids), L - 1)
h1 = min(h0 + hole_len, L - 1)
ids = list(before_ids) + [pad] * hole_len + list(after_ids)
ids = ids[:L]
n = len(ids)
canvas = torch.full((L,), pad, dtype=torch.long)
canvas[:n] = torch.tensor(ids, dtype=torch.long)
if bidirectional:
known = torch.ones(L, dtype=torch.bool)
known[h0:h1] = False # only the hole is unknown; both sides fixed
else:
known = torch.zeros(L, dtype=torch.bool)
known[:h0] = True # left context only
inp = _prompt_inputs(image, INFILL_INSTRUCTION)
plen = inp["input_ids"].shape[1]
ft = canvas[None].to("cuda")
fm = known[None].to("cuda")
with torch.no_grad(), _fixed_canvas_positions(ft, fm):
out = model.generate(**inp, max_new_tokens=L)
cv = _canvas_ids(out, plen)
fill = tok.decode(cv[h0:h1], skip_special_tokens=True).strip()
return fill or "(model produced an empty fill — try a shorter blank or different adapter)"
# ---------------------------------------------------------------------------
# UI
# ---------------------------------------------------------------------------
CSS = """
#col-container { max-width: 1100px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
"""
ADAPTER_LABELS = list(ADAPTERS.keys())
with gr.Blocks(title="DiffusionGemma Radiology VQA") as demo:
with gr.Column(elem_id="col-container"):
gr.Markdown(
"# 🩻 DiffusionGemma · Radiology VQA & Interactive Report Infill\n"
"Image-conditioned **discrete-diffusion** LLM for radiology, from the paper "
"[*Discrete Diffusion Language Models for Interactive Radiology Report Drafting*]"
"(https://huggingface.co/papers/2607.01436). "
"Backbone [`google/diffusiongemma-26B-A4B-it`](https://huggingface.co/google/diffusiongemma-26B-A4B-it) "
"+ LoRA finetunes [`gevaertlab/diffusiongemma-radiology-vqa`]"
"(https://huggingface.co/gevaertlab/diffusiongemma-radiology-vqa).\n\n"
"⚠️ Research demo — **not** a medical device, not for clinical use."
)
with gr.Tabs():
# ---- VQA tab ----
with gr.Tab("Visual Question Answering"):
with gr.Row():
with gr.Column():
vqa_image = gr.Image(type="pil", label="Medical image", height=340)
vqa_question = gr.Textbox(
label="Question",
placeholder="e.g. Is there evidence of an aortic aneurysm?",
)
vqa_adapter = gr.Dropdown(
ADAPTER_LABELS, value=ADAPTER_LABELS[0], label="Adapter (dataset)")
vqa_btn = gr.Button("Answer", variant="primary")
with gr.Column():
vqa_out = gr.Textbox(label="Answer", lines=8)
with gr.Accordion("Advanced", open=False):
vqa_tokens = gr.Slider(32, 256, value=MAX_NEW_TOKENS, step=16,
label="Canvas length (max new tokens)")
gr.Examples(
examples=[
["cxr_aorta.png", "Is there evidence of an aortic aneurysm?", ADAPTER_LABELS[0]],
["cxr_consolidation.png", "Is there airspace consolidation on the left side?", ADAPTER_LABELS[0]],
["abdomen_colon.png", "Is the colon more prominent on the patient's right or left side?", ADAPTER_LABELS[0]],
],
inputs=[vqa_image, vqa_question, vqa_adapter],
outputs=vqa_out,
fn=answer_question,
cache_examples=True,
cache_mode="lazy",
)
vqa_btn.click(
answer_question,
inputs=[vqa_image, vqa_question, vqa_adapter, vqa_tokens],
outputs=vqa_out,
api_name="answer_question",
)
# ---- Infill tab ----
with gr.Tab("Bidirectional Report Infill"):
gr.Markdown(
"Write a partial report with exactly one **`[BLANK]`** where you want the "
"model to fill in. Discrete diffusion fills it using text on **both** sides "
"of the hole — toggle *bidirectional* off to compare against left-only "
"(autoregressive-style) context."
)
with gr.Row():
with gr.Column():
inf_image = gr.Image(type="pil", label="Medical image", height=340)
inf_template = gr.Textbox(
label="Report template (use one [BLANK])",
lines=4,
value="The lungs are clear. [BLANK] No pleural effusion is seen.",
)
inf_adapter = gr.Dropdown(
ADAPTER_LABELS, value=ADAPTER_LABELS[0], label="Adapter (dataset)")
inf_bidir = gr.Checkbox(value=True, label="Bidirectional (both-sides context)")
inf_btn = gr.Button("Fill the blank", variant="primary")
with gr.Column():
inf_out = gr.Textbox(label="Filled span", lines=8)
with gr.Accordion("Advanced", open=False):
inf_tokens = gr.Slider(4, 96, value=48, step=4,
label="Max fill span (tokens)")
gr.Examples(
examples=[
["cxr_aorta.png", "The lungs are clear. [BLANK] No pleural effusion is seen.", ADAPTER_LABELS[0], True],
["cxr_consolidation.png", "Findings: [BLANK] There is no pneumothorax.", ADAPTER_LABELS[0], True],
],
inputs=[inf_image, inf_template, inf_adapter, inf_bidir],
outputs=inf_out,
fn=infill_report,
cache_examples=True,
cache_mode="lazy",
)
inf_btn.click(
infill_report,
inputs=[inf_image, inf_template, inf_adapter, inf_bidir, inf_tokens],
outputs=inf_out,
api_name="infill_report",
)
if __name__ == "__main__":
demo.queue().launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)