一句话总结

DroneScan-YOLO通过四个协同设计——更高分辨率、冗余感知剪枝、stride-4检测分支、Wasserstein损失——让YOLOv8在无人机图像上的微小目标检测提升16个mAP点,同时维持96.7 FPS推理速度。

为什么UAV微小目标检测这么难?

无人机视角的目标检测是天然的极端多尺度场景

  • 4K图像中,行人可能只占 8×12 像素
  • 同一张图里,近处车辆可能占 200×150 像素
  • 尺度比例差达到 25×,远超标准检测器的设计假设

更坏的消息是:这不只是分辨率问题。YOLO系列面对三个系统性失效

  1. 最小检测步长限制:YOLOv8的P3层步长为8,对于20px的行人,特征图上只有 $2.5\times2.5$ 个点——根本没有足够信息判断这是什么
  2. IoU损失零梯度:当预测框和真实框不重叠时,$\text{IoU}=0$,CIoU梯度消失。微小目标训练初期预测框几乎永远不重叠,训练无效
  3. 滤波器冗余:大量滤波器学到相似特征,徒增计算量,没有提升小目标区分能力

三个问题互相纠缠:提升分辨率会加重计算负担,减小网络又损失小目标检测能力——这是系统性矛盾。

背景:特征金字塔的尺度盲区

现代检测器用特征金字塔网络 (FPN) 处理多尺度问题。YOLOv8的结构:

输入 (640×640)
    ↓
P3 (80×80, stride=8)   → 检测中等目标 (32-96px)
P4 (40×40, stride=16)  → 检测大目标 (96-288px)
P5 (20×20, stride=32)  → 检测超大目标 (288px+)

P3已经是最细粒度的检测层。对于20px目标:在P3特征图上只有 2.5 个像素——边界模糊到无法定位。这解释了为什么YOLOv8s在VisDrone上的recall只有0.374,超过60%的目标直接漏检。

DroneScan-YOLO的四个设计选择

1. 分辨率策略:1280×1280

最直接的方案:把输入分辨率翻倍。

分辨率 20px目标在P3上的特征点数 计算量
640×640 2.5×2.5 基准
1280×1280 5×5(4倍信息) ~4×

分辨率翻倍让微小目标从”不够用”变成”勉强够用”,但计算量增加4倍,需要剪枝补偿。

2. RPA-Block:懒惰的冗余剪枝

核心思想:两个滤波器若余弦相似度接近1,其中一个是冗余的,抑制其输出。

关键设计:

  • 10-epoch warm-up:前10个epoch让滤波器自由学习,不干预
  • 懒更新:每5个epoch才重新计算相似度矩阵(避免频繁计算代价)
  • 软剪枝:按冗余程度衰减输出,而非直接置零,梯度仍能流过去
import torch
import torch.nn as nn
import torch.nn.functional as F

class RPABlock(nn.Module):
    """
    冗余感知剪枝注意力块
    warm-up后用余弦相似度检测冗余滤波器,懒更新mask
    """
    def __init__(self, channels, prune_ratio=0.15,
                 warmup_epochs=10, update_freq=5):
        super().__init__()
        self.prune_ratio = prune_ratio
        self.warmup_epochs = warmup_epochs
        self.update_freq = update_freq
        self.current_epoch = 0

        self.conv = nn.Conv2d(channels, channels, 3, padding=1, bias=False)
        self.bn = nn.BatchNorm2d(channels)
        self.register_buffer('channel_mask', torch.ones(channels))

    def update_mask(self):
        """懒惰更新:warm-up结束后每update_freq个epoch执行一次"""
        if (self.current_epoch < self.warmup_epochs or
                self.current_epoch % self.update_freq != 0):
            return
        with torch.no_grad():
            C = self.conv.weight.shape[0]
            w = self.conv.weight.view(C, -1)         # [C, K²]
            w_norm = F.normalize(w, dim=1)
            sim = torch.mm(w_norm, w_norm.t())       # [C, C] 相似度矩阵
            sim.fill_diagonal_(0)                    # 排除自相似

            max_sim = sim.max(dim=1).values          # 每个滤波器的最高相似度
            threshold = torch.quantile(max_sim, 1 - self.prune_ratio)
            redundancy = (max_sim - threshold).clamp(0, 1)
            # 软剪枝:冗余滤波器最多衰减80%,不完全置零
            self.channel_mask = (1.0 - redundancy * 0.8).clamp(0.2, 1.0)

    def forward(self, x):
        out = self.bn(self.conv(x))
        return F.silu(out * self.channel_mask.view(1, -1, 1, 1))

