1. 项目概述
VisDrone 这个数据集,做过无人机视角目标检测的应该都不陌生。它是目前无人机航拍场景下最常用的公开数据集之一,涵盖了城市、乡村、公路、操场等多种俯视场景,里面的目标基本都是小目标,密集程度还特别高。这次做的项目就是在 YOLOv8 上把 VisDrone 的 baseline 完整跑通一遍,从 VisDrone 原始标注怎么转成 YOLO 格式、环境怎么配、模型怎么选、训练参数怎么调,到最后验证集指标怎么看、检测效果怎么可视化,整套流程全部捋一遍。适合刚接触目标检测、想在无人机航拍数据上动手跑模型的新手,也适合已经跑过 COCO 或 VOC 数据集、想切换到 VisDrone 这类小目标密集场景的开发者参考。
先说一下为什么选 YOLOv8 而不是别的模型。YOLOv8 是当前 YOLO 系列里集成度最高、使用最方便的一个版本,ultralytics库把数据加载、增强、训练、验证、导出全流程都封装好了,一个命令行就能跑起来。VisDrone 的标注格式比较特殊,是四角坐标,和 COCO 的x1,y1,w,h、YOLO 的cx,cy,w,h都不一样,所以数据集格式转换是绕不开的第一步。而 YOLOv8 对自定义数据集的接入方式非常友好,只要准备好images和labels目录,再写一个.yaml配置文件,就能直接开训。
这个 baseline 的意义在于,先建立一个可复现的参考基准,后续不管你是要改模型结构、做数据增强、做切图推理,还是换更复杂的检测头,都要拿这个 baseline 来对比效果。所以这次我会把每一步的细节都记录下来,包括踩过的坑。
2. VisDrone 数据集深度拆解
2.1 VisDrone 的格式到底长什么样
VisDrone2019-DET 数据集解压出来之后,目录结构大致是这样的:
VisDrone2019-DET/ ├── train/ ├── val/ ├── test-dev/ └── annotations/每个文件夹下面又有images和annotations两个子目录。图片都是jpg格式,分辨率不固定,常见的有2000x1500、1360x765之类的,基本都是高清大图。标注文件是txt格式,每一行对应一个目标,但它的字段不是 YOLO 那种class_id cx cy w h,而是:
<bbox_left>, <bbox_top>, <bbox_width>, <bbox_height>, <score>, <class_id>, <truncation>, <occlusion>八个字段,用逗号分隔。这里面有几个关键差异要特别注意:
bbox_left, bbox_top, bbox_width, bbox_height是边界框的左上角坐标加上宽高,单位是像素,不是归一化值。<score>在训练集中恒为1,在test-dev中可能是别的值,表示置信度。<truncation>表示目标是否被截断,0表示完整,1表示部分截断。<occlusion>表示遮挡状态,0无遮挡,1部分遮挡,2严重遮挡。- 还有一个特殊情况:某些类别为
0的标注行表示ignored区域,这类区域在训练时应当跳过,否则会引入大量噪声。
类别一共有 10 类,索引从 0 开始:
| 索引 | 类别名 | 常见场景 |
|---|---|---|
| 0 | ignored | 忽略区域,不参与训练 |
| 1 | pedestrian | 行人 |
| 2 | people | 人群(多人聚集) |
| 3 | bicycle | 自行车 |
| 4 | car | 小汽车 |
| 5 | van | 面包车/厢式货车 |
| 6 | truck | 卡车 |
| 7 | tricycle | 三轮车 |
| 8 | awning-tricycle | 带棚三轮车 |
| 9 | bus | 公交车 |
| 10 | motor | 摩托车 |
这个类别设计本身就很有意思,把pedestrian和people分开,把tricycle和awning-tricycle分开,这些都是无人机俯视场景下常见的细粒度目标区分。但在做 baseline 的时候,我建议先把0号类别(ignored)过滤掉,后面几个细分类别可以保留,因为它们正好是区分模型能力的关键点。
2.2 为什么 VisDrone 比 COCO 更难搞
做过 COCO 检测的人上手 VisDrone,第一感觉就是"怎么这么小"。VisDrone 里大量目标的像素尺寸在20x20甚至10x10以下,属于典型的小目标。这类目标在 YOLOv8 的 P3 特征层(下采样 8 倍)上,可能只占几个像素点,特征信息严重不足。
另外,VisDrone 的密集程度非常高,一辆公交车上可能同时出现十几个行人,道路上各种车辆互相遮挡,标注框大量重叠。这个时候 NMS 的处理非常关键,YOLOv8 默认的 NMS 参数在 COCO 上表现很好,但到了 VisDrone 这种密集场景,iou阈值可能需要适当调整,否则很容易把相邻目标合并掉。
还有一个容易被忽略的点:VisDrone 是俯视视角,目标的角度、形体和自然场景图片差异非常大。模型的预训练权重如果是 COCO 上来的,迁移到 VisDrone 上需要一个不小的适应过程,这也是为什么跑 baseline 时不能一上来就期望 mAP 跟 COCO 一样高。
2.3 数据集统计与类别分布
我在转换完成后对训练集做了简单的统计,总共 6471 张训练图片,371 个验证图片。类别分布很不均衡:
pedestrian和car数量最多,各占 30% 左右。people、van、truck中等数量。awning-tricycle、bus数量很少,有些类别在验证集中甚至凑不够一个 batch 的正样本。
这个不均衡直接影响训练效果。YOLOv8 默认的损失函数对每个类别是等权重的,在小类别上很容易欠拟合。后面训练的时候需要关注这一点,可以考虑调整类别权重,或者用数据增强策略去平衡。
3. 环境准备与工具选型
3.1 硬件与软件环境
这次跑的机器配置:GPU 是单张 24GB 显存的显卡。但这并不意味着没有大显存就跑不了,yolov8n在imgsz=640、batch=16的情况下,大概只需要 6-8GB 显存,普通消费级显卡完全能跑。
软件环境我用的版本组合:
Python 3.10 PyTorch 2.0.1+cu118 ultralytics 8.0.xx CUDA 11.8ultralytics库可以直接用 pip 安装,它会自动把 YOLOv8 所需的核心依赖一起装好。不建议手动去源码编译,除非你要改底层代码,否则用官方包就够了。
3.2 目录结构规划
在开始转换之前,我建议先规划好整体的目录结构,避免后面手忙脚乱:
visdrone_yolov8/ ├── datasets/ │ ├── VisDrone2019-DET/ │ │ ├── train/ │ │ ├── val/ │ │ └── test-dev/ ├── visdrone.yaml ├── convert_visdrone.py ├── runs/ ├── weights/这样把数据集、代码、训练产物切分开,训练的时候日志、权重输出到runs/下,不会把根目录搞乱。
4. VisDrone 标注转 YOLO 格式的完整过程
4.1 转换脚本的编写思路
VisDrone 标注转 YOLO 格式,核心公式是:
center_x = bbox_left + bbox_width / 2center_y = bbox_top + bbox_height / 2norm_cx = center_x / image_widthnorm_cy = center_y / image_heightnorm_w = bbox_width / image_widthnorm_h = bbox_height / image_height
YOLO 格式要求的是归一化后的cx, cy, w, h,都是 0 到 1 之间的浮点数。这里有一个很关键的细节:VisDrone 的bbox_left, bbox_top是左上角坐标,而 YOLO 的cx, cy是中心点坐标,转换时不能直接套用 COCO 转 YOLO 的代码,一定要把左上角转换成中心点。
4.2 完整转换代码
下面是我写的转换脚本,支持过滤ignored类别和截断目标:
import os from pathlib import Path def visdrone_to_yolo(txt_path, img_width, img_height): """ 将 VisDrone 的标注文件转换为 YOLO 格式 """ yolo_lines = [] with open(txt_path, 'r') as f: lines = f.readlines() for line in lines: line = line.strip() if not line: continue parts = line.split(',') if len(parts) < 6: continue bbox_left = float(parts[0]) bbox_top = float(parts[1]) bbox_width = float(parts[2]) bbox_height = float(parts[3]) score = float(parts[4]) class_id = int(parts[5]) # 过滤 ignored 类别(class_id == 0) if class_id == 0: continue # 过滤无效标注 if bbox_width <= 0 or bbox_height <= 0: continue # 过滤 truncated 或严重遮挡的目标 # 如果你觉得 occlusion 信息有价值也可以保留 # 这里我们只保留 score > 0 的 if score < 0: continue # VisDrone 的 class_id 1-10 转 YOLO 的 0-9 # 因为忽略了 class_id 0,所以这里要减 1 yolo_class_id = class_id - 1 center_x = bbox_left + bbox_width / 2.0 center_y = bbox_top + bbox_height / 2.0 norm_cx = center_x / img_width norm_cy = center_y / img_height norm_w = bbox_width / img_width norm_h = bbox_height / img_height # 防止边界超出 0-1 范围 norm_cx = min(max(norm_cx, 0), 1) norm_cy = min(max(norm_cy, 0), 1) norm_w = min(max(norm_w, 0), 1) norm_h = min(max(norm_h, 0), 1) yolo_lines.append(f"{yolo_class_id} {norm_cx:.6f} {norm_cy:.6f} {norm_w:.6f} {norm_h:.6f}") return yolo_lines def convert_folder(image_dir, annotation_dir, output_label_dir): """ 遍历一个文件夹下的所有图片,转换标注 """ os.makedirs(output_label_dir, exist_ok=True) image_files = list(Path(image_dir).glob('*.jpg')) + list(Path(image_dir).glob('*.png')) for img_path in image_files: # 读取图片尺寸 import cv2 img = cv2.imread(str(img_path)) if img is None: print(f"Warning: Failed to read image {img_path}") continue img_height, img_width = img.shape[:2] # 对应的标注文件 ann_path = Path(annotation_dir) / (img_path.stem + '.txt') if not ann_path.exists(): print(f"Warning: Annotation not found for {img_path}") continue yolo_lines = visdrone_to_yolo(ann_path, img_width, img_height) # 写入 YOLO 格式 label 文件 label_path = Path(output_label_dir) / (img_path.stem + '.txt') with open(label_path, 'w') as f: for line in yolo_lines: f.write(line + '\n') if len(yolo_lines) == 0: # 没有目标也保留空文件,YOLO 会跳过 print(f"Info: No valid objects in {img_path}") if __name__ == '__main__': base_dir = 'datasets/VisDrone2019-DET' # 转换训练集 convert_folder( os.path.join(base_dir, 'train/images'), os.path.join(base_dir, 'train/annotations'), os.path.join(base_dir, 'train/labels') ) # 转换验证集 convert_folder( os.path.join(base_dir, 'val/images'), os.path.join(base_dir, 'val/annotations'), os.path.join(base_dir, 'val/labels') )4.3 转换后的目录结构
转换完成后,train/和val/目录下分别会有images和labels两个子目录:
train/ ├── images/ │ ├── 0000001_00001_d_0000001.jpg │ ├── ... └── labels/ ├── 0000001_00001_d_0000001.txt ├── ...这里要注意,VisDrone 的标注文件命名和图片文件命名是一一对应的,都是类似0000001_00001_d_0000001这种长文件名。转换脚本是按照图片文件名去匹配标注文件,所以不会出错。
4.4 转换时最容易踩的坑
第一个坑是class_id偏移。VisDrone 的类别索引 0 是ignored,而 YOLO 的类别索引从 0 开始。因为过滤了ignored,所以转换后 YOLO 的类别 0 对应 VisDrone 的类别 1(pedestrian),依此类推,最后 YOLO 的类别 9 对应 VisDrone 的类别 10(motor)。visdrone.yaml文件里的类别列表必须和这个顺序一致,否则训练出来的模型类别全乱了。
第二个坑是坐标系误差。VisDrone 标注是整数像素值,转换后要保留 6 位小数,足够精确。但有些标注框的bbox_left + bbox_width可能超出图片宽度,这时候归一化后的值会大于 1,必须做裁剪,否则 YOLO 训练时会出问题。
第三个坑是空标注。有些图片上可能没有任何有效目标,转换后会产生空的 txt 文件。YOLO 训练时遇到空标注会直接跳过该图片,不影响训练,但如果你用的是随机数据增强,空标注图片可能出现概率不高,影响不大。
5. 配置 visdrone.yaml 与数据校验
5.1 编写数据配置文件
YOLOv8 训练自定义数据集需要一个.yaml文件:
# visdrone.yaml path: datasets/VisDrone2019-DET # 数据集根目录 train: train/images # 训练图片相对路径 val: val/images # 验证图片相对路径 nc: 10 # 类别数量 names: 0: pedestrian 1: people 2: bicycle 3: car 4: van 5: truck 6: tricycle 7: awning-tricycle 8: bus 9: motor注意path最好用绝对路径或者相对于你运行训练命令的目录的相对路径。train和val指向的是图片目录,YOLOv8 会自动去同名目录下找labels。也就是说,如果你指定train: train/images,那么它自动去train/labels找标注。所以图片和标注目录必须挨着,且名字匹配。
5.2 可视化标注检查
转换完直接开训是有点风险的,万一坐标转换算错了,训练出来的模型就是垃圾。我建议先做一个可视化检查,把转换后的标注画回图片上看一眼。
import cv2 import random from pathlib import Path # 随机选几张图检查 img_dir = Path('datasets/VisDrone2019-DET/train/images') label_dir = Path('datasets/VisDrone2019-DET/train/labels') class_names = ['pedestrian', 'people', 'bicycle', 'car', 'van', 'truck', 'tricycle', 'awning-tricycle', 'bus', 'motor'] for img_path in random.sample(list(img_dir.glob('*.jpg')), 5): img = cv2.imread(str(img_path)) label_path = label_dir / (img_path.stem + '.txt') if not label_path.exists(): continue with open(label_path, 'r') as f: lines = f.readlines() h, w = img.shape[:2] for line in lines: parts = line.strip().split() if len(parts) != 5: continue cls_id = int(parts[0]) cx = float(parts[1]) * w cy = float(parts[2]) * h bw = float(parts[3]) * w bh = float(parts[4]) * h x1 = int(cx - bw / 2) y1 = int(cy - bh / 2) x2 = int(cx + bw / 2) y2 = int(cy + bh / 2) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, class_names[cls_id], (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) cv2.imwrite(f'check_{img_path.stem}.jpg', img)这一步非常重要。我见过太多人跳过可视化直接开训,结果发现标注框整体偏移或者类别错乱,白白浪费几小时的训练时间。
6. 模型选型与训练参数配置
6.1 YOLOv8 模型选型
YOLOv8 官方提供了几个预训练模型:yolov8n、yolov8s、yolov8m、yolov8l、yolov8x。它们的参数量和速度递增,精度也递增。
我这次选了yolov8s作为 baseline。理由很简单:
yolov8n速度最快,但 VisDrone 的小目标多,模型容量太小容易欠拟合。yolov8m精度更高,但训练时间和显存消耗都会上升,对于验证 baseline 来说有点重。- 用
yolov8s可以在一个晚上跑完 300 个 epoch 的完整训练,同时精度够用。
当然,如果你的显卡显存比较小,用yolov8n也是可以的,只是后续对比提升空间时可能显得 baseline 太低。
6.2 训练命令与关键参数
YOLOv8 的训练命令非常简洁:
yolo train data=visdrone.yaml model=yolov8s.pt epochs=300 imgsz=640 batch=16 device=0 workers=8 patience=20逐项解释一下这些参数:
model=yolov8s.pt表示加载 COCO 预训练权重作为起点,这样可以利用 COCO 上学到的通用特征,比随机初始化收敛快得多。如果不想用预训练权重,可以传yolov8s.yaml,但我不推荐,因为 VisDrone 的数据量(6000+ 张)不足以从头训练一个大模型。imgsz=640是训练输入尺寸。VisDrone 原始图片是 2000x1500,直接缩放成 640 会导致小目标更小。但 YOLOv8 在训练时做了自适应缩放和 Mosaic 增强,中等尺寸下对小目标的稳定性更好。后续想提升精度,可以试试imgsz=1280,但显存和训练时间都会翻倍。batch=16取决于显存。24GB 显存下yolov8s+imgsz=640+batch=16没问题。如果显存不够,优先减 batch,其次是 imgsz。patience=20是早停的耐心值。如果验证集 mAP 连续 20 个 epoch 没有提升,训练会提前终止,防止过拟合和浪费时间。workers=8是数据加载的进程数。数值越高,GPU 等待时间越少,但不要超过 CPU 核心数。
6.3 训练超参数背后的逻辑
有人可能会问,为什么epochs=300这么多?VisDrone 数据集只有 6471 张图,理论上 100 个 epoch 也能收敛,但小目标检测需要更多次迭代来稳定学习边界框回归。我实测中,前 50 个 epoch 的 mAP 上升很快,但从 100 到 200 个 epoch 之间提升非常缓慢,如果没有耐心等,用patience提前停掉也行。
还有一个容易忽略的参数是mosaic。YOLOv8 默认在训练前 10 个 epoch 关闭 Mosaic,防止早期语义混乱。但如果你的数据集中小目标很多,Mosaic 对提升小目标检测帮助很大,因为它把 4 张图拼在一起,让模型在更小的尺度上学习目标特征。我建议保持默认的mosaic=1.0,不要关。
关于数据增强,VisDrone 场景的增强策略和 COCO 不太一样。俯视视角下目标不会倒过来,垂直翻转增强意义不大。fliplr=0.5可以保留,因为无人机影像左右对称是合理的。hsv_h、hsv_s、hsv_v这些颜色增强可以稍微调大一点,因为无人机在不同光照条件下拍摄的颜色差异很大。
6.4 实际训练过程观察
训练启动后,终端会打印每个 epoch 的 loss 和 mAP 信息。我习惯重点观察以下指标:
box_loss:边界框回归损失,下降平稳说明框定位在逐步优化。cls_loss:分类损失,如果这个值一直降不下去,可能是类别不均衡导致的。dfl_loss:分布焦点损失,这是 YOLOv8 特有的,跟边界框的分布建模有关。
第一个 epoch 结束后,box_loss大概在 2-3 左右,cls_loss在 1-2 左右。到第 50 个 epoch,box_loss会降到 1 以下,cls_loss会降到 0.3-0.5 左右。如果这些 loss 在后期出现明显反弹,大概率是学习率过大或者过拟合了,可以适当调低lr0。
7. 验证集评估与预测结果分析
7.1 评估指标怎么看
训练结束后,yolo会自动在验证集上做评估,输出 mAP50、mAP50-95 等指标。对于 VisDrone baseline,mAP50能到 0.35-0.45 左右就算正常,mAP50-950.2 左右也不意外。不要拿 COCO 的指标来比,VisDrone 的小目标难度本身就比 COCO 高一个量级。
如果想单独在验证集上重新评估,可以运行:
yolo val model=runs/detect/train/weights/best.pt data=visdrone.yaml它会输出每个类别的 AP,方便你分析模型在哪个类别上表现最差。按照我的经验,awning-tricycle和bus这两个类别的 AP 通常最低,因为它们样本量太小。如果你发现pedestrian的 AP 也不高,那大概率是小目标的问题,可以考虑后续做切图或提高输入分辨率。
7.2 预测可视化
光看数字不够直观,最好把预测结果画到图片上:
yolo predict model=runs/detect/train/weights/best.pt source=datasets/VisDrone2019-DET/val/images save=True conf=0.25conf=0.25表示只保留置信度大于 0.25 的框。实际应用中 VisDrone 密集场景下,这个值可以适当调低到 0.15 或 0.2,因为小目标本身的置信度就偏低,设太高会漏检严重。
查看预测图片时,重点关注这几类问题:
- 远处的行人是不是漏检了?
- 密集人群中是不是出现重复框?
- 大卡车是不是被拆成了多个框?
- 类别是否混淆,比如
truck被识别成van?
这些问题会直接引导你后续的优化方向。
7.3 常见问题与排查技巧
训练和评估过程中可能会遇到的一些典型问题:
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| loss 不下降 | 学习率过高或过低、数据集路径错误 | 检查 loss 曲线,降低或升高 lr0 |
| mAP 为 0 | 类别编号错乱、标签为空 | 可视化检查标注 |
| 显存不足 | batch 太大或 imgsz 太大 | 降低 batch 或 imgsz |
| 训练时间过长 | workers 太少、GPU 利用率低 | 增加 workers、检查 nvidia-smi |
| 验证集上所有类别 AP 都很低 | 过拟合或预训练权重不匹配 | 增加数据增强、减少 epochs |
有一个我踩过比较多坑的地方是visdrone.yaml里的names列表顺序必须和转换后的类别索引完全一致。很多人转完标注直接写 yaml,结果类别顺序和索引对不上,训练结束一评估,mAP 低得离谱,但看 loss 又是正常下降的。
还有一个坑是关于cache参数。YOLOv8 默认会缓存图片到内存或磁盘,如果数据集路径有变动,缓存可能失效。如果出现OSError或者读取图片失败,删掉labels.cache文件重新训练即可。
7.4 精度提升的几个方向
baseline 跑通之后,如果还有余力,可以尝试以下几种优化方向:
- 提高输入分辨率到
imgsz=1280。VisDrone 的小目标在高分辨率下特征更清晰,mAP50 通常能提升 5-10 个点。 - 使用
yolov8m或yolov8l,更大的模型对密集场景的表征能力更强。 - 改用
SAHI(Slicing Aided Hyper Inference)做切图推理,把大图切成小块分别检测再合并,对小目标特别友好。 - 使用
tune自动搜索超参数,但会消耗大量时间。 - 在训练集上补充更多的数据增强策略,比如随机缩放、随机平移、翻转等。
这些方向我以后会单独展开写,baseline 的意义就在于它给了你一个稳定的参照物,所有的改进都可以拿它来对比。
8. 实操汇总与个人心得
最后记录几条训练 VisDrone baseline 时最有价值的笔记。
第一,数据集转换这一步千万不要图省事。坐标转换公式很简单,但class_id偏移、边界裁剪、空标注处理,每一项都值得你多花 20 分钟检查。我当时先写了一个带可视化检查的小脚本,随机抽了几张图对照原图和转换后的标注,一眼就发现了问题,省下了一整晚的无效训练时间。
第二,YOLOv8 的默认参数在 COCO 上表现很好,但不是 VisDrone 的最佳配置。对于小目标密集的无人机数据,我建议把imgsz从默认的 640 提到 960 或 1280,把conf阈值调低,这会直接影响检测的查全率。baseline 阶段用默认参数是为了快速跑通流程,真正用好这个数据集,还需要针对场景做适配。
第三,跑深度学习项目,日志和版本管理真的很重要。YOLOv8 会自动把每次训练的输出放到runs/detect/train/目录下,里面包含了weights、args.yaml、results.csv等文件。我习惯在训练结束后把最关键的几张预测图挑出来拷到一个专门目录里,方便后面地对比实验。
VisDrone 这个数据集,跑出一个还不错的 baseline 只是第一步,真正挑战在于如何在保持速度的前提下把小目标检准。后续可以尝试的方向很多,切图检测、多尺度训练、使用带 Transformer 结构的检测头、加入注意力机制等等。祝大家在 VisDrone 上的实验都能顺利跑通,mAP 节节高。