Parallel Beam Filtered Backprojection (FBP)

This example demonstrates 2D parallel-beam filtered backprojection on a Shepp-Logan phantom using the public call chain from diffct:

ParallelProjectorFunction.apply  -- differentiable parallel forward
ramp_filter_1d                   -- ramp filter along the detector axis
angular_integration_weights      -- per-view integration weights
parallel_weighted_backproject    -- voxel-driven FBP backprojection gather

The accompanying source file is examples/fbp_parallel.py.

Overview

Parallel-beam FBP is the classical analytical reconstruction method for 2D parallel CT. Because there is no source (rays are collimated), the pipeline is the simplest of the three analytical paths in diffct: no cosine pre-weight, no distance weight, no magnification correction. What remains is ramp filtering, a per-view angular integration weight, and a 1/(2*pi) analytical constant that the backprojection helper applies internally.

This example shows how to:

  • Sample a circular full-scan trajectory and a flat detector.

  • Generate parallel projections of a 2D Shepp-Logan phantom.

  • Apply ramp filtering with zero-padding and a Hann window.

  • Run the dedicated voxel-driven FBP gather kernel through parallel_weighted_backproject to reconstruct the image, already amplitude-calibrated.

Mathematical Background

Parallel-beam geometry

Rays at angle \(\theta\) are collimated in the direction \((\cos\theta, \sin\theta)\). A detector cell at position \(u\) on the detector axis \((-\sin\theta, \cos\theta)\) samples a ray passing through the point \((-u \sin\theta,\; u \cos\theta)\) in image-space coordinates centered on the isocenter. The corresponding line integral is the classical Radon transform

\[p(\theta, u) = \int_{-\infty}^{\infty} f\!\left(-u \sin\theta + s \cos\theta,\; u \cos\theta + s \sin\theta\right) ds.\]

The pixel \((x, y)\) projects to the detector at \(u_{xy} = -x \sin\theta + y \cos\theta\).

FBP algorithm

  1. Ramp filtering along the detector axis:

    \[p_f(\theta, u) = \mathcal{F}_u^{-1}\bigl\{\,|\omega_u|\, \mathcal{F}_u\{p(\theta, u)\}\bigr\}.\]
  2. Voxel-driven backprojection without any distance weighting:

    \[f(x, y) = \frac{1}{4\pi} \int_0^{2\pi} p_f\!\left(\theta, u_{xy}(\theta)\right) d\theta.\]

    The 1/(4\pi) \cdot \int_0^{2\pi} factor is equivalent to \(\frac{1}{2\pi} \cdot \int_0^{\pi}\); diffct uses the full \([0, 2\pi)\) form with redundant_full_scan=True in angular_integration_weights to absorb the factor of 1/2.

    parallel_weighted_backproject dispatches to a dedicated voxel-driven gather kernel _parallel_2d_fbp_backproject_kernel for this step. For each pixel and each view it computes \(u_{xy}\), linearly samples the filtered sinogram, and sums - no distance weighting, no magnification. The Siddon-based autograd parallel backprojector is reserved for the differentiable path used by iterative reconstruction and is not touched by this analytical pipeline.

Ramp Filter Options

ramp_filter_1d accepts the same high-precision optional kwargs as in the fan and cone examples: sample_spacing (set to detector_spacing), pad_factor (2 recommended), window (None / "ram-lak", "hann", "hamming", "cosine", "shepp-logan"), and use_rfft. The call is backward compatible so passing only (sino, dim=1) still works.

Analytical FBP scale

parallel_weighted_backproject multiplies its output by 1 / (2 * pi). This is the Fourier-convention constant that matches the |omega| ramp used by ramp_filter_1d. Unlike the fan and cone helpers there is no sdd/sid magnification factor because parallel beam has no source.

Implementation Steps

The main() function in examples/fbp_parallel.py follows the unified 8-step structure: volume geometry, source trajectory, detector geometry, CUDA transfer, forward projection, FBP pipeline (ramp filter / angular weights / voxel-gather backprojection / optional ReLU), quantitative summary (including a gradient sanity check), visualization.

Source

