Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
192 changes: 188 additions & 4 deletions src/aspire/abinitio/commonline_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ def estimate_shifts(self):
"""

# Generate approximated shift equations from estimated rotations
shift_equations, shift_b = self._get_shift_equations_approx()
shift_equations, shift_b = self._get_shift_equations()

# Solve the linear equation, optionally printing numerical debug details.
show = False
Expand Down Expand Up @@ -241,6 +241,22 @@ def estimate(self, **kwargs):

return self.rotations, self.shifts

def _get_shift_equations(self):
"""
Generate shift equations from the estimated rotations.

Dispatches to the legacy asymmetric shift-equation construction for C1
sources, and to the symmetry-expanded construction for sources with
nontrivial symmetry. This keeps the C1 code path unchanged while allowing
symmetric molecules to contribute multiple common-line equations per image
pair without introducing additional shift unknowns.

:return: The sparse shift-equation matrix and right-hand side vector.
"""
if str(self.src.symmetry_group) == "C1":
return self._get_shift_equations_approx()
return self._get_shift_equations_approx_symmetric()

def _get_shift_equations_approx(self):
"""
Generate approximated shift equations from estimated rotations
Expand Down Expand Up @@ -380,15 +396,160 @@ def _get_shift_equations_approx(self):

return shift_equations, shift_b

def _estimate_num_shift_equations(self, n_img):
def _get_shift_equations_approx_symmetric(self):
"""
Generate symmetry-expanded approximate shift equations from estimated rotations.

For each sampled image pair, this method computes the common lines induced by
the first image rotation and every symmetry-transformed copy of the second
image rotation. Each symmetry copy contributes one shift equation involving
the same two 2D image-shift unknowns, adding constraints without duplicating
images or introducing independent shift variables for symmetry copies.

:return: The sparse shift-equation matrix and right-hand side vector.
"""

n_theta_half = self.n_theta // 2
n_img = self.n_img
pf = self.pf.copy()

# `estimate_shifts()` requires that rotations have already been estimated.
rotations = self.rotations

# Apply symmetry group to rotations.
# Symmetry copies add more equations for the same per-image shift unknowns.
sym_rots = self.src.symmetry_group.matrices.astype(self.dtype, copy=False)
n_sym = len(sym_rots)

# Estimate base image-pair equations, then expand each pair by symmetry.
n_pair_equations = self._estimate_num_shift_equations(n_img, n_sym=n_sym)
n_equations = n_pair_equations * n_sym

# Allocate local variables for estimating 2D shifts based on the estimated number
# of equations. The shift equations are represented using a sparse matrix,
# since each row in the system contains four non-zeros (as it involves
# exactly four unknowns). The variables below are used to construct
# this sparse system. The k'th non-zero element of the equations matrix
# is stored at index (shift_i(k),shift_j(k)).
shift_i = np.zeros((n_equations, 4), dtype=self.dtype)
shift_j = np.zeros((n_equations, 4), dtype=self.dtype)
shift_eq = np.zeros((n_equations, 4), dtype=self.dtype)
shift_b = np.zeros(n_equations, dtype=self.dtype)

# Prepare the shift phases to try and generate filter for common-line detection
# The shift phases are pre-defined in a range of max_shift that can be
# applied to maximize the common line calculation. The common-line filter
# is also applied to the radial direction for easier detection.
r_max = pf.shape[2]
_, shift_phases, h = _generate_shift_phase_and_filter(
r_max, self.offsets_max_shift, self.offsets_shift_step, self.dtype
)

d_theta = np.pi / n_theta_half

# Generate base [i, j] image pairs before symmetry expansion.
idx_i, idx_j = self._generate_index_pairs(n_pair_equations)

# Filter, normalize, and conjugate all rays once instead of once per equation.
# Conjugation uses ray from opposite side of origin.
# Correpsonds to `freqs` convention in PFT,
# where the legacy code used a negated frequency grid.
pf = np.conj(self._apply_filter_and_norm("ijk, k -> ijk", pf, r_max, h))

# Iterate over image pairs; each iteration fills one block of n_sym
# symmetry-induced common-line shift equations.
for pair_eq_idx in range(n_pair_equations):
i = idx_i[pair_eq_idx]
j = idx_j[pair_eq_idx]
rows = pair_eq_idx + np.arange(n_sym) * n_pair_equations

# Common lines for Ri against all symmetry copies g @ Rj.
Rjs = sym_rots @ rotations[j]
c_ij, c_ji = self._get_cl_indices_from_rot_pairs(
rotations[i], Rjs, n_theta_half
)

# Extract the Fourier rays that correspond to the common lines
pf_i = pf[i, c_ij] # shape (n_sym, n_rad)

# Track which symmetry-induced rays in image j use the opposite ray direction.
is_pf_j_flipped = c_ji >= n_theta_half
pf_j = pf[j, c_ji % n_theta_half]

# Apply candidate 1D shifts to all symmetry-induced rays for this image pair.
pf_i_stack = pf_i[:, :, None] * shift_phases.T[None, :, :]
pf_i_flipped_stack = np.conj(pf_i)[:, :, None] * shift_phases.T[None, :, :]

