VibecoderMcSwaggins commited on
Commit
1fbd498
·
unverified ·
2 Parent(s): d8cfaa8 518063b

Merge pull request #27 from The-Obstacle-Is-The-Way/main

Browse files

fix(ui): NiiVue self-loading + prediction overlay visibility (#24)

app.py CHANGED
@@ -18,7 +18,6 @@ gr.set_static_paths(paths=[str(_ASSETS_DIR)])
18
  from stroke_deepisles_demo.core.config import get_settings # noqa: E402
19
  from stroke_deepisles_demo.core.logging import get_logger, setup_logging # noqa: E402
20
  from stroke_deepisles_demo.ui.app import get_demo # noqa: E402
21
- from stroke_deepisles_demo.ui.viewer import get_niivue_head_html # noqa: E402
22
 
23
  logger = get_logger(__name__)
24
 
@@ -37,8 +36,9 @@ if __name__ == "__main__":
37
  logger.info("Assets exists: %s", _ASSETS_DIR.exists())
38
  logger.info("=" * 60)
39
 
40
- # Get the NiiVue loader HTML (inline script, no file I/O needed)
41
- niivue_head = get_niivue_head_html()
 
42
 
43
  demo.launch(
44
  server_name=settings.gradio_server_name,
@@ -47,5 +47,4 @@ if __name__ == "__main__":
47
  theme=gr.themes.Soft(),
48
  css="footer {visibility: hidden}",
49
  allowed_paths=[str(_ASSETS_DIR)],
50
- head=niivue_head, # Inject NiiVue loader directly
51
  )
 
18
  from stroke_deepisles_demo.core.config import get_settings # noqa: E402
19
  from stroke_deepisles_demo.core.logging import get_logger, setup_logging # noqa: E402
20
  from stroke_deepisles_demo.ui.app import get_demo # noqa: E402
 
21
 
22
  logger = get_logger(__name__)
23
 
 
36
  logger.info("Assets exists: %s", _ASSETS_DIR.exists())
37
  logger.info("=" * 60)
38
 
39
+ # NOTE: No `head=` parameter needed!
40
+ # NiiVue is loaded directly by js_on_load from data-niivue-url attribute.
41
+ # This fixes the HF Spaces "Loading..." forever bug (Issue #24).
42
 
43
  demo.launch(
44
  server_name=settings.gradio_server_name,
 
47
  theme=gr.themes.Soft(),
48
  css="footer {visibility: hidden}",
49
  allowed_paths=[str(_ASSETS_DIR)],
 
50
  )
pyproject.toml CHANGED
@@ -111,6 +111,7 @@ module = [
111
  "datasets.*",
112
  "niivue.*",
113
  "numpy.*",
 
114
  "pytest.*",
115
  # DeepISLES modules (only available in DeepISLES Docker image)
116
  "src.isles22_ensemble",
 
111
  "datasets.*",
112
  "niivue.*",
113
  "numpy.*",
114
+ "pyarrow.*",
115
  "pytest.*",
116
  # DeepISLES modules (only available in DeepISLES Docker image)
117
  "src.isles22_ensemble",
src/stroke_deepisles_demo/data/adapter.py CHANGED
@@ -269,7 +269,7 @@ class HuggingFaceDataset:
269
  Returns:
270
  Dict with dwi_bytes, adc_bytes, and optionally mask_bytes
271
  """
272
- import pyarrow.parquet as pq # type: ignore[import-untyped]
273
  from huggingface_hub import HfFileSystem
274
 
275
  from stroke_deepisles_demo.data.constants import ISLES24_NUM_FILES
 
269
  Returns:
270
  Dict with dwi_bytes, adc_bytes, and optionally mask_bytes
271
  """
272
+ import pyarrow.parquet as pq
273
  from huggingface_hub import HfFileSystem
274
 
275
  from stroke_deepisles_demo.data.constants import ISLES24_NUM_FILES
src/stroke_deepisles_demo/ui/app.py CHANGED
@@ -27,7 +27,6 @@ from stroke_deepisles_demo.ui.components import ( # noqa: E402
27
  from stroke_deepisles_demo.ui.viewer import ( # noqa: E402
28
  NIIVUE_UPDATE_JS,
29
  create_niivue_html,
30
- get_niivue_head_html,
31
  nifti_to_gradio_url,
32
  render_3panel_view,
33
  render_slice_comparison,
@@ -288,9 +287,10 @@ if __name__ == "__main__":
288
  logger.info("Assets exists: %s", _ASSETS_DIR.exists())
289
  logger.info("=" * 60)
290
 
291
- # Get the NiiVue loader HTML (inline script, no file I/O needed)
292
- # Using `head` param directly is simpler than `head_paths` with file generation
293
- niivue_head = get_niivue_head_html()
 
294
 
295
  get_demo().launch(
296
  server_name=settings.gradio_server_name,
@@ -300,5 +300,4 @@ if __name__ == "__main__":
300
  css="footer {visibility: hidden}",
301
  show_error=True, # Show full Python tracebacks in UI for debugging
302
  allowed_paths=[str(_ASSETS_DIR)],
303
- head=niivue_head, # Inject NiiVue loader directly (simpler than head_paths)
304
  )
 
27
  from stroke_deepisles_demo.ui.viewer import ( # noqa: E402
28
  NIIVUE_UPDATE_JS,
29
  create_niivue_html,
 
30
  nifti_to_gradio_url,
31
  render_3panel_view,
32
  render_slice_comparison,
 
287
  logger.info("Assets exists: %s", _ASSETS_DIR.exists())
288
  logger.info("=" * 60)
289
 
290
+ # NOTE: No `head=` parameter needed!
291
+ # NiiVue is loaded directly by js_on_load from data-niivue-url attribute.
292
+ # This fixes the HF Spaces "Loading..." forever bug (Issue #24) by removing
293
+ # the dependency on head scripts that could block Gradio initialization.
294
 
295
  get_demo().launch(
296
  server_name=settings.gradio_server_name,
 
300
  css="footer {visibility: hidden}",
301
  show_error=True, # Show full Python tracebacks in UI for debugging
302
  allowed_paths=[str(_ASSETS_DIR)],
 
303
  )
src/stroke_deepisles_demo/ui/viewer.py CHANGED
@@ -390,12 +390,16 @@ def create_niivue_html(
390
  Create HTML for NiiVue viewer (static content only).
391
 
392
  This function generates an HTML snippet with data attributes containing
393
- volume URLs. The actual NiiVue initialization is handled by js_on_load
394
- in the gr.HTML component (see NIIVUE_ON_LOAD_JS).
395
 
396
  IMPORTANT: Gradio's gr.HTML strips <script> tags for security.
397
  JavaScript must be passed via the js_on_load parameter instead.
398
 
 
 
 
 
399
  Args:
400
  volume_url: Gradio file URL (e.g., /gradio_api/file=/path/to/file.nii.gz)
401
  mask_url: Optional Gradio file URL to mask NIfTI file
@@ -416,12 +420,16 @@ def create_niivue_html(
416
  # Using json.dumps ensures proper escaping
417
  volume_attr = f"data-volume-url={json.dumps(volume_url)}"
418
  mask_attr = f"data-mask-url={json.dumps(mask_url)}" if mask_url else 'data-mask-url=""'
 
 
 
419
 
420
  return f"""<div
421
  id="niivue-container-{viewer_id}"
422
  class="niivue-viewer"
423
  {volume_attr}
424
  {mask_attr}
 
425
  style="width:100%; height:{height}px; background:#000; border-radius:8px; position:relative;"
426
  >
427
  <canvas style="width:100%; height:100%;"></canvas>
@@ -435,13 +443,12 @@ def create_niivue_html(
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
- # head_paths with niivue-loader.html. Do NOT use dynamic import()
440
- # as it breaks Gradio on HF Spaces.
441
  #
442
- # NOTE: waitForNiivue() is duplicated in NIIVUE_UPDATE_JS below. This is
443
- # intentional - extracting to a shared constant would require complex f-string
444
- # escaping of all JS braces. The 6-line duplication is acceptable for readability.
445
  NIIVUE_ON_LOAD_JS = """
446
  (async () => {
447
  const container = element.querySelector('.niivue-viewer') || element;
@@ -451,6 +458,7 @@ NIIVUE_ON_LOAD_JS = """
451
  // Get URLs from data attributes
452
  const volumeUrl = container.dataset.volumeUrl;
453
  const maskUrl = container.dataset.maskUrl;
 
454
 
455
  // Skip if no volume URL (initial empty state)
456
  if (!volumeUrl) {
@@ -468,28 +476,36 @@ NIIVUE_ON_LOAD_JS = """
468
  return;
469
  }
470
 
471
- if (status) status.innerText = 'Loading NiiVue...';
472
-
473
- // Use globally loaded NiiVue (from head script)
474
- // Poll for it to handle race conditions (Fixes P0 Loading Bug)
475
- const waitForNiivue = async () => {
476
- for (let i = 0; i < 50; i++) {
477
- if (window.Niivue) return window.Niivue;
478
- // Check if loader script failed (error stored in global)
479
- if (window.NIIVUE_LOAD_ERROR) {
480
- throw new Error('NiiVue loader failed: ' + window.NIIVUE_LOAD_ERROR);
481
- }
482
- await new Promise(r => setTimeout(r, 100));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
  }
484
- return null;
485
  };
486
 
487
- const Niivue = await waitForNiivue();
488
- if (!Niivue) {
489
- // Provide diagnostic info about what might be wrong
490
- const loadErr = window.NIIVUE_LOAD_ERROR || 'unknown';
491
- throw new Error('NiiVue not loaded after 5s. Check browser console for errors. Load error: ' + loadErr);
492
- }
493
 
494
  // Initialize NiiVue
495
  const nv = new Niivue({
@@ -529,10 +545,10 @@ NIIVUE_ON_LOAD_JS = """
529
  nv.setRenderAzimuthElevation(120, 10);
530
  nv.drawScene();
531
 
532
- console.log('NiiVue viewer initialized successfully');
533
 
534
  } catch (error) {
535
- console.error('NiiVue initialization error:', error);
536
  // Use textContent instead of innerHTML to prevent XSS
537
  const errorDiv = document.createElement('div');
538
  errorDiv.style.cssText = 'color:#f66;padding:20px;text-align:center;';
@@ -547,20 +563,15 @@ NIIVUE_ON_LOAD_JS = """
547
  # This runs after Python updates the HTML value.
548
  # ⚠️ CRITICAL: 'element' is NOT available here! Must use document.querySelector
549
  #
550
- # IMPORTANT: This code uses window.Niivue which must be loaded via
551
- # head_paths with niivue-loader.html. Do NOT use dynamic import()
552
- # as it breaks Gradio on HF Spaces.
553
- #
554
- # NOTE: waitForNiivue() is duplicated from NIIVUE_ON_LOAD_JS above. This is
555
- # intentional - extracting to a shared constant would require complex f-string
556
- # escaping of all JS braces. The 6-line duplication is acceptable for readability.
557
  NIIVUE_UPDATE_JS = """
558
  (async () => {
559
  // We must find the container globally since 'element' is not available in event handlers
560
  const container = document.querySelector('.niivue-viewer');
561
 
562
  if (!container) {
563
- console.error('NiiVue container not found');
564
  return;
565
  }
566
 
@@ -570,6 +581,7 @@ NIIVUE_UPDATE_JS = """
570
  // Get URLs from data attributes
571
  const volumeUrl = container.dataset.volumeUrl;
572
  const maskUrl = container.dataset.maskUrl;
 
573
 
574
  // Skip if no volume URL
575
  if (!volumeUrl) {
@@ -577,7 +589,10 @@ NIIVUE_UPDATE_JS = """
577
  }
578
 
579
  try {
580
- if (status) status.innerText = 'Reloading NiiVue...';
 
 
 
581
 
582
  // Check WebGL2 support
583
  const gl = canvas.getContext('webgl2');
@@ -586,26 +601,32 @@ NIIVUE_UPDATE_JS = """
586
  return;
587
  }
588
 
589
- // Use globally loaded NiiVue (from head script)
590
- // Poll for it to handle race conditions (Fixes P0 Loading Bug)
591
- const waitForNiivue = async () => {
592
- for (let i = 0; i < 50; i++) {
593
- if (window.Niivue) return window.Niivue;
594
- // Check if loader script failed (error stored in global)
595
- if (window.NIIVUE_LOAD_ERROR) {
596
- throw new Error('NiiVue loader failed: ' + window.NIIVUE_LOAD_ERROR);
597
- }
598
- await new Promise(r => setTimeout(r, 100));
 
 
 
 
 
 
 
 
 
 
 
 
599
  }
600
- return null;
601
  };
602
 
603
- const Niivue = await waitForNiivue();
604
- if (!Niivue) {
605
- // Provide diagnostic info about what might be wrong
606
- const loadErr = window.NIIVUE_LOAD_ERROR || 'unknown';
607
- throw new Error('NiiVue not loaded after 5s. Check browser console for errors. Load error: ' + loadErr);
608
- }
609
 
610
  // Initialize NiiVue
611
  const nv = new Niivue({
@@ -645,10 +666,10 @@ NIIVUE_UPDATE_JS = """
645
  nv.setRenderAzimuthElevation(120, 10);
646
  nv.drawScene();
647
 
648
- console.log('NiiVue viewer re-initialized successfully via event handler');
649
 
650
  } catch (error) {
651
- console.error('NiiVue re-initialization error:', error);
652
  const errorDiv = document.createElement('div');
653
  errorDiv.style.cssText = 'color:#f66;padding:20px;text-align:center;';
654
  errorDiv.textContent = 'Error reloading viewer: ' + error.message;
 
390
  Create HTML for NiiVue viewer (static content only).
391
 
392
  This function generates an HTML snippet with data attributes containing
393
+ volume URLs AND the NiiVue library URL. The actual NiiVue initialization
394
+ is handled by js_on_load in the gr.HTML component (see NIIVUE_ON_LOAD_JS).
395
 
396
  IMPORTANT: Gradio's gr.HTML strips <script> tags for security.
397
  JavaScript must be passed via the js_on_load parameter instead.
398
 
399
+ The NiiVue library URL is embedded in data-niivue-url so that js_on_load
400
+ can load the library on-demand. This removes the dependency on the `head=`
401
+ parameter working correctly, which has been problematic on HF Spaces.
402
+
403
  Args:
404
  volume_url: Gradio file URL (e.g., /gradio_api/file=/path/to/file.nii.gz)
405
  mask_url: Optional Gradio file URL to mask NIfTI file
 
420
  # Using json.dumps ensures proper escaping
421
  volume_attr = f"data-volume-url={json.dumps(volume_url)}"
422
  mask_attr = f"data-mask-url={json.dumps(mask_url)}" if mask_url else 'data-mask-url=""'
423
+ # Embed NiiVue library URL so js_on_load can load it directly
424
+ # This removes dependency on head= script working on HF Spaces
425
+ niivue_url_attr = f"data-niivue-url={json.dumps(NIIVUE_JS_URL)}"
426
 
427
  return f"""<div
428
  id="niivue-container-{viewer_id}"
429
  class="niivue-viewer"
430
  {volume_attr}
431
  {mask_attr}
432
+ {niivue_url_attr}
433
  style="width:100%; height:{height}px; background:#000; border-radius:8px; position:relative;"
434
  >
435
  <canvas style="width:100%; height:100%;"></canvas>
 
443
  # This runs when the gr.HTML component FIRST loads (mounts)
444
  # Variables available: element, props, trigger
445
  #
446
+ # CRITICAL FIX (Issue #24): This code loads NiiVue DIRECTLY via dynamic import()
447
+ # from the data-niivue-url attribute. This removes the dependency on the `head=`
448
+ # parameter which was blocking Gradio initialization on HF Spaces.
449
  #
450
+ # The old approach used window.Niivue from a head script, but ES module failures
451
+ # in <head> can prevent Gradio's Svelte app from hydrating, causing "Loading..." forever.
 
452
  NIIVUE_ON_LOAD_JS = """
453
  (async () => {
454
  const container = element.querySelector('.niivue-viewer') || element;
 
458
  // Get URLs from data attributes
459
  const volumeUrl = container.dataset.volumeUrl;
460
  const maskUrl = container.dataset.maskUrl;
461
+ const niivueUrl = container.dataset.niivueUrl;
462
 
463
  // Skip if no volume URL (initial empty state)
464
  if (!volumeUrl) {
 
476
  return;
477
  }
478
 
479
+ if (status) status.innerText = 'Loading NiiVue library...';
480
+
481
+ // Load NiiVue directly (self-sufficient, no head= dependency)
482
+ // This fixes the HF Spaces "Loading..." forever bug (Issue #24)
483
+ const loadNiivue = async () => {
484
+ // Return cached if already loaded
485
+ if (window.Niivue) {
486
+ console.log('[NiiVue] Using cached window.Niivue');
487
+ return window.Niivue;
488
+ }
489
+
490
+ // Load directly from the URL embedded in data attribute
491
+ if (!niivueUrl) {
492
+ throw new Error('No NiiVue URL provided in data-niivue-url attribute');
493
+ }
494
+
495
+ console.log('[NiiVue] Loading from:', niivueUrl);
496
+ try {
497
+ const module = await import(niivueUrl);
498
+ window.Niivue = module.Niivue;
499
+ console.log('[NiiVue] Successfully loaded and cached');
500
+ return module.Niivue;
501
+ } catch (e) {
502
+ // Provide detailed error for debugging
503
+ console.error('[NiiVue] Import failed:', e);
504
+ throw new Error('Failed to load NiiVue from ' + niivueUrl + ': ' + e.message);
505
  }
 
506
  };
507
 
508
+ const Niivue = await loadNiivue();
 
 
 
 
 
509
 
510
  // Initialize NiiVue
511
  const nv = new Niivue({
 
545
  nv.setRenderAzimuthElevation(120, 10);
546
  nv.drawScene();
547
 
548
+ console.log('[NiiVue] Viewer initialized successfully');
549
 
550
  } catch (error) {
551
+ console.error('[NiiVue] Initialization error:', error);
552
  // Use textContent instead of innerHTML to prevent XSS
553
  const errorDiv = document.createElement('div');
554
  errorDiv.style.cssText = 'color:#f66;padding:20px;text-align:center;';
 
563
  # This runs after Python updates the HTML value.
564
  # ⚠️ CRITICAL: 'element' is NOT available here! Must use document.querySelector
565
  #
566
+ # CRITICAL FIX (Issue #24): This code loads NiiVue DIRECTLY via dynamic import()
567
+ # from the data-niivue-url attribute. Same pattern as NIIVUE_ON_LOAD_JS.
 
 
 
 
 
568
  NIIVUE_UPDATE_JS = """
569
  (async () => {
570
  // We must find the container globally since 'element' is not available in event handlers
571
  const container = document.querySelector('.niivue-viewer');
572
 
573
  if (!container) {
574
+ console.error('[NiiVue] Container not found');
575
  return;
576
  }
577
 
 
581
  // Get URLs from data attributes
582
  const volumeUrl = container.dataset.volumeUrl;
583
  const maskUrl = container.dataset.maskUrl;
584
+ const niivueUrl = container.dataset.niivueUrl;
585
 
586
  // Skip if no volume URL
587
  if (!volumeUrl) {
 
589
  }
590
 
591
  try {
592
+ if (status) {
593
+ status.style.display = 'block';
594
+ status.innerText = 'Reloading viewer...';
595
+ }
596
 
597
  // Check WebGL2 support
598
  const gl = canvas.getContext('webgl2');
 
601
  return;
602
  }
603
 
604
+ // Load NiiVue directly (self-sufficient, no head= dependency)
605
+ const loadNiivue = async () => {
606
+ // Return cached if already loaded
607
+ if (window.Niivue) {
608
+ console.log('[NiiVue] Using cached window.Niivue');
609
+ return window.Niivue;
610
+ }
611
+
612
+ // Load directly from the URL embedded in data attribute
613
+ if (!niivueUrl) {
614
+ throw new Error('No NiiVue URL provided in data-niivue-url attribute');
615
+ }
616
+
617
+ console.log('[NiiVue] Loading from:', niivueUrl);
618
+ try {
619
+ const module = await import(niivueUrl);
620
+ window.Niivue = module.Niivue;
621
+ console.log('[NiiVue] Successfully loaded and cached');
622
+ return module.Niivue;
623
+ } catch (e) {
624
+ console.error('[NiiVue] Import failed:', e);
625
+ throw new Error('Failed to load NiiVue from ' + niivueUrl + ': ' + e.message);
626
  }
 
627
  };
628
 
629
+ const Niivue = await loadNiivue();
 
 
 
 
 
630
 
631
  // Initialize NiiVue
632
  const nv = new Niivue({
 
666
  nv.setRenderAzimuthElevation(120, 10);
667
  nv.drawScene();
668
 
669
+ console.log('[NiiVue] Viewer re-initialized successfully via event handler');
670
 
671
  } catch (error) {
672
+ console.error('[NiiVue] Re-initialization error:', error);
673
  const errorDiv = document.createElement('div');
674
  errorDiv.style.cssText = 'color:#f66;padding:20px;text-align:center;';
675
  errorDiv.textContent = 'Error reloading viewer: ' + error.message;