2D Parallel Beam FBP Example
  1"""Parallel-beam FBP reconstruction example.
  2
  3Pipeline (matches the fan-beam and cone-beam analytical examples):
  4
  5    ParallelProjectorFunction.apply  # forward projection (sinogram)
  6    ramp_filter_1d                   # ramp filter along detector axis
  7    angular_integration_weights      # per-view integration weights
  8    parallel_weighted_backproject    # voxel-driven FBP gather
  9
 10Parallel beam has no source, so there is no cosine pre-weighting and
 11no distance weighting during backprojection - the FBP pipeline only
 12needs ramp filtering, angular weights, and the ``1/(2*pi)`` analytical
 13constant that ``parallel_weighted_backproject`` applies internally.
 14"""
 15
 16import math
 17
 18import matplotlib.pyplot as plt
 19import numpy as np
 20import torch
 21import torch.nn.functional as F
 22
 23from diffct.differentiable import (
 24    ParallelProjectorFunction,
 25    angular_integration_weights,
 26    parallel_weighted_backproject,
 27    ramp_filter_1d,
 28)
 29
 30
 31def shepp_logan_2d(Nx, Ny):
 32    """2D Shepp-Logan phantom clipped to ``[0, 1]``."""
 33    Nx = int(Nx)
 34    Ny = int(Ny)
 35    phantom = np.zeros((Ny, Nx), dtype=np.float32)
 36    # (x0, y0, a, b, angle_deg, amplitude)
 37    ellipses = [
 38        (0.0,    0.0,    0.69,   0.92,    0.0,  1.0),
 39        (0.0,   -0.0184, 0.6624, 0.8740,  0.0, -0.8),
 40        (0.22,   0.0,    0.11,   0.31,  -18.0, -0.8),
 41        (-0.22,  0.0,    0.16,   0.41,   18.0, -0.8),
 42        (0.0,    0.35,   0.21,   0.25,    0.0,  0.7),
 43    ]
 44    cx = (Nx - 1) * 0.5
 45    cy = (Ny - 1) * 0.5
 46    for ix in range(Nx):
 47        for iy in range(Ny):
 48            xnorm = (ix - cx) / (Nx / 2)
 49            ynorm = (iy - cy) / (Ny / 2)
 50            val = 0.0
 51            for (x0, y0, a, b, angdeg, ampl) in ellipses:
 52                th = np.deg2rad(angdeg)
 53                xprime = (xnorm - x0) * np.cos(th) + (ynorm - y0) * np.sin(th)
 54                yprime = -(xnorm - x0) * np.sin(th) + (ynorm - y0) * np.cos(th)
 55                if xprime * xprime / (a * a) + yprime * yprime / (b * b) <= 1.0:
 56                    val += ampl
 57            phantom[iy, ix] = val
 58    return np.clip(phantom, 0.0, 1.0)
 59
 60
 61def main():
 62    # ------------------------------------------------------------------
 63    # 1. Image geometry
 64    # ------------------------------------------------------------------
 65    # ``Nx`` / ``Ny`` are the reconstruction grid size in pixels. The
 66    # phantom tensor has shape ``(Ny, Nx)`` (rows, cols) which matches
 67    # the ``(H, W)`` layout every 2D routine in diffct expects.
 68    Nx, Ny = 256, 256
 69    phantom = shepp_logan_2d(Nx, Ny)
 70
 71    # ``voxel_spacing`` is the physical pixel pitch in the same units as
 72    # ``detector_spacing``. Only ratios matter internally.
 73    voxel_spacing = 1.0
 74
 75    # ------------------------------------------------------------------
 76    # 2. Source trajectory (parallel beam on a circular orbit)
 77    # ------------------------------------------------------------------
 78    # For parallel beam the Radon inversion is periodic with period pi:
 79    # views at beta and beta+pi carry the same information up to a
 80    # flip of t. Sampling a full 2*pi range double-counts each ray and
 81    # we correct for it with ``redundant_full_scan=True`` in the
 82    # angular weights. You can equivalently use
 83    # ``np.linspace(0, np.pi, num_angles, endpoint=False)`` together
 84    # with ``redundant_full_scan=False``.
 85    num_angles = 360
 86    angles_np = np.linspace(
 87        0.0, 2.0 * np.pi, num_angles, endpoint=False
 88    ).astype(np.float32)
 89
 90    # ------------------------------------------------------------------
 91    # 3. Detector geometry
 92    # ------------------------------------------------------------------
 93    # ``num_detectors`` is the number of detector cells. ``detector_spacing``
 94    # is their physical pitch. Parallel beam needs enough detector cells
 95    # to cover the diagonal of the field of view (approximately
 96    # ``sqrt(Nx^2 + Ny^2) * voxel_spacing / detector_spacing``) so that
 97    # no ray through the FOV ever projects outside the detector.
 98    num_detectors = 512
 99    detector_spacing = 1.0
