pytorch parallelproj projection layer

In this example, we show how to define a custom pytorch layer that can be used to define a feed forward neural network that includes a parallelproj forward and back backward projections (or any LinearOperator) that can be used with pytorch’s autograd engine.

from __future__ import annotations

import array_api_compat.torch as torch
import matplotlib.pyplot as plt
import parallelproj.operators
import parallelproj.projectors
import parallelproj.pet_scanners
import parallelproj.pet_lors
import parallelproj_core as ppc
from array_api_compat import device

# device variable (cpu or cuda) that determines whether calculations
# are performed on the cpu or cuda gpu
if torch.cuda.is_available() and ppc.cuda_enabled == 1:
    dev = "cuda"
else:
    dev = "cpu"

Setup the forward projection layer

We subclass torch.autograd.Function to define a custom pytorch layer that is compatible with pytorch’s autograd engine. see also: https://pytorch.org/tutorials/beginner/examples_autograd/two_layer_net_custom_function.html

class LinearSingleChannelOperator(torch.autograd.Function):
    """
    Function representing a linear operator acting on a mini batch of single channel images
    """

    @staticmethod
    def forward(
        ctx, x: torch.Tensor, operator: parallelproj.operators.LinearOperator
    ) -> torch.Tensor:
        """forward pass of the linear operator

        Parameters
        ----------
        ctx : context object
            that can be used to store information for the backward pass
        x : torch.Tensor
            mini batch of 3D images with dimension (batch_size, 1, num_voxels_x, num_voxels_y, num_voxels_z)
        operator : parallelproj.operators.LinearOperator
            linear operator that can act on a single 3D image

        Returns
        -------
        torch.Tensor
            mini batch of 3D images with dimension (batch_size, *operator.out_shape)
        """

        # https://pytorch.org/docs/stable/notes/extending.html#how-to-use
        ctx.set_materialize_grads(False)
        ctx.operator = operator

        batch_size = x.shape[0]
        y = torch.zeros(
            (batch_size,) + operator.out_shape, dtype=x.dtype, device=device(x)
        )

        # loop over all samples in the batch and apply linear operator
        # to the first channel
        for i in range(batch_size):
            y[i, ...] = operator(x[i, 0, ...].detach())

        return y

    @staticmethod
    def backward(ctx, grad_output: torch.Tensor) -> tuple[torch.Tensor, None]:
        """backward pass of the forward pass

        Parameters
        ----------
        ctx : context object
            that can be used to obtain information from the forward pass
        grad_output : torch.Tensor
            mini batch of dimension (batch_size, *operator.out_shape)

        Returns
        -------
        torch.Tensor, None
            mini batch of 3D images with dimension (batch_size, 1, *operator.in_shape)
        """

        # For details on how to implement the backward pass, see
        # https://pytorch.org/docs/stable/notes/extending.html#how-to-use

        # since forward takes two input arguments (x, operator)
        # we have to return two arguments (the latter is None)
        if grad_output is None:
            return None, None
        else:
            operator = ctx.operator

            batch_size = grad_output.shape[0]
            x = torch.zeros(
                (batch_size, 1) + operator.in_shape,
                dtype=grad_output.dtype,
                device=device(grad_output),
            )

            # loop over all samples in the batch and apply linear operator
            # to the first channel
            for i in range(batch_size):
                x[i, 0, ...] = operator.adjoint(grad_output[i, ...].detach())

            return x, None

Setup the back projection layer

We subclass torch.autograd.Function to define a custom pytorch layer that is compatible with pytorch’s autograd engine. see also: https://pytorch.org/tutorials/beginner/examples_autograd/two_layer_net_custom_function.html

