☰
VisDrone2019数据集转COCO格式:DET与VID转换实战指南
2026/10/13 4:42:43 网站建设 项目流程

简介:面向计算机视觉初学者与目标检测实践者,这份资源聚焦VisDrone2019数据集与COCO标注格式之间的转换需求,解决DET检测集与VID视频集在训练框架中格式不兼容的问题。压缩包共3个文件,包含2个Python脚本与1份Markdown说明文档,整体约3KB,脚本承担核心转换逻辑,说明文档则交代了需要修改的路径位置,按提示调整后即可直接运行。目前已有2210人学习下载,说明该转换方案在无人机视觉与视频目标跟踪场景中具有一定实用价值。读者可获得一套可直接复用的数据格式转换工具,省去自行研究VisDrone标注结构的时间,同时借助readme快速定位路径配置项,降低上手门槛,尤其适合刚接触COCO格式与视频检测任务的新手,为后续模型训练与评测打好数据基础。

1. 从 VisDrone 到 COCO:为什么你的检测器训练总在第一步翻车

如果你做过无人机视角的目标检测,大概率绕不开 VisDrone2019 这个数据集。它由多所高校联合发布,包含 DET(目标检测)和 VID(视频目标检测)两个子任务,标注密度极高,小目标扎堆,是检验模型在密集场景下真实能力的试金石。但问题来了:主流检测框架——无论是 MMDetection、Detectron2 还是 YOLO 系列——默认吃的都是 COCO 格式。VisDrone 原生的标注是纯文本,一行一个目标,字段顺序和 COCO 的 JSON 结构完全对不上。很多人拿到数据后第一反应是“先跑起来再说”,结果训练 loss 不降、mAP 为零、可视化框全飘到天上。这不是模型的问题,是格式转换这一步就没做对。这篇笔记就把 DET 和 VID 两条转换链路拆开,从标注结构讲到脚本实现,再到验证和排错,让你拿到数据后能直接复现出一条可训练的 COCO 格式数据集。

2. VisDrone 标注结构拆解:DET 与 VID 的字段差异

2.1 DET 标注的七列字段到底怎么读

VisDrone2019-DET 的标注文件放在annotations/目录下,每个图片对应一个同名.txt文件。打开任意一个,你会看到类似这样的内容:

1234,567,89,45,1,4,0 2345,678,120,60,2,1,1

七列依次是:bbox_left, bbox_top, bbox_width, bbox_height, score, object_category, truncation。这里有几个容易看走眼的点。第一,坐标是左上角加宽高的绝对像素值,不是归一化的,也不是中心点格式。第二,score字段在 DET 任务里通常为 1,表示该标注被采纳,如果是 0 则一般忽略。第三,object_category的取值是 0 到 11,其中 0 是 ignored regions,11 是 others,真正参与训练的是 1 到 10 这十类。第四,truncation表示截断程度,0 为无截断,1 为部分截断,2 为严重截断。很多转换脚本直接把七列全读进来,把 score 当类别用,结果类别映射全乱。常见做法是只保留 score 为 1 且 category 在 1 到 10 之间的行,其余丢弃。

2.2 VID 标注多出来的帧号和目标 ID

VisDrone2019-VID 的标注结构和 DET 有本质区别。它的每个序列是一个文件夹,里面存放连续帧图片,标注文件则是整个序列一个.txt。每行的字段变成了八列:

frame_index, target_id, bbox_left, bbox_top, bbox_width, bbox_height, score, object_category, truncation

注意这里实际上是九列,我数错了——正确的是frame_index, target_id, bbox_left, bbox_top, bbox_width, bbox_height, score, object_category, truncation。frame_index从 1 开始,对应序列内的帧序号;target_id是跨帧跟踪的目标唯一标识。VID 任务的核心是视频目标检测,所以转换到 COCO 时不能只生成一个统一的instances_train.json,而是要按照序列切分成多个子集,或者至少保留video_id和frame_id字段,否则后续做跟踪或视频级评估时无法还原时序关系。我一般会为 VID 单独生成一个 COCO 风格的 JSON,其中images数组的id用全局递增,但额外加video_id和frame_id两个自定义字段,方便后续按序列拆分。

2.3 为什么不能直接把 txt 塞给 COCO

COCO 的标注是一个大的 JSON 对象,核心包含images、annotations、categories三个数组。images里每张图有id、file_name、width、height;annotations里每个目标有id、image_id、category_id、bbox([x, y, width, height])、area、iscrowd;categories里每个类有id、name、supercategory。VisDrone 的 txt 是逐行扁平结构,没有全局唯一的 annotation id,没有 area 字段,类别 id 也和 COCO 默认的 1 到 90 不兼容。更关键的是,COCO 的bbox虽然也是左上角加宽高,但要求是浮点数,且area必须等于width * height,否则某些评估脚本会报错。这些字段缺一个,训练框架在加载时就会抛异常或者静默跳过样本,导致你看到的数据量对不上。

