zephyrie commited on
Commit
ab1db83
·
0 Parent(s):

Initial commit: NV-Generate Gradio showcase

Browse files

A Hugging Face Spaces app that surfaces NVIDIA Medtech's three open-weight
3D medical image generators (CT, MR, MR Brain) under one console-style UI,
all running the latest MAISI-v2 rectified-flow variants.

Architecture
============

- app.py — Gradio Blocks entrypoint. Hero card landing → per-model workspace
switched via gr.Group visibility. Auto-clones the NV-Generate-CTMR upstream
repo on first run so the same flow works locally and on HF Spaces.

- pipelines/
base.py — GenerationRequest / GenerationResult dataclasses.
_runner.py — Shared driver: chdir into upstream, write modified configs
into configs/_temp/, invoke scripts.diff_model_infer or
scripts.inference, harvest the resulting NIfTI(s).
ct.py — Paired image + 132-class mask (rflow-ct).
mr.py — Multi-contrast MR image-only (rflow-mr).
mr_brain.py — Brain MR multi-sequence (rflow-mr-brain).

- viewer/
niivue_embed.py — Renders a self-contained niivue iframe with multiplanar
grid layout, cyan crosshair, corner pane labels
(Coronal / Sagittal / Axial / 3D), mask overlay with
per-label LUT, base64 NIfTI data URL loading.
colormaps.py — Deterministic anatomy color table + named legend HTML
generated from the upstream label_dict.json (~125
named CT classes).

- ui/
hero.py — Three datasheet cards with anatomical SVG glyphs,
use-case chips, license chips, decoupled
availability/license status, per-card accent color.
workspace_ct.py — CT controls (body region, anatomy multiselect,
X/Y/Z radios, spacing sliders) + niivue viewer +
W/L preset row + anatomy mask legend + downloads.
workspace_mr.py — MR variant with contrast dropdown + license banner.
workspace_mr_brain.py — Brain MR with sequence radio.
presets.py — XY/Z choices, sample preset chips, anatomy lists.

- utils/ nifti_io, windowing (CT W/L presets), weights (HF Hub lazy loader).

Design
======

"MAISI Console" aesthetic: navy-on-near-black palette with surgical NVIDIA
green accents, Geist Sans + Geist Mono, hairline borders, blueprint-grid
background overlay. Status chips for runtime/seed/steps after generation;
Generate button has disabled+spinner state during inference.

Pill-toggle pattern (filled green when active, outline on hover) is
consistent across body region, X/Y/Z voxel selectors, and the W/L preset row.

Deployment
==========

requirements.txt covers both the Gradio app deps and the upstream's
PyTorch/MONAI stack. README.md frontmatter is HF-Spaces ready: sdk: gradio,
sdk_version pinned, ZeroGPU @spaces.GPU decorator with 300s budget for CT
(longest inference) and 180s for MR/MR-Brain.

NV-Generate-CT and NV-Generate-MR-Brain weights are under the NVIDIA Open
Model License; NV-Generate-MR weights are non-commercial — the MR workspace
shows a visible amber banner reminding users of that restriction.