class AdjointLinearSingleChannelOperator(torch.autograd.Function):
    """
    Function representing the adjoint of a linear operator acting on a mini batch of single channel images
    """

    @staticmethod
    def forward(
        ctx, x: torch.Tensor, operator: parallelproj.operators.LinearOperator
    ) -> torch.Tensor:
        """forward pass of the adjoint of the linear operator

        Parameters
        ----------
        ctx : context object
            that can be used to store information for the backward pass
        x : torch.Tensor
            mini batch of 3D images with dimension (batch_size, *operator.out_shape)
        operator : parallelproj.operators.LinearOperator
            linear operator that can act on a single 3D image

        Returns
        -------
        torch.Tensor
            mini batch of 3D images with dimension (batch_size, 1, *operator.in_shape)
        """

        ctx.set_materialize_grads(False)
        ctx.operator = operator

        batch_size = x.shape[0]
        y = torch.zeros(
            (batch_size, 1) + operator.in_shape, dtype=x.dtype, device=device(x)
        )

        # loop over all samples in the batch and apply linear operator
        # to the first channel
        for i in range(batch_size):
            y[i, 0, ...] = operator.adjoint(x[i, ...].detach())

        return y

    @staticmethod
    def backward(ctx, grad_output):
        """backward pass of the forward pass

        Parameters
        ----------
        ctx : context object
            that can be used to obtain information from the forward pass
        grad_output : torch.Tensor
            mini batch of dimension (batch_size, 1, *operator.in_shape)

        Returns
        -------
        torch.Tensor, None
            mini batch of 3D images with dimension (batch_size, *operator.out_shape)
        """

        # For details on how to implement the backward pass, see
        # https://pytorch.org/docs/stable/notes/extending.html#how-to-use

        # since forward takes two input arguments (x, operator)
        # we have to return two arguments (the latter is None)
        if grad_output is None:
            return None, None
        else:
            operator = ctx.operator

            batch_size = grad_output.shape[0]
            x = torch.zeros(
                (batch_size,) + operator.out_shape,
                dtype=grad_output.dtype,
                device=device(grad_output),
            )

            # loop over all samples in the batch and apply linear operator
            # to the first channel
            for i in range(batch_size):
                x[i, ...] = operator(grad_output[i, 0, ...].detach())

            return x, None

Setup a minimal non-TOF PET projector

We setup a minimal non-TOF PET projector of small scanner with three rings.

num_rings = 3
scanner = parallelproj.pet_scanners.RegularPolygonPETScannerGeometry(
    torch,
    dev,
    radius=35.0,
    num_sides=12,
    num_lor_endpoints_per_side=6,
    lor_spacing=3.0,
    ring_positions=torch.linspace(-4, 4, num_rings, device=dev),
    symmetry_axis=2,
)

# setup the LOR descriptor that defines the sinogram
lor_desc = parallelproj.pet_lors.RegularPolygonPETLORDescriptor(
    scanner,
    parallelproj.pet_lors.Michelogram(scanner.num_rings, max_ring_difference=1, span=1),
    radial_trim=10,
    sinogram_order=parallelproj.pet_lors.SinogramSpatialAxisOrder.RVP,
)

proj = parallelproj.projectors.RegularPolygonPETProjector(
    lor_desc, img_shape=(20, 20, 5), voxel_size=(2.0, 2.0, 2.0)
)

Define a mini batch of input and output tensors

batch_size = 2

x = torch.rand(
    (batch_size, 1) + proj.in_shape,
    device=dev,
    dtype=torch.float32,
    requires_grad=True,
)

y = torch.rand(
    (batch_size,) + proj.out_shape,
    device=dev,
    dtype=torch.float32,
    requires_grad=True,
)

Define the forward and backward projection layers

fwd_op_layer = LinearSingleChannelOperator.apply
adjoint_op_layer = AdjointLinearSingleChannelOperator.apply

f1 = fwd_op_layer(x, proj)
print("forward projection (Ax) .:", f1.shape, type(f1), device(f1))

b1 = adjoint_op_layer(y, proj)
print("back projection (A^T y) .:", b1.shape, type(b1), device(b1))

fb1 = adjoint_op_layer(fwd_op_layer(x, proj), proj)
print("back + forward projection (A^TAx) .:", fb1.shape, type(fb1), device(fb1))
forward projection (Ax) .: torch.Size([2, 51, 36, 7]) <class 'torch.Tensor'> cpu
back projection (A^T y) .: torch.Size([2, 1, 20, 20, 5]) <class 'torch.Tensor'> cpu
back + forward projection (A^TAx) .: torch.Size([2, 1, 20, 20, 5]) <class 'torch.Tensor'> cpu

Define a dummy loss function and trigger the backpropagation

# define a dummy loss function
dummy_loss = (fb1**2).sum()
# trigger the backpropagation
dummy_loss.backward()

