torch-reconstruct-tomogram 0.6.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,115 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ env/
12
+ build/
13
+ develop-eggs/
14
+ dist/
15
+ downloads/
16
+ eggs/
17
+ .eggs/
18
+ lib/
19
+ lib64/
20
+ parts/
21
+ sdist/
22
+ var/
23
+ wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+
28
+ .DS_Store
29
+
30
+ # PyInstaller
31
+ *.manifest
32
+ *.spec
33
+
34
+ # Installer logs
35
+ pip-log.txt
36
+ pip-delete-this-directory.txt
37
+
38
+ # Unit test / coverage reports
39
+ htmlcov/
40
+ .tox/
41
+ .coverage
42
+ .coverage.*
43
+ .cache
44
+ nosetests.xml
45
+ coverage.xml
46
+ *.cover
47
+ .hypothesis/
48
+ .pytest_cache/
49
+
50
+ # Files downloaded for unit tests
51
+ **/tests/tmp/
52
+
53
+ # Translations
54
+ *.mo
55
+ *.pot
56
+
57
+ # Django stuff:
58
+ *.log
59
+ local_settings.py
60
+
61
+ # Flask stuff:
62
+ instance/
63
+ .webassets-cache
64
+
65
+ # Scrapy stuff:
66
+ .scrapy
67
+
68
+ # Sphinx documentation
69
+ docs/_build/
70
+
71
+ # PyBuilder
72
+ target/
73
+
74
+ # Jupyter Notebook
75
+ .ipynb_checkpoints
76
+
77
+ # dotenv
78
+ .env
79
+
80
+ # virtualenv
81
+ .venv
82
+ venv/
83
+ ENV/
84
+
85
+ # Spyder project settings
86
+ .spyderproject
87
+ .spyproject
88
+
89
+ # Rope project settings
90
+ .ropeproject
91
+
92
+ # mkdocs documentation
93
+ /site
94
+
95
+ # mypy
96
+ .mypy_cache/
97
+
98
+ # ruff
99
+ .ruff_cache/
100
+
101
+ # IDEs
102
+ .idea/
103
+ .vscode/
104
+
105
+ # Mojo extension compile cache (experimental torch-fourier-slice kernels)
106
+ __mojocache__/
107
+ *.mojopkg
108
+ # experimental demo outputs
109
+ packages/primitives/torch-fourier-slice/examples/*.npz
110
+ packages/primitives/torch-fourier-slice/examples/*.png
111
+ packages/primitives/torch-fourier-slice/examples/*.gif
112
+ # Personal local notes (not for commit)
113
+ notes/*.local.md
114
+
115
+ lightning_logs/
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2020, TeamTomo
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.5
2
+ Name: torch-reconstruct-tomogram
3
+ Version: 0.6.0
4
+ Summary: (sub-)tomogram reconstruction and subtilt extraction for cryoET.
5
+ Project-URL: homepage, https://github.com/teamtomo/teamtomo
6
+ Project-URL: repository, https://github.com/teamtomo/teamtomo
7
+ Project-URL: documentation, https://github.com/teamtomo/teamtomo#readme
8
+ Project-URL: changelog, https://github.com/teamtomo/teamtomo#changelog
9
+ Project-URL: Bug Tracker, https://github.com/teamtomo/teamtomo/issues
10
+ Project-URL: Source Code, https://github.com/teamtomo/teamtomo
11
+ Author-email: Marten Chaillet <martenchaillet@gmail.com>, Davide Torre <davidetorre99@gmail.com>
12
+ License: BSD-3-Clause
13
+ License-File: LICENSE
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: License :: OSI Approved :: BSD License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: einops
24
+ Requires-Dist: torch
25
+ Requires-Dist: torch-ctf
26
+ Requires-Dist: torch-fourier-rescale
27
+ Requires-Dist: torch-fourier-slice
28
+ Requires-Dist: torch-grid-utils>=0.0.8
29
+ Requires-Dist: torch-subpixel-crop
30
+ Requires-Dist: torch-tilt-series[io]
31
+ Description-Content-Type: text/markdown
32
+
33
+ # torch-reconstruct-tomogram
34
+
35
+ [![License](https://img.shields.io/pypi/l/torch-reconstruct-tomogram.svg?color=green)](https://github.com/teamtomo/torch-reconstruct-tomogram/raw/main/LICENSE)
36
+ [![PyPI](https://img.shields.io/pypi/v/torch-reconstruct-tomogram.svg?color=green)](https://pypi.org/project/torch-reconstruct-tomogram)
37
+ [![Python Version](https://img.shields.io/pypi/pyversions/torch-reconstruct-tomogram.svg?color=green)](https://python.org)
38
+ [![CI](https://github.com/teamtomo/torch-reconstruct-tomogram/actions/workflows/ci.yml/badge.svg)](https://github.com/teamtomo/torch-reconstruct-tomogram/actions/workflows/ci.yml)
39
+ [![codecov](https://codecov.io/gh/teamtomo/torch-reconstruct-tomogram/branch/main/graph/badge.svg)](https://codecov.io/gh/teamtomo/torch-reconstruct-tomogram)
40
+
41
+ (sub-)Tomogram reconstruction and subtilt extraction for cryo-ET.
42
+
43
+ ## Overview
44
+
45
+ This package provides (sub-)tomogram reconstruction and subtilt extraction driven entirely from a [`torch-tilt-series`](https://github.com/teamtomo/torch-tilt-series) `TiltSeries`. It supports
46
+
47
+ * `extract_particle_tilt_series()`: extract a subtilt-series at 3D location(s) in the sample
48
+ * `reconstruct_subvolume()`: rank-polymorphic reconstruction of 3D patch(es) at location(s) in the sample
49
+ * `reconstruct_tomogram()`: full volume reconstruction by tiling reconstructed patches in 3D
50
+
51
+ `TiltSeries` holds alignment geometry (in Angstroms) plus `image_path`/`image_indices`/`pixel_spacing` metadata. The functions above take a `TiltSeries`, and load and (by default) preprocess the matching raw images internally via `torch_tilt_series.load_tilt_series_images()` / `preprocess_tilt_series_images()` (by default: plane subtraction, a DC-excluding bandpass with no low-pass, i.e. up to Nyquist, and central-crop normalization); pass `**preprocessing_kwargs` to override any of its defaults (`low=`, `high=`, `falloff=`, `bandpass_padding=`, `subtract_background=`, `normalize=`) - see `preprocess_tilt_series_images()`'s docstring for details. `output_pixel_spacing` lets both local (`reconstruct_subvolume`) and global (`reconstruct_tomogram`) reconstruction target an arbitrary output voxel size. Reconstruction happens at the input pixel spacing and is Fourier-rescaled to the requested output size. Reconstruction is performed in Fourier space using central slice insertion. Positions are in `zyx` coordinates, in Angstroms, relative to the tomogram center.
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ pip install torch-reconstruct-tomogram
57
+ ```
58
+
59
+ To load a tilt series from AreTomo or ETOMO output, also install the IO dependencies for [`torch-tilt-series`](https://github.com/teamtomo/torch-tilt-series):
60
+
61
+ ```bash
62
+ pip install torch-tilt-series[io]
63
+ ```
64
+
65
+ ## Examples
66
+
67
+ See the [`examples/`](examples/) folder for scripts showing how to load a tilt series, reconstruct subvolumes and tomograms, and save the result.
68
+
69
+ ## License
70
+
71
+ This project is licensed under the BSD 3-Clause License - see the LICENSE file for details.
@@ -0,0 +1,39 @@
1
+ # torch-reconstruct-tomogram
2
+
3
+ [![License](https://img.shields.io/pypi/l/torch-reconstruct-tomogram.svg?color=green)](https://github.com/teamtomo/torch-reconstruct-tomogram/raw/main/LICENSE)
4
+ [![PyPI](https://img.shields.io/pypi/v/torch-reconstruct-tomogram.svg?color=green)](https://pypi.org/project/torch-reconstruct-tomogram)
5
+ [![Python Version](https://img.shields.io/pypi/pyversions/torch-reconstruct-tomogram.svg?color=green)](https://python.org)
6
+ [![CI](https://github.com/teamtomo/torch-reconstruct-tomogram/actions/workflows/ci.yml/badge.svg)](https://github.com/teamtomo/torch-reconstruct-tomogram/actions/workflows/ci.yml)
7
+ [![codecov](https://codecov.io/gh/teamtomo/torch-reconstruct-tomogram/branch/main/graph/badge.svg)](https://codecov.io/gh/teamtomo/torch-reconstruct-tomogram)
8
+
9
+ (sub-)Tomogram reconstruction and subtilt extraction for cryo-ET.
10
+
11
+ ## Overview
12
+
13
+ This package provides (sub-)tomogram reconstruction and subtilt extraction driven entirely from a [`torch-tilt-series`](https://github.com/teamtomo/torch-tilt-series) `TiltSeries`. It supports
14
+
15
+ * `extract_particle_tilt_series()`: extract a subtilt-series at 3D location(s) in the sample
16
+ * `reconstruct_subvolume()`: rank-polymorphic reconstruction of 3D patch(es) at location(s) in the sample
17
+ * `reconstruct_tomogram()`: full volume reconstruction by tiling reconstructed patches in 3D
18
+
19
+ `TiltSeries` holds alignment geometry (in Angstroms) plus `image_path`/`image_indices`/`pixel_spacing` metadata. The functions above take a `TiltSeries`, and load and (by default) preprocess the matching raw images internally via `torch_tilt_series.load_tilt_series_images()` / `preprocess_tilt_series_images()` (by default: plane subtraction, a DC-excluding bandpass with no low-pass, i.e. up to Nyquist, and central-crop normalization); pass `**preprocessing_kwargs` to override any of its defaults (`low=`, `high=`, `falloff=`, `bandpass_padding=`, `subtract_background=`, `normalize=`) - see `preprocess_tilt_series_images()`'s docstring for details. `output_pixel_spacing` lets both local (`reconstruct_subvolume`) and global (`reconstruct_tomogram`) reconstruction target an arbitrary output voxel size. Reconstruction happens at the input pixel spacing and is Fourier-rescaled to the requested output size. Reconstruction is performed in Fourier space using central slice insertion. Positions are in `zyx` coordinates, in Angstroms, relative to the tomogram center.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pip install torch-reconstruct-tomogram
25
+ ```
26
+
27
+ To load a tilt series from AreTomo or ETOMO output, also install the IO dependencies for [`torch-tilt-series`](https://github.com/teamtomo/torch-tilt-series):
28
+
29
+ ```bash
30
+ pip install torch-tilt-series[io]
31
+ ```
32
+
33
+ ## Examples
34
+
35
+ See the [`examples/`](examples/) folder for scripts showing how to load a tilt series, reconstruct subvolumes and tomograms, and save the result.
36
+
37
+ ## License
38
+
39
+ This project is licensed under the BSD 3-Clause License - see the LICENSE file for details.
@@ -0,0 +1,195 @@
1
+ # https://peps.python.org/pep-0517/
2
+ [build-system]
3
+ requires = ["hatchling", "hatch-vcs"]
4
+ build-backend = "hatchling.build"
5
+
6
+ # https://hatch.pypa.io/latest/config/metadata/
7
+ [tool.hatch.version]
8
+ source = "vcs"
9
+ tag-pattern = "^torch-reconstruct-tomogram@v(?P<version>.+)$"
10
+ fallback-version = "0.5.0"
11
+
12
+ [tool.hatch.version.raw-options]
13
+ search_parent_directories = true
14
+ tag_regex = "^torch-reconstruct-tomogram@v(?P<version>\\d+\\.\\d+\\.\\d+.*)$"
15
+ # Constrain git-describe so it only considers TeamTomo's own tags, not other workspace tags.
16
+ # See https://github.com/ofek/hatch-vcs/issues/71
17
+ git_describe_command = "git describe --dirty --tags --long --match 'torch-reconstruct-tomogram@v[0-9]*.[0-9]*.[0-9]*'"
18
+
19
+ # read more about configuring hatch at:
20
+ # https://hatch.pypa.io/latest/config/build/
21
+ [tool.hatch.build.targets.wheel]
22
+ only-include = ["src"]
23
+ sources = ["src"]
24
+
25
+ # https://peps.python.org/pep-0621/
26
+ [project]
27
+ name = "torch-reconstruct-tomogram"
28
+ dynamic = ["version"]
29
+ description = "(sub-)tomogram reconstruction and subtilt extraction for cryoET."
30
+ readme = "README.md"
31
+ requires-python = ">=3.11"
32
+ license = { text = "BSD-3-Clause" }
33
+ authors = [
34
+ { name = "Marten Chaillet", email = "martenchaillet@gmail.com" },
35
+ { name = "Davide Torre", email = "davidetorre99@gmail.com" },
36
+ ]
37
+ # https://pypi.org/classifiers/
38
+ classifiers = [
39
+ "Development Status :: 3 - Alpha",
40
+ "License :: OSI Approved :: BSD License",
41
+ "Programming Language :: Python :: 3",
42
+ "Programming Language :: Python :: 3.11",
43
+ "Programming Language :: Python :: 3.12",
44
+ "Programming Language :: Python :: 3.13",
45
+ "Programming Language :: Python :: 3.14",
46
+ "Typing :: Typed",
47
+ ]
48
+ # add your package dependencies here
49
+ dependencies = [
50
+ "torch",
51
+ "einops",
52
+ "torch-fourier-slice",
53
+ "torch-fourier-rescale",
54
+ "torch-ctf",
55
+ "torch-grid-utils>=0.0.8",
56
+ "torch-subpixel-crop",
57
+ "torch-tilt-series[io]",
58
+ ]
59
+
60
+ # https://peps.python.org/pep-0621/#dependencies-optional-dependencies
61
+ # add dependencies for "extra" features here. Not dev dependencies.
62
+ # [project.optional-dependencies]
63
+ # name = ["dependency"]
64
+
65
+ [project.urls]
66
+ homepage = "https://github.com/teamtomo/teamtomo"
67
+ repository = "https://github.com/teamtomo/teamtomo"
68
+ documentation = "https://github.com/teamtomo/teamtomo#readme"
69
+ changelog = "https://github.com/teamtomo/teamtomo#changelog"
70
+ "Bug Tracker" = "https://github.com/teamtomo/teamtomo/issues"
71
+ "Source Code" = "https://github.com/teamtomo/teamtomo"
72
+ # Entry points
73
+ # https://peps.python.org/pep-0621/#entry-points
74
+ # same as console_scripts entry point
75
+ # [project.scripts]
76
+ # torch-reconstruct-tomogram-cli = "torch_reconstruct_tomogram:main_cli"
77
+
78
+ # [project.entry-points."some.group"]
79
+ # tomatoes = "torch_reconstruct_tomogram:main_tomatoes"
80
+
81
+ # https://peps.python.org/pep-0735/
82
+ # setup with `uv sync` or `pip install -e . --group dev`
83
+ [dependency-groups]
84
+ test = ["pytest", "pytest-cov"]
85
+ dev = [
86
+ { include-group = "test" },
87
+ "ipython",
88
+ "mypy",
89
+ "pdbpp", # https://github.com/pdbpp/pdbpp
90
+ "pre-commit-uv",
91
+ "rich", # https://github.com/Textualize/rich
92
+ "ruff",
93
+ ]
94
+
95
+ [tool.uv.sources]
96
+ torch-tilt-series = { workspace = true }
97
+
98
+ # https://docs.astral.sh/ruff
99
+ [tool.ruff]
100
+ line-length = 88
101
+ target-version = "py311"
102
+ src = ["src"]
103
+ fix = true
104
+ # unsafe-fixes = true
105
+
106
+ [tool.ruff.lint]
107
+ pydocstyle = { convention = "numpy" }
108
+ select = [
109
+ "E", # style errors
110
+ "W", # style warnings
111
+ "F", # flakes
112
+ "D", # pydocstyle
113
+ "D417", # Missing argument descriptions in Docstrings
114
+ "I", # isort
115
+ "UP", # pyupgrade
116
+ "C4", # flake8-comprehensions
117
+ "B", # flake8-bugbear
118
+ "A001", # flake8-builtins
119
+ "RUF", # ruff-specific rules
120
+ "TC", # flake8-type-checking
121
+ "TID", # flake8-tidy-imports
122
+ ]
123
+ ignore = [
124
+ "D401", # First line should be in imperative mood (remove to opt in)
125
+ ]
126
+
127
+ [tool.ruff.lint.per-file-ignores]
128
+ "tests/*.py" = ["D", "S"]
129
+ "docs/*.py" = ["D", "A"]
130
+
131
+ # https://docs.astral.sh/ruff/formatter/
132
+ [tool.ruff.format]
133
+ docstring-code-format = true
134
+ skip-magic-trailing-comma = false # default is false
135
+
136
+ # https://docs.pytest.org/
137
+ [tool.pytest.ini_options]
138
+ minversion = "7.0"
139
+ addopts = ["--color=yes"]
140
+ testpaths = ["tests"]
141
+ filterwarnings = [
142
+ "error",
143
+ # mrcfile 1.5.x triggers this when reading MRC headers with NumPy >= 2.5
144
+ "ignore:Setting the dtype on a NumPy array has been deprecated:DeprecationWarning",
145
+ ]
146
+
147
+ # https://mypy.readthedocs.io/en/stable/config_file.html
148
+ [tool.mypy]
149
+ files = "src/**/"
150
+ strict = true
151
+ disallow_any_generics = false
152
+ disallow_subclassing_any = false
153
+ show_error_codes = true
154
+ pretty = true
155
+ # plugins = ["pydantic.mypy"]
156
+
157
+ # # module specific overrides
158
+ # [[tool.mypy.overrides]]
159
+ # module = ["numpy.*",]
160
+ # ignore_errors = true
161
+
162
+ # https://coverage.readthedocs.io/
163
+ [tool.coverage.report]
164
+ show_missing = true
165
+ exclude_lines = [
166
+ "pragma: no cover",
167
+ "if TYPE_CHECKING:",
168
+ "@overload",
169
+ "except ImportError",
170
+ "raise AssertionError",
171
+ "\\.\\.\\.",
172
+ "raise NotImplementedError()",
173
+ "pass",
174
+ ]
175
+
176
+ [tool.coverage.run]
177
+ source = ["torch_reconstruct_tomogram"]
178
+
179
+ # https://github.com/mgedmin/check-manifest#configuration
180
+ # add files that you want check-manifest to explicitly ignore here
181
+ # (files that are in the repo but shouldn't go in the package)
182
+ [tool.check-manifest]
183
+ ignore = [
184
+ ".pre-commit-config.yaml",
185
+ ".ruff_cache/**/*",
186
+ "tests/**/*",
187
+ "uv.lock",
188
+ ]
189
+
190
+ # https://github.com/crate-ci/typos/blob/master/docs/reference.md
191
+ [tool.typos.default]
192
+ extend-ignore-identifiers-re = []
193
+
194
+ [tool.typos.default.extend-words]
195
+ writeable = "writeable"
@@ -0,0 +1,26 @@
1
+ """(sub-)tomogram reconstruction and subtilt extraction for cryoET."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("torch-reconstruct-tomogram")
7
+ except PackageNotFoundError:
8
+ __version__ = "uninstalled"
9
+ __author__ = "Marten Chaillet, Davide Torre"
10
+ __email__ = "martenchaillet@gmail.com, davidetorre99@gmail.com"
11
+
12
+ from torch_reconstruct_tomogram.projection import (
13
+ extract_particle_tilt_series,
14
+ project_points,
15
+ )
16
+ from torch_reconstruct_tomogram.reconstruct import (
17
+ reconstruct_subvolume,
18
+ reconstruct_tomogram,
19
+ )
20
+
21
+ __all__ = [
22
+ "extract_particle_tilt_series",
23
+ "project_points",
24
+ "reconstruct_subvolume",
25
+ "reconstruct_tomogram",
26
+ ]
@@ -0,0 +1,77 @@
1
+ """Project 3D points into tilt images and crop patches for reconstruction."""
2
+
3
+ from typing import Any
4
+
5
+ import torch
6
+ from torch_grid_utils import dft_center
7
+ from torch_subpixel_crop import subpixel_crop_2d
8
+ from torch_tilt_series import (
9
+ TiltSeries,
10
+ load_tilt_series_images,
11
+ preprocess_tilt_series_images,
12
+ )
13
+
14
+
15
+ def project_points(tilt_series: TiltSeries, points_zyx: torch.Tensor) -> torch.Tensor:
16
+ """Project 3D points to 2D image pixel coordinates.
17
+
18
+ - points are 3D zyx coordinates in Angstroms, relative to the tomogram center
19
+ - tilt_series supplies the projection geometry (`tilt_series.project_points`
20
+ works in Angstroms) and `tilt_series.pixel_spacing` (raises if unset),
21
+ used to convert the projected Angstrom positions to pixels
22
+ - projected 2D points are in pixels, relative to the center of each image
23
+ """
24
+ return tilt_series.project_points(points_zyx) / tilt_series.pixel_spacing
25
+
26
+
27
+ def _extract_particle_tilt_series(
28
+ tilt_series: TiltSeries,
29
+ images: torch.Tensor,
30
+ points_zyx: torch.Tensor,
31
+ sidelength: int,
32
+ return_rfft: bool = True,
33
+ ) -> torch.Tensor:
34
+ """Extract a subtilt-series given already-loaded images."""
35
+ projected_yx = project_points(tilt_series, points_zyx)
36
+ projected_yx = projected_yx + dft_center(
37
+ images.shape[-2:], rfft=False, fftshift=True, device=images.device
38
+ )
39
+ return subpixel_crop_2d(
40
+ image=images,
41
+ positions=projected_yx,
42
+ sidelength=sidelength,
43
+ return_rfft=return_rfft,
44
+ decenter=return_rfft,
45
+ )
46
+
47
+
48
+ def extract_particle_tilt_series(
49
+ tilt_series: TiltSeries,
50
+ points_zyx: torch.Tensor,
51
+ sidelength: int,
52
+ return_rfft: bool = True,
53
+ preprocess: bool = True,
54
+ **preprocessing_kwargs: Any,
55
+ ) -> torch.Tensor:
56
+ """Extract a subtilt-series at 3D location(s) in the sample.
57
+
58
+ Loads (and, by default, preprocesses) the raw tilt images matching
59
+ `tilt_series` via `tilt_series.image_path`/`image_indices`. Preprocessing
60
+ (see `torch_tilt_series.preprocess_tilt_series_images`) by default applies
61
+ plane subtraction, a DC-excluding bandpass with no low-pass (i.e. up to
62
+ Nyquist), and central-crop normalization. `**preprocessing_kwargs` are
63
+ forwarded to `preprocess_tilt_series_images`, overriding any of its
64
+ defaults (`low`, `high`, `falloff`, `bandpass_padding`,
65
+ `subtract_background`, `normalize`) - see that function's docstring for
66
+ details.
67
+ """
68
+ images = load_tilt_series_images(tilt_series)
69
+ if preprocess:
70
+ images = preprocess_tilt_series_images(images, **preprocessing_kwargs)
71
+ return _extract_particle_tilt_series(
72
+ tilt_series,
73
+ images,
74
+ points_zyx,
75
+ sidelength,
76
+ return_rfft=return_rfft,
77
+ )
@@ -0,0 +1,5 @@
1
+ You may remove this file if you don't intend to add types to your package
2
+
3
+ Details at:
4
+
5
+ https://mypy.readthedocs.io/en/stable/installed_packages.html#creating-pep-561-compatible-packages
@@ -0,0 +1,294 @@
1
+ """(sub-)tomogram reconstruction in pytorch."""
2
+
3
+ from typing import Any
4
+
5
+ import einops
6
+ import numpy as np
7
+ import torch
8
+ import torch.nn.functional as F
9
+ from torch_fourier_rescale import fourier_rescale_rfft_2d
10
+ from torch_fourier_slice import insert_central_slices_rfft_3d_multichannel
11
+ from torch_grid_utils import fftfreq_grid
12
+ from torch_tilt_series import (
13
+ TiltSeries,
14
+ load_tilt_series_images,
15
+ preprocess_tilt_series_images,
16
+ )
17
+
18
+ from torch_reconstruct_tomogram.projection import _extract_particle_tilt_series
19
+
20
+ _PAD_FACTOR = 2.0
21
+
22
+
23
+ def _writable(data):
24
+ if isinstance(data, np.ndarray) and not data.flags.writeable:
25
+ data = data.copy()
26
+ return data
27
+
28
+
29
+ def _reconstruct_subvolume(
30
+ tilt_series: TiltSeries,
31
+ images: torch.Tensor,
32
+ points_zyx: torch.Tensor,
33
+ sidelength: int,
34
+ output_pixel_spacing: float | None = None,
35
+ ) -> torch.Tensor:
36
+ """Reconstruct subvolume(s), given already-loaded images."""
37
+ device = images.device
38
+ input_pixel_spacing = tilt_series.pixel_spacing # raises if unset
39
+ if output_pixel_spacing is None:
40
+ output_pixel_spacing = input_pixel_spacing
41
+
42
+ points_zyx = torch.as_tensor(_writable(points_zyx), device=device).float()
43
+ points_zyx, ps = einops.pack([points_zyx], "* zyx")
44
+
45
+ # tomogram -> detector rotation: projection_matrices is sample -> detector
46
+ # only, so compose tomo2sample's rotation in first. Every patch's
47
+ # Fourier-insertion rotation must be expressed relative to the
48
+ # tomogram frame.
49
+ rotation_matrices = (
50
+ tilt_series.projection_matrices[:, :3, :3] @ tilt_series.tomo2sample[:3, :3]
51
+ )
52
+ rotation_matrices = torch.linalg.pinv(rotation_matrices)
53
+
54
+ sidelength_padded_output = int(_PAD_FACTOR * sidelength)
55
+ sidelength_padded_native = max(
56
+ 1,
57
+ round(sidelength_padded_output * output_pixel_spacing / input_pixel_spacing),
58
+ )
59
+
60
+ particle_tilt_series_rfft = _extract_particle_tilt_series(
61
+ tilt_series,
62
+ images,
63
+ points_zyx,
64
+ sidelength=sidelength_padded_native,
65
+ return_rfft=True,
66
+ )
67
+
68
+ particle_tilt_series_rfft = torch.fft.fftshift(particle_tilt_series_rfft, dim=(-2,))
69
+
70
+ particle_tilt_series_rfft = fourier_rescale_rfft_2d(
71
+ dft=particle_tilt_series_rfft,
72
+ image_shape=(sidelength_padded_native, sidelength_padded_native),
73
+ target_shape=(sidelength_padded_output, sidelength_padded_output),
74
+ )
75
+
76
+ particle_tilt_series_rfft = einops.rearrange(
77
+ particle_tilt_series_rfft,
78
+ "n_positions n_tilts h w_rfft -> n_tilts n_positions h w_rfft",
79
+ )
80
+
81
+ patches_rfft, weights = insert_central_slices_rfft_3d_multichannel(
82
+ image_rfft=particle_tilt_series_rfft,
83
+ volume_shape=(sidelength_padded_output,) * 3,
84
+ rotation_matrices=rotation_matrices,
85
+ zyx_matrices=True,
86
+ fftfreq_max=0.5,
87
+ )
88
+
89
+ valid_weights = weights > 1e-3
90
+ patches_rfft[:, valid_weights] /= weights[valid_weights]
91
+
92
+ patches_rfft = torch.fft.ifftshift(patches_rfft, dim=(-3, -2))
93
+
94
+ patches = torch.fft.irfftn(
95
+ patches_rfft,
96
+ s=(sidelength_padded_output,) * 3,
97
+ dim=(-3, -2, -1),
98
+ )
99
+
100
+ patches = torch.fft.ifftshift(patches, dim=(-3, -2, -1))
101
+
102
+ grid = fftfreq_grid(
103
+ image_shape=(sidelength_padded_output,) * 3,
104
+ rfft=False,
105
+ fftshift=True,
106
+ norm=True,
107
+ device=device,
108
+ )
109
+ patches = patches / torch.sinc(grid) ** 2
110
+
111
+ p = (sidelength_padded_output - sidelength) // 2
112
+ patches = F.pad(patches, [-p] * 6)
113
+
114
+ [patches] = einops.unpack(patches, ps, "* d h w")
115
+
116
+ return patches
117
+
118
+
119
+ def reconstruct_subvolume(
120
+ tilt_series: TiltSeries,
121
+ points_zyx: torch.Tensor,
122
+ sidelength: int,
123
+ output_pixel_spacing: float | None = None,
124
+ preprocess: bool = True,
125
+ **preprocessing_kwargs: Any,
126
+ ) -> torch.Tensor:
127
+ """Reconstruct 3D patch(es) at location(s) in the sample.
128
+
129
+ Rank-polymorphic: input (..., 3) -> output (..., d, h, w)
130
+
131
+ - tilt_series supplies the projection geometry,
132
+ - points_zyx are zyx coordinates in Angstroms, relative to the tomogram center
133
+ - sidelength is the output subvolume size in voxels
134
+ - output_pixel_spacing is the voxel size of the output in Angstroms
135
+ (defaults to `tilt_series.pixel_spacing`); the per-tilt 2D crops are
136
+ Fourier-rescaled to this pixel size before 3D reconstruction, so local
137
+ (subvolume) and global (tomogram) reconstructions can each target an
138
+ arbitrary output pixel size independent of the raw data's
139
+ - preprocess, if True (default), applies
140
+ `torch_tilt_series.preprocess_tilt_series_images` to the loaded images
141
+ before reconstruction - by default plane subtraction, a DC-excluding
142
+ bandpass with no low-pass, i.e. up to Nyquist, and central-crop
143
+ normalization
144
+ - `**preprocessing_kwargs` are forwarded to `preprocess_tilt_series_images`,
145
+ overriding any of its defaults (`low`, `high`, `falloff`,
146
+ `bandpass_padding`, `subtract_background`, `normalize`) - see that
147
+ function's docstring for details
148
+ """
149
+ images = load_tilt_series_images(tilt_series)
150
+ if preprocess:
151
+ images = preprocess_tilt_series_images(images, **preprocessing_kwargs)
152
+ return _reconstruct_subvolume(
153
+ tilt_series,
154
+ images,
155
+ points_zyx,
156
+ sidelength,
157
+ output_pixel_spacing=output_pixel_spacing,
158
+ )
159
+
160
+
161
+ def _cosine_taper_window(core_length: int, margin: int, device) -> torch.Tensor:
162
+ """1D cosine-taper window, flat in the middle, tapered at the edges.
163
+
164
+ 1.0 over the central `core_length` samples, cosine-tapered from 0 up to 1
165
+ (and back down to 0) over `margin` samples on each side. Total length is
166
+ core_length + 2 * margin.
167
+ """
168
+ if margin == 0:
169
+ return torch.ones(core_length, device=device)
170
+ ramp = 0.5 * (1 - torch.cos(torch.linspace(0, torch.pi, margin, device=device)))
171
+ core = torch.ones(core_length, device=device)
172
+ return torch.cat([ramp, core, ramp.flip(0)])
173
+
174
+
175
+ def reconstruct_tomogram(
176
+ tilt_series: TiltSeries,
177
+ volume_shape: tuple[int, int, int],
178
+ sidelength: int,
179
+ batch_size: int | None = None,
180
+ output_pixel_spacing: float | None = None,
181
+ preprocess: bool = True,
182
+ blend_margin: int | None = None,
183
+ **preprocessing_kwargs: Any,
184
+ ) -> torch.Tensor:
185
+ """Reconstruct the full tomogram by tiling reconstructed patches in 3D.
186
+
187
+ - tilt_series supplies the projection geometry, plus `image_path`/
188
+ `image_indices` used to load the raw images
189
+ - volume_shape is the (d, h, w) shape of the output tomogram, in voxels
190
+ - sidelength is the spacing between patch centers, in voxels; patches
191
+ are reconstructed on a grid tiling `volume_shape`
192
+ - batch_size, if set, reconstructs at most this many patches per chunk
193
+ (to bound memory usage); defaults to reconstructing all patches at once
194
+ - output_pixel_spacing is the voxel size of the output in Angstroms
195
+ (defaults to `tilt_series.pixel_spacing`)
196
+ - preprocess, if True (default), applies
197
+ `torch_tilt_series.preprocess_tilt_series_images` to the loaded images
198
+ before reconstruction - by default plane subtraction, a DC-excluding
199
+ bandpass with no low-pass, i.e. up to Nyquist, and central-crop
200
+ normalization
201
+ - blend_margin is the extra margin, in voxels, added around each patch
202
+ (total reconstructed patch size is `sidelength + 2 * blend_margin`);
203
+ overlapping patches are cosine-tapered and blended together over this
204
+ margin to avoid seams at patch boundaries. Defaults to
205
+ `sidelength // 4`
206
+ - `**preprocessing_kwargs` are forwarded to `preprocess_tilt_series_images`,
207
+ overriding any of its defaults (`low`, `high`, `falloff`,
208
+ `bandpass_padding`, `subtract_background`, `normalize`) - see that
209
+ function's docstring for details
210
+ """
211
+ images = load_tilt_series_images(tilt_series)
212
+ if preprocess:
213
+ images = preprocess_tilt_series_images(images, **preprocessing_kwargs)
214
+
215
+ pixel_spacing = tilt_series.pixel_spacing # raises if unset
216
+ if output_pixel_spacing is None:
217
+ output_pixel_spacing = pixel_spacing
218
+
219
+ if blend_margin is None:
220
+ blend_margin = sidelength // 4
221
+ patch_sidelength = sidelength + 2 * blend_margin
222
+ half = patch_sidelength // 2
223
+
224
+ d, h, w = volume_shape
225
+ r = sidelength // 2
226
+ device = images.device
227
+
228
+ z_centers = torch.arange(start=r, end=d + r, step=sidelength, device=device)
229
+ y_centers = torch.arange(start=r, end=h + r, step=sidelength, device=device)
230
+ x_centers = torch.arange(start=r, end=w + r, step=sidelength, device=device)
231
+ # absolute 0-indexed voxel coordinates of each patch center
232
+ centers_voxel = torch.stack(
233
+ torch.meshgrid(z_centers, y_centers, x_centers, indexing="ij"), dim=-1
234
+ )
235
+
236
+ volume_center = torch.tensor([d, h, w], device=device) // 2
237
+ centers_zyx_ang = (centers_voxel - volume_center) * output_pixel_spacing
238
+
239
+ window_1d = _cosine_taper_window(sidelength, blend_margin, device="cpu")
240
+ window_3d = (
241
+ window_1d[:, None, None] * window_1d[None, :, None] * window_1d[None, None, :]
242
+ )
243
+
244
+ tomogram_sum = torch.zeros(volume_shape, dtype=torch.float32)
245
+ weight_sum = torch.zeros(volume_shape, dtype=torch.float32)
246
+
247
+ centers_flat, _ = einops.pack([centers_voxel], "* zyx")
248
+ centers_ang_flat, _ = einops.pack([centers_zyx_ang], "* zyx")
249
+ chunk_size = batch_size or len(centers_flat)
250
+
251
+ for start in range(0, len(centers_flat), chunk_size):
252
+ chunk_centers = centers_flat[start : start + chunk_size]
253
+ chunk_centers_ang = centers_ang_flat[start : start + chunk_size]
254
+
255
+ patches_batch = _reconstruct_subvolume(
256
+ tilt_series,
257
+ images,
258
+ chunk_centers_ang,
259
+ patch_sidelength,
260
+ output_pixel_spacing=output_pixel_spacing,
261
+ ).cpu()
262
+
263
+ for j in range(len(patches_batch)):
264
+ cz, cy, cx = chunk_centers[j].tolist()
265
+ z0, y0, x0 = cz - half, cy - half, cx - half
266
+ z1, y1, x1 = (
267
+ z0 + patch_sidelength,
268
+ y0 + patch_sidelength,
269
+ x0 + patch_sidelength,
270
+ )
271
+
272
+ # clip the patch's placement to the volume bounds
273
+ cz0, cy0, cx0 = max(z0, 0), max(y0, 0), max(x0, 0)
274
+ cz1, cy1, cx1 = min(z1, d), min(y1, h), min(x1, w)
275
+ if cz0 >= cz1 or cy0 >= cy1 or cx0 >= cx1:
276
+ continue
277
+
278
+ src = (
279
+ slice(cz0 - z0, cz1 - z0),
280
+ slice(cy0 - y0, cy1 - y0),
281
+ slice(cx0 - x0, cx1 - x0),
282
+ )
283
+ dst = (slice(cz0, cz1), slice(cy0, cy1), slice(cx0, cx1))
284
+ weight_block = window_3d[src]
285
+ tomogram_sum[dst] += patches_batch[j][src] * weight_block
286
+ weight_sum[dst] += weight_block
287
+
288
+ del patches_batch
289
+ if device.type != "cpu":
290
+ torch.cuda.empty_cache()
291
+
292
+ tomogram = tomogram_sum / weight_sum.clamp_min(1e-6)
293
+
294
+ return tomogram.to(device)
@@ -0,0 +1,242 @@
1
+ import numpy as np
2
+ import pytest
3
+ import torch
4
+ from torch_tilt_series import TiltSeries
5
+
6
+ import torch_reconstruct_tomogram
7
+ from torch_reconstruct_tomogram import (
8
+ project_points,
9
+ reconstruct_subvolume,
10
+ reconstruct_tomogram,
11
+ )
12
+
13
+ DEVICES = ["cpu"] + (["cuda"] if torch.cuda.is_available() else [])
14
+
15
+
16
+ def make_tilt_series(tmp_path, device="cpu", size=32):
17
+ mrcfile = pytest.importorskip("mrcfile")
18
+ tilt_angles = torch.tensor([-30.0, 0.0, 30.0])
19
+ images = np.zeros((3, size, size), dtype=np.float32)
20
+ c = size // 2
21
+ images[:, c - 2 : c + 2, c - 2 : c + 2] = 1.0
22
+ image_path = tmp_path / "images.mrc"
23
+ mrcfile.write(image_path, images, overwrite=True)
24
+ return TiltSeries(
25
+ tilt_angles=tilt_angles,
26
+ tilt_axis_angle=torch.tensor(0.0),
27
+ sample_translations=torch.zeros((3, 2)),
28
+ image_path=image_path,
29
+ pixel_spacing=1.0,
30
+ device=device,
31
+ )
32
+
33
+
34
+ def test_imports_with_version():
35
+ assert isinstance(torch_reconstruct_tomogram.__version__, str)
36
+
37
+
38
+ @pytest.mark.parametrize("device", DEVICES)
39
+ def test_reconstruct_subvolume(device, tmp_path):
40
+ tilt_series = make_tilt_series(tmp_path, device)
41
+ point_zyx = torch.tensor([0.0, 0.0, 0.0], device=device)
42
+ subvolume = reconstruct_subvolume(tilt_series, point_zyx, sidelength=8)
43
+ assert subvolume.shape == (8, 8, 8)
44
+ assert subvolume.dtype == torch.float32
45
+ assert device in str(subvolume.device)
46
+ assert torch.isfinite(subvolume).all()
47
+
48
+
49
+ @pytest.mark.parametrize("device", DEVICES)
50
+ def test_reconstruct_subvolume_rank_polymorphic(device, tmp_path):
51
+ tilt_series = make_tilt_series(tmp_path, device)
52
+
53
+ # single point (3,) -> (d, h, w)
54
+ point = torch.tensor([0.0, 0.0, 0.0], device=device)
55
+ assert reconstruct_subvolume(tilt_series, point, sidelength=8).shape == (8, 8, 8)
56
+
57
+ # batch (N, 3) -> (N, d, h, w)
58
+ points = torch.tensor([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]], device=device)
59
+ assert reconstruct_subvolume(tilt_series, points, sidelength=8).shape == (
60
+ 2,
61
+ 8,
62
+ 8,
63
+ 8,
64
+ )
65
+
66
+ # 2D grid (a, b, 3) -> (a, b, d, h, w)
67
+ grid_2d = torch.zeros(2, 3, 3, device=device)
68
+ assert reconstruct_subvolume(tilt_series, grid_2d, sidelength=8).shape == (
69
+ 2,
70
+ 3,
71
+ 8,
72
+ 8,
73
+ 8,
74
+ )
75
+
76
+
77
+ @pytest.mark.parametrize("device", DEVICES)
78
+ def test_reconstruct_subvolume_output_pixel_spacing(device, tmp_path):
79
+ tilt_series = make_tilt_series(tmp_path, device)
80
+ point = torch.tensor([0.0, 0.0, 0.0], device=device)
81
+
82
+ subvolume_default = reconstruct_subvolume(tilt_series, point, sidelength=8)
83
+ subvolume_explicit = reconstruct_subvolume(
84
+ tilt_series, point, sidelength=8, output_pixel_spacing=1.0
85
+ )
86
+ assert torch.allclose(subvolume_default, subvolume_explicit)
87
+
88
+ subvolume_coarse = reconstruct_subvolume(
89
+ tilt_series, point, sidelength=8, output_pixel_spacing=2.0
90
+ )
91
+ assert subvolume_coarse.shape == (8, 8, 8)
92
+ assert torch.isfinite(subvolume_coarse).all()
93
+
94
+
95
+ @pytest.mark.parametrize("device", DEVICES)
96
+ def test_reconstruct_subvolume_local_shifts(device, tmp_path):
97
+ tilt_series = make_tilt_series(tmp_path, device)
98
+ point = torch.tensor([0.0, 0.0, 0.0], device=device)
99
+
100
+ def zero_local_shifts(projected_yx):
101
+ return torch.zeros_like(projected_yx)
102
+
103
+ subvolume_default = reconstruct_subvolume(tilt_series, point, sidelength=8)
104
+ tilt_series.local_shifts_2d = zero_local_shifts
105
+ subvolume_with_hook = reconstruct_subvolume(tilt_series, point, sidelength=8)
106
+ assert torch.allclose(subvolume_default, subvolume_with_hook)
107
+
108
+
109
+ def test_project_points_local_shifts_are_angstroms_not_pixels():
110
+ # pixel_spacing != 1 so an Angstrom-space shift and a pixel-space shift
111
+ # would disagree if local_shifts were (still) being applied in pixels.
112
+ shift_ang = torch.tensor([5.0, -3.0])
113
+
114
+ def shift_fn(projected_yx_ang):
115
+ return shift_ang.expand_as(projected_yx_ang)
116
+
117
+ tilt_series_shifted = TiltSeries(
118
+ tilt_angles=torch.tensor([0.0]),
119
+ tilt_axis_angle=torch.tensor(0.0),
120
+ sample_translations=torch.zeros((1, 2)),
121
+ pixel_spacing=2.0,
122
+ local_shifts_2d=shift_fn,
123
+ )
124
+ tilt_series = TiltSeries(
125
+ tilt_angles=torch.tensor([0.0]),
126
+ tilt_axis_angle=torch.tensor(0.0),
127
+ sample_translations=torch.zeros((1, 2)),
128
+ pixel_spacing=2.0,
129
+ )
130
+ point = torch.tensor([[0.0, 0.0, 0.0]])
131
+
132
+ shifted_px = project_points(tilt_series_shifted, point)
133
+ unshifted_px = project_points(tilt_series, point)
134
+ # shift is applied in Angstroms, then the whole result is divided by
135
+ # pixel_spacing so the pixel-space delta is shift_ang / pixel_spacing,
136
+ # not shift_ang itself.
137
+ expected_delta_px = shift_ang / tilt_series.pixel_spacing
138
+ assert torch.allclose(
139
+ (shifted_px - unshifted_px)[0, 0], expected_delta_px, atol=1e-5
140
+ )
141
+
142
+
143
+ @pytest.mark.parametrize("device", DEVICES)
144
+ def test_reconstruct_subvolume_preprocess_toggle(device, tmp_path):
145
+ tilt_series = make_tilt_series(tmp_path, device)
146
+ point = torch.tensor([0.0, 0.0, 0.0], device=device)
147
+
148
+ preprocessed = reconstruct_subvolume(
149
+ tilt_series, point, sidelength=8, preprocess=True
150
+ )
151
+ raw = reconstruct_subvolume(tilt_series, point, sidelength=8, preprocess=False)
152
+ assert not torch.allclose(preprocessed, raw)
153
+
154
+
155
+ @pytest.mark.parametrize("device", DEVICES)
156
+ def test_reconstruct_subvolume_preprocessing_kwargs_are_forwarded(device, tmp_path):
157
+ tilt_series = make_tilt_series(tmp_path, device)
158
+ point = torch.tensor([0.0, 0.0, 0.0], device=device)
159
+
160
+ default = reconstruct_subvolume(tilt_series, point, sidelength=8)
161
+ custom = reconstruct_subvolume(
162
+ tilt_series, point, sidelength=8, high=0.3, bandpass_padding=4
163
+ )
164
+ assert not torch.allclose(default, custom)
165
+
166
+
167
+ @pytest.mark.parametrize("device", DEVICES)
168
+ def test_reconstruct_tomogram_output_pixel_spacing(device, tmp_path):
169
+ tilt_series = make_tilt_series(tmp_path, device)
170
+ volume = reconstruct_tomogram(
171
+ tilt_series, (16, 16, 16), sidelength=8, output_pixel_spacing=2.0
172
+ )
173
+ assert volume.shape == (16, 16, 16)
174
+ assert torch.isfinite(volume).all()
175
+
176
+
177
+ @pytest.mark.parametrize("device", DEVICES)
178
+ def test_reconstruct_tomogram(device, tmp_path):
179
+ tilt_series = make_tilt_series(tmp_path, device)
180
+ volume = reconstruct_tomogram(tilt_series, (16, 16, 16), sidelength=8)
181
+ assert volume.shape == (16, 16, 16)
182
+ assert volume.dtype == torch.float32
183
+ assert device in str(volume.device)
184
+ assert torch.isfinite(volume).all()
185
+
186
+
187
+ @pytest.mark.parametrize("device", DEVICES)
188
+ def test_reconstruct_tomogram_non_cubic(device, tmp_path):
189
+ tilt_series = make_tilt_series(tmp_path, device)
190
+ # shape not divisible by sidelength is still cropped to the requested shape
191
+ volume = reconstruct_tomogram(tilt_series, (8, 24, 20), sidelength=8)
192
+ assert volume.shape == (8, 24, 20)
193
+
194
+
195
+ @pytest.mark.parametrize("device", DEVICES)
196
+ def test_reconstruct_tomogram_batch_size(device, tmp_path):
197
+ tilt_series = make_tilt_series(tmp_path, device)
198
+ recon_no_batch = reconstruct_tomogram(tilt_series, (16, 16, 16), sidelength=8)
199
+ recon_with_batch = reconstruct_tomogram(
200
+ tilt_series, (16, 16, 16), sidelength=8, batch_size=2
201
+ )
202
+ assert recon_no_batch.shape == (16, 16, 16)
203
+ assert recon_with_batch.shape == (16, 16, 16)
204
+ diff = torch.abs(recon_no_batch - recon_with_batch.to(recon_no_batch.device)).max()
205
+ assert diff == 0.0
206
+
207
+
208
+ def test_reconstruct_subvolume_rotation_includes_tomo2sample(tmp_path, monkeypatch):
209
+ """The Fourier-insertion rotation must be the tomogram -> detector
210
+ rotation (projection_matrices' rotation composed with tomo2sample's),
211
+ not just projection_matrices' sample -> detector rotation alone.
212
+ Otherwise every patch is reconstructed oriented to sample space while
213
+ being tiled into an axis-aligned tomogram-space grid, tearing apart at
214
+ patch boundaries whenever sample2tomo carries a rotation. No-op when
215
+ sample2tomo is the default identity, which is why this needs its own
216
+ explicit test rather than relying on the (identity-only) tests above.
217
+ """
218
+ from torch_affine_utils.transforms_3d import Rz
219
+
220
+ import torch_reconstruct_tomogram.reconstruct as reconstruct_module
221
+
222
+ tilt_series = make_tilt_series(tmp_path)
223
+ tilt_series.levelled2tomo = Rz(torch.tensor(37.0), zyx=True, device="cpu")
224
+
225
+ captured = {}
226
+ real_insert = reconstruct_module.insert_central_slices_rfft_3d_multichannel
227
+
228
+ def spy(*args, **kwargs):
229
+ captured["rotation_matrices"] = kwargs["rotation_matrices"].clone()
230
+ return real_insert(*args, **kwargs)
231
+
232
+ monkeypatch.setattr(
233
+ reconstruct_module, "insert_central_slices_rfft_3d_multichannel", spy
234
+ )
235
+
236
+ reconstruct_subvolume(tilt_series, torch.tensor([0.0, 0.0, 0.0]), sidelength=8)
237
+
238
+ expected_forward = (
239
+ tilt_series.projection_matrices[:, :3, :3] @ tilt_series.tomo2sample[:3, :3]
240
+ )
241
+ expected = torch.linalg.pinv(expected_forward)
242
+ assert torch.allclose(captured["rotation_matrices"], expected, atol=1e-5)