TOF listmode MLEM with projection data

This example demonstrates the use of the listmode MLEM algorithm to minimize the negative Poisson log-likelihood function.

\[f(x) = \sum_{i=1}^m \bar{y}_i (x) - \bar{y}_i (x) \log(y_i)\]

subject to

\[x \geq 0\]

using the listmode linear forward model

\[\bar{y}_{LM}(x) = A_{LM} x + s\]

and data stored in listmode format (event by event).

Tip

parallelproj is python array API compatible meaning it supports different array backends (e.g. numpy, cupy, torch, …) and devices (CPU or GPU). Choose your preferred array API xp and device dev below.

https://mybinder.org/badge_logo.svg
32 from __future__ import annotations
33 from parallelproj import Array
34
35 import array_api_compat.numpy as xp
36
37 # import array_api_compat.cupy as xp
38 # import array_api_compat.torch as xp
39
40 import parallelproj
41 from array_api_compat import to_device, size
42 import array_api_compat.numpy as np
43 import matplotlib.pyplot as plt
44 import matplotlib.animation as animation
45 from copy import copy
46
47 # choose a device (CPU or CUDA GPU)
48 if "numpy" in xp.__name__:
49     # using numpy, device must be cpu
50     dev = "cpu"
51 elif "cupy" in xp.__name__:
52     # using cupy, only cuda devices are possible
53     dev = xp.cuda.Device(0)
54 elif "torch" in xp.__name__:
55     # using torch valid choices are 'cpu' or 'cuda'
56     if parallelproj.cuda_present:
57         dev = "cuda"
58     else:
59         dev = "cpu"

Simulation of PET data in sinogram space

In this example, we use simulated listmode data for which we first need to setup a sinogram forward model to create a noise-free and noisy emission sinogram that can be converted to listmode data.

Sinogram forward model setup

We setup a linear forward operator \(A\) consisting of an image-based resolution model, a non-TOF PET projector and an attenuation model

 78 num_rings = 5
 79 scanner = parallelproj.RegularPolygonPETScannerGeometry(
 80     xp,
 81     dev,
 82     radius=65.0,
 83     num_sides=12,
 84     num_lor_endpoints_per_side=15,
 85     lor_spacing=2.3,
 86     ring_positions=xp.linspace(-10, 10, num_rings),
 87     symmetry_axis=2,
 88 )
 89
 90 # setup the LOR descriptor that defines the sinogram
 91
 92 img_shape = (40, 40, 8)
 93 voxel_size = (2.0, 2.0, 2.0)
 94
 95 lor_desc = parallelproj.RegularPolygonPETLORDescriptor(
 96     scanner,
 97     radial_trim=10,
 98     max_ring_difference=2,
 99     sinogram_order=parallelproj.SinogramSpatialAxisOrder.RVP,
100 )
101
102 proj = parallelproj.RegularPolygonPETProjector(
103     lor_desc, img_shape=img_shape, voxel_size=voxel_size
104 )
105
106 # setup a simple test image containing a few "hot rods"
107 x_true = xp.ones(proj.in_shape, device=dev, dtype=xp.float32)
108 c0 = proj.in_shape[0] // 2
109 c1 = proj.in_shape[1] // 2
110 x_true[(c0 - 2) : (c0 + 2), (c1 - 2) : (c1 + 2), :] = 5.0
111 x_true[4, c1, 2:] = 5.0
112 x_true[c0, 4, :-2] = 5.0
113
114 x_true[:2, :, :] = 0
115 x_true[-2:, :, :] = 0
116 x_true[:, :2, :] = 0
117 x_true[:, -2:, :] = 0

Attenuation image and sinogram setup

124 # setup an attenuation image
125 x_att = 0.01 * xp.astype(x_true > 0, xp.float32)
126 # calculate the attenuation sinogram
127 att_sino = xp.exp(-proj(x_att))

Complete PET forward model setup

We combine an image-based resolution model, a non-TOF or TOF PET projector and an attenuation model into a single linear operator.

137 # enable TOF - comment if you want to run non-TOF
138 proj.tof_parameters = parallelproj.TOFParameters(
139     num_tofbins=13, tofbin_width=12.0, sigma_tof=12.0
140 )
141
142 # setup the attenuation multiplication operator which is different
143 # for TOF and non-TOF since the attenuation sinogram is always non-TOF
144 if proj.tof:
145     att_op = parallelproj.TOFNonTOFElementwiseMultiplicationOperator(
146         proj.out_shape, att_sino
147     )
148 else:
149     att_op = parallelproj.ElementwiseMultiplicationOperator(att_sino)
150
151 res_model = parallelproj.GaussianFilterOperator(
152     proj.in_shape, sigma=4.5 / (2.35 * proj.voxel_size)
153 )
154
155 # compose all 3 operators into a single linear operator
156 pet_lin_op = parallelproj.CompositeLinearOperator((att_op, proj, res_model))

Simulation of sinogram projection data

We setup an arbitrary ground truth \(x_{true}\) and simulate noise-free and noisy data \(y\) by adding Poisson noise.

