Reconstructing DICOM Data#

[1]:
import os
import numpy as np
import torch
import pytomography
from pytomography.io.SPECT import dicom
from pytomography.transforms import SPECTAttenuationTransform, SPECTPSFTransform
from pytomography.algorithms import OSEMOSL
from pytomography.projections import SystemMatrix
import matplotlib.pyplot as plt
import torch
import pydicom
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
pytomography.device = device

First we specify the required filepaths for the SPECT scan (file_NM) and the corresponding filepaths for the atteunation map. Note that we open the CT data (in units of Hounsfield units) and an attenuation map here. Only one is needed for reconstruction (PyTomography has functionality for converting CT images in units of HU to attenuation maps in units of \(\text{cm}^{-1}\)). We will compare reconstruction using the vendor attenuation map and the attenuation map created using PyTomography.

[2]:
# initialize the `path`` variable below to specify the location of the required data
path = '/disk1/pytomography_tutorial_data/dicom_tutorial/'
path_CT = os.path.join(path, 'CT_files')
files_CT = [os.path.join(path_CT, file) for file in os.listdir(path_CT)]
file_AM = os.path.join(path, 'attenuation_map.dcm')
file_NM = os.path.join(path, 'projection_data.dcm')

The object meta, image meta, and photopeak can be obtained using the dicom_projections_to_data function. Note that most raw SPECT DICOM files contain data from multiple energy windows. If the index_peak argument isn’t included, then projections the third argument it returns would contain all energy windows (along the batch dimension). In this case, we specify index_peak=3, so it only returns the projections corresponding to that energy window. This infomation can be obtained by opening the dicom file using pydicom and looking at the EnergyWindowInformationSequence attribute

[3]:
object_meta, image_meta, photopeak = dicom.get_projections(file_NM, index_peak=3)
[4]:
plt.figure(figsize=(7,6))
plt.pcolormesh(photopeak[0,0].cpu().T, cmap='nipy_spectral')
plt.axis('off')
plt.colorbar(label='Counts')
[4]:
<matplotlib.colorbar.Colorbar at 0x7fd69ee83cd0>
../_images/notebooks_t_dicomdata_6_1.png

Scatter projections can be estimated using the triple energy window function dicom_get_scatterTEW. One needs to specify the index of the peak, and lower/upper energy windows

[5]:
scatter = dicom.get_scatter_from_TEW(file_NM, index_peak=3, index_lower=2, index_upper=4)

The attenuation map can be obtained in two ways

  1. If you have access to an attenuation map from the manufacturer in a single DICOM file, you should use this. This is obtained using the get_attenuation_map_from_file

  2. If you don’t have access to this attenuation map, then you’re not out of luck. PyTomography has functionality to arrange, align, resize, and rescale a sequence of CT scans into an attenuation map. It uses an approximate bilinear relationship between Hounsfield Units and linear attenuation coefficients. You can use the get_attenuation_map_from_CT_slices function, and give as input the files corresponding to the CT scan, the raw projection file name, and the index in the raw projection file corresponding to the photopeak.

[6]:
attenuation_map_from_file = dicom.get_attenuation_map_from_file(file_AM)
attenuation_map_from_CT_slices = dicom.get_attenuation_map_from_CT_slices(files_CT, file_NM, index_peak=3)
Cortical Bone Peak: 1419.3499755859375 HU
Effective CT Energy Determined: 75.89505539164563 keV
/home/gpuvmadm/PyTomography/src/pytomography/io/SPECT/helpers.py:43: RuntimeWarning: overflow encountered in exp
  return c1*np.exp(-d1*np.sqrt(energy)) + c2*np.exp(-d2*np.sqrt(energy))

We can compare the vendor obtained attenuation map and the approximated one below:

[7]:
sample_slice_from_file = attenuation_map_from_file[0][:,70].T
sample_slice_from_CT_slices = attenuation_map_from_CT_slices[0][:,70].T
ratio = (sample_slice_from_file+1e-3)/(sample_slice_from_CT_slices+1e-3)
[8]:
plt.subplots(1,3,figsize=(10,3))
plt.subplot(131)
plt.title('From File')
plt.pcolormesh(sample_slice_from_file, cmap='nipy_spectral')
plt.colorbar()
plt.subplot(132)
plt.title('From CT Slices')
plt.pcolormesh(sample_slice_from_CT_slices, cmap='nipy_spectral')
plt.colorbar()
plt.subplot(133)
plt.title('Ratio')
plt.pcolormesh(ratio, cmap='seismic', vmin=0.9, vmax=1.1)
plt.colorbar()
[8]:
<matplotlib.colorbar.Colorbar at 0x7fd69ac43fa0>
../_images/notebooks_t_dicomdata_13_1.png

