☰
VisDrone2019转COCO格式:目标检测数据转换实战指南
2026/10/7 0:52:41 网站建设 项目流程

简介:一套针对Visdrone2019数据格式转换的轻量工具包,面向计算机视觉初学者及有检测、跟踪需求的开发者,解决DET与VID标注向COCO格式迁移的常见问题。包内文件共3个,包含2个Python脚本与1个Markdown说明文件,压缩包整体仅3KB;Python脚本承担转换核心逻辑与演示调用,Markdown文档则详细标明了需要修改的路径位置,用户按说明调整路径即可运行,无需附加依赖,操作简便。目前已有2207人学习使用。作者在描述中强调适合新手小白,代码注释与说明围绕实际使用场景展开,能帮助读者避开格式转换中常见的路径、目录结构等坑位;借助这一小体积方案,可快速完成Visdrone2019的DET、VID数据到COCO格式的本地转换,为后续模型训练与验证扫清数据准备障碍。 做目标检测的朋友应该都有这种体会:拿下一个新数据集,真正让人头疼的往往不是网络结构怎么调,而是“怎么把数据喂进训练脚本”。VisDrone2019就是最典型的例子。它是无人机视角下的目标检测与跟踪数据集,其中的DET(目标检测)和VID(视频目标检测)子任务都使用一套自定义的txt标注格式,字段虽然清晰,但主流训练框架根本不认。如果你打算在MMDetection、Detectron2或者自己写的检测pipeline里跑VisDrone2019,第一步几乎都是做CV数据格式转换,把DET和VID标注统一成COCO格式。这篇文章就把整个过程拆开讲,从两种格式的字段差异、转换脚本的核心设计,到我在实际转换中踩过的坑和验证技巧,完整过一遍。不管你是刚开始接触目标检测、准备拿VisDrone2019做实验,还是工作中要处理各种自定义数据集格式,这篇都能帮你省下不少排查时间。

1. 为什么转换是训练前最值得花的一步:两种格式的本质差异

1.1 VisDrone原生标注:一眼看懂的自定义txt

VisDrone2019-DET训练集的目录结构大概长这样:

VisDrone2019-DET-train/ ├── annotations/ │ ├── 0000001_00005_d_0000001.txt │ ├── 0000001_00005_d_0000002.txt │ └── ... └── images/ ├── 0000001_00005_d_0000001.jpg ├── 0000001_00005_d_0000002.jpg └── ...

每个txt和每张图片文件名一一对应,txt里每一行代表一个目标,全部用英文逗号分隔。DET格式一行共8个字段:

  1. bbox_left:框左上角x坐标
  2. bbox_top:框左上角y坐标
  3. bbox_width:框宽度
  4. bbox_height:框高度
  5. score:置信度,-1表示该行不参与AP计算
  6. object_category:目标类别编号
  7. truncation:目标截断程度,0表示未截断,1表示部分截断,2表示严重截断
  8. occlusion:目标遮挡程度,0表示未遮挡,1表示部分遮挡,2表示严重遮挡,3表示无法判断

这种格式的好处是直观、好解析,用split(',')就能拆开。缺点是训练框架不认,而且字段含义里还混着“评估时用的置信度”和“目标属性标记”,并不完全是模型训练需要的标签信息。

1.2 COCO:检测和实例分割领域的通用语言

COCO格式本质上是一个大JSON文件,最核心的三个数组是images、annotations、categories。每张图片在images里有一条记录,包含id、file_name、width、height;每个标注框在annotations里有一条记录,包含id、image_id、category_id、bbox、area、iscrowd;每个类别在categories里有一条记录,包含id、name、supercategory。

现代检测框架对COCO的支持是最成熟的。MMDetection里的CocoDataset、Detectron2里的COCOEvaluator、各种基于COCO预训练权重做迁移学习的方案,全都默认输入是COCO格式。把VisDrone转成COCO,不是为了显得正规,而是让后面的训练、验证、评估都能直接使用框架自带的数据加载和评测代码,省掉大量自造轮子的时间。

1.3 两种格式的核心差异

