tomorecon 0.1.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.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .ipynb_checkpoints/
4
+ .pytest_cache/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .venv/
9
+ venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 GivralNguyen
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.
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.5
2
+ Name: tomorecon
3
+ Version: 0.1.0
4
+ Summary: Analytical tomographic image reconstruction: Radon transform, Fourier gridding, filtered backprojection, backproject-filter and convolve-backproject.
5
+ Project-URL: Homepage, https://github.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction
6
+ Author: GivralNguyen
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: computed tomography,filtered backprojection,image reconstruction,radon transform,tomography
10
+ Classifier: Intended Audience :: Education
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
15
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
16
+ Requires-Python: >=3.9
17
+ Requires-Dist: imageio>=2.16
18
+ Requires-Dist: numpy>=1.21
19
+ Requires-Dist: scikit-image>=0.19
20
+ Requires-Dist: scipy>=1.8
21
+ Provides-Extra: notebook
22
+ Requires-Dist: jupyter; extra == 'notebook'
23
+ Requires-Dist: matplotlib; extra == 'notebook'
24
+ Provides-Extra: test
25
+ Requires-Dist: pytest; extra == 'test'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # Analytical Tomographic Image Reconstruction
29
+
30
+ Welcome to the Analytical Tomographic Image Reconstruction repository! This collection of Python code provides implementations for various analytical tomographic image reconstruction algorithms. These algorithms play a crucial role in transforming projection data into detailed images, making them valuable tools in medical imaging, materials science, and more. Included are **Fourier Gridding**, **Backproject Filter**, **Filtered Backprojection** and **Convolve Backproject**.
31
+
32
+ ![Reconstructions of the Shepp-Logan phantom with each method](https://raw.githubusercontent.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/main/images/overview.png)
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install git+https://github.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction.git
38
+ ```
39
+
40
+ Or from a local clone (editable, with the extras for the notebook and tests):
41
+
42
+ ```bash
43
+ git clone https://github.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction.git
44
+ cd Python-Medical-Analytical-Tomographic-Image-Reconstruction
45
+ pip install -e ".[notebook,test]"
46
+ ```
47
+
48
+ Requires Python ≥ 3.9; dependencies (numpy, scipy, scikit-image, imageio) are installed automatically.
49
+
50
+ ## Quick Start
51
+
52
+ ```python
53
+ import numpy as np
54
+ import tomorecon as tr
55
+
56
+ phantom = tr.shepp_logan() # 256 x 256, bundled with the package
57
+ theta = np.arange(180) # projection angles in degrees
58
+ sinogram = tr.radon(phantom, theta) # shape (256, 180): one projection per column
59
+
60
+ image = tr.fbp(sinogram, theta) # filtered backprojection
61
+ image = tr.fbp(sinogram, theta, filter="hann") # 'ramp', 'shepp-logan', 'cosine', 'hamming', 'hann'
62
+ image = tr.cbp(sinogram, theta) # convolve-backproject (spatial-domain filtering)
63
+ image = tr.bpf(sinogram, theta) # backproject-filter (2-D cone filter)
64
+ image = tr.fourier_gridding(sinogram, theta) # direct Fourier reconstruction
65
+ image = tr.backproject(sinogram, theta) # unfiltered backprojection (blurred)
66
+ ```
67
+
68
+ Every step is also available on its own, e.g. `tr.filter_sinogram`, `tr.convolve_sinogram`, `tr.spatial_kernel`, `tr.cone_filter`, `tr.projection_spectra`, `tr.polar_to_cartesian` and `tr.inverse_fft2`. See the [tutorial](https://github.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/blob/main/tutorial.ipynb) for how they fit together.
69
+
70
+ ### API
71
+
72
+ | Function | Description |
73
+ |---|---|
74
+ | `shepp_logan(size=256)`, `disk(...)`, `load_image(path, size=256)` | Test objects |
75
+ | `radon(image, theta=None, circle=True)` | Sinogram, shape `(n_detectors, n_angles)` |
76
+ | `backproject(sinogram, theta=None, output_size=None, interval=1)` | Unfiltered backprojection |
77
+ | `fbp(sinogram, theta=None, filter="ramp", output_size=None)` | Filtered backprojection |
78
+ | `cbp(sinogram, theta=None, filter="ramp", output_size=None)` | Convolve-backproject |
79
+ | `bpf(sinogram, theta=None, filter="ramp", output_size=None, grid_factor=2)` | Backproject-filter |
80
+ | `fourier_gridding(sinogram, theta=None, output_size=None, oversample=4)` | Direct Fourier reconstruction |
81
+
82
+ Angles are in degrees; if `theta` is omitted, the projections are assumed equally spaced over [0°, 180°).
83
+
84
+ ## Sample Results
85
+
86
+ All images below are produced from the 256×256 Shepp-Logan phantom with 180 projections (0°–179°), using the code in this repository. See [tutorial.ipynb](https://github.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/blob/main/tutorial.ipynb) for the full walkthrough.
87
+
88
+ ### Radon Transform
89
+
90
+ Each column of the sinogram is one projection: the line integrals of the object along parallel rays at angle θ.
91
+
92
+ ![Phantom and its sinogram](https://raw.githubusercontent.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/main/images/radon.png)
93
+
94
+ ### Fourier Gridding
95
+
96
+ By the Fourier slice theorem, the 1-D Fourier transform of a projection at angle θ is a radial line through the 2-D Fourier transform of the object. The slices are interpolated from the polar grid onto a Cartesian grid, and an inverse 2-D FFT gives the image. The projections are zero-padded 4× first (oversampling), which keeps the interpolation error small.
97
+
98
+ ![Fourier gridding: 1-D FT of projections, interpolated 2-D spectrum, reconstruction](https://raw.githubusercontent.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/main/images/fourier_gridding.png)
99
+
100
+ ### Backprojection
101
+
102
+ Smearing every projection back across the image plane. With few angles the streaks are obvious; with all 180 angles the result is the object blurred by 1/r.
103
+
104
+ ![Unfiltered backprojection with 1, 2, 10, 45 and 180 projections](https://raw.githubusercontent.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/main/images/backprojection.png)
105
+
106
+ ### Backproject Filter
107
+
108
+ The 1/r blur of the backprojection is removed afterwards in 2-D: multiply its spectrum by the cone filter |ρ| and transform back. The backprojection is computed on a 2× larger grid, since the blur extends far outside the object.
109
+
110
+ ![Backproject-filter: backprojection, spectrum before and after the cone filter, result](https://raw.githubusercontent.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/main/images/backproject_filter.png)
111
+
112
+ ### Filtered Backprojection
113
+
114
+ Each projection is filtered with a 1-D ramp filter |ω| (optionally apodized) before backprojection. Smoother windows trade resolution for less noise.
115
+
116
+ ![Filter responses, filtered sinogram, and FBP with each filter](https://raw.githubusercontent.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/main/images/filtered_backprojection.png)
117
+
118
+ ### Convolve Backproject
119
+
120
+ The same filtering as FBP, but done in the spatial domain by convolving each projection with the inverse Fourier transform of the filter. The result is identical to FBP up to floating-point error.
121
+
122
+ ![Spatial kernels, convolved sinogram, CBP reconstruction](https://raw.githubusercontent.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/main/images/convolve_backproject.png)
123
+
124
+ ## Code Structure
125
+
126
+ The package lives in `src/tomorecon/`:
127
+
128
+ | Module | Contents |
129
+ |---|---|
130
+ | `phantoms.py` | `shepp_logan`, `disk`, `load_image` |
131
+ | `projection.py` | `radon`: the forward model |
132
+ | `backprojection.py` | `backproject`, `fbp`, `cbp`, `bpf` |
133
+ | `filters.py` | Ramp-type filters (`fourier_filter`), their spatial kernels (`spatial_kernel`), `filter_sinogram`, `convolve_sinogram` and the 2-D `cone_filter` |
134
+ | `gridding.py` | `fourier_gridding` and its steps: `projection_spectra`, `polar_to_cartesian`, `inverse_fft2`, `polar_grid`, `cartesian_grid` |
135
+
136
+ Outside the package:
137
+
138
+ - **tutorial.ipynb**: Jupyter notebook demonstrating the usage of all reconstruction methods with examples and visualizations.
139
+ - **tests/**: checks that every method reconstructs the phantom accurately (run `pytest`).
140
+ - **data/**: extra sample images for `load_image`.
141
+
142
+ ## Tutorial
143
+
144
+ After installing with the `notebook` extra, explore the provided [tutorial.ipynb](https://github.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/blob/main/tutorial.ipynb) notebook for comprehensive examples and visualizations of each reconstruction method. You can experiment with different data sources and filters to see how these algorithms perform under various conditions.
145
+
146
+ ## Contributing
147
+
148
+ We welcome contributions to enhance and expand this repository. If you have improvements, additional algorithms, or new features to suggest, please feel free to open an issue or submit a pull request.
149
+
150
+ ## Acknowledgments
151
+
152
+ Special thanks to me for writing this and Prof. Lasser for the wonderful Seminar course
153
+
154
+ ## License
155
+
156
+ This code is released under the [MIT License](https://github.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/blob/main/LICENSE). Feel free to use, modify, and distribute the code in accordance with the terms of the license.
157
+
158
+ Happy reconstructing! 🌐🖼️
@@ -0,0 +1,131 @@
1
+ # Analytical Tomographic Image Reconstruction
2
+
3
+ Welcome to the Analytical Tomographic Image Reconstruction repository! This collection of Python code provides implementations for various analytical tomographic image reconstruction algorithms. These algorithms play a crucial role in transforming projection data into detailed images, making them valuable tools in medical imaging, materials science, and more. Included are **Fourier Gridding**, **Backproject Filter**, **Filtered Backprojection** and **Convolve Backproject**.
4
+
5
+ ![Reconstructions of the Shepp-Logan phantom with each method](https://raw.githubusercontent.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/main/images/overview.png)
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install git+https://github.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction.git
11
+ ```
12
+
13
+ Or from a local clone (editable, with the extras for the notebook and tests):
14
+
15
+ ```bash
16
+ git clone https://github.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction.git
17
+ cd Python-Medical-Analytical-Tomographic-Image-Reconstruction
18
+ pip install -e ".[notebook,test]"
19
+ ```
20
+
21
+ Requires Python ≥ 3.9; dependencies (numpy, scipy, scikit-image, imageio) are installed automatically.
22
+
23
+ ## Quick Start
24
+
25
+ ```python
26
+ import numpy as np
27
+ import tomorecon as tr
28
+
29
+ phantom = tr.shepp_logan() # 256 x 256, bundled with the package
30
+ theta = np.arange(180) # projection angles in degrees
31
+ sinogram = tr.radon(phantom, theta) # shape (256, 180): one projection per column
32
+
33
+ image = tr.fbp(sinogram, theta) # filtered backprojection
34
+ image = tr.fbp(sinogram, theta, filter="hann") # 'ramp', 'shepp-logan', 'cosine', 'hamming', 'hann'
35
+ image = tr.cbp(sinogram, theta) # convolve-backproject (spatial-domain filtering)
36
+ image = tr.bpf(sinogram, theta) # backproject-filter (2-D cone filter)
37
+ image = tr.fourier_gridding(sinogram, theta) # direct Fourier reconstruction
38
+ image = tr.backproject(sinogram, theta) # unfiltered backprojection (blurred)
39
+ ```
40
+
41
+ Every step is also available on its own, e.g. `tr.filter_sinogram`, `tr.convolve_sinogram`, `tr.spatial_kernel`, `tr.cone_filter`, `tr.projection_spectra`, `tr.polar_to_cartesian` and `tr.inverse_fft2`. See the [tutorial](https://github.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/blob/main/tutorial.ipynb) for how they fit together.
42
+
43
+ ### API
44
+
45
+ | Function | Description |
46
+ |---|---|
47
+ | `shepp_logan(size=256)`, `disk(...)`, `load_image(path, size=256)` | Test objects |
48
+ | `radon(image, theta=None, circle=True)` | Sinogram, shape `(n_detectors, n_angles)` |
49
+ | `backproject(sinogram, theta=None, output_size=None, interval=1)` | Unfiltered backprojection |
50
+ | `fbp(sinogram, theta=None, filter="ramp", output_size=None)` | Filtered backprojection |
51
+ | `cbp(sinogram, theta=None, filter="ramp", output_size=None)` | Convolve-backproject |
52
+ | `bpf(sinogram, theta=None, filter="ramp", output_size=None, grid_factor=2)` | Backproject-filter |
53
+ | `fourier_gridding(sinogram, theta=None, output_size=None, oversample=4)` | Direct Fourier reconstruction |
54
+
55
+ Angles are in degrees; if `theta` is omitted, the projections are assumed equally spaced over [0°, 180°).
56
+
57
+ ## Sample Results
58
+
59
+ All images below are produced from the 256×256 Shepp-Logan phantom with 180 projections (0°–179°), using the code in this repository. See [tutorial.ipynb](https://github.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/blob/main/tutorial.ipynb) for the full walkthrough.
60
+
61
+ ### Radon Transform
62
+
63
+ Each column of the sinogram is one projection: the line integrals of the object along parallel rays at angle θ.
64
+
65
+ ![Phantom and its sinogram](https://raw.githubusercontent.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/main/images/radon.png)
66
+
67
+ ### Fourier Gridding
68
+
69
+ By the Fourier slice theorem, the 1-D Fourier transform of a projection at angle θ is a radial line through the 2-D Fourier transform of the object. The slices are interpolated from the polar grid onto a Cartesian grid, and an inverse 2-D FFT gives the image. The projections are zero-padded 4× first (oversampling), which keeps the interpolation error small.
70
+
71
+ ![Fourier gridding: 1-D FT of projections, interpolated 2-D spectrum, reconstruction](https://raw.githubusercontent.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/main/images/fourier_gridding.png)
72
+
73
+ ### Backprojection
74
+
75
+ Smearing every projection back across the image plane. With few angles the streaks are obvious; with all 180 angles the result is the object blurred by 1/r.
76
+
77
+ ![Unfiltered backprojection with 1, 2, 10, 45 and 180 projections](https://raw.githubusercontent.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/main/images/backprojection.png)
78
+
79
+ ### Backproject Filter
80
+
81
+ The 1/r blur of the backprojection is removed afterwards in 2-D: multiply its spectrum by the cone filter |ρ| and transform back. The backprojection is computed on a 2× larger grid, since the blur extends far outside the object.
82
+
83
+ ![Backproject-filter: backprojection, spectrum before and after the cone filter, result](https://raw.githubusercontent.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/main/images/backproject_filter.png)
84
+
85
+ ### Filtered Backprojection
86
+
87
+ Each projection is filtered with a 1-D ramp filter |ω| (optionally apodized) before backprojection. Smoother windows trade resolution for less noise.
88
+
89
+ ![Filter responses, filtered sinogram, and FBP with each filter](https://raw.githubusercontent.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/main/images/filtered_backprojection.png)
90
+
91
+ ### Convolve Backproject
92
+
93
+ The same filtering as FBP, but done in the spatial domain by convolving each projection with the inverse Fourier transform of the filter. The result is identical to FBP up to floating-point error.
94
+
95
+ ![Spatial kernels, convolved sinogram, CBP reconstruction](https://raw.githubusercontent.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/main/images/convolve_backproject.png)
96
+
97
+ ## Code Structure
98
+
99
+ The package lives in `src/tomorecon/`:
100
+
101
+ | Module | Contents |
102
+ |---|---|
103
+ | `phantoms.py` | `shepp_logan`, `disk`, `load_image` |
104
+ | `projection.py` | `radon`: the forward model |
105
+ | `backprojection.py` | `backproject`, `fbp`, `cbp`, `bpf` |
106
+ | `filters.py` | Ramp-type filters (`fourier_filter`), their spatial kernels (`spatial_kernel`), `filter_sinogram`, `convolve_sinogram` and the 2-D `cone_filter` |
107
+ | `gridding.py` | `fourier_gridding` and its steps: `projection_spectra`, `polar_to_cartesian`, `inverse_fft2`, `polar_grid`, `cartesian_grid` |
108
+
109
+ Outside the package:
110
+
111
+ - **tutorial.ipynb**: Jupyter notebook demonstrating the usage of all reconstruction methods with examples and visualizations.
112
+ - **tests/**: checks that every method reconstructs the phantom accurately (run `pytest`).
113
+ - **data/**: extra sample images for `load_image`.
114
+
115
+ ## Tutorial
116
+
117
+ After installing with the `notebook` extra, explore the provided [tutorial.ipynb](https://github.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/blob/main/tutorial.ipynb) notebook for comprehensive examples and visualizations of each reconstruction method. You can experiment with different data sources and filters to see how these algorithms perform under various conditions.
118
+
119
+ ## Contributing
120
+
121
+ We welcome contributions to enhance and expand this repository. If you have improvements, additional algorithms, or new features to suggest, please feel free to open an issue or submit a pull request.
122
+
123
+ ## Acknowledgments
124
+
125
+ Special thanks to me for writing this and Prof. Lasser for the wonderful Seminar course
126
+
127
+ ## License
128
+
129
+ This code is released under the [MIT License](https://github.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction/blob/main/LICENSE). Feel free to use, modify, and distribute the code in accordance with the terms of the license.
130
+
131
+ Happy reconstructing! 🌐🖼️
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tomorecon"
7
+ version = "0.1.0"
8
+ description = "Analytical tomographic image reconstruction: Radon transform, Fourier gridding, filtered backprojection, backproject-filter and convolve-backproject."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "GivralNguyen" }]
14
+ keywords = ["tomography", "computed tomography", "image reconstruction", "radon transform", "filtered backprojection"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: OS Independent",
18
+ "Intended Audience :: Science/Research",
19
+ "Intended Audience :: Education",
20
+ "Topic :: Scientific/Engineering :: Image Processing",
21
+ "Topic :: Scientific/Engineering :: Medical Science Apps.",
22
+ ]
23
+ dependencies = [
24
+ "numpy>=1.21",
25
+ "scipy>=1.8",
26
+ "scikit-image>=0.19",
27
+ "imageio>=2.16",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ notebook = ["matplotlib", "jupyter"]
32
+ test = ["pytest"]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/GivralNguyen/Python-Medical-Analytical-Tomographic-Image-Reconstruction"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/tomorecon"]
39
+
40
+ [tool.hatch.build.targets.sdist]
41
+ include = ["src/tomorecon", "tests", "README.md", "LICENSE", "pyproject.toml"]
@@ -0,0 +1,52 @@
1
+ """
2
+ tomorecon: analytical tomographic image reconstruction.
3
+
4
+ Radon transform, Fourier gridding, (filtered) backprojection, backproject-filter
5
+ and convolve-backproject for 2-D parallel-beam tomography.
6
+
7
+ Example
8
+ -------
9
+ >>> import tomorecon as tr
10
+ >>> phantom = tr.shepp_logan()
11
+ >>> sinogram = tr.radon(phantom) # 180 angles, 0..179 degrees
12
+ >>> image = tr.fbp(sinogram) # ramp-filtered backprojection
13
+ """
14
+
15
+ from .backprojection import backproject, bpf, cbp, fbp
16
+ from .filters import (
17
+ FILTERS,
18
+ cone_filter,
19
+ convolve_sinogram,
20
+ filter_sinogram,
21
+ fourier_filter,
22
+ padded_size,
23
+ spatial_kernel,
24
+ )
25
+ from .gridding import (
26
+ cartesian_grid,
27
+ fourier_gridding,
28
+ frequency_axis,
29
+ inverse_fft2,
30
+ polar_grid,
31
+ polar_to_cartesian,
32
+ projection_spectra,
33
+ )
34
+ from .phantoms import disk, load_image, shepp_logan
35
+ from .projection import radon
36
+
37
+ __version__ = "0.1.0"
38
+
39
+ __all__ = [
40
+ # data
41
+ "shepp_logan", "disk", "load_image",
42
+ # forward model
43
+ "radon",
44
+ # reconstruction
45
+ "backproject", "fbp", "cbp", "bpf", "fourier_gridding",
46
+ # filters
47
+ "FILTERS", "fourier_filter", "spatial_kernel", "filter_sinogram", "convolve_sinogram",
48
+ "cone_filter", "padded_size",
49
+ # Fourier gridding steps
50
+ "projection_spectra", "polar_to_cartesian", "inverse_fft2", "polar_grid", "cartesian_grid",
51
+ "frequency_axis",
52
+ ]
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ from skimage.util import img_as_float
5
+
6
+
7
+ def as_float_image(image) -> np.ndarray:
8
+ """Image as float; integer/bool images are rescaled to [0, 1] like skimage's `img_as_float`."""
9
+ image = np.asarray(image)
10
+ if image.dtype == np.float16:
11
+ return image.astype(np.float32)
12
+ return img_as_float(image)
13
+
14
+
15
+ def as_float_data(data) -> np.ndarray:
16
+ """Measured data (e.g. a sinogram) as float, keeping its values."""
17
+ data = np.asarray(data)
18
+ if data.dtype.char not in "df":
19
+ data = data.astype(np.float32 if data.dtype == np.float16 else float)
20
+ return data
21
+
22
+
23
+ def default_theta(n_angles: int) -> np.ndarray:
24
+ """`n_angles` equally spaced angles in [0, 180) degrees."""
25
+ return np.linspace(0, 180, n_angles, endpoint=False)
26
+
27
+
28
+ def center_crop(image: np.ndarray, size: int) -> np.ndarray:
29
+ """Centred size x size crop of a square image (centre pixel at index shape // 2)."""
30
+ start = image.shape[0] // 2 - size // 2
31
+ return image[start:start + size, start:start + size]
@@ -0,0 +1,95 @@
1
+ """Backprojection-based reconstruction: BP, FBP, CBP and BPF."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+
6
+ from ._utils import as_float_data, default_theta
7
+ from .filters import cone_filter, convolve_sinogram, filter_sinogram
8
+
9
+ __all__ = ["backproject", "fbp", "cbp", "bpf"]
10
+
11
+
12
+ def _angles(sinogram, theta):
13
+ if theta is None:
14
+ return default_theta(sinogram.shape[1])
15
+ theta = np.asarray(theta, dtype=float)
16
+ if theta.shape != (sinogram.shape[1],):
17
+ raise ValueError(f"theta has {theta.size} angles but the sinogram has {sinogram.shape[1]} columns")
18
+ return theta
19
+
20
+
21
+ def backproject(sinogram: np.ndarray, theta: np.ndarray | None = None,
22
+ output_size: int | None = None, interval: int = 1) -> np.ndarray:
23
+ """
24
+ Unfiltered backprojection: smear every projection back across the image plane.
25
+
26
+ Parameters
27
+ ----------
28
+ sinogram : ndarray of shape (n_detectors, n_angles)
29
+ As returned by `radon`; rotation centre at row n_detectors // 2.
30
+ theta : 1-D array, optional
31
+ Projection angles in degrees. Default: n_angles equally spaced angles in [0, 180).
32
+ output_size : int, optional
33
+ Side of the reconstructed image. Default: n_detectors.
34
+ interval : int
35
+ Use only every `interval`-th projection (to show the effect of fewer angles).
36
+
37
+ Returns
38
+ -------
39
+ ndarray of shape (output_size, output_size)
40
+ Scaled by pi / (2 * angles used), so that backprojecting a ramp-filtered
41
+ sinogram reconstructs the object at its original intensity.
42
+ """
43
+ sinogram = as_float_data(sinogram)
44
+ theta = _angles(sinogram, theta)
45
+ n = sinogram.shape[0]
46
+ output_size = output_size or n
47
+
48
+ rows, cols = np.mgrid[:output_size, :output_size] - output_size // 2
49
+ detector = np.arange(n) - n // 2
50
+ image = np.zeros((output_size, output_size))
51
+ used = range(0, len(theta), interval)
52
+ for i in used:
53
+ angle = np.deg2rad(theta[i])
54
+ t = cols * np.cos(angle) - rows * np.sin(angle)
55
+ image += np.interp(t, detector, sinogram[:, i], left=0, right=0)
56
+ return image * np.pi / (2 * len(used))
57
+
58
+
59
+ def fbp(sinogram: np.ndarray, theta: np.ndarray | None = None, filter: str | None = "ramp",
60
+ output_size: int | None = None) -> np.ndarray:
61
+ """
62
+ Filtered backprojection: filter every projection in the frequency domain, then backproject.
63
+
64
+ `filter` is one of `FILTERS` ('ramp', 'shepp-logan', 'cosine', 'hamming', 'hann');
65
+ smoother windows trade resolution for less noise. See `backproject` for the other arguments.
66
+ """
67
+ return backproject(filter_sinogram(sinogram, filter), theta, output_size)
68
+
69
+
70
+ def cbp(sinogram: np.ndarray, theta: np.ndarray | None = None, filter: str | None = "ramp",
71
+ output_size: int | None = None) -> np.ndarray:
72
+ """
73
+ Convolve-backproject: like `fbp`, but the filter is applied as a spatial convolution
74
+ of each projection. The result is identical to `fbp` up to floating-point error.
75
+ """
76
+ return backproject(convolve_sinogram(sinogram, filter), theta, output_size)
77
+
78
+
79
+ def bpf(sinogram: np.ndarray, theta: np.ndarray | None = None, filter: str | None = "ramp",
80
+ output_size: int | None = None, grid_factor: int = 2) -> np.ndarray:
81
+ """
82
+ Backproject-filter: backproject first, then remove the 1/r blur with the 2-D cone filter.
83
+
84
+ Parameters
85
+ ----------
86
+ grid_factor : int
87
+ Backproject onto a grid `grid_factor` times larger than `output_size` before
88
+ filtering, because the 1/r blur extends far outside the object (1 gives visible
89
+ errors; 2 is enough in practice).
90
+
91
+ See `backproject` and `fbp` for the other arguments.
92
+ """
93
+ output_size = output_size or np.asarray(sinogram).shape[0]
94
+ blurred = backproject(sinogram, theta, grid_factor * output_size)
95
+ return cone_filter(blurred, filter, output_size)[1]
@@ -0,0 +1,163 @@
1
+ """Reconstruction filters: 1-D ramp-type filters, their spatial kernels, and the 2-D cone filter."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+ from scipy.fft import fft, fft2, fftfreq, fftshift, ifft, ifft2
6
+
7
+ from ._utils import as_float_data, center_crop
8
+
9
+ __all__ = [
10
+ "FILTERS",
11
+ "padded_size",
12
+ "fourier_filter",
13
+ "spatial_kernel",
14
+ "filter_sinogram",
15
+ "convolve_sinogram",
16
+ "cone_filter",
17
+ ]
18
+
19
+ FILTERS = ("ramp", "shepp-logan", "cosine", "hamming", "hann")
20
+
21
+
22
+ def _check(name):
23
+ if name is not None and name not in FILTERS:
24
+ raise ValueError(f"Unknown filter {name!r}; choose one of {FILTERS} or None")
25
+
26
+
27
+ def padded_size(n: int) -> int:
28
+ """Smallest power of two >= 2n (and >= 64): FFT filtering at this length is a linear convolution."""
29
+ return max(64, int(2 ** np.ceil(np.log2(2 * n))))
30
+
31
+
32
+ def fourier_filter(size: int, name: str | None = "ramp") -> np.ndarray:
33
+ """
34
+ Frequency response of a reconstruction filter, in FFT order (DC first).
35
+
36
+ The ramp is built as the DFT of the band-limited spatial ramp kernel
37
+ (h[0] = 1/4, h[odd n] = -1/(pi n)^2), which avoids the DC error of a sampled |f|.
38
+ It equals 2|f| (f in cycles/sample), i.e. 1 at Nyquist. Other filters are the ramp
39
+ times an apodizing window.
40
+
41
+ Parameters
42
+ ----------
43
+ size : int
44
+ Filter length (use `padded_size(n_detectors)`).
45
+ name : {'ramp', 'shepp-logan', 'cosine', 'hamming', 'hann'} or None
46
+ None returns an all-pass (unity) filter.
47
+
48
+ Returns
49
+ -------
50
+ ndarray of shape (size,)
51
+ """
52
+ _check(name)
53
+ if name is None:
54
+ return np.ones(size)
55
+
56
+ n = np.concatenate((np.arange(1, size / 2 + 1, 2, dtype=int),
57
+ np.arange(size / 2 - 1, 0, -2, dtype=int)))
58
+ h = np.zeros(size)
59
+ h[0] = 0.25
60
+ h[1::2] = -1 / (np.pi * n) ** 2
61
+ response = 2 * np.real(fft(h))
62
+
63
+ if name == "shepp-logan":
64
+ omega = np.pi * fftfreq(size)[1:]
65
+ response[1:] *= np.sin(omega) / omega
66
+ elif name == "cosine":
67
+ response *= fftshift(np.sin(np.linspace(0, np.pi, size, endpoint=False)))
68
+ elif name == "hamming":
69
+ response *= fftshift(np.hamming(size))
70
+ elif name == "hann":
71
+ response *= fftshift(np.hanning(size))
72
+ return response
73
+
74
+
75
+ def spatial_kernel(n_detectors: int, name: str | None = "ramp") -> np.ndarray:
76
+ """
77
+ Spatial convolution kernel equivalent to `fourier_filter` (for convolve-backproject).
78
+
79
+ Covers lags -(n-1) ... (n-1), every lag that occurs when convolving a projection of
80
+ length n, so convolving with it equals FFT filtering with zero-padding.
81
+
82
+ Returns
83
+ -------
84
+ ndarray of shape (2 * n_detectors - 1,), lag 0 at index n_detectors - 1
85
+ """
86
+ size = padded_size(n_detectors)
87
+ h = np.real(ifft(fourier_filter(size, name)))
88
+ lags = np.arange(-(n_detectors - 1), n_detectors)
89
+ return h[lags % size]
90
+
91
+
92
+ def filter_sinogram(sinogram: np.ndarray, name: str | None = "ramp") -> np.ndarray:
93
+ """
94
+ Filter every projection (column) in the frequency domain.
95
+
96
+ Projections are zero-padded to `padded_size` so the product of FFTs is a linear,
97
+ not circular, convolution.
98
+ """
99
+ sinogram = as_float_data(sinogram)
100
+ n = sinogram.shape[0]
101
+ size = padded_size(n)
102
+ spectrum = fft(sinogram, n=size, axis=0) * fourier_filter(size, name)[:, None]
103
+ return np.real(ifft(spectrum, axis=0))[:n]
104
+
105
+
106
+ def convolve_sinogram(sinogram: np.ndarray, name: str | None = "ramp") -> np.ndarray:
107
+ """
108
+ Filter every projection (column) by spatial convolution with `spatial_kernel`.
109
+ Gives the same result as `filter_sinogram` (to floating-point precision).
110
+ """
111
+ sinogram = as_float_data(sinogram)
112
+ n = sinogram.shape[0]
113
+ kernel = spatial_kernel(n, name)
114
+ # full convolution has length 3n-2; output sample i sits at index i + (n-1)
115
+ return np.stack([np.convolve(column, kernel)[n - 1:2 * n - 1] for column in sinogram.T], axis=1)
116
+
117
+
118
+ def _window(freq: np.ndarray, name: str | None) -> np.ndarray:
119
+ """Apodizing window at |freq| (cycles/sample); beyond Nyquist it is held at its Nyquist value."""
120
+ _check(name)
121
+ f = np.minimum(np.abs(freq), 0.5)
122
+ if name in ("ramp", None):
123
+ return np.ones_like(f)
124
+ if name == "shepp-logan":
125
+ return np.sinc(f)
126
+ if name == "cosine":
127
+ return np.cos(np.pi * f)
128
+ if name == "hamming":
129
+ return 0.54 + 0.46 * np.cos(2 * np.pi * f)
130
+ return 0.5 + 0.5 * np.cos(2 * np.pi * f) # hann
131
+
132
+
133
+ def cone_filter(image: np.ndarray, name: str | None = "ramp",
134
+ output_size: int | None = None) -> tuple[np.ndarray, np.ndarray]:
135
+ """
136
+ Apply the 2-D cone filter 2|rho| (optionally apodized) to an unfiltered backprojection.
137
+
138
+ Unfiltered backprojection is the object convolved with 1/r, whose 2-D Fourier transform
139
+ is 1/|rho|; multiplying by |rho| undoes it. The factor 2 matches the scaling of the
140
+ 1-D ramp in `fourier_filter`.
141
+
142
+ Parameters
143
+ ----------
144
+ image : square 2-D array
145
+ Unfiltered backprojection. The 1/r blur reaches far outside the object, so compute it
146
+ on a larger grid than needed (e.g. 2x) and crop with `output_size`.
147
+ name : filter name, see `FILTERS`
148
+ output_size : int, optional
149
+ Side of the centred crop to return. Default: full size.
150
+
151
+ Returns
152
+ -------
153
+ spectrum : ndarray
154
+ Filtered 2-D spectrum, fftshift-ed (DC in the centre), for display.
155
+ filtered : ndarray
156
+ Filtered image, cropped to `output_size`.
157
+ """
158
+ size = image.shape[0]
159
+ u, v = np.meshgrid(fftfreq(size), fftfreq(size))
160
+ rho = np.hypot(u, v)
161
+ spectrum = fft2(image) * (2 * rho * _window(rho, name))
162
+ filtered = np.real(ifft2(spectrum))
163
+ return fftshift(spectrum), center_crop(filtered, output_size or size)
@@ -0,0 +1,127 @@
1
+ """Direct Fourier reconstruction (Fourier gridding) via the Fourier slice theorem."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+ from numpy.fft import fft, fftshift, ifft2, ifftshift
6
+ from scipy.interpolate import RegularGridInterpolator
7
+
8
+ from ._utils import as_float_data, center_crop, default_theta
9
+
10
+ __all__ = [
11
+ "frequency_axis",
12
+ "projection_spectra",
13
+ "polar_grid",
14
+ "cartesian_grid",
15
+ "polar_to_cartesian",
16
+ "inverse_fft2",
17
+ "fourier_gridding",
18
+ ]
19
+
20
+
21
+ def frequency_axis(n: int) -> np.ndarray:
22
+ """Angular frequencies (rad/sample) of an fftshift-ed length-n FFT; zero at index n // 2."""
23
+ return (np.arange(n) - n // 2) * (2 * np.pi / n)
24
+
25
+
26
+ def projection_spectra(sinogram: np.ndarray, oversample: int = 1) -> np.ndarray:
27
+ """
28
+ 1-D Fourier transform of every projection (along the detector axis).
29
+
30
+ Parameters
31
+ ----------
32
+ sinogram : ndarray of shape (n_detectors, n_angles)
33
+ oversample : int
34
+ Zero-pad each projection to `oversample * n_detectors` first. This samples the
35
+ Fourier slices more finely and greatly reduces gridding interpolation error
36
+ (1 gives strong artifacts; 4 is about as accurate as FBP).
37
+
38
+ Returns
39
+ -------
40
+ ndarray of shape (oversample * n_detectors, n_angles), complex
41
+ fftshift-ed: zero frequency at row N // 2, frequencies given by `frequency_axis(N)`.
42
+ """
43
+ sinogram = as_float_data(sinogram)
44
+ n0 = sinogram.shape[0]
45
+ n = n0 * oversample
46
+ padded = np.zeros((n, sinogram.shape[1]))
47
+ start = n // 2 - n0 // 2 # keep the rotation centre at row n // 2
48
+ padded[start:start + n0] = sinogram
49
+ return fftshift(fft(ifftshift(padded, axes=0), axis=0), axes=0)
50
+
51
+
52
+ def polar_grid(n: int, theta: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
53
+ """(theta_grid [rad], radius_grid [rad/sample]), both (n, len(theta)): where the slices are sampled."""
54
+ return np.meshgrid(np.deg2rad(theta), frequency_axis(n))
55
+
56
+
57
+ def cartesian_grid(n: int) -> tuple[np.ndarray, np.ndarray]:
58
+ """(kx, ky), both (n, n): kx varies along columns, ky along rows (rad/sample)."""
59
+ return np.meshgrid(frequency_axis(n), frequency_axis(n))
60
+
61
+
62
+ def polar_to_cartesian(spectra: np.ndarray, theta: np.ndarray) -> np.ndarray:
63
+ """
64
+ Interpolate the polar samples of the 2-D Fourier transform onto the Cartesian grid.
65
+
66
+ Fourier slice convention of `radon` (t = x cos(theta) - y sin(theta)):
67
+ P_theta(w) = F(kx = w cos(theta), ky = -w sin(theta)).
68
+ Each Cartesian point maps to theta = atan2(-ky, kx), w = |k|; angles below 0 use
69
+ P_{theta+pi}(w) = P_theta(-w). Interpolation is bilinear in (w, theta), with the slice
70
+ at 180 degrees appended so the 179 -> 0 degree wrap-around is handled.
71
+
72
+ Parameters
73
+ ----------
74
+ spectra : ndarray of shape (N, n_angles)
75
+ From `projection_spectra`.
76
+ theta : 1-D array
77
+ Projection angles in degrees, ascending, within [0, 180).
78
+
79
+ Returns
80
+ -------
81
+ ndarray of shape (N, N), complex, fftshift-ed (DC at [N // 2, N // 2])
82
+ """
83
+ n = spectra.shape[0]
84
+ w = frequency_axis(n)
85
+ theta = np.deg2rad(np.asarray(theta, dtype=float))
86
+
87
+ # slice at pi = slice at 0 mirrored (w -> -w maps row k to row 2*(n//2) - k)
88
+ mirrored = spectra[(2 * (n // 2) - np.arange(n)) % n, 0]
89
+ values = np.concatenate([spectra, mirrored[:, None]], axis=1)
90
+ angles = np.append(theta, np.pi)
91
+
92
+ kx, ky = cartesian_grid(n)
93
+ angle = np.arctan2(-ky, kx)
94
+ radius = np.hypot(kx, ky)
95
+ negative = angle < 0
96
+ angle[negative] += np.pi
97
+ radius[negative] *= -1
98
+
99
+ interpolate = RegularGridInterpolator((w, angles), values, bounds_error=False, fill_value=0)
100
+ return interpolate(np.column_stack((radius.ravel(), angle.ravel()))).reshape(n, n)
101
+
102
+
103
+ def inverse_fft2(spectrum: np.ndarray, output_size: int | None = None) -> np.ndarray:
104
+ """Real image from an fftshift-ed 2-D spectrum, optionally cropped to the centred output_size."""
105
+ image = np.real(fftshift(ifft2(ifftshift(spectrum))))
106
+ return image if output_size is None else center_crop(image, output_size)
107
+
108
+
109
+ def fourier_gridding(sinogram: np.ndarray, theta: np.ndarray | None = None,
110
+ output_size: int | None = None, oversample: int = 4) -> np.ndarray:
111
+ """
112
+ Direct Fourier reconstruction: 1-D FFT of each projection, polar-to-Cartesian
113
+ interpolation of the slices, inverse 2-D FFT.
114
+
115
+ Parameters
116
+ ----------
117
+ sinogram : ndarray of shape (n_detectors, n_angles)
118
+ theta : 1-D array, optional
119
+ Projection angles in degrees. Default: n_angles equally spaced angles in [0, 180).
120
+ output_size : int, optional
121
+ Side of the reconstructed image. Default: n_detectors.
122
+ oversample : int
123
+ Zero-padding factor of the projections, see `projection_spectra`.
124
+ """
125
+ theta = default_theta(np.shape(sinogram)[1]) if theta is None else theta
126
+ spectrum = polar_to_cartesian(projection_spectra(sinogram, oversample), theta)
127
+ return inverse_fft2(spectrum, output_size or np.shape(sinogram)[0])
@@ -0,0 +1,60 @@
1
+ """Test objects: the Shepp-Logan phantom, a disk, or any image file."""
2
+ from __future__ import annotations
3
+
4
+ from importlib.resources import as_file, files
5
+
6
+ import imageio.v3 as iio
7
+ import numpy as np
8
+ from skimage.transform import rescale, resize
9
+
10
+ __all__ = ["shepp_logan", "disk", "load_image"]
11
+
12
+
13
+ def shepp_logan(size: int = 256) -> np.ndarray:
14
+ """
15
+ Modified Shepp-Logan head phantom (bundled with the package).
16
+
17
+ Parameters
18
+ ----------
19
+ size : int
20
+ Side length of the returned square image.
21
+
22
+ Returns
23
+ -------
24
+ ndarray of shape (size, size), float, values in [0, 1]
25
+ """
26
+ with as_file(files("tomorecon") / "data" / "shepp_logan_phantom.png") as path:
27
+ image = iio.imread(path, mode="L")
28
+ return rescale(image, scale=size / image.shape[0], mode="reflect", channel_axis=None)
29
+
30
+
31
+ def disk(shape: tuple[int, int] = (120, 120), radius: float = 20,
32
+ center: tuple[float, float] = (100, 60)) -> np.ndarray:
33
+ """
34
+ Binary disk, useful for seeing how a single object traces a sine curve in the sinogram.
35
+
36
+ Parameters
37
+ ----------
38
+ shape : (rows, cols)
39
+ radius : float
40
+ center : (x, y)
41
+ Disk centre as (column, row).
42
+
43
+ Returns
44
+ -------
45
+ ndarray of shape `shape`, float, 1 inside the disk and 0 outside
46
+ """
47
+ y, x = np.mgrid[:shape[0], :shape[1]]
48
+ return (((x - center[0]) ** 2 + (y - center[1]) ** 2) < radius ** 2).astype(float)
49
+
50
+
51
+ def load_image(path, size: int = 256) -> np.ndarray:
52
+ """
53
+ Load an image file as grayscale and resize it to (size, size).
54
+
55
+ Returns
56
+ -------
57
+ ndarray of shape (size, size), float, values in [0, 1]
58
+ """
59
+ image = iio.imread(path, mode="L")
60
+ return resize(image, (size, size), mode="reflect", anti_aliasing=True)
@@ -0,0 +1,70 @@
1
+ """Forward model: the 2-D parallel-beam Radon transform."""
2
+ from __future__ import annotations
3
+
4
+ from warnings import warn
5
+
6
+ import numpy as np
7
+ from skimage.transform import warp
8
+
9
+ from ._utils import as_float_image, default_theta
10
+
11
+ __all__ = ["radon"]
12
+
13
+
14
+ def _pad_to_square(image: np.ndarray, circle: bool) -> np.ndarray:
15
+ if circle:
16
+ # Object must lie inside the inscribed circle; crop to the largest centred square.
17
+ size = min(image.shape)
18
+ radius = size // 2
19
+ rows, cols = np.ogrid[:image.shape[0], :image.shape[1]]
20
+ dist2 = (rows - image.shape[0] // 2) ** 2 + (cols - image.shape[1] // 2) ** 2
21
+ if np.any(image[dist2 > radius ** 2]):
22
+ warn("radon: image must be zero outside the reconstruction circle", stacklevel=3)
23
+ starts = [int(np.ceil((s - size) / 2)) for s in image.shape]
24
+ return image[starts[0]:starts[0] + size, starts[1]:starts[1] + size]
25
+
26
+ # Pad so the whole image fits inside the inscribed circle of the padded square.
27
+ diagonal = np.sqrt(2) * max(image.shape)
28
+ pad = [int(np.ceil(diagonal - s)) for s in image.shape]
29
+ before = [(s + p) // 2 - s // 2 for s, p in zip(image.shape, pad)]
30
+ return np.pad(image, [(b, p - b) for b, p in zip(before, pad)], mode="constant")
31
+
32
+
33
+ def radon(image: np.ndarray, theta: np.ndarray | None = None, circle: bool = True) -> np.ndarray:
34
+ """
35
+ Radon transform: line integrals of `image` along parallel rays at each angle.
36
+
37
+ For each angle the image is rotated about its centre (bilinear interpolation) and
38
+ summed along the columns. The detector coordinate is t = x cos(theta) - y sin(theta)
39
+ with x = column and y = row, both relative to the centre pixel.
40
+
41
+ Parameters
42
+ ----------
43
+ image : 2-D array
44
+ theta : 1-D array, optional
45
+ Projection angles in degrees. Default: 0, 1, ..., 179.
46
+ circle : bool
47
+ If True (default), assume the object lies inside the circle inscribed in the image;
48
+ the sinogram then has as many detector bins as the image side. If False, the image
49
+ is zero-padded so that nothing is cut off (sinogram length ~ sqrt(2) * side).
50
+
51
+ Returns
52
+ -------
53
+ sinogram : ndarray of shape (n_detectors, len(theta))
54
+ One projection per column; the rotation centre is at row n_detectors // 2.
55
+ """
56
+ image = as_float_image(image)
57
+ if image.ndim != 2:
58
+ raise ValueError("radon: image must be 2-D")
59
+ theta = default_theta(180) if theta is None else np.asarray(theta, dtype=float)
60
+
61
+ padded = _pad_to_square(image, circle)
62
+ center = padded.shape[0] // 2
63
+ sinogram = np.zeros((padded.shape[0], len(theta)), dtype=padded.dtype)
64
+ for i, angle in enumerate(np.deg2rad(theta)):
65
+ cos_a, sin_a = np.cos(angle), np.sin(angle)
66
+ rotation = np.array([[cos_a, sin_a, -center * (cos_a + sin_a - 1)],
67
+ [-sin_a, cos_a, -center * (cos_a - sin_a - 1)],
68
+ [0, 0, 1]])
69
+ sinogram[:, i] = warp(padded, rotation, clip=False).sum(axis=0)
70
+ return sinogram
@@ -0,0 +1,110 @@
1
+ """Every reconstruction method must reproduce the Shepp-Logan phantom (RMSE inside the FOV circle)."""
2
+ import numpy as np
3
+ import pytest
4
+ from skimage.transform import iradon
5
+ from skimage.transform import radon as sk_radon
6
+
7
+ import tomorecon as tr
8
+
9
+ THETA = np.arange(180)
10
+
11
+
12
+ @pytest.fixture(scope="module")
13
+ def phantom():
14
+ return tr.shepp_logan()
15
+
16
+
17
+ @pytest.fixture(scope="module")
18
+ def sinogram(phantom):
19
+ return tr.radon(phantom, THETA)
20
+
21
+
22
+ @pytest.fixture(scope="module")
23
+ def inside(phantom):
24
+ n = phantom.shape[0]
25
+ rows, cols = np.mgrid[:n, :n] - n // 2
26
+ return rows ** 2 + cols ** 2 < (n // 2 - 2) ** 2
27
+
28
+
29
+ @pytest.fixture(scope="module")
30
+ def rmse(phantom, inside):
31
+ return lambda image: float(np.sqrt(np.mean((image[inside] - phantom[inside]) ** 2)))
32
+
33
+
34
+ def test_phantom(phantom):
35
+ assert phantom.shape == (256, 256)
36
+ assert 0 <= phantom.min() and phantom.max() <= 1
37
+ assert tr.shepp_logan(128).shape == (128, 128)
38
+
39
+
40
+ def test_disk():
41
+ d = tr.disk((50, 60), radius=5, center=(30, 20))
42
+ assert d.shape == (50, 60) and d[20, 30] == 1 and d[0, 0] == 0
43
+
44
+
45
+ def test_radon_matches_skimage(phantom, sinogram):
46
+ np.testing.assert_allclose(sinogram, sk_radon(phantom, THETA), atol=1e-10)
47
+
48
+
49
+ def test_radon_no_circle_matches_skimage(phantom):
50
+ np.testing.assert_allclose(tr.radon(phantom, THETA[::10], circle=False),
51
+ sk_radon(phantom, THETA[::10], circle=False), atol=1e-10)
52
+
53
+
54
+ def test_fbp_matches_skimage(sinogram, inside):
55
+ ref = iradon(sinogram, THETA, filter_name="ramp", circle=True)
56
+ np.testing.assert_allclose(tr.fbp(sinogram, THETA)[inside], ref[inside], atol=1e-8)
57
+
58
+
59
+ @pytest.mark.parametrize("name", tr.FILTERS)
60
+ def test_fbp(sinogram, rmse, name):
61
+ assert rmse(tr.fbp(sinogram, THETA, filter=name)) < 0.06
62
+
63
+
64
+ @pytest.mark.parametrize("name", ["ramp", "cosine"])
65
+ def test_cbp_equals_fbp(sinogram, name):
66
+ np.testing.assert_allclose(tr.cbp(sinogram, THETA, name), tr.fbp(sinogram, THETA, name), atol=1e-10)
67
+
68
+
69
+ def test_bpf(sinogram, rmse):
70
+ assert rmse(tr.bpf(sinogram, THETA)) < 0.05
71
+
72
+
73
+ def test_fourier_gridding(sinogram, rmse):
74
+ assert rmse(tr.fourier_gridding(sinogram, THETA)) < 0.04
75
+ # without oversampling the interpolation error is much larger
76
+ assert rmse(tr.fourier_gridding(sinogram, THETA, oversample=1)) > 0.06
77
+
78
+
79
+ def test_fourier_slice_theorem(phantom, sinogram):
80
+ # theta = 0: the FT of the projection is the ky = 0 row of the 2-D FT
81
+ spectra = tr.projection_spectra(sinogram)
82
+ F = np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(phantom)))
83
+ n = spectra.shape[0]
84
+ np.testing.assert_allclose(spectra[:, 0], F[n // 2, :], atol=1e-6 * np.abs(F).max())
85
+
86
+
87
+ def test_default_theta(sinogram):
88
+ np.testing.assert_allclose(tr.fbp(sinogram), tr.fbp(sinogram, THETA))
89
+
90
+
91
+ def test_theta_length_mismatch(sinogram):
92
+ with pytest.raises(ValueError):
93
+ tr.fbp(sinogram, THETA[:10])
94
+
95
+
96
+ def test_interval_normalisation(sinogram):
97
+ full = tr.backproject(sinogram, THETA).mean()
98
+ assert abs(tr.backproject(sinogram, THETA, interval=18).mean() / full - 1) < 0.02
99
+
100
+
101
+ def test_spatial_kernel_is_centred():
102
+ k = tr.spatial_kernel(256)
103
+ assert k.size == 511 and np.argmax(k) == 255
104
+ np.testing.assert_allclose(k, k[::-1], atol=1e-12)
105
+
106
+
107
+ def test_filter_validation():
108
+ np.testing.assert_array_equal(tr.fourier_filter(64, None), np.ones(64))
109
+ with pytest.raises(ValueError):
110
+ tr.fourier_filter(64, "nope")