☰
基于Keras的无人机目标检测与视觉跟随实现详解
2026/10/13 2:56:06 网站建设 项目流程

我花了大半个周末把Udacity机器人软件工程师课程里关于四轴无人机目标检测跟随的内容重新过了一遍,用Keras从零搭了一套检测加跟踪的流程,中间踩了不少坑。这里把整个思路、代码结构、训练细节和实测结果整理出来,给正在啃这门课或者准备做类似项目的朋友一份参考。

先说清楚这篇文章要解决的问题:无人机在飞行过程中需要对地面或空中的特定目标进行实时检测,并据此调整飞行姿态与轨迹,做到“相机看到目标 → 检测识别 → 无人机跟随”。我选择Keras而不是其他框架,核心原因是课程里大量示例基于Keras的简洁API,对我来说验证想法最快,后续切换到TensorFlow Serving或者其他部署方案也比较顺。文章适合两类读者:一类是正在学机器人软件工程课程、卡在项目三或项目四的同学,另一类是自己做无人机视觉跟随、想快速跑通一个检测闭环的开发者。

1. 目标检测跟随的整体架构与任务拆解

1.1 为什么无人机视觉跟随不是“检测框加PID”这么简单

很多刚接触这个项目的同学默认认为只要训练一个目标检测模型,把检测框的中心点坐标喂给飞控,无人机就能跟着目标飞。落地实测后会发现完全不是这回事。我先简单拆一下实际要处理的几个模块:

  • 感知层:无人机搭载单目相机,输出实时视频帧,检测模型在每帧或每隔几帧识别目标,输出边界框和类别置信度。
  • 状态估计层:由于单目相机缺少深度信息,不能直接得到目标在世界坐标系中的精确位置,必须借助一些假设(比如已知目标的物理尺寸)估算距离,或者用像素坐标变化推算相对运动。
  • 控制层:飞控收到目标在图像中的位置偏差后,需要将其转换为机体坐标系下的期望速度或期望位置偏移,再交给底层姿态控制器执行。
  • 决策与任务管理层:目标丢失、目标切换、飞行高度变化、光照突变等场景都需要有对应的处理策略,否则检测模型再准也无法稳定跟随。

换句话说,检测模型只是整个系统里的一个感知组件。我从课程项目文档里体会最深的一句话是:跟随任务的难点往往不在“看得见”,而在“看得稳、控得住”。如果只把精力放在刷检测精度上,忽略了目标丢失后的恢复流程和图像到控制的延迟补偿,无人机在天上会表现得很“神经质”。

1.2 基于Keras的检测模块选型思路

无人机平台上的算力一般有限,课程环境里常用的是NVIDIA Jetson系列或者带有独立GPU的开发板。模型选型要平衡三点:推理速度、检测精度、部署难度。

我在这个项目里对比过三个方向:

方案优点缺点适用场景
经典YOLO系列(YOLOv3/YOLOv4的Keras复现版)速度快,检测头简洁,社区资料多需要自己处理锚框和NMS,配置稍繁琐目标类别少、算力有限的机载设备
SSD(Single Shot MultiBox Detector)结构直观,Keras有现成实现,训练稳定小目标检测一般,速度中规中矩想快速跑通验证流程
Faster R-CNN系列精度高速度慢,部署复杂离线分析,不适合实时跟随

我最终选择了基于Keras实现的轻量化YOLO结构,具体来说是在课程提供的检测模型基础上,把骨干网络换成MobileNetV2风格的深度可分离卷积,既保留YOLO的回归思想,又降低了参数量。课程本身并不要求从零实现完整的目标检测框架,但我在做项目的时候感觉只调用现成的预训练模型不够踏实,还是手写了损失函数和训练流程,对理解检测原理非常有帮助。

1.3 训练数据从哪来:仿真环境与自采数据的配合

课程环境里没有现成的“无人机视角下的跟随目标数据集”,这算是第一个比较现实的问题。我的做法是分两步走:

  • 第一步,用仿真环境(比如AirSim或者课程提供的模拟器)采集大量带有目标物体的图像,导出图片和对应标注。模拟器的好处是能精确控制相机位置、光照和角度,可以快速生成不同高度、不同背景的样本。
  • 第二步,在真实环境中用无人机悬停拍摄少量图片,手动标注,作为微调数据。这样能弥补仿真图片与真实纹理之间的差异。

