fix(ui): use head_paths for NiiVue loading on HF Spaces (#24) (#25)
Browse files* chore: DIAGNOSTIC - disable js_on_load to test HF Spaces loading
* fix(ui): use head_paths for NiiVue loading on HF Spaces (#24)
Root cause: gr.HTML(js_on_load=...) with dynamic ES module import()
breaks Gradio frontend initialization on HuggingFace Spaces, causing
the app to hang on "Loading..." indefinitely.
Fix: Use the official Gradio-recommended head_paths approach:
1. Generate niivue-loader.html at runtime with correct absolute path
2. Load via demo.launch(head_paths=[...]) instead of head=
3. NiiVue exposed globally as window.Niivue
4. js_on_load and event handlers access window.Niivue (no import())
This follows the solution recommended by Gradio maintainers in
GitHub issue #11649 for loading custom JavaScript reliably.
Files changed:
- viewer.py: Add get_niivue_loader_path(), convert JS to regular strings
- app.py: Use head_paths instead of head parameter
- ui/app.py: Use head_paths, re-enable NIIVUE_UPDATE_JS handler
- components.py: Re-enable js_on_load=NIIVUE_ON_LOAD_JS
- assets/niivue-loader.html: Auto-generated loader (gitignored runtime)
All 136 tests pass. Lint and type checks clean.
* fix: gitignore niivue-loader.html (generated at runtime with env-specific path)
* fix: add logging and error handling to get_niivue_loader_path (CodeRabbit)
- .gitignore +2 -0
- app.py +5 -0
- docs/specs/24-bug-hf-spaces-loading-forever.md +49 -0
- src/stroke_deepisles_demo/ui/app.py +6 -1
- src/stroke_deepisles_demo/ui/components.py +3 -4
- src/stroke_deepisles_demo/ui/viewer.py +138 -46
|
@@ -215,3 +215,5 @@ data/scratch/
|
|
| 215 |
|
| 216 |
# macOS
|
| 217 |
.DS_Store
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
# macOS
|
| 217 |
.DS_Store
|
| 218 |
+
# Auto-generated at runtime (path is environment-specific)
|
| 219 |
+
src/stroke_deepisles_demo/ui/assets/niivue-loader.html
|
|
@@ -16,6 +16,7 @@ import gradio as gr
|
|
| 16 |
from stroke_deepisles_demo.core.config import get_settings
|
| 17 |
from stroke_deepisles_demo.core.logging import setup_logging
|
| 18 |
from stroke_deepisles_demo.ui.app import get_demo
|
|
|
|
| 19 |
|
| 20 |
# Initialize logging
|
| 21 |
settings = get_settings()
|
|
@@ -35,6 +36,9 @@ if __name__ == "__main__":
|
|
| 35 |
# Assets are located in src/stroke_deepisles_demo/ui/assets
|
| 36 |
assets_dir = Path(__file__).parent / "src" / "stroke_deepisles_demo" / "ui" / "assets"
|
| 37 |
|
|
|
|
|
|
|
|
|
|
| 38 |
demo.launch(
|
| 39 |
server_name=settings.gradio_server_name,
|
| 40 |
server_port=settings.gradio_server_port,
|
|
@@ -42,4 +46,5 @@ if __name__ == "__main__":
|
|
| 42 |
theme=gr.themes.Soft(),
|
| 43 |
css="footer {visibility: hidden}",
|
| 44 |
allowed_paths=[str(assets_dir)],
|
|
|
|
| 45 |
)
|
|
|
|
| 16 |
from stroke_deepisles_demo.core.config import get_settings
|
| 17 |
from stroke_deepisles_demo.core.logging import setup_logging
|
| 18 |
from stroke_deepisles_demo.ui.app import get_demo
|
| 19 |
+
from stroke_deepisles_demo.ui.viewer import get_niivue_loader_path
|
| 20 |
|
| 21 |
# Initialize logging
|
| 22 |
settings = get_settings()
|
|
|
|
| 36 |
# Assets are located in src/stroke_deepisles_demo/ui/assets
|
| 37 |
assets_dir = Path(__file__).parent / "src" / "stroke_deepisles_demo" / "ui" / "assets"
|
| 38 |
|
| 39 |
+
# Generate the NiiVue loader HTML file (creates if needed)
|
| 40 |
+
niivue_loader = get_niivue_loader_path()
|
| 41 |
+
|
| 42 |
demo.launch(
|
| 43 |
server_name=settings.gradio_server_name,
|
| 44 |
server_port=settings.gradio_server_port,
|
|
|
|
| 46 |
theme=gr.themes.Soft(),
|
| 47 |
css="footer {visibility: hidden}",
|
| 48 |
allowed_paths=[str(assets_dir)],
|
| 49 |
+
head_paths=[str(niivue_loader)], # Official Gradio approach (Issue #11649)
|
| 50 |
)
|
|
@@ -203,3 +203,52 @@ This caused the Gradio frontend to remain stuck on "Loading..." even though the
|
|
| 203 |
3. **Security-compliant:** Respects HF Spaces CSP policy
|
| 204 |
4. **Reproducible:** Same NiiVue version always loaded
|
| 205 |
5. **Standard practice:** Vendoring is the recommended approach for HF Spaces
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
3. **Security-compliant:** Respects HF Spaces CSP policy
|
| 204 |
4. **Reproducible:** Same NiiVue version always loaded
|
| 205 |
5. **Standard practice:** Vendoring is the recommended approach for HF Spaces
|
| 206 |
+
|
| 207 |
+
---
|
| 208 |
+
|
| 209 |
+
## Update: Vendoring Alone Did Not Fix It (2025-12-09)
|
| 210 |
+
|
| 211 |
+
### New Finding
|
| 212 |
+
|
| 213 |
+
Vendoring NiiVue locally bypassed CSP but **the app still wouldn't load**.
|
| 214 |
+
|
| 215 |
+
**Diagnostic test:** Disabled `js_on_load` parameter entirely.
|
| 216 |
+
|
| 217 |
+
**Result:** App loads perfectly! Everything works EXCEPT Interactive 3D viewer.
|
| 218 |
+
|
| 219 |
+
### Real Root Cause
|
| 220 |
+
|
| 221 |
+
**`gr.HTML(js_on_load=...)` with dynamic ES module `import()` blocks Gradio frontend initialization on HF Spaces.**
|
| 222 |
+
|
| 223 |
+
The issue is NOT the vendored file location - it's HOW we load the JavaScript:
|
| 224 |
+
|
| 225 |
+
```javascript
|
| 226 |
+
// This approach BREAKS the entire Gradio app on HF Spaces:
|
| 227 |
+
const { Niivue } = await import('/gradio_api/file=...');
|
| 228 |
+
```
|
| 229 |
+
|
| 230 |
+
When this fails (silently), it prevents the Gradio frontend from completing initialization, causing the eternal "Loading..." screen.
|
| 231 |
+
|
| 232 |
+
### Evidence
|
| 233 |
+
|
| 234 |
+
With `js_on_load` disabled:
|
| 235 |
+
- ✅ Gradio app loads
|
| 236 |
+
- ✅ Case selector works
|
| 237 |
+
- ✅ DeepISLES segmentation runs (38.66s)
|
| 238 |
+
- ✅ Static Report (Matplotlib) renders correctly
|
| 239 |
+
- ✅ Metrics JSON displays
|
| 240 |
+
- ✅ Download works
|
| 241 |
+
- ❌ Interactive 3D shows "Loading viewer..." (expected - JS disabled)
|
| 242 |
+
|
| 243 |
+
### Correct Approach
|
| 244 |
+
|
| 245 |
+
Use `gr.Blocks(head=...)` to load NiiVue as a `<script>` tag instead of dynamic `import()`:
|
| 246 |
+
|
| 247 |
+
```python
|
| 248 |
+
with gr.Blocks(
|
| 249 |
+
head='<script src="/gradio_api/file=.../niivue.js"></script>'
|
| 250 |
+
) as demo:
|
| 251 |
+
...
|
| 252 |
+
```
|
| 253 |
+
|
| 254 |
+
Or use the global `js` parameter on `gr.Blocks` to define initialization code that runs after the script loads.
|
|
@@ -21,6 +21,7 @@ from stroke_deepisles_demo.ui.components import (
|
|
| 21 |
from stroke_deepisles_demo.ui.viewer import (
|
| 22 |
NIIVUE_UPDATE_JS,
|
| 23 |
create_niivue_html,
|
|
|
|
| 24 |
nifti_to_gradio_url,
|
| 25 |
render_3panel_view,
|
| 26 |
render_slice_comparison,
|
|
@@ -245,7 +246,7 @@ def create_app() -> gr.Blocks:
|
|
| 245 |
previous_results_state, # Update state with new results_dir
|
| 246 |
],
|
| 247 |
).then(
|
| 248 |
-
fn=None, #
|
| 249 |
js=NIIVUE_UPDATE_JS,
|
| 250 |
)
|
| 251 |
|
|
@@ -277,6 +278,9 @@ if __name__ == "__main__":
|
|
| 277 |
# Allow access to local assets (e.g., niivue.js)
|
| 278 |
assets_dir = Path(__file__).parent / "assets"
|
| 279 |
|
|
|
|
|
|
|
|
|
|
| 280 |
get_demo().launch(
|
| 281 |
server_name=settings.gradio_server_name,
|
| 282 |
server_port=settings.gradio_server_port,
|
|
@@ -285,4 +289,5 @@ if __name__ == "__main__":
|
|
| 285 |
css="footer {visibility: hidden}",
|
| 286 |
show_error=True, # Show full Python tracebacks in UI for debugging
|
| 287 |
allowed_paths=[str(assets_dir)],
|
|
|
|
| 288 |
)
|
|
|
|
| 21 |
from stroke_deepisles_demo.ui.viewer import (
|
| 22 |
NIIVUE_UPDATE_JS,
|
| 23 |
create_niivue_html,
|
| 24 |
+
get_niivue_loader_path,
|
| 25 |
nifti_to_gradio_url,
|
| 26 |
render_3panel_view,
|
| 27 |
render_slice_comparison,
|
|
|
|
| 246 |
previous_results_state, # Update state with new results_dir
|
| 247 |
],
|
| 248 |
).then(
|
| 249 |
+
fn=None, # JS-only handler to re-initialize NiiVue after HTML update
|
| 250 |
js=NIIVUE_UPDATE_JS,
|
| 251 |
)
|
| 252 |
|
|
|
|
| 278 |
# Allow access to local assets (e.g., niivue.js)
|
| 279 |
assets_dir = Path(__file__).parent / "assets"
|
| 280 |
|
| 281 |
+
# Generate the NiiVue loader HTML file (creates if needed)
|
| 282 |
+
niivue_loader = get_niivue_loader_path()
|
| 283 |
+
|
| 284 |
get_demo().launch(
|
| 285 |
server_name=settings.gradio_server_name,
|
| 286 |
server_port=settings.gradio_server_port,
|
|
|
|
| 289 |
css="footer {visibility: hidden}",
|
| 290 |
show_error=True, # Show full Python tracebacks in UI for debugging
|
| 291 |
allowed_paths=[str(assets_dir)],
|
| 292 |
+
head_paths=[str(niivue_loader)], # Official Gradio approach (Issue #11649)
|
| 293 |
)
|
|
@@ -41,10 +41,9 @@ def create_results_display() -> dict[str, gr.components.Component]:
|
|
| 41 |
with gr.Group():
|
| 42 |
with gr.Tabs():
|
| 43 |
with gr.Tab("Interactive 3D"):
|
| 44 |
-
# NiiVue
|
| 45 |
-
#
|
| 46 |
-
#
|
| 47 |
-
# The HTML value contains data-* attributes with volume URLs.
|
| 48 |
niivue_viewer = gr.HTML(
|
| 49 |
label="Interactive 3D Viewer",
|
| 50 |
js_on_load=NIIVUE_ON_LOAD_JS,
|
|
|
|
| 41 |
with gr.Group():
|
| 42 |
with gr.Tabs():
|
| 43 |
with gr.Tab("Interactive 3D"):
|
| 44 |
+
# NiiVue 3D viewer - uses js_on_load to initialize after HTML renders
|
| 45 |
+
# The NiiVue library is loaded globally via head_paths (see app.py)
|
| 46 |
+
# This handler accesses window.Niivue set by the loader script
|
|
|
|
| 47 |
niivue_viewer = gr.HTML(
|
| 48 |
label="Interactive 3D Viewer",
|
| 49 |
js_on_load=NIIVUE_ON_LOAD_JS,
|
|
@@ -19,8 +19,11 @@ from pathlib import Path
|
|
| 19 |
import numpy as np
|
| 20 |
from matplotlib.figure import Figure
|
| 21 |
|
|
|
|
| 22 |
from stroke_deepisles_demo.metrics import load_nifti_as_array
|
| 23 |
|
|
|
|
|
|
|
| 24 |
# NiiVue version - updated to latest stable (Dec 2025)
|
| 25 |
# Switched to local vendoring to avoid CSP issues on HuggingFace Spaces (Issue #24)
|
| 26 |
# The file is located in src/stroke_deepisles_demo/ui/assets/niivue.js
|
|
@@ -33,6 +36,79 @@ _NIIVUE_JS_PATH = _ASSET_DIR / "niivue.js"
|
|
| 33 |
NIIVUE_JS_URL = f"/gradio_api/file={_NIIVUE_JS_PATH.resolve()}"
|
| 34 |
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
def nifti_to_gradio_url(nifti_path: Path) -> str:
|
| 37 |
"""
|
| 38 |
Get Gradio file URL for a NIfTI file.
|
|
@@ -358,8 +434,12 @@ def create_niivue_html(
|
|
| 358 |
# JavaScript code for js_on_load parameter
|
| 359 |
# This runs when the gr.HTML component FIRST loads (mounts)
|
| 360 |
# Variables available: element, props, trigger
|
| 361 |
-
|
| 362 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
const container = element.querySelector('.niivue-viewer') || element;
|
| 364 |
const canvas = element.querySelector('canvas');
|
| 365 |
const status = element.querySelector('.niivue-status');
|
|
@@ -369,34 +449,38 @@ NIIVUE_ON_LOAD_JS = f"""
|
|
| 369 |
const maskUrl = container.dataset.maskUrl;
|
| 370 |
|
| 371 |
// Skip if no volume URL (initial empty state)
|
| 372 |
-
if (!volumeUrl) {
|
| 373 |
if (status) status.innerText = 'Waiting for segmentation...';
|
| 374 |
return;
|
| 375 |
-
}
|
| 376 |
|
| 377 |
-
try {
|
| 378 |
if (status) status.innerText = 'Checking WebGL2...';
|
| 379 |
|
| 380 |
// Check WebGL2 support
|
| 381 |
const gl = canvas.getContext('webgl2');
|
| 382 |
-
if (!gl) {
|
| 383 |
container.innerHTML = '<div style="color:#fff;padding:20px;text-align:center;">WebGL2 not supported. Please use a modern browser.</div>';
|
| 384 |
return;
|
| 385 |
-
}
|
| 386 |
|
| 387 |
if (status) status.innerText = 'Loading NiiVue...';
|
| 388 |
|
| 389 |
-
//
|
| 390 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
|
| 392 |
// Initialize NiiVue
|
| 393 |
-
const nv = new Niivue({
|
| 394 |
logging: false,
|
| 395 |
show3Dcrosshair: true,
|
| 396 |
textHeight: 0.04,
|
| 397 |
backColor: [0, 0, 0, 1],
|
| 398 |
crosshairColor: [0.2, 0.8, 0.2, 1]
|
| 399 |
-
}
|
| 400 |
|
| 401 |
// Attach to canvas
|
| 402 |
await nv.attachToCanvas(canvas);
|
|
@@ -405,31 +489,31 @@ NIIVUE_ON_LOAD_JS = f"""
|
|
| 405 |
if (status) status.style.display = 'none';
|
| 406 |
|
| 407 |
// Prepare volumes
|
| 408 |
-
const volumes = [{
|
| 409 |
|
| 410 |
-
if (maskUrl) {
|
| 411 |
-
volumes.push({
|
| 412 |
url: maskUrl,
|
| 413 |
colorMap: 'red',
|
| 414 |
opacity: 0.5
|
| 415 |
-
}
|
| 416 |
-
}
|
| 417 |
|
| 418 |
// Load volumes
|
| 419 |
await nv.loadVolumes(volumes);
|
| 420 |
|
| 421 |
// Configure view: multiplanar + 3D
|
| 422 |
nv.setSliceType(nv.sliceTypeMultiplanar);
|
| 423 |
-
if (typeof nv.setMultiplanarLayout === 'function') {
|
| 424 |
nv.setMultiplanarLayout(2);
|
| 425 |
-
}
|
| 426 |
nv.opts.show3Dcrosshair = true;
|
| 427 |
nv.setRenderAzimuthElevation(120, 10);
|
| 428 |
nv.drawScene();
|
| 429 |
|
| 430 |
console.log('NiiVue viewer initialized successfully');
|
| 431 |
|
| 432 |
-
}
|
| 433 |
console.error('NiiVue initialization error:', error);
|
| 434 |
// Use textContent instead of innerHTML to prevent XSS
|
| 435 |
const errorDiv = document.createElement('div');
|
|
@@ -437,22 +521,26 @@ NIIVUE_ON_LOAD_JS = f"""
|
|
| 437 |
errorDiv.textContent = 'Error loading viewer: ' + error.message;
|
| 438 |
container.innerHTML = '';
|
| 439 |
container.appendChild(errorDiv);
|
| 440 |
-
}
|
| 441 |
-
}
|
| 442 |
"""
|
| 443 |
|
| 444 |
# JavaScript code for event handlers (e.g. .then(js=...))
|
| 445 |
# This runs after Python updates the HTML value.
|
| 446 |
# ⚠️ CRITICAL: 'element' is NOT available here! Must use document.querySelector
|
| 447 |
-
|
| 448 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
// We must find the container globally since 'element' is not available in event handlers
|
| 450 |
const container = document.querySelector('.niivue-viewer');
|
| 451 |
|
| 452 |
-
if (!container) {
|
| 453 |
console.error('NiiVue container not found');
|
| 454 |
return;
|
| 455 |
-
}
|
| 456 |
|
| 457 |
const canvas = container.querySelector('canvas');
|
| 458 |
const status = container.querySelector('.niivue-status');
|
|
@@ -462,31 +550,35 @@ NIIVUE_UPDATE_JS = f"""
|
|
| 462 |
const maskUrl = container.dataset.maskUrl;
|
| 463 |
|
| 464 |
// Skip if no volume URL
|
| 465 |
-
if (!volumeUrl) {
|
| 466 |
return;
|
| 467 |
-
}
|
| 468 |
|
| 469 |
-
try {
|
| 470 |
if (status) status.innerText = 'Reloading NiiVue...';
|
| 471 |
|
| 472 |
// Check WebGL2 support
|
| 473 |
const gl = canvas.getContext('webgl2');
|
| 474 |
-
if (!gl) {
|
| 475 |
container.innerHTML = '<div style="color:#fff;padding:20px;text-align:center;">WebGL2 not supported. Please use a modern browser.</div>';
|
| 476 |
return;
|
| 477 |
-
}
|
| 478 |
|
| 479 |
-
//
|
| 480 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
|
| 482 |
// Initialize NiiVue
|
| 483 |
-
const nv = new Niivue({
|
| 484 |
logging: false,
|
| 485 |
show3Dcrosshair: true,
|
| 486 |
textHeight: 0.04,
|
| 487 |
backColor: [0, 0, 0, 1],
|
| 488 |
crosshairColor: [0.2, 0.8, 0.2, 1]
|
| 489 |
-
}
|
| 490 |
|
| 491 |
// Attach to canvas
|
| 492 |
await nv.attachToCanvas(canvas);
|
|
@@ -495,39 +587,39 @@ NIIVUE_UPDATE_JS = f"""
|
|
| 495 |
if (status) status.style.display = 'none';
|
| 496 |
|
| 497 |
// Prepare volumes
|
| 498 |
-
const volumes = [{
|
| 499 |
|
| 500 |
-
if (maskUrl) {
|
| 501 |
-
volumes.push({
|
| 502 |
url: maskUrl,
|
| 503 |
colorMap: 'red',
|
| 504 |
opacity: 0.5
|
| 505 |
-
}
|
| 506 |
-
}
|
| 507 |
|
| 508 |
// Load volumes
|
| 509 |
await nv.loadVolumes(volumes);
|
| 510 |
|
| 511 |
// Configure view: multiplanar + 3D
|
| 512 |
nv.setSliceType(nv.sliceTypeMultiplanar);
|
| 513 |
-
if (typeof nv.setMultiplanarLayout === 'function') {
|
| 514 |
nv.setMultiplanarLayout(2);
|
| 515 |
-
}
|
| 516 |
nv.opts.show3Dcrosshair = true;
|
| 517 |
nv.setRenderAzimuthElevation(120, 10);
|
| 518 |
nv.drawScene();
|
| 519 |
|
| 520 |
console.log('NiiVue viewer re-initialized successfully via event handler');
|
| 521 |
|
| 522 |
-
}
|
| 523 |
console.error('NiiVue re-initialization error:', error);
|
| 524 |
const errorDiv = document.createElement('div');
|
| 525 |
errorDiv.style.cssText = 'color:#f66;padding:20px;text-align:center;';
|
| 526 |
errorDiv.textContent = 'Error reloading viewer: ' + error.message;
|
| 527 |
-
if (container) {
|
| 528 |
container.innerHTML = '';
|
| 529 |
container.appendChild(errorDiv);
|
| 530 |
-
}
|
| 531 |
-
}
|
| 532 |
-
}
|
| 533 |
"""
|
|
|
|
| 19 |
import numpy as np
|
| 20 |
from matplotlib.figure import Figure
|
| 21 |
|
| 22 |
+
from stroke_deepisles_demo.core.logging import get_logger
|
| 23 |
from stroke_deepisles_demo.metrics import load_nifti_as_array
|
| 24 |
|
| 25 |
+
logger = get_logger(__name__)
|
| 26 |
+
|
| 27 |
# NiiVue version - updated to latest stable (Dec 2025)
|
| 28 |
# Switched to local vendoring to avoid CSP issues on HuggingFace Spaces (Issue #24)
|
| 29 |
# The file is located in src/stroke_deepisles_demo/ui/assets/niivue.js
|
|
|
|
| 36 |
NIIVUE_JS_URL = f"/gradio_api/file={_NIIVUE_JS_PATH.resolve()}"
|
| 37 |
|
| 38 |
|
| 39 |
+
def get_niivue_loader_path() -> Path:
|
| 40 |
+
"""
|
| 41 |
+
Get path to the NiiVue loader HTML file, creating it if needed.
|
| 42 |
+
|
| 43 |
+
This function generates an HTML file that loads NiiVue as a global.
|
| 44 |
+
Using head_paths with a file is the official Gradio-recommended approach
|
| 45 |
+
for loading custom JavaScript (see GitHub issue #11649).
|
| 46 |
+
|
| 47 |
+
The HTML file is generated at runtime because the niivue.js path
|
| 48 |
+
is dynamic (depends on installation location).
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
Path to the niivue-loader.html file
|
| 52 |
+
|
| 53 |
+
Note:
|
| 54 |
+
The returned path must be included in allowed_paths during launch().
|
| 55 |
+
"""
|
| 56 |
+
loader_path = _ASSET_DIR / "niivue-loader.html"
|
| 57 |
+
|
| 58 |
+
# Generate the loader HTML with the correct absolute path
|
| 59 |
+
loader_content = f"""<!--
|
| 60 |
+
NiiVue Loader for Gradio (auto-generated)
|
| 61 |
+
Loads NiiVue library and exposes it globally for js_on_load handlers.
|
| 62 |
+
See: docs/specs/24-bug-hf-spaces-loading-forever.md
|
| 63 |
+
-->
|
| 64 |
+
<script type="module">
|
| 65 |
+
import {{ Niivue }} from '{NIIVUE_JS_URL}';
|
| 66 |
+
window.Niivue = Niivue;
|
| 67 |
+
console.log('[NiiVue Loader] Loaded globally:', typeof window.Niivue);
|
| 68 |
+
</script>
|
| 69 |
+
"""
|
| 70 |
+
|
| 71 |
+
# Write/update the loader file (idempotent)
|
| 72 |
+
# This ensures the path is always correct for the current installation
|
| 73 |
+
try:
|
| 74 |
+
# Check if file exists and has correct content
|
| 75 |
+
if loader_path.exists():
|
| 76 |
+
existing_content = loader_path.read_text()
|
| 77 |
+
if existing_content == loader_content:
|
| 78 |
+
return loader_path
|
| 79 |
+
|
| 80 |
+
loader_path.write_text(loader_content)
|
| 81 |
+
logger.debug("Generated NiiVue loader at %s", loader_path)
|
| 82 |
+
except OSError as e:
|
| 83 |
+
# If we can't write (e.g., read-only filesystem), the file should
|
| 84 |
+
# already exist from the build process
|
| 85 |
+
logger.warning("Could not write loader file at %s: %s", loader_path, e)
|
| 86 |
+
if not loader_path.exists():
|
| 87 |
+
raise RuntimeError(
|
| 88 |
+
f"NiiVue loader file not found and cannot be created: {loader_path}"
|
| 89 |
+
) from e
|
| 90 |
+
|
| 91 |
+
return loader_path
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# Legacy function for backward compatibility
|
| 95 |
+
def get_niivue_head_script() -> str:
|
| 96 |
+
"""
|
| 97 |
+
Get HTML script tag for loading NiiVue in Gradio's head.
|
| 98 |
+
|
| 99 |
+
DEPRECATED: Use get_niivue_loader_path() with head_paths instead.
|
| 100 |
+
This function is kept for backward compatibility only.
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
HTML string with script tag
|
| 104 |
+
"""
|
| 105 |
+
return f"""<script type="module">
|
| 106 |
+
import {{ Niivue }} from '{NIIVUE_JS_URL}';
|
| 107 |
+
window.Niivue = Niivue;
|
| 108 |
+
console.log('NiiVue loaded globally:', typeof window.Niivue);
|
| 109 |
+
</script>"""
|
| 110 |
+
|
| 111 |
+
|
| 112 |
def nifti_to_gradio_url(nifti_path: Path) -> str:
|
| 113 |
"""
|
| 114 |
Get Gradio file URL for a NIfTI file.
|
|
|
|
| 434 |
# JavaScript code for js_on_load parameter
|
| 435 |
# This runs when the gr.HTML component FIRST loads (mounts)
|
| 436 |
# Variables available: element, props, trigger
|
| 437 |
+
#
|
| 438 |
+
# IMPORTANT: This code uses window.Niivue which must be loaded via
|
| 439 |
+
# gr.Blocks(head=get_niivue_head_script()). Do NOT use dynamic import()
|
| 440 |
+
# as it breaks Gradio on HF Spaces.
|
| 441 |
+
NIIVUE_ON_LOAD_JS = """
|
| 442 |
+
(async () => {
|
| 443 |
const container = element.querySelector('.niivue-viewer') || element;
|
| 444 |
const canvas = element.querySelector('canvas');
|
| 445 |
const status = element.querySelector('.niivue-status');
|
|
|
|
| 449 |
const maskUrl = container.dataset.maskUrl;
|
| 450 |
|
| 451 |
// Skip if no volume URL (initial empty state)
|
| 452 |
+
if (!volumeUrl) {
|
| 453 |
if (status) status.innerText = 'Waiting for segmentation...';
|
| 454 |
return;
|
| 455 |
+
}
|
| 456 |
|
| 457 |
+
try {
|
| 458 |
if (status) status.innerText = 'Checking WebGL2...';
|
| 459 |
|
| 460 |
// Check WebGL2 support
|
| 461 |
const gl = canvas.getContext('webgl2');
|
| 462 |
+
if (!gl) {
|
| 463 |
container.innerHTML = '<div style="color:#fff;padding:20px;text-align:center;">WebGL2 not supported. Please use a modern browser.</div>';
|
| 464 |
return;
|
| 465 |
+
}
|
| 466 |
|
| 467 |
if (status) status.innerText = 'Loading NiiVue...';
|
| 468 |
|
| 469 |
+
// Use globally loaded NiiVue (from head script)
|
| 470 |
+
// Do NOT use dynamic import() - it breaks Gradio on HF Spaces
|
| 471 |
+
const Niivue = window.Niivue;
|
| 472 |
+
if (!Niivue) {
|
| 473 |
+
throw new Error('NiiVue not loaded. Ensure head script is included via gr.Blocks(head=...)');
|
| 474 |
+
}
|
| 475 |
|
| 476 |
// Initialize NiiVue
|
| 477 |
+
const nv = new Niivue({
|
| 478 |
logging: false,
|
| 479 |
show3Dcrosshair: true,
|
| 480 |
textHeight: 0.04,
|
| 481 |
backColor: [0, 0, 0, 1],
|
| 482 |
crosshairColor: [0.2, 0.8, 0.2, 1]
|
| 483 |
+
});
|
| 484 |
|
| 485 |
// Attach to canvas
|
| 486 |
await nv.attachToCanvas(canvas);
|
|
|
|
| 489 |
if (status) status.style.display = 'none';
|
| 490 |
|
| 491 |
// Prepare volumes
|
| 492 |
+
const volumes = [{ url: volumeUrl, name: 'input.nii.gz' }];
|
| 493 |
|
| 494 |
+
if (maskUrl) {
|
| 495 |
+
volumes.push({
|
| 496 |
url: maskUrl,
|
| 497 |
colorMap: 'red',
|
| 498 |
opacity: 0.5
|
| 499 |
+
});
|
| 500 |
+
}
|
| 501 |
|
| 502 |
// Load volumes
|
| 503 |
await nv.loadVolumes(volumes);
|
| 504 |
|
| 505 |
// Configure view: multiplanar + 3D
|
| 506 |
nv.setSliceType(nv.sliceTypeMultiplanar);
|
| 507 |
+
if (typeof nv.setMultiplanarLayout === 'function') {
|
| 508 |
nv.setMultiplanarLayout(2);
|
| 509 |
+
}
|
| 510 |
nv.opts.show3Dcrosshair = true;
|
| 511 |
nv.setRenderAzimuthElevation(120, 10);
|
| 512 |
nv.drawScene();
|
| 513 |
|
| 514 |
console.log('NiiVue viewer initialized successfully');
|
| 515 |
|
| 516 |
+
} catch (error) {
|
| 517 |
console.error('NiiVue initialization error:', error);
|
| 518 |
// Use textContent instead of innerHTML to prevent XSS
|
| 519 |
const errorDiv = document.createElement('div');
|
|
|
|
| 521 |
errorDiv.textContent = 'Error loading viewer: ' + error.message;
|
| 522 |
container.innerHTML = '';
|
| 523 |
container.appendChild(errorDiv);
|
| 524 |
+
}
|
| 525 |
+
})();
|
| 526 |
"""
|
| 527 |
|
| 528 |
# JavaScript code for event handlers (e.g. .then(js=...))
|
| 529 |
# This runs after Python updates the HTML value.
|
| 530 |
# ⚠️ CRITICAL: 'element' is NOT available here! Must use document.querySelector
|
| 531 |
+
#
|
| 532 |
+
# IMPORTANT: This code uses window.Niivue which must be loaded via
|
| 533 |
+
# head_paths with niivue-loader.html. Do NOT use dynamic import()
|
| 534 |
+
# as it breaks Gradio on HF Spaces.
|
| 535 |
+
NIIVUE_UPDATE_JS = """
|
| 536 |
+
(async () => {
|
| 537 |
// We must find the container globally since 'element' is not available in event handlers
|
| 538 |
const container = document.querySelector('.niivue-viewer');
|
| 539 |
|
| 540 |
+
if (!container) {
|
| 541 |
console.error('NiiVue container not found');
|
| 542 |
return;
|
| 543 |
+
}
|
| 544 |
|
| 545 |
const canvas = container.querySelector('canvas');
|
| 546 |
const status = container.querySelector('.niivue-status');
|
|
|
|
| 550 |
const maskUrl = container.dataset.maskUrl;
|
| 551 |
|
| 552 |
// Skip if no volume URL
|
| 553 |
+
if (!volumeUrl) {
|
| 554 |
return;
|
| 555 |
+
}
|
| 556 |
|
| 557 |
+
try {
|
| 558 |
if (status) status.innerText = 'Reloading NiiVue...';
|
| 559 |
|
| 560 |
// Check WebGL2 support
|
| 561 |
const gl = canvas.getContext('webgl2');
|
| 562 |
+
if (!gl) {
|
| 563 |
container.innerHTML = '<div style="color:#fff;padding:20px;text-align:center;">WebGL2 not supported. Please use a modern browser.</div>';
|
| 564 |
return;
|
| 565 |
+
}
|
| 566 |
|
| 567 |
+
// Use globally loaded NiiVue (from head script)
|
| 568 |
+
// Do NOT use dynamic import() - it breaks Gradio on HF Spaces
|
| 569 |
+
const Niivue = window.Niivue;
|
| 570 |
+
if (!Niivue) {
|
| 571 |
+
throw new Error('NiiVue not loaded. Ensure head_paths includes niivue-loader.html');
|
| 572 |
+
}
|
| 573 |
|
| 574 |
// Initialize NiiVue
|
| 575 |
+
const nv = new Niivue({
|
| 576 |
logging: false,
|
| 577 |
show3Dcrosshair: true,
|
| 578 |
textHeight: 0.04,
|
| 579 |
backColor: [0, 0, 0, 1],
|
| 580 |
crosshairColor: [0.2, 0.8, 0.2, 1]
|
| 581 |
+
});
|
| 582 |
|
| 583 |
// Attach to canvas
|
| 584 |
await nv.attachToCanvas(canvas);
|
|
|
|
| 587 |
if (status) status.style.display = 'none';
|
| 588 |
|
| 589 |
// Prepare volumes
|
| 590 |
+
const volumes = [{ url: volumeUrl, name: 'input.nii.gz' }];
|
| 591 |
|
| 592 |
+
if (maskUrl) {
|
| 593 |
+
volumes.push({
|
| 594 |
url: maskUrl,
|
| 595 |
colorMap: 'red',
|
| 596 |
opacity: 0.5
|
| 597 |
+
});
|
| 598 |
+
}
|
| 599 |
|
| 600 |
// Load volumes
|
| 601 |
await nv.loadVolumes(volumes);
|
| 602 |
|
| 603 |
// Configure view: multiplanar + 3D
|
| 604 |
nv.setSliceType(nv.sliceTypeMultiplanar);
|
| 605 |
+
if (typeof nv.setMultiplanarLayout === 'function') {
|
| 606 |
nv.setMultiplanarLayout(2);
|
| 607 |
+
}
|
| 608 |
nv.opts.show3Dcrosshair = true;
|
| 609 |
nv.setRenderAzimuthElevation(120, 10);
|
| 610 |
nv.drawScene();
|
| 611 |
|
| 612 |
console.log('NiiVue viewer re-initialized successfully via event handler');
|
| 613 |
|
| 614 |
+
} catch (error) {
|
| 615 |
console.error('NiiVue re-initialization error:', error);
|
| 616 |
const errorDiv = document.createElement('div');
|
| 617 |
errorDiv.style.cssText = 'color:#f66;padding:20px;text-align:center;';
|
| 618 |
errorDiv.textContent = 'Error reloading viewer: ' + error.message;
|
| 619 |
+
if (container) {
|
| 620 |
container.innerHTML = '';
|
| 621 |
container.appendChild(errorDiv);
|
| 622 |
+
}
|
| 623 |
+
}
|
| 624 |
+
})();
|
| 625 |
"""
|