dbdicom 0.3.9__py3-none-any.whl → 0.3.10__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 CHANGED
@@ -4,8 +4,7 @@ import zipfile
4
4
  from pathlib import Path
5
5
  from typing import Union
6
6
  from tqdm import tqdm
7
-
8
-
7
+ import numpy as np
9
8
  import vreg
10
9
 
11
10
  from dbdicom.dbd import DataBaseDicom
@@ -164,16 +163,22 @@ def series(entity:str | list, desc:str=None, contains:str=None, isin:list=None)-
164
163
  "To retrieve a series, the entity must be a database, patient or study."
165
164
  )
166
165
 
167
- def copy(from_entity:list, to_entity:list):
168
- """Copy a DICOM entity (patient, study or series)
166
+ def copy(from_entity:list, to_entity=None):
167
+ """Copy a DICOM entity (patient, study or series)
169
168
 
170
169
  Args:
171
170
  from_entity (list): entity to copy
172
- to_entity (list): entity after copying.
171
+ to_entity (list, optional): entity after copying. If this is not
172
+ provided, a copy will be made in the same study and returned.
173
+
174
+ Returns:
175
+ entity: the copied entity. If th to_entity is provided, this is
176
+ returned.
173
177
  """
174
178
  dbd = open(from_entity[0])
175
- dbd.copy(from_entity, to_entity)
179
+ from_entity_copy = dbd.copy(from_entity, to_entity)
176
180
  dbd.close()
181
+ return from_entity_copy
177
182
 
178
183
 
179
184
  def delete(entity:list):
@@ -216,25 +221,42 @@ def split_series(series:list, attr:Union[str, tuple], key=None)->list:
216
221
  return split_series
217
222
 
218
223
 
219
- def volume(entity:Union[list, str], dims:list=None, verbose=1) -> Union[vreg.Volume3D, list]:
220
- """Read volume or volumes.
224
+ def volume(series:list, dims:list=None, verbose=1) -> vreg.Volume3D:
225
+ """Read volume from a series.
221
226
 
222
227
  Args:
223
- entity (list, str): DICOM entity to read
228
+ series (list, str): DICOM entity to read
224
229
  dims (list, optional): Non-spatial dimensions of the volume. Defaults to None.
225
230
  verbose (bool, optional): If set to 1, shows progress bar. Defaults to 1.
226
231
 
227
232
  Returns:
228
- vreg.Volume3D | list: If the entity is a series this returns
229
- a volume, else a list of volumes.
233
+ vreg.Volume3D.
230
234
  """
231
- if isinstance(entity, str):
232
- entity = [entity]
233
- dbd = open(entity[0])
234
- vol = dbd.volume(entity, dims, verbose)
235
+ dbd = open(series[0])
236
+ vol = dbd.volume(series, dims, verbose)
235
237
  dbd.close()
236
238
  return vol
237
239
 
240
+
241
+ def values(series:list, *attr, dims:list=None, verbose=1) -> Union[np.ndarray, list]:
242
+ """Read the values of some attributes from a DICOM series
243
+
244
+ Args:
245
+ series (list): DICOM series to read.
246
+ attr (tuple, optional): DICOM attributes to read.
247
+ dims (list, optional): Dimensions to sort the values.
248
+ If dims is not provided, values are sorted by
249
+ InstanceNumber.
250
+
251
+ Returns:
252
+ tuple: arrays with values for the attributes.
253
+ """
254
+ dbd = open(series[0])
255
+ values = dbd.values(series, *attr, dims=dims, verbose=verbose)
256
+ dbd.close()
257
+ return values
258
+
259
+
238
260
  def write_volume(vol:Union[vreg.Volume3D, tuple], series:list, ref:list=None):
239
261
  """Write a vreg.Volume3D to a DICOM series
240
262
 
@@ -247,6 +269,25 @@ def write_volume(vol:Union[vreg.Volume3D, tuple], series:list, ref:list=None):
247
269
  dbd.write_volume(vol, series, ref)
248
270
  dbd.close()
249
271
 
272
+
273
+ def edit(series:list, new_values:dict, dims:list=None, verbose=1):
274
+ """Edit attribute values in a DICOM series
275
+
276
+ Warning: this function edits all values as requested. Please take care
277
+ when editing attributes that affect the DICOM file organisation, such as
278
+ UIDs, as this could corrupt the database.
279
+
280
+ Args:
281
+ series (list): DICOM series to edit
282
+ new_values (dict): dictionary with attribute: value pairs to write to the series
283
+ dims (list, optional): Non-spatial dimensions of the volume. Defaults to None.
284
+ verbose (bool, optional): If set to 1, shows progress bar. Defaults to 1.
285
+
286
+ """
287
+ dbd = open(series[0])
288
+ dbd.edit(series, new_values, dims=dims, verbose=verbose)
289
+ dbd.close()
290
+
250
291
  def to_nifti(series:list, file:str, dims:list=None, verbose=1):
251
292
  """Save a DICOM series in nifti format.
252
293
 
@@ -274,35 +315,6 @@ def from_nifti(file:str, series:list, ref:list=None):
274
315
  dbd.close()
275
316
 
276
317
 
