diffct 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diffct/__init__.py +83 -0
- diffct/analytical.py +569 -0
- diffct/constants.py +72 -0
- diffct/differentiable.py +74 -0
- diffct/geometry.py +991 -0
- diffct/kernels/__init__.py +43 -0
- diffct/kernels/cone_beam.py +617 -0
- diffct/kernels/fan_beam.py +451 -0
- diffct/kernels/geometry_vjp.py +562 -0
- diffct/kernels/parallel_beam.py +400 -0
- diffct/operators.py +546 -0
- diffct/projectors.py +910 -0
- diffct/utils.py +357 -0
- diffct-2.0.0.dist-info/METADATA +240 -0
- diffct-2.0.0.dist-info/RECORD +18 -0
- diffct-2.0.0.dist-info/WHEEL +4 -0
- diffct-2.0.0.dist-info/licenses/LICENSE +201 -0
- diffct-2.0.0.dist-info/licenses/NOTICE +18 -0
diffct/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# diffct/__init__.py
|
|
2
|
+
"""diffct - Differentiable CT Reconstruction Package.
|
|
3
|
+
|
|
4
|
+
A GPU-accelerated, differentiable computed tomography (CT) forward and backward
|
|
5
|
+
projection library built with PyTorch and Numba CUDA.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .projectors import (
|
|
9
|
+
ParallelProjectorFunction,
|
|
10
|
+
ParallelBackprojectorFunction,
|
|
11
|
+
FanProjectorFunction,
|
|
12
|
+
FanBackprojectorFunction,
|
|
13
|
+
ConeProjectorFunction,
|
|
14
|
+
ConeBackprojectorFunction,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from .operators import Projector
|
|
18
|
+
|
|
19
|
+
from .geometry import (
|
|
20
|
+
circular_trajectory_3d,
|
|
21
|
+
random_trajectory_3d,
|
|
22
|
+
spiral_trajectory_3d,
|
|
23
|
+
sinusoidal_trajectory_3d,
|
|
24
|
+
saddle_trajectory_3d,
|
|
25
|
+
custom_trajectory_3d,
|
|
26
|
+
circular_trajectory_2d_fan,
|
|
27
|
+
sinusoidal_trajectory_2d_fan,
|
|
28
|
+
custom_trajectory_2d_fan,
|
|
29
|
+
circular_trajectory_2d_parallel,
|
|
30
|
+
sinusoidal_trajectory_2d_parallel,
|
|
31
|
+
custom_trajectory_2d_parallel,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
from .analytical import (
|
|
35
|
+
detector_coordinates_1d,
|
|
36
|
+
angular_integration_weights,
|
|
37
|
+
fan_cosine_weights,
|
|
38
|
+
cone_cosine_weights,
|
|
39
|
+
parker_weights,
|
|
40
|
+
ramp_filter_1d,
|
|
41
|
+
parallel_weighted_backproject,
|
|
42
|
+
fan_weighted_backproject,
|
|
43
|
+
cone_weighted_backproject,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# For backwards compatibility, also import from differentiable module if it still exists
|
|
47
|
+
try:
|
|
48
|
+
from . import differentiable
|
|
49
|
+
except ImportError:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
__version__ = '2.0.0'
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
'Projector',
|
|
56
|
+
'ParallelProjectorFunction',
|
|
57
|
+
'ParallelBackprojectorFunction',
|
|
58
|
+
'FanProjectorFunction',
|
|
59
|
+
'FanBackprojectorFunction',
|
|
60
|
+
'ConeProjectorFunction',
|
|
61
|
+
'ConeBackprojectorFunction',
|
|
62
|
+
'circular_trajectory_3d',
|
|
63
|
+
'random_trajectory_3d',
|
|
64
|
+
'spiral_trajectory_3d',
|
|
65
|
+
'sinusoidal_trajectory_3d',
|
|
66
|
+
'saddle_trajectory_3d',
|
|
67
|
+
'custom_trajectory_3d',
|
|
68
|
+
'circular_trajectory_2d_fan',
|
|
69
|
+
'sinusoidal_trajectory_2d_fan',
|
|
70
|
+
'custom_trajectory_2d_fan',
|
|
71
|
+
'circular_trajectory_2d_parallel',
|
|
72
|
+
'sinusoidal_trajectory_2d_parallel',
|
|
73
|
+
'custom_trajectory_2d_parallel',
|
|
74
|
+
'detector_coordinates_1d',
|
|
75
|
+
'angular_integration_weights',
|
|
76
|
+
'fan_cosine_weights',
|
|
77
|
+
'cone_cosine_weights',
|
|
78
|
+
'parker_weights',
|
|
79
|
+
'ramp_filter_1d',
|
|
80
|
+
'parallel_weighted_backproject',
|
|
81
|
+
'fan_weighted_backproject',
|
|
82
|
+
'cone_weighted_backproject',
|
|
83
|
+
]
|
diffct/analytical.py
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
"""Analytical reconstruction helpers (ramp filter, cosine weights, Parker,
|
|
2
|
+
angular weights, voxel-driven FBP/FDK backprojection wrappers).
|
|
3
|
+
|
|
4
|
+
Ported from the main branch's ``diffct.differentiable`` module and rewritten
|
|
5
|
+
for the dev branch's arbitrary-trajectory kernel API. Every helper here is
|
|
6
|
+
trajectory-agnostic or takes the same ``(src_pos, det_center, det_u_vec,
|
|
7
|
+
det_v_vec)`` arrays as the ``ParallelProjectorFunction`` /
|
|
8
|
+
``FanProjectorFunction`` / ``ConeProjectorFunction`` classes, so they
|
|
9
|
+
compose cleanly with both circular and non-circular trajectories.
|
|
10
|
+
|
|
11
|
+
Fixes the long-standing FDK / FBP amplitude bugs on the dev branch:
|
|
12
|
+
* Analytical backprojection now goes through dedicated voxel-driven
|
|
13
|
+
gather kernels with the classical ``(sid/U)^2`` weight instead of
|
|
14
|
+
the Siddon adjoint.
|
|
15
|
+
* The analytical Fourier-convention constant ``1/(2*pi)`` is applied
|
|
16
|
+
automatically inside the ``*_weighted_backproject`` wrappers, so
|
|
17
|
+
a unit-density disk reconstructs to amplitude 1 without any
|
|
18
|
+
extra manual scaling in user code.
|
|
19
|
+
* ``ramp_filter_1d`` is scaled by ``1 / sample_spacing`` so the
|
|
20
|
+
reconstruction amplitude is stable across different detector
|
|
21
|
+
pitches.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import math
|
|
27
|
+
|
|
28
|
+
import torch
|
|
29
|
+
|
|
30
|
+
from .constants import _DTYPE
|
|
31
|
+
from .utils import (
|
|
32
|
+
DeviceManager,
|
|
33
|
+
TorchCUDABridge,
|
|
34
|
+
_get_numba_external_stream_for,
|
|
35
|
+
_grid_2d,
|
|
36
|
+
_grid_3d,
|
|
37
|
+
)
|
|
38
|
+
from .kernels import (
|
|
39
|
+
_parallel_2d_fbp_backproject_kernel,
|
|
40
|
+
_fan_2d_fbp_backproject_kernel,
|
|
41
|
+
_cone_3d_fdk_backproject_kernel,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ============================================================================
|
|
46
|
+
# Detector coordinate helper
|
|
47
|
+
# ============================================================================
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def detector_coordinates_1d(num_detectors, detector_spacing, detector_offset=0.0,
|
|
51
|
+
device=None, dtype=torch.float32):
|
|
52
|
+
"""Return detector cell centre coordinates along a 1D axis.
|
|
53
|
+
|
|
54
|
+
The convention matches the CUDA kernels: the detector array is centred,
|
|
55
|
+
``u[k] = (k - (num_detectors - 1) * 0.5) * detector_spacing + detector_offset``.
|
|
56
|
+
"""
|
|
57
|
+
k = torch.arange(num_detectors, device=device, dtype=dtype)
|
|
58
|
+
return (k - 0.5 * (num_detectors - 1)) * detector_spacing + detector_offset
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ============================================================================
|
|
62
|
+
# Angular integration weights
|
|
63
|
+
# ============================================================================
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _angular_scan_period(span, median_step):
|
|
67
|
+
"""Shared scan classification for angular quadrature and Parker weights."""
|
|
68
|
+
step = float(abs(median_step).item())
|
|
69
|
+
tol = max(1e-4, 0.05 * step)
|
|
70
|
+
# Endpoint-included, endpoint-excluded and downsampled circular scans
|
|
71
|
+
# can have a closure gap smaller than a normal retained-view interval.
|
|
72
|
+
for candidate in (math.pi, 2.0 * math.pi):
|
|
73
|
+
closure = float((span.new_tensor(candidate) - span).item())
|
|
74
|
+
if -tol <= closure <= 1.5 * step + tol:
|
|
75
|
+
return candidate
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def angular_integration_weights(angles, redundant_full_scan=True):
|
|
80
|
+
"""Trapezoidal per-view integration weights for the analytical FBP/FDK sum.
|
|
81
|
+
|
|
82
|
+
For a full ``2*pi`` scan, each redundant ray is sampled twice so the
|
|
83
|
+
classical FBP/FDK formula carries a ``1/2`` redundancy factor. When
|
|
84
|
+
``redundant_full_scan=True`` (the default) this factor is baked into
|
|
85
|
+
the returned weights: they sum to ``pi`` over a uniform full scan,
|
|
86
|
+
which is exactly what a voxel-driven gather backprojector with the
|
|
87
|
+
``(sid/U)^2`` weight needs to produce a unit-amplitude reconstruction.
|
|
88
|
+
|
|
89
|
+
Short scans retain their actual angular range; the Parker window
|
|
90
|
+
handles their redundancy at the sinogram-filtering stage. Leave
|
|
91
|
+
``redundant_full_scan=True`` for automatic fan/cone scan classification.
|
|
92
|
+
Setting it to ``False`` explicitly disables the full-turn ``1/2`` factor.
|
|
93
|
+
Parker and angular quadrature share the same periodic-scan decision.
|
|
94
|
+
"""
|
|
95
|
+
angles = angles.to(dtype=torch.float32).contiguous()
|
|
96
|
+
n = angles.shape[0]
|
|
97
|
+
if n == 0:
|
|
98
|
+
return torch.zeros_like(angles)
|
|
99
|
+
if n == 1:
|
|
100
|
+
return torch.ones_like(angles) * (math.pi if redundant_full_scan else 1.0)
|
|
101
|
+
|
|
102
|
+
device = angles.device
|
|
103
|
+
angles_sorted, sort_idx = torch.sort(angles)
|
|
104
|
+
diffs = angles_sorted[1:] - angles_sorted[:-1]
|
|
105
|
+
|
|
106
|
+
# Endpoint-excluded uniform circular scans are periodic quadrature
|
|
107
|
+
# problems, not open-interval trapezoids. Treat both parallel-beam
|
|
108
|
+
# half scans over pi and fan/cone full scans over 2*pi as periodic
|
|
109
|
+
# when the missing closure gap matches the interior angular step.
|
|
110
|
+
median_step = torch.median(diffs)
|
|
111
|
+
span = angles_sorted[-1] - angles_sorted[0]
|
|
112
|
+
period = _angular_scan_period(span, median_step)
|
|
113
|
+
|
|
114
|
+
w = torch.zeros(n, device=device, dtype=angles.dtype)
|
|
115
|
+
if period is not None:
|
|
116
|
+
closure = torch.clamp(angles.new_tensor(period) - span, min=0.0)
|
|
117
|
+
w[0] = 0.5 * (closure + diffs[0])
|
|
118
|
+
w[-1] = 0.5 * (diffs[-1] + closure)
|
|
119
|
+
if n > 2:
|
|
120
|
+
w[1:-1] = 0.5 * (diffs[:-1] + diffs[1:])
|
|
121
|
+
else:
|
|
122
|
+
# Open-interval trapezoidal rule: each interior sample gets
|
|
123
|
+
# (prev + next) / 2, boundaries get half the adjacent step.
|
|
124
|
+
w[0] = 0.5 * diffs[0]
|
|
125
|
+
w[-1] = 0.5 * diffs[-1]
|
|
126
|
+
if n > 2:
|
|
127
|
+
w[1:-1] = 0.5 * (diffs[:-1] + diffs[1:])
|
|
128
|
+
|
|
129
|
+
if redundant_full_scan and period == 2.0 * math.pi:
|
|
130
|
+
w = w * 0.5 # absorb the 1/2 FBP/FDK redundancy factor
|
|
131
|
+
|
|
132
|
+
# Return weights in the input order so the caller can just multiply
|
|
133
|
+
# ``sino * d_beta`` without worrying about angle ordering.
|
|
134
|
+
out = torch.empty_like(w)
|
|
135
|
+
out[sort_idx] = w
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ============================================================================
|
|
140
|
+
# Cosine pre-weights
|
|
141
|
+
# ============================================================================
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def fan_cosine_weights(num_detectors, detector_spacing, sdd, detector_offset=0.0,
|
|
145
|
+
device=None, dtype=torch.float32):
|
|
146
|
+
"""Per-detector-cell ``cos(gamma)`` weight for fan-beam FBP.
|
|
147
|
+
|
|
148
|
+
``cos(gamma) = sdd / sqrt(sdd^2 + u^2)`` where ``u`` is the in-plane
|
|
149
|
+
detector coordinate. Multiplying the sinogram by this weight before
|
|
150
|
+
the ramp filter compensates for the extra path length that off-
|
|
151
|
+
principal-ray fans traverse.
|
|
152
|
+
"""
|
|
153
|
+
u = detector_coordinates_1d(
|
|
154
|
+
num_detectors, detector_spacing, detector_offset, device=device, dtype=dtype
|
|
155
|
+
)
|
|
156
|
+
return sdd / torch.sqrt(sdd * sdd + u * u)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cone_cosine_weights(det_u, det_v, du, dv, sdd,
|
|
160
|
+
detector_offset_u=0.0, detector_offset_v=0.0,
|
|
161
|
+
device=None, dtype=torch.float32):
|
|
162
|
+
"""Per-detector-cell ``cos(gamma_cone)`` weight for cone-beam FDK.
|
|
163
|
+
|
|
164
|
+
``cos(gamma_cone) = sdd / sqrt(sdd^2 + u^2 + v^2)``. Returns a
|
|
165
|
+
``(det_u, det_v)`` tensor ready to broadcast across the view axis
|
|
166
|
+
of a cone sinogram ``(n_views, det_u, det_v)``.
|
|
167
|
+
"""
|
|
168
|
+
u = detector_coordinates_1d(det_u, du, detector_offset_u, device=device, dtype=dtype)
|
|
169
|
+
v = detector_coordinates_1d(det_v, dv, detector_offset_v, device=device, dtype=dtype)
|
|
170
|
+
uu = u.view(-1, 1)
|
|
171
|
+
vv = v.view(1, -1)
|
|
172
|
+
return sdd / torch.sqrt(sdd * sdd + uu * uu + vv * vv)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ============================================================================
|
|
176
|
+
# Parker short-scan weights (circular fan / cone only)
|
|
177
|
+
# ============================================================================
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def parker_weights(angles, num_detectors, detector_spacing, sdd, detector_offset=0.0):
|
|
181
|
+
"""Parker redundancy weights for a circular fan-beam short scan.
|
|
182
|
+
|
|
183
|
+
The formula follows Parker (1982): each ray at view ``beta`` and
|
|
184
|
+
fan angle ``gamma`` is weighted by a smooth window that guarantees
|
|
185
|
+
every ray contributes exactly once when integrated over the scan.
|
|
186
|
+
For a full ``2*pi`` scan this returns a constant ``1`` tensor (the
|
|
187
|
+
usual redundancy ``1/2`` factor lives in
|
|
188
|
+
``angular_integration_weights`` instead).
|
|
189
|
+
|
|
190
|
+
Only meaningful for circular (fan-shaped) trajectories — when using
|
|
191
|
+
non-circular trajectories you typically want either a constant weight
|
|
192
|
+
(full-coverage) or a custom redundancy scheme.
|
|
193
|
+
"""
|
|
194
|
+
angles = angles.to(dtype=torch.float32).contiguous()
|
|
195
|
+
n_ang = angles.shape[0]
|
|
196
|
+
device = angles.device
|
|
197
|
+
|
|
198
|
+
u = detector_coordinates_1d(
|
|
199
|
+
num_detectors, detector_spacing, detector_offset,
|
|
200
|
+
device=device, dtype=angles.dtype,
|
|
201
|
+
)
|
|
202
|
+
gamma = torch.atan(u / sdd) # (n_det,)
|
|
203
|
+
gamma_max = float(gamma.abs().max().item())
|
|
204
|
+
|
|
205
|
+
angles_sorted = torch.sort(angles).values
|
|
206
|
+
span = angles_sorted[-1] - angles_sorted[0]
|
|
207
|
+
median_step = torch.median(angles_sorted[1:] - angles_sorted[:-1])
|
|
208
|
+
# Full-scan detection depends on acquired angles, not detector fan width.
|
|
209
|
+
# Use exactly the quadrature's classification, including its tolerance.
|
|
210
|
+
if _angular_scan_period(span, median_step) == 2.0 * math.pi:
|
|
211
|
+
# Already a full scan — no Parker needed.
|
|
212
|
+
return torch.ones((n_ang, num_detectors), device=device, dtype=angles.dtype)
|
|
213
|
+
|
|
214
|
+
# Generalized Parker taper for pi + 2*gamma_max <= coverage < 2*pi.
|
|
215
|
+
# The minimum also accommodates an endpoint-excluded minimal short scan.
|
|
216
|
+
scan_range = float(span.item())
|
|
217
|
+
delta = max(gamma_max, 0.5 * (scan_range - math.pi))
|
|
218
|
+
beta = angles - angles.min()
|
|
219
|
+
beta = beta.view(-1, 1)
|
|
220
|
+
gamma_b = gamma.view(1, -1)
|
|
221
|
+
|
|
222
|
+
w = torch.ones_like(beta.expand(n_ang, num_detectors))
|
|
223
|
+
|
|
224
|
+
# Region 1: 0 <= beta < 2*(delta - gamma)
|
|
225
|
+
r1 = beta < 2.0 * (delta - gamma_b)
|
|
226
|
+
arg1 = (math.pi * 0.25) * beta / (delta - gamma_b + 1e-12)
|
|
227
|
+
w = torch.where(r1, torch.sin(arg1) ** 2, w)
|
|
228
|
+
|
|
229
|
+
# Region 3: pi - 2*gamma <= beta <= pi + 2*delta
|
|
230
|
+
r3 = beta > math.pi - 2.0 * gamma_b
|
|
231
|
+
arg3 = (math.pi * 0.25) * (math.pi + 2.0 * delta - beta) / (delta + gamma_b + 1e-12)
|
|
232
|
+
w = torch.where(r3, torch.sin(arg3) ** 2, w)
|
|
233
|
+
|
|
234
|
+
# Outside scan range: zero (shouldn't happen for well-formed inputs).
|
|
235
|
+
w = torch.where(beta < 0, torch.zeros_like(w), w)
|
|
236
|
+
w = torch.where(beta > math.pi + 2.0 * delta, torch.zeros_like(w), w)
|
|
237
|
+
return w
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ============================================================================
|
|
241
|
+
# Ramp filter
|
|
242
|
+
# ============================================================================
|
|
243
|
+
|
|
244
|
+
_RAMP_WINDOWS = ("ram-lak", "hann", "hanning", "hamming", "cosine", "shepp-logan")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _ramp_window(name, freqs):
|
|
248
|
+
"""Frequency-domain apodization window for the ramp filter.
|
|
249
|
+
|
|
250
|
+
``freqs`` are in cycles per sample (matching ``torch.fft.fftfreq``).
|
|
251
|
+
The normalized coordinate ``nf = 2 * |freqs|`` maps DC to 0 and the
|
|
252
|
+
Nyquist bin to 1.
|
|
253
|
+
"""
|
|
254
|
+
if name is None:
|
|
255
|
+
return torch.ones_like(freqs)
|
|
256
|
+
nf = torch.abs(freqs) * 2.0
|
|
257
|
+
nf = torch.clamp(nf, max=1.0)
|
|
258
|
+
key = name.lower().replace("_", "-")
|
|
259
|
+
if key in ("hann", "hanning"):
|
|
260
|
+
return 0.5 * (1.0 + torch.cos(torch.pi * nf))
|
|
261
|
+
if key == "hamming":
|
|
262
|
+
return 0.54 + 0.46 * torch.cos(torch.pi * nf)
|
|
263
|
+
if key == "cosine":
|
|
264
|
+
return torch.cos(torch.pi * nf * 0.5)
|
|
265
|
+
if key == "shepp-logan":
|
|
266
|
+
arg = torch.pi * nf * 0.5
|
|
267
|
+
return torch.where(
|
|
268
|
+
nf > 1e-8, torch.sin(arg) / torch.clamp(arg, min=1e-8), torch.ones_like(nf)
|
|
269
|
+
)
|
|
270
|
+
if key in ("ram-lak", "ramlak", "none"):
|
|
271
|
+
return torch.ones_like(freqs)
|
|
272
|
+
raise ValueError(
|
|
273
|
+
f"Unknown ramp window {name!r}. Expected one of {_RAMP_WINDOWS} or None."
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def ramp_filter_1d(sinogram_tensor, dim=-1, sample_spacing=1.0, pad_factor=1,
|
|
278
|
+
window=None, use_rfft=True):
|
|
279
|
+
"""Apply a finite discrete Ram-Lak filter along ``dim`` using FFT.
|
|
280
|
+
|
|
281
|
+
The angular-frequency impulse response is ``h[0] = pi/2``,
|
|
282
|
+
``h[k] = -2/(pi*k**2)`` for odd ``k``, and zero for nonzero even
|
|
283
|
+
``k`` (Kak and Slaney, chapter 3, equation 61, scaled by ``2*pi``).
|
|
284
|
+
Transforming this finite kernel preserves its small positive DC
|
|
285
|
+
response and avoids the low-frequency bias of directly sampling
|
|
286
|
+
``abs(omega)``. ``pad_factor >= 2`` gives linear convolution over the
|
|
287
|
+
retained samples for an unwindowed filter.
|
|
288
|
+
|
|
289
|
+
Parameters
|
|
290
|
+
----------
|
|
291
|
+
sinogram_tensor : torch.Tensor
|
|
292
|
+
Real-valued sinogram tensor.
|
|
293
|
+
dim : int, optional
|
|
294
|
+
Axis along which to filter. Default ``-1``.
|
|
295
|
+
sample_spacing : float, optional
|
|
296
|
+
Physical spacing between detector samples along ``dim``. The continuous
|
|
297
|
+
ramp filter has units of ``1/length``, so the FFT output is rescaled by
|
|
298
|
+
``1 / sample_spacing`` to respect physical units.
|
|
299
|
+
pad_factor : int, optional
|
|
300
|
+
Zero-pad the signal to ``pad_factor * N`` along ``dim`` before the FFT
|
|
301
|
+
to suppress circular-convolution wrap-around artifacts.
|
|
302
|
+
window : str or None, optional
|
|
303
|
+
Frequency-domain apodization: ``None`` / ``"ram-lak"`` (unwindowed),
|
|
304
|
+
``"hann"``, ``"hamming"``, ``"cosine"``, or ``"shepp-logan"``.
|
|
305
|
+
use_rfft : bool, optional
|
|
306
|
+
Use the real-valued FFT path when ``True`` (default).
|
|
307
|
+
"""
|
|
308
|
+
dim_pos = dim if dim >= 0 else sinogram_tensor.ndim + dim
|
|
309
|
+
n = sinogram_tensor.shape[dim_pos]
|
|
310
|
+
|
|
311
|
+
if pad_factor is None or pad_factor < 1:
|
|
312
|
+
pad_factor = 1
|
|
313
|
+
pad_factor = int(pad_factor)
|
|
314
|
+
n_pad = n * pad_factor
|
|
315
|
+
if n_pad > n:
|
|
316
|
+
pad_total = n_pad - n
|
|
317
|
+
pad_left = pad_total // 2
|
|
318
|
+
pad_right = pad_total - pad_left
|
|
319
|
+
pad_spec = [0, 0] * sinogram_tensor.ndim
|
|
320
|
+
rev_dim = sinogram_tensor.ndim - 1 - dim_pos
|
|
321
|
+
pad_spec[2 * rev_dim] = pad_left
|
|
322
|
+
pad_spec[2 * rev_dim + 1] = pad_right
|
|
323
|
+
x = torch.nn.functional.pad(sinogram_tensor, pad_spec, mode="constant", value=0.0)
|
|
324
|
+
else:
|
|
325
|
+
pad_left = 0
|
|
326
|
+
x = sinogram_tensor
|
|
327
|
+
|
|
328
|
+
scale = 1.0 / float(sample_spacing)
|
|
329
|
+
# Wrapped signed lags give a symmetric kernel for even and odd FFT sizes.
|
|
330
|
+
lags = torch.arange(n_pad, device=x.device)
|
|
331
|
+
lags = torch.minimum(lags, n_pad - lags)
|
|
332
|
+
impulse = torch.zeros(n_pad, device=x.device, dtype=x.real.dtype)
|
|
333
|
+
impulse[0] = math.pi / 2.0
|
|
334
|
+
odd = lags % 2 == 1
|
|
335
|
+
impulse[odd] = -2.0 / (math.pi * lags[odd].to(impulse.dtype).square())
|
|
336
|
+
|
|
337
|
+
if use_rfft and torch.is_floating_point(x):
|
|
338
|
+
freqs = torch.fft.rfftfreq(n_pad, device=x.device, dtype=x.dtype)
|
|
339
|
+
ramp = torch.fft.rfft(impulse).real * _ramp_window(window, freqs) * scale
|
|
340
|
+
shape = [1] * x.ndim
|
|
341
|
+
shape[dim_pos] = ramp.shape[0]
|
|
342
|
+
ramp = ramp.reshape(shape)
|
|
343
|
+
x_fft = torch.fft.rfft(x, dim=dim_pos)
|
|
344
|
+
x_filtered = torch.fft.irfft(x_fft * ramp, n=n_pad, dim=dim_pos)
|
|
345
|
+
else:
|
|
346
|
+
freqs = torch.fft.fftfreq(n_pad, device=x.device, dtype=x.real.dtype)
|
|
347
|
+
ramp = torch.fft.fft(impulse).real * _ramp_window(window, freqs) * scale
|
|
348
|
+
shape = [1] * x.ndim
|
|
349
|
+
shape[dim_pos] = ramp.shape[0]
|
|
350
|
+
ramp = ramp.reshape(shape)
|
|
351
|
+
x_fft = torch.fft.fft(x, dim=dim_pos)
|
|
352
|
+
x_filtered = torch.real(torch.fft.ifft(x_fft * ramp, dim=dim_pos))
|
|
353
|
+
|
|
354
|
+
if n_pad > n:
|
|
355
|
+
x_filtered = x_filtered.narrow(dim_pos, pad_left, n).contiguous()
|
|
356
|
+
return x_filtered
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ============================================================================
|
|
360
|
+
# Analytical FBP/FDK backprojection wrappers (voxel-driven gather)
|
|
361
|
+
# ============================================================================
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _as_contig_f32(t, device):
|
|
365
|
+
return DeviceManager.ensure_device(t, device).to(dtype=torch.float32).contiguous()
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def parallel_weighted_backproject(sinogram, ray_dir, det_origin, det_u_vec,
|
|
369
|
+
detector_spacing, H, W, voxel_spacing=1.0):
|
|
370
|
+
"""Voxel-driven parallel-beam FBP backprojection with analytical constant.
|
|
371
|
+
|
|
372
|
+
Runs the dedicated parallel-beam FBP gather kernel (one thread per
|
|
373
|
+
output pixel, linear interpolation of the filtered sinogram, no
|
|
374
|
+
distance weighting because parallel beam has no source) and
|
|
375
|
+
multiplies by ``1 / (2 * pi)`` so a unit-density disk reconstructs
|
|
376
|
+
to amplitude 1.
|
|
377
|
+
|
|
378
|
+
Assumes the input sinogram has already been ramp-filtered with
|
|
379
|
+
``ramp_filter_1d`` and weighted by ``angular_integration_weights``.
|
|
380
|
+
"""
|
|
381
|
+
if sinogram.shape[1] < 2:
|
|
382
|
+
raise ValueError("Analytical backprojection requires at least two detector bins")
|
|
383
|
+
device = DeviceManager.get_device(sinogram)
|
|
384
|
+
sinogram = _as_contig_f32(sinogram, device)
|
|
385
|
+
ray_dir = _as_contig_f32(ray_dir, device)
|
|
386
|
+
det_origin = _as_contig_f32(det_origin, device)
|
|
387
|
+
det_u_vec = _as_contig_f32(det_u_vec, device)
|
|
388
|
+
|
|
389
|
+
n_views, n_det = sinogram.shape
|
|
390
|
+
Ny, Nx = H, W
|
|
391
|
+
reco = torch.zeros((Ny, Nx), dtype=torch.float32, device=device)
|
|
392
|
+
|
|
393
|
+
d_sino = TorchCUDABridge.tensor_to_cuda_array(sinogram)
|
|
394
|
+
d_reco = TorchCUDABridge.tensor_to_cuda_array(reco)
|
|
395
|
+
d_ray_dir = TorchCUDABridge.tensor_to_cuda_array(ray_dir)
|
|
396
|
+
d_det_origin = TorchCUDABridge.tensor_to_cuda_array(det_origin)
|
|
397
|
+
d_det_u_vec = TorchCUDABridge.tensor_to_cuda_array(det_u_vec)
|
|
398
|
+
|
|
399
|
+
cx, cy = _DTYPE(Nx * 0.5), _DTYPE(Ny * 0.5)
|
|
400
|
+
grid, tpb = _grid_2d(Nx, Ny)
|
|
401
|
+
|
|
402
|
+
pt_stream = torch.cuda.current_stream()
|
|
403
|
+
numba_stream = _get_numba_external_stream_for(pt_stream)
|
|
404
|
+
|
|
405
|
+
_parallel_2d_fbp_backproject_kernel[grid, tpb, numba_stream](
|
|
406
|
+
d_sino, n_views, n_det, d_reco, Nx, Ny,
|
|
407
|
+
_DTYPE(detector_spacing), d_ray_dir, d_det_origin, d_det_u_vec,
|
|
408
|
+
cx, cy, _DTYPE(voxel_spacing),
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
return reco * (1.0 / (2.0 * math.pi))
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _analytical_geometry(src_pos, det_center, n, isocenter):
|
|
415
|
+
"""Resolve an orbit's physical isocenter and detector-normal distances."""
|
|
416
|
+
n = n / torch.linalg.vector_norm(n, dim=1, keepdim=True).clamp(min=1e-9)
|
|
417
|
+
cs = det_center - src_pos
|
|
418
|
+
align = (cs * n).sum(dim=1, keepdim=True)
|
|
419
|
+
n = torch.where(align < 0, -n, n)
|
|
420
|
+
|
|
421
|
+
if isocenter is None:
|
|
422
|
+
# Intersect source lines along detector normals in least squares.
|
|
423
|
+
# Centering first makes the fit translation invariant even for a short
|
|
424
|
+
# arc. CPU float64 supports rank-revealing solves on every CUDA device.
|
|
425
|
+
source = src_pos.detach().to(device="cpu", dtype=torch.float64)
|
|
426
|
+
normal = n.detach().to(device="cpu", dtype=torch.float64)
|
|
427
|
+
normal = normal / torch.linalg.vector_norm(normal, dim=1, keepdim=True)
|
|
428
|
+
reference = source.mean(dim=0)
|
|
429
|
+
perpendicular = torch.eye(source.shape[1], dtype=source.dtype) - (
|
|
430
|
+
normal[:, :, None] * normal[:, None, :]
|
|
431
|
+
)
|
|
432
|
+
rhs = (perpendicular @ (source - reference)[:, :, None]).sum(dim=0)
|
|
433
|
+
# Rank tolerance must reflect the input geometry's precision, not the
|
|
434
|
+
# solve dtype; float32 roundoff must not create a spurious intersection.
|
|
435
|
+
fit = torch.linalg.lstsq(perpendicular.sum(dim=0), rhs,
|
|
436
|
+
rcond=torch.finfo(src_pos.dtype).eps * source.shape[1])
|
|
437
|
+
if int(fit.rank) < source.shape[1]:
|
|
438
|
+
raise ValueError("Cannot infer isocenter from these views; supply isocenter explicitly")
|
|
439
|
+
isocenter = reference + fit.solution[:, 0]
|
|
440
|
+
isocenter = torch.as_tensor(isocenter, device=src_pos.device, dtype=src_pos.dtype)
|
|
441
|
+
if isocenter.shape != (src_pos.shape[1],) or not torch.isfinite(isocenter).all():
|
|
442
|
+
raise ValueError("isocenter must be a finite coordinate vector matching the trajectory")
|
|
443
|
+
sid_n = ((isocenter - src_pos) * n).sum(dim=1)
|
|
444
|
+
sdd_n = (cs * n).sum(dim=1)
|
|
445
|
+
if not (torch.isfinite(sid_n).all() and torch.isfinite(sdd_n).all()
|
|
446
|
+
and (sid_n > 1e-6).all() and (sdd_n > 1e-6).all()):
|
|
447
|
+
raise ValueError("Source-to-isocenter and source-to-detector distances must be positive")
|
|
448
|
+
return isocenter, float(sid_n.mean().item()), float(sdd_n.mean().item())
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def fan_weighted_backproject(sinogram, src_pos, det_center, det_u_vec,
|
|
452
|
+
detector_spacing, H, W, voxel_spacing=1.0, *, isocenter=None):
|
|
453
|
+
"""Voxel-driven fan-beam FBP backprojection with analytical constant.
|
|
454
|
+
|
|
455
|
+
Runs the dedicated fan-beam FBP gather kernel (one thread per output
|
|
456
|
+
pixel, ``(sid_n/U_n)^2`` distance weighting, linear interpolation of
|
|
457
|
+
the filtered sinogram) and multiplies by the analytical constant
|
|
458
|
+
``sdd_mean / (2 * pi * sid_mean)`` so a unit-density disk reconstructs
|
|
459
|
+
to amplitude 1. Both means are measured in the direction of the
|
|
460
|
+
detector normal per view, so the helper reduces to the classical
|
|
461
|
+
``sdd/(2*pi*sid)`` on a circular orbit. ``isocenter`` is a physical
|
|
462
|
+
coordinate pair; by default it is inferred from the source lines along
|
|
463
|
+
detector normals. Supply it explicitly for an ambiguous or non-circular
|
|
464
|
+
acquisition. Analytical FBP remains acquisition-specific.
|
|
465
|
+
"""
|
|
466
|
+
if sinogram.shape[1] < 2:
|
|
467
|
+
raise ValueError("Analytical backprojection requires at least two detector bins")
|
|
468
|
+
device = DeviceManager.get_device(sinogram)
|
|
469
|
+
sinogram = _as_contig_f32(sinogram, device)
|
|
470
|
+
src_pos = _as_contig_f32(src_pos, device)
|
|
471
|
+
det_center = _as_contig_f32(det_center, device)
|
|
472
|
+
det_u_vec = _as_contig_f32(det_u_vec, device)
|
|
473
|
+
normal = torch.stack((-det_u_vec[:, 1], det_u_vec[:, 0]), dim=1)
|
|
474
|
+
isocenter, sid_mean, sdd_mean = _analytical_geometry(
|
|
475
|
+
src_pos, det_center, normal, isocenter,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
n_views, n_det = sinogram.shape
|
|
479
|
+
Ny, Nx = H, W
|
|
480
|
+
reco = torch.zeros((Ny, Nx), dtype=torch.float32, device=device)
|
|
481
|
+
|
|
482
|
+
d_sino = TorchCUDABridge.tensor_to_cuda_array(sinogram)
|
|
483
|
+
d_reco = TorchCUDABridge.tensor_to_cuda_array(reco)
|
|
484
|
+
d_src_pos = TorchCUDABridge.tensor_to_cuda_array(src_pos)
|
|
485
|
+
d_det_center = TorchCUDABridge.tensor_to_cuda_array(det_center)
|
|
486
|
+
d_det_u_vec = TorchCUDABridge.tensor_to_cuda_array(det_u_vec)
|
|
487
|
+
|
|
488
|
+
cx, cy = _DTYPE(Nx * 0.5), _DTYPE(Ny * 0.5)
|
|
489
|
+
grid, tpb = _grid_2d(Nx, Ny)
|
|
490
|
+
|
|
491
|
+
pt_stream = torch.cuda.current_stream()
|
|
492
|
+
numba_stream = _get_numba_external_stream_for(pt_stream)
|
|
493
|
+
|
|
494
|
+
_fan_2d_fbp_backproject_kernel[grid, tpb, numba_stream](
|
|
495
|
+
d_sino, n_views, n_det, d_reco, Nx, Ny,
|
|
496
|
+
_DTYPE(detector_spacing), d_src_pos, d_det_center, d_det_u_vec,
|
|
497
|
+
cx, cy, _DTYPE(voxel_spacing),
|
|
498
|
+
_DTYPE(isocenter[0].item() / voxel_spacing),
|
|
499
|
+
_DTYPE(isocenter[1].item() / voxel_spacing),
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
scale = sdd_mean / (2.0 * math.pi * sid_mean)
|
|
503
|
+
return reco * scale
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def cone_weighted_backproject(sinogram, src_pos, det_center, det_u_vec, det_v_vec,
|
|
507
|
+
D, H, W, du, dv, voxel_spacing=1.0, *, isocenter=None):
|
|
508
|
+
"""Voxel-driven cone-beam FDK backprojection with analytical constant.
|
|
509
|
+
|
|
510
|
+
Runs the dedicated cone-beam FDK gather kernel (one thread per output
|
|
511
|
+
voxel, ``(sid_n/U_n)^2`` distance weighting, bilinear interpolation of
|
|
512
|
+
the filtered sinogram) and multiplies by ``sdd_mean/(2*pi*sid_mean)``
|
|
513
|
+
so a unit-density sphere reconstructs to amplitude 1. Both means are
|
|
514
|
+
taken in the per-view detector-normal direction, so the helper
|
|
515
|
+
reduces to the classical ``sdd/(2*pi*sid)`` on a circular orbit.
|
|
516
|
+
``isocenter`` is a physical coordinate triple, inferred from source
|
|
517
|
+
lines along detector normals by default. Supply it explicitly for an
|
|
518
|
+
ambiguous or non-circular acquisition. FDK remains an approximation
|
|
519
|
+
outside its circular-acquisition assumptions.
|
|
520
|
+
"""
|
|
521
|
+
if min(sinogram.shape[1:]) < 2:
|
|
522
|
+
raise ValueError("Analytical backprojection requires at least two detector bins on each axis")
|
|
523
|
+
device = DeviceManager.get_device(sinogram)
|
|
524
|
+
sinogram = _as_contig_f32(sinogram, device)
|
|
525
|
+
src_pos = _as_contig_f32(src_pos, device)
|
|
526
|
+
det_center = _as_contig_f32(det_center, device)
|
|
527
|
+
det_u_vec = _as_contig_f32(det_u_vec, device)
|
|
528
|
+
det_v_vec = _as_contig_f32(det_v_vec, device)
|
|
529
|
+
normal = torch.cross(det_u_vec, det_v_vec, dim=1)
|
|
530
|
+
isocenter, sid_mean, sdd_mean = _analytical_geometry(
|
|
531
|
+
src_pos, det_center, normal, isocenter,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
n_views, n_u, n_v = sinogram.shape
|
|
535
|
+
Nx, Ny, Nz = W, H, D
|
|
536
|
+
# Allocate reco in WHD layout to match the FDK gather kernel's
|
|
537
|
+
# coalescing (iz warp-adjacent, stride-1 along the D axis).
|
|
538
|
+
reco_perm = torch.zeros((Nx, Ny, Nz), dtype=torch.float32, device=device)
|
|
539
|
+
|
|
540
|
+
d_sino = TorchCUDABridge.tensor_to_cuda_array(sinogram)
|
|
541
|
+
d_reco = TorchCUDABridge.tensor_to_cuda_array(reco_perm)
|
|
542
|
+
d_src_pos = TorchCUDABridge.tensor_to_cuda_array(src_pos)
|
|
543
|
+
d_det_center = TorchCUDABridge.tensor_to_cuda_array(det_center)
|
|
544
|
+
d_det_u_vec = TorchCUDABridge.tensor_to_cuda_array(det_u_vec)
|
|
545
|
+
d_det_v_vec = TorchCUDABridge.tensor_to_cuda_array(det_v_vec)
|
|
546
|
+
|
|
547
|
+
cx = _DTYPE(Nx * 0.5)
|
|
548
|
+
cy = _DTYPE(Ny * 0.5)
|
|
549
|
+
cz = _DTYPE(Nz * 0.5)
|
|
550
|
+
grid, tpb = _grid_3d(Nz, Ny, Nx)
|
|
551
|
+
|
|
552
|
+
pt_stream = torch.cuda.current_stream()
|
|
553
|
+
numba_stream = _get_numba_external_stream_for(pt_stream)
|
|
554
|
+
|
|
555
|
+
_cone_3d_fdk_backproject_kernel[grid, tpb, numba_stream](
|
|
556
|
+
d_sino, n_views, n_u, n_v, d_reco, Nx, Ny, Nz,
|
|
557
|
+
_DTYPE(du), _DTYPE(dv),
|
|
558
|
+
d_src_pos, d_det_center, d_det_u_vec, d_det_v_vec,
|
|
559
|
+
cx, cy, cz, _DTYPE(voxel_spacing),
|
|
560
|
+
_DTYPE(isocenter[0].item() / voxel_spacing),
|
|
561
|
+
_DTYPE(isocenter[1].item() / voxel_spacing),
|
|
562
|
+
_DTYPE(isocenter[2].item() / voxel_spacing),
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
scale = sdd_mean / (2.0 * math.pi * sid_mean)
|
|
566
|
+
|
|
567
|
+
# Convert back to (D, H, W) layout for the user.
|
|
568
|
+
reco = reco_perm.permute(2, 1, 0).contiguous() * scale
|
|
569
|
+
return reco
|