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/projectors.py ADDED
@@ -0,0 +1,910 @@
1
+ """PyTorch autograd functions for CT projections.
2
+
3
+ This module contains PyTorch autograd Function classes that wrap CUDA kernels
4
+ for differentiable CT forward projection and backprojection operations.
5
+ """
6
+
7
+ import torch
8
+ import numpy as np
9
+
10
+ from .constants import _DTYPE
11
+ from .utils import (
12
+ DeviceManager,
13
+ TorchCUDABridge,
14
+ _get_numba_external_stream_for,
15
+ _trig_tables,
16
+ _validate_3d_memory_layout,
17
+ _grid_2d,
18
+ _grid_3d,
19
+ )
20
+ from .kernels import (
21
+ _parallel_2d_forward_kernel,
22
+ _parallel_2d_backward_kernel,
23
+ _fan_2d_forward_kernel,
24
+ _fan_2d_backward_kernel,
25
+ _cone_3d_forward_kernel,
26
+ _cone_3d_backward_kernel,
27
+ _parallel_2d_geometry_vjp_kernel,
28
+ _fan_2d_geometry_vjp_kernel,
29
+ _cone_3d_geometry_vjp_kernel,
30
+ )
31
+
32
+
33
+ # ============================================================================
34
+ # Geometry gradients
35
+ # ============================================================================
36
+
37
+ def _geometry_vjp(beam, volume, grad_sino, geometry, detector_spacing, voxel_spacing):
38
+ """Return d<grad_sino, A(geometry) volume>/d(geometry), one tensor per component.
39
+
40
+ ``detector_spacing`` is a scalar for 2D beams and ``(du, dv)`` for cone
41
+ beams. Results take the dtype and device of each geometry component.
42
+ """
43
+ device = volume.device
44
+ vol = volume.detach().to(device=device, dtype=torch.float32).contiguous()
45
+ cot = grad_sino.detach().to(device=device, dtype=torch.float32).contiguous()
46
+ geom = [g.detach().to(device=device, dtype=torch.float32).contiguous() for g in geometry]
47
+ grads = [torch.zeros_like(g) for g in geom]
48
+ d_geom = [TorchCUDABridge.tensor_to_cuda_array(g) for g in geom]
49
+ d_grads = [TorchCUDABridge.tensor_to_cuda_array(g) for g in grads]
50
+ numba_stream = _get_numba_external_stream_for(torch.cuda.current_stream())
51
+ if beam == "cone":
52
+ D, H, W = vol.shape
53
+ n_views, n_u, n_v = cot.shape
54
+ du, dv = detector_spacing
55
+ d_vol = TorchCUDABridge.tensor_to_cuda_array(vol.permute(2, 1, 0).contiguous())
56
+ grid, tpb = _grid_3d(n_v, n_u, n_views)
57
+ _cone_3d_geometry_vjp_kernel[grid, tpb, numba_stream](
58
+ d_vol, W, H, D, TorchCUDABridge.tensor_to_cuda_array(cot), n_views, n_u, n_v,
59
+ _DTYPE(du), _DTYPE(dv), *d_geom,
60
+ _DTYPE(W * 0.5), _DTYPE(H * 0.5), _DTYPE(D * 0.5), _DTYPE(voxel_spacing), *d_grads,
61
+ )
62
+ else:
63
+ Ny, Nx = vol.shape
64
+ n_views, n_det = cot.shape
65
+ kernel = _parallel_2d_geometry_vjp_kernel if beam == "parallel" else _fan_2d_geometry_vjp_kernel
66
+ grid, tpb = _grid_2d(n_views, n_det)
67
+ kernel[grid, tpb, numba_stream](
68
+ TorchCUDABridge.tensor_to_cuda_array(vol), Nx, Ny,
69
+ TorchCUDABridge.tensor_to_cuda_array(cot), n_views, n_det,
70
+ _DTYPE(detector_spacing), *d_geom,
71
+ _DTYPE(Nx * 0.5), _DTYPE(Ny * 0.5), _DTYPE(voxel_spacing), *d_grads,
72
+ )
73
+ return tuple(grad.to(dtype=g.dtype, device=g.device) for grad, g in zip(grads, geometry))
74
+
75
+
76
+ class _GeometryVJPFunction(torch.autograd.Function):
77
+ """First-order geometry gradient; differentiating it again raises."""
78
+
79
+ @staticmethod
80
+ def forward(ctx, beam, volume, grad_sino, detector_spacing, voxel_spacing, *geometry):
81
+ return _geometry_vjp(beam, volume, grad_sino, geometry, detector_spacing, voxel_spacing)
82
+
83
+ @staticmethod
84
+ def backward(ctx, *grad_outputs):
85
+ raise RuntimeError(
86
+ "diffct does not support second derivatives with respect to the geometry"
87
+ )
88
+
89
+
90
+ def _geometry_grads(needs_grad, beam, volume, grad_sino, geometry, detector_spacing, voxel_spacing):
91
+ """Geometry gradients for the components in ``needs_grad``; None for the rest."""
92
+ if not any(needs_grad):
93
+ return (None,) * len(geometry)
94
+ grads = _GeometryVJPFunction.apply(
95
+ beam, volume, grad_sino, detector_spacing, voxel_spacing, *geometry
96
+ )
97
+ return tuple(grad if needed else None for grad, needed in zip(grads, needs_grad))
98
+
99
+
100
+ # ============================================================================
101
+ # PyTorch Autograd Functions
102
+ # ============================================================================
103
+
104
+ class ParallelProjectorFunction(torch.autograd.Function):
105
+ """
106
+ Summary
107
+ -------
108
+ PyTorch autograd function for differentiable 2D parallel beam forward projection.
109
+
110
+ Notes
111
+ -----
112
+ Provides a differentiable interface to the CUDA-accelerated Siddon ray-tracing
113
+ method with a cell-constant image basis for parallel beam CT geometry. The forward pass computes
114
+ the sinogram from a 2D image using parallel beam geometry. The backward pass
115
+ computes gradients using the adjoint backprojection operation. Requires
116
+ CUDA-capable hardware and a properly configured CUDA environment; all input
117
+ tensors must reside on the same CUDA device.
118
+
119
+ Examples
120
+ --------
121
+ >>> import torch
122
+ >>> from diffct.differentiable import ParallelProjectorFunction
123
+ >>>
124
+ >>> # Create a 2D image with gradient tracking
125
+ >>> image = torch.randn(128, 128, device='cuda', requires_grad=True)
126
+ >>> # Define projection parameters
127
+ >>> angles = torch.linspace(0, torch.pi, 180, device='cuda')
128
+ >>> num_detectors = 128
129
+ >>> detector_spacing = 1.0
130
+ >>> # Compute forward projection
131
+ >>> projector = ParallelProjectorFunction.apply
132
+ >>> sinogram = projector(image, angles, num_detectors, detector_spacing)
133
+ >>> # Compute loss and gradients
134
+ >>> loss = sinogram.sum()
135
+ >>> loss.backward()
136
+ >>> print(f"Gradient shape: {image.grad.shape}") # (128, 128)
137
+ """
138
+ @staticmethod
139
+ def forward(ctx, image, ray_dir, det_origin, det_u_vec, num_detectors, detector_spacing=1.0, voxel_spacing=1.0):
140
+ """Compute the 2D parallel beam forward projection with arbitrary trajectories using CUDA acceleration.
141
+
142
+ Parameters
143
+ ----------
144
+ image : torch.Tensor
145
+ 2D input image tensor of shape (H, W), must be on a CUDA device and of type float32.
146
+ ray_dir : torch.Tensor
147
+ Ray direction unit vectors for each view, shape (n_views, 2).
148
+ det_origin : torch.Tensor
149
+ Detector origin positions for each view, shape (n_views, 2), in physical units.
150
+ det_u_vec : torch.Tensor
151
+ Detector u-direction unit vectors for each view, shape (n_views, 2).
152
+ num_detectors : int
153
+ Number of detector elements in the sinogram (columns).
154
+ detector_spacing : float, optional
155
+ Physical spacing between detector elements (default: 1.0).
156
+ voxel_spacing : float, optional
157
+ Physical size of one voxel (in same units as detector_spacing, default: 1.0).
158
+
159
+ Returns
160
+ -------
161
+ sinogram : torch.Tensor
162
+ 2D tensor of shape (n_views, num_detectors) containing the forward projection (sinogram) on the same device as `image`.
163
+
164
+ Notes
165
+ -----
166
+ - All input tensors must be on the same CUDA device.
167
+ - The operation is fully differentiable and supports autograd.
168
+ - Supports arbitrary parallel beam geometries.
169
+ - Uses cell-constant Siddon ray tracing.
170
+
171
+ Examples
172
+ --------
173
+ >>> image = torch.randn(128, 128, device='cuda', requires_grad=True)
174
+ >>> ray_dir, det_origin, det_u_vec = circular_trajectory_2d_parallel(180, device='cuda')
175
+ >>> sinogram = ParallelProjectorFunction.apply(
176
+ ... image, ray_dir, det_origin, det_u_vec, 128, 1.0
177
+ ... )
178
+ """
179
+ # Original inputs: backward builds a differentiable graph through them.
180
+ ctx.save_for_backward(
181
+ image if any(ctx.needs_input_grad[1:4]) else None,
182
+ ray_dir, det_origin, det_u_vec,
183
+ )
184
+ device = DeviceManager.get_device(image)
185
+ image = DeviceManager.ensure_device(image, device)
186
+ ray_dir = DeviceManager.ensure_device(ray_dir, device)
187
+ det_origin = DeviceManager.ensure_device(det_origin, device)
188
+ det_u_vec = DeviceManager.ensure_device(det_u_vec, device)
189
+
190
+ # Ensure input is float32 for kernel compatibility
191
+ image = image.to(dtype=torch.float32).contiguous()
192
+ ray_dir = ray_dir.to(dtype=torch.float32).contiguous()
193
+ det_origin = det_origin.to(dtype=torch.float32).contiguous()
194
+ det_u_vec = det_u_vec.to(dtype=torch.float32).contiguous()
195
+
196
+ Ny, Nx = image.shape
197
+ n_views = ray_dir.shape[0]
198
+
199
+ # Allocate output tensor on the same device
200
+ sinogram = torch.zeros((n_views, num_detectors), dtype=image.dtype, device=device)
201
+
202
+ # Get Numba CUDA array views for kernel
203
+ d_image = TorchCUDABridge.tensor_to_cuda_array(image)
204
+ d_sino = TorchCUDABridge.tensor_to_cuda_array(sinogram)
205
+ d_ray_dir_arr = TorchCUDABridge.tensor_to_cuda_array(ray_dir)
206
+ d_det_origin_arr = TorchCUDABridge.tensor_to_cuda_array(det_origin)
207
+ d_det_u_vec_arr = TorchCUDABridge.tensor_to_cuda_array(det_u_vec)
208
+
209
+ grid, tpb = _grid_2d(n_views, num_detectors)
210
+ cx, cy = _DTYPE(Nx * 0.5), _DTYPE(Ny * 0.5)
211
+
212
+ pt_stream = torch.cuda.current_stream()
213
+ numba_stream = _get_numba_external_stream_for(pt_stream)
214
+ _parallel_2d_forward_kernel[grid, tpb, numba_stream](
215
+ d_image, Nx, Ny, d_sino, n_views, num_detectors,
216
+ _DTYPE(detector_spacing), d_ray_dir_arr, d_det_origin_arr, d_det_u_vec_arr, cx, cy, _DTYPE(voxel_spacing)
217
+ )
218
+
219
+ ctx.intermediate = (num_detectors, detector_spacing, Ny, Nx, voxel_spacing)
220
+ return sinogram
221
+
222
+ @staticmethod
223
+ def backward(ctx, grad_sinogram):
224
+ image, ray_dir, det_origin, det_u_vec = ctx.saved_tensors
225
+ num_detectors, detector_spacing, Ny, Nx, voxel_spacing = ctx.intermediate
226
+ grad_image = None
227
+ if ctx.needs_input_grad[0]:
228
+ # The adjoint is an autograd Function too, so second derivatives work.
229
+ grad_image = ParallelBackprojectorFunction.apply(
230
+ grad_sinogram, ray_dir, det_origin, det_u_vec, detector_spacing, Ny, Nx, voxel_spacing
231
+ )
232
+ geometry_grads = _geometry_grads(
233
+ ctx.needs_input_grad[1:4], "parallel", image, grad_sinogram,
234
+ (ray_dir, det_origin, det_u_vec), detector_spacing, voxel_spacing,
235
+ )
236
+ return (grad_image, *geometry_grads, None, None, None)
237
+
238
+
239
+ class ParallelBackprojectorFunction(torch.autograd.Function):
240
+ """
241
+ Summary
242
+ -------
243
+ PyTorch autograd function for differentiable 2D parallel beam backprojection.
244
+
245
+ Notes
246
+ -----
247
+ Provides a differentiable interface to the CUDA-accelerated Siddon ray-tracing
248
+ method with a cell-constant image basis for parallel beam backprojection. The forward pass computes a 2D
249
+ reconstruction from sinogram data using parallel beam backprojection, and the
250
+ backward pass computes gradients via forward projection as the adjoint operation.
251
+ Requires CUDA-capable hardware and consistent device placements.
252
+
253
+
254
+ Examples
255
+ --------
256
+ >>> import torch
257
+ >>> from diffct.differentiable import ParallelBackprojectorFunction
258
+ >>>
259
+ >>> sinogram = torch.randn(180, 128, device='cuda', requires_grad=True)
260
+ >>> angles = torch.linspace(0, torch.pi, 180, device='cuda')
261
+ >>> recon = ParallelBackprojectorFunction.apply(sinogram, angles, 1.0, 128, 128)
262
+ >>> loss = recon.sum()
263
+ >>> loss.backward()
264
+ >>> print(sinogram.grad.shape) # (180, 128)
265
+ """
266
+ @staticmethod
267
+ def forward(ctx, sinogram, ray_dir, det_origin, det_u_vec, detector_spacing=1.0, H=128, W=128, voxel_spacing=1.0):
268
+ """Compute the 2D parallel beam backprojection with arbitrary trajectories using CUDA acceleration.
269
+
270
+ Parameters
271
+ ----------
272
+ sinogram : torch.Tensor
273
+ 2D input sinogram tensor of shape (n_views, num_detectors), must be on a CUDA device and of type float32.
274
+ ray_dir : torch.Tensor
275
+ Ray direction unit vectors for each view, shape (n_views, 2).
276
+ det_origin : torch.Tensor
277
+ Detector origin positions for each view, shape (n_views, 2), in physical units.
278
+ det_u_vec : torch.Tensor
279
+ Detector u-direction unit vectors for each view, shape (n_views, 2).
280
+ detector_spacing : float, optional
281
+ Physical spacing between detector elements (default: 1.0).
282
+ H : int, optional
283
+ Height of the output reconstruction image (default: 128).
284
+ W : int, optional
285
+ Width of the output reconstruction image (default: 128).
286
+ voxel_spacing : float, optional
287
+ Physical size of one voxel (in same units as detector_spacing, default: 1.0).
288
+
289
+ Returns
290
+ -------
291
+ reco : torch.Tensor
292
+ 2D tensor of shape (H, W) containing the reconstructed image on the same device as `sinogram`.
293
+
294
+ Notes
295
+ -----
296
+ - All input tensors must be on the same CUDA device.
297
+ - The operation is fully differentiable and supports autograd.
298
+ - Supports arbitrary parallel beam geometries.
299
+ - Uses the adjoint of cell-constant Siddon ray tracing.
300
+
301
+ Examples
302
+ --------
303
+ >>> sinogram = torch.randn(180, 128, device='cuda', requires_grad=True)
304
+ >>> ray_dir, det_origin, det_u_vec = circular_trajectory_2d_parallel(180, device='cuda')
305
+ >>> reco = ParallelBackprojectorFunction.apply(
306
+ ... sinogram, ray_dir, det_origin, det_u_vec, 1.0, 128, 128
307
+ ... )
308
+ """
309
+ # Original inputs: backward builds a differentiable graph through them.
310
+ ctx.save_for_backward(
311
+ sinogram if any(ctx.needs_input_grad[1:4]) else None,
312
+ ray_dir, det_origin, det_u_vec,
313
+ )
314
+ device = DeviceManager.get_device(sinogram)
315
+ sinogram = DeviceManager.ensure_device(sinogram, device)
316
+ ray_dir = DeviceManager.ensure_device(ray_dir, device)
317
+ det_origin = DeviceManager.ensure_device(det_origin, device)
318
+ det_u_vec = DeviceManager.ensure_device(det_u_vec, device)
319
+
320
+ # Ensure input is float32 for kernel compatibility
321
+ sinogram = sinogram.to(dtype=torch.float32).contiguous()
322
+ ray_dir = ray_dir.to(dtype=torch.float32).contiguous()
323
+ det_origin = det_origin.to(dtype=torch.float32).contiguous()
324
+ det_u_vec = det_u_vec.to(dtype=torch.float32).contiguous()
325
+
326
+ n_views, n_det = sinogram.shape
327
+ Ny, Nx = H, W
328
+
329
+ # Allocate output tensor on the same device
330
+ reco = torch.zeros((Ny, Nx), dtype=sinogram.dtype, device=device)
331
+
332
+ # Get Numba CUDA array views for kernel
333
+ d_sino = TorchCUDABridge.tensor_to_cuda_array(sinogram)
334
+ d_reco = TorchCUDABridge.tensor_to_cuda_array(reco)
335
+ d_ray_dir_arr = TorchCUDABridge.tensor_to_cuda_array(ray_dir)
336
+ d_det_origin_arr = TorchCUDABridge.tensor_to_cuda_array(det_origin)
337
+ d_det_u_vec_arr = TorchCUDABridge.tensor_to_cuda_array(det_u_vec)
338
+
339
+ grid, tpb = _grid_2d(n_views, n_det)
340
+ cx, cy = _DTYPE(Nx * 0.5), _DTYPE(Ny * 0.5)
341
+
342
+ pt_stream = torch.cuda.current_stream()
343
+ numba_stream = _get_numba_external_stream_for(pt_stream)
344
+ _parallel_2d_backward_kernel[grid, tpb, numba_stream](
345
+ d_sino, n_views, n_det, d_reco, Nx, Ny,
346
+ _DTYPE(detector_spacing), d_ray_dir_arr, d_det_origin_arr, d_det_u_vec_arr, cx, cy, _DTYPE(voxel_spacing)
347
+ )
348
+
349
+ ctx.intermediate = (H, W, detector_spacing, sinogram.shape[0], sinogram.shape[1], voxel_spacing)
350
+ return reco
351
+
352
+ @staticmethod
353
+ def backward(ctx, grad_output):
354
+ sinogram, ray_dir, det_origin, det_u_vec = ctx.saved_tensors
355
+ H, W, detector_spacing, n_views, n_det, voxel_spacing = ctx.intermediate
356
+ grad_sino = None
357
+ if ctx.needs_input_grad[0]:
358
+ grad_sino = ParallelProjectorFunction.apply(
359
+ grad_output, ray_dir, det_origin, det_u_vec, n_det, detector_spacing, voxel_spacing
360
+ )
361
+ # <grad_output, A^T y> = <A grad_output, y>, so the projector's VJP applies.
362
+ geometry_grads = _geometry_grads(
363
+ ctx.needs_input_grad[1:4], "parallel", grad_output, sinogram,
364
+ (ray_dir, det_origin, det_u_vec), detector_spacing, voxel_spacing,
365
+ )
366
+ return (grad_sino, *geometry_grads, None, None, None, None)
367
+
368
+
369
+ class FanProjectorFunction(torch.autograd.Function):
370
+ """
371
+ Summary
372
+ -------
373
+ PyTorch autograd function for differentiable 2D fan beam forward projection.
374
+
375
+ Notes
376
+ -----
377
+ Provides a differentiable interface to the CUDA-accelerated Siddon ray-tracing
378
+ method with a cell-constant image basis for fan beam geometry, where rays diverge from a point
379
+ X-ray source to a linear detector array. The forward pass computes sinograms
380
+ using divergent beam geometry, and the backward pass computes gradients via
381
+ adjoint backprojection.
382
+
383
+
384
+ Examples
385
+ --------
386
+ >>> import torch
387
+ >>> from diffct.differentiable import FanProjectorFunction
388
+ >>>
389
+ >>> image = torch.randn(256, 256, device='cuda', requires_grad=True)
390
+ >>> angles = torch.linspace(0, 2 * torch.pi, 360, device='cuda')
391
+ >>> sinogram = FanProjectorFunction.apply(image, angles, 512, 1.0, 1500.0, 1000.0)
392
+ >>> loss = sinogram.sum()
393
+ >>> loss.backward()
394
+ >>> print(image.grad.shape) # (256, 256)
395
+ """
396
+ @staticmethod
397
+ def forward(ctx, image, src_pos, det_center, det_u_vec, num_detectors, detector_spacing, voxel_spacing=1.0):
398
+ """Compute the 2D fan beam forward projection with arbitrary trajectories using CUDA acceleration.
399
+
400
+ Parameters
401
+ ----------
402
+ image : torch.Tensor
403
+ 2D input image tensor of shape (H, W), must be on a CUDA device and of type float32.
404
+ src_pos : torch.Tensor
405
+ Source positions for each view, shape (n_views, 2), in physical units.
406
+ det_center : torch.Tensor
407
+ Detector center positions for each view, shape (n_views, 2), in physical units.
408
+ det_u_vec : torch.Tensor
409
+ Detector u-direction unit vectors for each view, shape (n_views, 2).
410
+ num_detectors : int
411
+ Number of detector elements in the sinogram (columns).
412
+ detector_spacing : float
413
+ Physical spacing between detector elements.
414
+ voxel_spacing : float, optional
415
+ Physical size of one voxel (in same units as detector_spacing, default: 1.0).
416
+
417
+ Returns
418
+ -------
419
+ sinogram : torch.Tensor
420
+ 2D tensor of shape (n_views, num_detectors) containing the fan beam sinogram on the same device as `image`.
421
+
422
+ Notes
423
+ -----
424
+ - All input tensors must be on the same CUDA device.
425
+ - The operation is fully differentiable and supports autograd.
426
+ - Supports arbitrary fan beam geometries.
427
+ - Uses cell-constant Siddon ray tracing.
428
+
429
+ Examples
430
+ --------
431
+ >>> image = torch.randn(256, 256, device='cuda', requires_grad=True)
432
+ >>> src_pos, det_center, det_u_vec = circular_trajectory_2d_fan(360, 1000.0, 1500.0, device='cuda')
433
+ >>> sinogram = FanProjectorFunction.apply(
434
+ ... image, src_pos, det_center, det_u_vec, 512, 1.0
435
+ ... )
436
+ """
437
+ # Original inputs: backward builds a differentiable graph through them.
438
+ ctx.save_for_backward(
439
+ image if any(ctx.needs_input_grad[1:4]) else None,
440
+ src_pos, det_center, det_u_vec,
441
+ )
442
+ device = DeviceManager.get_device(image)
443
+ image = DeviceManager.ensure_device(image, device)
444
+ src_pos = DeviceManager.ensure_device(src_pos, device)
445
+ det_center = DeviceManager.ensure_device(det_center, device)
446
+ det_u_vec = DeviceManager.ensure_device(det_u_vec, device)
447
+
448
+ image = image.to(dtype=torch.float32).contiguous()
449
+ src_pos = src_pos.to(dtype=torch.float32).contiguous()
450
+ det_center = det_center.to(dtype=torch.float32).contiguous()
451
+ det_u_vec = det_u_vec.to(dtype=torch.float32).contiguous()
452
+
453
+ Ny, Nx = image.shape
454
+ n_views = src_pos.shape[0]
455
+
456
+ sinogram = torch.zeros((n_views, num_detectors), dtype=image.dtype, device=device)
457
+
458
+ d_image = TorchCUDABridge.tensor_to_cuda_array(image)
459
+ d_sino = TorchCUDABridge.tensor_to_cuda_array(sinogram)
460
+ d_src_pos_arr = TorchCUDABridge.tensor_to_cuda_array(src_pos)
461
+ d_det_center_arr = TorchCUDABridge.tensor_to_cuda_array(det_center)
462
+ d_det_u_vec_arr = TorchCUDABridge.tensor_to_cuda_array(det_u_vec)
463
+
464
+ grid, tpb = _grid_2d(n_views, num_detectors)
465
+ cx, cy = _DTYPE(Nx * 0.5), _DTYPE(Ny * 0.5)
466
+
467
+ pt_stream = torch.cuda.current_stream()
468
+ numba_stream = _get_numba_external_stream_for(pt_stream)
469
+ _fan_2d_forward_kernel[grid, tpb, numba_stream](
470
+ d_image, Nx, Ny, d_sino, n_views, num_detectors,
471
+ _DTYPE(detector_spacing), d_src_pos_arr, d_det_center_arr, d_det_u_vec_arr,
472
+ cx, cy, _DTYPE(voxel_spacing)
473
+ )
474
+
475
+ ctx.intermediate = (num_detectors, detector_spacing, Ny, Nx, voxel_spacing)
476
+ return sinogram
477
+
478
+ @staticmethod
479
+ def backward(ctx, grad_sinogram):
480
+ image, src_pos, det_center, det_u_vec = ctx.saved_tensors
481
+ num_detectors, detector_spacing, Ny, Nx, voxel_spacing = ctx.intermediate
482
+ grad_image = None
483
+ if ctx.needs_input_grad[0]:
484
+ # The adjoint is an autograd Function too, so second derivatives work.
485
+ grad_image = FanBackprojectorFunction.apply(
486
+ grad_sinogram, src_pos, det_center, det_u_vec, detector_spacing, Ny, Nx, voxel_spacing
487
+ )
488
+ geometry_grads = _geometry_grads(
489
+ ctx.needs_input_grad[1:4], "fan", image, grad_sinogram,
490
+ (src_pos, det_center, det_u_vec), detector_spacing, voxel_spacing,
491
+ )
492
+ return (grad_image, *geometry_grads, None, None, None)
493
+
494
+
495
+ class FanBackprojectorFunction(torch.autograd.Function):
496
+ """
497
+ Summary
498
+ -------
499
+ PyTorch autograd function for differentiable 2D fan beam backprojection.
500
+
501
+ Notes
502
+ -----
503
+ Provides a differentiable interface to the CUDA-accelerated Siddon ray-tracing
504
+ method with a cell-constant image basis for fan beam backprojection. Implements the adjoint
505
+ of the fan beam projection operator, distributing sinogram values back into
506
+ the reconstruction volume along divergent ray paths. The forward pass
507
+ computes reconstruction from sinogram data, and the backward pass computes
508
+ gradients via forward projection.
509
+
510
+
511
+ Examples
512
+ --------
513
+ >>> import torch
514
+ >>> from diffct.differentiable import FanBackprojectorFunction
515
+ >>>
516
+ >>> sinogram = torch.randn(360, 512, device='cuda', requires_grad=True)
517
+ >>> angles = torch.linspace(0, 2 * torch.pi, 360, device='cuda')
518
+ >>> recon = FanBackprojectorFunction.apply(sinogram, angles, 1.0, 256, 256, 1500.0, 1000.0)
519
+ >>> loss = recon.sum()
520
+ >>> loss.backward()
521
+ >>> print(sinogram.grad.shape) # (360, 512)
522
+ """
523
+ @staticmethod
524
+ def forward(ctx, sinogram, src_pos, det_center, det_u_vec, detector_spacing, H, W, voxel_spacing=1.0):
525
+ """Compute the 2D fan beam backprojection with arbitrary trajectories using CUDA acceleration.
526
+
527
+ Parameters
528
+ ----------
529
+ sinogram : torch.Tensor
530
+ 2D input fan beam sinogram tensor of shape (n_views, num_detectors), must be on a CUDA device and of type float32.
531
+ src_pos : torch.Tensor
532
+ Source positions for each view, shape (n_views, 2), in physical units.
533
+ det_center : torch.Tensor
534
+ Detector center positions for each view, shape (n_views, 2), in physical units.
535
+ det_u_vec : torch.Tensor
536
+ Detector u-direction unit vectors for each view, shape (n_views, 2).
537
+ detector_spacing : float
538
+ Physical spacing between detector elements.
539
+ H : int
540
+ Height of the output reconstruction image.
541
+ W : int
542
+ Width of the output reconstruction image.
543
+ voxel_spacing : float, optional
544
+ Physical size of one voxel (in same units as detector_spacing, default: 1.0).
545
+
546
+ Returns
547
+ -------
548
+ reco : torch.Tensor
549
+ 2D tensor of shape (H, W) containing the reconstructed image on the same device as `sinogram`.
550
+
551
+ Notes
552
+ -----
553
+ - All input tensors must be on the same CUDA device.
554
+ - The operation is fully differentiable and supports autograd.
555
+ - Supports arbitrary fan beam geometries.
556
+ - Uses the adjoint of cell-constant Siddon ray tracing.
557
+
558
+ Examples
559
+ --------
560
+ >>> sinogram = torch.randn(360, 512, device='cuda', requires_grad=True)
561
+ >>> src_pos, det_center, det_u_vec = circular_trajectory_2d_fan(360, 1000.0, 1500.0, device='cuda')
562
+ >>> reco = FanBackprojectorFunction.apply(
563
+ ... sinogram, src_pos, det_center, det_u_vec, 1.0, 256, 256
564
+ ... )
565
+ """
566
+ # Original inputs: backward builds a differentiable graph through them.
567
+ ctx.save_for_backward(
568
+ sinogram if any(ctx.needs_input_grad[1:4]) else None,
569
+ src_pos, det_center, det_u_vec,
570
+ )
571
+ device = DeviceManager.get_device(sinogram)
572
+ sinogram = DeviceManager.ensure_device(sinogram, device)
573
+ src_pos = DeviceManager.ensure_device(src_pos, device)
574
+ det_center = DeviceManager.ensure_device(det_center, device)
575
+ det_u_vec = DeviceManager.ensure_device(det_u_vec, device)
576
+
577
+ sinogram = sinogram.to(dtype=torch.float32).contiguous()
578
+ src_pos = src_pos.to(dtype=torch.float32).contiguous()
579
+ det_center = det_center.to(dtype=torch.float32).contiguous()
580
+ det_u_vec = det_u_vec.to(dtype=torch.float32).contiguous()
581
+
582
+ n_views, n_det = sinogram.shape
583
+ Ny, Nx = H, W
584
+
585
+ reco = torch.zeros((Ny, Nx), dtype=sinogram.dtype, device=device)
586
+
587
+ d_sino = TorchCUDABridge.tensor_to_cuda_array(sinogram)
588
+ d_reco = TorchCUDABridge.tensor_to_cuda_array(reco)
589
+ d_src_pos_arr = TorchCUDABridge.tensor_to_cuda_array(src_pos)
590
+ d_det_center_arr = TorchCUDABridge.tensor_to_cuda_array(det_center)
591
+ d_det_u_vec_arr = TorchCUDABridge.tensor_to_cuda_array(det_u_vec)
592
+
593
+ grid, tpb = _grid_2d(n_views, n_det)
594
+ cx, cy = _DTYPE(Nx * 0.5), _DTYPE(Ny * 0.5)
595
+
596
+ pt_stream = torch.cuda.current_stream()
597
+ numba_stream = _get_numba_external_stream_for(pt_stream)
598
+ _fan_2d_backward_kernel[grid, tpb, numba_stream](
599
+ d_sino, n_views, n_det, d_reco, Nx, Ny,
600
+ _DTYPE(detector_spacing), d_src_pos_arr, d_det_center_arr, d_det_u_vec_arr,
601
+ cx, cy, _DTYPE(voxel_spacing)
602
+ )
603
+
604
+ ctx.intermediate = (H, W, detector_spacing, n_views, n_det, voxel_spacing)
605
+ return reco
606
+
607
+ @staticmethod
608
+ def backward(ctx, grad_output):
609
+ sinogram, src_pos, det_center, det_u_vec = ctx.saved_tensors
610
+ H, W, detector_spacing, n_views, n_det, voxel_spacing = ctx.intermediate
611
+ grad_sino = None
612
+ if ctx.needs_input_grad[0]:
613
+ grad_sino = FanProjectorFunction.apply(
614
+ grad_output, src_pos, det_center, det_u_vec, n_det, detector_spacing, voxel_spacing
615
+ )
616
+ # <grad_output, A^T y> = <A grad_output, y>, so the projector's VJP applies.
617
+ geometry_grads = _geometry_grads(
618
+ ctx.needs_input_grad[1:4], "fan", grad_output, sinogram,
619
+ (src_pos, det_center, det_u_vec), detector_spacing, voxel_spacing,
620
+ )
621
+ return (grad_sino, *geometry_grads, None, None, None, None)
622
+
623
+
624
+ class ConeProjectorFunction(torch.autograd.Function):
625
+ """
626
+ Summary
627
+ -------
628
+ PyTorch autograd function for differentiable 3D cone beam forward projection.
629
+
630
+ Notes
631
+ -----
632
+ Provides a differentiable interface to the CUDA-accelerated Siddon ray-tracing
633
+ method with a cell-constant voxel basis for 3D cone beam geometry. Rays emanate from a point
634
+ X-ray source to a 2D detector array capturing volumetric projection data.
635
+ The forward pass computes 3D projections, and the backward pass computes
636
+ gradients via adjoint 3D backprojection. Requires significant GPU memory.
637
+
638
+
639
+ Examples
640
+ --------
641
+ >>> import torch
642
+ >>> from diffct.differentiable import ConeProjectorFunction
643
+ >>>
644
+ >>> volume = torch.randn(128, 128, 128, device='cuda', requires_grad=True)
645
+ >>> angles = torch.linspace(0, 2 * torch.pi, 360, device='cuda')
646
+ >>> projections = ConeProjectorFunction.apply(volume, angles, 256, 256, 1.0, 1.0, 1500.0, 1000.0)
647
+ >>> loss = projections.sum()
648
+ >>> loss.backward()
649
+ >>> print(volume.grad.shape) # (128, 128, 128)
650
+ """
651
+ @staticmethod
652
+ def forward(ctx, volume, src_pos, det_center, det_u_vec, det_v_vec, det_u, det_v, du, dv, voxel_spacing=1.0):
653
+ """Compute the 3D cone beam forward projection with arbitrary trajectories using CUDA acceleration.
654
+
655
+ Parameters
656
+ ----------
657
+ volume : torch.Tensor
658
+ 3D input volume tensor of shape (D, H, W), must be on a CUDA device and of type float32.
659
+ src_pos : torch.Tensor
660
+ Source positions for each view, shape (n_views, 3), in physical units.
661
+ det_center : torch.Tensor
662
+ Detector center positions for each view, shape (n_views, 3), in physical units.
663
+ det_u_vec : torch.Tensor
664
+ Detector u-direction unit vectors for each view, shape (n_views, 3).
665
+ det_v_vec : torch.Tensor
666
+ Detector v-direction unit vectors for each view, shape (n_views, 3).
667
+ det_u : int
668
+ Number of detector elements along the u-axis (width).
669
+ det_v : int
670
+ Number of detector elements along the v-axis (height).
671
+ du : float
672
+ Physical spacing between detector elements along the u-axis.
673
+ dv : float
674
+ Physical spacing between detector elements along the v-axis.
675
+ voxel_spacing : float, optional
676
+ Physical size of one voxel (in same units as positions, default: 1.0).
677
+
678
+ Returns
679
+ -------
680
+ sino : torch.Tensor
681
+ 3D tensor of shape (n_views, det_u, det_v) containing the cone beam projections on the same device as `volume`.
682
+
683
+ Notes
684
+ -----
685
+ - All input tensors must be on the same CUDA device.
686
+ - The operation is fully differentiable and supports autograd.
687
+ - Supports arbitrary source and detector trajectories, not limited to circular orbits.
688
+ - Uses cell-constant Siddon ray tracing.
689
+
690
+ Examples
691
+ --------
692
+ >>> volume = torch.randn(128, 128, 128, device='cuda', requires_grad=True)
693
+ >>> # Create circular trajectory
694
+ >>> src_pos, det_center, det_u_vec, det_v_vec = circular_trajectory_3d(360, 1000.0, 1500.0, device='cuda')
695
+ >>> sino = ConeProjectorFunction.apply(
696
+ ... volume, src_pos, det_center, det_u_vec, det_v_vec, 256, 256, 1.0, 1.0
697
+ ... )
698
+ """
699
+ # Original inputs: backward builds a differentiable graph through them.
700
+ ctx.save_for_backward(
701
+ volume if any(ctx.needs_input_grad[1:5]) else None,
702
+ src_pos, det_center, det_u_vec, det_v_vec,
703
+ )
704
+ device = DeviceManager.get_device(volume)
705
+ volume = DeviceManager.ensure_device(volume, device)
706
+ src_pos = DeviceManager.ensure_device(src_pos, device)
707
+ det_center = DeviceManager.ensure_device(det_center, device)
708
+ det_u_vec = DeviceManager.ensure_device(det_u_vec, device)
709
+ det_v_vec = DeviceManager.ensure_device(det_v_vec, device)
710
+
711
+ volume = volume.to(dtype=torch.float32).contiguous()
712
+ src_pos = src_pos.to(dtype=torch.float32).contiguous()
713
+ det_center = det_center.to(dtype=torch.float32).contiguous()
714
+ det_u_vec = det_u_vec.to(dtype=torch.float32).contiguous()
715
+ det_v_vec = det_v_vec.to(dtype=torch.float32).contiguous()
716
+
717
+ D, H, W = volume.shape
718
+ n_views = src_pos.shape[0]
719
+
720
+ # Validate memory layout to prevent coordinate system inconsistencies
721
+ _validate_3d_memory_layout(volume, expected_order='DHW')
722
+
723
+ sino = torch.zeros((n_views, det_u, det_v), dtype=volume.dtype, device=device)
724
+
725
+ volume_perm = volume.permute(2, 1, 0).contiguous()
726
+ d_vol = TorchCUDABridge.tensor_to_cuda_array(volume_perm)
727
+ d_sino = TorchCUDABridge.tensor_to_cuda_array(sino)
728
+ d_src_pos_arr = TorchCUDABridge.tensor_to_cuda_array(src_pos)
729
+ d_det_center_arr = TorchCUDABridge.tensor_to_cuda_array(det_center)
730
+ d_det_u_vec_arr = TorchCUDABridge.tensor_to_cuda_array(det_u_vec)
731
+ d_det_v_vec_arr = TorchCUDABridge.tensor_to_cuda_array(det_v_vec)
732
+
733
+ grid, tpb = _grid_3d(det_v, det_u, n_views)
734
+ cx, cy, cz = _DTYPE(W * 0.5), _DTYPE(H * 0.5), _DTYPE(D * 0.5)
735
+
736
+ pt_stream = torch.cuda.current_stream()
737
+ numba_stream = _get_numba_external_stream_for(pt_stream)
738
+ _cone_3d_forward_kernel[grid, tpb, numba_stream](
739
+ d_vol, W, H, D, d_sino, n_views, det_u, det_v,
740
+ _DTYPE(du), _DTYPE(dv), d_src_pos_arr, d_det_center_arr, d_det_u_vec_arr, d_det_v_vec_arr,
741
+ cx, cy, cz, _DTYPE(voxel_spacing)
742
+ )
743
+
744
+ ctx.intermediate = (D, H, W, det_u, det_v, du, dv, voxel_spacing)
745
+ return sino
746
+
747
+ @staticmethod
748
+ def backward(ctx, grad_sinogram):
749
+ volume, src_pos, det_center, det_u_vec, det_v_vec = ctx.saved_tensors
750
+ (D, H, W, det_u, det_v, du, dv, voxel_spacing) = ctx.intermediate
751
+ grad_vol = None
752
+ if ctx.needs_input_grad[0]:
753
+ # The adjoint is an autograd Function too, so second derivatives work.
754
+ grad_vol = ConeBackprojectorFunction.apply(
755
+ grad_sinogram, src_pos, det_center, det_u_vec, det_v_vec, D, H, W, du, dv, voxel_spacing
756
+ )
757
+ geometry_grads = _geometry_grads(
758
+ ctx.needs_input_grad[1:5], "cone", volume, grad_sinogram,
759
+ (src_pos, det_center, det_u_vec, det_v_vec), (du, dv), voxel_spacing,
760
+ )
761
+ return (grad_vol, *geometry_grads, None, None, None, None, None)
762
+
763
+
764
+ class ConeBackprojectorFunction(torch.autograd.Function):
765
+ """
766
+ Summary
767
+ -------
768
+ PyTorch autograd function for differentiable 3D cone beam backprojection.
769
+
770
+ Notes
771
+ -----
772
+ Provides a differentiable interface to the CUDA-accelerated Siddon ray-tracing
773
+ method with a cell-constant voxel basis for 3D cone beam backprojection. The forward pass
774
+ computes a 3D reconstruction from cone beam projection data using
775
+ backprojection as the adjoint operation. The backward pass computes gradients
776
+ via 3D cone beam forward projection. Requires CUDA-capable hardware and
777
+ consistent device placements.
778
+
779
+ This operation may be memory- and computationally-intensive due to 3D geometry.
780
+ Consider using gradient checkpointing, smaller volumes, or distributed computing
781
+ for large-scale applications, and ensure sufficient GPU memory is available.
782
+
783
+
784
+ Examples
785
+ --------
786
+ >>> import torch
787
+ >>> from diffct.differentiable import ConeBackprojectorFunction
788
+ >>>
789
+ >>> projections = torch.randn(360, 256, 256, device='cuda', requires_grad=True)
790
+ >>> angles = torch.linspace(0, 2 * torch.pi, 360, device='cuda')
791
+ >>> D, H, W = 128, 128, 128
792
+ >>> du, dv = 1.0, 1.0
793
+ >>> sdd, sid = 1500.0, 1000.0
794
+ >>> backprojector = ConeBackprojectorFunction.apply
795
+ >>> volume = backprojector(projections, angles, D, H, W, du, dv, sdd, sid)
796
+ >>> loss = volume.sum()
797
+ >>> loss.backward()
798
+ >>> print(f"Projection gradient shape: {projections.grad.shape}") # (360, 256, 256)
799
+ """
800
+ @staticmethod
801
+ def forward(ctx, sinogram, src_pos, det_center, det_u_vec, det_v_vec, D, H, W, du, dv, voxel_spacing=1.0):
802
+ """Compute the 3D cone beam backprojection with arbitrary trajectories using CUDA acceleration.
803
+
804
+ Parameters
805
+ ----------
806
+ sinogram : torch.Tensor
807
+ 3D input cone beam projection tensor of shape (n_views, det_u, det_v), must be on a CUDA device and of type float32.
808
+ src_pos : torch.Tensor
809
+ Source positions for each view, shape (n_views, 3), in physical units.
810
+ det_center : torch.Tensor
811
+ Detector center positions for each view, shape (n_views, 3), in physical units.
812
+ det_u_vec : torch.Tensor
813
+ Detector u-direction unit vectors for each view, shape (n_views, 3).
814
+ det_v_vec : torch.Tensor
815
+ Detector v-direction unit vectors for each view, shape (n_views, 3).
816
+ D : int
817
+ Depth (z-dimension) of the output reconstruction volume.
818
+ H : int
819
+ Height (y-dimension) of the output reconstruction volume.
820
+ W : int
821
+ Width (x-dimension) of the output reconstruction volume.
822
+ du : float
823
+ Physical spacing between detector elements along the u-axis.
824
+ dv : float
825
+ Physical spacing between detector elements along the v-axis.
826
+ voxel_spacing : float, optional
827
+ Physical size of one voxel (in same units as positions, default: 1.0).
828
+
829
+ Returns
830
+ -------
831
+ vol : torch.Tensor
832
+ 3D tensor of shape (D, H, W) containing the reconstructed volume on the same device as `sinogram`.
833
+
834
+ Notes
835
+ -----
836
+ - All input tensors must be on the same CUDA device.
837
+ - The operation is fully differentiable and supports autograd.
838
+ - Supports arbitrary source and detector trajectories.
839
+ - Uses the adjoint of cell-constant Siddon ray tracing.
840
+
841
+ Examples
842
+ --------
843
+ >>> projections = torch.randn(360, 256, 256, device='cuda', requires_grad=True)
844
+ >>> src_pos, det_center, det_u_vec, det_v_vec = circular_trajectory_3d(360, 1000.0, 1500.0, device='cuda')
845
+ >>> vol = ConeBackprojectorFunction.apply(
846
+ ... projections, src_pos, det_center, det_u_vec, det_v_vec, 128, 128, 128, 1.0, 1.0
847
+ ... )
848
+ """
849
+ # Original inputs: backward builds a differentiable graph through them.
850
+ ctx.save_for_backward(
851
+ sinogram if any(ctx.needs_input_grad[1:5]) else None,
852
+ src_pos, det_center, det_u_vec, det_v_vec,
853
+ )
854
+ device = DeviceManager.get_device(sinogram)
855
+ sinogram = DeviceManager.ensure_device(sinogram, device)
856
+ src_pos = DeviceManager.ensure_device(src_pos, device)
857
+ det_center = DeviceManager.ensure_device(det_center, device)
858
+ det_u_vec = DeviceManager.ensure_device(det_u_vec, device)
859
+ det_v_vec = DeviceManager.ensure_device(det_v_vec, device)
860
+
861
+ sinogram = sinogram.to(dtype=torch.float32).contiguous()
862
+ src_pos = src_pos.to(dtype=torch.float32).contiguous()
863
+ det_center = det_center.to(dtype=torch.float32).contiguous()
864
+ det_u_vec = det_u_vec.to(dtype=torch.float32).contiguous()
865
+ det_v_vec = det_v_vec.to(dtype=torch.float32).contiguous()
866
+
867
+ n_views, n_u, n_v = sinogram.shape
868
+
869
+ # Validate memory layout to prevent coordinate system inconsistencies
870
+ _validate_3d_memory_layout(sinogram, expected_order='VHW')
871
+
872
+ vol_perm = torch.zeros((W, H, D), dtype=sinogram.dtype, device=device)
873
+
874
+ d_sino = TorchCUDABridge.tensor_to_cuda_array(sinogram)
875
+ d_reco = TorchCUDABridge.tensor_to_cuda_array(vol_perm)
876
+ d_src_pos_arr = TorchCUDABridge.tensor_to_cuda_array(src_pos)
877
+ d_det_center_arr = TorchCUDABridge.tensor_to_cuda_array(det_center)
878
+ d_det_u_vec_arr = TorchCUDABridge.tensor_to_cuda_array(det_u_vec)
879
+ d_det_v_vec_arr = TorchCUDABridge.tensor_to_cuda_array(det_v_vec)
880
+
881
+ grid, tpb = _grid_3d(n_v, n_u, n_views)
882
+ cx, cy, cz = _DTYPE(W * 0.5), _DTYPE(H * 0.5), _DTYPE(D * 0.5)
883
+
884
+ pt_stream = torch.cuda.current_stream()
885
+ numba_stream = _get_numba_external_stream_for(pt_stream)
886
+ _cone_3d_backward_kernel[grid, tpb, numba_stream](
887
+ d_sino, n_views, n_u, n_v, d_reco, W, H, D,
888
+ _DTYPE(du), _DTYPE(dv), d_src_pos_arr, d_det_center_arr, d_det_u_vec_arr, d_det_v_vec_arr,
889
+ cx, cy, cz, _DTYPE(voxel_spacing)
890
+ )
891
+
892
+ ctx.intermediate = (D, H, W, n_u, n_v, du, dv, voxel_spacing)
893
+ vol = vol_perm.permute(2, 1, 0).contiguous()
894
+ return vol
895
+
896
+ @staticmethod
897
+ def backward(ctx, grad_output):
898
+ sinogram, src_pos, det_center, det_u_vec, det_v_vec = ctx.saved_tensors
899
+ (D, H, W, n_u, n_v, du, dv, voxel_spacing) = ctx.intermediate
900
+ grad_sino = None
901
+ if ctx.needs_input_grad[0]:
902
+ grad_sino = ConeProjectorFunction.apply(
903
+ grad_output, src_pos, det_center, det_u_vec, det_v_vec, n_u, n_v, du, dv, voxel_spacing
904
+ )
905
+ # <grad_output, A^T y> = <A grad_output, y>, so the projector's VJP applies.
906
+ geometry_grads = _geometry_grads(
907
+ ctx.needs_input_grad[1:5], "cone", grad_output, sinogram,
908
+ (src_pos, det_center, det_u_vec, det_v_vec), (du, dv), voxel_spacing,
909
+ )
910
+ return (grad_sino, *geometry_grads, None, None, None, None, None, None)