Skip to content
Snippets Groups Projects
dicom.py 15.16 KiB
"""
Utility functions to deal with DICOM images.
"""
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
import os
import random
from typing import Optional, Tuple

import numpy as np
import pydicom

from mu_map.types import Spacing


"""
Since DICOM images only allow images stored in short integer format,
the Siemens scanner software multiplies values by a factor before storing
so that no precision is lost.
The scale can be found in this private DICOM tag.
"""
DCM_TAG_PIXEL_SCALE_FACTOR = 0x00331038
DCM_TAG_DETECTOR_INFORMATION_SEQUENCE = 0x00540022
DCM_TAG_IMAGE_ORIENTATION_PATIENT = 0x00200037

"""
Maximum value that can be stored in an unsigned integer with 16 bits.
"""
UINT16_MAX = 2**16 - 1
"""
DICOM images always contain UIDs to indicate their uniqueness.
Thus, when a DICOM image is updated, UIDs have to be changed for
which the following prefix is used.
"""
UID_PREFIX = "1.2.826.0.1.3680043.2.521."
"""
Default extension of dicom files.
"""
EXTENSION_DICOM = ".dcm"


@dataclass
class DICOM:
    """
    DICOM dataclass wrapping the pydicom Filedataset (header) and the
    image as a numpy array into an object.

    This is useful because the pixel array in the pydicom Filedataset
    often needs to be scaled.
    """

    header: pydicom.dataset.FileDataset
    image: np.ndarray

    def __iter__(self):
        return iter((self.header, self.image))

    def update_image(self, image: np.ndarray, spacing: Optional[Spacing] = None):
        """
        See `update_dcm_image`.
        """
        self.image = image
        update_dcm_image(self.header, image, spacing=spacing)

    def change_uid(self):
        """
        See `change_uid`.
        """
        change_uid(self.header)

    def get_rotation_matrix(self) -> np.array:
        """
        See `get_rotation_matrix`.
        """
        return get_rotation_matrix(self.header)

    def write(self, filename: str):
        """
        Write this DICOM image to a filename.

        It is verified that the filename extension is `.dcm`.

        Parameters
        ----------
        filename: str
            the filename of the written DICOM file
        """
        filename = os.path.splitext(filename)[0] + EXTENSION_DICOM
        pydicom.dcmwrite(filename, self.header)


class DICOMTime(Enum):
    """
    Class for parsing dates and times defined in a DICOM header
    into a python datetime type. It maps the four datetime fields
    [Study, Series, Acquisition, Content] of the DICOM header into
    an enumeration type.

    Usage: DICOMTime.Series.to_datetime(dcm)
    """

    Study = 1
    Series = 2
    Acquisition = 3
    Content = 4

    def date_field(self) -> str:
        """
        Get the name of the date field according to this DICOMTime type.
        """
        return f"{self.name}Date"

    def time_field(self) -> str:
        """
        Get the name of the time field according to this DICOMTime type.
        """
        return f"{self.name}Time"

    def to_datetime(self, dcm_header: pydicom.dataset.FileDataset) -> datetime:
        """
        Get the datetime according to this DICOMTime type.
        """
        try:
            _date = dcm_header[self.date_field()].value
            _time = dcm_header[self.time_field()].value
        except:
            return "               None"

        return datetime(
            year=int(_date[0:4]),
            month=int(_date[4:6]),
            day=int(_date[6:8]),
            hour=int(_time[0:2]),
            minute=int(_time[2:4]),
            second=int(_time[4:6]),
        )


def parse_age(patient_age: str) -> int:
    """
    Parse an age string as defined in the DICOM standard into an integer representing the age in years.

    Parameters
    ----------
    patient_age: str
        age string as defined in the DICOM standard

    Returns
    -------
    int
        the age in years as a number
    """
    assert (
        type(patient_age) == str
    ), f"patient age needs to be a string and not {type(patient_age)}"
    assert (
        len(patient_age) == 4
    ), f"patient age [{patient_age}] has to be four characters long"
    _num, _format = patient_age[:3], patient_age[3]
    assert (
        _format == "Y"
    ), f"currently, only patient ages in years [Y] is supported, not [{_format}]"
    return int(_num)


