1. 先把 VisDrone 摸清楚,别急着写转换脚本
用过 VisDrone 训练 YOLOv5 的人大概都有同一个体会:真正耗时间的不是敲训练命令,而是前面那段“数据到底长什么样”的摸索期。VisDrone 是一套无人机视角的目标检测数据集,图像全部由低空飞行平台俯拍采集,视角高、覆盖范围大、目标密集。这个数据集最核心的价值在于它把“高空小目标 + 极端密集 + 严重遮挡”这三件事一次性摆到了你面前,逼着你去处理普通街景数据集里根本不会遇到的场景。YOLOv5 这边生态成熟、配置直观、日志可读性好,很适合作为验证想法的第一站。
这篇文章面向三类人:刚把 YOLOv5 跑通 COCO、想换一个更有挑战性的数据集练手的朋友;手里有无人机航拍图、想快速搭一套检测流程的开发者;以及已经能训起来但对指标不满意、想搞清楚“为什么加了分辨率还是提不上去”的同学。我不打算讲 YOLOv5 的骨干网络结构,那是另一篇文章的事,这里只聊从原始压缩包到能出指标这一条链路上的具体操作、参数取舍和我自己踩过的坑。
1.1 十个类别和标注文件的真实结构
VisDrone 检测任务一共十个类别,顺序固定,这套顺序在后面写 yaml 和做可视化时都会反复用到:pedestrian、people、bicycle、car、van、truck、tricycle、awning-tricycle、bus、motor。注意pedestrian和people是两个不同的类,前者指在行走或站立状态下的行人,后者泛指人群聚集、坐卧、难以单独界定的个体,实际图像里这两类边界很模糊,是最容易混淆、AP 也往往最低的一对。
官方的标注文件是每张图对应一个同名.txt,里面一行一个目标,字段用逗号分隔,顺序是bbox_left, bbox_top, bbox_width, bbox_height, score, object_category, truncation, occlusion。这八个字段里,前四个是像素坐标,但注意它是从 1 开始计数的左上角坐标加宽高,不是右下角;score字段取 0 或 1,0 表示这块区域是“忽略区”,比如拍到天空、地面纹理、镜头遮挡产生的无意义区域;object_category取 0 到 10,0 同样是忽略标记,1 到 10 才对应上面那十个类;truncation是 0 到 1 的截断程度,occlusion是 0 到 2 的遮挡等级。
最容易出事的地方就在这里。很多人写转换脚本时只看object_category,忘了过滤score == 0,结果大批“忽略区”被当成真实目标塞进了训练集,模型被迫去学一些本来就不该被检测的区域,mAP 莫名其妙掉好几个点。我见过最典型的情况是有人反馈“训练 loss 一直在抖,收敛不了”,查下来就是忽略区没过滤。
1.2 三个容易被忽略的数据特征
第一个特征是图像尺寸不统一。VisDrone 的图并不是清一色 1920×1080,常见的有一批在 2000×1500 上下,也夹杂着 1344×756、2686×1512 这种尺寸。这意味着你绝不能硬编码图片宽高去做归一化,必须每张图单独用 PIL 或 OpenCV 读一次真实尺寸。硬编码的后果不是报错,而是标签全部偏移,而且偏移量不固定,训练时看起来 loss 在降,验证时框得莫名其妙。
第二个特征是目标尺寸分布极端偏小。VisDrone 里相当比例的目标框边长在 20 像素以内,甚至十几个像素。你可以自己跑一遍统计,把所有 W、H 算出来画个直方图,会发现峰值压在很低的区间。这个特征直接决定了后面的分辨率选择:在 640 输入下,一个 20 像素的框被缩放后只剩 6 个像素左右,卷积网络基本没法从 6 个像素里提取出有判别力的特征。
第三个特征是验证集很小,只有 548 张。这个数量在指标上会带来很大的抖动,一个 epoch 之间 mAP@0.5 上下波动两三个点是很正常的。更麻烦的是,你如果反复盯着这 548 张调超参,很快就会对这批数据过拟合,换到测试集上效果打折。我的做法是从训练集里再切一小块做本地验证,官方 val 集只在关键节点上评估一次。
| 数据划分 | 图像数量 | 有无公开标注 | 典型用途 |
|---|---|---|---|
| train | 6471 | 有 | 训练主干与增强策略 |
| val | 548 | 有 | 中期评估、挑选权重 |
| test-dev | 1610 | 无 | 官方在线评测 |
| test-challenge | 1580 | 无 | 竞赛场景 |
提示:官方测试集的标注不公开,本地能拿到的只有 train 和 val。不要指望在测试集上做任何“本地调试”,所有对比实验都只能基于 val 或者自己从 train 里切出来的子集。
2. 从原始标注到 YOLO 标签:转换脚本与目录搭建
YOLO 系列要求的标签格式是每行class_id x_center y_center width height,后四个值都是相对原图宽高归一化到 0 到 1 之间的小数。这个格式本身很简单,麻烦的是从 VisDrone 的绝对像素坐标、1-based 起始、附带忽略标记的格式转过去,中间每一步都要小心。
2.1 目录结构和文件命名
我建议养成一套固定的目录习惯,这样后面写 yaml、写脚本、换数据集都不用重新想路径。VisDrone 的原始图像命名类似0000006_00159_d_0000006.jpg,前面是序列号,中间是帧号,后缀是d。这种命名方式在不同序列之间有重复风险,但 VisDrone 官方已经保证了全局唯一,直接沿用原名即可,不需要重命名。
我用的目录结构大致是这样:
datasets/ visdrone/ images/ train/ val/ labels/ train/ val/ raw/ # 原始解压目录,保留不动 VisDrone2019-DET-train/ images/ annotations/ VisDrone2019-DET-val/ images/ annotations/保留一份完整的 raw 目录很重要。我早期为了“干净”直接把原始标注删了,后来想统计遮挡分布、想按 truncation 过滤重做一遍,又得重新下一次压缩包,白等半小时。硬盘不紧张的话,原始数据一律别删。
2.2 转换脚本逐段拆解
下面这个脚本我在多个小目标数据集上复用,改一下类别表和文件名规则就能直接用。核心逻辑有四步:过滤无效标注、坐标归一化、越界裁剪、写入目标路径。
import os from pathlib import Path from PIL import Image CLASSES = [ "pedestrian", "people", "bicycle", "car", "van", "truck", "tricycle", "awning-tricycle", "bus", "motor" ] # occlusion=2 表示严重遮挡,这里保留但可随时调整策略 MAX_OCCLUSION = 2 # 小于这个像素面积的目标直接丢弃,避免出极端的退化框 MIN_AREA = 4 def convert_split(img_dir, ann_dir, dst_img_dir, dst_lbl_dir): os.makedirs(dst_img_dir, exist_ok=True) os.makedirs(dst_lbl_dir, exist_ok=True) stats = {"total": 0, "kept": 0, "skip_ignore": 0, "skip_tiny": 0} for ann_path in Path(ann_dir).glob("*.txt"): img_path = Path(img_dir) / (ann_path.stem + ".jpg") if not img_path.exists(): continue with Image.open(img_path) as im: W, H = im.size lines = [] with open(ann_path, "r", encoding="utf-8") as f: for raw in f: raw = raw.strip() if not raw: continue parts = raw.split(",") if len(parts) != 8: continue stats["total"] += 1 bx, by, bw, bh = [float(v) for v in parts[:4]] score = int(float(parts[4])) cat = int(float(parts[5])) occl = int(float(parts[7])) # 1. 忽略区过滤 if score == 0 or cat == 0: stats["skip_ignore"] += 1 continue # 2. 遮挡等级过滤 if occl > MAX_OCCLUSION: continue # 3. 退化框过滤 if bw * bh < MIN_AREA: stats["skip_tiny"] += 1 continue # 4. VisDrone 坐标是 1-based 左上角 + 宽高 x1 = bx - 1.0 y1 = by - 1.0 x2 = x1 + bw y2 = y1 + bh # 5. 越界裁剪 x1 = max(0.0, min(x1, W - 1)) y1 = max(0.0, min(y1, H - 1)) x2 = max(0.0, min(x2, W - 1)) y2 = max(0.0, min(y2, H - 1)) if x2 - x1 < 1 or y2 - y1 < 1: continue xc = (x1 + x2) / 2.0 / W yc = (y1 + y2) / 2.0 / H nw = (x2 - x1) / W nh = (y2 - y1) / H cls_id = cat - 1 # 1..10 -> 0..9 lines.append(f"{cls_id} {xc:.6f} {yc:.6f} {nw:.6f} {nh:.6f}") stats["kept"] += 1 stem = ann_path.stem # 图像可以做软链接省空间,这里直接复制 target_img = Path(dst_img_dir) / (stem + ".jpg") if not target_img.exists(): with open(img_path, "rb") as src, open(target_img, "wb") as dst: dst.write(src.read()) with open(Path(dst_lbl_dir) / (stem + ".txt"), "w", encoding="utf-8") as f: f.write("\n".join(lines)) return stats if __name__ == "__main__": root = Path("datasets/visdrone/raw") dst = Path("datasets/visdrone") for split, folder in [("train", "VisDrone2019-DET-train"), ("val", "VisDrone2019-DET-val")]: s = convert_split( root / folder / "images", root / folder / "annotations", dst / "images" / split, dst / "labels" / split, ) print(split, s)几个必须强调的点。第一,cls_id = cat - 1这一步看着不起眼,但如果漏了,整个类别标签会整体错一位,pedestrian变成people,car变成van,训练照样能跑、loss 照样会降,只是最后每类 AP 全是乱的,排查起来非常痛苦。第二,x1 = bx - 1.0这个减一是 VisDrone 特有的,因为它的坐标从 1 开始。第三,归一化用的是裁剪之后的框,不是裁剪前的,否则会出现大于 1 的坐标,YOLOv5 加载时会报越界警告。
跑完之后打印的统计信息值得看一眼。正常情况下一张训练集图片平均有几十个目标框,如果算出来平均只有一两个,那基本可以确定过滤条件写错了。
2.3 数据集 yaml 与首轮自检
转换完成之后写一份visdrone.yaml,放在项目的data/目录下:
path: ../datasets/visdrone train: images/train val: images/val nc: 10 names: ['pedestrian', 'people', 'bicycle', 'car', 'van', 'truck', 'tricycle', 'awning-tricycle', 'bus', 'motor']path是数据根目录,train和val是相对路径,YOLOv5 会自动去对应位置找images/xxx,然后把路径里的images替换成labels去找标签。这个约定必须严格遵守,如果你把标签放在别的地方,框架找不到,只会静默地把这批图当作无标签负样本处理,训练时 loss 里的目标项一直是 0,很多人卡在这里。
自检我一般做两件事。第一件是随机抽十张图把标签画上去看一眼,红色的框是不是正好套在车和人身上,框的方向有没有整体偏移。第二件是跑一次极短的训练,--epochs 1 --batch 8 --img 640,看日志里train和val的实例数量是不是合理数量级。如果 val 的实例数比你预估的少了一个数量级,回去查转换脚本。
注意:如果你打算从 train 里切一部分做本地验证,记得在 yaml 里用绝对或相对路径明确指向切出来的那份,别直接改
train字段,否则训练集少了却没人发现,指标对比全部失真。
3. 训练配置的取舍:分辨率、锚框与检测头
数据这一层理顺之后,接下来是决定上限的三个配置。它们的影响远大于学习率、权重衰减这些细节参数,前面没调好,后面再怎么炼丹都是徒劳。
3.1 输入分辨率是第一优先级
VisDrone 上我见过的最大误区就是“先用 640 跑通再说”。这个数据集的问题不是模型不行,是信息在缩放那一步就丢了。假设原图 2000×1500 缩到 640×480,缩放比大约是 0.32,一个原本 25×25 像素的汽车,进去只剩 8×8。8 像素在 P3 特征图(stride 8)上对应 1 个格子,到 P4(stride 16)上连半个格子都不到。也就是说,模型在语义层根本“看不见”这个目标。
把--img提到 1280,同样那个目标变成 16×16,在 P3 上占两个格子,才能被正常激活。公开评测里的量级大致是这样:YOLOv5s 在 640 输入下 mAP@0.5 落在二三十这个区间,换成 1280 通常能往上走七八个点甚至更多,提升幅度比换更大的骨干网络明显得多。这不是玄学,就是有效像素数量的问题。
代价也很直接:显存和耗时。1280 输入的张量体积是 640 的四倍,训练时激活值占用同步涨。以单卡 16GB 显存为例,YOLOv5s 在 1280 下 batch 大概只能开到 8 到 16,还要把--workers调低一点防止内存爆掉。如果显存更紧张,可以用--img 1024折中,1024 相对 640 的提升同样很可观。
| 输入尺寸 | 单卡 16GB 可行 batch | 小目标有效像素 | 相对 640 的提升幅度(量级参考) |
|---|---|---|---|
| 640 | 32 及以上 | 严重不足 | 基线 |
| 1024 | 16 左右 | 勉强 | 明显提升 |
| 1280 | 8 到 12 | 基本够用 | 提升显著 |
| 1536 | 4 到 6 | 充足 | 继续提升但边际递减 |
3.2 重新聚类锚框
YOLOv5 自带的默认锚框是在 COCO 上用 K-means 聚出来的,九个尺度,从小到大的分布跟航拍视角完全不是一回事。VisDrone 里大量框集中在很小的尺寸区间,直接用默认锚框,匹配到的正样本 anchor 会严重偏大,回归目标偏差大,训练前期 loss 掉得很慢。
好在 YOLOv5 训练启动时会自动跑一次 anchor 检查,日志里会打出AutoAnchor相关的信息,如果它认为当前锚框跟数据不匹配,会提示重新聚类。你可以手动跑一次聚类,把结果写回模型配置:
python utils/autoanchor.py --data data/visdrone.yaml \ --weights yolov5s.pt --img-size 1280聚完会得到九个(如果加了 P2 层就是十二个)新的宽高对,形如[12,15, 19,22, 27,31, ...],前面的数字普遍比默认值小一圈,这是符合预期的。把这些值替换到models/yolov5s.yaml或者其他对应模型配置的anchors:字段下。注意顺序必须是从小到大排,因为 YOLO 是把锚框按面积排序后分给不同尺度的检测层的,顺序错了会导致小锚框配到 P5 上去,效果比不换还差。
这一步的收益不像分辨率那么立竿见影,但属于“几乎零成本、普遍有正收益”的操作,没有理由跳过。
3.3 加一个 P2 检测头值不值
YOLOv5 标准配置有三个检测头,分别接在 stride 8、16、32 的特征图上。对于 VisDrone 这种极小目标密集的数据集,社区里很常见的做法是再加一个 stride 4 的 P2 头。做法是把models/hub/目录下现成的 P2 版配置复制过来,调整一下nc和anchors,作为--cfg传入。
加了 P2 之后,理论上 8 到 16 像素的目标终于有了一个高分辨率的检测层可以负责。实测下来这种改造对小目标 AP 的提升确实存在,尤其是pedestrian、people、motor这几类小目标占比高的类别。
但代价必须说清楚。P2 特征图的分辨率是 P3 的两倍,通道数也不少,显存占用和计算量会上一个台阶。在同样的显存条件下,你可能被迫把 batch 降到 4 甚至 2,或者把输入尺寸从 1280 退回到 1024。这时候就出现一个权衡:1280 + P3 三头和1024 + P2 四头,哪个更好?我的经验是前者通常更稳,因为分辨率带来的信息增益是全局的,而 P2 头只对极小目标那一小部分样本起作用,还容易引入大量低质量正样本,让训练变得更不稳定。如果你的显存足够撑住 1280 + P2,那就两个都要;如果只能二选一,优先保分辨率。
4. 训练实操:命令、超参与过程监控
前面三步定下来之后,训练本身就相对机械了。这一节把命令拆开讲清楚每个参数为什么这么给,再聊聊增强策略和日志阅读。
4.1 完整训练命令与参数逐条解释
我常用的起手命令是这样:
python train.py \ --img 1280 \ --batch 8 \ --epochs 100 \ --data data/visdrone.yaml \ --weights yolov5s.pt \ --cfg models/yolov5s-p2.yaml \ --hyp data/hyps/hyp.visdrone.yaml \ --device 0 \ --workers 8 \ --project runs/visdrone \ --name exp_s_1280_p2 \ --exist-ok--weights yolov5s.pt用的是 COCO 预训练权重。有人会问航拍视角跟街景差别那么大,预训练还有用吗?有用,而且很有用。浅层学到的边缘、角点、纹理响应是通用的,深层虽然语义有偏移,但整体收敛速度比从零开始快得多。我在 VisDrone 上做过对比,从零训练到同等 mAP 大概需要两到三倍的 epoch 数。
--hyp指向自定义的超参文件,这是很多人忽略的一步。YOLOv5 默认的 hyp 是针对 COCO 调的,直接套过来在 VisDrone 上不一定合适。我一般从默认文件复制一份,改几个关键项:初始学习率从 0.01 降到 0.005 左右(数据集小、batch 小,学习率太大会震荡),warmup_epochs从 3 提到 5(小数据集前期更脆弱,需要更长的预热),weight_decay保持 0.0005 不动。
--workers的设置要看你机器的 CPU 核数和内存。VisDrone 的图很大,1280 输入下每张图解码加增强的内存开销不小,workers 开太高容易触发 OOM Killer。8 是一个比较安全的起点,如果nvidia-smi显示 GPU 利用率长期低于 60%,说明数据加载是瓶颈,可以往上加;如果系统内存吃紧,就往下减。
--name里把关键配置都写进去,exp_s_1280_p2一眼能看出是 small 模型、1280 输入、带 P2 头。我吃过这个亏,跑了一堆实验,名字全是exp、exp2、exp3,回头根本分不清哪次对应哪个配置,只能翻日志。
4.2 数据增强参数该怎么调
YOLOv5 的增强项挺多,VisDrone 上不是每项都开得越大越好。
mosaic这一项需要重点说。它把四张图缩小后拼成一张,对普通数据集能显著提升小目标数量和上下文多样性,但对 VisDrone 这种本来就全是小目标的数据集,拼完之后每张子图被缩到原尺寸的一半,目标变得更小了。在 640 输入下开满 mosaic,很多目标会缩到三四个像素,基本等于噪声。所以我的建议是 1280 输入下保持默认的 1.0 但配合close_mosaic提前关闭,或者干脆把概率降到 0.5 左右;640 输入下反而不建议开满。
flipud垂直翻转我一般设成 0。无人机俯拍图像有明确的上下语义,天空在上、地面在下,垂直翻转后出现了物理上不合理的场景,虽然模型未必学得出来,但没有收益的事情不做。fliplr水平翻转设 0.5 没问题。
scale随机缩放默认 0.5,这个对 VisDrone 是有益的,能让模型见到同一目标在不同尺度下的样子。但因为原图本身目标就小,缩放的负向区间(缩小)要小心,缩过头目标直接消失。可以把scale调到 0.3 到 0.4 之间,减小向下缩放的比例。
hsv_h/s/v三个颜色扰动保持默认即可,无人机在不同光照时段拍的图色温差异大,颜色扰动对这个数据集是有实际意义的。degrees旋转默认 0,如果图像本身没有明显的方向先验,可以开到 5 到 10 度,但别开太大,旋转会让边界框变大、引入大量背景。
4.3 训练曲线和日志怎么读
YOLOv5 会把结果写到runs/visdrone/exp_xxx/下面,results.csv里逐 epoch 记录所有指标,results.png是汇总曲线。我通常先看三个东西。
第一个是train/box_loss和val/box_loss的走向。正常情况是前期快速下降,大概二三十个 epoch 后趋于平缓。如果 val 的 box_loss 在中后期开始往上翘,而 train 还在降,那是过拟合的信号。VisDrone 训练集六千多张,对 YOLOv5s 来说不算多,过拟合很常见,早停或者加强正则都是选项。
第二个是metrics/mAP_0.5的曲线。这条线在 VisDrone 上不会像 COCO 那样平滑上升,它会抖。我见过在 60 epoch 附近突然掉两个点又涨回来的情况,这种单点波动不要去调参,看趋势。判断收敛的依据是最近二十个 epoch 的峰值不再刷新。
第三个是各类别的 AP。YOLOv5 会在验证时打印每个类别的 P、R、mAP@0.5。VisDrone 上car、van、truck通常表现不错,awning-tricycle、tricycle、bicycle这几类往往很差,因为它们数量少、形态相似、目标又小。如果发现某一类 AP 接近 0,先别急着改模型,回头看看这类在训练集里到底有多少个实例,是不是样本太少导致的。
心得:训练初期最值得看的是锚框匹配情况。日志里会打印每个检测层匹配到的正样本数量,如果某一层的数字明显偏少,说明那层的锚框跟数据分布不匹配,重新聚类能解决。
5. 评估、推理与问题排查实录
模型训出来之后,评估和排查是另一半工作量。VisDrone 的指标解读跟 COCO 有一些不同,值得单独讲。
5.1 指标怎么看才有意义
YOLOv5 默认报告的mAP@0.5是 IoU 阈值 0.5 下的平均精度,mAP@0.5:0.95是 0.5 到 0.95 步长 0.05 的平均。VisDrone 上这两个数字差距会很大,前者可能三四十,后者只有二十上下,这很正常——小目标的定位精度天然就差,框稍微偏几个像素 IoU 就掉到 0.5 以下了。
对比实验时只用 mAP@0.5 会掩盖定位质量的变化。比如你换了更大的输入尺寸,mAP@0.5 涨了 3 个点,但 mAP@0.5:0.95 只涨了 0.5,说明模型找到了更多目标但框得不够准。反之如果后者涨得更多,说明定位质量有实质改善。我一般两个都记录,横向对比时一起看。
还有一个常见误区是拿不同输入尺寸下的结果直接对比。1280 训出来的模型在验证时也是按 1280 推理,640 训出来的按 640 推理,这个对比是公平的。但如果你把 1280 训的模型拿去做 640 推理,指标会掉得厉害,这不能说明模型差,只是训练和推理的尺度不匹配。
另外,VisDrone 的官方评测用的是一个特定的评估脚本,它对低矮目标、密集重叠的处理跟 YOLOv5 内置的 pycocotools 评估有一些细节差异。如果你的目标是对标公开榜单,最终结果应该用官方脚本再跑一遍,别直接拿训练日志里的数字去比。
5.2 常见问题速查表
下面这些是我在实际项目里遇到过、并且被问得最多的几个问题,整理成表方便对照。
| 现象 | 最可能的原因 | 排查与解决 |
|---|---|---|
| 训练能跑但 mAP 一直是 0 | 标签路径不对,被当负样本 | 检查 images 与 labels 目录是否同级对应 |
| 框整体偏移一个固定量 | 忘记 VisDrone 坐标是 1-based | 转换脚本加x1 = bx - 1 |
| 所有类别混淆 | cls_id忘了减 1 | 核对 yaml 中 names 顺序 |
| loss 剧烈震荡不收敛 | 学习率过大或 batch 过小 | lr 降到 0.005,用--accumulate |
| mAP 比预期低一截 | 输入尺寸仍为 640 | 提到 1024 或 1280 再试 |
| 小目标召回极低 | 锚框与数据不匹配 | 重新聚类并写回配置 |
| 显存 OOM | 输入尺寸与 batch 组合过激 | 降 batch 或开--amp混合精度 |
| 验证指标波动大 | val 只有 548 张 | 多次评估取平均,或切分自建验证集 |
| 推理速度慢得离谱 | 密集小目标导致 NMS 耗时高 | 调--conf-thres和--iou-thres |
| 出现大量重复框 | NMS 阈值过高 | iou-thres 从 0.45 降到 0.35 |
表里最后两条值得展开说一下。VisDrone 一张图动辄上百个目标,NMS 阶段的计算量跟候选框数量是平方关系,推理变慢很正常。把置信度阈值从默认 0.25 提到 0.4 左右,能砍掉大量低分候选框,速度快不少,召回损失在可接受范围内。重复框的问题则往往出现在密集区域,两辆车贴得很近,IoU 天然就高,NMS 阈值设太高会把它们都留下,适当降低能缓解。
还有一个不那么常见但很折磨人的问题:训练时一切正常,推理时发现某些图的检测结果整体偏了一个方向。这通常是推理时的 letterbox 填充参数和训练时不一致导致的,检查一下推理脚本里--img-size是否跟训练时一致,以及是否用了--rect矩形推理。矩形推理会改变填充方式,如果训练时是方形填充、推理时是矩形,坐标还原就会错。
5.3 推理与可视化的两个实用技巧
推理命令本身很简单:
python detect.py \ --weights runs/visdrone/exp_s_1280_p2/weights/best.pt \ --source datasets/visdrone/images/val \ --img-size 1280 \ --conf-thres 0.35 \ --iou-thres 0.4 \ --save-txt \ --project runs/detect \ --name visdrone_val--save-txt会同时把检测结果存成 YOLO 格式的 txt,方便后面做定量分析或者喂给下游任务。
第一个技巧是切片推理。原图两千多像素宽,直接缩到 1280 会损失细节。如果把原图切成四块甚至九块,每块单独送进网络推理,再把结果映射回原图坐标做一次全局 NMS,小目标的检出率会有明显改善。代价是推理时间成倍增加,而且块与块交界处的目标容易被切断,需要在切片时留一定的重叠区域。这套方案在对精度要求高、对速度不敏感的离线场景里很实用。
第二个技巧是按类别调阈值。全局一个置信度阈值对 VisDrone 不太公平,car这种大类模型很自信,0.35 就够;awning-tricycle这种小类分数普遍偏低,用同样的阈值会漏掉一大批。可以在后处理阶段给每个类别单独设阈值,大类严一点、小类松一点,整体 F1 会有改善。这不是什么高深技巧,但确实能立竿见影地改善可视化效果。
6. 我个人在几轮实验后的几点体会
折腾这个数据集断断续续有段时间了,说几个不太会写在文档里的感受。
关于分辨率和模型容量的取舍,我现在的默认策略是先把输入尺寸顶到显存允许的上限,再考虑换模型。同样一份显存预算,从 YOLOv5s 换到 YOLOv5m 带来的提升,往往不如把 640 提到 1024 来得直接。这个结论在别的数据集上未必成立,但在 VisDrone 这种小目标主导的场景里,信息量比参数量更稀缺。
关于验证集,548 张真的太少了,我后来的做法是从训练集里按序列切出大约 800 张做本地验证,跟官方 val 分开。序列切分很关键,同一段视频的相邻帧高度相似,随机切会把几乎一样的图分到训练和验证两边,指标虚高。按序列前缀切,才能真实反映泛化能力。
还有一个容易忽略的点是训练完成之后的权重选择。YOLOv5 默认保存best.pt和last.pt,best.pt是按验证集 mAP@0.5:0.95 挑的。如果你后面要做的是高召回场景(比如先粗筛再人工复核),那按 mAP 挑出来的模型未必最合适,可能要回头在results.csv里找一个召回更高的 epoch 对应的权重。这个细节值得在项目开始前就想清楚,评估指标应该对齐最终业务目标,而不是默认值。
最后提一个数据层面的思路:VisDrone 的十个类别里,pedestrian和people的语义边界本身就模糊,强行让模型区分这两类,往往会拉低整体指标。如果下游任务并不需要区分“走着的人”和“站着坐着的人”,把它们合并成一个行人类,其余八类保持不动,重新训一遍,mAP 通常会有肉眼可见的提升。这不算作弊,是对任务定义的合理简化,前提是你要清楚自己在做什么。