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 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