☰
PX4 Offboard坐标系转换与setpoint_raw/local实战
2026/10/5 1:04:52 网站建设 项目流程

做无人机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就是:

NEDENU说明
x (北)yENU的Y轴朝北
y (东)xENU的X轴朝东
z (下)-zENU的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消息里常用字段如下:

字段类型说明
headerstd_msgs/Header时间戳和frame_id
coordinate_frameuint8位置/速度所在坐标系,LOCAL_ENU=4
type_maskuint16哪些通道生效,哪些通道忽略
positiongeometry_msgs/Point目标位置
velocitygeometry_msgs/Vector3目标速度
accelerationgeometry_msgs/Vector3目标加速度
yawfloat32目标偏航角
yaw_ratefloat32目标偏航角速度

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
东π/20
南π-π/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开发会顺畅非常多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询