c1 = 2 * np.sum(pf_i_stack.conj() * pf_j[:, :, None], axis=1).real
c2 = 2 * np.sum(pf_i_flipped_stack.conj() * pf_j[:, :, None], axis=1).real

# Pick the best candidate shift for each symmetry-induced ray pair.
sidx1 = np.argmax(c1, axis=1)
sidx2 = np.argmax(c2, axis=1)

score1 = c1[np.arange(n_sym), sidx1]
score2 = c2[np.arange(n_sym), sidx2]
sidx = np.where(score1 > score2, sidx1, sidx2)
dx = -self.offsets_max_shift + sidx * self.offsets_shift_step

# Angle(s) of common ray(s) in image i
shift_alpha = c_ij * d_theta
# Angle(s) of common ray(s) in image j
shift_beta = c_ji * d_theta
# Row indices to construct the sparse equations
shift_i[rows] = rows[:, None]
# All symmetry rows for this pair use the same set of image shift unknowns.
shift_j[rows] = [2 * i, 2 * i + 1, 2 * j, 2 * j + 1]
# Right hand side of the current equation(s)
shift_b[rows] = dx

# Initialize shift equation block.
# One four-coefficient equation row per symmetry-induced common line.
eq = np.empty((n_sym, 4), dtype=self.dtype)

# Compute the coefficients of the current block of equations.
not_flipped = ~is_pf_j_flipped
eq[not_flipped] = np.column_stack(
(
np.sin(shift_alpha[not_flipped]),
np.cos(shift_alpha[not_flipped]),
-np.sin(shift_beta[not_flipped]),
-np.cos(shift_beta[not_flipped]),
)
)

beta_flipped = shift_beta[is_pf_j_flipped] - np.pi
eq[is_pf_j_flipped] = np.column_stack(
(
-np.sin(shift_alpha[is_pf_j_flipped]),
-np.cos(shift_alpha[is_pf_j_flipped]),
-np.sin(beta_flipped),
-np.cos(beta_flipped),
)
)

shift_eq[rows] = eq

# create sparse matrix object only containing non-zero elements
shift_equations = sparse.csr_matrix(
(shift_eq.flatten(), (shift_i.flatten(), shift_j.flatten())),
shape=(n_equations, 2 * n_img),
dtype=self.dtype,
)

return shift_equations, shift_b

def _estimate_num_shift_equations(self, n_img, n_sym=1):
"""
Estimate total number of shift equations in images

The function computes total number of shift equations based on
number of images and preselected memory factor.

:param n_img: The total number of input images
:return: Estimated number of shift equations
:param n_sym: Number of symmetry-expanded rows generated per sampled image pair.
Defaults to 1 for the legacy asymmetric path.
:return: Number of base image-pair equations to sample before any symmetry expansion.
"""
# Number of equations that will be used to estimation the shifts
n_equations_total = int(np.ceil(n_img * (self.n_check - 1) / 2))
Expand All @@ -397,7 +558,7 @@ def _estimate_num_shift_equations(self, n_img):
# This ignores the sparsity of the system, since backslash seems to
# ignore it.
memory_total = self.offsets_equations_factor * (
n_equations_total * 2 * n_img * self.dtype.itemsize
n_equations_total * n_sym * 2 * n_img * self.dtype.itemsize
)

if memory_total < (self.offsets_max_memory * 10**6):
Expand Down Expand Up @@ -458,6 +619,29 @@ def _get_cl_indices(self, rotations, i, j, n_theta):

return c_ij, c_ji

def _get_cl_indices_from_rot_pairs(self, Ri, Rjs, n_theta):
"""
Get common-line indices for one rotation Ri and multiple rotations Rjs.
"""
ell = 2 * n_theta

# Match _get_cl_indices, which calls
# Rotation(np.stack((Ri, Rj))).invert().common_lines(i, j, ...).
ut = np.swapaxes(Rjs, -1, -2) @ Ri

alpha_ij = np.arctan2(ut[:, 2, 0], -ut[:, 2, 1]) + np.pi
alpha_ji = np.arctan2(-ut[:, 0, 2], ut[:, 1, 2]) + np.pi

c_ij = np.mod(np.round(alpha_ij * ell / (2 * np.pi)), ell).astype(int)
c_ji = np.mod(np.round(alpha_ji * ell / (2 * np.pi)), ell).astype(int)

mask = c_ij >= n_theta
c_ij[mask] -= n_theta
c_ji[mask] -= n_theta
c_ji[c_ji < 0] += ell

return c_ij, c_ji