有人问为什么不完全用公开数据集,比如COCO或Pascal VOC来做迁移学习?可行,但要注意两个坑:第一,公开数据集里的目标类别和拍摄视角与无人机俯瞰视角差异很大,直接迁移的效果有限;第二,无人机跟随的目标往往在画面中占比较小,COCO预训练模型在相对尺寸很小的目标上表现并不好。所以我的建议是——公开数据集可以用来预训练骨干网络,但目标检测的微调一定要用跟自己任务场景相近的数据。

2. Keras实现目标检测模型的关键细节

2.1 模型输入输出的设计

目标检测模型本质上是在解决“图片里有什么,在哪,多大”的问题。我采用YOLO风格的网格回归方式,把输入图片划分成S×S个网格,每个网格负责预测B个边界框。每个边界框包含五个基本量:中心点相对网格的偏移(tx, ty)、宽高相对于锚框的缩放(tw, th)、目标置信度。

这里举一个具体的例子。假设输入尺寸是416×416,S设为13,B设为3,每个网格还要预测类别概率。我的输出张量维度就是13×13×3×(5+类别数)。如果检测目标是“人”和“车”两类,那么输出维度就是13×13×3×7。

Keras中实现时,我用自定义Lambda层或者直接使用函数式API拼接检测头。下面是一个简化版的模型构建示意:

from keras.layers import Input, Conv2D, DepthwiseConv2D, BatchNormalization, LeakyReLU, Lambda from keras.models import Model import tensorflow as tf def conv_block(x, filters, kernel_size, strides=1): x = Conv2D(filters, kernel_size, strides=strides, padding='same')(x) x = BatchNormalization()(x) x = LeakyReLU(alpha=0.1)(x) return x def depthwise_conv_block(x, filters, strides=1): x = DepthwiseConv2D(3, strides=strides, padding='same')(x) x = BatchNormalization()(x) x = LeakyReLU(alpha=0.1)(x) x = Conv2D(filters, 1, strides=1, padding='same')(x) x = BatchNormalization()(x) x = LeakyReLU(alpha=0.1)(x) return x def yolo_head(x, num_anchors, num_classes): x = conv_block(x, num_anchors * (5 + num_classes), 1, strides=1) return x # 输入占位符 input_image = Input(shape=(416, 416, 3)) # 简化版MobileNetV2风格骨干(实际项目会更复杂) x = conv_block(input_image, 32, 3, strides=2) x = depthwise_conv_block(x, 64, strides=1) x = depthwise_conv_block(x, 128, strides=2) x = depthwise_conv_block(x, 256, strides=2) x = depthwise_conv_block(x, 512, strides=2) # 检测头 feature_map = yolo_head(x, num_anchors=3, num_classes=2) # 定义输出shape为 (batch, 13, 13, 21) model = Model(input_image, feature_map) model.summary()

这段代码只是骨架,实际项目中骨干网络深度和通道数需要根据设备算力调整。我最初偷懒直接调用Keras Applications里的MobileNetV2作为骨干,后来发现迁移过来的特征图分辨率不够细,小目标召回率特别低,于是改成自己组装中间几层跳跃连接,保证输出特征图保留更多空间信息。

2.2 损失函数:检测模型训练的核心

检测模型训练和普通分类网络最大的区别在损失函数。YOLO系列损失通常由三部分组成:

  • 坐标损失:预测框与真实框之间的中心点偏移和宽高缩放误差。我用了平方误差,但对宽高取了对数缩放,这样大框和小框的误差尺度相对一致。实际训练中发现,如果不加宽高的平方根惩罚,模型会倾向于预测更大的框,因为大框的绝对像素误差对总损失的贡献更大,导致小目标漏检严重。
  • 置信度损失:判断网格内是否存在目标。这里要特别注意背景框的采样平衡,因为绝大多数网格都是背景,如果全部参与损失计算,模型会偏向输出极低的置信度。我借鉴了Focal Loss的思路,对易分类的背景样本做了降权处理。
  • 类别损失:对存在目标的网格计算类别交叉熵。由于我这里的跟随任务类别很少(通常只有一个目标类别),类别损失占比不高,但在多类别场景下依然重要。

