Spaces:
Runtime error
Runtime error
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
- .github/workflows/ci.yml +110 -1
- docs/specs/00-context.md +0 -214
- docs/specs/24-bug-gradio-webgl-analysis.md +0 -156
- docs/specs/28-gradio-custom-component-niivue.md +0 -702
- docs/specs/29-codebase-status-audit.md +0 -276
- docs/specs/30-bug-hf-spaces-build-packages-dir.md +0 -116
- docs/specs/AUDIT_REPORT_2025_12_10.md +0 -52
- docs/specs/NIIVUE-GRADIO-POSTMORTEM.md +408 -0
- docs/specs/archive/01-phase-0-repo-bootstrap.md +0 -438
- docs/specs/archive/02-phase-1-data-access.md +0 -415
- docs/specs/archive/03-phase-2-deepisles-docker.md +0 -884
- docs/specs/archive/04-phase-3-pipeline.md +0 -705
- docs/specs/archive/05-phase-4-gradio-ui.md +0 -778
- docs/specs/archive/06-phase-5-polish.md +0 -667
- docs/specs/archive/07-hf-spaces-deployment.md +0 -969
- docs/specs/archive/08-bug-hf-spaces-dataset-loop.md +0 -239
- docs/specs/archive/09-bug-deepisles-not-installed-hf-spaces.md +0 -92
- docs/specs/archive/10-bug-niivue-viewer-black-screen.md +0 -418
- docs/specs/archive/11-bug-niivue-js-on-load-not-rerunning.md +0 -484
- docs/specs/archive/19-perf-base64-to-file-urls.md +0 -244
- docs/specs/archive/23-slice-comparison-overlay-bug.md +0 -287
- docs/specs/archive/24-bug-hf-spaces-loading-forever.md +0 -254
- docs/specs/archive/AUDIT_JS_LOADING_ISSUES.md +0 -935
- docs/specs/archive/DIAGNOSTIC_HF_LOADING.md +0 -228
- docs/specs/archive/ROOT_CAUSE_ANALYSIS.md +0 -230
- docs/specs/archive/data-discovery.md +0 -66
- docs/specs/frontend/36-frontend-without-gradio-hf-spaces.md +1102 -0
- docs/specs/frontend/37-0-project-setup.md +307 -0
- docs/specs/frontend/37-1-foundation-components.md +331 -0
- docs/specs/frontend/37-2-api-layer.md +523 -0
- docs/specs/frontend/37-3-interactive-components.md +681 -0
- docs/specs/frontend/37-4-app-integration.md +461 -0
- docs/specs/frontend/37-5-e2e-and-ci.md +607 -0
- frontend/.env.example +1 -0
- frontend/.gitignore +29 -0
- frontend/README.md +73 -0
- frontend/e2e/error-handling.spec.ts +54 -0
- frontend/e2e/fixtures.ts +50 -0
- frontend/e2e/home.spec.ts +36 -0
- frontend/e2e/pages/HomePage.ts +62 -0
- frontend/e2e/segmentation-flow.spec.ts +49 -0
- frontend/eslint.config.js +33 -0
- frontend/index.html +13 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +50 -0
- frontend/playwright.config.ts +31 -0
- frontend/public/vite.svg +1 -0
- frontend/src/App.test.tsx +240 -0
- frontend/src/App.tsx +65 -0
- 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:
|
| 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 |
+
})
|