diffct 2.2.0__tar.gz → 2.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. {diffct-2.2.0 → diffct-2.2.1}/CHANGELOG.md +24 -0
  2. {diffct-2.2.0 → diffct-2.2.1}/CITATION.cff +2 -2
  3. {diffct-2.2.0 → diffct-2.2.1}/PKG-INFO +3 -3
  4. {diffct-2.2.0 → diffct-2.2.1}/README.md +2 -2
  5. {diffct-2.2.0 → diffct-2.2.1}/diffct/__init__.py +13 -1
  6. {diffct-2.2.0 → diffct-2.2.1}/diffct/analytical.py +151 -6
  7. {diffct-2.2.0 → diffct-2.2.1}/diffct/kernels/__init__.py +8 -0
  8. diffct-2.2.1/diffct/kernels/analytical_transpose.py +295 -0
  9. diffct-2.2.1/diffct/reconstruction.py +399 -0
  10. {diffct-2.2.0 → diffct-2.2.1}/examples/README.md +22 -0
  11. {diffct-2.2.0 → diffct-2.2.1}/pyproject.toml +1 -1
  12. diffct-2.2.1/tests/test_analytical_autograd.py +313 -0
  13. {diffct-2.2.0 → diffct-2.2.1}/tests/test_block_storage.py +7 -1
  14. diffct-2.2.1/tests/test_reconstruction_api.py +872 -0
  15. {diffct-2.2.0 → diffct-2.2.1}/.gitignore +0 -0
  16. {diffct-2.2.0 → diffct-2.2.1}/LICENSE +0 -0
  17. {diffct-2.2.0 → diffct-2.2.1}/NOTICE +0 -0
  18. {diffct-2.2.0 → diffct-2.2.1}/diffct/chunking.py +0 -0
  19. {diffct-2.2.0 → diffct-2.2.1}/diffct/constants.py +0 -0
  20. {diffct-2.2.0 → diffct-2.2.1}/diffct/differentiable.py +0 -0
  21. {diffct-2.2.0 → diffct-2.2.1}/diffct/geometry.py +0 -0
  22. {diffct-2.2.0 → diffct-2.2.1}/diffct/kernels/cone_beam.py +0 -0
  23. {diffct-2.2.0 → diffct-2.2.1}/diffct/kernels/fan_beam.py +0 -0
  24. {diffct-2.2.0 → diffct-2.2.1}/diffct/kernels/geometry_vjp.py +0 -0
  25. {diffct-2.2.0 → diffct-2.2.1}/diffct/kernels/parallel_beam.py +0 -0
  26. {diffct-2.2.0 → diffct-2.2.1}/diffct/operators.py +0 -0
  27. {diffct-2.2.0 → diffct-2.2.1}/diffct/pipeline.py +0 -0
  28. {diffct-2.2.0 → diffct-2.2.1}/diffct/projectors.py +0 -0
  29. {diffct-2.2.0 → diffct-2.2.1}/diffct/storage.py +0 -0
  30. {diffct-2.2.0 → diffct-2.2.1}/diffct/streaming.py +0 -0
  31. {diffct-2.2.0 → diffct-2.2.1}/diffct/surfaces.py +0 -0
  32. {diffct-2.2.0 → diffct-2.2.1}/diffct/utils.py +0 -0
  33. {diffct-2.2.0 → diffct-2.2.1}/docs/video/README.md +0 -0
  34. {diffct-2.2.0 → diffct-2.2.1}/examples/data/NOTICE +0 -0
  35. {diffct-2.2.0 → diffct-2.2.1}/formal/README.md +0 -0
  36. {diffct-2.2.0 → diffct-2.2.1}/pytest.ini +0 -0
  37. {diffct-2.2.0 → diffct-2.2.1}/tests/__init__.py +0 -0
  38. {diffct-2.2.0 → diffct-2.2.1}/tests/_phantoms.py +0 -0
  39. {diffct-2.2.0 → diffct-2.2.1}/tests/benchmarks/README.md +0 -0
  40. {diffct-2.2.0 → diffct-2.2.1}/tests/benchmarks/__init__.py +0 -0
  41. {diffct-2.2.0 → diffct-2.2.1}/tests/benchmarks/_common.py +0 -0
  42. {diffct-2.2.0 → diffct-2.2.1}/tests/benchmarks/test_bench_cone.py +0 -0
  43. {diffct-2.2.0 → diffct-2.2.1}/tests/benchmarks/test_bench_fan.py +0 -0
  44. {diffct-2.2.0 → diffct-2.2.1}/tests/benchmarks/test_bench_parallel.py +0 -0
  45. {diffct-2.2.0 → diffct-2.2.1}/tests/distributed_projector_check.py +0 -0
  46. {diffct-2.2.0 → diffct-2.2.1}/tests/test_adjoint_inner_product.py +0 -0
  47. {diffct-2.2.0 → diffct-2.2.1}/tests/test_allocator_capacity.py +0 -0
  48. {diffct-2.2.0 → diffct-2.2.1}/tests/test_analytical_regressions.py +0 -0
  49. {diffct-2.2.0 → diffct-2.2.1}/tests/test_axis_aligned_chords.py +0 -0
  50. {diffct-2.2.0 → diffct-2.2.1}/tests/test_batched_surfaces.py +0 -0
  51. {diffct-2.2.0 → diffct-2.2.1}/tests/test_block_cgls.py +0 -0
  52. {diffct-2.2.0 → diffct-2.2.1}/tests/test_cgls_state_admission.py +0 -0
  53. {diffct-2.2.0 → diffct-2.2.1}/tests/test_chunked_projector.py +0 -0
  54. {diffct-2.2.0 → diffct-2.2.1}/tests/test_completed_transfer_reuse.py +0 -0
  55. {diffct-2.2.0 → diffct-2.2.1}/tests/test_cone_projector_autograd.py +0 -0
  56. {diffct-2.2.0 → diffct-2.2.1}/tests/test_cuda_exit_deallocations.py +0 -0
  57. {diffct-2.2.0 → diffct-2.2.1}/tests/test_cuda_sink_lifetimes.py +0 -0
  58. {diffct-2.2.0 → diffct-2.2.1}/tests/test_cuda_smoke.py +0 -0
  59. {diffct-2.2.0 → diffct-2.2.1}/tests/test_detector_surface_dtypes.py +0 -0
  60. {diffct-2.2.0 → diffct-2.2.1}/tests/test_detector_surface_mapping.py +0 -0
  61. {diffct-2.2.0 → diffct-2.2.1}/tests/test_detector_surface_units.py +0 -0
  62. {diffct-2.2.0 → diffct-2.2.1}/tests/test_detector_surfaces.py +0 -0
  63. {diffct-2.2.0 → diffct-2.2.1}/tests/test_example_psnr.py +0 -0
  64. {diffct-2.2.0 → diffct-2.2.1}/tests/test_execution_buffers.py +0 -0
  65. {diffct-2.2.0 → diffct-2.2.1}/tests/test_execution_schedules.py +0 -0
  66. {diffct-2.2.0 → diffct-2.2.1}/tests/test_fbp_fan_accuracy.py +0 -0
  67. {diffct-2.2.0 → diffct-2.2.1}/tests/test_fbp_fan_offsets.py +0 -0
  68. {diffct-2.2.0 → diffct-2.2.1}/tests/test_fbp_parallel_accuracy.py +0 -0
  69. {diffct-2.2.0 → diffct-2.2.1}/tests/test_fdk_cone_accuracy.py +0 -0
  70. {diffct-2.2.0 → diffct-2.2.1}/tests/test_fdk_cone_offsets.py +0 -0
  71. {diffct-2.2.0 → diffct-2.2.1}/tests/test_geometry_autograd.py +0 -0
  72. {diffct-2.2.0 → diffct-2.2.1}/tests/test_geometry_gradients.py +0 -0
  73. {diffct-2.2.0 → diffct-2.2.1}/tests/test_gradcheck.py +0 -0
  74. {diffct-2.2.0 → diffct-2.2.1}/tests/test_multi_gpu_exit.py +0 -0
  75. {diffct-2.2.0 → diffct-2.2.1}/tests/test_output_pipeline_overlap.py +0 -0
  76. {diffct-2.2.0 → diffct-2.2.1}/tests/test_projector_api.py +0 -0
  77. {diffct-2.2.0 → diffct-2.2.1}/tests/test_projector_degenerate_geometry.py +0 -0
  78. {diffct-2.2.0 → diffct-2.2.1}/tests/test_projector_geometry_devices.py +0 -0
  79. {diffct-2.2.0 → diffct-2.2.1}/tests/test_ramp_filter_windows.py +0 -0
  80. {diffct-2.2.0 → diffct-2.2.1}/tests/test_release_2_0_1.py +0 -0
  81. {diffct-2.2.0 → diffct-2.2.1}/tests/test_spatial_partition.py +0 -0
  82. {diffct-2.2.0 → diffct-2.2.1}/tests/test_transfer_pipeline.py +0 -0
  83. {diffct-2.2.0 → diffct-2.2.1}/tests/test_transfer_retirement_lifetimes.py +0 -0
  84. {diffct-2.2.0 → diffct-2.2.1}/tests/test_weights.py +0 -0
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.2.1] - 2026-10-13
11
+
12
+ ### Added
13
+
14
+ - Public differentiable `fbp`, `fdk` and approximate slice-windowed `helical_fdk`
15
+ pipelines, with replaceable projection, angular and frequency-response tensors.
16
+ Low-precision measurements run in float32 and keep gradients in their own dtype.
17
+ - Dedicated CUDA transposes of the analytical parallel/fan/cone gathers,
18
+ including sinogram gradients and higher derivatives with fixed geometry.
19
+ - Circular short-scan checks for `short_scan=True`: orbit, detector-u handedness,
20
+ angular span, and constant detector distance and offset. Tolerances follow the
21
+ physical geometry scale and the original trajectory precision.
22
+ - Public helical view weights and weighted gather. Slice centers and angular
23
+ quadrature use float64, so large angle or height offsets keep full-turn coverage.
24
+ - Examples `learnable_reconstruction.py` and `helical_fdk.py`, and the
25
+ analytical learning guide.
26
+
27
+ ### Changed
28
+
29
+ - `parallel_weighted_backproject`, `fan_weighted_backproject` and
30
+ `cone_weighted_backproject` support sinogram autograd. Geometry and isocenter
31
+ gradients raise an error.
32
+ - The FDK baselines in `examples/_common.py` and `walnut_reconstruction.py` call `fdk`.
33
+
10
34
  ## [2.2.0] - 2026-10-11
