VibecoderMcSwaggins commited on
Commit
8e0cd11
Β·
1 Parent(s): d88c04d

Add initial project structure and documentation for stroke-deepisles-demo

Browse files
docs/specs/00-context.md ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ HuggingFace Hub (ISLES24-MR-Lite)
15
+ ↓
16
+ BIDS/NIfTI loader (datasets fork)
17
+ ↓
18
+ DeepISLES Docker (stroke segmentation)
19
+ ↓
20
+ NiiVue visualization (Gradio Space)
21
+ ```
22
+
23
+ This showcases that:
24
+ 1. Neuroimaging data can be consumed from HF Hub with proper BIDS/NIfTI support
25
+ 2. Clinical-grade models can run via Docker as black boxes
26
+ 3. Results can be visualized interactively in a browser
27
+
28
+ ## why we need tobias's datasets fork
29
+
30
+ As of December 2025, the official `huggingface/datasets` library has **partial** NIfTI support but lacks critical features for neuroimaging workflows.
31
+
32
+ ### what's merged upstream
33
+
34
+ | PR | Author | Status | Description |
35
+ |----|--------|--------|-------------|
36
+ | [#7874](https://github.com/huggingface/datasets/pull/7874) | CloseChoice (Tobias) | Merged Nov 21 | NIfTI visualization support |
37
+ | [#7878](https://github.com/huggingface/datasets/pull/7878) | CloseChoice (Tobias) | Merged Nov 27 | Replace papaya with NiiVue |
38
+
39
+ ### what's NOT merged (and why we need the fork)
40
+
41
+ | PR | Author | Status | Description |
42
+ |----|--------|--------|-------------|
43
+ | [#7886](https://github.com/huggingface/datasets/pull/7886) | The-Obstacle-Is-The-Way | Open | **BIDS dataset loader** - `load_dataset('bids', ...)` |
44
+ | [#7887](https://github.com/huggingface/datasets/pull/7887) | The-Obstacle-Is-The-Way | Open | **NIfTI lazy loading fix** - use `dataobj` not `get_fdata()` |
45
+ | [#7892](https://github.com/huggingface/datasets/pull/7892) | CloseChoice (Tobias) | Open | **NIfTI encoding for lazy upload** - fixes Arrow serialization |
46
+
47
+ The fork branch bundles all these features:
48
+ ```
49
+ https://github.com/CloseChoice/datasets/tree/feat/bids-loader-streaming-upload-fix
50
+ ```
51
+
52
+ We pin to this branch until upstream merges the PRs.
53
+
54
+ ## key components
55
+
56
+ ### 1. data source: ISLES24-MR-Lite
57
+
58
+ - **HF Dataset**: [YongchengYAO/ISLES24-MR-Lite](https://huggingface.co/datasets/YongchengYAO/ISLES24-MR-Lite)
59
+ - **Content**: 149 acute stroke MRI cases with DWI, ADC, and manual infarct masks
60
+ - **Origin**: Subset of ISLES 2024 challenge data
61
+ - **Why suitable**: DeepISLES was trained on ISLES 2022, so ISLES24 is an **external** test set (no data leakage)
62
+
63
+ ### 2. model: DeepISLES
64
+
65
+ - **Paper**: Nature Communications 2025 - "DeepISLES: A clinically validated ischemic stroke segmentation model"
66
+ - **GitHub**: [ezequieldlrosa/DeepIsles](https://github.com/ezequieldlrosa/DeepIsles)
67
+ - **Docker**: `isleschallenge/deepisles`
68
+ - **Inputs**: DWI + ADC (required), FLAIR (optional)
69
+ - **Output**: 3D binary lesion mask (NIfTI)
70
+ - **Mode**: We use `fast=True` (single model) not the full 3-model ensemble
71
+
72
+ ### 3. visualization: NiiVue
73
+
74
+ - **Library**: [niivue/niivue](https://github.com/niivue/niivue)
75
+ - **Type**: WebGL2-based neuroimaging viewer
76
+ - **Formats**: Native NIfTI support, overlays, multiplanar views
77
+ - **Integration**: Via Gradio custom HTML component or iframe
78
+
79
+ ### 4. UI framework: Gradio 5
80
+
81
+ - **Version**: Gradio 5.x (latest as of Dec 2025)
82
+ - **Features**: SSR for fast loading, improved components, WebRTC support
83
+ - **Deployment**: Hugging Face Spaces
84
+
85
+ ## architecture diagram
86
+
87
+ ```
88
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
89
+ β”‚ stroke-deepisles-demo β”‚
90
+ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
91
+ β”‚ β”‚
92
+ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
93
+ β”‚ β”‚ data/ β”‚ β”‚ inference/ β”‚ β”‚ ui/ β”‚ β”‚
94
+ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚
95
+ β”‚ β”‚ - loader │───▢│ - docker │───▢│ - gradio β”‚ β”‚
96
+ β”‚ β”‚ - adapter β”‚ β”‚ - wrapper β”‚ β”‚ - niivue β”‚ β”‚
97
+ β”‚ β”‚ - staging β”‚ β”‚ - pipeline β”‚ β”‚ - viewer β”‚ β”‚
98
+ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
99
+ β”‚ β”‚ β”‚ β”‚ β”‚
100
+ β”‚ β–Ό β–Ό β–Ό β”‚
101
+ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
102
+ β”‚ β”‚ core/ β”‚ β”‚
103
+ β”‚ β”‚ - config (pydantic-settings) β”‚ β”‚
104
+ β”‚ β”‚ - types (dataclasses, TypedDicts) β”‚ β”‚
105
+ β”‚ β”‚ - exceptions β”‚ β”‚
106
+ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
107
+ β”‚ β”‚
108
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
109
+ β”‚ β”‚ β”‚
110
+ β–Ό β–Ό β–Ό
111
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
112
+ β”‚ HF Hub β”‚ β”‚ Docker β”‚ β”‚ Browser β”‚
113
+ β”‚ datasets β”‚ β”‚ Engine β”‚ β”‚ WebGL2 β”‚
114
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
115
+ ```
116
+
117
+ ## design principles
118
+
119
+ 1. **Vertical slices**: Each phase delivers runnable functionality
120
+ 2. **TDD**: Tests written before implementation
121
+ 3. **Type safety**: Full type hints, mypy/pyright strict mode
122
+ 4. **Separation of concerns**: Data, inference, and UI are independent modules
123
+ 5. **Docker as black box**: We don't reimplement DeepISLES, we call it
124
+ 6. **Graceful degradation**: Mock Docker for tests, fallback viewers if NiiVue fails
125
+
126
+ ## reference repositories
127
+
128
+ These are cloned locally (without git linkages) for reference:
129
+
130
+ | Directory | Source | Purpose |
131
+ |-----------|--------|---------|
132
+ | `_reference_repos/datasets-tobias-bids-fork/` | CloseChoice/datasets@feat/bids-loader-streaming-upload-fix | BIDS loader + NIfTI lazy loading |
133
+ | `_reference_repos/arc-aphasia-bids/` | The-Obstacle-Is-The-Way/arc-aphasia-bids | BIDS upload patterns (reference only) |
134
+ | `_reference_repos/DeepIsles/` | ezequieldlrosa/DeepIsles | DeepISLES CLI interface reference |
135
+ | `_reference_repos/bids-neuroimaging-space/` | [TobiasPitters/bids-neuroimaging](https://huggingface.co/spaces/TobiasPitters/bids-neuroimaging) | **Working NiiVue + FastAPI implementation** |
136
+
137
+ ### key reference: tobias's bids-neuroimaging space
138
+
139
+ This is the most important reference for Phase 4 (UI). It demonstrates:
140
+
141
+ 1. **NiiVue working in HF Spaces** - Proof that WebGL2 viewer works in production
142
+ 2. **FastAPI + raw HTML approach** - Clean, no Gradio overhead for viewer
143
+ 3. **Base64 data URLs for NIfTI** - `data:application/octet-stream;base64,{b64}`
144
+ 4. **NiiVue CDN loading** - `https://unpkg.com/@niivue/niivue@0.57.0/dist/index.js`
145
+ 5. **Multiplanar + 3D rendering** - `setSliceType(sliceTypeMultiplanar)` + `setMultiplanarLayout(2)`
146
+
147
+ Key file: `main.py` (~485 lines) - complete working implementation.
148
+
149
+ ## sources
150
+
151
+ - [uv project configuration](https://docs.astral.sh/uv/concepts/projects/config/)
152
+ - [Python packaging guide - pyproject.toml](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/)
153
+ - [Real Python - Managing projects with uv](https://realpython.com/python-uv/)
154
+ - [Gradio 5 announcement](https://huggingface.co/blog/gradio-5)
155
+ - [NiiVue GitHub](https://github.com/niivue/niivue)
156
+ - [Gradio custom HTML components](https://www.gradio.app/guides/custom_HTML_components)
docs/specs/01-phase-0-repo-bootstrap.md ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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/02-phase-1-data-access.md ADDED
@@ -0,0 +1,695 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # phase 1: data access / hf integration
2
+
3
+ ## purpose
4
+
5
+ Implement the data loading layer that consumes ISLES24-MR-Lite from HuggingFace Hub. At the end of this phase, we can load any case by ID and get local paths to DWI, ADC, and ground truth NIfTI files.
6
+
7
+ ## deliverables
8
+
9
+ - [ ] `src/stroke_deepisles_demo/data/loader.py` - HF dataset loading
10
+ - [ ] `src/stroke_deepisles_demo/data/adapter.py` - Case adapter for file access
11
+ - [ ] `src/stroke_deepisles_demo/data/staging.py` - Stage files for DeepISLES
12
+ - [ ] Unit tests with fixtures (no network required)
13
+ - [ ] Integration test (marked, requires network)
14
+
15
+ ## vertical slice outcome
16
+
17
+ After this phase, you can run:
18
+
19
+ ```python
20
+ from stroke_deepisles_demo.data import get_case, list_case_ids
21
+
22
+ # List available cases
23
+ case_ids = list_case_ids()
24
+ print(f"Found {len(case_ids)} cases")
25
+
26
+ # Load a specific case
27
+ case = get_case("sub-001")
28
+ print(f"DWI: {case.dwi}")
29
+ print(f"ADC: {case.adc}")
30
+ print(f"Ground truth: {case.ground_truth}")
31
+ ```
32
+
33
+ ## module structure
34
+
35
+ ```
36
+ src/stroke_deepisles_demo/data/
37
+ β”œβ”€β”€ __init__.py # Public API exports
38
+ β”œβ”€β”€ loader.py # HF Hub dataset loading
39
+ β”œβ”€β”€ adapter.py # Case adapter (index β†’ files)
40
+ └── staging.py # Stage files with DeepISLES naming
41
+ ```
42
+
43
+ ## interfaces and types
44
+
45
+ ### `data/loader.py`
46
+
47
+ ```python
48
+ """Load ISLES24-MR-Lite dataset from HuggingFace Hub."""
49
+
50
+ from __future__ import annotations
51
+
52
+ from pathlib import Path
53
+ from typing import TYPE_CHECKING
54
+
55
+ if TYPE_CHECKING:
56
+ from datasets import Dataset
57
+
58
+
59
+ def load_isles_dataset(
60
+ dataset_id: str = "YongchengYAO/ISLES24-MR-Lite",
61
+ *,
62
+ cache_dir: Path | None = None,
63
+ streaming: bool = False,
64
+ ) -> Dataset:
65
+ """
66
+ Load the ISLES24-MR-Lite dataset from HuggingFace Hub.
67
+
68
+ Args:
69
+ dataset_id: HuggingFace dataset identifier
70
+ cache_dir: Local cache directory (uses HF default if None)
71
+ streaming: If True, use streaming mode (lazy loading)
72
+
73
+ Returns:
74
+ HuggingFace Dataset object with BIDS/NIfTI support
75
+
76
+ Raises:
77
+ DataLoadError: If dataset cannot be loaded
78
+ """
79
+ ...
80
+
81
+
82
+ def get_dataset_info(dataset_id: str = "YongchengYAO/ISLES24-MR-Lite") -> DatasetInfo:
83
+ """
84
+ Get metadata about the dataset without downloading.
85
+
86
+ Returns:
87
+ DatasetInfo with case count, available modalities, etc.
88
+ """
89
+ ...
90
+
91
+
92
+ @dataclass
93
+ class DatasetInfo:
94
+ """Metadata about the loaded dataset."""
95
+
96
+ dataset_id: str
97
+ num_cases: int
98
+ modalities: list[str] # e.g., ["dwi", "adc", "mask"]
99
+ has_ground_truth: bool
100
+ ```
101
+
102
+ ### `data/adapter.py`
103
+
104
+ ```python
105
+ """Adapt HF dataset rows to typed file references."""
106
+
107
+ from __future__ import annotations
108
+
109
+ from pathlib import Path
110
+ from typing import Iterator
111
+
112
+ from stroke_deepisles_demo.core.types import CaseFiles
113
+
114
+
115
+ class CaseAdapter:
116
+ """
117
+ Adapts HuggingFace dataset to provide typed access to case files.
118
+
119
+ This handles the mapping between HF dataset structure and our
120
+ internal CaseFiles type.
121
+ """
122
+
123
+ def __init__(self, dataset: Dataset) -> None:
124
+ """
125
+ Initialize adapter with a loaded dataset.
126
+
127
+ Args:
128
+ dataset: HuggingFace Dataset with NIfTI files
129
+ """
130
+ ...
131
+
132
+ def __len__(self) -> int:
133
+ """Return number of cases in the dataset."""
134
+ ...
135
+
136
+ def __iter__(self) -> Iterator[str]:
137
+ """Iterate over case IDs."""
138
+ ...
139
+
140
+ def list_case_ids(self) -> list[str]:
141
+ """
142
+ List all available case identifiers.
143
+
144
+ Returns:
145
+ List of case IDs (e.g., ["sub-001", "sub-002", ...])
146
+ """
147
+ ...
148
+
149
+ def get_case(self, case_id: str | int) -> CaseFiles:
150
+ """
151
+ Get file paths for a specific case.
152
+
153
+ Args:
154
+ case_id: Either a string ID (e.g., "sub-001") or integer index
155
+
156
+ Returns:
157
+ CaseFiles with paths to DWI, ADC, and optionally ground truth
158
+
159
+ Raises:
160
+ KeyError: If case_id not found
161
+ DataLoadError: If files cannot be accessed
162
+ """
163
+ ...
164
+
165
+ def get_case_by_index(self, index: int) -> tuple[str, CaseFiles]:
166
+ """
167
+ Get case by numerical index.
168
+
169
+ Returns:
170
+ Tuple of (case_id, CaseFiles)
171
+ """
172
+ ...
173
+ ```
174
+
175
+ ### `data/staging.py`
176
+
177
+ ```python
178
+ """Stage NIfTI files with DeepISLES-expected naming."""
179
+
180
+ from __future__ import annotations
181
+
182
+ from pathlib import Path
183
+ from typing import NamedTuple
184
+
185
+ from stroke_deepisles_demo.core.types import CaseFiles
186
+
187
+
188
+ class StagedCase(NamedTuple):
189
+ """Paths to staged files ready for DeepISLES."""
190
+
191
+ input_dir: Path # Directory containing staged files
192
+ dwi_path: Path # Path to dwi.nii.gz
193
+ adc_path: Path # Path to adc.nii.gz
194
+ flair_path: Path | None # Path to flair.nii.gz if available
195
+
196
+
197
+ def stage_case_for_deepisles(
198
+ case_files: CaseFiles,
199
+ output_dir: Path,
200
+ *,
201
+ case_id: str | None = None,
202
+ ) -> StagedCase:
203
+ """
204
+ Stage case files with DeepISLES-expected naming convention.
205
+
206
+ DeepISLES expects files named exactly:
207
+ - dwi.nii.gz
208
+ - adc.nii.gz
209
+ - flair.nii.gz (optional)
210
+
211
+ This function copies/symlinks the source files to a staging directory
212
+ with the correct names.
213
+
214
+ Args:
215
+ case_files: Source file paths from CaseAdapter
216
+ output_dir: Directory to stage files into
217
+ case_id: Optional case ID for logging/subdirectory
218
+
219
+ Returns:
220
+ StagedCase with paths to staged files
221
+
222
+ Raises:
223
+ MissingInputError: If required files (DWI, ADC) are missing
224
+ OSError: If file operations fail
225
+ """
226
+ ...
227
+
228
+
229
+ def create_staging_directory(base_dir: Path | None = None) -> Path:
230
+ """
231
+ Create a temporary staging directory.
232
+
233
+ Args:
234
+ base_dir: Parent directory (uses system temp if None)
235
+
236
+ Returns:
237
+ Path to created staging directory
238
+ """
239
+ ...
240
+ ```
241
+
242
+ ### `data/__init__.py` (public API)
243
+
244
+ ```python
245
+ """Data loading and case management for stroke-deepisles-demo."""
246
+
247
+ from stroke_deepisles_demo.data.adapter import CaseAdapter
248
+ from stroke_deepisles_demo.data.loader import DatasetInfo, get_dataset_info, load_isles_dataset
249
+ from stroke_deepisles_demo.data.staging import StagedCase, stage_case_for_deepisles
250
+
251
+ __all__ = [
252
+ # Loader
253
+ "load_isles_dataset",
254
+ "get_dataset_info",
255
+ "DatasetInfo",
256
+ # Adapter
257
+ "CaseAdapter",
258
+ # Staging
259
+ "stage_case_for_deepisles",
260
+ "StagedCase",
261
+ ]
262
+
263
+
264
+ # Convenience functions (combine loader + adapter)
265
+ def get_case(case_id: str | int) -> CaseFiles:
266
+ """Load a single case by ID or index."""
267
+ ...
268
+
269
+
270
+ def list_case_ids() -> list[str]:
271
+ """List all available case IDs."""
272
+ ...
273
+ ```
274
+
275
+ ## tdd plan
276
+
277
+ ### test file structure
278
+
279
+ ```
280
+ tests/
281
+ β”œβ”€β”€ conftest.py # Shared fixtures
282
+ β”œβ”€β”€ data/
283
+ β”‚ β”œβ”€β”€ __init__.py
284
+ β”‚ β”œβ”€β”€ test_loader.py # Tests for HF loading
285
+ β”‚ β”œβ”€β”€ test_adapter.py # Tests for case adapter
286
+ β”‚ └── test_staging.py # Tests for file staging
287
+ └── fixtures/
288
+ └── nifti/ # Minimal synthetic NIfTI files
289
+ β”œβ”€β”€ dwi.nii.gz
290
+ β”œβ”€β”€ adc.nii.gz
291
+ └── mask.nii.gz
292
+ ```
293
+
294
+ ### tests to write first (TDD order)
295
+
296
+ #### 1. `tests/conftest.py` - Fixtures
297
+
298
+ ```python
299
+ """Shared test fixtures."""
300
+
301
+ from __future__ import annotations
302
+
303
+ import tempfile
304
+ from pathlib import Path
305
+
306
+ import nibabel as nib
307
+ import numpy as np
308
+ import pytest
309
+
310
+
311
+ @pytest.fixture
312
+ def temp_dir() -> Path:
313
+ """Create a temporary directory for test outputs."""
314
+ with tempfile.TemporaryDirectory() as td:
315
+ yield Path(td)
316
+
317
+
318
+ @pytest.fixture
319
+ def synthetic_nifti_3d(temp_dir: Path) -> Path:
320
+ """Create a minimal synthetic 3D NIfTI file."""
321
+ data = np.random.rand(10, 10, 10).astype(np.float32)
322
+ img = nib.Nifti1Image(data, affine=np.eye(4))
323
+ path = temp_dir / "synthetic.nii.gz"
324
+ nib.save(img, path)
325
+ return path
326
+
327
+
328
+ @pytest.fixture
329
+ def synthetic_case_files(temp_dir: Path) -> CaseFiles:
330
+ """Create a complete set of synthetic case files."""
331
+ # Create DWI
332
+ dwi_data = np.random.rand(64, 64, 30).astype(np.float32)
333
+ dwi_img = nib.Nifti1Image(dwi_data, affine=np.eye(4))
334
+ dwi_path = temp_dir / "dwi.nii.gz"
335
+ nib.save(dwi_img, dwi_path)
336
+
337
+ # Create ADC
338
+ adc_data = np.random.rand(64, 64, 30).astype(np.float32) * 2000
339
+ adc_img = nib.Nifti1Image(adc_data, affine=np.eye(4))
340
+ adc_path = temp_dir / "adc.nii.gz"
341
+ nib.save(adc_img, adc_path)
342
+
343
+ # Create mask
344
+ mask_data = (np.random.rand(64, 64, 30) > 0.9).astype(np.uint8)
345
+ mask_img = nib.Nifti1Image(mask_data, affine=np.eye(4))
346
+ mask_path = temp_dir / "mask.nii.gz"
347
+ nib.save(mask_img, mask_path)
348
+
349
+ return CaseFiles(
350
+ dwi=dwi_path,
351
+ adc=adc_path,
352
+ flair=None,
353
+ ground_truth=mask_path,
354
+ )
355
+
356
+
357
+ @pytest.fixture
358
+ def mock_hf_dataset(synthetic_case_files: CaseFiles):
359
+ """Create a mock HF Dataset-like object."""
360
+ # Returns a simple dict-based mock that mimics Dataset behavior
361
+ ...
362
+ ```
363
+
364
+ #### 2. `tests/data/test_staging.py` - Start with staging (no network)
365
+
366
+ ```python
367
+ """Tests for data staging module."""
368
+
369
+ from __future__ import annotations
370
+
371
+ from pathlib import Path
372
+
373
+ import pytest
374
+
375
+ from stroke_deepisles_demo.core.exceptions import MissingInputError
376
+ from stroke_deepisles_demo.core.types import CaseFiles
377
+ from stroke_deepisles_demo.data.staging import (
378
+ StagedCase,
379
+ create_staging_directory,
380
+ stage_case_for_deepisles,
381
+ )
382
+
383
+
384
+ class TestCreateStagingDirectory:
385
+ """Tests for create_staging_directory."""
386
+
387
+ def test_creates_directory(self, temp_dir: Path) -> None:
388
+ """Staging directory is created and exists."""
389
+ staging = create_staging_directory(base_dir=temp_dir)
390
+ assert staging.exists()
391
+ assert staging.is_dir()
392
+
393
+ def test_uses_system_temp_when_no_base(self) -> None:
394
+ """Uses system temp directory when base_dir is None."""
395
+ staging = create_staging_directory(base_dir=None)
396
+ assert staging.exists()
397
+ # Cleanup
398
+ staging.rmdir()
399
+
400
+
401
+ class TestStageCaseForDeepIsles:
402
+ """Tests for stage_case_for_deepisles."""
403
+
404
+ def test_stages_required_files(
405
+ self, synthetic_case_files: CaseFiles, temp_dir: Path
406
+ ) -> None:
407
+ """DWI and ADC are staged with correct names."""
408
+ staged = stage_case_for_deepisles(synthetic_case_files, temp_dir)
409
+
410
+ assert staged.dwi_path.name == "dwi.nii.gz"
411
+ assert staged.adc_path.name == "adc.nii.gz"
412
+ assert staged.dwi_path.exists()
413
+ assert staged.adc_path.exists()
414
+
415
+ def test_staged_files_are_readable(
416
+ self, synthetic_case_files: CaseFiles, temp_dir: Path
417
+ ) -> None:
418
+ """Staged files can be read as valid NIfTI."""
419
+ import nibabel as nib
420
+
421
+ staged = stage_case_for_deepisles(synthetic_case_files, temp_dir)
422
+
423
+ dwi = nib.load(staged.dwi_path)
424
+ assert dwi.shape == (64, 64, 30)
425
+
426
+ def test_raises_when_dwi_missing(self, temp_dir: Path) -> None:
427
+ """Raises MissingInputError when DWI is missing."""
428
+ case_files = CaseFiles(
429
+ dwi=temp_dir / "nonexistent.nii.gz",
430
+ adc=temp_dir / "adc.nii.gz",
431
+ flair=None,
432
+ ground_truth=None,
433
+ )
434
+
435
+ with pytest.raises(MissingInputError, match="DWI"):
436
+ stage_case_for_deepisles(case_files, temp_dir)
437
+
438
+ def test_flair_is_optional(
439
+ self, synthetic_case_files: CaseFiles, temp_dir: Path
440
+ ) -> None:
441
+ """Staging succeeds when FLAIR is None."""
442
+ # synthetic_case_files has flair=None
443
+ staged = stage_case_for_deepisles(synthetic_case_files, temp_dir)
444
+
445
+ assert staged.flair_path is None
446
+ ```
447
+
448
+ #### 3. `tests/data/test_adapter.py` - Case adapter with mocks
449
+
450
+ ```python
451
+ """Tests for case adapter module."""
452
+
453
+ from __future__ import annotations
454
+
455
+ import pytest
456
+
457
+ from stroke_deepisles_demo.core.types import CaseFiles
458
+ from stroke_deepisles_demo.data.adapter import CaseAdapter
459
+
460
+
461
+ class TestCaseAdapter:
462
+ """Tests for CaseAdapter."""
463
+
464
+ def test_list_case_ids_returns_strings(self, mock_hf_dataset) -> None:
465
+ """list_case_ids returns list of string identifiers."""
466
+ adapter = CaseAdapter(mock_hf_dataset)
467
+ case_ids = adapter.list_case_ids()
468
+
469
+ assert isinstance(case_ids, list)
470
+ assert all(isinstance(cid, str) for cid in case_ids)
471
+
472
+ def test_len_matches_dataset_size(self, mock_hf_dataset) -> None:
473
+ """len(adapter) equals number of cases in dataset."""
474
+ adapter = CaseAdapter(mock_hf_dataset)
475
+
476
+ assert len(adapter) == len(mock_hf_dataset)
477
+
478
+ def test_get_case_by_string_id(self, mock_hf_dataset) -> None:
479
+ """Can retrieve case by string identifier."""
480
+ adapter = CaseAdapter(mock_hf_dataset)
481
+ case_ids = adapter.list_case_ids()
482
+
483
+ case = adapter.get_case(case_ids[0])
484
+
485
+ assert isinstance(case, dict) # CaseFiles is a TypedDict
486
+ assert "dwi" in case
487
+ assert "adc" in case
488
+
489
+ def test_get_case_by_index(self, mock_hf_dataset) -> None:
490
+ """Can retrieve case by integer index."""
491
+ adapter = CaseAdapter(mock_hf_dataset)
492
+
493
+ case_id, case = adapter.get_case_by_index(0)
494
+
495
+ assert isinstance(case_id, str)
496
+ assert case["dwi"] is not None
497
+
498
+ def test_get_case_invalid_id_raises(self, mock_hf_dataset) -> None:
499
+ """Raises KeyError for invalid case ID."""
500
+ adapter = CaseAdapter(mock_hf_dataset)
501
+
502
+ with pytest.raises(KeyError):
503
+ adapter.get_case("nonexistent-case-id")
504
+
505
+ def test_iteration(self, mock_hf_dataset) -> None:
506
+ """Can iterate over case IDs."""
507
+ adapter = CaseAdapter(mock_hf_dataset)
508
+
509
+ case_ids = list(adapter)
510
+
511
+ assert len(case_ids) == len(adapter)
512
+ ```
513
+
514
+ #### 4. `tests/data/test_loader.py` - Loader with network mocks
515
+
516
+ ```python
517
+ """Tests for data loader module."""
518
+
519
+ from __future__ import annotations
520
+
521
+ from unittest.mock import MagicMock, patch
522
+
523
+ import pytest
524
+
525
+ from stroke_deepisles_demo.core.exceptions import DataLoadError
526
+ from stroke_deepisles_demo.data.loader import (
527
+ DatasetInfo,
528
+ get_dataset_info,
529
+ load_isles_dataset,
530
+ )
531
+
532
+
533
+ class TestLoadIslesDataset:
534
+ """Tests for load_isles_dataset."""
535
+
536
+ def test_calls_hf_load_dataset(self) -> None:
537
+ """Calls datasets.load_dataset with correct arguments."""
538
+ with patch("stroke_deepisles_demo.data.loader.load_dataset") as mock_load:
539
+ mock_load.return_value = MagicMock()
540
+
541
+ load_isles_dataset("test/dataset")
542
+
543
+ mock_load.assert_called_once()
544
+ call_args = mock_load.call_args
545
+ assert call_args.args[0] == "test/dataset"
546
+
547
+ def test_returns_dataset_object(self) -> None:
548
+ """Returns the loaded Dataset object."""
549
+ with patch("stroke_deepisles_demo.data.loader.load_dataset") as mock_load:
550
+ expected = MagicMock()
551
+ mock_load.return_value = expected
552
+
553
+ result = load_isles_dataset()
554
+
555
+ assert result is expected
556
+
557
+ def test_handles_load_error(self) -> None:
558
+ """Wraps HF errors in DataLoadError."""
559
+ with patch("stroke_deepisles_demo.data.loader.load_dataset") as mock_load:
560
+ mock_load.side_effect = Exception("Network error")
561
+
562
+ with pytest.raises(DataLoadError, match="Network error"):
563
+ load_isles_dataset()
564
+
565
+
566
+ class TestGetDatasetInfo:
567
+ """Tests for get_dataset_info."""
568
+
569
+ def test_returns_datasetinfo(self) -> None:
570
+ """Returns DatasetInfo with expected fields."""
571
+ with patch("stroke_deepisles_demo.data.loader.load_dataset") as mock_load:
572
+ mock_ds = MagicMock()
573
+ mock_ds.__len__ = MagicMock(return_value=149)
574
+ mock_ds.features = {"dwi": ..., "adc": ..., "mask": ...}
575
+ mock_load.return_value = mock_ds
576
+
577
+ info = get_dataset_info()
578
+
579
+ assert isinstance(info, DatasetInfo)
580
+ assert info.num_cases == 149
581
+
582
+
583
+ @pytest.mark.integration
584
+ class TestLoadIslesDatasetIntegration:
585
+ """Integration tests that hit the real HuggingFace Hub."""
586
+
587
+ @pytest.mark.slow
588
+ def test_load_real_dataset(self) -> None:
589
+ """Actually loads ISLES24-MR-Lite from HF Hub."""
590
+ # This test requires network access
591
+ # Run with: pytest -m integration
592
+ dataset = load_isles_dataset(streaming=True)
593
+
594
+ # Just verify we got something
595
+ assert dataset is not None
596
+ ```
597
+
598
+ ### what to mock
599
+
600
+ - `datasets.load_dataset` - Mock for unit tests, real for integration tests
601
+ - `huggingface_hub` calls - Mock for unit tests
602
+ - File system operations - Use `temp_dir` fixture with real files
603
+
604
+ ### what to test for real
605
+
606
+ - NIfTI file creation/reading with nibabel
607
+ - File staging (copy/symlink operations)
608
+ - Integration test: actual HF Hub download (marked `@pytest.mark.integration`)
609
+
610
+ ## "done" criteria
611
+
612
+ Phase 1 is complete when:
613
+
614
+ 1. All unit tests pass: `uv run pytest tests/data/ -v`
615
+ 2. Can load synthetic test cases without network
616
+ 3. Can list case IDs from mock dataset
617
+ 4. Can stage files with correct DeepISLES naming
618
+ 5. Integration test passes (with network): `uv run pytest -m integration`
619
+ 6. Type checking passes: `uv run mypy src/stroke_deepisles_demo/data/`
620
+ 7. Code coverage for data module > 80%
621
+
622
+ ## implementation notes
623
+
624
+ - ISLES24-MR-Lite structure needs investigation - check HF page for exact column names
625
+ - Consider using `huggingface_hub.snapshot_download` if `datasets.load_dataset` has issues with NIfTI
626
+ - Staging can use symlinks on Unix, copies on Windows
627
+ - Cache the HF dataset locally to avoid repeated downloads
628
+
629
+ ### critical: streaming mode + docker materialization
630
+
631
+ **Reviewer feedback (valid)**: When using `streaming=True`, the dataset returns URLs or lazy file objects, NOT local POSIX paths. Docker requires physical files on the host disk for volume mounting.
632
+
633
+ **Solution**: The `stage_case_for_deepisles` function MUST handle materialization:
634
+
635
+ ```python
636
+ def stage_case_for_deepisles(
637
+ case_files: CaseFiles,
638
+ output_dir: Path,
639
+ *,
640
+ case_id: str | None = None,
641
+ ) -> StagedCase:
642
+ """
643
+ Stage case files with DeepISLES-expected naming.
644
+
645
+ IMPORTANT: This function handles both local paths and streaming data.
646
+ When files come from streaming mode, they must be downloaded/materialized
647
+ before Docker can mount them.
648
+ """
649
+ output_dir.mkdir(parents=True, exist_ok=True)
650
+
651
+ # Handle DWI - may be Path, URL, or NIfTI object
652
+ dwi_staged = output_dir / "dwi.nii.gz"
653
+ _materialize_nifti(case_files["dwi"], dwi_staged)
654
+
655
+ # Handle ADC
656
+ adc_staged = output_dir / "adc.nii.gz"
657
+ _materialize_nifti(case_files["adc"], adc_staged)
658
+
659
+ # ... etc
660
+
661
+
662
+ def _materialize_nifti(source: Path | str | bytes | NiftiImage, dest: Path) -> None:
663
+ """
664
+ Materialize a NIfTI file to a local path.
665
+
666
+ Handles:
667
+ - Local Path: copy or symlink
668
+ - URL string: download
669
+ - bytes: write directly
670
+ - NIfTI object: serialize with nibabel
671
+ """
672
+ if isinstance(source, Path) and source.exists():
673
+ # Local file - symlink if possible, copy otherwise
674
+ shutil.copy2(source, dest)
675
+ elif isinstance(source, str) and source.startswith(("http://", "https://")):
676
+ # URL - download
677
+ _download_file(source, dest)
678
+ elif isinstance(source, bytes):
679
+ # Raw bytes
680
+ dest.write_bytes(source)
681
+ elif hasattr(source, "to_bytes"):
682
+ # NIfTI object (nibabel or wrapper)
683
+ dest.write_bytes(source.to_bytes())
684
+ else:
685
+ raise MissingInputError(f"Cannot materialize source: {type(source)}")
686
+ ```
687
+
688
+ This ensures Docker always gets physical files regardless of how data was loaded.
689
+
690
+ ## dependencies to add
691
+
692
+ No new dependencies needed - all specified in Phase 0:
693
+ - `datasets` (Tobias fork)
694
+ - `nibabel`
695
+ - `numpy`
docs/specs/03-phase-2-deepisles-docker.md ADDED
@@ -0,0 +1,884 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) - FLAIR sequence
59
+
60
+ **Output:**
61
+ - `results/` directory containing the lesion mask
62
+
63
+ ## interfaces and types
64
+
65
+ ### `inference/docker.py`
66
+
67
+ ```python
68
+ """Docker execution utilities."""
69
+
70
+ from __future__ import annotations
71
+
72
+ import subprocess
73
+ from dataclasses import dataclass
74
+ from pathlib import Path
75
+ from typing import Sequence
76
+
77
+ from stroke_deepisles_demo.core.exceptions import DockerNotAvailableError
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class DockerRunResult:
82
+ """Result of a Docker container run."""
83
+
84
+ exit_code: int
85
+ stdout: str
86
+ stderr: str
87
+ elapsed_seconds: float
88
+
89
+
90
+ def check_docker_available() -> bool:
91
+ """
92
+ Check if Docker is installed and the daemon is running.
93
+
94
+ Returns:
95
+ True if Docker is available, False otherwise
96
+ """
97
+ ...
98
+
99
+
100
+ def ensure_docker_available() -> None:
101
+ """
102
+ Ensure Docker is available, raising if not.
103
+
104
+ Raises:
105
+ DockerNotAvailableError: If Docker is not installed or not running
106
+ """
107
+ ...
108
+
109
+
110
+ def pull_image_if_missing(image: str, *, timeout: float = 600) -> bool:
111
+ """
112
+ Pull a Docker image if not present locally.
113
+
114
+ Args:
115
+ image: Docker image name (e.g., "isleschallenge/deepisles")
116
+ timeout: Maximum seconds to wait for pull
117
+
118
+ Returns:
119
+ True if image was pulled, False if already present
120
+ """
121
+ ...
122
+
123
+
124
+ def run_container(
125
+ image: str,
126
+ *,
127
+ command: Sequence[str] | None = None,
128
+ volumes: dict[Path, str] | None = None, # host_path -> container_path
129
+ environment: dict[str, str] | None = None,
130
+ gpu: bool = False,
131
+ remove: bool = True,
132
+ timeout: float | None = None,
133
+ ) -> DockerRunResult:
134
+ """
135
+ Run a Docker container and wait for completion.
136
+
137
+ Args:
138
+ image: Docker image name
139
+ command: Command to run in container
140
+ volumes: Volume mounts (host path -> container path)
141
+ environment: Environment variables
142
+ gpu: If True, pass --gpus all
143
+ remove: If True, remove container after exit (--rm)
144
+ timeout: Maximum seconds to wait (None = no timeout)
145
+
146
+ Returns:
147
+ DockerRunResult with exit code, stdout, stderr, elapsed time
148
+
149
+ Raises:
150
+ DockerNotAvailableError: If Docker is not available
151
+ subprocess.TimeoutExpired: If timeout exceeded
152
+ """
153
+ ...
154
+
155
+
156
+ def build_docker_command(
157
+ image: str,
158
+ *,
159
+ command: Sequence[str] | None = None,
160
+ volumes: dict[Path, str] | None = None,
161
+ environment: dict[str, str] | None = None,
162
+ gpu: bool = False,
163
+ remove: bool = True,
164
+ ) -> list[str]:
165
+ """
166
+ Build the docker run command without executing.
167
+
168
+ Useful for logging/debugging.
169
+
170
+ Returns:
171
+ List of command arguments for subprocess
172
+ """
173
+ ...
174
+ ```
175
+
176
+ ### `inference/deepisles.py`
177
+
178
+ ```python
179
+ """DeepISLES stroke segmentation wrapper."""
180
+
181
+ from __future__ import annotations
182
+
183
+ import time
184
+ from dataclasses import dataclass
185
+ from pathlib import Path
186
+
187
+ from stroke_deepisles_demo.core.config import settings
188
+ from stroke_deepisles_demo.core.exceptions import DeepISLESError, MissingInputError
189
+ from stroke_deepisles_demo.inference.docker import (
190
+ DockerRunResult,
191
+ ensure_docker_available,
192
+ run_container,
193
+ )
194
+
195
+
196
+ @dataclass(frozen=True)
197
+ class DeepISLESResult:
198
+ """Result of DeepISLES inference."""
199
+
200
+ prediction_path: Path
201
+ docker_result: DockerRunResult
202
+ elapsed_seconds: float
203
+
204
+
205
+ def validate_input_folder(input_dir: Path) -> tuple[Path, Path, Path | None]:
206
+ """
207
+ Validate that input folder contains required files.
208
+
209
+ Args:
210
+ input_dir: Directory to validate
211
+
212
+ Returns:
213
+ Tuple of (dwi_path, adc_path, flair_path_or_none)
214
+
215
+ Raises:
216
+ MissingInputError: If required files are missing
217
+ """
218
+ ...
219
+
220
+
221
+ def run_deepisles_on_folder(
222
+ input_dir: Path,
223
+ *,
224
+ output_dir: Path | None = None,
225
+ fast: bool = True,
226
+ gpu: bool = True,
227
+ timeout: float | None = 1800, # 30 minutes default
228
+ ) -> DeepISLESResult:
229
+ """
230
+ Run DeepISLES stroke segmentation on a folder of NIfTI files.
231
+
232
+ Args:
233
+ input_dir: Directory containing dwi.nii.gz, adc.nii.gz, [flair.nii.gz]
234
+ output_dir: Where to write results (default: input_dir/results)
235
+ fast: If True, use single-model mode (faster, slightly less accurate)
236
+ gpu: If True, use GPU acceleration
237
+ timeout: Maximum seconds to wait for inference
238
+
239
+ Returns:
240
+ DeepISLESResult with path to prediction mask
241
+
242
+ Raises:
243
+ DockerNotAvailableError: If Docker is not available
244
+ MissingInputError: If required input files are missing
245
+ DeepISLESError: If inference fails (non-zero exit, missing output)
246
+
247
+ Example:
248
+ >>> result = run_deepisles_on_folder(Path("/data/case001"), fast=True)
249
+ >>> print(result.prediction_path)
250
+ /data/case001/results/prediction.nii.gz
251
+ """
252
+ ...
253
+
254
+
255
+ def find_prediction_mask(output_dir: Path) -> Path:
256
+ """
257
+ Find the prediction mask in DeepISLES output directory.
258
+
259
+ DeepISLES outputs may have varying names depending on version.
260
+ This function finds the most likely prediction file.
261
+
262
+ Args:
263
+ output_dir: DeepISLES output directory
264
+
265
+ Returns:
266
+ Path to the prediction mask NIfTI file
267
+
268
+ Raises:
269
+ DeepISLESError: If no prediction mask found
270
+ """
271
+ ...
272
+
273
+
274
+ # Constants
275
+ DEEPISLES_IMAGE = "isleschallenge/deepisles"
276
+ EXPECTED_INPUT_FILES = ["dwi.nii.gz", "adc.nii.gz"]
277
+ OPTIONAL_INPUT_FILES = ["flair.nii.gz"]
278
+ ```
279
+
280
+ ### `inference/__init__.py` (public API)
281
+
282
+ ```python
283
+ """Inference module for stroke-deepisles-demo."""
284
+
285
+ from stroke_deepisles_demo.inference.deepisles import (
286
+ DEEPISLES_IMAGE,
287
+ DeepISLESResult,
288
+ run_deepisles_on_folder,
289
+ validate_input_folder,
290
+ )
291
+ from stroke_deepisles_demo.inference.docker import (
292
+ DockerRunResult,
293
+ build_docker_command,
294
+ check_docker_available,
295
+ ensure_docker_available,
296
+ run_container,
297
+ )
298
+
299
+ __all__ = [
300
+ # DeepISLES
301
+ "run_deepisles_on_folder",
302
+ "validate_input_folder",
303
+ "DeepISLESResult",
304
+ "DEEPISLES_IMAGE",
305
+ # Docker utilities
306
+ "check_docker_available",
307
+ "ensure_docker_available",
308
+ "run_container",
309
+ "build_docker_command",
310
+ "DockerRunResult",
311
+ ]
312
+ ```
313
+
314
+ ## tdd plan
315
+
316
+ ### test file structure
317
+
318
+ ```
319
+ tests/
320
+ β”œβ”€β”€ inference/
321
+ β”‚ β”œβ”€β”€ __init__.py
322
+ β”‚ β”œβ”€β”€ test_docker.py # Tests for Docker utilities
323
+ β”‚ └── test_deepisles.py # Tests for DeepISLES wrapper
324
+ ```
325
+
326
+ ### tests to write first (TDD order)
327
+
328
+ #### 1. `tests/inference/test_docker.py`
329
+
330
+ ```python
331
+ """Tests for Docker utilities."""
332
+
333
+ from __future__ import annotations
334
+
335
+ import subprocess
336
+ from pathlib import Path
337
+ from unittest.mock import MagicMock, patch
338
+
339
+ import pytest
340
+
341
+ from stroke_deepisles_demo.core.exceptions import DockerNotAvailableError
342
+ from stroke_deepisles_demo.inference.docker import (
343
+ build_docker_command,
344
+ check_docker_available,
345
+ ensure_docker_available,
346
+ run_container,
347
+ )
348
+
349
+
350
+ class TestCheckDockerAvailable:
351
+ """Tests for check_docker_available."""
352
+
353
+ def test_returns_true_when_docker_responds(self) -> None:
354
+ """Returns True when 'docker info' succeeds."""
355
+ with patch("subprocess.run") as mock_run:
356
+ mock_run.return_value = MagicMock(returncode=0)
357
+
358
+ result = check_docker_available()
359
+
360
+ assert result is True
361
+
362
+ def test_returns_false_when_docker_not_found(self) -> None:
363
+ """Returns False when docker command not found."""
364
+ with patch("subprocess.run") as mock_run:
365
+ mock_run.side_effect = FileNotFoundError()
366
+
367
+ result = check_docker_available()
368
+
369
+ assert result is False
370
+
371
+ def test_returns_false_when_daemon_not_running(self) -> None:
372
+ """Returns False when docker daemon not running."""
373
+ with patch("subprocess.run") as mock_run:
374
+ mock_run.return_value = MagicMock(returncode=1)
375
+
376
+ result = check_docker_available()
377
+
378
+ assert result is False
379
+
380
+
381
+ class TestEnsureDockerAvailable:
382
+ """Tests for ensure_docker_available."""
383
+
384
+ def test_raises_when_docker_not_available(self) -> None:
385
+ """Raises DockerNotAvailableError when Docker not available."""
386
+ with patch(
387
+ "stroke_deepisles_demo.inference.docker.check_docker_available",
388
+ return_value=False,
389
+ ):
390
+ with pytest.raises(DockerNotAvailableError):
391
+ ensure_docker_available()
392
+
393
+ def test_no_error_when_docker_available(self) -> None:
394
+ """No exception when Docker is available."""
395
+ with patch(
396
+ "stroke_deepisles_demo.inference.docker.check_docker_available",
397
+ return_value=True,
398
+ ):
399
+ ensure_docker_available() # Should not raise
400
+
401
+
402
+ class TestBuildDockerCommand:
403
+ """Tests for build_docker_command."""
404
+
405
+ def test_basic_command(self) -> None:
406
+ """Builds basic docker run command."""
407
+ cmd = build_docker_command("myimage:latest")
408
+
409
+ assert cmd[0] == "docker"
410
+ assert "run" in cmd
411
+ assert "myimage:latest" in cmd
412
+
413
+ def test_includes_rm_flag(self) -> None:
414
+ """Includes --rm when remove=True."""
415
+ cmd = build_docker_command("myimage", remove=True)
416
+
417
+ assert "--rm" in cmd
418
+
419
+ def test_excludes_rm_flag(self) -> None:
420
+ """Excludes --rm when remove=False."""
421
+ cmd = build_docker_command("myimage", remove=False)
422
+
423
+ assert "--rm" not in cmd
424
+
425
+ def test_includes_gpu_flag(self) -> None:
426
+ """Includes --gpus all when gpu=True."""
427
+ cmd = build_docker_command("myimage", gpu=True)
428
+
429
+ assert "--gpus" in cmd
430
+ gpu_index = cmd.index("--gpus")
431
+ assert cmd[gpu_index + 1] == "all"
432
+
433
+ def test_volume_mounts(self, temp_dir: Path) -> None:
434
+ """Includes volume mounts."""
435
+ volumes = {temp_dir: "/data"}
436
+ cmd = build_docker_command("myimage", volumes=volumes)
437
+
438
+ assert "-v" in cmd
439
+ # Find the volume argument
440
+ v_index = cmd.index("-v")
441
+ assert f"{temp_dir}:/data" in cmd[v_index + 1]
442
+
443
+ def test_custom_command(self) -> None:
444
+ """Appends custom command arguments."""
445
+ cmd = build_docker_command(
446
+ "myimage", command=["--input", "/data", "--fast", "True"]
447
+ )
448
+
449
+ assert "--input" in cmd
450
+ assert "--fast" in cmd
451
+
452
+
453
+ class TestRunContainer:
454
+ """Tests for run_container."""
455
+
456
+ def test_calls_subprocess_with_built_command(self) -> None:
457
+ """Calls subprocess.run with built command."""
458
+ with patch("subprocess.run") as mock_run:
459
+ mock_run.return_value = MagicMock(
460
+ returncode=0, stdout="output", stderr=""
461
+ )
462
+ with patch(
463
+ "stroke_deepisles_demo.inference.docker.ensure_docker_available"
464
+ ):
465
+ run_container("myimage")
466
+
467
+ mock_run.assert_called_once()
468
+
469
+ def test_returns_result_with_exit_code(self) -> None:
470
+ """Returns DockerRunResult with correct exit code."""
471
+ with patch("subprocess.run") as mock_run:
472
+ mock_run.return_value = MagicMock(
473
+ returncode=42, stdout="out", stderr="err"
474
+ )
475
+ with patch(
476
+ "stroke_deepisles_demo.inference.docker.ensure_docker_available"
477
+ ):
478
+ result = run_container("myimage")
479
+
480
+ assert result.exit_code == 42
481
+
482
+ def test_captures_stdout_stderr(self) -> None:
483
+ """Captures stdout and stderr from container."""
484
+ with patch("subprocess.run") as mock_run:
485
+ mock_run.return_value = MagicMock(
486
+ returncode=0, stdout="hello", stderr="warning"
487
+ )
488
+ with patch(
489
+ "stroke_deepisles_demo.inference.docker.ensure_docker_available"
490
+ ):
491
+ result = run_container("myimage")
492
+
493
+ assert result.stdout == "hello"
494
+ assert result.stderr == "warning"
495
+
496
+ def test_respects_timeout(self) -> None:
497
+ """Passes timeout to subprocess."""
498
+ with patch("subprocess.run") as mock_run:
499
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
500
+ with patch(
501
+ "stroke_deepisles_demo.inference.docker.ensure_docker_available"
502
+ ):
503
+ run_container("myimage", timeout=60.0)
504
+
505
+ call_kwargs = mock_run.call_args.kwargs
506
+ assert call_kwargs.get("timeout") == 60.0
507
+
508
+
509
+ @pytest.mark.integration
510
+ class TestDockerIntegration:
511
+ """Integration tests requiring real Docker."""
512
+
513
+ def test_docker_actually_available(self) -> None:
514
+ """Docker is actually available on this system."""
515
+ # This test only runs with -m integration
516
+ assert check_docker_available() is True
517
+
518
+ def test_can_run_hello_world(self) -> None:
519
+ """Can run docker hello-world container."""
520
+ result = run_container("hello-world", timeout=60.0)
521
+
522
+ assert result.exit_code == 0
523
+ assert "Hello from Docker!" in result.stdout
524
+ ```
525
+
526
+ #### 2. `tests/inference/test_deepisles.py`
527
+
528
+ ```python
529
+ """Tests for DeepISLES wrapper."""
530
+
531
+ from __future__ import annotations
532
+
533
+ from pathlib import Path
534
+ from unittest.mock import MagicMock, patch
535
+
536
+ import pytest
537
+
538
+ from stroke_deepisles_demo.core.exceptions import DeepISLESError, MissingInputError
539
+ from stroke_deepisles_demo.inference.deepisles import (
540
+ DeepISLESResult,
541
+ find_prediction_mask,
542
+ run_deepisles_on_folder,
543
+ validate_input_folder,
544
+ )
545
+
546
+
547
+ class TestValidateInputFolder:
548
+ """Tests for validate_input_folder."""
549
+
550
+ def test_succeeds_with_required_files(self, temp_dir: Path) -> None:
551
+ """Returns paths when required files exist."""
552
+ (temp_dir / "dwi.nii.gz").touch()
553
+ (temp_dir / "adc.nii.gz").touch()
554
+
555
+ dwi, adc, flair = validate_input_folder(temp_dir)
556
+
557
+ assert dwi == temp_dir / "dwi.nii.gz"
558
+ assert adc == temp_dir / "adc.nii.gz"
559
+ assert flair is None
560
+
561
+ def test_includes_flair_when_present(self, temp_dir: Path) -> None:
562
+ """Returns FLAIR path when present."""
563
+ (temp_dir / "dwi.nii.gz").touch()
564
+ (temp_dir / "adc.nii.gz").touch()
565
+ (temp_dir / "flair.nii.gz").touch()
566
+
567
+ dwi, adc, flair = validate_input_folder(temp_dir)
568
+
569
+ assert flair == temp_dir / "flair.nii.gz"
570
+
571
+ def test_raises_when_dwi_missing(self, temp_dir: Path) -> None:
572
+ """Raises MissingInputError when DWI is missing."""
573
+ (temp_dir / "adc.nii.gz").touch()
574
+
575
+ with pytest.raises(MissingInputError, match="dwi"):
576
+ validate_input_folder(temp_dir)
577
+
578
+ def test_raises_when_adc_missing(self, temp_dir: Path) -> None:
579
+ """Raises MissingInputError when ADC is missing."""
580
+ (temp_dir / "dwi.nii.gz").touch()
581
+
582
+ with pytest.raises(MissingInputError, match="adc"):
583
+ validate_input_folder(temp_dir)
584
+
585
+
586
+ class TestFindPredictionMask:
587
+ """Tests for find_prediction_mask."""
588
+
589
+ def test_finds_prediction_file(self, temp_dir: Path) -> None:
590
+ """Finds prediction.nii.gz in output directory."""
591
+ results_dir = temp_dir / "results"
592
+ results_dir.mkdir()
593
+ pred_file = results_dir / "prediction.nii.gz"
594
+ pred_file.touch()
595
+
596
+ result = find_prediction_mask(temp_dir)
597
+
598
+ assert result == pred_file
599
+
600
+ def test_raises_when_no_prediction(self, temp_dir: Path) -> None:
601
+ """Raises DeepISLESError when no prediction found."""
602
+ results_dir = temp_dir / "results"
603
+ results_dir.mkdir()
604
+
605
+ with pytest.raises(DeepISLESError, match="prediction"):
606
+ find_prediction_mask(temp_dir)
607
+
608
+
609
+ class TestRunDeepIslesOnFolder:
610
+ """Tests for run_deepisles_on_folder."""
611
+
612
+ @pytest.fixture
613
+ def valid_input_dir(self, temp_dir: Path) -> Path:
614
+ """Create a valid input directory with required files."""
615
+ (temp_dir / "dwi.nii.gz").touch()
616
+ (temp_dir / "adc.nii.gz").touch()
617
+ return temp_dir
618
+
619
+ def test_validates_input_files(self, temp_dir: Path) -> None:
620
+ """Validates input files before running Docker."""
621
+ # Missing required files
622
+ with pytest.raises(MissingInputError):
623
+ run_deepisles_on_folder(temp_dir)
624
+
625
+ def test_calls_docker_with_correct_image(self, valid_input_dir: Path) -> None:
626
+ """Calls Docker with DeepISLES image."""
627
+ with patch(
628
+ "stroke_deepisles_demo.inference.deepisles.run_container"
629
+ ) as mock_run:
630
+ mock_run.return_value = MagicMock(exit_code=0, stdout="", stderr="")
631
+ # Also mock finding the prediction
632
+ with patch(
633
+ "stroke_deepisles_demo.inference.deepisles.find_prediction_mask"
634
+ ) as mock_find:
635
+ mock_find.return_value = valid_input_dir / "results" / "pred.nii.gz"
636
+
637
+ run_deepisles_on_folder(valid_input_dir)
638
+
639
+ # Check image name
640
+ call_args = mock_run.call_args
641
+ assert "isleschallenge/deepisles" in str(call_args)
642
+
643
+ def test_passes_fast_flag(self, valid_input_dir: Path) -> None:
644
+ """Passes --fast True when fast=True."""
645
+ with patch(
646
+ "stroke_deepisles_demo.inference.deepisles.run_container"
647
+ ) as mock_run:
648
+ mock_run.return_value = MagicMock(exit_code=0, stdout="", stderr="")
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, fast=True)
655
+
656
+ # Check --fast in command
657
+ call_kwargs = mock_run.call_args.kwargs
658
+ command = call_kwargs.get("command", [])
659
+ assert "--fast" in command
660
+
661
+ def test_raises_on_docker_failure(self, valid_input_dir: Path) -> None:
662
+ """Raises DeepISLESError when Docker returns non-zero."""
663
+ with patch(
664
+ "stroke_deepisles_demo.inference.deepisles.run_container"
665
+ ) as mock_run:
666
+ mock_run.return_value = MagicMock(
667
+ exit_code=1, stdout="", stderr="Segmentation fault"
668
+ )
669
+
670
+ with pytest.raises(DeepISLESError, match="failed"):
671
+ run_deepisles_on_folder(valid_input_dir)
672
+
673
+ def test_returns_result_with_prediction_path(self, valid_input_dir: Path) -> None:
674
+ """Returns DeepISLESResult with prediction path."""
675
+ with patch(
676
+ "stroke_deepisles_demo.inference.deepisles.run_container"
677
+ ) as mock_run:
678
+ mock_run.return_value = MagicMock(exit_code=0, stdout="", stderr="")
679
+ with patch(
680
+ "stroke_deepisles_demo.inference.deepisles.find_prediction_mask"
681
+ ) as mock_find:
682
+ expected_path = valid_input_dir / "results" / "prediction.nii.gz"
683
+ mock_find.return_value = expected_path
684
+
685
+ result = run_deepisles_on_folder(valid_input_dir)
686
+
687
+ assert isinstance(result, DeepISLESResult)
688
+ assert result.prediction_path == expected_path
689
+
690
+
691
+ @pytest.mark.integration
692
+ @pytest.mark.slow
693
+ class TestDeepIslesIntegration:
694
+ """Integration tests requiring real Docker and DeepISLES image."""
695
+
696
+ def test_real_inference(self, synthetic_case_files) -> None:
697
+ """Run actual DeepISLES inference on synthetic data."""
698
+ # This test requires:
699
+ # 1. Docker available
700
+ # 2. isleschallenge/deepisles image pulled
701
+ # 3. GPU (optional but recommended)
702
+ #
703
+ # Run with: pytest -m "integration and slow"
704
+
705
+ from stroke_deepisles_demo.data.staging import stage_case_for_deepisles
706
+
707
+ # Stage the synthetic files
708
+ staged = stage_case_for_deepisles(
709
+ synthetic_case_files,
710
+ Path("/tmp/deepisles_test"),
711
+ )
712
+
713
+ # Run inference
714
+ result = run_deepisles_on_folder(
715
+ staged.input_dir,
716
+ fast=True,
717
+ gpu=False, # Might not have GPU in CI
718
+ timeout=600,
719
+ )
720
+
721
+ # Verify output exists
722
+ assert result.prediction_path.exists()
723
+ ```
724
+
725
+ ### what to mock
726
+
727
+ - `subprocess.run` - Mock for all unit tests
728
+ - `check_docker_available` - Mock to control Docker availability
729
+ - `run_container` - Mock in DeepISLES tests to avoid Docker
730
+ - File system for prediction finding - Use temp directories
731
+
732
+ ### what to test for real
733
+
734
+ - Command building (no subprocess needed)
735
+ - Input validation (real file system with temp dirs)
736
+ - Integration test: actual Docker hello-world
737
+ - Integration test: actual DeepISLES inference (marked `slow`)
738
+
739
+ ## "done" criteria
740
+
741
+ Phase 2 is complete when:
742
+
743
+ 1. All unit tests pass: `uv run pytest tests/inference/ -v`
744
+ 2. Can build Docker commands correctly
745
+ 3. Can validate input folders
746
+ 4. Unit tests don't require Docker (all mocked)
747
+ 5. Integration test passes with Docker: `uv run pytest -m integration tests/inference/`
748
+ 6. Type checking passes: `uv run mypy src/stroke_deepisles_demo/inference/`
749
+ 7. Code coverage for inference module > 80%
750
+
751
+ ## implementation notes
752
+
753
+ - Check DeepISLES repo for exact output file names/structure
754
+ - Consider `--gpus all` vs `--gpus '"device=0"'` for GPU selection
755
+ - Timeout should be generous (30+ minutes) for full ensemble mode
756
+ - Log Docker stdout/stderr for debugging
757
+ - Consider streaming Docker output for long-running inference
758
+
759
+ ### critical: docker file permissions (linux)
760
+
761
+ **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.
762
+
763
+ **Solution**: Pass `--user` flag to match host user:
764
+
765
+ ```python
766
+ def build_docker_command(
767
+ image: str,
768
+ *,
769
+ volumes: dict[Path, str] | None = None,
770
+ gpu: bool = False,
771
+ remove: bool = True,
772
+ match_user: bool = True, # NEW: default True on Linux
773
+ ) -> list[str]:
774
+ """Build docker run command."""
775
+ cmd = ["docker", "run"]
776
+
777
+ if remove:
778
+ cmd.append("--rm")
779
+
780
+ if gpu:
781
+ cmd.extend(["--gpus", "all"])
782
+
783
+ # Match host user to avoid permission issues
784
+ if match_user and sys.platform != "darwin": # Not needed on macOS
785
+ import os
786
+ uid = os.getuid()
787
+ gid = os.getgid()
788
+ cmd.extend(["--user", f"{uid}:{gid}"])
789
+
790
+ if volumes:
791
+ for host_path, container_path in volumes.items():
792
+ cmd.extend(["-v", f"{host_path}:{container_path}"])
793
+
794
+ cmd.append(image)
795
+ return cmd
796
+ ```
797
+
798
+ Alternative: Fix permissions after Docker completes (less clean but works):
799
+
800
+ ```python
801
+ def fix_docker_output_permissions(output_dir: Path) -> None:
802
+ """Fix permissions on Docker-created files."""
803
+ import subprocess
804
+ # Only needed if running as non-root and files are root-owned
805
+ try:
806
+ subprocess.run(
807
+ ["sudo", "chown", "-R", f"{os.getuid()}:{os.getgid()}", str(output_dir)],
808
+ check=True,
809
+ capture_output=True,
810
+ )
811
+ except (subprocess.CalledProcessError, FileNotFoundError):
812
+ pass # sudo not available or not needed
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/04-phase-3-pipeline.md ADDED
@@ -0,0 +1,705 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 single-model mode (faster)
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/05-phase-4-gradio-ui.md ADDED
@@ -0,0 +1,817 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ### recommended approach: hybrid fastapi + gradio
95
+
96
+ For our demo, we use a **hybrid approach**:
97
+ - **Gradio** for case selection dropdown and "Run Segmentation" button
98
+ - **FastAPI endpoints** for serving NIfTI data as base64
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 from Tobias's implementation
104
+ - No iframe complexity
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
+ ### fallback: matplotlib 2d slices
173
+
174
+ For environments where WebGL fails, provide matplotlib fallback:
175
+
176
+ ```python
177
+ import matplotlib.pyplot as plt
178
+ import nibabel as nib
179
+
180
+ def render_slices_fallback(nifti_path: Path, mask_path: Path | None = None) -> Figure:
181
+ """Render 3-panel slice view with optional mask overlay."""
182
+ img = nib.load(nifti_path)
183
+ data = img.get_fdata()
184
+
185
+ fig, axes = plt.subplots(1, 3, figsize=(15, 5))
186
+
187
+ # Middle slices
188
+ ax_slice = data.shape[2] // 2
189
+ cor_slice = data.shape[1] // 2
190
+ sag_slice = data.shape[0] // 2
191
+
192
+ axes[0].imshow(data[:, :, ax_slice].T, cmap='gray', origin='lower')
193
+ axes[0].set_title('Axial')
194
+ axes[1].imshow(data[:, cor_slice, :].T, cmap='gray', origin='lower')
195
+ axes[1].set_title('Coronal')
196
+ axes[2].imshow(data[sag_slice, :, :].T, cmap='gray', origin='lower')
197
+ axes[2].set_title('Sagittal')
198
+
199
+ if mask_path:
200
+ mask = nib.load(mask_path).get_fdata()
201
+ # Overlay in red with alpha
202
+ for ax, sl in zip(axes, [mask[:,:,ax_slice].T, mask[:,cor_slice,:].T, mask[sag_slice,:,:].T]):
203
+ ax.imshow(sl, cmap='Reds', alpha=0.5, origin='lower')
204
+
205
+ return fig
206
+ ```
207
+
208
+ **Recommendation**: Use NiiVue as primary (proven working), matplotlib as fallback.
209
+
210
+ ## interfaces and types
211
+
212
+ ### `ui/app.py`
213
+
214
+ ```python
215
+ """Main Gradio application for stroke-deepisles-demo."""
216
+
217
+ from __future__ import annotations
218
+
219
+ import gradio as gr
220
+
221
+ from stroke_deepisles_demo.pipeline import run_pipeline_on_case
222
+ from stroke_deepisles_demo.ui.components import create_case_selector, create_results_display
223
+ from stroke_deepisles_demo.ui.viewer import render_comparison_view
224
+
225
+
226
+ def create_app() -> gr.Blocks:
227
+ """
228
+ Create the Gradio application.
229
+
230
+ Returns:
231
+ Configured gr.Blocks application
232
+ """
233
+ with gr.Blocks(
234
+ title="Stroke Lesion Segmentation Demo",
235
+ theme=gr.themes.Soft(),
236
+ ) as demo:
237
+ # Header
238
+ gr.Markdown("""
239
+ # Stroke Lesion Segmentation Demo
240
+
241
+ This demo runs [DeepISLES](https://github.com/ezequieldlrosa/DeepIsles)
242
+ stroke segmentation on cases from
243
+ [ISLES24-MR-Lite](https://huggingface.co/datasets/YongchengYAO/ISLES24-MR-Lite).
244
+
245
+ > **Disclaimer**: This is for research/demonstration only. Not for clinical use.
246
+ """)
247
+
248
+ with gr.Row():
249
+ # Left column: Controls
250
+ with gr.Column(scale=1):
251
+ case_selector = create_case_selector()
252
+ run_btn = gr.Button("Run Segmentation", variant="primary")
253
+ status = gr.Textbox(label="Status", interactive=False)
254
+
255
+ # Right column: Results
256
+ with gr.Column(scale=2):
257
+ results_display = create_results_display()
258
+
259
+ # Event handlers
260
+ run_btn.click(
261
+ fn=run_segmentation,
262
+ inputs=[case_selector],
263
+ outputs=[results_display, status],
264
+ )
265
+
266
+ return demo
267
+
268
+
269
+ def run_segmentation(case_id: str) -> tuple[dict, str]:
270
+ """
271
+ Run segmentation and return results for display.
272
+
273
+ Args:
274
+ case_id: Selected case identifier
275
+
276
+ Returns:
277
+ Tuple of (results_dict, status_message)
278
+ """
279
+ ...
280
+
281
+
282
+ # Module-level app instance for Gradio CLI
283
+ demo = create_app()
284
+
285
+ if __name__ == "__main__":
286
+ demo.launch()
287
+ ```
288
+
289
+ ### `ui/viewer.py`
290
+
291
+ ```python
292
+ """Neuroimaging visualization for Gradio."""
293
+
294
+ from __future__ import annotations
295
+
296
+ from pathlib import Path
297
+ from typing import TYPE_CHECKING
298
+
299
+ import numpy as np
300
+
301
+ if TYPE_CHECKING:
302
+ from matplotlib.figure import Figure
303
+ from numpy.typing import NDArray
304
+
305
+
306
+ def render_slice_comparison(
307
+ dwi_path: Path,
308
+ prediction_path: Path,
309
+ ground_truth_path: Path | None = None,
310
+ *,
311
+ slice_idx: int | None = None,
312
+ orientation: str = "axial",
313
+ ) -> Figure:
314
+ """
315
+ Render side-by-side comparison of DWI, prediction, and ground truth.
316
+
317
+ Args:
318
+ dwi_path: Path to DWI NIfTI
319
+ prediction_path: Path to predicted mask NIfTI
320
+ ground_truth_path: Optional path to ground truth mask
321
+ slice_idx: Slice index (default: middle slice)
322
+ orientation: One of "axial", "coronal", "sagittal"
323
+
324
+ Returns:
325
+ Matplotlib figure with comparison view
326
+ """
327
+ ...
328
+
329
+
330
+ def render_3panel_view(
331
+ nifti_path: Path,
332
+ mask_path: Path | None = None,
333
+ *,
334
+ mask_alpha: float = 0.5,
335
+ mask_color: str = "red",
336
+ ) -> Figure:
337
+ """
338
+ Render axial/coronal/sagittal slices with optional mask overlay.
339
+
340
+ Args:
341
+ nifti_path: Path to base NIfTI volume
342
+ mask_path: Optional path to mask for overlay
343
+ mask_alpha: Transparency of mask overlay
344
+ mask_color: Color for mask overlay
345
+
346
+ Returns:
347
+ Matplotlib figure with 3-panel view
348
+ """
349
+ ...
350
+
351
+
352
+ def create_niivue_html(
353
+ volume_url: str,
354
+ mask_url: str | None = None,
355
+ *,
356
+ height: int = 400,
357
+ ) -> str:
358
+ """
359
+ Create HTML/JS for NiiVue viewer.
360
+
361
+ Args:
362
+ volume_url: URL to volume NIfTI file
363
+ mask_url: Optional URL to mask NIfTI file
364
+ height: Viewer height in pixels
365
+
366
+ Returns:
367
+ HTML string with embedded NiiVue viewer
368
+ """
369
+ template = f"""
370
+ <div id="gl" style="width:100%; height:{height}px;"></div>
371
+ <script type="module">
372
+ import {{ Niivue }} from 'https://niivue.github.io/niivue/features/niivue.esm.js';
373
+ const nv = new Niivue({{ show3Dcrosshair: true }});
374
+ nv.attachToCanvas(document.getElementById('gl'));
375
+ const volumes = [{{ url: '{volume_url}' }}];
376
+ {'volumes.push({ url: "' + mask_url + '", colorMap: "red", opacity: 0.5 });' if mask_url else ''}
377
+ await nv.loadVolumes(volumes);
378
+ </script>
379
+ """
380
+ return template
381
+
382
+
383
+ def get_slice_at_max_lesion(
384
+ mask_path: Path,
385
+ orientation: str = "axial",
386
+ ) -> int:
387
+ """
388
+ Find slice index with maximum lesion area.
389
+
390
+ Useful for displaying the most informative slice.
391
+
392
+ Args:
393
+ mask_path: Path to lesion mask NIfTI
394
+ orientation: Slice orientation
395
+
396
+ Returns:
397
+ Slice index with maximum lesion area
398
+ """
399
+ ...
400
+ ```
401
+
402
+ ### `ui/components.py`
403
+
404
+ ```python
405
+ """Reusable UI components."""
406
+
407
+ from __future__ import annotations
408
+
409
+ import gradio as gr
410
+
411
+ from stroke_deepisles_demo.data import list_case_ids
412
+
413
+
414
+ def create_case_selector() -> gr.Dropdown:
415
+ """
416
+ Create a dropdown for selecting cases.
417
+
418
+ Returns:
419
+ Configured gr.Dropdown component
420
+ """
421
+ try:
422
+ case_ids = list_case_ids()
423
+ except Exception:
424
+ case_ids = ["Error loading cases"]
425
+
426
+ return gr.Dropdown(
427
+ choices=case_ids,
428
+ value=case_ids[0] if case_ids else None,
429
+ label="Select Case",
430
+ info="Choose a case from ISLES24-MR-Lite",
431
+ )
432
+
433
+
434
+ def create_results_display() -> dict[str, gr.components.Component]:
435
+ """
436
+ Create results display components.
437
+
438
+ Returns:
439
+ Dictionary of component name -> gr.Component
440
+ """
441
+ with gr.Group():
442
+ viewer = gr.Image(label="Segmentation Result", type="filepath")
443
+ metrics = gr.JSON(label="Metrics")
444
+ download = gr.File(label="Download Prediction")
445
+
446
+ return {
447
+ "viewer": viewer,
448
+ "metrics": metrics,
449
+ "download": download,
450
+ }
451
+
452
+
453
+ def create_settings_accordion() -> dict[str, gr.components.Component]:
454
+ """
455
+ Create expandable settings section.
456
+
457
+ Returns:
458
+ Dictionary of setting name -> gr.Component
459
+ """
460
+ with gr.Accordion("Advanced Settings", open=False):
461
+ fast_mode = gr.Checkbox(
462
+ value=True,
463
+ label="Fast Mode",
464
+ info="Use single model (faster, slightly less accurate)",
465
+ )
466
+ show_ground_truth = gr.Checkbox(
467
+ value=True,
468
+ label="Show Ground Truth",
469
+ info="Display ground truth mask if available",
470
+ )
471
+
472
+ return {
473
+ "fast_mode": fast_mode,
474
+ "show_ground_truth": show_ground_truth,
475
+ }
476
+ ```
477
+
478
+ ### Root `app.py` for HF Spaces
479
+
480
+ ```python
481
+ """Entry point for Hugging Face Spaces deployment."""
482
+
483
+ from stroke_deepisles_demo.ui.app import demo
484
+
485
+ if __name__ == "__main__":
486
+ demo.launch()
487
+ ```
488
+
489
+ ## hugging face spaces configuration
490
+
491
+ ### `README.md` header for Spaces
492
+
493
+ ```yaml
494
+ ---
495
+ title: Stroke DeepISLES Demo
496
+ emoji: 🧠
497
+ colorFrom: blue
498
+ colorTo: purple
499
+ sdk: gradio
500
+ sdk_version: 5.0.0
501
+ app_file: app.py
502
+ pinned: false
503
+ license: mit
504
+ ---
505
+ ```
506
+
507
+ ### `requirements.txt` for Spaces
508
+
509
+ ```
510
+ # Note: HF Spaces uses requirements.txt, not pyproject.toml
511
+ git+https://github.com/CloseChoice/datasets.git@feat/bids-loader-streaming-upload-fix
512
+ huggingface-hub>=0.25.0
513
+ nibabel>=5.2.0
514
+ numpy>=1.26.0
515
+ pydantic>=2.5.0
516
+ pydantic-settings>=2.1.0
517
+ gradio>=5.0.0
518
+ matplotlib>=3.8.0
519
+ ```
520
+
521
+ ## tdd plan
522
+
523
+ ### test file structure
524
+
525
+ ```
526
+ tests/
527
+ β”œβ”€β”€ ui/
528
+ β”‚ β”œβ”€β”€ __init__.py
529
+ β”‚ β”œβ”€β”€ test_viewer.py # Tests for visualization
530
+ β”‚ β”œβ”€β”€ test_components.py # Tests for UI components
531
+ β”‚ └── test_app.py # Smoke tests for app
532
+ ```
533
+
534
+ ### tests to write first (TDD order)
535
+
536
+ #### 1. `tests/ui/test_viewer.py` - Pure visualization functions
537
+
538
+ ```python
539
+ """Tests for viewer module."""
540
+
541
+ from __future__ import annotations
542
+
543
+ from pathlib import Path
544
+
545
+ import matplotlib
546
+ import matplotlib.pyplot as plt
547
+ import numpy as np
548
+ import pytest
549
+
550
+ matplotlib.use("Agg") # Non-interactive backend for tests
551
+
552
+ from stroke_deepisles_demo.ui.viewer import (
553
+ create_niivue_html,
554
+ get_slice_at_max_lesion,
555
+ render_3panel_view,
556
+ render_slice_comparison,
557
+ )
558
+
559
+
560
+ class TestRender3PanelView:
561
+ """Tests for render_3panel_view."""
562
+
563
+ def test_returns_matplotlib_figure(self, synthetic_nifti_3d: Path) -> None:
564
+ """Returns a matplotlib Figure object."""
565
+ fig = render_3panel_view(synthetic_nifti_3d)
566
+
567
+ assert isinstance(fig, plt.Figure)
568
+ plt.close(fig)
569
+
570
+ def test_has_three_axes(self, synthetic_nifti_3d: Path) -> None:
571
+ """Figure has 3 subplots (axial, coronal, sagittal)."""
572
+ fig = render_3panel_view(synthetic_nifti_3d)
573
+
574
+ assert len(fig.axes) == 3
575
+ plt.close(fig)
576
+
577
+ def test_overlay_mask_when_provided(
578
+ self, synthetic_nifti_3d: Path, temp_dir: Path
579
+ ) -> None:
580
+ """Overlays mask when mask_path provided."""
581
+ # Create a simple mask
582
+ import nibabel as nib
583
+
584
+ mask_data = np.zeros((10, 10, 10), dtype=np.uint8)
585
+ mask_data[4:6, 4:6, 4:6] = 1
586
+ mask_img = nib.Nifti1Image(mask_data, np.eye(4))
587
+ mask_path = temp_dir / "mask.nii.gz"
588
+ nib.save(mask_img, mask_path)
589
+
590
+ fig = render_3panel_view(synthetic_nifti_3d, mask_path=mask_path)
591
+
592
+ # Should not raise
593
+ assert fig is not None
594
+ plt.close(fig)
595
+
596
+
597
+ class TestRenderSliceComparison:
598
+ """Tests for render_slice_comparison."""
599
+
600
+ def test_comparison_without_ground_truth(
601
+ self, synthetic_nifti_3d: Path
602
+ ) -> None:
603
+ """Works when ground truth is None."""
604
+ fig = render_slice_comparison(
605
+ synthetic_nifti_3d,
606
+ synthetic_nifti_3d, # Use same as prediction for test
607
+ ground_truth_path=None,
608
+ )
609
+
610
+ assert isinstance(fig, plt.Figure)
611
+ plt.close(fig)
612
+
613
+ def test_comparison_with_ground_truth(
614
+ self, synthetic_nifti_3d: Path
615
+ ) -> None:
616
+ """Works when ground truth is provided."""
617
+ fig = render_slice_comparison(
618
+ synthetic_nifti_3d,
619
+ synthetic_nifti_3d,
620
+ ground_truth_path=synthetic_nifti_3d,
621
+ )
622
+
623
+ assert isinstance(fig, plt.Figure)
624
+ plt.close(fig)
625
+
626
+
627
+ class TestGetSliceAtMaxLesion:
628
+ """Tests for get_slice_at_max_lesion."""
629
+
630
+ def test_finds_slice_with_lesion(self, temp_dir: Path) -> None:
631
+ """Returns slice index where lesion is largest."""
632
+ import nibabel as nib
633
+
634
+ # Create mask with lesion at slice 7
635
+ mask_data = np.zeros((10, 10, 10), dtype=np.uint8)
636
+ mask_data[:, :, 7] = 1 # Full slice 7 is lesion
637
+
638
+ mask_img = nib.Nifti1Image(mask_data, np.eye(4))
639
+ mask_path = temp_dir / "mask.nii.gz"
640
+ nib.save(mask_img, mask_path)
641
+
642
+ slice_idx = get_slice_at_max_lesion(mask_path, orientation="axial")
643
+
644
+ assert slice_idx == 7
645
+
646
+ def test_returns_middle_for_empty_mask(self, temp_dir: Path) -> None:
647
+ """Returns middle slice when mask is empty."""
648
+ import nibabel as nib
649
+
650
+ mask_data = np.zeros((10, 10, 20), dtype=np.uint8)
651
+ mask_img = nib.Nifti1Image(mask_data, np.eye(4))
652
+ mask_path = temp_dir / "mask.nii.gz"
653
+ nib.save(mask_img, mask_path)
654
+
655
+ slice_idx = get_slice_at_max_lesion(mask_path, orientation="axial")
656
+
657
+ assert slice_idx == 10 # Middle of 20
658
+
659
+
660
+ class TestCreateNiivueHtml:
661
+ """Tests for create_niivue_html."""
662
+
663
+ def test_includes_volume_url(self) -> None:
664
+ """Generated HTML includes the volume URL."""
665
+ html = create_niivue_html("http://example.com/brain.nii.gz")
666
+
667
+ assert "http://example.com/brain.nii.gz" in html
668
+
669
+ def test_includes_mask_when_provided(self) -> None:
670
+ """Generated HTML includes mask URL when provided."""
671
+ html = create_niivue_html(
672
+ "http://example.com/brain.nii.gz",
673
+ mask_url="http://example.com/mask.nii.gz",
674
+ )
675
+
676
+ assert "http://example.com/mask.nii.gz" in html
677
+
678
+ def test_sets_height(self) -> None:
679
+ """Generated HTML respects height parameter."""
680
+ html = create_niivue_html(
681
+ "http://example.com/brain.nii.gz",
682
+ height=600,
683
+ )
684
+
685
+ assert "height:600px" in html
686
+ ```
687
+
688
+ #### 2. `tests/ui/test_app.py` - Smoke tests
689
+
690
+ ```python
691
+ """Smoke tests for Gradio app."""
692
+
693
+ from __future__ import annotations
694
+
695
+
696
+ def test_app_module_imports() -> None:
697
+ """App module imports without side effects."""
698
+ # This should not launch the app or make network calls
699
+ from stroke_deepisles_demo.ui import app
700
+
701
+ assert hasattr(app, "create_app")
702
+ assert hasattr(app, "demo")
703
+
704
+
705
+ def test_create_app_returns_blocks() -> None:
706
+ """create_app returns a gr.Blocks instance."""
707
+ import gradio as gr
708
+
709
+ from stroke_deepisles_demo.ui.app import create_app
710
+
711
+ app = create_app()
712
+
713
+ assert isinstance(app, gr.Blocks)
714
+
715
+
716
+ def test_viewer_module_imports() -> None:
717
+ """Viewer module imports without errors."""
718
+ from stroke_deepisles_demo.ui import viewer
719
+
720
+ assert hasattr(viewer, "render_3panel_view")
721
+ assert hasattr(viewer, "create_niivue_html")
722
+
723
+
724
+ def test_components_module_imports() -> None:
725
+ """Components module imports without errors."""
726
+ from stroke_deepisles_demo.ui import components
727
+
728
+ assert hasattr(components, "create_case_selector")
729
+ assert hasattr(components, "create_results_display")
730
+ ```
731
+
732
+ ### what to mock
733
+
734
+ - `list_case_ids()` in components - Avoid network during import
735
+ - Any data loading in app initialization
736
+
737
+ ### what to test for real
738
+
739
+ - Matplotlib figure generation
740
+ - NiiVue HTML string generation
741
+ - Slice finding algorithms
742
+ - Module imports (no network side effects)
743
+
744
+ ## "done" criteria
745
+
746
+ Phase 4 is complete when:
747
+
748
+ 1. All unit tests pass: `uv run pytest tests/ui/ -v`
749
+ 2. App launches locally: `uv run python -m stroke_deepisles_demo.ui.app`
750
+ 3. Can select a case, click "Run", see visualization
751
+ 4. Visualization shows DWI with predicted mask overlay
752
+ 5. Metrics (Dice score) displayed
753
+ 6. Type checking passes: `uv run mypy src/stroke_deepisles_demo/ui/`
754
+ 7. Ready for HF Spaces deployment (README header, requirements.txt)
755
+
756
+ ## implementation notes
757
+
758
+ - **NiiVue is primary** - Proven working in Tobias's Space, not "fragile"
759
+ - **Base64 data URLs** - Avoids file serving complexity, works in all environments
760
+ - **Lazy initialization** - Do NOT call `list_case_ids()` at module import time (causes network calls)
761
+ - **Test on HF Spaces early** - Verify WebGL works in their environment
762
+ - **Keep UI simple** - This is a demo, not a full application
763
+ - **Cache case list** - Avoid repeated HF Hub calls
764
+
765
+ ### avoiding import-time side effects
766
+
767
+ The reviewer correctly noted that `demo = create_app()` at module level triggers network calls. Fix:
768
+
769
+ ```python
770
+ # BAD - triggers network call on import
771
+ demo = create_app()
772
+
773
+ # GOOD - lazy initialization
774
+ _demo: gr.Blocks | None = None
775
+
776
+ def get_demo() -> gr.Blocks:
777
+ global _demo
778
+ if _demo is None:
779
+ _demo = create_app()
780
+ return _demo
781
+
782
+ # For Gradio CLI compatibility
783
+ demo = None # Set lazily
784
+
785
+ if __name__ == "__main__":
786
+ get_demo().launch()
787
+ ```
788
+
789
+ Or use a factory pattern in the root `app.py`:
790
+
791
+ ```python
792
+ # app.py (HF Spaces entry point)
793
+ from stroke_deepisles_demo.ui.app import create_app
794
+
795
+ demo = create_app() # Only called when this file is executed
796
+
797
+ if __name__ == "__main__":
798
+ demo.launch()
799
+ ```
800
+
801
+ ## dependencies to add
802
+
803
+ ```toml
804
+ # Add to pyproject.toml dependencies
805
+ "matplotlib>=3.8.0",
806
+ "fastapi>=0.115.0", # For API endpoints if using hybrid approach
807
+ "uvicorn[standard]>=0.32.0", # For local development
808
+ ```
809
+
810
+ ## reference implementation
811
+
812
+ Clone Tobias's working Space for reference:
813
+ ```
814
+ _reference_repos/bids-neuroimaging-space/
815
+ ```
816
+
817
+ Key file: `main.py` - Complete NiiVue + FastAPI implementation.
docs/specs/06-phase-5-polish.md ADDED
@@ -0,0 +1,667 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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
+ ```