# phase 4: gradio / spaces app
## purpose
Build a minimal but clean Gradio 5 app that allows interactive case selection, segmentation, and visualization. At the end of this phase, we have a deployable Hugging Face Space.
## deliverables
- [ ] `src/stroke_deepisles_demo/ui/app.py` - Main Gradio application
- [ ] `src/stroke_deepisles_demo/ui/viewer.py` - NiiVue integration
- [ ] `src/stroke_deepisles_demo/ui/components.py` - Reusable UI components
- [ ] `app.py` at repo root - HF Spaces entry point
- [ ] Unit tests for UI logic (not Gradio itself)
- [ ] Smoke test for app import
## vertical slice outcome
After this phase, you can run locally:
```bash
uv run gradio src/stroke_deepisles_demo/ui/app.py
# or
uv run python -m stroke_deepisles_demo.ui.app
```
And deploy to Hugging Face Spaces with the standard Gradio SDK.
## module structure
```
src/stroke_deepisles_demo/ui/
├── __init__.py # Public API
├── app.py # Main Gradio application
├── viewer.py # NiiVue integration
└── components.py # Reusable UI components
# Root level for HF Spaces
app.py # Entry point: from stroke_deepisles_demo.ui.app import demo
```
## gradio 5 considerations
Based on [Gradio 5 documentation](https://huggingface.co/blog/gradio-5):
- Server-side rendering (SSR) for fast initial load
- Improved components (Buttons, Tabs, Sliders)
- WebRTC support for real-time streaming
- New built-in themes
Key patterns:
```python
import gradio as gr
# Gradio 5 app pattern
with gr.Blocks(theme=gr.themes.Soft()) as demo:
gr.Markdown("# Title")
with gr.Row():
with gr.Column():
# Inputs
...
with gr.Column():
# Outputs
...
demo.launch()
```
## niivue integration strategy
[NiiVue](https://github.com/niivue/niivue) is a WebGL2-based neuroimaging viewer.
### proven implementation: tobias's bids-neuroimaging space
**Reference**: [TobiasPitters/bids-neuroimaging](https://huggingface.co/spaces/TobiasPitters/bids-neuroimaging) - A working HF Space with NiiVue multiplanar + 3D rendering.
Key patterns from Tobias's implementation:
1. **FastAPI + raw HTML** (not Gradio) - Cleaner for single-page viewer
2. **NiiVue via unpkg CDN**: `https://unpkg.com/@niivue/niivue@0.57.0/dist/index.js`
3. **Base64 data URLs** for NIfTI data (no file serving needed):
```python
import base64
nifti_bytes = nifti_image.to_bytes()
nifti_b64 = base64.b64encode(nifti_bytes).decode("utf-8")
data_url = f"data:application/octet-stream;base64,{nifti_b64}"
```
4. **NiiVue configuration for multiplanar + 3D**:
```javascript
nv.setSliceType(nv.sliceTypeMultiplanar);
nv.setMultiplanarLayout(2); // 2x2 grid with 3D render
nv.opts.show3Dcrosshair = true;
```
### implementation approach: gradio + direct base64 injection
For our demo, we use:
- **Gradio** for case selection dropdown and "Run Segmentation" button
- **Direct Base64 data URLs** injected into HTML (no separate API endpoints)
- **NiiVue via `gr.HTML`** for interactive 3D visualization
This gives us:
- Gradio's nice UI components for inputs
- Proven NiiVue rendering pattern from Tobias's implementation
- No iframe complexity, no proxy issues in HF Spaces
### concrete implementation
```python
import base64
from pathlib import Path
import nibabel as nib
def nifti_to_data_url(nifti_path: Path) -> str:
"""Convert NIfTI file to base64 data URL for NiiVue."""
img = nib.load(nifti_path)
nifti_bytes = img.to_bytes()
nifti_b64 = base64.b64encode(nifti_bytes).decode("utf-8")
return f"data:application/octet-stream;base64,{nifti_b64}"
def create_niivue_viewer_html(
volume_data_url: str,
mask_data_url: str | None = None,
height: int = 600,
) -> str:
"""Create NiiVue HTML viewer with optional mask overlay."""
mask_loading = ""
if mask_data_url:
mask_loading = f"""
volumes.push({{
url: '{mask_data_url}',
colorMap: 'red',
opacity: 0.5
}});
"""
return f"""
"""
```
## interfaces and types
### `ui/app.py`
```python
"""Main Gradio application for stroke-deepisles-demo."""
from __future__ import annotations
import gradio as gr
from stroke_deepisles_demo.pipeline import run_pipeline_on_case
from stroke_deepisles_demo.ui.components import create_case_selector, create_results_display
from stroke_deepisles_demo.ui.viewer import render_comparison_view
def create_app() -> gr.Blocks:
"""
Create the Gradio application.
Returns:
Configured gr.Blocks application
"""
with gr.Blocks(
title="Stroke Lesion Segmentation Demo",
theme=gr.themes.Soft(),
) as demo:
# Header
gr.Markdown("""
# Stroke Lesion Segmentation Demo
This demo runs [DeepISLES](https://github.com/ezequieldlrosa/DeepIsles)
stroke segmentation on cases from
[ISLES24-MR-Lite](https://huggingface.co/datasets/YongchengYAO/ISLES24-MR-Lite).
> **Disclaimer**: This is for research/demonstration only. Not for clinical use.
""")
with gr.Row():
# Left column: Controls
with gr.Column(scale=1):
case_selector = create_case_selector()
run_btn = gr.Button("Run Segmentation", variant="primary")
status = gr.Textbox(label="Status", interactive=False)
# Right column: Results
with gr.Column(scale=2):
results_display = create_results_display()
# Event handlers
run_btn.click(
fn=run_segmentation,
inputs=[case_selector],
outputs=[results_display, status],
)
return demo
def run_segmentation(case_id: str) -> tuple[dict, str]:
"""
Run segmentation and return results for display.
Args:
case_id: Selected case identifier
Returns:
Tuple of (results_dict, status_message)
"""
...
# Module-level app instance for Gradio CLI
demo = create_app()
if __name__ == "__main__":
demo.launch()
```
### `ui/viewer.py`
```python
"""Neuroimaging visualization for Gradio."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
import numpy as np
if TYPE_CHECKING:
from matplotlib.figure import Figure
from numpy.typing import NDArray
def render_slice_comparison(
dwi_path: Path,
prediction_path: Path,
ground_truth_path: Path | None = None,
*,
slice_idx: int | None = None,
orientation: str = "axial",
) -> Figure:
"""
Render side-by-side comparison of DWI, prediction, and ground truth.
Args:
dwi_path: Path to DWI NIfTI
prediction_path: Path to predicted mask NIfTI
ground_truth_path: Optional path to ground truth mask
slice_idx: Slice index (default: middle slice)
orientation: One of "axial", "coronal", "sagittal"
Returns:
Matplotlib figure with comparison view
"""
...
def render_3panel_view(
nifti_path: Path,
mask_path: Path | None = None,
*,
mask_alpha: float = 0.5,
mask_color: str = "red",
) -> Figure:
"""
Render axial/coronal/sagittal slices with optional mask overlay.
Args:
nifti_path: Path to base NIfTI volume
mask_path: Optional path to mask for overlay
mask_alpha: Transparency of mask overlay
mask_color: Color for mask overlay
Returns:
Matplotlib figure with 3-panel view
"""
...
def create_niivue_html(
volume_url: str,
mask_url: str | None = None,
*,
height: int = 400,
) -> str:
"""
Create HTML/JS for NiiVue viewer.
Args:
volume_url: URL to volume NIfTI file
mask_url: Optional URL to mask NIfTI file
height: Viewer height in pixels
Returns:
HTML string with embedded NiiVue viewer
"""
template = f"""
"""
return template
def get_slice_at_max_lesion(
mask_path: Path,
orientation: str = "axial",
) -> int:
"""
Find slice index with maximum lesion area.
Useful for displaying the most informative slice.
Args:
mask_path: Path to lesion mask NIfTI
orientation: Slice orientation
Returns:
Slice index with maximum lesion area
"""
...
```
### `ui/components.py`
```python
"""Reusable UI components."""
from __future__ import annotations
import gradio as gr
from stroke_deepisles_demo.data import list_case_ids
def create_case_selector() -> gr.Dropdown:
"""
Create a dropdown for selecting cases.
Returns:
Configured gr.Dropdown component
"""
try:
case_ids = list_case_ids()
except Exception:
case_ids = ["Error loading cases"]
return gr.Dropdown(
choices=case_ids,
value=case_ids[0] if case_ids else None,
label="Select Case",
info="Choose a case from ISLES24-MR-Lite",
)
def create_results_display() -> dict[str, gr.components.Component]:
"""
Create results display components.
Returns:
Dictionary of component name -> gr.Component
"""
with gr.Group():
viewer = gr.Image(label="Segmentation Result", type="filepath")
metrics = gr.JSON(label="Metrics")
download = gr.File(label="Download Prediction")
return {
"viewer": viewer,
"metrics": metrics,
"download": download,
}
def create_settings_accordion() -> dict[str, gr.components.Component]:
"""
Create expandable settings section.
Returns:
Dictionary of setting name -> gr.Component
"""
with gr.Accordion("Advanced Settings", open=False):
fast_mode = gr.Checkbox(
value=True,
label="Fast Mode (SEALS)",
info="Run SEALS only (ISLES'22 winner, requires DWI+ADC). Disable for full ensemble (requires FLAIR).",
)
show_ground_truth = gr.Checkbox(
value=True,
label="Show Ground Truth",
info="Display ground truth mask if available",
)
return {
"fast_mode": fast_mode,
"show_ground_truth": show_ground_truth,
}
```
### Root `app.py` for HF Spaces
```python
"""Entry point for Hugging Face Spaces deployment."""
from stroke_deepisles_demo.ui.app import demo
if __name__ == "__main__":
demo.launch()
```
## hugging face spaces configuration
### `README.md` header for Spaces
```yaml
---
title: Stroke DeepISLES Demo
emoji: 🧠
colorFrom: blue
colorTo: purple
sdk: gradio
sdk_version: 5.0.0
app_file: app.py
pinned: false
license: mit
---
```
### `requirements.txt` for Spaces
```
# Note: HF Spaces uses requirements.txt, not pyproject.toml
git+https://github.com/CloseChoice/datasets.git@feat/bids-loader-streaming-upload-fix
huggingface-hub>=0.25.0
nibabel>=5.2.0
numpy>=1.26.0
pydantic>=2.5.0
pydantic-settings>=2.1.0
gradio>=5.0.0
matplotlib>=3.8.0
```
## tdd plan
### test file structure
```
tests/
├── ui/
│ ├── __init__.py
│ ├── test_viewer.py # Tests for visualization
│ ├── test_components.py # Tests for UI components
│ └── test_app.py # Smoke tests for app
```
### tests to write first (TDD order)
#### 1. `tests/ui/test_viewer.py` - Pure visualization functions
```python
"""Tests for viewer module."""
from __future__ import annotations
from pathlib import Path
import matplotlib
import matplotlib.pyplot as plt
import numpy as np
import pytest
matplotlib.use("Agg") # Non-interactive backend for tests
from stroke_deepisles_demo.ui.viewer import (
create_niivue_html,
get_slice_at_max_lesion,
render_3panel_view,
render_slice_comparison,
)
class TestRender3PanelView:
"""Tests for render_3panel_view."""
def test_returns_matplotlib_figure(self, synthetic_nifti_3d: Path) -> None:
"""Returns a matplotlib Figure object."""
fig = render_3panel_view(synthetic_nifti_3d)
assert isinstance(fig, plt.Figure)
plt.close(fig)
def test_has_three_axes(self, synthetic_nifti_3d: Path) -> None:
"""Figure has 3 subplots (axial, coronal, sagittal)."""
fig = render_3panel_view(synthetic_nifti_3d)
assert len(fig.axes) == 3
plt.close(fig)
def test_overlay_mask_when_provided(
self, synthetic_nifti_3d: Path, temp_dir: Path
) -> None:
"""Overlays mask when mask_path provided."""
# Create a simple mask
import nibabel as nib
mask_data = np.zeros((10, 10, 10), dtype=np.uint8)
mask_data[4:6, 4:6, 4:6] = 1
mask_img = nib.Nifti1Image(mask_data, np.eye(4))
mask_path = temp_dir / "mask.nii.gz"
nib.save(mask_img, mask_path)
fig = render_3panel_view(synthetic_nifti_3d, mask_path=mask_path)
# Should not raise
assert fig is not None
plt.close(fig)
class TestRenderSliceComparison:
"""Tests for render_slice_comparison."""
def test_comparison_without_ground_truth(
self, synthetic_nifti_3d: Path
) -> None:
"""Works when ground truth is None."""
fig = render_slice_comparison(
synthetic_nifti_3d,
synthetic_nifti_3d, # Use same as prediction for test
ground_truth_path=None,
)
assert isinstance(fig, plt.Figure)
plt.close(fig)
def test_comparison_with_ground_truth(
self, synthetic_nifti_3d: Path
) -> None:
"""Works when ground truth is provided."""
fig = render_slice_comparison(
synthetic_nifti_3d,
synthetic_nifti_3d,
ground_truth_path=synthetic_nifti_3d,
)
assert isinstance(fig, plt.Figure)
plt.close(fig)
class TestGetSliceAtMaxLesion:
"""Tests for get_slice_at_max_lesion."""
def test_finds_slice_with_lesion(self, temp_dir: Path) -> None:
"""Returns slice index where lesion is largest."""
import nibabel as nib
# Create mask with lesion at slice 7
mask_data = np.zeros((10, 10, 10), dtype=np.uint8)
mask_data[:, :, 7] = 1 # Full slice 7 is lesion
mask_img = nib.Nifti1Image(mask_data, np.eye(4))
mask_path = temp_dir / "mask.nii.gz"
nib.save(mask_img, mask_path)
slice_idx = get_slice_at_max_lesion(mask_path, orientation="axial")
assert slice_idx == 7
def test_returns_middle_for_empty_mask(self, temp_dir: Path) -> None:
"""Returns middle slice when mask is empty."""
import nibabel as nib
mask_data = np.zeros((10, 10, 20), dtype=np.uint8)
mask_img = nib.Nifti1Image(mask_data, np.eye(4))
mask_path = temp_dir / "mask.nii.gz"
nib.save(mask_img, mask_path)
slice_idx = get_slice_at_max_lesion(mask_path, orientation="axial")
assert slice_idx == 10 # Middle of 20
class TestCreateNiivueHtml:
"""Tests for create_niivue_html."""
def test_includes_volume_url(self) -> None:
"""Generated HTML includes the volume URL."""
html = create_niivue_html("http://example.com/brain.nii.gz")
assert "http://example.com/brain.nii.gz" in html
def test_includes_mask_when_provided(self) -> None:
"""Generated HTML includes mask URL when provided."""
html = create_niivue_html(
"http://example.com/brain.nii.gz",
mask_url="http://example.com/mask.nii.gz",
)
assert "http://example.com/mask.nii.gz" in html
def test_sets_height(self) -> None:
"""Generated HTML respects height parameter."""
html = create_niivue_html(
"http://example.com/brain.nii.gz",
height=600,
)
assert "height:600px" in html
```
#### 2. `tests/ui/test_app.py` - Smoke tests
```python
"""Smoke tests for Gradio app."""
from __future__ import annotations
def test_app_module_imports() -> None:
"""App module imports without side effects."""
# This should not launch the app or make network calls
from stroke_deepisles_demo.ui import app
assert hasattr(app, "create_app")
assert hasattr(app, "demo")
def test_create_app_returns_blocks() -> None:
"""create_app returns a gr.Blocks instance."""
import gradio as gr
from stroke_deepisles_demo.ui.app import create_app
app = create_app()
assert isinstance(app, gr.Blocks)
def test_viewer_module_imports() -> None:
"""Viewer module imports without errors."""
from stroke_deepisles_demo.ui import viewer
assert hasattr(viewer, "render_3panel_view")
assert hasattr(viewer, "create_niivue_html")
def test_components_module_imports() -> None:
"""Components module imports without errors."""
from stroke_deepisles_demo.ui import components
assert hasattr(components, "create_case_selector")
assert hasattr(components, "create_results_display")
```
### what to mock
- `list_case_ids()` in components - Avoid network during import
- Any data loading in app initialization
### what to test for real
- Matplotlib figure generation
- NiiVue HTML string generation
- Slice finding algorithms
- Module imports (no network side effects)
## "done" criteria
Phase 4 is complete when:
1. All unit tests pass: `uv run pytest tests/ui/ -v`
2. App launches locally: `uv run python -m stroke_deepisles_demo.ui.app`
3. Can select a case, click "Run", see visualization
4. Visualization shows DWI with predicted mask overlay
5. Metrics (Dice score) displayed
6. Type checking passes: `uv run mypy src/stroke_deepisles_demo/ui/`
7. Ready for HF Spaces deployment (README header, requirements.txt)
## implementation notes
- **NiiVue is primary** - Proven working in Tobias's Space, not "fragile"
- **Base64 data URLs** - Avoids file serving complexity, works in all environments
- **Lazy initialization** - Do NOT call `list_case_ids()` at module import time (causes network calls)
- **Test on HF Spaces early** - Verify WebGL works in their environment
- **Keep UI simple** - This is a demo, not a full application
- **Cache case list** - Avoid repeated HF Hub calls
### avoiding import-time side effects
The reviewer correctly noted that `demo = create_app()` at module level triggers network calls. Fix:
```python
# BAD - triggers network call on import
demo = create_app()
# GOOD - lazy initialization
_demo: gr.Blocks | None = None
def get_demo() -> gr.Blocks:
global _demo
if _demo is None:
_demo = create_app()
return _demo
# For Gradio CLI compatibility
demo = None # Set lazily
if __name__ == "__main__":
get_demo().launch()
```
Or use a factory pattern in the root `app.py`:
```python
# app.py (HF Spaces entry point)
from stroke_deepisles_demo.ui.app import create_app
demo = create_app() # Only called when this file is executed
if __name__ == "__main__":
demo.launch()
```
## dependencies to add
```toml
# Add to pyproject.toml dependencies
"matplotlib>=3.8.0", # For static slice rendering in viewer.py
```
## reference implementation
Clone Tobias's working Space for reference:
```
_reference_repos/bids-neuroimaging-space/
```
Key file: `main.py` - Complete NiiVue + FastAPI implementation.