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 +13 -1
- diffct/_solver_state.py +284 -0
- diffct/_solver_tv.py +110 -0
- diffct/analytical.py +55 -34
- diffct/analytical_execution.py +427 -0
- diffct/backends.py +675 -0
- diffct/constants.py +10 -5
- diffct/distribution.py +137 -0
- diffct/kernels/__init__.py +26 -25
- diffct/operators.py +138 -68
- diffct/projectors.py +33 -16
- diffct/reconstruction.py +108 -24
- diffct/solvers.py +318 -0
- diffct/streaming.py +104 -12
- diffct/utils.py +9 -1
- {diffct-2.2.1.dist-info → diffct-2.3.0.dist-info}/METADATA +11 -8
- diffct-2.3.0.dist-info/RECORD +31 -0
- diffct-2.2.1.dist-info/RECORD +0 -25
- {diffct-2.2.1.dist-info → diffct-2.3.0.dist-info}/WHEEL +0 -0
- {diffct-2.2.1.dist-info → diffct-2.3.0.dist-info}/licenses/LICENSE +0 -0
- {diffct-2.2.1.dist-info → diffct-2.3.0.dist-info}/licenses/NOTICE +0 -0
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.
|
|
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',
|
diffct/_solver_state.py
ADDED
|
@@ -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 .
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
|
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,
|
|
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)
|
|
464
|
-
``
|
|
465
|
-
to amplitude 1. Both
|
|
466
|
-
detector
|
|
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,
|
|
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
|
-
|
|
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)
|
|
520
|
-
so a unit-density sphere reconstructs to amplitude 1. Both
|
|
521
|
-
|
|
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,
|
|
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()
|
|
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,
|
|
620
|
-
scale =
|
|
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
|
-
|
|
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):
|