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.
- diffct-1.2.11/CHANGELOG.md +225 -0
- {diffct-1.2.10 → diffct-1.2.11}/PKG-INFO +1 -1
- {diffct-1.2.10 → diffct-1.2.11}/diffct/differentiable.py +57 -2
- {diffct-1.2.10 → diffct-1.2.11}/examples/fbp_fan.py +32 -21
- {diffct-1.2.10 → diffct-1.2.11}/examples/fdk_cone.py +78 -27
- {diffct-1.2.10 → diffct-1.2.11}/pyproject.toml +1 -1
- diffct-1.2.11/pytest.ini +14 -0
- diffct-1.2.11/tests/benchmarks/README.md +60 -0
- diffct-1.2.11/tests/benchmarks/__init__.py +0 -0
- diffct-1.2.11/tests/benchmarks/_common.py +62 -0
- diffct-1.2.11/tests/benchmarks/test_bench_cone.py +145 -0
- diffct-1.2.11/tests/benchmarks/test_bench_fan.py +127 -0
- diffct-1.2.11/tests/benchmarks/test_bench_parallel.py +115 -0
- diffct-1.2.11/tests/test_gradcheck.py +136 -0
- diffct-1.2.11/tests/test_ramp_filter_windows.py +217 -0
- diffct-1.2.10/pytest.ini +0 -3
- {diffct-1.2.10 → diffct-1.2.11}/.github/workflows/docs.yml +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/.github/workflows/release.yml +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/.gitignore +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/LICENSE +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/README.md +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/diffct/__init__.py +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/docs/Makefile +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/docs/source/_static/.gitkeep +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/docs/source/api.rst +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/docs/source/conf.py +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/docs/source/examples.rst +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/docs/source/fbp_fan_example.rst +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/docs/source/fbp_parallel_example.rst +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/docs/source/fdk_cone_example.rst +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/docs/source/getting_started.rst +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/docs/source/index.rst +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/docs/source/iterative_reco_cone_example.rst +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/docs/source/iterative_reco_fan_example.rst +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/docs/source/iterative_reco_parallel_example.rst +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/examples/fbp_parallel.py +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/examples/iterative_reco_cone.py +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/examples/iterative_reco_fan.py +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/examples/iterative_reco_parallel.py +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/requirements.txt +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/tests/test_adjoint_inner_product.py +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/tests/test_cone_projector_autograd.py +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/tests/test_cuda_smoke.py +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/tests/test_fbp_fan_accuracy.py +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/tests/test_fbp_fan_offsets.py +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/tests/test_fbp_parallel_accuracy.py +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/tests/test_fdk_cone_accuracy.py +0 -0
- {diffct-1.2.10 → diffct-1.2.11}/tests/test_fdk_cone_offsets.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
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
|
-
|
|
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.
|
|
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
|
|
266
|
-
#
|
|
267
|
-
#
|
|
268
|
-
#
|
|
269
|
-
#
|
|
270
|
-
#
|
|
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=
|
|
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
|
-
|
|
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.
|
|
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 = [
|
diffct-1.2.11/pytest.ini
ADDED
|
@@ -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
|