diffct 2.4.0__py3-none-any.whl → 2.4.2__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 +1 -1
- diffct/_solver_state.py +7 -1
- diffct/backends.py +165 -110
- diffct/kernels/cone_beam.py +27 -20
- diffct/kernels/fan_beam.py +23 -16
- diffct/kernels/geometry_vjp.py +45 -31
- diffct/kernels/surface_geometry.py +13 -7
- diffct/operators.py +171 -40
- diffct/projectors.py +3 -2
- diffct/surfaces.py +2 -0
- {diffct-2.4.0.dist-info → diffct-2.4.2.dist-info}/METADATA +7 -7
- {diffct-2.4.0.dist-info → diffct-2.4.2.dist-info}/RECORD +15 -15
- {diffct-2.4.0.dist-info → diffct-2.4.2.dist-info}/WHEEL +0 -0
- {diffct-2.4.0.dist-info → diffct-2.4.2.dist-info}/licenses/LICENSE +0 -0
- {diffct-2.4.0.dist-info → diffct-2.4.2.dist-info}/licenses/NOTICE +0 -0
diffct/__init__.py
CHANGED
diffct/_solver_state.py
CHANGED
|
@@ -211,6 +211,8 @@ def load_checkpoint(operator, directory, expected, iterations, elements, names):
|
|
|
211
211
|
try:
|
|
212
212
|
directory = Path(directory).resolve()
|
|
213
213
|
saved = json.loads((directory / "metadata.json").read_text(encoding="utf-8"))
|
|
214
|
+
if not isinstance(saved, dict):
|
|
215
|
+
raise ValueError("checkpoint metadata must be an object")
|
|
214
216
|
# JSON canonicalization also normalizes tuple-valued physical spacing.
|
|
215
217
|
canonical = json.loads(json.dumps(expected))
|
|
216
218
|
if any(saved.get(key) != value for key, value in canonical.items()):
|
|
@@ -225,12 +227,16 @@ def load_checkpoint(operator, directory, expected, iterations, elements, names):
|
|
|
225
227
|
if expected["algorithm"] in ("cgls", "cgls_into"):
|
|
226
228
|
finite(saved.get("gamma"), "checkpoint gamma")
|
|
227
229
|
manifest = saved.get("states", {})
|
|
230
|
+
if not isinstance(manifest, dict):
|
|
231
|
+
raise ValueError("checkpoint states must be an object")
|
|
228
232
|
if set(manifest) != set(names):
|
|
229
233
|
raise ValueError("checkpoint does not contain the complete solver state")
|
|
230
234
|
states = {}
|
|
231
235
|
for name in names:
|
|
232
|
-
store = NpyStore(directory / f"{name}.npy", mode="r")
|
|
233
236
|
specification = manifest[name]
|
|
237
|
+
if not isinstance(specification, dict):
|
|
238
|
+
raise ValueError("checkpoint state specification must be an object")
|
|
239
|
+
store = NpyStore(directory / f"{name}.npy", mode="r")
|
|
234
240
|
shape = operator.projection_shape if name in ("r", "q", "row") else operator.volume_shape
|
|
235
241
|
if name == "step":
|
|
236
242
|
shape = (1,)
|
diffct/backends.py
CHANGED
|
@@ -168,7 +168,8 @@ class ReferenceProjector:
|
|
|
168
168
|
self.spacing = voxel_spacing_tuple(owner.voxel_spacing, len(owner.global_volume_shape))
|
|
169
169
|
self.chunk_shape, self.view_chunk_size, self.detector_chunk_shape = self._work_chunks()
|
|
170
170
|
if not validate:
|
|
171
|
-
#
|
|
171
|
+
# Call-time owners validate frames separately and sample surfaces
|
|
172
|
+
# from the invocation's single geometry snapshot below.
|
|
172
173
|
return
|
|
173
174
|
with torch.enable_grad():
|
|
174
175
|
geometry = self._snapshot_geometry(synchronize=synchronize)
|
|
@@ -273,8 +274,7 @@ class ReferenceProjector:
|
|
|
273
274
|
def _validate_geometry(self, geometry, fallback, *, admission=False, synchronize=True):
|
|
274
275
|
"""Finish every local geometry check before any rank starts numerics."""
|
|
275
276
|
owner = self.owner
|
|
276
|
-
if not admission and
|
|
277
|
-
(owner.world_size == 1 and isinstance(owner.detector_surface, ParameterizedSurface))):
|
|
277
|
+
if not admission and owner.detector_surface is None:
|
|
278
278
|
return
|
|
279
279
|
devices = tuple(torch.device(device) for device in (owner._devices or (fallback,)))
|
|
280
280
|
error = None
|
|
@@ -294,14 +294,8 @@ class ReferenceProjector:
|
|
|
294
294
|
device = devices[batch % len(devices)]
|
|
295
295
|
origins, directions, _ = self._rays(
|
|
296
296
|
views, pixels, device, validate=True, geometry=staged[device],
|
|
297
|
-
admission=admission,
|
|
298
297
|
)
|
|
299
|
-
|
|
300
|
-
or not torch.isfinite(directions.to(dtype=owner.dtype)).all().item()):
|
|
301
|
-
raise ValueError("detector surface world coordinates must be finite")
|
|
302
|
-
if (owner.beam not in ("parallel", "parallel3d")
|
|
303
|
-
and torch.any(torch.all(directions == 0, dim=-1)).item()):
|
|
304
|
-
raise ValueError("source and detector surface pixels must differ")
|
|
298
|
+
self._check_rays(origins, directions)
|
|
305
299
|
except (TypeError, ValueError, RuntimeError) as failure:
|
|
306
300
|
error = failure
|
|
307
301
|
if synchronize:
|
|
@@ -309,10 +303,23 @@ class ReferenceProjector:
|
|
|
309
303
|
elif error is not None:
|
|
310
304
|
raise error
|
|
311
305
|
|
|
312
|
-
def
|
|
306
|
+
def _check_rays(self, origins, directions):
|
|
307
|
+
owner = self.owner
|
|
308
|
+
if (not torch.isfinite(origins.to(dtype=owner.dtype)).all().item()
|
|
309
|
+
or not torch.isfinite(directions.to(dtype=owner.dtype)).all().item()):
|
|
310
|
+
raise ValueError("detector surface world coordinates must be finite")
|
|
311
|
+
if (owner.beam not in ("parallel", "parallel3d")
|
|
312
|
+
and torch.any(torch.all(directions == 0, dim=-1)).item()):
|
|
313
|
+
raise ValueError("source and detector surface pixels must differ")
|
|
314
|
+
|
|
315
|
+
def _rays(self, views, pixels, device, validate=False, stats=None, geometry=None, *, admit=False):
|
|
316
|
+
"""Rays of one view/pixel batch.
|
|
317
|
+
|
|
318
|
+
``admit`` applies the admission checks of ``_validate_geometry`` to the
|
|
319
|
+
same sampled offsets before composing the numerical rays, so a
|
|
320
|
+
parameterized sampler runs once for both.
|
|
321
|
+
"""
|
|
313
322
|
owner = self.owner
|
|
314
|
-
# Admission compares original coordinates before FP32 rounding.
|
|
315
|
-
dtype = torch.float64 if validate and admission else owner.dtype
|
|
316
323
|
axes = [(torch.arange(pixel.start, pixel.stop, dtype=torch.float64, device="cpu")
|
|
317
324
|
+ .5 - size / 2) * pitch
|
|
318
325
|
for pixel, size, pitch in zip(pixels, owner.detector_shape, owner.detector_spacing)]
|
|
@@ -338,99 +345,138 @@ class ReferenceProjector:
|
|
|
338
345
|
offsets = offsets[views]
|
|
339
346
|
elif tuple(offsets.shape) not in (shared_shape, (len(ids), *shared_shape)):
|
|
340
347
|
raise ValueError("detector_surface output shape must match the selected view/pixel batch")
|
|
341
|
-
if validate:
|
|
348
|
+
if validate or admit:
|
|
342
349
|
if not torch.isfinite(offsets).all().item():
|
|
343
350
|
raise ValueError("detector_surface offsets must be finite")
|
|
344
351
|
if owner.beam not in ("cone", "parallel3d") and torch.any(offsets[..., 1] != 0).item():
|
|
345
352
|
raise ValueError("2D detector_surface middle offsets must be zero")
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
353
|
+
def compose(validate, stats):
|
|
354
|
+
check_range = (validate and owner.beam not in ("parallel", "parallel3d")
|
|
355
|
+
and owner.dtype == torch.float32 and surface is not None)
|
|
356
|
+
dtype = torch.float64 if check_range else owner.dtype
|
|
357
|
+
frames = (owner._learnable or owner._trajectory) if geometry is None else geometry[:frame_count]
|
|
358
|
+
native_surface = owner.backend == "numba" and surface is not None
|
|
359
|
+
native_legacy = native_surface and not isinstance(surface, ParameterizedSurface)
|
|
360
|
+
if native_surface and not native_legacy:
|
|
361
|
+
dtype = torch.float32
|
|
362
|
+
for component in (*frames, offsets):
|
|
363
|
+
dtype = torch.promote_types(dtype, component.dtype)
|
|
364
|
+
local_dtype = offsets.dtype if native_legacy else dtype
|
|
365
|
+
local = self._transfer(offsets.to(dtype=local_dtype), device, stats)
|
|
366
|
+
if local.ndim == u.ndim + 1:
|
|
367
|
+
local = local.unsqueeze(0)
|
|
368
|
+
dimensions = len(owner.global_volume_shape)
|
|
369
|
+
selected = tuple(self._transfer(value[views].to(
|
|
370
|
+
dtype=torch.promote_types(torch.float32, value.dtype) if native_legacy else dtype,
|
|
371
|
+
), device, stats) for value in frames)
|
|
372
|
+
|
|
373
|
+
def frame(value):
|
|
374
|
+
return value.reshape(len(ids), *([1] * u.ndim), dimensions)
|
|
375
|
+
|
|
376
|
+
def world_points(offsets, components):
|
|
377
|
+
axis_u = frame(components[2])
|
|
378
|
+
points = frame(components[1]) + offsets[..., 0, None] * axis_u
|
|
379
|
+
if owner.beam in ("cone", "parallel3d"):
|
|
380
|
+
axis_v = frame(components[3])
|
|
381
|
+
points = points + offsets[..., 1, None] * axis_v
|
|
382
|
+
normal_dtype = torch.promote_types(axis_u.dtype, axis_v.dtype)
|
|
383
|
+
normal = torch.linalg.cross(axis_u.to(dtype=normal_dtype),
|
|
384
|
+
axis_v.to(dtype=normal_dtype), dim=-1)
|
|
385
|
+
else:
|
|
386
|
+
normal = torch.stack((axis_u[..., 1], -axis_u[..., 0]), dim=-1)
|
|
387
|
+
return points + offsets[..., 2, None] * normal
|
|
388
|
+
|
|
389
|
+
if check_range and not native_surface:
|
|
390
|
+
# Certify ordinary batches without expanding raw FP64 world points.
|
|
391
|
+
# L1 norms bound Euclidean distances without squaring tiny bases.
|
|
392
|
+
pitches = local.new_tensor(self.spacing[::-1])
|
|
393
|
+
maximum = local.abs().amax(dim=tuple(range(1, local.ndim - 1)))
|
|
394
|
+
axis_u = selected[2]
|
|
395
|
+
if owner.beam == "cone":
|
|
396
|
+
basis = (axis_u, selected[3], torch.linalg.cross(axis_u, selected[3], dim=-1))
|
|
397
|
+
components = (0, 1, 2)
|
|
398
|
+
else:
|
|
399
|
+
basis = (axis_u, torch.stack((axis_u[:, 1], -axis_u[:, 0]), dim=-1))
|
|
400
|
+
components = (0, 2)
|
|
401
|
+
source_bound = torch.linalg.vector_norm(selected[0] / pitches, ord=1, dim=-1)
|
|
402
|
+
pixel_bound = torch.linalg.vector_norm(selected[1] / pitches, ord=1, dim=-1)
|
|
403
|
+
for component, axis in zip(components, basis):
|
|
404
|
+
pixel_bound = pixel_bound + maximum[:, component] * torch.linalg.vector_norm(
|
|
405
|
+
axis / pitches, ord=1, dim=-1,
|
|
406
|
+
)
|
|
407
|
+
# Cover rounding in the few frame/offset operations; nonfinite or
|
|
408
|
+
# boundary-adjacent bounds remain inconclusive and use exact points.
|
|
409
|
+
slack = 64 * torch.finfo(torch.float64).eps
|
|
410
|
+
source_bound = source_bound + slack * source_bound.clamp_min(1)
|
|
411
|
+
pixel_bound = pixel_bound + slack * pixel_bound.clamp_min(1)
|
|
412
|
+
certified = (torch.isfinite(source_bound) & torch.isfinite(pixel_bound)
|
|
413
|
+
& (torch.minimum(source_bound, pixel_bound) <= 1e6)
|
|
414
|
+
& (source_bound <= 1e15) & (pixel_bound <= 1e15))
|
|
415
|
+
if certified.all().item():
|
|
416
|
+
check_range = False
|
|
417
|
+
dtype = owner.dtype
|
|
418
|
+
selected = tuple(value.to(dtype=dtype) for value in selected)
|
|
419
|
+
local = local.to(dtype=dtype)
|
|
420
|
+
points = world_points(local, selected)
|
|
421
|
+
if check_range:
|
|
422
|
+
# Raw FP64 coordinates preserve the exact admission boundaries.
|
|
423
|
+
sources = frame(selected[0]).expand_as(points)
|
|
424
|
+
pitches = points.new_tensor(self.spacing[::-1])
|
|
425
|
+
source_distance = torch.linalg.vector_norm(sources / pitches, dim=-1)
|
|
426
|
+
pixel_distance = torch.linalg.vector_norm(points / pitches, dim=-1)
|
|
427
|
+
if torch.any(torch.minimum(source_distance, pixel_distance) > 1e6).item():
|
|
428
|
+
raise ValueError(
|
|
429
|
+
"the source or a detector pixel must lie within 1e6 voxels "
|
|
430
|
+
"of the volume center in every view"
|
|
431
|
+
)
|
|
432
|
+
if torch.any((source_distance > 1e15) | (pixel_distance > 1e15)).item():
|
|
433
|
+
raise ValueError(
|
|
434
|
+
"the source and detector surface pixels must lie within 1e15 voxels "
|
|
435
|
+
"of the volume center"
|
|
436
|
+
)
|
|
437
|
+
if native_surface:
|
|
438
|
+
# Native surface preprocessing composes before staging complete
|
|
439
|
+
# rays to FP32. Data transforms must preserve that operator.
|
|
440
|
+
selected = tuple(value.to(dtype=owner.dtype) for value in selected)
|
|
441
|
+
points = points.to(dtype=owner.dtype)
|
|
442
|
+
elif validate and dtype != owner.dtype:
|
|
443
|
+
# Casting the completed point misses cancellation/overflow during
|
|
444
|
+
# FP32 component arithmetic. Reuse the same sampled offsets.
|
|
445
|
+
selected = tuple(value.to(dtype=owner.dtype) for value in selected)
|
|
446
|
+
points = world_points(local.to(dtype=owner.dtype), selected)
|
|
447
|
+
if owner.beam in ("parallel", "parallel3d"):
|
|
448
|
+
origins, directions = points, frame(selected[0]).expand_as(points)
|
|
374
449
|
else:
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
points = world_points(local.to(dtype=owner.dtype), selected)
|
|
406
|
-
if owner.beam in ("parallel", "parallel3d"):
|
|
407
|
-
origins, directions = points, frame(selected[0]).expand_as(points)
|
|
408
|
-
else:
|
|
409
|
-
sources = frame(selected[0]).expand_as(points)
|
|
410
|
-
pitches = points.new_tensor(self.spacing[::-1])
|
|
411
|
-
source_distance = torch.linalg.vector_norm(sources / pitches, dim=-1)
|
|
412
|
-
pixel_distance = torch.linalg.vector_norm(points / pitches, dim=-1)
|
|
413
|
-
if validate:
|
|
414
|
-
if torch.any(torch.all(
|
|
415
|
-
points.to(dtype=owner.dtype) == sources.to(dtype=owner.dtype), dim=-1,
|
|
416
|
-
)).item():
|
|
417
|
-
raise ValueError("source and detector surface pixels must differ")
|
|
418
|
-
# Starting near the volume avoids subtracting its voxel planes
|
|
419
|
-
# from a distant endpoint; reversing a segment preserves [0, 1].
|
|
420
|
-
source_nearer = (source_distance <= pixel_distance).unsqueeze(-1)
|
|
421
|
-
origins = torch.where(source_nearer, sources, points)
|
|
422
|
-
difference = points - sources
|
|
423
|
-
directions = torch.where(source_nearer, difference, -difference)
|
|
424
|
-
|
|
425
|
-
pixel_ids = torch.meshgrid(*(torch.arange(pixel.start, pixel.stop, device=device)
|
|
426
|
-
for pixel in pixels), indexing="ij")
|
|
427
|
-
detector_ids = torch.zeros_like(pixel_ids[0])
|
|
428
|
-
for coordinate, size in zip(pixel_ids, owner.detector_shape):
|
|
429
|
-
detector_ids = detector_ids * size + coordinate
|
|
430
|
-
ray_ids = ((self._transfer(ids, device, stats) - owner.view_slice.start).reshape(
|
|
431
|
-
len(ids), *([1] * u.ndim),
|
|
432
|
-
) * math.prod(owner.detector_shape) + detector_ids).reshape(-1)
|
|
433
|
-
return origins.reshape(-1, dimensions), directions.reshape(-1, dimensions), ray_ids
|
|
450
|
+
sources = frame(selected[0]).expand_as(points)
|
|
451
|
+
pitches = points.new_tensor(self.spacing[::-1])
|
|
452
|
+
source_distance = torch.linalg.vector_norm(sources / pitches, dim=-1)
|
|
453
|
+
pixel_distance = torch.linalg.vector_norm(points / pitches, dim=-1)
|
|
454
|
+
if validate:
|
|
455
|
+
if torch.any(torch.all(
|
|
456
|
+
points.to(dtype=owner.dtype) == sources.to(dtype=owner.dtype), dim=-1,
|
|
457
|
+
)).item():
|
|
458
|
+
raise ValueError("source and detector surface pixels must differ")
|
|
459
|
+
# Starting near the volume avoids subtracting its voxel planes
|
|
460
|
+
# from a distant endpoint; reversing a segment preserves [0, 1].
|
|
461
|
+
source_nearer = (source_distance <= pixel_distance).unsqueeze(-1)
|
|
462
|
+
origins = torch.where(source_nearer, sources, points)
|
|
463
|
+
difference = points - sources
|
|
464
|
+
directions = torch.where(source_nearer, difference, -difference)
|
|
465
|
+
|
|
466
|
+
pixel_ids = torch.meshgrid(*(torch.arange(pixel.start, pixel.stop, device=device)
|
|
467
|
+
for pixel in pixels), indexing="ij")
|
|
468
|
+
detector_ids = torch.zeros_like(pixel_ids[0])
|
|
469
|
+
for coordinate, size in zip(pixel_ids, owner.detector_shape):
|
|
470
|
+
detector_ids = detector_ids * size + coordinate
|
|
471
|
+
ray_ids = ((self._transfer(ids, device, stats) - owner.view_slice.start).reshape(
|
|
472
|
+
len(ids), *([1] * u.ndim),
|
|
473
|
+
) * math.prod(owner.detector_shape) + detector_ids).reshape(-1)
|
|
474
|
+
return origins.reshape(-1, dimensions), directions.reshape(-1, dimensions), ray_ids
|
|
475
|
+
|
|
476
|
+
if admit:
|
|
477
|
+
with torch.no_grad():
|
|
478
|
+
self._check_rays(*compose(True, None)[:2])
|
|
479
|
+
return compose(validate, stats)
|
|
434
480
|
|
|
435
481
|
def _intervals(self, origins, directions, tile):
|
|
436
482
|
owner = self.owner
|
|
@@ -547,7 +593,7 @@ class ReferenceProjector:
|
|
|
547
593
|
return indices.flatten()
|
|
548
594
|
|
|
549
595
|
def _tensor_apply(self, tensor, is_project, reduce_cotangent=False, *, geometry=None,
|
|
550
|
-
reduce_output=True, _plan=None):
|
|
596
|
+
reduce_output=True, _plan=None, admit=False):
|
|
551
597
|
owner = self.owner
|
|
552
598
|
owner.last_execution_stats = None
|
|
553
599
|
start = time.perf_counter()
|
|
@@ -588,7 +634,7 @@ class ReferenceProjector:
|
|
|
588
634
|
slices[axis] = slice(first, min(first + step, leading[axis]))
|
|
589
635
|
pieces.append(self._tensor_apply(tensor[tuple(slices)], is_project, reduce_cotangent,
|
|
590
636
|
geometry=geometry, reduce_output=reduce_output,
|
|
591
|
-
_plan=plan))
|
|
637
|
+
_plan=plan, admit=admit))
|
|
592
638
|
part_stats = owner.last_execution_stats
|
|
593
639
|
for key in counters:
|
|
594
640
|
stats[key] += part_stats[key]
|
|
@@ -603,13 +649,14 @@ class ReferenceProjector:
|
|
|
603
649
|
# Empty ownership and empty leading dimensions retain a differentiable seam.
|
|
604
650
|
output = output + _zero_dependency(tensor).to(dtype=owner.dtype)
|
|
605
651
|
if is_project:
|
|
606
|
-
for tile, world in self._tiles(plan[0]):
|
|
652
|
+
for tile_index, (tile, world) in enumerate(self._tiles(plan[0])):
|
|
607
653
|
values = tensor[(..., *tile)]
|
|
608
654
|
if reduce_cotangent:
|
|
609
655
|
values = self._reduce(values, owner.view_group, owner.view_world_size, stats)
|
|
610
656
|
for batch, (views, pixels) in enumerate(self._batches(*plan[1:])):
|
|
611
657
|
device = self._device(batch, tensor.device, stats)
|
|
612
|
-
origins, directions, rays = self._rays(views, pixels, device, stats=stats, geometry=geometry
|
|
658
|
+
origins, directions, rays = self._rays(views, pixels, device, stats=stats, geometry=geometry,
|
|
659
|
+
admit=admit and tile_index == 0)
|
|
613
660
|
indices, lengths = self._intervals(origins, directions, world)
|
|
614
661
|
local = self._to(values, device, stats).reshape(*leading, math.prod(values.shape[-len(tile):]))
|
|
615
662
|
selected = torch.index_select(local, -1, indices.flatten()).reshape(*leading, *indices.shape)
|
|
@@ -632,7 +679,8 @@ class ReferenceProjector:
|
|
|
632
679
|
if reduce_cotangent:
|
|
633
680
|
values = self._reduce(values, owner.space_group, owner.space_world_size, stats)
|
|
634
681
|
values = self._to(values, device, stats).reshape(*leading, math.prod(values.shape[-len(shape):]))
|
|
635
|
-
origins, directions, _ = self._rays(views, pixels, device, stats=stats, geometry=geometry
|
|
682
|
+
origins, directions, _ = self._rays(views, pixels, device, stats=stats, geometry=geometry,
|
|
683
|
+
admit=admit)
|
|
636
684
|
for tile, world in self._tiles(plan[0]):
|
|
637
685
|
indices, lengths = self._intervals(origins, directions, world)
|
|
638
686
|
tile_shape = tuple(part.stop - part.start for part in tile)
|
|
@@ -657,10 +705,17 @@ class ReferenceProjector:
|
|
|
657
705
|
|
|
658
706
|
def _apply(self, tensor, is_project):
|
|
659
707
|
geometry = self._snapshot_geometry()
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
708
|
+
owner = self.owner
|
|
709
|
+
# A single process returns no output before every batch passes, so a
|
|
710
|
+
# parameterized surface is admitted in the numerical sampling pass and
|
|
711
|
+
# its sampler runs once per batch. Distributed calls finish all checks
|
|
712
|
+
# before any rank starts numerics.
|
|
713
|
+
admit = owner.world_size == 1 and isinstance(owner.detector_surface, ParameterizedSurface)
|
|
714
|
+
if not admit:
|
|
715
|
+
self._validate_geometry(geometry, tensor.device if isinstance(tensor, torch.Tensor) else torch.device("cpu"))
|
|
716
|
+
if owner.world_size > 1 and torch.is_grad_enabled():
|
|
717
|
+
return _DistributedApply.apply(tensor, self, is_project, False, owner._participation(), *geometry)
|
|
718
|
+
return self._tensor_apply(tensor, is_project, geometry=geometry, admit=admit)
|
|
664
719
|
|
|
665
720
|
def _geometry_grad(self, tensor, cotangent, is_project, reduce_cotangent, geometry):
|
|
666
721
|
owner = self.owner
|
diffct/kernels/cone_beam.py
CHANGED
|
@@ -120,6 +120,11 @@ def _cone_3d_forward_kernel(
|
|
|
120
120
|
det_y = d_detector_positions[iview, iu, iv, 1] / voxel_spacing
|
|
121
121
|
det_z = d_detector_positions[iview, iu, iv, 2] / voxel_spacing
|
|
122
122
|
|
|
123
|
+
# Preserve native endpoint composition, then clip in float64: when both
|
|
124
|
+
# endpoints are distant, float32 loses voxel widths before rebasing.
|
|
125
|
+
src_x, src_y, src_z = np.float64(src_x), np.float64(src_y), np.float64(src_z)
|
|
126
|
+
det_x, det_y, det_z = np.float64(det_x), np.float64(det_y), np.float64(det_z)
|
|
127
|
+
|
|
123
128
|
# === 3D RAY DIRECTION CALCULATION ===
|
|
124
129
|
# Ray direction vector from source to detector element in 3D space
|
|
125
130
|
dir_x, dir_y, dir_z = det_x - src_x, det_y - src_y, det_z - src_z
|
|
@@ -133,7 +138,7 @@ def _cone_3d_forward_kernel(
|
|
|
133
138
|
|
|
134
139
|
# === 3D RAY-VOLUME INTERSECTION CALCULATION ===
|
|
135
140
|
# Measure the ray parameter from the endpoint nearer the volume centre, so a
|
|
136
|
-
# distant source does not make the
|
|
141
|
+
# distant source does not make the parameters coarse. Only the
|
|
137
142
|
# source-to-detector segment is integrated.
|
|
138
143
|
if src_x * src_x + src_y * src_y + src_z * src_z <= det_x * det_x + det_y * det_y + det_z * det_z:
|
|
139
144
|
org_x = src_x
|
|
@@ -170,15 +175,14 @@ def _cone_3d_forward_kernel(
|
|
|
170
175
|
if t_min >= t_max: # No valid 3D intersection
|
|
171
176
|
d_sino[iview, iu, iv] = _ZERO; return
|
|
172
177
|
|
|
173
|
-
#
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
t_end = t_max - t_min
|
|
178
|
+
# Only setup needs float64; traverse in float32 from the volume entry.
|
|
179
|
+
ent_x = np.float32(org_x + t_min * dir_x)
|
|
180
|
+
ent_y = np.float32(org_y + t_min * dir_y)
|
|
181
|
+
ent_z = np.float32(org_z + t_min * dir_z)
|
|
182
|
+
ray_x = np.float32(dir_x)
|
|
183
|
+
ray_y = np.float32(dir_y)
|
|
184
|
+
ray_z = np.float32(dir_z)
|
|
185
|
+
t_end = np.float32(t_max - t_min)
|
|
182
186
|
|
|
183
187
|
# === 3D SIDDON METHOD TRAVERSAL INITIALIZATION ===
|
|
184
188
|
accum = _ZERO # Accumulated projection value
|
|
@@ -337,6 +341,10 @@ def _cone_3d_backward_kernel(
|
|
|
337
341
|
det_y = d_detector_positions[iview, iu, iv, 1] / voxel_spacing
|
|
338
342
|
det_z = d_detector_positions[iview, iu, iv, 2] / voxel_spacing
|
|
339
343
|
|
|
344
|
+
# Match forward setup while retaining native endpoint composition.
|
|
345
|
+
src_x, src_y, src_z = np.float64(src_x), np.float64(src_y), np.float64(src_z)
|
|
346
|
+
det_x, det_y, det_z = np.float64(det_x), np.float64(det_y), np.float64(det_z)
|
|
347
|
+
|
|
340
348
|
# === 3D RAY DIRECTION CALCULATION ===
|
|
341
349
|
# Ray direction vector from source to detector element in 3D space
|
|
342
350
|
dir_x, dir_y, dir_z = det_x - src_x, det_y - src_y, det_z - src_z
|
|
@@ -347,7 +355,7 @@ def _cone_3d_backward_kernel(
|
|
|
347
355
|
|
|
348
356
|
# === 3D RAY-VOLUME INTERSECTION CALCULATION ===
|
|
349
357
|
# Measure the ray parameter from the endpoint nearer the volume centre, so a
|
|
350
|
-
# distant source does not make the
|
|
358
|
+
# distant source does not make the parameters coarse. Only the
|
|
351
359
|
# source-to-detector segment is integrated.
|
|
352
360
|
if src_x * src_x + src_y * src_y + src_z * src_z <= det_x * det_x + det_y * det_y + det_z * det_z:
|
|
353
361
|
org_x = src_x
|
|
@@ -380,15 +388,14 @@ def _cone_3d_backward_kernel(
|
|
|
380
388
|
|
|
381
389
|
if t_min >= t_max: return
|
|
382
390
|
|
|
383
|
-
#
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
t_end = t_max - t_min
|
|
391
|
+
# Only setup needs float64; traverse in float32 from the volume entry.
|
|
392
|
+
ent_x = np.float32(org_x + t_min * dir_x)
|
|
393
|
+
ent_y = np.float32(org_y + t_min * dir_y)
|
|
394
|
+
ent_z = np.float32(org_z + t_min * dir_z)
|
|
395
|
+
ray_x = np.float32(dir_x)
|
|
396
|
+
ray_y = np.float32(dir_y)
|
|
397
|
+
ray_z = np.float32(dir_z)
|
|
398
|
+
t_end = np.float32(t_max - t_min)
|
|
392
399
|
|
|
393
400
|
# === 3D SIDDON METHOD TRAVERSAL INITIALIZATION ===
|
|
394
401
|
t = _ZERO
|
diffct/kernels/fan_beam.py
CHANGED
|
@@ -100,6 +100,11 @@ def _fan_2d_forward_kernel(
|
|
|
100
100
|
det_x = d_detector_positions[iang, idet, 0] / voxel_spacing
|
|
101
101
|
det_y = d_detector_positions[iang, idet, 1] / voxel_spacing
|
|
102
102
|
|
|
103
|
+
# Preserve native endpoint composition, then clip in float64: when both
|
|
104
|
+
# endpoints are distant, float32 loses voxel widths before rebasing.
|
|
105
|
+
src_x, src_y = np.float64(src_x), np.float64(src_y)
|
|
106
|
+
det_x, det_y = np.float64(det_x), np.float64(det_y)
|
|
107
|
+
|
|
103
108
|
# === RAY DIRECTION CALCULATION ===
|
|
104
109
|
# Ray direction vector from source to detector element
|
|
105
110
|
dir_x, dir_y = det_x - src_x, det_y - src_y
|
|
@@ -113,7 +118,7 @@ def _fan_2d_forward_kernel(
|
|
|
113
118
|
|
|
114
119
|
# === RAY-VOLUME INTERSECTION CALCULATION ===
|
|
115
120
|
# Measure the ray parameter from the endpoint nearer the volume centre, so a
|
|
116
|
-
# distant source does not make the
|
|
121
|
+
# distant source does not make the parameters coarse. Only the
|
|
117
122
|
# source-to-detector segment is integrated.
|
|
118
123
|
if src_x * src_x + src_y * src_y <= det_x * det_x + det_y * det_y:
|
|
119
124
|
org_x = src_x
|
|
@@ -138,13 +143,12 @@ def _fan_2d_forward_kernel(
|
|
|
138
143
|
if t_min >= t_max: # No valid intersection
|
|
139
144
|
d_sino[iang, idet] = _ZERO; return
|
|
140
145
|
|
|
141
|
-
#
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
t_end = t_max - t_min
|
|
146
|
+
# Only setup needs float64; traverse in float32 from the volume entry.
|
|
147
|
+
ent_x = np.float32(org_x + t_min * dir_x)
|
|
148
|
+
ent_y = np.float32(org_y + t_min * dir_y)
|
|
149
|
+
ray_x = np.float32(dir_x)
|
|
150
|
+
ray_y = np.float32(dir_y)
|
|
151
|
+
t_end = np.float32(t_max - t_min)
|
|
148
152
|
|
|
149
153
|
# === SIDDON METHOD TRAVERSAL (same algorithm as parallel beam) ===
|
|
150
154
|
accum = _ZERO # Accumulated projection value
|
|
@@ -270,6 +274,10 @@ def _fan_2d_backward_kernel(
|
|
|
270
274
|
det_x = d_detector_positions[iang, idet, 0] / voxel_spacing
|
|
271
275
|
det_y = d_detector_positions[iang, idet, 1] / voxel_spacing
|
|
272
276
|
|
|
277
|
+
# Match forward setup while retaining native endpoint composition.
|
|
278
|
+
src_x, src_y = np.float64(src_x), np.float64(src_y)
|
|
279
|
+
det_x, det_y = np.float64(det_x), np.float64(det_y)
|
|
280
|
+
|
|
273
281
|
# === RAY DIRECTION CALCULATION ===
|
|
274
282
|
# Ray direction vector from source to detector element
|
|
275
283
|
dir_x, dir_y = det_x - src_x, det_y - src_y
|
|
@@ -280,7 +288,7 @@ def _fan_2d_backward_kernel(
|
|
|
280
288
|
|
|
281
289
|
# === RAY-VOLUME INTERSECTION CALCULATION ===
|
|
282
290
|
# Measure the ray parameter from the endpoint nearer the volume centre, so a
|
|
283
|
-
# distant source does not make the
|
|
291
|
+
# distant source does not make the parameters coarse. Only the
|
|
284
292
|
# source-to-detector segment is integrated.
|
|
285
293
|
if src_x * src_x + src_y * src_y <= det_x * det_x + det_y * det_y:
|
|
286
294
|
org_x = src_x
|
|
@@ -302,13 +310,12 @@ def _fan_2d_backward_kernel(
|
|
|
302
310
|
|
|
303
311
|
if t_min >= t_max: return
|
|
304
312
|
|
|
305
|
-
#
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
t_end = t_max - t_min
|
|
313
|
+
# Only setup needs float64; traverse in float32 from the volume entry.
|
|
314
|
+
ent_x = np.float32(org_x + t_min * dir_x)
|
|
315
|
+
ent_y = np.float32(org_y + t_min * dir_y)
|
|
316
|
+
ray_x = np.float32(dir_x)
|
|
317
|
+
ray_y = np.float32(dir_y)
|
|
318
|
+
t_end = np.float32(t_max - t_min)
|
|
312
319
|
|
|
313
320
|
# === SIDDON METHOD TRAVERSAL INITIALIZATION ===
|
|
314
321
|
t = _ZERO
|