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