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