@@ -264,12 +264,12 @@ def _get_shift_equations_approx(self):
264264 # `estimate_shifts()` requires that rotations have already been estimated.
265265 rotations = self .rotations
266266
267- # Apply symmetry group to rotations
267+ # Apply symmetry group to rotations.
268+ # Symmetry copies add more equations for the same per-image shift unknowns.
268269 sym_rots = self .src .symmetry_group .matrices .astype (self .dtype , copy = False )
269270 n_sym = len (sym_rots )
270271
271- # Estimate number of equations that will be used to calculate the shifts,
272- # taking into account particle symmetry.
272+ # Estimate base image-pair equations, then expand each pair by symmetry.
273273 n_pair_equations = self ._estimate_num_shift_equations (n_img )
274274 n_equations = n_pair_equations * n_sym
275275
@@ -295,94 +295,88 @@ def _get_shift_equations_approx(self):
295295
296296 d_theta = np .pi / n_theta_half
297297
298- # Generate two index lists for [i, j] pairs of images
298+ # Generate base [i, j] image pairs before symmetry expansion.
299299 idx_i , idx_j = self ._generate_index_pairs (n_pair_equations )
300300
301- # Go through all shift equations in the size of n_equations
302- # Iterate over the common lines pairs and for each pair find the 1D
303- # relative shift between the two Fourier lines in the pair.
301+ # Filter, normalize, and conjugate all rays once instead of once per equation.
302+ # Conjugation uses ray from opposite side of origin.
303+ # Correpsonds to `freqs` convention in PFT,
304+ # where the legacy code used a negated frequency grid.
305+ pf = np .conj (self ._apply_filter_and_norm ("ijk, k -> ijk" , pf , r_max , h ))
306+
307+ # Iterate over image pairs; each iteration fills one block of n_sym
308+ # symmetry-induced common-line shift equations.
304309 for pair_eq_idx in range (n_pair_equations ):
305310 i = idx_i [pair_eq_idx ]
306311 j = idx_j [pair_eq_idx ]
312+ rows = pair_eq_idx + np .arange (n_sym ) * n_pair_equations
307313
308- for sym_idx , g in enumerate (sym_rots ):
309- shift_eq_idx = pair_eq_idx + sym_idx * n_pair_equations
314+ # Common lines for Ri against all symmetry copies g @ Rj.
315+ Rjs = sym_rots @ rotations [j ]
316+ c_ij , c_ji = self ._get_cl_indices_from_rot_pairs (
317+ rotations [i ], Rjs , n_theta_half
318+ )
310319
311- # get the common line indices based on the rotations from i and j images
312- c_ij , c_ji = self ._get_cl_indices_from_rot_pair (
313- rotations [i ],
314- g @ rotations [j ],
315- n_theta_half ,
320+ # Extract the Fourier rays that correspond to the common lines
321+ pf_i = pf [i , c_ij ] # shape (n_sym, n_rad)
322+
323+ # Track which symmetry-induced rays in image j use the opposite ray direction.
324+ is_pf_j_flipped = c_ji >= n_theta_half
325+ pf_j = pf [j , c_ji % n_theta_half ]
326+
327+ # Apply candidate 1D shifts to all symmetry-induced rays for this image pair.
328+ pf_i_stack = pf_i [:, :, None ] * shift_phases .T [None , :, :]
329+ pf_i_flipped_stack = np .conj (pf_i )[:, :, None ] * shift_phases .T [None , :, :]
330+
331+ c1 = 2 * np .sum (pf_i_stack .conj () * pf_j [:, :, None ], axis = 1 ).real
332+ c2 = 2 * np .sum (pf_i_flipped_stack .conj () * pf_j [:, :, None ], axis = 1 ).real
333+
334+ # Pick the best candidate shift for each symmetry-induced ray pair.
335+ sidx1 = np .argmax (c1 , axis = 1 )
336+ sidx2 = np .argmax (c2 , axis = 1 )
337+
338+ score1 = c1 [np .arange (n_sym ), sidx1 ]
339+ score2 = c2 [np .arange (n_sym ), sidx2 ]
340+ sidx = np .where (score1 > score2 , sidx1 , sidx2 )
341+ dx = - self .offsets_max_shift + sidx * self .offsets_shift_step
342+
343+ # Angle(s) of common ray(s) in image i
344+ shift_alpha = c_ij * d_theta
345+ # Angle(s) of common ray(s) in image j
346+ shift_beta = c_ji * d_theta
347+ # Row indices to construct the sparse equations
348+ shift_i [rows ] = rows [:, None ]
349+ # All symmetry rows for this pair use the same set of image shift unknowns.
350+ shift_j [rows ] = [2 * i , 2 * i + 1 , 2 * j , 2 * j + 1 ]
351+ # Right hand side of the current equation(s)
352+ shift_b [rows ] = dx
353+
354+ # Initialize shift equation block.
355+ # One four-coefficient equation row per symmetry-induced common line.
356+ eq = np .empty ((n_sym , 4 ), dtype = self .dtype )
357+
358+ # Compute the coefficients of the current block of equations.
359+ not_flipped = ~ is_pf_j_flipped
360+ eq [not_flipped ] = np .column_stack (
361+ (
362+ np .sin (shift_alpha [not_flipped ]),
363+ np .cos (shift_alpha [not_flipped ]),
364+ - np .sin (shift_beta [not_flipped ]),
365+ - np .cos (shift_beta [not_flipped ]),
316366 )
367+ )
317368
318- # Extract the Fourier rays that correspond to the common line
319- pf_i = pf [i , c_ij ]
320-
321- # Check whether need to flip or not Fourier ray of j image
322- # Is the common line in image j in the positive
323- # direction of the ray (is_pf_j_flipped=False) or in the
324- # negative direction (is_pf_j_flipped=True).
325- is_pf_j_flipped = c_ji >= n_theta_half
326- if not is_pf_j_flipped :
327- pf_j = pf [j , c_ji ]
328- else :
329- pf_j = pf [j , c_ji - n_theta_half ]
330-
331- # Use ray from opposite side of origin.
332- # Correpsonds to `freqs` convention in PFT,
333- # where the legacy code used a negated frequency grid.
334- pf_i , pf_j = np .conj (pf_i ), np .conj (pf_j )
335-
336- # perform bandpass filter, normalize each ray of each image,
337- pf_i = self ._apply_filter_and_norm ("i, i -> i" , pf_i , r_max , h )
338- pf_j = self ._apply_filter_and_norm ("i, i -> i" , pf_j , r_max , h )
339-
340- # apply the shifts to images
341- pf_i_flipped = np .conj (pf_i )
342- pf_i_stack = pf_i [:, None ] * shift_phases .T
343- pf_i_flipped_stack = pf_i_flipped [:, None ] * shift_phases .T
344-
345- c1 = 2 * np .dot (pf_i_stack .T .conj (), pf_j ).real
346- c2 = 2 * np .dot (pf_i_flipped_stack .T .conj (), pf_j ).real
347-
348- # find the indices for the maximum values
349- # and apply corresponding shifts
350- sidx1 = np .argmax (c1 )
351- sidx2 = np .argmax (c2 )
352- sidx = sidx1 if c1 [sidx1 ] > c2 [sidx2 ] else sidx2
353- dx = - self .offsets_max_shift + sidx * self .offsets_shift_step
354-
355- # angle of common ray in image i
356- shift_alpha = c_ij * d_theta
357- # Angle of common ray in image j.
358- shift_beta = c_ji * d_theta
359- # Row index to construct the sparse equations
360- shift_i [shift_eq_idx ] = shift_eq_idx
361- # Columns of the shift variables that correspond to the current pair [i, j]
362- shift_j [shift_eq_idx ] = [2 * i , 2 * i + 1 , 2 * j , 2 * j + 1 ]
363- # Right hand side of the current equation
364- shift_b [shift_eq_idx ] = dx
365-
366- # Compute the coefficients of the current equation
367- if not is_pf_j_flipped :
368- shift_eq [shift_eq_idx ] = np .array (
369- [
370- np .sin (shift_alpha ),
371- np .cos (shift_alpha ),
372- - np .sin (shift_beta ),
373- - np .cos (shift_beta ),
374- ]
375- )
376- else :
377- shift_beta = shift_beta - np .pi
378- shift_eq [shift_eq_idx ] = np .array (
379- [
380- - np .sin (shift_alpha ),
381- - np .cos (shift_alpha ),
382- - np .sin (shift_beta ),
383- - np .cos (shift_beta ),
384- ]
385- )
369+ beta_flipped = shift_beta [is_pf_j_flipped ] - np .pi
370+ eq [is_pf_j_flipped ] = np .column_stack (
371+ (
372+ - np .sin (shift_alpha [is_pf_j_flipped ]),
373+ - np .cos (shift_alpha [is_pf_j_flipped ]),
374+ - np .sin (beta_flipped ),
375+ - np .cos (beta_flipped ),
376+ )
377+ )
378+
379+ shift_eq [rows ] = eq
386380
387381 # create sparse matrix object only containing non-zero elements
388382 shift_equations = sparse .csr_matrix (
@@ -471,18 +465,26 @@ def _get_cl_indices(self, rotations, i, j, n_theta):
471465
472466 return c_ij , c_ji
473467
474- def _get_cl_indices_from_rot_pair (self , Ri , Rj , n_theta ):
468+ def _get_cl_indices_from_rot_pairs (self , Ri , Rjs , n_theta ):
475469 """
476- Get common-line indices for an explicit pair of rotation matrices .
470+ Get common-line indices for one rotation Ri and multiple rotations Rjs .
477471 """
478- rotations = Rotation (np .stack ((Ri , Rj ))).invert ()
479- c_ij , c_ji = rotations .common_lines (0 , 1 , 2 * n_theta )
472+ ell = 2 * n_theta
480473
481- if c_ij >= n_theta :
482- c_ij -= n_theta
483- c_ji -= n_theta
484- if c_ji < 0 :
485- c_ji += 2 * n_theta
474+ # Match _get_cl_indices, which calls
475+ # Rotation(np.stack((Ri, Rj))).invert().common_lines(i, j, ...).
476+ ut = np .swapaxes (Rjs , - 1 , - 2 ) @ Ri
477+
478+ alpha_ij = np .arctan2 (ut [:, 2 , 0 ], - ut [:, 2 , 1 ]) + np .pi
479+ alpha_ji = np .arctan2 (- ut [:, 0 , 2 ], ut [:, 1 , 2 ]) + np .pi
480+
481+ c_ij = np .mod (np .round (alpha_ij * ell / (2 * np .pi )), ell ).astype (int )
482+ c_ji = np .mod (np .round (alpha_ji * ell / (2 * np .pi )), ell ).astype (int )
483+
484+ mask = c_ij >= n_theta
485+ c_ij [mask ] -= n_theta
486+ c_ji [mask ] -= n_theta
487+ c_ji [c_ji < 0 ] += ell
486488
487489 return c_ij , c_ji
488490
0 commit comments