Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
9e5eb62
Simulation pixel_size default=1.0. Private/public Image.downsample to…
j-c-c Jul 11, 2025
3a2a851
Fix gallery pixel_size
j-c-c Jul 14, 2025
7688e41
Adopt Relion 3.1 offset convention (_rlnOriginX(Y)Angst). Default pix…
j-c-c Jul 14, 2025
5737bd6
Require pixel_size for all ImageSource objects
j-c-c Jul 18, 2025
94a556b
tox
j-c-c Jul 18, 2025
fc0a059
cleanup Simulation pixel_size behavior. Fix docstring. Add test.
j-c-c Jul 23, 2025
2588e71
Resolve Errors and failing tests
j-c-c Sep 16, 2025
b4fc52c
Fix galleries
j-c-c Sep 16, 2025
e001c01
fix param in gallery
j-c-c Sep 16, 2025
137ddd4
more rebase cleanup
j-c-c Sep 17, 2025
f740d88
Update Simulation pixel_size behavior. Warn on mismatch.
j-c-c Sep 18, 2025
6ee4315
_populate_pixel_size. Handle mismatches. tests
j-c-c Sep 19, 2025
8849024
Fix failing tests
j-c-c Sep 19, 2025
2d00e25
pixel_size setter/getter.
j-c-c Sep 19, 2025
099bb38
add offset tests
j-c-c Sep 19, 2025
73fc3a8
test src.downsample.pixel_size == src.img.downsample.pixel_size
j-c-c Sep 22, 2025
4cc1760
test downsample save.
j-c-c Sep 25, 2025
1551aa4
Remove detector metadata after src.downsample
j-c-c Sep 25, 2025
edd10cb
Remove xfail and fix apply_sim_filters bug
j-c-c Sep 29, 2025
2063e69
ArrayImageSource pixel_size logic. Testing.
j-c-c Sep 30, 2025
d987367
fix gallery
j-c-c Sep 30, 2025
9ce428b
CoordinateSource pixel_size behavior. Testing
j-c-c Sep 30, 2025
5d1219f
Add pixel_size docstring to MicrographSimulation
j-c-c Sep 30, 2025
6a3e997
edit docstring
j-c-c Oct 1, 2025
b73a355
asnumpy
j-c-c Oct 1, 2025
8a2cf4a
update docstring for required pixel_size
j-c-c Oct 1, 2025
999199d
clean up _populate_pixel_size logic
j-c-c Oct 1, 2025
ef3a0c2
_projection_pixel_size. test projections and clean_images.
j-c-c Oct 2, 2025
26c86c0
pop_metadata
j-c-c Oct 2, 2025
72eb2ad
check_pixel_size_mismatch util function.
j-c-c Oct 3, 2025
fed43aa
tox
j-c-c Oct 3, 2025
8deee0b
offsets stored in doubles. pixels stored as float.
j-c-c Oct 7, 2025
f89a5b5
remove strict
j-c-c Oct 7, 2025
3dee8c3
pixel_size logic
j-c-c Oct 8, 2025
a9d2d41
update check_pixel_size
j-c-c Oct 8, 2025
fdeec0f
cast pixel_size as float correctly. Add test to check pixel_size is c…
j-c-c Oct 8, 2025
341d44e
check for scalar pixel_size. Add test.
j-c-c Oct 8, 2025
a3c6f4a
change argument names
j-c-c Oct 9, 2025
f58eab7
Remove None default for CoordinateSource pixel_size
j-c-c Oct 17, 2025
1bab70c
typos
j-c-c Oct 17, 2025
4680fc0
clarifying pixel_size comment in Downsample xform
j-c-c Oct 17, 2025
7d10379
update check_pixel_size warning message. fix broken test.
j-c-c Oct 17, 2025
339fef0
fix gallery
j-c-c Oct 17, 2025
2cea2fc
update extract_particles CLI
j-c-c Oct 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gallery/tutorials/tutorials/basic_image_array.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ def noise_function(x, y):
# The implementation of batching for memory management
# would be managed behind the scenes for you.

imgs_src = ArrayImageSource(imgs_with_noise)
imgs_src = ArrayImageSource(imgs_with_noise, pixel_size=1.0)