以下是对损失函数的简化表达:

def yolo_loss(y_true, y_pred, anchors, num_classes, ignore_thresh=0.5): # y_true shape: (batch, 13, 13, 3, 5+num_classes) # y_pred shape: (batch, 13, 13, 3, 5+num_classes) # 坐标损失 xy_loss = object_mask * tf.reduce_sum(tf.square(y_true[..., 0:2] - sigmoid(y_pred[..., 0:2])), axis=-1) wh_loss = object_mask * tf.reduce_sum(tf.square(y_true[..., 2:4] - y_pred[..., 2:4]), axis=-1) # 置信度损失 conf_loss = object_mask * tf.square(y_true[..., 4] - sigmoid(y_pred[..., 4])) \ + (1 - object_mask) * tf.square(0 - sigmoid(y_pred[..., 4])) * bg_scale # 类别损失 class_loss = object_mask * tf.reduce_sum(tf.square(y_true[..., 5:] - sigmoid(y_pred[..., 5:])), axis=-1) return tf.reduce_mean(xy_loss + wh_loss + conf_loss + class_loss)

这里一个容易踩坑的地方是锚框匹配策略。模型并不是让每个网格都去预测真实框,而是先计算真实框与预设锚框的IoU,选择IoU最高的锚框作为正样本。如果某个网格内没有目标或者锚框匹配不上,就作为负样本。实际训练时我发现在早期epoch,模型输出的框非常不准确,锚框匹配结果一直在抖动,所以前500步我固定了正样本选择,等模型输出逐渐稳定后再放开,收敛速度快了很多。

2.3 数据增强与预处理

无人机视觉数据增强和普通目标检测不太一样,只做随机翻转和裁剪是不够的,还要考虑飞行场景的特点:

  • 尺度抖动(Scale Jittering):无人机飞行高度变化导致目标在画面中的相对尺寸变化很大。我随机把图片缩放0.8到1.2倍后再裁剪到416×416,相当于模拟了不同飞行高度。
  • 亮度与对比度扰动:室外飞行时一天里的光照变化剧烈,我加了轻微的HSV扰动,但强度不能太大,否则训练出来的模型在暗光场景会误检。
  • 马赛克增强(Mosaic Augmentation):把四张图拼接成一张训练图片,可以显著提升小目标检测能力。这个方法在无人机视角下尤其好用,因为单张俯拍图里目标往往不多,四张拼接后整个batch的样本多样性提高很多。
  • 水平翻转与旋转:无人机飞行姿态变化很快,旋转增强采用90度、180度、270度随机旋转,而不是只做水平翻转。

我之前犯过的低级错误是在数据增强时直接对图片做了resize,但没有同步修改标注框的坐标,训练出来的模型在验证集上分数看起来还正常,一部署到真机上就原形毕露,边界框完全偏到目标旁边。所有增强操作都必须先处理标注坐标,再处理像素变换。

3. 推理端优化:非极大值抑制与多尺度预测

3.1 如何从13×13×21的原始输出得到最终检测框

训练完成后,模型输出的是密密麻麻的候选框,绝大多数置信度极低,不能直接使用。推理阶段要经过三步:阈值过滤、非极大值抑制、坐标还原。

阈值过滤很简单:把置信度低于0.3的框全部去掉。然后对剩下的框按类别做NMS,核心思想是—如果两个框的交并比很高并且置信度差异不大,只保留置信度最高的那个。这一步在无人机跟随场景里特别关键,因为同一个目标可能会被相邻网格重复检测,如果不过滤,控制模块会收到多个互相打架的目标位置,无人机就会来回抖。

坐标还原需要注意:模型输出的是相对于网格偏移的归一化值,要还原到原图坐标需要乘以每个网格的尺寸和输入图片相对原图的缩放比例。课程里提供的工具函数写得很清楚,但我在实际使用时发现边界处理有个细节—如果预测框的中心点落在图像边缘,还原后的坐标可能会超出图像范围,需要裁剪到[0, width]和[0, height]区间,否则绘制检测框时程序会直接崩溃。

