Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/aspire/abinitio/commonline_lud.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ def __init__(
self.eps = eps

# Adjust rank limits
self.max_rankZ = max_rankW or max(6, self.n_img // 2)
self.max_rankZ = max_rankZ or max(6, self.n_img // 2)
self.max_rankW = max_rankW or max(6, self.n_img // 2)

# Upper-triangular mask used in `_Q_theta`
Expand Down
1 change: 1 addition & 0 deletions src/aspire/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from .types import complex_type, real_type, utest_tolerance # isort:skip
from .coor_trans import ( # isort:skip
mean_aligned_angular_distance,
mean_aligned_shift_error,
cart2pol,
crop_pad_2d,
crop_pad_3d,
Expand Down
33 changes: 33 additions & 0 deletions src/aspire/utils/coor_trans.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,39 @@ def mean_aligned_angular_distance(rots_est, rots_gt, degree_tol=None):
return mean_ang_dist


def mean_aligned_shift_error(rots, est_shifts, gt_shifts):
"""
Mean per-image shift error after aligning the 3D translation ambiguity.

:param rots: Reference rotations used to define global 3D translation.
:param est_shifts: Estimated shifts.
:param gt_shifts: Ground truth shifts.

:return: The mean Euclidean distance between ground truth and
estimated shifts after aligning the 3D translational ambiguity.
"""

# For image i, R_i.T expresses a global 3D translation t in that image's
# coordinates: (image_x, image_y, viewing_axis). Only the first two
# components shift the 2D image, so stack those rows for every image.
basis = rots.transpose(0, 2, 1)[:, :2, :].reshape(-1, 3)

# QR gives perpendicular unit directions spanning the same shift patterns.
q, _ = np.linalg.qr(basis, mode="reduced")

# Flatten the per-image (x, y) errors to match the row order of basis and q.
error = (gt_shifts - est_shifts).reshape(-1)

# q.T @ error finds the amount of error along each column of q.
# q @ (q.T @ error) projects the error onto shifts caused by a global 3D translation.
# We subtract away the error caused by global translation leaving the error
# inherent to the shift estimation alone.
aligned_error = error - q @ (q.T @ error)

# Return mean Euclidean error.
return np.linalg.norm(aligned_error.reshape(-1, 2), axis=1).mean()


def rots_to_clmatrix(rots, n_theta):
"""
Compute the common lines matrix induced by all pairs of rotation
Expand Down
52 changes: 52 additions & 0 deletions tests/test_coor_trans.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
grid_2d,
grid_3d,
mean_aligned_angular_distance,
mean_aligned_shift_error,
)

DATA_DIR = os.path.join(os.path.dirname(__file__), "saved_test_data")
Expand Down Expand Up @@ -336,3 +337,54 @@ def test_mean_aligned_angular_distance():

# Test internal assert using the `degree_tol` argument.
mean_aligned_angular_distance(rots_est, rots_gt, degree_tol=0.1)


def test_mean_aligned_shift_error():
"""
Check that global 3D translation is ignored but image-specific shift error remains.
"""
n = 8
rots = Rotation.generate_random_rotations(n, seed=0, dtype=np.float64).matrices

# Give each image a random reference shift.
reference_offsets = np.random.default_rng(1).normal(size=(n, 2))

# Fix a global 3D translation and project it onto each image plane
# to find its 2D contribution to that image's shift.
translation = np.array([1.2, -0.8, 0.5])
projected_translation = np.array([(rot.T @ translation)[:2] for rot in rots])

# A global 3D translation should contribute no aligned error.
np.testing.assert_allclose(
mean_aligned_shift_error(
rots, reference_offsets + projected_translation, reference_offsets
),
0,
atol=1e-12,
)

# Now we add some error that cannot be accounted for by a global translation:
# The third column of each rotation is that image's viewing direction.
# Their cross product lies in the planes of both images. Make it
# unit length so the errors added below each have length 1.
u = np.cross(rots[0, :, 2], rots[1, :, 2])
u /= np.linalg.norm(u)

# Add opposite errors along this shared direction to two images.
# No single move of the 3D object can explain this pair of errors.
non_translation_error = np.zeros_like(reference_offsets)
non_translation_error[0] = (rots[0].T @ u)[:2]
non_translation_error[1] = -(rots[1].T @ u)[:2]

# Each of those two images has an error of length 1; the others have 0.
# The mean error over all n images should then be 2 / n.
np.testing.assert_allclose(
mean_aligned_shift_error(
rots,
reference_offsets + projected_translation + non_translation_error,
reference_offsets,
),
2 / n,
rtol=0,
atol=1e-12,
)
44 changes: 29 additions & 15 deletions tests/test_orient_d2.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

DTYPE = [np.float32, pytest.param(np.float64, marks=pytest.mark.expensive)]
RESOLUTION = [48, 49]
N_IMG = [10]
N_IMG = [20]
OFFSETS = [0, pytest.param(None, marks=pytest.mark.expensive)]

# Since these tests are optimized for runtime, detuned parameters cause
Expand Down Expand Up @@ -86,6 +86,9 @@ def orient_est(source):
#########


@pytest.mark.skip(
reason="g_sync_d2 test helper is incorrect; remove after downstream fix (PR #1391) merges"
)
def test_estimate_rotations(orient_est):
"""
This test runs through the complete D2 algorithm and compares the
Expand Down Expand Up @@ -120,14 +123,15 @@ def test_scl_scores(orient_est):
they match perfectly the Simulation rotations.
"""
# Generate lookup data and extract rotations from the candidate `sphere_grid`.
# In this case, we take first 10 candidates from a non-equator viewing direction.
# In this case, we take first src.n candidates from a non-equator viewing direction.
n_cand = orient_est.src.n
orient_est._generate_lookup_data()
cand_rots = orient_est.inplane_rotated_grid1
non_eq_idx = int(np.argwhere(orient_est.eq_class1 == 0)[0][0])
rots = cand_rots[non_eq_idx, :10]
rots = cand_rots[non_eq_idx, :n_cand]
angles = Rotation(rots).angles

# Create a Simulation using those first 10 candidate rotations.
# Create a Simulation using those first n_cand candidate rotations.
src = Simulation(
n=orient_est.src.n,
L=orient_est.src.L,
Expand All @@ -139,7 +143,9 @@ def test_scl_scores(orient_est):
)

# Initialize CL instance with new source.
cl = build_cl_from_source(src)
# Disabling fuzzy_mask as it was obscuring relevant image
# data for images with large offsets.
cl = build_cl_from_source(src, mask=False)

# Generate lookup data.
cl._compute_shifted_pf()
Expand All @@ -149,15 +155,22 @@ def test_scl_scores(orient_est):
# Compute self-commonline scores.
cl._compute_scl_scores()

# cl.scls_scores is shape (n_img, n_cand_rots). Since we used the first
# 10 candidate rotations of the first non-equator viewing direction as our
# Simulation rotations, the maximum correlation for image i should occur at
# candidate rotation index (non_eq_idx * cl.n_inplane_rots + i).
# Scores have one row per image and one column per candidate rotation.
# Image i uses in-plane candidate i at viewing direction index non_eq_idx.
# Either that candidate or its 180-degree counterpart may win.
max_corr_idx = np.argmax(cl.scls_scores, axis=1)
gt_idx = non_eq_idx * cl.n_inplane_rots + np.arange(10)
gt_idx = non_eq_idx * cl.n_inplane_rots + np.arange(n_cand)

# Self-commonline scores are identical for in-plane rotations 180 degrees
# apart. This grid has 30 angles, so the equivalent candidate is 15
# indices away, wrapping within the same viewing direction.
half_turn = cl.n_inplane_rots // 2
equiv_idx = non_eq_idx * cl.n_inplane_rots + (
(np.arange(n_cand) + half_turn) % cl.n_inplane_rots
)

# Check that self-commonline indices match ground truth.
n_match = np.sum(max_corr_idx == gt_idx)
# Count either representative of the correct 180-degree pair as a match.
n_match = np.count_nonzero((max_corr_idx == gt_idx) | (max_corr_idx == equiv_idx))
match_tol = 0.99 # match at least 99%.
if not (src.offsets == 0.0).all():
match_tol = 0.89 # match at least 89% with offsets.
Expand Down Expand Up @@ -451,13 +464,13 @@ def g_sync_d2(rots, rots_gt):
return rots_gt_sync


def build_cl_from_source(source):
def build_cl_from_source(source, mask=True):
# Search for common lines over less shifts for 0 offsets.
max_shift = 0
shift_step = 1
if source.offsets.all() != 0:
max_shift = 0.2
shift_step = 0.02 # Reduce shift steps for non-integer offsets of Simulation.
max_shift = 0.25
shift_step = 0.25 # Reduce shift steps for non-integer offsets of Simulation.

orient_est = CLSymmetryD2(
source,
Expand All @@ -470,5 +483,6 @@ def build_cl_from_source(source):
eq_min_dist=10, # Tuned for speed
epsilon=0.001,
seed=SEED,
mask=mask,
)
return orient_est
2 changes: 1 addition & 1 deletion tests/test_orient_irls.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

SPECTRAL_NORM_CONSTRAINT = [
None,
2 / 3,
0.8, # Bsaed on measured ground truth Gram matrix spectral norm.
]

ADAPTIVE_PROJECTION = [
Expand Down
4 changes: 3 additions & 1 deletion tests/test_orient_lud.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
]

SPECTRAL_NORM_CONSTRAINT = [
2 / 3,
0.8, # Bsaed on measured ground truth Gram matrix spectral norm.
pytest.param(None, marks=pytest.mark.expensive),
]

Expand Down Expand Up @@ -82,13 +82,15 @@ def source(resolution, offsets, dtype):
def orient_est(source, alpha, adp_proj):
"""Fixture for LUD orientation estimation object."""
# Generate LUD orientation estimation object.
shift_step = 0.5 if np.any(source.offsets != 0) else 1
orient_est = CommonlineLUD(
source,
alpha=alpha,
adp_proj=adp_proj,
delta_mu_l=0.4, # Ensures branch is tested
mask=False,
tol=0.005, # Improves test speed
shift_step=shift_step,
)

return orient_est
Expand Down
31 changes: 18 additions & 13 deletions tests/test_orient_sdp.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,22 @@ def dtype(request):
@pytest.fixture
def src_orient_est_fixture(resolution, offsets, dtype):
"""Fixture for simulation source and orientation estimation object."""
seed = 1984
src = Simulation(
n=50,
L=resolution,
vols=AsymmetricVolume(L=resolution, C=1, K=100, seed=0).generate(),
vols=AsymmetricVolume(
L=resolution, C=1, K=100, seed=seed, dtype=dtype
).generate(),
offsets=offsets,
amplitudes=1,
seed=0,
seed=seed,
)

# Increase max_shift and set shift_step to be sub-pixel when using
# random offsets in the Simulation. This improves common-line detection.
max_shift = 0.20
shift_step = 0.25
max_shift = 0.25
shift_step = 0.125

# Set max_shift 1 pixel and shift_step to 1 pixel when using 0 offsets.
if np.all(src.offsets == 0.0):
Expand All @@ -77,8 +80,8 @@ def test_estimate_rotations(src_orient_est_fixture):

# Register estimates to ground truth rotations and compute the
# angular distance between them (in degrees).
# Assert that mean aligned angular distance is less than 1 degrees.
mean_aligned_angular_distance(orient_est.rotations, src.rotations, degree_tol=1)
# Assert that mean aligned angular distance is less than 2 degrees.
mean_aligned_angular_distance(orient_est.rotations, src.rotations, degree_tol=2)


def test_construct_S(src_orient_est_fixture):
Expand All @@ -98,14 +101,14 @@ def test_construct_S(src_orient_est_fixture):

# For uniformly distributed rotations the top eigenvalue should have multiplicity 3.
# As such, we can expect that the top 3 eigenvalues will all be close in value to their mean.
eigs = np.linalg.eigvalsh(S)
eigs = np.linalg.eigvalsh(S)[::-1] # descending order
eigs_mean = np.mean(eigs[:3])

# Check that the top 3 eigenvalues are all within 10% of the their mean.
np.testing.assert_array_less(abs((eigs[:3] - eigs_mean) / eigs_mean), 0.10)
# Check that the top 3 eigenvalues are all within 25% of the their mean.
np.testing.assert_array_less(abs((eigs[:3] - eigs_mean) / eigs_mean), 0.25)

# Check that the next eigenvalue is not close to the top 3, ie. multiplicity is not greater than 3.
np.testing.assert_array_less(0.25, abs((eigs[4] - eigs_mean) / eigs_mean))
np.testing.assert_array_less(0.25, abs((eigs[3] - eigs_mean) / eigs_mean))


def test_gram_matrix(src_orient_est_fixture):
Expand Down Expand Up @@ -133,9 +136,11 @@ def test_gram_matrix(src_orient_est_fixture):
R = np.concatenate((R1, R2))
gt_gram = R @ R.T

# We'll check that the RMSE is within 10% of the mean value of gt_gram
rmse = np.sqrt(np.mean((gram - R @ R.T) ** 2))
np.testing.assert_array_less(rmse / np.mean(gt_gram), 0.10)
# We'll check that the Frobenius relative error is within 1%
relative_error = np.linalg.norm(gram - gt_gram, ord="fro") / np.linalg.norm(
gt_gram, ord="fro"
)
np.testing.assert_array_less(relative_error, 0.01)


def test_ATA_solver():
Expand Down
Loading
Loading