66
77from aspire .image import Image
88from aspire .operators import PolarFT
9- from aspire .utils import fuzzy_mask
9+ from aspire .utils import Rotation , fuzzy_mask
1010from aspire .utils .random import choice
1111
1212from .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 ):
0 commit comments