277
- def values(series:list, attr=None, dims:list=None, coords=False) -> Union[dict, tuple]:
278
- """Read the values of some or all attributes from a DICOM series
279
-
280
- Args:
281
- series (list or str): DICOM series to read. This can also
282
- be a path to a folder containing DICOM files, or a
283
- patient or study to read all series in that patient or
284
- study. In those cases a list is returned.
285
- attr (list, optional): list of DICOM attributes to read.
286
- dims (list, optional): Dimensions to sort the attributes.
287
- If dims is not provided, values are sorted by
288
- InstanceNumber.
289
- coords (bool): If set to True, the coordinates of the
290
- attributes are returned alongside the values
291
-
292
- Returns:
293
- dict or tuple: values as a dictionary in the last
294
- return value, where each value is a numpy array with
295
- the required dimensions. If coords is set to True,
296
- these are returned too.
297
- """
298
- if isinstance(series, str):
299
- series = [series]
300
- dbd = open(series[0])
301
- array = dbd.values(series, attr, dims, coords)
302
- dbd.close()
303
- return array
304
-
305
-
306
318
  def files(entity:list) -> list:
307
319
  """Read the files in a DICOM entity
308
320
 
dbdicom/dataset.py CHANGED
@@ -370,7 +370,7 @@ def set_volume(ds, volume:vreg.Volume3D):
370
370
  set_affine(ds, volume.affine)
371
371
  if volume.coords is not None:
372
372
  # All other dimensions should have size 1
373
- coords = volume.coords.reshape((volume.coords.shape[0], -1))
373
+ coords = [c.reshape(-1) for c in volume.coords]
374
374
  for i, d in enumerate(volume.dims):
375
375
  if not is_valid_dicom_tag(d):
376
376
  raise ValueError(
@@ -380,7 +380,7 @@ def set_volume(ds, volume:vreg.Volume3D):
380
380
  "tags to change the dimensions."
381
381
  )
382
382
  else:
383
- set_values(ds, d, coords[i,0])
383
+ set_values(ds, d, coords[i][0])
384
384
 
385
385
 
386
386
 
dbdicom/dbd.py CHANGED
@@ -1,8 +1,10 @@
1
1
  import os
2
+ import shutil
2
3
  import json
3
4
  from typing import Union
4
5
  import zipfile
5
6
  import re
7
+ from copy import deepcopy
6
8
 
7
9
  from tqdm import tqdm
8
10
  import numpy as np
@@ -76,14 +78,16 @@ class DataBaseDicom():
76
78
  Args:
77
79
  entity (list): entity to delete
78
80
  """
81
+ # delete datasets on disk
79
82
  removed = register.index(self.register, entity)
80
- # delete datasets marked for removal
81
83
  for index in removed:
82
84
  file = os.path.join(self.path, index)
83
85
  if os.path.exists(file):
84
86
  os.remove(file)
85
- # and drop then from the register
86
- self.register = register.drop(self.register, removed)
87
+ # drop the entity from the register
88
+ register.remove(self.register, entity)
89
+ # cleanup empty folders
90
+ remove_empty_folders(entity[0])
87
91
  return self
88
92
 
89
93
 
@@ -206,22 +210,22 @@ class DataBaseDicom():
206
210
  return register.series(self.register, entity, desc, contains, isin)
207
211
 
208
212
 
209
- def volume(self, entity:Union[list, str], dims:list=None, verbose=1) -> Union[vreg.Volume3D, list]:
210
- """Read volume or volumes.
213
+ def volume(self, entity:Union[list, str], dims:list=None, verbose=1) -> vreg.Volume3D:
214
+ """Read volume.
211
215
 
212
216
  Args:
213
- entity (list, str): DICOM entity to read
217
+ entity (list, str): DICOM series to read
214
218
  dims (list, optional): Non-spatial dimensions of the volume. Defaults to None.
215
219
  verbose (bool, optional): If set to 1, shows progress bar. Defaults to 1.
216
220
 
217
221
  Returns:
