dbdicom 0.3.1__py3-none-any.whl → 0.3.3__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/api.py +38 -23
- dbdicom/database.py +126 -0
- dbdicom/dataset.py +35 -60
- dbdicom/dbd.py +221 -201
- 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 +317 -235
- dbdicom/sop_classes/mr_image.py +156 -143
- dbdicom/sop_classes/parametric_map.py +93 -22
- dbdicom/utils/image.py +10 -10
- {dbdicom-0.3.1.dist-info → dbdicom-0.3.3.dist-info}/METADATA +2 -4
- {dbdicom-0.3.1.dist-info → dbdicom-0.3.3.dist-info}/RECORD +16 -15
- {dbdicom-0.3.1.dist-info → dbdicom-0.3.3.dist-info}/WHEEL +1 -1
- {dbdicom-0.3.1.dist-info → dbdicom-0.3.3.dist-info}/licenses/LICENSE +0 -0
- {dbdicom-0.3.1.dist-info → dbdicom-0.3.3.dist-info}/top_level.txt +0 -0
dbdicom/dbd.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import os
|
|
2
2
|
from datetime import datetime
|
|
3
|
+
import json
|
|
4
|
+
from typing import Union
|
|
3
5
|
|
|
4
6
|
from tqdm import tqdm
|
|
5
7
|
import numpy as np
|
|
@@ -8,9 +10,9 @@ import vreg
|
|
|
8
10
|
from pydicom.dataset import Dataset
|
|
9
11
|
|
|
10
12
|
import dbdicom.utils.arrays
|
|
11
|
-
|
|
12
|
-
import dbdicom.utils.dcm4che as dcm4che
|
|
13
|
+
|
|
13
14
|
import dbdicom.dataset as dbdataset
|
|
15
|
+
import dbdicom.database as dbdatabase
|
|
14
16
|
import dbdicom.register as register
|
|
15
17
|
import dbdicom.const as const
|
|
16
18
|
|
|
@@ -32,7 +34,8 @@ class DataBaseDicom():
|
|
|
32
34
|
file = self._register_file()
|
|
33
35
|
if os.path.exists(file):
|
|
34
36
|
try:
|
|
35
|
-
|
|
37
|
+
with open(file, 'r') as f:
|
|
38
|
+
self.register = json.load(f)
|
|
36
39
|
except:
|
|
37
40
|
# If the file is corrupted, delete it and load again
|
|
38
41
|
os.remove(file)
|
|
@@ -44,75 +47,45 @@ class DataBaseDicom():
|
|
|
44
47
|
def read(self):
|
|
45
48
|
"""Read the DICOM folder again
|
|
46
49
|
"""
|
|
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()
|
|
50
|
+
self.register = dbdatabase.read(self.path)
|
|
58
51
|
# For now ensure all series have just a single CIOD
|
|
59
|
-
|
|
52
|
+
# Leaving this out for now until the issue occurs again
|
|
53
|
+
# self._split_series()
|
|
60
54
|
return self
|
|
61
|
-
|
|
62
55
|
|
|
63
|
-
|
|
64
|
-
"""Close the DICOM folder
|
|
65
|
-
|
|
66
|
-
This also saves changes in the header file to disk.
|
|
67
|
-
"""
|
|
56
|
+
|
|
68
57
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
created = created[created].index
|
|
72
|
-
removed = removed[removed].index
|
|
58
|
+
def delete(self, entity):
|
|
59
|
+
"""Delete a DICOM entity from the database
|
|
73
60
|
|
|
61
|
+
Args:
|
|
62
|
+
entity (list): entity to delete
|
|
63
|
+
"""
|
|
64
|
+
removed = register.index(self.register, entity)
|
|
74
65
|
# delete datasets marked for removal
|
|
75
66
|
for index in removed.tolist():
|
|
76
67
|
file = os.path.join(self.path, index)
|
|
77
68
|
if os.path.exists(file):
|
|
78
69
|
os.remove(file)
|
|
79
70
|
# and drop then from the register
|
|
80
|
-
self.register.drop(
|
|
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)
|
|
71
|
+
self.register = register.drop(removed)
|
|
88
72
|
return self
|
|
89
73
|
|
|
90
74
|
|
|
91
|
-
def
|
|
92
|
-
"""
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
75
|
+
def close(self):
|
|
76
|
+
"""Close the DICOM folder
|
|
77
|
+
|
|
78
|
+
This also saves changes in the header file to disk.
|
|
79
|
+
"""
|
|
80
|
+
# Save df as pkl
|
|
112
81
|
file = self._register_file()
|
|
113
|
-
|
|
114
|
-
|
|
82
|
+
with open(file, 'w') as f:
|
|
83
|
+
json.dump(self.register, f, indent=4)
|
|
84
|
+
return self
|
|
115
85
|
|
|
86
|
+
def _register_file(self):
|
|
87
|
+
return os.path.join(self.path, 'dbtree.json')
|
|
88
|
+
|
|
116
89
|
|
|
117
90
|
def summary(self):
|
|
118
91
|
"""Return a summary of the contents of the database.
|
|
@@ -122,6 +95,7 @@ class DataBaseDicom():
|
|
|
122
95
|
"""
|
|
123
96
|
return register.summary(self.register)
|
|
124
97
|
|
|
98
|
+
|
|
125
99
|
def print(self):
|
|
126
100
|
"""Print the contents of the DICOM folder
|
|
127
101
|
"""
|
|
@@ -170,6 +144,11 @@ class DataBaseDicom():
|
|
|
170
144
|
for patient in self.patients():
|
|
171
145
|
studies += self.studies(patient, name, contains, isin)
|
|
172
146
|
return studies
|
|
147
|
+
elif len(entity)==1:
|
|
148
|
+
studies = []
|
|
149
|
+
for patient in self.patients():
|
|
150
|
+
studies += self.studies(patient, name, contains, isin)
|
|
151
|
+
return studies
|
|
173
152
|
else:
|
|
174
153
|
return register.studies(self.register, entity, name, contains, isin)
|
|
175
154
|
|
|
@@ -199,6 +178,11 @@ class DataBaseDicom():
|
|
|
199
178
|
for study in self.studies(entity):
|
|
200
179
|
series += self.series(study, name, contains, isin)
|
|
201
180
|
return series
|
|
181
|
+
elif len(entity)==1:
|
|
182
|
+
series = []
|
|
183
|
+
for study in self.studies(entity):
|
|
184
|
+
series += self.series(study, name, contains, isin)
|
|
185
|
+
return series
|
|
202
186
|
elif len(entity)==2:
|
|
203
187
|
series = []
|
|
204
188
|
for study in self.studies(entity):
|
|
@@ -208,20 +192,20 @@ class DataBaseDicom():
|
|
|
208
192
|
return register.series(self.register, entity, name, contains, isin)
|
|
209
193
|
|
|
210
194
|
|
|
211
|
-
def volume(self, series:list, dims:list=None
|
|
195
|
+
def volume(self, series:list, dims:list=None) -> vreg.Volume3D:
|
|
212
196
|
"""Read a vreg.Volume3D from a DICOM series
|
|
213
197
|
|
|
214
198
|
Args:
|
|
215
199
|
series (list): DICOM series to read
|
|
216
200
|
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
201
|
|
|
221
202
|
Returns:
|
|
222
203
|
vreg.Volume3D: vole read from the series.
|
|
223
204
|
"""
|
|
224
|
-
|
|
205
|
+
if isinstance(series, str): # path to folder
|
|
206
|
+
return [self.volume(s, dims) for s in self.series(series)]
|
|
207
|
+
if len(series) < 4: # folder, patient or study
|
|
208
|
+
return [self.volume(s, dims) for s in self.series(series)]
|
|
225
209
|
if dims is None:
|
|
226
210
|
dims = []
|
|
227
211
|
elif isinstance(dims, str):
|
|
@@ -238,7 +222,7 @@ class DataBaseDicom():
|
|
|
238
222
|
for f in tqdm(files, desc='Reading volume..'):
|
|
239
223
|
ds = dbdataset.read_dataset(f)
|
|
240
224
|
values.append(dbdataset.get_values(ds, dims))
|
|
241
|
-
volumes.append(dbdataset.volume(ds
|
|
225
|
+
volumes.append(dbdataset.volume(ds))
|
|
242
226
|
|
|
243
227
|
# Format as mesh
|
|
244
228
|
coords = np.stack(values, axis=-1)
|
|
@@ -256,9 +240,21 @@ class DataBaseDicom():
|
|
|
256
240
|
"firstslice=True, the coordinates of the lowest "
|
|
257
241
|
"slice will be assigned to the whole volume."
|
|
258
242
|
)
|
|
243
|
+
|
|
244
|
+
# Infer spacing between slices from slice locations
|
|
245
|
+
# Technically only necessary if SpacingBetweenSlices not set or incorrect
|
|
246
|
+
vols = infer_slice_spacing(vols)
|
|
259
247
|
|
|
260
248
|
# Join 2D volumes into 3D volumes
|
|
261
|
-
|
|
249
|
+
try:
|
|
250
|
+
vol = vreg.join(vols)
|
|
251
|
+
except ValueError:
|
|
252
|
+
# some vendors define the slice vector as -cross product
|
|
253
|
+
# of row and column vector. Check if that solves the issue.
|
|
254
|
+
for v in vols.reshape(-1):
|
|
255
|
+
v.affine[:3,2] = -v.affine[:3,2]
|
|
256
|
+
# Then try again
|
|
257
|
+
vol = vreg.join(vols)
|
|
262
258
|
if vol.ndim > 3:
|
|
263
259
|
vol.set_coords(c0)
|
|
264
260
|
vol.set_dims(dims[1:])
|
|
@@ -266,21 +262,21 @@ class DataBaseDicom():
|
|
|
266
262
|
|
|
267
263
|
|
|
268
264
|
def write_volume(
|
|
269
|
-
self, vol:vreg.Volume3D, series:list,
|
|
270
|
-
ref:list=None,
|
|
265
|
+
self, vol:Union[vreg.Volume3D, tuple], series:list,
|
|
266
|
+
ref:list=None,
|
|
271
267
|
):
|
|
272
268
|
"""Write a vreg.Volume3D to a DICOM series
|
|
273
269
|
|
|
274
270
|
Args:
|
|
275
271
|
vol (vreg.Volume3D): Volume to write to the series.
|
|
276
272
|
series (list): DICOM series to read
|
|
277
|
-
|
|
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.
|
|
273
|
+
ref (list): Reference series
|
|
281
274
|
"""
|
|
275
|
+
if isinstance(vol, tuple):
|
|
276
|
+
vol = vreg.volume(vol[0], vol[1])
|
|
282
277
|
if ref is None:
|
|
283
278
|
ds = dbdataset.new_dataset('MRImage')
|
|
279
|
+
#ds = dbdataset.new_dataset('ParametricMap')
|
|
284
280
|
else:
|
|
285
281
|
if ref[0] == series[0]:
|
|
286
282
|
ref_mgr = self
|
|
@@ -293,26 +289,24 @@ class DataBaseDicom():
|
|
|
293
289
|
attr = self._attributes(series)
|
|
294
290
|
n = self._max_instance_number(attr['SeriesInstanceUID'])
|
|
295
291
|
|
|
296
|
-
new_instances = {}
|
|
297
292
|
if vol.ndim==3:
|
|
298
293
|
slices = vol.split()
|
|
299
294
|
for i, sl in tqdm(enumerate(slices), desc='Writing volume..'):
|
|
300
|
-
dbdataset.set_volume(ds, sl
|
|
301
|
-
self._write_dataset(ds, attr, n + 1 + i
|
|
295
|
+
dbdataset.set_volume(ds, sl)
|
|
296
|
+
self._write_dataset(ds, attr, n + 1 + i)
|
|
302
297
|
else:
|
|
303
298
|
i=0
|
|
304
299
|
vols = vol.separate().reshape(-1)
|
|
305
300
|
for vt in tqdm(vols, desc='Writing volume..'):
|
|
306
301
|
for sl in vt.split():
|
|
307
|
-
dbdataset.set_volume(ds, sl
|
|
302
|
+
dbdataset.set_volume(ds, sl)
|
|
308
303
|
dbdataset.set_value(ds, sl.dims, sl.coords[:,...])
|
|
309
|
-
self._write_dataset(ds, attr, n + 1 + i
|
|
304
|
+
self._write_dataset(ds, attr, n + 1 + i)
|
|
310
305
|
i+=1
|
|
311
|
-
|
|
312
|
-
self._update_register(new_instances)
|
|
306
|
+
return self
|
|
313
307
|
|
|
314
308
|
|
|
315
|
-
def to_nifti(self, series:list, file:str, dims=None
|
|
309
|
+
def to_nifti(self, series:list, file:str, dims=None):
|
|
316
310
|
"""Save a DICOM series in nifti format.
|
|
317
311
|
|
|
318
312
|
Args:
|
|
@@ -320,34 +314,31 @@ class DataBaseDicom():
|
|
|
320
314
|
file (str): file path of the nifti file.
|
|
321
315
|
dims (list, optional): Non-spatial dimensions of the volume.
|
|
322
316
|
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
317
|
"""
|
|
327
|
-
vol = self.volume(series, dims
|
|
318
|
+
vol = self.volume(series, dims)
|
|
328
319
|
vreg.write_nifti(vol, file)
|
|
329
320
|
return self
|
|
330
321
|
|
|
331
|
-
def from_nifti(self, file:str, series:list, ref:list=None
|
|
322
|
+
def from_nifti(self, file:str, series:list, ref:list=None):
|
|
332
323
|
"""Create a DICOM series from a nifti file.
|
|
333
324
|
|
|
334
325
|
Args:
|
|
335
326
|
file (str): file path of the nifti file.
|
|
336
327
|
series (list): DICOM series to create
|
|
337
328
|
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
329
|
"""
|
|
342
330
|
vol = vreg.read_nifti(file)
|
|
343
|
-
self.write_volume(vol, series, ref
|
|
331
|
+
self.write_volume(vol, series, ref)
|
|
344
332
|
return self
|
|
345
333
|
|
|
346
334
|
def pixel_data(self, series:list, dims:list=None, coords=False, include=None) -> np.ndarray:
|
|
347
335
|
"""Read the pixel data from a DICOM series
|
|
348
336
|
|
|
349
337
|
Args:
|
|
350
|
-
series (list): DICOM series to read
|
|
338
|
+
series (list or str): DICOM series to read. This can also
|
|
339
|
+
be a path to a folder containing DICOM files, or a
|
|
340
|
+
patient or study to read all series in that patient or
|
|
341
|
+
study. In those cases a list is returned.
|
|
351
342
|
dims (list, optional): Dimensions of the array.
|
|
352
343
|
coords (bool): If set to Trye, the coordinates of the
|
|
353
344
|
arrays are returned alongside the pixel data
|
|
@@ -360,8 +351,12 @@ class DataBaseDicom():
|
|
|
360
351
|
coords is set these are returned too as an array with
|
|
361
352
|
coordinates of the slices according to dims. If include
|
|
362
353
|
is provide the values are returned as a dictionary in the last
|
|
363
|
-
return value.
|
|
354
|
+
return value.
|
|
364
355
|
"""
|
|
356
|
+
if isinstance(series, str): # path to folder
|
|
357
|
+
return [self.pixel_data(s, dims, coords, include) for s in self.series(series)]
|
|
358
|
+
if len(series) < 4: # folder, patient or study
|
|
359
|
+
return [self.pixel_data(s, dims, coords, include) for s in self.series(series)]
|
|
365
360
|
if coords:
|
|
366
361
|
if dims is None:
|
|
367
362
|
raise ValueError(
|
|
@@ -488,16 +483,6 @@ class DataBaseDicom():
|
|
|
488
483
|
f"Cannot copy {from_entity} to {to_entity}. "
|
|
489
484
|
)
|
|
490
485
|
|
|
491
|
-
def delete(self, entity):
|
|
492
|
-
"""Delete a DICOM entity from the database
|
|
493
|
-
|
|
494
|
-
Args:
|
|
495
|
-
entity (list): entity to delete
|
|
496
|
-
"""
|
|
497
|
-
index = register.index(self.register, entity)
|
|
498
|
-
self.register.loc[index,'removed'] = True
|
|
499
|
-
return self
|
|
500
|
-
|
|
501
486
|
def move(self, from_entity, to_entity):
|
|
502
487
|
"""Move a DICOM entity
|
|
503
488
|
|
|
@@ -510,15 +495,15 @@ class DataBaseDicom():
|
|
|
510
495
|
|
|
511
496
|
def _values(self, attributes:list, entity:list):
|
|
512
497
|
# Create a np array v with values for each instance and attribute
|
|
513
|
-
if set(attributes) <= set(
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
else:
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
498
|
+
# if set(attributes) <= set(dbdatabase.COLUMNS):
|
|
499
|
+
# index = register.index(self.register, entity)
|
|
500
|
+
# v = self.register.loc[index, attributes].values
|
|
501
|
+
# else:
|
|
502
|
+
files = register.files(self.register, entity)
|
|
503
|
+
v = np.empty((len(files), len(attributes)), dtype=object)
|
|
504
|
+
for i, f in enumerate(files):
|
|
505
|
+
ds = dbdataset.read_dataset(f)
|
|
506
|
+
v[i,:] = dbdataset.get_values(ds, attributes)
|
|
522
507
|
return v
|
|
523
508
|
|
|
524
509
|
def _copy_patient(self, from_patient, to_patient):
|
|
@@ -562,30 +547,34 @@ class DataBaseDicom():
|
|
|
562
547
|
n = self._max_instance_number(attr['SeriesInstanceUID'])
|
|
563
548
|
|
|
564
549
|
# Copy the files to the new series
|
|
565
|
-
new_instances = {}
|
|
566
550
|
for i, f in tqdm(enumerate(files), total=len(files), desc=f'Copying series {to_series[1:]}'):
|
|
567
551
|
# Read dataset and assign new properties
|
|
568
552
|
ds = dbdataset.read_dataset(f)
|
|
569
|
-
self._write_dataset(ds, attr, n + 1 + i
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
553
|
+
self._write_dataset(ds, attr, n + 1 + i)
|
|
554
|
+
|
|
555
|
+
def _max_study_id(self, patient_id):
|
|
556
|
+
for pt in self.register:
|
|
557
|
+
if pt['PatientID'] == patient_id:
|
|
558
|
+
n = [int(st['StudyID']) for st in pt['studies']]
|
|
559
|
+
return int(np.amax(n))
|
|
560
|
+
return 0
|
|
561
|
+
|
|
573
562
|
def _max_series_number(self, study_uid):
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
return
|
|
563
|
+
for pt in self.register:
|
|
564
|
+
for st in pt['studies']:
|
|
565
|
+
if st['StudyInstanceUID'] == study_uid:
|
|
566
|
+
n = [sr['SeriesNumber'] for sr in st['series']]
|
|
567
|
+
return int(np.amax(n))
|
|
568
|
+
return 0
|
|
580
569
|
|
|
581
570
|
def _max_instance_number(self, series_uid):
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
571
|
+
for pt in self.register:
|
|
572
|
+
for st in pt['studies']:
|
|
573
|
+
for sr in st['series']:
|
|
574
|
+
if sr['SeriesInstanceUID'] == series_uid:
|
|
575
|
+
n = list(sr['instances'].keys())
|
|
576
|
+
return int(np.amax([int(i) for i in n]))
|
|
577
|
+
return 0
|
|
589
578
|
|
|
590
579
|
def _attributes(self, entity):
|
|
591
580
|
if len(entity)==4:
|
|
@@ -606,7 +595,8 @@ class DataBaseDicom():
|
|
|
606
595
|
except:
|
|
607
596
|
# If the patient does not exist, generate values
|
|
608
597
|
attr = ['PatientID', 'PatientName']
|
|
609
|
-
patient_id = dbdataset.new_uid()
|
|
598
|
+
#patient_id = dbdataset.new_uid()
|
|
599
|
+
patient_id = patient[-1] if isinstance(patient[-1], str) else f"{patient[-1][0]}_{patient[-1][1]}"
|
|
610
600
|
patient_name = patient[-1] if isinstance(patient[-1], str) else patient[-1][0]
|
|
611
601
|
vals = [patient_id, patient_name]
|
|
612
602
|
return {attr[i]:vals[i] for i in range(len(attr)) if vals[i] is not None}
|
|
@@ -621,12 +611,18 @@ class DataBaseDicom():
|
|
|
621
611
|
ds = dbdataset.read_dataset(files[0])
|
|
622
612
|
vals = dbdataset.get_values(ds, attr)
|
|
623
613
|
except:
|
|
624
|
-
# If the study does not exist, generate values
|
|
625
|
-
|
|
626
|
-
|
|
614
|
+
# If the study does not exist or is empty, generate values
|
|
615
|
+
try:
|
|
616
|
+
patient_id = register.uid(self.register, study[:-1])
|
|
617
|
+
except:
|
|
618
|
+
study_id = 1
|
|
619
|
+
else:
|
|
620
|
+
study_id = 1 + self._max_study_id(patient_id)
|
|
621
|
+
attr = ['StudyInstanceUID', 'StudyDescription', 'StudyDate', 'StudyID']
|
|
622
|
+
study_uid = dbdataset.new_uid()
|
|
627
623
|
study_desc = study[-1] if isinstance(study[-1], str) else study[-1][0]
|
|
628
624
|
study_date = datetime.today().strftime('%Y%m%d')
|
|
629
|
-
vals = [
|
|
625
|
+
vals = [study_uid, study_desc, study_date, str(study_id)]
|
|
630
626
|
return patient_attr | {attr[i]:vals[i] for i in range(len(attr)) if vals[i] is not None}
|
|
631
627
|
|
|
632
628
|
|
|
@@ -647,90 +643,114 @@ class DataBaseDicom():
|
|
|
647
643
|
else:
|
|
648
644
|
series_number = 1 + self._max_series_number(study_uid)
|
|
649
645
|
attr = ['SeriesInstanceUID', 'SeriesDescription', 'SeriesNumber']
|
|
650
|
-
|
|
646
|
+
series_uid = dbdataset.new_uid()
|
|
651
647
|
series_desc = series[-1] if isinstance(series[-1], str) else series[-1][0]
|
|
652
|
-
vals = [
|
|
648
|
+
vals = [series_uid, series_desc, int(series_number)]
|
|
653
649
|
return study_attr | {attr[i]:vals[i] for i in range(len(attr)) if vals[i] is not None}
|
|
654
650
|
|
|
655
651
|
|
|
656
|
-
def _write_dataset(self, ds:Dataset, attr:dict, instance_nr:int
|
|
652
|
+
def _write_dataset(self, ds:Dataset, attr:dict, instance_nr:int):
|
|
657
653
|
# Set new attributes
|
|
658
654
|
attr['SOPInstanceUID'] = dbdataset.new_uid()
|
|
659
|
-
attr['InstanceNumber'] = instance_nr
|
|
655
|
+
attr['InstanceNumber'] = str(instance_nr)
|
|
660
656
|
dbdataset.set_values(ds, list(attr.keys()), list(attr.values()))
|
|
661
657
|
# Save results in a new file
|
|
662
|
-
|
|
658
|
+
rel_dir = os.path.join(
|
|
659
|
+
f"patient_{attr['PatientID']}",
|
|
660
|
+
f"study_[{attr['StudyID']}]_{attr['StudyDescription']}",
|
|
661
|
+
f"series_[{attr['SeriesNumber']}]_{attr['SeriesDescription']}",
|
|
662
|
+
)
|
|
663
|
+
os.makedirs(os.path.join(self.path, rel_dir), exist_ok=True)
|
|
664
|
+
rel_path = os.path.join(rel_dir, dbdataset.new_uid() + '.dcm')
|
|
663
665
|
dbdataset.write(ds, os.path.join(self.path, rel_path))
|
|
664
|
-
# Add
|
|
665
|
-
register
|
|
666
|
-
|
|
666
|
+
# Add an entry in the register
|
|
667
|
+
register.add_instance(self.register, attr, rel_path)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
# def _split_series(self):
|
|
672
|
+
# """
|
|
673
|
+
# Split series with multiple SOP Classes.
|
|
674
|
+
|
|
675
|
+
# If a series contain instances from different SOP Classes,
|
|
676
|
+
# these are separated out into multiple series with identical SOP Classes.
|
|
677
|
+
# """
|
|
678
|
+
# # For each series, check if there are multiple
|
|
679
|
+
# # SOP Classes in the series and split them if yes.
|
|
680
|
+
# for series in self.series():
|
|
681
|
+
# series_index = register.index(self.register, series)
|
|
682
|
+
# df_series = self.register.loc[series_index]
|
|
683
|
+
# sop_classes = df_series.SOPClassUID.unique()
|
|
684
|
+
# if len(sop_classes) > 1:
|
|
685
|
+
# # For each sop_class, create a new series and move all
|
|
686
|
+
# # instances of that sop_class to the new series
|
|
687
|
+
# desc = series[-1] if isinstance(series, str) else series[0]
|
|
688
|
+
# for i, sop_class in tqdm(enumerate(sop_classes[1:]), desc='Splitting series with multiple SOP Classes.'):
|
|
689
|
+
# df_sop_class = df_series[df_series.SOPClassUID == sop_class]
|
|
690
|
+
# relpaths = df_sop_class.index.tolist()
|
|
691
|
+
# sop_class_files = [os.path.join(self.path, p) for p in relpaths]
|
|
692
|
+
# sop_class_series = series[:-1] + [desc + f' [{i+1}]']
|
|
693
|
+
# self._files_to_series(sop_class_files, sop_class_series)
|
|
694
|
+
# # Delete original files permanently
|
|
695
|
+
# self.register.drop(relpaths)
|
|
696
|
+
# for f in sop_class_files:
|
|
697
|
+
# os.remove(f)
|
|
698
|
+
# self.register.drop('SOPClassUID', axis=1, inplace=True)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def infer_slice_spacing(vols):
|
|
702
|
+
# In case spacing between slices is not (correctly) encoded in
|
|
703
|
+
# DICOM it can be inferred from the slice locations.
|
|
704
|
+
|
|
705
|
+
shape = vols.shape
|
|
706
|
+
vols = vols.reshape((shape[0], -1))
|
|
707
|
+
slice_spacing = np.zeros(vols.shape[-1])
|
|
708
|
+
|
|
709
|
+
for d in range(vols.shape[-1]):
|
|
710
|
+
|
|
711
|
+
# For single slice volumes there is nothing to do
|
|
712
|
+
if vols[:,d].shape[0]==1:
|
|
713
|
+
continue
|
|
714
|
+
|
|
715
|
+
# Get a normal slice vector from the first volume.
|
|
716
|
+
mat = vols[0,d].affine[:3,:3]
|
|
717
|
+
normal = mat[:,2]/np.linalg.norm(mat[:,2])
|
|
718
|
+
|
|
719
|
+
# Get slice locations by projection on the normal.
|
|
720
|
+
pos = [v.affine[:3,3] for v in vols[:,d]]
|
|
721
|
+
slice_loc = [np.dot(p, normal) for p in pos]
|
|
722
|
+
|
|
723
|
+
# Sort slice locations and take consecutive differences.
|
|
724
|
+
slice_loc = np.sort(slice_loc)
|
|
725
|
+
distances = slice_loc[1:] - slice_loc[:-1]
|
|
726
|
+
|
|
727
|
+
# Round to micrometer and check if unique
|
|
728
|
+
distances = np.around(distances, 3)
|
|
729
|
+
slice_spacing_d = np.unique(distances)
|
|
730
|
+
|
|
731
|
+
# Check if unique - otherwise this is not a volume
|
|
732
|
+
if len(slice_spacing_d) > 1:
|
|
733
|
+
raise ValueError(
|
|
734
|
+
'Cannot build a volume - spacings between slices are not unique.'
|
|
735
|
+
)
|
|
736
|
+
else:
|
|
737
|
+
slice_spacing_d= slice_spacing_d[0]
|
|
738
|
+
|
|
739
|
+
# Set correct slice spacing in all volumes
|
|
740
|
+
for v in vols[:,d]:
|
|
741
|
+
v.affine[:3,2] = normal * abs(slice_spacing_d)
|
|
667
742
|
|
|
668
|
-
|
|
669
|
-
# A new instances to the register
|
|
670
|
-
df = pd.DataFrame.from_dict(new_instances, orient='index', columns=self.register.columns)
|
|
671
|
-
df['removed'] = False
|
|
672
|
-
df['created'] = True
|
|
673
|
-
self.register = pd.concat([self.register, df])
|
|
743
|
+
slice_spacing[d] = slice_spacing_d
|
|
674
744
|
|
|
745
|
+
# Check slice_spacing is the same across dimensions
|
|
746
|
+
slice_spacing = np.unique(slice_spacing)
|
|
747
|
+
if len(slice_spacing) > 1:
|
|
748
|
+
raise ValueError(
|
|
749
|
+
'Cannot build a volume - spacings between slices are not unique.'
|
|
750
|
+
)
|
|
675
751
|
|
|
676
|
-
|
|
677
|
-
filename = os.path.basename(os.path.normpath(self.path)) + ".pkl"
|
|
678
|
-
return os.path.join(self.path, filename)
|
|
679
|
-
|
|
752
|
+
return vols.reshape(shape)
|
|
680
753
|
|
|
681
|
-
def _multiframe_to_singleframe(self):
|
|
682
|
-
"""Converts all multiframe files in the folder into single-frame files.
|
|
683
|
-
|
|
684
|
-
Reads all the multi-frame files in the folder,
|
|
685
|
-
converts them to singleframe files, and delete the original multiframe file.
|
|
686
|
-
"""
|
|
687
|
-
singleframe = self.register.NumberOfFrames.isnull()
|
|
688
|
-
multiframe = singleframe == False
|
|
689
|
-
nr_multiframe = multiframe.sum()
|
|
690
|
-
if nr_multiframe != 0:
|
|
691
|
-
for relpath in tqdm(self.register[multiframe].index.values, desc="Converting multiframe file " + relpath):
|
|
692
|
-
filepath = os.path.join(self.path, relpath)
|
|
693
|
-
singleframe_files = dcm4che.split_multiframe(filepath)
|
|
694
|
-
if singleframe_files != []:
|
|
695
|
-
# add the single frame files to the dataframe
|
|
696
|
-
df = dbdataset.read_dataframe(singleframe_files, self.register.columns, path=self.path)
|
|
697
|
-
df['removed'] = False
|
|
698
|
-
df['created'] = False
|
|
699
|
-
self.register = pd.concat([self.register, df])
|
|
700
|
-
# delete the original multiframe
|
|
701
|
-
os.remove(filepath)
|
|
702
|
-
# drop the file also if the conversion has failed
|
|
703
|
-
self.register.drop(index=relpath, inplace=True)
|
|
704
|
-
self.register.drop('NumberOfFrames', axis=1, inplace=True)
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
def _split_series(self):
|
|
708
|
-
"""
|
|
709
|
-
Split series with multiple SOP Classes.
|
|
710
754
|
|
|
711
|
-
If a series contain instances from different SOP Classes,
|
|
712
|
-
these are separated out into multiple series with identical SOP Classes.
|
|
713
|
-
"""
|
|
714
|
-
# For each series, check if there are multiple
|
|
715
|
-
# SOP Classes in the series and split them if yes.
|
|
716
|
-
for series in self.series():
|
|
717
|
-
series_index = register.index(self.register, series)
|
|
718
|
-
df_series = self.register.loc[series_index]
|
|
719
|
-
sop_classes = df_series.SOPClassUID.unique()
|
|
720
|
-
if len(sop_classes) > 1:
|
|
721
|
-
# For each sop_class, create a new series and move all
|
|
722
|
-
# instances of that sop_class to the new series
|
|
723
|
-
desc = series[-1] if isinstance(series, str) else series[0]
|
|
724
|
-
for i, sop_class in tqdm(enumerate(sop_classes[1:]), desc='Splitting series with multiple SOP Classes.'):
|
|
725
|
-
df_sop_class = df_series[df_series.SOPClassUID == sop_class]
|
|
726
|
-
relpaths = df_sop_class.index.tolist()
|
|
727
|
-
sop_class_files = [os.path.join(self.path, p) for p in relpaths]
|
|
728
|
-
sop_class_series = series[:-1] + [desc + f' [{i+1}]']
|
|
729
|
-
self._files_to_series(sop_class_files, sop_class_series)
|
|
730
|
-
# Delete original files permanently
|
|
731
|
-
self.register.drop(relpaths)
|
|
732
|
-
for f in sop_class_files:
|
|
733
|
-
os.remove(f)
|
|
734
|
-
self.register.drop('SOPClassUID', axis=1, inplace=True)
|
|
735
755
|
|
|
736
756
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|