11
35
 
12
36
  ### Added
@@ -13,8 +13,8 @@ authors:
13
13
  given-names: Yipeng
14
14
  email: yipeng.sun@fau.de
15
15
  alias: sypsyp97
16
- version: 2.2.0
17
- date-released: "2026-10-11"
16
+ version: 2.2.1
17
+ date-released: "2026-10-13"
18
18
  license: Apache-2.0
19
19
  repository-code: "https://github.com/sypsyp97/diffct"
20
20
  url: "https://sypsyp97.github.io/diffct/"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: diffct
3
- Version: 2.2.0
3
+ Version: 2.2.1
4
4
  Summary: Differentiable CUDA projectors for CT: arbitrary trajectories, multi-GPU and multi-node execution, geometry gradients
5
5
  Project-URL: Homepage, https://github.com/sypsyp97/diffct
6
6
  Project-URL: Documentation, https://sypsyp97.github.io/diffct/
@@ -77,7 +77,7 @@ To our knowledge, diffct is the only open-source GPU CT library that combines ar
77
77
  - **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.
78
78
  - **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).
79
79
  - **Multi-GPU and multi-node.** Use `devices=[0, 1, 2, 3]` in one process, or torchrun and NCCL across ranks. Default `partition="views"` shards projections and replicates volumes. `partition="space"` gives each rank an owned volume slab and combines ray contributions. Speedup depends on workload and communication costs.