100    detector_offset = 0.0
101
102    # ------------------------------------------------------------------
103    # 4. Move everything to CUDA
104    # ------------------------------------------------------------------
105    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
106    image_torch = torch.tensor(
107        phantom, device=device, dtype=torch.float32, requires_grad=True
108    )
109    angles_torch = torch.tensor(angles_np, device=device, dtype=torch.float32)
110
111    # ------------------------------------------------------------------
112    # 5. Forward projection: image -> parallel sinogram
113    # ------------------------------------------------------------------
114    # ``ParallelProjectorFunction`` is the differentiable Siddon-based
115    # parallel-beam forward projector. It returns a
116    # (num_angles, num_detectors) sinogram. The call is autograd-aware
117    # so the same function can be used inside an iterative reconstruction
118    # loop (see ``iterative_reco_parallel.py``).
119    sinogram = ParallelProjectorFunction.apply(
120        image_torch,
121        angles_torch,
122        num_detectors,
123        detector_spacing,
124        voxel_spacing,
125    )
126
127    # ==================================================================
128    # 6. FBP analytical reconstruction
129    # ==================================================================
130
131    # --- 6.1  1D ramp filter along the detector axis -----------------
132    # Parallel beam does not need a cosine pre-weight (there is no fan
133    # angle). The only filtering step is the 1D ramp along the
134    # detector-u axis. See the fdk_cone.py example for the full list
135    # of ``ramp_filter_1d`` options.
136    sinogram_filt = ramp_filter_1d(
137        sinogram,
138        dim=1,
139        sample_spacing=detector_spacing,
140        pad_factor=2,
141        window="hann",
142    ).contiguous()
143
144    # --- 6.2  Per-view angular integration weights -------------------
145    # For a full 2*pi scan uniformly sampled, each view contributes
146    # ``pi / num_angles`` to the FBP integral (the ``1/2`` redundancy
147    # factor is absorbed by ``redundant_full_scan=True``).
148    d_beta = angular_integration_weights(
149        angles_torch, redundant_full_scan=True
150    ).view(-1, 1)
151    sinogram_filt = sinogram_filt * d_beta
152
153    # --- 6.3  Voxel-driven FBP backprojection ------------------------
154    # ``parallel_weighted_backproject`` dispatches to the dedicated
155    # parallel-beam FBP gather kernel. For each pixel and each view it
156    # computes the detector-u coordinate the pixel projects to,
157    # linearly samples the filtered sinogram and accumulates. The
158    # ``1/(2*pi)`` Fourier-convention constant is applied inside the
159    # wrapper so the returned image is already amplitude-calibrated.
160    reconstruction_raw = parallel_weighted_backproject(
161        sinogram_filt,
162        angles_torch,
163        detector_spacing,
164        Ny,
165        Nx,
166        voxel_spacing=voxel_spacing,
167        detector_offset=detector_offset,
168    )
169    reconstruction = F.relu(reconstruction_raw)
170
171    # ------------------------------------------------------------------
172    # 7. Quantitative summary + a gradient sanity check
173    # ------------------------------------------------------------------
174    raw_loss = torch.mean((reconstruction_raw - image_torch) ** 2)
175    clamped_loss = torch.mean((reconstruction - image_torch) ** 2)
176    # Trigger autograd to make sure the differentiable forward still
177    # flows a gradient back to ``image_torch`` after the pipeline.
178    clamped_loss.backward()
179
180    print("Parallel Beam FBP example (circular full scan):")
181    print(f"  Raw MSE:              {raw_loss.item():.6f}")
182    print(f"  Clamped MSE:          {clamped_loss.item():.6f}")
183    print(f"  Reconstruction shape: {tuple(reconstruction.shape)}")
184    print(
185        "  Raw reco data range:  "
186        f"[{reconstruction_raw.min().item():.4f}, {reconstruction_raw.max().item():.4f}]"
187    )
188    print(
189        "  Clamped reco range:   "
190        f"[{reconstruction.min().item():.4f}, {reconstruction.max().item():.4f}]"
191    )
192    print(
193        "  Phantom data range:   "
194        f"[{float(phantom.min()):.4f}, {float(phantom.max()):.4f}]"
195    )
196    print(
197        "  |phantom.grad|.mean:  "
198        f"{image_torch.grad.abs().mean().item():.6e}"
199    )
200
201    # ------------------------------------------------------------------
202    # 8. Visualization
203    # ------------------------------------------------------------------
204    sinogram_cpu = sinogram.detach().cpu().numpy()
205    reco_cpu = reconstruction.detach().cpu().numpy()
206
207    plt.figure(figsize=(12, 4))
208    plt.subplot(1, 3, 1)
209    plt.imshow(phantom, cmap="gray")
210    plt.title("Phantom")
211    plt.axis("off")
212    plt.subplot(1, 3, 2)
213    plt.imshow(sinogram_cpu, aspect="auto", cmap="gray")
214    plt.title("Parallel Sinogram")
215    plt.axis("off")
216    plt.subplot(1, 3, 3)
217    plt.imshow(reco_cpu, cmap="gray")
218    plt.title("Parallel Reconstruction")
219    plt.axis("off")
220    plt.tight_layout()
221    plt.show()
222
223
224if __name__ == "__main__":
225    main()