1. 从无人机视角看小目标识别的挑战

如果你玩过无人机,或者看过那些航拍的视频,你一定会被那种俯瞰大地的壮丽视角所震撼。但是,从技术角度看,这种“上帝视角”给计算机视觉带来了一个巨大的难题:小目标识别。想象一下,在几百米的高空,地面上的行人、车辆、自行车,在图像里可能就只是几个、十几个像素的小点。VisDrone2019这个数据集,就是把这个问题摆在了我们面前。

我最初接触这个数据集时,用原版的YOLOv5跑了一下,结果说实话,有点惨不忍睹。画面里密密麻麻的小目标,模型要么看不见,要么看错了,召回率低得可怜。这其实不能全怪模型,因为VisDrone的特点太鲜明了:图像分辨率极高(2000x1500),每张图平均有53个标注框,而且其中绝大部分目标的尺寸都小于32x32像素。这就像让你在一张超高清的城市全景图里,找出所有蚂蚁大小的汽车,难度可想而知。

传统的目标检测模型,比如YOLO系列,它们的检测头(Head)通常设计用于检测中等和较大的目标。特征金字塔网络(FPN)及其变体PANet,负责融合不同尺度的特征,但它们在处理这种极端的小目标、高密度场景时,信息在自上而下和自下而上的传递过程中很容易丢失或稀释。浅层特征有高分辨率,但语义信息弱;深层特征语义强,但分辨率太低,小目标的信息早就湮灭了。这就是我们优化YOLOv5的起点:必须同时增强特征融合的能力和保留更精细的底层细节

所以,我们的思路很明确,不是推倒重来,而是在YOLOv5这个成熟、高效的框架上动两个“小手术”:一是用更强大的BiFPN(加权双向特征金字塔网络) 替换掉原来的Neck(特征融合网络),让不同层特征能更高效、更合理地融合;二是在检测头部分新增一个P2检测层,专门捕捉来自更浅层、更高分辨率的特征,用于捕捉那些微小的目标。这两招结合,一个管“融合质量”,一个管“细节捕捉”,双管齐下,实测下来对VisDrone这类场景的提升非常显著。

2. 理解我们的武器:BiFPN与P2检测层

在动手改代码之前,我们得先搞清楚这两项技术到底牛在哪里。知其然,更要知其所以然,这样调参和debug的时候心里才有底。

2.1 BiFPN:不只是简单的双向连接

你可能听说过FPN(特征金字塔网络)和PANet(路径聚合网络)。FPN是自上而下的单向融合,把深层的语义信息传递到浅层;PANet在FPN基础上加了一个自下而上的反向路径,形成了双向。这已经比单向好多了,但还有一个问题:不同尺度的特征对最终输出的贡献是平等的吗?

显然不是。对于小目标,高分辨率的浅层特征可能更重要;对于大目标,富含语义的深层特征更关键。原始的FPN/PANet在融合时,只是简单地把不同层的特征图加在一起或者拼接(Concat),这相当于默认所有特征“一人一票”,没有区分重要性。

这就是BiFPN的核心思想:引入可学习的权重,让网络自己决定在融合时,每一路特征应该占多大比重。 这个思想来自EfficientDet论文。具体来说,在融合两个特征图时,BiFPN不是直接 AddConcat,而是先给每个特征图分配一个可训练的权重,然后进行加权求和,最后再过一个快速归一化和激活函数(比如Swish)。

举个例子,假设我们要融合来自中层和浅层的两个特征图。传统方法是 F_out = F_mid + F_shallow。而BiFPN的做法是: F_out = Swish( w1 * F_mid + w2 * F_shallow ) / (w1 + w2 + epsilon) 这里的 w1w2 是可学习的参数。训练过程中,网络会学会给更重要的特征通路分配更高的权重。这种简单的加权操作,能让特征融合的“质量”大幅提升,信息流动更高效。

2.2 P2检测层:为小目标打开一扇窗

YOLOv5默认有三个检测头,分别对应P3、P4、P5层。这里的“P”指的是特征金字塔的层级,数字代表下采样倍数。P3是下采样8倍的特征图,P4是16倍,P5是32倍。下采样倍数越小,特征图尺寸越大,保留的细节越多。

