diffct 2.4.4__tar.gz → 2.5.0__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 (185) hide show
  1. {diffct-2.4.4 → diffct-2.5.0}/CHANGELOG.md +27 -1
  2. {diffct-2.4.4 → diffct-2.5.0}/CITATION.cff +5 -4
  3. {diffct-2.4.4 → diffct-2.5.0}/PKG-INFO +7 -6
  4. {diffct-2.4.4 → diffct-2.5.0}/README.md +5 -4
  5. {diffct-2.4.4 → diffct-2.5.0}/diffct/__init__.py +1 -1
  6. {diffct-2.4.4 → diffct-2.5.0}/diffct/_solver_state.py +34 -7
  7. {diffct-2.4.4 → diffct-2.5.0}/diffct/analytical_execution.py +2 -0
  8. {diffct-2.4.4 → diffct-2.5.0}/diffct/backends.py +40 -12
  9. {diffct-2.4.4 → diffct-2.5.0}/diffct/geometry.py +57 -5
  10. {diffct-2.4.4 → diffct-2.5.0}/diffct/operators.py +36 -18
  11. {diffct-2.4.4 → diffct-2.5.0}/diffct/reconstruction.py +15 -3
  12. {diffct-2.4.4 → diffct-2.5.0}/diffct/streaming.py +7 -0
  13. {diffct-2.4.4 → diffct-2.5.0}/diffct/surfaces.py +11 -7
  14. {diffct-2.4.4 → diffct-2.5.0}/diffct/utils.py +20 -0
  15. {diffct-2.4.4 → diffct-2.5.0}/formal/README.md +17 -0
  16. diffct-2.5.0/formal/tla/README.md +131 -0
  17. {diffct-2.4.4 → diffct-2.5.0}/pyproject.toml +2 -2
  18. diffct-2.5.0/tests/test_geometry_input_validation.py +179 -0
  19. diffct-2.5.0/tests/test_mps_backend.py +431 -0
  20. {diffct-2.4.4 → diffct-2.5.0}/.gitignore +0 -0
  21. {diffct-2.4.4 → diffct-2.5.0}/LICENSE +0 -0
  22. {diffct-2.4.4 → diffct-2.5.0}/NOTICE +0 -0
  23. {diffct-2.4.4 → diffct-2.5.0}/diffct/_solver_tv.py +0 -0
  24. {diffct-2.4.4 → diffct-2.5.0}/diffct/analytical.py +0 -0
  25. {diffct-2.4.4 → diffct-2.5.0}/diffct/chunking.py +0 -0
  26. {diffct-2.4.4 → diffct-2.5.0}/diffct/constants.py +0 -0
  27. {diffct-2.4.4 → diffct-2.5.0}/diffct/differentiable.py +0 -0
  28. {diffct-2.4.4 → diffct-2.5.0}/diffct/distribution.py +0 -0
  29. {diffct-2.4.4 → diffct-2.5.0}/diffct/kernels/__init__.py +0 -0
  30. {diffct-2.4.4 → diffct-2.5.0}/diffct/kernels/analytical_transpose.py +0 -0
  31. {diffct-2.4.4 → diffct-2.5.0}/diffct/kernels/cone_beam.py +0 -0
  32. {diffct-2.4.4 → diffct-2.5.0}/diffct/kernels/fan_beam.py +0 -0
  33. {diffct-2.4.4 → diffct-2.5.0}/diffct/kernels/geometry_vjp.py +0 -0
  34. {diffct-2.4.4 → diffct-2.5.0}/diffct/kernels/parallel_beam.py +0 -0
  35. {diffct-2.4.4 → diffct-2.5.0}/diffct/kernels/spacing.py +0 -0
  36. {diffct-2.4.4 → diffct-2.5.0}/diffct/kernels/surface_geometry.py +0 -0
  37. {diffct-2.4.4 → diffct-2.5.0}/diffct/pipeline.py +0 -0
  38. {diffct-2.4.4 → diffct-2.5.0}/diffct/projectors.py +0 -0
  39. {diffct-2.4.4 → diffct-2.5.0}/diffct/solvers.py +0 -0
  40. {diffct-2.4.4 → diffct-2.5.0}/diffct/storage.py +0 -0
  41. {diffct-2.4.4 → diffct-2.5.0}/diffct/surface_gradients.py +0 -0
  42. {diffct-2.4.4 → diffct-2.5.0}/docs/assets/gallery/NOTICE +0 -0
  43. {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/.gitignore +0 -0
  44. {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/fixtures/make_tiny_model.py +0 -0
  45. {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/fixtures/tiny-model/config.json +0 -0
  46. {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/fixtures/tiny-model/onnx/model_quantized.onnx +0 -0
  47. {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/fixtures/tiny-model/onnx/model_quantized.onnx_data +0 -0
  48. {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/fixtures/tiny-model/tokenizer.json +0 -0
  49. {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/fixtures/tiny-model/tokenizer_config.json +0 -0
  50. {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/full-model.spec.mjs +0 -0
  51. {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/harness.mjs +0 -0
  52. {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/item1-ordering.spec.mjs +0 -0
  53. {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/item2-single-load.spec.mjs +0 -0
  54. {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/item3-cancel.spec.mjs +0 -0
  55. {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/item4-leaks.spec.mjs +0 -0
  56. {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/item5-errors.spec.mjs +0 -0
  57. {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/item6-a11y.spec.mjs +0 -0
  58. {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/item7-security.spec.mjs +0 -0
  59. {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/playwright.config.mjs +0 -0
  60. {diffct-2.4.4 → diffct-2.5.0}/docs/video/README.md +0 -0
  61. {diffct-2.4.4 → diffct-2.5.0}/examples/README.md +0 -0
  62. {diffct-2.4.4 → diffct-2.5.0}/examples/_block_cgls.py +0 -0
  63. {diffct-2.4.4 → diffct-2.5.0}/examples/_common.py +0 -0
  64. {diffct-2.4.4 → diffct-2.5.0}/examples/data/NOTICE +0 -0
  65. {diffct-2.4.4 → diffct-2.5.0}/examples/data/preprocess_walnut.py +0 -0
  66. {diffct-2.4.4 → diffct-2.5.0}/examples/walnut_reconstruction.py +0 -0
  67. {diffct-2.4.4 → diffct-2.5.0}/pytest.ini +0 -0
  68. {diffct-2.4.4 → diffct-2.5.0}/tests/__init__.py +0 -0
  69. {diffct-2.4.4 → diffct-2.5.0}/tests/_multiprocessing.py +0 -0
  70. {diffct-2.4.4 → diffct-2.5.0}/tests/_native_per_axis_spacing.py +0 -0
  71. {diffct-2.4.4 → diffct-2.5.0}/tests/_phantoms.py +0 -0
  72. {diffct-2.4.4 → diffct-2.5.0}/tests/benchmarks/README.md +0 -0
  73. {diffct-2.4.4 → diffct-2.5.0}/tests/benchmarks/__init__.py +0 -0
  74. {diffct-2.4.4 → diffct-2.5.0}/tests/benchmarks/_common.py +0 -0
  75. {diffct-2.4.4 → diffct-2.5.0}/tests/benchmarks/test_bench_cone.py +0 -0
  76. {diffct-2.4.4 → diffct-2.5.0}/tests/benchmarks/test_bench_fan.py +0 -0
  77. {diffct-2.4.4 → diffct-2.5.0}/tests/benchmarks/test_bench_parallel.py +0 -0
  78. {diffct-2.4.4 → diffct-2.5.0}/tests/distributed_projector_check.py +0 -0
  79. {diffct-2.4.4 → diffct-2.5.0}/tests/fixtures/native_scalar_spacing_main.npz +0 -0
  80. {diffct-2.4.4 → diffct-2.5.0}/tests/test_adjoint_inner_product.py +0 -0
  81. {diffct-2.4.4 → diffct-2.5.0}/tests/test_allocator_capacity.py +0 -0
  82. {diffct-2.4.4 → diffct-2.5.0}/tests/test_analytical_autograd.py +0 -0
  83. {diffct-2.4.4 → diffct-2.5.0}/tests/test_analytical_distribution.py +0 -0
  84. {diffct-2.4.4 → diffct-2.5.0}/tests/test_analytical_regressions.py +0 -0
  85. {diffct-2.4.4 → diffct-2.5.0}/tests/test_analytical_weight_distribution.py +0 -0
  86. {diffct-2.4.4 → diffct-2.5.0}/tests/test_angular_low_precision.py +0 -0
  87. {diffct-2.4.4 → diffct-2.5.0}/tests/test_axis_aligned_chords.py +0 -0
  88. {diffct-2.4.4 → diffct-2.5.0}/tests/test_batch_execution_edges.py +0 -0
  89. {diffct-2.4.4 → diffct-2.5.0}/tests/test_batched_surfaces.py +0 -0
  90. {diffct-2.4.4 → diffct-2.5.0}/tests/test_block_cgls.py +0 -0
  91. {diffct-2.4.4 → diffct-2.5.0}/tests/test_block_storage.py +0 -0
  92. {diffct-2.4.4 → diffct-2.5.0}/tests/test_call_trajectory.py +0 -0
  93. {diffct-2.4.4 → diffct-2.5.0}/tests/test_cgls_state_admission.py +0 -0
  94. {diffct-2.4.4 → diffct-2.5.0}/tests/test_checkpoint_metadata_validation.py +0 -0
  95. {diffct-2.4.4 → diffct-2.5.0}/tests/test_chunked_projector.py +0 -0
  96. {diffct-2.4.4 → diffct-2.5.0}/tests/test_completed_transfer_reuse.py +0 -0
  97. {diffct-2.4.4 → diffct-2.5.0}/tests/test_cone_backprojection_transpose.py +0 -0
  98. {diffct-2.4.4 → diffct-2.5.0}/tests/test_cone_projector_autograd.py +0 -0
  99. {diffct-2.4.4 → diffct-2.5.0}/tests/test_cpu_install.py +0 -0
  100. {diffct-2.4.4 → diffct-2.5.0}/tests/test_cuda_exit_deallocations.py +0 -0
  101. {diffct-2.4.4 → diffct-2.5.0}/tests/test_cuda_sink_lifetimes.py +0 -0
  102. {diffct-2.4.4 → diffct-2.5.0}/tests/test_cuda_smoke.py +0 -0
  103. {diffct-2.4.4 → diffct-2.5.0}/tests/test_detector_surface_dtypes.py +0 -0
  104. {diffct-2.4.4 → diffct-2.5.0}/tests/test_detector_surface_mapping.py +0 -0
  105. {diffct-2.4.4 → diffct-2.5.0}/tests/test_detector_surface_units.py +0 -0
  106. {diffct-2.4.4 → diffct-2.5.0}/tests/test_detector_surfaces.py +0 -0
  107. {diffct-2.4.4 → diffct-2.5.0}/tests/test_distributed_training_transforms.py +0 -0
  108. {diffct-2.4.4 → diffct-2.5.0}/tests/test_example_psnr.py +0 -0
  109. {diffct-2.4.4 → diffct-2.5.0}/tests/test_execution_buffers.py +0 -0
  110. {diffct-2.4.4 → diffct-2.5.0}/tests/test_execution_features.py +0 -0
  111. {diffct-2.4.4 → diffct-2.5.0}/tests/test_execution_schedules.py +0 -0
  112. {diffct-2.4.4 → diffct-2.5.0}/tests/test_fbp_fan_accuracy.py +0 -0
  113. {diffct-2.4.4 → diffct-2.5.0}/tests/test_fbp_fan_offsets.py +0 -0
  114. {diffct-2.4.4 → diffct-2.5.0}/tests/test_fbp_parallel_accuracy.py +0 -0
  115. {diffct-2.4.4 → diffct-2.5.0}/tests/test_fdk_cone_accuracy.py +0 -0
  116. {diffct-2.4.4 → diffct-2.5.0}/tests/test_fdk_cone_offsets.py +0 -0
  117. {diffct-2.4.4 → diffct-2.5.0}/tests/test_geometry_autograd.py +0 -0
  118. {diffct-2.4.4 → diffct-2.5.0}/tests/test_geometry_conversions.py +0 -0
  119. {diffct-2.4.4 → diffct-2.5.0}/tests/test_geometry_gradients.py +0 -0
  120. {diffct-2.4.4 → diffct-2.5.0}/tests/test_gpu_performance_contracts.py +0 -0
  121. {diffct-2.4.4 → diffct-2.5.0}/tests/test_gradcheck.py +0 -0
  122. {diffct-2.4.4 → diffct-2.5.0}/tests/test_lazy_native_observers.py +0 -0
  123. {diffct-2.4.4 → diffct-2.5.0}/tests/test_live_trajectory_validation.py +0 -0
  124. {diffct-2.4.4 → diffct-2.5.0}/tests/test_multi_gpu_exit.py +0 -0
  125. {diffct-2.4.4 → diffct-2.5.0}/tests/test_multiprocessing.py +0 -0
  126. {diffct-2.4.4 → diffct-2.5.0}/tests/test_native_endpoint_precision.py +0 -0
  127. {diffct-2.4.4 → diffct-2.5.0}/tests/test_native_per_axis_admission.py +0 -0
  128. {diffct-2.4.4 → diffct-2.5.0}/tests/test_native_per_axis_extremes.py +0 -0
  129. {diffct-2.4.4 → diffct-2.5.0}/tests/test_native_per_axis_helical.py +0 -0
  130. {diffct-2.4.4 → diffct-2.5.0}/tests/test_native_per_axis_spacing.py +0 -0
  131. {diffct-2.4.4 → diffct-2.5.0}/tests/test_native_per_axis_spacing_gpu.py +0 -0
  132. {diffct-2.4.4 → diffct-2.5.0}/tests/test_native_surface_transforms.py +0 -0
  133. {diffct-2.4.4 → diffct-2.5.0}/tests/test_native_volume_layout.py +0 -0
  134. {diffct-2.4.4 → diffct-2.5.0}/tests/test_native_volume_layout_distributed.py +0 -0
  135. {diffct-2.4.4 → diffct-2.5.0}/tests/test_output_pipeline_overlap.py +0 -0
  136. {diffct-2.4.4 → diffct-2.5.0}/tests/test_parallel3d.py +0 -0
  137. {diffct-2.4.4 → diffct-2.5.0}/tests/test_parallel3d_surface_oracle.py +0 -0
  138. {diffct-2.4.4 → diffct-2.5.0}/tests/test_partition_lifecycle.py +0 -0
  139. {diffct-2.4.4 → diffct-2.5.0}/tests/test_plan_memory_bounds.py +0 -0
  140. {diffct-2.4.4 → diffct-2.5.0}/tests/test_portable_bounded_autograd.py +0 -0
  141. {diffct-2.4.4 → diffct-2.5.0}/tests/test_portable_cuda_budget.py +0 -0
  142. {diffct-2.4.4 → diffct-2.5.0}/tests/test_portable_distributed_geometry.py +0 -0
  143. {diffct-2.4.4 → diffct-2.5.0}/tests/test_portable_endpoint_precision.py +0 -0
  144. {diffct-2.4.4 → diffct-2.5.0}/tests/test_portable_execution_bounds.py +0 -0
  145. {diffct-2.4.4 → diffct-2.5.0}/tests/test_portable_numeric_regressions.py +0 -0
  146. {diffct-2.4.4 → diffct-2.5.0}/tests/test_portable_sampler_snapshot.py +0 -0
  147. {diffct-2.4.4 → diffct-2.5.0}/tests/test_portable_surface_admission.py +0 -0
  148. {diffct-2.4.4 → diffct-2.5.0}/tests/test_pr42_review_regressions.py +0 -0
  149. {diffct-2.4.4 → diffct-2.5.0}/tests/test_projector_api.py +0 -0
  150. {diffct-2.4.4 → diffct-2.5.0}/tests/test_projector_degenerate_geometry.py +0 -0
  151. {diffct-2.4.4 → diffct-2.5.0}/tests/test_projector_geometry_devices.py +0 -0
  152. {diffct-2.4.4 → diffct-2.5.0}/tests/test_ramp_filter_windows.py +0 -0
  153. {diffct-2.4.4 → diffct-2.5.0}/tests/test_reconstruction_api.py +0 -0
  154. {diffct-2.4.4 → diffct-2.5.0}/tests/test_reconstruction_work_reuse.py +0 -0
  155. {diffct-2.4.4 → diffct-2.5.0}/tests/test_reference_backend.py +0 -0
  156. {diffct-2.4.4 → diffct-2.5.0}/tests/test_release_2_0_1.py +0 -0
  157. {diffct-2.4.4 → diffct-2.5.0}/tests/test_review_230.py +0 -0
  158. {diffct-2.4.4 → diffct-2.5.0}/tests/test_review_regressions.py +0 -0
  159. {diffct-2.4.4 → diffct-2.5.0}/tests/test_solver_admission_regressions.py +0 -0
  160. {diffct-2.4.4 → diffct-2.5.0}/tests/test_solver_api.py +0 -0
  161. {diffct-2.4.4 → diffct-2.5.0}/tests/test_solver_checkpoint_edges.py +0 -0
  162. {diffct-2.4.4 → diffct-2.5.0}/tests/test_solver_collective_failures.py +0 -0
  163. {diffct-2.4.4 → diffct-2.5.0}/tests/test_solver_device_scalars.py +0 -0
  164. {diffct-2.4.4 → diffct-2.5.0}/tests/test_spatial_partition.py +0 -0
  165. {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_collective_failures.py +0 -0
  166. {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_composition_kernel.py +0 -0
  167. {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_compute_shards.py +0 -0
  168. {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_geometry_cudasim.py +0 -0
  169. {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_gpu_execution.py +0 -0
  170. {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_gradient_replay.py +0 -0
  171. {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_host_sampling_budget.py +0 -0
  172. {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_native_regressions.py +0 -0
  173. {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_parameter_updates.py +0 -0
  174. {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_pipeline_contracts.py +0 -0
  175. {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_saved_snapshot_budget.py +0 -0
  176. {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_snapshot_admission.py +0 -0
  177. {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_trajectory_transform.py +0 -0
  178. {diffct-2.4.4 → diffct-2.5.0}/tests/test_training_transforms.py +0 -0
  179. {diffct-2.4.4 → diffct-2.5.0}/tests/test_trajectory_admission_cost.py +0 -0
  180. {diffct-2.4.4 → diffct-2.5.0}/tests/test_trajectory_surface_integration.py +0 -0
  181. {diffct-2.4.4 → diffct-2.5.0}/tests/test_transfer_pipeline.py +0 -0
  182. {diffct-2.4.4 → diffct-2.5.0}/tests/test_transfer_retirement_lifetimes.py +0 -0
  183. {diffct-2.4.4 → diffct-2.5.0}/tests/test_tv_zero_dependency.py +0 -0
  184. {diffct-2.4.4 → diffct-2.5.0}/tests/test_two_far_endpoints.py +0 -0
  185. {diffct-2.4.4 → diffct-2.5.0}/tests/test_weights.py +0 -0
@@ -5,7 +5,33 @@ 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.0] - 2026-10-26
9
+
10
+ ### Added
11
+
12
+ - Apple Silicon GPU (MPS) support for the portable PyTorch backend, including
13
+ the solvers, detector surfaces and streamed stores. MPS has no FP64, so ray
14
+ clipping and entry setup run in FP32 there; `precision="float64"` raises a
15
+ `ValueError` for MPS data, outputs or trajectories. See `docs/EXECUTION.md`
16
+ for the FP32 error bound. Thanks to @cslht11
17
+ ([#57](https://github.com/sypsyp97/diffct/pull/57)).
18
+ - TLA+ protocol models with TLC/Apalache checks and execution trace validation
19
+ for memory budgets, native admission and distributed collectives, run in a
20
+ separate CI workflow. They live in `formal/` and do not change library
21
+ behavior ([#55](https://github.com/sypsyp97/diffct/pull/55)).
22
+
23
+ ### Fixed
24
+
25
+ - 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)).
26
+
27
+ ### Documentation
28
+
29
+ - 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)).
30
+ - Every documentation page uses the same three-column layout with the site navigation on the left ([#58](https://github.com/sypsyp97/diffct/pull/58)).
31
+
32
+ ### Changed
33
+
34
+ - 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
35
 
10
36
  ## [2.4.4] - 2026-10-25
11
37
 
@@ -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.0
18
+ date-released: "2026-10-26"
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.0
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.0'
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):
@@ -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
 
@@ -286,6 +325,8 @@ def random_trajectory_3d(n_views, sid_mean, sdd_mean, sid_std=0.0, pos_std=0.0,
286
325
  det_u_vec = det_u_vec / torch.norm(det_u_vec, dim=1, keepdim=True)
287
326
  det_v_vec = det_v_vec / torch.norm(det_v_vec, dim=1, keepdim=True)
288
327
 
328
+ _check_source_positions(src_pos, "random_trajectory_3d")
329
+
289
330
  return src_pos, det_center, det_u_vec, det_v_vec
290
331
 
291
332
 
@@ -334,6 +375,7 @@ def spiral_trajectory_3d(n_views, sid, sdd, z_range=100.0, n_turns=2.0,
334
375
  """
335
376
  # Generate angles for spiral motion
336
377
  end_angle = start_angle + 2 * math.pi * n_turns
378
+ _check_n_views(n_views)
337
379
  step = (end_angle - start_angle) / n_views
338
380
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
339
381
 
@@ -419,6 +461,7 @@ def sinusoidal_trajectory_3d(n_views, sid, sdd, amplitude=50.0, frequency=2.0,
419
461
  """
420
462
  # Generate angles
421
463
  end_angle = start_angle + 2 * math.pi
464
+ _check_n_views(n_views)
422
465
  step = (end_angle - start_angle) / n_views
423
466
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
424
467
 
@@ -505,6 +548,7 @@ def saddle_trajectory_3d(n_views, sid, sdd, z_amplitude=50.0, radial_amplitude=3
505
548
  """
506
549
  # Generate angles
507
550
  end_angle = start_angle + 2 * math.pi
551
+ _check_n_views(n_views)
508
552
  step = (end_angle - start_angle) / n_views
509
553
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
510
554
 
@@ -601,6 +645,7 @@ def custom_trajectory_3d(n_views, sid, sdd,
601
645
  """
602
646
  # Generate angles
603
647
  end_angle = start_angle + 2 * math.pi
648
+ _check_n_views(n_views)
604
649
  step = (end_angle - start_angle) / n_views
605
650
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
606
651
 
@@ -746,6 +791,7 @@ def sinusoidal_trajectory_2d_fan(n_views, sid, sdd, amplitude=50.0, frequency=2.
746
791
  """
747
792
  # Generate angles
748
793
  end_angle = start_angle + 2 * math.pi
794
+ _check_n_views(n_views)
749
795
  step = (end_angle - start_angle) / n_views
750
796
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
751
797
 
@@ -829,6 +875,7 @@ def custom_trajectory_2d_fan(n_views, sid, sdd,
829
875
  """
830
876
  # Generate angles
831
877
  end_angle = start_angle + 2 * math.pi
878
+ _check_n_views(n_views)
832
879
  step = (end_angle - start_angle) / n_views
833
880
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
834
881
 
@@ -973,6 +1020,7 @@ def sinusoidal_trajectory_2d_parallel(n_views, amplitude=50.0, frequency=2.0,
973
1020
  """
974
1021
  # Generate angles
975
1022
  end_angle = start_angle + math.pi
1023
+ _check_n_views(n_views)
976
1024
  step = (end_angle - start_angle) / n_views
977
1025
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
978
1026
 
@@ -1055,6 +1103,7 @@ def custom_trajectory_2d_parallel(n_views,
1055
1103
  """
1056
1104
  # Generate angles
1057
1105
  end_angle = start_angle + math.pi
1106
+ _check_n_views(n_views)
1058
1107
  step = (end_angle - start_angle) / n_views
1059
1108
  angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
1060
1109
 
@@ -1069,6 +1118,9 @@ def custom_trajectory_2d_parallel(n_views,
1069
1118
  raise ValueError(f"det_origin_fn must return tensor of shape ({n_views}, 2), "
1070
1119
  f"got {det_origin.shape}")
1071
1120
 
1121
+ # Reject degenerate directions before normalization turns them into NaN
1122
+ _check_ray_directions(ray_dir)
1123
+
1072
1124
  # Normalize ray directions
1073
1125
  ray_dir = ray_dir / torch.norm(ray_dir, dim=1, keepdim=True)
1074
1126
 
@@ -9,6 +9,7 @@ import warnings
9
9
 
10
10
  import torch
11
11
 
12
+ from .constants import _native_spacing
12
13
  from .chunking import (_ChunkPlan, _allocation_bytes, _host_geometry_bytes, _largest_buffer_bytes, _memory_budget,
13
14
  _memory_lower_bounds, _pipeline_buffers, _pipeline_bytes, _pixel_tiles,
14
15
  _reservation_budget, _reservation_bytes, _tiles, _working_set_bytes)
@@ -17,11 +18,10 @@ from .storage import TensorStore
17
18
  from .streaming import _validate_stores
18
19
  from .surfaces import ParameterizedSurface, _GeometrySpec, _sample_geometry, _sample_offsets
19
20
  from .surface_gradients import surface_batch_vjp
20
- from .utils import TorchCUDABridge, _get_numba_external_stream_for, _raise_geometry_errors
21
+ from .utils import TorchCUDABridge, _cast_work, _get_numba_external_stream_for, _raise_geometry_errors, _work_dtype
21
22
  from .backends import (_GEOMETRY_FORWARD_DERIVATIVE, ReferenceProjector, _flat_admission_batches, _forward_tangent,
22
23
  select_backend, voxel_spacing_tuple)
23
24
  from .distribution import Partition
24
- from .constants import _native_spacing
25
25
  from .projectors import (
26
26
  _backproject_into,
27
27
  _geometry_vjp,
@@ -126,28 +126,36 @@ def _validate_trajectory(trajectory, beam, *, detector_surface=False, clone=True
126
126
  dots = torch.sum(first.to(device) * second.to(device), dim=1)
127
127
  checks.append((torch.any(torch.abs(dots) > 1e-4), "trajectory direction axes must be orthogonal"))
128
128
  if beam == "parallel3d":
129
- direction = geometry[0].to(device=device, dtype=torch.float64)
130
- frame = torch.stack((direction, geometry[2].to(direction.device, torch.float64),
131
- geometry[3].to(direction.device, torch.float64)), dim=-1)
129
+ direction = _cast_work(geometry[0], device)
130
+ frame = torch.stack((direction, _cast_work(geometry[2], direction.device),
131
+ _cast_work(geometry[3], direction.device)), dim=-1)
132
132
  # Numerical rank rejects a tangent frame at machine roundoff without
133
133
  # imposing an application-level minimum angle of incidence.
134
134
  # Bad entries already have a finite-value error above; keep the rank
135
135
  # solver well-defined until the combined error flags reach the host.
136
136
  frame = torch.where(torch.isfinite(frame), frame, 0.)
137
- checks.append((torch.any(torch.linalg.matrix_rank(frame) < 3),
138
- "parallel3d rays must not be tangent to the detector plane"))
137
+ if frame.dtype == torch.float64:
138
+ checks.append((torch.any(torch.linalg.matrix_rank(frame) < 3),
139
+ "parallel3d rays must not be tangent to the detector plane"))
140
+ else:
141
+ # |det| via the triple product: matrix_rank has no MPS kernel. Its
142
+ # float32 threshold sits far above the FP64 rank tolerance, so it
143
+ # rejects a wider band of near-tangent frames than the FP64 check.
144
+ determinant = torch.sum(frame[..., 0] * torch.linalg.cross(frame[..., 1], frame[..., 2], dim=-1), dim=-1)
145
+ scale = torch.linalg.vector_norm(frame, dim=-2).prod(dim=-1)
146
+ checks.append((torch.any(determinant.abs() <= torch.finfo(determinant.dtype).eps * scale),
147
+ "parallel3d rays must not be tangent to the detector plane"))
139
148
  if beam in ("fan", "cone") and not detector_surface:
140
- # float64 avoids overflow of half-precision input and dtype mismatches in cross().
141
- source = geometry[0].to(device=device, dtype=torch.float64)
142
- principal = geometry[1].to(source.device, torch.float64) - source
149
+ source = _cast_work(geometry[0], device)
150
+ principal = _cast_work(geometry[1], source.device) - source
143
151
  lengths = torch.linalg.vector_norm(principal, dim=1)
144
152
  checks.append((torch.any(lengths == 0), "source and detector center must differ in every view"))
145
153
  principal = principal / lengths[:, None]
146
- det_u = geometry[2].to(source.device, torch.float64)
154
+ det_u = _cast_work(geometry[2], source.device)
147
155
  if beam == "fan":
148
156
  facing = principal[:, 0] * det_u[:, 1] - principal[:, 1] * det_u[:, 0]
149
157
  else:
150
- normal = torch.linalg.cross(det_u, geometry[3].to(source.device, torch.float64), dim=1)
158
+ normal = torch.linalg.cross(det_u, _cast_work(geometry[3], source.device), dim=1)
151
159
  facing = torch.sum(principal * normal, dim=1)
152
160
  checks.append((torch.any(torch.abs(facing) < 1e-4),
153
161
  "the detector must not be edge-on to the source in any view"))
@@ -442,6 +450,7 @@ class Projector:
442
450
  self.native_volume_layout = bool(native_volume_layout)
443
451
  self.precision = precision
444
452
  self.dtype = torch.float64 if precision == "float64" else torch.float32
453
+ self._validate_precision(trajectory)
445
454
  self._reference = None
446
455
  self.partition = partition
447
456
  if not isinstance(partition, str) or partition not in ("views", "space", "hybrid"):
@@ -571,6 +580,12 @@ class Projector:
571
580
  UserWarning, stacklevel=2,
572
581
  )
573
582
 
583
+ def _validate_precision(self, values):
584
+ if self.dtype == torch.float64 and any(
585
+ torch.device(getattr(value, "device", "cpu")).type == "mps"
586
+ for value in values):
587
+ raise ValueError("precision must be 'fast' or 'float32' on MPS; MPS provides no float64")
588
+
574
589
  def _validate_geometry_range(self):
575
590
  if self.dtype == torch.float32 and self.beam in ("fan", "cone") and self.detector_surface is None:
576
591
  # Both FP32 backends set up each segment from the endpoint nearer
@@ -578,10 +593,11 @@ class Projector:
578
593
  spacing = voxel_spacing_tuple(self.voxel_spacing, len(self.global_volume_shape))
579
594
  device = next((value.device for value in self._trajectory[:2] if value.is_cuda),
580
595
  self._trajectory[0].device)
581
- pitches = torch.tensor(spacing[::-1], device=device, dtype=torch.float64)
596
+ work = _work_dtype(device)
597
+ pitches = torch.tensor(spacing[::-1], device=device, dtype=work)
582
598
  distances = torch.stack([
583
599
  torch.linalg.vector_norm(
584
- component.to(device=device, dtype=torch.float64) / pitches,
600
+ component.to(device=device, dtype=work) / pitches,
585
601
  dim=1,
586
602
  )
587
603
  for component in self._trajectory[:2]
@@ -2602,8 +2618,10 @@ class Projector:
2602
2618
  override = trajectory is not None
2603
2619
  if trajectory is None and self._learnable:
2604
2620
  trajectory = self._learnable
2605
- if trajectory is not None:
2606
- try:
2621
+ try:
2622
+ self._validate_precision(args)
2623
+ if trajectory is not None:
2624
+ self._validate_precision(trajectory)
2607
2625
  _reject_trajectory_tangents(trajectory)
2608
2626
  frames, views = _validate_trajectory(
2609
2627
  trajectory, self.beam, detector_surface=self.detector_surface is not None,
@@ -2686,8 +2704,8 @@ class Projector:
2686
2704
  (coincident, "source and detector surface pixels must differ"),
2687
2705
  ])
2688
2706
  del staged
2689
- except (TypeError, ValueError, RuntimeError) as failure:
2690
- error = failure
2707
+ except (TypeError, ValueError, RuntimeError) as failure:
2708
+ error = failure
2691
2709
  # None must participate too: mixed override/default calls or gradient
2692
2710
  # layouts cannot skip a collective on only one rank.
2693
2711
  # Metadata transport needs ordinary storage-backed tensors, even when