While the two maps are very similar, they differ by approximately \(\pm 5\%\) in highly attenuating regions. This minor difference can have significant implications on the reconstruction (as we shall see shortly).

We can obtain the required parameters for PSF modeling provided we know the name of the scanner (scanner_model), the type of collimators used (collimator_name) and the energy (energy_kev) for finding the linear attenuation coefficient of lead. In this case, we know the radioactive substance used was Lu177, so we set the energy to the energy of the photopeak: 208keV.

[9]:
camera_model = 'SymbiaTSeries'
collimator_name = 'ME'
energy_kev = 208
psf_meta = dicom.get_psfmeta_from_scanner_params(camera_model, collimator_name, energy_kev)
print(psf_meta)
Function:         sigma_fit : function = lambda r, a, b: a*r+b,

Parameters: (0.03161992134409504, 0.12485030464233879)
Dimensions: 2D
Maximum sigmas: 3

Reconstruct the object#

We’re now ready to reconstruct the DICOM object. We’ll try using attenuation correction with both the vendors attenuation map and the one created by PyTomography

So we’ll create a function that performs reconstruction given a particular attenuation map:

[10]:
def reconstruct_phantom(attenuation_map):
    # Create transforms
    att_transform = SPECTAttenuationTransform(attenuation_map)
    psf_transform = SPECTPSFTransform(psf_meta)
    # Create system matrix
    system_matrix = SystemMatrix(
        obj2obj_transforms = [att_transform,psf_transform],
        im2im_transforms = [],
        object_meta = object_meta,
        image_meta = image_meta)
    # Initialize reconstruction algorithm
    reconstruction_algorithm = OSEMOSL(
        image = photopeak,
        system_matrix = system_matrix,
        scatter=scatter)
    # Reconstruct object
    reconstructed_object = reconstruction_algorithm(n_iters=4, n_subsets=10)
    return reconstructed_object
[11]:
reconstructed_object_atten_from_file = reconstruct_phantom(attenuation_map_from_file)
reconstructed_object_atten_from_CT_slices = reconstruct_phantom(attenuation_map_from_CT_slices)

We’ll compare these reconstructions to one done on the vendors software, which also performed OSEM with 4 iterations, 10 subsets, with attenuation modeling, PSF modeling, and triple energy window scatter correction.

[12]:
ds_recon = pydicom.read_file(os.path.join(path, 'scanner_recon.dcm'))
reconstructed_object_vendor = ds_recon.pixel_array / 90
reconstructed_object_vendor = np.transpose(reconstructed_object_vendor, (2,1,0))

Plotting#

We can looks at axial slices from all three reconstructions.

[13]:
slice_atten_from_file = reconstructed_object_atten_from_file.cpu()[0][:,:,70].T
slice_atten_from_CT_slices = reconstructed_object_atten_from_CT_slices.cpu()[0][:,:,70].T
slice_vendor = reconstructed_object_vendor[:,:,70].T
[14]:
plt.subplots(1,3,figsize=(10,3))
plt.subplot(131)
plt.title('From File')
plt.pcolormesh(slice_atten_from_file , cmap='nipy_spectral', vmax=110)
plt.axis('off')
plt.colorbar()
plt.subplot(132)
plt.title('From CT Slices')
plt.pcolormesh(slice_atten_from_CT_slices, cmap='nipy_spectral', vmax=110)
plt.axis('off')
plt.colorbar()
plt.subplot(133)
plt.title('From Vendor')
plt.pcolormesh(slice_vendor, cmap='nipy_spectral', vmax=110)
plt.axis('off')
plt.colorbar()
plt.show()
../_images/notebooks_t_dicomdata_26_0.png

Careful observation of the top bright lobe shows that the reconstruction obtained by using the vendors attenuation map gives a reconstruction more consistent with the vendor than that using seperate CT slices.