218
- vreg.Volume3D | list: If the entity is a series this returns
219
- a volume, else a list of volumes.
222
+ vreg.Volume3D:
220
223
  """
221
- if isinstance(entity, str): # path to folder
222
- return [self.volume(s, dims) for s in self.series(entity)]
223
- if len(entity) < 4: # folder, patient or study
224
- return [self.volume(s, dims) for s in self.series(entity)]
224
+ # if isinstance(entity, str): # path to folder
225
+ # return [self.volume(s, dims) for s in self.series(entity)]
226
+ # if len(entity) < 4: # folder, patient or study
227
+ # return [self.volume(s, dims) for s in self.series(entity)]
228
+
225
229
  if dims is None:
226
230
  dims = []
227
231
  elif isinstance(dims, str):
@@ -230,33 +234,39 @@ class DataBaseDicom():
230
234
  dims = list(dims)
231
235
  dims = ['SliceLocation'] + dims
232
236
 
233
- files = register.files(self.register, entity)
234
-
235
237
  # Read dicom files
236
- values = []
238
+ values = [[] for _ in dims]
237
239
  volumes = []
240
+
241
+ files = register.files(self.register, entity)
238
242
  for f in tqdm(files, desc='Reading volume..', disable=(verbose==0)):
239
243
  ds = pydicom.dcmread(f)
240
- values.append(get_values(ds, dims))
244
+ values_f = get_values(ds, dims)
245
+ for d in range(len(dims)):
246
+ values[d].append(values_f[d])
241
247
  volumes.append(dbdataset.volume(ds))
242
248
 
243
- # Format as mesh
244
- # coords = np.stack(values, axis=-1, dtype=object)
245
- values = [np.array(v, dtype=object) for v in values] # object array to allow for mixed types
246
- coords = np.stack(values, axis=-1)
249
+ # Format coordinates as mesh
250
+ coords = [np.array(v) for v in values]
247
251
  coords, inds = dbdicom.utils.arrays.meshvals(coords)
248
- vols = np.array(volumes)
249
- vols = vols[inds].reshape(coords.shape[1:])
250
252
 
251
253
  # Check that all slices have the same coordinates
252
- c0 = coords[1:,0,...]
253
- for k in range(coords.shape[1]-1):
254
- if not np.array_equal(coords[1:,k+1,...], c0):
255
- raise ValueError(
256
- "Cannot build a single volume. Not all slices "
257
- "have the same coordinates."
258
- )
259
-
254
+ if len(dims) > 1:
255
+ # Loop over all coordinates after slice location
256
+ for c in coords[1:]:
257
+ # Loop over all slice locations
258
+ for k in range(1, c.shape[0]):
259
+ # Coordinate c of slice k
260
+ if not np.array_equal(c[k,...], c[0,...]):
261
+ raise ValueError(
262
+ "Cannot build a single volume. Not all slices "
263
+ "have the same coordinates."
264
+ )
265
+
266
+ # Build volumes
267
+ vols = np.array(volumes)
268
+ vols = vols[inds].reshape(coords[0].shape)
269
+
260
270
  # Infer spacing between slices from slice locations
261
271
  # Technically only necessary if SpacingBetweenSlices not set or incorrect
262
272
  vols = infer_slice_spacing(vols)
@@ -272,11 +282,66 @@ class DataBaseDicom():
272
282
  # Then try again
273
283
  vol = vreg.join(vols)
274
284
  if vol.ndim > 3:
285
+ # Coordinates of slice 0
286
+ c0 = [c[0,...] for c in coords[1:]]
275
287
  vol.set_coords(c0)
276
288
  vol.set_dims(dims[1:])
277
289
  return vol
278
290
 
279
-
291
+
292
+ def values(self, series:list, *attr, dims:list=None, verbose=1) -> Union[dict, tuple]:
293
+ """Read the values of some attributes from a DICOM series
294
+
295
+ Args:
296
+ series (list): DICOM series to read.
297
+ attr (tuple, optional): DICOM attributes to read.
298
+ dims (list, optional): Non-spatial dimensions of the volume. Defaults to None.
299
+ verbose (bool, optional): If set to 1, shows progress bar. Defaults to 1.
300
+
301
+ Returns:
302
+ tuple: arrays with values for the attributes.
303
+ """
304
+ # if isinstance(series, str): # path to folder
305
+ # return [self.values(s, attr, dims) for s in self.series(series)]
306
+ # if len(series) < 4: # folder, patient or study
307
+ # return [self.values(s, attr, dims) for s in self.series(series)]
308
+
309
+ if dims is None:
310
+ dims = ['InstanceNumber']
311
+ elif np.isscalar(dims):
312
+ dims = [dims]
313
+ else:
314
+ dims = list(dims)
315
+
316
+ # Read dicom files
317
+ coord_values = [[] for _ in dims]
318
+ attr_values = [[] for _ in attr]
319
+
320
+ files = register.files(self.register, series)
321
+ for f in tqdm(files, desc='Reading values..', disable=(verbose==0)):
322
+ ds = pydicom.dcmread(f)
323
+ coord_values_f = get_values(ds, dims)
324
+ for d in range(len(dims)):
325
+ coord_values[d].append(coord_values_f[d])
326
+ attr_values_f = get_values(ds, attr)
327
+ for a in range(len(attr)):
328
+ attr_values[a].append(attr_values_f[a])
329
+
330
+ # Format coordinates as mesh
331
+ coords = [np.array(v) for v in coord_values]
332
+ coords, inds = dbdicom.utils.arrays.meshvals(coords)
333
+
334
+ # Sort values accordingly
335
+ values = [np.array(v) for v in attr_values]
336
+ values = [v[inds].reshape(coords[0].shape) for v in values]
337
+
338
+ # Return values
339
+ if len(values) == 1:
340
+ return values[0]
341
+ else:
342
+ return tuple(values)
343
+
344
+
280
345
  def write_volume(
281
346
  self, vol:Union[vreg.Volume3D, tuple], series:list,
282
347
  ref:list=None,
@@ -288,6 +353,10 @@ class DataBaseDicom():
288
353
  series (list): DICOM series to read
289
354
  ref (list): Reference series
290
355
  """
356
+ series_full_name = full_name(series)
357
+ if series_full_name in self.series():
358
+ raise ValueError(f"Series {series_full_name[-1]} already exists in study {series_full_name[-2]}.")
359
+
291
360
  if isinstance(vol, tuple):
292
361
  vol = vreg.volume(vol[0], vol[1])
293
362
  if ref is None:
@@ -318,11 +387,85 @@ class DataBaseDicom():
318
387
  slices = vt.split()
319
388
  for sl in slices:
320
389
  dbdataset.set_volume(ds, sl)
321
- sl_coords = [sl.coords[i,...].ravel()[0] for i in range(len(sl.dims))]
390
+ sl_coords = [c.ravel()[0] for c in sl.coords]
322
391
  set_value(ds, sl.dims, sl_coords)
323
392
  self._write_dataset(ds, attr, n + 1 + i)
324
393
  i+=1
325
394
  return self
