前言 【10天速通ROS2-PX4无人机】(二) FAST-LIO2 仿真部署,200HZ IMU前推的无限魅力 【FAST-LIO2 源码解读】从 Mid-360 到 ESEKF — 公式与代码逐行对照 刚好前两期我们都完成了FAST-LIO2的仿真部署与源码解读 最近在机器狗项目中遇到一个问题:Mid-360雷达不是水平安装的,而是以45° 倾斜 固定在机器狗前方——这样做是为了让雷达能看到更多地面和前方障碍物 但直接打开FAST-LIO2后出现了三个诡异现象:里程计一直在飞(Z 轴不断升高)、Rviz 里狗和路径都歪了 45°、直走还凑合,一转弯就 “升天” 或 “入地” 本文记录这个问题的根因分析、方案对比、以及最终落地的输入端旋转方案 文章目录 前言 1 问题描述 2 根因分析 2-1 坐标系关系 2-2 为什么 `extrinsic_R` 救不了 2-3 方案对比 3 解决方法 3-1 IMU 坐标转换节点 3-2 点云坐标转换节点 3-3 踩坑:旋转方向 4 配置与验证 总结 1 问题描述 机器狗前面放置的雷达是倾斜 45 度放置的,这样是为了能够看到更多的地面特征 在URDF中,laser_livox(雷达)通过joint挂在base上: <!-- robot.xacro --> < jointname = " laser_livox_joint" type = " fixed" > < originxyz = " 0.2 0 0.08" rpy = " 0 0.785 0" /> < parentlink = " base" /> < childlink = " laser_livox" /> </ joint> < jointname = " livox_imu_joint" type = " fixed" > < parentlink = " laser_livox" /> < childlink = " livox_imu_link" /> < originxyz = " -0.011 -0.02329 0.04412" rpy = " 0 0 0" /> </ joint> base (水平, Z朝上) └── laser_livox (rpy=0, 0.785, 0 → +45° pitch) └── livox_imu_link (rpy=0, 0, 0 → 同姿态)说人话:base是水平的,雷达和 IMU 都固定在同一块板子上,整块板子前倾 45°,所以雷达和 IMU 的坐标系都是斜的
如果这个时候直接使用默认配置打开FAST-LIO2,会出现严重问题:狗往前走,里程计 Z 轴持续升高——走几米就 “飞” 到几米高 狗和路径都倾斜 45°,世界坐标系却是正的 直走勉强能看,转弯就 “升天” 或 “入地”
2 根因分析 2-1 坐标系关系 FAST-LIO2的body帧 = IMU 所在的坐标系我们的body被设成base(水平),但 IMU 数据实际在livox_imu_link(倾斜 45°) echo一下 IMU 数据就能看到问题:rostopicecho /livox/imu| grep linear_acceleration# 水平安装应该是 (0, 0, -9.8),但实际是: # x: -6.8, y: 0.0, z: 7.1 ← 重力被劈成了 X 和 Z 两个分量! FAST-LIO2启动时读 IMU 重力方向来初始化姿态。它读到(-6.8, 0, 7.1),以为 “body 是倾斜的”,于是把base姿态转了 ~114° 去 “对齐” 重力说人话:FAST-LIO2以为狗是歪着站的,所以把整个odom → base的 TF 转歪了 2-2 为什么extrinsic_R救不了 extrinsic_R是 LiDAR 相对于 IMU 的旋转。在Mid-360内部,IMU 和 LiDAR 是同一个物理封装,它们之间没有相对旋转 45° 是整个传感器包 相对于机器人base的倾斜,不是 IMU 和 LiDAR 之间的倾斜 > extrinsic_R只管 IMU↔LiDAR,管不了传感器包↔base。这个问题超过了它的管辖范围2-3 方案对比 网上搜索了倾斜安装的标准做法,三种候选方案对比如下: 方案 做法 效果 适用场景 改 extrinsic_R 把倾斜角写入 LiDAR↔IMU 外参 只适用于 LiDAR 单独倾斜、IMU 水平 IMU 外置 输出端静态 TF 在 FAST-LIO2 输出后加 TF “掰正” 直走还行,转弯飞 临时凑合 输入端旋转 IMU 和点云进 FAST-LIO2 之前就转到 base 系 里外都对 一体式传感器倾斜
核心原理一句话:FAST-LIO2的body帧 = IMU 坐标系。只要 IMU 数据在水平系,body就是水平的 我们选择输入端旋转 方案——在 IMU 和点云进入FAST-LIO2之前,都把数据转到base坐标系下: /livox/imu (倾斜 livox_imu_link) → imu_transform → /livox/imu_base (水平 base) /scan (倾斜 laser_livox) → scan_to_pointcloud2 → /livox/lidar (水平 base)转换后,FAST-LIO2保持完全默认配置:extrinsic_R = identity,extrinsic_T = [0,0,0],body = base 3 解决方法 3-1 IMU 坐标转换节点 订阅/livox/imu,查询TF: base → livox_imu_link的旋转矩阵,用R_y(+45°)旋转加速度和角速度,发布到/livox/imu_base(frame_id = "base") // imu_transform.cpp # include <ros/ros.h> # include <sensor_msgs/Imu.h> # include <tf/transform_listener.h> # include <tf/transform_datatypes.h> class ImuTransform { public : ImuTransform ( ) { ros:: NodeHandle nh; sub_= nh. subscribe ( "/livox/imu" , 200 , & ImuTransform:: callback, this ) ; pub_= nh. advertise < sensor_msgs:: Imu> ( "/livox/imu_base" , 200 ) ; } private : void callback ( const sensor_msgs:: Imu:: ConstPtr& msg) { tf:: StampedTransform transform; try { listener_. lookupTransform ( "base" , "livox_imu_link" , msg-> header. stamp, transform) ; } catch ( tf:: TransformException& e) { ROS_WARN_THROTTLE ( 1.0 , "imu_transform TF error: %s" , e. what ( ) ) ; return ; } sensor_msgs:: Imu out= * msg; out. header. frame_id= "base" ; // R_base→imu = R_y(45°), 直接用 getBasis() 旋转到 base 系 tf:: Matrix3x3 R_imu_to_base= transform. getBasis ( ) ; tf:: Vector3acc_in ( msg-> linear_acceleration. x, msg-> linear_acceleration. y, msg-> linear_acceleration. z) ; tf:: Vector3 acc_out= R_imu_to_base* acc_in; out. linear_acceleration. x= acc_out. x ( ) ; out. linear_acceleration. y= acc_out. y ( ) ; out. linear_acceleration. z= acc_out. z ( ) ; tf:: Vector3ang_in ( msg-> angular_velocity. x, msg-> angular_velocity. y, msg-> angular_velocity. z) ; tf:: Vector3 ang_out= R_imu_to_base* ang_in; out. angular_velocity. x= ang_out. x ( ) ; out. angular_velocity. y= ang_out. y ( ) ; out. angular_velocity. z= ang_out. z ( ) ; pub_. publish ( out) ; } ros:: Subscriber sub_; ros:: Publisher pub_; tf:: TransformListener listener_; } ; 3-2 点云坐标转换节点 订阅/scan(sensor_msgs/PointCloud),查询TF: base → laser_livox,对每个点做p_base = R * p_lidar + T,加入intensity字段(FAST-LIO2内部走pcl::PointXYZI),发布为sensor_msgs/PointCloud2,frame_id = "base" // scan_to_pointcloud2.cpp # include <ros/ros.h> # include <sensor_msgs/PointCloud.h> # include <sensor_msgs/PointCloud2.h> # include <sensor_msgs/point_cloud2_iterator.h> # include <tf/transform_listener.h> class ScanToPointCloud2 { public : ScanToPointCloud2 ( ) { ros:: NodeHandle nh; sub_= nh. subscribe ( "/scan" , 10 , & ScanToPointCloud2:: callback, this ) ; pub_= nh. advertise < sensor_msgs:: PointCloud2> ( "/livox/lidar" , 10 ) ; } private : void callback ( const sensor_msgs:: PointCloud:: ConstPtr& msg) { tf:: StampedTransform T_base_lidar; try { listener_. lookupTransform ( "base" , "laser_livox" , msg-> header. stamp, T_base_lidar) ; } catch ( tf:: TransformException& e) { ROS_WARN_THROTTLE ( 1.0 , "scan_to_pointcloud2 TF: %s" , e. what ( ) ) ; return ; } sensor_msgs:: PointCloud2 cloud2; cloud2. header= msg-> header; cloud2. header. frame_id= "base" ; cloud2. height= 1 ; cloud2. width= msg-> points. size ( ) ; cloud2. is_bigendian= false ; cloud2. is_dense= true ; sensor_msgs:: PointCloud2Modifiermodifier ( cloud2) ; modifier. setPointCloud2Fields ( 4 , "x" , 1 , sensor_msgs:: PointField:: FLOAT32, "y" , 1 , sensor_msgs:: PointField:: FLOAT32, "z" , 1 , sensor_msgs:: PointField:: FLOAT32, "intensity" , 1 , sensor_msgs:: PointField:: FLOAT32) ; modifier. resize ( msg-> points. size ( ) ) ; sensor_msgs:: PointCloud2Iterator< float > iter_x ( cloud2, "x" ) ; sensor_msgs:: PointCloud2Iterator< float > iter_y ( cloud2, "y" ) ; sensor_msgs:: PointCloud2Iterator< float > iter_z ( cloud2, "z" ) ; sensor_msgs:: PointCloud2Iterator< float > iter_i ( cloud2, "intensity" ) ; tf:: Matrix3x3 R= T_base_lidar. getBasis ( ) ; tf:: Vector3 T= T_base_lidar. getOrigin ( ) ; for ( const auto & pt: msg-> points) { tf:: Vector3p ( pt. x, pt. y, pt. z) ; tf:: Vector3 q= R* p+ T; * iter_x= q. x ( ) ; ++ iter_x; * iter_y= q. y ( ) ; ++ iter_y; * iter_z= q. z ( ) ; ++ iter_z; * iter_i= 0.0 ; ++ iter_i; } pub_. publish ( cloud2) ; } ros:: Subscriber sub_; ros:: Publisher pub_; tf:: TransformListener listener_; } ; 3-3 踩坑:旋转方向 这是我们花费时间最多的一个坑 lookupTransform("base", "livox_imu_link", ...)返回T_base→imu,其getBasis()=R_base→imu = R_y(+45°)最开始用了transform.getBasis().inverse()(=R_y(-45°)),加速度从(-6.8, 0, 7.1)转成了(0.1, 0, 9.8)——看起来 Z 分量接近 9.8,似乎对了,但角速度方向是反的 改用transform.getBasis()直接乘(=R_y(+45°)),角速度才转对。验证方法:原地转弯,只有 Z 轴角速度变化——这才是正确的 > 核心教训:旋转方向必须用纯 Z 轴旋转来验证。直走测试不够——直走只考验平移分量,转弯才考验角速度方向4 配置与验证 因为输入数据已经在base坐标系下,FAST-LIO2的外参保持默认即可: # config/sim.yaml common : lid_topic : "/livox/lidar" imu_topic : "/livox/imu_base" # 注意:走转换后的 topic preprocess : lidar_type : 4 # MARSIM(仿真 PointCloud2) scan_line : 4 blind : 0.1 mapping : acc_cov : 0.1 gyr_cov : 0.1 b_acc_cov : 0.0001 b_gyr_cov : 0.0001 fov_degree : 360 det_range : 100.0 extrinsic_est_en : false # 输入端已对齐,关闭在线估计 extrinsic_T : [ 0.0 , 0.0 , 0.0 ] extrinsic_R : [ 1 , 0 , 0 , 0 , 1 , 0 , 0 , 0 , 1 ] 参数 值 含义 lidar_type4 MARSIM 模式,走标准PointCloud2 imu_topic/livox/imu_base指向 IMU 转换后的水平坐标系 blind0.1 仿真环境特征稀疏,减小盲区保留更多点 extrinsic_est_enfalse 输入端已对齐,关闭在线估计 extrinsic_T/R零/单位阵 外参保持 Mid-360 默认
<!-- launch/mapping_sim.launch --> < launch> < nodepkg = " fast_lio" type = " scan_to_pointcloud2" name = " scan_to_pointcloud2" output = " screen" /> < nodepkg = " fast_lio" type = " imu_transform" name = " imu_transform" output = " screen" /> < nodepkg = " fast_lio" type = " odom_2d" name = " odom_2d" output = " screen" /> < rosparamfile = " $(find fast_lio)/config/sim.yaml" command = " load" /> < nodepkg = " fast_lio" type = " fastlio_mapping" name = " laserMapping" output = " screen" /> </ launch> # 确认 IMU 重力只在 Z 轴 rostopicecho /livox/imu_base| grep linear_acceleration# 预期:x≈0, y≈0, z≈9.8
总结 本文从 Mid-360 倾斜安装的实际问题出发,分析了坐标系关系、对比了三种方案,最终落地为输入端双旋转 方案 核心要点回顾:extrinsic_R只管 LiDAR↔IMU,管不了整个传感器包的倾斜输入端旋转(IMU + 点云都转到 base 系)是从根源解决倾斜问题的正确方式 getBasis()的方向必须用纯 Z 轴旋转来验证——直走测试不够FAST-LIO2的body帧 = IMU 所在坐标系。只要 IMU 数据在水平系,body就是水平的 如有错误,欢迎指出! 感谢观看!