
做海上平台降落这个项目最初是因为一次差点炸机的经历在近海养殖区三米见方的起降平台随着波浪晃GPS在岸边还有两三米精度出了防波堤直接漂到五米开外。飞控按“绝对坐标”对准降落点时实际落点偏出了平台边缘小半米要不是桨叶先扫到护栏整机就交代在海里了。那次之后我彻底明白海上精准降落不能指望GPS必须靠视觉闭环。这个项目最终用大疆Matrice系列无人机搭配机载电脑通过Apriltag视觉标志做相对定位实现了分米级的落点精度。整套方案从原理到代码我都会在下面拆开讲代码不是“示意”是可以在自己的无人机上改改参数就能跑的完整版本。1. 为什么海上精准降落非视觉不可1.1 海上GPS的“靠谱”只是错觉很多飞手觉得GPS精度差那就加RTK但海上场景里RTK并没有想象中那么好用。近海区域网络差分信号本来就不稳定RTK数据链经常掉线从差分修正到输出位置本身就有一段延迟而船载平台又在实时晃动一个延迟超过100毫秒的绝对坐标对降落引导来说基本没有参考意义。更麻烦的是杆臂效应。GPS天线装在无人机顶部视觉传感器装在机腹两者之间存在几十厘米的物理距离。无人机带横滚角飞行时机体的空间姿态会让GPS解算出的位置与相机实际光轴指向产生偏差姿态越大偏差越大。降落阶段无人机通常带着俯仰角前飞这时候用GPS绝对位置去对齐目标横向误差会被姿态角放大落点偏差累积到几十厘米甚至一米都不奇怪。还有一点常被忽略船的起降平台本身是动的。GPS锁定的是一个地理坐标但平台随波浪漂移的幅度往往比GPS误差还大。锚泊状态好一些但涌浪周期长平台会绕着锚点画弧线这种动态目标用静态坐标去跟踪原理上就是错的。视觉方案的优势在于测的是“相机到标志的相对位姿”目标移动也没关系每一帧都在闭环更新这是位置环的本质区别。1.2 Apriltag、ArUco与深度学习检测怎么选做降落引导视觉方案常见的有三种深度学习目标检测、ArUco码、Apriltag。我最终选了Apriltag原因是它在海上这种环境下的综合性价比最高。深度学习YOLO等目标检测网络能识别任意形状的起降平台这是它最大的优点。但要达到稳定的识别效果需要采集不同光照、不同角度、不同海况下的大量平台图像做训练数据采集本身就够折腾的。而且网络推理在Jetson这类嵌入式设备上虽然能跑但帧率和精度互相制约模型泛化能力在陌生海域可能急剧下降。ArUco码原理和Apriltag非常接近也是基于方形二值标记的视觉定位。OpenCV里直接集成了aruco模块上手比Apriltag还方便。但实际对比下来ArUco在不同光照下解码稳定性不如Apriltag尤其是海面反光刺眼、图像对比度剧烈变化时误识别率上升明显。Apriltag不需要训练一张黑白方格图打印出来就能用单目相机即可解算6自由度位姿计算量很小在低算力平台上也能稳定跑满30帧。三类方案对比如下对比维度深度学习目标检测ArUcoApriltag训练成本高需采集标注大量样本无无相对位姿精度依赖检测框精度较粗糙较高高抗强反光/复杂光照依赖训练数据分布一般较好计算资源消耗高需GPU推理低低平台适应性强任意外观可训练弱需贴码弱需贴码从工程落地角度Apriltag的成熟度也更高无论是apriltag库还是各种ROS封装接口都很稳定。标签本身的黑白方格结构自带几何约束解码时有校验位检测异常的干扰远小于纯视觉特征。1.3 Apriltag的定位原理一句话版Apriltag定位的核心是单目视觉的PnP解算。每个标签由黑白方格编码组成角点位置是精确的几何特征。检测到标签后算法会对四个角点做亚像素定位然后根据标签在世界坐标系中的真实物理尺寸配合相机内参矩阵求解“标签坐标系到相机坐标系”的旋转矩阵和平移向量。可以这样理解如果你知道一个篮球的直径是24.6厘米那么在照片里量出篮球的像素直径结合相机焦距就能算出篮球离你多远。Apriltag是利用四个角的几何关系一次性解出距离和姿态不只给距离还给出空间六自由度的完整位姿。距离解算的精度和距离的平方近似成正比。10米外检测到标签位置误差可能到半米3米内误差能收敛到5厘米以内。所以降落引导策略一定是“远距离粗找、近距离精对”先用GPS或前视相机把无人机引导到标签附近进入可检测范围后再切换到Apriltag闭环。2. 硬件链路的选型与安装细节2.1 一套忍得住海风咸湿的配置这个项目我的核心硬件配置如下无人机大疆Matrice 300 RTK。之所以选M300是因为它的SDK开发体系最完善支持PSDK外挂负载机载电脑可以通过串口直接控制飞行还能挂载喊话器、探照灯等扩展设备后续做巡检任务不需要换机。机载电脑NVIDIA Jetson Xavier NX算力足够跑图像处理和位姿解算功耗约15瓦用无人机电池经过DC-DC降压供电即可。相比树莓派它的视觉处理性能和CUDA生态是决定性优势。相机海康威视MV-CA050-10UC工业相机500万像素全局快门配6mm镜头。全局快门很重要无人机飞行震动大卷帘快门容易产生果冻效应直接影响Apriltag角点定位精度。通信链路机载电脑通过USB转TTL串口连接M300的PSDK接口波特率115200。这个串口用于发送飞行控制指令和接收飞控状态。海边环境对电子设备是残酷的。机载电脑和降压模块必须放在防水壳里接口处涂硅胶密封镜头要配偏振镜来压制海面反光定期用无水乙醇清理镜头上的盐雾结晶否则两天不擦镜头就花了。2.2 相机安装的位置与外参标定相机安装位置我踩过两次坑最终选在机腹正下方偏前约10厘米的位置光轴尽量垂直向下。如果装在前视云台上降落阶段飞行器低头云台角度和机身姿态反复调整位姿解算容易抖动。垂直向下安装的话视觉几何关系最简单控制解耦清晰。相机安装好之后必须做外参标定也就是确定“相机坐标系”在“机体坐标系”下的位姿。简单做法是无人机水平放置在机体正下方已知位置放一张Apriltag记录tag在机体坐标系下的坐标再读Apriltag解算出的tag在相机坐标系下的坐标两者相减即可得到相机到机体的平移量。旋转量通常近似为单位阵如果相机装得足够正。外参误差对降落精度的影响非常直接。假设相机中心与机体中心偏了5厘米而程序里以为它们是重合的那所有解算出的相对位置都会整体偏移5厘米。这对三米见方的平台来说可能不致命但对更小的起降区域外参不准就是降落后的落点漂移元凶。2.3 相机内参标定的坑内参标定用张正友标定法OpenCV的cv2.calibrateCamera即可完成。这里分享三个实际经验第一标定板棋盘格绝对不要使用反光材质打印。海面上阳光直射时反光板会让角点检测在部分照片中直接失效标定出的内参不准确。用哑光相纸打印贴在硬纸板上保持平整。第二标定图片要覆盖整个画面。很多人标定时只拍了画面中心的棋盘格这样标定出来的畸变系数在画面边缘是错的而Apriltag恰恰可能出现在画面边缘。我一般拍25到30张棋盘格在画面四角和中心都有分布并且有不同倾斜角度。第三内参误差对远距离影响极大。焦距fx、fy一个像素的偏差在5米距离上可能造成10厘米以上的测距误差。标定完成后用已知尺寸的Apriltag在几个距离上做验证数值对不上就重新标定。3. 代码实现从检测到落地的三段式闭环3.1 检测模块apriltag库的正确打开方式我用的是apriltag库安装包名apriltagPython下from apriltag import apriltag。这个库对tag36h11家族支持最好检测速度快且鲁棒性高。tag大小建议用1米平台3米见方时1米的标签在10米高度能被稳定检测到。import cv2 import numpy as np from apriltag import apriltag, DetectorOptions # 配置检测器参数 options DetectorOptions( familiestag36h11, nthreads4, # 开启多线程Jetson上4线程比较合适 quad_decimate1.0, # 不解采样保证远距离小角点也能检出 quad_sigma0.0, # 不额外模糊海上强边沿比较多模糊反而增加噪声 refine_edges1, # 对边缘做亚像素细分精度来源 decode_sharpening0.25, # 解码锐化对付轻微失焦 ) detector apriltag(options) def detect_tag(frame, camera_matrix, dist_coeffs): 输入BGR图像返回tag中心点、四角点、位姿 gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) detections detector.detect(gray) results [] for det in detections: # det.pose_R 是旋转矩阵 3x3, det.pose_t 是平移向量 3x1 # 注意det.pose_t的单位由tag_size决定 # 相机坐标定义为tag中心为原点tag平面法线为Z轴 tag_pose (det.pose_R, det.pose_t) center_px det.center.reshape(2).tolist() corners_px det.corners.reshape(4, 2).tolist() # tag在相机坐标系下的位置单位由tag_size控制这里为米 # pose_t 返回的是从tag坐标系到相机坐标系的平移 t det.pose_t.reshape(3) results.append({ center_px: center_px, corners_px: corners_px, R: det.pose_R, t: t, }) return results这个库的detect函数返回的pose_R和pose_t是OpenCV坐标系下的结果用的时候要小心方向。实测中quad_decimate这个参数很关键默认1.0不解采样保证远距离小角点也能检出如果追求帧率可以改成2.0但10米外的tag检出率会下降。3.2 解算与滤波把像素变成机体坐标系下的误差拿到Apriltag在相机坐标系下的位姿后还需要做两步坐标变换相机坐标系到机体坐标系机体坐标系到水平坐标系。为了控制无人机我们需要的是“tag在机体前向、右向、下方”的误差而不是相机坐标系下的原始数值。def camera_to_body(tag_t_cam, R_cam_to_body, t_cam_to_body): 将tag在相机坐标系的位置转换到机体坐标系。 相机系x右y下z前OpenCV默认。 机体系x前y右z下多数飞控的机体坐标系定义。 tag_pos_cam np.array(tag_t_cam).reshape(3, 1) # 先平移到机体坐标系原点再旋转 tag_pos_body R_cam_to_body tag_pos_cam t_cam_to_body.reshape(3, 1) return tag_pos_body.flatten() # [x前, y右, z下] def ema_filter(new_value, last_value, alpha0.4): 简单一阶低通滤波抑制单帧抖动 return alpha * np.array(new_value) (1 - alpha) * np.array(last_value)这里R_cam_to_body和t_cam_to_body就是2.2节标定的外参矩阵。滤波我推荐用一阶低通阿尔法取0.3到0.5。海面环境下检测抖动主要来自平台随浪晃动和机体震动一阶低通在工程上最稳妥Kalman滤波也能用但参数调不好反而会有相位滞后。3.3 降落状态机与控制指令下发控制逻辑是整个系统的核心。我设计了一个五状态的状态机确保每个阶段都有明确的进入条件和退出条件避免无人机“看到tag就乱降”。import time class LandingStateMachine: def __init__(self): self.state SEARCH self.loss_count 0 self.align_start_time None self.descend_start_alt None def update(self, tag_pos_body, current_alt, config): tag_pos_body: [前向误差, 右向误差, 下方距离]单位米 current_alt: 气压计/雷达高度米 返回值(新状态, 控制指令) # SEARCH没有tag或距离太远先用GPS/前视视觉进近 if self.state SEARCH: if tag_pos_body is not None and tag_pos_body[2] config[search_range]: self.state ALIGN_HIGH self.align_start_time None return self.state, None # None表示使用外部导航 if tag_pos_body is None: self.loss_count 1 if self.loss_count config[max_loss_frames]: self.state SEARCH return self.state, None self.loss_count 0 x_err, y_err, dist tag_pos_body # ALIGN_HIGH高空水平对准目标是让机头指向tag正上方 if self.state ALIGN_HIGH: if abs(x_err) config[align_xy_thresh] and abs(y_err) config[align_xy_thresh]: if self.align_start_time is None: self.align_start_time time.time() elif time.time() - self.align_start_time config[align_hold_time]: self.state DESCEND self.descend_start_alt current_alt else: self.align_start_time None # 返回水平修正速度由PID控制器执行 vx config[xy_pid][0] * x_err config[xy_pid][2] * (x_err - self.last_x_err) vy config[xy_pid][0] * y_err config[xy_pid][2] * (y_err - self.last_y_err) return self.state, (vx, vy, 0.0) # z速度0保持高度 # DESCEND保持对中缓慢下降 elif self.state DESCEND: vx config[xy_pid][0] * x_err config[xy_pid][2] * (x_err - self.last_x_err) vy config[xy_pid][0] * y_err config[xy_pid][2] * (y_err - self.last_y_err) vz -config[descend_speed] # 向下速度单位m/s if abs(x_err) config[lost_xy_thresh] or abs(y_err) config[lost_xy_thresh]: # 偏差过大停止下降并回到高空对准 self.state ALIGN_HIGH self.align_start_time None return self.state, (vx, vy, 0.0) if current_alt config[land_alt_thresh]: self.state LAND return self.state, (vx, vy, vz) # LAND飞控接管物理着陆 else: return self.state, (0.0, 0.0, 0.0) self.last_x_err x_err self.last_y_err y_err状态机的核心思路是对中后再下降下降中一旦失准立刻停止下降回到对准状态。海上平台随浪晃动如果不管偏差直接往下按无人机很可能在下降过程中被侧风或近地面效应吹偏眼睁睁看着tag滑出视野。3.4 主循环完整代码主循环的思路是摄像头以30帧读取检测帧率可以降到15帧以节省算力检测到tag后走状态机逻辑把控制指令通过串口发送给飞控同时读取飞控返回的高度和姿态用于保护逻辑。def main(): # 初始化相机 cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) cap.set(cv2.CAP_PROP_FPS, 30) # 相机内参用你自己的标定结果替换 camera_matrix np.array([[650.0, 0, 640.0], [0, 650.0, 360.0], [0, 0, 1.0]]) dist_coeffs np.zeros((4, 1)) # 外参相机到机体 R_cam_to_body np.eye(3) t_cam_to_body np.array([[0.0], [0.0], [0.1]]) # 相机在机体下方10cm fsm LandingStateMachine() fsm.last_x_err, fsm.last_y_err 0.0, 0.0 config { search_range: 12.0, # 12米内开始视觉引导 max_loss_frames: 10, # 连续丢帧10次回到搜索 align_xy_thresh: 0.15, # 水平误差小于15cm继续 align_hold_time: 1.5, # 持续对准1.5秒再下降 lost_xy_thresh: 0.30, # 下降中偏差超过30cm回退 descend_speed: 0.4, # 下降速度0.4m/s land_alt_thresh: 0.8, # 高度低于0.8m直接降落 xy_pid: [0.8, 0.0, 0.15], } while True: ret, frame cap.read() if not ret: continue # 1. 检测不需要每帧都跑可隔帧处理 detections detect_tag(frame, camera_matrix, dist_coeffs) tag_pos_body None if len(detections) 0: det detections[0] tag_pos_cam det[t] tag_pos_body camera_to_body(tag_pos_cam, R_cam_to_body, t_cam_to_body) tag_pos_body ema_filter(tag_pos_body, fsm.last_x_err, alpha0.4) # 2. 获取当前高度读取飞控接口这里做简化 current_alt get_current_altitude() # 需要根据自己的SDK实现 # 3. 状态机更新 state, cmd fsm.update(tag_pos_body, current_alt, config) fsm.last_x_err tag_pos_body[0] if tag_pos_body is not None else fsm.last_x_err fsm.last_y_err tag_pos_body[1] if tag_pos_body is not None else fsm.last_y_err # 4. 发送控制指令发送速度指令给飞控 if cmd is not None: send_velocity_setpoint(cmd[0], cmd[1], cmd[2]) # 5. 显示画面便于调试 if tag_pos_body is not None: cv2.circle(frame, tuple(map(int, detections[0][center_px])), 5, (0, 0, 255), -1) cv2.putText(frame, fState: {fsm.state}, (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) cv2.imshow(Apriltag Landing, frame) if cv2.waitKey(1) 0xFF ord(q): break这里send_velocity_setpoint和get_current_altitude需要根据你的飞控SDK实现。如果是DJI PSDK对应的接口是SetVelocityAndYawRate如果用MAVLink协议的飞控则是SET_POSITION_TARGET_LOCAL_NED类型的消息。无论哪种本质都是把解算出的机体速度指令喂给飞行控制器。注意代码中的PID参数是我在平稳海况下实测得到的经验值。不同机型的惯量、响应速度差异很大上真机前务必先在仿真或绑绳测试中验证。4. 海上环境实测这些坑不踩一遍真的想不到4.1 海面反光如何弄崩自动曝光第一次海上测试无人机悬停在8米高度阳光从侧面照来海面反光直接让相机的自动曝光把画面压暗Apriltag的白色方格在画面上变成灰色黑色方格接近黑色对比度严重不足检测率骤降。我一度以为是代码问题查看调试画面才发现是曝光策略不合适。解决办法有两步。第一相机曝光模式改为手动固定曝光时间。海上光照变化虽然大但固定曝光配合偏振镜后画面质量整体稳定得多。第二在镜头前加偏振镜旋转到合适角度即可大幅抑制海面偏振光让Apriltag区域的对比度恢复正常。需要留意的细节是每天的太阳高度角不同固定曝光参数建议分别在上午、中午、下午各标定一组切换场景时手动调整。这个操作看起来原始但在海上实测中比任何自动曝光算法都可靠。4.2 平台晃动的“假稳定”问题降落平台随波浪晃动时会出现一种“假稳定”现象某一帧检测到的tag位置刚好在机体正下方偏差为0但实际上平台正处于晃动的一个极端位置下一秒就会偏出去。如果状态机在这一帧判定“对中成功开始下降”下一帧平台漂开后无人机反而带着降落速度飞向错误位置。解决思路是增加一个对准确认时间窗。状态机里ALIGN_HIGH状态要求偏差连续1.5秒保持在15厘米以内才允许进入下降状态。这个时间窗要覆盖一个完整的晃动周期实测中1到2秒是合理区间太短拦不住晃动太长又会在风浪中频繁失准回退导致降落过程极慢。4.3 无人机近海面时的下洗气流无人机下降到距离海面1米左右时旋翼下洗气流冲击水面会产生明显的颠簸和地面效应此时飞控的高度控制性能会下降视觉检测画面也会剧烈抖动。第一次实测时无人机在0.8米高度突然下坠差点拍进水里我当时还以为是飞控故障。后来分析发现这是近地面效应叠加海面波浪反射流的综合结果。对策是下降速度不要超过0.4米/秒接近0.8米阈值时不要直接切换LAND而是先悬停1秒让飞控稳定下来再落。如果安装了下视激光雷达/毫米波雷达可以用雷达高度替代气压高度来触发LAND防下坠效果更明显。5. 上船前的调试流程与参数调整经验5.1 先在仿真环境把控制链路跑通不要一上来就上真机。我推荐用Gazebo配合PX4的软件在环仿真或者大疆自己的模拟器。在仿真环境里把状态机逻辑、坐标变换、PID参数整体跑一遍重点验证的是“检测到tag后能否稳定收敛”而不是外观精度。仿真中要故意制造抖动和丢帧模拟海上平台晃动。我习惯在仿真里把tag的坐标加上一个正弦波动的偏移幅度0.3米周期3秒这样能真实暴露状态机回退逻辑的缺陷。5.2 地面递增测试的递进步骤仿真通过后上真机也不要直接去海上。我的实测递进顺序是地面测试手动控制无人机悬停在不同高度验证Apriltag的检出率、位姿解算精度。无人机不用飞高1到3米即可通过地面站实时查看解算的坐标是否稳定合理。系留测试用安全绳系住无人机开启自动降落状态机逐渐逼近降落点。系留测试能验证控制逻辑即使出问题也不会炸机。停靠模拟平台测试在陆地上搭一个模拟起降平台比如在硬化地面上贴Apriltag执行完整的自动降落流程。抛锚平台测试在近岸抛锚的小型平台上进行真实海上测试从悬停8米高度开始逐步下降。每一步测试都要记录关键日志状态切换时间、偏差变化曲线、漏检帧数。这些数据是后面调参的唯一依据。5.3 参数调整速查表基于多轮实测整理几个关键参数的调整经验参数推荐值范围调整经验align_xy_thresh0.10~0.20m越小降落越准但海况差时难以满足易频繁回退align_hold_time1.0~2.5s海流周期长则取较大值确保稳定后才下降descend_speed0.2~0.5m/s平台晃动大时降到0.3以下避免近地面效应失控lost_xy_thresh0.25~0.40m小于align_xy_thresh会导致下降后立刻回退必须大于它xy_pid[0]比例项0.6~1.2过小收敛慢过大下降时超调明显画面抖动加剧xy_pid[2]微分项0.10~0.20用来抑制平台晃动引起的震荡但太大会放大检测噪声这里特别提醒海况变化时参数的鲁棒性比最优值重要。我见过不少人把陆地上的高PID增益直接搬到海上结果抖动大系统直接震荡。海上的核心诉求是“稳”而不是“快”收敛慢一点没关系炸机一次什么都白搭。我个人测试下来还有一个很有用的习惯把整个降落过程的画面录下来同时叠加实时数据状态、偏差、高度、速度作为水印。出了任何问题回看录像比翻日志直观得多这套调试方法帮我在后续项目里节省了大量定位问题的时间。如果你也计划在海上用视觉引导降落希望这篇分享能让你少走一些弯路。