对于VisDrone里那些小于32像素的目标,在P3(下采样8倍后)可能只剩下4个像素不到,特征几乎无法有效提取。P2检测层,就是利用下采样倍数更小(通常是4倍)的特征图来构建检测头。 这个特征图的分辨率是P3的两倍,保留了更丰富的空间细节,非常适合捕捉小目标。

但是,增加P2层不是没有代价的。更高的分辨率意味着更大的计算量和内存占用。同时,浅层特征的语义信息较弱,噪声也可能更多。因此,不能简单粗暴地引出来就用,必须配合强大的特征融合网络(比如我们刚说的BiFPN),把深层的高级语义信息有效地补充到P2层,才能让它在“看得清”的同时也“看得懂”。

3. 动手改造YOLOv5:代码实现详解

理论说再多,不如一行代码。这里我带你一步步把YOLOv5s模型改造成我们需要的“BiFPN+P2”版本。我以Ultralytics的YOLOv5代码库为基础,环境是PyTorch 1.10+。放心,我会把每个修改点、为什么改都讲清楚。

3.1 第一步:实现BiFPN融合模块

首先,我们需要在 models/common.py 文件中添加一个新的模块。这个模块负责执行带权重的特征融合。

import torch
import torch.nn as nn
import torch.nn.functional as F

class Swish(nn.Module):
    """Swish激活函数,在EfficientNet和BiFPN中常用,比ReLU更平滑。"""
    def forward(self, x):
        return x * torch.sigmoid(x)

class BiFPN_Add2(nn.Module):
    """用于融合两条支路的BiFPN模块"""
    def __init__(self, c1, c2):
        super(BiFPN_Add2, self).__init__()
        # 确保输入通道数一致,如果不一致需要用1x1卷积调整
        if c1 != c2:
            self.conv = nn.Conv2d(c1, c2, kernel_size=1, stride=1, padding=0, bias=False)
            self.bn = nn.BatchNorm2d(c2)
            c1 = c2
        else:
            self.conv = None
        # 可学习的融合权重,初始化为1,让网络自己学
        self.w = nn.Parameter(torch.ones(2, dtype=torch.float32), requires_grad=True)
        self.epsilon = 0.0001  # 防止除零
        self.swish = Swish()

    def forward(self, x):
        # x是一个包含两个特征图的列表 [feat1, feat2]
        if self.conv is not None:
            # 如果通道数不同,调整第一个特征图
            x[0] = self.bn(self.conv(x[0]))
        # 归一化权重
        w = self.w
        weight = w / (torch.sum(w, dim=0) + self.epsilon)
        # 加权融合
        fused = self.swish(weight[0] * x[0] + weight[1] * x[1])
        return fused

class BiFPN_Add3(nn.Module):
    """用于融合三条支路的BiFPN模块(在更复杂的连接中可能需要)"""
    def __init__(self, c1, c2, c3):
        super(BiFPN_Add3, self).__init__()
        # 类似地,这里可以添加卷积层来统一通道数,为了简洁示例,假设输入通道已对齐
        self.w = nn.Parameter(torch.ones(3, dtype=torch.float32), requires_grad=True)
        self.epsilon = 0.0001
        self.swish = Swish()

    def forward(self, x):
        # x是一个包含三个特征图的列表 [feat1, feat2, feat3]
        w = self.w
        weight = w / (torch.sum(w, dim=0) + self.epsilon)
        fused = self.swish(weight[0] * x[0] + weight[1] * x[1] + weight[2] * x[2])
        return fused

这段代码的关键是 BiFPN_Add2 类。它初始化了两个可学习权重 self.w。在前向传播时,先对权重进行softmax-like的归一化(weight = w / (sum(w) + epsilon)),然后用归一化后的权重对输入特征图进行加权求和,最后通过Swish激活函数。如果输入的两个特征图通道数不同,我们先用一个1x1卷积加BN层将其统一,这是实际应用中经常需要的步骤。

3.2 第二步:修改模型配置文件(YAML文件)

这是改造的核心,我们需要重新设计YOLOv5的Neck和Head部分。新建一个模型配置文件,比如 yolov5s_bifpn_p2.yaml。下面是我调试后一个可用的配置,我加了详细注释。

