diffct 1.2.10__tar.gz → 1.2.11__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 (49) hide show
  1. diffct-1.2.11/CHANGELOG.md +225 -0
  2. {diffct-1.2.10 → diffct-1.2.11}/PKG-INFO +1 -1
  3. {diffct-1.2.10 → diffct-1.2.11}/diffct/differentiable.py +57 -2
  4. {diffct-1.2.10 → diffct-1.2.11}/examples/fbp_fan.py +32 -21
  5. {diffct-1.2.10 → diffct-1.2.11}/examples/fdk_cone.py +78 -27
  6. {diffct-1.2.10 → diffct-1.2.11}/pyproject.toml +1 -1
  7. diffct-1.2.11/pytest.ini +14 -0
  8. diffct-1.2.11/tests/benchmarks/README.md +60 -0
  9. diffct-1.2.11/tests/benchmarks/__init__.py +0 -0
  10. diffct-1.2.11/tests/benchmarks/_common.py +62 -0
  11. diffct-1.2.11/tests/benchmarks/test_bench_cone.py +145 -0
  12. diffct-1.2.11/tests/benchmarks/test_bench_fan.py +127 -0
  13. diffct-1.2.11/tests/benchmarks/test_bench_parallel.py +115 -0
  14. diffct-1.2.11/tests/test_gradcheck.py +136 -0
  15. diffct-1.2.11/tests/test_ramp_filter_windows.py +217 -0
  16. diffct-1.2.10/pytest.ini +0 -3
  17. {diffct-1.2.10 → diffct-1.2.11}/.github/workflows/docs.yml +0 -0
  18. {diffct-1.2.10 → diffct-1.2.11}/.github/workflows/release.yml +0 -0
  19. {diffct-1.2.10 → diffct-1.2.11}/.gitignore +0 -0
  20. {diffct-1.2.10 → diffct-1.2.11}/LICENSE +0 -0
  21. {diffct-1.2.10 → diffct-1.2.11}/README.md +0 -0
  22. {diffct-1.2.10 → diffct-1.2.11}/diffct/__init__.py +0 -0
  23. {diffct-1.2.10 → diffct-1.2.11}/docs/Makefile +0 -0
  24. {diffct-1.2.10 → diffct-1.2.11}/docs/source/_static/.gitkeep +0 -0
  25. {diffct-1.2.10 → diffct-1.2.11}/docs/source/api.rst +0 -0
  26. {diffct-1.2.10 → diffct-1.2.11}/docs/source/conf.py +0 -0
  27. {diffct-1.2.10 → diffct-1.2.11}/docs/source/examples.rst +0 -0
  28. {diffct-1.2.10 → diffct-1.2.11}/docs/source/fbp_fan_example.rst +0 -0
  29. {diffct-1.2.10 → diffct-1.2.11}/docs/source/fbp_parallel_example.rst +0 -0
  30. {diffct-1.2.10 → diffct-1.2.11}/docs/source/fdk_cone_example.rst +0 -0
  31. {diffct-1.2.10 → diffct-1.2.11}/docs/source/getting_started.rst +0 -0
  32. {diffct-1.2.10 → diffct-1.2.11}/docs/source/index.rst +0 -0
  33. {diffct-1.2.10 → diffct-1.2.11}/docs/source/iterative_reco_cone_example.rst +0 -0
  34. {diffct-1.2.10 → diffct-1.2.11}/docs/source/iterative_reco_fan_example.rst +0 -0
  35. {diffct-1.2.10 → diffct-1.2.11}/docs/source/iterative_reco_parallel_example.rst +0 -0
  36. {diffct-1.2.10 → diffct-1.2.11}/examples/fbp_parallel.py +0 -0
  37. {diffct-1.2.10 → diffct-1.2.11}/examples/iterative_reco_cone.py +0 -0
  38. {diffct-1.2.10 → diffct-1.2.11}/examples/iterative_reco_fan.py +0 -0
  39. {diffct-1.2.10 → diffct-1.2.11}/examples/iterative_reco_parallel.py +0 -0
  40. {diffct-1.2.10 → diffct-1.2.11}/requirements.txt +0 -0
  41. {diffct-1.2.10 → diffct-1.2.11}/tests/test_adjoint_inner_product.py +0 -0
  42. {diffct-1.2.10 → diffct-1.2.11}/tests/test_cone_projector_autograd.py +0 -0
  43. {diffct-1.2.10 → diffct-1.2.11}/tests/test_cuda_smoke.py +0 -0
  44. {diffct-1.2.10 → diffct-1.2.11}/tests/test_fbp_fan_accuracy.py +0 -0
  45. {diffct-1.2.10 → diffct-1.2.11}/tests/test_fbp_fan_offsets.py +0 -0
  46. {diffct-1.2.10 → diffct-1.2.11}/tests/test_fbp_parallel_accuracy.py +0 -0
  47. {diffct-1.2.10 → diffct-1.2.11}/tests/test_fdk_cone_accuracy.py +0 -0
  48. {diffct-1.2.10 → diffct-1.2.11}/tests/test_fdk_cone_offsets.py +0 -0
  49. {diffct-1.2.10 → diffct-1.2.11}/tests/test_weights.py +0 -0
