Skip to content

Commit b3c3472

Browse files
Merge pull request #1182 from effigies/enh/qform_calculation
ENH: Set symmetric threshold for identifying unit quaternions in qform calculation
2 parents 43885d7 + aa4b017 commit b3c3472

File tree

4 files changed

+88
-38
lines changed

4 files changed

+88
-38
lines changed

doc/source/nifti_images.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,8 +273,8 @@ You can get and set the qform affine using the equivalent methods to those for
273273
the sform: ``get_qform()``, ``set_qform()``.
274274

275275
>>> n1_header.get_qform(coded=True)
276-
(array([[ -2. , 0. , 0. , 117.86],
277-
[ -0. , 1.97, -0.36, -35.72],
276+
(array([[ -2. , 0. , -0. , 117.86],
277+
[ 0. , 1.97, -0.36, -35.72],
278278
[ 0. , 0.32, 2.17, -7.25],
279279
[ 0. , 0. , 0. , 1. ]]), 1)
280280

nibabel/nifti1.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -688,7 +688,7 @@ class Nifti1Header(SpmAnalyzeHeader):
688688
single_magic = b'n+1'
689689

690690
# Quaternion threshold near 0, based on float32 precision
691-
quaternion_threshold = -np.finfo(np.float32).eps * 3
691+
quaternion_threshold = np.finfo(np.float32).eps * 3
692692

693693
def __init__(self, binaryblock=None, endianness=None, check=True, extensions=()):
694694
"""Initialize header from binary data block and extensions"""

nibabel/quaternions.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ def fillpositive(xyz, w2_thresh=None):
4242
xyz : iterable
4343
iterable containing 3 values, corresponding to quaternion x, y, z
4444
w2_thresh : None or float, optional
45-
threshold to determine if w squared is really negative.
45+
threshold to determine if w squared is non-zero.
4646
If None (default) then w2_thresh set equal to
47-
``-np.finfo(xyz.dtype).eps``, if possible, otherwise
48-
``-np.finfo(np.float64).eps``
47+
3 * ``np.finfo(xyz.dtype).eps``, if possible, otherwise
48+
3 * ``np.finfo(np.float64).eps``
4949
5050
Returns
5151
-------
@@ -89,17 +89,17 @@ def fillpositive(xyz, w2_thresh=None):
8989
# If necessary, guess precision of input
9090
if w2_thresh is None:
9191
try: # trap errors for non-array, integer array
92-
w2_thresh = -np.finfo(xyz.dtype).eps * 3
92+
w2_thresh = np.finfo(xyz.dtype).eps * 3
9393
except (AttributeError, ValueError):
94-
w2_thresh = -FLOAT_EPS * 3
94+
w2_thresh = FLOAT_EPS * 3
9595
# Use maximum precision
9696
xyz = np.asarray(xyz, dtype=MAX_FLOAT)
9797
# Calculate w
98-
w2 = 1.0 - np.dot(xyz, xyz)
99-
if w2 < 0:
100-
if w2 < w2_thresh:
101-
raise ValueError(f'w2 should be positive, but is {w2:e}')
98+
w2 = 1.0 - xyz @ xyz
99+
if np.abs(w2) < np.abs(w2_thresh):
102100
w = 0
101+
elif w2 < 0:
102+
raise ValueError(f'w2 should be positive, but is {w2:e}')
103103
else:
104104
w = np.sqrt(w2)
105105
return np.r_[w, xyz]

nibabel/tests/test_quaternions.py

Lines changed: 76 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,35 +16,40 @@
1616
from .. import eulerangles as nea
1717
from .. import quaternions as nq
1818

19+
20+
def norm(vec):
21+
# Return unit vector with same orientation as input vector
22+
return vec / np.sqrt(vec @ vec)
23+
24+
25+
def gen_vec(dtype):
26+
# Generate random 3-vector in [-1, 1]^3
27+
rand = np.random.default_rng()
28+
return rand.uniform(low=-1.0, high=1.0, size=(3,)).astype(dtype)
29+
30+
1931
# Example rotations
20-
eg_rots = []
21-
params = (-pi, pi, pi / 2)
22-
zs = np.arange(*params)
23-
ys = np.arange(*params)
24-
xs = np.arange(*params)
25-
for z in zs:
26-
for y in ys:
27-
for x in xs:
28-
eg_rots.append(nea.euler2mat(z, y, x))
32+
eg_rots = [
33+
nea.euler2mat(z, y, x)
34+
for z in np.arange(-pi, pi, pi / 2)
35+
for y in np.arange(-pi, pi, pi / 2)
36+
for x in np.arange(-pi, pi, pi / 2)
37+
]
38+
2939
# Example quaternions (from rotations)
30-
eg_quats = []
31-
for M in eg_rots:
32-
eg_quats.append(nq.mat2quat(M))
40+
eg_quats = [nq.mat2quat(M) for M in eg_rots]
3341
# M, quaternion pairs
3442
eg_pairs = list(zip(eg_rots, eg_quats))
3543

3644
# Set of arbitrary unit quaternions
37-
unit_quats = set()
38-
params = range(-2, 3)
39-
for w in params:
40-
for x in params:
41-
for y in params:
42-
for z in params:
43-
q = (w, x, y, z)
44-
Nq = np.sqrt(np.dot(q, q))
45-
if not Nq == 0:
46-
q = tuple([e / Nq for e in q])
47-
unit_quats.add(q)
45+
unit_quats = set(
46+
tuple(norm(np.r_[w, x, y, z]))
47+
for w in range(-2, 3)
48+
for x in range(-2, 3)
49+
for y in range(-2, 3)
50+
for z in range(-2, 3)
51+
if (w, x, y, z) != (0, 0, 0, 0)
52+
)
4853

4954

5055
def test_fillpos():
@@ -69,6 +74,51 @@ def test_fillpos():
6974
assert wxyz[0] == 0.0
7075

7176

77+
@pytest.mark.parametrize('dtype', ('f4', 'f8'))
78+
def test_fillpositive_plus_minus_epsilon(dtype):
79+
# Deterministic test for fillpositive threshold
80+
# We are trying to fill (x, y, z) with a w such that |(w, x, y, z)| == 1
81+
# If |(x, y, z)| is slightly off one, w should still be 0
82+
nptype = np.dtype(dtype).type
83+
84+
# Obviously, |(x, y, z)| == 1
85+
baseline = np.array([0, 0, 1], dtype=dtype)
86+
87+
# Obviously, |(x, y, z)| ~ 1
88+
plus = baseline * nptype(1 + np.finfo(dtype).eps)
89+
minus = baseline * nptype(1 - np.finfo(dtype).eps)
90+
91+
assert nq.fillpositive(plus)[0] == 0.0
92+
assert nq.fillpositive(minus)[0] == 0.0
93+
94+
# |(x, y, z)| > 1, no real solutions
95+
plus = baseline * nptype(1 + 2 * np.finfo(dtype).eps)
96+
with pytest.raises(ValueError):
97+
nq.fillpositive(plus)
98+
99+
# |(x, y, z)| < 1, two real solutions, we choose positive
100+
minus = baseline * nptype(1 - 2 * np.finfo(dtype).eps)
101+
assert nq.fillpositive(minus)[0] > 0.0
102+
103+
104+
@pytest.mark.parametrize('dtype', ('f4', 'f8'))
105+
def test_fillpositive_simulated_error(dtype):
106+
# Nondeterministic test for fillpositive threshold
107+
# Create random vectors, normalize to unit length, and count on floating point
108+
# error to result in magnitudes larger/smaller than one
109+
# This is to simulate cases where a unit quaternion with w == 0 would be encoded
110+
# as xyz with small error, and we want to recover the w of 0
111+
112+
# Permit 1 epsilon per value (default, but make explicit here)
113+
w2_thresh = 3 * np.finfo(dtype).eps
114+
115+
pos_error = neg_error = False
116+
for _ in range(50):
117+
xyz = norm(gen_vec(dtype))
118+
119+
assert nq.fillpositive(xyz, w2_thresh)[0] == 0.0
120+
121+
72122
def test_conjugate():
73123
# Takes sequence
74124
cq = nq.conjugate((1, 0, 0, 0))
@@ -125,7 +175,7 @@ def test_norm():
125175
def test_mult(M1, q1, M2, q2):
126176
# Test that quaternion * same as matrix *
127177
q21 = nq.mult(q2, q1)
128-
assert_array_almost_equal, np.dot(M2, M1), nq.quat2mat(q21)
178+
assert_array_almost_equal, M2 @ M1, nq.quat2mat(q21)
129179

130180

131181
@pytest.mark.parametrize('M, q', eg_pairs)
@@ -146,7 +196,7 @@ def test_eye():
146196
@pytest.mark.parametrize('M, q', eg_pairs)
147197
def test_qrotate(vec, M, q):
148198
vdash = nq.rotate_vector(vec, q)
149-
vM = np.dot(M, vec)
199+
vM = M @ vec
150200
assert_array_almost_equal(vdash, vM)
151201

152202

@@ -179,6 +229,6 @@ def test_angle_axis():
179229
nq.nearly_equivalent(q, q2)
180230
aa_mat = nq.angle_axis2mat(theta, vec)
181231
assert_array_almost_equal(aa_mat, M)
182-
unit_vec = vec / np.sqrt(vec.dot(vec))
232+
unit_vec = norm(vec)
183233
aa_mat2 = nq.angle_axis2mat(theta, unit_vec, is_normalized=True)
184234
assert_array_almost_equal(aa_mat2, M)

0 commit comments

Comments
 (0)