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 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()