mediml 0.9.9__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.
- MEDiml/MEDscan.py +1696 -0
- MEDiml/__init__.py +21 -0
- MEDiml/biomarkers/BatchExtractor.py +806 -0
- MEDiml/biomarkers/BatchExtractorTexturalFilters.py +840 -0
- MEDiml/biomarkers/__init__.py +16 -0
- MEDiml/biomarkers/diagnostics.py +125 -0
- MEDiml/biomarkers/get_oriented_bound_box.py +158 -0
- MEDiml/biomarkers/glcm.py +1602 -0
- MEDiml/biomarkers/gldzm.py +523 -0
- MEDiml/biomarkers/glrlm.py +1315 -0
- MEDiml/biomarkers/glszm.py +555 -0
- MEDiml/biomarkers/int_vol_hist.py +527 -0
- MEDiml/biomarkers/intensity_histogram.py +615 -0
- MEDiml/biomarkers/local_intensity.py +89 -0
- MEDiml/biomarkers/morph.py +1756 -0
- MEDiml/biomarkers/ngldm.py +780 -0
- MEDiml/biomarkers/ngtdm.py +414 -0
- MEDiml/biomarkers/stats.py +373 -0
- MEDiml/biomarkers/utils.py +389 -0
- MEDiml/filters/TexturalFilter.py +299 -0
- MEDiml/filters/__init__.py +9 -0
- MEDiml/filters/apply_filter.py +134 -0
- MEDiml/filters/gabor.py +215 -0
- MEDiml/filters/laws.py +283 -0
- MEDiml/filters/log.py +147 -0
- MEDiml/filters/mean.py +121 -0
- MEDiml/filters/textural_filters_kernels.py +1738 -0
- MEDiml/filters/utils.py +107 -0
- MEDiml/filters/wavelet.py +237 -0
- MEDiml/learning/DataCleaner.py +198 -0
- MEDiml/learning/DesignExperiment.py +480 -0
- MEDiml/learning/FSR.py +667 -0
- MEDiml/learning/Normalization.py +112 -0
- MEDiml/learning/RadiomicsLearner.py +714 -0
- MEDiml/learning/Results.py +2237 -0
- MEDiml/learning/Stats.py +694 -0
- MEDiml/learning/__init__.py +10 -0
- MEDiml/learning/cleaning_utils.py +107 -0
- MEDiml/learning/ml_utils.py +1015 -0
- MEDiml/processing/__init__.py +6 -0
- MEDiml/processing/compute_suv_map.py +121 -0
- MEDiml/processing/discretisation.py +149 -0
- MEDiml/processing/interpolation.py +275 -0
- MEDiml/processing/resegmentation.py +66 -0
- MEDiml/processing/segmentation.py +912 -0
- MEDiml/utils/__init__.py +25 -0
- MEDiml/utils/batch_patients.py +45 -0
- MEDiml/utils/create_radiomics_table.py +131 -0
- MEDiml/utils/data_frame_export.py +42 -0
- MEDiml/utils/find_process_names.py +16 -0
- MEDiml/utils/get_file_paths.py +34 -0
- MEDiml/utils/get_full_rad_names.py +21 -0
- MEDiml/utils/get_institutions_from_ids.py +16 -0
- MEDiml/utils/get_patient_id_from_scan_name.py +22 -0
- MEDiml/utils/get_patient_names.py +26 -0
- MEDiml/utils/get_radiomic_names.py +27 -0
- MEDiml/utils/get_scan_name_from_rad_name.py +22 -0
- MEDiml/utils/image_reader_SITK.py +37 -0
- MEDiml/utils/image_volume_obj.py +22 -0
- MEDiml/utils/imref.py +340 -0
- MEDiml/utils/initialize_features_names.py +62 -0
- MEDiml/utils/inpolygon.py +159 -0
- MEDiml/utils/interp3.py +43 -0
- MEDiml/utils/json_utils.py +78 -0
- MEDiml/utils/mode.py +31 -0
- MEDiml/utils/parse_contour_string.py +58 -0
- MEDiml/utils/save_MEDscan.py +30 -0
- MEDiml/utils/strfind.py +32 -0
- MEDiml/utils/textureTools.py +188 -0
- MEDiml/utils/texture_features_names.py +115 -0
- MEDiml/utils/write_radiomics_csv.py +47 -0
- MEDiml/wrangling/DataManager.py +1724 -0
- MEDiml/wrangling/ProcessDICOM.py +512 -0
- MEDiml/wrangling/__init__.py +3 -0
- mediml-0.9.9.dist-info/LICENSE.md +674 -0
- mediml-0.9.9.dist-info/METADATA +232 -0
- mediml-0.9.9.dist-info/RECORD +78 -0
- mediml-0.9.9.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
import warnings
|
|
6
|
+
from typing import List, Union
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pydicom
|
|
10
|
+
import ray
|
|
11
|
+
|
|
12
|
+
from ..utils.imref import imref3d
|
|
13
|
+
|
|
14
|
+
warnings.simplefilter("ignore")
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from ..MEDscan import MEDscan
|
|
19
|
+
from ..processing.segmentation import get_roi
|
|
20
|
+
from ..utils.save_MEDscan import save_MEDscan
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProcessDICOM():
|
|
24
|
+
"""
|
|
25
|
+
Class to process dicom files and extract imaging volume and 3D masks from it
|
|
26
|
+
in order to oganize the data in a MEDscan class object.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
path_images: List[Path],
|
|
32
|
+
path_rs: List[Path],
|
|
33
|
+
path_save: Union[str, Path],
|
|
34
|
+
save: bool) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Args:
|
|
37
|
+
path_images (List[Path]): List of paths to the dicom files of a single scan.
|
|
38
|
+
path_rs (List[Path]): List of paths to the RT struct dicom files for the same scan.
|
|
39
|
+
path_save (Union[str, Path]): Path to the folder where the MEDscan object will be saved.
|
|
40
|
+
save (bool): Whether to save the MEDscan object or not.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
None.
|
|
44
|
+
"""
|
|
45
|
+
self.path_images = path_images
|
|
46
|
+
self.path_rs = path_rs
|
|
47
|
+
self.path_save = Path(path_save) if path_save is str else path_save
|
|
48
|
+
self.save = save
|
|
49
|
+
|
|
50
|
+
def __get_dicom_scan_orientation(self, dicom_header: List[pydicom.dataset.FileDataset]) -> str:
|
|
51
|
+
"""
|
|
52
|
+
Get the orientation of the scan.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
dicom_header (List[pydicom.dataset.FileDataset]): List of dicom headers.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
str: Orientation of the scan.
|
|
59
|
+
"""
|
|
60
|
+
n_slices = len(dicom_header)
|
|
61
|
+
image_patient_positions_x = [dicom_header[i].ImagePositionPatient[0] for i in range(n_slices)]
|
|
62
|
+
image_patient_positions_y = [dicom_header[i].ImagePositionPatient[1] for i in range(n_slices)]
|
|
63
|
+
image_patient_positions_z = [dicom_header[i].ImagePositionPatient[2] for i in range(n_slices)]
|
|
64
|
+
dist = [
|
|
65
|
+
np.median(np.abs(np.diff(image_patient_positions_x))),
|
|
66
|
+
np.median(np.abs(np.diff(image_patient_positions_y))),
|
|
67
|
+
np.median(np.abs(np.diff(image_patient_positions_z)))
|
|
68
|
+
]
|
|
69
|
+
index = dist.index(max(dist))
|
|
70
|
+
if index == 0:
|
|
71
|
+
orientation = 'Sagittal'
|
|
72
|
+
elif index == 1:
|
|
73
|
+
orientation = 'Coronal'
|
|
74
|
+
else:
|
|
75
|
+
orientation = 'Axial'
|
|
76
|
+
|
|
77
|
+
return orientation
|
|
78
|
+
|
|
79
|
+
def __merge_slice_pixel_arrays(self, slice_datasets):
|
|
80
|
+
first_dataset = slice_datasets[0]
|
|
81
|
+
num_rows = first_dataset.Rows
|
|
82
|
+
num_columns = first_dataset.Columns
|
|
83
|
+
num_slices = len(slice_datasets)
|
|
84
|
+
|
|
85
|
+
sorted_slice_datasets = self.__sort_by_slice_spacing(slice_datasets)
|
|
86
|
+
|
|
87
|
+
if any(self.__requires_rescaling(d) for d in sorted_slice_datasets):
|
|
88
|
+
voxels = np.empty(
|
|
89
|
+
(num_columns, num_rows, num_slices), dtype=np.float32)
|
|
90
|
+
for k, dataset in enumerate(sorted_slice_datasets):
|
|
91
|
+
slope = float(getattr(dataset, 'RescaleSlope', 1))
|
|
92
|
+
intercept = float(getattr(dataset, 'RescaleIntercept', 0))
|
|
93
|
+
voxels[:, :, k] = dataset.pixel_array.T.astype(
|
|
94
|
+
np.float32)*slope + intercept
|
|
95
|
+
else:
|
|
96
|
+
dtype = first_dataset.pixel_array.dtype
|
|
97
|
+
voxels = np.empty((num_columns, num_rows, num_slices), dtype=dtype)
|
|
98
|
+
for k, dataset in enumerate(sorted_slice_datasets):
|
|
99
|
+
voxels[:, :, k] = dataset.pixel_array.T
|
|
100
|
+
|
|
101
|
+
return voxels
|
|
102
|
+
|
|
103
|
+
def __requires_rescaling(self, dataset):
|
|
104
|
+
return hasattr(dataset, 'RescaleSlope') or hasattr(dataset, 'RescaleIntercept')
|
|
105
|
+
|
|
106
|
+
def __ijk_to_patient_xyz_transform_matrix(self, slice_datasets):
|
|
107
|
+
first_dataset = self.__sort_by_slice_spacing(slice_datasets)[0]
|
|
108
|
+
image_orientation = first_dataset.ImageOrientationPatient
|
|
109
|
+
row_cosine, column_cosine, slice_cosine = self.__extract_cosines(
|
|
110
|
+
image_orientation)
|
|
111
|
+
|
|
112
|
+
row_spacing, column_spacing = first_dataset.PixelSpacing
|
|
113
|
+
slice_spacing = self.__slice_spacing(slice_datasets)
|
|
114
|
+
|
|
115
|
+
transform = np.identity(4, dtype=np.float32)
|
|
116
|
+
rotation = np.identity(3, dtype=np.float32)
|
|
117
|
+
scaling = np.identity(3, dtype=np.float32)
|
|
118
|
+
|
|
119
|
+
transform[:3, 0] = row_cosine*column_spacing
|
|
120
|
+
transform[:3, 1] = column_cosine*row_spacing
|
|
121
|
+
transform[:3, 2] = slice_cosine*slice_spacing
|
|
122
|
+
|
|
123
|
+
transform[:3, 3] = first_dataset.ImagePositionPatient
|
|
124
|
+
|
|
125
|
+
rotation[:3, 0] = row_cosine
|
|
126
|
+
rotation[:3, 1] = column_cosine
|
|
127
|
+
rotation[:3, 2] = slice_cosine
|
|
128
|
+
|
|
129
|
+
rotation = np.transpose(rotation)
|
|
130
|
+
|
|
131
|
+
scaling[0, 0] = column_spacing
|
|
132
|
+
scaling[1, 1] = row_spacing
|
|
133
|
+
scaling[2, 2] = slice_spacing
|
|
134
|
+
|
|
135
|
+
return transform, rotation, scaling
|
|
136
|
+
|
|
137
|
+
def __validate_slices_form_uniform_grid(self, slice_datasets):
|
|
138
|
+
"""
|
|
139
|
+
Perform various data checks to ensure that the list of slices form a
|
|
140
|
+
evenly-spaced grid of data.
|
|
141
|
+
Some of these checks are probably not required if the data follows the
|
|
142
|
+
DICOM specification, however it seems pertinent to check anyway.
|
|
143
|
+
"""
|
|
144
|
+
invariant_properties = [
|
|
145
|
+
'Modality',
|
|
146
|
+
'SOPClassUID',
|
|
147
|
+
'SeriesInstanceUID',
|
|
148
|
+
'Rows',
|
|
149
|
+
'Columns',
|
|
150
|
+
'ImageOrientationPatient',
|
|
151
|
+
'PixelSpacing',
|
|
152
|
+
'PixelRepresentation',
|
|
153
|
+
'BitsAllocated',
|
|
154
|
+
'BitsStored',
|
|
155
|
+
'HighBit',
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
for property_name in invariant_properties:
|
|
159
|
+
self.__slice_attribute_equal(slice_datasets, property_name)
|
|
160
|
+
|
|
161
|
+
self.__validate_image_orientation(slice_datasets[0].ImageOrientationPatient)
|
|
162
|
+
|
|
163
|
+
slice_positions = self.__slice_positions(slice_datasets)
|
|
164
|
+
self.__check_for_missing_slices(slice_positions)
|
|
165
|
+
|
|
166
|
+
def __validate_image_orientation(self, image_orientation):
|
|
167
|
+
"""
|
|
168
|
+
Ensure that the image orientation is supported
|
|
169
|
+
- The direction cosines have magnitudes of 1 (just in case)
|
|
170
|
+
- The direction cosines are perpendicular
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
row_cosine, column_cosine, slice_cosine = self.__extract_cosines(
|
|
174
|
+
image_orientation)
|
|
175
|
+
|
|
176
|
+
if not self.__almost_zero(np.dot(row_cosine, column_cosine), 1e-4):
|
|
177
|
+
raise ValueError(
|
|
178
|
+
"Non-orthogonal direction cosines: {}, {}".format(row_cosine, column_cosine))
|
|
179
|
+
elif not self.__almost_zero(np.dot(row_cosine, column_cosine), 1e-8):
|
|
180
|
+
warnings.warn("Direction cosines aren't quite orthogonal: {}, {}".format(
|
|
181
|
+
row_cosine, column_cosine))
|
|
182
|
+
|
|
183
|
+
if not self.__almost_one(np.linalg.norm(row_cosine), 1e-4):
|
|
184
|
+
raise ValueError(
|
|
185
|
+
"The row direction cosine's magnitude is not 1: {}".format(row_cosine))
|
|
186
|
+
elif not self.__almost_one(np.linalg.norm(row_cosine), 1e-8):
|
|
187
|
+
warnings.warn(
|
|
188
|
+
"The row direction cosine's magnitude is not quite 1: {}".format(row_cosine))
|
|
189
|
+
|
|
190
|
+
if not self.__almost_one(np.linalg.norm(column_cosine), 1e-4):
|
|
191
|
+
raise ValueError(
|
|
192
|
+
"The column direction cosine's magnitude is not 1: {}".format(column_cosine))
|
|
193
|
+
elif not self.__almost_one(np.linalg.norm(column_cosine), 1e-8):
|
|
194
|
+
warnings.warn(
|
|
195
|
+
"The column direction cosine's magnitude is not quite 1: {}".format(column_cosine))
|
|
196
|
+
sys.stderr.flush()
|
|
197
|
+
|
|
198
|
+
def __is_close(self, a, b, rel_tol=1e-9, abs_tol=0.0):
|
|
199
|
+
return abs(a-b) <= max(rel_tol*max(abs(a), abs(b)), abs_tol)
|
|
200
|
+
|
|
201
|
+
def __almost_zero(self, value, abs_tol):
|
|
202
|
+
return self.__is_close(value, 0.0, abs_tol=abs_tol)
|
|
203
|
+
|
|
204
|
+
def __almost_one(self, value, abs_tol):
|
|
205
|
+
return self.__is_close(value, 1.0, abs_tol=abs_tol)
|
|
206
|
+
|
|
207
|
+
def __extract_cosines(self, image_orientation):
|
|
208
|
+
row_cosine = np.array(image_orientation[:3])
|
|
209
|
+
column_cosine = np.array(image_orientation[3:])
|
|
210
|
+
slice_cosine = np.cross(row_cosine, column_cosine)
|
|
211
|
+
return row_cosine, column_cosine, slice_cosine
|
|
212
|
+
|
|
213
|
+
def __slice_attribute_equal(self, slice_datasets, property_name):
|
|
214
|
+
initial_value = getattr(slice_datasets[0], property_name, None)
|
|
215
|
+
for slice_idx, dataset in enumerate(slice_datasets[1:]):
|
|
216
|
+
value = getattr(dataset, property_name, None)
|
|
217
|
+
if value != initial_value:
|
|
218
|
+
msg = f'Slice {slice_idx+1} have different value for {property_name}: {value} != {initial_value}'
|
|
219
|
+
warnings.warn(msg)
|
|
220
|
+
|
|
221
|
+
def __slice_positions(self, slice_datasets):
|
|
222
|
+
image_orientation = slice_datasets[0].ImageOrientationPatient
|
|
223
|
+
row_cosine, column_cosine, slice_cosine = self.__extract_cosines(
|
|
224
|
+
image_orientation)
|
|
225
|
+
return [np.dot(slice_cosine, d.ImagePositionPatient) for d in slice_datasets]
|
|
226
|
+
|
|
227
|
+
def __check_for_missing_slices(self, slice_positions):
|
|
228
|
+
slice_positions_diffs = np.diff(sorted(slice_positions))
|
|
229
|
+
if not np.allclose(slice_positions_diffs, slice_positions_diffs[0], atol=0, rtol=1e-5):
|
|
230
|
+
msg = "The slice spacing is non-uniform. Slice spacings:\n{}"
|
|
231
|
+
warnings.warn(msg.format(slice_positions_diffs))
|
|
232
|
+
sys.stderr.flush()
|
|
233
|
+
if not np.allclose(slice_positions_diffs, slice_positions_diffs[0], atol=0, rtol=1e-1):
|
|
234
|
+
raise ValueError('The slice spacing is non-uniform. It appears there are extra slices from another scan')
|
|
235
|
+
|
|
236
|
+
def __slice_spacing(self, slice_datasets):
|
|
237
|
+
if len(slice_datasets) > 1:
|
|
238
|
+
slice_positions = self.__slice_positions(slice_datasets)
|
|
239
|
+
slice_positions_diffs = np.diff(sorted(slice_positions))
|
|
240
|
+
return np.mean(slice_positions_diffs)
|
|
241
|
+
|
|
242
|
+
return 0.0
|
|
243
|
+
|
|
244
|
+
def __sort_by_slice_spacing(self, slice_datasets):
|
|
245
|
+
slice_spacing = self.__slice_positions(slice_datasets)
|
|
246
|
+
return [d for (s, d) in sorted(zip(slice_spacing, slice_datasets))]
|
|
247
|
+
|
|
248
|
+
def combine_slices(self, slice_datasets: List[pydicom.dataset.FileDataset]) -> List[np.ndarray]:
|
|
249
|
+
"""
|
|
250
|
+
Given a list of pydicom datasets for an image series, stitch them together into a
|
|
251
|
+
three-dimensional numpy array of iamging data. Also calculate a 4x4 affine transformation
|
|
252
|
+
matrix that converts the ijk-pixel-indices into the xyz-coordinates in the
|
|
253
|
+
DICOM patient's coordinate system and 4x4 rotation and scaling matrix.
|
|
254
|
+
If any of the DICOM images contain either the
|
|
255
|
+
`Rescale Slope <https://dicom.innolitics.com/ciods/ct-image/ct-image/00281053>`__ or the
|
|
256
|
+
`Rescale Intercept <https://dicom.innolitics.com/ciods/ct-image/ct-image/00281052>`__
|
|
257
|
+
attributes they will be applied to each slice individually.
|
|
258
|
+
This function requires that the datasets:
|
|
259
|
+
|
|
260
|
+
- Be in same series (have the same
|
|
261
|
+
`Series Instance UID <https://dicom.innolitics.com/ciods/ct-image/general-series/0020000e>`__,
|
|
262
|
+
`Modality <https://dicom.innolitics.com/ciods/ct-image/general-series/00080060>`__,
|
|
263
|
+
and `SOP Class UID <https://dicom.innolitics.com/ciods/ct-image/sop-common/00080016>`__).
|
|
264
|
+
- The binary storage of each slice must be the same (have the same
|
|
265
|
+
`Bits Allocated <https://dicom.innolitics.com/ciods/ct-image/image-pixel/00280100>`__,
|
|
266
|
+
`Bits Stored <https://dicom.innolitics.com/ciods/ct-image/image-pixel/00280101>`__,
|
|
267
|
+
`High Bit <https://dicom.innolitics.com/ciods/ct-image/image-pixel/00280102>`__, and
|
|
268
|
+
`Pixel Representation <https://dicom.innolitics.com/ciods/ct-image/image-pixel/00280103>`__).
|
|
269
|
+
- The image slice must approximately form a grid. This means there can not
|
|
270
|
+
be any missing internal slices (missing slices on the ends of the dataset
|
|
271
|
+
are not detected). It also means that each slice must have the same
|
|
272
|
+
`Rows <https://dicom.innolitics.com/ciods/ct-image/image-pixel/00280010>`__,
|
|
273
|
+
`Columns <https://dicom.innolitics.com/ciods/ct-image/image-pixel/00280011>`__,
|
|
274
|
+
`Pixel Spacing <https://dicom.innolitics.com/ciods/ct-image/image-plane/00280030>`__, and
|
|
275
|
+
`Image Orientation (Patient) <https://dicom.innolitics.com/ciods/ct-image/image-plane/00200037>`__
|
|
276
|
+
attribute values.
|
|
277
|
+
- The direction cosines derived from the
|
|
278
|
+
`Image Orientation (Patient) <https://dicom.innolitics.com/ciods/ct-image/image-plane/00200037>`__
|
|
279
|
+
attribute must, within 1e-4, have a magnitude of 1. The cosines must
|
|
280
|
+
also be approximately perpendicular (their dot-product must be within
|
|
281
|
+
1e-4 of 0). Warnings are displayed if any of theseapproximations are
|
|
282
|
+
below 1e-8, however, since we have seen real datasets with values up to
|
|
283
|
+
1e-4, we let them pass.
|
|
284
|
+
- The `Image Position (Patient) <https://dicom.innolitics.com/ciods/ct-image/image-plane/00200032>`__
|
|
285
|
+
values must approximately form a line.
|
|
286
|
+
|
|
287
|
+
If any of these conditions are not met, a `dicom_numpy.DicomImportException` is raised.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
slice_datasets (List[pydicom.dataset.FileDataset]): List of dicom headers.
|
|
291
|
+
Returns:
|
|
292
|
+
List[numpy.ndarray]: List of numpy arrays containing the data extracted the dicom files
|
|
293
|
+
(voxels, translation, rotation and scaling matrix).
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
if not slice_datasets:
|
|
297
|
+
raise ValueError("Must provide at least one DICOM dataset")
|
|
298
|
+
|
|
299
|
+
self.__validate_slices_form_uniform_grid(slice_datasets)
|
|
300
|
+
|
|
301
|
+
voxels = self.__merge_slice_pixel_arrays(slice_datasets)
|
|
302
|
+
transform, rotation, scaling = self.__ijk_to_patient_xyz_transform_matrix(
|
|
303
|
+
slice_datasets)
|
|
304
|
+
|
|
305
|
+
return voxels, transform, rotation, scaling
|
|
306
|
+
|
|
307
|
+
def process_files(self):
|
|
308
|
+
"""
|
|
309
|
+
Reads DICOM files (imaging volume + ROIs) in the instance data path
|
|
310
|
+
and then organizes it in the MEDscan class.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
None.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
medscan (MEDscan): Instance of a MEDscan class.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
return self.process_files_wrapper.remote(self)
|
|
320
|
+
|
|
321
|
+
@ray.remote
|
|
322
|
+
def process_files_wrapper(self) -> MEDscan:
|
|
323
|
+
"""
|
|
324
|
+
Wrapper function to process the files.
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
# PARTIAL PARSING OF ARGUMENTS
|
|
328
|
+
if self.path_images is None:
|
|
329
|
+
raise ValueError('At least two arguments must be provided')
|
|
330
|
+
|
|
331
|
+
# INITIALIZATION
|
|
332
|
+
medscan = MEDscan()
|
|
333
|
+
|
|
334
|
+
# IMAGING DATA AND ROI DEFINITION (if applicable)
|
|
335
|
+
# Reading DICOM images and headers
|
|
336
|
+
dicom_hi = [pydicom.dcmread(str(dicom_file), force=True)
|
|
337
|
+
for dicom_file in self.path_images]
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
# Determination of the scan orientation
|
|
341
|
+
medscan.data.orientation = self.__get_dicom_scan_orientation(dicom_hi)
|
|
342
|
+
|
|
343
|
+
# IMPORTANT NOTE: extract_voxel_data using combine_slices from dicom_numpy
|
|
344
|
+
# missing slices and oblique restrictions apply see the reference:
|
|
345
|
+
# https://dicom-numpy.readthedocs.io/en/latest/index.html#dicom_numpy.combine_slices
|
|
346
|
+
try:
|
|
347
|
+
voxel_ndarray, ijk_to_xyz, rotation_m, scaling_m = self.combine_slices(dicom_hi)
|
|
348
|
+
except ValueError as e:
|
|
349
|
+
raise ValueError(f'Invalid DICOM data for combine_slices(). Error: {e}')
|
|
350
|
+
|
|
351
|
+
# Alignment of scan coordinates for MR scans
|
|
352
|
+
# (inverse of ImageOrientationPatient rotation matrix)
|
|
353
|
+
if not np.allclose(rotation_m, np.eye(rotation_m.shape[0])):
|
|
354
|
+
medscan.data.volume.scan_rot = rotation_m
|
|
355
|
+
|
|
356
|
+
medscan.data.volume.array = voxel_ndarray
|
|
357
|
+
medscan.type = dicom_hi[0].Modality + 'scan'
|
|
358
|
+
|
|
359
|
+
# 7. Creation of imref3d object
|
|
360
|
+
pixel_x = scaling_m[0, 0]
|
|
361
|
+
pixel_y = scaling_m[1, 1]
|
|
362
|
+
slice_s = scaling_m[2, 2]
|
|
363
|
+
min_grid = rotation_m@ijk_to_xyz[:3, 3]
|
|
364
|
+
min_x_grid = min_grid[0]
|
|
365
|
+
min_y_grid = min_grid[1]
|
|
366
|
+
min_z_grid = min_grid[2]
|
|
367
|
+
size_image = np.shape(voxel_ndarray)
|
|
368
|
+
spatial_ref = imref3d(size_image, pixel_x, pixel_y, slice_s)
|
|
369
|
+
spatial_ref.XWorldLimits = (np.array(spatial_ref.XWorldLimits) -
|
|
370
|
+
(spatial_ref.XWorldLimits[0] -
|
|
371
|
+
(min_x_grid-pixel_x/2))).tolist()
|
|
372
|
+
spatial_ref.YWorldLimits = (np.array(spatial_ref.YWorldLimits) -
|
|
373
|
+
(spatial_ref.YWorldLimits[0] -
|
|
374
|
+
(min_y_grid-pixel_y/2))).tolist()
|
|
375
|
+
spatial_ref.ZWorldLimits = (np.array(spatial_ref.ZWorldLimits) -
|
|
376
|
+
(spatial_ref.ZWorldLimits[0] -
|
|
377
|
+
(min_z_grid-slice_s/2))).tolist()
|
|
378
|
+
|
|
379
|
+
# Converting the results into lists
|
|
380
|
+
spatial_ref.ImageSize = spatial_ref.ImageSize.tolist()
|
|
381
|
+
spatial_ref.XIntrinsicLimits = spatial_ref.XIntrinsicLimits.tolist()
|
|
382
|
+
spatial_ref.YIntrinsicLimits = spatial_ref.YIntrinsicLimits.tolist()
|
|
383
|
+
spatial_ref.ZIntrinsicLimits = spatial_ref.ZIntrinsicLimits.tolist()
|
|
384
|
+
|
|
385
|
+
# Update the spatial reference in the MEDscan class
|
|
386
|
+
medscan.data.volume.spatialRef = spatial_ref
|
|
387
|
+
|
|
388
|
+
# DICOM HEADERS OF IMAGING DATA
|
|
389
|
+
dicom_h = [
|
|
390
|
+
pydicom.dcmread(str(dicom_file),stop_before_pixels=True,force=True) for dicom_file in self.path_images
|
|
391
|
+
]
|
|
392
|
+
for i in range(0, len(dicom_h)):
|
|
393
|
+
dicom_h[i].remove_private_tags()
|
|
394
|
+
medscan.dicomH = dicom_h
|
|
395
|
+
|
|
396
|
+
# DICOM RTstruct (if applicable)
|
|
397
|
+
if self.path_rs is not None and len(self.path_rs) > 0:
|
|
398
|
+
dicom_rs_full = [
|
|
399
|
+
pydicom.dcmread(str(dicom_file),
|
|
400
|
+
stop_before_pixels=True,
|
|
401
|
+
force=True)
|
|
402
|
+
for dicom_file in self.path_rs
|
|
403
|
+
]
|
|
404
|
+
for i in range(0, len(dicom_rs_full)):
|
|
405
|
+
dicom_rs_full[i].remove_private_tags()
|
|
406
|
+
|
|
407
|
+
# GATHER XYZ POINTS OF ROIs USING RTstruct
|
|
408
|
+
n_rs = len(dicom_rs_full) if type(dicom_rs_full) is list else dicom_rs_full
|
|
409
|
+
contour_num = 0
|
|
410
|
+
for rs in range(n_rs):
|
|
411
|
+
n_roi = len(dicom_rs_full[rs].StructureSetROISequence)
|
|
412
|
+
for roi in range(n_roi):
|
|
413
|
+
if roi!=0:
|
|
414
|
+
if dicom_rs_full[rs].StructureSetROISequence[roi].ROIName == \
|
|
415
|
+
dicom_rs_full[rs].StructureSetROISequence[roi-1].ROIName:
|
|
416
|
+
continue
|
|
417
|
+
points = []
|
|
418
|
+
name_set_strings = ['StructureSetName', 'StructureSetDescription',
|
|
419
|
+
'series_description', 'SeriesInstanceUID']
|
|
420
|
+
for name_field in name_set_strings:
|
|
421
|
+
if name_field in dicom_rs_full[rs]:
|
|
422
|
+
name_set = getattr(dicom_rs_full[rs], name_field)
|
|
423
|
+
name_set_info = name_field
|
|
424
|
+
break
|
|
425
|
+
|
|
426
|
+
medscan.data.ROI.update_roi_name(key=contour_num,
|
|
427
|
+
roi_name=dicom_rs_full[rs].StructureSetROISequence[roi].ROIName)
|
|
428
|
+
medscan.data.ROI.update_indexes(key=contour_num,
|
|
429
|
+
indexes=None)
|
|
430
|
+
medscan.data.ROI.update_name_set(key=contour_num,
|
|
431
|
+
name_set=name_set)
|
|
432
|
+
medscan.data.ROI.update_name_set_info(key=contour_num,
|
|
433
|
+
nameSetInfo=name_set_info)
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
n_closed_contour = len(dicom_rs_full[rs].ROIContourSequence[roi].ContourSequence)
|
|
437
|
+
ind_closed_contour = []
|
|
438
|
+
for s in range(0, n_closed_contour):
|
|
439
|
+
# points stored in the RTstruct file for a given closed
|
|
440
|
+
# contour (beware: there can be multiple closed contours
|
|
441
|
+
# on a given slice).
|
|
442
|
+
pts_temp = dicom_rs_full[rs].ROIContourSequence[roi].ContourSequence[s].ContourData
|
|
443
|
+
n_points = int(len(pts_temp) / 3)
|
|
444
|
+
if len(pts_temp) > 0:
|
|
445
|
+
ind_closed_contour = ind_closed_contour + np.tile(s, n_points).tolist()
|
|
446
|
+
if type(points) == list:
|
|
447
|
+
points = np.reshape(np.transpose(pts_temp),(n_points, 3))
|
|
448
|
+
else:
|
|
449
|
+
points = np.concatenate(
|
|
450
|
+
(points, np.reshape(np.transpose(pts_temp), (n_points, 3))),
|
|
451
|
+
axis=0
|
|
452
|
+
)
|
|
453
|
+
if n_closed_contour == 0:
|
|
454
|
+
print(f'Warning: no contour data found for ROI: \
|
|
455
|
+
{dicom_rs_full[rs].StructureSetROISequence[roi].ROIName}')
|
|
456
|
+
else:
|
|
457
|
+
# Save the XYZ points in the MEDscan class
|
|
458
|
+
medscan.data.ROI.update_indexes(
|
|
459
|
+
key=contour_num,
|
|
460
|
+
indexes=np.concatenate(
|
|
461
|
+
(points,
|
|
462
|
+
np.reshape(ind_closed_contour, (len(ind_closed_contour), 1))),
|
|
463
|
+
axis=1)
|
|
464
|
+
)
|
|
465
|
+
# Compute the ROI box
|
|
466
|
+
_, roi_obj = get_roi(
|
|
467
|
+
medscan,
|
|
468
|
+
name_roi='{' + dicom_rs_full[rs].StructureSetROISequence[roi].ROIName + '}',
|
|
469
|
+
box_string='full'
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
# Save the ROI box non-zero indexes in the MEDscan class
|
|
473
|
+
medscan.data.ROI.update_indexes(key=contour_num, indexes=np.nonzero(roi_obj.data.flatten()))
|
|
474
|
+
|
|
475
|
+
except Exception as e:
|
|
476
|
+
if 'SeriesDescription' in dicom_h[0]:
|
|
477
|
+
print(f'patientID: {dicom_hi[0].PatientID} Modality: {dicom_hi[0].SeriesDescription} error: \
|
|
478
|
+
{str(e)} n_roi: {str(roi)} n_rs: {str(rs)}')
|
|
479
|
+
else:
|
|
480
|
+
print(f'patientID: {dicom_hi[0].PatientID} Modality: {dicom_hi[0].Modality} error: \
|
|
481
|
+
{str(e)} n_roi: {str(roi)} n_rs: {str(rs)}')
|
|
482
|
+
medscan.data.ROI.update_indexes(key=contour_num, indexes=np.NaN)
|
|
483
|
+
contour_num += 1
|
|
484
|
+
|
|
485
|
+
# Save additional scan information in the MEDscan class
|
|
486
|
+
medscan.data.set_patient_position(patient_position=dicom_h[0].PatientPosition)
|
|
487
|
+
medscan.patientID = str(dicom_h[0].PatientID)
|
|
488
|
+
medscan.format = "dicom"
|
|
489
|
+
if 'SeriesDescription' in dicom_h[0]:
|
|
490
|
+
medscan.series_description = dicom_h[0].SeriesDescription
|
|
491
|
+
else:
|
|
492
|
+
medscan.series_description = dicom_h[0].Modality
|
|
493
|
+
|
|
494
|
+
# save MEDscan class instance as a pickle object
|
|
495
|
+
if self.save and self.path_save:
|
|
496
|
+
name_complete = save_MEDscan(medscan, self.path_save)
|
|
497
|
+
del medscan
|
|
498
|
+
else:
|
|
499
|
+
series_description = medscan.series_description.translate({ord(ch): '-' for ch in '/\\ ()&:*'})
|
|
500
|
+
name_id = medscan.patientID.translate({ord(ch): '-' for ch in '/\\ ()&:*'})
|
|
501
|
+
|
|
502
|
+
# final saving name
|
|
503
|
+
name_complete = name_id + '__' + series_description + '.' + medscan.type + '.npy'
|
|
504
|
+
|
|
505
|
+
except Exception as e:
|
|
506
|
+
if 'SeriesDescription' in dicom_hi[0]:
|
|
507
|
+
print(f'patientID: {dicom_hi[0].PatientID} Modality: {dicom_hi[0].SeriesDescription} error: {str(e)}')
|
|
508
|
+
else:
|
|
509
|
+
print(f'patientID: {dicom_hi[0].PatientID} Modality: {dicom_hi[0].Modality} error: {str(e)}')
|
|
510
|
+
return ''
|
|
511
|
+
|
|
512
|
+
return name_complete
|