80
- - **Analytical helpers.** `diffct.analytical` provides ramp filters (ram-lak, shepp-logan, cosine, hamming, hann), fan, cone and Parker weights, and FBP and FDK backprojection.
80
+ - **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).
81
81
 
82
82
  Capabilities, limits and isocenter rules: [docs/REFERENCE.md](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#capabilities-and-limits).
83
83
 
@@ -220,7 +220,7 @@ Walnut data: Meaney 2022, Zenodo 6986012, CC BY 4.0; see [examples/data/NOTICE](
220
220
 
221
221
  ## Examples
222
222
 
223
- `quickstart.py`, `curved_detector.py`, `chunked_reconstruction.py`, `analytical_reconstruction.py`, `iterative_reconstruction.py`, `walnut_reconstruction.py`, `geometry_calibration.py`, `benchmark_projector.py`, `plot_trajectory.py`, and the Slurm template `slurm/multi_node.sbatch`. Launch modes and distributed-loss rules are in [examples/README.md](https://github.com/sypsyp97/diffct/blob/main/examples/README.md).
223
+ `quickstart.py`, `curved_detector.py`, `chunked_reconstruction.py`, `disk_reconstruction.py`, `benchmark_execution.py`, `analytical_reconstruction.py`, `learnable_reconstruction.py`, `helical_fdk.py`, `iterative_reconstruction.py`, `walnut_reconstruction.py`, `geometry_calibration.py`, `benchmark_projector.py`, `benchmark_scaling.py`, `plot_trajectory.py`, and the Slurm template `slurm/multi_node.sbatch`. Launch modes and distributed-loss rules are in [examples/README.md](https://github.com/sypsyp97/diffct/blob/main/examples/README.md).
224
224
 
225
225
  ## Documentation
226
226
 
@@ -46,7 +46,7 @@ To our knowledge, diffct is the only open-source GPU CT library that combines ar
46
46
  - **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.
47
47
  - **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).
48
48
  - **Multi-GPU and multi-node.** Use `devices=[0, 1, 2, 3]` in one process, or torchrun and NCCL across ranks. Default `partition="views"` shards projections and replicates volumes. `partition="space"` gives each rank an owned volume slab and combines ray contributions. Speedup depends on workload and communication costs.
49
- - **Analytical helpers.** `diffct.analytical` provides ramp filters (ram-lak, shepp-logan, cosine, hamming, hann), fan, cone and Parker weights, and FBP and FDK backprojection.
49
+ - **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).
50
50
 
51
51
  Capabilities, limits and isocenter rules: [docs/REFERENCE.md](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#capabilities-and-limits).
52
52
 
@@ -189,7 +189,7 @@ Walnut data: Meaney 2022, Zenodo 6986012, CC BY 4.0; see [examples/data/NOTICE](
189
189
 
190
190
  ## Examples
191
191
 
192
- `quickstart.py`, `curved_detector.py`, `chunked_reconstruction.py`, `analytical_reconstruction.py`, `iterative_reconstruction.py`, `walnut_reconstruction.py`, `geometry_calibration.py`, `benchmark_projector.py`, `plot_trajectory.py`, and the Slurm template `slurm/multi_node.sbatch`. Launch modes and distributed-loss rules are in [examples/README.md](https://github.com/sypsyp97/diffct/blob/main/examples/README.md).
192
+ `quickstart.py`, `curved_detector.py`, `chunked_reconstruction.py`, `disk_reconstruction.py`, `benchmark_execution.py`, `analytical_reconstruction.py`, `learnable_reconstruction.py`, `helical_fdk.py`, `iterative_reconstruction.py`, `walnut_reconstruction.py`, `geometry_calibration.py`, `benchmark_projector.py`, `benchmark_scaling.py`, `plot_trajectory.py`, and the Slurm template `slurm/multi_node.sbatch`. Launch modes and distributed-loss rules are in [examples/README.md](https://github.com/sypsyp97/diffct/blob/main/examples/README.md).
193
193
 
194
194
  ## Documentation
195
195
 
@@ -17,6 +17,13 @@ from .projectors import (
17
17
  from .operators import Projector
18
18
  from .storage import TensorStore, NpyStore
19
19
  from .surfaces import ParameterizedSurface
20
+ from .reconstruction import (
21
+ fbp,
22
+ fdk,
23
+ helical_fdk,
24
+ helical_view_weights,
25
+ helical_weighted_backproject,
26
+ )
20
27
 
21
28
  from .geometry import (
22
29
  circular_trajectory_3d,
@@ -51,13 +58,18 @@ try:
51
58
  except ImportError:
52
59
  pass
53
60
 
54
- __version__ = '2.2.0'
61
+ __version__ = '2.2.1'
55
62
 
56
63
  __all__ = [
57
64
  'Projector',
58
65
  'TensorStore',
59
66
  'NpyStore',
60
67
  'ParameterizedSurface',
68
+ 'fbp',
69
+ 'fdk',
70
+ 'helical_fdk',
71
+ 'helical_view_weights',
72
+ 'helical_weighted_backproject',
61
73
  'ParallelProjectorFunction',
62
74
  'ParallelBackprojectorFunction',
63
75
  'FanProjectorFunction',
@@ -40,6 +40,9 @@ from .kernels import (
40
40
  _parallel_2d_fbp_backproject_kernel,
41
41
  _fan_2d_fbp_backproject_kernel,
42
42
  _cone_3d_fdk_backproject_kernel,
43
+ _parallel_2d_fbp_transpose_kernel,
44
+ _fan_2d_fbp_transpose_kernel,
45
+ _cone_3d_fdk_transpose_kernel,
43
46
  )
44
47
 
45
48
 
@@ -367,8 +370,8 @@ def _as_contig_f32(t, device):
367
370
 
368
371
 
369
372
  @_on_device_of("sinogram")
370
- def parallel_weighted_backproject(sinogram, ray_dir, det_origin, det_u_vec,
371
- detector_spacing, H, W, voxel_spacing=1.0):
373
+ def _parallel_weighted_backproject_raw(sinogram, ray_dir, det_origin, det_u_vec,
374
+ detector_spacing, H, W, voxel_spacing=1.0):
372
375
  """Voxel-driven parallel-beam FBP backprojection with analytical constant.
373
376
 
374
377
  Runs the dedicated parallel-beam FBP gather kernel (one thread per
@@ -451,8 +454,8 @@ def _analytical_geometry(src_pos, det_center, n, isocenter):
451
454
 
452
455
 
453
456
  @_on_device_of("sinogram")
454
- def fan_weighted_backproject(sinogram, src_pos, det_center, det_u_vec,
455
- detector_spacing, H, W, voxel_spacing=1.0, *, isocenter=None):
457
+ def _fan_weighted_backproject_raw(sinogram, src_pos, det_center, det_u_vec,
458
+ detector_spacing, H, W, voxel_spacing=1.0, *, isocenter=None):
456
459
  """Voxel-driven fan-beam FBP backprojection with analytical constant.
457
460
 
458
461
  Runs the dedicated fan-beam FBP gather kernel (one thread per output
@@ -507,8 +510,8 @@ def fan_weighted_backproject(sinogram, src_pos, det_center, det_u_vec,
507
510
 
508
511
 
509
512
  @_on_device_of("sinogram")
510
- def cone_weighted_backproject(sinogram, src_pos, det_center, det_u_vec, det_v_vec,
511
- D, H, W, du, dv, voxel_spacing=1.0, *, isocenter=None):
513
+ def _cone_weighted_backproject_raw(sinogram, src_pos, det_center, det_u_vec, det_v_vec,
514
+ D, H, W, du, dv, voxel_spacing=1.0, *, isocenter=None):
512
515
  """Voxel-driven cone-beam FDK backprojection with analytical constant.
513
516
 
514
517
  Runs the dedicated cone-beam FDK gather kernel (one thread per output
@@ -571,3 +574,145 @@ def cone_weighted_backproject(sinogram, src_pos, det_center, det_u_vec, det_v_ve
571
574
  # Convert back to (D, H, W) layout for the user.
572
575
  reco = reco_perm.permute(2, 1, 0).contiguous() * scale
573
576
  return reco
577
+
578
+
579
+ def _validate_analytical_inputs(sinogram, geometry, ndim, isocenter=None):
580
+ if not isinstance(sinogram, torch.Tensor) or sinogram.ndim != ndim:
581
+ raise ValueError(f"sinogram must have rank {ndim}")
582
+ if min(sinogram.shape[1:]) < 2:
583
+ raise ValueError("Analytical backprojection requires at least two detector bins on each axis")
584
+ for component in geometry:
585
+ if not isinstance(component, torch.Tensor) or component.shape != (sinogram.shape[0], ndim):
586
+ raise ValueError(f"Every geometry tensor must have shape (n_views, {ndim})")
587
+ if component.requires_grad:
588
+ raise ValueError("Analytical backprojection does not support geometry gradients")
589
+ if isinstance(isocenter, torch.Tensor) and isocenter.requires_grad:
590
+ raise ValueError("Analytical backprojection does not support isocenter gradients")
591
+ if not sinogram.is_cuda:
592
+ raise ValueError("sinogram must be on a CUDA device")
593
+
594
+
595
+ def _analytical_gather_raw(sinogram, beam, geometry, parameters, isocenter):
596
+ if beam == "parallel":
597
+ return _parallel_weighted_backproject_raw(sinogram, *geometry, *parameters)
598
+ if beam == "fan":
599
+ return _fan_weighted_backproject_raw(sinogram, *geometry, *parameters, isocenter=isocenter)
600
+ return _cone_weighted_backproject_raw(sinogram, *geometry, *parameters, isocenter=isocenter)
601
+
602
+
603
+ @_on_device_of("image")
604
+ def _analytical_transpose_raw(image, beam, geometry, parameters, isocenter, sinogram_shape):
605
+ device = image.device
606
+ geometry = tuple(_as_contig_f32(component, device) for component in geometry)
607
+ image = _as_contig_f32(image, device)
608
+ sinogram = torch.zeros(sinogram_shape, device=device, dtype=torch.float32)
609
+ d_sino = TorchCUDABridge.tensor_to_cuda_array(sinogram)
610
+ d_geometry = tuple(TorchCUDABridge.tensor_to_cuda_array(component) for component in geometry)
611
+ stream = _get_numba_external_stream_for(torch.cuda.current_stream())
612
+
613
+ scale = 1.0 / (2.0 * math.pi)
614
+ iso = ()
615
+ if beam != "parallel":
616
+ source, detector, u = geometry[:3]
617
+ normal = (torch.stack((-u[:, 1], u[:, 0]), dim=1) if beam == "fan"
618
+ else torch.cross(u, geometry[3], dim=1))
619
+ isocenter, sid_mean, sdd_mean = _analytical_geometry(source, detector, normal, isocenter)
620
+ scale = sdd_mean / (2.0 * math.pi * sid_mean)
621
+ iso = tuple(_DTYPE(value.item() / parameters[-1]) for value in isocenter)
622
+ image = image * scale
623
+
624
+ if beam == "cone":
625
+ D, H, W, du, dv, spacing = parameters
626
+ image = image.permute(2, 1, 0).contiguous()
627
+ d_image = TorchCUDABridge.tensor_to_cuda_array(image)
628
+ grid, tpb = _grid_3d(D, H, W)
629
+ _cone_3d_fdk_transpose_kernel[grid, tpb, stream](
630
+ d_sino, *sinogram_shape, d_image, W, H, D, _DTYPE(du), _DTYPE(dv),
631
+ *d_geometry, _DTYPE(W * 0.5), _DTYPE(H * 0.5), _DTYPE(D * 0.5),
632
+ _DTYPE(spacing), *iso,
633
+ )
634
+ else:
635
+ pitch, H, W, spacing = parameters
636
+ d_image = TorchCUDABridge.tensor_to_cuda_array(image)
637
+ grid, tpb = _grid_2d(W, H)
638
+ kernel = (_parallel_2d_fbp_transpose_kernel if beam == "parallel"
639
+ else _fan_2d_fbp_transpose_kernel)
640
+ kernel[grid, tpb, stream](
641
+ d_sino, *sinogram_shape, d_image, W, H, _DTYPE(pitch), *d_geometry,
642
+ _DTYPE(W * 0.5), _DTYPE(H * 0.5), _DTYPE(spacing), *iso,
643
+ )
644
+ return sinogram
645
+
646
+
647
+ class _AnalyticalGather(torch.autograd.Function):
648
+ @staticmethod
649
+ def forward(ctx, sinogram, beam, geometry, parameters, isocenter):
650
+ # Backward uses the exact geometry observed by forward, even if the
651
+ # caller edits its fixed trajectory or isocenter afterwards.
652
+ geometry = tuple(component.detach().clone() for component in geometry)
653
+ if isocenter is not None:
654
+ isocenter = torch.as_tensor(isocenter, device=sinogram.device,
655
+ dtype=torch.float32).detach().clone()
656
+ ctx.save_for_backward(*geometry, isocenter)
657
+ ctx.beam, ctx.parameters = beam, parameters
658
+ ctx.input_shape, ctx.input_dtype, ctx.input_device = sinogram.shape, sinogram.dtype, sinogram.device
659
+ return _analytical_gather_raw(sinogram, beam, geometry, parameters, isocenter)
660
+
661
+ @staticmethod
662
+ def backward(ctx, grad_output):
663
+ *geometry, isocenter = ctx.saved_tensors
664
+ grad_input = _AnalyticalTranspose.apply(
665
+ grad_output, ctx.beam, tuple(geometry), ctx.parameters, isocenter, ctx.input_shape,
666
+ ).to(device=ctx.input_device, dtype=ctx.input_dtype)
667
+ return grad_input, None, None, None, None
668
+
669
+
670
+ class _AnalyticalTranspose(torch.autograd.Function):
671
+ @staticmethod
672
+ def forward(ctx, image, beam, geometry, parameters, isocenter, sinogram_shape):
673
+ ctx.beam, ctx.geometry, ctx.parameters, ctx.isocenter = beam, geometry, parameters, isocenter
674
+ ctx.input_dtype, ctx.input_device = image.dtype, image.device
675
+ return _analytical_transpose_raw(image, beam, geometry, parameters, isocenter, sinogram_shape)
676
+
677
+ @staticmethod
678
+ def backward(ctx, grad_output):
679
+ grad_input = _AnalyticalGather.apply(
680
+ grad_output, ctx.beam, ctx.geometry, ctx.parameters, ctx.isocenter,
681
+ ).to(device=ctx.input_device, dtype=ctx.input_dtype)
682
+ return grad_input, None, None, None, None, None
683
+
684
+
685
+ def _analytical_backproject(sinogram, beam, geometry, parameters, isocenter=None):
686
+ _validate_analytical_inputs(sinogram, geometry, 3 if beam == "cone" else 2, isocenter)
687
+ if torch.is_grad_enabled() and sinogram.requires_grad:
688
+ return _AnalyticalGather.apply(sinogram, beam, geometry, parameters, isocenter)
689
+ return _analytical_gather_raw(sinogram, beam, geometry, parameters, isocenter)
690
+
691
+
692
+ def parallel_weighted_backproject(sinogram, ray_dir, det_origin, det_u_vec,
693
+ detector_spacing, H, W, voxel_spacing=1.0):
694
+ return _analytical_backproject(
695
+ sinogram, "parallel", (ray_dir, det_origin, det_u_vec),
696
+ (detector_spacing, H, W, voxel_spacing),
697
+ )
698
+
699
+
700
+ def fan_weighted_backproject(sinogram, src_pos, det_center, det_u_vec,
701
+ detector_spacing, H, W, voxel_spacing=1.0, *, isocenter=None):
702
+ return _analytical_backproject(
703
+ sinogram, "fan", (src_pos, det_center, det_u_vec),
704
+ (detector_spacing, H, W, voxel_spacing), isocenter,
705
+ )
706
+
707
+
708
+ def cone_weighted_backproject(sinogram, src_pos, det_center, det_u_vec, det_v_vec,
709
+ D, H, W, du, dv, voxel_spacing=1.0, *, isocenter=None):
710
+ return _analytical_backproject(
711
+ sinogram, "cone", (src_pos, det_center, det_u_vec, det_v_vec),
712
+ (D, H, W, du, dv, voxel_spacing), isocenter,
713
+ )
714
+
715
+
716
+ parallel_weighted_backproject.__doc__ = _parallel_weighted_backproject_raw.__doc__
717
+ fan_weighted_backproject.__doc__ = _fan_weighted_backproject_raw.__doc__
718
+ cone_weighted_backproject.__doc__ = _cone_weighted_backproject_raw.__doc__
@@ -26,6 +26,11 @@ from .geometry_vjp import (
26
26
  _fan_2d_geometry_vjp_kernel,
27
27
  _cone_3d_geometry_vjp_kernel,
28
28
  )
29
+ from .analytical_transpose import (
30
+ _parallel_2d_fbp_transpose_kernel,
31
+ _fan_2d_fbp_transpose_kernel,
32
+ _cone_3d_fdk_transpose_kernel,
33
+ )
29
34
 
30
35
  __all__ = [
31
36
  '_parallel_2d_forward_kernel',
@@ -40,4 +45,7 @@ __all__ = [
40
45
  '_parallel_2d_geometry_vjp_kernel',
41
46
  '_fan_2d_geometry_vjp_kernel',
42
47
  '_cone_3d_geometry_vjp_kernel',
48
+ '_parallel_2d_fbp_transpose_kernel',
49
+ '_fan_2d_fbp_transpose_kernel',
50
+ '_cone_3d_fdk_transpose_kernel',
43
51
  ]
@@ -0,0 +1,295 @@
1
+ """Discrete transposes of the analytical FBP/FDK gather kernels.
2
+
3
+ Geometry arithmetic and interpolation bounds mirror the gather kernels;
4
+ only the final sample operation changes to an atomic detector-bin scatter.
5
+ """
6
+
7
+ import math
8
+
9
+ import numpy as np
10
+ from numba import cuda
11
+
12
+ from ..constants import _EPSILON, _FDK_ACCURACY_DECORATOR, _HALF, _ONE, _ZERO
13
+
14
+
15
+ @_FDK_ACCURACY_DECORATOR
16
+ def _parallel_2d_fbp_transpose_kernel(
17
+ d_sino, n_views, n_det,
18
+ d_image, Nx, Ny,
19
+ det_spacing, d_ray_dir, d_det_origin, d_det_u_vec,
20
+ cx, cy, voxel_spacing
21
+ ):
22
+ """Scatter image cotangents through the parallel analytical gather stencil."""
23
+ ix, iy = cuda.grid(2)
24
+ if ix >= Nx or iy >= Ny:
25
+ return
26
+ if n_det < 2:
27
+ return
28
+
29
+ # Sample at voxel centres, matching the cell-constant Siddon projector.
30
+ x_v = np.float32(ix) + _HALF - cx
31
+ y_v = np.float32(iy) + _HALF - cy
32
+
33
+ det_spacing_v = det_spacing / voxel_spacing
34
+ half_u = (np.float32(n_det) - _ONE) * _HALF
35
+
36
+ for iview in range(n_views):
37
+ dox = d_det_origin[iview, 0] / voxel_spacing
38
+ doy = d_det_origin[iview, 1] / voxel_spacing
39
+
40
+ ux = d_det_u_vec[iview, 0]
41
+ uy = d_det_u_vec[iview, 1]
42
+
43
+ rx = x_v - dox
44
+ ry = y_v - doy
45
+
46
+ u_det = rx * ux + ry * uy
47
+
48
+ fu = u_det / det_spacing_v + half_u
49
+ if fu < _ZERO or fu > (np.float32(n_det) - _ONE):
50
+ continue
51
+
52
+ iu0 = int(math.floor(fu))
53
+ if iu0 >= n_det - 1:
54
+ iu0 = n_det - 2
55
+ if iu0 < 0:
56
+ iu0 = 0
57
+ tu = fu - np.float32(iu0)
58
+ if tu < _ZERO:
59
+ tu = _ZERO
60
+ elif tu > _ONE:
61
+ tu = _ONE
62
+
63
+ value = d_image[iy, ix]
64
+ cuda.atomic.add(d_sino, (iview, iu0), value * (_ONE - tu))
65
+ cuda.atomic.add(d_sino, (iview, iu0 + 1), value * tu)
66
+
67
+
68
+ @_FDK_ACCURACY_DECORATOR
69
+ def _fan_2d_fbp_transpose_kernel(
70
+ d_sino, n_views, n_det,
71
+ d_image, Nx, Ny,
72
+ det_spacing, d_src_pos, d_det_center, d_det_u_vec,
73
+ cx, cy, voxel_spacing, iso_x, iso_y
74
+ ):
75
+ """Scatter image cotangents through the fan analytical gather stencil."""
76
+ ix, iy = cuda.grid(2)
77
+ if ix >= Nx or iy >= Ny:
78
+ return
79
+ if n_det < 2:
80
+ return
81
+
82
+ # Sample at voxel centres, matching the cell-constant Siddon projector.
83
+ x_v = np.float32(ix) + _HALF - cx
84
+ y_v = np.float32(iy) + _HALF - cy
85
+
86
+ det_spacing_v = det_spacing / voxel_spacing
87
+ half_u = (np.float32(n_det) - _ONE) * _HALF
88
+
89
+ for iview in range(n_views):
90
+ sx = d_src_pos[iview, 0] / voxel_spacing
91
+ sy = d_src_pos[iview, 1] / voxel_spacing
92
+
93
+ dcx = d_det_center[iview, 0] / voxel_spacing
94
+ dcy = d_det_center[iview, 1] / voxel_spacing
95
+
96
+ ux = d_det_u_vec[iview, 0]
97
+ uy = d_det_u_vec[iview, 1]
98
+
99
+ # Detector normal in 2D: rotate u_vec 90 degrees. Choose the
100
+ # orientation that points from the source toward the detector,
101
+ # so that ``U_n`` is positive for voxels on the detector side.
102
+ nx = -uy
103
+ ny = ux
104
+ # Align normal so that (det_center - src) . n > 0.
105
+ align = (dcx - sx) * nx + (dcy - sy) * ny
106
+ if align < _ZERO:
107
+ nx = -nx
108
+ ny = -ny
109
+
110
+ # Perpendicular distance from source to voxel along normal.
111
+ px = x_v - sx
112
+ py = y_v - sy
113
+ U_n = px * nx + py * ny
114
+ if U_n <= _EPSILON:
115
+ continue
116
+
117
+ sdd_n = (dcx - sx) * nx + (dcy - sy) * ny
118
+ if sdd_n <= _EPSILON:
119
+ continue
120
+ mag = sdd_n / U_n
121
+
122
+ # Source-to-isocenter distance, independent of coordinate origin.
123
+ sid_n = (iso_x - sx) * nx + (iso_y - sy) * ny
124
+ if sid_n <= _EPSILON:
125
+ continue
126
+
127
+ hx = sx + mag * px
128
+ hy = sy + mag * py
129
+
130
+ rx = hx - dcx
131
+ ry = hy - dcy
132
+ u_det = rx * ux + ry * uy
133
+
134
+ fu = u_det / det_spacing_v + half_u
135
+ if fu < _ZERO or fu > (np.float32(n_det) - _ONE):
136
+ continue
137
+
138
+ iu0 = int(math.floor(fu))
139
+ if iu0 >= n_det - 1:
140
+ iu0 = n_det - 2
141
+ if iu0 < 0:
142
+ iu0 = 0
143
+ tu = fu - np.float32(iu0)
144
+ if tu < _ZERO:
145
+ tu = _ZERO
146
+ elif tu > _ONE:
147
+ tu = _ONE
148
+
149
+ w_ratio = sid_n / U_n
150
+ w = w_ratio * w_ratio
151
+ value = d_image[iy, ix] * w
152
+ cuda.atomic.add(d_sino, (iview, iu0), value * (_ONE - tu))
153
+ cuda.atomic.add(d_sino, (iview, iu0 + 1), value * tu)
154
+
155
+
156
+ @_FDK_ACCURACY_DECORATOR
157
+ def _cone_3d_fdk_transpose_kernel(
158
+ d_sino, n_views, n_u, n_v,
159
+ d_vol, Nx, Ny, Nz,
160
+ du, dv, d_src_pos, d_det_center, d_det_u_vec, d_det_v_vec,
161
+ cx, cy, cz, voxel_spacing, iso_x, iso_y, iso_z
162
+ ):
163
+ """Scatter image cotangents through the cone analytical gather stencil."""
164
+ iz, iy, ix = cuda.grid(3)
165
+ if ix >= Nx or iy >= Ny or iz >= Nz:
166
+ return
167
+ if n_u < 2 or n_v < 2:
168
+ return
169
+
170
+ # Voxel position in voxel-unit, origin-centred coordinates.
171
+ # Sample at voxel centres, matching the cell-constant Siddon projector.
172
+ x_v = np.float32(ix) + _HALF - cx
173
+ y_v = np.float32(iy) + _HALF - cy
174
+ z_v = np.float32(iz) + _HALF - cz
175
+
176
+ du_v = du / voxel_spacing
177
+ dv_v = dv / voxel_spacing
178
+
179
+ half_u = (np.float32(n_u) - _ONE) * _HALF
180
+ half_v = (np.float32(n_v) - _ONE) * _HALF
181
+
182
+ for iview in range(n_views):
183
+ # Source position in voxel units.
184
+ sx = d_src_pos[iview, 0] / voxel_spacing
185
+ sy = d_src_pos[iview, 1] / voxel_spacing
186
+ sz = d_src_pos[iview, 2] / voxel_spacing
187
+
188
+ # Detector centre in voxel units.
189
+ dcx = d_det_center[iview, 0] / voxel_spacing
190
+ dcy = d_det_center[iview, 1] / voxel_spacing
191
+ dcz = d_det_center[iview, 2] / voxel_spacing
192
+
193
+ # Detector orientation (unit vectors, ratio-free).
194
+ ux = d_det_u_vec[iview, 0]
195
+ uy = d_det_u_vec[iview, 1]
196
+ uz = d_det_u_vec[iview, 2]
197
+ vx = d_det_v_vec[iview, 0]
198
+ vy = d_det_v_vec[iview, 1]
199
+ vz = d_det_v_vec[iview, 2]
200
+
201
+ # Detector normal n = u x v. Normalized defensively in case the
202
+ # caller supplied non-orthonormal u/v axes.
203
+ nx = uy * vz - uz * vy
204
+ ny = uz * vx - ux * vz
205
+ nz = ux * vy - uy * vx
206
+ n_len = math.sqrt(nx * nx + ny * ny + nz * nz)
207
+ if n_len < _EPSILON:
208
+ continue
209
+ nx /= n_len
210
+ ny /= n_len
211
+ nz /= n_len
212
+
213
+ # Orient the normal so it points from the source toward the
214
+ # detector. Without this flip, user-supplied (u_vec, v_vec) pairs
215
+ # whose cross product happens to point the other way would make
216
+ # every voxel's U_n negative and get silently skipped, producing
217
+ # a zero reconstruction. The circular_trajectory_3d helper already
218
+ # produces the right-handed convention so this is a no-op there.
219
+ align = (dcx - sx) * nx + (dcy - sy) * ny + (dcz - sz) * nz
220
+ if align < _ZERO:
221
+ nx = -nx
222
+ ny = -ny
223
+ nz = -nz
224
+
225
+ # Signed distance from source to voxel along the detector normal
226
+ # (this is the U in the (sid/U)^2 FDK weight).
227
+ px = x_v - sx
228
+ py = y_v - sy
229
+ pz = z_v - sz
230
+ U_n = px * nx + py * ny + pz * nz
231
+ if U_n <= _EPSILON:
232
+ continue
233
+
234
+ # Source-to-detector distance along the normal.
235
+ sdd_n = (dcx - sx) * nx + (dcy - sy) * ny + (dcz - sz) * nz
236
+ if sdd_n <= _EPSILON:
237
+ continue
238
+
239
+ # Source-to-isocenter distance, independent of coordinate origin.
240
+ sid_n = (iso_x - sx) * nx + (iso_y - sy) * ny + (iso_z - sz) * nz
241
+ if sid_n <= _EPSILON:
242
+ continue
243
+
244
+ mag = sdd_n / U_n
245
+
246
+ # Hit point on the detector plane.
247
+ hx = sx + mag * px
248
+ hy = sy + mag * py
249
+ hz = sz + mag * pz
250
+
251
+ # Detector coordinates relative to detector centre.
252
+ rx = hx - dcx
253
+ ry = hy - dcy
254
+ rz = hz - dcz
255
+
256
+ u_det = rx * ux + ry * uy + rz * uz
257
+ v_det = rx * vx + ry * vy + rz * vz
258
+
259
+ # Convert (u, v) to fractional bin index.
260
+ fu = u_det / du_v + half_u
261
+ fv = v_det / dv_v + half_v
262
+
263
+ if fu < _ZERO or fu > (np.float32(n_u) - _ONE) or fv < _ZERO or fv > (np.float32(n_v) - _ONE):
264
+ continue
265
+
266
+ iu0 = int(math.floor(fu))
267
+ iv0 = int(math.floor(fv))
268
+ if iu0 >= n_u - 1:
269
+ iu0 = n_u - 2
270
+ if iv0 >= n_v - 1:
271
+ iv0 = n_v - 2
272
+ if iu0 < 0:
273
+ iu0 = 0
274
+ if iv0 < 0:
275
+ iv0 = 0
276
+ tu = fu - np.float32(iu0)
277
+ tv = fv - np.float32(iv0)
278
+ if tu < _ZERO:
279
+ tu = _ZERO
280
+ elif tu > _ONE:
281
+ tu = _ONE
282
+ if tv < _ZERO:
283
+ tv = _ZERO
284
+ elif tv > _ONE:
285
+ tv = _ONE
286
+
287
+ omtu = _ONE - tu
288
+ omtv = _ONE - tv
289
+ w_ratio = sid_n / U_n
290
+ w = w_ratio * w_ratio
291
+ value = d_vol[ix, iy, iz] * w
292
+ cuda.atomic.add(d_sino, (iview, iu0, iv0), value * omtu * omtv)
293
+ cuda.atomic.add(d_sino, (iview, iu0 + 1, iv0), value * tu * omtv)
294
+ cuda.atomic.add(d_sino, (iview, iu0, iv0 + 1), value * omtu * tv)
295
+ cuda.atomic.add(d_sino, (iview, iu0 + 1, iv0 + 1), value * tu * tv)