diffct 2.2.0__py3-none-any.whl → 2.2.1__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 CHANGED
@@ -17,6 +17,13 @@ from .projectors import (
17
17
  from .operators import Projector
18
18
  from .storage import TensorStore, NpyStore
19
19
  from .surfaces import ParameterizedSurface
20
+ from .reconstruction import (
21
+ fbp,
22
+ fdk,
23
+ helical_fdk,
24
+ helical_view_weights,
25
+ helical_weighted_backproject,
26
+ )
20
27
 
21
28
  from .geometry import (
22
29
  circular_trajectory_3d,
@@ -51,13 +58,18 @@ try:
51
58
  except ImportError:
52
59
  pass
53
60
 
54
- __version__ = '2.2.0'
61
+ __version__ = '2.2.1'
55
62
 
56
63
  __all__ = [
57
64
  'Projector',
58
65
  'TensorStore',
59
66
  'NpyStore',
60
67
  'ParameterizedSurface',
68
+ 'fbp',
69
+ 'fdk',
70
+ 'helical_fdk',
71
+ 'helical_view_weights',
72
+ 'helical_weighted_backproject',
61
73
  'ParallelProjectorFunction',
62
74
  'ParallelBackprojectorFunction',
63
75
  'FanProjectorFunction',
diffct/analytical.py CHANGED
@@ -40,6 +40,9 @@ from .kernels import (
40
40
  _parallel_2d_fbp_backproject_kernel,
41
41
  _fan_2d_fbp_backproject_kernel,
42
42
  _cone_3d_fdk_backproject_kernel,
43
+ _parallel_2d_fbp_transpose_kernel,
44
+ _fan_2d_fbp_transpose_kernel,
45
+ _cone_3d_fdk_transpose_kernel,
43
46
  )
44
47
 
45
48
 
@@ -367,8 +370,8 @@ def _as_contig_f32(t, device):
367
370
 
368
371
 
369
372
  @_on_device_of("sinogram")
370
- def parallel_weighted_backproject(sinogram, ray_dir, det_origin, det_u_vec,
371
- detector_spacing, H, W, voxel_spacing=1.0):
373
+ def _parallel_weighted_backproject_raw(sinogram, ray_dir, det_origin, det_u_vec,
374
+ detector_spacing, H, W, voxel_spacing=1.0):
372
375
  """Voxel-driven parallel-beam FBP backprojection with analytical constant.
373
376
 
374
377
  Runs the dedicated parallel-beam FBP gather kernel (one thread per
@@ -451,8 +454,8 @@ def _analytical_geometry(src_pos, det_center, n, isocenter):
451
454
 
452
455
 
453
456
  @_on_device_of("sinogram")
454
- def fan_weighted_backproject(sinogram, src_pos, det_center, det_u_vec,
455
- detector_spacing, H, W, voxel_spacing=1.0, *, isocenter=None):
457
+ def _fan_weighted_backproject_raw(sinogram, src_pos, det_center, det_u_vec,
458
+ detector_spacing, H, W, voxel_spacing=1.0, *, isocenter=None):
456
459
  """Voxel-driven fan-beam FBP backprojection with analytical constant.
457
460
 
458
461
  Runs the dedicated fan-beam FBP gather kernel (one thread per output
@@ -507,8 +510,8 @@ def fan_weighted_backproject(sinogram, src_pos, det_center, det_u_vec,
507
510
 
508
511
 
509
512
  @_on_device_of("sinogram")
510
- def cone_weighted_backproject(sinogram, src_pos, det_center, det_u_vec, det_v_vec,
511
- D, H, W, du, dv, voxel_spacing=1.0, *, isocenter=None):
513
+ def _cone_weighted_backproject_raw(sinogram, src_pos, det_center, det_u_vec, det_v_vec,
514
+ D, H, W, du, dv, voxel_spacing=1.0, *, isocenter=None):
512
515
  """Voxel-driven cone-beam FDK backprojection with analytical constant.
513
516
 
514
517
  Runs the dedicated cone-beam FDK gather kernel (one thread per output
@@ -571,3 +574,145 @@ def cone_weighted_backproject(sinogram, src_pos, det_center, det_u_vec, det_v_ve
571
574
  # Convert back to (D, H, W) layout for the user.
572
575
  reco = reco_perm.permute(2, 1, 0).contiguous() * scale
573
576
  return reco
577
+
578
+
579
+ def _validate_analytical_inputs(sinogram, geometry, ndim, isocenter=None):
580
+ if not isinstance(sinogram, torch.Tensor) or sinogram.ndim != ndim:
581
+ raise ValueError(f"sinogram must have rank {ndim}")
582
+ if min(sinogram.shape[1:]) < 2:
583
+ raise ValueError("Analytical backprojection requires at least two detector bins on each axis")
584
+ for component in geometry:
585
+ if not isinstance(component, torch.Tensor) or component.shape != (sinogram.shape[0], ndim):
586
+ raise ValueError(f"Every geometry tensor must have shape (n_views, {ndim})")
587
+ if component.requires_grad:
588
+ raise ValueError("Analytical backprojection does not support geometry gradients")
589
+ if isinstance(isocenter, torch.Tensor) and isocenter.requires_grad:
590
+ raise ValueError("Analytical backprojection does not support isocenter gradients")
591
+ if not sinogram.is_cuda:
592
+ raise ValueError("sinogram must be on a CUDA device")
593
+
594
+
595
+ def _analytical_gather_raw(sinogram, beam, geometry, parameters, isocenter):
596
+ if beam == "parallel":
597
+ return _parallel_weighted_backproject_raw(sinogram, *geometry, *parameters)
598
+ if beam == "fan":
599
+ return _fan_weighted_backproject_raw(sinogram, *geometry, *parameters, isocenter=isocenter)
600
+ return _cone_weighted_backproject_raw(sinogram, *geometry, *parameters, isocenter=isocenter)
601
+
602
+
603
+ @_on_device_of("image")
604
+ def _analytical_transpose_raw(image, beam, geometry, parameters, isocenter, sinogram_shape):
605
+ device = image.device
606
+ geometry = tuple(_as_contig_f32(component, device) for component in geometry)
607
+ image = _as_contig_f32(image, device)
608
+ sinogram = torch.zeros(sinogram_shape, device=device, dtype=torch.float32)
609
+ d_sino = TorchCUDABridge.tensor_to_cuda_array(sinogram)
610
+ d_geometry = tuple(TorchCUDABridge.tensor_to_cuda_array(component) for component in geometry)
611
+ stream = _get_numba_external_stream_for(torch.cuda.current_stream())
612
+
613
+ scale = 1.0 / (2.0 * math.pi)
614
+ iso = ()
615
+ if beam != "parallel":
616
+ source, detector, u = geometry[:3]
617
+ normal = (torch.stack((-u[:, 1], u[:, 0]), dim=1) if beam == "fan"
618
+ else torch.cross(u, geometry[3], dim=1))
619
+ isocenter, sid_mean, sdd_mean = _analytical_geometry(source, detector, normal, isocenter)
620
+ scale = sdd_mean / (2.0 * math.pi * sid_mean)
621
+ iso = tuple(_DTYPE(value.item() / parameters[-1]) for value in isocenter)
622
+ image = image * scale
623
+
624
+ if beam == "cone":
625
+ D, H, W, du, dv, spacing = parameters
626
+ image = image.permute(2, 1, 0).contiguous()
627
+ d_image = TorchCUDABridge.tensor_to_cuda_array(image)
628
+ grid, tpb = _grid_3d(D, H, W)
629
+ _cone_3d_fdk_transpose_kernel[grid, tpb, stream](
630
+ d_sino, *sinogram_shape, d_image, W, H, D, _DTYPE(du), _DTYPE(dv),
631
+ *d_geometry, _DTYPE(W * 0.5), _DTYPE(H * 0.5), _DTYPE(D * 0.5),
632
+ _DTYPE(spacing), *iso,
633
+ )
634
+ else:
635
+ pitch, H, W, spacing = parameters
636
+ d_image = TorchCUDABridge.tensor_to_cuda_array(image)
637
+ grid, tpb = _grid_2d(W, H)
638
+ kernel = (_parallel_2d_fbp_transpose_kernel if beam == "parallel"
639
+ else _fan_2d_fbp_transpose_kernel)
640
+ kernel[grid, tpb, stream](
641
+ d_sino, *sinogram_shape, d_image, W, H, _DTYPE(pitch), *d_geometry,
642
+ _DTYPE(W * 0.5), _DTYPE(H * 0.5), _DTYPE(spacing), *iso,
643
+ )
644
+ return sinogram
645
+
646
+
647
+ class _AnalyticalGather(torch.autograd.Function):
648
+ @staticmethod
649
+ def forward(ctx, sinogram, beam, geometry, parameters, isocenter):
650
+ # Backward uses the exact geometry observed by forward, even if the
651
+ # caller edits its fixed trajectory or isocenter afterwards.
652
+ geometry = tuple(component.detach().clone() for component in geometry)
653
+ if isocenter is not None:
654
+ isocenter = torch.as_tensor(isocenter, device=sinogram.device,
655
+ dtype=torch.float32).detach().clone()
656
+ ctx.save_for_backward(*geometry, isocenter)
657
+ ctx.beam, ctx.parameters = beam, parameters
658
+ ctx.input_shape, ctx.input_dtype, ctx.input_device = sinogram.shape, sinogram.dtype, sinogram.device
659
+ return _analytical_gather_raw(sinogram, beam, geometry, parameters, isocenter)
660
+
661
+ @staticmethod
662
+ def backward(ctx, grad_output):
663
+ *geometry, isocenter = ctx.saved_tensors
664
+ grad_input = _AnalyticalTranspose.apply(
665
+ grad_output, ctx.beam, tuple(geometry), ctx.parameters, isocenter, ctx.input_shape,
666
+ ).to(device=ctx.input_device, dtype=ctx.input_dtype)
667
+ return grad_input, None, None, None, None
668
+
669
+
670
+ class _AnalyticalTranspose(torch.autograd.Function):
671
+ @staticmethod
672
+ def forward(ctx, image, beam, geometry, parameters, isocenter, sinogram_shape):
673
+ ctx.beam, ctx.geometry, ctx.parameters, ctx.isocenter = beam, geometry, parameters, isocenter
674
+ ctx.input_dtype, ctx.input_device = image.dtype, image.device
675
+ return _analytical_transpose_raw(image, beam, geometry, parameters, isocenter, sinogram_shape)
676
+
677
+ @staticmethod
678
+ def backward(ctx, grad_output):
679
+ grad_input = _AnalyticalGather.apply(
680
+ grad_output, ctx.beam, ctx.geometry, ctx.parameters, ctx.isocenter,
681
+ ).to(device=ctx.input_device, dtype=ctx.input_dtype)
682
+ return grad_input, None, None, None, None, None
683
+
684
+
685
+ def _analytical_backproject(sinogram, beam, geometry, parameters, isocenter=None):
686
+ _validate_analytical_inputs(sinogram, geometry, 3 if beam == "cone" else 2, isocenter)
687
+ if torch.is_grad_enabled() and sinogram.requires_grad:
688
+ return _AnalyticalGather.apply(sinogram, beam, geometry, parameters, isocenter)
689
+ return _analytical_gather_raw(sinogram, beam, geometry, parameters, isocenter)
690
+
691
+
692
+ def parallel_weighted_backproject(sinogram, ray_dir, det_origin, det_u_vec,
693
+ detector_spacing, H, W, voxel_spacing=1.0):
694
+ return _analytical_backproject(
695
+ sinogram, "parallel", (ray_dir, det_origin, det_u_vec),
696
+ (detector_spacing, H, W, voxel_spacing),
697
+ )
698
+
699
+
700
+ def fan_weighted_backproject(sinogram, src_pos, det_center, det_u_vec,
701
+ detector_spacing, H, W, voxel_spacing=1.0, *, isocenter=None):
702
+ return _analytical_backproject(
703
+ sinogram, "fan", (src_pos, det_center, det_u_vec),
704
+ (detector_spacing, H, W, voxel_spacing), isocenter,
705
+ )
706
+
707
+
708
+ def cone_weighted_backproject(sinogram, src_pos, det_center, det_u_vec, det_v_vec,
709
+ D, H, W, du, dv, voxel_spacing=1.0, *, isocenter=None):
710
+ return _analytical_backproject(
711
+ sinogram, "cone", (src_pos, det_center, det_u_vec, det_v_vec),
712
+ (D, H, W, du, dv, voxel_spacing), isocenter,
713
+ )
714
+
715
+
716
+ parallel_weighted_backproject.__doc__ = _parallel_weighted_backproject_raw.__doc__
717
+ fan_weighted_backproject.__doc__ = _fan_weighted_backproject_raw.__doc__
718
+ cone_weighted_backproject.__doc__ = _cone_weighted_backproject_raw.__doc__
@@ -26,6 +26,11 @@ from .geometry_vjp import (
26
26
  _fan_2d_geometry_vjp_kernel,
27
27
  _cone_3d_geometry_vjp_kernel,
28
28
  )
29
+ from .analytical_transpose import (
30
+ _parallel_2d_fbp_transpose_kernel,
31
+ _fan_2d_fbp_transpose_kernel,
32
+ _cone_3d_fdk_transpose_kernel,
33
+ )
29
34
 
30
35
  __all__ = [
31
36
  '_parallel_2d_forward_kernel',
@@ -40,4 +45,7 @@ __all__ = [
40
45
  '_parallel_2d_geometry_vjp_kernel',
41
46
  '_fan_2d_geometry_vjp_kernel',
42
47
  '_cone_3d_geometry_vjp_kernel',
48
+ '_parallel_2d_fbp_transpose_kernel',
49
+ '_fan_2d_fbp_transpose_kernel',
50
+ '_cone_3d_fdk_transpose_kernel',
43
51
  ]
@@ -0,0 +1,295 @@
1
+ """Discrete transposes of the analytical FBP/FDK gather kernels.
2
+
3
+ Geometry arithmetic and interpolation bounds mirror the gather kernels;
4
+ only the final sample operation changes to an atomic detector-bin scatter.
5
+ """
6
+
7
+ import math
8
+
9
+ import numpy as np
10
+ from numba import cuda
11
+
12
+ from ..constants import _EPSILON, _FDK_ACCURACY_DECORATOR, _HALF, _ONE, _ZERO
13
+
14
+
15
+ @_FDK_ACCURACY_DECORATOR
16
+ def _parallel_2d_fbp_transpose_kernel(
17
+ d_sino, n_views, n_det,
18
+ d_image, Nx, Ny,
19
+ det_spacing, d_ray_dir, d_det_origin, d_det_u_vec,
20
+ cx, cy, voxel_spacing
21
+ ):
22
+ """Scatter image cotangents through the parallel analytical gather stencil."""
23
+ ix, iy = cuda.grid(2)
24
+ if ix >= Nx or iy >= Ny:
25
+ return
26
+ if n_det < 2:
27
+ return
28
+
29
+ # Sample at voxel centres, matching the cell-constant Siddon projector.
30
+ x_v = np.float32(ix) + _HALF - cx
31
+ y_v = np.float32(iy) + _HALF - cy
32
+
33
+ det_spacing_v = det_spacing / voxel_spacing
34
+ half_u = (np.float32(n_det) - _ONE) * _HALF
35
+
36
+ for iview in range(n_views):
37
+ dox = d_det_origin[iview, 0] / voxel_spacing
38
+ doy = d_det_origin[iview, 1] / voxel_spacing
39
+
40
+ ux = d_det_u_vec[iview, 0]
41
+ uy = d_det_u_vec[iview, 1]
42
+
43
+ rx = x_v - dox
44
+ ry = y_v - doy
45
+
46
+ u_det = rx * ux + ry * uy
47
+
48
+ fu = u_det / det_spacing_v + half_u
49
+ if fu < _ZERO or fu > (np.float32(n_det) - _ONE):
50
+ continue
51
+
52
+ iu0 = int(math.floor(fu))
53
+ if iu0 >= n_det - 1:
54
+ iu0 = n_det - 2
55
+ if iu0 < 0:
56
+ iu0 = 0
57
+ tu = fu - np.float32(iu0)
58
+ if tu < _ZERO:
59
+ tu = _ZERO
60
+ elif tu > _ONE:
61
+ tu = _ONE
62
+
63
+ value = d_image[iy, ix]
64
+ cuda.atomic.add(d_sino, (iview, iu0), value * (_ONE - tu))
65
+ cuda.atomic.add(d_sino, (iview, iu0 + 1), value * tu)
66
+
67
+
68
+ @_FDK_ACCURACY_DECORATOR
69
+ def _fan_2d_fbp_transpose_kernel(
70
+ d_sino, n_views, n_det,
71
+ d_image, Nx, Ny,
72
+ det_spacing, d_src_pos, d_det_center, d_det_u_vec,
73
+ cx, cy, voxel_spacing, iso_x, iso_y
74
+ ):
75
+ """Scatter image cotangents through the fan analytical gather stencil."""
76
+ ix, iy = cuda.grid(2)
77
+ if ix >= Nx or iy >= Ny:
78
+ return
79
+ if n_det < 2:
80
+ return
81
+
82
+ # Sample at voxel centres, matching the cell-constant Siddon projector.
83
+ x_v = np.float32(ix) + _HALF - cx
84
+ y_v = np.float32(iy) + _HALF - cy
85
+
86
+ det_spacing_v = det_spacing / voxel_spacing
87
+ half_u = (np.float32(n_det) - _ONE) * _HALF
88
+
89
+ for iview in range(n_views):
90
+ sx = d_src_pos[iview, 0] / voxel_spacing
91
+ sy = d_src_pos[iview, 1] / voxel_spacing
92
+
93
+ dcx = d_det_center[iview, 0] / voxel_spacing
94
+ dcy = d_det_center[iview, 1] / voxel_spacing
95
+
96
+ ux = d_det_u_vec[iview, 0]
97
+ uy = d_det_u_vec[iview, 1]
98
+
99
+ # Detector normal in 2D: rotate u_vec 90 degrees. Choose the
100
+ # orientation that points from the source toward the detector,
101
+ # so that ``U_n`` is positive for voxels on the detector side.
102
+ nx = -uy
103
+ ny = ux
104
+ # Align normal so that (det_center - src) . n > 0.
105
+ align = (dcx - sx) * nx + (dcy - sy) * ny
106
+ if align < _ZERO:
107
+ nx = -nx
108
+ ny = -ny
109
+
110
+ # Perpendicular distance from source to voxel along normal.
111
+ px = x_v - sx
112
+ py = y_v - sy
113
+ U_n = px * nx + py * ny
114
+ if U_n <= _EPSILON:
115
+ continue
116
+
117
+ sdd_n = (dcx - sx) * nx + (dcy - sy) * ny
118
+ if sdd_n <= _EPSILON:
119
+ continue
120
+ mag = sdd_n / U_n
121
+
122
+ # Source-to-isocenter distance, independent of coordinate origin.
123
+ sid_n = (iso_x - sx) * nx + (iso_y - sy) * ny
124
+ if sid_n <= _EPSILON:
125
+ continue
126
+
127
+ hx = sx + mag * px
128
+ hy = sy + mag * py
129
+
130
+ rx = hx - dcx
131
+ ry = hy - dcy
132
+ u_det = rx * ux + ry * uy
133
+
134
+ fu = u_det / det_spacing_v + half_u
135
+ if fu < _ZERO or fu > (np.float32(n_det) - _ONE):
136
+ continue
137
+
138
+ iu0 = int(math.floor(fu))
139
+ if iu0 >= n_det - 1:
140
+ iu0 = n_det - 2
141
+ if iu0 < 0:
142
+ iu0 = 0
143
+ tu = fu - np.float32(iu0)
144
+ if tu < _ZERO:
145
+ tu = _ZERO
146
+ elif tu > _ONE:
147
+ tu = _ONE
148
+
149
+ w_ratio = sid_n / U_n
150
+ w = w_ratio * w_ratio
151
+ value = d_image[iy, ix] * w
152
+ cuda.atomic.add(d_sino, (iview, iu0), value * (_ONE - tu))
153
+ cuda.atomic.add(d_sino, (iview, iu0 + 1), value * tu)
154
+
155
+
156
+ @_FDK_ACCURACY_DECORATOR
157
+ def _cone_3d_fdk_transpose_kernel(
158
+ d_sino, n_views, n_u, n_v,
159
+ d_vol, Nx, Ny, Nz,
160
+ du, dv, d_src_pos, d_det_center, d_det_u_vec, d_det_v_vec,
161
+ cx, cy, cz, voxel_spacing, iso_x, iso_y, iso_z
162
+ ):
163
+ """Scatter image cotangents through the cone analytical gather stencil."""
164
+ iz, iy, ix = cuda.grid(3)
165
+ if ix >= Nx or iy >= Ny or iz >= Nz:
166
+ return
167
+ if n_u < 2 or n_v < 2:
168
+ return
169
+
170
+ # Voxel position in voxel-unit, origin-centred coordinates.
171
+ # Sample at voxel centres, matching the cell-constant Siddon projector.
172
+ x_v = np.float32(ix) + _HALF - cx
173
+ y_v = np.float32(iy) + _HALF - cy
174
+ z_v = np.float32(iz) + _HALF - cz
175
+
176
+ du_v = du / voxel_spacing
177
+ dv_v = dv / voxel_spacing
178
+
179
+ half_u = (np.float32(n_u) - _ONE) * _HALF
180
+ half_v = (np.float32(n_v) - _ONE) * _HALF
181
+
182
+ for iview in range(n_views):
183
+ # Source position in voxel units.
184
+ sx = d_src_pos[iview, 0] / voxel_spacing
185
+ sy = d_src_pos[iview, 1] / voxel_spacing
186
+ sz = d_src_pos[iview, 2] / voxel_spacing
187
+
188
+ # Detector centre in voxel units.
189
+ dcx = d_det_center[iview, 0] / voxel_spacing
190
+ dcy = d_det_center[iview, 1] / voxel_spacing
191
+ dcz = d_det_center[iview, 2] / voxel_spacing
192
+
193
+ # Detector orientation (unit vectors, ratio-free).
194
+ ux = d_det_u_vec[iview, 0]
195
+ uy = d_det_u_vec[iview, 1]
196
+ uz = d_det_u_vec[iview, 2]
197
+ vx = d_det_v_vec[iview, 0]
198
+ vy = d_det_v_vec[iview, 1]
199
+ vz = d_det_v_vec[iview, 2]
200
+
201
+ # Detector normal n = u x v. Normalized defensively in case the
202
+ # caller supplied non-orthonormal u/v axes.
203
+ nx = uy * vz - uz * vy
204
+ ny = uz * vx - ux * vz
205
+ nz = ux * vy - uy * vx
206
+ n_len = math.sqrt(nx * nx + ny * ny + nz * nz)
207
+ if n_len < _EPSILON:
208
+ continue
209
+ nx /= n_len
210
+ ny /= n_len
211
+ nz /= n_len
212
+
213
+ # Orient the normal so it points from the source toward the
214
+ # detector. Without this flip, user-supplied (u_vec, v_vec) pairs
215
+ # whose cross product happens to point the other way would make
216
+ # every voxel's U_n negative and get silently skipped, producing
217
+ # a zero reconstruction. The circular_trajectory_3d helper already
218
+ # produces the right-handed convention so this is a no-op there.
219
+ align = (dcx - sx) * nx + (dcy - sy) * ny + (dcz - sz) * nz
220
+ if align < _ZERO:
221
+ nx = -nx
222
+ ny = -ny
223
+ nz = -nz
224
+
225
+ # Signed distance from source to voxel along the detector normal
226
+ # (this is the U in the (sid/U)^2 FDK weight).
227
+ px = x_v - sx
228
+ py = y_v - sy
229
+ pz = z_v - sz
230
+ U_n = px * nx + py * ny + pz * nz
231
+ if U_n <= _EPSILON:
232
+ continue
233
+
234
+ # Source-to-detector distance along the normal.
235
+ sdd_n = (dcx - sx) * nx + (dcy - sy) * ny + (dcz - sz) * nz
236
+ if sdd_n <= _EPSILON:
237
+ continue
238
+
239
+ # Source-to-isocenter distance, independent of coordinate origin.
240
+ sid_n = (iso_x - sx) * nx + (iso_y - sy) * ny + (iso_z - sz) * nz
241
+ if sid_n <= _EPSILON:
242
+ continue
243
+
244
+ mag = sdd_n / U_n
245
+
246
+ # Hit point on the detector plane.
247
+ hx = sx + mag * px
248
+ hy = sy + mag * py
249
+ hz = sz + mag * pz
250
+
251
+ # Detector coordinates relative to detector centre.
252
+ rx = hx - dcx
253
+ ry = hy - dcy
254
+ rz = hz - dcz
255
+
256
+ u_det = rx * ux + ry * uy + rz * uz
257
+ v_det = rx * vx + ry * vy + rz * vz
258
+
259
+ # Convert (u, v) to fractional bin index.
260
+ fu = u_det / du_v + half_u
261
+ fv = v_det / dv_v + half_v
262
+
263
+ if fu < _ZERO or fu > (np.float32(n_u) - _ONE) or fv < _ZERO or fv > (np.float32(n_v) - _ONE):
264
+ continue
265
+
266
+ iu0 = int(math.floor(fu))
267
+ iv0 = int(math.floor(fv))
268
+ if iu0 >= n_u - 1:
269
+ iu0 = n_u - 2
270
+ if iv0 >= n_v - 1:
271
+ iv0 = n_v - 2
272
+ if iu0 < 0:
273
+ iu0 = 0
274
+ if iv0 < 0:
275
+ iv0 = 0
276
+ tu = fu - np.float32(iu0)
277
+ tv = fv - np.float32(iv0)
278
+ if tu < _ZERO:
279
+ tu = _ZERO
280
+ elif tu > _ONE:
281
+ tu = _ONE
282
+ if tv < _ZERO:
283
+ tv = _ZERO
284
+ elif tv > _ONE:
285
+ tv = _ONE
286
+
287
+ omtu = _ONE - tu
288
+ omtv = _ONE - tv
289
+ w_ratio = sid_n / U_n
290
+ w = w_ratio * w_ratio
291
+ value = d_vol[ix, iy, iz] * w
292
+ cuda.atomic.add(d_sino, (iview, iu0, iv0), value * omtu * omtv)
293
+ cuda.atomic.add(d_sino, (iview, iu0 + 1, iv0), value * tu * omtv)
294
+ cuda.atomic.add(d_sino, (iview, iu0, iv0 + 1), value * omtu * tv)
295
+ cuda.atomic.add(d_sino, (iview, iu0 + 1, iv0 + 1), value * tu * tv)
@@ -0,0 +1,399 @@
1
+ """Composable analytical reconstruction APIs for fixed, flat-detector geometry."""
2
+
3
+ import math
4
+ import numbers
5
+
6
+ import torch
7
+ import torch.nn.functional as F
8
+
9
+ from .analytical import (
10
+ _analytical_geometry, angular_integration_weights, cone_weighted_backproject,
11
+ fan_weighted_backproject, parallel_weighted_backproject,
12
+ parker_weights, ramp_filter_1d,
13
+ )
14
+ from .operators import _detector_spacing, _positive_float, _shape, _validate_trajectory
15
+
16
+
17
+ def _fixed(value, name):
18
+ if isinstance(value, torch.Tensor) and value.requires_grad:
19
+ raise ValueError(f"{name} gradients are not supported by analytical reconstruction")
20
+
21
+
22
+ def _prepare(sinogram, trajectory, volume_shape, beam, detector_spacing, voxel_spacing, isocenter):
23
+ if not isinstance(sinogram, torch.Tensor) or not sinogram.is_cuda:
24
+ raise ValueError("Analytical reconstruction requires a CUDA sinogram tensor")
25
+ if not torch.is_floating_point(sinogram):
26
+ raise ValueError("sinogram must have a real floating-point dtype")
27
+ # FFT and geometric checks need at least float32 precision. The native
28
+ # cast keeps gradients connected to low-precision input/parameter leaves.
29
+ if sinogram.dtype not in (torch.float32, torch.float64):
30
+ sinogram = sinogram.float()
31
+ rank = 3 if beam == "cone" else 2
32
+ if sinogram.ndim != rank or min(sinogram.shape[1:]) < 2:
33
+ raise ValueError("sinogram rank must match the beam and detector axes need at least two bins")
34
+ for component in trajectory:
35
+ _fixed(component, "trajectory")
36
+ _fixed(isocenter, "isocenter")
37
+ geometry, views = _validate_trajectory(trajectory, beam)
38
+ if sinogram.shape[0] != views:
39
+ raise ValueError("sinogram and trajectory must have the same number of views")
40
+ shape = _shape(volume_shape, rank, "volume_shape")
41
+ pitch = _detector_spacing(detector_spacing, beam)
42
+ spacing = _positive_float(voxel_spacing, "voxel_spacing")
43
+ geometry = tuple(t.to(device=sinogram.device, dtype=sinogram.dtype) for t in geometry)
44
+ return sinogram, geometry, shape, pitch, spacing
45
+
46
+
47
+ def _angles(angles, views, device):
48
+ _fixed(angles, "angles")
49
+ value = torch.as_tensor(angles, device=device)
50
+ if not torch.is_floating_point(value) or value.shape != (views,) or not torch.isfinite(value).all():
51
+ raise ValueError("angles must be a finite real vector with one unwrapped radian angle per view")
52
+ return value
53
+
54
+
55
+ def _weights(value, sinogram, name, shape=None):
56
+ value = torch.as_tensor(value, device=sinogram.device, dtype=sinogram.dtype)
57
+ if not torch.isfinite(value).all():
58
+ raise ValueError(f"{name} must contain finite values")
59
+ if shape is not None:
60
+ if value.shape != shape:
61
+ raise ValueError(f"{name} must have shape {shape}")
62
+ else:
63
+ try:
64
+ if torch.broadcast_shapes(value.shape, sinogram.shape) != sinogram.shape:
65
+ raise ValueError
66
+ except (RuntimeError, ValueError) as error:
67
+ raise ValueError(f"{name} must broadcast to the sinogram shape") from error
68
+ return value
69
+
70
+
71
+ def _cosine(sinogram, geometry, pitch):
72
+ """Detector-normal cosine from actual per-view rays, including offsets."""
73
+ source, detector, axis_u = geometry[:3]
74
+ vector = detector - source
75
+ if sinogram.ndim == 2:
76
+ normal = torch.stack((-axis_u[:, 1], axis_u[:, 0]), dim=1)
77
+ else:
78
+ normal = torch.linalg.cross(axis_u, geometry[3], dim=1)
79
+ normal = F.normalize(normal, dim=1)
80
+ distance = (vector * normal).sum(1).abs()
81
+ u = (torch.arange(sinogram.shape[1], device=sinogram.device, dtype=sinogram.dtype)
82
+ - (sinogram.shape[1] - 1) / 2) * pitch[0]
83
+ offset_u = (vector * axis_u).sum(1)
84
+ u = u[None, :] + offset_u[:, None]
85
+ if sinogram.ndim == 2:
86
+ return distance[:, None] / torch.sqrt(distance[:, None].square() + u.square())
87
+ v = (torch.arange(sinogram.shape[2], device=sinogram.device, dtype=sinogram.dtype)
88
+ - (sinogram.shape[2] - 1) / 2) * pitch[1]
89
+ offset_v = (vector * geometry[3]).sum(1)
90
+ v = v[None, :] + offset_v[:, None]
91
+ return distance[:, None, None] / torch.sqrt(
92
+ distance[:, None, None].square() + u[:, :, None].square() + v[:, None, :].square())
93
+
94
+
95
+ def _filter(sinogram, pitch, window, pad_factor, response):
96
+ if isinstance(pad_factor, bool) or not isinstance(pad_factor, numbers.Integral) or pad_factor < 2:
97
+ raise ValueError("pad_factor must be an integer >=2 for analytical reconstruction")
98
+ if response is None:
99
+ return ramp_filter_1d(sinogram, dim=1, sample_spacing=pitch,
100
+ pad_factor=pad_factor, window=window)
101
+ n_u = sinogram.shape[1]
102
+ fft_size = pad_factor * n_u
103
+ response = _weights(response, sinogram, "filter_response", (fft_size // 2 + 1,))
104
+ left = (fft_size - n_u) // 2
105
+ right = fft_size - n_u - left
106
+ padded = F.pad(sinogram, (0, 0, left, right) if sinogram.ndim == 3 else (left, right))
107
+ broadcast = (1, len(response)) + (1,) * (sinogram.ndim - 2)
108
+ filtered = torch.fft.irfft(torch.fft.rfft(padded, dim=1) * response.reshape(broadcast),
109
+ n=fft_size, dim=1)
110
+ return filtered.narrow(1, left, n_u)
111
+
112
+
113
+ def _parker(angles, sinogram, geometry, pitch, isocenter, geometry_eps):
114
+ source, detector, u = geometry[:3]
115
+ vector = detector - source
116
+ normal = (torch.stack((-u[:, 1], u[:, 0]), dim=1) if sinogram.ndim == 2
117
+ else torch.linalg.cross(u, geometry[3], dim=1))
118
+ normal = F.normalize(normal, dim=1)
119
+ normal = torch.where(((vector * normal).sum(1) < 0)[:, None], -normal, normal)
120
+ distance = (vector * normal).sum(1).abs()
121
+ offset = (vector * u).sum(1)
122
+ # Preserve each input's quantization after _prepare promotes the geometry.
123
+ # Eight eps covers coordinate rounding, subtraction, projection and a
124
+ # comparison between two views; its physical scale also covers translation.
125
+ coordinate_scale = float(torch.linalg.vector_norm(
126
+ source.double().abs() + detector.double().abs(), dim=1).max())
127
+ arithmetic_eps = torch.finfo(source.dtype).eps
128
+ # V participates in the cone normal/distance, but never the U-offset dot.
129
+ distance_precision = max((arithmetic_eps, *geometry_eps))
130
+ offset_precision = max((arithmetic_eps, *geometry_eps[:3]))
131
+ distance_tolerance = max(1e-5, 8 * distance_precision * coordinate_scale)
132
+ offset_tolerance = max(1e-5, 8 * offset_precision * coordinate_scale)
133
+ if not (torch.allclose(distance, distance[:1].expand_as(distance), rtol=1e-4, atol=distance_tolerance)
134
+ and torch.allclose(offset, offset[:1].expand_as(offset), rtol=1e-4, atol=offset_tolerance)):
135
+ raise ValueError("Parker short-scan weighting requires constant detector distance and offset")
136
+ sdd, offset_u = float(distance[0]), float(offset[0])
137
+ gamma_max = math.atan(((sinogram.shape[1] - 1) * pitch[0] / 2 + abs(offset_u)) / sdd)
138
+ span = float(angles.max() - angles.min())
139
+ if span < math.pi + 2 * gamma_max - 1e-5:
140
+ raise ValueError("short scan must cover at least pi + twice the maximum fan angle")
141
+ if not (torch.all(angles[1:] > angles[:-1]) or torch.all(angles[1:] < angles[:-1])):
142
+ raise ValueError("Parker short-scan angles must be strictly monotone")
143
+
144
+ center, _, _ = _analytical_geometry(source, detector, normal, isocenter)
145
+ radial = (source - center).double()
146
+ radius = torch.linalg.vector_norm(radial, dim=1)
147
+ if (not torch.allclose(radius, radius[:1].expand_as(radius), rtol=1e-4, atol=1e-5)
148
+ or not torch.allclose(normal.double(), -F.normalize(radial, dim=1), rtol=1e-4, atol=1e-5)):
149
+ raise ValueError("Parker weighting requires a circular orbit with radial detector normals")
150
+ delta = angles.double() - angles[0].double()
151
+ cosine, sine = delta.cos(), delta.sin()
152
+ index = int(sine.abs().argmax())
153
+ if abs(float(sine[index])) < 1e-4:
154
+ raise ValueError("Parker weighting needs circular views that define the orbit orientation")
155
+ tangent = (radial[index] - radial[0] * cosine[index]) / sine[index]
156
+ predicted = radial[0] * cosine[:, None] + tangent * sine[:, None]
157
+ if (not torch.allclose(predicted, radial, rtol=1e-4, atol=1e-5)
158
+ or not torch.allclose(tangent.norm(), radius[0], rtol=1e-4, atol=1e-5)):
159
+ raise ValueError("Parker weighting requires angles matching a circular orbit")
160
+ # The detector u axis points opposite the source tangent for the scalar
161
+ # Parker helper's convention. Derive its sign from the actual orbit, so a
162
+ # detector-axis mirror plus a data-bin mirror leaves reconstruction intact.
163
+ expected_u = F.normalize(radial[0] * sine[:, None] - tangent * cosine[:, None], dim=1)
164
+ handedness = (expected_u * u.double()).sum(1)
165
+ if not torch.allclose(handedness.abs(), torch.ones_like(handedness), rtol=1e-4, atol=1e-5):
166
+ raise ValueError("Parker weighting requires detector u tangent to the circular orbit")
167
+ if not (torch.all(handedness > 0) or torch.all(handedness < 0)):
168
+ raise ValueError("Parker weighting requires consistent detector-u orientation")
169
+ direction = 1 if angles[-1] > angles[0] else -1
170
+ fan_sign = direction * (1 if handedness[0] > 0 else -1)
171
+ weights = parker_weights(delta * direction, sinogram.shape[1], pitch[0], sdd,
172
+ detector_offset=fan_sign * offset_u)
173
+ if fan_sign < 0:
174
+ weights = weights.flip(1)
175
+ return weights[..., None] if sinogram.ndim == 3 else weights
176
+
177
+
178
+ def _pipeline(sinogram, geometry, angles, beam, pitch, window, pad_factor,
179
+ projection_weights, angular_weights, filter_response, short_scan, isocenter,
180
+ geometry_eps):
181
+ if short_scan and beam == "parallel":
182
+ raise ValueError("Parker short-scan weights apply to fan or cone beams, not parallel")
183
+ if projection_weights is None:
184
+ weighted = sinogram if beam == "parallel" else sinogram * _cosine(sinogram, geometry, pitch)
185
+ else:
186
+ weighted = sinogram * _weights(projection_weights, sinogram, "projection_weights")
187
+ if short_scan:
188
+ weighted = weighted * _parker(angles, sinogram, geometry, pitch, isocenter, geometry_eps)
189
+ filtered = _filter(weighted, pitch[0], window, pad_factor, filter_response)
190
+ relative_angles = angles.double() - angles[0].double()
191
+ angular = (angular_integration_weights(relative_angles) if angular_weights is None
192
+ else _weights(angular_weights, sinogram, "angular_weights", (len(angles),)))
193
+ return filtered * angular.reshape((len(angles),) + (1,) * (sinogram.ndim - 1))
194
+
195
+
196
+ def fbp(sinogram, trajectory, volume_shape, angles, *, beam="parallel",
197
+ detector_spacing=1.0, voxel_spacing=1.0, window="ram-lak", pad_factor=2,
198
+ projection_weights=None, angular_weights=None, filter_response=None,
199
+ short_scan=False, isocenter=None):
200
+ """Differentiable parallel/fan FBP for fixed flat-detector geometry.
201
+
202
+ ``sinogram`` is a CUDA tensor ``(views, bins)``; ``volume_shape`` is ``(H,W)``.
203
+ ``angles`` are explicit unwrapped radians. Detector/voxel spacings share the
204
+ trajectory's physical unit. ``projection_weights`` and ``angular_weights``
205
+ replace the default cosine and angular quadrature, respectively.
206
+ ``filter_response`` replaces the complete real rFFT multiplier, including
207
+ its physical units; length is ``pad_factor*bins//2+1``. Its use ignores
208
+ ``window``. These tensors retain autograd. ``short_scan=True`` adds Parker
209
+ weighting before filtering for a circular fan scan with sufficient coverage.
210
+ Low-precision inputs are promoted to float32 for reconstruction, retaining
211
+ gradients to their original dtype. Output is signed float32; geometry
212
+ gradients are rejected.
213
+ """
214
+ if beam not in ("parallel", "fan"):
215
+ raise ValueError("fbp beam must be 'parallel' or 'fan'; use fdk for cone data")
216
+ sinogram, geometry, shape, pitch, spacing = _prepare(
217
+ sinogram, trajectory, volume_shape, beam, detector_spacing, voxel_spacing, isocenter)
218
+ angles = _angles(angles, len(geometry[0]), sinogram.device)
219
+ filtered = _pipeline(sinogram, geometry, angles, beam, pitch, window, pad_factor,
220
+ projection_weights, angular_weights, filter_response, short_scan, isocenter,
221
+ tuple(torch.finfo(component.dtype).eps for component in trajectory))
222
+ if beam == "parallel":
223
+ return parallel_weighted_backproject(filtered, *geometry, pitch[0], *shape, spacing)
224
+ return fan_weighted_backproject(filtered, *geometry, pitch[0], *shape, spacing, isocenter=isocenter)
225
+
226
+
227
+ def fdk(sinogram, trajectory, volume_shape, angles, *, detector_spacing=1.0,
228
+ voxel_spacing=1.0, window="ram-lak", pad_factor=2,
229
+ projection_weights=None, angular_weights=None, filter_response=None,
230
+ short_scan=False, isocenter=None):
231
+ """Differentiable flat-detector FDK; fixed geometry, approximate inversion.
232
+
233
+ ``sinogram`` is ``(views,U,V)`` on CUDA; ``volume_shape`` is ``(D,H,W)``.
234
+ Filtering runs along U. ``detector_spacing`` is a scalar or ``(du,dv)``.
235
+ The explicit weight/filter overrides and ``short_scan`` follow :func:`fbp`.
236
+ Returns signed float32 without clamping. The backward pass is the precise
237
+ discrete interpolation transpose; it does not make FDK an exact inverse.
238
+ Use :func:`helical_fdk` for slice-centered integration of an axial helix.
239
+ """
240
+ sinogram, geometry, shape, pitch, spacing = _prepare(
241
+ sinogram, trajectory, volume_shape, "cone", detector_spacing, voxel_spacing, isocenter)
242
+ angles = _angles(angles, len(geometry[0]), sinogram.device)
243
+ filtered = _pipeline(sinogram, geometry, angles, "cone", pitch, window, pad_factor,
244
+ projection_weights, angular_weights, filter_response, short_scan, isocenter,
245
+ tuple(torch.finfo(component.dtype).eps for component in trajectory))
246
+ return cone_weighted_backproject(filtered, *geometry, *shape, *pitch, spacing, isocenter=isocenter)
247
+
248
+
249
+ def helical_view_weights(angles, source_z, slice_z):
250
+ """Return ``(slices,views)`` weights for complete slice-centered turns.
251
+
252
+ ``angles`` are strictly monotone, unwrapped radians; ``source_z`` is the
253
+ corresponding strictly monotone source height, with at least two samples.
254
+ ``slice_z`` holds physical slice centers. Reversed acquisitions are valid.
255
+ Interpolate the angle at each height, then integrate angular sample cells
256
+ over the centered ``2*pi`` interval. Weights include the half redundancy
257
+ factor and sum to pi. Missing coverage raises instead of renormalizing.
258
+ These acquisition coordinates are fixed; learn replacement weights instead.
259
+ """
260
+ for value, name in ((angles, "angles"), (source_z, "source_z"), (slice_z, "slice_z")):
261
+ _fixed(value, name)
262
+ angles = torch.as_tensor(angles)
263
+ if not torch.is_floating_point(angles) or angles.ndim != 1 or len(angles) < 2:
264
+ raise ValueError("angles must be a real vector with at least two views")
265
+ output_dtype = angles.dtype
266
+ source_z = torch.as_tensor(source_z, device=angles.device, dtype=torch.float64)
267
+ slice_z = torch.as_tensor(slice_z, device=angles.device, dtype=torch.float64)
268
+ if (source_z.shape != angles.shape or slice_z.ndim != 1 or len(slice_z) == 0
269
+ or not all(torch.isfinite(t).all() for t in (angles, source_z, slice_z))):
270
+ raise ValueError("angles/source_z must match and slice_z must be a finite vector")
271
+ for value, name in ((angles, "angles"), (source_z, "source_z")):
272
+ differences = value[1:] - value[:-1]
273
+ if not (torch.all(differences > 0) or torch.all(differences < 0)):
274
+ raise ValueError(f"{name} must be strictly monotone; use circular fdk for zero pitch")
275
+ # Compute interval arithmetic relative to the first angle in float64. A
276
+ # large absolute angle must neither erase pi nor widen the coverage guard.
277
+ angles = angles.double() - angles[0].double()
278
+ source_z, slice_z = source_z.double(), slice_z.double()
279
+ ordered, order = angles.sort()
280
+ heights = source_z[order]
281
+ heights, z_order = heights.sort()
282
+ angle_at_z = ordered[z_order]
283
+ if torch.any(slice_z < heights[0]) or torch.any(slice_z > heights[-1]):
284
+ raise ValueError("Source heights do not cover the requested slices")
285
+ upper = torch.searchsorted(heights, slice_z).clamp(1, len(heights) - 1)
286
+ fraction = (slice_z - heights[upper - 1]) / (heights[upper] - heights[upper - 1])
287
+ center = angle_at_z[upper - 1] + fraction * (angle_at_z[upper] - angle_at_z[upper - 1])
288
+ lower, upper_angle = center - math.pi, center + math.pi
289
+ # Output rounding must not relax the acquired-window coverage contract.
290
+ precision = min(torch.finfo(output_dtype).eps, torch.finfo(torch.float32).eps)
291
+ tolerance = 8 * precision * max(1.0, float(ordered[-1] - ordered[0]))
292
+ if torch.any(lower < ordered[0] - tolerance) or torch.any(upper_angle > ordered[-1] + tolerance):
293
+ raise ValueError("Every reconstructed slice needs a fully acquired 2*pi angular window")
294
+ # Clip Voronoi integration cells, including fractional boundary cells.
295
+ # Their union is the acquired interval. Missing views are never normalized.
296
+ edges = torch.cat((ordered[:1], (ordered[:-1] + ordered[1:]) / 2, ordered[-1:]))
297
+ overlap = (torch.minimum(edges[1:][None, :], upper_angle[:, None])
298
+ - torch.maximum(edges[:-1][None, :], lower[:, None])).clamp_min(0) * 0.5
299
+ weights = torch.empty_like(overlap)
300
+ weights[:, order] = overlap
301
+ return weights.to(output_dtype)
302
+
303
+
304
+ def _axial_helix(geometry, isocenter):
305
+ _fixed(isocenter, "isocenter")
306
+ source, detector, u, v = geometry
307
+ center = torch.as_tensor(isocenter, device=source.device, dtype=source.dtype)
308
+ if center.shape != (3,) or not torch.isfinite(center).all():
309
+ raise ValueError("isocenter must be a finite physical (x,y,z) vector")
310
+ radial = source[:, :2] - center[:2]
311
+ radius = torch.linalg.vector_norm(radial, dim=1)
312
+ if torch.any(radius <= 0) or not torch.allclose(radius, radius[:1].expand_as(radius), rtol=1e-4, atol=1e-5):
313
+ raise ValueError("helical FDK requires a constant source radius about isocenter")
314
+ if (not torch.allclose(u[:, 2], torch.zeros_like(u[:, 2]), atol=1e-5)
315
+ or not torch.allclose(v[:, :2], torch.zeros_like(v[:, :2]), atol=1e-5)
316
+ or not torch.allclose(source[:, 2], detector[:, 2], rtol=1e-4, atol=1e-5)):
317
+ raise ValueError("helical FDK requires an axial helix and an untilted vertical detector")
318
+ normal = F.normalize(torch.linalg.cross(u, v), dim=1)
319
+ align = ((detector - source) * normal).sum(1)
320
+ normal = torch.where(align[:, None] < 0, -normal, normal)
321
+ if not torch.allclose(normal[:, :2], -radial / radius[:, None], rtol=1e-4, atol=1e-5):
322
+ raise ValueError("helical FDK requires radial detector normals")
323
+ dz = source[1:, 2] - source[:-1, 2]
324
+ if len(dz) == 0 or not (torch.all(dz > 0) or torch.all(dz < 0)):
325
+ raise ValueError("helical source heights must be strictly monotone; use circular fdk for zero pitch")
326
+ return center
327
+
328
+
329
+ def helical_weighted_backproject(sinogram, trajectory, volume_shape, view_weights,
330
+ *, isocenter, detector_spacing=1.0,
331
+ voxel_spacing=1.0):
332
+ """Gather filtered cone data using replaceable ``(D,views)`` weights.
333
+
334
+ ``sinogram`` is a filtered CUDA ``(views,U,V)`` tensor. ``view_weights``
335
+ includes angular quadrature and redundancy and is applied AFTER filtering.
336
+ Gradients flow to both tensors through the discrete cone-gather transpose.
337
+ Fixed axial geometry, constant source radius and radial flat detectors are
338
+ required. Caller-supplied weights own their angular coverage and truncation.
339
+ One slice is processed per launch, avoiding a views-by-volume weight array;
340
+ a fused slice-weight kernel can replace this if launch overhead dominates.
341
+ """
342
+ sinogram, geometry, shape, pitch, spacing = _prepare(
343
+ sinogram, trajectory, volume_shape, "cone", detector_spacing, voxel_spacing, isocenter)
344
+ center = _axial_helix(geometry, isocenter)
345
+ weights = _weights(view_weights, sinogram, "view_weights", (shape[0], len(geometry[0])))
346
+ slices = []
347
+ # One launch per slice; fuse slice weights if measured launch overhead
348
+ # dominates. Preserve ordinary PyTorch weight gradients here.
349
+ for index in range(shape[0]):
350
+ z = (index + 0.5 - shape[0] / 2) * spacing
351
+ shift = sinogram.new_tensor((0, 0, z))
352
+ source, detector, u, v = geometry
353
+ weighted = sinogram * weights[index, :, None, None]
354
+ plane = cone_weighted_backproject(
355
+ weighted, source - shift, detector - shift, u, v,
356
+ 1, shape[1], shape[2], *pitch, spacing, isocenter=center - shift)
357
+ slices.append(plane[0])
358
+ return torch.stack(slices)
359
+
360
+
361
+ def helical_fdk(sinogram, trajectory, volume_shape, angles, *, isocenter,
362
+ detector_spacing=1.0, voxel_spacing=1.0, window="ram-lak",
363
+ pad_factor=2, projection_weights=None, view_weights=None,
364
+ filter_response=None):
365
+ """Approximate axial helical FDK with one complete angular turn per slice.
366
+
367
+ Filters detector U once, then applies :func:`helical_view_weights` inside
368
+ the analytical gather. ``isocenter`` is required in physical coordinates.
369
+ Source heights and unwrapped ``angles`` must agree with the actual helix;
370
+ finite scans must cover a full turn around every requested slice.
371
+ ``view_weights`` replaces the complete ``(D,views)`` window/quadrature stage.
372
+ Projection/filter replacements follow :func:`fbp`. Custom tensors retain
373
+ autograd and are not normalized or clamped. Detector truncation is not
374
+ corrected. This native-cone approximation does not implement cone-parallel
375
+ rebinning, 3D conjugate weights or exact Katsevich inversion.
376
+ """
377
+ sinogram, geometry, shape, pitch, spacing = _prepare(
378
+ sinogram, trajectory, volume_shape, "cone", detector_spacing, voxel_spacing, isocenter)
379
+ center = _axial_helix(geometry, isocenter)
380
+ angles = _angles(angles, len(geometry[0]), sinogram.device)
381
+ relative = geometry[0][:, :2] - center[:2]
382
+ observed = torch.atan2(-relative[:, 0], relative[:, 1])
383
+ phase = observed.double() - angles.double()
384
+ if not torch.allclose(torch.cos(phase - phase[0]), torch.ones_like(phase), rtol=0, atol=1e-5):
385
+ raise ValueError("angles must describe the source's actual unwrapped rotation")
386
+ if view_weights is None:
387
+ z = (torch.arange(shape[0], device=sinogram.device, dtype=torch.float64)
388
+ + 0.5 - shape[0] / 2) * spacing
389
+ view_weights = helical_view_weights(angles, geometry[0][:, 2], z)
390
+ else:
391
+ # Still validate the acquired order when a caller replaces the windows.
392
+ difference = angles[1:] - angles[:-1]
393
+ if not (torch.all(difference > 0) or torch.all(difference < 0)):
394
+ raise ValueError("helical angles must be strictly monotone")
395
+ weighted = (sinogram * _cosine(sinogram, geometry, pitch) if projection_weights is None
396
+ else sinogram * _weights(projection_weights, sinogram, "projection_weights"))
397
+ filtered = _filter(weighted, pitch[0], window, pad_factor, filter_response)
398
+ return helical_weighted_backproject(filtered, geometry, shape, view_weights,
399
+ isocenter=center, detector_spacing=pitch, voxel_spacing=spacing)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: diffct
3
- Version: 2.2.0
3
+ Version: 2.2.1
4
4
  Summary: Differentiable CUDA projectors for CT: arbitrary trajectories, multi-GPU and multi-node execution, geometry gradients
5
5
  Project-URL: Homepage, https://github.com/sypsyp97/diffct
6
6
  Project-URL: Documentation, https://sypsyp97.github.io/diffct/
@@ -77,7 +77,7 @@ To our knowledge, diffct is the only open-source GPU CT library that combines ar
77
77
  - **Matched operators.** `project()` and `backproject()` form an exact adjoint pair for the cell-constant Siddon model. Both support PyTorch autograd, with volume/sinogram gradients and Hessian-vector products.
78
78
  - **Large volumes.** Automatic spatial, view and pixel blocks with bounded transfer buffers control working memory. `project_into`/`backproject_into` accept caller-owned tensor or disk stores; tensor autograd keeps complete data on its input device. See [chunked execution](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#chunked-execution).
79
79
  - **Multi-GPU and multi-node.** Use `devices=[0, 1, 2, 3]` in one process, or torchrun and NCCL across ranks. Default `partition="views"` shards projections and replicates volumes. `partition="space"` gives each rank an owned volume slab and combines ray contributions. Speedup depends on workload and communication costs.
80
- - **Analytical helpers.** `diffct.analytical` provides ramp filters (ram-lak, shepp-logan, cosine, hamming, hann), fan, cone and Parker weights, and FBP and FDK backprojection.
80
+ - **Differentiable FBP and FDK.** `fbp`, `fdk` and approximate `helical_fdk` compose the public filters, weights and analytical backprojectors. Their backward kernels transpose the actual interpolation stencil; custom filters and weights can be learned. See the [analytical guide](docs/source/analytical_learning.rst).
81
81
 
82
82
  Capabilities, limits and isocenter rules: [docs/REFERENCE.md](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#capabilities-and-limits).
83
83
 
@@ -220,7 +220,7 @@ Walnut data: Meaney 2022, Zenodo 6986012, CC BY 4.0; see [examples/data/NOTICE](
220
220
 
221
221
  ## Examples
222
222
 
223
- `quickstart.py`, `curved_detector.py`, `chunked_reconstruction.py`, `analytical_reconstruction.py`, `iterative_reconstruction.py`, `walnut_reconstruction.py`, `geometry_calibration.py`, `benchmark_projector.py`, `plot_trajectory.py`, and the Slurm template `slurm/multi_node.sbatch`. Launch modes and distributed-loss rules are in [examples/README.md](https://github.com/sypsyp97/diffct/blob/main/examples/README.md).
223
+ `quickstart.py`, `curved_detector.py`, `chunked_reconstruction.py`, `disk_reconstruction.py`, `benchmark_execution.py`, `analytical_reconstruction.py`, `learnable_reconstruction.py`, `helical_fdk.py`, `iterative_reconstruction.py`, `walnut_reconstruction.py`, `geometry_calibration.py`, `benchmark_projector.py`, `benchmark_scaling.py`, `plot_trajectory.py`, and the Slurm template `slurm/multi_node.sbatch`. Launch modes and distributed-loss rules are in [examples/README.md](https://github.com/sypsyp97/diffct/blob/main/examples/README.md).
224
224
 
225
225
  ## Documentation
226
226
 
@@ -1,5 +1,5 @@
1
- diffct/__init__.py,sha256=iK4fsfSPKVumWh4UviojwgMYg0Jav_Ha8t6j3LUCx20,2340
2
- diffct/analytical.py,sha256=cDJFMJOYzSaHLr9TUkiKtu5R6TR2d9b7GPZnCI48-VY,25206
1
+ diffct/__init__.py,sha256=megkY4RH88JkBLvG24tSJizwAfyCSoO_Qb3ZLsR1o8g,2572
2
+ diffct/analytical.py,sha256=_utt7n9dxR-D11NJuQ2wjI-9Fd5J62rm9AzMyB-fTpk,32318
3
3
  diffct/chunking.py,sha256=Ny7_QCGQDpNlQQAJQ_Y8XnTK3dvVWLt-U0GUozqC2Wg,9485
4
4
  diffct/constants.py,sha256=IKC_0u9aoNCG4M13KcRrl2plO0dTiokac-2aCYJJMb4,3060
5
5
  diffct/differentiable.py,sha256=cFirhqXRpUemL7y7Sl_0uqZKGJNj__ptQEcTwYNKzys,1879
@@ -7,17 +7,19 @@ diffct/geometry.py,sha256=ZFXqrn2b1kmwzlXqDFGHPVOhHkuAsjRuFbUF8slnOYQ,38154
7
7
  diffct/operators.py,sha256=Ia-WCK1L6O77bIcOiuLFXnq10jWoMvb5LbyivmWHViQ,118162
8
8
  diffct/pipeline.py,sha256=jp7AvDeQQOME3OPkO9XTcjWFLvOLrv8wczhAf1jcklM,10891
9
9
  diffct/projectors.py,sha256=xrPtQWQwVCrUovAXZX82q4J_RxCURZffF4GQ0ua_2sY,42520
10
+ diffct/reconstruction.py,sha256=I546L5bNigSb39oOHSJtRZeslI8fleNSBv7w-iGd994,23484
10
11
  diffct/storage.py,sha256=EdcFTzRtEAPRvAZhRPrSf8gRNF5o_JVbPLE8eKPhNdY,4153
11
12
  diffct/streaming.py,sha256=EjHOQd5RECMl2QLYyqEARwUzzrUAyEaFsCv5UFUjWSo,2380
12
13
  diffct/surfaces.py,sha256=OYVju0urcF8SzTkbGF-BJQrYZw1YjieYwCWH5ziypn8,5155
13
14
  diffct/utils.py,sha256=S-87F7z4AcxajtLbxIXlkLd7esE5ISncsBEwAFDIxQs,14648
14
- diffct/kernels/__init__.py,sha256=PhdgiZEyPzdnJ6tVmX6zzUvQH4EESwW8rfC_jCTlzhU,1106
15
+ diffct/kernels/__init__.py,sha256=QPA-d6Mg1ZAaOiqjLYGxuRyPtt9C5IhN0kyyxC739_w,1366
16
+ diffct/kernels/analytical_transpose.py,sha256=D4vr_ZFPrjFeQyhoDqTyijbkTY-g-sHJet52ho-cbkQ,9187
15
17
  diffct/kernels/cone_beam.py,sha256=Y17Yeb8qhUXDoiRPk6s3xQJChG4uHk7L7aBVf5VQXo8,25400
16
18
  diffct/kernels/fan_beam.py,sha256=J3Oj3LXEOcTSaB0pBf7tne_itqBJSgAPH3CmRtgCOCg,17068
17
19
  diffct/kernels/geometry_vjp.py,sha256=rv2v8QqCDEtMollYAOBiyVA6w8BhUV9Wl0UBsQnmNCk,20907
18
20
  diffct/kernels/parallel_beam.py,sha256=tCiSx9W8nO4mn8DVyRuK4E0Ry8q38g3pAUACab0UYVU,17143
19
- diffct-2.2.0.dist-info/METADATA,sha256=pWkEDbnmptbrtZH1XlgS6Zv0F6uvD83Y-VQ-1sljOZE,15638
20
- diffct-2.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
21
- diffct-2.2.0.dist-info/licenses/LICENSE,sha256=a1fjzGs44LpDnV_LmA2Tzp8z-eJKoNVVdLOnh-F6J6E,11344
22
- diffct-2.2.0.dist-info/licenses/NOTICE,sha256=du9I2sDF5IDfiY9vF64WWzMJh-MarpVXyLfWQmhbvmI,714
23
- diffct-2.2.0.dist-info/RECORD,,
21
+ diffct-2.2.1.dist-info/METADATA,sha256=gwqjaVILUAR3Mh90hHrg9s7Y4GdlGbFwW4xWpo1htP8,15905
22
+ diffct-2.2.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
23
+ diffct-2.2.1.dist-info/licenses/LICENSE,sha256=a1fjzGs44LpDnV_LmA2Tzp8z-eJKoNVVdLOnh-F6J6E,11344
24
+ diffct-2.2.1.dist-info/licenses/NOTICE,sha256=du9I2sDF5IDfiY9vF64WWzMJh-MarpVXyLfWQmhbvmI,714
25
+ diffct-2.2.1.dist-info/RECORD,,
File without changes