-
Notifications
You must be signed in to change notification settings - Fork 13
/
combine_slices.py
240 lines (179 loc) · 10.3 KB
/
combine_slices.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
import logging
import numpy as np
from .utils import isclose
from .exceptions import DicomImportException
logger = logging.getLogger(__name__)
def combine_slices(slice_datasets, rescale=None):
"""
Given a list of pydicom datasets for an image series, stitch them together into a
three-dimensional numpy array. Also calculate a 4x4 affine transformation
matrix that converts the ijk-pixel-indices into the xyz-coordinates in the
DICOM patient's coordinate system.
Returns a two-tuple containing the 3D-ndarray and the affine matrix.
If `rescale` is set to `None` (the default), then the image array dtype
will be preserved, unless any of the DICOM images contain either the
`Rescale Slope
<https://dicom.innolitics.com/ciods/ct-image/ct-image/00281053>`_ or the
`Rescale Intercept <https://dicom.innolitics.com/ciods/ct-image/ct-image/00281052>`_
attributes. If either of these attributes are present, they will be
applied to each slice individually.
If `rescale` is `True` the voxels will be cast to `float32`, if set to
`False`, the original dtype will be preserved even if DICOM rescaling information is present.
The returned array has the column-major byte-order.
This function requires that the datasets:
- Be in same series (have the same
`Series Instance UID <https://dicom.innolitics.com/ciods/ct-image/general-series/0020000e>`_,
`Modality <https://dicom.innolitics.com/ciods/ct-image/general-series/00080060>`_,
and `SOP Class UID <https://dicom.innolitics.com/ciods/ct-image/sop-common/00080016>`_).
- The binary storage of each slice must be the same (have the same
`Bits Allocated <https://dicom.innolitics.com/ciods/ct-image/image-pixel/00280100>`_,
`Bits Stored <https://dicom.innolitics.com/ciods/ct-image/image-pixel/00280101>`_,
`High Bit <https://dicom.innolitics.com/ciods/ct-image/image-pixel/00280102>`_, and
`Pixel Representation <https://dicom.innolitics.com/ciods/ct-image/image-pixel/00280103>`_).
- The image slice must approximately form a grid. This means there can not
be any missing internal slices (missing slices on the ends of the dataset
are not detected).
- It also means that each slice must have the same
`Rows <https://dicom.innolitics.com/ciods/ct-image/image-pixel/00280010>`_,
`Columns <https://dicom.innolitics.com/ciods/ct-image/image-pixel/00280011>`_,
`Pixel Spacing <https://dicom.innolitics.com/ciods/ct-image/image-plane/00280030>`_, and
`Image Orientation (Patient) <https://dicom.innolitics.com/ciods/ct-image/image-plane/00200037>`_
attribute values.
- The direction cosines derived from the
`Image Orientation (Patient) <https://dicom.innolitics.com/ciods/ct-image/image-plane/00200037>`_
attribute must, within 1e-4, have a magnitude of 1. The cosines must
also be approximately perpendicular (their dot-product must be within
1e-4 of 0). Warnings are displayed if any of these approximations are
below 1e-8, however, since we have seen real datasets with values up to
1e-4, we let them pass.
- The `Image Position (Patient) <https://dicom.innolitics.com/ciods/ct-image/image-plane/00200032>`_
values must approximately form a line.
If any of these conditions are not met, a `dicom_numpy.DicomImportException` is raised.
"""
if len(slice_datasets) == 0:
raise DicomImportException("Must provide at least one DICOM dataset")
_validate_slices_form_uniform_grid(slice_datasets)
voxels = _merge_slice_pixel_arrays(slice_datasets, rescale)
transform = _ijk_to_patient_xyz_transform_matrix(slice_datasets)
return voxels, transform
def _merge_slice_pixel_arrays(slice_datasets, rescale=None):
first_dataset = slice_datasets[0]
num_rows = first_dataset.Rows
num_columns = first_dataset.Columns
num_slices = len(slice_datasets)
sorted_slice_datasets = _sort_by_slice_position(slice_datasets)
if rescale is None:
rescale = any(_requires_rescaling(d) for d in sorted_slice_datasets)
if rescale:
voxels = np.empty((num_columns, num_rows, num_slices), dtype=np.float32, order='F')
for k, dataset in enumerate(sorted_slice_datasets):
slope = float(getattr(dataset, 'RescaleSlope', 1))
intercept = float(getattr(dataset, 'RescaleIntercept', 0))
voxels[:, :, k] = dataset.pixel_array.T.astype(np.float32) * slope + intercept
else:
dtype = first_dataset.pixel_array.dtype
voxels = np.empty((num_columns, num_rows, num_slices), dtype=dtype, order='F')
for k, dataset in enumerate(sorted_slice_datasets):
voxels[:, :, k] = dataset.pixel_array.T
return voxels
def _requires_rescaling(dataset):
return hasattr(dataset, 'RescaleSlope') or hasattr(dataset, 'RescaleIntercept')
def _ijk_to_patient_xyz_transform_matrix(slice_datasets):
first_dataset = _sort_by_slice_position(slice_datasets)[0]
image_orientation = first_dataset.ImageOrientationPatient
row_cosine, column_cosine, slice_cosine = _extract_cosines(image_orientation)
row_spacing, column_spacing = first_dataset.PixelSpacing
slice_spacing = _slice_spacing(slice_datasets)
transform = np.identity(4, dtype=np.float32)
transform[:3, 0] = row_cosine * column_spacing
transform[:3, 1] = column_cosine * row_spacing
transform[:3, 2] = slice_cosine * slice_spacing
transform[:3, 3] = first_dataset.ImagePositionPatient
return transform
def _validate_slices_form_uniform_grid(slice_datasets):
"""
Perform various data checks to ensure that the list of slices form a
evenly-spaced grid of data.
Some of these checks are probably not required if the data follows the
DICOM specification, however it seems pertinent to check anyway.
"""
invariant_properties = [
'Modality',
'SOPClassUID',
'SeriesInstanceUID',
'Rows',
'Columns',
'PixelSpacing',
'PixelRepresentation',
'BitsAllocated',
]
for property_name in invariant_properties:
_slice_attribute_equal(slice_datasets, property_name)
_validate_image_orientation(slice_datasets[0].ImageOrientationPatient)
_slice_ndarray_attribute_almost_equal(slice_datasets, 'ImageOrientationPatient', 1e-5)
slice_positions = _slice_positions(slice_datasets)
_check_for_missing_slices(slice_positions)
def _validate_image_orientation(image_orientation):
"""
Ensure that the image orientation is supported
- The direction cosines have magnitudes of 1 (just in case)
- The direction cosines are perpendicular
"""
row_cosine, column_cosine, slice_cosine = _extract_cosines(image_orientation)
if not _almost_zero(np.dot(row_cosine, column_cosine), 1e-4):
raise DicomImportException("Non-orthogonal direction cosines: {}, {}".format(row_cosine, column_cosine))
elif not _almost_zero(np.dot(row_cosine, column_cosine), 1e-8):
logger.warning("Direction cosines aren't quite orthogonal: {}, {}".format(row_cosine, column_cosine))
if not _almost_one(np.linalg.norm(row_cosine), 1e-4):
raise DicomImportException("The row direction cosine's magnitude is not 1: {}".format(row_cosine))
elif not _almost_one(np.linalg.norm(row_cosine), 1e-8):
logger.warning("The row direction cosine's magnitude is not quite 1: {}".format(row_cosine))
if not _almost_one(np.linalg.norm(column_cosine), 1e-4):
raise DicomImportException("The column direction cosine's magnitude is not 1: {}".format(column_cosine))
elif not _almost_one(np.linalg.norm(column_cosine), 1e-8):
logger.warning("The column direction cosine's magnitude is not quite 1: {}".format(column_cosine))
def _almost_zero(value, abs_tol):
return isclose(value, 0.0, abs_tol=abs_tol)
def _almost_one(value, abs_tol):
return isclose(value, 1.0, abs_tol=abs_tol)
def _extract_cosines(image_orientation):
row_cosine = np.array(image_orientation[:3])
column_cosine = np.array(image_orientation[3:])
slice_cosine = np.cross(row_cosine, column_cosine)
return row_cosine, column_cosine, slice_cosine
def _slice_attribute_equal(slice_datasets, property_name):
initial_value = getattr(slice_datasets[0], property_name, None)
for dataset in slice_datasets[1:]:
value = getattr(dataset, property_name, None)
if value != initial_value:
msg = 'All slices must have the same value for "{}": {} != {}'
raise DicomImportException(msg.format(property_name, value, initial_value))
def _slice_ndarray_attribute_almost_equal(slice_datasets, property_name, abs_tol):
initial_value = getattr(slice_datasets[0], property_name, None)
for dataset in slice_datasets[1:]:
value = getattr(dataset, property_name, None)
if not np.allclose(value, initial_value, atol=abs_tol):
msg = 'All slices must have the same value for "{}" within "{}": {} != {}'
raise DicomImportException(msg.format(property_name, abs_tol, value, initial_value))
def _slice_positions(slice_datasets):
image_orientation = slice_datasets[0].ImageOrientationPatient
row_cosine, column_cosine, slice_cosine = _extract_cosines(image_orientation)
return [np.dot(slice_cosine, d.ImagePositionPatient) for d in slice_datasets]
def _check_for_missing_slices(slice_positions):
if len(slice_positions) > 1:
slice_positions_diffs = np.diff(sorted(slice_positions))
if not np.allclose(slice_positions_diffs, slice_positions_diffs[0], atol=0, rtol=1e-5):
# TODO: figure out how we should handle non-even slice spacing
msg = "The slice spacing is non-uniform. Slice spacings:\n{}"
logger.warning(msg.format(slice_positions_diffs))
if not np.allclose(slice_positions_diffs, slice_positions_diffs[0], atol=0, rtol=1e-1):
raise DicomImportException('It appears there are missing slices')
def _slice_spacing(slice_datasets):
if len(slice_datasets) > 1:
slice_positions = _slice_positions(slice_datasets)
slice_positions_diffs = np.diff(sorted(slice_positions))
return np.mean(slice_positions_diffs)
return getattr(slice_datasets[0], 'SpacingBetweenSlices', 0)
def _sort_by_slice_position(slice_datasets):
slice_positions = _slice_positions(slice_datasets)
return [d for (s, d) in sorted(zip(slice_positions, slice_datasets))]