Skip to content

Commit 4063114

Browse files
moloneyeffigies
authored andcommitted
BF+TST: Test and fix a bunch of multiframe fixes
Corrects issue where order of slice indices was assumed to match the order needed to move along the direction of the slice normal, which resulted in slice orientation flips. Ignores indices that don't evenly divide data, and at the end will try to combine those indices (if needed) into a single tuple index.
1 parent c49dff2 commit 4063114

File tree

2 files changed

+203
-53
lines changed

2 files changed

+203
-53
lines changed

nibabel/nicom/dicomwrappers.py

Lines changed: 94 additions & 30 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 = [self.shared.PlanePositionSequence[0].ImagePositionPatient]
474+
except AttributeError:
475+
try:
476+
frame_ipps = [f.PlanePositionSequence[0].ImagePositionPatient for f in self.frames]
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,20 +575,70 @@ 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)
578+
# Determine the shape and which indices to use
579+
shape = [rows, cols]
580+
curr_parts = n_frames
581+
frames_per_part = 1
582+
del_indices = {}
583+
for row_idx, row in enumerate(frame_indices.T):
584+
if curr_parts == 1:
585+
break
586+
unique = np.unique(row)
587+
count = len(unique)
588+
if count == 1:
589+
continue
590+
# Replace slice indices with order determined from slice positions along normal
591+
if len(shape) == 2:
592+
row = self._frame_slc_ord
593+
frame_indices.T[row_idx, :] = row
594+
unique = np.unique(row)
595+
if len(unique) != count:
596+
raise WrapperError("Number of slice indices and positions don't match")
597+
new_parts, leftover = divmod(curr_parts, count)
598+
allowed_val_counts = [new_parts * frames_per_part]
599+
if len(shape) > 2:
600+
# Except for the slice dim, having a unique value for each frame is valid
601+
allowed_val_counts.append(n_frames)
602+
if leftover != 0 or any(
603+
np.count_nonzero(row == val) not in allowed_val_counts for val in unique
604+
):
605+
if len(shape) == 2:
606+
raise WrapperError('Missing slices from multiframe')
607+
del_indices[row_idx] = count
608+
continue
609+
frames_per_part *= count
610+
shape.append(count)
611+
curr_parts = new_parts
612+
if del_indices:
613+
if curr_parts > 1:
614+
ns_failed = [k for k, v in del_indices.items() if v != 1]
615+
if len(ns_failed) > 1:
616+
# If some indices weren't used yet but we still have unaccounted for
617+
# partitions, try combining indices into single tuple and using that
618+
tup_dtype = np.dtype(','.join(['I'] * len(ns_failed)))
619+
row = [tuple(x for x in vals) for vals in frame_indices[:, ns_failed]]
620+
row = np.array(row, dtype=tup_dtype)
621+
frame_indices = np.delete(frame_indices, np.array(list(del_indices.keys())), axis=1)
622+
if curr_parts > 1 and len(ns_failed) > 1:
623+
unique = np.unique(row, axis=0)
624+
count = len(unique)
625+
new_parts, rem = divmod(curr_parts, count)
626+
allowed_val_counts = [new_parts * frames_per_part, n_frames]
627+
if rem == 0 and all(
628+
np.count_nonzero(row == val) in allowed_val_counts for val in unique
629+
):
630+
shape.append(count)
631+
curr_parts = new_parts
632+
ord_vals = np.argsort(unique)
633+
order = {tuple(unique[i]): ord_vals[i] for i in range(count)}
634+
ord_row = np.array([order[tuple(v)] for v in row])
635+
frame_indices = np.hstack(
636+
[frame_indices, np.array(ord_row).reshape((n_frames, 1))]
637+
)
638+
if curr_parts > 1:
639+
raise WrapperError('Unable to determine sorting of final dimension(s)')
557640
# Store frame indices
558641
self._frame_indices = frame_indices
559-
# Determine size of any extra-spatial dimensions
560-
ns_unique = [len(np.unique(row)) for row in self._frame_indices.T]
561-
shape = (rows, cols) + tuple(x for i, x in enumerate(ns_unique) if i == 0 or x != 1)
562-
n_dim = len(shape)
563-
if n_dim > 3:
564-
n_vols = np.prod(shape[3:])
565-
n_frames_calc = n_vols * shape[2]
566-
if n_frames != n_frames_calc:
567-
raise WrapperError(
568-
f'Calculated # of frames ({n_frames_calc}={n_vols}*{shape[2]}) '
569-
f'of shape {shape} does not match NumberOfFrames {n_frames}.'
570-
)
571642
return tuple(shape)
572643

