# Spec #28: Gradio Custom Component for NiiVue
**Date:** 2025-12-10
**Status:** REQUIRED - All gr.HTML hacks have failed (confirmed Dec 10)
**Blocks:** Issue #24 (HF Spaces "Loading..." forever)
**Effort:** Medium (2-3 days + 0.5-1 day buffer for HF Spaces quirks)
**Success Probability:** 90%
**Audited:** AUDIT_REPORT_2025_12_10.md - GO recommendation
---
## Executive Summary
**All `gr.HTML` + JavaScript approaches have FAILED. This is the only path forward.**
Gradio maintainers have explicitly closed both:
- [Issue #4511](https://github.com/gradio-app/gradio/issues/4511) - NIfTI/medical imaging support → "Not planned"
- [Issue #7649](https://github.com/gradio-app/gradio/issues/7649) - WebGL canvas component → "Not planned"
Their official answer: **"Create a Gradio Custom Component."**
This spec documents what we need to build to properly integrate NiiVue (WebGL2 medical imaging viewer) into our Gradio app.
---
## Why Current Approach Fails
### What We've Tried
| Attempt | Why It Failed |
|---------|---------------|
| CDN import in js_on_load | HF Spaces CSP blocks external imports |
| Vendored NiiVue + dynamic import() | import() in js_on_load blocks Svelte hydration |
| head= parameter | Still uses ES module import, same problem |
| head_paths= parameter | Same as above |
| gr.set_static_paths() | File serving works, but JS loading mechanism broken |
### Root Cause
**We're fighting `gr.HTML`'s limitations, not Gradio itself.** Gradio CAN do WebGL (proven by `gradio-litmodel3d`), but NOT via `gr.HTML`:
1. `gr.HTML` strips `
{#if error}
{error}
{:else if loading}
Loading viewer...
{/if}
```
**Key improvements from audit feedback:**
- WebGL2 capability check before initialization
- WebGL context loss/restore handlers
- Proper error UI states
- Loading state management
- Reactive update when value changes
### Phase 3: Implement Python Backend (2-3 hours)
```python
# backend/gradio_niivue_viewer/__init__.py
from __future__ import annotations
from typing import Any
from gradio.components.base import Component
from gradio.data_classes import FileData, GradioModel
class NiiVueViewerData(GradioModel):
background_url: str | None = None
overlay_url: str | None = None
class NiiVueViewer(Component):
"""WebGL NIfTI viewer using NiiVue."""
data_model = NiiVueViewerData
def __init__(
self,
value: NiiVueViewerData | None = None,
*,
label: str | None = None,
height: int = 500,
**kwargs,
):
self.height = height
super().__init__(value=value, label=label, **kwargs)
def preprocess(self, payload: NiiVueViewerData | None) -> dict[str, Any] | None:
if payload is None:
return None
return {
"background_url": payload.background_url,
"overlay_url": payload.overlay_url,
}
def postprocess(self, value: dict[str, Any] | None) -> NiiVueViewerData | None:
if value is None:
return None
return NiiVueViewerData(
background_url=value.get("background_url"),
overlay_url=value.get("overlay_url"),
)
```
### Phase 4: Build and Test (2-3 hours)
```bash
# Build the component
cd gradio-niivue-viewer
gradio cc build
# Install locally
pip install -e .
# Test in demo app
python demo/app.py
```
### Phase 5: Integrate into stroke-deepisles-demo (1-2 hours)
Replace `gr.HTML` with the custom component:
```python
# Before (broken)
from stroke_deepisles_demo.ui.viewer import create_niivue_html
viewer = gr.HTML(value="", elem_id="niivue-viewer")
# ... then set viewer.value = create_niivue_html(...)
# After (working)
from gradio_niivue_viewer import NiiVueViewer
viewer = NiiVueViewer(label="Interactive 3D Viewer")
# ... then set viewer.value = {"background_url": dwi_url, "overlay_url": mask_url}
```
### Phase 6: HF Spaces Deployment (CRITICAL)
**This phase is essential.** HF Spaces runs `pip install` only - it does NOT run `npm` or `gradio cc build`.
#### 6a. Commit build artifacts to git
```bash
cd packages/gradio-niivue-viewer
# Build the component (generates frontend/dist/ or templates/)
gradio cc build
# Force-add build artifacts (they may be gitignored by default)
git add -f gradio_niivue_viewer/templates/
# Or wherever the build output lands - check with:
# find . -name "*.js" -path "*/dist/*" -o -name "*.css" -path "*/dist/*"
git commit -m "chore: add compiled frontend assets for HF Spaces deployment"
```
**Why:** HF Spaces won't run npm/node build steps. The compiled JS/CSS must be in the repo.
#### 6b. Update requirements.txt
Add the local component to the main `requirements.txt`:
```text
# requirements.txt
gradio>=5.0
# ... other deps ...
# Local custom component (editable install)
-e ./packages/gradio-niivue-viewer
```
**Alternative:** If the component is at repo root:
```text
-e .
```
#### 6c. Verify .gitignore doesn't exclude build artifacts
Check that `packages/gradio-niivue-viewer/.gitignore` doesn't exclude:
- `gradio_niivue_viewer/templates/`
- `frontend/dist/`
- Any compiled `.js` or `.css` files needed at runtime
If they're excluded, either:
1. Remove those lines from `.gitignore`, OR
2. Use `git add -f` to force-add them
#### 6d. Test deployment flow
```bash
# Simulate what HF Spaces does
pip install -r requirements.txt
python -m stroke_deepisles_demo.ui.app
# Should work WITHOUT running gradio cc build
```
---
## Existing References
### Working WebGL Custom Components
1. **[gradio-litmodel3d](https://pypi.org/project/gradio-litmodel3d/)**
- WebGL Model3D viewer with HDR lighting
- Source: https://github.com/gradio-app/gradio/tree/main/demo/model3d_component
- Proof that WebGL works in Custom Components
2. **[gradio-molecule3d](https://pypi.org/project/gradio-molecule3d/)**
- 3D molecule viewer
- Uses Three.js (WebGL)
### Gradio Documentation
- [Custom Components in 5 Minutes](https://www.gradio.app/guides/custom-components-in-five-minutes)
- [Gradio Components Documentation](https://www.gradio.app/docs/gradio/components)
- [Custom Component Gallery](https://www.gradio.app/custom-components/gallery)
### NiiVue Resources
- [NiiVue GitHub](https://github.com/niivue/niivue)
- [NiiVue npm](https://www.npmjs.com/package/@niivue/niivue)
- [NiiVue Examples](https://niivue.com/docs/)
---
## Acceptance Criteria
### Must Have (MVP)
- [ ] Component loads NIfTI volumes from Gradio file URLs
- [ ] Component displays background image (DWI)
- [ ] Component displays overlay mask (segmentation) with colormap
- [ ] Component works on HuggingFace Spaces
- [ ] No "Loading..." hang - failures are graceful
- [ ] All existing tests pass
### Nice to Have (Future)
- [ ] Crosshair controls
- [ ] Slice orientation toggle (axial/coronal/sagittal)
- [ ] Opacity slider for overlay
- [ ] Pan/zoom/rotate controls
- [ ] Screenshot/export functionality
- [ ] Publish to PyPI for community use
---
## Risk Assessment
| Risk | Mitigation |
|------|------------|
| Svelte/TypeScript learning curve | Follow gradio-litmodel3d example closely |
| NiiVue WebGL2 browser support | Explicit WebGL2 check in Svelte + graceful error UI |
| Build system complexity | Use gradio cc tooling, don't customize |
| HF Spaces static file serving | Component bundles NiiVue, no external deps |
| **Build artifacts not in git** | Phase 6a: Force-add compiled assets with `git add -f` |
| **requirements.txt missing component** | Phase 6b: Add `-e ./packages/gradio-niivue-viewer` |
---
## Alternatives Considered
### Alternative 1: Keep Hacking gr.HTML
- **Effort:** Low
- **Success probability:** 0% (CONFIRMED FAILED)
- **Why rejected:** We tried 6 approaches over 2 days. ALL failed. This is not a viable path.
### Alternative 2: Static HTML Space (No Gradio)
- **Effort:** High (rebuild entire UI)
- **Success probability:** 99%
- **Why rejected:** Lose Gradio's file upload, dropdowns, layout features. Too much work.
### Alternative 3: Remove 3D Viewer (2D Only)
- **Effort:** Low
- **Success probability:** 100%
- **Why rejected:** Loses key feature. Static Report tab already works, but 3D is valuable.
---
## Decision
**Proceed with Gradio Custom Component approach.**
This is the official Gradio-recommended solution. It's more work than hacking `gr.HTML`, but it's the architecturally correct approach with 90% success probability vs 30%.
---
## Testing Matrix
### Level 1: Local Build Verification
```bash
cd packages/gradio-niivue-viewer
# Build component
gradio cc build
# Install locally
pip install -e .
# Run demo app
python demo/app.py
# → Verify: App loads, no console errors, viewer renders
```
**Pass criteria:**
- [ ] `gradio cc build` completes without errors
- [ ] Demo app launches at localhost:7860
- [ ] No JavaScript console errors
- [ ] Canvas renders (black background visible)
### Level 2: Volume Loading Test
```python
# demo/app.py
import gradio as gr
from gradio_niivue_viewer import NiiVueViewer
def load_sample():
# Use a known good NIfTI file
return {
"background_url": "/gradio_api/file=/path/to/sample.nii.gz",
"overlay_url": None
}
with gr.Blocks() as demo:
viewer = NiiVueViewer()
btn = gr.Button("Load Sample")
btn.click(load_sample, outputs=viewer)
demo.launch()
```
**Pass criteria:**
- [ ] NIfTI file loads without errors
- [ ] Multiplanar view displays correctly
- [ ] Overlay mask renders with red colormap (when provided)
### Level 3: HF Spaces Dry Run
Deploy to a **private/throwaway Space** before production:
```bash
# Create test space
huggingface-cli repo create test-niivue-viewer --type space --private
# Push and test
git push hf-test main
```
**Pass criteria:**
- [ ] Space shows "Running" (not stuck on "Loading...")
- [ ] Viewer initializes (no hydration deadlock)
- [ ] Volume loading works via Gradio file serving
- [ ] WebGL2 error shown gracefully if unsupported
### Level 4: Integration Test
Replace `gr.HTML` in stroke-deepisles-demo:
```python
# src/stroke_deepisles_demo/ui/components.py
from gradio_niivue_viewer import NiiVueViewer
def create_results_display():
# ...
niivue_viewer = NiiVueViewer(label="Interactive 3D Viewer")
# ...
```
**Pass criteria:**
- [ ] Existing 136 tests still pass
- [ ] Segmentation pipeline works end-to-end
- [ ] Viewer displays DWI + mask overlay
- [ ] No "Loading..." hang on HF Spaces
---
## Next Steps
1. [x] Senior review of this spec (AUDIT_REPORT_2025_12_10.md)
2. [x] Red team review - all gaps addressed (build artifacts, npm install, null handling)
3. [ ] Create `packages/gradio-niivue-viewer/` subdirectory
4. [ ] Scaffold component with `gradio cc create`
5. [ ] Install NiiVue: `cd frontend && npm install @niivue/niivue@0.65.0`
6. [ ] Implement Svelte frontend (with WebGL2 checks + null value handling)
7. [ ] Implement Python backend
8. [ ] Level 1 test: Local build verification
9. [ ] Level 2 test: Volume loading
10. [ ] Level 3 test: HF Spaces dry run
11. [ ] Level 4 test: Integration
12. [ ] **CRITICAL**: Commit build artifacts to git
13. [ ] **CRITICAL**: Update requirements.txt with `-e ./packages/gradio-niivue-viewer`
14. [ ] (Optional) Publish to PyPI
---
## Appendix: Why WebGL + `gr.HTML` Doesn't Work
From the ROOT_CAUSE_ANALYSIS.md and GRADIO_WEBGL_ANALYSIS.md research:
1. **Gradio CAN do WebGL** - proven by `gradio-litmodel3d` custom component
2. **But NOT via `gr.HTML`** - the `js_on_load` + `import()` pattern blocks Svelte hydration
3. **Our A/B test proved it** - disabling `js_on_load` makes the app load perfectly
4. **Gradio closed NIfTI support** (Issue #4511) - "Not planned for core"
5. **Gradio closed WebGL canvas** (Issue #7649) - "Not planned for core"
6. **gr.HTML strips script tags** - Security feature, can't bypass
7. **HF Spaces CSP blocks external CDNs** - Must vendor or bundle dependencies
8. **Gradio maintainer recommendation**: Custom Components
The pattern is clear: **The `gr.HTML` + `js_on_load` + async `import()` pattern is fundamentally broken.** The Custom Component is the officially supported path for WebGL content.