.gitignore ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Upstream repo and downloaded weights
2
+ repos/
3
+ models/
4
+ weights/
5
+ output/
6
+ embeddings/
7
+ datasets/
8
+
9
+ # Generated NIfTI artifacts
10
+ *.nii
11
+ *.nii.gz
12
+
13
+ # Python
14
+ __pycache__/
15
+ *.py[cod]
16
+ *$py.class
17
+ *.so
18
+ .Python
19
+ .venv/
20
+ venv/
21
+ env/
22
+ *.egg-info/
23
+ .pytest_cache/
24
+
25
+ # Environment
26
+ .env
27
+ .env.local
28
+
29
+ # IDE
30
+ .vscode/
31
+ .idea/
32
+ *.swp
33
+ *.swo
34
+
35
+ # OS
36
+ .DS_Store
37
+ Thumbs.db
38
+
39
+ # Hugging Face cache
40
+ .cache/
41
+ huggingface/
42
+
43
+ # MONAI working dir
44
+ temp_work_dir/
45
+
46
+ # Gradio
47
+ gradio_cached_examples/
48
+ flagged/
README.md ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: NV-Generate Synthetic Medical Imaging
3
+ emoji: 🧠
4
+ colorFrom: green
5
+ colorTo: indigo
6
+ sdk: gradio
7
+ sdk_version: "6.14.0"
8
+ app_file: app.py
9
+ python_version: "3.11"
10
+ pinned: true
11
+ license: other
12
+ license_name: nvidia-open-model-license
13
+ license_link: https://www.nvidia.com/en-us/agreements/enterprise-software/nvidia-open-model-license/
14
+ short_description: Synthetic 3D CT and MR generation with NVIDIA NV-Generate.
15
+ ---
16
+
17
+ # NV-Generate · Synthetic Medical Imaging
18
+
19
+ A unified Hugging Face Spaces demo for NVIDIA Medtech's three open-weight 3D
20
+ medical image generators, all built on the MAISI-v2 rectified-flow architecture
21
+ (~30 inference steps each).
22
+
23
+ | Model | Modality | Output | Weights |
24
+ |---|---|---|---|
25
+ | **NV-Generate · CT** | Computed Tomography | Image + paired 132-class anatomy mask | [nvidia/NV-Generate-CT](https://huggingface.co/nvidia/NV-Generate-CT) |
26
+ | **NV-Generate · MR** | MR (multi-contrast, multi-anatomy) | Image only | [nvidia/NV-Generate-MR](https://huggingface.co/nvidia/NV-Generate-MR) |
27
+ | **NV-Generate · MR Brain** | Brain MR (T1 / T2 / FLAIR / SWI) | Image only | [nvidia/NV-Generate-MR-Brain](https://huggingface.co/nvidia/NV-Generate-MR-Brain) |
28
+
29
+ ## Features
30
+
31
+ - Hero card landing → per-model workspace.
32
+ - niivue WebGL multiplanar viewer (axial / coronal / sagittal + 3D render).
33
+ - Paired 132-class anatomy mask overlay for CT (with deterministic per-label colormap + named legend).
34
+ - Window/Level presets (Soft Tissue / Lung / Bone / Brain) on the CT viewer.
35
+ - Direct NIfTI download of every generated volume + mask.
36
+ - ZeroGPU support for HF Spaces (`@spaces.GPU` decorator).
37
+
38
+ ## Local development
39
+
40
+ ```bash
41
+ pip install -r requirements.txt
42
+ pip install -r repos/NV-Generate-CTMR/requirements.txt
43
+ python app.py # http://localhost:7860
44
+ ```
45
+
46
+ `app.py` auto-clones the upstream `NV-Generate-CTMR` inference repo into
47
+ `./repos/` on first run (no separate `pre-build.sh` needed). Weights are
48
+ downloaded lazily from the Hugging Face Hub the first time each model is
49
+ exercised, then cached.
50
+
51
+ ## Hugging Face Spaces deployment
52
+
53
+ Recommended hardware: **ZeroGPU** (A10G or H100), since each model needs ~16–80 GB
54
+ VRAM depending on volume size.
55
+
56
+ 1. Create a new Space on huggingface.co with `sdk: gradio` (already set in this
57
+ README's frontmatter).
58
+ 2. Push this repository:
59
+ ```bash
60
+ git remote add space https://huggingface.co/spaces/<your-username>/nv-generate
61
+ git push space main
62
+ ```
63
+ 3. In the Space's **Settings → Hardware**, select **ZeroGPU**.
64
+ 4. First build will install dependencies + clone the upstream repo. First
65
+ generation on each model downloads weights into a persistent cache.
66
+
67
+ ## License
68
+
69
+ | Component | License |
70
+ |---|---|
71
+ | This repo (Gradio glue) | Apache 2.0 |
72
+ | Upstream `NV-Generate-CTMR` source | Apache 2.0 |
73
+ | `NV-Generate-CT` weights | [NVIDIA Open Model License](https://www.nvidia.com/en-us/agreements/enterprise-software/nvidia-open-model-license/) |
74
+ | `NV-Generate-MR` weights | [**NVIDIA OneWay Non-Commercial License**](https://developer.download.nvidia.com/licenses/NVIDIA-OneWay-Noncommercial-License-22Mar2022.pdf) — academic / research use only |
75
+ | `NV-Generate-MR-Brain` weights | [NVIDIA Open Model License](https://www.nvidia.com/en-us/agreements/enterprise-software/nvidia-open-model-license/) |
76
+
77
+ **Not for clinical diagnostics.** This is a research demo for synthetic data
78
+ generation only.
79
+
80
+ ## Credits
81
+
82
+ Built on the MAISI framework:
83
+
84
+ - **MAISI-v1** — [WACV 2025 paper](https://arxiv.org/abs/2409.11169)
85
+ - **MAISI-v2** — [AAAI 2026 paper](https://arxiv.org/abs/2508.05772)
86
+
87
+ NVIDIA Medtech, University of Zurich, Istanbul Medipol, Forithmus.
88
+ </content>
89
+ </invoke>
app.py ADDED
@@ -0,0 +1,1462 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NV-Generate Showcase — Gradio app entrypoint.
3
+
4
+ Hero card landing → per-model workspace. Each model's Generate button is wrapped
5
+ with @spaces.GPU on Hugging Face Spaces (ZeroGPU); locally the decorator is a no-op.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ import gradio as gr
15
+
16
+ ROOT = Path(__file__).resolve().parent
17
+ sys.path.insert(0, str(ROOT))
18
+
19
+
20
+ def _ensure_upstream():
21
+ """Clone the NV-Generate-CTMR inference repo on first run.
22
+
23
+ HF Spaces with `sdk: gradio` does not run shell scripts at build time, so we
24
+ bootstrap the upstream checkout here at module load. Idempotent: skips if
25
+ the directory already exists (local dev, subsequent Space restarts).
26
+ """
27
+ upstream = ROOT / "repos" / "NV-Generate-CTMR"
28
+ if upstream.exists():
29
+ return
30
+ print("[nv-generate] cloning NV-Generate-CTMR upstream...", flush=True)
31
+ upstream.parent.mkdir(parents=True, exist_ok=True)
32
+ subprocess.run(
33
+ [
34
+ "git", "clone", "--depth", "1",
35
+ "https://github.com/NVIDIA-Medtech/NV-Generate-CTMR.git",
36
+ str(upstream),
37
+ ],
38
+ check=True,
39
+ )
40
+
41
+
42
+ _ensure_upstream()
43
+
44
+ # ZeroGPU decorator — present on HF Spaces, optional locally.
45
+ try:
46
+ import spaces # type: ignore
47
+ spaces_gpu_ct = spaces.GPU(duration=300)
48
+ spaces_gpu_mr = spaces.GPU(duration=180)
49
+ spaces_gpu_mr_brain = spaces.GPU(duration=180)
50
+ except (ImportError, AttributeError):
51
+ def _identity(d=None): # noqa: ARG001
52
+ def deco(fn):
53
+ return fn
54
+ return deco
55
+ spaces_gpu_ct = _identity()
56
+ spaces_gpu_mr = _identity()
57
+ spaces_gpu_mr_brain = _identity()
58
+
59
+ from ui.hero import render_hero
60
+ from ui import workspace_ct, workspace_mr, workspace_mr_brain
61
+
62
+
63
+ CSS = """
64
+ @import url('https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap');
65
+
66
+ :root {
67
+ /* Navy / blue-black palette — distinctly blue, never gray */
68
+ --bg-0: #06102a;
69
+ --bg-1: #0a1530;
70
+ --bg-2: #0e1838;
71
+ --panel: #0e1b3a;
72
+ --panel-2: #142348;
73
+ --line: rgba(140, 180, 240, 0.12);
74
+ --line-strong: rgba(140, 180, 240, 0.22);
75
+ --line-bright: rgba(140, 180, 240, 0.38);
76
+ --text: #ecf0fa;
77
+ --text-2: #b0bcd5;
78
+ --muted: #7280a0;
79
+ --muted-2: #4a5778;
80
+
81
+ --green: #76b900;
82
+ --green-glow: rgba(118, 185, 0, 0.55);
83
+ --green-soft: rgba(118, 185, 0, 0.10);
84
+ --warn: #f6c861;
85
+ --warn-soft: rgba(246, 200, 97, 0.10);
86
+
87
+ --ct: #76b900;
88
+ --mr: #5fb4ff;
89
+ --mrb: #b48aff;
90
+
91
+ --font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
92
+ --font-mono: "Geist Mono", ui-monospace, "JetBrains Mono", "SF Mono", monospace;
93
+ --num: "Geist Mono", ui-monospace, monospace;
94
+
95
+ --container: 1240px;
96
+ --gutter: 32px;
97
+ }
98
+
99
+ /* ─────────────── base + blueprint grid ─────────────── */
100
+ /* Override Gradio Base theme tokens that paint a default gray fill across the app */
101
+ .gradio-container,
102
+ .gradio-container *,
103
+ gradio-app {
104
+ --body-background-fill: transparent !important;
105
+ --body-background-fill-dark: transparent !important;
106
+ --background-fill-primary: transparent !important;
107
+ --background-fill-primary-dark: transparent !important;
108
+ --background-fill-secondary: transparent !important;
109
+ --background-fill-secondary-dark: transparent !important;
110
+ --block-background-fill: transparent !important;
111
+ --block-background-fill-dark: transparent !important;
112
+ --panel-background-fill: transparent !important;
113
+ --panel-background-fill-dark: transparent !important;
114
+ --color-background-primary: transparent !important;
115
+ }
116
+
117
+ html { background: #010206 !important; }
118
+ html, body {
119
+ color: var(--text) !important;
120
+ font-family: var(--font-sans);
121
+ font-feature-settings: "ss01", "cv11", "tnum";
122
+ }
123
+
124
+ body {
125
+ background-image:
126
+ /* Concentrated blue glow behind the hero only — not page-wide */
127
+ radial-gradient(ellipse 1100px 600px at 50% 220px, rgba(70, 140, 230, 0.16), transparent 65%),
128
+ /* NVIDIA green hint warming the top-left of the page */
129
+ radial-gradient(ellipse 700px 380px at 18% -40px, rgba(118, 185, 0, 0.06), transparent 70%),
130
+ /* Cool purple sweep at lower-right (very faint) */
131
+ radial-gradient(ellipse 900px 600px at 108% 108%, rgba(140, 100, 220, 0.06), transparent 60%),
132
+ /* Architectural blueprint grid — dark lines for blueprint-paper feel */
133
+ linear-gradient(rgba(0, 0, 6, 0.42) 1px, transparent 1px),
134
+ linear-gradient(90deg, rgba(0, 0, 6, 0.42) 1px, transparent 1px),
135
+ /* Near-black base with a whisper of navy — panels now pop against this */
136
+ linear-gradient(180deg, #050810 0%, #02040c 50%, #010206 100%) !important;
137
+ background-size: auto, auto, auto, 32px 32px, 32px 32px, auto;
138
+ background-attachment: fixed, fixed, fixed, fixed, fixed, fixed;
139
+ background-color: #010206 !important;
140
+ }
141
+
142
+ /* ─────────────── film-grain noise overlay ─────────────── */
143
+ /* Breaks gradient banding, adds photographic warmth across every screenshot. */
144
+ body::before {
145
+ content: "";
146
+ position: fixed; inset: 0;
147
+ z-index: 0;
148
+ pointer-events: none;
149
+ background-image: url("data:image/svg+xml;utf8,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.6 0 0 0 0 0.7 0 0 0 0 0.9 0 0 0 1 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
150
+ background-size: 240px 240px;
151
+ opacity: 0.045;
152
+ mix-blend-mode: overlay;
153
+ }
154
+
155
+ /* ─────────────── one-time scanline sweep on page load ─────────────── */
156
+ body::after {
157
+ content: "";
158
+ position: fixed;
159
+ left: 0; right: 0; top: -8px;
160
+ height: 2px;
161
+ z-index: 9999;
162
+ pointer-events: none;
163
+ background: linear-gradient(90deg,
164
+ transparent 5%,
165
+ rgba(118, 185, 0, 0.35) 30%,
166
+ rgba(118, 185, 0, 0.85) 50%,
167
+ rgba(118, 185, 0, 0.35) 70%,
168
+ transparent 95%);
169
+ filter: blur(3px);
170
+ animation: page-scanline 1.8s cubic-bezier(.4,.0,.2,1) 0.4s 1 forwards;
171
+ opacity: 0;
172
+ }
173
+ @keyframes page-scanline {
174
+ 0% { transform: translateY(0); opacity: 0; }
175
+ 8% { opacity: 0.9; }
176
+ 50% { opacity: 1; }
177
+ 90% { opacity: 0.4; }
178
+ 100% { transform: translateY(100vh); opacity: 0; }
179
+ }
180
+
181
+ /* All Gradio root / wrapper elements stay transparent so body bg shows through */
182
+ gradio-app, gradio-app > div, gradio-app .main, gradio-app .wrap,
183
+ gradio-app .contain, gradio-app #root, .app, .main, .wrap, .contain {
184
+ background: transparent !important;
185
+ background-color: transparent !important;
186
+ }
187
+
188
+ .gradio-container {
189
+ max-width: var(--container) !important;
190
+ padding: 0 var(--gutter) !important;
191
+ background: transparent !important;
192
+ background-color: transparent !important;
193
+ font-family: var(--font-sans) !important;
194
+ margin: 0 auto !important;
195
+ }
196
+
197
+ /* Override Gradio's default panel chrome — every wrapper class that can show bg */
198
+ .gradio-container .form, .gradio-container .gap, .gradio-container .panel,
199
+ .gradio-container .block, .gradio-container .gr-box, .gradio-container .gr-group,
200
+ .gradio-container .gr-padded, .gradio-container > .main, .gradio-container > .wrap,
201
+ .gradio-container .styler, .gradio-container .container, .gradio-container .row,
202
+ .gradio-container .column {
203
+ background: transparent !important;
204
+ background-color: transparent !important;
205
+ border: none !important;
206
+ box-shadow: none !important;
207
+ }
208
+
209
+ /* gr.HTML wraps its value in a .html-container div with horizontal padding.
210
+ Zero the PADDING so HTML blocks (ws-intro, viewer-strip, legend, status) sit
211
+ flush against the column edge; leave margin alone so children's own
212
+ margin-bottom (e.g. .ws-intro { margin: 0 0 28px }) still creates space below. */
213
+ .gradio-container .html-container,
214
+ .gradio-container [class*="html-container"] {
215
+ padding: 0 !important;
216
+ background: transparent !important;
217
+ }
218
+ /* The hero/workspace gr.Group wrappers must not add their own chrome */
219
+ .hero, .workspace {
220
+ background: transparent !important;
221
+ background-color: transparent !important;
222
+ border: 0 !important; padding: 0 !important;
223
+ }
224
+ .hero > .gap, .workspace > .gap { padding: 0 !important; background: transparent !important; }
225
+
226
+ .dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; background: var(--green); box-shadow: 0 0 6px var(--green-glow); }
227
+ .dot-pulse { animation: dotpulse 2s ease-in-out infinite; }
228
+ @keyframes dotpulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
229
+ @keyframes fadein { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
230
+
231
+ /* ─────────────── masthead ─────────────── */
232
+ .masthead {
233
+ display: flex; align-items: center; justify-content: space-between;
234
+ height: 56px;
235
+ border-bottom: 1px solid var(--line);
236
+ font-family: var(--font-sans);
237
+ animation: fadein 0.4s ease both;
238
+ }
239
+ .masthead-brand {
240
+ display: flex; align-items: center; gap: 12px;
241
+ text-decoration: none; color: var(--text);
242
+ }
243
+ .nv-mark {
244
+ display: inline-flex; align-items: center; justify-content: center;
245
+ width: 28px; height: 22px;
246
+ background: var(--green); color: #000;
247
+ font-family: var(--font-mono); font-weight: 700; font-size: 11px;
248
+ letter-spacing: 0.04em;
249
+ clip-path: polygon(0 0, 100% 0, 100% 70%, 86% 100%, 0 100%);
250
+ }
251
+ .masthead-name {
252
+ font-size: 14px; font-weight: 500; letter-spacing: -0.005em;
253
+ color: var(--text);
254
+ }
255
+ .masthead-nav {
256
+ display: flex; align-items: center; gap: 18px;
257
+ font-family: var(--font-sans);
258
+ font-size: 13px;
259
+ color: var(--text-2);
260
+ }
261
+ .masthead-nav a {
262
+ color: var(--text-2); text-decoration: none;
263
+ transition: color 160ms ease;
264
+ }
265
+ .masthead-nav a:hover { color: var(--green); }
266
+ .masthead-sep { width: 3px; height: 3px; background: var(--muted-2); border-radius: 50%; }
267
+
268
+ /* ─────────────── hero block ─────────────── */
269
+ .hero { padding: 0 0 32px; }
270
+ .hero-mono {
271
+ padding: 88px 0 48px; text-align: center;
272
+ max-width: 720px;
273
+ margin: 0 auto;
274
+ position: relative;
275
+ animation: fadein 0.55s ease 0.05s both;
276
+ }
277
+ /* Soft blue glow behind the headline gives atmospheric depth */
278
+ .hero-mono::before {
279
+ content: "";
280
+ position: absolute;
281
+ width: 720px; height: 380px;
282
+ left: 50%; top: -40px;
283
+ transform: translateX(-50%);
284
+ background:
285
+ radial-gradient(ellipse at center, rgba(95, 180, 255, 0.18) 0%, transparent 55%),
286
+ radial-gradient(ellipse at 30% 70%, rgba(118, 185, 0, 0.12) 0%, transparent 60%);
287
+ filter: blur(16px);
288
+ pointer-events: none;
289
+ z-index: -1;
290
+ opacity: 0.9;
291
+ }
292
+ .hero-eyebrow {
293
+ display: inline-flex; align-items: center; gap: 12px;
294
+ color: var(--green);
295
+ font-family: var(--font-mono); font-size: 11px; font-weight: 500;
296
+ letter-spacing: 0.18em; text-transform: uppercase;
297
+ margin-bottom: 24px;
298
+ }
299
+ .line-tick { display: inline-block; width: 28px; height: 1px; background: var(--green); opacity: 0.5; }
300
+
301
+ .hero-title {
302
+ font-family: var(--font-sans);
303
+ font-size: clamp(2.0rem, 4.0vw, 3.0rem);
304
+ font-weight: 500;
305
+ line-height: 1.05;
306
+ letter-spacing: -0.032em;
307
+ margin: 0;
308
+ color: var(--text);
309
+ }
310
+
311
+ /* ─────────────── datasheet cards ─────────────── */
312
+ .hero-row { gap: 20px !important; align-items: stretch !important; }
313
+ .hero-row > * { display: flex !important; flex-direction: column !important; }
314
+ .hero-card-col {
315
+ padding: 0 !important;
316
+ display: flex !important; flex-direction: column !important;
317
+ animation: fadein 0.5s ease both;
318
+ }
319
+ .hero-card-col:nth-child(1) { animation-delay: 0.10s; }
320
+ .hero-card-col:nth-child(2) { animation-delay: 0.18s; }
321
+ .hero-card-col:nth-child(3) { animation-delay: 0.26s; }
322
+
323
+ .ds-card {
324
+ position: relative;
325
+ background: var(--panel);
326
+ border: 1px solid var(--line);
327
+ transition: border-color 220ms ease, background 220ms ease, transform 220ms ease, box-shadow 280ms ease;
328
+ --accent: var(--green);
329
+ flex: 1 1 auto;
330
+ display: flex;
331
+ flex-direction: column;
332
+ }
333
+ .ds-card.ds-ct { --accent: var(--ct); }
334
+ .ds-card.ds-mr { --accent: var(--mr); }
335
+ .ds-card.ds-mrb { --accent: var(--mrb); }
336
+ .ds-card:hover {
337
+ border-color: color-mix(in srgb, var(--accent) 60%, transparent);
338
+ background: var(--panel-2);
339
+ box-shadow:
340
+ 0 1px 0 rgba(255,255,255,0.04) inset,
341
+ 0 24px 60px -16px color-mix(in srgb, var(--accent) 35%, transparent);
342
+ }
343
+ .ds-card:hover .ds-corner { background: var(--accent); }
344
+
345
+ .ds-frame {
346
+ position: relative;
347
+ padding: 18px 22px 22px;
348
+ display: flex; flex-direction: column;
349
+ flex: 1 1 auto;
350
+ }
351
+
352
+ /* Banner at top of card — modality scan icon over a faint accent gradient */
353
+ .ds-banner {
354
+ position: relative;
355
+ height: 110px;
356
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
357
+ gap: 6px;
358
+ border-bottom: 1px solid var(--line);
359
+ background:
360
+ linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.22) 100%),
361
+ radial-gradient(ellipse at center, color-mix(in srgb, var(--accent) 18%, transparent) 0%, transparent 60%);
362
+ overflow: hidden;
363
+ color: var(--accent);
364
+ }
365
+ .ds-banner-grid {
366
+ position: absolute; inset: 0;
367
+ background-image:
368
+ linear-gradient(rgba(150,180,240,0.06) 1px, transparent 1px),
369
+ linear-gradient(90deg, rgba(150,180,240,0.06) 1px, transparent 1px);
370
+ background-size: 16px 16px;
371
+ pointer-events: none;
372
+ mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%);
373
+ }
374
+ /* Single scanline sweep on first card render — telegraphs "this generates volumes" */
375
+ .ds-banner::after {
376
+ content: "";
377
+ position: absolute; left: 0; right: 0; top: 0;
378
+ height: 2px;
379
+ background: linear-gradient(90deg, transparent, var(--accent), transparent);
380
+ filter: blur(1px);
381
+ opacity: 0;
382
+ animation: banner-scan 2.6s cubic-bezier(.4,0,.2,1) 1 forwards;
383
+ animation-delay: 0.5s;
384
+ }
385
+ .hero-card-col:nth-child(1) .ds-banner::after { animation-delay: 0.5s; }
386
+ .hero-card-col:nth-child(2) .ds-banner::after { animation-delay: 0.75s; }
387
+ .hero-card-col:nth-child(3) .ds-banner::after { animation-delay: 1.0s; }
388
+ @keyframes banner-scan {
389
+ 0% { top: 0; opacity: 0; }
390
+ 10% { opacity: 0.85; }
391
+ 85% { opacity: 0.85; }
392
+ 100% { top: 110px; opacity: 0; }
393
+ }
394
+ .ds-banner-icon {
395
+ width: 64px; height: 64px;
396
+ position: relative; z-index: 1;
397
+ display: flex; align-items: center; justify-content: center;
398
+ transition: transform 280ms ease;
399
+ }
400
+ .ds-card:hover .ds-banner-icon { transform: scale(1.05); }
401
+ .ds-banner-icon svg { width: 100%; height: 100%; display: block; }
402
+ .ds-banner-caption {
403
+ font-family: var(--font-mono); font-size: 9.5px;
404
+ letter-spacing: 0.16em; text-transform: uppercase;
405
+ color: var(--accent);
406
+ opacity: 0.85;
407
+ position: relative; z-index: 1;
408
+ }
409
+ .ds-corner {
410
+ position: absolute; width: 7px; height: 7px;
411
+ background: var(--line-bright);
412
+ transition: background 220ms ease;
413
+ }
414
+ .ds-tl { top: -1px; left: -1px; clip-path: polygon(0 0, 100% 0, 0 100%); }
415
+ .ds-tr { top: -1px; right: -1px; clip-path: polygon(0 0, 100% 0, 100% 100%); }
416
+ .ds-bl { bottom: -1px; left: -1px; clip-path: polygon(0 0, 0 100%, 100% 100%); }
417
+ .ds-br { bottom: -1px; right: -1px; clip-path: polygon(100% 0, 100% 100%, 0 100%); }
418
+
419
+ .ds-head {
420
+ display: flex; align-items: center; justify-content: space-between;
421
+ margin-bottom: 18px;
422
+ height: 24px;
423
+ }
424
+ .ds-index {
425
+ font-family: var(--font-mono);
426
+ font-size: 11px; font-weight: 600; letter-spacing: 0.18em;
427
+ color: var(--accent);
428
+ padding: 3px 9px;
429
+ border: 1px solid var(--accent);
430
+ border-radius: 0;
431
+ opacity: 0.9;
432
+ }
433
+ /* Availability status — single dot + label. Decoupled from license info. */
434
+ .ds-status {
435
+ display: inline-flex; align-items: center; gap: 7px;
436
+ font-family: var(--font-mono); font-size: 9.5px;
437
+ letter-spacing: 0.16em; text-transform: uppercase;
438
+ color: var(--muted);
439
+ }
440
+ .ds-status .dot { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
441
+
442
+ .ds-name { margin-bottom: 16px; }
443
+ .ds-name-title {
444
+ font-family: var(--font-sans);
445
+ font-size: 1.20rem; font-weight: 500;
446
+ letter-spacing: -0.018em; line-height: 1.15;
447
+ color: var(--text);
448
+ margin-bottom: 4px;
449
+ }
450
+ .ds-name-code {
451
+ font-family: var(--font-mono); font-size: 11px;
452
+ letter-spacing: 0.04em; text-transform: lowercase;
453
+ color: var(--accent);
454
+ opacity: 0.85;
455
+ margin-bottom: 10px;
456
+ }
457
+ .ds-name-sub {
458
+ font-family: var(--font-sans);
459
+ font-size: 0.86rem; line-height: 1.5;
460
+ color: var(--text-2);
461
+ min-height: 2.6em; /* reserves ~2 lines so cards stay aligned */
462
+ margin-bottom: 14px;
463
+ }
464
+
465
+ /* Use-case section label */
466
+ .ds-uses-label {
467
+ font-family: var(--font-mono); font-size: 9px;
468
+ letter-spacing: 0.22em; text-transform: uppercase;
469
+ color: var(--muted);
470
+ margin-bottom: 8px;
471
+ }
472
+ /* Use-case chips: small mono pills below the subtitle */
473
+ .ds-uses {
474
+ display: flex; flex-wrap: wrap; gap: 6px;
475
+ margin-bottom: 16px;
476
+ min-height: 28px;
477
+ }
478
+ .ds-use {
479
+ display: inline-flex; align-items: center;
480
+ padding: 4px 9px;
481
+ border: 1px solid var(--line);
482
+ background: rgba(140, 180, 240, 0.04);
483
+ font-family: var(--font-sans); font-size: 11px;
484
+ font-weight: 500;
485
+ letter-spacing: 0;
486
+ color: var(--text-2);
487
+ white-space: nowrap;
488
+ }
489
+ .ds-card:hover .ds-use { border-color: rgba(140, 180, 240, 0.20); }
490
+
491
+ /* License chip: separate from availability status, lives at card bottom */
492
+ .ds-license {
493
+ margin-top: 14px;
494
+ padding: 8px 12px;
495
+ display: flex; align-items: center; gap: 10px;
496
+ border: 1px solid var(--line);
497
+ background: rgba(140, 180, 240, 0.03);
498
+ font-family: var(--font-mono); font-size: 10px;
499
+ letter-spacing: 0.04em;
500
+ }
501
+ .ds-license-k {
502
+ color: var(--muted);
503
+ text-transform: uppercase; letter-spacing: 0.16em; font-size: 9px;
504
+ }
505
+ .ds-license-v { color: var(--text); }
506
+ .ds-license-warn {
507
+ border-color: rgba(246, 200, 97, 0.30);
508
+ background: var(--warn-soft);
509
+ }
510
+ .ds-license-warn .ds-license-v { color: var(--warn); }
511
+ .ds-license-ok {
512
+ border-color: rgba(118, 185, 0, 0.25);
513
+ }
514
+ .ds-license-ok .ds-license-v { color: var(--text); }
515
+
516
+ .ds-divider {
517
+ height: 1px; background: var(--line);
518
+ margin: 0 0 14px;
519
+ position: relative;
520
+ }
521
+ .ds-divider::before, .ds-divider::after {
522
+ content: ""; position: absolute; top: -2px; width: 4px; height: 4px;
523
+ background: var(--accent);
524
+ }
525
+ .ds-divider::before { left: 0; }
526
+ .ds-divider::after { right: 0; }
527
+
528
+ .ds-spec {
529
+ display: flex; flex-direction: column; gap: 7px;
530
+ margin-top: auto; /* pushes spec to bottom of frame so cards always end at same baseline */
531
+ }
532
+ .ds-row {
533
+ display: flex; align-items: baseline;
534
+ font-family: var(--font-mono); font-size: 10.5px;
535
+ letter-spacing: 0.02em;
536
+ color: var(--text-2);
537
+ white-space: nowrap;
538
+ overflow: hidden;
539
+ }
540
+ .ds-key {
541
+ color: var(--muted);
542
+ flex-shrink: 0;
543
+ text-transform: uppercase;
544
+ font-weight: 500;
545
+ }
546
+ .ds-leader {
547
+ flex: 1 1 auto;
548
+ border-bottom: 1px dotted var(--line-strong);
549
+ margin: 0 8px;
550
+ height: 0;
551
+ align-self: end;
552
+ margin-bottom: 4px;
553
+ min-width: 12px;
554
+ }
555
+ .ds-val {
556
+ color: var(--text);
557
+ text-align: right;
558
+ flex-shrink: 0;
559
+ overflow: hidden;
560
+ text-overflow: ellipsis;
561
+ }
562
+
563
+ /* CTA buttons under each card */
564
+ .ds-cta { padding: 0 !important; margin: 0 !important; }
565
+ .ds-cta button, button.ds-cta {
566
+ width: 100% !important;
567
+ margin-top: 14px !important;
568
+ padding: 14px 16px !important;
569
+ background: transparent !important;
570
+ border: 1px solid var(--line-strong) !important;
571
+ border-radius: 0 !important;
572
+ color: var(--text) !important;
573
+ font-family: var(--font-sans) !important;
574
+ font-size: 13px !important;
575
+ font-weight: 500 !important;
576
+ letter-spacing: 0 !important;
577
+ text-transform: none !important;
578
+ text-align: center !important;
579
+ cursor: pointer !important;
580
+ transition: all 200ms ease !important;
581
+ }
582
+ .ds-cta-ct button:hover { border-color: var(--ct) !important; background: rgba(118, 185, 0, 0.10) !important; color: var(--ct) !important; }
583
+ .ds-cta-mr button:hover { border-color: var(--mr) !important; background: rgba(95, 180, 255, 0.10) !important; color: var(--mr) !important; }
584
+ .ds-cta-mrb button:hover { border-color: var(--mrb) !important; background: rgba(180, 138, 255, 0.10) !important; color: var(--mrb) !important; }
585
+
586
+ /* ─────────────── workspace shell ─────────────── */
587
+ .workspace { padding: 0 0 32px; animation: fadein 0.35s ease both; }
588
+ .workspace-row { gap: 24px !important; align-items: stretch !important; }
589
+
590
+ /* ─────────────── workspace intro (per-model context block) ─────────────── */
591
+ .ws-intro {
592
+ display: grid;
593
+ grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
594
+ gap: 32px;
595
+ padding: 24px 26px;
596
+ margin: 0 0 28px !important;
597
+ background:
598
+ linear-gradient(180deg, rgba(120,165,255,0.05), rgba(120,165,255,0.02)),
599
+ var(--panel);
600
+ border: 1px solid var(--line);
601
+ border-left: 3px solid var(--accent, var(--green));
602
+ position: relative;
603
+ --accent: var(--green);
604
+ }
605
+ .ws-intro-ct { --accent: var(--ct); }
606
+ .ws-intro-mr { --accent: var(--mr); }
607
+ .ws-intro-mrb { --accent: var(--mrb); }
608
+
609
+ .ws-intro-title {
610
+ font-family: var(--font-sans);
611
+ font-size: 1.30rem; font-weight: 500;
612
+ letter-spacing: -0.018em; line-height: 1.15;
613
+ color: var(--text);
614
+ margin: 0 0 10px;
615
+ }
616
+ .ws-intro-desc {
617
+ font-family: var(--font-sans);
618
+ font-size: 0.95rem; line-height: 1.55;
619
+ color: var(--text-2);
620
+ margin: 0;
621
+ max-width: 56ch;
622
+ }
623
+ .ws-intro-facts {
624
+ display: flex; flex-direction: column;
625
+ gap: 6px;
626
+ align-self: center;
627
+ }
628
+ .ws-fact {
629
+ display: flex; align-items: baseline; gap: 12px;
630
+ font-family: var(--font-mono); font-size: 11px;
631
+ letter-spacing: 0.02em;
632
+ }
633
+ .ws-fact-k {
634
+ color: var(--muted);
635
+ text-transform: uppercase;
636
+ letter-spacing: 0.12em;
637
+ font-size: 9.5px;
638
+ flex: 0 0 92px;
639
+ }
640
+ .ws-fact-v { color: var(--text); }
641
+ .ws-fact-v.ws-fact-warn { color: var(--warn); }
642
+ .workspace-header {
643
+ align-items: center !important;
644
+ gap: 14px !important;
645
+ padding: 18px 0 18px !important;
646
+ border-bottom: 1px solid var(--line);
647
+ margin-bottom: 22px;
648
+ }
649
+ .workspace-title {
650
+ font-family: var(--font-sans);
651
+ font-size: 14px;
652
+ color: var(--text-2);
653
+ display: flex; align-items: center; gap: 10px;
654
+ flex: 1;
655
+ }
656
+ .workspace-title .ws-dot { width: 7px; height: 7px; border-radius: 50%; box-shadow: 0 0 6px currentColor; flex-shrink: 0; }
657
+ .workspace-title .ws-crumb { color: var(--text-2); font-weight: 500; }
658
+ .workspace-title .ws-crumb-sep { color: var(--muted-2); margin: 0 2px; }
659
+ .workspace-title .ws-active { color: var(--text); font-weight: 500; }
660
+ .workspace-title .ws-meta {
661
+ font-family: var(--font-mono); font-size: 11px;
662
+ color: var(--muted); margin-left: 14px;
663
+ padding: 3px 8px; border: 1px solid var(--line);
664
+ letter-spacing: 0.04em;
665
+ }
666
+
667
+ .back-btn { padding: 0 !important; margin: 0 !important; flex: 0 0 auto !important; }
668
+ .back-btn button {
669
+ background: transparent !important;
670
+ border: 1px solid var(--line-strong) !important;
671
+ border-radius: 0 !important;
672
+ color: var(--text-2) !important;
673
+ font-family: var(--font-sans) !important;
674
+ font-size: 13px !important;
675
+ font-weight: 500 !important;
676
+ letter-spacing: 0 !important;
677
+ text-transform: none !important;
678
+ padding: 8px 14px !important;
679
+ }
680
+ .back-btn button:hover { border-color: var(--line-bright) !important; color: var(--text) !important; }
681
+
682
+ .hint {
683
+ font-family: var(--font-sans); font-size: 12px;
684
+ color: var(--muted);
685
+ padding: 4px 0 6px;
686
+ margin: -4px 0 0;
687
+ }
688
+
689
+ /* ─────────────── controls panel ─────────────── */
690
+ .controls {
691
+ background: var(--panel) !important;
692
+ border: 1px solid var(--line) !important;
693
+ border-radius: 0 !important;
694
+ padding: 18px 22px !important;
695
+ position: relative;
696
+ }
697
+ /* Reset Gradio's interior block padding so we control it */
698
+ .controls > * { padding: 0 !important; }
699
+
700
+ /* Section headers (the gr.Markdown ##### lines) */
701
+ .controls h5 {
702
+ font-family: var(--font-mono) !important;
703
+ font-size: 10px !important;
704
+ font-weight: 600 !important;
705
+ letter-spacing: 0.20em !important;
706
+ text-transform: uppercase !important;
707
+ color: var(--green) !important;
708
+ margin: 24px 0 14px !important;
709
+ padding: 0 0 10px !important;
710
+ border-bottom: 1px solid var(--line) !important;
711
+ display: flex; align-items: center; gap: 10px;
712
+ }
713
+ .controls h5::before {
714
+ content: ""; width: 4px; height: 4px; background: var(--green);
715
+ display: inline-block;
716
+ }
717
+ /* First section header sits flush at top of panel */
718
+ .controls > div:first-child h5,
719
+ .controls .markdown:first-child h5 { margin-top: 0 !important; }
720
+
721
+ /* Input labels: title case sans, not shouting mono */
722
+ .controls label, .controls label > span {
723
+ font-family: var(--font-sans) !important;
724
+ font-size: 12px !important;
725
+ font-weight: 500 !important;
726
+ letter-spacing: 0 !important;
727
+ text-transform: none !important;
728
+ color: var(--text-2) !important;
729
+ }
730
+
731
+ /* Helper text under input (info=) */
732
+ .controls .info, .controls .gr-info, .controls .help-text {
733
+ font-family: var(--font-sans) !important;
734
+ font-size: 11px !important;
735
+ font-weight: 400 !important;
736
+ color: var(--muted) !important;
737
+ letter-spacing: 0 !important;
738
+ text-transform: none !important;
739
+ margin-top: 4px !important;
740
+ }
741
+
742
+ /* Inputs */
743
+ .controls input[type="text"], .controls input[type="number"],
744
+ .controls select, .controls textarea {
745
+ background: var(--bg-1) !important;
746
+ border: 1px solid var(--line-strong) !important;
747
+ border-radius: 0 !important;
748
+ color: var(--text) !important;
749
+ font-family: var(--num) !important;
750
+ font-feature-settings: "tnum", "zero" !important;
751
+ padding: 8px 10px !important;
752
+ }
753
+ .controls input:focus, .controls select:focus, .controls textarea:focus {
754
+ outline: none !important;
755
+ border-color: var(--green) !important;
756
+ box-shadow: 0 0 0 1px var(--green-soft) !important;
757
+ }
758
+
759
+ /* Sliders — keep the number input box tucked next to the track, not floating right */
760
+ .controls input[type="range"] { accent-color: var(--green) !important; }
761
+ .controls .gradio-slider input[type="number"],
762
+ .controls .gr-slider input[type="number"],
763
+ .controls input.slider-value {
764
+ max-width: 64px !important;
765
+ min-width: 0 !important;
766
+ padding: 6px 8px !important;
767
+ font-size: 12px !important;
768
+ }
769
+ /* Pull the slider label and value tight to the left edge */
770
+ .controls .gradio-slider, .controls .gr-slider {
771
+ padding: 0 !important;
772
+ margin: 0 !important;
773
+ }
774
+ .controls .gradio-slider > .head, .controls .gradio-slider > div:first-child {
775
+ margin: 0 !important;
776
+ padding: 0 !important;
777
+ }
778
+
779
+ /* Gradio ships some input wrappers with margin-left:auto which pushes inputs
780
+ to the right side of their column. Zero it so inputs align to the left. */
781
+ .controls .tab-like-container,
782
+ .controls [class*="tab-like-container"],
783
+ .controls .input-container,
784
+ .controls [class*="input-container"] {
785
+ margin-left: 0 !important;
786
+ margin-right: 0 !important;
787
+ }
788
+
789
+ /* Dropdown input — Gradio v6's actual structure is .wrap > .wrap-inner >
790
+ .secondary-wrap > input[role="listbox"]. Put the visible border on .wrap
791
+ (the outermost), make every inner layer transparent so it doesn't double-
792
+ border. Match using :has() on the input role so this rule only fires on
793
+ dropdown wraps, not slider/other wraps inside .controls. */
794
+ .controls .wrap:has(input[role="listbox"]),
795
+ .controls .wrap:has(input.border-none) {
796
+ background: var(--bg-1) !important;
797
+ border: 1px solid var(--line-strong) !important;
798
+ border-radius: 0 !important;
799
+ min-height: 38px !important;
800
+ width: 100% !important;
801
+ box-sizing: border-box !important;
802
+ display: flex !important;
803
+ align-items: center !important;
804
+ }
805
+ .controls .wrap:has(input[role="listbox"]) .wrap-inner,
806
+ .controls .wrap:has(input[role="listbox"]) .secondary-wrap,
807
+ .controls .wrap:has(input[role="listbox"]) .icon-wrap {
808
+ background: transparent !important;
809
+ border: 0 !important;
810
+ width: 100% !important;
811
+ }
812
+ .controls .wrap:has(input[role="listbox"]) input,
813
+ .controls input.border-none,
814
+ .controls input[role="listbox"] {
815
+ background: transparent !important;
816
+ border: 0 !important;
817
+ padding: 8px 10px !important;
818
+ color: var(--text) !important;
819
+ width: 100% !important;
820
+ font-family: var(--font-sans) !important;
821
+ }
822
+ /* Hide any built-in radio/checkbox glyph SVGs Gradio injects inside pill
823
+ labels — the styled pill IS the indicator. */
824
+ .controls .gradio-radio label > svg,
825
+ .controls .gradio-checkboxgroup label > svg,
826
+ .controls fieldset label > svg {
827
+ display: none !important;
828
+ }
829
+
830
+ /* Dropdown popup — only style the visual chrome. Let Gradio's JS handle
831
+ position so the popup anchors to the input regardless of scroll. */
832
+ .gradio-container ul.options,
833
+ .gradio-container ul[role="listbox"] {
834
+ max-height: 280px !important;
835
+ overflow-y: auto !important;
836
+ z-index: 1000 !important;
837
+ }
838
+
839
+ /* Dropdown OPTION LIST (the floating popup) — must have an opaque bg or it
840
+ appears to "float in the air" against the dark page bg. */
841
+ .gradio-container .options,
842
+ .gradio-container ul.options,
843
+ .gradio-container ul[role="listbox"],
844
+ .gradio-container .secondary-wrap ul,
845
+ .gradio-container [class*="option-list"],
846
+ body .options,
847
+ body ul[role="listbox"] {
848
+ background: var(--panel-2) !important;
849
+ background-color: var(--panel-2) !important;
850
+ border: 1px solid var(--line-strong) !important;
851
+ border-radius: 0 !important;
852
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.7), 0 2px 0 rgba(118, 185, 0, 0.20) inset !important;
853
+ font-family: var(--font-sans) !important;
854
+ color: var(--text) !important;
855
+ padding: 4px 0 !important;
856
+ z-index: 1000 !important;
857
+ }
858
+ .gradio-container .options li,
859
+ .gradio-container ul[role="listbox"] li,
860
+ .gradio-container [role="option"],
861
+ body .options li,
862
+ body ul[role="listbox"] li,
863
+ body [role="option"] {
864
+ background: transparent !important;
865
+ color: var(--text) !important;
866
+ font-family: var(--font-sans) !important;
867
+ font-size: 13px !important;
868
+ font-weight: 400 !important;
869
+ letter-spacing: 0 !important;
870
+ text-transform: none !important;
871
+ padding: 8px 14px !important;
872
+ cursor: pointer !important;
873
+ border: 0 !important;
874
+ }
875
+ .gradio-container .options li:hover,
876
+ .gradio-container ul[role="listbox"] li:hover,
877
+ .gradio-container [role="option"]:hover,
878
+ body .options li:hover,
879
+ body ul[role="listbox"] li:hover,
880
+ body [role="option"]:hover {
881
+ background: rgba(118, 185, 0, 0.12) !important;
882
+ color: var(--green) !important;
883
+ }
884
+ .gradio-container [role="option"][aria-selected="true"],
885
+ body [role="option"][aria-selected="true"] {
886
+ background: rgba(118, 185, 0, 0.18) !important;
887
+ color: var(--green) !important;
888
+ }
889
+
890
+ /* Checkbox */
891
+ .controls input[type="checkbox"] { accent-color: var(--green) !important; }
892
+
893
+ /* Sample preset chip buttons */
894
+ .controls button {
895
+ background: transparent !important;
896
+ border: 1px solid var(--line) !important;
897
+ border-radius: 0 !important;
898
+ color: var(--text-2) !important;
899
+ font-family: var(--font-sans) !important;
900
+ font-size: 12px !important;
901
+ font-weight: 500 !important;
902
+ letter-spacing: 0 !important;
903
+ text-transform: none !important;
904
+ padding: 8px 14px !important;
905
+ transition: border-color 160ms ease, color 160ms ease, background 160ms ease;
906
+ }
907
+ /* Sample preset chips: only the BORDER tints green on hover. We intentionally
908
+ do NOT change text color here, because this rule blanket-matches every
909
+ button inside .controls — including the primary Generate CTA — and tinting
910
+ text green there would make it invisible against the green button bg. */
911
+ .controls button:hover { border-color: var(--green) !important; }
912
+
913
+ /* ─────────────── UNIFIED SELECTED STATE ─────────────── */
914
+ /* Pattern: active = filled accent bg + accent border + accent text.
915
+ Hover (non-selected) = outline only. Applied across body region toggles,
916
+ anatomy chips, window/level presets — one consistent visual pattern. */
917
+
918
+ /* Radio / Checkbox group containers: zero EVERY potentially-padded wrapper so
919
+ the first pill aligns flush with the column's left edge, matching the
920
+ slider labels above. Browser default fieldset has ~32px padding-inline-start
921
+ plus min-inline-size, and Gradio adds extra wrappers on top of that. */
922
+ .controls .gradio-radio,
923
+ .controls .gradio-radio > *,
924
+ .controls .gradio-radio fieldset,
925
+ .controls .gradio-radio fieldset > *,
926
+ .controls .gradio-radio .wrap,
927
+ .controls .gradio-radio .wrap-inner,
928
+ .controls .gradio-checkboxgroup,
929
+ .controls .gradio-checkboxgroup > *,
930
+ .controls .gradio-checkboxgroup fieldset,
931
+ .controls .gradio-checkboxgroup fieldset > *,
932
+ .controls .gradio-checkboxgroup .wrap,
933
+ .controls .gradio-checkboxgroup .wrap-inner,
934
+ .controls fieldset {
935
+ padding-left: 0 !important;
936
+ padding-inline-start: 0 !important;
937
+ margin-left: 0 !important;
938
+ margin-inline-start: 0 !important;
939
+ border-inline-start-width: 0 !important;
940
+ min-inline-size: 0 !important;
941
+ }
942
+ /* Restore fieldset to a clean flex pill row */
943
+ .controls .gradio-radio fieldset,
944
+ .controls .gradio-checkboxgroup fieldset {
945
+ display: flex !important;
946
+ flex-wrap: wrap !important;
947
+ gap: 6px !important;
948
+ padding: 0 !important;
949
+ margin: 0 !important;
950
+ border: 0 !important;
951
+ min-inline-size: 0 !important;
952
+ }
953
+
954
+ /* Body region (gr.CheckboxGroup) — strip the native check, style label as toggle */
955
+ .controls .gradio-checkboxgroup label,
956
+ .controls fieldset label {
957
+ display: inline-flex !important; align-items: center !important;
958
+ padding: 8px 14px !important;
959
+ border: 1px solid var(--line) !important;
960
+ background: transparent !important;
961
+ font-family: var(--font-sans) !important;
962
+ font-size: 12px !important;
963
+ font-weight: 500 !important;
964
+ color: var(--text-2) !important;
965
+ cursor: pointer;
966
+ transition: all 160ms ease;
967
+ margin: 0 6px 6px 0 !important;
968
+ }
969
+ .controls .gradio-checkboxgroup label > input,
970
+ .controls fieldset label > input { display: none !important; }
971
+ .controls .gradio-checkboxgroup label > span,
972
+ .controls fieldset label > span {
973
+ font-family: var(--font-sans) !important;
974
+ font-size: 12px !important;
975
+ font-weight: 500 !important;
976
+ color: inherit !important;
977
+ letter-spacing: 0 !important;
978
+ text-transform: none !important;
979
+ }
980
+ .controls .gradio-checkboxgroup label:hover,
981
+ .controls fieldset label:hover {
982
+ border-color: var(--line-bright) !important;
983
+ color: var(--text) !important;
984
+ }
985
+ .controls .gradio-checkboxgroup label:has(input:checked),
986
+ .controls fieldset label:has(input:checked) {
987
+ background: rgba(118, 185, 0, 0.14) !important;
988
+ border-color: var(--green) !important;
989
+ color: var(--green) !important;
990
+ }
991
+
992
+ /* Primary CTA: Generate — same NVIDIA green as section headers, single-tone */
993
+ .primary-cta button, button.primary-cta {
994
+ margin: 18px 0 6px !important;
995
+ padding: 14px 20px !important;
996
+ background: linear-gradient(180deg, #7fc003 0%, var(--green) 100%) !important;
997
+ color: #0b1a00 !important;
998
+ border: 1px solid #5d9100 !important;
999
+ border-radius: 0 !important;
1000
+ font-family: var(--font-sans) !important;
1001
+ font-size: 14px !important;
1002
+ font-weight: 600 !important;
1003
+ letter-spacing: 0 !important;
1004
+ text-transform: none !important;
1005
+ position: relative;
1006
+ width: 100% !important;
1007
+ cursor: pointer !important;
1008
+ box-shadow:
1009
+ 0 1px 0 rgba(255,255,255,0.25) inset,
1010
+ 0 -1px 0 rgba(0,0,0,0.20) inset,
1011
+ 0 10px 28px -10px rgba(118, 185, 0, 0.40) !important;
1012
+ transition: transform 140ms ease, box-shadow 220ms ease, background 180ms ease !important;
1013
+ }
1014
+ .primary-cta button:hover {
1015
+ background: linear-gradient(180deg, #8acc00 0%, #74b400 100%) !important;
1016
+ transform: translateY(-1px) !important;
1017
+ box-shadow:
1018
+ 0 1px 0 rgba(255,255,255,0.35) inset,
1019
+ 0 -1px 0 rgba(0,0,0,0.20) inset,
1020
+ 0 0 0 1px rgba(118,185,0,0.30),
1021
+ 0 18px 44px -10px rgba(118, 185, 0, 0.60) !important;
1022
+ }
1023
+ .primary-cta button:active {
1024
+ transform: translateY(0) !important;
1025
+ box-shadow:
1026
+ 0 1px 0 rgba(255,255,255,0.15) inset,
1027
+ 0 -1px 0 rgba(0,0,0,0.30) inset,
1028
+ 0 6px 16px -8px rgba(118, 185, 0, 0.50) !important;
1029
+ }
1030
+
1031
+ /* Lock the Generate button text color across ALL states + descendants. Going
1032
+ aggressive because Gradio injects Svelte-hashed classes on the button that
1033
+ can bump up rule specificity unpredictably. */
1034
+ .controls .primary-cta button,
1035
+ .controls .primary-cta button:hover,
1036
+ .controls .primary-cta button:focus,
1037
+ .controls .primary-cta button:active,
1038
+ .controls .primary-cta button:focus-visible,
1039
+ .controls .primary-cta button *,
1040
+ .controls .primary-cta button:hover *,
1041
+ .controls .primary-cta button:focus *,
1042
+ .controls .primary-cta button:active *,
1043
+ .primary-cta button,
1044
+ .primary-cta button:hover,
1045
+ .primary-cta button:focus,
1046
+ .primary-cta button:active,
1047
+ .primary-cta button *,
1048
+ .primary-cta button:hover *,
1049
+ .gradio-container .primary-cta button,
1050
+ .gradio-container .primary-cta button:hover {
1051
+ color: #0b1a00 !important;
1052
+ -webkit-text-fill-color: #0b1a00 !important;
1053
+ }
1054
+
1055
+ /* Disabled / generating state — must beat the hover rules above. The combined
1056
+ pseudo-class `:disabled:hover` adds extra specificity (0,3,2 with .controls)
1057
+ so the disabled style wins even while the user is hovering. */
1058
+ .primary-cta button:disabled,
1059
+ .primary-cta button:disabled:hover,
1060
+ .primary-cta button:disabled:focus,
1061
+ .primary-cta button:disabled:active,
1062
+ .primary-cta button:disabled:focus-visible,
1063
+ .controls .primary-cta button:disabled,
1064
+ .controls .primary-cta button:disabled:hover,
1065
+ .controls .primary-cta button:disabled:focus,
1066
+ .controls .primary-cta button:disabled:active,
1067
+ .gradio-container .primary-cta button:disabled,
1068
+ .gradio-container .primary-cta button:disabled:hover {
1069
+ background: linear-gradient(180deg, #4a5f00 0%, #3a4c00 100%) !important;
1070
+ color: rgba(255, 255, 255, 0.70) !important;
1071
+ -webkit-text-fill-color: rgba(255, 255, 255, 0.70) !important;
1072
+ cursor: wait !important;
1073
+ opacity: 1 !important;
1074
+ transform: none !important;
1075
+ box-shadow: none !important;
1076
+ border-color: #2a3700 !important;
1077
+ }
1078
+ /* Make sure nested spans inside the disabled button inherit the dim text color */
1079
+ .primary-cta button:disabled *,
1080
+ .primary-cta button:disabled:hover * {
1081
+ color: rgba(255, 255, 255, 0.70) !important;
1082
+ -webkit-text-fill-color: rgba(255, 255, 255, 0.70) !important;
1083
+ }
1084
+ .primary-cta button:disabled::after {
1085
+ content: "";
1086
+ display: inline-block;
1087
+ width: 10px; height: 10px;
1088
+ margin-left: 10px;
1089
+ border: 2px solid rgba(255,255,255,0.3);
1090
+ border-top-color: rgba(255,255,255,0.85);
1091
+ border-radius: 50%;
1092
+ animation: btn-spin 0.8s linear infinite;
1093
+ vertical-align: -2px;
1094
+ }
1095
+ @keyframes btn-spin { to { transform: rotate(360deg); } }
1096
+
1097
+ /* ─────────────── viewer panel ─────────────── */
1098
+ .viewer-col { padding-left: 0 !important; }
1099
+ .viewer-strip {
1100
+ display: flex; justify-content: space-between; align-items: center;
1101
+ padding: 10px 14px;
1102
+ border: 1px solid var(--line);
1103
+ border-bottom: none;
1104
+ background: var(--panel);
1105
+ font-family: var(--font-mono); font-size: 10px;
1106
+ letter-spacing: 0.20em; text-transform: uppercase;
1107
+ }
1108
+ .viewer-strip-left { color: var(--muted); }
1109
+ .viewer-strip-right { color: var(--text-2); }
1110
+ .viewer {
1111
+ background: var(--bg-1) !important;
1112
+ border: 1px solid var(--line) !important;
1113
+ border-radius: 0 !important;
1114
+ padding: 0 !important;
1115
+ position: relative;
1116
+ }
1117
+
1118
+ /* Window/Level preset bar — its own self-contained card with breathing room
1119
+ above and below so it doesn't blend into the viewer / legend stack. */
1120
+ .preset-row {
1121
+ padding: 14px 18px !important;
1122
+ gap: 14px !important;
1123
+ align-items: center !important;
1124
+ margin: 14px 0 !important;
1125
+ border: 1px solid var(--line) !important;
1126
+ background:
1127
+ linear-gradient(180deg, rgba(80, 150, 240, 0.10) 0%, rgba(80, 150, 240, 0.03) 100%),
1128
+ var(--panel) !important;
1129
+ position: relative;
1130
+ }
1131
+ .preset-row::before {
1132
+ /* Thin left-edge accent to mark this as a control surface */
1133
+ content: ""; position: absolute; left: 0; top: 0; bottom: 0;
1134
+ width: 2px;
1135
+ background: linear-gradient(180deg, rgba(95, 180, 255, 0.55), rgba(95, 180, 255, 0.15));
1136
+ }
1137
+ .preset-label {
1138
+ font-family: var(--font-mono) !important;
1139
+ font-size: 10px;
1140
+ letter-spacing: 0.20em;
1141
+ text-transform: uppercase;
1142
+ color: rgba(170, 200, 255, 0.85);
1143
+ padding: 0 !important;
1144
+ }
1145
+
1146
+ /* Window/Level preset radio — filled-accent active state for consistency */
1147
+ .preset-row label {
1148
+ font-family: var(--font-sans) !important;
1149
+ font-size: 11px !important;
1150
+ font-weight: 500 !important;
1151
+ letter-spacing: 0 !important;
1152
+ text-transform: none !important;
1153
+ color: var(--text-2) !important;
1154
+ cursor: pointer;
1155
+ padding: 7px 12px !important;
1156
+ border: 1px solid var(--line) !important;
1157
+ background: transparent !important;
1158
+ transition: all 160ms ease;
1159
+ }
1160
+ .preset-row input[type="radio"] { display: none; }
1161
+ .preset-row label:has(input:checked) {
1162
+ background: rgba(118, 185, 0, 0.14) !important;
1163
+ border-color: var(--green) !important;
1164
+ color: var(--green) !important;
1165
+ }
1166
+ .preset-row label:hover { border-color: var(--line-bright) !important; color: var(--text) !important; }
1167
+
1168
+ /* Status / runtime line — labeled metric chips after generation */
1169
+ .status {
1170
+ padding: 14px 0 6px !important;
1171
+ font-family: var(--font-sans) !important;
1172
+ font-size: 11px !important;
1173
+ color: var(--text-2) !important;
1174
+ margin: 0 !important;
1175
+ border-top: 1px dashed var(--line);
1176
+ margin-top: 6px !important;
1177
+ }
1178
+
1179
+ /* When status text is replaced with structured HTML chips (post-generation) */
1180
+ .status .stat-line {
1181
+ display: flex; flex-wrap: wrap; align-items: center;
1182
+ gap: 8px 10px;
1183
+ row-gap: 8px;
1184
+ }
1185
+ .status .stat-mark {
1186
+ display: inline-block; width: 7px; height: 7px; border-radius: 50%;
1187
+ background: var(--green); box-shadow: 0 0 6px var(--green-glow);
1188
+ }
1189
+ .status .stat-label {
1190
+ font-family: var(--font-sans); font-size: 12px;
1191
+ color: var(--text); font-weight: 500;
1192
+ }
1193
+ .status .stat-chip {
1194
+ display: inline-flex; align-items: baseline; gap: 6px;
1195
+ padding: 3px 8px;
1196
+ border: 1px solid var(--line);
1197
+ background: rgba(140, 180, 240, 0.04);
1198
+ font-family: var(--font-mono); font-size: 10px;
1199
+ white-space: nowrap;
1200
+ }
1201
+ .status .stat-k { color: var(--muted); letter-spacing: 0.12em; text-transform: uppercase; }
1202
+ .status .stat-v { color: var(--text); font-feature-settings: "tnum", "zero"; }
1203
+ .status .stat-err { color: var(--warn); }
1204
+
1205
+ /* License banner — MR */
1206
+ .license-banner {
1207
+ margin: 0 0 28px !important;
1208
+ padding: 14px 18px 16px;
1209
+ border: 1px solid rgba(246, 200, 97, 0.25);
1210
+ border-left: 3px solid var(--warn);
1211
+ background: var(--warn-soft);
1212
+ color: var(--warn);
1213
+ font-family: var(--font-sans);
1214
+ font-size: 12px;
1215
+ line-height: 1.5;
1216
+ border-radius: 0;
1217
+ }
1218
+ .license-banner strong {
1219
+ display: inline-block;
1220
+ margin-right: 8px;
1221
+ font-family: var(--font-mono);
1222
+ letter-spacing: 0.14em;
1223
+ text-transform: uppercase;
1224
+ font-size: 10px;
1225
+ font-weight: 600;
1226
+ }
1227
+ .license-banner a { color: #ffe9a3; text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 2px; }
1228
+
1229
+ /* ─────────────── empty viewport: 2x2 wireframe preview of what's coming ─────────────── */
1230
+ .nv-empty {
1231
+ position: relative;
1232
+ width: 100%; aspect-ratio: 1/1; max-height: 720px;
1233
+ background:
1234
+ radial-gradient(circle at 50% 50%, rgba(95, 180, 255, 0.06) 0%, transparent 55%),
1235
+ var(--bg-1);
1236
+ color: var(--muted);
1237
+ overflow: hidden;
1238
+ }
1239
+ /* The 2x2 grid showing where each MPR pane will render after generation */
1240
+ .nv-empty-wireframe {
1241
+ position: absolute; inset: 14px;
1242
+ display: grid;
1243
+ grid-template-columns: 1fr 1fr;
1244
+ grid-template-rows: 1fr 1fr;
1245
+ gap: 14px;
1246
+ }
1247
+ .nv-wireq {
1248
+ position: relative;
1249
+ border: 1px dashed rgba(140, 180, 240, 0.18);
1250
+ background:
1251
+ radial-gradient(circle at 50% 50%, rgba(140, 180, 240, 0.025), transparent 70%);
1252
+ display: flex; align-items: flex-end; justify-content: flex-start;
1253
+ padding: 10px 12px;
1254
+ }
1255
+ .nv-wireq span {
1256
+ font-family: var(--font-mono); font-size: 9.5px;
1257
+ letter-spacing: 0.20em; text-transform: uppercase;
1258
+ color: rgba(140, 180, 240, 0.55);
1259
+ }
1260
+ /* Faint crosshair at the center of each pane to suggest the MPR cursor */
1261
+ .nv-wireq::before, .nv-wireq::after {
1262
+ content: ""; position: absolute;
1263
+ background: rgba(140, 180, 240, 0.18);
1264
+ }
1265
+ .nv-wireq::before { width: 1px; height: 28px; left: 50%; top: 50%; transform: translate(-50%, -50%); }
1266
+ .nv-wireq::after { width: 28px; height: 1px; left: 50%; top: 50%; transform: translate(-50%, -50%); }
1267
+ /* 3D pane gets a different treatment: an isometric box hint */
1268
+ .nv-wireq-3d::before, .nv-wireq-3d::after { display: none; }
1269
+ .nv-wireq-3d {
1270
+ background-image:
1271
+ linear-gradient(135deg, transparent 48%, rgba(140, 180, 240, 0.22) 49%, rgba(140, 180, 240, 0.22) 51%, transparent 52%),
1272
+ linear-gradient(45deg, transparent 48%, rgba(140, 180, 240, 0.10) 49%, rgba(140, 180, 240, 0.10) 51%, transparent 52%),
1273
+ radial-gradient(circle at 50% 50%, rgba(140, 180, 240, 0.04), transparent 70%);
1274
+ background-size: 22px 22px, 22px 22px, auto;
1275
+ background-position: center, center, center;
1276
+ }
1277
+ .nv-empty-text {
1278
+ position: absolute; left: 50%; top: 50%;
1279
+ transform: translate(-50%, -50%);
1280
+ text-align: center;
1281
+ padding: 12px 18px;
1282
+ background: linear-gradient(180deg, rgba(10, 21, 48, 0.92), rgba(10, 21, 48, 0.82));
1283
+ border: 1px solid var(--line);
1284
+ backdrop-filter: blur(6px);
1285
+ z-index: 2;
1286
+ min-width: 280px;
1287
+ }
1288
+ .nv-empty-icon {
1289
+ font-family: var(--font-sans);
1290
+ font-size: 13px;
1291
+ font-weight: 500;
1292
+ color: var(--text);
1293
+ margin-bottom: 6px;
1294
+ }
1295
+ .nv-empty-msg {
1296
+ font-family: var(--font-sans);
1297
+ font-size: 11.5px;
1298
+ color: var(--muted);
1299
+ max-width: 280px; text-align: center;
1300
+ line-height: 1.5;
1301
+ }
1302
+
1303
+ /* ─────────────── anatomy legend ─────────────── */
1304
+ /* Gradio applies elem_classes to both the outer .block wrapper AND the inner
1305
+ .prose container — so .legend-host appears on two nested elements. Scope
1306
+ the panel chrome to the outer .block one only and reset the inner copy. */
1307
+ .legend-host,
1308
+ .prose.legend-host {
1309
+ padding: 0 !important;
1310
+ background: transparent !important;
1311
+ border: 0 !important;
1312
+ margin: 0 !important;
1313
+ max-width: none !important;
1314
+ }
1315
+ .block.legend-host {
1316
+ padding: 16px 18px !important;
1317
+ background: var(--panel) !important;
1318
+ border: 1px solid var(--line) !important;
1319
+ margin: 0 0 14px !important;
1320
+ }
1321
+ .nv-legend { padding: 0; }
1322
+ .nv-legend-title {
1323
+ font-family: var(--font-mono);
1324
+ font-size: 10px;
1325
+ font-weight: 500;
1326
+ letter-spacing: 0.22em;
1327
+ text-transform: uppercase;
1328
+ color: var(--green);
1329
+ margin-bottom: 12px;
1330
+ display: flex; align-items: center; gap: 10px;
1331
+ }
1332
+ .nv-legend-title::before { content: ""; width: 4px; height: 4px; background: var(--green); }
1333
+ .nv-legend-count {
1334
+ font-size: 9px;
1335
+ color: var(--muted);
1336
+ border: 1px solid var(--line);
1337
+ padding: 1px 7px;
1338
+ letter-spacing: 0.10em;
1339
+ margin-left: 4px;
1340
+ }
1341
+ .nv-legend-grid { display: flex; flex-wrap: wrap; gap: 6px; }
1342
+ .nv-legend-chip {
1343
+ display: flex; align-items: center; gap: 8px;
1344
+ padding: 5px 10px;
1345
+ background: var(--bg-1);
1346
+ border: 1px solid var(--line);
1347
+ border-radius: 0;
1348
+ font-family: var(--font-mono); font-size: 10.5px;
1349
+ color: var(--text-2);
1350
+ letter-spacing: 0.02em;
1351
+ text-transform: lowercase;
1352
+ }
1353
+ .nv-swatch { width: 9px; height: 9px; border-radius: 0; display: inline-block; }
1354
+
1355
+ /* Download file widget — self-contained card sitting below the legend with
1356
+ visible breathing room above it. Solid border matches the legend / preset
1357
+ pattern instead of dashed (which read as "drop zone" for a finished file). */
1358
+ .nv-download {
1359
+ background: var(--panel) !important;
1360
+ border: 1px solid var(--line) !important;
1361
+ border-radius: 0 !important;
1362
+ margin: 14px 0 0 !important;
1363
+ padding: 4px !important;
1364
+ }
1365
+ .nv-download .gr-file,
1366
+ .nv-download [class*="file"] {
1367
+ background: transparent !important;
1368
+ border: 0 !important;
1369
+ border-radius: 0 !important;
1370
+ }
1371
+ .nv-download button[aria-label*="Remove" i],
1372
+ .nv-download button[aria-label*="Clear" i],
1373
+ .nv-download button[title*="Remove" i],
1374
+ .nv-download button[title*="Clear" i],
1375
+ .nv-download .delete-btn,
1376
+ .nv-download .clear-button,
1377
+ .nv-download .icon-button[aria-label*="x" i] {
1378
+ display: none !important;
1379
+ }
1380
+ /* Override the label heading inside the file widget so it matches our type system */
1381
+ .nv-download label,
1382
+ .nv-download [data-testid="file-label"] {
1383
+ font-family: var(--font-mono) !important;
1384
+ font-size: 10px !important;
1385
+ letter-spacing: 0.18em !important;
1386
+ text-transform: uppercase !important;
1387
+ color: var(--muted) !important;
1388
+ padding: 8px 12px !important;
1389
+ }
1390
+
1391
+ /* Hide Gradio footer */
1392
+ footer { display: none !important; }
1393
+
1394
+ /* ─────────────── credits strip (two-line: papers + affiliations) ─────────────── */
1395
+ .credits-strip {
1396
+ display: flex; flex-direction: column; align-items: center; gap: 8px;
1397
+ padding: 28px 0 8px;
1398
+ margin-top: 8px;
1399
+ border-top: 1px solid var(--line);
1400
+ font-family: var(--font-mono); font-size: 9.5px;
1401
+ letter-spacing: 0.22em; text-transform: uppercase;
1402
+ color: var(--muted);
1403
+ animation: fadein 0.6s ease 0.6s both;
1404
+ }
1405
+ .credits-line {
1406
+ display: flex; justify-content: center; flex-wrap: wrap; gap: 12px;
1407
+ }
1408
+ .credits-strip .credits-sep { color: var(--muted-2); opacity: 0.6; }
1409
+ .credits-strip .credits-strong { color: var(--text-2); }
1410
+ .credits-strip a { color: inherit; text-decoration: none; transition: color 160ms ease; }
1411
+ .credits-strip a:hover { color: var(--green); }
1412
+ .credits-papers a.credits-strong { color: var(--text); }
1413
+ .credits-affil { font-size: 9px; opacity: 0.85; }
1414
+
1415
+ /* (Intentionally no position:relative on gradio-app or .gradio-container —
1416
+ Gradio's dropdown popup uses position:absolute calculated relative to the
1417
+ nearest positioned ancestor, and adding extra positioned wrappers makes the
1418
+ popup snap to the wrong containing block.) */
1419
+
1420
+ /* Subtle scrollbar */
1421
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
1422
+ ::-webkit-scrollbar-track { background: var(--bg-1); }
1423
+ ::-webkit-scrollbar-thumb { background: var(--line-strong); }
1424
+ ::-webkit-scrollbar-thumb:hover { background: var(--line-bright); }
1425
+ """
1426
+
1427
+
1428
+ def build_app() -> gr.Blocks:
1429
+ with gr.Blocks(title="NV-Generate") as app:
1430
+ hero_group, hero_buttons = render_hero()
1431
+ ct_group, ct_back = workspace_ct.build(spaces_gpu_ct)
1432
+ mr_group, mr_back = workspace_mr.build(spaces_gpu_mr)
1433
+ mrb_group, mrb_back = workspace_mr_brain.build(spaces_gpu_mr_brain)
1434
+
1435
+ def _show(active: str):
1436
+ return (
1437
+ gr.update(visible=(active == "home")),
1438
+ gr.update(visible=(active == "ct")),
1439
+ gr.update(visible=(active == "mr")),
1440
+ gr.update(visible=(active == "mr_brain")),
1441
+ )
1442
+
1443
+ outputs = [hero_group, ct_group, mr_group, mrb_group]
1444
+ hero_buttons["ct"].click(lambda: _show("ct"), outputs=outputs)
1445
+ hero_buttons["mr"].click(lambda: _show("mr"), outputs=outputs)
1446
+ hero_buttons["mr_brain"].click(lambda: _show("mr_brain"), outputs=outputs)
1447
+ ct_back.click(lambda: _show("home"), outputs=outputs)
1448
+ mr_back.click(lambda: _show("home"), outputs=outputs)
1449
+ mrb_back.click(lambda: _show("home"), outputs=outputs)
1450
+
1451
+ return app
1452
+
1453
+
1454
+ if __name__ == "__main__":
1455
+ app = build_app()
1456
+ app.launch(
1457
+ server_name="0.0.0.0",
1458
+ server_port=int(os.environ.get("PORT", "7860")),
1459
+ show_error=True,
1460
+ css=CSS,
1461
+ theme=gr.themes.Base(),
1462
+ )
pipelines/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .base import GenerationRequest, GenerationResult
2
+
3
+ __all__ = ["GenerationRequest", "GenerationResult"]
pipelines/_runner.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared driver for the upstream NV-Generate-CTMR scripts.
3
+
4
+ Strategy: the upstream code is structured around argparse + global filesystem layout
5
+ (reads relative-path configs, writes to a relative output_dir). Rather than refactor
6
+ its internals, we treat it as an external tool: chdir into the upstream root, write
7
+ modified config copies that override the user-controlled fields, ensure weights are
8
+ downloaded, then call the upstream entry-point function. We then return the path of
9
+ the most recently produced NIfTI from the configured output dir.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import importlib
15
+ import json
16
+ import os
17
+ import sys
18
+ import time
19
+ import uuid
20
+ from pathlib import Path
21
+ from typing import Iterable, Optional
22
+
23
+ ROOT = Path(__file__).resolve().parent.parent
24
+ UPSTREAM = ROOT / "repos" / "NV-Generate-CTMR"
25
+ GENERATED_OUTPUT = UPSTREAM / "output"
26
+
27
+
28
+ @contextlib.contextmanager
29
+ def upstream_context():
30
+ """Temporarily add the upstream repo to sys.path and switch CWD to it."""
31
+ if not UPSTREAM.exists():
32
+ raise RuntimeError(
33
+ f"Upstream repo not found at {UPSTREAM}. Run `bash pre-build.sh` first."
34
+ )
35
+ prev_cwd = os.getcwd()
36
+ added = False
37
+ try:
38
+ upstream_str = str(UPSTREAM)
39
+ if upstream_str not in sys.path:
40
+ sys.path.insert(0, upstream_str)
41
+ added = True
42
+ os.chdir(upstream_str)
43
+ yield UPSTREAM
44
+ finally:
45
+ os.chdir(prev_cwd)
46
+ if added and upstream_str in sys.path:
47
+ sys.path.remove(upstream_str)
48
+
49
+
50
+ def ensure_weights(version: str) -> None:
51
+ """Download model weights from HF Hub if not already on disk."""
52
+ with upstream_context():
53
+ download_mod = importlib.import_module("scripts.download_model_data")
54
+ # download_model_data is idempotent — it skips files that already exist.
55
+ download_mod.download_model_data(version, "./", model_only=False if version in ("rflow-ct", "ddpm-ct") else True)
56
+
57
+
58
+ def _list_outputs_before(output_dir: Path) -> set[str]:
59
+ if not output_dir.exists():
60
+ return set()
61
+ return {p.name for p in output_dir.glob("*.nii.gz")}
62
+
63
+
64
+ def _newest_outputs(output_dir: Path, before: set[str]) -> list[Path]:
65
+ if not output_dir.exists():
66
+ return []
67
+ new = [p for p in output_dir.glob("*.nii.gz") if p.name not in before]
68
+ new.sort(key=lambda p: p.stat().st_mtime)
69
+ return new
70
+
71
+
72
+ def _write_temp_configs(
73
+ base_env_config: Path,
74
+ base_model_config: Path,
75
+ overrides: dict,
76
+ tag: str,
77
+ ) -> tuple[Path, Path]:
78
+ """
79
+ Write modified copies of the env + model configs into a per-call temp dir under
80
+ UPSTREAM / configs / _temp /. Returns (env_path, model_path).
81
+ """
82
+ temp_dir = UPSTREAM / "configs" / "_temp"
83
+ temp_dir.mkdir(parents=True, exist_ok=True)
84
+
85
+ env = json.loads(base_env_config.read_text())
86
+ model = json.loads(base_model_config.read_text())
87
+ if "env" in overrides:
88
+ env.update(overrides["env"])
89
+ if "diffusion_unet_inference" in overrides:
90
+ model.setdefault("diffusion_unet_inference", {}).update(overrides["diffusion_unet_inference"])
91
+
92
+ suffix = f"{tag}_{uuid.uuid4().hex[:8]}"
93
+ env_path = temp_dir / f"env_{suffix}.json"
94
+ model_path = temp_dir / f"model_{suffix}.json"
95
+ env_path.write_text(json.dumps(env, indent=2))
96
+ model_path.write_text(json.dumps(model, indent=2))
97
+ return env_path, model_path
98
+
99
+
100
+ def run_image_only(
101
+ *,
102
+ version: str,
103
+ output_size: tuple[int, int, int],
104
+ spacing: tuple[float, float, float],
105
+ modality: int,
106
+ seed: int,
107
+ num_inference_steps: int = 30,
108
+ cfg_guidance_scale: Optional[float] = None,
109
+ ) -> Path:
110
+ """
111
+ Run the image-only diffusion pipeline (`scripts.diff_model_infer`) for the given
112
+ version (rflow-ct / rflow-mr / rflow-mr-brain). Returns path to generated NIfTI.
113
+ """
114
+ ensure_weights(version)
115
+
116
+ base_env = UPSTREAM / "configs" / f"environment_maisi_diff_model_{version}.json"
117
+ base_model = UPSTREAM / "configs" / f"config_maisi_diff_model_{version}.json"
118
+ network_def = UPSTREAM / "configs" / "config_network_rflow.json"
119
+
120
+ inference_overrides = {
121
+ "dim": list(output_size),
122
+ "spacing": list(spacing),
123
+ "modality": modality,
124
+ "random_seed": seed,
125
+ "num_inference_steps": num_inference_steps,
126
+ }
127
+ if cfg_guidance_scale is not None:
128
+ inference_overrides["cfg_guidance_scale"] = cfg_guidance_scale
129
+
130
+ with upstream_context():
131
+ env_path, model_path = _write_temp_configs(
132
+ base_env_config=base_env,
133
+ base_model_config=base_model,
134
+ overrides={"diffusion_unet_inference": inference_overrides},
135
+ tag=version,
136
+ )
137
+
138
+ # Read env to determine output_dir (relative to upstream root)
139
+ env_data = json.loads(env_path.read_text())
140
+ output_dir = (UPSTREAM / env_data["output_dir"]).resolve()
141
+ existing = _list_outputs_before(output_dir)
142
+
143
+ diff_mod = importlib.import_module("scripts.diff_model_infer")
144
+
145
+ t0 = time.time()
146
+ diff_mod.diff_model_infer(
147
+ env_config_path=str(env_path.relative_to(UPSTREAM)),
148
+ model_config_path=str(model_path.relative_to(UPSTREAM)),
149
+ model_def_path=str(network_def.relative_to(UPSTREAM)),
150
+ num_gpus=1,
151
+ )
152
+ runtime = time.time() - t0
153
+
154
+ new_files = _newest_outputs(output_dir, existing)
155
+ if not new_files:
156
+ raise RuntimeError(f"No new NIfTI produced in {output_dir}")
157
+ latest = new_files[-1]
158
+
159
+ # Cleanup temp configs (don't fail if cleanup errors)
160
+ for p in (env_path, model_path):
161
+ try:
162
+ p.unlink()
163
+ except OSError:
164
+ pass
165
+
166
+ return latest
167
+
168
+
169
+ def run_paired_ct(
170
+ *,
171
+ output_size: tuple[int, int, int],
172
+ spacing: tuple[float, float, float],
173
+ body_region: list[str],
174
+ anatomy_list: list[str],
175
+ seed: int,
176
+ num_inference_steps: int = 30,
177
+ num_output_samples: int = 1,
178
+ ) -> tuple[Path, Optional[Path]]:
179
+ """
180
+ Run the paired CT image+mask pipeline (`scripts.inference`). Returns
181
+ (image_path, mask_path). Mask is the corresponding label volume.
182
+ """
183
+ version = "rflow-ct"
184
+ ensure_weights(version)
185
+
186
+ base_env = UPSTREAM / "configs" / f"environment_{version}.json"
187
+ base_infer = UPSTREAM / "configs" / "config_infer.json"
188
+
189
+ # Build a custom config_infer with overrides
190
+ infer_data = json.loads(base_infer.read_text())
191
+ infer_data["output_size"] = list(output_size)
192
+ infer_data["spacing"] = list(spacing)
193
+ infer_data["body_region"] = list(body_region)
194
+ infer_data["anatomy_list"] = list(anatomy_list)
195
+ infer_data["num_inference_steps"] = num_inference_steps
196
+ infer_data["num_output_samples"] = num_output_samples
197
+
198
+ temp_dir = UPSTREAM / "configs" / "_temp"
199
+ temp_dir.mkdir(parents=True, exist_ok=True)
200
+ suffix = uuid.uuid4().hex[:8]
201
+ infer_path = temp_dir / f"config_infer_{version}_{suffix}.json"
202
+ infer_path.write_text(json.dumps(infer_data, indent=2))
203
+
204
+ env_data = json.loads(base_env.read_text())
205
+ output_dir = (UPSTREAM / env_data["output_dir"]).resolve()
206
+
207
+ with upstream_context():
208
+ existing = _list_outputs_before(output_dir)
209
+
210
+ inference_mod = importlib.import_module("scripts.inference")
211
+ # The upstream `main()` parses argv directly. Patch sys.argv around the call.
212
+ old_argv = sys.argv
213
+ sys.argv = [
214
+ "scripts.inference",
215
+ "-t", "./configs/config_network_rflow.json",
216
+ "-i", str(infer_path.relative_to(UPSTREAM)),
217
+ "-e", str(base_env.relative_to(UPSTREAM)),
218
+ "--random-seed", str(seed),
219
+ "--version", version,
220
+ ]
221
+ os.environ.setdefault("MONAI_DATA_DIRECTORY", str(UPSTREAM / "temp_work_dir"))
222
+
223
+ try:
224
+ inference_mod.main()
225
+ finally:
226
+ sys.argv = old_argv
227
+
228
+ new_files = _newest_outputs(output_dir, existing)
229
+
230
+ try:
231
+ infer_path.unlink()
232
+ except OSError:
233
+ pass
234
+
235
+ # Paired pipeline writes both image and label NIfTIs. Convention: filenames
236
+ # contain "image" / "label" or are emitted as adjacent files.
237
+ image_path: Optional[Path] = None
238
+ mask_path: Optional[Path] = None
239
+ for p in new_files:
240
+ name = p.name.lower()
241
+ if "label" in name or "_mask" in name or "seg" in name:
242
+ mask_path = p
243
+ elif "image" in name or "img" in name:
244
+ image_path = p
245
+
246
+ # Fallback: if naming is ambiguous, treat the smaller-modality-time file as image
247
+ if image_path is None and new_files:
248
+ image_path = new_files[0]
249
+ if mask_path is None and len(new_files) > 1:
250
+ mask_path = new_files[-1]
251
+
252
+ if image_path is None:
253
+ raise RuntimeError(f"No NIfTI produced in {output_dir}")
254
+ return image_path, mask_path
255
+
256
+
257
+ def labels_present(mask_path: Path) -> set[int]:
258
+ """Return the set of unique non-zero label IDs present in the mask volume."""
259
+ import nibabel as nib
260
+ import numpy as np
261
+
262
+ img = nib.load(str(mask_path))
263
+ data = np.asarray(img.dataobj)
264
+ uniq = np.unique(data).astype(int).tolist()
265
+ return {int(u) for u in uniq if u != 0}
pipelines/base.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Literal, Optional
5
+
6
+ ModelKey = Literal["ct", "mr", "mr_brain"]
7
+
8
+
9
+ @dataclass
10
+ class GenerationRequest:
11
+ model: ModelKey
12
+ output_size: tuple[int, int, int]
13
+ spacing: tuple[float, float, float]
14
+ seed: int = 0
15
+ num_steps: int = 30
16
+ cfg_guidance_scale: float = 0.0
17
+
18
+ # CT-only
19
+ body_region: Optional[list[str]] = None
20
+ anatomy_list: Optional[list[str]] = None
21
+ generate_masks: bool = True
22
+
23
+ # MR-only (modality int from configs/modality_mapping.json: 8/9/10/11/...)
24
+ modality_class: Optional[int] = None
25
+
26
+ # MR-Brain-only (mapped to a modality int)
27
+ contrast: Optional[str] = None # "T1" / "T2" / "FLAIR" / "SWI" / "T1_skull_stripped" / ...
28
+
29
+
30
+ @dataclass
31
+ class GenerationResult:
32
+ volume_path: str
33
+ mask_path: Optional[str] = None
34
+ used_anatomy_labels: dict[int, str] = field(default_factory=dict)
35
+ runtime_seconds: float = 0.0
36
+ seed: int = 0
37
+ modality: Optional[int] = None
pipelines/ct.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ from .base import GenerationRequest, GenerationResult
6
+ from . import _runner
7
+
8
+
9
+ def generate(req: GenerationRequest) -> GenerationResult:
10
+ """CT generation. With masks: paired pipeline. Without: image-only."""
11
+ t0 = time.time()
12
+ if req.generate_masks:
13
+ image_path, mask_path = _runner.run_paired_ct(
14
+ output_size=req.output_size,
15
+ spacing=req.spacing,
16
+ body_region=req.body_region or [],
17
+ anatomy_list=req.anatomy_list or ["liver"],
18
+ seed=req.seed,
19
+ num_inference_steps=req.num_steps,
20
+ )
21
+ used: dict[int, str] = {}
22
+ if mask_path is not None:
23
+ from viewer.colormaps import CT_LABELS_BY_ID
24
+
25
+ present = _runner.labels_present(mask_path)
26
+ used = {lbl: CT_LABELS_BY_ID.get(lbl, f"label {lbl}") for lbl in sorted(present)}
27
+ return GenerationResult(
28
+ volume_path=str(image_path),
29
+ mask_path=str(mask_path) if mask_path else None,
30
+ used_anatomy_labels=used,
31
+ runtime_seconds=time.time() - t0,
32
+ seed=req.seed,
33
+ modality=1,
34
+ )
35
+ else:
36
+ path = _runner.run_image_only(
37
+ version="rflow-ct",
38
+ output_size=req.output_size,
39
+ spacing=req.spacing,
40
+ modality=1,
41
+ seed=req.seed,
42
+ num_inference_steps=req.num_steps,
43
+ )
44
+ return GenerationResult(
45
+ volume_path=str(path),
46
+ runtime_seconds=time.time() - t0,
47
+ seed=req.seed,
48
+ modality=1,
49
+ )
pipelines/mr.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ from .base import GenerationRequest, GenerationResult
6
+ from . import _runner
7
+
8
+
9
+ def generate(req: GenerationRequest) -> GenerationResult:
10
+ """MR (rflow-mr) — image-only across multiple contrasts and anatomies."""
11
+ if req.modality_class is None:
12
+ raise ValueError("MR generation requires modality_class (8=mri, 9=t1, 10=t2, 11=flair, ...)")
13
+ t0 = time.time()
14
+ path = _runner.run_image_only(
15
+ version="rflow-mr",
16
+ output_size=req.output_size,
17
+ spacing=req.spacing,
18
+ modality=req.modality_class,
19
+ seed=req.seed,
20
+ num_inference_steps=req.num_steps,
21
+ cfg_guidance_scale=req.cfg_guidance_scale or 15.0,
22
+ )
23
+ return GenerationResult(
24
+ volume_path=str(path),
25
+ runtime_seconds=time.time() - t0,
26
+ seed=req.seed,
27
+ modality=req.modality_class,
28
+ )
pipelines/mr_brain.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ from .base import GenerationRequest, GenerationResult
6
+ from . import _runner
7
+
8
+ CONTRAST_TO_MODALITY: dict[str, int] = {
9
+ "T1": 9,
10
+ "T2": 10,
11
+ "FLAIR": 11,
12
+ "SWI": 20,
13
+ "T1_skull_stripped": 29,
14
+ "T2_skull_stripped": 30,
15
+ "FLAIR_skull_stripped": 31,
16
+ "SWI_skull_stripped": 32,
17
+ }
18
+
19
+
20
+ def generate(req: GenerationRequest) -> GenerationResult:
21
+ """MR Brain (rflow-mr-brain) — image-only multi-contrast brain MRI."""
22
+ contrast = req.contrast or "T1"
23
+ if contrast not in CONTRAST_TO_MODALITY:
24
+ raise ValueError(f"Unsupported contrast: {contrast}. Choose from {list(CONTRAST_TO_MODALITY)}")
25
+ modality = CONTRAST_TO_MODALITY[contrast]
26
+
27
+ t0 = time.time()
28
+ path = _runner.run_image_only(
29
+ version="rflow-mr-brain",
30
+ output_size=req.output_size,
31
+ spacing=req.spacing,
32
+ modality=modality,
33
+ seed=req.seed,
34
+ num_inference_steps=req.num_steps,
35
+ cfg_guidance_scale=req.cfg_guidance_scale or 10.0,
36
+ )
37
+ return GenerationResult(
38
+ volume_path=str(path),
39
+ runtime_seconds=time.time() - t0,
40
+ seed=req.seed,
41
+ modality=modality,
42
+ )
pre-build.sh ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Run at HF Spaces build time (or locally before first run) to pull upstream code.
3
+ set -euo pipefail
4
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ mkdir -p "${ROOT}/repos"
6
+ if [ ! -d "${ROOT}/repos/NV-Generate-CTMR" ]; then
7
+ git clone --depth 1 https://github.com/NVIDIA-Medtech/NV-Generate-CTMR.git "${ROOT}/repos/NV-Generate-CTMR"
8
+ fi
9
+ echo "Upstream repo ready at ${ROOT}/repos/NV-Generate-CTMR"
requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Gradio app
2
+ gradio>=5.0
3
+ spaces # ZeroGPU decorator (no-op locally)
4
+
5
+ # Upstream NV-Generate-CTMR dependencies
6
+ torch>=2.1.0
7
+ monai>=1.5.0
8
+ numpy>=1.24.0
9
+ scipy>=1.10.0
10
+ scikit-image>=0.20.0
11
+ nibabel>=5.0.0
12
+ matplotlib>=3.7.0
13
+ einops>=0.7.0
14
+ huggingface_hub>=0.20.0
15
+ tqdm>=4.65.0
16
+ fire>=0.5.0
17
+ PyYAML>=6.0
ui/__init__.py ADDED
File without changes
ui/hero.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hero / landing page — NV-Generate masthead + three equal-height datasheet cards."""
2
+ from __future__ import annotations
3
+
4
+ import gradio as gr
5
+
6
+ # SVG modality glyphs — each is a recognizable anatomical silhouette of the modality.
7
+ # CT: axial body cross-section with spine and ribs.
8
+ # MR: a k-space spiral trajectory.
9
+ # MR-Brain: an axial brain outline with hemispheres.
10
+ SVG_CT = """
11
+ <svg viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
12
+ <!-- Axial body cross-section silhouette -->
13
+ <ellipse cx="40" cy="44" rx="30" ry="24" fill="none" stroke="currentColor" stroke-width="1.2"/>
14
+ <!-- Lungs (left & right) -->
15
+ <path d="M22 38 Q20 50 26 56 Q32 54 33 44 Q33 36 28 34 Q24 34 22 38 Z"
16
+ fill="none" stroke="currentColor" stroke-width="0.9" stroke-opacity="0.7"/>
17
+ <path d="M58 38 Q60 50 54 56 Q48 54 47 44 Q47 36 52 34 Q56 34 58 38 Z"
18
+ fill="none" stroke="currentColor" stroke-width="0.9" stroke-opacity="0.7"/>
19
+ <!-- Spine vertebra -->
20
+ <ellipse cx="40" cy="56" rx="5" ry="4" fill="none" stroke="currentColor" stroke-width="0.9"/>
21
+ <circle cx="40" cy="56" r="1.5" fill="currentColor" opacity="0.6"/>
22
+ <!-- Calibration ticks -->
23
+ <line x1="4" y1="44" x2="10" y2="44" stroke="currentColor" stroke-width="0.6" stroke-opacity="0.6"/>
24
+ <line x1="70" y1="44" x2="76" y2="44" stroke="currentColor" stroke-width="0.6" stroke-opacity="0.6"/>
25
+ <line x1="40" y1="14" x2="40" y2="20" stroke="currentColor" stroke-width="0.6" stroke-opacity="0.6"/>
26
+ <line x1="40" y1="68" x2="40" y2="74" stroke="currentColor" stroke-width="0.6" stroke-opacity="0.6"/>
27
+ </svg>
28
+ """
29
+
30
+ SVG_MR = """
31
+ <svg viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
32
+ <!-- k-space spiral trajectory: concentric arcs forming an inward spiral -->
33
+ <circle cx="40" cy="40" r="6" fill="none" stroke="currentColor" stroke-width="1" stroke-opacity="1.0"/>
34
+ <circle cx="40" cy="40" r="12" fill="none" stroke="currentColor" stroke-width="1" stroke-opacity="0.8" stroke-dasharray="6 1"/>
35
+ <circle cx="40" cy="40" r="18" fill="none" stroke="currentColor" stroke-width="1" stroke-opacity="0.6" stroke-dasharray="8 2"/>
36
+ <circle cx="40" cy="40" r="24" fill="none" stroke="currentColor" stroke-width="1" stroke-opacity="0.4" stroke-dasharray="10 3"/>
37
+ <circle cx="40" cy="40" r="30" fill="none" stroke="currentColor" stroke-width="0.8" stroke-opacity="0.25" stroke-dasharray="12 4"/>
38
+ <!-- Radial sampling line from center outward -->
39
+ <line x1="40" y1="40" x2="62" y2="22" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
40
+ <circle cx="40" cy="40" r="1.8" fill="currentColor"/>
41
+ <circle cx="62" cy="22" r="1.5" fill="currentColor" opacity="0.6"/>
42
+ </svg>
43
+ """
44
+
45
+ SVG_MRB = """
46
+ <svg viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
47
+ <!-- Axial brain silhouette with hemispheres -->
48
+ <path d="M40 14
49
+ C28 14 18 22 16 34
50
+ C14 44 18 56 30 62
51
+ L33 62
52
+ C24 56 22 48 24 38
53
+ C26 28 32 22 40 22
54
+ C48 22 54 28 56 38
55
+ C58 48 56 56 47 62
56
+ L50 62
57
+ C62 56 66 44 64 34
58
+ C62 22 52 14 40 14 Z"
59
+ fill="none" stroke="currentColor" stroke-width="1.2"/>
60
+ <!-- Midline (longitudinal fissure) -->
61
+ <line x1="40" y1="16" x2="40" y2="62" stroke="currentColor" stroke-width="0.9" stroke-opacity="0.55"/>
62
+ <!-- Gyrus / sulcus hints, left & right -->
63
+ <path d="M28 28 Q34 32 32 38" fill="none" stroke="currentColor" stroke-width="0.6" stroke-opacity="0.5"/>
64
+ <path d="M52 28 Q46 32 48 38" fill="none" stroke="currentColor" stroke-width="0.6" stroke-opacity="0.5"/>
65
+ <path d="M28 44 Q34 48 32 54" fill="none" stroke="currentColor" stroke-width="0.6" stroke-opacity="0.5"/>
66
+ <path d="M52 44 Q46 48 48 54" fill="none" stroke="currentColor" stroke-width="0.6" stroke-opacity="0.5"/>
67
+ </svg>
68
+ """
69
+
70
+ # Each card has exactly 5 spec rows, each value short enough to render on one line.
71
+ CARDS = [
72
+ {
73
+ "key": "ct",
74
+ "index": "01",
75
+ "title": "NV-Generate · CT",
76
+ "subtitle": "Whole-body synthetic CT with paired anatomy masks.",
77
+ "icon": SVG_CT,
78
+ "icon_caption": "Body region · paired anatomy",
79
+ # Consistent category: USE CASES across all three cards.
80
+ "uses": ["Segmentation data", "Pathology augmentation", "Privacy-preserving sharing"],
81
+ "license": "NVIDIA Open Model",
82
+ "license_tone": "ok",
83
+ "spec": [
84
+ ("MODALITY", "Computed Tomography"),
85
+ ("ARCHITECTURE", "MAISI-v2 · Rectified Flow"),
86
+ ("MAX VOLUME", "512 × 512 × 768 vox"),
87
+ ("SEGMENTATION", "132 classes · paired"),
88
+ ("LICENSE", "NVIDIA Open Model"),
89
+ ],
90
+ "accent_class": "ct",
91
+ },
92
+ {
93
+ "key": "mr",
94
+ "index": "02",
95
+ "title": "NV-Generate · MR",
96
+ "subtitle": "Multi-contrast, multi-anatomy synthetic MRI.",
97
+ "icon": SVG_MR,
98
+ "icon_caption": "Multi-contrast · multi-region",
99
+ "uses": ["Modality augmentation", "Cross-region training", "Fine-tune base"],
100
+ "license": "NVIDIA Non-Commercial",
101
+ "license_tone": "warn",
102
+ "spec": [
103
+ ("MODALITY", "Magnetic Resonance"),
104
+ ("ARCHITECTURE", "MAISI-v2 · Rectified Flow"),
105
+ ("MAX VOLUME", "512 × 512 × 128 vox"),
106
+ ("CONTRASTS", "T1 · T2 · FLAIR · multi-region"),
107
+ ("LICENSE", "NVIDIA Non-Commercial"),
108
+ ],
109
+ "accent_class": "mr",
110
+ },
111
+ {
112
+ "key": "mr_brain",
113
+ "index": "03",
114
+ "title": "NV-Generate · MR Brain",
115
+ "subtitle": "High-resolution multi-contrast brain MRI.",
116
+ "icon": SVG_MRB,
117
+ "icon_caption": "Brain · multi-sequence",
118
+ "uses": ["Brain segmentation data", "Lesion augmentation", "Sequence translation"],
119
+ "license": "NVIDIA Open Model",
120
+ "license_tone": "ok",
121
+ "spec": [
122
+ ("MODALITY", "MR · Brain"),
123
+ ("ARCHITECTURE", "MAISI-v2 · Rectified Flow"),
124
+ ("MAX VOLUME", "512 × 512 × 256 vox"),
125
+ ("SEQUENCES", "T1 · T2 · FLAIR · SWI"),
126
+ ("LICENSE", "NVIDIA Open Model"),
127
+ ],
128
+ "accent_class": "mrb",
129
+ },
130
+ ]
131
+
132
+
133
+ def _spec_rows(spec: list[tuple[str, str]]) -> str:
134
+ rows = []
135
+ for k, v in spec:
136
+ rows.append(
137
+ f'<div class="ds-row"><span class="ds-key">{k}</span>'
138
+ f'<span class="ds-leader"></span>'
139
+ f'<span class="ds-val">{v}</span></div>'
140
+ )
141
+ return "".join(rows)
142
+
143
+
144
+ def render_hero() -> tuple[gr.Group, dict[str, gr.Button]]:
145
+ """Build the hero. Returns the wrapping Group and a dict of {key: button}."""
146
+ buttons: dict[str, gr.Button] = {}
147
+ with gr.Group(elem_classes=["hero"]) as group:
148
+ # Masthead: brand on the left, real links on the right. No decorative pills.
149
+ gr.HTML(
150
+ """
151
+ <div class="masthead">
152
+ <div class="masthead-brand">
153
+ <span class="nv-mark">NV</span>
154
+ <span class="masthead-name">NV-Generate</span>
155
+ </div>
156
+ <nav class="masthead-nav">
157
+ <a href="https://github.com/NVIDIA-Medtech/NV-Generate-CTMR" target="_blank" rel="noopener">GitHub</a>
158
+ <span class="masthead-sep"></span>
159
+ <a href="https://huggingface.co/nvidia/NV-Generate-CT" target="_blank" rel="noopener">Hugging Face</a>
160
+ <span class="masthead-sep"></span>
161
+ <a href="https://arxiv.org/abs/2508.05772" target="_blank" rel="noopener">MAISI-v2 paper</a>
162
+ </nav>
163
+ </div>
164
+ """
165
+ )
166
+
167
+ # Hero headline — eyebrow + bold title, no marketing paragraph below.
168
+ gr.HTML(
169
+ """
170
+ <div class="hero-mono">
171
+ <div class="hero-eyebrow">
172
+ <span class="line-tick"></span>
173
+ <span>Latent diffusion · MAISI-v2 · open weights</span>
174
+ </div>
175
+ <h1 class="hero-title">
176
+ Synthetic 3D medical imaging.
177
+ </h1>
178
+ </div>
179
+ """
180
+ )
181
+
182
+ # Datasheet cards — three equal-height subsystems
183
+ with gr.Row(elem_classes=["hero-row"], equal_height=True):
184
+ for card in CARDS:
185
+ with gr.Column(scale=1, min_width=300, elem_classes=["hero-card-col"]):
186
+ uses_html = "".join(
187
+ f'<span class="ds-use">{u}</span>' for u in card.get("uses", [])
188
+ )
189
+ license_tone_cls = "ds-license-warn" if card.get("license_tone") == "warn" else "ds-license-ok"
190
+ gr.HTML(
191
+ f"""
192
+ <div class="ds-card ds-{card['accent_class']}">
193
+ <div class="ds-corner ds-tl"></div>
194
+ <div class="ds-corner ds-tr"></div>
195
+ <div class="ds-corner ds-bl"></div>
196
+ <div class="ds-corner ds-br"></div>
197
+
198
+ <div class="ds-banner">
199
+ <div class="ds-banner-grid"></div>
200
+ <div class="ds-banner-icon">{card['icon']}</div>
201
+ <div class="ds-banner-caption">{card['icon_caption']}</div>
202
+ </div>
203
+
204
+ <div class="ds-frame">
205
+ <div class="ds-head">
206
+ <div class="ds-index">{card['index']}</div>
207
+ <div class="ds-status">
208
+ <span class="dot dot-pulse"></span>Available
209
+ </div>
210
+ </div>
211
+
212
+ <div class="ds-name">
213
+ <div class="ds-name-title">{card['title']}</div>
214
+ <div class="ds-name-sub">{card['subtitle']}</div>
215
+ </div>
216
+
217
+ <div class="ds-uses-label">Use cases</div>
218
+ <div class="ds-uses">{uses_html}</div>
219
+
220
+ <div class="ds-divider"></div>
221
+
222
+ <div class="ds-spec">
223
+ {_spec_rows(card['spec'])}
224
+ </div>
225
+
226
+ <div class="ds-license {license_tone_cls}">
227
+ <span class="ds-license-k">License</span>
228
+ <span class="ds-license-v">{card['license']}</span>
229
+ </div>
230
+ </div>
231
+ </div>
232
+ """
233
+ )
234
+ btn = gr.Button(
235
+ f"Launch {card['title'].replace(' · ', ' ')} →",
236
+ elem_classes=["ds-cta", f"ds-cta-{card['accent_class']}"],
237
+ )
238
+ buttons[card["key"]] = btn
239
+
240
+ return group, buttons
ui/presets.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """UI preset definitions: anatomy choices, sample-input chips, body regions."""
2
+ from __future__ import annotations
3
+
4
+ # Body regions accepted by the upstream CT pipeline (input to LDMSampler).
5
+ CT_BODY_REGIONS = ["head", "chest", "thorax", "abdomen", "pelvis", "lower"]
6
+
7
+ # Controllable anatomies for paired CT generation. Subset of label_dict.json that
8
+ # the upstream pipeline knows how to condition on (per docs/inference.md).
9
+ CT_ANATOMY_CHOICES = [
10
+ "liver",
11
+ "spleen",
12
+ "pancreas",
13
+ "right kidney",
14
+ "left kidney",
15
+ "gallbladder",
16
+ "stomach",
17
+ "duodenum",
18
+ "bladder",
19
+ "colon",
20
+ "small bowel",
21
+ "left lung upper lobe",
22
+ "left lung lower lobe",
23
+ "right lung upper lobe",
24
+ "right lung middle lobe",
25
+ "right lung lower lobe",
26
+ "lung tumor",
27
+ "hepatic tumor",
28
+ "pancreatic tumor",
29
+ "bone lesion",
30
+ ]
31
+
32
+ # MR contrast options exposed in the dropdown (maps to modality int)
33
+ MR_CONTRAST_CHOICES = [
34
+ ("T1 brain", 9),
35
+ ("T2 brain", 10),
36
+ ("FLAIR (skull-stripped brain)", 11),
37
+ ("T2 prostate", 10),
38
+ ("T1 breast", 9),
39
+ ("T1 abdomen", 9),
40
+ ("T2 abdomen", 10),
41
+ ("Generic MRI", 8),
42
+ ]
43
+
44
+ MR_BRAIN_CONTRASTS = ["T1", "T2", "FLAIR", "SWI", "T1_skull_stripped", "T2_skull_stripped", "FLAIR_skull_stripped", "SWI_skull_stripped"]
45
+
46
+ # Paired CT requires X==Y from {256, 384, 512} and Z from {128, 256, 384, 512, 640, 768}.
47
+ # We expose 256 and 384 by default (skipping 512 to stay within ~70 GB VRAM).
48
+ XY_CHOICES = [256, 384]
49
+ Z_CHOICES = [128, 256, 384]
50
+
51
+
52
+ CT_SAMPLES = [
53
+ {"label": "Abdomen + liver", "body_region": ["abdomen"], "anatomy_list": ["liver"], "xy": 256, "z": 256, "spacing": [1.5, 1.5, 1.5]},
54
+ {"label": "Chest + lung", "body_region": ["chest"], "anatomy_list": ["right lung upper lobe", "right lung lower lobe"], "xy": 256, "z": 128, "spacing": [1.5, 1.5, 2.5]},
55
+ {"label": "Pelvis", "body_region": ["pelvis"], "anatomy_list": ["bladder"], "xy": 256, "z": 256, "spacing": [1.5, 1.5, 1.5]},
56
+ ]
57
+
58
+ MR_SAMPLES = [
59
+ {"label": "T2 prostate", "modality_label": "T2 prostate", "xy": 256, "z": 128, "spacing": [1.0, 1.0, 1.5]},
60
+ {"label": "T1 abdomen", "modality_label": "T1 abdomen", "xy": 256, "z": 128, "spacing": [1.25, 1.0, 1.0]},
61
+ {"label": "T2 brain", "modality_label": "T2 brain", "xy": 256, "z": 128, "spacing": [1.0, 1.0, 1.0]},
62
+ ]
63
+
64
+ MR_BRAIN_SAMPLES = [
65
+ {"label": "T1 whole brain", "contrast": "T1", "xy": 256, "z": 256, "spacing": [1.0, 1.0, 1.0]},
66
+ {"label": "T2 whole brain", "contrast": "T2", "xy": 256, "z": 256, "spacing": [1.0, 1.0, 1.0]},
67
+ {"label": "FLAIR", "contrast": "FLAIR", "xy": 256, "z": 256, "spacing": [1.0, 1.0, 1.0]},
68
+ {"label": "SWI", "contrast": "SWI", "xy": 256, "z": 256, "spacing": [1.0, 1.0, 1.0]},
69
+ ]
ui/workspace_ct.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CT workspace — paired image+mask generation with anatomy controls."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ import gradio as gr
7
+
8
+ from pipelines import GenerationRequest
9
+ from pipelines.ct import generate as generate_ct
10
+ from utils.windowing import CT_PRESETS
11
+ from viewer.niivue_embed import empty_html, render_viewer
12
+ from viewer.colormaps import legend_html
13
+ from .presets import CT_ANATOMY_CHOICES, CT_BODY_REGIONS, CT_SAMPLES, XY_CHOICES, Z_CHOICES
14
+
15
+
16
+ def _spacing_default() -> tuple[float, float, float]:
17
+ return (1.5, 1.5, 1.5)
18
+
19
+
20
+ def _on_preset(preset_name: str) -> tuple[float, float] | None:
21
+ preset = CT_PRESETS.get(preset_name)
22
+ if preset is None:
23
+ return None
24
+ lo, hi = preset.to_min_max()
25
+ return lo, hi
26
+
27
+
28
+ def build(spaces_gpu: Any) -> tuple[gr.Group, gr.Button]:
29
+ """Returns the hidden workspace Group and the back-to-home button."""
30
+ with gr.Group(visible=False, elem_classes=["workspace"]) as group:
31
+ with gr.Row(elem_classes=["workspace-header"]):
32
+ back_btn = gr.Button("← Back", elem_classes=["back-btn"], scale=0)
33
+ gr.HTML(
34
+ '<div class="workspace-title">'
35
+ '<span class="ws-dot" style="background:#76b900;color:#76b900"></span>'
36
+ '<span class="ws-crumb">NV-Generate</span>'
37
+ '<span class="ws-crumb-sep">/</span>'
38
+ '<span class="ws-active">CT</span>'
39
+ '</div>'
40
+ )
41
+ gr.HTML(
42
+ """
43
+ <div class="ws-intro ws-intro-ct">
44
+ <div class="ws-intro-left">
45
+ <h2 class="ws-intro-title">NV-Generate · CT</h2>
46
+ <p class="ws-intro-desc">
47
+ Whole-body synthetic CT volumes with paired 132-class anatomy masks.
48
+ Generate balanced training data for segmentation models, augment rare
49
+ pathologies with controllable organ and tumor size, or share
50
+ privacy-preserving samples for research.
51
+ </p>
52
+ </div>
53
+ <div class="ws-intro-facts">
54
+ <div class="ws-fact"><span class="ws-fact-k">Architecture</span><span class="ws-fact-v">MAISI-v2 · Rectified Flow</span></div>
55
+ <div class="ws-fact"><span class="ws-fact-k">Body regions</span><span class="ws-fact-v">head · chest · thorax · abdomen · pelvis · lower</span></div>
56
+ <div class="ws-fact"><span class="ws-fact-k">Segmentation</span><span class="ws-fact-v">132 classes · paired</span></div>
57
+ <div class="ws-fact"><span class="ws-fact-k">Inference</span><span class="ws-fact-v">30 rectified-flow steps</span></div>
58
+ <div class="ws-fact"><span class="ws-fact-k">Max volume</span><span class="ws-fact-v">512 × 512 × 768 vox</span></div>
59
+ </div>
60
+ </div>
61
+ """
62
+ )
63
+ with gr.Row(elem_classes=["workspace-row"]):
64
+ with gr.Column(scale=4, min_width=320, elem_classes=["controls"]):
65
+ gr.Markdown("##### Conditioning")
66
+ generate_masks = gr.Checkbox(
67
+ label="Paired anatomy mask · 132 classes",
68
+ value=True,
69
+ info="Off → image-only generation (faster).",
70
+ )
71
+ body_region = gr.CheckboxGroup(
72
+ choices=CT_BODY_REGIONS,
73
+ value=["abdomen"],
74
+ label="Body region",
75
+ )
76
+ anatomy_list = gr.Dropdown(
77
+ choices=CT_ANATOMY_CHOICES,
78
+ value=["liver"],
79
+ multiselect=True,
80
+ label="Target anatomies",
81
+ )
82
+
83
+ gr.Markdown("##### Geometry")
84
+ dim_xy = gr.Radio(choices=XY_CHOICES, value=256, label="X / Y (voxels)")
85
+ dim_z = gr.Radio(choices=Z_CHOICES, value=256, label="Z (voxels)")
86
+ with gr.Row(equal_height=True):
87
+ sp_x = gr.Slider(1.0, 5.0, value=1.5, step=0.05, label="Spacing X (mm)")
88
+ sp_y = gr.Slider(1.0, 5.0, value=1.5, step=0.05, label="Spacing Y (mm)")
89
+ sp_z = gr.Slider(0.5, 5.0, value=1.5, step=0.05, label="Spacing Z (mm)")
90
+ gr.HTML('<div class="hint">Field of view needs at least 256 mm: increase X voxels or X spacing if shorter.</div>')
91
+
92
+ gr.Markdown("##### Diffusion")
93
+ with gr.Row(equal_height=True):
94
+ seed = gr.Number(value=0, label="Seed", precision=0)
95
+ steps = gr.Slider(10, 60, value=30, step=1, label="Inference steps")
96
+
97
+ gr.Markdown("##### Quick presets")
98
+ with gr.Row():
99
+ sample_btns = [gr.Button(s["label"], size="sm") for s in CT_SAMPLES]
100
+
101
+ generate_btn = gr.Button("Generate volume", variant="primary", elem_classes=["primary-cta"])
102
+ status = gr.HTML('<div class="stat-line"><span class="stat-label" style="color:var(--muted)">Idle. Configure parameters and click Generate.</span></div>', elem_classes=["status"])
103
+
104
+ with gr.Column(scale=8, min_width=520, elem_classes=["viewer-col"]):
105
+ gr.HTML(
106
+ '<div class="viewer-strip">'
107
+ '<span class="viewer-strip-left">Viewport · Multiplanar</span>'
108
+ '<span class="viewer-strip-right">Axial · Coronal · Sagittal · 3D</span>'
109
+ '</div>'
110
+ )
111
+ viewer = gr.HTML(empty_html(), elem_classes=["viewer"])
112
+ with gr.Row(elem_classes=["preset-row"]):
113
+ gr.HTML('<div class="preset-label">Window / level preset</div>')
114
+ preset = gr.Radio(
115
+ choices=[p.name for p in CT_PRESETS.values()],
116
+ value="Soft Tissue",
117
+ show_label=False,
118
+ container=False,
119
+ elem_classes=["preset-radio"],
120
+ )
121
+ legend = gr.HTML("", elem_classes=["legend-host"], visible=False)
122
+ download = gr.File(label="Download generated NIfTI", visible=False, elem_classes=["nv-download"])
123
+
124
+ # State holding the most recent generation, so the W/L preset radio can
125
+ # re-render the viewer without re-running inference.
126
+ last_result = gr.State(None)
127
+
128
+ _PRESET_KEY = {"Soft Tissue": "soft_tissue", "Lung": "lung", "Bone": "bone", "Brain": "brain"}
129
+
130
+ # ---- handlers ----
131
+ def _generate(generate_masks, body_region, anatomy_list, dim_xy, dim_z, sp_x, sp_y, sp_z, seed, steps, preset):
132
+ req = GenerationRequest(
133
+ model="ct",
134
+ output_size=(int(dim_xy), int(dim_xy), int(dim_z)),
135
+ spacing=(float(sp_x), float(sp_y), float(sp_z)),
136
+ seed=int(seed),
137
+ num_steps=int(steps),
138
+ body_region=list(body_region) if generate_masks else None,
139
+ anatomy_list=list(anatomy_list) if anatomy_list else ["liver"],
140
+ generate_masks=bool(generate_masks),
141
+ )
142
+ try:
143
+ result = generate_ct(req)
144
+ except Exception as e:
145
+ return (
146
+ empty_html(f"Generation failed: {e}"),
147
+ gr.update(visible=False, value=""),
148
+ gr.update(visible=False, value=None),
149
+ f'<div class="stat-line"><span class="stat-err">✕ Generation failed</span> <span class="stat-chip"><span class="stat-k">ERR</span><span class="stat-v">{e}</span></span></div>',
150
+ None,
151
+ )
152
+
153
+ wm = CT_PRESETS.get(_PRESET_KEY.get(preset, "soft_tissue"))
154
+ window_min, window_max = wm.to_min_max() if wm else (None, None)
155
+
156
+ html = render_viewer(
157
+ volume_path=result.volume_path,
158
+ mask_path=result.mask_path,
159
+ colormap="gray", # CT base is grayscale; W/L preset drives windowing via cal_min/max
160
+ used_label_ids=list(result.used_anatomy_labels.keys()),
161
+ window_min=window_min,
162
+ window_max=window_max,
163
+ )
164
+ legend_str = legend_html(result.used_anatomy_labels) if result.mask_path else ""
165
+ files = [result.volume_path] + ([result.mask_path] if result.mask_path else [])
166
+ stat = (
167
+ '<div class="stat-line">'
168
+ '<span class="stat-mark"></span>'
169
+ '<span class="stat-label">Generated</span>'
170
+ f'<span class="stat-chip"><span class="stat-k">runtime</span><span class="stat-v">{result.runtime_seconds:.1f}s</span></span>'
171
+ f'<span class="stat-chip"><span class="stat-k">seed</span><span class="stat-v">{result.seed}</span></span>'
172
+ f'<span class="stat-chip"><span class="stat-k">steps</span><span class="stat-v">{req.num_steps}</span></span>'
173
+ f'<span class="stat-chip"><span class="stat-k">size</span><span class="stat-v">{req.output_size[0]}³</span></span>'
174
+ '</div>'
175
+ )
176
+ return (
177
+ html,
178
+ gr.update(visible=bool(legend_str), value=legend_str),
179
+ gr.update(visible=True, value=files),
180
+ stat,
181
+ result,
182
+ )
183
+
184
+ def _reapply_preset(preset, result):
185
+ if not result or not getattr(result, "volume_path", None):
186
+ return gr.update()
187
+ wm = CT_PRESETS.get(_PRESET_KEY.get(preset, "soft_tissue"))
188
+ window_min, window_max = wm.to_min_max() if wm else (None, None)
189
+ return render_viewer(
190
+ volume_path=result.volume_path,
191
+ mask_path=result.mask_path,
192
+ colormap="gray",
193
+ used_label_ids=list(result.used_anatomy_labels.keys()) if result.used_anatomy_labels else [],
194
+ window_min=window_min,
195
+ window_max=window_max,
196
+ )
197
+
198
+ decorated = spaces_gpu(_generate) if spaces_gpu else _generate
199
+ (
200
+ generate_btn.click(
201
+ lambda: gr.update(value="Generating volume…", interactive=False),
202
+ outputs=[generate_btn],
203
+ )
204
+ .then(
205
+ decorated,
206
+ inputs=[generate_masks, body_region, anatomy_list, dim_xy, dim_z, sp_x, sp_y, sp_z, seed, steps, preset],
207
+ outputs=[viewer, legend, download, status, last_result],
208
+ show_progress="full",
209
+ )
210
+ .then(
211
+ lambda: gr.update(value="Generate volume", interactive=True),
212
+ outputs=[generate_btn],
213
+ )
214
+ )
215
+ preset.change(_reapply_preset, inputs=[preset, last_result], outputs=[viewer])
216
+
217
+ for btn, sample in zip(sample_btns, CT_SAMPLES):
218
+ def _apply(s=sample):
219
+ return (
220
+ s["body_region"],
221
+ s["anatomy_list"],
222
+ s["xy"], s["z"],
223
+ s["spacing"][0], s["spacing"][1], s["spacing"][2],
224
+ )
225
+ btn.click(_apply, outputs=[body_region, anatomy_list, dim_xy, dim_z, sp_x, sp_y, sp_z])
226
+
227
+ return group, back_btn
ui/workspace_mr.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MR workspace — image-only multi-contrast MR generation."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ import gradio as gr
7
+
8
+ from pipelines import GenerationRequest
9
+ from pipelines.mr import generate as generate_mr
10
+ from viewer.niivue_embed import empty_html, render_viewer
11
+ from .presets import XY_CHOICES, Z_CHOICES, MR_CONTRAST_CHOICES, MR_SAMPLES
12
+
13
+
14
+ CONTRAST_LABELS = [c[0] for c in MR_CONTRAST_CHOICES]
15
+ LABEL_TO_MODALITY = {c[0]: c[1] for c in MR_CONTRAST_CHOICES}
16
+
17
+
18
+ def build(spaces_gpu: Any) -> tuple[gr.Group, gr.Button]:
19
+ with gr.Group(visible=False, elem_classes=["workspace"]) as group:
20
+ with gr.Row(elem_classes=["workspace-header"]):
21
+ back_btn = gr.Button("← Back", elem_classes=["back-btn"], scale=0)
22
+ gr.HTML(
23
+ '<div class="workspace-title">'
24
+ '<span class="ws-dot" style="background:#5fb4ff;color:#5fb4ff"></span>'
25
+ '<span class="ws-crumb">NV-Generate</span>'
26
+ '<span class="ws-crumb-sep">/</span>'
27
+ '<span class="ws-active">MR</span>'
28
+ '</div>'
29
+ )
30
+ gr.HTML(
31
+ """
32
+ <div class="ws-intro ws-intro-mr">
33
+ <div class="ws-intro-left">
34
+ <h2 class="ws-intro-title">NV-Generate · MR</h2>
35
+ <p class="ws-intro-desc">
36
+ Multi-contrast MRI across brain, prostate, breast, and abdominal anatomy.
37
+ Drive contrast through a modality embedding — T1, T2, FLAIR — at
38
+ variable resolution and voxel spacing. Fine-tune on your own MRI data
39
+ to extend to new modalities and regions.
40
+ </p>
41
+ </div>
42
+ <div class="ws-intro-facts">
43
+ <div class="ws-fact"><span class="ws-fact-k">Architecture</span><span class="ws-fact-v">MAISI-v2 · Rectified Flow</span></div>
44
+ <div class="ws-fact"><span class="ws-fact-k">Contrasts</span><span class="ws-fact-v">T1 · T2 · FLAIR</span></div>
45
+ <div class="ws-fact"><span class="ws-fact-k">Regions</span><span class="ws-fact-v">brain · prostate · breast · abdomen</span></div>
46
+ <div class="ws-fact"><span class="ws-fact-k">Inference</span><span class="ws-fact-v">30 steps</span></div>
47
+ <div class="ws-fact"><span class="ws-fact-k">Max volume</span><span class="ws-fact-v">512 × 512 × 128 vox</span></div>
48
+ <div class="ws-fact"><span class="ws-fact-k">License</span><span class="ws-fact-v ws-fact-warn">NVIDIA Non-Commercial</span></div>
49
+ </div>
50
+ </div>
51
+ """
52
+ )
53
+
54
+ gr.HTML(
55
+ '<div class="license-banner">'
56
+ '<strong>Non-commercial license.</strong> '
57
+ 'NV-Generate-MR weights are released under the <a href="https://developer.download.nvidia.com/licenses/NVIDIA-OneWay-Noncommercial-License-22Mar2022.pdf" target="_blank">NVIDIA OneWay Non-Commercial License</a>. '
58
+ 'Use is permitted for academic research only.'
59
+ '</div>'
60
+ )
61
+
62
+ with gr.Row(elem_classes=["workspace-row"]):
63
+ with gr.Column(scale=4, min_width=320, elem_classes=["controls"]):
64
+ gr.Markdown("##### Conditioning")
65
+ contrast = gr.Dropdown(
66
+ choices=CONTRAST_LABELS,
67
+ value="T2 prostate",
68
+ label="Contrast & anatomy",
69
+ info="Drives the modality embedding.",
70
+ )
71
+
72
+ gr.Markdown("##### Geometry")
73
+ dim_xy = gr.Radio(choices=XY_CHOICES, value=256, label="X / Y (voxels)")
74
+ dim_z = gr.Radio(choices=Z_CHOICES, value=128, label="Z (voxels)")
75
+ with gr.Row(equal_height=True):
76
+ sp_x = gr.Slider(0.5, 5.0, value=1.0, step=0.05, label="Spacing X (mm)")
77
+ sp_y = gr.Slider(0.5, 5.0, value=1.0, step=0.05, label="Spacing Y (mm)")
78
+ sp_z = gr.Slider(0.5, 5.0, value=1.5, step=0.05, label="Spacing Z (mm)")
79
+
80
+ gr.Markdown("##### Diffusion")
81
+ with gr.Row(equal_height=True):
82
+ seed = gr.Number(value=0, label="Seed", precision=0)
83
+ steps = gr.Slider(10, 60, value=30, step=1, label="Inference steps")
84
+ cfg = gr.Slider(0.0, 20.0, value=15.0, step=0.5, label="CFG guidance")
85
+
86
+ gr.Markdown("##### Quick presets")
87
+ with gr.Row():
88
+ sample_btns = [gr.Button(s["label"], size="sm") for s in MR_SAMPLES]
89
+
90
+ generate_btn = gr.Button("Generate volume", variant="primary", elem_classes=["primary-cta"])
91
+ status = gr.HTML('<div class="stat-line"><span class="stat-label" style="color:var(--muted)">Idle. Configure parameters and click Generate.</span></div>', elem_classes=["status"])
92
+
93
+ with gr.Column(scale=8, min_width=520, elem_classes=["viewer-col"]):
94
+ gr.HTML(
95
+ '<div class="viewer-strip">'
96
+ '<span class="viewer-strip-left">Viewport · Multiplanar</span>'
97
+ '<span class="viewer-strip-right">Axial · Coronal · Sagittal · 3D</span>'
98
+ '</div>'
99
+ )
100
+ viewer = gr.HTML(empty_html(), elem_classes=["viewer"])
101
+ download = gr.File(label="Download generated NIfTI", visible=False, elem_classes=["nv-download"])
102
+ # MR has no mask, but keep a legend slot so all workspaces share structure
103
+ legend = gr.HTML("", elem_classes=["legend-host"], visible=False)
104
+
105
+ def _generate(contrast, dim_xy, dim_z, sp_x, sp_y, sp_z, seed, steps, cfg):
106
+ req = GenerationRequest(
107
+ model="mr",
108
+ output_size=(int(dim_xy), int(dim_xy), int(dim_z)),
109
+ spacing=(float(sp_x), float(sp_y), float(sp_z)),
110
+ seed=int(seed),
111
+ num_steps=int(steps),
112
+ cfg_guidance_scale=float(cfg),
113
+ modality_class=LABEL_TO_MODALITY.get(contrast, 9),
114
+ )
115
+ try:
116
+ result = generate_mr(req)
117
+ except Exception as e:
118
+ return (
119
+ empty_html(f"Generation failed: {e}"),
120
+ gr.update(visible=False, value=None),
121
+ f'<div class="stat-line"><span class="stat-err">✕ Generation failed</span> <span class="stat-chip"><span class="stat-k">ERR</span><span class="stat-v">{e}</span></span></div>',
122
+ )
123
+ html = render_viewer(volume_path=result.volume_path, colormap="gray")
124
+ stat = (
125
+ '<div class="stat-line">'
126
+ '<span class="stat-mark"></span>'
127
+ '<span class="stat-label">Generated</span>'
128
+ f'<span class="stat-chip"><span class="stat-k">runtime</span><span class="stat-v">{result.runtime_seconds:.1f}s</span></span>'
129
+ f'<span class="stat-chip"><span class="stat-k">seed</span><span class="stat-v">{result.seed}</span></span>'
130
+ f'<span class="stat-chip"><span class="stat-k">steps</span><span class="stat-v">{req.num_steps}</span></span>'
131
+ f'<span class="stat-chip"><span class="stat-k">size</span><span class="stat-v">{req.output_size[0]}³</span></span>'
132
+ '</div>'
133
+ )
134
+ return html, gr.update(visible=True, value=result.volume_path), stat
135
+
136
+ decorated = spaces_gpu(_generate) if spaces_gpu else _generate
137
+ (
138
+ generate_btn.click(
139
+ lambda: gr.update(value="Generating volume…", interactive=False),
140
+ outputs=[generate_btn],
141
+ )
142
+ .then(
143
+ decorated,
144
+ inputs=[contrast, dim_xy, dim_z, sp_x, sp_y, sp_z, seed, steps, cfg],
145
+ outputs=[viewer, download, status],
146
+ show_progress="full",
147
+ )
148
+ .then(
149
+ lambda: gr.update(value="Generate volume", interactive=True),
150
+ outputs=[generate_btn],
151
+ )
152
+ )
153
+
154
+ for btn, sample in zip(sample_btns, MR_SAMPLES):
155
+ def _apply(s=sample):
156
+ return (
157
+ s["modality_label"],
158
+ s["xy"], s["z"],
159
+ s["spacing"][0], s["spacing"][1], s["spacing"][2],
160
+ )
161
+ btn.click(_apply, outputs=[contrast, dim_xy, dim_z, sp_x, sp_y, sp_z])
162
+
163
+ return group, back_btn
ui/workspace_mr_brain.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MR-Brain workspace — image-only T1/T2/FLAIR/SWI brain MRI."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ import gradio as gr
7
+
8
+ from pipelines import GenerationRequest
9
+ from pipelines.mr_brain import generate as generate_mr_brain
10
+ from viewer.niivue_embed import empty_html, render_viewer
11
+ from .presets import XY_CHOICES, Z_CHOICES, MR_BRAIN_CONTRASTS, MR_BRAIN_SAMPLES
12
+
13
+
14
+ def build(spaces_gpu: Any) -> tuple[gr.Group, gr.Button]:
15
+ with gr.Group(visible=False, elem_classes=["workspace"]) as group:
16
+ with gr.Row(elem_classes=["workspace-header"]):
17
+ back_btn = gr.Button("← Back", elem_classes=["back-btn"], scale=0)
18
+ gr.HTML(
19
+ '<div class="workspace-title">'
20
+ '<span class="ws-dot" style="background:#b48aff;color:#b48aff"></span>'
21
+ '<span class="ws-crumb">NV-Generate</span>'
22
+ '<span class="ws-crumb-sep">/</span>'
23
+ '<span class="ws-active">MR Brain</span>'
24
+ '</div>'
25
+ )
26
+ gr.HTML(
27
+ """
28
+ <div class="ws-intro ws-intro-mrb">
29
+ <div class="ws-intro-left">
30
+ <h2 class="ws-intro-title">NV-Generate · MR Brain</h2>
31
+ <p class="ws-intro-desc">
32
+ Multi-sequence brain MRI generation across T1, T2, FLAIR, and SWI —
33
+ in both whole-brain and skull-stripped forms. Trained on the open
34
+ MR-RATE dataset. Useful for downstream tumor and lesion segmentation
35
+ studies that need controlled, synthetic data.
36
+ </p>
37
+ </div>
38
+ <div class="ws-intro-facts">
39
+ <div class="ws-fact"><span class="ws-fact-k">Architecture</span><span class="ws-fact-v">MAISI-v2 · Rectified Flow</span></div>
40
+ <div class="ws-fact"><span class="ws-fact-k">Sequences</span><span class="ws-fact-v">T1 · T2 · FLAIR · SWI</span></div>
41
+ <div class="ws-fact"><span class="ws-fact-k">Variants</span><span class="ws-fact-v">whole-brain · skull-stripped</span></div>
42
+ <div class="ws-fact"><span class="ws-fact-k">Trained on</span><span class="ws-fact-v">MR-RATE dataset (open)</span></div>
43
+ <div class="ws-fact"><span class="ws-fact-k">Inference</span><span class="ws-fact-v">30 steps</span></div>
44
+ <div class="ws-fact"><span class="ws-fact-k">Max volume</span><span class="ws-fact-v">512 × 512 × 256 vox</span></div>
45
+ </div>
46
+ </div>
47
+ """
48
+ )
49
+
50
+ with gr.Row(elem_classes=["workspace-row"]):
51
+ with gr.Column(scale=4, min_width=320, elem_classes=["controls"]):
52
+ gr.Markdown("##### Contrast")
53
+ contrast = gr.Radio(
54
+ choices=MR_BRAIN_CONTRASTS,
55
+ value="T1",
56
+ label="Sequence",
57
+ info="Skull-stripped variants supported.",
58
+ )
59
+
60
+ gr.Markdown("##### Geometry")
61
+ dim_xy = gr.Radio(choices=XY_CHOICES, value=256, label="X / Y (voxels)")
62
+ dim_z = gr.Radio(choices=Z_CHOICES, value=256, label="Z (voxels)")
63
+ with gr.Row(equal_height=True):
64
+ sp_x = gr.Slider(0.5, 5.0, value=1.0, step=0.05, label="Spacing X (mm)")
65
+ sp_y = gr.Slider(0.5, 5.0, value=1.0, step=0.05, label="Spacing Y (mm)")
66
+ sp_z = gr.Slider(0.5, 5.0, value=1.0, step=0.05, label="Spacing Z (mm)")
67
+
68
+ gr.Markdown("##### Diffusion")
69
+ with gr.Row(equal_height=True):
70
+ seed = gr.Number(value=0, label="Seed", precision=0)
71
+ steps = gr.Slider(10, 60, value=30, step=1, label="Inference steps")
72
+ cfg = gr.Slider(0.0, 20.0, value=10.0, step=0.5, label="CFG guidance")
73
+
74
+ gr.Markdown("##### Quick presets")
75
+ with gr.Row():
76
+ sample_btns = [gr.Button(s["label"], size="sm") for s in MR_BRAIN_SAMPLES]
77
+
78
+ generate_btn = gr.Button("Generate volume", variant="primary", elem_classes=["primary-cta"])
79
+ status = gr.HTML('<div class="stat-line"><span class="stat-label" style="color:var(--muted)">Idle. Configure parameters and click Generate.</span></div>', elem_classes=["status"])
80
+
81
+ with gr.Column(scale=8, min_width=520, elem_classes=["viewer-col"]):
82
+ gr.HTML(
83
+ '<div class="viewer-strip">'
84
+ '<span class="viewer-strip-left">Viewport · Multiplanar</span>'
85
+ '<span class="viewer-strip-right">Axial · Coronal · Sagittal · 3D</span>'
86
+ '</div>'
87
+ )
88
+ viewer = gr.HTML(empty_html(), elem_classes=["viewer"])
89
+ download = gr.File(label="Download generated NIfTI", visible=False, elem_classes=["nv-download"])
90
+ legend = gr.HTML("", elem_classes=["legend-host"], visible=False)
91
+
92
+ def _generate(contrast, dim_xy, dim_z, sp_x, sp_y, sp_z, seed, steps, cfg):
93
+ req = GenerationRequest(
94
+ model="mr_brain",
95
+ output_size=(int(dim_xy), int(dim_xy), int(dim_z)),
96
+ spacing=(float(sp_x), float(sp_y), float(sp_z)),
97
+ seed=int(seed),
98
+ num_steps=int(steps),
99
+ cfg_guidance_scale=float(cfg),
100
+ contrast=contrast,
101
+ )
102
+ try:
103
+ result = generate_mr_brain(req)
104
+ except Exception as e:
105
+ return (
106
+ empty_html(f"Generation failed: {e}"),
107
+ gr.update(visible=False, value=None),
108
+ f'<div class="stat-line"><span class="stat-err">✕ Generation failed</span> <span class="stat-chip"><span class="stat-k">ERR</span><span class="stat-v">{e}</span></span></div>',
109
+ )
110
+ html = render_viewer(volume_path=result.volume_path, colormap="gray")
111
+ stat = (
112
+ '<div class="stat-line">'
113
+ '<span class="stat-mark"></span>'
114
+ '<span class="stat-label">Generated</span>'
115
+ f'<span class="stat-chip"><span class="stat-k">runtime</span><span class="stat-v">{result.runtime_seconds:.1f}s</span></span>'
116
+ f'<span class="stat-chip"><span class="stat-k">seed</span><span class="stat-v">{result.seed}</span></span>'
117
+ f'<span class="stat-chip"><span class="stat-k">steps</span><span class="stat-v">{req.num_steps}</span></span>'
118
+ f'<span class="stat-chip"><span class="stat-k">size</span><span class="stat-v">{req.output_size[0]}³</span></span>'
119
+ '</div>'
120
+ )
121
+ return html, gr.update(visible=True, value=result.volume_path), stat
122
+
123
+ decorated = spaces_gpu(_generate) if spaces_gpu else _generate
124
+ (
125
+ generate_btn.click(
126
+ lambda: gr.update(value="Generating volume…", interactive=False),
127
+ outputs=[generate_btn],
128
+ )
129
+ .then(
130
+ decorated,
131
+ inputs=[contrast, dim_xy, dim_z, sp_x, sp_y, sp_z, seed, steps, cfg],
132
+ outputs=[viewer, download, status],
133
+ show_progress="full",
134
+ )
135
+ .then(
136
+ lambda: gr.update(value="Generate volume", interactive=True),
137
+ outputs=[generate_btn],
138
+ )
139
+ )
140
+
141
+ for btn, sample in zip(sample_btns, MR_BRAIN_SAMPLES):
142
+ def _apply(s=sample):
143
+ return (
144
+ s["contrast"],
145
+ s["xy"], s["z"],
146
+ s["spacing"][0], s["spacing"][1], s["spacing"][2],
147
+ )
148
+ btn.click(_apply, outputs=[contrast, dim_xy, dim_z, sp_x, sp_y, sp_z])
149
+
150
+ return group, back_btn
utils/__init__.py ADDED
File without changes
utils/nifti_io.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import nibabel as nib
6
+ import numpy as np
7
+
8
+
9
+ def load_volume(path: str | Path) -> tuple[np.ndarray, np.ndarray]:
10
+ img = nib.load(str(path))
11
+ return np.asarray(img.dataobj), img.affine
12
+
13
+
14
+ def percentile_window(data: np.ndarray, lo: float = 2.0, hi: float = 98.0) -> tuple[float, float]:
15
+ nz = data[data > 0] if (data > 0).any() else data
16
+ return float(np.percentile(nz, lo)), float(np.percentile(nz, hi))
utils/weights.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Lazy-download helper. Delegates to upstream `scripts.download_model_data`."""
2
+ from __future__ import annotations
3
+
4
+ from pipelines._runner import ensure_weights # re-export
5
+
6
+ __all__ = ["ensure_weights"]
utils/windowing.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CT Hounsfield-unit window/level presets and conversion helpers."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class WLPreset:
9
+ name: str
10
+ width: float
11
+ level: float
12
+
13
+ def to_min_max(self) -> tuple[float, float]:
14
+ return self.level - self.width / 2.0, self.level + self.width / 2.0
15
+
16
+
17
+ CT_PRESETS: dict[str, WLPreset] = {
18
+ "soft_tissue": WLPreset("Soft Tissue", 350, 50),
19
+ "lung": WLPreset("Lung", 1500, -600),
20
+ "bone": WLPreset("Bone", 1800, 400),
21
+ "brain": WLPreset("Brain", 80, 40),
22
+ }
23
+
24
+ CT_PRESET_LABELS = [p.name for p in CT_PRESETS.values()]
viewer/__init__.py ADDED
File without changes
viewer/colormaps.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Anatomy label tables and color assignments for the legend overlay.
3
+
4
+ CT labels are loaded directly from the upstream config so we stay in sync.
5
+ A deterministic golden-ratio HSV walk maps each label ID to a distinguishable RGB.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import colorsys
10
+ import json
11
+ from pathlib import Path
12
+
13
+ ROOT = Path(__file__).resolve().parent.parent
14
+ UPSTREAM_LABEL_DICT = ROOT / "repos" / "NV-Generate-CTMR" / "configs" / "label_dict.json"
15
+
16
+
17
+ def _load_ct_labels() -> dict[int, str]:
18
+ if not UPSTREAM_LABEL_DICT.exists():
19
+ return {}
20
+ raw: dict[str, int] = json.loads(UPSTREAM_LABEL_DICT.read_text())
21
+ return {int(v): k for k, v in raw.items() if not k.startswith("dummy")}
22
+
23
+
24
+ def _color_for(idx: int) -> tuple[int, int, int]:
25
+ """Deterministic high-contrast color from an integer ID."""
26
+ golden = 0.61803398875
27
+ h = (idx * golden) % 1.0
28
+ s = 0.55 + 0.35 * ((idx * 7) % 5) / 4.0
29
+ v = 0.70 + 0.25 * ((idx * 11) % 4) / 3.0
30
+ r, g, b = colorsys.hsv_to_rgb(h, s, v)
31
+ return int(r * 255), int(g * 255), int(b * 255)
32
+
33
+
34
+ CT_LABELS_BY_ID: dict[int, str] = _load_ct_labels()
35
+
36
+
37
+ def color_table(label_ids: list[int]) -> list[tuple[int, int, int, int]]:
38
+ """RGBA palette for niivue label overlay — index 0 is background (transparent)."""
39
+ if not label_ids:
40
+ return []
41
+ max_id = max(label_ids)
42
+ palette: list[tuple[int, int, int, int]] = [(0, 0, 0, 0)]
43
+ for i in range(1, max_id + 1):
44
+ if i in label_ids:
45
+ r, g, b = _color_for(i)
46
+ palette.append((r, g, b, 200))
47
+ else:
48
+ palette.append((0, 0, 0, 0))
49
+ return palette
50
+
51
+
52
+ def legend_html(used_labels: dict[int, str]) -> str:
53
+ """Compact horizontal legend showing only labels present in the generated mask."""
54
+ if not used_labels:
55
+ return ""
56
+ rows: list[str] = []
57
+ for lbl, name in sorted(used_labels.items()):
58
+ r, g, b = _color_for(lbl)
59
+ rows.append(
60
+ f'<div class="nv-legend-chip">'
61
+ f'<span class="nv-swatch" style="background:rgb({r},{g},{b})"></span>'
62
+ f'<span class="nv-label">{name}</span></div>'
63
+ )
64
+ count = len(used_labels)
65
+ return (
66
+ '<div class="nv-legend">'
67
+ f'<div class="nv-legend-title">Classes present in mask <span class="nv-legend-count">{count}</span></div>'
68
+ '<div class="nv-legend-grid">' + "".join(rows) + "</div>"
69
+ "</div>"
70
+ )
viewer/niivue_embed.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Generate a self-contained `gr.HTML` snippet that loads niivue from CDN, renders
3
+ a multiplanar (axial / coronal / sagittal / 3D) viewer, and optionally overlays a
4
+ labeled segmentation mask.
5
+
6
+ We render the viewer inside an `<iframe srcdoc=...>` because Gradio's React-based
7
+ DOM update cycle does NOT execute `<script>` tags injected via innerHTML when an
8
+ HTML component's value changes. An iframe with srcdoc gets a fresh document on
9
+ every update, so scripts always run.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import html as _html
15
+ import json
16
+ import uuid
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ from .colormaps import color_table
21
+
22
+
23
+ def _b64(path: Path) -> str:
24
+ with open(path, "rb") as f:
25
+ return base64.b64encode(f.read()).decode("ascii")
26
+
27
+
28
+ def empty_html(message: str = "Configure parameters, then click Generate volume.") -> str:
29
+ # Show a faint 2x2 wireframe preview of where AX/COR/SAG/3D will appear after
30
+ # generation, so the user knows what they will see.
31
+ return (
32
+ '<div class="nv-empty">'
33
+ '<div class="nv-empty-wireframe">'
34
+ '<div class="nv-wireq nv-wireq-ax"><span>Axial</span></div>'
35
+ '<div class="nv-wireq nv-wireq-cor"><span>Coronal</span></div>'
36
+ '<div class="nv-wireq nv-wireq-sag"><span>Sagittal</span></div>'
37
+ '<div class="nv-wireq nv-wireq-3d"><span>3D Render</span></div>'
38
+ '</div>'
39
+ '<div class="nv-empty-text">'
40
+ '<div class="nv-empty-icon">Awaiting generation</div>'
41
+ f'<div class="nv-empty-msg">{message}</div>'
42
+ "</div>"
43
+ "</div>"
44
+ )
45
+
46
+
47
+ def _build_iframe_doc(cfg_json: str, container_id: str) -> str:
48
+ """Full HTML document loaded by the viewer iframe."""
49
+ return f"""<!DOCTYPE html>
50
+ <html>
51
+ <head>
52
+ <meta charset="utf-8">
53
+ <style>
54
+ html, body {{ margin: 0; padding: 0; height: 100%; background: #0b1020; overflow: hidden; }}
55
+ #{container_id} {{ display: block; width: 100%; height: 100%; background: #0b1020; }}
56
+ #status {{ position: absolute; top: 8px; left: 12px; color: #8c93ad; font: 12px -apple-system, BlinkMacSystemFont, sans-serif; pointer-events: none; }}
57
+ #err {{ position: absolute; bottom: 8px; left: 12px; right: 12px; color: #ff8d8d; font: 12px monospace; white-space: pre-wrap; pointer-events: none; }}
58
+ /* Pane labels overlaid at the outer corner of each MPR quadrant */
59
+ .pane-labels {{ position: absolute; inset: 0; pointer-events: none; }}
60
+ .pane-label {{
61
+ position: absolute;
62
+ font: 600 9.5px/1 ui-monospace, "JetBrains Mono", "SF Mono", monospace;
63
+ letter-spacing: 0.20em; text-transform: uppercase;
64
+ color: rgba(170, 200, 255, 0.85);
65
+ background: rgba(8, 16, 36, 0.65);
66
+ backdrop-filter: blur(4px);
67
+ padding: 5px 9px;
68
+ border: 1px solid rgba(170, 200, 255, 0.18);
69
+ }}
70
+ .pl-tl {{ top: 10px; left: 10px; }}
71
+ .pl-tr {{ top: 10px; right: 10px; }}
72
+ .pl-bl {{ bottom: 10px; left: 10px; }}
73
+ .pl-br {{ bottom: 10px; right: 10px; }}
74
+ </style>
75
+ </head>
76
+ <body>
77
+ <canvas id="{container_id}"></canvas>
78
+ <div class="pane-labels">
79
+ <div class="pane-label pl-tl">Coronal</div>
80
+ <div class="pane-label pl-tr">Sagittal</div>
81
+ <div class="pane-label pl-bl">Axial</div>
82
+ <div class="pane-label pl-br">3D</div>
83
+ </div>
84
+ <div id="status">Loading niivue…</div>
85
+ <div id="err"></div>
86
+ <script src="https://cdn.jsdelivr.net/npm/@niivue/niivue@0.68.2/dist/niivue.umd.js"></script>
87
+ <script>
88
+ const cfg = {cfg_json};
89
+ const statusEl = document.getElementById('status');
90
+ const errEl = document.getElementById('err');
91
+ function setStatus(t) {{ if (statusEl) statusEl.textContent = t; }}
92
+ function showErr(e) {{ if (errEl) errEl.textContent = String(e && e.stack || e); console.error(e); }}
93
+
94
+ function b64ToBlobUrl(b64, mime) {{
95
+ const bin = atob(b64);
96
+ const bytes = new Uint8Array(bin.length);
97
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
98
+ return URL.createObjectURL(new Blob([bytes], {{ type: mime }}));
99
+ }}
100
+
101
+ (async () => {{
102
+ try {{
103
+ setStatus('Initializing viewer…');
104
+ if (!window.niivue || !window.niivue.Niivue) throw new Error('niivue UMD failed to load');
105
+ const canvas = document.getElementById(cfg.container_id);
106
+ const nv = new niivue.Niivue({{
107
+ backColor: [0.043, 0.062, 0.125, 1],
108
+ // Cyan crosshair: high contrast on both dark and light tissue
109
+ crosshairColor: [0.4, 0.88, 1.0, 1],
110
+ crosshairWidth: 1,
111
+ show3Dcrosshair: true,
112
+ multiplanarLayout: 2, // GRID = 2 (AUTO=0, COLUMN=1, GRID=2, ROW=3)
113
+ multiplanarShowRender: 2, // ALWAYS show 3D in 4th tile
114
+ isResizeCanvas: true,
115
+ isOrientCube: false,
116
+ }});
117
+ await nv.attachToCanvas(canvas);
118
+
119
+ const volUrl = b64ToBlobUrl(cfg.vol_b64, 'application/octet-stream');
120
+ const baseSpec = {{ url: volUrl, name: 'volume.nii.gz', colormap: cfg.colormap || 'gray' }};
121
+ // Pre-set cal_min/cal_max on the spec so 3D render uses correct opacity/contrast on first paint.
122
+ if (cfg.window_min !== null && cfg.window_min !== undefined) baseSpec.cal_min = cfg.window_min;
123
+ if (cfg.window_max !== null && cfg.window_max !== undefined) baseSpec.cal_max = cfg.window_max;
124
+
125
+ const volSpecs = [baseSpec];
126
+ if (cfg.mask_b64) {{
127
+ const mUrl = b64ToBlobUrl(cfg.mask_b64, 'application/octet-stream');
128
+ volSpecs.push({{
129
+ url: mUrl,
130
+ name: 'mask.nii.gz',
131
+ colormap: 'random',
132
+ opacity: 0.6,
133
+ }});
134
+ }}
135
+ setStatus('Loading volume…');
136
+ await nv.loadVolumes(volSpecs);
137
+
138
+ // Re-apply windowing post-load (some loaders override cal_min/max to data percentiles).
139
+ if (nv.volumes && nv.volumes.length > 0) {{
140
+ if (cfg.window_min !== null && cfg.window_min !== undefined) nv.volumes[0].cal_min = cfg.window_min;
141
+ if (cfg.window_max !== null && cfg.window_max !== undefined) nv.volumes[0].cal_max = cfg.window_max;
142
+ }}
143
+
144
+ // Apply per-label colors to the mask via colormapLabel (niivue 0.68 API).
145
+ if (cfg.palette && cfg.palette.length > 0 && nv.volumes && nv.volumes.length > 1) {{
146
+ try {{
147
+ const n = cfg.palette.length;
148
+ const R = new Uint8ClampedArray(n);
149
+ const G = new Uint8ClampedArray(n);
150
+ const B = new Uint8ClampedArray(n);
151
+ const A = new Uint8ClampedArray(n);
152
+ const lut = new Uint8ClampedArray(n * 4);
153
+ const labels = new Array(n);
154
+ for (let i = 0; i < n; i++) {{
155
+ const c = cfg.palette[i];
156
+ R[i] = c[0]; G[i] = c[1]; B[i] = c[2]; A[i] = c[3];
157
+ lut[i*4+0] = c[0]; lut[i*4+1] = c[1]; lut[i*4+2] = c[2]; lut[i*4+3] = c[3];
158
+ labels[i] = (cfg.label_names && cfg.label_names[i]) || ('label ' + i);
159
+ }}
160
+ nv.volumes[1].colormapLabel = {{ R, G, B, A, lut, labels, min: 0, max: n - 1 }};
161
+ }} catch (e) {{ console.warn('Custom label colormap failed:', e); }}
162
+ }}
163
+
164
+ nv.setSliceType(nv.sliceTypeMultiplanar);
165
+ nv.updateGLVolume();
166
+ setStatus('');
167
+ }} catch (e) {{ showErr(e); }}
168
+ }})();
169
+ </script>
170
+ </body>
171
+ </html>"""
172
+
173
+
174
+ def render_viewer(
175
+ volume_path: Optional[str],
176
+ mask_path: Optional[str] = None,
177
+ *,
178
+ colormap: str = "gray",
179
+ used_label_ids: Optional[list[int]] = None,
180
+ window_min: Optional[float] = None,
181
+ window_max: Optional[float] = None,
182
+ ) -> str:
183
+ """Build the gr.HTML payload (iframe wrapper) for the niivue widget."""
184
+ if not volume_path:
185
+ return empty_html()
186
+
187
+ vol_b64 = _b64(Path(volume_path))
188
+ mask_b64 = _b64(Path(mask_path)) if mask_path else None
189
+ palette = color_table(used_label_ids or [])
190
+ container_id = f"nvc-{uuid.uuid4().hex[:8]}"
191
+
192
+ label_names: list[str] = []
193
+ if used_label_ids:
194
+ max_id = max(used_label_ids)
195
+ from .colormaps import CT_LABELS_BY_ID
196
+ label_names = ["background"] + [
197
+ CT_LABELS_BY_ID.get(i, f"label {i}") if i in used_label_ids else ""
198
+ for i in range(1, max_id + 1)
199
+ ]
200
+
201
+ cfg = {
202
+ "container_id": container_id,
203
+ "vol_b64": vol_b64,
204
+ "mask_b64": mask_b64,
205
+ "colormap": colormap,
206
+ "palette": palette,
207
+ "label_names": label_names,
208
+ "window_min": window_min,
209
+ "window_max": window_max,
210
+ }
211
+ # JSON inside JS context — escape `</` to avoid premature script termination.
212
+ cfg_json = json.dumps(cfg).replace("</", "<\\/")
213
+
214
+ doc = _build_iframe_doc(cfg_json, container_id)
215
+ # srcdoc must escape '"' and '&' (HTML attribute escaping).
216
+ srcdoc = _html.escape(doc, quote=True)
217
+ # Fixed square aspect-ratio wrapper guarantees the niivue 2x2 grid stays
218
+ # 2x2 with equal-size tiles regardless of the volume's voxel dimensions.
219
+ return (
220
+ '<div style="width:100%;aspect-ratio:1/1;max-height:720px;'
221
+ 'border-radius:14px;overflow:hidden;background:#0b1020;'
222
+ 'margin:0 auto;display:block">'
223
+ f'<iframe srcdoc="{srcdoc}" '
224
+ 'style="width:100%;height:100%;border:0;display:block;background:#0b1020" '
225
+ 'sandbox="allow-scripts allow-same-origin"></iframe>'
226
+ '</div>'
227
+ )