Skip to content

Commit dc9a73c

Browse files
committed
Leave legacy estimate_shifts. Separate code path for symmetric shift estimation.
1 parent 671e91d commit dc9a73c

2 files changed

Lines changed: 161 additions & 7 deletions

File tree

src/aspire/abinitio/commonline_base.py

Lines changed: 160 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from aspire.image import Image
88
from aspire.operators import PolarFT
9-
from aspire.utils import fuzzy_mask
9+
from aspire.utils import Rotation, fuzzy_mask
1010
from aspire.utils.random import choice
1111

1212
from .commonline_utils import _generate_shift_phase_and_filter
@@ -212,7 +212,7 @@ def estimate_shifts(self):
212212
"""
213213

214214
# Generate approximated shift equations from estimated rotations
215-
shift_equations, shift_b = self._get_shift_equations_approx()
215+
shift_equations, shift_b = self._get_shift_equations()
216216

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

242242
return self.rotations, self.shifts
243243

244+
def _get_shift_equations(self):
245+
"""
246+
Generate shift equations from the estimated rotations.
247+
248+
Dispatches to the legacy asymmetric shift-equation construction for C1
249+
sources, and to the symmetry-expanded construction for sources with
250+
nontrivial symmetry. This keeps the C1 code path unchanged while allowing
251+
symmetric molecules to contribute multiple common-line equations per image
252+
pair without introducing additional shift unknowns.
253+
254+
:return: The sparse shift-equation matrix and right-hand side vector.
255+
"""
256+
if str(self.src.symmetry_group) == "C1":
257+
return self._get_shift_equations_approx()
258+
return self._get_shift_equations_approx_symmetric()
259+
244260
def _get_shift_equations_approx(self):
245261
"""
246262
Generate approximated shift equations from estimated rotations
@@ -257,6 +273,142 @@ def _get_shift_equations_approx(self):
257273
:return; The left and right-hand side of shift equations
258274
"""
259275

276+
n_theta_half = self.n_theta // 2
277+
n_img = self.n_img
278+
279+
# `estimate_shifts()` requires that rotations have already been estimated.
280+
rotations = Rotation(self.rotations)
281+
282+
pf = self.pf.copy()
283+
284+
# Estimate number of equations that will be used to calculate the shifts
285+
n_equations = self._estimate_num_shift_equations(n_img)
286+
287+
# Allocate local variables for estimating 2D shifts based on the estimated number
288+
# of equations. The shift equations are represented using a sparse matrix,
289+
# since each row in the system contains four non-zeros (as it involves
290+
# exactly four unknowns). The variables below are used to construct
291+
# this sparse system. The k'th non-zero element of the equations matrix
292+
# is stored at index (shift_i(k),shift_j(k)).
293+
shift_i = np.zeros((n_equations, 4), dtype=self.dtype)
294+
shift_j = np.zeros((n_equations, 4), dtype=self.dtype)
295+
shift_eq = np.zeros((n_equations, 4), dtype=self.dtype)
296+
shift_b = np.zeros(n_equations, dtype=self.dtype)
297+
298+
# Prepare the shift phases to try and generate filter for common-line detection
299+
# The shift phases are pre-defined in a range of max_shift that can be
300+
# applied to maximize the common line calculation. The common-line filter
301+
# is also applied to the radial direction for easier detection.
302+
r_max = pf.shape[2]
303+
_, shift_phases, h = _generate_shift_phase_and_filter(
304+
r_max, self.offsets_max_shift, self.offsets_shift_step, self.dtype
305+
)
306+
307+
d_theta = np.pi / n_theta_half
308+
309+
# Generate two index lists for [i, j] pairs of images
310+
idx_i, idx_j = self._generate_index_pairs(n_equations)
311+
312+
# Go through all shift equations in the size of n_equations
313+
# Iterate over the common lines pairs and for each pair find the 1D
314+
# relative shift between the two Fourier lines in the pair.
315+
for shift_eq_idx in range(n_equations):
316+
i = idx_i[shift_eq_idx]
317+
j = idx_j[shift_eq_idx]
318+
# get the common line indices based on the rotations from i and j images
319+
c_ij, c_ji = self._get_cl_indices(rotations, i, j, n_theta_half)
320+
321+
# Extract the Fourier rays that correspond to the common line
322+
pf_i = pf[i, c_ij]
323+
324+
# Check whether need to flip or not Fourier ray of j image
325+
# Is the common line in image j in the positive
326+
# direction of the ray (is_pf_j_flipped=False) or in the
327+
# negative direction (is_pf_j_flipped=True).
328+
is_pf_j_flipped = c_ji >= n_theta_half
329+
if not is_pf_j_flipped:
330+
pf_j = pf[j, c_ji]
331+
else:
332+
pf_j = pf[j, c_ji - n_theta_half]
333+
334+
# Use ray from opposite side of origin.
335+
# Correpsonds to `freqs` convention in PFT,
336+
# where the legacy code used a negated frequency grid.
337+
pf_i, pf_j = np.conj(pf_i), np.conj(pf_j)
338+
339+
# perform bandpass filter, normalize each ray of each image,
340+
pf_i = self._apply_filter_and_norm("i, i -> i", pf_i, r_max, h)
341+
pf_j = self._apply_filter_and_norm("i, i -> i", pf_j, r_max, h)
342+
343+
# apply the shifts to images
344+
pf_i_flipped = np.conj(pf_i)
345+
pf_i_stack = pf_i[:, None] * shift_phases.T
346+
pf_i_flipped_stack = pf_i_flipped[:, None] * shift_phases.T
347+
348+
c1 = 2 * np.dot(pf_i_stack.T.conj(), pf_j).real
349+
c2 = 2 * np.dot(pf_i_flipped_stack.T.conj(), pf_j).real
350+
351+
# find the indices for the maximum values
352+
# and apply corresponding shifts
353+
sidx1 = np.argmax(c1)
354+
sidx2 = np.argmax(c2)
355+
sidx = sidx1 if c1[sidx1] > c2[sidx2] else sidx2
356+
dx = -self.offsets_max_shift + sidx * self.offsets_shift_step
357+
358+
# angle of common ray in image i
359+
shift_alpha = c_ij * d_theta
360+
# Angle of common ray in image j.
361+
shift_beta = c_ji * d_theta
362+
# Row index to construct the sparse equations
363+
shift_i[shift_eq_idx] = shift_eq_idx
364+
# Columns of the shift variables that correspond to the current pair [i, j]
365+
shift_j[shift_eq_idx] = [2 * i, 2 * i + 1, 2 * j, 2 * j + 1]
366+
# Right hand side of the current equation
367+
shift_b[shift_eq_idx] = dx
368+
369+
# Compute the coefficients of the current equation
370+
if not is_pf_j_flipped:
371+
shift_eq[shift_eq_idx] = np.array(
372+
[
373+
np.sin(shift_alpha),
374+
np.cos(shift_alpha),
375+
-np.sin(shift_beta),
376+
-np.cos(shift_beta),
377+
]
378+
)
379+
else:
380+
shift_beta = shift_beta - np.pi
381+
shift_eq[shift_eq_idx] = np.array(
382+
[
383+
-np.sin(shift_alpha),
384+
-np.cos(shift_alpha),
385+
-np.sin(shift_beta),
386+
-np.cos(shift_beta),
387+
]
388+
)
389+
390+
# create sparse matrix object only containing non-zero elements
391+
shift_equations = sparse.csr_matrix(
392+
(shift_eq.flatten(), (shift_i.flatten(), shift_j.flatten())),
393+
shape=(n_equations, 2 * n_img),
394+
dtype=self.dtype,
395+
)
396+
397+
return shift_equations, shift_b
398+
399+
def _get_shift_equations_approx_symmetric(self):
400+
"""
401+
Generate symmetry-expanded approximate shift equations from estimated rotations.
402+
403+
For each sampled image pair, this method computes the common lines induced by
404+
the first image rotation and every symmetry-transformed copy of the second
405+
image rotation. Each symmetry copy contributes one shift equation involving
406+
the same two 2D image-shift unknowns, adding constraints without duplicating
407+
images or introducing independent shift variables for symmetry copies.
408+
409+
:return: The sparse shift-equation matrix and right-hand side vector.
410+
"""
411+
260412
n_theta_half = self.n_theta // 2
261413
n_img = self.n_img
262414
pf = self.pf.copy()
@@ -270,7 +422,7 @@ def _get_shift_equations_approx(self):
270422
n_sym = len(sym_rots)
271423

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

276428
# Allocate local variables for estimating 2D shifts based on the estimated number
@@ -387,15 +539,17 @@ def _get_shift_equations_approx(self):
387539

388540
return shift_equations, shift_b
389541

390-
def _estimate_num_shift_equations(self, n_img):
542+
def _estimate_num_shift_equations(self, n_img, n_sym=1):
391543
"""
392544
Estimate total number of shift equations in images
393545
394546
The function computes total number of shift equations based on
395547
number of images and preselected memory factor.
396548
397549
:param n_img: The total number of input images
398-
:return: Estimated number of shift equations
550+
:param n_sym: Number of symmetry-expanded rows generated per sampled image pair.
551+
Defaults to 1 for the legacy asymmetric path.
552+
:return: Number of base image-pair equations to sample before any symmetry expansion.
399553
"""
400554
# Number of equations that will be used to estimation the shifts
401555
n_equations_total = int(np.ceil(n_img * (self.n_check - 1) / 2))
@@ -404,7 +558,7 @@ def _estimate_num_shift_equations(self, n_img):
404558
# This ignores the sparsity of the system, since backslash seems to
405559
# ignore it.
406560
memory_total = self.offsets_equations_factor * (
407-
n_equations_total * 2 * n_img * self.dtype.itemsize
561+
n_equations_total * n_sym * 2 * n_img * self.dtype.itemsize
408562
)
409563

410564
if memory_total < (self.offsets_max_memory * 10**6):

tests/test_estimate_shifts.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ def test_estimate_shifts(estimator):
117117
"""
118118
# Build the sparse common-line shift system Ax = b and solve it directly,
119119
# matching the solver used by estimate_shifts().
120-
A, b = estimator._get_shift_equations_approx()
120+
A, b = estimator._get_shift_equations()
121121
lsqr_result = sparse.linalg.lsqr(A, b, atol=1e-8, btol=1e-8, iter_lim=100)
122122
x_est = lsqr_result[0]
123123

0 commit comments

Comments
 (0)