Quantitative Analysis (SIMIND)#
In this tutorial, we’ll make use of a ground truth phantom and corresponding projection data obtained using SIMIND. We’ll use attenuation correction, PSF correction, scatter correction, and priors to compare the reconstruction of SIMIND SPECT simulation data to the corresponding ground truth phantom.
[1]:
import os
import torch
import numpy as np
from skimage.transform import resize
import matplotlib.pyplot as plt
import pytomography
from pytomography.algorithms import OSEMBSR
from pytomography.priors import RelativeDifferencePrior
from pytomography.metadata import PSFMeta
from pytomography.transforms import SPECTAttenuationTransform, SPECTPSFTransform
from pytomography.projections import SPECTSystemMatrix
from pytomography.callbacks import CallBack
from pytomography.io.SPECT import simind
from scipy.ndimage import zoom
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
Set the device used in pytomography, and the path where you saved the files (you will need to modify path)
[2]:
pytomography.device = device
path = '/disk1/pytomography_tutorial_data/simind_tutorial/'
Part 1: Open Projection Data#
Like in the first SIMIND tutorial, we’ll start by opening our projection data
[3]:
organs = ['bkg', 'liver', 'l_lung', 'r_lung', 'l_kidney', 'r_kidney','salivary', 'bladder']
activities = [2500, 450, 7, 7, 100, 100, 20, 90] # MBq
activity_dict = {organ: activity for organ, activity in zip(organs, activities)}
headerfiles = [os.path.join(path, 'multi_projections', organ, 'photopeak.h00') for organ in organs]
headerfiles_lower = [os.path.join(path, 'multi_projections', organ, 'lowerscatter.h00') for organ in organs]
headerfiles_upper = [os.path.join(path, 'multi_projections', organ, 'upperscatter.h00') for organ in organs]
object_meta, image_meta, projections = simind.combine_projection_data(headerfiles, activities)
scatter = simind.combine_scatter_data_TEW(headerfiles, headerfiles_lower, headerfiles_upper, activities)
Now lets make them representative of a real scan where each projection is taken for 15 s, like we did in the first SIMIND tutorial
[4]:
dT = 15 #s
projections *= dT
scatter *= dT
projections = torch.poisson(projections)
scatter = torch.poisson(scatter)
And plot
[5]:
plt.subplots(1, 2, figsize=(6,6))
plt.subplot(121)
plt.pcolormesh(projections[0,0].cpu().T, cmap='nipy_spectral')
plt.axis('off')
plt.colorbar(label='Counts')
plt.subplot(122)
plt.pcolormesh(scatter[0,0].cpu().T, cmap='nipy_spectral')
plt.axis('off')
plt.colorbar(label='Counts')
[5]:
<matplotlib.colorbar.Colorbar at 0x7fc6201ae2e0>

These reconstructions can be reconstructed as shown in the introductory SIMIND tutorial. The goal of this tutorial, however, is to perform a quantitative analysis on the accuracy of the reconstruction, given that we know the ground truth distribution.
2. Obtain Organ Masks#
We need to start by creating 3D organ masks for each of the different simulated regions in SIMIND. We’ll start by getting access to all the ground truth files used to simulate each seperate region.
The following code creates 4 things:
mask
: An object of shape (1,128,128,384) where each voxel is an integer signifying which region it is in. For example, a voxel with integer2
corresponds to liver. This requires opening the GT images of shape (756,512,512), transposing/resizing these images to shape (128,128,384), and building a mask.mask_idx_dict
: A dictionary which has organs/regions as keys and their corresponding integer value (inmask
) as values.organ_vol_dict
: A dictionary which has organs/regions as keys and the corresponding volume of the organ in mLmask_vol_dict
: A dictionary which has organs/regions as keys and the corresponding volume of the organ mask in mL. Note that this is different fromorgan_vol_dict
since themask
only includes voxels that are completely inside the organ (see plot below)
[6]:
GTfiles = [os.path.join(path, 'phantom_organs', f'{organ}_act_av.bin') for organ in organs]
GT_shape = (768,512,512)
GT_spacing = (0.15,0.075,0.075) #cm
mask = np.zeros(object_meta.shape)
mask_idx_dict = {}
mask_vol_dict = {}
organ_vol_dict = {}
for i, GT_path in enumerate(GTfiles):
# Open GT file
GTi = np.fromfile(GT_path, dtype=np.float32)
# Reshape to 3D dimensions
GTi = GTi.reshape((768,512,512))
# Tranpose x/z axis to fit with pytomography standards
GTi = np.transpose(GTi, (2,1,0))
# Look for values >0 that specify the mask
GTi = (GTi>0).astype(np.float32)
# Find the volume of the organ (in mL)
organ_vol_dict[organs[i]] = GTi.sum() * np.prod(GT_spacing)
# Resize the mask to be the same shape as the reconstructed object
GTi = resize(GTi, object_meta.shape, anti_aliasing=True)
# Take only voxels that lie entirely inside the organ/region
GTi = GTi>=1
# Find the volume of the organ mask (in mL)
mask_vol_dict[organs[i]] = GTi.sum() * np.prod(object_meta.dr)
# Update the mask
mask[GTi] = i+1
mask_idx_dict[organs[i]] = i+1
The mask
can be viewed below. Note that the masks only include voxels that don’t overlap boundaries
[7]:
plt.figure(figsize=(3,6))
plt.pcolormesh(mask[:,64].T, cmap='nipy_spectral')
[7]:
<matplotlib.collections.QuadMesh at 0x7fc6fc2b3880>