def load_dcm(filename: str, direction: float = 0.0) -> DICOM:
    """
    Load a DICOM image as a header and a numpy array.

    Perform image rescaling if the header contains a slope or intercept tag.

    Parameters
    ----------
    filename: str
        filename of the DICOM image
    direction: float
        other than 0 changes the stacking order of slices to the desired direction

    Returns
    -------
    DICOM
        the DICOM image
    """
    header = pydicom.dcmread(filename)
    image = header.pixel_array
    image = get_slope(header) * image + get_intercept(header)

    dcm = DICOM(header, image)
    dcm = to_direction(dcm, direction=direction)
    return dcm


def get_slope(dcm_header: pydicom.dataset.FileDataset) -> float:
    """
    Get the rescale slope of a DICOM image.

    This is a wrapper which returns the DCM_TAG_PIXEL_SCALE_FACTOR
    used by Siemens scanners if available. Else it returns the RescaleSlope
    value or 1.0.

    Parameters
    ----------
    dcm_header: pydicom.dataset.FileDataset

    Returns
    -------
    float
    """
    if DCM_TAG_PIXEL_SCALE_FACTOR in dcm_header:
        return 1.0 / dcm_header[DCM_TAG_PIXEL_SCALE_FACTOR].value

    try:
        return dcm_header.RescaleSlope
    except:
        return 1.0


def set_slope(dcm_header: pydicom.dataset.FileDataset, slope: float):
    """
    Set rescale slope of a DICOM image.

    Similar to `get_slope` this function is a wrapper to set the value as the correct tag.
    If the header has the Siemens scale factor tag it is used and if the header has the
    RescaleSlope tag it is used.

    Parameters
    ----------
    dcm_header: pydicom.dataset.FileDataset
    slope: float
    """
    if DCM_TAG_PIXEL_SCALE_FACTOR in dcm_header:
        dcm_header[DCM_TAG_PIXEL_SCALE_FACTOR].value = float(round(1.0 / slope))
        return

    # do not set the rescale slope if the header is missing the field and it is 1 anyway
    if slope == 1.0 and not hasattr(dcm_header, "RescaleSlope"):
        return

    dcm_header.RescaleSlope = slope


def get_intercept(dcm_header: pydicom.dataset.FileDataset) -> float:
    """
    Get the rescale intercept of a DICOM image.

    This is a wrapper which returns RescaleSlope or 0.0 if the tag
    is not available.

    Parameters
    ----------
    dcm_header: pydicom.dataset.FileDataset

    Returns
    -------
    float
    """
    try:
        _intercept = dcm_header.RescaleIntercept
        return dcm_header.RescaleIntercept
    except AttributeError:
        return 0.0


def set_intercept(dcm_header: pydicom.dataset.FileDataset, intercept: float):
    """
    Set the rescale intercept of a DICOM image.

    Does nothing if the intercept is 0 and the DICOM image does not
    already have the tag

    Parameters
    ----------
    dcm_header: pydicom.dataset.FileDataset
    intercept: float
    """
    if intercept == 0.0 and not hasattr(dcm_header, "RescaleIntercept"):
        return

    dcm_header.RescaleIntercept = intercept


def set_rescale(
    dcm_header: pydicom.dataset.FileDataset, intercept: float, slope: float
):
    """
    Call `set_intercept` and `set_slope`.

    Parameters
    ----------
    dcm_header: pydicom.dataset.FileDataset
    intercept: float
    slope: float
    """
    set_intercept(dcm_header, intercept)
    set_slope(dcm_header, slope)


