Skip to content

Commit 33c6721

Browse files
authored
Merge pull request #1340 from moloney/bf-multiframe
Bunch of multiframe fixes
2 parents 5d10c5b + 629dbb5 commit 33c6721

File tree

3 files changed

+323
-61
lines changed

3 files changed

+323
-61
lines changed

Changelog

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,33 @@ Eric Larson (EL), Demian Wassermann, Stephan Gerhard and Ross Markello (RM).
2525

2626
References like "pr/298" refer to github pull request numbers.
2727

28+
Upcoming release (To be determined)
29+
===================================
30+
31+
New features
32+
------------
33+
34+
Enhancements
35+
------------
36+
* Ability to read data from many multiframe DICOM files that previously generated errors
37+
38+
Bug fixes
39+
---------
40+
* Fixed multiframe DICOM issue where data could be flipped along slice dimension relative to the
41+
affine
42+
* Fixed multiframe DICOM issue where ``image_position`` and the translation component in the
43+
``affine`` could be incorrect
44+
45+
Documentation
46+
-------------
47+
48+
Maintenance
49+
-----------
50+
51+
API changes and deprecations
52+
----------------------------
53+
54+
2855
5.2.1 (Monday 26 February 2024)
2956
===============================
3057

nibabel/nicom/dicomwrappers.py

Lines changed: 114 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,25 @@ def __init__(self, dcm_data):
467467
self.shared = dcm_data.get('SharedFunctionalGroupsSequence')[0]
468468
except TypeError:
469469
raise WrapperError('SharedFunctionalGroupsSequence is empty.')
470+
# Try to determine slice order and minimal image position patient
471+
self._frame_slc_ord = self._ipp = None
472+
try:
473+
frame_ipps = [f.PlanePositionSequence[0].ImagePositionPatient for f in self.frames]
474+
except AttributeError:
475+
try:
476+
frame_ipps = [self.shared.PlanePositionSequence[0].ImagePositionPatient]
477+
except AttributeError:
478+
frame_ipps = None
479+
if frame_ipps is not None and all(ipp is not None for ipp in frame_ipps):
480+
frame_ipps = [np.array(list(map(float, ipp))) for ipp in frame_ipps]
481+
frame_slc_pos = [np.inner(ipp, self.slice_normal) for ipp in frame_ipps]
482+
rnd_slc_pos = np.round(frame_slc_pos, 4)
483+
uniq_slc_pos = np.unique(rnd_slc_pos)
484+
pos_ord_map = {
485+
val: order for val, order in zip(uniq_slc_pos, np.argsort(uniq_slc_pos))
486+
}
487+
self._frame_slc_ord = [pos_ord_map[pos] for pos in rnd_slc_pos]
488+
self._ipp = frame_ipps[np.argmin(frame_slc_pos)]
470489
self._shape = None
471490