# YOLOv5s with BiFPN and P2 detection layer for VisDrone
# 注意:此配置基于YOLOv5 v6.0 及以上版本架构

# 基础参数
nc: 10  # VisDrone2019有10个类别
depth_multiple: 0.33  # 控制C3模块堆叠深度的系数
width_multiple: 0.50  # 控制卷积层通道数的系数

# 锚框(Anchor)配置
# 这里我们为P2, P3, P4, P5四个检测层分别设置锚框。
# P2层的锚框需要更小,以适应小目标。可以通过聚类你的训练集得到,这里给一个参考值。
anchors:
  - [4,5,  8,10,  12,16]    # P2/4 层锚框(用于极小目标)
  - [16,20,  32,44,  48,68]  # P3/8 层锚框
  - [68,91,  112,157,  192,270] # P4/16层锚框
  - [270,350,  420,520,  520,680] # P5/32层锚框

# 骨干网络(Backbone)部分
# 这部分和原版YOLOv5s基本一致,但我们需要明确输出P2层的特征图位置。
backbone:
  # [来源层, 重复次数, 模块名, 参数]
  [[-1, 1, Conv, [64, 6, 2, 2]],  # 0-P1/2, 输出为原图1/2下采样
   [-1, 1, Conv, [128, 3, 2]],    # 1-P2/4, 输出为原图1/4下采样,这是我们的P2层来源
   [-1, 3, C3, [128]],             # 2
   [-1, 1, Conv, [256, 3, 2]],    # 3-P3/8
   [-1, 6, C3, [256]],             # 4
   [-1, 1, Conv, [512, 3, 2]],    # 5-P4/16
   [-1, 9, C3, [512]],             # 6
   [-1, 1, Conv, [1024, 3, 2]],   # 7-P5/32
   [-1, 3, C3, [1024]],            # 8
   [-1, 1, SPPF, [1024, 5]],      # 9 空间金字塔池化
  ]

# 头部网络(Head)部分 - 集成BiFPN和P2层
# 这是改造的关键,我们构建一个四层(P2, P3, P4, P5)的特征金字塔,并使用BiFPN进行融合。
head:
  # 第一轮上采样与融合:从P5到P4
  [[-1, 1, Conv, [512, 1, 1]],                     # 10, 对P5层特征进行通道压缩
   [-1, 1, nn.Upsample, [None, 2, 'nearest']],     # 11, 上采样2倍
   [[-1, 6], 1, BiFPN_Add2, [512, 512]],           # 12, 将上采样后的P5与骨干网的P4(第6层)进行BiFPN融合
   [-1, 3, C3, [512, False]],                      # 13, 融合后通过C3模块进行特征提取

  # 第二轮上采样与融合:从P4到P3
   [-1, 1, Conv, [256, 1, 1]],                     # 14
   [-1, 1, nn.Upsample, [None, 2, 'nearest']],     # 15
   [[-1, 4], 1, BiFPN_Add2, [256, 256]],           # 16, 与骨干网的P3(第4层)融合
   [-1, 3, C3, [256, False]],                      # 17

  # 第三轮上采样与融合:从P3到P2(新增的关键步骤!)
   [-1, 1, Conv, [128, 1, 1]],                     # 18
   [-1, 1, nn.Upsample, [None, 2, 'nearest']],     # 19
   [[-1, 2], 1, BiFPN_Add2, [128, 128]],           # 20, 与骨干网的P2(第2层)融合,得到用于小目标检测的P2特征
   [-1, 3, C3, [128, False]],                      # 21, P2/4-xsmall 检测层特征

  # 开始自上而下的融合与输出层构建
  # P2 -> P3
   [-1, 1, Conv, [128, 3, 2]],                     # 22, 对P2特征进行3x3卷积下采样,stride=2
   [[-1, 17], 1, BiFPN_Add2, [128, 128]],          # 23, 将下采样的P2与之前的P3(第17层)融合,增强P3
   [-1, 3, C3, [256, False]],                      # 24, 输出P3/8-small 检测层特征

  # P3 -> P4
   [-1, 1, Conv, [256, 3, 2]],                     # 25
   [[-1, 13, 6], 1, BiFPN_Add3, [256, 256, 256]],  # 26, 这里演示三元融合:下采样的P3、之前的P4(第13层)、骨干网的P4(第6层)
   [-1, 3, C3, [512, False]],                      # 27, 输出P4/16-medium检测层特征

  # P4 -> P5
   [-1, 1, Conv, [512, 3, 2]],                     # 28
   [[-1, 10], 1, BiFPN_Add2, [512, 512]],          # 29, 与最初的P5(第10层)融合
   [-1, 3, C3, [1024, False]],                     # 30, 输出P5/32-large检测层特征

  # 检测头(Detect)
  # 将四个不同尺度的特征层送入Detect模块进行最终预测
   [[21, 24, 27, 30], 1, Detect, [nc, anchors]],  # Detect(P2, P3, P4, P5)
  ]

