diffct 2.2.1__py3-none-any.whl → 2.3.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 CHANGED
@@ -20,11 +20,15 @@ from .surfaces import ParameterizedSurface
20
20
  from .reconstruction import (
21
21
  fbp,
22
22
  fdk,
23
+ fbp_into,
24
+ fdk_into,
23
25
  helical_fdk,
24
26
  helical_view_weights,
25
27
  helical_weighted_backproject,
26
28
  )
27
29
 
30
+ from .solvers import SolverResult, cgls, sirt, tv_reconstruction, cgls_into, total_variation
31
+
28
32
  from .geometry import (
29
33
  circular_trajectory_3d,
30
34
  random_trajectory_3d,
@@ -58,7 +62,7 @@ try:
58
62
  except ImportError:
59
63
  pass
60
64
 
61
- __version__ = '2.2.1'
65
+ __version__ = '2.3.0'
62
66
 
63
67
  __all__ = [
64
68
  'Projector',
@@ -67,6 +71,14 @@ __all__ = [
67
71
  'ParameterizedSurface',
68
72
  'fbp',
69
73
  'fdk',
74
+ 'fbp_into',
75
+ 'fdk_into',
76
+ 'SolverResult',
77
+ 'cgls',
78
+ 'sirt',
79
+ 'tv_reconstruction',
80
+ 'cgls_into',
81
+ 'total_variation',
70
82
  'helical_fdk',
71
83
  'helical_view_weights',
72
84
  'helical_weighted_backproject',
@@ -0,0 +1,284 @@
1
+ """Bounded solver state, owned reductions and complete checkpoint publication."""
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ import numbers
7
+ from pathlib import Path
8
+
9
+ import torch
10
+ import torch.distributed as dist
11
+
12
+ from .pipeline import _fragments
13
+ from .storage import NpyStore, TensorStore
14
+
15
+
16
+ def count(value, name, positive=False):
17
+ if isinstance(value, bool) or not isinstance(value, numbers.Integral) or value < int(positive):
18
+ raise ValueError(f"{name} must be a {'positive' if positive else 'nonnegative'} integer")
19
+ return int(value)
20
+
21
+
22
+ def finite(value, name, positive=False):
23
+ if isinstance(value, bool) or not isinstance(value, numbers.Real) or not math.isfinite(value) or value < 0 or (positive and value == 0):
24
+ raise ValueError(f"{name} must be finite and {'positive' if positive else 'nonnegative'}")
25
+ return float(value)
26
+
27
+
28
+ def agreement(operator, error=None, token=None):
29
+ """Every rank rejects a bad request before numerical execution starts."""
30
+ world = getattr(operator, "world_size", 1)
31
+ if world == 1:
32
+ if error is not None:
33
+ raise error
34
+ return
35
+ records = [None] * world
36
+ dist.all_gather_object(records, (None if error is None else str(error), token),
37
+ group=operator.process_group)
38
+ failures = [record[0] for record in records if record[0] is not None]
39
+ if failures or any(record[1] != records[0][1] for record in records):
40
+ raise ValueError("distributed solver agreement failed: " + (failures[0] if failures else "inconsistent checkpoint iteration or controls"))
41
+
42
+
43
+ def scalar_sum(operator, value, kind):
44
+ """Count physical rays/voxels once, including view/space replicas."""
45
+ world = getattr(operator, "world_size", 1)
46
+ partition = getattr(operator, "partition", "views")
47
+ rank = getattr(operator, "rank", 0)
48
+ owns = (getattr(operator, "view_rank", 0) == 0 if kind == "volume"
49
+ else getattr(operator, "space_rank", 0) == 0) if partition == "hybrid" else (
50
+ rank == 0 if (kind == "volume") == (partition == "views") else True
51
+ )
52
+ value = float(value) if owns else 0.
53
+ if world > 1:
54
+ group = operator.process_group
55
+ device = torch.device("cuda", torch.cuda.current_device()) if dist.get_backend(group) == "nccl" else torch.device("cpu")
56
+ accumulator = torch.tensor(value, dtype=torch.float64, device=device)
57
+ dist.all_reduce(accumulator, group=group)
58
+ value = accumulator.item()
59
+ return value
60
+
61
+
62
+ def squared_norm(operator, value, kind, elements=1048576):
63
+ store = TensorStore(value) if isinstance(value, torch.Tensor) else value
64
+ total = 0.
65
+ for selected in _fragments(tuple(store.shape), elements):
66
+ # Clone even an FP64 view: norms must never alter caller measurements.
67
+ block = store.read(selected).to(dtype=torch.float64).clone()
68
+ total += block.square_().sum().item()
69
+ return scalar_sum(operator, total, kind)
70
+
71
+
72
+ def copy_blocks(source, target, elements):
73
+ for selected in _fragments(tuple(target.shape), elements):
74
+ target.write(selected, source.read(selected).to(device=getattr(target, "device", "cpu"), dtype=target.dtype))
75
+
76
+
77
+ def fill_blocks(target, elements):
78
+ for selected in _fragments(tuple(target.shape), elements):
79
+ shape = tuple(part.stop - part.start for part in selected)
80
+ target.write(selected, torch.zeros(shape, dtype=target.dtype))
81
+
82
+
83
+ def update_blocks(target, source, factor, elements, scale=1.):
84
+ for selected in _fragments(tuple(target.shape), elements):
85
+ block = target.read(selected).to(device="cpu", dtype=target.dtype).clone()
86
+ block.mul_(scale).add_(source.read(selected).to(device="cpu", dtype=target.dtype), alpha=factor)
87
+ target.write(selected, block)
88
+
89
+
90
+ def fingerprint(value, elements):
91
+ store = TensorStore(value.detach()) if isinstance(value, torch.Tensor) else value
92
+ digest = hashlib.sha256(json.dumps((tuple(store.shape), str(store.dtype))).encode())
93
+ for selected in _fragments(tuple(store.shape), elements):
94
+ block = store.read(selected).detach().to(device="cpu").contiguous()
95
+ # Hash storage bytes without requiring NumPy support for the original
96
+ # floating dtype (for example bfloat16). This view allocates no payload.
97
+ digest.update(block.reshape(-1).view(torch.uint8).numpy().tobytes())
98
+ return digest.hexdigest()
99
+
100
+
101
+ def _effective_surface(operator, frames, elements):
102
+ """Hash the actual physical pixel rays, without materializing a volume."""
103
+ surface = getattr(operator, "detector_surface", None)
104
+ if surface is None:
105
+ return None
106
+ from .backends import ReferenceProjector
107
+ from .surfaces import ParameterizedSurface, _detector_grids
108
+
109
+ dimensions = len(operator.global_volume_shape)
110
+ if elements < max(3, dimensions):
111
+ raise ValueError("block_elements cannot hold one physical detector pixel")
112
+ if isinstance(surface, ParameterizedSurface):
113
+ if any(value.device.type != "cpu" and value.numel() > elements for value in surface.parameters):
114
+ raise ValueError("surface parameters cannot be staged within block_elements")
115
+ geometry = (*frames, *surface.parameters)
116
+ else:
117
+ # Legacy callbacks require complete grids. Their documented output can
118
+ # contain every view, so admit that snapshot only when it fits the
119
+ # bound; a smaller arbitrary callback invocation may change its meaning.
120
+ if operator._n_views * math.prod(operator.detector_shape) * 3 > elements:
121
+ raise ValueError("legacy surface fingerprint exceeds block_elements; use ParameterizedSurface")
122
+ pixels = tuple(slice(0, size) for size in operator.detector_shape)
123
+ u, v = _detector_grids(operator.detector_shape, operator.detector_spacing, pixels)
124
+ offsets = surface(u, v)
125
+ if not isinstance(offsets, torch.Tensor) or not torch.is_floating_point(offsets):
126
+ raise TypeError("detector_surface must return floating-point offsets")
127
+ if tuple(offsets.shape) not in ((*operator.detector_shape, 3),
128
+ (operator._n_views, *operator.detector_shape, 3)):
129
+ raise ValueError("legacy detector surface must match the complete pixel grids")
130
+ geometry = (*frames, offsets)
131
+ reference = getattr(operator, "_reference", None)
132
+ if reference is None:
133
+ reference = ReferenceProjector(operator, validate=False)
134
+ description = json.dumps((operator._n_views, operator.detector_shape, str(operator.dtype))).encode()
135
+ origins_digest, directions_digest = hashlib.sha256(description), hashlib.sha256(description)
136
+ for view in range(operator._n_views):
137
+ for pixels in _fragments(operator.detector_shape, elements // max(3, dimensions)):
138
+ origins, directions, _ = reference._rays(slice(view, view + 1), pixels,
139
+ torch.device("cpu"), validate=True, geometry=geometry)
140
+ # Separate streams preserve row-major ray ordering across different
141
+ # block sizes; changing a memory ceiling does not change the hash.
142
+ origins_digest.update(origins.detach().contiguous().numpy().tobytes())
143
+ directions_digest.update(directions.detach().contiguous().numpy().tobytes())
144
+ return [origins_digest.hexdigest(), directions_digest.hexdigest()]
145
+
146
+
147
+ def _signature_local(operator, source, algorithm, settings, elements, compute_dtype):
148
+ frames = getattr(operator, "_learnable", ()) or getattr(operator, "_trajectory", ())
149
+ geometry = tuple(fingerprint(value, elements) for value in frames)
150
+ surface = getattr(operator, "detector_surface", None)
151
+ surface_values = (() if surface is None else getattr(surface, "parameters", ()))
152
+ if isinstance(surface, torch.Tensor):
153
+ surface_values = (surface,)
154
+ return dict(
155
+ schema_version=1, algorithm=algorithm, settings=settings,
156
+ rank=getattr(operator, "rank", 0), world_size=getattr(operator, "world_size", 1),
157
+ partition=getattr(operator, "partition", "views"),
158
+ partition_grid=getattr(operator, "partition_grid", None),
159
+ global_volume_shape=list(operator.global_volume_shape), volume_shape=list(operator.volume_shape),
160
+ volume_slice=[[part.start, part.stop] for part in operator.volume_slice],
161
+ projection_shape=list(operator.projection_shape),
162
+ view_slice=[operator.view_slice.start, operator.view_slice.stop],
163
+ beam=operator.beam, voxel_spacing=operator.voxel_spacing,
164
+ detector_spacing=list(operator.detector_spacing), detector_shape=list(operator.detector_shape),
165
+ input_dtype=str(source.dtype), geometry=list(geometry),
166
+ surface=[fingerprint(value, elements) for value in surface_values],
167
+ effective_surface=_effective_surface(operator, frames, elements),
168
+ compute_dtype=str(compute_dtype), precision=getattr(operator, "precision", None),
169
+ backend=getattr(operator, "backend", None),
170
+ measurements=fingerprint(source, elements),
171
+ )
172
+
173
+
174
+ def signature(operator, source, algorithm, settings, elements, *, compute_dtype=None):
175
+ error, metadata = None, None
176
+ try:
177
+ dtype = getattr(operator, "dtype", source.dtype) if compute_dtype is None else compute_dtype
178
+ metadata = _signature_local(operator, source, algorithm, settings, elements, dtype)
179
+ except Exception as caught:
180
+ error = caught
181
+ agreement(operator, error)
182
+ if getattr(operator, "world_size", 1) > 1:
183
+ records = [None] * operator.world_size
184
+ dist.all_gather_object(records, metadata, group=operator.process_group)
185
+ local_fields = {"rank", "volume_shape", "volume_slice", "projection_shape", "view_slice", "measurements"}
186
+ scans = [{key: value for key, value in record.items() if key not in local_fields} for record in records]
187
+ if any(scan != scans[0] for scan in scans):
188
+ raise ValueError("distributed checkpoint scan and compute configuration must agree")
189
+ replicas = {}
190
+ for record in records:
191
+ views = tuple(record["view_slice"])
192
+ digest = record["measurements"]
193
+ if views in replicas and replicas[views] != digest:
194
+ raise ValueError("distributed checkpoint measurement replicas must agree")
195
+ replicas[views] = digest
196
+ return metadata
197
+
198
+
199
+ def load_checkpoint(operator, directory, expected, iterations, elements, names):
200
+ error, saved, states = None, None, None
201
+ try:
202
+ directory = Path(directory).resolve()
203
+ saved = json.loads((directory / "metadata.json").read_text(encoding="utf-8"))
204
+ # JSON canonicalization also normalizes tuple-valued physical spacing.
205
+ canonical = json.loads(json.dumps(expected))
206
+ if any(saved.get(key) != value for key, value in canonical.items()):
207
+ raise ValueError("checkpoint scan, data, ownership, dtype or algorithm settings do not match")
208
+ completed = count(saved.get("iteration"), "checkpoint iteration")
209
+ history = saved.get("residual_history")
210
+ if completed > iterations or not isinstance(history, list) or len(history) != completed + 1:
211
+ raise ValueError("checkpoint iteration or residual history is invalid")
212
+ for value in history:
213
+ finite(value, "checkpoint residual")
214
+ finite(saved.get("measurement_norm"), "checkpoint measurement norm")
215
+ if expected["algorithm"] in ("cgls", "cgls_into"):
216
+ finite(saved.get("gamma"), "checkpoint gamma")
217
+ manifest = saved.get("states", {})
218
+ if set(manifest) != set(names):
219
+ raise ValueError("checkpoint does not contain the complete solver state")
220
+ states = {}
221
+ for name in names:
222
+ store = NpyStore(directory / f"{name}.npy", mode="r")
223
+ specification = manifest[name]
224
+ shape = operator.projection_shape if name in ("r", "q", "row") else operator.volume_shape
225
+ if name == "step":
226
+ shape = (1,)
227
+ if tuple(store.shape) != tuple(shape) or list(store.shape) != specification.get("shape") or str(store.dtype) != specification.get("dtype"):
228
+ raise ValueError("checkpoint solver state shape or dtype does not match")
229
+ if fingerprint(store, elements) != specification.get("fingerprint"):
230
+ raise ValueError("checkpoint solver state fingerprint does not match")
231
+ states[name] = store
232
+ except (ValueError, TypeError, OSError, EOFError, KeyError) as caught:
233
+ error = caught
234
+ token = None if error is not None else (saved["iteration"], saved["residual_history"], saved.get("gamma"), saved["measurement_norm"])
235
+ agreement(operator, error, token)
236
+ return saved, states
237
+
238
+
239
+ def save_checkpoint(operator, directory, states, metadata, iteration, history, measurement_norm, elements, **scalars):
240
+ error, manifest, rank = None, {}, None
241
+ try:
242
+ step = Path(directory).resolve() / f"step-{iteration:06d}"
243
+ step.mkdir(parents=True, exist_ok=True)
244
+ rank = step / f"rank-{getattr(operator, 'rank', 0):05d}"
245
+ rank.mkdir()
246
+ for name, source in states.items():
247
+ source = TensorStore(source.detach()) if isinstance(source, torch.Tensor) else source
248
+ target = NpyStore.create(rank / f"{name}.npy", source.shape, dtype=source.dtype)
249
+ copy_blocks(source, target, elements)
250
+ target.flush()
251
+ manifest[name] = dict(shape=list(source.shape), dtype=str(source.dtype), fingerprint=fingerprint(target, elements))
252
+ except (OSError, ValueError, TypeError) as caught:
253
+ error = caught
254
+ # All owned states on every rank are written and flushed before any rank
255
+ # atomically publishes the metadata marking a completed iteration.
256
+ agreement(operator, error, (iteration, history))
257
+ error = None
258
+ try:
259
+ document = dict(metadata, iteration=iteration, residual_history=history,
260
+ measurement_norm=measurement_norm, states=manifest, **scalars)
261
+ temporary = rank / "metadata.tmp"
262
+ with temporary.open("x", encoding="utf-8") as file:
263
+ json.dump(document, file)
264
+ file.flush()
265
+ temporary.replace(rank / "metadata.json")
266
+ except Exception as caught:
267
+ error = caught
268
+ # A failed metadata write/rename must stop every rank before another
269
+ # projection or callback can enter a numerical collective.
270
+ agreement(operator, error, iteration)
271
+
272
+
273
+ def callback_stop(operator, callback, iteration, residual, volume):
274
+ error, stop = None, False
275
+ try:
276
+ stop = callback is not None and callback(iteration, residual, volume) is False
277
+ except Exception as caught:
278
+ error = caught
279
+ agreement(operator, error)
280
+ if getattr(operator, "world_size", 1) > 1:
281
+ records = [None] * operator.world_size
282
+ dist.all_gather_object(records, stop, group=operator.process_group)
283
+ stop = any(records)
284
+ return stop
diffct/_solver_tv.py ADDED
@@ -0,0 +1,110 @@
1
+ """Physical forward-difference TV with differentiable slab boundary faces."""
2
+
3
+ import math
4
+ import numbers
5
+
6
+ import torch
7
+ import torch.distributed as dist
8
+
9
+ from ._solver_state import finite, scalar_sum
10
+ from .backends import _zero_dependency
11
+
12
+
13
+ def _peers(operator, nonempty):
14
+ spaces = operator.space_world_size
15
+ current = operator.space_rank
16
+ occupied = [rank for rank in range(spaces) if rank < min(spaces, operator.global_volume_shape[0])]
17
+ previous = max((rank for rank in occupied if rank < current), default=None) if nonempty else None
18
+ following = min((rank for rank in occupied if rank > current), default=None) if nonempty else None
19
+ def global_rank(rank):
20
+ if rank is None:
21
+ return None
22
+ parent = operator.view_rank * spaces + rank
23
+ return operator._partition.global_rank(parent)
24
+ return global_rank(previous), global_rank(following)
25
+
26
+
27
+ class _FollowingFace(torch.autograd.Function):
28
+ @staticmethod
29
+ def forward(ctx, volume, operator):
30
+ previous, following = _peers(operator, volume.shape[0] > 0)
31
+ ctx.operator, ctx.previous, ctx.following = operator, previous, following
32
+ ctx.shape, ctx.dtype, ctx.device = volume.shape, volume.dtype, volume.device
33
+ nccl = dist.get_backend(operator.space_group) == "nccl"
34
+ ctx.communication_device = (torch.device("cuda", torch.cuda.current_device())
35
+ if nccl else torch.device("cpu"))
36
+ if nccl:
37
+ # Initialize the subgroup communicator collectively, including
38
+ # empty slabs, before the first subset-only face exchange.
39
+ dist.barrier(group=operator.space_group)
40
+ face = torch.zeros(volume.shape[1:], dtype=volume.dtype, device=ctx.communication_device)
41
+ operations = []
42
+ if previous is not None:
43
+ first = volume[0].detach().to(device=ctx.communication_device).contiguous()
44
+ operations.append(dist.P2POp(dist.isend, first, previous, operator.space_group))
45
+ if following is not None:
46
+ operations.append(dist.P2POp(dist.irecv, face, following, operator.space_group))
47
+ if operations:
48
+ for work in dist.batch_isend_irecv(operations):
49
+ work.wait()
50
+ return face.to(device=volume.device)
51
+
52
+ @staticmethod
53
+ def backward(ctx, grad_face):
54
+ received = torch.zeros(ctx.shape[1:], dtype=ctx.dtype, device=ctx.communication_device)
55
+ operations = []
56
+ if ctx.following is not None:
57
+ outgoing = grad_face.to(device=ctx.communication_device).contiguous()
58
+ operations.append(dist.P2POp(dist.isend, outgoing, ctx.following, ctx.operator.space_group))
59
+ if ctx.previous is not None:
60
+ operations.append(dist.P2POp(dist.irecv, received, ctx.previous, ctx.operator.space_group))
61
+ if operations:
62
+ for work in dist.batch_isend_irecv(operations):
63
+ work.wait()
64
+ gradient = torch.zeros(ctx.shape, dtype=ctx.dtype, device=ctx.device)
65
+ if ctx.shape[0] > 0:
66
+ gradient[0] = received.to(device=ctx.device)
67
+ return gradient, None
68
+
69
+
70
+ def total_variation(volume, *, operator=None, voxel_spacing=1., eps=1e-6, reduction="mean"):
71
+ """Isotropic TV over every voxel, with physical forward differences.
72
+
73
+ Differences vanish at the global outer boundary. Spatially partitioned
74
+ volumes exchange a single neighboring face in forward and backward; the
75
+ reported scalar counts replicated ownership once and gradients stay local.
76
+ """
77
+ if not isinstance(volume, torch.Tensor) or not torch.is_floating_point(volume) or volume.ndim < 1:
78
+ raise TypeError("volume must be a real floating-point tensor")
79
+ eps = finite(eps, "eps", positive=True)
80
+ if reduction not in ("mean", "sum"):
81
+ raise ValueError("reduction must be mean or sum")
82
+ spacing = (voxel_spacing,) * volume.ndim if isinstance(voxel_spacing, numbers.Real) else tuple(voxel_spacing)
83
+ if len(spacing) != volume.ndim:
84
+ raise ValueError("voxel_spacing must match the volume axes")
85
+ spacing = tuple(finite(pitch, "voxel_spacing", positive=True) for pitch in spacing)
86
+ if operator is not None and tuple(volume.shape) != tuple(operator.volume_shape):
87
+ raise ValueError("volume shape must match its operator ownership")
88
+ distributed_space = operator is not None and getattr(operator, "world_size", 1) > 1 and operator.partition in ("space", "hybrid")
89
+ halo = _FollowingFace.apply(volume, operator) if distributed_space else None
90
+ squared = torch.zeros_like(volume)
91
+ for axis, pitch in enumerate(spacing):
92
+ current, following = [slice(None)] * volume.ndim, [slice(None)] * volume.ndim
93
+ current[axis], following[axis] = slice(0, -1), slice(1, None)
94
+ difference = torch.zeros_like(volume)
95
+ difference[tuple(current)] = (volume[tuple(following)] - volume[tuple(current)]) / pitch
96
+ if axis == 0 and halo is not None and volume.shape[0] and operator.volume_slice[0].stop < operator.global_volume_shape[0]:
97
+ difference[-1] = (halo - volume[-1]) / pitch
98
+ squared = squared + difference.square()
99
+ local = torch.sqrt(squared + eps).sum()
100
+ if halo is not None:
101
+ # The final slab also needs backward to receive the previous slab's
102
+ # contribution, even though its own global outer difference is zero.
103
+ local = local + _zero_dependency(halo)
104
+ total = local
105
+ elements = volume.numel()
106
+ if operator is not None:
107
+ global_value = scalar_sum(operator, local.detach().item(), "volume")
108
+ total = local + (local.new_tensor(global_value) - local.detach())
109
+ elements = math.prod(operator.global_volume_shape)
110
+ return total if reduction == "sum" else total / max(elements, 1)
diffct/analytical.py CHANGED
@@ -36,16 +36,29 @@ from .utils import (
36
36
  _grid_3d,
37
37
  _on_device_of,
38
38
  )
39
- from .kernels import (
40
- _parallel_2d_fbp_backproject_kernel,
41
- _fan_2d_fbp_backproject_kernel,
42
- _cone_3d_fdk_backproject_kernel,
43
- _parallel_2d_fbp_transpose_kernel,
44
- _fan_2d_fbp_transpose_kernel,
45
- _cone_3d_fdk_transpose_kernel,
39
+ from . import kernels as _kernels
40
+
41
+
42
+ _KERNEL_NAMES = (
43
+ '_parallel_2d_fbp_backproject_kernel',
44
+ '_fan_2d_fbp_backproject_kernel',
45
+ '_cone_3d_fdk_backproject_kernel',
46
+ '_parallel_2d_fbp_transpose_kernel',
47
+ '_fan_2d_fbp_transpose_kernel',
48
+ '_cone_3d_fdk_transpose_kernel',
46
49
  )
47
50
 
48
51
 
52
+ def _resolve_kernel(name):
53
+ return globals()[name] if name in globals() else getattr(_kernels, name)
54
+
55
+
56
+ def __getattr__(name):
57
+ if name not in _KERNEL_NAMES:
58
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
59
+ return getattr(_kernels, name)
60
+
61
+
49
62
  # ============================================================================
50
63
  # Detector coordinate helper
51
64
  # ============================================================================
@@ -95,8 +108,11 @@ def angular_integration_weights(angles, redundant_full_scan=True):
95
108
  ``redundant_full_scan=True`` for automatic fan/cone scan classification.
96
109
  Setting it to ``False`` explicitly disables the full-turn ``1/2`` factor.
97
110
  Parker and angular quadrature share the same periodic-scan decision.
111
+ FP16 and BF16 angles are promoted to FP32; FP64 angles retain their precision.
98
112
  """
99
- angles = angles.to(dtype=torch.float32).contiguous()
113
+ if angles.dtype not in (torch.float32, torch.float64):
114
+ angles = angles.to(dtype=torch.float32)
115
+ angles = angles.contiguous()
100
116
  n = angles.shape[0]
101
117
  if n == 0:
102
118
  return torch.zeros_like(angles)
@@ -105,6 +121,8 @@ def angular_integration_weights(angles, redundant_full_scan=True):
105
121
 
106
122
  device = angles.device
107
123
  angles_sorted, sort_idx = torch.sort(angles)
124
+ # Take gaps between sorted neighbours directly. Removing an origin first
125
+ # rounds every angle again and can collapse distinct neighbours.
108
126
  diffs = angles_sorted[1:] - angles_sorted[:-1]
109
127
 
110
128
  # Endpoint-excluded uniform circular scans are periodic quadrature
@@ -194,8 +212,11 @@ def parker_weights(angles, num_detectors, detector_spacing, sdd, detector_offset
194
212
  Only meaningful for circular (fan-shaped) trajectories — when using
195
213
  non-circular trajectories you typically want either a constant weight
196
214
  (full-coverage) or a custom redundancy scheme.
215
+ FP16 and BF16 angles are promoted to FP32; FP64 angles retain their precision.
197
216
  """
198
- angles = angles.to(dtype=torch.float32).contiguous()
217
+ if angles.dtype not in (torch.float32, torch.float64):
218
+ angles = angles.to(dtype=torch.float32)
219
+ angles = angles.contiguous()
199
220
  n_ang = angles.shape[0]
200
221
  device = angles.device
201
222
 
@@ -407,7 +428,7 @@ def _parallel_weighted_backproject_raw(sinogram, ray_dir, det_origin, det_u_vec,
407
428
  pt_stream = torch.cuda.current_stream()
408
429
  numba_stream = _get_numba_external_stream_for(pt_stream)
409
430
 
410
- _parallel_2d_fbp_backproject_kernel[grid, tpb, numba_stream](
431
+ _resolve_kernel('_parallel_2d_fbp_backproject_kernel')[grid, tpb, numba_stream](
411
432
  d_sino, n_views, n_det, d_reco, Nx, Ny,
412
433
  _DTYPE(detector_spacing), d_ray_dir, d_det_origin, d_det_u_vec,
413
434
  cx, cy, _DTYPE(voxel_spacing),
@@ -450,7 +471,7 @@ def _analytical_geometry(src_pos, det_center, n, isocenter):
450
471
  if not (torch.isfinite(sid_n).all() and torch.isfinite(sdd_n).all()
451
472
  and (sid_n > 1e-6).all() and (sdd_n > 1e-6).all()):
452
473
  raise ValueError("Source-to-isocenter and source-to-detector distances must be positive")
453
- return isocenter, float(sid_n.mean().item()), float(sdd_n.mean().item())
474
+ return isocenter, sid_n, sdd_n
454
475
 
455
476
 
456
477
  @_on_device_of("sinogram")
@@ -460,10 +481,10 @@ def _fan_weighted_backproject_raw(sinogram, src_pos, det_center, det_u_vec,
460
481
 
461
482
  Runs the dedicated fan-beam FBP gather kernel (one thread per output
462
483
  pixel, ``(sid_n/U_n)^2`` distance weighting, linear interpolation of
463
- the filtered sinogram) and multiplies by the analytical constant
464
- ``sdd_mean / (2 * pi * sid_mean)`` so a unit-density disk reconstructs
465
- to amplitude 1. Both means are measured in the direction of the
466
- detector normal per view, so the helper reduces to the classical
484
+ the filtered sinogram), applying the analytical factor
485
+ ``sdd_n / (2 * pi * sid_n)`` to each view so a unit-density disk
486
+ reconstructs to amplitude 1. Both distances are measured in the
487
+ detector-normal direction per view, so the helper reduces to the classical
467
488
  ``sdd/(2*pi*sid)`` on a circular orbit. ``isocenter`` is a physical
468
489
  coordinate pair; by default it is inferred from the source lines along
469
490
  detector normals. Supply it explicitly for an ambiguous or non-circular
@@ -477,9 +498,10 @@ def _fan_weighted_backproject_raw(sinogram, src_pos, det_center, det_u_vec,
477
498
  det_center = _as_contig_f32(det_center, device)
478
499
  det_u_vec = _as_contig_f32(det_u_vec, device)
479
500
  normal = torch.stack((-det_u_vec[:, 1], det_u_vec[:, 0]), dim=1)
480
- isocenter, sid_mean, sdd_mean = _analytical_geometry(
501
+ isocenter, sid_n, sdd_n = _analytical_geometry(
481
502
  src_pos, det_center, normal, isocenter,
482
503
  )
504
+ sinogram = sinogram * (sdd_n / (2.0 * math.pi * sid_n)).view(-1, 1)
483
505
 
484
506
  n_views, n_det = sinogram.shape
485
507
  Ny, Nx = H, W
@@ -497,7 +519,7 @@ def _fan_weighted_backproject_raw(sinogram, src_pos, det_center, det_u_vec,
497
519
  pt_stream = torch.cuda.current_stream()
498
520
  numba_stream = _get_numba_external_stream_for(pt_stream)
499
521
 
500
- _fan_2d_fbp_backproject_kernel[grid, tpb, numba_stream](
522
+ _resolve_kernel('_fan_2d_fbp_backproject_kernel')[grid, tpb, numba_stream](
501
523
  d_sino, n_views, n_det, d_reco, Nx, Ny,
502
524
  _DTYPE(detector_spacing), d_src_pos, d_det_center, d_det_u_vec,
503
525
  cx, cy, _DTYPE(voxel_spacing),
@@ -505,8 +527,7 @@ def _fan_weighted_backproject_raw(sinogram, src_pos, det_center, det_u_vec,
505
527
  _DTYPE(isocenter[1].item() / voxel_spacing),
506
528
  )
507
529
 
508
- scale = sdd_mean / (2.0 * math.pi * sid_mean)
509
- return reco * scale
530
+ return reco
510
531
 
511
532
 
512
533
  @_on_device_of("sinogram")
@@ -516,9 +537,9 @@ def _cone_weighted_backproject_raw(sinogram, src_pos, det_center, det_u_vec, det
516
537
 
517
538
  Runs the dedicated cone-beam FDK gather kernel (one thread per output
518
539
  voxel, ``(sid_n/U_n)^2`` distance weighting, bilinear interpolation of
519
- the filtered sinogram) and multiplies by ``sdd_mean/(2*pi*sid_mean)``
520
- so a unit-density sphere reconstructs to amplitude 1. Both means are
521
- taken in the per-view detector-normal direction, so the helper
540
+ the filtered sinogram), applying ``sdd_n/(2*pi*sid_n)`` to each view
541
+ so a unit-density sphere reconstructs to amplitude 1. Both distances are
542
+ measured in the per-view detector-normal direction, so the helper
522
543
  reduces to the classical ``sdd/(2*pi*sid)`` on a circular orbit.
523
544
  ``isocenter`` is a physical coordinate triple, inferred from source
524
545
  lines along detector normals by default. Supply it explicitly for an
@@ -534,9 +555,10 @@ def _cone_weighted_backproject_raw(sinogram, src_pos, det_center, det_u_vec, det
534
555
  det_u_vec = _as_contig_f32(det_u_vec, device)
535
556
  det_v_vec = _as_contig_f32(det_v_vec, device)
536
557
  normal = torch.cross(det_u_vec, det_v_vec, dim=1)
537
- isocenter, sid_mean, sdd_mean = _analytical_geometry(
558
+ isocenter, sid_n, sdd_n = _analytical_geometry(
538
559
  src_pos, det_center, normal, isocenter,
539
560
  )
561
+ sinogram = sinogram * (sdd_n / (2.0 * math.pi * sid_n)).view(-1, 1, 1)
540
562
 
541
563
  n_views, n_u, n_v = sinogram.shape
542
564
  Nx, Ny, Nz = W, H, D
@@ -559,7 +581,7 @@ def _cone_weighted_backproject_raw(sinogram, src_pos, det_center, det_u_vec, det
559
581
  pt_stream = torch.cuda.current_stream()
560
582
  numba_stream = _get_numba_external_stream_for(pt_stream)
561
583
 
562
- _cone_3d_fdk_backproject_kernel[grid, tpb, numba_stream](
584
+ _resolve_kernel('_cone_3d_fdk_backproject_kernel')[grid, tpb, numba_stream](
563
585
  d_sino, n_views, n_u, n_v, d_reco, Nx, Ny, Nz,
564
586
  _DTYPE(du), _DTYPE(dv),
565
587
  d_src_pos, d_det_center, d_det_u_vec, d_det_v_vec,
@@ -569,10 +591,8 @@ def _cone_weighted_backproject_raw(sinogram, src_pos, det_center, det_u_vec, det
569
591
  _DTYPE(isocenter[2].item() / voxel_spacing),
570
592
  )
571
593
 
572
- scale = sdd_mean / (2.0 * math.pi * sid_mean)
573
-
574
594
  # Convert back to (D, H, W) layout for the user.
575
- reco = reco_perm.permute(2, 1, 0).contiguous() * scale
595
+ reco = reco_perm.permute(2, 1, 0).contiguous()
576
596
  return reco
577
597
 
578
598
 
@@ -616,17 +636,18 @@ def _analytical_transpose_raw(image, beam, geometry, parameters, isocenter, sino
616
636
  source, detector, u = geometry[:3]
617
637
  normal = (torch.stack((-u[:, 1], u[:, 0]), dim=1) if beam == "fan"
618
638
  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)
639
+ isocenter, sid_n, sdd_n = _analytical_geometry(source, detector, normal, isocenter)
640
+ scale = (sdd_n / (2.0 * math.pi * sid_n)).view(
641
+ (-1,) + (1,) * (len(sinogram_shape) - 1)
642
+ )
621
643
  iso = tuple(_DTYPE(value.item() / parameters[-1]) for value in isocenter)
622
- image = image * scale
623
644
 
624
645
  if beam == "cone":
625
646
  D, H, W, du, dv, spacing = parameters
626
647
  image = image.permute(2, 1, 0).contiguous()
627
648
  d_image = TorchCUDABridge.tensor_to_cuda_array(image)
628
649
  grid, tpb = _grid_3d(D, H, W)
629
- _cone_3d_fdk_transpose_kernel[grid, tpb, stream](
650
+ _resolve_kernel('_cone_3d_fdk_transpose_kernel')[grid, tpb, stream](
630
651
  d_sino, *sinogram_shape, d_image, W, H, D, _DTYPE(du), _DTYPE(dv),
631
652
  *d_geometry, _DTYPE(W * 0.5), _DTYPE(H * 0.5), _DTYPE(D * 0.5),
632
653
  _DTYPE(spacing), *iso,
@@ -635,13 +656,13 @@ def _analytical_transpose_raw(image, beam, geometry, parameters, isocenter, sino
635
656
  pitch, H, W, spacing = parameters
636
657
  d_image = TorchCUDABridge.tensor_to_cuda_array(image)
637
658
  grid, tpb = _grid_2d(W, H)
638
- kernel = (_parallel_2d_fbp_transpose_kernel if beam == "parallel"
639
- else _fan_2d_fbp_transpose_kernel)
659
+ kernel = _resolve_kernel('_parallel_2d_fbp_transpose_kernel' if beam == "parallel"
660
+ else '_fan_2d_fbp_transpose_kernel')
640
661
  kernel[grid, tpb, stream](
641
662
  d_sino, *sinogram_shape, d_image, W, H, _DTYPE(pitch), *d_geometry,
642
663
  _DTYPE(W * 0.5), _DTYPE(H * 0.5), _DTYPE(spacing), *iso,
643
664
  )
644
- return sinogram
665
+ return sinogram * scale
645
666
 
646
667
 
647
668
  class _AnalyticalGather(torch.autograd.Function):