1. 视觉SLAM里的位姿表示,为什么绕不开Sophus
做视觉SLAM的人,迟早会撞上李群李代数这堵墙。相机位姿、地图点变换、优化过程中的增量更新,全都离不开旋转矩阵和平移向量的组合。刚开始你可能觉得用Eigen::Matrix3d和Eigen::Vector3d拼一拼就够了,但一旦进入后端优化,需要求导、需要迭代更新、需要处理约束,裸矩阵那套写法很快就会让你崩溃。
Sophus这个库就是来解决这个问题的。它把SO(3)和SE(3)这两个李群封装成了类似矩阵的类,让你能像操作普通矩阵一样做乘法、求逆、取对数、求伴随,同时还能通过指数映射在流形上做无约束优化。说白了,它把“旋转矩阵必须正交且行列式为1”这个约束,通过李代数参数化给消掉了,优化器只需要在三维或六维向量空间里随便跑,映射回去自然满足约束。
这篇文章适合谁看?如果你正在学视觉SLAM,已经写过简单的PnP或者ICP,但一到g2o、Ceres或者自己手写高斯牛顿的时候就被Sophus::SO3d的各种API搞晕,那这篇就是给你准备的。我会把SO(3)和SE(3)的构造、更新、求导、常见坑点全部拆开讲一遍,代码可以直接抄。
2. Sophus的安装与工程集成,别在第一步卡住
2.1 安装方式选择与依赖处理
Sophus本身是header-only的模板库,但它的编译和安装过程有几个容易踩的坑。最稳妥的方式是源码编译安装,因为不同版本对Eigen和fmt的依赖不一样。
git clone https://github.com/strasdat/Sophus.git cd Sophus mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release -DBUILD_SOPHUS_TESTS=OFF make -j4 sudo make install这里有几个关键点。第一,BUILD_SOPHUS_TESTS建议关掉,测试用例编译很慢而且容易因为fmt版本问题报错。第二,Sophus新版本依赖fmt库,如果系统里没有,先装libfmt-dev。第三,如果你用的是ROS环境,注意ROS自带的Sophus可能版本很老,API和新版有差异,建议统一用源码安装的版本,在CMakeLists里显式指定路径。
在CMakeLists.txt里的集成方式:
find_package(Sophus REQUIRED) find_package(Eigen3 REQUIRED) include_directories(${Sophus_INCLUDE_DIRS} ${EIGEN3_INCLUDE_DIR}) target_link_libraries(your_target ${Sophus_LIBRARIES})注意:Sophus的CMake配置文件在不同版本里导出的变量名可能不同,有的是
Sophus_INCLUDE_DIRS,有的是Sophus_INCLUDE_DIR。如果find_package成功但include报错,用message(STATUS ${Sophus_INCLUDE_DIRS})打印一下确认。
2.2 头文件包含与命名空间约定
Sophus的核心头文件就两个:#include <sophus/so3.hpp>和#include <sophus/se3.hpp>。所有类型都在Sophus命名空间下。常用的类型别名有:
Sophus::SO3d:双精度SO(3),内部用四元数存储Sophus::SE3d:双精度SE(3),内部用四元数加平移向量存储Sophus::SO3f、Sophus::SE3f:单精度版本,嵌入式场景用
我个人的习惯是在文件开头加using namespace Sophus;,但如果你在大型项目里,建议还是老老实实写Sophus::SO3d,避免和别的库冲突。
3. SO(3)的核心用法:旋转的构造、更新与求导
3.1 四种构造方式与适用场景
SO(3)表示三维旋转,Sophus提供了多种构造入口,每种对应不同的数据来源:
// 方式一:从旋转矩阵构造(最常用) Eigen::Matrix3d R = Eigen::AngleAxisd(M_PI/4, Eigen::Vector3d::UnitZ()).toRotationMatrix(); Sophus::SO3d SO3_R(R); // 方式二:从四元数构造 Eigen::Quaterniond q(R); Sophus::SO3d SO3_q(q); // 方式三:从旋转向量(轴角)构造,内部自动做指数映射 Eigen::Vector3d omega(0, 0, M_PI/4); Sophus::SO3d SO3_exp = Sophus::SO3d::exp(omega); // 方式四:直接构造单位旋转 Sophus::SO3d SO3_I; // 默认是单位旋转这四种方式里,exp是最需要理解的。它把李代数so(3)里的三维向量映射到李群SO(3)上,物理意义是“绕轴omega旋转|omega|角度”。在优化过程中,我们求出来的增量就是这种三维向量,用exp映射回去更新位姿。
实操心得:从旋转矩阵构造时,Sophus内部会做正交化处理,但如果你给的矩阵离正交矩阵太远(比如数值误差累积很大),结果可能不是你预期的。我一般会在构造前用SVD重新正交化一下,尤其是从优化器里拿到的矩阵。
3.2 对数映射与旋转向量提取
反过来,从SO(3)取李代数用log():
Sophus::SO3d SO3_R(R); Eigen::Vector3d omega = SO3_R.log();这里有个细节:log()返回的向量方向是旋转轴,模长是旋转角度,角度范围在[0, π]。当旋转角度接近π时,数值上会有点不稳定,因为轴的方向会翻转。实际用的时候如果发现log结果跳变,检查一下是不是接近奇异点。
另外,Sophus还提供了SO3_R.angle()直接返回旋转角度,SO3_R.axis()返回单位旋转轴,比手动从log结果里算方便。
3.3 乘法、逆与伴随矩阵
SO(3)的乘法就是旋转的复合:
Sophus::SO3d R1 = Sophus::SO3d::exp(Eigen::Vector3d(0.1, 0.2, 0.3)); Sophus::SO3d R2 = Sophus::SO3d::exp(Eigen::Vector3d(0.4, 0.5, 0.6)); Sophus::SO3d R12 = R1 * R2; // 先R2后R1的旋转 Sophus::SO3d R1_inv = R1.inverse();伴随矩阵Adj()在推导雅可比和坐标系变换时经常用到:
Eigen::Matrix3d Adj = R1.Adj();伴随矩阵满足R * exp(omega) * R^T = exp(Adj * omega),这个性质在推导扰动模型的时候是核心工具。很多教程讲到这里一笔带过,但你如果自己推一遍SO(3)上的右扰动雅可比,就会发现伴随矩阵无处不在。
3.4 在流形上做增量更新
这是Sophus在SLAM里最核心的用法。优化器每次迭代算出一个三维增量delta,然后更新旋转:
Sophus::SO3d R_updated = Sophus::SO3d::exp(delta) * R_current; // 左乘更新 // 或者 Sophus::SO3d R_updated = R_current * Sophus::SO3d::exp(delta); // 右乘更新左乘和右乘的区别在于扰动定义在哪个坐标系。左乘对应“在世界坐标系下施加扰动”,右乘对应“在局部坐标系下施加扰动”。视觉SLAM里两种都有用,取决于你的雅可比是怎么推的。我个人的经验是,BA优化里用右乘更直观,因为增量直接对应相机局部坐标系下的微小旋转。
常见坑:千万不要写成
R_current + delta这种裸矩阵加法,结果不再是旋转矩阵,后续所有计算全错。必须通过exp映射回SO(3)再乘。
4. SE(3)的实战用法:位姿变换与优化更新
4.1 SE(3)的构造与数据访问
SE(3)表示刚体变换,包含旋转和平移:
// 从旋转矩阵和平移向量构造 Eigen::Matrix3d R = Eigen::Matrix3d::Identity(); Eigen::Vector3d t(1, 2, 3); Sophus::SE3d SE3_Rt(R, t); // 从四元数和平移向量构造 Eigen::Quaterniond q(R); Sophus::SE3d SE3_qt(q, t); // 从李代数构造(六维向量,前三维平移,后三维旋转) Eigen::Matrix<double, 6, 1> xi; xi << 1, 2, 3, 0.1, 0.2, 0.3; Sophus::SE3d SE3_exp = Sophus::SE3d::exp(xi);访问内部数据:
Eigen::Matrix3d rotation_matrix = SE3_Rt.rotationMatrix(); Eigen::Quaterniond quaternion = SE3_Rt.unit_quaternion(); Eigen::Vector3d translation = SE3_Rt.translation(); Eigen::Matrix4d matrix4x4 = SE3_Rt.matrix(); // 4x4齐次矩阵这里要注意matrix()返回的是4x4矩阵,而rotationMatrix()只返回3x3旋转部分。在给OpenGL或者别的库传数据的时候别搞混了。
4.2 点变换与坐标系变换
SE(3)作用在三维点上:
Eigen::Vector3d p(1, 0, 0); Eigen::Vector3d p_transformed = SE3_Rt * p;这个操作等价于R * p + t,但Sophus内部做了优化,比手动展开快。如果你要批量变换大量点,建议用SE3_Rt.matrix()取出4x4矩阵,然后做齐次坐标乘法,比逐个调用operator*效率高。
坐标系变换用乘法:
Sophus::SE3d T_wc; // 世界到相机 Sophus::SE3d T_cw = T_wc.inverse(); // 相机到世界 Sophus::SE3d T_wa = T_wb * T_ba; // 链式变换注意:SE(3)的乘法顺序不能随便换。
T1 * T2表示先做T2的变换,再做T1的变换。在SLAM里写坐标变换的时候,建议在变量名里把坐标系标清楚,比如T_wc表示world到camera,这样不容易搞反。
4.3 SE(3)上的扰动模型与雅可比
SE(3)的优化更新和SO(3)类似,但增量是六维的:
Eigen::Matrix<double, 6, 1> delta; delta << 0.01, 0.02, 0.03, 0.001, 0.002, 0.003; Sophus::SE3d T_updated = Sophus::SE3d::exp(delta) * T_current;在推导雅可比的时候,SE(3)的伴随矩阵是6x6的:
Eigen::Matrix<double, 6, 6> Adj = T_current.Adj();这个伴随矩阵在把扰动从一个坐标系转到另一个坐标系时非常关键。比如你在世界坐标系下定义了扰动,但雅可比是在相机坐标系下算的,就需要用伴随矩阵做转换。
4.4 实际优化中的更新策略对比
在视觉SLAM的BA优化里,SE(3)的更新有两种常见策略:
| 策略 | 更新方式 | 优点 | 缺点 |
|---|---|---|---|
| 左乘更新 | T_new = exp(delta) * T_old | 扰动在世界坐标系,推导直观 | 需要额外处理伴随矩阵 |
| 右乘更新 | T_new = T_old * exp(delta) | 扰动在局部坐标系,雅可比形式简洁 | 需要明确局部坐标系定义 |
我实测下来,在Ceres里用右乘更新更顺手,因为Ceres的自动求导对局部参数化支持很好,Sophus::SE3d可以直接配合LocalParameterization使用。在g2o里两种都有现成的顶点类型,看你的边是怎么定义的。
5. 常见报错与排查技巧实录
5.1 编译期问题速查
| 报错信息 | 原因 | 解决方法 |
|---|---|---|
fatal error: sophus/so3.hpp: No such file | 头文件路径没配 | 检查CMakeLists里的include路径 |
undefined reference to Sophus::SO3d::exp | 链接库没加 | 确认target_link_libraries包含Sophus |
static assertion failed: YOU_MIXED_MATRICES_OF_DIFFERENT_SIZES | Eigen矩阵维度不匹配 | 检查旋转矩阵是不是3x3,平移是不是3x1 |
fmt/core.h: No such file | 缺少fmt依赖 | 安装libfmt-dev或源码编译fmt |
5.2 运行期数值问题排查
问题一:优化不收敛,位姿乱飞。
最常见的原因是增量更新写错了。检查是不是直接用了T + delta而不是T * exp(delta)。另一个可能是雅可比的符号搞反了,导致优化器往错误方向走。我一般会先固定住其他变量,只优化一个位姿,看能不能收敛到正确值,逐步排查。
问题二:log()结果跳变。
当旋转角度接近π时,log的轴方向会突然翻转。这不是bug,是李代数在π处的固有奇异性。解决方法是在接近π时用四元数插值或者双覆盖处理,或者干脆避免让旋转角度累积到π附近。
问题三:SE(3)的exp和log不对应。
检查你用的李代数向量顺序。Sophus的SE(3)李代数前三维是平移,后三维是旋转,但有些教材写的是反过来的。如果你从别的地方抄的代码,一定要确认顺序。
独家避坑:在写单元测试的时候,一定要加一条
T * T.inverse() == Identity的检查,以及exp(log(T)) == T的往返测试。这两个测试能帮你抓住90%的API误用问题。
5.3 性能相关的注意事项
Sophus的exp和log涉及三角函数和矩阵运算,在优化内循环里调用频率很高。如果你发现优化速度慢,可以考虑以下优化:
- 用单精度
SO3f和SE3f替代双精度,速度能快一倍左右,精度损失在视觉SLAM里通常可以接受 - 避免在循环里反复构造SO(3)对象,尽量复用
- 如果只需要旋转矩阵,直接用
rotationMatrix(),不要先转四元数再转矩阵
6. 从SO(3)到SE(3)的进阶理解
6.1 两者的代数结构关系
SO(3)是SE(3)的子群,SE(3)可以看成SO(3)和R^3的半直积。这个数学结构决定了SE(3)的李代数不是简单的平移加旋转,而是有一个耦合项。具体来说,SE(3)的exp映射里,平移部分会被旋转部分影响:
// SE(3)的exp不是简单地把平移和旋转分开exp再拼起来 // 内部有V矩阵耦合这个耦合在推导雅可比的时候必须考虑,否则优化会出问题。很多初学者在这里翻车,以为SE(3)就是SO(3)加个平移向量,结果雅可比推错了。
6.2 在视觉SLAM中的典型应用场景
| 场景 | 用的群 | 典型操作 |
|---|---|---|
| 相机位姿表示 | SE(3) | 构造、求逆、乘法 |
| 特征点三角化 | SE(3) | 点变换 |
| BA优化 | SE(3) | exp更新、伴随矩阵 |
| IMU预积分 | SO(3)+SE(3) | 旋转更新、雅可比传递 |
| 回环检测位姿图 | SE(3) | 相对变换、优化 |
6.3 和其他库的互操作
Sophus和Eigen是无缝衔接的,但和ROS的tf、geometry_msgs转换时需要手动处理:
// Sophus转ROS geometry_msgs::Pose pose; pose.position.x = SE3.translation().x(); // ... 四元数赋值反过来从ROS消息构造Sophus对象也很直接。关键是注意ROS用的四元数顺序是(x, y, z, w),而Eigen的构造函数也是这个顺序,但有些库用的是(w, x, y, z),别搞混。
7. 我踩过的那些坑和最后的小技巧
第一个坑是版本兼容性。Sophus在2020年前后的API有过一次大改,老版本的SO3d::exp返回类型和新版本不一样,如果你从网上抄的代码编译不过,先检查版本。我现在的习惯是在CMakeLists里锁定Sophus的版本号,避免不同机器上行为不一致。
第二个坑是SE3d::exp的输入向量顺序。我至少见过三种不同的约定:平移在前旋转在后、旋转在前平移在后、还有把平移除以旋转角度再放的。Sophus用的是平移在前旋转在后,但如果你从论文里抄公式,一定要确认论文用的哪种约定。
第三个坑是优化过程中的数值稳定性。当旋转增量很小时,exp的泰勒展开和直接计算会有微小差异,长时间迭代可能累积误差。我的做法是每隔几百次迭代,把SE(3)重新正交化一次,用SVD把旋转矩阵投影回SO(3)。
最后分享一个小技巧:如果你只需要旋转不要平移,用SO(3)比SE(3)快很多,因为SE(3)的exp和log涉及更复杂的矩阵运算。在IMU预积分里,旋转部分单独用SO(3)处理,平移部分单独算,比整体用SE(3)效率高不少。这个优化在我自己的VIO系统里实测能省15%左右的计算时间。