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,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
|