资讯动态

视觉SLAM中Sophus库实战:SO(3)与SE(3)位姿表示、更新与求导详解

发布时间:2026/10/15 4:00:48 来源:尧图企业网站定制
1. 视觉SLAM里的位姿表示为什么绕不开Sophus做视觉SLAM的人迟早会撞上李群李代数这堵墙。相机位姿、地图点变换、优化时的雅可比矩阵全都建立在旋转矩阵和平移向量之上。问题在于旋转矩阵本身带约束——正交且行列式为1直接拿它做优化更新一步就可能破坏约束迭代几次数值就飘了。所以主流做法是把位姿映射到李代数空间在无约束的向量空间里做加减再通过指数映射回到李群。这套流程里Sophus就是那个把数学公式变成可调用代码的库。Sophus是一个基于Eigen的C李群库专门处理SO(3)、SE(3)以及对应的Sim(3)、SO(2)、SE(2)。它最早由某位德国研究者维护后来在视觉SLAM圈子里被广泛采用高翔那本《视觉SLAM十四讲》用的就是它。你会在ORB-SLAM、VINS-Mono、DSO这些开源系统的代码里反复看到Sophus::SO3d、Sophus::SE3d的身影。它解决的问题很具体让你用exp、log、hat、vee这些操作在旋转矩阵和旋转向量之间自由切换同时提供左乘、右乘更新模型配合Eigen做矩阵运算几乎零开销。这篇文章适合谁看如果你正在学视觉SLAM已经能跑通特征点法或直接法但一看到Sophus::SE3d::exp()就发懵或者优化时不知道该用左扰动还是右扰动那这篇就是写给你的。我会从SO(3)和SE(3)的基本构造讲起把更新模型、伴随性质、求导链路这些容易踩坑的地方拆开配上可直接编译运行的代码片段和参数说明。假设你有Eigen基础知道旋转矩阵和平移向量是什么但对李代数只有模糊印象那正好。2. Sophus的安装与工程接入先把环境理顺2.1 依赖关系与版本选择Sophus本身依赖Eigen早期版本还依赖fmt库做格式化输出。如果你用的是较新的Sophus比如2021年之后的master分支它默认要求C17并且把fmt作为可选依赖。安装方式有两种源码编译安装或者直接把头文件拖进工程。我建议源码编译因为Sophus是header-only加少量源文件编译一次全局可用省得每个工程都拷一遍。具体操作上先确认Eigen版本。Sophus对Eigen3的依赖是硬性的find_package(Eigen3 REQUIRED)必须能找到。如果你在Ubuntu下apt install libeigen3-dev通常够用。然后克隆Sophus仓库注意选对分支。老版本比如1.0.0用C11新版本用C14或C17。视觉SLAM项目里如果你用的是较老的ORB-SLAM2代码它可能依赖旧版Sophus的API比如Sophus::SO3d::exp的返回类型在新旧版本间有差异这点后面会细说。编译命令大致如下git clone https://github.com/strasdat/Sophus.git cd Sophus mkdir build cd build cmake .. make -j4 sudo make install装完之后在CMakeLists里加find_package(Sophus REQUIRED) include_directories(${Sophus_INCLUDE_DIRS}) target_link_libraries(your_target ${Sophus_LIBRARIES})注意如果你同时装了多个版本的Sophusfind_package可能找到旧版。用Sophus_DIR变量显式指定路径或者在CMake里打印Sophus_INCLUDE_DIRS确认。2.2 头文件包含与命名空间Sophus的核心头文件是#include sophus/se3.hpp和#include sophus/so3.hpp。命名空间就是Sophus里面常用的类型别名有SO3d、SE3d、SO3f、SE3f分别对应double和float精度。视觉SLAM里几乎都用double因为优化对数值精度敏感float在累积误差上容易出问题。有个细节值得说Sophus的SO3d内部存储的是一个四元数而不是旋转矩阵。这是为了数值稳定和存储效率。你调用.matrix()时它才临时算出旋转矩阵。同理SE3d内部存的是SO3d加一个Vector3d平移。理解这一点很重要因为当你直接改内部数据时四元数的归一化约束需要自己维护而通过exp、log、setQuaternion这些接口操作Sophus会帮你处理。3. SO(3)的核心操作构造、映射与更新3.1 三种构造方式与适用场景SO(3)表示三维旋转Sophus提供了多种构造途径。第一种是从旋转矩阵构造Sophus::SO3d R(matrix)其中matrix必须是3x3正交矩阵。这里有个坑如果矩阵只是近似正交比如从优化中间结果来的Sophus不会自动正交化你得先做SVD或QR分解。第二种是从四元数构造Sophus::SO3d R(quaternion)四元数需要是单位四元数否则内部会归一化。第三种是从旋转向量构造用Sophus::SO3d::exp(omega)omega是三维向量方向是旋转轴模长是旋转角。实际SLAM代码里从旋转向量构造最常用因为优化变量就是旋转向量。比如你有一个3维的增量delta_theta想更新当前旋转就写R R * Sophus::SO3d::exp(delta_theta)。这里用的是右乘对应右扰动模型。左乘则是R Sophus::SO3d::exp(delta_theta) * R。两者数学上等价但雅可比形式不同选哪个取决于你的推导习惯和优化库的约定。3.2 log映射与旋转向量的提取log()是exp()的逆运算把SO(3)元素映射回三维旋转向量。代码里写Vector3d omega R.log()。这里要注意log返回的旋转向量模长在[0, π]之间因为旋转角超过π时等价于反向旋转。Sophus内部处理了奇异性当旋转角接近0或π时有专门的数值稳定分支。我实测过一个场景连续做多次小角度旋转然后log回来结果和直接累加旋转向量有细微差异。原因是SO(3)不是交换群旋转顺序影响最终结果。所以如果你在优化里用旋转向量做加法更新必须明确是左扰动还是右扰动不能简单把增量加到当前旋转向量上。这是初学者最容易犯的错误之一。3.3 hat与vee向量与反对称矩阵的互转hat操作把一个三维向量变成反对称矩阵vee反过来。代码里是Matrix3d skew Sophus::SO3d::hat(omega)和Vector3d omega Sophus::SO3d::vee(skew)。这两个操作在推导雅可比时天天用因为旋转矩阵对旋转向量的导数涉及反对称矩阵。举个例子如果你要计算重投影误差对旋转的导数链式法则里会出现-R * hat(point)这样的项。用Sophus的hat代码写起来和公式几乎一一对应不容易出错。我建议在草稿纸上把公式推一遍然后在代码里用hat和vee对照能快速定位符号错误。3.4 更新模型左扰动与右扰动的代码差异左扰动模型下更新写成R_new Sophus::SO3d::exp(delta) * R_old。右扰动则是R_new R_old * Sophus::SO3d::exp(delta)。两者对应的雅可比矩阵不同左扰动下雅可比通常更简洁因为扰动在左侧和误差对旋转的导数直接相乘。但右扰动在视觉SLAM里也常见尤其是当旋转作用于局部坐标系时。我个人的习惯是如果旋转定义在世界坐标系到相机坐标系的变换用右扰动如果定义在相机坐标系到世界坐标系用左扰动。这不是硬性规定但保持一致性很重要。混用会导致优化不收敛而且很难debug。你可以写个小测试给一个旋转分别用左扰动和右扰动加一个已知增量看结果是否和预期一致。4. SE(3)的完整位姿操作从构造到求逆4.1 SE(3)的内部结构与构造方法SE(3)是SO(3)加平移表示刚体变换。Sophus里SE3d内部存一个SO3d和一个Vector3d。构造方式有几种从旋转矩阵和平移向量构造SE3d(R, t)从四元数和平移构造SE3d(q, t)从4x4变换矩阵构造SE3d(T)从旋转向量和平移构造SE3d::exp(xi)其中xi是6维向量前三维是平移部分注意不是直接平移而是与旋转耦合的后三维是旋转向量。这里有个关键点SE3d::exp的输入6维向量其平移部分和最终平移不是简单相等。指数映射的公式里平移部分要乘以一个与旋转相关的雅可比矩阵通常叫V矩阵。所以如果你想把一个已知平移t和旋转R转成6维向量不能直接拼[t, R.log()]而要用SE3d(R, t).log()。这个log返回的6维向量平移部分才是正确的。4.2 旋转向量与平移的提取SE3d提供.so3()拿旋转部分.translation()拿平移部分。如果你想拿6维李代数向量用.log()。注意.log()返回的平移部分不是.translation()两者差一个V矩阵的逆。很多人在优化里直接拿.translation()当李代数平移用结果更新后位姿不对。正确做法是统一用.log()和.exp()做映射。我踩过一次坑在写位姿图优化时误差定义为SE3d::log(T_ij.inverse() * T_i.inverse() * T_j)然后对T_i和T_j求导。如果中间混用了.translation()和.log()的平移雅可比就错了优化会震荡。后来统一用log和exp问题消失。4.3 求逆与伴随性质SE3d的求逆用.inverse()它返回逆变换。数学上T.inverse()的旋转是R.transpose()平移是-R.transpose() * t。Sophus内部做了优化比直接求4x4矩阵逆快。伴随性质在推导雅可比时很有用。SE3d有.Adj()方法返回6x6伴随矩阵。伴随矩阵的作用是T * exp(xi) * T.inverse() exp(Adj(T) * xi)。这个性质在把扰动从一个坐标系转到另一个坐标系时特别方便。比如你在世界坐标系下定义扰动但误差在相机坐标系下计算就可以用伴随矩阵转换。4.4 实际SLAM中的位姿更新代码示例假设你在做PnP有一个当前位姿SE3d T_cur, 优化增量是6维向量delta。右扰动更新写Sophus::SE3d T_cur ...; Eigen::Matrixdouble, 6, 1 delta ...; T_cur T_cur * Sophus::SE3d::exp(delta);左扰动则是T_cur Sophus::SE3d::exp(delta) * T_cur;如果你用Ceres或g2o它们内部有自己的流形定义但原理一样。Ceres里用Sophus::SE3d作为模板参数时需要特化Plus和ComputeJacobian或者直接用LocalParameterization。g2o里则用SE3Quat但你可以通过Sophus::SE3d转换。提示更新后记得检查旋转部分是否归一化。虽然Sophus的exp保证单位四元数但如果你手动改了内部数据最好调用.normalize()。5. 常见问题与排查技巧实录5.1 编译报错找不到Sophus或Eigen版本冲突最常见的问题是CMake找不到Sophus。先确认Sophus_DIR是否指向正确的安装路径。如果报错说Eigen3版本太低检查/usr/include/eigen3是否存在或者用Eigen3_DIR指定。另一个坑是Sophus新版本要求C17而你的工程用C11编译时会报std::optional之类的错误。解决办法是在CMake里设set(CMAKE_CXX_STANDARD 17)。5.2 运行时数值异常log返回NaN如果R.log()返回NaN通常是旋转矩阵不正交。检查构造R时是否用了未归一化的四元数或近似正交矩阵。可以用R.matrix().determinant()看行列式是否接近1用R.matrix().transpose() * R.matrix()看是否接近单位阵。如果不满足先做SVD正交化JacobiSVD分解后取U * V.transpose()。5.3 优化不收敛扰动模型选错如果优化迭代几次后误差反而变大先检查扰动模型是否一致。左扰动和右扰动的雅可比差一个负号或转置混用会导致梯度方向错误。一个快速验证方法用数值微分算雅可比和解析雅可比对比。如果符号相反就是扰动模型搞反了。5.4 精度问题float与double混用Sophus的SO3f和SE3f是float精度在视觉SLAM里尽量别用。我试过用float跑BA迭代100次后旋转误差累积到0.1弧度而double只有1e-6。如果必须用float至少在做log和exp时转成double算完再转回去。5.5 常见问题速查表问题现象可能原因排查方法解决方式编译报错找不到SophusCMake路径不对打印Sophus_INCLUDE_DIRS设Sophus_DIR或手动includelog返回NaN旋转矩阵不正交检查行列式和正交性SVD正交化后再构造优化震荡不收敛扰动模型混用数值微分对比雅可比统一左扰动或右扰动位姿更新后漂移平移部分用错对比log和translation统一用log/exp精度差float精度不足对比double结果关键计算用double5.6 独家避坑技巧第一个技巧在写涉及SO(3)的代码时先写单元测试。给一个已知旋转验证exp(log(R)) R验证hat(vee(M)) M。这些测试能提前发现大部分符号和映射错误。第二个技巧用Sophus的operator打印位姿但注意它输出的是四元数和平移不是矩阵。如果你想看矩阵显式调用.matrix()。调试时把中间结果写文件用Python画出来比盯着终端输出直观。第三个技巧如果你从ROS的geometry_msgs转Sophus注意四元数的顺序。ROS的Quaternion是x,y,z,wEigen的Quaterniond构造函数是w,x,y,z。顺序搞反会导致旋转完全错误而且不容易发现。我一般写个辅助函数fromROSMsg和toROSMsg集中处理转换。6. 从SO(3)到SE(3)的求导链路把雅可比推明白6.1 重投影误差对位姿的导数视觉SLAM里最常见的求导是重投影误差对相机位姿的导数。假设世界点P相机位姿T投影函数π误差e π(T * P) - p_obs。对T的导数链式法则展开后旋转部分会出现-R * hat(P)平移部分是-R。用Sophus的hat代码里直接写-R.matrix() * Sophus::SO3d::hat(P)和公式一一对应。这里的关键是T * P的导数对T的扰动。如果T用右扰动T_new T * exp(delta)那么T_new * P T * exp(delta) * P ≈ T * (I hat(delta_rot)) * P T * delta_trans。整理后得到对delta_rot的导数是-R * hat(P)对delta_trans的导数是-R。这个推导过程建议自己手推一遍比背公式强。6.2 位姿图优化中的雅可比位姿图里误差定义为两个位姿之间的相对变换。比如e log(T_ij.inverse() * T_i.inverse() * T_j)。对T_i的导数用右扰动T_i_new T_i * exp(delta_i)代入后利用伴随性质最终雅可比是-J_r^{-1}(e) * Adj(T_j.inverse() * T_i)。这个公式看起来复杂但Sophus提供了.Adj()代码里直接调用不用手算。我实测过用Sophus的Adj和手推的雅可比对比数值误差在1e-8以内。所以放心用但前提是理解伴随矩阵的物理意义它把李代数从一个坐标系转到另一个坐标系。6.3 数值微分验证雅可比写完解析雅可比一定要用数值微分验证。方法很简单对每个优化变量加一个小扰动ε计算误差变化除以ε。和解析雅可比对比如果相对误差小于1e-5基本正确。Sophus的exp和log让这个过程很干净因为扰动直接加在李代数上。Eigen::Matrixdouble, 6, 1 delta Eigen::Matrixdouble, 6, 1::Zero(); delta(0) 1e-6; Sophus::SE3d T_plus T * Sophus::SE3d::exp(delta); // 计算误差变化这个技巧帮我抓过好几次符号错误尤其是涉及多个坐标系变换时。7. 工程实践中的性能与精度权衡7.1 什么时候用SO(3)什么时候用SE(3)如果只优化旋转比如全景拼接或相机标定中的外参旋转用SO(3)就够了。如果同时优化旋转和平移比如SLAM中的位姿必须用SE(3)。但SE(3)的优化变量是6维比SO(3)多3维计算量更大。在资源受限的嵌入式平台上可以考虑把平移和旋转分开优化或者用SO(3)加单独的平移向量。7.2 内存布局与缓存友好性Sophus的SO3d内部是四元数占4个doubleSE3d占7个double。相比旋转矩阵的9个double和变换矩阵的16个double内存占用小很多。在优化时大量位姿存储在std::vectorSE3d里连续内存访问对缓存友好。如果你用std::vectorSE3d*反而会破坏局部性。所以尽量存值不存指针。7.3 与Ceres、g2o的集成要点Ceres里用Sophus需要定义LocalParameterization。Ceres 2.0之后推荐用Manifold。代码里继承ceres::Manifold实现Plus、Minus、ComputeJacobian。Sophus的exp和log直接对应Plus和Minus。g2o里则用SE3Quat但你可以通过Sophus::SE3d转换。注意g2o的SE3Quat内部也是四元数转换时只涉及四元数和平移的拷贝开销很小。注意Ceres的自动求导对Sophus不直接支持因为Sophus的exp涉及分支。所以要么用解析雅可比要么用数值微分。解析雅可比更快但容易写错建议先用数值微分验证。8. 我个人的使用体会与后续扩展Sophus这个库用熟了之后会发现它把李群李代数的复杂性封装得很好但前提是你得理解背后的数学。我见过不少人直接抄代码结果换个场景就出错因为不知道exp和log的物理意义。我的建议是先手推一遍SO(3)和SE(3)的指数映射再用Sophus验证最后写单元测试固化下来。后续如果想深入可以看Sophus的源码尤其是so3.hpp和se3.hpp里的exp和log实现。你会发现它处理了旋转角接近0和π的奇异性用了泰勒展开和分支判断。这些细节在写自己的优化库时很有参考价值。另外Sophus还支持Sim(3)用于单目SLAM的尺度优化如果你做单目迟早会用到。最后分享一个小技巧在调试位姿时把SE3d转成4x4矩阵用Eigen::IOFormat格式化输出比默认的流输出可读性好很多。或者写个Python脚本把位姿序列画成轨迹一眼就能看出优化是否收敛。这些工具花不了多少时间但能省下大量debug的功夫。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