# We'll copy the orginals for comparison later, before we process them further.
noisy_imgs_copy = imgs_src.images[:n_imgs].asnumpy()
Expand Down
2 changes: 1 addition & 1 deletion gallery/tutorials/tutorials/class_averaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@

# So now that we have cooked up an example dataset, lets create an
# ASPIRE source
src = ArrayImageSource(example_array)
src = ArrayImageSource(example_array, pixel_size=1.0)

# Let's peek at the images to make sure they're shuffled up nicely
src.images[:10].show()
Expand Down
6 changes: 5 additions & 1 deletion gallery/tutorials/tutorials/micrograph_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,11 @@

# %%

img_src = CentersCoordinateSource(results, src.particle_box_size)
img_src = CentersCoordinateSource(
results,
src.pixel_size,
src.particle_box_size,
)
# Show the first five images from the image source.
img_src.images[:3].show()

Expand Down
2 changes: 1 addition & 1 deletion gallery/tutorials/tutorials/relion_projection_interop.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
# We load the Relion projections as a ``RelionSource`` and view the images.

starfile = os.path.join(os.path.dirname(os.getcwd()), "data", "rln_proj_65.star")
rln_src = RelionSource(starfile)
rln_src = RelionSource(starfile, pixel_size=1)
rln_src.images[:].show(colorbar=False)