3.2 多尺度预测对跟随稳定性的提升

单尺度预测在小目标场景下表现不佳,于是我在Keras模型里增加了两个额外的检测头,分别输出26×26和52×52的特征图。这种多尺度架构也是YOLOv3的核心思想:大尺寸特征图负责检测小目标,小尺寸特征图负责检测大目标。

实际感受是:无人机在10米高度飞行时,目标在画面中很小,单尺度模型经常漏检,加入52×52检测头之后召回率明显改善。不过多尺度带来的问题也很直接——模型推理时间变长了,在低功耗设备上帧率可能从30FPS掉到18FPS。我的做法是动态切换策略:当检测目标在画面中比较大时,跳过52×52检测头的计算,直接使用13×13和26×26的结果;当小目标数量较多时,再启用全部分支。

这个动态策略在Keras里实现起来并不复杂,推理时不采用model.predict直接调用,而是分阶段获取中间层输出:

from keras.models import Model # 假设原始model里三个检测头的输出层分别命名为out_large, out_mid, out_small inference_small = Model(model.input, model.get_layer('out_small').output) inference_mid = Model(model.input, model.get_layer('out_mid').output) inference_large = Model(model.input, model.get_layer('out_large').output) # 根据画面目标尺寸决定是否计算全部头

这个方法的好处是控制端可以基于前一帧的目标框面积动态决定是否启用大特征图检测头,在稳定跟随阶段和重新捕获目标阶段使用不同推理路径,既保证了召回又保住了帧率。

3.3 Keras模型转TensorFlow Serving的部署细节

课程项目最终会要求在模拟环境或真实无人机上运行,所以Keras模型不能只停留在训练阶段。我部署时遇到的问题比想象中多,单独记录一下:

模型保存时,我一开始用了model.save('detect_model.h5'),加载时直接keras.models.load_model。在训练机上没问题,但拷贝到Jetson设备上后,由于TensorFlow版本不同,自定义损失函数和Lambda层无法反序列化。解决方案是改用model.save_weights加重新构建模型结构的方式,保证目标环境代码与训练环境一致。

导出TensorFlow Serving格式时,需要注意模型输入尺寸必须是动态的。无人机图像分辨率不固定,我将输入维度设为(None, None, 3),也就是允许任意宽高,然后在模型内部用tf.image.resize统一缩放到416×416。这样部署后输入原图尺寸即可,省去了上层应用手动resize的麻烦。

另一个隐藏问题:TensorFlow Serving默认启用了批处理优化,当单张推理请求频繁到来时,批处理反而会增加延迟。只做视觉跟随的无人机系统,我建议把enable_batching设为false,并给模型设置maximum_batch_size=1。

4. 目标跟踪与丢失恢复:从检测到跟随的最后一公里

4.1 为什么单帧检测不够用,需要引入跟踪器

检测模型处理的是单帧图像,而跟随任务需要的是稳定的时间序列关联。当目标短暂被遮挡或者检测模型偶发漏检时,只靠检测框会出现目标闪烁消失又出现的情况,直接控制无人机就会导致急刹和转向。我在检测结果之上叠加了一个轻量级跟踪器,选择的是经典的相关滤波思路或者简单的匈牙利匹配。课程里给了Kalman滤波和光流的示例,但它们在单目无人机场景下参数调整比较麻烦,我最终用的是基于IoU的帧间匹配策略。

逻辑非常简单:每帧检测框集合记为D_t,上一帧跟踪框集合记为T_{t-1},计算所有D_t与T_{t-1}之间的IoU矩阵,用匈牙利算法做最大匹配。匹配上的检测框更新跟踪框位置,没有被匹配上的检测框作为新轨迹,连续消失超过设定帧数的跟踪框被删除。

这套策略的优势是零参数、计算量极小,在Keras推理之上多花不到1毫秒。缺点是无法处理目标快速移动导致相邻帧IoU小于匹配阈值的情况,比如无人机大角度旋转时。所以我又加了一步:如果检测目标在画面边缘快速移动,就跳过IoU匹配,直接用上一帧速度外推目标位置。

4.2 目标丢失后的搜索策略