472491
@cached_property
@@ -509,14 +528,16 @@ def image_shape(self):
509528
if hasattr(first_frame, 'get') and first_frame.get([0x18, 0x9117]):
510529
# DWI image may include derived isotropic, ADC or trace volume
511530
try:
512-
anisotropic = pydicom.Sequence(
513-
frame
514-
for frame in self.frames
515-
if frame.MRDiffusionSequence[0].DiffusionDirectionality != 'ISOTROPIC'
516-
)
531+
aniso_frames = pydicom.Sequence()
532+
aniso_slc_ord = []
533+
for slc_ord, frame in zip(self._frame_slc_ord, self.frames):
534+
if frame.MRDiffusionSequence[0].DiffusionDirectionality != 'ISOTROPIC':
535+
aniso_frames.append(frame)
536+
aniso_slc_ord.append(slc_ord)
517537
# Image contains DWI volumes followed by derived images; remove derived images
518-
if len(anisotropic) != 0:
519-
self.frames = anisotropic
538+
if len(aniso_frames) != 0:
539+
self.frames = aniso_frames
540+
self._frame_slc_ord = aniso_slc_ord
520541
except IndexError:
521542
# Sequence tag is found but missing items!
522543
raise WrapperError('Diffusion file missing information')
@@ -554,23 +575,85 @@ def image_shape(self):
554575
raise WrapperError('Missing information, cannot remove indices with confidence.')
555576
derived_dim_idx = dim_seq.index(derived_tag)
556577
frame_indices = np.delete(frame_indices, derived_dim_idx, axis=1)
557-
# account for the 2 additional dimensions (row and column) not included
558-
# in the indices
559-
n_dim = frame_indices.shape[1] + 2
578+
dim_seq.pop(derived_dim_idx)
579+
# Determine the shape and which indices to use
580+
shape = [rows, cols]
581+
curr_parts = n_frames
582+
frames_per_part = 1
583+
del_indices = {}
584+
stackpos_tag = pydicom.datadict.tag_for_keyword('InStackPositionNumber')
585+
slice_dim_idx = dim_seq.index(stackpos_tag)
586+
for row_idx, row in enumerate(frame_indices.T):
587+
unique = np.unique(row)
588+
count = len(unique)
589+
if curr_parts == 1 or (count == 1 and row_idx != slice_dim_idx):
590+
del_indices[row_idx] = count
591+
continue
592+
# Replace slice indices with order determined from slice positions along normal
593+
if row_idx == slice_dim_idx:
594+
if len(shape) > 2:
595+
raise WrapperError('Non-singular index precedes the slice index')
596+
row = self._frame_slc_ord
597+
frame_indices.T[row_idx, :] = row
598+
unique = np.unique(row)
599+
if len(unique) != count:
600+
raise WrapperError("Number of slice indices and positions don't match")
601+
elif count == n_frames:
602+
if shape[-1] == 'remaining':
603+
raise WrapperError('At most one index have ambiguous size')
604+
shape.append('remaining')
605+
continue
606+
new_parts, leftover = divmod(curr_parts, count)
607+
expected = new_parts * frames_per_part
608+
if leftover != 0 or any(np.count_nonzero(row == val) != expected for val in unique):
609+
if row_idx == slice_dim_idx:
610+
raise WrapperError('Missing slices from multiframe')
611+
del_indices[row_idx] = count
612+
continue
613+
if shape[-1] == 'remaining':
614+
shape[-1] = new_parts
615+
frames_per_part *= shape[-1]
616+
new_parts = 1
617+
frames_per_part *= count
618+
shape.append(count)
619+
curr_parts = new_parts
620+
if shape[-1] == 'remaining':
621+
if curr_parts > 1:
622+
shape[-1] = curr_parts
623+
curr_parts = 1
624+
else:
625+
del_indices[len(shape)] = 1
626+
shape = shape[:-1]
627+
if del_indices:
628+
if curr_parts > 1:
629+
ns_failed = [k for k, v in del_indices.items() if v != 1]
630+
if len(ns_failed) > 1:
631+
# If some indices weren't used yet but we still have unaccounted for
632+
# partitions, try combining indices into single tuple and using that
633+
tup_dtype = np.dtype(','.join(['I'] * len(ns_failed)))
634+
row = [tuple(x for x in vals) for vals in frame_indices[:, ns_failed]]
635+
row = np.array(row, dtype=tup_dtype)
636+
frame_indices = np.delete(frame_indices, np.array(list(del_indices.keys())), axis=1)
637+
if curr_parts > 1 and len(ns_failed) > 1:
638+
unique = np.unique(row, axis=0)
639+
count = len(unique)
640+
new_parts, rem = divmod(curr_parts, count)
641+
allowed_val_counts = [new_parts * frames_per_part, n_frames]
642+
if rem == 0 and all(
643+
np.count_nonzero(row == val) in allowed_val_counts for val in unique
644+
):
645+
shape.append(count)
646+
curr_parts = new_parts
647+
ord_vals = np.argsort(unique)
648+
order = {tuple(unique[i]): ord_vals[i] for i in range(count)}
649+
ord_row = np.array([order[tuple(v)] for v in row])
650+
frame_indices = np.hstack(
651+
[frame_indices, np.array(ord_row).reshape((n_frames, 1))]
652+
)
653+
if curr_parts > 1:
654+
raise WrapperError('Unable to determine sorting of final dimension(s)')
560655
# Store frame indices
561656
self._frame_indices = frame_indices
562-
if n_dim < 4: # 3D volume
563-
return rows, cols, n_frames
564-
# More than 3 dimensions
565-
ns_unique = [len(np.unique(row)) for row in self._frame_indices.T]
566-
shape = (rows, cols) + tuple(ns_unique)
567-
n_vols = np.prod(shape[3:])
568-
n_frames_calc = n_vols * shape[2]
569-
if n_frames != n_frames_calc:
570-
raise WrapperError(
571-
f'Calculated # of frames ({n_frames_calc}={n_vols}*{shape[2]}) '
572-
f'of shape {shape} does not match NumberOfFrames {n_frames}.'
573-
)
574657
return tuple(shape)
575658

576659
@cached_property
@@ -610,18 +693,11 @@ def voxel_sizes(self):
610693
# Ensure values are float rather than Decimal
611694
return tuple(map(float, list(pix_space) + [zs]))
612695

613-
@cached_property
696+
@property
614697
def image_position(self):
615-
try:
616-
ipp = self.shared.PlanePositionSequence[0].ImagePositionPatient
617-
except AttributeError:
618-
try:
619-
ipp = self.frames[0].PlanePositionSequence[0].ImagePositionPatient
620-
except AttributeError:
621-
raise WrapperError('Cannot get image position from dicom')
622-
if ipp is None:
623-
return None
624-
return np.array(list(map(float, ipp)))
698+
if self._ipp is None:
699+
raise WrapperError('Not enough information for image_position_patient')
700+
return self._ipp
625701

626702
@cached_property
627703
def series_signature(self):
@@ -640,10 +716,11 @@ def get_data(self):
640716
raise WrapperError('No valid information for image shape')
641717
data = self.get_pixel_array()
642718
# Roll frames axis to last
643-
data = data.transpose((1, 2, 0))
644-
# Sort frames with first index changing fastest, last slowest
645-
sorted_indices = np.lexsort(self._frame_indices.T)
646-
data = data[..., sorted_indices]
719+
if len(data.shape) > 2:
720+
data = data.transpose((1, 2, 0))
721+
# Sort frames with first index changing fastest, last slowest
722+
sorted_indices = np.lexsort(self._frame_indices.T)
723+
data = data[..., sorted_indices]
647724
data = data.reshape(shape, order='F')
648725
return self._scale_data(data)
649726

0 commit comments

Comments
 (0)