def compute_rescale(image: np.ndarray) -> Tuple[np.ndarray, float, float]:
    """
    Compute the rescale intercept and slope to store a numpy array
    as np.uint16 as the DICOM pixel array.

    The intercept is the to the minimal image value if it is negative.
    The slope is only set if precision is lost by converting to an
    np.uint16 image. Then, the slope is computed in a way that choses
    to cover the most range of the np.uint16 space.

    Parameters
    ----------
    image: np.ndarray

    Returns
    -------
    np.ndarray
        the rescaled image as np.uint16
    float
        the intercept used for rescaling
    float
        the slope used for rescaling
    """
    intercept = 0.0 if image.min() >= 0 else image.min()

    _image = image - intercept
    if np.allclose(_image, _image.astype(np.uint16)):
        slope = 1.0
    else:
        _max = _image.max()
        slope = 1e-5
        while _max / slope > UINT16_MAX:
            slope *= 10
    _image = (_image / slope).astype(np.uint16)

    return _image, intercept, slope


def load_dcm_img(
    filename: str,
    direction: float = 0.0,
) -> np.ndarray:
    """
    Load a DICOM image as a numpy array and apply normalization of the Siemens SPECT/CT
    Scanner.

    See `load_dcm`. This function does the same but only returns the image

    Parameters
    ----------
    filename: str
        filename of the DICOM image
    direction: float
        other than 0 changes the stacking order of slices to the desired direction

    Returns
    -------
    np.ndarray
        the image scaled and loaded into a numpy array
    """
    return load_dcm(filename, direction=direction).image


def to_direction(dcm: DICOM, direction: float = 1) -> DICOM:
    """
    Change the stacking direction of slices in a DICOM image.
    The direction is specified by the sign of the [Spacing Between Slices](https://dicom.innolitics.com/ciods/nm-image/nm-reconstruction/00180088) parameter.

    Parameters
    ----------
    dcm: DICOM header and an image
    direction: float
        the desired stacking direction as a float (>0 positive, <0 negative, =0 stay as it is)

    Returns
    -------
    DICOM:
        DICOM with image and header updated
    """
    try:
        spacing_between_slices = float(dcm.header.SpacingBetweenSlices)
    except AttributeError:
        return dcm

    _header = dcm.header.copy()
    _image = dcm.image.copy()
    if spacing_between_slices * direction < 0:
        _header.DetectorInformationSequence[0].ImagePositionPatient[2] *= (
            dcm.image.shape[0] * spacing_between_slices
        )
        _header.SpacingBetweenSlices = -spacing_between_slices
        _image = _image[::-1].copy()
    return DICOM(_header, _image)


def update_dcm_image(
    dcm_header: pydicom.dataset.FileDataset, image: np.ndarray, spacing: Optional[Spacing] = None
) -> pydicom.dataset.FileDataset:
    """
    Update the image data in a DICOM header.

    This function scales the image, converts it to unsigned integers with 16 bits and
    updates the pixel data in the DICOM header. Additionally, other related tags in the
    DICOM header, such as image dimensions and maximum pixel values, are updated accordingly.

    Note that this function modifies the given DICOM header. If you want to keep the old
    one, you should copy it first.

    Parameters
    ----------
    dcm: DICOM
        the DICOM header to be updated
    image: np.ndarray
        the image stored with the DICOM header
    spacing: Spacing, optional
        the spacing of the new image

    Returns
    -------
    DICOM
        the updated DICOM header
    """
    image, intercept, slope = compute_rescale(image)
    dcm_header.PixelData = image.tobytes()
    set_rescale(dcm_header, intercept, slope)

    dcm_header.NumberOfFrames = image.shape[0]
    dcm_header.NumberOfSlices = image.shape[0]
    dcm_header.SliceVector = list(range(1, image.shape[0] + 1))
    dcm_header.Columns = image.shape[1]
    dcm_header.Rows = image.shape[2]
    dcm_header.WindowWidth = image.max()
    dcm_header.WindowCenter = image.max() / 2
    dcm_header.LargestImagePixelValue = image.max()
    dcm_header["LargestImagePixelValue"].VR = "US"

    if spacing is not None:
        dcm_header.PixelSpacing = list(spacing[:2])
        dcm_header.SliceThickness = spacing[2]

    return dcm_header