395
+
396
+
397
+ def edit(
398
+ self, series:list, new_values:dict, dims:list=None, verbose=1,
399
+ ):
400
+ """Edit attribute values in a new DICOM series
401
+
402
+ Args:
403
+ series (list): DICOM series to edit
404
+ new_values (dict): dictionary with attribute: value pairs to write to the series
405
+ dims (list, optional): Non-spatial dimensions of the volume. Defaults to None.
406
+ verbose (bool, optional): If set to 1, shows progress bar. Defaults to 1.
407
+ """
408
+
409
+ if dims is None:
410
+ dims = ['InstanceNumber']
411
+ elif np.isscalar(dims):
412
+ dims = [dims]
413
+ else:
414
+ dims = list(dims)
415
+
416
+ # Check that all values have the correct nr of elements
417
+ files = register.files(self.register, series)
418
+ for a in new_values.values():
419
+ if np.isscalar(a):
420
+ pass
421
+ elif np.array(a).size != len(files):
422
+ raise ValueError(
423
+ f"Incorrect value lengths. All values need to have {len(files)} elements"
424
+ )
425
+
426
+ # Read dicom files to sort them
427
+ coord_values = [[] for _ in dims]
428
+ for f in tqdm(files, desc='Sorting series..', disable=(verbose==0)):
429
+ ds = pydicom.dcmread(f)
430
+ coord_values_f = get_values(ds, dims)
431
+ for d in range(len(dims)):
432
+ coord_values[d].append(coord_values_f[d])
433
+
434
+ # Format coordinates as mesh
435
+ coords = [np.array(v) for v in coord_values]
436
+ coords, inds = dbdicom.utils.arrays.meshvals(coords)
437
+
438
+ # Sort files accordingly
439
+ files = np.array(files)[inds]
440
+
441
+ # Now edit and write the files
442
+ attr = self._series_attributes(series)
443
+ n = self._max_instance_number(attr['SeriesInstanceUID'])
444
+
445
+ # Drop existing attributes if they are edited
446
+ attr = {a:attr[a] for a in attr if a not in new_values}
447
+
448
+ # List instances to be edited
449
+ to_drop = register.index(self.register, series)
450
+
451
+ # Write the instances
452
+ tags = list(new_values.keys())
453
+ for i, f in tqdm(enumerate(files), desc='Writing values..', disable=(verbose==0)):
454
+ ds = pydicom.dcmread(f)
455
+ values = []
456
+ for a in new_values.values():
457
+ if np.isscalar(a):
458
+ values.append(a)
459
+ else:
460
+ values.append(np.array(a).reshape(-1)[i])
461
+ set_values(ds, tags, values)
462
+ self._write_dataset(ds, attr, n + 1 + i)
463
+
464
+ # Delete the originals files
465
+ register.drop(self.register, to_drop)
466
+ [os.remove(os.path.join(self.path, idx)) for idx in to_drop]
467
+
468
+ return self
326
469
 
327
470
 
328
471
  def to_nifti(self, series:list, file:str, dims=None, verbose=1):
@@ -456,103 +599,7 @@ class DataBaseDicom():
456
599
  return arrays, values_return
457
600
 
458
601
 
459
- def values(self, series:list, attr=None, dims:list=None, coords=False) -> Union[dict, tuple]:
460
- """Read the values of some or all attributes from a DICOM series
461
602
 
462
- Args:
463
- series (list or str): DICOM series to read. This can also
464
- be a path to a folder containing DICOM files, or a
465
- patient or study to read all series in that patient or
466
- study. In those cases a list is returned.
467
- attr (list, optional): list of DICOM attributes to read.
468
- dims (list, optional): Dimensions to sort the attributes.
469
- If dims is not provided, values are sorted by
470
- InstanceNumber.
471
- coords (bool): If set to True, the coordinates of the
472
- attributes are returned alongside the values
473
-
474
- Returns:
475
- dict or tuple: values as a dictionary in the last
476
- return value, where each value is a numpy array with
477
- the required dimensions. If coords is set to True,
478
- these are returned too.
479
- """
480
- if isinstance(series, str): # path to folder
481
- return [self.values(s, attr, dims, coords) for s in self.series(series)]
482
- if len(series) < 4: # folder, patient or study
483
- return [self.values(s, attr, dims, coords) for s in self.series(series)]
484
-
485
- if dims is None:
486
- dims = ['InstanceNumber']
487
- elif np.isscalar(dims):
488
- dims = [dims]
489
- else:
490
- dims = list(dims)
491
-
492
- files = register.files(self.register, series)
493
-
494
- # Ensure return_vals is a list
495
- if attr is None:
496
- # If attributes are not provided, read all
497
- # attributes from the first file
498
- ds = pydicom.dcmread(files[0])
499
- exclude = ['PixelData', 'FloatPixelData', 'DoubleFloatPixelData']
500
- params = []
501
- param_labels = []
502
- for elem in ds:
503
- if elem.keyword not in exclude:
504
- params.append(elem.tag)
505
- # For known tags use the keyword as label
506
- label = elem.tag if len(elem.keyword)==0 else elem.keyword
507
- param_labels.append(label)
508
- elif np.isscalar(attr):
509
- params = [attr]
510
- param_labels = params[:]
511
- else:
512
- params = list(attr)
513
- param_labels = params[:]
514
-
515
- # Read dicom files
516
- coords_array = []
517
- values = np.empty(len(files), dtype=dict)
518
- for i, f in tqdm(enumerate(files), desc='Reading values..'):
519
- ds = pydicom.dcmread(f)
520
- coords_array.append(get_values(ds, dims))
521
- # save as dict so numpy does not stack as arrays
522
- values[i] = {'values': get_values(ds, params)}
523
-
524
- # Format as mesh
525
- coords_array = np.stack([v for v in coords_array], axis=-1)
526
- coords_array, inds = dbdicom.utils.arrays.meshvals(coords_array)
527
-
528
- # Sort values accordingly
529
- values = values[inds].reshape(-1)
530
-
531
- # Return values as a dictionary
532
- values_dict = {}
533
- for p in range(len(params)):
534
- # Get the type from the first value
535
- vp0 = values[0]['values'][p]
536
- # Build an array of the right type
537
- vp = np.zeros(values.size, dtype=type(vp0))
538
- # Populate the arrate with values for parameter p
539
- for i, v in enumerate(values):
540
- vp[i] = v['values'][p]
541
- # Reshape values for parameter p
542
- vp = vp.reshape(coords_array.shape[1:])
543
- # Eneter in the dictionary
544
- values_dict[param_labels[p]] = vp
545
-
546
- # If only one, return as value
547
- if len(params) == 1:
548
- values_return = values_dict[params[0]]
549
- else:
550
- values_return = values_dict
551
-
552
- if coords:
553
- return values_return, coords_array
554
- else:
555
- return values_return
556
603
 
