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.
- {diffct-2.4.4 → diffct-2.5.0}/CHANGELOG.md +27 -1
- {diffct-2.4.4 → diffct-2.5.0}/CITATION.cff +5 -4
- {diffct-2.4.4 → diffct-2.5.0}/PKG-INFO +7 -6
- {diffct-2.4.4 → diffct-2.5.0}/README.md +5 -4
- {diffct-2.4.4 → diffct-2.5.0}/diffct/__init__.py +1 -1
- {diffct-2.4.4 → diffct-2.5.0}/diffct/_solver_state.py +34 -7
- {diffct-2.4.4 → diffct-2.5.0}/diffct/analytical_execution.py +2 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/backends.py +40 -12
- {diffct-2.4.4 → diffct-2.5.0}/diffct/geometry.py +57 -5
- {diffct-2.4.4 → diffct-2.5.0}/diffct/operators.py +36 -18
- {diffct-2.4.4 → diffct-2.5.0}/diffct/reconstruction.py +15 -3
- {diffct-2.4.4 → diffct-2.5.0}/diffct/streaming.py +7 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/surfaces.py +11 -7
- {diffct-2.4.4 → diffct-2.5.0}/diffct/utils.py +20 -0
- {diffct-2.4.4 → diffct-2.5.0}/formal/README.md +17 -0
- diffct-2.5.0/formal/tla/README.md +131 -0
- {diffct-2.4.4 → diffct-2.5.0}/pyproject.toml +2 -2
- diffct-2.5.0/tests/test_geometry_input_validation.py +179 -0
- diffct-2.5.0/tests/test_mps_backend.py +431 -0
- {diffct-2.4.4 → diffct-2.5.0}/.gitignore +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/LICENSE +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/NOTICE +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/_solver_tv.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/analytical.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/chunking.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/constants.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/differentiable.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/distribution.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/kernels/__init__.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/kernels/analytical_transpose.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/kernels/cone_beam.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/kernels/fan_beam.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/kernels/geometry_vjp.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/kernels/parallel_beam.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/kernels/spacing.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/kernels/surface_geometry.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/pipeline.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/projectors.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/solvers.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/storage.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/diffct/surface_gradients.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/docs/assets/gallery/NOTICE +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/.gitignore +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/fixtures/make_tiny_model.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/fixtures/tiny-model/config.json +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/fixtures/tiny-model/onnx/model_quantized.onnx +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/fixtures/tiny-model/onnx/model_quantized.onnx_data +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/fixtures/tiny-model/tokenizer.json +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/fixtures/tiny-model/tokenizer_config.json +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/full-model.spec.mjs +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/harness.mjs +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/item1-ordering.spec.mjs +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/item2-single-load.spec.mjs +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/item3-cancel.spec.mjs +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/item4-leaks.spec.mjs +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/item5-errors.spec.mjs +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/item6-a11y.spec.mjs +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/item7-security.spec.mjs +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/docs/search/tests/playwright.config.mjs +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/docs/video/README.md +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/examples/README.md +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/examples/_block_cgls.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/examples/_common.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/examples/data/NOTICE +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/examples/data/preprocess_walnut.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/examples/walnut_reconstruction.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/pytest.ini +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/__init__.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/_multiprocessing.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/_native_per_axis_spacing.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/_phantoms.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/benchmarks/README.md +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/benchmarks/__init__.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/benchmarks/_common.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/benchmarks/test_bench_cone.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/benchmarks/test_bench_fan.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/benchmarks/test_bench_parallel.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/distributed_projector_check.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/fixtures/native_scalar_spacing_main.npz +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_adjoint_inner_product.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_allocator_capacity.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_analytical_autograd.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_analytical_distribution.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_analytical_regressions.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_analytical_weight_distribution.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_angular_low_precision.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_axis_aligned_chords.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_batch_execution_edges.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_batched_surfaces.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_block_cgls.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_block_storage.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_call_trajectory.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_cgls_state_admission.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_checkpoint_metadata_validation.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_chunked_projector.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_completed_transfer_reuse.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_cone_backprojection_transpose.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_cone_projector_autograd.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_cpu_install.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_cuda_exit_deallocations.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_cuda_sink_lifetimes.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_cuda_smoke.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_detector_surface_dtypes.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_detector_surface_mapping.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_detector_surface_units.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_detector_surfaces.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_distributed_training_transforms.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_example_psnr.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_execution_buffers.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_execution_features.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_execution_schedules.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_fbp_fan_accuracy.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_fbp_fan_offsets.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_fbp_parallel_accuracy.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_fdk_cone_accuracy.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_fdk_cone_offsets.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_geometry_autograd.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_geometry_conversions.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_geometry_gradients.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_gpu_performance_contracts.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_gradcheck.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_lazy_native_observers.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_live_trajectory_validation.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_multi_gpu_exit.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_multiprocessing.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_native_endpoint_precision.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_native_per_axis_admission.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_native_per_axis_extremes.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_native_per_axis_helical.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_native_per_axis_spacing.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_native_per_axis_spacing_gpu.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_native_surface_transforms.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_native_volume_layout.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_native_volume_layout_distributed.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_output_pipeline_overlap.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_parallel3d.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_parallel3d_surface_oracle.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_partition_lifecycle.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_plan_memory_bounds.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_portable_bounded_autograd.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_portable_cuda_budget.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_portable_distributed_geometry.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_portable_endpoint_precision.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_portable_execution_bounds.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_portable_numeric_regressions.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_portable_sampler_snapshot.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_portable_surface_admission.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_pr42_review_regressions.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_projector_api.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_projector_degenerate_geometry.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_projector_geometry_devices.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_ramp_filter_windows.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_reconstruction_api.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_reconstruction_work_reuse.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_reference_backend.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_release_2_0_1.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_review_230.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_review_regressions.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_solver_admission_regressions.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_solver_api.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_solver_checkpoint_edges.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_solver_collective_failures.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_solver_device_scalars.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_spatial_partition.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_collective_failures.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_composition_kernel.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_compute_shards.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_geometry_cudasim.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_gpu_execution.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_gradient_replay.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_host_sampling_budget.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_native_regressions.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_parameter_updates.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_pipeline_contracts.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_saved_snapshot_budget.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_snapshot_admission.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_surface_trajectory_transform.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_training_transforms.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_trajectory_admission_cost.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_trajectory_surface_integration.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_transfer_pipeline.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_transfer_retirement_lifetimes.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_tv_zero_dependency.py +0 -0
- {diffct-2.4.4 → diffct-2.5.0}/tests/test_two_far_endpoints.py +0 -0
- {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
|
-
## [
|
|
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
|
|
6
|
-
autograd
|
|
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.
|
|
17
|
-
date-released: "2026-10-
|
|
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.
|
|
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
|
|
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
|
-
>
|
|
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
|
|
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
|
|
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
|
|
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
|
-
>
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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=
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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=
|
|
768
|
-
directions = directions.to(dtype=
|
|
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
|
|
772
|
-
|
|
773
|
-
upper = origins
|
|
774
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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("
|
|
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
|
-
"
|
|
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]
|
|
130
|
-
frame = torch.stack((direction, geometry[2]
|
|
131
|
-
geometry[3]
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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]
|
|
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]
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
2606
|
-
|
|
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
|
-
|
|
2690
|
-
|
|
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
|