无人机视角下的目标检测,一直是个很让人头疼的场景。地面拍摄的数据集里目标通常又大又清晰,到了无人机俯拍图里,一辆车在两千多像素宽的画面里可能只占二三十个像素,这种小目标别说人工标注费劲,模型训练的收敛难度也完全不是一个量级。这篇文章想聊的,就是我拿 YOLOv8 在公开的 VisDrone 无人机视角数据集上做小目标检测的完整过程——从数据格式转换、基线训练,到输入分辨率、切图、数据增强、检测头改进这一系列精度提升手段。无论你是刚接触检测任务的学生,还是要在航拍、卫星、高位监控这类小目标场景里落地模型的工程人员,文里的经验应该都能直接搬过去用。
先说明一下,标题里的 Visidron 其实就是 VisDrone 的常见笔误,下文统一用 VisDrone。这个数据集的难点非常典型:图像分辨率极高,目标在上面显得极小,同时目标密集、遮挡严重,很适合用来验证小目标检测的提升思路。
1. 项目背景与数据集认知
1.1 VisDrone 到底难在哪
VisDrone 是一个无人机视角下的公开目标检测数据集,图像全部来自真实航拍,涵盖城市、乡村、港口、球场等场景。标注类别一共有 10 个有效类别:行人、人、自行车、轿车、面包车、卡车、三轮车、带篷三轮车、公交车、摩托车。注意它还有一类 ignore 区域是用来遮挡标注的,转换时要过滤掉。
这个数据集最核心的难点是目标尺度。按照通用定义,小于 32×32 像素的目标就算小目标,而 VisDrone 里大量目标连这个尺寸都达不到。我随机统计过一批训练图像里的标注框大小,在 2000×1500 左右的原始分辨率下,相当比例的目标宽高只有十几个像素。这类目标经过模型 backbone 的多次下采样之后,在深层特征图上基本就变成一两个像素点,信息量已经近乎丢失。
同时,无人机俯拍视角下目标非常密集。一个街区画面里可能有上百辆车和行人,相互遮挡、堆叠,导致检测框很容易被 NMS 误杀。再加上光照变化、阴影、视角倾斜,这数据集想刷高 mAP 确实不容易。对做工程的人来说,VisDrone 练出来的模型往监控、巡检、安防场景迁移,泛化能力往往都还不错,这也是我选它的原因。
1.2 为什么选 YOLOv8 作为基线
选 YOLOv8 不是因为它是当前精度最高的检测器,而是因为它的训练成本、部署生态、迭代效率综合起来最适合做这种小目标专项优化。
YOLOv8 相比之前的版本有几个重要改进:backbone 改用 C2f 结构,颈部沿用 PAN-FPN,检测头换成 Anchor-Free 的 Decoupled Head,分类和回归分支解耦,损失函数用 CIoU 加 DFL。这些改动让它在训练时会更快收敛,对密集场景的抗干扰能力也更强。
但要说清楚:YOLOv8 默认配置并不是为小目标设计的。它默认输出三个尺度的特征图,最小 stride 是 8,也就是说一个 8×8 像素的小目标,在最小尺度的特征图上只占 1 个像素。所以用默认参数跑 VisDrone,效果一般是中等偏下,这就给了我们后面一系列调优动作的空间。
1.3 整体技术路线
我的完整路线分四个阶段,每一步都有明确目的,这样出了问题能快速定位。
| 阶段 | 核心操作 | 预期效果 |
|---|---|---|
| 数据准备 | 格式转换、过滤 ignore、目录重整 | 训练流程不卡壳 |
| 基线训练 | 用官方预训练权重跑 640 分辨率 | 跑通流程,暴露瓶颈 |
| 精度提升 | 分辨率提升、切图、数据增强、结构改动 | 小目标召回率明显上涨 |
| 后处理与部署 | NMS 调参、TTA、模型导出 | 提升最终推理效果 |
这个路线最好的地方在于,每一步都可以独立验证效果。如果直接一上来就同时改一堆东西,后面根本分不清是谁起的作用。这也是做算法优化最重要的习惯:一次只动一个变量。
2. 环境准备与数据转换:先把地基打牢
2.1 硬件与训练环境清单
训练 YOLOv8 的门槛其实不高,但 VisDrone 训练图像很大,如果显卡显存不够,分辨率提升这一步就会非常吃力。我的建议是至少 8GB 显存起步,理想是 24GB 以上。显存小也不是不能做,后面会提到梯度累积和切图训练这两个绕开显存限制的办法。
软件环境方面,我使用的是常规的 Python 3.8 及以上版本,PyTorch 建议装对应 CUDA 的官方版本。模型部分直接安装 YOLOv8 官方 Python 包即可,安装命令:
pip install ultralytics装完后在命令行敲yolo如果能弹出提示,说明环境就绪。这里有个小坑:如果你同时装了多个深度学习框架,要留意依赖版本冲突,建议用独立的虚拟环境,别把系统环境搞乱。
2.2 VisDrone 标注转 YOLO 格式
VisDrone 官方给的是 txt 标注,每一行格式为:
<bbox_id>, <x>, <y>, <w>, <h>, <score>, <category>, <truncation>, <occlusion>其中x, y是目标左上角坐标,w, h是宽高,都没有归一化。category 为 0 表示 ignore 区域,1 到 11 对应具体类别。YOLO 格式需要的是归一化后的中心点坐标和宽高:
<category> <x_center> <y_center> <w> <h>所以转换脚本要做四件事:读取原始标注、过滤掉 ignore 和目标面积过小或 score 为 0 的框、把 category 减 1 映射到 0-9、最后做归一化。
下面是我实际用过的转换脚本,可以直接参考:
import os import glob def visdrone2yolo(src_dir, dst_dir, img_width=2000, img_height=1500): os.makedirs(dst_dir, exist_ok=True) for txt_path in glob.glob(os.path.join(src_dir, "*.txt")): base = os.path.basename(txt_path).replace(".txt", "") out_path = os.path.join(dst_dir, base + ".txt") with open(txt_path, "r") as f, open(out_path, "w") as out: for line in f: parts = line.strip().split(",") if len(parts) < 7: continue x, y, w, h, score, category = map(float, parts[0:6]) # 过滤 ignore 区域和无效标注 if category == 0 or score == 0: continue cls_id = int(category) - 1 if cls_id < 0 or cls_id >= 10: continue # 过滤过小的框,像素小于2的基本是噪声 if w < 2 or h < 2: continue x_center = (x + w / 2) / img_width y_center = (y + h / 2) / img_height norm_w = w / img_width norm_h = h / img_height out.write(f"{cls_id} {x_center:.6f} {y_center:.6f} {norm_w:.6f} {norm_h:.6f}\n")需要注意,VisDrone 不同划分的图像尺寸不完全一致,不能像我上面这样写死宽高。更稳妥的做法是先从对应图像文件读取真实宽高再传进来,甚至可以在脚本里用图像库自动获取。我写死 2000×1500 只是为了演示逻辑,实际使用时一定要按每张图的真实尺寸归一化。
2.3 数据集目录与配置文件
转换完成后,目录结构组织成 YOLO 训练的标准形式:
visdrone/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── visdrone.yaml对应写一个数据集配置:
path: /data/visdrone train: images/train val: images/val names: 0: pedestrian 1: people 2: bicycle 3: car 4: van 5: truck 6: tricycle 7: awning-tricycle 8: bus 9: motor这个 yaml 文件里的 names 顺序必须和转换脚本里的类别映射保持一致,否则训练出来的类别标签会错位。另外建议训练前用 YOLOv8 的可视化接口抽查几张图,确认标注框是否贴合目标轮廓。如果标注偏移严重,后面所有的提升都无从谈起。
3. 基线训练:跑通流程,找到瓶颈
3.1 超参数设计
第一次跑基线,我的目标是快速验证数据管线正常,同时拿到一组可靠的对比数据。模型我选择的是 YOLOv8s 而不是 n。原因很简单:n 太小,能力上限低,后面调优时精度涨幅会受限;而 s 的训练成本适中,精度已经有不错的起点。如果你显存确实紧张,用 n 也可以,但基线数字会低一些。
输入分辨率先用 640,这是官方默认值,训练速度快,也能暴露小目标检测的底线问题。其他超参数尽量不动,只把 epoch 加到 200,因为 VisDrone 类别多、场景多,200 轮比较稳妥。batch 大小按显存能承受的最大值来,我的配置是 16,配合默认的自动混合精度 AMP。
这里要解释一个关键点:为什么要用官方 COCO 预训练权重而不是从零训练。YOLOv8 模型在 COCO 上已经学到了丰富的通用特征,尤其是纹理、边缘、形状这些底层信息。VisDrone 虽然场景特殊,但底层视觉特征和 COCO 是通用的,用预训练权重初始化,训练会快得多,精度也通常更高。
3.2 训练命令与过程监控
使用命令行或者 Python API 都可以,我习惯用命令行,参数一目了然:
yolo detect train data=visdrone.yaml model=yolov8s.pt epochs=200 imgsz=640 batch=16 device=0训练过程中要重点盯几个指标:P是精确率,R是召回率,mAP50是 IoU 阈值为 0.5 时的平均精度,mAP50-95是更严格的综合指标。对小目标场景来说,R比P更值得关注,因为小目标本来就容易被漏检,召回率上去了才有后续调整空间。
我自己跑完 200 轮,基线结果大概是 mAP50 在 0.35 到 0.40 之间,mAP50-95 在 0.20 左右。不同硬件、不同随机种子会有浮动,这个数字不关键,关键是看到趋势:模型对行人、公交车这种相对大一点的目标检测尚可,但对摩托车、自行车这类小目标召回非常低,几乎没有超过 0.2。
3.3 基线结果分析
拿到基线后,不要急着改参数,先搞清楚问题到底出在哪。我通常做两件事。
第一,看训练结束生成的混淆矩阵和 P-R 曲线。混淆矩阵能直观显示哪些类别之间容易相互混淆,比如行人和骑自行车的人,在无人机俯拍视角下形态接近,误检率就会很高。第二,把验证集上的预测结果可视化出来,看漏检目标在画面中的分布。你会发现,漏检的大多是面积小于 20×20 像素的目标,尤其是暗光处和密集人群里的目标。
由此可以得出一个结论:YOLOv8 默认 640 输入配合 stride 为 8 的最小检测层,对小目标的特征表达严重不足。后面所有优化都应该围绕一件事来展开:让模型在更大的特征图尺度上看到这些微小目标。这也是 4.2 节切图方案的出发点。
4. 小目标精度提升的五个关键操作
4.1 输入分辨率:性价比最高的参数
第一个动作,把输入分辨率从 640 提高到 1280,或者如果有显存,直接上 1536。原理非常朴素:输入分辨率越高,小目标在图像中占据的像素就越多,模型就有更多特征可以学习。在 stride 为 8 的最小检测层上,一个原始的 16×16 目标在 640 输入下约等于 2×2 像素,在 1280 输入下就变成约 4×4 像素,特征表达能力强了不止一倍。
具体训练命令:
yolo detect train data=visdrone.yaml model=yolov8s.pt epochs=200 imgsz=1280 batch=8 device=0这里 batch 从 16 降到了 8,因为图像面积变大了 4 倍,显存占用剧增。如果显存还是不够,可以加amp=True(默认开启)和accumulate=4,让梯度累积四步再更新一次,等效于 batch 32。
我在实测中发现,仅这一点改动,mAP50 就能提升大约 4 到 6 个点。这是整个调优过程中性价比最高的一步,强烈建议优先尝试。但有一点要注意:训练时用了 1280,推理时也要用 1280 或至少接近这个尺度,不要训练用高分辨率、推理用 640,那样精度会明显回退。
4.2 切图训练与切图推理
提升分辨率的本质,是让每个目标在输入图像里占更多像素。但直接加大分辨率有上限,它受显存约束。切图的思路则是另一个方向:把大图先切成若干小图,每个小图单独训练和推理。这样在不改变模型输入尺寸的情况下,让目标相对尺寸变大。
切图有两种做法。第一种是离线切图训练,把原始 2000×1500 的大图切成 4 到 6 块,比如 1000×1000 带 20% 重叠的子图,过滤掉没有有效目标的空白子图,用这批子图重新训练。这样做训练集的数量会膨胀好几倍,但每个目标在子图中的相对尺度都变大了,模型会更容易收敛。
第二种是推理阶段切图,也就是 Slicing Aided Hyper Inference 的做法。推理时用滑动窗口在原始大图上遍历检测,每个窗口独立推理,最后把所有窗口的检测框映射回原图坐标,做一次全局 NMS 合并。
我建议两者结合:训练阶段可以不做太复杂的切图,直接使用高分辨率训练;推理阶段用滑动窗口切图来弥补训练和推理的尺度差异。下面是一个简单的推理阶段切图伪代码:
import numpy as np def slice_inference(model, img, slice_size=640, overlap=0.2): h, w = img.shape[:2] detections = [] step = int(slice_size * (1 - overlap)) for y in range(0, h - slice_size + 1, step): for x in range(0, w - slice_size + 1, step): patch = img[y:y + slice_size, x:x + slice_size] results = model(patch, imgsz=slice_size, conf=0.25) for box in results[0].boxes: x1, y1, x2, y2 = box.xyxy[0].tolist() score = box.conf[0].item() cls = int(box.cls[0].item()) detections.append([x + x1, y + y1, x + x2, y + y2, score, cls]) # 最后用 torchvision.ops.nms 或 opencv 做合并 return detections这个思路对小目标的提升是非常明显的,在保持模型不变的前提下,mAP50 通常能再涨 3 到 5 个点。代价是推理速度会成倍下降,一个 2000×1500 的大图如果切成 640×640,要跑十几个 patch。实际工程中要按帧率要求做取舍,不能无脑切。
4.3 数据增强策略的取舍
YOLOv8 默认开启了一堆数据增强,其中效果最强的是 Mosaic。Mosaic 把四张图拼在一起训练,能显著提升模型泛化能力,但对小目标并不全是好事。因为四张图拼在一起后,每张图都会被等比缩小,原本就很小目标再次被缩小,很多直接缩到不可见的程度。
解决办法是在训练后期关闭 Mosaic,让模型回到真实的尺度分布上微调。官方超参数里有一个close_mosaic,字面意思是最后 N 轮关闭 Mosaic。比如训练 200 轮,可以设置close_mosaic=10,让最后 10 轮用完整原图训练,这往往能给 mAP 带来一个小幅提升。
Copy-Paste 和 MixUp 这两个增强,我在 VisDrone 上做了对比实验,结果发现对小目标场景收益不大。原因我认为是:Copy-Paste 会在一个位置插入另一个目标,容易产生不自然的遮挡关系;MixUp 则会让目标透明度变低,已经很小的目标在叠加后更加模糊。所以如果时间有限,建议只保留 HSV 颜色扰动、随机翻转和多尺度训练,这类几何和颜色层面的增强对小目标更友好。
多尺度训练也很值得加。YOLOv8 支持在多尺度图像上训练,每轮迭代随机选择一个尺度,这相当于一种免费的尺度抖动,让模型对目标大小更不敏感。
4.4 从模型结构上照顾小目标
除了数据和输入层面,模型结构本身也值得动一动。
我做的第一个结构尝试是增加 P2 检测层。YOLOv8 默认从 stride 8 的 P3 层开始输出检测结果,而 P2 层是 stride 4 的高分辨率特征图,图上是原始图像四分之一大小,小目标的响应会更完整。具体做法是修改 YOLOv8 的 yaml 配置文件,在检测头里增加一个基于 P2 特征的分支。这个改动会增加计算量,但收益很直接。实测在小目标类别上的召回率提升明显,不过对显存和推理耗时都有影响,需要权衡。
第二个尝试是加注意力模块。我测试过在 backbone 末尾接一个 CBAM,以及在颈部加 SimAM。效果比较稳定的是轻量级注意力,参数增加不多,但能帮助模型聚焦目标区域。要注意别加得太重,小目标数据集本身量级不小,太强的注意力模块可能过拟合,反而掉点。
第三类是从损失函数入手。小目标对 IoU 的变化非常敏感,目标本身只有十几个像素,预测框偏移两三个像素 IoU 就骤降,梯度也容易不稳定。常见的改进方向是用 Wise-IoU 或 NWD 这类对小目标更友好的回归损失。NWD 的思路是把边界框建模成二维高斯分布,用 Wasserstein 距离衡量两个框的相似度,对微小偏移的度量更平滑。网上有现成的实现,我接进去后定位精度有了一些改善,尤其是对极小的目标。
4.5 把后处理也当成精度提升的一部分
训练调优做到位了,后处理还能再抠一点分出来。YOLOv8 推理时的默认置信度阈值是 0.25,NMS 的 IoU 阈值是 0.7。在 VisDrone 这种密集场景下,我建议把 NMS 的 IoU 阈值调到 0.5,减少重叠框的误保留,同时把置信度阈值适当提高到 0.3 到 0.4,过滤掉一堆低置信度的误检框。
如果你的应用场景允许更高的召回率,可以开启 TTA。YOLOv8 提供了测试时增强,会对图像做多尺度、多方向变换后综合结果。TTA 对小目标检测效果的提升是稳的,大概能涨 1 到 2 个点 mAP,但推理耗时成倍增加,我一般只在线下评测或离线批处理场景用。
另外对密集目标场景,可以设置max_det,避免同一区域输出太多框。实际在无人机俯拍的密集人群画面里,过高的 max_det 会导致大量低质量预测框涌出,反而降低评估指标。
5. 训练中常见问题与排查实录
5.1 显存溢出
这是做高分辨率和切图训练时最常碰到的报错,典型错误是 CUDA out of memory。排查顺序先看 batch 是不是太大,再看是否关闭了 AMP。解决办法优先级从低到高:降低 batch、开启梯度累积、关闭 Mosaic、降低输入分辨率。注意梯度累积只是延迟了更新频率,并不能减少单步显存占用,它解决的是 batch 太小导致 BN 统计不稳定和收敛变差的问题。
如果你真的需要大分辨率训练但显存只有 8GB,我建议直接用切图方案,把大图切成小块训练,而不是硬撑着跑 1280 分辨率。后者大概率会频繁 OOM,时间全浪费在重启上。
5.2 训练不收敛或指标震荡
训练前几十轮 loss 不降是正常现象,但如果在 100 轮后 mAP 还在震荡上不去,就要考虑学习率问题了。YOLOv8 默认 lr0 是 0.01,对小数据集来说可能偏大。我一般在小目标场景会把初始学习率降到 0.001 到 0.002,然后开启 warmup。另一个隐藏问题是预训练权重没加载成功,如果发现 loss 从特别高的值开始,说明模型可能随机初始化了,检查一下权重路径。
指标震荡还有一个原因是 batch 太小。如果只有 4 或 8,BN 统计不稳定,训练曲线会上下剧烈跳动。这时用 accumulate 增大等效 batch,或者干脆缩小模型尺寸。
5.3 小目标类别漏检严重
如果你发现模型对行人和摩托车几乎不预测,先不要急着改模型结构,先统计一下数据集里每个类别的目标数量。VisDrone 的类别分布严重不均衡,car 可能占比极高,而 awning-tricycle 这种类别数量很少,模型学不好很正常。这种情况下可以做简单的类别加权采样,或者在损失函数里给少数类更高的权重。
另外检查一下数据增强是否把小目标增强得过于模糊。我遇到过 Mosaic 开着导致小目标大量消失,关闭后漏检率明显下降的情况。
5.4 误检过多
误检率和数据质量直接相关。VisDrone 的标注里有大量遮挡严重的框,模型会学到把部分遮挡的人体也预测出来,这在严格评估下算误检。我的处理方式是在转换脚本里过滤掉 truncation 或 occlusion 值过高的标注,让模型只学干净样本。
推理侧把置信度阈值调高是立竿见影的办法,但会牺牲召回率。更推荐的做法是做类别层面的阈值区分,比如对容易混淆的行人和骑车人,单独给一个较高阈值。
5.5 常见问题速查表
| 问题 | 可能原因 | 快速解决方案 |
|---|---|---|
| CUDA OOM | batch 过大、分辨率过高 | 降 batch、开 AMP、用梯度累积 |
| loss 降不动 | 学习率过高或预训练没加载 | 降低 lr0、检查权重路径 |
| 训练震荡 | batch 太小 | 调大 batch 或 accumulate |
| 小类别漏检 | 类别不平衡、目标太小 | 类别加权、切图、提高分辨率 |
| 误检暴涨 | 标注遮挡多、阈值偏低 | 过滤坏标注、调高置信度阈值 |
| 训练慢 | 数据加载瓶颈、分辨率太高 | 开多线程、缩小训练尺寸 |
| 推理掉帧 | 切图太碎、TTA 开启 | 加大切片尺寸、关闭 TTA |
6. 消融对比与落地建议
6.1 我的消融实验总览
下面这组数据是我在某个实验环境下得到的相对提升,环境不同数值会有出入,但它能反映各手段的贡献大小。
| 配置组合 | mAP50 相对提升 |
|---|---|
| 默认 640 输入 | 基线 |
| 1280 输入 | 约 +5 |
| 1280 + 推理切图 | 约 +3 |
| 1280 + 切图 + 关闭后期 Mosaic | 约 +1 |
| 1280 + 切图 + P2 层 + 注意力 | 约 +2 |
| 全部组合 | 约 +11 |
结论很清楚:分辨率提升和推理切图是两大主力,结构改动是锦上添花。如果你的时间和算力有限,优先做前两者。这个顺序对大多数小目标场景都是成立的。
6.2 推理部署时的注意点
小目标模型落地时,精度和速度的矛盾会被放大。我通常会把模型导出为 ONNX,再用 TensorRT 加速,这样在相同分辨率下推理速度能快不少。量化时要特别谨慎,INT8 量化对小目标的特征表达损失很大,可能出现模型每张图都测得到目标、推理时一个都测不到的情况。如果一定要用 INT8,建议用 1280 以上的分辨率和更大的输入图来补偿量化误差,或者干脆用 FP16。
如果业务方只给很紧的推理预算,我建议放弃大分辨率推理,改用切图加小模型方案。比如用 YOLOv8n 在 640 分辨率下切图,速度和精度往往比用 YOLOv8l 全图推理更平衡,因为它让每个目标的相对尺度更大。
6.3 后续还能怎么继续提升
走到这一步,常规手段基本用完了,还想继续提精度就得往更高阶的方向走。我个人经验里有几个方向值得研究。
第一个是伪标签半监督。用当前最强模型在没有标注的测试集上生成预测,挑高置信度的作为伪标签,加入训练集重训。这个迭代在小目标场景里很有效,相当于免费扩充了数据。
第二个是结合检测与分割的强先验。无人机视角下的车辆、行人都有比较规整的轮廓,如果先用分割模型提取前景区域,再引导检测器聚焦前景区域,误检率通常会明显下降。
第三个是尝试新的检测器结构,比如 RT-DETR 这类端到端检测器,或者把 YOLOv8 和 DETR 的优势结合。但这类模型训练成本高,调参也更复杂,建议先在小规模数据上验证可行性再投入。
说实话,小目标检测没有一招鲜的方案,每一次提升都来自对数据和模型的深入理解。VisDrone 这个数据集好在它难度足够大,能逼着你把每一步都做扎实。我做完这一整套实践后的体会是,很多同学一开始就想着改网络结构,这是最不明智的。先检查和优化数据、输入、增强,往往花最小的代价就能拿到最大的提升。只有在这些常规手段都不起作用时,再去动网络结构,你才会真正理解结构改动到底解决了什么问题。这套思路放到任何小目标检测项目里,都是通用的。