# phase 2: deepisles docker integration ## purpose Create a Python wrapper that calls the DeepISLES Docker image as a black box. At the end of this phase, we can run stroke lesion segmentation on a folder of NIfTI files and get back the predicted mask. ## deliverables - [ ] `src/stroke_deepisles_demo/inference/docker.py` - Docker execution wrapper - [ ] `src/stroke_deepisles_demo/inference/deepisles.py` - DeepISLES-specific CLI interface - [ ] Unit tests with subprocess mocking - [ ] Integration test (marked, requires Docker) ## vertical slice outcome After this phase, you can run: ```python from stroke_deepisles_demo.inference import run_deepisles_on_folder # input_dir contains: dwi.nii.gz, adc.nii.gz result = run_deepisles_on_folder( input_dir=Path("/path/to/staged/case"), fast=True, ) print(f"Prediction mask: {result.prediction_path}") print(f"Elapsed: {result.elapsed_seconds:.1f}s") ``` ## module structure ``` src/stroke_deepisles_demo/inference/ ├── __init__.py # Public API exports ├── docker.py # Generic Docker execution utilities └── deepisles.py # DeepISLES-specific wrapper ``` ## deepisles cli reference From the [DeepIsles repository](https://github.com/ezequieldlrosa/DeepIsles), the Docker interface expects: ```bash docker run --rm \ -v /path/to/input:/input \ -v /path/to/output:/output \ --gpus all \ isleschallenge/deepisles \ --dwi_file_name dwi.nii.gz \ --adc_file_name adc.nii.gz \ [--flair_file_name flair.nii.gz] \ --fast True # Single model mode, faster ``` **Expected input files:** - `dwi.nii.gz` (required) - Diffusion-weighted imaging - `adc.nii.gz` (required) - Apparent diffusion coefficient - `flair.nii.gz` (optional) - Required for full ensemble, not needed for fast mode **Output:** - `results/` directory containing the lesion mask ### Why `--fast True` (SEALS-only mode) DeepISLES contains 3 models from the ISLES'22 challenge: | Model | Inputs | Notes | |-------|--------|-------| | **SEALS** (nnUNet) | DWI + ADC | 🏆 ISLES'22 Winner - runs with `--fast True` | | NVAUTO (MONAI) | DWI + ADC + FLAIR | Requires FLAIR | | SWAN (FACTORIZER) | DWI + ADC + FLAIR | Requires FLAIR | **We default to `fast=True` because:** 1. ISLES24-MR-Lite only has DWI + ADC (no FLAIR) 2. SEALS alone is the challenge-winning algorithm 3. Running the full ensemble without FLAIR would fail for 2/3 models This is not a compromise—SEALS is state-of-the-art for DWI+ADC stroke segmentation. ## interfaces and types ### `inference/docker.py` ```python """Docker execution utilities.""" from __future__ import annotations import subprocess from dataclasses import dataclass from pathlib import Path from typing import Sequence from stroke_deepisles_demo.core.exceptions import DockerNotAvailableError @dataclass(frozen=True) class DockerRunResult: """Result of a Docker container run.""" exit_code: int stdout: str stderr: str elapsed_seconds: float def check_docker_available() -> bool: """ Check if Docker is installed and the daemon is running. Returns: True if Docker is available, False otherwise """ ... def ensure_docker_available() -> None: """ Ensure Docker is available, raising if not. Raises: DockerNotAvailableError: If Docker is not installed or not running """ ... def pull_image_if_missing(image: str, *, timeout: float = 600) -> bool: """ Pull a Docker image if not present locally. Args: image: Docker image name (e.g., "isleschallenge/deepisles") timeout: Maximum seconds to wait for pull Returns: True if image was pulled, False if already present """ ... def run_container( image: str, *, command: Sequence[str] | None = None, volumes: dict[Path, str] | None = None, # host_path -> container_path environment: dict[str, str] | None = None, gpu: bool = False, remove: bool = True, timeout: float | None = None, ) -> DockerRunResult: """ Run a Docker container and wait for completion. Args: image: Docker image name command: Command to run in container volumes: Volume mounts (host path -> container path) environment: Environment variables gpu: If True, pass --gpus all remove: If True, remove container after exit (--rm) timeout: Maximum seconds to wait (None = no timeout) Returns: DockerRunResult with exit code, stdout, stderr, elapsed time Raises: DockerNotAvailableError: If Docker is not available subprocess.TimeoutExpired: If timeout exceeded """ ... def build_docker_command( image: str, *, command: Sequence[str] | None = None, volumes: dict[Path, str] | None = None, environment: dict[str, str] | None = None, gpu: bool = False, remove: bool = True, ) -> list[str]: """ Build the docker run command without executing. Useful for logging/debugging. Returns: List of command arguments for subprocess """ ... ``` ### `inference/deepisles.py` ```python """DeepISLES stroke segmentation wrapper.""" from __future__ import annotations import time from dataclasses import dataclass from pathlib import Path from stroke_deepisles_demo.core.config import settings from stroke_deepisles_demo.core.exceptions import DeepISLESError, MissingInputError from stroke_deepisles_demo.inference.docker import ( DockerRunResult, ensure_docker_available, run_container, ) @dataclass(frozen=True) class DeepISLESResult: """Result of DeepISLES inference.""" prediction_path: Path docker_result: DockerRunResult elapsed_seconds: float def validate_input_folder(input_dir: Path) -> tuple[Path, Path, Path | None]: """ Validate that input folder contains required files. Args: input_dir: Directory to validate Returns: Tuple of (dwi_path, adc_path, flair_path_or_none) Raises: MissingInputError: If required files are missing """ ... def run_deepisles_on_folder( input_dir: Path, *, output_dir: Path | None = None, fast: bool = True, gpu: bool = True, timeout: float | None = 1800, # 30 minutes default ) -> DeepISLESResult: """ Run DeepISLES stroke segmentation on a folder of NIfTI files. Args: input_dir: Directory containing dwi.nii.gz, adc.nii.gz, [flair.nii.gz] output_dir: Where to write results (default: input_dir/results) fast: If True, use single-model mode (faster, slightly less accurate) gpu: If True, use GPU acceleration timeout: Maximum seconds to wait for inference Returns: DeepISLESResult with path to prediction mask Raises: DockerNotAvailableError: If Docker is not available MissingInputError: If required input files are missing DeepISLESError: If inference fails (non-zero exit, missing output) Example: >>> result = run_deepisles_on_folder(Path("/data/case001"), fast=True) >>> print(result.prediction_path) /data/case001/results/prediction.nii.gz """ ... def find_prediction_mask(output_dir: Path) -> Path: """ Find the prediction mask in DeepISLES output directory. DeepISLES outputs may have varying names depending on version. This function finds the most likely prediction file. Args: output_dir: DeepISLES output directory Returns: Path to the prediction mask NIfTI file Raises: DeepISLESError: If no prediction mask found """ ... # Constants DEEPISLES_IMAGE = "isleschallenge/deepisles" EXPECTED_INPUT_FILES = ["dwi.nii.gz", "adc.nii.gz"] OPTIONAL_INPUT_FILES = ["flair.nii.gz"] ``` ### `inference/__init__.py` (public API) ```python """Inference module for stroke-deepisles-demo.""" from stroke_deepisles_demo.inference.deepisles import ( DEEPISLES_IMAGE, DeepISLESResult, run_deepisles_on_folder, validate_input_folder, ) from stroke_deepisles_demo.inference.docker import ( DockerRunResult, build_docker_command, check_docker_available, ensure_docker_available, run_container, ) __all__ = [ # DeepISLES "run_deepisles_on_folder", "validate_input_folder", "DeepISLESResult", "DEEPISLES_IMAGE", # Docker utilities "check_docker_available", "ensure_docker_available", "run_container", "build_docker_command", "DockerRunResult", ] ``` ## tdd plan ### test file structure ``` tests/ ├── inference/ │ ├── __init__.py │ ├── test_docker.py # Tests for Docker utilities │ └── test_deepisles.py # Tests for DeepISLES wrapper ``` ### tests to write first (TDD order) #### 1. `tests/inference/test_docker.py` ```python """Tests for Docker utilities.""" from __future__ import annotations import subprocess from pathlib import Path from unittest.mock import MagicMock, patch import pytest from stroke_deepisles_demo.core.exceptions import DockerNotAvailableError from stroke_deepisles_demo.inference.docker import ( build_docker_command, check_docker_available, ensure_docker_available, run_container, ) class TestCheckDockerAvailable: """Tests for check_docker_available.""" def test_returns_true_when_docker_responds(self) -> None: """Returns True when 'docker info' succeeds.""" with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0) result = check_docker_available() assert result is True def test_returns_false_when_docker_not_found(self) -> None: """Returns False when docker command not found.""" with patch("subprocess.run") as mock_run: mock_run.side_effect = FileNotFoundError() result = check_docker_available() assert result is False def test_returns_false_when_daemon_not_running(self) -> None: """Returns False when docker daemon not running.""" with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=1) result = check_docker_available() assert result is False class TestEnsureDockerAvailable: """Tests for ensure_docker_available.""" def test_raises_when_docker_not_available(self) -> None: """Raises DockerNotAvailableError when Docker not available.""" with patch( "stroke_deepisles_demo.inference.docker.check_docker_available", return_value=False, ): with pytest.raises(DockerNotAvailableError): ensure_docker_available() def test_no_error_when_docker_available(self) -> None: """No exception when Docker is available.""" with patch( "stroke_deepisles_demo.inference.docker.check_docker_available", return_value=True, ): ensure_docker_available() # Should not raise class TestBuildDockerCommand: """Tests for build_docker_command.""" def test_basic_command(self) -> None: """Builds basic docker run command.""" cmd = build_docker_command("myimage:latest") assert cmd[0] == "docker" assert "run" in cmd assert "myimage:latest" in cmd def test_includes_rm_flag(self) -> None: """Includes --rm when remove=True.""" cmd = build_docker_command("myimage", remove=True) assert "--rm" in cmd def test_excludes_rm_flag(self) -> None: """Excludes --rm when remove=False.""" cmd = build_docker_command("myimage", remove=False) assert "--rm" not in cmd def test_includes_gpu_flag(self) -> None: """Includes --gpus all when gpu=True.""" cmd = build_docker_command("myimage", gpu=True) assert "--gpus" in cmd gpu_index = cmd.index("--gpus") assert cmd[gpu_index + 1] == "all" def test_volume_mounts(self, temp_dir: Path) -> None: """Includes volume mounts.""" volumes = {temp_dir: "/data"} cmd = build_docker_command("myimage", volumes=volumes) assert "-v" in cmd # Find the volume argument v_index = cmd.index("-v") assert f"{temp_dir}:/data" in cmd[v_index + 1] def test_custom_command(self) -> None: """Appends custom command arguments.""" cmd = build_docker_command( "myimage", command=["--input", "/data", "--fast", "True"] ) assert "--input" in cmd assert "--fast" in cmd class TestRunContainer: """Tests for run_container.""" def test_calls_subprocess_with_built_command(self) -> None: """Calls subprocess.run with built command.""" with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=0, stdout="output", stderr="" ) with patch( "stroke_deepisles_demo.inference.docker.ensure_docker_available" ): run_container("myimage") mock_run.assert_called_once() def test_returns_result_with_exit_code(self) -> None: """Returns DockerRunResult with correct exit code.""" with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=42, stdout="out", stderr="err" ) with patch( "stroke_deepisles_demo.inference.docker.ensure_docker_available" ): result = run_container("myimage") assert result.exit_code == 42 def test_captures_stdout_stderr(self) -> None: """Captures stdout and stderr from container.""" with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=0, stdout="hello", stderr="warning" ) with patch( "stroke_deepisles_demo.inference.docker.ensure_docker_available" ): result = run_container("myimage") assert result.stdout == "hello" assert result.stderr == "warning" def test_respects_timeout(self) -> None: """Passes timeout to subprocess.""" with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") with patch( "stroke_deepisles_demo.inference.docker.ensure_docker_available" ): run_container("myimage", timeout=60.0) call_kwargs = mock_run.call_args.kwargs assert call_kwargs.get("timeout") == 60.0 @pytest.mark.integration class TestDockerIntegration: """Integration tests requiring real Docker.""" def test_docker_actually_available(self) -> None: """Docker is actually available on this system.""" # This test only runs with -m integration assert check_docker_available() is True def test_can_run_hello_world(self) -> None: """Can run docker hello-world container.""" result = run_container("hello-world", timeout=60.0) assert result.exit_code == 0 assert "Hello from Docker!" in result.stdout ``` #### 2. `tests/inference/test_deepisles.py` ```python """Tests for DeepISLES wrapper.""" from __future__ import annotations from pathlib import Path from unittest.mock import MagicMock, patch import pytest from stroke_deepisles_demo.core.exceptions import DeepISLESError, MissingInputError from stroke_deepisles_demo.inference.deepisles import ( DeepISLESResult, find_prediction_mask, run_deepisles_on_folder, validate_input_folder, ) class TestValidateInputFolder: """Tests for validate_input_folder.""" def test_succeeds_with_required_files(self, temp_dir: Path) -> None: """Returns paths when required files exist.""" (temp_dir / "dwi.nii.gz").touch() (temp_dir / "adc.nii.gz").touch() dwi, adc, flair = validate_input_folder(temp_dir) assert dwi == temp_dir / "dwi.nii.gz" assert adc == temp_dir / "adc.nii.gz" assert flair is None def test_includes_flair_when_present(self, temp_dir: Path) -> None: """Returns FLAIR path when present.""" (temp_dir / "dwi.nii.gz").touch() (temp_dir / "adc.nii.gz").touch() (temp_dir / "flair.nii.gz").touch() dwi, adc, flair = validate_input_folder(temp_dir) assert flair == temp_dir / "flair.nii.gz" def test_raises_when_dwi_missing(self, temp_dir: Path) -> None: """Raises MissingInputError when DWI is missing.""" (temp_dir / "adc.nii.gz").touch() with pytest.raises(MissingInputError, match="dwi"): validate_input_folder(temp_dir) def test_raises_when_adc_missing(self, temp_dir: Path) -> None: """Raises MissingInputError when ADC is missing.""" (temp_dir / "dwi.nii.gz").touch() with pytest.raises(MissingInputError, match="adc"): validate_input_folder(temp_dir) class TestFindPredictionMask: """Tests for find_prediction_mask.""" def test_finds_prediction_file(self, temp_dir: Path) -> None: """Finds prediction.nii.gz in output directory.""" results_dir = temp_dir / "results" results_dir.mkdir() pred_file = results_dir / "prediction.nii.gz" pred_file.touch() result = find_prediction_mask(temp_dir) assert result == pred_file def test_raises_when_no_prediction(self, temp_dir: Path) -> None: """Raises DeepISLESError when no prediction found.""" results_dir = temp_dir / "results" results_dir.mkdir() with pytest.raises(DeepISLESError, match="prediction"): find_prediction_mask(temp_dir) class TestRunDeepIslesOnFolder: """Tests for run_deepisles_on_folder.""" @pytest.fixture def valid_input_dir(self, temp_dir: Path) -> Path: """Create a valid input directory with required files.""" (temp_dir / "dwi.nii.gz").touch() (temp_dir / "adc.nii.gz").touch() return temp_dir def test_validates_input_files(self, temp_dir: Path) -> None: """Validates input files before running Docker.""" # Missing required files with pytest.raises(MissingInputError): run_deepisles_on_folder(temp_dir) def test_calls_docker_with_correct_image(self, valid_input_dir: Path) -> None: """Calls Docker with DeepISLES image.""" with patch( "stroke_deepisles_demo.inference.deepisles.run_container" ) as mock_run: mock_run.return_value = MagicMock(exit_code=0, stdout="", stderr="") # Also mock finding the prediction with patch( "stroke_deepisles_demo.inference.deepisles.find_prediction_mask" ) as mock_find: mock_find.return_value = valid_input_dir / "results" / "pred.nii.gz" run_deepisles_on_folder(valid_input_dir) # Check image name call_args = mock_run.call_args assert "isleschallenge/deepisles" in str(call_args) def test_passes_fast_flag(self, valid_input_dir: Path) -> None: """Passes --fast True when fast=True.""" with patch( "stroke_deepisles_demo.inference.deepisles.run_container" ) as mock_run: mock_run.return_value = MagicMock(exit_code=0, stdout="", stderr="") with patch( "stroke_deepisles_demo.inference.deepisles.find_prediction_mask" ) as mock_find: mock_find.return_value = valid_input_dir / "results" / "pred.nii.gz" run_deepisles_on_folder(valid_input_dir, fast=True) # Check --fast in command call_kwargs = mock_run.call_args.kwargs command = call_kwargs.get("command", []) assert "--fast" in command def test_raises_on_docker_failure(self, valid_input_dir: Path) -> None: """Raises DeepISLESError when Docker returns non-zero.""" with patch( "stroke_deepisles_demo.inference.deepisles.run_container" ) as mock_run: mock_run.return_value = MagicMock( exit_code=1, stdout="", stderr="Segmentation fault" ) with pytest.raises(DeepISLESError, match="failed"): run_deepisles_on_folder(valid_input_dir) def test_returns_result_with_prediction_path(self, valid_input_dir: Path) -> None: """Returns DeepISLESResult with prediction path.""" with patch( "stroke_deepisles_demo.inference.deepisles.run_container" ) as mock_run: mock_run.return_value = MagicMock(exit_code=0, stdout="", stderr="") with patch( "stroke_deepisles_demo.inference.deepisles.find_prediction_mask" ) as mock_find: expected_path = valid_input_dir / "results" / "prediction.nii.gz" mock_find.return_value = expected_path result = run_deepisles_on_folder(valid_input_dir) assert isinstance(result, DeepISLESResult) assert result.prediction_path == expected_path @pytest.mark.integration @pytest.mark.slow class TestDeepIslesIntegration: """Integration tests requiring real Docker and DeepISLES image.""" def test_real_inference(self, synthetic_case_files) -> None: """Run actual DeepISLES inference on synthetic data.""" # This test requires: # 1. Docker available # 2. isleschallenge/deepisles image pulled # 3. GPU (optional but recommended) # # Run with: pytest -m "integration and slow" from stroke_deepisles_demo.data.staging import stage_case_for_deepisles # Stage the synthetic files staged = stage_case_for_deepisles( synthetic_case_files, Path("/tmp/deepisles_test"), ) # Run inference result = run_deepisles_on_folder( staged.input_dir, fast=True, gpu=False, # Might not have GPU in CI timeout=600, ) # Verify output exists assert result.prediction_path.exists() ``` ### what to mock - `subprocess.run` - Mock for all unit tests - `check_docker_available` - Mock to control Docker availability - `run_container` - Mock in DeepISLES tests to avoid Docker - File system for prediction finding - Use temp directories ### what to test for real - Command building (no subprocess needed) - Input validation (real file system with temp dirs) - Integration test: actual Docker hello-world - Integration test: actual DeepISLES inference (marked `slow`) ## "done" criteria Phase 2 is complete when: 1. All unit tests pass: `uv run pytest tests/inference/ -v` 2. Can build Docker commands correctly 3. Can validate input folders 4. Unit tests don't require Docker (all mocked) 5. Integration test passes with Docker: `uv run pytest -m integration tests/inference/` 6. Type checking passes: `uv run mypy src/stroke_deepisles_demo/inference/` 7. Code coverage for inference module > 80% ## implementation notes - Check DeepISLES repo for exact output file names/structure - Consider `--gpus all` vs `--gpus '"device=0"'` for GPU selection - Timeout should be generous (30+ minutes) for full ensemble mode - Log Docker stdout/stderr for debugging - Consider streaming Docker output for long-running inference ### critical: docker file permissions (linux) **Reviewer feedback (valid)**: Docker containers run as root by default on Linux. Output files written to mounted volumes will be owned by root:root. The Python process running as a normal user will fail to read or delete these files. **Solution**: Pass `--user` flag to match host user: ```python def build_docker_command( image: str, *, volumes: dict[Path, str] | None = None, gpu: bool = False, remove: bool = True, match_user: bool = True, # NEW: default True on Linux ) -> list[str]: """Build docker run command.""" cmd = ["docker", "run"] if remove: cmd.append("--rm") if gpu: cmd.extend(["--gpus", "all"]) # Match host user to avoid permission issues if match_user and sys.platform != "darwin": # Not needed on macOS import os uid = os.getuid() gid = os.getgid() cmd.extend(["--user", f"{uid}:{gid}"]) if volumes: for host_path, container_path in volumes.items(): cmd.extend(["-v", f"{host_path}:{container_path}"]) cmd.append(image) return cmd ``` ### critical: gpu availability check **Reviewer feedback (valid)**: We check for Docker daemon but not NVIDIA Container Runtime. A user might have Docker but lack GPU passthrough setup. **Solution**: Add GPU-specific availability check: ```python def check_nvidia_docker_available() -> bool: """ Check if NVIDIA Container Runtime is available for GPU support. Returns: True if nvidia-docker/nvidia-container-toolkit is configured """ try: result = subprocess.run( ["docker", "run", "--rm", "--gpus", "all", "nvidia/cuda:11.0-base", "nvidia-smi"], capture_output=True, timeout=30, ) return result.returncode == 0 except (subprocess.TimeoutExpired, FileNotFoundError): return False def ensure_gpu_available_if_requested(gpu: bool) -> None: """ Verify GPU is available if requested, or warn user. Raises: DockerGPUNotAvailableError: If GPU requested but not available """ if gpu and not check_nvidia_docker_available(): raise DockerGPUNotAvailableError( "GPU requested but NVIDIA Container Runtime not available. " "Either install nvidia-container-toolkit or set gpu=False." ) ``` Add to exceptions: ```python class DockerGPUNotAvailableError(StrokeDemoError): """GPU requested but NVIDIA Container Runtime not available.""" ``` ### nifti orientation (medium risk) **Reviewer feedback (noted)**: DeepISLES may expect specific anatomical orientation (e.g., RAS). BIDS data might be in different orientations. **Mitigation**: DeepISLES is trained on ISLES challenge data which follows standard conventions. If issues arise, add orientation checking in staging: ```python def check_nifti_orientation(nifti_path: Path) -> str: """Check NIfTI orientation code (e.g., 'RAS', 'LPS').""" import nibabel as nib img = nib.load(nifti_path) return nib.aff2axcodes(img.affine) def conform_to_ras(nifti_path: Path, output_path: Path) -> Path: """Reorient NIfTI to RAS if needed.""" import nibabel as nib img = nib.load(nifti_path) # nibabel can reorient - implement if needed ... ``` ## dependencies to add None - all covered in Phase 0.