557
604
 
558
605
  def files(self, entity:list) -> list:
@@ -622,34 +669,66 @@ class DataBaseDicom():
622
669
  else:
623
670
  return {p: values[i] for i, p in enumerate(pars)}
624
671
 
625
- def copy(self, from_entity, to_entity):
672
+ def copy(self, from_entity, to_entity=None):
626
673
  """Copy a DICOM entity (patient, study or series)
627
674
 
628
675
  Args:
629
676
  from_entity (list): entity to copy
630
- to_entity (list): entity after copying.
677
+ to_entity (list, optional): entity after copying. If this is not
678
+ provided, a copy will be made in the same study and returned
679
+
680
+ Returns:
681
+ entity: the copied entity. If th to_entity is provided, this is
682
+ returned.
631
683
  """
632
684
  if len(from_entity) == 4:
685
+ if to_entity is None:
686
+ to_entity = deepcopy(from_entity)
687
+ if isinstance(to_entity[-1], tuple):
688
+ to_entity[-1] = (to_entity[-1][0] + '_copy', 0)
689
+ else:
690
+ to_entity[-1] = (to_entity[-1] + '_copy', 0)
691
+ while to_entity in self.series():
692
+ to_entity[-1][1] += 1
633
693
  if len(to_entity) != 4:
634
694
  raise ValueError(
635
695
  f"Cannot copy series {from_entity} to series {to_entity}. "
636
696
  f"{to_entity} is not a series (needs 4 elements)."
637
697
  )
638
- return self._copy_series(from_entity, to_entity)
698
+ self._copy_series(from_entity, to_entity)
699
+ return to_entity
700
+
639
701
  if len(from_entity) == 3:
702
+ if to_entity is None:
703
+ to_entity = deepcopy(from_entity)
704
+ if isinstance(to_entity[-1], tuple):
705
+ to_entity[-1] = (to_entity[-1][0] + '_copy', 0)
706
+ else:
707
+ to_entity[-1] = (to_entity[-1] + '_copy', 0)
708
+ while to_entity in self.studies():
709
+ to_entity[-1][1] += 1
640
710
  if len(to_entity) != 3:
641
711
  raise ValueError(
642
712
  f"Cannot copy study {from_entity} to study {to_entity}. "
643
713
  f"{to_entity} is not a study (needs 3 elements)."
644
714
  )
645
- return self._copy_study(from_entity, to_entity)
715
+ self._copy_study(from_entity, to_entity)
716
+ return to_entity
717
+
646
718
  if len(from_entity) == 2:
719
+ if to_entity is None:
720
+ to_entity = deepcopy(from_entity)
721
+ to_entity[-1] += '_copy'
722
+ while to_entity in self.patients():
723
+ to_entity[-1] += '_copy'
647
724
  if len(to_entity) != 2:
648
725
  raise ValueError(
649
726
  f"Cannot copy patient {from_entity} to patient {to_entity}. "
650
727
  f"{to_entity} is not a patient (needs 2 elements)."
651
728
  )
652
- return self._copy_patient(from_entity, to_entity)
729
+ self._copy_patient(from_entity, to_entity)
730
+ return to_entity
731
+
653
732
  raise ValueError(
654
733
  f"Cannot copy {from_entity} to {to_entity}. "
655
734
  )
@@ -930,6 +1009,28 @@ class DataBaseDicom():
930
1009
 
931
1010
 
932
1011
 
1012
+ def full_name(entity):
1013
+
1014
+ if len(entity)==3: # study
1015
+ if isinstance(entity[-1], tuple):
1016
+ return entity
1017
+ else:
1018
+ full_name_study = deepcopy(entity)
1019
+ full_name_study[-1] = (full_name_study[-1], 0)
1020
+ return full_name_study
1021
+
1022
+ elif len(entity)==4: # series
1023
+ full_name_study = full_name(entity[:3])
1024
+ series = full_name_study + [entity[-1]]
1025
+ if isinstance(series[-1], tuple):
1026
+ return series
1027
+ else:
1028
+ full_name_series = deepcopy(series)
1029
+ full_name_series[-1] = (full_name_series[-1], 0)
1030
+ return full_name_series
1031
+ else:
1032
+ return entity
1033
+
933
1034
 
934
1035
  def clean_folder_name(name, replacement="", max_length=255):
935
1036
  # Strip leading/trailing whitespace
@@ -954,6 +1055,30 @@ def clean_folder_name(name, replacement="", max_length=255):
954
1055
 
955
1056
 
956
1057
 
1058
+ def remove_empty_folders(path):
1059
+ """
1060
+ Removes all empty subfolders from a given directory.
1061
+
1062
+ This function walks through the directory tree from the bottom up.
1063
+ This is crucial because it allows child directories to be removed before
1064
+ their parents, potentially making the parent directory empty and
1065
+ eligible for removal in the same pass.
1066
+
1067
+ Args:
1068
+ path (str): The absolute or relative path to the directory to scan.
1069
+ """
1070
+ # Walk the directory tree in a bottom-up manner (topdown=False)
1071
+ for dirpath, dirnames, filenames in os.walk(path, topdown=False):
1072
+ # A directory is considered empty if it has no subdirectories and no files
1073
+ if not dirnames and not filenames:
1074
+ try:
1075
+ shutil.rmtree(dirpath)
1076
+ except OSError as e:
1077
+ # This might happen due to permissions issues
1078
+ print(f"Error removing {dirpath}: {e}")
1079
+
1080
+
1081
+
957
1082
  def infer_slice_spacing(vols):
