diffct 2.4.4__tar.gz → 2.5.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 (187) hide show
  1. {diffct-2.4.4 → diffct-2.5.1}/CHANGELOG.md +55 -1
  2. {diffct-2.4.4 → diffct-2.5.1}/CITATION.cff +5 -4
  3. {diffct-2.4.4 → diffct-2.5.1}/PKG-INFO +7 -6
  4. {diffct-2.4.4 → diffct-2.5.1}/README.md +5 -4
  5. {diffct-2.4.4 → diffct-2.5.1}/diffct/__init__.py +1 -1
  6. {diffct-2.4.4 → diffct-2.5.1}/diffct/_solver_state.py +39 -7
  7. {diffct-2.4.4 → diffct-2.5.1}/diffct/analytical_execution.py +2 -0
  8. {diffct-2.4.4 → diffct-2.5.1}/diffct/backends.py +40 -12
  9. {diffct-2.4.4 → diffct-2.5.1}/diffct/geometry.py +70 -12
  10. {diffct-2.4.4 → diffct-2.5.1}/diffct/operators.py +36 -18
  11. {diffct-2.4.4 → diffct-2.5.1}/diffct/reconstruction.py +15 -3
  12. {diffct-2.4.4 → diffct-2.5.1}/diffct/streaming.py +7 -0
  13. {diffct-2.4.4 → diffct-2.5.1}/diffct/surfaces.py +11 -7
  14. {diffct-2.4.4 → diffct-2.5.1}/diffct/utils.py +20 -0
  15. {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/full-model.spec.mjs +2 -1
  16. {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/harness.mjs +20 -2
  17. {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/item1-ordering.spec.mjs +2 -0
  18. {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/item2-single-load.spec.mjs +8 -0
  19. {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/item3-cancel.spec.mjs +9 -4
  20. {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/item4-leaks.spec.mjs +2 -0
  21. {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/item5-errors.spec.mjs +2 -0
  22. {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/item6-a11y.spec.mjs +2 -0
  23. {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/item7-security.spec.mjs +2 -0
  24. diffct-2.5.1/docs/search/tests/semantic-opt-in.spec.mjs +88 -0
  25. {diffct-2.4.4 → diffct-2.5.1}/formal/README.md +17 -0
  26. diffct-2.5.1/formal/tla/README.md +131 -0
  27. {diffct-2.4.4 → diffct-2.5.1}/pyproject.toml +2 -2
  28. diffct-2.5.1/tests/test_geometry_input_validation.py +393 -0
  29. diffct-2.5.1/tests/test_mps_backend.py +431 -0
  30. diffct-2.5.1/tests/test_portable_accuracy.py +141 -0
  31. {diffct-2.4.4 → diffct-2.5.1}/tests/test_solver_checkpoint_edges.py +77 -0
  32. {diffct-2.4.4 → diffct-2.5.1}/.gitignore +0 -0
  33. {diffct-2.4.4 → diffct-2.5.1}/LICENSE +0 -0
  34. {diffct-2.4.4 → diffct-2.5.1}/NOTICE +0 -0
  35. {diffct-2.4.4 → diffct-2.5.1}/diffct/_solver_tv.py +0 -0
  36. {diffct-2.4.4 → diffct-2.5.1}/diffct/analytical.py +0 -0
  37. {diffct-2.4.4 → diffct-2.5.1}/diffct/chunking.py +0 -0
  38. {diffct-2.4.4 → diffct-2.5.1}/diffct/constants.py +0 -0
  39. {diffct-2.4.4 → diffct-2.5.1}/diffct/differentiable.py +0 -0
  40. {diffct-2.4.4 → diffct-2.5.1}/diffct/distribution.py +0 -0
  41. {diffct-2.4.4 → diffct-2.5.1}/diffct/kernels/__init__.py +0 -0
  42. {diffct-2.4.4 → diffct-2.5.1}/diffct/kernels/analytical_transpose.py +0 -0
  43. {diffct-2.4.4 → diffct-2.5.1}/diffct/kernels/cone_beam.py +0 -0
  44. {diffct-2.4.4 → diffct-2.5.1}/diffct/kernels/fan_beam.py +0 -0
  45. {diffct-2.4.4 → diffct-2.5.1}/diffct/kernels/geometry_vjp.py +0 -0
  46. {diffct-2.4.4 → diffct-2.5.1}/diffct/kernels/parallel_beam.py +0 -0
  47. {diffct-2.4.4 → diffct-2.5.1}/diffct/kernels/spacing.py +0 -0
  48. {diffct-2.4.4 → diffct-2.5.1}/diffct/kernels/surface_geometry.py +0 -0
  49. {diffct-2.4.4 → diffct-2.5.1}/diffct/pipeline.py +0 -0
  50. {diffct-2.4.4 → diffct-2.5.1}/diffct/projectors.py +0 -0
  51. {diffct-2.4.4 → diffct-2.5.1}/diffct/solvers.py +0 -0
  52. {diffct-2.4.4 → diffct-2.5.1}/diffct/storage.py +0 -0
  53. {diffct-2.4.4 → diffct-2.5.1}/diffct/surface_gradients.py +0 -0
  54. {diffct-2.4.4 → diffct-2.5.1}/docs/assets/gallery/NOTICE +0 -0
  55. {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/.gitignore +0 -0
  56. {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/fixtures/make_tiny_model.py +0 -0
  57. {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/fixtures/tiny-model/config.json +0 -0
  58. {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/fixtures/tiny-model/onnx/model_quantized.onnx +0 -0
  59. {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/fixtures/tiny-model/onnx/model_quantized.onnx_data +0 -0
  60. {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/fixtures/tiny-model/tokenizer.json +0 -0
  61. {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/fixtures/tiny-model/tokenizer_config.json +0 -0
  62. {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/playwright.config.mjs +0 -0
  63. {diffct-2.4.4 → diffct-2.5.1}/docs/video/README.md +0 -0
  64. {diffct-2.4.4 → diffct-2.5.1}/examples/README.md +0 -0
  65. {diffct-2.4.4 → diffct-2.5.1}/examples/_block_cgls.py +0 -0
  66. {diffct-2.4.4 → diffct-2.5.1}/examples/_common.py +0 -0
  67. {diffct-2.4.4 → diffct-2.5.1}/examples/data/NOTICE +0 -0
  68. {diffct-2.4.4 → diffct-2.5.1}/examples/data/preprocess_walnut.py +0 -0
  69. {diffct-2.4.4 → diffct-2.5.1}/examples/walnut_reconstruction.py +0 -0
  70. {diffct-2.4.4 → diffct-2.5.1}/pytest.ini +0 -0
  71. {diffct-2.4.4 → diffct-2.5.1}/tests/__init__.py +0 -0
  72. {diffct-2.4.4 → diffct-2.5.1}/tests/_multiprocessing.py +0 -0
  73. {diffct-2.4.4 → diffct-2.5.1}/tests/_native_per_axis_spacing.py +0 -0
  74. {diffct-2.4.4 → diffct-2.5.1}/tests/_phantoms.py +0 -0
  75. {diffct-2.4.4 → diffct-2.5.1}/tests/benchmarks/README.md +0 -0
  76. {diffct-2.4.4 → diffct-2.5.1}/tests/benchmarks/__init__.py +0 -0
  77. {diffct-2.4.4 → diffct-2.5.1}/tests/benchmarks/_common.py +0 -0
  78. {diffct-2.4.4 → diffct-2.5.1}/tests/benchmarks/test_bench_cone.py +0 -0
  79. {diffct-2.4.4 → diffct-2.5.1}/tests/benchmarks/test_bench_fan.py +0 -0
  80. {diffct-2.4.4 → diffct-2.5.1}/tests/benchmarks/test_bench_parallel.py +0 -0
  81. {diffct-2.4.4 → diffct-2.5.1}/tests/distributed_projector_check.py +0 -0
  82. {diffct-2.4.4 → diffct-2.5.1}/tests/fixtures/native_scalar_spacing_main.npz +0 -0
  83. {diffct-2.4.4 → diffct-2.5.1}/tests/test_adjoint_inner_product.py +0 -0
  84. {diffct-2.4.4 → diffct-2.5.1}/tests/test_allocator_capacity.py +0 -0
  85. {diffct-2.4.4 → diffct-2.5.1}/tests/test_analytical_autograd.py +0 -0
  86. {diffct-2.4.4 → diffct-2.5.1}/tests/test_analytical_distribution.py +0 -0
  87. {diffct-2.4.4 → diffct-2.5.1}/tests/test_analytical_regressions.py +0 -0
  88. {diffct-2.4.4 → diffct-2.5.1}/tests/test_analytical_weight_distribution.py +0 -0
  89. {diffct-2.4.4 → diffct-2.5.1}/tests/test_angular_low_precision.py +0 -0
  90. {diffct-2.4.4 → diffct-2.5.1}/tests/test_axis_aligned_chords.py +0 -0
  91. {diffct-2.4.4 → diffct-2.5.1}/tests/test_batch_execution_edges.py +0 -0
  92. {diffct-2.4.4 → diffct-2.5.1}/tests/test_batched_surfaces.py +0 -0
  93. {diffct-2.4.4 → diffct-2.5.1}/tests/test_block_cgls.py +0 -0
  94. {diffct-2.4.4 → diffct-2.5.1}/tests/test_block_storage.py +0 -0
  95. {diffct-2.4.4 → diffct-2.5.1}/tests/test_call_trajectory.py +0 -0
  96. {diffct-2.4.4 → diffct-2.5.1}/tests/test_cgls_state_admission.py +0 -0
  97. {diffct-2.4.4 → diffct-2.5.1}/tests/test_checkpoint_metadata_validation.py +0 -0
  98. {diffct-2.4.4 → diffct-2.5.1}/tests/test_chunked_projector.py +0 -0
  99. {diffct-2.4.4 → diffct-2.5.1}/tests/test_completed_transfer_reuse.py +0 -0
  100. {diffct-2.4.4 → diffct-2.5.1}/tests/test_cone_backprojection_transpose.py +0 -0
  101. {diffct-2.4.4 → diffct-2.5.1}/tests/test_cone_projector_autograd.py +0 -0
  102. {diffct-2.4.4 → diffct-2.5.1}/tests/test_cpu_install.py +0 -0
  103. {diffct-2.4.4 → diffct-2.5.1}/tests/test_cuda_exit_deallocations.py +0 -0
  104. {diffct-2.4.4 → diffct-2.5.1}/tests/test_cuda_sink_lifetimes.py +0 -0
  105. {diffct-2.4.4 → diffct-2.5.1}/tests/test_cuda_smoke.py +0 -0
  106. {diffct-2.4.4 → diffct-2.5.1}/tests/test_detector_surface_dtypes.py +0 -0
  107. {diffct-2.4.4 → diffct-2.5.1}/tests/test_detector_surface_mapping.py +0 -0
  108. {diffct-2.4.4 → diffct-2.5.1}/tests/test_detector_surface_units.py +0 -0
  109. {diffct-2.4.4 → diffct-2.5.1}/tests/test_detector_surfaces.py +0 -0
  110. {diffct-2.4.4 → diffct-2.5.1}/tests/test_distributed_training_transforms.py +0 -0
  111. {diffct-2.4.4 → diffct-2.5.1}/tests/test_example_psnr.py +0 -0
  112. {diffct-2.4.4 → diffct-2.5.1}/tests/test_execution_buffers.py +0 -0
  113. {diffct-2.4.4 → diffct-2.5.1}/tests/test_execution_features.py +0 -0
  114. {diffct-2.4.4 → diffct-2.5.1}/tests/test_execution_schedules.py +0 -0
  115. {diffct-2.4.4 → diffct-2.5.1}/tests/test_fbp_fan_accuracy.py +0 -0
  116. {diffct-2.4.4 → diffct-2.5.1}/tests/test_fbp_fan_offsets.py +0 -0
  117. {diffct-2.4.4 → diffct-2.5.1}/tests/test_fbp_parallel_accuracy.py +0 -0
  118. {diffct-2.4.4 → diffct-2.5.1}/tests/test_fdk_cone_accuracy.py +0 -0
  119. {diffct-2.4.4 → diffct-2.5.1}/tests/test_fdk_cone_offsets.py +0 -0
  120. {diffct-2.4.4 → diffct-2.5.1}/tests/test_geometry_autograd.py +0 -0
  121. {diffct-2.4.4 → diffct-2.5.1}/tests/test_geometry_conversions.py +0 -0
  122. {diffct-2.4.4 → diffct-2.5.1}/tests/test_geometry_gradients.py +0 -0
  123. {diffct-2.4.4 → diffct-2.5.1}/tests/test_gpu_performance_contracts.py +0 -0
  124. {diffct-2.4.4 → diffct-2.5.1}/tests/test_gradcheck.py +0 -0
  125. {diffct-2.4.4 → diffct-2.5.1}/tests/test_lazy_native_observers.py +0 -0
  126. {diffct-2.4.4 → diffct-2.5.1}/tests/test_live_trajectory_validation.py +0 -0
  127. {diffct-2.4.4 → diffct-2.5.1}/tests/test_multi_gpu_exit.py +0 -0
  128. {diffct-2.4.4 → diffct-2.5.1}/tests/test_multiprocessing.py +0 -0
  129. {diffct-2.4.4 → diffct-2.5.1}/tests/test_native_endpoint_precision.py +0 -0
  130. {diffct-2.4.4 → diffct-2.5.1}/tests/test_native_per_axis_admission.py +0 -0
  131. {diffct-2.4.4 → diffct-2.5.1}/tests/test_native_per_axis_extremes.py +0 -0
  132. {diffct-2.4.4 → diffct-2.5.1}/tests/test_native_per_axis_helical.py +0 -0
  133. {diffct-2.4.4 → diffct-2.5.1}/tests/test_native_per_axis_spacing.py +0 -0
  134. {diffct-2.4.4 → diffct-2.5.1}/tests/test_native_per_axis_spacing_gpu.py +0 -0
  135. {diffct-2.4.4 → diffct-2.5.1}/tests/test_native_surface_transforms.py +0 -0
  136. {diffct-2.4.4 → diffct-2.5.1}/tests/test_native_volume_layout.py +0 -0
  137. {diffct-2.4.4 → diffct-2.5.1}/tests/test_native_volume_layout_distributed.py +0 -0
  138. {diffct-2.4.4 → diffct-2.5.1}/tests/test_output_pipeline_overlap.py +0 -0
  139. {diffct-2.4.4 → diffct-2.5.1}/tests/test_parallel3d.py +0 -0
  140. {diffct-2.4.4 → diffct-2.5.1}/tests/test_parallel3d_surface_oracle.py +0 -0
  141. {diffct-2.4.4 → diffct-2.5.1}/tests/test_partition_lifecycle.py +0 -0
  142. {diffct-2.4.4 → diffct-2.5.1}/tests/test_plan_memory_bounds.py +0 -0
  143. {diffct-2.4.4 → diffct-2.5.1}/tests/test_portable_bounded_autograd.py +0 -0
  144. {diffct-2.4.4 → diffct-2.5.1}/tests/test_portable_cuda_budget.py +0 -0
  145. {diffct-2.4.4 → diffct-2.5.1}/tests/test_portable_distributed_geometry.py +0 -0
  146. {diffct-2.4.4 → diffct-2.5.1}/tests/test_portable_endpoint_precision.py +0 -0
  147. {diffct-2.4.4 → diffct-2.5.1}/tests/test_portable_execution_bounds.py +0 -0
  148. {diffct-2.4.4 → diffct-2.5.1}/tests/test_portable_numeric_regressions.py +0 -0
  149. {diffct-2.4.4 → diffct-2.5.1}/tests/test_portable_sampler_snapshot.py +0 -0
  150. {diffct-2.4.4 → diffct-2.5.1}/tests/test_portable_surface_admission.py +0 -0
  151. {diffct-2.4.4 → diffct-2.5.1}/tests/test_pr42_review_regressions.py +0 -0
  152. {diffct-2.4.4 → diffct-2.5.1}/tests/test_projector_api.py +0 -0
  153. {diffct-2.4.4 → diffct-2.5.1}/tests/test_projector_degenerate_geometry.py +0 -0
  154. {diffct-2.4.4 → diffct-2.5.1}/tests/test_projector_geometry_devices.py +0 -0
  155. {diffct-2.4.4 → diffct-2.5.1}/tests/test_ramp_filter_windows.py +0 -0
  156. {diffct-2.4.4 → diffct-2.5.1}/tests/test_reconstruction_api.py +0 -0
  157. {diffct-2.4.4 → diffct-2.5.1}/tests/test_reconstruction_work_reuse.py +0 -0
  158. {diffct-2.4.4 → diffct-2.5.1}/tests/test_reference_backend.py +0 -0
  159. {diffct-2.4.4 → diffct-2.5.1}/tests/test_release_2_0_1.py +0 -0
  160. {diffct-2.4.4 → diffct-2.5.1}/tests/test_review_230.py +0 -0
  161. {diffct-2.4.4 → diffct-2.5.1}/tests/test_review_regressions.py +0 -0
  162. {diffct-2.4.4 → diffct-2.5.1}/tests/test_solver_admission_regressions.py +0 -0
  163. {diffct-2.4.4 → diffct-2.5.1}/tests/test_solver_api.py +0 -0
  164. {diffct-2.4.4 → diffct-2.5.1}/tests/test_solver_collective_failures.py +0 -0
  165. {diffct-2.4.4 → diffct-2.5.1}/tests/test_solver_device_scalars.py +0 -0
  166. {diffct-2.4.4 → diffct-2.5.1}/tests/test_spatial_partition.py +0 -0
  167. {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_collective_failures.py +0 -0
  168. {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_composition_kernel.py +0 -0
  169. {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_compute_shards.py +0 -0
  170. {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_geometry_cudasim.py +0 -0
  171. {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_gpu_execution.py +0 -0
  172. {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_gradient_replay.py +0 -0
  173. {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_host_sampling_budget.py +0 -0
  174. {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_native_regressions.py +0 -0
  175. {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_parameter_updates.py +0 -0
  176. {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_pipeline_contracts.py +0 -0
  177. {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_saved_snapshot_budget.py +0 -0
  178. {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_snapshot_admission.py +0 -0
  179. {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_trajectory_transform.py +0 -0
  180. {diffct-2.4.4 → diffct-2.5.1}/tests/test_training_transforms.py +0 -0
  181. {diffct-2.4.4 → diffct-2.5.1}/tests/test_trajectory_admission_cost.py +0 -0
  182. {diffct-2.4.4 → diffct-2.5.1}/tests/test_trajectory_surface_integration.py +0 -0
  183. {diffct-2.4.4 → diffct-2.5.1}/tests/test_transfer_pipeline.py +0 -0
  184. {diffct-2.4.4 → diffct-2.5.1}/tests/test_transfer_retirement_lifetimes.py +0 -0
  185. {diffct-2.4.4 → diffct-2.5.1}/tests/test_tv_zero_dependency.py +0 -0
  186. {diffct-2.4.4 → diffct-2.5.1}/tests/test_two_far_endpoints.py +0 -0
  187. {diffct-2.4.4 → diffct-2.5.1}/tests/test_weights.py +0 -0
@@ -5,7 +5,61 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [2.5.1] - 2026-11-07
9
+
10
+ ### Fixed
11
+
12
+ - Saving a solver checkpoint into a step that already exists raises a clear
13
+ `ValueError` that names the existing directory, instead of a bare
14
+ `FileExistsError`. The existing checkpoint is not changed. Use a new
15
+ `checkpoint_directory`, or pass `resume=` to continue from it. Thanks to
16
+ @cslht11 ([#63](https://github.com/sypsyp97/diffct/pull/63)).
17
+ - `random_trajectory_3d(seed=...)` draws its noise from a private generator
18
+ and no longer reseeds the process-wide PyTorch generator. Seeded
19
+ trajectories are unchanged. Thanks to @cslht11
20
+ ([#64](https://github.com/sypsyp97/diffct/pull/64)).
21
+
22
+ ### Changed
23
+
24
+ - CPU tests check the FBP, FDK and short-scan Parker accuracy bounds on the
25
+ portable backend, so CI covers them without a GPU. Thanks to @cslht11
26
+ ([#65](https://github.com/sypsyp97/diffct/pull/65)).
27
+ - A `.mailmap` joins the commits of one contributor under one name
28
+ ([#62](https://github.com/sypsyp97/diffct/pull/62)).
29
+
30
+ ### Documentation
31
+
32
+ - Semantic search on the documentation site is off by default. The
33
+ embedding model (about 377 MB) downloads only after the visitor enables it
34
+ ([#66](https://github.com/sypsyp97/diffct/pull/66)).
35
+
36
+ ## [2.5.0] - 2026-10-26
37
+
38
+ ### Added
39
+
40
+ - Apple Silicon GPU (MPS) support for the portable PyTorch backend, including
41
+ the solvers, detector surfaces and streamed stores. MPS has no FP64, so ray
42
+ clipping and entry setup run in FP32 there; `precision="float64"` raises a
43
+ `ValueError` for MPS data, outputs or trajectories. See `docs/EXECUTION.md`
44
+ for the FP32 error bound. Thanks to @cslht11
45
+ ([#57](https://github.com/sypsyp97/diffct/pull/57)).
46
+ - TLA+ protocol models with TLC/Apalache checks and execution trace validation
47
+ for memory budgets, native admission and distributed collectives, run in a
48
+ separate CI workflow. They live in `formal/` and do not change library
49
+ behavior ([#55](https://github.com/sypsyp97/diffct/pull/55)).
50
+
51
+ ### Fixed
52
+
53
+ - Trajectory constructors reject degenerate inputs instead of building NaN or empty geometry: zero or non-finite `ray_dir_fn` directions in `custom_trajectory_2d_parallel`, `random_trajectory_3d` sources at the isocenter, and view counts that are not positive integers (`TypeError` for floats and booleans, `ValueError` for counts below 1). Explicit angle tensors are still accepted. Thanks to @cslht11 ([#56](https://github.com/sypsyp97/diffct/pull/56)).
54
+
55
+ ### Documentation
56
+
57
+ - README, documentation landing page, `llms.txt` summary, `CITATION.cff` and package keywords name the native CUDA kernels and the portable backend on CPU, ROCm and Apple Silicon. The landing quickstart has an Apple Silicon (MPS) tab ([#60](https://github.com/sypsyp97/diffct/pull/60)).
58
+ - Every documentation page uses the same three-column layout with the site navigation on the left ([#58](https://github.com/sypsyp97/diffct/pull/58)).
59
+
60
+ ### Changed
61
+
62
+ - Continuous integration: pull requests that change only `docs/` skip the package, test and formal jobs; the documentation build still runs. Pushes to `main` and release tags run every job ([#59](https://github.com/sypsyp97/diffct/pull/59)).
9
63
 
10
64
  ## [2.4.4] - 2026-10-25
11
65
 
@@ -2,8 +2,9 @@ cff-version: 1.2.0
2
2
  message: "If you use this software, please cite both the software (below) and the technical report listed under preferred-citation."
3
3
  title: "diffct: Differentiable Computed Tomography Reconstruction with CUDA"
4
4
  abstract: >-
5
- Differentiable CUDA projectors for computed tomography (CT) with PyTorch
6
- autograd. Each view has its own source and detector geometry, so circular,
5
+ Differentiable projectors for computed tomography (CT) with PyTorch
6
+ autograd: native NVIDIA CUDA kernels and a portable PyTorch backend for
7
+ CPU, ROCm and Apple Silicon (MPS). Each view has its own source and detector geometry, so circular,
7
8
  helical, saddle and calibrated scans use the same matched Siddon operator.
8
9
  Projections run on one GPU, several GPUs in one process, or across nodes,
9
10
  and trajectory tensors receive geometry gradients for calibration.
@@ -13,8 +14,8 @@ authors:
13
14
  given-names: Yipeng
14
15
  email: yipeng.sun@fau.de
15
16
  alias: sypsyp97
16
- version: 2.4.4
17
- date-released: "2026-10-25"
17
+ version: 2.5.1
18
+ date-released: "2026-11-07"
18
19
  license: Apache-2.0
19
20
  repository-code: "https://github.com/sypsyp97/diffct"
20
21
  url: "https://sypsyp97.github.io/diffct/"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: diffct
3
- Version: 2.4.4
3
+ Version: 2.5.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/
@@ -10,7 +10,7 @@ Author-email: Yipeng Sun <yipeng.sun@fau.de>
10
10
  License-Expression: Apache-2.0
11
11
  License-File: LICENSE
12
12
  License-File: NOTICE
13
- Keywords: CT reconstruction,CUDA,PyTorch,ROCm,computed tomography,differentiable programming
13
+ Keywords: Apple Silicon,CT reconstruction,CUDA,MPS,PyTorch,ROCm,computed tomography,differentiable programming
14
14
  Classifier: Development Status :: 5 - Production/Stable
15
15
  Classifier: Environment :: GPU :: NVIDIA CUDA
16
16
  Classifier: Intended Audience :: Science/Research
@@ -34,7 +34,8 @@ Description-Content-Type: text/markdown
34
34
  <p align="center">English · <a href="https://github.com/sypsyp97/diffct/blob/main/README.zh.md">简体中文</a></p>
35
35
 
36
36
  <p align="center">
37
- Differentiable CUDA projectors for CT: arbitrary trajectories, multi-GPU, multi-node, geometry gradients.
37
+ Differentiable CT projectors: arbitrary trajectories, multi-GPU, multi-node, geometry gradients.<br>
38
+ Native NVIDIA CUDA kernels, plus a portable PyTorch backend for CPU, ROCm and Apple Silicon (MPS).
38
39
  </p>
39
40
 
40
41
  <p align="center">
@@ -66,7 +67,7 @@ Description-Content-Type: text/markdown
66
67
  </p>
67
68
 
68
69
  > **Version 2.0** replaces the circular-orbit API of 1.x with per-view trajectories. Code written for 1.x needs changes; see the [migration notes](https://github.com/sypsyp97/diffct/blob/main/docs/MIGRATION.md).
69
- > For Apple Silicon, use the [MLX port by Linda-Sophie Schneider](https://github.com/Linda-SophieSchneider/DiffCT-MLX).
70
+ > Apple Silicon runs through the built-in MPS support (`backend="torch"`); a community [MLX port by Linda-Sophie Schneider](https://github.com/Linda-SophieSchneider/DiffCT-MLX) also exists.
70
71
 
71
72
  This README describes the source checkout. Features listed under [Unreleased](CHANGELOG.md#unreleased) require installation from source.
72
73
 
@@ -79,7 +80,7 @@ To our knowledge, diffct is the only open-source GPU CT library that combines ar
79
80
  - **Matched operators.** `project()` and `backproject()` form an exact adjoint pair for the cell-constant Siddon model. Both support PyTorch autograd, with volume/sinogram gradients and Hessian-vector products.
80
81
  - **Large volumes.** Automatic spatial, view and pixel blocks with bounded transfer buffers control working memory. `project_into`/`backproject_into` accept caller-owned tensor or disk stores; tensor autograd keeps complete data on its input device. See [chunked execution](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#chunked-execution).
81
82
  - **Multi-GPU and multi-node.** Use `devices=[0, 1, 2, 3]` in one process, or torchrun and NCCL across ranks. Views, space and hybrid partitions control replication of volumes and projections. Speedup depends on workload and communication costs.
82
- - **Precision and training.** Explicit native/PyTorch backends, FP32/FP64 modes, per-axis voxel spacing, leading batch/channel dimensions and data `torch.func` transforms. The portable path supports CPU and PyTorch CUDA/ROCm devices; see [execution contracts](docs/EXECUTION.md).
83
+ - **Precision and training.** Explicit native/PyTorch backends, FP32/FP64 modes, per-axis voxel spacing, leading batch/channel dimensions and data `torch.func` transforms. The portable path supports CPU, PyTorch CUDA/ROCm devices and Apple Silicon GPUs (MPS, `backend="torch"`); see [execution contracts](docs/EXECUTION.md).
83
84
  - **Solver API.** `cgls`, `sirt` and `tv_reconstruction` report residual history, tolerances and callbacks, with scan/data fingerprints and complete checkpoint state. `cgls_into` keeps state in bounded mapped blocks.
84
85
  - **Differentiable FBP and FDK.** `fbp`, `fdk` and approximate `helical_fdk` compose the public filters, weights and analytical backprojectors. Their backward kernels transpose the actual interpolation stencil; custom filters and weights can be learned. See the [analytical guide](docs/source/analytical_learning.rst).
85
86
 
@@ -95,7 +96,7 @@ pip install "diffct[cu12]" # PyTorch built for CUDA 12
95
96
  pip install "diffct[cu13]" # PyTorch built for CUDA 13
96
97
  ```
97
98
 
98
- The extra installs the CUDA compiler libraries Numba needs. For CPU or PyTorch ROCm execution, install the appropriate PyTorch build and plain `pip install diffct`, then select `backend="torch"`. Importing diffct and using this backend require no CUDA runtime; Numba CUDA initializes when native execution is used. Plain installation also works for native execution when a compatible CUDA Toolkit is already present.
99
+ The extra installs the CUDA compiler libraries Numba needs. For CPU, PyTorch ROCm or Apple Silicon (MPS) execution, install the appropriate PyTorch build and plain `pip install diffct`, then select `backend="torch"`. Importing diffct and using this backend require no CUDA runtime; Numba CUDA initializes when native execution is used. Plain installation also works for native execution when a compatible CUDA Toolkit is already present.
99
100
 
100
101
  From source, with the examples:
101
102
 
@@ -3,7 +3,8 @@
3
3
  <p align="center">English · <a href="https://github.com/sypsyp97/diffct/blob/main/README.zh.md">简体中文</a></p>
4
4
 
5
5
  <p align="center">
6
- Differentiable CUDA projectors for CT: arbitrary trajectories, multi-GPU, multi-node, geometry gradients.
6
+ Differentiable CT projectors: arbitrary trajectories, multi-GPU, multi-node, geometry gradients.<br>
7
+ Native NVIDIA CUDA kernels, plus a portable PyTorch backend for CPU, ROCm and Apple Silicon (MPS).
7
8
  </p>
8
9
 
9
10
  <p align="center">
@@ -35,7 +36,7 @@
35
36
  </p>
36
37
 
37
38
  > **Version 2.0** replaces the circular-orbit API of 1.x with per-view trajectories. Code written for 1.x needs changes; see the [migration notes](https://github.com/sypsyp97/diffct/blob/main/docs/MIGRATION.md).
38
- > For Apple Silicon, use the [MLX port by Linda-Sophie Schneider](https://github.com/Linda-SophieSchneider/DiffCT-MLX).
39
+ > Apple Silicon runs through the built-in MPS support (`backend="torch"`); a community [MLX port by Linda-Sophie Schneider](https://github.com/Linda-SophieSchneider/DiffCT-MLX) also exists.
39
40
 
40
41
  This README describes the source checkout. Features listed under [Unreleased](CHANGELOG.md#unreleased) require installation from source.
41
42
 
@@ -48,7 +49,7 @@ To our knowledge, diffct is the only open-source GPU CT library that combines ar
48
49
  - **Matched operators.** `project()` and `backproject()` form an exact adjoint pair for the cell-constant Siddon model. Both support PyTorch autograd, with volume/sinogram gradients and Hessian-vector products.
49
50
  - **Large volumes.** Automatic spatial, view and pixel blocks with bounded transfer buffers control working memory. `project_into`/`backproject_into` accept caller-owned tensor or disk stores; tensor autograd keeps complete data on its input device. See [chunked execution](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#chunked-execution).
50
51
  - **Multi-GPU and multi-node.** Use `devices=[0, 1, 2, 3]` in one process, or torchrun and NCCL across ranks. Views, space and hybrid partitions control replication of volumes and projections. Speedup depends on workload and communication costs.
51
- - **Precision and training.** Explicit native/PyTorch backends, FP32/FP64 modes, per-axis voxel spacing, leading batch/channel dimensions and data `torch.func` transforms. The portable path supports CPU and PyTorch CUDA/ROCm devices; see [execution contracts](docs/EXECUTION.md).
52
+ - **Precision and training.** Explicit native/PyTorch backends, FP32/FP64 modes, per-axis voxel spacing, leading batch/channel dimensions and data `torch.func` transforms. The portable path supports CPU, PyTorch CUDA/ROCm devices and Apple Silicon GPUs (MPS, `backend="torch"`); see [execution contracts](docs/EXECUTION.md).
52
53
  - **Solver API.** `cgls`, `sirt` and `tv_reconstruction` report residual history, tolerances and callbacks, with scan/data fingerprints and complete checkpoint state. `cgls_into` keeps state in bounded mapped blocks.
53
54
  - **Differentiable FBP and FDK.** `fbp`, `fdk` and approximate `helical_fdk` compose the public filters, weights and analytical backprojectors. Their backward kernels transpose the actual interpolation stencil; custom filters and weights can be learned. See the [analytical guide](docs/source/analytical_learning.rst).
54
55
 
@@ -64,7 +65,7 @@ pip install "diffct[cu12]" # PyTorch built for CUDA 12
64
65
  pip install "diffct[cu13]" # PyTorch built for CUDA 13
65
66
  ```
66
67
 
67
- The extra installs the CUDA compiler libraries Numba needs. For CPU or PyTorch ROCm execution, install the appropriate PyTorch build and plain `pip install diffct`, then select `backend="torch"`. Importing diffct and using this backend require no CUDA runtime; Numba CUDA initializes when native execution is used. Plain installation also works for native execution when a compatible CUDA Toolkit is already present.
68
+ The extra installs the CUDA compiler libraries Numba needs. For CPU, PyTorch ROCm or Apple Silicon (MPS) execution, install the appropriate PyTorch build and plain `pip install diffct`, then select `backend="torch"`. Importing diffct and using this backend require no CUDA runtime; Numba CUDA initializes when native execution is used. Plain installation also works for native execution when a compatible CUDA Toolkit is already present.
68
69
 
69
70
  From source, with the examples:
70
71
 
@@ -64,7 +64,7 @@ try:
64
64
  except ImportError:
65
65
  pass
66
66
 
67
- __version__ = '2.4.4'
67
+ __version__ = '2.5.1'
68
68
 
69
69
  __all__ = [
70
70
  'Projector',
@@ -12,6 +12,7 @@ import torch.distributed as dist
12
12
  from .pipeline import _fragments
13
13
  from .storage import NpyStore, TensorStore
14
14
  from .distribution import control_status, preflight
15
+ from .utils import _work_dtype
15
16
 
16
17
 
17
18
  def count(value, name, positive=False):
@@ -65,20 +66,46 @@ def scalar_sum(operator, value, kind, *, return_tensor=False):
65
66
  dist.all_reduce(accumulator, group=group)
66
67
  return accumulator if return_tensor else accumulator.item()
67
68
  if return_tensor:
68
- return torch.as_tensor(value, dtype=torch.float64, device=result_device)
69
+ return torch.as_tensor(value, dtype=_work_dtype(result_device), device=result_device)
69
70
  return float(value)
70
71
 
71
72
 
72
73
  def squared_norm(operator, value, kind, elements=1048576, *, return_tensor=False):
73
74
  store = TensorStore(value) if isinstance(value, torch.Tensor) else value
74
- total = torch.zeros((), dtype=torch.float64, device=getattr(store, "device", "cpu"))
75
+ # Scale every block by its own peak before squaring and track the
76
+ # running maximum on the device: unscaled FP32 squares overflow for
77
+ # large measurements and underflow for tiny geometries.
78
+ device = getattr(store, "device", torch.device("cpu"))
79
+ if _work_dtype(device) == torch.float64:
80
+ total = torch.zeros((), dtype=torch.float64, device=device)
81
+ if owns_values(operator, kind):
82
+ for selected in _fragments(tuple(store.shape), elements):
83
+ # Clone even an FP64 view: norms must never alter caller measurements.
84
+ block = store.read(selected).to(dtype=torch.float64).clone()
85
+ # Preserve the block reductions and their sequential FP64 order.
86
+ total.add_(block.square_().sum().to(device=total.device))
87
+ return scalar_sum(operator, total, kind, return_tensor=return_tensor)
88
+ # FP32 devices (MPS): squares of large measurements overflow and tiny
89
+ # geometries underflow, so every block is rescaled by its peak and the
90
+ # running sum follows the largest peak seen. All block math stays on the
91
+ # device; the two closing scalar reads fit the per-iteration reduction
92
+ # budget, and their product leaves FP32 before it can overflow.
93
+ peak = torch.zeros((), device=device, dtype=torch.float32)
94
+ total = torch.zeros((), device=device, dtype=torch.float32)
75
95
  if owns_values(operator, kind):
76
96
  for selected in _fragments(tuple(store.shape), elements):
77
- # Clone even an FP64 view: norms must never alter caller measurements.
78
- block = store.read(selected).to(dtype=torch.float64).clone()
79
- # Preserve the block reductions and their sequential FP64 order.
80
- total.add_(block.square_().sum().to(device=total.device))
81
- return scalar_sum(operator, total, kind, return_tensor=return_tensor)
97
+ block = store.read(selected)
98
+ if not block.numel():
99
+ continue
100
+ block_peak = block.detach().abs().amax()
101
+ safe = torch.where(block_peak > 0, block_peak, torch.ones_like(block_peak))
102
+ combined = torch.maximum(peak, block_peak)
103
+ guard = torch.where(combined > 0, combined, torch.ones_like(combined))
104
+ total = (total * (peak / guard).square()
105
+ + (block.detach() / safe).square().sum() * (block_peak / guard).square())
106
+ peak = combined
107
+ return scalar_sum(operator, peak.item() ** 2 * total.item(), kind,
108
+ return_tensor=return_tensor)
82
109
 
83
110
 
84
111
  def copy_blocks(source, target, elements):
@@ -261,6 +288,11 @@ def save_checkpoint(operator, directory, states, metadata, iteration, history, m
261
288
  step = Path(directory).resolve() / f"step-{iteration:06d}"
262
289
  step.mkdir(parents=True, exist_ok=True)
263
290
  rank = step / f"rank-{getattr(operator, 'rank', 0):05d}"
291
+ if rank.exists():
292
+ # Replacing a published step in place could destroy it on a
293
+ # failed write, and ranks cannot roll back together.
294
+ raise ValueError(f"checkpoint {rank} already exists; use a new checkpoint_directory "
295
+ "or pass resume= to continue from it")
264
296
  rank.mkdir()
265
297
  for name, source in states.items():
266
298
  source = TensorStore(source.detach()) if isinstance(source, torch.Tensor) else source
@@ -160,6 +160,8 @@ class AnalyticalExecution:
160
160
  raise TypeError("sinogram store must have dtype float32 or float64")
161
161
  self.source = source
162
162
  self.output_device = torch.device(getattr(source, "device", "cpu"))
163
+ if self.dtype == torch.float64 and self.output_device.type == "mps":
164
+ raise ValueError("precision must be 'fast' or 'float32' on MPS; MPS provides no float64")
163
165
  self.detector_shape = tuple(source.shape[1:])
164
166
  for component in trajectory:
165
167
  _fixed(component, "trajectory")
@@ -12,6 +12,7 @@ import torch.distributed as dist
12
12
  from torch.autograd import forward_ad
13
13
 
14
14
  from .chunking import _memory_lower_bounds
15
+ from .utils import _work_dtype
15
16
  from .distribution import control_status
16
17
  from .surfaces import ParameterizedSurface
17
18
  from .constants import _native_spacing
@@ -76,6 +77,16 @@ def _zero_dependency(value):
76
77
  return value.unsqueeze(0)[:0].sum()
77
78
 
78
79
 
80
+ def _constant_like(reference, values):
81
+ """Build a constant tensor on ``reference``'s device and dtype.
82
+
83
+ ``new_tensor`` has no kernel behind a functorch dual on MPS and raises
84
+ "DispatchKey Undefined doesn't correspond to a device"; an explicit
85
+ constructor keeps the same constant on every device and transform.
86
+ """
87
+ return torch.tensor(values, dtype=reference.dtype, device=reference.device)
88
+
89
+
79
90
  def voxel_spacing_tuple(spacing, rank):
80
91
  """Return positive physical pitches in tensor axis order."""
81
92
  values = spacing if isinstance(spacing, (tuple, list)) else (spacing,) * rank
@@ -563,7 +574,14 @@ class ReferenceProjector:
563
574
  with torch.no_grad():
564
575
  # Reuse frame transfers across detector chunks. Offset batches
565
576
  # remain bounded and move only when a ray batch needs them.
566
- staged = {device: (*tuple(value.to(device=device) for value in geometry[:frame_count]),
577
+ def _stage(value, target_device):
578
+ # FP64 frames must not follow FP64-less compute devices.
579
+ target_dtype = _work_dtype(target_device)
580
+ if value.dtype.itemsize > target_dtype.itemsize:
581
+ return value.to(device=target_device, dtype=target_dtype)
582
+ return value.to(device=target_device)
583
+
584
+ staged = {device: (*tuple(_stage(value, device) for value in geometry[:frame_count]),
567
585
  *parameters) for device in set(devices)}
568
586
  batches = (self._admission_batches(staged[devices[0]], plan) if admission
569
587
  else self._batches(*plan[1:]))
@@ -630,7 +648,7 @@ class ReferenceProjector:
630
648
  def compose(validate, stats):
631
649
  check_range = (validate and owner.beam not in ("parallel", "parallel3d")
632
650
  and owner.dtype == torch.float32 and surface is not None)
633
- dtype = torch.float64 if check_range else owner.dtype
651
+ dtype = _work_dtype(device) if check_range else owner.dtype
634
652
  frames = (owner._learnable or owner._trajectory) if geometry is None else geometry[:frame_count]
635
653
  native_surface = owner.backend == "numba" and surface is not None
636
654
  native_legacy = native_surface and not isinstance(surface, ParameterizedSurface)
@@ -666,7 +684,7 @@ class ReferenceProjector:
666
684
  if check_range and not native_surface:
667
685
  # Certify ordinary batches without expanding raw FP64 world points.
668
686
  # L1 norms bound Euclidean distances without squaring tiny bases.
669
- pitches = local.new_tensor(self.spacing[::-1])
687
+ pitches = _constant_like(local, self.spacing[::-1])
670
688
  maximum = local.abs().amax(dim=tuple(range(1, local.ndim - 1)))
671
689
  axis_u = selected[2]
672
690
  if owner.beam == "cone":
@@ -698,7 +716,7 @@ class ReferenceProjector:
698
716
  if check_range:
699
717
  # Raw FP64 coordinates preserve the exact admission boundaries.
700
718
  sources = frame(selected[0]).expand_as(points)
701
- pitches = points.new_tensor(self.spacing[::-1])
719
+ pitches = _constant_like(points, self.spacing[::-1])
702
720
  source_distance = torch.linalg.vector_norm(sources / pitches, dim=-1)
703
721
  pixel_distance = torch.linalg.vector_norm(points / pitches, dim=-1)
704
722
  if torch.any(torch.minimum(source_distance, pixel_distance) > 1e6).item():
@@ -725,7 +743,7 @@ class ReferenceProjector:
725
743
  origins, directions = points, frame(selected[0]).expand_as(points)
726
744
  else:
727
745
  sources = frame(selected[0]).expand_as(points)
728
- pitches = points.new_tensor(self.spacing[::-1])
746
+ pitches = _constant_like(points, self.spacing[::-1])
729
747
  source_distance = torch.linalg.vector_norm(sources / pitches, dim=-1)
730
748
  pixel_distance = torch.linalg.vector_norm(points / pitches, dim=-1)
731
749
  if validate:
@@ -764,14 +782,14 @@ class ReferenceProjector:
764
782
  # Global FP32 segment parameters lose voxel widths when both endpoints
765
783
  # are distant. Plane tables below use local physical distances and the
766
784
  # chosen precision, including for native data-transform dispatch.
767
- origins = origins.to(dtype=torch.float64)
768
- directions = directions.to(dtype=torch.float64)
785
+ origins = origins.to(dtype=_work_dtype(origins.device))
786
+ directions = directions.to(dtype=_work_dtype(directions.device))
769
787
  ray_length = torch.linalg.vector_norm(directions, dim=-1)
770
788
  directions = directions / ray_length[:, None]
771
- lower = origins.new_tensor([(part.start - size / 2) * pitch
772
- for part, size, pitch in zip(world_tile, shape, spacing)])
773
- upper = origins.new_tensor([(part.stop - size / 2) * pitch
774
- for part, size, pitch in zip(world_tile, shape, spacing)])
789
+ lower = _constant_like(origins, [(part.start - size / 2) * pitch
790
+ for part, size, pitch in zip(world_tile, shape, spacing)])
791
+ upper = _constant_like(origins, [(part.stop - size / 2) * pitch
792
+ for part, size, pitch in zip(world_tile, shape, spacing)])
775
793
  constant = directions == 0
776
794
  safe_direction = torch.where(constant, torch.ones_like(directions), directions)
777
795
  first, last = (lower - origins) / safe_direction, (upper - origins) / safe_direction
@@ -1093,7 +1111,17 @@ class ReferenceProjector:
1093
1111
  # The zero dependency keeps unused inputs connected. With several
1094
1112
  # devices, a pair's loss and the geometry leaves can be on different
1095
1113
  # CUDA devices, so each zero moves to the loss's device.
1096
- loss = loss + sum(_zero_dependency(value).to(loss.device) for value in inputs)
1114
+ def _zero_to(value, device):
1115
+ zero = _zero_dependency(value)
1116
+ target_dtype = _work_dtype(device)
1117
+ if zero.dtype.itemsize > target_dtype.itemsize:
1118
+ # Cast on the source device first: a fused device and dtype
1119
+ # copy routes the float64 gradient back through the
1120
+ # FP64-less device on the way out.
1121
+ return zero.to(dtype=target_dtype).to(device=device)
1122
+ return zero.to(device=device)
1123
+
1124
+ loss = loss + sum(_zero_to(value, loss.device) for value in inputs)
1097
1125
  for total, part in zip(totals, torch.autograd.grad(loss, inputs)):
1098
1126
  total.add_(part)
1099
1127
 
@@ -5,6 +5,7 @@ and custom trajectories for parallel, fan and cone beams, plus rigid transforms.
5
5
  """
6
6
 
7
7
  import math
8
+ import operator
8
9
  import torch
9
10
  from typing import Callable, Tuple
10
11
 
@@ -40,10 +41,20 @@ def transform_trajectory(trajectory, rotation=None, translation=None, *, beam='c
40
41
  if rotation is not None:
41
42
  if tuple(rotation.shape) not in ((dimensions, dimensions), (views, dimensions, dimensions)):
42
43
  raise ValueError("rotation must have shape (d, d) or (views, d, d)")
43
- matrix = rotation.detach().double()
44
+ # MPS provides no float64; the formal harness also loads this module
45
+ # standalone, so the cast stays inline instead of importing helpers.
46
+ matrix = rotation.detach().to(dtype=torch.float32 if rotation.device.type == "mps"
47
+ else torch.float64)
44
48
  identity = torch.eye(dimensions, device=matrix.device, dtype=matrix.dtype).expand_as(matrix)
49
+ # Small explicit determinants: linalg.det routes through LU kernels
50
+ # that MPS does not provide.
51
+ if dimensions == 2:
52
+ determinant = (matrix[..., 0, 0] * matrix[..., 1, 1]
53
+ - matrix[..., 0, 1] * matrix[..., 1, 0])
54
+ else:
55
+ determinant = (matrix[..., 0] * torch.linalg.cross(matrix[..., 1], matrix[..., 2], dim=-1)).sum(-1)
45
56
  if (not torch.allclose(matrix.transpose(-1, -2) @ matrix, identity, rtol=1e-4, atol=1e-4)
46
- or not torch.allclose(torch.linalg.det(matrix), matrix.new_ones(matrix.shape[:-2]),
57
+ or not torch.allclose(determinant, matrix.new_ones(matrix.shape[:-2]),
47
58
  rtol=1e-4, atol=1e-4)):
48
59
  raise ValueError("rotation must be orthogonal and proper (determinant +1)")
49
60
  if translation is not None and tuple(translation.shape) not in ((dimensions,), (views, dimensions)):
@@ -84,22 +95,50 @@ def _circular_angles(n_views, start_angle, end_angle, device, dtype):
84
95
  if not torch.isfinite(n_views).all().item():
85
96
  raise ValueError("angles must be finite")
86
97
  return n_views, n_views.numel(), n_views.device, n_views.dtype
98
+ _check_n_views(n_views)
87
99
  step = (end_angle - start_angle) / n_views
88
100
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
89
101
  return angles, n_views, device, dtype
90
102
 
91
103
 
92
- def _check_source_positions(src_pos):
104
+ def _check_source_positions(src_pos, what="source_path_fn"):
93
105
  """Reject non-finite sources and sources at the isocenter, which have no viewing direction."""
94
106
  if not torch.isfinite(src_pos).all():
95
- raise ValueError("source_path_fn returned non-finite source positions")
107
+ raise ValueError(f"{what} returned non-finite source positions")
96
108
  if (torch.linalg.vector_norm(src_pos.detach(), dim=1) == 0).any():
97
109
  raise ValueError(
98
- "source_path_fn returned a source at the isocenter; every source needs a "
110
+ f"{what} returned a source at the isocenter; every source needs a "
99
111
  "nonzero distance from the origin"
100
112
  )
101
113
 
102
114
 
115
+ def _check_n_views(n_views):
116
+ """Reject view counts that are not positive integers.
117
+
118
+ A float count would space ``arange(ceil(n))`` views by ``span / n``, and a
119
+ nonpositive count builds an empty trajectory or divides by zero.
120
+ """
121
+ try:
122
+ count = operator.index(n_views)
123
+ except TypeError:
124
+ count = None
125
+ if count is None or isinstance(n_views, bool):
126
+ raise TypeError(f"n_views must be a positive integer, got {n_views!r}")
127
+ if count < 1:
128
+ raise ValueError(f"n_views must be a positive integer, got {n_views!r}")
129
+
130
+
131
+ def _check_ray_directions(ray_dir):
132
+ """Reject non-finite and zero ray directions, which normalize to NaN."""
133
+ if not torch.isfinite(ray_dir).all():
134
+ raise ValueError("ray_dir_fn returned non-finite ray directions")
135
+ if (torch.linalg.vector_norm(ray_dir.detach(), dim=1) == 0).any():
136
+ raise ValueError(
137
+ "ray_dir_fn returned a zero ray direction; every view needs a "
138
+ "nonzero direction vector"
139
+ )
140
+
141
+
103
142
  def circular_trajectory_3d(n_views, sid, sdd, start_angle=0.0, end_angle=None, device='cuda', dtype=torch.float32):
104
143
  """Generate circular trajectory geometry for cone-beam CT.
105
144
 
@@ -235,18 +274,24 @@ def random_trajectory_3d(n_views, sid_mean, sdd_mean, sid_std=0.0, pos_std=0.0,
235
274
  """
236
275
  import math
237
276
 
238
- if seed is not None:
239
- torch.manual_seed(seed)
240
-
241
277
  # Start with circular trajectory
242
278
  src_pos, det_center, det_u_vec, det_v_vec = circular_trajectory_3d(
243
279
  n_views, sid_mean, sdd_mean, device=device, dtype=dtype
244
280
  )
245
281
 
282
+ # Angle tensors override the requested device. Keep seeded draws on that
283
+ # device without changing any process-wide RNG state.
284
+ generator = None
285
+ if seed is not None:
286
+ # No fallback stream: a different generator would change the values.
287
+ generator = torch.Generator(device=src_pos.device)
288
+ # Match torch.manual_seed's conversion of bool, NumPy and tensor seeds.
289
+ generator.manual_seed(int(seed))
290
+
246
291
  # Add random perturbations
247
292
  if sid_std > 0.0:
248
293
  # Random SID variations
249
- sid_perturbations = torch.randn(n_views, device=device, dtype=dtype) * sid_std
294
+ sid_perturbations = torch.randn(n_views, device=device, dtype=dtype, generator=generator) * sid_std
250
295
  sdd_perturbations = sid_perturbations.clone() # Keep SDD-SID distance approximately constant
251
296
 
252
297
  # Compute angles from existing positions
@@ -267,12 +312,12 @@ def random_trajectory_3d(n_views, sid_mean, sdd_mean, sid_std=0.0, pos_std=0.0,
267
312
 
268
313
  if pos_std > 0.0:
269
314
  # Random 3D position offsets
270
- src_pos += torch.randn_like(src_pos) * pos_std
271
- det_center += torch.randn_like(det_center) * pos_std
315
+ src_pos += torch.empty_like(src_pos).normal_(generator=generator) * pos_std
316
+ det_center += torch.empty_like(det_center).normal_(generator=generator) * pos_std
272
317
 
273
318
  if angle_std > 0.0:
274
319
  # Random angular perturbations (rotate detector orientation)
275
- angle_perturbations = torch.randn(n_views, device=device, dtype=dtype) * angle_std
320
+ angle_perturbations = torch.randn(n_views, device=device, dtype=dtype, generator=generator) * angle_std
276
321
  cos_perturb = torch.cos(angle_perturbations)
277
322
  sin_perturb = torch.sin(angle_perturbations)
278
323
 
@@ -286,6 +331,8 @@ def random_trajectory_3d(n_views, sid_mean, sdd_mean, sid_std=0.0, pos_std=0.0,
286
331
  det_u_vec = det_u_vec / torch.norm(det_u_vec, dim=1, keepdim=True)
287
332
  det_v_vec = det_v_vec / torch.norm(det_v_vec, dim=1, keepdim=True)
288
333
 
334
+ _check_source_positions(src_pos, "random_trajectory_3d")
335
+
289
336
  return src_pos, det_center, det_u_vec, det_v_vec
290
337
 
291
338
 
@@ -334,6 +381,7 @@ def spiral_trajectory_3d(n_views, sid, sdd, z_range=100.0, n_turns=2.0,
334
381
  """
335
382
  # Generate angles for spiral motion
336
383
  end_angle = start_angle + 2 * math.pi * n_turns
384
+ _check_n_views(n_views)
337
385
  step = (end_angle - start_angle) / n_views
338
386
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
339
387
 
@@ -419,6 +467,7 @@ def sinusoidal_trajectory_3d(n_views, sid, sdd, amplitude=50.0, frequency=2.0,
419
467
  """
420
468
  # Generate angles
421
469
  end_angle = start_angle + 2 * math.pi
470
+ _check_n_views(n_views)
422
471
  step = (end_angle - start_angle) / n_views
423
472
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
424
473
 
@@ -505,6 +554,7 @@ def saddle_trajectory_3d(n_views, sid, sdd, z_amplitude=50.0, radial_amplitude=3
505
554
  """
506
555
  # Generate angles
507
556
  end_angle = start_angle + 2 * math.pi
557
+ _check_n_views(n_views)
508
558
  step = (end_angle - start_angle) / n_views
509
559
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
510
560
 
@@ -601,6 +651,7 @@ def custom_trajectory_3d(n_views, sid, sdd,
601
651
  """
602
652
  # Generate angles
603
653
  end_angle = start_angle + 2 * math.pi
654
+ _check_n_views(n_views)
604
655
  step = (end_angle - start_angle) / n_views
605
656
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
606
657
 
@@ -746,6 +797,7 @@ def sinusoidal_trajectory_2d_fan(n_views, sid, sdd, amplitude=50.0, frequency=2.
746
797
  """
747
798
  # Generate angles
748
799
  end_angle = start_angle + 2 * math.pi
800
+ _check_n_views(n_views)
749
801
  step = (end_angle - start_angle) / n_views
750
802
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
751
803
 
@@ -829,6 +881,7 @@ def custom_trajectory_2d_fan(n_views, sid, sdd,
829
881
  """
830
882
  # Generate angles
831
883
  end_angle = start_angle + 2 * math.pi
884
+ _check_n_views(n_views)
832
885
  step = (end_angle - start_angle) / n_views
833
886
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
834
887
 
@@ -973,6 +1026,7 @@ def sinusoidal_trajectory_2d_parallel(n_views, amplitude=50.0, frequency=2.0,
973
1026
  """
974
1027
  # Generate angles
975
1028
  end_angle = start_angle + math.pi
1029
+ _check_n_views(n_views)
976
1030
  step = (end_angle - start_angle) / n_views
977
1031
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
978
1032
 
@@ -1055,6 +1109,7 @@ def custom_trajectory_2d_parallel(n_views,
1055
1109
  """
1056
1110
  # Generate angles
1057
1111
  end_angle = start_angle + math.pi
1112
+ _check_n_views(n_views)
1058
1113
  step = (end_angle - start_angle) / n_views
1059
1114
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
1060
1115
 
@@ -1069,6 +1124,9 @@ def custom_trajectory_2d_parallel(n_views,
1069
1124
  raise ValueError(f"det_origin_fn must return tensor of shape ({n_views}, 2), "
1070
1125
  f"got {det_origin.shape}")
1071
1126
 
1127
+ # Reject degenerate directions before normalization turns them into NaN
1128
+ _check_ray_directions(ray_dir)
1129
+
1072
1130
  # Normalize ray directions
1073
1131
  ray_dir = ray_dir / torch.norm(ray_dir, dim=1, keepdim=True)
1074
1132