xnattools 1.0.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.
- xnattools/__init__.py +17 -0
- xnattools/cli.py +328 -0
- xnattools/core.py +367 -0
- xnattools/dataclasses/__init__.py +0 -0
- xnattools/dataclasses/core.py +242 -0
- xnattools/dataclasses/datmat.py +91 -0
- xnattools/dataclasses/dcm2nifti.py +130 -0
- xnattools/dataclasses/dcm2snapshots.py +75 -0
- xnattools/dataclasses/dcmsort.py +214 -0
- xnattools/dataclasses/returntypes.py +13 -0
- xnattools/dataclasses/scanmetadata.py +99 -0
- xnattools/datmat.py +143 -0
- xnattools/dcm2nifti.py +692 -0
- xnattools/dcm2snapshots.py +191 -0
- xnattools/dcmsort.py +235 -0
- xnattools/logger_config.py +48 -0
- xnattools/map.py +44 -0
- xnattools/utils/__init__.py +0 -0
- xnattools/utils/datmat.py +170 -0
- xnattools/utils/dcm2nifti.py +64 -0
- xnattools/utils/dcm2snaphots.py +190 -0
- xnattools/utils/dcmsort.py +765 -0
- xnattools/utils/download.py +590 -0
- xnattools/utils/filters.py +39 -0
- xnattools/utils/utils.py +261 -0
- xnattools-1.0.0.dist-info/METADATA +159 -0
- xnattools-1.0.0.dist-info/RECORD +30 -0
- xnattools-1.0.0.dist-info/WHEEL +4 -0
- xnattools-1.0.0.dist-info/entry_points.txt +2 -0
- xnattools-1.0.0.dist-info/licenses/LICENSE +7 -0
xnattools/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
from xnattools.core import run_tools
|
|
3
|
+
from xnattools.dataclasses.core import GeneralSettings
|
|
4
|
+
from xnattools.dataclasses.datmat import DatMatSettings
|
|
5
|
+
from xnattools.dataclasses.dcm2nifti import Dcm2niftiSettings
|
|
6
|
+
from xnattools.dataclasses.dcm2snapshots import Dcm2snapshotsSettings
|
|
7
|
+
from xnattools.dataclasses.scanmetadata import ScanMetaDataSettings
|
|
8
|
+
|
|
9
|
+
__version__ = version("xnattools")
|
|
10
|
+
__all__ = [
|
|
11
|
+
"DatMatSettings",
|
|
12
|
+
"Dcm2niftiSettings",
|
|
13
|
+
"Dcm2snapshotsSettings",
|
|
14
|
+
"GeneralSettings",
|
|
15
|
+
"ScanMetaDataSettings",
|
|
16
|
+
"run_tools",
|
|
17
|
+
]
|
xnattools/cli.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Command-line interface for XNAT Tools.
|
|
2
|
+
|
|
3
|
+
This module provides a CLI parser for running XNAT tools via command line.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
from importlib.metadata import PackageNotFoundError
|
|
11
|
+
from importlib.metadata import version
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
import xnattools as xt
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import tomllib
|
|
18
|
+
except ImportError:
|
|
19
|
+
try:
|
|
20
|
+
import tomli as tomllib # type: ignore[import-not-found,no-redef]
|
|
21
|
+
except ImportError:
|
|
22
|
+
tomllib = None # type: ignore[assignment]
|
|
23
|
+
|
|
24
|
+
from xnattools.logger_config import get_logger
|
|
25
|
+
|
|
26
|
+
logger = get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_version() -> str:
|
|
30
|
+
"""Get the version of xnattools.
|
|
31
|
+
|
|
32
|
+
First tries to get it from installed package metadata.
|
|
33
|
+
If not installed (e.g., running from source), reads from pyproject.toml.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Version string (e.g., "0.1.0")
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
return version("xnattools")
|
|
40
|
+
except PackageNotFoundError:
|
|
41
|
+
if tomllib is None:
|
|
42
|
+
return "unknown"
|
|
43
|
+
try:
|
|
44
|
+
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
|
|
45
|
+
if pyproject_path.exists():
|
|
46
|
+
with pyproject_path.open("rb") as f:
|
|
47
|
+
pyproject_data = tomllib.load(f)
|
|
48
|
+
return pyproject_data.get("project", {}).get("version", "unknown")
|
|
49
|
+
except (OSError, KeyError, AttributeError):
|
|
50
|
+
logger.exception("Exception occurred while reading version from pyproject.toml")
|
|
51
|
+
|
|
52
|
+
return "unknown"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_scans_argument(scans_input: str) -> list[str] | dict[str, list[str | int]]:
|
|
56
|
+
"""Parses the scan arguments.
|
|
57
|
+
|
|
58
|
+
Parse the scans argument which can be:
|
|
59
|
+
- A comma-separated list of experiment IDs: "exp1,exp2,exp3"
|
|
60
|
+
- A JSON string mapping experiments to scan IDs: '{"exp1": [1,2], "exp2": ["3","4"]}'
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
scans_input: String input from command line
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Either a list of experiment IDs or a dictionary mapping experiments to scan IDs
|
|
67
|
+
"""
|
|
68
|
+
# Try to parse as JSON first
|
|
69
|
+
if scans_input.startswith(("{", "[")):
|
|
70
|
+
try:
|
|
71
|
+
scans_data = json.loads(scans_input)
|
|
72
|
+
# If it's a list, it's invalid - we only accept dict or comma-separated strings
|
|
73
|
+
if isinstance(scans_data, list):
|
|
74
|
+
msg = (
|
|
75
|
+
"JSON arrays are not supported. Use comma-separated experiment IDs "
|
|
76
|
+
"(e.g., 'EXP1,EXP2') or a JSON dictionary mapping experiments to scan IDs "
|
|
77
|
+
'(e.g., \'{"EXP1": [1,2], "EXP2": [3]}\')'
|
|
78
|
+
)
|
|
79
|
+
raise TypeError(msg)
|
|
80
|
+
|
|
81
|
+
# Validate dictionary structure
|
|
82
|
+
if not isinstance(scans_data, dict):
|
|
83
|
+
msg = "JSON must be a dictionary"
|
|
84
|
+
raise TypeError(msg)
|
|
85
|
+
for key, value in scans_data.items():
|
|
86
|
+
if not isinstance(key, str):
|
|
87
|
+
msg = f"Experiment ID '{key}' must be a string"
|
|
88
|
+
raise TypeError(msg)
|
|
89
|
+
if not isinstance(value, list):
|
|
90
|
+
msg = f"Scan IDs for '{key}' must be a list"
|
|
91
|
+
raise TypeError(msg)
|
|
92
|
+
except json.JSONDecodeError as e:
|
|
93
|
+
msg = f"Invalid JSON format for scans: {e}"
|
|
94
|
+
raise ValueError(msg) from e
|
|
95
|
+
return scans_data
|
|
96
|
+
# Parse as comma-separated list
|
|
97
|
+
experiment_list = [s.strip() for s in scans_input.split(",") if s.strip()]
|
|
98
|
+
|
|
99
|
+
# Validate that the result doesn't look like malformed JSON
|
|
100
|
+
for exp_id in experiment_list:
|
|
101
|
+
if exp_id.startswith(("{", "[", '"')):
|
|
102
|
+
msg = (
|
|
103
|
+
f"Invalid experiment ID '{exp_id}'. "
|
|
104
|
+
"Experiment IDs should be simple strings separated by commas, "
|
|
105
|
+
"not JSON syntax. Use proper JSON format if you need to specify scan IDs."
|
|
106
|
+
)
|
|
107
|
+
raise ValueError(msg)
|
|
108
|
+
|
|
109
|
+
return experiment_list
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
113
|
+
"""Create and configure the argument parser for XNAT Tools CLI."""
|
|
114
|
+
parser = argparse.ArgumentParser(
|
|
115
|
+
description="xnattools - Python scripts for handy operations on XNAT data",
|
|
116
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
117
|
+
epilog="""
|
|
118
|
+
Examples:
|
|
119
|
+
# Run dcm2nifti on entire project
|
|
120
|
+
%(prog)s dcm2nifti https://xnat.example.com PROJECT_ID
|
|
121
|
+
|
|
122
|
+
# Run with custom output directory
|
|
123
|
+
%(prog)s dcm2nii https://xnat.example.com PROJECT_ID --output-dir /custom/output/dir
|
|
124
|
+
|
|
125
|
+
# Run multiple tools on specific subjects
|
|
126
|
+
%(prog)s dcm2nifti dcm2snapshots https://xnat.example.com PROJECT_ID --subjects SUB001 SUB002
|
|
127
|
+
|
|
128
|
+
# Run on specific experiments (as list)
|
|
129
|
+
%(prog)s scanmetadata https://xnat.example.com PROJECT_ID --scans EXP001,EXP002
|
|
130
|
+
|
|
131
|
+
# Run on specific scans within experiments (as JSON mapping)
|
|
132
|
+
%(prog)s dcm2nifti https://xnat.example.com PROJECT_ID --scans '{"EXP001": [1, 2, 3], "EXP002": [1]}'
|
|
133
|
+
|
|
134
|
+
# With additional options
|
|
135
|
+
%(prog)s dcm2nifti https://xnat.example.com PROJECT_ID --tempdir /custom/tmp --overwrite --subjects SUB001
|
|
136
|
+
|
|
137
|
+
# Run dcm2nifti and scanmetadata with independent options
|
|
138
|
+
%(prog)s dcm2nifti scanmetadata https://xnat.example.com MY_PROJECT \\
|
|
139
|
+
--dcm2nifti-overwrite --dcm2nifti-use-dicom-series-description \\
|
|
140
|
+
--scanmetadata-output-location ./meta.csv --scanmetadata-headtype CT
|
|
141
|
+
""",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Positional arguments
|
|
145
|
+
parser.add_argument(
|
|
146
|
+
"tools",
|
|
147
|
+
nargs="+",
|
|
148
|
+
choices=["dcm2nifti", "dcm2snapshots", "scanmetadata", "datmat"],
|
|
149
|
+
help="XNAT tools to run (can specify multiple)",
|
|
150
|
+
)
|
|
151
|
+
# general settings - mandatory
|
|
152
|
+
parser.add_argument("system_url", type=str, help="URL of the XNAT instance (e.g., https://xnat.example.com)")
|
|
153
|
+
parser.add_argument("project_id", type=str, help="XNAT project ID to process")
|
|
154
|
+
|
|
155
|
+
# general settings - Optional arguments - Filtering
|
|
156
|
+
filter_group = parser.add_argument_group("Filtering options")
|
|
157
|
+
filter_group.add_argument(
|
|
158
|
+
"--subjects",
|
|
159
|
+
nargs="+",
|
|
160
|
+
metavar="SUBJECT_ID",
|
|
161
|
+
help="List of subject IDs to process (space-separated). If omitted, all subjects.",
|
|
162
|
+
)
|
|
163
|
+
filter_group.add_argument(
|
|
164
|
+
"--scans",
|
|
165
|
+
type=str,
|
|
166
|
+
metavar="SCANS",
|
|
167
|
+
help="Experiments/scans to process. Can be: "
|
|
168
|
+
'(1) Comma-separated experiment IDs: "EXP1,EXP2" '
|
|
169
|
+
'(2) JSON mapping experiments to scan IDs: \'{"EXP1": [1,2], "EXP2": [3]}\'',
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# general settings - Optional arguments - General
|
|
173
|
+
general_group = parser.add_argument_group("General options")
|
|
174
|
+
general_group.add_argument(
|
|
175
|
+
"--output-dir", type=Path, default=Path.cwd(), help="Output directory (default: current directory)"
|
|
176
|
+
)
|
|
177
|
+
general_group.add_argument("-v", "--verbose", action="store_true", help="Enable verbose logging")
|
|
178
|
+
general_group.add_argument("--version", action="version", version=f"%(prog)s {get_version()}")
|
|
179
|
+
|
|
180
|
+
# Tool-specific options
|
|
181
|
+
dcm2nifti_group = parser.add_argument_group("dcm2nifti options")
|
|
182
|
+
dcm2nifti_group.add_argument(
|
|
183
|
+
"--dcm2nifti-overwrite", action="store_true", help="Overwrite existing files locally and on XNAT"
|
|
184
|
+
)
|
|
185
|
+
dcm2nifti_group.add_argument(
|
|
186
|
+
"--dcm2nifti-allow-multiple-niftis", action="store_true", help="Allow multiple nifti files per series"
|
|
187
|
+
)
|
|
188
|
+
dcm2nifti_group.add_argument("--dcm2nifti-make-3d-niftis", action="store_true", help="Create 3D nifti files")
|
|
189
|
+
dcm2nifti_group.add_argument("--dcm2nifti-rename-temporal", action="store_true", help="Rename temporal series")
|
|
190
|
+
dcm2nifti_group.add_argument("--dcm2nifti-rename-dwi", action="store_true", help="Rename DWI series")
|
|
191
|
+
dcm2nifti_group.add_argument(
|
|
192
|
+
"--dcm2nifti-use-dicom-series-description",
|
|
193
|
+
action="store_true",
|
|
194
|
+
help="Use DICOM series description for descrip field in nifti",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
dcm2snapshots_group = parser.add_argument_group("dcm2snapshots options")
|
|
198
|
+
dcm2snapshots_group.add_argument(
|
|
199
|
+
"--dcm2snapshots-overwrite", action="store_true", help="Overwrite existing snapshots"
|
|
200
|
+
)
|
|
201
|
+
dcm2snapshots_group.add_argument(
|
|
202
|
+
"--dcm2snapshots-snapshot-size", action="store_true", help="Size of the snapshot (used as width and height)"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
scanmetadata_group = parser.add_argument_group("scanmetadata options")
|
|
206
|
+
scanmetadata_group.add_argument(
|
|
207
|
+
"--scanmetadata-output-location", type=Path, help="Output CSV location (default: output-dir/metadata.csv)"
|
|
208
|
+
)
|
|
209
|
+
scanmetadata_group.add_argument(
|
|
210
|
+
"--scanmetadata-headtype", default="MR", help="Type of scan to process, compared to DICOM Tag (0008,0060)"
|
|
211
|
+
)
|
|
212
|
+
scanmetadata_group.add_argument(
|
|
213
|
+
"--scanmetadata-t1-repetition-time", default=300, type=int, help="Repetition time to standardize T1 scans"
|
|
214
|
+
)
|
|
215
|
+
scanmetadata_group.add_argument(
|
|
216
|
+
"--scanmetadata-t2-echo-time", default=10, type=int, help="Echo time to standardize T2 scans"
|
|
217
|
+
)
|
|
218
|
+
scanmetadata_group.add_argument(
|
|
219
|
+
"--scanmetadata-process-tag-value", action="store_true", help="Process tag values in metadata"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
datmat_group = parser.add_argument_group("datmat options")
|
|
223
|
+
datmat_group.add_argument(
|
|
224
|
+
"--datmat-output-location", help="Directory where downloaded files will be saved, can be local or remote"
|
|
225
|
+
)
|
|
226
|
+
datmat_group.add_argument("--datmat-file-types", default="DICOM", help="Type of file to download from XNAT")
|
|
227
|
+
datmat_group.add_argument(
|
|
228
|
+
"--datmat-output-folder-structure", nargs="+", help="Folder hierarchy for organizing downloads."
|
|
229
|
+
)
|
|
230
|
+
datmat_group.add_argument(
|
|
231
|
+
"--datmat-pseudo-anonymize", action="store_true", help="Hash folder names for pseudo-anonymization."
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
return parser
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _build_general_settings(args: argparse.Namespace) -> xt.GeneralSettings:
|
|
238
|
+
"""Construct GeneralSettings from shared arguments."""
|
|
239
|
+
subjects = list(args.subjects) if args.subjects else None
|
|
240
|
+
scans = None
|
|
241
|
+
if args.scans:
|
|
242
|
+
scans = parse_scans_argument(args.scans)
|
|
243
|
+
return xt.GeneralSettings(
|
|
244
|
+
system_url=args.system_url,
|
|
245
|
+
project_id=args.project_id,
|
|
246
|
+
subjects=subjects,
|
|
247
|
+
scans=scans,
|
|
248
|
+
output_dir=args.output_dir,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _build_tool_settings(args: argparse.Namespace, tools: list[str]) -> list[Any]:
|
|
253
|
+
"""Instantiate tool-specific settings for each requested tool."""
|
|
254
|
+
tools_to_run = []
|
|
255
|
+
|
|
256
|
+
for tool in tools:
|
|
257
|
+
if tool == "dcm2nifti":
|
|
258
|
+
settings = xt.Dcm2niftiSettings(
|
|
259
|
+
overwrite=args.dcm2nifti_overwrite,
|
|
260
|
+
allow_multiple_niftis=args.dcm2nifti_allow_multiple_niftis,
|
|
261
|
+
make_3d_niftis=args.dcm2nifti_make_3d_niftis,
|
|
262
|
+
rename_temporal=args.dcm2nifti_rename_temporal,
|
|
263
|
+
rename_dwi=args.dcm2nifti_rename_dwi,
|
|
264
|
+
use_dicom_series_description=args.dcm2nifti_use_dicom_series_description,
|
|
265
|
+
)
|
|
266
|
+
tools_to_run.append(settings)
|
|
267
|
+
|
|
268
|
+
elif tool == "dcm2snapshots":
|
|
269
|
+
settings = xt.Dcm2snapshotsSettings(
|
|
270
|
+
overwrite=args.dcm2snapshots_overwrite,
|
|
271
|
+
)
|
|
272
|
+
tools_to_run.append(settings)
|
|
273
|
+
|
|
274
|
+
elif tool == "scanmetadata":
|
|
275
|
+
settings = xt.ScanMetaDataSettings(
|
|
276
|
+
output_location=args.scanmetadata_output_location,
|
|
277
|
+
headtype=args.scanmetadata_headtype,
|
|
278
|
+
t1_repetition_time=args.scanmetadata_t1_repetition_time,
|
|
279
|
+
t2_echo_time=args.scanmetadata_t2_echo_time,
|
|
280
|
+
process_tag_value=args.scanmetadata_process_tag_value,
|
|
281
|
+
)
|
|
282
|
+
tools_to_run.append(settings)
|
|
283
|
+
|
|
284
|
+
elif tool == "datmat":
|
|
285
|
+
settings = xt.DatMatSettings(
|
|
286
|
+
output_location=args.datmat_output_location,
|
|
287
|
+
file_types=args.datmat_file_types,
|
|
288
|
+
output_folder_structure=args.datmat_output_folder_structure,
|
|
289
|
+
pseudo_anonymize=args.datmat_pseudo_anonymize,
|
|
290
|
+
)
|
|
291
|
+
tools_to_run.append(settings)
|
|
292
|
+
|
|
293
|
+
else:
|
|
294
|
+
msg = f"Unknown tool: {tool}"
|
|
295
|
+
raise ValueError(msg)
|
|
296
|
+
|
|
297
|
+
return tools_to_run
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def main() -> int:
|
|
301
|
+
"""Main entry point for the CLI.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
True if run was successfull, False otherwise
|
|
305
|
+
"""
|
|
306
|
+
parser = create_parser()
|
|
307
|
+
args = parser.parse_args()
|
|
308
|
+
|
|
309
|
+
# Configure logging based on verbosity
|
|
310
|
+
if args.verbose:
|
|
311
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
312
|
+
logger.setLevel(logging.DEBUG) # TODO: fix levels later
|
|
313
|
+
|
|
314
|
+
# Run the tools
|
|
315
|
+
try:
|
|
316
|
+
general = _build_general_settings(args)
|
|
317
|
+
tool_settings = _build_tool_settings(args, args.tools)
|
|
318
|
+
xt.run_tools(tools_to_run=tool_settings, general_settings=general)
|
|
319
|
+
except Exception:
|
|
320
|
+
logger.exception("Error during execution.")
|
|
321
|
+
return 1
|
|
322
|
+
|
|
323
|
+
logger.info("Successfully completed tools: %s", ", ".join(args.tools))
|
|
324
|
+
return 0
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
sys.exit(main())
|