Part 3: Reconstructing SPECT Data#
Now we will reconstruct our SPECT data, and compare it to the original phantom. In particular, since we’re using an iterative algorithm, we’ll want to see how the improvements as a function of iterations. We’ll get to that later.
[8]:
CT = simind.get_atteuation_map(os.path.join(path, 'multi_projections', 'mu208.hct'))
att_transform = SPECTAttenuationTransform(CT)
psf_meta = simind.get_psfmeta_from_header(headerfiles[0])
psf_transform = SPECTPSFTransform(psf_meta)
system_matrix = SPECTSystemMatrix(
obj2obj_transforms = [att_transform,psf_transform],
im2im_transforms = [],
object_meta = object_meta,
image_meta = image_meta)
/home/gpuvmadm/PyTomography/src/pytomography/io/io_utils.py:43: RuntimeWarning: overflow encountered in exp
return c1*np.exp(-d1*np.sqrt(energy)) + c2*np.exp(-d2*np.sqrt(energy))
Next we’ll create a subclass of the CallBack
class to compute statistics during the reconstruction after each iteraton.
[9]:
class CompareToGroundTruth(CallBack):
def __init__(self, organs, mask, mask_idx_dict, mask_vol_dict):
self.organs = organs
self.mask = mask
self.mask_idx_dict = mask_idx_dict
self.mask_vol_dict = mask_vol_dict
self.avg_counts_per_mL = {organ: [] for organ in organs}
self.std_counts_per_mL = {organ: [] for organ in organs}
def run(self, obj, n_iter, n_subset):
for organ in organs:
# Compute the counts/mL for each voxel in the organ
counts_per_mL = obj[0][self.mask==self.mask_idx_dict[organ]] / np.prod(object_meta.dr)
# Compute average counts/mL and standard deviation of counts/mL in the organ
avg_counts_per_mL = counts_per_mL.mean().item()
std_counts_per_mL = counts_per_mL.std().item()
# Append these
self.avg_counts_per_mL[organ].append(avg_counts_per_mL)
self.std_counts_per_mL[organ].append(std_counts_per_mL)
Now create an instance of the callback and use it in a reconstruction algorithm:
Note, we are running for 80 iterations; this may take a few minutes depending on your graphics card
[10]:
callback = CompareToGroundTruth(organs, mask, mask_idx_dict, mask_vol_dict)
reconstruction_algorithm = OSEMBSR(projections, system_matrix, scatter=scatter)
reconstructed_object = reconstruction_algorithm(n_iters=80, n_subsets=8, callback=callback)
The reconstructed object has units of counts. If we want to convert to MBq, we need to normalize based on the total activity injected
In clinical practice, this normalization factor can be obtained by scanning a point source of known activity (in MBq) and then dividing the total number of counts detected by the known activity
[11]:
counts_to_MBq = sum(activities) / reconstructed_object.sum().item()
Let’s visualize a coronal slice of the reconstructed objects.
We’ll convert to units of MBq/mL by multiple the array of counts by the
counts_to_MBq
conversion factor, and then dividing by the voxel volume
[12]:
o1 = reconstructed_object[0][:,64].cpu().numpy().T * counts_to_MBq / np.prod(object_meta.dr)
[13]:
plt.figure(figsize=(3,6))
plt.pcolormesh(o1,cmap='nipy_spectral', vmax=1)
plt.axis('off')
plt.colorbar(label='MBq/mL')
[13]:
<matplotlib.colorbar.Colorbar at 0x7fc60c715dc0>

Finally, here’s some code written to plot/compare noise-bias curves for each reconstruction algorithm.
[14]:
def plot_bvc(callback, organ, ax, color, label):
activity_MBQ_per_mL = np.array(callback.avg_counts_per_mL[organ]) * counts_to_MBq
activity_noise_MBQ_per_mL = np.array(callback.std_counts_per_mL[organ]) * counts_to_MBq
true_activity_MBQ_per_mL = activity_dict[organ] / organ_vol_dict[organ]
activity_bias_pct = 100 * activity_MBQ_per_mL / true_activity_MBQ_per_mL - 100
activity_noise_pct = 100 * activity_noise_MBQ_per_mL / true_activity_MBQ_per_mL
ax.plot(activity_bias_pct, activity_noise_pct, ls='--', marker='o', markersize=4, lw=2, color=color, label=label)
fig, axes = plt.subplots(2, 4, figsize=(13,5))
for ax, organ in zip(axes.ravel(), organs):
plot_bvc(callback, organ, ax, 'blue', 'Generic')
ax.grid()
ax.tick_params(axis='y', which='major', labelsize=8)
ax.tick_params(axis='x', which='major', labelsize=8, rotation=10)
ax.tick_params(axis='both', which='minor', labelsize=8)
ax.set_title(organ)
axes[0,0].legend(facecolor='white', framealpha=1, frameon=True, loc='upper right')
fig.add_subplot(111, frameon=False)
plt.tick_params(labelcolor='none', which='both', top=False, bottom=False, left=False, right=False)
plt.xlabel("Bias [%]", fontsize=15)
plt.ylabel("Noise [%]", fontsize=15)
fig.tight_layout()

These noise-bias curves can be compared between various reconstruction algorithms to assess the capabilities of each algorithm.