2.4 类别映射表:十类到 COCO 的对应关系

VisDrone 的十类分别是:pedestrian、people、bicycle、car、van、truck、tricycle、awning-tricycle、bus、motor。转换到 COCO 时,category_id可以从 1 开始重新编号,也可以映射到 COCO 的原始类 id。我建议重新编号为 1 到 10,并在categories里写清楚name。这样做的原因是 COCO 原始类 id 不连续,直接映射容易在可视化时出现颜色错乱。下面这张表是我常用的映射方案:

VisDrone category_id类别名COCO 转换后 id
1pedestrian1
2people2
3bicycle3
4car4
5van5
6truck6
7tricycle7
8awning-tricycle8
9bus9
10motor10

注意:0 和 11 不参与训练,转换时直接过滤掉,不要试图给它们分配 id。

3. 手写转换脚本:从 txt 到 COCO JSON 的完整实现

3.1 DET 转换脚本:逐行解析与 JSON 组装

先看 DET 的转换。核心逻辑是遍历所有图片,读取同名 txt,过滤无效行,组装images和annotations。下面是我常用的脚本骨架:

import os import json from PIL import Image # 配置路径 IMG_DIR = "VisDrone2019-DET-train/images" ANN_DIR = "VisDrone2019-DET-train/annotations" OUTPUT_JSON = "visdrone_det_train_coco.json" # 类别映射:VisDrone id -> COCO id CATEGORY_MAP = {i: i for i in range(1, 11)} CATEGORIES = [ {"id": i, "name": name, "supercategory": "none"} for i, name in enumerate([ "pedestrian", "people", "bicycle", "car", "van", "truck", "tricycle", "awning-tricycle", "bus", "motor" ], start=1) ] images = [] annotations = [] ann_id = 1 # 遍历图片目录 for img_name in sorted(os.listdir(IMG_DIR)): if not img_name.endswith(".jpg"): continue img_path = os.path.join(IMG_DIR, img_name) img = Image.open(img_path) width, height = img.size img_id = len(images) + 1 images.append({ "id": img_id, "file_name": img_name, "width": width, "height": height }) # 读取对应标注文件 txt_name = img_name.replace(".jpg", ".txt") txt_path = os.path.join(ANN_DIR, txt_name) if not os.path.exists(txt_path): continue with open(txt_path, "r") as f: for line in f: parts = line.strip().split(",") if len(parts) != 7: continue x, y, w, h = map(int, parts[:4]) score = int(parts[4]) category = int(parts[5]) truncation = int(parts[6]) # 过滤无效标注 if score != 1 or category not in CATEGORY_MAP: continue if w <= 0 or h <= 0: continue annotations.append({ "id": ann_id, "image_id": img_id, "category_id": CATEGORY_MAP[category], "bbox": [float(x), float(y), float(w), float(h)], "area": float(w * h), "iscrowd": 0 }) ann_id += 1 # 写出 JSON coco = { "images": images, "annotations": annotations, "categories": CATEGORIES } with open(OUTPUT_JSON, "w") as f: json.dump(coco, f)

这段代码的逻辑很直白:先建images列表,每张图分配一个自增id;再读标注,过滤掉 score 不为 1 和类别不在 1 到 10 的行;最后把bbox转成浮点,area算好,iscrowd统一置 0。参数方面,CATEGORY_MAP决定了类别 id 的映射关系,如果你想把 VisDrone 的 car 映射到 COCO 的 car(id 3),改这个字典就行。ann_id必须全局唯一,不能每个图片重新计数,否则 COCO API 加载时会报重复 id。

3.2 VID 转换脚本:保留时序信息的改造点

VID 的转换不能照搬 DET。因为一个序列一个标注文件,图片分散在多个子文件夹里,所以需要先遍历序列,再遍历帧。关键改动有三处:第一,images的id要全局递增,但额外记录video_id和frame_id;第二,标注行多了解析frame_index和target_id;第三,输出时可以选择按序列拆分 JSON,也可以合成一个大 JSON。我一般先合成大 JSON,再用脚本按video_id切分。