def change_uid(dcm_header: pydicom.dataset.FileDataset) -> pydicom.dataset.FileDataset:
    """
    Change the UIDs (SeriesInstance and SOPInstance) in a DICOM header so that
    it becomes its own unique file.

    Note that this method does not guarantee that the UIDs are fully unique.
    It just generated a new random UID with a specified prefix, which hopefully is unique.

    Parameters
    ----------
    dcm: DICOM
        the DICOM header to be udpated
    """
    dcm_header.SeriesInstanceUID = pydicom.uid.generate_uid(prefix=UID_PREFIX)
    dcm_header.SOPInstanceUID = pydicom.uid.generate_uid(prefix=UID_PREFIX)
    dcm_header.file_meta.MediaStorageSOPInstanceUID = dcm_header.SOPInstanceUID
    return dcm_header


def get_rotation_matrix(dcm_header: pydicom.dataset.FileDataset) -> np.array:
    """
    Get a rotation matrix from a DICOM header.

    Parameters
    ----------
    dcm_header: pydicom.dataset.FileDataset
        the DICOM header

    Returns
    -------
    np.array
        a 3x3 rotation matrix
    """
    if (
        DCM_TAG_DETECTOR_INFORMATION_SEQUENCE not in dcm_header
        or len(dcm_header[DCM_TAG_DETECTOR_INFORMATION_SEQUENCE].value) == 0
    ):
        raise ValueError("DICOM header does not contain DetectorInformationSequence")

    detector_info = dcm_header[DCM_TAG_DETECTOR_INFORMATION_SEQUENCE].value[0]

    if DCM_TAG_IMAGE_ORIENTATION_PATIENT not in detector_info:
        raise ValueError("DICOM header does not contain ImageOrientationPatient")

    ori = detector_info[DCM_TAG_IMAGE_ORIENTATION_PATIENT].value

    x_dir = np.array(ori[:3])
    y_dir = np.array(ori[3:])
    z_dir = np.cross(x_dir, y_dir)

    dirs = [x_dir, y_dir, z_dir]
    dirs = list(map(lambda v: v / np.linalg.norm(v), dirs))
    return np.array(dirs).T


def create_empty_dicom() -> DICOM:
    """
    Create a new empty DICOM header with default values and an empty image.

    Returns
    -------
    DICOM
    """
    meta = pydicom.dataset.FileMetaDataset()
    meta.FileMetaInformationVersion = b"\x00\x01"
    meta.MediaStorageSOPClassUID = pydicom.uid.NuclearMedicineImageStorage
    meta.MediaStorageSOPInstanceUID = pydicom.uid.generate_uid(prefix=UID_PREFIX)
    meta.TransferSyntaxUID = pydicom.uid.ImplicitVRLittleEndian
    meta.ImplementationClassUID = "1.2.40.0.13.1.3"
    meta.ImplementationVersionName = "dcm4che-null"

    dcm_header = pydicom.dataset.FileDataset(
        filename_or_obj=None, dataset={}, file_meta=meta, preamble=b"\x00" * 128
    )
    dcm_header.SpecificCharacterSet = "ISO_IR 100"
    dcm_header.SOPInstanceUID = meta.MediaStorageSOPInstanceUID
    dcm_header.BitsAllocated = 16
    dcm_header.BitsStored = 16
    dcm_header.HighBit = 15
    dcm_header.PhotometricInterpretation = "MONOCHROME2"
    dcm_header.PixelRepresentation = 0
    dcm_header.SamplesPerPixel = 1
    dcm_header.PixelData = None
    dcm_header["PixelData"].VR = "OW"
    dcm_header.set_original_encoding(True, True, "ISO_IR 100")

    return DICOM(dcm_header, image=None)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(
        description="Dump the header of a DICOM image",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument(
        "file", type=str, help="the DICOM file of which the header is dumped"
    )
    args = parser.parse_args()

    dcm = pydicom.dcmread(args.file)
    print(dcm)