573644
@cached_property
@@ -607,18 +678,11 @@ def voxel_sizes(self):
607678
# Ensure values are float rather than Decimal
608679
return tuple(map(float, list(pix_space) + [zs]))
609680

610-
@cached_property
681+
@property
611682
def image_position(self):
612-
try:
613-
ipp = self.shared.PlanePositionSequence[0].ImagePositionPatient
614-
except AttributeError:
615-
try:
616-
ipp = self.frames[0].PlanePositionSequence[0].ImagePositionPatient
617-
except AttributeError:
618-
raise WrapperError('Cannot get image position from dicom')
619-
if ipp is None:
620-
return None
621-
return np.array(list(map(float, ipp)))
683+
if self._ipp is None:
684+
raise WrapperError('Not enough information for image_position_patient')
685+
return self._ipp
622686

623687
@cached_property
624688
def series_signature(self):

nibabel/nicom/tests/test_dicomwrappers.py

Lines changed: 109 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ def test_decimal_rescale():
364364
assert dw.get_data().dtype != np.dtype(object)
365365

366366

367-
def fake_frames(seq_name, field_name, value_seq):
367+
def fake_frames(seq_name, field_name, value_seq, frame_seq=None):
368368
"""Make fake frames for multiframe testing
369369
370370
Parameters
@@ -375,6 +375,8 @@ def fake_frames(seq_name, field_name, value_seq):
375375
name of field within sequence
376376
value_seq : length N sequence
377377
sequence of values
378+
frame_seq : length N list
379+
previous result from this function to update
378380
379381
Returns
380382
-------
@@ -386,19 +388,28 @@ def fake_frames(seq_name, field_name, value_seq):
386388
class Fake:
387389
pass
388390

389-
frames = []
390-
for value in value_seq:
391-
fake_frame = Fake()
391+
if frame_seq == None:
392+
frame_seq = [Fake() for _ in range(len(value_seq))]
393+
for value, fake_frame in zip(value_seq, frame_seq):
392394
fake_element = Fake()
393395
setattr(fake_element, field_name, value)
394396
setattr(fake_frame, seq_name, [fake_element])
395-
frames.append(fake_frame)
396-
return frames
397+
return frame_seq
397398

398399

399-
def fake_shape_dependents(div_seq, sid_seq=None, sid_dim=None):
400+
def fake_shape_dependents(
401+
div_seq,
402+
sid_seq=None,
403+
sid_dim=None,
404+
ipp_seq=None,
405+
slice_dim=None,
406+
flip_ipp_idx_corr=False,
407+
):
400408
"""Make a fake dictionary of data that ``image_shape`` is dependent on.
401409
410+
If you are providing the ``ipp_seq`` argument, they should be generated using
411+
a slice normal aligned with the z-axis (i.e. iop == (0, 1, 0, 1, 0, 0)).
412+
402413
Parameters
403414
----------
404415
div_seq : list of tuples
@@ -407,39 +418,86 @@ def fake_shape_dependents(div_seq, sid_seq=None, sid_dim=None):
407418
list of values to use for the `StackID` of each frame.
408419
sid_dim : int
409420
the index of the column in 'div_seq' to use as 'sid_seq'
421+
ipp_seq : list of tuples
422+
list of values to use for `ImagePositionPatient` for each frame
423+
slice_dim : int
424+
the index of the column in 'div_seq' corresponding to slices
425+
flip_ipp_idx_corr : bool
426+
generate ipp values so slice location is negatively correlated with slice index
410427
"""
411428

412-
class DimIdxSeqElem:
429+
class PrintBase:
430+
def __repr__(self):
431+
attr_strs = []
432+
for attr in dir(self):
433+
if attr[0].isupper():
434+
attr_strs.append(f'{attr}={getattr(self, attr)}')
435+
return f"{self.__class__.__name__}({', '.join(attr_strs)})"
436+
437+
class DimIdxSeqElem(PrintBase):
413438
def __init__(self, dip=(0, 0), fgp=None):
414439
self.DimensionIndexPointer = dip
415440
if fgp is not None:
416441
self.FunctionalGroupPointer = fgp
417442

418-
class FrmContSeqElem:
443+
class FrmContSeqElem(PrintBase):
419444
def __init__(self, div, sid):
420445
self.DimensionIndexValues = div
421446
self.StackID = sid
422447

423-
class PerFrmFuncGrpSeqElem:
424-
def __init__(self, div, sid):
448+
class PlnPosSeqElem(PrintBase):
449+
def __init__(self, ipp):
450+
self.ImagePositionPatient = ipp
451+
452+
class PlnOrientSeqElem(PrintBase):
453+
def __init__(self, iop):
454+
self.ImageOrientationPatient = iop
455+
456+
class PerFrmFuncGrpSeqElem(PrintBase):
457+
def __init__(self, div, sid, ipp, iop):
425458
self.FrameContentSequence = [FrmContSeqElem(div, sid)]
459+
self.PlanePositionSequence = [PlnPosSeqElem(ipp)]
460+
self.PlaneOrientationSequence = [PlnOrientSeqElem(iop)]
426461

427462
# if no StackID values passed in then use the values at index 'sid_dim' in
428463
# the value for DimensionIndexValues for it
464+
n_indices = len(div_seq[0])
429465
if sid_seq is None:
430466
if sid_dim is None:
431467
sid_dim = 0
432468
sid_seq = [div[sid_dim] for div in div_seq]
433-
# create the DimensionIndexSequence
469+
# Determine slice_dim and create per-slice ipp information
470+
if slice_dim is None:
471+
slice_dim = 1 if sid_dim == 0 else 0
434472
num_of_frames = len(div_seq)
435-
dim_idx_seq = [DimIdxSeqElem()] * num_of_frames
473+
frame_slc_indices = np.array(div_seq)[:, slice_dim]
474+
uniq_slc_indices = np.unique(frame_slc_indices)
475+
n_slices = len(uniq_slc_indices)
476+
assert num_of_frames % n_slices == 0
477+
iop_seq = [(0.0, 1.0, 0.0, 1.0, 0.0, 0.0) for _ in range(num_of_frames)]
478+
if ipp_seq is None:
479+
slc_locs = np.linspace(-1.0, 1.0, n_slices)
480+
if flip_ipp_idx_corr:
481+
slc_locs = slc_locs[::-1]
482+
slc_idx_loc = {
483+
div_idx: slc_locs[arr_idx] for arr_idx, div_idx in enumerate(np.sort(uniq_slc_indices))
484+
}
485+
ipp_seq = [(-1.0, -1.0, slc_idx_loc[idx]) for idx in frame_slc_indices]
486+
else:
487+
assert flip_ipp_idx_corr is False # caller can flip it themselves
488+
assert len(ipp_seq) == num_of_frames
489+
# create the DimensionIndexSequence
490+
dim_idx_seq = [DimIdxSeqElem()] * n_indices
436491
# add an entry for StackID into the DimensionIndexSequence
437492
if sid_dim is not None:
438493
sid_tag = pydicom.datadict.tag_for_keyword('StackID')
439494
fcs_tag = pydicom.datadict.tag_for_keyword('FrameContentSequence')
440495
dim_idx_seq[sid_dim] = DimIdxSeqElem(sid_tag, fcs_tag)
441496
# create the PerFrameFunctionalGroupsSequence
442-
frames = [PerFrmFuncGrpSeqElem(div, sid) for div, sid in zip(div_seq, sid_seq)]
497+
frames = [
498+
PerFrmFuncGrpSeqElem(div, sid, ipp, iop)
499+
for div, sid, ipp, iop in zip(div_seq, sid_seq, ipp_seq, iop_seq)
500+
]
443501
return {
444502
'NumberOfFrames': num_of_frames,
445503
'DimensionIndexSequence': dim_idx_seq,
@@ -480,7 +538,15 @@ def test_shape(self):
480538
# PerFrameFunctionalGroupsSequence does not match NumberOfFrames
481539
with pytest.raises(AssertionError):
482540
dw.image_shape
483-
# check 3D shape when StackID index is 0
541+
# check 2D shape with StackID index is 0
542+
div_seq = ((1, 1),)
543+
fake_mf.update(fake_shape_dependents(div_seq, sid_dim=0))
544+
assert MFW(fake_mf).image_shape == (32, 64)
545+
# Check 2D shape with extraneous extra indices
546+
div_seq = ((1, 1, 2),)
547+
fake_mf.update(fake_shape_dependents(div_seq, sid_dim=0))
548+
assert MFW(fake_mf).image_shape == (32, 64)
549+
# Check 3D shape when StackID index is 0
484550
div_seq = ((1, 1), (1, 2), (1, 3), (1, 4))
485551
fake_mf.update(fake_shape_dependents(div_seq, sid_dim=0))
486552
assert MFW(fake_mf).image_shape == (32, 64, 4)
@@ -541,6 +607,18 @@ def test_shape(self):
541607
div_seq = ((1, 1, 1), (2, 1, 1), (1, 1, 2), (2, 1, 2), (1, 1, 3), (2, 1, 3))
542608
fake_mf.update(fake_shape_dependents(div_seq, sid_dim=1))
543609
assert MFW(fake_mf).image_shape == (32, 64, 2, 3)
610+
# Test with combo indices, here with the last two needing to be combined into
611+
# a single index corresponding to [(1, 1), (1, 1), (2, 1), (2, 1), (2, 2), (2, 2)]
612+
div_seq = (
613+
(1, 1, 1, 1),
614+
(1, 2, 1, 1),
615+
(1, 1, 2, 1),
616+
(1, 2, 2, 1),
617+
(1, 1, 2, 2),
618+
(1, 2, 2, 2),
619+
)
620+
fake_mf.update(fake_shape_dependents(div_seq, sid_dim=0))
621+
assert MFW(fake_mf).image_shape == (32, 64, 2, 3)
544622

545623
def test_iop(self):
546624
# Test Image orient patient for multiframe
@@ -608,22 +686,30 @@ def test_image_position(self):
608686
with pytest.raises(didw.WrapperError):
609687
dw.image_position
610688
# Make a fake frame
611-
fake_frame = fake_frames(
612-
'PlanePositionSequence', 'ImagePositionPatient', [[-2.0, 3.0, 7]]
613-
)[0]
614-
fake_mf['SharedFunctionalGroupsSequence'] = [fake_frame]
689+
iop = [0, 1, 0, 1, 0, 0]
690+
frames = fake_frames('PlaneOrientationSequence', 'ImageOrientationPatient', [iop])
691+
frames = fake_frames(
692+
'PlanePositionSequence', 'ImagePositionPatient', [[-2.0, 3.0, 7]], frames
693+
)
694+
fake_mf['SharedFunctionalGroupsSequence'] = frames
615695
assert_array_equal(MFW(fake_mf).image_position, [-2, 3, 7])
616696
fake_mf['SharedFunctionalGroupsSequence'] = [None]
617697
with pytest.raises(didw.WrapperError):
618698
MFW(fake_mf).image_position
619-
fake_mf['PerFrameFunctionalGroupsSequence'] = [fake_frame]
699+
fake_mf['PerFrameFunctionalGroupsSequence'] = frames
620700
assert_array_equal(MFW(fake_mf).image_position, [-2, 3, 7])
621701
# Check lists of Decimals work
622-
fake_frame.PlanePositionSequence[0].ImagePositionPatient = [
702+
frames[0].PlanePositionSequence[0].ImagePositionPatient = [
623703
Decimal(str(v)) for v in [-2, 3, 7]
624704
]
625705
assert_array_equal(MFW(fake_mf).image_position, [-2, 3, 7])
626706
assert MFW(fake_mf).image_position.dtype == float
707+
# We should get minimum along slice normal with multiple frames
708+
frames = fake_frames('PlaneOrientationSequence', 'ImageOrientationPatient', [iop] * 2)
709+
ipps = [[-2.0, 3.0, 7], [-2.0, 3.0, 6]]
710+
frames = fake_frames('PlanePositionSequence', 'ImagePositionPatient', ipps, frames)
711+
fake_mf['PerFrameFunctionalGroupsSequence'] = frames
712+
assert_array_equal(MFW(fake_mf).image_position, [-2, 3, 6])
627713

628714
@dicom_test
629715
@pytest.mark.xfail(reason='Not packaged in install', raises=FileNotFoundError)
@@ -644,7 +730,7 @@ def test_data_real(self):
644730
if endian_codes[data.dtype.byteorder] == '>':
645731
data = data.byteswap()
646732
dat_str = data.tobytes()
647-
assert sha1(dat_str).hexdigest() == '149323269b0af92baa7508e19ca315240f77fa8c'
733+
assert sha1(dat_str).hexdigest() == 'dc011bb49682fb78f3cebacf965cb65cc9daba7d'
648734

649735
@dicom_test
650736
def test_slicethickness_fallback(self):
@@ -665,7 +751,7 @@ def test_data_derived_shape(self):
665751
def test_data_trace(self):
666752
# Test that a standalone trace volume is found and not dropped
667753
dw = didw.wrapper_from_file(DATA_FILE_SIEMENS_TRACE)
668-
assert dw.image_shape == (72, 72, 39, 1)
754+
assert dw.image_shape == (72, 72, 39)
669755

670756
@dicom_test
671757
@needs_nibabel_data('nitest-dicom')

0 commit comments

Comments
 (0)