958
1083
  # In case spacing between slices is not (correctly) encoded in
959
1084
  # DICOM it can be inferred from the slice locations.
@@ -1010,3 +1135,4 @@ def infer_slice_spacing(vols):
1010
1135
 
1011
1136
 
1012
1137
 
1138
+
dbdicom/register.py CHANGED
@@ -96,6 +96,27 @@ def index(dbtree, entity):
96
96
  if sr['SeriesInstanceUID'] == series_uid:
97
97
  return list(sr['instances'].values())
98
98
 
99
+ def remove(dbtree, entity):
100
+ if len(entity)==2:
101
+ patient_id = entity[1]
102
+ for pt in sorted(dbtree, key=lambda pt: pt['PatientID']):
103
+ if pt['PatientID'] == patient_id:
104
+ dbtree.remove(pt)
105
+ elif len(entity)==3:
106
+ study_uid = uid(dbtree, entity)
107
+ for pt in sorted(dbtree, key=lambda pt: pt['PatientID']):
108
+ for st in sorted(pt['studies'], key=lambda st: st['StudyInstanceUID']):
109
+ if st['StudyInstanceUID'] == study_uid:
110
+ pt['studies'].remove(st)
111
+ elif len(entity)==4:
112
+ series_uid = uid(dbtree, entity)
113
+ for pt in sorted(dbtree, key=lambda pt: pt['PatientID']):
114
+ for st in sorted(pt['studies'], key=lambda st: st['StudyInstanceUID']):
115
+ for sr in sorted(st['series'], key=lambda sr: sr['SeriesNumber']):
116
+ if sr['SeriesInstanceUID'] == series_uid:
117
+ st['series'].remove(sr)
118
+ return dbtree
119
+
99
120
 
100
121
  def drop(dbtree, relpaths):
101
122
  for pt in sorted(dbtree[:], key=lambda pt: pt['PatientID']):
@@ -110,7 +110,7 @@ def from_volume(vol:vreg.Volume3D):
110
110
 
111
111
  # Assign parameters using dims as DICOM keywords
112
112
  for ax_i, axis in enumerate(vol.dims):
113
- val = vol.coords[(ax_i,) + tuple(indices)]
113
+ val = vol.coords[ax_i][indices]
114
114
 
115
115
  sequence, attr = axis.split("/")
116
116
  if not hasattr(frame_ds, sequence):
dbdicom/utils/arrays.py CHANGED
@@ -1,40 +1,128 @@
1
1
  import numpy as np
2
2
 
3
+ from typing import List, Tuple
3
4
 