165 # simulated noise-free data
166 noise_free_data = pet_lin_op(x_true)
167
168 # generate a contant contamination sinogram
169 contamination = xp.full(
170     noise_free_data.shape,
171     0.5 * float(xp.mean(noise_free_data)),
172     device=dev,
173     dtype=xp.float32,
174 )
175
176 noise_free_data += contamination
177
178 # add Poisson noise
179 np.random.seed(1)
180 y = xp.asarray(
181     np.random.poisson(parallelproj.to_numpy_array(noise_free_data)),
182     device=dev,
183     dtype=xp.int16,
184 )

Conversion of the emission sinogram to listmode

Using RegularPolygonPETProjector.convert_sinogram_to_listmode() we can convert an integer non-TOF or TOF sinogram to an event list for listmode processing.

Note: The create event list is sorted and should be shuffled running LM-MLEM.

195 event_start_coords, event_end_coords, event_tofbins = proj.convert_sinogram_to_listmode(
196     y
197 )

Setup of the LM projector and LM forward model

203 lm_proj = parallelproj.ListmodePETProjector(
204     event_start_coords,
205     event_end_coords,
206     proj.in_shape,
207     proj.voxel_size,
208     proj.img_origin,
209 )
210
211 # recalculate the attenuation factor for all LM events
212 # this needs to be a non-TOF projection
213 att_list = xp.exp(-lm_proj(x_att))
214 lm_att_op = parallelproj.ElementwiseMultiplicationOperator(att_list)
215
216 # enable TOF in the LM projector
217 lm_proj.tof_parameters = proj.tof_parameters
218 if proj.tof:
219     lm_proj.event_tofbins = event_tofbins
220     lm_proj.tof = proj.tof
221
222 # create the contamination list
223 contamination_list = xp.full(
224     event_start_coords.shape[0],
225     float(xp.reshape(contamination, (size(contamination),))[0]),
226     device=dev,
227     dtype=xp.float32,
228 )
229
230 lm_pet_lin_op = parallelproj.CompositeLinearOperator((lm_att_op, lm_proj, res_model))

LM MLEM reconstruction

The EM update that can be used in LM-MLEM is given by

\[x^+ = \frac{x}{A^H 1} A_{LM}^H \frac{1}{A_{LM} x + s_{LM}}\]

to calculate the minimizer of \(f(x)\) iteratively.

244 def lm_em_update(
245     x_cur: Array,
246     op: parallelproj.LinearOperator,
247     s: Array,
248     adjoint_ones: Array,
249 ) -> Array:
250     """LM EM update
251
252     Parameters
253     ----------
254     x_cur : Array
255         current solution
256     op : parallelproj.LinearOperator
257         listmode linear forward operator
258     s : Array
259         contamination list
260     adjoint_ones : Array
261         adjoint of ones of the non-LM (the complete) operator
262
263     Returns
264     -------
265     Array
266     """
267     ybar = op(x_cur) + s
268     return x_cur * op.adjoint(1 / ybar) / adjoint_ones

Run LM MLEM iterations

275 # number of MLEM iterations
276 num_iter = 10
277
278 # initialize x
279 x = xp.ones(pet_lin_op.in_shape, dtype=xp.float32, device=dev)
280 # calculate A^H 1
281 adjoint_ones = pet_lin_op.adjoint(
282     xp.ones(pet_lin_op.out_shape, dtype=xp.float32, device=dev)
283 )
284
285 for i in range(num_iter):
286     print(f"MLEM iteration {(i + 1):03} / {num_iter:03}", end="\r")
287     x = lm_em_update(x, lm_pet_lin_op, contamination_list, adjoint_ones)

Calculate the negative Poisson log-likelihood function of the reconstruction

293 # calculate the negative Poisson log-likelihood function of the reconstruction
294 exp = pet_lin_op(x) + contamination
295 # calculate the relative cost and distance to the optimal point
296 cost = float(xp.sum(exp - xp.astype(y, xp.float32) * xp.log(exp)))
297 print(f"\nMLEM cost {cost:.6E} after {num_iter:03} iterations")

Visualize the results

304 def _update_img(i):
305     img0.set_data(x_true_np[:, :, i])
306     img1.set_data(x_np[:, :, i])
307     ax[0].set_title(f"true image - plane {i:02}")
308     ax[1].set_title(f"LM MLEM iteration {num_iter} - plane {i:02}")
309     return (img0, img1)
310
311
312 x_true_np = parallelproj.to_numpy_array(x_true)
313 x_np = parallelproj.to_numpy_array(x)
314
315 fig, ax = plt.subplots(1, 2, figsize=(10, 5))
316 vmax = x_np.max()
317 img0 = ax[0].imshow(x_true_np[:, :, 0], cmap="Greys", vmin=0, vmax=vmax)
318 img1 = ax[1].imshow(x_np[:, :, 0], cmap="Greys", vmin=0, vmax=vmax)
319 ax[0].set_title(f"true image - plane {0:02}")
320 ax[1].set_title(f"LM MLEM iteration {num_iter} - plane {0:02}")
321 fig.tight_layout()
322 ani = animation.FuncAnimation(fig, _update_img, x_np.shape[2], interval=200, blit=False)
323 fig.show()

Related examples

TOF listmode OSEM with projection data

TOF listmode OSEM with projection data

TOF-MLEM with projection data

TOF-MLEM with projection data

MLEM with projection data of an open PET geometry

MLEM with projection data of an open PET geometry

TOF OSEM with projection data

TOF OSEM with projection data

Gallery generated by Sphinx-Gallery