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_backprojectto 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
The pixel \((x, y)\) projects to the detector at \(u_{xy} = -x \sin\theta + y \cos\theta\).
FBP algorithm
Ramp filtering along the detector axis:
\[p_f(\theta, u) = \mathcal{F}_u^{-1}\bigl\{\,|\omega_u|\, \mathcal{F}_u\{p(\theta, u)\}\bigr\}.\]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}\);diffctuses the full \([0, 2\pi)\) form withredundant_full_scan=Trueinangular_integration_weightsto absorb the factor of 1/2.parallel_weighted_backprojectdispatches to a dedicated voxel-driven gather kernel_parallel_2d_fbp_backproject_kernelfor 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
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()