Skip to content

Commit c208506

Browse files
authored
Add align functions (#32)
* Add `align` functions * Bump #minor * Upload coverage in github actions
1 parent 6b90609 commit c208506

File tree

8 files changed

+273
-11
lines changed

8 files changed

+273
-11
lines changed

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,6 @@ jobs:
3232
- uses: julia-actions/julia-runtest@latest
3333
- uses: julia-actions/julia-processcoverage@latest
3434
- uses: codecov/codecov-action@v1
35-
if: "matrix.version == 'nightly' && matrix.os == 'ubuntu-latest'"
35+
if: "matrix.version == '1' && matrix.os == 'ubuntu-latest'"
3636
with:
3737
file: lcov.info

CITATION.cff

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ authors:
66
orcid: https://orcid.org/0000-0002-5075-5116
77
title: "Quaternionic.jl"
88
license: MIT
9-
version: v0.2.1
9+
version: v0.3.0
1010
doi: 10.5281/zenodo.5514302
1111
date-released: 2021-09-17

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name = "Quaternionic"
22
uuid = "0756cd96-85bf-4b6f-a009-b5012ea7a443"
33
authors = ["Michael Boyle <michael.oliver.boyle@gmail.com>"]
4-
version = "0.2.3"
4+
version = "0.3.0"
55

66
[deps]
77
LaTeXStrings = "b964fa9f-0449-5b57-a5c2-d3ea65f4040f"

docs/src/manual.md

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,8 @@ satisfy. For any quaternions ``q_1`` and ``q_2`` and any *unit* quaternion
130130
* identity: ``d(q_1, q_1) = 0``
131131
* positive-definiteness: ``d(q_1, q_2) > 0`` whenever ``q_1 ≠ q_2``
132132

133-
(Of course, it should be noted that these criteria all hold in the *exact*
134-
case; when using floating-point numbers, will likely be violated near edge
133+
(Of course, it should be noted that these criteria all hold in the *exact* case;
134+
when using floating-point numbers, they will likely be violated near edge
135135
cases.)
136136

137137
It is not hard to see that these criteria can be satisfied by any of
@@ -145,15 +145,33 @@ If ``q_1`` and ``q_2`` are interpreted as rotations, we frequently don't care
145145
about their signs, and just want the *smallest* distance between them, for any
146146
choice of sign. Furthermore, in the multiplicative case, the `log` functions
147147
will involve calculation of the `log` of the magnitudes of the quaternions,
148-
which should be 1. In this case, we relax the "positive-definiteness"
149-
criterion to allow ``d(q_1, q_2)`` to equal zero when ``q_1`` and ``q_2`` are
150-
related by a nonzero scalar multiple.
148+
which should be 1. In this case, we relax the "positive-definiteness" criterion
149+
to allow ``d(q_1, q_2)`` to equal zero when ``q_1`` and ``q_2`` are related by a
150+
nonzero scalar multiple.
151151

152-
For `Rotor` types, the latter two multiplicative options are most relevant, while for
153-
other types the additive options are more relevant. These are the default
154-
behaviors of the `distance` and `distance2` functions.
152+
For `Rotor` types, the latter two multiplicative options are most relevant,
153+
while for other types the additive options are more relevant. These are the
154+
default behaviors of the `distance` and `distance2` functions.
155155

156156
```@autodocs
157157
Modules = [Quaternionic]
158158
Pages = ["distance.jl"]
159159
```
160+
161+
162+
## Alignment
163+
164+
There are many ways to optimize alignment with rotations. In particular, we can
165+
seek the optimal rotation that takes one set of points onto a corresponding set
166+
of points, or the optimal quaternion that takes one set of quaternions onto a
167+
corresponding set of quaternions. In both cases, the "optimal" value depends on
168+
the metric being used. The simplest and most robust results are obtained when
169+
the metric is the standard Euclidean metric (in the case of points), or the
170+
magnitude of the difference (in the case of quaternions). Here, we assume that
171+
`QuatVec`s represent points, and any other type of quaternion should be treated
172+
as rotors.
173+
174+
```@autodocs
175+
Modules = [Quaternionic]
176+
Pages = ["alignment.jl"]
177+
```

src/Quaternionic.jl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export from_float_array, to_float_array,
1616
from_spherical_coordinates, to_spherical_coordinates,
1717
from_rotation_matrix, to_rotation_matrix
1818
export distance, distance2
19+
export align
1920
export unflip, unflip!, slerp, squad
2021
export ∂log, log∂log, ∂exp, exp∂exp, slerp∂slerp, slerp∂slerp∂τ, squad∂squad∂t
2122
export precessing_nutating_example
@@ -30,6 +31,7 @@ include("math.jl")
3031
include("random.jl")
3132
include("conversion.jl")
3233
include("distance.jl")
34+
include("alignment.jl")
3335
include("interpolation.jl")
3436
include("gradients.jl")
3537
include("examples.jl")

src/alignment.jl

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
@doc raw"""
2+
align(a⃗, b⃗, [w])
3+
4+
Solve [Wahba's problem](https://en.wikipedia.org/wiki/Wahba%27s_problem),
5+
finding a rotation that aligns the set of points `a⃗` to a corresponding set of
6+
points `b⃗` by minimizing the distance between the first set and the rotated
7+
second set.
8+
9+
Here, `a⃗` and `b⃗` must be equally sized arrays of `QuatVec`s. If present, `w`
10+
must be an equally sized array of real numbers; if not, it is taken to be 1.
11+
We define the loss function
12+
```math
13+
L(ℛ) ≔ Σᵢ wᵢ ‖a⃗ᵢ - ℛ b⃗ᵢ‖²
14+
```
15+
where ``ℛ`` is a rotation operator, and return the quaternion corresponding to
16+
the optimal ``ℛ`` that minimizes this function.
17+
18+
Note that it is possible that the points do not uniquely determine a rotation —
19+
as when one or both sets of points is rotationally symmetric. In that case, the
20+
loss function ``L(ℛ)`` will still be minimized and the points will still be
21+
optimally aligned by the output quaternion, but that quaternion will not be
22+
unique.
23+
24+
25+
# Notes
26+
27+
In their book [_Fundamentals of Spacecraft Attitude Determination and Control_
28+
(2014)](https://doi.org/10.1007/978-1-4939-0802-8), Markley and Crassidis say
29+
that "Davenport’s method remains the best method for solving Wahba’s problem".
30+
This method provides the optimal quaternion as the dominant eigenvector (the one
31+
with the largest eigenvalue) of a certain matrix. We start by defining the
32+
supplementary matrix
33+
```math
34+
S ≔ Σᵢ wᵢ a⃗ᵢ b⃗ᵢᵀ
35+
```
36+
and vector
37+
```math
38+
s⃗ ≔ \begin{bmatrix}
39+
S₂₃-S₃₂ \\
40+
S₃₁-S₁₃ \\
41+
S₁₂-S₂₁
42+
\end{bmatrix}.
43+
```
44+
Then the key matrix is
45+
```math
46+
M ≔ \begin{bmatrix}
47+
S + Sᵀ - (\mathrm{tr}S)\, I₃ & s⃗ \\
48+
s⃗ᵀ & \mathrm{tr}S
49+
\end{bmatrix}
50+
```
51+
It is possible for this matrix to have degenerate eigenvalues, corresponding to
52+
cases where the points do not uniquely determine the rotation, as described
53+
above.
54+
55+
"""
56+
function align(a⃗::AbstractArray{<:QuatVec}, b⃗::AbstractArray{<:QuatVec}, w::AbstractArray{<:Real})
57+
# This is Eq. (5.11) from Markley and Crassidis
58+
S = sum(w[i] * a⃗[i].vec * b⃗[i].vec' for i in eachindex(a⃗, b⃗, w))
59+
return _align_Wahba(S)
60+
end
61+
62+
function align(a⃗::AbstractArray{<:QuatVec}, b⃗::AbstractArray{<:QuatVec})
63+
# This is Eq. (5.11) from Markley and Crassidis
64+
S = sum(a⃗[i].vec * b⃗[i].vec' for i in eachindex(a⃗, b⃗))
65+
return _align_Wahba(S)
66+
end
67+
68+
function _align_Wahba(S)
69+
# This is Eq. (5.17) from Markley and Crassidis, modified to suit our
70+
# conventions by flipping the sign of ``z``, and moving the final dimension
71+
# to the first dimension.
72+
M = Symmetric([
73+
S[1,1]+S[2,2]+S[3,3] S[3,2]-S[2,3] S[1,3]-S[3,1] S[2,1]-S[1,2]
74+
S[3,2]-S[2,3] S[1,1]-S[2,2]-S[3,3] S[1,2]+S[2,1] S[1,3]+S[3,1]
75+
S[1,3]-S[3,1] S[1,2]+S[2,1] -S[1,1]+S[2,2]-S[3,3] S[2,3]+S[3,2]
76+
S[2,1]-S[1,2] S[1,3]+S[3,1] S[2,3]+S[3,2] -S[1,1]-S[2,2]+S[3,3]
77+
])
78+
# This extracts the dominant eigenvector, and interprets it as a Rotor. In
79+
# particular, note that the _last_ eigenvector output by `eigen` (the 4th)
80+
# has the largest eigenvalue.
81+
return Rotor(eigen(M, 4:4).vectors[:, 1]...)
82+
end
83+
84+
85+
@doc raw"""
86+
align(A, B, [w])
87+
88+
Find a `Rotor` that aligns the set of rotors `A` to a corresponding set `B` by
89+
minimizing the distance between the first set and the rotated second set.
90+
91+
Here, `A` and `B` must be equally sized arrays of `AbstractQuaternion`s. If
92+
present, `w` must be an equally sized array of real numbers; if not, it is
93+
taken to be 1. We define the loss function
94+
```math
95+
L(R) ≔ Σᵢ wᵢ |Aᵢ - R Bᵢ|²
96+
```
97+
where ``R`` is a `Rotor`, and return the quaternion corresponding to the optimal
98+
``R`` that minimizes this function.
99+
100+
Note that it is possible that the input data do not uniquely determine a rotor,
101+
which will happen when sum below is zero. When this happens, the result will
102+
contain `NaN`s, but no error will be raised. When the sum is very close to —
103+
but not exactly — zero, the accuracy of the result will be limited. However,
104+
the loss function will not depend strongly on the result in that case.
105+
106+
Be aware that this function _is_ sensitive to the signs of the input
107+
quaternions. See the [`unflip`](@ref) function for one way to avoid problems
108+
related to signs.
109+
110+
111+
## Notes
112+
113+
We can ensure that the loss function is minimized by multiplying ``R`` by an
114+
exponential, differentiating with respect to the argument of the exponential,
115+
and setting that argument to 0. This derivative should be 0 at the minimum. We
116+
have
117+
```math
118+
∂ⱼ Σᵢ wᵢ |Aᵢ - \exp[vⱼ] R Bᵢ|² → 2 ⟨ eⱼ R Σᵢ wᵢ Bᵢ Āᵢ ⟩₀
119+
```
120+
where → denotes taking ``vⱼ→0``, the symbol ``⟨⟩₀`` denotes taking the scalar
121+
part, and ``eⱼ`` is the unit quaternionic vector in the ``j`` direction. The
122+
only way for this quantity to be zero for each choice of ``j`` is if
123+
```math
124+
R Σᵢ wᵢ Bᵢ Āᵢ
125+
```
126+
is itself a pure scalar. This, in turn, can only happen if either (1) the sum
127+
is 0 or (2) if ``R`` is proportional to the _conjugate_ of the sum:
128+
```math
129+
R ∝ Σᵢ wᵢ Aᵢ B̄ᵢ
130+
```
131+
Now, since we want ``R`` to be a rotor, we simply define it to be the normalized
132+
sum.
133+
134+
"""
135+
function align(A, B, w)
136+
Rotor(sum(w[i] * A[i] * conj(B[i]) for i in eachindex(A, B, w)))
137+
end
138+
139+
function align(A, B)
140+
Rotor(sum(A[i] * conj(B[i]) for i in eachindex(A, B)))
141+
end

test/alignment.jl

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
@testset verbose=true "Alignment" begin
2+
Random.seed!(1234)
3+
@testset verbose=true "Align QuatVec{$T}" for T in [Float16, Float32, Float64]
4+
for N in [1, 2, 3, 4, 5, 10, 20]
5+
a⃗ = randn(QuatVec{T}, N)
6+
R = randn(Rotor{T})
7+
8+
# Test the exact result
9+
b⃗ = R .* a⃗ .* conj(R)
10+
R′ = align(a⃗, b⃗)
11+
if N > 1
12+
@test distance(R, conj(R′)) < 25eps(T)
13+
end
14+
@test maximum(abs, a⃗ - R′ .* b⃗ .* conj(R′)) < 40eps(T)
15+
@test_throws DimensionMismatch align(a⃗, b⃗[2:end])
16+
17+
# Uniform weights
18+
w = 17ones(T, size(a⃗)) / T(3)
19+
R′′ = align(a⃗, b⃗, w)
20+
if N > 1
21+
@test distance(R, conj(R′′)) < 25eps(T)
22+
end
23+
@test maximum(abs, a⃗ - R′′ .* b⃗ .* conj(R′′)) < 40eps(T)
24+
@test_throws DimensionMismatch align(a⃗, b⃗, w[2:end])
25+
26+
# Perturb b⃗ slightly
27+
δ = eps(T)
28+
b⃗′′′ = [b⃗i + QuatVec((2*(rand(T, 3) .- 1/T(2)) * δ/√T(3))...) for b⃗i in b⃗]
29+
R′′′ = align(a⃗, b⃗′′′)
30+
if N > 1
31+
@test distance(R, conj(R′′′)) < 25δ
32+
end
33+
@test maximum(abs, a⃗ - R′′′ .* b⃗′′′ .* conj(R′′′)) < 40δ
34+
35+
# Change first third, but use weights to ignore
36+
if N > 3
37+
N′ = N ÷ 3
38+
b⃗′ = copy(b⃗)
39+
w′ = copy(w)
40+
b⃗′[1:N′] = randn(QuatVec{T}, N′)
41+
w′[1:N′] .= 0
42+
R1 = align(a⃗, b⃗, w′)
43+
R2 = align(a⃗, b⃗′, w′)
44+
@test distance(R, conj(R1)) < 25eps(T)
45+
@test distance(R, conj(R2)) < 25eps(T)
46+
@test maximum(abs, (a⃗ - R1 .* b⃗ .* conj(R1))[N′+1:end]) < 40eps(T)
47+
@test maximum(abs, (a⃗ - R2 .* b⃗ .* conj(R2))[N′+1:end]) < 40eps(T)
48+
end
49+
end
50+
end
51+
52+
@testset verbose=true "Align Rotor{$T}" for T in [Float16, Float32, Float64]
53+
for N in [1, 2, 3, 4, 5, 10, 20]
54+
A = randn(Rotor{T}, N)
55+
R = randn(Rotor{T})
56+
57+
# Test the exact result
58+
B = R .* A
59+
R′ = align(A, B)
60+
if N > 1
61+
@test distance(R, conj(R′)) < 25eps(T)
62+
end
63+
@test maximum(abs, A - R′ .* B) < 40eps(T)
64+
@test_throws DimensionMismatch align(A, B[2:end])
65+
66+
# Uniform weights
67+
w = 17ones(T, size(A)) / T(3)
68+
R′′ = align(A, B, w)
69+
if N > 1
70+
@test distance(R, conj(R′′)) < 25eps(T)
71+
end
72+
@test maximum(abs, A - R′′ .* B) < 40eps(T)
73+
@test_throws DimensionMismatch align(A, B, w[2:end])
74+
75+
# Perturb B slightly
76+
δ = eps(T)
77+
B′′′ = [Rotor(Bi + Quaternion((2*(rand(T, 4) .- 1/T(2)) * δ/√T(3))...)) for Bi in B]
78+
R′′′ = align(A, B′′′)
79+
if N > 1
80+
@test distance(R, conj(R′′′)) < 25δ
81+
end
82+
@test maximum(abs, A - R′′′ .* B′′′) < 40δ
83+
84+
# Change first third, but use weights to ignore
85+
if N > 3
86+
N′ = N ÷ 3
87+
B′ = copy(B)
88+
w′ = copy(w)
89+
B′[1:N′] = randn(Rotor{T}, N′)
90+
w′[1:N′] .= 0
91+
R1 = align(A, B, w′)
92+
R2 = align(A, B′, w′)
93+
@test distance(R, conj(R1)) < 25eps(T)
94+
@test distance(R, conj(R2)) < 25eps(T)
95+
@test maximum(abs, (A - R1 .* B)[N′+1:end]) < 40eps(T)
96+
@test maximum(abs, (A - R2 .* B)[N′+1:end]) < 40eps(T)
97+
end
98+
end
99+
end
100+
end

test/runtests.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ end
6767
addtests("random.jl")
6868
addtests("conversion.jl")
6969
addtests("distance.jl")
70+
addtests("alignment.jl")
7071
addtests("interpolation.jl")
7172
addtests("gradients.jl")
7273
addtests("doctests.jl")

0 commit comments

Comments
 (0)