对比项VisDrone DET/VID原生txtCOCO JSON
数据结构一个txt文件对应一张图/一个视频,行代表标注一个json统一描述所有图片、标注、类别
bbox表示左上角x、左上角y、宽、高也是左上角x、左上角y、宽、高
类别编号1到12,最后一个通常是忽略区域自定义id,一般从1开始
图片组织文件名和标注文件名相同,或视频目录下的帧file_name字段直接记录路径
轨迹信息VID有target_id,DET没有默认没有track_id,需要自行扩展
是否带置信度有score字段训练标注中一般没有置信度

从表格能看出,两种格式的bbox表示完全一致,所以坐标本身不需要换算,这已经省了一半工作量。真正要处理的其实是结构重组:把一个txt文件里的每一行,变成JSON里的image记录和annotation记录,再把类别编号做一次映射。逻辑不复杂,但细节特别容易出错。

2. VisDrone2019 DET和VID标注文件拆解:从字段到边界情况

2.1 DET:一行一个框,文件与图片同名

DET的标注文件我上面已经写了字段定义,实际读取时每行就是逗号分隔的8个值。有一个细节需要注意:有些txt文件末尾会有空行,有些行尾还带着空格,split之前要先strip,否则解析出来的最后一个字段可能带换行符或者空字符串。

另一个容易忽略的点是“同名不同扩展名”。图片是.jpg,标注是.txt,但如果用文件名字符串拼接时写死小写扩展名,碰到.jpg和.JPG混用的情况就会漏文件。VisDrone官方包里确实存在这种大小写不一致的情况,Linux下尤其容易踩。我在转换时一律用os.path.splitext取主文件名,再统一到已建立的图片索引里查找,避免直接拼接路径。

2.2 VID:一行多出两个字段,跨帧才能还原轨迹

VID子任务的标注文件和DET长得像,但每一行开头多了两个字段,所以一共是10个字段:

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

frame_index表示这是该视频的第几帧,target_id是轨迹ID,同一个目标在连续帧中共享同一个target_id。目录结构也变了:

VisDrone2019-VID-train/ ├── annotations/ │ ├── uav0000001_00000_s.txt │ └── ... └── sequences/ ├── uav0000001_00000_s/ │ ├── 0000001.jpg │ ├── 0000002.jpg │ └── ... └── ...

每个视频一个文件夹,里面是逐帧图片,标注文件按视频命名。转换到COCO时,视频里每一帧都要成为images数组里的一条记录,这一帧上的所有标注框则挂在image_id下面。VID比DET多一个关键问题:不是每一帧都有标注。如果某些帧没有目标,txt里对应帧号就没有行,但这一帧图片仍然存在于sequences目录里。转换时如果只遍历txt行、按frame_index生成图片记录,那些没有标注的空帧就会被漏掉,这对后面做视频级检测或时序模型是不利的。稳妥做法是先扫描整个视频目录下的所有jpg文件,以实际存在的帧为准生成images记录,再根据frame_index去匹配标注。

2.3 类别编号的“版本差异”是第一个坑

VisDrone2019的类别,官方定义基本是这些:pedestrian、people、bicycle、car、van、truck、tricycle、awning-tricycle、bus、motor、other vehicle、ignored regions。但具体编号是“1到11是目标、12是忽略区域”还是“0是忽略区域、1到11是目标”,不同资料和不同开源代码里写法并不统一。

我第一次转换时在这上面栽过跟头。按照网上某个脚本的映射表直接把类别过滤了,结果训练出来的模型在验证集上mAP特别低,回头看才发现是类别编号错位,人和车全乱了。后来我养成了一个习惯:拿到数据集,先打开几个txt随便打印两行,用眼睛确认object_category的取值分布,再写映射表。这一步花不了两分钟,但能避免后面所有问题。转成COCO后,category_id重新从1开始编号,和源数据的原始编号完全解耦,这样即使VisDrone官方以后调整编号,我们的下游训练代码也不用改。

2.4 score字段和“是否保留”的取舍

DET标注里的score经常是-1。有不少人看到这个-1就以为是无用的标注,顺手把它过滤掉了。实际上,-1的意思是“该行不参与AP计算”,但作为训练样本,它仍然是真实标注。如果按“score小于某个阈值就丢掉”的逻辑过滤一遍,训练集会莫名其妙少掉一大块,而且少掉的多半是那些未被完整标注的困难样本,影响还挺大的。

所以我的建议是:训练集转换时,除非有特殊理由,否则不要用score做过滤。把score字段直接丢弃就好,因为COCO训练标注里本来就不需要置信度。只有在转换测试集、后续要做评估时,score字段才有意义,如果想保留,可以额外写在annotation的自定义字段里,不影响COCO的常规加载。

