做四旋翼视觉降落的坑,我踩了快三个月。上一篇文章写完了视觉部分怎么做标记识别,很多朋友在后台问:识别出来了,然后呢?怎么让pixhawk动起来?怎么把这个“marker在图像里”的信息变成飞机真的能落上去?
这篇就专门讲mavros和pixhawk之间的那点事。你会看到:
- 从单目视觉解算到飞机控制,中间那条链路怎么搭
- mavros里到底该用哪些topic,offboard模式怎么安全切入
- 坐标变换怎么梳理才不翻车
- 降落流程怎么拆成阶段,每个阶段的控制指令怎么写
- 一堆实测中遇到的坑,包括飞控拒收、模式切不过去、marker丢失
适合已经能让无人机正常起飞、会简单用QGroundControl和PX4,但卡在“视觉定位到飞控控制”这段的人。纯新手建议先把ROS、MAVROS和PX4的offboard官方例程跑通再来看。
1. 先说清楚:视觉到控制这条链路,到底要怎么走
1.1 单目视觉在整个系统里扮演什么角色
很多刚接触视觉降落的人容易犯一个错:觉得单目视觉就是用来“看清目标”的。实际不是。在这个系统里,单目相机更准确地说是一个“相对定位传感器”——它不断输出“飞机相对降落点”的位置和姿态偏差。
单目没有深度信息,所以必须借助已知尺寸的标记(ArUco、AprilTag这类),通过PNP解算来恢复相对位姿。这正是单目视觉降落的经典做法:利用一个已知物理尺寸的标记,估算相机相对标记的位置和朝向。
这个相对位姿就是控制链路的源头。但它还不能直接被mavros用,因为:
- 相机解算出来的是“相机在标记坐标系里的位姿”
- 飞控需要的是“机体在NED世界坐标系下的位置设定点”
- pixhawk内部默认的坐标系是NED(x北、y东、z向下),单位是米
所以链路里最关键的一步,不是算法本身,而是坐标变换。这一步做错了,飞机落地时会精准地飞到标记旁边一米二米之外。
1.2 两条技术路线:外环控制 vs 视觉位姿融合
我测试过两种主流做法,先说结论再解释。
第一条线:视觉端只做“位置偏差计算”,直接把目标位置发给飞控。这条线最简单,也是我最终推荐使用的。视觉在机载电脑(NUC、树莓派、TX2都行)上算出“目标点相对当前机体的NED偏移”,然后通过mavros的/mavros/setpoint_position/local话题,把目标点坐标发给pixhawk。飞控内部的PID照常工作。
第二条线:视觉位姿通过/mavros/vision_pose/pose发给飞控,飞控用EKF把视觉和IMU、气压计做融合,直接在飞控里形成“视觉引导”的稳定估计。这条线理论上更平滑,能够修正IMU漂移,但调试复杂度高得多,而且必须处理好协方差、坐标系对齐、飞控参数设置。很多人卡在EKF“拒收”视觉数据这一关。
我的建议是:第一套方案先跑通。等整个降落流程稳定下来了,再考虑要不要把视觉喂给EKF做融合。大部分落地场景,外环控制完全够用,还省去一多半的排查时间。
1.3 整条链路的模块划分
我自己是把整个系统拆成四个模块来看的:
- 视觉模块:识别marker,PNP解算,输出相对位姿
- 坐标变换模块:把视觉输出转换到NED机体系
- 控制指令模块:把变换后的位置偏差发给mavros对应topic,并进入offboard
- 飞控执行模块:pixhawk收到setpoint,执行内部的姿态/位置控制
这四个模块我建议按顺序单独调试。尤其坐标变换这一步,务必在地面用模拟数据验完再接真机,不然飞机起飞后姿态一乱,你根本分不清是视觉算错了还是控制写错了。
2. 环境准备:pixhawk固件、mavros和机载电脑怎么“接头”
2.1 固件选型与关键参数
我用的是PX4固件,版本1.13之后都测过,2.0也跑过,逻辑基本兼容。ArduPilot也能用mavros,但offboard模式和参数名不太一样,下文以PX4为主。
飞控接线这块没太多可说的,机载电脑通过USB或者串口连pixhawk的TELEM2口都行。USB方便但有时候会供电不稳,我后来改用了串口,稳一些。
飞控参数里,和视觉降落强相关的几个:
| 参数 | 作用 | 建议值 |
|---|---|---|
COM_RC_IN_MODE | 是否允许无遥控进入offboard | 如果纯自主,建议开启 |
NAV_RCL_ACT | 遥控信号丢失时的动作 | 建议设成Land |
SYS_MC_IMU_POS | IMU相对机体中心的位置 | 根据你的飞控安装位置填 |
MPC_XY_P | 水平位置环P增益 | 风中降落可适当调大 |
MPC_Z_VEL_MAX_DN | 最大下降速度 | 视觉降落建议0.3~0.5 |
还有一类参数是给EKF视觉融合用的,比如EKF2_AID_MASK。如果你走外环控制方案,不需要改这个。我一开始就把这个位打开了,结果EKF一直报vision数据融合异常,排查了很久才发现是根本不需要开。
这里要特别提醒:EKF2_AID_MASK修改后必须重启飞控才生效。很多人的视觉数据发过去了,飞控死活不认,就是因为改了参数没重启。
2.2 mavros安装与通信确认
mavros的安装就不多写了,官方脚本或二进制安装都行。Ubuntu 20.04配ROS Noetic,Ubuntu 22.04配ROS Humble,Ubuntu 24.04配ROS Jazzy,版本匹配问题要注意,不要装了ROS版本再回头装不匹配的mavros,会报一串依赖错误。
装完后先做两个基本测试:
# 启动mavros,接USB口 roslaunch mavros px4.launch fcu_url:=/dev/ttyACM0:921600 # 另开终端查看飞控状态 rostopic echo /mavros/state正常情况下state里会显示connected: True,mode会随着你在QGC上切模式而变化。如果connected一直为False,多半是串口权限没加:sudo usermod -a -G dialout $USER,退出重登试试。
这里有个小细节:mavros启动后,即使你还没发任何指令,它也会和飞控保持心跳通信。这时候不要直接切offboard,先看/mavros/state里的mode是不是自己想要的,arm状态是不是自己预期的。我见过几个人一上来就忙着发command,结果飞机在地面直接切offboard起飞了。
3. 实操核心:mavros关键话题和offboard模式的安全进入法
3.1 必须认识的几个关键话题
mavros的话题很多,但视觉降落里真正高频用的就几个:
| 话题 | 类型 | 作用 |
|---|---|---|
/mavros/state | mavros_msgs/State | 当前飞行模式、连接状态 |
/mavros/local_position/pose | geometry_msgs/PoseStamped | 飞控当前本地位置(NED) |
/mavros/setpoint_position/local | geometry_msgs/PoseStamped | 发送本地位置设定点 |
/mavros/setpoint_velocity/cmd_vel | geometry_msgs/TwistStamped | 可选:速度设定点 |
/mavros/vision_pose/pose | geometry_msgs/PoseStamped | 可选:视觉位姿输入 |
/mavros/cmd/arming | mavros_msgs/CommandBool | 解锁 |
/mavros/cmd/land | mavros_msgs/CommandTOL | 直接触发降落 |
这些话题的名字我会让自己背下来。调试的时候不用每次去查。
特别要说的是/mavros/setpoint_position/local。它发的是NED坐标系下的位置,单位米,z轴向下为负。很多人第一次写,直接发了一个z = 1.0,结果飞机不是往上飞而是往下扎,就是这个原因。高度1米要写成z = -1.0。
3.2 offboard模式的进入流程,以及为什么不能乱切
PX4的offboard模式有个安全机制:飞控必须持续接收到一定频率的setpoint,才允许切换过去。官方文档说要求setpoint频率不低于2Hz,实际操作上我建议稳定在10Hz以上,否则模式切了一半会被踢回来。
正确流程是这样:
- 手动起飞到2~3米高度,悬停
- 启动你的视觉节点,确认marker被稳定识别,视觉输出正常
- 开始持续发送“当前位置保持”的setpoint,让自己当前的悬停位置被飞控连续接收
- 等
/mavros/state里mode变成OFFBOARD,确认后再开始发降落调整指令
这套流程能保证即使视觉有问题,飞机也只是在海拔高度悬停,不会乱飞。我踩过一个大坑:在地面直接切offboard,飞机立马全油门往上冲。因为我的程序启动后第一帧setpoint是z=0,地面高度0,但mavros和飞控之间对接瞬间产生了较大的高度差,飞控以为是掉高,猛拉油门。人不在急停面前,真的是眼睁睁看着飞机往前窜。
所以强烈建议:offboard的进入必须在悬停状态下完成,而且要提前发一段时间的保持指令,让飞控有适应过程。
3.3 把视觉偏差变成setpoint的坐标变换
这个环节是整个系统最容易出错的地方,我单独拎出来写。
假设视觉模块已经通过PNP解算,得到了“marker中心在相机坐标系下的位置向量”,记作P_camera = [x_c, y_c, z_c]。这个坐标系的定义随你用的视觉库而不同,以OpenCV的ArUco为例,解算出的tvec方向通常是:x向右,y向下,z向前(从相机看向标记)。
飞控期望的位置设定点是:在NED坐标系下,相对当前机体位置,目标点的坐标。
先转换到机体坐标系。为了简化,我假设相机安装在机体前方固定位置,且相机光轴与机体x轴近似平行。那么从相机系到机体系的变换是:
P_body = R_cam_to_body * P_camera + T_cam_to_body这里的R_cam_to_body取决于安装方向。最常见的安装方式:相机朝下安装,光轴垂直向下。此时相机系和机体系的关系大概是:
x_body = y_camera y_body = -x_camera z_body = -z_camera这就是一个90度旋转加上方向翻转。不同安装方式对应的矩阵不一样,一定要根据自己的实际安装情况推导。
关键一步:机载电脑里发布setpoint时,发送的是“目标点在NED世界系下的坐标”。如果飞控已经通过EKF估计出了当前机体位置P_cur,目标点NED坐标近似为:
P_target_ned = P_cur + R_body_to_ned * P_body这里的R_body_to_ned可以根据当前姿态(从/mavros/local_position/pose读取四元数转旋转矩阵得到)计算。
很多网上教程会让你直接用“当前位置+机体系偏差”作为setpoint发出去,其实不太对。因为setpoint_position/local期望的是世界系NED坐标,不是机体系坐标。你直接把body系的偏移当成世界系偏移发过去,在小角度悬停时误差不大,但飞机一旦有姿态倾斜,误差就会放大——特别是在降落过程中,飞机前倾或侧倾,落点就会偏。
正确做法是我上面那一步:先转NED,再叠加到当前世界坐标上。
为了验证这个变换对不对,我把相机拿在手里,对着地上的marker从不同方向移动,观察程序输出的NED偏差是否符合直觉:我往东走,x(北向)应该基本不变,y(东向)应该变大;飞机低头一点,z方向读数应该变小。这样做比盲写代码后上机测试靠谱得多。
3.4 一个可直接参考的控制循环骨架
下面是一个简化版的Python控制循环,演示如何连续发送保持和降落设定点。这个代码不是我的完整工程,但核心逻辑都在:
#!/usr/bin/env python3 import rospy import math from geometry_msgs.msg import PoseStamped from mavros_msgs.msg import State from mavros_msgs.srv import CommandBool, SetMode # 全局变量 current_state = State() local_pos = PoseStamped() def state_cb(msg): global current_state current_state = msg def local_pos_cb(msg): global local_pos local_pos = msg rospy.init_node('vis_landing_node') rate = rospy.Rate(20) # 20Hz,推荐10Hz以上 state_sub = rospy.Subscriber('/mavros/state', State, state_cb) local_pos_sub = rospy.Subscriber('/mavros/local_position/pose', PoseStamped, local_pos_cb) setpoint_pub = rospy.Publisher('/mavros/setpoint_position/local', PoseStamped, queue_size=1) # 等待服务 rospy.wait_for_service('/mavros/cmd/arming') rospy.wait_for_service('/mavros/set_mode') arm_service = rospy.ServiceProxy('/mavros/cmd/arming', CommandBool) set_mode_service = rospy.ServiceProxy('/mavros/set_mode', SetMode) # 先发送一组保持当前悬停的点,让飞控收到稳定setpoint setpoint = PoseStamped() setpoint.pose.position.x = local_pos.pose.position.x setpoint.pose.position.y = local_pos.pose.position.y setpoint.pose.position.z = local_pos.pose.position.z for _ in range(100): setpoint_pub.publish(setpoint) rate.sleep() # 切换到offboard if current_state.mode != 'OFFBOARD': set_mode_service(custom_mode='OFFBOARD') rospy.loginfo('Send OFFBOARD mode') else: rospy.loginfo('Already in OFFBOARD') # 主控制循环 while not rospy.is_shutdown(): # 这里根据你的视觉检测结果计算目标点NED坐标 target_x = local_pos.pose.position.x + delta_x_ned target_y = local_pos.pose.position.y + delta_y_ned target_z = -1.5 # 例子:目标高度1.5米 setpoint.pose.position.x = target_x setpoint.pose.position.y = target_y setpoint.pose.position.z = target_z setpoint_pub.publish(setpoint) rate.sleep()这个例子里的delta_x_ned、delta_y_ned,就是坐标变换模块输出的结果。可以看到其实发布setpoint本身不复杂,复杂的是算准这几个delta。
4. 单目视觉定位:从marker像素到位置偏差的完整解算
4.1 标记库选择:ArUco还是AprilTag
视觉标记这一环,性能直接决定整套系统的上限。我在ArUco和AprilTag之间反复横跳过,最终稳定使用AprilTag。
ArUco的好处是OpenCV直接内置,调用方便,新手友好。但它的false positive概率略高,在光照变化大的室外容易被误识别。AprilTag的家族设计更严谨,位姿解算的数值稳定性更好,尤其是大角度下的表现比我实测的ArUco要好。缺点是依赖库需要自己装,但也不复杂。
降落用标记尺寸,我用的是30cm和50cm两种。室内小场地用30cm,室外大场地用50cm。标记越大,PNP解算的精度越好,但问题是最低识别高度和最高识别高度都会受影响,需要根据场地调整。
4.2 PNP解算与相机安装位置补偿
用AprilTag的Python包或者OpenCV的cv2.solvePnP都能拿到相机相对标记的位置和姿态。
以OpenCV为例,把marker的四个角点坐标(在marker坐标系下的3D坐标,单位米)和对应的2D像素坐标输入进去:
# 假设marker实际尺寸为size米,以marker中心为原点 size = 0.5 object_points = np.array([ [-size/2, -size/2, 0], [ size/2, -size/2, 0], [ size/2, size/2, 0], [-size/2, size/2, 0] ], dtype=np.float32) # 2D点来自标记检测,corner顺序与object_points对应 ret, rvec, tvec = cv2.solvePnP(object_points, corners_2d, camera_matrix, dist_coeffs) # tvec就是marker坐标系原点在相机坐标系下的位置 # 也就是目标点相对相机的位置这里返回的tvec是marker原点在相机坐标系的坐标。注意方向:相机朝前,正前方是z正方向,x向右,y向下。
相机安装补偿很容易被人忽略。相机装在机头前方10cm处和装在机身正下方,对结果的影响完全不同。我建议把T_cam_to_body作为固定参数做一次标定,之后所有视觉坐标都先转成机体系再进行后续计算。
4.3 如何验证坐标系方向没搞反
我见过十个做视觉降落的人,至少五个人在坐标方向上有过问题。我自己也是其中之一。
验证方法其实很简单:把飞机放在地面,解锁但不起飞,或者拿在手里(确保桨已经拆了),然后:
- 把marker放在飞机正前方,看视觉输出里飞机相对marker的位置是不是“前方”
- 把marker放在飞机右侧,看y方向偏差是不是正
- 把marker举到飞机上方,看z方向是不是负值
如果发现左右反了、前后反了,不要急着改代码——先检查坐标变换矩阵的符号。最常见的是NED系z轴向下导致的高度方向搞反,以及OpenCV相机系与机体系的y方向取反问题。
我通常会在代码里加一个debug输出,打印出P_camera、P_body和P_target_ned三段数值,在地面上人为移动marker看这三个值的变化是否符合预期。做对了再让飞机上电。
5. 定点降落流程设计与实战参数
5.1 降落阶段划分与状态机
整个降落流程我的建议是拆成以下阶段:
- 搜索阶段:飞机在目标点上空悬停,相机寻找marker
- 对准阶段:持续读取视觉偏差,发送水平位置校正指令
- 下降阶段:水平偏差小于某一阈值后,开始垂直下降
- 近地阶段:接近地面时降低下降速度,切换近地传感器
- 触地判定:确认触地后关桨
每一阶段之间用状态机切换,切换条件必须写清楚。以下是我用于参考的参数:
| 阶段 | 切换条件 | 动作 |
|---|---|---|
| 搜索→对准 | 连续20帧检测到marker | 开始水平校正 |
| 对准→下降 | 水平偏差<0.15m,持续2秒 | 设定z目标逐步下降 |
| 下降→近地 | 离地高度<0.8m | 下降速度降到0.2m/s以下 |
| 近地→触地 | 电流突变或高度跳变 | 触发land或直接关桨 |
这个状态机建议在代码里单独实现,不要在视觉回调里塞逻辑。我一开始把所有逻辑都堆在一个回调里,改一处崩三处。
5.2 下降速度与位置环的关系
PX4内部对setpoint的响应其实已经做了位置环和速度环的串联控制。你发一个位置设定点,飞控自己会规划速度去跟踪。所以不要自己在那里“每个小步都发新setpoint”,发得太频繁太跳跃反而会让位置响应超调。
实测下来,视觉降落最稳的下降速度在0.3m/s到0.5m/s之间。下降速度超过0.8m/s,地面效应开始显现,飞机姿态容易抖动,视觉检测的帧率如果跟不上,状态就容易翻车。
水平校正的增益我也提一下:不要试图在下降过程中做过大的水平修正。我的策略是在2米高度以上把水平偏差修到0.1m以内,再开始下降。下降过程中只做微小修正(增量限制在0.05m/s以内),这样飞机不会因为大幅水平运动导致marker出画。
5.3 近地阶段怎么处理
近地阶段是整个视觉降落里最“薛定谔”的部分。相机离marker太近,marker很容易出画或者被机架遮挡;气压计高度在近地时误差又大,飞控容易误判高度。
我的经验是:1米以下不是视觉的主场。在这个高度,我会停止用视觉来做水平定位,直接切换到飞控的自身估计,老老实实用/mavros/cmd/land或者发送一个恒定小速度下降的指令。如果条件允许,加一个激光测距模块(或者光流模块的近地辅助),能极大提升落地成功率。
网上很多教程不强调这一点,导致很多人在最后半米里摔机。视觉降落不是“全程视觉”,而是“视觉引导到一定高度,之后交给飞控的安全机制”。这个认知很重要。
5.4 丢失marker的兜底策略
不管算法多好,总会有marker丢失的瞬间。我的兜底逻辑很简单:
- 丢失后的前1秒:保持当前位置和高度,继续尝试检测
- 丢失超过2秒:退出下降模式,回到悬停,发出告警
- 丢失超过5秒:触发LAND,安全降落在当前位置
这套兜底救过我很多次。特别是室外光线变化大、地面反光强的时候,marker偶尔就是会突然消失。没有兜底,飞机可能就会在自己都不知道在哪的状态下盲目下降。
6. 常见问题与排查技巧实录
6.1 offboard模式切不过去
这是我被问得最多的问题。
先自查三件事:
- 是否在切模式之前连续发送了至少几秒的setpoint?
- setpoint频率是否达到10Hz以上?
/mavros/state里是否真的显示OFFBOARD?
如果以上都做了还是切不过去,看看遥控器通道上有没有设offboard开关。PX4默认可以用RC通道切换offboard。如果你的遥控器拨杆没拨过去,即使程序里发了模式切换指令也可能被RC覆盖。
另外要注意,切换offboard时如果/mavros/state里显示armed=false,那是正常的。offboard模式和解锁是两回事,先切mode再解锁,或者先解锁再切mode都行,但建议先切mode再解锁,因为offboard状态下解锁,飞控会立刻开始按照你发的setpoint执行,一定要确保setpoint是安全的当前悬停点。
6.2 飞机乱飞,问题可能不在控制端
很多次“看起来是控制问题”,实际是视觉输出就是错的。
典型表现:飞机在悬停,marker明明在正下方,但视觉输出的delta_x_ned一直飘或者跳变。这时候先别急着调PID,做一个最简单的实验:把飞机锁住,或者拆桨手持,手动移动marker,看视觉输出是否单调、平滑地变化。
如果视觉输出本身抖得厉害,最常见原因有三个:
- 相机内参标定不准,导致PNP解算在不同角度下不一致
- marker反光,角点检测抖动
- 相机曝光时间太长,运动模糊导致角点偏移
这些改善方法:重新做相机标定、换哑光材质的marker纸、调低曝光时间。
6.3 下降过程中marker丢失
这几乎每个人都会遇到。原因通常是标记在画面中的像素尺寸太小,或者下降速度太快导致图像模糊。
解决思路:
- 增大marker尺寸,让它在更高高度就能被稳定识别
- 降低下降速度,给视觉留足处理时间
- 设定一个“最低视觉控制高度”,低于这个高度后不再依赖视觉,直接触发飞控的land
我实测在2米高度,50cm的AprilTag可以轻松识别;降到0.5米以下,除非标记装得非常正,否则大概率丢失。所以最低视觉控制高度我设为0.8米。
6.4 飞控拒收vision数据,EKF一直报错
如果你走的是vision_pose融合路线,遇到EKF拒收很常见。检查这几个地方:
EKF2_AID_MASK是否正确使能了vision位置估计- 视觉位姿的协方差是否合理,太小会让EKF过于信任视觉,太大直接被忽略
- 视觉坐标系是否和飞控NED对齐
不过我的建议还是:第一版别做视觉融合。直接在外部计算好位置偏差再发给setpoint_position,系统简单可控,排查成本低一个量级。等整体跑通了再研究融合的事。
6.5 一张问题速查表
| 现象 | 可能原因 | 排查/解决办法 |
|---|---|---|
| offboard切不过去 | setpoint频率不足/未持续发送 | 提前发送保持点,频率调到10Hz以上 |
| offboard切过去后猛冲 | 首次setpoint不是当前位置 | 先读取local_position再用当前位置作为起点 |
| 飞机左右方向反 | 坐标变换中y取反错误 | 地面验证时手动移动marker观察输出方向 |
| 高度方向反 | NED的z轴向下被忽略 | setpoint的z写成负值 |
| 视觉输出抖动 | 相机标定不准/曝光过长 | 重新标定,降低曝光 |
| 下降中marker丢失 | marker像素太小/速度太快 | 增大marker尺寸,降低下降速度,设最低视觉高度 |
| 近地高度不准 | 气压计在地面效应下跳变 | 用激光测距或飞控land模式收尾 |
| vision数据被拒收 | EKF_AID_MASK未重启/协方差不对 | 正确设置参数并重启飞控 |
7. 一点个人经验和后续扩展
最后说几句实在话。这套系统调试过程中,我最深的体会是:视觉降落项目里,算法本身占三成,剩下的七成都是“让各个模块稳定可靠地协同工作”。marker识别准了不算完,坐标变换对了才是万里长征第一步,offboard模式的切换时机和兜底策略才是真正决定成败的细节。
如果你准备动手做,我建议调试顺序是这样的:
- 先在地面把视觉解算和坐标变换单独验证完毕,用手持移动marker的方式确认输出符合直觉
- 让飞机在室内悬停,先不要让飞机移动,只输出setpoint,确认飞控收到的值和期望一致
- 小幅度水平校正,先别降落,看飞机能否自动修正到marker正上方
- 最后再开始完整降落流程
每一步都验证通过再进入下一步,看起来慢,实际操作中反而是最快成功的路径。我见过太多人为了省这一步,直接上整机测试,坠机一次修机一周,得不偿失。
后续可以扩展的方向也很多:加入激光雷达或者光流做近地融合、改成动态目标跟踪降落、用深度相机替代单目做更稳定的位姿估计、甚至在机载端直接部署独立的视觉追踪控制器,把mavros的负载进一步降下来。这套架构本身是很灵活的,只要坐标系和控制链路梳理清楚,扩展新的传感器和算法只是替换其中一个模块的事。