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
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312 | class Image:
'''`Image` is a class to handle medical images with SimpleITK backend and PyTorch tensor support.
This class provides functionality to work with 2D/3D medical images, handling both regular images
and segmentation masks. It maintains both SimpleITK and PyTorch representations, and handles
coordinate transformations between physical, pixel, and normalized coordinate spaces.
Args:
itk_image (SimpleITK.Image): The SimpleITK image object
device (devicetype, optional): Device to store the PyTorch tensors on. Defaults to 'cuda'.
is_segmentation (bool, optional): Whether the image is a segmentation mask. Defaults to False.
Set this to True if the image is an integer-valued segmentation mask.
This is used to convert integer labels to one-hot encoding.
See `max_seg_label`, `background_seg_label`, `seg_preprocessor` for more details on how to manipulate integer label images.
max_seg_label (int, optional): Maximum label value for segmentation. Values above this are set to background_seg_label.
Set to None by default, meaning no label clipping is done.
background_seg_label (int, optional): Label value representing background in segmentations. Defaults to -1 (background is included as a one-hot label)
is_onehot (bool, optional): If True, use one-hot interpolation (bilinear/trilinear on one-hot channels). If False and FFO is available, use generic-label fused sampler. Defaults to False. When FFO is not available, this is forced to True.
seg_preprocessor (callable, optional): Function to preprocess segmentation arrays. Defaults to identity function.
orientation (str, optional): Reorient the image to this orientation. Defaults to None.
spacing (array-like, optional): Custom spacing for the image. If None, uses SimpleITK values.
direction (array-like, optional): Custom direction matrix. If None, uses SimpleITK values.
origin (array-like, optional): Custom origin point. If None, uses SimpleITK values.
center (array-like, optional): Custom center point to recalibrate origin. If provided, origin is recalculated.
Attributes:
array (torch.Tensor): PyTorch tensor representation of the image
This is of size [1, C, *dims] where C is the number of channels / labels
itk_image (SimpleITK.Image): Original SimpleITK image
channels (int): Number of channels in the image
dims (int): Dimensionality of the image (2 or 3)
torch2phy (torch.Tensor): Transform matrix from normalized to physical coordinates
phy2torch (torch.Tensor): Transform matrix from physical to normalized coordinates
device (devicetype): Device where PyTorch tensors are stored
'''
def __init__(self, itk_image: sitk.SimpleITK.Image,
device: devicetype = 'cuda',
dtype: torch.dtype = None,
is_segmentation=False, max_seg_label=None,
background_seg_label=-1, is_onehot: bool = False, seg_preprocessor=lambda x: x,
orientation: str = None,
winsorize: bool = False,
winsorize_percentile: Tuple[float, float] = (1.0, 99.0),
spacing=None, direction=None, origin=None, center=None) -> None:
if orientation is not None:
itk_image = sitk.DICOMOrient(itk_image, orientation)
self.itk_image = itk_image
if dtype is None:
dtype = torch.float32
# check for segmentation parameters
# if `is_segmentation` is False, then just treat this as an image with given dtype
if not is_segmentation:
self.interpolation_mode = None
self.is_onehot = False
itk_img = sitk.GetArrayFromImage(itk_image).astype(float)
if winsorize:
itk_img = winsorize_image(itk_img, winsorize_percentile[0], winsorize_percentile[1])
self.array = torch.from_numpy(itk_img).to(device, dtype)
channels = itk_image.GetNumberOfComponentsPerPixel()
self.channels = channels
# assert channels == 1, "Only single channel images supported"
if channels > 1:
logger.warning("Image has multiple channels, make sure its not a spatial dimension")
# permute the channel dimension to the front
ndim = self.array.ndim
self.array = self.array.permute([ndim-1] + list(range(ndim-1))).contiguous() # permute to [C, H, W, D] from [H, W, D, C]
else:
self.array.unsqueeze_(0)
# add batch dimension
self.array.unsqueeze_(0)
else:
# When FFO is not available, force one-hot mode so generic-label path is never used
if not fi.use_ffo:
is_onehot = True
self.is_onehot = is_onehot
array = torch.from_numpy(sitk.GetArrayFromImage(itk_image).astype(int)).float().to(device)
# preprocess segmentation if provided by user
array = seg_preprocessor(array)
if max_seg_label is not None:
array[array > max_seg_label] = background_seg_label if is_onehot else array.min()
# convert to one-hot encoding
if is_onehot:
array = integer_to_onehot(array.long(), background_label=background_seg_label, max_label=max_seg_label, dtype=dtype)[None] # [1, C, H, W, D]
else:
array = array[None, None] # [1, 1, H, W, D]
# set these values
self.array = array
self.channels = array.shape[1]
self.interpolation_mode = 'genericlabel' if (not is_onehot) else None
# initialize matrix for pixel to physical
dims = itk_image.GetDimension()
self.dims = dims
if dims not in [2, 3]:
raise NotImplementedError("Image class only supports 2D/3D images.")
# custom spacing if not provided use simpleitk values
spacing = np.array(itk_image.GetSpacing())[None] if spacing is None else np.array(spacing)[None]
origin = np.array(itk_image.GetOrigin())[None] if origin is None else np.array(origin)[None]
direction = np.array(itk_image.GetDirection()).reshape(dims, dims) if direction is None else np.array(direction).reshape(dims, dims)
if center is not None:
print("Center location provided, recalibrating origin.")
origin = center - np.matmul(direction, ((np.array(itk_image.GetSize())*spacing/2).squeeze())[:, None]).T
px2phy = np.eye(dims+1)
px2phy[:dims, -1] = origin
px2phy[:dims, :dims] = direction
px2phy[:dims, :dims] = px2phy[:dims, :dims] * spacing
# generate mapping from torch to px
torch2px = np.eye(dims+1)
scaleterm = (np.array(itk_image.GetSize())-1)*0.5
torch2px[:dims, :dims] = np.diag(scaleterm)
torch2px[:dims, -1] = scaleterm
# save the mapping from physical to torch and vice versa
self.torch2phy = torch.from_numpy(np.matmul(px2phy, torch2px)).to(device).float().unsqueeze_(0)
self.phy2torch = torch.inverse(self.torch2phy[0]).float().unsqueeze_(0)
# also save intermediates just in case (as numpy arrays)
self._torch2px = torch2px
self._px2phy = px2phy
# keep these as well
self.torch2px = torch.from_numpy(self._torch2px).to(device).float().unsqueeze_(0)
self.px2torch = torch.inverse(self.torch2px[0]).float().unsqueeze_(0)
self.px2phy = torch.from_numpy(self._px2phy).to(device).float().unsqueeze_(0)
self.phy2px = torch.inverse(self.px2phy[0]).float().unsqueeze_(0)
self._px2phy = px2phy
self.device = device
@classmethod
def load_file(cls, image_path:str, *args: Any, **kwargs: Any) -> 'Image':
'''Load an image from a file.
Args:
image_path (str): Path to the image file
*args: Additional arguments to pass to the Image constructor
**kwargs: Additional arguments to pass to the Image constructor
Returns:
Image: An instance of the Image class
'''
itk_image = sitk.ReadImage(image_path)
return cls(itk_image, *args, **kwargs)
@property
def shape(self) -> Union[torch.Size, List, Tuple]:
'''Get the shape of the image.
Returns:
torch.Size: Shape of the image
'''
return self.array.shape
@property
def is_array_present(self) -> bool:
'''Check if the PyTorch tensor representation of the image is present.
This is needed because the BatchedImages may delete the array of the image.
Returns:
bool: True if the PyTorch tensor representation of the image is present, False otherwise
'''
return hasattr(self, 'array')
def delete_array(self):
'''Delete the PyTorch tensor representation of the image.
This is a placeholder function to be replaced with batched images.
'''
if self.is_array_present:
del self.array
def concat(self, *others, optimize_memory: bool = True):
''' alias for concatenate '''
return self.concatenate(*others, optimize_memory=optimize_memory)
def concatenate(self, *others, optimize_memory: bool = True):
'''
others is a list of Images or list of lists (in which case the user accidentally passed a list of images)
optimize_memory: if True, delete the arrays of the other images after concatenation
Example:
t1 = Image.load_file(t1_path)
t2 = Image.load_file(t2_path)
flair = Image.load_file(flair_path)
t1.concatenate(t2, flair, optimize_memory=True) # deletes the arrays of t2 and flair after concatenation
'''
if len(others) == 0:
return self
check_and_raise_cond(self.is_array_present, "Image must have a PyTorch tensor representation to concatenate", ValueError)
if isinstance(others[0], list) and len(others) == 1:
others = others[0]
else:
check_and_raise_cond(all([isinstance(other, Image) for other in others]), "All elements of others must be of type Image", TypeError)
# check if all images have the same shape
shapes = [x.array.shape[2:] for x in others]
base_shape = self.array.shape[2:]
check_and_raise_cond(all([x == base_shape for x in shapes]), "All images must have the same shape", ValueError)
check_and_raise_cond(all([x.is_array_present for x in others]), "All images must have a PyTorch tensor representation", ValueError)
check_and_raise_cond(all([self.array.device == other.array.device for other in others]), "Images must be on the same device", ValueError)
check_and_raise_cond(all([torch.allclose(self.phy2torch, other.phy2torch) for other in others]), "Images reside in different physical spaces, using the first image's physical space", logger.warning)
self.array = torch.cat([self.array] + [other.array for other in others], dim=1)
self.channels = self.array.shape[1]
if optimize_memory:
logger.debug("Deleting the arrays of the other images after concatenation")
for other in others:
other.delete_array()
return self
def to(self, device_or_dtype: Union[devicetype, torch.dtype]):
'''
Move the image to a different device or change its dtype.
Args:
device_or_dtype (Union[devicetype, torch.dtype]): Device to move the image to,
or dtype to convert the image to
Returns:
Image: The image on the new device or with the new dtype
'''
self.array = self.array.to(device_or_dtype)
if isinstance(device_or_dtype, (str, torch.device)):
self.device = device_or_dtype
return self
def __del__(self):
'''Delete the SimpleITK image and all intermediate variables.'''
del self.itk_image
if self.is_array_present:
del self.array
del self.torch2phy
del self.phy2torch
del self._torch2px
del self._px2phy
del self.torch2px
del self.px2torch
del self.px2phy
del self.phy2px
|