1. 直接开跑之前:VisDrone 到底难在哪
第一次拿 YOLOv8 训练 VisDrone 数据集的人,十有八九会经历同一种落差:命令行敲下去,训练日志刷刷往上走,loss 看着也在降,心里挺美,结果一百个 epoch 跑完一看验证集,mAP50 卡在三十几,mAP50-95 二十出头。把预测结果叠回原图更扎心——马路上密密麻麻的车,模型只框出来零星几辆,人行道上的人基本靠猜。
这不是模型不行,是数据集和模型的匹配度没调好。VisDrone 属于无人机视角的航拍检测数据集,标注了十个类别:pedestrian、people、bicycle、car、van、truck、tricycle、awning-tricycle、bus、motor。训练集六千多张,验证集五百多张,测试集一千五百多张。数字看着不大,但每一张图里塞的目标数量,往往是 COCO 单图的十几倍甚至几十倍。你要做的不是"把一个通用检测器跑起来",而是"把一个通用检测器改造成能吃航拍数据的形状"。
下面这些内容适合两类人看:一类是刚接触 YOLOv8、想拿 VisDrone 练手但被指标劝退的;另一类是已经在跑、但卡在某个瓶颈上不去,想找找别人是怎么绕路的。我不会只给你几条命令,重点讲清楚每一步为什么这么做、不做会怎样。
1.1 三个绕不开的数据特性
VisDrone 有三件事必须先心里有数,后面所有的配置选择都围绕它们展开。
第一是目标尺寸极端偏小。COCO 里小于 32×32 像素算小目标,而 VisDrone 里大量车辆的宽高只有十几到二十几个像素,行人更小。一张两千万像素的原图,缩到 640 输入尺寸时,一个 20 像素的车只剩 6 个像素——这已经不是"难检",是物理上信息不足。这是后面必须动输入分辨率的根本原因。
第二是密集遮挡。停车场、十字路口、步行街这些场景,目标之间互相压盖是常态。这直接影响后处理,因为标准的 NMS 在目标框高度重叠时会把正确的框也一并压掉,造成"明明检出来了却显示少了"。
第三是类别分布极度倾斜。car 和 pedestrian 两个类别的实例数,可能比剩下八个类加起来还多好几倍。awning-tricycle、tricycle 这类样本少得可怜,模型很难把它们和 car、van 区分开。你会在混淆矩阵里清楚看到它们互相当成对方。
提示:动手之前先花十分钟看一眼原始图片,尤其是那些目标特别密的场景。你对"最难的那几张图长什么样"有概念之后,后面判断指标合不合理会轻松很多。
1.2 八十类 COCO 权重和十类航拍场景的错位
YOLOv8 官方放出来的预训练权重是在 COCO 上训的,八十个类别,绝大部分是生活场景里的东西。直接拿来微调 VisDrone,好处是 backbone 的底层特征提取能力已经不错,收敛快;坏处是检测头那部分参数跟新任务几乎完全不搭,需要较多轮次重新学。
实践中我更倾向的做法是:加载预训练权重,但给足训练轮次,而且不要过早冻结 backbone。有人喜欢一上来冻住前几层"保护特征",在小数据集上这可能有用,VisDrone 训练集六千多张、场景差异又大,冻结反而限制了模型适应航拍视角的能力。我试过前面十个 epoch 冻结、后面解冻的玩法,最终指标和全程不冻结基本持平,甚至略低零点几个点,收益不明显。
另一个容易忽略的点是输入分布差异。COCO 的图大多是平视或略微俯视,VisDrone 是近乎垂直俯视,同一个物体在两种视角下的外观差别非常大——车的形状从侧面变成了俯视的长方形,人的形状从立式变成了一个点状轮廓。这意味着预训练权重能提供的先验有限,别指望"加载预训练就能少训一半轮次"。我给的建议是至少 150 个 epoch 起步,赶时间的场景也别低于 80。
1.3 分辨率、显存和小目标之间的死结
这三者是互相拉扯的。想检出小目标,就得把输入尺寸往上提,提到 1280 甚至 1536;尺寸一上去,特征图面积翻倍,显存占用跟着涨;显存不够就只能砍 batch,batch 一小,BN 统计不稳定,训练又容易抖。
我见过的最典型翻车是:用一张 12G 的卡,imgsz 设 1280、batch 设 16,跑到第二个 epoch 直接 OOM,然后回头把 imgsz 改回 640,训完了骂模型不行。正确的做法是反过来——先把 imgsz 定在能承受的最大值,再拿 batch 去迁就它,batch 小到 4 也能训,只是要把学习率和 warmup 相应调一调。
粗略的显存参考(单卡、AMP 开启、不含验证开销):
| 模型规模 | imgsz | batch | 显存占用大致区间 |
|---|---|---|---|
| n | 640 | 32 | 5G - 7G |
| s | 1280 | 8 | 12G - 16G |
| s | 1280 | 16 | 20G - 24G |
| m | 1280 | 8 | 20G - 26G |
| m | 1536 | 4 | 22G - 28G |
这些数字会随数据增强强度、是否启用多尺度训练浮动,当参考用,不是标准答案。你可以在训练命令里加batch=-1让框架自动探测显存能撑多大的 batch,这个功能挺实用,初次上手时省事。
2. 标注转换:把原始 txt 掰成 YOLO 能吃的形状
拿到 VisDrone 原始包,目录里通常是images/和annotations/两个平行文件夹,标注是每张图对应一个 txt。这个格式跟 YOLO 需要的labels/目录结构、以及每行类别 中心x 中心y 宽 高的归一化格式完全不一样,中间必须做一次转换。这一步看起来枯燥,但转错了后面全白搭。
2.1 一行标注里的八个字段
VisDrone 每行标注是逗号分隔的八个值,顺序是这样的:
bbox_left, bbox_top, bbox_width, bbox_height, score, category, truncation, occlusion前四个是像素坐标下的左上角 x、左上角 y、宽、高。score表示这条标注的有效性,一般填 1,填 0 的应该被丢弃。category是类别 ID,从 1 到 10,其中0 表示忽略区域(ignored region),通常是画面里一大片不关心的地带,比如建筑、天空。truncation和occlusion分别表示截断程度和遮挡程度,取值 0 到 2 这种离散等级,训练时用不上,但做难易样本分析的时候很有价值。
类别 ID 的对应关系必须背下来或者写死在字典里:1 pedestrian、2 people、3 bicycle、4 car、5 van、6 truck、7 tricycle、8 awning-tricycle、9 bus、10 motor。注意 YOLO 要求类别从 0 开始,所以转换时要整体减一。
注意:category 等于 0 的忽略区域绝对不能当成一个类别转进去。我见过有人为了图省事,直接用
category - 1做映射,结果 0 变成了 -1,或者被 clamp 成 0,直接跟 pedestrian 撞了,模型学出来一堆莫名其妙的大框,指标还看着"有数",排查起来极其费劲。
2.2 转换脚本与三类必须处理的脏数据
转换逻辑本身不复杂,麻烦的是脏数据。以下三类如果不处理,训练时会出现各种诡异现象。
第一类是标注里的框宽高为 0 或 1。这种框在原图上看就是一个点,归一化之后几乎为零,训练时算 loss 会出 NaN,或者被当成噪声。直接过滤掉,判断条件是宽或高小于等于 1 像素就丢弃。
第二类是坐标越界。偶尔会出现 right 或 bottom 超出图片边界的框,归一化之后中心点和宽高都大于 1。虽然 YOLO 训练时对越界框有一定容忍,但最好在转换阶段就裁到[0, 1]区间内。
第三类是图片和标注文件不成对。有的图片没有对应的 txt,有的 txt 对应的图片不存在。前者如果不给一个空的 label 文件,框架扫描时会报错或者直接跳过这张图;后者必须在转换时跳过,否则会生成一个指向不存在文件的条目。
以下是我常用的转换脚本骨架,可以直接改路径拿去用:
import os from pathlib import Path from PIL import Image # VisDrone 原始类别 ID -> YOLO 类别 ID CATEGORY_MAP = { 1: 0, # pedestrian 2: 1, # people 3: 2, # bicycle 4: 3, # car 5: 4, # van 6: 5, # truck 7: 6, # tricycle 8: 7, # awning-tricycle 9: 8, # bus 10: 9, # motor } def convert_split(src_root: Path, dst_root: Path, split: str): img_src = src_root / split / "images" ann_src = src_root / split / "annotations" img_dst = dst_root / "images" / split lbl_dst = dst_root / "labels" / split img_dst.mkdir(parents=True, exist_ok=True) lbl_dst.mkdir(parents=True, exist_ok=True) kept, skipped = 0, 0 for ann in sorted(ann_src.glob("*.txt")): img_path = img_src / f"{ann.stem}.jpg" if not img_path.exists(): skipped += 1 continue w, h = Image.open(img_path).size out_lines = [] for raw in ann.read_text().strip().splitlines(): parts = raw.split(",") if len(parts) < 8: continue x, y, bw, bh = map(float, parts[:4]) score = int(float(parts[4])) cat = int(float(parts[5])) # 丢弃无效标注、忽略区域、未知类别、退化框 if score == 0 or cat not in CATEGORY_MAP: continue if bw <= 1 or bh <= 1: continue cx = min(max((x + bw / 2) / w, 0.0), 1.0) cy = min(max((y + bh / 2) / h, 0.0), 1.0) nw = min(max(bw / w, 0.0), 1.0) nh = min(max(bh / h, 0.0), 1.0) out_lines.append( f"{CATEGORY_MAP[cat]} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}" ) (lbl_dst / f"{ann.stem}.txt").write_text("\n".join(out_lines)) # 图片建议用软链接,别复制,省几十个 G 的空间 target = img_dst / img_path.name if not target.exists(): os.symlink(img_path.resolve(), target) kept += 1 print(f"{split}: 转换 {kept} 张,跳过 {skipped} 张缺图标注")用软链接而不是复制图片,是个小但实用的习惯。VisDrone 全量图片解压完体积不小,复制一份既费时间又费磁盘,软链接几秒钟搞定。
2.3 目录结构与 data.yaml 的写法
转换完之后,目录应该是这个样子:
/data/visdrone/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── visdrone.yamldata.yaml里有两处容易写错。一是path必须是绝对路径或者相对训练启动目录能解析的路径,很多人写成相对路径然后换个目录启动就找不到数据。二是names的键必须从 0 开始连续,不能有跳号。
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这里train和val写相对路径,是因为上面已经有path作为根目录,框架会自动拼接。如果写成绝对路径也能跑,但换机器就得改,不如这样清爽。
2.4 转完先跑一遍统计,别急着开训
我强烈建议在开训之前跑一段统计脚本,把每个类别的实例数、每张图的平均目标数、目标宽高的像素分布都打出来。这个动作花不了五分钟,但能提前发现类别映射错位、训练集为空、某个类别一个样本都没有这类致命问题。
import numpy as np from pathlib import Path from PIL import Image from collections import Counter root = Path("/data/visdrone") split = "train" cls_counter = Counter() widths, heights = [], [] for lbl in (root / "labels" / split).glob("*.txt"): img = root / "images" / split / f"{lbl.stem}.jpg" if not img.exists(): continue w, h = Image.open(img).size for line in lbl.read_text().strip().splitlines(): c, cx, cy, nw, nh = line.split() cls_counter[int(c)] += 1 widths.append(float(nw) * w) heights.append(float(nh) * h) names = ["pedestrian", "people", "bicycle", "car", "van", "truck", "tricycle", "awning-tricycle", "bus", "motor"] for i, n in enumerate(names): print(f"{n:>18s}: {cls_counter.get(i, 0):>8d}") widths = np.array(widths) heights = np.array(heights) areas = widths * heights for tag, th in [("小于 32x32", 1024), ("小于 16x16", 256)]: print(f"{tag}: {(areas < th).mean() * 100:.1f}%")跑完你会得到一个很直观的结论:如果"小于 32×32 占比"超过 60%,那 640 的输入尺寸基本没戏,必须考虑第 3 章的做法。我在一份完整训练集上统计到的比例大概是七成上下,具体数值随划分方式浮动,但"绝大多数是小目标"这个结论是稳的。
3. 让小目标"显形":分辨率、P2 层与切块训练
小目标检测是 VisDrone 的核心矛盾,业界能用的手段无非三条路:抬高输入分辨率、增加高分辨率特征图上的检测头、把大图切块。三者原理不同,效果叠加,代价也各不相同。
3.1 imgsz 往上加,收益和代价都要算清楚
YOLOv8 默认 imgsz 是 640。这个尺寸对 COCO 那种目标尺度是合理的,对 VisDrone 明显偏小。把 imgsz 提到 1280,理论上每个目标在输入上的像素面积扩大四倍,特征图上前景区域的信息量完全不同。实测下来,从 640 提到 1280,mAP50-95 通常能涨五到八个点,小目标 AP 的涨幅更明显。
但要注意代价不只是显存。推理速度会掉,一张 1280 的图在消费级卡上单帧耗时可能是 640 的三倍左右;训练时间也成比例拉长。如果最终是要部署到边缘设备,这个代价得提前算进方案里。
还有一个坑:imgsz 必须是 32 的倍数,因为模型最大下采样倍数是 32。你写 1300 会被自动取整到最近的合法值,日志里能看到实际用的尺寸和你写的不一样,别以为是 bug。
提示:YOLOv8 会把图片长边缩放到 imgsz,短边按比例缩放后 letterbox 补齐。VisDrone 图片大多是 4:3 左右的比例,设 1280 实际得到的有效内容区域差不多是 1280×960,短边其实还有余量。如果你很在意显存利用率,可以把 imgsz 按图片的宽高比去凑,但收益有限,不值得花太多精力。
3.2 手动给 YOLOv8 补一个 P2 检测头
默认的 YOLOv8 检测头接在 stride 8、16、32 三层特征图上,也就是 P3、P4、P5。对 20 像素的目标来说,走到 stride 32 那层时只剩不到一个像素,特征早就没了。加一个 stride 4 的 P2 层,等于在更高分辨率的特征图上做一次预测,是专门为小目标准备的一条通路。
改法是复制一份模型 yaml,在 backbone 的 P2 位置多接一路,head 里顺着往下再上采样一层。以下这份是以某一版yolov8s.yaml结构改写出来的,可以直接存成yolov8s-p2.yaml:
nc: 10 scales: s: [0.33, 0.50, 1024] backbone: - [-1, 1, Conv, [64, 3, 2]] # 0 P1/2 - [-1, 1, Conv, [128, 3, 2]] # 1 P2/4 - [-1, 3, C2f, [128, True]] # 2 - [-1, 1, Conv, [256, 3, 2]] # 3 P3/8 - [-1, 6, C2f, [256, True]] # 4 - [-1, 1, Conv, [512, 3, 2]] # 5 P4/16 - [-1, 6, C2f, [512, True]] # 6 - [-1, 1, Conv, [1024, 3, 2]] # 7 P5/32 - [-1, 3, C2f, [1024, True]] # 8 - [-1, 1, SPPF, [1024, 5]] # 9 head: - [-1, 1, nn.Upsample, [None, 2, "nearest"]] # 10 - [[-1, 6], 1, Concat, [1]] # 11 拼接 backbone P4 - [-1, 3, C2f, [512]] # 12 - [-1, 1, nn.Upsample, [None, 2, "nearest"]] # 13 - [[-1, 4], 1, Concat, [1]] # 14 拼接 backbone P3 - [-1, 3, C2f, [256]] # 15 P3/8-small - [-1, 1, nn.Upsample, [None, 2, "nearest"]] # 16 - [[-1, 2], 1, Concat, [1]] # 17 拼接 backbone P2 - [-1, 3, C2f, [128]] # 18 P2/4-xsmall - [-1, 1, Conv, [128, 3, 2]] # 19 - [[-1, 15], 1, Concat, [1]] # 20 - [-1, 3, C2f, [256]] # 21 P3/8-small - [-1, 1, Conv, [256, 3, 2]] # 22 - [[-1, 12], 1, Concat, [1]] # 23 - [-1, 3, C2f, [512]] # 24 P4/16-medium - [-1, 1, Conv, [512, 3, 2]] # 25 - [[-1, 9], 1, Concat, [1]] # 26 - [-1, 3, C2f, [1024]] # 27 P5/32-large - [[18, 21, 24, 27], 1, Detect, [nc]] # 检测头 P2-P5写完之后第一件事不是训练,而是先 dry-run 一遍确认层索引没串。不同小版本的官方 yaml 结构会微调(比如 backbone 里多了注意力模块),直接照抄会撞上索引越界或者维度不匹配。保险做法是:
yolo detect train model=yolov8s-p2.yaml data=visdrone.yaml imgsz=640 epochs=1 batch=2 device=0跑通一个 epoch 再放大参数。加 P2 之后参数量的增量不大,但计算量涨得明显——stride 4 那层特征图是 320×320(imgsz 1280 时),比 stride 8 那层大四倍,推理耗时大约增加三到四成。如果对速度敏感,可以考虑把 P2 层做窄一点(通道数砍半),牺牲一点点精度换速度。
3.3 大图切块训练:另一种等效放大
如果不想改网络结构,还有一条路:把原图切成若干块,每块单独当一张训练样本。一张 2000×1500 的图切成 800×800、重叠 100 像素的块,大概能得到六块左右。每块里的目标相对变大了,等价于在不动 imgsz 的前提下提高了目标的有效分辨率。
切块的关键在边框处理。有两种常见策略:一是只保留中心点落在当前块内的框,这样不会出现半个框,但块边界上的目标会被切给相邻块;二是保留裁剪后面积保留率超过某个阈值(比如 40%)的框,能多救回一些边界目标,但会引入不完整的样本。我一般用第一种,简单可靠,配合重叠区域已经能覆盖大部分情况。
def slice_image(img_w, img_h, boxes, size=800, overlap=100): step = size - overlap crops = [] for y0 in range(0, max(img_h - overlap, 1), step): for x0 in range(0, max(img_w - overlap, 1), step): x1, y1 = min(x0 + size, img_w), min(y0 + size, img_h) keep = [] for box in boxes: cx, cy = box[0] + box[2] / 2, box[1] + box[3] / 2 if x0 <= cx < x1 and y0 <= cy < y1: keep.append([box[0] - x0, box[1] - y0, box[2], box[3]]) crops.append((x0, y0, x1, y1, keep)) return crops切块有个必须接受的副作用:样本数量和训练时间成倍增长。六千多张变成三万多张,一个 epoch 的耗时差不多翻五倍。我的经验是,切块方案更适合那些显存吃紧、又必须改善小目标的中等配置机器,用时间换精度。
推理时同样要切块,然后把各块的框映射回原图坐标再做 NMS,这套逻辑就是第 6 章要讲的切片推理。训练和推理用同一套切分参数,效果才对得上。
3.4 三种方案的取舍
不同资源条件下我给的建议不太一样,直接对比一下:
| 方案 | 小目标收益 | 训练耗时 | 推理耗时 | 适合场景 |
|---|---|---|---|---|
| imgsz 提到 1280 | 中 | 约 2.5 倍 | 约 3 倍 | 显存 12G 以上,改动最小 |
| 加 P2 检测头 | 中高 | 约 3 倍 | 约 1.4 倍 | 显存一般但能接受推理变慢 |
| 大图切块训练 | 高 | 约 5 倍 | 可灵活控制 | 显存小、时间充裕、追求精度 |
| 1280 + P2 组合 | 高 | 约 4 倍 | 约 4 倍 | 精度优先,不计成本 |
如果只能选一个,我会先上 imgsz=1280。这一步改动成本最低、收益最直接,跑完一轮有了基线,再决定要不要叠 P2 或者切块。上来就三件套全开,出了问题根本不知道是哪一步带来的。
4. 超参与增强:哪些开关对小目标是有害的
YOLOv8 的默认超参是为通用数据集调的,直接套到 VisDrone 上,有几个增强策略会起反效果。这一章讲清楚每个开关在小目标密集场景下到底做了什么。
4.1 mosaic 的双刃剑效应
mosaic 是 YOLO 系列的王牌增强,把四张图拼成一张。对小目标检测,它的作用是双面的。
好的一面:一张图里出现更多目标、更多上下文组合,模型见得多,泛化好;拼图后原本的大目标在拼接图里相对缩小,等于额外造出了"中目标变小目标"的样本,对学习小目标特征有帮助。
坏的一面:原图在拼接过程中被缩放到四分之一区域,本来就只有二十像素的车,缩完只剩十来个像素,几乎不可辨。也就是说,mosaic 一边在造小目标样本,一边把原有小目标压到无法学习的程度。
我的处理办法是开着 mosaic 但把关闭时机提前。默认close_mosaic=10,意思是最后 10 个 epoch 关闭。对 VisDrone 我会设成 15 到 20,让模型有更长时间在真实单图分布上收敛。这个参数看着不起眼,实测能影响最终指标一到两个点。
另外mosaic=1.0是开启概率,可以降到 0.8 左右,保留一部分纯单图样本,让模型别过度依赖拼接图的统计特性。
4.2 mixup、旋转、翻转的实测结果
mixup 是把两张图按透明度混合。在分类任务里效果很好,在密集小目标检测里我一般关掉(mixup=0.0)。原因很直白:两张图叠在一起,小目标变成半透明的鬼影,标签还是原样,模型学到的就是"这种模糊不清的东西也算目标",反而拉低精度。有人在小目标数据集上开mixup=0.1拿到过微弱收益,但复现不稳定,不建议作为默认配置。
旋转增强degrees值得聊一下。航拍是俯视图,目标在画面里的朝向本来就是任意的,理论上旋转增强应该有效。但 YOLOv8 的旋转实现是整图旋转再裁剪,转完要么留黑边、要么裁掉边角的目标,对小目标不友好。我试过degrees=0、10、30三档,10 度左右有微弱正向收益,30 度开始掉点。所以建议设 10,别贪多。
上下翻转flipud是个有意思的点。航拍图没有明显的"天在上地在下"约束,上下翻转出来的样本在物理上是合理的。但很多航拍场景里车辆有统一的行驶方向分布,翻过来之后可能出现"车都在逆行"这种现实中不常见的组合。我实测flipud=0.5和0差别在一个点以内,属于噪声范围,开不开都行,我倾向保守关掉。
左右翻转fliplr=0.5是默认值,保持就行,这个对航拍数据没有副作用。
4.3 优化器与学习率的搭配
默认 YOLOv8 用 SGD,lr0=0.01。VisDrone 上我更倾向也用 SGD,原因是大 batch 场景下 SGD 的泛化表现更稳,而且收敛曲线好判断。如果你 batch 被迫压到很小(比如 4 或 8),可以换成 AdamW,lr0相应降到 0.001 到 0.002 量级,学习率太高会直接发散。
学习率调度上cos_lr=True值得开,余弦退火在训练后期能更平滑地收敛到局部最优,比阶梯下降的最终指标通常好零点几个点。warmup_epochs保持 3 就行,如果 batch 很小可以拉到 5,让 BN 统计更稳定。
一份我常用的完整训练命令:
yolo detect train \ model=yolov8s.pt \ data=/data/visdrone/visdrone.yaml \ imgsz=1280 \ epochs=150 \ batch=8 \ workers=8 \ device=0 \ optimizer=SGD \ lr0=0.01 \ lrf=0.01 \ cos_lr=True \ warmup_epochs=3 \ mosaic=0.8 \ close_mosaic=15 \ mixup=0.0 \ degrees=10 \ scale=0.4 \ fliplr=0.5 \ flipud=0.0 \ project=runs/visdrone \ name=v8s_1280_p2注意scale=0.4,比默认的 0.5 小一点。随机缩放是双面的:向上放能让小目标变大,是好事;向下缩会让小目标彻底消失,是坏事。VisDrone 本身小目标已经够多了,我倾向削弱向下缩放的比例,宁可少一点尺度多样性。
4.4 类别不平衡要不要硬处理
最直接的想法是给稀有类别加权。YOLOv8 本身没有暴露类别权重参数,得改 loss 计算或者用采样策略。我的实践结论是:在 VisDrone 上,类别不平衡不是主要矛盾,不值得花大力气。
原因是这样:稀有类别(bicycle、awning-tricycle)数量少,但它们本身的检测难度也高(目标小、外观接近其他类),加权之后模型会更倾向于在这些类上"乱猜",precision 掉得比 recall 涨得多。而 car、pedestrian 这些大类,模型本来就能学得不错,加权反而破坏平衡。
真正有效的做法是在数据层面:如果某个类别的样本实在少得可怜,可以考虑用 copy-paste 增强,把稀有类别的实例抠出来贴到别的图上。但这需要实例分割 mask,或者退一步用 bbox 级别的 copy-paste(YOLOv8 支持copy_paste参数,配合copy_paste_mode使用)。我试过一轮,awning-tricycle 的单类 AP 有小幅提升,但整体 mAP 没明显变化,工作量不小。如果你只是想把模型跑通、指标能用,这块可以先跳过。
5. 训练过程怎么看:曲线、指标和那些假象
训练跑起来之后,最有价值的信息不是最终那个数字,而是过程中的曲线形态。很多人盯着最后一行结果,中间那些异常信号全忽略了,等到发现不对已经浪费了一天算力。
5.1 前几十个 epoch 不涨是正常的
VisDrone 上加了大分辨率、改了检测头,前 20 到 30 个 epoch 的 mAP 曲线基本是平的,甚至会先掉再涨。这是正常的,原因有两个:一是检测头随机初始化后需要时间适配新的特征尺度;二是大输入尺寸下每个 batch 的有效样本数变少,梯度噪声大。
真正该警惕的是损失曲线异常。如果 box_loss 在前 10 个 epoch 里持续上升不回落,多半是学习率太大或者标注里有大量越界框。如果 cls_loss 一直卡在一个较高值不动,检查一下类别映射是不是错了——这是最常见的隐性错误。
我一般会在第 30、60、100 个 epoch 各存一次权重(save_period=30),训练完之后用yolo detect val分别评估,看看是不是真的一直在涨。有些配置下最优权重出现在第 80 个 epoch 而不是最后一个,早停(patience)设 50 比较合适,别设太紧,小目标收敛本来就慢。
5.2 val mAP 和 test-dev 的差距
VisDrone 官方把测试集分成了 val 和 test-dev 两部分,val 是公开标注的,test-dev 的标注不公开,要提交到官方评测服务器才能拿到结果。这意味着你本地算出来的指标只对 val 有意义。
val 只有五百多张图,统计波动不小。我做过一个小实验:同一个权重,把 val 里的图片顺序打乱、随机抽 80% 重算三遍,mAP50-95 的波动能到正负 1.5 个点。也就是说,两个配置之间如果只差一个点,很可能只是随机波动,别急着下结论说哪个方案更好。
想降低这种不确定性,有两个办法。一是做 k 折交叉验证,把训练集和验证集重新划分,跑三次取平均,成本高三倍但结论可靠。二是固定随机种子,用seed=0、deterministic=True保证可复现,至少同一份数据上对比是公平的。
5.3 把指标按目标尺寸拆开看
只看总 mAP 会掩盖很多信息。加了大分辨率、加了 P2 层之后,如果真的对小目标有效,小目标那部分的 AP 应该涨得比整体更多;如果整体涨了但小目标没动,说明提升来自别的地方(比如中大型目标定位更准了),你可能走错方向了。
YOLOv8 的验证输出默认只给整体 mAP50、mAP50-95 和各类别的 AP,不细分尺寸。想拆开看,得自己把预测结果导出成 COCO 格式,用官方评估工具按area分组统计。
yolo detect val \ model=runs/visdrone/v8s_1280_p2/weights/best.pt \ data=visdrone.yaml \ imgsz=1280 \ save_json=True \ project=runs/val \ name=v8s_p2_cocosave_json=True会在输出目录生成predictions.json,格式兼容 COCO。拿去跟真值一起喂给评估工具,就能拿到AP_small、AP_medium、AP_large三段。VisDrone 里 large 基本可以忽略,重点看 small 和 medium 两段。
我印象比较深的对比是:基线 640 输入的模型,AP_small 差不多在个位数;提到 1280 之后 AP_small 能翻倍,虽然绝对值仍然不高,但相对提升比