跟随过程中目标丢失是必然会发生的事,尤其是无人机转向或者目标被障碍物遮挡时。处理丢失的策略决定了系统是否“可用”。我的做法分三个等级:

  • 一级丢失(连续1-2帧丢失):保持当前悬停位置,扩大检测范围,增加scale抖动,等待目标重新出现在画面中。
  • 二级丢失(连续3-10帧丢失):启动滑翔搜索模式,无人机以固定半径在目标丢失位置附近做圆周运动,同时相机朝向搜索区域。
  • 三级丢失(超过10帧):判断目标彻底丢失,无人机返回到目标丢失前的保持高度,并发送报警信息给地面站。

实现时要注意:丢失期间无人机的控制指令需要平滑过渡,不能在检测恢复瞬间突然加速。我在控制端加了一个平滑滤波器,期望速度变化率限制在每秒不超过0.5m/s。这个小改动看起来不起眼,但实测中能明显减少“丢失恢复后无人机猛冲出去”的危险情况。

4.3 控制信号转换:像素坐标到机体速度指令

检测和跟踪模块最终输出的是图像坐标系中的目标中心点(u, v)和框面积s。为了让飞控执行跟随,需要将像素坐标转换为无人机机体坐标系下的速度指令。

我用一个比例控制器:

vx = Kp_x * (u - u_center) / u_center vy = Kp_y * (v - v_center) / v_center vz = Kp_z * (target_area - current_area) / current_area

这里的vx对应无人机左右速度,vy对应前后速度,vz对应上下速度。比例系数根据飞行高度和相机视场角调整。课程里给出的方法更严谨,会考虑相机内参矩阵,将像素坐标反投影到相机坐标系再计算位移,但我用归一化偏差作为速度输入,实现简单且对硬件标定误差有很强的鲁棒性。

需要注意的细节是:像素坐标到速度指令的延迟问题。相机采集图像、模型推理、控制指令下发都有延迟,在快速移动目标场景下,直接比例控制会产生明显的滞后振荡。我发现给速度指令增加一个基于目标历史轨迹的预测修正项,也就是用目标在两帧间的像素位移估算目标的移动速度,叠加到控制器输出里,跟随效果立刻改善。这个修正项本质上是一个前馈补偿,不需要复杂的卡尔曼滤波器也能跑得不错。

5. 实际测试中遇到的问题与解决办法

5.1 光照变化导致的检漏与误检

无人机室外飞行,最常见的干扰是阴影和强光。我最初用仿真数据训练的模型在真实阳光直射环境下,目标框置信度从0.85直接掉到0.35以下。经过排查发现是训练数据里对比度过低,模型对高光区域的鲁棒性不足。

解决办法有两步:

  • 数据层面,在真实场景采集数据时覆盖不同时间段、不同天气条件,下午逆光、正午顶光、阴天散射光都要有。增强阶段对亮度通道做更大幅度的调整。
  • 算法层面,将输入图像在进入网络前先做自动白平衡和直方图均衡化。这样虽然增加了少量预处理时间,但检测稳定性提升非常明显。

实测中,我还在检测结果上增加了一个“置信度平滑”逻辑:连续两帧检测同一目标的置信度均高于0.5才认为成功捕获,利用帧间信息降低单帧误检。

5.2 无人机抖动导致的目标框抖动

无人机悬停时的机身微振动会让检测框在目标周围小幅跳动,如果不处理,跟随控制指令会不停抖动。我做了两级平滑:

  • 坐标平滑:对目标中心点位置做指数滑动平均,权重系数设为0.6,也就是当前帧坐标权重0.6,上一帧结果权重0.4。
  • 速度指令平滑:控制器输出的速度指令再过一次低通滤波,截止频率设为2Hz左右。这样能滤掉身体的机械振动频率。

这里有一个权衡:平滑过度会导致目标快速移动时跟随滞后,平滑不够又会抖动。我的经验是优先保证不抖动,因为稳定悬停比快速跟踪更重要,在程序里设置死区:目标中心偏差小于图像宽度10%时,不输出修正速度指令。

5.3 模型在低分辨率下的退化

