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 CHANGED
@@ -64,7 +64,7 @@ try:
64
64
  except ImportError:
65
65
  pass
66
66
 
67
- __version__ = '2.4.0'
67
+ __version__ = '2.4.2'
68
68
 
69
69
  __all__ = [
70
70
  'Projector',
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
- # Native owners already validated geometry before transform dispatch.
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 (owner.detector_surface is None or
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
- if (not torch.isfinite(origins.to(dtype=owner.dtype)).all().item()
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 _rays(self, views, pixels, device, validate=False, stats=None, geometry=None, *, admission=True):
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
- frames = (owner._learnable or owner._trajectory) if geometry is None else geometry[:frame_count]
347
- native_surface = owner.backend == "numba" and surface is not None
348
- native_legacy = native_surface and not isinstance(surface, ParameterizedSurface)
349
- if native_surface and not native_legacy:
350
- dtype = torch.float32
351
- for component in (*frames, offsets):
352
- dtype = torch.promote_types(dtype, component.dtype)
353
- local_dtype = offsets.dtype if native_legacy else dtype
354
- local = self._transfer(offsets.to(dtype=local_dtype), device, stats)
355
- if local.ndim == u.ndim + 1:
356
- local = local.unsqueeze(0)
357
- dimensions = len(owner.global_volume_shape)
358
- selected = tuple(self._transfer(value[views].to(
359
- dtype=torch.promote_types(torch.float32, value.dtype) if native_legacy else dtype,
360
- ), device, stats) for value in frames)
361
-
362
- def frame(value):
363
- return value.reshape(len(ids), *([1] * u.ndim), dimensions)
364
-
365
- def world_points(offsets, components):
366
- axis_u = frame(components[2])
367
- points = frame(components[1]) + offsets[..., 0, None] * axis_u
368
- if owner.beam in ("cone", "parallel3d"):
369
- axis_v = frame(components[3])
370
- points = points + offsets[..., 1, None] * axis_v
371
- normal_dtype = torch.promote_types(axis_u.dtype, axis_v.dtype)
372
- normal = torch.linalg.cross(axis_u.to(dtype=normal_dtype),
373
- axis_v.to(dtype=normal_dtype), dim=-1)
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
- normal = torch.stack((axis_u[..., 1], -axis_u[..., 0]), dim=-1)
376
- return points + offsets[..., 2, None] * normal
377
-
378
- points = world_points(local, selected)
379
- if (validate and owner.beam not in ("parallel", "parallel3d") and owner.dtype == torch.float32
380
- and owner.detector_surface is not None):
381
- # Raw FP64 coordinates preserve the exact admission boundaries.
382
- sources = frame(selected[0]).expand_as(points)
383
- pitches = points.new_tensor(self.spacing[::-1])
384
- source_distance = torch.linalg.vector_norm(sources / pitches, dim=-1)
385
- pixel_distance = torch.linalg.vector_norm(points / pitches, dim=-1)
386
- if torch.any(torch.minimum(source_distance, pixel_distance) > 1e6).item():
387
- raise ValueError(
388
- "the source or a detector pixel must lie within 1e6 voxels "
389
- "of the volume center in every view"
390
- )
391
- if torch.any((source_distance > 1e15) | (pixel_distance > 1e15)).item():
392
- raise ValueError(
393
- "the source and detector surface pixels must lie within 1e15 voxels "
394
- "of the volume center"
395
- )
396
- if native_surface:
397
- # Native surface preprocessing composes before staging complete
398
- # rays to FP32. Data transforms must preserve that operator.
399
- selected = tuple(value.to(dtype=owner.dtype) for value in selected)
400
- points = points.to(dtype=owner.dtype)
401
- elif validate and dtype != owner.dtype:
402
- # Casting the completed point misses cancellation/overflow during
403
- # FP32 component arithmetic. Reuse the same sampled offsets.
404
- selected = tuple(value.to(dtype=owner.dtype) for value in selected)
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
- self._validate_geometry(geometry, tensor.device if isinstance(tensor, torch.Tensor) else torch.device("cpu"))
661
- if self.owner.world_size > 1 and torch.is_grad_enabled():
662
- return _DistributedApply.apply(tensor, self, is_project, False, self.owner._participation(), *geometry)
663
- return self._tensor_apply(tensor, is_project, geometry=geometry)
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
@@ -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 float32 parameters coarse. Only 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
- # The traversal restarts at the entry point, so its parameters stay
174
- # within the volume diameter.
175
- ent_x = org_x + t_min * dir_x
176
- ent_y = org_y + t_min * dir_y
177
- ent_z = org_z + t_min * dir_z
178
- ray_x = dir_x
179
- ray_y = dir_y
180
- ray_z = dir_z
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 float32 parameters coarse. Only 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
- # The traversal restarts at the entry point, so its parameters stay
384
- # within the volume diameter.
385
- ent_x = org_x + t_min * dir_x
386
- ent_y = org_y + t_min * dir_y
387
- ent_z = org_z + t_min * dir_z
388
- ray_x = dir_x
389
- ray_y = dir_y
390
- ray_z = dir_z
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
@@ -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 float32 parameters coarse. Only 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
- # The traversal restarts at the entry point, so its parameters stay
142
- # within the volume diameter.
143
- ent_x = org_x + t_min * dir_x
144
- ent_y = org_y + t_min * dir_y
145
- ray_x = dir_x
146
- ray_y = dir_y
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 float32 parameters coarse. Only 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
- # The traversal restarts at the entry point, so its parameters stay
306
- # within the volume diameter.
307
- ent_x = org_x + t_min * dir_x
308
- ent_y = org_y + t_min * dir_y
309
- ray_x = dir_x
310
- ray_y = dir_y
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