dbdicom 0.2.5__py3-none-any.whl → 0.3.0__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.
Potentially problematic release.
This version of dbdicom might be problematic. Click here for more details.
- dbdicom/__init__.py +1 -28
- dbdicom/api.py +267 -0
- dbdicom/const.py +144 -0
- dbdicom/dataset.py +752 -0
- dbdicom/dbd.py +719 -0
- dbdicom/external/__pycache__/__init__.cpython-311.pyc +0 -0
- dbdicom/external/dcm4che/__pycache__/__init__.cpython-311.pyc +0 -0
- dbdicom/external/dcm4che/bin/__pycache__/__init__.cpython-311.pyc +0 -0
- dbdicom/register.py +527 -0
- dbdicom/{ds/types → sop_classes}/ct_image.py +2 -16
- dbdicom/{ds/types → sop_classes}/enhanced_mr_image.py +153 -26
- dbdicom/{ds/types → sop_classes}/mr_image.py +185 -140
- dbdicom/sop_classes/parametric_map.py +307 -0
- dbdicom/sop_classes/secondary_capture.py +140 -0
- dbdicom/sop_classes/segmentation.py +311 -0
- dbdicom/{ds/types → sop_classes}/ultrasound_multiframe_image.py +1 -15
- dbdicom/{ds/types → sop_classes}/xray_angiographic_image.py +2 -17
- dbdicom/utils/arrays.py +36 -0
- dbdicom/utils/files.py +0 -20
- dbdicom/utils/image.py +10 -629
- dbdicom-0.3.0.dist-info/METADATA +28 -0
- dbdicom-0.3.0.dist-info/RECORD +53 -0
- {dbdicom-0.2.5.dist-info → dbdicom-0.3.0.dist-info}/WHEEL +1 -1
- dbdicom/create.py +0 -457
- dbdicom/dro.py +0 -174
- dbdicom/ds/__init__.py +0 -10
- dbdicom/ds/create.py +0 -63
- dbdicom/ds/dataset.py +0 -869
- dbdicom/ds/dictionaries.py +0 -620
- dbdicom/ds/types/parametric_map.py +0 -226
- dbdicom/extensions/__init__.py +0 -9
- dbdicom/extensions/dipy.py +0 -448
- dbdicom/extensions/elastix.py +0 -503
- dbdicom/extensions/matplotlib.py +0 -107
- dbdicom/extensions/numpy.py +0 -271
- dbdicom/extensions/scipy.py +0 -1512
- dbdicom/extensions/skimage.py +0 -1030
- dbdicom/extensions/sklearn.py +0 -243
- dbdicom/extensions/vreg.py +0 -1390
- dbdicom/manager.py +0 -2132
- dbdicom/message.py +0 -119
- dbdicom/pipelines.py +0 -66
- dbdicom/record.py +0 -1893
- dbdicom/types/database.py +0 -107
- dbdicom/types/instance.py +0 -231
- dbdicom/types/patient.py +0 -40
- dbdicom/types/series.py +0 -2874
- dbdicom/types/study.py +0 -58
- dbdicom-0.2.5.dist-info/METADATA +0 -71
- dbdicom-0.2.5.dist-info/RECORD +0 -66
- {dbdicom-0.2.5.dist-info → dbdicom-0.3.0.dist-info/licenses}/LICENSE +0 -0
- {dbdicom-0.2.5.dist-info → dbdicom-0.3.0.dist-info}/top_level.txt +0 -0
dbdicom/dbd.py
ADDED
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from tqdm import tqdm
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import vreg
|
|
8
|
+
from pydicom.dataset import Dataset
|
|
9
|
+
|
|
10
|
+
import dbdicom.utils.arrays
|
|
11
|
+
import dbdicom.utils.files as filetools
|
|
12
|
+
import dbdicom.utils.dcm4che as dcm4che
|
|
13
|
+
import dbdicom.dataset as dbdataset
|
|
14
|
+
import dbdicom.register as register
|
|
15
|
+
import dbdicom.const as const
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DataBaseDicom():
|
|
20
|
+
"""Class to read and write a DICOM folder.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
path (str): path to the DICOM folder.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, path):
|
|
27
|
+
|
|
28
|
+
if not os.path.exists(path):
|
|
29
|
+
os.makedirs(path)
|
|
30
|
+
self.path = path
|
|
31
|
+
|
|
32
|
+
file = self._register_file()
|
|
33
|
+
if os.path.exists(file):
|
|
34
|
+
try:
|
|
35
|
+
self.register = pd.read_pickle(file)
|
|
36
|
+
except:
|
|
37
|
+
# If the file is corrupted, delete it and load again
|
|
38
|
+
os.remove(file)
|
|
39
|
+
self.read()
|
|
40
|
+
else:
|
|
41
|
+
self.read()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def read(self):
|
|
45
|
+
"""Read the DICOM folder again
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
files = filetools.all_files(self.path)
|
|
49
|
+
self.register = dbdataset.read_dataframe(
|
|
50
|
+
files,
|
|
51
|
+
register.COLUMNS + ['NumberOfFrames','SOPClassUID'],
|
|
52
|
+
path=self.path,
|
|
53
|
+
images_only = True)
|
|
54
|
+
self.register['removed'] = False
|
|
55
|
+
self.register['created'] = False
|
|
56
|
+
# No support for multiframe data at the moment
|
|
57
|
+
self._multiframe_to_singleframe()
|
|
58
|
+
# For now ensure all series have just a single CIOD
|
|
59
|
+
self._split_series()
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def close(self):
|
|
64
|
+
"""Close the DICOM folder
|
|
65
|
+
|
|
66
|
+
This also saves changes in the header file to disk.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
created = self.register.created & (self.register.removed==False)
|
|
70
|
+
removed = self.register.removed
|
|
71
|
+
created = created[created].index
|
|
72
|
+
removed = removed[removed].index
|
|
73
|
+
|
|
74
|
+
# delete datasets marked for removal
|
|
75
|
+
for index in removed.tolist():
|
|
76
|
+
file = os.path.join(self.path, index)
|
|
77
|
+
if os.path.exists(file):
|
|
78
|
+
os.remove(file)
|
|
79
|
+
# and drop then from the register
|
|
80
|
+
self.register.drop(index=removed, inplace=True)
|
|
81
|
+
|
|
82
|
+
# for new or edited data, mark as saved.
|
|
83
|
+
self.register.loc[created, 'created'] = False
|
|
84
|
+
|
|
85
|
+
# save register
|
|
86
|
+
file = self._register_file()
|
|
87
|
+
self.register.to_pickle(file)
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def restore(self):
|
|
92
|
+
"""Restore the DICOM folder to the last saved state."""
|
|
93
|
+
|
|
94
|
+
created = self.register.created
|
|
95
|
+
removed = self.register.removed & (self.register.created==False)
|
|
96
|
+
created = created[created].index
|
|
97
|
+
removed = removed[removed].index
|
|
98
|
+
|
|
99
|
+
# permanently delete newly created datasets
|
|
100
|
+
for index in created.tolist():
|
|
101
|
+
file = os.path.join(self.path, index)
|
|
102
|
+
if os.path.exists(file):
|
|
103
|
+
os.remove(file)
|
|
104
|
+
|
|
105
|
+
# and drop then from the register
|
|
106
|
+
self.register.drop(index=created, inplace=True)
|
|
107
|
+
|
|
108
|
+
# Restore those that were marked for removal
|
|
109
|
+
self.register.loc[removed, 'removed'] = False
|
|
110
|
+
|
|
111
|
+
# save register
|
|
112
|
+
file = self._register_file()
|
|
113
|
+
self.register.to_pickle(file)
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def summary(self):
|
|
118
|
+
"""Return a summary of the contents of the database.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
dict: Nested dictionary with summary information on the database.
|
|
122
|
+
"""
|
|
123
|
+
return register.summary(self.register)
|
|
124
|
+
|
|
125
|
+
def print(self):
|
|
126
|
+
"""Print the contents of the DICOM folder
|
|
127
|
+
"""
|
|
128
|
+
register.print_tree(self.register)
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
def patients(self, name=None, contains=None, isin=None):
|
|
132
|
+
"""Return a list of patients in the DICOM folder.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
name (str, optional): value of PatientName, to search for
|
|
136
|
+
individuals with a given name. Defaults to None.
|
|
137
|
+
contains (str, optional): substring of PatientName, to
|
|
138
|
+
search for individuals based on part of their name.
|
|
139
|
+
Defaults to None.
|
|
140
|
+
isin (list, optional): List of PatientName values, to search
|
|
141
|
+
for patients whose name is in the list. Defaults to None.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
list: list of patients fulfilling the criteria.
|
|
145
|
+
"""
|
|
146
|
+
return register.patients(self.register, self.path, name, contains, isin)
|
|
147
|
+
|
|
148
|
+
def studies(self, entity=None, name=None, contains=None, isin=None):
|
|
149
|
+
"""Return a list of studies in the DICOM folder.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
entity (str or list): path to a DICOM folder (to search in
|
|
153
|
+
the whole folder), or a two-element list identifying a
|
|
154
|
+
patient (to search studies of a given patient).
|
|
155
|
+
name (str, optional): value of StudyDescription, to search for
|
|
156
|
+
studies with a given description. Defaults to None.
|
|
157
|
+
contains (str, optional): substring of StudyDescription, to
|
|
158
|
+
search for studies based on part of their description.
|
|
159
|
+
Defaults to None.
|
|
160
|
+
isin (list, optional): List of StudyDescription values, to search
|
|
161
|
+
for studies whose description is in a list. Defaults to None.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
list: list of studies fulfilling the criteria.
|
|
165
|
+
"""
|
|
166
|
+
if entity == None:
|
|
167
|
+
entity = self.path
|
|
168
|
+
if isinstance(entity, str):
|
|
169
|
+
studies = []
|
|
170
|
+
for patient in self.patients():
|
|
171
|
+
studies += self.studies(patient, name, contains, isin)
|
|
172
|
+
return studies
|
|
173
|
+
else:
|
|
174
|
+
return register.studies(self.register, entity, name, contains, isin)
|
|
175
|
+
|
|
176
|
+
def series(self, entity=None, name=None, contains=None, isin=None):
|
|
177
|
+
"""Return a list of series in the DICOM folder.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
entity (str or list): path to a DICOM folder (to search in
|
|
181
|
+
the whole folder), or a list identifying a
|
|
182
|
+
patient or a study (to search series of a given patient
|
|
183
|
+
or study).
|
|
184
|
+
name (str, optional): value of SeriesDescription, to search for
|
|
185
|
+
series with a given description. Defaults to None.
|
|
186
|
+
contains (str, optional): substring of SeriesDescription, to
|
|
187
|
+
search for series based on part of their description.
|
|
188
|
+
Defaults to None.
|
|
189
|
+
isin (list, optional): List of SeriesDescription values, to search
|
|
190
|
+
for series whose description is in a list. Defaults to None.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
list: list of series fulfilling the criteria.
|
|
194
|
+
"""
|
|
195
|
+
if entity == None:
|
|
196
|
+
entity = self.path
|
|
197
|
+
if isinstance(entity, str):
|
|
198
|
+
series = []
|
|
199
|
+
for study in self.studies(entity):
|
|
200
|
+
series += self.series(study, name, contains, isin)
|
|
201
|
+
return series
|
|
202
|
+
elif len(entity)==2:
|
|
203
|
+
series = []
|
|
204
|
+
for study in self.studies(entity):
|
|
205
|
+
series += self.series(study, name, contains, isin)
|
|
206
|
+
return series
|
|
207
|
+
else: # path = None (all series) or path = patient (all series in patient)
|
|
208
|
+
return register.series(self.register, entity, name, contains, isin)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def volume(self, series:list, dims:list=None, multislice=False) -> vreg.Volume3D:
|
|
212
|
+
"""Read a vreg.Volume3D from a DICOM series
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
series (list): DICOM series to read
|
|
216
|
+
dims (list, optional): Non-spatial dimensions of the volume. Defaults to None.
|
|
217
|
+
multislice (bool, optional): Whether the data are to be read
|
|
218
|
+
as multislice or not. In multislice data the voxel size
|
|
219
|
+
is taken from the slice gap rather thsan the slice thickness. Defaults to False.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
vreg.Volume3D: vole read from the series.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
if dims is None:
|
|
226
|
+
dims = []
|
|
227
|
+
elif isinstance(dims, str):
|
|
228
|
+
dims = [dims]
|
|
229
|
+
else:
|
|
230
|
+
dims = list(dims)
|
|
231
|
+
dims = ['SliceLocation'] + dims
|
|
232
|
+
|
|
233
|
+
files = register.files(self.register, series)
|
|
234
|
+
|
|
235
|
+
# Read dicom files
|
|
236
|
+
values = []
|
|
237
|
+
volumes = []
|
|
238
|
+
for f in tqdm(files, desc='Reading volume..'):
|
|
239
|
+
ds = dbdataset.read_dataset(f)
|
|
240
|
+
values.append(dbdataset.get_values(ds, dims))
|
|
241
|
+
volumes.append(dbdataset.volume(ds, multislice))
|
|
242
|
+
|
|
243
|
+
# Format as mesh
|
|
244
|
+
coords = np.stack(values, axis=-1)
|
|
245
|
+
coords, inds = dbdicom.utils.arrays.meshvals(coords)
|
|
246
|
+
vols = np.array(volumes)
|
|
247
|
+
vols = vols[inds].reshape(coords.shape[1:])
|
|
248
|
+
|
|
249
|
+
# Check that all slices have the same coordinates
|
|
250
|
+
c0 = coords[1:,0,...]
|
|
251
|
+
for k in range(coords.shape[1]-1):
|
|
252
|
+
if not np.array_equal(coords[1:,k+1,...], c0):
|
|
253
|
+
raise ValueError(
|
|
254
|
+
"Cannot build a single volume. Not all slices "
|
|
255
|
+
"have the same coordinates. \nIf you set "
|
|
256
|
+
"firstslice=True, the coordinates of the lowest "
|
|
257
|
+
"slice will be assigned to the whole volume."
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Join 2D volumes into 3D volumes
|
|
261
|
+
vol = vreg.join(vols)
|
|
262
|
+
if vol.ndim > 3:
|
|
263
|
+
vol.set_coords(c0)
|
|
264
|
+
vol.set_dims(dims[1:])
|
|
265
|
+
return vol
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def write_volume(
|
|
269
|
+
self, vol:vreg.Volume3D, series:list,
|
|
270
|
+
ref:list=None, multislice=False,
|
|
271
|
+
):
|
|
272
|
+
"""Write a vreg.Volume3D to a DICOM series
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
vol (vreg.Volume3D): Volume to write to the series.
|
|
276
|
+
series (list): DICOM series to read
|
|
277
|
+
dims (list, optional): Non-spatial dimensions of the volume. Defaults to None.
|
|
278
|
+
multislice (bool, optional): Whether the data are to be read
|
|
279
|
+
as multislice or not. In multislice data the voxel size
|
|
280
|
+
is taken from the slice gap rather thsan the slice thickness. Defaults to False.
|
|
281
|
+
"""
|
|
282
|
+
if ref is None:
|
|
283
|
+
ds = dbdataset.new_dataset('MRImage')
|
|
284
|
+
else:
|
|
285
|
+
if ref[0] == series[0]:
|
|
286
|
+
ref_mgr = self
|
|
287
|
+
else:
|
|
288
|
+
ref_mgr = DataBaseDicom(ref[0])
|
|
289
|
+
files = register.files(ref_mgr.register, ref)
|
|
290
|
+
ds = dbdataset.read_dataset(files[0])
|
|
291
|
+
|
|
292
|
+
# Get the attributes of the destination series
|
|
293
|
+
attr = self._attributes(series)
|
|
294
|
+
n = self._max_instance_number(attr['SeriesInstanceUID'])
|
|
295
|
+
|
|
296
|
+
new_instances = {}
|
|
297
|
+
if vol.ndim==3:
|
|
298
|
+
slices = vol.split()
|
|
299
|
+
for i, sl in tqdm(enumerate(slices), desc='Writing volume..'):
|
|
300
|
+
dbdataset.set_volume(ds, sl, multislice)
|
|
301
|
+
self._write_dataset(ds, attr, n + 1 + i, new_instances)
|
|
302
|
+
else:
|
|
303
|
+
i=0
|
|
304
|
+
vols = vol.separate().reshape(-1)
|
|
305
|
+
for vt in tqdm(vols, desc='Writing volume..'):
|
|
306
|
+
for sl in vt.split():
|
|
307
|
+
dbdataset.set_volume(ds, sl, multislice)
|
|
308
|
+
dbdataset.set_value(ds, sl.dims, sl.coords[:,...])
|
|
309
|
+
self._write_dataset(ds, attr, n + 1 + i, new_instances)
|
|
310
|
+
i+=1
|
|
311
|
+
return self
|
|
312
|
+
self._update_register(new_instances)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def to_nifti(self, series:list, file:str, dims=None, multislice=False):
|
|
316
|
+
"""Save a DICOM series in nifti format.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
series (list): DICOM series to read
|
|
320
|
+
file (str): file path of the nifti file.
|
|
321
|
+
dims (list, optional): Non-spatial dimensions of the volume.
|
|
322
|
+
Defaults to None.
|
|
323
|
+
multislice (bool, optional): Whether the data are to be read
|
|
324
|
+
as multislice or not. In multislice data the voxel size
|
|
325
|
+
is taken from the slice gap rather thaan the slice thickness. Defaults to False.
|
|
326
|
+
"""
|
|
327
|
+
vol = self.volume(series, dims, multislice)
|
|
328
|
+
vreg.write_nifti(vol, file)
|
|
329
|
+
return self
|
|
330
|
+
|
|
331
|
+
def from_nifti(self, file:str, series:list, ref:list=None, multislice=False):
|
|
332
|
+
"""Create a DICOM series from a nifti file.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
file (str): file path of the nifti file.
|
|
336
|
+
series (list): DICOM series to create
|
|
337
|
+
ref (list): DICOM series to use as template.
|
|
338
|
+
multislice (bool, optional): Whether the data are to be written
|
|
339
|
+
as multislice or not. In multislice data the voxel size
|
|
340
|
+
is written in the slice gap rather thaan the slice thickness. Defaults to False.
|
|
341
|
+
"""
|
|
342
|
+
vol = vreg.read_nifti(file)
|
|
343
|
+
self.write_volume(vol, series, ref, multislice)
|
|
344
|
+
return self
|
|
345
|
+
|
|
346
|
+
def pixel_data(self, series:list, dims:list=None, include=None) -> np.ndarray:
|
|
347
|
+
"""Read the pixel data from a DICOM series
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
series (list): DICOM series to read
|
|
351
|
+
dims (list, optional): Dimensions of the array.
|
|
352
|
+
include (list, optional): list of DICOM attributes that are
|
|
353
|
+
read on the fly to avoid reading the data twice.
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
tuple: numpy array with pixel values and an array with
|
|
357
|
+
coordinates of the slices according to dims. If include
|
|
358
|
+
is provide these are returned as a dictionary in a third
|
|
359
|
+
return value.
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
if np.isscalar(dims):
|
|
363
|
+
dims = [dims]
|
|
364
|
+
else:
|
|
365
|
+
dims = list(dims)
|
|
366
|
+
|
|
367
|
+
# Ensure return_vals is a list
|
|
368
|
+
if include is None:
|
|
369
|
+
params = []
|
|
370
|
+
elif np.isscalar(include):
|
|
371
|
+
params = [include]
|
|
372
|
+
else:
|
|
373
|
+
params = list(include)
|
|
374
|
+
|
|
375
|
+
files = register.files(self.register, series)
|
|
376
|
+
|
|
377
|
+
# Read dicom files
|
|
378
|
+
coords = []
|
|
379
|
+
arrays = np.empty(len(files), dtype=dict)
|
|
380
|
+
if include is not None:
|
|
381
|
+
values = np.empty(len(files), dtype=dict)
|
|
382
|
+
for i, f in tqdm(enumerate(files), desc='Reading pixel data..'):
|
|
383
|
+
ds = dbdataset.read_dataset(f)
|
|
384
|
+
coords.append(dbdataset.get_values(ds, dims))
|
|
385
|
+
# save as dict so numpy does not stack as arrays
|
|
386
|
+
arrays[i] = {'pixel_data': dbdataset.pixel_data(ds)}
|
|
387
|
+
if include is not None:
|
|
388
|
+
values[i] = {'values': dbdataset.get_values(ds, params)}
|
|
389
|
+
|
|
390
|
+
# Format as mesh
|
|
391
|
+
coords = np.stack([v for v in coords], axis=-1)
|
|
392
|
+
coords, inds = dbdicom.utils.arrays.meshvals(coords)
|
|
393
|
+
|
|
394
|
+
arrays = arrays[inds].reshape(coords.shape[1:])
|
|
395
|
+
arrays = np.stack([a['pixel_data'] for a in arrays.reshape(-1)], axis=-1)
|
|
396
|
+
arrays = arrays.reshape(arrays.shape[:2] + coords.shape[1:])
|
|
397
|
+
|
|
398
|
+
if include is None:
|
|
399
|
+
return arrays, coords
|
|
400
|
+
|
|
401
|
+
values = values[inds].reshape(coords.shape[1:])
|
|
402
|
+
values = np.stack([a['values'] for a in values.reshape(-1)], axis=-1)
|
|
403
|
+
values = values.reshape((len(params), ) + coords.shape[1:])
|
|
404
|
+
|
|
405
|
+
return arrays, coords, values
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def unique(self, pars:list, entity:list) -> dict:
|
|
409
|
+
"""Return a list of unique values for a DICOM entity
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
pars (list): attributes to return.
|
|
413
|
+
entity (list): DICOM entity to search (Patient, Study or Series)
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
dict: dictionary with unique values for each attribute.
|
|
417
|
+
"""
|
|
418
|
+
v = self._values(pars, entity)
|
|
419
|
+
|
|
420
|
+
# Return a list with unique values for each attribute
|
|
421
|
+
values = []
|
|
422
|
+
for a in range(v.shape[1]):
|
|
423
|
+
va = v[:,a]
|
|
424
|
+
# Remove None values
|
|
425
|
+
va = va[[x is not None for x in va]]
|
|
426
|
+
va = list(va)
|
|
427
|
+
# Get unique values and sort
|
|
428
|
+
va = [x for i, x in enumerate(va) if i==va.index(x)]
|
|
429
|
+
if len(va) == 0:
|
|
430
|
+
va = None
|
|
431
|
+
elif len(va) == 1:
|
|
432
|
+
va = va[0]
|
|
433
|
+
else:
|
|
434
|
+
try:
|
|
435
|
+
va.sort()
|
|
436
|
+
except:
|
|
437
|
+
pass
|
|
438
|
+
values.append(va)
|
|
439
|
+
return {p: values[i] for i, p in enumerate(pars)}
|
|
440
|
+
|
|
441
|
+
def copy(self, from_entity, to_entity):
|
|
442
|
+
"""Copy a DICOM entity (patient, study or series)
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
from_entity (list): entity to copy
|
|
446
|
+
to_entity (list): entity after copying.
|
|
447
|
+
"""
|
|
448
|
+
if len(from_entity) == 4:
|
|
449
|
+
if len(to_entity) != 4:
|
|
450
|
+
raise ValueError(
|
|
451
|
+
f"Cannot copy series {from_entity} to series {to_entity}. "
|
|
452
|
+
f"{to_entity} is not a series (needs 4 elements)."
|
|
453
|
+
)
|
|
454
|
+
return self._copy_series(from_entity, to_entity)
|
|
455
|
+
if len(from_entity) == 3:
|
|
456
|
+
if len(to_entity) != 3:
|
|
457
|
+
raise ValueError(
|
|
458
|
+
f"Cannot copy study {from_entity} to study {to_entity}. "
|
|
459
|
+
f"{to_entity} is not a study (needs 3 elements)."
|
|
460
|
+
)
|
|
461
|
+
return self._copy_study(from_entity, to_entity)
|
|
462
|
+
if len(from_entity) == 2:
|
|
463
|
+
if len(to_entity) != 2:
|
|
464
|
+
raise ValueError(
|
|
465
|
+
f"Cannot copy patient {from_entity} to patient {to_entity}. "
|
|
466
|
+
f"{to_entity} is not a patient (needs 2 elements)."
|
|
467
|
+
)
|
|
468
|
+
return self._copy_patient(from_entity, to_entity)
|
|
469
|
+
raise ValueError(
|
|
470
|
+
f"Cannot copy {from_entity} to {to_entity}. "
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
def delete(self, entity):
|
|
474
|
+
"""Delete a DICOM entity from the database
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
entity (list): entity to delete
|
|
478
|
+
"""
|
|
479
|
+
index = register.index(self.register, entity)
|
|
480
|
+
self.register.loc[index,'removed'] = True
|
|
481
|
+
return self
|
|
482
|
+
|
|
483
|
+
def move(self, from_entity, to_entity):
|
|
484
|
+
"""Move a DICOM entity
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
entity (list): entity to move
|
|
488
|
+
"""
|
|
489
|
+
self.copy(from_entity, to_entity)
|
|
490
|
+
self.delete(from_entity)
|
|
491
|
+
return self
|
|
492
|
+
|
|
493
|
+
def _values(self, attributes:list, entity:list):
|
|
494
|
+
# Create a np array v with values for each instance and attribute
|
|
495
|
+
if set(attributes) <= set(self.register.columns):
|
|
496
|
+
index = register.index(self.register, entity)
|
|
497
|
+
v = self.register.loc[index, attributes].values
|
|
498
|
+
else:
|
|
499
|
+
files = register.files(self.register, entity)
|
|
500
|
+
v = np.empty((len(files), len(attributes)), dtype=object)
|
|
501
|
+
for i, f in enumerate(files):
|
|
502
|
+
ds = dbdataset.read_dataset(f)
|
|
503
|
+
v[i,:] = dbdataset.get_values(ds, attributes)
|
|
504
|
+
return v
|
|
505
|
+
|
|
506
|
+
def _copy_patient(self, from_patient, to_patient):
|
|
507
|
+
from_patient_studies = register.studies(self.register, from_patient)
|
|
508
|
+
for from_study in tqdm(from_patient_studies, desc=f'Copying patient {from_patient[1:]}'):
|
|
509
|
+
if to_patient[0]==from_patient[0]:
|
|
510
|
+
to_study = register.append(self.register, to_patient, from_study[-1])
|
|
511
|
+
else:
|
|
512
|
+
mgr = DataBaseDicom(to_study[0])
|
|
513
|
+
to_study = register.append(mgr.register, to_patient, from_study[-1])
|
|
514
|
+
self._copy_study(from_study, to_study)
|
|
515
|
+
|
|
516
|
+
def _copy_study(self, from_study, to_study):
|
|
517
|
+
from_study_series = register.series(self.register, from_study)
|
|
518
|
+
for from_series in tqdm(from_study_series, desc=f'Copying study {from_study[1:]}'):
|
|
519
|
+
if to_study[0]==from_study[0]:
|
|
520
|
+
to_series = register.append(self.register, to_study, from_series[-1])
|
|
521
|
+
else:
|
|
522
|
+
mgr = DataBaseDicom(to_study[0])
|
|
523
|
+
to_series = register.append(mgr.register, to_study, from_series[-1])
|
|
524
|
+
self._copy_series(from_series, to_series)
|
|
525
|
+
|
|
526
|
+
def _copy_series(self, from_series, to_series):
|
|
527
|
+
# Get the files to be exported
|
|
528
|
+
from_series_files = register.files(self.register, from_series)
|
|
529
|
+
|
|
530
|
+
if to_series[0] == from_series[0]:
|
|
531
|
+
# Copy in the same database
|
|
532
|
+
self._files_to_series(from_series_files, to_series)
|
|
533
|
+
else:
|
|
534
|
+
# Copy to another database
|
|
535
|
+
mgr = DataBaseDicom(to_series[0])
|
|
536
|
+
mgr._files_to_series(from_series_files, to_series)
|
|
537
|
+
mgr.close()
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _files_to_series(self, files, to_series):
|
|
541
|
+
|
|
542
|
+
# Get the attributes of the destination series
|
|
543
|
+
attr = self._attributes(to_series)
|
|
544
|
+
n = self._max_instance_number(attr['SeriesInstanceUID'])
|
|
545
|
+
|
|
546
|
+
# Copy the files to the new series
|
|
547
|
+
new_instances = {}
|
|
548
|
+
for i, f in tqdm(enumerate(files), total=len(files), desc=f'Copying series {to_series[1:]}'):
|
|
549
|
+
# Read dataset and assign new properties
|
|
550
|
+
ds = dbdataset.read_dataset(f)
|
|
551
|
+
self._write_dataset(ds, attr, n + 1 + i, new_instances)
|
|
552
|
+
self._update_register(new_instances)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _max_series_number(self, study_uid):
|
|
556
|
+
df = self.register
|
|
557
|
+
df = df[(df.StudyInstanceUID==study_uid) & (df.removed==False)]
|
|
558
|
+
n = df['SeriesNumber'].values
|
|
559
|
+
n = n[n != -1]
|
|
560
|
+
max_number=0 if n.size==0 else np.amax(n)
|
|
561
|
+
return max_number
|
|
562
|
+
|
|
563
|
+
def _max_instance_number(self, series_uid):
|
|
564
|
+
df = self.register
|
|
565
|
+
df = df[(df.SeriesInstanceUID==series_uid) & (df.removed==False)]
|
|
566
|
+
n = df['InstanceNumber'].values
|
|
567
|
+
n = n[n != -1]
|
|
568
|
+
max_number=0 if n.size==0 else np.amax(n)
|
|
569
|
+
return max_number
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _attributes(self, entity):
|
|
573
|
+
if len(entity)==4:
|
|
574
|
+
return self._series_attributes(entity)
|
|
575
|
+
if len(entity)==3:
|
|
576
|
+
return self._study_attributes(entity)
|
|
577
|
+
if len(entity)==2:
|
|
578
|
+
return self._patient_attributes(entity)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _patient_attributes(self, patient):
|
|
582
|
+
try:
|
|
583
|
+
# If the patient exists and has files, read from file
|
|
584
|
+
files = register.files(self.register, patient)
|
|
585
|
+
attr = const.PATIENT_MODULE
|
|
586
|
+
ds = dbdataset.read_dataset(files[0])
|
|
587
|
+
vals = dbdataset.get_values(ds, attr)
|
|
588
|
+
except:
|
|
589
|
+
# If the patient does not exist, generate values
|
|
590
|
+
attr = ['PatientID', 'PatientName']
|
|
591
|
+
patient_id = dbdataset.new_uid()
|
|
592
|
+
patient_name = patient[-1] if isinstance(patient[-1], str) else patient[-1][0]
|
|
593
|
+
vals = [patient_id, patient_name]
|
|
594
|
+
return {attr[i]:vals[i] for i in range(len(attr)) if vals[i] is not None}
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _study_attributes(self, study):
|
|
598
|
+
patient_attr = self._patient_attributes(study[:2])
|
|
599
|
+
try:
|
|
600
|
+
# If the study exists and has files, read from file
|
|
601
|
+
files = register.files(self.register, study)
|
|
602
|
+
attr = const.STUDY_MODULE
|
|
603
|
+
ds = dbdataset.read_dataset(files[0])
|
|
604
|
+
vals = dbdataset.get_values(ds, attr)
|
|
605
|
+
except:
|
|
606
|
+
# If the study does not exist, generate values
|
|
607
|
+
attr = ['StudyInstanceUID', 'StudyDescription', 'StudyDate']
|
|
608
|
+
study_id = dbdataset.new_uid()
|
|
609
|
+
study_desc = study[-1] if isinstance(study[-1], str) else study[-1][0]
|
|
610
|
+
study_date = datetime.today().strftime('%Y%m%d')
|
|
611
|
+
vals = [study_id, study_desc, study_date]
|
|
612
|
+
return patient_attr | {attr[i]:vals[i] for i in range(len(attr)) if vals[i] is not None}
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _series_attributes(self, series):
|
|
616
|
+
study_attr = self._study_attributes(series[:3])
|
|
617
|
+
try:
|
|
618
|
+
# If the series exists and has files, read from file
|
|
619
|
+
files = register.files(self.register, series)
|
|
620
|
+
attr = const.SERIES_MODULE
|
|
621
|
+
ds = dbdataset.read_dataset(files[0])
|
|
622
|
+
vals = dbdataset.get_values(ds, attr)
|
|
623
|
+
except:
|
|
624
|
+
# If the series does not exist or is empty, generate values
|
|
625
|
+
try:
|
|
626
|
+
study_uid = register.uid(self.register, series[:-1])
|
|
627
|
+
except:
|
|
628
|
+
series_number = 1
|
|
629
|
+
else:
|
|
630
|
+
series_number = 1 + self._max_series_number(study_uid)
|
|
631
|
+
attr = ['SeriesInstanceUID', 'SeriesDescription', 'SeriesNumber']
|
|
632
|
+
series_id = dbdataset.new_uid()
|
|
633
|
+
series_desc = series[-1] if isinstance(series[-1], str) else series[-1][0]
|
|
634
|
+
vals = [series_id, series_desc, series_number]
|
|
635
|
+
return study_attr | {attr[i]:vals[i] for i in range(len(attr)) if vals[i] is not None}
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _write_dataset(self, ds:Dataset, attr:dict, instance_nr:int, register:dict):
|
|
639
|
+
# Set new attributes
|
|
640
|
+
attr['SOPInstanceUID'] = dbdataset.new_uid()
|
|
641
|
+
attr['InstanceNumber'] = instance_nr
|
|
642
|
+
dbdataset.set_values(ds, list(attr.keys()), list(attr.values()))
|
|
643
|
+
# Save results in a new file
|
|
644
|
+
rel_path = os.path.join('dbdicom', dbdataset.new_uid() + '.dcm')
|
|
645
|
+
dbdataset.write(ds, os.path.join(self.path, rel_path))
|
|
646
|
+
# Add a row to the register
|
|
647
|
+
register[rel_path] = dbdataset.get_values(ds, self.register.columns)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _update_register(self, new_instances:dict):
|
|
651
|
+
# A new instances to the register
|
|
652
|
+
df = pd.DataFrame.from_dict(new_instances, orient='index', columns=self.register.columns)
|
|
653
|
+
df['removed'] = False
|
|
654
|
+
df['created'] = True
|
|
655
|
+
self.register = pd.concat([self.register, df])
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _register_file(self):
|
|
659
|
+
filename = os.path.basename(os.path.normpath(self.path)) + ".pkl"
|
|
660
|
+
return os.path.join(self.path, filename)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _multiframe_to_singleframe(self):
|
|
664
|
+
"""Converts all multiframe files in the folder into single-frame files.
|
|
665
|
+
|
|
666
|
+
Reads all the multi-frame files in the folder,
|
|
667
|
+
converts them to singleframe files, and delete the original multiframe file.
|
|
668
|
+
"""
|
|
669
|
+
singleframe = self.register.NumberOfFrames.isnull()
|
|
670
|
+
multiframe = singleframe == False
|
|
671
|
+
nr_multiframe = multiframe.sum()
|
|
672
|
+
if nr_multiframe != 0:
|
|
673
|
+
for relpath in tqdm(self.register[multiframe].index.values, desc="Converting multiframe file " + relpath):
|
|
674
|
+
filepath = os.path.join(self.path, relpath)
|
|
675
|
+
singleframe_files = dcm4che.split_multiframe(filepath)
|
|
676
|
+
if singleframe_files != []:
|
|
677
|
+
# add the single frame files to the dataframe
|
|
678
|
+
df = dbdataset.read_dataframe(singleframe_files, self.register.columns, path=self.path)
|
|
679
|
+
df['removed'] = False
|
|
680
|
+
df['created'] = False
|
|
681
|
+
self.register = pd.concat([self.register, df])
|
|
682
|
+
# delete the original multiframe
|
|
683
|
+
os.remove(filepath)
|
|
684
|
+
# drop the file also if the conversion has failed
|
|
685
|
+
self.register.drop(index=relpath, inplace=True)
|
|
686
|
+
self.register.drop('NumberOfFrames', axis=1, inplace=True)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _split_series(self):
|
|
690
|
+
"""
|
|
691
|
+
Split series with multiple SOP Classes.
|
|
692
|
+
|
|
693
|
+
If a series contain instances from different SOP Classes,
|
|
694
|
+
these are separated out into multiple series with identical SOP Classes.
|
|
695
|
+
"""
|
|
696
|
+
# For each series, check if there are multiple
|
|
697
|
+
# SOP Classes in the series and split them if yes.
|
|
698
|
+
all_series = self.series()
|
|
699
|
+
for series in tqdm(all_series, desc='Splitting series with multiple SOP Classes.'):
|
|
700
|
+
series_index = register.index(self.register, series)
|
|
701
|
+
df_series = self.register.loc[series_index]
|
|
702
|
+
sop_classes = df_series.SOPClassUID.unique()
|
|
703
|
+
if len(sop_classes) > 1:
|
|
704
|
+
# For each sop_class, create a new series and move all
|
|
705
|
+
# instances of that sop_class to the new series
|
|
706
|
+
desc = series[-1] if isinstance(series, str) else series[0]
|
|
707
|
+
for i, sop_class in enumerate(sop_classes[1:]):
|
|
708
|
+
df_sop_class = df_series[df_series.SOPClassUID == sop_class]
|
|
709
|
+
relpaths = df_sop_class.index.tolist()
|
|
710
|
+
sop_class_files = [os.path.join(self.path, p) for p in relpaths]
|
|
711
|
+
sop_class_series = series[:-1] + [desc + f' [{i+1}]']
|
|
712
|
+
self._files_to_series(sop_class_files, sop_class_series)
|
|
713
|
+
# Delete original files permanently
|
|
714
|
+
self.register.drop(relpaths)
|
|
715
|
+
for f in sop_class_files:
|
|
716
|
+
os.remove(f)
|
|
717
|
+
self.register.drop('SOPClassUID', axis=1, inplace=True)
|
|
718
|
+
|
|
719
|
+
|