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    if not torch.cuda.is_available():
106        raise RuntimeError("This example requires CUDA.")
107    device = torch.device("cuda")
108    image_torch = torch.tensor(
109        phantom, device=device, dtype=torch.float32, requires_grad=True
110    )
111    angles_torch = torch.tensor(angles_np, device=device, dtype=torch.float32)
112
113    # ------------------------------------------------------------------
114    # 5. Forward projection: image -> parallel sinogram
115    # ------------------------------------------------------------------
116    # ``ParallelProjectorFunction`` is the differentiable Siddon-based
117    # parallel-beam forward projector. It returns a
118    # (num_angles, num_detectors) sinogram. The call is autograd-aware
119    # so the same function can be used inside an iterative reconstruction
120    # loop (see ``iterative_reco_parallel.py``).
121    sinogram = ParallelProjectorFunction.apply(
122        image_torch,
123        angles_torch,
124        num_detectors,
125        detector_spacing,
126        voxel_spacing,
127    )
128
129    # ==================================================================
130    # 6. FBP analytical reconstruction
131    # ==================================================================
132
133    # --- 6.1  1D ramp filter along the detector axis -----------------
134    # Parallel beam does not need a cosine pre-weight (there is no fan
135    # angle). The only filtering step is the 1D ramp along the
136    # detector-u axis. See the fdk_cone.py example for the full list
137    # of ``ramp_filter_1d`` options.
138    sinogram_filt = ramp_filter_1d(
139        sinogram,
140        dim=1,
141        sample_spacing=detector_spacing,
142        pad_factor=2,
143        window="hann",
144    ).contiguous()
145
146    # --- 6.2  Per-view angular integration weights -------------------
147    # For a full 2*pi scan uniformly sampled, each view contributes
148    # ``pi / num_angles`` to the FBP integral (the ``1/2`` redundancy
149    # factor is absorbed by ``redundant_full_scan=True``).
150    d_beta = angular_integration_weights(
151        angles_torch, redundant_full_scan=True
152    ).view(-1, 1)
153    sinogram_filt = sinogram_filt * d_beta
154
155    # --- 6.3  Voxel-driven FBP backprojection ------------------------
156    # ``parallel_weighted_backproject`` dispatches to the dedicated
157    # parallel-beam FBP gather kernel. For each pixel and each view it
158    # computes the detector-u coordinate the pixel projects to,
159    # linearly samples the filtered sinogram and accumulates. The
160    # ``1/(2*pi)`` Fourier-convention constant is applied inside the
161    # wrapper so the returned image is already amplitude-calibrated.
162    reconstruction_raw = parallel_weighted_backproject(
163        sinogram_filt,
164        angles_torch,
165        detector_spacing,
166        Ny,
167        Nx,
168        voxel_spacing=voxel_spacing,
169        detector_offset=detector_offset,
170    )
171    reconstruction = F.relu(reconstruction_raw)
172
173    # ------------------------------------------------------------------
174    # 7. Quantitative summary + a gradient sanity check
175    # ------------------------------------------------------------------
176    raw_loss = torch.mean((reconstruction_raw - image_torch) ** 2)
177    clamped_loss = torch.mean((reconstruction - image_torch) ** 2)
178    # Trigger autograd to make sure the differentiable forward still
179    # flows a gradient back to ``image_torch`` after the pipeline.
180    clamped_loss.backward()
181
182    print("Parallel Beam FBP example (circular full scan):")
183    print(f"  Raw MSE:              {raw_loss.item():.6f}")
184    print(f"  Clamped MSE:          {clamped_loss.item():.6f}")
185    print(f"  Reconstruction shape: {tuple(reconstruction.shape)}")
186    print(
187        "  Raw reco data range:  "
188        f"[{reconstruction_raw.min().item():.4f}, {reconstruction_raw.max().item():.4f}]"
189    )
190    print(
191        "  Clamped reco range:   "
192        f"[{reconstruction.min().item():.4f}, {reconstruction.max().item():.4f}]"
193    )
194    print(
195        "  Phantom data range:   "
196        f"[{float(phantom.min()):.4f}, {float(phantom.max()):.4f}]"
197    )
198    print(
199        "  |phantom.grad|.mean:  "
200        f"{image_torch.grad.abs().mean().item():.6e}"
201    )
202
203    # ------------------------------------------------------------------
204    # 8. Visualization
205    # ------------------------------------------------------------------
206    sinogram_cpu = sinogram.detach().cpu().numpy()
207    reco_cpu = reconstruction.detach().cpu().numpy()
208
209    plt.figure(figsize=(12, 4))
210    plt.subplot(1, 3, 1)
211    plt.imshow(phantom, cmap="gray")
212    plt.title("Phantom")
213    plt.axis("off")
214    plt.subplot(1, 3, 2)
215    plt.imshow(sinogram_cpu, aspect="auto", cmap="gray")
216    plt.title("Parallel Sinogram")
217    plt.axis("off")
218    plt.subplot(1, 3, 3)
219    plt.imshow(reco_cpu, cmap="gray")
220    plt.title("Parallel Reconstruction")
221    plt.axis("off")
222    plt.tight_layout()
223    plt.show()
224
225
226if __name__ == "__main__":
227    main()