3. MSFD:stride-4的P2检测分支

在P3/P4/P5基础上增加P2层(stride=4)

输入 (1280×1280)
    ↓
P2 (320×320, stride=4)  ← 新增!检测 < 32px 微小目标
P3 (160×160, stride=8)
P4 (80×80, stride=16)
P5 (40×40, stride=32)

代价仅增加 114,592 参数(+1.1%),因为P2分支本身很轻量:

class MSFDHead(nn.Module):
    """
    轻量P2检测分支(stride=4),专为<32px目标设计
    3层卷积,隐层只用64通道,参数量约114K
    """
    def __init__(self, in_channels, num_classes, num_anchors=3):
        super().__init__()
        hidden = 64
        self.branch = nn.Sequential(
            nn.Conv2d(in_channels, hidden, 3, padding=1),
            nn.BatchNorm2d(hidden),
            nn.SiLU(),
            nn.Conv2d(hidden, hidden, 3, padding=1),
            nn.BatchNorm2d(hidden),
            nn.SiLU(),
            nn.Conv2d(hidden, num_anchors * (5 + num_classes), 1)
        )

    def forward(self, p2_feat):
        return self.branch(p2_feat)

4. SAL-NWD:用Wasserstein距离解决零梯度问题

问题根源:微小目标训练初期预测框几乎不重叠,$\text{IoU}=0$,CIoU梯度来源只剩几何惩罚项,信号极弱。

解决方案:把边界框建模为二维高斯分布,用Wasserstein距离衡量相似度。

对于框 $b=(c_x,c_y,w,h)$,建模为 $\mathcal{N}!\left(\mu,\Sigma\right)$,其中 $\mu=(c_x,c_y)$,$\Sigma=\text{diag}((w/2)^2,(h/2)^2)$。

两个高斯分布的2-Wasserstein距离:

\[W_2^2 = \underbrace{(c_{x1}-c_{x2})^2 + (c_{y1}-c_{y2})^2}_{\text{中心距离}} + \underbrace{\left(\frac{w_1-w_2}{2}\right)^2 + \left(\frac{h_1-h_2}{2}\right)^2}_{\text{尺寸差异}}\]

归一化为相似度($C$ 为数据集相关常数,VisDrone@1280 取 $C=12.8$):

\[\text{NWD}(b_1,b_2) = \exp\!\left(-\frac{\sqrt{W_2^2}}{C}\right)\]

优势:即使两框完全不重叠,$W_2^2$ 依然有限,梯度不会消失。

尺寸自适应权重对tiny objects增大惩罚:

\[w_i = \text{clip}\!\left(\frac{\bar{s}}{\sqrt{w_i\cdot h_i}},\;0.5,\;2.0\right)\]
def normalized_wasserstein_distance(pred, target, C=12.8):
    """
    将bounding box建模为2D高斯,计算归一化Wasserstein距离
    pred/target: [N, 4] = [cx, cy, w, h]
    返回: NWD相似度 [N],范围(0,1],越大越好
    """
    center_dist2 = ((pred[:, 0] - target[:, 0]) ** 2 +
                    (pred[:, 1] - target[:, 1]) ** 2)
    size_dist2   = (((pred[:, 2] - target[:, 2]) / 2) ** 2 +
                    ((pred[:, 3] - target[:, 3]) / 2) ** 2)

    w2 = center_dist2 + size_dist2
    return torch.exp(-torch.sqrt(w2) / C)   # NWD

