diffct 2.4.4__tar.gz → 2.5.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {diffct-2.4.4 → diffct-2.5.1}/CHANGELOG.md +55 -1
- {diffct-2.4.4 → diffct-2.5.1}/CITATION.cff +5 -4
- {diffct-2.4.4 → diffct-2.5.1}/PKG-INFO +7 -6
- {diffct-2.4.4 → diffct-2.5.1}/README.md +5 -4
- {diffct-2.4.4 → diffct-2.5.1}/diffct/__init__.py +1 -1
- {diffct-2.4.4 → diffct-2.5.1}/diffct/_solver_state.py +39 -7
- {diffct-2.4.4 → diffct-2.5.1}/diffct/analytical_execution.py +2 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/backends.py +40 -12
- {diffct-2.4.4 → diffct-2.5.1}/diffct/geometry.py +70 -12
- {diffct-2.4.4 → diffct-2.5.1}/diffct/operators.py +36 -18
- {diffct-2.4.4 → diffct-2.5.1}/diffct/reconstruction.py +15 -3
- {diffct-2.4.4 → diffct-2.5.1}/diffct/streaming.py +7 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/surfaces.py +11 -7
- {diffct-2.4.4 → diffct-2.5.1}/diffct/utils.py +20 -0
- {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/full-model.spec.mjs +2 -1
- {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/harness.mjs +20 -2
- {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/item1-ordering.spec.mjs +2 -0
- {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/item2-single-load.spec.mjs +8 -0
- {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/item3-cancel.spec.mjs +9 -4
- {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/item4-leaks.spec.mjs +2 -0
- {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/item5-errors.spec.mjs +2 -0
- {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/item6-a11y.spec.mjs +2 -0
- {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/item7-security.spec.mjs +2 -0
- diffct-2.5.1/docs/search/tests/semantic-opt-in.spec.mjs +88 -0
- {diffct-2.4.4 → diffct-2.5.1}/formal/README.md +17 -0
- diffct-2.5.1/formal/tla/README.md +131 -0
- {diffct-2.4.4 → diffct-2.5.1}/pyproject.toml +2 -2
- diffct-2.5.1/tests/test_geometry_input_validation.py +393 -0
- diffct-2.5.1/tests/test_mps_backend.py +431 -0
- diffct-2.5.1/tests/test_portable_accuracy.py +141 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_solver_checkpoint_edges.py +77 -0
- {diffct-2.4.4 → diffct-2.5.1}/.gitignore +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/LICENSE +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/NOTICE +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/_solver_tv.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/analytical.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/chunking.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/constants.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/differentiable.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/distribution.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/kernels/__init__.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/kernels/analytical_transpose.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/kernels/cone_beam.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/kernels/fan_beam.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/kernels/geometry_vjp.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/kernels/parallel_beam.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/kernels/spacing.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/kernels/surface_geometry.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/pipeline.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/projectors.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/solvers.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/storage.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/diffct/surface_gradients.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/docs/assets/gallery/NOTICE +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/.gitignore +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/fixtures/make_tiny_model.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/fixtures/tiny-model/config.json +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/fixtures/tiny-model/onnx/model_quantized.onnx +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/fixtures/tiny-model/onnx/model_quantized.onnx_data +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/fixtures/tiny-model/tokenizer.json +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/fixtures/tiny-model/tokenizer_config.json +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/docs/search/tests/playwright.config.mjs +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/docs/video/README.md +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/examples/README.md +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/examples/_block_cgls.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/examples/_common.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/examples/data/NOTICE +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/examples/data/preprocess_walnut.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/examples/walnut_reconstruction.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/pytest.ini +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/__init__.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/_multiprocessing.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/_native_per_axis_spacing.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/_phantoms.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/benchmarks/README.md +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/benchmarks/__init__.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/benchmarks/_common.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/benchmarks/test_bench_cone.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/benchmarks/test_bench_fan.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/benchmarks/test_bench_parallel.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/distributed_projector_check.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/fixtures/native_scalar_spacing_main.npz +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_adjoint_inner_product.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_allocator_capacity.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_analytical_autograd.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_analytical_distribution.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_analytical_regressions.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_analytical_weight_distribution.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_angular_low_precision.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_axis_aligned_chords.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_batch_execution_edges.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_batched_surfaces.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_block_cgls.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_block_storage.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_call_trajectory.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_cgls_state_admission.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_checkpoint_metadata_validation.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_chunked_projector.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_completed_transfer_reuse.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_cone_backprojection_transpose.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_cone_projector_autograd.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_cpu_install.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_cuda_exit_deallocations.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_cuda_sink_lifetimes.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_cuda_smoke.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_detector_surface_dtypes.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_detector_surface_mapping.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_detector_surface_units.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_detector_surfaces.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_distributed_training_transforms.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_example_psnr.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_execution_buffers.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_execution_features.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_execution_schedules.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_fbp_fan_accuracy.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_fbp_fan_offsets.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_fbp_parallel_accuracy.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_fdk_cone_accuracy.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_fdk_cone_offsets.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_geometry_autograd.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_geometry_conversions.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_geometry_gradients.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_gpu_performance_contracts.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_gradcheck.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_lazy_native_observers.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_live_trajectory_validation.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_multi_gpu_exit.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_multiprocessing.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_native_endpoint_precision.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_native_per_axis_admission.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_native_per_axis_extremes.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_native_per_axis_helical.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_native_per_axis_spacing.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_native_per_axis_spacing_gpu.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_native_surface_transforms.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_native_volume_layout.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_native_volume_layout_distributed.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_output_pipeline_overlap.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_parallel3d.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_parallel3d_surface_oracle.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_partition_lifecycle.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_plan_memory_bounds.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_portable_bounded_autograd.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_portable_cuda_budget.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_portable_distributed_geometry.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_portable_endpoint_precision.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_portable_execution_bounds.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_portable_numeric_regressions.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_portable_sampler_snapshot.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_portable_surface_admission.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_pr42_review_regressions.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_projector_api.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_projector_degenerate_geometry.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_projector_geometry_devices.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_ramp_filter_windows.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_reconstruction_api.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_reconstruction_work_reuse.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_reference_backend.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_release_2_0_1.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_review_230.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_review_regressions.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_solver_admission_regressions.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_solver_api.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_solver_collective_failures.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_solver_device_scalars.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_spatial_partition.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_collective_failures.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_composition_kernel.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_compute_shards.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_geometry_cudasim.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_gpu_execution.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_gradient_replay.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_host_sampling_budget.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_native_regressions.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_parameter_updates.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_pipeline_contracts.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_saved_snapshot_budget.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_snapshot_admission.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_surface_trajectory_transform.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_training_transforms.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_trajectory_admission_cost.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_trajectory_surface_integration.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_transfer_pipeline.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_transfer_retirement_lifetimes.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_tv_zero_dependency.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_two_far_endpoints.py +0 -0
- {diffct-2.4.4 → diffct-2.5.1}/tests/test_weights.py +0 -0
|
@@ -5,7 +5,61 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [2.5.1] - 2026-11-07
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Saving a solver checkpoint into a step that already exists raises a clear
|
|
13
|
+
`ValueError` that names the existing directory, instead of a bare
|
|
14
|
+
`FileExistsError`. The existing checkpoint is not changed. Use a new
|
|
15
|
+
`checkpoint_directory`, or pass `resume=` to continue from it. Thanks to
|
|
16
|
+
@cslht11 ([#63](https://github.com/sypsyp97/diffct/pull/63)).
|
|
17
|
+
- `random_trajectory_3d(seed=...)` draws its noise from a private generator
|
|
18
|
+
and no longer reseeds the process-wide PyTorch generator. Seeded
|
|
19
|
+
trajectories are unchanged. Thanks to @cslht11
|
|
20
|
+
([#64](https://github.com/sypsyp97/diffct/pull/64)).
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- CPU tests check the FBP, FDK and short-scan Parker accuracy bounds on the
|
|
25
|
+
portable backend, so CI covers them without a GPU. Thanks to @cslht11
|
|
26
|
+
([#65](https://github.com/sypsyp97/diffct/pull/65)).
|
|
27
|
+
- A `.mailmap` joins the commits of one contributor under one name
|
|
28
|
+
([#62](https://github.com/sypsyp97/diffct/pull/62)).
|
|
29
|
+
|
|
30
|
+
### Documentation
|
|
31
|
+
|
|
32
|
+
- Semantic search on the documentation site is off by default. The
|
|
33
|
+
embedding model (about 377 MB) downloads only after the visitor enables it
|
|
34
|
+
([#66](https://github.com/sypsyp97/diffct/pull/66)).
|
|
35
|
+
|
|
36
|
+
## [2.5.0] - 2026-10-26
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- Apple Silicon GPU (MPS) support for the portable PyTorch backend, including
|
|
41
|
+
the solvers, detector surfaces and streamed stores. MPS has no FP64, so ray
|
|
42
|
+
clipping and entry setup run in FP32 there; `precision="float64"` raises a
|
|
43
|
+
`ValueError` for MPS data, outputs or trajectories. See `docs/EXECUTION.md`
|
|
44
|
+
for the FP32 error bound. Thanks to @cslht11
|
|
45
|
+
([#57](https://github.com/sypsyp97/diffct/pull/57)).
|
|
46
|
+
- TLA+ protocol models with TLC/Apalache checks and execution trace validation
|
|
47
|
+
for memory budgets, native admission and distributed collectives, run in a
|
|
48
|
+
separate CI workflow. They live in `formal/` and do not change library
|
|
49
|
+
behavior ([#55](https://github.com/sypsyp97/diffct/pull/55)).
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- Trajectory constructors reject degenerate inputs instead of building NaN or empty geometry: zero or non-finite `ray_dir_fn` directions in `custom_trajectory_2d_parallel`, `random_trajectory_3d` sources at the isocenter, and view counts that are not positive integers (`TypeError` for floats and booleans, `ValueError` for counts below 1). Explicit angle tensors are still accepted. Thanks to @cslht11 ([#56](https://github.com/sypsyp97/diffct/pull/56)).
|
|
54
|
+
|
|
55
|
+
### Documentation
|
|
56
|
+
|
|
57
|
+
- README, documentation landing page, `llms.txt` summary, `CITATION.cff` and package keywords name the native CUDA kernels and the portable backend on CPU, ROCm and Apple Silicon. The landing quickstart has an Apple Silicon (MPS) tab ([#60](https://github.com/sypsyp97/diffct/pull/60)).
|
|
58
|
+
- Every documentation page uses the same three-column layout with the site navigation on the left ([#58](https://github.com/sypsyp97/diffct/pull/58)).
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- Continuous integration: pull requests that change only `docs/` skip the package, test and formal jobs; the documentation build still runs. Pushes to `main` and release tags run every job ([#59](https://github.com/sypsyp97/diffct/pull/59)).
|
|
9
63
|
|
|
10
64
|
## [2.4.4] - 2026-10-25
|
|
11
65
|
|
|
@@ -2,8 +2,9 @@ cff-version: 1.2.0
|
|
|
2
2
|
message: "If you use this software, please cite both the software (below) and the technical report listed under preferred-citation."
|
|
3
3
|
title: "diffct: Differentiable Computed Tomography Reconstruction with CUDA"
|
|
4
4
|
abstract: >-
|
|
5
|
-
Differentiable
|
|
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-
|
|
17
|
+
version: 2.5.1
|
|
18
|
+
date-released: "2026-11-07"
|
|
18
19
|
license: Apache-2.0
|
|
19
20
|
repository-code: "https://github.com/sypsyp97/diffct"
|
|
20
21
|
url: "https://sypsyp97.github.io/diffct/"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: diffct
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.1
|
|
4
4
|
Summary: Differentiable CT projection and reconstruction: precision modes, streaming, distributed execution and geometry gradients
|
|
5
5
|
Project-URL: Homepage, https://github.com/sypsyp97/diffct
|
|
6
6
|
Project-URL: Documentation, https://sypsyp97.github.io/diffct/
|
|
@@ -10,7 +10,7 @@ Author-email: Yipeng Sun <yipeng.sun@fau.de>
|
|
|
10
10
|
License-Expression: Apache-2.0
|
|
11
11
|
License-File: LICENSE
|
|
12
12
|
License-File: NOTICE
|
|
13
|
-
Keywords: CT reconstruction,CUDA,PyTorch,ROCm,computed tomography,differentiable programming
|
|
13
|
+
Keywords: Apple Silicon,CT reconstruction,CUDA,MPS,PyTorch,ROCm,computed tomography,differentiable programming
|
|
14
14
|
Classifier: Development Status :: 5 - Production/Stable
|
|
15
15
|
Classifier: Environment :: GPU :: NVIDIA CUDA
|
|
16
16
|
Classifier: Intended Audience :: Science/Research
|
|
@@ -34,7 +34,8 @@ Description-Content-Type: text/markdown
|
|
|
34
34
|
<p align="center">English · <a href="https://github.com/sypsyp97/diffct/blob/main/README.zh.md">简体中文</a></p>
|
|
35
35
|
|
|
36
36
|
<p align="center">
|
|
37
|
-
Differentiable
|
|
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):
|
|
@@ -261,6 +288,11 @@ def save_checkpoint(operator, directory, states, metadata, iteration, history, m
|
|
|
261
288
|
step = Path(directory).resolve() / f"step-{iteration:06d}"
|
|
262
289
|
step.mkdir(parents=True, exist_ok=True)
|
|
263
290
|
rank = step / f"rank-{getattr(operator, 'rank', 0):05d}"
|
|
291
|
+
if rank.exists():
|
|
292
|
+
# Replacing a published step in place could destroy it on a
|
|
293
|
+
# failed write, and ranks cannot roll back together.
|
|
294
|
+
raise ValueError(f"checkpoint {rank} already exists; use a new checkpoint_directory "
|
|
295
|
+
"or pass resume= to continue from it")
|
|
264
296
|
rank.mkdir()
|
|
265
297
|
for name, source in states.items():
|
|
266
298
|
source = TensorStore(source.detach()) if isinstance(source, torch.Tensor) else source
|
|
@@ -160,6 +160,8 @@ class AnalyticalExecution:
|
|
|
160
160
|
raise TypeError("sinogram store must have dtype float32 or float64")
|
|
161
161
|
self.source = source
|
|
162
162
|
self.output_device = torch.device(getattr(source, "device", "cpu"))
|
|
163
|
+
if self.dtype == torch.float64 and self.output_device.type == "mps":
|
|
164
|
+
raise ValueError("precision must be 'fast' or 'float32' on MPS; MPS provides no float64")
|
|
163
165
|
self.detector_shape = tuple(source.shape[1:])
|
|
164
166
|
for component in trajectory:
|
|
165
167
|
_fixed(component, "trajectory")
|
|
@@ -12,6 +12,7 @@ import torch.distributed as dist
|
|
|
12
12
|
from torch.autograd import forward_ad
|
|
13
13
|
|
|
14
14
|
from .chunking import _memory_lower_bounds
|
|
15
|
+
from .utils import _work_dtype
|
|
15
16
|
from .distribution import control_status
|
|
16
17
|
from .surfaces import ParameterizedSurface
|
|
17
18
|
from .constants import _native_spacing
|
|
@@ -76,6 +77,16 @@ def _zero_dependency(value):
|
|
|
76
77
|
return value.unsqueeze(0)[:0].sum()
|
|
77
78
|
|
|
78
79
|
|
|
80
|
+
def _constant_like(reference, values):
|
|
81
|
+
"""Build a constant tensor on ``reference``'s device and dtype.
|
|
82
|
+
|
|
83
|
+
``new_tensor`` has no kernel behind a functorch dual on MPS and raises
|
|
84
|
+
"DispatchKey Undefined doesn't correspond to a device"; an explicit
|
|
85
|
+
constructor keeps the same constant on every device and transform.
|
|
86
|
+
"""
|
|
87
|
+
return torch.tensor(values, dtype=reference.dtype, device=reference.device)
|
|
88
|
+
|
|
89
|
+
|
|
79
90
|
def voxel_spacing_tuple(spacing, rank):
|
|
80
91
|
"""Return positive physical pitches in tensor axis order."""
|
|
81
92
|
values = spacing if isinstance(spacing, (tuple, list)) else (spacing,) * rank
|
|
@@ -563,7 +574,14 @@ class ReferenceProjector:
|
|
|
563
574
|
with torch.no_grad():
|
|
564
575
|
# Reuse frame transfers across detector chunks. Offset batches
|
|
565
576
|
# remain bounded and move only when a ray batch needs them.
|
|
566
|
-
|
|
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
|
|
|
@@ -235,18 +274,24 @@ def random_trajectory_3d(n_views, sid_mean, sdd_mean, sid_std=0.0, pos_std=0.0,
|
|
|
235
274
|
"""
|
|
236
275
|
import math
|
|
237
276
|
|
|
238
|
-
if seed is not None:
|
|
239
|
-
torch.manual_seed(seed)
|
|
240
|
-
|
|
241
277
|
# Start with circular trajectory
|
|
242
278
|
src_pos, det_center, det_u_vec, det_v_vec = circular_trajectory_3d(
|
|
243
279
|
n_views, sid_mean, sdd_mean, device=device, dtype=dtype
|
|
244
280
|
)
|
|
245
281
|
|
|
282
|
+
# Angle tensors override the requested device. Keep seeded draws on that
|
|
283
|
+
# device without changing any process-wide RNG state.
|
|
284
|
+
generator = None
|
|
285
|
+
if seed is not None:
|
|
286
|
+
# No fallback stream: a different generator would change the values.
|
|
287
|
+
generator = torch.Generator(device=src_pos.device)
|
|
288
|
+
# Match torch.manual_seed's conversion of bool, NumPy and tensor seeds.
|
|
289
|
+
generator.manual_seed(int(seed))
|
|
290
|
+
|
|
246
291
|
# Add random perturbations
|
|
247
292
|
if sid_std > 0.0:
|
|
248
293
|
# Random SID variations
|
|
249
|
-
sid_perturbations = torch.randn(n_views, device=device, dtype=dtype) * sid_std
|
|
294
|
+
sid_perturbations = torch.randn(n_views, device=device, dtype=dtype, generator=generator) * sid_std
|
|
250
295
|
sdd_perturbations = sid_perturbations.clone() # Keep SDD-SID distance approximately constant
|
|
251
296
|
|
|
252
297
|
# Compute angles from existing positions
|
|
@@ -267,12 +312,12 @@ def random_trajectory_3d(n_views, sid_mean, sdd_mean, sid_std=0.0, pos_std=0.0,
|
|
|
267
312
|
|
|
268
313
|
if pos_std > 0.0:
|
|
269
314
|
# Random 3D position offsets
|
|
270
|
-
src_pos += torch.
|
|
271
|
-
det_center += torch.
|
|
315
|
+
src_pos += torch.empty_like(src_pos).normal_(generator=generator) * pos_std
|
|
316
|
+
det_center += torch.empty_like(det_center).normal_(generator=generator) * pos_std
|
|
272
317
|
|
|
273
318
|
if angle_std > 0.0:
|
|
274
319
|
# Random angular perturbations (rotate detector orientation)
|
|
275
|
-
angle_perturbations = torch.randn(n_views, device=device, dtype=dtype) * angle_std
|
|
320
|
+
angle_perturbations = torch.randn(n_views, device=device, dtype=dtype, generator=generator) * angle_std
|
|
276
321
|
cos_perturb = torch.cos(angle_perturbations)
|
|
277
322
|
sin_perturb = torch.sin(angle_perturbations)
|
|
278
323
|
|
|
@@ -286,6 +331,8 @@ def random_trajectory_3d(n_views, sid_mean, sdd_mean, sid_std=0.0, pos_std=0.0,
|
|
|
286
331
|
det_u_vec = det_u_vec / torch.norm(det_u_vec, dim=1, keepdim=True)
|
|
287
332
|
det_v_vec = det_v_vec / torch.norm(det_v_vec, dim=1, keepdim=True)
|
|
288
333
|
|
|
334
|
+
_check_source_positions(src_pos, "random_trajectory_3d")
|
|
335
|
+
|
|
289
336
|
return src_pos, det_center, det_u_vec, det_v_vec
|
|
290
337
|
|
|
291
338
|
|
|
@@ -334,6 +381,7 @@ def spiral_trajectory_3d(n_views, sid, sdd, z_range=100.0, n_turns=2.0,
|
|
|
334
381
|
"""
|
|
335
382
|
# Generate angles for spiral motion
|
|
336
383
|
end_angle = start_angle + 2 * math.pi * n_turns
|
|
384
|
+
_check_n_views(n_views)
|
|
337
385
|
step = (end_angle - start_angle) / n_views
|
|
338
386
|
angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
|
|
339
387
|
|
|
@@ -419,6 +467,7 @@ def sinusoidal_trajectory_3d(n_views, sid, sdd, amplitude=50.0, frequency=2.0,
|
|
|
419
467
|
"""
|
|
420
468
|
# Generate angles
|
|
421
469
|
end_angle = start_angle + 2 * math.pi
|
|
470
|
+
_check_n_views(n_views)
|
|
422
471
|
step = (end_angle - start_angle) / n_views
|
|
423
472
|
angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
|
|
424
473
|
|
|
@@ -505,6 +554,7 @@ def saddle_trajectory_3d(n_views, sid, sdd, z_amplitude=50.0, radial_amplitude=3
|
|
|
505
554
|
"""
|
|
506
555
|
# Generate angles
|
|
507
556
|
end_angle = start_angle + 2 * math.pi
|
|
557
|
+
_check_n_views(n_views)
|
|
508
558
|
step = (end_angle - start_angle) / n_views
|
|
509
559
|
angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
|
|
510
560
|
|
|
@@ -601,6 +651,7 @@ def custom_trajectory_3d(n_views, sid, sdd,
|
|
|
601
651
|
"""
|
|
602
652
|
# Generate angles
|
|
603
653
|
end_angle = start_angle + 2 * math.pi
|
|
654
|
+
_check_n_views(n_views)
|
|
604
655
|
step = (end_angle - start_angle) / n_views
|
|
605
656
|
angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
|
|
606
657
|
|
|
@@ -746,6 +797,7 @@ def sinusoidal_trajectory_2d_fan(n_views, sid, sdd, amplitude=50.0, frequency=2.
|
|
|
746
797
|
"""
|
|
747
798
|
# Generate angles
|
|
748
799
|
end_angle = start_angle + 2 * math.pi
|
|
800
|
+
_check_n_views(n_views)
|
|
749
801
|
step = (end_angle - start_angle) / n_views
|
|
750
802
|
angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
|
|
751
803
|
|
|
@@ -829,6 +881,7 @@ def custom_trajectory_2d_fan(n_views, sid, sdd,
|
|
|
829
881
|
"""
|
|
830
882
|
# Generate angles
|
|
831
883
|
end_angle = start_angle + 2 * math.pi
|
|
884
|
+
_check_n_views(n_views)
|
|
832
885
|
step = (end_angle - start_angle) / n_views
|
|
833
886
|
angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
|
|
834
887
|
|
|
@@ -973,6 +1026,7 @@ def sinusoidal_trajectory_2d_parallel(n_views, amplitude=50.0, frequency=2.0,
|
|
|
973
1026
|
"""
|
|
974
1027
|
# Generate angles
|
|
975
1028
|
end_angle = start_angle + math.pi
|
|
1029
|
+
_check_n_views(n_views)
|
|
976
1030
|
step = (end_angle - start_angle) / n_views
|
|
977
1031
|
angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
|
|
978
1032
|
|
|
@@ -1055,6 +1109,7 @@ def custom_trajectory_2d_parallel(n_views,
|
|
|
1055
1109
|
"""
|
|
1056
1110
|
# Generate angles
|
|
1057
1111
|
end_angle = start_angle + math.pi
|
|
1112
|
+
_check_n_views(n_views)
|
|
1058
1113
|
step = (end_angle - start_angle) / n_views
|
|
1059
1114
|
angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
|
|
1060
1115
|
|
|
@@ -1069,6 +1124,9 @@ def custom_trajectory_2d_parallel(n_views,
|
|
|
1069
1124
|
raise ValueError(f"det_origin_fn must return tensor of shape ({n_views}, 2), "
|
|
1070
1125
|
f"got {det_origin.shape}")
|
|
1071
1126
|
|
|
1127
|
+
# Reject degenerate directions before normalization turns them into NaN
|
|
1128
|
+
_check_ray_directions(ray_dir)
|
|
1129
|
+
|
|
1072
1130
|
# Normalize ray directions
|
|
1073
1131
|
ray_dir = ray_dir / torch.norm(ray_dir, dim=1, keepdim=True)
|
|
1074
1132
|
|