def _apply_filter_and_norm(self, subscripts, pf, r_max, h):
"""
Apply common line filter and normalize each ray
Expand Down
162 changes: 162 additions & 0 deletions tests/test_estimate_shifts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import numpy as np
import pytest
from scipy import sparse

from aspire.abinitio import Orient3D
from aspire.source import Simulation
from aspire.volume import (
AsymmetricVolume,
CnSymmetricVolume,
DnSymmetricVolume,
OSymmetricVolume,
TSymmetricVolume,
)

DTYPES = [np.float64]
RES = [89]
SYMMETRIES = [
None,
"C2",
"C3",
"C4",
"C5",
"C6",
"D2",
"D3",
"D4",
"D5",
"D6",
"D7",
"T",
"O",
]
N_IMGS = 100
SEED = 1980


@pytest.fixture(params=RES, ids=lambda x: f"resolution={x}", scope="module")
def resolution(request):
return request.param


@pytest.fixture(params=SYMMETRIES, ids=lambda x: f"symmetry={x}", scope="module")
def symmetry(request):
return request.param


@pytest.fixture(params=DTYPES, ids=lambda x: f"dtype={x}", scope="module")
def dtype(request):
return request.param


@pytest.fixture(scope="module")
def volume(resolution, symmetry, dtype):
if symmetry is None:
return AsymmetricVolume(
L=resolution, C=1, K=25, dtype=dtype, seed=SEED
).generate()

if symmetry.startswith("C"):
order = int(symmetry[1:])
return CnSymmetricVolume(
L=resolution, C=1, order=order, K=25, dtype=dtype, seed=SEED
).generate()

if symmetry.startswith("D"):
order = int(symmetry[1:])
return DnSymmetricVolume(
L=resolution, C=1, order=order, K=25, dtype=dtype, seed=SEED
).generate()

if symmetry == "T":
return TSymmetricVolume(
L=resolution, C=1, K=25, dtype=dtype, seed=SEED
).generate()

if symmetry == "O":
return OSymmetricVolume(
L=resolution, C=1, K=25, dtype=dtype, seed=SEED
).generate()


@pytest.fixture(scope="module")
def estimator(volume):
"""
Build a simulated source and use ground-truth rotations so this test isolates
shift-equation construction and shift recovery from orientation estimation error.
"""
offset_scale = 1.5 # standard deviation of shifts
offsets = np.random.normal(scale=offset_scale, size=(N_IMGS, 2))

src = Simulation(
n=N_IMGS,
vols=volume,
amplitudes=1,
offsets=offsets,
seed=SEED,
).cache()

orient_est = Orient3D(src)
orient_est.rotations = src.rotations

return orient_est


def test_estimate_shifts(estimator):
"""
Compare estimated shifts to ground truth after removing the nullspace of
the shift equation matrix. See the following publication for more info on
measuring shift estimation error:

Y. Shkolnisky and A. Singer,
Center of Mass Operators for Cryo-EM - Theory and Implementation,
Modeling Nanoscale Imaging in Electron Microscopy,
T. Vogt, W. Dahmen, and P. Binev (Eds.)
Nanostructure Science and Technology Series,
Springer, 2012, pp. 147–177
"""
# Build the sparse common-line shift system Ax = b and solve it directly,
# matching the solver used by estimate_shifts().
A, b = estimator._get_shift_equations()
lsqr_result = sparse.linalg.lsqr(A, b, atol=1e-8, btol=1e-8, iter_lim=100)
x_est = lsqr_result[0]

# Convert Simulation offsets to the internal LSQR convention:
# estimate_shifts returns -x_est.reshape(n, 2)[:, ::-1].
x_ref_internal = (-estimator.src.offsets[:, ::-1]).reshape(-1)

# Use the SVD to separate the constrained directions from the nullspace,
# which corresponds to global 3D translation ambiguity.
_, s, Vt = np.linalg.svd(A.toarray(), full_matrices=False)

# Estimate the effective rank of A and keep the constrained directions.
sv_tol = 1e-2
rank = int(np.sum(s > sv_tol * s[0]))
V_nonnull = Vt[:rank].T

# Compute relative error after projecting out the nullspace.
num = np.linalg.norm(V_nonnull.T @ (x_ref_internal - x_est))
den = np.linalg.norm(V_nonnull.T @ x_ref_internal)
projected_rel_err = num / den

# Check the shift error is within 15% of the reference shift norm.
np.testing.assert_array_less(projected_rel_err, 0.15)

# The projected relative error follows the legacy diagnostic, but it is not
# a pixel-scale quantity. Below we check the same solution after aligning away
# the nullspace component so the error is easier to interpret.
V_null = Vt[rank:].T

# Add the nullspace component to the estimate before comparing directly
# against the reference shifts.
x_err = x_ref_internal - x_est
x_est_aligned = x_est + V_null @ (V_null.T @ x_err)

# Convert back to ASPIRE shift convention and compute per-image Euclidean
# shift error in pixels.
est_shifts_aligned = -x_est_aligned.reshape(estimator.src.n, 2)[:, ::-1]
per_img_err = np.linalg.norm(estimator.src.offsets - est_shifts_aligned, axis=1)
mean_aligned_px = per_img_err.mean()

# Check that aligned estimate errors are within 0.25 pixels on average.
np.testing.assert_array_less(mean_aligned_px, 0.25)