3. 转换脚本实现:先把DET跑通,再处理VID

3.1 DET转COCO:直接逐行映射

下面这段代码是我实际使用的DET转换脚本骨架,可以直接复制修改。

import json import os from glob import glob from PIL import Image VISDRONE_NAMES = [ "pedestrian", "people", "bicycle", "car", "van", "truck", "tricycle", "awning-tricycle", "bus", "motor", "other-vehicle", "ignored-regions" ] def visdrone_det_to_coco(img_dir, ann_dir, out_path, skip_ignored=True): images = [] annotations = [] # COCO类别id从1开始 categories = [ {"id": i + 1, "name": name, "supercategory": "visdrone"} for i, name in enumerate(VISDRONE_NAMES) ] cat_map = {i + 1: i + 1 for i in range(len(VISDRONE_NAMES))} # 建立 主文件名 -> 图片路径 的索引,避免直接拼路径 img_files = {} for root, _, files in os.walk(img_dir): for f in files: if f.lower().endswith((".jpg", ".jpeg", ".png")): base = os.path.splitext(f)[0] img_files[base] = os.path.join(root, f) ann_id = 1 image_id = 1 for txt_path in sorted(glob(os.path.join(ann_dir, "*.txt"))): base = os.path.splitext(os.path.basename(txt_path))[0] if base not in img_files: continue img_path = img_files[base] with Image.open(img_path) as im: w, h = im.size images.append({ "id": image_id, "file_name": os.path.relpath(img_path, img_dir), "width": w, "height": h, }) with open(txt_path, "r", encoding="utf-8") as f: for line in f: parts = [p.strip() for p in line.strip().split(",")] if len(parts) < 8: continue x, y, bw, bh = map(float, parts[:4]) cat_raw = int(parts[5]) if skip_ignored and cat_raw == 12: continue if cat_raw not in cat_map: continue annotations.append({ "id": ann_id, "image_id": image_id, "category_id": cat_map[cat_raw], "bbox": [x, y, bw, bh], "area": bw * bh, "iscrowd": 0, }) ann_id += 1 image_id += 1 coco = { "info": {"description": "VisDrone2019 DET converted to COCO"}, "images": images, "annotations": annotations, "categories": categories, } with open(out_path, "w", encoding="utf-8") as f: json.dump(coco, f)

这段代码里有两个设计是我特意保留的。第一,用os.walk建立图片索引而不是直接“images目录下所有jpg”,是因为VisDrone的包经过不同渠道下载,图片目录结构可能不完全一致,有的版本会把图片按子目录拆开,直接glob单个目录会漏图。第二,ann_id单独维护一个全局递增计数器,不和image_id混用,避免后续要往同一个JSON里追加标注时产生ID冲突。

3.2 VID转COCO:按帧聚合标注

VID的核心是“先按frame_index把标注行分组,再为每一帧生成一条image记录”。代码骨架如下:

def visdrone_vid_to_coco(video_dir, ann_dir, out_path, skip_ignored=True): images = [] annotations = [] categories = [ {"id": i + 1, "name": name, "supercategory": "visdrone"} for i, name in enumerate(VISDRONE_NAMES) ] cat_map = {i + 1: i + 1 for i in range(len(VISDRONE_NAMES))} ann_id = 1 image_id = 1 for txt_path in sorted(glob(os.path.join(ann_dir, "*.txt"))): video_name = os.path.splitext(os.path.basename(txt_path))[0] frame_dir = os.path.join(video_dir, video_name) # 1. 读取所有标注行 rows = [] with open(txt_path, "r", encoding="utf-8") as f: for line in f: parts = [p.strip() for p in line.strip().split(",")] if len(parts) < 10: continue rows.append(parts) # 2. 按frame_index分组 -> {frame_id: [annotation rows]} frames = {} for r in rows: fid = int(r[0]) frames.setdefault(fid, []).append(r) # 3. 以实际存在的图片为准,逐帧生成image记录 for jpg_name in sorted(os.listdir(frame_dir)): if not jpg_name.lower().endswith(".jpg"): continue base = os.path.splitext(jpg_name)[0] fid = int(base) # 帧文件名一般是数字 img_path = os.path.join(frame_dir, jpg_name) with Image.open(img_path) as im: w, h = im.size images.append({ "id": image_id, "file_name": os.path.relpath(img_path, video_dir), "width": w, "height": h, "video_id": video_name, "frame_id": fid, }) for r in frames.get(fid, []): x, y, bw, bh = map(float, r[2:6]) cat_raw = int(r[7]) if skip_ignored and cat_raw == 12: continue if cat_raw not in cat_map: continue annotations.append({ "id": ann_id, "image_id": image_id, "category_id": cat_map[cat_raw], "bbox": [x, y, bw, bh], "area": bw * bh, "iscrowd": 0, }) ann_id += 1 image_id += 1 coco = { "info": {"description": "VisDrone2019 VID converted to COCO"}, "images": images, "annotations": annotations, "categories": categories, } with open(out_path, "w", encoding="utf-8") as f: json.dump(coco, f)

