diff --git a/src/aspire/abinitio/J_sync.py b/src/aspire/abinitio/J_sync.py new file mode 100644 index 0000000000..b8458d9807 --- /dev/null +++ b/src/aspire/abinitio/J_sync.py @@ -0,0 +1,247 @@ +import logging + +import numpy as np +from numpy.linalg import norm + +from aspire.utils import J_conjugate, all_pairs, all_triplets, tqdm +from aspire.utils.random import randn + +logger = logging.getLogger(__name__) + + +class JSync: + """ + Class for handling J-synchronization methods. + """ + + def __init__( + self, + n, + epsilon=1e-2, + max_iters=1000, + seed=None, + ): + """ + Initialize JSync object for estimating global handedness synchronization for a + set of relative rotations, Rij = Ri @ Rj.T, where i <= j = 0, 1, ..., n. + + :param n: Number of images/rotations. + :param epsilon: Tolerance for the power method. + :param max_iters: Maximum iterations for the power method. + :param seed: Optional seed for power method initial random vector. + """ + self.n_img = n + self.epsilon = epsilon + self.max_iters = max_iters + self.seed = seed + + def global_J_sync(self, vijs): + """ + Global J-synchronization of all third row outer products. Given 3x3 matrices vijs, each + of which might contain a spurious J (ie. vij = J*vi*vj^T*J instead of vij = vi*vj^T), + we return vijs that all have either a spurious J or not. + + :param vijs: An (n-choose-2)x3x3 array where each 3x3 slice holds an estimate for the corresponding + outer-product vi*vj^T between the third rows of the rotation matrices Ri and Rj. Each estimate + might have a spurious J independently of other estimates. + + :return: vijs, all of which have a spurious J or not. + """ + + # Determine relative handedness of vijs. + sign_ij_J = self.power_method(vijs) + + # Synchronize vijs + vijs_sync = vijs.copy() + for i, sign in enumerate(sign_ij_J): + if sign == -1: + vijs_sync[i] = J_conjugate(vijs[i]) + + return vijs_sync + + def power_method(self, vijs): + """ + Calculate the leading eigenvector of the J-synchronization matrix + using the power method. + + As the J-synchronization matrix is of size (n-choose-2)x(n-choose-2), we + use the power method to compute the eigenvalues and eigenvectors, + while constructing the matrix on-the-fly. + + :param vijs: (n-choose-2)x3x3 array of estimates of relative orientation matrices. + + :return: An array of length n-choose-2 consisting of 1 or -1, where the sign of the + i'th entry indicates whether the i'th relative orientation matrix will be J-conjugated. + """ + + # Set power method tolerance and maximum iterations. + epsilon = self.epsilon + max_iters = self.max_iters + + # Initialize candidate eigenvectors + n_vijs = vijs.shape[0] + vec = randn(n_vijs, seed=self.seed) + vec = vec / norm(vec) + residual = 1 + itr = 0 + + # Power method iterations + logger.info( + "Initiating power method to estimate J-synchronization matrix eigenvector." + ) + while itr < max_iters and residual > epsilon: + itr += 1 + # Note, this appears to need double precision for accuracy in the following division. + vec_new = self._signs_times_v(vijs, vec).astype(np.float64, copy=False) + vec_new = vec_new / norm(vec_new) + residual = norm(vec_new - vec) + vec = vec_new + logger.info( + f"Iteration {itr}, residual {round(residual, 5)} (target {epsilon})" + ) + + # We need only the signs of the eigenvector + J_sync = np.sign(vec, dtype=vijs.dtype) + + return J_sync + + def sync_viis(self, vijs, viis): + """ + Given a set of synchronized pairwise outer products vijs, J-synchronize the set of + outer products viis. + + :param vijs: An (n-choose-2)x3x3 array where each 3x3 slice holds an estimate for the corresponding + outer-product vi*vj^T between the third rows of the rotation matrices Ri and Rj. Each estimate + might have a spurious J independently of other estimates. + + :param viis: An n_imgx3x3 array where the i'th slice holds an estimate for the outer product vi*vi^T + between the third row of matrix Ri and itself. Each estimate might have a spurious J independently + of other estimates. + + :return: J-synchronized viis. + """ + + # Synchronize viis + # We use the fact that if v_ii and v_ij are of the same handedness, then v_ii @ v_ij = v_ij. + # If they are opposite handed then Jv_iiJ @ v_ij = v_ij. We compare each v_ii against all + # previously synchronized v_ij to get a consensus on the handedness of v_ii. + _, pairs_to_linear = all_pairs(self.n_img, return_map=True) + for i in range(self.n_img): + vii = viis[i] + vii_J = J_conjugate(vii) + J_consensus = 0 + for j in range(self.n_img): + if j < i: + idx = pairs_to_linear[j, i] + vji = vijs[idx] + + err1 = norm(vji @ vii - vji) + err2 = norm(vji @ vii_J - vji) + + elif j > i: + idx = pairs_to_linear[i, j] + vij = vijs[idx] + + err1 = norm(vii @ vij - vij) + err2 = norm(vii_J @ vij - vij) + + else: + continue + + # Accumulate J consensus + if err1 < err2: + J_consensus -= 1 + else: + J_consensus += 1 + + if J_consensus > 0: + viis[i] = vii_J + return viis + + def _signs_times_v(self, vijs, vec): + """ + Multiplication of the J-synchronization matrix by a candidate eigenvector. + + The J-synchronization matrix is a matrix representation of the handedness graph, Gamma, whose set of + nodes consists of the estimates vijs and whose set of edges consists of the undirected edges between + all triplets of estimates vij, vjk, and vik, where i i: - idx = pairs_to_linear[i, j] - vij = vijs[idx] - - err1 = norm(vii @ vij - vij) - err2 = norm(vii_J @ vij - vij) - - else: - continue - - # Accumulate J consensus - if err1 < err2: - J_consensus -= 1 - else: - J_consensus += 1 - - if J_consensus > 0: - viis[i] = vii_J + vijs = self.J_sync.global_J_sync(vijs) + + # Determine relative handedness of viis, given synchronized vijs. + viis = self.J_sync.sync_viis(vijs, viis) + return vijs, viis ################################################# @@ -266,8 +230,8 @@ def _self_clmatrix_c3_c4(self): # Compute the correlation over all shifts. # Generate Shifts. r_max = pf.shape[-1] - shifts, shift_phases, _ = self._generate_shift_phase_and_filter( - r_max, max_shift_1d, shift_step + shifts, shift_phases, _ = _generate_shift_phase_and_filter( + r_max, max_shift_1d, shift_step, self.dtype ) n_shifts = len(shifts) @@ -356,17 +320,21 @@ def _estimate_all_Riis_c3_c4(self, sclmatrix): return Riis - def _estimate_all_Rijs_c3_c4(self, clmatrix): + def _estimate_all_Rijs_c3_c4(self): """ Estimate Rijs using the voting method. """ - n_img = self.n_img - n_theta = self.n_theta - pairs = all_pairs(n_img) + pairs = all_pairs(self.n_img) Rijs = np.zeros((len(pairs), 3, 3)) for idx, (i, j) in enumerate(pairs): - Rijs[idx] = self._syncmatrix_ij_vote_3n( - clmatrix, i, j, np.arange(n_img), n_theta + Rijs[idx] = _syncmatrix_ij_vote_3n( + self.clmatrix, + i, + j, + np.arange(self.n_img), + self.n_theta, + self.hist_bin_width, + self.full_width, ) return Rijs @@ -449,201 +417,3 @@ def _local_J_sync_c3_c4(self, Rijs, Riis): vijs[idx] = opts[min_idx] return vijs, viis - - ####################################### - # Secondary Methods for Global J Sync # - ####################################### - - def _J_sync_power_method(self, vijs): - """ - Calculate the leading eigenvector of the J-synchronization matrix - using the power method. - - As the J-synchronization matrix is of size (n-choose-2)x(n-choose-2), we - use the power method to compute the eigenvalues and eigenvectors, - while constructing the matrix on-the-fly. - - :param vijs: (n-choose-2)x3x3 array of estimates of relative orientation matrices. - - :return: An array of length n-choose-2 consisting of 1 or -1, where the sign of the - i'th entry indicates whether the i'th relative orientation matrix will be J-conjugated. - """ - - # Set power method tolerance and maximum iterations. - epsilon = self.epsilon - max_iters = self.max_iters - - # Initialize candidate eigenvectors - n_vijs = vijs.shape[0] - vec = randn(n_vijs, seed=self.seed) - vec = vec / norm(vec) - residual = 1 - itr = 0 - - # Power method iterations - logger.info( - "Initiating power method to estimate J-synchronization matrix eigenvector." - ) - while itr < max_iters and residual > epsilon: - itr += 1 - # Note, this appears to need double precision for accuracy in the following division. - vec_new = self._signs_times_v(vijs, vec).astype(np.float64, copy=False) - vec_new = vec_new / norm(vec_new) - residual = norm(vec_new - vec) - vec = vec_new - logger.info( - f"Iteration {itr}, residual {round(residual, 5)} (target {epsilon})" - ) - - # We need only the signs of the eigenvector - J_sync = np.sign(vec) - - return J_sync - - def _signs_times_v(self, vijs, vec): - """ - Multiplication of the J-synchronization matrix by a candidate eigenvector. - - The J-synchronization matrix is a matrix representation of the handedness graph, Gamma, whose set of - nodes consists of the estimates vijs and whose set of edges consists of the undirected edges between - all triplets of estimates vij, vjk, and vik, where i4. @@ -65,22 +73,24 @@ def __init__( super().__init__( src, - symmetry=symmetry, n_rad=n_rad, n_theta=n_theta, max_shift=max_shift, shift_step=shift_step, - epsilon=epsilon, - max_iters=max_iters, - degree_res=degree_res, - seed=seed, mask=mask, **kwargs, ) + self._check_symmetry(symmetry) + self.epsilon = epsilon + self.max_iters = max_iters + self.degree_res = degree_res + self.seed = seed self.n_points_sphere = n_points_sphere self.equator_threshold = equator_threshold + self.J_sync = JSync(src.n, self.epsilon, self.max_iters, self.seed) + def _check_symmetry(self, symmetry): if symmetry is None: raise NotImplementedError( @@ -102,7 +112,27 @@ def estimate_rotations(self): :return: Array of rotation matrices, size n_imgx3x3. """ - super().estimate_rotations() + vijs, viis = self._estimate_relative_viewing_directions() + + logger.info("Performing global handedness synchronization.") + vijs, viis = self._global_J_sync(vijs, viis) + + logger.info("Estimating third rows of rotation matrices.") + vis = _estimate_third_rows(vijs, viis) + + logger.info("Estimating in-plane rotations and rotations matrices.") + Ris = _estimate_inplane_rotations( + vis, + self.pf, + self.max_shift, + self.shift_step, + self.order, + self.degree_res, + ) + + self.rotations = Ris + + return self.rotations def _estimate_relative_viewing_directions(self): logger.info(f"Estimating relative viewing directions for {self.n_img} images.") @@ -123,8 +153,8 @@ def _estimate_relative_viewing_directions(self): # Generate shift phases. r_max = pf.shape[-1] - shifts, shift_phases, _ = self._generate_shift_phase_and_filter( - r_max, self.max_shift, self.shift_step + shifts, shift_phases, _ = _generate_shift_phase_and_filter( + r_max, self.max_shift, self.shift_step, self.dtype ) n_shifts = len(shifts) @@ -286,6 +316,31 @@ def _compute_cls_inds(self, Ris_tilde, R_theta_ijs): cij_inds[i, j, :, 1] = c2s return cij_inds + def _global_J_sync(self, vijs, viis): + """ + Global J-synchronization of all third row outer products. Given 3x3 matrices vijs and viis, each + of which might contain a spurious J (ie. vij = J*vi*vj^T*J instead of vij = vi*vj^T), + we return vijs and viis that all have either a spurious J or not. + + :param vijs: An (n-choose-2)x3x3 array where each 3x3 slice holds an estimate for the corresponding + outer-product vi*vj^T between the third rows of the rotation matrices Ri and Rj. Each estimate + might have a spurious J independently of other estimates. + + :param viis: An n_imgx3x3 array where the i'th slice holds an estimate for the outer product vi*vi^T + between the third row of matrix Ri and itself. Each estimate might have a spurious J independently + of other estimates. + + :return: vijs, viis all of which have a spurious J or not. + """ + + # Determine relative handedness of vijs. + vijs = self.J_sync.global_J_sync(vijs) + + # Determine relative handedness of viis, given synchronized vijs. + viis = self.J_sync.sync_viis(vijs, viis) + + return vijs, viis + @staticmethod def relative_rots_to_cl_indices(relative_rots, n_theta): """ @@ -300,8 +355,8 @@ def relative_rots_to_cl_indices(relative_rots, n_theta): c1s = np.array((-relative_rots[:, 1, 2], relative_rots[:, 0, 2])).T c2s = np.array((relative_rots[:, 2, 1], -relative_rots[:, 2, 0])).T - c1s = cl_angles_to_ind(c1s, n_theta) - c2s = cl_angles_to_ind(c2s, n_theta) + c1s = _cl_angles_to_ind(c1s, n_theta) + c2s = _cl_angles_to_ind(c2s, n_theta) inds = np.where(c1s >= n_theta // 2) c1s[inds] -= n_theta // 2 @@ -333,7 +388,7 @@ def generate_candidate_rots(n, equator_threshold, order, degree_res, seed): while counter < n: third_row = randn(3) third_row /= anorm(third_row, axes=(-1,)) - Ri_tilde = complete_third_row_to_rot(third_row) + Ri_tilde = _complete_third_row_to_rot(third_row) # Exclude candidates that represent equator images. Equator candidates # induce collinear self-common-lines, which always have perfect correlation. diff --git a/src/aspire/abinitio/commonline_d2.py b/src/aspire/abinitio/commonline_d2.py index f8022d3db9..e1730bf35e 100644 --- a/src/aspire/abinitio/commonline_d2.py +++ b/src/aspire/abinitio/commonline_d2.py @@ -10,6 +10,8 @@ from aspire.utils.random import randn from aspire.volume import DnSymmetryGroup +from .commonline_utils import _generate_shift_phase_and_filter + logger = logging.getLogger(__name__) @@ -131,8 +133,8 @@ def _compute_shifted_pf(self): # Generate shift phases. r_max = pf.shape[-1] max_shift_1d = np.ceil(2 * np.sqrt(2) * self.max_shift) - shifts, shift_phases, _ = self._generate_shift_phase_and_filter( - r_max, max_shift_1d, self.shift_step + shifts, shift_phases, _ = _generate_shift_phase_and_filter( + r_max, max_shift_1d, self.shift_step, self.dtype ) self.n_shifts = len(shifts) diff --git a/src/aspire/abinitio/commonline_sync.py b/src/aspire/abinitio/commonline_sync.py index 6fcc8daddc..6ed774958b 100644 --- a/src/aspire/abinitio/commonline_sync.py +++ b/src/aspire/abinitio/commonline_sync.py @@ -2,14 +2,15 @@ import numpy as np -from aspire.abinitio import CLOrient3D, SyncVotingMixin +from aspire.abinitio import CLOrient3D +from aspire.abinitio.sync_voting import _rotratio_eulerangle_vec, _vote_ij from aspire.utils import nearest_rotations from aspire.utils.matlab_compat import stable_eigsh logger = logging.getLogger(__name__) -class CLSyncVoting(CLOrient3D, SyncVotingMixin): +class CLSyncVoting(CLOrient3D): """ Define a class to estimate 3D orientations using synchronization matrix and voting method. @@ -201,9 +202,11 @@ def _syncmatrix_ij_vote(self, clmatrix, i, j, k_list, n_theta): :return: The (i,j) rotation block of the synchronization matrix """ - _, good_k = self._vote_ij(clmatrix, n_theta, i, j, k_list) + _, good_k = _vote_ij( + clmatrix, n_theta, i, j, k_list, self.hist_bin_width, self.full_width + ) - rots = self._rotratio_eulerangle_vec(clmatrix, i, j, good_k, n_theta) + rots = _rotratio_eulerangle_vec(clmatrix, i, j, good_k, n_theta) if rots is not None: rot_mean = np.mean(rots, 0) diff --git a/src/aspire/abinitio/commonline_sync3n.py b/src/aspire/abinitio/commonline_sync3n.py index ed7ca94048..7be4b37c2c 100644 --- a/src/aspire/abinitio/commonline_sync3n.py +++ b/src/aspire/abinitio/commonline_sync3n.py @@ -6,14 +6,15 @@ from numpy.linalg import norm from scipy.optimize import curve_fit -from aspire.abinitio import CLOrient3D, SyncVotingMixin +from aspire.abinitio import CLOrient3D +from aspire.abinitio.sync_voting import _syncmatrix_ij_vote_3n from aspire.utils import J_conjugate, all_pairs, nearest_rotations, random, tqdm, trange from aspire.utils.matlab_compat import stable_eigsh logger = logging.getLogger(__name__) -class CLSync3N(CLOrient3D, SyncVotingMixin): +class CLSync3N(CLOrient3D): """ Define a class to estimate 3D orientations using common lines Sync3N methods (2017). @@ -957,8 +958,14 @@ def _estimate_all_Rijs_host(self, clmatrix): Rijs = np.zeros((len(self._pairs), 3, 3)) for idx, (i, j) in enumerate(tqdm(self._pairs, desc="Estimate Rijs")): - Rijs[idx] = self._syncmatrix_ij_vote_3n( - clmatrix, i, j, np.arange(n_img), n_theta + Rijs[idx] = _syncmatrix_ij_vote_3n( + clmatrix, + i, + j, + np.arange(n_img), + n_theta, + self.hist_bin_width, + self.full_width, ) return Rijs diff --git a/src/aspire/abinitio/commonline_utils.py b/src/aspire/abinitio/commonline_utils.py index bfd1e5ce5f..45352bf026 100644 --- a/src/aspire/abinitio/commonline_utils.py +++ b/src/aspire/abinitio/commonline_utils.py @@ -4,12 +4,12 @@ from numpy.linalg import eigh, norm from aspire.operators import PolarFT -from aspire.utils import Rotation, all_pairs, anorm, tqdm +from aspire.utils import J_conjugate, Rotation, all_pairs, anorm, cyclic_rotations, tqdm logger = logging.getLogger(__name__) -def estimate_third_rows(vijs, viis): +def _estimate_third_rows(vijs, viis): """ Find the third row of each rotation matrix given a collection of matrices representing the outer products of the third rows from each rotation matrix. @@ -59,39 +59,69 @@ def estimate_third_rows(vijs, viis): return vis -def estimate_inplane_rotations(cl_class, vis): +def _generate_shift_phase_and_filter(r_max, max_shift, shift_step, dtype): """ - Estimate the rotation matrices for each image by constructing arbitrary rotation matrices - populated with the given third rows, vis, and then rotating by an appropriate in-plane rotation. + Prepare the shift phases and generate filter for common-line detection - :cl_class: A commonlines class instance. - :param vis: An n_imgx3 array where the i'th row holds the estimate for the third row of - the i'th rotation matrix. + 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. + + :param r_max: Maximum index for common line detection. + :param max_shift: Maximum value of 1D shift (in pixels) to search. + :param shift_step: Resolution of shift estimation in pixels. + :param dtype: dtype for shift phases and filter. + :return: shift phases matrix and common lines filter. + """ + + # Number of shifts to try + n_shifts = int(np.ceil(2 * max_shift / shift_step + 1)) + + # only half of ray, excluding the DC component. + rk = np.arange(1, r_max + 1, dtype=dtype) + + # Generate all shift phases + shifts = -max_shift + shift_step * np.arange(n_shifts, dtype=dtype) + shift_phases = np.exp(np.outer(shifts, -2 * np.pi * 1j * rk / (2 * r_max + 1))) + # Set filter for common-line detection + h = np.sqrt(np.abs(rk)) * np.exp(-np.square(rk) / (2 * (r_max / 4) ** 2)) + + return shifts, shift_phases, h + +def _estimate_inplane_rotations(vis, pf, max_shift, shift_step, order, degree_res): + """ + Estimate the rotation matrices for each image of a cyclically symmetric molecule by + constructing arbitrary rotation matrices populated with the given third rows, vis, and + then rotating by an appropriate in-plane rotation. + + :param vis: An n_imgx3 array where the i'th row holds the estimate for the third row of + the i'th rotation matrix. + :param pf: The polar Fourier transform of the source images, shape (n_img, n_theta/2, n_rad). + :param max_shift: Maximum range for shifts (in pixels) for estimating in-plane rotations. + :param shift_step: Shift step (in pixels) for estimating in-plane rotations. + :param order: Cyclic order. + :param degree_res: Resolution (in degrees) of in-plane rotation to search over. :return: Rotation matrices Ris and in-plane rotation matrices R_thetas, both size n_imgx3x3. """ - pf = cl_class.pf - n_img = cl_class.n_img - n_theta = cl_class.n_theta - max_shift_1d = cl_class.max_shift - shift_step = cl_class.shift_step - order = cl_class.order - degree_res = cl_class.degree_res + n_img = vis.shape[0] + dtype = vis.dtype + n_theta = pf.shape[1] * 2 # Step 1: Construct all rotation matrices Ri_tildes whose third rows are equal to # the corresponding third rows vis. - Ri_tildes = complete_third_row_to_rot(vis) + Ri_tildes = _complete_third_row_to_rot(vis) # Step 2: Construct all in-plane rotation matrices, R_theta_ijs. max_angle = (360 // order) * order theta_ijs = np.arange(0, max_angle, degree_res) * np.pi / 180 - R_theta_ijs = Rotation.about_axis("z", theta_ijs, dtype=cl_class.dtype).matrices + R_theta_ijs = Rotation.about_axis("z", theta_ijs, dtype=dtype).matrices # Step 3: Compute the correlation over all shifts. # Generate shifts. r_max = pf.shape[-1] - shifts, shift_phases, _ = cl_class._generate_shift_phase_and_filter( - r_max, max_shift_1d, shift_step + shifts, shift_phases, _ = _generate_shift_phase_and_filter( + r_max, max_shift, shift_step, dtype ) n_shifts = len(shifts) @@ -100,7 +130,7 @@ def estimate_inplane_rotations(cl_class, vis): # and theta_i in [0, 2pi/order) is the in-plane rotation angle for the i'th image. Q = np.zeros((n_img, n_img), dtype=complex) - # Reconstruct the full polar Fourier for use in correlation. cl_class.pf only consists of + # Reconstruct the full polar Fourier for use in correlation. pf only consists of # rays in the range [180, 360), with shape (n_img, n_theta//2, n_rad-1). pf = PolarFT.half_to_full(pf) @@ -108,87 +138,83 @@ def estimate_inplane_rotations(cl_class, vis): pf /= norm(pf, axis=-1)[..., np.newaxis] n_pairs = n_img * (n_img - 1) // 2 - with tqdm(total=n_pairs) as pbar: - idx = 0 - # Note: the ordering of i and j in these loops should not be changed as - # they correspond to the ordered tuples (i, j), for i 1e-12): - logger.warning( - f"Globally Consistent Angular Reconstruction (GCAR) exists" - f" numerical problem: abs(cos_phi2) > 1, with the" - f" difference of {np.abs(cos_phi2)-1}." - ) - cos_phi2 = np.clip(cos_phi2, -1, 1) - - # Store angles between i and j induced by each third image k. - phis = cos_phi2 - # Sore good indices of l in k_list of the image that creates that angle. - inds = k_list[good_idx] - - if phis.shape[0] == 0: - return None, [] - - # Parameters used to compute the smoothed angle histogram. - ntics = int(180 / self.hist_bin_width) - angles_grid = np.linspace(0, 180, ntics + 1, True) - - # Get angles between images i and j for computing the histogram - angles = np.arccos(phis[:]) * 180 / np.pi - - # Angles that are up to 10 degrees apart are considered - # similar. This sigma ensures that the width of the density - # estimation kernel is roughly 10 degrees. For 15 degrees, the - # value of the kernel is negligible. - sigma = getattr(self, "sigma", 3.0) # get from class if avail - - # Compute the histogram of the angles between images i and j - angles_distances = angles_grid[None, :] - angles[:, None] - angles_hist = np.sum(np.exp(-(angles_distances**2) / (2 * sigma**2)), axis=0) - - # We assume that at the location of the peak we get the true angle - # between images i and j. Find all third images k, that induce an - # angle between i and j that is at most 10 off the true angle. - # Even for debugging, don't put a value that is smaller than two - # tics, since the peak might move a little bit due to wrong k images - # that accidentally fall near the peak. - peak_idx = angles_hist.argmax() - - if self.full_width == -1: - # Adaptive width (MATLAB) - # Look for the estimations in the peak of the histogram - w_theta_needed = 0 - idx = [] - while sum(idx) == 0: - w_theta_needed += self.hist_bin_width # widen peak as needed - idx = np.abs(angles - angles_grid[peak_idx]) < w_theta_needed - if w_theta_needed > self.hist_bin_width: - logger.info( - f"Adaptive width {w_theta_needed} required for ({i},{j}), found {sum(idx)} indices." - ) - else: - # Fixed width - idx = np.abs(angles - angles_grid[peak_idx]) < self.full_width - - good_k = inds[idx] - alpha = np.arccos(phis[idx]) - - return alpha, good_k.astype("int") - - def _get_cos_phis(self, cl_diff1, cl_diff2, cl_diff3, n_theta, sync=False): - """ - Calculate cos values of rotation angles between i and j images - - Given C1, C2, and C3 are unit circles of image i, j, and k, compute - resulting cos values of rotation angles between i an j images when both - of them are intersecting with k. - - To ensure that the smallest singular value is big enough, controlled by - the determinant of the matrix, - C=[ 1 c1 c2 ; - c1 1 c3 ; - c2 c3 1 ], - we therefore use the condition below - 1+2*c1*c2*c3-(c1^2+c2^2+c3^2) > 1.0e-5, - so the matrix is far from singular. - - :param cl_diff1: Difference of common line indices on C1 created by - its intersection with C3 and C2 - :param cl_diff2: Difference of common line indices on C2 created by - its intersection with C1 and C3 - :param cl_diff3: Difference of common line indices on C3 created by - its intersection with C2 and C1 - :param n_theta: The number of points in the theta direction (common lines) - :param sync: Perform 180 degree ambiguity synchronization. - :return: cos values of rotation angles between i and j images - and indices for good k - """ - - # Calculate the theta values from the differences of common line indices - # C1, C2, and C3 are unit circles of image i, j, and k - # theta1 is the angle on C1 created by its intersection with C3 and C2. - # theta2 is the angle on C2 created by its intersection with C1 and C3. - # theta3 is the angle on C3 created by its intersection with C2 and C1. - theta1 = cl_diff1 * 2 * np.pi / n_theta - theta2 = cl_diff2 * 2 * np.pi / n_theta - theta3 = cl_diff3 * 2 * np.pi / n_theta - - c1 = np.cos(theta1) - c2 = np.cos(theta2) - c3 = np.cos(theta3) - - # Each common-line corresponds to a point on the unit sphere. Denote the - # coordinates of these points by (Pix, Piy Piz), and put them in the matrix - # M=[ P1x P2x P3x ; - # P1y P2y P3y ; - # P1z P2z P3z ]. - # - # Then the matrix - # C=[ 1 c1 c2 ; - # c1 1 c3 ; - # c2 c3 1 ], - # where c1, c2, c3 are given above, is given by C = M.T @ M. - # For the points P1, P2, and P3 to form a triangle on the unit sphere, a - # necessary and sufficient condition is for C to be positive definite. This - # is equivalent to - # 1+2*c1*c2*c3-(c1^2+c2^2+c3^2) > 0. - # However, this may result in a triangle that is too flat, that is, the - # angle between the projections is very close to zero. We therefore use the - # condition below - # 1+2*c1*c2*c3-(c1^2+c2^2+c3^2) > 1.0e-5. - # This ensures that the smallest singular value (which is actually - # controlled by the determinant of C) is big enough, so the matrix is far - # from singular. This condition is equivalent to computing the singular - # values of C, followed by checking that the smallest one is big enough. - - cond = 1 + 2 * c1 * c2 * c3 - (np.square(c1) + np.square(c2) + np.square(c3)) - good_idx = np.nonzero(cond > 1e-5)[0] - - # Calculated cos values of angle between i and j images - if sync: - # MATLAB - cos_phi2 = (c3[good_idx] - c1[good_idx] * c2[good_idx]) / ( - np.sqrt(1 - c1[good_idx] ** 2) * np.sqrt(1 - c2[good_idx] ** 2) - ) + :return: The rotation matrix that takes image i to image j for good index of k. + """ - # Some synchronization must be applied when common line is - # out by 180 degrees. - # Here fix the angles between c_ij(c_ji) and c_ik(c_jk) to be smaller than pi/2, - # otherwise there will be an ambiguity between alpha and pi-alpha. - TOL_idx = 1e-12 + if i == j: + return [] - # Select only good_idx - theta1 = theta1[good_idx] - theta2 = theta2[good_idx] - theta3 = theta3[good_idx] + # Prepare the theta values from the differences of common line indices + # C1, C2, and C3 are unit circles of image i, j, and k + # cl_diff1 is for the angle on C1 created by its intersection with C3 and C2. + # cl_diff2 is for the angle on C2 created by its intersection with C1 and C3. + # cl_diff3 is for the angle on C3 created by its intersection with C2 and C1. + cl_diff1 = clmatrix[i, good_k] - clmatrix[i, j] # for theta1 + cl_diff2 = clmatrix[j, good_k] - clmatrix[j, i] # for theta2 + cl_diff3 = clmatrix[good_k, j] - clmatrix[good_k, i] # for theta3 - # Check sync conditions - ind1 = (theta1 > (np.pi + TOL_idx)) | ( - (theta1 < -TOL_idx) & (theta1 > -np.pi) - ) - ind2 = (theta2 > (np.pi + TOL_idx)) | ( - (theta2 < -TOL_idx) & (theta2 > -np.pi) - ) - align180 = (ind1 & ~ind2) | (~ind1 & ind2) - - # Apply sync - cos_phi2[align180] = -cos_phi2[align180] - else: - # Python - cos_phi2 = (c3[good_idx] - c1[good_idx] * c2[good_idx]) / ( - np.sin(theta1[good_idx]) * np.sin(theta2[good_idx]) + # Calculate the cos values of rotation angles between i an j images for good k images + c_alpha, good_idx = _get_cos_phis(cl_diff1, cl_diff2, cl_diff3, n_theta, sync=False) + + if len(c_alpha) == 0: + return None + alpha = np.arccos(c_alpha) + + # Convert the Euler angles with ZYZ conversion to rotation matrices + angles = np.zeros((alpha.shape[0], 3)) + angles[:, 0] = clmatrix[i, j] * 2 * np.pi / n_theta + np.pi / 2 + angles[:, 1] = alpha + angles[:, 2] = -np.pi / 2 - clmatrix[j, i] * 2 * np.pi / n_theta + r = Rotation.from_euler(angles).matrices + + return r[good_idx, :, :] + + +def _vote_ij( + clmatrix, n_theta, i, j, k_list, hist_bin_width, full_width, sigma=3.0, sync=False +): + """ + Apply the voting algorithm for images i and j. + + clmatrix is the common lines matrix, constructed using angular resolution, + n_theta. k_list are the images to be used for voting of the pair of images + (i ,j). + + :param clmatrix: The common lines matrix + :param n_theta: The number of points in the theta direction (common lines) + :param i: The i image + :param j: The j image + :param k_list: The list of images for the third image for voting algorithm + :param hist_bin_width: Bin width in smoothing histogram (degrees). + :param full_width: Selection width around smoothed histogram peak (degrees). + `adaptive` will attempt to automatically find the smallest number of + `hist_bin_width`s required to find at least one valid image index. + :param sigma: Voting contribution smoothing factor. Default is 3.0. + :param sync: Perform 180 degree ambiguity synchronization. + + :return: (alpha, good_k), angles and list of all third images + in the peak of the histogram corresponding to the pair of + images (i,j) + """ + + if i == j or clmatrix[i, j] == -1: + return None, [] + + # Some of the entries in clmatrix may be zero if we cleared + # them due to small correlation, or if for each image + # we compute intersections with only some of the other images. + # + # Note that as long as the diagonal of the common lines matrix is + # -1, the conditions (i != j) && (j != k) are not needed, since + # if i == j then clmatrix[i, k] == -1 and similarly for i == k or + # j == k. Thus, the previous voting code (from the JSB paper) is + # correct even though it seems that we should test also that + # (i != j) && (i != k) && (j != k), and only (i != j) && (i != k) + # as tested there. + cl_idx12 = clmatrix[i, j] + cl_idx21 = clmatrix[j, i] + k_list = k_list[ + (k_list != i) & (clmatrix[i, k_list] != -1) & (clmatrix[j, k_list] != -1) + ] + cl_idx13 = clmatrix[i, k_list] + cl_idx31 = clmatrix[k_list, i] + cl_idx23 = clmatrix[j, k_list] + cl_idx32 = clmatrix[k_list, j] + + # Prepare the theta values from the differences of common line indices + # C1, C2, and C3 are unit circles of image i, j, and k + # cl_diff1 is for the angle on C1 created by its intersection with C3 and C2. + # cl_diff2 is for the angle on C2 created by its intersection with C1 and C3. + # cl_diff3 is for the angle on C3 created by its intersection with C2 and C1. + cl_diff1 = cl_idx13 - cl_idx12 + cl_diff2 = cl_idx23 - cl_idx21 + cl_diff3 = cl_idx32 - cl_idx31 + + # Calculate the cos values of rotation angles between i an j images for good k images + cos_phi2, good_idx = _get_cos_phis(cl_diff1, cl_diff2, cl_diff3, n_theta, sync=sync) + + if np.any(np.abs(cos_phi2) - 1 > 1e-12): + logger.warning( + f"Globally Consistent Angular Reconstruction (GCAR) exists" + f" numerical problem: abs(cos_phi2) > 1, with the" + f" difference of {np.abs(cos_phi2)-1}." + ) + cos_phi2 = np.clip(cos_phi2, -1, 1) + + # Store angles between i and j induced by each third image k. + phis = cos_phi2 + # Sore good indices of l in k_list of the image that creates that angle. + inds = k_list[good_idx] + + if phis.shape[0] == 0: + return None, [] + + # Parameters used to compute the smoothed angle histogram. + ntics = int(180 / hist_bin_width) + angles_grid = np.linspace(0, 180, ntics + 1, True) + + # Get angles between images i and j for computing the histogram + angles = np.arccos(phis[:]) * 180 / np.pi + + # Angles that are up to 10 degrees apart are considered + # similar. `sigma` ensures that the width of the density + # estimation kernel is roughly 10 degrees. For 15 degrees, the + # value of the kernel is negligible. + + # Compute the histogram of the angles between images i and j + angles_distances = angles_grid[None, :] - angles[:, None] + angles_hist = np.sum(np.exp(-(angles_distances**2) / (2 * sigma**2)), axis=0) + + # We assume that at the location of the peak we get the true angle + # between images i and j. Find all third images k, that induce an + # angle between i and j that is at most 10 off the true angle. + # Even for debugging, don't put a value that is smaller than two + # tics, since the peak might move a little bit due to wrong k images + # that accidentally fall near the peak. + peak_idx = angles_hist.argmax() + + if full_width == -1: + # Adaptive width (MATLAB) + # Look for the estimations in the peak of the histogram + w_theta_needed = 0 + idx = [] + while sum(idx) == 0: + w_theta_needed += hist_bin_width # widen peak as needed + idx = np.abs(angles - angles_grid[peak_idx]) < w_theta_needed + if w_theta_needed > hist_bin_width: + logger.info( + f"Adaptive width {w_theta_needed} required for ({i},{j}), found {sum(idx)} indices." ) + else: + # Fixed width + idx = np.abs(angles - angles_grid[peak_idx]) < full_width + + good_k = inds[idx] + alpha = np.arccos(phis[idx]) + + return alpha, good_k.astype("int") + + +def _get_cos_phis(cl_diff1, cl_diff2, cl_diff3, n_theta, sync=False): + """ + Calculate cos values of rotation angles between i and j images + + Given C1, C2, and C3 are unit circles of image i, j, and k, compute + resulting cos values of rotation angles between i an j images when both + of them are intersecting with k. + + To ensure that the smallest singular value is big enough, controlled by + the determinant of the matrix, + C=[ 1 c1 c2 ; + c1 1 c3 ; + c2 c3 1 ], + we therefore use the condition below + 1+2*c1*c2*c3-(c1^2+c2^2+c3^2) > 1.0e-5, + so the matrix is far from singular. + + :param cl_diff1: Difference of common line indices on C1 created by + its intersection with C3 and C2 + :param cl_diff2: Difference of common line indices on C2 created by + its intersection with C1 and C3 + :param cl_diff3: Difference of common line indices on C3 created by + its intersection with C2 and C1 + :param n_theta: The number of points in the theta direction (common lines) + :param sync: Perform 180 degree ambiguity synchronization. + + :return: cos values of rotation angles between i and j images + and indices for good k + """ + + # Calculate the theta values from the differences of common line indices + # C1, C2, and C3 are unit circles of image i, j, and k + # theta1 is the angle on C1 created by its intersection with C3 and C2. + # theta2 is the angle on C2 created by its intersection with C1 and C3. + # theta3 is the angle on C3 created by its intersection with C2 and C1. + theta1 = cl_diff1 * 2 * np.pi / n_theta + theta2 = cl_diff2 * 2 * np.pi / n_theta + theta3 = cl_diff3 * 2 * np.pi / n_theta + + c1 = np.cos(theta1) + c2 = np.cos(theta2) + c3 = np.cos(theta3) + + # Each common-line corresponds to a point on the unit sphere. Denote the + # coordinates of these points by (Pix, Piy Piz), and put them in the matrix + # M=[ P1x P2x P3x ; + # P1y P2y P3y ; + # P1z P2z P3z ]. + # + # Then the matrix + # C=[ 1 c1 c2 ; + # c1 1 c3 ; + # c2 c3 1 ], + # where c1, c2, c3 are given above, is given by C = M.T @ M. + # For the points P1, P2, and P3 to form a triangle on the unit sphere, a + # necessary and sufficient condition is for C to be positive definite. This + # is equivalent to + # 1+2*c1*c2*c3-(c1^2+c2^2+c3^2) > 0. + # However, this may result in a triangle that is too flat, that is, the + # angle between the projections is very close to zero. We therefore use the + # condition below + # 1+2*c1*c2*c3-(c1^2+c2^2+c3^2) > 1.0e-5. + # This ensures that the smallest singular value (which is actually + # controlled by the determinant of C) is big enough, so the matrix is far + # from singular. This condition is equivalent to computing the singular + # values of C, followed by checking that the smallest one is big enough. + + cond = 1 + 2 * c1 * c2 * c3 - (np.square(c1) + np.square(c2) + np.square(c3)) + good_idx = np.nonzero(cond > 1e-5)[0] + + # Calculated cos values of angle between i and j images + if sync: + # MATLAB + cos_phi2 = (c3[good_idx] - c1[good_idx] * c2[good_idx]) / ( + np.sqrt(1 - c1[good_idx] ** 2) * np.sqrt(1 - c2[good_idx] ** 2) + ) + + # Some synchronization must be applied when common line is + # out by 180 degrees. + # Here fix the angles between c_ij(c_ji) and c_ik(c_jk) to be smaller than pi/2, + # otherwise there will be an ambiguity between alpha and pi-alpha. + TOL_idx = 1e-12 + + # Select only good_idx + theta1 = theta1[good_idx] + theta2 = theta2[good_idx] + theta3 = theta3[good_idx] + + # Check sync conditions + ind1 = (theta1 > (np.pi + TOL_idx)) | ((theta1 < -TOL_idx) & (theta1 > -np.pi)) + ind2 = (theta2 > (np.pi + TOL_idx)) | ((theta2 < -TOL_idx) & (theta2 > -np.pi)) + align180 = (ind1 & ~ind2) | (~ind1 & ind2) + + # Apply sync + cos_phi2[align180] = -cos_phi2[align180] + else: + # Python + cos_phi2 = (c3[good_idx] - c1[good_idx] * c2[good_idx]) / ( + np.sin(theta1[good_idx]) * np.sin(theta2[good_idx]) + ) - return cos_phi2, good_idx + return cos_phi2, good_idx diff --git a/src/aspire/utils/__init__.py b/src/aspire/utils/__init__.py index ae781d823f..ae896ebeb8 100644 --- a/src/aspire/utils/__init__.py +++ b/src/aspire/utils/__init__.py @@ -1,5 +1,4 @@ from .types import complex_type, real_type, utest_tolerance # isort:skip - from .coor_trans import ( # isort:skip mean_aligned_angular_distance, cart2pol, diff --git a/tests/test_commonline_utils.py b/tests/test_commonline_utils.py new file mode 100644 index 0000000000..64d015d1dd --- /dev/null +++ b/tests/test_commonline_utils.py @@ -0,0 +1,118 @@ +import numpy as np +import pytest + +from aspire.abinitio import JSync +from aspire.abinitio.commonline_utils import ( + _complete_third_row_to_rot, + _estimate_third_rows, + build_outer_products, +) +from aspire.utils import J_conjugate, Rotation, randn, utest_tolerance + +DTYPES = [np.float32, np.float64] + + +@pytest.fixture(params=DTYPES, ids=lambda x: f"dtype={x}", scope="module") +def dtype(request): + return request.param + + +def test_estimate_third_rows(dtype): + """ + Test we accurately estimate a set of 3rd rows of rotation matrices + given the 3rd row outer products vijs = vi @ vj.T and viis = vi @ vi.T. + """ + n_img = 20 + + # `build_outer_products` generates a set of ground truth 3rd rows + # of rotation matrices, then forms the outer products vijs = vi @ vj.T + # and viis = vi @ vi.T. + vijs, viis, gt_vis = build_outer_products(n_img, dtype) + + # Estimate third rows from outer products. + # Due to factorization of V, these might be negated third rows. + vis = _estimate_third_rows(vijs, viis) + + # Check if all-close up to difference of sign + ground_truth = np.sign(gt_vis[0, 0]) * gt_vis + estimate = np.sign(vis[0, 0]) * vis + np.testing.assert_allclose(ground_truth, estimate, rtol=1e-05, atol=1e-08) + + # Check dtype passthrough + assert vis.dtype == dtype + + +def test_complete_third_row(dtype): + """ + Test that `complete_third_row_to_rot` produces a proper rotations + given a set of 3rd rows. + """ + # Build random third rows. + r3 = randn(10, 3, seed=123).astype(dtype) + r3 /= np.linalg.norm(r3, axis=1)[..., np.newaxis] + + # Set first row to be identical with z-axis. + r3[0] = np.array([0, 0, 1], dtype=dtype) + + # Generate rotations. + R = _complete_third_row_to_rot(r3) + + # Check dtype passthrough + assert R.dtype == dtype + + # Assert that first rotation is the identity matrix. + np.testing.assert_allclose(R[0], np.eye(3, dtype=dtype)) + + # Assert that each rotation is orthogonal with determinant 1. + assert np.allclose( + R @ R.transpose((0, 2, 1)), np.eye(3, dtype=dtype), atol=utest_tolerance(dtype) + ) + assert np.allclose(np.linalg.det(R), 1) + + +def test_J_sync(dtype): + """ + Test that the J_sync `power_method` returns a set of signs indicating + the set of relative rotations that need to be J-conjugated to attain + global handedness consistency, and that `global_J_sync` returns the + ground truth rotations up to a spurious J-conjugation. + """ + n = 25 + rots = Rotation.generate_random_rotations(n, dtype=dtype).matrices + + # Generate ground truth and randomly J-conjugate relative rotations, + # keeping track of the signs associated with J-conjugated rotations. + n_choose_2 = (n * (n - 1)) // 2 + signs = np.random.randint(0, 2, n_choose_2) * 2 - 1 + Rijs_gt = np.zeros((n_choose_2, 3, 3), dtype=dtype) + Rijs_conjugated = np.zeros((n_choose_2, 3, 3), dtype=dtype) + ij = 0 + for i in range(n - 1): + Ri = rots[i] + for j in range(i + 1, n): + Rj = rots[j] + Rijs_gt[ij] = Rij = Ri.T @ Rj + if signs[ij] == -1: + Rij = J_conjugate(Rij) + Rijs_conjugated[ij] = Rij + ij += 1 + + # Initialize JSync instance with default params. + J_sync = JSync(n) + + # Perform power method and check that signs are correct up to + # multilication by -1. Also check dtype pass-through. + signs_est = J_sync.power_method(Rijs_conjugated) + np.testing.assert_allclose(signs[0] * signs, signs_est[0] * signs_est) + assert signs_est.dtype == dtype + + # Perform global J sync and check that rotations are correct up to + # a spurious J conjugation. Also check dtype pass-through. + Rijs_sync = J_sync.global_J_sync(Rijs_conjugated) + + # If the first is off by a J, J-conjugate the whole set. + if np.allclose(Rijs_gt[0], J_conjugate(Rijs_sync[0])): + Rijs_sync = J_conjugate(Rijs_sync) + + np.testing.assert_allclose(Rijs_sync, Rijs_gt) + assert Rijs_sync.dtype == dtype diff --git a/tests/test_orient_symmetric.py b/tests/test_orient_symmetric.py index d7c4c4716f..ed9c5a6904 100644 --- a/tests/test_orient_symmetric.py +++ b/tests/test_orient_symmetric.py @@ -1,17 +1,15 @@ import numpy as np import pytest -from numpy import pi, random -from numpy.linalg import det, norm from aspire.abinitio import ( CLSymmetryC2, CLSymmetryC3C4, CLSymmetryCn, - cl_angles_to_ind, - complete_third_row_to_rot, - estimate_third_rows, + build_outer_products, + g_sync, ) from aspire.abinitio.commonline_cn import MeanOuterProductEstimator +from aspire.abinitio.commonline_utils import _cl_angles_to_ind from aspire.source import Simulation from aspire.utils import ( J_conjugate, @@ -19,8 +17,6 @@ all_pairs, cyclic_rotations, mean_aligned_angular_distance, - randn, - utest_tolerance, ) from aspire.volume import CnSymmetricVolume @@ -127,7 +123,7 @@ def test_estimate_rotations(n_img, L, order, dtype): rots_gt = src.rotations # g-synchronize ground truth rotations. - rots_gt_sync = cl_symm.g_sync(rots_est, order, rots_gt) + rots_gt_sync = g_sync(rots_est, order, rots_gt) # Register estimates to ground truth rotations and check that the # mean angular distance between them is less than 3 degrees. @@ -141,9 +137,7 @@ def test_relative_rotations(n_img, L, order, dtype): src, cl_symm = source_orientation_objs(n_img, L, order, dtype) # Estimate relative viewing directions. - cl_symm.build_clmatrix() - cl = cl_symm.clmatrix - Rijs = cl_symm._estimate_all_Rijs_c3_c4(cl) + Rijs = cl_symm._estimate_all_Rijs_c3_c4() # Each Rij belongs to the set {Ri.Tg_n^sRj, JRi.Tg_n^sRjJ}, # s = 1, 2, ..., order. We find the mean squared error over @@ -326,8 +320,8 @@ def test_self_commonlines(n_img, L, order, dtype): # Get angle difference between scl_gt and scl. scl_diff1 = scl_gt - scl scl_diff2 = scl_gt - np.flip(scl, 1) # Order of indices might be switched. - scl_diff1_angle = scl_diff1 * 2 * pi / n_theta - scl_diff2_angle = scl_diff2 * 2 * pi / n_theta + scl_diff1_angle = scl_diff1 * 2 * np.pi / n_theta + scl_diff2_angle = scl_diff2 * 2 * np.pi / n_theta # cosine is invariant to 2pi, and abs is invariant to +-pi due to J-conjugation. # We take the mean deviation wrt to the two lines in each image. @@ -339,7 +333,7 @@ def test_self_commonlines(n_img, L, order, dtype): min_mean_angle_diff = scl_idx.choose(scl_diff_angle_mean) # Assert scl detection rate is 100% for 5 degree angle tolerance - angle_tol_err = 5 * pi / 180 + angle_tol_err = 5 * np.pi / 180 detection_rate = np.count_nonzero(min_mean_angle_diff < angle_tol_err) / len(scl) assert np.allclose(detection_rate, 1.0) @@ -484,45 +478,6 @@ def test_global_J_sync(n_img, dtype): assert np.allclose(viis, viis_sync) -@pytest.mark.parametrize("dtype", [np.float32, np.float64]) -def test_estimate_third_rows(dtype): - n_img = 20 - - # Build outer products vijs, viis, and get ground truth third rows. - vijs, viis, gt_vis = build_outer_products(n_img, dtype) - - # Estimate third rows from outer products. - # Due to factorization of V, these might be negated third rows. - vis = estimate_third_rows(vijs, viis) - - # Check if all-close up to difference of sign - ground_truth = np.sign(gt_vis[0, 0]) * gt_vis - estimate = np.sign(vis[0, 0]) * vis - assert np.allclose(ground_truth, estimate) - - -@pytest.mark.parametrize("dtype", [np.float32, np.float64]) -def test_complete_third_row(dtype): - # Build random third rows. - r3 = randn(10, 3, seed=123).astype(dtype) - r3 /= norm(r3, axis=1)[..., np.newaxis] - - # Set first row to be identical with z-axis. - r3[0] = np.array([0, 0, 1], dtype=dtype) - - # Generate rotations. - R = complete_third_row_to_rot(r3) - - # Assert that first rotation is the identity matrix. - assert np.allclose(R[0], np.eye(3, dtype=dtype)) - - # Assert that each rotation is orthogonal with determinant 1. - assert np.allclose( - R @ R.transpose((0, 2, 1)), np.eye(3, dtype=dtype), atol=utest_tolerance(dtype) - ) - assert np.allclose(det(R), 1) - - @pytest.mark.parametrize("dtype", [np.float32, np.float64]) def test_dtype_pass_through(dtype): L = 16 @@ -558,31 +513,6 @@ def build_self_commonlines_matrix(n_theta, rots, order): return scl_gt -def build_outer_products(n_img, dtype): - # Build random third rows, ground truth vis (unit vectors) - gt_vis = np.zeros((n_img, 3), dtype=dtype) - for i in range(n_img): - random.seed(i) - v = random.randn(3) - gt_vis[i] = v / norm(v) - - # Find outer products viis and vijs for i