print(f" backpropagted gradient {x.grad}")
backpropagted gradient tensor([[[[[16932896.0000,  6352481.0000, 27397480.0000,  6380920.5000,
           17273928.0000],
          [19099642.0000,  8492678.0000, 30550182.0000,  8595404.0000,
           19520214.0000],
          [20724762.0000, 10817195.0000, 32533588.0000, 10969943.0000,
           21175812.0000],
          ...,
          [21330666.0000, 11219816.0000, 32817322.0000, 10795242.0000,
           21046446.0000],
          [19716474.0000,  8825927.0000, 30585234.0000,  8521366.0000,
           19409140.0000],
          [17443932.0000,  6524830.5000, 27436158.0000,  6335062.5000,
           17214780.0000]],

         [[19079140.0000,  8518590.0000, 30548918.0000,  8635200.0000,
           19515670.0000],
          [21970598.0000, 11735578.0000, 34764804.0000, 11939253.0000,
           22518344.0000],
          [23001820.0000, 14203363.0000, 35754420.0000, 14447078.0000,
           23564554.0000],
          ...,
          [23655534.0000, 14826847.0000, 35964320.0000, 14242244.0000,
           23373646.0000],
          [22714170.0000, 12212977.0000, 34842596.0000, 11741427.0000,
           22380786.0000],
          [19671780.0000,  8783159.0000, 30601994.0000,  8515551.0000,
           19473182.0000]],

         [[20694072.0000, 10785287.0000, 32655780.0000, 10952261.0000,
           21163144.0000],
          [22953230.0000, 14239013.0000, 35821944.0000, 14495623.0000,
           23553086.0000],
          [23803294.0000, 17099806.0000, 36531948.0000, 17369572.0000,
           24395942.0000],
          ...,
          [24420630.0000, 17774280.0000, 36707048.0000, 17094096.0000,
           24189944.0000],
          [23678734.0000, 14788140.0000, 35898872.0000, 14172911.0000,
           23389088.0000],
          [21287292.0000, 11221167.0000, 32686326.0000, 10809938.0000,
           21046232.0000]],

         ...,

         [[21217240.0000, 11126856.0000, 32737468.0000, 10992263.0000,
           21453314.0000],
          [23564412.0000, 14672806.0000, 35934688.0000, 14433272.0000,
           23863818.0000],
          [24334654.0000, 17679062.0000, 36652464.0000, 17281730.0000,
           24642454.0000],
          ...,
          [24431972.0000, 17777002.0000, 36866788.0000, 17235558.0000,
           24435248.0000],
          [23619254.0000, 14858222.0000, 36209088.0000, 14413261.0000,
           23643350.0000],
          [21265478.0000, 11235368.0000, 33006884.0000, 10911495.0000,
           21265736.0000]],

         [[19561894.0000,  8698728.0000, 30661058.0000,  8625960.0000,
           19766036.0000],
          [22500922.0000, 12075355.0000, 34907808.0000, 11956471.0000,
           22819774.0000],
          [23496694.0000, 14703118.0000, 35941144.0000, 14452310.0000,
           23797616.0000],
          ...,
          [23667030.0000, 14831042.0000, 36237068.0000, 14385982.0000,
           23675464.0000],
          [22625018.0000, 12250281.0000, 35251120.0000, 11904508.0000,
           22645544.0000],
          [19619982.0000,  8857850.0000, 30965348.0000,  8628376.0000,
           19625624.0000]],

         [[17276590.0000,  6472267.5000, 27512382.0000,  6406755.0000,
           17471192.0000],
          [19455524.0000,  8706516.0000, 30630314.0000,  8664995.0000,
           19763740.0000],
          [21130730.0000, 11084881.0000, 32789740.0000, 10970615.0000,
           21402508.0000],
          ...,
          [21285094.0000, 11266842.0000, 32980902.0000, 10931723.0000,
           21243362.0000],
          [19655728.0000,  8833125.0000, 30987346.0000,  8609263.0000,
           19656150.0000],
          [17381372.0000,  6559932.5000, 27789476.0000,  6404095.5000,
           17386040.0000]]]],



       [[[[17453540.0000,  6426334.5000, 27569554.0000,  6539884.5000,
           17709240.0000],
          [19719024.0000,  8625850.0000, 30683532.0000,  8757020.0000,
           19995464.0000],
          [21380000.0000, 10990309.0000, 32706550.0000, 11160870.0000,
           21679512.0000],
          ...,
          [21413716.0000, 11182053.0000, 33643548.0000, 11302536.0000,
           21275740.0000],
          [19750886.0000,  8852370.0000, 31631352.0000,  8929781.0000,
           19630158.0000],
          [17503398.0000,  6558547.0000, 28375730.0000,  6621824.5000,
           17398850.0000]],

         [[19715248.0000,  8657915.0000, 30667510.0000,  8787188.0000,
           19941534.0000],
          [22736022.0000, 11934922.0000, 34872032.0000, 12113318.0000,
           23030538.0000],
          [23771188.0000, 14427895.0000, 35903972.0000, 14645279.0000,
           24076510.0000],
          ...,
          [23826300.0000, 14756789.0000, 36982544.0000, 14937423.0000,
           23609876.0000],
          [22828278.0000, 12230759.0000, 36047136.0000, 12324991.0000,
           22587968.0000],
          [19800720.0000,  8828948.0000, 31652212.0000,  8900760.0000,
           19633938.0000]],

         [[21362968.0000, 10956920.0000, 32753396.0000, 11112851.0000,
           21604830.0000],
          [23721838.0000, 14445152.0000, 35876188.0000, 14647468.0000,
           23985268.0000],
          [24579326.0000, 17297700.0000, 36617972.0000, 17538658.0000,
           24818194.0000],
          ...,
          [24713816.0000, 17688978.0000, 37772620.0000, 17898656.0000,
           24368340.0000],
          [23925148.0000, 14763799.0000, 37117420.0000, 14923081.0000,
           23596978.0000],
          [21512820.0000, 11253455.0000, 33730940.0000, 11332607.0000,
           21236112.0000]],

         ...,

         [[21176776.0000, 11195371.0000, 33456984.0000, 11413606.0000,
           21575896.0000],
          [23485108.0000, 14727279.0000, 36761996.0000, 15002947.0000,
           23975026.0000],
          [24239052.0000, 17684870.0000, 37508472.0000, 17975980.0000,
           24795268.0000],
          ...,
          [23795510.0000, 17448168.0000, 38071148.0000, 17875876.0000,
           24311138.0000],
          [22976302.0000, 14534755.0000, 37149772.0000, 14891490.0000,
           23533728.0000],
          [20746876.0000, 11015792.0000, 33783488.0000, 11290141.0000,
           21241408.0000]],

         [[19533742.0000,  8773919.0000, 31357566.0000,  8949866.0000,
           19970990.0000],
          [22486218.0000, 12180318.0000, 35709148.0000, 12419766.0000,
           23000100.0000],
          [23449020.0000, 14756201.0000, 36786152.0000, 15067909.0000,
           24029238.0000],
          ...,
          [23017316.0000, 14525668.0000, 37349160.0000, 14898541.0000,
           23596344.0000],
          [22007262.0000, 12004958.0000, 36136904.0000, 12329064.0000,
           22636866.0000],
          [19118072.0000,  8704756.0000, 31642100.0000,  8917210.0000,
           19635786.0000]],

         [[17291692.0000,  6506094.5000, 28078752.0000,  6641336.0000,
           17696962.0000],
          [19512912.0000,  8802124.0000, 31351354.0000,  8985838.0000,
           19973954.0000],
          [21163630.0000, 11187562.0000, 33543126.0000, 11411037.0000,
           21653642.0000],
          ...,
          [20770672.0000, 11074748.0000, 33956064.0000, 11358930.0000,
           21286236.0000],
          [19171008.0000,  8694352.0000, 31740994.0000,  8910645.0000,
           19671166.0000],
          [17031134.0000,  6478053.5000, 28424450.0000,  6625546.5000,
           17450056.0000]]]]])

Check whether the gradients are calculated correctly

We use pytorch’s gradcheck function to check whether the implementation of the backward pass, needed to calculate the gradients, is correct.

This test can be slow which is why we only execute it on the gpu. Note that parallelproj’s projectors use single precision precision which is why we have to use a larger atol and rtol.

if dev == "cpu":
    print("skipping (slow) gradient checks on cpu")
else:
    print("Running forward projection layer gradient test")
    grad_test_fwd = torch.autograd.gradcheck(
        fwd_op_layer, (x, proj), eps=1e-1, atol=1e-3, rtol=1e-3
    )

    print("Running adjoint projection layer gradient test")
    grad_test_fwd = torch.autograd.gradcheck(
        adjoint_op_layer, (y, proj), eps=1e-1, atol=1e-3, rtol=1e-3
    )
skipping (slow) gradient checks on cpu

Visualize the scanner geometry and image FOV

fig = plt.figure(figsize=(10, 10))
ax = fig.add_subplot(111, projection="3d")
ax.view_init(elev=-30, azim=160, roll=180, vertical_axis="y")
proj.show_geometry(ax)
fig.tight_layout()
fig.show()
01 run projection layer

Total running time of the script: (0 minutes 0.164 seconds)

Gallery generated by Sphinx-Gallery