这里的顺序反过来了:不再从txt出发推断有哪些图片,而是先从图片目录里扫描所有帧,再去txt里找对应frame_index的标注。好处是空帧也会被保留在images里,模型的输入和原始视频保持一致的时序关系。

3.3 为什么建议额外保留video_id和frame_id

COCO格式的image记录里本来没有video_id和frame_id这两个字段,但我强烈建议在转换VID时主动加上。加这两个字段不会影响任何现有框架加载COCO数据,因为大多数Dataloader只读取固定字段。但当后面你想做滑窗时空检测、或者把检测结果和跟踪算法串联起来时,video_id和frame_id就是现成的关联线索,不需要重新对文件名做解析。这个属于典型的“多写一行、省一大片事”的操作。

4. 最容易翻车的四个边界情况:我的排查过程

4.1 负坐标和超出图像边界的框

无人机俯拍场景下,目标从画面边缘离开时,标注框经常会出现负坐标,或者框的右下角超出图像宽高。如果直接拿这样的框去训练,某些网络里的数据增强模块会计算出非法ROI,轻则训练报错,重则模型输出NaN。

我在转换时统一做了clip处理:x、y、w、h解析出来后,先把x1=x、y1=y、x2=x+w、y2=y+h算出来,再用0和width/height做边界裁剪,最后重新计算w和h。如果裁剪后w或h小于1像素,就直接丢弃这个标注。写脚本时记得把这一步放在写入annotations之前,不要放在读取的时候,因为同一帧可能从多个txt行里取数,统一处理更好排查。

4.2 空标注文件和空视频帧

不少txt文件是空的,或者里面只有几行但恰好全是无效类别。还有的图片没有对应txt。我的脚本里遇到“base不在img_files里”时会直接continue,但这里有一个暗坑:如果图片存在但标注缺失,continue之后这张图片就不会被注册进images,后面如果你想拿标注文件清单和图片清单做完整性核对,会发现两边数量对不上。

排查这类问题有个简单办法:转换完成后,统计images数量、annotations数量、txt文件总数、图片文件总数,四者放在一起看。如果images数量明显少于图片文件总数,就说明有图片被漏掉了,需要把缺失清单导出来人工确认是“真的没有标注”还是“文件名对不上”。

4.3 score=-1的框要不要保留

前面提到过,score=-1不意味着无效。但在VID标注里我还遇到过一种情况:同一帧里同一个target_id出现两条标注,一条score是1,一条score是-1。这种大概率是标注冗余,转换后会出现两个几乎重叠的框。我处理时参考的是“保留score绝对值更大的那一条”,如果score相同就保留后出现的那条。这也是为什么我不建议在转换时粗暴丢弃score字段,可以留着做去重判断的依据。

4.4 路径分隔符和大小写问题

在Windows上跑脚本,如果直接把路径写死成'/'或'\',换到Linux服务器上就得重写。用os.path.join和os.path.relpath可以避免大部分问题。真正烦人的是文件名大小写不一致。VisDrone的图片扩展名混用.jpg和.JPG,如果图片索引用的是原文件名、而txt里的basename是小写,合并的时候两边对不上。我统一的做法是:建立索引时把主文件名转成小写作为key,查找时也转小写。这样即使扩展名大小写不一样,也能正确匹配。

5. 转完怎么确认没有转错:三个层面的验证

5.1 pycocotools加载测试

转换脚本跑完后,第一件事不是直接把JSON丢给训练框架,而是先用pycocotools加载一遍:

