cil-krl 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. cil_krl-0.3.0/LICENSE +21 -0
  2. cil_krl-0.3.0/PKG-INFO +312 -0
  3. cil_krl-0.3.0/README.md +254 -0
  4. cil_krl-0.3.0/pyproject.toml +94 -0
  5. cil_krl-0.3.0/setup.cfg +4 -0
  6. cil_krl-0.3.0/src/cil_krl.egg-info/PKG-INFO +312 -0
  7. cil_krl-0.3.0/src/cil_krl.egg-info/SOURCES.txt +36 -0
  8. cil_krl-0.3.0/src/cil_krl.egg-info/dependency_links.txt +1 -0
  9. cil_krl-0.3.0/src/cil_krl.egg-info/requires.txt +15 -0
  10. cil_krl-0.3.0/src/cil_krl.egg-info/top_level.txt +1 -0
  11. cil_krl-0.3.0/src/krl/__init__.py +58 -0
  12. cil_krl-0.3.0/src/krl/algorithms/__init__.py +7 -0
  13. cil_krl-0.3.0/src/krl/algorithms/lbfgsb.py +177 -0
  14. cil_krl-0.3.0/src/krl/algorithms/maprl.py +321 -0
  15. cil_krl-0.3.0/src/krl/algorithms/richardson_lucy.py +190 -0
  16. cil_krl-0.3.0/src/krl/callbacks.py +185 -0
  17. cil_krl-0.3.0/src/krl/operators/__init__.py +20 -0
  18. cil_krl-0.3.0/src/krl/operators/blurring.py +161 -0
  19. cil_krl-0.3.0/src/krl/operators/directional.py +61 -0
  20. cil_krl-0.3.0/src/krl/operators/gpu_kernel_operator.py +1241 -0
  21. cil_krl-0.3.0/src/krl/operators/kernel_operator.py +898 -0
  22. cil_krl-0.3.0/src/krl/utils.py +163 -0
  23. cil_krl-0.3.0/tests/test_backend_selection.py +112 -0
  24. cil_krl-0.3.0/tests/test_blurring.py +99 -0
  25. cil_krl-0.3.0/tests/test_callbacks.py +183 -0
  26. cil_krl-0.3.0/tests/test_cuda_fallback.py +164 -0
  27. cil_krl-0.3.0/tests/test_directional_maprl_krl.py +243 -0
  28. cil_krl-0.3.0/tests/test_gpu_kernel_operator.py +904 -0
  29. cil_krl-0.3.0/tests/test_gradient_directional_maprl.py +498 -0
  30. cil_krl-0.3.0/tests/test_hybrid_freezing.py +458 -0
  31. cil_krl-0.3.0/tests/test_imports.py +11 -0
  32. cil_krl-0.3.0/tests/test_integration.py +217 -0
  33. cil_krl-0.3.0/tests/test_kernel_operator.py +1114 -0
  34. cil_krl-0.3.0/tests/test_lbfgsb.py +105 -0
  35. cil_krl-0.3.0/tests/test_mps_kernel_operator.py +143 -0
  36. cil_krl-0.3.0/tests/test_nifti_io.py +192 -0
  37. cil_krl-0.3.0/tests/test_preconditioner.py +368 -0
  38. cil_krl-0.3.0/tests/test_quickstart.py +61 -0
