diffct 2.4.0__tar.gz → 2.4.1__tar.gz

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.
Files changed (145) hide show
  1. {diffct-2.4.0 → diffct-2.4.1}/CHANGELOG.md +11 -0
  2. {diffct-2.4.0 → diffct-2.4.1}/CITATION.cff +1 -1
  3. {diffct-2.4.0 → diffct-2.4.1}/PKG-INFO +7 -7
  4. {diffct-2.4.0 → diffct-2.4.1}/README.md +6 -6
  5. {diffct-2.4.0 → diffct-2.4.1}/diffct/__init__.py +1 -1
  6. {diffct-2.4.0 → diffct-2.4.1}/diffct/_solver_state.py +7 -1
  7. {diffct-2.4.0 → diffct-2.4.1}/diffct/backends.py +165 -110
  8. {diffct-2.4.0 → diffct-2.4.1}/diffct/kernels/cone_beam.py +27 -20
  9. {diffct-2.4.0 → diffct-2.4.1}/diffct/kernels/fan_beam.py +23 -16
  10. {diffct-2.4.0 → diffct-2.4.1}/diffct/kernels/geometry_vjp.py +45 -31
  11. {diffct-2.4.0 → diffct-2.4.1}/diffct/operators.py +69 -32
  12. {diffct-2.4.0 → diffct-2.4.1}/diffct/projectors.py +3 -2
  13. {diffct-2.4.0 → diffct-2.4.1}/pyproject.toml +1 -1
  14. diffct-2.4.1/tests/test_checkpoint_metadata_validation.py +154 -0
  15. diffct-2.4.1/tests/test_live_trajectory_validation.py +234 -0
  16. diffct-2.4.1/tests/test_native_endpoint_precision.py +182 -0
  17. diffct-2.4.1/tests/test_portable_sampler_snapshot.py +160 -0
  18. diffct-2.4.1/tests/test_surface_gpu_execution.py +221 -0
  19. diffct-2.4.1/tests/test_surface_parameter_updates.py +271 -0
  20. {diffct-2.4.0 → diffct-2.4.1}/.gitignore +0 -0
  21. {diffct-2.4.0 → diffct-2.4.1}/LICENSE +0 -0
  22. {diffct-2.4.0 → diffct-2.4.1}/NOTICE +0 -0
  23. {diffct-2.4.0 → diffct-2.4.1}/diffct/_solver_tv.py +0 -0
  24. {diffct-2.4.0 → diffct-2.4.1}/diffct/analytical.py +0 -0
  25. {diffct-2.4.0 → diffct-2.4.1}/diffct/analytical_execution.py +0 -0
  26. {diffct-2.4.0 → diffct-2.4.1}/diffct/chunking.py +0 -0
  27. {diffct-2.4.0 → diffct-2.4.1}/diffct/constants.py +0 -0
  28. {diffct-2.4.0 → diffct-2.4.1}/diffct/differentiable.py +0 -0
  29. {diffct-2.4.0 → diffct-2.4.1}/diffct/distribution.py +0 -0
  30. {diffct-2.4.0 → diffct-2.4.1}/diffct/geometry.py +0 -0
  31. {diffct-2.4.0 → diffct-2.4.1}/diffct/kernels/__init__.py +0 -0
  32. {diffct-2.4.0 → diffct-2.4.1}/diffct/kernels/analytical_transpose.py +0 -0
  33. {diffct-2.4.0 → diffct-2.4.1}/diffct/kernels/parallel_beam.py +0 -0
  34. {diffct-2.4.0 → diffct-2.4.1}/diffct/kernels/surface_geometry.py +0 -0
  35. {diffct-2.4.0 → diffct-2.4.1}/diffct/pipeline.py +0 -0
  36. {diffct-2.4.0 → diffct-2.4.1}/diffct/reconstruction.py +0 -0
  37. {diffct-2.4.0 → diffct-2.4.1}/diffct/solvers.py +0 -0
  38. {diffct-2.4.0 → diffct-2.4.1}/diffct/storage.py +0 -0
  39. {diffct-2.4.0 → diffct-2.4.1}/diffct/streaming.py +0 -0
  40. {diffct-2.4.0 → diffct-2.4.1}/diffct/surface_gradients.py +0 -0
  41. {diffct-2.4.0 → diffct-2.4.1}/diffct/surfaces.py +0 -0
  42. {diffct-2.4.0 → diffct-2.4.1}/diffct/utils.py +0 -0
  43. {diffct-2.4.0 → diffct-2.4.1}/docs/video/README.md +0 -0
  44. {diffct-2.4.0 → diffct-2.4.1}/examples/README.md +0 -0
  45. {diffct-2.4.0 → diffct-2.4.1}/examples/_block_cgls.py +0 -0
  46. {diffct-2.4.0 → diffct-2.4.1}/examples/_common.py +0 -0
  47. {diffct-2.4.0 → diffct-2.4.1}/examples/data/NOTICE +0 -0
  48. {diffct-2.4.0 → diffct-2.4.1}/examples/data/preprocess_walnut.py +0 -0
  49. {diffct-2.4.0 → diffct-2.4.1}/examples/walnut_reconstruction.py +0 -0
  50. {diffct-2.4.0 → diffct-2.4.1}/formal/README.md +0 -0
  51. {diffct-2.4.0 → diffct-2.4.1}/pytest.ini +0 -0
  52. {diffct-2.4.0 → diffct-2.4.1}/tests/__init__.py +0 -0
  53. {diffct-2.4.0 → diffct-2.4.1}/tests/_phantoms.py +0 -0
  54. {diffct-2.4.0 → diffct-2.4.1}/tests/benchmarks/README.md +0 -0
  55. {diffct-2.4.0 → diffct-2.4.1}/tests/benchmarks/__init__.py +0 -0
  56. {diffct-2.4.0 → diffct-2.4.1}/tests/benchmarks/_common.py +0 -0
  57. {diffct-2.4.0 → diffct-2.4.1}/tests/benchmarks/test_bench_cone.py +0 -0
  58. {diffct-2.4.0 → diffct-2.4.1}/tests/benchmarks/test_bench_fan.py +0 -0
  59. {diffct-2.4.0 → diffct-2.4.1}/tests/benchmarks/test_bench_parallel.py +0 -0
  60. {diffct-2.4.0 → diffct-2.4.1}/tests/distributed_projector_check.py +0 -0
  61. {diffct-2.4.0 → diffct-2.4.1}/tests/test_adjoint_inner_product.py +0 -0
  62. {diffct-2.4.0 → diffct-2.4.1}/tests/test_allocator_capacity.py +0 -0
  63. {diffct-2.4.0 → diffct-2.4.1}/tests/test_analytical_autograd.py +0 -0
  64. {diffct-2.4.0 → diffct-2.4.1}/tests/test_analytical_distribution.py +0 -0
  65. {diffct-2.4.0 → diffct-2.4.1}/tests/test_analytical_regressions.py +0 -0
  66. {diffct-2.4.0 → diffct-2.4.1}/tests/test_analytical_weight_distribution.py +0 -0
  67. {diffct-2.4.0 → diffct-2.4.1}/tests/test_angular_low_precision.py +0 -0
  68. {diffct-2.4.0 → diffct-2.4.1}/tests/test_axis_aligned_chords.py +0 -0
  69. {diffct-2.4.0 → diffct-2.4.1}/tests/test_batch_execution_edges.py +0 -0
  70. {diffct-2.4.0 → diffct-2.4.1}/tests/test_batched_surfaces.py +0 -0
  71. {diffct-2.4.0 → diffct-2.4.1}/tests/test_block_cgls.py +0 -0
  72. {diffct-2.4.0 → diffct-2.4.1}/tests/test_block_storage.py +0 -0
  73. {diffct-2.4.0 → diffct-2.4.1}/tests/test_call_trajectory.py +0 -0
  74. {diffct-2.4.0 → diffct-2.4.1}/tests/test_cgls_state_admission.py +0 -0
  75. {diffct-2.4.0 → diffct-2.4.1}/tests/test_chunked_projector.py +0 -0
  76. {diffct-2.4.0 → diffct-2.4.1}/tests/test_completed_transfer_reuse.py +0 -0
  77. {diffct-2.4.0 → diffct-2.4.1}/tests/test_cone_backprojection_transpose.py +0 -0
  78. {diffct-2.4.0 → diffct-2.4.1}/tests/test_cone_projector_autograd.py +0 -0
  79. {diffct-2.4.0 → diffct-2.4.1}/tests/test_cpu_install.py +0 -0
  80. {diffct-2.4.0 → diffct-2.4.1}/tests/test_cuda_exit_deallocations.py +0 -0
  81. {diffct-2.4.0 → diffct-2.4.1}/tests/test_cuda_sink_lifetimes.py +0 -0
  82. {diffct-2.4.0 → diffct-2.4.1}/tests/test_cuda_smoke.py +0 -0
  83. {diffct-2.4.0 → diffct-2.4.1}/tests/test_detector_surface_dtypes.py +0 -0
  84. {diffct-2.4.0 → diffct-2.4.1}/tests/test_detector_surface_mapping.py +0 -0
  85. {diffct-2.4.0 → diffct-2.4.1}/tests/test_detector_surface_units.py +0 -0
  86. {diffct-2.4.0 → diffct-2.4.1}/tests/test_detector_surfaces.py +0 -0
  87. {diffct-2.4.0 → diffct-2.4.1}/tests/test_distributed_training_transforms.py +0 -0
  88. {diffct-2.4.0 → diffct-2.4.1}/tests/test_example_psnr.py +0 -0
  89. {diffct-2.4.0 → diffct-2.4.1}/tests/test_execution_buffers.py +0 -0
  90. {diffct-2.4.0 → diffct-2.4.1}/tests/test_execution_features.py +0 -0
  91. {diffct-2.4.0 → diffct-2.4.1}/tests/test_execution_schedules.py +0 -0
  92. {diffct-2.4.0 → diffct-2.4.1}/tests/test_fbp_fan_accuracy.py +0 -0
  93. {diffct-2.4.0 → diffct-2.4.1}/tests/test_fbp_fan_offsets.py +0 -0
  94. {diffct-2.4.0 → diffct-2.4.1}/tests/test_fbp_parallel_accuracy.py +0 -0
  95. {diffct-2.4.0 → diffct-2.4.1}/tests/test_fdk_cone_accuracy.py +0 -0
  96. {diffct-2.4.0 → diffct-2.4.1}/tests/test_fdk_cone_offsets.py +0 -0
  97. {diffct-2.4.0 → diffct-2.4.1}/tests/test_geometry_autograd.py +0 -0
  98. {diffct-2.4.0 → diffct-2.4.1}/tests/test_geometry_conversions.py +0 -0
  99. {diffct-2.4.0 → diffct-2.4.1}/tests/test_geometry_gradients.py +0 -0
  100. {diffct-2.4.0 → diffct-2.4.1}/tests/test_gradcheck.py +0 -0
  101. {diffct-2.4.0 → diffct-2.4.1}/tests/test_lazy_native_observers.py +0 -0
  102. {diffct-2.4.0 → diffct-2.4.1}/tests/test_multi_gpu_exit.py +0 -0
  103. {diffct-2.4.0 → diffct-2.4.1}/tests/test_native_surface_transforms.py +0 -0
  104. {diffct-2.4.0 → diffct-2.4.1}/tests/test_native_volume_layout.py +0 -0
  105. {diffct-2.4.0 → diffct-2.4.1}/tests/test_native_volume_layout_distributed.py +0 -0
  106. {diffct-2.4.0 → diffct-2.4.1}/tests/test_output_pipeline_overlap.py +0 -0
  107. {diffct-2.4.0 → diffct-2.4.1}/tests/test_parallel3d.py +0 -0
  108. {diffct-2.4.0 → diffct-2.4.1}/tests/test_parallel3d_surface_oracle.py +0 -0
  109. {diffct-2.4.0 → diffct-2.4.1}/tests/test_partition_lifecycle.py +0 -0
  110. {diffct-2.4.0 → diffct-2.4.1}/tests/test_plan_memory_bounds.py +0 -0
  111. {diffct-2.4.0 → diffct-2.4.1}/tests/test_portable_distributed_geometry.py +0 -0
  112. {diffct-2.4.0 → diffct-2.4.1}/tests/test_portable_endpoint_precision.py +0 -0
  113. {diffct-2.4.0 → diffct-2.4.1}/tests/test_portable_execution_bounds.py +0 -0
  114. {diffct-2.4.0 → diffct-2.4.1}/tests/test_portable_numeric_regressions.py +0 -0
  115. {diffct-2.4.0 → diffct-2.4.1}/tests/test_portable_surface_admission.py +0 -0
  116. {diffct-2.4.0 → diffct-2.4.1}/tests/test_projector_api.py +0 -0
  117. {diffct-2.4.0 → diffct-2.4.1}/tests/test_projector_degenerate_geometry.py +0 -0
  118. {diffct-2.4.0 → diffct-2.4.1}/tests/test_projector_geometry_devices.py +0 -0
  119. {diffct-2.4.0 → diffct-2.4.1}/tests/test_ramp_filter_windows.py +0 -0
  120. {diffct-2.4.0 → diffct-2.4.1}/tests/test_reconstruction_api.py +0 -0
  121. {diffct-2.4.0 → diffct-2.4.1}/tests/test_reference_backend.py +0 -0
  122. {diffct-2.4.0 → diffct-2.4.1}/tests/test_release_2_0_1.py +0 -0
  123. {diffct-2.4.0 → diffct-2.4.1}/tests/test_review_230.py +0 -0
  124. {diffct-2.4.0 → diffct-2.4.1}/tests/test_review_regressions.py +0 -0
  125. {diffct-2.4.0 → diffct-2.4.1}/tests/test_solver_admission_regressions.py +0 -0
  126. {diffct-2.4.0 → diffct-2.4.1}/tests/test_solver_api.py +0 -0
  127. {diffct-2.4.0 → diffct-2.4.1}/tests/test_solver_checkpoint_edges.py +0 -0
  128. {diffct-2.4.0 → diffct-2.4.1}/tests/test_solver_collective_failures.py +0 -0
  129. {diffct-2.4.0 → diffct-2.4.1}/tests/test_spatial_partition.py +0 -0
  130. {diffct-2.4.0 → diffct-2.4.1}/tests/test_surface_collective_failures.py +0 -0
  131. {diffct-2.4.0 → diffct-2.4.1}/tests/test_surface_composition_kernel.py +0 -0
  132. {diffct-2.4.0 → diffct-2.4.1}/tests/test_surface_compute_shards.py +0 -0
  133. {diffct-2.4.0 → diffct-2.4.1}/tests/test_surface_geometry_cudasim.py +0 -0
  134. {diffct-2.4.0 → diffct-2.4.1}/tests/test_surface_gradient_replay.py +0 -0
  135. {diffct-2.4.0 → diffct-2.4.1}/tests/test_surface_native_regressions.py +0 -0
  136. {diffct-2.4.0 → diffct-2.4.1}/tests/test_surface_pipeline_contracts.py +0 -0
  137. {diffct-2.4.0 → diffct-2.4.1}/tests/test_surface_trajectory_transform.py +0 -0
  138. {diffct-2.4.0 → diffct-2.4.1}/tests/test_training_transforms.py +0 -0
  139. {diffct-2.4.0 → diffct-2.4.1}/tests/test_trajectory_admission_cost.py +0 -0
  140. {diffct-2.4.0 → diffct-2.4.1}/tests/test_trajectory_surface_integration.py +0 -0
  141. {diffct-2.4.0 → diffct-2.4.1}/tests/test_transfer_pipeline.py +0 -0
  142. {diffct-2.4.0 → diffct-2.4.1}/tests/test_transfer_retirement_lifetimes.py +0 -0
  143. {diffct-2.4.0 → diffct-2.4.1}/tests/test_tv_zero_dependency.py +0 -0
  144. {diffct-2.4.0 → diffct-2.4.1}/tests/test_two_far_endpoints.py +0 -0
  145. {diffct-2.4.0 → diffct-2.4.1}/tests/test_weights.py +0 -0
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.4.1] - 2026-10-20
11
+
12
+ ### Fixed
13
+
14
+ - Let `ParameterizedSurface` use full native CUDA execution when the existing memory limits admit it. Full curved-detector geometry gradients now pull back one GPU composition graph instead of replaying tiny pixel fragments through host autograd; explicitly chunked and memory-limited calls retain bounded streaming (#37).
15
+ - Revalidate updated `ParameterizedSurface` pixels on portable calls and native data transforms. Native `*_into` calls validate all sampled batches before changing caller output (#37).
16
+ - Apply call-time trajectory admission to retained, trainable constructor frames, including geometry updated into degenerate or out-of-range values. Flat CUDA frame validation avoids bulk CPU round trips (#37).
17
+ - Keep each portable distributed forward's surface sampler for backward when callers replace the sampler or parameters before differentiating (#37).
18
+ - Preserve native fan/cone ray weights for distant endpoints by clipping and rebasing each ray in FP64 on the GPU, while retaining FP32 local traversal (#37).
19
+ - Reject malformed checkpoint metadata structures collectively before solver work, keeping distributed process groups usable after a failed resume (#37).
20
+
10
21
  ## [2.4.0] - 2026-10-20
11
22
 
12
23
  ### Added
@@ -13,7 +13,7 @@ authors:
13
13
  given-names: Yipeng
14
14
  email: yipeng.sun@fau.de
15
15
  alias: sypsyp97
16
- version: 2.4.0
16
+ version: 2.4.1
17
17
  date-released: "2026-10-20"
18
18
  license: Apache-2.0
19
19
  repository-code: "https://github.com/sypsyp97/diffct"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: diffct
3
- Version: 2.4.0
3
+ Version: 2.4.1
4
4
  Summary: Differentiable CT projection and reconstruction: precision modes, streaming, distributed execution and geometry gradients
5
5
  Project-URL: Homepage, https://github.com/sypsyp97/diffct
6
6
  Project-URL: Documentation, https://sypsyp97.github.io/diffct/
@@ -177,18 +177,18 @@ volume slabs use `--partition space` under torchrun. Run
177
177
 
178
178
  <p align="center">
179
179
  <picture>
180
- <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_dark.png?v=2.4.0">
181
- <img src="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_light.png?v=2.4.0" width="100%" alt="Time per forward, adjoint and CGLS iteration on 1, 4 and 8 GPUs, 512^3 volume">
180
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_dark.png?v=2.4.1">
181
+ <img src="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_light.png?v=2.4.1" width="100%" alt="Time per forward, adjoint and CGLS iteration on 1, 4 and 8 GPUs, 512^3 volume">
182
182
  </picture>
183
183
  </p>
184
184
 
185
185
  | 512³ volume | 1 GPU | 4 GPUs, 1 node | 8 GPUs, 2 nodes |
186
186
  |---|---:|---:|---:|
187
- | Forward projection | 493.40 ms | 131.78 ms (3.7×) | 72.42 ms (6.8×) |
188
- | Adjoint (backprojection) | 1118.98 ms | 293.01 ms (3.8×) | 232.50 ms (4.8×) |
189
- | CGLS iteration | 1744.92 ms | 465.62 ms (3.7×) | 337.98 ms (5.2×) |
187
+ | Forward projection | 569.99 ms | 151.02 ms (3.8×) | 81.50 ms (7.0×) |
188
+ | Adjoint (backprojection) | 1061.36 ms | 278.75 ms (3.8×) | 220.34 ms (4.8×) |
189
+ | CGLS iteration | 1756.03 ms | 468.05 ms (3.8×) | 333.08 ms (5.3×) |
190
190
 
191
- diffct 2.4.0, FP32, A100 64 GB GPUs; circular cone beam, 360 views, 1024×768 detector, pitch 1.25. Default memory settings, without an imposed allocator cap.
191
+ diffct 2.4.1, FP32, A100 64 GB GPUs; circular cone beam, 360 views, 1024×768 detector, pitch 1.25. Default memory settings, without an imposed allocator cap.
192
192
 
193
193
  Projection times are medians of seven samples after three warmups. CGLS uses three ten-iteration solves after a two-iteration warmup; the median includes initialization and is divided by ten. Timings use synchronized wall time and the slowest rank. Raw samples and configuration: [scaling.json](https://github.com/sypsyp97/diffct/blob/main/docs/assets/scaling.json).
194
194
 
@@ -146,18 +146,18 @@ volume slabs use `--partition space` under torchrun. Run
146
146
 
147
147
  <p align="center">
148
148
  <picture>
149
- <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_dark.png?v=2.4.0">
150
- <img src="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_light.png?v=2.4.0" width="100%" alt="Time per forward, adjoint and CGLS iteration on 1, 4 and 8 GPUs, 512^3 volume">
149
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_dark.png?v=2.4.1">
150
+ <img src="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_light.png?v=2.4.1" width="100%" alt="Time per forward, adjoint and CGLS iteration on 1, 4 and 8 GPUs, 512^3 volume">
151
151
  </picture>
152
152
  </p>
153
153
 
154
154
  | 512³ volume | 1 GPU | 4 GPUs, 1 node | 8 GPUs, 2 nodes |
155
155
  |---|---:|---:|---:|
156
- | Forward projection | 493.40 ms | 131.78 ms (3.7×) | 72.42 ms (6.8×) |
157
- | Adjoint (backprojection) | 1118.98 ms | 293.01 ms (3.8×) | 232.50 ms (4.8×) |
158
- | CGLS iteration | 1744.92 ms | 465.62 ms (3.7×) | 337.98 ms (5.2×) |
156
+ | Forward projection | 569.99 ms | 151.02 ms (3.8×) | 81.50 ms (7.0×) |
157
+ | Adjoint (backprojection) | 1061.36 ms | 278.75 ms (3.8×) | 220.34 ms (4.8×) |
158
+ | CGLS iteration | 1756.03 ms | 468.05 ms (3.8×) | 333.08 ms (5.3×) |
159
159
 
160
- diffct 2.4.0, FP32, A100 64 GB GPUs; circular cone beam, 360 views, 1024×768 detector, pitch 1.25. Default memory settings, without an imposed allocator cap.
160
+ diffct 2.4.1, FP32, A100 64 GB GPUs; circular cone beam, 360 views, 1024×768 detector, pitch 1.25. Default memory settings, without an imposed allocator cap.
161
161
 
162
162
  Projection times are medians of seven samples after three warmups. CGLS uses three ten-iteration solves after a two-iteration warmup; the median includes initialization and is divided by ten. Timings use synchronized wall time and the slowest rank. Raw samples and configuration: [scaling.json](https://github.com/sypsyp97/diffct/blob/main/docs/assets/scaling.json).
163
163
 
@@ -64,7 +64,7 @@ try:
64
64
  except ImportError:
65
65
  pass
66
66
 
67
- __version__ = '2.4.0'
67
+ __version__ = '2.4.1'
68
68
 
69
69
  __all__ = [
70
70
  'Projector',
@@ -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,)
@@ -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