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.
Files changed (78) hide show
  1. MEDiml/MEDscan.py +1696 -0
  2. MEDiml/__init__.py +21 -0
  3. MEDiml/biomarkers/BatchExtractor.py +806 -0
  4. MEDiml/biomarkers/BatchExtractorTexturalFilters.py +840 -0
  5. MEDiml/biomarkers/__init__.py +16 -0
  6. MEDiml/biomarkers/diagnostics.py +125 -0
  7. MEDiml/biomarkers/get_oriented_bound_box.py +158 -0
  8. MEDiml/biomarkers/glcm.py +1602 -0
  9. MEDiml/biomarkers/gldzm.py +523 -0
  10. MEDiml/biomarkers/glrlm.py +1315 -0
  11. MEDiml/biomarkers/glszm.py +555 -0
  12. MEDiml/biomarkers/int_vol_hist.py +527 -0
  13. MEDiml/biomarkers/intensity_histogram.py +615 -0
  14. MEDiml/biomarkers/local_intensity.py +89 -0
  15. MEDiml/biomarkers/morph.py +1756 -0
  16. MEDiml/biomarkers/ngldm.py +780 -0
  17. MEDiml/biomarkers/ngtdm.py +414 -0
  18. MEDiml/biomarkers/stats.py +373 -0
  19. MEDiml/biomarkers/utils.py +389 -0
  20. MEDiml/filters/TexturalFilter.py +299 -0
  21. MEDiml/filters/__init__.py +9 -0
  22. MEDiml/filters/apply_filter.py +134 -0
  23. MEDiml/filters/gabor.py +215 -0
  24. MEDiml/filters/laws.py +283 -0
  25. MEDiml/filters/log.py +147 -0
  26. MEDiml/filters/mean.py +121 -0
  27. MEDiml/filters/textural_filters_kernels.py +1738 -0
  28. MEDiml/filters/utils.py +107 -0
  29. MEDiml/filters/wavelet.py +237 -0
  30. MEDiml/learning/DataCleaner.py +198 -0
  31. MEDiml/learning/DesignExperiment.py +480 -0
  32. MEDiml/learning/FSR.py +667 -0
  33. MEDiml/learning/Normalization.py +112 -0
  34. MEDiml/learning/RadiomicsLearner.py +714 -0
  35. MEDiml/learning/Results.py +2237 -0
  36. MEDiml/learning/Stats.py +694 -0
  37. MEDiml/learning/__init__.py +10 -0
  38. MEDiml/learning/cleaning_utils.py +107 -0
  39. MEDiml/learning/ml_utils.py +1015 -0
  40. MEDiml/processing/__init__.py +6 -0
  41. MEDiml/processing/compute_suv_map.py +121 -0
  42. MEDiml/processing/discretisation.py +149 -0
  43. MEDiml/processing/interpolation.py +275 -0
  44. MEDiml/processing/resegmentation.py +66 -0
  45. MEDiml/processing/segmentation.py +912 -0
  46. MEDiml/utils/__init__.py +25 -0
  47. MEDiml/utils/batch_patients.py +45 -0
  48. MEDiml/utils/create_radiomics_table.py +131 -0
  49. MEDiml/utils/data_frame_export.py +42 -0
  50. MEDiml/utils/find_process_names.py +16 -0
  51. MEDiml/utils/get_file_paths.py +34 -0
  52. MEDiml/utils/get_full_rad_names.py +21 -0
  53. MEDiml/utils/get_institutions_from_ids.py +16 -0
  54. MEDiml/utils/get_patient_id_from_scan_name.py +22 -0
  55. MEDiml/utils/get_patient_names.py +26 -0
  56. MEDiml/utils/get_radiomic_names.py +27 -0
  57. MEDiml/utils/get_scan_name_from_rad_name.py +22 -0
  58. MEDiml/utils/image_reader_SITK.py +37 -0
  59. MEDiml/utils/image_volume_obj.py +22 -0
  60. MEDiml/utils/imref.py +340 -0
  61. MEDiml/utils/initialize_features_names.py +62 -0
  62. MEDiml/utils/inpolygon.py +159 -0
  63. MEDiml/utils/interp3.py +43 -0
  64. MEDiml/utils/json_utils.py +78 -0
  65. MEDiml/utils/mode.py +31 -0
  66. MEDiml/utils/parse_contour_string.py +58 -0
  67. MEDiml/utils/save_MEDscan.py +30 -0
  68. MEDiml/utils/strfind.py +32 -0
  69. MEDiml/utils/textureTools.py +188 -0
  70. MEDiml/utils/texture_features_names.py +115 -0
  71. MEDiml/utils/write_radiomics_csv.py +47 -0
  72. MEDiml/wrangling/DataManager.py +1724 -0
  73. MEDiml/wrangling/ProcessDICOM.py +512 -0
  74. MEDiml/wrangling/__init__.py +3 -0
  75. mediml-0.9.9.dist-info/LICENSE.md +674 -0
  76. mediml-0.9.9.dist-info/METADATA +232 -0
  77. mediml-0.9.9.dist-info/RECORD +78 -0
  78. mediml-0.9.9.dist-info/WHEEL +4 -0
