当你第一次听说LiDAR和IMU数据融合时,可能会觉得这是两个完全不相干的传感器。LiDAR通过激光扫描获取周围环境的精确三维点云,而IMU(惯性测量单元)则通过加速度计和陀螺仪测量设备的运动状态。但正是这种看似不相关的特性,让它们的结合产生了1+1>2的效果。
我在自动驾驶项目中第一次接触这个技术组合时,发现单独使用LiDAR存在一个致命问题:运动畸变。当车辆快速移动时,LiDAR扫描需要时间(通常是100ms左右),在这段时间内车辆已经发生了位移,导致点云出现"拖影"现象。就像用手机拍摄快速移动的物体时出现的模糊效果。这时候IMU的高频运动数据(通常200Hz以上)就派上用场了,它能帮助我们还原LiDAR每个激光点采集时的真实位置。
另一个常见场景是隧道或地下停车场,这些地方GPS信号弱或者完全丢失。我们做过实测,仅依赖LiDAR的定位在5分钟内就会产生超过2米的漂移。而融合IMU数据后,即使在15分钟无GPS的情况下,定位误差也能控制在30厘米以内。这得益于IMU在短时间内的运动预测非常准确,虽然长时间会有累积误差,但正好与LiDAR的特性互补。
数据同步是融合的第一步,也是最容易出问题的环节。在实际项目中,我们遇到过各种奇怪的bug,最后发现80%都源于同步没做好。LiDAR和IMU通常使用不同的时钟源,即使硬件上做了时间同步,软件处理时也需要注意很多细节。
以常见的Velodyne LiDAR和Xsens IMU组合为例,我们的代码中会维护两个缓存队列:
cpp复制std::deque<sensor_msgs::PointCloud2::ConstPtr> lidar_buffer;
std::deque<sensor_msgs::Imu::ConstPtr> imu_buffer;
同步的核心逻辑是找到一帧LiDAR数据,以及这帧数据采集期间的所有IMU数据。这里有个关键时间点需要特别注意——LiDAR的结束时间戳(lidar_end_time)。因为LiDAR是旋转扫描的,最后一个点的采集时间才是整帧的完整时间参考。
我们实现的同步函数大致是这样的流程:
在实际部署时,我们发现时间补偿(timediff_imu_wrt_lidar)参数特别重要。这个参数表示IMU时钟相对于LiDAR时钟的偏差,即使是同一厂商的设备,这个值也可能达到毫秒级。我们的做法是在系统启动时,通过统计前100组数据的时间差中位数来自动计算这个值。
运动畸变矫正是LiDAR数据处理中最吃计算资源的环节之一。我们曾经在Jetson Xavier上测试,一帧包含10万个点的点云,矫正需要约50ms,这对于实时性要求高的场景是个挑战。
矫正的核心思想很简单:知道每个激光点采集时的精确位姿,就能把它投影到统一坐标系下。但实现起来需要考虑很多工程细节。我们的ImuProcess类中主要包含以下几个关键函数:
cpp复制void UndistortPcl(const MeasureGroup &meas, PointCloudXYZI &pcl_out);
void Propagate(const MeasureGroup &meas, StatesGroup &state);
Propagate函数负责IMU的前向传播,使用IMU的角速度和加速度积分计算位姿变化。这里采用了中值积分法来平衡精度和计算量:
cpp复制// 中值积分示例代码
void midPointIntegration(double dt,
const Eigen::Vector3d &acc_0, const Eigen::Vector3d &gyr_0,
const Eigen::Vector3d &acc_1, const Eigen::Vector3d &gyr_1,
Eigen::Vector3d &delta_p, Eigen::Vector3d &delta_v,
Eigen::Quaterniond &delta_q) {
Eigen::Vector3d un_acc_0 = delta_q * (acc_0 - linearized_ba);
Eigen::Vector3d un_gyr = 0.5 * (gyr_0 + gyr_1) - linearized_bg;
delta_q = delta_q * Eigen::Quaterniond(1, un_gyr(0)*dt/2, un_gyr(1)*dt/2, un_gyr(2)*dt/2);
Eigen::Vector3d un_acc_1 = delta_q * (acc_1 - linearized_ba);
Eigen::Vector3d un_acc = 0.5 * (un_acc_0 + un_acc_1);
delta_p += delta_v * dt + 0.5 * un_acc * dt * dt;
delta_v += un_acc * dt;
}
UndistortPcl函数则负责将原始点云根据IMU积分结果进行矫正。这里有个优化技巧:不是对每个点都做完整的位姿变换,而是先对点云按时间排序,然后对相邻点使用相同的变换矩阵,这样可以减少约40%的计算量。
位姿估计是整个系统的核心,我们采用了基于ikd-Tree的迭代最近点(ICP)算法结合卡尔曼滤波的方案。ikd-Tree相比传统KD-Tree有两个显著优势:支持增量更新和并行查询,这对实时系统至关重要。
构建地图时,我们维护了一个局部地图(local map)的概念,只保留当前位置周围一定范围内的点云。这显著降低了内存占用和计算量。当车辆移动时,会动态更新地图边界:
cpp复制void lasermap_fov_segment() {
// 计算当前位置到地图边界的距离
float dist_to_map_edge = min(
min(pos.x - local_map_center.x + max_x, local_map_center.x + max_x - pos.x),
min(pos.y - local_map_center.y + max_y, local_map_center.y + max_y - pos.y));
// 当距离小于阈值时更新地图
if(dist_to_map_edge < 1.5 * det_range) {
// 计算需要删除的边界
cub_needrm.clear();
// 更新局部地图中心
local_map_center = pos;
// 标记需要删除的区域
...
}
}
在匹配阶段,我们对降采样后的点云进行多线程处理。每个线程负责一部分点的最近邻搜索和平面拟合。这里有个关键参数是搜索半径,我们通过实验发现5米是个比较好的折衷值——既能保证足够的约束,又不会引入太多噪声。
卡尔曼滤波的更新步骤中,测量雅可比矩阵H的计算方式会根据是否使用IMU而有所不同。当IMU数据可靠时,我们会利用IMU提供的角速度信息来改善状态预测:
cpp复制void calcBodyVar(const StatesGroup &state, Eigen::MatrixXd &H) {
if(use_imu) {
// 使用IMU数据计算H矩阵
H.block<3,3>(0,0) = -skewSymmetric(state.rot_end * state.vel);
H.block<3,3>(0,3) = Eigen::Matrix3d::Identity();
} else {
// 不使用IMU的简化版本
H.block<3,3>(0,0) = Eigen::Matrix3d::Zero();
H.block<3,3>(0,3) = Eigen::Matrix3d::Identity();
}
}
我们在实际部署中发现,当车辆进行急转弯时,使用IMU的版本能显著提高定位精度,因为LiDAR在快速旋转时点云质量会明显下降。
经过多个项目的迭代,我们总结出几个关键的性能优化点。首先是点云降采样策略,直接影响到后续所有环节的计算量。我们采用了体素网格滤波结合曲率筛选的方法:
cpp复制void downSample(const PointCloudXYZI &pl_in, PointCloudXYZI &pl_out) {
// 体素网格滤波
pcl::VoxelGrid<PointType> voxel;
voxel.setLeafSize(0.2, 0.2, 0.2);
voxel.setInputCloud(pl_in.makeShared());
voxel.filter(pl_out);
// 曲率筛选(保留特征明显的点)
std::vector<int> indices;
pcl::removeNaNFromPointCloud(pl_out, pl_out, indices);
std::sort(pl_out.points.begin(), pl_out.points.end(),
[](const PointType &a, const PointType &b) {
return a.curvature > b.curvature;
});
if(pl_out.size() > max_points) {
pl_out.points.resize(max_points);
}
}
第二个优化点是ikd-Tree的动态更新策略。我们发现完全重建KD-Tree的开销太大,而增量更新又可能导致树结构不平衡。最终采用的方案是:
内存管理方面,我们使用内存池来避免频繁的内存分配释放。特别是对于点云数据,预分配大块内存可以显著减少系统延迟。
在多线程实现上,我们采用了生产者-消费者模式。一个线程专门负责数据采集和预处理,另一个线程进行位姿估计,第三个线程处理地图更新和可视化。这种架构在8核处理器上能达到最佳性能平衡。
在实际部署中,我们遇到过各种奇怪的问题。最常见的是时间同步异常,表现为定位结果出现周期性跳动。这类问题通常有以下几个原因:
我们的调试方法是记录原始数据和时间戳,然后用离线工具重放。一个实用的技巧是在数据包头添加接收时间戳和硬件时间戳两个字段,这样能直观看到延迟情况。
另一个常见问题是IMU初始化不良导致的定位漂移。我们发现IMU的bias估计对最终精度影响很大。现在采用的初始化流程是:
当环境特征不足时(如长隧道),系统容易发散。我们增加了几个鲁棒性措施:
调试时最实用的工具是RViz和PlotJuggler。我们把关键变量(如匹配点数、位姿协方差、计算耗时等)实时发布出来,通过可视化能快速定位问题所在。