Skip to content

Commit 9bb93fa

Browse files
authored
average quat function (#23)
* average quat function * fixed pr feedback
1 parent 1120672 commit 9bb93fa

File tree

3 files changed

+150
-0
lines changed

3 files changed

+150
-0
lines changed

cpp_test/test_math.cpp

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,4 +338,97 @@ TEST(anti_windup, test_anti_windup) {
338338
EXPECT_TRUE(expected.isApprox(result, 1e-10));
339339
}
340340

341+
TEST(average_quaternions, empty_input_throws) {
342+
std::vector<Eigen::Quaterniond> quats;
343+
EXPECT_THROW(average_quaternions(quats), std::invalid_argument);
344+
}
345+
346+
TEST(average_quaternions, single_quaternion_returns_same) {
347+
Eigen::Quaterniond q =
348+
Eigen::Quaterniond(Eigen::AngleAxisd(0.7, Eigen::Vector3d::UnitX()));
349+
350+
std::vector<Eigen::Quaterniond> quats{q};
351+
352+
Eigen::Quaterniond avg = average_quaternions(quats);
353+
354+
EXPECT_NEAR(avg.angularDistance(q), 0.0, 1e-12);
355+
}
356+
357+
TEST(average_quaternions, identical_quaternions) {
358+
Eigen::Quaterniond q =
359+
Eigen::Quaterniond(Eigen::AngleAxisd(1.2, Eigen::Vector3d::UnitZ()));
360+
361+
std::vector<Eigen::Quaterniond> quats = {q, q, q};
362+
363+
Eigen::Quaterniond avg = average_quaternions(quats);
364+
365+
EXPECT_NEAR(avg.angularDistance(q), 0.0, 1e-12);
366+
}
367+
368+
TEST(average_quaternions, antipodal_quaternions) {
369+
Eigen::Quaterniond q(Eigen::AngleAxisd(0.8, Eigen::Vector3d::UnitY()));
370+
371+
Eigen::Quaterniond q_neg = q;
372+
q_neg.coeffs() *= -1.0;
373+
374+
std::vector<Eigen::Quaterniond> quats = {q, q_neg};
375+
376+
Eigen::Quaterniond avg = average_quaternions(quats);
377+
378+
EXPECT_NEAR(avg.angularDistance(q), 0.0, 1e-12);
379+
}
380+
381+
TEST(average_quaternions, average_of_small_rotations_near_identity) {
382+
std::vector<Eigen::Quaterniond> quats;
383+
384+
quats.emplace_back(Eigen::AngleAxisd(0.05, Eigen::Vector3d::UnitX()));
385+
quats.emplace_back(Eigen::AngleAxisd(-0.05, Eigen::Vector3d::UnitX()));
386+
quats.emplace_back(Eigen::AngleAxisd(0.02, Eigen::Vector3d::UnitX()));
387+
388+
Eigen::Quaterniond avg = average_quaternions(quats);
389+
390+
EXPECT_TRUE(avg.isApprox(Eigen::Quaterniond::Identity(), 1e-2));
391+
}
392+
393+
TEST(average_quaternions, noisy_measurements_about_known_rotation) {
394+
const Eigen::Quaterniond truth =
395+
Eigen::Quaterniond(Eigen::AngleAxisd(1.0, Eigen::Vector3d::UnitZ()));
396+
397+
std::vector<Eigen::Quaterniond> quats;
398+
quats.emplace_back(Eigen::AngleAxisd(1.02, Eigen::Vector3d::UnitZ()));
399+
quats.emplace_back(Eigen::AngleAxisd(0.98, Eigen::Vector3d::UnitZ()));
400+
quats.emplace_back(Eigen::AngleAxisd(1.01, Eigen::Vector3d::UnitZ()));
401+
quats.emplace_back(Eigen::AngleAxisd(0.99, Eigen::Vector3d::UnitZ()));
402+
403+
Eigen::Quaterniond avg = average_quaternions(quats);
404+
405+
EXPECT_NEAR(avg.angularDistance(truth), 0.0, 1e-2);
406+
}
407+
408+
TEST(average_quaternions, symmetric_opposing_axes) {
409+
Eigen::Quaterniond qx =
410+
Eigen::Quaterniond(Eigen::AngleAxisd(0.5, Eigen::Vector3d::UnitX()));
411+
Eigen::Quaterniond qy =
412+
Eigen::Quaterniond(Eigen::AngleAxisd(0.5, Eigen::Vector3d::UnitY()));
413+
414+
std::vector<Eigen::Quaterniond> quats{qx, qy};
415+
416+
Eigen::Quaterniond avg = average_quaternions(quats);
417+
418+
double dx = avg.angularDistance(qx);
419+
double dy = avg.angularDistance(qy);
420+
421+
EXPECT_NEAR(dx, dy, 1e-6);
422+
}
423+
424+
TEST(average_quaternions, test_degeneracy) {
425+
Eigen::Quaterniond q0 =
426+
Eigen::Quaterniond(Eigen::AngleAxisd(0.0, Eigen::Vector3d::UnitZ()));
427+
428+
Eigen::Quaterniond q180 =
429+
Eigen::Quaterniond(Eigen::AngleAxisd(M_PI, Eigen::Vector3d::UnitZ()));
430+
431+
EXPECT_THROW(average_quaternions({q0, q180}), std::runtime_error);
432+
}
433+
341434
} // namespace vortex::utils::math