import os import json from PIL import Image SEQ_DIR = "VisDrone2019-VID-train/sequences" ANN_DIR = "VisDrone2019-VID-train/annotations" OUTPUT_JSON = "visdrone_vid_train_coco.json" CATEGORIES = [ {"id": i, "name": name, "supercategory": "none"} for i, name in enumerate([ "pedestrian", "people", "bicycle", "car", "van", "truck", "tricycle", "awning-tricycle", "bus", "motor" ], start=1) ] images = [] annotations = [] ann_id = 1 img_id = 1 for seq_name in sorted(os.listdir(SEQ_DIR)): seq_path = os.path.join(SEQ_DIR, seq_name) if not os.path.isdir(seq_path): continue # 读取该序列的标注 ann_path = os.path.join(ANN_DIR, seq_name + ".txt") if not os.path.exists(ann_path): continue # 先建立帧号到图片id的映射 frame_to_imgid = {} frame_files = sorted(os.listdir(seq_path)) for frame_file in frame_files: if not frame_file.endswith(".jpg"): continue frame_idx = int(frame_file.replace(".jpg", "")) img_path = os.path.join(seq_path, frame_file) img = Image.open(img_path) w, h = img.size images.append({ "id": img_id, "file_name": f"{seq_name}/{frame_file}", "width": w, "height": h, "video_id": seq_name, "frame_id": frame_idx }) frame_to_imgid[frame_idx] = img_id img_id += 1 # 解析标注 with open(ann_path, "r") as f: for line in f: parts = line.strip().split(",") if len(parts) != 9: continue frame_idx = int(parts[0]) target_id = int(parts[1]) x, y, w, h = map(int, parts[2:6]) score = int(parts[6]) category = int(parts[7]) truncation = int(parts[8]) if score != 1 or category not in range(1, 11): continue if frame_idx not in frame_to_imgid: continue if w <= 0 or h <= 0: continue annotations.append({ "id": ann_id, "image_id": frame_to_imgid[frame_idx], "category_id": category, "bbox": [float(x), float(y), float(w), float(h)], "area": float(w * h), "iscrowd": 0, "target_id": target_id }) ann_id += 1 coco = { "images": images, "annotations": annotations, "categories": CATEGORIES } with open(OUTPUT_JSON, "w") as f: json.dump(coco, f)

和 DET 版本相比,这里多了frame_to_imgid映射,因为标注里的frame_index是序列内的局部编号,而images里的id是全局编号,必须做一次转换。另外target_id被保留在 annotation 里,后续做跟踪评估时可以直接用。video_id和frame_id是自定义字段,COCO API 不会解析它们,但你自己写 DataLoader 时可以读出来。

3.3 用 pycocotools 验证转换结果

脚本跑完不代表万事大吉。我见过太多次 JSON 能打开、但 COCO API 加载报错的情况。所以转换后第一件事是用pycocotools做一次完整加载和统计:

from pycocotools.coco import COCO coco = COCO("visdrone_det_train_coco.json") print("图片数:", len(coco.imgs)) print("标注数:", len(coco.anns)) print("类别数:", len(coco.cats)) # 检查每张图的标注是否可读 img_ids = coco.getImgIds() for img_id in img_ids[:5]: ann_ids = coco.getAnnIds(imgIds=img_id) anns = coco.loadAnns(ann_ids) print(f"图片 {img_id} 有 {len(anns)} 个标注")

如果这一步不报错,且图片数、标注数和你的预期一致,说明格式基本没问题。常见报错是KeyError: 'area'或AssertionError: bbox 格式不对,回去检查area是否等于w * h,bbox是否是四个浮点数。

3.4 可视化抽查:用 matplotlib 画框验证

数字对不代表框画得对。我习惯随机抽几张图,把 COCO 标注画出来看一眼:

import matplotlib.pyplot as plt import matplotlib.patches as patches from PIL import Image from pycocotools.coco import COCO coco = COCO("visdrone_det_train_coco.json") img_ids = coco.getImgIds() for img_id in img_ids[:3]: img_info = coco.loadImgs(img_id)[0] img = Image.open(os.path.join("VisDrone2019-DET-train/images", img_info["file_name"])) fig, ax = plt.subplots(1) ax.imshow(img) ann_ids = coco.getAnnIds(imgIds=img_id) for ann in coco.loadAnns(ann_ids): x, y, w, h = ann["bbox"] rect = patches.Rectangle((x, y), w, h, linewidth=1, edgecolor="r", facecolor="none") ax.add_patch(rect) plt.show()

如果框的位置和图片里的目标对不上,大概率是坐标解析时把x, y, w, h的顺序搞错了,或者图片尺寸读的是缩略图而不是原图。VisDrone 的图片分辨率不统一,有的序列是 1920x1080,有的是 1344x756,所以width和height必须从原图读,不能硬编码。

4. 避坑与排查:转换过程中最容易翻车的五个点

4.1 现象:训练时 mAP 始终为 0,loss 正常下降

原因:类别 id 从 0 开始编号,而 COCO 默认类别 id 从 1 开始。很多框架在加载时会把 0 当作背景类,导致所有目标都被忽略。解决:检查categories里的id是否从 1 开始,annotations里的category_id是否和categories一一对应。VisDrone 原始类别 0 是 ignored regions,转换时必须过滤,不能直接减一变成 0。