cil_krl-0.3.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sam Porter
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
cil_krl-0.3.0/PKG-INFO ADDED
@@ -0,0 +1,312 @@
1
+ Metadata-Version: 2.4
2
+ Name: cil-krl
3
+ Version: 0.3.0
4
+ Summary: Kernelised Richardson-Lucy deconvolution for PET with anatomical guidance, built as a CIL plugin
5
+ Author-email: Kjell Erlandsson <k.erlandsson@ucl.ac.uk>, Sam Porter <sam.porter.18@ucl.ac.uk>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Sam Porter
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/samdporter/Kernel_RL
29
+ Project-URL: Repository, https://github.com/samdporter/Kernel_RL
30
+ Project-URL: Issues, https://github.com/samdporter/Kernel_RL/issues
31
+ Keywords: PET,deconvolution,Richardson-Lucy,CIL,medical imaging,image reconstruction,kernel methods
32
+ Classifier: Development Status :: 3 - Alpha
33
+ Classifier: Intended Audience :: Science/Research
34
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
35
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Programming Language :: Python :: 3
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Programming Language :: Python :: 3.13
42
+ Requires-Python: >=3.10
43
+ Description-Content-Type: text/markdown
44
+ License-File: LICENSE
45
+ Requires-Dist: numpy>=1.23
46
+ Requires-Dist: scipy>=1.7
47
+ Requires-Dist: nibabel>=3.0
48
+ Requires-Dist: numba>=0.55
49
+ Provides-Extra: gpu
50
+ Requires-Dist: torch>=1.9; extra == "gpu"
51
+ Provides-Extra: dev
52
+ Requires-Dist: pytest>=7.0; extra == "dev"
53
+ Requires-Dist: pytest-cov>=3.0; extra == "dev"
54
+ Requires-Dist: ruff>=0.4; extra == "dev"
55
+ Provides-Extra: all
56
+ Requires-Dist: cil-krl[dev,gpu]; extra == "all"
57
+ Dynamic: license-file
58
+
59
+ # cil-krl: Kernelised Richardson-Lucy Deconvolution for PET
60
+
61
+ [![CI](https://github.com/samdporter/Kernel_RL/actions/workflows/ci.yml/badge.svg)](https://github.com/samdporter/Kernel_RL/actions/workflows/ci.yml)
62
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
63
+
64
+ **cil-krl** is a plugin for the [Core Imaging Library (CIL)](https://github.com/TomographicImaging/CIL)
65
+ implementing anatomically-guided Richardson-Lucy deconvolution for PET imaging:
66
+
67
+ - **KRL** — kernelised Richardson-Lucy: deconvolution steered by an anatomical image (e.g. MRI) via a kernel operator
68
+ - **HKRL** — hybrid KRL mixing emission and anatomical features, with optional kernel freezing
69
+ - **MAP-RL** — maximum-a-posteriori RL with Armijo line search and preconditioning
70
+ - **DTV** — directional total variation regularisation built on CIL's gradient operators
71
+ - **Backends** — numba CPU backend; optional PyTorch backend for CUDA GPUs and Apple MPS
72
+
73
+ Everything is built directly on CIL's optimisation framework: operators subclass
74
+ `cil.optimisation.operators.LinearOperator`, algorithms subclass
75
+ `cil.optimisation.algorithms.Algorithm`, so they compose with the rest of the CIL
76
+ ecosystem (callbacks, functions, block operators, ...).
77
+
78
+ ## Installation
79
+
80
+ `cil-krl` is **not published on PyPI**, so `pip install cil-krl` does not work.
81
+ Install CIL first — it is distributed via conda (conda-forge + ccpi channels),
82
+ not PyPI — then install this package from a checkout or from a wheel you build
83
+ yourself.
84
+
85
+ Combinations tested in CI:
86
+
87
+ | Python | CIL |
88
+ |--------|-----|
89
+ | 3.10 | 25.0.0 |
90
+ | 3.11, 3.12, 3.13 | 26.0.0 |
91
+
92
+ The wheel-install gate in CI builds and installs the wheel on Python 3.11 /
93
+ CIL 26.0.0, which resolved to NumPy 2.4 and Numba 0.68 on CPU. The CUDA/torch
94
+ tests are opt-in and skip without a GPU, so they are not covered by that gate.
95
+
96
+ ### Linux
97
+
98
+ ```bash
99
+ # 1. Create an environment with CIL (conda or micromamba)
100
+ conda create -n krl -c conda-forge -c ccpi python=3.11 cil=26.0.0 pip
101
+ conda activate krl
102
+
103
+ # 2. Install cil-krl from a checkout
104
+ git clone https://github.com/samdporter/Kernel_RL.git && cd Kernel_RL
105
+ pip install -e ".[dev]" # + pytest, ruff
106
+ # pip install -e ".[gpu]" # optional: adds PyTorch for the torch backend
107
+ ```
108
+
109
+ Alternatively build a wheel in the checkout with `make build` and install it with
110
+ `pip install dist/*.whl`.
111
+
112
+ ### macOS
113
+
114
+ CIL has no `osx-arm64` build, so run the whole thing in a Linux (x86_64)
115
+ container, from the repository root:
116
+
117
+ ```bash
118
+ docker run --rm --platform linux/amd64 -v "$PWD:/repo:ro" --workdir /tmp \
119
+ --entrypoint /bin/bash mambaorg/micromamba:2.3.2 -lc '
120
+ micromamba create -y -q -n krl -c conda-forge -c ccpi python=3.11 cil=26.0.0 pip &&
121
+ cp -R /repo /tmp/krl &&
122
+ micromamba run -n krl python -m pip install -e "/tmp/krl[dev]" &&
123
+ micromamba run -n krl python -c "import krl; print(krl.__version__)"'
124
+ ```
125
+
126
+ > **macOS / OpenMP caveat:** importing PyTorch into a process that also uses
127
+ > CIL's native acceleration libraries can abort due to duplicate OpenMP runtimes.
128
+ > On macOS keep to the numba backend — `backend="auto"` resolves to numba there
129
+ > *without importing torch* — or run torch-based work in a separate process.
130
+
131
+ A native Apple Silicon route also works: CIL built from source plus the torch
132
+ backend on MPS (with a documented OpenMP workaround). See
133
+ [macOS / ARM](docs/MACOS-ARM.md) for the verified setup and test commands.
134
+
135
+ ## Quickstart
136
+
137
+ A complete, self-contained CPU example: a synthetic emission image, a synthetic
138
+ anatomical image, the observed blurred data and a callback. Every variable is
139
+ defined below, so the snippet runs as-is.
140
+
141
+ ```python
142
+ import numpy as np
143
+ from cil.framework import ImageGeometry
144
+ from cil.optimisation.utilities.callbacks import Callback
145
+
146
+ from krl import RichardsonLucy, create_gaussian_blur, get_kernel_operator
147
+
148
+ # 1. Geometry and two aligned synthetic images
149
+ geometry = ImageGeometry(voxel_num_x=32, voxel_num_y=32, voxel_num_z=16)
150
+
151
+ z, y, x = np.indices((16, 32, 32), dtype=np.float32)
152
+ emission = geometry.allocate(0.0)
153
+ emission.fill(50.0 * np.exp(-((z - 8) ** 2 + (y - 12) ** 2 + (x - 14) ** 2) / 12.0))
154
+ mr_image = geometry.allocate(0.0)
155
+ mr_image.fill(np.exp(-((z - 6) ** 2 + (y - 20) ** 2 + (x - 18) ** 2) / 8.0))
156
+
157
+ # 2. Observed data: the emission image blurred by the PSF
158
+ blur_op = create_gaussian_blur(sigma=(1.5, 1.5, 1.5), geometry=geometry, backend="numba")
159
+ observed = blur_op.direct(emission)
160
+
161
+ # 3. Anatomical guidance operator
162
+ kernel_op = get_kernel_operator(
163
+ geometry, backend="numba", num_neighbours=3, sigma_anat=0.5
164
+ )
165
+ kernel_op.set_anatomical_image(mr_image)
166
+
167
+
168
+ # 4. Callback: records and prints the objective after each iteration
169
+ class ObjectiveCallback(Callback):
170
+ def __init__(self):
171
+ super().__init__()
172
+ self.values = []
173
+
174
+ def __call__(self, algorithm):
175
+ self.values.append(float(algorithm.loss[-1]))
176
+ print(f"iteration {algorithm.iteration}: objective {self.values[-1]:.4f}")
177
+
178
+
179
+ # 5. Reconstruct (omit kernel_operator for standard RL)
180
+ callback = ObjectiveCallback()
181
+ algo = RichardsonLucy(
182
+ initial_estimate=observed,
183
+ blurring_operator=blur_op,
184
+ observed_data=observed,
185
+ kernel_operator=kernel_op,
186
+ )
187
+ algo.run(iterations=8, callbacks=[callback])
188
+
189
+ reconstruction = algo.get_output()
190
+ assert np.isfinite(reconstruction.as_array()).all()
191
+ assert (reconstruction.as_array() >= 0).all()
192
+ ```
193
+
194
+ Callbacks receive the CIL `Algorithm` object after every iteration. `krl` also
195
+ provides `NRMSECallback` (NRMSE against a ground truth, appended to a CSV file)
196
+ and `SaveIterationCallback` (writes `.nii.gz` snapshots of the reconstruction).
197
+
198
+ Because `KernelOperator` is a plain CIL `LinearOperator`, you can also drop it
199
+ into your own CIL compositions (`CompositionOperator`, custom `Function`s, ...)
200
+ and drive it with any CIL algorithm.
201
+
202
+ ## Backends
203
+
204
+ | Backend | Used by | Hardware | Notes |
205
+ |---------|---------|----------|-------|
206
+ | `numba` | kernel + blur | CPU | used by the quickstart above; kernel arithmetic in float64 |
207
+ | `torch` | kernel + blur | CUDA GPU / Apple MPS | optional (`gpu` extra); device falls back cuda → mps → cpu. The kernel operator's MPS path is verified end-to-end against the numba reference; the blur backend's MPS branch is not verified end-to-end |
208
+ | `scipy` | blur only | CPU | last-resort fallback for the blur operator |
209
+
210
+ - `backend="auto"` is the default for both `get_kernel_operator` and
211
+ `create_gaussian_blur`:
212
+ - on **macOS** it resolves to numba **without importing torch** (see the OpenMP
213
+ caveat above);
214
+ - elsewhere it probes for torch and uses it only when CUDA is available,
215
+ otherwise it falls back to numba (blur: torch → numba → scipy).
216
+ - The torch kernel operator's `device="auto"` resolves cuda → mps → cpu
217
+ (same order as the blur backend); when the device resolves to CPU it runs
218
+ through the numba implementation, so installing torch never makes the
219
+ package CUDA-only.
220
+
221
+ ## Notes
222
+
223
+ ### Boundary conditions
224
+
225
+ - The numba and torch blur backends **zero-pad** at the volume boundary; the
226
+ scipy backend uses **reflection**.
227
+ - The kernel operator uses inclusive mirror reflection of its neighbourhood at
228
+ the volume boundaries on both backends.
229
+
230
+ ### Data type vs computational precision
231
+
232
+ - Operator results are written into a clone of the *input* container, so the
233
+ storage dtype of a result follows the image you passed in (a float32 input
234
+ gives a float32 output, even when the computation used float64).
235
+ - The numba backends accumulate in float64. The torch kernel backend computes in
236
+ float32 by default (`dtype="float64"` selects double), and the torch blur
237
+ backend always computes in float32.
238
+
239
+ ### Aligned images
240
+
241
+ - The anatomical image must be on exactly the same grid as the emission image:
242
+ same shape (validated, a mismatch raises `ValueError`) and voxel-by-voxel
243
+ correspondence. No resampling is performed, so co-register beforehand.
244
+
245
+ ### HKRL (adaptive hybrid) restrictions
246
+
247
+ - The kernel operator supports single-channel 3-D volumes only.
248
+ - With `hybrid=True` the emission reference is taken from the first `direct()`
249
+ call, so `direct()` must run before `adjoint()` — the adjoint raises if the
250
+ reference or the normalisation map has not been initialised.
251
+ - While unfrozen, the reference is refreshed on every forward call and
252
+ `RichardsonLucy` recomputes the sensitivity `A^T 1` each iteration.
253
+ - `freeze_iteration=N` freezes the kernel after the N-th update: from then on
254
+ forward and adjoint share the same frozen reference and the operator no longer
255
+ changes between iterations. Freezing is a modelling choice; no convergence
256
+ rate is claimed for it.
257
+
258
+ ### L-BFGS-B
259
+
260
+ - `LBFGSBOptimizer` is a thin wrapper around `scipy.optimize.minimize(method=
261
+ "L-BFGS-B")` operating on flat arrays. It is **not** a CIL `Algorithm`
262
+ subclass: it has its own `run(iterations, callbacks, verbose)` and its
263
+ callbacks receive the optimiser object rather than a CIL algorithm.
264
+
265
+ ### Image I/O
266
+
267
+ - `load_image` / `save_image` accept only 3-D `.nii` / `.nii.gz` volumes
268
+ (singleton axes are rejected on load). Saved files record voxel values and
269
+ voxel spacing only — no original NIfTI header or affine is preserved.
270
+
271
+ ## Development
272
+
273
+ ```bash
274
+ export PYTHON=$(micromamba run -n krl which python) # interpreter with CIL
275
+ make install # editable install + dev tools (uv)
276
+ make test # CPU test suite
277
+ make lint # ruff check
278
+ make build # sdist + wheel
279
+ ```
280
+
281
+ GPU tests are opt-in via `KRL_RUN_GPU_TESTS=1`: `make gpu-test` runs the CUDA
282
+ modules, while the MPS module is run explicitly (see
283
+ [macOS / ARM](docs/MACOS-ARM.md)).
284
+
285
+ The research pipelines, benchmark scripts and BrainWeb data preparation used in
286
+ the original study live under [`examples/`](examples/README.md): they are
287
+ historical reference material, not part of the installed package and not
288
+ maintained.
289
+
290
+ ## Documentation
291
+
292
+ - [Methods overview](docs/METHODS.md) — RL, KRL, HKRL and DTV via the Python API
293
+ - [Documentation index](docs/README.md)
294
+
295
+ ## Citation
296
+
297
+ If you use cil-krl in your research, please cite it and CIL:
298
+
299
+ ```bibtex
300
+ @software{krl2025,
301
+ author = {Erlandsson, Kjell and Porter, Sam},
302
+ title = {cil-krl: Kernelised Richardson-Lucy Deconvolution for PET},
303
+ year = {2025},
304
+ url = {https://github.com/samdporter/Kernel_RL}
305
+ }
306
+ ```
307
+
308
+ See also the [CIL citation guidelines](https://github.com/TomographicImaging/CIL#citing-cil).
309
+
310
+ ## License
311
+
312
+ cil-krl is MIT-licensed — see [LICENSE](LICENSE).
@@ -0,0 +1,254 @@
1
+ # cil-krl: Kernelised Richardson-Lucy Deconvolution for PET
2
+
3
+ [![CI](https://github.com/samdporter/Kernel_RL/actions/workflows/ci.yml/badge.svg)](https://github.com/samdporter/Kernel_RL/actions/workflows/ci.yml)
4
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
5
+
6
+ **cil-krl** is a plugin for the [Core Imaging Library (CIL)](https://github.com/TomographicImaging/CIL)
7
+ implementing anatomically-guided Richardson-Lucy deconvolution for PET imaging:
8
+
9
+ - **KRL** — kernelised Richardson-Lucy: deconvolution steered by an anatomical image (e.g. MRI) via a kernel operator
10
+ - **HKRL** — hybrid KRL mixing emission and anatomical features, with optional kernel freezing
11
+ - **MAP-RL** — maximum-a-posteriori RL with Armijo line search and preconditioning
12
+ - **DTV** — directional total variation regularisation built on CIL's gradient operators
13
+ - **Backends** — numba CPU backend; optional PyTorch backend for CUDA GPUs and Apple MPS
14
+
15
+ Everything is built directly on CIL's optimisation framework: operators subclass
16
+ `cil.optimisation.operators.LinearOperator`, algorithms subclass
17
+ `cil.optimisation.algorithms.Algorithm`, so they compose with the rest of the CIL
18
+ ecosystem (callbacks, functions, block operators, ...).
19
+
20
+ ## Installation
21
+
22
+ `cil-krl` is **not published on PyPI**, so `pip install cil-krl` does not work.
23
+ Install CIL first — it is distributed via conda (conda-forge + ccpi channels),
24
+ not PyPI — then install this package from a checkout or from a wheel you build
25
+ yourself.
26
+
27
+ Combinations tested in CI:
28
+
29
+ | Python | CIL |
30
+ |--------|-----|
31
+ | 3.10 | 25.0.0 |
32
+ | 3.11, 3.12, 3.13 | 26.0.0 |
33
+
34
+ The wheel-install gate in CI builds and installs the wheel on Python 3.11 /
35
+ CIL 26.0.0, which resolved to NumPy 2.4 and Numba 0.68 on CPU. The CUDA/torch
36
+ tests are opt-in and skip without a GPU, so they are not covered by that gate.
37
+
38
+ ### Linux
39
+
40
+ ```bash
41
+ # 1. Create an environment with CIL (conda or micromamba)
42
+ conda create -n krl -c conda-forge -c ccpi python=3.11 cil=26.0.0 pip
43
+ conda activate krl
44
+
45
+ # 2. Install cil-krl from a checkout
46
+ git clone https://github.com/samdporter/Kernel_RL.git && cd Kernel_RL
47
+ pip install -e ".[dev]" # + pytest, ruff
48
+ # pip install -e ".[gpu]" # optional: adds PyTorch for the torch backend
49
+ ```
50
+
51
+ Alternatively build a wheel in the checkout with `make build` and install it with
52
+ `pip install dist/*.whl`.
53
+
54
+ ### macOS
55
+
56
+ CIL has no `osx-arm64` build, so run the whole thing in a Linux (x86_64)
57
+ container, from the repository root:
58
+
59
+ ```bash
60
+ docker run --rm --platform linux/amd64 -v "$PWD:/repo:ro" --workdir /tmp \
61
+ --entrypoint /bin/bash mambaorg/micromamba:2.3.2 -lc '
62
+ micromamba create -y -q -n krl -c conda-forge -c ccpi python=3.11 cil=26.0.0 pip &&
63
+ cp -R /repo /tmp/krl &&
64
+ micromamba run -n krl python -m pip install -e "/tmp/krl[dev]" &&
65
+ micromamba run -n krl python -c "import krl; print(krl.__version__)"'
66
+ ```
67
+
68
+ > **macOS / OpenMP caveat:** importing PyTorch into a process that also uses
69
+ > CIL's native acceleration libraries can abort due to duplicate OpenMP runtimes.
70
+ > On macOS keep to the numba backend — `backend="auto"` resolves to numba there
71
+ > *without importing torch* — or run torch-based work in a separate process.
72
+
73
+ A native Apple Silicon route also works: CIL built from source plus the torch
74
+ backend on MPS (with a documented OpenMP workaround). See
75
+ [macOS / ARM](docs/MACOS-ARM.md) for the verified setup and test commands.
76
+
77
+ ## Quickstart
78
+
79
+ A complete, self-contained CPU example: a synthetic emission image, a synthetic
80
+ anatomical image, the observed blurred data and a callback. Every variable is
81
+ defined below, so the snippet runs as-is.
82
+
83
+ ```python
84
+ import numpy as np
85
+ from cil.framework import ImageGeometry
86
+ from cil.optimisation.utilities.callbacks import Callback
87
+
88
+ from krl import RichardsonLucy, create_gaussian_blur, get_kernel_operator
89
+
90
+ # 1. Geometry and two aligned synthetic images
91
+ geometry = ImageGeometry(voxel_num_x=32, voxel_num_y=32, voxel_num_z=16)
92
+
93
+ z, y, x = np.indices((16, 32, 32), dtype=np.float32)
94
+ emission = geometry.allocate(0.0)
95
+ emission.fill(50.0 * np.exp(-((z - 8) ** 2 + (y - 12) ** 2 + (x - 14) ** 2) / 12.0))
96
+ mr_image = geometry.allocate(0.0)
97
+ mr_image.fill(np.exp(-((z - 6) ** 2 + (y - 20) ** 2 + (x - 18) ** 2) / 8.0))
98
+
99
+ # 2. Observed data: the emission image blurred by the PSF
100
+ blur_op = create_gaussian_blur(sigma=(1.5, 1.5, 1.5), geometry=geometry, backend="numba")
101
+ observed = blur_op.direct(emission)
102
+
103
+ # 3. Anatomical guidance operator
104
+ kernel_op = get_kernel_operator(
105
+ geometry, backend="numba", num_neighbours=3, sigma_anat=0.5
106
+ )
107
+ kernel_op.set_anatomical_image(mr_image)
108
+
109
+
110
+ # 4. Callback: records and prints the objective after each iteration
111
+ class ObjectiveCallback(Callback):
112
+ def __init__(self):
113
+ super().__init__()
114
+ self.values = []
115
+
116
+ def __call__(self, algorithm):
117
+ self.values.append(float(algorithm.loss[-1]))
118
+ print(f"iteration {algorithm.iteration}: objective {self.values[-1]:.4f}")
119
+
120
+
121
+ # 5. Reconstruct (omit kernel_operator for standard RL)
122
+ callback = ObjectiveCallback()
123
+ algo = RichardsonLucy(
124
+ initial_estimate=observed,
125
+ blurring_operator=blur_op,
126
+ observed_data=observed,
127
+ kernel_operator=kernel_op,
128
+ )
129
+ algo.run(iterations=8, callbacks=[callback])
130
+
131
+ reconstruction = algo.get_output()
132
+ assert np.isfinite(reconstruction.as_array()).all()
133
+ assert (reconstruction.as_array() >= 0).all()
134
+ ```
135
+
136
+ Callbacks receive the CIL `Algorithm` object after every iteration. `krl` also
137
+ provides `NRMSECallback` (NRMSE against a ground truth, appended to a CSV file)
138
+ and `SaveIterationCallback` (writes `.nii.gz` snapshots of the reconstruction).
139
+
140
+ Because `KernelOperator` is a plain CIL `LinearOperator`, you can also drop it
141
+ into your own CIL compositions (`CompositionOperator`, custom `Function`s, ...)
142
+ and drive it with any CIL algorithm.
143
+
144
+ ## Backends
145
+
146
+ | Backend | Used by | Hardware | Notes |
147
+ |---------|---------|----------|-------|
148
+ | `numba` | kernel + blur | CPU | used by the quickstart above; kernel arithmetic in float64 |
149
+ | `torch` | kernel + blur | CUDA GPU / Apple MPS | optional (`gpu` extra); device falls back cuda → mps → cpu. The kernel operator's MPS path is verified end-to-end against the numba reference; the blur backend's MPS branch is not verified end-to-end |
150
+ | `scipy` | blur only | CPU | last-resort fallback for the blur operator |
151
+
152
+ - `backend="auto"` is the default for both `get_kernel_operator` and
153
+ `create_gaussian_blur`:
154
+ - on **macOS** it resolves to numba **without importing torch** (see the OpenMP
155
+ caveat above);
156
+ - elsewhere it probes for torch and uses it only when CUDA is available,
157
+ otherwise it falls back to numba (blur: torch → numba → scipy).
158
+ - The torch kernel operator's `device="auto"` resolves cuda → mps → cpu
159
+ (same order as the blur backend); when the device resolves to CPU it runs
160
+ through the numba implementation, so installing torch never makes the
161
+ package CUDA-only.
162
+
163
+ ## Notes
164
+
165
+ ### Boundary conditions
166
+
167
+ - The numba and torch blur backends **zero-pad** at the volume boundary; the
168
+ scipy backend uses **reflection**.
169
+ - The kernel operator uses inclusive mirror reflection of its neighbourhood at
170
+ the volume boundaries on both backends.
171
+
172
+ ### Data type vs computational precision
173
+
174
+ - Operator results are written into a clone of the *input* container, so the
175
+ storage dtype of a result follows the image you passed in (a float32 input
176
+ gives a float32 output, even when the computation used float64).
177
+ - The numba backends accumulate in float64. The torch kernel backend computes in
178
+ float32 by default (`dtype="float64"` selects double), and the torch blur
179
+ backend always computes in float32.
180
+
181
+ ### Aligned images
182
+
183
+ - The anatomical image must be on exactly the same grid as the emission image:
184
+ same shape (validated, a mismatch raises `ValueError`) and voxel-by-voxel
185
+ correspondence. No resampling is performed, so co-register beforehand.
186
+
187
+ ### HKRL (adaptive hybrid) restrictions
188
+
189
+ - The kernel operator supports single-channel 3-D volumes only.
190
+ - With `hybrid=True` the emission reference is taken from the first `direct()`
191
+ call, so `direct()` must run before `adjoint()` — the adjoint raises if the
192
+ reference or the normalisation map has not been initialised.
193
+ - While unfrozen, the reference is refreshed on every forward call and
194
+ `RichardsonLucy` recomputes the sensitivity `A^T 1` each iteration.
195
+ - `freeze_iteration=N` freezes the kernel after the N-th update: from then on
196
+ forward and adjoint share the same frozen reference and the operator no longer
197
+ changes between iterations. Freezing is a modelling choice; no convergence
198
+ rate is claimed for it.
199
+
200
+ ### L-BFGS-B
201
+
202
+ - `LBFGSBOptimizer` is a thin wrapper around `scipy.optimize.minimize(method=
203
+ "L-BFGS-B")` operating on flat arrays. It is **not** a CIL `Algorithm`
204
+ subclass: it has its own `run(iterations, callbacks, verbose)` and its
205
+ callbacks receive the optimiser object rather than a CIL algorithm.
206
+
207
+ ### Image I/O
208
+
209
+ - `load_image` / `save_image` accept only 3-D `.nii` / `.nii.gz` volumes
210
+ (singleton axes are rejected on load). Saved files record voxel values and
211
+ voxel spacing only — no original NIfTI header or affine is preserved.
212
+
213
+ ## Development
214
+
215
+ ```bash
216
+ export PYTHON=$(micromamba run -n krl which python) # interpreter with CIL
217
+ make install # editable install + dev tools (uv)
218
+ make test # CPU test suite
219
+ make lint # ruff check
220
+ make build # sdist + wheel
221
+ ```
222
+
223
+ GPU tests are opt-in via `KRL_RUN_GPU_TESTS=1`: `make gpu-test` runs the CUDA
224
+ modules, while the MPS module is run explicitly (see
225
+ [macOS / ARM](docs/MACOS-ARM.md)).
226
+
227
+ The research pipelines, benchmark scripts and BrainWeb data preparation used in
228
+ the original study live under [`examples/`](examples/README.md): they are
229
+ historical reference material, not part of the installed package and not
230
+ maintained.
231
+
232
+ ## Documentation
233
+
234
+ - [Methods overview](docs/METHODS.md) — RL, KRL, HKRL and DTV via the Python API
235
+ - [Documentation index](docs/README.md)
236
+
237
+ ## Citation
238
+
239
+ If you use cil-krl in your research, please cite it and CIL:
240
+
241
+ ```bibtex
242
+ @software{krl2025,
243
+ author = {Erlandsson, Kjell and Porter, Sam},
244
+ title = {cil-krl: Kernelised Richardson-Lucy Deconvolution for PET},
245
+ year = {2025},
246
+ url = {https://github.com/samdporter/Kernel_RL}
247
+ }
248
+ ```
249
+
250
+ See also the [CIL citation guidelines](https://github.com/TomographicImaging/CIL#citing-cil).
251
+
252
+ ## License
253
+
254
+ cil-krl is MIT-licensed — see [LICENSE](LICENSE).
@@ -0,0 +1,94 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cil-krl"
7
+ version = "0.3.0"
8
+ description = "Kernelised Richardson-Lucy deconvolution for PET with anatomical guidance, built as a CIL plugin"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {file = "LICENSE"}
12
+ authors = [
13
+ {name = "Kjell Erlandsson", email = "k.erlandsson@ucl.ac.uk"},
14
+ {name = "Sam Porter", email = "sam.porter.18@ucl.ac.uk"},
15
+ ]
16
+ keywords = [
17
+ "PET",
18
+ "deconvolution",
19
+ "Richardson-Lucy",
20
+ "CIL",
21
+ "medical imaging",
22
+ "image reconstruction",
23
+ "kernel methods",
24
+ ]
25
+ classifiers = [
26
+ "Development Status :: 3 - Alpha",
27
+ "Intended Audience :: Science/Research",
28
+ "Topic :: Scientific/Engineering :: Medical Science Apps.",
29
+ "Topic :: Scientific/Engineering :: Image Processing",
30
+ "License :: OSI Approved :: MIT License",
31
+ "Programming Language :: Python :: 3",
32
+ "Programming Language :: Python :: 3.10",
33
+ "Programming Language :: Python :: 3.11",
34
+ "Programming Language :: Python :: 3.12",
35
+ "Programming Language :: Python :: 3.13",
36
+ ]
37
+
38
+ # NOTE: CIL is a runtime requirement but is not pip-installable from PyPI.
39
+ # Install CIL first (conda: `conda install -c conda-forge -c ccpi cil`), then
40
+ # install this package from source (`pip install -e .`) or from a built wheel;
41
+ # it is not published on PyPI either.
42
+ dependencies = [
43
+ "numpy>=1.23",
44
+ "scipy>=1.7",
45
+ "nibabel>=3.0",
46
+ "numba>=0.55",
47
+ ]
48
+
49
+ [project.optional-dependencies]
50
+ gpu = [
51
+ "torch>=1.9",
52
+ ]
53
+ dev = [
54
+ "pytest>=7.0",
55
+ "pytest-cov>=3.0",
56
+ "ruff>=0.4",
57
+ ]
58
+ all = [
59
+ "cil-krl[gpu,dev]",
60
+ ]
61
+
62
+ [project.urls]
63
+ Homepage = "https://github.com/samdporter/Kernel_RL"
64
+ Repository = "https://github.com/samdporter/Kernel_RL"
65
+ Issues = "https://github.com/samdporter/Kernel_RL/issues"
66
+
67
+ [tool.setuptools]
68
+ package-dir = {"" = "src"}
69
+
70
+ [tool.setuptools.packages.find]
71
+ where = ["src"]
72
+ include = ["krl*"]
73
+
74
+ [tool.pytest.ini_options]
75
+ testpaths = ["tests"]
76
+ python_files = ["test_*.py"]
77
+ python_classes = ["Test*"]
78
+ python_functions = ["test_*"]
79
+ addopts = "-q --tb=short"
80
+ markers = [
81
+ "gpu: tests requiring an NVIDIA GPU with CUDA (deselected by default)",
82
+ ]
83
+
84
+ [tool.ruff]
85
+ line-length = 120
86
+ target-version = "py310"
87
+ exclude = ["examples"]
88
+
89
+ [tool.ruff.lint]
90
+ select = ["E", "F", "W", "I"]
91
+ ignore = [
92
+ "E402", # torch-guarded imports in the GPU operator module
93
+ "F841", # captured intermediate results in tests
94
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+