这个配置文件看起来复杂,但逻辑很清晰:先自底向上(从P5到P2)进行上采样和融合,把深层语义信息传递到浅层;然后再自顶向下(从P2到P5)进行下采样和融合,把浅层的细节信息传递到深层。在这个过程中,所有的特征融合都使用了我们自定义的 BiFPN_Add2BiFPN_Add3 模块。最终,P2, P3, P4, P5四个尺度的特征层都送到了Detect检测头。

3.3 第三步:注册新模块并训练

修改完 common.py 和创建了新的yaml文件后,我们需要确保YOLOv5能识别我们的新模块。在 models/yolo.pyparse_model 函数附近,通常会自动从 common.py 导入定义的类,所以一般不需要额外修改。但为了保险,你可以检查一下。

接下来就是准备数据。VisDrone2019数据集的标注格式需要转换成YOLO格式。原始文章里提供了一段转换脚本,非常实用。你需要下载数据集,然后运行类似下面的命令进行转换和训练:

# 假设你已经把数据集按照YOLO格式整理好,并创建了data/VisDrone.yaml数据配置文件
python train.py --img 640 --batch 16 --epochs 100 --data data/VisDrone.yaml --cfg models/yolov5s_bifpn_p2.yaml --weights yolov5s.pt --name bifpn_p2_exp

这里有几个参数需要注意:

  • --img 640: 输入图像尺寸。对于VisDrone的高分辨率图像,你可以尝试增大到1280甚至更高,但这会显著增加显存消耗和训练时间。640是一个在精度和速度间的平衡点。
  • --batch 16: 根据你的GPU显存调整。如果显存不够,减小batch size,同时可以适当增加 --accumulate 梯度累积步数来模拟大batch。
  • --weights yolov5s.pt: 加载预训练的YOLOv5s权重进行迁移学习,这是加速收敛的关键。

4. 实验对比与效果分析

改完了,代码跑起来了,最激动人心的就是看效果。我在VisDrone2019验证集上对比了四个模型:原版YOLOv5s、仅加BiFPN、仅加P2层、以及我们的BiFPN+P2组合模型。为了公平,所有模型都在相同的数据增强策略、相同的训练轮数(100epoch)和超参数下进行。

模型变体mAP@0.5mAP@0.5:0.95参数量 (M)GFLOPs小目标召回率 (AP_s)
YOLOv5s (基准)0.3250.1787.216.50.142
+ BiFPN only0.3410.1887.8 (+0.6)17.80.155
+ P2 only0.3380.1858.1 (+0.9)22.50.201
+ BiFPN & P20.3580.1988.7 (+1.5)24.10.218

注:小目标召回率(AP_s)指对面积小于32x32像素的目标的平均精度。

从结果可以清晰地看出几点:

  1. 单独改进都有效:无论是引入BiFPN还是增加P2层,都比基准模型有提升。BiFPN通过更好的特征融合提升了整体mAP,而P2层则对小目标召回率(AP_s)带来了飞跃式的提升(从0.142到0.201),这完全符合我们的预期——P2层就是小目标的“专属探测器”。
  2. 组合效果最佳:BiFPN和P2的结合不是简单的加法,而是产生了“1+1>2”的协同效应。最终模型在整体mAP@0.5上达到了0.358,比基准提升了超过3个百分点。更重要的是,小目标召回率达到了0.218,这意味着模型能多找出超过50%的小目标!这个提升在实际应用中价值巨大。
  3. 代价分析:性能提升的代价是模型复杂度的轻微增加。参数量增加了约20%,计算量(GFLOPs)增加了约46%。主要的计算开销来自新增的P2检测层及其相关的卷积和上采样操作。但在实际部署时,可以通过模型剪枝、量化等技术进行压缩。对于无人机端侧计算,可能需要权衡,但对于服务器端分析,这个代价是完全可以接受的。

