From 92590540a90fc3596b762b896b3de305f6a618e7 Mon Sep 17 00:00:00 2001 From: Josh Carmichael Date: Tue, 5 Aug 2025 10:42:01 -0400 Subject: [PATCH 1/4] Minimally invasive adoption of _rlnOriginX(Y)Angst. --- src/aspire/source/image.py | 29 +++++++++++++++++++++-------- src/aspire/source/relion.py | 9 +++++++++ src/aspire/utils/relion_interop.py | 2 ++ tests/test_relion_source.py | 26 ++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/aspire/source/image.py b/src/aspire/source/image.py index f4f30bf481..e5dae59dfe 100644 --- a/src/aspire/source/image.py +++ b/src/aspire/source/image.py @@ -417,17 +417,29 @@ def filter_indices(self, indices): @property def offsets(self): - return np.atleast_2d( - self.get_metadata( - ["_rlnOriginX", "_rlnOriginY"], - default_value=np.array(0.0, dtype=self.dtype), + """ + Get pixel offsets. + """ + px_sz = self.pixel_size or 1.0 + return ( + np.atleast_2d( + self.get_metadata( + ["_rlnOriginXAngst", "_rlnOriginYAngst"], + default_value=np.array(0.0, dtype=self.dtype), + ) ) + / px_sz ) @offsets.setter def offsets(self, values): + """ + Set angstrom valued offsets from pixel offset values. + """ + px_sz = self.pixel_size or 1.0 return self.set_metadata( - ["_rlnOriginX", "_rlnOriginY"], np.array(values, dtype=self.dtype) + ["_rlnOriginXAngst", "_rlnOriginYAngst"], + np.array(values * px_sz, dtype=self.dtype), ) @property @@ -782,9 +794,10 @@ def downsample(self, L, zero_nyquist=True, legacy=False): ds_factor = self.L / L self.unique_filters = [f.scale(ds_factor) for f in self.unique_filters] - self.offsets /= ds_factor if self.pixel_size is not None: self.pixel_size *= ds_factor + else: + self.offsets /= ds_factor self.L = L @@ -1657,8 +1670,8 @@ def _reset_orientation(self): "_rlnAngleRot", "_rlnAngleTilt", "_rlnAnglePsi", - "_rlnOriginX", - "_rlnOriginY", + "_rlnOriginXAngst", + "_rlnOriginYAngst", ] for key in rot_keys: if self.has_metadata(key): diff --git a/src/aspire/source/relion.py b/src/aspire/source/relion.py index a70367643b..dd705675d8 100644 --- a/src/aspire/source/relion.py +++ b/src/aspire/source/relion.py @@ -130,6 +130,15 @@ def __init__( pixel_size = 1.0 self.pixel_size = float(pixel_size) + # Ensure Relion >= 3.1 convention for offsets + offset_keys = ["_rlnOriginX", "_rlnOriginY"] + if self.has_metadata(offset_keys): + # The setter will store offsets as _rlnOriginX(Y)Angst in metadata + self.offsets = np.atleast_2d(self.get_metadata(offset_keys)) + # Remove old convention from metadata + for key in offset_keys: + del self._metadata[key] + # CTF estimation parameters coming from Relion CTF_params = [ "_rlnVoltage", diff --git a/src/aspire/utils/relion_interop.py b/src/aspire/utils/relion_interop.py index a359a48448..996d321366 100644 --- a/src/aspire/utils/relion_interop.py +++ b/src/aspire/utils/relion_interop.py @@ -34,6 +34,8 @@ "_rlnGroupNumber": str, "_rlnOriginX": float, "_rlnOriginY": float, + "_rlnOriginXAngst": float, + "_rlnOriginYAngst": float, "_rlnAngleRot": float, "_rlnAngleTilt": float, "_rlnAnglePsi": float, diff --git a/tests/test_relion_source.py b/tests/test_relion_source.py index 2f789999ef..5ef60bc3e5 100644 --- a/tests/test_relion_source.py +++ b/tests/test_relion_source.py @@ -5,6 +5,7 @@ import pytest from aspire.source import RelionSource +from aspire.utils import RelionStarFile from aspire.volume import SymmetryGroup from .test_starfile_stack import StarFileTestCase @@ -97,3 +98,28 @@ def test_pixel_size(caplog): src = RelionSource(starfile_no_pix_size) assert msg in caplog.text np.testing.assert_equal(src.pixel_size, 1.0) + + +def test_offsets(): + """ + Check that offset convention gets converted to Relion >= 3.1 convention. + """ + starfile = os.path.join(DATA_DIR, "sample_particles_relion30.star") + + # Extract pixel valued offsets from starfile prior to source instantiation. + metadata = RelionStarFile(starfile).get_merged_data_block() + pixel_offsets = np.column_stack((metadata["_rlnOriginX"], metadata["_rlnOriginY"])) + + # Create Relion Source and extract offsets from metadata using updated field names. + src = RelionSource(starfile) + angst_offsets = src.get_metadata(["_rlnOriginXAngst", "_rlnOriginYAngst"]) + + # Check that old convention offset fields have been removed. + assert "_rlnOriginX" not in src._metadata + assert "_rlnOriginY" not in src._metadata + + # Check that offsets in metadata match upto pixel/angstrom conversion. + np.testing.assert_allclose(angst_offsets / src.pixel_size, pixel_offsets) + + # src.offsets should still return pixel valued offsets. + np.testing.assert_allclose(src.offsets, pixel_offsets) From c4bc3bf3596fa2ccdf595a468073e2fdd402ab8a Mon Sep 17 00:00:00 2001 From: Josh Carmichael Date: Tue, 5 Aug 2025 11:12:17 -0400 Subject: [PATCH 2/4] code comments --- src/aspire/source/image.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/aspire/source/image.py b/src/aspire/source/image.py index e5dae59dfe..c07685f459 100644 --- a/src/aspire/source/image.py +++ b/src/aspire/source/image.py @@ -420,6 +420,7 @@ def offsets(self): """ Get pixel offsets. """ + # Use pixel_size = 1 for sources agnostic to pixel_size. px_sz = self.pixel_size or 1.0 return ( np.atleast_2d( @@ -436,6 +437,7 @@ def offsets(self, values): """ Set angstrom valued offsets from pixel offset values. """ + # Use pixel_size = 1 for sources agnostic to pixel_size. px_sz = self.pixel_size or 1.0 return self.set_metadata( ["_rlnOriginXAngst", "_rlnOriginYAngst"], @@ -797,6 +799,7 @@ def downsample(self, L, zero_nyquist=True, legacy=False): if self.pixel_size is not None: self.pixel_size *= ds_factor else: + # For sources agnostic to pixel size, offsets must be explicitly scaled. self.offsets /= ds_factor self.L = L From 99a8b30d50311f1b5fa0c232fa13bd6d80646d1f Mon Sep 17 00:00:00 2001 From: Josh Carmichael Date: Wed, 6 Aug 2025 08:54:14 -0400 Subject: [PATCH 3/4] typo --- tests/test_relion_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_relion_source.py b/tests/test_relion_source.py index 5ef60bc3e5..fb3d429c6f 100644 --- a/tests/test_relion_source.py +++ b/tests/test_relion_source.py @@ -118,7 +118,7 @@ def test_offsets(): assert "_rlnOriginX" not in src._metadata assert "_rlnOriginY" not in src._metadata - # Check that offsets in metadata match upto pixel/angstrom conversion. + # Check that offsets in metadata match up to pixel/angstrom conversion. np.testing.assert_allclose(angst_offsets / src.pixel_size, pixel_offsets) # src.offsets should still return pixel valued offsets. From b89914d463ea435f84f5902254460d1b3bc59d5f Mon Sep 17 00:00:00 2001 From: Josh Carmichael Date: Wed, 6 Aug 2025 13:51:20 -0400 Subject: [PATCH 4/4] offset tests save/load --- tests/test_relion_source.py | 44 ++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/tests/test_relion_source.py b/tests/test_relion_source.py index fb3d429c6f..b5d5fcf5ca 100644 --- a/tests/test_relion_source.py +++ b/tests/test_relion_source.py @@ -100,7 +100,7 @@ def test_pixel_size(caplog): np.testing.assert_equal(src.pixel_size, 1.0) -def test_offsets(): +def test_offsets_conversion(): """ Check that offset convention gets converted to Relion >= 3.1 convention. """ @@ -123,3 +123,45 @@ def test_offsets(): # src.offsets should still return pixel valued offsets. np.testing.assert_allclose(src.offsets, pixel_offsets) + + +def test_offsets(): + """ + Check that offsets are loaded properly with starfile field _rlnOriginX(Y)Angst. + """ + # This starfile has offsets stored with angstrom values as _rlnOriginX(Y)Angst. + starfile = os.path.join(DATA_DIR, "sample_particles_relion31.star") + + # Create a RelionSource + src = RelionSource(starfile) + + # Check offsets are angstrom valued in metadata and correspond to src.offsets. + angst_offsets = src.get_metadata(["_rlnOriginXAngst", "_rlnOriginYAngst"]) + np.testing.assert_allclose(src.offsets * src.pixel_size, angst_offsets) + + +def test_offsets_save(tmp_path): + """ + Test that saving a RelionSource that was loaded with pixel offsets + saves with angstrom valued offsets. + """ + # Starfile with pixel offsets. + starfile = os.path.join(DATA_DIR, "sample_particles_relion30.star") + + # Extract pixel valued offsets from starfile prior to source instantiation. + metadata = RelionStarFile(starfile).get_merged_data_block() + pixel_offsets = np.column_stack((metadata["_rlnOriginX"], metadata["_rlnOriginY"])) + + # Create and RelionSource and save to starfile. + src = RelionSource(starfile) + save_path = tmp_path / "test_file.star" + src.save(save_path) + + # Saved starfile should have angstrom valued offsets. + metadata = RelionStarFile(save_path).get_merged_data_block() + angst_offsets = np.column_stack( + (metadata["_rlnOriginXAngst"], metadata["_rlnOriginYAngst"]) + ) + + # Check saved offsets match original up to pixel_size scaling. + np.testing.assert_allclose(angst_offsets / src.pixel_size, pixel_offsets)