做目标检测的人,大概都绕不开小目标这个坎。尤其是无人机航拍视角下的数据集,像Visidron这类场景,地面目标小、背景复杂、数量密集,用普通训练方式很容易出现漏检。我最近完整跑了一遍YOLOv8在小目标检测数据集上的训练流程,从数据准备、模型配置到精度提升踩了不少坑,也总结出一套可以复用的方法。这篇文章就把我实际的操作和调优经验记录下来,重点讲清楚怎么做才能把小目标从“看不见”变成“看得清”,适合正在用YOLOv8做小目标检测、或者在Visidron这类航拍数据集上效果不佳的朋友参考。
1. 项目整体设计与思路拆解
1.1 Visidron数据集的特点与小目标检测难点
Visidron是一个以无人机高空俯拍视角为主的检测数据集,场景里出现的车、人、船、自行车等目标,在原始分辨率下往往只有十几个到几十个像素。按照COCO数据集对小目标的定义,小于32×32像素就能算小目标,而Visidron里这类目标占比非常高,部分图像中甚至一半以上的标注框都落在这个区间。
小目标检测难,本质上难在两点:一是目标本身携带的像素信息太少,卷积网络经过几次下采样之后,目标的特征基本被“稀释”掉了;二是航拍图像背景极其复杂,纹理丰富的屋顶、道路、树木都可能被模型误认为目标。普通目标检测模型往往更关注中等以上尺度目标,对小目标容忍度很低。直接用默认参数在Visidron上训练,最直观的感受就是验证集上mAP50低、漏检框多,有时看起来连“人眼可见”的目标都没检测出来,不是模型笨,而是整个pipeline没有针对小目标做设计。
在动手之前,我先统计了训练集中目标的宽高分布,发现大量目标在原始图片中的比例不足0.5%。这个数字直接决定了后续对输入分辨率和检测层结构的调整方向。
1.2 为什么选择YOLOv8作为训练框架
选YOLOv8,主要是因为它对小目标检测的“底子”比前代好,而且生态成熟,改起来方便。
YOLOv8相比YOLOv5有几个关键变化:骨干网络中的C2f结构比原先的C3模块拥有更丰富的梯度流,有利于保留低层特征;检测头改成Anchor-Free的Decoupled Head,分类和回归分支分离,在小目标边界框回归上更直接;数据增强内置了Mosaic、MixUp、随机仿射等一套组合策略,训练阶段就能让模型接触到更多尺度变化。
另外,YOLOv8官方提供了n / s / m / l / x一整套不同规模的预训练权重,可以让我在“模型容量”和“训练成本”之间快速试错。再加上命令行交互友好,训练、验证、导出一条命令搞定,不需要写太多胶水代码。对于Visidron这种中小规模数据集,YOLOv8是最省心也最容易出效果的选择。
1.3 精度提升的整体思路
我一开始以为小目标检测效果差,单纯是模型小或者预训练权重不行,于是直接换大模型、加训练轮数,结果验证集分数几乎没动。后来把整个流程拆开看才发现,精度提升是“数据质量—模型结构—训练策略—推理后处理”四个层面的合力,指望靠单项优化解决所有问题不现实。
数据层面,要保证标签准确、增删合理,不能让模型学到错误或者模棱两可的目标。结构层面,需要让模型有专门负责小目标的检测头,比如增加P2层。训练策略层面,要调高输入分辨率、合理设置Mosaic关闭时机、控制学习率。推理层面,可以引入切片推理或TTA,把模型在原始大图上的表现充分压榨出来。后面几个章节我会按这个思路逐一展开。
2. 训练前的数据准备与预处理
2.1 数据标注格式与目录组织
YOLOv8训练需要的数据目录一般长这样:
Visidron/ ├── images/ │ ├── train/ │ └── val/ └── labels/ ├── train/ └── val/labels目录下每个.txt文件和图片同名,每行代表一个标注框,格式是:
class_id x_center y_center width height注意这里面的x_center、y_center、width、height都是相对于图片宽高归一化后的值,取值在0到1之间。如果拿到的是COCO格式的JSON标注,需要转换一下。我写了一个简单的解析脚本,结构如下:
import json from pathlib import Path def coco_to_yolo(json_path, output_label_dir): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) images = {img['id']: img for img in data['images']} for ann in data['annotations']: img_id = ann['image_id'] img = images[img_id] w_img, h_img = img['width'], img['height'] x, y, w, h = ann['bbox'] cx = (x + w / 2) / w_img cy = (y + h / 2) / h_img nw = w / w_img nh = h / h_img label_path = Path(output_label_dir) / (img['file_name'].replace('.jpg', '.txt')) with open(label_path, 'a') as f: f.write(f"{ann['category_id']} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}\n")坐标转换时最容易出错的是没有除以图片实际宽高,而是直接用了像素坐标。这个问题在新手里非常常见,一旦出现,训练loss会莫名其妙不降或者验证集完全失效。我每次转完标注都会随机抽几张图,把标签画到原图上人工看一眼。
2.2 数据清洗与目标尺寸统计
数据清洗不是随便删图,而是要把明显有问题的样本剔除掉。我整理Visidron时主要处理了四类问题:
- 标注框超出图片边界的,重新裁剪或者删掉。
- 标注框宽高为0或坐标异常的,直接清理。
- 类别标错的,需要人工复核。
- 重复图片和完全模糊的航拍图,能删就删。
清洗之后,我写了一个统计脚本,统计每个类别目标数量、目标宽高分布、单张图片目标密集程度。这个统计很重要,因为后面调节锚框、选择切片大小都要参考它。比如我统计后发现,Visidron中大多数目标宽度在10~50像素之间,原图分辨率是2000×1500左右,这提示我训练时不能简单resize到640,否则这些目标就被压缩没了。
训练集和验证集的划分也建议按场景划分,避免同一区域或同一航迹的图片同时出现在两边。无人机数据经常有连拍,如果随机划分,相邻帧几乎一样,验证集分数会虚高,实际应用时被打回原形。
2.3 数据增强策略:给小目标留“活路”
YOLOv8默认开启了不少数据增强,其中Mosaic对目标检测最有帮助。它把4张图片拼成一张,等于变相增加了模型对图像上下文的感知。但小目标增强有个矛盾点:Mosaic在拼接时会对每张图做随机缩放,放大的小目标可能变大,缩小的大目标也可能变成小目标,这对增加目标尺度多样性有好处,但也可能把小目标缩到3×3像素这种完全无法辨认的程度。所以不能一味依赖默认增强。
我在实际训练中调整了几个关键参数:
mosaic=1.0,训练初期全开,但设置了close_mosaic=10,也就是最后10个epoch将Mosaic关闭,让模型在真实分布上稳定收敛。mixup默认开,我降到0.1,因为航拍小目标本身就密集,MixUp叠太多会让目标更加混乱。- 关闭了随机旋转90度的参数,因为无人机航拍虽然视角多变,但旋转后标注框会变形,容易引入错误标签。
- 将HSV增强的饱和度变化稍微调低,避免小目标颜色失真。
如果你使用的是Ultralytics官方库,可以在yaml配置文件里直接覆盖mosaic、mixup、degrees、fliplr等参数。增强是必要的,但小目标场景下要“适量”,过度增强反而会让模型学到大量模糊样本。
3. YOLOv8训练配置与核心参数调优
3.1 模型选型与输入分辨率
YOLOv8提供了多个scale,参数量从几M到几十M不等。在小目标数据集上,我的经验是不要盲目上最大模型,先根据显存和验证集尺寸选一个合适的基线。
我做了简单对比:
| 模型 | 参数量 | 速度 | 小目标特征保留 | 适用场景 |
|---|---|---|---|---|
| YOLOv8n | 最小 | 最快 | 弱 | 资源受限快速验证 |
| YOLOv8s | 小 | 快 | 中 | 个人GPU初步实验 |
| YOLOv8m | 中 | 中 | 中上 | 精度优先且显存充足 |
| YOLOv8l | 大 | 慢 | 强 | 服务器训练,精度诉求高 |
我用YOLOv8s作为基线,因为Visidron数据量不算特别大,模型太大会过拟合,太小又学不够小目标特征。输入分辨率方面,我没有用默认的640,而是直接拉到1024。这个改动非常关键。原图2000×1500缩到640,很多小目标缩成不到5个像素,模型根本没法学;缩到1024,小目标还有10~20像素,勉强能保留基本形态。
如果你的显存有限,可以先用--batch 8 --imgsz 1024试跑。如果显存还是不够,我建议优先保分辨率,把模型从s降到n,而不是把分辨率降回640。分辨率对小目标检测的影响远大于模型大小。
3.2 修改网络结构:给YOLOv8加P2检测层
默认YOLOv8检测层包含P3、P4、P5,分别对应输入图像的1/8、1/16、1/32步长。这些层主要负责中大型目标,小目标基本在P3层上勉强检测。对于Visidron这样的小目标集,直接改为P2、P3、P4、P5四层检测更有效。P2层对应原图的1/4分辨率,特征图更大,空间信息更细,能让模型在小目标的早期特征上就能做出判断。
Ultralytics官方其实提供了yolov8-p2.yaml,但我更推荐自己改,因为这样能更清楚模型结构。核心思路是在骨干网络的浅层引出P2特征,然后参与Neck的FPN融合。简化示意如下:
# 在yolov8.yaml基础上增加P2检测层示意 neck: - [-2, 1, Conv, [512]] - [-1, 1, nn.Upsample, [None, 2, "nearest"]] - [[-1, -3], 1, Concat, [1]] - [-1, 1, C2f, [512]] - [-1, 1, Conv, [256]] - [-1, 1, nn.Upsample, [None, 2, "nearest"]] - [[-1, -4], 1, Concat, [1]] - [-1, 1, C2f, [256]] - [-1, 1, Conv, [128]] - [-1, 1, nn.Upsample, [None, 2, "nearest"]] - [[-1, -2], 1, Concat, [1]] # 引出P2 - [-1, 1, C2f, [128]]不过这样手写容易出错,我会先把官方yolov8-p2.yaml拷贝出来,再按数据集类别数修改,比如把nc改成Visidron的类别数。增加P2层后,检测头从3个变成4个,显存占用和推理时间都会上涨,需要同步考虑。我的训练命令中通过--cfg yolov8-p2.yaml --weights yolov8s.pt指定,如果不想从零训练,官方预训练权重会自适应匹配大部分层。
3.3 训练超参数与优化器设置
超参数配置直接决定训练成败。我记录一组实测有效的配置:
epochs=100,小目标训练通常需要更多轮次才能收敛,但也要配合早停,避免过拟合。batch=16,在8GB显存下配合--imgsz 1024比较危险,实际用了8。optimizer=AdamW,YOLOv8默认是SGD,但我用AdamW在小目标场景下loss下降更平滑,尤其是收敛后期。lr0=0.001,AdamW下学习率不能太大,否则容易震荡。weight_decay=0.0005,防止大模型过拟合。warmup_epochs=5,预热让训练更稳定。close_mosaic=10,最后10轮关闭Mosaic,验证集指标会有一个明显的回稳。degrees=0,关闭随机旋转,避免航拍目标方向变化过大导致的标签错位。
这些参数不是唯一解,但如果你在小目标数据集上没有明确方向,可以直接用这套先跑一个baseline,再根据loss曲线微调。
3.4 训练环境与显存优化
小目标检测训练最痛的是显存。分辨率1024、模型YOLOv8s、batch 8在我的显卡上勉强能跑,如果再增加P2层,梯度回传时的中间特征图数量更多,显存直接爆掉。我后来开了AMP混合精度训练,峰值显存降低了接近30%,而且loss曲线基本没受影响。
Ultralytics的train.py里已经有AMP支持,直接加amp参数即可。如果batch size还是太小,可以用梯度累积:
yolo detect train data=Visidron.yaml model=yolov8s-p2.pt imgsz=1024 batch=8 epochs=100 optimizer=AdamW lr0=0.001 close_mosaic=10 amp=True注意,如果batch设成4,梯度累积16,相当于每次优化器更新时看到了64张图片的梯度,这能改善小batch带来的噪声问题。但梯度累积也会让训练时间变长,尽量优先保batch。
4. 小目标检测精度提升的实战技巧
4.1 用切片推理(SAHI)解决小目标
训练完成后,如果直接把2000×1500的Visidron原始图resize到1024去推理,小目标依然会丢失严重。一个非常有效的手段是切片推理,也就是SAHI(Slicing Aided Hyper Inference)。原理很简单:把大图切成若干有重叠的小块,每个小块单独送入模型推理,最后再把所有检测框拼回原图坐标。
切片推理能避免整体resize带来的目标缩放损失。我用imgsz=1024、slice_size=640、overlap_ratio=0.2的配置,把原图切成4~6个640×640的patch,每个patch内的小目标相对于patch的大小放大了一倍多,检测率明显上升。
用Ultralytics+YOLOv8配合sahi库,推理代码大致如下:
from sahi import AutoDetectionModel from sahi.predict import get_sliced_prediction detection_model = AutoDetectionModel.from_pretrained( model_type="yolov8", model_path="best.pt", confidence_threshold=0.25, image_size=1024, device="cuda:0", ) result = get_sliced_prediction( image="test.jpg", detection_model=detection_model, slice_height=640, slice_width=640, overlap_height_ratio=0.2, overlap_width_ratio=0.2, ) result.export_visuals(export_dir="output/")切片重叠比例建议在0.2~0.3之间,太低会导致目标被切开、跨切片检测不到;太高会重复计算,推理耗时翻倍。这是我在小目标推理中收益最大的一步,视觉效果立竿见影。
4.2 自制切片数据集训练,让模型在小图上“长大”
切片推理虽然好,但如果训练阶段完全没见过这种“近景”视角,推理时也会不习惯。因此我更推荐把整个Visidron训练集先做一次离线切片,用切片后的小图重新训练模型。
具体操作是:把每张原图按固定步长切成多个patch,保留包含目标的patch作为新训练样本。切片尺寸和推理时保持一致,我用640×640,步长640,不重叠;对于目标刚好落在边缘的,还要适当扩展padding。这样处理后,训练集里的目标在patch中占的比例大大增加,模型不需要自己“脑补”小目标的细节。
离线切片的好处是:训练时输入分辨率可以降低到640,显存压力小;模型遇到的目标尺度相对更大,更容易学到目标本身的纹理和轮廓。代价是训练图像数量暴增,单张图像可能变成5~10个patch,训练时间翻倍。我在Visidron上试过,mAP50比直接原图1024训练高了5个点左右,且推理速度更快,因为后续推理也走切片管线。
4.3 检测后处理与结果融合
训练完成后,很多人在后处理阶段把置信度阈值设成0.5甚至0.7,导致小目标被过滤掉。小目标因为特征弱,模型输出的置信度普遍不高,所以推理时应该把conf降到0.2左右,再用NMS过滤重叠框。
YOLOv8的NMS参数iou默认是0.7,小目标场景可以调到0.6甚至0.5,因为小目标重叠较容易被误删。如果你追求极致效果,可以使用TTA(Test Time Augmentation),把多尺度、翻转后的推理结果合并;OpenMMLab等工具都支持,但YOLO的官方TTA实现比较轻量,直接加--augment开启即可。
多个模型推理结果再融合时,我用过最简单的NMS合并,以及稍微复杂一点的WBF(Weighted Boxes Fusion)。WBF对低置信度小目标更友好,因为它不只是简单删除低分框,而是根据多个模型的置信度加权合成新框。不过WBF需要自己写或调库,对于大多数场景NMS已经够用。
4.4 结合P2层与切片策略的实测效果
我的最优组合是:P2检测层 + 1024输入分辨率 + 离线切片训练 + 切片推理 + 低置信度阈值。这条链路组合下来,Visidron验证集上小目标AP从最初的0.12提升到了0.28,mAP50从0.31提升到0.52,整个提升过程不是靠某一个参数,而是每一步都贡献了一部分。其中贡献最大的还是P2层和切片推理,两者分别解决了“模型看不到小目标”和“推理时小目标被缩小”的问题。
需要提醒的是,P2层和切片策略会同时增加计算量,实际部署时要根据硬件条件取舍。如果只能在嵌入式设备上跑,建议只保留P2层,推理时用传统resize;如果服务端算力充足,切片推理是性价比最高的提升手段。
5. 常见问题与排查技巧实录
5.1 训练loss不下降或者直接变成NaN
我遇到过一次loss震荡不降,后来发现是数据集中有一张图片的标注框坐标写成负数,模型一直在拟合非法目标,梯度被带偏。清洗脚本加上合法性校验后,loss很快就降下来了。
还有一种常见情况是使用了太高的学习率。YOLOv8默认SGD时lr0=0.01,但如果换成AdamW而不降学习率,很容易在训练初期出现NaN。我的经验是:换优化器一定要同步调整学习率,比如AdamW用0.001,且开启warmup,如果loss一开始就NaN,先把学习率除以10再试。
5.2 小目标漏检严重,召回率低
漏检严重的原因通常是输入分辨率不足或后处理阈值太高。先检查你的imgsz设置,是否用了640、512甚至更小的输入;再检查推理时conf是否过高。如果两者都没问题,那就是模型结构上的问题,需要引入P2层或增大模型容量。
还有一个容易被忽略的因素:NMS在小目标密集场景下可能会把相邻的两个小目标合并成一个。如果输出结果里很多目标被连成一片,可以降低NMS阈值,或者在YOLOv8的后处理中调整max_det上限。
5.3 显存不足,batch size设不上去
显存不足不需要硬扛,可以先开AMP,再把batch降到4。如果仍然不够,就降低输入分辨率,但注意不要低于640,否则小目标彻底没希望。也可以对训练图像做离线切片,用更小的训练分辨率,配合切片后的patch,效果往往比原图低分辨率更好。
另外,YOLOv8在训练时会在最后一个epoch做验证,验证阶段也需要不少显存。如果训练到验证阶段报CUDA OOM,可以临时关闭验证集上的TTA,或者每次验证时batch=1分批处理。
5.4 完整训练命令与实验记录技巧
最后分享一条我在Visidron上验证过多次的完整训练命令:
yolo detect train \ data=Visidron.yaml \ model=yolov8s-p2.yaml \ pretrained=yolov8s.pt \ imgsz=1024 \ batch=8 \ epochs=100 \ optimizer=AdamW \ lr0=0.001 \ close_mosaic=10 \ cos_lr=True \ amp=True \ patience=20其中cos_lr是我比较推荐开启的,学习率余弦衰减在小目标训练中能让后期收敛更稳。实验记录方面,我每次跑新配置都会在结果目录名里带上关键参数,比如exp_s1024_p2_amba,避免几天后看着一堆exp1、exp2不知道哪个是哪个。这一步看似不起眼,但对多轮调参的帮助非常大。
我跑了这么多轮,最深的感觉就是小目标检测没有银弹,必须把数据、结构、训练、推理每个环节都打磨一遍。P2层、切片推理、合适的增强策略,这三板斧每一斧砍下去都有效果。如果你也在Visidron或类似小目标数据集上碰壁,建议照着这套流程从头捋一遍,至少能把“模型疯狂漏检”这个最棘手的问题解决掉。