Skip to content

Commit 3dbb64c

Browse files
committed
[Feature] <Allow reading of DICOM images> #68
Squashed some bugs and tried some more use case testing. Wrote up a function to save simulated DWI as DICOM images with headers configured for all in x-direction. provided pyproject.toml for separation of dependenccies and setuptools configured for the different root packages.
1 parent 22df73a commit 3dbb64c

File tree

6 files changed

+217
-68
lines changed

6 files changed

+217
-68
lines changed

Docker/README.md

Lines changed: 30 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,6 @@ Before running the Docker container, here are the available options for the `Doc
6565

6666
Replace `brain.nii.gz`, `brain.bvec`, and `brain.bval` with the actual file names you want to use.
6767

68-
69-
7068
## Running the Docker container for reading in DICOM Images
7169

7270
- You can run the dicom2nifti Docker container using the `docker run` command. This command runs the Docker image with the specified input files:
@@ -76,53 +74,48 @@ Before running the Docker container, here are the available options for the `Doc
7674
-v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/src/app \
7775
-v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/app/output \
7876
tf2.4_ivim-mri_codecollection \
79-
/usr/src/app/dicom_folder /usr/app/output
77+
/usr/src/app/dicom_folder
8078
```
8179

8280
- You can run the dicom2niix_wrapper.py script either directly or from inside a Docker container (non-interactive only). Here are the available options:
8381

84-
##### example usage:
82+
### Example usage
8583

86-
```sh
87-
sudo docker run -it --rm --name TF2.4_IVIM-MRI_CodeCollection \
88-
-v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/src/app \
89-
-v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/app/output \
90-
tf2.4_ivim-mri_codecollection \
91-
/usr/src/app/dicom_folder /usr/app/output -n -1
92-
```
84+
```sh
85+
sudo docker run -it --rm --name TF2.4_IVIM-MRI_CodeCollection \
86+
-v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/src/app \
87+
-v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/app/output \
88+
tf2.4_ivim-mri_codecollection \
89+
/usr/src/app/dicom_folder -o /usr/app/output
90+
```
9391

94-
```sh
95-
sudo docker run -it --rm --name TF2.4_IVIM-MRI_CodeCollection \
96-
-v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/src/app \
97-
-v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/app/output \
98-
tf2.4_ivim-mri_codecollection \
99-
/usr/src/app/dicom_folder /usr/app/output -m
100-
```
92+
```sh
93+
sudo docker run -it --rm --name TF2.4_IVIM-MRI_CodeCollection \
94+
-v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/src/app \
95+
-v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/app/output \
96+
tf2.4_ivim-mri_codecollection \
97+
/usr/src/app/dicom_folder -o /usr/app/output -m
98+
```
10199

102-
```sh
103-
sudo docker run -it --rm --name TF2.4_IVIM-MRI_CodeCollection \
104-
-v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/src/app \
105-
-v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/app/output \
106-
tf2.4_ivim-mri_codecollection \
107-
/usr/src/app/dicom_file /usr/app/output -s
108-
```
100+
```sh
101+
sudo docker run -it --rm --name TF2.4_IVIM-MRI_CodeCollection \
102+
-v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/src/app \
103+
-v ~/TF2.4_IVIM-MRI_CodeCollection:/usr/app/output \
104+
tf2.4_ivim-mri_codecollection \
105+
/usr/src/app/dicom_file -o /usr/app/output -s
106+
```
109107

110108
#### Required parameters
111109

112-
input: Path to the input DICOM directory.
110+
input: Path to the input DICOM directory. Some scanners store images in nested subfolders, so a single session might be stored in the folders "/usr/subj22/111", "/usr/subj22/112" and "/usr/subj22/123". In this case you should simply provide the parent directly ("/usr/subj22") and dcm2niix will recursively search the sub directories and organize all your images for you.
113111

114112
output: Path to the output directory for the converted NIfTI files.
115113

116114
#### Optional Flags
117115

118-
#### -n, --series-number
119-
120-
Convert only the specified DICOM series number.
121-
A special feature of this option is unleashed when you provide a negative series number ("-n -1"): in this case the software will report the series numbers available in the input folder, but will convert nothing.
122-
123116
#### -m, --merge-2d
124117

125-
Merge 2D slices into a 3D or 4D NIfTI image regardless of study time, echo, coil, orientation, etc.
118+
Merge 2D slices into a 3D or 4D NIfTI image regardless of study time, echo, coil, orientation, etc.
126119
Depending on your vendor, you may want to keep images segmented based on these attributes or merge/combine them.
127120

128121
#### -s, --single-file
@@ -143,4 +136,9 @@ Run the tool in interactive mode. This launches a terminal-based wizard where yo
143136

144137
#### It is strongly recommend that users check validate the b-vector directions for their hardware and sequence as [described in a dedicated document](https://www.nitrc.org/docman/?group_id=880)
145138

139+
![DICOM2NiFTI](dicom2nifti/DICOM2NiFTI.png)
140+
141+
#### Output of NIFTI and DICOM objects generated from signal generation test
142+
143+
- NIfTI follows the Talairach/MNI coordinate system where the X value increases as we move toward the participant's right, the Y increases as we move anteriorly. In contrast, for bipeds DICOM specifies the the X increases as we more toward the participant's left, while the Y increases as we move posteriorly. Both agree that the Z coordinate increases as we move superiorly.
146144
---

Docker/dicom2nifti/DICOM2NiFTI.png

714 KB
Loading

Docker/generate_signal_docker_test.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
from pathlib import Path
12
import numpy as np
23
import nibabel as nib
34
from utilities.data_simulation.GenerateData import GenerateData
45
from WrapImage.nifti_wrapper import save_nifti_file
5-
6+
from WrapImage.dicom2niix_wrapper import save_dicom_objects
67

78
def save_bval_bvec(filename, values):
89
if filename.endswith('.bval'):
@@ -34,9 +35,28 @@ def save_bval_bvec(filename, values):
3435
# Generate IVIM signal
3536
signals = gd.ivim_signal(D_in, Dp_in, f_in, S0, bvals_reshaped)
3637

38+
39+
# Generate 4D signal by stacking volumes for each bval
40+
signals_4d = np.stack([
41+
gd.ivim_signal(D_in, Dp_in, f_in, S0, np.full(shape, bval))
42+
for bval in bvals
43+
], axis=-1)
44+
3745
# Save the generated image as a NIfTI file
3846
save_nifti_file(signals, "ivim_simulation.nii.gz")
3947
# Save the bval in a file
4048
save_bval_bvec("ivim_simulation.bval", [0, 50, 100, 500, 1000])
4149
# Save the bvec value
4250
save_bval_bvec("ivim_simulation.bvec", [[1, 0, 0], [0, 1, 0], [0, 0, 1]])
51+
52+
bvecs = [[1, 0, 0]] * len(bvals) # Assuming all x-direction
53+
54+
save_dicom_objects(
55+
volume_4d=signals_4d,
56+
bvals=bvals.tolist(),
57+
bvecs=bvecs,
58+
out_dir=Path("ivim_simulation_dicom"),
59+
f_vals=f_in,
60+
D_vals=D_in,
61+
Dp_vals=Dp_in
62+
)

WrapImage/dicom2niix_wrapper.py

Lines changed: 119 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,101 @@
66
import subprocess
77
import sys
88
import inquirer
9+
import itk
10+
import numpy as np
11+
import datetime
12+
import uuid
13+
14+
def save_dicom_objects(
15+
volume_4d: np.ndarray,
16+
bvals: list,
17+
bvecs: list,
18+
out_dir: Path,
19+
f_vals: np.ndarray = None,
20+
D_vals: np.ndarray = None,
21+
Dp_vals: np.ndarray = None,
22+
patient_id: str = "SIM123",
23+
modality: str = "MR",
24+
pixel_spacing=(1.0, 1.0),
25+
slice_thickness=1.0,
26+
series_description="Simulated IVIM",
27+
protocol_name="SimulatedProtocol",
28+
patient_position="HFS",
29+
manufacturer="Simulated Manufacturer",
30+
):
31+
out_dir.mkdir(parents=True, exist_ok=True)
32+
x, y, z, t = volume_4d.shape
33+
assert t == len(bvals) == len(bvecs), "Mismatch in timepoints and bvals/bvecs"
34+
35+
# Define 2D image type (DICOM expects 2D slices)
36+
ImageType = itk.Image[itk.SS, 2] # 2D image with short (int16) pixel type
37+
WriterType = itk.ImageFileWriter[ImageType]
38+
39+
for t_idx in range(t):
40+
volume_3d = volume_4d[:, :, :, t_idx].astype(np.int16)
41+
42+
# Generate common metadata for this timepoint
43+
study_uid = f"1.2.826.0.1.3680043.2.1125.{uuid.uuid4().int >> 64}"
44+
series_uid = f"1.2.826.0.1.3680043.2.1125.{uuid.uuid4().int >> 64}"
45+
now = datetime.datetime.now().strftime("%Y%m%d")
46+
series_number = str(t_idx + 1)
47+
48+
for slice_idx in range(z):
49+
# Create 2D slice
50+
slice_2d = volume_3d[:, :, slice_idx]
51+
image_2d = itk.image_view_from_array(slice_2d.T) # Transpose for proper orientation
52+
image_2d = itk.cast_image_filter(image_2d, ttype=(type(image_2d), ImageType))
53+
54+
# Set image properties
55+
image_2d.SetSpacing(pixel_spacing)
56+
image_2d.SetOrigin([0.0, 0.0])
57+
58+
# Create metadata dictionary
59+
meta_dict = itk.MetaDataDictionary()
60+
meta_dict["0010|0010"] = "Simulated^Patient"
61+
meta_dict["0010|0020"] = patient_id
62+
meta_dict["0008|0060"] = modality
63+
meta_dict["0008|0020"] = now
64+
meta_dict["0008|0030"] = "120000"
65+
meta_dict["0020|000d"] = study_uid
66+
meta_dict["0020|000e"] = series_uid
67+
meta_dict["0020|0011"] = series_number
68+
meta_dict["0008|103e"] = f"{series_description} Volume {series_number}"
69+
meta_dict["0018|1030"] = protocol_name
70+
meta_dict["0018|5100"] = patient_position
71+
meta_dict["0008|0070"] = manufacturer
72+
73+
meta_dict["0018|9087"] = str(float(bvals[t_idx]))
74+
gx, gy, gz = bvecs[t_idx]
75+
meta_dict["0018|9089"] = f"{gx}\\{gy}\\{gz}"
76+
77+
if f_vals is not None:
78+
meta_dict["0011|1001"] = f"f_in_mean={np.mean(f_vals):.6f}"
79+
if D_vals is not None:
80+
meta_dict["0011|1002"] = f"D_in_mean={np.mean(D_vals):.6e}"
81+
if Dp_vals is not None:
82+
meta_dict["0011|1003"] = f"Dp_in_mean={np.mean(Dp_vals):.6e}"
83+
84+
sop_uid = f"1.2.826.0.1.3680043.8.498.{uuid.uuid4().int >> 64}"
85+
meta_dict["0008|0018"] = sop_uid
86+
meta_dict["0020|0013"] = str(slice_idx + 1)
87+
meta_dict["0020|0032"] = f"0\\0\\{slice_idx * slice_thickness}"
88+
meta_dict["0020|0037"] = "1\\0\\0\\0\\1\\0"
89+
90+
# Create and configure writer
91+
writer = WriterType.New()
92+
writer.SetInput(image_2d)
93+
writer.SetFileName(str(out_dir / f"vol_{t_idx:03d}_slice_{slice_idx:03d}.dcm"))
94+
95+
# Use GDCMImageIO for DICOM writing
96+
gdcm_io = itk.GDCMImageIO.New()
97+
gdcm_io.SetMetaDataDictionary(meta_dict)
98+
gdcm_io.KeepOriginalUIDOn()
99+
100+
writer.SetImageIO(gdcm_io)
101+
writer.Update()
102+
103+
print(f" DICOMs written to {out_dir.resolve()}")
9104

10105
def prompt_input_directory():
11106
return inquirer.prompt([
@@ -49,26 +144,24 @@ def prompt_output_directory(input_dir):
49144
return os.path.abspath(os.path.join(input_dir, answer.strip("./")))
50145

51146

52-
def dicom_to_niix(vol_dir: Path, out_dir: Path, merge_2d: bool = False, series_num: int = -2, is_single_file: bool = False):
147+
def dicom_to_niix(vol_dir: Path, out_dir: Path = None, merge_2d: bool = False, is_single_file: bool = False):
53148
"""
54149
For converting DICOM images to a (compresssed) 4d nifti image
55150
"""
56-
57-
os.makedirs(out_dir, exist_ok=True)
151+
if not out_dir:
152+
os.makedirs(out_dir, exist_ok=True)
58153

59154
cmd = [
60155
"dcm2niix",
61156
"-f", "%s_%p", # dcm2niix attempts to provide a sensible file naming scheme
62-
"-o", out_dir, # output directory
63-
"-z", "y", #specifying compressed nii.gz file
157+
"-o", out_dir if out_dir else "", # Add merge option
158+
"-z", "y", # parallel pigz compressed nii.gz file; 'optimal'"-z o" which pipes data directly to pigz
64159
"-m", "y" if merge_2d else "n", # Add merge option
65160
"-s", "y" if is_single_file else "n",
66161
# https://www.nitrc.org/plugins/mwiki/index.php/dcm2nii:MainPage
67162
# for further configuration for general usage see page above
163+
vol_dir
68164
]
69-
if series_num >= -1:
70-
cmd.extend(["-n", str(series_num)])
71-
cmd.append(str(vol_dir)) # input directory
72165

73166
try:
74167
success, output = capture_subprocess_output(cmd)
@@ -78,18 +171,15 @@ def dicom_to_niix(vol_dir: Path, out_dir: Path, merge_2d: bool = False, series_n
78171

79172
nifti_files = list(Path(out_dir).glob("*.nii.gz"))
80173

81-
if (is_single_file or merge_2d or series_num >= 0):
82-
if len(nifti_files) != 1:
83-
raise RuntimeError(
84-
f"Expected a single .nii.gz output due to flags "
85-
f"{'-s' if is_single_file else ''} "
86-
f"{'-m' if merge_2d else ''} "
87-
f"{f'-n {series_num}' if series_num >= 0 else ''}, "
88-
f"but found {len(nifti_files)} files."
89-
)
90-
else:
91-
if len(nifti_files) < 1:
92-
raise RuntimeError("No NIfTI (.nii.gz) files were generated.")
174+
if is_single_file and len(nifti_files) != 1:
175+
raise RuntimeError("Expected a single .nii.gz output due to flags"
176+
" Please collect your NIfTI files in the output directory prior to execution.")
177+
if merge_2d and len(nifti_files) != 1:
178+
raise RuntimeError("Expected a single .nii.gz output due to flags"
179+
" Check the Warnings logged by dicom2niix to see source of error.")
180+
if len(nifti_files) < 1:
181+
raise RuntimeError("No NIfTI (.nii.gz) files were generated."
182+
" Check the Warnings logged by dicom2niix and Double-check your input before running again.")
93183

94184
bval_files = list(out_dir.glob("*.bval"))
95185
bvec_files = list(out_dir.glob("*.bvec"))
@@ -104,7 +194,9 @@ def dicom_to_niix(vol_dir: Path, out_dir: Path, merge_2d: bool = False, series_n
104194
except subprocess.CalledProcessError as e:
105195
raise RuntimeError(f"dcm2niix failed: {e.stderr}")
106196

107-
197+
### Adapted from https://gist.github.com/nawatts/e2cdca610463200c12eac2a14efc0bfb ###
198+
### for further breakdown and see gist above ###
199+
### for future improvements https://me.micahrl.com/blog/magicrun/ to be called over SSH OR SLURM. ###
108200
def capture_subprocess_output(subprocess_args):
109201
process = subprocess.Popen(
110202
subprocess_args,
@@ -165,18 +257,13 @@ def run_interactive():
165257
if not add_more:
166258
break
167259

168-
series_num = inquirer.prompt([
169-
inquirer.Text("series", message="🔢 Enter series number to convert (-n <num>) [Leave blank for all]", default="")
170-
])["series"]
171-
series_num = int(series_num) if series_num.isdigit() else -1
172-
173260
merge_answer = inquirer.prompt([
174261
inquirer.Confirm("merge", message="🧩 Merge 2D slices into a single NIfTI (-m y)?", default=True)
175262
])
176263
merge_2d = merge_answer["merge"]
177264

178265
single_file = inquirer.prompt([
179-
inquirer.Confirm("single", message="📦 Force single file output (-s y)?", default=False)
266+
inquirer.Confirm("single", message="📦 Force single file input (-s y)?", default=False)
180267
])["single"]
181268

182269
for in_dir, out_dir in zip(input_dirs, output_dirs):
@@ -185,22 +272,21 @@ def run_interactive():
185272

186273
print(f"Converting:\n → Input: {vol_dir}\n → Output: {out_path}")
187274
try:
188-
nifti, bval, bvec = dicom_to_niix(vol_dir, out_path, merge_2d, series_num, single_file)
275+
nifti, bval, bvec = dicom_to_niix(vol_dir, out_path, merge_2d, single_file)
189276
print(f" Conversion succeeded: {nifti}")
190277
except RuntimeError as err:
191278
print(f"❌ Conversion failed: {err}")
192279

193280
def run_cli(input_path: str, output_path: str, **kwargs):
194281
vol_dir = Path(input_path)
195-
out_dir = Path(output_path)
282+
out_dir = Path(output_path) if output_path else vol_dir
196283

197284
merge_2d = kwargs.get("merge_2d", False)
198-
series_num = kwargs.get("series_number", -2)
199285
single_file = kwargs.get("single_file", False)
200286

201287
print(f" Converting:\n → Input: {vol_dir}\n → Output: {out_dir}")
202288
try:
203-
nifti, bval, bvec = dicom_to_niix(vol_dir, out_dir, merge_2d, series_num, single_file)
289+
nifti, bval, bvec = dicom_to_niix(vol_dir, out_dir, merge_2d, single_file)
204290
print(f" Created NIfTI: {nifti}")
205291

206292
if bval and bvec:
@@ -219,8 +305,7 @@ def run_cli(input_path: str, output_path: str, **kwargs):
219305
if __name__ == "__main__":
220306
parser = argparse.ArgumentParser(description="DICOM to NIfTI converter with optional IVIM processing")
221307
parser.add_argument("input", nargs="?", help="Path to input DICOM directory")
222-
parser.add_argument("output", nargs="?", help="Path to output directory for NIfTI files")
223-
parser.add_argument("-n", "--series-number", type=int, default=-2, help="Only convert this series number (-n <num>)")
308+
parser.add_argument("-o", "--output", nargs="?", help="Path to output directory for NIfTI files, defaults to the same folder as the original DICOM files.")
224309
parser.add_argument("-m", "--merge-2d", action="store_true", help="Merge 2D slices (-m y)")
225310
parser.add_argument("-s", "--single-file", action="store_true", help="Enable single file mode (-s y)")
226311
parser.add_argument("-pu", "--prompt-user", action="store_true", help="Run in interactive mode")
@@ -229,7 +314,7 @@ def run_cli(input_path: str, output_path: str, **kwargs):
229314

230315
if args.prompt_user:
231316
run_interactive()
232-
elif args.input and args.output:
317+
elif args.input:
233318
run_cli(args.input, args.output, **vars(args))
234319
else:
235320
print("❗ You must provide input and output paths OR use --prompt-user for interactive mode.")

0 commit comments

Comments
 (0)