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