def sal_nwd_loss(pred, target, ciou_loss_per_box, C=12.8):
    """混合损失:尺寸自适应加权CIoU + NWD"""
    sizes = torch.sqrt(target[:, 2] * target[:, 3]).clamp(1e-6)
    w = (sizes.mean() / sizes).clamp(0.5, 2.0)   # 小目标权重更大

    nwd = normalized_wasserstein_distance(pred, target, C)
    loss = w * ciou_loss_per_box + (1.0 - nwd)
    return loss.mean()

NWD与CIoU梯度对比可视化

import numpy as np
import matplotlib.pyplot as plt

target = np.array([100.0, 100.0, 20.0, 20.0])  # 20px的微小目标
offsets = np.linspace(-60, 60, 300)

iou_vals, nwd_vals = [], []
for dx in offsets:
    pred = np.array([100 + dx, 100.0, 20.0, 20.0])
    # IoU计算(x轴偏移,y方向完全对齐)
    inter_x = max(0, 10 - abs(dx))      # 重叠宽度
    iou = (inter_x * 20) / (2 * 20 * 20 - inter_x * 20)
    iou_vals.append(iou)
    # NWD:即使不重叠也有值
    nwd_vals.append(np.exp(-abs(dx) / 12.8))

fig, axes = plt.subplots(1, 2, figsize=(12, 4))
axes[0].plot(offsets, iou_vals, 'b-', linewidth=2)
axes[0].axvspan(-10, 10, alpha=0.15, color='blue', label='有梯度区域')
axes[0].set_title('CIoU:非重叠区域梯度为零')
axes[0].set_xlabel('x轴偏移 (像素)'); axes[0].set_ylabel('IoU')
axes[0].legend()

axes[1].plot(offsets, nwd_vals, 'g-', linewidth=2, label='全程有梯度')
axes[1].set_title('NWD:全程平滑,无零梯度死区')
axes[1].set_xlabel('x轴偏移 (像素)'); axes[1].set_ylabel('NWD相似度')
axes[1].legend()

plt.tight_layout()
plt.savefig('nwd_vs_ciou.png', dpi=150)

预期效果:IoU曲线在±10px外完全平坦,NWD曲线从中心向两侧平滑衰减,全程不为零。

实验结果

VisDrone2019-DET包含10种无人机俯拍目标类别,图像分辨率1920×1080,是这个领域最主要的基准。

方法 mAP@50 mAP@50-95 Recall FPS
YOLOv8s (baseline) 38.7% 23.3% 0.374 ~120
DroneScan-YOLO 55.3% 35.6% 0.518 96.7
提升 +16.6 +12.3 +38.5% -19%

类别级别的提升更能说明问题:

类别 baseline AP@50 DroneScan AP@50 提升
bicycle(最小类之一) 0.114 0.328 +187%
awning-tricycle 0.156 0.237 +52%

Bicycle提升187%不是噱头——在baseline里这个类几乎不被检测到,因为自行车在无人机视角下通常只有10-15px,三个系统性失效同时压在它身上。

工程实践

实际部署考虑

论文的96.7 FPS需要RTX 3090/4090级别GPU。实际部署时:

  • 无人机机载设备(Jetson Orin NX)大约能做到 15-30 FPS
  • 1280×1280分辨率的P2特征图(320×320)额外占用约 2-4 GB显存
  • 生产环境建议至少 8 GB显存

如果追求边缘实时性,可以保留 RPA-Block + SAL-NWD 但降回 640×640 分辨率,牺牲部分精度换速度。

数据采集建议

