Parallel Beam Filtered Backprojection (FBP)#
This page describes the examples/analytical_reconstruction.py pipeline. For
filter options and helper signatures, see API Reference.
Ground truth: Shepp-Logan, and an abdominal slice of TCIA case L213 (see Data sources). 512², 720 views over 180°, 1536 detector bins at half the pixel size, Shepp-Logan window. PSNR in the lower corner.#
Run it#
Run the commands from the root of a source checkout; pip install diffct does not
install the examples/ directory. See the “From source” tab in
Installation. --figure needs Matplotlib: pip install matplotlib.
python examples/analytical_reconstruction.py --size 64 --window shepp-logan
python examples/analytical_reconstruction.py --size 128 --figure analytical.png
The script runs parallel, fan and cone reconstruction on one CUDA GPU and prints
PSNR against the synthetic phantom. --figure requires Matplotlib. --window
accepts ram-lak, shepp-logan, cosine, hamming or hann.
Pipeline#
The example uses a 180-degree parallel-beam scan with 360 views. The detector has
3 * size cells and a pitch of 0.5 voxels.
Project a phantom slice with
Projector(beam="parallel").Filter along the detector dimension (
dim=1) withramp_filter_1d(..., sample_spacing=pitch, pad_factor=2).Multiply by
angular_integration_weights(half_turn, redundant_full_scan=False).Call
parallel_weighted_backprojecton the sinogram’s CUDA device.
The analytical helpers are not the Projector.backproject() adjoint. Do not
use the adjoint in this pipeline, and do not add extra scale factors. The helpers
apply their own normalization.
Source#
1"""Analytical reconstruction: parallel-beam FBP, fan-beam FBP and cone-beam FDK.
2
3Each pipeline is the textbook one: optional cosine weighting, ramp filter with
4a window, angular weights, voxel-driven weighted backprojection. The window
5trades sharpness against noise: "ram-lak" keeps all frequencies, "shepp-logan"
6and "cosine" damp the highest, "hamming" and "hann" damp the most.
7
8 python examples/analytical_reconstruction.py --window shepp-logan --figure fbp.png
9"""
10
11import argparse
12import math
13from pathlib import Path
14
15import torch
16
17from diffct import (
18 Projector,
19 angular_integration_weights,
20 circular_trajectory_2d_fan,
21 circular_trajectory_2d_parallel,
22 fan_cosine_weights,
23 fan_weighted_backproject,
24 fbp,
25 parallel_weighted_backproject,
26 ramp_filter_1d,
27)
28from _common import Scan, fdk, psnr, save_slices, shepp_logan_3d
29
30
31def main():
32 parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
33 parser.add_argument("--size", type=int, default=128)
34 parser.add_argument("--window", default="shepp-logan",
35 choices=["ram-lak", "shepp-logan", "cosine", "hamming", "hann"])
36 parser.add_argument("--figure", type=Path)
37 args = parser.parse_args()
38 if not torch.cuda.is_available():
39 raise RuntimeError("diffct needs a CUDA GPU")
40 n = args.size
41 phantom = torch.from_numpy(shepp_logan_3d(n)).cuda()
42 image = phantom[n // 2]
43 views = 360
44 full_turn = torch.linspace(0.0, 2 * math.pi, views + 1, device="cuda")[:-1]
45 half_turn = torch.linspace(0.0, math.pi, views + 1, device="cuda")[:-1]
46
47 # Parallel beam over 180 degrees; detector pitch 0.5 voxel.
48 n_det, pitch = 3 * n, 0.5
49 trajectory = circular_trajectory_2d_parallel(views, end_angle=math.pi, device="cpu")
50 sinogram = Projector(trajectory, (n, n), n_det, beam="parallel", detector_spacing=pitch).project(image)
51 filtered = ramp_filter_1d(sinogram, dim=1, sample_spacing=pitch, pad_factor=2, window=args.window)
52 filtered = filtered * angular_integration_weights(half_turn, redundant_full_scan=False).view(-1, 1)
53 parallel = parallel_weighted_backproject(filtered.contiguous(), *[t.cuda() for t in trajectory],
54 pitch, n, n).clamp_min(0)
55 parallel_api = fbp(sinogram, trajectory, (n, n), half_turn, detector_spacing=pitch,
56 window=args.window).clamp_min(0)
57 parallel_difference = (parallel - parallel_api).abs().max().item()
58
59 # Fan beam over a full turn; magnification 1.6, detector pitch 0.8 (0.5 voxel at the isocentre).
60 sid, sdd, n_det, pitch = 2.5 * n, 4.0 * n, 3 * n, 0.8
61 trajectory = circular_trajectory_2d_fan(views, sid, sdd, device="cpu")
62 sinogram = Projector(trajectory, (n, n), n_det, beam="fan", detector_spacing=pitch).project(image)
63 weighted = sinogram * fan_cosine_weights(n_det, pitch, sdd, device="cuda").unsqueeze(0)
64 filtered = ramp_filter_1d(weighted, dim=1, sample_spacing=pitch, pad_factor=2, window=args.window)
65 filtered = filtered * angular_integration_weights(full_turn, redundant_full_scan=True).view(-1, 1)
66 fan = fan_weighted_backproject(filtered.contiguous(), *[t.cuda() for t in trajectory],
67 pitch, n, n).clamp_min(0)
68 fan_api = fbp(sinogram, trajectory, (n, n), full_turn, beam="fan", detector_spacing=pitch,
69 window=args.window, isocenter=(0, 0)).clamp_min(0)
70 fan_difference = (fan - fan_api).abs().max().item()
71
72 # Cone beam FDK on a circular orbit.
73 scan = Scan(n, views)
74 trajectory = scan.trajectory("circular")
75 sinogram = Projector(trajectory, (n, n, n), scan.detector, detector_spacing=scan.pitch).project(phantom)
76 cone = fdk(scan, sinogram, trajectory, window=args.window)
77
78 print(f"window {args.window}: parallel FBP {psnr(parallel, image):.2f} dB, "
79 f"fan FBP {psnr(fan, image):.2f} dB, cone FDK {psnr(cone, phantom):.2f} dB")
80 print(f"manual/API maximum difference: parallel {parallel_difference:.3g}, fan {fan_difference:.3g}")
81 if args.figure:
82 save_slices(args.figure, [("phantom\nground truth", phantom),
83 (f"parallel FBP\n{psnr(parallel, image):.1f} dB", parallel),
84 (f"fan FBP\n{psnr(fan, image):.1f} dB", fan),
85 (f"cone FDK\n{psnr(cone, phantom):.1f} dB", cone)],
86 f"{n}² / {n}³ Shepp-Logan, {args.window} window")
87
88
89if __name__ == "__main__":
90 main()