@@ -0,0 +1,225 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.2.11] - 2026-04-14
11
+
12
+ ### Added
13
+ - **Parker short-scan demos** in `examples/fdk_cone.py` and `examples/fbp_fan.py`.
14
+ Both examples now expose an `apply_parker` switch that not only multiplies
15
+ by `parker_weights` but also samples angles on the minimal short-scan range
16
+ `pi + 2*gamma_max`, where `gamma_max` is computed from the detector half-width
17
+ and `sdd`. (Before this change the `apply_parker` flag in `fbp_fan.py` was
18
+ silently a no-op because the angular range stayed at `2*pi`.)
19
+ - **`tests/test_gradcheck.py`** — `torch.autograd.gradcheck` coverage for
20
+ `ParallelProjectorFunction`, `FanProjectorFunction`, and `ConeProjectorFunction`.
21
+ Compares the analytical backward Jacobian to a finite-difference numerical
22
+ Jacobian over small inputs with float32-calibrated tolerances. Strongest
23
+ autograd-correctness guard beyond the existing inner-product adjoint tests.
24
+ - **`tests/test_ramp_filter_windows.py`** — 27 parametrised tests covering every
25
+ `ramp_filter_1d` window option (`None`/`"ram-lak"`, `"hann"`, `"hanning"`,
26
+ `"hamming"`, `"cosine"`, `"shepp-logan"`) at three layers: direct `_ramp_window`
27
+ helper (DC gain, Nyquist value, monotonicity), full `ramp_filter_1d` end-to-end
28
+ (shape, DC annihilation, pad correctness, rfft vs complex-fft parity,
29
+ `sample_spacing` scaling), and a high-frequency attenuation sanity check on a
30
+ step input.
31
+ - **`CHANGELOG.md`** — new Keep-a-Changelog file at the repo root.
32
+ - **`tests/benchmarks/`** — opt-in `pytest-benchmark` suite covering every CUDA
33
+ kernel in the library (forward projector, pure-adjoint backprojector, and
34
+ analytical FBP/FDK pipeline) across three sizes for each of the three
35
+ geometries. 27 benchmarks total. Excluded from the default `pytest tests/`
36
+ run via `--ignore=tests/benchmarks` in `pytest.ini` so the normal test suite
37
+ stays fast; run explicitly with `pytest tests/benchmarks/ --benchmark-only`.
38
+ Enables before/after perf comparisons via `--benchmark-save=` /
39
+ `--benchmark-compare=`.
40
+
41
+ ### Changed
42
+ - **`_cone_3d_fdk_backproject_kernel`** memory-access layout. The kernel now
43
+ unpacks `cuda.grid(3)` as `(iz, iy, ix)` instead of `(ix, iy, iz)`, and
44
+ `cone_weighted_backproject` launches the grid as `_grid_3d(D, H, W)` instead
45
+ of `_grid_3d(W, H, D)`. This makes `iz` warp-adjacent, which matches the
46
+ innermost stride-1 axis of the WHD output buffer, so the final `d_vol` write
47
+ becomes coalesced. Measured speedup on a 64^3–160^3 sweep: 1.0x–1.14x,
48
+ within run-to-run noise (the kernel is read-bound on the sinogram bilinear
49
+ samples, not write-bound, so L2 absorbed the old uncoalesced writes). The
50
+ fix is kept as the right default and future-proofs the kernel against
51
+ edits that shift work toward the write path. Matching coalescing comments
52
+ were added to the 2D `_parallel_2d_fbp_backproject_kernel` and
53
+ `_fan_2d_fbp_backproject_kernel` kernels, which were already coalesced
54
+ (their first grid index is also warp-adjacent and matches the `Nx` innermost
55
+ axis of their `(Ny, Nx)` output buffers).
56
+
57
+ ### Notes
58
+ - No public-API changes. Pure-adjoint kernels and autograd Function classes
59
+ are untouched, so the autograd and iterative-reconstruction paths are
60
+ byte-for-byte equivalent to 1.2.10.
61
+
62
+ ## [1.2.10] - 2026-04-14
63
+
64
+ This release is a correctness overhaul of every analytical reconstruction path
65
+ (parallel / fan / cone) **and** of the autograd adjoint path shared by the
66
+ iterative examples. It also ships a unified architecture where each geometry
67
+ has its own dedicated voxel-driven FBP/FDK gather kernel, separate from the
68
+ pure Siddon adjoint used by autograd.
69
+
70
+ ### Fixed
71
+
72
+ #### Analytical FBP / FDK amplitude bugs
73
+
74
+ Prior to this release, all three analytical reconstruction examples produced
75
+ results that were off by large constant factors:
76
+
77
+ | Example | Pre-fix reco range | Post-fix reco range | MSE improvement |
78
+ |---|---|---|---|
79
+ | `fbp_parallel.py` | [0, 6.52] | [−0.02, 1.01] | ~580× |
80
+ | `fbp_fan.py` | [0, 6.33] | [−0.08, 1.01] | ~900× |
81
+ | `fdk_cone.py` | [0, 10.08] | [−0.08, 1.00] | ~600× |
82
+
83
+ Root causes:
84
+ - Cone and fan used `(sdd/U)^2` with `U = sdd + x·sin − y·cos` instead of
85
+ the correct FDK weight `(sid/U)^2` with `U = sid + x·sin − y·cos`.
86
+ - All three geometries were missing the `1/(2π)` Fourier-convention
87
+ constant from the FBP/FDK reconstruction formula.
88
+ - Cone and fan used a Siddon ray-driven scatter for the analytical path,
89
+ which is neither a true adjoint nor a classical FBP/FDK gather.
90
+
91
+ #### Autograd adjoint bug (cone + fan)
92
+
93
+ `ConeProjectorFunction.backward`, `ConeBackprojectorFunction.forward`,
94
+ `FanProjectorFunction.backward` and `FanBackprojectorFunction.forward`
95
+ were all passing `distance_weight=1.0` to the shared Siddon backward
96
+ kernel. This meant the autograd backward was **not the true adjoint** `P^T`
97
+ of the forward projector — it had a `(sdd/U)^2` per-voxel factor baked in,
98
+ biasing the gradient by ~2–3× depending on voxel position. Any iterative
99
+ reconstruction that relied on autograd (including `iterative_reco_cone.py`
100
+ and `iterative_reco_fan.py`) was running on a biased gradient flow.
101
+
102
+ Parallel beam was unaffected (its backward kernel had no `distance_weight`
103
+ parameter to begin with).
104
+
105
+ This release removes the `distance_weight` parameter from both
106
+ `_cone_3d_backward_kernel` and `_fan_2d_backward_kernel` entirely, so the
107
+ dead code path cannot be accidentally reintroduced, and fixes the four
108
+ autograd call sites to use the pure adjoint.
109
+
110
+ ### Added
111
+
112
+ #### Voxel-driven gather kernels for every geometry
113
+
114
+ Three new CUDA kernels, all under a dedicated `fastmath=False` decorator
115
+ for FDK-grade accuracy:
116
+
117
+ - `_parallel_2d_fbp_backproject_kernel` — no distance weighting (no source).
118
+ - `_fan_2d_fbp_backproject_kernel` — `(sid/U)^2` + linear detector interp.
119
+ - `_cone_3d_fdk_backproject_kernel` — `(sid/U)^2` + bilinear detector interp.
120
+
121
+ Each kernel is voxel-driven: one thread per output pixel/voxel, loops over
122
+ views, computes the projected detector coordinate, interpolates the filtered
123
+ sinogram, weights and accumulates.
124
+
125
+ #### `parallel_weighted_backproject` (new public helper)
126
+
127
+ Mirrors `fan_weighted_backproject` and `cone_weighted_backproject`. Applies
128
+ the `1/(2π)` Fourier-convention constant so a unit-density disk reconstructs
129
+ to amplitude 1. Exported from `diffct`.
130
+
131
+ #### `ramp_filter_1d` — new backward-compatible kwargs
132
+
133
+ - `sample_spacing` (default `1.0`): physical detector cell pitch. Output is
134
+ rescaled by `1/sample_spacing` for physical-unit correctness.
135
+ - `pad_factor` (default `1`): zero-pad to `pad_factor * N` before the FFT to
136
+ suppress circular-convolution wrap-around. `2` is recommended for FBP/FDK.
137
+ - `window` (default `None`): frequency-domain apodization. Options: `None` /
138
+ `"ram-lak"`, `"hann"`, `"hamming"`, `"cosine"`, `"shepp-logan"`.
139
+ - `use_rfft` (default `True`): faster real-FFT path for real-valued inputs.
140
+
141
+ Existing `ramp_filter_1d(sino, dim=1)` calls keep working unchanged.
142
+
143
+ #### Analytical FBP / FDK scale factors
144
+
145
+ Each analytical helper now applies the correct analytical constant
146
+ automatically so reconstructions are already amplitude-calibrated:
147
+
148
+ - `parallel_weighted_backproject`: `1 / (2π)`.
149
+ - `fan_weighted_backproject`: `sdd / (2π · sid)`.
150
+ - `cone_weighted_backproject`: `sdd / (2π · sid)`.
151
+
152
+ ### Tests
153
+
154
+ Test suite expanded from 6 to **27 tests**:
155
+
156
+ - `tests/test_adjoint_inner_product.py` — the definitive check. For random `x`
157
+ and `y`, asserts `⟨A x, y⟩ = ⟨x, A^T y⟩` for parallel, fan and cone autograd
158
+ pairs. Permanently guards against any regression that would reintroduce the
159
+ `distance_weight=1.0` bug.
160
+ - `tests/test_fdk_cone_accuracy.py` — 128³ Shepp-Logan RMSE / amplitude bounds.
161
+ - `tests/test_fdk_cone_offsets.py` — detector, center and combined offsets with
162
+ amplitude assertions.
163
+ - `tests/test_cone_projector_autograd.py` — gradient finiteness and non-zero
164
+ sanity.
165
+ - `tests/test_fbp_fan_accuracy.py` — 256×256 Shepp-Logan RMSE.
166
+ - `tests/test_fbp_fan_offsets.py` — three offset configurations.
167
+ - `tests/test_fbp_parallel_accuracy.py` — parallel FBP RMSE + offset.
168
+
169
+ ### Examples
170
+
171
+ `examples/fdk_cone.py`, `examples/fbp_fan.py` and `examples/fbp_parallel.py`
172
+ have been rewritten with a consistent 8-step layout and detailed inline
173
+ comments documenting every geometry variable (what it is, units, typical
174
+ values, available options). Each example prints raw MSE, clamped MSE, and the
175
+ reconstruction / phantom data ranges.
176
+
177
+ The iterative reconstruction examples (`iterative_reco_parallel.py`,
178
+ `iterative_reco_fan.py`, `iterative_reco_cone.py`) are unchanged — they
179
+ silently benefit from the fixed autograd adjoint.
180
+
181
+ ### Documentation
182
+
183
+ - `docs/source/api.rst`: adds autofunction entries for
184
+ `parallel_weighted_backproject`, documents the new `ramp_filter_1d`
185
+ options, and adds an "Analytical FBP / FDK architecture" section
186
+ explaining the scale-factor derivation.
187
+ - `docs/source/fdk_cone_example.rst`: formula updated to reflect the
188
+ voxel-gather kernel and `(sid/U)^2` weight.
189
+ - `docs/source/fbp_fan_example.rst` and `fbp_parallel_example.rst`: completely
190
+ rewritten to reference the new analytical helpers and fix a sign-convention
191
+ inconsistency in the math section.
192
+
193
+ ### Compatibility
194
+
195
+ This release is source-compatible with 1.2.9 for every public entry point. The
196
+ `distance_weight` parameter was internal and is removed from the private
197
+ backward kernels; no public API touched that argument.
198
+
199
+ ## [1.2.9] - 2026-02-14
200
+
201
+ Restored `main` to the state of 1.2.9 by reverting an accidental merge.
202
+ See [GitHub release v1.2.9](https://github.com/sypsyp97/diffct/releases/tag/v1.2.9).
203
+
204
+ ## [1.2.8] - 2026-02-14
205
+
206
+ See [GitHub release v1.2.8](https://github.com/sypsyp97/diffct/releases/tag/v1.2.8)
207
+ and the commit history for details.
208
+
209
+ ## [1.2.7] - 2025-08-05
210
+
211
+ See [GitHub release v1.2.7](https://github.com/sypsyp97/diffct/releases/tag/v1.2.7).
212
+
213
+ ## Earlier versions
214
+
215
+ Releases 1.2.0 through 1.2.6 and the 1.1.x / 1.0.x lines are tracked on
216
+ [GitHub releases](https://github.com/sypsyp97/diffct/releases). This CHANGELOG
217
+ was introduced in 1.2.10 and does not back-fill detailed notes for earlier
218
+ versions beyond pointers to the GitHub release pages.
219
+
220
+ [Unreleased]: https://github.com/sypsyp97/diffct/compare/v1.2.11...HEAD
221
+ [1.2.11]: https://github.com/sypsyp97/diffct/releases/tag/v1.2.11
222
+ [1.2.10]: https://github.com/sypsyp97/diffct/releases/tag/v1.2.10
223
+ [1.2.9]: https://github.com/sypsyp97/diffct/releases/tag/v1.2.9
224
+ [1.2.8]: https://github.com/sypsyp97/diffct/releases/tag/v1.2.8
225
+ [1.2.7]: https://github.com/sypsyp97/diffct/releases/tag/v1.2.7
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffct
3
- Version: 1.2.10
3
+ Version: 1.2.11
4
4
  Summary: A CUDA-based library for computed tomography (CT) projection and reconstruction with differentiable operators
5
5
  Project-URL: Homepage, https://github.com/sypsyp97/diffct
6
6
  Author-email: Yipeng Sun <yipeng.sun@fau.de>
@@ -1570,8 +1570,35 @@ def _cone_3d_fdk_backproject_kernel(
1570
1570
  the central ray, and all ``*_offset_*`` values are already in voxel
1571
1571
  units at call time. Indices follow the existing permuted layout where
1572
1572
  ``ix`` marches along the W axis, ``iy`` along H and ``iz`` along D.
1573
+
1574
+ Memory-access layout note
1575
+ -------------------------
1576
+ Numba ``cuda.grid(3)`` returns ``(threadIdx.x+..., threadIdx.y+...,
1577
+ threadIdx.z+...)`` in that order, so the FIRST element is the
1578
+ warp-adjacent axis (lanes 0..31 span the first index). We deliberately
1579
+ unpack as ``(iz, iy, ix)`` - not ``(ix, iy, iz)`` - so that ``iz``
1580
+ becomes warp-adjacent. The output buffer ``d_vol`` has WHD layout
1581
+ ``(W, H, D)``, row-major, which means its innermost stride-1 axis is
1582
+ ``D`` (== ``iz``). With ``iz`` warp-adjacent, the 32 threads of a warp
1583
+ write to ``d_vol[ix, iy, iz..iz+31]`` - 32 consecutive float32 cells,
1584
+ one coalesced transaction per warp. The sinogram reads
1585
+ ``d_sino[iview, iu, iv]`` are also coalesced because ``iv`` varies
1586
+ linearly with ``iz`` (``v_det = z_v * sdd/U``) and ``d_sino`` has its
1587
+ innermost stride-1 axis on ``iv``, so a warp's ``fv`` values span a
1588
+ handful of adjacent ``iv`` bins. Matching this ordering, the Python
1589
+ wrapper launches the grid as ``_grid_3d(D, H, W)``.
1590
+
1591
+ Expected performance impact of this layout is *small*: the kernel
1592
+ does ~4 sinogram loads per view per output voxel but only ONE write
1593
+ at the end, so total memory traffic is overwhelmingly read-bound and
1594
+ the L2 easily absorbs any stray write transactions. An A/B
1595
+ benchmark of the old ``ix``-first layout versus this ``iz``-first
1596
+ layout measured ~1.0x to 1.14x speedup on 64^3..160^3 volumes - well
1597
+ within run-to-run noise. We keep the coalesced layout anyway because
1598
+ it is the right default and future kernel edits that shift work
1599
+ toward the write path will benefit immediately without re-auditing.
1573
1600
  """
1574
- ix, iy, iz = cuda.grid(3)
1601
+ iz, iy, ix = cuda.grid(3)
1575
1602
  if ix >= Nx or iy >= Ny or iz >= Nz:
1576
1603
  return
1577
1604
 
@@ -1681,6 +1708,17 @@ def _parallel_2d_fbp_backproject_kernel(
1681
1708
  filtered sinogram at that position, and accumulate. Cosine
1682
1709
  pre-weighting, ramp filtering and per-view integration weights are
1683
1710
  expected to already be baked into ``d_sino`` by the Python wrapper.
1711
+
1712
+ Memory-access layout note
1713
+ -------------------------
1714
+ Numba ``cuda.grid(2)`` returns ``(threadIdx.x+..., threadIdx.y+...)``
1715
+ in that order, so the FIRST element is the warp-adjacent axis. We
1716
+ unpack as ``(ix, iy)``, which puts ``ix`` on ``threadIdx.x``. The
1717
+ output buffer has shape ``(Ny, Nx)`` row-major, whose innermost
1718
+ stride-1 axis is ``Nx`` (== ``ix``). Writing ``d_image[iy, ix]`` is
1719
+ therefore coalesced: the 32 threads of a warp write 32 consecutive
1720
+ float32 cells in one transaction. The grid is launched as
1721
+ ``_grid_2d(Nx, Ny)`` to match this ordering.
1684
1722
  """
1685
1723
  ix, iy = cuda.grid(2)
1686
1724
  if ix >= Nx or iy >= Ny:
@@ -1750,6 +1788,17 @@ def _fan_2d_fbp_backproject_kernel(
1750
1788
  geometry: source at ``(-sid_v*sin_a, sid_v*cos_a)`` relative to the
1751
1789
  isocenter, detector at distance SDD from the source perpendicular
1752
1790
  to the central ray, ``u`` on the detector is in-plane.
1791
+
1792
+ Memory-access layout note
1793
+ -------------------------
1794
+ Same coalescing pattern as the parallel FBP gather kernel above:
1795
+ ``ix`` is on ``threadIdx.x`` (warp-adjacent), and the output buffer
1796
+ ``(Ny, Nx)`` has ``Nx`` as its innermost stride-1 axis, so
1797
+ ``d_image[iy, ix]`` writes 32 consecutive float32 cells per warp.
1798
+ Sinogram reads ``d_sino[iang, idet0..idet0+1]`` also sweep through
1799
+ a small span of the stride-1 detector axis as ``ix`` varies across
1800
+ a warp, so they are approximately coalesced as well. Launch grid is
1801
+ ``_grid_2d(Nx, Ny)``.
1753
1802
  """
1754
1803
  ix, iy = cuda.grid(2)
1755
1804
  if ix >= Nx or iy >= Ny:
@@ -3173,7 +3222,13 @@ def cone_weighted_backproject(
3173
3222
  d_cos_arr = TorchCUDABridge.tensor_to_cuda_array(d_cos)
3174
3223
  d_sin_arr = TorchCUDABridge.tensor_to_cuda_array(d_sin)
3175
3224
 
3176
- grid, tpb = _grid_3d(W, H, D)
3225
+ # Launch grid is (D, H, W) to match the kernel's ``iz, iy, ix = cuda.grid(3)``
3226
+ # unpack order. Numba puts the first grid dimension on ``threadIdx.x``
3227
+ # (the warp-adjacent axis), so by putting D first we make ``iz``
3228
+ # warp-adjacent, which matches the innermost stride-1 axis of the WHD
3229
+ # output buffer - coalesced writes. See the memory-access note in the
3230
+ # kernel docstring.
3231
+ grid, tpb = _grid_3d(D, H, W)
3177
3232
  cx, cy, cz = _DTYPE(W * 0.5), _DTYPE(H * 0.5), _DTYPE(D * 0.5)
3178
3233
  det_offset_u_v = _DTYPE(detector_offset_u / voxel_spacing)
3179
3234
  det_offset_v_v = _DTYPE(detector_offset_v / voxel_spacing)
@@ -78,21 +78,11 @@ def main():
78
78
  voxel_spacing = 1.0
79
79
 
80
80
  # ------------------------------------------------------------------
81
- # 2. Source trajectory (circular orbit)
82
- # ------------------------------------------------------------------
83
- # ``num_angles`` samples on the circular orbit. This example uses a
84
- # full 2*pi scan, uniformly sampled, with ``endpoint=False`` to
85
- # avoid duplicating angle 0. For a short-scan geometry you would
86
- # use an interval of length ``pi + 2*gamma_max`` and set
87
- # ``apply_parker=True`` to enable Parker redundancy weighting.
88
- num_angles = 360
89
- angles_np = np.linspace(
90
- 0.0, 2.0 * math.pi, num_angles, endpoint=False
91
- ).astype(np.float32)
92
-
93
- # ------------------------------------------------------------------
94
- # 3. Detector geometry
81
+ # 2. Detector geometry
95
82
  # ------------------------------------------------------------------
83
+ # (Listed before the trajectory so the short-scan coverage below can
84
+ # use the detector fan angle to compute ``pi + 2*gamma_max``.)
85
+ #
96
86
  # ``num_detectors`` is the number of detector cells along the
97
87
  # detector axis. ``detector_spacing`` is their physical pitch.
98
88
  # Make sure the detector is wide enough that no ray that intersects
@@ -114,14 +104,34 @@ def main():
114
104
  sdd = 800.0
115
105
  sid = 500.0
116
106
 
117
- # Set to ``True`` for short-scan trajectories. When ``True``, the
118
- # Parker weights are multiplied onto the raw sinogram before the
119
- # cosine pre-weight, and ``redundant_full_scan=False`` is passed
120
- # to the angular integration weights (the FBP 1/2 factor that
121
- # corrects for 2*pi redundancy is dropped since Parker already
122
- # handles redundancy).
107
+ # ------------------------------------------------------------------
108
+ # 3. Source trajectory (circular orbit)
109
+ # ------------------------------------------------------------------
110
+ # ``apply_parker`` selects between two supported trajectories:
111
+ #
112
+ # False -> full 2*pi scan, 1/2 FBP redundancy factor absorbed
113
+ # inside ``redundant_full_scan=True``.
114
+ #
115
+ # True -> minimal short scan ``pi + 2*gamma_max`` with Parker
116
+ # redundancy weighting. ``gamma_max`` is computed from
117
+ # the detector half-width and ``sdd``.
118
+ #
119
+ # Both branches reuse the same ``num_angles`` so reconstruction
120
+ # runtime stays identical - only the angular *range* changes.
123
121
  apply_parker = False
124
122
 
123
+ if apply_parker:
124
+ u_max = ((num_detectors - 1) * 0.5) * detector_spacing + abs(detector_offset)
125
+ gamma_max = math.atan(u_max / sdd)
126
+ scan_range = math.pi + 2.0 * gamma_max
127
+ else:
128
+ scan_range = 2.0 * math.pi
129
+
130
+ num_angles = 360
131
+ angles_np = np.linspace(
132
+ 0.0, scan_range, num_angles, endpoint=False
133
+ ).astype(np.float32)
134
+
125
135
  # ------------------------------------------------------------------
126
136
  # 4. Move everything to CUDA
127
137
  # ------------------------------------------------------------------
@@ -229,7 +239,8 @@ def main():
229
239
  raw_loss = torch.mean((reconstruction_raw - image_torch) ** 2)
230
240
  clamped_loss = torch.mean((reconstruction - image_torch) ** 2)
231
241
 
232
- print("Fan Beam FBP example (circular full scan):")
242
+ scan_label = "Parker short scan" if apply_parker else "full 2*pi scan"
243
+ print(f"Fan Beam FBP example ({scan_label}):")
233
244
  print(f" Raw MSE: {raw_loss.item():.6f}")
234
245
  print(f" Clamped MSE: {clamped_loss.item():.6f}")
235
246
  print(f" Reconstruction shape: {tuple(reconstruction.shape)}")
@@ -27,6 +27,7 @@ from diffct.differentiable import (
27
27
  angular_integration_weights,
28
28
  cone_cosine_weights,
29
29
  cone_weighted_backproject,
30
+ parker_weights,
30
31
  ramp_filter_1d,
31
32
  )
32
33
 
@@ -115,26 +116,11 @@ def main():
115
116
  voxel_spacing = 1.0
116
117
 
117
118
  # ------------------------------------------------------------------
118
- # 2. Source trajectory (circular orbit)
119
- # ------------------------------------------------------------------
120
- # ``num_views`` is the number of projection angles sampled on the
121
- # circular orbit. More views generally mean smaller angular
122
- # aliasing and a cleaner FDK reconstruction, at the cost of memory
123
- # and runtime linear in ``num_views``.
124
- num_views = 360
125
-
126
- # Angles in radians. This example uses a full 2*pi scan sampled
127
- # uniformly (``endpoint=False`` avoids duplicating angle 0). For a
128
- # short-scan geometry you would use an interval of length
129
- # ``pi + 2*gamma_max`` and feed the result through ``parker_weights``
130
- # before the ramp filter.
131
- angles_np = np.linspace(
132
- 0.0, 2.0 * math.pi, num_views, endpoint=False
133
- ).astype(np.float32)
134
-
135
- # ------------------------------------------------------------------
136
- # 3. Detector geometry
119
+ # 2. Detector geometry
137
120
  # ------------------------------------------------------------------
121
+ # (Listed before the trajectory so the short-scan coverage below can
122
+ # use the detector fan angle to compute ``pi + 2*gamma_max``.)
123
+ #
138
124
  # ``det_u`` / ``det_v`` are the number of detector cells along the
139
125
  # u (in-plane / horizontal) and v (axial / vertical) directions.
140
126
  # ``du`` / ``dv`` are their physical spacings. Together they define
@@ -163,6 +149,50 @@ def main():
163
149
  sdd = 900.0
164
150
  sid = 600.0
165
151
 
152
+ # ------------------------------------------------------------------
153
+ # 3. Source trajectory (circular orbit)
154
+ # ------------------------------------------------------------------
155
+ # ``apply_parker`` selects between two supported trajectories:
156
+ #
157
+ # False -> full 2*pi circular scan. Each ray is sampled twice
158
+ # (once going one way, once the opposite), so the FDK
159
+ # formula carries a 1/2 redundancy factor which is
160
+ # absorbed by ``redundant_full_scan=True`` inside the
161
+ # angular integration weights.
162
+ #
163
+ # True -> minimal short scan of length ``pi + 2*gamma_max``
164
+ # where ``gamma_max = atan(u_max / sdd)`` is the maximum
165
+ # fan angle. Every ray is sampled *at least once*, some
166
+ # rays twice; the standard Parker window smoothly tapers
167
+ # the duplicate regions so each ray's total contribution
168
+ # integrates to pi (the same effective weight as the
169
+ # full-scan case after the 1/2 redundancy factor), and
170
+ # the angular weights use plain trapezoidal rule
171
+ # (``redundant_full_scan=False``).
172
+ #
173
+ # Short scans are common on clinical C-arm and cone-beam systems
174
+ # where a full 2*pi rotation is mechanically impossible.
175
+ apply_parker = False
176
+
177
+ if apply_parker:
178
+ u_max = ((det_u - 1) * 0.5) * du + abs(detector_offset_u)
179
+ gamma_max = math.atan(u_max / sdd)
180
+ scan_range = math.pi + 2.0 * gamma_max
181
+ else:
182
+ scan_range = 2.0 * math.pi
183
+
184
+ # ``num_views`` is the number of projection angles sampled on the
185
+ # orbit. More views generally mean smaller angular aliasing and a
186
+ # cleaner FDK reconstruction, at the cost of memory and runtime
187
+ # linear in ``num_views``.
188
+ num_views = 360
189
+
190
+ # Angles in radians. ``endpoint=False`` avoids duplicating the start
191
+ # angle at the end of the sweep.
192
+ angles_np = np.linspace(
193
+ 0.0, scan_range, num_views, endpoint=False
194
+ ).astype(np.float32)
195
+
166
196
  # ------------------------------------------------------------------
167
197
  # 4. Move everything to CUDA
168
198
  # ------------------------------------------------------------------
@@ -196,6 +226,25 @@ def main():
196
226
  # 6. FDK analytical reconstruction
197
227
  # ==================================================================
198
228
 
229
+ # --- 6.0 Optional Parker redundancy weighting -------------------
230
+ # ``parker_weights`` returns a ``(num_views, det_u)`` tensor that
231
+ # tapers rays in the redundantly-sampled regions of a short scan.
232
+ # For a full 2*pi scan it detects the coverage and returns all
233
+ # ones (no-op), so this branch is safe to run unconditionally; we
234
+ # gate it on ``apply_parker`` just for clarity.
235
+ #
236
+ # The returned weight depends on the in-plane fan angle only, so
237
+ # we broadcast it across the v (axial) direction via ``unsqueeze(-1)``.
238
+ if apply_parker:
239
+ parker = parker_weights(
240
+ angles_torch,
241
+ det_u,
242
+ du,
243
+ sdd,
244
+ detector_offset=detector_offset_u,
245
+ )
246
+ sinogram = sinogram * parker.unsqueeze(-1)
247
+
199
248
  # --- 6.1 Cosine pre-weight --------------------------------------
200
249
  # Multiplies each detector pixel by ``sdd / sqrt(sdd^2 + u^2 + v^2)``,
201
250
  # i.e. the cosine of the cone angle. This compensates for the
@@ -262,14 +311,15 @@ def main():
262
311
  ).contiguous()
263
312
 
264
313
  # --- 6.3 Per-view angular integration weights -------------------
265
- # For a circular full-scan sampled uniformly, each view contributes
266
- # ``pi / num_views`` to the FDK integral (the ``1/2`` redundancy
267
- # factor of the FDK formula is absorbed inside ``redundant_full_scan``).
268
- # For a short scan you would pass ``redundant_full_scan=False`` and
269
- # typically combine this with Parker weights earlier in the
270
- # pipeline.
314
+ # For a full 2*pi scan each view contributes ``pi / num_views`` to
315
+ # the FDK integral: the ``1/2`` redundancy factor of the FDK
316
+ # formula is absorbed inside ``redundant_full_scan=True``. For a
317
+ # Parker short scan the redundancy has already been handled by the
318
+ # Parker window above, so we fall back to a plain trapezoidal rule
319
+ # with ``redundant_full_scan=False`` (the 1/2 factor would then be
320
+ # incorrect).
271
321
  d_beta = angular_integration_weights(
272
- angles_torch, redundant_full_scan=True
322
+ angles_torch, redundant_full_scan=(not apply_parker)
273
323
  ).view(-1, 1, 1)
274
324
  sinogram_filt = sinogram_filt * d_beta
275
325
 
@@ -311,7 +361,8 @@ def main():
311
361
  raw_loss = torch.mean((reconstruction_raw - phantom_torch) ** 2)
312
362
  clamped_loss = torch.mean((reconstruction - phantom_torch) ** 2)
313
363
 
314
- print("Cone Beam FDK example (circular full scan):")
364
+ scan_label = "Parker short scan" if apply_parker else "full 2*pi scan"
365
+ print(f"Cone Beam FDK example ({scan_label}):")
315
366
  print(f" Raw MSE: {raw_loss.item():.6f}")
316
367
  print(f" Clamped MSE: {clamped_loss.item():.6f}")
317
368
  print(f" Reconstruction shape: {tuple(reconstruction.shape)}")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "diffct"
7
- version = "1.2.10"
7
+ version = "1.2.11"
8
8
  description = "A CUDA-based library for computed tomography (CT) projection and reconstruction with differentiable operators"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -0,0 +1,14 @@
1
+ [pytest]
2
+ markers =
3
+ cuda: CUDA-dependent smoke tests
4
+ benchmark: Performance benchmarks (opt-in, excluded from default runs)
5
+ # Default run excludes the benchmarks directory entirely. Use
6
+ # pytest tests/benchmarks/
7
+ # explicitly to run them. This keeps `pytest tests/ -q` fast.
8
+ addopts = --ignore=tests/benchmarks
9
+ # Disable the timer/timings column noise from pytest-benchmark when not
10
+ # running it (so default runs stay quiet).
11
+ benchmark_disable_gc = true
12
+ benchmark_warmup = true
13
+ benchmark_min_rounds = 3
14
+ benchmark_max_time = 2.0
@@ -0,0 +1,60 @@
1
+ # diffct benchmarks
2
+
3
+ Performance benchmarks for every CUDA kernel in `diffct`, built on
4
+ `pytest-benchmark`. These are **opt-in** — they are excluded from the
5
+ default `pytest tests/` run by `--ignore=tests/benchmarks` in
6
+ `pytest.ini`, so the regular test suite stays fast.
7
+
8
+ ## Running
9
+
10
+ Run everything:
11
+
12
+ ```bash
13
+ /c/Users/sun/miniconda3/envs/cuda12/python.exe -m pytest tests/benchmarks/ \
14
+ --benchmark-only --benchmark-columns=min,mean,median,stddev,rounds
15
+ ```
16
+
17
+ Run a single geometry:
18
+
19
+ ```bash
20
+ pytest tests/benchmarks/test_bench_cone.py --benchmark-only
21
+ ```
22
+
23
+ Compare against a saved baseline (useful for before/after perf work):
24
+
25
+ ```bash
26
+ # first, save a baseline
27
+ pytest tests/benchmarks/ --benchmark-only --benchmark-save=baseline
28
+
29
+ # ... make your changes ...
30
+
31
+ # then compare
32
+ pytest tests/benchmarks/ --benchmark-only --benchmark-compare=baseline
33
+ ```
34
+
35
+ ## What gets benchmarked
36
+
37
+ Each geometry has a matching benchmark file that covers three kernels
38
+ at three sizes (small / medium / large). All benchmarks run on CUDA
39
+ and `pytest.skip` automatically when CUDA is unavailable.
40
+
41
+ | File | Kernel under test |
42
+ |---|---|
43
+ | `test_bench_parallel.py` | `ParallelProjectorFunction`, `ParallelBackprojectorFunction`, `parallel_weighted_backproject` |
44
+ | `test_bench_fan.py` | `FanProjectorFunction`, `FanBackprojectorFunction`, `fan_weighted_backproject` |
45
+ | `test_bench_cone.py` | `ConeProjectorFunction`, `ConeBackprojectorFunction`, `cone_weighted_backproject` |
46
+
47
+ For the weighted-backproject helpers the benchmark times the full
48
+ analytical FBP / FDK pipeline (cosine pre-weight + ramp filter +
49
+ angular weights + voxel-gather kernel), because that is what a user
50
+ actually pays for when reconstructing.
51
+
52
+ ## How it works
53
+
54
+ `pytest-benchmark` re-runs the target callable many times and reports
55
+ the statistics (min / mean / median / stddev / rounds). CUDA streams
56
+ are synchronised inside every call so we measure real GPU time rather
57
+ than async kernel launch latency. The `pytest.ini` defaults
58
+ (`benchmark_disable_gc`, `benchmark_warmup`,
59
+ `benchmark_min_rounds=3`, `benchmark_max_time=2.0`) keep each
60
+ benchmark under a couple of seconds while still averaging out noise.
File without changes