VibecoderMcSwaggins Claude commited on
Commit
a544a50
·
unverified ·
1 Parent(s): 71ff53a

feat(hf-spaces): complete deployment infrastructure for Hugging Face Spaces (#8)

Browse files

* feat(hf-spaces): complete deployment infrastructure for Hugging Face Spaces

This commit implements full Hugging Face Spaces deployment support with:

Deployment Infrastructure:
- Add Dockerfile based on isleschallenge/deepisles image with Gradio app
- Add requirements.txt for HF Spaces (with git dependency)
- Add README.md YAML frontmatter (sdk: docker, t4-small GPU)

Direct DeepISLES Invocation:
- Add HF Spaces environment detection (is_running_in_hf_spaces())
- Add direct Python invocation wrapper (inference/direct.py)
- Refactor deepisles.py to auto-select Docker vs direct invocation
- This enables running on HF Spaces where Docker-in-Docker is unavailable

NiiVue Viewer Improvements:
- Update NiiVue from 0.57.0 to 0.65.0 (latest stable Dec 2025)
- Generate unique canvas IDs with UUID to support multiple viewers
- Add WebGL2 detection with graceful fallback
- Improve error handling in JavaScript

Dependency Updates:
- Pin Gradio to 6.0.x (latest stable Dec 2025)
- Pin numpy <2.0.0 for compatibility
- Add src.isles22_ensemble to mypy ignore list

Tests:
- Add tests for HF Spaces detection and direct invocation
- All 114 tests passing

References:
- docs/specs/07-hf-spaces-deployment.md
- https://huggingface.co/docs/hub/spaces-sdks-docker
- https://github.com/ezequieldlrosa/DeepIsles

* fix(hf-spaces): resolve critical deployment issues found in code review

Critical fixes:
- Dockerfile: Install package via `pip install -e .` so stroke_deepisles_demo is importable
(previously module would fail with ModuleNotFoundError on HF Spaces)
- config.py: Remove path-based HF Spaces detection fallback that caused false positives
on any Linux machine with /home/user and /app directories

Code quality:
- Consolidate duplicate find_prediction_mask functions into single implementation
in deepisles.py (direct.py now imports from deepisles.py)
- Update tests to import from correct locations

All 114 tests passing.

* refactor: improve error handling and observability

Changes:
- components.py: Fail loudly instead of silent fallback when loading cases
- Specific error messages for FileNotFoundError vs unexpected errors
- No more "Error loading cases" as a fake dropdown option
- Wire up deepisles_fast_mode config to UI checkbox (was dead code)

- adapter.py: Add logging for skipped cases in build_local_dataset
- Warn when DWI files have unparseable subject IDs
- Warn when cases are missing ADC files (shows first 5)
- Info log showing total loaded cases

These changes ensure problems surface immediately rather than causing
confusing failures downstream.

* fix: address CodeRabbit review feedback

Security:
- viewer.py: Fix XSS vulnerability by using json.dumps() to serialize URLs
before embedding in JavaScript (prevents injection via special characters)

Reproducibility:
- Dockerfile: Add documentation explaining 'latest' tag usage and
recommend SHA digest pinning for production
- requirements.txt: Pin CloseChoice/datasets to specific commit hash
(c1c15aaa4f00f28f1916f3a896283494162eac49) instead of branch name

Code quality:
- config.py: Make is_deepisles_direct_available() side-effect free
(only checks env var, no sys.path mutation during availability checks)
- deepisles.py: Use _ for unused tuple elements from validate_input_folder()
- direct.py: Remove unused stdout/stderr fields from DirectInvocationResult
(direct invocation uses Python API, not subprocess)
- components.py: Fix ruff formatting

All 114 tests passing.

* fix: additional codebase-wide anti-pattern fixes

- pipeline.py: Add exc_info=True to warning when Dice computation fails
(preserves traceback for debugging instead of just error message)

- metrics.py: Replace assert with proper type narrowing
(asserts are stripped in optimized mode -O; restructured to use
explicitly typed local variable `voxel_dims`)

All 114 tests passing.

* fix: address CodeRabbit review nitpicks

- Remove unused stdout/stderr fields from DirectInvocationResult
- Add logger.exception before re-raising DeepISLESError for full traceback

---------

Co-authored-by: Claude <noreply@anthropic.com>

Dockerfile ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dockerfile for Hugging Face Spaces deployment
2
+ # Base: DeepISLES image with nnU-Net, SEALS, and all ML dependencies
3
+ # See: docs/specs/07-hf-spaces-deployment.md
4
+ #
5
+ # IMPORTANT: During Docker build, GPU is NOT available.
6
+ # All GPU operations happen at runtime only.
7
+
8
+ # NOTE: isleschallenge/deepisles only publishes 'latest' tag on Docker Hub.
9
+ # For reproducibility, consider using a SHA digest if available:
10
+ # FROM isleschallenge/deepisles@sha256:<digest>
11
+ # Check https://hub.docker.com/r/isleschallenge/deepisles/tags for updates.
12
+ # Current base: DeepISLES v1.1 (as of Dec 2025)
13
+ FROM isleschallenge/deepisles:latest
14
+
15
+ # Set environment variables for non-interactive installation
16
+ ENV DEBIAN_FRONTEND=noninteractive
17
+ ENV PYTHONUNBUFFERED=1
18
+ ENV PYTHONDONTWRITEBYTECODE=1
19
+
20
+ # HF Spaces runs containers with user ID 1000
21
+ # Create user if not exists (DeepISLES image may already have a user)
22
+ RUN useradd -m -u 1000 user 2>/dev/null || true
23
+
24
+ # Set working directory
25
+ WORKDIR /app
26
+
27
+ # Copy requirements first for better layer caching
28
+ COPY --chown=1000:1000 requirements.txt /app/requirements.txt
29
+
30
+ # Install Python dependencies (extras only - DeepISLES image has PyTorch, nnUNet, etc.)
31
+ RUN pip install --no-cache-dir -r requirements.txt
32
+
33
+ # Copy application source code and package files
34
+ COPY --chown=1000:1000 pyproject.toml /app/pyproject.toml
35
+ COPY --chown=1000:1000 README.md /app/README.md
36
+ COPY --chown=1000:1000 src/ /app/src/
37
+ COPY --chown=1000:1000 app.py /app/app.py
38
+
39
+ # Install the package itself (makes stroke_deepisles_demo importable)
40
+ # Using --no-deps since requirements.txt already installed dependencies
41
+ RUN pip install --no-cache-dir --no-deps -e .
42
+
43
+ # Set environment variable to indicate we're running in HF Spaces
44
+ # This allows the app to detect runtime environment and use direct invocation
45
+ ENV HF_SPACES=1
46
+ ENV DEEPISLES_DIRECT_INVOCATION=1
47
+
48
+ # Create directories for data with proper permissions
49
+ RUN mkdir -p /app/data /app/results /app/cache && \
50
+ chown -R 1000:1000 /app
51
+
52
+ # Switch to non-root user (required by HF Spaces)
53
+ USER user
54
+
55
+ # Expose the Gradio port
56
+ EXPOSE 7860
57
+
58
+ # Set the default command
59
+ # Use Gradio's built-in server settings for HF Spaces
60
+ CMD ["python", "-m", "stroke_deepisles_demo.ui.app"]
README.md CHANGED
@@ -1,3 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # Stroke DeepISLES Demo
2
 
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
 
1
+ ---
2
+ title: Stroke DeepISLES Demo
3
+ emoji: "\U0001F9E0"
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ suggested_hardware: t4-small
9
+ pinned: false
10
+ license: mit
11
+ short_description: Ischemic stroke lesion segmentation using DeepISLES
12
+ models:
13
+ - isleschallenge/deepisles
14
+ datasets:
15
+ - YongchengYAO/ISLES24-MR-Lite
16
+ tags:
17
+ - medical-imaging
18
+ - stroke
19
+ - segmentation
20
+ - neuroimaging
21
+ - niivue
22
+ - nnunet
23
+ ---
24
+
25
  # Stroke DeepISLES Demo
26
 
27
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
app.py CHANGED
@@ -1,11 +1,37 @@
1
- """Entry point for Hugging Face Spaces deployment."""
 
 
 
 
 
 
 
 
 
2
 
3
  import gradio as gr
4
 
 
 
5
  from stroke_deepisles_demo.ui.app import get_demo
6
 
 
 
 
 
7
  # Create the demo instance at module level for Gradio
8
  demo = get_demo()
9
 
10
  if __name__ == "__main__":
11
- demo.launch(theme=gr.themes.Soft(), css="footer {visibility: hidden}")
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Entry point for Hugging Face Spaces deployment.
2
+
3
+ This module provides the entry point for deploying the stroke-deepisles-demo
4
+ application to Hugging Face Spaces. It handles environment detection and
5
+ configures Gradio appropriately for the deployment environment.
6
+
7
+ See:
8
+ - docs/specs/07-hf-spaces-deployment.md
9
+ - https://huggingface.co/docs/hub/spaces-sdks-docker
10
+ """
11
 
12
  import gradio as gr
13
 
14
+ from stroke_deepisles_demo.core.config import get_settings
15
+ from stroke_deepisles_demo.core.logging import setup_logging
16
  from stroke_deepisles_demo.ui.app import get_demo
17
 
18
+ # Initialize logging
19
+ settings = get_settings()
20
+ setup_logging(settings.log_level, format_style=settings.log_format)
21
+
22
  # Create the demo instance at module level for Gradio
23
  demo = get_demo()
24
 
25
  if __name__ == "__main__":
26
+ # Launch configuration
27
+ # - server_name: 0.0.0.0 required for HF Spaces (Docker)
28
+ # - server_port: 7860 is HF Spaces default
29
+ # - theme: Gradio 6 uses launch() for theme
30
+ # - css: Hide footer for cleaner look
31
+ demo.launch(
32
+ server_name=settings.gradio_server_name,
33
+ server_port=settings.gradio_server_port,
34
+ share=settings.gradio_share,
35
+ theme=gr.themes.Soft(),
36
+ css="footer {visibility: hidden}",
37
+ )
pyproject.toml CHANGED
@@ -25,14 +25,14 @@ dependencies = [
25
 
26
  # NIfTI handling
27
  "nibabel>=5.2.0",
28
- "numpy>=1.26.0",
29
 
30
  # Configuration
31
  "pydantic>=2.5.0",
32
  "pydantic-settings>=2.1.0",
33
 
34
- # UI (Gradio 5.x)
35
- "gradio>=5.0.0",
36
  "matplotlib>=3.8.0",
37
 
38
  # Networking
@@ -112,6 +112,8 @@ module = [
112
  "niivue.*",
113
  "numpy.*",
114
  "pytest.*",
 
 
115
  ]
116
  ignore_missing_imports = true
117
 
 
25
 
26
  # NIfTI handling
27
  "nibabel>=5.2.0",
28
+ "numpy>=1.26.0,<2.0.0",
29
 
30
  # Configuration
31
  "pydantic>=2.5.0",
32
  "pydantic-settings>=2.1.0",
33
 
34
+ # UI (Gradio 6.x - Dec 2025 stable)
35
+ "gradio>=6.0.0,<7.0.0",
36
  "matplotlib>=3.8.0",
37
 
38
  # Networking
 
112
  "niivue.*",
113
  "numpy.*",
114
  "pytest.*",
115
+ # DeepISLES modules (only available in DeepISLES Docker image)
116
+ "src.isles22_ensemble",
117
  ]
118
  ignore_missing_imports = true
119
 
requirements.txt ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # requirements.txt for Hugging Face Spaces Docker deployment
2
+ # Generated: December 2025
3
+ # See: docs/specs/07-hf-spaces-deployment.md
4
+
5
+ # Core - Tobias's fork with BIDS + NIfTI lazy loading
6
+ # Pinned to specific commit for reproducibility (Dec 2025)
7
+ git+https://github.com/CloseChoice/datasets.git@c1c15aaa4f00f28f1916f3a896283494162eac49
8
+
9
+ # HuggingFace
10
+ huggingface-hub>=0.25.0
11
+
12
+ # NIfTI handling
13
+ nibabel>=5.2.0
14
+ numpy>=1.26.0,<2.0.0
15
+
16
+ # Configuration
17
+ pydantic>=2.5.0
18
+ pydantic-settings>=2.1.0
19
+
20
+ # UI - Gradio 6.x (latest stable as of Dec 2025)
21
+ gradio>=6.0.0,<7.0.0
22
+ matplotlib>=3.8.0
23
+
24
+ # Networking
25
+ requests>=2.0.0
src/stroke_deepisles_demo/core/config.py CHANGED
@@ -2,13 +2,56 @@
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.
@@ -42,6 +85,8 @@ class Settings(BaseSettings):
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
@@ -52,6 +97,24 @@ class Settings(BaseSettings):
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:
 
2
 
3
  from __future__ import annotations
4
 
5
+ import os
6
  from pathlib import Path
7
  from typing import Literal
8
 
9
+ from pydantic import Field, computed_field, field_validator
10
  from pydantic_settings import BaseSettings, SettingsConfigDict
11
 
12
 
13
+ def is_running_in_hf_spaces() -> bool:
14
+ """
15
+ Detect if running inside Hugging Face Spaces environment.
16
+
17
+ Returns:
18
+ True if running in HF Spaces, False otherwise
19
+
20
+ Detection methods (all env-var based for reliability):
21
+ 1. HF_SPACES=1 env var (set by our Dockerfile)
22
+ 2. SPACE_ID env var (set by HF Spaces runtime)
23
+
24
+ Note:
25
+ We intentionally avoid path-based detection (like checking for
26
+ /home/user or /app) because these paths exist on many Linux
27
+ systems and would cause false positives.
28
+ """
29
+ # Check explicit env vars only - no path-based fallbacks
30
+ if os.environ.get("HF_SPACES") == "1":
31
+ return True
32
+ # SPACE_ID is set by HF Spaces runtime
33
+ return bool(os.environ.get("SPACE_ID"))
34
+
35
+
36
+ def is_deepisles_direct_available() -> bool:
37
+ """
38
+ Check if DeepISLES can be invoked directly (without Docker).
39
+
40
+ Returns:
41
+ True if DEEPISLES_DIRECT_INVOCATION env var is set
42
+
43
+ This check is intentionally simple and side-effect free.
44
+ The env var is set by our Dockerfile when running on HF Spaces.
45
+ Actual module path setup happens in inference/direct.py when invoked.
46
+
47
+ Note:
48
+ We don't attempt import-based detection here because it would
49
+ require modifying sys.path, which is a side effect inappropriate
50
+ for a simple availability check.
51
+ """
52
+ return os.environ.get("DEEPISLES_DIRECT_INVOCATION") == "1"
53
+
54
+
55
  class Settings(BaseSettings):
56
  """
57
  Application settings loaded from environment variables.
 
85
  deepisles_fast_mode: bool = True # SEALS-only (ISLES'22 winner, no FLAIR needed)
86
  deepisles_timeout_seconds: int = 1800 # 30 minutes
87
  deepisles_use_gpu: bool = True
88
+ # Path to DeepISLES repo (for direct invocation mode)
89
+ deepisles_repo_path: Path | None = None
90
 
91
  # Paths
92
  temp_dir: Path | None = None
 
97
  gradio_server_port: int = 7860
98
  gradio_share: bool = False
99
 
100
+ @computed_field # type: ignore[prop-decorator]
101
+ @property
102
+ def is_hf_spaces(self) -> bool:
103
+ """Check if running in HF Spaces environment."""
104
+ return is_running_in_hf_spaces()
105
+
106
+ @computed_field # type: ignore[prop-decorator]
107
+ @property
108
+ def use_direct_invocation(self) -> bool:
109
+ """
110
+ Check if should use direct DeepISLES invocation (vs Docker).
111
+
112
+ Direct invocation is used when:
113
+ 1. Running in HF Spaces (cannot run Docker-in-Docker)
114
+ 2. DeepISLES modules are available for import
115
+ """
116
+ return self.is_hf_spaces or is_deepisles_direct_available()
117
+
118
  @field_validator("results_dir", mode="before")
119
  @classmethod
120
  def ensure_results_dir_exists(cls, v: Path | str) -> Path:
src/stroke_deepisles_demo/data/adapter.py CHANGED
@@ -6,12 +6,16 @@ import re
6
  from dataclasses import dataclass
7
  from typing import TYPE_CHECKING
8
 
 
 
9
  if TYPE_CHECKING:
10
  from collections.abc import Iterator
11
  from pathlib import Path
12
 
13
  from stroke_deepisles_demo.core.types import CaseFiles
14
 
 
 
15
 
16
  @dataclass
17
  class LocalDataset:
@@ -52,17 +56,21 @@ def build_local_dataset(data_dir: Path) -> LocalDataset:
52
  Scan directory and build case mapping.
53
 
54
  Matches DWI + ADC + Mask files by subject ID.
 
55
  """
56
  dwi_dir = data_dir / "Images-DWI"
57
  adc_dir = data_dir / "Images-ADC"
58
  mask_dir = data_dir / "Masks"
59
 
60
  cases: dict[str, CaseFiles] = {}
 
 
61
 
62
  # Scan DWI files to get subject IDs
63
  for dwi_file in dwi_dir.glob("*.nii.gz"):
64
  subject_id = parse_subject_id(dwi_file.name)
65
  if not subject_id:
 
66
  continue
67
 
68
  # Find matching ADC and Mask
@@ -70,7 +78,8 @@ def build_local_dataset(data_dir: Path) -> LocalDataset:
70
  mask_file = mask_dir / dwi_file.name.replace("_dwi.", "_lesion-msk.")
71
 
72
  if not adc_file.exists():
73
- continue # Skip incomplete cases
 
74
 
75
  case_files: CaseFiles = {
76
  "dwi": dwi_file,
@@ -81,4 +90,18 @@ def build_local_dataset(data_dir: Path) -> LocalDataset:
81
 
82
  cases[subject_id] = case_files
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  return LocalDataset(data_dir=data_dir, cases=cases)
 
6
  from dataclasses import dataclass
7
  from typing import TYPE_CHECKING
8
 
9
+ from stroke_deepisles_demo.core.logging import get_logger
10
+
11
  if TYPE_CHECKING:
12
  from collections.abc import Iterator
13
  from pathlib import Path
14
 
15
  from stroke_deepisles_demo.core.types import CaseFiles
16
 
17
+ logger = get_logger(__name__)
18
+
19
 
20
  @dataclass
21
  class LocalDataset:
 
56
  Scan directory and build case mapping.
57
 
58
  Matches DWI + ADC + Mask files by subject ID.
59
+ Logs warnings for incomplete cases that are skipped.
60
  """
61
  dwi_dir = data_dir / "Images-DWI"
62
  adc_dir = data_dir / "Images-ADC"
63
  mask_dir = data_dir / "Masks"
64
 
65
  cases: dict[str, CaseFiles] = {}
66
+ skipped_no_subject_id = 0
67
+ skipped_no_adc: list[str] = []
68
 
69
  # Scan DWI files to get subject IDs
70
  for dwi_file in dwi_dir.glob("*.nii.gz"):
71
  subject_id = parse_subject_id(dwi_file.name)
72
  if not subject_id:
73
+ skipped_no_subject_id += 1
74
  continue
75
 
76
  # Find matching ADC and Mask
 
78
  mask_file = mask_dir / dwi_file.name.replace("_dwi.", "_lesion-msk.")
79
 
80
  if not adc_file.exists():
81
+ skipped_no_adc.append(subject_id)
82
+ continue
83
 
84
  case_files: CaseFiles = {
85
  "dwi": dwi_file,
 
90
 
91
  cases[subject_id] = case_files
92
 
93
+ # Log skipped cases for debugging
94
+ if skipped_no_subject_id > 0:
95
+ logger.warning(
96
+ "Skipped %d DWI files: could not parse subject ID from filename",
97
+ skipped_no_subject_id,
98
+ )
99
+ if skipped_no_adc:
100
+ logger.warning(
101
+ "Skipped %d cases missing ADC file: %s",
102
+ len(skipped_no_adc),
103
+ ", ".join(skipped_no_adc[:5]) + ("..." if len(skipped_no_adc) > 5 else ""),
104
+ )
105
+
106
+ logger.info("Loaded %d cases from %s", len(cases), data_dir)
107
  return LocalDataset(data_dir=data_dir, cases=cases)
src/stroke_deepisles_demo/inference/__init__.py CHANGED
@@ -6,6 +6,10 @@ from stroke_deepisles_demo.inference.deepisles import (
6
  run_deepisles_on_folder,
7
  validate_input_folder,
8
  )
 
 
 
 
9
  from stroke_deepisles_demo.inference.docker import (
10
  DockerRunResult,
11
  build_docker_command,
@@ -17,11 +21,13 @@ from stroke_deepisles_demo.inference.docker import (
17
  __all__ = [
18
  "DEEPISLES_IMAGE",
19
  "DeepISLESResult",
 
20
  "DockerRunResult",
21
  "build_docker_command",
22
  "check_docker_available",
23
  "ensure_docker_available",
24
  "run_container",
 
25
  "run_deepisles_on_folder",
26
  "validate_input_folder",
27
  ]
 
6
  run_deepisles_on_folder,
7
  validate_input_folder,
8
  )
9
+ from stroke_deepisles_demo.inference.direct import (
10
+ DirectInvocationResult,
11
+ run_deepisles_direct,
12
+ )
13
  from stroke_deepisles_demo.inference.docker import (
14
  DockerRunResult,
15
  build_docker_command,
 
21
  __all__ = [
22
  "DEEPISLES_IMAGE",
23
  "DeepISLESResult",
24
+ "DirectInvocationResult",
25
  "DockerRunResult",
26
  "build_docker_command",
27
  "check_docker_available",
28
  "ensure_docker_available",
29
  "run_container",
30
+ "run_deepisles_direct",
31
  "run_deepisles_on_folder",
32
  "validate_input_folder",
33
  ]
src/stroke_deepisles_demo/inference/deepisles.py CHANGED
@@ -1,4 +1,14 @@
1
- """DeepISLES stroke segmentation wrapper."""
 
 
 
 
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
@@ -6,6 +16,7 @@ import time
6
  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.core.logging import get_logger
11
  from stroke_deepisles_demo.inference.docker import (
@@ -30,7 +41,7 @@ class DeepISLESResult:
30
  """Result of DeepISLES inference."""
31
 
32
  prediction_path: Path
33
- docker_result: DockerRunResult
34
  elapsed_seconds: float
35
 
36
 
@@ -65,7 +76,8 @@ def find_prediction_mask(output_dir: Path) -> Path:
65
  Find the prediction mask in DeepISLES output directory.
66
 
67
  DeepISLES outputs may have varying names depending on version.
68
- This function finds the most likely prediction file.
 
69
 
70
  Args:
71
  output_dir: DeepISLES output directory
@@ -76,7 +88,9 @@ def find_prediction_mask(output_dir: Path) -> Path:
76
  Raises:
77
  DeepISLESError: If no prediction mask found
78
  """
 
79
  results_dir = output_dir / "results"
 
80
 
81
  # Check common output patterns
82
  possible_names = [
@@ -84,70 +98,50 @@ def find_prediction_mask(output_dir: Path) -> Path:
84
  "pred.nii.gz",
85
  "lesion_mask.nii.gz",
86
  "output.nii.gz",
 
87
  ]
88
 
89
- for name in possible_names:
90
- pred_path = results_dir / name
91
- if pred_path.exists():
92
- return pred_path
93
-
94
- # Fall back to finding any .nii.gz in results dir
95
- if results_dir.exists():
96
- nifti_files = list(results_dir.glob("*.nii.gz"))
 
 
 
 
97
  if nifti_files:
98
  return nifti_files[0]
99
 
100
  raise DeepISLESError(
101
- f"No prediction mask found in {results_dir}. "
102
  "Expected files like 'prediction.nii.gz' or similar."
103
  )
104
 
105
 
106
- def run_deepisles_on_folder(
107
  input_dir: Path,
 
108
  *,
109
- output_dir: Path | None = None,
110
- fast: bool = True,
111
- gpu: bool = True,
112
- timeout: float | None = 1800, # 30 minutes default
113
  ) -> DeepISLESResult:
114
  """
115
- Run DeepISLES stroke segmentation on a folder of NIfTI files.
116
 
117
- Args:
118
- input_dir: Directory containing dwi.nii.gz, adc.nii.gz, [flair.nii.gz]
119
- output_dir: Where to write results (default: input_dir/results)
120
- fast: If True, use single-model mode (faster, slightly less accurate)
121
- gpu: If True, use GPU acceleration
122
- timeout: Maximum seconds to wait for inference
123
-
124
- Returns:
125
- DeepISLESResult with path to prediction mask
126
-
127
- Raises:
128
- DockerNotAvailableError: If Docker is not available
129
- DockerGPUNotAvailableError: If GPU requested but not available
130
- MissingInputError: If required input files are missing
131
- DeepISLESError: If inference fails (non-zero exit, missing output)
132
-
133
- Example:
134
- >>> result = run_deepisles_on_folder(Path("/data/case001"), fast=True)
135
- >>> print(result.prediction_path)
136
- /data/case001/results/prediction.nii.gz
137
  """
138
  start_time = time.time()
139
 
140
- # Validate inputs
141
- _dwi_path, _adc_path, flair_path = validate_input_folder(input_dir)
142
-
143
  # Check GPU if requested
144
  if gpu:
145
  ensure_gpu_available_if_requested(gpu)
146
 
147
- # Set up output directory
148
- if output_dir is None:
149
- output_dir = input_dir
150
-
151
  # Build command arguments
152
  command: list[str] = [
153
  "--dwi_file_name",
@@ -168,6 +162,8 @@ def run_deepisles_on_folder(
168
  output_dir.resolve(): "/output",
169
  }
170
 
 
 
171
  # Run the container
172
  docker_result = run_container(
173
  DEEPISLES_IMAGE,
@@ -194,3 +190,114 @@ def run_deepisles_on_folder(
194
  docker_result=docker_result,
195
  elapsed_seconds=elapsed,
196
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DeepISLES stroke segmentation wrapper.
2
+
3
+ This module provides a unified interface for running DeepISLES segmentation.
4
+ It automatically detects the runtime environment and uses either:
5
+ - Docker invocation (local development with Docker)
6
+ - Direct Python invocation (HF Spaces, inside DeepISLES container)
7
+
8
+ See:
9
+ - docs/specs/07-hf-spaces-deployment.md
10
+ - https://github.com/ezequieldlrosa/DeepIsles
11
+ """
12
 
13
  from __future__ import annotations
14
 
 
16
  from dataclasses import dataclass
17
  from typing import TYPE_CHECKING
18
 
19
+ from stroke_deepisles_demo.core.config import get_settings
20
  from stroke_deepisles_demo.core.exceptions import DeepISLESError, MissingInputError
21
  from stroke_deepisles_demo.core.logging import get_logger
22
  from stroke_deepisles_demo.inference.docker import (
 
41
  """Result of DeepISLES inference."""
42
 
43
  prediction_path: Path
44
+ docker_result: DockerRunResult | None # None when using direct invocation
45
  elapsed_seconds: float
46
 
47
 
 
76
  Find the prediction mask in DeepISLES output directory.
77
 
78
  DeepISLES outputs may have varying names depending on version.
79
+ This function searches both the results subdirectory and the
80
+ output directory itself.
81
 
82
  Args:
83
  output_dir: DeepISLES output directory
 
88
  Raises:
89
  DeepISLESError: If no prediction mask found
90
  """
91
+ # Check for results subdirectory (standard DeepISLES output structure)
92
  results_dir = output_dir / "results"
93
+ search_dirs = [results_dir, output_dir] if results_dir.exists() else [output_dir]
94
 
95
  # Check common output patterns
96
  possible_names = [
 
98
  "pred.nii.gz",
99
  "lesion_mask.nii.gz",
100
  "output.nii.gz",
101
+ "ensemble_prediction.nii.gz",
102
  ]
103
 
104
+ for search_dir in search_dirs:
105
+ for name in possible_names:
106
+ pred_path = search_dir / name
107
+ if pred_path.exists():
108
+ return pred_path
109
+
110
+ # Fall back to finding any .nii.gz in the directory
111
+ # Exclude input files that might have been copied
112
+ nifti_files = list(search_dir.glob("*.nii.gz"))
113
+ nifti_files = [
114
+ f for f in nifti_files if not any(x in f.name.lower() for x in ["dwi", "adc", "flair"])
115
+ ]
116
  if nifti_files:
117
  return nifti_files[0]
118
 
119
  raise DeepISLESError(
120
+ f"No prediction mask found in {output_dir}. "
121
  "Expected files like 'prediction.nii.gz' or similar."
122
  )
123
 
124
 
125
+ def _run_via_docker(
126
  input_dir: Path,
127
+ output_dir: Path,
128
  *,
129
+ flair_path: Path | None,
130
+ fast: bool,
131
+ gpu: bool,
132
+ timeout: float | None,
133
  ) -> DeepISLESResult:
134
  """
135
+ Run DeepISLES via Docker container.
136
 
137
+ This is the standard execution path for local development.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  """
139
  start_time = time.time()
140
 
 
 
 
141
  # Check GPU if requested
142
  if gpu:
143
  ensure_gpu_available_if_requested(gpu)
144
 
 
 
 
 
145
  # Build command arguments
146
  command: list[str] = [
147
  "--dwi_file_name",
 
162
  output_dir.resolve(): "/output",
163
  }
164
 
165
+ logger.info("Running DeepISLES via Docker: input=%s, fast=%s, gpu=%s", input_dir, fast, gpu)
166
+
167
  # Run the container
168
  docker_result = run_container(
169
  DEEPISLES_IMAGE,
 
190
  docker_result=docker_result,
191
  elapsed_seconds=elapsed,
192
  )
193
+
194
+
195
+ def _run_via_direct_invocation(
196
+ input_dir: Path,
197
+ output_dir: Path,
198
+ *,
199
+ flair_path: Path | None,
200
+ fast: bool,
201
+ ) -> DeepISLESResult:
202
+ """
203
+ Run DeepISLES via direct Python invocation.
204
+
205
+ This execution path is used on HF Spaces where Docker-in-Docker
206
+ is not available. The container is based on isleschallenge/deepisles
207
+ so all dependencies are pre-installed.
208
+ """
209
+ from stroke_deepisles_demo.inference.direct import run_deepisles_direct
210
+
211
+ dwi_path = input_dir / "dwi.nii.gz"
212
+ adc_path = input_dir / "adc.nii.gz"
213
+
214
+ logger.info(
215
+ "Running DeepISLES via direct invocation: input=%s, fast=%s",
216
+ input_dir,
217
+ fast,
218
+ )
219
+
220
+ result = run_deepisles_direct(
221
+ dwi_path=dwi_path,
222
+ adc_path=adc_path,
223
+ output_dir=output_dir,
224
+ flair_path=flair_path,
225
+ fast=fast,
226
+ )
227
+
228
+ return DeepISLESResult(
229
+ prediction_path=result.prediction_path,
230
+ docker_result=None, # No Docker result for direct invocation
231
+ elapsed_seconds=result.elapsed_seconds,
232
+ )
233
+
234
+
235
+ def run_deepisles_on_folder(
236
+ input_dir: Path,
237
+ *,
238
+ output_dir: Path | None = None,
239
+ fast: bool = True,
240
+ gpu: bool = True,
241
+ timeout: float | None = 1800, # 30 minutes default
242
+ ) -> DeepISLESResult:
243
+ """
244
+ Run DeepISLES stroke segmentation on a folder of NIfTI files.
245
+
246
+ This function automatically selects the execution method based on
247
+ the runtime environment:
248
+ - Docker invocation: Used for local development
249
+ - Direct invocation: Used on HF Spaces (Docker-in-Docker not available)
250
+
251
+ Args:
252
+ input_dir: Directory containing dwi.nii.gz, adc.nii.gz, [flair.nii.gz]
253
+ output_dir: Where to write results (default: input_dir/results)
254
+ fast: If True, use single-model mode (faster, slightly less accurate)
255
+ gpu: If True, use GPU acceleration (only affects Docker mode)
256
+ timeout: Maximum seconds to wait for inference (only affects Docker mode)
257
+
258
+ Returns:
259
+ DeepISLESResult with path to prediction mask
260
+
261
+ Raises:
262
+ DockerNotAvailableError: If Docker is not available (Docker mode only)
263
+ DockerGPUNotAvailableError: If GPU requested but not available (Docker mode only)
264
+ MissingInputError: If required input files are missing
265
+ DeepISLESError: If inference fails (non-zero exit, missing output)
266
+
267
+ Example:
268
+ >>> result = run_deepisles_on_folder(Path("/data/case001"), fast=True)
269
+ >>> print(result.prediction_path)
270
+ /data/case001/results/prediction.nii.gz
271
+ """
272
+ # Validate inputs (validation ensures dwi/adc exist; we only need flair_path)
273
+ _, _, flair_path = validate_input_folder(input_dir)
274
+
275
+ # Set up output directory
276
+ if output_dir is None:
277
+ output_dir = input_dir
278
+
279
+ # Check if we should use direct invocation
280
+ settings = get_settings()
281
+ use_direct = settings.use_direct_invocation
282
+
283
+ if use_direct:
284
+ logger.info(
285
+ "Using direct DeepISLES invocation (HF Spaces mode: %s)",
286
+ settings.is_hf_spaces,
287
+ )
288
+ return _run_via_direct_invocation(
289
+ input_dir=input_dir,
290
+ output_dir=output_dir,
291
+ flair_path=flair_path,
292
+ fast=fast,
293
+ )
294
+ else:
295
+ logger.info("Using Docker-based DeepISLES invocation")
296
+ return _run_via_docker(
297
+ input_dir=input_dir,
298
+ output_dir=output_dir,
299
+ flair_path=flair_path,
300
+ fast=fast,
301
+ gpu=gpu,
302
+ timeout=timeout,
303
+ )
src/stroke_deepisles_demo/inference/direct.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Direct DeepISLES invocation without Docker.
2
+
3
+ This module provides direct Python invocation of DeepISLES when running
4
+ inside the DeepISLES Docker image (e.g., on HF Spaces). This avoids
5
+ Docker-in-Docker which is not supported on HF Spaces.
6
+
7
+ Usage:
8
+ When running in HF Spaces, our container is based on isleschallenge/deepisles,
9
+ which has all DeepISLES dependencies pre-installed. This module imports
10
+ and calls DeepISLES directly.
11
+
12
+ See:
13
+ - https://github.com/ezequieldlrosa/DeepIsles
14
+ - docs/specs/07-hf-spaces-deployment.md
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ import time
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ from stroke_deepisles_demo.core.exceptions import DeepISLESError, MissingInputError
25
+ from stroke_deepisles_demo.core.logging import get_logger
26
+ from stroke_deepisles_demo.inference.deepisles import find_prediction_mask
27
+
28
+ logger = get_logger(__name__)
29
+
30
+ # Paths where DeepISLES source might be located in the Docker image
31
+ DEEPISLES_SEARCH_PATHS = [
32
+ "/app",
33
+ "/DeepIsles",
34
+ "/opt/deepisles",
35
+ "/home/user/DeepIsles",
36
+ ]
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class DirectInvocationResult:
41
+ """Result of direct DeepISLES invocation."""
42
+
43
+ prediction_path: Path
44
+ elapsed_seconds: float
45
+
46
+
47
+ def _ensure_deepisles_importable() -> str:
48
+ """
49
+ Ensure DeepISLES modules are importable by adding to sys.path.
50
+
51
+ Returns:
52
+ Path where DeepISLES was found
53
+
54
+ Raises:
55
+ DeepISLESError: If DeepISLES cannot be found
56
+ """
57
+ for path in DEEPISLES_SEARCH_PATHS:
58
+ if Path(path).exists():
59
+ if path not in sys.path:
60
+ sys.path.insert(0, path)
61
+ try:
62
+ # Test import (only available in DeepISLES Docker image)
63
+ from src.isles22_ensemble import IslesEnsemble # noqa: F401
64
+
65
+ logger.debug("Found DeepISLES at %s", path)
66
+ return path
67
+ except ImportError:
68
+ continue
69
+
70
+ raise DeepISLESError(
71
+ "DeepISLES modules not found. Direct invocation requires running "
72
+ "inside the DeepISLES Docker image. Searched paths: "
73
+ f"{DEEPISLES_SEARCH_PATHS}"
74
+ )
75
+
76
+
77
+ def validate_input_files(
78
+ dwi_path: Path,
79
+ adc_path: Path,
80
+ flair_path: Path | None = None,
81
+ ) -> None:
82
+ """
83
+ Validate that input files exist.
84
+
85
+ Args:
86
+ dwi_path: Path to DWI NIfTI file
87
+ adc_path: Path to ADC NIfTI file
88
+ flair_path: Optional path to FLAIR NIfTI file
89
+
90
+ Raises:
91
+ MissingInputError: If required files are missing
92
+ """
93
+ if not dwi_path.exists():
94
+ raise MissingInputError(f"DWI file not found: {dwi_path}")
95
+ if not adc_path.exists():
96
+ raise MissingInputError(f"ADC file not found: {adc_path}")
97
+ if flair_path is not None and not flair_path.exists():
98
+ raise MissingInputError(f"FLAIR file not found: {flair_path}")
99
+
100
+
101
+ def run_deepisles_direct(
102
+ dwi_path: Path,
103
+ adc_path: Path,
104
+ output_dir: Path,
105
+ *,
106
+ flair_path: Path | None = None,
107
+ fast: bool = True,
108
+ skull_strip: bool = False,
109
+ parallelize: bool = True,
110
+ save_team_outputs: bool = False,
111
+ results_mni: bool = False,
112
+ ) -> DirectInvocationResult:
113
+ """
114
+ Run DeepISLES segmentation via direct Python invocation.
115
+
116
+ This function calls the DeepISLES IslesEnsemble.predict_ensemble() method
117
+ directly, bypassing Docker. It's used when running inside the DeepISLES
118
+ container on HF Spaces.
119
+
120
+ Args:
121
+ dwi_path: Path to DWI NIfTI file (b=1000)
122
+ adc_path: Path to ADC NIfTI file
123
+ output_dir: Directory for output files
124
+ flair_path: Optional path to FLAIR NIfTI file
125
+ fast: If True, use SEALS model only (faster, no FLAIR needed)
126
+ skull_strip: If True, perform skull stripping
127
+ parallelize: If True, run models in parallel
128
+ save_team_outputs: If True, save individual team outputs
129
+ results_mni: If True, output results in MNI space
130
+
131
+ Returns:
132
+ DirectInvocationResult with path to prediction mask
133
+
134
+ Raises:
135
+ DeepISLESError: If invocation fails
136
+ MissingInputError: If required input files are missing
137
+
138
+ Example:
139
+ >>> result = run_deepisles_direct(
140
+ ... dwi_path=Path("/data/dwi.nii.gz"),
141
+ ... adc_path=Path("/data/adc.nii.gz"),
142
+ ... output_dir=Path("/data/output"),
143
+ ... fast=True
144
+ ... )
145
+ >>> print(result.prediction_path)
146
+ """
147
+ start_time = time.time()
148
+
149
+ # Validate inputs
150
+ validate_input_files(dwi_path, adc_path, flair_path)
151
+
152
+ # Ensure DeepISLES is importable
153
+ deepisles_path = _ensure_deepisles_importable()
154
+
155
+ # Import DeepISLES (only available in DeepISLES Docker image)
156
+ try:
157
+ from src.isles22_ensemble import IslesEnsemble
158
+ except ImportError as e:
159
+ raise DeepISLESError(f"Failed to import DeepISLES: {e}") from e
160
+
161
+ # Create output directory
162
+ output_dir.mkdir(parents=True, exist_ok=True)
163
+
164
+ logger.info(
165
+ "Running DeepISLES direct invocation: dwi=%s, adc=%s, flair=%s, fast=%s",
166
+ dwi_path,
167
+ adc_path,
168
+ flair_path,
169
+ fast,
170
+ )
171
+
172
+ try:
173
+ # Initialize the ensemble
174
+ stroke_segm = IslesEnsemble()
175
+
176
+ # Run prediction
177
+ stroke_segm.predict_ensemble(
178
+ ensemble_path=deepisles_path,
179
+ input_dwi_path=str(dwi_path),
180
+ input_adc_path=str(adc_path),
181
+ input_flair_path=str(flair_path) if flair_path else None,
182
+ output_path=str(output_dir),
183
+ skull_strip=skull_strip,
184
+ fast=fast,
185
+ save_team_outputs=save_team_outputs,
186
+ results_mni=results_mni,
187
+ parallelize=parallelize,
188
+ )
189
+ except Exception as e:
190
+ logger.exception("DeepISLES inference failed")
191
+ raise DeepISLESError(f"DeepISLES inference failed: {e}") from e
192
+
193
+ # Find the prediction mask (using shared function from deepisles module)
194
+ prediction_path = find_prediction_mask(output_dir)
195
+
196
+ elapsed = time.time() - start_time
197
+ logger.info("DeepISLES direct invocation completed in %.2fs", elapsed)
198
+
199
+ return DirectInvocationResult(
200
+ prediction_path=prediction_path,
201
+ elapsed_seconds=elapsed,
202
+ )
src/stroke_deepisles_demo/metrics.py CHANGED
@@ -101,21 +101,19 @@ def compute_volume_ml(
101
  Returns:
102
  Volume in milliliters (mL)
103
  """
 
 
 
 
104
  if isinstance(mask, Path):
105
  data, loaded_zooms = load_nifti_as_array(mask)
106
- if voxel_size_mm is None:
107
- voxel_size_mm = loaded_zooms
108
  else:
109
  data = mask
110
- if voxel_size_mm is None:
111
- # Default to 1mm isotropic if not provided for array
112
- voxel_size_mm = (1.0, 1.0, 1.0)
113
-
114
- # Ensure voxel_size_mm is not None for type checker
115
- assert voxel_size_mm is not None
116
 
117
  volume_voxels = np.sum(data > 0)
118
- # Use math.prod for better type compatibility
119
- voxel_vol_mm3 = math.prod(voxel_size_mm)
120
 
121
  return float(volume_voxels * voxel_vol_mm3 / 1000.0) # mm3 -> mL
 
101
  Returns:
102
  Volume in milliliters (mL)
103
  """
104
+ # Resolve data and voxel sizes
105
+ data: NDArray[np.float64]
106
+ voxel_dims: tuple[float, float, float]
107
+
108
  if isinstance(mask, Path):
109
  data, loaded_zooms = load_nifti_as_array(mask)
110
+ voxel_dims = voxel_size_mm if voxel_size_mm is not None else loaded_zooms
 
111
  else:
112
  data = mask
113
+ # Default to 1mm isotropic if not provided for array
114
+ voxel_dims = voxel_size_mm if voxel_size_mm is not None else (1.0, 1.0, 1.0)
 
 
 
 
115
 
116
  volume_voxels = np.sum(data > 0)
117
+ voxel_vol_mm3 = math.prod(voxel_dims)
 
118
 
119
  return float(volume_voxels * voxel_vol_mm3 / 1000.0) # mm3 -> mL
src/stroke_deepisles_demo/pipeline.py CHANGED
@@ -129,8 +129,8 @@ def run_pipeline_on_case(
129
  if compute_dice and ground_truth and ground_truth.exists():
130
  try:
131
  dice_score = metrics.compute_dice(inference_result.prediction_path, ground_truth)
132
- except Exception as e:
133
- logger.warning("Failed to compute Dice score for %s: %s", resolved_case_id, e)
134
 
135
  # 5. Cleanup (Optional)
136
  if cleanup_staging:
 
129
  if compute_dice and ground_truth and ground_truth.exists():
130
  try:
131
  dice_score = metrics.compute_dice(inference_result.prediction_path, ground_truth)
132
+ except Exception:
133
+ logger.warning("Failed to compute Dice score for %s", resolved_case_id, exc_info=True)
134
 
135
  # 5. Cleanup (Optional)
136
  if cleanup_staging:
src/stroke_deepisles_demo/ui/app.py CHANGED
@@ -113,13 +113,15 @@ def create_app() -> gr.Blocks:
113
  ) as demo:
114
  # Header
115
  gr.Markdown("""
116
- # 🧠 Stroke Lesion Segmentation Demo
117
 
118
  This demo runs [DeepISLES](https://github.com/ezequieldlrosa/DeepIsles)
119
  stroke segmentation on cases from
120
  [ISLES24-MR-Lite](https://huggingface.co/datasets/YongchengYAO/ISLES24-MR-Lite).
121
 
122
- **Note:** The pipeline runs inside a Docker container. First run might be slow (pulling image).
 
 
123
  """)
124
 
125
  with gr.Row():
@@ -167,4 +169,16 @@ def get_demo() -> gr.Blocks:
167
 
168
 
169
  if __name__ == "__main__":
170
- get_demo().launch(theme=gr.themes.Soft(), css="footer {visibility: hidden}")
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  ) as demo:
114
  # Header
115
  gr.Markdown("""
116
+ # Stroke Lesion Segmentation Demo
117
 
118
  This demo runs [DeepISLES](https://github.com/ezequieldlrosa/DeepIsles)
119
  stroke segmentation on cases from
120
  [ISLES24-MR-Lite](https://huggingface.co/datasets/YongchengYAO/ISLES24-MR-Lite).
121
 
122
+ **Model:** SEALS (ISLES'22 winner) - Fast, accurate ischemic stroke lesion segmentation.
123
+
124
+ **Note:** First run may take a moment to load models and data.
125
  """)
126
 
127
  with gr.Row():
 
169
 
170
 
171
  if __name__ == "__main__":
172
+ from stroke_deepisles_demo.core.config import get_settings
173
+ from stroke_deepisles_demo.core.logging import setup_logging
174
+
175
+ settings = get_settings()
176
+ setup_logging(settings.log_level, format_style=settings.log_format)
177
+
178
+ get_demo().launch(
179
+ server_name=settings.gradio_server_name,
180
+ server_port=settings.gradio_server_port,
181
+ share=settings.gradio_share,
182
+ theme=gr.themes.Soft(),
183
+ css="footer {visibility: hidden}",
184
+ )
src/stroke_deepisles_demo/ui/components.py CHANGED
@@ -4,6 +4,7 @@ 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
 
@@ -16,16 +17,27 @@ def create_case_selector() -> gr.Dropdown:
16
 
17
  Returns:
18
  Configured gr.Dropdown component
 
 
 
19
  """
20
  try:
21
  case_ids = list_case_ids()
22
- except Exception:
23
- logger.warning("Failed to load case IDs, using fallback", exc_info=True)
24
- case_ids = ["Error loading cases"]
 
 
 
 
 
 
 
 
25
 
26
  return gr.Dropdown(
27
  choices=case_ids,
28
- value=case_ids[0] if case_ids else None,
29
  label="Select Case",
30
  info="Choose a case from ISLES24-MR-Lite",
31
  filterable=True,
@@ -65,9 +77,11 @@ def create_settings_accordion() -> dict[str, gr.components.Component]:
65
  Returns:
66
  Dictionary of setting name -> gr.Component
67
  """
 
 
68
  with gr.Accordion("Advanced Settings", open=False):
69
  fast_mode = gr.Checkbox(
70
- value=True,
71
  label="Fast Mode (SEALS)",
72
  info="Run SEALS only (ISLES'22 winner, requires DWI+ADC). Disable for full ensemble (requires FLAIR).",
73
  )
 
4
 
5
  import gradio as gr
6
 
7
+ from stroke_deepisles_demo.core.config import get_settings
8
  from stroke_deepisles_demo.core.logging import get_logger
9
  from stroke_deepisles_demo.data import list_case_ids
10
 
 
17
 
18
  Returns:
19
  Configured gr.Dropdown component
20
+
21
+ Raises:
22
+ RuntimeError: If case IDs cannot be loaded (no silent fallback)
23
  """
24
  try:
25
  case_ids = list_case_ids()
26
+ except FileNotFoundError as e:
27
+ # Data directory not found - fail loudly with helpful message
28
+ logger.error("Data directory not found: %s", e)
29
+ raise RuntimeError("ISLES24 data not found. Please run: uv run stroke-demo download") from e
30
+ except Exception as e:
31
+ # Unexpected error - fail loudly, don't mask with fake dropdown option
32
+ logger.exception("Failed to load case IDs")
33
+ raise RuntimeError(f"Failed to load case IDs: {e}") from e
34
+
35
+ if not case_ids:
36
+ raise RuntimeError("No cases found in dataset. Please verify data directory structure.")
37
 
38
  return gr.Dropdown(
39
  choices=case_ids,
40
+ value=case_ids[0],
41
  label="Select Case",
42
  info="Choose a case from ISLES24-MR-Lite",
43
  filterable=True,
 
77
  Returns:
78
  Dictionary of setting name -> gr.Component
79
  """
80
+ settings = get_settings()
81
+
82
  with gr.Accordion("Advanced Settings", open=False):
83
  fast_mode = gr.Checkbox(
84
+ value=settings.deepisles_fast_mode,
85
  label="Fast Mode (SEALS)",
86
  info="Run SEALS only (ISLES'22 winner, requires DWI+ADC). Disable for full ensemble (requires FLAIR).",
87
  )
src/stroke_deepisles_demo/ui/viewer.py CHANGED
@@ -1,8 +1,19 @@
1
- """Neuroimaging visualization for Gradio."""
 
 
 
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
  import base64
 
 
6
  from typing import TYPE_CHECKING
7
 
8
  import matplotlib.pyplot as plt
@@ -15,6 +26,10 @@ if TYPE_CHECKING:
15
 
16
  from matplotlib.figure import Figure
17
 
 
 
 
 
18
 
19
  def nifti_to_data_url(nifti_path: Path) -> str:
20
  """
@@ -268,57 +283,103 @@ def create_niivue_html(
268
  """
269
  Create HTML/JS for NiiVue viewer.
270
 
 
 
 
 
271
  Args:
272
- volume_url: URL to volume NIfTI file
273
- mask_url: Optional URL to mask NIfTI file
274
  height: Viewer height in pixels
275
 
276
  Returns:
277
  HTML string with embedded NiiVue viewer
 
 
 
 
 
278
  """
 
 
 
 
 
 
 
 
 
279
  mask_js = ""
280
  if mask_url:
 
281
  mask_js = f"""
282
- volumes.push({{
283
- url: '{mask_url}',
284
- colorMap: 'red',
285
- opacity: 0.5
286
- }});
287
- """
288
-
 
289
  return f"""
290
- <div style="width:100%; height:{height}px; background:#000; border-radius:8px; position: relative;">
291
- <canvas id="niivue-canvas" style="width:100%; height:100%;"></canvas>
292
  </div>
293
  <script type="module">
294
- const niivueModule = await import('https://unpkg.com/@niivue/niivue@0.57.0/dist/index.js');
295
- const Niivue = niivueModule.Niivue;
296
-
297
- const nv = new Niivue({{
298
- logging: false,
299
- show3Dcrosshair: true,
300
- textHeight: 0.04,
301
- backColor: [0, 0, 0, 1]
302
- }});
303
-
304
- await nv.attachTo('niivue-canvas');
305
-
306
- const volumes = [{{
307
- url: '{volume_url}',
308
- name: 'input.nii.gz'
309
- }}];
310
- {mask_js}
311
-
312
- await nv.loadVolumes(volumes);
313
-
314
- // Multiplanar + 3D view
315
- nv.setSliceType(nv.sliceTypeMultiplanar);
316
- // Check if setMultiplanarLayout exists (added in newer versions, 0.57 has it)
317
- if (nv.setMultiplanarLayout) {{
318
- nv.setMultiplanarLayout(2);
319
- }}
320
- nv.opts.show3Dcrosshair = true;
321
- nv.setRenderAzimuthElevation(120, 10);
322
- nv.drawScene();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  </script>
324
  """
 
1
+ """Neuroimaging visualization for Gradio.
2
+
3
+ This module provides visualization components for neuroimaging data:
4
+ - NiiVue WebGL-based 3D viewer
5
+ - Matplotlib-based 2D slice comparisons
6
+
7
+ See:
8
+ - https://github.com/niivue/niivue (NiiVue v0.65.0)
9
+ - docs/specs/07-hf-spaces-deployment.md
10
+ """
11
 
12
  from __future__ import annotations
13
 
14
  import base64
15
+ import json
16
+ import uuid
17
  from typing import TYPE_CHECKING
18
 
19
  import matplotlib.pyplot as plt
 
26
 
27
  from matplotlib.figure import Figure
28
 
29
+ # NiiVue version - updated to latest stable (Dec 2025)
30
+ NIIVUE_VERSION = "0.65.0"
31
+ NIIVUE_CDN_URL = f"https://unpkg.com/@niivue/niivue@{NIIVUE_VERSION}/dist/index.js"
32
+
33
 
34
  def nifti_to_data_url(nifti_path: Path) -> str:
35
  """
 
283
  """
284
  Create HTML/JS for NiiVue viewer.
285
 
286
+ This function generates an HTML snippet with embedded JavaScript for
287
+ NiiVue WebGL-based neuroimaging visualization. Each invocation creates
288
+ a unique canvas ID to avoid conflicts when multiple viewers are rendered.
289
+
290
  Args:
291
+ volume_url: Data URL or URL to volume NIfTI file
292
+ mask_url: Optional data URL or URL to mask NIfTI file
293
  height: Viewer height in pixels
294
 
295
  Returns:
296
  HTML string with embedded NiiVue viewer
297
+
298
+ Note:
299
+ The JavaScript uses dynamic import() which works in modern browsers
300
+ and Gradio's HTML component. Each viewer gets a unique ID to support
301
+ multiple simultaneous viewers.
302
  """
303
+ # Generate unique ID for this viewer instance
304
+ viewer_id = uuid.uuid4().hex[:8]
305
+ canvas_id = f"niivue-canvas-{viewer_id}"
306
+ container_id = f"niivue-container-{viewer_id}"
307
+
308
+ # Safely serialize URLs for JavaScript (prevents XSS)
309
+ volume_url_js = json.dumps(volume_url)
310
+
311
+ # Build mask volume configuration if provided
312
  mask_js = ""
313
  if mask_url:
314
+ mask_url_js = json.dumps(mask_url)
315
  mask_js = f"""
316
+ volumes.push({{
317
+ url: {mask_url_js},
318
+ colorMap: 'red',
319
+ opacity: 0.5
320
+ }});"""
321
+
322
+ # JavaScript that initializes NiiVue
323
+ # Using an IIFE pattern that works better in Gradio's HTML component
324
  return f"""
325
+ <div id="{container_id}" style="width:100%; height:{height}px; background:#000; border-radius:8px; position:relative;">
326
+ <canvas id="{canvas_id}" style="width:100%; height:100%;"></canvas>
327
  </div>
328
  <script type="module">
329
+ // NiiVue initialization for viewer {viewer_id}
330
+ (async function() {{
331
+ try {{
332
+ // Check if browser supports WebGL2
333
+ const testCanvas = document.createElement('canvas');
334
+ const gl = testCanvas.getContext('webgl2');
335
+ if (!gl) {{
336
+ document.getElementById('{container_id}').innerHTML =
337
+ '<div style="color:#fff;padding:20px;text-align:center;">' +
338
+ 'WebGL2 not supported. Please use a modern browser.</div>';
339
+ return;
340
+ }}
341
+
342
+ // Dynamically import NiiVue
343
+ const niivueModule = await import('{NIIVUE_CDN_URL}');
344
+ const Niivue = niivueModule.Niivue;
345
+
346
+ // Initialize NiiVue with options
347
+ const nv = new Niivue({{
348
+ logging: false,
349
+ show3Dcrosshair: true,
350
+ textHeight: 0.04,
351
+ backColor: [0, 0, 0, 1],
352
+ crosshairColor: [0.2, 0.8, 0.2, 1]
353
+ }});
354
+
355
+ // Attach to canvas
356
+ await nv.attachToCanvas(document.getElementById('{canvas_id}'));
357
+
358
+ // Prepare volumes
359
+ const volumes = [{{
360
+ url: {volume_url_js},
361
+ name: 'input.nii.gz'
362
+ }}];{mask_js}
363
+
364
+ // Load volumes
365
+ await nv.loadVolumes(volumes);
366
+
367
+ // Configure view: multiplanar + 3D
368
+ nv.setSliceType(nv.sliceTypeMultiplanar);
369
+ if (typeof nv.setMultiplanarLayout === 'function') {{
370
+ nv.setMultiplanarLayout(2);
371
+ }}
372
+ nv.opts.show3Dcrosshair = true;
373
+ nv.setRenderAzimuthElevation(120, 10);
374
+ nv.drawScene();
375
+
376
+ console.log('NiiVue viewer {viewer_id} initialized successfully');
377
+ }} catch (error) {{
378
+ console.error('NiiVue initialization error:', error);
379
+ document.getElementById('{container_id}').innerHTML =
380
+ '<div style="color:#fff;padding:20px;text-align:center;">' +
381
+ 'Error loading viewer: ' + error.message + '</div>';
382
+ }}
383
+ }})();
384
  </script>
385
  """
tests/core/test_config.py CHANGED
@@ -8,7 +8,12 @@ from typing import TYPE_CHECKING
8
  if TYPE_CHECKING:
9
  import pytest
10
 
11
- from stroke_deepisles_demo.core.config import Settings, reload_settings
 
 
 
 
 
12
 
13
 
14
  class TestSettings:
@@ -59,3 +64,72 @@ class TestSettings:
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  if TYPE_CHECKING:
9
  import pytest
10
 
11
+ from stroke_deepisles_demo.core.config import (
12
+ Settings,
13
+ is_deepisles_direct_available,
14
+ is_running_in_hf_spaces,
15
+ reload_settings,
16
+ )
17
 
18
 
19
  class TestSettings:
 
64
  assert config.settings.log_level == "ERROR"
65
  # Ensure it's the same object instance reference in the module
66
  assert config.settings is new_settings
67
+
68
+
69
+ class TestHFSpacesDetection:
70
+ """Tests for HF Spaces environment detection."""
71
+
72
+ def test_not_in_hf_spaces_by_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
73
+ """Returns False when not in HF Spaces."""
74
+ # Clear any HF Spaces env vars
75
+ monkeypatch.delenv("HF_SPACES", raising=False)
76
+ monkeypatch.delenv("SPACE_ID", raising=False)
77
+ assert is_running_in_hf_spaces() is False
78
+
79
+ def test_detects_hf_spaces_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None:
80
+ """Detects HF Spaces via HF_SPACES env var."""
81
+ monkeypatch.setenv("HF_SPACES", "1")
82
+ assert is_running_in_hf_spaces() is True
83
+
84
+ def test_detects_space_id_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None:
85
+ """Detects HF Spaces via SPACE_ID env var."""
86
+ monkeypatch.delenv("HF_SPACES", raising=False)
87
+ monkeypatch.setenv("SPACE_ID", "username/space-name")
88
+ assert is_running_in_hf_spaces() is True
89
+
90
+
91
+ class TestDirectInvocationDetection:
92
+ """Tests for direct DeepISLES invocation detection."""
93
+
94
+ def test_not_available_by_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
95
+ """Returns False when DeepISLES modules not available."""
96
+ # Clear env var
97
+ monkeypatch.delenv("DEEPISLES_DIRECT_INVOCATION", raising=False)
98
+ # In test environment, DeepISLES won't be importable
99
+ assert is_deepisles_direct_available() is False
100
+
101
+ def test_detects_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None:
102
+ """Detects direct invocation via env var."""
103
+ monkeypatch.setenv("DEEPISLES_DIRECT_INVOCATION", "1")
104
+ assert is_deepisles_direct_available() is True
105
+
106
+
107
+ class TestSettingsComputedFields:
108
+ """Tests for Settings computed fields."""
109
+
110
+ def test_is_hf_spaces_computed(self, monkeypatch: pytest.MonkeyPatch) -> None:
111
+ """Settings.is_hf_spaces reflects environment."""
112
+ monkeypatch.delenv("HF_SPACES", raising=False)
113
+ monkeypatch.delenv("SPACE_ID", raising=False)
114
+ settings = Settings()
115
+ assert settings.is_hf_spaces is False
116
+
117
+ monkeypatch.setenv("HF_SPACES", "1")
118
+ # Need new instance to pick up env change
119
+ settings2 = Settings()
120
+ assert settings2.is_hf_spaces is True
121
+
122
+ def test_use_direct_invocation_computed(self, monkeypatch: pytest.MonkeyPatch) -> None:
123
+ """Settings.use_direct_invocation reflects environment."""
124
+ monkeypatch.delenv("HF_SPACES", raising=False)
125
+ monkeypatch.delenv("SPACE_ID", raising=False)
126
+ monkeypatch.delenv("DEEPISLES_DIRECT_INVOCATION", raising=False)
127
+
128
+ settings = Settings()
129
+ # Not in HF Spaces and DeepISLES not directly available
130
+ assert settings.use_direct_invocation is False
131
+
132
+ # Enable direct invocation
133
+ monkeypatch.setenv("DEEPISLES_DIRECT_INVOCATION", "1")
134
+ settings2 = Settings()
135
+ assert settings2.use_direct_invocation is True
tests/inference/test_direct.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for direct DeepISLES invocation module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import pytest
8
+
9
+ if TYPE_CHECKING:
10
+ from pathlib import Path
11
+
12
+ from stroke_deepisles_demo.core.exceptions import DeepISLESError, MissingInputError
13
+ from stroke_deepisles_demo.inference.deepisles import find_prediction_mask
14
+ from stroke_deepisles_demo.inference.direct import validate_input_files
15
+
16
+
17
+ class TestValidateInputFiles:
18
+ """Tests for validate_input_files."""
19
+
20
+ def test_valid_files(self, tmp_path: Path) -> None:
21
+ """Passes validation when required files exist."""
22
+ dwi = tmp_path / "dwi.nii.gz"
23
+ adc = tmp_path / "adc.nii.gz"
24
+ dwi.touch()
25
+ adc.touch()
26
+
27
+ # Should not raise
28
+ validate_input_files(dwi, adc)
29
+
30
+ def test_valid_files_with_flair(self, tmp_path: Path) -> None:
31
+ """Passes validation when all files including FLAIR exist."""
32
+ dwi = tmp_path / "dwi.nii.gz"
33
+ adc = tmp_path / "adc.nii.gz"
34
+ flair = tmp_path / "flair.nii.gz"
35
+ dwi.touch()
36
+ adc.touch()
37
+ flair.touch()
38
+
39
+ # Should not raise
40
+ validate_input_files(dwi, adc, flair)
41
+
42
+ def test_missing_dwi(self, tmp_path: Path) -> None:
43
+ """Raises MissingInputError when DWI file missing."""
44
+ dwi = tmp_path / "dwi.nii.gz"
45
+ adc = tmp_path / "adc.nii.gz"
46
+ adc.touch()
47
+
48
+ with pytest.raises(MissingInputError, match="DWI file not found"):
49
+ validate_input_files(dwi, adc)
50
+
51
+ def test_missing_adc(self, tmp_path: Path) -> None:
52
+ """Raises MissingInputError when ADC file missing."""
53
+ dwi = tmp_path / "dwi.nii.gz"
54
+ adc = tmp_path / "adc.nii.gz"
55
+ dwi.touch()
56
+
57
+ with pytest.raises(MissingInputError, match="ADC file not found"):
58
+ validate_input_files(dwi, adc)
59
+
60
+ def test_missing_flair_when_specified(self, tmp_path: Path) -> None:
61
+ """Raises MissingInputError when FLAIR specified but missing."""
62
+ dwi = tmp_path / "dwi.nii.gz"
63
+ adc = tmp_path / "adc.nii.gz"
64
+ flair = tmp_path / "flair.nii.gz"
65
+ dwi.touch()
66
+ adc.touch()
67
+
68
+ with pytest.raises(MissingInputError, match="FLAIR file not found"):
69
+ validate_input_files(dwi, adc, flair)
70
+
71
+
72
+ class TestFindPredictionMask:
73
+ """Tests for find_prediction_mask (shared function in deepisles module)."""
74
+
75
+ def test_finds_prediction_in_results_dir(self, tmp_path: Path) -> None:
76
+ """Finds prediction.nii.gz in results subdirectory."""
77
+ results = tmp_path / "results"
78
+ results.mkdir()
79
+ pred = results / "prediction.nii.gz"
80
+ pred.touch()
81
+
82
+ found = find_prediction_mask(tmp_path)
83
+ assert found == pred
84
+
85
+ def test_finds_alternative_names(self, tmp_path: Path) -> None:
86
+ """Finds prediction with alternative naming patterns."""
87
+ results = tmp_path / "results"
88
+ results.mkdir()
89
+ pred = results / "lesion_mask.nii.gz"
90
+ pred.touch()
91
+
92
+ found = find_prediction_mask(tmp_path)
93
+ assert found == pred
94
+
95
+ def test_finds_in_output_dir_directly(self, tmp_path: Path) -> None:
96
+ """Finds prediction directly in output directory."""
97
+ pred = tmp_path / "prediction.nii.gz"
98
+ pred.touch()
99
+
100
+ found = find_prediction_mask(tmp_path)
101
+ assert found == pred
102
+
103
+ def test_finds_any_nifti(self, tmp_path: Path) -> None:
104
+ """Falls back to any NIfTI file if standard names not found."""
105
+ results = tmp_path / "results"
106
+ results.mkdir()
107
+ pred = results / "custom_output.nii.gz"
108
+ pred.touch()
109
+
110
+ found = find_prediction_mask(tmp_path)
111
+ assert found == pred
112
+
113
+ def test_excludes_input_files(self, tmp_path: Path) -> None:
114
+ """Excludes DWI/ADC/FLAIR from fallback search."""
115
+ # Only input files, no prediction
116
+ (tmp_path / "dwi.nii.gz").touch()
117
+ (tmp_path / "adc.nii.gz").touch()
118
+
119
+ with pytest.raises(DeepISLESError, match="No prediction mask found"):
120
+ find_prediction_mask(tmp_path)
121
+
122
+ def test_no_mask_found(self, tmp_path: Path) -> None:
123
+ """Raises DeepISLESError when no prediction mask found."""
124
+ with pytest.raises(DeepISLESError, match="No prediction mask found"):
125
+ find_prediction_mask(tmp_path)
126
+
127
+
128
+ class TestRunDeepISLESDirect:
129
+ """Tests for run_deepisles_direct function.
130
+
131
+ Note: These tests don't actually run DeepISLES (which requires the
132
+ DeepISLES Docker image). They test the wrapper logic only.
133
+ """
134
+
135
+ def test_missing_input_raises(self, tmp_path: Path) -> None:
136
+ """Raises MissingInputError for missing input files."""
137
+ from stroke_deepisles_demo.inference.direct import run_deepisles_direct
138
+
139
+ dwi = tmp_path / "dwi.nii.gz"
140
+ adc = tmp_path / "adc.nii.gz"
141
+ output = tmp_path / "output"
142
+
143
+ with pytest.raises(MissingInputError):
144
+ run_deepisles_direct(dwi, adc, output)
145
+
146
+ def test_deepisles_not_available_raises(
147
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
148
+ ) -> None:
149
+ """Raises DeepISLESError when DeepISLES not available."""
150
+ from stroke_deepisles_demo.inference.direct import run_deepisles_direct
151
+
152
+ # Create input files
153
+ dwi = tmp_path / "dwi.nii.gz"
154
+ adc = tmp_path / "adc.nii.gz"
155
+ output = tmp_path / "output"
156
+ dwi.touch()
157
+ adc.touch()
158
+
159
+ # Ensure DeepISLES is not importable
160
+ monkeypatch.delenv("DEEPISLES_DIRECT_INVOCATION", raising=False)
161
+
162
+ with pytest.raises(DeepISLESError, match="DeepISLES modules not found"):
163
+ run_deepisles_direct(dwi, adc, output)
uv.lock CHANGED
@@ -1541,83 +1541,26 @@ wheels = [
1541
 
1542
  [[package]]
1543
  name = "numpy"
1544
- version = "2.3.5"
1545
- source = { registry = "https://pypi.org/simple" }
1546
- sdist = { url = "https://files.pythonhosted.org/packages/76/65/21b3bc86aac7b8f2862db1e808f1ea22b028e30a225a34a5ede9bf8678f2/numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0", size = 20584950, upload-time = "2025-11-16T22:52:42.067Z" }
1547
- wheels = [
1548
- { url = "https://files.pythonhosted.org/packages/43/77/84dd1d2e34d7e2792a236ba180b5e8fcc1e3e414e761ce0253f63d7f572e/numpy-2.3.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de5672f4a7b200c15a4127042170a694d4df43c992948f5e1af57f0174beed10", size = 17034641, upload-time = "2025-11-16T22:49:19.336Z" },
1549
- { url = "https://files.pythonhosted.org/packages/2a/ea/25e26fa5837106cde46ae7d0b667e20f69cbbc0efd64cba8221411ab26ae/numpy-2.3.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:acfd89508504a19ed06ef963ad544ec6664518c863436306153e13e94605c218", size = 12528324, upload-time = "2025-11-16T22:49:22.582Z" },
1550
- { url = "https://files.pythonhosted.org/packages/4d/1a/e85f0eea4cf03d6a0228f5c0256b53f2df4bc794706e7df019fc622e47f1/numpy-2.3.5-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ffe22d2b05504f786c867c8395de703937f934272eb67586817b46188b4ded6d", size = 5356872, upload-time = "2025-11-16T22:49:25.408Z" },
1551
- { url = "https://files.pythonhosted.org/packages/5c/bb/35ef04afd567f4c989c2060cde39211e4ac5357155c1833bcd1166055c61/numpy-2.3.5-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:872a5cf366aec6bb1147336480fef14c9164b154aeb6542327de4970282cd2f5", size = 6893148, upload-time = "2025-11-16T22:49:27.549Z" },
1552
- { url = "https://files.pythonhosted.org/packages/f2/2b/05bbeb06e2dff5eab512dfc678b1cc5ee94d8ac5956a0885c64b6b26252b/numpy-2.3.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3095bdb8dd297e5920b010e96134ed91d852d81d490e787beca7e35ae1d89cf7", size = 14557282, upload-time = "2025-11-16T22:49:30.964Z" },
1553
- { url = "https://files.pythonhosted.org/packages/65/fb/2b23769462b34398d9326081fad5655198fcf18966fcb1f1e49db44fbf31/numpy-2.3.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cba086a43d54ca804ce711b2a940b16e452807acebe7852ff327f1ecd49b0d4", size = 16897903, upload-time = "2025-11-16T22:49:34.191Z" },
1554
- { url = "https://files.pythonhosted.org/packages/ac/14/085f4cf05fc3f1e8aa95e85404e984ffca9b2275a5dc2b1aae18a67538b8/numpy-2.3.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6cf9b429b21df6b99f4dee7a1218b8b7ffbbe7df8764dc0bd60ce8a0708fed1e", size = 16341672, upload-time = "2025-11-16T22:49:37.2Z" },
1555
- { url = "https://files.pythonhosted.org/packages/6f/3b/1f73994904142b2aa290449b3bb99772477b5fd94d787093e4f24f5af763/numpy-2.3.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:396084a36abdb603546b119d96528c2f6263921c50df3c8fd7cb28873a237748", size = 18838896, upload-time = "2025-11-16T22:49:39.727Z" },
1556
- { url = "https://files.pythonhosted.org/packages/cd/b9/cf6649b2124f288309ffc353070792caf42ad69047dcc60da85ee85fea58/numpy-2.3.5-cp311-cp311-win32.whl", hash = "sha256:b0c7088a73aef3d687c4deef8452a3ac7c1be4e29ed8bf3b366c8111128ac60c", size = 6563608, upload-time = "2025-11-16T22:49:42.079Z" },
1557
- { url = "https://files.pythonhosted.org/packages/aa/44/9fe81ae1dcc29c531843852e2874080dc441338574ccc4306b39e2ff6e59/numpy-2.3.5-cp311-cp311-win_amd64.whl", hash = "sha256:a414504bef8945eae5f2d7cb7be2d4af77c5d1cb5e20b296c2c25b61dff2900c", size = 13078442, upload-time = "2025-11-16T22:49:43.99Z" },
1558
- { url = "https://files.pythonhosted.org/packages/6d/a7/f99a41553d2da82a20a2f22e93c94f928e4490bb447c9ff3c4ff230581d3/numpy-2.3.5-cp311-cp311-win_arm64.whl", hash = "sha256:0cd00b7b36e35398fa2d16af7b907b65304ef8bb4817a550e06e5012929830fa", size = 10458555, upload-time = "2025-11-16T22:49:47.092Z" },
1559
- { url = "https://files.pythonhosted.org/packages/44/37/e669fe6cbb2b96c62f6bbedc6a81c0f3b7362f6a59230b23caa673a85721/numpy-2.3.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:74ae7b798248fe62021dbf3c914245ad45d1a6b0cb4a29ecb4b31d0bfbc4cc3e", size = 16733873, upload-time = "2025-11-16T22:49:49.84Z" },
1560
- { url = "https://files.pythonhosted.org/packages/c5/65/df0db6c097892c9380851ab9e44b52d4f7ba576b833996e0080181c0c439/numpy-2.3.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee3888d9ff7c14604052b2ca5535a30216aa0a58e948cdd3eeb8d3415f638769", size = 12259838, upload-time = "2025-11-16T22:49:52.863Z" },
1561
- { url = "https://files.pythonhosted.org/packages/5b/e1/1ee06e70eb2136797abe847d386e7c0e830b67ad1d43f364dd04fa50d338/numpy-2.3.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:612a95a17655e213502f60cfb9bf9408efdc9eb1d5f50535cc6eb365d11b42b5", size = 5088378, upload-time = "2025-11-16T22:49:55.055Z" },
1562
- { url = "https://files.pythonhosted.org/packages/6d/9c/1ca85fb86708724275103b81ec4cf1ac1d08f465368acfc8da7ab545bdae/numpy-2.3.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3101e5177d114a593d79dd79658650fe28b5a0d8abeb8ce6f437c0e6df5be1a4", size = 6628559, upload-time = "2025-11-16T22:49:57.371Z" },
1563
- { url = "https://files.pythonhosted.org/packages/74/78/fcd41e5a0ce4f3f7b003da85825acddae6d7ecb60cf25194741b036ca7d6/numpy-2.3.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b973c57ff8e184109db042c842423ff4f60446239bd585a5131cc47f06f789d", size = 14250702, upload-time = "2025-11-16T22:49:59.632Z" },
1564
- { url = "https://files.pythonhosted.org/packages/b6/23/2a1b231b8ff672b4c450dac27164a8b2ca7d9b7144f9c02d2396518352eb/numpy-2.3.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d8163f43acde9a73c2a33605353a4f1bc4798745a8b1d73183b28e5b435ae28", size = 16606086, upload-time = "2025-11-16T22:50:02.127Z" },
1565
- { url = "https://files.pythonhosted.org/packages/a0/c5/5ad26fbfbe2012e190cc7d5003e4d874b88bb18861d0829edc140a713021/numpy-2.3.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:51c1e14eb1e154ebd80e860722f9e6ed6ec89714ad2db2d3aa33c31d7c12179b", size = 16025985, upload-time = "2025-11-16T22:50:04.536Z" },
1566
- { url = "https://files.pythonhosted.org/packages/d2/fa/dd48e225c46c819288148d9d060b047fd2a6fb1eb37eae25112ee4cb4453/numpy-2.3.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b46b4ec24f7293f23adcd2d146960559aaf8020213de8ad1909dba6c013bf89c", size = 18542976, upload-time = "2025-11-16T22:50:07.557Z" },
1567
- { url = "https://files.pythonhosted.org/packages/05/79/ccbd23a75862d95af03d28b5c6901a1b7da4803181513d52f3b86ed9446e/numpy-2.3.5-cp312-cp312-win32.whl", hash = "sha256:3997b5b3c9a771e157f9aae01dd579ee35ad7109be18db0e85dbdbe1de06e952", size = 6285274, upload-time = "2025-11-16T22:50:10.746Z" },
1568
- { url = "https://files.pythonhosted.org/packages/2d/57/8aeaf160312f7f489dea47ab61e430b5cb051f59a98ae68b7133ce8fa06a/numpy-2.3.5-cp312-cp312-win_amd64.whl", hash = "sha256:86945f2ee6d10cdfd67bcb4069c1662dd711f7e2a4343db5cecec06b87cf31aa", size = 12782922, upload-time = "2025-11-16T22:50:12.811Z" },
1569
- { url = "https://files.pythonhosted.org/packages/78/a6/aae5cc2ca78c45e64b9ef22f089141d661516856cf7c8a54ba434576900d/numpy-2.3.5-cp312-cp312-win_arm64.whl", hash = "sha256:f28620fe26bee16243be2b7b874da327312240a7cdc38b769a697578d2100013", size = 10194667, upload-time = "2025-11-16T22:50:16.16Z" },
1570
- { url = "https://files.pythonhosted.org/packages/db/69/9cde09f36da4b5a505341180a3f2e6fadc352fd4d2b7096ce9778db83f1a/numpy-2.3.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d0f23b44f57077c1ede8c5f26b30f706498b4862d3ff0a7298b8411dd2f043ff", size = 16728251, upload-time = "2025-11-16T22:50:19.013Z" },
1571
- { url = "https://files.pythonhosted.org/packages/79/fb/f505c95ceddd7027347b067689db71ca80bd5ecc926f913f1a23e65cf09b/numpy-2.3.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa5bc7c5d59d831d9773d1170acac7893ce3a5e130540605770ade83280e7188", size = 12254652, upload-time = "2025-11-16T22:50:21.487Z" },
1572
- { url = "https://files.pythonhosted.org/packages/78/da/8c7738060ca9c31b30e9301ee0cf6c5ffdbf889d9593285a1cead337f9a5/numpy-2.3.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ccc933afd4d20aad3c00bcef049cb40049f7f196e0397f1109dba6fed63267b0", size = 5083172, upload-time = "2025-11-16T22:50:24.562Z" },
1573
- { url = "https://files.pythonhosted.org/packages/a4/b4/ee5bb2537fb9430fd2ef30a616c3672b991a4129bb1c7dcc42aa0abbe5d7/numpy-2.3.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afaffc4393205524af9dfa400fa250143a6c3bc646c08c9f5e25a9f4b4d6a903", size = 6622990, upload-time = "2025-11-16T22:50:26.47Z" },
1574
- { url = "https://files.pythonhosted.org/packages/95/03/dc0723a013c7d7c19de5ef29e932c3081df1c14ba582b8b86b5de9db7f0f/numpy-2.3.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c75442b2209b8470d6d5d8b1c25714270686f14c749028d2199c54e29f20b4d", size = 14248902, upload-time = "2025-11-16T22:50:28.861Z" },
1575
- { url = "https://files.pythonhosted.org/packages/f5/10/ca162f45a102738958dcec8023062dad0cbc17d1ab99d68c4e4a6c45fb2b/numpy-2.3.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e06aa0af8c0f05104d56450d6093ee639e15f24ecf62d417329d06e522e017", size = 16597430, upload-time = "2025-11-16T22:50:31.56Z" },
1576
- { url = "https://files.pythonhosted.org/packages/2a/51/c1e29be863588db58175175f057286900b4b3327a1351e706d5e0f8dd679/numpy-2.3.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ed89927b86296067b4f81f108a2271d8926467a8868e554eaf370fc27fa3ccaf", size = 16024551, upload-time = "2025-11-16T22:50:34.242Z" },
1577
- { url = "https://files.pythonhosted.org/packages/83/68/8236589d4dbb87253d28259d04d9b814ec0ecce7cb1c7fed29729f4c3a78/numpy-2.3.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51c55fe3451421f3a6ef9a9c1439e82101c57a2c9eab9feb196a62b1a10b58ce", size = 18533275, upload-time = "2025-11-16T22:50:37.651Z" },
1578
- { url = "https://files.pythonhosted.org/packages/40/56/2932d75b6f13465239e3b7b7e511be27f1b8161ca2510854f0b6e521c395/numpy-2.3.5-cp313-cp313-win32.whl", hash = "sha256:1978155dd49972084bd6ef388d66ab70f0c323ddee6f693d539376498720fb7e", size = 6277637, upload-time = "2025-11-16T22:50:40.11Z" },
1579
- { url = "https://files.pythonhosted.org/packages/0c/88/e2eaa6cffb115b85ed7c7c87775cb8bcf0816816bc98ca8dbfa2ee33fe6e/numpy-2.3.5-cp313-cp313-win_amd64.whl", hash = "sha256:00dc4e846108a382c5869e77c6ed514394bdeb3403461d25a829711041217d5b", size = 12779090, upload-time = "2025-11-16T22:50:42.503Z" },
1580
- { url = "https://files.pythonhosted.org/packages/8f/88/3f41e13a44ebd4034ee17baa384acac29ba6a4fcc2aca95f6f08ca0447d1/numpy-2.3.5-cp313-cp313-win_arm64.whl", hash = "sha256:0472f11f6ec23a74a906a00b48a4dcf3849209696dff7c189714511268d103ae", size = 10194710, upload-time = "2025-11-16T22:50:44.971Z" },
1581
- { url = "https://files.pythonhosted.org/packages/13/cb/71744144e13389d577f867f745b7df2d8489463654a918eea2eeb166dfc9/numpy-2.3.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:414802f3b97f3c1eef41e530aaba3b3c1620649871d8cb38c6eaff034c2e16bd", size = 16827292, upload-time = "2025-11-16T22:50:47.715Z" },
1582
- { url = "https://files.pythonhosted.org/packages/71/80/ba9dc6f2a4398e7f42b708a7fdc841bb638d353be255655498edbf9a15a8/numpy-2.3.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5ee6609ac3604fa7780e30a03e5e241a7956f8e2fcfe547d51e3afa5247ac47f", size = 12378897, upload-time = "2025-11-16T22:50:51.327Z" },
1583
- { url = "https://files.pythonhosted.org/packages/2e/6d/db2151b9f64264bcceccd51741aa39b50150de9b602d98ecfe7e0c4bff39/numpy-2.3.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:86d835afea1eaa143012a2d7a3f45a3adce2d7adc8b4961f0b362214d800846a", size = 5207391, upload-time = "2025-11-16T22:50:54.542Z" },
1584
- { url = "https://files.pythonhosted.org/packages/80/ae/429bacace5ccad48a14c4ae5332f6aa8ab9f69524193511d60ccdfdc65fa/numpy-2.3.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:30bc11310e8153ca664b14c5f1b73e94bd0503681fcf136a163de856f3a50139", size = 6721275, upload-time = "2025-11-16T22:50:56.794Z" },
1585
- { url = "https://files.pythonhosted.org/packages/74/5b/1919abf32d8722646a38cd527bc3771eb229a32724ee6ba340ead9b92249/numpy-2.3.5-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1062fde1dcf469571705945b0f221b73928f34a20c904ffb45db101907c3454e", size = 14306855, upload-time = "2025-11-16T22:50:59.208Z" },
1586
- { url = "https://files.pythonhosted.org/packages/a5/87/6831980559434973bebc30cd9c1f21e541a0f2b0c280d43d3afd909b66d0/numpy-2.3.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce581db493ea1a96c0556360ede6607496e8bf9b3a8efa66e06477267bc831e9", size = 16657359, upload-time = "2025-11-16T22:51:01.991Z" },
1587
- { url = "https://files.pythonhosted.org/packages/dd/91/c797f544491ee99fd00495f12ebb7802c440c1915811d72ac5b4479a3356/numpy-2.3.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:cc8920d2ec5fa99875b670bb86ddeb21e295cb07aa331810d9e486e0b969d946", size = 16093374, upload-time = "2025-11-16T22:51:05.291Z" },
1588
- { url = "https://files.pythonhosted.org/packages/74/a6/54da03253afcbe7a72785ec4da9c69fb7a17710141ff9ac5fcb2e32dbe64/numpy-2.3.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9ee2197ef8c4f0dfe405d835f3b6a14f5fee7782b5de51ba06fb65fc9b36e9f1", size = 18594587, upload-time = "2025-11-16T22:51:08.585Z" },
1589
- { url = "https://files.pythonhosted.org/packages/80/e9/aff53abbdd41b0ecca94285f325aff42357c6b5abc482a3fcb4994290b18/numpy-2.3.5-cp313-cp313t-win32.whl", hash = "sha256:70b37199913c1bd300ff6e2693316c6f869c7ee16378faf10e4f5e3275b299c3", size = 6405940, upload-time = "2025-11-16T22:51:11.541Z" },
1590
- { url = "https://files.pythonhosted.org/packages/d5/81/50613fec9d4de5480de18d4f8ef59ad7e344d497edbef3cfd80f24f98461/numpy-2.3.5-cp313-cp313t-win_amd64.whl", hash = "sha256:b501b5fa195cc9e24fe102f21ec0a44dffc231d2af79950b451e0d99cea02234", size = 12920341, upload-time = "2025-11-16T22:51:14.312Z" },
1591
- { url = "https://files.pythonhosted.org/packages/bb/ab/08fd63b9a74303947f34f0bd7c5903b9c5532c2d287bead5bdf4c556c486/numpy-2.3.5-cp313-cp313t-win_arm64.whl", hash = "sha256:a80afd79f45f3c4a7d341f13acbe058d1ca8ac017c165d3fa0d3de6bc1a079d7", size = 10262507, upload-time = "2025-11-16T22:51:16.846Z" },
1592
- { url = "https://files.pythonhosted.org/packages/ba/97/1a914559c19e32d6b2e233cf9a6a114e67c856d35b1d6babca571a3e880f/numpy-2.3.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:bf06bc2af43fa8d32d30fae16ad965663e966b1a3202ed407b84c989c3221e82", size = 16735706, upload-time = "2025-11-16T22:51:19.558Z" },
1593
- { url = "https://files.pythonhosted.org/packages/57/d4/51233b1c1b13ecd796311216ae417796b88b0616cfd8a33ae4536330748a/numpy-2.3.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:052e8c42e0c49d2575621c158934920524f6c5da05a1d3b9bab5d8e259e045f0", size = 12264507, upload-time = "2025-11-16T22:51:22.492Z" },
1594
- { url = "https://files.pythonhosted.org/packages/45/98/2fe46c5c2675b8306d0b4a3ec3494273e93e1226a490f766e84298576956/numpy-2.3.5-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:1ed1ec893cff7040a02c8aa1c8611b94d395590d553f6b53629a4461dc7f7b63", size = 5093049, upload-time = "2025-11-16T22:51:25.171Z" },
1595
- { url = "https://files.pythonhosted.org/packages/ce/0e/0698378989bb0ac5f1660c81c78ab1fe5476c1a521ca9ee9d0710ce54099/numpy-2.3.5-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2dcd0808a421a482a080f89859a18beb0b3d1e905b81e617a188bd80422d62e9", size = 6626603, upload-time = "2025-11-16T22:51:27Z" },
1596
- { url = "https://files.pythonhosted.org/packages/5e/a6/9ca0eecc489640615642a6cbc0ca9e10df70df38c4d43f5a928ff18d8827/numpy-2.3.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727fd05b57df37dc0bcf1a27767a3d9a78cbbc92822445f32cc3436ba797337b", size = 14262696, upload-time = "2025-11-16T22:51:29.402Z" },
1597
- { url = "https://files.pythonhosted.org/packages/c8/f6/07ec185b90ec9d7217a00eeeed7383b73d7e709dae2a9a021b051542a708/numpy-2.3.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fffe29a1ef00883599d1dc2c51aa2e5d80afe49523c261a74933df395c15c520", size = 16597350, upload-time = "2025-11-16T22:51:32.167Z" },
1598
- { url = "https://files.pythonhosted.org/packages/75/37/164071d1dde6a1a84c9b8e5b414fa127981bad47adf3a6b7e23917e52190/numpy-2.3.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f7f0e05112916223d3f438f293abf0727e1181b5983f413dfa2fefc4098245c", size = 16040190, upload-time = "2025-11-16T22:51:35.403Z" },
1599
- { url = "https://files.pythonhosted.org/packages/08/3c/f18b82a406b04859eb026d204e4e1773eb41c5be58410f41ffa511d114ae/numpy-2.3.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2e2eb32ddb9ccb817d620ac1d8dae7c3f641c1e5f55f531a33e8ab97960a75b8", size = 18536749, upload-time = "2025-11-16T22:51:39.698Z" },
1600
- { url = "https://files.pythonhosted.org/packages/40/79/f82f572bf44cf0023a2fe8588768e23e1592585020d638999f15158609e1/numpy-2.3.5-cp314-cp314-win32.whl", hash = "sha256:66f85ce62c70b843bab1fb14a05d5737741e74e28c7b8b5a064de10142fad248", size = 6335432, upload-time = "2025-11-16T22:51:42.476Z" },
1601
- { url = "https://files.pythonhosted.org/packages/a3/2e/235b4d96619931192c91660805e5e49242389742a7a82c27665021db690c/numpy-2.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:e6a0bc88393d65807d751a614207b7129a310ca4fe76a74e5c7da5fa5671417e", size = 12919388, upload-time = "2025-11-16T22:51:45.275Z" },
1602
- { url = "https://files.pythonhosted.org/packages/07/2b/29fd75ce45d22a39c61aad74f3d718e7ab67ccf839ca8b60866054eb15f8/numpy-2.3.5-cp314-cp314-win_arm64.whl", hash = "sha256:aeffcab3d4b43712bb7a60b65f6044d444e75e563ff6180af8f98dd4b905dfd2", size = 10476651, upload-time = "2025-11-16T22:51:47.749Z" },
1603
- { url = "https://files.pythonhosted.org/packages/17/e1/f6a721234ebd4d87084cfa68d081bcba2f5cfe1974f7de4e0e8b9b2a2ba1/numpy-2.3.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17531366a2e3a9e30762c000f2c43a9aaa05728712e25c11ce1dbe700c53ad41", size = 16834503, upload-time = "2025-11-16T22:51:50.443Z" },
1604
- { url = "https://files.pythonhosted.org/packages/5c/1c/baf7ffdc3af9c356e1c135e57ab7cf8d247931b9554f55c467efe2c69eff/numpy-2.3.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d21644de1b609825ede2f48be98dfde4656aefc713654eeee280e37cadc4e0ad", size = 12381612, upload-time = "2025-11-16T22:51:53.609Z" },
1605
- { url = "https://files.pythonhosted.org/packages/74/91/f7f0295151407ddc9ba34e699013c32c3c91944f9b35fcf9281163dc1468/numpy-2.3.5-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c804e3a5aba5460c73955c955bdbd5c08c354954e9270a2c1565f62e866bdc39", size = 5210042, upload-time = "2025-11-16T22:51:56.213Z" },
1606
- { url = "https://files.pythonhosted.org/packages/2e/3b/78aebf345104ec50dd50a4d06ddeb46a9ff5261c33bcc58b1c4f12f85ec2/numpy-2.3.5-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:cc0a57f895b96ec78969c34f682c602bf8da1a0270b09bc65673df2e7638ec20", size = 6724502, upload-time = "2025-11-16T22:51:58.584Z" },
1607
- { url = "https://files.pythonhosted.org/packages/02/c6/7c34b528740512e57ef1b7c8337ab0b4f0bddf34c723b8996c675bc2bc91/numpy-2.3.5-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:900218e456384ea676e24ea6a0417f030a3b07306d29d7ad843957b40a9d8d52", size = 14308962, upload-time = "2025-11-16T22:52:01.698Z" },
1608
- { url = "https://files.pythonhosted.org/packages/80/35/09d433c5262bc32d725bafc619e095b6a6651caf94027a03da624146f655/numpy-2.3.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09a1bea522b25109bf8e6f3027bd810f7c1085c64a0c7ce050c1676ad0ba010b", size = 16655054, upload-time = "2025-11-16T22:52:04.267Z" },
1609
- { url = "https://files.pythonhosted.org/packages/7a/ab/6a7b259703c09a88804fa2430b43d6457b692378f6b74b356155283566ac/numpy-2.3.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04822c00b5fd0323c8166d66c701dc31b7fbd252c100acd708c48f763968d6a3", size = 16091613, upload-time = "2025-11-16T22:52:08.651Z" },
1610
- { url = "https://files.pythonhosted.org/packages/c2/88/330da2071e8771e60d1038166ff9d73f29da37b01ec3eb43cb1427464e10/numpy-2.3.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d6889ec4ec662a1a37eb4b4fb26b6100841804dac55bd9df579e326cdc146227", size = 18591147, upload-time = "2025-11-16T22:52:11.453Z" },
1611
- { url = "https://files.pythonhosted.org/packages/51/41/851c4b4082402d9ea860c3626db5d5df47164a712cb23b54be028b184c1c/numpy-2.3.5-cp314-cp314t-win32.whl", hash = "sha256:93eebbcf1aafdf7e2ddd44c2923e2672e1010bddc014138b229e49725b4d6be5", size = 6479806, upload-time = "2025-11-16T22:52:14.641Z" },
1612
- { url = "https://files.pythonhosted.org/packages/90/30/d48bde1dfd93332fa557cff1972fbc039e055a52021fbef4c2c4b1eefd17/numpy-2.3.5-cp314-cp314t-win_amd64.whl", hash = "sha256:c8a9958e88b65c3b27e22ca2a076311636850b612d6bbfb76e8d156aacde2aaf", size = 13105760, upload-time = "2025-11-16T22:52:17.975Z" },
1613
- { url = "https://files.pythonhosted.org/packages/2d/fd/4b5eb0b3e888d86aee4d198c23acec7d214baaf17ea93c1adec94c9518b9/numpy-2.3.5-cp314-cp314t-win_arm64.whl", hash = "sha256:6203fdf9f3dc5bdaed7319ad8698e685c7a3be10819f41d32a0723e611733b42", size = 10545459, upload-time = "2025-11-16T22:52:20.55Z" },
1614
- { url = "https://files.pythonhosted.org/packages/c6/65/f9dea8e109371ade9c782b4e4756a82edf9d3366bca495d84d79859a0b79/numpy-2.3.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f0963b55cdd70fad460fa4c1341f12f976bb26cb66021a5580329bd498988310", size = 16910689, upload-time = "2025-11-16T22:52:23.247Z" },
1615
- { url = "https://files.pythonhosted.org/packages/00/4f/edb00032a8fb92ec0a679d3830368355da91a69cab6f3e9c21b64d0bb986/numpy-2.3.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f4255143f5160d0de972d28c8f9665d882b5f61309d8362fdd3e103cf7bf010c", size = 12457053, upload-time = "2025-11-16T22:52:26.367Z" },
1616
- { url = "https://files.pythonhosted.org/packages/16/a4/e8a53b5abd500a63836a29ebe145fc1ab1f2eefe1cfe59276020373ae0aa/numpy-2.3.5-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:a4b9159734b326535f4dd01d947f919c6eefd2d9827466a696c44ced82dfbc18", size = 5285635, upload-time = "2025-11-16T22:52:29.266Z" },
1617
- { url = "https://files.pythonhosted.org/packages/a3/2f/37eeb9014d9c8b3e9c55bc599c68263ca44fdbc12a93e45a21d1d56df737/numpy-2.3.5-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2feae0d2c91d46e59fcd62784a3a83b3fb677fead592ce51b5a6fbb4f95965ff", size = 6801770, upload-time = "2025-11-16T22:52:31.421Z" },
1618
- { url = "https://files.pythonhosted.org/packages/7d/e4/68d2f474df2cb671b2b6c2986a02e520671295647dad82484cde80ca427b/numpy-2.3.5-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffac52f28a7849ad7576293c0cb7b9f08304e8f7d738a8cb8a90ec4c55a998eb", size = 14391768, upload-time = "2025-11-16T22:52:33.593Z" },
1619
- { url = "https://files.pythonhosted.org/packages/b8/50/94ccd8a2b141cb50651fddd4f6a48874acb3c91c8f0842b08a6afc4b0b21/numpy-2.3.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63c0e9e7eea69588479ebf4a8a270d5ac22763cc5854e9a7eae952a3908103f7", size = 16729263, upload-time = "2025-11-16T22:52:36.369Z" },
1620
- { url = "https://files.pythonhosted.org/packages/2d/ee/346fa473e666fe14c52fcdd19ec2424157290a032d4c41f98127bfb31ac7/numpy-2.3.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f16417ec91f12f814b10bafe79ef77e70113a2f5f7018640e7425ff979253425", size = 12967213, upload-time = "2025-11-16T22:52:39.38Z" },
1621
  ]
1622
 
1623
  [[package]]
@@ -2468,11 +2411,11 @@ dev = [
2468
  [package.metadata]
2469
  requires-dist = [
2470
  { name = "datasets", git = "https://github.com/CloseChoice/datasets.git?rev=feat%2Fbids-loader-streaming-upload-fix" },
2471
- { name = "gradio", specifier = ">=5.0.0" },
2472
  { name = "huggingface-hub", specifier = ">=0.25.0" },
2473
  { name = "matplotlib", specifier = ">=3.8.0" },
2474
  { name = "nibabel", specifier = ">=5.2.0" },
2475
- { name = "numpy", specifier = ">=1.26.0" },
2476
  { name = "pydantic", specifier = ">=2.5.0" },
2477
  { name = "pydantic-settings", specifier = ">=2.1.0" },
2478
  { name = "requests", specifier = ">=2.0.0" },
 
1541
 
1542
  [[package]]
1543
  name = "numpy"
1544
+ version = "1.26.4"
1545
+ source = { registry = "https://pypi.org/simple" }
1546
+ sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" }
1547
+ wheels = [
1548
+ { url = "https://files.pythonhosted.org/packages/11/57/baae43d14fe163fa0e4c47f307b6b2511ab8d7d30177c491960504252053/numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71", size = 20630554, upload-time = "2024-02-05T23:51:50.149Z" },
1549
+ { url = "https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef", size = 13997127, upload-time = "2024-02-05T23:52:15.314Z" },
1550
+ { url = "https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e", size = 14222994, upload-time = "2024-02-05T23:52:47.569Z" },
1551
+ { url = "https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5", size = 18252005, upload-time = "2024-02-05T23:53:15.637Z" },
1552
+ { url = "https://files.pythonhosted.org/packages/09/bf/2b1aaf8f525f2923ff6cfcf134ae5e750e279ac65ebf386c75a0cf6da06a/numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a", size = 13885297, upload-time = "2024-02-05T23:53:42.16Z" },
1553
+ { url = "https://files.pythonhosted.org/packages/df/a0/4e0f14d847cfc2a633a1c8621d00724f3206cfeddeb66d35698c4e2cf3d2/numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a", size = 18093567, upload-time = "2024-02-05T23:54:11.696Z" },
1554
+ { url = "https://files.pythonhosted.org/packages/d2/b7/a734c733286e10a7f1a8ad1ae8c90f2d33bf604a96548e0a4a3a6739b468/numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20", size = 5968812, upload-time = "2024-02-05T23:54:26.453Z" },
1555
+ { url = "https://files.pythonhosted.org/packages/3f/6b/5610004206cf7f8e7ad91c5a85a8c71b2f2f8051a0c0c4d5916b76d6cbb2/numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2", size = 15811913, upload-time = "2024-02-05T23:54:53.933Z" },
1556
+ { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901, upload-time = "2024-02-05T23:55:32.801Z" },
1557
+ { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868, upload-time = "2024-02-05T23:55:56.28Z" },
1558
+ { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109, upload-time = "2024-02-05T23:56:20.368Z" },
1559
+ { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613, upload-time = "2024-02-05T23:56:56.054Z" },
1560
+ { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172, upload-time = "2024-02-05T23:57:21.56Z" },
1561
+ { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload-time = "2024-02-05T23:57:56.585Z" },
1562
+ { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload-time = "2024-02-05T23:58:08.963Z" },
1563
+ { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1564
  ]
1565
 
1566
  [[package]]
 
2411
  [package.metadata]
2412
  requires-dist = [
2413
  { name = "datasets", git = "https://github.com/CloseChoice/datasets.git?rev=feat%2Fbids-loader-streaming-upload-fix" },
2414
+ { name = "gradio", specifier = ">=6.0.0,<7.0.0" },
2415
  { name = "huggingface-hub", specifier = ">=0.25.0" },
2416
  { name = "matplotlib", specifier = ">=3.8.0" },
2417
  { name = "nibabel", specifier = ">=5.2.0" },
2418
+ { name = "numpy", specifier = ">=1.26.0,<2.0.0" },
2419
  { name = "pydantic", specifier = ">=2.5.0" },
2420
  { name = "pydantic-settings", specifier = ">=2.1.0" },
2421
  { name = "requests", specifier = ">=2.0.0" },