Source code for deepmr.io.image.api

"""Image IO API."""

__all__ = ["read_image", "write_image"]

import time

import numpy as np
import torch

from . import dicom as _dicom
from . import nifti as _nifti

# from .dicom import *  # noqa
# from .nifti import *  # noqa


[docs]def read_image(filepath, acqheader=None, device="cpu", verbose=0): """ Read image data from file. Supported formats are ``DICOM`` and ``NIfTI``. Parameters ---------- filepath : str Path to image file. Supports wildcard (e.g., ``/path-to-dicom-exam/*``, ``/path-to-BIDS/*.nii``). acqheader : Header, optional Acquisition header loaded from trajectory. If not provided, assume Cartesian acquisition and infer from data. The default is ``None``. device : str, optional Computational device for internal attributes. The default is ``cpu``. verbose : int, optional Verbosity level ``(0=Silent, 1=Less, 2=More)``. The default is ``0``. Returns ------- image : torch.tensor Complex image data. head : Header Metadata for image reconstruction. Example ------- >>> import deepmr Get the filenames for exemplary DICOM and NIfTI files. >>> dcmpath = deepmr.testdata("dicom") >>> niftipath = deepmr.testdata("nifti") Load the file contents. >>> image_dcm, head_dcm = deepmr.io.read_image(dcmpath) >>> image_nii, head_nii = deepmr.io.read_image(niftipath) The result is a image/header pair. ``Image`` contains image-space data. Here, it represents a 2D cartesian acquisition with 3 echoes, 2 slices and 192x192 matrix size. >>> image_dcm.shape torch.Size([3, 2, 192, 192]) >>> image_nii.shape torch.Size([3, 2, 192, 192]) ``Head`` contains the acquisition information. We can inspect the image shape and resolution: >>> head_dcm.shape tensor([ 2, 192, 192]) >>> head_nii.shape tensor([ 2, 192, 192]) >>> head_dcm.ref_dicom.SpacingBetweenSlices '10.5' >>> head_nii.ref_dicom.SpacingBetweenSlices '10.5' >>> head_dcm.ref_dicom.SliceThickness '7.0' >>> head_nii.ref_dicom.SliceThickness '7.0' >>> head_dcm.ref_dicom.PixelSpacing [0.67, 0.67] >>> head_nii.ref_dicom.PixelSpacing [0.67,0.67] ``Head`` also contains contrast information (for forward simulation and parameter inference): >>> head_dcm.FA 180.0 >>> head_nii.FA 180.0 >>> head_dcm.TE tensor([ 20.0, 40.0, 60.0]) >>> head_nii.TE tensor([ 20.0, 40.0, 60.0]) >>> head_dcm.TR 3000.0 >>> head_nii.TR 3000.0 Notes ----- The returned ``image`` tensor contains image space data. Dimensions are defined as following: * **2D:** ``(ncontrasts, nslices, ny, nx)``. * **3D:** ``(ncontrasts, nz, ny, nx)``. The returned ``head`` (:func:`deepmr.Header`) is a structure with the following fields: * shape (torch.Tensor): This is the expected image size of shape ``(nz, ny, nx)``. * resolution (torch.Tensor): This is the expected image resolution in mm of shape ``(dz, dy, dx)``. * t (torch.Tensor): This is the readout sampling time ``(0, t_read)`` in ``ms``. with shape ``(nsamples,)``. * traj (torch.Tensor): This is the k-space trajectory normalized as ``(-0.5, 0.5)`` with shape ``(ncontrasts, nviews, nsamples, ndims)``. * dcf (torch.Tensor): This is the k-space sampling density compensation factor with shape ``(ncontrasts, nviews, nsamples)``. * FA (torch.Tensor, float): This is either the acquisition flip angle in degrees or the list of flip angles of shape ``(ncontrasts,)`` for each image in the series. * TR (torch.Tensor, float): This is either the repetition time in ms or the list of repetition times of shape ``(ncontrasts,)`` for each image in the series. * TE (torch.Tensor, float): This is either the echo time in ms or the list of echo times of shape ``(ncontrasts,)`` for each image in the series. * TI (torch.Tensor, float): This is either the inversion time in ms or the list of inversion times of shape ``(ncontrasts,)`` for each image in the series. * user (dict): User parameters. Some examples are: * ordering (torch.Tensor): Indices for reordering (acquisition to reconstruction) of acquired k-space data, shaped ``(3, nslices * ncontrasts * nview)``, whose rows are ``contrast_index``, ``slice_index`` and ``view_index``, respectively. * mode (str): Acquisition mode (``2Dcart``, ``3Dcart``, ``2Dnoncart``, ``3Dnoncart``). * separable (bool): Whether the acquisition can be decoupled by fft along ``slice`` / ``readout`` directions (3D stack-of-noncartesian / 3D cartesian, respectively) or not (3D noncartesian and 2D acquisitions). * slice_profile (torch.Tensor): Flip angle scaling along slice profile of shape ``(nlocs,)``. * basis (torch.Tensor): Low rank subspace basis for subspace reconstruction of shape ``(ncoeff, ncontrasts)``. * affine (np.ndarray): Affine matrix describing image spacing, orientation and origin of shape ``(4, 4)``. * ref_dicom (pydicom.Dataset): Template dicom for image export. """ tstart = time.time() if verbose >= 1: print(f"Reading image from file {filepath}...", end="\t") done = False # convert header to numpy if acqheader is not None: acqheader.numpy() # dicom if verbose == 2: t0 = time.time() try: image, head = _dicom.read_dicom(filepath) done = True except Exception as e: msg0 = _get_error(e) # nifti if verbose == 2: t0 = time.time() try: image, head = _nifti.read_nifti(filepath) done = True except Exception as e: msg1 = _get_error(e) if not (done): raise RuntimeError( f"File (={filepath}) not recognized! Error:\nDICOM {msg0}\nNIfTI {msg1}" ) if verbose == 2: t1 = time.time() print(f"done! Elapsed time: {round(t1-t0, 2)} s") # load trajectory info from acqheader if present if acqheader is not None: if acqheader.traj is not None: head.traj = acqheader.traj if acqheader.dcf is not None: head.dcf = acqheader.dcf if acqheader.t is not None: head.t = acqheader.t if acqheader.user is not None: head.user = acqheader.user if acqheader.FA is not None: head.FA = acqheader.FA if acqheader.TR is not None: head.TR = acqheader.TR if acqheader.TE is not None: head.TE = acqheader.TE if acqheader.TI is not None: head.TI = acqheader.TI # remove flip and transpose head.transpose = None head.flip = None # final report if verbose == 2: if len(image.shape) == 3: print( f"Image shape: (nz={image.shape[-3]}, ny={image.shape[-2]}, nx={image.shape[-1]})" ) else: print( f"Image shape: (ncontrasts={image.shape[0]}, nz={image.shape[-3]}, ny={image.shape[-2]}, nx={image.shape[-1]})" ) if head.t is not None: print(f"Readout time: {round(float(head.t[-1]), 2)} ms") if head.traj is not None: print(f"Trajectory range: ({head.traj.min()},{head.traj.max()})") print( f"Trajectory shape: (ncontrasts={head.traj.shape[0]}, nviews={head.traj.shape[1]}, nsamples={head.traj.shape[2]}, ndim={head.traj.shape[-1]})" ) if head.dcf is not None: print( f"DCF shape: (ncontrasts={head.dcf.shape[0]}, nviews={head.dcf.shape[1]}, nsamples={head.dcf.shape[2]})" ) if head.FA is not None: if len(np.unique(head.FA)) > 1: if verbose == 2: print(f"Flip Angle train length: {len(head.FA)}") else: FA = float(np.unique(head.FA)[0]) head.FA = FA if verbose == 2: print(f"Constant FA: {round(abs(FA), 2)} deg") if head.TR is not None: if len(np.unique(head.TR)) > 1: if verbose == 2: print(f"TR train length: {len(head.TR)}") else: TR = float(np.unique(head.TR)[0]) head.TR = TR if verbose == 2: print(f"Constant TR: {round(TR, 2)} ms") if head.TE is not None: if len(np.unique(head.TE)) > 1: if verbose == 2: print(f"Echo train length: {len(head.TE)}") else: TE = float(np.unique(head.TE)[0]) head.TE = TE if verbose == 2: print(f"Constant TE: {round(TE, 2)} ms") if head.TI is not None and np.allclose(head.TI, 0.0) is False: if len(np.unique(head.TI)) > 1: if verbose == 2: print(f"Inversion train length: {len(head.TI)}") else: TI = float(np.unique(head.TI)[0]) head.TI = TI if verbose == 2: print(f"Constant TI: {round(TI, 2)} ms") # cast image = torch.as_tensor( np.ascontiguousarray(image), dtype=torch.complex64, device=device ) head.torch(device) tend = time.time() if verbose == 1: print(f"done! Elapsed time: {round(tend-tstart, 2)} s") elif verbose == 2: print(f"Total elapsed time: {round(tend-tstart, 2)} s") return image, head
[docs]def write_image( filename, image, head=None, dataformat="nifti", filepath="./", series_description="", series_number_offset=0, series_number_scale=1000, rescale=False, anonymize=False, verbose=False, ): """ Write image to disk. Parameters ---------- filename : str Name of the file. image : np.ndarray Complex image data of shape ``(ncontrasts, nslices, ny, n)``. See ``'Notes'`` for additional information. head : Header, optional Structure containing trajectory of shape ``(ncontrasts, nviews, npts, ndim)`` and meta information (shape, resolution, spacing, etc). If None, assume 1mm isotropic resolution, contiguous slices and axial orientation. The default is None. dataformat : str, optional Available formats (``dicom`` or ``nifti``). The default is ``nifti``. filepath : str, optional Path to file. The default is ``./``. series_description : str, optional Custom series description. The default is ``""`` (empty string). series_number_offset : int, optional Series number offset with respect to the acquired one. Final series number is ``series_number_scale * acquired_series_number + series_number_offset``. he default is ``0``. series_number_scale : int, optional Series number multiplicative scaling with respect to the acquired one. Final series number is ``series_number_scale * acquired_series_number + series_number_offset``. The default is ``1000``. rescale : bool, optional If true, rescale image intensity between ``0`` and ``int16_max``. Beware! Avoid this if you are working with quantitative maps. The default is ``False``. anonymize : bool, optional If True, remove sensible info from header. The default is ``False``. verbose : bool, optional Verbosity flag. The default is ``False``. Example ------- >>> import deepmr >>> import tempfile Get the filenames for an example DICOM file. >>> filepath = deepmr.testdata("dicom") Load the file contents. >>> image_orig, head_orig = deepmr.io.read_image(filepath) >>> with tempfile.TemporaryDirectory() as tempdir: >>> dcmpath = os.path.join(tempdir, "dicomtest") >>> niftipath = os.path.join(tempdir, "niftitest.nii") >>> deepmr.io.write_image(dcmpath, image_orig, head_orig, dataformat="dicom") >>> deepmr.io.write_image(niftipath, image_orig, head_orig, dataformat="nifti") >>> deepmr.io.write_image(dcmpath, image_orig, head_orig, dataformat="dicom") >>> deepmr.io.write_image(niftipath, image_orig, head_orig, dataformat="nifti") >>> image_dcm, head_dcm = deepmr.io.read_image(dcmpath) >>> image_nii, head_nii = deepmr.io.read_image(niftipath) The result is a image/header pair. ``Image`` contains image-space data. Here, it represents a 2D cartesian acquisition with 3 echoes, 2 slices and 192x192 matrix size. >>> image_dcm.shape torch.Size([3, 2, 192, 192]) >>> image_nii.shape torch.Size([3, 2, 192, 192]) ``Head`` contains the acquisition information. We can inspect the image shape and resolution: >>> head_dcm.shape tensor([ 2, 192, 192]) >>> head_nii.shape tensor([ 2, 192, 192]) >>> head_dcm.ref_dicom.SpacingBetweenSlices '10.5' >>> head_nii.ref_dicom.SpacingBetweenSlices '10.5' >>> head_dcm.ref_dicom.SliceThickness '7.0' >>> head_nii.ref_dicom.SliceThickness '7.0' >>> head_dcm.ref_dicom.PixelSpacing [0.67, 0.67] >>> head_nii.ref_dicom.PixelSpacing [0.67,0.67] ``Head`` also contains contrast information (for forward simulation and parameter inference): >>> head_dcm.FA 180.0 >>> head_nii.FA 180.0 >>> head_dcm.TE tensor([ 20.0, 40.0, 60.0]) >>> head_nii.TE tensor([ 20.0, 40.0, 60.0]) >>> head_dcm.TR 3000.0 >>> head_nii.TR 3000.0 Notes ----- When the image to be written is the result of a reconstruction performed on k-space data loaded using :func:`deepmr.io.read_rawdata`, axis order depends on acquisition mode: * **2Dcart:** ``(nslices, ncontrasts, ny, nx)`` * **2Dnoncart:** ``(nslices, ncontrasts, ny, nx)`` * **3Dcart:** ``(ncontrasts, nz, ny, nx)`` * **3Dnoncart:** ``(nx, ncontrasts, nz, ny)`` In this case, image should be transposed to ``(ncontrasts, nslices, ny, nx)`` or ``(ncontrasts, nz, ny, nx)`` for 2D/3D acquisitions, respectively. If provided, ``head`` will contain the appropriate permutation order (:func:`head.transpose`): * **2Dcart:** ``head.transpose = [1, 0, 2, 3]`` * **2Dnoncart:** ``head.transpose = [1, 0, 2, 3]`` * **3Dcart:** ``head.transpose = [0, 1, 2, 3]`` * **3Dnoncart:** ``head.transpose = [1, 2, 3, 0]`` If ``head`` is not provided, the user shoudl manually transpose the image tensor to match the required shape. """ if dataformat == "dicom": _dicom.write_dicom( filename, image, filepath, head, series_description, series_number_offset, series_number_scale, rescale, anonymize, verbose, ) elif dataformat == "nifti": _nifti.write_nifti( filename, image, filepath, head, series_description, series_number_offset, series_number_scale, rescale, anonymize, verbose, ) else: raise RuntimeError( f"Data format = {dataformat} not recognized! Please use 'dicom' or 'nifti'" )
# %% sub routines def _get_error(ex): trace = [] tb = ex.__traceback__ while tb is not None: trace.append( { "filename": tb.tb_frame.f_code.co_filename, "name": tb.tb_frame.f_code.co_name, "lineno": tb.tb_lineno, } ) tb = tb.tb_next return str({"type": type(ex).__name__, "message": str(ex), "trace": trace})