dcm2bids4ct 0.1.0__tar.gz
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.
- dcm2bids4ct-0.1.0/PKG-INFO +36 -0
- dcm2bids4ct-0.1.0/README.md +25 -0
- dcm2bids4ct-0.1.0/pyproject.toml +20 -0
- dcm2bids4ct-0.1.0/src/dcm2bids4ct/__init__.py +24 -0
- dcm2bids4ct-0.1.0/src/dcm2bids4ct/cli.py +40 -0
- dcm2bids4ct-0.1.0/src/dcm2bids4ct/main.py +269 -0
- dcm2bids4ct-0.1.0/src/dcm2bids4ct/py.typed +0 -0
- dcm2bids4ct-0.1.0/src/dcm2bids4ct/wrapper.py +137 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: dcm2bids4ct
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A wrapper for dcm2niix that adds BIDS-compliant CT metadata to JSON sidecars
|
|
5
|
+
Author: Christian Hinge
|
|
6
|
+
Author-email: Christian Hinge <christian.hinge@outlook.dk>
|
|
7
|
+
Requires-Dist: pydicom>=2.3.0
|
|
8
|
+
Requires-Dist: numpy>=1.20.0
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# dcm2bids4ct
|
|
13
|
+
|
|
14
|
+
A simple wrapper for dcm2niix that adds BIDS-compliant CT metadata to JSON sidecars.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install git+https://github.com/ChristianHinge/dcm2bids4ct.git
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or clone and install:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
git clone https://github.com/ChristianHinge/dcm2bids4ct.git
|
|
26
|
+
cd dcm2bids4ct
|
|
27
|
+
pip install -e .
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
dcm2bids4ct /path/to/dicom/folder
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Works exactly like dcm2niix, but automatically extracts CT metadata (tube voltage, dose, exposure, etc.) and adds it to the JSON sidecar.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# dcm2bids4ct
|
|
2
|
+
|
|
3
|
+
A simple wrapper for dcm2niix that adds BIDS-compliant CT metadata to JSON sidecars.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install git+https://github.com/ChristianHinge/dcm2bids4ct.git
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or clone and install:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
git clone https://github.com/ChristianHinge/dcm2bids4ct.git
|
|
15
|
+
cd dcm2bids4ct
|
|
16
|
+
pip install -e .
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
dcm2bids4ct /path/to/dicom/folder
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Works exactly like dcm2niix, but automatically extracts CT metadata (tube voltage, dose, exposure, etc.) and adds it to the JSON sidecar.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dcm2bids4ct"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A wrapper for dcm2niix that adds BIDS-compliant CT metadata to JSON sidecars"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Christian Hinge", email = "christian.hinge@outlook.dk" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pydicom>=2.3.0",
|
|
12
|
+
"numpy>=1.20.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
dcm2bids4ct = "dcm2bids4ct.cli:main"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["uv_build>=0.8.16,<0.9.0"]
|
|
20
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dcm2niix4ct: A wrapper for dcm2niix that adds BIDS-compliant CT metadata to JSON sidecars.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .wrapper import Dcm2niixWrapper, run_dcm2niix_wrapper
|
|
6
|
+
from .main import (
|
|
7
|
+
find_dicom_files,
|
|
8
|
+
is_dicom_file,
|
|
9
|
+
is_ct_scan,
|
|
10
|
+
validate_ct_series,
|
|
11
|
+
extract_ct_metadata,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Dcm2niixWrapper",
|
|
18
|
+
"run_dcm2niix_wrapper",
|
|
19
|
+
"find_dicom_files",
|
|
20
|
+
"is_dicom_file",
|
|
21
|
+
"is_ct_scan",
|
|
22
|
+
"validate_ct_series",
|
|
23
|
+
"extract_ct_metadata",
|
|
24
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Command-line interface for dcm2niix4ct wrapper."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from .wrapper import Dcm2niixWrapper
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main(argv=None):
|
|
9
|
+
"""Run dcm2niix and add CT metadata to JSON sidecars."""
|
|
10
|
+
args = argv or sys.argv[1:]
|
|
11
|
+
|
|
12
|
+
if not args or args[0] in ['-h', '--help']:
|
|
13
|
+
print("Usage: dcm2niix4ct <input_dir> [dcm2niix_args...]")
|
|
14
|
+
print("\nWrapper for dcm2niix that adds BIDS CT metadata to JSON sidecars")
|
|
15
|
+
print("All arguments are passed directly to dcm2niix")
|
|
16
|
+
return 0
|
|
17
|
+
|
|
18
|
+
input_dir = args[0]
|
|
19
|
+
dcm2niix_args = args[1:]
|
|
20
|
+
|
|
21
|
+
# Extract and remove output dir from args if specified
|
|
22
|
+
output_dir = None
|
|
23
|
+
if '-o' in dcm2niix_args:
|
|
24
|
+
idx = dcm2niix_args.index('-o')
|
|
25
|
+
if idx + 1 < len(dcm2niix_args):
|
|
26
|
+
output_dir = dcm2niix_args[idx + 1]
|
|
27
|
+
# Remove -o and its argument from dcm2niix_args
|
|
28
|
+
dcm2niix_args = dcm2niix_args[:idx] + dcm2niix_args[idx+2:]
|
|
29
|
+
|
|
30
|
+
if not Path(input_dir).is_dir():
|
|
31
|
+
print(f"Error: {input_dir} is not a directory", file=sys.stderr)
|
|
32
|
+
return 1
|
|
33
|
+
|
|
34
|
+
wrapper = Dcm2niixWrapper()
|
|
35
|
+
result = wrapper.run(input_dir, output_dir, dcm2niix_args)
|
|
36
|
+
return result.returncode
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
sys.exit(main())
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DICOM CT metadata extraction and BIDS JSON modification utilities.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import List, Dict, Any, Optional
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def find_dicom_files(directory: str) -> List[str]:
|
|
12
|
+
"""
|
|
13
|
+
Find all DICOM files in a directory (non-recursive).
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
directory: Path to the directory to search
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
List of DICOM file paths
|
|
20
|
+
"""
|
|
21
|
+
dicom_files = []
|
|
22
|
+
|
|
23
|
+
# List all items in directory
|
|
24
|
+
for filename in os.listdir(directory):
|
|
25
|
+
filepath = os.path.join(directory, filename)
|
|
26
|
+
|
|
27
|
+
# Skip if not a file
|
|
28
|
+
if not os.path.isfile(filepath):
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
# Check if it's a DICOM file by reading the header
|
|
32
|
+
if is_dicom_file(filepath):
|
|
33
|
+
dicom_files.append(filepath)
|
|
34
|
+
|
|
35
|
+
return dicom_files
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_dicom_file(filepath: str) -> bool:
|
|
39
|
+
"""Check if file is DICOM by looking for DICM magic number at byte 128."""
|
|
40
|
+
with open(filepath, 'rb') as f:
|
|
41
|
+
f.seek(128)
|
|
42
|
+
return f.read(4) == b'DICM'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_ct_scan(dicom_files: List[str]) -> bool:
|
|
46
|
+
"""
|
|
47
|
+
Check if DICOM files represent a CT scan.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
dicom_files: List of DICOM file paths
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
True if files are CT scans, False otherwise
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
RuntimeError: If DICOM file cannot be read
|
|
57
|
+
"""
|
|
58
|
+
if not dicom_files:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
import pydicom
|
|
62
|
+
|
|
63
|
+
dcm = pydicom.dcmread(dicom_files[0], stop_before_pixels=True)
|
|
64
|
+
modality_element = dcm.get("Modality")
|
|
65
|
+
|
|
66
|
+
if modality_element is None:
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
# Handle both DataElement objects and plain values
|
|
70
|
+
if hasattr(modality_element, 'value'):
|
|
71
|
+
modality = modality_element.value
|
|
72
|
+
else:
|
|
73
|
+
modality = modality_element
|
|
74
|
+
|
|
75
|
+
return modality == "CT"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def validate_ct_series(dicom_files: List[str]) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Validate that DICOM files represent a single CT series.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
dicom_files: List of DICOM file paths
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ValueError: If files are not CT modality or contain multiple series
|
|
87
|
+
"""
|
|
88
|
+
if not dicom_files:
|
|
89
|
+
raise ValueError("No DICOM files found in directory")
|
|
90
|
+
|
|
91
|
+
import pydicom
|
|
92
|
+
|
|
93
|
+
# Check first file
|
|
94
|
+
try:
|
|
95
|
+
first_dcm = pydicom.dcmread(dicom_files[0], stop_before_pixels=True)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
raise ValueError(f"Failed to read DICOM file {dicom_files[0]}: {e}")
|
|
98
|
+
|
|
99
|
+
# Get modality
|
|
100
|
+
modality = first_dcm.get("Modality")
|
|
101
|
+
if modality is None:
|
|
102
|
+
raise ValueError("DICOM files do not contain Modality tag")
|
|
103
|
+
|
|
104
|
+
modality_value = modality.value if hasattr(modality, "value") else str(modality)
|
|
105
|
+
|
|
106
|
+
# Check if CT
|
|
107
|
+
if modality_value != "CT":
|
|
108
|
+
raise ValueError(f"DICOM modality is '{modality_value}', expected 'CT'")
|
|
109
|
+
|
|
110
|
+
# Get series information from first file
|
|
111
|
+
first_series_uid = first_dcm.get("SeriesInstanceUID")
|
|
112
|
+
first_series_number = first_dcm.get("SeriesNumber")
|
|
113
|
+
|
|
114
|
+
if first_series_uid is None:
|
|
115
|
+
raise ValueError("DICOM files do not contain SeriesInstanceUID tag")
|
|
116
|
+
|
|
117
|
+
first_series_uid_value = first_series_uid.value if hasattr(first_series_uid, "value") else str(first_series_uid)
|
|
118
|
+
|
|
119
|
+
# Check all files belong to the same series
|
|
120
|
+
unique_series = set()
|
|
121
|
+
unique_series.add(first_series_uid_value)
|
|
122
|
+
|
|
123
|
+
# Sample some files to check for consistency
|
|
124
|
+
# For large datasets, we don't need to check every single file
|
|
125
|
+
sample_size = min(len(dicom_files), 10)
|
|
126
|
+
step = max(1, len(dicom_files) // sample_size)
|
|
127
|
+
|
|
128
|
+
for i in range(0, len(dicom_files), step):
|
|
129
|
+
dcm = pydicom.dcmread(dicom_files[i], stop_before_pixels=True)
|
|
130
|
+
|
|
131
|
+
# Check modality
|
|
132
|
+
modality = dcm.get("Modality")
|
|
133
|
+
if modality:
|
|
134
|
+
mod_value = modality.value if hasattr(modality, "value") else str(modality)
|
|
135
|
+
if mod_value != "CT":
|
|
136
|
+
raise ValueError(f"Mixed modalities found: CT and {mod_value}")
|
|
137
|
+
|
|
138
|
+
# Check series UID
|
|
139
|
+
series_uid = dcm.get("SeriesInstanceUID")
|
|
140
|
+
if series_uid:
|
|
141
|
+
uid_value = series_uid.value if hasattr(series_uid, "value") else str(series_uid)
|
|
142
|
+
unique_series.add(uid_value)
|
|
143
|
+
|
|
144
|
+
if len(unique_series) > 1:
|
|
145
|
+
raise ValueError(f"Directory contains multiple series (found {len(unique_series)} unique SeriesInstanceUIDs). "
|
|
146
|
+
"Please ensure only one CT series is present in the directory.")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def extract_ct_metadata(dicom_files: List[str]) -> Dict[str, Any]:
|
|
150
|
+
"""
|
|
151
|
+
Extract BIDS-compliant CT metadata from DICOM files.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
dicom_files: List of DICOM file paths
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Dictionary with BIDS CT metadata fields
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
ValueError: If no DICOM files provided
|
|
161
|
+
RuntimeError: If DICOM files cannot be read
|
|
162
|
+
"""
|
|
163
|
+
import pydicom
|
|
164
|
+
import numpy as np
|
|
165
|
+
|
|
166
|
+
if not dicom_files:
|
|
167
|
+
raise ValueError("No DICOM files provided for metadata extraction")
|
|
168
|
+
|
|
169
|
+
# Read first file for basic metadata
|
|
170
|
+
ct_file = pydicom.dcmread(dicom_files[0], stop_before_pixels=True)
|
|
171
|
+
|
|
172
|
+
# Initialize parameters dictionary
|
|
173
|
+
params: Dict[str, Any] = {}
|
|
174
|
+
|
|
175
|
+
# Helper to get numeric DICOM values
|
|
176
|
+
def get_float(dcm, tag, default="n/a"):
|
|
177
|
+
value = dcm.get(tag)
|
|
178
|
+
if value is None:
|
|
179
|
+
return default
|
|
180
|
+
value = value.value if hasattr(value, 'value') else value
|
|
181
|
+
return float(value) if value else default
|
|
182
|
+
|
|
183
|
+
def get_int(dcm, tag, default="n/a"):
|
|
184
|
+
value = dcm.get(tag)
|
|
185
|
+
if value is None:
|
|
186
|
+
return default
|
|
187
|
+
value = value.value if hasattr(value, 'value') else value
|
|
188
|
+
return int(value) if value else default
|
|
189
|
+
|
|
190
|
+
def get_str(dcm, tag, default="n/a"):
|
|
191
|
+
value = dcm.get(tag)
|
|
192
|
+
if value is None:
|
|
193
|
+
return default
|
|
194
|
+
value = value.value if hasattr(value, 'value') else value
|
|
195
|
+
if hasattr(value, '__iter__') and not isinstance(value, (str, bytes)):
|
|
196
|
+
return list(value)
|
|
197
|
+
return value if value else default
|
|
198
|
+
|
|
199
|
+
# Extract all parameters
|
|
200
|
+
params = {
|
|
201
|
+
"Modality": get_str(ct_file, "Modality", "CT"),
|
|
202
|
+
"Manufacturer": get_str(ct_file, "Manufacturer"),
|
|
203
|
+
"ManufacturerModelName": get_str(ct_file, "ManufacturerModelName"),
|
|
204
|
+
"SeriesDescription": get_str(ct_file, "SeriesDescription"),
|
|
205
|
+
"ProtocolName": get_str(ct_file, "ProtocolName"),
|
|
206
|
+
"TubeVoltage": get_float(ct_file, "KVP"),
|
|
207
|
+
"SliceThickness": get_float(ct_file, "SliceThickness"),
|
|
208
|
+
"FilterType": get_str(ct_file, "FilterType", "none"),
|
|
209
|
+
"ConvolutionKernel": get_str(ct_file, "ConvolutionKernel", "none"),
|
|
210
|
+
"GantryTilt": get_float(ct_file, "GantryDetectorTilt", 0.0),
|
|
211
|
+
"DiameterFOV": get_float(ct_file, "DataCollectionDiameter"),
|
|
212
|
+
"ReconstructionDiameter": get_float(ct_file, "ReconstructionDiameter"),
|
|
213
|
+
"Pitch": get_float(ct_file, "SpiralPitchFactor"),
|
|
214
|
+
"SingleCollimationWidth": get_float(ct_file, "SingleCollimationWidth"),
|
|
215
|
+
"TotalCollimationWidth": get_float(ct_file, "TotalCollimationWidth"),
|
|
216
|
+
"FocalSpots": get_str(ct_file, "FocalSpots"),
|
|
217
|
+
"CTDIvol": get_float(ct_file, "CTDIvol"),
|
|
218
|
+
"ContrastBolusAgent": get_str(ct_file, "ContrastBolusAgent", "none"),
|
|
219
|
+
"ContrastBolusRoute": get_str(ct_file, "ContrastBolusRoute", "n/a"),
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
# Voxel size
|
|
223
|
+
pixel_spacing = get_str(ct_file, "PixelSpacing")
|
|
224
|
+
slice_thickness = params["SliceThickness"]
|
|
225
|
+
if pixel_spacing != "n/a" and slice_thickness != "n/a":
|
|
226
|
+
params["AcquisitionVoxelSize"] = [float(pixel_spacing[0]), float(pixel_spacing[1]), slice_thickness]
|
|
227
|
+
|
|
228
|
+
# Matrix
|
|
229
|
+
rows = get_int(ct_file, "Rows")
|
|
230
|
+
cols = get_int(ct_file, "Columns")
|
|
231
|
+
if rows != "n/a" and cols != "n/a":
|
|
232
|
+
params["ReconMatrixSize"] = [rows, cols]
|
|
233
|
+
params["ReconMatrix"] = [rows, cols, len(dicom_files)]
|
|
234
|
+
|
|
235
|
+
# Per-slice parameters
|
|
236
|
+
if len(dicom_files) > 1:
|
|
237
|
+
z_positions, tube_currents, exposures = [], [], []
|
|
238
|
+
for filepath in dicom_files:
|
|
239
|
+
dcm = pydicom.dcmread(filepath, stop_before_pixels=True)
|
|
240
|
+
pos = get_str(dcm, "ImagePositionPatient")
|
|
241
|
+
if pos != "n/a":
|
|
242
|
+
z_positions.append(pos[-1])
|
|
243
|
+
tc = get_float(dcm, "XRayTubeCurrent")
|
|
244
|
+
if tc != "n/a":
|
|
245
|
+
tube_currents.append(tc)
|
|
246
|
+
exp = get_float(dcm, "Exposure")
|
|
247
|
+
if exp != "n/a":
|
|
248
|
+
exposures.append(exp)
|
|
249
|
+
|
|
250
|
+
if z_positions:
|
|
251
|
+
ix = np.argsort(z_positions)
|
|
252
|
+
if tube_currents and len(tube_currents) == len(z_positions):
|
|
253
|
+
params["TubeCurrent"] = np.array(tube_currents)[ix].tolist()
|
|
254
|
+
if exposures and len(exposures) == len(z_positions):
|
|
255
|
+
params["Exposure"] = np.array(exposures)[ix].tolist()
|
|
256
|
+
|
|
257
|
+
# Derived values
|
|
258
|
+
if slice_thickness != "n/a":
|
|
259
|
+
params["ScanLength"] = len(dicom_files) * slice_thickness / 10
|
|
260
|
+
params["ScanLengthUnit"] = "cm"
|
|
261
|
+
if params["CTDIvol"] != "n/a":
|
|
262
|
+
params["DLP"] = params["ScanLength"] * params["CTDIvol"]
|
|
263
|
+
params["DLPUnit"] = "mGy*cm"
|
|
264
|
+
params["CTDIvolUnit"] = "mGy"
|
|
265
|
+
|
|
266
|
+
# Sort so scalar values come first, then lists last
|
|
267
|
+
scalars = {k: v for k, v in params.items() if not isinstance(v, list)}
|
|
268
|
+
lists = {k: v for k, v in params.items() if isinstance(v, list)}
|
|
269
|
+
return {**scalars, **lists}
|
|
File without changes
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core wrapper for dcm2niix that modifies JSON sidecars with BIDS CT metadata.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional, List, Dict, Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Dcm2niixWrapper:
|
|
13
|
+
"""Wrapper for dcm2niix that adds BIDS CT metadata to JSON sidecars."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, dcm2niix_path: str = "dcm2niix"):
|
|
16
|
+
"""
|
|
17
|
+
Initialize the wrapper.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
dcm2niix_path: Path to dcm2niix executable (default: "dcm2niix" from PATH)
|
|
21
|
+
"""
|
|
22
|
+
self.dcm2niix_path = dcm2niix_path
|
|
23
|
+
|
|
24
|
+
def run(self, input_dir: str, output_dir: Optional[str] = None,
|
|
25
|
+
dcm2niix_args: Optional[List[str]] = None) -> subprocess.CompletedProcess:
|
|
26
|
+
"""
|
|
27
|
+
Run dcm2niix and modify the output JSON sidecars with CT metadata.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
input_dir: Directory containing DICOM files
|
|
31
|
+
output_dir: Output directory (if None, uses dcm2niix default)
|
|
32
|
+
dcm2niix_args: Additional arguments to pass to dcm2niix
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
CompletedProcess object from subprocess
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ValueError: If not a CT scan or multiple series found
|
|
39
|
+
"""
|
|
40
|
+
from .main import is_dicom_file, validate_ct_series
|
|
41
|
+
|
|
42
|
+
# Validate the input directory
|
|
43
|
+
input_path = Path(input_dir)
|
|
44
|
+
dicom_files = []
|
|
45
|
+
if input_path.is_dir():
|
|
46
|
+
for file in input_path.iterdir():
|
|
47
|
+
if file.is_file() and is_dicom_file(str(file)):
|
|
48
|
+
dicom_files.append(str(file))
|
|
49
|
+
|
|
50
|
+
if dicom_files:
|
|
51
|
+
validate_ct_series(dicom_files)
|
|
52
|
+
|
|
53
|
+
# Build dcm2niix command
|
|
54
|
+
cmd = [self.dcm2niix_path]
|
|
55
|
+
|
|
56
|
+
if dcm2niix_args:
|
|
57
|
+
cmd.extend(dcm2niix_args)
|
|
58
|
+
|
|
59
|
+
if output_dir:
|
|
60
|
+
cmd.extend(["-o", output_dir])
|
|
61
|
+
|
|
62
|
+
cmd.append(input_dir)
|
|
63
|
+
|
|
64
|
+
# Get the actual output directory
|
|
65
|
+
if output_dir:
|
|
66
|
+
out_path = Path(output_dir)
|
|
67
|
+
else:
|
|
68
|
+
out_path = Path(input_dir)
|
|
69
|
+
|
|
70
|
+
# List existing JSON files before conversion
|
|
71
|
+
existing_jsons = set(out_path.glob("*.json")) if out_path.exists() else set()
|
|
72
|
+
|
|
73
|
+
# Run dcm2niix
|
|
74
|
+
print(f"Running: {' '.join(cmd)}", file=sys.stderr)
|
|
75
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
76
|
+
|
|
77
|
+
# Print dcm2niix output
|
|
78
|
+
if result.stdout:
|
|
79
|
+
print(result.stdout, end='')
|
|
80
|
+
if result.stderr:
|
|
81
|
+
print(result.stderr, end='', file=sys.stderr)
|
|
82
|
+
|
|
83
|
+
if result.returncode != 0:
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
# Find newly created JSON files
|
|
87
|
+
current_jsons = set(out_path.glob("*.json")) if out_path.exists() else set()
|
|
88
|
+
new_jsons = current_jsons - existing_jsons
|
|
89
|
+
|
|
90
|
+
# Modify each new JSON sidecar
|
|
91
|
+
for json_file in new_jsons:
|
|
92
|
+
self._modify_json_sidecar(json_file, dicom_files)
|
|
93
|
+
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
def _modify_json_sidecar(self, json_file: Path, dicom_files: List[str]):
|
|
97
|
+
"""Add CT metadata to JSON sidecar."""
|
|
98
|
+
from .main import extract_ct_metadata, is_ct_scan
|
|
99
|
+
|
|
100
|
+
if not dicom_files:
|
|
101
|
+
raise ValueError("No DICOM files found")
|
|
102
|
+
|
|
103
|
+
if not is_ct_scan(dicom_files):
|
|
104
|
+
raise ValueError("Not a CT scan")
|
|
105
|
+
|
|
106
|
+
with open(json_file, 'r') as f:
|
|
107
|
+
data = json.load(f)
|
|
108
|
+
|
|
109
|
+
ct_metadata = extract_ct_metadata(dicom_files)
|
|
110
|
+
data.update(ct_metadata)
|
|
111
|
+
|
|
112
|
+
with open(json_file, 'w') as f:
|
|
113
|
+
json.dump(data, f, indent=2)
|
|
114
|
+
|
|
115
|
+
print(f"Added {len(ct_metadata)} CT metadata fields to {json_file.name}", file=sys.stderr)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def run_dcm2niix_wrapper(input_dir: str, output_dir: Optional[str] = None,
|
|
119
|
+
dcm2niix_args: Optional[List[str]] = None,
|
|
120
|
+
additional_metadata: Optional[Dict[str, Any]] = None,
|
|
121
|
+
dcm2niix_path: str = "dcm2niix") -> int:
|
|
122
|
+
"""
|
|
123
|
+
Convenience function to run the dcm2niix wrapper.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
input_dir: Directory containing DICOM files
|
|
127
|
+
output_dir: Output directory (if None, uses dcm2niix default)
|
|
128
|
+
dcm2niix_args: Additional arguments to pass to dcm2niix
|
|
129
|
+
additional_metadata: Additional metadata to add to JSON sidecars
|
|
130
|
+
dcm2niix_path: Path to dcm2niix executable
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Exit code from dcm2niix
|
|
134
|
+
"""
|
|
135
|
+
wrapper = Dcm2niixWrapper(dcm2niix_path)
|
|
136
|
+
result = wrapper.run(input_dir, output_dir, dcm2niix_args, additional_metadata)
|
|
137
|
+
return result.returncode
|