4
- def meshvals(coords):
5
- # Input array shape: (d, f) with d = nr of dims and f = nr of frames
6
- # Output array shape: (d, f1,..., fd)
7
- if coords.size == 0:
8
- return np.array([])
9
- # Sort by column
10
- sorted_indices = np.lexsort(coords[::-1])
11
- sorted_array = coords[:, sorted_indices]
12
- # Find shape
13
- shape = _mesh_shape(sorted_array)
14
- # Reshape
15
- mesh_array = sorted_array.reshape(shape)
16
- return mesh_array, sorted_indices
17
-
18
-
19
- def _mesh_shape(sorted_array):
20
-
21
- nd = np.unique(sorted_array[0,:]).size
22
- shape = (sorted_array.shape[0], nd)
23
-
24
- for dim in range(1,shape[0]):
25
- shape_dim = (shape[0], np.prod(shape[1:]), -1)
26
- sorted_array = sorted_array.reshape(shape_dim)
27
- nd = [np.unique(sorted_array[dim,d,:]).size for d in range(shape_dim[1])]
28
- shape = shape + (max(nd),)
29
-
30
- if np.prod(shape) != sorted_array.size:
5
+
6
+ def meshvals(arrays) -> Tuple[List[np.ndarray], np.ndarray]:
7
+ """
8
+ Lexicographically sort flattened N coordinate arrays and reshape back to inferred grid shape,
9
+ preserving original type of each input array.
10
+
11
+ Parameters
12
+ ----------
13
+ *arrays : array-like
14
+ Flattened coordinate arrays of the same length. Can be numbers, strings, or list objects.
15
+
16
+ Returns
17
+ -------
18
+ sorted_arrays : list[np.ndarray]
19
+ Coordinate arrays reshaped to inferred N-D grid shape, dtype/type preserved.
20
+ indices : np.ndarray
21
+ Permutation indices applied to the flattened arrays.
22
+ shape : tuple[int, ...]
23
+ Inferred grid shape (number of unique values per axis).
24
+ """
25
+ # Remember original type/dtype for each array
26
+ orig_types = [a.dtype if isinstance(a[0], np.ndarray) else type(a[0]) for a in arrays]
27
+
28
+ # Convert non arrays to object arrays
29
+ arrs = []
30
+ for a in arrays:
31
+ arrs_a = np.empty(len(a), dtype=object)
32
+ arrs_a[:] = a
33
+ arrs.append(arrs_a)
34
+
35
+ # Stack arrays as columns (M x N)
36
+ coords = np.stack(arrs, axis=1)
37
+
38
+ # Lexicographic sort using structured array
39
+ indices = np.lexsort(coords.T[::-1])
40
+ sorted_coords = coords[indices]
41
+
42
+ # Check that all coordinates are unique
43
+ points = [tuple(col) for col in sorted_coords]
44
+ if not all_elements_unique(points):
31
45
  raise ValueError(
32
- 'Improper dimensions for the series. This usually means '
33
- 'that there are multiple images at the same location, \n or that '
34
- 'there are no images at one or more locations. \n\n'
35
- 'Make sure to specify proper dimensions when reading a pixel array or volume. \n'
36
- 'If the default dimensions of pixel_array (InstanceNumber) generate this error, '
37
- 'the DICOM data may be corrupted.'
38
- )
39
-
40
- return shape
46
+ f"Improper coordinates. Coordinate values are not unique."
47
+ )
48
+
49
+ # Infer shape from unique values per axis
50
+ shape = tuple(len(np.unique(sorted_coords[:, i])) for i in range(sorted_coords.shape[1]))
51
+
52
+ # Check perfect grid
53
+ if np.prod(shape) != sorted_coords.shape[0]:
54
+ raise ValueError(
55
+ f"Coordinates do not form a perfect Cartesian grid: inferred shape {shape} "
56
+ f"does not match number of points {sorted_coords.shape[0]}"
57
+ )
58
+
59
+ # Split back into individual arrays and cast to original type
60
+ sorted_arrays = []
61
+ for i, orig_type in enumerate(orig_types):
62
+ arr = sorted_coords[:, i]
63
+ arr = arr.astype(orig_type).reshape(shape)
64
+ sorted_arrays.append(arr)
65
+
66
+ return sorted_arrays, indices
67
+
68
+
69
+ def all_elements_unique(items):
70
+ """
71
+ The most general uniqueness check, but also the slowest (O(n^2)).
72
+
73
+ It works for ANY type that supports equality checking (==), including
74
+ lists, dicts, and custom objects, without requiring them to be hashable.
75
+ """
76
+ for i in range(len(items)):
77
+ for j in range(i + 1, len(items)):
78
+ if items[i] == items[j]:
79
+ return False
80
+ return True
81
+
82
+
83
+
84
+ # def NEWmeshvals(coords):
85
+ # stack_coords = [np.array(c, dtype=object) for c in coords]
86
+ # stack_coords = np.stack(stack_coords)
87
+ # mesh_coords, sorted_indices = _meshvals(stack_coords)
88
+ # mesh_coords = [mesh_coords[d,...] for d in range(mesh_coords.shape[0])]
89
+ # return mesh_coords, sorted_indices
90
+
91
+
92
+ # def _meshvals(coords):
93
+ # # Input array shape: (d, f) with d = nr of dims and f = nr of frames
94
+ # # Output array shape: (d, f1,..., fd)
95
+ # if coords.size == 0:
96
+ # return np.array([])
97
+ # # Sort by column
98
+ # sorted_indices = np.lexsort(coords[::-1])
99
+ # sorted_array = coords[:, sorted_indices]
100
+ # # Find shape
101
+ # shape = _mesh_shape(sorted_array)
102
+ # # Reshape
103
+ # mesh_array = sorted_array.reshape(shape)
104
+ # return mesh_array, sorted_indices
105
+
106
+
107
+ # def _mesh_shape(sorted_array):
108
+
109
+ # nd = np.unique(sorted_array[0,:]).size
110
+ # shape = (sorted_array.shape[0], nd)
111
+
112
+ # for dim in range(1,shape[0]):
113
+ # shape_dim = (shape[0], np.prod(shape[1:]), -1)
114
+ # sorted_array = sorted_array.reshape(shape_dim)
115
+ # nd = [np.unique(sorted_array[dim,d,:]).size for d in range(shape_dim[1])]
116
+ # shape = shape + (max(nd),)
117
+
118
+ # if np.prod(shape) != sorted_array.size:
119
+ # raise ValueError(
120
+ # 'Improper dimensions for the series. This usually means '
121
+ # 'that there are multiple images at the same location, \n or that '
122
+ # 'there are no images at one or more locations. \n\n'
123
+ # 'Make sure to specify proper dimensions when reading a pixel array or volume. \n'
124
+ # 'If the default dimensions of pixel_array (InstanceNumber) generate this error, '
125
+ # 'the DICOM data may be corrupted.'
126
+ # )
127
+
128
+ # return shape
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbdicom
3
- Version: 0.3.9
3
+ Version: 0.3.10
4
4
  Summary: A pythonic interface for reading and writing DICOM databases
5
5
  Author-email: Steven Sourbron <s.sourbron@sheffield.ac.uk>, Ebony Gunwhy <e.gunwhy@sheffield.ac.uk>
6
6
  Project-URL: Homepage, https://openmiblab.github.io/dbdicom/
@@ -1,10 +1,10 @@
1
1
  dbdicom/__init__.py,sha256=dW5aezonmMc_41Dp1PuYmXQlr307RkyJxsJuetkpWso,87
2
- dbdicom/api.py,sha256=oIvTktChBoEl4z0NXHMPVn2ZnPxUUXtZahwrIM7zfx0,14426
2
+ dbdicom/api.py,sha256=5jOJjwOahDrj97Hu0XEJbryvhihpv4iuPSXdX8INpsE,14731
3
3
  dbdicom/const.py,sha256=BqBiRRjeiSqDr1W6YvaayD8WKCjG4Cny2NT0GeLM6bI,4269
4
4
  dbdicom/database.py,sha256=mkYQAAf9fETzUhSQflQFp7RQUBdPlSlDty9nn6KY1jQ,4771
