Skip to content

Commit

Permalink
Merge pull request #82 from open-dicom/sequences
Browse files Browse the repository at this point in the history
Sequences
  • Loading branch information
ZviBaratz committed Jan 14, 2022
2 parents a5a8ce9 + 48cbaef commit acc82f6
Show file tree
Hide file tree
Showing 25 changed files with 305 additions and 106 deletions.
113 changes: 79 additions & 34 deletions src/dicom_parser/header.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
from pydicom.dataset import FileDataset

from dicom_parser.data_element import DataElement
from dicom_parser.messages import INVALID_ELEMENT_IDENTIFIER
from dicom_parser.messages import (
INVALID_ELEMENT_IDENTIFIER,
MISSING_HEADER_INFO,
UNREGISTERED_MODALITY,
)
from dicom_parser.utils import read_file, requires_pandas
from dicom_parser.utils.bids.bids_detector import BidsDetector
from dicom_parser.utils.format_header_df import format_header_df
Expand Down Expand Up @@ -38,16 +42,21 @@ class Header:

#: Header fields to pass to
#: :class:`~dicom_parser.utils.sequence_detector.sequence_detector.SequenceDetector`. # noqa: E501
sequence_identifiers = {
SEQUENCE_IDENTIFIERS = {
"Magnetic Resonance": [
"ScanningSequence",
"SequenceVariant",
"SeriesDescription",
"ImageType",
"ScanOptions",
"phase_encoding_direction",
]
}

DICTIONARY_APPENDICES = {
"Magnetic Resonance": ["phase_encoding_direction"]
}

#: Column names to use when converting to dataframe.
DATAFRAME_COLUMNS: Iterable[str] = ("Tag", "Keyword", "VR", "VM", "Value")

Expand All @@ -56,12 +65,8 @@ class Header:

#: Dictionary used to convert in-plane phase encoding direction to the
#: NIfTI appropriate equivalent.
PHASE_ENCODING_DIRECTION: Dict[str, str] = {"COL": "i", "ROW": "j"}
PHASE_ENCODING_SIGN: Dict[int, str] = {0: "-", 1: ""}
PHASE_ENCODING: Dict[Plane, Dict[str, str]] = {
Plane.AXIAL: {"i": "LR", "i-": "RL", "j": "PA", "j-": "AP"},
# Plane.SAGITTAL: {"i": "PA", "i-": "AP", "j": ""}
}
PHASE_ENCODING_DIRECTION: Dict[str, str] = {"ROW": "i", "COL": "j"}
PHASE_ENCODING_SIGN: Dict[int, str] = {0: "", 1: "-"}

#: Infer image plane from the rounded ImageOrientationPatient value.
#: Based on https://stackoverflow.com/a/56670334/4416932
Expand Down Expand Up @@ -105,7 +110,6 @@ def __init__(
self.bids_detector = bids_detector()
self.raw = read_file(raw, read_data=False)
self.manufacturer = self.get("Manufacturer")
self.detected_sequence = self.detect_sequence()
self._as_dict = None

def __getitem__(self, key: Union[str, tuple, list]) -> Any:
Expand Down Expand Up @@ -196,11 +200,29 @@ def detect_sequence(self, verbose: bool = False) -> str:
Imaging sequence name
"""
modality = self.get("Modality")
sequence_identifiers = self.sequence_identifiers.get(modality)
sequence_identifying_values = self.get(sequence_identifiers)
if modality is None:
return
keys = self.SEQUENCE_IDENTIFIERS.get(modality)
if keys is None:
message = UNREGISTERED_MODALITY.format(modality=modality)
print(message)
return
values = self.get(keys)
if values is None:
message = MISSING_HEADER_INFO.format(modality=modality, keys=keys)
print(message)
return
for key, value in values.items():
if value is None:
method = getattr(self, key, None)
if method is not None:
try:
values[key] = method()
except TypeError:
pass
try:
return self.sequence_detector.detect(
modality, sequence_identifying_values, verbose=verbose
modality, values, verbose=verbose
)
except NotImplementedError:
pass
Expand Down Expand Up @@ -430,8 +452,21 @@ def get_parsed_value(self, tag_or_keyword) -> Any:
Any
Parsed data element value
"""
data_element = self.get_data_element(tag_or_keyword)
return data_element.value
try:
data_element = self.get_data_element(tag_or_keyword)
except KeyError as e:
# Look for method or property.
try:
value = getattr(self, tag_or_keyword)
except AttributeError:
raise KeyError(str(e))
else:
try:
return value()
except TypeError:
return value
else:
return data_element.value

def get_private_tag(self, keyword: str) -> tuple:
"""
Expand Down Expand Up @@ -507,7 +542,16 @@ def get(
if isinstance(tag_or_keyword, (str, tuple)):
value = get_method(tag_or_keyword)
elif isinstance(tag_or_keyword, list):
value = {item: get_method(item) for item in tag_or_keyword}
value = {
item: self.get(
item,
default=default,
parsed=parsed,
missing_ok=missing_ok,
as_json=as_json,
)
for item in tag_or_keyword
}
except (KeyError, TypeError):
if not missing_ok:
raise
Expand All @@ -529,10 +573,20 @@ def to_dict(self, parsed: bool = True) -> dict:
dict
Header information
"""
return {
d = {
data_element.keyword: self.get(data_element.tag, parsed=parsed)
for data_element in self.data_elements
}
modality = self.get("Modality")
appendices = self.DICTIONARY_APPENDICES.get(modality, [])
for appendix in appendices:
attribute = getattr(self, appendix, None)
if attribute is not None:
try:
d[appendix] = attribute()
except TypeError:
d[appendix] = attribute
return d

@requires_pandas
def to_dataframe(
Expand Down Expand Up @@ -675,24 +729,7 @@ def get_phase_encoding_direction(self) -> str:
if inplane_pe is not None and sign is not None:
return f"{inplane_pe}{sign}"

def infer_phase_encoding(self) -> str:
"""
Returns the applied phase encoding as defined in the AP/PA or LR/RL
format, based on the acquisition plane and phase encoding direction.
Returns
-------
str
Phase encoding as AP/PA/LR/RL
"""
plane = self.get_plane()
direction = self.get_phase_encoding_direction()
try:
return self.PHASE_ENCODING[plane][direction]
except KeyError:
pass

def get_plane(self) -> Plane:
def estimate_acquisition_plane(self) -> Plane:
"""
Returns the image plane (see :class:`dicom_parser.utils.plane.Plane`)
based on the header's 'ImageOrientationPatient' (0x20, 0x37) tag.
Expand Down Expand Up @@ -778,3 +815,11 @@ def keys(self) -> List[str]:
Header keywords
"""
return list(self.as_dict.keys())

@property
def phase_encoding_direction(self) -> str:
return self.get_phase_encoding_direction()

@property
def detected_sequence(self) -> str:
return self.detect_sequence()
3 changes: 2 additions & 1 deletion src/dicom_parser/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,8 @@ def data(self) -> np.ndarray:
np.ndarray
Pixel data array
"""
return self.fix_data()
if self._data is not None:
return self.fix_data()

@property
def default_relative_path(self) -> Path:
Expand Down
4 changes: 4 additions & 0 deletions src/dicom_parser/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,9 @@
INVALID_ELEMENT_IDENTIFIER: str = "Invalid data element identifier: {tag_or_keyword} of type {input_type}!\nData elements may only be queried using a string representing a keyword or a tuple of two strings representing a tag!"
INVALID_INDEXING_OPERATOR: str = "Invalid indexing operator value ({key})! Must be of type str, tuple, int, or slice."
INVALID_SERIES_DIRECTORY: str = "Series instances must be initialized with a valid directory path! Could not locate directory {path}."
UNREGISTERED_MODALITY: str = (
"No sequence identifiers registered for {modality}!"
)
MISSING_HEADER_INFO: str = "No header information found for {modality} sequence detection using {keys}!"

# flake8: noqa: E501
9 changes: 7 additions & 2 deletions src/dicom_parser/utils/bids/bids_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,18 @@ def validate_fields(self, sequence: str, fields: dict) -> None:
fields : dict
Dictionaty with sequence-specific BIDS fields and values
"""
if not fields:
# Check for unregistered sequences.
if fields is None:
warnings.warn(INVALID_SEQUENCE.format(sequence=sequence))
return False
# Check for sequences registered as not BIDS compatible, such as
# derived DWI data.
if fields is False:
return False
for required_key in self.REQUIRED_KEYS:
if required_key not in fields:
message = INVALID_SEQUENCE_KEYS.format(
required_key=required_key
required_key=required_key, fields=fields
)
raise ValueError(message)
return True
Expand Down
27 changes: 19 additions & 8 deletions src/dicom_parser/utils/bids/header_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@
INVALID_CHARACTERS: str = "!@#$%^&*()_-+="


def find_mprage_acq(header: dict) -> str:
def find_mprage_ce(header: dict) -> str:
"""
Finds correct value for the "acq" field of BIDS specification for MPRAGE
Finds correct value for the "ce" field of BIDS specification for MPRAGE
sequences.
Parameters
----------
header : dict
Dictionary containing DICOM's header.
Dictionary containing DICOM's header
Returns
-------
str
Either "corrected" or "uncorrected" in terms of bias field correction.
Either "corrected" or "uncorrected" in terms of bias field correction
"""
image_type = header.get("ImageType", "")
return "corrected" if "NORM" in image_type else "uncorrected"
Expand Down Expand Up @@ -88,6 +88,9 @@ def find_task_name(header: dict) -> str:
return task


PHASE_ENCODINGS = ("ap", "pa", "lr", "rl", "fwd", "rev")


def find_phase_encoding(header: dict) -> str:
"""
Finds correct value for the "dir" field of BIDS specification for EPI
Expand All @@ -101,8 +104,16 @@ def find_phase_encoding(header: dict) -> str:
Returns
-------
str
Phase encoding direction (AP/PA)
Phase encoding direction
"""
description = header.get("ProtocolName").lower()
pe = description.split("_")[-1]
return pe
try:
phase_encoding = header["phase_encoding_direction"]
return "FWD" if phase_encoding.endswith("-") else "REV"
except (KeyError, AttributeError):
try:
description = header.get("ProtocolName").lower()
pe = description.split("_")[-1]
if pe in PHASE_ENCODINGS:
return pe
except (AttributeError, IndexError):
return
27 changes: 20 additions & 7 deletions src/dicom_parser/utils/bids/sequence_to_bids.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"""
from dicom_parser.utils.bids.header_queries import (
find_irepi_acq,
find_mprage_acq,
find_mprage_ce,
find_phase_encoding,
find_task_name,
)
Expand All @@ -21,11 +21,15 @@
"dir": find_phase_encoding,
"suffix": "dwi",
}
DWI_SBREF = {
"data_type": "dwi",
"dir": find_phase_encoding,
"suffix": "sbref",
}
DWI_FIELDMAP = {
"data_type": "fmap",
"acq": "dwi",
"data_type": "dwi",
"dir": find_phase_encoding,
"suffix": "epi",
"suffix": "dwi",
}
FLAIR = {"data_type": "anat", "suffix": "FLAIR"}
FUNCTIONAL_FIELDMAP = {
Expand All @@ -40,11 +44,16 @@
"dir": find_phase_encoding,
"suffix": "sbref",
}
IREPI = ({"data_type": "anat", "inv": find_irepi_acq, "suffix": "IRT1"},)
MPRAGE = {"data_type": "anat", "ce": find_mprage_acq, "suffix": "T1w"}
IREPI = {"data_type": "anat", "inv": find_irepi_acq, "suffix": "IRT1"}
MPRAGE = {
"data_type": "anat",
"ce": find_mprage_ce,
"suffix": "T1w",
}
SPGR = {"data_type": "anat", "acq": "spgr", "suffix": "T1w"}
FSPGR = {"data_type": "anat", "acq": "fspgr", "suffix": "T1w"}
T2W = {"data_type": "anat", "ce": find_mprage_acq, "suffix": "T2w"}
TIRM = {"data_type": "anat", "acq": "tirm", "suffix": "T1w"}
T2W = {"data_type": "anat", "ce": find_mprage_ce, "suffix": "T2w"}

#: BIDS fields used in Magnetic Resonance (MR) imaging and their associated
#: definitions.
Expand All @@ -55,11 +64,15 @@
"ir_epi": IREPI,
"t2w": T2W,
"flair": FLAIR,
"tirm": TIRM,
"bold": BOLD,
"func_sbref": FUNCTIONAL_SBREF,
"func_fieldmap": FUNCTIONAL_FIELDMAP,
"dwi": DWI,
"dwi_fieldmap": DWI_FIELDMAP,
"dwi_sbref": DWI_SBREF,
"dwi_derived": False,
"physio_log": False,
}
#: Known BIDS field values by modality.
SEQUENCE_TO_BIDS = {"Magnetic Resonance": MR_SEQUENCE_TO_BIDS}
2 changes: 1 addition & 1 deletion src/dicom_parser/utils/sequence_detector/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
INVALID_SEQUENCE: str = (
"There is no `{sequence}` BIDS naming definition avaliable yet!"
)
INVALID_SEQUENCE_KEYS: str = "All sequences' BIDS naming schemes must contain `{required_key}` definition."
INVALID_SEQUENCE_KEYS: str = "All sequences' BIDS naming schemes must contain `{required_key}` definition, please fix:\n{fields}"
MISSING_RULE_KEY: str = "Missing key {key} in definition rule."

# flake8: noqa: E501
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
from dicom_parser.utils.sequence_detector.sequences.mr.anat.t2w import (
T2W_RULES,
)
from dicom_parser.utils.sequence_detector.sequences.mr.anat.tirm import (
TIRM_RULES,
)

MR_ANATOMICAL_SEQUENCES = {
"flair": FLAIR_RULES,
Expand All @@ -28,4 +31,5 @@
"spgr": SPGR_RULES,
"fspgr": FSPGR_RULES,
"t2w": T2W_RULES,
"tirm": TIRM_RULES,
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,15 @@
},
{
"key": "ImageType",
"value": ("ORIGINAL", "PRIMARY", "M", "NORM", "DIS2D"),
"lookup": "exact",
"value": ["ORIGINAL", "PRIMARY", "M"],
"lookup": "in",
"operator": "all",
},
{
"key": "ImageType",
"value": ["DIS2D", "ND"],
"lookup": "in",
"operator": "any",
},
]
# GE
Expand Down

0 comments on commit acc82f6

Please sign in to comment.