File size: 7,181 Bytes
4b42170
a544a50
4b42170
 
 
 
 
a544a50
 
4b42170
 
a544a50
 
 
 
4b42170
a544a50
 
 
 
4b42170
a544a50
 
4b42170
a544a50
 
 
 
 
 
4b42170
 
 
 
a544a50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4b42170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a544a50
 
 
 
 
 
 
4b42170
 
 
 
 
a544a50
 
4b42170
a544a50
4b42170
 
 
a544a50
 
 
 
 
 
 
4b42170
 
 
 
 
a544a50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4b42170
a544a50
 
 
 
 
 
4b42170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a544a50
4b42170
 
 
 
 
 
a544a50
 
4b42170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a544a50
 
 
4b42170
a544a50
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
"""Direct DeepISLES invocation via subprocess.

This module provides subprocess-based invocation of DeepISLES when running
on HF Spaces. We use subprocess because:
- DeepISLES runs in a conda env with Python 3.8
- Our Gradio app requires Python 3.10+ for modern dependencies
- The two environments are incompatible, so we bridge via subprocess

Usage:
    The subprocess calls /app/deepisles_adapter.py inside the isles_ensemble
    conda environment, which imports and runs IslesEnsemble.

See:
    - https://github.com/ezequieldlrosa/DeepIsles
    - docs/specs/07-hf-spaces-deployment.md
    - scripts/deepisles_adapter.py
"""

from __future__ import annotations

import subprocess
import time
from dataclasses import dataclass
from pathlib import Path  # noqa: TC003 - used at runtime in dataclass and functions

from stroke_deepisles_demo.core.exceptions import DeepISLESError, MissingInputError
from stroke_deepisles_demo.core.logging import get_logger

logger = get_logger(__name__)

# Path to conda and adapter script in the Docker container
CONDA_PATH = "/opt/conda/bin/conda"
ADAPTER_SCRIPT = "/app/deepisles_adapter.py"
CONDA_ENV_NAME = "isles_ensemble"


@dataclass(frozen=True)
class DirectInvocationResult:
    """Result of direct DeepISLES invocation."""

    prediction_path: Path
    elapsed_seconds: float


def validate_input_files(
    dwi_path: Path,
    adc_path: Path,
    flair_path: Path | None = None,
) -> None:
    """
    Validate that input files exist.

    Args:
        dwi_path: Path to DWI NIfTI file
        adc_path: Path to ADC NIfTI file
        flair_path: Optional path to FLAIR NIfTI file

    Raises:
        MissingInputError: If required files are missing
    """
    if not dwi_path.exists():
        raise MissingInputError(f"DWI file not found: {dwi_path}")
    if not adc_path.exists():
        raise MissingInputError(f"ADC file not found: {adc_path}")
    if flair_path is not None and not flair_path.exists():
        raise MissingInputError(f"FLAIR file not found: {flair_path}")


def find_prediction_mask(output_dir: Path) -> Path:
    """
    Find the prediction mask in DeepISLES output directory.

    DeepISLES outputs the lesion mask as 'lesion_msk.nii.gz'.

    Args:
        output_dir: DeepISLES output directory

    Returns:
        Path to the prediction mask NIfTI file

    Raises:
        DeepISLESError: If no prediction mask found
    """
    # DeepISLES outputs lesion_msk.nii.gz
    expected_path = output_dir / "lesion_msk.nii.gz"
    if expected_path.exists():
        return expected_path

    # Fall back to searching for any NIfTI file
    nifti_files = list(output_dir.glob("*.nii.gz"))
    nifti_files = [
        f for f in nifti_files if not any(x in f.name.lower() for x in ["dwi", "adc", "flair"])
    ]
    if nifti_files:
        return nifti_files[0]

    raise DeepISLESError(
        f"No prediction mask found in {output_dir}. Expected 'lesion_msk.nii.gz' or similar."
    )


