VibecoderMcSwaggins commited on
Commit
bfe80c5
·
unverified ·
1 Parent(s): d77e99f

feat(phase-5): polish, observability, and documentation (#6)

Browse files

* feat(phase-5): add polish, observability, and documentation

Implements Phase 5 deliverables:
- Structured logging with centralized setup (src/stroke_deepisles_demo/core/logging.py)
- Enhanced pydantic-settings configuration (src/stroke_deepisles_demo/core/config.py)
- Complete documentation (README, CONTRIBUTING, CHANGELOG, .env.example, docs/guides/)
- GitHub Actions CI pipeline (.github/workflows/ci.yml)
- Integration of centralized logging into pipeline, UI, and inference modules

Quality:
- 96 tests passing (8 new tests for logging and config)
- Coverage: 82%
- ruff + mypy clean

* fix: address CodeRabbit review feedback for Phase 5

Security:
- docker.py: Redact environment variable values in debug logs to prevent
leaking secrets (e.g., HF tokens)

Bug fixes:
- logging.py: Fix get_logger() to avoid double-prefixing when __name__
already includes package name (was: stroke_deepisles_demo.stroke_deepisles_demo.module)

Documentation:
- configuration.md: Add missing STROKE_DEMO_TEMP_DIR to env var table
- configuration.md: Fix example to use get_settings() instead of imported
settings (which doesn't update after reload_settings())
- README.md: Format bare URL as proper markdown link (MD034)

.env.example ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logging
2
+ STROKE_DEMO_LOG_LEVEL=INFO
3
+ STROKE_DEMO_LOG_FORMAT=simple
4
+
5
+ # HuggingFace
6
+ STROKE_DEMO_HF_DATASET_ID=YongchengYAO/ISLES24-MR-Lite
7
+ # STROKE_DEMO_HF_TOKEN=hf_...
8
+
9
+ # DeepISLES
10
+ STROKE_DEMO_DEEPISLES_DOCKER_IMAGE=isleschallenge/deepisles
11
+ STROKE_DEMO_DEEPISLES_FAST_MODE=true
12
+ STROKE_DEMO_DEEPISLES_TIMEOUT_SECONDS=1800
13
+ STROKE_DEMO_DEEPISLES_USE_GPU=true
14
+
15
+ # Paths
16
+ STROKE_DEMO_RESULTS_DIR=./results
17
+ # STROKE_DEMO_TEMP_DIR=/tmp/custom_temp
18
+
19
+ # UI
20
+ STROKE_DEMO_GRADIO_SERVER_NAME=0.0.0.0
21
+ STROKE_DEMO_GRADIO_SERVER_PORT=7860
22
+ STROKE_DEMO_GRADIO_SHARE=false
.github/workflows/ci.yml ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ lint:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - name: Install uv
16
+ uses: astral-sh/setup-uv@v4
17
+
18
+ - name: Set up Python
19
+ run: uv python install 3.12
20
+
21
+ - name: Install dependencies
22
+ run: uv sync
23
+
24
+ - name: Lint with ruff
25
+ run: uv run ruff check .
26
+
27
+ - name: Check formatting
28
+ run: uv run ruff format --check .
29
+
30
+ typecheck:
31
+ runs-on: ubuntu-latest
32
+ steps:
33
+ - uses: actions/checkout@v4
34
+
35
+ - name: Install uv
36
+ uses: astral-sh/setup-uv@v4
37
+
38
+ - name: Set up Python
39
+ run: uv python install 3.12
40
+
41
+ - name: Install dependencies
42
+ run: uv sync
43
+
44
+ - name: Type check with mypy
45
+ run: uv run mypy src/
46
+
47
+ test:
48
+ runs-on: ubuntu-latest
49
+ steps:
50
+ - uses: actions/checkout@v4
51
+
52
+ - name: Install uv
53
+ uses: astral-sh/setup-uv@v4
54
+
55
+ - name: Set up Python
56
+ run: uv python install 3.12
57
+
58
+ - name: Install dependencies
59
+ run: uv sync
60
+
61
+ - name: Run tests
62
+ run: uv run pytest --cov --cov-report=xml
63
+
64
+ - name: Upload coverage
65
+ uses: codecov/codecov-action@v4
66
+ with:
67
+ files: ./coverage.xml
68
+ token: ${{ secrets.CODECOV_TOKEN }}
69
+
70
+ integration:
71
+ runs-on: ubuntu-latest
72
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
73
+ steps:
74
+ - uses: actions/checkout@v4
75
+
76
+ - name: Install uv
77
+ uses: astral-sh/setup-uv@v4
78
+
79
+ - name: Set up Python
80
+ run: uv python install 3.12
81
+
82
+ - name: Install dependencies
83
+ run: uv sync
84
+
85
+ - name: Run integration tests
86
+ run: uv run pytest -m integration --timeout=600
CHANGELOG.md ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+ - **Phase 5**: Polish, Observability, and Documentation
12
+ - Structured logging via `stroke_deepisles_demo.core.logging`.
13
+ - Enhanced configuration via `pydantic-settings`.
14
+ - Comprehensive documentation (README, CONTRIBUTING, guides).
15
+ - GitHub Actions CI pipeline.
16
+
17
+ - **Phase 4**: Gradio UI and Visualization
18
+ - Interactive Gradio application (`ui/app.py`).
19
+ - NiiVue integration for 3D/multi-planar visualization.
20
+ - Matplotlib slice comparison plots.
21
+
22
+ - **Phase 3**: End-to-End Pipeline
23
+ - `PipelineResult` and `run_pipeline_on_case`.
24
+ - Metrics calculation (Dice score, Volume).
25
+ - CLI (`stroke-demo`) with `list` and `run` commands.
26
+
27
+ - **Phase 2**: DeepISLES Docker Integration
28
+ - Wrapper for DeepISLES Docker container.
29
+ - Automatic GPU detection and fallback.
30
+ - Input/Output validation and staging.
31
+
32
+ - **Phase 1**: Data Access Layer
33
+ - Integration with HuggingFace Datasets (ISLES24-MR-Lite).
34
+ - Local NIfTI file adapter.
35
+ - Lazy loading of large neuroimaging files.
36
+
37
+ - **Phase 0**: Repository Bootstrap
38
+ - Project structure with `uv` and `hatchling`.
39
+ - Strict typing with `mypy`.
40
+ - Linting/Formatting with `ruff`.
41
+ - Testing with `pytest`.
CONTRIBUTING.md ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing to stroke-deepisles-demo
2
+
3
+ Thank you for your interest in contributing!
4
+
5
+ ## Development Setup
6
+
7
+ 1. **Clone the repository**
8
+ ```bash
9
+ git clone https://github.com/The-Obstacle-Is-The-Way/stroke-deepisles-demo.git
10
+ cd stroke-deepisles-demo
11
+ ```
12
+
13
+ 2. **Install uv** (if not already installed)
14
+ ```bash
15
+ curl -LsSf https://astral.sh/uv/install.sh | sh
16
+ ```
17
+
18
+ 3. **Install dependencies**
19
+ ```bash
20
+ uv sync
21
+ ```
22
+
23
+ 4. **Install pre-commit hooks**
24
+ ```bash
25
+ uv run pre-commit install
26
+ ```
27
+
28
+ ## Running Tests
29
+
30
+ ```bash
31
+ # All tests (excluding integration)
32
+ uv run pytest
33
+
34
+ # With coverage
35
+ uv run pytest --cov
36
+
37
+ # Integration tests (requires Docker)
38
+ uv run pytest -m integration
39
+
40
+ # Slow tests (requires Docker + DeepISLES image)
41
+ uv run pytest -m "integration and slow"
42
+ ```
43
+
44
+ ## Code Quality
45
+
46
+ ```bash
47
+ # Lint
48
+ uv run ruff check .
49
+
50
+ # Format
51
+ uv run ruff format .
52
+
53
+ # Type check
54
+ uv run mypy src/
55
+ ```
56
+
57
+ ## Project Structure
58
+
59
+ ```
60
+ src/stroke_deepisles_demo/
61
+ ├── core/ # Shared utilities (config, types, exceptions)
62
+ ├── data/ # HF dataset loading and case management
63
+ ├── inference/ # DeepISLES Docker integration
64
+ ├── ui/ # Gradio application
65
+ ├── pipeline.py # End-to-end orchestration
66
+ └── metrics.py # Evaluation metrics
67
+ ```
68
+
69
+ ## Pull Request Process
70
+
71
+ 1. Create a feature branch from `main`
72
+ 2. Write tests for new functionality
73
+ 3. Ensure all tests pass and code quality checks pass
74
+ 4. Update documentation if needed
75
+ 5. Submit PR with clear description
76
+
77
+ ## Code Style
78
+
79
+ - Type hints on all functions
80
+ - Docstrings in Google style
81
+ - Keep functions focused and small
82
+ - Prefer explicit over implicit
README.md CHANGED
@@ -1,2 +1,93 @@
1
- # stroke-deepisles-demo
2
- Demo: run DeepISLES ischemic stroke segmentation on BIDS/HF data via arc-bids (research only, not for clinical use).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stroke DeepISLES Demo
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/release/python-3110/)
5
+ [![Code style: ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
6
+ [![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/)
7
+
8
+ A demonstration pipeline and UI for ischemic stroke lesion segmentation using **DeepISLES** and **ISLES24-MR-Lite** data.
9
+
10
+ This project provides a complete end-to-end workflow:
11
+ 1. **Data Loading**: Lazy-loading of NIfTI neuroimaging data from HuggingFace.
12
+ 2. **Inference**: Running DeepISLES segmentation (SEALS or Ensemble) via Docker.
13
+ 3. **Visualization**: Interactive 3D and multi-planar viewing with NiiVue in Gradio.
14
+
15
+ > **Disclaimer**: This software is for research and demonstration purposes only. It is not intended for clinical use.
16
+
17
+ ## Features
18
+
19
+ - 🧠 **State-of-the-Art Segmentation**: Uses DeepISLES (ISLES'22 winner) for accurate lesion segmentation.
20
+ - ☁️ **Cloud-Native Data**: Streams data directly from HuggingFace Datasets (no massive downloads).
21
+ - 🐳 **Dockerized Inference**: Encapsulates complex deep learning dependencies in a reproducible container.
22
+ - 🖥️ **Interactive UI**: Gradio-based web interface with 3D rendering (NiiVue).
23
+ - ⚙️ **Production Ready**: Type-safe, tested, and configurable via environment variables.
24
+
25
+ ## Quickstart
26
+
27
+ ### Prerequisites
28
+
29
+ - Python 3.11+
30
+ - Docker (for inference)
31
+ - [uv](https://github.com/astral-sh/uv) (recommended) or pip
32
+
33
+ ### Installation
34
+
35
+ ```bash
36
+ # Clone the repository
37
+ git clone https://github.com/The-Obstacle-Is-The-Way/stroke-deepisles-demo.git
38
+ cd stroke-deepisles-demo
39
+
40
+ # Install dependencies
41
+ uv sync
42
+ ```
43
+
44
+ ### Running the Demo
45
+
46
+ 1. **Pull the Docker image** (first time only):
47
+ ```bash
48
+ docker pull isleschallenge/deepisles
49
+ ```
50
+
51
+ 2. **Launch the UI**:
52
+ ```bash
53
+ uv run python -m stroke_deepisles_demo.ui.app
54
+ ```
55
+ Open [http://localhost:7860](http://localhost:7860) in your browser.
56
+
57
+ 3. **Run via CLI**:
58
+ ```bash
59
+ # List cases
60
+ uv run stroke-demo list
61
+
62
+ # Run segmentation on a specific case
63
+ uv run stroke-demo run --case sub-stroke0001
64
+ ```
65
+
66
+ ## Documentation
67
+
68
+ - [Quickstart Guide](docs/guides/quickstart.md)
69
+ - [Configuration](docs/guides/configuration.md)
70
+ - [Deployment](docs/guides/deployment.md)
71
+ - [Contributing](CONTRIBUTING.md)
72
+
73
+ ## Architecture
74
+
75
+ ```mermaid
76
+ graph TD
77
+ HF[HuggingFace Hub] -->|Stream NIfTI| Loader[Data Loader]
78
+ Loader -->|Stage Files| Staging[Staging Dir]
79
+ Staging -->|Mount Volume| Docker[DeepISLES Container]
80
+ Docker -->|Inference| Results[Prediction Mask]
81
+ Results -->|Load| Metrics[Metrics (Dice)]
82
+ Results -->|Render| UI[Gradio UI / NiiVue]
83
+ ```
84
+
85
+ ## License
86
+
87
+ MIT License. See [LICENSE](LICENSE) for details.
88
+
89
+ ## Acknowledgements
90
+
91
+ - [DeepISLES](https://github.com/ezequieldlrosa/DeepIsles) team for the segmentation model.
92
+ - [ISLES24](https://www.isles-challenge.org/) challenge for the dataset.
93
+ - [NiiVue](https://github.com/niivue/niivue) for the web-based neuroimaging viewer.
docs/guides/configuration.md ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Configuration
2
+
3
+ All settings can be configured via environment variables.
4
+
5
+ ## Environment Variables
6
+
7
+ | Variable | Default | Description |
8
+ |----------|---------|-------------|
9
+ | `STROKE_DEMO_LOG_LEVEL` | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
10
+ | `STROKE_DEMO_LOG_FORMAT` | `simple` | Log format (simple, detailed, json) |
11
+ | `STROKE_DEMO_HF_DATASET_ID` | `YongchengYAO/ISLES24-MR-Lite` | HuggingFace dataset ID |
12
+ | `STROKE_DEMO_HF_CACHE_DIR` | `None` | Custom HF cache directory |
13
+ | `STROKE_DEMO_HF_TOKEN` | `None` | HuggingFace API token (for private datasets) |
14
+ | `STROKE_DEMO_DEEPISLES_DOCKER_IMAGE` | `isleschallenge/deepisles` | DeepISLES Docker image |
15
+ | `STROKE_DEMO_DEEPISLES_FAST_MODE` | `true` | Use single-model mode |
16
+ | `STROKE_DEMO_DEEPISLES_TIMEOUT_SECONDS` | `1800` | Inference timeout |
17
+ | `STROKE_DEMO_DEEPISLES_USE_GPU` | `true` | Use GPU acceleration |
18
+ | `STROKE_DEMO_TEMP_DIR` | `None` | Scratch directory for intermediate files |
19
+ | `STROKE_DEMO_RESULTS_DIR` | `./results` | Directory for output files |
20
+ | `STROKE_DEMO_GRADIO_SERVER_NAME` | `0.0.0.0` | Gradio server host |
21
+ | `STROKE_DEMO_GRADIO_SERVER_PORT` | `7860` | Gradio server port |
22
+ | `STROKE_DEMO_GRADIO_SHARE` | `false` | Create public Gradio link |
23
+
24
+ ## Using .env File
25
+
26
+ Create a `.env` file in the project root:
27
+
28
+ ```bash
29
+ STROKE_DEMO_LOG_LEVEL=DEBUG
30
+ STROKE_DEMO_DEEPISLES_USE_GPU=false
31
+ STROKE_DEMO_RESULTS_DIR=/data/results
32
+ ```
33
+
34
+ ## Programmatic Configuration
35
+
36
+ ```python
37
+ from stroke_deepisles_demo.core.config import get_settings, reload_settings
38
+ import os
39
+
40
+ # Check current settings
41
+ print(get_settings().log_level)
42
+
43
+ # Override via environment
44
+ os.environ["STROKE_DEMO_LOG_LEVEL"] = "DEBUG"
45
+ reload_settings()
46
+ print(get_settings().log_level) # DEBUG
47
+ ```
docs/guides/deployment.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deployment
2
+
3
+ The demo is designed to be deployed on Hugging Face Spaces.
4
+
5
+ ## Hugging Face Spaces
6
+
7
+ 1. **Create a Space**: Go to [huggingface.co/spaces](https://huggingface.co/spaces) and create a new Space.
8
+ * **SDK**: Docker (Recommended for custom dependencies) or Gradio
9
+ * **Hardware**: GPU is recommended for DeepISLES inference.
10
+
11
+ 2. **Configure Dockerfile (if using Docker SDK)**:
12
+ Ensure the Dockerfile installs Python 3.11, uv, and pulls the DeepISLES image (or handles it appropriately, though Spaces might restrict running Docker-in-Docker).
13
+
14
+ *Note*: Since DeepISLES runs as a Docker container, running it inside a HF Space (which is a container) requires Docker-in-Docker (DinD) or a compatible runtime. If DinD is not supported, you might need to adapt the inference to run directly in the python environment if possible (DeepISLES source code integration instead of Docker wrapper), but this project wraps the Docker image.
15
+
16
+ **Standard Deployment (Gradio SDK)**:
17
+ The project includes `app.py` at the root for standard Gradio deployment. However, checking `requirements.txt` or `pyproject.toml` is needed.
18
+
19
+ For standard Gradio Spaces, you need to ensure `docker` command is available if you stick to the current architecture. Most HF Spaces do not support running `docker run`.
20
+
21
+ **Alternative**: Use a VM (AWS/GCP/Azure) with Docker installed.
22
+
23
+ ## Local Deployment
24
+
25
+ 1. **Build/Pull**:
26
+ ```bash
27
+ docker pull isleschallenge/deepisles
28
+ ```
29
+
30
+ 2. **Run App**:
31
+ ```bash
32
+ uv run python -m stroke_deepisles_demo.ui.app
33
+ ```
34
+
35
+ ## Environment Variables
36
+
37
+ Configure the deployment using environment variables (Secrets in HF Spaces):
38
+ - `STROKE_DEMO_HF_TOKEN`: Read-only token for accessing datasets if private.
39
+ - `STROKE_DEMO_DEEPISLES_USE_GPU`: Set to `false` if deploying on CPU-only instance.
docs/guides/quickstart.md ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Quickstart
2
+
3
+ Get started with stroke-deepisles-demo in 5 minutes.
4
+
5
+ ## Prerequisites
6
+
7
+ - Python 3.11+
8
+ - Docker (for DeepISLES inference)
9
+ - ~10GB disk space (for Docker image and datasets)
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ # Clone
15
+ git clone https://github.com/The-Obstacle-Is-The-Way/stroke-deepisles-demo.git
16
+ cd stroke-deepisles-demo
17
+
18
+ # Install
19
+ uv sync
20
+ ```
21
+
22
+ ## Pull DeepISLES Docker Image
23
+
24
+ ```bash
25
+ docker pull isleschallenge/deepisles
26
+ ```
27
+
28
+ ## Run Locally
29
+
30
+ ### Option 1: Gradio UI
31
+
32
+ ```bash
33
+ uv run python -m stroke_deepisles_demo.ui.app
34
+ # Open http://localhost:7860
35
+ ```
36
+
37
+ ### Option 2: CLI
38
+
39
+ ```bash
40
+ # List available cases
41
+ uv run stroke-demo list
42
+
43
+ # Run on a specific case
44
+ uv run stroke-demo run --case sub-stroke0001 --fast
45
+ ```
46
+
47
+ ### Option 3: Python API
48
+
49
+ ```python
50
+ from stroke_deepisles_demo.pipeline import run_pipeline_on_case
51
+
52
+ result = run_pipeline_on_case("sub-stroke0001", fast=True)
53
+ print(f"Dice score: {result.dice_score:.3f}")
54
+ print(f"Prediction: {result.prediction_mask}")
55
+ ```
56
+
57
+ ## Configuration
58
+
59
+ Set environment variables or create a `.env` file:
60
+
61
+ ```bash
62
+ # .env
63
+ STROKE_DEMO_LOG_LEVEL=DEBUG
64
+ STROKE_DEMO_DEEPISLES_USE_GPU=false # If no GPU available
65
+ ```
66
+
67
+ See [Configuration Guide](configuration.md) for all options.
src/stroke_deepisles_demo/core/config.py CHANGED
@@ -2,28 +2,76 @@
2
 
3
  from __future__ import annotations
4
 
 
 
 
 
5
  from pydantic_settings import BaseSettings, SettingsConfigDict
6
 
7
 
8
  class Settings(BaseSettings):
9
- """Application settings loaded from environment variables."""
 
 
 
 
 
 
 
 
 
10
 
11
  model_config = SettingsConfigDict(
12
  env_prefix="STROKE_DEMO_",
13
  env_file=".env",
14
  env_file_encoding="utf-8",
 
15
  )
16
 
 
 
 
 
17
  # HuggingFace
18
  hf_dataset_id: str = "YongchengYAO/ISLES24-MR-Lite"
19
- hf_cache_dir: str | None = None
 
20
 
21
  # DeepISLES
22
  deepisles_docker_image: str = "isleschallenge/deepisles"
23
- deepisles_fast_mode: bool = True
 
 
24
 
25
  # Paths
26
- temp_dir: str | None = None
 
 
 
 
 
 
27
 
 
 
 
 
 
 
 
28
 
 
 
29
  settings = Settings()
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ from pydantic import Field, field_validator
9
  from pydantic_settings import BaseSettings, SettingsConfigDict
10
 
11
 
12
  class Settings(BaseSettings):
13
+ """
14
+ Application settings loaded from environment variables.
15
+
16
+ All settings can be overridden via environment variables with
17
+ the STROKE_DEMO_ prefix.
18
+
19
+ Example:
20
+ export STROKE_DEMO_LOG_LEVEL=DEBUG
21
+ export STROKE_DEMO_HF_DATASET_ID=my/dataset
22
+ """
23
 
24
  model_config = SettingsConfigDict(
25
  env_prefix="STROKE_DEMO_",
26
  env_file=".env",
27
  env_file_encoding="utf-8",
28
+ extra="ignore",
29
  )
30
 
31
+ # Logging
32
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
33
+ log_format: Literal["simple", "detailed", "json"] = "simple"
34
+
35
  # HuggingFace
36
  hf_dataset_id: str = "YongchengYAO/ISLES24-MR-Lite"
37
+ hf_cache_dir: Path | None = None
38
+ hf_token: str | None = Field(default=None, repr=False) # Hidden from logs
39
 
40
  # DeepISLES
41
  deepisles_docker_image: str = "isleschallenge/deepisles"
42
+ deepisles_fast_mode: bool = True # SEALS-only (ISLES'22 winner, no FLAIR needed)
43
+ deepisles_timeout_seconds: int = 1800 # 30 minutes
44
+ deepisles_use_gpu: bool = True
45
 
46
  # Paths
47
+ temp_dir: Path | None = None
48
+ results_dir: Path = Path("./results")
49
+
50
+ # UI
51
+ gradio_server_name: str = "0.0.0.0"
52
+ gradio_server_port: int = 7860
53
+ gradio_share: bool = False
54
 
55
+ @field_validator("results_dir", mode="before")
56
+ @classmethod
57
+ def ensure_results_dir_exists(cls, v: Path | str) -> Path:
58
+ """Create results directory if it doesn't exist."""
59
+ path = Path(v)
60
+ path.mkdir(parents=True, exist_ok=True)
61
+ return path
62
 
63
+
64
+ # Global settings instance
65
  settings = Settings()
66
+
67
+
68
+ def get_settings() -> Settings:
69
+ """Get the current settings instance."""
70
+ return settings
71
+
72
+
73
+ def reload_settings() -> Settings:
74
+ """Reload settings from environment (useful for testing)."""
75
+ global settings
76
+ settings = Settings()
77
+ return settings
src/stroke_deepisles_demo/core/logging.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Centralized logging configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from typing import Literal
8
+
9
+ LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
10
+
11
+
12
+ def setup_logging(
13
+ level: LogLevel = "INFO",
14
+ *,
15
+ format_style: Literal["simple", "detailed", "json"] = "simple",
16
+ ) -> None:
17
+ """
18
+ Configure logging for the application.
19
+
20
+ Args:
21
+ level: Minimum log level
22
+ format_style: Output format style
23
+
24
+ Example:
25
+ >>> setup_logging("DEBUG", format_style="detailed")
26
+ """
27
+ formats = {
28
+ "simple": "%(levelname)s: %(message)s",
29
+ "detailed": "%(asctime)s | %(name)s | %(levelname)s | %(message)s",
30
+ "json": '{"time": "%(asctime)s", "name": "%(name)s", "level": "%(levelname)s", "message": "%(message)s"}',
31
+ }
32
+
33
+ logging.basicConfig(
34
+ level=getattr(logging, level),
35
+ format=formats[format_style],
36
+ stream=sys.stderr,
37
+ force=True,
38
+ )
39
+
40
+ # Reduce noise from libraries
41
+ logging.getLogger("urllib3").setLevel(logging.WARNING)
42
+ logging.getLogger("httpx").setLevel(logging.WARNING)
43
+ logging.getLogger("datasets").setLevel(logging.WARNING)
44
+
45
+
46
+ def get_logger(name: str) -> logging.Logger:
47
+ """
48
+ Get a logger for a module.
49
+
50
+ Args:
51
+ name: Logger name (typically __name__)
52
+
53
+ Returns:
54
+ Configured logger instance
55
+ """
56
+ # Avoid double-prefixing when __name__ already includes package name
57
+ if name.startswith("stroke_deepisles_demo."):
58
+ return logging.getLogger(name)
59
+ return logging.getLogger(f"stroke_deepisles_demo.{name}")
src/stroke_deepisles_demo/inference/deepisles.py CHANGED
@@ -7,6 +7,7 @@ from dataclasses import dataclass
7
  from typing import TYPE_CHECKING
8
 
9
  from stroke_deepisles_demo.core.exceptions import DeepISLESError, MissingInputError
 
10
  from stroke_deepisles_demo.inference.docker import (
11
  DockerRunResult,
12
  ensure_gpu_available_if_requested,
@@ -16,6 +17,8 @@ from stroke_deepisles_demo.inference.docker import (
16
  if TYPE_CHECKING:
17
  from pathlib import Path
18
 
 
 
19
  # Constants
20
  DEEPISLES_IMAGE = "isleschallenge/deepisles"
21
  EXPECTED_INPUT_FILES = ["dwi.nii.gz", "adc.nii.gz"]
 
7
  from typing import TYPE_CHECKING
8
 
9
  from stroke_deepisles_demo.core.exceptions import DeepISLESError, MissingInputError
10
+ from stroke_deepisles_demo.core.logging import get_logger
11
  from stroke_deepisles_demo.inference.docker import (
12
  DockerRunResult,
13
  ensure_gpu_available_if_requested,
 
17
  if TYPE_CHECKING:
18
  from pathlib import Path
19
 
20
+ logger = get_logger(__name__)
21
+
22
  # Constants
23
  DEEPISLES_IMAGE = "isleschallenge/deepisles"
24
  EXPECTED_INPUT_FILES = ["dwi.nii.gz", "adc.nii.gz"]
src/stroke_deepisles_demo/inference/docker.py CHANGED
@@ -12,11 +12,14 @@ from stroke_deepisles_demo.core.exceptions import (
12
  DockerGPUNotAvailableError,
13
  DockerNotAvailableError,
14
  )
 
15
 
16
  if TYPE_CHECKING:
17
  from collections.abc import Sequence
18
  from pathlib import Path
19
 
 
 
20
 
21
  @dataclass(frozen=True)
22
  class DockerRunResult:
@@ -123,15 +126,18 @@ def pull_image_if_missing(image: str, *, timeout: float = 600) -> bool:
123
  check=False,
124
  )
125
  if result.returncode == 0:
 
126
  return False # Image already present
127
 
128
  # Pull the image
 
129
  subprocess.run(
130
  ["docker", "pull", image],
131
  capture_output=True,
132
  timeout=timeout,
133
  check=True,
134
  )
 
135
  return True
136
 
137
 
@@ -241,6 +247,19 @@ def run_container(
241
  )
242
 
243
  start_time = time.time()
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  result = subprocess.run(
245
  cmd,
246
  capture_output=True,
@@ -250,6 +269,13 @@ def run_container(
250
  )
251
  elapsed = time.time() - start_time
252
 
 
 
 
 
 
 
 
253
  return DockerRunResult(
254
  exit_code=result.returncode,
255
  stdout=result.stdout,
 
12
  DockerGPUNotAvailableError,
13
  DockerNotAvailableError,
14
  )
15
+ from stroke_deepisles_demo.core.logging import get_logger
16
 
17
  if TYPE_CHECKING:
18
  from collections.abc import Sequence
19
  from pathlib import Path
20
 
21
+ logger = get_logger(__name__)
22
+
23
 
24
  @dataclass(frozen=True)
25
  class DockerRunResult:
 
126
  check=False,
127
  )
128
  if result.returncode == 0:
129
+ logger.debug("Docker image %s already present", image)
130
  return False # Image already present
131
 
132
  # Pull the image
133
+ logger.info("Pulling Docker image %s (this may take a while)", image)
134
  subprocess.run(
135
  ["docker", "pull", image],
136
  capture_output=True,
137
  timeout=timeout,
138
  check=True,
139
  )
140
+ logger.info("Successfully pulled Docker image %s", image)
141
  return True
142
 
143
 
 
247
  )
248
 
249
  start_time = time.time()
250
+ # Redact environment variable values to avoid leaking secrets in logs
251
+ redacted_cmd: list[str] = []
252
+ skip_next = False
253
+ for arg in cmd:
254
+ if skip_next:
255
+ redacted_cmd.append("***")
256
+ skip_next = False
257
+ elif arg == "-e":
258
+ redacted_cmd.append(arg)
259
+ skip_next = True
260
+ else:
261
+ redacted_cmd.append(arg)
262
+ logger.debug("Running container: %s", " ".join(redacted_cmd))
263
  result = subprocess.run(
264
  cmd,
265
  capture_output=True,
 
269
  )
270
  elapsed = time.time() - start_time
271
 
272
+ if result.returncode != 0:
273
+ logger.error(
274
+ "Container execution failed (code %d). stderr: %s", result.returncode, result.stderr
275
+ )
276
+ else:
277
+ logger.info("Container execution completed in %.2fs", elapsed)
278
+
279
  return DockerRunResult(
280
  exit_code=result.returncode,
281
  stdout=result.stdout,
src/stroke_deepisles_demo/pipeline.py CHANGED
@@ -2,7 +2,6 @@
2
 
3
  from __future__ import annotations
4
 
5
- import logging
6
  import shutil
7
  import statistics
8
  import tempfile
@@ -12,6 +11,7 @@ from pathlib import Path
12
  from typing import TYPE_CHECKING
13
 
14
  from stroke_deepisles_demo import metrics
 
15
  from stroke_deepisles_demo.data import load_isles_dataset, stage_case_for_deepisles
16
  from stroke_deepisles_demo.inference import run_deepisles_on_folder
17
 
@@ -20,7 +20,7 @@ if TYPE_CHECKING:
20
 
21
  from stroke_deepisles_demo.core.types import CaseFiles
22
 
23
- logger = logging.getLogger(__name__)
24
 
25
 
26
  @dataclass(frozen=True)
 
2
 
3
  from __future__ import annotations
4
 
 
5
  import shutil
6
  import statistics
7
  import tempfile
 
11
  from typing import TYPE_CHECKING
12
 
13
  from stroke_deepisles_demo import metrics
14
+ from stroke_deepisles_demo.core.logging import get_logger
15
  from stroke_deepisles_demo.data import load_isles_dataset, stage_case_for_deepisles
16
  from stroke_deepisles_demo.inference import run_deepisles_on_folder
17
 
 
20
 
21
  from stroke_deepisles_demo.core.types import CaseFiles
22
 
23
+ logger = get_logger(__name__)
24
 
25
 
26
  @dataclass(frozen=True)
src/stroke_deepisles_demo/ui/app.py CHANGED
@@ -2,12 +2,12 @@
2
 
3
  from __future__ import annotations
4
 
5
- import logging
6
  from typing import Any
7
 
8
  import gradio as gr
9
- from matplotlib.figure import Figure # noqa: TC002 - needed at runtime for Gradio
10
 
 
11
  from stroke_deepisles_demo.pipeline import run_pipeline_on_case
12
  from stroke_deepisles_demo.ui.components import (
13
  create_case_selector,
@@ -20,7 +20,7 @@ from stroke_deepisles_demo.ui.viewer import (
20
  render_slice_comparison,
21
  )
22
 
23
- logger = logging.getLogger(__name__)
24
 
25
 
26
  def run_segmentation(
 
2
 
3
  from __future__ import annotations
4
 
 
5
  from typing import Any
6
 
7
  import gradio as gr
8
+ from matplotlib.figure import Figure # noqa: TC002
9
 
10
+ from stroke_deepisles_demo.core.logging import get_logger
11
  from stroke_deepisles_demo.pipeline import run_pipeline_on_case
12
  from stroke_deepisles_demo.ui.components import (
13
  create_case_selector,
 
20
  render_slice_comparison,
21
  )
22
 
23
+ logger = get_logger(__name__)
24
 
25
 
26
  def run_segmentation(
src/stroke_deepisles_demo/ui/components.py CHANGED
@@ -2,13 +2,12 @@
2
 
3
  from __future__ import annotations
4
 
5
- import logging
6
-
7
  import gradio as gr
8
 
 
9
  from stroke_deepisles_demo.data import list_case_ids
10
 
11
- logger = logging.getLogger(__name__)
12
 
13
 
14
  def create_case_selector() -> gr.Dropdown:
 
2
 
3
  from __future__ import annotations
4
 
 
 
5
  import gradio as gr
6
 
7
+ from stroke_deepisles_demo.core.logging import get_logger
8
  from stroke_deepisles_demo.data import list_case_ids
9
 
10
+ logger = get_logger(__name__)
11
 
12
 
13
  def create_case_selector() -> gr.Dropdown:
tests/core/test_config.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ import pytest
10
+
11
+ from stroke_deepisles_demo.core.config import Settings, reload_settings
12
+
13
+
14
+ class TestSettings:
15
+ """Tests for Settings."""
16
+
17
+ def test_default_values(self) -> None:
18
+ """Has sensible defaults."""
19
+ settings = Settings()
20
+ assert settings.log_level == "INFO"
21
+ assert settings.hf_dataset_id == "YongchengYAO/ISLES24-MR-Lite"
22
+ assert settings.deepisles_timeout_seconds == 1800
23
+ assert settings.results_dir == Path("./results")
24
+
25
+ def test_env_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
26
+ """Environment variables override defaults."""
27
+ monkeypatch.setenv("STROKE_DEMO_LOG_LEVEL", "DEBUG")
28
+ monkeypatch.setenv("STROKE_DEMO_DEEPISLES_TIMEOUT_SECONDS", "60")
29
+
30
+ settings = Settings()
31
+ assert settings.log_level == "DEBUG"
32
+ assert settings.deepisles_timeout_seconds == 60
33
+
34
+ def test_hf_token_hidden_from_repr(self) -> None:
35
+ """HF token is not visible in repr."""
36
+ settings = Settings(hf_token="secret123")
37
+ assert "secret123" not in repr(settings)
38
+
39
+ def test_results_dir_created(self, tmp_path: Path) -> None:
40
+ """Results directory is created if it doesn't exist."""
41
+ # This test relies on the validator running during instantiation
42
+ new_dir = tmp_path / "new_results"
43
+ assert not new_dir.exists()
44
+
45
+ Settings(results_dir=new_dir)
46
+ assert new_dir.exists()
47
+
48
+ def test_reload_settings(self, monkeypatch: pytest.MonkeyPatch) -> None:
49
+ """Test that reload_settings updates the global instance."""
50
+ from stroke_deepisles_demo.core import config
51
+
52
+ # Set env var
53
+ monkeypatch.setenv("STROKE_DEMO_LOG_LEVEL", "ERROR")
54
+
55
+ # Reload
56
+ new_settings = reload_settings()
57
+
58
+ assert new_settings.log_level == "ERROR"
59
+ assert config.settings.log_level == "ERROR"
60
+ # Ensure it's the same object instance reference in the module
61
+ assert config.settings is new_settings
tests/core/test_logging.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for logging configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from stroke_deepisles_demo.core.logging import get_logger, setup_logging
8
+
9
+
10
+ class TestSetupLogging:
11
+ """Tests for setup_logging."""
12
+
13
+ def test_sets_log_level(self) -> None:
14
+ """Sets the root logger level."""
15
+ # Reset root logger handlers to avoid interference
16
+ logging.getLogger().handlers = []
17
+
18
+ setup_logging("DEBUG")
19
+ # Note: basicConfig might not reset if already configured unless force=True is used
20
+ # The implementation should use force=True
21
+ assert logging.getLogger().level == logging.DEBUG
22
+
23
+ def test_format_styles(self) -> None:
24
+ """Different format styles work."""
25
+ for style in ["simple", "detailed", "json"]:
26
+ # Reset handlers
27
+ logging.getLogger().handlers = []
28
+ setup_logging("INFO", format_style=style) # type: ignore
29
+ # Should not raise
30
+
31
+
32
+ class TestGetLogger:
33
+ """Tests for get_logger."""
34
+
35
+ def test_returns_namespaced_logger(self) -> None:
36
+ """Returns logger with stroke_demo prefix."""
37
+ logger = get_logger("my_module")
38
+ assert logger.name == "stroke_deepisles_demo.my_module"