为了跑实时推理,我把输入分辨率从608降到416再降到320,目标检测准确率随之下滑。损失最大的是小目标召回率,从82%直接掉到54%。我后面用了一个折中方案:推理时先用320分辨率快速扫描画面,如果检测到目标置信度较低(低于0.5),再用416分辨率重新推理一次,以低分辨率结果作为初始区域提示,在高分辨率下精修目标框。

这个两级推理方案在实际跟随中表现很好,目标在画面中较大时全程使用低分辨率推理,帧率稳定在25FPS以上;目标较小或置信度不足时才切换高分辨率模式,整体平均帧率依然能维持在18FPS。

5.4 安全降落与停止逻辑

跟随任务结束时,无人机的行为逻辑也需要设计。目标停止移动时,检测框面积保持稳定,此时不能直接降落,因为目标可能位于不适合降落的地形。我在降落前增加了一个目标场景区域判定:目标周围一定范围内没有高置信度的障碍物检测结果时才允许降落。这个逻辑虽然简单,但避免了一次在草坪上方直接触地炸机的事故。

另外,电池电量低于30%时应触发自动返航,而不是继续跟随,这个逻辑不依赖视觉模块,但必须在任务管理层里和视觉跟随并行运行。课程模拟环境一般不会强制要求这部分,但真实场景我觉得这是底线安全功能。

6. 复现这个项目的建议路径

6.1 环境搭建与依赖版本

我使用的环境配置如下,供参考:

组件版本
操作系统Ubuntu 18.04 / 20.04均可
Python3.7及以上
TensorFlow2.4左右(Keras集成在主包中)
CUDA10.1或11.0,取决于显卡
无人机仿真AirSim或课程自带模拟器
图像标注工具labelImg或labelme

需要注意,Keras作为TensorFlow的子模块使用时,keras.layers与tensorflow.keras.layers混用会导致模型序列化出问题。代码里我全部使用tensorflow.keras的接口,统一风格,避免排查低级错误。

6.2 分阶段训练流程

不要一上来就端到端训练完整检测模型,我建议按下面几步走:

  1. 先在公开数据集上微调MobileNetV2骨干网络,目标是让骨干具备通用特征提取能力。
  2. 冻结骨干网络,只训练检测头,用无人机视角数据集训练50个epoch左右。这一步收敛很快,让我能快速验证标注数据是否对齐。
  3. 解冻部分骨干层,降低学习率,用全量数据训练到收敛。解冻层数不宜过多,我解冻了最后三个深度可分离卷积块,效果最好。
  4. 在真实场景数据上继续微调20个epoch,只调整最后两层检测头,防止过拟合仿真数据。

每一步训练完都保存检查点并在验证集上测试,中途如果发现损失不下降,基本都是数据标注或数据增强的问题,而不是网络结构的问题。

6.3 从模拟器到真实无人机的迁移

模拟器数据训练出来的模型直接上真机,效果会有折扣。我实测的折扣主要来自两方面:一是模拟器纹理过于干净,真实场景噪声较多;二是模型对运动模糊非常敏感,真机动起来之后画面模糊,检测框会错位。

解决运动模糊问题,我是在数据增强阶段加入了随机运动模糊的模拟,用OpenCV的卷积核模糊模拟相机运动拖影。虽然简单粗糙,但迁移效果提升明显。

6.4 一个重要的反思:检测精度和跟随稳定性的关系

做完整套系统后,我越来越觉得,目标检测模型在跟随任务里的指标不能只看mAP或precision,更要关注输出框的连续稳定性。一个mAP略低但每帧输出框位置稳定的模型,在跟随控制中的表现往往优于一个mAP更高但框跳来跳去的模型。

所以我的调优顺序是:先保证帧率稳定,再优化框的连续性,最后才提升检测精度。这一点是课程项目里没怎么强调,但实际开发和部署时非常关键的认知。

拉长时间看,跟着课程做这个项目,最大的收获其实不是拿到了一个多少精度的检测模型,而是理解了视觉感知、状态估计、控制决策三者之间怎么衔接。每个单点技术都有大量现成资料,但把它们串成一个能在无人机上稳定跑起来、并且应对各种异常情况的系统,才是真正的工程能力所在。如果后续你想继续深入,可以尝试加入深度估计模块,把单目视觉的像素坐标提升到三维空间坐标,跟随算法会有质的变化。

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

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

立即咨询