5
- dbdicom/dataset.py,sha256=2_MB7t9nMzTyq6sEhbu__MjU1VEnO0MdZuhzZK13a1I,14433
6
- dbdicom/dbd.py,sha256=AP3BGcwWXnHwSov9kcm4CdWzgIDKod9WiGFNOf2aHLE,39799
7
- dbdicom/register.py,sha256=_NyNbOEAN_hkwjxupNpr9F5DWUwARCsci8knK41-EsA,13931
5
+ dbdicom/dataset.py,sha256=pokXlcXLM33OdqMKGfVcWHlVE9ez4iBfpoefVWw1ob8,14421
6
+ dbdicom/dbd.py,sha256=isqBzCE5bxfixZTH5ShNmJ123vCAhueGxV7pCTrqBF8,44035
7
+ dbdicom/register.py,sha256=5yXnTbRUu8rYJqeIbSv5SiRf2E4BZ0JZyvKm_xvaXZQ,14944
8
8
  dbdicom/external/__init__.py,sha256=XNQqfspyf6vFGedXlRKZsUB8k8E-0W19Uamwn8Aioxo,316
9
9
  dbdicom/external/__pycache__/__init__.cpython-311.pyc,sha256=pXAQ35ixd92fm6YcuHgzR1t6RcASQ-cHhU1wOA5b8sw,542
10
10
  dbdicom/external/dcm4che/README.md,sha256=0aAGRs36W3_0s5LzWHRGf_tqariS_JP4iJggaxnD4Xw,8987
@@ -35,20 +35,20 @@ dbdicom/external/dcm4che/lib/windows-x86/clib_jiio_util.dll,sha256=wi4yyrI1gTRo_
35
35
  dbdicom/external/dcm4che/lib/windows-x86/opencv_java.dll,sha256=QanyzLy0Cd79-aOVPwOcXwikUYeutne0Au-Um91_B4M,8505856
36
36
  dbdicom/external/dcm4che/lib/windows-x86-64/opencv_java.dll,sha256=TmjW2SbG4MR3GQ95T8xCVVDLgsdKukgaHBPUvWkfXp8,11039232
37
37
  dbdicom/sop_classes/ct_image.py,sha256=16PNv_0e1_7cfxE12JWlx5YQeaTAQVzwtXTjxs3aonk,2812
38
- dbdicom/sop_classes/enhanced_mr_image.py,sha256=8JWg8I86YtYBKYgiZdsA-OHPFS6_1GpW4lYCudKMAI4,33379
38
+ dbdicom/sop_classes/enhanced_mr_image.py,sha256=6x4CEd982i64e90ZlFDKNSc83XHC2k2DVit1iyjXjCU,33368
39
39
  dbdicom/sop_classes/mr_image.py,sha256=1biIw7R26Fc38FAeSlWxd29VO17e8cEQdDIdLbeXTzw,10959
40
40
  dbdicom/sop_classes/parametric_map.py,sha256=2OKBuC2bo03OEpKqimQS-nVGFp1cKRPYwVgmDGVf1JU,12288
41
41
  dbdicom/sop_classes/secondary_capture.py,sha256=wgNRX8qyhV7HR7Jq2tQWPPuGpiRzYl6qPOgK6qFbPUc,4541
42
42
  dbdicom/sop_classes/segmentation.py,sha256=I8-PciIoIz27_-dZ4esBZSw0TBBbO8KbNYTiTmVe62g,11465
43
43
  dbdicom/sop_classes/ultrasound_multiframe_image.py,sha256=j3KN5R90j6WwPMy01hAN2_XSum5TvksF2MYoNGfX_yE,2797
44
44
  dbdicom/sop_classes/xray_angiographic_image.py,sha256=nWysCGaEWKVNItnOgyJfcGMpS3oEK1T0_uNR2D7p0Ls,3270
45
- dbdicom/utils/arrays.py,sha256=cZo6hKk-pg_e2WCs9vxW9dxX04gmH3EwSZKFX1n8pq4,1451
45
+ dbdicom/utils/arrays.py,sha256=_dJGFQPVRfchIRN6vra08RBYnEezobclHv5rEndQ3OA,4588
46
46
  dbdicom/utils/dcm4che.py,sha256=Vxq8NYWWK3BuqJkzhBQ89oMqzJlnxqTxgsgTo_Frznc,2317
47
47
  dbdicom/utils/files.py,sha256=qhWNJqeWnRjDNbERpC6Mz962_TW9mFdvd2lnBbK3xt4,2259
48
48
  dbdicom/utils/image.py,sha256=zRM1O0bxPp-qpf3Iv_GRS1omKaMN1SgSkAwufWLJ0Fk,3863
49
49
  dbdicom/utils/pydicom_dataset.py,sha256=XM3EERsCWPlEaUzVaFQSbPNiNbEGwxIbf-sUKKf_YxA,12755
50
- dbdicom-0.3.9.dist-info/licenses/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
51
- dbdicom-0.3.9.dist-info/METADATA,sha256=aNhxdZoSLOvA-OIJOPGsL-BPWjUWFoNNJd2ICWjZUGk,1030
52
- dbdicom-0.3.9.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
53
- dbdicom-0.3.9.dist-info/top_level.txt,sha256=nJWxXg4YjD6QblfmhrzTMXcr8FSKNc0Yk-CAIDUsYkQ,8
54
- dbdicom-0.3.9.dist-info/RECORD,,
50
+ dbdicom-0.3.10.dist-info/licenses/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
51
+ dbdicom-0.3.10.dist-info/METADATA,sha256=0BoheAKLRKF5pClyGgZqc1Y07-AH3I7IbvB-Mh9tQQA,1031
52
+ dbdicom-0.3.10.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
53
+ dbdicom-0.3.10.dist-info/top_level.txt,sha256=nJWxXg4YjD6QblfmhrzTMXcr8FSKNc0Yk-CAIDUsYkQ,8
54
+ dbdicom-0.3.10.dist-info/RECORD,,