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.

Files changed (52) hide show
  1. dbdicom/__init__.py +1 -28
  2. dbdicom/api.py +267 -0
  3. dbdicom/const.py +144 -0
  4. dbdicom/dataset.py +752 -0
  5. dbdicom/dbd.py +719 -0
  6. dbdicom/external/__pycache__/__init__.cpython-311.pyc +0 -0
  7. dbdicom/external/dcm4che/__pycache__/__init__.cpython-311.pyc +0 -0
  8. dbdicom/external/dcm4che/bin/__pycache__/__init__.cpython-311.pyc +0 -0
  9. dbdicom/register.py +527 -0
  10. dbdicom/{ds/types → sop_classes}/ct_image.py +2 -16
  11. dbdicom/{ds/types → sop_classes}/enhanced_mr_image.py +153 -26
  12. dbdicom/{ds/types → sop_classes}/mr_image.py +185 -140
  13. dbdicom/sop_classes/parametric_map.py +307 -0
  14. dbdicom/sop_classes/secondary_capture.py +140 -0
  15. dbdicom/sop_classes/segmentation.py +311 -0
  16. dbdicom/{ds/types → sop_classes}/ultrasound_multiframe_image.py +1 -15
  17. dbdicom/{ds/types → sop_classes}/xray_angiographic_image.py +2 -17
  18. dbdicom/utils/arrays.py +36 -0
  19. dbdicom/utils/files.py +0 -20
  20. dbdicom/utils/image.py +10 -629
  21. dbdicom-0.3.0.dist-info/METADATA +28 -0
  22. dbdicom-0.3.0.dist-info/RECORD +53 -0
  23. {dbdicom-0.2.5.dist-info → dbdicom-0.3.0.dist-info}/WHEEL +1 -1
  24. dbdicom/create.py +0 -457
  25. dbdicom/dro.py +0 -174
  26. dbdicom/ds/__init__.py +0 -10
  27. dbdicom/ds/create.py +0 -63
  28. dbdicom/ds/dataset.py +0 -869
  29. dbdicom/ds/dictionaries.py +0 -620
  30. dbdicom/ds/types/parametric_map.py +0 -226
  31. dbdicom/extensions/__init__.py +0 -9
  32. dbdicom/extensions/dipy.py +0 -448
  33. dbdicom/extensions/elastix.py +0 -503
  34. dbdicom/extensions/matplotlib.py +0 -107
  35. dbdicom/extensions/numpy.py +0 -271
  36. dbdicom/extensions/scipy.py +0 -1512
  37. dbdicom/extensions/skimage.py +0 -1030
  38. dbdicom/extensions/sklearn.py +0 -243
  39. dbdicom/extensions/vreg.py +0 -1390
  40. dbdicom/manager.py +0 -2132
  41. dbdicom/message.py +0 -119
  42. dbdicom/pipelines.py +0 -66
  43. dbdicom/record.py +0 -1893
  44. dbdicom/types/database.py +0 -107
  45. dbdicom/types/instance.py +0 -231
  46. dbdicom/types/patient.py +0 -40
  47. dbdicom/types/series.py +0 -2874
  48. dbdicom/types/study.py +0 -58
  49. dbdicom-0.2.5.dist-info/METADATA +0 -71
  50. dbdicom-0.2.5.dist-info/RECORD +0 -66
  51. {dbdicom-0.2.5.dist-info → dbdicom-0.3.0.dist-info/licenses}/LICENSE +0 -0
  52. {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
+