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