def run_deepisles_direct(
    dwi_path: Path,
    adc_path: Path,
    output_dir: Path,
    *,
    flair_path: Path | None = None,
    fast: bool = True,
    skull_strip: bool = False,  # noqa: ARG001 - kept for API compatibility
    parallelize: bool = True,  # noqa: ARG001 - kept for API compatibility
    save_team_outputs: bool = False,  # noqa: ARG001 - kept for API compatibility
    results_mni: bool = False,  # noqa: ARG001 - kept for API compatibility
    timeout: float = 1800,  # 30 minutes
) -> DirectInvocationResult:
    """
    Run DeepISLES segmentation via subprocess into conda environment.

    This function calls the deepisles_adapter.py script inside the
    isles_ensemble conda environment via subprocess. This bridges the
    Python version gap (Gradio needs 3.10+, DeepISLES needs 3.8).

    Args:
        dwi_path: Path to DWI NIfTI file (b=1000)
        adc_path: Path to ADC NIfTI file
        output_dir: Directory for output files
        flair_path: Optional path to FLAIR NIfTI file
        fast: If True, use SEALS model only (faster, no FLAIR needed)
        skull_strip: If True, perform skull stripping (not passed to subprocess)
        parallelize: If True, run models in parallel (not passed to subprocess)
        save_team_outputs: If True, save individual team outputs (not passed)
        results_mni: If True, output results in MNI space (not passed)
        timeout: Maximum seconds to wait for inference

    Returns:
        DirectInvocationResult with path to prediction mask

    Raises:
        DeepISLESError: If invocation fails
        MissingInputError: If required input files are missing

    Example:
        >>> result = run_deepisles_direct(
        ...     dwi_path=Path("/data/dwi.nii.gz"),
        ...     adc_path=Path("/data/adc.nii.gz"),
        ...     output_dir=Path("/data/output"),
        ...     fast=True
        ... )
        >>> print(result.prediction_path)
    """
    start_time = time.time()

    # Validate inputs
    validate_input_files(dwi_path, adc_path, flair_path)

    # Create output directory
    output_dir.mkdir(parents=True, exist_ok=True)

    logger.info(
        "Running DeepISLES via subprocess: dwi=%s, adc=%s, flair=%s, fast=%s",
        dwi_path,
        adc_path,
        flair_path,
        fast,
    )

    # Build command to run adapter script in conda environment
    # Using: conda run -n isles_ensemble python /app/deepisles_adapter.py ...
    cmd = [
        CONDA_PATH,
        "run",
        "-n",
        CONDA_ENV_NAME,
        "python",
        ADAPTER_SCRIPT,
        "--dwi",
        str(dwi_path.resolve()),
        "--adc",
        str(adc_path.resolve()),
        "--output",
        str(output_dir.resolve()),
    ]

    if flair_path is not None:
        cmd.extend(["--flair", str(flair_path.resolve())])

    if fast:
        cmd.append("--fast")

    logger.info("Subprocess command: %s", " ".join(cmd))

    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=timeout,
            cwd="/app",  # Run from DeepISLES directory
        )

        # Log output
        if result.stdout:
            logger.info("DeepISLES stdout:\n%s", result.stdout)
        if result.stderr:
            logger.warning("DeepISLES stderr:\n%s", result.stderr)

        # Check for failure
        if result.returncode != 0:
            raise DeepISLESError(
                f"DeepISLES inference failed with exit code {result.returncode}. "
                f"stderr: {result.stderr}"
            )

    except subprocess.TimeoutExpired as e:
        raise DeepISLESError(f"DeepISLES inference timed out after {timeout} seconds") from e
    except FileNotFoundError as e:
        raise DeepISLESError(
            f"Failed to run DeepISLES subprocess: {e}. Is conda available at /opt/conda/bin/conda?"
        ) from e

    # Find the prediction mask
    prediction_path = find_prediction_mask(output_dir)

    elapsed = time.time() - start_time
    logger.info("DeepISLES subprocess completed in %.2fs", elapsed)

    return DirectInvocationResult(
        prediction_path=prediction_path,
        elapsed_seconds=elapsed,
    )