kstember commited on
Commit
3805678
·
verified ·
1 Parent(s): cddb718

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +11 -344
app.py CHANGED
@@ -12,8 +12,6 @@ import SimpleITK as sitk
12
  from huggingface_hub import hf_hub_download
13
 
14
  import spaces
15
- from pathlib import Path
16
- from typing import Optional, Dict
17
 
18
 
19
  # Dummy function to satisfy HF Spaces GPU detection during startup
@@ -68,6 +66,7 @@ try:
68
  except Exception:
69
  pass
70
 
 
71
  def is_zip(path: Path) -> bool:
72
  return path.suffix.lower() == ".zip"
73
 
@@ -297,7 +296,8 @@ def register_rigid_affine(prev_stripped: Path, new_stripped: Path, reg_dir: Path
297
  sitk.WriteImage(registered_img, str(registered_path))
298
  return registered_path, affine_tx, fixed
299
 
300
- @spaces.GPU(duration=300)
 
301
  def run_flames_single(input_nii: Path, out_mask_path: Path, device: str = "cuda") -> Path:
302
  """Run FLAMeS (nnUNetv2) on a single input NIfTI and write a mask. Uses shared MODEL_ROOT."""
303
  with (Path(input_nii).open("rb")):
@@ -498,321 +498,6 @@ def package_selected(job_dir: Path,
498
 
499
 
500
  # =========================
501
- # NiiVue viewer (HTML iframe) — uses /jobs/<relative path>
502
- # =========================
503
- from pathlib import Path
504
- from typing import Optional, Dict
505
- import json
506
-
507
- def niivue_iframe_for(
508
- abs_fs_path: Path,
509
- overlay_abs_fs_paths: Optional[Dict[str, Path]] = None,
510
- default_overlay_key: Optional[str] = "New lesions only",
511
- ) -> str:
512
- """
513
- Creates a standalone HTML file with NiiVue that embeds file paths.
514
- The HTML uses absolute /file= URLs that work in HF Spaces.
515
- """
516
- base_path = Path(abs_fs_path).resolve()
517
-
518
- # Find job directory (go up from registered_dir)
519
- job_dir = base_path.parent
520
- while job_dir.name in ["registered", "stripped", "seg_flames", "diff_seg"]:
521
- job_dir = job_dir.parent
522
-
523
- viewer_html = job_dir / "niivue_viewer.html"
524
-
525
- # Build file URLs - these need to be absolute paths for Gradio
526
- def make_file_url(p: Path) -> str:
527
- return f"/file={p.resolve()}"
528
-
529
- base_url = make_file_url(base_path)
530
-
531
- overlay_map = {}
532
- if overlay_abs_fs_paths:
533
- for label, p in overlay_abs_fs_paths.items():
534
- overlay_map[label] = make_file_url(Path(p))
535
-
536
- # Create standalone HTML with embedded URLs
537
- html_content = f"""<!DOCTYPE html>
538
- <html lang="en">
539
- <head>
540
- <meta charset="UTF-8">
541
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
542
- <title>NiiVue Viewer</title>
543
- <style>
544
- * {{ margin: 0; padding: 0; box-sizing: border-box; }}
545
- html, body {{
546
- height: 100%;
547
- background: #111;
548
- color: #ddd;
549
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
550
- overflow: hidden;
551
- }}
552
- #app {{
553
- height: 100%;
554
- display: flex;
555
- flex-direction: column;
556
- }}
557
- #toolbar {{
558
- flex: 0 0 auto;
559
- display: flex;
560
- flex-wrap: wrap;
561
- gap: 12px;
562
- align-items: center;
563
- padding: 10px;
564
- background: #1a1a1a;
565
- border-bottom: 1px solid #333;
566
- }}
567
- #toolbar label {{
568
- display: flex;
569
- align-items: center;
570
- gap: 6px;
571
- font-size: 14px;
572
- }}
573
- select, input[type="range"] {{
574
- background: #222;
575
- color: #ddd;
576
- border: 1px solid #444;
577
- border-radius: 6px;
578
- padding: 6px 10px;
579
- font-size: 14px;
580
- }}
581
- select:focus, input:focus {{
582
- outline: 2px solid #4cafef;
583
- outline-offset: 2px;
584
- }}
585
- #status {{
586
- margin-left: auto;
587
- font-size: 13px;
588
- opacity: 0.8;
589
- }}
590
- #canvas-container {{
591
- flex: 1;
592
- min-height: 0;
593
- position: relative;
594
- }}
595
- #gl {{
596
- width: 100%;
597
- height: 100%;
598
- display: block;
599
- }}
600
- </style>
601
- </head>
602
- <body>
603
- <div id="app">
604
- <div id="toolbar">
605
- <label>
606
- View:
607
- <select id="view-select">
608
- <option value="multiplanar" selected>Multi-planar</option>
609
- <option value="axial">Axial</option>
610
- <option value="coronal">Coronal</option>
611
- <option value="sagittal">Sagittal</option>
612
- <option value="render">3D Render</option>
613
- </select>
614
- </label>
615
-
616
- <label>
617
- Convention:
618
- <select id="convention-select">
619
- <option value="radiological" selected>Radiological (L→R)</option>
620
- <option value="neurological">Neurological (R→R)</option>
621
- </select>
622
- </label>
623
-
624
- <label>
625
- Overlay:
626
- <select id="overlay-select">
627
- <option value="">None</option>
628
- </select>
629
- </label>
630
-
631
- <label>
632
- Opacity:
633
- <input type="range" id="opacity-slider" min="0" max="100" value="60">
634
- <span id="opacity-value">60%</span>
635
- </label>
636
-
637
- <label>
638
- Colormap:
639
- <select id="colormap-select">
640
- <option value="actc" selected>ACTC</option>
641
- <option value="jet">Jet</option>
642
- <option value="hot">Hot</option>
643
- <option value="cool">Cool</option>
644
- <option value="viridis">Viridis</option>
645
- <option value="roi">ROI</option>
646
- </select>
647
- </label>
648
-
649
- <span id="status">Loading...</span>
650
- </div>
651
-
652
- <div id="canvas-container">
653
- <canvas id="gl"></canvas>
654
- </div>
655
- </div>
656
-
657
- <script type="module">
658
- // Embedded configuration
659
- const CONFIG = {{
660
- baseUrl: {json.dumps(base_url)},
661
- overlays: {json.dumps(overlay_map)},
662
- defaultOverlay: {json.dumps(default_overlay_key)}
663
- }};
664
-
665
- console.log('NiiVue Config:', CONFIG);
666
-
667
- // Import NiiVue
668
- const {{ Niivue }} = await import('https://unpkg.com/@niivue/niivue@0.62.1/dist/index.js');
669
-
670
- // Initialize
671
- const statusEl = document.getElementById('status');
672
- const nv = new Niivue({{
673
- colorbarVisible: true,
674
- crosshairWidth: 1,
675
- backColor: [0.067, 0.067, 0.067, 1]
676
- }});
677
-
678
- await nv.attachTo('gl');
679
- nv.setRadiologicalConvention(true);
680
-
681
- function updateStatus(msg, isError = false) {{
682
- statusEl.textContent = msg;
683
- statusEl.style.color = isError ? '#ff6b6b' : '#ddd';
684
- }}
685
-
686
- // Load main volume
687
- try {{
688
- updateStatus('Loading MRI...');
689
- console.log('Loading base from:', CONFIG.baseUrl);
690
-
691
- await nv.loadVolumes([{{
692
- url: CONFIG.baseUrl,
693
- name: 'Registered MRI'
694
- }}]);
695
-
696
- updateStatus('MRI loaded ✓');
697
- console.log('Base volume loaded successfully');
698
- }} catch (err) {{
699
- console.error('Failed to load base volume:', err);
700
- updateStatus('Failed to load MRI: ' + err.message, true);
701
- }}
702
-
703
- // Populate overlay dropdown
704
- const overlaySelect = document.getElementById('overlay-select');
705
- for (const [label, url] of Object.entries(CONFIG.overlays)) {{
706
- const option = document.createElement('option');
707
- option.value = url;
708
- option.textContent = label;
709
- overlaySelect.appendChild(option);
710
- }}
711
-
712
- // Set default overlay
713
- if (CONFIG.defaultOverlay && CONFIG.overlays[CONFIG.defaultOverlay]) {{
714
- overlaySelect.value = CONFIG.overlays[CONFIG.defaultOverlay];
715
- }}
716
-
717
- // Overlay controls
718
- async function loadOverlay(url, label) {{
719
- if (!url) {{
720
- // Remove all overlays
721
- while (nv.volumes.length > 1) {{
722
- nv.removeVolume(nv.volumes[nv.volumes.length - 1].id);
723
- }}
724
- updateStatus('MRI only');
725
- nv.updateGLVolume();
726
- return;
727
- }}
728
-
729
- try {{
730
- updateStatus('Loading overlay...');
731
- console.log('Loading overlay from:', url);
732
-
733
- // Remove existing overlays
734
- while (nv.volumes.length > 1) {{
735
- nv.removeVolume(nv.volumes[nv.volumes.length - 1].id);
736
- }}
737
-
738
- // Load new overlay
739
- await nv.addVolumeFromUrl({{ url, name: label || 'Overlay' }});
740
-
741
- // Apply styling
742
- const overlayIdx = nv.volumes.length - 1;
743
- const opacity = parseInt(document.getElementById('opacity-slider').value) / 100;
744
- nv.setOpacity(overlayIdx, opacity);
745
-
746
- const colormap = document.getElementById('colormap-select').value;
747
- nv.setColormap(nv.volumes[overlayIdx].id, colormap);
748
-
749
- updateStatus('Overlay loaded ✓');
750
- console.log('Overlay loaded successfully');
751
- }} catch (err) {{
752
- console.error('Failed to load overlay:', err);
753
- updateStatus('Overlay failed: ' + err.message, true);
754
- }}
755
- }}
756
-
757
- // Event listeners
758
- overlaySelect.addEventListener('change', (e) => {{
759
- const url = e.target.value;
760
- const label = e.target.options[e.target.selectedIndex].text;
761
- loadOverlay(url, label);
762
- }});
763
-
764
- document.getElementById('opacity-slider').addEventListener('input', (e) => {{
765
- const val = e.target.value;
766
- document.getElementById('opacity-value').textContent = val + '%';
767
- if (nv.volumes.length > 1) {{
768
- nv.setOpacity(1, val / 100);
769
- }}
770
- }});
771
-
772
- document.getElementById('colormap-select').addEventListener('change', (e) => {{
773
- if (nv.volumes.length > 1) {{
774
- nv.setColormap(nv.volumes[1].id, e.target.value);
775
- }}
776
- }});
777
-
778
- document.getElementById('view-select').addEventListener('change', (e) => {{
779
- const sliceTypes = {{
780
- 'multiplanar': nv.sliceTypeMultiplanar ?? 4,
781
- 'axial': nv.sliceTypeAxial ?? 0,
782
- 'coronal': nv.sliceTypeCoronal ?? 1,
783
- 'sagittal': nv.sliceTypeSagittal ?? 2,
784
- 'render': nv.sliceTypeRender ?? 3
785
- }};
786
- nv.setSliceType(sliceTypes[e.target.value]);
787
- }});
788
-
789
- document.getElementById('convention-select').addEventListener('change', (e) => {{
790
- nv.setRadiologicalConvention(e.target.value === 'radiological');
791
- }});
792
-
793
- // Load default overlay if set
794
- if (overlaySelect.value) {{
795
- const label = overlaySelect.options[overlaySelect.selectedIndex].text;
796
- setTimeout(() => loadOverlay(overlaySelect.value, label), 500);
797
- }}
798
-
799
- // Handle resize
800
- window.addEventListener('resize', () => {{
801
- nv.resizeListener?.();
802
- nv.updateGLVolume();
803
- }});
804
- </script>
805
- </body>
806
- </html>"""
807
-
808
- # Write HTML file
809
- viewer_html.write_text(html_content, encoding='utf-8')
810
-
811
- # Return iframe pointing to the HTML file
812
- iframe_url = f"/file={viewer_html}"
813
- return f'<iframe src="{iframe_url}" style="width:100%;height:85vh;border:none;border-radius:12px;background:#111;"></iframe>'
814
-
815
- #==========================
816
  # Main pipeline (UI callback)
817
  # =========================
818
  def _redact_paths(s: str) -> str:
@@ -822,19 +507,19 @@ def _redact_paths(s: str) -> str:
822
  s = s.replace(p, "[redacted]")
823
  return s
824
 
825
- @spaces.GPU(duration=300)
 
826
  def run_pipeline(file1, file2, dilate_prev_radius_vox=1, min_lesion_vol_ml=0.01):
827
  """
828
  file1: previous (baseline) FLAIR (.nii/.nii.gz or DICOM .zip)
829
  file2: new (follow-up) FLAIR (.nii/.nii.gz or DICOM .zip)
830
- Returns: (status_html, outputs_zip_path_or_None, niivue_html_update, report_html_update)
831
  """
832
  if file1 is None or file2 is None:
833
  return (
834
  "<div>⚠️ Please upload both the previous and the new scan.</div>",
835
  None,
836
  gr.update(value="", visible=False),
837
- gr.update(value="", visible=False),
838
  )
839
 
840
  ensure_dirs()
@@ -909,22 +594,6 @@ def run_pipeline(file1, file2, dilate_prev_radius_vox=1, min_lesion_vol_ml=0.01)
909
  if not outputs_zip.exists():
910
  raise RuntimeError("Packaging failed: outputs.zip not found.")
911
 
912
- # Viewer overlays
913
- overlay_paths: Dict[str, Path] = {
914
- "All lesions (new scan)": new_mask_flames,
915
- "New lesions only": diff_paths["new_only"],
916
- "Resolved lesions only": diff_paths["resolved_only"],
917
- "Stable overlap": diff_paths["stable"],
918
- "XOR diff": diff_paths["xor"],
919
- "Combined labels (1=stable, 2=new, 3=resolved)": diff_paths["combined"],
920
- }
921
-
922
- iframe_html = niivue_iframe_for(
923
- registered_path,
924
- overlay_abs_fs_paths=overlay_paths,
925
- default_overlay_key="New lesions only"
926
- )
927
-
928
  # Textual report
929
  report_html = f"""
930
  <div>
@@ -939,11 +608,10 @@ def run_pipeline(file1, file2, dilate_prev_radius_vox=1, min_lesion_vol_ml=0.01)
939
  </div>
940
  """.strip()
941
 
942
- status_html = "✅ Done. Download the results below and explore them in the viewer."
943
  return (
944
  status_html,
945
  str(outputs_zip),
946
- gr.update(value=iframe_html, visible=True),
947
  gr.update(value=report_html, visible=True),
948
  )
949
 
@@ -954,9 +622,9 @@ def run_pipeline(file1, file2, dilate_prev_radius_vox=1, min_lesion_vol_ml=0.01)
954
  status_html,
955
  None,
956
  gr.update(value="", visible=False),
957
- gr.update(value="", visible=False),
958
  )
959
 
 
960
  # =========================
961
  # Gradio UI (refined, consistent theme)
962
  # =========================
@@ -1149,8 +817,8 @@ with gr.Blocks(
1149
  <li>Upload <em>previous (baseline)</em> and <em>new (follow-up)</em>
1150
  isotropic 3D FLAIR scans (<code>.nii/.nii.gz</code> or DICOM <code>.zip</code>).</li>
1151
  <li>Click <strong>Run pipeline</strong>. Processing time takes approximately 3 minutes on current hardware.</li>
1152
- <li>The viewer will open with registered scan and highlighted new lesions.
1153
- Use the controls above the viewer to switch views, toggle orientation, and adjust overlay opacity/colormap.</li>
1154
  </ul>
1155
 
1156
  <p style="margin-top:16px;"><strong>Advanced options:</strong></p>
@@ -1178,13 +846,12 @@ with gr.Blocks(
1178
 
1179
  status = gr.HTML(label="Status", elem_id="status_box")
1180
  out_zip = gr.File(label="Download outputs (ZIP)")
1181
- viewer = gr.HTML(visible=False, label="Registered MRI viewer (with new-lesion overlay)")
1182
  report = gr.HTML(visible=False, label="Textual report", elem_id="report")
1183
 
1184
  run_btn.click(
1185
  fn=run_pipeline,
1186
  inputs=[prev_in, new_in, dil, minvol],
1187
- outputs=[status, out_zip, viewer, report]
1188
  )
1189
 
1190
  # ----- References -----
 
12
  from huggingface_hub import hf_hub_download
13
 
14
  import spaces
 
 
15
 
16
 
17
  # Dummy function to satisfy HF Spaces GPU detection during startup
 
66
  except Exception:
67
  pass
68
 
69
+
70
  def is_zip(path: Path) -> bool:
71
  return path.suffix.lower() == ".zip"
72
 
 
296
  sitk.WriteImage(registered_img, str(registered_path))
297
  return registered_path, affine_tx, fixed
298
 
299
+
300
+ @spaces.GPU(duration=500)
301
  def run_flames_single(input_nii: Path, out_mask_path: Path, device: str = "cuda") -> Path:
302
  """Run FLAMeS (nnUNetv2) on a single input NIfTI and write a mask. Uses shared MODEL_ROOT."""
303
  with (Path(input_nii).open("rb")):
 
498
 
499
 
500
  # =========================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  # Main pipeline (UI callback)
502
  # =========================
503
  def _redact_paths(s: str) -> str:
 
507
  s = s.replace(p, "[redacted]")
508
  return s
509
 
510
+
511
+ @spaces.GPU(duration=500)
512
  def run_pipeline(file1, file2, dilate_prev_radius_vox=1, min_lesion_vol_ml=0.01):
513
  """
514
  file1: previous (baseline) FLAIR (.nii/.nii.gz or DICOM .zip)
515
  file2: new (follow-up) FLAIR (.nii/.nii.gz or DICOM .zip)
516
+ Returns: (status_html, outputs_zip_path_or_None, report_html_update)
517
  """
518
  if file1 is None or file2 is None:
519
  return (
520
  "<div>⚠️ Please upload both the previous and the new scan.</div>",
521
  None,
522
  gr.update(value="", visible=False),
 
523
  )
524
 
525
  ensure_dirs()
 
594
  if not outputs_zip.exists():
595
  raise RuntimeError("Packaging failed: outputs.zip not found.")
596
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
597
  # Textual report
598
  report_html = f"""
599
  <div>
 
608
  </div>
609
  """.strip()
610
 
611
+ status_html = "✅ Done. Download the results below to inspect them in your preferred viewer."
612
  return (
613
  status_html,
614
  str(outputs_zip),
 
615
  gr.update(value=report_html, visible=True),
616
  )
617
 
 
622
  status_html,
623
  None,
624
  gr.update(value="", visible=False),
 
625
  )
626
 
627
+
628
  # =========================
629
  # Gradio UI (refined, consistent theme)
630
  # =========================
 
817
  <li>Upload <em>previous (baseline)</em> and <em>new (follow-up)</em>
818
  isotropic 3D FLAIR scans (<code>.nii/.nii.gz</code> or DICOM <code>.zip</code>).</li>
819
  <li>Click <strong>Run pipeline</strong>. Processing time takes approximately 3 minutes on current hardware.</li>
820
+ <li>After processing, download the ZIP file and open the NIfTI outputs in your preferred neuroimaging viewer
821
+ (e.g. ITK-SNAP, FSLeyes, 3D Slicer) to inspect the lesions and overlays.</li>
822
  </ul>
823
 
824
  <p style="margin-top:16px;"><strong>Advanced options:</strong></p>
 
846
 
847
  status = gr.HTML(label="Status", elem_id="status_box")
848
  out_zip = gr.File(label="Download outputs (ZIP)")
 
849
  report = gr.HTML(visible=False, label="Textual report", elem_id="report")
850
 
851
  run_btn.click(
852
  fn=run_pipeline,
853
  inputs=[prev_in, new_in, dil, minvol],
854
+ outputs=[status, out_zip, report]
855
  )
856
 
857
  # ----- References -----