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/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 is corrupted, delete it and load again
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.tolist():
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, name=None, contains=None, isin=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
- name (str, optional): value of StudyDescription, to search for
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, name, contains, isin)
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, name, contains, isin)
155
+ return register.studies(self.register, entity, desc, contains, isin)
148
156
 
149
- def series(self, entity=None, name=None, contains=None, isin=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
- name (str, optional): value of SeriesDescription, to search for
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, name, contains, isin)
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, name, contains, isin)
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, name, contains, isin)
194
+ return register.series(self.register, entity, desc, contains, isin)
182
195
 
183
196
 
184
- def volume(self, series:list, dims:list=None, multislice=False) -> vreg.Volume3D:
185
- """Read a vreg.Volume3D from a DICOM series
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
- series (list): DICOM series to read
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: vole read from the series.
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, series)
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, multislice))
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
- vol = vreg.join(vols)
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, multislice=False,
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
- dims (list, optional): Non-spatial dimensions of the volume. Defaults to None.
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._attributes(series)
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, multislice)
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, multislice)
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, multislice=False):
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, multislice)
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, multislice=False):
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, multislice)
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: dictionary with unique values for each attribute.
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
- if len(va) == 0:
420
- va = None
421
- elif len(va) == 1:
422
- va = va[0]
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
- return {p: values[i] for i, p in enumerate(pars)}
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
- to_study = register.append(self.register, to_patient, from_study[-1])
560
+ cnt = len(self.studies(to_patient, desc=study_desc))
491
561
  else:
492
- mgr = DataBaseDicom(to_study[0])
493
- to_study = register.append(mgr.register, to_patient, from_study[-1])
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
- to_series = register.append(self.register, to_study, from_series[-1])
575
+ cnt = len(self.series(to_study, desc=series_desc))
501
576
  else:
502
577
  mgr = DataBaseDicom(to_study[0])
503
- to_series = register.append(mgr.register, to_study, from_series[-1])
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._attributes(to_series)
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['studies']]
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
- if len(entity)==4:
552
- return self._series_attributes(entity)
553
- if len(entity)==3:
554
- return self._study_attributes(entity)
555
- if len(entity)==2:
556
- return self._patient_attributes(entity)
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
- patient_id = dbdataset.new_uid()
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
- attr = ['StudyInstanceUID', 'StudyDescription', 'StudyDate']
586
- study_id = dbdataset.new_uid()
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 = [study_id, study_desc, study_date]
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.uid(self.register, series[:-1])
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
- series_id = dbdataset.new_uid()
713
+ series_uid = dbdataset.new_uid()
611
714
  series_desc = series[-1] if isinstance(series[-1], str) else series[-1][0]
612
- vals = [series_id, series_desc, series_number]
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
- rel_path = os.path.join('dbdicom', dbdataset.new_uid() + '.dcm')
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
- # def _split_series(self):
630
- # """
631
- # Split series with multiple SOP Classes.
632
-
633
- # If a series contain instances from different SOP Classes,
634
- # these are separated out into multiple series with identical SOP Classes.
635
- # """
636
- # # For each series, check if there are multiple
637
- # # SOP Classes in the series and split them if yes.
638
- # for series in self.series():
639
- # series_index = register.index(self.register, series)
640
- # df_series = self.register.loc[series_index]
641
- # sop_classes = df_series.SOPClassUID.unique()
642
- # if len(sop_classes) > 1:
643
- # # For each sop_class, create a new series and move all
644
- # # instances of that sop_class to the new series
645
- # desc = series[-1] if isinstance(series, str) else series[0]
646
- # for i, sop_class in tqdm(enumerate(sop_classes[1:]), desc='Splitting series with multiple SOP Classes.'):
647
- # df_sop_class = df_series[df_series.SOPClassUID == sop_class]
648
- # relpaths = df_sop_class.index.tolist()
649
- # sop_class_files = [os.path.join(self.path, p) for p in relpaths]
650
- # sop_class_series = series[:-1] + [desc + f' [{i+1}]']
651
- # self._files_to_series(sop_class_files, sop_class_series)
652
- # # Delete original files permanently
653
- # self.register.drop(relpaths)
654
- # for f in sop_class_files:
655
- # os.remove(f)
656
- # self.register.drop('SOPClassUID', axis=1, inplace=True)
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