voxelops 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voxelops/__init__.py +98 -0
- voxelops/exceptions.py +158 -0
- voxelops/runners/__init__.py +13 -0
- voxelops/runners/_base.py +191 -0
- voxelops/runners/heudiconv.py +202 -0
- voxelops/runners/qsiparc.py +150 -0
- voxelops/runners/qsiprep.py +187 -0
- voxelops/runners/qsirecon.py +173 -0
- voxelops/schemas/__init__.py +41 -0
- voxelops/schemas/heudiconv.py +121 -0
- voxelops/schemas/qsiparc.py +107 -0
- voxelops/schemas/qsiprep.py +140 -0
- voxelops/schemas/qsirecon.py +154 -0
- voxelops/utils/__init__.py +1 -0
- voxelops/utils/bids.py +486 -0
- voxelops-0.1.0.dist-info/METADATA +221 -0
- voxelops-0.1.0.dist-info/RECORD +19 -0
- voxelops-0.1.0.dist-info/WHEEL +4 -0
- voxelops-0.1.0.dist-info/licenses/LICENSE +21 -0
voxelops/__init__.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""VoxelOps: Clean, simple neuroimaging pipeline automation for brain banks.
|
|
2
|
+
|
|
3
|
+
This package provides straightforward functions for running neuroimaging procedures
|
|
4
|
+
in Docker containers. Each procedure follows a simple pattern:
|
|
5
|
+
1. Define inputs (required paths and parameters)
|
|
6
|
+
2. Run the procedure (returns execution record)
|
|
7
|
+
3. Use expected outputs (generated from inputs)
|
|
8
|
+
|
|
9
|
+
Quick Start:
|
|
10
|
+
>>> from voxelops import run_qsiprep, QSIPrepInputs
|
|
11
|
+
>>>
|
|
12
|
+
>>> inputs = QSIPrepInputs(
|
|
13
|
+
... bids_dir="/data/bids",
|
|
14
|
+
... participant="01",
|
|
15
|
+
... )
|
|
16
|
+
>>> result = run_qsiprep(inputs, nprocs=16)
|
|
17
|
+
>>>
|
|
18
|
+
>>> # Result is a dict with everything you need
|
|
19
|
+
>>> print(f"Completed in {result['duration_human']}")
|
|
20
|
+
>>> print(f"Outputs: {result['expected_outputs'].qsiprep_dir}")
|
|
21
|
+
>>>
|
|
22
|
+
>>> # Perfect for databases
|
|
23
|
+
>>> db.save_processing_record(result)
|
|
24
|
+
|
|
25
|
+
Available Procedures:
|
|
26
|
+
- run_heudiconv: DICOM → BIDS conversion
|
|
27
|
+
- run_qsiprep: Diffusion MRI preprocessing
|
|
28
|
+
- run_qsirecon: Diffusion reconstruction & connectivity
|
|
29
|
+
- run_qsiparc: Parcellation using parcellate package
|
|
30
|
+
|
|
31
|
+
For full pipeline example, see examples/full_pipeline.py
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
__author__ = "YALab DevOps"
|
|
35
|
+
__email__ = "yalab.dev@gmail.com"
|
|
36
|
+
__version__ = "2.0.0"
|
|
37
|
+
|
|
38
|
+
# Runner functions
|
|
39
|
+
# Exceptions
|
|
40
|
+
from voxelops.exceptions import (
|
|
41
|
+
InputValidationError,
|
|
42
|
+
ProcedureError,
|
|
43
|
+
ProcedureExecutionError,
|
|
44
|
+
)
|
|
45
|
+
from voxelops.runners import (
|
|
46
|
+
run_heudiconv,
|
|
47
|
+
run_qsiparc,
|
|
48
|
+
run_qsiprep,
|
|
49
|
+
run_qsirecon,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Schemas for inputs, outputs, and defaults
|
|
53
|
+
from voxelops.schemas import (
|
|
54
|
+
HeudiconvDefaults,
|
|
55
|
+
# HeudiConv
|
|
56
|
+
HeudiconvInputs,
|
|
57
|
+
HeudiconvOutputs,
|
|
58
|
+
QSIParcDefaults,
|
|
59
|
+
# QSIParc
|
|
60
|
+
QSIParcInputs,
|
|
61
|
+
QSIParcOutputs,
|
|
62
|
+
QSIPrepDefaults,
|
|
63
|
+
# QSIPrep
|
|
64
|
+
QSIPrepInputs,
|
|
65
|
+
QSIPrepOutputs,
|
|
66
|
+
QSIReconDefaults,
|
|
67
|
+
# QSIRecon
|
|
68
|
+
QSIReconInputs,
|
|
69
|
+
QSIReconOutputs,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
__all__ = [
|
|
73
|
+
# Runners
|
|
74
|
+
"run_heudiconv",
|
|
75
|
+
"run_qsiprep",
|
|
76
|
+
"run_qsirecon",
|
|
77
|
+
"run_qsiparc",
|
|
78
|
+
# Schemas - HeudiConv
|
|
79
|
+
"HeudiconvInputs",
|
|
80
|
+
"HeudiconvOutputs",
|
|
81
|
+
"HeudiconvDefaults",
|
|
82
|
+
# Schemas - QSIPrep
|
|
83
|
+
"QSIPrepInputs",
|
|
84
|
+
"QSIPrepOutputs",
|
|
85
|
+
"QSIPrepDefaults",
|
|
86
|
+
# Schemas - QSIRecon
|
|
87
|
+
"QSIReconInputs",
|
|
88
|
+
"QSIReconOutputs",
|
|
89
|
+
"QSIReconDefaults",
|
|
90
|
+
# Schemas - QSIParc
|
|
91
|
+
"QSIParcInputs",
|
|
92
|
+
"QSIParcOutputs",
|
|
93
|
+
"QSIParcDefaults",
|
|
94
|
+
# Exceptions
|
|
95
|
+
"ProcedureError",
|
|
96
|
+
"InputValidationError",
|
|
97
|
+
"ProcedureExecutionError",
|
|
98
|
+
]
|
voxelops/exceptions.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Custom exceptions for yalab-procedures.
|
|
2
|
+
|
|
3
|
+
This module defines a hierarchy of exceptions for consistent error handling
|
|
4
|
+
across all procedures in the yalab-procedures package.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class YALabProcedureError(Exception):
|
|
11
|
+
"""Base exception for all yalab-procedures errors.
|
|
12
|
+
|
|
13
|
+
All custom exceptions in this package inherit from this class,
|
|
14
|
+
allowing callers to catch all yalab-procedures errors with a single
|
|
15
|
+
except clause if desired.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ProcedureExecutionError(YALabProcedureError):
|
|
22
|
+
"""Raised when a procedure fails during execution.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
procedure_name : str
|
|
27
|
+
Name of the procedure that failed.
|
|
28
|
+
message : str
|
|
29
|
+
The error message.
|
|
30
|
+
original_error : Optional[Exception], optional
|
|
31
|
+
The underlying exception that caused the failure, if any, by default None.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
procedure_name: str,
|
|
37
|
+
message: str,
|
|
38
|
+
original_error: Optional[Exception] = None,
|
|
39
|
+
):
|
|
40
|
+
self.procedure_name = procedure_name
|
|
41
|
+
self.original_error = original_error
|
|
42
|
+
super().__init__(f"{procedure_name} failed: {message}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ProcedureConfigurationError(YALabProcedureError):
|
|
46
|
+
"""Raised when procedure configuration is invalid.
|
|
47
|
+
|
|
48
|
+
This includes missing required inputs, invalid input combinations,
|
|
49
|
+
or configuration that cannot be used together.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class InputValidationError(YALabProcedureError):
|
|
56
|
+
"""Raised when input validation fails.
|
|
57
|
+
|
|
58
|
+
This is raised during pre-flight checks when inputs don't meet
|
|
59
|
+
the requirements for procedure execution.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class OutputCollectionError(YALabProcedureError):
|
|
66
|
+
"""Raised when expected outputs are not found after procedure execution.
|
|
67
|
+
|
|
68
|
+
This indicates the procedure may have failed silently or produced
|
|
69
|
+
unexpected output structure.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class DockerExecutionError(ProcedureExecutionError):
|
|
76
|
+
"""Raised when a Docker-based procedure fails.
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
procedure_name : str
|
|
81
|
+
Name of the procedure that failed.
|
|
82
|
+
container : str
|
|
83
|
+
The Docker image/container that was running.
|
|
84
|
+
exit_code : int
|
|
85
|
+
The exit code returned by the Docker container.
|
|
86
|
+
stderr : str
|
|
87
|
+
Standard error output from the container.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
procedure_name: str,
|
|
93
|
+
container: str,
|
|
94
|
+
exit_code: int,
|
|
95
|
+
stderr: str,
|
|
96
|
+
):
|
|
97
|
+
self.container = container
|
|
98
|
+
self.exit_code = exit_code
|
|
99
|
+
self.stderr = stderr
|
|
100
|
+
message = f"Docker container '{container}' exited with code {exit_code}"
|
|
101
|
+
if stderr:
|
|
102
|
+
# Truncate very long stderr for the message
|
|
103
|
+
stderr_preview = stderr[:500] + "..." if len(stderr) > 500 else stderr
|
|
104
|
+
message += f": {stderr_preview}"
|
|
105
|
+
super().__init__(procedure_name, message)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class FreeSurferLicenseError(ProcedureConfigurationError):
|
|
109
|
+
"""Raised when FreeSurfer license file cannot be found.
|
|
110
|
+
|
|
111
|
+
This is a specific configuration error for procedures that require
|
|
112
|
+
FreeSurfer and cannot locate a valid license file.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, message: Optional[str] = None):
|
|
116
|
+
if message is None:
|
|
117
|
+
message = (
|
|
118
|
+
"FreeSurfer license not found. Set FS_LICENSE or FREESURFER_HOME "
|
|
119
|
+
"environment variable, or provide fs_license_file parameter."
|
|
120
|
+
)
|
|
121
|
+
super().__init__(message)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class BIDSValidationError(YALabProcedureError):
|
|
125
|
+
"""Raised when BIDS dataset validation fails.
|
|
126
|
+
|
|
127
|
+
This indicates the input data does not conform to BIDS specification
|
|
128
|
+
requirements for the procedure.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class DependencyError(YALabProcedureError):
|
|
137
|
+
"""Raised when a required external dependency is not available.
|
|
138
|
+
|
|
139
|
+
This includes missing Docker, missing command-line tools, or
|
|
140
|
+
unavailable Python packages.
|
|
141
|
+
|
|
142
|
+
Parameters
|
|
143
|
+
----------
|
|
144
|
+
dependency : str
|
|
145
|
+
The name of the missing dependency.
|
|
146
|
+
message : Optional[str], optional
|
|
147
|
+
The error message, by default None.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(self, dependency: str, message: Optional[str] = None):
|
|
151
|
+
self.dependency = dependency
|
|
152
|
+
if message is None:
|
|
153
|
+
message = f"Required dependency '{dependency}' is not available"
|
|
154
|
+
super().__init__(message)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# Alias for backwards compatibility
|
|
158
|
+
ProcedureError = YALabProcedureError
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Procedure runners for brain bank neuroimaging pipelines."""
|
|
2
|
+
|
|
3
|
+
from voxelops.runners.heudiconv import run_heudiconv
|
|
4
|
+
from voxelops.runners.qsiparc import run_qsiparc
|
|
5
|
+
from voxelops.runners.qsiprep import run_qsiprep
|
|
6
|
+
from voxelops.runners.qsirecon import run_qsirecon
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"run_heudiconv",
|
|
10
|
+
"run_qsiprep",
|
|
11
|
+
"run_qsirecon",
|
|
12
|
+
"run_qsiparc",
|
|
13
|
+
]
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Base utilities for all procedure runners."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from voxelops.exceptions import (
|
|
10
|
+
InputValidationError,
|
|
11
|
+
ProcedureExecutionError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_input_dir(input_dir: Path, dir_type: str = "Input") -> None:
|
|
16
|
+
"""Validate that an input directory exists.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
input_dir : Path
|
|
21
|
+
Directory to validate.
|
|
22
|
+
dir_type : str, optional
|
|
23
|
+
Type of directory for error message, by default "Input".
|
|
24
|
+
|
|
25
|
+
Raises
|
|
26
|
+
------
|
|
27
|
+
InputValidationError
|
|
28
|
+
If directory doesn't exist.
|
|
29
|
+
"""
|
|
30
|
+
if not input_dir.exists():
|
|
31
|
+
raise InputValidationError(f"{dir_type} directory not found: {input_dir}")
|
|
32
|
+
|
|
33
|
+
if not input_dir.is_dir():
|
|
34
|
+
raise InputValidationError(f"{dir_type} path is not a directory: {input_dir}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def validate_participant(
|
|
38
|
+
input_dir: Path, participant: str, prefix: str = "sub-"
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Validate that a participant exists in the input directory.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
input_dir : Path
|
|
45
|
+
Directory containing participant data.
|
|
46
|
+
participant : str
|
|
47
|
+
Participant label (without prefix).
|
|
48
|
+
prefix : str, optional
|
|
49
|
+
Expected prefix, by default "sub-".
|
|
50
|
+
|
|
51
|
+
Raises
|
|
52
|
+
------
|
|
53
|
+
InputValidationError
|
|
54
|
+
If participant not found.
|
|
55
|
+
"""
|
|
56
|
+
participant_dir = input_dir / f"{prefix}{participant}"
|
|
57
|
+
if not participant_dir.exists():
|
|
58
|
+
raise InputValidationError(
|
|
59
|
+
f"Participant {prefix}{participant} not found in {input_dir}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def run_docker(
|
|
64
|
+
cmd: List[str],
|
|
65
|
+
tool_name: str,
|
|
66
|
+
participant: str,
|
|
67
|
+
log_dir: Optional[Path] = None,
|
|
68
|
+
capture_output: bool = True,
|
|
69
|
+
) -> Dict[str, Any]:
|
|
70
|
+
"""Execute Docker command and return execution record.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
cmd : List[str]
|
|
75
|
+
Docker command as list of strings.
|
|
76
|
+
tool_name : str
|
|
77
|
+
Name of the tool being run (for logging).
|
|
78
|
+
participant : str
|
|
79
|
+
Participant label.
|
|
80
|
+
log_dir : Optional[Path], optional
|
|
81
|
+
Directory to save execution log JSON, by default None.
|
|
82
|
+
capture_output : bool, optional
|
|
83
|
+
Whether to capture stdout/stderr, by default True.
|
|
84
|
+
|
|
85
|
+
Returns
|
|
86
|
+
-------
|
|
87
|
+
Dict[str, Any]
|
|
88
|
+
A dictionary with execution details:
|
|
89
|
+
- tool: Tool name
|
|
90
|
+
- participant: Participant label
|
|
91
|
+
- command: Full command that was executed
|
|
92
|
+
- exit_code: Process exit code
|
|
93
|
+
- start_time: ISO format timestamp
|
|
94
|
+
- end_time: ISO format timestamp
|
|
95
|
+
- duration_seconds: Duration in seconds
|
|
96
|
+
- duration_human: Human-readable duration
|
|
97
|
+
- success: Boolean success status
|
|
98
|
+
- log_file: Path to JSON log (if log_dir provided)
|
|
99
|
+
- stdout: Process stdout (if captured)
|
|
100
|
+
- stderr: Process stderr (if captured)
|
|
101
|
+
- error: Error message (if failed)
|
|
102
|
+
|
|
103
|
+
Raises
|
|
104
|
+
------
|
|
105
|
+
ProcedureExecutionError
|
|
106
|
+
If command fails.
|
|
107
|
+
"""
|
|
108
|
+
# Setup logging
|
|
109
|
+
if log_dir:
|
|
110
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
112
|
+
log_file = log_dir / f"{tool_name}_{participant}_{timestamp}.json"
|
|
113
|
+
else:
|
|
114
|
+
log_file = None
|
|
115
|
+
|
|
116
|
+
print(f"\n{'='*80}")
|
|
117
|
+
print(f"Running {tool_name} for participant {participant}")
|
|
118
|
+
print(f"{'='*80}")
|
|
119
|
+
print(f"Command: {' '.join(cmd)}")
|
|
120
|
+
print(f"{'='*80}\n")
|
|
121
|
+
|
|
122
|
+
start_time = datetime.now()
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
result = subprocess.run(
|
|
126
|
+
cmd, capture_output=capture_output, text=True, check=False
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
end_time = datetime.now()
|
|
130
|
+
duration = end_time - start_time
|
|
131
|
+
|
|
132
|
+
# Build execution record
|
|
133
|
+
record = {
|
|
134
|
+
"tool": tool_name,
|
|
135
|
+
"participant": participant,
|
|
136
|
+
"command": cmd,
|
|
137
|
+
"exit_code": result.returncode,
|
|
138
|
+
"start_time": start_time.isoformat(),
|
|
139
|
+
"end_time": end_time.isoformat(),
|
|
140
|
+
"duration_seconds": duration.total_seconds(),
|
|
141
|
+
"duration_human": str(duration),
|
|
142
|
+
"success": result.returncode == 0,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if capture_output:
|
|
146
|
+
record["stdout"] = result.stdout
|
|
147
|
+
record["stderr"] = result.stderr
|
|
148
|
+
|
|
149
|
+
# Save to JSON log
|
|
150
|
+
if log_file:
|
|
151
|
+
record["log_file"] = str(log_file)
|
|
152
|
+
with open(log_file, "w") as f:
|
|
153
|
+
json.dump(record, f, indent=2)
|
|
154
|
+
print(f"Execution log saved: {log_file}")
|
|
155
|
+
|
|
156
|
+
# Check for errors
|
|
157
|
+
if result.returncode != 0:
|
|
158
|
+
error_msg = f"{tool_name} failed with exit code {result.returncode}"
|
|
159
|
+
if capture_output and result.stderr:
|
|
160
|
+
error_msg += f"\n\nStderr (last 1000 chars):\n{result.stderr[-1000:]}"
|
|
161
|
+
|
|
162
|
+
record["error"] = error_msg
|
|
163
|
+
|
|
164
|
+
# Update log file with error
|
|
165
|
+
if log_file:
|
|
166
|
+
with open(log_file, "w") as f:
|
|
167
|
+
json.dump(record, f, indent=2)
|
|
168
|
+
|
|
169
|
+
raise ProcedureExecutionError(
|
|
170
|
+
procedure_name=tool_name,
|
|
171
|
+
message=error_msg,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
print(f"\n{'='*80}")
|
|
175
|
+
print(f"✓ {tool_name} completed successfully")
|
|
176
|
+
print(f"Duration: {duration}")
|
|
177
|
+
print(f"{'='*80}\n")
|
|
178
|
+
|
|
179
|
+
return record
|
|
180
|
+
|
|
181
|
+
except subprocess.TimeoutExpired as e:
|
|
182
|
+
raise ProcedureExecutionError(
|
|
183
|
+
procedure_name=tool_name,
|
|
184
|
+
message=f"Process timed out after {e.timeout} seconds",
|
|
185
|
+
) from e
|
|
186
|
+
except Exception as e:
|
|
187
|
+
if not isinstance(e, ProcedureExecutionError):
|
|
188
|
+
raise ProcedureExecutionError(
|
|
189
|
+
procedure_name=tool_name, message=str(e), original_error=e
|
|
190
|
+
) from e
|
|
191
|
+
raise
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""HeudiConv DICOM to BIDS converter runner."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from voxelops.exceptions import InputValidationError
|
|
8
|
+
from voxelops.runners._base import run_docker, validate_input_dir
|
|
9
|
+
from voxelops.schemas.heudiconv import (
|
|
10
|
+
HeudiconvDefaults,
|
|
11
|
+
HeudiconvInputs,
|
|
12
|
+
HeudiconvOutputs,
|
|
13
|
+
)
|
|
14
|
+
from voxelops.utils.bids import post_process_heudiconv_output
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _build_heudiconv_docker_command(
|
|
18
|
+
inputs: HeudiconvInputs, config: HeudiconvDefaults, output_dir: Path
|
|
19
|
+
) -> list[str]:
|
|
20
|
+
"""Builds the Docker command for HeudiConv."""
|
|
21
|
+
uid = os.getuid()
|
|
22
|
+
gid = os.getgid()
|
|
23
|
+
|
|
24
|
+
cmd = [
|
|
25
|
+
"docker",
|
|
26
|
+
"run",
|
|
27
|
+
"--rm",
|
|
28
|
+
"--user",
|
|
29
|
+
f"{uid}:{gid}",
|
|
30
|
+
"-v",
|
|
31
|
+
f"{inputs.dicom_dir}:/dicom:ro",
|
|
32
|
+
"-v",
|
|
33
|
+
f"{output_dir}:/output",
|
|
34
|
+
"-v",
|
|
35
|
+
f"{config.heuristic}:/heuristic.py:ro",
|
|
36
|
+
config.docker_image,
|
|
37
|
+
"--files",
|
|
38
|
+
"/dicom",
|
|
39
|
+
"--outdir",
|
|
40
|
+
"/output",
|
|
41
|
+
"--subjects",
|
|
42
|
+
inputs.participant,
|
|
43
|
+
"--converter",
|
|
44
|
+
config.converter,
|
|
45
|
+
"--heuristic",
|
|
46
|
+
"/heuristic.py",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
if inputs.session:
|
|
50
|
+
cmd.extend(["--ses", inputs.session])
|
|
51
|
+
|
|
52
|
+
if config.overwrite:
|
|
53
|
+
cmd.append("--overwrite")
|
|
54
|
+
|
|
55
|
+
if config.bids_validator:
|
|
56
|
+
cmd.append("--bids")
|
|
57
|
+
|
|
58
|
+
if config.bids:
|
|
59
|
+
cmd.extend(["--bids", config.bids])
|
|
60
|
+
|
|
61
|
+
if config.grouping:
|
|
62
|
+
cmd.extend(["--grouping", config.grouping])
|
|
63
|
+
|
|
64
|
+
if config.overwrite:
|
|
65
|
+
cmd.append("--overwrite")
|
|
66
|
+
return cmd
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _handle_heudiconv_post_processing(
|
|
70
|
+
result: Dict[str, Any],
|
|
71
|
+
config: HeudiconvDefaults,
|
|
72
|
+
output_dir: Path,
|
|
73
|
+
inputs: HeudiconvInputs,
|
|
74
|
+
) -> Dict[str, Any]:
|
|
75
|
+
"""Handles post-processing steps for HeudiConv."""
|
|
76
|
+
if result["success"] and config.post_process:
|
|
77
|
+
print(f"\n{'='*80}")
|
|
78
|
+
print(f"Running post-HeudiConv processing for participant {inputs.participant}")
|
|
79
|
+
print(f"{'='*80}\n")
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
post_result = post_process_heudiconv_output(
|
|
83
|
+
bids_dir=output_dir,
|
|
84
|
+
participant=inputs.participant,
|
|
85
|
+
session=inputs.session,
|
|
86
|
+
dry_run=config.post_process_dry_run,
|
|
87
|
+
)
|
|
88
|
+
result["post_processing"] = post_result
|
|
89
|
+
|
|
90
|
+
if not post_result["success"]:
|
|
91
|
+
print("\n⚠ Post-processing completed with warnings:")
|
|
92
|
+
for error in post_result.get("errors", []):
|
|
93
|
+
print(f" - {error}")
|
|
94
|
+
else:
|
|
95
|
+
print("\n✓ Post-processing completed successfully")
|
|
96
|
+
|
|
97
|
+
except Exception as e:
|
|
98
|
+
print(f"\n⚠ Post-processing failed: {e}")
|
|
99
|
+
result["post_processing"] = {"success": False, "error": str(e)}
|
|
100
|
+
# Don't fail the entire conversion if post-processing fails
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def run_heudiconv(
|
|
105
|
+
inputs: HeudiconvInputs, config: Optional[HeudiconvDefaults] = None, **overrides
|
|
106
|
+
) -> Dict[str, Any]:
|
|
107
|
+
"""Convert DICOM to BIDS using HeudiConv.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
inputs : HeudiconvInputs
|
|
112
|
+
Required inputs (dicom_dir, participant, etc.).
|
|
113
|
+
config : Optional[HeudiconvDefaults], optional
|
|
114
|
+
Configuration (uses defaults if not provided), by default None.
|
|
115
|
+
**overrides
|
|
116
|
+
Override any config parameter.
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
Dict[str, Any]
|
|
121
|
+
Execution record with:
|
|
122
|
+
- tool: "heudiconv"
|
|
123
|
+
- participant: Participant label
|
|
124
|
+
- command: Full Docker command executed
|
|
125
|
+
- exit_code: Process exit code
|
|
126
|
+
- start_time, end_time: ISO format timestamps
|
|
127
|
+
- duration_seconds, duration_human: Execution duration
|
|
128
|
+
- success: Boolean success status
|
|
129
|
+
- log_file: Path to JSON log
|
|
130
|
+
- inputs: HeudiconvInputs instance
|
|
131
|
+
- config: HeudiconvDefaults instance
|
|
132
|
+
- expected_outputs: HeudiconvOutputs instance
|
|
133
|
+
|
|
134
|
+
Raises
|
|
135
|
+
------
|
|
136
|
+
InputValidationError
|
|
137
|
+
If inputs are invalid.
|
|
138
|
+
ProcedureExecutionError
|
|
139
|
+
If conversion fails.
|
|
140
|
+
|
|
141
|
+
Examples
|
|
142
|
+
--------
|
|
143
|
+
>>> inputs = HeudiconvInputs(
|
|
144
|
+
... dicom_dir=Path("/data/dicoms"),
|
|
145
|
+
... participant="01",
|
|
146
|
+
... )
|
|
147
|
+
>>> config = HeudiconvDefaults(
|
|
148
|
+
... heuristic=Path("/code/heuristic.py"),
|
|
149
|
+
... )
|
|
150
|
+
>>> result = run_heudiconv(inputs, config)
|
|
151
|
+
>>> print(result['expected_outputs'].bids_dir)
|
|
152
|
+
PosixPath('/data/bids')
|
|
153
|
+
"""
|
|
154
|
+
# Use defaults if config not provided
|
|
155
|
+
config = config or HeudiconvDefaults()
|
|
156
|
+
|
|
157
|
+
# Apply overrides
|
|
158
|
+
for key, value in overrides.items():
|
|
159
|
+
if hasattr(config, key):
|
|
160
|
+
print(f"Overriding config.{key} with value: {value}")
|
|
161
|
+
setattr(config, key, value)
|
|
162
|
+
|
|
163
|
+
# Validate inputs
|
|
164
|
+
validate_input_dir(inputs.dicom_dir, "DICOM")
|
|
165
|
+
|
|
166
|
+
if not config.heuristic:
|
|
167
|
+
raise InputValidationError(
|
|
168
|
+
"Heuristic file is required for HeudiConv. "
|
|
169
|
+
"Provide it via config.heuristic or heuristic= keyword argument."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if not config.heuristic.exists():
|
|
173
|
+
raise InputValidationError(f"Heuristic file not found: {config.heuristic}")
|
|
174
|
+
|
|
175
|
+
# Setup output directory
|
|
176
|
+
output_dir = inputs.output_dir or (inputs.dicom_dir.parent / "bids")
|
|
177
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
|
|
179
|
+
# Generate expected outputs
|
|
180
|
+
expected_outputs = HeudiconvOutputs.from_inputs(inputs, output_dir)
|
|
181
|
+
|
|
182
|
+
# Build Docker command
|
|
183
|
+
cmd = _build_heudiconv_docker_command(inputs, config, output_dir)
|
|
184
|
+
|
|
185
|
+
# Execute
|
|
186
|
+
log_dir = output_dir.parent / "logs"
|
|
187
|
+
result = run_docker(
|
|
188
|
+
cmd=cmd,
|
|
189
|
+
tool_name="heudiconv",
|
|
190
|
+
participant=inputs.participant,
|
|
191
|
+
log_dir=log_dir,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Post-processing steps
|
|
195
|
+
result = _handle_heudiconv_post_processing(result, config, output_dir, inputs)
|
|
196
|
+
|
|
197
|
+
# Add inputs, config, and expected outputs to result
|
|
198
|
+
result["inputs"] = inputs
|
|
199
|
+
result["config"] = config
|
|
200
|
+
result["expected_outputs"] = expected_outputs
|
|
201
|
+
|
|
202
|
+
return result
|