diffct 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diffct/__init__.py +83 -0
- diffct/analytical.py +569 -0
- diffct/constants.py +72 -0
- diffct/differentiable.py +74 -0
- diffct/geometry.py +991 -0
- diffct/kernels/__init__.py +43 -0
- diffct/kernels/cone_beam.py +617 -0
- diffct/kernels/fan_beam.py +451 -0
- diffct/kernels/geometry_vjp.py +562 -0
- diffct/kernels/parallel_beam.py +400 -0
- diffct/operators.py +546 -0
- diffct/projectors.py +910 -0
- diffct/utils.py +357 -0
- diffct-2.0.0.dist-info/METADATA +240 -0
- diffct-2.0.0.dist-info/RECORD +18 -0
- diffct-2.0.0.dist-info/WHEEL +4 -0
- diffct-2.0.0.dist-info/licenses/LICENSE +201 -0
- diffct-2.0.0.dist-info/licenses/NOTICE +18 -0
diffct/operators.py
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"""High-level operators for arbitrary CT trajectories."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import numbers
|
|
5
|
+
|
|
6
|
+
import torch
|
|
7
|
+
from numba import cuda
|
|
8
|
+
|
|
9
|
+
from .projectors import (
|
|
10
|
+
_geometry_vjp,
|
|
11
|
+
ConeBackprojectorFunction,
|
|
12
|
+
ConeProjectorFunction,
|
|
13
|
+
FanBackprojectorFunction,
|
|
14
|
+
FanProjectorFunction,
|
|
15
|
+
ParallelBackprojectorFunction,
|
|
16
|
+
ParallelProjectorFunction,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _positive_int(value, name):
|
|
21
|
+
if isinstance(value, bool) or not isinstance(value, numbers.Integral):
|
|
22
|
+
raise TypeError(f"{name} dimensions must be positive integers")
|
|
23
|
+
if value <= 0:
|
|
24
|
+
raise ValueError(f"{name} dimensions must be positive integers")
|
|
25
|
+
return int(value)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _shape(value, rank, name):
|
|
29
|
+
if not isinstance(value, (tuple, list)) or len(value) != rank:
|
|
30
|
+
raise TypeError(f"{name} must have {rank} dimensions")
|
|
31
|
+
return tuple(_positive_int(dim, name) for dim in value)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _positive_float(value, name):
|
|
35
|
+
if isinstance(value, bool) or not isinstance(value, numbers.Real):
|
|
36
|
+
raise TypeError(f"{name} must be a positive finite scalar")
|
|
37
|
+
value = float(value)
|
|
38
|
+
if not math.isfinite(value) or value <= 0:
|
|
39
|
+
raise ValueError(f"{name} must be a positive finite scalar")
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _detector_spacing(value, beam):
|
|
44
|
+
if beam != "cone":
|
|
45
|
+
if isinstance(value, (tuple, list)):
|
|
46
|
+
raise TypeError("2D detector_spacing must be a scalar")
|
|
47
|
+
return (_positive_float(value, "detector_spacing"),)
|
|
48
|
+
if isinstance(value, (tuple, list)):
|
|
49
|
+
if len(value) != 2:
|
|
50
|
+
raise TypeError("cone detector_spacing must be a scalar or pair")
|
|
51
|
+
return tuple(_positive_float(item, "detector_spacing") for item in value)
|
|
52
|
+
pitch = _positive_float(value, "detector_spacing")
|
|
53
|
+
return (pitch, pitch)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _validate_trajectory(trajectory, beam):
|
|
57
|
+
dimensions = 3 if beam == "cone" else 2
|
|
58
|
+
count = 4 if beam == "cone" else 3
|
|
59
|
+
if not isinstance(trajectory, (tuple, list)) or len(trajectory) != count:
|
|
60
|
+
raise TypeError(f"{beam} trajectory must contain {count} tensors")
|
|
61
|
+
|
|
62
|
+
geometry = []
|
|
63
|
+
n_views = None
|
|
64
|
+
for component in trajectory:
|
|
65
|
+
if not isinstance(component, torch.Tensor):
|
|
66
|
+
raise TypeError("trajectory components must be tensors")
|
|
67
|
+
if component.ndim != 2 or component.shape[1] != dimensions:
|
|
68
|
+
raise ValueError(f"trajectory tensors must have shape (views, {dimensions})")
|
|
69
|
+
if not torch.is_floating_point(component):
|
|
70
|
+
raise TypeError("trajectory tensors must have floating-point dtype")
|
|
71
|
+
if component.shape[0] == 0:
|
|
72
|
+
raise ValueError("trajectory must contain at least one view")
|
|
73
|
+
if n_views is not None and component.shape[0] != n_views:
|
|
74
|
+
raise ValueError("trajectory tensors must have the same number of views")
|
|
75
|
+
n_views = component.shape[0]
|
|
76
|
+
if not torch.isfinite(component).all().item():
|
|
77
|
+
raise ValueError("trajectory tensors must contain only finite values")
|
|
78
|
+
geometry.append(component.detach().clone())
|
|
79
|
+
|
|
80
|
+
axes = (geometry[0], geometry[2]) if beam == "parallel" else (
|
|
81
|
+
(geometry[2], geometry[3]) if beam == "cone" else (geometry[2],)
|
|
82
|
+
)
|
|
83
|
+
for axis in axes:
|
|
84
|
+
norms = torch.linalg.vector_norm(axis, dim=1)
|
|
85
|
+
if not torch.allclose(norms, torch.ones_like(norms), rtol=1e-4, atol=1e-4):
|
|
86
|
+
raise ValueError("trajectory direction axes must be unit vectors")
|
|
87
|
+
if beam in ("parallel", "cone"):
|
|
88
|
+
first, second = (geometry[0], geometry[2]) if beam == "parallel" else axes
|
|
89
|
+
dots = torch.sum(first * second.to(first.device), dim=1)
|
|
90
|
+
if torch.any(torch.abs(dots) > 1e-4).item():
|
|
91
|
+
raise ValueError("trajectory direction axes must be orthogonal")
|
|
92
|
+
if beam in ("fan", "cone"):
|
|
93
|
+
# float64 avoids overflow of half-precision input and dtype mismatches in cross().
|
|
94
|
+
source = geometry[0].double()
|
|
95
|
+
principal = geometry[1].to(source.device, torch.float64) - source
|
|
96
|
+
lengths = torch.linalg.vector_norm(principal, dim=1)
|
|
97
|
+
if torch.any(lengths == 0).item():
|
|
98
|
+
raise ValueError("source and detector center must differ in every view")
|
|
99
|
+
principal = principal / lengths[:, None]
|
|
100
|
+
det_u = geometry[2].to(source.device, torch.float64)
|
|
101
|
+
if beam == "fan":
|
|
102
|
+
facing = principal[:, 0] * det_u[:, 1] - principal[:, 1] * det_u[:, 0]
|
|
103
|
+
else:
|
|
104
|
+
normal = torch.linalg.cross(det_u, geometry[3].to(source.device, torch.float64), dim=1)
|
|
105
|
+
facing = torch.sum(principal * normal, dim=1)
|
|
106
|
+
if torch.any(torch.abs(facing) < 1e-4).item():
|
|
107
|
+
raise ValueError("the detector must not be edge-on to the source in any view")
|
|
108
|
+
|
|
109
|
+
return tuple(geometry), n_views
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _normalize_devices(devices):
|
|
113
|
+
if devices is None:
|
|
114
|
+
return None
|
|
115
|
+
if not isinstance(devices, (tuple, list)) or not devices:
|
|
116
|
+
raise ValueError("devices must be a nonempty sequence of CUDA devices")
|
|
117
|
+
|
|
118
|
+
normalized = []
|
|
119
|
+
for value in devices:
|
|
120
|
+
if isinstance(value, bool):
|
|
121
|
+
raise TypeError("devices must contain CUDA devices")
|
|
122
|
+
if isinstance(value, int):
|
|
123
|
+
device = torch.device("cuda", value)
|
|
124
|
+
else:
|
|
125
|
+
try:
|
|
126
|
+
device = torch.device(value)
|
|
127
|
+
except (TypeError, RuntimeError) as error:
|
|
128
|
+
raise TypeError("devices must contain CUDA devices") from error
|
|
129
|
+
if device.type != "cuda":
|
|
130
|
+
raise TypeError("devices must contain CUDA devices")
|
|
131
|
+
if not torch.cuda.is_available():
|
|
132
|
+
raise ValueError("CUDA devices are unavailable")
|
|
133
|
+
index = torch.cuda.current_device() if device.index is None else device.index
|
|
134
|
+
if index < 0 or index >= torch.cuda.device_count():
|
|
135
|
+
raise ValueError(f"CUDA device index {index} is not available")
|
|
136
|
+
normalized.append(torch.device("cuda", index))
|
|
137
|
+
if len(set(normalized)) != len(normalized):
|
|
138
|
+
raise ValueError("devices must be unique")
|
|
139
|
+
return tuple(normalized)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _balanced_slice(n_views, rank, world_size):
|
|
143
|
+
base, extra = divmod(n_views, world_size)
|
|
144
|
+
start = rank * base + min(rank, extra)
|
|
145
|
+
return slice(start, start + base + int(rank < extra))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# Operator modes and their adjoints. "project_reduced" sums its input over
|
|
149
|
+
# ranks before projecting; it is the adjoint of the replicated backprojection.
|
|
150
|
+
_ADJOINT_MODE = {
|
|
151
|
+
"project": "backproject",
|
|
152
|
+
"backproject": "project_reduced",
|
|
153
|
+
"project_reduced": "backproject",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _all_reduce_copy(projector, tensor):
|
|
158
|
+
"""Return a SUM over ranks of ``tensor`` (the tensor itself without ranks)."""
|
|
159
|
+
if not (projector._distributed and projector.world_size > 1):
|
|
160
|
+
return tensor
|
|
161
|
+
reduced = tensor.detach().clone(memory_format=torch.contiguous_format)
|
|
162
|
+
with torch.cuda.device(reduced.device):
|
|
163
|
+
torch.distributed.all_reduce(
|
|
164
|
+
reduced, op=torch.distributed.ReduceOp.SUM, group=projector._process_group
|
|
165
|
+
)
|
|
166
|
+
return reduced
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _ProjectorAutograd(torch.autograd.Function):
|
|
170
|
+
@staticmethod
|
|
171
|
+
def forward(ctx, tensor, projector, mode, *geometry):
|
|
172
|
+
if not isinstance(tensor, torch.Tensor):
|
|
173
|
+
raise TypeError("input must be a torch.Tensor")
|
|
174
|
+
ctx.projector = projector
|
|
175
|
+
ctx.mode = mode
|
|
176
|
+
ctx.input_device = tensor.device
|
|
177
|
+
ctx.input_dtype = tensor.dtype
|
|
178
|
+
ctx.save_for_backward(tensor if geometry else None, *geometry)
|
|
179
|
+
return projector._run(
|
|
180
|
+
tensor,
|
|
181
|
+
is_project=mode != "backproject",
|
|
182
|
+
reduce_cotangent=mode == "project_reduced",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def backward(ctx, grad_output):
|
|
187
|
+
tensor, *geometry = ctx.saved_tensors
|
|
188
|
+
projector = ctx.projector
|
|
189
|
+
# Both branches below contain collectives. In distributed mode they run
|
|
190
|
+
# on every rank, so ranks whose inputs differ in requires_grad still
|
|
191
|
+
# issue the same collective sequence.
|
|
192
|
+
collective = projector._distributed and projector.world_size > 1
|
|
193
|
+
grad_input = None
|
|
194
|
+
if ctx.needs_input_grad[0] or collective:
|
|
195
|
+
# The adjoint is this Function again, so second derivatives with
|
|
196
|
+
# respect to volumes and sinograms work.
|
|
197
|
+
grad_input = _ProjectorAutograd.apply(
|
|
198
|
+
grad_output, projector, _ADJOINT_MODE[ctx.mode], *geometry
|
|
199
|
+
).to(device=ctx.input_device, dtype=ctx.input_dtype)
|
|
200
|
+
if not ctx.needs_input_grad[0]:
|
|
201
|
+
grad_input = None
|
|
202
|
+
geometry_grads = (None,) * len(geometry)
|
|
203
|
+
if any(ctx.needs_input_grad[3:]) or (collective and geometry):
|
|
204
|
+
# Every mode is <cotangent, A_local(geometry) volume> for some pair.
|
|
205
|
+
if ctx.mode == "project":
|
|
206
|
+
volume, cotangent = tensor, grad_output
|
|
207
|
+
elif ctx.mode == "project_reduced":
|
|
208
|
+
volume, cotangent = _all_reduce_copy(projector, tensor), grad_output
|
|
209
|
+
else:
|
|
210
|
+
volume, cotangent = _all_reduce_copy(projector, grad_output), tensor
|
|
211
|
+
grads = _GeometryGradAutograd.apply(projector, volume, cotangent, *geometry)
|
|
212
|
+
geometry_grads = tuple(
|
|
213
|
+
grad if needed else None
|
|
214
|
+
for grad, needed in zip(grads, ctx.needs_input_grad[3:])
|
|
215
|
+
)
|
|
216
|
+
return (grad_input, None, None, *geometry_grads)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class _GeometryGradAutograd(torch.autograd.Function):
|
|
220
|
+
"""First-order geometry gradient; differentiating it again raises."""
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def forward(ctx, projector, volume, cotangent, *geometry):
|
|
224
|
+
return projector._geometry_grad(volume, cotangent, geometry)
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def backward(ctx, *grad_outputs):
|
|
228
|
+
raise RuntimeError(
|
|
229
|
+
"diffct does not support second derivatives with respect to the geometry"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class Projector:
|
|
234
|
+
"""Project CUDA volumes along fixed parallel, fan, or cone trajectories.
|
|
235
|
+
|
|
236
|
+
A parallel trajectory is ``(ray_dir, det_origin, det_u)`` and a fan
|
|
237
|
+
trajectory is ``(src_pos, det_center, det_u)``; each component has shape
|
|
238
|
+
``(views, 2)``. A cone trajectory is
|
|
239
|
+
``(src_pos, det_center, det_u, det_v)`` with components shaped
|
|
240
|
+
``(views, 3)``. Direction axes are unit vectors; parallel ``ray_dir`` and
|
|
241
|
+
``det_u`` and cone ``det_u`` and ``det_v`` are orthogonal. If no trajectory
|
|
242
|
+
tensor requires gradients, the projector clones the geometry. Otherwise it
|
|
243
|
+
keeps references, reads the current values at every call, and returns
|
|
244
|
+
gradients for those tensors; the checks above run only at construction.
|
|
245
|
+
Second derivatives are available for volumes and sinograms, not for the
|
|
246
|
+
geometry.
|
|
247
|
+
|
|
248
|
+
Volumes use ``(H, W)`` for parallel and fan beams and ``(D, H, W)`` for
|
|
249
|
+
cone beams. Sinograms use ``(views, detectors)`` or ``(views, U, V)``.
|
|
250
|
+
Results are float32 and returned to the input tensor's CUDA device. Local
|
|
251
|
+
devices split views in list order; distributed mode returns rank-local
|
|
252
|
+
projections and replicated backprojections. Distributed projection losses
|
|
253
|
+
use SUM across ranks; divide replicated backprojection losses by
|
|
254
|
+
``world_size`` and do not add a second DDP reduction for image or geometry
|
|
255
|
+
gradients.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
def __init__(self, trajectory, volume_shape, detector_shape, *, beam="cone",
|
|
259
|
+
detector_spacing=1.0, voxel_spacing=1.0, devices=None,
|
|
260
|
+
distributed=False, process_group=None):
|
|
261
|
+
if beam not in ("parallel", "fan", "cone"):
|
|
262
|
+
raise ValueError("beam must be 'parallel', 'fan', or 'cone'")
|
|
263
|
+
self.beam = beam
|
|
264
|
+
rank = 3 if beam == "cone" else 2
|
|
265
|
+
self.volume_shape = _shape(volume_shape, rank, "volume_shape")
|
|
266
|
+
if beam == "cone":
|
|
267
|
+
self.detector_shape = _shape(detector_shape, 2, "detector_shape")
|
|
268
|
+
elif isinstance(detector_shape, (tuple, list)):
|
|
269
|
+
self.detector_shape = _shape(detector_shape, 1, "detector_shape")
|
|
270
|
+
else:
|
|
271
|
+
self.detector_shape = (_positive_int(detector_shape, "detector_shape"),)
|
|
272
|
+
|
|
273
|
+
self.detector_spacing = _detector_spacing(detector_spacing, beam)
|
|
274
|
+
self.voxel_spacing = _positive_float(voxel_spacing, "voxel_spacing")
|
|
275
|
+
self._trajectory, n_views = _validate_trajectory(trajectory, beam)
|
|
276
|
+
self._learnable = (
|
|
277
|
+
tuple(trajectory) if any(c.requires_grad for c in trajectory) else ()
|
|
278
|
+
)
|
|
279
|
+
if beam in ("fan", "cone"):
|
|
280
|
+
# The kernels set up each ray in float32 from the endpoint nearer the
|
|
281
|
+
# volume centre, which is the origin.
|
|
282
|
+
distances = torch.stack([
|
|
283
|
+
torch.linalg.vector_norm(component.double(), dim=1).cpu()
|
|
284
|
+
for component in self._trajectory[:2]
|
|
285
|
+
]) / self.voxel_spacing
|
|
286
|
+
if torch.any(distances.min(dim=0).values > 1e6).item():
|
|
287
|
+
raise ValueError(
|
|
288
|
+
"the source or the detector center must lie within 1e6 voxels "
|
|
289
|
+
"of the volume center in every view"
|
|
290
|
+
)
|
|
291
|
+
if torch.any(distances > 1e15).item():
|
|
292
|
+
raise ValueError(
|
|
293
|
+
"the source and the detector center must lie within 1e15 voxels "
|
|
294
|
+
"of the volume center"
|
|
295
|
+
)
|
|
296
|
+
self._geometry_cache = {}
|
|
297
|
+
self._devices = _normalize_devices(devices)
|
|
298
|
+
if not isinstance(distributed, bool):
|
|
299
|
+
raise TypeError("distributed must be a bool")
|
|
300
|
+
self._distributed = distributed
|
|
301
|
+
if process_group is not None and not self._distributed:
|
|
302
|
+
raise ValueError("process_group requires distributed=True")
|
|
303
|
+
if self._distributed:
|
|
304
|
+
if not torch.distributed.is_available() or not torch.distributed.is_initialized():
|
|
305
|
+
raise RuntimeError("distributed=True requires an initialized process group")
|
|
306
|
+
try:
|
|
307
|
+
self.rank = torch.distributed.get_rank(process_group)
|
|
308
|
+
self.world_size = torch.distributed.get_world_size(process_group)
|
|
309
|
+
except (RuntimeError, ValueError) as error:
|
|
310
|
+
raise ValueError("process_group must include the calling rank") from error
|
|
311
|
+
if self.rank < 0 or self.world_size <= 0 or self.rank >= self.world_size:
|
|
312
|
+
raise ValueError("process_group must include the calling rank")
|
|
313
|
+
else:
|
|
314
|
+
self.rank = 0
|
|
315
|
+
self.world_size = 1
|
|
316
|
+
self._process_group = process_group
|
|
317
|
+
if self._distributed and self.world_size > 1:
|
|
318
|
+
# Backward issues an extra all_reduce for geometry gradients, so
|
|
319
|
+
# every rank must agree on whether the trajectory requires them.
|
|
320
|
+
backend = torch.distributed.get_backend(process_group)
|
|
321
|
+
device = (
|
|
322
|
+
torch.device("cuda", torch.cuda.current_device())
|
|
323
|
+
if backend == "nccl" else torch.device("cpu")
|
|
324
|
+
)
|
|
325
|
+
flag = 1 if self._learnable else 0
|
|
326
|
+
bounds = torch.tensor([flag, -flag], device=device)
|
|
327
|
+
torch.distributed.all_reduce(
|
|
328
|
+
bounds, op=torch.distributed.ReduceOp.MAX, group=process_group
|
|
329
|
+
)
|
|
330
|
+
if bounds[0].item() != -bounds[1].item():
|
|
331
|
+
raise ValueError(
|
|
332
|
+
"all ranks must agree on whether the trajectory requires gradients"
|
|
333
|
+
)
|
|
334
|
+
self.view_slice = _balanced_slice(n_views, self.rank, self.world_size)
|
|
335
|
+
self.projection_shape = (
|
|
336
|
+
self.view_slice.stop - self.view_slice.start,
|
|
337
|
+
*self.detector_shape,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
def _geometry_for(self, device, global_slice):
|
|
341
|
+
if self._learnable:
|
|
342
|
+
# Learnable geometry changes between calls, so it is staged every time.
|
|
343
|
+
return tuple(
|
|
344
|
+
component.detach()[global_slice]
|
|
345
|
+
.to(device=device, dtype=torch.float32)
|
|
346
|
+
.contiguous()
|
|
347
|
+
for component in self._learnable
|
|
348
|
+
)
|
|
349
|
+
key = (device.index, global_slice.start, global_slice.stop)
|
|
350
|
+
stream = torch.cuda.current_stream(device)
|
|
351
|
+
cached = self._geometry_cache.get(key)
|
|
352
|
+
if cached is None:
|
|
353
|
+
geometry = tuple(
|
|
354
|
+
component[global_slice]
|
|
355
|
+
.to(device=device, dtype=torch.float32, non_blocking=True)
|
|
356
|
+
.contiguous()
|
|
357
|
+
for component in self._trajectory
|
|
358
|
+
)
|
|
359
|
+
ready = torch.cuda.Event()
|
|
360
|
+
ready.record(stream)
|
|
361
|
+
self._geometry_cache[key] = (geometry, ready)
|
|
362
|
+
else:
|
|
363
|
+
geometry, ready = cached
|
|
364
|
+
stream.wait_event(ready)
|
|
365
|
+
for component in geometry:
|
|
366
|
+
component.record_stream(stream)
|
|
367
|
+
return geometry
|
|
368
|
+
|
|
369
|
+
def _run(self, tensor, is_project, reduce_cotangent=False):
|
|
370
|
+
if not isinstance(tensor, torch.Tensor):
|
|
371
|
+
raise TypeError("input must be a torch.Tensor")
|
|
372
|
+
expected_shape = self.volume_shape if is_project else self.projection_shape
|
|
373
|
+
if tuple(tensor.shape) != expected_shape:
|
|
374
|
+
raise ValueError(f"input shape must be {expected_shape}")
|
|
375
|
+
if not tensor.is_cuda:
|
|
376
|
+
raise TypeError("input tensor must be on CUDA")
|
|
377
|
+
if not torch.is_floating_point(tensor):
|
|
378
|
+
raise TypeError("input tensor must have floating-point dtype")
|
|
379
|
+
input_device = tensor.device
|
|
380
|
+
if reduce_cotangent and self._distributed and self.world_size > 1:
|
|
381
|
+
tensor = tensor.detach().clone(memory_format=torch.contiguous_format)
|
|
382
|
+
with torch.cuda.device(input_device):
|
|
383
|
+
torch.distributed.all_reduce(
|
|
384
|
+
tensor,
|
|
385
|
+
op=torch.distributed.ReduceOp.SUM,
|
|
386
|
+
group=self._process_group,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
devices = self._devices or (input_device,)
|
|
390
|
+
local_views = self.projection_shape[0]
|
|
391
|
+
|
|
392
|
+
# Stage every input and geometry shard before launching any raw kernel.
|
|
393
|
+
staged = []
|
|
394
|
+
for device_rank, device in enumerate(devices):
|
|
395
|
+
local_slice = _balanced_slice(local_views, device_rank, len(devices))
|
|
396
|
+
if local_slice.start == local_slice.stop:
|
|
397
|
+
continue
|
|
398
|
+
global_slice = slice(
|
|
399
|
+
self.view_slice.start + local_slice.start,
|
|
400
|
+
self.view_slice.start + local_slice.stop,
|
|
401
|
+
)
|
|
402
|
+
source = tensor if is_project else tensor[local_slice]
|
|
403
|
+
with torch.cuda.device(device):
|
|
404
|
+
staged_input = source.to(
|
|
405
|
+
device=device, dtype=torch.float32, non_blocking=True
|
|
406
|
+
).contiguous()
|
|
407
|
+
staged_geometry = self._geometry_for(device, global_slice)
|
|
408
|
+
staged.append((device, staged_input, staged_geometry))
|
|
409
|
+
|
|
410
|
+
# Keep all outputs alive until every device has launched its shard.
|
|
411
|
+
outputs = []
|
|
412
|
+
for device, staged_input, geometry in staged:
|
|
413
|
+
with torch.cuda.device(device), cuda.gpus[device.index]:
|
|
414
|
+
output = (
|
|
415
|
+
self._project_raw(staged_input, geometry)
|
|
416
|
+
if is_project
|
|
417
|
+
else self._backproject_raw(staged_input, geometry)
|
|
418
|
+
)
|
|
419
|
+
outputs.append((device, output))
|
|
420
|
+
|
|
421
|
+
with torch.cuda.device(input_device):
|
|
422
|
+
if is_project:
|
|
423
|
+
pieces = [
|
|
424
|
+
output.to(device=input_device, non_blocking=True)
|
|
425
|
+
for _, output in outputs
|
|
426
|
+
]
|
|
427
|
+
if pieces:
|
|
428
|
+
return torch.cat(pieces, dim=0)
|
|
429
|
+
return torch.empty(
|
|
430
|
+
self.projection_shape, dtype=torch.float32, device=input_device
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
result = torch.zeros(
|
|
434
|
+
self.volume_shape, dtype=torch.float32, device=input_device
|
|
435
|
+
)
|
|
436
|
+
for _, output in outputs:
|
|
437
|
+
result.add_(output.to(device=input_device, non_blocking=True))
|
|
438
|
+
if self._distributed and self.world_size > 1:
|
|
439
|
+
torch.distributed.all_reduce(
|
|
440
|
+
result,
|
|
441
|
+
op=torch.distributed.ReduceOp.SUM,
|
|
442
|
+
group=self._process_group,
|
|
443
|
+
)
|
|
444
|
+
return result
|
|
445
|
+
|
|
446
|
+
def _geometry_grad(self, volume, cotangent, geometry):
|
|
447
|
+
"""Return d<cotangent, A(geometry) volume>/d(geometry) over this rank's views.
|
|
448
|
+
|
|
449
|
+
``cotangent`` holds the rank's view shard. The result covers the full
|
|
450
|
+
trajectory; distributed mode sums it over ranks.
|
|
451
|
+
"""
|
|
452
|
+
devices = self._devices or (volume.device,)
|
|
453
|
+
local_views = self.projection_shape[0]
|
|
454
|
+
spacing = self.detector_spacing if self.beam == "cone" else self.detector_spacing[0]
|
|
455
|
+
grads = [
|
|
456
|
+
torch.zeros(component.shape, dtype=torch.float32, device=volume.device)
|
|
457
|
+
for component in geometry
|
|
458
|
+
]
|
|
459
|
+
for device_rank, device in enumerate(devices):
|
|
460
|
+
local_slice = _balanced_slice(local_views, device_rank, len(devices))
|
|
461
|
+
if local_slice.start == local_slice.stop:
|
|
462
|
+
continue
|
|
463
|
+
global_slice = slice(
|
|
464
|
+
self.view_slice.start + local_slice.start,
|
|
465
|
+
self.view_slice.start + local_slice.stop,
|
|
466
|
+
)
|
|
467
|
+
with torch.cuda.device(device), cuda.gpus[device.index]:
|
|
468
|
+
parts = _geometry_vjp(
|
|
469
|
+
self.beam,
|
|
470
|
+
volume.detach().to(device=device),
|
|
471
|
+
cotangent.detach()[local_slice].to(device=device),
|
|
472
|
+
tuple(
|
|
473
|
+
component.detach()[global_slice].to(device=device, dtype=torch.float32)
|
|
474
|
+
for component in geometry
|
|
475
|
+
),
|
|
476
|
+
spacing,
|
|
477
|
+
self.voxel_spacing,
|
|
478
|
+
)
|
|
479
|
+
for grad, part in zip(grads, parts):
|
|
480
|
+
grad[global_slice] += part.to(device=volume.device)
|
|
481
|
+
if self._distributed and self.world_size > 1:
|
|
482
|
+
flat = torch.cat([grad.flatten() for grad in grads])
|
|
483
|
+
with torch.cuda.device(volume.device):
|
|
484
|
+
torch.distributed.all_reduce(
|
|
485
|
+
flat, op=torch.distributed.ReduceOp.SUM, group=self._process_group
|
|
486
|
+
)
|
|
487
|
+
grads = list(flat.split([grad.numel() for grad in grads]))
|
|
488
|
+
grads = [part.view(component.shape) for part, component in zip(grads, geometry)]
|
|
489
|
+
return tuple(
|
|
490
|
+
grad.to(dtype=component.dtype, device=component.device)
|
|
491
|
+
for grad, component in zip(grads, geometry)
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
def _project_raw(self, volume, geometry):
|
|
495
|
+
if self.beam == "parallel":
|
|
496
|
+
return ParallelProjectorFunction.apply(
|
|
497
|
+
volume, *geometry, self.detector_shape[0],
|
|
498
|
+
self.detector_spacing[0], self.voxel_spacing,
|
|
499
|
+
)
|
|
500
|
+
if self.beam == "fan":
|
|
501
|
+
return FanProjectorFunction.apply(
|
|
502
|
+
volume, *geometry, self.detector_shape[0],
|
|
503
|
+
self.detector_spacing[0], self.voxel_spacing,
|
|
504
|
+
)
|
|
505
|
+
return ConeProjectorFunction.apply(
|
|
506
|
+
volume, *geometry, *self.detector_shape,
|
|
507
|
+
*self.detector_spacing, self.voxel_spacing,
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
def _backproject_raw(self, sinogram, geometry):
|
|
511
|
+
if self.beam == "parallel":
|
|
512
|
+
return ParallelBackprojectorFunction.apply(
|
|
513
|
+
sinogram, *geometry, self.detector_spacing[0],
|
|
514
|
+
*self.volume_shape, self.voxel_spacing,
|
|
515
|
+
)
|
|
516
|
+
if self.beam == "fan":
|
|
517
|
+
return FanBackprojectorFunction.apply(
|
|
518
|
+
sinogram, *geometry, self.detector_spacing[0],
|
|
519
|
+
*self.volume_shape, self.voxel_spacing,
|
|
520
|
+
)
|
|
521
|
+
return ConeBackprojectorFunction.apply(
|
|
522
|
+
sinogram, *geometry, *self.volume_shape,
|
|
523
|
+
*self.detector_spacing, self.voxel_spacing,
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
def project(self, volume):
|
|
527
|
+
"""Project a ``(H, W)`` or ``(D, H, W)`` CUDA tensor to float32.
|
|
528
|
+
|
|
529
|
+
The output shape is ``projection_shape``; in distributed mode it holds
|
|
530
|
+
this rank's contiguous view slice. The result stays on ``volume.device``.
|
|
531
|
+
"""
|
|
532
|
+
return _ProjectorAutograd.apply(volume, self, "project", *self._learnable)
|
|
533
|
+
|
|
534
|
+
def backproject(self, sinogram):
|
|
535
|
+
"""Backproject a floating-point CUDA sinogram.
|
|
536
|
+
|
|
537
|
+
The input must have shape ``projection_shape`` and uses ``(views, U,
|
|
538
|
+
V)`` order for cone beams. The float32 volume is returned to
|
|
539
|
+
``sinogram.device``; distributed mode SUM-reduces it to every rank, so
|
|
540
|
+
divide a replicated-output loss by ``world_size``.
|
|
541
|
+
"""
|
|
542
|
+
return _ProjectorAutograd.apply(sinogram, self, "backproject", *self._learnable)
|
|
543
|
+
|
|
544
|
+
def __call__(self, volume):
|
|
545
|
+
"""Alias for :meth:`project`."""
|
|
546
|
+
return self.project(volume)
|