include/vortex/utils/math.hpp

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#include <concepts>
66
#include <eigen3/Eigen/Dense>
77
#include <eigen3/Eigen/Geometry>
8+
#include <vector>
89

910
namespace vortex::utils::math {
1011

@@ -144,6 +145,19 @@ Eigen::VectorXd anti_windup(const double dt,
144145
const Eigen::VectorXd& integral,
145146
const double min_val,
146147
const double max_val);
148+
149+
/**
150+
* @brief Computes the average orientation from a set of quaternions.
151+
* @param quaternions A vector of Eigen::Quaterniond representing orientations.
152+
* @return Eigen::Quaterniond The normalized average quaternion with positive
153+
* scalar (w) part.
154+
* @warning The average orientation is degenerate when input rotations
155+
* are antipodally distributed (≈180° apart).
156+
* @throws std::invalid_argument if the input vector is empty.
157+
*/
158+
Eigen::Quaterniond average_quaternions(
159+
const std::vector<Eigen::Quaterniond>& quaternions);
160+
147161
} // namespace vortex::utils::math
148162

149163
#endif // VORTEX_UTILS_MATH_HPP

src/math.cpp

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,4 +131,47 @@ Eigen::VectorXd anti_windup(const double dt,
131131
return integral_anti_windup;
132132
}
133133

134+
Eigen::Quaterniond average_quaternions(
135+
const std::vector<Eigen::Quaterniond>& quaternions) {
136+
if (quaternions.empty()) {
137+
throw std::invalid_argument(
138+
"average_quaternions: input vector must not be empty");
139+
}
140+
141+
Eigen::Matrix4d scatter_matrix = Eigen::Matrix4d::Zero();
142+
std::ranges::for_each(quaternions, [&](const auto& q) {
143+
scatter_matrix +=
144+
q.normalized().coeffs() * q.normalized().coeffs().transpose();
145+
});
146+
147+
Eigen::SelfAdjointEigenSolver<Eigen::Matrix4d> eigensolver(scatter_matrix);
148+
149+
if (eigensolver.info() != Eigen::Success) {
150+
throw std::runtime_error(
151+
"average_quaternions: eigen decomposition failed after bias");
152+
}
153+
154+
const auto& eigenvalues = eigensolver.eigenvalues();
155+
constexpr double eps = 1e-12;
156+
157+
if (std::abs(eigenvalues(3) - eigenvalues(2)) < eps) {
158+
throw std::runtime_error(
159+
"average_quaternions_weighted: average orientation is not unique");
160+
}
161+
162+
const Eigen::Vector4d eigenvector = eigensolver.eigenvectors().col(3);
163+
164+
Eigen::Quaterniond avg_q;
165+
avg_q.x() = eigenvector(0);
166+
avg_q.y() = eigenvector(1);
167+
avg_q.z() = eigenvector(2);
168+
avg_q.w() = eigenvector(3);
169+
170+
if (avg_q.w() < 0.0) {
171+
avg_q.coeffs() *= -1.0;
172+
}
173+
174+
return avg_q.normalized();
175+
}
176+
134177
} // namespace vortex::utils::math

0 commit comments

Comments
 (0)