# %%
Expand Down
9 changes: 9 additions & 0 deletions src/aspire/commands/extract_particles.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@
required=True,
help="Path to starfile of the particle stack to be created",
)
@click.option(
"--pixel_size",
default=None,
type=float,
help="Pixel size of micrograph in angstroms.",
)
@click.option(
"--particle_size",
default=None,
Expand Down Expand Up @@ -77,6 +83,7 @@ def extract_particles(
mrc_paths,
coord_paths,
starfile_out,
pixel_size,
particle_size,
centers,
downsample,
Expand Down Expand Up @@ -133,11 +140,13 @@ def extract_particles(
)
src = CentersCoordinateSource(
files,
pixel_size,
particle_size=particle_size,
)
else:
src = BoxesCoordinateSource(
files,
pixel_size,
particle_size=particle_size,
)

Expand Down
2 changes: 1 addition & 1 deletion src/aspire/commands/orient3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
help="Path to output STAR file relative to data folder",
)
@click.option(
"--pixel_size", default=1, type=float, help="Pixel size of images in STAR file"
"--pixel_size", default=None, type=float, help="Pixel size of images in STAR file"
)
@click.option(
"--max_rows",
Expand Down
4 changes: 2 additions & 2 deletions src/aspire/downloader/data_fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,8 @@ def simulated_channelspin():
data = dict(data)

# Instantiate ASPIRE objects where appropriate
data["vols"] = Volume(data["vols"])
data["images"] = Image(data["images"])
data["vols"] = Volume(data["vols"], pixel_size=1.0)
data["images"] = Image(data["images"], pixel_size=1.0)
data["rots"] = Rotation(_LegacySimulation.rots_zyx_to_legacy_aspire(data["rots"]))

return data
41 changes: 31 additions & 10 deletions src/aspire/image/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,32 +519,32 @@ def legacy_whiten(self, psd, delta):

return Image(res)

def downsample(self, ds_res, zero_nyquist=True, centered_fft=True):
@staticmethod
def _downsample(data, ds_res, zero_nyquist=True, centered_fft=True):
"""
Downsample Image to a specific resolution. This method returns a new Image.
Downsample Image data to a specific resolution.

:param data: Numpy array of Image data, shape (n_imgs, resolution, resolution).
:param ds_res: int - new resolution, should be <= the current resolution
of this Image
:param zero_nyquist: Option to keep or remove Nyquist frequency for even
resolution (boolean). Defaults to zero_nyquist=True, removing the Nyquist frequency.
:param centered_fft: Default of True uses `centered_fft` to
maintain ASPIRE-Python centering conventions.

:return: The downsampled Image object.
:return: NumPy array of downsampled Image data.
"""

original_stack_shape = self.stack_shape
im = self.stack_reshape(-1)

# Note image data is intentionally migrated via `xp.asarray`
# because all of the subsequent calls until `asnumpy` are GPU
# when xp and fft in `cupy` mode.
resolution = data.shape[-1]

if centered_fft:
# compute FT with centered 0-frequency
fx = fft.centered_fft2(xp.asarray(im._data))
fx = fft.centered_fft2(xp.asarray(data))
else:
fx = fft.fftshift(fft.fft2(xp.asarray(im._data)))
fx = fft.fftshift(fft.fft2(xp.asarray(data)))

# crop 2D Fourier transform for each image
crop_fx = crop_pad_2d(fx, ds_res)
Expand All @@ -565,14 +565,35 @@ def downsample(self, ds_res, zero_nyquist=True, centered_fft=True):
# At time of writing CuPy is consistent with Numpy1.
# The additional parenths yield consistent out.dtype.
# See #1298 for relevant debugger output.
out = xp.asnumpy(out.real * (ds_res**2 / self.resolution**2))
out = xp.asnumpy(out.real * (ds_res**2 / resolution**2))

return out

def downsample(self, ds_res, zero_nyquist=True, centered_fft=True):
"""
Downsample Image to a specific resolution. This method returns a new Image.

:param ds_res: int - new resolution, should be <= the current resolution
of this Image
:param zero_nyquist: Option to keep or remove Nyquist frequency for even
resolution (boolean). Defaults to zero_nyquist=True, removing the Nyquist frequency.
:param centered_fft: Default of True uses `centered_fft` to
maintain ASPIRE-Python centering conventions.
:return: The downsampled Image object.
"""
original_stack_shape = self.stack_shape
data = self.stack_reshape(-1).asnumpy()

ims_ds = self._downsample(
data, ds_res, zero_nyquist=zero_nyquist, centered_fft=centered_fft
)

# Optionally scale pixel size
ds_pixel_size = self.pixel_size
if ds_pixel_size is not None:
ds_pixel_size *= self.resolution / ds_res

return self.__class__(out, pixel_size=ds_pixel_size).stack_reshape(
return self.__class__(ims_ds, pixel_size=ds_pixel_size).stack_reshape(
original_stack_shape
)

Expand Down
11 changes: 10 additions & 1 deletion src/aspire/image/xform.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,12 +217,21 @@ def __init__(self, resolution, zero_nyquist=True, centered_fft=True):
super().__init__()

def _forward(self, im, indices):
return im.downsample(
original_stack_shape = im.stack_shape
data = im.stack_reshape(-1)._data
im_ds = Image._downsample(
data,
self.resolution,
zero_nyquist=self.zero_nyquist,
centered_fft=self.centered_fft,
)

# pixel_size has already been adjusted in the ImageSource and passed
# to `im`, so we instantiate the new Image with im.pixel_size.
return Image(im_ds, pixel_size=im.pixel_size).stack_reshape(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we are bypassing the standard Image.downsample method in order to keep the pixel_size the same. Why?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the time this generation pipeline is kicked off by requesting images the pixel_size has already been adjusted as an image attribute and in metadata in the ImageSource.downsample method. If we use Image.downsample here the pixel size will get adjusted twice.

original_stack_shape
)

def _adjoint(self, im, indices):
# TODO: Implement up-sampling with zero-padding
raise NotImplementedError("Adjoint of downsampling not implemented yet.")
Expand Down
59 changes: 18 additions & 41 deletions src/aspire/source/coordinates.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import logging
import os
import warnings
from abc import ABC, abstractmethod
from collections import defaultdict
from collections.abc import Iterable
Expand All @@ -13,7 +12,7 @@
from aspire.operators import CTFFilter, IdentityFilter
from aspire.source.image import ImageSource
from aspire.storage import StarFile
from aspire.utils import RelionStarFile
from aspire.utils import RelionStarFile, check_pixel_size

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -48,17 +47,21 @@ class CoordinateSource(ImageSource, ABC):
"""

def __init__(
self, files, particle_size, max_rows, B, symmetry_group, pixel_size=None
self,
files,
pixel_size,
particle_size,
max_rows,
B,
symmetry_group,
):
"""
:param files: A list of tuples of the form (path_to_mrc, path_to_coord)
:param pixel_size: Pixel size of the images in angstroms (Required)
:param particle_size: Desired size of cropped particles (will override the size specified in coordinate file)
:param max_rows: Maximum number of particles to read. (If `None`, will attempt to load all particles)
:param B: CTF envelope decay factor
:param symmetry_group: A `SymmetryGroup` object or string corresponding to the symmetry of the molecule.
:param pixel_size: Pixel size of the images in angstroms.
Default `None` will attempt to infer `pixel_size` from
`CTFFilter` objects when available.
"""
mrc_paths, coord_paths = [f[0] for f in files], [f[1] for f in files]
# the particle_size parameter is the *user-specified* argument
Expand Down Expand Up @@ -405,32 +408,10 @@ def _extract_ctf(self, data_block):
# convert defocus_ang from degrees to radians
filter_params[:, 3] *= np.pi / 180.0

# Check pixel_size
# Get pixel_sizes from CTFFilters
# Warn if CTF pixel_sizes do match self.pixel_size
ctf_pixel_sizes = np.unique(filter_params[:, 6])
# Compare with source.pixel_size if assigned
if (self.pixel_size is not None) and (
not np.allclose(ctf_pixel_sizes, self.pixel_size)
):
warnings.warn(
"Pixel size mismatch."
f"\n\tSource: {self.pixel_size}"
f"\n\tCTFs: {ctf_pixel_sizes}.",
stacklevel=2,
)
# When source is not assigned we can try to assign it from CTF,
elif self.pixel_size is None:
# but only do this if all the CTFFilter pixel_sizes are consistent
if len(ctf_pixel_sizes) == 1:
self.pixel_size = ctf_pixel_sizes[0] # take the unique single element
logger.info(
f"Assigning source pixel_size={self.pixel_size} from CTFFilters."
)
# otherwise let the user know
elif len(ctf_pixel_sizes) > 1:
logger.warning(
"Unable to assign source pixel_size from CTFFilters, multiple pixel_sizes found."
)
check_pixel_size(ctf_pixel_sizes, self.pixel_size)

# construct filters
self.unique_filters = [
CTFFilter(
Expand Down Expand Up @@ -557,31 +538,29 @@ class BoxesCoordinateSource(CoordinateSource):
def __init__(
self,
files,
pixel_size,
particle_size=None,
max_rows=None,
B=0,
symmetry_group=None,
pixel_size=None,
):
"""
:param files: A list of tuples of the form (path_to_mrc, path_to_coord)
:param pixel_size: Pixel size of the images in angstroms (Required)
:param particle_size: Desired size of cropped particles (will override the size specified in coordinate file)
:param max_rows: Maximum number of particles to read. (If `None`, will attempt to load all particles)
:param B: CTF envelope decay factor
:param symmetry_group: A `SymmetryGroup` object or string corresponding to the symmetry of the molecule.
:param pixel_size: Pixel size of the images in angstroms.
Default `None` will attempt to infer `pixel_size` from
`CTFFilter` objects when available.
"""
# instantiate super
CoordinateSource.__init__(
self,
files,
pixel_size,
particle_size,
max_rows,
B,
symmetry_group,
pixel_size=pixel_size,
)

def _extract_box_size(self, box_file):
Expand Down Expand Up @@ -688,32 +667,30 @@ class CentersCoordinateSource(CoordinateSource):
def __init__(
self,
files,
pixel_size,
particle_size,
max_rows=None,
B=0,
symmetry_group=None,
pixel_size=None,
):
"""
:param files: A list of tuples of the form (path_to_mrc, path_to_coord)
:param pixel_size: Pixel size of the images in angstroms (Required).
:param particle_size: Desired size of cropped particles (mandatory)
:param max_rows: Maximum number of particles to read. (If `None`, will
attempt to load all particles)
:param B: CTF envelope decay factor
:param symmetry_group: A `SymmetryGroup` object or string corresponding to the symmetry of the molecule.
:param pixel_size: Pixel size of the images in angstroms.
Default `None` will attempt to infer `pixel_size` from
`CTFFilter` objects when available.
"""
# instantiate super
CoordinateSource.__init__(
self,
files,
pixel_size,
particle_size,
max_rows,
B,
symmetry_group,
pixel_size=pixel_size,
)

def _validate_centers_file(self, coord_file):
Expand Down
Loading
Loading