Spaces:
Running on Zero
Running on Zero
Download app.py from danesed/WaveDiT-demo: direct link, hf CLI and curl.
- Browser
- Download file 49.2 kB
-
https://huggingface.co/spaces/danesed/WaveDiT-demo/resolve/main/app.py
- Command line
-
hf download hf://spaces/danesed/WaveDiT-demo/app.py
-
curl -L -o app.py https://huggingface.co/spaces/danesed/WaveDiT-demo/resolve/main/app.py
49.2 kB
| """WaveDiT - 3D Brain MRI Generator (Hugging Face ZeroGPU Gradio Space). | |
| Single-page Gradio app: | |
| * pick an age (and optionally model / seed / steps / CFG / sampler / Morpheus), | |
| * generate a full-resolution synthetic 3D brain MRI on a ZeroGPU GPU, | |
| * explore it in-browser with an MRIcroGL-like Niivue viewer (triplane + 3D render), | |
| * sweep an "aging time-lapse", apply presets, switch colormap/gamma live, | |
| * download the float32 NIfTI and read an always-on generation/VRAM badge. | |
| The wavedit/ package is VENDORED into this Space (do not pip-install it: its | |
| pyproject pins torch==2.6.0, which is unsupported on ZeroGPU >= 2.8). The only | |
| source patch is a lazy `import wandb` in BOTH | |
| - wavedit/models/wavelet_flow_matching.py (used at sampling-import time), and | |
| - wavedit/training/trainer.py (pulled in transitively because | |
| wavedit/training/__init__.py imports Trainer, and app.py imports | |
| load_model_weights from wavedit.training.checkpoint). | |
| Research artifact only - synthetic images, not a medical device, not for clinical use. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| # CRITICAL: select the pure-PyTorch neighborhood-attention fallback BEFORE importing | |
| # wavedit (NATTEN is unavailable on Spaces; the torch backend is numerically equivalent). | |
| os.environ.setdefault("WAVEDIT_NA_BACKEND", "torch") | |
| import base64 | |
| import gzip | |
| import html as html_lib | |
| import random | |
| import time | |
| import traceback | |
| from pathlib import Path | |
| import gradio as gr | |
| import nibabel as nib | |
| import numpy as np | |
| import torch | |
| try: | |
| import spaces # True no-op off ZeroGPU; gates the real GPU on @spaces.GPU. | |
| _HAS_SPACES = True | |
| except Exception: # pragma: no cover - running fully off-platform | |
| _HAS_SPACES = False | |
| class _SpacesShim: | |
| def GPU(*dargs, **dkwargs): | |
| def _wrap(fn): | |
| return fn | |
| # Support both @spaces.GPU and @spaces.GPU(duration=...) | |
| if dargs and callable(dargs[0]) and not dkwargs: | |
| return dargs[0] | |
| return _wrap | |
| spaces = _SpacesShim() # type: ignore | |
| from huggingface_hub import hf_hub_download | |
| from wavedit import Config | |
| from wavedit.models import build_model | |
| # IMPORT load_model_weights DIRECTLY from .training.checkpoint. NOTE: this still runs | |
| # wavedit/training/__init__.py (which imports trainer.py); the vendored trainer.py has | |
| # the lazy-wandb patch, so this import succeeds even when wandb is not installed. | |
| from wavedit.training.checkpoint import load_model_weights | |
| from wavedit.generation.generator import center_crop_bounds | |
| # --------------------------------------------------------------------------- # | |
| # Constants | |
| # --------------------------------------------------------------------------- # | |
| HF_REPO = "danesed/WaveDiT" | |
| HF_REVISION = "main" | |
| CHECKPOINTS = { | |
| "Base (fast)": "WaveDiT-Base.pth", | |
| "FinePatch (detailed)": "WaveDiT-FinePatch.pth", | |
| "Wide (largest)": "WaveDiT-Wide.pth", | |
| } | |
| DEFAULT_MODEL = "Base (fast)" | |
| # Standard MNI-like target grid; FULL_SIZE is read per-checkpoint at build time so a | |
| # future Deep/Wide variant with a different image_size still crops correctly. | |
| DEFAULT_FULL_SIZE = (224, 224, 224) | |
| CROP_SIZE = (182, 218, 182) | |
| # Fallbacks if a checkpoint somehow lacks an age range (the real ones carry 6..95). | |
| AGE_MIN_FALLBACK, AGE_MAX_FALLBACK = 6, 95 | |
| AGE_DEFAULT = 72 | |
| SEED_DEFAULT = 42 | |
| SEED_MAX = 2_147_483_647 | |
| STEPS_DEFAULT = 10 | |
| STEPS_MIN, STEPS_MAX = 1, 200 | |
| # Aging time-lapse runs many frames; keep each frame within one short GPU window and | |
| # bound total session work. Per-frame steps are clamped to SWEEP_STEPS_MAX and the | |
| # frame count is bounded so a worst-case FinePatch sweep never overruns the per-call | |
| # @spaces.GPU budget (each frame is its OWN GPU call -- see gpu_sweep_frame). | |
| SWEEP_FRAMES_MIN, SWEEP_FRAMES_MAX = 3, 8 | |
| SWEEP_STEPS_MAX = 50 | |
| CFG_MIN, CFG_MAX, CFG_DEFAULT = 1.0, 8.0, 1.0 | |
| CFG_RESCALE = 0.7 # fixed; only active when cfg_scale != 1.0 | |
| MORPHEUS_DEFAULT = 1.0 | |
| SAMPLERS = ["Heun", "Euler"] | |
| SAMPLER_MAP = {"Heun": "heun", "Euler": "euler"} | |
| COLORMAPS = ["gray", "bone", "viridis", "plasma", "inferno", "magma", "hot", "cubehelix"] | |
| DELIGHT_COLORMAPS = ["viridis", "plasma", "bone", "magma"] | |
| SPACE_VERSION = "0.1.0" | |
| DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| HERE = Path(__file__).resolve().parent | |
| VIEWER_TEMPLATE_PATH = HERE / "viewer_template.html" | |
| HERO_NII_PATH = HERE / "assets" / "wavedit_hero_finepatch_age72_seed42.nii.gz" | |
| DOWNLOAD_DIR = Path(os.environ.get("WAVEDIT_OUT_DIR", "/tmp/wavedit_outputs")) | |
| DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True) | |
| # --------------------------------------------------------------------------- # | |
| # Links / copy | |
| # --------------------------------------------------------------------------- # | |
| LINK_PAPER = "https://arxiv.org/abs/2606.08670" | |
| LINK_CODE = "https://github.com/sisinflab/WaveDiT" | |
| LINK_PROJECT = "https://danesed.github.io/wavedit-page/" | |
| LINK_MODEL = "https://huggingface.co/danesed/WaveDiT" | |
| CITATION = """@misc{danese2026waveditdistributionawarewaveletflow, | |
| title = {WaveDiT: Distribution-Aware Wavelet Flow Matching for Efficient 3D Brain MRI Synthesis}, | |
| author = {Danilo Danese and Angela Lombardi and Giuseppe Fasano and Matteo Attimonelli and Tommaso Di Noia}, | |
| year = {2026}, | |
| eprint = {2606.08670}, | |
| archivePrefix = {arXiv}, | |
| primaryClass = {cs.CV}, | |
| url = {https://arxiv.org/abs/2606.08670} | |
| }""" | |
| # --------------------------------------------------------------------------- # | |
| # Viewer template | |
| # --------------------------------------------------------------------------- # | |
| _INLINE_VIEWER_TEMPLATE = """<!doctype html><html lang="en"><head><meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <style>html,body{margin:0;height:100%;background:#0a0e14;overflow:hidden} | |
| #gl{width:100%;height:100%;display:block} | |
| #err{position:absolute;inset:0;display:none;align-items:center;justify-content:center; | |
| color:#ffd0d0;background:#0a0e14;font-family:system-ui;padding:24px;text-align:center}</style></head> | |
| <body><canvas id="gl"></canvas> | |
| <div id="err">This viewer needs WebGL2. Use a recent browser, or the Download .nii.gz button.<br> | |
| <code id="errmsg"></code></div> | |
| <script type="module"> | |
| const showErr=(m)=>{const e=document.getElementById("err");document.getElementById("errmsg").textContent=m||"";e.style.display="flex"}; | |
| let webgl2ok=true;let nv=null; | |
| try{const t=document.createElement("canvas").getContext("webgl2");if(!t){webgl2ok=false;showErr("getContext(webgl2) returned null.")}}catch(e){webgl2ok=false;showErr(String(e))} | |
| if(webgl2ok){try{const mod=await import("https://esm.sh/@niivue/niivue@0.69.0"); | |
| const {Niivue,SLICE_TYPE,SHOW_RENDER,DRAG_MODE}=mod; | |
| nv=new Niivue({backColor:[0.04,0.05,0.07,1],dragMode:DRAG_MODE.slicer3D, | |
| multiplanarShowRender:SHOW_RENDER.ALWAYS,isResizeCanvas:true}); | |
| await nv.attachTo("gl"); | |
| await nv.loadVolumes([{url:"__DATA_URL__",name:"brain.nii.gz",colormap:"__COLORMAP__"}]); | |
| nv.setSliceType(SLICE_TYPE.MULTIPLANAR); | |
| if(nv.volumes.length){nv.setColormap(nv.volumes[0].id,"__COLORMAP__")} | |
| nv.setClipPlane([0.3,180,20]);nv.setInterpolation(false);nv.drawScene(); | |
| }catch(e){showErr("Could not load the 3D viewer library (network/CDN). Use the Download .nii.gz button. "+String(e&&e.message?e.message:e))}} | |
| window.addEventListener("message",(ev)=>{const d=ev&&ev.data;if(!d||!nv||!nv.volumes||!nv.volumes.length)return;try{ | |
| if(d.type==="colormap"&&typeof d.value==="string")nv.setColormap(nv.volumes[0].id,d.value); | |
| else if(d.type==="gamma")nv.setGamma(parseFloat(d.value)); | |
| else if(d.type==="clip"&&Array.isArray(d.value))nv.setClipPlane(d.value); | |
| else if(d.type==="reset"){nv.setClipPlane([0.3,180,20])} | |
| nv.drawScene()}catch(e){}}); | |
| </script></body></html>""" | |
| def _load_viewer_template() -> str: | |
| if VIEWER_TEMPLATE_PATH.exists(): | |
| try: | |
| return VIEWER_TEMPLATE_PATH.read_text(encoding="utf-8") | |
| except Exception: | |
| pass | |
| return _INLINE_VIEWER_TEMPLATE | |
| VIEWER_TEMPLATE = _load_viewer_template() | |
| def _render_iframe(data_url: str, colormap: str) -> str: | |
| """Build the Niivue <iframe srcdoc> HTML string for a given data URL + colormap.""" | |
| doc = VIEWER_TEMPLATE.replace("__DATA_URL__", data_url).replace("__COLORMAP__", colormap) | |
| # srcdoc must be attribute-safe: HTML-escape (quotes included). The template avoids | |
| # apostrophes, but escaping makes the embed robust regardless. | |
| srcdoc = html_lib.escape(doc, quote=True) | |
| return ( | |
| f'<iframe title="WaveDiT 3D viewer" sandbox="allow-scripts" ' | |
| f'style="width:100%;height:620px;border:0;border-radius:4px;background:#0a0e14" ' | |
| f'srcdoc="{srcdoc}"></iframe>' | |
| ) | |
| def _placeholder_iframe(message: str) -> str: | |
| msg = html_lib.escape(message, quote=True) | |
| doc = ( | |
| '<!doctype html><html><head><meta charset="utf-8">' | |
| '<style>html,body{margin:0;height:100%;display:flex;align-items:center;' | |
| 'justify-content:center;background:#0a0e14;color:#7fd1ff;' | |
| 'font-family:system-ui;text-align:center;padding:24px}</style></head>' | |
| f"<body><div>{msg}</div></body></html>" | |
| ) | |
| srcdoc = html_lib.escape(doc, quote=True) | |
| return ( | |
| f'<iframe title="WaveDiT 3D viewer" sandbox="allow-scripts" ' | |
| f'style="width:100%;height:620px;border:0;border-radius:4px;background:#0a0e14" ' | |
| f'srcdoc="{srcdoc}"></iframe>' | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # Model registry -- EAGER module-scope preload (ZeroGPU contract). | |
| # | |
| # ZeroGPU runs every @spaces.GPU call in a forked SUBPROCESS, and mutations to | |
| # module-scope state inside that call do NOT propagate back to the parent. So a | |
| # cache populated *inside* the GPU function would be thrown away on every call and | |
| # rebuilt from scratch -- burning the user's (very limited) GPU quota on one-time | |
| # work and hitting the documented "less efficient" CUDA-transfer-in-fn anti-pattern. | |
| # | |
| # The fix the docs mandate: build BOTH checkpoints and move them to cuda at the | |
| # MODULE LEVEL (parent process, where `import spaces` enables CUDA emulation so | |
| # `.to("cuda")` succeeds and the placement is inherited by every forked child). | |
| # Inside the decorated fn we then ONLY set num_flow_steps and call sample(). | |
| # num_flow_steps_sampling is a mutable instance attribute (verified line 69 of | |
| # wavelet_flow_matching.py, consumed at line 275), so the steps slider is a cheap | |
| # in-place overwrite -- no rebuild, ever. | |
| # | |
| # Resident weights are tiny relative to the 48 GB "large" GPU: Base ~3 GB + FinePatch | |
| # ~8.4 GB peak fit comfortably; do NOT request xlarge. | |
| # --------------------------------------------------------------------------- # | |
| _MODEL_REGISTRY: dict[str, torch.nn.Module] = {} | |
| _CKPT_META: dict[str, dict] = {} | |
| def _checkpoint_path(model_label: str) -> str: | |
| filename = CHECKPOINTS[model_label] | |
| # Local-dev escape hatch: WAVEDIT_LOCAL_CKPT_DIR lets you run the Space against | |
| # on-disk checkpoints (no Hub round-trip). Unset on the deployed Space -> uses the Hub. | |
| local_dir = os.environ.get("WAVEDIT_LOCAL_CKPT_DIR") | |
| if local_dir: | |
| local_path = Path(local_dir) / filename | |
| if local_path.exists(): | |
| return str(local_path) | |
| print(f"[startup] WAVEDIT_LOCAL_CKPT_DIR set but {local_path} missing; falling back to the Hub.") | |
| return hf_hub_download(HF_REPO, filename, revision=HF_REVISION) | |
| def _load_checkpoint_dict(model_label: str) -> dict: | |
| path = _checkpoint_path(model_label) | |
| return torch.load(path, map_location="cpu", weights_only=True) | |
| def _build_and_register(model_label: str) -> torch.nn.Module: | |
| """Download + build + load weights + move to DEVICE; register model and metadata. | |
| Called at MODULE SCOPE (startup), so the cuda placement is inherited by the | |
| forked @spaces.GPU subprocess and is never redone per-request. | |
| """ | |
| ck = _load_checkpoint_dict(model_label) | |
| cfg = Config.from_dict(ck["config"]) | |
| model = build_model( | |
| cfg, | |
| ck["condition_config"], | |
| ck["condition_ranges"], | |
| ck["categorical_maps"], | |
| ck["null_conditions"], | |
| num_flow_steps=STEPS_DEFAULT, | |
| ) | |
| load_model_weights(model, ck) | |
| model.to(DEVICE).eval() | |
| # Read the actual output grid from the checkpoint so the center-crop is size-agnostic. | |
| try: | |
| full_size = tuple(int(s) for s in cfg.data.image_size) | |
| except Exception: | |
| full_size = DEFAULT_FULL_SIZE | |
| _MODEL_REGISTRY[model_label] = model | |
| _CKPT_META[model_label] = { | |
| "condition_config": ck["condition_config"], | |
| "condition_ranges": ck["condition_ranges"], | |
| "condition_ranges_age": ck.get("condition_ranges", {}).get("age", {}), | |
| "full_size": full_size, | |
| } | |
| return model | |
| def get_model(model_label: str, num_flow_steps: int) -> torch.nn.Module: | |
| """Return the preloaded (cuda-resident) model for ``model_label``; set steps in place. | |
| Falls back to building on demand only if startup preload was skipped/failed (e.g. | |
| a fully off-platform CPU dev run that lazy-loads to keep cold start cheap). | |
| """ | |
| num_flow_steps = int(max(STEPS_MIN, min(STEPS_MAX, num_flow_steps))) | |
| model = _MODEL_REGISTRY.get(model_label) | |
| if model is None: # pragma: no cover - only when preload was skipped | |
| model = _build_and_register(model_label) | |
| model.num_flow_steps_sampling = num_flow_steps | |
| return model | |
| def _full_size_for(model_label: str) -> tuple[int, int, int]: | |
| meta = _CKPT_META.get(model_label) | |
| if meta and "full_size" in meta: | |
| return meta["full_size"] | |
| return DEFAULT_FULL_SIZE | |
| def _age_range_from_meta() -> tuple[int, int]: | |
| """Derive the age slider bounds from the already-loaded Base checkpoint metadata. | |
| Reuses the startup download (no second hf_hub_download); falls back to 6..95. | |
| """ | |
| try: | |
| rng = _CKPT_META.get(DEFAULT_MODEL, {}).get("condition_ranges_age", {}) | |
| lo = int(round(float(rng.get("min", AGE_MIN_FALLBACK)))) | |
| hi = int(round(float(rng.get("max", AGE_MAX_FALLBACK)))) | |
| if hi <= lo: | |
| return AGE_MIN_FALLBACK, AGE_MAX_FALLBACK | |
| return lo, hi | |
| except Exception as exc: # noqa: BLE001 | |
| print(f"[startup] Could not read age bounds ({exc}); using defaults.") | |
| return AGE_MIN_FALLBACK, AGE_MAX_FALLBACK | |
| def _preload_all_models() -> None: | |
| """Eagerly build BOTH checkpoints onto DEVICE at import time (ZeroGPU contract). | |
| Non-fatal: if a checkpoint cannot be fetched at startup (e.g. transient hub | |
| failure), get_model() will lazy-build it on first use so the UI still renders. | |
| """ | |
| for label in CHECKPOINTS: | |
| try: | |
| _build_and_register(label) | |
| print(f"[startup] Preloaded {label} onto {DEVICE}.") | |
| except Exception as exc: # noqa: BLE001 | |
| print(f"[startup] Could not preload {label} ({exc}); will lazy-load on demand.") | |
| _preload_all_models() | |
| AGE_MIN, AGE_MAX = _age_range_from_meta() | |
| # --------------------------------------------------------------------------- # | |
| # Post-processing helpers (CPU-side) | |
| # --------------------------------------------------------------------------- # | |
| def _postprocess(vol: torch.Tensor, model_label: str) -> np.ndarray: | |
| """(1,1,D,H,W) in [-1,1] -> (182,218,182) float32 in [0,1] (CPU numpy).""" | |
| vol = torch.clamp((vol.float() + 1.0) / 2.0, 0.0, 1.0) | |
| (d0, d1), (h0, h1), (w0, w1) = center_crop_bounds(_full_size_for(model_label), CROP_SIZE) | |
| vol = vol[:, :, d0:d1, h0:h1, w0:w1] | |
| return vol[0, 0].cpu().numpy().astype(np.float32) | |
| def _nii_gz_bytes(arr_float01: np.ndarray, as_uint8: bool) -> bytes: | |
| """Serialize a (182,218,182) [0,1] array to gzipped NIfTI bytes. | |
| as_uint8=True -> small payload for the in-browser viewer. | |
| as_uint8=False -> float32 for the scientific download. | |
| """ | |
| if as_uint8: | |
| data = np.clip(arr_float01 * 255.0, 0, 255).round().astype(np.uint8) | |
| else: | |
| data = arr_float01.astype(np.float32) | |
| img = nib.Nifti1Image(data, np.eye(4)) | |
| raw = img.to_bytes() # uncompressed .nii bytes | |
| return gzip.compress(raw, compresslevel=6) | |
| def _data_url_from_array(arr_float01: np.ndarray) -> str: | |
| gz = _nii_gz_bytes(arr_float01, as_uint8=True) | |
| b64 = base64.b64encode(gz).decode("ascii") | |
| return "data:application/gzip;base64," + b64 | |
| def _write_download(arr_float01: np.ndarray, fname: str) -> str: | |
| gz = _nii_gz_bytes(arr_float01, as_uint8=False) # full-precision for science | |
| out = DOWNLOAD_DIR / fname | |
| out.write_bytes(gz) | |
| return str(out) | |
| def _coerce_seed(seed) -> int: | |
| try: | |
| return abs(int(seed)) % (SEED_MAX + 1) | |
| except (TypeError, ValueError): | |
| return SEED_DEFAULT | |
| def _mid_axial_thumb(arr_float01: np.ndarray) -> np.ndarray: | |
| """Mid-axial slice as an HxW uint8 array for the gallery (display only).""" | |
| sl = arr_float01[arr_float01.shape[0] // 2, :, :] | |
| sl = np.rot90(sl) # radiological-ish orientation for the thumbnail | |
| return (np.clip(sl, 0, 1) * 255.0).round().astype(np.uint8) | |
| # --------------------------------------------------------------------------- # | |
| # Core sampling (GPU) | |
| # --------------------------------------------------------------------------- # | |
| def _sample_to_array( | |
| model_label: str, | |
| age: float, | |
| seed: int, | |
| steps: int, | |
| cfg_scale: float, | |
| sampler_label: str, | |
| morpheus: float, | |
| ) -> tuple[np.ndarray, float, float]: | |
| """Run one model.sample() and return (array[0,1], wall_seconds, peak_vram_gb).""" | |
| steps = int(max(STEPS_MIN, min(STEPS_MAX, steps))) | |
| sampler = SAMPLER_MAP.get(sampler_label, "heun") | |
| morpheus_scale = None if abs(float(morpheus) - 1.0) < 1e-6 else float(morpheus) | |
| # Model is already cuda-resident from the module-scope preload (ZeroGPU contract); | |
| # do NOT move it here -- per the docs, CUDA transfers belong at startup, not in | |
| # the decorated fn. get_model() only sets the step count in place. | |
| model = get_model(model_label, steps) | |
| if DEVICE.type == "cuda": | |
| torch.cuda.reset_peak_memory_stats() | |
| torch.cuda.synchronize() | |
| seed_i = _coerce_seed(seed) | |
| torch.manual_seed(seed_i) | |
| if DEVICE.type == "cuda": | |
| torch.cuda.manual_seed_all(seed_i) | |
| t0 = time.perf_counter() | |
| with torch.no_grad(): | |
| vol = model.sample( | |
| num_samples=1, | |
| raw_conditions={"age": float(age)}, | |
| cfg_scale=float(cfg_scale), | |
| sampler=sampler, | |
| morpheus_scale=morpheus_scale, | |
| cfg_rescale=CFG_RESCALE, | |
| autocast_dtype=torch.bfloat16, | |
| ) | |
| if DEVICE.type == "cuda": | |
| torch.cuda.synchronize() | |
| wall = time.perf_counter() - t0 | |
| peak_gb = 0.0 | |
| if DEVICE.type == "cuda": | |
| peak_gb = torch.cuda.max_memory_allocated() / (1024 ** 3) | |
| arr = _postprocess(vol, model_label) | |
| return arr, wall, peak_gb | |
| # --------------------------------------------------------------------------- # | |
| # Duration estimators for @spaces.GPU | |
| # --------------------------------------------------------------------------- # | |
| # Per-NFE wall-time budget (seconds) by model. Wide (506M params, width 2048) is much | |
| # heavier per step than Base/FinePatch. Measured on the pure-PyTorch NA fallback (no | |
| # NATTEN, as on Spaces): Wide ~0.30 s/NFE at 224^3, so a 200-step Heun run is ~136 s. | |
| # 0.35 leaves a cross-hardware margin and the 240 s cap keeps the worst case declared: | |
| # OVER-declaring only lowers queue priority, while UNDER-declaring risks ZeroGPU killing | |
| # the call mid-generation. | |
| _PER_STEP_S = {"Wide": 0.35, "FinePatch": 0.11, "Base": 0.05} | |
| _DURATION_CAP_S = {"Wide": 240} # default 180 for the others | |
| def _per_step_s(model_label) -> float: | |
| for prefix, value in _PER_STEP_S.items(): | |
| if str(model_label).startswith(prefix): | |
| return value | |
| return 0.15 | |
| def _duration_cap_s(model_label) -> int: | |
| for prefix, value in _DURATION_CAP_S.items(): | |
| if str(model_label).startswith(prefix): | |
| return value | |
| return 180 | |
| def _gen_duration(model_label, age, seed, steps, cfg_scale, sampler_label, morpheus, *args): | |
| nfe = 2 if SAMPLER_MAP.get(sampler_label, "heun") == "heun" else 1 | |
| # Single generate: weights are preloaded (no build/transfer here), so this is a | |
| # pure sampling estimate plus a small fixed overhead. | |
| return int(min(_duration_cap_s(model_label), | |
| 15 + int(steps) * nfe * _per_step_s(model_label))) | |
| def _sweep_frame_duration(model_label, age, frame_seed, steps, | |
| cfg_scale, sampler_label, morpheus, *args): | |
| """Per-FRAME duration. The sweep runs each frame as its OWN @spaces.GPU call, so | |
| the budget only has to cover ONE frame (steps already clamped to SWEEP_STEPS_MAX). | |
| """ | |
| nfe = 2 if SAMPLER_MAP.get(sampler_label, "heun") == "heun" else 1 | |
| steps = min(int(steps), SWEEP_STEPS_MAX) | |
| return int(min(150, 15 + steps * nfe * _per_step_s(model_label))) | |
| # --------------------------------------------------------------------------- # | |
| # Badges / captions | |
| # --------------------------------------------------------------------------- # | |
| def _badge(model_label, steps, sampler_label, morpheus, wall, peak_gb): | |
| sampler = SAMPLER_MAP.get(sampler_label, "heun") | |
| short = "Base" if model_label.startswith("Base") else "FinePatch" | |
| abl = " - ABLATION (Morpheus off)" if abs(float(morpheus)) < 1e-6 else "" | |
| hw = "RTX Pro 6000 (ZeroGPU large)" if DEVICE.type == "cuda" else "CPU" | |
| vram = f"{peak_gb:.1f} GB" if peak_gb > 0 else "n/a (CPU)" | |
| return ( | |
| f"{short} - {int(steps)} steps - {sampler} - 224 cubed - bf16{abl}\n" | |
| f"{wall:.1f} s - peak VRAM {vram} - {hw}" | |
| ) | |
| def _caption(model_label, age, seed, steps, sampler_label, cfg_scale, morpheus): | |
| sampler = SAMPLER_MAP.get(sampler_label, "heun") | |
| short = "Base" if model_label.startswith("Base") else "FinePatch" | |
| abl = " - Morpheus off" if abs(float(morpheus)) < 1e-6 else "" | |
| return ( | |
| f"Synthetic - age {int(age)} - seed {_coerce_seed(seed)} - {short} " | |
| f"- {int(steps)} steps - {sampler} - CFG {float(cfg_scale):.1f}{abl}" | |
| ) | |
| def _reproduce_snippet(model_label, age, seed, steps, cfg_scale, sampler_label, morpheus): | |
| sampler = SAMPLER_MAP.get(sampler_label, "heun") | |
| filename = CHECKPOINTS[model_label] | |
| morpheus_arg = "None" if abs(float(morpheus) - 1.0) < 1e-6 else f"{float(morpheus):.2f}" | |
| return f'''import os | |
| os.environ["WAVEDIT_NA_BACKEND"] = "torch" # set before importing wavedit | |
| import torch | |
| from huggingface_hub import hf_hub_download | |
| from wavedit import Config | |
| from wavedit.models import build_model | |
| from wavedit.training.checkpoint import load_model_weights | |
| from wavedit.generation.generator import center_crop_bounds | |
| path = hf_hub_download("{HF_REPO}", "{filename}", revision="{HF_REVISION}") | |
| ck = torch.load(path, map_location="cpu", weights_only=True) | |
| cfg = Config.from_dict(ck["config"]) | |
| model = build_model(cfg, ck["condition_config"], ck["condition_ranges"], | |
| ck["categorical_maps"], ck["null_conditions"], num_flow_steps={int(steps)}) | |
| load_model_weights(model, ck) | |
| model.to("cuda").eval() | |
| torch.manual_seed({_coerce_seed(seed)}) | |
| vol = model.sample(num_samples=1, raw_conditions={{"age": {float(age):.1f}}}, | |
| cfg_scale={float(cfg_scale):.1f}, sampler="{sampler}", | |
| morpheus_scale={morpheus_arg}, cfg_rescale={CFG_RESCALE}, | |
| autocast_dtype=torch.bfloat16) | |
| vol = torch.clamp((vol.float() + 1.0) / 2.0, 0.0, 1.0) | |
| (d0, d1), (h0, h1), (w0, w1) = center_crop_bounds((224, 224, 224), (182, 218, 182)) | |
| arr = vol[:, :, d0:d1, h0:h1, w0:w1][0, 0].cpu().numpy() # (182, 218, 182) in [0, 1]''' | |
| def _filename(model_label, age, seed, steps): | |
| short = "base" if model_label.startswith("Base") else "finepatch" | |
| return f"wavedit_{short}_age{int(age)}_seed{_coerce_seed(seed)}_steps{int(steps)}.nii.gz" | |
| # --------------------------------------------------------------------------- # | |
| # Living microcopy | |
| # --------------------------------------------------------------------------- # | |
| def age_microcopy(age) -> str: | |
| a = int(age) | |
| if a <= 12: | |
| body = "developing brain - ventricles typically tight, cortex still maturing." | |
| elif a <= 25: | |
| body = "young adult - typically near peak cortical volume." | |
| elif a <= 59: | |
| body = "adult brain - typically stable morphology." | |
| elif a <= 79: | |
| body = "older adult - ventricles typically begin to widen." | |
| else: | |
| body = "elderly - typically more pronounced atrophy and wider ventricles." | |
| return f"**Age {a}:** {body}" | |
| def cfg_microcopy(cfg_scale) -> str: | |
| if abs(float(cfg_scale) - 1.0) < 1e-6: | |
| return "Guidance off (CFG = 1.0). Std-rescaling activates only above 1.0." | |
| return "Classifier-free guidance on; std-rescale (cfg_rescale = 0.7) tames magnitude growth." | |
| def steps_microcopy(steps, model_label=DEFAULT_MODEL) -> str: | |
| s = int(steps) | |
| if s > 50 and not str(model_label).startswith("Base"): | |
| return "High step count - a FinePatch run can take up to ~90 s." | |
| if s > 100: | |
| return "Very high step count - this may take a while." | |
| return "More steps = smoother integration. Default 10 is fast and cheap." | |
| # --------------------------------------------------------------------------- # | |
| # Gradio event handlers | |
| # --------------------------------------------------------------------------- # | |
| def gpu_generate(model_label, age, seed, steps, cfg_scale, sampler_label, morpheus): | |
| """GPU work only: returns (array, wall, peak_gb). CPU-side packaging happens outside.""" | |
| return _sample_to_array(model_label, age, seed, steps, cfg_scale, sampler_label, morpheus) | |
| def generate(model_label, age, seed, steps, cfg_scale, sampler_label, morpheus, colormap): | |
| """Top-level generate handler: runs the GPU fn, packages on CPU, builds the viewer.""" | |
| try: | |
| arr, wall, peak_gb = gpu_generate( | |
| model_label, age, seed, steps, cfg_scale, sampler_label, morpheus | |
| ) | |
| except Exception as exc: # OOM / quota / hub / anything: stay non-fatal. | |
| # NOTE: do NOT call torch.cuda.empty_cache() here -- this runs in the MAIN | |
| # process where no real GPU is attached (the @spaces.GPU subprocess has | |
| # already torn down), so it frees nothing and risks a blocked cuda lazy-init. | |
| msg = str(exc) | |
| low = msg.lower() | |
| friendly = "Generation failed. " | |
| if "out of memory" in low or "oom" in low: | |
| friendly += "Out of GPU memory - try fewer steps or the Base model." | |
| elif "quota" in low or "gpu task" in low: | |
| friendly += "ZeroGPU quota reached - wait a moment and try again." | |
| else: | |
| friendly += "Please try again." | |
| traceback.print_exc() | |
| # Keep the previous viewer (gr.update no-op) and surface a friendly status. | |
| return ( | |
| gr.update(), # viewer unchanged | |
| gr.update(value=f"WARNING: {friendly}"), # status | |
| gr.update(), # badge | |
| gr.update(), # caption | |
| gr.update(), # download | |
| ) | |
| seed_i = _coerce_seed(seed) | |
| data_url = _data_url_from_array(arr) | |
| iframe = _render_iframe(data_url, colormap) | |
| badge = _badge(model_label, steps, sampler_label, morpheus, wall, peak_gb) | |
| caption = _caption(model_label, age, seed_i, steps, sampler_label, cfg_scale, morpheus) | |
| dl_path = _write_download(arr, _filename(model_label, age, seed_i, steps)) | |
| status = f"Done in {wall:.1f} s. Drag the 3D pane to cut into the brain." | |
| return ( | |
| iframe, | |
| status, | |
| badge, | |
| caption, | |
| gr.update(value=dl_path, visible=True), | |
| ) | |
| def gpu_sweep_frame(model_label, age, frame_seed, steps, | |
| cfg_scale, sampler_label, morpheus): | |
| """ONE aging-sweep frame on the GPU. Each frame is its own short @spaces.GPU call. | |
| Running per-frame (rather than one long call over all frames) keeps every GPU | |
| window comfortably inside its declared duration -- a 12x200-step FinePatch sweep | |
| in a single call would exceed any honest budget and ZeroGPU would kill it midway. | |
| Returns just the CPU array; main-process run_sweep packages + drives gr.Progress. | |
| """ | |
| arr, _, _ = _sample_to_array( | |
| model_label, age, frame_seed, steps, cfg_scale, sampler_label, morpheus | |
| ) | |
| return arr | |
| def run_sweep(model_label, start_age, end_age, frames, fix_seed, seed, | |
| steps, cfg_scale, sampler_label, morpheus, colormap, | |
| progress=gr.Progress()): | |
| """Drive the aging time-lapse from the MAIN process. | |
| gr.Progress lives in the main Gradio process; calling it from inside an | |
| @spaces.GPU subprocess is not a documented-supported path (args/returns are | |
| pickled across the fork). So progress is driven HERE and each frame's GPU work | |
| is a separate gpu_sweep_frame() call. Per-frame steps are clamped to | |
| SWEEP_STEPS_MAX and frames to SWEEP_FRAMES_MAX so total session work stays bounded. | |
| """ | |
| frames = int(max(SWEEP_FRAMES_MIN, min(SWEEP_FRAMES_MAX, frames))) | |
| sweep_steps = int(min(int(steps), SWEEP_STEPS_MAX)) | |
| ages = np.linspace(float(start_age), float(end_age), frames) | |
| base_seed = _coerce_seed(seed) | |
| results = [] | |
| try: | |
| for i, a in enumerate(ages): | |
| progress(i / frames, desc=f"Aging {i + 1}/{frames} - age {int(round(a))}") | |
| frame_seed = base_seed if fix_seed else (base_seed + i) % (SEED_MAX + 1) | |
| arr = gpu_sweep_frame( | |
| model_label, float(a), frame_seed, sweep_steps, | |
| cfg_scale, sampler_label, morpheus, | |
| ) | |
| results.append((int(round(a)), arr)) | |
| progress(1.0, desc="Done") | |
| except Exception as exc: # noqa: BLE001 | |
| traceback.print_exc() | |
| if results: | |
| # Salvage whatever frames completed before the failure. | |
| pass | |
| else: | |
| return ( | |
| gr.update(), # viewer | |
| gr.update(), # gallery | |
| gr.update(value=f"WARNING: Time-lapse failed: {exc}"), # status | |
| gr.update(), # frame slider | |
| {}, # sweep state | |
| ) | |
| # Gallery of mid-axial slices labelled by age (instant figure material). | |
| gallery = [(_mid_axial_thumb(arr), f"age {a}") for a, arr in results] | |
| # Show the first frame in the main viewer; precompute data URLs for the scrubber. | |
| urls = [_data_url_from_array(arr) for _, arr in results] | |
| labels = [a for a, _ in results] | |
| iframe = _render_iframe(urls[0], colormap) | |
| note = ("Same random seed across ages - only the age condition changes." | |
| if fix_seed else "Seed varies per frame.") | |
| clamp_note = (f" (sweep uses {sweep_steps} steps/frame)" | |
| if sweep_steps < int(steps) else "") | |
| status = (f"Time-lapse ready: {len(results)} frames. {note}{clamp_note} " | |
| "Qualitative, not a validated atrophy measurement.") | |
| return ( | |
| iframe, | |
| gr.update(value=gallery, visible=True), | |
| gr.update(value=status), | |
| gr.update(visible=True, maximum=len(urls) - 1, value=0), | |
| {"urls": urls, "labels": labels, "colormap": colormap}, | |
| ) | |
| def show_sweep_frame(idx, sweep_state): | |
| if not sweep_state or "urls" not in sweep_state: | |
| return gr.update() | |
| urls = sweep_state["urls"] | |
| cmap = sweep_state.get("colormap", "gray") | |
| i = int(max(0, min(len(urls) - 1, idx))) | |
| return _render_iframe(urls[i], cmap) | |
| # Random buttons | |
| def random_age(): | |
| # 20% weighted to the dramatic extremes for more striking brains. | |
| if random.random() < 0.2: | |
| a = random.choice( | |
| list(range(AGE_MIN, min(AGE_MIN + 13, AGE_MAX))) | |
| + list(range(max(AGE_MIN, AGE_MAX - 15), AGE_MAX + 1)) | |
| ) | |
| else: | |
| a = random.randint(AGE_MIN, AGE_MAX) | |
| return a | |
| def random_seed(): | |
| return random.randint(0, SEED_MAX) | |
| # Presets: return updates for (model, age, seed, steps, cfg, sampler, morpheus, colormap) | |
| def preset_child(): | |
| return DEFAULT_MODEL, 8, SEED_DEFAULT, STEPS_DEFAULT, CFG_DEFAULT, "Heun", MORPHEUS_DEFAULT, "gray" | |
| def preset_prime(): | |
| return DEFAULT_MODEL, 35, SEED_DEFAULT, STEPS_DEFAULT, CFG_DEFAULT, "Heun", MORPHEUS_DEFAULT, "gray" | |
| def preset_elder(): | |
| return DEFAULT_MODEL, 82, SEED_DEFAULT, STEPS_DEFAULT, CFG_DEFAULT, "Heun", MORPHEUS_DEFAULT, "bone" | |
| def preset_showcase(): | |
| return "FinePatch (detailed)", 80, SEED_DEFAULT, 80, CFG_DEFAULT, "Heun", MORPHEUS_DEFAULT, "plasma" | |
| def preset_ablation(): | |
| return DEFAULT_MODEL, AGE_DEFAULT, SEED_DEFAULT, STEPS_DEFAULT, CFG_DEFAULT, "Heun", 0.0, "gray" | |
| def preset_surprise(): | |
| a = random_age() | |
| s = random_seed() | |
| cmap = random.choice(DELIGHT_COLORMAPS) | |
| model = random.choice(list(CHECKPOINTS.keys())) | |
| return model, a, s, STEPS_DEFAULT, CFG_DEFAULT, "Heun", MORPHEUS_DEFAULT, cmap | |
| # Initial viewer (hero brain if present; otherwise a friendly placeholder). | |
| def _initial_viewer_html() -> str: | |
| if HERO_NII_PATH.exists(): | |
| try: | |
| gz = HERO_NII_PATH.read_bytes() | |
| b64 = base64.b64encode(gz).decode("ascii") | |
| data_url = "data:application/gzip;base64," + b64 | |
| return _render_iframe(data_url, "gray") | |
| except Exception: | |
| pass | |
| return _placeholder_iframe( | |
| "Pick an age and press Generate brain to synthesize a 3D MRI you can rotate and slice." | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # Theme + CSS | |
| # --------------------------------------------------------------------------- # | |
| THEME = gr.themes.Soft( | |
| primary_hue=gr.themes.colors.indigo, | |
| secondary_hue=gr.themes.colors.purple, | |
| neutral_hue=gr.themes.colors.slate, | |
| font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"], | |
| font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"], | |
| radius_size=gr.themes.sizes.radius_sm, | |
| ).set( | |
| # Base values = LIGHT theme; the *_dark variants keep the original dark palette. | |
| # Gradio follows the user's system/HF theme automatically (no theme is forced). | |
| body_background_fill="linear-gradient(180deg,#f6f8fc 0%,#eef1f8 100%)", | |
| body_background_fill_dark="linear-gradient(180deg,#0d1117 0%,#10131c 100%)", | |
| block_background_fill="#ffffff", | |
| block_background_fill_dark="#161b26", | |
| block_radius="3px", | |
| button_primary_background_fill="linear-gradient(90deg,#6366f1,#a855f7)", | |
| button_primary_text_color="#ffffff", | |
| ) | |
| CSS = """ | |
| #hdr h1 { margin: 0; font-size: 1.6rem; } | |
| .pill { display:inline-block; padding:3px 10px; border-radius:4px; font-size:0.75rem; | |
| font-weight:600; margin-right:6px; } | |
| /* Pills, badge and links carry their own colors, so they need explicit | |
| light + dark variants (Gradio toggles dark mode via a `.dark` root class). */ | |
| .pill-amber { background:rgba(245,158,11,0.15); color:#b45309; border:1px solid rgba(245,158,11,0.4); } | |
| .pill-warn { background:rgba(239,68,68,0.12); color:#b91c1c; border:1px solid rgba(239,68,68,0.35); } | |
| .dark .pill-amber { color:#fbbf24; } | |
| .dark .pill-warn { color:#fca5a5; } | |
| #badge textarea { font-family: var(--font-mono) !important; font-size:12px !important; | |
| background:#eef2ff !important; color:#1e3a8a !important; } | |
| .dark #badge textarea { background:#0d1117 !important; color:#9fe0ff !important; } | |
| .prov textarea { font-family: var(--font-mono) !important; font-size:12px !important; } | |
| .links a { margin-right:14px; text-decoration:none; font-weight:600; color:#4f46e5; } | |
| .links img { display:inline-block !important; vertical-align:middle; margin:0; } | |
| .links a:hover { color:#7c3aed; } | |
| .dark .links a { color:#a5b4fc; } | |
| .dark .links a:hover { color:#c4b5fd; } | |
| footer { display:none !important; } | |
| @media (max-width: 860px) { | |
| #workbench { flex-direction: column-reverse !important; } | |
| } | |
| """ | |
| # --------------------------------------------------------------------------- # | |
| # Build UI | |
| # --------------------------------------------------------------------------- # | |
| def build_demo() -> gr.Blocks: | |
| with gr.Blocks(title="WaveDiT - 3D Brain MRI Generator") as demo: | |
| # ---- Header ---- | |
| with gr.Row(elem_id="hdr"): | |
| gr.Markdown( | |
| "# \U0001f9e0 WaveDiT\n" | |
| "Generate and explore synthetic 3D brain MRI, conditioned on age, in your browser.\n\n" | |
| "**Danilo Danese**, Angela Lombardi, Giuseppe Fasano, Matteo Attimonelli, " | |
| "Tommaso Di Noia at SisInfLab, Politecnico di Bari." | |
| ) | |
| gr.HTML( | |
| '<div>' | |
| '<span class="pill pill-amber">Pre-release checkpoints</span>' | |
| '<span class="pill pill-warn">⚠️ Research demo, synthetic, not for clinical use</span>' | |
| '</div>' | |
| f'<div class="links" style="margin-top:8px;white-space:nowrap">' | |
| f'<a href="{LINK_PAPER}" target="_blank">\U0001f4c4 Paper</a>' | |
| f'<a href="{LINK_CODE}" target="_blank">\U0001f4bb Code</a>' | |
| f'<a href="{LINK_PROJECT}" target="_blank">\U0001f310 Project page</a>' | |
| f'<a href="{LINK_MODEL}" target="_blank">\U0001f917 Model</a>' | |
| f'<a href="{LINK_CODE}" target="_blank" title="Star WaveDiT on GitHub" ' | |
| f'style="vertical-align:middle">' | |
| f'<img src="https://img.shields.io/github/stars/sisinflab/WaveDiT?style=social" ' | |
| f'alt="Star WaveDiT on GitHub" ' | |
| f'style="display:inline-block;vertical-align:middle;margin:0"></a>' | |
| '</div>' | |
| ) | |
| sweep_state = gr.State({}) | |
| # ---- Workbench ---- | |
| with gr.Row(elem_id="workbench"): | |
| # LEFT: controls | |
| with gr.Column(scale=38): | |
| with gr.Group(): | |
| gr.Markdown("### Essentials") | |
| with gr.Row(): | |
| age = gr.Slider(AGE_MIN, AGE_MAX, value=AGE_DEFAULT, step=1, | |
| label="Age (years)", scale=8) | |
| age_rand = gr.Button("\U0001f3b2", scale=1, min_width=44, | |
| elem_id="age-rand") | |
| age_help = gr.Markdown(age_microcopy(AGE_DEFAULT)) | |
| model = gr.Radio( | |
| list(CHECKPOINTS.keys()), value=DEFAULT_MODEL, label="Model", | |
| info="Base = faster, ~3 GB VRAM. FinePatch = sharper, slower.", | |
| ) | |
| generate_btn = gr.Button("✦ Generate brain", variant="primary", size="lg") | |
| status = gr.Markdown("Ready. Pick an age and press Generate.") | |
| with gr.Accordion("Advanced settings", open=False): | |
| with gr.Row(): | |
| seed = gr.Number(value=SEED_DEFAULT, precision=0, label="Seed", scale=8) | |
| seed_rand = gr.Button("\U0001f3b2", scale=1, min_width=44, | |
| elem_id="seed-rand") | |
| steps = gr.Slider(STEPS_MIN, STEPS_MAX, value=STEPS_DEFAULT, step=1, | |
| label="ODE steps") | |
| steps_help = gr.Markdown(steps_microcopy(STEPS_DEFAULT)) | |
| cfg_scale = gr.Slider(CFG_MIN, CFG_MAX, value=CFG_DEFAULT, step=0.1, | |
| label="CFG scale") | |
| cfg_help = gr.Markdown(cfg_microcopy(CFG_DEFAULT)) | |
| sampler = gr.Radio(SAMPLERS, value="Heun", label="Sampler", | |
| info="Heun = 2nd-order (2 evals/step); Euler = 1st-order, faster.") | |
| morpheus = gr.Slider(0.0, 2.0, value=MORPHEUS_DEFAULT, step=0.05, | |
| label="Morpheus uncertainty guidance", | |
| info="Descends predicted per-band uncertainty, peaks at t=0.5 " | |
| "(prop. sin(pi*t)). 0 = ablate; 1.0 = trained default.") | |
| with gr.Accordion("Presets", open=False): | |
| with gr.Row(): | |
| p_child = gr.Button("\U0001f331 Child (8)", size="sm") | |
| p_prime = gr.Button("\U0001f9d1 Prime years (35)", size="sm") | |
| p_elder = gr.Button("\U0001f333 Wise elder (82)", size="sm") | |
| with gr.Row(): | |
| p_show = gr.Button("\U0001f48e Showcase (FinePatch, 80 steps)", size="sm") | |
| p_abl = gr.Button("\U0001f52c Ablation: Morpheus off", size="sm") | |
| p_surprise = gr.Button("\U0001f3b2 Surprise me", size="sm") | |
| with gr.Accordion("Aging time-lapse", open=False): | |
| gr.Markdown( | |
| "Sweep age across frames and scrub the result in the same viewer. " | |
| f"To stay within the GPU budget, each frame uses at most " | |
| f"{SWEEP_STEPS_MAX} ODE steps." | |
| ) | |
| with gr.Row(): | |
| start_age = gr.Slider(AGE_MIN, AGE_MAX, value=max(AGE_MIN, 20), step=1, | |
| label="Start age") | |
| end_age = gr.Slider(AGE_MIN, AGE_MAX, value=min(AGE_MAX, 80), step=1, | |
| label="End age") | |
| with gr.Row(): | |
| frames = gr.Slider(SWEEP_FRAMES_MIN, SWEEP_FRAMES_MAX, value=7, step=1, | |
| label="Frames") | |
| fix_seed = gr.Checkbox(value=True, label="Fix seed across ages") | |
| sweep_btn = gr.Button("▶ Run aging sweep", variant="secondary") | |
| frame_slider = gr.Slider(0, SWEEP_FRAMES_MAX - 1, value=0, step=1, | |
| visible=False, label="Time-lapse frame") | |
| # RIGHT: viewer | |
| with gr.Column(scale=62): | |
| viewer = gr.HTML(_initial_viewer_html()) | |
| with gr.Row(): | |
| colormap = gr.Dropdown(COLORMAPS, value="gray", label="Colormap", scale=3) | |
| gamma = gr.Slider(0.4, 2.0, value=1.0, step=0.05, label="Brightness (gamma)", scale=3) | |
| reset_view = gr.Button("Reset view", scale=1) | |
| badge = gr.Textbox( | |
| value="(generate a brain to see timing + VRAM)", | |
| label="Generation report", interactive=False, lines=2, elem_id="badge", | |
| ) | |
| caption = gr.Textbox( | |
| value="Provenance will appear here after you generate.", | |
| label="Provenance (copy for your records)", interactive=False, lines=1, | |
| elem_classes=["prov"], | |
| ) | |
| download = gr.DownloadButton("⬇ Download .nii.gz (float32)", visible=False) | |
| gr.Markdown( | |
| "Left-drag the 3D pane to rotate and cut into the brain · scroll to zoom " | |
| "· right-drag for contrast. The three flat panels are axial, coronal, sagittal." | |
| ) | |
| gallery = gr.Gallery(label="Aging time-lapse - mid-axial slices", | |
| visible=False, columns=8, height=160, object_fit="contain") | |
| # ---- Accordions ---- | |
| with gr.Accordion("How it works", open=False): | |
| gr.Markdown( | |
| "WaveDiT learns a velocity field in the 3D Haar-wavelet domain: a forward DWT splits " | |
| "each 224 cubed volume into 8 frequency bands, a flow-matching ODE is integrated in that " | |
| "compact space, and an inverse DWT reconstructs the volume - making full-resolution " | |
| "3D generation tractable on one GPU. You steer it with one condition: **age**. CFG uses " | |
| "std-rescaling (`cfg_rescale=0.7`), inactive at CFG=1. Neighborhood attention runs in a " | |
| "pure-PyTorch fallback (`WAVEDIT_NA_BACKEND=torch`), numerically equivalent to the NATTEN " | |
| "kernels used for training." | |
| ) | |
| with gr.Accordion("Cite this work", open=False): | |
| gr.Code(value=CITATION, language=None, label="BibTeX") | |
| with gr.Accordion("FAQ", open=False): | |
| gr.Markdown( | |
| "**Why does the very first request after a cold start take longer?** ZeroGPU spins up " | |
| "the GPU and attaches it on demand; both checkpoints are loaded once at startup and stay " | |
| "resident, so subsequent runs only pay for sampling.\n\n" | |
| "**Why are some runs slower?** FinePatch and high step counts do more work; the Heun " | |
| "sampler runs two network evaluations per step.\n\n" | |
| "**The 3D viewer is blank.** It needs WebGL2. Use a recent Chrome/Firefox/Safari, or " | |
| "download the `.nii.gz` and open it in your own viewer." | |
| ) | |
| # ---- Footer ---- | |
| gr.HTML( | |
| '<div style="margin-top:18px;padding-top:12px;border-top:1px solid #232a39;' | |
| 'color:#7c8aa0;font-size:0.8rem;line-height:1.6">' | |
| 'WaveDiT is a research artifact (MICCAI 2026). Synthetic data only - not a medical device, ' | |
| 'not for clinical use. © The authors · CC-BY-NC-4.0.<br>' | |
| f'MICCAI 2026 · arXiv:2606.08670 · space v{SPACE_VERSION}' | |
| '</div>' | |
| ) | |
| # ----------------------------------------------------------------- # | |
| # Wiring | |
| # ----------------------------------------------------------------- # | |
| gen_inputs = [model, age, seed, steps, cfg_scale, sampler, morpheus, colormap] | |
| gen_outputs = [viewer, status, badge, caption, download] | |
| generate_btn.click(generate, inputs=gen_inputs, outputs=gen_outputs) | |
| # Living microcopy | |
| age.change(age_microcopy, inputs=age, outputs=age_help) | |
| cfg_scale.change(cfg_microcopy, inputs=cfg_scale, outputs=cfg_help) | |
| steps.change(steps_microcopy, inputs=[steps, model], outputs=steps_help) | |
| model.change(steps_microcopy, inputs=[steps, model], outputs=steps_help) | |
| # Random buttons (no auto-generate) | |
| age_rand.click(random_age, outputs=age) | |
| seed_rand.click(random_seed, outputs=seed) | |
| # Live viewer updates via postMessage (no regeneration) to the existing iframe. | |
| colormap.change( | |
| None, inputs=colormap, outputs=None, | |
| js="(c) => { const f=document.querySelector('#workbench iframe'); " | |
| "if (f && f.contentWindow) f.contentWindow.postMessage({type:'colormap',value:c},'*'); }", | |
| ) | |
| gamma.change( | |
| None, inputs=gamma, outputs=None, | |
| js="(g) => { const f=document.querySelector('#workbench iframe'); " | |
| "if (f && f.contentWindow) f.contentWindow.postMessage({type:'gamma',value:g},'*'); }", | |
| ) | |
| reset_view.click( | |
| None, inputs=None, outputs=None, | |
| js="() => { const f=document.querySelector('#workbench iframe'); " | |
| "if (f && f.contentWindow) f.contentWindow.postMessage({type:'reset'},'*'); }", | |
| ) | |
| # Presets (set controls, refresh age microcopy, then auto-generate) | |
| preset_targets = [model, age, seed, steps, cfg_scale, sampler, morpheus, colormap] | |
| for btn, fn in [ | |
| (p_child, preset_child), (p_prime, preset_prime), (p_elder, preset_elder), | |
| (p_show, preset_showcase), (p_abl, preset_ablation), (p_surprise, preset_surprise), | |
| ]: | |
| btn.click(fn, outputs=preset_targets).then( | |
| age_microcopy, inputs=age, outputs=age_help | |
| ).then(generate, inputs=gen_inputs, outputs=gen_outputs) | |
| # Aging time-lapse | |
| sweep_inputs = [model, start_age, end_age, frames, fix_seed, seed, | |
| steps, cfg_scale, sampler, morpheus, colormap] | |
| sweep_btn.click( | |
| run_sweep, inputs=sweep_inputs, | |
| outputs=[viewer, gallery, status, frame_slider, sweep_state], | |
| ) | |
| frame_slider.change(show_sweep_frame, inputs=[frame_slider, sweep_state], outputs=viewer) | |
| return demo | |
| demo = build_demo() | |
| if __name__ == "__main__": | |
| # allowed_paths lets Gradio serve the float32 .nii.gz download from /tmp. | |
| # WAVEDIT_SHARE=1 opens a public *.gradio.live tunnel (handy when the server is | |
| # remote and localhost isn't reachable, e.g. VS Code over VPN). Off by default. | |
| demo.queue(max_size=24).launch( | |
| theme=THEME, | |
| css=CSS, | |
| allowed_paths=[str(DOWNLOAD_DIR)], | |
| share=os.environ.get("WAVEDIT_SHARE") == "1", | |
| ) | |