dbdicom 0.3.2__py3-none-any.whl → 0.3.4__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 +46 -36
- dbdicom/database.py +4 -0
- dbdicom/dataset.py +19 -26
- dbdicom/dbd.py +251 -119
- 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 +146 -404
- dbdicom/sop_classes/mr_image.py +34 -13
- dbdicom/utils/image.py +3 -4
- {dbdicom-0.3.2.dist-info → dbdicom-0.3.4.dist-info}/METADATA +1 -1
- {dbdicom-0.3.2.dist-info → dbdicom-0.3.4.dist-info}/RECORD +15 -15
- {dbdicom-0.3.2.dist-info → dbdicom-0.3.4.dist-info}/WHEEL +0 -0
- {dbdicom-0.3.2.dist-info → dbdicom-0.3.4.dist-info}/licenses/LICENSE +0 -0
- {dbdicom-0.3.2.dist-info → dbdicom-0.3.4.dist-info}/top_level.txt +0 -0
dbdicom/dbd.py
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import os
|
|
2
2
|
from datetime import datetime
|
|
3
3
|
import json
|
|
4
|
+
from typing import Union
|
|
4
5
|
|
|
5
6
|
from tqdm import tqdm
|
|
6
7
|
import numpy as np
|
|
7
|
-
import pandas as pd
|
|
8
8
|
import vreg
|
|
9
9
|
from pydicom.dataset import Dataset
|
|
10
10
|
|
|
11
11
|
import dbdicom.utils.arrays
|
|
12
|
-
|
|
13
12
|
import dbdicom.dataset as dbdataset
|
|
14
13
|
import dbdicom.database as dbdatabase
|
|
15
14
|
import dbdicom.register as register
|
|
@@ -35,8 +34,13 @@ class DataBaseDicom():
|
|
|
35
34
|
try:
|
|
36
35
|
with open(file, 'r') as f:
|
|
37
36
|
self.register = json.load(f)
|
|
37
|
+
# remove the json file after reading it. If the database
|
|
38
|
+
# is not properly closed this will prevent that changes
|
|
39
|
+
# have been made which are not reflected in the json
|
|
40
|
+
# file on disk
|
|
41
|
+
os.remove(file)
|
|
38
42
|
except:
|
|
39
|
-
# If the file
|
|
43
|
+
# If the file can't be read, delete it and load again
|
|
40
44
|
os.remove(file)
|
|
41
45
|
self.read()
|
|
42
46
|
else:
|
|
@@ -48,7 +52,7 @@ class DataBaseDicom():
|
|
|
48
52
|
"""
|
|
49
53
|
self.register = dbdatabase.read(self.path)
|
|
50
54
|
# For now ensure all series have just a single CIOD
|
|
51
|
-
# Leaving this out for now until the issue occurs again
|
|
55
|
+
# Leaving this out for now until the issue occurs again.
|
|
52
56
|
# self._split_series()
|
|
53
57
|
return self
|
|
54
58
|
|
|
@@ -62,12 +66,12 @@ class DataBaseDicom():
|
|
|
62
66
|
"""
|
|
63
67
|
removed = register.index(self.register, entity)
|
|
64
68
|
# delete datasets marked for removal
|
|
65
|
-
for index in removed
|
|
69
|
+
for index in removed:
|
|
66
70
|
file = os.path.join(self.path, index)
|
|
67
71
|
if os.path.exists(file):
|
|
68
72
|
os.remove(file)
|
|
69
73
|
# and drop then from the register
|
|
70
|
-
self.register = register.drop(removed)
|
|
74
|
+
self.register = register.drop(self.register, removed)
|
|
71
75
|
return self
|
|
72
76
|
|
|
73
77
|
|
|
@@ -76,7 +80,6 @@ class DataBaseDicom():
|
|
|
76
80
|
|
|
77
81
|
This also saves changes in the header file to disk.
|
|
78
82
|
"""
|
|
79
|
-
# Save df as pkl
|
|
80
83
|
file = self._register_file()
|
|
81
84
|
with open(file, 'w') as f:
|
|
82
85
|
json.dump(self.register, f, indent=4)
|
|
@@ -118,14 +121,14 @@ class DataBaseDicom():
|
|
|
118
121
|
"""
|
|
119
122
|
return register.patients(self.register, self.path, name, contains, isin)
|
|
120
123
|
|
|
121
|
-
def studies(self, entity=None,
|
|
124
|
+
def studies(self, entity=None, desc=None, contains=None, isin=None):
|
|
122
125
|
"""Return a list of studies in the DICOM folder.
|
|
123
126
|
|
|
124
127
|
Args:
|
|
125
128
|
entity (str or list): path to a DICOM folder (to search in
|
|
126
129
|
the whole folder), or a two-element list identifying a
|
|
127
130
|
patient (to search studies of a given patient).
|
|
128
|
-
|
|
131
|
+
desc (str, optional): value of StudyDescription, to search for
|
|
129
132
|
studies with a given description. Defaults to None.
|
|
130
133
|
contains (str, optional): substring of StudyDescription, to
|
|
131
134
|
search for studies based on part of their description.
|
|
@@ -141,12 +144,17 @@ class DataBaseDicom():
|
|
|
141
144
|
if isinstance(entity, str):
|
|
142
145
|
studies = []
|
|
143
146
|
for patient in self.patients():
|
|
144
|
-
studies += self.studies(patient,
|
|
147
|
+
studies += self.studies(patient, desc, contains, isin)
|
|
148
|
+
return studies
|
|
149
|
+
elif len(entity)==1:
|
|
150
|
+
studies = []
|
|
151
|
+
for patient in self.patients():
|
|
152
|
+
studies += self.studies(patient, desc, contains, isin)
|
|
145
153
|
return studies
|
|
146
154
|
else:
|
|
147
|
-
return register.studies(self.register, entity,
|
|
155
|
+
return register.studies(self.register, entity, desc, contains, isin)
|
|
148
156
|
|
|
149
|
-
def series(self, entity=None,
|
|
157
|
+
def series(self, entity=None, desc=None, contains=None, isin=None):
|
|
150
158
|
"""Return a list of series in the DICOM folder.
|
|
151
159
|
|
|
152
160
|
Args:
|
|
@@ -154,7 +162,7 @@ class DataBaseDicom():
|
|
|
154
162
|
the whole folder), or a list identifying a
|
|
155
163
|
patient or a study (to search series of a given patient
|
|
156
164
|
or study).
|
|
157
|
-
|
|
165
|
+
desc (str, optional): value of SeriesDescription, to search for
|
|
158
166
|
series with a given description. Defaults to None.
|
|
159
167
|
contains (str, optional): substring of SeriesDescription, to
|
|
160
168
|
search for series based on part of their description.
|
|
@@ -170,31 +178,37 @@ class DataBaseDicom():
|
|
|
170
178
|
if isinstance(entity, str):
|
|
171
179
|
series = []
|
|
172
180
|
for study in self.studies(entity):
|
|
173
|
-
series += self.series(study,
|
|
181
|
+
series += self.series(study, desc, contains, isin)
|
|
174
182
|
return series
|
|
183
|
+
elif len(entity)==1:
|
|
184
|
+
series = []
|
|
185
|
+
for study in self.studies(entity):
|
|
186
|
+
series += self.series(study, desc, contains, isin)
|
|
187
|
+
return series
|
|
175
188
|
elif len(entity)==2:
|
|
176
189
|
series = []
|
|
177
190
|
for study in self.studies(entity):
|
|
178
|
-
series += self.series(study,
|
|
191
|
+
series += self.series(study, desc, contains, isin)
|
|
179
192
|
return series
|
|
180
193
|
else: # path = None (all series) or path = patient (all series in patient)
|
|
181
|
-
return register.series(self.register, entity,
|
|
194
|
+
return register.series(self.register, entity, desc, contains, isin)
|
|
182
195
|
|
|
183
196
|
|
|
184
|
-
def volume(self,
|
|
185
|
-
"""Read
|
|
197
|
+
def volume(self, entity:Union[list, str], dims:list=None) -> Union[vreg.Volume3D, list]:
|
|
198
|
+
"""Read volume or volumes.
|
|
186
199
|
|
|
187
200
|
Args:
|
|
188
|
-
|
|
201
|
+
entity (list, str): DICOM entity to read
|
|
189
202
|
dims (list, optional): Non-spatial dimensions of the volume. Defaults to None.
|
|
190
|
-
multislice (bool, optional): Whether the data are to be read
|
|
191
|
-
as multislice or not. In multislice data the voxel size
|
|
192
|
-
is taken from the slice gap rather thsan the slice thickness. Defaults to False.
|
|
193
203
|
|
|
194
204
|
Returns:
|
|
195
|
-
vreg.Volume3D
|
|
205
|
+
vreg.Volume3D | list: If the entity is a series this returns
|
|
206
|
+
a volume, else a list of volumes.
|
|
196
207
|
"""
|
|
197
|
-
|
|
208
|
+
if isinstance(entity, str): # path to folder
|
|
209
|
+
return [self.volume(s, dims) for s in self.series(entity)]
|
|
210
|
+
if len(entity) < 4: # folder, patient or study
|
|
211
|
+
return [self.volume(s, dims) for s in self.series(entity)]
|
|
198
212
|
if dims is None:
|
|
199
213
|
dims = []
|
|
200
214
|
elif isinstance(dims, str):
|
|
@@ -203,15 +217,15 @@ class DataBaseDicom():
|
|
|
203
217
|
dims = list(dims)
|
|
204
218
|
dims = ['SliceLocation'] + dims
|
|
205
219
|
|
|
206
|
-
files = register.files(self.register,
|
|
220
|
+
files = register.files(self.register, entity)
|
|
207
221
|
|
|
208
222
|
# Read dicom files
|
|
209
223
|
values = []
|
|
210
224
|
volumes = []
|
|
211
225
|
for f in tqdm(files, desc='Reading volume..'):
|
|
212
|
-
ds = dbdataset.read_dataset(f)
|
|
226
|
+
ds = dbdataset.read_dataset(f)
|
|
213
227
|
values.append(dbdataset.get_values(ds, dims))
|
|
214
|
-
volumes.append(dbdataset.volume(ds
|
|
228
|
+
volumes.append(dbdataset.volume(ds))
|
|
215
229
|
|
|
216
230
|
# Format as mesh
|
|
217
231
|
coords = np.stack(values, axis=-1)
|
|
@@ -229,9 +243,21 @@ class DataBaseDicom():
|
|
|
229
243
|
"firstslice=True, the coordinates of the lowest "
|
|
230
244
|
"slice will be assigned to the whole volume."
|
|
231
245
|
)
|
|
246
|
+
|
|
247
|
+
# Infer spacing between slices from slice locations
|
|
248
|
+
# Technically only necessary if SpacingBetweenSlices not set or incorrect
|
|
249
|
+
vols = infer_slice_spacing(vols)
|
|
232
250
|
|
|
233
251
|
# Join 2D volumes into 3D volumes
|
|
234
|
-
|
|
252
|
+
try:
|
|
253
|
+
vol = vreg.join(vols)
|
|
254
|
+
except ValueError:
|
|
255
|
+
# some vendors define the slice vector as -cross product
|
|
256
|
+
# of row and column vector. Check if that solves the issue.
|
|
257
|
+
for v in vols.reshape(-1):
|
|
258
|
+
v.affine[:3,2] = -v.affine[:3,2]
|
|
259
|
+
# Then try again
|
|
260
|
+
vol = vreg.join(vols)
|
|
235
261
|
if vol.ndim > 3:
|
|
236
262
|
vol.set_coords(c0)
|
|
237
263
|
vol.set_dims(dims[1:])
|
|
@@ -239,19 +265,18 @@ class DataBaseDicom():
|
|
|
239
265
|
|
|
240
266
|
|
|
241
267
|
def write_volume(
|
|
242
|
-
self, vol:vreg.Volume3D, series:list,
|
|
243
|
-
ref:list=None,
|
|
268
|
+
self, vol:Union[vreg.Volume3D, tuple], series:list,
|
|
269
|
+
ref:list=None,
|
|
244
270
|
):
|
|
245
271
|
"""Write a vreg.Volume3D to a DICOM series
|
|
246
272
|
|
|
247
273
|
Args:
|
|
248
274
|
vol (vreg.Volume3D): Volume to write to the series.
|
|
249
275
|
series (list): DICOM series to read
|
|
250
|
-
|
|
251
|
-
multislice (bool, optional): Whether the data are to be read
|
|
252
|
-
as multislice or not. In multislice data the voxel size
|
|
253
|
-
is taken from the slice gap rather than the slice thickness. Defaults to False.
|
|
276
|
+
ref (list): Reference series
|
|
254
277
|
"""
|
|
278
|
+
if isinstance(vol, tuple):
|
|
279
|
+
vol = vreg.volume(vol[0], vol[1])
|
|
255
280
|
if ref is None:
|
|
256
281
|
ds = dbdataset.new_dataset('MRImage')
|
|
257
282
|
#ds = dbdataset.new_dataset('ParametricMap')
|
|
@@ -261,30 +286,31 @@ class DataBaseDicom():
|
|
|
261
286
|
else:
|
|
262
287
|
ref_mgr = DataBaseDicom(ref[0])
|
|
263
288
|
files = register.files(ref_mgr.register, ref)
|
|
289
|
+
ref_mgr.close()
|
|
264
290
|
ds = dbdataset.read_dataset(files[0])
|
|
265
291
|
|
|
266
292
|
# Get the attributes of the destination series
|
|
267
|
-
attr = self.
|
|
293
|
+
attr = self._series_attributes(series)
|
|
268
294
|
n = self._max_instance_number(attr['SeriesInstanceUID'])
|
|
269
295
|
|
|
270
296
|
if vol.ndim==3:
|
|
271
297
|
slices = vol.split()
|
|
272
298
|
for i, sl in tqdm(enumerate(slices), desc='Writing volume..'):
|
|
273
|
-
dbdataset.set_volume(ds, sl
|
|
299
|
+
dbdataset.set_volume(ds, sl)
|
|
274
300
|
self._write_dataset(ds, attr, n + 1 + i)
|
|
275
301
|
else:
|
|
276
302
|
i=0
|
|
277
303
|
vols = vol.separate().reshape(-1)
|
|
278
304
|
for vt in tqdm(vols, desc='Writing volume..'):
|
|
279
305
|
for sl in vt.split():
|
|
280
|
-
dbdataset.set_volume(ds, sl
|
|
306
|
+
dbdataset.set_volume(ds, sl)
|
|
281
307
|
dbdataset.set_value(ds, sl.dims, sl.coords[:,...])
|
|
282
308
|
self._write_dataset(ds, attr, n + 1 + i)
|
|
283
309
|
i+=1
|
|
284
310
|
return self
|
|
285
311
|
|
|
286
312
|
|
|
287
|
-
def to_nifti(self, series:list, file:str, dims=None
|
|
313
|
+
def to_nifti(self, series:list, file:str, dims=None):
|
|
288
314
|
"""Save a DICOM series in nifti format.
|
|
289
315
|
|
|
290
316
|
Args:
|
|
@@ -292,34 +318,31 @@ class DataBaseDicom():
|
|
|
292
318
|
file (str): file path of the nifti file.
|
|
293
319
|
dims (list, optional): Non-spatial dimensions of the volume.
|
|
294
320
|
Defaults to None.
|
|
295
|
-
multislice (bool, optional): Whether the data are to be read
|
|
296
|
-
as multislice or not. In multislice data the voxel size
|
|
297
|
-
is taken from the slice gap rather thaan the slice thickness. Defaults to False.
|
|
298
321
|
"""
|
|
299
|
-
vol = self.volume(series, dims
|
|
322
|
+
vol = self.volume(series, dims)
|
|
300
323
|
vreg.write_nifti(vol, file)
|
|
301
324
|
return self
|
|
302
325
|
|
|
303
|
-
def from_nifti(self, file:str, series:list, ref:list=None
|
|
326
|
+
def from_nifti(self, file:str, series:list, ref:list=None):
|
|
304
327
|
"""Create a DICOM series from a nifti file.
|
|
305
328
|
|
|
306
329
|
Args:
|
|
307
330
|
file (str): file path of the nifti file.
|
|
308
331
|
series (list): DICOM series to create
|
|
309
332
|
ref (list): DICOM series to use as template.
|
|
310
|
-
multislice (bool, optional): Whether the data are to be written
|
|
311
|
-
as multislice or not. In multislice data the voxel size
|
|
312
|
-
is written in the slice gap rather thaan the slice thickness. Defaults to False.
|
|
313
333
|
"""
|
|
314
334
|
vol = vreg.read_nifti(file)
|
|
315
|
-
self.write_volume(vol, series, ref
|
|
335
|
+
self.write_volume(vol, series, ref)
|
|
316
336
|
return self
|
|
317
337
|
|
|
318
338
|
def pixel_data(self, series:list, dims:list=None, coords=False, include=None) -> np.ndarray:
|
|
319
339
|
"""Read the pixel data from a DICOM series
|
|
320
340
|
|
|
321
341
|
Args:
|
|
322
|
-
series (list): DICOM series to read
|
|
342
|
+
series (list or str): DICOM series to read. This can also
|
|
343
|
+
be a path to a folder containing DICOM files, or a
|
|
344
|
+
patient or study to read all series in that patient or
|
|
345
|
+
study. In those cases a list is returned.
|
|
323
346
|
dims (list, optional): Dimensions of the array.
|
|
324
347
|
coords (bool): If set to Trye, the coordinates of the
|
|
325
348
|
arrays are returned alongside the pixel data
|
|
@@ -332,8 +355,12 @@ class DataBaseDicom():
|
|
|
332
355
|
coords is set these are returned too as an array with
|
|
333
356
|
coordinates of the slices according to dims. If include
|
|
334
357
|
is provide the values are returned as a dictionary in the last
|
|
335
|
-
return value.
|
|
358
|
+
return value.
|
|
336
359
|
"""
|
|
360
|
+
if isinstance(series, str): # path to folder
|
|
361
|
+
return [self.pixel_data(s, dims, coords, include) for s in self.series(series)]
|
|
362
|
+
if len(series) < 4: # folder, patient or study
|
|
363
|
+
return [self.pixel_data(s, dims, coords, include) for s in self.series(series)]
|
|
337
364
|
if coords:
|
|
338
365
|
if dims is None:
|
|
339
366
|
raise ValueError(
|
|
@@ -399,12 +426,20 @@ class DataBaseDicom():
|
|
|
399
426
|
"""Return a list of unique values for a DICOM entity
|
|
400
427
|
|
|
401
428
|
Args:
|
|
402
|
-
pars (list): attributes to return.
|
|
429
|
+
pars (list, str/tuple): attribute or attributes to return.
|
|
403
430
|
entity (list): DICOM entity to search (Patient, Study or Series)
|
|
404
431
|
|
|
405
432
|
Returns:
|
|
406
|
-
dict:
|
|
433
|
+
dict: if a pars is a list, this returns a dictionary with
|
|
434
|
+
unique values for each attribute. If pars is a scalar
|
|
435
|
+
this returnes a list of values.
|
|
407
436
|
"""
|
|
437
|
+
if not isinstance(pars, list):
|
|
438
|
+
single=True
|
|
439
|
+
pars = [pars]
|
|
440
|
+
else:
|
|
441
|
+
single=False
|
|
442
|
+
|
|
408
443
|
v = self._values(pars, entity)
|
|
409
444
|
|
|
410
445
|
# Return a list with unique values for each attribute
|
|
@@ -416,17 +451,16 @@ class DataBaseDicom():
|
|
|
416
451
|
va = list(va)
|
|
417
452
|
# Get unique values and sort
|
|
418
453
|
va = [x for i, x in enumerate(va) if i==va.index(x)]
|
|
419
|
-
|
|
420
|
-
va
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
else:
|
|
424
|
-
try:
|
|
425
|
-
va.sort()
|
|
426
|
-
except:
|
|
427
|
-
pass
|
|
454
|
+
try:
|
|
455
|
+
va.sort()
|
|
456
|
+
except:
|
|
457
|
+
pass
|
|
428
458
|
values.append(va)
|
|
429
|
-
|
|
459
|
+
|
|
460
|
+
if single:
|
|
461
|
+
return values[0]
|
|
462
|
+
else:
|
|
463
|
+
return {p: values[i] for i, p in enumerate(pars)}
|
|
430
464
|
|
|
431
465
|
def copy(self, from_entity, to_entity):
|
|
432
466
|
"""Copy a DICOM entity (patient, study or series)
|
|
@@ -469,6 +503,40 @@ class DataBaseDicom():
|
|
|
469
503
|
self.copy(from_entity, to_entity)
|
|
470
504
|
self.delete(from_entity)
|
|
471
505
|
return self
|
|
506
|
+
|
|
507
|
+
def split_series(self, series:list, attr:Union[str, tuple]) -> dict:
|
|
508
|
+
"""
|
|
509
|
+
Split a series into multiple series
|
|
510
|
+
|
|
511
|
+
Args:
|
|
512
|
+
series (list): series to split.
|
|
513
|
+
attr (str or tuple): dicom attribute to split the series by.
|
|
514
|
+
Returns:
|
|
515
|
+
dict: dictionary with keys the unique values found (ascending)
|
|
516
|
+
and as values the series corresponding to that value.
|
|
517
|
+
"""
|
|
518
|
+
|
|
519
|
+
# Find all values of the attr and list files per value
|
|
520
|
+
all_files = register.files(self.register, series)
|
|
521
|
+
files = {}
|
|
522
|
+
for f in tqdm(all_files, desc=f'Reading {attr}'):
|
|
523
|
+
ds = dbdataset.read_dataset(f)
|
|
524
|
+
v = dbdataset.get_values(ds, attr)
|
|
525
|
+
if v in files:
|
|
526
|
+
files[v].append(f)
|
|
527
|
+
else:
|
|
528
|
+
files[v] = [f]
|
|
529
|
+
|
|
530
|
+
# Copy the files for each value (sorted) to new series
|
|
531
|
+
values = sorted(list(files.keys()))
|
|
532
|
+
split_series = {}
|
|
533
|
+
for v in tqdm(values, desc='Writing new series'):
|
|
534
|
+
series_desc = series[-1] if isinstance(series, str) else series[-1][0]
|
|
535
|
+
series_v = series[:3] + [f'{series_desc}_{attr}_{v}']
|
|
536
|
+
self._files_to_series(files[v], series_v)
|
|
537
|
+
split_series[v] = series_v
|
|
538
|
+
return split_series
|
|
539
|
+
|
|
472
540
|
|
|
473
541
|
def _values(self, attributes:list, entity:list):
|
|
474
542
|
# Create a np array v with values for each instance and attribute
|
|
@@ -486,27 +554,36 @@ class DataBaseDicom():
|
|
|
486
554
|
def _copy_patient(self, from_patient, to_patient):
|
|
487
555
|
from_patient_studies = register.studies(self.register, from_patient)
|
|
488
556
|
for from_study in tqdm(from_patient_studies, desc=f'Copying patient {from_patient[1:]}'):
|
|
557
|
+
# Count the studies with the same description in the target patient
|
|
558
|
+
study_desc = from_study[-1][0]
|
|
489
559
|
if to_patient[0]==from_patient[0]:
|
|
490
|
-
|
|
560
|
+
cnt = len(self.studies(to_patient, desc=study_desc))
|
|
491
561
|
else:
|
|
492
|
-
mgr = DataBaseDicom(
|
|
493
|
-
|
|
562
|
+
mgr = DataBaseDicom(to_patient[0])
|
|
563
|
+
cnt = len(mgr.studies(to_patient, desc=study_desc))
|
|
564
|
+
mgr.close()
|
|
565
|
+
# Ensure the copied studies end up in a separate study with the same description
|
|
566
|
+
to_study = to_patient + [(study_desc, cnt)]
|
|
494
567
|
self._copy_study(from_study, to_study)
|
|
495
568
|
|
|
496
569
|
def _copy_study(self, from_study, to_study):
|
|
497
570
|
from_study_series = register.series(self.register, from_study)
|
|
498
571
|
for from_series in tqdm(from_study_series, desc=f'Copying study {from_study[1:]}'):
|
|
572
|
+
# Count the series with the same description in the target study
|
|
573
|
+
series_desc = from_series[-1][0]
|
|
499
574
|
if to_study[0]==from_study[0]:
|
|
500
|
-
|
|
575
|
+
cnt = len(self.series(to_study, desc=series_desc))
|
|
501
576
|
else:
|
|
502
577
|
mgr = DataBaseDicom(to_study[0])
|
|
503
|
-
|
|
578
|
+
cnt = len(mgr.series(to_study, desc=series_desc))
|
|
579
|
+
mgr.close()
|
|
580
|
+
# Ensure the copied series end up in a separate series with the same description
|
|
581
|
+
to_series = to_study + [(series_desc, cnt)]
|
|
504
582
|
self._copy_series(from_series, to_series)
|
|
505
583
|
|
|
506
584
|
def _copy_series(self, from_series, to_series):
|
|
507
585
|
# Get the files to be exported
|
|
508
586
|
from_series_files = register.files(self.register, from_series)
|
|
509
|
-
|
|
510
587
|
if to_series[0] == from_series[0]:
|
|
511
588
|
# Copy in the same database
|
|
512
589
|
self._files_to_series(from_series_files, to_series)
|
|
@@ -520,7 +597,7 @@ class DataBaseDicom():
|
|
|
520
597
|
def _files_to_series(self, files, to_series):
|
|
521
598
|
|
|
522
599
|
# Get the attributes of the destination series
|
|
523
|
-
attr = self.
|
|
600
|
+
attr = self._series_attributes(to_series)
|
|
524
601
|
n = self._max_instance_number(attr['SeriesInstanceUID'])
|
|
525
602
|
|
|
526
603
|
# Copy the files to the new series
|
|
@@ -529,13 +606,28 @@ class DataBaseDicom():
|
|
|
529
606
|
ds = dbdataset.read_dataset(f)
|
|
530
607
|
self._write_dataset(ds, attr, n + 1 + i)
|
|
531
608
|
|
|
532
|
-
|
|
609
|
+
def _max_study_id(self, patient_id):
|
|
610
|
+
for pt in self.register:
|
|
611
|
+
if pt['PatientID'] == patient_id:
|
|
612
|
+
# Find the largest integer StudyID
|
|
613
|
+
n = []
|
|
614
|
+
for st in pt['studies']:
|
|
615
|
+
try:
|
|
616
|
+
n.append(int(st['StudyID']))
|
|
617
|
+
except:
|
|
618
|
+
pass
|
|
619
|
+
if n == []:
|
|
620
|
+
return 0
|
|
621
|
+
else:
|
|
622
|
+
return int(np.amax(n))
|
|
623
|
+
return 0
|
|
624
|
+
|
|
533
625
|
def _max_series_number(self, study_uid):
|
|
534
626
|
for pt in self.register:
|
|
535
627
|
for st in pt['studies']:
|
|
536
628
|
if st['StudyInstanceUID'] == study_uid:
|
|
537
|
-
n = [sr['SeriesNumber'] for sr in st['
|
|
538
|
-
return np.amax(n)
|
|
629
|
+
n = [sr['SeriesNumber'] for sr in st['series']]
|
|
630
|
+
return int(np.amax(n))
|
|
539
631
|
return 0
|
|
540
632
|
|
|
541
633
|
def _max_instance_number(self, series_uid):
|
|
@@ -544,16 +636,16 @@ class DataBaseDicom():
|
|
|
544
636
|
for sr in st['series']:
|
|
545
637
|
if sr['SeriesInstanceUID'] == series_uid:
|
|
546
638
|
n = list(sr['instances'].keys())
|
|
547
|
-
return np.amax([int(i) for i in n])
|
|
639
|
+
return int(np.amax([int(i) for i in n]))
|
|
548
640
|
return 0
|
|
549
641
|
|
|
550
|
-
def _attributes(self, entity):
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
642
|
+
# def _attributes(self, entity):
|
|
643
|
+
# if len(entity)==4:
|
|
644
|
+
# return self._series_attributes(entity)
|
|
645
|
+
# if len(entity)==3:
|
|
646
|
+
# return self._study_attributes(entity)
|
|
647
|
+
# if len(entity)==2:
|
|
648
|
+
# return self._patient_attributes(entity)
|
|
557
649
|
|
|
558
650
|
|
|
559
651
|
def _patient_attributes(self, patient):
|
|
@@ -565,10 +657,13 @@ class DataBaseDicom():
|
|
|
565
657
|
vals = dbdataset.get_values(ds, attr)
|
|
566
658
|
except:
|
|
567
659
|
# If the patient does not exist, generate values
|
|
660
|
+
if patient in self.patients():
|
|
661
|
+
raise ValueError(
|
|
662
|
+
f"Cannot create patient with id {patient[1]}."
|
|
663
|
+
f"The ID is already taken. Please provide a unique ID."
|
|
664
|
+
)
|
|
568
665
|
attr = ['PatientID', 'PatientName']
|
|
569
|
-
|
|
570
|
-
patient_name = patient[-1] if isinstance(patient[-1], str) else patient[-1][0]
|
|
571
|
-
vals = [patient_id, patient_name]
|
|
666
|
+
vals = [patient[1], 'Anonymous']
|
|
572
667
|
return {attr[i]:vals[i] for i in range(len(attr)) if vals[i] is not None}
|
|
573
668
|
|
|
574
669
|
|
|
@@ -580,13 +675,19 @@ class DataBaseDicom():
|
|
|
580
675
|
attr = const.STUDY_MODULE
|
|
581
676
|
ds = dbdataset.read_dataset(files[0])
|
|
582
677
|
vals = dbdataset.get_values(ds, attr)
|
|
678
|
+
except register.AmbiguousError as e:
|
|
679
|
+
raise register.AmbiguousError(e)
|
|
583
680
|
except:
|
|
584
|
-
# If the study does not exist, generate values
|
|
585
|
-
|
|
586
|
-
|
|
681
|
+
# If the study does not exist or is empty, generate values
|
|
682
|
+
if study[:-1] not in self.patients():
|
|
683
|
+
study_id = 1
|
|
684
|
+
else:
|
|
685
|
+
study_id = 1 + self._max_study_id(study[-1])
|
|
686
|
+
attr = ['StudyInstanceUID', 'StudyDescription', 'StudyID']
|
|
687
|
+
study_uid = dbdataset.new_uid()
|
|
587
688
|
study_desc = study[-1] if isinstance(study[-1], str) else study[-1][0]
|
|
588
|
-
study_date = datetime.today().strftime('%Y%m%d')
|
|
589
|
-
vals = [
|
|
689
|
+
#study_date = datetime.today().strftime('%Y%m%d')
|
|
690
|
+
vals = [study_uid, study_desc, str(study_id)]
|
|
590
691
|
return patient_attr | {attr[i]:vals[i] for i in range(len(attr)) if vals[i] is not None}
|
|
591
692
|
|
|
592
693
|
|
|
@@ -598,18 +699,20 @@ class DataBaseDicom():
|
|
|
598
699
|
attr = const.SERIES_MODULE
|
|
599
700
|
ds = dbdataset.read_dataset(files[0])
|
|
600
701
|
vals = dbdataset.get_values(ds, attr)
|
|
702
|
+
except register.AmbiguousError as e:
|
|
703
|
+
raise register.AmbiguousError(e)
|
|
601
704
|
except:
|
|
602
705
|
# If the series does not exist or is empty, generate values
|
|
603
706
|
try:
|
|
604
|
-
study_uid = register.
|
|
707
|
+
study_uid = register.study_uid(self.register, series[:-1])
|
|
605
708
|
except:
|
|
606
709
|
series_number = 1
|
|
607
710
|
else:
|
|
608
711
|
series_number = 1 + self._max_series_number(study_uid)
|
|
609
712
|
attr = ['SeriesInstanceUID', 'SeriesDescription', 'SeriesNumber']
|
|
610
|
-
|
|
713
|
+
series_uid = dbdataset.new_uid()
|
|
611
714
|
series_desc = series[-1] if isinstance(series[-1], str) else series[-1][0]
|
|
612
|
-
vals = [
|
|
715
|
+
vals = [series_uid, series_desc, int(series_number)]
|
|
613
716
|
return study_attr | {attr[i]:vals[i] for i in range(len(attr)) if vals[i] is not None}
|
|
614
717
|
|
|
615
718
|
|
|
@@ -619,42 +722,71 @@ class DataBaseDicom():
|
|
|
619
722
|
attr['InstanceNumber'] = str(instance_nr)
|
|
620
723
|
dbdataset.set_values(ds, list(attr.keys()), list(attr.values()))
|
|
621
724
|
# Save results in a new file
|
|
622
|
-
|
|
725
|
+
rel_dir = os.path.join(
|
|
726
|
+
f"patient_{attr['PatientID']}",
|
|
727
|
+
f"study_[{attr['StudyID']}]_{attr['StudyDescription']}",
|
|
728
|
+
f"series_[{attr['SeriesNumber']}]_{attr['SeriesDescription']}",
|
|
729
|
+
)
|
|
730
|
+
os.makedirs(os.path.join(self.path, rel_dir), exist_ok=True)
|
|
731
|
+
rel_path = os.path.join(rel_dir, dbdataset.new_uid() + '.dcm')
|
|
623
732
|
dbdataset.write(ds, os.path.join(self.path, rel_path))
|
|
624
733
|
# Add an entry in the register
|
|
625
734
|
register.add_instance(self.register, attr, rel_path)
|
|
626
735
|
|
|
627
736
|
|
|
628
737
|
|
|
629
|
-
|
|
630
|
-
#
|
|
631
|
-
#
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
738
|
+
def infer_slice_spacing(vols):
|
|
739
|
+
# In case spacing between slices is not (correctly) encoded in
|
|
740
|
+
# DICOM it can be inferred from the slice locations.
|
|
741
|
+
|
|
742
|
+
shape = vols.shape
|
|
743
|
+
vols = vols.reshape((shape[0], -1))
|
|
744
|
+
slice_spacing = np.zeros(vols.shape[-1])
|
|
745
|
+
|
|
746
|
+
for d in range(vols.shape[-1]):
|
|
747
|
+
|
|
748
|
+
# For single slice volumes there is nothing to do
|
|
749
|
+
if vols[:,d].shape[0]==1:
|
|
750
|
+
continue
|
|
751
|
+
|
|
752
|
+
# Get a normal slice vector from the first volume.
|
|
753
|
+
mat = vols[0,d].affine[:3,:3]
|
|
754
|
+
normal = mat[:,2]/np.linalg.norm(mat[:,2])
|
|
755
|
+
|
|
756
|
+
# Get slice locations by projection on the normal.
|
|
757
|
+
pos = [v.affine[:3,3] for v in vols[:,d]]
|
|
758
|
+
slice_loc = [np.dot(p, normal) for p in pos]
|
|
759
|
+
|
|
760
|
+
# Sort slice locations and take consecutive differences.
|
|
761
|
+
slice_loc = np.sort(slice_loc)
|
|
762
|
+
distances = slice_loc[1:] - slice_loc[:-1]
|
|
763
|
+
|
|
764
|
+
# Round to micrometer and check if unique
|
|
765
|
+
distances = np.around(distances, 3)
|
|
766
|
+
slice_spacing_d = np.unique(distances)
|
|
767
|
+
|
|
768
|
+
# Check if unique - otherwise this is not a volume
|
|
769
|
+
if len(slice_spacing_d) > 1:
|
|
770
|
+
raise ValueError(
|
|
771
|
+
'Cannot build a volume - spacings between slices are not unique.'
|
|
772
|
+
)
|
|
773
|
+
else:
|
|
774
|
+
slice_spacing_d= slice_spacing_d[0]
|
|
775
|
+
|
|
776
|
+
# Set correct slice spacing in all volumes
|
|
777
|
+
for v in vols[:,d]:
|
|
778
|
+
v.affine[:3,2] = normal * abs(slice_spacing_d)
|
|
779
|
+
|
|
780
|
+
slice_spacing[d] = slice_spacing_d
|
|
781
|
+
|
|
782
|
+
# Check slice_spacing is the same across dimensions
|
|
783
|
+
slice_spacing = np.unique(slice_spacing)
|
|
784
|
+
if len(slice_spacing) > 1:
|
|
785
|
+
raise ValueError(
|
|
786
|
+
'Cannot build a volume - spacings between slices are not unique.'
|
|
787
|
+
)
|
|
657
788
|
|
|
789
|
+
return vols.reshape(shape)
|
|
658
790
|
|
|
659
791
|
|
|
660
792
|
|