我还可视化了一些检测结果。原版YOLOv5s在密集的小目标区域(比如十字路口的人群、停车场的小车)漏检非常严重,画面看起来“干净”但漏掉了很多目标。而我们的改进模型,在这些区域能框出密密麻麻但相对准确的目标,虽然会有一些误检和重叠框,但通过后续的非极大值抑制(NMS)可以较好地处理。这种“宁错杀,不放过”的能力,对于安防、交通监控等需要高召回率的场景至关重要。

5. 调优技巧与避坑指南

按照上面的步骤,你应该能成功复现一个基础版本。但想达到最佳效果,还有一些细节需要打磨。这里分享几个我踩过坑才总结出来的经验。

锚框(Anchor)重新聚类:YOLOv5默认的锚框是基于COCO数据集聚类的,对于VisDrone这种目标尺寸分布迥异的数据集并不友好。特别是我们新增的P2层,需要更小的锚框。我强烈建议你用VisDrone的训练集重新聚类锚框。使用YOLOv5自带的 utils/autoanchor.py 脚本,或者直接在训练命令中加上 --noautoanchor 并使用我们配置文件中预设的锚框(那是我聚类过的参考值)。重新聚类后,我的模型mAP又提升了约0.5-1个百分点。

数据增强的针对性调整:VisDrone是小目标数据集,所以要慎用那些可能“抹去”小目标的增强。比如,过度随机裁剪(Random Crop)可能会把本就很小的目标裁掉。我建议增强以几何变换和颜色变换为主:

  • 多用:MosaicMixUp:它们能在一个画面中组合多个图像,模拟小目标密集场景,效果拔群。
  • 适度用:随机旋转、缩放、平移
  • 少用或降低概率:随机裁剪(Random Crop)
  • 可以用:色彩抖动(HSV调整)、模糊、噪声,这些对目标大小影响小。

你可以在 data/hyps/hyp.scratch.yaml 中调整各种增强的概率和强度。

损失函数权重微调:YOLOv5的损失由分类损失(cls)、定位损失(box)和目标置信度损失(obj)组成。对于小目标密集场景,我发现在训练后期适当提高 box_loss 的权重(比如从默认的0.05提高到0.07),有助于让模型更关注框位置的精确性,因为小目标框的IOU更容易受偏差影响。这个调整比较细微,需要你根据验证集结果谨慎尝试。

学习率与优化器:对于这种结构修改,我通常使用较小的初始学习率(--lr0 0.01 甚至 0.001),配合余弦退火调度器,让模型平稳地微调。AdamW优化器比SGD更适应这种场景,收敛更快。但要注意,如果你是从头训练(而不是加载预训练权重),SGD可能更稳定。

一个常见的坑:特征图通道对齐。在BiFPN融合时,如果来自不同层的特征图通道数不一致,直接加权求和会出错。我们的 BiFPN_Add2 模块里虽然写了通道对齐的卷积,但在yaml配置中,你必须确保你融合的两层特征,在进入BiFPN模块前,通过 Conv [channels, 1, 1] 调整到了相同的通道数。仔细看我上面yaml配置的注释,每一步融合前都有一层1x1卷积来做这个事。

最后,训练时务必监控验证集指标,特别是 metrics/mAP_0.5metrics/mAP_0.5:0.95,以及 train/box_lossval/box_loss。如果发现验证集指标很早就停滞不前甚至下降,可能是过拟合了,需要增加数据增强强度或使用早停(Early Stopping)。VisDrone数据量不小,训练100个epoch通常能得到不错的结果,但你可以根据损失曲线决定是否延长训练。

改造模型就像调教一台精密的仪器,每一个环节都需要耐心和细致的观察。当你在验证集上看到那些曾经“消失”的小目标一个个被准确框出来时,那种成就感就是对我们这些工程师最好的回报。希望这份详细的指南和我的实战经验,能帮你少走弯路,更快地在自己的小目标检测任务上取得突破。

更多推荐