VisDrone的图像来自固定高度(~50-100m)俯瞰。自采数据时:

  • 飞行高度一致性:高度变化1倍,目标大小变化2倍,训练集高度分布要覆盖实际场景
  • 标注规范:微小目标建议放大4×以上仔细复查,漏标会严重影响recall指标
  • 光照多样性:强光遮阴、黄昏弱光都要覆盖

常见坑

坑1:分辨率提升但精度不涨

letterbox填充使有效像素比例降低,或backbone的stride配置限制了P2特征对齐:

# 正确的1280分辨率预处理(保持宽高比+黑边填充)
import albumentations as A
transform = A.Compose([
    A.LongestMaxSize(max_size=1280),
    A.PadIfNeeded(1280, 1280, border_mode=0),
], bbox_params=A.BboxParams(format='yolo', label_fields=['labels']))

坑2:NWD的C值直接套用VisDrone设置

$C=12.8$ 是针对VisDrone@1280校准的,换数据集需要重新估算:

def estimate_C(boxes_wh, percentile=50):
    """boxes_wh: [(w, h), ...],单位像素"""
    sizes = [np.sqrt(w * h) for w, h in boxes_wh]
    return np.percentile(sizes, percentile)  # 取中位数作为C

坑3:RPA-Block的warm-up设置过短

warm-up过短(<5 epochs)时剪枝太激进,loss会突然上升。发现这个现象时先把 warmup_epochs 调大,再观察 channel_mask 的分布:

# 训练中监控mask分布,正常情况下应该从全1逐渐演化
mask_stats = rpa_block.channel_mask
print(f"mask: min={mask_stats.min():.3f}, "
      f"mean={mask_stats.mean():.3f}, "
      f"pruned_ratio={(mask_stats < 0.5).float().mean():.1%}")

什么时候用/不用?

适用场景 不适用场景
目标平均尺寸 < 32px 目标主要是大物体(>96px)
固定高度俯瞰视角 斜视角、近距离拍摄
场景密集(目标多) 算力极度受限的嵌入式设备
有8GB以上显存的GPU 高速运动目标(需要时序建模)

与其他方法对比

方法 核心策略 mAP@50 (VisDrone) FPS 特点
YOLOv8s 标准检测 38.7% ~120 轻量基准
SAHI(切片推理) 大图切片+合并 ~50% <20 零修改,但极慢
QueryDet 稀疏查询注意力 ~48% 专为tiny objects设计
DroneScan-YOLO 四协同设计 55.3% 96.7 精度+速度平衡最好

SAHI 值得单独说一句:它把大图切成重叠小块分别检测再合并,不需要改网络,工程落地成本极低。如果推理速度要求不高(比如离线处理航测图),SAHI可能是更省事的选择。DroneScan-YOLO是对实时性有要求时的替代。

我的观点

这篇论文最有价值的地方不是数字,而是把三个独立问题打包成了协同系统:分辨率↑、冗余↓、检测粒度↑、损失改进——四个方向互相补偿,不是随机堆砌模块。

三个值得关注的开放问题

  1. 真正的边缘部署数据缺失:96.7 FPS是桌面GPU上的数字。无人机机载通常是Jetson级别,实际速度降一个数量级。论文没有提供嵌入式部署数据,这是实用化的重要缺口

  2. 动态目标的局限:VisDrone里的行人和车辆相对慢速。对高速无人机拦截场景,运动模糊会让tiny objects更难检测,需要引入时序信息,纯空间方法不够用

  3. C值的泛化性:NWD的 $C$ 依赖数据集尺寸分布,跨数据集迁移时需重新校准,限制了即插即用性

对从事无人机检测的工程师:RPA-Block + SAL-NWD 组合值得作为即插即用模块移植到现有pipeline,不必完整复现整个系统。特别是SAL-NWD,对任何tiny objects密集的场景(卫星图像、内窥镜、显微镜图像)都有迁移价值。

论文链接:https://arxiv.org/abs/2604.13278v1