Sophus库:C++中李群李代数的工程实践与SLAM应用
1. 项目概述为什么我们需要Sophus在机器人、自动驾驶、增强现实这些领域我们经常需要处理三维空间中的旋转和平移。比如一个机械臂的关节如何转动一个相机在空间中如何移动一个虚拟物体如何叠加到现实世界。这些变换在数学上有一个非常优雅且强大的工具来描述——李群Lie Group和李代数Lie Algebra。你可能已经用过或听说过Eigen库它是C里处理矩阵和向量的“瑞士军刀”线性代数运算又快又好。但当你需要频繁地在旋转矩阵、四元数、旋转向量之间转换或者需要计算这些变换的微小扰动导数时光有Eigen就显得有些力不从心了。你需要不断地写一堆模板化的、容易出错的代码来处理这些特殊的数学对象。这就是Sophus库登场的时候。Sophus是一个基于Eigen构建的、专门用于处理李群和李代数的C库。它不是一个独立的数学库而是Eigen的一个“超级外挂”专注于SO(3)、SE(3)、SO(2)、SE(2)等这些在SLAM、机器人学中至关重要的群。简单来说它把那些关于旋转和平移的、又抽象又容易写错的数学操作封装成了清晰、高效且类型安全的C类。如果你正在用C写SLAM后端、机器人运动学、或者任何涉及三维几何变换的算法Sophus几乎是一个绕不开的工具。它能让你从繁琐的数学公式实现中解放出来更专注于算法逻辑本身。2. 核心概念扫盲李群与李代数到底是什么在深入使用Sophus之前我们有必要花点时间用最直白的方式理解一下它背后的数学概念。别怕我们不推导公式只讲直觉。2.1 李群描述“光滑”的变换集合想象一下所有可能的、光滑的、连续的三维旋转。这些旋转构成一个集合这个集合就是一个“群”Group因为它满足封闭性、结合律、有单位元不旋转、有逆元反方向转回来。又因为描述旋转的参数比如欧拉角、轴角是连续的所以它是一个“李群”Lie Group。SO(3)就是所有三维旋转矩阵构成的李群。同理SE(3)是“特殊欧几里得群”它描述的是三维空间中的刚体运动即旋转加平移。一个SE(3)变换可以用一个4x4的矩阵来表示左上角3x3是旋转部分右上角3x1是平移向量。为什么用群因为刚体变换可以连续地复合先旋转再平移或者先A变换再B变换这正好对应了群的“乘法”操作。使用群的概念能让我们的数学表达和代码实现非常统一和简洁。2.2 李代数描述“瞬时”的变换速度与扰动李群是描述“状态”的比如相机当前的位姿。但当我们想讨论“变化”时比如相机的运动速度、或者给当前位姿加一个微小的扰动直接在李群上操作会很麻烦因为李群空间对于加法不封闭。这时就需要李代数。你可以把李代数想象成李群在单位元处的“切空间”。一个李代数元素比如 so(3)是一个三维向量它的方向代表旋转轴模长代表旋转角度。它描述的是一个“瞬时”的旋转速度或一个无穷小的旋转。李群与李代数的核心关系指数映射和对数映射。指数映射 (Exp) 把一个李代数向量so(3)“积分”成对应的李群元素SO(3)。物理意义是给定一个旋转轴和角速度积分一段时间后得到最终的旋转。对数映射 (Log) 是Exp的逆运算。把一个李群元素SO(3)“求导”得到对应的李代数向量so(3)。物理意义是给定一个旋转求其对应的旋转轴和角度。这个关系是Sophus库的基石也是优化算法如Bundle Adjustment中计算雅可比矩阵的关键。注意 对于初学者一个常见的误解是把李代数 so(3) 直接当作旋转向量。虽然它们都是三维向量且通过指数映射对应但在一些细微的归一化处理上特别是当旋转角度接近π时直接等同可能会出问题。Sophus帮我们妥善处理了这些边界情况。3. Sophus库的整体设计与模块解析Sophus的设计哲学非常清晰为每个李群SO2, SO3, SE2, SE3和李代数so2, so3, se2, se3提供对应的C类并实现它们之间以及它们与Eigen、STL容器之间的无缝交互。3.1 核心类层次结构Sophus库主要包含以下核心类它们都定义在Sophus命名空间下SO3d / SO3f: 表示三维旋转群模板参数表示标量类型double/float。内部通常用一个单位四元数Eigen::Quaternion来存储因为它在数值上更稳定。SE3d / SE3f: 表示三维刚体变换群。内部包含一个SO3d对象表示旋转和一个Eigen::Vector3d对象表示平移。SO2d / SO2f, SE2d / SE2f: 对应的二维版本在平面机器人或某些简化模型中常用。对应的李代数类:Sophus::Vector3d(作为 so3),Sophus::Vector6d(作为 se3) 等。注意这些李代数向量就是普通的Eigen向量Sophus通过函数来操作它们。3.2 关键特性与设计考量基于Eigen类型安全所有类都重度使用Eigen的类型作为成员或基类。这意味着你可以像使用Eigen向量一样使用Sophus对象进行加减乘除当然是在数学意义允许的范围内。类型系统能帮助你在编译期捕获很多错误比如误将SO3对象与向量点乘。模板化标量类型提供Sophus::SO3和Sophus::SO3的模板特化方便你在精度和速度之间权衡。大多数SLAM系统使用double以保证数值稳定性。提供多种构造函数可以从旋转矩阵、四元数、轴角、欧拉角等多种形式构造SO3。SE3可以从旋转和平移向量或者直接从一个4x4变换矩阵构造。这大大提升了易用性。// 多种方式构造SO3 Eigen::Matrix3d R Eigen::AngleAxisd(M_PI/2, Eigen::Vector3d::UnitZ()).toRotationMatrix(); Sophus::SO3d SO3_R(R); // 从旋转矩阵构造 Eigen::Quaterniond q(R); Sophus::SO3d SO3_q(q); // 从四元数构造 // 从旋转向量轴角构造注意这里是李代数 so3 Eigen::Vector3d so3(0, 0, M_PI/2); // 绕Z轴旋转90度 Sophus::SO3d SO3_so3 Sophus::SO3d::exp(so3); // 指数映射重载常用运算符*运算符被重载为群的乘法变换的复合。对于SO3d*可以作用于另一个SO3d旋转复合或一个Vector3d旋转一个点。对于SE3d*可以作用于另一个SE3d或一个Vector3d用变换作用到一个三维点上。Sophus::SO3d R1, R2; Eigen::Vector3d p(1, 0, 0); Sophus::SO3d R_composite R1 * R2; // 旋转的复合 Eigen::Vector3d p_rotated R1 * p; // 旋转点p Sophus::SE3d T1, T2; Sophus::SE3d T_composite T1 * T2; // 变换的复合 Eigen::Vector3d p_transformed T1 * p; // 变换点p (旋转平移)提供指数/对数映射Sophus::SO3d::exp()和Sophus::SO3d::log()是核心函数。exp()将李代数向量转为李群log()反之。SE3同理。扰动模型这是Sophus在SLAM中最重要的功能之一。它提供了计算李群关于李代数的导数雅可比矩阵的工具。例如SO3::JacobianR()可以计算当用右乘扰动模型时旋转矩阵关于扰动小量的雅可比。这在图优化中计算残差关于位姿节点的导数时至关重要。4. 从安装到“Hello World”环境配置与基础使用4.1 安装SophusSophus是一个只有头文件的库Header-only这使它的安装变得极其简单。但为了使用方便通常我们还是推荐用CMake来管理。方法一直接拷贝头文件最快从Sophus的GitHub仓库strasdat/Sophus下载源代码。将其中的sophus目录包含所有.hpp头文件拷贝到你的项目目录下或者系统的头文件路径中如/usr/local/include。在你的CMakeLists.txt中确保包含了Eigen库并将Sophus的头文件路径加入包含目录。find_package(Eigen3 REQUIRED) include_directories(${EIGEN3_INCLUDE_DIR}) include_directories(“path/to/your/sophus/directory”)方法二使用CMake FetchContent推荐便于版本管理cmake_minimum_required(VERSION 3.14) project(MySophusProject) set(CMAKE_CXX_STANDARD 14) # 使用FetchContent下载并编译Sophus include(FetchContent) FetchContent_Declare( sophus GIT_REPOSITORY https://github.com/strasdat/Sophus.git GIT_TAG v1.22.10 # 指定一个稳定版本 ) FetchContent_MakeAvailable(sophus) # 查找EigenSophus会依赖它 find_package(Eigen3 REQUIRED) add_executable(test_sophus test_sophus.cpp) target_link_libraries(test_sophus Eigen3::Eigen Sophus::Sophus)这种方式能自动处理依赖并确保你使用的是特定版本的Sophus。实操心得 我强烈推荐使用FetchContent方式。它不仅干净避免了全局安装可能带来的版本冲突而且在CI/CD持续集成环境中也能完美工作。直接拷贝头文件虽然快但在团队协作或复杂项目中管理头文件路径反而会更麻烦。4.2 第一个Sophus程序旋转一个点让我们写一个简单的程序感受一下Sophus的便利。#include iostream #include Eigen/Core #include Eigen/Geometry #include sophus/so3.hpp #include sophus/se3.hpp int main() { // 1. 创建一个绕Z轴旋转90度的旋转 Eigen::Matrix3d R Eigen::AngleAxisd(M_PI / 2, Eigen::Vector3d::UnitZ()).toRotationMatrix(); Sophus::SO3d SO3_R(R); // 从旋转矩阵构造SO3 std::cout “Rotation matrix R:\n” R std::endl; std::cout “SO3 from matrix:\n” SO3_R.matrix() std::endl; // 验证 // 2. 使用四元数构造同样的旋转 Eigen::Quaterniond q(R); Sophus::SO3d SO3_q(q); std::cout “SO3 from quaternion is equal? ” SO3_R.isApprox(SO3_q) std::endl; // 3. 使用指数映射从旋转向量李代数构造 Eigen::Vector3d so3(0, 0, M_PI / 2); // so3 [0,0,pi/2]^T Sophus::SO3d SO3_exp Sophus::SO3d::exp(so3); std::cout “SO3 from exp(so3):\n” SO3_exp.matrix() std::endl; // 4. 对数映射从SO3得到对应的李代数 Sophus::Vector3d so3_log SO3_R.log(); std::cout “so3 from log(SO3): ” so3_log.transpose() std::endl; // 5. 扰动模型计算旋转后的点关于旋转的导数雅可比 Eigen::Vector3d p(1, 0, 0); // 原始点 Eigen::Vector3d p_rotated SO3_R * p; // 旋转后的点 std::cout “p after rotation: ” p_rotated.transpose() std::endl; // 使用右乘扰动模型计算 d(R*p) / d(phi) phi是so3小扰动 Eigen::Matrix3d Jr SO3_R.jacobianR(); // 这是一个3x3矩阵 std::cout “Right Jacobian of SO3:\n” Jr std::endl; // 6. SE3示例一个旋转平移的变换 Eigen::Vector3d t(1, 2, 3); // 平移向量 Sophus::SE3d SE3_Rt(R, t); // 从R和t构造SE3 // 或者从变换矩阵构造 Eigen::Matrix4d T Eigen::Matrix4d::Identity(); T.block3, 3(0, 0) R; T.block3, 1(0, 3) t; Sophus::SE3d SE3_T(T); std::cout “SE3 transform matrix:\n” SE3_T.matrix() std::endl; // 用SE3变换一个点 Eigen::Vector3d p_transformed SE3_T * p; // 等价于 R*p t std::cout “p transformed by SE3: ” p_transformed.transpose() std::endl; return 0; }编译并运行这个程序你会直观地看到SO3/SE3的创建、群乘法、指数/对数映射等基本操作。注意jacobianR()的输出它在优化中会频繁用到。5. 深入核心Sophus在SLAM后端优化中的实战应用理论学习之后我们来看Sophus如何解决实际问题。在视觉SLAM的BABundle Adjustment优化中我们需要优化相机位姿李群元素和三维点坐标。优化过程如使用g2o, Ceres, GTSAM库需要我们知道误差函数关于优化变量的导数即雅可比矩阵。5.1 定义重投影误差与李代数扰动模型假设我们有一个世界点P_wEigen::Vector3d和相机位姿T_cwSophus::SE3d表示从世界坐标系到相机坐标系的变换。相机内参为K。该点在图像上的投影为u π(K * (T_cw * P_w))其中π是将三维点除以Z投影到二维的函数。观测到的像素坐标为z。重投影误差为e u - z。我们需要优化T_cw。但T_cw是李群SE(3)我们通常在其李代数se(3)空间进行扰动。设扰动量为ξ一个6维向量前3维旋转后3维平移扰动后的位姿为T_cw T_cw * exp(ξ^∧)右乘扰动模型。那么误差关于李代数扰动ξ的雅可比为J ∂e/∂ξ ∂e/∂u * ∂u/∂P_c * ∂P_c/∂ξ其中P_c T_cw * P_w是点在相机坐标系下的坐标。5.2 使用Sophus计算雅可比矩阵∂P_c/∂ξ这一项正是SE(3)的导数。根据李群理论对于右乘扰动∂(T * exp(ξ^∧) * P)/∂ξ |_{ξ0} [I, -P_c^∧]一个3x6的矩阵其中^∧是将向量转换为反对称矩阵的算子。Sophus库为我们提供了计算这个导数的便捷方法。虽然Sophus没有直接提供一个函数输出这个矩阵但它提供了计算扰动后变换的基础我们可以结合Eigen手动计算或者利用Sophus提供的generator()函数。但在实际使用中我们更常直接使用优化库如Ceres的自动微分或解析导数功能而Sophus确保了我们的变量类型能被这些库正确识别和操作。下面是一个概念性的代码片段展示如何在Ceres优化问题中使用Sophus的SE3#include ceres/ceres.h #include sophus/se3.hpp // 定义使用SE3的重投影误差CostFunction class ReprojectionErrorSE3 : public ceres::SizedCostFunction2, 7 { // 2维残差7维位姿参数四元数平移向量 public: ReprojectionErrorSE3(const Eigen::Vector2d observed_p, const Eigen::Vector3d P_w, const Eigen::Matrix3d K) : observed_p_(observed_p), P_w_(P_w), K_(K) {} virtual bool Evaluate(double const* const* parameters, double* residuals, double** jacobians) const { // 1. 从参数数组读取位姿四元数平移向量 Eigen::Mapconst Eigen::Quaterniond q(parameters[0]); Eigen::Mapconst Eigen::Vector3d t(parameters[0] 4); Sophus::SE3d T_cw(q, t); // 构造SE3 // 2. 将世界点变换到相机坐标系 Eigen::Vector3d P_c T_cw * P_w_; // 3. 投影到归一化平面再乘以内参 double inv_z 1.0 / P_c.z(); double x P_c.x() * inv_z; double y P_c.y() * inv_z; Eigen::Vector3d p_pixel_homo K_ * Eigen::Vector3d(x, y, 1); Eigen::Vector2d predicted_p(p_pixel_homo.x(), p_pixel_homo.y()); // 4. 计算残差 residuals[0] predicted_p.x() - observed_p_.x(); residuals[1] predicted_p.y() - observed_p_.y(); // 5. 计算雅可比此处省略详细的解析雅可比计算通常可使用Ceres的自动微分 if (jacobians ! nullptr jacobians[0] ! nullptr) { // 计算误差关于点P_c的导数 Eigen::Matrixdouble, 2, 3 d_e_d_pc; // ... 计算过程 ... // 计算P_c关于李代数扰动ξ的导数 (3x6矩阵) Eigen::Matrixdouble, 3, 6 d_pc_d_xi Eigen::Matrixdouble, 3, 6::Zero(); d_pc_d_xi.block3, 3(0, 0) Eigen::Matrix3d::Identity(); // 关于平移部分 d_pc_d_xi.block3, 3(0, 3) -Sophus::SO3d::hat(P_c); // 关于旋转部分使用hat算子 // 链式法则得到最终雅可比 Eigen::Matrixdouble, 2, 6 jacobian_xi d_e_d_pc * d_pc_d_xi; // 还需要将李代数扰动ξ的雅可比转换为关于四元数平移参数7维的雅可比 // 这涉及额外的左乘扰动雅可比矩阵比较复杂... // 在实际中我们经常直接优化李代数ξ6维或者使用Ceres的自动微分来避免手动推导。 } return true; } private: Eigen::Vector2d observed_p_; Eigen::Vector3d P_w_; Eigen::Matrix3d K_; };可以看到即使使用Sophus手动推导和实现SE3的完整雅可比矩阵仍然相当复杂。因此在现代SLAM实践中更常见的做法是使用李代数作为优化变量直接优化一个6维向量se3在误差函数内部用Sophus::SE3d::exp(se3)将其转换为SE3d。这样雅可比矩阵就是关于6维李代数的推导相对标准。使用自动微分像Ceres这样的库如果你将位姿参数定义为Eigen::Vector3d平移和Eigen::Quaterniond旋转并使用Ceres的EigenQuaternionParameterization它可以利用自动微分来计算导数无需手动推导复杂的李群雅可比。Sophus在这里的作用是提供了SE3d和SO3d类使得在代码中构造和操作变换非常直观和安全。注意事项 在优化中对四元数施加单位约束很重要。Ceres提供了EigenQuaternionParameterization来处理。如果你直接优化李代数se3则没有额外的约束。6. 常见问题、坑点与调试技巧实录即使有了Sophus这样优秀的库在实际使用中还是会遇到各种问题。下面是我在项目中踩过的一些坑和总结的经验。6.1 编译与链接问题问题undefined reference to ‘Sophus::...’原因Sophus虽然是头文件库但它的某些实现如模板的特化在.cpp文件中。如果你只包含了头文件但没有将Sophus的源文件如sophus/so3.cpp,sophus/se3.cpp加入编译或者没有链接对应的库就会报错。解决确保使用CMake的find_package(Sophus REQUIRED)和target_link_libraries(your_target Sophus::Sophus)。现代CMake的target方式会自动处理包含目录和链接。如果手动管理确保编译并链接了libsophus.a或libsophus.so。问题Eigen版本不兼容原因不同版本的Sophus可能依赖特定版本的Eigen。例如较新的Sophus可能要求Eigen 3.3以上。解决查看Sophus源码仓库的README或CMakeLists.txt确认其支持的Eigen版本。使用包管理器如apt, vcpkg, conda安装指定版本或从源码编译对应版本的Eigen。6.2 数值计算与精度问题问题四元数未归一化导致SO3构造函数抛出异常或结果错误原因Sophus::SO3d的构造函数接受Eigen::Quaterniond但它内部假设四元数是单位四元数。如果你传入了一个未归一化的四元数虽然构造函数可能不会立即报错但后续运算如matrix()、log()会产生错误结果。解决在构造SO3d之前务必确保你的四元数是单位四元数。使用Eigen::Quaterniond::normalize()或Eigen::Quaterniond q(rotation_matrix);Eigen的构造函数会自动归一化。Eigen::Quaterniond q; // 假设q来自不可靠的来源 q.normalize(); // 关键步骤 Sophus::SO3d SO3_q(q);问题对数映射log()在旋转角接近π时不稳定原因从旋转矩阵或四元数计算旋转向量轴角时当旋转角度接近π存在奇异性。虽然Sophus的实现已经处理了这种情况但数值精度可能下降。解决在SLAM中通常我们保证相邻帧间的旋转是小量不会接近π。如果算法中不可避免地会出现大旋转考虑使用其他参数化方式如直接使用四元数进行优化仅在需要时转换为李代数。问题SE3d::inverse()的效率原因求SE3的逆变换在滤波和优化中很常用。T.inverse()会计算旋转矩阵的转置和平移向量的反向旋转涉及一次矩阵转置运算。优化如果在一个紧循环中需要反复使用同一个变换的逆应该将其缓存起来而不是每次调用inverse()。// 低效 for (const auto point : points) { Eigen::Vector3d p_cam T_wc.inverse() * point; // 每次循环都求逆 } // 高效 Sophus::SE3d T_cw T_wc.inverse(); for (const auto point : points) { Eigen::Vector3d p_cam T_cw * point; }6.3 与第三方库的集成问题问题将Sophus类型传递给Ceres的AddResidualBlock场景你想直接优化Sophus::SE3d对象。难点Ceres的优化变量必须是普通的双精度数组double*。Sophus::SE3d内部存储方式一个四元数加一个向量对于Ceres是不透明的。解决方案两种优化参数块不直接传递SE3d而是传递一个double[7]数组前4个是四元数[qx, qy, qz, qw]后3个是平移[tx, ty, tz]。在CostFunction内部再将其重新构造成Sophus::SE3d。同时需要为这个参数块设置局部参数化EigenQuaternionParameterization。double pose[7]; // [qx, qy, qz, qw, tx, ty, tz] // ... 初始化 pose ... problem.AddParameterBlock(pose, 7, new ceres::EigenQuaternionParameterization());优化李代数传递一个double[6]数组作为李代数se3。在CostFunction内部使用Sophus::SE3d::exp(se3)来得到SE3d。这种方式更符合李群理论且参数维度更少6 vs 7但雅可比推导需要处理指数映射的导数。对于Ceres自动微分这种方式同样可行。问题Sophus与g2o的VertexSE3Expmap场景在g2o中优化位姿图。解决g2o有自己内置的SE3顶点类型VertexSE3Expmap它内部也是用李代数表示的。Sophus可以很方便地与它进行数据转换。通常我们从g2o顶点读取或写入的是g2o::SE3Quat类型可以轻松地与Sophus::SE3d相互转换通过旋转矩阵和平移向量。社区也有基于Sophus自定义g2o顶点和边的例子用起来更统一。6.4 调试与日志输出技巧格式化输出直接cout SO3_R可能输出的是内部四元数值。为了更直观通常输出其旋转矩阵cout SO3_R.matrix()。对于SE3d输出SE3_T.matrix()得到4x4变换矩阵。验证群乘法对于变换T1和T2验证(T1 * T2).inverse()是否等于T2.inverse() * T1.inverse()。这是一个快速检查变换复合和求逆是否正确的好方法。验证指数/对数映射对于一个小量xi验证SE3d::exp(xi).log()是否近似等于xi。这可以检查指数/对数映射的实现是否正确以及数值精度是否可接受。Sophus::Vector6d xi Sophus::Vector6d::Random() * 0.01; // 一个小随机扰动 Sophus::SE3d T Sophus::SE3d::exp(xi); Sophus::Vector6d xi_recovered T.log(); double error (xi - xi_recovered).norm(); std::cout “Exp-Log error: ” error std::endl; // 应该是一个非常小的数如1e-127. 进阶话题自定义Sophus类型与性能考量7.1 使用模板特化与自定义标量类型Sophus完全模板化你可以使用Sophus::SO3和Sophus::SE3。但为了代码清晰库提供了SO3d,SO3f,SE3d,SE3f的别名。在大多数对精度要求高的SLAM后端使用double类型。如果你的应用场景非常特殊比如需要自动微分例如用于Ceres的Jet类型理论上可以特化Sophus的模板以支持自定义标量类型。但这需要对Sophus的内部实现有深入了解通常不是必须的因为Ceres等库的自动微分可以绕过对Sophus的直接求导。7.2 性能优化小贴士避免频繁构造/析构在性能关键的循环中如为地图中成千上万个点做位姿变换尽量避免在循环内部构造临时的SO3d或SE3d对象。应该先在循环外构造好变换对象。利用Eigen的映射Map如果你有一段连续的内存如一个double数组存储了多个位姿四元数平移可以使用Eigen::Map来零拷贝地构造Quaterniond和Vector3d进而构造Sophus::SE3d避免内存拷贝。矩阵乘法优化SE3d对点的变换T * p内部实现为R * p t。Eigen会优化这个运算。但如果你要对大量点进行同一个变换可以考虑手动提取旋转矩阵R和平移向量t然后使用Eigen的广播功能进行批量运算这可能比在循环中逐个调用operator*更快。Eigen::Matrix3d R T.rotationMatrix(); Eigen::Vector3d t T.translation(); // points_mat 是一个3xN的矩阵每一列是一个点 Eigen::Matrix3Xd transformed_points (R * points_mat).colwise() t;注意调试模式下的性能在Debug模式下Eigen和Sophus会进行大量的边界检查性能会慢很多。在Benchmark或发布产品时务必在Release/RelWithDebInfo模式下编译和测试。Sophus库将李群李代数这一强大的数学工具以极其优雅和实用的方式带入了C工程实践。它可能不会是你项目中代码量最多的部分但绝对是保证几何计算正确性和代码简洁性的基石。理解其原理掌握其常见用法和陷阱能让你在开发机器人、SLAM、三维视觉等相关应用时更加得心应手。最开始接触那些exp、log、hat、vee运算符可能会觉得抽象但多写几个例子多调试几次你会发现它们就像加减乘除一样成为你处理三维空间变换的自然语言。