from pycocotools.coco import COCO coco = COCO("visdrone_coco.json") print("images:", len(coco.dataset["images"])) print("annotations:", len(coco.dataset["annotations"])) cat_ids = coco.getCatIds() cats = coco.loadCats(cat_ids) print([c["name"] for c in cats])

如果这一步能正常打印,说明JSON结构没有损坏,id没有重复,categories定义能被解析。注意,pycocotools能加载只能证明格式合法,不能证明内容对应正确,所以还要继续往下验证。

5.2 可视化抽检

可视化是最直接的验证方式。随机抽10张图,把转换后对应的annotation框画上去,看框是否贴合目标。

import random import cv2 from pycocotools.coco import COCO coco = COCO("visdrone_coco.json") img_ids = coco.getImgIds() random.shuffle(img_ids) for img_id in img_ids[:10]: info = coco.loadImgs(img_id)[0] img = cv2.imread(info["file_name"]) ann_ids = coco.getAnnIds(imgIds=img_id) anns = coco.loadAnns(ann_ids) for a in anns: x, y, w, h = a["bbox"] cv2.rectangle(img, (int(x), int(y)), (int(x + w), int(y + h)), (0, 255, 0), 2) cv2.imwrite(f"check_{img_id}.jpg", img)

这一步能发现很多低级错误,比如坐标xy反了、图片方向不对、类别颜色标注错。我在实际转换中通过可视化发现过一个文件,它的xml头里宽和高写反了,虽然极少数,但一旦出现就会污染整个数据集。

5.3 统计信息与源文件交叉核对

最后做一次数值层面的核验。统计源数据里每个类别出现的框数量,和转换后COCO里每个category_id的数量对比。两者应该完全一致。

from collections import Counter # 统计COCO里的类别分布 coco_ann_counts = Counter() for ann in coco.dataset["annotations"]: coco_ann_counts[ann["category_id"]] += 1 print(coco_ann_counts)

再写一个简单脚本统计源txt里的category分布,做差集。如果差距不为0,说明映射表有遗漏或者过滤条件有误位,需要回头检查。这一步虽然土,但比任何单元测试都管用。

6. 针对后续训练的取舍建议

6.1 要不要保留ignore类

VisDrone的类别里带一个ignored regions,通常指标注模糊、不参与评分的区域。我建议在训练集转换时直接过滤掉这一类,否则模型会把ignore区域也当成一个类别来学习,把原本“不区分”的区域强行区分,反而干扰检测性能。但在测试集转换时,建议保留原始类别编号,因为有些评估脚本需要读取ignore区域来计算被忽略的误检。

6.2 类别映射要不要重新设计

VisDrone的类别划分非常细,行人和人的区别在无人机视角下有时候连人眼都难以判断,三轮车和带蓬三轮车对模型来说更是天然的混淆项。如果要做的是通用场景检测,可以考虑把类别合并成person(pedestrian、people)、vehicle(car、van、truck、bus、motor、other-vehicle)、两轮车/三轮车(bicycle、motor、tricycle、awning-tricycle)等粗粒度类别。类别合并要在转换时做,不要留到训练后处理,因为训练时的分类头维度一开始就决定了。

6.3 VID转COCO后track信息的去向

COCO格式本身不支持轨迹信息,VID转换后target_id就没了。如果后续要做MOT或者单目标跟踪,就需要在annotation里额外保留track_id字段。虽然标准COCO没有这个字段,但很多框架在读取时对未知字段是忽略的,所以不影响训练。等到需要输出跟踪结果时,再按track_id把检测框串联成轨迹。这也是我在VID转换代码里建议在image记录里保留video_id和frame_id的原因,配合annotation里的track_id,一套数据可以同时喂给检测、跟踪、ReID三个方向。

6.4 转换脚本本身也要维护

最后分享一个小习惯:不要每次都从头写转换脚本。把visdrone_det_to_coco和visdrone_vid_to_coco放到一个独立的format_convert.py里,输入输出都用命令行参数控制。下次拿到同类新数据集,只需要改类别映射表和数据目录,脚本主体完全复用。我的这个脚本已经服务过三个项目,每次只需要微调过滤逻辑,很少再碰到“转出来的数据跑不了训练”这种问题。数据转换看起来是脏活累活,但它直接决定后续所有训练和评估的可靠性,值得多花几分钟把边界情况处理干净。

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

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

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

立即咨询