@@ -0,0 +1,912 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+
5
+ import logging
6
+ from copy import deepcopy
7
+ from typing import List, Sequence, Tuple, Union
8
+
9
+ import numpy as np
10
+ from nibabel import Nifti1Image
11
+ from scipy.ndimage import center_of_mass
12
+
13
+ from ..MEDscan import MEDscan
14
+ from ..utils.image_volume_obj import image_volume_obj
15
+ from ..utils.imref import imref3d, intrinsicToWorld, worldToIntrinsic
16
+ from ..utils.inpolygon import inpolygon
17
+ from ..utils.interp3 import interp3
18
+ from ..utils.mode import mode
19
+ from ..utils.parse_contour_string import parse_contour_string
20
+ from ..utils.strfind import strfind
21
+
22
+ _logger = logging.getLogger(__name__)
23
+
24
+
25
+ def get_roi_from_indexes(
26
+ medscan: MEDscan,
27
+ name_roi: str,
28
+ box_string: str
29
+ ) -> Tuple[image_volume_obj, image_volume_obj]:
30
+ """Extracts the ROI box (+ smallest box containing the region of interest)
31
+ and associated mask from the indexes saved in ``medscan`` scan.
32
+
33
+ Args:
34
+ medscan (MEDscan): The MEDscan class object.
35
+ name_roi (str): name of the ROI since the a volume can have multiple
36
+ ROIs.
37
+ box_string (str): Specifies the size if the box containing the ROI
38
+
39
+ - 'full': Full imaging data as output.
40
+ - 'box': computes the smallest bounding box.
41
+ - Ex: 'box10': 10 voxels in all three dimensions are added to \
42
+ the smallest bounding box. The number after 'box' defines the \
43
+ number of voxels to add.
44
+ - Ex: '2box': Computes the smallest box and outputs double its \
45
+ size. The number before 'box' defines the multiplication in \
46
+ size.
47
+
48
+ Returns:
49
+ 2-element tuple containing
50
+
51
+ - ndarray: vol_obj, 3D array of imaging data defining the smallest box \
52
+ containing the region of interest.
53
+ - ndarray: roi_obj, 3D array of 1's and 0's defining the ROI in ROIbox.
54
+ """
55
+ # This takes care of the "Volume resection" step
56
+ # as well using the argument "box". No fourth
57
+ # argument means 'interp' by default.
58
+
59
+ # PARSING OF ARGUMENTS
60
+ try:
61
+ name_structure_set = []
62
+ delimiters = ["\+", "\-"]
63
+ n_contour_data = len(medscan.data.ROI.indexes)
64
+
65
+ name_roi, vect_plus_minus = get_sep_roi_names(name_roi, delimiters)
66
+ contour_number = np.zeros(len(name_roi))
67
+
68
+ if name_structure_set is None:
69
+ name_structure_set = []
70
+
71
+ if name_structure_set:
72
+ name_structure_set, _ = get_sep_roi_names(name_structure_set, delimiters)
73
+ if len(name_roi) != len(name_structure_set):
74
+ raise ValueError(
75
+ "The numbers of defined ROI names and Structure Set names are not the same")
76
+
77
+ for i in range(0, len(name_roi)):
78
+ for j in range(0, n_contour_data):
79
+ name_temp = medscan.data.ROI.get_roi_name(key=j)
80
+ if name_temp == name_roi[i]:
81
+ if name_structure_set:
82
+ # FOR DICOM + RTSTRUCT
83
+ name_set_temp = medscan.data.ROI.get_name_set(key=j)
84
+ if name_set_temp == name_structure_set[i]:
85
+ contour_number[i] = j
86
+ break
87
+ else:
88
+ contour_number[i] = j
89
+ break
90
+
91
+ n_roi = np.size(contour_number)
92
+ # contour_string IS FOR EXAMPLE '3' or '1-3+2'
93
+ contour_string = str(contour_number[0].astype(int))
94
+
95
+ for i in range(1, n_roi):
96
+ if vect_plus_minus[i-1] == 1:
97
+ sign = '+'
98
+ elif vect_plus_minus[i-1] == -1:
99
+ sign = '-'
100
+ contour_string = contour_string + sign + \
101
+ str(contour_number[i].astype(int))
102
+
103
+ if not (box_string == "full" or "box" in box_string):
104
+ raise ValueError(
105
+ "The third argument must either be \"full\" or contain the word \"box\".")
106
+
107
+ contour_number, operations = parse_contour_string(contour_string)
108
+
109
+ # INTIALIZATIONS
110
+ if type(contour_number) is int:
111
+ n_contour = 1
112
+ contour_number = [contour_number]
113
+ else:
114
+ n_contour = len(contour_number)
115
+
116
+ # Note: sData is a nested dictionary not an object
117
+ spatial_ref = medscan.data.volume.spatialRef
118
+ vol = medscan.data.volume.array.astype(np.float32)
119
+
120
+ # APPLYING OPERATIONS ON ALL MASKS
121
+ roi = medscan.data.get_indexes_by_roi_name(name_roi[0])
122
+ for c in np.arange(start=1, stop=n_contour):
123
+ if operations[c-1] == "+":
124
+ roi += medscan.data.get_indexes_by_roi_name(name_roi[c])
125
+ elif operations[c-1] == "-":
126
+ roi -= medscan.data.get_indexes_by_roi_name(name_roi[c])
127
+ else:
128
+ raise ValueError("Unknown operation on ROI.")
129
+
130
+ roi[roi >= 1.0] = 1.0
131
+ roi[roi < 1.0] = 0.0
132
+
133
+ # COMPUTING THE BOUNDING BOX
134
+ vol, roi, new_spatial_ref = compute_box(vol=vol, roi=roi,
135
+ spatial_ref=spatial_ref,
136
+ box_string=box_string)
137
+
138
+ # ARRANGE OUTPUT
139
+ vol_obj = image_volume_obj(data=vol, spatial_ref=new_spatial_ref)
140
+ roi_obj = image_volume_obj(data=roi, spatial_ref=new_spatial_ref)
141
+
142
+ except Exception as e:
143
+ message = f"\n PROBLEM WITH PRE-PROCESSING OF FEATURES IN get_roi_from_indexes():\n {e}"
144
+ logging.error(message)
145
+ print(message)
146
+
147
+ if medscan:
148
+ medscan.radiomics.image.update(
149
+ {('scale'+(str(medscan.params.process.scale_non_text[0])).replace('.', 'dot')): 'ERROR_PROCESSING'})
150
+
151
+ return vol_obj, roi_obj
152
+
153
+ def get_sep_roi_names(name_roi_in: str,
154
+ delimiters: List) -> Tuple[List[int],
155
+ np.ndarray]:
156
+ """Seperated ROI names present in the given ROI name. An ROI name can
157
+ have multiple ROI names seperated with curly brackets and delimeters.
158
+ Note:
159
+ Works only for delimiters "+" and "-".
160
+ Args:
161
+ name_roi_in (str): Name of ROIs that will be extracted from the imagign volume. \
162
+ Separated with curly brackets and delimeters. Ex: '{ED}+{ET}'.
163
+ delimiters (List): List of delimeters of "+" and "-".
164
+ Returns:
165
+ 2-element tuple containing
166
+
167
+ - List[int]: List of ROI names seperated and excluding curly brackets.
168
+ - ndarray: array of 1's and -1's that defines the regions that will \
169
+ included and/or excluded in/from the imaging data.
170
+ Examples:
171
+ >>> get_sep_roi_names('{ED}+{ET}', ['+', '-'])
172
+ ['ED', 'ET'], [1]
173
+ >>> get_sep_roi_names('{ED}-{ET}', ['+', '-'])
174
+ ['ED', 'ET'], [-1]
175
+ """
176
+ # EX:
177
+ #name_roi_in = '{GTV-1}'
178
+ #delimiters = ['\\+','\\-']
179
+
180
+ # FINDING "+" and "-"
181
+ ind_plus = strfind(string=name_roi_in, pattern=delimiters[0])
182
+ vect_plus = np.ones(len(ind_plus))
183
+ ind_minus = strfind(string=name_roi_in, pattern=delimiters[1])
184
+ vect_minus = np.ones(len(ind_minus)) * -1
185
+ ind = np.argsort(np.hstack((ind_plus, ind_minus)))
186
+ vect_plus_minus = np.hstack((vect_plus, vect_minus))[ind]
187
+ ind = np.hstack((ind_plus, ind_minus))[ind].astype(int)
188
+ n_delim = np.size(vect_plus_minus)
189
+
190
+ # MAKING SURE "+" and "-" ARE NOT INSIDE A ROIname
191
+ ind_start = strfind(string=name_roi_in, pattern="{")
192
+ n_roi = len(ind_start)
193
+ ind_stop = strfind(string=name_roi_in, pattern="}")
194
+ ind_keep = np.ones(n_delim, dtype=bool)
195
+ for d in np.arange(n_delim):
196
+ for r in np.arange(n_roi):
197
+ # Thus not indise a ROI name
198
+ if (ind_stop[r] - ind[d]) > 0 and (ind[d] - ind_start[r]) > 0:
199
+ ind_keep[d] = False
200
+ break
201
+
202
+ ind = ind[ind_keep]
203
+ vect_plus_minus = vect_plus_minus[ind_keep]
204
+
205
+ # PARSING ROI NAMES
206
+ if ind.size == 0:
207
+ # Excluding the "{" and "}" at the start and end of the ROIname
208
+ name_roi_out = [name_roi_in[1:-1]]
209
+ else:
210
+ n_ind = len(ind)
211
+ # Excluding the "{" and "}" at the start and end of the ROIname
212
+ name_roi_out = [name_roi_in[1:(ind[0]-1)]]
213
+ for i in np.arange(start=1, stop=n_ind):
214
+ # Excluding the "{" and "}" at the start and end of the ROIname
215
+ name_roi_out += [name_roi_in[(ind[i-1]+2):(ind[i]-1)]]
216
+ name_roi_out += [name_roi_in[(ind[-1]+2):-1]]
217
+
218
+ return name_roi_out, vect_plus_minus
219
+
220
+ def roi_extract(vol: np.ndarray,
221
+ roi: np.ndarray) -> np.ndarray:
222
+ """Replaces volume intensities outside the ROI with NaN.
223
+
224
+ Args:
225
+ vol (ndarray): Imaging data.
226
+ roi (ndarray): ROI mask with values of 0's and 1's.
227
+
228
+ Returns:
229
+ ndarray: Imaging data with original intensities in the ROI \
230
+ and NaN for intensities outside the ROI.
231
+ """
232
+
233
+ vol_re = deepcopy(vol)
234
+ vol_re[roi == 0] = np.nan
235
+
236
+ return vol_re
237
+
238
+ def get_polygon_mask(roi_xyz: np.ndarray,
239
+ spatial_ref: imref3d) -> np.ndarray:
240
+ """Computes the indexes of the ROI (Region of interest) enclosing box
241
+ in all dimensions.
242
+
243
+ Args:
244
+ roi_xyz (ndarray): array of (x,y,z) triplets defining a contour in the
245
+ Patient-Based Coordinate System extracted from DICOM RTstruct.
246
+ spatial_ref (imref3d): imref3d object (same functionality of MATLAB imref3d class).
247
+
248
+ Returns:
249
+ ndarray: 3D array of 1's and 0's defining the ROI mask.
250
+ """
251
+
252
+ # COMPUTING MASK
253
+ s_z = spatial_ref.ImageSize.copy()
254
+ roi_mask = np.zeros(s_z)
255
+ # X,Y,Z in intrinsic image coordinates
256
+ X, Y, Z = worldToIntrinsic(R=spatial_ref,
257
+ xWorld=roi_xyz[:, 0],
258
+ yWorld=roi_xyz[:, 1],
259
+ zWorld=roi_xyz[:, 2])
260
+
261
+ points = np.transpose(np.vstack((X, Y, Z)))
262
+
263
+ K = np.round(points[:, 2]) # Must assign the points to one slice
264
+ closed_contours = np.unique(roi_xyz[:, 3])
265
+ x_q = np.arange(s_z[0])
266
+ y_q = np.arange(s_z[1])
267
+ x_q, y_q = np.meshgrid(x_q, y_q)
268
+
269
+ for c_c in np.arange(len(closed_contours)):
270
+ ind = roi_xyz[:, 3] == closed_contours[c_c]
271
+ # Taking the mode, just in case. But normally, numel(unique(K(ind)))
272
+ # should evaluate to 1, as closed contours are meant to be defined on
273
+ # a given slice
274
+ select_slice = mode(K[ind]).astype(int)
275
+ inpoly = inpolygon(x_q=x_q, y_q=y_q, x_v=points[ind, 0], y_v=points[ind, 1])
276
+ roi_mask[:, :, select_slice] = np.logical_or(
277
+ roi_mask[:, :, select_slice], inpoly)
278
+
279
+ return roi_mask
280
+
281
+ def voxel_to_spatial(affine: np.ndarray,
282
+ voxel_pos: list) -> np.array:
283
+ """Convert voxel position into spatial position.
284
+
285
+ Args:
286
+ affine (ndarray): Affine matrix.
287
+ voxel_pos (list): A list that correspond to the location in voxel.
288
+
289
+ Returns:
290
+ ndarray: A numpy array that correspond to the spatial position in mm.
291
+ """
292
+ m = affine[:3, :3]
293
+ translation = affine[:3, 3]
294
+ return m.dot(voxel_pos) + translation
295
+
296
+ def spatial_to_voxel(affine: np.ndarray,
297
+ spatial_pos: list) -> np.array:
298
+ """Convert spatial position into voxel position
299
+
300
+ Args:
301
+ affine (ndarray): Affine matrix.
302
+ spatial_pos (list): A list that correspond to the spatial location in mm.
303
+
304
+ Returns:
305
+ ndarray: A numpy array that correspond to the position in the voxel.
306
+ """
307
+ affine = np.linalg.inv(affine)
308
+ m = affine[:3, :3]
309
+ translation = affine[:3, 3]
310
+ return m.dot(spatial_pos) + translation
311
+
312
+ def crop_nifti_box(image: Nifti1Image,
313
+ roi: Nifti1Image,
314
+ crop_shape: List[int],
315
+ center: Union[Sequence[int], None] = None) -> Tuple[Nifti1Image,
316
+ Nifti1Image]:
317
+ """Crops the Nifti image and ROI.
318
+
319
+ Args:
320
+ image (Nifti1Image): Class for the file NIfTI1 format image that will be cropped.
321
+ roi (Nifti1Image): Class for the file NIfTI1 format ROI that will be cropped.
322
+ crop_shape (List[int]): The dimension of the region to crop in term of number of voxel.
323
+ center (Union[Sequence[int], None]): A list that indicate the center of the cropping box
324
+ in term of spatial position.
325
+
326
+ Returns:
327
+ Tuple[Nifti1Image, Nifti1Image] : Two Nifti images of the cropped image and roi
328
+ """
329
+ assert np.sum(np.array(crop_shape) % 2) == 0, "All elements of crop_shape should be even number."
330
+
331
+ image_data = image.get_fdata()
332
+ roi_data = roi.get_fdata()
333
+
334
+ radius = [int(x / 2) - 1 for x in crop_shape]
335
+ if center is None:
336
+ center = list(np.array(list(center_of_mass(roi_data))).astype(int))
337
+
338
+ center_min = np.floor(center).astype(int)
339
+ center_max = np.ceil(center).astype(int)
340
+
341
+ # If center_max and center_min are equal we add 1 to center_max to avoid trouble with crop.
342
+ for i in range(3):
343
+ center_max[i] += 1 if center_max[i] == center_min[i] else 0
344
+
345
+ img_shape = image.header['dim'][1:4]
346
+
347
+ # Pad the image and the ROI if its necessary
348
+ padding = []
349
+ for rad, cent_min, cent_max, shape in zip(radius, center_min, center_max, img_shape):
350
+ padding.append(
351
+ [abs(min(cent_min - rad, 0)), max(cent_max + rad + 1 - shape, 0)]
352
+ )
353
+
354
+ image_data = np.pad(image_data, tuple([tuple(x) for x in padding]))
355
+ roi_data = np.pad(roi_data, tuple([tuple(x) for x in padding]))
356
+
357
+ center_min = [center_min[i] + padding[i][0] for i in range(3)]
358
+ center_max = [center_max[i] + padding[i][0] for i in range(3)]
359
+
360
+ # Crop the image
361
+ image_data = image_data[center_min[0] - radius[0]:center_max[0] + radius[0] + 1,
362
+ center_min[1] - radius[1]:center_max[1] + radius[1] + 1,
363
+ center_min[2] - radius[2]:center_max[2] + radius[2] + 1]
364
+ roi_data = roi_data[center_min[0] - radius[0]:center_max[0] + radius[0] + 1,
365
+ center_min[1] - radius[1]:center_max[1] + radius[1] + 1,
366
+ center_min[2] - radius[2]:center_max[2] + radius[2] + 1]
367
+
368
+ # Update the image and the ROI
369
+ image = Nifti1Image(image_data, affine=image.affine, header=image.header)
370
+ roi = Nifti1Image(roi_data, affine=roi.affine, header=roi.header)
371
+
372
+ return image, roi
373
+
374
+ def crop_box(image_data: np.ndarray,
375
+ roi_data: np.ndarray,
376
+ crop_shape: List[int],
377
+ center: Union[Sequence[int], None] = None) -> Tuple[np.ndarray, np.ndarray]:
378
+ """Crops the imaging data and the ROI mask.
379
+
380
+ Args:
381
+ image_data (ndarray): Imaging data that will be cropped.
382
+ roi_data (ndarray): Mask data that will be cropped.
383
+ crop_shape (List[int]): The dimension of the region to crop in term of number of voxel.
384
+ center (Union[Sequence[int], None]): A list that indicate the center of the cropping box
385
+ in term of spatial position.
386
+
387
+ Returns:
388
+ Tuple[ndarray, ndarray] : Two numpy arrays of the cropped image and roi
389
+ """
390
+ assert np.sum(np.array(crop_shape) % 2) == 0, "All elements of crop_shape should be even number."
391
+
392
+ radius = [int(x / 2) - 1 for x in crop_shape]
393
+ if center is None:
394
+ center = list(np.array(list(center_of_mass(roi_data))).astype(int))
395
+
396
+ center_min = np.floor(center).astype(int)
397
+ center_max = np.ceil(center).astype(int)
398
+
399
+ # If center_max and center_min are equal we add 1 to center_max to avoid trouble with crop.
400
+ for i in range(3):
401
+ center_max[i] += 1 if center_max[i] == center_min[i] else 0
402
+
403
+ img_shape = image_data.shape
404
+
405
+ # Pad the image and the ROI if its necessary
406
+ padding = []
407
+ for rad, cent_min, cent_max, shape in zip(radius, center_min, center_max, img_shape):
408
+ padding.append(
409
+ [abs(min(cent_min - rad, 0)), max(cent_max + rad + 1 - shape, 0)]
410
+ )
411
+
412
+ image_data = np.pad(image_data, tuple([tuple(x) for x in padding]))
413
+ roi_data = np.pad(roi_data, tuple([tuple(x) for x in padding]))
414
+
415
+ center_min = [center_min[i] + padding[i][0] for i in range(3)]
416
+ center_max = [center_max[i] + padding[i][0] for i in range(3)]
417
+
418
+ # Crop the image
419
+ image_data = image_data[center_min[0] - radius[0]:center_max[0] + radius[0] + 1,
420
+ center_min[1] - radius[1]:center_max[1] + radius[1] + 1,
421
+ center_min[2] - radius[2]:center_max[2] + radius[2] + 1]
422
+ roi_data = roi_data[center_min[0] - radius[0]:center_max[0] + radius[0] + 1,
423
+ center_min[1] - radius[1]:center_max[1] + radius[1] + 1,
424
+ center_min[2] - radius[2]:center_max[2] + radius[2] + 1]
425
+
426
+ return image_data, roi_data
427
+
428
+ def compute_box(vol: np.ndarray,
429
+ roi: np.ndarray,
430
+ spatial_ref: imref3d,
431
+ box_string: str) -> Tuple[np.ndarray,
432
+ np.ndarray,
433
+ imref3d]:
434
+ """Computes a new box around the ROI (Region of interest) from the original box
435
+ and updates the volume and the ``spatial_ref``.
436
+
437
+ Args:
438
+ vol (ndarray): ROI mask with values of 0 and 1.
439
+ roi (ndarray): ROI mask with values of 0 and 1.
440
+ spatial_ref (imref3d): imref3d object (same functionality of MATLAB imref3d class).
441
+ box_string (str): Specifies the new box to be computed
442
+
443
+ * 'full': full imaging data as output.
444
+ * 'box': computes the smallest bounding box.
445
+ * Ex: 'box10' means 10 voxels in all three dimensions are added to the smallest bounding box. The number \
446
+ after 'box' defines the number of voxels to add.
447
+ * Ex: '2box' computes the smallest box and outputs double its \
448
+ size. The number before 'box' defines the multiplication in size.
449
+
450
+ Returns:
451
+ 3-element tuple containing
452
+
453
+ - ndarray: 3D array of imaging data defining the smallest box containing the ROI.
454
+ - ndarray: 3D array of 1's and 0's defining the ROI in ROIbox.
455
+ - imref3d: The associated imref3d object imaging data.
456
+
457
+ Todo:
458
+ * I would not recommend parsing different settings into a string. \
459
+ Provide two or more parameters instead, and use None if one or more \
460
+ are not used.
461
+ * There is no else statement, so "new_spatial_ref" might be unset
462
+ """
463
+
464
+ if "box" in box_string:
465
+ comp = box_string == "box"
466
+ box_bound = compute_bounding_box(mask=roi)
467
+ if not comp:
468
+ # Always returns the first appearance
469
+ ind_box = box_string.find("box")
470
+ # Addition of a certain number of voxels in all dimensions
471
+ if ind_box == 0:
472
+ n_v = float(box_string[(ind_box+3):])
473
+ n_v = np.array([n_v, n_v, n_v]).astype(int)
474
+ else: # Multiplication of the size of the box
475
+ factor = float(box_string[0:ind_box])
476
+ size_box = np.diff(box_bound, axis=1) + 1
477
+ new_box = size_box * factor
478
+ n_v = np.round((new_box - size_box)/2.0).astype(int)
479
+
480
+ o_k = False
481
+
482
+ while not o_k:
483
+ border = np.zeros([3, 2])
484
+ border[0, 0] = box_bound[0, 0] - n_v[0]
485
+ border[0, 1] = box_bound[0, 1] + n_v[0]
486
+ border[1, 0] = box_bound[1, 0] - n_v[1]
487
+ border[1, 1] = box_bound[1, 1] + n_v[1]
488
+ border[2, 0] = box_bound[2, 0] - n_v[2]
489
+ border[2, 1] = box_bound[2, 1] + n_v[2]
490
+ border = border + 1
491
+ check1 = np.sum(border[:, 0] > 0)
492
+ check2 = border[0, 1] <= vol.shape[0]
493
+ check3 = border[1, 1] <= vol.shape[1]
494
+ check4 = border[2, 1] <= vol.shape[2]
495
+
496
+ check = check1 + check2 + check3 + check4
497
+
498
+ if check == 6:
499
+ o_k = True
500
+ else:
501
+ n_v = np.floor(n_v / 2.0)
502
+ if np.sum(n_v) == 0.0:
503
+ o_k = True
504
+ n_v = [0.0, 0.0, 0.0]
505
+ else:
506
+ # Will compute the smallest bounding box possible
507
+ n_v = [0.0, 0.0, 0.0]
508
+
509
+ box_bound[0, 0] -= n_v[0]
510
+ box_bound[0, 1] += n_v[0]
511
+ box_bound[1, 0] -= n_v[1]
512
+ box_bound[1, 1] += n_v[1]
513
+ box_bound[2, 0] -= n_v[2]
514
+ box_bound[2, 1] += n_v[2]
515
+
516
+ box_bound = box_bound.astype(int)
517
+
518
+ vol = vol[box_bound[0, 0]:box_bound[0, 1] + 1,
519
+ box_bound[1, 0]:box_bound[1, 1] + 1,
520
+ box_bound[2, 0]:box_bound[2, 1] + 1]
521
+ roi = roi[box_bound[0, 0]:box_bound[0, 1] + 1,
522
+ box_bound[1, 0]:box_bound[1, 1] + 1,
523
+ box_bound[2, 0]:box_bound[2, 1] + 1]
524
+
525
+ # Resolution in mm, nothing has changed here in terms of resolution;
526
+ # XYZ format here.
527
+ res = np.array([spatial_ref.PixelExtentInWorldX,
528
+ spatial_ref.PixelExtentInWorldY,
529
+ spatial_ref.PixelExtentInWorldZ])
530
+
531
+ # IJK, as required by imref3d
532
+ size_box = (np.diff(box_bound, axis=1) + 1).tolist()
533
+ size_box[0] = size_box[0][0]
534
+ size_box[1] = size_box[1][0]
535
+ size_box[2] = size_box[2][0]
536
+ x_limit, y_limit, z_limit = intrinsicToWorld(spatial_ref,
537
+ box_bound[0, 0],
538
+ box_bound[1, 0],
539
+ box_bound[2, 0])
540
+ new_spatial_ref = imref3d(size_box, res[0], res[1], res[2])
541
+
542
+ # The limit is defined as the border of the first pixel
543
+ new_spatial_ref.XWorldLimits = new_spatial_ref.XWorldLimits - (
544
+ new_spatial_ref.XWorldLimits[0] - (x_limit - res[0]/2))
545
+ new_spatial_ref.YWorldLimits = new_spatial_ref.YWorldLimits - (
546
+ new_spatial_ref.YWorldLimits[0] - (y_limit - res[1]/2))
547
+ new_spatial_ref.ZWorldLimits = new_spatial_ref.ZWorldLimits - (
548
+ new_spatial_ref.ZWorldLimits[0] - (z_limit - res[2]/2))
549
+
550
+ elif "full" in box_string:
551
+ new_spatial_ref = spatial_ref
552
+
553
+ return vol, roi, new_spatial_ref
554
+
555
+ def compute_bounding_box(mask:np.ndarray) -> np.ndarray:
556
+ """Computes the indexes of the ROI (Region of interest) enclosing box
557
+ in all dimensions.
558
+
559
+ Args:
560
+ mask (ndarray): ROI mask with values of 0 and 1.
561
+
562
+ Returns:
563
+ ndarray: An array containing the indexes of the bounding box.
564
+ """
565
+
566
+ indices = np.where(np.reshape(mask, np.size(mask), order='F') == 1)
567
+ iv, jv, kv = np.unravel_index(indices, np.shape(mask), order='F')
568
+ box_bound = np.zeros((3, 2))
569
+ box_bound[0, 0] = np.min(iv)
570
+ box_bound[0, 1] = np.max(iv)
571
+ box_bound[1, 0] = np.min(jv)
572
+ box_bound[1, 1] = np.max(jv)
573
+ box_bound[2, 0] = np.min(kv)
574
+ box_bound[2, 1] = np.max(kv)
575
+
576
+ return box_bound.astype(int)
577
+
578
+ def get_roi(medscan: MEDscan,
579
+ name_roi: str,
580
+ box_string: str,
581
+ interp=False) -> Union[image_volume_obj,
582
+ image_volume_obj]:
583
+ """Computes the ROI box (box containing the region of interest)
584
+ and associated mask from MEDscan object.
585
+
586
+ Args:
587
+ medscan (MEDscan): The MEDscan class object.
588
+ name_roi (str): name of the ROI since the a volume can have multuiple ROIs.
589
+ box_string (str): Specifies the size if the box containing the ROI
590
+
591
+ - 'full': full imaging data as output.
592
+ - 'box': computes the smallest bounding box.
593
+ - Ex: 'box10': 10 voxels in all three dimensions are added to \
594
+ the smallest bounding box. The number after 'box' defines the \
595
+ number of voxels to add.
596
+ - Ex: '2box': Computes the smallest box and outputs double its \
597
+ size. The number before 'box' defines the multiplication in size.
598
+
599
+ interp (bool): True if we need to use an interpolation for box computation.
600
+
601
+ Returns:
602
+ 2-element tuple containing
603
+
604
+ - image_volume_obj: 3D array of imaging data defining box containing the ROI. \
605
+ vol.data is the 3D array, vol.spatialRef is its associated imref3d object.
606
+ - image_volume_obj: 3D array of 1's and 0's defining the ROI. \
607
+ roi.data is the 3D array, roi.spatialRef is its associated imref3d object.
608
+ """
609
+ # PARSING OF ARGUMENTS
610
+ try:
611
+ name_structure_set = []
612
+ delimiters = ["\+", "\-"]
613
+ n_contour_data = len(medscan.data.ROI.indexes)
614
+
615
+ name_roi, vect_plus_minus = get_sep_roi_names(name_roi, delimiters)
616
+ contour_number = np.zeros(len(name_roi))
617
+
618
+ if name_structure_set is None:
619
+ name_structure_set = []
620
+
621
+ if name_structure_set:
622
+ name_structure_set, _ = get_sep_roi_names(name_structure_set, delimiters)
623
+ if len(name_roi) != len(name_structure_set):
624
+ raise ValueError(
625
+ "The numbers of defined ROI names and Structure Set names are not the same")
626
+
627
+ for i in range(0, len(name_roi)):
628
+ for j in range(0, n_contour_data):
629
+ name_temp = medscan.data.ROI.get_roi_name(key=j)
630
+ if name_temp == name_roi[i]:
631
+ if name_structure_set:
632
+ # FOR DICOM + RTSTRUCT
633
+ name_set_temp = medscan.data.ROI.get_name_set(key=j)
634
+ if name_set_temp == name_structure_set[i]:
635
+ contour_number[i] = j
636
+ break
637
+ else:
638
+ contour_number[i] = j
639
+ break
640
+
641
+ n_roi = np.size(contour_number)
642
+ # contour_string IS FOR EXAMPLE '3' or '1-3+2'
643
+ contour_string = str(contour_number[0].astype(int))
644
+
645
+ for i in range(1, n_roi):
646
+ if vect_plus_minus[i-1] == 1:
647
+ sign = '+'
648
+ elif vect_plus_minus[i-1] == -1:
649
+ sign = '-'
650
+ contour_string = contour_string + sign + \
651
+ str(contour_number[i].astype(int))
652
+
653
+ if not (box_string == "full" or "box" in box_string):
654
+ raise ValueError(
655
+ "The third argument must either be \"full\" or contain the word \"box\".")
656
+
657
+ if type(interp) != bool:
658
+ raise ValueError(
659
+ "If present (i.e. it is optional), the fourth argument must be bool")
660
+
661
+ contour_number, operations = parse_contour_string(contour_string)
662
+
663
+ # INTIALIZATIONS
664
+ if type(contour_number) is int:
665
+ n_contour = 1
666
+ contour_number = [contour_number]
667
+ else:
668
+ n_contour = len(contour_number)
669
+
670
+ roi_mask_list = []
671
+ if medscan.type not in ["PTscan", "CTscan", "MRscan", "ADCscan"]:
672
+ raise ValueError("Unknown scan type.")
673
+
674
+ spatial_ref = medscan.data.volume.spatialRef
675
+ vol = medscan.data.volume.array.astype(np.float32)
676
+
677
+ # COMPUTING ALL MASKS
678
+ for c in np.arange(start=0, stop=n_contour):
679
+ contour = contour_number[c]
680
+ # GETTING THE XYZ POINTS FROM medscan
681
+ roi_xyz = medscan.data.ROI.get_indexes(key=contour).copy()
682
+
683
+ # APPLYING ROTATION TO XYZ POINTS (if necessary --> MRscan)
684
+ if hasattr(medscan.data.volume, 'scan_rot') and medscan.data.volume.scan_rot is not None:
685
+ roi_xyz[:, [0, 1, 2]] = np.transpose(
686
+ medscan.data.volume.scan_rot @ np.transpose(roi_xyz[:, [0, 1, 2]]))
687
+
688
+ # APPLYING TRANSLATION IF SIMULATION STRUCTURE AS INPUT
689
+ # (software STAMP utility)
690
+ if hasattr(medscan.data.volume, 'transScanToModel'):
691
+ translation = medscan.data.volume.transScanToModel
692
+ roi_xyz[:, 0] += translation[0]
693
+ roi_xyz[:, 1] += translation[1]
694
+ roi_xyz[:, 2] += translation[2]
695
+
696
+ # COMPUTING THE ROI MASK
697
+ # Problem here in compute_roi.m: If the volume is a full-body CT and the
698
+ # slice interpolation process occurs, a lot of RAM will be used.
699
+ # One solution could be to a priori compute the bounding box before
700
+ # computing the ROI (using XYZ points). But we still want the user to
701
+ # be able to fully use the "box" argument, so we are fourré...TO SOLVE!
702
+ roi_mask_list += [compute_roi(roi_xyz=roi_xyz,
703
+ spatial_ref=spatial_ref,
704
+ orientation=medscan.data.orientation,
705
+ scan_type=medscan.type,
706
+ interp=interp).astype(np.float32)]
707
+
708
+ # APPLYING OPERATIONS ON ALL MASKS
709
+ roi = roi_mask_list[0]
710
+ for c in np.arange(start=1, stop=n_contour):
711
+ if operations[c-1] == "+":
712
+ roi += roi_mask_list[c]
713
+ elif operations[c-1] == "-":
714
+ roi -= roi_mask_list[c]
715
+ else:
716
+ raise ValueError("Unknown operation on ROI.")
717
+
718
+ roi[roi >= 1.0] = 1.0
719
+ roi[roi < 1.0] = 0.0
720
+
721
+ # COMPUTING THE BOUNDING BOX
722
+ vol, roi, new_spatial_ref = compute_box(vol=vol,
723
+ roi=roi,
724
+ spatial_ref=spatial_ref,
725
+ box_string=box_string)
726
+
727
+ # ARRANGE OUTPUT
728
+ vol_obj = image_volume_obj(data=vol, spatial_ref=new_spatial_ref)
729
+ roi_obj = image_volume_obj(data=roi, spatial_ref=new_spatial_ref)
730
+
731
+ except Exception as e:
732
+ message = f"\n PROBLEM WITH PRE-PROCESSING OF FEATURES IN get_roi(): \n {e}"
733
+ _logger.error(message)
734
+ print(message)
735
+
736
+ if medscan:
737
+ medscan.radiomics.image.update(
738
+ {('scale'+(str(medscan.params.process.scale_non_text[0])).replace('.', 'dot')): 'ERROR_PROCESSING'})
739
+
740
+ return vol_obj, roi_obj
741
+
742
+ def compute_roi(roi_xyz: np.ndarray,
743
+ spatial_ref: imref3d,
744
+ orientation: str,
745
+ scan_type: str,
746
+ interp=False) -> np.ndarray:
747
+ """Computes the ROI (Region of interest) mask using the XYZ coordinates.
748
+
749
+ Args:
750
+ roi_xyz (ndarray): array of (x,y,z) triplets defining a contour in the Patient-Based
751
+ Coordinate System extracted from DICOM RTstruct.
752
+ spatial_ref (imref3d): imref3d object (same functionality of MATLAB imref3d class).
753
+ orientation (str): Imaging data ``orientation`` (axial, sagittal or coronal).
754
+ scan_type (str): Imaging modality (MRscan, CTscan...).
755
+ interp (bool): Specifies if we need to use an interpolation \
756
+ process prior to :func:`get_polygon_mask()` in the slice axis direction.
757
+
758
+ - True: Interpolation is performed in the slice axis dimensions. To be further \
759
+ tested, thus please use with caution (True is safer).
760
+ - False (default): No interpolation. This can definitely be safe \
761
+ when the RTstruct has been saved specifically for the volume of \
762
+ interest.
763
+
764
+ Returns:
765
+ ndarray: 3D array of 1's and 0's defining the ROI mask.
766
+
767
+ Todo:
768
+ - Using interpolation: this part needs to be further tested.
769
+ - Consider changing to 'if statement'. Changing ``interp`` variable here will change the ``interp`` variable everywhere
770
+ """
771
+
772
+ while interp:
773
+ # Initialization
774
+ if orientation == "Axial":
775
+ dim_ijk = 2
776
+ dim_xyz = 2
777
+ direction = "Z"
778
+ # Only the resolution in 'Z' will be changed
779
+ res_xyz = np.array([spatial_ref.PixelExtentInWorldX,
780
+ spatial_ref.PixelExtentInWorldY, 0.0])
781
+ elif orientation == "Sagittal":
782
+ dim_ijk = 0
783
+ dim_xyz = 1
784
+ direction = "Y"
785
+ # Only the resolution in 'Y' will be changed
786
+ res_xyz = np.array([spatial_ref.PixelExtentInWorldX, 0.0,
787
+ spatial_ref.PixelExtentInWorldZ])
788
+ elif orientation == "Coronal":
789
+ dim_ijk = 1
790
+ dim_xyz = 0
791
+ direction = "X"
792
+ # Only the resolution in 'X' will be changed
793
+ res_xyz = np.array([0.0, spatial_ref.PixelExtentInWorldY,
794
+ spatial_ref.PixelExtentInWorldZ])
795
+ else:
796
+ raise ValueError(
797
+ "Provided orientation is not one of \"Axial\", \"Sagittal\", \"Coronal\".")
798
+
799
+ # Creating new imref3d object for sample points (with slice dimension
800
+ # similar to original volume
801
+ # where RTstruct was created)
802
+ # Slice spacing in mm
803
+ slice_spacing = find_spacing(
804
+ roi_xyz[:, dim_ijk], scan_type).astype(np.float32)
805
+
806
+ # Only one slice found in the function "find_spacing" on the above line.
807
+ # We thus must set "slice_spacing" to the slice spacing of the queried
808
+ # volume, and no interpolation will be performed.
809
+ if slice_spacing is None:
810
+ slice_spacing = spatial_ref.PixelExtendInWorld(axis=direction)
811
+
812
+ new_size = round(spatial_ref.ImageExtentInWorld(
813
+ axis=direction) / slice_spacing)
814
+ res_xyz[dim_xyz] = slice_spacing
815
+ s_z = spatial_ref.ImageSize.copy()
816
+ s_z[dim_ijk] = new_size
817
+
818
+ xWorldLimits = spatial_ref.XWorldLimits.copy()
819
+ yWorldLimits = spatial_ref.YWorldLimits.copy()
820
+ zWorldLimits = spatial_ref.ZWorldLimits.copy()
821
+
822
+ new_spatial_ref = imref3d(imageSize=s_z,
823
+ pixelExtentInWorldX=res_xyz[0],
824
+ pixelExtentInWorldY=res_xyz[1],
825
+ pixelExtentInWorldZ=res_xyz[2],
826
+ xWorldLimits=xWorldLimits,
827
+ yWorldLimits=yWorldLimits,
828
+ zWorldLimits=zWorldLimits)
829
+
830
+ diff = (new_spatial_ref.ImageExtentInWorld(axis=direction) -
831
+ spatial_ref.ImageExtentInWorld(axis=direction))
832
+
833
+ if np.abs(diff) >= 0.01:
834
+ # Sampled and queried volume are considered "different".
835
+ new_limit = spatial_ref.WorldLimits(axis=direction)[0] - diff / 2.0
836
+
837
+ # Sampled volume is now centered on queried volume.
838
+ new_spatial_ref.WorldLimits(axis=direction, newValue=(new_spatial_ref.WorldLimits(axis=direction) -
839
+ (new_spatial_ref.WorldLimits(axis=direction)[0] -
840
+ new_limit)))
841
+ else:
842
+ # Less than a 0.01 mm, sampled and queried volume are considered
843
+ # to be the same. At this point,
844
+ # spatial_ref and new_spatial_ref may have differed due to data
845
+ # manipulation, so we simply compute
846
+ # the ROI mask with spatial_ref (i.e. simply using "poly2mask.m"),
847
+ # without performing interpolation.
848
+ interp = False
849
+ break # Getting out of the "while" statement
850
+
851
+ V = get_polygon_mask(roi_xyz, new_spatial_ref)
852
+
853
+ # Getting query points (x_q,y_q,z_q) of output roi_mask
854
+ sz_q = spatial_ref.ImageSize
855
+ x_qi = np.arange(sz_q[0])
856
+ y_qi = np.arange(sz_q[1])
857
+ z_qi = np.arange(sz_q[2])
858
+ x_qi, y_qi, z_qi = np.meshgrid(x_qi, y_qi, z_qi, indexing='ij')
859
+
860
+ # Getting queried mask
861
+ v_q = interp3(V=V, x_q=x_qi, y_q=y_qi, z_q=z_qi, method="cubic")
862
+ roi_mask = v_q
863
+ roi_mask[v_q < 0.5] = 0
864
+ roi_mask[v_q >= 0.5] = 1
865
+
866
+ # Getting out of the "while" statement
867
+ interp = False
868
+
869
+ # SIMPLY USING "poly2mask.m" or "inpolygon.m". "inpolygon.m" is slower, but
870
+ # apparently more accurate.
871
+ if not interp:
872
+ # Using the inpolygon.m function. To be further tested.
873
+ roi_mask = get_polygon_mask(roi_xyz, spatial_ref)
874
+
875
+ return roi_mask
876
+
877
+ def find_spacing(points: np.ndarray,
878
+ scan_type: str) -> float:
879
+ """Finds the slice spacing in mm.
880
+
881
+ Note:
882
+ This function works for points from at least 2 slices. If only
883
+ one slice is present, the function returns a None.
884
+
885
+ Args:
886
+ points (ndarray): Array of (x,y,z) triplets defining a contour in the
887
+ Patient-Based Coordinate System extracted from DICOM RTstruct.
888
+ scan_type (str): Imaging modality (MRscan, CTscan...)
889
+
890
+ Returns:
891
+ float: Slice spacing in mm.
892
+ """
893
+ decim_keep = 4 # We keep at most 4 decimals to find the slice spacing.
894
+
895
+ # Rounding to the nearest 0.1 mm, MRI is more problematic due to arbitrary
896
+ # orientations allowed for imaging volumes.
897
+ if scan_type == "MRscan":
898
+ slices = np.unique(np.around(points, 1))
899
+ else:
900
+ slices = np.unique(np.around(points, 2))
901
+
902
+ n_slices = len(slices)
903
+ if n_slices == 1:
904
+ return None
905
+
906
+ diff = np.abs(np.diff(slices))
907
+ diff = np.round(diff, decim_keep)
908
+ slice_spacing, nOcc = mode(x=diff, return_counts=True)
909
+ if np.max(nOcc) == 1:
910
+ slice_spacing = np.mean(diff)
911
+
912
+ return slice_spacing