做无人机offboard控制的朋友,十有八九都遇到过这种灵异事件:代码逻辑看着没毛病,setpoint也发了,可飞机要么原地画圈,要么朝着反方向冲,运气差一点的悬停一给目标点就往下砸。排查半天没发现参数问题,最后才发现是坐标系搞错了。PX4内部用的是NED和FRD,到了Mavros这一层变成了ENU和FLU,中间如果没做转换,你发出去的每一个目标点都是歪的。这篇文章就围绕setpoint_raw/local这个话题,把坐标系换算、消息字段、发布代码、避坑经验一次讲清楚。不管是刚搭好PX4仿真环境的新手,还是已经上手offboard但被坐标系折磨过的开发者,这篇应该都能帮上忙。
1. 坐标系基础与Mavros的坐标系约定
1.1 NED与ENU:地面参考系的差异
无人机导航里最常用的两个地面参考系就是NED和ENU。NED是North-East-Down,X轴指向北,Y轴指向东,Z轴指向下,这是一个右手坐标系,但Z轴朝下这个设定跟很多人日常习惯的“高度向上为正”是反着的。PX4内部的位置控制、航点规划、状态估计,全部跑在NED里。
ENU是East-North-Up,X轴指向东,Y轴指向北,Z轴指向上。这是ROS社区非常喜欢的坐标系,rviz里默认显示用的就是ENU,move_base、AMCL这些导航模块也都默认ENU。Mavros为了让ROS侧的数据使用体验更统一,几乎所有的local话题都采用ENU约定。
这两个坐标系之间并不是简单的“Z取反”就算完,X和Y轴也要互换。很多刚接触的人只把高度取负,结果水平方向全乱套。转换关系很直接:ENU的X等于NED的Y,ENU的Y等于NED的X,ENU的Z等于NED的Z取负。这个映射关系是基本功,下面会反复用到。
1.2 FRD与FLU:机体系的左右手分歧
地面参考系搞清楚之后,机体系同样存在两套约定。FRD是X轴朝机头前方、Y轴朝右、Z轴朝下的右手坐标系,固定翼和很多飞控内部都在用,PX4的姿态控制、角速度控制、机体速度估计全部用FRD。FLU是X轴朝前、Y轴朝左、Z轴朝上的坐标系,更符合机器人领域对“机体坐标”的习惯,ROS里的很多机载传感器、相机坐标系、机械臂基座都是这类约定。
FRD和FLU之间最本质的区别是Y轴和Z轴同时取反,等价于绕机体X轴旋转180度。这一点很多人容易忽略,以为只是Y取反,实际上Z也必须跟着变。
1.3 setpoint_raw/local到底以哪个坐标系为准
/mavros/setpoint_raw/local发布的是mavros_msgs/PositionTarget消息。这个topic属于Mavros里的“raw”系列,很多教程会告诉你它对应MAVLink的SET_POSITION_TARGET_LOCAL_NED消息。于是问题就来了:Mavros这边的local到底是NED还是ENU?
答案:setpoint_raw/local在设计上以Mavros的统一local坐标系为准,也就是ENU。Mavros内部收到消息后,会把你给的ENU位置、速度、加速度转换成NED,再塞进MAVLink消息发给PX4。所以在ROS侧发布话题时,位置数据应该按ENU填,同时把消息里的coordinate_frame字段设置成MAV_FRAME_LOCAL_ENU(常量值4)。
这里有个容易踩坑的细节:有些老教程直接把coordinate_frame设成MAV_FRAME_LOCAL_NED然后填入NED数据,这在某些Mavros版本里碰巧能用,但有不少人会遇到数据被二次转换导致位置错乱的情况。为了避免版本差异带来的不确定性问题,我强烈建议统一按ENU方向来写,不要自己先转成NED再去投机取巧。
另外要注意,setpoint_raw/local里的姿态信息只有yaw这一个标量,并没有完整四元数。如果你需要发完整姿态,应该用/mavros/setpoint_attitude/attitude这一路topic。这篇文章聚焦setpoint_raw/local,所以姿态部分主要讲yaw,完整四元数从FRD到FLU的转换方法我也会给出,作为需要发姿态时的参考。
2. 从PX4坐标系到Mavros坐标系的换算方法
2.1 位置坐标:NED转ENU的x/y/z映射
位置换算非常简单,不需要矩阵,不需要四元数,一行代码就能搞定。假设你从PX4的航点、日志或者某个NED坐标系的外部模块拿到目标点(x_ned, y_ned, z_ned),转换到ENU就是:
| NED | ENU | 说明 |
|---|---|---|
| x (北) | y | ENU的Y轴朝北 |
| y (东) | x | ENU的X轴朝东 |
| z (下) | -z | ENU的Z轴朝上 |
很多实际事故都出在这个最简单的映射上。有人只把z取负,x和y原样填,结果想让飞机朝北飞,它朝东冲;想让飞机朝东飞,它朝北冲,这就是典型的x/y没交换。
2.2 姿态四元数:从FRD/NED到FLU/ENU的完整变换
如果你的控制里需要发送完整姿态,比如对接setpoint_attitude/attitude,那就必须处理四元数从FRD/NED到FLU/ENU的变换。这个变换相比位置转换要复杂不少,很多资料里只写了“参考坐标系从NED变成ENU”这一半,忽略了“机体系从FRD变成FLU”另一半,导致算出来的姿态有时候对有时候歪。
完整公式是这样的:如果q_frd_ned表示FRD机体系相对于NED的四元数,那么转换到FLU/ENU的四元数可以写为:
q_flu_enu = q_T ⊗ q_frd_ned ⊗ q_S其中:
q_T = (0, √2/2, √2/2, 0) // NED参考系到ENU参考系 q_S = (0, 1, 0, 0) // FRD机体系到FLU机体系注意四元数乘法的顺序,左边是参考系变换,右边是机体系变换。Eigen里面初始化的顺序是(w, x, y, z),也就是:
Eigen::Quaterniond q_T(0.0, 0.7071067811865476, 0.7071067811865476, 0.0); Eigen::Quaterniond q_S(0.0, 1.0, 0.0, 0.0); Eigen::Quaterniond q_flu_enu = q_T * q_frd_ned * q_S;为什么不直接左乘一个q_T就完事?因为q_T本身只处理参考坐标系从NED变到ENU,但身体的轴还定义在FRD上。FRD和FLU之间差了绕机体X轴180度,所以还要额外右乘一个q_S。两个变换叠加才是完整结果。
验证一下这个公式。假设飞机机头朝北,在NED坐标系下机体和地面参考系完全重合,q_frd_ned = (1,0,0,0)。按公式计算得到q_flu_enu约等于(√2/2, 0, 0, √2/2),这正好是绕ENU的Z轴转90度的四元数。机头朝北在ENU下看,就是机体X轴指向Y轴正方向,绕Z轴偏航90度,完全吻合。
Mavros的mavros::ftf库里其实已经封装了类似函数,比如transform_frame_ned_enu_quat和transform_frame_frd_flu_quat,底层实现大致就是左乘q_T和右乘q_S的逻辑。如果你只是想在ROS节点里快速转换,直接调用Mavros的ftf是省事的路子;如果想摆脱对Mavros库的依赖,上面这段Eigen代码完全够用。
2.3 yaw字段的特别处理:不被坐标frame“同化”
setpoint_raw/local消息里的coordinate_frame字段设成ENU后,你会发现位置、速度、加速度都会被Mavros转换,但yaw字段并不会被“同化”成ENU下的偏航角。MAVLink的SET_POSITION_TARGET_LOCAL_NED消息定义中,yaw字段本身就是“机头相对NED北向顺时针为正的偏航角”,它是一个独立于位置frame的数值。
换句话说,setpoint_raw/local里的yaw永远按NED/FRD的语义来填。即便你在发布消息时把coordinate_frame设为MAV_FRAME_LOCAL_ENU,yaw依然要传NED语义下的角度。这一点和位置是完全不同的规则,也是最容易踩的认知误区。
那什么情况下需要做yaw转换呢?如果你期望的航向是从RViz、tf或者某个ENU坐标系里量出来的角度,那确实得做一次转换:
yaw_ned = M_PI_2 - yaw_enu举个例子,你想让机头朝东。在ENU坐标系里,东是X轴正方向,对应的yaw_enu是0度;但NED坐标系里顺时针转90度才到东,所以yaw_ned是90度,也就是π/2。再比如机头朝北,ENU下yaw_enu是90度,NED下yaw_ned是0度。这个对应关系大家可以在纸上画一画,很清晰。
所以,做目标点发布的时候,你一定要问自己一个问题:这个期望航向是从哪来的?如果来自PX4的航点或底层日志,直接透传yaw;如果来自ROS侧ENU坐标系的规划结果,先转成yaw_ned再填进消息。
3. 实际配置setpoint_raw/local发布目标点
3.1 PositionTarget消息结构与type_mask设置
mavros_msgs/PositionTarget消息里常用字段如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| header | std_msgs/Header | 时间戳和frame_id |
| coordinate_frame | uint8 | 位置/速度所在坐标系,LOCAL_ENU=4 |
| type_mask | uint16 | 哪些通道生效,哪些通道忽略 |
| position | geometry_msgs/Point | 目标位置 |
| velocity | geometry_msgs/Vector3 | 目标速度 |
| acceleration | geometry_msgs/Vector3 | 目标加速度 |
| yaw | float32 | 目标偏航角 |
| yaw_rate | float32 | 目标偏航角速度 |
type_mask是整个消息里最容易被忽略但影响巨大的字段。它的每一位表示某个量是否被忽略。对于“只发位置+偏航”的目标点,需要把速度、加速度、yaw_rate全部忽略,同时确保X/Y/Z和YAW没有被忽略。
常用的掩码如下:
static const uint16_t TYPE_MASK_POS_YAW_ONLY = (1 << 3) | (1 << 4) | (1 << 5) | // 忽略 VX VY VZ (1 << 6) | (1 << 7) | (1 << 8) | // 忽略 AX AY AZ (1 << 11); // 忽略 YAW_RATE这个值的十进制是2552,十六进制是0x9F8。如果你在代码里看到某个setpoint位置正确但飞机不按位置走,多半就是type_mask里不小心把X或Y或Z也给ignore了,或者没有忽略速度导致位置和速度控制同时生效,飞控内部打架,姿态抖动。
3.2 C++发布节点:完整可编译示例
我习惯把坐标系转换封装成一个独立函数,这样后面排查问题可以单独测这个函数,不用每次对着业务逻辑找bug。下面这段是一个可用的C++发布节点核心部分:
#include <ros/ros.h> #include <mavros_msgs/PositionTarget.h> #include <cmath> static const uint16_t TYPE_MASK_POS_YAW_ONLY = (1 << 3) | (1 << 4) | (1 << 5) | (1 << 6) | (1 << 7) | (1 << 8) | (1 << 11); mavros_msgs::PositionTarget makeLocalTarget( double x_ned, double y_ned, double z_ned, double yaw_ned) { mavros_msgs::PositionTarget sp; sp.header.stamp = ros::Time::now(); sp.coordinate_frame = mavros_msgs::PositionTarget::MAV_FRAME_LOCAL_ENU; // NED -> ENU sp.position.x = y_ned; // NED的东( Y ) -> ENU的东( X ) sp.position.y = x_ned; // NED的北( X ) -> ENU的北( Y ) sp.position.z = -z_ned; // NED的下( Z ) -> ENU的上( -Z ) // yaw 保持 NED 语义 sp.yaw = yaw_ned; sp.type_mask = TYPE_MASK_POS_YAW_ONLY; return sp; } int main(int argc, char** argv) { ros::init(argc, argv, "setpoint_raw_local_pub"); ros::NodeHandle nh; ros::Publisher pub = nh.advertise<mavros_msgs::PositionTarget>( "/mavros/setpoint_raw/local", 1); ros::Rate rate(30.0); // 示例:目标点是起飞点正北5m、正东3m、高度4m,机头朝东 // NED坐标:北5,东3,下-4,yaw=π/2 double x_ned = 5.0; double y_ned = 3.0; double z_ned = -4.0; double yaw_ned = M_PI / 2.0; while (ros::ok()) { auto sp = makeLocalTarget(x_ned, y_ned, z_ned, yaw_ned); pub.publish(sp); ros::spinOnce(); rate.sleep(); } return 0; }发布频率方面,我在仿真和真机上都试过,offboard模式下20Hz算是底线,30Hz比较稳妥,50Hz也没有问题。超过100Hz意义不大,还会增加不必要的消息流量。
3.3 Python发布节点:轻量快速验证
仿真阶段用Python做快速验证是最舒服的,不用编译,改参数重启也快。下面是一个对应的发布节点:
#!/usr/bin/env python3 import rospy from mavros_msgs.msg import PositionTarget TYPE_MASK_POS_YAW_ONLY = ( (1 << 3) | (1 << 4) | (1 << 5) | (1 << 6) | (1 << 7) | (1 << 8) | (1 << 11) ) def make_target(x_ned, y_ned, z_ned, yaw_ned): sp = PositionTarget() sp.header.stamp = rospy.Time.now() sp.coordinate_frame = PositionTarget.MAV_FRAME_LOCAL_ENU sp.position.x = y_ned sp.position.y = x_ned sp.position.z = -z_ned sp.yaw = yaw_ned sp.type_mask = TYPE_MASK_POS_YAW_ONLY return sp if __name__ == "__main__": rospy.init_node("setpoint_raw_local_pub") pub = rospy.Publisher("/mavros/setpoint_raw/local", PositionTarget, queue_size=1) rate = rospy.Rate(30) # NED: 北5m, 东3m, 高度4m, 机头朝东 x_ned, y_ned, z_ned, yaw_ned = 5.0, 3.0, -4.0, 1.5707 while not rospy.is_shutdown(): pub.publish(make_target(x_ned, y_ned, z_ned, yaw_ned)) rate.sleep()注意Python参数里的z_ned填的是-4.0,代表NED下4米深度,转换后sp.position.z = 4.0,正好是ENU下4米高度。这个负号一定要传对,很多下坠事故就是这里把负号弄丢了。
3.4 仿真环境从0到1的完整操作流程
这套流程我在Ubuntu的PX4 SITL环境里验证过很多次。先启动PX4仿真:
cd PX4-Autopilot make px4_sitl gazebo等PX4 SITL和Gazebo起来之后,另开一个终端启动Mavros:
roslaunch mavros px4.launch fcu_url:="udp://:14540@127.0.0.1:14557"然后启动自己的setpoint发布节点。注意顺序,一定要先发布setpoint再切换offboard模式,PX4要求offboard激活前持续收到setpoint,否则会拒绝进入offboard。我自己习惯做法是:先让节点跑起来至少1秒,等/mavros/state里的mode变成OFFBOARD,确认已解锁再发目标点。
# 切换offboard rosservice call /mavros/set_mode "custom_mode: 'OFFBOARD'" # 解锁 rosservice call /mavros/cmd/arming "value: true"一套基本流程下来,飞机应该能从起飞点朝着NED(5,3,-4)这个目标点飞过去,方向是东北,高度4米,机头朝东。如果出现方向偏差、高度不对或者飞机乱转,直接去第4节对照排查。
4. 坐标系错乱导致的真实事故与排查手册
4.1 方向反了/左右横飞:位置映射的经典翻车
最典型的翻车是把NED的x直接填到ENU的x里,没有做交换。有一次我调试一个小型四旋翼,想让它从起飞点向北飞,结果飞机起飞后直接朝东冲,吓得我赶紧切回自稳。日志里看位置反馈,发现飞机是沿着世界Y轴方向在移动,而不是我期望的X轴方向。问题就是位置映射里少了x/y交换。
这种问题在仿真里其实非常好排查。启动Gazebo后先别急着飞,把飞机推到某个位置,然后用rostopic echo /mavros/local_position/pose看看当前坐标。再用makeLocalTarget(1, 0, -1, 0)这种简单目标点测试,如果发布NED(1,0,-1)后飞机往东走而不是往北,那就是x/y映射反了。
4.2 目标一给就砸地:z轴符号的生死问题
Z轴符号错误是比x/y错误更危险的问题。x/y搞反最多是飞机飞错方向,Z搞反是直接往下砸。一次是某位同学在转换函数里写成sp.position.z = z_ned,没有取负,把原本应该“爬升到2米”的目标点变成了“下降到-2米”,飞机起飞后立刻往地面俯冲。幸好仿真里高度低,如果真机这样做后果真不堪设想。
我的经验是,第一次跑通offboard时,不要急着发大位移目标点。先让飞机起飞到半米高的安全高度,然后发一个只比当前高度高0.2米的小爬升目标,确认Z轴方向正确后再测试水平位移。这样即使Z符号错了,损失也小得多。
4.3 飞机原地画圈、航向锁不住:yaw语义混用
yaw语义混用的表现是位置能飞过去,但航向总是差一点,或者飞机在目标点上空不停画圈,好像永远追不上某个方向。这种情况基本上就是yaw没有按NED语义填,或者直接用ENU下的角度填进了消息里。
我在一个室内定位项目里就遇到过一次:位置控制完全正常,但飞机一到达目标点就开始绕Z轴缓慢旋转,像是航向永远差几度。排查后发现我的航向参考是从视觉定位模块出来的,那边给的是ENU角度,我填进setpoint之前忘了做yaw_ned = M_PI_2 - yaw_enu的转换。转换一加上,飞机马上稳了。
这里给一个速查表,方便对照:
| 期望机头方向 | NED语义yaw(rad) | ENU语义yaw(rad) |
|---|---|---|
| 北 | 0 | π/2 |
| 东 | π/2 | 0 |
| 南 | π | -π/2 |
| 西 | -π/2 | π |
4.4 目标点不生效、offboard进不去:掩码与时序
有时候setpoint发布了,飞机却纹丝不动。这时候先查type_mask,看是不是不小心把X/Y/Z也ignore了。之前有个朋友代码里type_mask用的是0b111111111111,把所有通道全忽略了,飞控收不到任何有效目标,自然不动。
还有一类问题是offboard模式进不去。PX4要求进入offboard前必须持续收到setpoint,而且这个setpoint不能是“所有通道都被忽略”的空消息。如果你把setpoint发布节点放在了切换offboard之后才启动,飞控很可能拒绝切换到offboard。正确顺序是先发布,再切换模式,再解锁,最后才给实际目标点。
4.5 我常用的定位三板斧
排查坐标系相关问题时,我有一套固定的三板斧流程。
第一板斧,用rostopic echo /mavros/local_position/pose看当前位置。起飞前先把飞机放在原点附近,确认Mavros输出的位置和真值一致。
第二板斧,用rostopic echo /mavros/setpoint_raw/local回读自己发布的消息,确认发出去的位置已经是ENU值。如果看到position.x和position.y还是北和东方向的原始值,那就是发布前没做转换。
第三板斧,用rostopic hz /mavros/setpoint_raw/local确认发布频率正常。如果频率只有1Hz甚至更低,PX4那边会频繁丢失setpoint,飞机抖动甚至退出offboard。
这三板斧看起来基础,但能解决80%的offboard坐标系问题。很多时候不是算法多难,而是数据在传递过程中跑偏了。
5. 写在最后的几个小经验
我曾经在同一个项目里经历过三次坐标系翻车,后来养成了一个习惯:把NED转ENU、FRD转FLU这些函数独立出来,先写几个标准用例做单元测试,比如NED的(1,0,0)应该转成ENU的(0,1,0),NED的(0,1,0)应该转成ENU的(1,0,0),q_frd_ned等于单位四元数时对应的q_flu_enu应该是绕Z轴90度。这些基础用例通过之后,才放心把它们用到setpoint发布里。
另外一个建议是初期尽量用/mavros/setpoint_position/local而不是/mavros/setpoint_raw/local。前者是Mavros对位置目标的封装,上手更简单,不容易误改type_mask;等把位置控制跑通了,再切换到raw话题去折腾速度和加速度,会省掉大量排查时间。
坐标系这件事,说穿了并不难,但确实是一票否决制的东西。任何一个轴搞反,飞机都不会按你的心意飞。把这套转换彻底搞明白之后,后面的offboard开发会顺畅非常多。