简介:面向基于3D激光雷达SLAM的开发者,尤其适合无人车、机器人与自动驾驶定位场景,解决了激光雷达与IMU之间外参姿态分量的自动校准问题,可为ICP、NDT等匹配算法提供准确的位姿先验。代码以C++为主,利用ndt_omp完成点云配准,通过ROS Bag录制imu与lidar点云话题并按launch文件配置即可运行,适合具备ROS与PCL基础的中级开发者。整个压缩包共7个文件、约10KB,包含2个cpp核心实现、1个h头文件、launch启动配置、XML包配置及说明文档,目录将核心代码、启动配置与说明拆分存放,结构精简且定位明确,便于直接查看关键算法和快速改动。已有5898人学习下载,资料虽小但直击3D激光雷达SLAM中的标定痛点,读者可获得自动校准节点源码、运行配置思路,以及如何用ndt_omp完成外参姿态标定的可复用流程,并迁移到自己的数据集上。
1. 这个项目解决什么问题:装好传感器却对不准外参的痛
如果你手里有一套 3D 激光雷达加 IMU 的传感器组合,装上车后第一件麻烦事不是选什么算法,而是激光雷达和 IMU 之间的外参到底是多少。手工量出来的结果往往差个几厘米,建图直接重影,跑定位变成玄学。这个 C++ 写的 lidar_imu_calib 项目,就是专治这个问题的:它把一段正常行驶中的点云和 IMU 数据扔进去,自动算出激光雷达到 IMU 坐标系的旋转矩阵和平移向量,全程不需要人工放置标定板,也不需要高精度转台。机器人、自动驾驶、无人机姿态估计方向的开发者,都能直接把它当成外参标定的离线工具先跑一遍。
2. 技术栈与整体架构:C++、ROS、PCL、Eigen、Ceres 组成的标定管线
2.1 项目定位与核心思路
拆这个项目之前,我一直以为激光雷达和 IMU 的外参标定是个纯粹的数学问题,动手以后才发现更像个系统工程。lidar_imu_calib 采用离线批处理,而不是在线实时估计。整条标定流程分成三个阶段:数据预处理、特征提取、联合优化。
核心思路是把外参当成优化变量,用激光雷达点云在 IMU 积分位姿下的对齐误差作为代价。换句话说,完成外参变换之后,激光雷达每一帧点云应该落在 IMU 预测的连续轨迹上。这个目标函数可以同时约束旋转和平移,比分开标定更稳。
从数学形式上看,外参是一个 4x4 齐次变换矩阵,由 3x3 旋转和 3x1 平移组成。旋转部分项目用四元数保存,因为四元数在优化时只有 4 个分量,而且天然在单位球面上,方便做归一化约束。平移部分就是三个浮点数。整个优化器最后输出的就是这两个参数块。
常见做法是先用手眼标定 AX=XB 初始化旋转,再用点到线、点到面的残差精修平移。这个项目也走这条路。好处是旋转初始化不依赖平移估计,旋转对了以后平移基本变成一堆线性方程的问题,不容易把误差搅在一起。因为面向的是离线标定,它还可以做更多数据清洗工作,比如滤掉低反射强度点、投影深度图后再提特征,这在在线标定里很难做到。
2.2 目录结构:每个文件夹在管线里的角色
拿到源码后我先把目录结构梳理了一遍,项目不大,分得却相当清楚:
lidar_imu_calib/ ├── include/lidar_imu_calib/ │ ├── data_preprocess.h # 点云与IMU时间同步、下采样 │ ├── feature_extract.h # 提取边缘点与平面点 │ ├── optimizer.h # Ceres 优化器封装 │ └── utils.h # 四元数、旋转矩阵、文件IO工具 ├── src/ │ ├── data_preprocess.cpp │ ├── feature_extract.cpp │ ├── optimizer.cpp │ └── main.cpp # ROS 节点入口,串联整条管线 ├── config/ │ ├── calib_config.yaml # 标定参数、阈值、收敛条件 │ └── sensor_param.yaml # 传感器分辨率、IMU噪声参数 ├── launch/ │ ├── calib.launch # 跑标定的 launch 文件 │ └── record.launch # 录制数据用的 launch 文件 ├── scripts/ │ ├── run_calib.sh # 一键标定脚本 │ └── verify_result.py # 标定结果验证脚本 └── CMakeLists.txt这个结构对二次开发非常友好。data_preprocess 只负责把数据变成干净的、带时间戳的流;feature_extract 只负责从点云中抽出两类特征;optimizer 只管残差和增量更新;main.cpp 把这几步串起来。如果你想替换自己的预处理算法,只要改 data_preprocess 的实现,不需要动 optimizer。
从模块边界上能看出作者是有意把它做成一个可替换管线的。标定这类代码最怕所有逻辑挤在一个 main 文件里,改一个参数要全局搜一遍。拆成这样以后,每个类都可以单独写单元测试,我后来把预处理和优化分开测,省了不少事。utils.h 里放着四元数与欧拉角互转的接口,同时处理 TF 广播逻辑,它不参与优化计算,但你做可视化时会经常用到。
2.3 关键依赖与版本选择
C++ 版本要求 C++11 或者 C++14,我拆的这份用的是 C++14。ROS 方面,melodic 和 noetic 都能编译,前提是装好 PCL 和 Eigen。PCL 用于点云滤波和特征提取,Eigen 处理矩阵运算,Ceres Solver 负责非线性优化。
我在这里踩过坑:Ceres 和 ROS 自带的版本偶尔会冲突,尤其是直接用 apt 装的 Ceres 太老时,自动求导会出现莫名其妙的incomplete type错误。我一般从源码编译 Ceres 1.14 或更高版本,安装到 /usr/local,然后让 CMake 优先找它而不是 ROS 带的那一份。
依赖清单大致如下:
| 依赖 | 作用 | 版本建议 |
|---|---|---|
| ROS | 消息通信、TF、launch | melodic / noetic |
| Eigen | 矩阵、四元数运算 | 3.3+ |
| PCL | 点云滤波、特征提取 | 1.8+ |
| Ceres Solver | 非线性优化 | 1.14+ |
这里要特别说明为什么用 Ceres 而不是 g2o 或 GTSAM。这个项目的残差是点到线和点到面的距离,属于典型的非线性最小二乘问题,Ceres 的自动求导加上鲁棒核函数用起来最直接。固定规模优化用 Ceres,滑窗后端才需要考虑 g2o,不是越大的框架越合适。
如果你不想依赖 ROS,main.cpp 里其实只用了时间戳和 TF,可以把输入换成文本文件,每行是一个点云的 PCD 路径加对应的 IMU 四元数,这在单元测试里非常方便。但项目默认还是走 ROS,因为激光雷达驱动和 IMU 采集在机器人系统里本来就以 topic 形式存在,直接接进去成本最低。不熟悉 CMake 的话,可以先把 include 和 src 都加进target_include_directories,依赖再逐个补,不建议跳过依赖检查。
2.4 数据流与模块调用关系
整条管线跑起来后的数据流是这样的:rosbag 里的点云和 IMU 消息通过 main.cpp 读进来,先进入 data_preprocess,输出一个按时间排序的 IMU 位姿队列;然后每帧点云去 feature_extract 提取特征;最后 optimizer 用这些特征和位姿队列构造残差,迭代更新外参。每次迭代后,优化器会打印当前的旋转角和平移量,方便在终端观察收敛情况。
调用链可以简写为 main → DataPreprocess::sync → FeatureExtract::extract → Optimizer::solve。中间的订阅回调、缓存队列都封装在类内部,不在外层暴露。这种设计让单元测试也好写,我直接把预处理结果写成独立单元,不用跑 ROS。
参数块在模块之间传递顺序是固定的:预处理只产生归一化之后的时间戳和点位姿流,特征提取只产生特征索引和几何信息,优化器只接收参数块和残差。做到这一点后,你想把预处理换成多线程流水线也很安全,只要接口不变,其他模块完全不感知。
3. 核心模块拆解:从原始点云到外参矩阵的完整链路
3.1 数据预处理:时间同步与点云过滤
第一阶段把原始传感器数据变成干净的输入。激光雷达一般 10Hz,IMU 可能有 100Hz 到 200Hz,两者到达节点的时刻对不齐。项目用一个滑窗缓存,每次来点云帧时,从 IMU 缓存里取前后最近的若干帧做线性插值,算出点云时间戳对应的 IMU 位姿。
// data_preprocess.cpp —— 找出与点云时间最接近的IMU位姿 bool DataPreprocess::getInterpolatedPose( double t_pc, const ImuBuffer& buffer, Eigen::Quaterniond& q, Eigen::Vector3d& t) { if (buffer.size() < 2) return false; // 找到第一个时间戳大于 t_pc 的 IMU 帧索引 size_t i = 0; while (i < buffer.size() && buffer[i].time < t_pc) ++i; if (i == 0 || i >= buffer.size()) return false; double t0 = buffer[i - 1].time; double t1 = buffer[i].time; double s = (t_pc - t0) / (t1 - t0); // 旋转在四元数空间用球面线性插值,位移直接线性插值 q = buffer[i - 1].q.slerp(s, buffer[i].q); t = buffer[i - 1].p + s * (buffer[i].p - buffer[i - 1].p); return true; }这里参数说清楚:t_pc是点云帧的 header.stamp,buffer 保存最近一段时间 IMU 的旋转和位移。s是插值比例,必须在 0 到 1 之间。四元数插值用slerp,直接对四元数做线性归一化在旋转角大的时候会产生非线性误差,处理大转角容易翻车。
预处理还包括点云下采样。项目默认用 PCL 的 VoxelGrid,叶子大小 0.1m。对于 64 线雷达可以开到 0.2m,16 线雷达建议 0.05m。叶子大了特征会被抹掉,小了优化太慢。这个参数在calib_config.yaml的voxel_leaf字段里调,不用改代码。
3.2 特征提取:边缘点和平面点是怎么挑出来的
特征提取的思路和 LOAM 一致:计算每个点的局部曲率,曲率大的作为边缘点,曲率小的作为平面点。为什么要这样?因为外参优化主要靠两个几何约束:点到直线的距离和点到平面的距离。边缘点天然提供线约束,平面点提供面约束,两者单独优化容易互相耦合,合在一起标定才稳定。
// feature_extract.cpp 曲率计算与特征分类 void FeatureExtract::extract( const pcl::PointCloud<pcl::PointXYZI>::Ptr& cloud, std::vector<int>& edge, std::vector<int>& plane) { edge.clear(); plane.clear(); int n = cloud->size(); for (int i = 5; i < n - 6; ++i) { float diff = 0.0f; const pcl::PointXYZI& p = cloud->points[i]; for (int j = -5; j <= 6; ++j) { if (j == 0) continue; const pcl::PointXYZI& q = cloud->points[i + j]; diff += (q.x - p.x) * (q.x - p.x) + (q.y - p.y) * (q.y - p.y) + (q.z - p.z) * (q.z - p.z); } float curvature = diff / 10.0f; if (curvature > edge_threshold_) edge.push_back(i); else if (curvature < plane_threshold_) plane.push_back(i); } }索引从 5 开始、到 n-6 结束,是为了留出至少 5 个邻域点参与计算。edge_threshold_和plane_threshold_是配置文件里的两个阈值,默认分别是 0.7 和 0.1。你可以把曲率打印出来看看分布再调,不要盲改。
有一个细节:edge 和 plane 是两个独立分支,如果一个点同时满足两个条件,它只会进 edge,因为在找角点的时候混入平面点会污染线约束。特征提取前还要做一次体素滤波,因为同一线束上的相邻点距离很近,直接算曲率会受噪声影响。体素滤波之后相邻点距离更均匀,曲率才稳定。
3.3 旋转外参初始化:手眼标定 AX=XB
非线性优化很怕初值给得差。项目先用几分钟数据做一次手眼标定,拿到旋转初值。手眼标定的思想是:激光雷达和 IMU 固定在一个刚体上,刚体旋转时两边的旋转增量存在相似变换关系。
记 IMU 从位姿 A 到 B 的旋转变换为 R_AB,激光雷达从位姿 C 到 D 的旋转变换为 R_CD,激光雷达到 IMU 的外参为 R_LC,那么满足 R_AB * R_LC = R_LC * R_CD。这是一个 AX=XB 问题,项目用四元数形式做线性最小二乘得到闭式解。得到旋转初值后,平移初值由几组离散点云做简单 ICP 先估一个粗糙值。
这个初始化能做到多准?手眼标定对运动激励要求比较高,旋转轴必须足够丰富。只绕一个轴转的话,标定结果会退化成只标了部分角度。作者在文档里也强调采集数据时要绕 8 字型,多转几个角度,不要只做平移动。
3.4 平移外参优化:Ceres 代价函数与参数块
初始化之后进入精修阶段。优化器有两个参数块:一个四元数旋转,一个三维平移。残差是激光雷达坐标系下的特征点在变换到 IMU 坐标系后,与 IMU 轨迹上对应位置的几何距离。
对于平面特征,残差是点到平面的距离。核心结构长这样:
// optimizer.cpp 平面点到目标平面的残差 struct PlanarResidual { PlanarResidual(const Eigen::Vector3d& point, const Eigen::Vector3d& normal, const Eigen::Vector3d& centroid) : point_(point), normal_(normal), centroid_(centroid) {} template <typename T> bool operator()(const T* const q, const T* const t, T* residual) const { T p[3]; // 先用外参旋转点,再平移 ceres::QuaternionRotatePoint(q, point_.data(), p); p[0] += t[0]; p[1] += t[1]; p[2] += t[2]; // 计算变换后的点到目标平面的有符号距离 residual[0] = normal_(0) * (p[0] - centroid_(0)) + normal_(1) * (p[1] - centroid_(1)) + normal_(2) * (p[2] - centroid_(2)); return true; } };ceres::QuaternionRotatePoint是 Ceres 提供的四元数旋转函数,它接收的点坐标是 double,但在模板编译时会替换成 Jet 类型,从而实现自动求导。t是平移参数块,q是旋转参数块。残差是点到平面的距离,优化目标为所有平面残差平方和最小。
边缘点残差类似,只是把目标点换成直线上的两个点,计算点到线的距离。求解时用ceres::LocalParameterization把四元数强制归一化,不然旋转参数超过四个自由度后优化会发散。平移参数不需要额外约束。
参数块初始值从 3.3 节得到。迭代次数默认 50,收敛阈值 1e-6。当平移变化量连续五次小于 0.001m 时 Ceres 会提前退出。输出结果保存在config/calib_result.yaml,同时会发布一个静态 TF。
4. 编译与运行:从空白工作区到跑通标定
4.1 编译环境准备
建议用 Ubuntu 18.04 + melodic 或者 Ubuntu 20.04 + noetic。先把依赖装齐。PCL 在 ROS 桌面版里通常已经带了,自己编译 Ceres 能避免版本冲突。顺序是:装 Eigen、编译安装 Ceres、创建 catkin 工作区、把 lidar_imu_calib 放到 src 下、catkin_make。
# 从源码编译 Ceres,避免系统包太老 sudo apt-get install -y libeigen3-dev libgoogle-glog-dev libgflags-dev libatlas-base-dev git clone https://github.com/ceres-solver/ceres-solver.git cd ceres-solver mkdir build && cd build cmake -DCMAKE_BUILD_TYPE=Release .. make -j$(nproc) sudo make install # 编译这个项目 mkdir -p ~/catkin_ws/src cd ~/catkin_ws/src catkin_init_workspace git clone <你实际拿到的源码包路径> cd ~/catkin_ws catkin_make source devel/setup.bash注意这里不要直接用 apt 的 libceres-dev,因为仓库里的 Ceres 版本可能低于 1.13,对模板化残差支持不完整。git 地址换成你实际拿到的源码路径,如果是从 zip 解压出来的,直接用绝对路径放到 src 下也行。
编译过程中最常见的是找不到 Ceres 头文件,需要在 CMakeLists.txt 里加set(CMAKE_PREFIX_PATH "/usr/local"),确保find_package(Ceres REQUIRED)能定位到你自己编译的那份。CMakeLists.txt 的关键项大致如下:
cmake_minimum_required(VERSION 3.0.2) project(lidar_imu_calib) find_package(catkin REQUIRED COMPONENTS roscpp sensor_msgs pcl_ros tf2_ros) find_package(PCL 1.8 REQUIRED) find_package(Eigen3 REQUIRED) find_package(Ceres REQUIRED) add_executable(lidar_imu_calib_node src/main.cpp ...) target_include_directories(lidar_imu_calib_node PRIVATE include) target_link_libraries(lidar_imu_calib_node ${catkin_LIBRARIES} ${PCL_LIBRARIES} ${CERES_LIBRARIES})Eigen 是 header-only 的,放在 include_directories 里即可,不需要链接。Ceres 的路径如果不在系统默认搜索范围,必须手动加CMAKE_PREFIX_PATH,否则find_package找不到会报红。装完后可以用pcl-config --version和一条简单的 c++ 预处理命令确认 Eigen 版本,避免用了过老的头文件接口。
4.2 录制数据与配置文件说明
项目标定需要一段 rosbag。录制时激光雷达和 IMU 消息必须同时采集,建议车辆或机器人以 8 字型行驶 3 到 5 分钟,速度控制在 1m/s 到 3m/s,转弯方向要多元化。避免长时间直线,因为纯平移对旋转标定没有激励。
录制命令直接用 rosbag 即可:
rosbag record -O calib.bag /imu/data /lidar/points话题名要改成你驱动实际发布的 topic。用rosbag info确认两个话题都有足够的消息,时间跨度覆盖全程。看一下每个 topic 的频率,记录在案,后面填sensor_param.yaml要用。
接下来是配置文件。calib_config.yaml里控制标定流程的参数我都整理成了表:
| 参数 | 默认值 | 作用 |
|---|---|---|
| voxel_leaf | 0.1 | 点云体素滤波叶子大小 |
| edge_threshold | 0.7 | 曲率大于该值视为边缘点 |
| plane_threshold | 0.1 | 曲率小于该值视为平面点 |
| max_iterations | 50 | 优化最大迭代次数 |
| min_step_change | 1e-4 | 平移更新量低于该值提前退出 |
| gravity_align | true | 是否先用重力对齐 IMU 坐标系 |
sensor_param.yaml主要放 IMU 噪声密度、加速度计零偏、激光雷达线数和水平分辨率。这些参数会影响预处理中的 IMU 积分结果,设错会让优化残差虚高。如果你没有 IMU 内参,可以参考 datasheet 填,或者先用 Allan 方差跑一组数据出来。
# sensor_param.yaml 示例 imu: rate: 200.0 # IMU 频率 gyro_noise: 0.003 # 陀螺仪噪声密度 rad/s/sqrt(Hz) acc_noise: 0.05 # 加速度计噪声密度 m/s^2/sqrt(Hz) lidar: lines: 16 # 雷达线数 horizontal_res: 0.2 # 水平角度分辨率(度) range_min: 0.5 range_max: 100.0rate偏差会影响插值位置的准确性。如果 IMU 实际是 100Hz 却填 200,轨迹在时间轴上会被压缩一倍,残差会明显变大。我用rosbag info核实了实际频率后才填的。
4.3 执行标定并解读输出
启动之前先把配置按传感器改成自己的。然后跑启动文件:
roslaunch lidar_imu_calib calib.launchlaunch 文件里会依次启动数据回放节点和标定节点,默认是从 bag 离线读入。需要指定 bag 路径,例如:
<launch> <arg name="bag_path" default="/path/to/calib.bag"/> <node pkg="rosbag" type="play" name="rosbag_play" args="$(arg bag_path)"/> <node pkg="lidar_imu_calib" type="lidar_imu_calib_node" name="lidar_imu_calib_node" output="screen"/> </launch>标定节点会在所有消息回放完后进入优化流程。终端会打印每次迭代的残差、旋转欧拉角和平移向量。当看到残差从几百降到个位数,并且连续多次下降小于 0.01,说明收敛了。
输出文件calib_result.yaml长这样:
rotation: w: 0.9999 x: 0.0123 y: -0.0045 z: 0.0008 translation: x: -0.401 y: 0.032 z: 0.108 tf_prefix: "imu"这是从激光雷达到 IMU 坐标系的变换。把点云通过这个变换转到 IMU 坐标系后,应该和 IMU 轨迹重合。如果你需要反向变换,把四元数取逆、平移按旋转逆后再取负即可。
验证结果最简单的办法是用项目自带的verify_result.py跑一遍数据集,把变换后的点云发布成 topic,和未校正点云叠在一起看重影是否消失。脚本里统计的是重投影误差,如果你看到误差中位数在 0.1m 以上,先不要急着改可视化,回去检查数据质量。
4.4 常见参数调优经验
再补一些调参手感。voxel_leaf设太大会把细小的角点特征抹掉,优化结果会很平;太小时特征提取要算满整帧点云,CPU 占用直接拉满。我一般先设 0.1,看特征点数量在每帧 300 到 1000 之间就差不多。
IMU 噪声参数如果没标定,可以把积分噪声放大一点,让优化更相信点云几何约束。反过来,如果点云本身很噪,则减小 IMU 噪声权重,让位姿轨迹更平滑。这个 trade-off 在全自动标定里很难绕开,多试几组就有手感。
还有一点,如果雷达驱动发出的点云带运动畸变,要记得在上游打开补偿,否则特征点在边界处会弯曲,残差永远是弯的,标定结果偏移几十厘米也降不下来。运动补偿开关一般在驱动配置里,不在这份源码里,跑之前先确认。
5. 避坑指南:标定结果飘忽不定的五种典型原因
跑通这个项目不难,难的是跑通之后数据也录了、参数也改了,结果每次标出来的还不一样。我拆这项目时把五种翻车原因都撞了一遍,下面按现象、原因、解决写清楚。
5.1 时间戳没对齐,结果随机
现象:第一次标定旋转在 20 度左右,第二次给我 5 度,第三次又跳到 18 度,用的是同一份 bag。
原因:激光雷达和 IMU 话题的时间戳没有统一来源。激光雷达驱动给的是点云完帧时间,IMU 给的是各自的板载时钟,两者没有做任何同步,缓存插值就用上了错位的 IMU 数据。时间戳偏差只要几十毫秒,外参结果就会乱跳,这是全项目里最隐蔽的黑匣子。
解决:确保同一台机器上所有话题由同一个时钟驱动。没有硬件同步时,先rosbag record录制,再做软同步。缓存滑窗长度要能覆盖 IMU 帧间隔,同时检查点云时间戳是不是 GPS 时间,是的话要转成 Unix 时间。我后来在预处理里加了一个time_offset变量,在插值前对 IMU 时间戳加一个常数偏移,把时间对齐的代价函数也放进优化器,结果才压稳。如果你只改这一个变量,记得记录 bag 的起始时间和结束时间,用rosbag info验证。
5.2 运动激励不够,优化退化
现象:标定结果只更新了平移,旋转一直停在初始值,残差曲线到最后是平的。
原因:运动激励不足。手眼标定需要绕多个旋转轴转动,只围着 yaw 轴转,x、y 方向的旋转约束几乎没有,优化问题退化,旋转被压在初始位置。这个问题在手持设备上不明显,装在车上容易被忽略。
解决:采集数据时按 8 字型绕圈,或者手拿着设备在三个轴方向各转几圈。回放数据后可以用脚本统计各轴角速度标准差,如果最小那个轴的标准差小于 0.05 rad/s,建议重新采集。旋转激励越丰富,旋转外参的置信度才越高。同时把gravity_align打开,让 IMU 先对齐重力方向,能减少一个自由度的盲目搜索。
5.3 IMU 噪声方差设错,结果收敛到错误值
现象:优化残差下降很快,但外参离手量结果差得很远,换一段数据结果又变。
原因:sensor_param.yaml里的gyro_noise和acc_noise是占位默认值,没做 Allan 方差分析。优化器里数据项误差和先验误差的权重由这两个参数决定,设太大 IMU 轨迹被噪声迷惑,设太小优化器几乎不听 IMU 约束。
解决:用 30 分钟以上的静止数据跑 Allan 方差,把真实噪声密度填进去。没有条件的时候,先把噪声放大十倍,再用点云约束回拉,观察结果比之前稳再收紧。这是仅次于时间戳的第二大坑。你还可以把优化器的代价函数打印到文件,看残差按数据项还是先验项分布,如果先验占大头,说明噪声参数压得太死,点云约束起不了作用。
5.4 点云畸变不处理,标定后地图重影
现象:标定后的平移看起来在合理范围,但把点云拼到 IMU 轨迹后,转弯处出现前后两帧交叉重影。
原因:旋转雷达一帧数据不是瞬时完成的,从第一个到最后一个点之间有几十毫秒甚至上百毫秒时间差,车在这期间已经走了几十厘米。外参标定默认所有点时间戳相同,导致点云不同位置的点被投影到同一时刻的 IMU 位姿,产生畸变。
解决:在雷达驱动或上游节点对点云做运动补偿,用 IMU 积分把每帧点按各自相对时间插值到起始时间。项目在预处理里预留了comp_vel参数,默认关闭。打开后需要 IMU 频率不低于 100Hz,否则插值出来的姿态抖动反而更重。补偿公式很简单:p_comp = p_origin - T * (t_point - t_start) * v,关键是你得有可靠的瞬时速度和姿态增量。
5.5 初始外参太差,掉进局部最优
现象:手动量了一个初值,误差大约 5cm 和 2 度,跑出来的结果和预期接近但就差一点,无论怎么调迭代步长都回不来。
原因:手眼标定初始化在数据质量差时会失败,项目有时会退回到读默认初值。默认旋转大多设为 0,如果实际安装角接近 90 度,Ceres 的雅可比在四元数 0 附近有奇异点,优化直接卡在局部最优。
解决:先按机械设计图把初始旋转调到 10 度以内,再交给项目精修。或者在配置里把init_rot_deg填上三个轴的粗略角度,不要用默认零值。我的经验是旋转初值误差不超过 15 度、平移初值误差不超过 5cm 时,这个项目基本都能收敛到差 0.01 度级别。如果还不放心,可以在优化器里开启 Ceres 的 debug 输出,观察每次迭代后的梯度范数,梯度范数长时间不变就是陷在局部最优里了。
6. 二次开发:从固定标定到自适应参数调优
6.1 换传感器的第一步:改配置而不是改代码
换传感器时,先看接口是否能复用。激光雷达从 16 线换成 64 线,主要改horizontal_res和range_max,算法本身不变。IMU 换型号,优先改噪声参数。只有时间同步和话题名变化才需要动 main.cpp 的订阅回调。
6.2 给 Ceres 加一个 Huber 鲁棒核
默认最小二乘里,一个离群点会让残差爆炸。我在优化器里加了一个ceres::HuberLoss(0.5),把距离超过 0.5m 的残差当成异常值压住。改动只有两行:
ceres::Problem problem; ceres::LossFunction* loss = new ceres::HuberLoss(0.5); problem.AddResidualBlock(cost_function, loss, rotation_, translation_);这样动态障碍物边缘的离群点不会拖垮外参。鲁棒核的阈值要和点云噪声匹配,设太大起不到作用,设太小会把正常的几何约束也压掉。
6.3 把结果发布成静态 TF
为了让定位模块直接使用标定结果,我在优化结束之后加了一个静态 TF 发布函数:
tf2_ros::StaticTransformBroadcaster br; geometry_msgs::TransformStamped ts; ts.header.frame_id = "imu"; ts.child_frame_id = "lidar"; ts.transform.rotation = result.rotation; ts.transform.translation = result.translation; br.sendTransform(ts);注意 frame_id 是 imu,child_frame_id 是 lidar,代表从 lidar 到 imu 的变换。别把两个坐标系写反,否则定位模块拿到后感知会反向错一半。
6.4 自检脚本:每次标定完都强制跑一遍
verify_result.py 会把标定结果加载进来,统计重投影误差的中位数和 95% 分位,如果中位数大于 0.1m 直接打印警告。从那以后我每次标完都强制走一遍自检脚本,误差大了先回去查时间戳和运动激励,而不是继续试参数。这套习惯救了我好几次,希望帮到你。
本文还有配套的精品资源,点击获取