4.2 现象:COCO API 加载时报KeyError: 'area'

原因:annotations里缺少area字段,或者area和bbox的宽高不一致。有些脚本用w * h算面积,但w和h是整数,area写成了整数,而 COCO 要求浮点。解决:统一用float(w * h),并确保bbox也是浮点列表。

4.3 现象:VID 转换后图片数对不上,部分帧丢失

原因:frame_index和图片文件名没有对齐。VisDrone 的 VID 序列里,图片文件名是0000001.jpg这种七位数字,但标注里的frame_index可能从 1 开始,也可能从 0 开始。解决:打印前几帧的文件名和标注帧号,确认映射关系。如果文件名是七位,解析时用int(frame_file.replace(".jpg", ""))得到的就是实际帧号,不要额外加减。

4.4 现象:可视化时框全部偏移到左上角

原因:把归一化坐标当成了绝对坐标,或者把中心点格式当成了左上角格式。VisDrone 的bbox是绝对像素的左上角加宽高,不需要乘以图片尺寸,也不需要做中心点转换。解决:检查解析代码里有没有多余的归一化操作,x和y直接就是左上角坐标。

4.5 现象:转换脚本跑完内存爆掉

原因:VID 序列的标注文件可能非常大,一次性读入所有行再组装 JSON,内存占用会飙升。解决:用生成器逐行读取,或者分序列处理,每个序列单独写一个 JSON,最后再合并。我一般会先按序列输出小 JSON,再用jq或 Python 脚本合并,避免单进程内存超过 8G。

5. 进阶技巧:把 COCO JSON 直接喂给 MMDetection 和 YOLO

5.1 MMDetection 的配置文件改动点

MMDetection 对 COCO 格式的支持最成熟,但 VisDrone 的类别数和 COCO 不同,需要改三处。第一,metainfo里的classes改成十类名称;第二,num_classes改成 10;第三,data_root和ann_file指向你生成的 JSON。下面是一个最小改动的配置片段:

# 在 config 里覆盖 metainfo = dict( classes=("pedestrian", "people", "bicycle", "car", "van", "truck", "tricycle", "awning-tricycle", "bus", "motor") ) model = dict(bbox_head=dict(num_classes=10)) train_dataloader = dict( dataset=dict( data_root="data/visdrone/", ann_file="visdrone_det_train_coco.json", metainfo=metainfo ) )

注意ann_file的路径是相对于data_root的,如果 JSON 放在data_root外面,要么改data_root,要么写绝对路径。另外 MMDetection 的CocoMetric默认按 COCO 的 80 类评估,需要把ann_file也指向你的 JSON,否则评估时会报类别不匹配。

5.2 YOLO 格式的二次转换:从 COCO 到 txt

YOLO 系列吃的是每张图一个 txt,每行class_id x_center y_center width height,全部归一化到 0 到 1。如果你已经生成了 COCO JSON,可以再写一个脚本转成 YOLO 格式:

import json import os from PIL import Image coco = json.load(open("visdrone_det_train_coco.json")) img_dir = "VisDrone2019-DET-train/images" out_dir = "visdrone_yolo_labels" os.makedirs(out_dir, exist_ok=True) # 建立 image_id 到图片信息的映射 img_map = {img["id"]: img for img in coco["images"]} for ann in coco["annotations"]: img_info = img_map[ann["image_id"]] w, h = img_info["width"], img_info["height"] x, y, bw, bh = ann["bbox"] # 转中心点归一化 x_center = (x + bw / 2) / w y_center = (y + bh / 2) / h nw = bw / w nh = bh / h # YOLO 类别从 0 开始 cls_id = ann["category_id"] - 1 line = f"{cls_id} {x_center:.6f} {y_center:.6f} {nw:.6f} {nh:.6f}\n" txt_name = img_info["file_name"].replace(".jpg", ".txt") with open(os.path.join(out_dir, txt_name), "a") as f: f.write(line)

这里的关键是cls_id = category_id - 1,因为 YOLO 的类别从 0 开始,而 COCO 从 1 开始。另外归一化时要用原图宽高,不能用手写死的值。转完之后,YOLO 的data.yaml里nc: 10,names按顺序写十类名称即可。

5.3 一个容易被忽略的验证习惯

从那以后我每次转换完数据,都强制走一遍「加载-统计-可视化-训练一个 epoch」的流程。加载用pycocotools,统计看图片数和标注数是否和原始 txt 行数对得上,可视化随机抽三张图确认框的位置,最后用一个小模型跑一个 epoch,看 loss 是否正常下降。这四步走完,基本能拦住 95% 的格式问题。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询