Skip to content

Commit 671e91d

Browse files
committed
Add estimate_shifts test parametrized over symmetry groups
1 parent f68a498 commit 671e91d

1 file changed

Lines changed: 162 additions & 0 deletions

File tree

tests/test_estimate_shifts.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import numpy as np
2+
import pytest
3+
from scipy import sparse
4+
5+
from aspire.abinitio import Orient3D
6+
from aspire.source import Simulation
7+
from aspire.volume import (
8+
AsymmetricVolume,
9+
CnSymmetricVolume,
10+
DnSymmetricVolume,
11+
OSymmetricVolume,
12+
TSymmetricVolume,
13+
)
14+
15+
DTYPES = [np.float64]
16+
RES = [89]
17+
SYMMETRIES = [
18+
None,
19+
"C2",
20+
"C3",
21+
"C4",
22+
"C5",
23+
"C6",
24+
"D2",
25+
"D3",
26+
"D4",
27+
"D5",
28+
"D6",
29+
"D7",
30+
"T",
31+
"O",
32+
]
33+
N_IMGS = 100
34+
SEED = 1980
35+
36+
37+
@pytest.fixture(params=RES, ids=lambda x: f"resolution={x}", scope="module")
38+
def resolution(request):
39+
return request.param
40+
41+
42+
@pytest.fixture(params=SYMMETRIES, ids=lambda x: f"symmetry={x}", scope="module")
43+
def symmetry(request):
44+
return request.param
45+
46+
47+
@pytest.fixture(params=DTYPES, ids=lambda x: f"dtype={x}", scope="module")
48+
def dtype(request):
49+
return request.param
50+
51+
52+
@pytest.fixture(scope="module")
53+
def volume(resolution, symmetry, dtype):
54+
if symmetry is None:
55+
return AsymmetricVolume(
56+
L=resolution, C=1, K=25, dtype=dtype, seed=SEED
57+
).generate()
58+
59+
if symmetry.startswith("C"):
60+
order = int(symmetry[1:])
61+
return CnSymmetricVolume(
62+
L=resolution, C=1, order=order, K=25, dtype=dtype, seed=SEED
63+
).generate()
64+
65+
if symmetry.startswith("D"):
66+
order = int(symmetry[1:])
67+
return DnSymmetricVolume(
68+
L=resolution, C=1, order=order, K=25, dtype=dtype, seed=SEED
69+
).generate()
70+
71+
if symmetry == "T":
72+
return TSymmetricVolume(
73+
L=resolution, C=1, K=25, dtype=dtype, seed=SEED
74+
).generate()
75+
76+
if symmetry == "O":
77+
return OSymmetricVolume(
78+
L=resolution, C=1, K=25, dtype=dtype, seed=SEED
79+
).generate()
80+
81+
82+
@pytest.fixture(scope="module")
83+
def estimator(volume):
84+
"""
85+
Build a simulated source and use ground-truth rotations so this test isolates
86+
shift-equation construction and shift recovery from orientation estimation error.
87+
"""
88+
offset_scale = 1.5 # standard deviation of shifts
89+
offsets = np.random.normal(scale=offset_scale, size=(N_IMGS, 2))
90+
91+
src = Simulation(
92+
n=N_IMGS,
93+
vols=volume,
94+
amplitudes=1,
95+
offsets=offsets,
96+
seed=SEED,
97+
).cache()
98+
99+
orient_est = Orient3D(src)
100+
orient_est.rotations = src.rotations
101+
102+
return orient_est
103+
104+
105+
def test_estimate_shifts(estimator):
106+
"""
107+
Compare estimated shifts to ground truth after removing the nullspace of
108+
the shift equation matrix. See the following publication for more info on
109+
measuring shift estimation error:
110+
111+
Y. Shkolnisky and A. Singer,
112+
Center of Mass Operators for Cryo-EM - Theory and Implementation,
113+
Modeling Nanoscale Imaging in Electron Microscopy,
114+
T. Vogt, W. Dahmen, and P. Binev (Eds.)
115+
Nanostructure Science and Technology Series,
116+
Springer, 2012, pp. 147–177
117+
"""
118+
# Build the sparse common-line shift system Ax = b and solve it directly,
119+
# matching the solver used by estimate_shifts().
120+
A, b = estimator._get_shift_equations_approx()
121+
lsqr_result = sparse.linalg.lsqr(A, b, atol=1e-8, btol=1e-8, iter_lim=100)
122+
x_est = lsqr_result[0]
123+
124+
# Convert Simulation offsets to the internal LSQR convention:
125+
# estimate_shifts returns -x_est.reshape(n, 2)[:, ::-1].
126+
x_ref_internal = (-estimator.src.offsets[:, ::-1]).reshape(-1)
127+
128+
# Use the SVD to separate the constrained directions from the nullspace,
129+
# which corresponds to global 3D translation ambiguity.
130+
_, s, Vt = np.linalg.svd(A.toarray(), full_matrices=False)
131+
132+
# Estimate the effective rank of A and keep the constrained directions.
133+
sv_tol = 1e-2
134+
rank = int(np.sum(s > sv_tol * s[0]))
135+
V_nonnull = Vt[:rank].T
136+
137+
# Compute relative error after projecting out the nullspace.
138+
num = np.linalg.norm(V_nonnull.T @ (x_ref_internal - x_est))
139+
den = np.linalg.norm(V_nonnull.T @ x_ref_internal)
140+
projected_rel_err = num / den
141+
142+
# Check the shift error is within 15% of the reference shift norm.
143+
np.testing.assert_array_less(projected_rel_err, 0.15)
144+
145+
# The projected relative error follows the legacy diagnostic, but it is not
146+
# a pixel-scale quantity. Below we check the same solution after aligning away
147+
# the nullspace component so the error is easier to interpret.
148+
V_null = Vt[rank:].T
149+
150+
# Add the nullspace component to the estimate before comparing directly
151+
# against the reference shifts.
152+
x_err = x_ref_internal - x_est
153+
x_est_aligned = x_est + V_null @ (V_null.T @ x_err)
154+
155+
# Convert back to ASPIRE shift convention and compute per-image Euclidean
156+
# shift error in pixels.
157+
est_shifts_aligned = -x_est_aligned.reshape(estimator.src.n, 2)[:, ::-1]
158+
per_img_err = np.linalg.norm(estimator.src.offsets - est_shifts_aligned, axis=1)
159+
mean_aligned_px = per_img_err.mean()
160+
161+
# Check that aligned estimate errors are within 0.25 pixels on average.
162+
np.testing.assert_array_less(mean_aligned_px, 0.25)

0 commit comments

Comments
 (0)