VibecoderMcSwaggins commited on
Commit
e4daa3b
·
unverified ·
1 Parent(s): 491d824

feat(frontend): React + Vite + NiiVue frontend (replaces Gradio) (#32)

Browse files

## Summary
React + Vite + NiiVue frontend replacing Gradio

### Key Changes
- React 19 + Vite 7 + TypeScript strict mode
- NiiVue 0.65.0 for medical image visualization
- 58 unit tests (Vitest) + 8 E2E tests (Playwright)
- 78%+ branch coverage
- CI pipeline with lint, typecheck, test, build jobs

### CodeRabbit Review Fixes
- Split NiiVue into two effects (mount vs URL changes)
- Add AbortController + request token to useSegmentation
- Add isCurrent cleanup flag to NiiVueViewer
- Remove test-only types from tsconfig.app.json
- Update spec docs with correct versions

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .github/workflows/ci.yml +110 -1
  2. docs/specs/00-context.md +0 -214
  3. docs/specs/24-bug-gradio-webgl-analysis.md +0 -156
  4. docs/specs/28-gradio-custom-component-niivue.md +0 -702
  5. docs/specs/29-codebase-status-audit.md +0 -276
  6. docs/specs/30-bug-hf-spaces-build-packages-dir.md +0 -116
  7. docs/specs/AUDIT_REPORT_2025_12_10.md +0 -52
  8. docs/specs/NIIVUE-GRADIO-POSTMORTEM.md +408 -0
  9. docs/specs/archive/01-phase-0-repo-bootstrap.md +0 -438
  10. docs/specs/archive/02-phase-1-data-access.md +0 -415
  11. docs/specs/archive/03-phase-2-deepisles-docker.md +0 -884
  12. docs/specs/archive/04-phase-3-pipeline.md +0 -705
  13. docs/specs/archive/05-phase-4-gradio-ui.md +0 -778
  14. docs/specs/archive/06-phase-5-polish.md +0 -667
  15. docs/specs/archive/07-hf-spaces-deployment.md +0 -969
  16. docs/specs/archive/08-bug-hf-spaces-dataset-loop.md +0 -239
  17. docs/specs/archive/09-bug-deepisles-not-installed-hf-spaces.md +0 -92
  18. docs/specs/archive/10-bug-niivue-viewer-black-screen.md +0 -418
  19. docs/specs/archive/11-bug-niivue-js-on-load-not-rerunning.md +0 -484
  20. docs/specs/archive/19-perf-base64-to-file-urls.md +0 -244
  21. docs/specs/archive/23-slice-comparison-overlay-bug.md +0 -287
  22. docs/specs/archive/24-bug-hf-spaces-loading-forever.md +0 -254
  23. docs/specs/archive/AUDIT_JS_LOADING_ISSUES.md +0 -935
  24. docs/specs/archive/DIAGNOSTIC_HF_LOADING.md +0 -228
  25. docs/specs/archive/ROOT_CAUSE_ANALYSIS.md +0 -230
  26. docs/specs/archive/data-discovery.md +0 -66
  27. docs/specs/frontend/36-frontend-without-gradio-hf-spaces.md +1102 -0
  28. docs/specs/frontend/37-0-project-setup.md +307 -0
  29. docs/specs/frontend/37-1-foundation-components.md +331 -0
  30. docs/specs/frontend/37-2-api-layer.md +523 -0
  31. docs/specs/frontend/37-3-interactive-components.md +681 -0
  32. docs/specs/frontend/37-4-app-integration.md +461 -0
  33. docs/specs/frontend/37-5-e2e-and-ci.md +607 -0
  34. frontend/.env.example +1 -0
  35. frontend/.gitignore +29 -0
  36. frontend/README.md +73 -0
  37. frontend/e2e/error-handling.spec.ts +54 -0
  38. frontend/e2e/fixtures.ts +50 -0
  39. frontend/e2e/home.spec.ts +36 -0
  40. frontend/e2e/pages/HomePage.ts +62 -0
  41. frontend/e2e/segmentation-flow.spec.ts +49 -0
  42. frontend/eslint.config.js +33 -0
  43. frontend/index.html +13 -0
  44. frontend/package-lock.json +0 -0
  45. frontend/package.json +50 -0
  46. frontend/playwright.config.ts +31 -0
  47. frontend/public/vite.svg +1 -0
  48. frontend/src/App.test.tsx +240 -0
  49. frontend/src/App.tsx +65 -0
  50. frontend/src/api/__tests__/client.test.ts +61 -0
.github/workflows/ci.yml CHANGED
@@ -10,7 +10,7 @@ on:
10
  run_integration:
11
  description: 'Run integration tests (requires HuggingFace download)'
12
  required: false
13
- default: 'false'
14
  type: boolean
15
 
16
  jobs:
@@ -106,3 +106,112 @@ jobs:
106
  run: uv run pytest -m integration --timeout=600
107
  env:
108
  HF_HOME: /tmp/hf_cache
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  run_integration:
11
  description: 'Run integration tests (requires HuggingFace download)'
12
  required: false
13
+ default: false
14
  type: boolean
15
 
16
  jobs:
 
106
  run: uv run pytest -m integration --timeout=600
107
  env:
108
  HF_HOME: /tmp/hf_cache
109
+
110
+ # Frontend Jobs
111
+ frontend-lint:
112
+ runs-on: ubuntu-latest
113
+ defaults:
114
+ run:
115
+ working-directory: frontend
116
+ steps:
117
+ - uses: actions/checkout@v4
118
+
119
+ - uses: actions/setup-node@v4
120
+ with:
121
+ node-version: '20'
122
+ cache: 'npm'
123
+ cache-dependency-path: frontend/package-lock.json
124
+
125
+ - run: npm ci
126
+ - run: npm run lint
127
+
128
+ frontend-typecheck:
129
+ runs-on: ubuntu-latest
130
+ defaults:
131
+ run:
132
+ working-directory: frontend
133
+ steps:
134
+ - uses: actions/checkout@v4
135
+
136
+ - uses: actions/setup-node@v4
137
+ with:
138
+ node-version: '20'
139
+ cache: 'npm'
140
+ cache-dependency-path: frontend/package-lock.json
141
+
142
+ - run: npm ci
143
+ - run: npx tsc --noEmit
144
+
145
+ frontend-test:
146
+ runs-on: ubuntu-latest
147
+ defaults:
148
+ run:
149
+ working-directory: frontend
150
+ steps:
151
+ - uses: actions/checkout@v4
152
+
153
+ - uses: actions/setup-node@v4
154
+ with:
155
+ node-version: '20'
156
+ cache: 'npm'
157
+ cache-dependency-path: frontend/package-lock.json
158
+
159
+ - run: npm ci
160
+ - run: npm run test:coverage
161
+
162
+ - uses: codecov/codecov-action@v4
163
+ with:
164
+ files: frontend/coverage/coverage-final.json
165
+ flags: frontend
166
+ fail_ci_if_error: false
167
+ token: ${{ secrets.CODECOV_TOKEN }}
168
+
169
+ frontend-e2e:
170
+ runs-on: ubuntu-latest
171
+ defaults:
172
+ run:
173
+ working-directory: frontend
174
+ steps:
175
+ - uses: actions/checkout@v4
176
+
177
+ - uses: actions/setup-node@v4
178
+ with:
179
+ node-version: '20'
180
+ cache: 'npm'
181
+ cache-dependency-path: frontend/package-lock.json
182
+
183
+ - run: npm ci
184
+ - run: npx playwright install --with-deps chromium
185
+
186
+ - run: npm run test:e2e
187
+
188
+ - uses: actions/upload-artifact@v4
189
+ if: failure()
190
+ with:
191
+ name: playwright-report
192
+ path: frontend/playwright-report/
193
+ retention-days: 7
194
+
195
+ frontend-build:
196
+ runs-on: ubuntu-latest
197
+ needs: [frontend-lint, frontend-typecheck, frontend-test]
198
+ defaults:
199
+ run:
200
+ working-directory: frontend
201
+ steps:
202
+ - uses: actions/checkout@v4
203
+
204
+ - uses: actions/setup-node@v4
205
+ with:
206
+ node-version: '20'
207
+ cache: 'npm'
208
+ cache-dependency-path: frontend/package-lock.json
209
+
210
+ - run: npm ci
211
+ - run: npm run build
212
+
213
+ - uses: actions/upload-artifact@v4
214
+ with:
215
+ name: frontend-dist
216
+ path: frontend/dist/
217
+ retention-days: 7
docs/specs/00-context.md DELETED
@@ -1,214 +0,0 @@
1
- # context: stroke-deepisles-demo
2
-
3
- > **Disclaimer**: This software is for research and demonstration purposes only. Not for clinical use.
4
-
5
- ## overview
6
-
7
- This document explains **why** we're building `stroke-deepisles-demo` and the architectural context that informs our design decisions.
8
-
9
- ## the problem we're solving
10
-
11
- We want to demonstrate an end-to-end neuroimaging inference pipeline:
12
-
13
- ```
14
- CURRENT (Phase 1A):
15
- Local NIfTI files (extracted from ISLES24-MR-Lite ZIPs)
16
-
17
- File-based loader (parse BIDS filenames)
18
-
19
- DeepISLES Docker (stroke segmentation)
20
-
21
- NiiVue visualization (Gradio Space)
22
-
23
- FUTURE (Phase 1C-D):
24
- HuggingFace Hub (properly uploaded dataset)
25
-
26
- Tobias's datasets fork (BIDS loader + Nifti feature)
27
-
28
- DeepISLES Docker (stroke segmentation)
29
-
30
- NiiVue visualization (Gradio Space)
31
- ```
32
-
33
- This showcases that:
34
- 1. Neuroimaging data can be loaded from local BIDS-named files (NOW)
35
- 2. Neuroimaging data can be consumed from HF Hub with proper BIDS/NIfTI support (FUTURE)
36
- 3. Clinical-grade models can run via Docker as black boxes
37
- 4. Results can be visualized interactively in a browser
38
-
39
- ## critical discovery (2025-12-04)
40
-
41
- **The original ISLES24-MR-Lite dataset is NOT properly uploaded to HuggingFace.**
42
-
43
- It's just raw ZIP files dumped on HF, not a proper Dataset with parquet/Arrow format. This means `load_dataset()` fails. See `data/discovery/isles24_schema_report.txt` for full details.
44
-
45
- **Workaround**: We extracted the ZIPs locally to `data/isles24/` (git-ignored) and will implement a file-based loader first. Later, we'll re-upload properly and verify full HF consumption.
46
-
47
- ## why we need tobias's datasets fork
48
-
49
- As of December 2025, the official `huggingface/datasets` library has **partial** NIfTI support but lacks critical features for neuroimaging workflows.
50
-
51
- ### what's merged upstream
52
-
53
- | PR | Author | Status | Description |
54
- |----|--------|--------|-------------|
55
- | [#7874](https://github.com/huggingface/datasets/pull/7874) | CloseChoice (Tobias) | Merged Nov 21 | NIfTI visualization support |
56
- | [#7878](https://github.com/huggingface/datasets/pull/7878) | CloseChoice (Tobias) | Merged Nov 27 | Replace papaya with NiiVue |
57
-
58
- ### what's NOT merged (and why we need the fork)
59
-
60
- | PR | Author | Status | Description |
61
- |----|--------|--------|-------------|
62
- | [#7886](https://github.com/huggingface/datasets/pull/7886) | The-Obstacle-Is-The-Way | Open | **BIDS dataset loader** - `load_dataset('bids', ...)` |
63
- | [#7887](https://github.com/huggingface/datasets/pull/7887) | The-Obstacle-Is-The-Way | Open | **NIfTI lazy loading fix** - use `dataobj` not `get_fdata()` |
64
- | [#7892](https://github.com/huggingface/datasets/pull/7892) | CloseChoice (Tobias) | Open | **NIfTI encoding for lazy upload** - fixes Arrow serialization |
65
-
66
- The fork branch bundles all these features:
67
- ```
68
- https://github.com/CloseChoice/datasets/tree/feat/bids-loader-streaming-upload-fix
69
- ```
70
-
71
- We pin to this branch until upstream merges the PRs.
72
-
73
- ## key components
74
-
75
- ### 1. data source: ISLES24-MR-Lite
76
-
77
- - **HF Dataset**: [YongchengYAO/ISLES24-MR-Lite](https://huggingface.co/datasets/YongchengYAO/ISLES24-MR-Lite) (**BROKEN** - raw ZIPs, not proper dataset)
78
- - **Local extracted**: `data/isles24/` (git-ignored)
79
- - **Content**: 149 acute stroke MRI cases with DWI, ADC, and manual infarct masks
80
- - **Origin**: Subset of ISLES 2024 challenge data
81
- - **Why suitable**: DeepISLES was trained on ISLES 2022, so ISLES24 is an **external** test set (no data leakage)
82
-
83
- **File structure** (after extraction):
84
- ```
85
- data/isles24/
86
- ├── Images-DWI/sub-stroke{XXXX}_ses-02_dwi.nii.gz # 149 files
87
- ├── Images-ADC/sub-stroke{XXXX}_ses-02_adc.nii.gz # 149 files
88
- └── Masks/sub-stroke{XXXX}_ses-02_lesion-msk.nii.gz # 149 files
89
- ```
90
-
91
- **Schema reference**: `data/discovery/isles24_schema_report.txt`
92
-
93
- ### 2. model: DeepISLES
94
-
95
- - **Paper**: Nature Communications 2025 - "DeepISLES: A clinically validated ischemic stroke segmentation model"
96
- - **GitHub**: [ezequieldlrosa/DeepIsles](https://github.com/ezequieldlrosa/DeepIsles)
97
- - **Docker**: `isleschallenge/deepisles`
98
- - **Inputs**: DWI + ADC (required), FLAIR (required for ensemble, optional for fast mode)
99
- - **Output**: 3D binary lesion mask (NIfTI)
100
- - **Mode**: `fast=True` runs **SEALS only** (the ISLES'22 challenge winner)
101
-
102
- #### Why we use `fast=True` (SEALS-only mode)
103
-
104
- DeepISLES is an ensemble of 3 models from the ISLES'22 challenge:
105
-
106
- | Model | Based On | Inputs Required | Notes |
107
- |-------|----------|-----------------|-------|
108
- | **SEALS** | nnUNet | DWI + ADC | 🏆 **ISLES'22 Winner** - runs in `--fast` mode |
109
- | NVAUTO | MONAI Auto3dseg | DWI + ADC + FLAIR | Requires FLAIR |
110
- | SWAN | FACTORIZER | DWI + ADC + FLAIR | Requires FLAIR |
111
-
112
- **Key insight**: ISLES24-MR-Lite contains only DWI + ADC (no FLAIR). Therefore:
113
- - `--fast True` → Runs SEALS only → **Perfect match** for our dataset
114
- - `--fast False` → Would try to run all 3 models → NVAUTO/SWAN would fail without FLAIR
115
-
116
- This is **not a downgrade**. SEALS won the ISLES'22 challenge and is state-of-the-art for stroke lesion segmentation using DWI+ADC alone.
117
-
118
- #### Scientific validity: External validation with zero data leakage
119
-
120
- | Dataset | Year | Used For |
121
- |---------|------|----------|
122
- | **ISLES 2022** | 2022 | SEALS training data (250 cases) |
123
- | **ISLES 2024** | 2024 | Our test data (149 cases from MR-Lite) |
124
-
125
- - Different patient cohorts (2 years apart, different hospitals)
126
- - SEALS has **never seen** ISLES24 patients
127
- - We have ground truth masks → can validate predictions
128
- - This constitutes a legitimate **external validation study**
129
-
130
- ### 3. visualization: NiiVue
131
-
132
- - **Library**: [niivue/niivue](https://github.com/niivue/niivue)
133
- - **Type**: WebGL2-based neuroimaging viewer
134
- - **Formats**: Native NIfTI support, overlays, multiplanar views
135
- - **Integration**: Via Gradio custom HTML component or iframe
136
-
137
- ### 4. UI framework: Gradio 5
138
-
139
- - **Version**: Gradio 5.x (latest as of Dec 2025)
140
- - **Features**: SSR for fast loading, improved components, WebRTC support
141
- - **Deployment**: Hugging Face Spaces
142
-
143
- ## architecture diagram
144
-
145
- ```
146
- ┌─────────────────────────────────────────────────────────────────┐
147
- │ stroke-deepisles-demo │
148
- ├─────────────────────────────────────────────────────────────────┤
149
- │ │
150
- │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
151
- │ │ data/ │ │ inference/ │ │ ui/ │ │
152
- │ │ │ │ │ │ │ │
153
- │ │ - loader │───▶│ - docker │───▶│ - gradio │ │
154
- │ │ - adapter │ │ - wrapper │ │ - niivue │ │
155
- │ │ - staging │ │ - pipeline │ │ - viewer │ │
156
- │ └──────────────┘ └──────────────┘ └──────────────┘ │
157
- │ │ │ │ │
158
- │ ▼ ▼ ▼ │
159
- │ ┌──────────────────────────────────────────────────────┐ │
160
- │ │ core/ │ │
161
- │ │ - config (pydantic-settings) │ │
162
- │ │ - types (dataclasses, TypedDicts) │ │
163
- │ │ - exceptions │ │
164
- │ └──────────────────────────────────────────────────────┘ │
165
- │ │
166
- └─────────────────────────────────────────────────────────────────┘
167
- │ │ │
168
- ▼ ▼ ▼
169
- ┌──────────┐ ┌──────────┐ ┌──────────┐
170
- │ HF Hub │ │ Docker │ │ Browser │
171
- │ datasets │ │ Engine │ │ WebGL2 │
172
- └──────────┘ └──────────┘ └──────────┘
173
- ```
174
-
175
- ## design principles
176
-
177
- 1. **Vertical slices**: Each phase delivers runnable functionality
178
- 2. **TDD**: Tests written before implementation
179
- 3. **Type safety**: Full type hints, mypy/pyright strict mode
180
- 4. **Separation of concerns**: Data, inference, and UI are independent modules
181
- 5. **Docker as black box**: We don't reimplement DeepISLES, we call it
182
- 6. **Graceful degradation**: Mock Docker for tests, fallback viewers if NiiVue fails
183
-
184
- ## reference repositories
185
-
186
- These are cloned locally (without git linkages) for reference:
187
-
188
- | Directory | Source | Purpose |
189
- |-----------|--------|---------|
190
- | `_reference_repos/datasets-tobias-bids-fork/` | CloseChoice/datasets@feat/bids-loader-streaming-upload-fix | BIDS loader + NIfTI lazy loading |
191
- | `_reference_repos/arc-aphasia-bids/` | The-Obstacle-Is-The-Way/arc-aphasia-bids | BIDS upload patterns (reference only) |
192
- | `_reference_repos/DeepIsles/` | ezequieldlrosa/DeepIsles | DeepISLES CLI interface reference |
193
- | `_reference_repos/bids-neuroimaging-space/` | [TobiasPitters/bids-neuroimaging](https://huggingface.co/spaces/TobiasPitters/bids-neuroimaging) | **Working NiiVue + FastAPI implementation** |
194
-
195
- ### key reference: tobias's bids-neuroimaging space
196
-
197
- This is the most important reference for Phase 4 (UI). It demonstrates:
198
-
199
- 1. **NiiVue working in HF Spaces** - Proof that WebGL2 viewer works in production
200
- 2. **FastAPI + raw HTML approach** - Clean, no Gradio overhead for viewer
201
- 3. **Base64 data URLs for NIfTI** - `data:application/octet-stream;base64,{b64}`
202
- 4. **NiiVue CDN loading** - `https://unpkg.com/@niivue/niivue@0.57.0/dist/index.js`
203
- 5. **Multiplanar + 3D rendering** - `setSliceType(sliceTypeMultiplanar)` + `setMultiplanarLayout(2)`
204
-
205
- Key file: `main.py` (~485 lines) - complete working implementation.
206
-
207
- ## sources
208
-
209
- - [uv project configuration](https://docs.astral.sh/uv/concepts/projects/config/)
210
- - [Python packaging guide - pyproject.toml](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/)
211
- - [Real Python - Managing projects with uv](https://realpython.com/python-uv/)
212
- - [Gradio 5 announcement](https://huggingface.co/blog/gradio-5)
213
- - [NiiVue GitHub](https://github.com/niivue/niivue)
214
- - [Gradio custom HTML components](https://www.gradio.app/guides/custom_HTML_components)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/24-bug-gradio-webgl-analysis.md DELETED
@@ -1,156 +0,0 @@
1
- # Bug #24: Gradio + WebGL/NiiVue Root Cause Analysis
2
-
3
- **Date:** 2025-12-10
4
- **Status:** ALL `gr.HTML` HACKS FAILED - Custom Component Required
5
- **Issue:** HF Spaces stuck on "Loading..." forever
6
- **Root Cause:** The `gr.HTML` + `js_on_load` + async `import()` pattern blocks Svelte hydration
7
- **Note:** Gradio CAN do WebGL via Custom Components (proven by gradio-litmodel3d)
8
- **Solution:** Build Gradio Custom Component (see spec #28)
9
-
10
- ---
11
-
12
- ## CONFIRMED: All gr.HTML Hacks Have Failed
13
-
14
- | Attempt | Date | Result |
15
- |---------|------|--------|
16
- | CDN import in js_on_load | Dec 9 | FAILED - CSP blocks external imports |
17
- | Vendored + dynamic import() in js_on_load | Dec 9 | FAILED - Blocks Svelte hydration |
18
- | head_paths with loader HTML | Dec 9 | FAILED - Same hydration issue |
19
- | head= with inline import() | Dec 10 | **FAILED** - Confirmed DOA |
20
-
21
- **There is no hack that works.** The only path forward is spec #28 (Gradio Custom Component).
22
-
23
- ---
24
-
25
- ## Why Are We Using Gradio?
26
-
27
- **What Gradio provides:**
28
- - Quick ML demo UIs with Python only (no frontend code needed)
29
- - Built-in components: file upload, sliders, dropdowns, image display
30
- - Easy deployment to HuggingFace Spaces
31
- - Handles backend/frontend communication automatically
32
-
33
- **What Gradio does NOT provide:**
34
- - Native support for NIfTI/DICOM medical imaging (closed as "not planned" - [Issue #4511](https://github.com/gradio-app/gradio/issues/4511))
35
- - Native WebGL canvas component (closed as "not planned" - [Issue #7649](https://github.com/gradio-app/gradio/issues/7649))
36
- - Clean way to embed custom WebGL libraries like NiiVue
37
-
38
- ---
39
-
40
- ## The Root Cause: We're Fighting `gr.HTML`, Not Gradio
41
-
42
- ### What We're Trying To Do
43
- Embed NiiVue (a WebGL2 library) into `gr.HTML` using JavaScript.
44
-
45
- ### Why `gr.HTML` + JavaScript Doesn't Work
46
- 1. **`gr.HTML` strips `<script>` tags** - Security feature
47
- 2. **`js_on_load` with async `import()` blocks Svelte hydration** - **PROVEN** by A/B test
48
- 3. **Our A/B test confirmed**: Disabling `js_on_load` makes the app load perfectly
49
- 4. **`head=` parameter with `import()`** - Same hydration blocking issue
50
-
51
- ### Gradio CAN Do WebGL
52
- **Important clarification:** Gradio supports WebGL via Custom Components. `gradio-litmodel3d` proves this.
53
-
54
- The issue is specifically the `gr.HTML` + `js_on_load` + `import()` pattern, NOT Gradio itself.
55
-
56
- ### Gradio's Official Stance
57
- From Gradio maintainer Abubakar Abid on Issues #4511 and #7649:
58
- > "We are not planning to include this in the core Gradio library."
59
- > "We've now made it possible for Gradio users to create their own custom components."
60
-
61
- **The official answer is: Create a Gradio Custom Component.**
62
-
63
- ---
64
-
65
- ## The Four Options (Ranked by Effort)
66
-
67
- ### Option 1: Keep Hacking `gr.HTML` (Current Approach)
68
- - **Effort:** Low
69
- - **Success probability:** 30%
70
- - **What we're trying:** `head=`, `demo.load(_js=...)`, `gr.Blocks(js=...)`
71
- - **Problem:** Fighting Gradio's architecture
72
-
73
- ### Option 2: Create a Gradio Custom Component
74
- - **Effort:** Medium (2-3 days)
75
- - **Success probability:** 90%
76
- - **What it is:** A proper Svelte + Python component that wraps NiiVue
77
- - **Why it works:** This is the official Gradio way to add WebGL
78
- - **Resources:**
79
- - [Custom Components Guide](https://www.gradio.app/guides/custom-components-in-five-minutes)
80
- - [gradio-litmodel3d](https://pypi.org/project/gradio-litmodel3d/) - Example WebGL custom component
81
- - [Custom Components Gallery](https://www.gradio.app/custom-components/gallery)
82
-
83
- ### Option 3: Static HTML Space (No Gradio)
84
- - **Effort:** High (rebuild entire UI)
85
- - **Success probability:** 99%
86
- - **What it is:** Pure HTML/CSS/JS app on HF Spaces
87
- - **Why it works:** WebGL works perfectly (Unity, Three.js examples exist)
88
- - **Downside:** Lose Gradio's nice features (file upload UX, etc.)
89
-
90
- ### Option 4: 2D Slice Fallback (Remove NiiVue Entirely)
91
- - **Effort:** Low
92
- - **Success probability:** 100%
93
- - **What it is:** Use Matplotlib 2D slices instead of 3D WebGL viewer
94
- - **Why it works:** Already works (Static Report tab)
95
- - **Downside:** No interactive 3D visualization
96
-
97
- ---
98
-
99
- ## Comparison: Custom Component vs Static HTML
100
-
101
- | Aspect | Custom Component | Static HTML |
102
- |--------|------------------|-------------|
103
- | Keep Gradio features | Yes | No |
104
- | File upload UX | Built-in | Must build |
105
- | Sliders/dropdowns | Built-in | Must build |
106
- | HF Spaces deployment | Works | Works |
107
- | Development time | 2-3 days | 3-5 days |
108
- | Maintainability | Better (Gradio handles updates) | Worse (all custom) |
109
-
110
- ---
111
-
112
- ## Recommendation
113
-
114
- **If current PR #28 fails:**
115
-
116
- 1. **First try:** `demo.load(_js=...)` approach (1 hour)
117
- 2. **If that fails:** Create a Gradio Custom Component for NiiVue (2-3 days)
118
- 3. **Nuclear option:** Static HTML Space or remove 3D viewer entirely
119
-
120
- **The Custom Component approach is the "correct" solution** - it's what Gradio maintainers recommend for WebGL content. We've been trying to hack around Gradio instead of working with it.
121
-
122
- ---
123
-
124
- ## Existing Work We Can Reference
125
-
126
- 1. **[gradio-litmodel3d](https://pypi.org/project/gradio-litmodel3d/)** - WebGL Model3D with HDR support
127
- 2. **[Unet-nifti-gradio](https://github.com/benjaminirving/Unet-nifti-gradio)** - NIfTI + Gradio integration
128
- 3. **[papaya-image-viewer-gradio](https://github.com/gradio-app/gradio/issues/4511)** - Medical imaging viewer mentioned in Issue #4511
129
- 4. **[NiiVue docs](https://niivue.com/docs/)** - Official NiiVue integration guide
130
-
131
- ---
132
-
133
- ## Answer: "What Does Gradio Unblock?"
134
-
135
- **Gradio unblocks:**
136
- - UI/UX components (dropdowns, sliders, file upload, etc.)
137
- - Backend/frontend communication
138
- - Easy HF Spaces deployment
139
- - Python-only development (no JS required for basic apps)
140
-
141
- **Gradio does NOT unblock:**
142
- - Custom WebGL content (you need a Custom Component)
143
- - Medical imaging formats (NIfTI, DICOM)
144
- - Advanced JavaScript integrations
145
-
146
- **If we go Static HTML:** Yes, we'd have to write all the HTML/CSS/JS ourselves, including file upload handling, UI layout, etc. That's what Gradio provides "for free."
147
-
148
- ---
149
-
150
- ## Sources
151
-
152
- - [HF Forum: Gradio HTML with JS](https://discuss.huggingface.co/t/gradio-html-component-with-javascript-code-dont-work/37316)
153
- - [Gradio Issue #4511: 3D Medical Images](https://github.com/gradio-app/gradio/issues/4511)
154
- - [Gradio Issue #7649: WebGL Canvas](https://github.com/gradio-app/gradio/issues/7649)
155
- - [Gradio Custom Components Guide](https://www.gradio.app/guides/custom-components-in-five-minutes)
156
- - [HF Unity WebGL Template](https://github.com/huggingface/Unity-WebGL-template-for-Hugging-Face-Spaces)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/28-gradio-custom-component-niivue.md DELETED
@@ -1,702 +0,0 @@
1
- # Spec #28: Gradio Custom Component for NiiVue
2
-
3
- **Date:** 2025-12-10
4
- **Status:** REQUIRED - All gr.HTML hacks have failed (confirmed Dec 10)
5
- **Blocks:** Issue #24 (HF Spaces "Loading..." forever)
6
- **Effort:** Medium (2-3 days + 0.5-1 day buffer for HF Spaces quirks)
7
- **Success Probability:** 90%
8
- **Audited:** AUDIT_REPORT_2025_12_10.md - GO recommendation
9
-
10
- ---
11
-
12
- ## Executive Summary
13
-
14
- **All `gr.HTML` + JavaScript approaches have FAILED. This is the only path forward.**
15
-
16
- Gradio maintainers have explicitly closed both:
17
- - [Issue #4511](https://github.com/gradio-app/gradio/issues/4511) - NIfTI/medical imaging support → "Not planned"
18
- - [Issue #7649](https://github.com/gradio-app/gradio/issues/7649) - WebGL canvas component → "Not planned"
19
-
20
- Their official answer: **"Create a Gradio Custom Component."**
21
-
22
- This spec documents what we need to build to properly integrate NiiVue (WebGL2 medical imaging viewer) into our Gradio app.
23
-
24
- ---
25
-
26
- ## Why Current Approach Fails
27
-
28
- ### What We've Tried
29
-
30
- | Attempt | Why It Failed |
31
- |---------|---------------|
32
- | CDN import in js_on_load | HF Spaces CSP blocks external imports |
33
- | Vendored NiiVue + dynamic import() | import() in js_on_load blocks Svelte hydration |
34
- | head= parameter | Still uses ES module import, same problem |
35
- | head_paths= parameter | Same as above |
36
- | gr.set_static_paths() | File serving works, but JS loading mechanism broken |
37
-
38
- ### Root Cause
39
-
40
- **We're fighting `gr.HTML`'s limitations, not Gradio itself.** Gradio CAN do WebGL (proven by `gradio-litmodel3d`), but NOT via `gr.HTML`:
41
-
42
- 1. `gr.HTML` strips `<script>` tags (security)
43
- 2. `js_on_load` runs during component mount - **async `import()` blocks Svelte hydration**
44
- 3. Our A/B test proved: disabling `js_on_load` makes the app load perfectly
45
-
46
- **The `gr.HTML` + `js_on_load` + `import()` pattern is the blocker.** Custom Components solve this by using Svelte's proper `onMount` lifecycle.
47
-
48
- ---
49
-
50
- ## The Solution: Gradio Custom Component
51
-
52
- ### What Is a Gradio Custom Component?
53
-
54
- A Custom Component is a proper Svelte + Python component that integrates with Gradio's architecture:
55
-
56
- ```
57
- gradio-niivue-viewer/
58
- ├── frontend/
59
- │ ├── Index.svelte # Svelte component (renders NiiVue)
60
- │ ├── package.json # Frontend deps (including niivue)
61
- │ └── ...
62
- ├── backend/
63
- │ └── gradio_niivue_viewer/
64
- │ └── __init__.py # Python component class
65
- ├── pyproject.toml # Package definition
66
- └── demo/
67
- └── app.py # Example usage
68
- ```
69
-
70
- ### Why This Works
71
-
72
- 1. **Svelte-native**: Component integrates with Gradio's lifecycle properly
73
- 2. **Official pattern**: Gradio maintainers recommend this for WebGL
74
- 3. **Isolated loading**: NiiVue loads within the component, not globally
75
- 4. **Proper error handling**: Failures don't block app initialization
76
- 5. **Reusable**: Can publish to PyPI for others to use
77
-
78
- ---
79
-
80
- ## Prerequisites
81
-
82
- ### Build Tooling Requirements
83
-
84
- | Tool | Version | Purpose |
85
- |------|---------|---------|
86
- | Node.js | >= 18.x | Required by `gradio cc build` |
87
- | npm | >= 9.x | Package management for Svelte frontend |
88
- | Python | >= 3.10 | Backend component |
89
- | Gradio | >= 5.0 | Custom component framework |
90
-
91
- Verify installation:
92
- ```bash
93
- node --version # v18.x or higher
94
- npm --version # 9.x or higher
95
- gradio --version # 5.x or higher
96
- ```
97
-
98
- ### Packaging Plan
99
-
100
- **Location:** Monorepo subdirectory at `packages/gradio-niivue-viewer/`
101
-
102
- This approach:
103
- - Keeps component close to main app for easy iteration
104
- - Allows `pip install -e packages/gradio-niivue-viewer` for local development
105
- - No PyPI publishing required initially (can add later)
106
-
107
- ---
108
-
109
- ## Value Schema
110
-
111
- The component uses Gradio's file serving URLs (not base64) per Issue #19 optimization:
112
-
113
- ```typescript
114
- // Frontend (Svelte)
115
- interface NiiVueViewerValue {
116
- background_url: string | null; // e.g., "/gradio_api/file=/tmp/.../dwi.nii.gz"
117
- overlay_url: string | null; // e.g., "/gradio_api/file=/tmp/.../mask.nii.gz"
118
- }
119
- ```
120
-
121
- ```python
122
- # Backend (Python)
123
- class NiiVueViewerData(GradioModel):
124
- background_url: str | None = None # Gradio file URL
125
- overlay_url: str | None = None # Gradio file URL
126
- ```
127
-
128
- **Critical:** URLs must use `/gradio_api/file=` format, NOT base64. This reduces payload from ~65MB to <1KB.
129
-
130
- ---
131
-
132
- ## Technical Approach
133
-
134
- ### Phase 1: Scaffold Component (1 hour)
135
-
136
- Use Gradio's CLI to create the component:
137
-
138
- ```bash
139
- # From repository root
140
- mkdir -p packages
141
- cd packages
142
-
143
- gradio cc create NiiVueViewer \
144
- --template Image \
145
- --overwrite
146
- ```
147
-
148
- This creates the basic structure with Svelte frontend and Python backend.
149
-
150
- ### Phase 2: Implement Svelte Frontend (4-6 hours)
151
-
152
- #### 2a. Install NiiVue dependency
153
-
154
- ```bash
155
- cd packages/gradio-niivue-viewer/frontend
156
- npm install @niivue/niivue@0.65.0 --save-exact
157
- ```
158
-
159
- This pins the exact version `0.65.0` to match our tested vendored copy.
160
-
161
- #### 2b. Verify package.json
162
-
163
- ```json
164
- {
165
- "name": "gradio-niivue-viewer",
166
- "version": "0.1.0",
167
- "dependencies": {
168
- "@niivue/niivue": "0.65.0"
169
- }
170
- }
171
- ```
172
-
173
- #### 2c. Modify `frontend/Index.svelte`:
174
-
175
- ```svelte
176
- <script lang="ts">
177
- import { onMount, onDestroy } from 'svelte';
178
- import { Niivue } from '@niivue/niivue';
179
-
180
- // Value schema: Gradio file URLs (not base64)
181
- export let value: {
182
- background_url: string | null;
183
- overlay_url: string | null;
184
- } | null = null;
185
-
186
- let container: HTMLDivElement;
187
- let canvas: HTMLCanvasElement;
188
- let nv: Niivue | null = null;
189
- let error: string | null = null;
190
- let loading: boolean = true;
191
-
192
- // WebGL2 capability check
193
- function checkWebGL2(): boolean {
194
- const testCanvas = document.createElement('canvas');
195
- const gl = testCanvas.getContext('webgl2');
196
- return gl !== null;
197
- }
198
-
199
- onMount(async () => {
200
- // Check WebGL2 support first
201
- if (!checkWebGL2()) {
202
- error = 'WebGL2 is not supported in this browser. Please use Chrome, Firefox, or Edge.';
203
- loading = false;
204
- return;
205
- }
206
-
207
- try {
208
- nv = new Niivue({
209
- backColor: [0, 0, 0, 1],
210
- show3Dcrosshair: true,
211
- logging: false,
212
- });
213
- await nv.attachToCanvas(canvas);
214
-
215
- // Handle WebGL context loss
216
- canvas.addEventListener('webglcontextlost', handleContextLost);
217
- canvas.addEventListener('webglcontextrestored', handleContextRestored);
218
-
219
- await loadVolumes();
220
- loading = false;
221
- } catch (e) {
222
- error = `Failed to initialize viewer: ${e instanceof Error ? e.message : 'Unknown error'}`;
223
- loading = false;
224
- }
225
- });
226
-
227
- onDestroy(() => {
228
- if (canvas) {
229
- canvas.removeEventListener('webglcontextlost', handleContextLost);
230
- canvas.removeEventListener('webglcontextrestored', handleContextRestored);
231
- }
232
- if (nv) nv.dispose();
233
- });
234
-
235
- function handleContextLost(event: Event) {
236
- event.preventDefault();
237
- error = 'WebGL context lost. Please refresh the page.';
238
- }
239
-
240
- function handleContextRestored() {
241
- error = null;
242
- if (nv && value) loadVolumes();
243
- }
244
-
245
- async function loadVolumes() {
246
- if (!nv) return;
247
-
248
- // Handle null/cleared value: clear the viewer
249
- if (!value || (!value.background_url && !value.overlay_url)) {
250
- try {
251
- // Clear all loaded volumes
252
- while (nv.volumes.length > 0) {
253
- nv.removeVolumeByIndex(0);
254
- }
255
- nv.drawScene();
256
- } catch (e) {
257
- console.warn('Failed to clear volumes:', e);
258
- }
259
- return;
260
- }
261
-
262
- try {
263
- loading = true;
264
- error = null;
265
-
266
- const volumes = [];
267
- if (value.background_url) {
268
- volumes.push({ url: value.background_url, name: 'background.nii.gz' });
269
- }
270
- if (value.overlay_url) {
271
- volumes.push({
272
- url: value.overlay_url,
273
- name: 'overlay.nii.gz',
274
- colorMap: 'red',
275
- opacity: 0.5,
276
- });
277
- }
278
-
279
- if (volumes.length > 0) {
280
- await nv.loadVolumes(volumes);
281
- // Configure view after loading
282
- nv.setSliceType(nv.sliceTypeMultiplanar);
283
- nv.setRenderAzimuthElevation(120, 10);
284
- nv.drawScene();
285
- }
286
-
287
- loading = false;
288
- } catch (e) {
289
- error = `Failed to load volumes: ${e instanceof Error ? e.message : 'Unknown error'}`;
290
- loading = false;
291
- }
292
- }
293
-
294
- // Reactive: reload when value changes (including null to clear)
295
- $: if (nv && !loading) loadVolumes();
296
- </script>
297
-
298
- <div bind:this={container} class="niivue-container">
299
- {#if error}
300
- <div class="error-message">{error}</div>
301
- {:else if loading}
302
- <div class="loading-message">Loading viewer...</div>
303
- {/if}
304
- <canvas bind:this={canvas} class:hidden={!!error}></canvas>
305
- </div>
306
-
307
- <style>
308
- .niivue-container {
309
- width: 100%;
310
- height: 500px;
311
- background: #000;
312
- position: relative;
313
- border-radius: 8px;
314
- overflow: hidden;
315
- }
316
- canvas {
317
- width: 100%;
318
- height: 100%;
319
- }
320
- canvas.hidden {
321
- display: none;
322
- }
323
- .error-message {
324
- position: absolute;
325
- top: 50%;
326
- left: 50%;
327
- transform: translate(-50%, -50%);
328
- color: #f66;
329
- text-align: center;
330
- padding: 20px;
331
- max-width: 80%;
332
- }
333
- .loading-message {
334
- position: absolute;
335
- top: 50%;
336
- left: 50%;
337
- transform: translate(-50%, -50%);
338
- color: #888;
339
- text-align: center;
340
- }
341
- </style>
342
- ```
343
-
344
- **Key improvements from audit feedback:**
345
- - WebGL2 capability check before initialization
346
- - WebGL context loss/restore handlers
347
- - Proper error UI states
348
- - Loading state management
349
- - Reactive update when value changes
350
-
351
- ### Phase 3: Implement Python Backend (2-3 hours)
352
-
353
- ```python
354
- # backend/gradio_niivue_viewer/__init__.py
355
- from __future__ import annotations
356
- from typing import Any
357
- from gradio.components.base import Component
358
- from gradio.data_classes import FileData, GradioModel
359
-
360
- class NiiVueViewerData(GradioModel):
361
- background_url: str | None = None
362
- overlay_url: str | None = None
363
-
364
- class NiiVueViewer(Component):
365
- """WebGL NIfTI viewer using NiiVue."""
366
-
367
- data_model = NiiVueViewerData
368
-
369
- def __init__(
370
- self,
371
- value: NiiVueViewerData | None = None,
372
- *,
373
- label: str | None = None,
374
- height: int = 500,
375
- **kwargs,
376
- ):
377
- self.height = height
378
- super().__init__(value=value, label=label, **kwargs)
379
-
380
- def preprocess(self, payload: NiiVueViewerData | None) -> dict[str, Any] | None:
381
- if payload is None:
382
- return None
383
- return {
384
- "background_url": payload.background_url,
385
- "overlay_url": payload.overlay_url,
386
- }
387
-
388
- def postprocess(self, value: dict[str, Any] | None) -> NiiVueViewerData | None:
389
- if value is None:
390
- return None
391
- return NiiVueViewerData(
392
- background_url=value.get("background_url"),
393
- overlay_url=value.get("overlay_url"),
394
- )
395
- ```
396
-
397
- ### Phase 4: Build and Test (2-3 hours)
398
-
399
- ```bash
400
- # Build the component
401
- cd gradio-niivue-viewer
402
- gradio cc build
403
-
404
- # Install locally
405
- pip install -e .
406
-
407
- # Test in demo app
408
- python demo/app.py
409
- ```
410
-
411
- ### Phase 5: Integrate into stroke-deepisles-demo (1-2 hours)
412
-
413
- Replace `gr.HTML` with the custom component:
414
-
415
- ```python
416
- # Before (broken)
417
- from stroke_deepisles_demo.ui.viewer import create_niivue_html
418
- viewer = gr.HTML(value="", elem_id="niivue-viewer")
419
- # ... then set viewer.value = create_niivue_html(...)
420
-
421
- # After (working)
422
- from gradio_niivue_viewer import NiiVueViewer
423
- viewer = NiiVueViewer(label="Interactive 3D Viewer")
424
- # ... then set viewer.value = {"background_url": dwi_url, "overlay_url": mask_url}
425
- ```
426
-
427
- ### Phase 6: HF Spaces Deployment (CRITICAL)
428
-
429
- **This phase is essential.** HF Spaces runs `pip install` only - it does NOT run `npm` or `gradio cc build`.
430
-
431
- #### 6a. Commit build artifacts to git
432
-
433
- ```bash
434
- cd packages/gradio-niivue-viewer
435
-
436
- # Build the component (generates frontend/dist/ or templates/)
437
- gradio cc build
438
-
439
- # Force-add build artifacts (they may be gitignored by default)
440
- git add -f gradio_niivue_viewer/templates/
441
- # Or wherever the build output lands - check with:
442
- # find . -name "*.js" -path "*/dist/*" -o -name "*.css" -path "*/dist/*"
443
-
444
- git commit -m "chore: add compiled frontend assets for HF Spaces deployment"
445
- ```
446
-
447
- **Why:** HF Spaces won't run npm/node build steps. The compiled JS/CSS must be in the repo.
448
-
449
- #### 6b. Update requirements.txt
450
-
451
- Add the local component to the main `requirements.txt`:
452
-
453
- ```text
454
- # requirements.txt
455
- gradio>=5.0
456
- # ... other deps ...
457
-
458
- # Local custom component (editable install)
459
- -e ./packages/gradio-niivue-viewer
460
- ```
461
-
462
- **Alternative:** If the component is at repo root:
463
- ```text
464
- -e .
465
- ```
466
-
467
- #### 6c. Verify .gitignore doesn't exclude build artifacts
468
-
469
- Check that `packages/gradio-niivue-viewer/.gitignore` doesn't exclude:
470
- - `gradio_niivue_viewer/templates/`
471
- - `frontend/dist/`
472
- - Any compiled `.js` or `.css` files needed at runtime
473
-
474
- If they're excluded, either:
475
- 1. Remove those lines from `.gitignore`, OR
476
- 2. Use `git add -f` to force-add them
477
-
478
- #### 6d. Test deployment flow
479
-
480
- ```bash
481
- # Simulate what HF Spaces does
482
- pip install -r requirements.txt
483
- python -m stroke_deepisles_demo.ui.app
484
-
485
- # Should work WITHOUT running gradio cc build
486
- ```
487
-
488
- ---
489
-
490
- ## Existing References
491
-
492
- ### Working WebGL Custom Components
493
-
494
- 1. **[gradio-litmodel3d](https://pypi.org/project/gradio-litmodel3d/)**
495
- - WebGL Model3D viewer with HDR lighting
496
- - Source: https://github.com/gradio-app/gradio/tree/main/demo/model3d_component
497
- - Proof that WebGL works in Custom Components
498
-
499
- 2. **[gradio-molecule3d](https://pypi.org/project/gradio-molecule3d/)**
500
- - 3D molecule viewer
501
- - Uses Three.js (WebGL)
502
-
503
- ### Gradio Documentation
504
-
505
- - [Custom Components in 5 Minutes](https://www.gradio.app/guides/custom-components-in-five-minutes)
506
- - [Gradio Components Documentation](https://www.gradio.app/docs/gradio/components)
507
- - [Custom Component Gallery](https://www.gradio.app/custom-components/gallery)
508
-
509
- ### NiiVue Resources
510
-
511
- - [NiiVue GitHub](https://github.com/niivue/niivue)
512
- - [NiiVue npm](https://www.npmjs.com/package/@niivue/niivue)
513
- - [NiiVue Examples](https://niivue.com/docs/)
514
-
515
- ---
516
-
517
- ## Acceptance Criteria
518
-
519
- ### Must Have (MVP)
520
-
521
- - [ ] Component loads NIfTI volumes from Gradio file URLs
522
- - [ ] Component displays background image (DWI)
523
- - [ ] Component displays overlay mask (segmentation) with colormap
524
- - [ ] Component works on HuggingFace Spaces
525
- - [ ] No "Loading..." hang - failures are graceful
526
- - [ ] All existing tests pass
527
-
528
- ### Nice to Have (Future)
529
-
530
- - [ ] Crosshair controls
531
- - [ ] Slice orientation toggle (axial/coronal/sagittal)
532
- - [ ] Opacity slider for overlay
533
- - [ ] Pan/zoom/rotate controls
534
- - [ ] Screenshot/export functionality
535
- - [ ] Publish to PyPI for community use
536
-
537
- ---
538
-
539
- ## Risk Assessment
540
-
541
- | Risk | Mitigation |
542
- |------|------------|
543
- | Svelte/TypeScript learning curve | Follow gradio-litmodel3d example closely |
544
- | NiiVue WebGL2 browser support | Explicit WebGL2 check in Svelte + graceful error UI |
545
- | Build system complexity | Use gradio cc tooling, don't customize |
546
- | HF Spaces static file serving | Component bundles NiiVue, no external deps |
547
- | **Build artifacts not in git** | Phase 6a: Force-add compiled assets with `git add -f` |
548
- | **requirements.txt missing component** | Phase 6b: Add `-e ./packages/gradio-niivue-viewer` |
549
-
550
- ---
551
-
552
- ## Alternatives Considered
553
-
554
- ### Alternative 1: Keep Hacking gr.HTML
555
- - **Effort:** Low
556
- - **Success probability:** 0% (CONFIRMED FAILED)
557
- - **Why rejected:** We tried 6 approaches over 2 days. ALL failed. This is not a viable path.
558
-
559
- ### Alternative 2: Static HTML Space (No Gradio)
560
- - **Effort:** High (rebuild entire UI)
561
- - **Success probability:** 99%
562
- - **Why rejected:** Lose Gradio's file upload, dropdowns, layout features. Too much work.
563
-
564
- ### Alternative 3: Remove 3D Viewer (2D Only)
565
- - **Effort:** Low
566
- - **Success probability:** 100%
567
- - **Why rejected:** Loses key feature. Static Report tab already works, but 3D is valuable.
568
-
569
- ---
570
-
571
- ## Decision
572
-
573
- **Proceed with Gradio Custom Component approach.**
574
-
575
- 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%.
576
-
577
- ---
578
-
579
- ## Testing Matrix
580
-
581
- ### Level 1: Local Build Verification
582
-
583
- ```bash
584
- cd packages/gradio-niivue-viewer
585
-
586
- # Build component
587
- gradio cc build
588
-
589
- # Install locally
590
- pip install -e .
591
-
592
- # Run demo app
593
- python demo/app.py
594
- # → Verify: App loads, no console errors, viewer renders
595
- ```
596
-
597
- **Pass criteria:**
598
- - [ ] `gradio cc build` completes without errors
599
- - [ ] Demo app launches at localhost:7860
600
- - [ ] No JavaScript console errors
601
- - [ ] Canvas renders (black background visible)
602
-
603
- ### Level 2: Volume Loading Test
604
-
605
- ```python
606
- # demo/app.py
607
- import gradio as gr
608
- from gradio_niivue_viewer import NiiVueViewer
609
-
610
- def load_sample():
611
- # Use a known good NIfTI file
612
- return {
613
- "background_url": "/gradio_api/file=/path/to/sample.nii.gz",
614
- "overlay_url": None
615
- }
616
-
617
- with gr.Blocks() as demo:
618
- viewer = NiiVueViewer()
619
- btn = gr.Button("Load Sample")
620
- btn.click(load_sample, outputs=viewer)
621
-
622
- demo.launch()
623
- ```
624
-
625
- **Pass criteria:**
626
- - [ ] NIfTI file loads without errors
627
- - [ ] Multiplanar view displays correctly
628
- - [ ] Overlay mask renders with red colormap (when provided)
629
-
630
- ### Level 3: HF Spaces Dry Run
631
-
632
- Deploy to a **private/throwaway Space** before production:
633
-
634
- ```bash
635
- # Create test space
636
- huggingface-cli repo create test-niivue-viewer --type space --private
637
-
638
- # Push and test
639
- git push hf-test main
640
- ```
641
-
642
- **Pass criteria:**
643
- - [ ] Space shows "Running" (not stuck on "Loading...")
644
- - [ ] Viewer initializes (no hydration deadlock)
645
- - [ ] Volume loading works via Gradio file serving
646
- - [ ] WebGL2 error shown gracefully if unsupported
647
-
648
- ### Level 4: Integration Test
649
-
650
- Replace `gr.HTML` in stroke-deepisles-demo:
651
-
652
- ```python
653
- # src/stroke_deepisles_demo/ui/components.py
654
- from gradio_niivue_viewer import NiiVueViewer
655
-
656
- def create_results_display():
657
- # ...
658
- niivue_viewer = NiiVueViewer(label="Interactive 3D Viewer")
659
- # ...
660
- ```
661
-
662
- **Pass criteria:**
663
- - [ ] Existing 136 tests still pass
664
- - [ ] Segmentation pipeline works end-to-end
665
- - [ ] Viewer displays DWI + mask overlay
666
- - [ ] No "Loading..." hang on HF Spaces
667
-
668
- ---
669
-
670
- ## Next Steps
671
-
672
- 1. [x] Senior review of this spec (AUDIT_REPORT_2025_12_10.md)
673
- 2. [x] Red team review - all gaps addressed (build artifacts, npm install, null handling)
674
- 3. [ ] Create `packages/gradio-niivue-viewer/` subdirectory
675
- 4. [ ] Scaffold component with `gradio cc create`
676
- 5. [ ] Install NiiVue: `cd frontend && npm install @niivue/niivue@0.65.0`
677
- 6. [ ] Implement Svelte frontend (with WebGL2 checks + null value handling)
678
- 7. [ ] Implement Python backend
679
- 8. [ ] Level 1 test: Local build verification
680
- 9. [ ] Level 2 test: Volume loading
681
- 10. [ ] Level 3 test: HF Spaces dry run
682
- 11. [ ] Level 4 test: Integration
683
- 12. [ ] **CRITICAL**: Commit build artifacts to git
684
- 13. [ ] **CRITICAL**: Update requirements.txt with `-e ./packages/gradio-niivue-viewer`
685
- 14. [ ] (Optional) Publish to PyPI
686
-
687
- ---
688
-
689
- ## Appendix: Why WebGL + `gr.HTML` Doesn't Work
690
-
691
- From the ROOT_CAUSE_ANALYSIS.md and GRADIO_WEBGL_ANALYSIS.md research:
692
-
693
- 1. **Gradio CAN do WebGL** - proven by `gradio-litmodel3d` custom component
694
- 2. **But NOT via `gr.HTML`** - the `js_on_load` + `import()` pattern blocks Svelte hydration
695
- 3. **Our A/B test proved it** - disabling `js_on_load` makes the app load perfectly
696
- 4. **Gradio closed NIfTI support** (Issue #4511) - "Not planned for core"
697
- 5. **Gradio closed WebGL canvas** (Issue #7649) - "Not planned for core"
698
- 6. **gr.HTML strips script tags** - Security feature, can't bypass
699
- 7. **HF Spaces CSP blocks external CDNs** - Must vendor or bundle dependencies
700
- 8. **Gradio maintainer recommendation**: Custom Components
701
-
702
- 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.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/29-codebase-status-audit.md DELETED
@@ -1,276 +0,0 @@
1
- # Spec #29: Codebase Status Audit (Issue #24 NiiVue/WebGL)
2
-
3
- **Date:** 2025-12-10
4
- **Status:** ALL `gr.HTML` HACKS CONFIRMED FAILED (Dec 10, 2025)
5
- **Purpose:** Top-down analysis of current frontend/NiiVue implementation state after multiple hotfix attempts
6
-
7
- ---
8
-
9
- ## Executive Summary: The `gr.HTML` + `js_on_load` + `import()` Pattern is Broken
10
-
11
- After 6 iterations of attempted hotfixes for Issue #24 (HF Spaces "Loading..." forever), **every `gr.HTML`-based approach has failed**:
12
-
13
- | Attempt | Result |
14
- |---------|--------|
15
- | CDN import | FAILED - CSP blocked |
16
- | Vendored + js_on_load import() | FAILED - Blocks Svelte hydration |
17
- | head_paths | FAILED - Same hydration issue |
18
- | head= with import() | **FAILED** - Confirmed Dec 10 |
19
-
20
- **Root Cause (PROVEN):** Async `import()` inside `js_on_load` blocks Gradio's Svelte hydration. Our A/B test confirmed: disabling `js_on_load` makes the app load.
21
-
22
- **Clarification:** Gradio CAN do WebGL via Custom Components (`gradio-litmodel3d` proves this). The issue is the `gr.HTML` approach, not Gradio itself.
23
-
24
- **The correct solution is Gradio Custom Component (spec #28).**
25
-
26
- ---
27
-
28
- ## Current Frontend Architecture
29
-
30
- ### File Inventory
31
-
32
- | File | Purpose | Lines | Status |
33
- |------|---------|-------|--------|
34
- | `ui/viewer.py` | NiiVue HTML/JS generation | 643 | **BLOATED** - contains 5 approaches |
35
- | `ui/app.py` | Main Gradio app | 313 | Clean |
36
- | `ui/components.py` | UI components | 94 | Clean |
37
- | `app.py` (root) | Local dev entry | 61 | Clean |
38
- | `ui/assets/niivue.js` | Vendored NiiVue v0.65.0 | 2.9MB | **NECESSARY** |
39
-
40
- ### What's in `viewer.py` Right Now
41
-
42
- | Component | Lines | Status | Notes |
43
- |-----------|-------|--------|-------|
44
- | `NIIVUE_VERSION` | 30 | OK | Version tracking |
45
- | `_ASSET_DIR`, `_NIIVUE_JS_PATH` | 31-32 | OK | Path constants |
46
- | `NIIVUE_JS_URL` | 36 | **UNUSED** | Computed but not actually used |
47
- | Module-level logging | 39-42 | **SLOP** | 4 log statements at import time |
48
- | `get_niivue_head_html()` | 45-77 | **PROBLEMATIC** | Still uses `await import()` |
49
- | `get_niivue_loader_path()` | 80-109 | **DEPRECATED** | Marked deprecated but still exists |
50
- | `nifti_to_gradio_url()` | 112-142 | OK | Issue #19 fix, working |
51
- | `get_slice_at_max_lesion()` | 145-187 | OK | Matplotlib helper |
52
- | `render_3panel_view()` | 190-281 | OK | Matplotlib 3-panel |
53
- | `render_slice_comparison()` | 284-380 | OK | Matplotlib comparison |
54
- | `create_niivue_html()` | 383-434 | OK | HTML generation |
55
- | `NIIVUE_ON_LOAD_JS` | 449-538 | **MOSTLY OK** | No import(), uses window.Niivue |
56
- | `NIIVUE_UPDATE_JS` | 546-642 | **MOSTLY OK** | No import(), uses window.Niivue |
57
-
58
- ---
59
-
60
- ## The Core Problem: `get_niivue_head_html()` Still Uses `import()`
61
-
62
- The current "fix" in `get_niivue_head_html()` does this:
63
-
64
- ```javascript
65
- // viewer.py:63-76
66
- <script type="module">
67
- try {
68
- const niivueUrl = '{NIIVUE_JS_URL}';
69
- console.log('[NiiVue Loader] Attempting to load from:', niivueUrl);
70
- const { Niivue } = await import(niivueUrl); // <-- SAME BROKEN PATTERN!
71
- window.Niivue = Niivue;
72
- console.log('[NiiVue Loader] Successfully loaded');
73
- } catch (error) {
74
- console.error('[NiiVue Loader] FAILED to load:', error);
75
- window.NIIVUE_LOAD_ERROR = error.message;
76
- }
77
- </script>
78
- ```
79
-
80
- **This is the EXACT same `await import()` pattern that breaks on HF Spaces.**
81
-
82
- The only difference from our previous attempts:
83
- - Before: `await import()` in `js_on_load`
84
- - Now: `await import()` in `head=` script
85
-
86
- **Why this might not matter:** The A/B test proved that `js_on_load` with async code breaks Gradio. Moving the `import()` to `head=` might help, but it's still executing async code that could fail silently and leave `window.Niivue` undefined.
87
-
88
- ---
89
-
90
- ## What's Necessary vs What's Slop
91
-
92
- ### NECESSARY (Keep)
93
-
94
- | Item | Why |
95
- |------|-----|
96
- | `ui/assets/niivue.js` | HF Spaces CSP blocks CDN imports |
97
- | `gr.set_static_paths()` | Required for Gradio 6.x file serving |
98
- | `nifti_to_gradio_url()` | Issue #19 fix, working |
99
- | `create_niivue_html()` | Generates viewer HTML |
100
- | `NIIVUE_ON_LOAD_JS` | Initializes viewer (doesn't import) |
101
- | `NIIVUE_UPDATE_JS` | Re-initializes after updates |
102
- | Matplotlib functions | Working 2D fallback |
103
- | `allowed_paths` in launch() | Runtime file access |
104
-
105
- ### SLOP (Should Remove/Refactor)
106
-
107
- | Item | Why It's Slop |
108
- |------|---------------|
109
- | `NIIVUE_JS_URL` module-level computation | Computed but unused in production |
110
- | Module-level logging (lines 39-42) | Noisy startup logs, not useful |
111
- | `get_niivue_loader_path()` | Deprecated, generates file we don't need |
112
- | `get_niivue_head_html()` with import() | Still uses broken pattern |
113
- | Multiple diagnostic docs | Overlapping, contradictory, stale |
114
-
115
- ### UNCERTAIN (Depends on head= fix working)
116
-
117
- | Item | Status |
118
- |------|--------|
119
- | `head=get_niivue_head_html()` in launch() | **30% chance this works** |
120
-
121
- ---
122
-
123
- ## Documentation Status
124
-
125
- ### docs/specs/ Files
126
-
127
- | File | Status | Issue |
128
- |------|--------|-------|
129
- | `00-context.md` | **ACCURATE** | None |
130
- | `28-gradio-custom-component-niivue.md` | **ACCURATE** | Just written |
131
- | `AUDIT_JS_LOADING_ISSUES.md` | **OUTDATED** | Says `set_static_paths` is blocker, but we've moved past that |
132
- | `DIAGNOSTIC_HF_LOADING.md` | **OUTDATED** | Lists hypotheses we've since disproven |
133
- | `ROOT_CAUSE_ANALYSIS.md` | **PARTIALLY OUTDATED** | Says "IN PROGRESS", discusses head= as solution |
134
- | `GRADIO_WEBGL_ANALYSIS.md` | **ACCURATE** | Core analysis, identifies real problem |
135
-
136
- ### docs/TECHNICAL_DEBT.md
137
-
138
- | Status | Issue |
139
- |--------|-------|
140
- | **OUTDATED** | Claims "Ironclad/Production-Ready" but doesn't mention P0 NiiVue/WebGL blocker |
141
-
142
- ---
143
-
144
- ## Recommended Cleanup Actions
145
-
146
- ### Immediate (If head= fix fails)
147
-
148
- 1. **Delete deprecated code:**
149
- - Remove `get_niivue_loader_path()`
150
- - Remove module-level logging
151
- - Clean up `NIIVUE_JS_URL` if unused
152
-
153
- 2. **Archive old diagnostic docs:**
154
- - Move `AUDIT_JS_LOADING_ISSUES.md` to `archive/`
155
- - Move `DIAGNOSTIC_HF_LOADING.md` to `archive/`
156
- - Update `ROOT_CAUSE_ANALYSIS.md` status
157
-
158
- 3. **Update TECHNICAL_DEBT.md:**
159
- - Add P0 section for NiiVue/WebGL blocker
160
- - Link to spec #28 (Custom Component)
161
-
162
- ### Long-term (After decision on path forward)
163
-
164
- 1. **If Custom Component route:**
165
- - Remove all `head=` NiiVue loading code
166
- - Remove `get_niivue_head_html()`
167
- - Simplify `viewer.py` to just Matplotlib functions
168
- - NiiVue loading becomes the component's responsibility
169
-
170
- 2. **If 2D fallback route:**
171
- - Remove entire NiiVue integration
172
- - Remove `ui/assets/niivue.js` (2.9MB)
173
- - Remove `NIIVUE_ON_LOAD_JS`, `NIIVUE_UPDATE_JS`
174
- - Keep only Matplotlib rendering
175
-
176
- ---
177
-
178
- ## Honest Assessment
179
-
180
- ### What We've Tried (6+ iterations)
181
-
182
- 1. **CDN import** → Blocked by CSP
183
- 2. **Vendored + dynamic import in js_on_load** → Blocks Svelte hydration
184
- 3. **head_paths with loader HTML** → Complex, didn't work
185
- 4. **head= with inline import()** → Current state, **probably won't work**
186
- 5. **Various set_static_paths/allowed_paths combos** → File serving works, JS loading doesn't
187
-
188
- ### The Pattern
189
-
190
- Every attempt has been a variation of:
191
- > "Load NiiVue via some JavaScript mechanism within Gradio"
192
-
193
- Every attempt has failed because:
194
- > **Gradio was not designed for custom WebGL content**
195
-
196
- ### The Correct Solution
197
-
198
- **Stop fighting Gradio's architecture. Use a Gradio Custom Component.**
199
-
200
- This is:
201
- - What Gradio maintainers recommend (Issues #4511, #7649)
202
- - How existing WebGL components work (gradio-litmodel3d)
203
- - 90% success probability vs 30% for more hacks
204
-
205
- See spec #28 for implementation details.
206
-
207
- ---
208
-
209
- ## Current Entry Point Flow
210
-
211
- ```
212
- HF Spaces Docker
213
-
214
- CMD ["python", "-m", "stroke_deepisles_demo.ui.app"]
215
-
216
- ui/app.py __main__ block
217
-
218
- gr.set_static_paths([_ASSETS_DIR]) # Enable file serving
219
-
220
- get_demo() # Creates Blocks with js_on_load components
221
-
222
- demo.launch(
223
- head=get_niivue_head_html(), # <-- Injects <script type="module"> with import()
224
- allowed_paths=[_ASSETS_DIR],
225
- )
226
-
227
- Browser loads page
228
-
229
- <head> script runs: await import('/gradio_api/file=.../niivue.js')
230
-
231
- [UNCERTAIN] Does import() succeed? Does it block Svelte?
232
-
233
- If yes: window.Niivue is set, js_on_load works
234
- If no: window.Niivue undefined, viewer shows error
235
- ```
236
-
237
- ---
238
-
239
- ## Files Modified During Issue #24 Debug
240
-
241
- | File | Changes | Commits |
242
- |------|---------|---------|
243
- | `viewer.py` | ~6 rewrites of JS loading approach | Multiple |
244
- | `ui/app.py` | Added head=, set_static_paths | Multiple |
245
- | `app.py` | Same as ui/app.py | Multiple |
246
- | `ui/assets/niivue.js` | Added vendored library | 1 |
247
- | `.gitignore` | Added niivue-loader.html | 1 |
248
- | `.pre-commit-config.yaml` | Exclude assets/ from large file check | 1 |
249
-
250
- ---
251
-
252
- ## Conclusion
253
-
254
- **The codebase is messy but not unfixable.** The mess comes from iterating through multiple failed approaches without cleaning up between attempts.
255
-
256
- **The real issue is architectural:** Gradio + custom WebGL = unsupported pattern.
257
-
258
- **Next steps:**
259
- 1. Test if current `head=` approach works on HF Spaces (low confidence)
260
- 2. If it fails, implement Gradio Custom Component (spec #28)
261
- 3. Clean up cruft regardless of which path we take
262
-
263
- ---
264
-
265
- ## Appendix: How to Verify Current State
266
-
267
- ```bash
268
- # Check if NiiVue file serving works
269
- curl -I "https://[space-url]/gradio_api/file=/home/user/demo/src/stroke_deepisles_demo/ui/assets/niivue.js"
270
- # Should return 200 OK with application/javascript
271
-
272
- # Check browser console for:
273
- # - "[NiiVue Loader] Attempting to load from: ..."
274
- # - "[NiiVue Loader] Successfully loaded" OR "[NiiVue Loader] FAILED"
275
- # - Any errors during Gradio initialization
276
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/30-bug-hf-spaces-build-packages-dir.md DELETED
@@ -1,116 +0,0 @@
1
- # Spec #30: HF Spaces Build Error - Missing packages/ Directory
2
-
3
- ## Status: 🔴 P0 BLOCKER
4
-
5
- ## Issue
6
- HuggingFace Spaces Docker build fails with:
7
- ```
8
- ERROR: Invalid requirement: './packages/niivueviewer': Expected package name at the start of dependency specifier
9
- ./packages/niivueviewer
10
- ^ (from line 28 of requirements.txt)
11
- Hint: It looks like a path. File './packages/niivueviewer' does not exist.
12
- ```
13
-
14
- ## Root Cause Analysis
15
-
16
- ### Timeline
17
- 1. PR #29 added `gradio_niivueviewer` custom component in `packages/niivueviewer/`
18
- 2. Added `./packages/niivueviewer` to `requirements.txt` line 28 for local installs
19
- 3. Dockerfile copies `requirements.txt` but NOT `packages/` directory
20
- 4. `pip install -r requirements.txt` fails because path doesn't exist
21
-
22
- ### Architecture Gap
23
- ```
24
- Local Development:
25
- requirements.txt → ./packages/niivueviewer → ✅ EXISTS
26
-
27
- HF Spaces Docker:
28
- COPY requirements.txt → Container
29
- pip install -r requirements.txt
30
- → ./packages/niivueviewer → ❌ NOT COPIED
31
- ```
32
-
33
- ## Solution Options
34
-
35
- ### Option A: Copy packages/ in Dockerfile (Recommended)
36
- Add `COPY --chown=1000:1000 packages/ /home/user/demo/packages/` before pip install.
37
-
38
- **Pros:**
39
- - Simple fix
40
- - Preserves local development workflow
41
- - Editable install works correctly
42
-
43
- **Cons:**
44
- - Adds ~1.3MB to Docker image (compiled JS bundle)
45
-
46
- ### Option B: Build wheel and include in Docker
47
- Pre-build wheel, copy to container, install from wheel file.
48
-
49
- **Pros:**
50
- - More "production" approach
51
-
52
- **Cons:**
53
- - More complex build process
54
- - Need to manage wheel artifacts
55
-
56
- ### Option C: Separate requirements files
57
- Create `requirements-docker.txt` without local path dependencies.
58
-
59
- **Pros:**
60
- - Clear separation of concerns
61
-
62
- **Cons:**
63
- - Duplicate maintenance
64
- - Easy to drift out of sync
65
-
66
- ## Recommended Fix
67
-
68
- **Option A** - Simple, maintainable, aligns with how local development works.
69
-
70
- ### Implementation
71
-
72
- ```dockerfile
73
- # BEFORE pip install, add:
74
- COPY --chown=1000:1000 packages/ /home/user/demo/packages/
75
- ```
76
-
77
- ### Full Dockerfile Change
78
-
79
- ```dockerfile
80
- # Copy requirements first for better layer caching
81
- COPY --chown=1000:1000 requirements.txt /home/user/demo/requirements.txt
82
-
83
- # Copy custom component packages (required for pip install)
84
- COPY --chown=1000:1000 packages/ /home/user/demo/packages/
85
-
86
- # Install Python dependencies into SYSTEM Python
87
- RUN pip install --no-cache-dir -r requirements.txt
88
- ```
89
-
90
- ## Test Plan
91
-
92
- ### Level 1: Local Docker Build
93
- ```bash
94
- docker build -t stroke-demo-test .
95
- docker run -p 7860:7860 stroke-demo-test
96
- ```
97
-
98
- ### Level 2: HF Spaces Deploy
99
- Push to HF Spaces, verify build succeeds and app loads.
100
-
101
- ### Level 3: Functional Test
102
- - Upload NIfTI files
103
- - Run segmentation
104
- - Verify NiiVue 3D viewer renders volumes
105
-
106
- ## Files to Modify
107
- - `Dockerfile` - Add COPY for packages/ directory
108
-
109
- ## Risk Assessment
110
- - **Low risk** - Additive change, doesn't modify existing code
111
- - **Reversible** - Can easily remove if issues arise
112
-
113
- ## Acceptance Criteria
114
- - [ ] HF Spaces build succeeds
115
- - [ ] App loads without "Loading..." hang
116
- - [ ] NiiVue viewer displays volumes correctly
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/AUDIT_REPORT_2025_12_10.md DELETED
@@ -1,52 +0,0 @@
1
- # Audit Report: Issue #24 Root Cause & Custom Component Proposal
2
-
3
- **Date:** December 10, 2025
4
- **Auditor:** Gemini (Senior CLI Agent)
5
- **Review Scope:** Spec #28, Root Cause Analysis, Codebase Status
6
-
7
- ## 1. Executive Summary
8
-
9
- **Recommendation: GO** - Proceed immediately with Spec #28 (Gradio Custom Component).
10
-
11
- My audit confirms your root cause analysis is accurate and your proposed solution is the correct, architecturally supported path. The "hacks" involving `gr.HTML` and `js_on_load` are fundamentally incompatible with Gradio's Svelte-based hydration lifecycle, especially when involving asynchronous module loading (`import()`). Continued attempts to patch `gr.HTML` will likely result in further fragility or failure.
12
-
13
- ## 2. Validation of Root Cause Analysis
14
-
15
- | Claim | Verdict | Evidence/Rationale |
16
- |-------|---------|--------------------|
17
- | **Gradio lacks native WebGL/NIfTI in core** | **CONFIRMED** | Issues #4511 and #7649 closed as "not planned" for core. BUT `gradio-litmodel3d` proves WebGL works via Custom Components. |
18
- | **`gr.HTML` + `import()` breaks hydration** | **CONFIRMED** | `js_on_load` runs during the critical hydration phase. Async operations or unhandled Promise rejections here can hang the entire Gradio app ("Loading..." forever). |
19
- | **HF Spaces CSP blocks CDNs** | **CONFIRMED** | HF Spaces enforces strict CSP. While `custom_http_headers` *can* relax this, it doesn't solve the hydration/execution context issue. |
20
- | **`gr.HTML` strips `<script>`** | **CONFIRMED** | Standard security behavior. The `head=` workaround puts scripts in the global scope, polluting the window object and risking race conditions, which you effectively identified. |
21
-
22
- **Key Insight:** The "Loading..." hang isn't just a network error; it's a frontend application state deadlock. Your move to a Custom Component moves the NiiVue initialization into a self-contained Svelte `onMount` lifecycle method, which is the correct place for side effects like WebGL context creation.
23
-
24
- ## 3. Evaluation of Proposed Solution (Spec #28)
25
-
26
- The proposed architecture for `gradio_niivue_viewer` is sound and follows best practices.
27
-
28
- * **Architecture:** Python Backend (data passing) + Svelte Frontend (NiiVue rendering) is the standard pattern.
29
- * **Reference:** `gradio-litmodel3d` is an excellent precedent. It proves WebGL contexts can be managed within a custom component.
30
- * **Data Passing:** Reusing the "File URL" optimization (Issue #19) is critical. Passing 65MB+ base64 strings to a custom component would degrade performance; passing `/gradio_api/file=` URLs is efficient.
31
-
32
- **Refinements for Implementation:**
33
- * **Build Tooling:** Ensure you have `node` and `npm` available in your environment, as `gradio cc build` requires them.
34
- * **Type Safety:** The Svelte code in your spec uses TypeScript (`<script lang="ts">`). Ensure your `tsconfig.json` (generated by `gradio cc`) is configured to handle `niivue` types if available, or add a declaration file.
35
- * **Fallbacks:** In your Svelte component, explicitly handle the case where WebGL2 context is lost or unavailable, providing a user-friendly error message within the component div, rather than crashing the whole app.
36
-
37
- ## 4. Alternative Approaches Audit
38
-
39
- You correctly dismissed the alternatives:
40
- * **CSP Header Config:** You *could* edit `README.md` to allow external CDNs (`custom_http_headers`), but this only fixes the *loading* of the script, not the *hydration blocking* caused by `js_on_load`. It's a partial fix that leads to the same deadlock.
41
- * **Static HTML Space:** Too high effort. Losing Gradio's file/state management would require rewriting the entire backend API.
42
-
43
- ## 5. Risk Assessment
44
-
45
- * **Component Publishing:** You don't strictly *need* to publish to PyPI to use it. You can install it locally (`pip install -e .`) within your repo, which is faster for iteration.
46
- * **NiiVue Version:** Ensure the custom component's `package.json` pins the exact version of NiiVue you've been testing with (`0.65.0` or newer) to avoid regressions.
47
-
48
- ## 6. Conclusion
49
-
50
- Your analysis effectively ruled out "easy" hacks. The investment of 2-3 days for a Custom Component is justified and necessary to unblock Feature #24.
51
-
52
- **Next Action:** Initialize the custom component immediately.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/NIIVUE-GRADIO-POSTMORTEM.md ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NiiVue + Gradio Integration: POSTMORTEM
2
+
3
+ **Date:** 2025-12-10
4
+ **Status:** ABANDONED
5
+ **Time Spent:** 2+ days
6
+ **Result:** IMPOSSIBLE with current Gradio architecture
7
+
8
+ ---
9
+
10
+ ## Executive Summary
11
+
12
+ We attempted to integrate NiiVue (a WebGL-based neuroimaging viewer) into a Gradio application deployed on HuggingFace Spaces. After exhaustive investigation across 10+ approaches, we conclusively determined that **Gradio is fundamentally incompatible with NiiVue or any JavaScript library requiring reliable execution**.
13
+
14
+ ---
15
+
16
+ ## Stack Versions
17
+
18
+ | Component | Version |
19
+ |-----------|---------|
20
+ | Python | 3.12 |
21
+ | Gradio | >=6.0.0,<7.0.0 |
22
+ | NiiVue | 0.65.0 (vendored) |
23
+ | Svelte (custom component) | ^5.43.4 |
24
+ | @gradio/preview | 0.15.1 |
25
+ | HuggingFace Spaces | Docker SDK |
26
+
27
+ ---
28
+
29
+ ## The Problem
30
+
31
+ We needed to display NiiVue inside a Gradio app on HF Spaces. NiiVue requires JavaScript execution to initialize WebGL and render volumes.
32
+
33
+ **Gradio does not reliably support JavaScript execution in HTML components.**
34
+
35
+ ---
36
+
37
+ ## All Approaches Tried
38
+
39
+ ### 1. gr.HTML with Inline `<script>` Tags
40
+
41
+ **What we did:** Put `<script type="module">` inside gr.HTML value
42
+
43
+ **Result:** FAILED
44
+
45
+ **Why:** Browsers do not execute `<script>` tags inserted via innerHTML. This is a browser security feature, not a bug. Gradio uses innerHTML to update gr.HTML components.
46
+
47
+ **Source:** [HuggingFace Forum](https://discuss.huggingface.co/t/gradio-html-component-with-javascript-code-dont-work/37316)
48
+
49
+ ---
50
+
51
+ ### 2. js_on_load Parameter with Dynamic import()
52
+
53
+ **What we did:** Used `gr.HTML(js_on_load=...)` with async IIFE containing `await import('niivue')`
54
+
55
+ **Result:** FAILED
56
+
57
+ **Why:** The async `import()` blocks Gradio's Svelte hydration process. The entire UI freezes on "Loading..." forever.
58
+
59
+ **Evidence:** A/B test confirmed - disabling js_on_load made the app load perfectly. All other features (DeepISLES pipeline, Matplotlib, Metrics) worked.
60
+
61
+ **Technical Detail:** `js_on_load` only runs ONCE when the component first mounts. When `gr.HTML` value updates (after segmentation), js_on_load does NOT re-run. The HTML updates but JavaScript initialization never happens again.
62
+
63
+ **Source:** [Gradio Custom HTML Components Guide](https://www.gradio.app/guides/custom_HTML_components) - "Event listeners attached in js_on_load are only attached once when the component is first rendered."
64
+
65
+ ---
66
+
67
+ ### 3. .then(fn=None, js=...) Pattern
68
+
69
+ **What we did:** Tried using `.then(fn=None, js=NIIVUE_UPDATE_JS)` on event handlers
70
+
71
+ **Result:** FAILED
72
+
73
+ **Why:** The `js` parameter in event handlers has a completely different context than `js_on_load`:
74
+ - `js_on_load` has access to `element` (the DOM element)
75
+ - `js` on event handlers only receives input/output VALUES, not DOM elements
76
+ - Must use `document.querySelector()` instead of `element.querySelector()`
77
+ - The async import() still blocked hydration
78
+
79
+ **Source:** [GitHub Issue #6729](https://github.com/gradio-app/gradio/issues/6729) - `js` without `fn` only executes if `fn` is explicitly set to `None`
80
+
81
+ ---
82
+
83
+ ### 4. head= Parameter on gr.Blocks() (Gradio 5 Syntax)
84
+
85
+ **What we did:** `gr.Blocks(head='<script>...</script>')`
86
+
87
+ **Result:** FAILED
88
+
89
+ **Why:** This is Gradio 5 syntax. In Gradio 6, `head=`, `js=`, `css=` moved from `gr.Blocks()` to `launch()`.
90
+
91
+ **Source:** [Gradio 6 Migration Guide](https://www.gradio.app/main/guides/gradio-6-migration-guide)
92
+
93
+ ---
94
+
95
+ ### 5. head= Parameter on launch() with import()
96
+
97
+ **What we did:** `demo.launch(head='<script type="module">await import(...)</script>')`
98
+
99
+ **Result:** FAILED
100
+
101
+ **Why:** Same problem as #2 - async import() still blocks hydration even when in `<head>`.
102
+
103
+ **Additional Issue:** [GitHub Issue #10250](https://github.com/gradio-app/gradio/issues/10250) documents that JavaScript in `head` param has non-deterministic execution - "would sometimes execute only after extended waiting periods (5+ minutes), or occasionally not at all."
104
+
105
+ ---
106
+
107
+ ### 6. head_paths= Parameter
108
+
109
+ **What we did:** Used `head_paths=['path/to/niivue-loader.html']`
110
+
111
+ **Result:** FAILED
112
+
113
+ **Why:** Multiple issues:
114
+ - Files weren't served correctly without `gr.set_static_paths()`
115
+ - [GitHub Issue #11649](https://github.com/gradio-app/gradio/issues/11649) - head= with file paths causes 404
116
+ - Even when files served correctly, the async import() pattern still blocked hydration
117
+
118
+ ---
119
+
120
+ ### 7. Vendored NiiVue (Local File)
121
+
122
+ **What we did:** Downloaded niivue.js (2.9MB) locally to bypass CDN/CSP issues
123
+
124
+ **Result:** PARTIALLY WORKED (file served) but FAILED (still blocks hydration)
125
+
126
+ **Why:** The file serving worked via `allowed_paths` and `gr.set_static_paths()`, but the async import() pattern still blocked Svelte hydration.
127
+
128
+ **Secondary Issue:** Base64 payload risk - encoding DWI (~30MB) + ADC (~18MB) as base64 would create ~65MB payloads, risking browser memory issues and Gradio payload limits.
129
+
130
+ ---
131
+
132
+ ### 8. Head Script + MutationObserver Pattern
133
+
134
+ **What we did:** Load NiiVue via `launch(head=...)` globally, then use MutationObserver to watch for DOM changes and trigger initialization
135
+
136
+ **Result:** FAILED
137
+
138
+ **Why:** The head script still uses `await import()` which blocks hydration. Even if NiiVue loads globally as `window.Niivue`, the async pattern during page load still freezes the UI.
139
+
140
+ **What it should have done:** MutationObserver watches for `data-*` attribute changes on gr.HTML elements, then calls `initNiiVue()`. But the initial import never completes due to hydration blocking.
141
+
142
+ ---
143
+
144
+ ### 9. Custom Svelte Component (packages/niivueviewer/)
145
+
146
+ **What we did:** Built a full Gradio custom component:
147
+ - Svelte 5 frontend with NiiVue bundled via npm (`@niivue/niivue@0.65.0`)
148
+ - Python backend component class
149
+ - StatusTracker for loading states
150
+ - Templates compiled via `gradio cc build`
151
+
152
+ **Result:** FAILED - Froze entire UI
153
+
154
+ **Issues encountered:**
155
+
156
+ | Issue | Description | Fix Applied | Still Broken |
157
+ |-------|-------------|-------------|--------------|
158
+ | Missing `gradio` prop | StatusTracker requires `gradio.i18n` for translations | PR #31 added it | Yes |
159
+ | Missing packages/ in Docker | Dockerfile didn't copy `packages/` directory | PR #30 added COPY | Yes |
160
+ | style.css 404 | [Issue #7026](https://github.com/gradio-app/gradio/issues/7026) - causes loading hang | N/A | Yes |
161
+ | CJS/ESM conflicts | [Issue #6087](https://github.com/gradio-app/gradio/issues/6087) - dev server stuck | N/A | Yes |
162
+ | Templates undefined | [Issue #9879](https://github.com/gradio-app/gradio/issues/9879) | N/A | Yes |
163
+
164
+ **Time spent:** ~1 day on this approach alone
165
+
166
+ **Root cause unresolved:** Even after adding `gradio` prop and shipping templates, UI still completely frozen. The exact failure mode remains unknown.
167
+
168
+ **Gradio team's stance on custom components:**
169
+ - [Issue #12074](https://github.com/gradio-app/gradio/issues/12074) - Proposed `gr.Custom` class because custom components are "too complex"
170
+ - Custom components have multiple fragile failure modes that make them riskier than simpler approaches
171
+
172
+ ---
173
+
174
+ ### 10. gradio-iframe Package
175
+
176
+ **What we did:** Attempted to use `gradio-iframe` package to isolate NiiVue in an iframe
177
+
178
+ **Result:** FAILED - Incompatible with Gradio 6
179
+
180
+ **Why:** `gradio-iframe` version 0.0.10 (Jan 2024) is the latest. Installing it forces Gradio downgrade from 6.x to 4.x. Package is effectively abandoned.
181
+
182
+ **Source:** [PyPI gradio-iframe](https://pypi.org/project/gradio-iframe/)
183
+
184
+ ---
185
+
186
+ ### 11. Inline iframe via gr.HTML (NOT TESTED)
187
+
188
+ **What we planned:** Use `gr.HTML('<iframe src="..."></iframe>')` with standalone viewer HTML
189
+
190
+ **Result:** PLANNED BUT NOT EXECUTED
191
+
192
+ **Why we stopped:** After 2+ days of failures, we stopped here because:
193
+ - No definitive evidence it works on HF Spaces
194
+ - CSP may still block JavaScript in iframes
195
+ - 50/50 chance of working - yet another gamble
196
+
197
+ **This remains the only untested approach** that might theoretically work.
198
+
199
+ ---
200
+
201
+ ## Root Causes
202
+
203
+ ### 1. Gradio's innerHTML Security Model
204
+
205
+ Gradio uses innerHTML to update component values. Browsers intentionally do not execute `<script>` tags inserted via innerHTML. This is a security feature, not a bug.
206
+
207
+ **Implication:** Any approach that relies on Gradio updating HTML content cannot include executable JavaScript.
208
+
209
+ ### 2. Svelte Hydration Blocking
210
+
211
+ Any async JavaScript execution during Gradio's component mounting phase can block Svelte hydration, causing the entire UI to freeze.
212
+
213
+ **Specifically:** `<script type="module">` with `import()` statements - even deferred - can interfere with Svelte's initialization timing in unpredictable ways.
214
+
215
+ ### 3. HuggingFace Spaces CSP
216
+
217
+ HF Spaces has Content Security Policy headers that block external CDN imports:
218
+ - `script-src 'self' 'unsafe-inline' 'unsafe-eval'` (estimated)
219
+ - External domains like unpkg.com are blocked
220
+ - CSP headers are NOT customizable per HF documentation
221
+
222
+ **Source:** [HF Spaces Config Reference](https://huggingface.co/docs/hub/spaces-config-reference)
223
+
224
+ ### 4. js_on_load Only Runs Once
225
+
226
+ Gradio's `js_on_load` executes only when the component first mounts, not when the value updates. This breaks any approach that needs to reinitialize JavaScript after data changes.
227
+
228
+ ### 5. Module Script Timing Race Condition
229
+
230
+ `<script type="module">` is deferred by default. It executes AFTER HTML parsing but the timing relative to Svelte component mounting is unpredictable. `window.Niivue` may be undefined when `js_on_load` tries to access it.
231
+
232
+ ### 6. Two Entry Points with Path Mismatch Risk
233
+
234
+ Root `app.py` vs `src/.../ui/app.py` compute asset paths differently:
235
+ - Root: `Path(__file__).parent / "src" / "..." / "assets"`
236
+ - Module: `Path(__file__).parent / "assets"`
237
+
238
+ Docker uses `python -m stroke_deepisles_demo.ui.app`, so only the module path matters, but this caused confusion during debugging.
239
+
240
+ ### 7. Gradio's Design Philosophy
241
+
242
+ Gradio is designed for simple input/output ML demos, not complex WebGL applications with their own JavaScript lifecycle. The Gradio maintainers explicitly closed requests for WebGL/NIfTI support:
243
+
244
+ | Issue | Request | Response |
245
+ |-------|---------|----------|
246
+ | [#4511](https://github.com/gradio-app/gradio/issues/4511) | NIfTI/3D medical image support | "Not planned" - told to build custom component |
247
+ | [#7649](https://github.com/gradio-app/gradio/issues/7649) | WebGL Canvas component | "Not planned" - "too niche" |
248
+
249
+ ---
250
+
251
+ ## GitHub Issues Referenced
252
+
253
+ ### WebGL/3D Related
254
+
255
+ | Issue | Description | Status |
256
+ |-------|-------------|--------|
257
+ | [#4511](https://github.com/gradio-app/gradio/issues/4511) | 3D medical image support | Closed - "use custom component" |
258
+ | [#7649](https://github.com/gradio-app/gradio/issues/7649) | WebGL Canvas component | Closed - "too niche" |
259
+ | [#5765](https://github.com/gradio-app/gradio/issues/5765) | Model3D rendered see-through | WebGL error |
260
+ | [#7632](https://github.com/gradio-app/gradio/issues/7632) | Model3D collapsed in tabs | Open |
261
+ | [#7485](https://github.com/gradio-app/gradio/issues/7485) | Model3D not working when embedded | Open |
262
+
263
+ ### Custom Component Related
264
+
265
+ | Issue | Description | Impact |
266
+ |-------|-------------|--------|
267
+ | [#7026](https://github.com/gradio-app/gradio/issues/7026) | style.css 404 causes hang | Loading forever |
268
+ | [#6087](https://github.com/gradio-app/gradio/issues/6087) | CJS imports break dev server | Dev stuck |
269
+ | [#9879](https://github.com/gradio-app/gradio/issues/9879) | Templates undefined | Component fails |
270
+ | [#12074](https://github.com/gradio-app/gradio/issues/12074) | Custom components too complex | Proposed gr.Custom |
271
+
272
+ ### JavaScript Loading Related
273
+
274
+ | Issue | Description | Status |
275
+ |-------|-------------|--------|
276
+ | [#11649](https://github.com/gradio-app/gradio/issues/11649) | head= with files causes 404 | Closed - use head_paths |
277
+ | [#10250](https://github.com/gradio-app/gradio/issues/10250) | head JS execution non-deterministic | Open |
278
+ | [#6426](https://github.com/gradio-app/gradio/issues/6426) | head argument bugs | Fixed in PR #6639 |
279
+ | [#6729](https://github.com/gradio-app/gradio/issues/6729) | js without fn requires explicit None | Closed |
280
+
281
+ ### HF Spaces SSE/Queue Related
282
+
283
+ | Issue | Description | Impact |
284
+ |-------|-------------|--------|
285
+ | [#5974](https://github.com/gradio-app/gradio/issues/5974) | Stuck in processing with queue | Loading forever |
286
+ | [#4279](https://github.com/gradio-app/gradio/issues/4279) | Stuck on Loading with new Gradio | Loading forever |
287
+ | [#4332](https://github.com/gradio-app/gradio/issues/4332) | Loading stuck in non-internet env | SSE connection issues |
288
+
289
+ ---
290
+
291
+ ## What Actually Works on HF Spaces
292
+
293
+ **Proof from 2025-12-11 HF Spaces test** (after removing custom component):
294
+
295
+ | Component | Status |
296
+ |-----------|--------|
297
+ | Gradio UI | ✅ WORKS - App loads, no freeze |
298
+ | Dropdown | ✅ WORKS - Case selector shows 149 cases |
299
+ | Buttons | ✅ WORKS - "Run Segmentation" triggers pipeline |
300
+ | DeepISLES Pipeline | ✅ WORKS - Runs in ~38 seconds |
301
+ | Matplotlib Plots | ✅ WORKS - Static Report renders correctly |
302
+ | Metrics JSON | ✅ WORKS - Displays dice_score, volume_ml |
303
+ | File Downloads | ✅ WORKS - Prediction NIfTI downloadable |
304
+ | NiiVue Viewer | ❌ BROKEN - Shows placeholder, script never executes |
305
+
306
+ **This proves:** The custom Svelte component WAS the cause of the UI freeze. Everything else in the stack works perfectly.
307
+
308
+ ---
309
+
310
+ ## Reference Implementation
311
+
312
+ The only working NiiVue + HF Spaces example:
313
+ - [TobiasPitters/bids-neuroimaging](https://huggingface.co/spaces/TobiasPitters/bids-neuroimaging)
314
+ - **Does NOT use Gradio** - uses FastAPI with raw HTML
315
+ - Scripts execute because they're in the actual HTML document, not injected via innerHTML
316
+ - Uses NiiVue 0.57.0
317
+
318
+ ---
319
+
320
+ ## Viable Paths Forward
321
+
322
+ ### Option 1: Remove NiiVue (IMPLEMENTED)
323
+
324
+ Keep 2D Matplotlib visualizations only. This is what we shipped.
325
+
326
+ **Pros:** Works reliably, no JavaScript complexity
327
+ **Cons:** No 3D interactivity
328
+
329
+ ### Option 2: Abandon Gradio
330
+
331
+ Rewrite with FastAPI + raw HTML, like the reference implementation.
332
+
333
+ **Pros:** Full control over JavaScript execution
334
+ **Cons:** Lose all Gradio benefits (state, components, themes, HF integration)
335
+
336
+ ### Option 3: Inline iframe (UNTESTED)
337
+
338
+ Use `gr.HTML('<iframe src="..."></iframe>')` with standalone viewer HTML.
339
+
340
+ **Pros:** Might work - iframes are isolated
341
+ **Cons:** Untested, CSP may still block, communication is complex
342
+
343
+ ### Option 4: Wait for Gradio gr.Custom
344
+
345
+ [Issue #12074](https://github.com/gradio-app/gradio/issues/12074) proposes simpler custom components.
346
+
347
+ **Pros:** Might solve our issues
348
+ **Cons:** No timeline, may never happen
349
+
350
+ ---
351
+
352
+ ## Lessons Learned
353
+
354
+ 1. **Gradio is not a general-purpose web framework** - It's designed for simple ML demos, not complex WebGL applications
355
+
356
+ 2. **JavaScript execution in Gradio is fundamentally broken** - innerHTML security model prevents script execution
357
+
358
+ 3. **js_on_load has severe limitations** - Only runs once, async IIFEs can block hydration, no access to `element` in .then(js=) handlers
359
+
360
+ 4. **"Workarounds" don't work** - js_on_load, head=, head_paths=, MutationObserver, custom components - all fail
361
+
362
+ 5. **Custom components are fragile** - Multiple failure modes (templates, i18n props, build artifacts) make them risky
363
+
364
+ 6. **Community packages may be abandoned** - gradio-iframe hasn't been updated for Gradio 6
365
+
366
+ 7. **Read the closed issues first** - Gradio maintainers already said "not planned" for WebGL
367
+
368
+ 8. **Use `data-*` attributes for state** - gr.HTML re-renders completely on update, so data attributes are the only reliable way to pass information to JavaScript
369
+
370
+ 9. **Two entry points = confusion** - Root app.py vs module app.py compute paths differently, causing debugging overhead
371
+
372
+ 10. **The only untested path is inline iframe** - If we ever revisit this, start there
373
+
374
+ ---
375
+
376
+ ## Final Status
377
+
378
+ **NiiVue integration: ABANDONED**
379
+
380
+ The app works on HF Spaces with 2D Matplotlib visualizations. The 3D NiiVue viewer is not possible with Gradio.
381
+
382
+ If 3D visualization is required in the future:
383
+ 1. Try inline iframe approach first (untested)
384
+ 2. If that fails, Gradio must be replaced entirely with FastAPI + raw HTML
385
+
386
+ ---
387
+
388
+ ## Archive Note
389
+
390
+ This postmortem consolidates all findings from the following archived specs:
391
+ - `00-context.md` - Project context
392
+ - `10-bug-niivue-viewer-black-screen.md` - Initial black screen investigation
393
+ - `11-bug-niivue-js-on-load-not-rerunning.md` - js_on_load limitations
394
+ - `19-perf-base64-to-file-urls.md` - Base64 payload optimization
395
+ - `24-bug-hf-spaces-loading-forever.md` - CSP and hydration analysis
396
+ - `24-bug-gradio-webgl-analysis.md` - WebGL analysis
397
+ - `28-gradio-custom-component-niivue.md` - Custom component attempt
398
+ - `29-codebase-status-audit.md` - Code audit
399
+ - `30-bug-hf-spaces-build-packages-dir.md` - Docker build fix
400
+ - `32-bug-hf-spaces-ui-frozen-audit.md` - UI freeze investigation
401
+ - `33-definitive-niivue-gradio-integration.md` - Integration research
402
+ - `34-COMPREHENSIVE-NIIVUE-GRADIO-AUDIT.md` - Final comprehensive audit
403
+ - `35-FINAL-ATTEMPT-GRADIO-IFRAME.md` - gradio-iframe attempt (not executed)
404
+ - `AUDIT_JS_LOADING_ISSUES.md` - JavaScript loading audit
405
+ - `DIAGNOSTIC_HF_LOADING.md` - HF loading diagnostics
406
+ - `ROOT_CAUSE_ANALYSIS.md` - Root cause analysis
407
+
408
+ The archive can be deleted now that this postmortem is complete.
docs/specs/archive/01-phase-0-repo-bootstrap.md DELETED
@@ -1,438 +0,0 @@
1
- # phase 0: repo bootstrap
2
-
3
- ## purpose
4
-
5
- Set up the foundational project structure with 2025 Python best practices. At the end of this phase, we have a working skeleton that can be installed, linted, type-checked, and tested (even if tests are empty).
6
-
7
- ## deliverables
8
-
9
- - [ ] `pyproject.toml` with uv + hatchling backend
10
- - [ ] `src/stroke_deepisles_demo/` package structure
11
- - [ ] `tests/` directory with pytest configuration
12
- - [ ] Development tooling: ruff, mypy, pre-commit
13
- - [ ] Basic `README.md` with clinical disclaimer
14
- - [ ] `.gitignore` updates if needed
15
-
16
- ## repo structure
17
-
18
- ```
19
- stroke-deepisles-demo/
20
- ├── pyproject.toml # Project metadata, deps, tool config
21
- ├── uv.lock # Locked dependencies (auto-generated)
22
- ├── .python-version # Python version (3.12)
23
- ├── README.md # Project overview + disclaimer
24
- ├── .gitignore # Standard Python ignores
25
- ├── .pre-commit-config.yaml # Pre-commit hooks
26
-
27
- ├── src/
28
- │ └── stroke_deepisles_demo/
29
- │ ├── __init__.py # Package version, exports
30
- │ ├── py.typed # PEP 561 marker
31
- │ │
32
- │ ├── core/ # Shared utilities
33
- │ │ ├── __init__.py
34
- │ │ ├── config.py # Pydantic settings (stub)
35
- │ │ ├── types.py # Shared type definitions (stub)
36
- │ │ └── exceptions.py # Custom exceptions (stub)
37
- │ │
38
- │ ├── data/ # Data loading (stub)
39
- │ │ └── __init__.py
40
- │ │
41
- │ ├── inference/ # DeepISLES integration (stub)
42
- │ │ └── __init__.py
43
- │ │
44
- │ └── ui/ # Gradio app (stub)
45
- │ └── __init__.py
46
-
47
- ├── tests/
48
- │ ├── __init__.py
49
- │ ├── conftest.py # Shared fixtures
50
- │ └── test_package.py # Smoke test: package imports
51
-
52
- └── docs/
53
- └── specs/ # These spec documents
54
- ├── 00-context.md
55
- ├── 01-phase-0-repo-bootstrap.md
56
- └── ...
57
- ```
58
-
59
- ## pyproject.toml specification
60
-
61
- ```toml
62
- [project]
63
- name = "stroke-deepisles-demo"
64
- version = "0.1.0"
65
- description = "Demo: HF datasets + DeepISLES stroke segmentation + Gradio visualization"
66
- readme = "README.md"
67
- license = { text = "MIT" }
68
- requires-python = ">=3.11"
69
- authors = [
70
- { name = "Your Name", email = "you@example.com" }
71
- ]
72
- classifiers = [
73
- "Development Status :: 3 - Alpha",
74
- "Intended Audience :: Science/Research",
75
- "License :: OSI Approved :: MIT License",
76
- "Programming Language :: Python :: 3.11",
77
- "Programming Language :: Python :: 3.12",
78
- "Topic :: Scientific/Engineering :: Medical Science Apps.",
79
- ]
80
- keywords = ["stroke", "neuroimaging", "segmentation", "BIDS", "NIfTI", "deep-learning"]
81
-
82
- dependencies = [
83
- # Core - pinned to Tobias's fork for BIDS + NIfTI lazy loading
84
- "datasets @ git+https://github.com/CloseChoice/datasets.git@feat/bids-loader-streaming-upload-fix",
85
- "huggingface-hub>=0.25.0",
86
-
87
- # NIfTI handling
88
- "nibabel>=5.2.0",
89
- "numpy>=1.26.0",
90
-
91
- # Configuration
92
- "pydantic>=2.5.0",
93
- "pydantic-settings>=2.1.0",
94
-
95
- # UI (Gradio 5.x)
96
- "gradio>=5.0.0",
97
- ]
98
-
99
- [dependency-groups]
100
- dev = [
101
- "pytest>=8.0.0",
102
- "pytest-cov>=4.1.0",
103
- "pytest-mock>=3.12.0",
104
- "mypy>=1.8.0",
105
- "ruff>=0.8.0",
106
- "pre-commit>=3.6.0",
107
- # Type stubs
108
- "types-requests",
109
- ]
110
-
111
- [build-system]
112
- requires = ["hatchling"]
113
- build-backend = "hatchling.build"
114
-
115
- [tool.hatch.build.targets.wheel]
116
- packages = ["src/stroke_deepisles_demo"]
117
-
118
- [tool.uv]
119
- dev-dependencies = [
120
- "pytest>=8.0.0",
121
- "pytest-cov>=4.1.0",
122
- "pytest-mock>=3.12.0",
123
- "mypy>=1.8.0",
124
- "ruff>=0.8.0",
125
- "pre-commit>=3.6.0",
126
- ]
127
-
128
- # ─────────────────────────────────────────────────────────────────
129
- # Tool configurations
130
- # ─────────────────────────────────────────────────────────────────
131
-
132
- [tool.ruff]
133
- target-version = "py311"
134
- line-length = 100
135
- src = ["src", "tests"]
136
-
137
- [tool.ruff.lint]
138
- select = [
139
- "E", # pycodestyle errors
140
- "W", # pycodestyle warnings
141
- "F", # pyflakes
142
- "I", # isort
143
- "B", # flake8-bugbear
144
- "C4", # flake8-comprehensions
145
- "UP", # pyupgrade
146
- "ARG", # flake8-unused-arguments
147
- "SIM", # flake8-simplify
148
- "TCH", # flake8-type-checking
149
- "PTH", # flake8-use-pathlib
150
- "RUF", # ruff-specific
151
- ]
152
- ignore = [
153
- "E501", # line too long (handled by formatter)
154
- ]
155
-
156
- [tool.ruff.lint.isort]
157
- known-first-party = ["stroke_deepisles_demo"]
158
-
159
- [tool.mypy]
160
- python_version = "3.11"
161
- strict = true
162
- warn_return_any = true
163
- warn_unused_ignores = true
164
- disallow_untyped_defs = true
165
- plugins = ["pydantic.mypy"]
166
-
167
- [[tool.mypy.overrides]]
168
- module = [
169
- "nibabel.*",
170
- "gradio.*",
171
- "datasets.*",
172
- "niivue.*",
173
- ]
174
- ignore_missing_imports = true
175
-
176
- [tool.pytest.ini_options]
177
- testpaths = ["tests"]
178
- python_files = ["test_*.py"]
179
- python_functions = ["test_*"]
180
- addopts = [
181
- "-v",
182
- "--tb=short",
183
- "--strict-markers",
184
- ]
185
- markers = [
186
- "integration: marks tests requiring external resources (Docker, network)",
187
- "slow: marks tests that take >10s to run",
188
- ]
189
- filterwarnings = [
190
- "ignore::DeprecationWarning",
191
- ]
192
-
193
- [tool.coverage.run]
194
- source = ["src/stroke_deepisles_demo"]
195
- branch = true
196
-
197
- [tool.coverage.report]
198
- exclude_lines = [
199
- "pragma: no cover",
200
- "if TYPE_CHECKING:",
201
- "raise NotImplementedError",
202
- ]
203
- ```
204
-
205
- ## module stubs
206
-
207
- ### `src/stroke_deepisles_demo/__init__.py`
208
-
209
- ```python
210
- """stroke-deepisles-demo: HF datasets + DeepISLES + Gradio visualization."""
211
-
212
- __version__ = "0.1.0"
213
-
214
- __all__ = ["__version__"]
215
- ```
216
-
217
- ### `src/stroke_deepisles_demo/core/config.py`
218
-
219
- ```python
220
- """Application configuration using pydantic-settings."""
221
-
222
- from __future__ import annotations
223
-
224
- from pydantic_settings import BaseSettings
225
-
226
-
227
- class Settings(BaseSettings):
228
- """Application settings loaded from environment variables."""
229
-
230
- # HuggingFace
231
- hf_dataset_id: str = "YongchengYAO/ISLES24-MR-Lite"
232
- hf_cache_dir: str | None = None
233
-
234
- # DeepISLES
235
- deepisles_docker_image: str = "isleschallenge/deepisles"
236
- deepisles_fast_mode: bool = True # SEALS-only (ISLES'22 winner, no FLAIR needed)
237
-
238
- # Paths
239
- temp_dir: str | None = None
240
-
241
- class Config:
242
- env_prefix = "STROKE_DEMO_"
243
- env_file = ".env"
244
-
245
-
246
- settings = Settings()
247
- ```
248
-
249
- ### `src/stroke_deepisles_demo/core/types.py`
250
-
251
- ```python
252
- """Shared type definitions."""
253
-
254
- from __future__ import annotations
255
-
256
- from dataclasses import dataclass
257
- from pathlib import Path
258
- from typing import TypedDict
259
-
260
-
261
- class CaseFiles(TypedDict):
262
- """Paths to NIfTI files for a single case."""
263
-
264
- dwi: Path
265
- adc: Path
266
- flair: Path | None
267
- ground_truth: Path | None
268
-
269
-
270
- @dataclass(frozen=True)
271
- class InferenceResult:
272
- """Result of running DeepISLES on a case."""
273
-
274
- case_id: str
275
- input_files: CaseFiles
276
- prediction_mask: Path
277
- elapsed_seconds: float
278
- ```
279
-
280
- ### `src/stroke_deepisles_demo/core/exceptions.py`
281
-
282
- ```python
283
- """Custom exceptions for stroke-deepisles-demo."""
284
-
285
- from __future__ import annotations
286
-
287
-
288
- class StrokeDemoError(Exception):
289
- """Base exception for stroke-deepisles-demo."""
290
-
291
-
292
- class DataLoadError(StrokeDemoError):
293
- """Failed to load data from HuggingFace Hub."""
294
-
295
-
296
- class DockerNotAvailableError(StrokeDemoError):
297
- """Docker is not installed or not running."""
298
-
299
-
300
- class DeepISLESError(StrokeDemoError):
301
- """DeepISLES inference failed."""
302
-
303
-
304
- class MissingInputError(StrokeDemoError):
305
- """Required input files are missing."""
306
- ```
307
-
308
- ## pre-commit configuration
309
-
310
- ### `.pre-commit-config.yaml`
311
-
312
- ```yaml
313
- repos:
314
- - repo: https://github.com/astral-sh/ruff-pre-commit
315
- rev: v0.8.0
316
- hooks:
317
- - id: ruff
318
- args: [--fix]
319
- - id: ruff-format
320
-
321
- - repo: https://github.com/pre-commit/mirrors-mypy
322
- rev: v1.8.0
323
- hooks:
324
- - id: mypy
325
- additional_dependencies:
326
- - pydantic>=2.5.0
327
- - pydantic-settings>=2.1.0
328
- args: [--config-file=pyproject.toml]
329
-
330
- - repo: https://github.com/pre-commit/pre-commit-hooks
331
- rev: v4.5.0
332
- hooks:
333
- - id: trailing-whitespace
334
- - id: end-of-file-fixer
335
- - id: check-yaml
336
- - id: check-added-large-files
337
- args: [--maxkb=1000]
338
- ```
339
-
340
- ## tdd plan
341
-
342
- ### tests to write first
343
-
344
- 1. **`tests/test_package.py`** - Smoke test that package imports work
345
-
346
- ```python
347
- """Smoke tests for package structure."""
348
-
349
- from __future__ import annotations
350
-
351
-
352
- def test_package_imports() -> None:
353
- """Verify the package can be imported."""
354
- import stroke_deepisles_demo
355
-
356
- assert stroke_deepisles_demo.__version__ == "0.1.0"
357
-
358
-
359
- def test_core_modules_import() -> None:
360
- """Verify core modules can be imported without side effects."""
361
- from stroke_deepisles_demo.core import config, exceptions, types
362
-
363
- assert config.settings is not None
364
- assert types.CaseFiles is not None
365
- assert exceptions.StrokeDemoError is not None
366
-
367
-
368
- def test_subpackages_exist() -> None:
369
- """Verify subpackage structure exists."""
370
- from stroke_deepisles_demo import data, inference, ui
371
-
372
- # These are stubs, just verify they exist
373
- assert data is not None
374
- assert inference is not None
375
- assert ui is not None
376
- ```
377
-
378
- ### what to mock
379
-
380
- - Nothing needed for Phase 0 - these are pure import tests
381
-
382
- ### what to test for real
383
-
384
- - Package imports
385
- - Module structure
386
- - Type definitions load correctly
387
- - Pydantic settings initialize with defaults
388
-
389
- ## "done" criteria
390
-
391
- Phase 0 is complete when:
392
-
393
- 1. `uv sync` succeeds and creates virtual environment
394
- 2. `uv run pytest` passes all smoke tests
395
- 3. `uv run ruff check .` reports no errors
396
- 4. `uv run ruff format --check .` reports no changes needed
397
- 5. `uv run mypy src/` passes with no errors
398
- 6. `uv run pre-commit run --all-files` passes
399
- 7. Package can be imported: `uv run python -c "import stroke_deepisles_demo"`
400
-
401
- ## commands cheatsheet
402
-
403
- ```bash
404
- # Initialize (if starting fresh)
405
- uv init --package stroke-deepisles-demo
406
-
407
- # Install dependencies
408
- uv sync
409
-
410
- # Run tests
411
- uv run pytest
412
-
413
- # Run tests with coverage
414
- uv run pytest --cov
415
-
416
- # Lint
417
- uv run ruff check .
418
-
419
- # Format
420
- uv run ruff format .
421
-
422
- # Type check
423
- uv run mypy src/
424
-
425
- # Install pre-commit hooks
426
- uv run pre-commit install
427
-
428
- # Run all pre-commit hooks
429
- uv run pre-commit run --all-files
430
- ```
431
-
432
- ## notes
433
-
434
- - We use `hatchling` as the build backend (current uv default, stable)
435
- - `uv_build` is newer but `hatchling` is battle-tested
436
- - The `datasets` dependency is pinned to Tobias's fork via git URL
437
- - Gradio 5.x for latest features (SSR, improved components)
438
- - Python 3.11+ for modern typing features (`X | None` syntax)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/archive/02-phase-1-data-access.md DELETED
@@ -1,415 +0,0 @@
1
- # phase 1: data access layer
2
-
3
- ## purpose
4
-
5
- Implement a data loading layer that provides typed access to ISLES24 neuroimaging cases. This phase is split into sub-phases due to a critical discovery: the upstream dataset is not properly formatted for HuggingFace consumption.
6
-
7
- ## critical discovery (2025-12-04)
8
-
9
- **`YongchengYAO/ISLES24-MR-Lite` is NOT a proper HuggingFace Dataset.**
10
-
11
- | What we expected | What actually exists |
12
- |------------------|---------------------|
13
- | `load_dataset()` returns Dataset with columns | `load_dataset()` FAILS with "no data" |
14
- | Columns: `dwi`, `adc`, `mask`, `participant_id` | No columns - just raw ZIP files |
15
- | Parquet/Arrow format | Three ZIP archives dumped on HF |
16
-
17
- **Evidence**: `data/discovery/isles24_schema_report.txt`
18
-
19
- This means the demo must be built in phases:
20
- 1. **Phase 1A**: Local file loader (works NOW with extracted data)
21
- 2. **Phase 1B**: Test Tobias's `Nifti()` feature on local files (proves loading works)
22
- 3. **Phase 1C**: Upload properly to HuggingFace (future - proves production pipeline)
23
- 4. **Phase 1D**: Consume via Tobias's fork (future - proves full round-trip)
24
-
25
- ---
26
-
27
- ## phase 1a: local file loader (CURRENT PRIORITY)
28
-
29
- ### data location
30
-
31
- ```
32
- data/isles24/ # Git-ignored
33
- ├── Images-DWI/ # 149 files
34
- │ └── sub-stroke{XXXX}_ses-02_dwi.nii.gz
35
- ├── Images-ADC/ # 149 files
36
- │ └── sub-stroke{XXXX}_ses-02_adc.nii.gz
37
- └── Masks/ # 149 files
38
- └── sub-stroke{XXXX}_ses-02_lesion-msk.nii.gz
39
- ```
40
-
41
- ### file naming convention (BIDS-like)
42
-
43
- | Component | Pattern | Example |
44
- |-----------|---------|---------|
45
- | Subject ID | `sub-stroke{XXXX}` | `sub-stroke0005` |
46
- | Session | `ses-02` | Always "02" in this dataset |
47
- | Modality | `dwi`, `adc`, `lesion-msk` | - |
48
- | Extension | `.nii.gz` | Compressed NIfTI |
49
-
50
- **Subject ID regex**: `sub-stroke(\d{4})_ses-02_.*\.nii\.gz`
51
-
52
- **Note**: Subject IDs have gaps (e.g., 0018 missing). Range is 0001-0189, total 149 cases.
53
-
54
- ### deliverables
55
-
56
- - [ ] `src/stroke_deepisles_demo/data/loader.py` - Rewrite with local mode
57
- - [ ] `src/stroke_deepisles_demo/data/adapter.py` - Rewrite for file-based access
58
- - [ ] `src/stroke_deepisles_demo/data/staging.py` - Already correct, no changes
59
- - [ ] Unit tests with synthetic fixtures
60
- - [ ] Integration test with actual extracted data
61
-
62
- ### interfaces
63
-
64
- #### `data/loader.py`
65
-
66
- ```python
67
- """Load ISLES24 data from local directory or HuggingFace Hub."""
68
-
69
- from __future__ import annotations
70
-
71
- from dataclasses import dataclass
72
- from pathlib import Path
73
- from typing import TYPE_CHECKING
74
-
75
- if TYPE_CHECKING:
76
- from stroke_deepisles_demo.data.adapter import LocalDataset
77
-
78
-
79
- @dataclass
80
- class DatasetInfo:
81
- """Metadata about the dataset."""
82
-
83
- source: str # "local" or HF dataset ID
84
- num_cases: int
85
- modalities: list[str]
86
- has_ground_truth: bool
87
-
88
-
89
- def load_isles_dataset(
90
- source: str | Path = "data/isles24",
91
- *,
92
- local_mode: bool = True, # Default to local for now
93
- ) -> LocalDataset:
94
- """
95
- Load ISLES24 dataset.
96
-
97
- Args:
98
- source: Local directory path or HuggingFace dataset ID
99
- local_mode: If True, treat source as local directory
100
-
101
- Returns:
102
- Dataset-like object providing case access
103
-
104
- Raises:
105
- DataLoadError: If data cannot be loaded
106
- """
107
- if local_mode or isinstance(source, Path):
108
- return _load_from_local_directory(Path(source))
109
- # Future: return _load_from_huggingface(source)
110
- raise NotImplementedError("HuggingFace mode not yet implemented")
111
-
112
-
113
- def _load_from_local_directory(data_dir: Path) -> LocalDataset:
114
- """
115
- Load cases from extracted local files.
116
-
117
- Expects structure:
118
- data_dir/
119
- ├── Images-DWI/sub-stroke{XXXX}_ses-02_dwi.nii.gz
120
- ├── Images-ADC/sub-stroke{XXXX}_ses-02_adc.nii.gz
121
- └── Masks/sub-stroke{XXXX}_ses-02_lesion-msk.nii.gz
122
- """
123
- ...
124
- ```
125
-
126
- #### `data/adapter.py`
127
-
128
- ```python
129
- """Provide typed access to ISLES24 cases."""
130
-
131
- from __future__ import annotations
132
-
133
- import re
134
- from dataclasses import dataclass
135
- from pathlib import Path
136
- from typing import Iterator
137
-
138
- from stroke_deepisles_demo.core.types import CaseFiles
139
-
140
-
141
- @dataclass
142
- class LocalDataset:
143
- """File-based dataset for local ISLES24 data."""
144
-
145
- data_dir: Path
146
- cases: dict[str, CaseFiles] # subject_id -> files
147
-
148
- def __len__(self) -> int:
149
- return len(self.cases)
150
-
151
- def __iter__(self) -> Iterator[str]:
152
- return iter(self.cases.keys())
153
-
154
- def list_case_ids(self) -> list[str]:
155
- """Return sorted list of subject IDs."""
156
- return sorted(self.cases.keys())
157
-
158
- def get_case(self, case_id: str | int) -> CaseFiles:
159
- """Get files for a case by ID or index."""
160
- if isinstance(case_id, int):
161
- case_id = self.list_case_ids()[case_id]
162
- return self.cases[case_id]
163
-
164
-
165
- # Subject ID extraction
166
- SUBJECT_PATTERN = re.compile(r"sub-(stroke\d{4})_ses-\d+_.*\.nii\.gz")
167
-
168
-
169
- def parse_subject_id(filename: str) -> str | None:
170
- """Extract subject ID from BIDS filename."""
171
- match = SUBJECT_PATTERN.match(filename)
172
- return f"sub-{match.group(1)}" if match else None
173
-
174
-
175
- def build_local_dataset(data_dir: Path) -> LocalDataset:
176
- """
177
- Scan directory and build case mapping.
178
-
179
- Matches DWI + ADC + Mask files by subject ID.
180
- """
181
- dwi_dir = data_dir / "Images-DWI"
182
- adc_dir = data_dir / "Images-ADC"
183
- mask_dir = data_dir / "Masks"
184
-
185
- cases: dict[str, CaseFiles] = {}
186
-
187
- # Scan DWI files to get subject IDs
188
- for dwi_file in dwi_dir.glob("*.nii.gz"):
189
- subject_id = parse_subject_id(dwi_file.name)
190
- if not subject_id:
191
- continue
192
-
193
- # Find matching ADC and Mask
194
- adc_file = adc_dir / dwi_file.name.replace("_dwi.", "_adc.")
195
- mask_file = mask_dir / dwi_file.name.replace("_dwi.", "_lesion-msk.")
196
-
197
- if not adc_file.exists():
198
- continue # Skip incomplete cases
199
-
200
- cases[subject_id] = CaseFiles(
201
- dwi=dwi_file,
202
- adc=adc_file,
203
- ground_truth=mask_file if mask_file.exists() else None,
204
- )
205
-
206
- return LocalDataset(data_dir=data_dir, cases=cases)
207
- ```
208
-
209
- ### synthetic fixture structure
210
-
211
- Unit tests MUST use fixtures that replicate the **exact** directory structure. Add to `tests/conftest.py`:
212
-
213
- ```python
214
- @pytest.fixture
215
- def synthetic_isles_dir(temp_dir: Path) -> Path:
216
- """
217
- Create synthetic ISLES24-like directory structure.
218
-
219
- Structure:
220
- temp_dir/
221
- ├── Images-DWI/
222
- │ ├── sub-stroke0001_ses-02_dwi.nii.gz
223
- │ └── sub-stroke0002_ses-02_dwi.nii.gz
224
- ├── Images-ADC/
225
- │ ├── sub-stroke0001_ses-02_adc.nii.gz
226
- │ └── sub-stroke0002_ses-02_adc.nii.gz
227
- └── Masks/
228
- ├── sub-stroke0001_ses-02_lesion-msk.nii.gz
229
- └── sub-stroke0002_ses-02_lesion-msk.nii.gz
230
- """
231
- dwi_dir = temp_dir / "Images-DWI"
232
- adc_dir = temp_dir / "Images-ADC"
233
- mask_dir = temp_dir / "Masks"
234
-
235
- dwi_dir.mkdir()
236
- adc_dir.mkdir()
237
- mask_dir.mkdir()
238
-
239
- for subject_num in [1, 2]:
240
- subject_id = f"sub-stroke{subject_num:04d}"
241
-
242
- # Create DWI
243
- dwi_data = np.random.rand(10, 10, 5).astype(np.float32)
244
- dwi_img = nib.Nifti1Image(dwi_data, affine=np.eye(4))
245
- nib.save(dwi_img, dwi_dir / f"{subject_id}_ses-02_dwi.nii.gz")
246
-
247
- # Create ADC
248
- adc_data = np.random.rand(10, 10, 5).astype(np.float32) * 2000
249
- adc_img = nib.Nifti1Image(adc_data, affine=np.eye(4))
250
- nib.save(adc_img, adc_dir / f"{subject_id}_ses-02_adc.nii.gz")
251
-
252
- # Create Mask
253
- mask_data = (np.random.rand(10, 10, 5) > 0.9).astype(np.uint8)
254
- mask_img = nib.Nifti1Image(mask_data, affine=np.eye(4))
255
- nib.save(mask_img, mask_dir / f"{subject_id}_ses-02_lesion-msk.nii.gz")
256
-
257
- return temp_dir
258
- ```
259
-
260
- ### tdd plan
261
-
262
- ```python
263
- # tests/data/test_loader.py
264
-
265
- def test_load_from_local_returns_local_dataset(synthetic_isles_dir):
266
- """Local mode returns LocalDataset."""
267
- ...
268
-
269
- def test_load_from_local_finds_all_cases(synthetic_isles_dir):
270
- """Finds all cases in synthetic structure."""
271
- ...
272
-
273
- # tests/data/test_adapter.py
274
-
275
- def test_parse_subject_id_extracts_correctly():
276
- """Extracts subject ID from BIDS filename."""
277
- assert parse_subject_id("sub-stroke0005_ses-02_dwi.nii.gz") == "sub-stroke0005"
278
-
279
- def test_build_local_dataset_matches_files(synthetic_isles_dir):
280
- """Matches DWI, ADC, Mask by subject ID."""
281
- ...
282
-
283
- def test_get_case_returns_case_files(synthetic_isles_dir):
284
- """get_case returns CaseFiles with correct paths."""
285
- ...
286
- ```
287
-
288
- ### done criteria (phase 1a)
289
-
290
- - [ ] `uv run pytest tests/data/ -v` passes
291
- - [ ] Can load all 149 cases from `data/isles24/`
292
- - [ ] `list_case_ids()` returns 149 subject IDs
293
- - [ ] `get_case("sub-stroke0005")` returns valid CaseFiles
294
- - [ ] Type checking passes: `uv run mypy src/stroke_deepisles_demo/data/`
295
-
296
- ---
297
-
298
- ## phase 1b: test tobias's nifti feature (NEXT)
299
-
300
- ### purpose
301
-
302
- Verify that Tobias's `Nifti()` feature type from the datasets fork can correctly load/parse NIfTI files. This proves the **loading** part of the consumption pipeline works, even though the **download** part is broken.
303
-
304
- ### approach
305
-
306
- ```python
307
- # Test script to verify Nifti() feature works on local files
308
- from datasets import Features, Value
309
- from datasets.features import Nifti # From Tobias's fork
310
-
311
- # Create a simple dataset from local files
312
- features = Features({
313
- "subject_id": Value("string"),
314
- "dwi": Nifti(),
315
- "adc": Nifti(),
316
- "mask": Nifti(),
317
- })
318
-
319
- # Load a single case and verify Nifti() decodes correctly
320
- ```
321
-
322
- ### done criteria (phase 1b)
323
-
324
- - [ ] Tobias's `Nifti()` feature loads local `.nii.gz` files
325
- - [ ] Decoded NIfTI has correct shape/dtype
326
- - [ ] Can access voxel data via nibabel-like interface
327
-
328
- ---
329
-
330
- ## phase 1c: proper huggingface upload (FUTURE)
331
-
332
- ### purpose
333
-
334
- Re-upload ISLES24 data to HuggingFace **properly** using the arc-aphasia-bids approach. This proves the **production** pipeline works.
335
-
336
- ### approach
337
-
338
- 1. Use BIDS loader from Tobias's fork
339
- 2. Create proper parquet schema with columns:
340
- - `subject`: string
341
- - `session`: string
342
- - `dwi`: Nifti()
343
- - `adc`: Nifti()
344
- - `mask`: Nifti()
345
- 3. Upload to new HuggingFace repo (e.g., `The-Obstacle-Is-The-Way/ISLES24-BIDS`)
346
-
347
- ### done criteria (phase 1c)
348
-
349
- - [ ] Dataset uploaded to HuggingFace with proper schema
350
- - [ ] HuggingFace dataset viewer shows data correctly
351
- - [ ] `load_dataset("new-repo-id")` returns Dataset with expected columns
352
-
353
- ---
354
-
355
- ## phase 1d: consumption verification (FUTURE)
356
-
357
- ### purpose
358
-
359
- Verify the full round-trip: Download from HuggingFace using Tobias's fork.
360
-
361
- ### approach
362
-
363
- ```python
364
- from datasets import load_dataset
365
-
366
- # This should work after Phase 1C
367
- ds = load_dataset("The-Obstacle-Is-The-Way/ISLES24-BIDS")
368
- case = ds["train"][0]
369
- print(case["dwi"].shape) # Should work!
370
- ```
371
-
372
- ### new adapter function
373
-
374
- When Phase 1D is implemented, `adapter.py` will need a new function alongside `build_local_dataset`:
375
-
376
- ```python
377
- def adapt_hf_case(hf_row: dict) -> CaseFiles:
378
- """
379
- Adapt a HuggingFace Dataset row to CaseFiles.
380
-
381
- Args:
382
- hf_row: Row from load_dataset() with columns:
383
- - dwi: Nifti feature (nibabel-like object)
384
- - adc: Nifti feature
385
- - mask: Nifti feature
386
- - subject: str
387
-
388
- Returns:
389
- CaseFiles with materialized paths or nibabel objects
390
- """
391
- # Implementation depends on how Nifti() feature exposes data
392
- # May need to write to temp files or pass nibabel objects directly
393
- ...
394
- ```
395
-
396
- This maintains the same `CaseFiles` contract for downstream phases regardless of data source.
397
-
398
- ### done criteria (phase 1d)
399
-
400
- - [ ] `load_dataset()` works on properly uploaded dataset
401
- - [ ] `adapt_hf_case()` function converts HF rows to CaseFiles
402
- - [ ] Full demo runs with HuggingFace consumption (not just local files)
403
- - [ ] Documents the pitfall for future projects
404
-
405
- ---
406
-
407
- ## dependencies
408
-
409
- No new dependencies needed beyond Phase 0.
410
-
411
- ## notes
412
-
413
- - The original `adapter.py` assumed HF Dataset with columns - COMPLETELY WRONG
414
- - The original `loader.py` called `load_dataset()` directly - FAILS on this dataset
415
- - `staging.py` is still correct - it just needs `CaseFiles` with paths
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/archive/03-phase-2-deepisles-docker.md DELETED
@@ -1,884 +0,0 @@
1
- # phase 2: deepisles docker integration
2
-
3
- ## purpose
4
-
5
- Create a Python wrapper that calls the DeepISLES Docker image as a black box. At the end of this phase, we can run stroke lesion segmentation on a folder of NIfTI files and get back the predicted mask.
6
-
7
- ## deliverables
8
-
9
- - [ ] `src/stroke_deepisles_demo/inference/docker.py` - Docker execution wrapper
10
- - [ ] `src/stroke_deepisles_demo/inference/deepisles.py` - DeepISLES-specific CLI interface
11
- - [ ] Unit tests with subprocess mocking
12
- - [ ] Integration test (marked, requires Docker)
13
-
14
- ## vertical slice outcome
15
-
16
- After this phase, you can run:
17
-
18
- ```python
19
- from stroke_deepisles_demo.inference import run_deepisles_on_folder
20
-
21
- # input_dir contains: dwi.nii.gz, adc.nii.gz
22
- result = run_deepisles_on_folder(
23
- input_dir=Path("/path/to/staged/case"),
24
- fast=True,
25
- )
26
- print(f"Prediction mask: {result.prediction_path}")
27
- print(f"Elapsed: {result.elapsed_seconds:.1f}s")
28
- ```
29
-
30
- ## module structure
31
-
32
- ```
33
- src/stroke_deepisles_demo/inference/
34
- ├── __init__.py # Public API exports
35
- ├── docker.py # Generic Docker execution utilities
36
- └── deepisles.py # DeepISLES-specific wrapper
37
- ```
38
-
39
- ## deepisles cli reference
40
-
41
- From the [DeepIsles repository](https://github.com/ezequieldlrosa/DeepIsles), the Docker interface expects:
42
-
43
- ```bash
44
- docker run --rm \
45
- -v /path/to/input:/input \
46
- -v /path/to/output:/output \
47
- --gpus all \
48
- isleschallenge/deepisles \
49
- --dwi_file_name dwi.nii.gz \
50
- --adc_file_name adc.nii.gz \
51
- [--flair_file_name flair.nii.gz] \
52
- --fast True # Single model mode, faster
53
- ```
54
-
55
- **Expected input files:**
56
- - `dwi.nii.gz` (required) - Diffusion-weighted imaging
57
- - `adc.nii.gz` (required) - Apparent diffusion coefficient
58
- - `flair.nii.gz` (optional) - Required for full ensemble, not needed for fast mode
59
-
60
- **Output:**
61
- - `results/` directory containing the lesion mask
62
-
63
- ### Why `--fast True` (SEALS-only mode)
64
-
65
- DeepISLES contains 3 models from the ISLES'22 challenge:
66
-
67
- | Model | Inputs | Notes |
68
- |-------|--------|-------|
69
- | **SEALS** (nnUNet) | DWI + ADC | 🏆 ISLES'22 Winner - runs with `--fast True` |
70
- | NVAUTO (MONAI) | DWI + ADC + FLAIR | Requires FLAIR |
71
- | SWAN (FACTORIZER) | DWI + ADC + FLAIR | Requires FLAIR |
72
-
73
- **We default to `fast=True` because:**
74
- 1. ISLES24-MR-Lite only has DWI + ADC (no FLAIR)
75
- 2. SEALS alone is the challenge-winning algorithm
76
- 3. Running the full ensemble without FLAIR would fail for 2/3 models
77
-
78
- This is not a compromise—SEALS is state-of-the-art for DWI+ADC stroke segmentation.
79
-
80
- ## interfaces and types
81
-
82
- ### `inference/docker.py`
83
-
84
- ```python
85
- """Docker execution utilities."""
86
-
87
- from __future__ import annotations
88
-
89
- import subprocess
90
- from dataclasses import dataclass
91
- from pathlib import Path
92
- from typing import Sequence
93
-
94
- from stroke_deepisles_demo.core.exceptions import DockerNotAvailableError
95
-
96
-
97
- @dataclass(frozen=True)
98
- class DockerRunResult:
99
- """Result of a Docker container run."""
100
-
101
- exit_code: int
102
- stdout: str
103
- stderr: str
104
- elapsed_seconds: float
105
-
106
-
107
- def check_docker_available() -> bool:
108
- """
109
- Check if Docker is installed and the daemon is running.
110
-
111
- Returns:
112
- True if Docker is available, False otherwise
113
- """
114
- ...
115
-
116
-
117
- def ensure_docker_available() -> None:
118
- """
119
- Ensure Docker is available, raising if not.
120
-
121
- Raises:
122
- DockerNotAvailableError: If Docker is not installed or not running
123
- """
124
- ...
125
-
126
-
127
- def pull_image_if_missing(image: str, *, timeout: float = 600) -> bool:
128
- """
129
- Pull a Docker image if not present locally.
130
-
131
- Args:
132
- image: Docker image name (e.g., "isleschallenge/deepisles")
133
- timeout: Maximum seconds to wait for pull
134
-
135
- Returns:
136
- True if image was pulled, False if already present
137
- """
138
- ...
139
-
140
-
141
- def run_container(
142
- image: str,
143
- *,
144
- command: Sequence[str] | None = None,
145
- volumes: dict[Path, str] | None = None, # host_path -> container_path
146
- environment: dict[str, str] | None = None,
147
- gpu: bool = False,
148
- remove: bool = True,
149
- timeout: float | None = None,
150
- ) -> DockerRunResult:
151
- """
152
- Run a Docker container and wait for completion.
153
-
154
- Args:
155
- image: Docker image name
156
- command: Command to run in container
157
- volumes: Volume mounts (host path -> container path)
158
- environment: Environment variables
159
- gpu: If True, pass --gpus all
160
- remove: If True, remove container after exit (--rm)
161
- timeout: Maximum seconds to wait (None = no timeout)
162
-
163
- Returns:
164
- DockerRunResult with exit code, stdout, stderr, elapsed time
165
-
166
- Raises:
167
- DockerNotAvailableError: If Docker is not available
168
- subprocess.TimeoutExpired: If timeout exceeded
169
- """
170
- ...
171
-
172
-
173
- def build_docker_command(
174
- image: str,
175
- *,
176
- command: Sequence[str] | None = None,
177
- volumes: dict[Path, str] | None = None,
178
- environment: dict[str, str] | None = None,
179
- gpu: bool = False,
180
- remove: bool = True,
181
- ) -> list[str]:
182
- """
183
- Build the docker run command without executing.
184
-
185
- Useful for logging/debugging.
186
-
187
- Returns:
188
- List of command arguments for subprocess
189
- """
190
- ...
191
- ```
192
-
193
- ### `inference/deepisles.py`
194
-
195
- ```python
196
- """DeepISLES stroke segmentation wrapper."""
197
-
198
- from __future__ import annotations
199
-
200
- import time
201
- from dataclasses import dataclass
202
- from pathlib import Path
203
-
204
- from stroke_deepisles_demo.core.config import settings
205
- from stroke_deepisles_demo.core.exceptions import DeepISLESError, MissingInputError
206
- from stroke_deepisles_demo.inference.docker import (
207
- DockerRunResult,
208
- ensure_docker_available,
209
- run_container,
210
- )
211
-
212
-
213
- @dataclass(frozen=True)
214
- class DeepISLESResult:
215
- """Result of DeepISLES inference."""
216
-
217
- prediction_path: Path
218
- docker_result: DockerRunResult
219
- elapsed_seconds: float
220
-
221
-
222
- def validate_input_folder(input_dir: Path) -> tuple[Path, Path, Path | None]:
223
- """
224
- Validate that input folder contains required files.
225
-
226
- Args:
227
- input_dir: Directory to validate
228
-
229
- Returns:
230
- Tuple of (dwi_path, adc_path, flair_path_or_none)
231
-
232
- Raises:
233
- MissingInputError: If required files are missing
234
- """
235
- ...
236
-
237
-
238
- def run_deepisles_on_folder(
239
- input_dir: Path,
240
- *,
241
- output_dir: Path | None = None,
242
- fast: bool = True,
243
- gpu: bool = True,
244
- timeout: float | None = 1800, # 30 minutes default
245
- ) -> DeepISLESResult:
246
- """
247
- Run DeepISLES stroke segmentation on a folder of NIfTI files.
248
-
249
- Args:
250
- input_dir: Directory containing dwi.nii.gz, adc.nii.gz, [flair.nii.gz]
251
- output_dir: Where to write results (default: input_dir/results)
252
- fast: If True, use single-model mode (faster, slightly less accurate)
253
- gpu: If True, use GPU acceleration
254
- timeout: Maximum seconds to wait for inference
255
-
256
- Returns:
257
- DeepISLESResult with path to prediction mask
258
-
259
- Raises:
260
- DockerNotAvailableError: If Docker is not available
261
- MissingInputError: If required input files are missing
262
- DeepISLESError: If inference fails (non-zero exit, missing output)
263
-
264
- Example:
265
- >>> result = run_deepisles_on_folder(Path("/data/case001"), fast=True)
266
- >>> print(result.prediction_path)
267
- /data/case001/results/prediction.nii.gz
268
- """
269
- ...
270
-
271
-
272
- def find_prediction_mask(output_dir: Path) -> Path:
273
- """
274
- Find the prediction mask in DeepISLES output directory.
275
-
276
- DeepISLES outputs may have varying names depending on version.
277
- This function finds the most likely prediction file.
278
-
279
- Args:
280
- output_dir: DeepISLES output directory
281
-
282
- Returns:
283
- Path to the prediction mask NIfTI file
284
-
285
- Raises:
286
- DeepISLESError: If no prediction mask found
287
- """
288
- ...
289
-
290
-
291
- # Constants
292
- DEEPISLES_IMAGE = "isleschallenge/deepisles"
293
- EXPECTED_INPUT_FILES = ["dwi.nii.gz", "adc.nii.gz"]
294
- OPTIONAL_INPUT_FILES = ["flair.nii.gz"]
295
- ```
296
-
297
- ### `inference/__init__.py` (public API)
298
-
299
- ```python
300
- """Inference module for stroke-deepisles-demo."""
301
-
302
- from stroke_deepisles_demo.inference.deepisles import (
303
- DEEPISLES_IMAGE,
304
- DeepISLESResult,
305
- run_deepisles_on_folder,
306
- validate_input_folder,
307
- )
308
- from stroke_deepisles_demo.inference.docker import (
309
- DockerRunResult,
310
- build_docker_command,
311
- check_docker_available,
312
- ensure_docker_available,
313
- run_container,
314
- )
315
-
316
- __all__ = [
317
- # DeepISLES
318
- "run_deepisles_on_folder",
319
- "validate_input_folder",
320
- "DeepISLESResult",
321
- "DEEPISLES_IMAGE",
322
- # Docker utilities
323
- "check_docker_available",
324
- "ensure_docker_available",
325
- "run_container",
326
- "build_docker_command",
327
- "DockerRunResult",
328
- ]
329
- ```
330
-
331
- ## tdd plan
332
-
333
- ### test file structure
334
-
335
- ```
336
- tests/
337
- ├── inference/
338
- │ ├── __init__.py
339
- │ ├── test_docker.py # Tests for Docker utilities
340
- │ └── test_deepisles.py # Tests for DeepISLES wrapper
341
- ```
342
-
343
- ### tests to write first (TDD order)
344
-
345
- #### 1. `tests/inference/test_docker.py`
346
-
347
- ```python
348
- """Tests for Docker utilities."""
349
-
350
- from __future__ import annotations
351
-
352
- import subprocess
353
- from pathlib import Path
354
- from unittest.mock import MagicMock, patch
355
-
356
- import pytest
357
-
358
- from stroke_deepisles_demo.core.exceptions import DockerNotAvailableError
359
- from stroke_deepisles_demo.inference.docker import (
360
- build_docker_command,
361
- check_docker_available,
362
- ensure_docker_available,
363
- run_container,
364
- )
365
-
366
-
367
- class TestCheckDockerAvailable:
368
- """Tests for check_docker_available."""
369
-
370
- def test_returns_true_when_docker_responds(self) -> None:
371
- """Returns True when 'docker info' succeeds."""
372
- with patch("subprocess.run") as mock_run:
373
- mock_run.return_value = MagicMock(returncode=0)
374
-
375
- result = check_docker_available()
376
-
377
- assert result is True
378
-
379
- def test_returns_false_when_docker_not_found(self) -> None:
380
- """Returns False when docker command not found."""
381
- with patch("subprocess.run") as mock_run:
382
- mock_run.side_effect = FileNotFoundError()
383
-
384
- result = check_docker_available()
385
-
386
- assert result is False
387
-
388
- def test_returns_false_when_daemon_not_running(self) -> None:
389
- """Returns False when docker daemon not running."""
390
- with patch("subprocess.run") as mock_run:
391
- mock_run.return_value = MagicMock(returncode=1)
392
-
393
- result = check_docker_available()
394
-
395
- assert result is False
396
-
397
-
398
- class TestEnsureDockerAvailable:
399
- """Tests for ensure_docker_available."""
400
-
401
- def test_raises_when_docker_not_available(self) -> None:
402
- """Raises DockerNotAvailableError when Docker not available."""
403
- with patch(
404
- "stroke_deepisles_demo.inference.docker.check_docker_available",
405
- return_value=False,
406
- ):
407
- with pytest.raises(DockerNotAvailableError):
408
- ensure_docker_available()
409
-
410
- def test_no_error_when_docker_available(self) -> None:
411
- """No exception when Docker is available."""
412
- with patch(
413
- "stroke_deepisles_demo.inference.docker.check_docker_available",
414
- return_value=True,
415
- ):
416
- ensure_docker_available() # Should not raise
417
-
418
-
419
- class TestBuildDockerCommand:
420
- """Tests for build_docker_command."""
421
-
422
- def test_basic_command(self) -> None:
423
- """Builds basic docker run command."""
424
- cmd = build_docker_command("myimage:latest")
425
-
426
- assert cmd[0] == "docker"
427
- assert "run" in cmd
428
- assert "myimage:latest" in cmd
429
-
430
- def test_includes_rm_flag(self) -> None:
431
- """Includes --rm when remove=True."""
432
- cmd = build_docker_command("myimage", remove=True)
433
-
434
- assert "--rm" in cmd
435
-
436
- def test_excludes_rm_flag(self) -> None:
437
- """Excludes --rm when remove=False."""
438
- cmd = build_docker_command("myimage", remove=False)
439
-
440
- assert "--rm" not in cmd
441
-
442
- def test_includes_gpu_flag(self) -> None:
443
- """Includes --gpus all when gpu=True."""
444
- cmd = build_docker_command("myimage", gpu=True)
445
-
446
- assert "--gpus" in cmd
447
- gpu_index = cmd.index("--gpus")
448
- assert cmd[gpu_index + 1] == "all"
449
-
450
- def test_volume_mounts(self, temp_dir: Path) -> None:
451
- """Includes volume mounts."""
452
- volumes = {temp_dir: "/data"}
453
- cmd = build_docker_command("myimage", volumes=volumes)
454
-
455
- assert "-v" in cmd
456
- # Find the volume argument
457
- v_index = cmd.index("-v")
458
- assert f"{temp_dir}:/data" in cmd[v_index + 1]
459
-
460
- def test_custom_command(self) -> None:
461
- """Appends custom command arguments."""
462
- cmd = build_docker_command(
463
- "myimage", command=["--input", "/data", "--fast", "True"]
464
- )
465
-
466
- assert "--input" in cmd
467
- assert "--fast" in cmd
468
-
469
-
470
- class TestRunContainer:
471
- """Tests for run_container."""
472
-
473
- def test_calls_subprocess_with_built_command(self) -> None:
474
- """Calls subprocess.run with built command."""
475
- with patch("subprocess.run") as mock_run:
476
- mock_run.return_value = MagicMock(
477
- returncode=0, stdout="output", stderr=""
478
- )
479
- with patch(
480
- "stroke_deepisles_demo.inference.docker.ensure_docker_available"
481
- ):
482
- run_container("myimage")
483
-
484
- mock_run.assert_called_once()
485
-
486
- def test_returns_result_with_exit_code(self) -> None:
487
- """Returns DockerRunResult with correct exit code."""
488
- with patch("subprocess.run") as mock_run:
489
- mock_run.return_value = MagicMock(
490
- returncode=42, stdout="out", stderr="err"
491
- )
492
- with patch(
493
- "stroke_deepisles_demo.inference.docker.ensure_docker_available"
494
- ):
495
- result = run_container("myimage")
496
-
497
- assert result.exit_code == 42
498
-
499
- def test_captures_stdout_stderr(self) -> None:
500
- """Captures stdout and stderr from container."""
501
- with patch("subprocess.run") as mock_run:
502
- mock_run.return_value = MagicMock(
503
- returncode=0, stdout="hello", stderr="warning"
504
- )
505
- with patch(
506
- "stroke_deepisles_demo.inference.docker.ensure_docker_available"
507
- ):
508
- result = run_container("myimage")
509
-
510
- assert result.stdout == "hello"
511
- assert result.stderr == "warning"
512
-
513
- def test_respects_timeout(self) -> None:
514
- """Passes timeout to subprocess."""
515
- with patch("subprocess.run") as mock_run:
516
- mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
517
- with patch(
518
- "stroke_deepisles_demo.inference.docker.ensure_docker_available"
519
- ):
520
- run_container("myimage", timeout=60.0)
521
-
522
- call_kwargs = mock_run.call_args.kwargs
523
- assert call_kwargs.get("timeout") == 60.0
524
-
525
-
526
- @pytest.mark.integration
527
- class TestDockerIntegration:
528
- """Integration tests requiring real Docker."""
529
-
530
- def test_docker_actually_available(self) -> None:
531
- """Docker is actually available on this system."""
532
- # This test only runs with -m integration
533
- assert check_docker_available() is True
534
-
535
- def test_can_run_hello_world(self) -> None:
536
- """Can run docker hello-world container."""
537
- result = run_container("hello-world", timeout=60.0)
538
-
539
- assert result.exit_code == 0
540
- assert "Hello from Docker!" in result.stdout
541
- ```
542
-
543
- #### 2. `tests/inference/test_deepisles.py`
544
-
545
- ```python
546
- """Tests for DeepISLES wrapper."""
547
-
548
- from __future__ import annotations
549
-
550
- from pathlib import Path
551
- from unittest.mock import MagicMock, patch
552
-
553
- import pytest
554
-
555
- from stroke_deepisles_demo.core.exceptions import DeepISLESError, MissingInputError
556
- from stroke_deepisles_demo.inference.deepisles import (
557
- DeepISLESResult,
558
- find_prediction_mask,
559
- run_deepisles_on_folder,
560
- validate_input_folder,
561
- )
562
-
563
-
564
- class TestValidateInputFolder:
565
- """Tests for validate_input_folder."""
566
-
567
- def test_succeeds_with_required_files(self, temp_dir: Path) -> None:
568
- """Returns paths when required files exist."""
569
- (temp_dir / "dwi.nii.gz").touch()
570
- (temp_dir / "adc.nii.gz").touch()
571
-
572
- dwi, adc, flair = validate_input_folder(temp_dir)
573
-
574
- assert dwi == temp_dir / "dwi.nii.gz"
575
- assert adc == temp_dir / "adc.nii.gz"
576
- assert flair is None
577
-
578
- def test_includes_flair_when_present(self, temp_dir: Path) -> None:
579
- """Returns FLAIR path when present."""
580
- (temp_dir / "dwi.nii.gz").touch()
581
- (temp_dir / "adc.nii.gz").touch()
582
- (temp_dir / "flair.nii.gz").touch()
583
-
584
- dwi, adc, flair = validate_input_folder(temp_dir)
585
-
586
- assert flair == temp_dir / "flair.nii.gz"
587
-
588
- def test_raises_when_dwi_missing(self, temp_dir: Path) -> None:
589
- """Raises MissingInputError when DWI is missing."""
590
- (temp_dir / "adc.nii.gz").touch()
591
-
592
- with pytest.raises(MissingInputError, match="dwi"):
593
- validate_input_folder(temp_dir)
594
-
595
- def test_raises_when_adc_missing(self, temp_dir: Path) -> None:
596
- """Raises MissingInputError when ADC is missing."""
597
- (temp_dir / "dwi.nii.gz").touch()
598
-
599
- with pytest.raises(MissingInputError, match="adc"):
600
- validate_input_folder(temp_dir)
601
-
602
-
603
- class TestFindPredictionMask:
604
- """Tests for find_prediction_mask."""
605
-
606
- def test_finds_prediction_file(self, temp_dir: Path) -> None:
607
- """Finds prediction.nii.gz in output directory."""
608
- results_dir = temp_dir / "results"
609
- results_dir.mkdir()
610
- pred_file = results_dir / "prediction.nii.gz"
611
- pred_file.touch()
612
-
613
- result = find_prediction_mask(temp_dir)
614
-
615
- assert result == pred_file
616
-
617
- def test_raises_when_no_prediction(self, temp_dir: Path) -> None:
618
- """Raises DeepISLESError when no prediction found."""
619
- results_dir = temp_dir / "results"
620
- results_dir.mkdir()
621
-
622
- with pytest.raises(DeepISLESError, match="prediction"):
623
- find_prediction_mask(temp_dir)
624
-
625
-
626
- class TestRunDeepIslesOnFolder:
627
- """Tests for run_deepisles_on_folder."""
628
-
629
- @pytest.fixture
630
- def valid_input_dir(self, temp_dir: Path) -> Path:
631
- """Create a valid input directory with required files."""
632
- (temp_dir / "dwi.nii.gz").touch()
633
- (temp_dir / "adc.nii.gz").touch()
634
- return temp_dir
635
-
636
- def test_validates_input_files(self, temp_dir: Path) -> None:
637
- """Validates input files before running Docker."""
638
- # Missing required files
639
- with pytest.raises(MissingInputError):
640
- run_deepisles_on_folder(temp_dir)
641
-
642
- def test_calls_docker_with_correct_image(self, valid_input_dir: Path) -> None:
643
- """Calls Docker with DeepISLES image."""
644
- with patch(
645
- "stroke_deepisles_demo.inference.deepisles.run_container"
646
- ) as mock_run:
647
- mock_run.return_value = MagicMock(exit_code=0, stdout="", stderr="")
648
- # Also mock finding the prediction
649
- with patch(
650
- "stroke_deepisles_demo.inference.deepisles.find_prediction_mask"
651
- ) as mock_find:
652
- mock_find.return_value = valid_input_dir / "results" / "pred.nii.gz"
653
-
654
- run_deepisles_on_folder(valid_input_dir)
655
-
656
- # Check image name
657
- call_args = mock_run.call_args
658
- assert "isleschallenge/deepisles" in str(call_args)
659
-
660
- def test_passes_fast_flag(self, valid_input_dir: Path) -> None:
661
- """Passes --fast True when fast=True."""
662
- with patch(
663
- "stroke_deepisles_demo.inference.deepisles.run_container"
664
- ) as mock_run:
665
- mock_run.return_value = MagicMock(exit_code=0, stdout="", stderr="")
666
- with patch(
667
- "stroke_deepisles_demo.inference.deepisles.find_prediction_mask"
668
- ) as mock_find:
669
- mock_find.return_value = valid_input_dir / "results" / "pred.nii.gz"
670
-
671
- run_deepisles_on_folder(valid_input_dir, fast=True)
672
-
673
- # Check --fast in command
674
- call_kwargs = mock_run.call_args.kwargs
675
- command = call_kwargs.get("command", [])
676
- assert "--fast" in command
677
-
678
- def test_raises_on_docker_failure(self, valid_input_dir: Path) -> None:
679
- """Raises DeepISLESError when Docker returns non-zero."""
680
- with patch(
681
- "stroke_deepisles_demo.inference.deepisles.run_container"
682
- ) as mock_run:
683
- mock_run.return_value = MagicMock(
684
- exit_code=1, stdout="", stderr="Segmentation fault"
685
- )
686
-
687
- with pytest.raises(DeepISLESError, match="failed"):
688
- run_deepisles_on_folder(valid_input_dir)
689
-
690
- def test_returns_result_with_prediction_path(self, valid_input_dir: Path) -> None:
691
- """Returns DeepISLESResult with prediction path."""
692
- with patch(
693
- "stroke_deepisles_demo.inference.deepisles.run_container"
694
- ) as mock_run:
695
- mock_run.return_value = MagicMock(exit_code=0, stdout="", stderr="")
696
- with patch(
697
- "stroke_deepisles_demo.inference.deepisles.find_prediction_mask"
698
- ) as mock_find:
699
- expected_path = valid_input_dir / "results" / "prediction.nii.gz"
700
- mock_find.return_value = expected_path
701
-
702
- result = run_deepisles_on_folder(valid_input_dir)
703
-
704
- assert isinstance(result, DeepISLESResult)
705
- assert result.prediction_path == expected_path
706
-
707
-
708
- @pytest.mark.integration
709
- @pytest.mark.slow
710
- class TestDeepIslesIntegration:
711
- """Integration tests requiring real Docker and DeepISLES image."""
712
-
713
- def test_real_inference(self, synthetic_case_files) -> None:
714
- """Run actual DeepISLES inference on synthetic data."""
715
- # This test requires:
716
- # 1. Docker available
717
- # 2. isleschallenge/deepisles image pulled
718
- # 3. GPU (optional but recommended)
719
- #
720
- # Run with: pytest -m "integration and slow"
721
-
722
- from stroke_deepisles_demo.data.staging import stage_case_for_deepisles
723
-
724
- # Stage the synthetic files
725
- staged = stage_case_for_deepisles(
726
- synthetic_case_files,
727
- Path("/tmp/deepisles_test"),
728
- )
729
-
730
- # Run inference
731
- result = run_deepisles_on_folder(
732
- staged.input_dir,
733
- fast=True,
734
- gpu=False, # Might not have GPU in CI
735
- timeout=600,
736
- )
737
-
738
- # Verify output exists
739
- assert result.prediction_path.exists()
740
- ```
741
-
742
- ### what to mock
743
-
744
- - `subprocess.run` - Mock for all unit tests
745
- - `check_docker_available` - Mock to control Docker availability
746
- - `run_container` - Mock in DeepISLES tests to avoid Docker
747
- - File system for prediction finding - Use temp directories
748
-
749
- ### what to test for real
750
-
751
- - Command building (no subprocess needed)
752
- - Input validation (real file system with temp dirs)
753
- - Integration test: actual Docker hello-world
754
- - Integration test: actual DeepISLES inference (marked `slow`)
755
-
756
- ## "done" criteria
757
-
758
- Phase 2 is complete when:
759
-
760
- 1. All unit tests pass: `uv run pytest tests/inference/ -v`
761
- 2. Can build Docker commands correctly
762
- 3. Can validate input folders
763
- 4. Unit tests don't require Docker (all mocked)
764
- 5. Integration test passes with Docker: `uv run pytest -m integration tests/inference/`
765
- 6. Type checking passes: `uv run mypy src/stroke_deepisles_demo/inference/`
766
- 7. Code coverage for inference module > 80%
767
-
768
- ## implementation notes
769
-
770
- - Check DeepISLES repo for exact output file names/structure
771
- - Consider `--gpus all` vs `--gpus '"device=0"'` for GPU selection
772
- - Timeout should be generous (30+ minutes) for full ensemble mode
773
- - Log Docker stdout/stderr for debugging
774
- - Consider streaming Docker output for long-running inference
775
-
776
- ### critical: docker file permissions (linux)
777
-
778
- **Reviewer feedback (valid)**: Docker containers run as root by default on Linux. Output files written to mounted volumes will be owned by root:root. The Python process running as a normal user will fail to read or delete these files.
779
-
780
- **Solution**: Pass `--user` flag to match host user:
781
-
782
- ```python
783
- def build_docker_command(
784
- image: str,
785
- *,
786
- volumes: dict[Path, str] | None = None,
787
- gpu: bool = False,
788
- remove: bool = True,
789
- match_user: bool = True, # NEW: default True on Linux
790
- ) -> list[str]:
791
- """Build docker run command."""
792
- cmd = ["docker", "run"]
793
-
794
- if remove:
795
- cmd.append("--rm")
796
-
797
- if gpu:
798
- cmd.extend(["--gpus", "all"])
799
-
800
- # Match host user to avoid permission issues
801
- if match_user and sys.platform != "darwin": # Not needed on macOS
802
- import os
803
- uid = os.getuid()
804
- gid = os.getgid()
805
- cmd.extend(["--user", f"{uid}:{gid}"])
806
-
807
- if volumes:
808
- for host_path, container_path in volumes.items():
809
- cmd.extend(["-v", f"{host_path}:{container_path}"])
810
-
811
- cmd.append(image)
812
- return cmd
813
- ```
814
-
815
- ### critical: gpu availability check
816
-
817
- **Reviewer feedback (valid)**: We check for Docker daemon but not NVIDIA Container Runtime. A user might have Docker but lack GPU passthrough setup.
818
-
819
- **Solution**: Add GPU-specific availability check:
820
-
821
- ```python
822
- def check_nvidia_docker_available() -> bool:
823
- """
824
- Check if NVIDIA Container Runtime is available for GPU support.
825
-
826
- Returns:
827
- True if nvidia-docker/nvidia-container-toolkit is configured
828
- """
829
- try:
830
- result = subprocess.run(
831
- ["docker", "run", "--rm", "--gpus", "all", "nvidia/cuda:11.0-base", "nvidia-smi"],
832
- capture_output=True,
833
- timeout=30,
834
- )
835
- return result.returncode == 0
836
- except (subprocess.TimeoutExpired, FileNotFoundError):
837
- return False
838
-
839
-
840
- def ensure_gpu_available_if_requested(gpu: bool) -> None:
841
- """
842
- Verify GPU is available if requested, or warn user.
843
-
844
- Raises:
845
- DockerGPUNotAvailableError: If GPU requested but not available
846
- """
847
- if gpu and not check_nvidia_docker_available():
848
- raise DockerGPUNotAvailableError(
849
- "GPU requested but NVIDIA Container Runtime not available. "
850
- "Either install nvidia-container-toolkit or set gpu=False."
851
- )
852
- ```
853
-
854
- Add to exceptions:
855
-
856
- ```python
857
- class DockerGPUNotAvailableError(StrokeDemoError):
858
- """GPU requested but NVIDIA Container Runtime not available."""
859
- ```
860
-
861
- ### nifti orientation (medium risk)
862
-
863
- **Reviewer feedback (noted)**: DeepISLES may expect specific anatomical orientation (e.g., RAS). BIDS data might be in different orientations.
864
-
865
- **Mitigation**: DeepISLES is trained on ISLES challenge data which follows standard conventions. If issues arise, add orientation checking in staging:
866
-
867
- ```python
868
- def check_nifti_orientation(nifti_path: Path) -> str:
869
- """Check NIfTI orientation code (e.g., 'RAS', 'LPS')."""
870
- import nibabel as nib
871
- img = nib.load(nifti_path)
872
- return nib.aff2axcodes(img.affine)
873
-
874
- def conform_to_ras(nifti_path: Path, output_path: Path) -> Path:
875
- """Reorient NIfTI to RAS if needed."""
876
- import nibabel as nib
877
- img = nib.load(nifti_path)
878
- # nibabel can reorient - implement if needed
879
- ...
880
- ```
881
-
882
- ## dependencies to add
883
-
884
- None - all covered in Phase 0.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/archive/04-phase-3-pipeline.md DELETED
@@ -1,705 +0,0 @@
1
- # phase 3: end-to-end pipeline (no ui)
2
-
3
- ## purpose
4
-
5
- Tie together Phase 1 (data loading) and Phase 2 (DeepISLES inference) into a cohesive pipeline. At the end of this phase, we can run stroke segmentation on any case from ISLES24-MR-Lite with a single function call.
6
-
7
- ## deliverables
8
-
9
- - [ ] `src/stroke_deepisles_demo/pipeline.py` - Main orchestration
10
- - [ ] `src/stroke_deepisles_demo/metrics.py` - Optional Dice computation
11
- - [ ] CLI entry point for testing
12
- - [ ] Unit tests with full mocking
13
- - [ ] Integration test for complete flow
14
-
15
- ## vertical slice outcome
16
-
17
- After this phase, you can run:
18
-
19
- ```python
20
- from stroke_deepisles_demo.pipeline import run_pipeline_on_case
21
-
22
- # Run segmentation on a specific case
23
- result = run_pipeline_on_case("sub-001")
24
-
25
- print(f"Input DWI: {result.input_files.dwi}")
26
- print(f"Input ADC: {result.input_files.adc}")
27
- print(f"Prediction: {result.prediction_mask}")
28
- print(f"Ground truth: {result.ground_truth}")
29
- print(f"Dice score: {result.dice_score:.3f}") # if computed
30
- ```
31
-
32
- Or via CLI:
33
-
34
- ```bash
35
- uv run stroke-demo run --case sub-001 --fast
36
- uv run stroke-demo run --index 0 --output ./results
37
- uv run stroke-demo list # List all available cases
38
- ```
39
-
40
- ## module structure
41
-
42
- ```
43
- src/stroke_deepisles_demo/
44
- ├── pipeline.py # Main orchestration
45
- ├── metrics.py # Dice score computation
46
- └── cli.py # CLI entry point (optional)
47
- ```
48
-
49
- ## interfaces and types
50
-
51
- ### `pipeline.py`
52
-
53
- ```python
54
- """End-to-end pipeline orchestration."""
55
-
56
- from __future__ import annotations
57
-
58
- import tempfile
59
- from dataclasses import dataclass
60
- from pathlib import Path
61
- from typing import Mapping
62
-
63
- from stroke_deepisles_demo.core.config import settings
64
- from stroke_deepisles_demo.core.types import CaseFiles, InferenceResult
65
- from stroke_deepisles_demo.data import CaseAdapter, load_isles_dataset, stage_case_for_deepisles
66
- from stroke_deepisles_demo.inference import run_deepisles_on_folder
67
-
68
-
69
- @dataclass(frozen=True)
70
- class PipelineResult:
71
- """Complete result of running the pipeline on a case."""
72
-
73
- case_id: str
74
- input_files: CaseFiles
75
- staged_dir: Path
76
- prediction_mask: Path
77
- ground_truth: Path | None
78
- dice_score: float | None # None if ground truth unavailable or not computed
79
- elapsed_seconds: float
80
-
81
-
82
- def run_pipeline_on_case(
83
- case_id: str | int,
84
- *,
85
- dataset_id: str | None = None,
86
- output_dir: Path | None = None,
87
- fast: bool = True,
88
- gpu: bool = True,
89
- compute_dice: bool = True,
90
- cleanup_staging: bool = False,
91
- ) -> PipelineResult:
92
- """
93
- Run the complete segmentation pipeline on a single case.
94
-
95
- This function:
96
- 1. Loads the case from HuggingFace Hub (or cache)
97
- 2. Stages NIfTI files with DeepISLES-expected naming
98
- 3. Runs DeepISLES Docker container
99
- 4. Optionally computes Dice score against ground truth
100
- 5. Returns all paths and metrics
101
-
102
- Args:
103
- case_id: Case identifier (string) or index (int)
104
- dataset_id: HF dataset ID (default from settings)
105
- output_dir: Directory for results (default: temp dir)
106
- fast: Use SEALS-only mode (ISLES'22 winner, DWI+ADC only, no FLAIR needed)
107
- gpu: Use GPU acceleration
108
- compute_dice: Compute Dice score if ground truth available
109
- cleanup_staging: Remove staging directory after inference
110
-
111
- Returns:
112
- PipelineResult with all paths and optional metrics
113
-
114
- Raises:
115
- DataLoadError: If case cannot be loaded
116
- MissingInputError: If required files missing
117
- DeepISLESError: If inference fails
118
-
119
- Example:
120
- >>> result = run_pipeline_on_case("sub-001", fast=True)
121
- >>> print(f"Dice: {result.dice_score:.3f}")
122
- """
123
- ...
124
-
125
-
126
- def run_pipeline_on_batch(
127
- case_ids: list[str | int],
128
- *,
129
- max_workers: int = 1,
130
- **kwargs,
131
- ) -> list[PipelineResult]:
132
- """
133
- Run pipeline on multiple cases.
134
-
135
- Note: Parallel execution requires multiple GPUs or sequential mode.
136
-
137
- Args:
138
- case_ids: List of case identifiers or indices
139
- max_workers: Number of parallel workers (default 1 for sequential)
140
- **kwargs: Passed to run_pipeline_on_case
141
-
142
- Returns:
143
- List of PipelineResult, one per case
144
- """
145
- ...
146
-
147
-
148
- def get_pipeline_summary(results: list[PipelineResult]) -> PipelineSummary:
149
- """
150
- Compute summary statistics from multiple pipeline results.
151
-
152
- Returns:
153
- Summary with mean Dice, success rate, etc.
154
- """
155
- ...
156
-
157
-
158
- @dataclass(frozen=True)
159
- class PipelineSummary:
160
- """Summary statistics from multiple pipeline runs."""
161
-
162
- num_cases: int
163
- num_successful: int
164
- num_failed: int
165
- mean_dice: float | None
166
- std_dice: float | None
167
- min_dice: float | None
168
- max_dice: float | None
169
- mean_elapsed_seconds: float
170
-
171
-
172
- # Internal helper
173
- def _load_or_get_adapter(
174
- dataset_id: str | None = None,
175
- cache: dict | None = None,
176
- ) -> CaseAdapter:
177
- """Load dataset and return adapter, using cache if available."""
178
- ...
179
- ```
180
-
181
- ### `metrics.py`
182
-
183
- ```python
184
- """Metrics for evaluating segmentation quality."""
185
-
186
- from __future__ import annotations
187
-
188
- from pathlib import Path
189
-
190
- import nibabel as nib
191
- import numpy as np
192
- from numpy.typing import NDArray
193
-
194
-
195
- def compute_dice(
196
- prediction: Path | NDArray[np.float64],
197
- ground_truth: Path | NDArray[np.float64],
198
- *,
199
- threshold: float = 0.5,
200
- ) -> float:
201
- """
202
- Compute Dice similarity coefficient between prediction and ground truth.
203
-
204
- Dice = 2 * |P ∩ G| / (|P| + |G|)
205
-
206
- Args:
207
- prediction: Path to NIfTI file or numpy array
208
- ground_truth: Path to NIfTI file or numpy array
209
- threshold: Threshold for binarization (if needed)
210
-
211
- Returns:
212
- Dice coefficient in [0, 1]
213
-
214
- Raises:
215
- ValueError: If shapes don't match
216
- """
217
- ...
218
-
219
-
220
- def compute_volume_ml(
221
- mask: Path | NDArray[np.float64],
222
- voxel_size_mm: tuple[float, float, float] | None = None,
223
- ) -> float:
224
- """
225
- Compute lesion volume in milliliters.
226
-
227
- Args:
228
- mask: Path to NIfTI file or numpy array
229
- voxel_size_mm: Voxel dimensions in mm (read from NIfTI if None)
230
-
231
- Returns:
232
- Volume in milliliters (mL)
233
- """
234
- ...
235
-
236
-
237
- def load_nifti_as_array(path: Path) -> tuple[NDArray[np.float64], tuple[float, ...]]:
238
- """
239
- Load NIfTI file and return data array with voxel dimensions.
240
-
241
- Returns:
242
- Tuple of (data_array, voxel_sizes_mm)
243
- """
244
- ...
245
- ```
246
-
247
- ### `cli.py` (optional)
248
-
249
- ```python
250
- """Command-line interface for stroke-deepisles-demo."""
251
-
252
- from __future__ import annotations
253
-
254
- import argparse
255
- import sys
256
- from pathlib import Path
257
-
258
-
259
- def main(argv: list[str] | None = None) -> int:
260
- """Main CLI entry point."""
261
- parser = argparse.ArgumentParser(
262
- prog="stroke-demo",
263
- description="Run DeepISLES stroke segmentation on HF datasets",
264
- )
265
- subparsers = parser.add_subparsers(dest="command", required=True)
266
-
267
- # List command
268
- list_parser = subparsers.add_parser("list", help="List available cases")
269
- list_parser.add_argument(
270
- "--dataset", default=None, help="HF dataset ID"
271
- )
272
-
273
- # Run command
274
- run_parser = subparsers.add_parser("run", help="Run segmentation")
275
- run_parser.add_argument(
276
- "--case", type=str, help="Case ID (e.g., sub-001)"
277
- )
278
- run_parser.add_argument(
279
- "--index", type=int, help="Case index (alternative to --case)"
280
- )
281
- run_parser.add_argument(
282
- "--output", type=Path, default=None, help="Output directory"
283
- )
284
- run_parser.add_argument(
285
- "--fast", action="store_true", default=True, help="Use fast mode"
286
- )
287
- run_parser.add_argument(
288
- "--no-gpu", action="store_true", help="Disable GPU"
289
- )
290
-
291
- args = parser.parse_args(argv)
292
-
293
- if args.command == "list":
294
- return cmd_list(args)
295
- elif args.command == "run":
296
- return cmd_run(args)
297
-
298
- return 0
299
-
300
-
301
- def cmd_list(args: argparse.Namespace) -> int:
302
- """Handle 'list' command."""
303
- ...
304
-
305
-
306
- def cmd_run(args: argparse.Namespace) -> int:
307
- """Handle 'run' command."""
308
- ...
309
-
310
-
311
- if __name__ == "__main__":
312
- sys.exit(main())
313
- ```
314
-
315
- ### pyproject.toml addition for CLI
316
-
317
- ```toml
318
- [project.scripts]
319
- stroke-demo = "stroke_deepisles_demo.cli:main"
320
- ```
321
-
322
- ## tdd plan
323
-
324
- ### test file structure
325
-
326
- ```
327
- tests/
328
- ├── test_pipeline.py # Pipeline orchestration tests
329
- ├── test_metrics.py # Metrics computation tests
330
- └── test_cli.py # CLI tests (optional)
331
- ```
332
-
333
- ### tests to write first (TDD order)
334
-
335
- #### 1. `tests/test_metrics.py` - Pure functions, no mocks needed
336
-
337
- ```python
338
- """Tests for metrics module."""
339
-
340
- from __future__ import annotations
341
-
342
- from pathlib import Path
343
-
344
- import nibabel as nib
345
- import numpy as np
346
- import pytest
347
-
348
- from stroke_deepisles_demo.metrics import (
349
- compute_dice,
350
- compute_volume_ml,
351
- load_nifti_as_array,
352
- )
353
-
354
-
355
- class TestComputeDice:
356
- """Tests for compute_dice."""
357
-
358
- def test_identical_masks_return_one(self) -> None:
359
- """Dice of identical masks is 1.0."""
360
- mask = np.array([[[1, 1, 0], [0, 1, 0], [0, 0, 1]]])
361
-
362
- dice = compute_dice(mask, mask)
363
-
364
- assert dice == 1.0
365
-
366
- def test_no_overlap_returns_zero(self) -> None:
367
- """Dice of non-overlapping masks is 0.0."""
368
- pred = np.array([[[1, 1, 0], [0, 0, 0], [0, 0, 0]]])
369
- gt = np.array([[[0, 0, 0], [0, 0, 0], [0, 0, 1]]])
370
-
371
- dice = compute_dice(pred, gt)
372
-
373
- assert dice == 0.0
374
-
375
- def test_partial_overlap(self) -> None:
376
- """Dice with partial overlap is between 0 and 1."""
377
- pred = np.array([[[1, 1, 0], [0, 0, 0], [0, 0, 0]]])
378
- gt = np.array([[[1, 0, 0], [0, 0, 0], [0, 0, 0]]])
379
-
380
- dice = compute_dice(pred, gt)
381
-
382
- # Overlap: 1, Pred: 2, GT: 1 -> Dice = 2*1 / (2+1) = 0.667
383
- assert 0.6 < dice < 0.7
384
-
385
- def test_empty_masks_return_one(self) -> None:
386
- """Dice of two empty masks is 1.0 (both agree on nothing)."""
387
- empty = np.zeros((10, 10, 10))
388
-
389
- dice = compute_dice(empty, empty)
390
-
391
- assert dice == 1.0
392
-
393
- def test_accepts_file_paths(self, temp_dir: Path) -> None:
394
- """Can compute Dice from NIfTI file paths."""
395
- mask = np.array([[[1, 1, 0], [0, 1, 0], [0, 0, 1]]]).astype(np.float32)
396
- img = nib.Nifti1Image(mask, np.eye(4))
397
-
398
- pred_path = temp_dir / "pred.nii.gz"
399
- gt_path = temp_dir / "gt.nii.gz"
400
- nib.save(img, pred_path)
401
- nib.save(img, gt_path)
402
-
403
- dice = compute_dice(pred_path, gt_path)
404
-
405
- assert dice == 1.0
406
-
407
- def test_shape_mismatch_raises(self) -> None:
408
- """Raises ValueError if shapes don't match."""
409
- pred = np.zeros((10, 10, 10))
410
- gt = np.zeros((10, 10, 5))
411
-
412
- with pytest.raises(ValueError, match="shape"):
413
- compute_dice(pred, gt)
414
-
415
-
416
- class TestComputeVolumeMl:
417
- """Tests for compute_volume_ml."""
418
-
419
- def test_computes_volume_from_voxel_size(self) -> None:
420
- """Volume computed correctly from voxel dimensions."""
421
- # 10x10x10 = 1000 voxels of size 1mm^3 each = 1000mm^3 = 1mL
422
- mask = np.ones((10, 10, 10))
423
-
424
- volume = compute_volume_ml(mask, voxel_size_mm=(1.0, 1.0, 1.0))
425
-
426
- assert volume == pytest.approx(1.0, rel=0.01)
427
-
428
- def test_reads_voxel_size_from_nifti(self, temp_dir: Path) -> None:
429
- """Reads voxel size from NIfTI header."""
430
- mask = np.ones((10, 10, 10)).astype(np.float32)
431
- # Affine with 2mm voxels
432
- affine = np.diag([2.0, 2.0, 2.0, 1.0])
433
- img = nib.Nifti1Image(mask, affine)
434
-
435
- path = temp_dir / "mask.nii.gz"
436
- nib.save(img, path)
437
-
438
- # 1000 voxels * 8mm^3 = 8000mm^3 = 8mL
439
- volume = compute_volume_ml(path)
440
-
441
- assert volume == pytest.approx(8.0, rel=0.01)
442
-
443
-
444
- class TestLoadNiftiAsArray:
445
- """Tests for load_nifti_as_array."""
446
-
447
- def test_returns_array_and_voxel_sizes(self, temp_dir: Path) -> None:
448
- """Returns data array and voxel dimensions."""
449
- data = np.random.rand(10, 10, 10).astype(np.float32)
450
- affine = np.diag([1.5, 1.5, 2.0, 1.0])
451
- img = nib.Nifti1Image(data, affine)
452
-
453
- path = temp_dir / "test.nii.gz"
454
- nib.save(img, path)
455
-
456
- arr, voxels = load_nifti_as_array(path)
457
-
458
- assert arr.shape == (10, 10, 10)
459
- assert voxels == pytest.approx((1.5, 1.5, 2.0), rel=0.01)
460
- ```
461
-
462
- #### 2. `tests/test_pipeline.py` - Full orchestration with mocks
463
-
464
- ```python
465
- """Tests for pipeline orchestration."""
466
-
467
- from __future__ import annotations
468
-
469
- from pathlib import Path
470
- from unittest.mock import MagicMock, patch
471
-
472
- import pytest
473
-
474
- from stroke_deepisles_demo.core.types import CaseFiles
475
- from stroke_deepisles_demo.pipeline import (
476
- PipelineResult,
477
- PipelineSummary,
478
- get_pipeline_summary,
479
- run_pipeline_on_case,
480
- )
481
-
482
-
483
- class TestRunPipelineOnCase:
484
- """Tests for run_pipeline_on_case."""
485
-
486
- @pytest.fixture
487
- def mock_dependencies(self, temp_dir: Path):
488
- """Mock all external dependencies."""
489
- with patch(
490
- "stroke_deepisles_demo.pipeline.load_isles_dataset"
491
- ) as mock_load, patch(
492
- "stroke_deepisles_demo.pipeline.CaseAdapter"
493
- ) as mock_adapter_cls, patch(
494
- "stroke_deepisles_demo.pipeline.stage_case_for_deepisles"
495
- ) as mock_stage, patch(
496
- "stroke_deepisles_demo.pipeline.run_deepisles_on_folder"
497
- ) as mock_inference, patch(
498
- "stroke_deepisles_demo.pipeline.compute_dice"
499
- ) as mock_dice:
500
- # Configure mocks
501
- mock_adapter = MagicMock()
502
- mock_adapter.get_case.return_value = CaseFiles(
503
- dwi=temp_dir / "dwi.nii.gz",
504
- adc=temp_dir / "adc.nii.gz",
505
- flair=None,
506
- ground_truth=temp_dir / "gt.nii.gz",
507
- )
508
- mock_adapter_cls.return_value = mock_adapter
509
-
510
- mock_stage.return_value = MagicMock(
511
- input_dir=temp_dir / "staged",
512
- dwi_path=temp_dir / "staged" / "dwi.nii.gz",
513
- adc_path=temp_dir / "staged" / "adc.nii.gz",
514
- flair_path=None,
515
- )
516
-
517
- mock_inference.return_value = MagicMock(
518
- prediction_path=temp_dir / "results" / "pred.nii.gz",
519
- elapsed_seconds=10.5,
520
- )
521
-
522
- mock_dice.return_value = 0.85
523
-
524
- yield {
525
- "load": mock_load,
526
- "adapter_cls": mock_adapter_cls,
527
- "adapter": mock_adapter,
528
- "stage": mock_stage,
529
- "inference": mock_inference,
530
- "dice": mock_dice,
531
- }
532
-
533
- def test_returns_pipeline_result(self, mock_dependencies, temp_dir) -> None:
534
- """Returns PipelineResult with expected fields."""
535
- result = run_pipeline_on_case("sub-001")
536
-
537
- assert isinstance(result, PipelineResult)
538
- assert result.case_id == "sub-001"
539
-
540
- def test_loads_case_from_adapter(self, mock_dependencies, temp_dir) -> None:
541
- """Loads case using CaseAdapter."""
542
- run_pipeline_on_case("sub-001")
543
-
544
- mock_dependencies["adapter"].get_case.assert_called_once_with("sub-001")
545
-
546
- def test_stages_files_for_deepisles(self, mock_dependencies, temp_dir) -> None:
547
- """Stages files with correct naming."""
548
- run_pipeline_on_case("sub-001")
549
-
550
- mock_dependencies["stage"].assert_called_once()
551
-
552
- def test_runs_deepisles_inference(self, mock_dependencies, temp_dir) -> None:
553
- """Runs DeepISLES on staged directory."""
554
- run_pipeline_on_case("sub-001", fast=True, gpu=False)
555
-
556
- mock_dependencies["inference"].assert_called_once()
557
- call_kwargs = mock_dependencies["inference"].call_args.kwargs
558
- assert call_kwargs.get("fast") is True
559
- assert call_kwargs.get("gpu") is False
560
-
561
- def test_computes_dice_when_ground_truth_available(
562
- self, mock_dependencies, temp_dir
563
- ) -> None:
564
- """Computes Dice score when ground truth is available."""
565
- result = run_pipeline_on_case("sub-001", compute_dice=True)
566
-
567
- mock_dependencies["dice"].assert_called_once()
568
- assert result.dice_score == 0.85
569
-
570
- def test_skips_dice_when_disabled(self, mock_dependencies, temp_dir) -> None:
571
- """Skips Dice computation when compute_dice=False."""
572
- result = run_pipeline_on_case("sub-001", compute_dice=False)
573
-
574
- mock_dependencies["dice"].assert_not_called()
575
- assert result.dice_score is None
576
-
577
- def test_handles_missing_ground_truth(self, mock_dependencies, temp_dir) -> None:
578
- """Handles cases without ground truth gracefully."""
579
- # Modify mock to return no ground truth
580
- mock_dependencies["adapter"].get_case.return_value = CaseFiles(
581
- dwi=temp_dir / "dwi.nii.gz",
582
- adc=temp_dir / "adc.nii.gz",
583
- flair=None,
584
- ground_truth=None,
585
- )
586
-
587
- result = run_pipeline_on_case("sub-001", compute_dice=True)
588
-
589
- assert result.dice_score is None
590
- assert result.ground_truth is None
591
-
592
- def test_accepts_integer_index(self, mock_dependencies, temp_dir) -> None:
593
- """Accepts integer index as case identifier."""
594
- mock_dependencies["adapter"].get_case_by_index.return_value = (
595
- "sub-001",
596
- CaseFiles(
597
- dwi=temp_dir / "dwi.nii.gz",
598
- adc=temp_dir / "adc.nii.gz",
599
- flair=None,
600
- ground_truth=None,
601
- ),
602
- )
603
-
604
- result = run_pipeline_on_case(0)
605
-
606
- assert result.case_id == "sub-001"
607
-
608
-
609
- class TestGetPipelineSummary:
610
- """Tests for get_pipeline_summary."""
611
-
612
- def test_computes_mean_dice(self) -> None:
613
- """Computes mean Dice from results."""
614
- results = [
615
- MagicMock(dice_score=0.8, elapsed_seconds=10),
616
- MagicMock(dice_score=0.9, elapsed_seconds=12),
617
- MagicMock(dice_score=0.7, elapsed_seconds=8),
618
- ]
619
-
620
- summary = get_pipeline_summary(results)
621
-
622
- assert summary.mean_dice == pytest.approx(0.8, rel=0.01)
623
-
624
- def test_handles_none_dice_scores(self) -> None:
625
- """Handles results with None Dice scores."""
626
- results = [
627
- MagicMock(dice_score=0.8, elapsed_seconds=10),
628
- MagicMock(dice_score=None, elapsed_seconds=12),
629
- MagicMock(dice_score=0.7, elapsed_seconds=8),
630
- ]
631
-
632
- summary = get_pipeline_summary(results)
633
-
634
- # Mean of 0.8 and 0.7 only
635
- assert summary.mean_dice == pytest.approx(0.75, rel=0.01)
636
-
637
- def test_counts_successful_and_failed(self) -> None:
638
- """Counts successful and failed runs."""
639
- results = [
640
- MagicMock(dice_score=0.8, elapsed_seconds=10),
641
- MagicMock(dice_score=None, elapsed_seconds=0), # Failed
642
- ]
643
-
644
- summary = get_pipeline_summary(results)
645
-
646
- assert summary.num_cases == 2
647
- assert summary.num_successful == 1
648
- assert summary.num_failed == 1
649
-
650
-
651
- @pytest.mark.integration
652
- class TestPipelineIntegration:
653
- """Integration tests for full pipeline."""
654
-
655
- @pytest.mark.slow
656
- def test_run_on_real_case(self) -> None:
657
- """Run pipeline on actual ISLES24-MR-Lite case."""
658
- # Requires: network, Docker, DeepISLES image
659
- # Run with: pytest -m "integration and slow"
660
-
661
- result = run_pipeline_on_case(
662
- 0, # First case
663
- fast=True,
664
- gpu=False,
665
- compute_dice=True,
666
- )
667
-
668
- assert result.prediction_mask.exists()
669
- assert 0 <= result.dice_score <= 1
670
- ```
671
-
672
- ### what to mock
673
-
674
- - `load_isles_dataset` - Avoid network calls
675
- - `CaseAdapter` - Return synthetic CaseFiles
676
- - `stage_case_for_deepisles` - Return mock staged paths
677
- - `run_deepisles_on_folder` - Avoid Docker
678
- - `compute_dice` - Return fixed value for deterministic tests
679
-
680
- ### what to test for real
681
-
682
- - Dice computation (pure NumPy)
683
- - Volume computation (pure NumPy + nibabel)
684
- - NIfTI loading
685
- - Integration: full pipeline on real data
686
-
687
- ## "done" criteria
688
-
689
- Phase 3 is complete when:
690
-
691
- 1. All unit tests pass: `uv run pytest tests/test_pipeline.py tests/test_metrics.py -v`
692
- 2. Dice computation is correct for known test cases
693
- 3. Pipeline orchestrates all components correctly
694
- 4. CLI works: `uv run stroke-demo list` and `uv run stroke-demo run --index 0`
695
- 5. Integration test passes: `uv run pytest -m "integration and slow"`
696
- 6. Type checking passes: `uv run mypy src/stroke_deepisles_demo/pipeline.py src/stroke_deepisles_demo/metrics.py`
697
- 7. Code coverage for pipeline module > 80%
698
-
699
- ## implementation notes
700
-
701
- - Use dataclasses for results (immutable, typed)
702
- - Consider caching the loaded dataset in module-level variable
703
- - Dice should handle edge cases (empty masks, shape mismatches)
704
- - CLI is optional but useful for manual testing
705
- - Batch processing is sequential by default (GPU constraint)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/archive/05-phase-4-gradio-ui.md DELETED
@@ -1,778 +0,0 @@
1
- # phase 4: gradio / spaces app
2
-
3
- ## purpose
4
-
5
- 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.
6
-
7
- ## deliverables
8
-
9
- - [ ] `src/stroke_deepisles_demo/ui/app.py` - Main Gradio application
10
- - [ ] `src/stroke_deepisles_demo/ui/viewer.py` - NiiVue integration
11
- - [ ] `src/stroke_deepisles_demo/ui/components.py` - Reusable UI components
12
- - [ ] `app.py` at repo root - HF Spaces entry point
13
- - [ ] Unit tests for UI logic (not Gradio itself)
14
- - [ ] Smoke test for app import
15
-
16
- ## vertical slice outcome
17
-
18
- After this phase, you can run locally:
19
-
20
- ```bash
21
- uv run gradio src/stroke_deepisles_demo/ui/app.py
22
- # or
23
- uv run python -m stroke_deepisles_demo.ui.app
24
- ```
25
-
26
- And deploy to Hugging Face Spaces with the standard Gradio SDK.
27
-
28
- ## module structure
29
-
30
- ```
31
- src/stroke_deepisles_demo/ui/
32
- ├── __init__.py # Public API
33
- ├── app.py # Main Gradio application
34
- ├── viewer.py # NiiVue integration
35
- └── components.py # Reusable UI components
36
-
37
- # Root level for HF Spaces
38
- app.py # Entry point: from stroke_deepisles_demo.ui.app import demo
39
- ```
40
-
41
- ## gradio 5 considerations
42
-
43
- Based on [Gradio 5 documentation](https://huggingface.co/blog/gradio-5):
44
-
45
- - Server-side rendering (SSR) for fast initial load
46
- - Improved components (Buttons, Tabs, Sliders)
47
- - WebRTC support for real-time streaming
48
- - New built-in themes
49
-
50
- Key patterns:
51
- ```python
52
- import gradio as gr
53
-
54
- # Gradio 5 app pattern
55
- with gr.Blocks(theme=gr.themes.Soft()) as demo:
56
- gr.Markdown("# Title")
57
- with gr.Row():
58
- with gr.Column():
59
- # Inputs
60
- ...
61
- with gr.Column():
62
- # Outputs
63
- ...
64
-
65
- demo.launch()
66
- ```
67
-
68
- ## niivue integration strategy
69
-
70
- [NiiVue](https://github.com/niivue/niivue) is a WebGL2-based neuroimaging viewer.
71
-
72
- ### proven implementation: tobias's bids-neuroimaging space
73
-
74
- **Reference**: [TobiasPitters/bids-neuroimaging](https://huggingface.co/spaces/TobiasPitters/bids-neuroimaging) - A working HF Space with NiiVue multiplanar + 3D rendering.
75
-
76
- Key patterns from Tobias's implementation:
77
-
78
- 1. **FastAPI + raw HTML** (not Gradio) - Cleaner for single-page viewer
79
- 2. **NiiVue via unpkg CDN**: `https://unpkg.com/@niivue/niivue@0.57.0/dist/index.js`
80
- 3. **Base64 data URLs** for NIfTI data (no file serving needed):
81
- ```python
82
- import base64
83
- nifti_bytes = nifti_image.to_bytes()
84
- nifti_b64 = base64.b64encode(nifti_bytes).decode("utf-8")
85
- data_url = f"data:application/octet-stream;base64,{nifti_b64}"
86
- ```
87
- 4. **NiiVue configuration for multiplanar + 3D**:
88
- ```javascript
89
- nv.setSliceType(nv.sliceTypeMultiplanar);
90
- nv.setMultiplanarLayout(2); // 2x2 grid with 3D render
91
- nv.opts.show3Dcrosshair = true;
92
- ```
93
-
94
- ### implementation approach: gradio + direct base64 injection
95
-
96
- For our demo, we use:
97
- - **Gradio** for case selection dropdown and "Run Segmentation" button
98
- - **Direct Base64 data URLs** injected into HTML (no separate API endpoints)
99
- - **NiiVue via `gr.HTML`** for interactive 3D visualization
100
-
101
- This gives us:
102
- - Gradio's nice UI components for inputs
103
- - Proven NiiVue rendering pattern from Tobias's implementation
104
- - No iframe complexity, no proxy issues in HF Spaces
105
-
106
- ### concrete implementation
107
-
108
- ```python
109
- import base64
110
- from pathlib import Path
111
- import nibabel as nib
112
-
113
- def nifti_to_data_url(nifti_path: Path) -> str:
114
- """Convert NIfTI file to base64 data URL for NiiVue."""
115
- img = nib.load(nifti_path)
116
- nifti_bytes = img.to_bytes()
117
- nifti_b64 = base64.b64encode(nifti_bytes).decode("utf-8")
118
- return f"data:application/octet-stream;base64,{nifti_b64}"
119
-
120
- def create_niivue_viewer_html(
121
- volume_data_url: str,
122
- mask_data_url: str | None = None,
123
- height: int = 600,
124
- ) -> str:
125
- """Create NiiVue HTML viewer with optional mask overlay."""
126
- mask_loading = ""
127
- if mask_data_url:
128
- mask_loading = f"""
129
- volumes.push({{
130
- url: '{mask_data_url}',
131
- colorMap: 'red',
132
- opacity: 0.5
133
- }});
134
- """
135
-
136
- return f"""
137
- <div style="width:100%; height:{height}px; background:#000; border-radius:8px;">
138
- <canvas id="niivue-canvas" style="width:100%; height:100%;"></canvas>
139
- </div>
140
- <script type="module">
141
- const niivueModule = await import('https://unpkg.com/@niivue/niivue@0.57.0/dist/index.js');
142
- const Niivue = niivueModule.Niivue;
143
-
144
- const nv = new Niivue({{
145
- logging: false,
146
- show3Dcrosshair: true,
147
- textHeight: 0.04
148
- }});
149
-
150
- await nv.attachTo('niivue-canvas');
151
-
152
- const volumes = [{{
153
- url: '{volume_data_url}',
154
- name: 'dwi.nii.gz'
155
- }}];
156
- {mask_loading}
157
-
158
- await nv.loadVolumes(volumes);
159
-
160
- // Multiplanar + 3D view
161
- nv.setSliceType(nv.sliceTypeMultiplanar);
162
- if (nv.setMultiplanarLayout) {{
163
- nv.setMultiplanarLayout(2);
164
- }}
165
- nv.opts.show3Dcrosshair = true;
166
- nv.setRenderAzimuthElevation(120, 10);
167
- nv.drawScene();
168
- </script>
169
- """
170
- ```
171
-
172
- ## interfaces and types
173
-
174
- ### `ui/app.py`
175
-
176
- ```python
177
- """Main Gradio application for stroke-deepisles-demo."""
178
-
179
- from __future__ import annotations
180
-
181
- import gradio as gr
182
-
183
- from stroke_deepisles_demo.pipeline import run_pipeline_on_case
184
- from stroke_deepisles_demo.ui.components import create_case_selector, create_results_display
185
- from stroke_deepisles_demo.ui.viewer import render_comparison_view
186
-
187
-
188
- def create_app() -> gr.Blocks:
189
- """
190
- Create the Gradio application.
191
-
192
- Returns:
193
- Configured gr.Blocks application
194
- """
195
- with gr.Blocks(
196
- title="Stroke Lesion Segmentation Demo",
197
- theme=gr.themes.Soft(),
198
- ) as demo:
199
- # Header
200
- gr.Markdown("""
201
- # Stroke Lesion Segmentation Demo
202
-
203
- This demo runs [DeepISLES](https://github.com/ezequieldlrosa/DeepIsles)
204
- stroke segmentation on cases from
205
- [ISLES24-MR-Lite](https://huggingface.co/datasets/YongchengYAO/ISLES24-MR-Lite).
206
-
207
- > **Disclaimer**: This is for research/demonstration only. Not for clinical use.
208
- """)
209
-
210
- with gr.Row():
211
- # Left column: Controls
212
- with gr.Column(scale=1):
213
- case_selector = create_case_selector()
214
- run_btn = gr.Button("Run Segmentation", variant="primary")
215
- status = gr.Textbox(label="Status", interactive=False)
216
-
217
- # Right column: Results
218
- with gr.Column(scale=2):
219
- results_display = create_results_display()
220
-
221
- # Event handlers
222
- run_btn.click(
223
- fn=run_segmentation,
224
- inputs=[case_selector],
225
- outputs=[results_display, status],
226
- )
227
-
228
- return demo
229
-
230
-
231
- def run_segmentation(case_id: str) -> tuple[dict, str]:
232
- """
233
- Run segmentation and return results for display.
234
-
235
- Args:
236
- case_id: Selected case identifier
237
-
238
- Returns:
239
- Tuple of (results_dict, status_message)
240
- """
241
- ...
242
-
243
-
244
- # Module-level app instance for Gradio CLI
245
- demo = create_app()
246
-
247
- if __name__ == "__main__":
248
- demo.launch()
249
- ```
250
-
251
- ### `ui/viewer.py`
252
-
253
- ```python
254
- """Neuroimaging visualization for Gradio."""
255
-
256
- from __future__ import annotations
257
-
258
- from pathlib import Path
259
- from typing import TYPE_CHECKING
260
-
261
- import numpy as np
262
-
263
- if TYPE_CHECKING:
264
- from matplotlib.figure import Figure
265
- from numpy.typing import NDArray
266
-
267
-
268
- def render_slice_comparison(
269
- dwi_path: Path,
270
- prediction_path: Path,
271
- ground_truth_path: Path | None = None,
272
- *,
273
- slice_idx: int | None = None,
274
- orientation: str = "axial",
275
- ) -> Figure:
276
- """
277
- Render side-by-side comparison of DWI, prediction, and ground truth.
278
-
279
- Args:
280
- dwi_path: Path to DWI NIfTI
281
- prediction_path: Path to predicted mask NIfTI
282
- ground_truth_path: Optional path to ground truth mask
283
- slice_idx: Slice index (default: middle slice)
284
- orientation: One of "axial", "coronal", "sagittal"
285
-
286
- Returns:
287
- Matplotlib figure with comparison view
288
- """
289
- ...
290
-
291
-
292
- def render_3panel_view(
293
- nifti_path: Path,
294
- mask_path: Path | None = None,
295
- *,
296
- mask_alpha: float = 0.5,
297
- mask_color: str = "red",
298
- ) -> Figure:
299
- """
300
- Render axial/coronal/sagittal slices with optional mask overlay.
301
-
302
- Args:
303
- nifti_path: Path to base NIfTI volume
304
- mask_path: Optional path to mask for overlay
305
- mask_alpha: Transparency of mask overlay
306
- mask_color: Color for mask overlay
307
-
308
- Returns:
309
- Matplotlib figure with 3-panel view
310
- """
311
- ...
312
-
313
-
314
- def create_niivue_html(
315
- volume_url: str,
316
- mask_url: str | None = None,
317
- *,
318
- height: int = 400,
319
- ) -> str:
320
- """
321
- Create HTML/JS for NiiVue viewer.
322
-
323
- Args:
324
- volume_url: URL to volume NIfTI file
325
- mask_url: Optional URL to mask NIfTI file
326
- height: Viewer height in pixels
327
-
328
- Returns:
329
- HTML string with embedded NiiVue viewer
330
- """
331
- template = f"""
332
- <div id="gl" style="width:100%; height:{height}px;"></div>
333
- <script type="module">
334
- const niivueModule = await import('https://unpkg.com/@niivue/niivue@0.57.0/dist/index.js');
335
- const Niivue = niivueModule.Niivue;
336
- const nv = new Niivue({{ show3Dcrosshair: true }});
337
- nv.attachToCanvas(document.getElementById('gl'));
338
- const volumes = [{{ url: '{volume_url}' }}];
339
- {'volumes.push({ url: "' + mask_url + '", colorMap: "red", opacity: 0.5 });' if mask_url else ''}
340
- await nv.loadVolumes(volumes);
341
- </script>
342
- """
343
- return template
344
-
345
-
346
- def get_slice_at_max_lesion(
347
- mask_path: Path,
348
- orientation: str = "axial",
349
- ) -> int:
350
- """
351
- Find slice index with maximum lesion area.
352
-
353
- Useful for displaying the most informative slice.
354
-
355
- Args:
356
- mask_path: Path to lesion mask NIfTI
357
- orientation: Slice orientation
358
-
359
- Returns:
360
- Slice index with maximum lesion area
361
- """
362
- ...
363
- ```
364
-
365
- ### `ui/components.py`
366
-
367
- ```python
368
- """Reusable UI components."""
369
-
370
- from __future__ import annotations
371
-
372
- import gradio as gr
373
-
374
- from stroke_deepisles_demo.data import list_case_ids
375
-
376
-
377
- def create_case_selector() -> gr.Dropdown:
378
- """
379
- Create a dropdown for selecting cases.
380
-
381
- Returns:
382
- Configured gr.Dropdown component
383
- """
384
- try:
385
- case_ids = list_case_ids()
386
- except Exception:
387
- case_ids = ["Error loading cases"]
388
-
389
- return gr.Dropdown(
390
- choices=case_ids,
391
- value=case_ids[0] if case_ids else None,
392
- label="Select Case",
393
- info="Choose a case from ISLES24-MR-Lite",
394
- )
395
-
396
-
397
- def create_results_display() -> dict[str, gr.components.Component]:
398
- """
399
- Create results display components.
400
-
401
- Returns:
402
- Dictionary of component name -> gr.Component
403
- """
404
- with gr.Group():
405
- viewer = gr.Image(label="Segmentation Result", type="filepath")
406
- metrics = gr.JSON(label="Metrics")
407
- download = gr.File(label="Download Prediction")
408
-
409
- return {
410
- "viewer": viewer,
411
- "metrics": metrics,
412
- "download": download,
413
- }
414
-
415
-
416
- def create_settings_accordion() -> dict[str, gr.components.Component]:
417
- """
418
- Create expandable settings section.
419
-
420
- Returns:
421
- Dictionary of setting name -> gr.Component
422
- """
423
- with gr.Accordion("Advanced Settings", open=False):
424
- fast_mode = gr.Checkbox(
425
- value=True,
426
- label="Fast Mode (SEALS)",
427
- info="Run SEALS only (ISLES'22 winner, requires DWI+ADC). Disable for full ensemble (requires FLAIR).",
428
- )
429
- show_ground_truth = gr.Checkbox(
430
- value=True,
431
- label="Show Ground Truth",
432
- info="Display ground truth mask if available",
433
- )
434
-
435
- return {
436
- "fast_mode": fast_mode,
437
- "show_ground_truth": show_ground_truth,
438
- }
439
- ```
440
-
441
- ### Root `app.py` for HF Spaces
442
-
443
- ```python
444
- """Entry point for Hugging Face Spaces deployment."""
445
-
446
- from stroke_deepisles_demo.ui.app import demo
447
-
448
- if __name__ == "__main__":
449
- demo.launch()
450
- ```
451
-
452
- ## hugging face spaces configuration
453
-
454
- ### `README.md` header for Spaces
455
-
456
- ```yaml
457
- ---
458
- title: Stroke DeepISLES Demo
459
- emoji: 🧠
460
- colorFrom: blue
461
- colorTo: purple
462
- sdk: gradio
463
- sdk_version: 5.0.0
464
- app_file: app.py
465
- pinned: false
466
- license: mit
467
- ---
468
- ```
469
-
470
- ### `requirements.txt` for Spaces
471
-
472
- ```
473
- # Note: HF Spaces uses requirements.txt, not pyproject.toml
474
- git+https://github.com/CloseChoice/datasets.git@feat/bids-loader-streaming-upload-fix
475
- huggingface-hub>=0.25.0
476
- nibabel>=5.2.0
477
- numpy>=1.26.0
478
- pydantic>=2.5.0
479
- pydantic-settings>=2.1.0
480
- gradio>=5.0.0
481
- matplotlib>=3.8.0
482
- ```
483
-
484
- ## tdd plan
485
-
486
- ### test file structure
487
-
488
- ```
489
- tests/
490
- ├── ui/
491
- │ ├── __init__.py
492
- │ ├── test_viewer.py # Tests for visualization
493
- │ ├── test_components.py # Tests for UI components
494
- │ └── test_app.py # Smoke tests for app
495
- ```
496
-
497
- ### tests to write first (TDD order)
498
-
499
- #### 1. `tests/ui/test_viewer.py` - Pure visualization functions
500
-
501
- ```python
502
- """Tests for viewer module."""
503
-
504
- from __future__ import annotations
505
-
506
- from pathlib import Path
507
-
508
- import matplotlib
509
- import matplotlib.pyplot as plt
510
- import numpy as np
511
- import pytest
512
-
513
- matplotlib.use("Agg") # Non-interactive backend for tests
514
-
515
- from stroke_deepisles_demo.ui.viewer import (
516
- create_niivue_html,
517
- get_slice_at_max_lesion,
518
- render_3panel_view,
519
- render_slice_comparison,
520
- )
521
-
522
-
523
- class TestRender3PanelView:
524
- """Tests for render_3panel_view."""
525
-
526
- def test_returns_matplotlib_figure(self, synthetic_nifti_3d: Path) -> None:
527
- """Returns a matplotlib Figure object."""
528
- fig = render_3panel_view(synthetic_nifti_3d)
529
-
530
- assert isinstance(fig, plt.Figure)
531
- plt.close(fig)
532
-
533
- def test_has_three_axes(self, synthetic_nifti_3d: Path) -> None:
534
- """Figure has 3 subplots (axial, coronal, sagittal)."""
535
- fig = render_3panel_view(synthetic_nifti_3d)
536
-
537
- assert len(fig.axes) == 3
538
- plt.close(fig)
539
-
540
- def test_overlay_mask_when_provided(
541
- self, synthetic_nifti_3d: Path, temp_dir: Path
542
- ) -> None:
543
- """Overlays mask when mask_path provided."""
544
- # Create a simple mask
545
- import nibabel as nib
546
-
547
- mask_data = np.zeros((10, 10, 10), dtype=np.uint8)
548
- mask_data[4:6, 4:6, 4:6] = 1
549
- mask_img = nib.Nifti1Image(mask_data, np.eye(4))
550
- mask_path = temp_dir / "mask.nii.gz"
551
- nib.save(mask_img, mask_path)
552
-
553
- fig = render_3panel_view(synthetic_nifti_3d, mask_path=mask_path)
554
-
555
- # Should not raise
556
- assert fig is not None
557
- plt.close(fig)
558
-
559
-
560
- class TestRenderSliceComparison:
561
- """Tests for render_slice_comparison."""
562
-
563
- def test_comparison_without_ground_truth(
564
- self, synthetic_nifti_3d: Path
565
- ) -> None:
566
- """Works when ground truth is None."""
567
- fig = render_slice_comparison(
568
- synthetic_nifti_3d,
569
- synthetic_nifti_3d, # Use same as prediction for test
570
- ground_truth_path=None,
571
- )
572
-
573
- assert isinstance(fig, plt.Figure)
574
- plt.close(fig)
575
-
576
- def test_comparison_with_ground_truth(
577
- self, synthetic_nifti_3d: Path
578
- ) -> None:
579
- """Works when ground truth is provided."""
580
- fig = render_slice_comparison(
581
- synthetic_nifti_3d,
582
- synthetic_nifti_3d,
583
- ground_truth_path=synthetic_nifti_3d,
584
- )
585
-
586
- assert isinstance(fig, plt.Figure)
587
- plt.close(fig)
588
-
589
-
590
- class TestGetSliceAtMaxLesion:
591
- """Tests for get_slice_at_max_lesion."""
592
-
593
- def test_finds_slice_with_lesion(self, temp_dir: Path) -> None:
594
- """Returns slice index where lesion is largest."""
595
- import nibabel as nib
596
-
597
- # Create mask with lesion at slice 7
598
- mask_data = np.zeros((10, 10, 10), dtype=np.uint8)
599
- mask_data[:, :, 7] = 1 # Full slice 7 is lesion
600
-
601
- mask_img = nib.Nifti1Image(mask_data, np.eye(4))
602
- mask_path = temp_dir / "mask.nii.gz"
603
- nib.save(mask_img, mask_path)
604
-
605
- slice_idx = get_slice_at_max_lesion(mask_path, orientation="axial")
606
-
607
- assert slice_idx == 7
608
-
609
- def test_returns_middle_for_empty_mask(self, temp_dir: Path) -> None:
610
- """Returns middle slice when mask is empty."""
611
- import nibabel as nib
612
-
613
- mask_data = np.zeros((10, 10, 20), dtype=np.uint8)
614
- mask_img = nib.Nifti1Image(mask_data, np.eye(4))
615
- mask_path = temp_dir / "mask.nii.gz"
616
- nib.save(mask_img, mask_path)
617
-
618
- slice_idx = get_slice_at_max_lesion(mask_path, orientation="axial")
619
-
620
- assert slice_idx == 10 # Middle of 20
621
-
622
-
623
- class TestCreateNiivueHtml:
624
- """Tests for create_niivue_html."""
625
-
626
- def test_includes_volume_url(self) -> None:
627
- """Generated HTML includes the volume URL."""
628
- html = create_niivue_html("http://example.com/brain.nii.gz")
629
-
630
- assert "http://example.com/brain.nii.gz" in html
631
-
632
- def test_includes_mask_when_provided(self) -> None:
633
- """Generated HTML includes mask URL when provided."""
634
- html = create_niivue_html(
635
- "http://example.com/brain.nii.gz",
636
- mask_url="http://example.com/mask.nii.gz",
637
- )
638
-
639
- assert "http://example.com/mask.nii.gz" in html
640
-
641
- def test_sets_height(self) -> None:
642
- """Generated HTML respects height parameter."""
643
- html = create_niivue_html(
644
- "http://example.com/brain.nii.gz",
645
- height=600,
646
- )
647
-
648
- assert "height:600px" in html
649
- ```
650
-
651
- #### 2. `tests/ui/test_app.py` - Smoke tests
652
-
653
- ```python
654
- """Smoke tests for Gradio app."""
655
-
656
- from __future__ import annotations
657
-
658
-
659
- def test_app_module_imports() -> None:
660
- """App module imports without side effects."""
661
- # This should not launch the app or make network calls
662
- from stroke_deepisles_demo.ui import app
663
-
664
- assert hasattr(app, "create_app")
665
- assert hasattr(app, "demo")
666
-
667
-
668
- def test_create_app_returns_blocks() -> None:
669
- """create_app returns a gr.Blocks instance."""
670
- import gradio as gr
671
-
672
- from stroke_deepisles_demo.ui.app import create_app
673
-
674
- app = create_app()
675
-
676
- assert isinstance(app, gr.Blocks)
677
-
678
-
679
- def test_viewer_module_imports() -> None:
680
- """Viewer module imports without errors."""
681
- from stroke_deepisles_demo.ui import viewer
682
-
683
- assert hasattr(viewer, "render_3panel_view")
684
- assert hasattr(viewer, "create_niivue_html")
685
-
686
-
687
- def test_components_module_imports() -> None:
688
- """Components module imports without errors."""
689
- from stroke_deepisles_demo.ui import components
690
-
691
- assert hasattr(components, "create_case_selector")
692
- assert hasattr(components, "create_results_display")
693
- ```
694
-
695
- ### what to mock
696
-
697
- - `list_case_ids()` in components - Avoid network during import
698
- - Any data loading in app initialization
699
-
700
- ### what to test for real
701
-
702
- - Matplotlib figure generation
703
- - NiiVue HTML string generation
704
- - Slice finding algorithms
705
- - Module imports (no network side effects)
706
-
707
- ## "done" criteria
708
-
709
- Phase 4 is complete when:
710
-
711
- 1. All unit tests pass: `uv run pytest tests/ui/ -v`
712
- 2. App launches locally: `uv run python -m stroke_deepisles_demo.ui.app`
713
- 3. Can select a case, click "Run", see visualization
714
- 4. Visualization shows DWI with predicted mask overlay
715
- 5. Metrics (Dice score) displayed
716
- 6. Type checking passes: `uv run mypy src/stroke_deepisles_demo/ui/`
717
- 7. Ready for HF Spaces deployment (README header, requirements.txt)
718
-
719
- ## implementation notes
720
-
721
- - **NiiVue is primary** - Proven working in Tobias's Space, not "fragile"
722
- - **Base64 data URLs** - Avoids file serving complexity, works in all environments
723
- - **Lazy initialization** - Do NOT call `list_case_ids()` at module import time (causes network calls)
724
- - **Test on HF Spaces early** - Verify WebGL works in their environment
725
- - **Keep UI simple** - This is a demo, not a full application
726
- - **Cache case list** - Avoid repeated HF Hub calls
727
-
728
- ### avoiding import-time side effects
729
-
730
- The reviewer correctly noted that `demo = create_app()` at module level triggers network calls. Fix:
731
-
732
- ```python
733
- # BAD - triggers network call on import
734
- demo = create_app()
735
-
736
- # GOOD - lazy initialization
737
- _demo: gr.Blocks | None = None
738
-
739
- def get_demo() -> gr.Blocks:
740
- global _demo
741
- if _demo is None:
742
- _demo = create_app()
743
- return _demo
744
-
745
- # For Gradio CLI compatibility
746
- demo = None # Set lazily
747
-
748
- if __name__ == "__main__":
749
- get_demo().launch()
750
- ```
751
-
752
- Or use a factory pattern in the root `app.py`:
753
-
754
- ```python
755
- # app.py (HF Spaces entry point)
756
- from stroke_deepisles_demo.ui.app import create_app
757
-
758
- demo = create_app() # Only called when this file is executed
759
-
760
- if __name__ == "__main__":
761
- demo.launch()
762
- ```
763
-
764
- ## dependencies to add
765
-
766
- ```toml
767
- # Add to pyproject.toml dependencies
768
- "matplotlib>=3.8.0", # For static slice rendering in viewer.py
769
- ```
770
-
771
- ## reference implementation
772
-
773
- Clone Tobias's working Space for reference:
774
- ```
775
- _reference_repos/bids-neuroimaging-space/
776
- ```
777
-
778
- Key file: `main.py` - Complete NiiVue + FastAPI implementation.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/archive/06-phase-5-polish.md DELETED
@@ -1,667 +0,0 @@
1
- # phase 5: polish, observability, and docs
2
-
3
- ## purpose
4
-
5
- Add production-quality polish: structured logging, environment-driven configuration, comprehensive documentation, and CI readiness. At the end of this phase, the codebase is maintainable, debuggable, and ready for others to contribute.
6
-
7
- ## deliverables
8
-
9
- - [ ] Structured logging throughout all modules
10
- - [ ] Environment-driven configuration via pydantic-settings
11
- - [ ] Developer documentation (CONTRIBUTING.md, architecture)
12
- - [ ] API documentation (docstrings, optional Sphinx/mkdocs)
13
- - [ ] CI configuration (GitHub Actions)
14
- - [ ] Final cleanup and code review checklist
15
-
16
- ## logging strategy
17
-
18
- ### centralized logging setup
19
-
20
- ```python
21
- # src/stroke_deepisles_demo/core/logging.py
22
-
23
- """Centralized logging configuration."""
24
-
25
- from __future__ import annotations
26
-
27
- import logging
28
- import sys
29
- from typing import Literal
30
-
31
- LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
32
-
33
-
34
- def setup_logging(
35
- level: LogLevel = "INFO",
36
- *,
37
- format_style: Literal["simple", "detailed", "json"] = "simple",
38
- ) -> None:
39
- """
40
- Configure logging for the application.
41
-
42
- Args:
43
- level: Minimum log level
44
- format_style: Output format style
45
-
46
- Example:
47
- >>> setup_logging("DEBUG", format_style="detailed")
48
- """
49
- formats = {
50
- "simple": "%(levelname)s: %(message)s",
51
- "detailed": "%(asctime)s | %(name)s | %(levelname)s | %(message)s",
52
- "json": '{"time": "%(asctime)s", "name": "%(name)s", "level": "%(levelname)s", "message": "%(message)s"}',
53
- }
54
-
55
- logging.basicConfig(
56
- level=getattr(logging, level),
57
- format=formats[format_style],
58
- stream=sys.stderr,
59
- force=True,
60
- )
61
-
62
- # Reduce noise from libraries
63
- logging.getLogger("urllib3").setLevel(logging.WARNING)
64
- logging.getLogger("httpx").setLevel(logging.WARNING)
65
- logging.getLogger("datasets").setLevel(logging.WARNING)
66
-
67
-
68
- def get_logger(name: str) -> logging.Logger:
69
- """
70
- Get a logger for a module.
71
-
72
- Args:
73
- name: Logger name (typically __name__)
74
-
75
- Returns:
76
- Configured logger instance
77
- """
78
- return logging.getLogger(f"stroke_demo.{name}")
79
- ```
80
-
81
- ### logging usage pattern
82
-
83
- ```python
84
- # In each module
85
- from stroke_deepisles_demo.core.logging import get_logger
86
-
87
- logger = get_logger(__name__)
88
-
89
-
90
- def run_deepisles_on_folder(input_dir: Path, *, fast: bool = True) -> DeepISLESResult:
91
- logger.info("Starting DeepISLES inference", extra={"input_dir": str(input_dir), "fast": fast})
92
-
93
- try:
94
- result = _run_docker(...)
95
- logger.info("Inference complete", extra={"elapsed": result.elapsed_seconds})
96
- return result
97
- except Exception as e:
98
- logger.error("Inference failed", extra={"error": str(e)}, exc_info=True)
99
- raise
100
- ```
101
-
102
- ## enhanced configuration
103
-
104
- ### `src/stroke_deepisles_demo/core/config.py`
105
-
106
- ```python
107
- """Application configuration using pydantic-settings."""
108
-
109
- from __future__ import annotations
110
-
111
- from pathlib import Path
112
- from typing import Literal
113
-
114
- from pydantic import Field, field_validator
115
- from pydantic_settings import BaseSettings, SettingsConfigDict
116
-
117
-
118
- class Settings(BaseSettings):
119
- """
120
- Application settings loaded from environment variables.
121
-
122
- All settings can be overridden via environment variables with
123
- the STROKE_DEMO_ prefix.
124
-
125
- Example:
126
- export STROKE_DEMO_LOG_LEVEL=DEBUG
127
- export STROKE_DEMO_HF_DATASET_ID=my/dataset
128
- """
129
-
130
- model_config = SettingsConfigDict(
131
- env_prefix="STROKE_DEMO_",
132
- env_file=".env",
133
- env_file_encoding="utf-8",
134
- extra="ignore",
135
- )
136
-
137
- # Logging
138
- log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
139
- log_format: Literal["simple", "detailed", "json"] = "simple"
140
-
141
- # HuggingFace
142
- hf_dataset_id: str = "YongchengYAO/ISLES24-MR-Lite"
143
- hf_cache_dir: Path | None = None
144
- hf_token: str | None = Field(default=None, repr=False) # Hidden from logs
145
-
146
- # DeepISLES
147
- deepisles_docker_image: str = "isleschallenge/deepisles"
148
- deepisles_fast_mode: bool = True # SEALS-only (ISLES'22 winner, no FLAIR needed)
149
- deepisles_timeout_seconds: int = 1800 # 30 minutes
150
- deepisles_use_gpu: bool = True
151
-
152
- # Paths
153
- temp_dir: Path | None = None
154
- results_dir: Path = Path("./results")
155
-
156
- # UI
157
- gradio_server_name: str = "0.0.0.0"
158
- gradio_server_port: int = 7860
159
- gradio_share: bool = False
160
-
161
- @field_validator("results_dir", mode="before")
162
- @classmethod
163
- def ensure_results_dir_exists(cls, v: Path | str) -> Path:
164
- """Create results directory if it doesn't exist."""
165
- path = Path(v)
166
- path.mkdir(parents=True, exist_ok=True)
167
- return path
168
-
169
-
170
- # Global settings instance
171
- settings = Settings()
172
-
173
-
174
- def get_settings() -> Settings:
175
- """Get the current settings instance."""
176
- return settings
177
-
178
-
179
- def reload_settings() -> Settings:
180
- """Reload settings from environment (useful for testing)."""
181
- global settings
182
- settings = Settings()
183
- return settings
184
- ```
185
-
186
- ## documentation structure
187
-
188
- ```
189
- docs/
190
- ├── specs/ # Design specs (these documents)
191
- │ ├── 00-context.md
192
- │ ├── 01-phase-0-repo-bootstrap.md
193
- │ ├── ...
194
- │ └── 06-phase-5-polish.md
195
-
196
- ├── guides/ # User guides
197
- │ ├── quickstart.md # Getting started
198
- │ ├── configuration.md # Environment variables
199
- │ └── deployment.md # HF Spaces deployment
200
-
201
- └── reference/ # API reference (auto-generated)
202
- └── api.md
203
-
204
- # Root level
205
- README.md # Project overview
206
- CONTRIBUTING.md # Contribution guidelines
207
- CHANGELOG.md # Version history
208
- ```
209
-
210
- ### `CONTRIBUTING.md`
211
-
212
- ```markdown
213
- # Contributing to stroke-deepisles-demo
214
-
215
- Thank you for your interest in contributing!
216
-
217
- ## Development Setup
218
-
219
- 1. **Clone the repository**
220
- ```bash
221
- git clone https://github.com/The-Obstacle-Is-The-Way/stroke-deepisles-demo.git
222
- cd stroke-deepisles-demo
223
- ```
224
-
225
- 2. **Install uv** (if not already installed)
226
- ```bash
227
- curl -LsSf https://astral.sh/uv/install.sh | sh
228
- ```
229
-
230
- 3. **Install dependencies**
231
- ```bash
232
- uv sync
233
- ```
234
-
235
- 4. **Install pre-commit hooks**
236
- ```bash
237
- uv run pre-commit install
238
- ```
239
-
240
- ## Running Tests
241
-
242
- ```bash
243
- # All tests (excluding integration)
244
- uv run pytest
245
-
246
- # With coverage
247
- uv run pytest --cov
248
-
249
- # Integration tests (requires Docker)
250
- uv run pytest -m integration
251
-
252
- # Slow tests (requires Docker + DeepISLES image)
253
- uv run pytest -m "integration and slow"
254
- ```
255
-
256
- ## Code Quality
257
-
258
- ```bash
259
- # Lint
260
- uv run ruff check .
261
-
262
- # Format
263
- uv run ruff format .
264
-
265
- # Type check
266
- uv run mypy src/
267
- ```
268
-
269
- ## Project Structure
270
-
271
- ```
272
- src/stroke_deepisles_demo/
273
- ├── core/ # Shared utilities (config, types, exceptions)
274
- ├── data/ # HF dataset loading and case management
275
- ├── inference/ # DeepISLES Docker integration
276
- ├── ui/ # Gradio application
277
- ├── pipeline.py # End-to-end orchestration
278
- └── metrics.py # Evaluation metrics
279
- ```
280
-
281
- ## Pull Request Process
282
-
283
- 1. Create a feature branch from `main`
284
- 2. Write tests for new functionality
285
- 3. Ensure all tests pass and code quality checks pass
286
- 4. Update documentation if needed
287
- 5. Submit PR with clear description
288
-
289
- ## Code Style
290
-
291
- - Type hints on all functions
292
- - Docstrings in Google style
293
- - Keep functions focused and small
294
- - Prefer explicit over implicit
295
- ```
296
-
297
- ### `docs/guides/quickstart.md`
298
-
299
- ```markdown
300
- # Quickstart
301
-
302
- Get started with stroke-deepisles-demo in 5 minutes.
303
-
304
- ## Prerequisites
305
-
306
- - Python 3.11+
307
- - Docker (for DeepISLES inference)
308
- - ~10GB disk space (for Docker image and datasets)
309
-
310
- ## Installation
311
-
312
- ```bash
313
- # Clone
314
- git clone https://github.com/The-Obstacle-Is-The-Way/stroke-deepisles-demo.git
315
- cd stroke-deepisles-demo
316
-
317
- # Install
318
- uv sync
319
- ```
320
-
321
- ## Pull DeepISLES Docker Image
322
-
323
- ```bash
324
- docker pull isleschallenge/deepisles
325
- ```
326
-
327
- ## Run Locally
328
-
329
- ### Option 1: Gradio UI
330
-
331
- ```bash
332
- uv run python -m stroke_deepisles_demo.ui.app
333
- # Open http://localhost:7860
334
- ```
335
-
336
- ### Option 2: CLI
337
-
338
- ```bash
339
- # List available cases
340
- uv run stroke-demo list
341
-
342
- # Run on a specific case
343
- uv run stroke-demo run --case sub-001 --fast
344
- ```
345
-
346
- ### Option 3: Python API
347
-
348
- ```python
349
- from stroke_deepisles_demo.pipeline import run_pipeline_on_case
350
-
351
- result = run_pipeline_on_case("sub-001", fast=True)
352
- print(f"Dice score: {result.dice_score:.3f}")
353
- print(f"Prediction: {result.prediction_mask}")
354
- ```
355
-
356
- ## Configuration
357
-
358
- Set environment variables or create a `.env` file:
359
-
360
- ```bash
361
- # .env
362
- STROKE_DEMO_LOG_LEVEL=DEBUG
363
- STROKE_DEMO_DEEPISLES_USE_GPU=false # If no GPU available
364
- ```
365
-
366
- See [Configuration Guide](configuration.md) for all options.
367
- ```
368
-
369
- ### `docs/guides/configuration.md`
370
-
371
- ```markdown
372
- # Configuration
373
-
374
- All settings can be configured via environment variables.
375
-
376
- ## Environment Variables
377
-
378
- | Variable | Default | Description |
379
- |----------|---------|-------------|
380
- | `STROKE_DEMO_LOG_LEVEL` | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
381
- | `STROKE_DEMO_LOG_FORMAT` | `simple` | Log format (simple, detailed, json) |
382
- | `STROKE_DEMO_HF_DATASET_ID` | `YongchengYAO/ISLES24-MR-Lite` | HuggingFace dataset ID |
383
- | `STROKE_DEMO_HF_CACHE_DIR` | `None` | Custom HF cache directory |
384
- | `STROKE_DEMO_HF_TOKEN` | `None` | HuggingFace API token (for private datasets) |
385
- | `STROKE_DEMO_DEEPISLES_DOCKER_IMAGE` | `isleschallenge/deepisles` | DeepISLES Docker image |
386
- | `STROKE_DEMO_DEEPISLES_FAST_MODE` | `true` | Use single-model mode |
387
- | `STROKE_DEMO_DEEPISLES_TIMEOUT_SECONDS` | `1800` | Inference timeout |
388
- | `STROKE_DEMO_DEEPISLES_USE_GPU` | `true` | Use GPU acceleration |
389
- | `STROKE_DEMO_RESULTS_DIR` | `./results` | Directory for output files |
390
-
391
- ## Using .env File
392
-
393
- Create a `.env` file in the project root:
394
-
395
- ```bash
396
- STROKE_DEMO_LOG_LEVEL=DEBUG
397
- STROKE_DEMO_DEEPISLES_USE_GPU=false
398
- STROKE_DEMO_RESULTS_DIR=/data/results
399
- ```
400
-
401
- ## Programmatic Configuration
402
-
403
- ```python
404
- from stroke_deepisles_demo.core.config import settings, reload_settings
405
- import os
406
-
407
- # Check current settings
408
- print(settings.log_level)
409
-
410
- # Override via environment
411
- os.environ["STROKE_DEMO_LOG_LEVEL"] = "DEBUG"
412
- reload_settings()
413
- print(settings.log_level) # DEBUG
414
- ```
415
- ```
416
-
417
- ## ci configuration
418
-
419
- ### `.github/workflows/ci.yml`
420
-
421
- ```yaml
422
- name: CI
423
-
424
- on:
425
- push:
426
- branches: [main]
427
- pull_request:
428
- branches: [main]
429
-
430
- jobs:
431
- lint:
432
- runs-on: ubuntu-latest
433
- steps:
434
- - uses: actions/checkout@v4
435
-
436
- - name: Install uv
437
- uses: astral-sh/setup-uv@v4
438
-
439
- - name: Set up Python
440
- run: uv python install 3.12
441
-
442
- - name: Install dependencies
443
- run: uv sync
444
-
445
- - name: Lint with ruff
446
- run: uv run ruff check .
447
-
448
- - name: Check formatting
449
- run: uv run ruff format --check .
450
-
451
- typecheck:
452
- runs-on: ubuntu-latest
453
- steps:
454
- - uses: actions/checkout@v4
455
-
456
- - name: Install uv
457
- uses: astral-sh/setup-uv@v4
458
-
459
- - name: Set up Python
460
- run: uv python install 3.12
461
-
462
- - name: Install dependencies
463
- run: uv sync
464
-
465
- - name: Type check with mypy
466
- run: uv run mypy src/
467
-
468
- test:
469
- runs-on: ubuntu-latest
470
- steps:
471
- - uses: actions/checkout@v4
472
-
473
- - name: Install uv
474
- uses: astral-sh/setup-uv@v4
475
-
476
- - name: Set up Python
477
- run: uv python install 3.12
478
-
479
- - name: Install dependencies
480
- run: uv sync
481
-
482
- - name: Run tests
483
- run: uv run pytest --cov --cov-report=xml
484
-
485
- - name: Upload coverage
486
- uses: codecov/codecov-action@v4
487
- with:
488
- files: ./coverage.xml
489
-
490
- integration:
491
- runs-on: ubuntu-latest
492
- if: github.event_name == 'push' && github.ref == 'refs/heads/main'
493
- steps:
494
- - uses: actions/checkout@v4
495
-
496
- - name: Install uv
497
- uses: astral-sh/setup-uv@v4
498
-
499
- - name: Set up Python
500
- run: uv python install 3.12
501
-
502
- - name: Install dependencies
503
- run: uv sync
504
-
505
- - name: Run integration tests
506
- run: uv run pytest -m integration --timeout=600
507
- ```
508
-
509
- ## final code review checklist
510
-
511
- ### code quality
512
- - [ ] All functions have type hints
513
- - [ ] All public functions have docstrings
514
- - [ ] No unused imports or variables
515
- - [ ] No hardcoded paths or secrets
516
- - [ ] Error messages are helpful
517
-
518
- ### testing
519
- - [ ] Unit test coverage > 80%
520
- - [ ] Edge cases covered
521
- - [ ] Integration tests for critical paths
522
- - [ ] Tests are deterministic (no flakiness)
523
-
524
- ### documentation
525
- - [ ] README is clear and accurate
526
- - [ ] CONTRIBUTING.md is complete
527
- - [ ] All configuration options documented
528
- - [ ] Example usage in docstrings
529
-
530
- ### security
531
- - [ ] No secrets in code
532
- - [ ] HF_TOKEN is optional and hidden from logs
533
- - [ ] Docker commands are properly escaped
534
- - [ ] No arbitrary code execution vulnerabilities
535
-
536
- ### production readiness
537
- - [ ] Logging is consistent and useful
538
- - [ ] Errors are handled gracefully
539
- - [ ] Configuration is environment-driven
540
- - [ ] CI passes on all checks
541
-
542
- ## tdd plan
543
-
544
- ### tests for logging
545
-
546
- ```python
547
- """Tests for logging configuration."""
548
-
549
- from __future__ import annotations
550
-
551
- import logging
552
-
553
- from stroke_deepisles_demo.core.logging import get_logger, setup_logging
554
-
555
-
556
- class TestSetupLogging:
557
- """Tests for setup_logging."""
558
-
559
- def test_sets_log_level(self) -> None:
560
- """Sets the root logger level."""
561
- setup_logging("DEBUG")
562
- assert logging.getLogger().level == logging.DEBUG
563
-
564
- def test_format_styles(self) -> None:
565
- """Different format styles work."""
566
- for style in ["simple", "detailed", "json"]:
567
- setup_logging("INFO", format_style=style)
568
- # Should not raise
569
-
570
-
571
- class TestGetLogger:
572
- """Tests for get_logger."""
573
-
574
- def test_returns_namespaced_logger(self) -> None:
575
- """Returns logger with stroke_demo prefix."""
576
- logger = get_logger("my_module")
577
- assert logger.name == "stroke_demo.my_module"
578
- ```
579
-
580
- ### tests for configuration
581
-
582
- ```python
583
- """Tests for configuration."""
584
-
585
- from __future__ import annotations
586
-
587
- import os
588
- from pathlib import Path
589
-
590
- import pytest
591
-
592
- from stroke_deepisles_demo.core.config import Settings, reload_settings
593
-
594
-
595
- class TestSettings:
596
- """Tests for Settings."""
597
-
598
- def test_default_values(self) -> None:
599
- """Has sensible defaults."""
600
- settings = Settings()
601
- assert settings.log_level == "INFO"
602
- assert settings.hf_dataset_id == "YongchengYAO/ISLES24-MR-Lite"
603
-
604
- def test_env_override(self, monkeypatch) -> None:
605
- """Environment variables override defaults."""
606
- monkeypatch.setenv("STROKE_DEMO_LOG_LEVEL", "DEBUG")
607
- settings = Settings()
608
- assert settings.log_level == "DEBUG"
609
-
610
- def test_hf_token_hidden_from_repr(self) -> None:
611
- """HF token is not visible in repr."""
612
- settings = Settings(hf_token="secret123")
613
- assert "secret123" not in repr(settings)
614
-
615
- def test_results_dir_created(self, tmp_path: Path) -> None:
616
- """Results directory is created if it doesn't exist."""
617
- new_dir = tmp_path / "new_results"
618
- settings = Settings(results_dir=new_dir)
619
- assert new_dir.exists()
620
- ```
621
-
622
- ## "done" criteria
623
-
624
- Phase 5 is complete when:
625
-
626
- 1. Structured logging is in place throughout
627
- 2. All settings are configurable via environment
628
- 3. README.md and CONTRIBUTING.md are complete
629
- 4. Developer guides are written
630
- 5. CI workflow passes on GitHub Actions
631
- 6. Code coverage > 80% overall
632
- 7. All code review checklist items pass
633
- 8. Repository is ready for others to contribute
634
-
635
- ## final deliverables
636
-
637
- At the end of all phases, the repository contains:
638
-
639
- ```
640
- stroke-deepisles-demo/
641
- ├── .github/
642
- │ └── workflows/
643
- │ └── ci.yml
644
- ├── docs/
645
- │ ├── specs/
646
- │ ├── guides/
647
- │ └── reference/
648
- ├── src/
649
- │ └── stroke_deepisles_demo/
650
- │ ├── core/
651
- │ ├── data/
652
- │ ├── inference/
653
- │ ├── ui/
654
- │ ├── pipeline.py
655
- │ ├── metrics.py
656
- │ └── cli.py
657
- ├── tests/
658
- ├── pyproject.toml
659
- ├── uv.lock
660
- ├── README.md
661
- ├── CONTRIBUTING.md
662
- ├── CHANGELOG.md
663
- ├── .pre-commit-config.yaml
664
- ├── .gitignore
665
- ├── .env.example
666
- └── app.py # HF Spaces entry point
667
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/archive/07-hf-spaces-deployment.md DELETED
@@ -1,969 +0,0 @@
1
- # spec: hugging face spaces + gradio deployment
2
-
3
- > **Version**: December 2025
4
- > **Status**: APPROVED - Ready for Implementation
5
- > **Last Updated**: 2025-12-05
6
- > **Verified**: Cold start claims, pause/restart behavior, ZeroGPU limitations
7
-
8
- ## important: gradio 6 is now available
9
-
10
- As of December 2025, **Gradio 6.0.2** is the latest stable release. Our `pyproject.toml` currently specifies `gradio>=5.0.0`, which will install Gradio 6.x.
11
-
12
- **Key breaking changes affecting our codebase:**
13
-
14
- | Change | Impact | Our Code |
15
- |--------|--------|----------|
16
- | `theme`, `css`, `js` moved from `Blocks()` to `launch()` | HIGH | `app.py:111` uses `gr.Blocks()`, `app.py:170` passes theme to `launch()` - **OK** |
17
- | `gr.HTML` padding default `True` → `False` | LOW | No visual impact expected |
18
- | Chatbot tuple format removed | NONE | We don't use Chatbot |
19
- | `show_api` → `footer_links` | LOW | We don't customize this |
20
-
21
- **Recommendation**: Pin to `gradio>=6.0.0,<7.0.0` for stability, or test with latest and update as needed.
22
-
23
- **Migration guide**: [Gradio 6 Migration Guide](https://www.gradio.app/main/guides/gradio-6-migration-guide)
24
-
25
- ---
26
-
27
- ## purpose
28
-
29
- This spec documents the requirements, constraints, and best practices for deploying the `stroke-deepisles-demo` Gradio application to Hugging Face Spaces. It identifies potential friction points between our current implementation and HF Spaces constraints, providing concrete guidance before deployment.
30
-
31
- ## executive summary
32
-
33
- ### critical friction points identified
34
-
35
- | Issue | Severity | Current State | Fix Required |
36
- |-------|----------|---------------|--------------|
37
- | **NVIDIA GPU required** | HIGH | DeepISLES needs CUDA | Use Docker SDK + GPU on HF Spaces |
38
- | **JavaScript in `gr.HTML`** | HIGH | `<script type="module">` in viewer.py | May not execute; needs `js=` param pattern |
39
- | **Git dependency in pyproject.toml** | MEDIUM | `datasets @ git+https://...` | Needs `requirements.txt` with git URL |
40
- | **Large NIfTI files as base64** | MEDIUM | Full file loaded to memory | Should be fine with GPU tier RAM |
41
- | **NiiVue version** | LOW | Currently 0.57.0 in viewer.py | Update to **0.65.0** (latest) |
42
-
43
- ### deployment strategy
44
-
45
- > **Important**: DeepISLES requires NVIDIA GPU with CUDA. There is no CPU-only or Apple Silicon option. "Demo mode" with pre-computed results was rejected as it defeats the purpose of a real inference demo.
46
-
47
- ### Primary: Local NVIDIA GPU
48
- - Develop and test locally with your NVIDIA GPU
49
- - Free, unlimited, real inference
50
- - Works on Windows/Linux with NVIDIA GPU (GTX 1080+, RTX series)
51
-
52
- ### Showcase: HF Spaces Docker SDK + GPU (On-Demand)
53
- - Use `sdk: docker` with GPU hardware
54
- - **Spin up** when demoing, **pause** when done
55
- - Cost: ~$0.20-$0.40 per 30-60 min demo session
56
- - Billing stops when paused ($0 while inactive)
57
-
58
- ---
59
-
60
- ## critical: cold start reality
61
-
62
- > ⚠️ **OPERATIONAL MANDATE**: Always run `api.restart_space()` **20-30 minutes** before a scheduled demo. Verify the Space is "Running" before sharing your screen.
63
-
64
- ### verified cold start times (december 2025)
65
-
66
- | Phase | Time | Source |
67
- |-------|------|--------|
68
- | HF Infrastructure boot | ~2 minutes | [HF Forums](https://discuss.huggingface.co/t/slow-space-cold-boot/72154) |
69
- | Docker image provision | 5-20 minutes | Large images (CUDA + nnU-Net ~15-20GB) |
70
- | Application startup | 1-5 minutes | Gradio + model loading |
71
- | **Total (best case)** | **8-12 minutes** | Normal conditions |
72
- | **Total (worst case)** | **30-60+ minutes** | Resource contention, Feb 2025 T4 issues |
73
-
74
- **Sources**: [T4 startup 45+ min issue (Feb 2025)](https://discuss.huggingface.co/t/staring-up-t4-instances-is-taking-45-minutes/139567), [Cold boot discussion](https://discuss.huggingface.co/t/slow-space-cold-boot/72154)
75
-
76
- ### why cold start is unavoidable
77
-
78
- From HF Staff (forum moderator):
79
- > "avoiding a cold start here is not possible"
80
-
81
- The ~2-minute infrastructure delay is inherent to HF Spaces architecture. Docker GPU Spaces add additional time for image provisioning and GPU allocation.
82
-
83
- ### deployment risks (edge cases)
84
-
85
- | Risk | Frequency | Mitigation |
86
- |------|-----------|------------|
87
- | Space stuck in "Starting" | Rare | Factory rebuild, contact HF support |
88
- | Space stuck in "Paused" | Rare | Wait + retry, contact HF support |
89
- | Build timeout (30-45 min limit) | Possible | Optimize Dockerfile, cache layers |
90
- | GPU unavailable (resource contention) | Rare | Try again later, different hardware tier |
91
-
92
- **Sources**: [Space stuck at Starting (Nov 2025)](https://discuss.huggingface.co/t/hf-space-stuck-at-starting/170911), [Space stuck in Paused (Oct 2025)](https://discuss.huggingface.co/t/space-stuck-in-paused/169467)
93
-
94
- ### pre-demo warm-up procedure
95
-
96
- ```bash
97
- # 20-30 minutes before your demo:
98
-
99
- # 1. Restart the Space
100
- python -c "
101
- from huggingface_hub import HfApi
102
- api = HfApi()
103
- api.restart_space('YOUR_USERNAME/stroke-deepisles-demo')
104
- print('Space restart initiated...')
105
- "
106
-
107
- # 2. Monitor status (check every 2 min)
108
- python -c "
109
- from huggingface_hub import HfApi
110
- api = HfApi()
111
- info = api.space_info('YOUR_USERNAME/stroke-deepisles-demo')
112
- print(f'Status: {info.runtime.stage}') # Should be 'RUNNING'
113
- "
114
-
115
- # 3. Only proceed when status = RUNNING
116
- ```
117
-
118
- ### contingency plan if cold start fails
119
-
120
- 1. **Space stuck in "Starting" > 30 min**:
121
- - Try "Factory rebuild" from Space Settings
122
- - If still stuck, contact HF support via [Discord](https://discord.gg/hugging-face-879548962464493619)
123
-
124
- 2. **Demo starts before Space is ready**:
125
- - Show local demo on your NVIDIA GPU machine instead
126
- - "Let me show you on my development machine while the cloud version warms up"
127
-
128
- 3. **GPU unavailable error**:
129
- - Try `a10g-small` instead of `t4-small` (different GPU pool)
130
- - Wait 15 minutes and retry
131
-
132
- ---
133
-
134
- ## zerogpu: why it doesn't work for us
135
-
136
- ZeroGPU offers free, dynamic GPU allocation on H200 GPUs. However:
137
-
138
- | Requirement | ZeroGPU | Our Need |
139
- |-------------|---------|----------|
140
- | SDK Support | Gradio SDK only | Docker SDK (for DeepISLES container) |
141
- | Docker containers | ❌ NOT supported | ✅ Required |
142
- | Custom CUDA environment | ❌ NOT supported | ✅ Required (nnU-Net) |
143
-
144
- **Source**: [ZeroGPU Documentation](https://huggingface.co/docs/hub/en/spaces-zerogpu), [Community request for Docker support](https://huggingface.co/spaces/zero-gpu-explorers/README/discussions/27)
145
-
146
- **Verdict**: ZeroGPU is incompatible with DeepISLES. We must use Docker SDK + paid GPU hardware.
147
-
148
- ---
149
-
150
- ## hugging face spaces constraints
151
-
152
- ### sdk options
153
-
154
- | SDK | Use Case | Docker Access | GPU Support |
155
- |-----|----------|---------------|-------------|
156
- | `gradio` | Standard Gradio apps | **NO** | Via hardware upgrade |
157
- | `docker` | Custom containers | **YES** | Via hardware upgrade |
158
- | `static` | HTML/JS only | **NO** | N/A |
159
-
160
- **Key insight**: The Gradio SDK **cannot run Docker containers**. Our pipeline requires the DeepISLES Docker image, creating a fundamental incompatibility.
161
-
162
- ### hardware tiers
163
-
164
- | Tier | vCPU | RAM | Cost | GPU |
165
- |------|------|-----|------|-----|
166
- | cpu-basic (free) | 2 | 16GB | $0 | None |
167
- | cpu-upgrade | 8 | 32GB | $0.03/hr | None |
168
- | t4-small | 4 | 15GB | $0.40/hr | T4 (16GB) |
169
- | t4-medium | 8 | 30GB | $0.60/hr | T4 (16GB) |
170
- | a10g-small | 4 | 15GB | $1.05/hr | A10G (24GB) |
171
- | a10g-large | 12 | 46GB | $3.15/hr | A10G (24GB) |
172
-
173
- **Source**: [Hugging Face Spaces GPU Upgrades](https://huggingface.co/docs/hub/spaces)
174
-
175
- ### storage limits
176
-
177
- | Type | Limit | Behavior |
178
- |------|-------|----------|
179
- | Ephemeral (root fs) | 50GB | Lost on restart |
180
- | Persistent (`/data`) | 20GB-1TB | Paid tiers ($5-$100/mo) |
181
- | Build cache | Varies | Can cause "storage limit exceeded" |
182
-
183
- **Best practice**: Set `HF_HOME=/data/.huggingface` to cache models in persistent storage.
184
-
185
- > ⚠️ **Important**: `HF_HOME` must be set in the Space's **Settings → Repository secrets** UI, not just in code. Environment variables set only in Python code won't persist across container restarts.
186
-
187
- **Source**: [Spaces Persistent Storage](https://huggingface.co/docs/hub/en/spaces-storage)
188
-
189
- ### build limits
190
-
191
- | Limit | Value | Notes |
192
- |-------|-------|-------|
193
- | Build timeout | 30-45 minutes | Large dependencies may fail |
194
- | Build cache | Part of 50GB ephemeral | Can cause "storage limit exceeded" |
195
- | Startup timeout | 30 minutes (default) | Configurable via `startup_duration_timeout` |
196
- | Idle sleep | 48 hours | Free Spaces sleep after inactivity |
197
-
198
- **Warning**: Heavy scientific stacks (PyTorch, large C extensions) may hit build timeout. Monitor build logs closely.
199
-
200
- ---
201
-
202
- ## gradio 6 constraints (december 2025)
203
-
204
- > **Note**: Gradio 6.0 was released in late November 2025. Our codebase was written for Gradio 5.x but is largely compatible.
205
-
206
- ### key breaking changes from gradio 5 → 6
207
-
208
- | Change | Gradio 5.x | Gradio 6.x | Our Status |
209
- |--------|------------|------------|------------|
210
- | Theme/CSS/JS placement | `gr.Blocks(theme=..., css=..., js=...)` | `demo.launch(theme=..., css=..., js=...)` | ✅ Already correct in `app.py:170` |
211
- | HTML padding default | `padding=True` | `padding=False` | ⚠️ Minor visual change |
212
- | Chatbot message format | Tuple `[["user", "bot"]]` | Dict `{"role": ..., "content": ...}` | N/A - Not used |
213
- | `show_api` parameter | `show_api=True/False` | `footer_links=["api", "gradio", "settings"]` | N/A - Not customized |
214
- | Event `api_name=False` | `api_name=False` | `api_visibility="private"` | N/A - Not used |
215
-
216
- ### new in gradio 6
217
-
218
- 1. **Custom Web Components**: Write custom components in pure HTML/JS inline in Python via `gradio cc`
219
- 2. **Vibe Mode**: `gradio --vibe app.py` for AI-assisted app editing
220
- 3. **Performance**: Significantly lighter and faster
221
- 4. **Security**: Trail of Bits audit improvements carried forward
222
- 5. **Server-Side Rendering (SSR)**: Faster initial loads, better SEO
223
-
224
- > ⚠️ **SSR Consideration**: With SSR enabled, JavaScript that references `window` or `document` may fail during server-side render. Ensure NiiVue initialization checks `typeof window !== 'undefined'` before accessing browser APIs.
225
-
226
- ### javascript execution in `gr.HTML`
227
-
228
- **CRITICAL ISSUE**: The `gr.HTML` component does **not** execute JavaScript in `<script>` tags in the standard way.
229
-
230
- #### current implementation (viewer.py:262-324)
231
-
232
- ```python
233
- def create_niivue_html(...) -> str:
234
- return f"""
235
- <div style="width:100%; height:{height}px; ...">
236
- <canvas id="niivue-canvas" style="width:100%; height:100%;"></canvas>
237
- </div>
238
- <script type="module">
239
- const niivueModule = await import('https://unpkg.com/@niivue/niivue@0.65.0/dist/index.js');
240
- // ... NiiVue initialization
241
- </script>
242
- """
243
- ```
244
-
245
- #### the problem
246
-
247
- From the [Gradio documentation](https://www.gradio.app/guides/custom-CSS-and-JS) and [HF Forums](https://discuss.huggingface.co/t/gradio-html-component-with-javascript-code-dont-work/37316):
248
-
249
- > "The `gr.HTML` component doesn't support loading scripts via traditional `<script>` tags. This prevents JavaScript functions from being accessible to inline event handlers."
250
-
251
- #### recommended fix
252
-
253
- Use `gr.Blocks(js=...)` or `demo.load(_js=...)` to inject JavaScript:
254
-
255
- ```python
256
- NIIVUE_INIT_JS = """
257
- async () => {
258
- // Wait for NiiVue module to load
259
- const niivueModule = await import('https://unpkg.com/@niivue/niivue@0.65.0/dist/index.js');
260
- globalThis.Niivue = niivueModule.Niivue;
261
- }
262
- """
263
-
264
- def create_app() -> gr.Blocks:
265
- with gr.Blocks(js=NIIVUE_INIT_JS) as demo:
266
- # ... components
267
-
268
- return demo
269
- ```
270
-
271
- Then in the HTML component, reference the global:
272
-
273
- ```python
274
- def create_niivue_html(volume_url: str, ...) -> str:
275
- return f"""
276
- <div id="niivue-container-{uuid}" style="...">
277
- <canvas id="niivue-canvas-{uuid}"></canvas>
278
- </div>
279
- <script>
280
- (async function() {{
281
- if (typeof globalThis.Niivue === 'undefined') {{
282
- console.error('NiiVue not loaded');
283
- return;
284
- }}
285
- const nv = new globalThis.Niivue({{...}});
286
- await nv.attachTo('niivue-canvas-{uuid}');
287
- // ...
288
- }})();
289
- </script>
290
- """
291
- ```
292
-
293
- **Note**: Even this may not work reliably. Testing on HF Spaces is required.
294
-
295
- #### alternative: gradio custom components (`gradio cc`)
296
-
297
- For production deployments, Gradio 6 supports first-class **Custom Components** via the `gradio cc` CLI. This is the recommended "production" solution (vs. the `js=` hack for MVP).
298
-
299
- ```bash
300
- # Create a NiiVue custom component
301
- gradio cc create NiiVueViewer --template HTML
302
-
303
- # Development server with hot reload
304
- gradio cc dev
305
-
306
- # Build for distribution
307
- gradio cc build
308
-
309
- # Publish to PyPI and HF Spaces
310
- gradio cc publish
311
- ```
312
-
313
- **Pros**:
314
- - First-class support, proper state management
315
- - No hacky string interpolation
316
- - Reusable across projects
317
-
318
- **Cons**:
319
- - Requires Node.js build step
320
- - Higher complexity than `js=` parameter
321
- - Overkill for MVP
322
-
323
- **Source**: [Custom Components In Five Minutes](https://www.gradio.app/guides/custom-components-in-five-minutes)
324
-
325
- #### alternative: `gradio-iframe` component
326
-
327
- The [`gradio-iframe`](https://pypi.org/project/gradio-iframe/) package (v0.0.10) provides an iframe component that may execute JavaScript more reliably:
328
-
329
- ```python
330
- from gradio_iframe import iFrame
331
-
332
- viewer = iFrame(
333
- value="<html>...NiiVue code...</html>",
334
- label="NiiVue Viewer"
335
- )
336
- ```
337
-
338
- **Warning**: This is experimental and "not fully tested" per the maintainer. Use with caution.
339
-
340
- ### css restrictions
341
-
342
- Custom CSS should use `elem_id` and `elem_classes` rather than query selectors:
343
-
344
- > "The use of query selectors in custom JS and CSS is not guaranteed to work across Gradio versions as the Gradio HTML DOM may change."
345
-
346
- **Source**: [Custom CSS and JS Guide](https://www.gradio.app/guides/custom-CSS-and-JS)
347
-
348
- ### security (gradio 5 audit, inherited by v6)
349
-
350
- The Trail of Bits security audit was performed on **Gradio 5.0**. All fixes are inherited by Gradio 6.x:
351
-
352
- - **CVE-2024-47872**: XSS via HTML/JS/SVG file uploads (fixed in 5.0.0)
353
- - File type restrictions enforced server-side
354
- - Our app uses `gradio>=6.0.0` - we're covered
355
-
356
- > **Note**: There was no separate Gradio 6 audit. The security improvements from Gradio 5 persist in v6.
357
-
358
- **Source**: [A Security Review of Gradio 5](https://huggingface.co/blog/gradio-5-security)
359
-
360
- ---
361
-
362
- ## readme.md yaml configuration
363
-
364
- ### required fields for gradio spaces
365
-
366
- ```yaml
367
- ---
368
- title: Stroke DeepISLES Demo
369
- emoji: 🧠
370
- colorFrom: blue
371
- colorTo: purple
372
- sdk: gradio
373
- sdk_version: "6.0.2" # Latest stable as of Dec 2025
374
- python_version: "3.11"
375
- app_file: app.py
376
- pinned: false
377
- license: mit
378
- short_description: "Ischemic stroke lesion segmentation using DeepISLES"
379
-
380
- # Optional but recommended
381
- models:
382
- - isleschallenge/deepisles # If we reference it
383
- datasets:
384
- - YongchengYAO/ISLES24-MR-Lite
385
- tags:
386
- - medical-imaging
387
- - stroke
388
- - segmentation
389
- - neuroimaging
390
- - niivue
391
-
392
- # For CPU-only demo mode
393
- suggested_hardware: cpu-basic
394
-
395
- # If we need cross-origin isolation (e.g., SharedArrayBuffer)
396
- # custom_headers:
397
- # cross-origin-embedder-policy: require-corp
398
- # cross-origin-opener-policy: same-origin
399
- ---
400
- ```
401
-
402
- ### configuration reference
403
-
404
- | Field | Type | Description |
405
- |-------|------|-------------|
406
- | `sdk` | string | `gradio`, `docker`, or `static` |
407
- | `sdk_version` | string | Gradio version (e.g., "5.0.0") |
408
- | `python_version` | string | Python version (e.g., "3.11") |
409
- | `app_file` | string | Entry point (default: `app.py`) |
410
- | `suggested_hardware` | string | Hardware for duplicators |
411
- | `disable_embedding` | bool | Prevent iframe embedding |
412
- | `custom_headers` | dict | COEP/COOP/CORP headers |
413
-
414
- **Source**: [Spaces Configuration Reference](https://huggingface.co/docs/hub/en/spaces-config-reference)
415
-
416
- ---
417
-
418
- ## dependencies
419
-
420
- ### requirements.txt for hf spaces
421
-
422
- HF Spaces uses `requirements.txt`, not `pyproject.toml` for dependency installation.
423
-
424
- ```text
425
- # requirements.txt for HF Spaces
426
-
427
- # Core - Tobias's fork with BIDS + NIfTI lazy loading
428
- git+https://github.com/CloseChoice/datasets.git@feat/bids-loader-streaming-upload-fix
429
-
430
- # HuggingFace
431
- huggingface-hub>=0.25.0
432
-
433
- # NIfTI handling
434
- nibabel>=5.2.0
435
- numpy>=1.26.0
436
-
437
- # Configuration
438
- pydantic>=2.5.0
439
- pydantic-settings>=2.1.0
440
-
441
- # UI - Gradio 6.x (latest stable as of Dec 2025)
442
- gradio>=6.0.0,<7.0.0
443
- matplotlib>=3.8.0
444
-
445
- # Networking
446
- requests>=2.0.0
447
- ```
448
-
449
- ### potential issues
450
-
451
- 1. **Git dependencies**: HF Spaces supports `git+https://...` in requirements.txt
452
- 2. **C extensions**: nibabel/numpy compile fine on HF Spaces
453
- 3. **Size**: No bloated dependencies (no PyTorch required for demo mode)
454
-
455
- ---
456
-
457
- ## deployment paths
458
-
459
- ### hardware requirements
460
-
461
- | Component | Requirement | Notes |
462
- |-----------|-------------|-------|
463
- | GPU | NVIDIA with CUDA 11.3+ | **Mandatory** - no CPU/MPS fallback |
464
- | VRAM | 4GB minimum, 12GB+ recommended | For parallel processing |
465
- | Docker | Docker + nvidia-container-toolkit | Required for DeepISLES |
466
- | Python | 3.8+ (3.11 recommended) | Per project config |
467
-
468
- > ⚠️ **Apple Silicon (M1/M2/M3) is NOT supported.** DeepISLES requires NVIDIA CUDA.
469
-
470
- ### path 1: local nvidia gpu (primary development)
471
-
472
- For day-to-day development and testing on your own NVIDIA GPU machine.
473
-
474
- ```bash
475
- # 1. Ensure Docker + nvidia-container-toolkit installed
476
- docker run --rm --gpus all nvidia/cuda:11.3-base nvidia-smi
477
-
478
- # 2. Pull DeepISLES image
479
- docker pull isleschallenge/deepisles
480
-
481
- # 3. Run the app
482
- uv run python -m stroke_deepisles_demo.ui.app
483
- ```
484
-
485
- **Pros**:
486
- - Free (you own the hardware)
487
- - Fast iteration
488
- - No network dependency
489
-
490
- **Cons**:
491
- - Requires NVIDIA GPU hardware
492
-
493
- ### path 2: hf spaces docker sdk + gpu (on-demand demos)
494
-
495
- For showcasing to others. Spin up when needed, pause when done.
496
-
497
- #### dockerfile for hf spaces
498
-
499
- ```dockerfile
500
- # Dockerfile for HF Spaces
501
- # CRITICAL: DeepISLES code lives at /app/src/ in the base image.
502
- # We install our demo at /home/user/demo to avoid overwriting DeepISLES.
503
- FROM isleschallenge/deepisles:latest
504
-
505
- # HF Spaces runs containers with user ID 1000
506
- RUN useradd -m -u 1000 user 2>/dev/null || true
507
-
508
- # IMPORTANT: Use /home/user/demo for our app, NOT /app
509
- WORKDIR /home/user/demo
510
-
511
- # Add our application
512
- COPY --chown=1000:1000 requirements.txt /home/user/demo/requirements.txt
513
- RUN pip install --no-cache-dir -r requirements.txt
514
-
515
- COPY --chown=1000:1000 pyproject.toml /home/user/demo/pyproject.toml
516
- COPY --chown=1000:1000 src/ /home/user/demo/src/
517
- COPY --chown=1000:1000 app.py /home/user/demo/app.py
518
- RUN pip install --no-cache-dir --no-deps -e .
519
-
520
- # Environment variables for HF Spaces + direct invocation
521
- ENV HF_SPACES=1
522
- ENV DEEPISLES_DIRECT_INVOCATION=1
523
- ENV DEEPISLES_PATH=/app
524
-
525
- USER user
526
- EXPOSE 7860
527
- ENTRYPOINT []
528
- CMD ["python", "-m", "stroke_deepisles_demo.ui.app"]
529
- ```
530
-
531
- #### readme.md configuration
532
-
533
- ```yaml
534
- ---
535
- title: Stroke DeepISLES Demo
536
- emoji: 🧠
537
- colorFrom: blue
538
- colorTo: purple
539
- sdk: docker
540
- app_port: 7860
541
- suggested_hardware: t4-small
542
- pinned: false
543
- license: mit
544
- ---
545
- ```
546
-
547
- #### cost management: pause/restart api
548
-
549
- ```python
550
- from huggingface_hub import HfApi
551
-
552
- api = HfApi()
553
- SPACE_ID = "your-username/stroke-deepisles-demo"
554
-
555
- # PAUSE - stops billing immediately
556
- api.pause_space(SPACE_ID)
557
-
558
- # RESTART - spin up for demo
559
- api.restart_space(SPACE_ID)
560
-
561
- # AUTO-SLEEP after 30 min inactivity
562
- api.set_space_sleep_time(SPACE_ID, sleep_time=1800)
563
- ```
564
-
565
- #### billing breakdown
566
-
567
- | State | Billed? | How to Enter |
568
- |-------|---------|--------------|
569
- | Running | ✅ $0.40/hr (T4) | `restart_space()` or visitor wakes it |
570
- | Sleeping | ❌ $0 | Auto after `sleep_time` inactivity |
571
- | Paused | ❌ $0 | `pause_space()` - only owner can restart |
572
-
573
- **Typical demo session**: 30-60 minutes = **$0.20-$0.40**
574
-
575
- **Monthly cost if paused**: **$0.00**
576
-
577
- ---
578
-
579
- ## niivue integration analysis
580
-
581
- ### current implementation
582
-
583
- Our viewer uses NiiVue loaded from unpkg CDN with base64 data URLs:
584
-
585
- ```python
586
- # viewer.py:289-324
587
- return f"""
588
- <div style="width:100%; height:{height}px; ...">
589
- <canvas id="niivue-canvas" style="width:100%; height:100%;"></canvas>
590
- </div>
591
- <script type="module">
592
- const niivueModule = await import('https://unpkg.com/@niivue/niivue@0.65.0/dist/index.js');
593
- const Niivue = niivueModule.Niivue;
594
- // ...
595
- await nv.loadVolumes(volumes);
596
- </script>
597
- """
598
- ```
599
-
600
- ### potential issues
601
-
602
- 1. **Script execution**: `<script type="module">` may not execute in `gr.HTML`
603
- 2. **Canvas element IDs**: Hardcoded `id="niivue-canvas"` will conflict if multiple viewers
604
- 3. **CSP headers**: External CDN might be blocked by Content Security Policy
605
- 4. **Memory**: Base64 NIfTI files loaded entirely into browser memory
606
-
607
- ### recommended fixes
608
-
609
- ```python
610
- import uuid
611
-
612
- def create_niivue_html(volume_url: str, mask_url: str | None = None, *, height: int = 400) -> str:
613
- """Create HTML/JS for NiiVue viewer with unique IDs."""
614
- canvas_id = f"niivue-canvas-{uuid.uuid4().hex[:8]}"
615
-
616
- # ... rest of implementation with unique canvas_id
617
- ```
618
-
619
- ### webgl compatibility
620
-
621
- NiiVue requires WebGL2. Most modern browsers support it, but:
622
-
623
- - HF Spaces renders in iframes
624
- - Some iframe security policies restrict WebGL
625
- - Cross-origin isolation may be needed for SharedArrayBuffer
626
-
627
- **Test required**: Verify NiiVue WebGL works in HF Spaces iframe environment.
628
-
629
- ---
630
-
631
- ## memory and performance
632
-
633
- ### memory considerations
634
-
635
- | Resource | Size | Concern |
636
- |----------|------|---------|
637
- | DWI NIfTI (ISLES24-MR-Lite) | ~2-5 MB | Low |
638
- | Base64 encoded | ~3-7 MB | ~1.33x overhead |
639
- | Multiple volumes in browser | ~15-20 MB | Moderate |
640
- | Matplotlib figures | ~1-5 MB | Low |
641
- | Free tier RAM | 16 GB | Sufficient |
642
-
643
- ### optimization strategies
644
-
645
- 1. **Lazy loading**: Don't load all cases at startup
646
- 2. **Cleanup**: Clear matplotlib figures after rendering
647
- 3. **Pagination**: Limit case dropdown to reasonable number
648
- 4. **Compression**: NIfTI files are already gzipped
649
-
650
- ---
651
-
652
- ## testing checklist
653
-
654
- Before deploying to HF Spaces, verify:
655
-
656
- ### local testing
657
-
658
- - [ ] `uv run python app.py` launches without errors
659
- - [ ] Case dropdown populates
660
- - [ ] NiiVue viewer renders (in browser, not headless)
661
- - [ ] Matplotlib plots display correctly
662
- - [ ] No import-time side effects (network calls)
663
-
664
- ### hf spaces testing
665
-
666
- - [ ] Create private Space first
667
- - [ ] Verify dependencies install
668
- - [ ] Check JavaScript execution in `gr.HTML`
669
- - [ ] Test NiiVue WebGL rendering
670
- - [ ] Monitor memory usage
671
- - [ ] Test on mobile browsers (if applicable)
672
-
673
- ### known issues to monitor
674
-
675
- 1. **Startup timeout**: Default is 30 minutes, may need adjustment
676
- 2. **Sleep behavior**: Free Spaces sleep after 48h of inactivity
677
- 3. **Build cache**: May cause "storage limit exceeded"
678
-
679
- ---
680
-
681
- ## deployment procedure
682
-
683
- ### step 1: verify local nvidia gpu setup
684
-
685
- ```bash
686
- # Verify NVIDIA driver and Docker GPU support
687
- docker run --rm --gpus all nvidia/cuda:11.3-base nvidia-smi
688
-
689
- # Pull DeepISLES image
690
- docker pull isleschallenge/deepisles
691
-
692
- # Test local inference
693
- uv run stroke-demo run --case sub-stroke0001
694
- ```
695
-
696
- ### step 2: create dockerfile for hf spaces
697
-
698
- ```dockerfile
699
- # Dockerfile
700
- # CRITICAL: DeepISLES code lives at /app/src/ in the base image.
701
- # We install our demo at /home/user/demo to avoid overwriting DeepISLES.
702
- FROM isleschallenge/deepisles:latest
703
-
704
- # HF Spaces runs containers with user ID 1000
705
- RUN useradd -m -u 1000 user 2>/dev/null || true
706
-
707
- # IMPORTANT: Use /home/user/demo for our app, NOT /app
708
- WORKDIR /home/user/demo
709
-
710
- # Install additional dependencies
711
- COPY --chown=1000:1000 requirements.txt /home/user/demo/requirements.txt
712
- RUN pip install --no-cache-dir -r requirements.txt
713
-
714
- # Copy application code
715
- COPY --chown=1000:1000 pyproject.toml /home/user/demo/pyproject.toml
716
- COPY --chown=1000:1000 src/ /home/user/demo/src/
717
- COPY --chown=1000:1000 app.py /home/user/demo/app.py
718
- RUN pip install --no-cache-dir --no-deps -e .
719
-
720
- # Environment variables for HF Spaces + direct invocation
721
- ENV HF_SPACES=1
722
- ENV DEEPISLES_DIRECT_INVOCATION=1
723
- ENV DEEPISLES_PATH=/app
724
-
725
- USER user
726
- EXPOSE 7860
727
- ENTRYPOINT []
728
- CMD ["python", "-m", "stroke_deepisles_demo.ui.app"]
729
- ```
730
-
731
- ### step 3: create requirements.txt
732
-
733
- ```bash
734
- cat > requirements.txt << 'EOF'
735
- git+https://github.com/CloseChoice/datasets.git@feat/bids-loader-streaming-upload-fix
736
- huggingface-hub>=0.25.0
737
- nibabel>=5.2.0
738
- numpy>=1.26.0
739
- pydantic>=2.5.0
740
- pydantic-settings>=2.1.0
741
- gradio>=6.0.0,<7.0.0
742
- matplotlib>=3.8.0
743
- requests>=2.0.0
744
- EOF
745
- ```
746
-
747
- ### step 4: update readme.md for docker sdk
748
-
749
- ```yaml
750
- ---
751
- title: Stroke DeepISLES Demo
752
- emoji: 🧠
753
- colorFrom: blue
754
- colorTo: purple
755
- sdk: docker
756
- app_port: 7860
757
- suggested_hardware: t4-small
758
- pinned: false
759
- license: mit
760
- ---
761
- ```
762
-
763
- ### step 5: deploy to private space
764
-
765
- ```bash
766
- # Create Docker Space with GPU
767
- huggingface-cli repo create stroke-deepisles-demo --type space --sdk docker
768
-
769
- # Push code
770
- git remote add space https://huggingface.co/spaces/YOUR_USERNAME/stroke-deepisles-demo
771
- git push space main
772
- ```
773
-
774
- ### step 6: configure cost management
775
-
776
- ```python
777
- from huggingface_hub import HfApi
778
-
779
- api = HfApi()
780
- SPACE_ID = "YOUR_USERNAME/stroke-deepisles-demo"
781
-
782
- # Set auto-sleep after 30 min of inactivity
783
- api.set_space_sleep_time(SPACE_ID, sleep_time=1800)
784
-
785
- # After demo: pause to stop all billing
786
- api.pause_space(SPACE_ID)
787
-
788
- # Before next demo: restart
789
- api.restart_space(SPACE_ID)
790
- ```
791
-
792
- ### step 7: monitor and iterate
793
-
794
- - Check build logs (Docker builds can take 10-20 min)
795
- - Test inference end-to-end
796
- - Verify NiiVue visualization works
797
- - Pause Space when done testing
798
-
799
- ---
800
-
801
- ## decision matrix
802
-
803
- | Approach | Real Inference | Cost | Complexity | Use Case |
804
- |----------|----------------|------|------------|----------|
805
- | Local NVIDIA GPU | ✅ | $0 | Low | **Primary development** |
806
- | HF Spaces Docker + GPU (on-demand) | ✅ | ~$0.40/demo | Medium | **Showcasing to others** |
807
- | ~~Demo Mode (pre-computed)~~ | ❌ Fake | $0 | Low | ~~Rejected - defeats purpose~~ |
808
- | ~~HF Spaces Gradio SDK (free)~~ | ❌ No Docker | $0 | Low | ~~Cannot run DeepISLES~~ |
809
- | ~~ZeroGPU (free H200)~~ | ❌ No Docker | $0 | Low | ~~Only supports Gradio SDK~~ |
810
-
811
- ---
812
-
813
- ## sources
814
-
815
- ### official documentation
816
- - [Gradio Spaces](https://huggingface.co/docs/hub/en/spaces-sdks-gradio)
817
- - [Gradio 6 Migration Guide](https://www.gradio.app/main/guides/gradio-6-migration-guide)
818
- - [Custom CSS and JS](https://www.gradio.app/guides/custom-CSS-and-JS)
819
- - [Custom Components In Five Minutes](https://www.gradio.app/guides/custom-components-in-five-minutes)
820
- - [Spaces Configuration Reference](https://huggingface.co/docs/hub/en/spaces-config-reference)
821
- - [Spaces Persistent Storage](https://huggingface.co/docs/hub/en/spaces-storage)
822
- - [Manage Spaces - HF Hub](https://huggingface.co/docs/huggingface_hub/main/en/guides/manage-spaces)
823
- - [A Security Review of Gradio 5](https://huggingface.co/blog/gradio-5-security)
824
- - [Trail of Bits Gradio Audit](https://blog.trailofbits.com/2024/10/10/auditing-gradio-5-hugging-faces-ml-gui-framework/)
825
- - [Docker Spaces](https://huggingface.co/docs/hub/spaces-sdks-docker)
826
- - [ZeroGPU Documentation](https://huggingface.co/docs/hub/en/spaces-zerogpu)
827
-
828
- ### forum discussions (cold start verification)
829
- - [Slow Space Cold Boot](https://discuss.huggingface.co/t/slow-space-cold-boot/72154) - 2 min baseline confirmed
830
- - [T4 startup taking 45+ minutes](https://discuss.huggingface.co/t/staring-up-t4-instances-is-taking-45-minutes/139567) - Feb 2025 resource issues
831
- - [Space stuck at Starting](https://discuss.huggingface.co/t/hf-space-stuck-at-starting/170911) - Nov 2025 edge case
832
- - [Space stuck in Paused](https://discuss.huggingface.co/t/space-stuck-in-paused/169467) - Oct 2025 edge case
833
- - [ZeroGPU Docker request](https://huggingface.co/spaces/zero-gpu-explorers/README/discussions/27) - Community asking for Docker support
834
- - [Gradio HTML component with javascript code don't work](https://discuss.huggingface.co/t/gradio-html-component-with-javascript-code-dont-work/37316)
835
-
836
- ### packages
837
- - [NiiVue npm package](https://www.npmjs.com/package/@niivue/niivue) - v0.65.0 (latest as of Dec 2025)
838
- - [gradio-iframe PyPI](https://pypi.org/project/gradio-iframe/) - v0.0.10 (experimental)
839
- - [DeepISLES Docker Hub](https://hub.docker.com/r/isleschallenge/deepisles)
840
-
841
- ---
842
-
843
- ## appendix: friction points summary
844
-
845
- ### high priority (must fix before deployment)
846
-
847
- 1. **JavaScript execution in `gr.HTML`**
848
- - Current: `<script type="module">` embedded in HTML string
849
- - Risk: May not execute at all
850
- - Fix: Use `gr.Blocks(js=...)` or `demo.load(_js=...)`
851
- - Testing: Required on actual HF Spaces environment
852
-
853
- 2. **Docker + GPU requirement**
854
- - Current: Pipeline requires `isleschallenge/deepisles` container with NVIDIA GPU
855
- - Risk: Gradio SDK cannot run Docker; Apple Silicon not supported
856
- - Fix: Use Docker SDK with GPU hardware (on-demand billing)
857
-
858
- ### medium priority (should fix)
859
-
860
- 3. **Unique canvas IDs**
861
- - Current: Hardcoded `id="niivue-canvas"`
862
- - Risk: Multiple viewers would conflict
863
- - Fix: Generate unique IDs with UUID
864
-
865
- 4. **Git dependency in requirements**
866
- - Current: `datasets @ git+https://...` in pyproject.toml
867
- - Risk: HF Spaces uses requirements.txt
868
- - Fix: Create requirements.txt with git URL
869
-
870
- ### low priority (nice to have)
871
-
872
- 5. **Memory optimization**
873
- - Current: Full NIfTI files in base64
874
- - Risk: Could hit memory limits on complex cases
875
- - Fix: Implement streaming or pagination
876
-
877
- 6. **CDN reliability**
878
- - Current: NiiVue from unpkg.com
879
- - Risk: CDN downtime affects app
880
- - Fix: Consider bundling or alternative CDN
881
-
882
- ---
883
-
884
- ## appendix: operational runbook
885
-
886
- ### daily operations
887
-
888
- **After development session:**
889
- ```bash
890
- # Always pause to stop billing
891
- python -c "
892
- from huggingface_hub import HfApi
893
- api = HfApi()
894
- api.pause_space('YOUR_USERNAME/stroke-deepisles-demo')
895
- print('Space paused - billing stopped')
896
- "
897
- ```
898
-
899
- **Before scheduled demo:**
900
- ```bash
901
- # T-30 minutes: Start warm-up
902
- python -c "
903
- from huggingface_hub import HfApi
904
- api = HfApi()
905
- api.restart_space('YOUR_USERNAME/stroke-deepisles-demo')
906
- print('Warming up... check status in 5 min')
907
- "
908
-
909
- # T-25, T-20, T-15, T-10, T-5 minutes: Check status
910
- python -c "
911
- from huggingface_hub import HfApi
912
- api = HfApi()
913
- info = api.space_info('YOUR_USERNAME/stroke-deepisles-demo')
914
- print(f'Status: {info.runtime.stage}')
915
- # BUILDING -> Wait
916
- # RUNNING_BUILDING -> Almost ready
917
- # RUNNING -> Ready to demo!
918
- "
919
- ```
920
-
921
- **After demo:**
922
- ```bash
923
- # Immediately pause to stop billing
924
- python -c "
925
- from huggingface_hub import HfApi
926
- api = HfApi()
927
- api.pause_space('YOUR_USERNAME/stroke-deepisles-demo')
928
- print('Demo complete - billing stopped')
929
- "
930
- ```
931
-
932
- ### troubleshooting
933
-
934
- | Symptom | Diagnosis | Resolution |
935
- |---------|-----------|------------|
936
- | Status stuck on "BUILDING" > 45 min | Build timeout | Check build logs, optimize Dockerfile |
937
- | Status stuck on "STARTING" > 30 min | Resource issue | Factory rebuild, or try different hardware |
938
- | Status stuck on "PAUSED" after restart | API issue | Wait 5 min, retry, or use UI |
939
- | "Scheduling failure" error | GPU unavailable | Try later or different hardware tier |
940
- | "Storage limit exceeded" | Build cache full | Clear cache, reduce image layers |
941
-
942
- ### cost tracking
943
-
944
- ```bash
945
- # Check current month's usage
946
- # Visit: https://huggingface.co/settings/billing
947
-
948
- # Estimate cost per demo:
949
- # T4-small: $0.40/hr × 0.5 hr = $0.20 per 30-min demo
950
- # T4-medium: $0.60/hr × 0.5 hr = $0.30 per 30-min demo
951
- # A10G-small: $1.05/hr × 0.5 hr = $0.53 per 30-min demo
952
- ```
953
-
954
- ---
955
-
956
- ## next steps
957
-
958
- > **Status**: Spec APPROVED - Ready for implementation
959
-
960
- 1. ~~Senior Review: Get approval on this spec~~ ✅ **APPROVED**
961
- 2. **Local Testing**: Verify full pipeline on local NVIDIA GPU machine
962
- 3. **Fix JavaScript Pattern**: Refactor NiiVue initialization for `gr.HTML`
963
- 4. **Create Dockerfile**: Build HF Spaces Docker image based on DeepISLES
964
- 5. **Create requirements.txt**: Generate from pyproject.toml
965
- 6. **Deploy to Private Space**: Test Docker SDK + GPU on HF Spaces
966
- 7. **Configure Auto-Sleep**: Set `sleep_time=1800` (30 min) to minimize costs
967
- 8. **Pre-Demo Test**: Practice warm-up procedure (20-30 min cold start)
968
- 9. **Demo & Pause**: Show to stakeholders, then `pause_space()` to stop billing
969
- 10. **Public Release**: Make Space public when stable (keep paused when not demoing)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/archive/08-bug-hf-spaces-dataset-loop.md DELETED
@@ -1,239 +0,0 @@
1
- # Bug Spec: HuggingFace Spaces Dataset Loading Issues
2
-
3
- **Status:** Root Causes Identified → Comprehensive Fix Ready
4
- **Priority:** P0 (Blocks deployment)
5
- **Branch:** `fix/pipeline-resource-leak`
6
- **Date:** 2025-12-08
7
- **Updated:** 2025-12-08
8
-
9
- ## Executive Summary
10
-
11
- Two distinct bugs prevent the HuggingFace Spaces deployment from working:
12
-
13
- | Bug | Symptom | Root Cause | Impact | Fix |
14
- |-----|---------|------------|--------|-----|
15
- | **#1** | Dropdown never populates | PyArrow streaming bug | App hangs at startup | Pre-computed case IDs |
16
- | **#2** | OOM on case selection | `load_dataset()` downloads 99GB | App crashes on first use | HfFileSystem + pyarrow |
17
-
18
- Both bugs stem from fundamental incompatibilities between the `datasets` library and our 99GB parquet dataset on resource-constrained HF Spaces hardware.
19
-
20
- ---
21
-
22
- ## Bug #1: Streaming Iteration Hang
23
-
24
- ### Summary
25
-
26
- The dropdown never populates because `load_dataset(..., streaming=True)` hangs indefinitely on parquet datasets. This is a **known PyArrow bug**, not a HuggingFace datasets bug.
27
-
28
- ### The Bug Chain
29
-
30
- 1. **Our code** calls `load_dataset("hugging-science/isles24-stroke", streaming=True)`
31
- 2. **HF datasets** internally uses `ParquetFileFragment.to_batches()` for streaming
32
- 3. **PyArrow** hangs when iterating batches from parquet with partial consumption
33
- 4. **Result:** Script hangs forever, never returns case IDs
34
-
35
- ### Upstream Issues
36
-
37
- - **PyArrow Issue:** [apache/arrow#45214](https://github.com/apache/arrow/issues/45214) - Root cause
38
- - **HF Datasets Issue:** [huggingface/datasets#7467](https://github.com/huggingface/datasets/issues/7467) - HF tracking
39
- - **Status:** Open, no fix ETA
40
- - **Maintainer:** @lhoestq (HF datasets core dev) correctly escalated to PyArrow team
41
-
42
- ### Minimal Reproduction (Pure PyArrow, no HF)
43
-
44
- ```python
45
- import pyarrow.dataset as ds
46
-
47
- file = "test-00000-of-00003.parquet"
48
- with open(file, "rb") as f:
49
- parquet_fragment = ds.ParquetFileFormat().make_fragment(f)
50
- for record_batch in parquet_fragment.to_batches():
51
- print(len(record_batch))
52
- break # ← Partial consumption causes hang
53
- # Script hangs here forever
54
- ```
55
-
56
- This proves the bug is in **PyArrow's C++ layer**, not HuggingFace datasets.
57
-
58
- ### Fix: Pre-computed Case ID List
59
-
60
- **Why this is professional, not hacky:**
61
-
62
- 1. **ISLES24 is a static challenge dataset** - case IDs will never change
63
- 2. **Industry standard** - many production ML systems pre-define dataset indices
64
- 3. **Zero startup latency** - dropdown populates instantly
65
- 4. **No network dependency** - works offline for dropdown population
66
- 5. **Bypasses upstream bug** - doesn't depend on PyArrow fix timeline
67
-
68
- ---
69
-
70
- ## Bug #2: Full Dataset OOM on Case Access
71
-
72
- ### Summary
73
-
74
- Even after fixing Bug #1, the application would crash immediately upon selecting a case. The current `get_case()` implementation calls:
75
-
76
- ```python
77
- # adapter.py:213
78
- self._hf_dataset = load_dataset(self.dataset_id, split="train")
79
- ```
80
-
81
- This attempts to download the **entire 99GB dataset** into memory, which OOMs on HF Spaces.
82
-
83
- ### Why This Wasn't Caught
84
-
85
- The bug document initially focused on the dropdown hang (Bug #1). Bug #2 would only manifest after Bug #1 was fixed and a user actually selected a case.
86
-
87
- ### Investigation Results
88
-
89
- | Approach | Result | Time | Memory |
90
- |----------|--------|------|--------|
91
- | `load_dataset(..., streaming=True)` | **HANGS** | ∞ | N/A |
92
- | `load_dataset(...)` (full download) | **OOMs** | ~10 min | 99GB+ |
93
- | `HfFileSystem` + `pyarrow` (single file) | **WORKS** | 1.7s | ~50MB |
94
-
95
- ### Dataset Structure Discovery
96
-
97
- Critical finding: Each case is stored in a **separate parquet file**:
98
-
99
- - **149 parquet files** named `train-00000-of-00149.parquet` through `train-00148-of-00149.parquet`
100
- - **Each file = one case** (~600-700MB raw data per case)
101
- - **Schema:** `subject_id`, `dwi`, `adc`, `lesion_mask` (NIfTI bytes stored as binary)
102
-
103
- This means we can **directly access individual cases** without loading the full dataset!
104
-
105
- ### Fix: Direct Parquet Access via HfFileSystem
106
-
107
- ```python
108
- from huggingface_hub import HfFileSystem
109
- import pyarrow.parquet as pq
110
-
111
- fs = HfFileSystem()
112
- fpath = f"datasets/{dataset_id}/data/train-{idx:05d}-of-00149.parquet"
113
-
114
- with fs.open(fpath, 'rb') as f:
115
- pf = pq.ParquetFile(f)
116
- table = pf.read(columns=['subject_id', 'dwi', 'adc', 'lesion_mask'])
117
- # Extract ~50MB for one case in ~2 seconds
118
- ```
119
-
120
- **Benefits:**
121
- - Downloads only the single case needed (~50MB vs 99GB)
122
- - Completes in 1.7 seconds (vs hanging or OOM)
123
- - No dependency on `datasets` library for data access
124
- - Bypasses both PyArrow streaming bug and memory constraints
125
-
126
- ---
127
-
128
- ## Comprehensive Fix Implementation
129
-
130
- ### 1. Create `constants.py` with case ID → file index mapping
131
-
132
- ```python
133
- # src/stroke_deepisles_demo/data/constants.py
134
-
135
- # Pre-computed case IDs for ISLES24 dataset (static challenge dataset)
136
- # Extracted via HfFileSystem enumeration on 2025-12-08
137
- ISLES24_CASE_IDS: tuple[str, ...] = (
138
- "sub-stroke0001", "sub-stroke0002", ..., "sub-stroke0189"
139
- )
140
-
141
- # Mapping from case ID to parquet file index (0-indexed)
142
- ISLES24_CASE_INDEX: dict[str, int] = {
143
- case_id: idx for idx, case_id in enumerate(ISLES24_CASE_IDS)
144
- }
145
- ```
146
-
147
- ### 2. Rewrite `HuggingFaceDataset.get_case()` to use HfFileSystem
148
-
149
- Replace `load_dataset()` call with direct parquet access:
150
-
151
- ```python
152
- def get_case(self, case_id: str | int) -> CaseFiles:
153
- from huggingface_hub import HfFileSystem
154
- import pyarrow.parquet as pq
155
-
156
- idx = self._case_index[case_id]
157
- fpath = f"datasets/{self.dataset_id}/data/train-{idx:05d}-of-00149.parquet"
158
-
159
- fs = HfFileSystem()
160
- with fs.open(fpath, 'rb') as f:
161
- table = pq.ParquetFile(f).read(columns=['dwi', 'adc', 'lesion_mask'])
162
- # Extract bytes and write to temp files...
163
- ```
164
-
165
- ### 3. Remove all `load_dataset()` calls from HuggingFace path
166
-
167
- The `datasets` library is completely bypassed for the HuggingFace workflow.
168
-
169
- ---
170
-
171
- ## All 149 Case IDs (Extracted via HfFileSystem)
172
-
173
- ```
174
- sub-stroke0001, sub-stroke0002, sub-stroke0003, sub-stroke0004, sub-stroke0005,
175
- sub-stroke0006, sub-stroke0007, sub-stroke0008, sub-stroke0009, sub-stroke0010,
176
- sub-stroke0011, sub-stroke0012, sub-stroke0013, sub-stroke0014, sub-stroke0015,
177
- sub-stroke0016, sub-stroke0017, sub-stroke0019, sub-stroke0020, sub-stroke0021,
178
- sub-stroke0022, sub-stroke0025, sub-stroke0026, sub-stroke0027, sub-stroke0028,
179
- sub-stroke0030, sub-stroke0033, sub-stroke0036, sub-stroke0037, sub-stroke0038,
180
- sub-stroke0040, sub-stroke0043, sub-stroke0045, sub-stroke0047, sub-stroke0048,
181
- sub-stroke0049, sub-stroke0052, sub-stroke0053, sub-stroke0054, sub-stroke0055,
182
- sub-stroke0057, sub-stroke0062, sub-stroke0066, sub-stroke0068, sub-stroke0070,
183
- sub-stroke0071, sub-stroke0073, sub-stroke0074, sub-stroke0075, sub-stroke0076,
184
- sub-stroke0077, sub-stroke0078, sub-stroke0079, sub-stroke0080, sub-stroke0081,
185
- sub-stroke0082, sub-stroke0083, sub-stroke0084, sub-stroke0085, sub-stroke0086,
186
- sub-stroke0087, sub-stroke0088, sub-stroke0089, sub-stroke0090, sub-stroke0091,
187
- sub-stroke0092, sub-stroke0093, sub-stroke0094, sub-stroke0095, sub-stroke0096,
188
- sub-stroke0097, sub-stroke0098, sub-stroke0099, sub-stroke0100, sub-stroke0101,
189
- sub-stroke0102, sub-stroke0103, sub-stroke0104, sub-stroke0105, sub-stroke0106,
190
- sub-stroke0107, sub-stroke0108, sub-stroke0109, sub-stroke0110, sub-stroke0111,
191
- sub-stroke0112, sub-stroke0113, sub-stroke0114, sub-stroke0115, sub-stroke0116,
192
- sub-stroke0117, sub-stroke0118, sub-stroke0119, sub-stroke0133, sub-stroke0134,
193
- sub-stroke0135, sub-stroke0136, sub-stroke0137, sub-stroke0138, sub-stroke0139,
194
- sub-stroke0140, sub-stroke0141, sub-stroke0142, sub-stroke0143, sub-stroke0144,
195
- sub-stroke0145, sub-stroke0146, sub-stroke0147, sub-stroke0148, sub-stroke0149,
196
- sub-stroke0150, sub-stroke0151, sub-stroke0152, sub-stroke0153, sub-stroke0154,
197
- sub-stroke0155, sub-stroke0156, sub-stroke0157, sub-stroke0158, sub-stroke0159,
198
- sub-stroke0161, sub-stroke0162, sub-stroke0163, sub-stroke0164, sub-stroke0165,
199
- sub-stroke0166, sub-stroke0167, sub-stroke0168, sub-stroke0169, sub-stroke0170,
200
- sub-stroke0171, sub-stroke0172, sub-stroke0173, sub-stroke0174, sub-stroke0175,
201
- sub-stroke0176, sub-stroke0177, sub-stroke0178, sub-stroke0179, sub-stroke0180,
202
- sub-stroke0181, sub-stroke0182, sub-stroke0183, sub-stroke0184, sub-stroke0185,
203
- sub-stroke0186, sub-stroke0187, sub-stroke0188, sub-stroke0189
204
- ```
205
-
206
- ---
207
-
208
- ## Environment
209
-
210
- - **Space:** `VibecoderMcSwaggins/stroke-deepisles-demo`
211
- - **Hardware:** T4-small GPU (limited memory)
212
- - **Dataset:** `hugging-science/isles24-stroke` (149 parquet files, ~99GB total)
213
- - **Dependencies:**
214
- - `datasets @ git+https://github.com/CloseChoice/datasets.git@c1c15aa...` (fork with Nifti support)
215
- - `pyarrow` (inherited, contains Bug #1)
216
- - `huggingface_hub` (used for Bug #2 fix)
217
-
218
- ---
219
-
220
- ## References
221
-
222
- - [PyArrow Issue #45214](https://github.com/apache/arrow/issues/45214) - Bug #1 root cause
223
- - [PyArrow Issue #43604](https://github.com/apache/arrow/issues/43604) - Related hang issue
224
- - [HF Datasets Issue #7467](https://github.com/huggingface/datasets/issues/7467) - HF tracking issue
225
- - [HF Datasets Issue #7357](https://github.com/huggingface/datasets/issues/7357) - Original report
226
-
227
- ---
228
-
229
- ## Checklist
230
-
231
- 1. [x] Identify Bug #1 root cause (PyArrow streaming hang)
232
- 2. [x] Identify Bug #2 root cause (OOM on full download)
233
- 3. [x] Extract all 149 case IDs via HfFileSystem
234
- 4. [x] Validate direct parquet access works (1.7s per case)
235
- 5. [x] Implement pre-computed case ID list (`constants.py`)
236
- 6. [x] Rewrite `get_case()` to use HfFileSystem + pyarrow
237
- 7. [x] Update tests
238
- 8. [ ] Test on HF Spaces
239
- 9. [ ] Monitor PyArrow issue for upstream fix
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/archive/09-bug-deepisles-not-installed-hf-spaces.md DELETED
@@ -1,92 +0,0 @@
1
- # Bug Spec: DeepISLES Path Conflict in Docker Build
2
-
3
- **Status:** Root Cause Found → Fix Ready
4
- **Priority:** P0 (Blocks inference)
5
- **Branch:** `fix/deepisles-docker-path`
6
- **Date:** 2025-12-08
7
-
8
- ## Executive Summary
9
-
10
- Our Dockerfile was **overwriting DeepISLES modules** by copying our app to `/app/src/`, which is where the base image stores DeepISLES code. The fix is to install our app at `/home/user/demo` instead.
11
-
12
- ## Root Cause
13
-
14
- The `isleschallenge/deepisles:latest` Docker image has this structure:
15
- ```text
16
- /app/
17
- ├── main.py
18
- ├── requirements.txt
19
- ├── src/ ← DeepISLES Python modules
20
- │ └── isles22_ensemble.py
21
- └── weights/ ← Model weights (~GB)
22
- ```
23
-
24
- Our original Dockerfile:
25
- ```dockerfile
26
- FROM isleschallenge/deepisles:latest
27
- WORKDIR /app
28
- COPY src/ /app/src/ ← OVERWRITES DeepISLES modules!
29
- ```
30
-
31
- This replaced `/app/src/isles22_ensemble.py` (DeepISLES) with `/app/src/stroke_deepisles_demo/` (our app).
32
-
33
- ## The Fix
34
-
35
- 1. **Changed app directory** from `/app` to `/home/user/demo`
36
- 2. **Added `DEEPISLES_PATH=/app`** environment variable
37
- 3. **Updated `direct.py`** to check `DEEPISLES_PATH` first
38
-
39
- ### Dockerfile Changes
40
- ```dockerfile
41
- # Before: WORKDIR /app
42
- # After:
43
- WORKDIR /home/user/demo
44
-
45
- # Before: COPY src/ /app/src/
46
- # After:
47
- COPY src/ /home/user/demo/src/
48
-
49
- # New:
50
- ENV DEEPISLES_PATH=/app
51
- ```
52
-
53
- ### direct.py Changes
54
- ```python
55
- def _get_deepisles_search_paths() -> list[str]:
56
- paths = []
57
- # Check environment variable first (set in Dockerfile)
58
- env_path = os.environ.get("DEEPISLES_PATH")
59
- if env_path:
60
- paths.append(env_path)
61
- # Add common installation locations
62
- paths.extend(["/app", "/DeepIsles", ...])
63
- return paths
64
- ```
65
-
66
- ## Investigation Process
67
-
68
- 1. Pulled `isleschallenge/deepisles:latest` locally
69
- 2. Inspected WORKDIR: `/app`
70
- 3. Listed `/app` contents: found `src/`, `weights/`, `main.py`
71
- 4. Realized our `COPY src/ /app/src/` was overwriting DeepISLES
72
-
73
- ## Files Changed
74
-
75
- - `Dockerfile` - Use `/home/user/demo`, add `DEEPISLES_PATH`
76
- - `src/stroke_deepisles_demo/inference/direct.py` - Dynamic search paths
77
-
78
- ## Testing
79
-
80
- - All 125 unit tests pass
81
- - Need to test on HF Spaces to verify inference works
82
-
83
- ## References
84
-
85
- - [DeepISLES GitHub](https://github.com/ezequieldlrosa/DeepIsles)
86
- - [Docker Hub Image](https://hub.docker.com/r/isleschallenge/deepisles)
87
- - [HuggingFace Docker Spaces](https://huggingface.co/docs/hub/en/spaces-sdks-docker)
88
-
89
- ## Sources
90
-
91
- - [Docker Blog: Build ML Apps with HuggingFace](https://www.docker.com/blog/build-machine-learning-apps-with-hugging-faces-docker-spaces/)
92
- - [HuggingFace Docker Spaces Docs](https://huggingface.co/docs/hub/en/spaces-sdks-docker)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/archive/10-bug-niivue-viewer-black-screen.md DELETED
@@ -1,418 +0,0 @@
1
- # Bug #10: NiiVue 3D Viewer Renders Black Screen on HF Spaces
2
-
3
- ## Status: PARTIALLY FIXED → See Bug #11
4
-
5
- **Date:** 2025-12-09
6
- **Branch:** `fix/niivue-js-on-load` (merged), now `fix/niivue-js-rerun`
7
- **Discovered:** After fixing Bug #9 (DeepISLES subprocess bridge)
8
-
9
- ### Fix Applied (2025-12-09) - PARTIAL
10
-
11
- Implemented `js_on_load` approach (Solution 1 from this spec):
12
-
13
- 1. **`viewer.py`**: Removed `<script>` tags, added `NIIVUE_JS_ON_LOAD` constant
14
- 2. **`components.py`**: Added `js_on_load=NIIVUE_JS_ON_LOAD` to gr.HTML
15
- 3. **All 130 tests pass locally**
16
-
17
- The HTML now uses `data-*` attributes to pass volume URLs, and JavaScript
18
- executes via `js_on_load` instead of inline `<script>` tags.
19
-
20
- ### Continued in Bug #11
21
-
22
- After HF Spaces deployment, we discovered that `js_on_load` **only runs once
23
- on component mount**, not on value updates. This means the NiiVue viewer
24
- initializes correctly on page load, but when `run_segmentation()` updates
25
- the gr.HTML value with new data-* attributes, the JS doesn't re-execute.
26
-
27
- **See [Bug #11](./11-bug-niivue-js-on-load-not-rerunning.md) for the complete
28
- analysis and the verified fix using `.then(fn=None, js=...)`.**
29
-
30
- ---
31
-
32
- ## TL;DR - ROOT CAUSE
33
-
34
- **Gradio's `gr.HTML` component does NOT execute `<script>` tags (including `type="module"`).**
35
-
36
- Our code embeds NiiVue initialization JavaScript inside `<script type="module">` tags within the HTML value. Gradio intentionally ignores these for security reasons. The canvas renders but NiiVue never initializes → black screen.
37
-
38
- ---
39
-
40
- ## Symptom
41
-
42
- After successful DeepISLES inference on HF Spaces, the NiiVue 3D viewer component (top-right panel) renders as a completely black rectangle. No brain scan or mask overlay is visible.
43
-
44
- **What IS working:**
45
- - DeepISLES inference completes successfully (~32 seconds)
46
- - Slice Comparison (matplotlib 2D view) renders correctly
47
- - Metrics JSON displays correctly
48
- - Download button provides the prediction mask
49
- - Ground truth overlay in Slice Comparison works
50
-
51
- **What is NOT working:**
52
- - NiiVue WebGL 3D viewer shows black screen
53
- - No error message displayed in the viewer area
54
- - No visible WebGL error fallback message
55
-
56
- **What SHOULD appear:**
57
- - Multi-planar view (axial/coronal/sagittal slices)
58
- - Optional 3D volume rendering
59
- - Interactive crosshairs for navigation
60
- - DWI volume as grayscale background
61
- - Prediction mask as semi-transparent red overlay
62
-
63
- ---
64
-
65
- ## Root Cause Analysis
66
-
67
- ### Evidence Chain
68
-
69
- 1. **[HuggingFace Forum](https://discuss.huggingface.co/t/gradio-html-component-with-javascript-code-dont-work/37316)**:
70
- > "You can't load scripts via `gr.HTML`"
71
-
72
- 2. **[Gradio Official Docs](https://www.gradio.app/docs/gradio/html)**:
73
- > "Only static HTML is rendered (e.g., no JavaScript). To render JavaScript, use the `js` or `head` parameters"
74
-
75
- 3. **[Gradio 6 Migration Guide](https://www.gradio.app/main/guides/gradio-6-migration-guide)**:
76
- > The `js` and `head` parameters moved from `gr.Blocks()` to `launch()` in Gradio 6
77
-
78
- 4. **[GitHub Issue #10250](https://github.com/gradio-app/gradio/issues/10250)**:
79
- > Known issue with JavaScript in `head` param not executing reliably
80
-
81
- ### Our Code (BROKEN)
82
-
83
- ```python
84
- # viewer.py:324-385 - Returns HTML with embedded script tags
85
- def create_niivue_html(volume_url, mask_url, height=400) -> str:
86
- return f"""
87
- <div id="{container_id}" style="...">
88
- <canvas id="{canvas_id}" style="..."></canvas>
89
- </div>
90
- <script type="module">
91
- // THIS ENTIRE BLOCK IS IGNORED BY GRADIO!
92
- (async function() {{
93
- const niivueModule = await import('{NIIVUE_CDN_URL}');
94
- const Niivue = niivueModule.Niivue;
95
- const nv = new Niivue({{...}});
96
- await nv.attachToCanvas(document.getElementById('{canvas_id}'));
97
- await nv.loadVolumes([{{ url: {volume_url_js} }}]);
98
- // ... more initialization
99
- }})();
100
- </script>
101
- """
102
-
103
- # components.py:42 - Basic HTML component without js_on_load
104
- niivue_viewer = gr.HTML(label="Interactive 3D Viewer") # No js_on_load!
105
- ```
106
-
107
- ### Why It Fails
108
-
109
- 1. `gr.HTML` receives our HTML string as `value`
110
- 2. Gradio renders the `<div>` and `<canvas>` elements (static HTML)
111
- 3. Gradio **strips or ignores** the `<script>` tags for security
112
- 4. NiiVue JavaScript never executes
113
- 5. Canvas remains empty → black screen
114
- 6. Our try/catch error handling never runs (script doesn't execute at all)
115
-
116
- ---
117
-
118
- ## Secondary Issues
119
-
120
- ### Issue 1: Base64 Payload Size (~65MB)
121
-
122
- Even if JavaScript executed, we're passing massive base64-encoded NIfTI data:
123
-
124
- | File | Raw Size | Base64 Size |
125
- |------|----------|-------------|
126
- | DWI | 30.1 MB | ~40 MB |
127
- | ADC | 17.7 MB | ~24 MB |
128
- | **Total** | ~48 MB | **~65 MB** |
129
-
130
- This could cause:
131
- - Browser memory issues
132
- - Gradio payload limits
133
- - Slow/failed rendering
134
-
135
- ### Issue 2: Gradio 6 Breaking Changes
136
-
137
- Our code uses Gradio 5.x patterns. In Gradio 6.x:
138
- - `js`, `head`, `head_paths` moved from `gr.Blocks()` to `launch()`
139
- - `padding` default changed from `True` to `False`
140
- - `js_on_load` is now the proper way for component-level JavaScript
141
-
142
- ### Issue 3: No Error Visibility
143
-
144
- Our JavaScript has try/catch that should display errors in the container, but since the script never executes, the error handling never runs. The canvas just stays black with no feedback to the user.
145
-
146
- ---
147
-
148
- ## Code Locations
149
-
150
- | File | Lines | Description |
151
- |------|-------|-------------|
152
- | `src/stroke_deepisles_demo/ui/viewer.py` | 277-385 | `create_niivue_html()` - generates broken HTML |
153
- | `src/stroke_deepisles_demo/ui/viewer.py` | 34-51 | `nifti_to_data_url()` - base64 encoding |
154
- | `src/stroke_deepisles_demo/ui/app.py` | 101-117 | NiiVue HTML generation in `run_segmentation()` |
155
- | `src/stroke_deepisles_demo/ui/components.py` | 41-42 | `gr.HTML` component creation (missing js_on_load) |
156
-
157
- ---
158
-
159
- ## External Validation (2025-12-09)
160
-
161
- An external agent review claimed `js_on_load` does not exist. **This claim was REFUTED.**
162
-
163
- ### Verification Results
164
-
165
- | Claim | Status | Evidence |
166
- |-------|--------|----------|
167
- | "gr.HTML does NOT have js_on_load parameter" | ❌ **REFUTED** | [Gradio Docs](https://www.gradio.app/docs/gradio/html) show `js_on_load` with default value |
168
- | "js_on_load was added in PR #12098" | ✅ Confirmed | Part of "gr.HTML custom components" feature |
169
- | "Base64 payload (~65MB) is a risk" | ✅ Confirmed | Valid concern, should use file URLs |
170
- | "CSP headers may block CDN" | ⚠️ Possible | HF Spaces typically allows unpkg.com, but worth testing |
171
-
172
- ### Validated `js_on_load` Signature
173
-
174
- ```python
175
- js_on_load: str | None = "element.addEventListener('click', function() { trigger('click') });"
176
- ```
177
-
178
- **Available in js_on_load context:**
179
- - `element` - The HTML DOM element
180
- - `trigger(event_name)` - Fire Gradio events
181
- - `props` - Access component props including `props.value`
182
-
183
- **Untested (needs verification):**
184
- - Async/await patterns
185
- - Dynamic `import()` for CDN modules
186
- - Error propagation to Gradio
187
-
188
- ---
189
-
190
- ## Proposed Solutions (Ranked)
191
-
192
- ### Solution 1: Use `js_on_load` Parameter (Recommended)
193
-
194
- Gradio 6's `gr.HTML` supports `js_on_load` for component-level JavaScript (added in PR #12098):
195
-
196
- ```python
197
- def create_niivue_component(volume_url, mask_url, height=400):
198
- container_id = f"nv-{uuid.uuid4().hex[:8]}"
199
-
200
- html_content = f'<div id="{container_id}" style="height:{height}px;background:#000;"><canvas></canvas></div>'
201
-
202
- js_code = f"""
203
- (async () => {{
204
- try {{
205
- const {{ Niivue }} = await import('https://unpkg.com/@niivue/niivue@0.65.0/dist/index.js');
206
- const nv = new Niivue({{ logging: false, backColor: [0,0,0,1] }});
207
- await nv.attachToCanvas(element.querySelector('canvas'));
208
- await nv.loadVolumes([{{ url: {json.dumps(volume_url)} }}]);
209
- nv.setSliceType(nv.sliceTypeMultiplanar);
210
- }} catch (e) {{
211
- element.innerHTML = '<div style="color:#fff;padding:20px;">Error: ' + e.message + '</div>';
212
- }}
213
- }})();
214
- """
215
-
216
- return gr.HTML(
217
- value=html_content,
218
- js_on_load=js_code,
219
- label="Interactive 3D Viewer"
220
- )
221
- ```
222
-
223
- **Pros:** Native Gradio 6 approach, component-scoped
224
- **Cons:** May have issues with dynamic import in js_on_load context
225
-
226
- ### Solution 2: Use `head` Parameter in `launch()`
227
-
228
- Load NiiVue globally via the `head` parameter:
229
-
230
- ```python
231
- # app.py
232
- NIIVUE_HEAD = '''
233
- <script type="module">
234
- import { Niivue } from 'https://unpkg.com/@niivue/niivue@0.65.0/dist/index.js';
235
- window.Niivue = Niivue;
236
- </script>
237
- '''
238
-
239
- demo.launch(
240
- head=NIIVUE_HEAD,
241
- server_name="0.0.0.0",
242
- server_port=7860
243
- )
244
- ```
245
-
246
- **Pros:** Loads library once, available globally
247
- **Cons:** GitHub Issue #10250 reports unreliable execution
248
-
249
- ### Solution 3: Server-Side File Serving
250
-
251
- Instead of base64 data URLs, serve NIfTI files via Gradio's file system:
252
-
253
- ```python
254
- # Use Gradio's file URL instead of data URLs
255
- from gradio import FileData
256
- file_data = FileData(path=str(dwi_path))
257
- # Pass file_data.url to NiiVue instead of base64
258
- ```
259
-
260
- **Pros:** Avoids 65MB payload, better memory efficiency
261
- **Cons:** Requires refactoring data flow, CORS considerations
262
-
263
- ### Solution 4: Custom Gradio Component
264
-
265
- Build a proper `gradio_niivue` package:
266
-
267
- ```bash
268
- gradio cc create NiiVue --template HTML
269
- # Implement Svelte frontend with NiiVue
270
- # Publish to PyPI
271
- ```
272
-
273
- **Pros:** Most robust, reusable, proper architecture
274
- **Cons:** Significant development effort
275
-
276
- ### Solution 5: Enhanced 2D Fallback (Simplest)
277
-
278
- Remove NiiVue entirely, enhance matplotlib visualization:
279
-
280
- ```python
281
- def create_results_display():
282
- with gr.Group():
283
- # Remove: niivue_viewer = gr.HTML(...)
284
-
285
- # Enhanced 2D visualization
286
- slice_plot = gr.Plot(label="Multi-View Comparison")
287
- slice_slider = gr.Slider(label="Slice", minimum=0, maximum=100)
288
-
289
- # Add orthogonal views
290
- with gr.Row():
291
- axial_plot = gr.Plot(label="Axial")
292
- coronal_plot = gr.Plot(label="Coronal")
293
- sagittal_plot = gr.Plot(label="Sagittal")
294
- ```
295
-
296
- **Pros:** Eliminates WebGL complexity, works reliably
297
- **Cons:** Loses 3D interactivity, less impressive demo
298
-
299
- ---
300
-
301
- ## Investigation Steps
302
-
303
- ### Step 0: Test Async/Await in js_on_load (CRITICAL)
304
- Before implementing Solution 1, verify async works:
305
- ```python
306
- import gradio as gr
307
-
308
- with gr.Blocks() as demo:
309
- html = gr.HTML(
310
- value="<div>Testing async...</div>",
311
- js_on_load="""
312
- (async () => {
313
- element.innerText = 'Async started...';
314
- await new Promise(r => setTimeout(r, 1000));
315
- element.innerText = 'Async works!';
316
- element.style.background = 'green';
317
- })();
318
- """
319
- )
320
-
321
- demo.launch()
322
- ```
323
-
324
- If this shows "Async works!" with green background after 1 second, async is supported.
325
-
326
- ### Step 1: Verify js_on_load Works (Basic)
327
- Create minimal test:
328
- ```python
329
- import gradio as gr
330
-
331
- with gr.Blocks() as demo:
332
- html = gr.HTML(
333
- value="<div id='test'>Loading...</div>",
334
- js_on_load="element.style.background='green'; element.innerText='JS Works!';"
335
- )
336
-
337
- demo.launch()
338
- ```
339
-
340
- ### Step 2: Test Dynamic Import in js_on_load
341
- ```python
342
- js_on_load="""
343
- (async () => {
344
- const mod = await import('https://unpkg.com/@niivue/niivue@0.65.0/dist/index.js');
345
- console.log('NiiVue loaded:', mod);
346
- element.innerText = 'Import succeeded!';
347
- })();
348
- """
349
- ```
350
-
351
- ### Step 3: Check Browser Console
352
- 1. Open HF Spaces demo
353
- 2. Open DevTools (F12) → Console
354
- 3. Look for errors related to:
355
- - Module loading failures
356
- - WebGL context issues
357
- - CORS errors
358
- - Memory errors
359
-
360
- ### Step 4: Test with Smaller Files
361
- Create downsampled test NIfTI (~1MB) to isolate size vs JS issues.
362
-
363
- ---
364
-
365
- ## Related Issues
366
-
367
- - **Bug #9**: DeepISLES modules not found (FIXED - subprocess bridge)
368
- - **Bug #8**: HF Spaces streaming hang (FIXED)
369
- - **Technical Debt**: NiiVue memory overhead (P2)
370
- - **[Gradio #4511](https://github.com/gradio-app/gradio/issues/4511)**: 3D medical image support request (closed, not planned)
371
- - **[Gradio #10250](https://github.com/gradio-app/gradio/issues/10250)**: JS in head param issues (open)
372
-
373
- ---
374
-
375
- ## Priority Assessment
376
-
377
- **Severity:** P2 (Medium)
378
- - Core inference pipeline works correctly
379
- - 2D visualization provides adequate fallback
380
- - No data loss or security impact
381
- - Demo is functional for evaluation purposes
382
-
383
- **Impact:**
384
- - Less impressive without 3D viewer
385
- - Users can still evaluate predictions via 2D slices
386
- - Download functionality unaffected
387
-
388
- **Recommendation:**
389
- 1. First, validate inference accuracy across multiple cases
390
- 2. Then attempt Solution 1 (js_on_load) as quick fix
391
- 3. If that fails, implement Solution 5 (enhanced 2D) for reliability
392
- 4. Consider Solution 4 (custom component) for future enhancement
393
-
394
- ---
395
-
396
- ## References
397
-
398
- - [Gradio HTML Docs](https://www.gradio.app/docs/gradio/html)
399
- - [Gradio Custom HTML Components Guide](https://www.gradio.app/guides/custom_HTML_components)
400
- - [Gradio 6 Migration Guide](https://www.gradio.app/main/guides/gradio-6-migration-guide)
401
- - [HuggingFace Forum: JS doesn't work in gr.HTML](https://discuss.huggingface.co/t/gradio-html-component-with-javascript-code-dont-work/37316)
402
- - [GitHub Issue #10250: JS in head param](https://github.com/gradio-app/gradio/issues/10250)
403
- - [GitHub Issue #4511: 3D Medical Images](https://github.com/gradio-app/gradio/issues/4511)
404
- - [NiiVue GitHub](https://github.com/niivue/niivue)
405
- - [ipyniivue (Jupyter Widget)](https://github.com/niivue/ipyniivue)
406
- - [Gradio 6 Announcement](https://alternativeto.net/news/2025/11/gradio-6-released-with-faster-performance-for-creating-machine-learning-apps-in-python/)
407
-
408
- ---
409
-
410
- ## Appendix: HF Spaces Logs
411
-
412
- ```text
413
- INFO: Running segmentation for sub-stroke0002
414
- INFO: Case sub-stroke0002 ready: DWI=20.9MB, ADC=12.6MB
415
- INFO: DeepISLES subprocess completed in 30.88s
416
- ```
417
-
418
- Note: No JavaScript errors visible in server logs (client-side only).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/archive/11-bug-niivue-js-on-load-not-rerunning.md DELETED
@@ -1,484 +0,0 @@
1
- # Bug #11: NiiVue js_on_load Doesn't Re-run on Value Update
2
-
3
- ## Status: FIXED
4
-
5
- **Date:** 2025-12-09
6
- **Branch:** `fix/niivue-js-rerun`
7
- **Fixed By:** Implementing `.then(fn=None, js=NIIVUE_UPDATE_JS)` pattern with correct `document.querySelector` context.
8
- **Related:** Bug #10 (Fixed)
9
-
10
- ---
11
-
12
- ## TL;DR - ROOT CAUSE
13
-
14
- **Gradio's `js_on_load` only runs ONCE when the component first mounts.**
15
-
16
- When we update the `gr.HTML` value with new content (after segmentation), the `js_on_load` code does NOT re-execute. The HTML updates, but the JavaScript initialization never runs.
17
-
18
- ---
19
-
20
- ## Symptom
21
-
22
- After successful DeepISLES inference on HF Spaces:
23
- - Viewer shows "Loading viewer..." (initial HTML state)
24
- - Status never changes to "Checking WebGL2..." or "Loading NiiVue..."
25
- - No error message displayed
26
- - No brain scan visible
27
-
28
- **What IS working:**
29
- - DeepISLES inference completes (~36 seconds)
30
- - Slice Comparison (matplotlib 2D view) renders correctly
31
- - Metrics JSON displays correctly
32
- - Download button provides the prediction mask
33
- - Initial HTML renders with data-* attributes
34
-
35
- **What is NOT working:**
36
- - js_on_load JavaScript doesn't re-run when value updates
37
- - NiiVue never initializes after segmentation
38
-
39
- ---
40
-
41
- ## Evidence
42
-
43
- ### Gradio Documentation
44
-
45
- From [Custom HTML Components Guide](https://www.gradio.app/guides/custom_HTML_components):
46
-
47
- > "Event listeners attached in `js_on_load` are only attached **once** when the component is first rendered. If your component creates new elements dynamically that need event listeners, attach the event listener to a parent element..."
48
-
49
- ### Observed Behavior
50
-
51
- 1. Page loads → js_on_load runs → No volumeUrl → Shows "Waiting for segmentation..."
52
- 2. User clicks "Run Segmentation"
53
- 3. DeepISLES runs successfully
54
- 4. `run_segmentation()` returns new HTML with data-volume-url attribute
55
- 5. gr.HTML value updates with new HTML
56
- 6. **js_on_load does NOT re-run** ← THE BUG
57
- 7. Viewer shows "Loading viewer..." (static HTML, no JS executed)
58
-
59
- ### Server Logs (Working)
60
-
61
- ```text
62
- INFO: Running segmentation for sub-stroke0001
63
- INFO: DeepISLES subprocess completed in 35.73s
64
- ```
65
-
66
- Inference works. The problem is client-side JavaScript execution.
67
-
68
- ---
69
-
70
- ## Code Flow Analysis
71
-
72
- ### Current Implementation (BROKEN)
73
-
74
- ```python
75
- # components.py - js_on_load set once at component creation
76
- niivue_viewer = gr.HTML(
77
- label="Interactive 3D Viewer",
78
- js_on_load=NIIVUE_ON_LOAD_JS, # Runs ONCE on mount
79
- )
80
-
81
- # app.py - returns new HTML value after segmentation
82
- def run_segmentation(...):
83
- # ... inference ...
84
- niivue_html = create_niivue_html(dwi_url, mask_url)
85
- return niivue_html, ... # Value updates, but js_on_load doesn't re-run
86
- ```
87
-
88
- ### Why It Fails
89
-
90
- 1. Component mounts → js_on_load runs (no data yet)
91
- 2. Value updates → HTML re-renders, js_on_load SKIPPED
92
- 3. New HTML has data-* attributes but no JS execution
93
-
94
- ---
95
-
96
- ## Proposed Solutions (Ranked)
97
-
98
- ### Solution 1: Use `js` Parameter on Event Handler (Recommended)
99
-
100
- Gradio allows running JavaScript after an event completes:
101
-
102
- ```python
103
- run_btn.click(
104
- fn=run_segmentation,
105
- inputs=[...],
106
- outputs=[results["niivue_viewer"], ...],
107
- ).then(
108
- fn=None, # MUST be explicit!
109
- js=NIIVUE_UPDATE_JS, # ⚠️ CANNOT reuse NIIVUE_ON_LOAD_JS - different context!
110
- )
111
- ```
112
-
113
- **Pros:** Native Gradio pattern, runs after each update
114
- **Cons:** Requires separate JS constant (see "Different JS Context" section below)
115
-
116
- **⚠️ CRITICAL:** The `js` param does NOT have access to `element`. You must use
117
- `document.querySelector()` instead. See the corrected JavaScript in the
118
- "Recommended Implementation" section.
119
-
120
- ### Solution 2: MutationObserver in js_on_load
121
-
122
- Watch for DOM changes and re-initialize. This approach IS valid because
123
- `js_on_load` has access to `element`:
124
-
125
- ```javascript
126
- // In js_on_load - 'element' IS available here
127
- const initNiiVue = async () => {
128
- const container = element.querySelector('.niivue-viewer') || element;
129
- const volumeUrl = container.dataset.volumeUrl;
130
- if (!volumeUrl) return;
131
- // ... NiiVue initialization code ...
132
- };
133
-
134
- // Watch for attribute changes (when Python updates data-volume-url)
135
- const observer = new MutationObserver((mutations) => {
136
- for (const mutation of mutations) {
137
- if (mutation.type === 'attributes' &&
138
- mutation.attributeName.startsWith('data-')) {
139
- initNiiVue();
140
- break;
141
- }
142
- }
143
- });
144
-
145
- // Observe the element for attribute changes
146
- observer.observe(element, {
147
- attributes: true,
148
- subtree: true,
149
- attributeFilter: ['data-volume-url', 'data-mask-url']
150
- });
151
-
152
- // Initial check
153
- initNiiVue();
154
- ```
155
-
156
- **Pros:** Self-contained in js_on_load, no separate event wiring needed
157
- **Cons:** More complex, relies on Gradio updating DOM attributes (may not work
158
- if Gradio replaces the entire element instead of updating attributes)
159
-
160
- ### Solution 3: Use gradio-iframe Component
161
-
162
- The `gradio-iframe` package allows JavaScript to execute normally:
163
-
164
- ```python
165
- from gradio_iframe import iFrame
166
-
167
- niivue_viewer = iFrame(
168
- value=create_niivue_html_with_script(...), # Scripts execute in iframe
169
- )
170
- ```
171
-
172
- **Pros:** Scripts execute normally inside iframe
173
- **Cons:** Additional dependency, iframe quirks
174
-
175
- ### Solution 4: Embed JS in HTML via data: URL iframe
176
-
177
- Self-contained iframe with script:
178
-
179
- ```python
180
- def create_niivue_html(...):
181
- html_with_script = f'''<script>...</script><canvas>...</canvas>'''
182
- encoded = base64.b64encode(html_with_script.encode()).decode()
183
- return f'<iframe src="data:text/html;base64,{encoded}"></iframe>'
184
- ```
185
-
186
- **Pros:** No external dependency, scripts execute
187
- **Cons:** Complex, potential CSP issues
188
-
189
- ### Solution 5: Custom Gradio Component
190
-
191
- Build a proper `gradio_niivue` Svelte component:
192
-
193
- ```bash
194
- gradio cc create NiiVue --template HTML
195
- ```
196
-
197
- **Pros:** Most robust, proper lifecycle hooks
198
- **Cons:** Significant development effort
199
-
200
- ---
201
-
202
- ## Investigation Steps
203
-
204
- ### Step 1: Test Solution 1 (js param on .then())
205
-
206
- ```python
207
- run_btn.click(
208
- fn=run_segmentation,
209
- inputs=[...],
210
- outputs=[...],
211
- ).then(
212
- fn=None,
213
- js="console.log('then JS ran'); console.log(document.querySelector('.niivue-viewer'));"
214
- )
215
- ```
216
-
217
- Verify:
218
- - Does `js` run after value update?
219
- - Does it have access to the updated DOM?
220
-
221
- ### Step 2: Test Solution 2 (MutationObserver)
222
-
223
- Add observer to js_on_load and check if it triggers on value change.
224
-
225
- ### Step 3: Check Browser Console
226
-
227
- Open DevTools and look for:
228
- - JavaScript errors
229
- - Console logs from js_on_load
230
- - Network requests to NiiVue CDN
231
-
232
- ---
233
-
234
- ## Temporary Workaround
235
-
236
- The 2D Slice Comparison view works correctly and provides adequate visualization for evaluation purposes while we fix the 3D viewer.
237
-
238
- ---
239
-
240
- ## Priority Assessment
241
-
242
- **Severity:** P1 (High)
243
- - 3D viewer is a key feature for the demo
244
- - The fix we deployed doesn't fully work
245
- - Blocks demo usability for 3D visualization
246
-
247
- **Impact:**
248
- - Users see "Loading viewer..." indefinitely
249
- - 2D fallback still works
250
- - Demo is partially functional
251
-
252
- ---
253
-
254
- ## Deep Web Research (2025-12-09)
255
-
256
- ### Relationship Between Bug #10 and Bug #11
257
-
258
- **They are the SAME underlying issue with two symptoms:**
259
-
260
- 1. **Bug #10**: Gradio strips `<script>` tags from gr.HTML for XSS security
261
- 2. **Bug #11**: Gradio's `js_on_load` only runs once on component mount
262
-
263
- Both stem from Gradio's design decision to limit JavaScript execution in HTML components for security reasons.
264
-
265
- ### Verified Gradio Behavior (from official docs)
266
-
267
- #### js_on_load Limitation (CONFIRMED)
268
-
269
- From [Gradio Custom HTML Components](https://www.gradio.app/guides/custom_HTML_components):
270
-
271
- > "Event listeners attached in `js_on_load` are **only attached once** when the component is first rendered."
272
-
273
- #### Solution 1 VALIDATED: `.then(fn=None, js=...)`
274
-
275
- From [Gradio Custom CSS and JS](https://www.gradio.app/guides/custom-CSS-and-JS):
276
-
277
- > "You can pass both a JavaScript function and a Python function (in which case the JavaScript function is run first) or **only Javascript (and set the Python `fn` to `None`)**."
278
-
279
- **Critical Implementation Detail** from [GitHub Issue #6729](https://github.com/gradio-app/gradio/issues/6729):
280
-
281
- > "`js` without `fn` is executed only if `fn` is **explicitly** set to `None`"
282
-
283
- ```python
284
- # WORKS
285
- b1.click(js=js, fn=None)
286
-
287
- # DOES NOT WORK
288
- b2.click(js=js) # fn defaults to something, not None
289
- ```
290
-
291
- #### js Parameter Signature
292
-
293
- From [Gradio HTML Docs](https://www.gradio.app/docs/gradio/html):
294
-
295
- > "The `js` parameter is an optional frontend js method to run before running 'fn'. Input arguments for js method are values of 'inputs' and 'outputs', return should be a list of values for output components."
296
-
297
- ### Alternative Solutions Research
298
-
299
- #### gradio-iframe Package
300
-
301
- From [PyPI gradio-iframe](https://pypi.org/project/gradio-iframe/):
302
-
303
- - Version: 0.0.10 (Jan 2024)
304
- - **JavaScript executes normally inside iframe**
305
- - Known issues: Height doesn't always adjust, not fully responsive
306
- - Status: Alpha, possibly abandoned (no updates in 12 months)
307
- - **Risk:** May not be compatible with Gradio 6.x
308
-
309
- #### MutationObserver Pattern
310
-
311
- From [MDN MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver):
312
-
313
- MutationObserver can watch for DOM changes and trigger re-initialization:
314
-
315
- ```javascript
316
- const observer = new MutationObserver((mutations) => {
317
- mutations.forEach((mutation) => {
318
- if (mutation.type === 'attributes' &&
319
- mutation.attributeName === 'data-volume-url') {
320
- initNiiVue();
321
- }
322
- });
323
- });
324
- observer.observe(element, { attributes: true, attributeFilter: ['data-volume-url'] });
325
- ```
326
-
327
- **Caveat from Gradio docs:**
328
-
329
- > "Warning: The use of query selectors in custom JS and CSS is not guaranteed to work across Gradio versions that bind to Gradio's own HTML elements as the Gradio HTML DOM may change."
330
-
331
- #### ipyniivue (Jupyter Widget)
332
-
333
- From [GitHub ipyniivue](https://github.com/niivue/ipyniivue):
334
-
335
- - Built on anywidget framework
336
- - Designed for Jupyter, not Gradio
337
- - No direct Gradio integration exists
338
-
339
- ### Recommended Implementation
340
-
341
- Based on research, **Solution 1 (`.then(fn=None, js=...)`) is the correct fix**.
342
-
343
- #### Step 1: Create a NEW JavaScript constant for event handlers
344
-
345
- We **CANNOT** reuse `NIIVUE_ON_LOAD_JS` because it uses `element` which is not
346
- available in the event handler context. We need a new constant:
347
-
348
- ```python
349
- # viewer.py - NEW constant for event handler context
350
- NIIVUE_UPDATE_JS = f"""
351
- (async () => {{
352
- // ⚠️ NO 'element' available - must use document.querySelector()
353
- const container = document.querySelector('.niivue-viewer');
354
- if (!container) {{
355
- console.error('NiiVue container not found');
356
- return;
357
- }}
358
-
359
- const canvas = container.querySelector('canvas');
360
- const status = container.querySelector('.niivue-status');
361
-
362
- // Get URLs from data attributes
363
- const volumeUrl = container.dataset.volumeUrl;
364
- const maskUrl = container.dataset.maskUrl;
365
-
366
- // Skip if no volume URL
367
- if (!volumeUrl) {{
368
- console.log('No volume URL yet');
369
- return;
370
- }}
371
-
372
- try {{
373
- if (status) status.innerText = 'Loading NiiVue...';
374
-
375
- const {{ Niivue }} = await import('{NIIVUE_CDN_URL}');
376
- const nv = new Niivue({{
377
- logging: false,
378
- show3Dcrosshair: true,
379
- backColor: [0, 0, 0, 1]
380
- }});
381
-
382
- await nv.attachToCanvas(canvas);
383
- if (status) status.style.display = 'none';
384
-
385
- const volumes = [{{ url: volumeUrl, name: 'input.nii.gz' }}];
386
- if (maskUrl) {{
387
- volumes.push({{ url: maskUrl, colorMap: 'red', opacity: 0.5 }});
388
- }}
389
-
390
- await nv.loadVolumes(volumes);
391
- nv.setSliceType(nv.sliceTypeMultiplanar);
392
- nv.drawScene();
393
-
394
- console.log('NiiVue initialized via .then()');
395
- }} catch (error) {{
396
- console.error('NiiVue init error:', error);
397
- if (container) {{
398
- const errorDiv = document.createElement('div');
399
- errorDiv.style.cssText = 'color:#f66;padding:20px;text-align:center;';
400
- errorDiv.textContent = 'Error: ' + error.message;
401
- container.innerHTML = '';
402
- container.appendChild(errorDiv);
403
- }}
404
- }}
405
- }})();
406
- """
407
- ```
408
-
409
- #### Step 2: Wire up the event handler in app.py
410
-
411
- ```python
412
- # app.py
413
- from stroke_deepisles_demo.ui.viewer import NIIVUE_UPDATE_JS
414
-
415
- run_btn.click(
416
- fn=run_segmentation,
417
- inputs=[case_selector, settings["fast_mode"], settings["show_ground_truth"]],
418
- outputs=[results["niivue_viewer"], results["slice_plot"], results["metrics"],
419
- results["download"], status],
420
- ).then(
421
- fn=None, # MUST be explicit per GitHub Issue #6729!
422
- js=NIIVUE_UPDATE_JS,
423
- )
424
- ```
425
-
426
- **Why this works:**
427
- 1. Python `run_segmentation()` updates gr.HTML value with new data-* attributes
428
- 2. `.then()` chains after the click handler completes
429
- 3. `fn=None` tells Gradio to skip Python, run JS only
430
- 4. `js=NIIVUE_UPDATE_JS` runs our initialization code
431
- 5. JS uses `document.querySelector()` to find the updated DOM
432
-
433
- ### ⚠️ CRITICAL: Different JS Context (VERIFIED)
434
-
435
- The `js` parameter on event handlers has a **completely different context** than `js_on_load`:
436
-
437
- | Context | `js_on_load` | `js` on event handler |
438
- |---------|--------------|----------------------|
439
- | `element` | ✅ Available | ❌ **NOT available** |
440
- | `props` | ✅ Available | ❌ **NOT available** |
441
- | `trigger()` | ✅ Available | ❌ **NOT available** |
442
- | Arguments | None | Receives input/output **values** |
443
-
444
- From [Gradio Custom CSS and JS](https://www.gradio.app/guides/custom-CSS-and-JS):
445
-
446
- > "Input arguments for js method are **values of 'inputs' and 'outputs'**"
447
-
448
- Example from Gradio docs:
449
- ```python
450
- reverse_btn.click(
451
- None, [subject, verb, object], output2,
452
- js="(s, v, o) => o + ' ' + v + ' ' + s" # Receives VALUES, not DOM elements
453
- )
454
- ```
455
-
456
- **This is why we need TWO separate JavaScript constants:**
457
- - `NIIVUE_ON_LOAD_JS` - Uses `element.querySelector()` (for initial mount)
458
- - `NIIVUE_UPDATE_JS` - Uses `document.querySelector()` (for .then() handler)
459
-
460
- ### Risk Assessment: Is This Fixable?
461
-
462
- | Approach | Feasibility | Risk Level | Notes |
463
- |----------|-------------|------------|-------|
464
- | `.then(fn=None, js=...)` | ✅ High | Low | Native Gradio, documented |
465
- | MutationObserver | ✅ High | Medium | Complex, DOM stability warning |
466
- | gradio-iframe | ⚠️ Medium | High | Abandoned, Gradio 6 compat unknown |
467
- | data: URL iframe | ⚠️ Medium | Medium | CSP issues possible |
468
- | Custom component | ✅ High | Low | Most work, most robust |
469
-
470
- **Verdict: YES, this is fixable.** Solution 1 should work based on verified documentation.
471
-
472
- ---
473
-
474
- ## References
475
-
476
- - [Gradio Custom HTML Components](https://www.gradio.app/guides/custom_HTML_components) - js_on_load limitation
477
- - [Gradio Custom CSS and JS](https://www.gradio.app/guides/custom-CSS-and-JS) - js parameter docs
478
- - [Gradio Event Listeners](https://www.gradio.app/docs/gradio/blocks#events) - .then() method
479
- - [GitHub Issue #6729](https://github.com/gradio-app/gradio/issues/6729) - fn=None requirement
480
- - [gradio-iframe PyPI](https://pypi.org/project/gradio-iframe/) - Alternative approach
481
- - [ipyniivue GitHub](https://github.com/niivue/ipyniivue) - Jupyter widget (not Gradio)
482
- - [MDN MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) - DOM watching
483
- - [Bug #10 Spec](./10-bug-niivue-viewer-black-screen.md) - Previous fix attempt
484
- - [Issue #19](https://github.com/The-Obstacle-Is-The-Way/stroke-deepisles-demo/issues/19) - Base64 optimization (related)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/archive/19-perf-base64-to-file-urls.md DELETED
@@ -1,244 +0,0 @@
1
- # Issue #19: Replace Base64 Data URLs with File URLs for NiiVue Viewer
2
-
3
- ## Status: RESOLVED ✅
4
-
5
- **Date:** 2025-12-09
6
- **Resolved:** 2025-12-09
7
- **Priority:** P3 (Performance optimization)
8
- **GitHub Issue:** https://github.com/The-Obstacle-Is-The-Way/stroke-deepisles-demo/issues/19
9
- **Related:** Bug #10, Bug #11 (both FIXED)
10
-
11
- ---
12
-
13
- ## TL;DR
14
-
15
- Replace base64-encoded data URLs (~65MB payloads) with Gradio's file serving for
16
- NiiVue volumes. The viewer works correctly now, but large payloads may cause
17
- slow loading or memory issues.
18
-
19
- ---
20
-
21
- ## Problem
22
-
23
- The NiiVue 3D viewer currently uses base64-encoded data URLs to pass NIfTI
24
- volumes to the browser:
25
-
26
- ```python
27
- # Current implementation in viewer.py
28
- def nifti_to_data_url(nifti_path: Path) -> str:
29
- """Convert NIfTI file to base64 data URL."""
30
- data = nifti_path.read_bytes()
31
- b64 = base64.b64encode(data).decode("ascii")
32
- return f"data:application/octet-stream;base64,{b64}"
33
- ```
34
-
35
- ### Payload Size Analysis
36
-
37
- | File | Raw Size | Base64 Size |
38
- |------|----------|-------------|
39
- | DWI | 30.1 MB | ~40 MB |
40
- | ADC | 17.7 MB | ~24 MB |
41
- | **Total** | ~48 MB | **~65 MB** |
42
-
43
- ### Potential Issues
44
-
45
- 1. **Browser memory pressure** - Large base64 strings in DOM
46
- 2. **Slow loading times** - 65MB transferred per segmentation
47
- 3. **Gradio payload limits** - May hit internal limits on large responses
48
- 4. **Mobile/low-bandwidth issues** - Poor UX on slower connections
49
-
50
- ---
51
-
52
- ## Proposed Solution
53
-
54
- Use Gradio's built-in file serving instead of base64 data URLs.
55
-
56
- ### Option A: Use `gr.File` component (Recommended)
57
-
58
- Gradio automatically serves files and provides URLs:
59
-
60
- ```python
61
- from gradio import FileData
62
-
63
- def nifti_to_file_url(nifti_path: Path) -> str:
64
- """Get Gradio file URL for NIfTI file."""
65
- file_data = FileData(path=str(nifti_path))
66
- return file_data.url # Returns /file=... URL served by Gradio
67
- ```
68
-
69
- ### Option B: Use Gradio's file caching
70
-
71
- ```python
72
- import gradio as gr
73
-
74
- # Gradio caches files and provides URLs
75
- cached_path = gr.utils.get_upload_folder() / nifti_path.name
76
- shutil.copy(nifti_path, cached_path)
77
- file_url = f"/file={cached_path}"
78
- ```
79
-
80
- ---
81
-
82
- ## Files to Modify
83
-
84
- | File | Changes |
85
- |------|---------|
86
- | `src/stroke_deepisles_demo/ui/viewer.py` | Replace `nifti_to_data_url()` with file URL function |
87
- | `src/stroke_deepisles_demo/ui/app.py` | Update `run_segmentation()` to use file URLs |
88
-
89
- ---
90
-
91
- ## Implementation Steps
92
-
93
- ### Step 1: Research Gradio File Serving
94
-
95
- Verify how Gradio serves files and what URL format NiiVue expects:
96
-
97
- ```python
98
- # Test script
99
- import gradio as gr
100
- from gradio import FileData
101
-
102
- file_data = FileData(path="/path/to/test.nii.gz")
103
- print(f"URL: {file_data.url}")
104
- print(f"Type: {type(file_data.url)}")
105
- ```
106
-
107
- ### Step 2: Update `nifti_to_data_url()` → `nifti_to_file_url()`
108
-
109
- ```python
110
- # viewer.py
111
- def nifti_to_file_url(nifti_path: Path) -> str:
112
- """Get Gradio-served file URL for NIfTI file.
113
-
114
- Args:
115
- nifti_path: Path to NIfTI file
116
-
117
- Returns:
118
- URL string that Gradio will serve (e.g., /file=...)
119
- """
120
- from gradio import FileData
121
- file_data = FileData(path=str(nifti_path))
122
- return file_data.url
123
- ```
124
-
125
- ### Step 3: Update `app.py` to Use File URLs
126
-
127
- ```python
128
- # app.py - run_segmentation()
129
- # Replace:
130
- dwi_url = nifti_to_data_url(dwi_path)
131
- mask_url = nifti_to_data_url(result.prediction_mask)
132
-
133
- # With:
134
- dwi_url = nifti_to_file_url(dwi_path)
135
- mask_url = nifti_to_file_url(result.prediction_mask)
136
- ```
137
-
138
- ### Step 4: Test NiiVue with File URLs
139
-
140
- Verify NiiVue can load from Gradio's file URLs:
141
- - Check CORS headers
142
- - Verify Content-Type header
143
- - Test with different browsers
144
-
145
- ### Step 5: Cleanup
146
-
147
- Remove or deprecate `nifti_to_data_url()` if no longer needed.
148
-
149
- ---
150
-
151
- ## Testing Checklist
152
-
153
- - [x] NiiVue loads DWI volume from file URL
154
- - [x] NiiVue loads prediction mask overlay from file URL
155
- - [x] No CORS errors in browser console (same-origin requests)
156
- - [x] Loading time improved (no base64 encoding overhead)
157
- - [x] Memory usage reduced (streaming vs. DOM strings)
158
- - [x] Works on HF Spaces deployment (uses tempfile.gettempdir())
159
- - [x] All existing tests pass (134 tests)
160
-
161
- ---
162
-
163
- ## Implementation Details
164
-
165
- ### Final Implementation (2025-12-09)
166
-
167
- The solution uses Gradio's built-in file serving at `/gradio_api/file=<path>`:
168
-
169
- **`viewer.py` - New function:**
170
- ```python
171
- def nifti_to_gradio_url(nifti_path: Path) -> str:
172
- """Get Gradio file URL for a NIfTI file."""
173
- abs_path = nifti_path.resolve()
174
- return f"/gradio_api/file={abs_path}"
175
- ```
176
-
177
- **`app.py` - Updated usage:**
178
- ```python
179
- dwi_url = nifti_to_gradio_url(dwi_path)
180
- mask_url = nifti_to_gradio_url(result.prediction_mask)
181
- ```
182
-
183
- ### Why This Works
184
-
185
- 1. **Gradio allows temp files by default**: Files in `tempfile.gettempdir()` are
186
- automatically accessible via the `/gradio_api/file=` endpoint.
187
-
188
- 2. **Pipeline results are in temp dir**: `run_pipeline_on_case()` creates results
189
- in `tempfile.mkdtemp()`, which is under `tempfile.gettempdir()`.
190
-
191
- 3. **NiiVue supports HTTP URLs**: The `loadVolumes()` method can fetch from any
192
- HTTP/HTTPS URL, including relative URLs served by Gradio.
193
-
194
- 4. **Same-origin requests**: Since NiiVue's JavaScript runs in the browser and
195
- requests files from the same Gradio server, there are no CORS issues.
196
-
197
- ### Tests Added
198
-
199
- ```python
200
- class TestNiftiToGradioUrl:
201
- def test_returns_gradio_api_format(self, synthetic_nifti_3d: Path) -> None:
202
- url = nifti_to_gradio_url(synthetic_nifti_3d)
203
- assert url.startswith("/gradio_api/file=")
204
-
205
- def test_uses_absolute_path(self, synthetic_nifti_3d: Path) -> None:
206
- url = nifti_to_gradio_url(synthetic_nifti_3d)
207
- path_part = url.replace("/gradio_api/file=", "")
208
- assert path_part.startswith("/")
209
-
210
- def test_no_base64_encoding(self, synthetic_nifti_3d: Path) -> None:
211
- url = nifti_to_gradio_url(synthetic_nifti_3d)
212
- assert not url.startswith("data:")
213
- assert ";base64," not in url
214
- ```
215
-
216
- ---
217
-
218
- ## Risks and Mitigations
219
-
220
- | Risk | Mitigation |
221
- |------|------------|
222
- | CORS issues | Gradio should handle CORS for its own file serving |
223
- | NiiVue URL format | Test that NiiVue accepts relative URLs |
224
- | File cleanup | Gradio handles temp file cleanup automatically |
225
- | Security | Gradio's file serving is sandboxed to allowed paths |
226
-
227
- ---
228
-
229
- ## Acceptance Criteria
230
-
231
- 1. NiiVue viewer loads volumes from file URLs (not base64)
232
- 2. No regression in viewer functionality
233
- 3. Measurable improvement in loading time or memory usage
234
- 4. All 130+ tests pass
235
- 5. Works on HF Spaces
236
-
237
- ---
238
-
239
- ## References
240
-
241
- - [Gradio FileData API](https://www.gradio.app/docs/gradio/filedata)
242
- - [Gradio File Serving](https://www.gradio.app/guides/file-access)
243
- - [NiiVue Loading Volumes](https://niivue.github.io/niivue/features/loading.volumes.html)
244
- - [Bug #10 - Secondary Issue 1](./10-bug-niivue-viewer-black-screen.md)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/archive/23-slice-comparison-overlay-bug.md DELETED
@@ -1,287 +0,0 @@
1
- # Bug Investigation: Slice Comparison Prediction Overlay Not Visible
2
-
3
- **Issue**: Prediction overlay is invisible in slice comparison while ground truth overlay is visible
4
-
5
- **Date**: 2025-12-09
6
- **Branch**: `debug/slice-comparison-prediction-overlay`
7
-
8
- ---
9
-
10
- ## Observed Behavior
11
-
12
- In the Gradio UI "Slice Comparison" tab:
13
- - **DWI Input** (left panel): Shows grayscale brain scan ✓
14
- - **Prediction** (middle panel): Shows grayscale brain scan **without any visible overlay** ✗
15
- - **Ground Truth** (right panel): Shows grayscale brain scan **with green overlay** ✓
16
-
17
- ## Expected Behavior
18
-
19
- The Prediction panel should show a **red overlay** on the predicted lesion area, similar to how Ground Truth shows a green overlay.
20
-
21
- ---
22
-
23
- ## Code Analysis
24
-
25
- ### Visualization Code (`viewer.py:261-268`)
26
-
27
- ```python
28
- # Prediction panel
29
- axes[1].imshow(d_slice, cmap="gray")
30
- axes[1].imshow(
31
- np.ma.masked_where(p_slice == 0, p_slice),
32
- cmap="Reds",
33
- alpha=0.5,
34
- vmin=0,
35
- vmax=1,
36
- )
37
- ```
38
-
39
- ### Ground Truth Code (`viewer.py:273-280`)
40
-
41
- ```python
42
- # Ground Truth panel
43
- axes[2].imshow(d_slice, cmap="gray")
44
- axes[2].imshow(
45
- np.ma.masked_where(g_slice == 0, g_slice),
46
- cmap="Greens",
47
- alpha=0.5,
48
- vmin=0,
49
- vmax=1,
50
- )
51
- ```
52
-
53
- The code is **structurally identical**. The only difference is:
54
- - Prediction: `cmap="Reds"`
55
- - Ground Truth: `cmap="Greens"`
56
-
57
- ---
58
-
59
- ## Hypothesis
60
-
61
- ### Primary Hypothesis: Probability vs Binary Mask Values
62
-
63
- | Mask Type | Typical Values | Colormap Rendering | Visibility |
64
- |-----------|----------------|-------------------|------------|
65
- | Ground Truth | Binary (0 or 1) | 1.0 → **Dark Green** | High ✓ |
66
- | Prediction | Probabilities (0.0-0.3) | 0.1 → **Nearly White** | None ✗ |
67
-
68
- **Why this matters:**
69
-
70
- 1. Matplotlib's **"Reds" colormap** goes from white (0) → red (1)
71
- 2. With `vmin=0, vmax=1`:
72
- - A value of `0.05` maps to 5% of the colormap = nearly white
73
- - A value of `1.0` maps to 100% of the colormap = red
74
- 3. With `alpha=0.5` over a grayscale background, nearly-white overlays are **invisible**
75
-
76
- **Evidence:**
77
- - DeepISLES SEALS model may output probability maps, not binary masks
78
- - The `compute_dice` function in `metrics.py` applies a `threshold=0.5` to binarize predictions
79
- - The visualization does **not** apply any thresholding before display
80
-
81
- ### Alternative Hypotheses
82
-
83
- 1. **Empty slice**: Prediction mask is all zeros at the selected slice (unlikely given the slice selection logic uses `get_slice_at_max_lesion(prediction_path)`)
84
-
85
- 2. **Data type issue**: Float comparison `p_slice == 0` may fail for float32 arrays (unlikely - works for ground truth)
86
-
87
- 3. **File path mismatch**: Wrong file being loaded as prediction (need to verify)
88
-
89
- ---
90
-
91
- ## Diagnostic Steps
92
-
93
- ### 1. Check Prediction Mask Values
94
-
95
- ```python
96
- import nibabel as nib
97
- import numpy as np
98
-
99
- # Load a prediction mask from a recent run
100
- pred = nib.load("/path/to/prediction.nii.gz").get_fdata()
101
- print(f"Shape: {pred.shape}")
102
- print(f"Dtype: {pred.dtype}")
103
- print(f"Min: {pred.min()}, Max: {pred.max()}")
104
- print(f"Unique values: {np.unique(pred)[:20]}") # First 20 unique values
105
- print(f"Non-zero count: {np.count_nonzero(pred)}")
106
- print(f"Values > 0.5: {np.count_nonzero(pred > 0.5)}")
107
- ```
108
-
109
- ### 2. Check Ground Truth Mask Values
110
-
111
- ```python
112
- gt = nib.load("/path/to/ground_truth.nii.gz").get_fdata()
113
- print(f"Shape: {gt.shape}")
114
- print(f"Dtype: {gt.dtype}")
115
- print(f"Min: {gt.min()}, Max: {gt.max()}")
116
- print(f"Unique values: {np.unique(gt)}")
117
- ```
118
-
119
- ### 3. Visual Comparison
120
-
121
- ```python
122
- # Plot histogram of values
123
- import matplotlib.pyplot as plt
124
- fig, axes = plt.subplots(1, 2)
125
- axes[0].hist(pred[pred > 0].flatten(), bins=50)
126
- axes[0].set_title("Prediction non-zero values")
127
- axes[1].hist(gt[gt > 0].flatten(), bins=50)
128
- axes[1].set_title("Ground Truth non-zero values")
129
- plt.savefig("mask_histograms.png")
130
- ```
131
-
132
- ---
133
-
134
- ## Proposed Fix
135
-
136
- ### Option A: Binarize Prediction Before Display (Recommended)
137
-
138
- ```python
139
- # In render_slice_comparison, before creating overlay:
140
- p_slice_binary = (p_slice > 0.5).astype(float)
141
-
142
- axes[1].imshow(
143
- np.ma.masked_where(p_slice_binary == 0, p_slice_binary),
144
- cmap="Reds",
145
- alpha=0.5,
146
- vmin=0,
147
- vmax=1,
148
- )
149
- ```
150
-
151
- **Pros:**
152
- - Consistent with how `compute_dice` treats predictions
153
- - Clear visualization of model decision boundary
154
- - Matches clinical interpretation (lesion vs not-lesion)
155
-
156
- **Cons:**
157
- - Loses probability information in visualization
158
-
159
- ### Option B: Dynamic Normalization
160
-
161
- ```python
162
- # Normalize to actual value range instead of fixed 0-1
163
- p_max = p_slice.max() if p_slice.max() > 0 else 1.0
164
- axes[1].imshow(
165
- np.ma.masked_where(p_slice == 0, p_slice),
166
- cmap="Reds",
167
- alpha=0.5,
168
- vmin=0,
169
- vmax=p_max,
170
- )
171
- ```
172
-
173
- **Pros:**
174
- - Shows probability information
175
- - Works regardless of value range
176
-
177
- **Cons:**
178
- - Inconsistent intensity across cases
179
- - Low-confidence predictions still appear bright (misleading)
180
-
181
- ### Option C: Threshold-Based Masking
182
-
183
- ```python
184
- # Only show values above a threshold
185
- threshold = 0.5
186
- axes[1].imshow(
187
- np.ma.masked_where(p_slice < threshold, p_slice),
188
- cmap="Reds",
189
- alpha=0.5,
190
- vmin=threshold,
191
- vmax=1.0,
192
- )
193
- ```
194
-
195
- **Pros:**
196
- - Only shows confident predictions
197
- - Good dynamic range for visible values
198
-
199
- **Cons:**
200
- - May hide uncertain but potentially relevant areas
201
-
202
- ---
203
-
204
- ## Recommendation
205
-
206
- **Implement Option A (Binarize)** because:
207
-
208
- 1. It matches the clinical use case (segmentation → binary decision)
209
- 2. It's consistent with `compute_dice` threshold behavior
210
- 3. It provides clear, interpretable visualization
211
- 4. The raw probability map can still be viewed in NiiVue if needed
212
-
213
- ---
214
-
215
- ## Dependencies
216
-
217
- | Package | Version | Relevant |
218
- |---------|---------|----------|
219
- | gradio | >=6.0.0 | Unlikely cause (renders matplotlib figure correctly) |
220
- | matplotlib | >=3.8.0 | Colormap behavior is standard |
221
- | numpy | >=1.26.0,<2.0.0 | Float comparison works correctly |
222
- | nibabel | >=5.2.0 | Loads data correctly |
223
-
224
- ---
225
-
226
- ## Resolution
227
-
228
- **Status**: FIXED (2025-12-09)
229
- **Branch**: `debug/slice-comparison-prediction-overlay`
230
-
231
- ### Changes Made
232
-
233
- **Primary Fix (Issue #23):**
234
-
235
- 1. **`viewer.py:270-275`**: Added binarization of prediction mask in `render_slice_comparison`:
236
- ```python
237
- # Binarize prediction at threshold 0.5 for visible overlay (Issue #23)
238
- p_slice_binary = (p_slice > 0.5).astype(float)
239
- ```
240
-
241
- 2. **`viewer.py:156-164`**: Added binarization in `render_3panel_view` for consistency
242
-
243
- 3. **`tests/conftest.py`**: Added `synthetic_probability_mask` and `synthetic_binary_mask` fixtures
244
-
245
- 4. **`tests/ui/test_viewer.py`**: Added `TestRenderSliceComparisonProbabilityMask` test class
246
-
247
- **Additional Fixes (Found During Audit):**
248
-
249
- 5. **Race Condition (P2)**: Replaced global `_previous_results_dir` with `gr.State` for per-session thread-safe cleanup tracking
250
-
251
- 6. **Inconsistent Threshold in compute_volume_ml**: Added `threshold=0.5` parameter for consistent binarization
252
-
253
- 7. **render_3panel_view Wired Into UI**:
254
- - Added `gr.Tabs` layout with "Interactive 3D" and "Static Report" tabs
255
- - `render_3panel_view` now displayed in "Static Report" alongside slice comparison
256
- - Provides WebGL2 fallback via static matplotlib figures
257
-
258
- 8. **Thread-Safe Matplotlib**: Refactored from `pyplot` API to Object-Oriented API (`Figure()`) for multi-user safety
259
-
260
- ### Verification
261
-
262
- - All 136 tests pass
263
- - Lint (ruff) passes
264
- - Type check (mypy) passes
265
-
266
- ## Files Modified
267
-
268
- | File | Changes |
269
- |------|---------|
270
- | `src/stroke_deepisles_demo/ui/viewer.py` | OO matplotlib API, binarization in both render functions |
271
- | `src/stroke_deepisles_demo/ui/app.py` | gr.State, render_3panel_view integration, volume_ml |
272
- | `src/stroke_deepisles_demo/ui/components.py` | Tabs layout (Interactive 3D / Static Report) |
273
- | `src/stroke_deepisles_demo/metrics.py` | threshold parameter for compute_volume_ml |
274
- | `tests/conftest.py` | New probability/binary mask fixtures |
275
- | `tests/ui/test_viewer.py` | Probability mask tests |
276
- | `tests/ui/test_app.py` | Updated for new return signature |
277
-
278
- ## Next Steps
279
-
280
- 1. [x] Run diagnostic script to confirm hypothesis
281
- 2. [x] Implement fix (Option A - binarize)
282
- 3. [x] Add test case for probability-valued masks
283
- 4. [x] Wire render_3panel_view into UI with tabs
284
- 5. [x] Fix race condition with gr.State
285
- 6. [x] Make matplotlib thread-safe with OO API
286
- 7. [ ] Verify fix in local Gradio app (manual testing recommended)
287
- 8. [ ] Create PR and merge to main
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/archive/24-bug-hf-spaces-loading-forever.md DELETED
@@ -1,254 +0,0 @@
1
- # Bug #24: HuggingFace Space Stuck on "Loading..." (P0)
2
-
3
- **Date:** 2025-12-09
4
- **Status:** FIXED
5
- **Branch:** `debug/hf-spaces-loading-forever`
6
- **Space:** https://huggingface.co/spaces/VibecoderMcSwaggins/stroke-deepisles-demo
7
-
8
- ---
9
-
10
- ## Symptom
11
-
12
- The HuggingFace Space shows:
13
- - **Status badge:** "Running on T4" (green) ✓
14
- - **App panel:** Stuck on "Loading..." indefinitely ✗
15
-
16
- The Docker container has started successfully (hence "Running on T4"), but the Gradio frontend never receives a response from the backend.
17
-
18
- ---
19
-
20
- ## What We Know
21
-
22
- ### Local Testing: Works Fine
23
- ```python
24
- # All pass in ~1.3 seconds
25
- from stroke_deepisles_demo.ui.app import create_app
26
- demo = create_app() # Returns gr.Blocks successfully
27
- ```
28
-
29
- ### Code on HF Space
30
- The Space is synced with `main` branch (commit `10a72ea`), NOT the PR #23 branch.
31
- - `js_on_load` parameter was already added in commit `bc1d8e8`
32
- - Server binds to `0.0.0.0:7860` (correct for Docker)
33
- - Dataset loading uses pre-computed case IDs (no network calls on startup)
34
-
35
- ### Configuration Verified
36
- | Setting | Value | Status |
37
- |---------|-------|--------|
38
- | `sdk` | `docker` | ✓ Correct |
39
- | `app_port` | `7860` | ✓ Correct |
40
- | `server_name` | `0.0.0.0` | ✓ Correct |
41
- | `server_port` | `7860` | ✓ Correct |
42
- | Gradio version | `>=6.0.0,<7.0.0` | ✓ Correct |
43
-
44
- ---
45
-
46
- ## Hypotheses
47
-
48
- ### H1: Python Startup Crash (Silent)
49
- The Python app may be crashing during startup but HF Spaces still shows "Running on T4" because the container process is alive (perhaps a shell wrapper).
50
-
51
- **Check:** Look at HF Spaces logs for Python tracebacks.
52
-
53
- ### H2: Gradio Server Not Binding
54
- The Gradio server may be failing to bind or timing out before accepting connections.
55
-
56
- **Check:** Look for "Running on local URL" in logs.
57
-
58
- ### H3: HF Spaces Platform Issue
59
- HuggingFace Spaces may have a platform-wide issue affecting Docker SDK spaces.
60
-
61
- **Check:** https://status.huggingface.co/ and HF Forums.
62
-
63
- ### H4: Memory/Resource Exhaustion
64
- The T4 instance may be running out of memory during startup.
65
-
66
- **Check:** Look for OOM errors in logs.
67
-
68
- ### H5: Dependencies Installation Failure
69
- The `git+https://github.com/CloseChoice/datasets.git@...` dependency may fail to install.
70
-
71
- **Check:** Build logs for pip install errors.
72
-
73
- ---
74
-
75
- ## Diagnostic Steps
76
-
77
- ### 1. Check HF Spaces Logs
78
- Go to the Space → Settings → Logs and look for:
79
- - Python tracebacks
80
- - "Running on local URL: http://0.0.0.0:7860"
81
- - Memory errors
82
- - Dependency installation errors
83
-
84
- ### 2. Factory Rebuild
85
- Settings → Factory rebuild to force a clean Docker build.
86
-
87
- ### 3. Check HF Status
88
- Visit https://status.huggingface.co/ for platform outages.
89
-
90
- ### 4. Test Minimal Dockerfile
91
- Create a minimal test Space with just Gradio to isolate the issue:
92
-
93
- ```dockerfile
94
- FROM python:3.11-slim
95
- RUN pip install gradio
96
- COPY <<EOF app.py
97
- import gradio as gr
98
- demo = gr.Interface(fn=lambda x: x, inputs="text", outputs="text")
99
- demo.launch(server_name="0.0.0.0", server_port=7860)
100
- EOF
101
- CMD ["python", "app.py"]
102
- ```
103
-
104
- ---
105
-
106
- ## Related Issues
107
-
108
- - [HF Forum: Space stuck at Starting](https://discuss.huggingface.co/t/hf-space-stuck-at-starting/170911) (Nov 2025)
109
- - [HF Forum: Dockerized app stuck at Building](https://discuss.huggingface.co/t/dockerized-gradio-app-stuck-at-building-despite-clean-logs/65558)
110
- - [HF Forum: How to debug Spaces](https://discuss.huggingface.co/t/how-to-debug-spaces-on-hf-co/13191)
111
- - [Gradio Issue #11401: Errors accessing spaces](https://github.com/gradio-app/gradio/issues/11401) (June 2025)
112
-
113
- ---
114
-
115
- ## Questions for User
116
-
117
- 1. **When did the Space last work correctly?** (Before which commit/PR?)
118
- 2. **What do the HF Spaces logs show?** (Settings → Logs)
119
- 3. **Has a factory rebuild been attempted?**
120
- 4. **Is HF Spaces having any platform issues today?**
121
-
122
- ---
123
-
124
- ## Next Steps
125
-
126
- 1. [ ] User to check HF Spaces logs
127
- 2. [ ] User to attempt factory rebuild
128
- 3. [ ] Check if issue is platform-wide (HF status page)
129
- 4. [ ] If needed, create minimal reproduction Space
130
- 5. [ ] If dependency issue, consider vendoring the datasets fork
131
-
132
- ---
133
-
134
- ## Resolution
135
-
136
- **Status:** FIXED (2025-12-09)
137
-
138
- ### Root Cause
139
-
140
- **Content Security Policy (CSP) blocking external CDN imports.**
141
-
142
- The NiiVue library was being loaded via dynamic ES module import from unpkg.com CDN:
143
- ```javascript
144
- const { Niivue } = await import('https://unpkg.com/@niivue/niivue@0.65.0/dist/index.js');
145
- ```
146
-
147
- HuggingFace Spaces enforces strict CSP headers that block external script imports. The import would either:
148
- 1. Be silently blocked by CSP
149
- 2. Hang indefinitely waiting for a response that never comes
150
-
151
- This caused the Gradio frontend to remain stuck on "Loading..." even though the Python backend was running correctly.
152
-
153
- **Evidence:**
154
- - HF Spaces logs showed `Running on local URL: http://0.0.0.0:7860` (server healthy)
155
- - No Python tracebacks (no backend crash)
156
- - `list_case_ids()` uses pre-computed constants (no network blocking)
157
- - Classic symptom of client-side JS execution failure
158
-
159
- ### Fix Applied
160
-
161
- **Vendored the NiiVue library locally** instead of relying on external CDN:
162
-
163
- 1. **Downloaded NiiVue to local assets:**
164
- - `src/stroke_deepisles_demo/ui/assets/niivue.js` (2.9MB)
165
-
166
- 2. **Updated `viewer.py` to use local path:**
167
- ```python
168
- _ASSET_DIR = Path(__file__).parent / "assets"
169
- _NIIVUE_JS_PATH = _ASSET_DIR / "niivue.js"
170
- NIIVUE_JS_URL = f"/gradio_api/file={_NIIVUE_JS_PATH.resolve()}"
171
- ```
172
-
173
- 3. **Added `allowed_paths` to `demo.launch()`:**
174
- ```python
175
- assets_dir = Path(__file__).parent / "assets"
176
- demo.launch(
177
- # ...
178
- allowed_paths=[str(assets_dir)],
179
- )
180
- ```
181
-
182
- ### Files Modified
183
-
184
- | File | Changes |
185
- |------|---------|
186
- | `src/stroke_deepisles_demo/ui/assets/niivue.js` | NEW - Vendored NiiVue v0.65.0 |
187
- | `src/stroke_deepisles_demo/ui/viewer.py` | Use local path instead of CDN |
188
- | `src/stroke_deepisles_demo/ui/app.py` | Add `allowed_paths` to launch() |
189
- | `app.py` | Add `allowed_paths` to launch() |
190
- | `.pre-commit-config.yaml` | Exclude assets/ from hooks |
191
-
192
- ### Verification
193
-
194
- - All 136 tests pass
195
- - Ruff lint passes
196
- - Mypy type check passes
197
- - Local Gradio app loads correctly
198
-
199
- ### Why This Is The Professional Solution
200
-
201
- 1. **Self-contained:** No external dependencies at runtime
202
- 2. **Reliable:** Immune to CDN outages or rate limits
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.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/archive/AUDIT_JS_LOADING_ISSUES.md DELETED
@@ -1,935 +0,0 @@
1
- # Comprehensive Audit: JavaScript Loading Issues on HuggingFace Spaces
2
-
3
- **Created:** 2025-12-09
4
- **Status:** P0 - Critical
5
- **Issue:** HF Spaces stuck on "Loading..." forever despite "Running on T4"
6
-
7
- ---
8
-
9
- ## Executive Summary
10
-
11
- The NiiVue 3D viewer fails to load on HuggingFace Spaces due to a combination of JavaScript loading issues, timing race conditions, and architectural problems. This document catalogs EVERY potential issue found in the codebase.
12
-
13
- ---
14
-
15
- ## ROOT CAUSES IDENTIFIED
16
-
17
- ### 1. Module Script Timing Race Condition (CRITICAL)
18
-
19
- **Location:** `src/stroke_deepisles_demo/ui/viewer.py:64-68`
20
-
21
- ```python
22
- loader_content = f"""...
23
- <script type="module">
24
- import {{ Niivue }} from '{NIIVUE_JS_URL}';
25
- window.Niivue = Niivue;
26
- console.log('[NiiVue Loader] Loaded globally:', typeof window.Niivue);
27
- </script>
28
- """
29
- ```
30
-
31
- **Problem:** `<script type="module">` is **deferred by default**. It executes AFTER HTML parsing completes, but `js_on_load` may run BEFORE the module finishes loading.
32
-
33
- **Impact:** `window.Niivue` is `undefined` when `NIIVUE_ON_LOAD_JS` tries to access it.
34
-
35
- ---
36
-
37
- ### 2. Dynamic Path Resolution at Import Time
38
-
39
- **Location:** `src/stroke_deepisles_demo/ui/viewer.py:32-36`
40
-
41
- ```python
42
- _ASSET_DIR = Path(__file__).parent / "assets"
43
- _NIIVUE_JS_PATH = _ASSET_DIR / "niivue.js"
44
- NIIVUE_JS_URL = f"/gradio_api/file={_NIIVUE_JS_PATH.resolve()}"
45
- ```
46
-
47
- **Problem:** `NIIVUE_JS_URL` is computed at **module import time** with `.resolve()`. This creates an absolute path like:
48
- - Local: `/Users/ray/Desktop/.../assets/niivue.js`
49
- - HF Spaces: `/home/user/demo/src/.../assets/niivue.js`
50
-
51
- **Risk:** If the path is wrong or the file is not accessible, the module import fails silently.
52
-
53
- ---
54
-
55
- ### 3. Two Entry Points with Different Configurations
56
-
57
- **Location:** Root `app.py` vs `src/stroke_deepisles_demo/ui/app.py`
58
-
59
- **Dockerfile uses:**
60
- ```dockerfile
61
- CMD ["python", "-m", "stroke_deepisles_demo.ui.app"]
62
- ```
63
-
64
- This runs `src/stroke_deepisles_demo/ui/app.py` as `__main__`, NOT root `app.py`.
65
-
66
- **Both files configure `head_paths` and `allowed_paths` in their `if __name__ == "__main__":` blocks:**
67
-
68
- Root `app.py:35-49`:
69
- ```python
70
- assets_dir = Path(__file__).parent / "src" / "stroke_deepisles_demo" / "ui" / "assets"
71
- ```
72
-
73
- `src/.../ui/app.py:278-292`:
74
- ```python
75
- assets_dir = Path(__file__).parent / "assets"
76
- ```
77
-
78
- **Risk:** Different path calculations, potential mismatch.
79
-
80
- ---
81
-
82
- ### 4. Async IIFE in js_on_load
83
-
84
- **Location:** `src/stroke_deepisles_demo/ui/viewer.py:441-526` and `viewer.py:535-625`
85
-
86
- ```javascript
87
- NIIVUE_ON_LOAD_JS = """
88
- (async () => {
89
- // ... async code ...
90
- })();
91
- """
92
- ```
93
-
94
- **Problem:** Gradio's `js_on_load` mechanism may not properly handle async IIFEs. If the function throws before completing, Gradio's frontend initialization may hang.
95
-
96
- ---
97
-
98
- ### 5. Error Message Inconsistency / Stale Comments
99
-
100
- **Location:** `src/stroke_deepisles_demo/ui/viewer.py:437-440`
101
-
102
- ```python
103
- # IMPORTANT: This code uses window.Niivue which must be loaded via
104
- # gr.Blocks(head=get_niivue_head_script()). Do NOT use dynamic import()
105
- ```
106
-
107
- **But we actually use `head_paths`!** Comment is stale.
108
-
109
- **Location:** `src/stroke_deepisles_demo/ui/viewer.py:473`
110
- ```javascript
111
- throw new Error('NiiVue not loaded. Ensure head script is included via gr.Blocks(head=...)');
112
- ```
113
-
114
- **Wrong!** Should reference `head_paths`, not `head`.
115
-
116
- ---
117
-
118
- ### 6. Deprecated Function Still Present
119
-
120
- **Location:** `src/stroke_deepisles_demo/ui/viewer.py:95-109`
121
-
122
- ```python
123
- def get_niivue_head_script() -> str:
124
- """
125
- DEPRECATED: Use get_niivue_loader_path() with head_paths instead.
126
- """
127
- ```
128
-
129
- **Risk:** Could be accidentally used, causing confusion.
130
-
131
- ---
132
-
133
- ### 7. Test Script Uses CDN (Outdated Pattern)
134
-
135
- **Location:** `scripts/test_js_on_load.py:38` and `scripts/test_js_on_load.py:76`
136
-
137
- ```javascript
138
- const mod = await import('https://unpkg.com/@niivue/niivue@0.65.0/dist/index.js');
139
- ```
140
-
141
- **Problem:** This is the EXACT pattern that was blocked by HF Spaces CSP! The test script uses the old CDN approach.
142
-
143
- ---
144
-
145
- ### 8. niivue-loader.html Generated at Runtime
146
-
147
- **Location:** `src/stroke_deepisles_demo/ui/viewer.py:39-91`
148
-
149
- ```python
150
- def get_niivue_loader_path() -> Path:
151
- loader_path = _ASSET_DIR / "niivue-loader.html"
152
- # ... generates file at runtime ...
153
- ```
154
-
155
- **Gitignored at:** `.gitignore:219`
156
- ```text
157
- src/stroke_deepisles_demo/ui/assets/niivue-loader.html
158
- ```
159
-
160
- **Risk:**
161
- - File must be generated before `launch()` is called
162
- - Write permissions required on HF Spaces
163
- - If generation fails, `head_paths` has invalid file
164
-
165
- ---
166
-
167
- ## ALL JAVASCRIPT CODE LOCATIONS
168
-
169
- ### Production Code
170
-
171
- | File | Line | Type | Content |
172
- |------|------|------|---------|
173
- | `viewer.py` | 64-68 | ES Module | `import { Niivue } from '...'` in loader HTML |
174
- | `viewer.py` | 105-109 | ES Module | Deprecated `get_niivue_head_script()` |
175
- | `viewer.py` | 441-526 | js_on_load | `NIIVUE_ON_LOAD_JS` - async IIFE |
176
- | `viewer.py` | 535-625 | .then(js=) | `NIIVUE_UPDATE_JS` - async IIFE |
177
- | `components.py` | 49 | js_on_load | `js_on_load=NIIVUE_ON_LOAD_JS` |
178
- | `ui/app.py` | 250 | .then(js=) | `js=NIIVUE_UPDATE_JS` |
179
-
180
- ### Test/Development Code
181
-
182
- | File | Line | Type | Content |
183
- |------|------|------|---------|
184
- | `test_js_on_load.py` | 38 | Dynamic Import | CDN import (unpkg.com) - **BLOCKED BY CSP** |
185
- | `test_js_on_load.py` | 76 | Dynamic Import | CDN import (unpkg.com) - **BLOCKED BY CSP** |
186
-
187
- ---
188
-
189
- ## ALL EXTERNAL URLs
190
-
191
- ### In Production Code
192
-
193
- | File | Line | URL | Status |
194
- |------|------|-----|--------|
195
- | `viewer.py` | 36 | `/gradio_api/file=...` | Internal (OK) |
196
-
197
- ### In Documentation (Historical)
198
-
199
- | File | URL | Status |
200
- |------|-----|--------|
201
- | `docs/specs/00-context.md:202` | `https://unpkg.com/@niivue/niivue@0.57.0/dist/index.js` | **BLOCKED BY CSP** |
202
- | `docs/specs/07-hf-spaces-deployment.md:239` | `https://unpkg.com/@niivue/niivue@0.65.0/dist/index.js` | **BLOCKED BY CSP** |
203
- | `docs/specs/07-hf-spaces-deployment.md:259` | `https://unpkg.com/@niivue/niivue@0.65.0/dist/index.js` | **BLOCKED BY CSP** |
204
- | `docs/specs/07-hf-spaces-deployment.md:592` | `https://unpkg.com/@niivue/niivue@0.65.0/dist/index.js` | **BLOCKED BY CSP** |
205
-
206
- ---
207
-
208
- ## ALL head_paths / allowed_paths CONFIGURATIONS
209
-
210
- | File | Line | Configuration |
211
- |------|------|---------------|
212
- | `app.py` | 48-49 | `allowed_paths=[str(assets_dir)], head_paths=[str(niivue_loader)]` |
213
- | `ui/app.py` | 291-292 | `allowed_paths=[str(assets_dir)], head_paths=[str(niivue_loader)]` |
214
-
215
- ---
216
-
217
- ## ALL async/await PATTERNS IN JAVASCRIPT
218
-
219
- | File | Line | Pattern | Risk |
220
- |------|------|---------|------|
221
- | `viewer.py` | 442 | `(async () => { ... })();` | Unhandled rejection may hang Gradio |
222
- | `viewer.py` | 536 | `(async () => { ... })();` | Unhandled rejection may hang Gradio |
223
- | `test_js_on_load.py` | 24 | `(async () => { ... })();` | Test-only |
224
- | `test_js_on_load.py` | 35 | `(async () => { ... })();` | Test-only |
225
- | `test_js_on_load.py` | 61 | `(async () => { ... })();` | Test-only |
226
-
227
- ---
228
-
229
- ## POTENTIAL CSP VIOLATIONS
230
-
231
- ### HuggingFace Spaces CSP Headers (Suspected)
232
-
233
- ```text
234
- Content-Security-Policy:
235
- script-src 'self' 'unsafe-inline' 'unsafe-eval';
236
- connect-src 'self' ...;
237
- ```
238
-
239
- ### Code That May Violate CSP
240
-
241
- 1. **Dynamic ES Module Import** - `<script type="module">` with `import()` from local file
242
- - Should be OK if file is same-origin
243
- - May fail if path resolution is wrong
244
-
245
- 2. **External CDN (Historical)** - `import('https://unpkg.com/...')`
246
- - **BLOCKED** by `script-src` not including unpkg.com
247
-
248
- ---
249
-
250
- ## TIMING DIAGRAM: What SHOULD Happen
251
-
252
- ```text
253
- 1. Gradio loads HTML page
254
- 2. <head> includes niivue-loader.html via head_paths
255
- 3. Module script in loader imports niivue.js
256
- 4. window.Niivue is set globally
257
- 5. gr.HTML component mounts
258
- 6. js_on_load runs, accesses window.Niivue
259
- 7. NiiVue initializes
260
- ```
261
-
262
- ## TIMING DIAGRAM: What MAY Be Happening
263
-
264
- ```text
265
- 1. Gradio loads HTML page
266
- 2. <head> includes niivue-loader.html via head_paths
267
- 3. Module script DEFERRED (not executed yet)
268
- 4. gr.HTML component mounts
269
- 5. js_on_load runs, window.Niivue is UNDEFINED
270
- 6. Error thrown: "NiiVue not loaded"
271
- 7. Gradio hangs waiting for component
272
- ```
273
-
274
- ---
275
-
276
- ## RECOMMENDED FIXES (Priority Order)
277
-
278
- ### P0: Verify head_paths is Actually Working
279
-
280
- Add diagnostic logging:
281
- ```python
282
- print(f"[DEBUG] niivue_loader path: {niivue_loader}")
283
- print(f"[DEBUG] File exists: {Path(niivue_loader).exists()}")
284
- print(f"[DEBUG] File contents: {Path(niivue_loader).read_text()[:200]}")
285
- ```
286
-
287
- ### P1: Add Module Load Waiting
288
-
289
- Change NIIVUE_ON_LOAD_JS to wait for window.Niivue:
290
- ```javascript
291
- (async () => {
292
- // Wait for NiiVue to be available (max 5 seconds)
293
- for (let i = 0; i < 50 && !window.Niivue; i++) {
294
- await new Promise(r => setTimeout(r, 100));
295
- }
296
- if (!window.Niivue) {
297
- throw new Error('NiiVue failed to load after 5 seconds');
298
- }
299
- // ... rest of initialization
300
- })();
301
- ```
302
-
303
- ### P2: Use Non-Module Script Tag
304
-
305
- Instead of `<script type="module">`, use regular script:
306
- ```html
307
- <script>
308
- // UMD build instead of ESM
309
- </script>
310
- ```
311
-
312
- ### P3: Bundle NiiVue into a Single IIFE
313
-
314
- Create a self-contained bundle that doesn't need ES module import.
315
-
316
- ---
317
-
318
- ## FILES TO AUDIT BEFORE ANY FIX
319
-
320
- 1. `src/stroke_deepisles_demo/ui/viewer.py` - All JS constants
321
- 2. `src/stroke_deepisles_demo/ui/components.py` - js_on_load usage
322
- 3. `src/stroke_deepisles_demo/ui/app.py` - .then(js=) usage, launch config
323
- 4. `app.py` - launch config
324
- 5. `.gitignore` - niivue-loader.html entry
325
- 6. `Dockerfile` - CMD entry point
326
-
327
- ---
328
-
329
- ## VERSION HISTORY
330
-
331
- | Date | Change | Result |
332
- |------|--------|--------|
333
- | Pre-bc1d8e8 | Inline `<script>` tags | Black screen (scripts stripped) |
334
- | bc1d8e8 | js_on_load + CDN import | Loading forever (CSP blocked CDN) |
335
- | 1973147 | Vendored niivue.js | Loading forever (still using import()) |
336
- | 08c3363 | head_paths approach | Loading forever (timing race?) |
337
-
338
- ---
339
-
340
- ---
341
-
342
- ## RESEARCH FINDINGS FROM WEB
343
-
344
- ### Source 1: GitHub Issue #11649 - head_paths is Official Solution
345
-
346
- **URL:** https://github.com/gradio-app/gradio/issues/11649
347
-
348
- **Finding:** Gradio maintainer @dawoodkhan82 explicitly recommended `head_paths`:
349
- > "use the `head_paths` param where you can pass a path or list of paths to html files, and in that file you can include your `<script>`"
350
-
351
- **Confirmation:** "I just tested, and this works on my end."
352
-
353
- **Implication:** Our approach using `head_paths` is correct according to Gradio maintainers.
354
-
355
- ---
356
-
357
- ### Source 2: GitHub Issue #10250 - head Parameter JS Execution Non-Deterministic
358
-
359
- **URL:** https://github.com/gradio-app/gradio/issues/10250
360
-
361
- **Finding:** JavaScript in `head` parameter has non-deterministic execution:
362
- > "JavaScript would sometimes execute only after extended waiting periods (5+ minutes), or occasionally not at all."
363
-
364
- **Root Cause:** Timing issues between Gradio's frontend initialization and script loading.
365
-
366
- **Implication:** Even if `head_paths` works, the timing may be unpredictable.
367
-
368
- ---
369
-
370
- ### Source 3: ES Module Script Timing
371
-
372
- **URLs:**
373
- - https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script
374
- - https://gist.github.com/jakub-g/385ee6b41085303a53ad92c7c8afd7a6
375
-
376
- **Finding:** Module scripts execute BEFORE DOMContentLoaded:
377
- > "The DOMContentLoaded event fires when the HTML document has been completely parsed, and all deferred scripts (`<script defer src="…">` and `<script type="module">`) have downloaded and executed."
378
-
379
- **Key Points:**
380
- - Module scripts are deferred by default
381
- - They execute AFTER HTML parsing but BEFORE DOMContentLoaded
382
- - Regular inline scripts execute immediately
383
-
384
- **Implication:** In theory, `window.Niivue` should be set BEFORE Gradio's frontend fully initializes. BUT Gradio may initialize components differently.
385
-
386
- ---
387
-
388
- ### Source 4: Gradio js_on_load Parameter
389
-
390
- **URL:** https://www.gradio.app/docs/gradio/html
391
-
392
- **Finding:** `js_on_load` executes "when the component is loaded."
393
-
394
- **Available Variables:**
395
- - `element` - the HTML element of the component
396
- - `trigger` - function to trigger events
397
- - `props` - component properties
398
-
399
- **Default:** `"element.addEventListener('click', function() { trigger('click') });"`
400
-
401
- **Implication:** js_on_load runs during Svelte component mounting, which may be AFTER or BEFORE module scripts complete.
402
-
403
- ---
404
-
405
- ### Source 5: Gradio Frontend Architecture
406
-
407
- **URL:** https://www.gradio.app/guides/frontend
408
-
409
- **Finding:** Gradio frontend is built with Svelte 5 and SvelteKit. Components use Svelte's `onMount` lifecycle.
410
-
411
- **Svelte onMount Timing:**
412
- > "The onMount function schedules a callback to run as soon as the component has been mounted to the DOM."
413
-
414
- **Implication:** js_on_load likely runs during `onMount`, which is AFTER the component renders to DOM. Module scripts in `<head>` should have already executed by then... BUT there may be framework-specific timing issues.
415
-
416
- ---
417
-
418
- ### Source 6: HuggingFace Spaces CSP
419
-
420
- **URL:** https://huggingface.co/docs/hub/spaces-config-reference
421
-
422
- **Finding:** HF Spaces only allows these custom headers:
423
- - `cross-origin-embedder-policy`
424
- - `cross-origin-opener-policy`
425
- - `cross-origin-resource-policy`
426
-
427
- **Content-Security-Policy is NOT customizable.**
428
-
429
- **Implication:** We cannot modify CSP. We must work within HF Spaces' default CSP.
430
-
431
- ---
432
-
433
- ### Source 7: HF Spaces Perpetual Loading
434
-
435
- **URL:** https://discuss.huggingface.co/t/issue-with-perpetual-loading-on-the-space/35684
436
-
437
- **Finding:** Browser cache can cause perpetual loading even when Space is running correctly.
438
-
439
- **Solution:** Clear browser cache.
440
-
441
- **Implication:** Some "Loading..." issues may be client-side, not server-side.
442
-
443
- ---
444
-
445
- ### Source 8: Gradio Custom JS Documentation
446
-
447
- **URL:** https://www.gradio.app/guides/custom-CSS-and-JS
448
-
449
- **Key Differences:**
450
-
451
- | Parameter | Location | Timing | Purpose |
452
- |-----------|----------|--------|---------|
453
- | `js` in launch() | Page body | Page load | Interactive logic |
454
- | `head` in launch() | `<head>` | Document init | Setup/analytics |
455
- | `head_paths` | `<head>` | Document init | External files |
456
- | `js_on_load` | Component | Component mount | Per-component |
457
-
458
- **Warning from docs:**
459
- > "Query selectors in custom JS and CSS are _not_ guaranteed to work across Gradio versions"
460
-
461
- ---
462
-
463
- ## REVISED THEORY: Why It's Still Breaking
464
-
465
- Based on research, here's the likely sequence:
466
-
467
- 1. **Browser requests page from HF Spaces**
468
- 2. **Gradio server returns HTML with `<head>` contents from `head_paths`**
469
- 3. **Browser parses HTML, encounters `<script type="module">` in `<head>`**
470
- 4. **Module script is DEFERRED** (won't block parsing)
471
- 5. **Gradio's Svelte frontend initializes**
472
- 6. **gr.HTML component mounts → `js_on_load` runs**
473
- 7. **`js_on_load` tries to access `window.Niivue`**
474
- 8. **If module hasn't finished loading → `window.Niivue` is undefined**
475
- 9. **Error is thrown or code hangs**
476
-
477
- The issue is that Gradio's Svelte components may mount BEFORE all deferred scripts complete, even though DOMContentLoaded waits for them.
478
-
479
- ---
480
-
481
- ## ALTERNATIVE THEORIES
482
-
483
- ### Theory A: head_paths File Not Being Served
484
-
485
- The `niivue-loader.html` file might not be accessible via Gradio's file serving on HF Spaces.
486
-
487
- **Test:** Check browser Network tab for 404 on niivue-loader.html or niivue.js
488
-
489
- ### Theory B: allowed_paths Not Working
490
-
491
- The `allowed_paths` parameter might not be properly allowing access to the assets directory on HF Spaces.
492
-
493
- **Test:** Try serving a simple text file via /gradio_api/file=
494
-
495
- ### Theory C: Path Resolution Mismatch
496
-
497
- The absolute path in `NIIVUE_JS_URL` might be wrong for the HF Spaces Docker environment.
498
-
499
- **Expected path:** `/home/user/demo/src/stroke_deepisles_demo/ui/assets/niivue.js`
500
-
501
- **Test:** Log the actual path and verify it exists
502
-
503
- ### Theory D: Svelte Hydration Issue
504
-
505
- Gradio's Svelte frontend might be having hydration issues that prevent proper initialization.
506
-
507
- **Symptom:** Page shows "Loading..." but no JavaScript errors in console
508
-
509
- ### Theory E: Uncaught Promise Rejection
510
-
511
- The async IIFE in js_on_load might be throwing an uncaught error that Gradio doesn't handle gracefully.
512
-
513
- **Test:** Wrap entire js_on_load in try-catch with console.error
514
-
515
- ---
516
-
517
- ## COMPREHENSIVE FIX STRATEGY
518
-
519
- ### Step 1: Add Polling for window.Niivue
520
-
521
- Don't assume window.Niivue exists. Poll for it:
522
-
523
- ```javascript
524
- async function waitForNiivue(timeout = 10000) {
525
- const start = Date.now();
526
- while (!window.Niivue && Date.now() - start < timeout) {
527
- await new Promise(r => setTimeout(r, 100));
528
- }
529
- return window.Niivue;
530
- }
531
- ```
532
-
533
- ### Step 2: Add Comprehensive Error Handling
534
-
535
- Catch all errors and display them visually:
536
-
537
- ```javascript
538
- try {
539
- const Niivue = await waitForNiivue();
540
- if (!Niivue) {
541
- element.innerHTML = '<div style="color:red;">NiiVue failed to load after 10s</div>';
542
- return;
543
- }
544
- // ... rest of code
545
- } catch (e) {
546
- console.error('NiiVue error:', e);
547
- element.innerHTML = '<div style="color:red;">Error: ' + e.message + '</div>';
548
- }
549
- ```
550
-
551
- ### Step 3: Add Diagnostic Logging
552
-
553
- Log everything to console for debugging:
554
-
555
- ```javascript
556
- console.log('[NiiVue] js_on_load started');
557
- console.log('[NiiVue] window.Niivue:', typeof window.Niivue);
558
- console.log('[NiiVue] element:', element);
559
- console.log('[NiiVue] volumeUrl:', volumeUrl);
560
- ```
561
-
562
- ### Step 4: Consider Alternative Loading Method
563
-
564
- If module script timing is fundamentally broken, use the `js` parameter in launch() to load NiiVue:
565
-
566
- ```python
567
- NIIVUE_LOADER_JS = """
568
- (async () => {
569
- const script = document.createElement('script');
570
- script.type = 'module';
571
- script.textContent = `import { Niivue } from '/gradio_api/file=...'; window.Niivue = Niivue;`;
572
- document.head.appendChild(script);
573
- })();
574
- """
575
-
576
- demo.launch(js=NIIVUE_LOADER_JS, ...)
577
- ```
578
-
579
- ---
580
-
581
- ## CONCLUSION
582
-
583
- The root cause is likely a **timing race condition** where `js_on_load` executes before the ES module in `head_paths` finishes loading.
584
-
585
- **Secondary issues:**
586
- - Stale comments referencing wrong parameters
587
- - Deprecated functions still in codebase
588
- - Test scripts using blocked CDN patterns
589
- - No error visibility when things fail
590
-
591
- **Research confirms:**
592
- 1. `head_paths` IS the correct approach (GitHub #11649)
593
- 2. BUT `head` parameter JS execution can be non-deterministic (GitHub #10250)
594
- 3. Module scripts SHOULD execute before component mount
595
- 4. Gradio's Svelte frontend may have its own timing quirks
596
-
597
- **Next step:** Add diagnostic logging AND polling for window.Niivue to handle timing uncertainty.
598
-
599
- ---
600
-
601
- ## CRITICAL FINDING: THE UPSTREAM BLOCKER
602
-
603
- ### The Real Root Cause: `allowed_paths` Bug in Gradio 5.x+
604
-
605
- **Source:** https://github.com/gradio-app/gradio/issues/11649
606
-
607
- **Finding:** `allowed_paths` has known bugs in Gradio 5.x and 6.x:
608
- > "Starting from Gradio 5.x, files are not accessible anymore via the `/file=` path even if they are in a subfolder of the project root."
609
-
610
- **Our Setup:**
611
- - Gradio version: `>=6.0.0,<7.0.0`
612
- - We use: `allowed_paths=[str(assets_dir)]`
613
- - We do NOT use: `gr.set_static_paths()`
614
-
615
- **The Bug:**
616
- - We tell Gradio to allow serving from `assets/` directory
617
- - niivue-loader.html contains: `import { Niivue } from '/gradio_api/file=.../niivue.js'`
618
- - The `/gradio_api/file=...` URL returns **404 NOT FOUND** due to the Gradio bug
619
- - Module import fails silently
620
- - `window.Niivue` is never set
621
- - `js_on_load` tries to use `window.Niivue` → undefined → error
622
- - Gradio frontend hangs
623
-
624
- ### The Fix: Use `gr.set_static_paths()`
625
-
626
- **Source:** https://www.gradio.app/docs/gradio/set_static_paths
627
-
628
- **Key Requirements:**
629
- 1. Call `gr.set_static_paths()` BEFORE creating Blocks
630
- 2. Pass the assets directory path
631
- 3. Files become accessible at `/gradio_api/file=<path>`
632
-
633
- **Example:**
634
- ```python
635
- import gradio as gr
636
- from pathlib import Path
637
-
638
- # MUST be called BEFORE creating Blocks!
639
- assets_dir = Path(__file__).parent / "src" / "stroke_deepisles_demo" / "ui" / "assets"
640
- gr.set_static_paths(paths=[str(assets_dir)])
641
-
642
- # Now create the demo
643
- demo = create_app()
644
-
645
- demo.launch(
646
- # allowed_paths may still be needed for runtime files
647
- allowed_paths=[str(assets_dir)],
648
- head_paths=[str(niivue_loader)],
649
- )
650
- ```
651
-
652
- ---
653
-
654
- ## COMPREHENSIVE FIX LIST
655
-
656
- ### Fix 1: Add `gr.set_static_paths()` (CRITICAL - UPSTREAM BLOCKER)
657
-
658
- **Files to modify:**
659
- - `app.py` (root entry point)
660
- - `src/stroke_deepisles_demo/ui/app.py` (module entry point)
661
-
662
- **Change:**
663
- ```python
664
- # At module level, BEFORE any demo creation
665
- import gradio as gr
666
- from pathlib import Path
667
-
668
- _ASSETS_DIR = Path(__file__).parent / "assets" # Adjust path per file
669
- gr.set_static_paths(paths=[str(_ASSETS_DIR)])
670
- ```
671
-
672
- ### Fix 2: Add Polling for window.Niivue (DEFENSIVE)
673
-
674
- **File:** `src/stroke_deepisles_demo/ui/viewer.py`
675
-
676
- **Change:** Modify NIIVUE_ON_LOAD_JS and NIIVUE_UPDATE_JS to poll for window.Niivue
677
-
678
- ### Fix 3: Update Stale Comments (CLEANUP)
679
-
680
- **File:** `src/stroke_deepisles_demo/ui/viewer.py:437-440`
681
-
682
- **Change:** Update comments to reference `head_paths` and `set_static_paths`
683
-
684
- ### Fix 4: Update Error Messages (CLEANUP)
685
-
686
- **File:** `src/stroke_deepisles_demo/ui/viewer.py:473, 571`
687
-
688
- **Change:** Update error messages to be more helpful
689
-
690
- ### Fix 5: Remove Deprecated Function (CLEANUP)
691
-
692
- **File:** `src/stroke_deepisles_demo/ui/viewer.py:95-109`
693
-
694
- **Change:** Remove `get_niivue_head_script()` or mark it more clearly
695
-
696
- ### Fix 6: Update Test Script (CLEANUP)
697
-
698
- **File:** `scripts/test_js_on_load.py:38, 76`
699
-
700
- **Change:** Update to use local vendored NiiVue instead of CDN
701
-
702
- ---
703
-
704
- ## FINAL DIAGNOSIS
705
-
706
- **One upstream blocker:** Missing `gr.set_static_paths()` call
707
-
708
- **Why:** Gradio 6.x has a known bug where `allowed_paths` doesn't properly enable file serving. The official workaround is `gr.set_static_paths()`.
709
-
710
- **Chain of failure:**
711
- ```text
712
- Missing gr.set_static_paths()
713
-
714
- /gradio_api/file=.../niivue.js returns 404
715
-
716
- ES module import in niivue-loader.html fails
717
-
718
- window.Niivue is never set
719
-
720
- js_on_load checks window.Niivue → undefined
721
-
722
- Error thrown or NiiVue never initializes
723
-
724
- Gradio frontend may hang on "Loading..."
725
- ```
726
-
727
- **Secondary issues** (should be fixed but not blocking):
728
- - Stale comments
729
- - Deprecated functions
730
- - Test scripts using CDN
731
- - No error visibility
732
-
733
- **Vendoring niivue.js WAS necessary** because:
734
- 1. CDN imports are blocked by HF Spaces CSP
735
- 2. Local files need to be served via Gradio's file serving
736
- 3. `gr.set_static_paths()` enables this
737
-
738
- ---
739
-
740
- ## VERIFICATION STEPS AFTER FIX
741
-
742
- 1. Run locally: `python -m stroke_deepisles_demo.ui.app`
743
- 2. Open browser DevTools → Network tab
744
- 3. Check that `/gradio_api/file=.../niivue.js` returns 200 (not 404)
745
- 4. Check console for "[NiiVue Loader] Loaded globally: function"
746
- 5. Run segmentation and verify 3D viewer works
747
- 6. Deploy to HF Spaces and repeat verification
748
-
749
- ---
750
-
751
- ## DEEP AUDIT COMPLETE - FINAL SUMMARY
752
-
753
- **Audit Date:** 2025-12-09
754
- **Auditor:** Claude (Opus 4.5)
755
- **Status:** COMPLETE - All issues identified
756
-
757
- ### DEFINITIVE LIST OF ALL ISSUES
758
-
759
- | # | Severity | File | Line(s) | Issue | Fix Required |
760
- |---|----------|------|---------|-------|--------------|
761
- | 1 | **CRITICAL** | `ui/app.py` | 284 | Missing `gr.set_static_paths()` before Blocks creation | Add call before `get_demo()` |
762
- | 2 | **CRITICAL** | `app.py` | 26 | Missing `gr.set_static_paths()` before Blocks creation | Add call before `get_demo()` |
763
- | 3 | HIGH | `viewer.py` | 437-440 | Stale comment says `gr.Blocks(head=...)` | Update to reference `head_paths` and `set_static_paths` |
764
- | 4 | HIGH | `viewer.py` | 473 | Wrong error message: "gr.Blocks(head=...)" | Update to reference `head_paths` |
765
- | 5 | MEDIUM | `viewer.py` | 530-533 | Stale comment says `head=` | Update to reference `head_paths` |
766
- | 6 | MEDIUM | `viewer.py` | 95-109 | Deprecated `get_niivue_head_script()` still exists | Remove or clearly mark |
767
- | 7 | LOW | `test_js_on_load.py` | 38, 76 | Uses CDN imports (blocked by CSP) | Update to use local NiiVue |
768
-
769
- ### CONFIRMED NON-ISSUES
770
-
771
- These were investigated and confirmed NOT to be problems:
772
-
773
- | Item | Status | Reason |
774
- |------|--------|--------|
775
- | `niivue.js` vendoring | ✅ CORRECT | CDN is blocked by HF Spaces CSP |
776
- | `head_paths` approach | ✅ CORRECT | Official Gradio recommendation |
777
- | `js_on_load` usage | ✅ CORRECT | Proper way for component-level JS |
778
- | Path calculation in `ui/app.py` | ✅ CORRECT | Docker uses this entry point |
779
- | `niivue-loader.html` gitignored | ✅ CORRECT | Generated at runtime with env-specific path |
780
- | `allowed_paths` in launch() | ✅ CORRECT | Still needed for runtime files |
781
-
782
- ### ROOT CAUSE CHAIN
783
-
784
- ```text
785
- [UPSTREAM BLOCKER]
786
- Both entry points call get_demo() BEFORE gr.set_static_paths()
787
-
788
- Gradio 6.x bug: allowed_paths alone doesn't enable file serving
789
-
790
- /gradio_api/file=.../niivue.js returns 404
791
-
792
- <script type="module"> import fails silently
793
-
794
- window.Niivue is never set
795
-
796
- js_on_load throws "NiiVue not loaded" error
797
-
798
- Gradio frontend hangs on "Loading..."
799
- ```
800
-
801
- ### SEARCH PATTERNS USED
802
-
803
- All search patterns used to find issues:
804
-
805
- - `gradio_api|file=|allowed_paths|head_paths|set_static_paths|js_on_load`
806
- - `import\s*\(|from\s+['"]https?://`
807
- - `unpkg|jsdelivr|cdnjs|cdn\.|esm\.sh`
808
- - `window\.|document\.|<script|<link|<style`
809
- - `async|await|Promise|setTimeout`
810
- - `throw|Error\(|error|catch|try`
811
- - `https?://[^'\"\s]+`
812
- - `Path\(__file__|__file__`
813
- - `\.resolve\(\)|\.absolute\(\)`
814
-
815
- ### CONFIDENCE LEVEL
816
-
817
- **100% confidence** that all JavaScript loading issues have been identified.
818
-
819
- The fix for Issue #1 and #2 (`gr.set_static_paths()`) is the **only upstream blocker**. All other issues are cleanup/hardening.
820
-
821
- ---
822
-
823
- ## WEB-VERIFIED FIXES (December 2025)
824
-
825
- ### Fix #1 & #2: `gr.set_static_paths()` - VERIFIED CORRECT
826
-
827
- **Source:** [Gradio set_static_paths Documentation](https://www.gradio.app/docs/gradio/set_static_paths)
828
-
829
- **Official Documentation Confirms:**
830
- - "Calling this function will set the static paths for all gradio applications defined in the same interpreter session"
831
- - Must be called **BEFORE** creating Blocks
832
- - Files become network-accessible via `/gradio_api/file=<path>`
833
- - Files are "served directly from the file system instead of being copied"
834
-
835
- **Correct Implementation:**
836
- ```python
837
- import gradio as gr
838
- from pathlib import Path
839
-
840
- # MUST be called BEFORE get_demo() or create_app()
841
- _ASSETS_DIR = Path(__file__).parent / "assets"
842
- gr.set_static_paths(paths=[str(_ASSETS_DIR)])
843
-
844
- # Now create the demo
845
- demo = get_demo()
846
- demo.launch(...)
847
- ```
848
-
849
- ---
850
-
851
- ### `head_paths` Approach - VERIFIED CORRECT
852
-
853
- **Source:** [GitHub Issue #11649](https://github.com/gradio-app/gradio/issues/11649)
854
-
855
- **Gradio Maintainer @dawoodkhan82 explicitly recommended:**
856
- > "use the `head_paths` param where you can pass a path or list of paths to html files, and in that file you can include your `<script>`"
857
-
858
- **Issue Status:** Closed as resolved on August 25, 2025
859
-
860
- **Our Approach:** We're using `head_paths` correctly in `launch()`.
861
-
862
- ---
863
-
864
- ### ES Module Load Order - VERIFIED
865
-
866
- **Source:** [MDN DOMContentLoaded](https://developer.mozilla.org/en-US/docs/Web/API/Document/DOMContentLoaded_event)
867
-
868
- **Official MDN Documentation:**
869
- > "The DOMContentLoaded event fires when the HTML document has been completely parsed, and all deferred scripts (`<script defer src="…">` and `<script type="module">`) have downloaded and executed."
870
-
871
- **Source:** [MDN JavaScript Modules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules)
872
-
873
- **Module Scope:**
874
- > "Module-defined variables are scoped to the module unless explicitly attached to the global object."
875
-
876
- **Our Approach:** We correctly use `window.Niivue = Niivue;` to expose globally.
877
-
878
- **Conclusion:** If `set_static_paths()` enables file serving, ES modules SHOULD execute before `js_on_load`. Polling is DEFENSIVE but may not be strictly necessary.
879
-
880
- ---
881
-
882
- ### Gradio 6 Migration - VERIFIED COMPATIBLE
883
-
884
- **Source:** [Gradio 6 Migration Guide](https://www.gradio.app/main/guides/gradio-6-migration-guide)
885
-
886
- **Key Changes in Gradio 6:**
887
- - `theme`, `css`, `css_paths`, `js`, `head`, `head_paths` moved from `gr.Blocks()` to `launch()`
888
- - "Gradio 6.1.0 was uploaded on December 9, 2025"
889
- - "Only Gradio 6 will receive ongoing support"
890
-
891
- **Our Code:** Already uses `launch()` for these parameters - CORRECT.
892
-
893
- ---
894
-
895
- ### `js_on_load` Parameter - VERIFIED EXISTS
896
-
897
- **Source:** [Gradio HTML Component Docs](https://www.gradio.app/docs/gradio/html)
898
-
899
- **Available Variables:**
900
- - `element` - References the HTML element
901
- - `trigger` - Function for dispatching events
902
- - `props` - Object for modifying values
903
-
904
- **Note:** Documentation does NOT explicitly address async/await patterns. Our async IIFE may work but is not officially documented.
905
-
906
- ---
907
-
908
- ## FINAL VERIFIED FIX STRATEGY
909
-
910
- | Fix | Approach | Source | Confidence |
911
- |-----|----------|--------|------------|
912
- | #1-2: `set_static_paths()` | Call BEFORE `get_demo()` | [Gradio Docs](https://www.gradio.app/docs/gradio/set_static_paths) | ✅ 100% |
913
- | `head_paths` usage | Already correct | [GitHub #11649](https://github.com/gradio-app/gradio/issues/11649) | ✅ 100% |
914
- | Polling for Niivue | DEFENSIVE only | [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Document/DOMContentLoaded_event) | ⚠️ Optional |
915
- | Stale comments | Cleanup | N/A | ✅ Do it |
916
- | Deprecated function | Remove | N/A | ✅ Do it |
917
- | Test script CDN | Update | N/A | ✅ Do it |
918
-
919
- ---
920
-
921
- ## WHY `allowed_paths` ALONE DOESN'T WORK
922
-
923
- Based on [GitHub Issue #11649](https://github.com/gradio-app/gradio/issues/11649) and [Gradio File Access Guide](https://www.gradio.app/guides/file-access):
924
-
925
- **`allowed_paths`** (in `launch()`):
926
- - Controls security permissions for file access
927
- - Does NOT enable static file serving by itself
928
- - May require files to be copied to Gradio cache first
929
-
930
- **`gr.set_static_paths()`** (function call):
931
- - Enables direct file serving without caching
932
- - Files served with `Content-Disposition: inline`
933
- - Files become accessible at `/gradio_api/file=<path>`
934
-
935
- **The Bug:** In Gradio 5.x/6.x, using `allowed_paths` alone does not properly enable `/gradio_api/file=` serving for arbitrary paths. The `set_static_paths()` function is required.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/archive/DIAGNOSTIC_HF_LOADING.md DELETED
@@ -1,228 +0,0 @@
1
- # Diagnostic: HuggingFace Spaces "Loading..." Forever Bug
2
-
3
- **Date**: 2025-12-10
4
- **Status**: UNRESOLVED - App stuck on "Loading..." despite backend running
5
- **Symptom**: HF Spaces shows "Running on T4", logs show successful startup, but UI never renders
6
-
7
- ## Observed Behavior
8
-
9
- ```
10
- ===== Application Startup at 2025-12-10 02:10:47 =====
11
- * Running on local URL: http://0.0.0.0:7860
12
- ```
13
-
14
- - Backend Python starts successfully
15
- - Gradio server binds to `0.0.0.0:7860` (correct for Docker)
16
- - Frontend shows Gradio "Loading..." spinner indefinitely
17
- - No error messages in container logs
18
-
19
- ---
20
-
21
- ## Research Findings
22
-
23
- ### 1. Known Gradio Issues with Custom JavaScript
24
-
25
- #### Issue #11649: Custom JS via `head` fails with 404
26
- **Source**: [GitHub Issue #11649](https://github.com/gradio-app/gradio/issues/11649)
27
-
28
- Users report custom JavaScript files failing to load even with `allowed_paths` configured.
29
-
30
- **Solution found**: Use `head_paths` parameter instead of `head`:
31
- ```python
32
- with gr.Blocks(head_paths=["custom.html"]) as demo:
33
- ```
34
-
35
- Alternative URL format that works: `/gradio_api/file=static/custom.js` instead of `/file/static/custom.js`
36
-
37
- #### Issue #10250: JavaScript in `head` param not executing
38
- **Source**: [GitHub Issue #10250](https://github.com/gradio-app/gradio/issues/10250)
39
-
40
- JavaScript occasionally executes after extended delays (5-10+ minutes) or not at all.
41
-
42
- **Key insight**: `gr.HTML()` components intentionally do NOT execute JavaScript for security. Only `head` parameter supports JS execution.
43
-
44
- #### Issue #6426: gr.Blocks head argument not working
45
- **Source**: [GitHub Issue #6426](https://github.com/gradio-app/gradio/issues/6426)
46
-
47
- Two critical bugs:
48
- 1. Only the FIRST script tag from `head` is applied
49
- 2. Script tags are injected AFTER page loads, preventing execution
50
-
51
- **Fixed in PR #6639** - but may require specific Gradio version.
52
-
53
- ### 2. ES Module Script Behavior
54
-
55
- **Source**: [ES Modules Explainer](https://gist.github.com/jakub-g/385ee6b41085303a53ad92c7c8afd7a6)
56
-
57
- Key facts about `<script type="module">`:
58
- - **Always deferred by default** - does NOT block HTML parsing
59
- - **No way to make it blocking** - even without async/defer
60
- - If import fails, script silently fails but shouldn't block page
61
-
62
- **Implication**: Our NiiVue loader (`<script type="module">`) should NOT be blocking Gradio's rendering. The issue is elsewhere.
63
-
64
- ### 3. HuggingFace Spaces Known Issues
65
-
66
- #### Origin Mismatch Bug (Issue #10893)
67
- **Source**: [HF Forum Thread](https://discuss.huggingface.co/t/gradio-space-javascript-not-executing-fields-not-populating-persistent-syntaxerror-in-browser-console/163689)
68
-
69
- Gradio's `postMessage` calls fail due to origin mismatch between `https://huggingface.co` and the actual Space URL. This can prevent JavaScript execution entirely.
70
-
71
- #### Docker "Running" But "Loading..." Forever
72
- **Source**: [HF Forum Thread](https://discuss.huggingface.co/t/space-stuck-in-building-despite-gradio-welcome-port/36890)
73
-
74
- Common causes:
75
- - Binding to `127.0.0.1` instead of `0.0.0.0` (**we already fixed this**)
76
- - Incorrect port configuration (**we use 7860**)
77
- - Private Space authentication issues (**our Space is public**)
78
-
79
- #### Interface Not Showing Despite Running
80
- **Source**: [HF Forum Thread](https://discuss.huggingface.co/t/bug-free-gradio-interface-not-loading-on-hfs/25102)
81
-
82
- Assigning Interface to a variable before launching can cause this:
83
- ```python
84
- # WRONG
85
- iface = gr.Interface(...).launch()
86
-
87
- # RIGHT
88
- gr.Interface(...).launch()
89
- ```
90
-
91
- **Our code**: We use `get_demo().launch()` which SHOULD be correct.
92
-
93
- ### 4. Gradio set_static_paths Requirements
94
-
95
- **Source**: [Gradio Docs](https://www.gradio.app/docs/gradio/set_static_paths)
96
-
97
- - Must be called BEFORE creating Blocks
98
- - Affects ALL Gradio apps in the same interpreter session
99
- - Exposes entire directories to network (security consideration)
100
-
101
- **Our code**: We call `gr.set_static_paths()` at module level before imports. ✅
102
-
103
- ### 5. Browser Cache Issues
104
-
105
- **Source**: [HF Forum Thread](https://discuss.huggingface.co/t/issue-with-perpetual-loading-on-the-space/35684)
106
-
107
- Some "Loading..." issues resolved by clearing browser cache. Works in one browser but not another.
108
-
109
- **Unlikely cause**: This is a fresh deployment, not a cache issue.
110
-
111
- ---
112
-
113
- ## Our Current Implementation
114
-
115
- ### JavaScript Loading Flow
116
-
117
- 1. `app.py` (root entry point for HF Spaces Docker):
118
- - Calls `gr.set_static_paths(paths=[str(_ASSETS_DIR)])` before imports
119
- - Imports `get_demo()` and `get_niivue_loader_path()`
120
- - Launches with `head_paths=[str(niivue_loader)]`
121
-
122
- 2. `ui/app.py` (also calls set_static_paths):
123
- - Module-level `gr.set_static_paths()` before imports
124
- - Creates demo with `js_on_load=NIIVUE_ON_LOAD_JS` on gr.HTML component
125
-
126
- 3. `viewer.py`:
127
- - Generates `niivue-loader.html` at runtime with absolute path
128
- - Content: `<script type="module">import { Niivue } from '/gradio_api/file=...'</script>`
129
-
130
- ### Files Involved
131
-
132
- | File | Purpose | Status |
133
- |------|---------|--------|
134
- | `app.py` (root) | HF Spaces entry point | Uses head_paths ✅ |
135
- | `src/.../ui/app.py` | Main UI module | Uses js_on_load ✅ |
136
- | `src/.../ui/viewer.py` | NiiVue loader generation | Generates at runtime ✅ |
137
- | `src/.../ui/components.py` | UI components | Uses NIIVUE_ON_LOAD_JS ✅ |
138
- | `src/.../ui/assets/niivue.js` | Vendored NiiVue library | 2.9MB, tracked ✅ |
139
- | `src/.../ui/assets/niivue-loader.html` | Generated loader | gitignored ✅ |
140
-
141
- ### Dockerfile CMD
142
-
143
- ```dockerfile
144
- CMD ["python", "-m", "stroke_deepisles_demo.ui.app"]
145
- ```
146
-
147
- This runs `ui/app.py` as `__main__`, which should execute our launch() with head_paths.
148
-
149
- ---
150
-
151
- ## Hypotheses
152
-
153
- ### H1: `js_on_load` Breaking Gradio Initialization
154
-
155
- **Theory**: The `js_on_load` parameter on `gr.HTML` might be executing before Gradio fully initializes, causing a crash.
156
-
157
- **Evidence**: Our code has `js_on_load=NIIVUE_ON_LOAD_JS` which is a complex async IIFE.
158
-
159
- **Test**: Remove `js_on_load` parameter and see if app loads.
160
-
161
- ### H2: `head_paths` Not Being Applied on HF Spaces
162
-
163
- **Theory**: The `head_paths` parameter might not be reaching the frontend on HF Spaces due to Docker networking or Gradio configuration.
164
-
165
- **Evidence**: Issue #11649 shows head-related parameters have bugs.
166
-
167
- **Test**: Check browser Network tab for niivue.js 404 or missing script.
168
-
169
- ### H3: demo.load() Blocking Initial Render
170
-
171
- **Theory**: The `demo.load(initialize_case_selector, ...)` call might be blocking the initial UI render.
172
-
173
- **Evidence**: `initialize_case_selector()` calls `list_case_ids()` which loads HuggingFace dataset.
174
-
175
- **Test**: Remove demo.load() and see if app loads.
176
-
177
- ### H4: Double set_static_paths Causing Conflict
178
-
179
- **Theory**: Both `app.py` (root) and `ui/app.py` call `gr.set_static_paths()`. This might cause conflicts.
180
-
181
- **Evidence**: Gradio docs say "affects ALL Gradio apps in same interpreter session".
182
-
183
- **Test**: Remove one of the set_static_paths calls.
184
-
185
- ### H5: Module Import Order Issue
186
-
187
- **Theory**: The order of imports and set_static_paths calls might matter on HF Spaces but not locally.
188
-
189
- **Evidence**: We have `noqa: E402` comments indicating non-standard import order.
190
-
191
- **Test**: Trace exact import order and when set_static_paths is effective.
192
-
193
- ### H6: Path Resolution Different in Docker
194
-
195
- **Theory**: `Path(__file__).resolve()` might resolve to different paths in Docker vs local.
196
-
197
- **Evidence**: We use absolute paths for NIIVUE_JS_URL computed at import time.
198
-
199
- **Test**: Log the actual paths being computed in Docker.
200
-
201
- ---
202
-
203
- ## Diagnostic Steps to Try
204
-
205
- 1. **Minimal Test**: Create a branch that removes ALL custom JS and test if basic Gradio loads
206
- 2. **Log Paths**: Add logging to show exactly what paths are computed in Docker
207
- 3. **Browser DevTools**: Check Network tab and Console for errors (if accessible)
208
- 4. **Gradio Version**: Verify we're using a version with all relevant fixes
209
- 5. **HF Spaces Logs**: Check full container logs for any Python errors not shown in UI
210
-
211
- ---
212
-
213
- ## Related Documentation
214
-
215
- - [AUDIT_JS_LOADING_ISSUES.md](./AUDIT_JS_LOADING_ISSUES.md) - Previous audit of JavaScript loading issues
216
- - [docs/specs/24-bug-hf-spaces-loading-forever.md](./docs/specs/24-bug-hf-spaces-loading-forever.md) - Original bug specification
217
-
218
- ---
219
-
220
- ## External Resources
221
-
222
- - [Gradio Custom CSS and JS Guide](https://www.gradio.app/guides/custom-CSS-and-JS)
223
- - [Gradio File Access Guide](https://www.gradio.app/guides/file-access)
224
- - [Gradio set_static_paths Docs](https://www.gradio.app/docs/gradio/set_static_paths)
225
- - [Gradio Issue #11649](https://github.com/gradio-app/gradio/issues/11649) - head_paths solution
226
- - [Gradio Issue #10250](https://github.com/gradio-app/gradio/issues/10250) - head JS not executing
227
- - [Gradio Issue #6426](https://github.com/gradio-app/gradio/issues/6426) - head argument bugs
228
- - [HF Spaces Docker Guide](https://huggingface.co/docs/hub/spaces-sdks-docker)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/archive/ROOT_CAUSE_ANALYSIS.md DELETED
@@ -1,230 +0,0 @@
1
- # Root Cause Analysis: HF Spaces "Loading..." Forever (Issue #24)
2
-
3
- **Date:** 2025-12-10
4
- **Status:** IN PROGRESS
5
- **Branch:** `debug/niivue-head-script-loading`
6
-
7
- ---
8
-
9
- ## Executive Summary
10
-
11
- The HuggingFace Spaces app hangs on "Loading..." indefinitely because **dynamic ES module `import()` inside `gr.HTML(js_on_load=...)` blocks Gradio's Svelte frontend from hydrating**.
12
-
13
- This was proven empirically in our own A/B test documented in `docs/specs/24-bug-hf-spaces-loading-forever.md`:
14
-
15
- > **Diagnostic test:** Disabled `js_on_load` parameter entirely.
16
- > **Result:** App loads perfectly! Everything works EXCEPT Interactive 3D viewer.
17
-
18
- ---
19
-
20
- ## First Principles Analysis
21
-
22
- ### How Gradio Renders
23
-
24
- 1. Server sends initial HTML with loading spinner
25
- 2. Gradio's Svelte app downloads and hydrates
26
- 3. Components mount, including `gr.HTML`
27
- 4. `js_on_load` executes during component mount
28
- 5. Loading spinner clears when hydration completes
29
-
30
- ### Why Dynamic Import Blocks Hydration
31
-
32
- The current `js_on_load` code does this (viewer.py:497):
33
-
34
- ```javascript
35
- const module = await import(niivueUrl); // <-- BLOCKS HYDRATION
36
- window.Niivue = module.Niivue;
37
- ```
38
-
39
- **Problem:** If this `import()` hangs (CSP issues, network issues, MIME type issues, etc.), the async IIFE never resolves, and Svelte's mount lifecycle stalls. HF Spaces silently blocks or delays these imports.
40
-
41
- ### Why `head=` Works
42
-
43
- The `head=` parameter injects content into `<head>` BEFORE Gradio hydrates:
44
-
45
- ```html
46
- <head>
47
- <!-- Injected by head= -->
48
- <script type="module">
49
- const { Niivue } = await import('/gradio_api/file=.../niivue.js');
50
- window.Niivue = Niivue;
51
- </script>
52
- </head>
53
- ```
54
-
55
- **Key insight:** Even if this script fails, Gradio still loads because:
56
- 1. Script tags in `<head>` don't block Svelte hydration
57
- 2. They run BEFORE Gradio components mount
58
- 3. Failure just means `window.Niivue` is undefined (graceful degradation)
59
-
60
- Then `js_on_load` simply USES `window.Niivue` (no imports):
61
-
62
- ```javascript
63
- // No import() - just use what's already loaded
64
- const Niivue = window.Niivue;
65
- if (!Niivue) {
66
- // Show error message, don't block
67
- }
68
- ```
69
-
70
- ---
71
-
72
- ## Evidence-Based Conclusions
73
-
74
- | Claim | Evidence | Validated |
75
- |-------|----------|-----------|
76
- | Dynamic `import()` in `js_on_load` blocks HF Spaces | A/B test: disabling `js_on_load` makes app load | **YES** |
77
- | Vendored NiiVue file is served correctly | Local testing shows 200 response | **YES** |
78
- | `gr.set_static_paths()` is called correctly | Called before any Blocks in both entry points | **YES** |
79
- | `allowed_paths` is configured correctly | Both entry points pass `allowed_paths` | **YES** |
80
- | `demo.load()` doesn't block initial render | Gradio docs confirm load runs post-hydration | **YES** |
81
-
82
- ---
83
-
84
- ## The Fix
85
-
86
- ### Before (Broken)
87
-
88
- ```python
89
- # viewer.py - NIIVUE_ON_LOAD_JS
90
- const module = await import(niivueUrl); # Dynamic import in js_on_load
91
- window.Niivue = module.Niivue;
92
- ```
93
-
94
- ```python
95
- # ui/app.py - No head= parameter, relying on js_on_load to load NiiVue
96
- demo.launch(...) # No head= parameter
97
- ```
98
-
99
- ### After (Fixed)
100
-
101
- ```python
102
- # ui/app.py - Load NiiVue via head= BEFORE Gradio hydrates
103
- from stroke_deepisles_demo.ui.viewer import get_niivue_head_html
104
-
105
- get_demo().launch(
106
- head=get_niivue_head_html(), # Inject NiiVue loader into <head>
107
- ...
108
- )
109
- ```
110
-
111
- ```python
112
- # viewer.py - NIIVUE_ON_LOAD_JS just USES window.Niivue (no import)
113
- const Niivue = window.Niivue;
114
- if (!Niivue) {
115
- // Graceful error - don't block Gradio
116
- container.innerHTML = 'NiiVue failed to load...';
117
- return;
118
- }
119
- ```
120
-
121
- ---
122
-
123
- ## Why Previous Attempts Failed
124
-
125
- ### Attempt 1: CDN Import
126
- **Failed because:** HF Spaces CSP blocks external CDN imports
127
-
128
- ### Attempt 2: Vendor NiiVue + Dynamic Import in js_on_load
129
- **Failed because:** Dynamic `import()` in js_on_load still blocks Svelte hydration, even for local files
130
-
131
- ### Attempt 3: Remove head= and make js_on_load self-sufficient
132
- **Failed because:** This approach doubled down on the broken pattern (dynamic import in js_on_load)
133
-
134
- ### This Fix: head= for loading + js_on_load for init only
135
- **Should work because:** Matches the architecture documented in spec 24 and proven by the A/B test
136
-
137
- ---
138
-
139
- ## Test Strategy
140
-
141
- 1. **Local sanity:** Run with fix, verify app loads and NiiVue works
142
- 2. **A/B comparison:** Compare behavior with/without `head=` parameter
143
- 3. **HF Spaces deployment:** Push to hf-personal remote and verify
144
- 4. **Console inspection:** Check for `[NiiVue Loader]` logs in browser console
145
-
146
- ---
147
-
148
- ## Files to Modify
149
-
150
- | File | Change |
151
- |------|--------|
152
- | `src/stroke_deepisles_demo/ui/viewer.py` | Remove `import()` from js_on_load, use `window.Niivue` directly |
153
- | `src/stroke_deepisles_demo/ui/app.py` | Add `head=get_niivue_head_html()` to launch() |
154
- | `app.py` | Same as above for local dev |
155
-
156
- ---
157
-
158
- ## Update (2025-12-10): Web Research Findings
159
-
160
- ### Critical Discovery: The Issue is Gradio, NOT HuggingFace Spaces
161
-
162
- **Web search confirmed:**
163
- - HF Spaces DOES support JavaScript, WebGL, ES modules
164
- - Working examples: Unity WebGL, Three.js games, Gaussian Splat Viewer
165
- - The issue is specifically **Gradio's handling of custom JavaScript**
166
-
167
- **Sources:**
168
- - [HF Unity WebGL Template](https://github.com/huggingface/Unity-WebGL-template-for-Hugging-Face-Spaces)
169
- - [WebGL Gaussian Splat Viewer on HF](https://huggingface.co/spaces/cakewalk/splat)
170
- - [HF Forum: Gradio HTML with JS doesn't work](https://discuss.huggingface.co/t/gradio-html-component-with-javascript-code-dont-work/37316)
171
-
172
- ### Known Gradio Limitations
173
-
174
- 1. **`gr.HTML()` cannot load `<script>` tags** - They're stripped for security
175
- 2. **postMessage origin mismatch bug** (Gradio Issue #10893) - Causes SyntaxError
176
- 3. **`js_on_load` with dynamic `import()`** - Can block Svelte hydration
177
-
178
- ### Alternative Approaches NOT YET TRIED
179
-
180
- #### Option 1: `demo.load(_js=...)` with globalThis
181
-
182
- ```python
183
- scripts = """
184
- async () => {
185
- const script = document.createElement("script");
186
- script.src = "/gradio_api/file=.../niivue.js";
187
- script.type = "module";
188
- document.head.appendChild(script);
189
- await new Promise(resolve => script.onload = resolve);
190
- globalThis.Niivue = window.Niivue;
191
- }
192
- """
193
- demo.load(None, None, None, _js=scripts)
194
- ```
195
-
196
- Source: [HF Forum workaround](https://discuss.huggingface.co/t/gradio-html-component-with-javascript-code-dont-work/37316)
197
-
198
- #### Option 2: `gr.Blocks(js=...)` parameter
199
-
200
- ```python
201
- with gr.Blocks(js="() => { /* load NiiVue */ }") as demo:
202
- ...
203
- ```
204
-
205
- Source: [Gradio Custom CSS/JS Guide](https://www.gradio.app/guides/custom-CSS-and-JS)
206
-
207
- #### Option 3: Static HTML Space (Nuclear Option)
208
-
209
- If all Gradio approaches fail, create a **Static HTML Space** with pure JS/HTML/CSS.
210
- NiiVue would definitely work since WebGL examples exist on HF Spaces.
211
-
212
- Would require rebuilding the UI without Gradio.
213
-
214
- ### Decision Tree
215
-
216
- ```
217
- PR #28 (head= approach) works? ──YES──> Done!
218
-
219
- NO
220
-
221
- Try demo.load(_js=...) works? ──YES──> Done!
222
-
223
- NO
224
-
225
- Try gr.Blocks(js=...) works? ──YES──> Done!
226
-
227
- NO
228
-
229
- Static HTML Space (rebuild UI without Gradio)
230
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/archive/data-discovery.md DELETED
@@ -1,66 +0,0 @@
1
- # data discovery & verification protocol
2
-
3
- ## purpose
4
- To establish a rigorous, reproducible process for exploring, verifying, and documenting external data sources (Hugging Face Datasets, BIDS repos, etc.) before integrating them into the production codebase. This prevents "schema guessing" and ensures strict typing aligns with reality.
5
-
6
- ## principles
7
- 1. **No Assumptions**: Never assume column names, file formats, or data types. Verify them programmatically.
8
- 2. **Isolation**: Discovery scripts and their outputs must be isolated from production code and source control.
9
- 3. **Reproducibility**: The discovery process must be scriptable and reproducible, not a series of manual CLI commands.
10
-
11
- ## standard locations
12
-
13
- ### scripts
14
- All discovery logic resides in:
15
- ```
16
- scripts/discovery/
17
- ├── __init__.py
18
- ├── inspect_hf_dataset.py # e.g., Generic HF inspector
19
- ├── verify_bids_layout.py # e.g., BIDS validator
20
- └── ...
21
- ```
22
-
23
- ### data & artifacts
24
- All downloaded samples, temporary outputs, and schema reports reside in:
25
- ```
26
- data/
27
- ├── isles24/ # Extracted ISLES24 data (IGNORED)
28
- └── discovery/ # Schema reports, samples (IGNORED)
29
- ```
30
-
31
- ## discovery workflow
32
-
33
- ### 1. implementation
34
- Write a focused script in `scripts/discovery/` that:
35
- - Connects to the data source (e.g., HF Hub).
36
- - Fetches *metadata* or a *minimal sample* (streaming mode preferred).
37
- - Prints/Logs:
38
- - Feature keys (column names).
39
- - Data types (Arrow types, Python types).
40
- - Non-null counts (if feasible).
41
- - A sample row structure.
42
-
43
- ### 2. execution
44
- Run the script from the project root:
45
- ```bash
46
- uv run scripts/discovery/inspect_hf_dataset.py > data/discovery/schema_report.txt
47
- ```
48
-
49
- ### 3. verification
50
- Manually review `data/discovery/schema_report.txt`.
51
- - **Check**: Do column names match `CaseAdapter` expectations?
52
- - **Check**: Are file paths strings or objects?
53
- - **Check**: Are required fields (DWI, ADC) actually present?
54
-
55
- ### 4. remediation
56
- If the report contradicts the code/specs:
57
- 1. Update the spec (`docs/specs/`) to reflect reality.
58
- 2. Update the code (`src/.../adapter.py`) to handle the actual schema.
59
- 3. Add a regression test if the edge case is complex.
60
-
61
- ## git configuration
62
- Ensure `.gitignore` includes:
63
- ```gitignore
64
- data/isles24/
65
- data/discovery/
66
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/specs/frontend/36-frontend-without-gradio-hf-spaces.md ADDED
@@ -0,0 +1,1102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Spec 36: React Frontend + FastAPI Backend for HuggingFace Spaces
2
+
3
+ **Status**: APPROVED PLAN
4
+ **Date**: 2025-12-11
5
+ **Goal**: Replace Gradio with React frontend for NiiVue, FastAPI backend for DeepISLES
6
+
7
+ ---
8
+
9
+ ## Security Note: CVE-2025-55182 Does NOT Affect This App
10
+
11
+ **CVE-2025-55182 (React2Shell)** is a critical RCE vulnerability disclosed December 3, 2025.
12
+
13
+ | What | Status |
14
+ |------|--------|
15
+ | **React 19.x with RSC** | VULNERABLE if using Server Components |
16
+ | **React 19.x client-only** | SAFE - no Server Components = no vulnerability |
17
+ | **React 18.x** | NOT AFFECTED - no Server Components |
18
+
19
+ **We use React 19.2.0** which is **safe for our use case** because:
20
+ - CVE-2025-55182 only affects React Server Components (RSC)
21
+ - Our app is **client-only** (Static Space = no server-side rendering)
22
+ - We do not use React Server Components
23
+ - The vulnerability requires SSR/RSC to be exploitable
24
+
25
+ Sources:
26
+ - [React Security Advisory](https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components)
27
+ - [Wiz Analysis](https://www.wiz.io/blog/critical-vulnerability-in-react-cve-2025-55182)
28
+
29
+ ---
30
+
31
+ ## The Stack
32
+
33
+ | Component | Technology | Version | Purpose |
34
+ |-----------|------------|---------|---------|
35
+ | **Frontend Framework** | React | 19.2.0 | UI components (client-only, see security note) |
36
+ | **Type Safety** | TypeScript | 5.9.3 | Type checking |
37
+ | **Build Tool** | Vite | 7.2.4 | Fast builds, HMR |
38
+ | **CSS Framework** | Tailwind CSS | 4.1.17 | Utility-first styling |
39
+ | **3D Viewer** | @niivue/niivue | 0.65.0 | WebGL2 NIfTI viewer |
40
+ | **Testing** | Vitest + Playwright | 4.0.15 / 1.57.0 | Unit, integration, E2E tests |
41
+ | **Backend Framework** | FastAPI | 0.124.2 | Python REST API |
42
+ | **ML Pipeline** | DeepISLES | existing | Stroke segmentation |
43
+
44
+ ---
45
+
46
+ ## Architecture: Two HuggingFace Spaces
47
+
48
+ You **need both** because:
49
+ - **Static Space** = JavaScript only (React, NiiVue) - cannot run Python
50
+ - **Docker Space** = Python runtime (FastAPI, DeepISLES, PyTorch)
51
+
52
+ ```
53
+ ┌─────────────────────────────────────┐
54
+ │ HuggingFace Static Space │
55
+ │ stroke-viewer-frontend │
56
+ │ │
57
+ │ React 19 + TypeScript + Tailwind │
58
+ │ @niivue/niivue for 3D viewing │
59
+ │ │
60
+ │ Serves: index.html, JS, CSS │
61
+ │ Always on, never sleeps │
62
+ └──────────────┬──────────────────────┘
63
+ │ HTTPS API calls
64
+
65
+ ┌─────────────────────────────────────┐
66
+ │ HuggingFace Docker Space │
67
+ │ stroke-viewer-api │
68
+ │ │
69
+ │ FastAPI + DeepISLES + PyTorch │
70
+ │ │
71
+ │ Endpoints: │
72
+ │ - GET /api/cases │
73
+ │ - POST /api/segment │
74
+ │ - GET /api/files/{path} │
75
+ │ │
76
+ │ Sleeps after 48h inactivity │
77
+ └─────────────────────────────────────┘
78
+ ```
79
+
80
+ ---
81
+
82
+ ## Project Structure
83
+
84
+ ```
85
+ stroke-viewer/
86
+ ├── frontend/ # Static Space
87
+ │ ├── src/
88
+ │ │ ├── components/
89
+ │ │ │ ├── NiiVueViewer.tsx
90
+ │ │ │ ├── CaseSelector.tsx
91
+ │ │ │ ├── MetricsPanel.tsx
92
+ │ │ │ └── Layout.tsx
93
+ │ │ ├── hooks/
94
+ │ │ │ └── useSegmentation.ts
95
+ │ │ ├── api/
96
+ │ │ │ └── client.ts
97
+ │ │ ├── types/
98
+ │ │ │ └── index.ts
99
+ │ │ ├── App.tsx
100
+ │ │ ├── main.tsx
101
+ │ │ └── index.css
102
+ │ ├── public/
103
+ │ ├── index.html
104
+ │ ├── vite.config.ts
105
+ │ ├── tsconfig.json
106
+ │ ├── package.json
107
+ │ └── README.md # HF Spaces YAML config
108
+
109
+ ├── backend/ # Docker Space
110
+ │ ├── api/
111
+ │ │ ├── __init__.py
112
+ │ │ ├── main.py # FastAPI app
113
+ │ │ ├── routes.py # API endpoints
114
+ │ │ └── schemas.py # Pydantic models
115
+ │ ├── Dockerfile
116
+ │ ├── requirements.txt
117
+ │ └── README.md # HF Spaces YAML config
118
+
119
+ └── README.md # Project overview
120
+ ```
121
+
122
+ ---
123
+
124
+ ## Frontend Implementation
125
+
126
+ ### package.json
127
+
128
+ ```json
129
+ {
130
+ "name": "frontend",
131
+ "private": true,
132
+ "version": "0.0.0",
133
+ "type": "module",
134
+ "scripts": {
135
+ "dev": "vite",
136
+ "build": "tsc -b && vite build",
137
+ "preview": "vite preview",
138
+ "lint": "eslint .",
139
+ "test": "vitest",
140
+ "test:coverage": "vitest run --coverage",
141
+ "test:e2e": "playwright test"
142
+ },
143
+ "dependencies": {
144
+ "@niivue/niivue": "^0.65.0",
145
+ "react": "^19.2.0",
146
+ "react-dom": "^19.2.0"
147
+ },
148
+ "devDependencies": {
149
+ "@playwright/test": "^1.57.0",
150
+ "@tailwindcss/vite": "^4.1.17",
151
+ "@testing-library/jest-dom": "^6.6.3",
152
+ "@testing-library/react": "^16.3.0",
153
+ "@vitejs/plugin-react": "^5.1.1",
154
+ "@vitest/coverage-v8": "^4.0.15",
155
+ "eslint": "^9.39.1",
156
+ "tailwindcss": "^4.1.17",
157
+ "typescript": "~5.9.3",
158
+ "vite": "^7.2.4",
159
+ "vitest": "^4.0.15"
160
+ }
161
+ }
162
+ ```
163
+
164
+ **Why these versions:**
165
+ - `react` / `react-dom` **19.2.0**: Latest React 19 - client-only so CVE-2025-55182 doesn't apply
166
+ - `@niivue/niivue` **0.65.0**: Latest stable (Dec 2025)
167
+ - `vite` **7.2.4**: Latest stable v7
168
+ - `vitest` **4.0.15**: Fast unit testing with React Testing Library
169
+ - `@playwright/test` **1.57.0**: E2E browser testing
170
+ - `tailwindcss` **4.1.17**: Latest stable v4
171
+ - `typescript` **5.9.3**: Latest stable
172
+ - ESLint included for code quality in CI
173
+
174
+ ### vite.config.ts
175
+
176
+ ```typescript
177
+ import { defineConfig } from 'vite'
178
+ import react from '@vitejs/plugin-react'
179
+ import tailwindcss from '@tailwindcss/vite'
180
+
181
+ export default defineConfig({
182
+ plugins: [react(), tailwindcss()],
183
+ build: {
184
+ outDir: 'dist',
185
+ },
186
+ })
187
+ ```
188
+
189
+ ### tsconfig.json
190
+
191
+ ```json
192
+ {
193
+ "compilerOptions": {
194
+ "target": "ES2020",
195
+ "useDefineForClassFields": true,
196
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
197
+ "module": "ESNext",
198
+ "skipLibCheck": true,
199
+ "moduleResolution": "bundler",
200
+ "allowImportingTsExtensions": true,
201
+ "isolatedModules": true,
202
+ "moduleDetection": "force",
203
+ "noEmit": true,
204
+ "jsx": "react-jsx",
205
+ "strict": true,
206
+ "noUnusedLocals": true,
207
+ "noUnusedParameters": true,
208
+ "noFallthroughCasesInSwitch": true
209
+ },
210
+ "include": ["src"]
211
+ }
212
+ ```
213
+
214
+ ### src/index.css
215
+
216
+ ```css
217
+ @import "tailwindcss";
218
+ ```
219
+
220
+ ### src/main.tsx
221
+
222
+ ```tsx
223
+ import { StrictMode } from 'react'
224
+ import { createRoot } from 'react-dom/client'
225
+ import App from './App'
226
+ import './index.css'
227
+
228
+ createRoot(document.getElementById('root')!).render(
229
+ <StrictMode>
230
+ <App />
231
+ </StrictMode>,
232
+ )
233
+ ```
234
+
235
+ ### src/App.tsx
236
+
237
+ ```tsx
238
+ import { useState } from 'react'
239
+ import { Layout } from './components/Layout'
240
+ import { CaseSelector } from './components/CaseSelector'
241
+ import { NiiVueViewer } from './components/NiiVueViewer'
242
+ import { MetricsPanel } from './components/MetricsPanel'
243
+ import { useSegmentation } from './hooks/useSegmentation'
244
+
245
+ export default function App() {
246
+ const [selectedCase, setSelectedCase] = useState<string | null>(null)
247
+ const { result, isLoading, error, runSegmentation } = useSegmentation()
248
+
249
+ const handleRunSegmentation = async () => {
250
+ if (selectedCase) {
251
+ await runSegmentation(selectedCase)
252
+ }
253
+ }
254
+
255
+ return (
256
+ <Layout>
257
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
258
+ {/* Left Panel: Controls */}
259
+ <div className="space-y-4">
260
+ <CaseSelector
261
+ selectedCase={selectedCase}
262
+ onSelectCase={setSelectedCase}
263
+ />
264
+ <button
265
+ onClick={handleRunSegmentation}
266
+ disabled={!selectedCase || isLoading}
267
+ className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400
268
+ text-white font-medium py-3 px-4 rounded-lg transition"
269
+ >
270
+ {isLoading ? 'Processing...' : 'Run Segmentation'}
271
+ </button>
272
+ {error && (
273
+ <div className="bg-red-100 text-red-700 p-3 rounded-lg">
274
+ {error}
275
+ </div>
276
+ )}
277
+ {result && <MetricsPanel metrics={result.metrics} />}
278
+ </div>
279
+
280
+ {/* Right Panel: Viewer */}
281
+ <div className="lg:col-span-2">
282
+ {result ? (
283
+ <NiiVueViewer
284
+ backgroundUrl={result.dwiUrl}
285
+ overlayUrl={result.predictionUrl}
286
+ />
287
+ ) : (
288
+ <div className="bg-gray-900 rounded-lg h-[500px] flex items-center justify-center">
289
+ <p className="text-gray-400">
290
+ Select a case and run segmentation to view results
291
+ </p>
292
+ </div>
293
+ )}
294
+ </div>
295
+ </div>
296
+ </Layout>
297
+ )
298
+ }
299
+ ```
300
+
301
+ ### src/components/Layout.tsx
302
+
303
+ ```tsx
304
+ import { ReactNode } from 'react'
305
+
306
+ interface LayoutProps {
307
+ children: ReactNode
308
+ }
309
+
310
+ export function Layout({ children }: LayoutProps) {
311
+ return (
312
+ <div className="min-h-screen bg-gray-950 text-white">
313
+ <header className="border-b border-gray-800 py-4">
314
+ <div className="container mx-auto px-4">
315
+ <h1 className="text-2xl font-bold">Stroke Lesion Segmentation</h1>
316
+ <p className="text-gray-400 text-sm mt-1">
317
+ DeepISLES segmentation on ISLES24 dataset
318
+ </p>
319
+ </div>
320
+ </header>
321
+ <main className="container mx-auto px-4 py-6">
322
+ {children}
323
+ </main>
324
+ </div>
325
+ )
326
+ }
327
+ ```
328
+
329
+ ### src/components/NiiVueViewer.tsx
330
+
331
+ ```tsx
332
+ import { useRef, useEffect } from 'react'
333
+ import { Niivue } from '@niivue/niivue'
334
+
335
+ interface NiiVueViewerProps {
336
+ backgroundUrl: string
337
+ overlayUrl?: string
338
+ }
339
+
340
+ export function NiiVueViewer({ backgroundUrl, overlayUrl }: NiiVueViewerProps) {
341
+ const canvasRef = useRef<HTMLCanvasElement>(null)
342
+ const nvRef = useRef<Niivue | null>(null)
343
+
344
+ useEffect(() => {
345
+ if (!canvasRef.current) return
346
+
347
+ // Only instantiate NiiVue once; reuse for volume reloads
348
+ let nv = nvRef.current
349
+ if (!nv) {
350
+ nv = new Niivue({
351
+ backColor: [0.05, 0.05, 0.05, 1],
352
+ show3Dcrosshair: true,
353
+ crosshairColor: [1, 0, 0, 0.5],
354
+ })
355
+ nv.attachToCanvas(canvasRef.current)
356
+ nvRef.current = nv
357
+ }
358
+
359
+ // Build volumes array - always reload when URLs change
360
+ const volumes: Array<{ url: string; colormap: string; opacity: number }> = [
361
+ { url: backgroundUrl, colormap: 'gray', opacity: 1 },
362
+ ]
363
+
364
+ if (overlayUrl) {
365
+ volumes.push({
366
+ url: overlayUrl,
367
+ colormap: 'red',
368
+ opacity: 0.5,
369
+ })
370
+ }
371
+
372
+ // Load volumes (async but we don't await - just fire off)
373
+ void nv.loadVolumes(volumes)
374
+
375
+ // Cleanup on unmount - CRITICAL: Release WebGL context
376
+ // Browsers limit WebGL contexts (~16 in Chrome). Without cleanup,
377
+ // navigating between results will exhaust contexts and break the viewer.
378
+ return () => {
379
+ if (nvRef.current) {
380
+ // Capture gl BEFORE cleanup (cleanup may null internal state)
381
+ const gl = nvRef.current.gl
382
+ try {
383
+ // NiiVue's cleanup() releases event listeners and observers
384
+ // See: https://niivue.github.io/niivue/devdocs/classes/Niivue.html#cleanup
385
+ nvRef.current.cleanup()
386
+ // Force WebGL context loss to free GPU memory immediately
387
+ if (gl) {
388
+ const ext = gl.getExtension('WEBGL_lose_context')
389
+ ext?.loseContext()
390
+ }
391
+ } catch {
392
+ // Ignore cleanup errors
393
+ }
394
+ nvRef.current = null
395
+ }
396
+ }
397
+ }, [backgroundUrl, overlayUrl])
398
+
399
+ return (
400
+ <div className="bg-gray-900 rounded-lg p-2">
401
+ <canvas
402
+ ref={canvasRef}
403
+ className="w-full h-[500px] rounded"
404
+ />
405
+ <div className="flex gap-4 mt-2 text-xs text-gray-400">
406
+ <span>Scroll: Navigate slices</span>
407
+ <span>Drag: Adjust contrast</span>
408
+ <span>Right-click: Pan</span>
409
+ </div>
410
+ </div>
411
+ )
412
+ }
413
+ ```
414
+
415
+ ### src/components/CaseSelector.tsx
416
+
417
+ ```tsx
418
+ import { useEffect, useState } from 'react'
419
+ import { apiClient } from '../api/client'
420
+
421
+ interface CaseSelectorProps {
422
+ selectedCase: string | null
423
+ onSelectCase: (caseId: string) => void
424
+ }
425
+
426
+ export function CaseSelector({ selectedCase, onSelectCase }: CaseSelectorProps) {
427
+ const [cases, setCases] = useState<string[]>([])
428
+ const [loading, setLoading] = useState(true)
429
+ const [error, setError] = useState<string | null>(null)
430
+
431
+ useEffect(() => {
432
+ const fetchCases = async () => {
433
+ try {
434
+ const data = await apiClient.getCases()
435
+ setCases(data.cases)
436
+ } catch (err) {
437
+ setError('Failed to load cases')
438
+ console.error(err)
439
+ } finally {
440
+ setLoading(false)
441
+ }
442
+ }
443
+ fetchCases()
444
+ }, [])
445
+
446
+ if (loading) {
447
+ return (
448
+ <div className="bg-gray-800 rounded-lg p-4">
449
+ <p className="text-gray-400">Loading cases...</p>
450
+ </div>
451
+ )
452
+ }
453
+
454
+ if (error) {
455
+ return (
456
+ <div className="bg-red-900 rounded-lg p-4">
457
+ <p className="text-red-300">{error}</p>
458
+ </div>
459
+ )
460
+ }
461
+
462
+ return (
463
+ <div className="bg-gray-800 rounded-lg p-4">
464
+ <label className="block text-sm font-medium mb-2">
465
+ Select Case
466
+ </label>
467
+ <select
468
+ value={selectedCase || ''}
469
+ onChange={(e) => onSelectCase(e.target.value)}
470
+ className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2
471
+ text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
472
+ >
473
+ <option value="">Choose a case...</option>
474
+ {cases.map((caseId) => (
475
+ <option key={caseId} value={caseId}>
476
+ {caseId}
477
+ </option>
478
+ ))}
479
+ </select>
480
+ </div>
481
+ )
482
+ }
483
+ ```
484
+
485
+ ### src/components/MetricsPanel.tsx
486
+
487
+ ```tsx
488
+ interface Metrics {
489
+ caseId: string
490
+ diceScore: number | null
491
+ volumeMl: number | null
492
+ elapsedSeconds: number
493
+ }
494
+
495
+ interface MetricsPanelProps {
496
+ metrics: Metrics
497
+ }
498
+
499
+ export function MetricsPanel({ metrics }: MetricsPanelProps) {
500
+ return (
501
+ <div className="bg-gray-800 rounded-lg p-4 space-y-3">
502
+ <h3 className="font-medium text-lg">Results</h3>
503
+
504
+ <div className="grid grid-cols-2 gap-3 text-sm">
505
+ <div>
506
+ <span className="text-gray-400">Case:</span>
507
+ <span className="ml-2 font-mono">{metrics.caseId}</span>
508
+ </div>
509
+
510
+ {metrics.diceScore !== null && (
511
+ <div>
512
+ <span className="text-gray-400">Dice Score:</span>
513
+ <span className="ml-2 font-mono text-green-400">
514
+ {metrics.diceScore.toFixed(3)}
515
+ </span>
516
+ </div>
517
+ )}
518
+
519
+ {metrics.volumeMl !== null && (
520
+ <div>
521
+ <span className="text-gray-400">Volume:</span>
522
+ <span className="ml-2 font-mono">{metrics.volumeMl.toFixed(2)} mL</span>
523
+ </div>
524
+ )}
525
+
526
+ <div>
527
+ <span className="text-gray-400">Time:</span>
528
+ <span className="ml-2 font-mono">{metrics.elapsedSeconds.toFixed(1)}s</span>
529
+ </div>
530
+ </div>
531
+ </div>
532
+ )
533
+ }
534
+ ```
535
+
536
+ ### src/api/client.ts
537
+
538
+ ```typescript
539
+ // API base URL - configure via environment variable
540
+ const API_BASE = import.meta.env.VITE_API_URL || 'https://your-backend.hf.space'
541
+
542
+ interface CasesResponse {
543
+ cases: string[]
544
+ }
545
+
546
+ interface SegmentResponse {
547
+ caseId: string
548
+ diceScore: number | null
549
+ volumeMl: number | null
550
+ elapsedSeconds: number
551
+ dwiUrl: string
552
+ predictionUrl: string
553
+ }
554
+
555
+ class ApiClient {
556
+ private baseUrl: string
557
+
558
+ constructor(baseUrl: string) {
559
+ this.baseUrl = baseUrl
560
+ }
561
+
562
+ async getCases(): Promise<CasesResponse> {
563
+ const response = await fetch(`${this.baseUrl}/api/cases`)
564
+ if (!response.ok) {
565
+ throw new Error(`Failed to fetch cases: ${response.statusText}`)
566
+ }
567
+ return response.json()
568
+ }
569
+
570
+ async runSegmentation(caseId: string, fastMode = true): Promise<SegmentResponse> {
571
+ const response = await fetch(`${this.baseUrl}/api/segment`, {
572
+ method: 'POST',
573
+ headers: {
574
+ 'Content-Type': 'application/json',
575
+ },
576
+ body: JSON.stringify({
577
+ case_id: caseId,
578
+ fast_mode: fastMode,
579
+ }),
580
+ })
581
+ if (!response.ok) {
582
+ throw new Error(`Segmentation failed: ${response.statusText}`)
583
+ }
584
+ return response.json()
585
+ }
586
+ }
587
+
588
+ export const apiClient = new ApiClient(API_BASE)
589
+ ```
590
+
591
+ ### src/hooks/useSegmentation.ts
592
+
593
+ ```typescript
594
+ import { useState, useCallback } from 'react'
595
+ import { apiClient } from '../api/client'
596
+
597
+ interface SegmentationResult {
598
+ dwiUrl: string
599
+ predictionUrl: string
600
+ metrics: {
601
+ caseId: string
602
+ diceScore: number | null
603
+ volumeMl: number | null
604
+ elapsedSeconds: number
605
+ }
606
+ }
607
+
608
+ export function useSegmentation() {
609
+ const [result, setResult] = useState<SegmentationResult | null>(null)
610
+ const [isLoading, setIsLoading] = useState(false)
611
+ const [error, setError] = useState<string | null>(null)
612
+
613
+ const runSegmentation = useCallback(async (caseId: string) => {
614
+ setIsLoading(true)
615
+ setError(null)
616
+
617
+ try {
618
+ const data = await apiClient.runSegmentation(caseId)
619
+ setResult({
620
+ dwiUrl: data.dwiUrl,
621
+ predictionUrl: data.predictionUrl,
622
+ metrics: {
623
+ caseId: data.caseId,
624
+ diceScore: data.diceScore,
625
+ volumeMl: data.volumeMl,
626
+ elapsedSeconds: data.elapsedSeconds,
627
+ },
628
+ })
629
+ } catch (err) {
630
+ setError(err instanceof Error ? err.message : 'Unknown error')
631
+ setResult(null)
632
+ } finally {
633
+ setIsLoading(false)
634
+ }
635
+ }, [])
636
+
637
+ return { result, isLoading, error, runSegmentation }
638
+ }
639
+ ```
640
+
641
+ ### src/types/index.ts
642
+
643
+ ```typescript
644
+ export interface Case {
645
+ id: string
646
+ name: string
647
+ }
648
+
649
+ export interface Metrics {
650
+ caseId: string
651
+ diceScore: number | null
652
+ volumeMl: number | null
653
+ elapsedSeconds: number
654
+ }
655
+
656
+ export interface SegmentationResult {
657
+ dwiUrl: string
658
+ predictionUrl: string
659
+ metrics: Metrics
660
+ }
661
+ ```
662
+
663
+ ### index.html
664
+
665
+ ```html
666
+ <!DOCTYPE html>
667
+ <html lang="en">
668
+ <head>
669
+ <meta charset="UTF-8" />
670
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
671
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
672
+ <title>Stroke Lesion Segmentation</title>
673
+ </head>
674
+ <body>
675
+ <div id="root"></div>
676
+ <script type="module" src="/src/main.tsx"></script>
677
+ </body>
678
+ </html>
679
+ ```
680
+
681
+ ### frontend/README.md (HuggingFace Spaces Config)
682
+
683
+ ```markdown
684
+ ---
685
+ title: Stroke Lesion Viewer
686
+ emoji: 🧠
687
+ colorFrom: blue
688
+ colorTo: purple
689
+ sdk: static
690
+ app_file: dist/index.html
691
+ app_build_command: npm run build
692
+ # CRITICAL: Vite 6 requires Node.js >= 20. HF Spaces defaults to Node 18.
693
+ # Without this, the build will fail or produce warnings.
694
+ nodejs_version: "20"
695
+ pinned: false
696
+ ---
697
+
698
+ # Stroke Lesion Segmentation Viewer
699
+
700
+ Interactive 3D viewer for stroke lesion segmentation results using NiiVue.
701
+
702
+ Built with React, TypeScript, Tailwind CSS, and Vite.
703
+ ```
704
+
705
+ ---
706
+
707
+ ## Backend Implementation
708
+
709
+ ### requirements.txt
710
+
711
+ ```
712
+ fastapi==0.124.2
713
+ uvicorn[standard]==0.34.0
714
+ pydantic==2.10.4
715
+ python-multipart>=0.0.18
716
+
717
+ # Existing project dependencies
718
+ stroke-deepisles-demo @ file:.
719
+ ```
720
+
721
+ **Why these exact versions (Dec 2025):**
722
+ - `fastapi` **0.124.2**: Latest stable (Dec 10, 2025)
723
+ - `uvicorn[standard]` **0.34.0**: Latest stable
724
+ - `pydantic` **2.10.4**: Latest stable
725
+ - `python-multipart` **>=0.0.18**: Required by FastAPI 0.124.x
726
+
727
+ ### backend/api/main.py
728
+
729
+ ```python
730
+ """FastAPI backend for stroke segmentation."""
731
+
732
+ import os
733
+ import re
734
+
735
+ from fastapi import FastAPI
736
+ from fastapi.middleware.cors import CORSMiddleware
737
+ from fastapi.staticfiles import StaticFiles
738
+
739
+ from api.routes import router
740
+
741
+ app = FastAPI(
742
+ title="Stroke Segmentation API",
743
+ description="DeepISLES stroke lesion segmentation",
744
+ version="1.0.0",
745
+ )
746
+
747
+ # CORS for frontend - HF Spaces use dashed hostnames: {org}--{space}.hf.space
748
+ # Also supports PR previews: pr-{n}--{org}--{space}.hf.space
749
+ FRONTEND_ORIGIN = os.environ.get("FRONTEND_ORIGIN", "")
750
+ CORS_ORIGINS = [
751
+ "http://localhost:5173", # Local Vite dev server
752
+ "http://localhost:3000", # Alternative local port
753
+ ]
754
+ if FRONTEND_ORIGIN:
755
+ CORS_ORIGINS.append(FRONTEND_ORIGIN)
756
+
757
+ app.add_middleware(
758
+ CORSMiddleware,
759
+ allow_origins=CORS_ORIGINS,
760
+ # Regex matches HuggingFace Spaces origins:
761
+ # - Production: https://{org}--stroke-viewer-frontend.hf.space
762
+ # - PR preview: https://{org}--stroke-viewer-frontend--pr-{N}.hf.space
763
+ # - Branch: https://{org}--stroke-viewer-frontend--{branch}.hf.space
764
+ # Pattern: anything--stroke-viewer-frontend, optionally followed by --anything
765
+ allow_origin_regex=r"https://.*--stroke-viewer-frontend(--.*)?\.hf\.space",
766
+ allow_credentials=True,
767
+ allow_methods=["*"],
768
+ allow_headers=["*"],
769
+ )
770
+
771
+ # API routes
772
+ app.include_router(router, prefix="/api")
773
+
774
+ # Serve NIfTI files from results directory
775
+ # Files are stored as /tmp/stroke-results/{run_id}/{case_id}/{filename}
776
+ app.mount("/files", StaticFiles(directory="/tmp/stroke-results"), name="files")
777
+
778
+
779
+ @app.get("/")
780
+ async def root():
781
+ return {"status": "healthy", "service": "stroke-segmentation-api"}
782
+ ```
783
+
784
+ ### backend/api/routes.py
785
+
786
+ ```python
787
+ """API route handlers."""
788
+
789
+ import os
790
+ import uuid
791
+ from pathlib import Path
792
+
793
+ from fastapi import APIRouter, HTTPException, Request
794
+ from api.schemas import SegmentRequest, SegmentResponse, CasesResponse
795
+
796
+ from stroke_deepisles_demo.data import list_case_ids
797
+ from stroke_deepisles_demo.pipeline import run_pipeline_on_case
798
+ from stroke_deepisles_demo.metrics import compute_volume_ml
799
+
800
+ router = APIRouter()
801
+
802
+ # Base directory for results (must match StaticFiles mount in main.py)
803
+ RESULTS_BASE = Path("/tmp/stroke-results")
804
+
805
+
806
+ def get_backend_base_url(request: Request) -> str:
807
+ """Get the backend's public URL for building absolute file URLs.
808
+
809
+ Priority:
810
+ 1. BACKEND_PUBLIC_URL env var (for production HF Spaces)
811
+ 2. Request's base URL (for local development)
812
+ """
813
+ env_url = os.environ.get("BACKEND_PUBLIC_URL", "").rstrip("/")
814
+ if env_url:
815
+ return env_url
816
+ # Fall back to request origin (works for local dev)
817
+ return str(request.base_url).rstrip("/")
818
+
819
+
820
+ @router.get("/cases", response_model=CasesResponse)
821
+ async def get_cases():
822
+ """List available cases from dataset."""
823
+ try:
824
+ cases = list_case_ids()
825
+ return CasesResponse(cases=cases)
826
+ except Exception as e:
827
+ raise HTTPException(status_code=500, detail=str(e))
828
+
829
+
830
+ @router.post("/segment", response_model=SegmentResponse)
831
+ async def run_segmentation(request: Request, body: SegmentRequest):
832
+ """Run DeepISLES segmentation on a case."""
833
+ try:
834
+ # Generate unique run ID to avoid conflicts between concurrent requests
835
+ run_id = str(uuid.uuid4())[:8]
836
+ output_dir = RESULTS_BASE / run_id
837
+
838
+ result = run_pipeline_on_case(
839
+ body.case_id,
840
+ output_dir=output_dir,
841
+ fast=body.fast_mode,
842
+ compute_dice=True,
843
+ cleanup_staging=True,
844
+ )
845
+
846
+ # Compute volume
847
+ volume_ml = None
848
+ try:
849
+ volume_ml = round(compute_volume_ml(result.prediction_mask, threshold=0.5), 2)
850
+ except Exception:
851
+ pass
852
+
853
+ # Build ABSOLUTE file URLs for cross-origin NiiVue loading
854
+ # Files are at: /tmp/stroke-results/{run_id}/{case_id}/{filename}
855
+ # Served at: /files/{run_id}/{case_id}/{filename}
856
+ backend_url = get_backend_base_url(request)
857
+ dwi_filename = result.input_files["dwi"].name
858
+ pred_filename = result.prediction_mask.name
859
+
860
+ # URL path: /files/{run_id}/{case_id}/{filename}
861
+ file_path_prefix = f"/files/{run_id}/{result.case_id}"
862
+
863
+ return SegmentResponse(
864
+ caseId=result.case_id,
865
+ diceScore=result.dice_score,
866
+ volumeMl=volume_ml,
867
+ elapsedSeconds=round(result.elapsed_seconds, 2),
868
+ dwiUrl=f"{backend_url}{file_path_prefix}/{dwi_filename}",
869
+ predictionUrl=f"{backend_url}{file_path_prefix}/{pred_filename}",
870
+ )
871
+
872
+ except Exception as e:
873
+ raise HTTPException(status_code=500, detail=str(e))
874
+ ```
875
+
876
+ ### backend/api/schemas.py
877
+
878
+ ```python
879
+ """Pydantic schemas for API."""
880
+
881
+ from pydantic import BaseModel
882
+
883
+
884
+ class CasesResponse(BaseModel):
885
+ cases: list[str]
886
+
887
+
888
+ class SegmentRequest(BaseModel):
889
+ case_id: str
890
+ fast_mode: bool = True
891
+
892
+
893
+ class SegmentResponse(BaseModel):
894
+ caseId: str
895
+ diceScore: float | None
896
+ volumeMl: float | None
897
+ elapsedSeconds: float
898
+ dwiUrl: str
899
+ predictionUrl: str
900
+ ```
901
+
902
+ ### backend/Dockerfile
903
+
904
+ ```dockerfile
905
+ # CRITICAL: Must use isleschallenge/deepisles base image
906
+ # This image contains:
907
+ # - PyTorch with CUDA support
908
+ # - Pre-installed DeepISLES model weights (~18GB)
909
+ # - All medical imaging dependencies (nibabel, nnunet, etc.)
910
+ #
911
+ # Using python:3.11-slim would require manually downloading weights
912
+ # and reinstalling all CUDA/PyTorch dependencies - not feasible.
913
+ FROM isleschallenge/deepisles:latest
914
+
915
+ WORKDIR /app
916
+
917
+ # Copy the ENTIRE project (stroke-deepisles-demo package)
918
+ # This is required because requirements.txt references "stroke-deepisles-demo @ file:."
919
+ COPY pyproject.toml .
920
+ COPY src/ src/
921
+ COPY README.md .
922
+
923
+ # Copy API code
924
+ COPY backend/api/ api/
925
+ COPY backend/requirements.txt .
926
+
927
+ # Install API dependencies (FastAPI, uvicorn) + local package
928
+ # Note: Base image already has torch, nibabel, etc.
929
+ RUN pip install --no-cache-dir -r requirements.txt
930
+
931
+ # Create results directory (used by StaticFiles mount)
932
+ RUN mkdir -p /tmp/stroke-results
933
+
934
+ # Environment variables for HuggingFace Spaces
935
+ ENV HF_SPACES=1
936
+ ENV DEEPISLES_DIRECT_INVOCATION=1
937
+
938
+ # Expose port (HF Spaces expects 7860)
939
+ EXPOSE 7860
940
+
941
+ # Run FastAPI
942
+ CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "7860"]
943
+ ```
944
+
945
+ **CRITICAL: GPU Required**
946
+
947
+ DeepISLES requires GPU acceleration. HuggingFace Spaces FREE tier (`cpu-basic`) will NOT work.
948
+
949
+ | Tier | GPU | Will Work? |
950
+ |------|-----|------------|
951
+ | `cpu-basic` (free) | None | ❌ No |
952
+ | `t4-small` | NVIDIA T4 (16GB) | ✅ Yes |
953
+ | `t4-medium` | NVIDIA T4 (16GB) | ✅ Yes |
954
+ | `a10g-small` | NVIDIA A10G (24GB) | ✅ Yes |
955
+
956
+ When creating the HF Space, select **T4-small** or higher.
957
+
958
+ **Note:** The Dockerfile copies the full project because `requirements.txt` has:
959
+ ```
960
+ stroke-deepisles-demo @ file:.
961
+ ```
962
+ This PEP 508 local path reference requires the package source to be present.
963
+
964
+ ### backend/README.md (HuggingFace Spaces Config)
965
+
966
+ ```markdown
967
+ ---
968
+ title: Stroke Segmentation API
969
+ emoji: 🧠
970
+ colorFrom: blue
971
+ colorTo: purple
972
+ sdk: docker
973
+ app_port: 7860
974
+ pinned: false
975
+ ---
976
+
977
+ # Stroke Segmentation API
978
+
979
+ FastAPI backend running DeepISLES stroke lesion segmentation.
980
+
981
+ ## Endpoints
982
+
983
+ - `GET /api/cases` - List available cases
984
+ - `POST /api/segment` - Run segmentation
985
+ - `GET /files/{filename}` - Download result files
986
+ ```
987
+
988
+ ---
989
+
990
+ ## Setup Commands
991
+
992
+ ### Frontend (Local Development)
993
+
994
+ ```bash
995
+ # Create project
996
+ npm create vite@latest stroke-viewer-frontend -- --template react-ts
997
+ cd stroke-viewer-frontend
998
+
999
+ # Install dependencies
1000
+ npm install @niivue/niivue
1001
+ npm install -D tailwindcss @tailwindcss/vite
1002
+
1003
+ # Copy the files from this spec into src/
1004
+
1005
+ # Run dev server
1006
+ npm run dev
1007
+ # Opens http://localhost:5173
1008
+ ```
1009
+
1010
+ ### Backend (Local Development)
1011
+
1012
+ ```bash
1013
+ cd backend
1014
+
1015
+ # Create virtual environment
1016
+ python -m venv venv
1017
+ source venv/bin/activate
1018
+
1019
+ # Install dependencies
1020
+ pip install -r requirements.txt
1021
+
1022
+ # Run server
1023
+ uvicorn api.main:app --reload --port 7860
1024
+ # Opens http://localhost:7860
1025
+ ```
1026
+
1027
+ ### Deploy to HuggingFace
1028
+
1029
+ ```bash
1030
+ # Frontend (Static Space)
1031
+ cd frontend
1032
+ huggingface-cli repo create stroke-viewer-frontend --type space --space-sdk static
1033
+ huggingface-cli upload stroke-viewer-frontend ./dist . --repo-type space
1034
+
1035
+ # Backend (Docker Space)
1036
+ cd backend
1037
+ huggingface-cli repo create stroke-viewer-api --type space --space-sdk docker
1038
+ huggingface-cli upload stroke-viewer-api . . --repo-type space
1039
+ ```
1040
+
1041
+ ---
1042
+
1043
+ ## Environment Variables
1044
+
1045
+ ### Frontend (.env)
1046
+
1047
+ ```env
1048
+ VITE_API_URL=https://your-username-stroke-viewer-api.hf.space
1049
+ ```
1050
+
1051
+ ### Backend
1052
+
1053
+ No additional env vars needed - uses existing stroke-deepisles-demo configuration.
1054
+
1055
+ ---
1056
+
1057
+ ## Key Differences from Gradio
1058
+
1059
+ | What | Gradio (broken) | This Stack |
1060
+ |------|-----------------|------------|
1061
+ | NiiVue JavaScript | Blocked by innerHTML | Full execution ✓ |
1062
+ | WebGL2 context | Frozen during hydration | Works normally ✓ |
1063
+ | Bundle size | ~2MB Gradio overhead | ~200KB total |
1064
+ | Cold start | Python + Gradio init | Instant (static) |
1065
+ | Customization | Limited to Gradio components | Full React control |
1066
+
1067
+ ---
1068
+
1069
+ ## Next Steps
1070
+
1071
+ 1. Create `frontend/` directory with files from this spec
1072
+ 2. Create `backend/` directory with files from this spec
1073
+ 3. Test locally: `npm run dev` + `uvicorn api.main:app`
1074
+ 4. Create HuggingFace Spaces (one Static, one Docker)
1075
+ 5. Deploy and test
1076
+
1077
+ ---
1078
+
1079
+ ## Dependencies Summary (Verified Dec 11, 2025)
1080
+
1081
+ **Frontend (npm) - PINNED VERSIONS:**
1082
+ | Package | Version | Notes |
1083
+ |---------|---------|-------|
1084
+ | react | 18.3.1 | NOT React 19 (CVE-2025-55182) |
1085
+ | react-dom | 18.3.1 | Must match react version |
1086
+ | @niivue/niivue | 0.65.0 | Latest stable |
1087
+ | typescript | 5.6.3 | Latest 5.6.x |
1088
+ | vite | 6.0.5 | Stable v6 (not v7/v8 beta) |
1089
+ | tailwindcss | 4.1.7 | Latest v4 |
1090
+ | @tailwindcss/vite | 4.1.7 | Must match tailwindcss |
1091
+ | @vitejs/plugin-react | 4.3.4 | Latest stable |
1092
+
1093
+ **Backend (pip) - PINNED VERSIONS:**
1094
+ | Package | Version | Notes |
1095
+ |---------|---------|-------|
1096
+ | fastapi | 0.124.2 | Latest (Dec 10, 2025) |
1097
+ | uvicorn[standard] | 0.34.0 | Latest stable |
1098
+ | pydantic | 2.10.4 | Latest stable |
1099
+ | python-multipart | >=0.0.18 | Required by FastAPI |
1100
+
1101
+ **Node.js:** >= 20.0.0 (required for Vite 6)
1102
+ **Python:** >= 3.11 (recommended for FastAPI)
docs/specs/frontend/37-0-project-setup.md ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Spec 37.0: Frontend Project Setup
2
+
3
+ **Status**: READY FOR IMPLEMENTATION
4
+ **Phase**: 0 of 5
5
+ **Depends On**: Spec 36 (Stack Definition)
6
+ **Goal**: Scaffold Vite + React + TypeScript project with testing infrastructure
7
+
8
+ ---
9
+
10
+ ## Deliverables
11
+
12
+ By the end of this phase, you will have:
13
+
14
+ 1. Working Vite dev server (`npm run dev`)
15
+ 2. TypeScript compilation passing (`npx tsc --noEmit`)
16
+ 3. Vitest running with a smoke test (`npm test`)
17
+ 4. Tailwind CSS working
18
+ 5. MSW configured for API mocking
19
+
20
+ ---
21
+
22
+ ## Step 1: Create Vite Project
23
+
24
+ ```bash
25
+ cd /Users/ray/Desktop/CLARITY-DIGITAL-TWIN/stroke-deepisles-demo
26
+ npm create vite@latest frontend -- --template react-ts
27
+ cd frontend
28
+ ```
29
+
30
+ ---
31
+
32
+ ## Step 2: Install Dependencies
33
+
34
+ ```bash
35
+ # Core dependencies
36
+ npm install @niivue/niivue@0.65.0
37
+
38
+ # Dev dependencies - Testing
39
+ npm install -D vitest@2.1.8 @vitest/coverage-v8@2.1.8 @vitest/ui@2.1.8
40
+ npm install -D @testing-library/react@16.3.0 @testing-library/jest-dom@6.6.3 @testing-library/user-event@14.5.2
41
+ npm install -D jsdom@25.0.1 msw@2.7.0
42
+
43
+ # Dev dependencies - Styling
44
+ npm install -D tailwindcss@4.1.7 @tailwindcss/vite@4.1.7
45
+ ```
46
+
47
+ ---
48
+
49
+ ## Step 3: Configure package.json Scripts
50
+
51
+ Replace scripts section:
52
+
53
+ ```json
54
+ {
55
+ "scripts": {
56
+ "dev": "vite",
57
+ "build": "tsc -b && vite build",
58
+ "preview": "vite preview",
59
+ "test": "vitest",
60
+ "test:ui": "vitest --ui",
61
+ "test:coverage": "vitest run --coverage"
62
+ }
63
+ }
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Step 4: Configure Vite + Vitest
69
+
70
+ Replace `vite.config.ts`:
71
+
72
+ ```typescript
73
+ /// <reference types="vitest" />
74
+ import { defineConfig } from 'vite'
75
+ import react from '@vitejs/plugin-react'
76
+ import tailwindcss from '@tailwindcss/vite'
77
+
78
+ export default defineConfig({
79
+ plugins: [react(), tailwindcss()],
80
+ build: {
81
+ outDir: 'dist',
82
+ },
83
+ test: {
84
+ globals: true,
85
+ environment: 'jsdom',
86
+ setupFiles: ['./src/test/setup.ts'],
87
+ include: ['src/**/*.{test,spec}.{ts,tsx}'],
88
+ exclude: ['node_modules', 'e2e'],
89
+ coverage: {
90
+ provider: 'v8',
91
+ reporter: ['text', 'json', 'html'],
92
+ include: ['src/**/*.{ts,tsx}'],
93
+ exclude: [
94
+ 'src/**/*.test.{ts,tsx}',
95
+ 'src/test/**',
96
+ 'src/mocks/**',
97
+ 'src/main.tsx',
98
+ ],
99
+ },
100
+ },
101
+ })
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Step 5: Configure TypeScript
107
+
108
+ Replace `tsconfig.json`:
109
+
110
+ ```json
111
+ {
112
+ "compilerOptions": {
113
+ "target": "ES2020",
114
+ "useDefineForClassFields": true,
115
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
116
+ "module": "ESNext",
117
+ "skipLibCheck": true,
118
+ "moduleResolution": "bundler",
119
+ "allowImportingTsExtensions": true,
120
+ "isolatedModules": true,
121
+ "moduleDetection": "force",
122
+ "noEmit": true,
123
+ "jsx": "react-jsx",
124
+ "strict": true,
125
+ "noUnusedLocals": true,
126
+ "noUnusedParameters": true,
127
+ "noFallthroughCasesInSwitch": true,
128
+ "types": ["vitest/globals", "@testing-library/jest-dom"]
129
+ },
130
+ "include": ["src"]
131
+ }
132
+ ```
133
+
134
+ ---
135
+
136
+ ## Step 6: Configure Tailwind CSS
137
+
138
+ Replace `src/index.css`:
139
+
140
+ ```css
141
+ @import "tailwindcss";
142
+ ```
143
+
144
+ ---
145
+
146
+ ## Step 7: Create Test Setup
147
+
148
+ Create `src/test/setup.ts`:
149
+
150
+ ```typescript
151
+ import '@testing-library/jest-dom/vitest'
152
+ import { cleanup } from '@testing-library/react'
153
+ import { afterEach, beforeAll, afterAll } from 'vitest'
154
+ import { server } from '../mocks/server'
155
+
156
+ // Establish API mocking before all tests
157
+ beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
158
+
159
+ // Clean up after each test
160
+ afterEach(() => {
161
+ cleanup()
162
+ server.resetHandlers()
163
+ })
164
+
165
+ // Clean up after all tests
166
+ afterAll(() => server.close())
167
+
168
+ // Mock ResizeObserver (needed for some UI components)
169
+ global.ResizeObserver = class ResizeObserver {
170
+ observe() {}
171
+ unobserve() {}
172
+ disconnect() {}
173
+ }
174
+ ```
175
+
176
+ ---
177
+
178
+ ## Step 8: Create MSW Mocks
179
+
180
+ Create `src/mocks/handlers.ts`:
181
+
182
+ ```typescript
183
+ import { http, HttpResponse } from 'msw'
184
+
185
+ const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:7860'
186
+
187
+ export const handlers = [
188
+ // GET /api/cases - List available cases
189
+ http.get(`${API_BASE}/api/cases`, () => {
190
+ return HttpResponse.json({
191
+ cases: ['sub-stroke0001', 'sub-stroke0002', 'sub-stroke0003'],
192
+ })
193
+ }),
194
+
195
+ // POST /api/segment - Run segmentation
196
+ http.post(`${API_BASE}/api/segment`, async ({ request }) => {
197
+ const body = (await request.json()) as { case_id: string; fast_mode?: boolean }
198
+ return HttpResponse.json({
199
+ caseId: body.case_id,
200
+ diceScore: 0.847,
201
+ volumeMl: 15.32,
202
+ elapsedSeconds: body.fast_mode === false ? 45.0 : 12.5,
203
+ dwiUrl: `${API_BASE}/files/dwi.nii.gz`,
204
+ predictionUrl: `${API_BASE}/files/prediction.nii.gz`,
205
+ })
206
+ }),
207
+ ]
208
+ ```
209
+
210
+ Create `src/mocks/server.ts`:
211
+
212
+ ```typescript
213
+ import { setupServer } from 'msw/node'
214
+ import { handlers } from './handlers'
215
+
216
+ export const server = setupServer(...handlers)
217
+ ```
218
+
219
+ ---
220
+
221
+ ## Step 9: Create Smoke Test
222
+
223
+ Create `src/App.test.tsx`:
224
+
225
+ ```typescript
226
+ import { describe, it, expect } from 'vitest'
227
+ import { render, screen } from '@testing-library/react'
228
+ import App from './App'
229
+
230
+ describe('App', () => {
231
+ it('renders without crashing', () => {
232
+ render(<App />)
233
+ // This will pass with default Vite template
234
+ expect(document.body).toBeInTheDocument()
235
+ })
236
+ })
237
+ ```
238
+
239
+ ---
240
+
241
+ ## Step 10: Create Environment File
242
+
243
+ Create `.env`:
244
+
245
+ ```env
246
+ VITE_API_URL=http://localhost:7860
247
+ ```
248
+
249
+ Create `.env.example`:
250
+
251
+ ```env
252
+ VITE_API_URL=http://localhost:7860
253
+ ```
254
+
255
+ ---
256
+
257
+ ## Verification Checklist
258
+
259
+ Run these commands to verify setup:
260
+
261
+ ```bash
262
+ # 1. TypeScript compiles
263
+ npx tsc --noEmit
264
+ # Expected: No errors
265
+
266
+ # 2. Dev server starts
267
+ npm run dev
268
+ # Expected: Server at http://localhost:5173
269
+
270
+ # 3. Tests pass
271
+ npm test
272
+ # Expected: 1 test passing
273
+
274
+ # 4. Build works
275
+ npm run build
276
+ # Expected: dist/ folder created
277
+ ```
278
+
279
+ ---
280
+
281
+ ## File Structure After This Phase
282
+
283
+ ```
284
+ frontend/
285
+ ├── src/
286
+ │ ├── mocks/
287
+ │ │ ├── handlers.ts
288
+ │ │ └── server.ts
289
+ │ ├── test/
290
+ │ │ └── setup.ts
291
+ │ ├── App.tsx
292
+ │ ├── App.test.tsx
293
+ │ ├── main.tsx
294
+ │ └── index.css
295
+ ├── .env
296
+ ├── .env.example
297
+ ├── index.html
298
+ ├── package.json
299
+ ├── tsconfig.json
300
+ └── vite.config.ts
301
+ ```
302
+
303
+ ---
304
+
305
+ ## Next Phase
306
+
307
+ Once verification passes, proceed to **Spec 37.1: Foundation Components**
docs/specs/frontend/37-1-foundation-components.md ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Spec 37.1: Foundation Components
2
+
3
+ **Status**: READY FOR IMPLEMENTATION
4
+ **Phase**: 1 of 5
5
+ **Depends On**: Spec 37.0 (Project Setup)
6
+ **Goal**: TDD implementation of Layout and MetricsPanel components
7
+
8
+ ---
9
+
10
+ ## Deliverables
11
+
12
+ By the end of this phase, you will have:
13
+
14
+ 1. `Layout` component with header and main content area
15
+ 2. `MetricsPanel` component displaying segmentation results
16
+ 3. 100% test coverage for both components
17
+ 4. Visual verification in browser
18
+
19
+ ---
20
+
21
+ ## Component 1: Layout
22
+
23
+ ### Test First
24
+
25
+ Create `src/components/__tests__/Layout.test.tsx`:
26
+
27
+ ```typescript
28
+ import { describe, it, expect } from 'vitest'
29
+ import { render, screen } from '@testing-library/react'
30
+ import { Layout } from '../Layout'
31
+
32
+ describe('Layout', () => {
33
+ it('renders header with title', () => {
34
+ render(<Layout>Content</Layout>)
35
+
36
+ expect(
37
+ screen.getByRole('heading', { name: /stroke lesion segmentation/i })
38
+ ).toBeInTheDocument()
39
+ })
40
+
41
+ it('renders subtitle', () => {
42
+ render(<Layout>Content</Layout>)
43
+
44
+ expect(screen.getByText(/deepisles segmentation/i)).toBeInTheDocument()
45
+ })
46
+
47
+ it('renders children in main area', () => {
48
+ render(
49
+ <Layout>
50
+ <div data-testid="child">Test Child</div>
51
+ </Layout>
52
+ )
53
+
54
+ expect(screen.getByTestId('child')).toBeInTheDocument()
55
+ })
56
+
57
+ it('has accessible landmark structure', () => {
58
+ render(<Layout>Content</Layout>)
59
+
60
+ expect(screen.getByRole('banner')).toBeInTheDocument()
61
+ expect(screen.getByRole('main')).toBeInTheDocument()
62
+ })
63
+
64
+ it('applies dark theme styling', () => {
65
+ render(<Layout>Content</Layout>)
66
+
67
+ const container = screen.getByRole('banner').parentElement
68
+ expect(container).toHaveClass('bg-gray-950')
69
+ })
70
+ })
71
+ ```
72
+
73
+ ### Implementation
74
+
75
+ Create `src/components/Layout.tsx`:
76
+
77
+ ```typescript
78
+ import { ReactNode } from 'react'
79
+
80
+ interface LayoutProps {
81
+ children: ReactNode
82
+ }
83
+
84
+ export function Layout({ children }: LayoutProps) {
85
+ return (
86
+ <div className="min-h-screen bg-gray-950 text-white">
87
+ <header className="border-b border-gray-800 py-4">
88
+ <div className="container mx-auto px-4">
89
+ <h1 className="text-2xl font-bold">Stroke Lesion Segmentation</h1>
90
+ <p className="text-gray-400 text-sm mt-1">
91
+ DeepISLES segmentation on ISLES24 dataset
92
+ </p>
93
+ </div>
94
+ </header>
95
+ <main className="container mx-auto px-4 py-6">{children}</main>
96
+ </div>
97
+ )
98
+ }
99
+ ```
100
+
101
+ ### Verify
102
+
103
+ ```bash
104
+ npm test -- Layout
105
+ # Expected: 5 tests passing
106
+ ```
107
+
108
+ ---
109
+
110
+ ## Component 2: MetricsPanel
111
+
112
+ ### Test First
113
+
114
+ Create `src/components/__tests__/MetricsPanel.test.tsx`:
115
+
116
+ ```typescript
117
+ import { describe, it, expect } from 'vitest'
118
+ import { render, screen } from '@testing-library/react'
119
+ import { MetricsPanel } from '../MetricsPanel'
120
+
121
+ describe('MetricsPanel', () => {
122
+ const defaultMetrics = {
123
+ caseId: 'sub-stroke0001',
124
+ diceScore: 0.847,
125
+ volumeMl: 15.32,
126
+ elapsedSeconds: 12.5,
127
+ }
128
+
129
+ it('renders results heading', () => {
130
+ render(<MetricsPanel metrics={defaultMetrics} />)
131
+
132
+ expect(
133
+ screen.getByRole('heading', { name: /results/i })
134
+ ).toBeInTheDocument()
135
+ })
136
+
137
+ it('displays case ID', () => {
138
+ render(<MetricsPanel metrics={defaultMetrics} />)
139
+
140
+ expect(screen.getByText('sub-stroke0001')).toBeInTheDocument()
141
+ })
142
+
143
+ it('displays dice score with 3 decimal places', () => {
144
+ render(<MetricsPanel metrics={defaultMetrics} />)
145
+
146
+ expect(screen.getByText('0.847')).toBeInTheDocument()
147
+ })
148
+
149
+ it('displays volume in mL with 2 decimal places', () => {
150
+ render(<MetricsPanel metrics={defaultMetrics} />)
151
+
152
+ expect(screen.getByText('15.32 mL')).toBeInTheDocument()
153
+ })
154
+
155
+ it('displays elapsed time with 1 decimal place', () => {
156
+ render(<MetricsPanel metrics={defaultMetrics} />)
157
+
158
+ expect(screen.getByText('12.5s')).toBeInTheDocument()
159
+ })
160
+
161
+ it('hides dice score row when null', () => {
162
+ render(
163
+ <MetricsPanel metrics={{ ...defaultMetrics, diceScore: null }} />
164
+ )
165
+
166
+ expect(screen.queryByText(/dice score/i)).not.toBeInTheDocument()
167
+ })
168
+
169
+ it('hides volume row when null', () => {
170
+ render(
171
+ <MetricsPanel metrics={{ ...defaultMetrics, volumeMl: null }} />
172
+ )
173
+
174
+ expect(screen.queryByText(/volume/i)).not.toBeInTheDocument()
175
+ })
176
+
177
+ it('applies card styling', () => {
178
+ render(<MetricsPanel metrics={defaultMetrics} />)
179
+
180
+ const panel = screen.getByRole('heading', { name: /results/i }).parentElement
181
+ expect(panel).toHaveClass('bg-gray-800', 'rounded-lg')
182
+ })
183
+ })
184
+ ```
185
+
186
+ ### Implementation
187
+
188
+ Create `src/components/MetricsPanel.tsx`:
189
+
190
+ ```typescript
191
+ interface Metrics {
192
+ caseId: string
193
+ diceScore: number | null
194
+ volumeMl: number | null
195
+ elapsedSeconds: number
196
+ }
197
+
198
+ interface MetricsPanelProps {
199
+ metrics: Metrics
200
+ }
201
+
202
+ export function MetricsPanel({ metrics }: MetricsPanelProps) {
203
+ return (
204
+ <div className="bg-gray-800 rounded-lg p-4 space-y-3">
205
+ <h3 className="font-medium text-lg">Results</h3>
206
+
207
+ <div className="grid grid-cols-2 gap-3 text-sm">
208
+ <div>
209
+ <span className="text-gray-400">Case:</span>
210
+ <span className="ml-2 font-mono">{metrics.caseId}</span>
211
+ </div>
212
+
213
+ {metrics.diceScore !== null && (
214
+ <div>
215
+ <span className="text-gray-400">Dice Score:</span>
216
+ <span className="ml-2 font-mono text-green-400">
217
+ {metrics.diceScore.toFixed(3)}
218
+ </span>
219
+ </div>
220
+ )}
221
+
222
+ {metrics.volumeMl !== null && (
223
+ <div>
224
+ <span className="text-gray-400">Volume:</span>
225
+ <span className="ml-2 font-mono">
226
+ {metrics.volumeMl.toFixed(2)} mL
227
+ </span>
228
+ </div>
229
+ )}
230
+
231
+ <div>
232
+ <span className="text-gray-400">Time:</span>
233
+ <span className="ml-2 font-mono">
234
+ {metrics.elapsedSeconds.toFixed(1)}s
235
+ </span>
236
+ </div>
237
+ </div>
238
+ </div>
239
+ )
240
+ }
241
+ ```
242
+
243
+ ### Verify
244
+
245
+ ```bash
246
+ npm test -- MetricsPanel
247
+ # Expected: 8 tests passing
248
+ ```
249
+
250
+ ---
251
+
252
+ ## Create Index Export
253
+
254
+ Create `src/components/index.ts`:
255
+
256
+ ```typescript
257
+ export { Layout } from './Layout'
258
+ export { MetricsPanel } from './MetricsPanel'
259
+ ```
260
+
261
+ ---
262
+
263
+ ## Visual Verification
264
+
265
+ Update `src/App.tsx` to see components:
266
+
267
+ ```typescript
268
+ import { Layout } from './components/Layout'
269
+ import { MetricsPanel } from './components/MetricsPanel'
270
+
271
+ const mockMetrics = {
272
+ caseId: 'sub-stroke0001',
273
+ diceScore: 0.847,
274
+ volumeMl: 15.32,
275
+ elapsedSeconds: 12.5,
276
+ }
277
+
278
+ function App() {
279
+ return (
280
+ <Layout>
281
+ <div className="max-w-md">
282
+ <MetricsPanel metrics={mockMetrics} />
283
+ </div>
284
+ </Layout>
285
+ )
286
+ }
287
+
288
+ export default App
289
+ ```
290
+
291
+ Run dev server and verify visually:
292
+
293
+ ```bash
294
+ npm run dev
295
+ # Open http://localhost:5173
296
+ ```
297
+
298
+ ---
299
+
300
+ ## Verification Checklist
301
+
302
+ - [ ] `npm test` - All 13+ tests pass
303
+ - [ ] `npm run dev` - Components render correctly
304
+ - [ ] Header shows "Stroke Lesion Segmentation"
305
+ - [ ] MetricsPanel shows all metrics with correct formatting
306
+ - [ ] Dark theme applies correctly
307
+
308
+ ---
309
+
310
+ ## File Structure After This Phase
311
+
312
+ ```
313
+ frontend/src/
314
+ ├── components/
315
+ │ ├── __tests__/
316
+ │ │ ├── Layout.test.tsx
317
+ │ │ └── MetricsPanel.test.tsx
318
+ │ ├── Layout.tsx
319
+ │ ├── MetricsPanel.tsx
320
+ │ └── index.ts
321
+ ├── mocks/
322
+ ├── test/
323
+ ├── App.tsx (updated)
324
+ └── ...
325
+ ```
326
+
327
+ ---
328
+
329
+ ## Next Phase
330
+
331
+ Once verification passes, proceed to **Spec 37.2: API Layer**
docs/specs/frontend/37-2-api-layer.md ADDED
@@ -0,0 +1,523 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Spec 37.2: API Layer
2
+
3
+ **Status**: READY FOR IMPLEMENTATION
4
+ **Phase**: 2 of 5
5
+ **Depends On**: Spec 37.1 (Foundation Components)
6
+ **Goal**: TDD implementation of API client and useSegmentation hook
7
+
8
+ ---
9
+
10
+ ## Deliverables
11
+
12
+ By the end of this phase, you will have:
13
+
14
+ 1. Type definitions for API responses
15
+ 2. `apiClient` with `getCases()` and `runSegmentation()` methods
16
+ 3. `useSegmentation` React hook for state management
17
+ 4. MSW handlers for all API endpoints
18
+ 5. Error handling tests
19
+
20
+ ---
21
+
22
+ ## Step 1: Type Definitions
23
+
24
+ Create `src/types/index.ts`:
25
+
26
+ ```typescript
27
+ export interface Metrics {
28
+ caseId: string
29
+ diceScore: number | null
30
+ volumeMl: number | null
31
+ elapsedSeconds: number
32
+ }
33
+
34
+ export interface SegmentationResult {
35
+ dwiUrl: string
36
+ predictionUrl: string
37
+ metrics: Metrics
38
+ }
39
+
40
+ export interface CasesResponse {
41
+ cases: string[]
42
+ }
43
+
44
+ export interface SegmentResponse {
45
+ caseId: string
46
+ diceScore: number | null
47
+ volumeMl: number | null
48
+ elapsedSeconds: number
49
+ dwiUrl: string
50
+ predictionUrl: string
51
+ }
52
+ ```
53
+
54
+ ---
55
+
56
+ ## Step 2: Test Fixtures
57
+
58
+ Create `src/test/fixtures.ts`:
59
+
60
+ ```typescript
61
+ import type { SegmentationResult, CasesResponse } from '../types'
62
+
63
+ export const mockCases: string[] = [
64
+ 'sub-stroke0001',
65
+ 'sub-stroke0002',
66
+ 'sub-stroke0003',
67
+ ]
68
+
69
+ export const mockCasesResponse: CasesResponse = {
70
+ cases: mockCases,
71
+ }
72
+
73
+ export const mockSegmentationResult: SegmentationResult = {
74
+ dwiUrl: 'http://localhost:7860/files/dwi.nii.gz',
75
+ predictionUrl: 'http://localhost:7860/files/prediction.nii.gz',
76
+ metrics: {
77
+ caseId: 'sub-stroke0001',
78
+ diceScore: 0.847,
79
+ volumeMl: 15.32,
80
+ elapsedSeconds: 12.5,
81
+ },
82
+ }
83
+ ```
84
+
85
+ ---
86
+
87
+ ## Step 3: Enhanced MSW Handlers
88
+
89
+ Update `src/mocks/handlers.ts`:
90
+
91
+ ```typescript
92
+ import { http, HttpResponse, delay } from 'msw'
93
+
94
+ const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:7860'
95
+
96
+ export const handlers = [
97
+ http.get(`${API_BASE}/api/cases`, async () => {
98
+ await delay(100)
99
+ return HttpResponse.json({
100
+ cases: ['sub-stroke0001', 'sub-stroke0002', 'sub-stroke0003'],
101
+ })
102
+ }),
103
+
104
+ http.post(`${API_BASE}/api/segment`, async ({ request }) => {
105
+ const body = (await request.json()) as { case_id: string; fast_mode?: boolean }
106
+ await delay(200)
107
+ return HttpResponse.json({
108
+ caseId: body.case_id,
109
+ diceScore: 0.847,
110
+ volumeMl: 15.32,
111
+ // Reflect fast_mode in response - slower when fast_mode=false
112
+ elapsedSeconds: body.fast_mode === false ? 45.0 : 12.5,
113
+ dwiUrl: `${API_BASE}/files/dwi.nii.gz`,
114
+ predictionUrl: `${API_BASE}/files/prediction.nii.gz`,
115
+ })
116
+ }),
117
+ ]
118
+
119
+ // Error handlers for testing error states
120
+ export const errorHandlers = {
121
+ casesServerError: http.get(`${API_BASE}/api/cases`, () => {
122
+ return HttpResponse.json(
123
+ { detail: 'Internal server error' },
124
+ { status: 500 }
125
+ )
126
+ }),
127
+
128
+ casesNetworkError: http.get(`${API_BASE}/api/cases`, () => {
129
+ return HttpResponse.error()
130
+ }),
131
+
132
+ segmentServerError: http.post(`${API_BASE}/api/segment`, () => {
133
+ return HttpResponse.json(
134
+ { detail: 'Segmentation failed: out of memory' },
135
+ { status: 500 }
136
+ )
137
+ }),
138
+
139
+ segmentTimeout: http.post(`${API_BASE}/api/segment`, async () => {
140
+ await delay(30000)
141
+ return HttpResponse.json({ detail: 'Timeout' }, { status: 504 })
142
+ }),
143
+ }
144
+ ```
145
+
146
+ ---
147
+
148
+ ## Step 4: API Client
149
+
150
+ ### Test First
151
+
152
+ Create `src/api/__tests__/client.test.ts`:
153
+
154
+ ```typescript
155
+ import { describe, it, expect } from 'vitest'
156
+ import { server } from '../../mocks/server'
157
+ import { errorHandlers } from '../../mocks/handlers'
158
+ import { apiClient } from '../client'
159
+
160
+ describe('apiClient', () => {
161
+ describe('getCases', () => {
162
+ it('returns list of case IDs', async () => {
163
+ const result = await apiClient.getCases()
164
+
165
+ expect(result.cases).toHaveLength(3)
166
+ expect(result.cases).toContain('sub-stroke0001')
167
+ })
168
+
169
+ it('throws ApiError on server error', async () => {
170
+ server.use(errorHandlers.casesServerError)
171
+
172
+ await expect(apiClient.getCases()).rejects.toThrow(/failed to fetch cases/i)
173
+ })
174
+
175
+ it('throws ApiError on network error', async () => {
176
+ server.use(errorHandlers.casesNetworkError)
177
+
178
+ await expect(apiClient.getCases()).rejects.toThrow()
179
+ })
180
+ })
181
+
182
+ describe('runSegmentation', () => {
183
+ it('returns segmentation result', async () => {
184
+ const result = await apiClient.runSegmentation('sub-stroke0001')
185
+
186
+ expect(result.caseId).toBe('sub-stroke0001')
187
+ expect(result.diceScore).toBe(0.847)
188
+ expect(result.volumeMl).toBe(15.32)
189
+ expect(result.dwiUrl).toContain('dwi.nii.gz')
190
+ expect(result.predictionUrl).toContain('prediction.nii.gz')
191
+ })
192
+
193
+ it('sends fast_mode parameter', async () => {
194
+ const result = await apiClient.runSegmentation('sub-stroke0001', false)
195
+
196
+ expect(result).toBeDefined()
197
+ })
198
+
199
+ it('defaults fast_mode to true', async () => {
200
+ const result = await apiClient.runSegmentation('sub-stroke0001')
201
+
202
+ expect(result).toBeDefined()
203
+ })
204
+
205
+ it('throws ApiError on server error', async () => {
206
+ server.use(errorHandlers.segmentServerError)
207
+
208
+ await expect(
209
+ apiClient.runSegmentation('sub-stroke0001')
210
+ ).rejects.toThrow(/segmentation failed/i)
211
+ })
212
+ })
213
+ })
214
+ ```
215
+
216
+ ### Implementation
217
+
218
+ Create `src/api/client.ts`:
219
+
220
+ ```typescript
221
+ import type { CasesResponse, SegmentResponse } from '../types'
222
+
223
+ const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:7860'
224
+
225
+ export class ApiError extends Error {
226
+ constructor(
227
+ message: string,
228
+ public status: number,
229
+ public detail?: string
230
+ ) {
231
+ super(message)
232
+ this.name = 'ApiError'
233
+ }
234
+ }
235
+
236
+ class ApiClient {
237
+ private baseUrl: string
238
+
239
+ constructor(baseUrl: string) {
240
+ this.baseUrl = baseUrl
241
+ }
242
+
243
+ async getCases(): Promise<CasesResponse> {
244
+ const response = await fetch(`${this.baseUrl}/api/cases`)
245
+
246
+ if (!response.ok) {
247
+ const error = await response.json().catch(() => ({}))
248
+ throw new ApiError(
249
+ `Failed to fetch cases: ${response.statusText}`,
250
+ response.status,
251
+ error.detail
252
+ )
253
+ }
254
+
255
+ return response.json()
256
+ }
257
+
258
+ async runSegmentation(
259
+ caseId: string,
260
+ fastMode: boolean = true
261
+ ): Promise<SegmentResponse> {
262
+ const response = await fetch(`${this.baseUrl}/api/segment`, {
263
+ method: 'POST',
264
+ headers: {
265
+ 'Content-Type': 'application/json',
266
+ },
267
+ body: JSON.stringify({
268
+ case_id: caseId,
269
+ fast_mode: fastMode,
270
+ }),
271
+ })
272
+
273
+ if (!response.ok) {
274
+ const error = await response.json().catch(() => ({}))
275
+ throw new ApiError(
276
+ `Segmentation failed: ${error.detail || response.statusText}`,
277
+ response.status,
278
+ error.detail
279
+ )
280
+ }
281
+
282
+ return response.json()
283
+ }
284
+ }
285
+
286
+ export const apiClient = new ApiClient(API_BASE)
287
+ ```
288
+
289
+ ### Verify
290
+
291
+ ```bash
292
+ npm test -- client
293
+ # Expected: 7 tests passing
294
+ ```
295
+
296
+ ---
297
+
298
+ ## Step 5: useSegmentation Hook
299
+
300
+ ### Test First
301
+
302
+ Create `src/hooks/__tests__/useSegmentation.test.tsx`:
303
+
304
+ ```typescript
305
+ import { describe, it, expect } from 'vitest'
306
+ import { renderHook, waitFor, act } from '@testing-library/react'
307
+ import { server } from '../../mocks/server'
308
+ import { errorHandlers } from '../../mocks/handlers'
309
+ import { useSegmentation } from '../useSegmentation'
310
+
311
+ describe('useSegmentation', () => {
312
+ it('starts with null result and not loading', () => {
313
+ const { result } = renderHook(() => useSegmentation())
314
+
315
+ expect(result.current.result).toBeNull()
316
+ expect(result.current.isLoading).toBe(false)
317
+ expect(result.current.error).toBeNull()
318
+ })
319
+
320
+ it('sets loading state during segmentation', async () => {
321
+ const { result } = renderHook(() => useSegmentation())
322
+
323
+ act(() => {
324
+ result.current.runSegmentation('sub-stroke0001')
325
+ })
326
+
327
+ expect(result.current.isLoading).toBe(true)
328
+
329
+ await waitFor(() => {
330
+ expect(result.current.isLoading).toBe(false)
331
+ })
332
+ })
333
+
334
+ it('returns result on success', async () => {
335
+ const { result } = renderHook(() => useSegmentation())
336
+
337
+ await act(async () => {
338
+ await result.current.runSegmentation('sub-stroke0001')
339
+ })
340
+
341
+ expect(result.current.result).not.toBeNull()
342
+ expect(result.current.result?.metrics.caseId).toBe('sub-stroke0001')
343
+ expect(result.current.result?.metrics.diceScore).toBe(0.847)
344
+ expect(result.current.result?.dwiUrl).toContain('dwi.nii.gz')
345
+ })
346
+
347
+ it('sets error on failure', async () => {
348
+ server.use(errorHandlers.segmentServerError)
349
+
350
+ const { result } = renderHook(() => useSegmentation())
351
+
352
+ await act(async () => {
353
+ await result.current.runSegmentation('sub-stroke0001')
354
+ })
355
+
356
+ expect(result.current.error).toMatch(/segmentation failed/i)
357
+ expect(result.current.result).toBeNull()
358
+ })
359
+
360
+ it('clears previous error on new request', async () => {
361
+ server.use(errorHandlers.segmentServerError)
362
+ const { result } = renderHook(() => useSegmentation())
363
+
364
+ // First request fails
365
+ await act(async () => {
366
+ await result.current.runSegmentation('sub-stroke0001')
367
+ })
368
+ expect(result.current.error).not.toBeNull()
369
+
370
+ // Reset to success handler
371
+ server.resetHandlers()
372
+
373
+ // Second request succeeds
374
+ await act(async () => {
375
+ await result.current.runSegmentation('sub-stroke0001')
376
+ })
377
+
378
+ expect(result.current.error).toBeNull()
379
+ expect(result.current.result).not.toBeNull()
380
+ })
381
+
382
+ it('clears previous result on new request', async () => {
383
+ const { result } = renderHook(() => useSegmentation())
384
+
385
+ // First request
386
+ await act(async () => {
387
+ await result.current.runSegmentation('sub-stroke0001')
388
+ })
389
+ expect(result.current.result).not.toBeNull()
390
+
391
+ // Start second request - result should clear while loading
392
+ act(() => {
393
+ result.current.runSegmentation('sub-stroke0002')
394
+ })
395
+
396
+ // While loading, previous result is still available
397
+ // (or you could clear it - depends on UX preference)
398
+ expect(result.current.isLoading).toBe(true)
399
+ })
400
+ })
401
+ ```
402
+
403
+ ### Implementation
404
+
405
+ Create `src/hooks/useSegmentation.ts`:
406
+
407
+ ```typescript
408
+ import { useState, useCallback } from 'react'
409
+ import { apiClient } from '../api/client'
410
+ import type { SegmentationResult } from '../types'
411
+
412
+ export function useSegmentation() {
413
+ const [result, setResult] = useState<SegmentationResult | null>(null)
414
+ const [isLoading, setIsLoading] = useState(false)
415
+ const [error, setError] = useState<string | null>(null)
416
+
417
+ const runSegmentation = useCallback(async (caseId: string, fastMode = true) => {
418
+ setIsLoading(true)
419
+ setError(null)
420
+
421
+ try {
422
+ const data = await apiClient.runSegmentation(caseId, fastMode)
423
+
424
+ setResult({
425
+ dwiUrl: data.dwiUrl,
426
+ predictionUrl: data.predictionUrl,
427
+ metrics: {
428
+ caseId: data.caseId,
429
+ diceScore: data.diceScore,
430
+ volumeMl: data.volumeMl,
431
+ elapsedSeconds: data.elapsedSeconds,
432
+ },
433
+ })
434
+ } catch (err) {
435
+ const message = err instanceof Error ? err.message : 'Unknown error'
436
+ setError(message)
437
+ setResult(null)
438
+ } finally {
439
+ setIsLoading(false)
440
+ }
441
+ }, [])
442
+
443
+ return { result, isLoading, error, runSegmentation }
444
+ }
445
+ ```
446
+
447
+ ### Verify
448
+
449
+ ```bash
450
+ npm test -- useSegmentation
451
+ # Expected: 6 tests passing
452
+ ```
453
+
454
+ ---
455
+
456
+ ## Step 6: Create Index Export
457
+
458
+ Create `src/hooks/index.ts`:
459
+
460
+ ```typescript
461
+ export { useSegmentation } from './useSegmentation'
462
+ ```
463
+
464
+ Create `src/api/index.ts`:
465
+
466
+ ```typescript
467
+ export { apiClient, ApiError } from './client'
468
+ ```
469
+
470
+ ---
471
+
472
+ ## Verification Checklist
473
+
474
+ ```bash
475
+ # Run all tests
476
+ npm test
477
+
478
+ # Expected output:
479
+ # - client.test.ts: 7 tests passing
480
+ # - useSegmentation.test.tsx: 6 tests passing
481
+ # Total: ~25+ tests passing
482
+ ```
483
+
484
+ - [ ] API client handles success responses
485
+ - [ ] API client handles error responses
486
+ - [ ] Hook manages loading state correctly
487
+ - [ ] Hook manages error state correctly
488
+ - [ ] Hook transforms API response to SegmentationResult
489
+
490
+ ---
491
+
492
+ ## File Structure After This Phase
493
+
494
+ ```
495
+ frontend/src/
496
+ ├── api/
497
+ │ ├── __tests__/
498
+ │ │ └── client.test.ts
499
+ │ ├── client.ts
500
+ │ └── index.ts
501
+ ├── hooks/
502
+ │ ├── __tests__/
503
+ │ │ └── useSegmentation.test.tsx
504
+ │ ├── useSegmentation.ts
505
+ │ └── index.ts
506
+ ├── types/
507
+ │ └── index.ts
508
+ ├── test/
509
+ │ ├── setup.ts
510
+ │ └── fixtures.ts
511
+ ├── mocks/
512
+ │ ├── handlers.ts (updated)
513
+ │ └── server.ts
514
+ ├── components/
515
+ │ └── ...
516
+ └── ...
517
+ ```
518
+
519
+ ---
520
+
521
+ ## Next Phase
522
+
523
+ Once verification passes, proceed to **Spec 37.3: Interactive Components**
docs/specs/frontend/37-3-interactive-components.md ADDED
@@ -0,0 +1,681 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Spec 37.3: Interactive Components
2
+
3
+ **Status**: READY FOR IMPLEMENTATION
4
+ **Phase**: 3 of 5
5
+ **Depends On**: Spec 37.2 (API Layer)
6
+ **Goal**: TDD implementation of CaseSelector and NiiVueViewer components
7
+
8
+ ---
9
+
10
+ ## Deliverables
11
+
12
+ By the end of this phase, you will have:
13
+
14
+ 1. `CaseSelector` dropdown that fetches and displays cases
15
+ 2. `NiiVueViewer` component for 3D medical image viewing
16
+ 3. Loading and error states for both components
17
+ 4. WebGL mocking for NiiVue tests
18
+
19
+ ---
20
+
21
+ ## Component 1: CaseSelector
22
+
23
+ ### Test First
24
+
25
+ Create `src/components/__tests__/CaseSelector.test.tsx`:
26
+
27
+ ```typescript
28
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
29
+ import { render, screen, waitFor } from '@testing-library/react'
30
+ import userEvent from '@testing-library/user-event'
31
+ import { server } from '../../mocks/server'
32
+ import { errorHandlers } from '../../mocks/handlers'
33
+ import { CaseSelector } from '../CaseSelector'
34
+
35
+ describe('CaseSelector', () => {
36
+ const mockOnSelectCase = vi.fn()
37
+
38
+ beforeEach(() => {
39
+ mockOnSelectCase.mockClear()
40
+ })
41
+
42
+ it('shows loading state initially', () => {
43
+ render(
44
+ <CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
45
+ )
46
+
47
+ expect(screen.getByText(/loading/i)).toBeInTheDocument()
48
+ })
49
+
50
+ it('renders select after loading', async () => {
51
+ render(
52
+ <CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
53
+ )
54
+
55
+ await waitFor(() => {
56
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
57
+ })
58
+ })
59
+
60
+ it('displays all cases as options', async () => {
61
+ render(
62
+ <CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
63
+ )
64
+
65
+ await waitFor(() => {
66
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
67
+ })
68
+
69
+ expect(screen.getByRole('option', { name: /sub-stroke0001/i })).toBeInTheDocument()
70
+ expect(screen.getByRole('option', { name: /sub-stroke0002/i })).toBeInTheDocument()
71
+ expect(screen.getByRole('option', { name: /sub-stroke0003/i })).toBeInTheDocument()
72
+ })
73
+
74
+ it('has placeholder option', async () => {
75
+ render(
76
+ <CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
77
+ )
78
+
79
+ await waitFor(() => {
80
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
81
+ })
82
+
83
+ expect(screen.getByRole('option', { name: /choose a case/i })).toBeInTheDocument()
84
+ })
85
+
86
+ it('calls onSelectCase when case selected', async () => {
87
+ const user = userEvent.setup()
88
+
89
+ render(
90
+ <CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
91
+ )
92
+
93
+ await waitFor(() => {
94
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
95
+ })
96
+
97
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
98
+
99
+ expect(mockOnSelectCase).toHaveBeenCalledWith('sub-stroke0001')
100
+ })
101
+
102
+ it('shows selected case value', async () => {
103
+ render(
104
+ <CaseSelector
105
+ selectedCase="sub-stroke0002"
106
+ onSelectCase={mockOnSelectCase}
107
+ />
108
+ )
109
+
110
+ await waitFor(() => {
111
+ expect(screen.getByRole('combobox')).toHaveValue('sub-stroke0002')
112
+ })
113
+ })
114
+
115
+ it('shows error state on API failure', async () => {
116
+ server.use(errorHandlers.casesServerError)
117
+
118
+ render(
119
+ <CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
120
+ )
121
+
122
+ await waitFor(() => {
123
+ expect(screen.getByText(/failed to load/i)).toBeInTheDocument()
124
+ })
125
+ })
126
+
127
+ it('applies correct styling', async () => {
128
+ render(
129
+ <CaseSelector selectedCase={null} onSelectCase={mockOnSelectCase} />
130
+ )
131
+
132
+ await waitFor(() => {
133
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
134
+ })
135
+
136
+ const container = screen.getByRole('combobox').closest('div')
137
+ expect(container).toHaveClass('bg-gray-800')
138
+ })
139
+ })
140
+ ```
141
+
142
+ ### Implementation
143
+
144
+ Create `src/components/CaseSelector.tsx`:
145
+
146
+ ```typescript
147
+ import { useEffect, useState } from 'react'
148
+ import { apiClient } from '../api/client'
149
+
150
+ interface CaseSelectorProps {
151
+ selectedCase: string | null
152
+ onSelectCase: (caseId: string) => void
153
+ }
154
+
155
+ export function CaseSelector({ selectedCase, onSelectCase }: CaseSelectorProps) {
156
+ const [cases, setCases] = useState<string[]>([])
157
+ const [isLoading, setIsLoading] = useState(true)
158
+ const [error, setError] = useState<string | null>(null)
159
+
160
+ useEffect(() => {
161
+ const fetchCases = async () => {
162
+ try {
163
+ const data = await apiClient.getCases()
164
+ setCases(data.cases)
165
+ } catch (err) {
166
+ const message = err instanceof Error ? err.message : 'Unknown error'
167
+ setError(`Failed to load cases: ${message}`)
168
+ } finally {
169
+ setIsLoading(false)
170
+ }
171
+ }
172
+
173
+ fetchCases()
174
+ }, [])
175
+
176
+ if (isLoading) {
177
+ return (
178
+ <div className="bg-gray-800 rounded-lg p-4">
179
+ <p className="text-gray-400">Loading cases...</p>
180
+ </div>
181
+ )
182
+ }
183
+
184
+ if (error) {
185
+ return (
186
+ <div className="bg-red-900/50 rounded-lg p-4">
187
+ <p className="text-red-300">{error}</p>
188
+ </div>
189
+ )
190
+ }
191
+
192
+ return (
193
+ <div className="bg-gray-800 rounded-lg p-4">
194
+ <label className="block text-sm font-medium mb-2">Select Case</label>
195
+ <select
196
+ value={selectedCase || ''}
197
+ onChange={(e) => onSelectCase(e.target.value)}
198
+ className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2
199
+ text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
200
+ >
201
+ <option value="">Choose a case...</option>
202
+ {cases.map((caseId) => (
203
+ <option key={caseId} value={caseId}>
204
+ {caseId}
205
+ </option>
206
+ ))}
207
+ </select>
208
+ </div>
209
+ )
210
+ }
211
+ ```
212
+
213
+ ### Verify
214
+
215
+ ```bash
216
+ npm test -- CaseSelector
217
+ # Expected: 9 tests passing
218
+ ```
219
+
220
+ ---
221
+
222
+ ## Component 2: NiiVueViewer
223
+
224
+ ### WebGL Mock Setup
225
+
226
+ Update `src/test/setup.ts` to add WebGL mocking:
227
+
228
+ ```typescript
229
+ import '@testing-library/jest-dom/vitest'
230
+ import { cleanup } from '@testing-library/react'
231
+ import { afterEach, beforeAll, afterAll, vi } from 'vitest'
232
+ import { server } from '../mocks/server'
233
+
234
+ beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
235
+
236
+ afterEach(() => {
237
+ cleanup()
238
+ server.resetHandlers()
239
+ })
240
+
241
+ afterAll(() => server.close())
242
+
243
+ // Mock ResizeObserver
244
+ global.ResizeObserver = class ResizeObserver {
245
+ observe() {}
246
+ unobserve() {}
247
+ disconnect() {}
248
+ }
249
+
250
+ // Mock WebGL2 context for NiiVue
251
+ // NiiVue requires specific extensions for float textures (overlays)
252
+ // See: https://github.com/niivue/niivue#browser-requirements
253
+ const mockExtensions: Record<string, object> = {
254
+ // Required for float textures (overlay rendering)
255
+ EXT_color_buffer_float: {},
256
+ OES_texture_float_linear: {},
257
+ // Required for WebGL context management
258
+ WEBGL_lose_context: {
259
+ loseContext: vi.fn(),
260
+ restoreContext: vi.fn(),
261
+ },
262
+ // Optional but commonly requested
263
+ EXT_texture_filter_anisotropic: {
264
+ TEXTURE_MAX_ANISOTROPY_EXT: 0x84fe,
265
+ MAX_TEXTURE_MAX_ANISOTROPY_EXT: 0x84ff,
266
+ },
267
+ WEBGL_debug_renderer_info: {
268
+ UNMASKED_VENDOR_WEBGL: 0x9245,
269
+ UNMASKED_RENDERER_WEBGL: 0x9246,
270
+ },
271
+ }
272
+
273
+ const mockWebGL2Context = {
274
+ canvas: null as HTMLCanvasElement | null,
275
+ drawingBufferWidth: 640,
276
+ drawingBufferHeight: 480,
277
+ createShader: vi.fn(() => ({})),
278
+ shaderSource: vi.fn(),
279
+ compileShader: vi.fn(),
280
+ getShaderParameter: vi.fn(() => true),
281
+ getShaderInfoLog: vi.fn(() => ''),
282
+ createProgram: vi.fn(() => ({})),
283
+ attachShader: vi.fn(),
284
+ linkProgram: vi.fn(),
285
+ getProgramParameter: vi.fn(() => true),
286
+ getProgramInfoLog: vi.fn(() => ''),
287
+ useProgram: vi.fn(),
288
+ getAttribLocation: vi.fn(() => 0),
289
+ getUniformLocation: vi.fn(() => ({})),
290
+ createBuffer: vi.fn(() => ({})),
291
+ bindBuffer: vi.fn(),
292
+ bufferData: vi.fn(),
293
+ enableVertexAttribArray: vi.fn(),
294
+ vertexAttribPointer: vi.fn(),
295
+ createTexture: vi.fn(() => ({})),
296
+ bindTexture: vi.fn(),
297
+ texParameteri: vi.fn(),
298
+ texParameterf: vi.fn(),
299
+ texImage2D: vi.fn(),
300
+ texImage3D: vi.fn(),
301
+ texStorage2D: vi.fn(),
302
+ texStorage3D: vi.fn(),
303
+ texSubImage2D: vi.fn(),
304
+ texSubImage3D: vi.fn(),
305
+ activeTexture: vi.fn(),
306
+ generateMipmap: vi.fn(),
307
+ uniform1i: vi.fn(),
308
+ uniform1f: vi.fn(),
309
+ uniform2f: vi.fn(),
310
+ uniform2fv: vi.fn(),
311
+ uniform3f: vi.fn(),
312
+ uniform3fv: vi.fn(),
313
+ uniform4f: vi.fn(),
314
+ uniform4fv: vi.fn(),
315
+ uniformMatrix4fv: vi.fn(),
316
+ viewport: vi.fn(),
317
+ scissor: vi.fn(),
318
+ clear: vi.fn(),
319
+ clearColor: vi.fn(),
320
+ clearDepth: vi.fn(),
321
+ enable: vi.fn(),
322
+ disable: vi.fn(),
323
+ blendFunc: vi.fn(),
324
+ blendFuncSeparate: vi.fn(),
325
+ depthFunc: vi.fn(),
326
+ depthMask: vi.fn(),
327
+ cullFace: vi.fn(),
328
+ drawArrays: vi.fn(),
329
+ drawElements: vi.fn(),
330
+ // CRITICAL: Return stub extensions for NiiVue float texture support
331
+ getExtension: vi.fn((name: string) => mockExtensions[name] || null),
332
+ getParameter: vi.fn((pname: number) => {
333
+ // Return reasonable defaults for common parameter queries
334
+ if (pname === 0x0d33) return 16384 // MAX_TEXTURE_SIZE
335
+ if (pname === 0x8073) return 2048 // MAX_3D_TEXTURE_SIZE
336
+ if (pname === 0x851c) return 16 // MAX_TEXTURE_IMAGE_UNITS
337
+ return 0
338
+ }),
339
+ getSupportedExtensions: vi.fn(() => Object.keys(mockExtensions)),
340
+ pixelStorei: vi.fn(),
341
+ readPixels: vi.fn(),
342
+ createFramebuffer: vi.fn(() => ({})),
343
+ bindFramebuffer: vi.fn(),
344
+ framebufferTexture2D: vi.fn(),
345
+ checkFramebufferStatus: vi.fn(() => 36053), // FRAMEBUFFER_COMPLETE
346
+ createRenderbuffer: vi.fn(() => ({})),
347
+ bindRenderbuffer: vi.fn(),
348
+ renderbufferStorage: vi.fn(),
349
+ framebufferRenderbuffer: vi.fn(),
350
+ deleteTexture: vi.fn(),
351
+ deleteBuffer: vi.fn(),
352
+ deleteProgram: vi.fn(),
353
+ deleteShader: vi.fn(),
354
+ deleteFramebuffer: vi.fn(),
355
+ deleteRenderbuffer: vi.fn(),
356
+ createVertexArray: vi.fn(() => ({})),
357
+ bindVertexArray: vi.fn(),
358
+ deleteVertexArray: vi.fn(),
359
+ flush: vi.fn(),
360
+ finish: vi.fn(),
361
+ isContextLost: vi.fn(() => false),
362
+ }
363
+
364
+ HTMLCanvasElement.prototype.getContext = function (
365
+ contextType: string
366
+ ): RenderingContext | null {
367
+ if (contextType === 'webgl2' || contextType === 'webgl') {
368
+ return {
369
+ ...mockWebGL2Context,
370
+ canvas: this,
371
+ } as unknown as WebGL2RenderingContext
372
+ }
373
+ return null
374
+ }
375
+ ```
376
+
377
+ ### Test First
378
+
379
+ Create `src/components/__tests__/NiiVueViewer.test.tsx`:
380
+
381
+ ```typescript
382
+ import { describe, it, expect, vi } from 'vitest'
383
+ import { render, screen, waitFor } from '@testing-library/react'
384
+ import { NiiVueViewer } from '../NiiVueViewer'
385
+
386
+ // Mock the NiiVue module since it requires actual WebGL
387
+ vi.mock('@niivue/niivue', () => ({
388
+ Niivue: vi.fn().mockImplementation(() => ({
389
+ attachToCanvas: vi.fn(),
390
+ loadVolumes: vi.fn().mockResolvedValue(undefined),
391
+ setSliceType: vi.fn(),
392
+ cleanup: vi.fn(), // NiiVue's cleanup() releases event listeners/observers
393
+ gl: {
394
+ getExtension: vi.fn(() => ({ loseContext: vi.fn() })),
395
+ },
396
+ opts: {},
397
+ })),
398
+ }))
399
+
400
+ describe('NiiVueViewer', () => {
401
+ const defaultProps = {
402
+ backgroundUrl: 'http://localhost:7860/files/dwi.nii.gz',
403
+ }
404
+
405
+ it('renders canvas element', () => {
406
+ render(<NiiVueViewer {...defaultProps} />)
407
+
408
+ expect(document.querySelector('canvas')).toBeInTheDocument()
409
+ })
410
+
411
+ it('renders container with correct styling', () => {
412
+ render(<NiiVueViewer {...defaultProps} />)
413
+
414
+ const container = document.querySelector('canvas')?.parentElement
415
+ expect(container).toHaveClass('bg-gray-900')
416
+ })
417
+
418
+ it('renders help text for controls', () => {
419
+ render(<NiiVueViewer {...defaultProps} />)
420
+
421
+ expect(screen.getByText(/scroll/i)).toBeInTheDocument()
422
+ expect(screen.getByText(/drag/i)).toBeInTheDocument()
423
+ })
424
+
425
+ it('initializes NiiVue with background volume', async () => {
426
+ const { Niivue } = await import('@niivue/niivue')
427
+
428
+ render(<NiiVueViewer {...defaultProps} />)
429
+
430
+ expect(Niivue).toHaveBeenCalled()
431
+ })
432
+
433
+ it('loads overlay when provided', async () => {
434
+ const { Niivue } = await import('@niivue/niivue')
435
+ const mockInstance = {
436
+ attachToCanvas: vi.fn(),
437
+ loadVolumes: vi.fn().mockResolvedValue(undefined),
438
+ cleanup: vi.fn(),
439
+ gl: { getExtension: vi.fn(() => ({ loseContext: vi.fn() })) },
440
+ opts: {},
441
+ }
442
+ ;(Niivue as unknown as ReturnType<typeof vi.fn>).mockImplementation(
443
+ () => mockInstance
444
+ )
445
+
446
+ render(
447
+ <NiiVueViewer
448
+ {...defaultProps}
449
+ overlayUrl="http://localhost:7860/files/prediction.nii.gz"
450
+ />
451
+ )
452
+
453
+ // Wait for useEffect to run
454
+ await waitFor(() => {
455
+ expect(mockInstance.loadVolumes).toHaveBeenCalled()
456
+ })
457
+
458
+ const loadVolumesCall = mockInstance.loadVolumes.mock.calls[0][0]
459
+ expect(loadVolumesCall).toHaveLength(2)
460
+ expect(loadVolumesCall[1].url).toContain('prediction.nii.gz')
461
+ })
462
+
463
+ it('sets canvas dimensions', () => {
464
+ render(<NiiVueViewer {...defaultProps} />)
465
+
466
+ const canvas = document.querySelector('canvas')
467
+ expect(canvas).toHaveClass('w-full', 'h-[500px]')
468
+ })
469
+ })
470
+ ```
471
+
472
+ ### Implementation
473
+
474
+ Create `src/components/NiiVueViewer.tsx`:
475
+
476
+ ```typescript
477
+ import { useRef, useEffect } from 'react'
478
+ import { Niivue } from '@niivue/niivue'
479
+
480
+ interface NiiVueViewerProps {
481
+ backgroundUrl: string
482
+ overlayUrl?: string
483
+ }
484
+
485
+ export function NiiVueViewer({ backgroundUrl, overlayUrl }: NiiVueViewerProps) {
486
+ const canvasRef = useRef<HTMLCanvasElement>(null)
487
+ const nvRef = useRef<Niivue | null>(null)
488
+
489
+ useEffect(() => {
490
+ if (!canvasRef.current) return
491
+
492
+ // Only instantiate NiiVue once; reuse for volume reloads
493
+ let nv = nvRef.current
494
+ if (!nv) {
495
+ nv = new Niivue({
496
+ backColor: [0.05, 0.05, 0.05, 1],
497
+ show3Dcrosshair: true,
498
+ crosshairColor: [1, 0, 0, 0.5],
499
+ })
500
+ nv.attachToCanvas(canvasRef.current)
501
+ nvRef.current = nv
502
+ }
503
+
504
+ // Build volumes array - always reload when URLs change
505
+ const volumes: Array<{ url: string; colormap: string; opacity: number }> = [
506
+ { url: backgroundUrl, colormap: 'gray', opacity: 1 },
507
+ ]
508
+
509
+ if (overlayUrl) {
510
+ volumes.push({
511
+ url: overlayUrl,
512
+ colormap: 'red',
513
+ opacity: 0.5,
514
+ })
515
+ }
516
+
517
+ // Load volumes (async but we don't await - just fire off)
518
+ void nv.loadVolumes(volumes)
519
+
520
+ // Cleanup on unmount - CRITICAL: Release WebGL context
521
+ // Browsers limit WebGL contexts (~16 in Chrome). Without cleanup,
522
+ // navigating between results will exhaust contexts and break the viewer.
523
+ return () => {
524
+ if (nvRef.current) {
525
+ // Capture gl BEFORE cleanup (cleanup may null internal state)
526
+ const gl = nvRef.current.gl
527
+ try {
528
+ // NiiVue's cleanup() releases event listeners and observers
529
+ // See: https://niivue.github.io/niivue/devdocs/classes/Niivue.html#cleanup
530
+ nvRef.current.cleanup()
531
+ // Force WebGL context loss to free GPU memory immediately
532
+ if (gl) {
533
+ const ext = gl.getExtension('WEBGL_lose_context')
534
+ ext?.loseContext()
535
+ }
536
+ } catch {
537
+ // Ignore cleanup errors
538
+ }
539
+ nvRef.current = null
540
+ }
541
+ }
542
+ }, [backgroundUrl, overlayUrl])
543
+
544
+ return (
545
+ <div className="bg-gray-900 rounded-lg p-2">
546
+ <canvas ref={canvasRef} className="w-full h-[500px] rounded" />
547
+ <div className="flex gap-4 mt-2 text-xs text-gray-400">
548
+ <span>Scroll: Navigate slices</span>
549
+ <span>Drag: Adjust contrast</span>
550
+ <span>Right-click: Pan</span>
551
+ </div>
552
+ </div>
553
+ )
554
+ }
555
+ ```
556
+
557
+ ### Verify
558
+
559
+ ```bash
560
+ npm test -- NiiVueViewer
561
+ # Expected: 6 tests passing
562
+ ```
563
+
564
+ ---
565
+
566
+ ## Update Component Index
567
+
568
+ Update `src/components/index.ts`:
569
+
570
+ ```typescript
571
+ export { Layout } from './Layout'
572
+ export { MetricsPanel } from './MetricsPanel'
573
+ export { CaseSelector } from './CaseSelector'
574
+ export { NiiVueViewer } from './NiiVueViewer'
575
+ ```
576
+
577
+ ---
578
+
579
+ ## Visual Verification
580
+
581
+ Update `src/App.tsx` to preview all components:
582
+
583
+ ```typescript
584
+ import { useState } from 'react'
585
+ import { Layout } from './components/Layout'
586
+ import { CaseSelector } from './components/CaseSelector'
587
+ import { MetricsPanel } from './components/MetricsPanel'
588
+ import { NiiVueViewer } from './components/NiiVueViewer'
589
+
590
+ const mockMetrics = {
591
+ caseId: 'sub-stroke0001',
592
+ diceScore: 0.847,
593
+ volumeMl: 15.32,
594
+ elapsedSeconds: 12.5,
595
+ }
596
+
597
+ // Demo NIfTI file from NiiVue examples
598
+ const DEMO_NIFTI = 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz'
599
+
600
+ function App() {
601
+ const [selectedCase, setSelectedCase] = useState<string | null>(null)
602
+
603
+ return (
604
+ <Layout>
605
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
606
+ <div className="space-y-4">
607
+ <CaseSelector
608
+ selectedCase={selectedCase}
609
+ onSelectCase={setSelectedCase}
610
+ />
611
+ <MetricsPanel metrics={mockMetrics} />
612
+ </div>
613
+ <div className="lg:col-span-2">
614
+ <NiiVueViewer backgroundUrl={DEMO_NIFTI} />
615
+ </div>
616
+ </div>
617
+ </Layout>
618
+ )
619
+ }
620
+
621
+ export default App
622
+ ```
623
+
624
+ ```bash
625
+ npm run dev
626
+ # Open http://localhost:5173
627
+ # Verify:
628
+ # - CaseSelector loads and shows cases
629
+ # - NiiVue viewer renders 3D brain
630
+ # - MetricsPanel displays correctly
631
+ ```
632
+
633
+ ---
634
+
635
+ ## Verification Checklist
636
+
637
+ ```bash
638
+ npm test
639
+ # Expected: ~35+ tests passing
640
+ ```
641
+
642
+ - [ ] CaseSelector shows loading state
643
+ - [ ] CaseSelector fetches and displays cases
644
+ - [ ] CaseSelector calls onSelectCase on selection
645
+ - [ ] CaseSelector shows error state on API failure
646
+ - [ ] NiiVueViewer renders canvas
647
+ - [ ] NiiVueViewer initializes NiiVue instance
648
+ - [ ] NiiVueViewer loads overlay when provided
649
+ - [ ] Visual: All components render correctly in browser
650
+
651
+ ---
652
+
653
+ ## File Structure After This Phase
654
+
655
+ ```text
656
+ frontend/src/
657
+ ├── components/
658
+ │ ├── __tests__/
659
+ │ │ ├── Layout.test.tsx
660
+ │ │ ├── MetricsPanel.test.tsx
661
+ │ │ ├── CaseSelector.test.tsx
662
+ │ │ └── NiiVueViewer.test.tsx
663
+ │ ├── Layout.tsx
664
+ │ ├── MetricsPanel.tsx
665
+ │ ├── CaseSelector.tsx
666
+ │ ├── NiiVueViewer.tsx
667
+ │ └── index.ts
668
+ ├── api/
669
+ ├── hooks/
670
+ ├── types/
671
+ ├── test/
672
+ │ └── setup.ts (updated with WebGL mocks)
673
+ ├── mocks/
674
+ └── App.tsx (updated)
675
+ ```
676
+
677
+ ---
678
+
679
+ ## Next Phase
680
+
681
+ Once verification passes, proceed to **Spec 37.4: App Integration**
docs/specs/frontend/37-4-app-integration.md ADDED
@@ -0,0 +1,461 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Spec 37.4: App Integration
2
+
3
+ **Status**: READY FOR IMPLEMENTATION
4
+ **Phase**: 4 of 5
5
+ **Depends On**: Spec 37.3 (Interactive Components)
6
+ **Goal**: Wire all components together into a working application
7
+
8
+ ---
9
+
10
+ ## Deliverables
11
+
12
+ By the end of this phase, you will have:
13
+
14
+ 1. Complete `App.tsx` with full user flow
15
+ 2. Integration tests for the complete workflow
16
+ 3. Error handling for all states
17
+ 4. Working end-to-end flow (with mocked API)
18
+
19
+ ---
20
+
21
+ ## Step 1: App Integration Tests
22
+
23
+ Create `src/App.test.tsx`:
24
+
25
+ ```typescript
26
+ import { describe, it, expect, vi } from 'vitest'
27
+ import { render, screen, waitFor } from '@testing-library/react'
28
+ import userEvent from '@testing-library/user-event'
29
+ import { server } from './mocks/server'
30
+ import { errorHandlers } from './mocks/handlers'
31
+ import App from './App'
32
+
33
+ describe('App Integration', () => {
34
+ describe('Initial Render', () => {
35
+ it('renders main heading', () => {
36
+ render(<App />)
37
+
38
+ expect(
39
+ screen.getByRole('heading', { name: /stroke lesion segmentation/i })
40
+ ).toBeInTheDocument()
41
+ })
42
+
43
+ it('renders case selector', async () => {
44
+ render(<App />)
45
+
46
+ await waitFor(() => {
47
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
48
+ })
49
+ })
50
+
51
+ it('renders run button', () => {
52
+ render(<App />)
53
+
54
+ expect(
55
+ screen.getByRole('button', { name: /run segmentation/i })
56
+ ).toBeInTheDocument()
57
+ })
58
+
59
+ it('shows placeholder viewer message', () => {
60
+ render(<App />)
61
+
62
+ expect(
63
+ screen.getByText(/select a case and run segmentation/i)
64
+ ).toBeInTheDocument()
65
+ })
66
+ })
67
+
68
+ describe('Run Button State', () => {
69
+ it('disables run button when no case selected', async () => {
70
+ render(<App />)
71
+
72
+ await waitFor(() => {
73
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
74
+ })
75
+
76
+ expect(
77
+ screen.getByRole('button', { name: /run segmentation/i })
78
+ ).toBeDisabled()
79
+ })
80
+
81
+ it('enables run button when case selected', async () => {
82
+ const user = userEvent.setup()
83
+ render(<App />)
84
+
85
+ await waitFor(() => {
86
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
87
+ })
88
+
89
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
90
+
91
+ expect(
92
+ screen.getByRole('button', { name: /run segmentation/i })
93
+ ).toBeEnabled()
94
+ })
95
+ })
96
+
97
+ describe('Segmentation Flow', () => {
98
+ it('shows processing state when running', async () => {
99
+ const user = userEvent.setup()
100
+ render(<App />)
101
+
102
+ await waitFor(() => {
103
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
104
+ })
105
+
106
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
107
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
108
+
109
+ expect(screen.getByText(/processing/i)).toBeInTheDocument()
110
+ })
111
+
112
+ it('displays metrics after successful segmentation', async () => {
113
+ const user = userEvent.setup()
114
+ render(<App />)
115
+
116
+ await waitFor(() => {
117
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
118
+ })
119
+
120
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
121
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
122
+
123
+ await waitFor(() => {
124
+ expect(screen.getByText('0.847')).toBeInTheDocument()
125
+ })
126
+
127
+ expect(screen.getByText('15.32 mL')).toBeInTheDocument()
128
+ expect(screen.getByText(/12\.5s/)).toBeInTheDocument()
129
+ })
130
+
131
+ it('displays viewer after successful segmentation', async () => {
132
+ const user = userEvent.setup()
133
+ render(<App />)
134
+
135
+ await waitFor(() => {
136
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
137
+ })
138
+
139
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
140
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
141
+
142
+ await waitFor(() => {
143
+ expect(document.querySelector('canvas')).toBeInTheDocument()
144
+ })
145
+ })
146
+
147
+ it('hides placeholder after successful segmentation', async () => {
148
+ const user = userEvent.setup()
149
+ render(<App />)
150
+
151
+ await waitFor(() => {
152
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
153
+ })
154
+
155
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
156
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
157
+
158
+ await waitFor(() => {
159
+ expect(screen.getByText('0.847')).toBeInTheDocument()
160
+ })
161
+
162
+ expect(
163
+ screen.queryByText(/select a case and run segmentation/i)
164
+ ).not.toBeInTheDocument()
165
+ })
166
+ })
167
+
168
+ describe('Error Handling', () => {
169
+ it('shows error when segmentation fails', async () => {
170
+ server.use(errorHandlers.segmentServerError)
171
+ const user = userEvent.setup()
172
+
173
+ render(<App />)
174
+
175
+ await waitFor(() => {
176
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
177
+ })
178
+
179
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
180
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
181
+
182
+ await waitFor(() => {
183
+ expect(screen.getByRole('alert')).toBeInTheDocument()
184
+ })
185
+
186
+ expect(screen.getByText(/segmentation failed/i)).toBeInTheDocument()
187
+ })
188
+
189
+ it('allows retry after error', async () => {
190
+ server.use(errorHandlers.segmentServerError)
191
+ const user = userEvent.setup()
192
+
193
+ render(<App />)
194
+
195
+ await waitFor(() => {
196
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
197
+ })
198
+
199
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
200
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
201
+
202
+ await waitFor(() => {
203
+ expect(screen.getByRole('alert')).toBeInTheDocument()
204
+ })
205
+
206
+ // Reset to success handler
207
+ server.resetHandlers()
208
+
209
+ // Retry
210
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
211
+
212
+ await waitFor(() => {
213
+ expect(screen.getByText('0.847')).toBeInTheDocument()
214
+ })
215
+
216
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
217
+ })
218
+ })
219
+
220
+ describe('Multiple Runs', () => {
221
+ it('allows running segmentation on different cases', async () => {
222
+ const user = userEvent.setup()
223
+ render(<App />)
224
+
225
+ await waitFor(() => {
226
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
227
+ })
228
+
229
+ // First case
230
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
231
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
232
+
233
+ await waitFor(() => {
234
+ expect(screen.getByText('sub-stroke0001')).toBeInTheDocument()
235
+ })
236
+
237
+ // Second case
238
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0002')
239
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
240
+
241
+ await waitFor(() => {
242
+ expect(screen.getByText('sub-stroke0002')).toBeInTheDocument()
243
+ })
244
+ })
245
+ })
246
+ })
247
+ ```
248
+
249
+ ---
250
+
251
+ ## Step 2: App Implementation
252
+
253
+ Replace `src/App.tsx`:
254
+
255
+ ```typescript
256
+ import { useState } from 'react'
257
+ import { Layout } from './components/Layout'
258
+ import { CaseSelector } from './components/CaseSelector'
259
+ import { NiiVueViewer } from './components/NiiVueViewer'
260
+ import { MetricsPanel } from './components/MetricsPanel'
261
+ import { useSegmentation } from './hooks/useSegmentation'
262
+
263
+ export default function App() {
264
+ const [selectedCase, setSelectedCase] = useState<string | null>(null)
265
+ const { result, isLoading, error, runSegmentation } = useSegmentation()
266
+
267
+ const handleRunSegmentation = async () => {
268
+ if (selectedCase) {
269
+ await runSegmentation(selectedCase)
270
+ }
271
+ }
272
+
273
+ return (
274
+ <Layout>
275
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
276
+ {/* Left Panel: Controls */}
277
+ <div className="space-y-4">
278
+ <CaseSelector
279
+ selectedCase={selectedCase}
280
+ onSelectCase={setSelectedCase}
281
+ />
282
+
283
+ <button
284
+ onClick={handleRunSegmentation}
285
+ disabled={!selectedCase || isLoading}
286
+ className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600
287
+ disabled:cursor-not-allowed text-white font-medium
288
+ py-3 px-4 rounded-lg transition-colors"
289
+ >
290
+ {isLoading ? 'Processing...' : 'Run Segmentation'}
291
+ </button>
292
+
293
+ {error && (
294
+ <div role="alert" className="bg-red-900/50 text-red-300 p-3 rounded-lg">
295
+ {error}
296
+ </div>
297
+ )}
298
+
299
+ {result && <MetricsPanel metrics={result.metrics} />}
300
+ </div>
301
+
302
+ {/* Right Panel: Viewer */}
303
+ <div className="lg:col-span-2">
304
+ {result ? (
305
+ <NiiVueViewer
306
+ backgroundUrl={result.dwiUrl}
307
+ overlayUrl={result.predictionUrl}
308
+ />
309
+ ) : (
310
+ <div className="bg-gray-900 rounded-lg h-[500px] flex items-center justify-center">
311
+ <p className="text-gray-400">
312
+ Select a case and run segmentation to view results
313
+ </p>
314
+ </div>
315
+ )}
316
+ </div>
317
+ </div>
318
+ </Layout>
319
+ )
320
+ }
321
+ ```
322
+
323
+ ---
324
+
325
+ ## Step 3: Run Tests
326
+
327
+ ```bash
328
+ npm test
329
+ # Expected: ~45+ tests passing
330
+ ```
331
+
332
+ ---
333
+
334
+ ## Step 4: Visual Verification
335
+
336
+ ```bash
337
+ npm run dev
338
+ # Open http://localhost:5173
339
+ ```
340
+
341
+ **Manual Test Checklist:**
342
+
343
+ 1. [ ] Page loads with header
344
+ 2. [ ] Case selector shows "Loading cases..."
345
+ 3. [ ] Case selector populates with 3 cases
346
+ 4. [ ] Run button is disabled initially
347
+ 5. [ ] Selecting a case enables run button
348
+ 6. [ ] Clicking run shows "Processing..."
349
+ 7. [ ] After completion, metrics panel appears
350
+ 8. [ ] After completion, viewer shows (with demo image)
351
+ 9. [ ] Selecting different case and running updates results
352
+
353
+ ---
354
+
355
+ ## Step 5: Test Custom Render Utility (Optional Enhancement)
356
+
357
+ Create `src/test/test-utils.tsx`:
358
+
359
+ ```typescript
360
+ import type { ReactElement, ReactNode } from 'react'
361
+ import { render, RenderOptions } from '@testing-library/react'
362
+ import userEvent from '@testing-library/user-event'
363
+
364
+ // Wrapper for any providers (Router, Theme, etc.)
365
+ function AllTheProviders({ children }: { children: ReactNode }) {
366
+ return <>{children}</>
367
+ }
368
+
369
+ function customRender(
370
+ ui: ReactElement,
371
+ options?: Omit<RenderOptions, 'wrapper'>
372
+ ) {
373
+ return {
374
+ user: userEvent.setup(),
375
+ ...render(ui, { wrapper: AllTheProviders, ...options }),
376
+ }
377
+ }
378
+
379
+ // Re-export everything
380
+ export * from '@testing-library/react'
381
+ export { customRender as render }
382
+ ```
383
+
384
+ Update tests to use custom render (optional):
385
+
386
+ ```typescript
387
+ import { render, screen, waitFor } from '../test/test-utils'
388
+ // Now `render` returns `{ user, ...result }`
389
+ ```
390
+
391
+ ---
392
+
393
+ ## Verification Checklist
394
+
395
+ ```bash
396
+ # All tests pass
397
+ npm test
398
+ # Expected: ~45+ tests passing
399
+
400
+ # Build succeeds
401
+ npm run build
402
+ # Expected: dist/ folder created
403
+
404
+ # No TypeScript errors
405
+ npx tsc --noEmit
406
+ # Expected: No errors
407
+ ```
408
+
409
+ - [ ] All integration tests pass
410
+ - [ ] Full flow works in browser
411
+ - [ ] Error states display correctly
412
+ - [ ] Loading states display correctly
413
+ - [ ] Results update on new runs
414
+
415
+ ---
416
+
417
+ ## File Structure After This Phase
418
+
419
+ ```
420
+ frontend/src/
421
+ ├── components/
422
+ │ ├── __tests__/
423
+ │ │ ├── Layout.test.tsx
424
+ │ │ ├── MetricsPanel.test.tsx
425
+ │ │ ├── CaseSelector.test.tsx
426
+ │ │ └── NiiVueViewer.test.tsx
427
+ │ ├── Layout.tsx
428
+ │ ├── MetricsPanel.tsx
429
+ │ ├── CaseSelector.tsx
430
+ │ ├── NiiVueViewer.tsx
431
+ │ └── index.ts
432
+ ├── api/
433
+ │ ├── __tests__/
434
+ │ │ └── client.test.ts
435
+ │ ├── client.ts
436
+ │ └── index.ts
437
+ ├── hooks/
438
+ │ ├── __tests__/
439
+ │ │ └── useSegmentation.test.tsx
440
+ │ ├── useSegmentation.ts
441
+ │ └── index.ts
442
+ ├── types/
443
+ │ └── index.ts
444
+ ├── test/
445
+ │ ├── setup.ts
446
+ │ ├── fixtures.ts
447
+ │ └── test-utils.tsx
448
+ ├── mocks/
449
+ │ ├── handlers.ts
450
+ │ └── server.ts
451
+ ├── App.tsx
452
+ ├── App.test.tsx
453
+ ├── main.tsx
454
+ └── index.css
455
+ ```
456
+
457
+ ---
458
+
459
+ ## Next Phase
460
+
461
+ Once verification passes, proceed to **Spec 37.5: E2E Tests & CI/CD**
docs/specs/frontend/37-5-e2e-and-ci.md ADDED
@@ -0,0 +1,607 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Spec 37.5: E2E Tests & CI/CD
2
+
3
+ **Status**: READY FOR IMPLEMENTATION
4
+ **Phase**: 5 of 5
5
+ **Depends On**: Spec 37.4 (App Integration)
6
+ **Goal**: End-to-end tests with Playwright and GitHub Actions CI pipeline
7
+
8
+ ---
9
+
10
+ ## Deliverables
11
+
12
+ By the end of this phase, you will have:
13
+
14
+ 1. Playwright E2E tests for critical user flows
15
+ 2. Page Object Models for maintainable tests
16
+ 3. GitHub Actions workflow for CI
17
+ 4. Coverage reporting integration
18
+
19
+ ---
20
+
21
+ ## Step 1: Install Playwright
22
+
23
+ ```bash
24
+ cd frontend
25
+ npm install -D @playwright/test@1.49.1
26
+ npx playwright install
27
+ ```
28
+
29
+ ---
30
+
31
+ ## Step 2: Playwright Configuration
32
+
33
+ Create `playwright.config.ts`:
34
+
35
+ ```typescript
36
+ import { defineConfig, devices } from '@playwright/test'
37
+
38
+ export default defineConfig({
39
+ testDir: './e2e',
40
+ fullyParallel: true,
41
+ forbidOnly: !!process.env.CI,
42
+ retries: process.env.CI ? 2 : 0,
43
+ workers: process.env.CI ? 1 : undefined,
44
+ reporter: [
45
+ ['html', { open: 'never' }],
46
+ ['list'],
47
+ ...(process.env.CI ? [['github' as const]] : []),
48
+ ],
49
+ use: {
50
+ baseURL: 'http://localhost:5173',
51
+ trace: 'on-first-retry',
52
+ screenshot: 'only-on-failure',
53
+ },
54
+ projects: [
55
+ {
56
+ name: 'chromium',
57
+ use: { ...devices['Desktop Chrome'] },
58
+ },
59
+ // Uncomment for cross-browser testing:
60
+ // {
61
+ // name: 'firefox',
62
+ // use: { ...devices['Desktop Firefox'] },
63
+ // },
64
+ // {
65
+ // name: 'webkit',
66
+ // use: { ...devices['Desktop Safari'] },
67
+ // },
68
+ ],
69
+ webServer: {
70
+ command: 'npm run dev',
71
+ url: 'http://localhost:5173',
72
+ reuseExistingServer: !process.env.CI,
73
+ timeout: 120000,
74
+ },
75
+ })
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Step 3: Update package.json Scripts
81
+
82
+ Add to `package.json` scripts:
83
+
84
+ ```json
85
+ {
86
+ "scripts": {
87
+ "test:e2e": "playwright test",
88
+ "test:e2e:ui": "playwright test --ui",
89
+ "test:e2e:headed": "playwright test --headed",
90
+ "test:e2e:debug": "playwright test --debug"
91
+ }
92
+ }
93
+ ```
94
+
95
+ ---
96
+
97
+ ## Step 4: Page Object Model
98
+
99
+ Create `e2e/pages/HomePage.ts`:
100
+
101
+ ```typescript
102
+ import { type Page, type Locator, expect } from '@playwright/test'
103
+
104
+ export class HomePage {
105
+ readonly page: Page
106
+ readonly heading: Locator
107
+ readonly caseSelector: Locator
108
+ readonly runButton: Locator
109
+ readonly processingText: Locator
110
+ readonly metricsPanel: Locator
111
+ readonly diceScore: Locator
112
+ readonly viewer: Locator
113
+ readonly placeholderText: Locator
114
+ readonly errorAlert: Locator
115
+
116
+ constructor(page: Page) {
117
+ this.page = page
118
+ this.heading = page.getByRole('heading', {
119
+ name: /stroke lesion segmentation/i,
120
+ })
121
+ this.caseSelector = page.getByRole('combobox')
122
+ this.runButton = page.getByRole('button', { name: /run segmentation/i })
123
+ this.processingText = page.getByText(/processing/i)
124
+ this.metricsPanel = page.getByRole('heading', { name: /results/i })
125
+ this.diceScore = page.getByText(/0\.\d{3}/)
126
+ this.viewer = page.locator('canvas')
127
+ this.placeholderText = page.getByText(/select a case and run segmentation/i)
128
+ this.errorAlert = page.getByRole('alert')
129
+ }
130
+
131
+ async goto() {
132
+ await this.page.goto('/')
133
+ await expect(this.heading).toBeVisible()
134
+ }
135
+
136
+ async waitForCasesToLoad() {
137
+ await expect(this.caseSelector).toBeEnabled({ timeout: 10000 })
138
+ }
139
+
140
+ async selectCase(caseId: string) {
141
+ await this.caseSelector.selectOption(caseId)
142
+ }
143
+
144
+ async runSegmentation() {
145
+ await this.runButton.click()
146
+ }
147
+
148
+ async waitForResults() {
149
+ await expect(this.metricsPanel).toBeVisible({ timeout: 30000 })
150
+ }
151
+
152
+ async expectViewerVisible() {
153
+ await expect(this.viewer).toBeVisible()
154
+ }
155
+
156
+ async expectPlaceholderVisible() {
157
+ await expect(this.placeholderText).toBeVisible()
158
+ }
159
+
160
+ async expectErrorVisible() {
161
+ await expect(this.errorAlert).toBeVisible()
162
+ }
163
+ }
164
+ ```
165
+
166
+ ---
167
+
168
+ ## Step 5: Global API Mocking Fixture
169
+
170
+ **CRITICAL:** E2E tests run against `npm run dev` which has no backend.
171
+ Without API mocking, tests will hang or fail on API calls.
172
+
173
+ Create `e2e/fixtures.ts` - Global mock for all API calls:
174
+
175
+ ```typescript
176
+ import { test as base, expect } from '@playwright/test'
177
+
178
+ // API response mocks matching MSW handlers
179
+ const MOCK_CASES = ['sub-stroke0001', 'sub-stroke0002', 'sub-stroke0003']
180
+ const MOCK_SEGMENT_RESPONSE = {
181
+ caseId: 'sub-stroke0001',
182
+ diceScore: 0.847,
183
+ volumeMl: 15.32,
184
+ elapsedSeconds: 12.5,
185
+ // Use a real public NIfTI for visual testing (NiiVue demo image)
186
+ dwiUrl: 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz',
187
+ predictionUrl: 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz',
188
+ }
189
+
190
+ // Extend base test to include API mocking
191
+ export const test = base.extend({
192
+ // Auto-mock API routes for every test
193
+ page: async ({ page }, use) => {
194
+ // Mock GET /api/cases
195
+ await page.route('**/api/cases', (route) => {
196
+ route.fulfill({
197
+ status: 200,
198
+ contentType: 'application/json',
199
+ body: JSON.stringify({ cases: MOCK_CASES }),
200
+ })
201
+ })
202
+
203
+ // Mock POST /api/segment - return different caseId based on request
204
+ await page.route('**/api/segment', async (route) => {
205
+ const request = route.request()
206
+ const body = JSON.parse(request.postData() || '{}')
207
+
208
+ // Simulate network delay
209
+ await new Promise((r) => setTimeout(r, 200))
210
+
211
+ route.fulfill({
212
+ status: 200,
213
+ contentType: 'application/json',
214
+ body: JSON.stringify({
215
+ ...MOCK_SEGMENT_RESPONSE,
216
+ caseId: body.case_id || 'sub-stroke0001',
217
+ }),
218
+ })
219
+ })
220
+
221
+ await use(page)
222
+ },
223
+ })
224
+
225
+ export { expect }
226
+ ```
227
+
228
+ ---
229
+
230
+ ## Step 6: E2E Tests
231
+
232
+ Create `e2e/home.spec.ts`:
233
+
234
+ ```typescript
235
+ import { test, expect } from './fixtures'
236
+ import { HomePage } from './pages/HomePage'
237
+
238
+ test.describe('Home Page', () => {
239
+ test('displays main heading', async ({ page }) => {
240
+ const homePage = new HomePage(page)
241
+ await homePage.goto()
242
+
243
+ await expect(homePage.heading).toBeVisible()
244
+ })
245
+
246
+ test('loads case selector with options', async ({ page }) => {
247
+ const homePage = new HomePage(page)
248
+ await homePage.goto()
249
+ await homePage.waitForCasesToLoad()
250
+
251
+ // Verify selector has options
252
+ const options = await homePage.caseSelector.locator('option').count()
253
+ expect(options).toBeGreaterThan(1) // placeholder + cases
254
+ })
255
+
256
+ test('shows placeholder viewer initially', async ({ page }) => {
257
+ const homePage = new HomePage(page)
258
+ await homePage.goto()
259
+
260
+ await homePage.expectPlaceholderVisible()
261
+ })
262
+
263
+ test('run button disabled without case selected', async ({ page }) => {
264
+ const homePage = new HomePage(page)
265
+ await homePage.goto()
266
+ await homePage.waitForCasesToLoad()
267
+
268
+ await expect(homePage.runButton).toBeDisabled()
269
+ })
270
+ })
271
+ ```
272
+
273
+ Create `e2e/segmentation-flow.spec.ts`:
274
+
275
+ ```typescript
276
+ import { test, expect } from './fixtures'
277
+ import { HomePage } from './pages/HomePage'
278
+
279
+ test.describe('Segmentation Flow', () => {
280
+ test('complete segmentation workflow', async ({ page }) => {
281
+ const homePage = new HomePage(page)
282
+ await homePage.goto()
283
+ await homePage.waitForCasesToLoad()
284
+
285
+ // Select a case
286
+ await homePage.selectCase('sub-stroke0001')
287
+ await expect(homePage.runButton).toBeEnabled()
288
+
289
+ // Run segmentation
290
+ await homePage.runSegmentation()
291
+
292
+ // Verify processing state
293
+ await expect(homePage.processingText).toBeVisible()
294
+
295
+ // Wait for results
296
+ await homePage.waitForResults()
297
+
298
+ // Verify results displayed
299
+ await expect(homePage.diceScore).toBeVisible()
300
+ await homePage.expectViewerVisible()
301
+
302
+ // Placeholder should be gone
303
+ await expect(homePage.placeholderText).not.toBeVisible()
304
+ })
305
+
306
+ test('can run multiple segmentations', async ({ page }) => {
307
+ const homePage = new HomePage(page)
308
+ await homePage.goto()
309
+ await homePage.waitForCasesToLoad()
310
+
311
+ // First run
312
+ await homePage.selectCase('sub-stroke0001')
313
+ await homePage.runSegmentation()
314
+ await homePage.waitForResults()
315
+
316
+ // Second run with different case
317
+ await homePage.selectCase('sub-stroke0002')
318
+ await homePage.runSegmentation()
319
+ await homePage.waitForResults()
320
+
321
+ // Results should still be visible
322
+ await expect(homePage.metricsPanel).toBeVisible()
323
+ })
324
+ })
325
+ ```
326
+
327
+ Create `e2e/error-handling.spec.ts`:
328
+
329
+ ```typescript
330
+ import { test as base, expect } from '@playwright/test'
331
+ import { HomePage } from './pages/HomePage'
332
+
333
+ // Error tests need to override the default mocks, so use base test
334
+ const test = base
335
+
336
+ test.describe('Error Handling', () => {
337
+ test('shows error when API fails', async ({ page }) => {
338
+ // Mock cases API (needed for page to load)
339
+ await page.route('**/api/cases', (route) => {
340
+ route.fulfill({
341
+ status: 200,
342
+ contentType: 'application/json',
343
+ body: JSON.stringify({ cases: ['sub-stroke0001'] }),
344
+ })
345
+ })
346
+
347
+ // Mock segment API to return error
348
+ await page.route('**/api/segment', (route) => {
349
+ route.fulfill({
350
+ status: 500,
351
+ contentType: 'application/json',
352
+ body: JSON.stringify({ detail: 'Segmentation failed' }),
353
+ })
354
+ })
355
+
356
+ const homePage = new HomePage(page)
357
+ await homePage.goto()
358
+ await homePage.waitForCasesToLoad()
359
+
360
+ await homePage.selectCase('sub-stroke0001')
361
+ await homePage.runSegmentation()
362
+
363
+ await homePage.expectErrorVisible()
364
+ await expect(homePage.errorAlert).toContainText(/failed/i)
365
+ })
366
+
367
+ test('shows error when cases fail to load', async ({ page }) => {
368
+ // Mock cases API to return error
369
+ await page.route('**/api/cases', (route) => {
370
+ route.fulfill({
371
+ status: 500,
372
+ contentType: 'application/json',
373
+ body: JSON.stringify({ detail: 'Server error' }),
374
+ })
375
+ })
376
+
377
+ const homePage = new HomePage(page)
378
+ await homePage.goto()
379
+
380
+ // Case selector should show error state
381
+ await expect(page.getByText(/failed to load/i)).toBeVisible()
382
+ })
383
+ })
384
+ ```
385
+
386
+ ---
387
+
388
+ ## Step 7: GitHub Actions CI Workflow
389
+
390
+ Create `.github/workflows/frontend-ci.yml`:
391
+
392
+ ```yaml
393
+ name: Frontend CI
394
+
395
+ on:
396
+ push:
397
+ branches: [main]
398
+ paths:
399
+ - 'frontend/**'
400
+ - '.github/workflows/frontend-ci.yml'
401
+ pull_request:
402
+ paths:
403
+ - 'frontend/**'
404
+
405
+ defaults:
406
+ run:
407
+ working-directory: frontend
408
+
409
+ jobs:
410
+ typecheck:
411
+ runs-on: ubuntu-latest
412
+ steps:
413
+ - uses: actions/checkout@v4
414
+
415
+ - uses: actions/setup-node@v4
416
+ with:
417
+ node-version: '20'
418
+ cache: 'npm'
419
+ cache-dependency-path: frontend/package-lock.json
420
+
421
+ - run: npm ci
422
+ - run: npx tsc --noEmit
423
+
424
+ test:
425
+ runs-on: ubuntu-latest
426
+ steps:
427
+ - uses: actions/checkout@v4
428
+
429
+ - uses: actions/setup-node@v4
430
+ with:
431
+ node-version: '20'
432
+ cache: 'npm'
433
+ cache-dependency-path: frontend/package-lock.json
434
+
435
+ - run: npm ci
436
+ - run: npm run test:coverage
437
+
438
+ - uses: codecov/codecov-action@v4
439
+ with:
440
+ files: frontend/coverage/coverage-final.json
441
+ flags: frontend
442
+ fail_ci_if_error: false
443
+
444
+ e2e:
445
+ runs-on: ubuntu-latest
446
+ steps:
447
+ - uses: actions/checkout@v4
448
+
449
+ - uses: actions/setup-node@v4
450
+ with:
451
+ node-version: '20'
452
+ cache: 'npm'
453
+ cache-dependency-path: frontend/package-lock.json
454
+
455
+ - run: npm ci
456
+ - run: npx playwright install --with-deps chromium
457
+
458
+ - run: npm run test:e2e
459
+
460
+ - uses: actions/upload-artifact@v4
461
+ if: failure()
462
+ with:
463
+ name: playwright-report
464
+ path: frontend/playwright-report/
465
+ retention-days: 7
466
+
467
+ build:
468
+ runs-on: ubuntu-latest
469
+ needs: [typecheck, test]
470
+ steps:
471
+ - uses: actions/checkout@v4
472
+
473
+ - uses: actions/setup-node@v4
474
+ with:
475
+ node-version: '20'
476
+ cache: 'npm'
477
+ cache-dependency-path: frontend/package-lock.json
478
+
479
+ - run: npm ci
480
+ - run: npm run build
481
+
482
+ - uses: actions/upload-artifact@v4
483
+ with:
484
+ name: frontend-dist
485
+ path: frontend/dist/
486
+ retention-days: 7
487
+ ```
488
+
489
+ ---
490
+
491
+ ## Step 8: Add Coverage Thresholds
492
+
493
+ Update `vite.config.ts` coverage section:
494
+
495
+ ```typescript
496
+ coverage: {
497
+ provider: 'v8',
498
+ reporter: ['text', 'json', 'html'],
499
+ include: ['src/**/*.{ts,tsx}'],
500
+ exclude: [
501
+ 'src/**/*.test.{ts,tsx}',
502
+ 'src/test/**',
503
+ 'src/mocks/**',
504
+ 'src/main.tsx',
505
+ 'src/vite-env.d.ts',
506
+ ],
507
+ thresholds: {
508
+ statements: 80,
509
+ branches: 75,
510
+ functions: 80,
511
+ lines: 80,
512
+ },
513
+ },
514
+ ```
515
+
516
+ ---
517
+
518
+ ## Step 9: Run All Tests
519
+
520
+ ```bash
521
+ # Unit & Integration tests
522
+ npm test
523
+ # Expected: ~45+ tests passing
524
+
525
+ # E2E tests
526
+ npm run test:e2e
527
+ # Expected: 7 tests passing
528
+
529
+ # Coverage report
530
+ npm run test:coverage
531
+ # Expected: >80% coverage
532
+ ```
533
+
534
+ ---
535
+
536
+ ## Verification Checklist
537
+
538
+ - [ ] `npm test` - All unit/integration tests pass
539
+ - [ ] `npm run test:coverage` - Coverage meets thresholds
540
+ - [ ] `npm run test:e2e` - All E2E tests pass
541
+ - [ ] `npm run build` - Production build succeeds
542
+ - [ ] CI workflow runs successfully (push to branch)
543
+
544
+ ---
545
+
546
+ ## File Structure After This Phase
547
+
548
+ ```
549
+ frontend/
550
+ ├── e2e/
551
+ │ ├── pages/
552
+ │ │ └── HomePage.ts
553
+ │ ├── fixtures.ts # <-- NEW: Global API mocking
554
+ │ ├── home.spec.ts
555
+ │ ├── segmentation-flow.spec.ts
556
+ │ └── error-handling.spec.ts
557
+ ├── src/
558
+ │ ├── components/
559
+ │ ├── api/
560
+ │ ├── hooks/
561
+ │ ├── types/
562
+ │ ├── test/
563
+ │ ├── mocks/
564
+ │ ├── App.tsx
565
+ │ ├── App.test.tsx
566
+ │ └── ...
567
+ ├── .github/
568
+ │ └── workflows/
569
+ │ └── frontend-ci.yml
570
+ ├── playwright.config.ts
571
+ ├── vite.config.ts
572
+ ├── package.json
573
+ └── ...
574
+ ```
575
+
576
+ ---
577
+
578
+ ## Summary: Complete Testing Stack
579
+
580
+ | Layer | Tool | Test Count | Purpose |
581
+ |-------|------|------------|---------|
582
+ | Unit | Vitest + RTL | ~35 | Component isolation |
583
+ | Integration | Vitest + MSW | ~15 | API + hooks |
584
+ | E2E | Playwright | ~7 | Full user flows |
585
+ | **Total** | | **~57** | |
586
+
587
+ ---
588
+
589
+ ## Next Steps After All Phases Complete
590
+
591
+ 1. **Deploy Frontend**: Push to HuggingFace Static Space
592
+ 2. **Connect to Backend**: Update `VITE_API_URL` to real backend
593
+ 3. **Test Against Real API**: Run E2E tests with real backend
594
+ 4. **Monitor**: Set up error tracking (optional)
595
+
596
+ ---
597
+
598
+ ## Congratulations!
599
+
600
+ You now have a fully tested React frontend with:
601
+
602
+ - Type-safe TypeScript code
603
+ - Comprehensive unit tests
604
+ - API mocking with MSW
605
+ - End-to-end browser tests
606
+ - Automated CI/CD pipeline
607
+ - 80%+ code coverage
frontend/.env.example ADDED
@@ -0,0 +1 @@
 
 
1
+ VITE_API_URL=http://localhost:7860
frontend/.gitignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
25
+
26
+ # Test output
27
+ coverage
28
+ playwright-report
29
+ test-results
frontend/README.md ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + TypeScript + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
+
14
+ ## Expanding the ESLint configuration
15
+
16
+ If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
17
+
18
+ ```js
19
+ export default defineConfig([
20
+ globalIgnores(['dist']),
21
+ {
22
+ files: ['**/*.{ts,tsx}'],
23
+ extends: [
24
+ // Other configs...
25
+
26
+ // Remove tseslint.configs.recommended and replace with this
27
+ tseslint.configs.recommendedTypeChecked,
28
+ // Alternatively, use this for stricter rules
29
+ tseslint.configs.strictTypeChecked,
30
+ // Optionally, add this for stylistic rules
31
+ tseslint.configs.stylisticTypeChecked,
32
+
33
+ // Other configs...
34
+ ],
35
+ languageOptions: {
36
+ parserOptions: {
37
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
38
+ tsconfigRootDir: import.meta.dirname,
39
+ },
40
+ // other options...
41
+ },
42
+ },
43
+ ])
44
+ ```
45
+
46
+ You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
47
+
48
+ ```js
49
+ // eslint.config.js
50
+ import reactX from 'eslint-plugin-react-x'
51
+ import reactDom from 'eslint-plugin-react-dom'
52
+
53
+ export default defineConfig([
54
+ globalIgnores(['dist']),
55
+ {
56
+ files: ['**/*.{ts,tsx}'],
57
+ extends: [
58
+ // Other configs...
59
+ // Enable lint rules for React
60
+ reactX.configs['recommended-typescript'],
61
+ // Enable lint rules for React DOM
62
+ reactDom.configs.recommended,
63
+ ],
64
+ languageOptions: {
65
+ parserOptions: {
66
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
67
+ tsconfigRootDir: import.meta.dirname,
68
+ },
69
+ // other options...
70
+ },
71
+ },
72
+ ])
73
+ ```
frontend/e2e/error-handling.spec.ts ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test as base, expect } from '@playwright/test'
2
+ import { HomePage } from './pages/HomePage'
3
+
4
+ // Error tests need to override the default mocks, so use base test
5
+ const test = base
6
+
7
+ test.describe('Error Handling', () => {
8
+ test('shows error when API fails', async ({ page }) => {
9
+ // Mock cases API (needed for page to load)
10
+ await page.route('**/api/cases', (route) => {
11
+ route.fulfill({
12
+ status: 200,
13
+ contentType: 'application/json',
14
+ body: JSON.stringify({ cases: ['sub-stroke0001'] }),
15
+ })
16
+ })
17
+
18
+ // Mock segment API to return error
19
+ await page.route('**/api/segment', (route) => {
20
+ route.fulfill({
21
+ status: 500,
22
+ contentType: 'application/json',
23
+ body: JSON.stringify({ detail: 'Segmentation failed' }),
24
+ })
25
+ })
26
+
27
+ const homePage = new HomePage(page)
28
+ await homePage.goto()
29
+ await homePage.waitForCasesToLoad()
30
+
31
+ await homePage.selectCase('sub-stroke0001')
32
+ await homePage.runSegmentation()
33
+
34
+ await homePage.expectErrorVisible()
35
+ await expect(homePage.errorAlert).toContainText(/failed/i)
36
+ })
37
+
38
+ test('shows error when cases fail to load', async ({ page }) => {
39
+ // Mock cases API to return error
40
+ await page.route('**/api/cases', (route) => {
41
+ route.fulfill({
42
+ status: 500,
43
+ contentType: 'application/json',
44
+ body: JSON.stringify({ detail: 'Server error' }),
45
+ })
46
+ })
47
+
48
+ const homePage = new HomePage(page)
49
+ await homePage.goto()
50
+
51
+ // Case selector should show error state
52
+ await expect(page.getByText(/failed to load/i)).toBeVisible()
53
+ })
54
+ })
frontend/e2e/fixtures.ts ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test as base, expect } from '@playwright/test'
2
+
3
+ // API response mocks matching MSW handlers
4
+ const MOCK_CASES = ['sub-stroke0001', 'sub-stroke0002', 'sub-stroke0003']
5
+ const MOCK_SEGMENT_RESPONSE = {
6
+ caseId: 'sub-stroke0001',
7
+ diceScore: 0.847,
8
+ volumeMl: 15.32,
9
+ elapsedSeconds: 12.5,
10
+ // Use real public NIfTI for visual testing (NiiVue demo image)
11
+ dwiUrl: 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz',
12
+ predictionUrl: 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz',
13
+ }
14
+
15
+ // Extend base test to include API mocking
16
+ export const test = base.extend({
17
+ // Auto-mock API routes for every test
18
+ page: async ({ page }, use) => {
19
+ // Mock GET /api/cases
20
+ await page.route('**/api/cases', (route) => {
21
+ route.fulfill({
22
+ status: 200,
23
+ contentType: 'application/json',
24
+ body: JSON.stringify({ cases: MOCK_CASES }),
25
+ })
26
+ })
27
+
28
+ // Mock POST /api/segment - return different caseId based on request
29
+ await page.route('**/api/segment', async (route) => {
30
+ const request = route.request()
31
+ const body = JSON.parse(request.postData() || '{}') as { case_id?: string }
32
+
33
+ // Simulate network delay
34
+ await new Promise((r) => setTimeout(r, 200))
35
+
36
+ route.fulfill({
37
+ status: 200,
38
+ contentType: 'application/json',
39
+ body: JSON.stringify({
40
+ ...MOCK_SEGMENT_RESPONSE,
41
+ caseId: body.case_id || 'sub-stroke0001',
42
+ }),
43
+ })
44
+ })
45
+
46
+ await use(page)
47
+ },
48
+ })
49
+
50
+ export { expect }
frontend/e2e/home.spec.ts ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from './fixtures'
2
+ import { HomePage } from './pages/HomePage'
3
+
4
+ test.describe('Home Page', () => {
5
+ test('displays main heading', async ({ page }) => {
6
+ const homePage = new HomePage(page)
7
+ await homePage.goto()
8
+
9
+ await expect(homePage.heading).toBeVisible()
10
+ })
11
+
12
+ test('loads case selector with options', async ({ page }) => {
13
+ const homePage = new HomePage(page)
14
+ await homePage.goto()
15
+ await homePage.waitForCasesToLoad()
16
+
17
+ // Verify selector has options
18
+ const options = await homePage.caseSelector.locator('option').count()
19
+ expect(options).toBeGreaterThan(1) // placeholder + cases
20
+ })
21
+
22
+ test('shows placeholder viewer initially', async ({ page }) => {
23
+ const homePage = new HomePage(page)
24
+ await homePage.goto()
25
+
26
+ await homePage.expectPlaceholderVisible()
27
+ })
28
+
29
+ test('run button disabled without case selected', async ({ page }) => {
30
+ const homePage = new HomePage(page)
31
+ await homePage.goto()
32
+ await homePage.waitForCasesToLoad()
33
+
34
+ await expect(homePage.runButton).toBeDisabled()
35
+ })
36
+ })
frontend/e2e/pages/HomePage.ts ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type Page, type Locator, expect } from '@playwright/test'
2
+
3
+ export class HomePage {
4
+ readonly page: Page
5
+ readonly heading: Locator
6
+ readonly caseSelector: Locator
7
+ readonly runButton: Locator
8
+ readonly processingText: Locator
9
+ readonly metricsPanel: Locator
10
+ readonly diceScore: Locator
11
+ readonly viewer: Locator
12
+ readonly placeholderText: Locator
13
+ readonly errorAlert: Locator
14
+
15
+ constructor(page: Page) {
16
+ this.page = page
17
+ this.heading = page.getByRole('heading', {
18
+ name: /stroke lesion segmentation/i,
19
+ })
20
+ this.caseSelector = page.getByRole('combobox')
21
+ this.runButton = page.getByRole('button', { name: /run segmentation/i })
22
+ this.processingText = page.getByText(/processing/i)
23
+ this.metricsPanel = page.getByRole('heading', { name: /results/i })
24
+ this.diceScore = page.getByText(/0\.\d{3}/)
25
+ this.viewer = page.locator('canvas')
26
+ this.placeholderText = page.getByText(/select a case and run segmentation/i)
27
+ this.errorAlert = page.getByRole('alert')
28
+ }
29
+
30
+ async goto() {
31
+ await this.page.goto('/')
32
+ await expect(this.heading).toBeVisible()
33
+ }
34
+
35
+ async waitForCasesToLoad() {
36
+ await expect(this.caseSelector).toBeEnabled({ timeout: 10000 })
37
+ }
38
+
39
+ async selectCase(caseId: string) {
40
+ await this.caseSelector.selectOption(caseId)
41
+ }
42
+
43
+ async runSegmentation() {
44
+ await this.runButton.click()
45
+ }
46
+
47
+ async waitForResults() {
48
+ await expect(this.metricsPanel).toBeVisible({ timeout: 30000 })
49
+ }
50
+
51
+ async expectViewerVisible() {
52
+ await expect(this.viewer).toBeVisible()
53
+ }
54
+
55
+ async expectPlaceholderVisible() {
56
+ await expect(this.placeholderText).toBeVisible()
57
+ }
58
+
59
+ async expectErrorVisible() {
60
+ await expect(this.errorAlert).toBeVisible()
61
+ }
62
+ }
frontend/e2e/segmentation-flow.spec.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from './fixtures'
2
+ import { HomePage } from './pages/HomePage'
3
+
4
+ test.describe('Segmentation Flow', () => {
5
+ test('complete segmentation workflow', async ({ page }) => {
6
+ const homePage = new HomePage(page)
7
+ await homePage.goto()
8
+ await homePage.waitForCasesToLoad()
9
+
10
+ // Select a case
11
+ await homePage.selectCase('sub-stroke0001')
12
+ await expect(homePage.runButton).toBeEnabled()
13
+
14
+ // Run segmentation
15
+ await homePage.runSegmentation()
16
+
17
+ // Verify processing state
18
+ await expect(homePage.processingText).toBeVisible()
19
+
20
+ // Wait for results
21
+ await homePage.waitForResults()
22
+
23
+ // Verify results displayed
24
+ await expect(homePage.diceScore).toBeVisible()
25
+ await homePage.expectViewerVisible()
26
+
27
+ // Placeholder should be gone
28
+ await expect(homePage.placeholderText).not.toBeVisible()
29
+ })
30
+
31
+ test('can run multiple segmentations', async ({ page }) => {
32
+ const homePage = new HomePage(page)
33
+ await homePage.goto()
34
+ await homePage.waitForCasesToLoad()
35
+
36
+ // First run
37
+ await homePage.selectCase('sub-stroke0001')
38
+ await homePage.runSegmentation()
39
+ await homePage.waitForResults()
40
+
41
+ // Second run with different case
42
+ await homePage.selectCase('sub-stroke0002')
43
+ await homePage.runSegmentation()
44
+ await homePage.waitForResults()
45
+
46
+ // Results should still be visible
47
+ await expect(homePage.metricsPanel).toBeVisible()
48
+ })
49
+ })
frontend/eslint.config.js ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import tseslint from 'typescript-eslint'
6
+ import { defineConfig, globalIgnores } from 'eslint/config'
7
+
8
+ export default defineConfig([
9
+ globalIgnores(['dist', 'coverage']),
10
+ // Main source files - full React rules
11
+ {
12
+ files: ['src/**/*.{ts,tsx}'],
13
+ extends: [
14
+ js.configs.recommended,
15
+ tseslint.configs.recommended,
16
+ reactHooks.configs.flat.recommended,
17
+ reactRefresh.configs.vite,
18
+ ],
19
+ languageOptions: {
20
+ ecmaVersion: 2020,
21
+ globals: globals.browser,
22
+ },
23
+ },
24
+ // E2E tests - Playwright, not React (disable react-hooks rules)
25
+ {
26
+ files: ['e2e/**/*.{ts,tsx}'],
27
+ extends: [js.configs.recommended, tseslint.configs.recommended],
28
+ languageOptions: {
29
+ ecmaVersion: 2020,
30
+ globals: { ...globals.browser, ...globals.node },
31
+ },
32
+ },
33
+ ])
frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Stroke Lesion Segmentation</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "preview": "vite preview",
10
+ "lint": "eslint .",
11
+ "lint:fix": "eslint . --fix",
12
+ "test": "vitest",
13
+ "test:ui": "vitest --ui",
14
+ "test:coverage": "vitest run --coverage",
15
+ "test:e2e": "playwright test",
16
+ "test:e2e:ui": "playwright test --ui",
17
+ "test:e2e:headed": "playwright test --headed",
18
+ "test:e2e:debug": "playwright test --debug"
19
+ },
20
+ "dependencies": {
21
+ "@niivue/niivue": "^0.65.0",
22
+ "react": "^19.2.0",
23
+ "react-dom": "^19.2.0"
24
+ },
25
+ "devDependencies": {
26
+ "@eslint/js": "^9.39.1",
27
+ "@playwright/test": "^1.57.0",
28
+ "@tailwindcss/vite": "^4.1.17",
29
+ "@testing-library/jest-dom": "^6.6.3",
30
+ "@testing-library/react": "^16.3.0",
31
+ "@testing-library/user-event": "^14.5.2",
32
+ "@types/node": "^24.10.1",
33
+ "@types/react": "^19.2.5",
34
+ "@types/react-dom": "^19.2.3",
35
+ "@vitejs/plugin-react": "^5.1.1",
36
+ "@vitest/coverage-v8": "^4.0.15",
37
+ "@vitest/ui": "^4.0.15",
38
+ "eslint": "^9.39.1",
39
+ "eslint-plugin-react-hooks": "^7.0.1",
40
+ "eslint-plugin-react-refresh": "^0.4.24",
41
+ "globals": "^16.5.0",
42
+ "jsdom": "^25.0.1",
43
+ "msw": "^2.7.0",
44
+ "tailwindcss": "^4.1.17",
45
+ "typescript": "~5.9.3",
46
+ "typescript-eslint": "^8.46.4",
47
+ "vite": "^7.2.4",
48
+ "vitest": "^4.0.15"
49
+ }
50
+ }
frontend/playwright.config.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, devices } from '@playwright/test'
2
+
3
+ export default defineConfig({
4
+ testDir: './e2e',
5
+ fullyParallel: true,
6
+ forbidOnly: !!process.env.CI,
7
+ retries: process.env.CI ? 2 : 0,
8
+ workers: process.env.CI ? 1 : undefined,
9
+ reporter: [
10
+ ['html', { open: 'never' }],
11
+ ['list'],
12
+ ...(process.env.CI ? [['github' as const]] : []),
13
+ ],
14
+ use: {
15
+ baseURL: 'http://localhost:5173',
16
+ trace: 'on-first-retry',
17
+ screenshot: 'only-on-failure',
18
+ },
19
+ projects: [
20
+ {
21
+ name: 'chromium',
22
+ use: { ...devices['Desktop Chrome'] },
23
+ },
24
+ ],
25
+ webServer: {
26
+ command: 'npm run dev',
27
+ url: 'http://localhost:5173',
28
+ reuseExistingServer: !process.env.CI,
29
+ timeout: 120000,
30
+ },
31
+ })
frontend/public/vite.svg ADDED
frontend/src/App.test.tsx ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, waitFor } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import { server } from './mocks/server'
5
+ import { errorHandlers } from './mocks/handlers'
6
+ import App from './App'
7
+
8
+ // Mock NiiVue to avoid WebGL in tests
9
+ vi.mock('@niivue/niivue', () => ({
10
+ Niivue: class MockNiivue {
11
+ attachToCanvas = vi.fn()
12
+ loadVolumes = vi.fn().mockResolvedValue(undefined)
13
+ cleanup = vi.fn()
14
+ gl = {
15
+ getExtension: vi.fn(() => ({ loseContext: vi.fn() })),
16
+ }
17
+ opts = {}
18
+ },
19
+ }))
20
+
21
+ describe('App Integration', () => {
22
+ describe('Initial Render', () => {
23
+ it('renders main heading', () => {
24
+ render(<App />)
25
+
26
+ expect(
27
+ screen.getByRole('heading', { name: /stroke lesion segmentation/i })
28
+ ).toBeInTheDocument()
29
+ })
30
+
31
+ it('renders case selector', async () => {
32
+ render(<App />)
33
+
34
+ await waitFor(() => {
35
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
36
+ })
37
+ })
38
+
39
+ it('renders run button', () => {
40
+ render(<App />)
41
+
42
+ expect(
43
+ screen.getByRole('button', { name: /run segmentation/i })
44
+ ).toBeInTheDocument()
45
+ })
46
+
47
+ it('shows placeholder viewer message', () => {
48
+ render(<App />)
49
+
50
+ expect(
51
+ screen.getByText(/select a case and run segmentation/i)
52
+ ).toBeInTheDocument()
53
+ })
54
+ })
55
+
56
+ describe('Run Button State', () => {
57
+ it('disables run button when no case selected', async () => {
58
+ render(<App />)
59
+
60
+ await waitFor(() => {
61
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
62
+ })
63
+
64
+ expect(
65
+ screen.getByRole('button', { name: /run segmentation/i })
66
+ ).toBeDisabled()
67
+ })
68
+
69
+ it('enables run button when case selected', async () => {
70
+ const user = userEvent.setup()
71
+ render(<App />)
72
+
73
+ await waitFor(() => {
74
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
75
+ })
76
+
77
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
78
+
79
+ expect(
80
+ screen.getByRole('button', { name: /run segmentation/i })
81
+ ).toBeEnabled()
82
+ })
83
+ })
84
+
85
+ describe('Segmentation Flow', () => {
86
+ it('shows processing state when running', async () => {
87
+ const user = userEvent.setup()
88
+ render(<App />)
89
+
90
+ await waitFor(() => {
91
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
92
+ })
93
+
94
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
95
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
96
+
97
+ expect(screen.getByText(/processing/i)).toBeInTheDocument()
98
+ })
99
+
100
+ it('displays metrics after successful segmentation', async () => {
101
+ const user = userEvent.setup()
102
+ render(<App />)
103
+
104
+ await waitFor(() => {
105
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
106
+ })
107
+
108
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
109
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
110
+
111
+ await waitFor(() => {
112
+ expect(screen.getByText('0.847')).toBeInTheDocument()
113
+ })
114
+
115
+ expect(screen.getByText('15.32 mL')).toBeInTheDocument()
116
+ expect(screen.getByText(/12\.5s/)).toBeInTheDocument()
117
+ })
118
+
119
+ it('displays viewer after successful segmentation', async () => {
120
+ const user = userEvent.setup()
121
+ render(<App />)
122
+
123
+ await waitFor(() => {
124
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
125
+ })
126
+
127
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
128
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
129
+
130
+ await waitFor(() => {
131
+ expect(document.querySelector('canvas')).toBeInTheDocument()
132
+ })
133
+ })
134
+
135
+ it('hides placeholder after successful segmentation', async () => {
136
+ const user = userEvent.setup()
137
+ render(<App />)
138
+
139
+ await waitFor(() => {
140
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
141
+ })
142
+
143
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
144
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
145
+
146
+ await waitFor(() => {
147
+ expect(screen.getByText('0.847')).toBeInTheDocument()
148
+ })
149
+
150
+ expect(
151
+ screen.queryByText(/select a case and run segmentation/i)
152
+ ).not.toBeInTheDocument()
153
+ })
154
+ })
155
+
156
+ describe('Error Handling', () => {
157
+ it('shows error when segmentation fails', async () => {
158
+ server.use(errorHandlers.segmentServerError)
159
+ const user = userEvent.setup()
160
+
161
+ render(<App />)
162
+
163
+ await waitFor(() => {
164
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
165
+ })
166
+
167
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
168
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
169
+
170
+ await waitFor(() => {
171
+ expect(screen.getByRole('alert')).toBeInTheDocument()
172
+ })
173
+
174
+ expect(screen.getByText(/segmentation failed/i)).toBeInTheDocument()
175
+ })
176
+
177
+ it('allows retry after error', async () => {
178
+ server.use(errorHandlers.segmentServerError)
179
+ const user = userEvent.setup()
180
+
181
+ render(<App />)
182
+
183
+ await waitFor(() => {
184
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
185
+ })
186
+
187
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
188
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
189
+
190
+ await waitFor(() => {
191
+ expect(screen.getByRole('alert')).toBeInTheDocument()
192
+ })
193
+
194
+ // Reset to success handler
195
+ server.resetHandlers()
196
+
197
+ // Retry
198
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
199
+
200
+ await waitFor(() => {
201
+ expect(screen.getByText('0.847')).toBeInTheDocument()
202
+ })
203
+
204
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
205
+ })
206
+ })
207
+
208
+ describe('Multiple Runs', () => {
209
+ it('allows running segmentation on different cases', async () => {
210
+ const user = userEvent.setup()
211
+ render(<App />)
212
+
213
+ await waitFor(() => {
214
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
215
+ })
216
+
217
+ // First case
218
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001')
219
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
220
+
221
+ // Wait for first segmentation to complete
222
+ await waitFor(() => {
223
+ expect(screen.getByText('sub-stroke0001')).toBeInTheDocument()
224
+ })
225
+
226
+ // Wait for button to be ready again (not "Processing...")
227
+ await waitFor(() => {
228
+ expect(screen.getByRole('button', { name: /run segmentation/i })).toBeInTheDocument()
229
+ })
230
+
231
+ // Second case
232
+ await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0002')
233
+ await user.click(screen.getByRole('button', { name: /run segmentation/i }))
234
+
235
+ await waitFor(() => {
236
+ expect(screen.getByText('sub-stroke0002')).toBeInTheDocument()
237
+ })
238
+ })
239
+ })
240
+ })
frontend/src/App.tsx ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+ import { Layout } from './components/Layout'
3
+ import { CaseSelector } from './components/CaseSelector'
4
+ import { NiiVueViewer } from './components/NiiVueViewer'
5
+ import { MetricsPanel } from './components/MetricsPanel'
6
+ import { useSegmentation } from './hooks/useSegmentation'
7
+
8
+ export default function App() {
9
+ const [selectedCase, setSelectedCase] = useState<string | null>(null)
10
+ const { result, isLoading, error, runSegmentation } = useSegmentation()
11
+
12
+ const handleRunSegmentation = async () => {
13
+ if (selectedCase) {
14
+ await runSegmentation(selectedCase)
15
+ }
16
+ }
17
+
18
+ return (
19
+ <Layout>
20
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
21
+ {/* Left Panel: Controls */}
22
+ <div className="space-y-4">
23
+ <CaseSelector
24
+ selectedCase={selectedCase}
25
+ onSelectCase={setSelectedCase}
26
+ />
27
+
28
+ <button
29
+ onClick={handleRunSegmentation}
30
+ disabled={!selectedCase || isLoading}
31
+ className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600
32
+ disabled:cursor-not-allowed text-white font-medium
33
+ py-3 px-4 rounded-lg transition-colors"
34
+ >
35
+ {isLoading ? 'Processing...' : 'Run Segmentation'}
36
+ </button>
37
+
38
+ {error && (
39
+ <div role="alert" className="bg-red-900/50 text-red-300 p-3 rounded-lg">
40
+ {error}
41
+ </div>
42
+ )}
43
+
44
+ {result && <MetricsPanel metrics={result.metrics} />}
45
+ </div>
46
+
47
+ {/* Right Panel: Viewer */}
48
+ <div className="lg:col-span-2">
49
+ {result ? (
50
+ <NiiVueViewer
51
+ backgroundUrl={result.dwiUrl}
52
+ overlayUrl={result.predictionUrl}
53
+ />
54
+ ) : (
55
+ <div className="bg-gray-900 rounded-lg h-[500px] flex items-center justify-center">
56
+ <p className="text-gray-400">
57
+ Select a case and run segmentation to view results
58
+ </p>
59
+ </div>
60
+ )}
61
+ </div>
62
+ </div>
63
+ </Layout>
64
+ )
65
+ }
frontend/src/api/__tests__/client.test.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from 'vitest'
2
+ import { server } from '../../mocks/server'
3
+ import { errorHandlers } from '../../mocks/handlers'
4
+ import { apiClient } from '../client'
5
+
6
+ describe('apiClient', () => {
7
+ describe('getCases', () => {
8
+ it('returns list of case IDs', async () => {
9
+ const result = await apiClient.getCases()
10
+
11
+ expect(result.cases).toHaveLength(3)
12
+ expect(result.cases).toContain('sub-stroke0001')
13
+ })
14
+
15
+ it('throws ApiError on server error', async () => {
16
+ server.use(errorHandlers.casesServerError)
17
+
18
+ await expect(apiClient.getCases()).rejects.toThrow(/failed to fetch cases/i)
19
+ })
20
+
21
+ it('throws ApiError on network error', async () => {
22
+ server.use(errorHandlers.casesNetworkError)
23
+
24
+ await expect(apiClient.getCases()).rejects.toThrow()
25
+ })
26
+ })
27
+
28
+ describe('runSegmentation', () => {
29
+ it('returns segmentation result', async () => {
30
+ const result = await apiClient.runSegmentation('sub-stroke0001')
31
+
32
+ expect(result.caseId).toBe('sub-stroke0001')
33
+ expect(result.diceScore).toBe(0.847)
34
+ expect(result.volumeMl).toBe(15.32)
35
+ expect(result.dwiUrl).toContain('dwi.nii.gz')
36
+ expect(result.predictionUrl).toContain('prediction.nii.gz')
37
+ })
38
+
39
+ it('sends fast_mode=false parameter (slower processing)', async () => {
40
+ const result = await apiClient.runSegmentation('sub-stroke0001', false)
41
+
42
+ // Mock returns 45.0s when fast_mode=false
43
+ expect(result.elapsedSeconds).toBe(45.0)
44
+ })
45
+
46
+ it('defaults fast_mode to true (faster processing)', async () => {
47
+ const result = await apiClient.runSegmentation('sub-stroke0001')
48
+
49
+ // Mock returns 12.5s when fast_mode=true (the default)
50
+ expect(result.elapsedSeconds).toBe(12.5)
51
+ })
52
+
53
+ it('throws ApiError on server error', async () => {
54
+ server.use(errorHandlers.segmentServerError)
55
+
56
+ await expect(
57
+ apiClient.runSegmentation('sub-stroke0001')
58
+ ).rejects.toThrow(/segmentation failed/i)
59
+ })
60
+ })
61
+ })