Skip to content

Commit 3adb787

Browse files
authored
Merge pull request #1485 from MPIB/enh/filter_frames_broken_stackid
ENH: Multi frame filter to handle stack with multiple orientations
2 parents 9fa9550 + db8e0fc commit 3adb787

4 files changed

Lines changed: 101 additions & 1 deletion

File tree

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,6 @@
2222
[submodule "nibabel-data/dcm_qa_xa30"]
2323
path = nibabel-data/dcm_qa_xa30
2424
url = https://github.com/neurolabusc/dcm_qa_xa30.git
25+
[submodule "nibabel-data/dcm_qa_xa60"]
26+
path = nibabel-data/dcm_qa_xa60
27+
url = https://github.com/neurolabusc/dcm_qa_xa60.git

nibabel-data/dcm_qa_xa60

Submodule dcm_qa_xa60 added at c19de46

nibabel/nicom/dicomwrappers.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -597,7 +597,46 @@ def keep(self, frame) -> bool:
597597
return frame.MRDiffusionSequence[0].DiffusionDirectionality != 'ISOTROPIC'
598598

599599

600-
DEFAULT_FRAME_FILTERS = (FilterMultiStack(), FilterDwiIso())
600+
class FilterMultiOrient(FrameFilter):
601+
"""Filter out all but one orientation when a stack contains multiple orientations.
602+
603+
DICOM permits a stack to contain a group of frames with different
604+
orientations, but nibabel can only process one orientation at a time.
605+
This filter retains only the frames that belong to the first or a specific
606+
group index.
607+
"""
608+
609+
def __init__(self, keep_group=None):
610+
self._keep_group = keep_group if keep_group is not None else 0
611+
self._implicit = keep_group is None
612+
613+
def _frame_iop(self, frame):
614+
try:
615+
iop = frame.PlaneOrientationSequence[0].ImageOrientationPatient
616+
return tuple(round(float(v), 4) for v in iop)
617+
except AttributeError:
618+
return None
619+
620+
def applies(self, dcm_wrp) -> bool:
621+
iops = dict.fromkeys(self._frame_iop(f) for f in dcm_wrp.frames)
622+
if None in iops or len(iops) <= 1:
623+
return False
624+
if self._keep_group >= len(iops) or self._keep_group < 0:
625+
raise WrapperError(f'MultiOrientation group index must be in [0,{len(iops) - 1}]')
626+
self._selected = list(iops.keys())[self._keep_group]
627+
if self._implicit:
628+
warnings.warn(
629+
'Multiple frame orientations found in a single stack; '
630+
'nibabel can only process one orientation at a time. '
631+
'Retaining only frames matching the first orientation.'
632+
)
633+
return True
634+
635+
def keep(self, frame) -> bool:
636+
return self._frame_iop(frame) == self._selected
637+
638+
639+
DEFAULT_FRAME_FILTERS = (FilterMultiStack(), FilterDwiIso(), FilterMultiOrient())
601640

602641

603642
class MultiframeWrapper(Wrapper):

nibabel/nicom/tests/test_dicomwrappers.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import pytest
1313
from numpy.testing import assert_array_almost_equal, assert_array_equal
1414

15+
from ...openers import ImageOpener
1516
from ...tests.nibabel_data import get_nibabel_data, needs_nibabel_data
1617
from ...volumeutils import endian_codes
1718
from .. import dicomreaders as didr
@@ -39,6 +40,11 @@
3940
'dcm_qa_xa30',
4041
'In/20_DWI_dir80_AP/0001_1.3.12.2.1107.5.2.43.67093.2022071112140611403312307.dcm',
4142
)
43+
DATA_FILE_XA60_MULTI_ORIENT = pjoin(
44+
get_nibabel_data(),
45+
'dcm_qa_xa60',
46+
'In/XA60/DICOM/24100413/39280000/75739321',
47+
)
4248

4349
# This affine from our converted image was shown to match our image spatially
4450
# with an image from SPM DICOM conversion. We checked the matching with SPM
@@ -873,6 +879,57 @@ def test_data_trace(self):
873879
dw = didw.wrapper_from_file(DATA_FILE_SIEMENS_TRACE)
874880
assert dw.image_shape == (72, 72, 39)
875881

882+
@dicom_test
883+
@needs_nibabel_data('dcm_qa_xa60')
884+
def test_data_multi_orient_same_stack(self):
885+
# Test that a file with 3 orientations sharing the same StackID returns
886+
# only one stack/orientation
887+
dw = didw.wrapper_from_file(DATA_FILE_XA60_MULTI_ORIENT)
888+
assert dw.image_shape == (512, 512, 7)
889+
890+
@dicom_test
891+
@needs_nibabel_data('dcm_qa_xa60')
892+
def test_data_multi_orient_extract_all(self):
893+
with ImageOpener(DATA_FILE_XA60_MULTI_ORIENT) as fobj:
894+
dcm_data = pydicom.dcmread(fobj)
895+
for keep_group, nslices in enumerate([7, 3, 3]):
896+
dw = didw.wrapper_from_data(
897+
dcm_data, frame_filters=[didw.FilterMultiOrient(keep_group=keep_group)]
898+
)
899+
assert dw.image_shape == (512, 512, nslices)
900+
901+
@dicom_test
902+
def test_filter_multi_orient_unit(self):
903+
iop_a = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]
904+
iop_b = [0.0, 1.0, 0.0, 1.0, 0.0, 0.0]
905+
906+
class FakeFrame(pydicom.Dataset):
907+
def __init__(self, iop=None):
908+
super().__init__()
909+
if iop is not None:
910+
elem = pydicom.Dataset()
911+
elem.ImageOrientationPatient = iop
912+
self.PlaneOrientationSequence = [elem]
913+
914+
class FakeWrp:
915+
def __init__(self, frames):
916+
self.frames = frames
917+
918+
filt = didw.FilterMultiOrient()
919+
# Single orientation -> does not apply
920+
assert not filt.applies(FakeWrp([FakeFrame(iop_a), FakeFrame(iop_a)]))
921+
# Frame missing PlaneOrientationSequence -> does not apply
922+
assert not filt.applies(FakeWrp([FakeFrame(), FakeFrame()]))
923+
# Mixed orientations -> applies, keeps first group
924+
wrp = FakeWrp([FakeFrame(iop_a), FakeFrame(iop_b)])
925+
with pytest.warns(UserWarning, match='Multiple frame orientations'):
926+
assert filt.applies(wrp)
927+
assert filt.keep(wrp.frames[0])
928+
assert not filt.keep(wrp.frames[1])
929+
# Out-of-range group index
930+
with pytest.raises(didw.WrapperError):
931+
didw.FilterMultiOrient(keep_group=5).applies(wrp)
932+
876933
@dicom_test
877934
@needs_nibabel_data('nitest-dicom')
878935
def test_data_unreadable_private_headers(self):

0 commit comments

Comments
 (0)