ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

手工标注高质量人车识别VOC数据集1000张:从VOC格式到YOLO训练全流程

手工标注高质量人车识别VOC数据集1000张:从VOC格式到YOLO训练全流程 简介手工标注的1000张人车识别VOC数据集面向计算机视觉开发者与深度学习算法工程师用于解决行人及车辆检测任务中标注数据不足、标注质量不稳定的问题。整个压缩包共1994个文件包括997个xml标注文件、729张jpg与268张png原始图像包体大小约711MB其中xml文件记录每一目标的类别、边界框坐标等关键信息可直接配合YOLO等检测框架进行训练。该数据包目前已有1035人学习下载。作者亲测训练后检测效果良好全部为纯手工标注在边界框准确性和标注一致性上有较高保证。相比自动标注或未经验证的数据集这份资源能显著减少数据清洗和重新标注的时间适合作为人车检测模型开发、算法验证与课程项目的起点。1. 手工标注高质量人车识别VOC数据集1000张这件事远比想象得更工程化做园区人车流量统计算法时我第一次没多想就直接用了官方COCO权重结果到了晚上问题一堆路边的外卖电动车被当成行人卡车车厢又被切成一个个独立小目标。复盘后才发现根子不在模型而在数据分布。于是把方案拆成两步先按PASCAL VOC格式手工标注高质量人车识别VOC数据集1000张再进入针对性训练和验证。手工标注的真正价值不只是“得有人干活”而是把类别边界、遮挡规则和难例筛选的主动权握在自己手里。这篇笔记适合三类人被业务数据和真实场景逼到想自己标图的工程师、零散标过图却没有规范化流程的团队、以及第一次认真想用YOLO训练自己数据的新手。从XML格式讲到工具选型从标注流程讲到质检脚本从转换命令讲到最后的验证环节全程按踩过的坑来写。2. 把“人车识别”落进VOC格式XML字段拆解、类别边界与工具选型很多人觉得手工标注嘛打开工具画框就行。但真正吃亏的是在标注进行到一半时发现XML里的filename和图片对不上、目标框坐标越界、类别名和训练脚本里的class list不匹配返工成本比重新标注还高。VOC格式的核心是一张JPEG对应一个XML文件两者靠“同名同前缀”绑定。XML里定义了这个文件里所有目标的位置、类别和状态。不理解这些字段后面写转换脚本就是在黑匣子里调参数出错了都不知道往哪查。2.1 VOC的XML里每个字段在管什么bndbox、truncated、difficult不能忽略先看一份实际标注后最常见的VOC XML结构我按LabelImg导出后的字段来说明annotation folderJPEGImages/folder filename20240115_193002_042.jpg/filename pathE:/datasets/JPEGImages/20240115_193002_042.jpg/path source databaseUserAnnotation/database /source size width1920/width height1080/height depth3/depth /size segmented0/segmented object nameperson/name poseUnspecified/pose truncated1/truncated difficult0/difficult bndbox xmin812/xmin ymin340/ymin xmax946/xmax ymax1020/ymax /bndbox /object /annotation这里最关键的字段是size和bndbox。width和height必须是原图像素尺寸转换到YOLO格式时所有坐标都要除以这两个值一旦尺寸不对训练时损失函数剧烈震荡且不收敛。bndbox里的xmin/ymin和xmax/ymax是像素坐标系下的左上角和右下角坐标左上角小于右下角这是基本约束哪怕标反了一个符号目标框就变成负宽度。truncated表示目标是否被图像边界截断比如人从画面边缘走出来只露出一半身体此时truncated应该标1但框体只需要框住可见部分。difficult在PASCAL VOC原始定义里表示这个目标因为太模糊、太小或遮挡严重难以判断类型官方评测时不计入AP。实际工程里我一般把difficult分成两种用法如果目标小到人类肉眼看都分不清是人是车直接不标如果目标是明确的但被严重遮挡我会仍然标全并通过质量脚本记录让模型处理遮挡而不是把边界难题丢给评测。pose和segmented对2D检测基本没有用途保留默认值即可。可以说手工标注时你真正在生产的不是框而是一份机器可读的“责任说明书”。每个字段都有它的下游去向宁可格式单一只用一种工具也不要混着多工具导出的XML到转换阶段才发现字段对不上。2.2 类别边界先于标注person和vehicle之间没有被框住的模糊地带“人车识别”看起来是两个类别但在交通场景里骑自行车的人、骑电动车的人、推着摩托车走的人这些目标天然地横跨两类。我在最初做园区统计时犯过一个错把骑电动车的外卖员整体框成一个“person”大框结果模型学到的是“person框里包含了电动车形状”后来对停在路边的电动车也产生了误检。解决这个问题的办法是在动鼠标之前把类别和优先级写成标注规范。常见做法是把类别定义为person、bicycle、car、motorbike、bus、truck六个类人车识别任务的输出端再从这些类里聚合出“人”和“车”。但这种划分会显著增加标注工作量对于只想做人车二分类的业务也可以只定义person和vehicle两类代价是失去了车的细分类别信息。我的习惯是询问下游需求如果最终要做“车流分类统计”就分六类如果只是判断画面里有没有人、有没有车就分两类。分类粒度越细标注规则越复杂。对于骑行人物的处理我采用一条铁律只要是人在骑行状态必须把人和车分开框人体框不能包进车轮车体框不能包进人体。两个框可以重叠但类别不能混。这样做的原因是人体姿态检测和车辆计数仍然是两个独立下游与其让模型从混合框里学出耦合特征不如在数据源头就分开。多说一句手工标注质量高指的不是框得多么精细到像素级而是“边界情况一致”。同一个目标在1000张图里无论谁标注框的风格和类别应该一致。为此类别定义表要保存成文本文件和数据集一起版本管理谁都不允许在标注过程中临时改类别名。2.3 手工标注工具选型LabelImg够用什么时候换X-AnyLabeling或CVAT我按“人工成本”和“协作规模”两个维度来看工具选择。个人或两人以内、单机完成1000张LabelImg几乎是最稳妥的选择它默认输出VOC XML没有中间转换学习成本极低。三人以上的团队并行标注我更建议直接上CVAT它能做任务分配、二次审核和版本回溯虽然部署成本高一点但省下来的协作时间远远超过部署时间。工具适合规模核心能力主要限制LabelImg1到2人单机输出标准VOC XML快捷键简单无多人协作无自动保存恢复X-AnyLabeling单人但需要半自动辅助支持跟踪、分割、半自动标注导出需要二次确认字段CVAT3人以上团队任务分配、审核流、版本管理需要服务器部署有学习成本这里有一个容易被忽视的点X-AnyLabeling这类工具支持视频跟踪自动插值能大幅减少重复标注但自动生成的目标框常常在遮挡时漂移逐帧复核的成本并不低。对于1000张静态图场景我并没有觉得半自动是刚需反而是LabelImg的W键画框和A/D切换图片的流畅度最顺手。工具只有趁手这一个判断标准预览功能再强大不如让你一晚上标出200张图来得实在。3. 1000张VOC手工标注全过程从图像筛选到质检脚本这一章进入实操。我默认你已经确定了类别和工具接下来按“原始图像准备 → 标注操作 → 自动质检”三步推进。很多人标到一半才发现图像源有严重偏置比如全是白天的平视画面没有任何夜间和俯视样本再回头补数据就晚了。所以筛选图片这步不该省。3.1 原始图像筛选监控抽帧怎么做、哪些图必须被排除1000张图不等于从视频里随便截1000帧。监控视频连续帧之间高度相似如果直接顺序抽帧你会发现训练集里大量重复场景模型的泛化能力基本为零。常见做法是使用ffmpeg按时间间隔抽帧先抽出冗余池再人工剔除模糊和重复帧ffmpeg -i 20240115_1920_01.mp4 -vf fps1/20,scale1920:1080 -q:v 2 cam_frame_%04d.jpg这条命令里fps1/20表示每20秒取一帧scale1920:1080把画面统一缩放到1080p分辨率-q:v 2控制JPEG质量数值越小质量越高一般取2到4之间。抽出来的帧放进一个候选池然后人工把以下三类直接删除画面模糊到人眼无法判断目标类型的目标总数超过清晰识别极限的密集场景以及目标占比过小导致框体小于30×30像素的。你需要让数据集覆盖至少两种拍摄姿态、两个时段、两类天气条件。我自己的经验比例是白天平视占40%、夜间平视占25%、高空俯视占20%、黄昏或逆光占15%。如果原本的监控数据没有俯视角度宁可先少收一些也不要让全部1000张都来自同一个视角否则模型换一个安装位置就可能性能断崖。还有一个小技巧抽帧时保留原始时间戳在文件名里比如20240115_193002_042.jpg后面按场景划分train/val时非常有用。3.2 LabelImg标注操作流从安装启动到完整保存流程安装LabelImg最稳定的路径是pip install labelImg如果安装后启动报PyQt5关联错误先执行pip install pyqt5再试。启动前我建议先准备一个类别文件classes.txt内容按行写person bicycle car motorbike bus truck然后带着图片文件夹、类别文件和输出目录一起启动labelImg JPEGImages classes.txt Annotations这个命令让标注框直接保存在Annotations目录下XML与JPEG同名。画框的快捷键是W拖动鼠标覆盖目标主体切到上一张和下一张是A和D保存是CtrlS。我强烈建议每标一张图就按一次CtrlS不要攒着批量保存。工具崩溃或断电时未保存的框会全部消失那种体验经历过一次就不想再经历第二次。还有两个细节要养成习惯。第一画框时的四条边要贴着目标的最外轮廓不要留一大圈背景也不要切掉车辆后视镜或人体四肢。第二LabelImg会把图片的绝对路径写进XML的path字段但转换脚本只认filename和size所以任何时候移动数据集目录只需要保证图片和XML在同一前缀结构下即可。我一般会在标注全部完成后用脚本把path字段统一置空避免不同机器上路径不一致带来的困扰。3.3 标注抽样质检用Python脚本查超界、宽高异常和漏标手工标注难免眼花所以质检脚本是数据集的最后一道防线。这个脚本不应该等到全部标完才跑而是每标200张就跑一次发现问题当场修。下面是我常用的检查脚本逻辑很直接# voc_quality_check.py # 检查XML与图片是否对得上并找出可疑的标注框 import os import sys import glob import xml.etree.ElementTree as ET from PIL import Image def check_one(xml_path, image_root): root ET.parse(xml_path).getroot() filename root.findtext(filename) if filename is None: return [f{xml_path}: 缺少filename字段] img_path os.path.join(image_root, filename) if not os.path.exists(img_path): return [f{xml_path}: 图片 {filename} 不存在] img Image.open(img_path) img_w, img_h img.size xml_w int(root.findtext(size/width)) xml_h int(root.findtext(size/height)) problems [] if img_w ! xml_w or img_h ! xml_h: problems.append(f{xml_path}: 尺寸不一致 XML{xml_w}x{xml_h} 图像{img_w}x{img_h}) for obj in root.findall(object): name obj.findtext(name) if name is None or name : problems.append(f{xml_path}: 发现空类别) box obj.find(bndbox) xmin int(box.findtext(xmin)) ymin int(box.findtext(ymin)) xmax int(box.findtext(xmax)) ymax int(box.findtext(ymax)) if xmin xmax or ymin ymax: problems.append(f{xml_path}: 目标 {name} 宽高异常 f({xmin},{ymin})-({xmax},{ymax})) if xmin 0 or ymin 0 or xmax img_w or ymax img_h: problems.append(f{xml_path}: 目标 {name} 越界) return problems if __name__ __main__: ann_dir sys.argv[1] img_dir sys.argv[2] xml_list glob.glob(os.path.join(ann_dir, *.xml)) if len(xml_list) 0: print(未找到任何XML文件请检查目录) sys.exit(1) error_count 0 for xml_path in sorted(xml_list): problems check_one(xml_path, img_dir) for p in problems: print(p) error_count 1 print(f共检查 {len(xml_list)} 个XML发现 {error_count} 个问题)调用方式为python voc_quality_check.py Annotations JPEGImages。脚本输出的每一条问题都对应一段返工路径尺寸不一致是因为标注后改了图片分辨率忘记同步XML宽高异常是手误双击产生的零面积框越界则是画框时超出画布边界。这些问题是训练时最难排查的隐性故障直接在源头消掉比你后期看着NaN损失函数找原因高效得多。4. 手工标注避坑实录类别失衡、同名覆盖和工具使用带来的5个坑手工标注的“翻车现场”往往不在画框那一下而在流程管理的细枝末节。这一章我按现象、原因、解决三个层次来写每条都是我或身边人实际遇到过并修复过的。4.1 现象转换标签时提示label index out of range从XML转YOLO时训练脚本报错提示某个标注的类别索引超出了class list长度。打开XML才发现里面存在一个叫motor的类别而转换脚本的CLASSES列表里写的是motorbike。原因几乎都是同一个多人标注或不同批次标注时类别名没有强制统一。有人图省事把motorbike简写成motor也有人把car写成vehicle每个不一致都会在转换阶段变成一个独立的类名索引轻则训练类别错位重则直接数组越界。解决把类别白名单写进质检脚本任何不在白名单里的name都输出为错误并中断转换。同时在项目开始的第一天就锁定classes.txt不做任何“看起来更顺眼”的改名。你要记住类别名是给程序读的不是给人读的一致性比美观重要一百倍。4.2 现象模型把路边的电动车误检成行人训练完后的可视化结果里一辆停在路边的电动车被画上了person框。回头查训练集发现大量标注确实是把骑行中的人车合体框统一标成了person。原因短促的标注规程里没有定义“人车重叠时该怎么处理”标注员图省事直接拉一个最大外接矩形覆盖整个目标把电动车也包进了person框里。模型学到的person框里包含了大量车体边缘特征于是路边的空车也被激活了person响应。解决把标注规范落成文本具体到“人在两轮车上时人体框只框身体躯干不包含车轮如果人体被车体完全遮挡则优先保证可见的车体类别不要强行补person”。这个规范在工具里无法强制只能靠抽样评审。我的习惯是每标完200张随机抽10张标注密集的图专门看人车重叠区发现一次违规就直接让标注员自查最近50张。4.3 现象多人并行标注后部分XML文件被覆盖丢失三个人通过网盘共享同一个Annotations目录各标各的结果第二天发现某个区间段的XML内容变成了别人的标注版本有的文件整个消失。原因网盘同步是有时间差的两个人在同一时刻读写同一个XML后写入的一方会覆盖先写入的内容。更糟的情况是有人修改了本地文件但网盘没有同步直接关闭电脑服务端保存的是旧版本。解决最先要防的是“多人同时操作同一批文件”。我按编号段切分比如1到400号图由A标401到800号图由B标801到1000号图由C标各标各的互不干扰。然后每天收工时用Git提交一次第二天开工前先检查工作区状态。这个习惯多花五分钟但能避免整批返工值得养成。4.4 现象从XML转换出的YOLO坐标全是0.0训练时发现数据加载正常但可视化出来的目标框全部落在图片左上角坐标趋向于0或负值。用调试脚本打印转换结果发现x_center和y_center都是0或者负数。原因有两个常见来源。一是XML里的bndbox本身就出现了xmin大于xmax的情况转换脚本没有做合法性判断直接把负宽度算出x_center结果当然异常。二是读取字段时用错了路径比如用findtext(bndbox/xmin)写成findtext(.//xmin)在多目标文件里可能读到第一个目标的xmin导致所有类别框共享同一个坐标。解决转换脚本里必须对每个框做一次合法性检查计算完x_center、y_center、w、h后检查它们是否都落在[0, 1]区间内不满足就抛出完整错误信息附带文件名和目标类别。除此之外逐字段读取bndbox是不二法门别用相对路径去模糊匹配。你可以在打点时直接用零宽零高来判定配合第3章的质检脚本这类问题基本能在标注阶段就被拦截。4.5 现象类别平衡统计亮红灯某些车类只有几十个框标注完成统计后发现1000张图里person有4000多个框car有2500多个框但bus只有35个框。训练结果几乎必然会是模型见到公交车根本不框。原因其实不在于标注质量而在于原始图像筛选失衡。采集阶段的摄像头位置固定画面里压根没有公交车经过你再怎么精标也生不出这类样本。解决把“补录”当成流程里的一环而不是事后补救。用一天时间去目标路段拍摄公交车、卡车和夜间行驶车辆抽帧后加入候选池再重新平衡。这里有一个不推荐的做法对少数类做简单复制粘贴或过度旋转增强来凑数量。这只能让模型“见过”这个类别的纹理但对不同姿态和场景的泛化能力仍然为零。手工标注的宝贵之处就是可以按真实分布去补充原始图像而不是用增强制造数据幻觉。5. 把VOC转成YOLO训练格式直接可跑的转换脚本与数据划分策略VOC XML是给人看也适合做数据交换的格式但YOLO系列训练只吃TXT标签。这一步转换是手工标注成果的“最后一公里”。很多人在这一公里上翻车因为坐标归一化和类别索引的顺序问题都是只看一眼难以发现的。5.1 VOC转YOLO必须处理的三个差异归一化、difficult过滤、路径对齐VOC里的bndbox是像素绝对坐标YOLO需要的是一行相对坐标由class_id x_center y_center w h组成所有值都是浮点数范围在0到1之间。转换的核心是四步运算x_center (xmin xmax) / 2 / widthy_center (ymin ymax) / 2 / heightw (xmax - xmin) / widthh (ymax - ymin) / height第二个必须处理的差异是difficult字段。VOC官方评测里difficult1的目标不参与AP计算但YOLO训练没有这个概念。你要在转换时决定保留它进训练还是直接过滤。我的经验是过滤掉difficult1的框因为人眼都觉得困难的目标喂给模型只会增加训练过程中的不稳定噪声。第三个差异是路径对齐。LabelImg导出的XML里filename可能只带文件名也可能是相对路径YOLO转换时还要在数据集配置里指定图像根目录。尽量让所有图片平铺在JPEGImages目录下不要嵌套多级子目录这能省掉大量路径拼接的bug。5.2 转换脚本与调用方式可直接运行的VOC2YOLO脚本下面是一个我精简过的转换脚本完全面向VOC到YOLO的转换场景# voc2yolo.py # 用法: python voc2yolo.py --xml-dir Annotations --out-dir labels --ignore-difficult 1 import os import glob import argparse import xml.etree.ElementTree as ET CLASSES [person, bicycle, car, motorbike, bus, truck] def convert_one(xml_path, out_dir, ignore_difficultTrue): root ET.parse(xml_path).getroot() filename root.findtext(filename) stem os.path.splitext(filename)[0] width int(root.findtext(size/width)) height int(root.findtext(size/height)) txt_path os.path.join(out_dir, stem .txt) lines [] for obj in root.findall(object): name obj.findtext(name) if name not in CLASSES: raise ValueError(f{xml_path} 里出现未知类别: {name}) difficult int(obj.findtext(difficult) or 0) if ignore_difficult and difficult 1: continue class_id CLASSES.index(name) box obj.find(bndbox) xmin float(box.findtext(xmin)) ymin float(box.findtext(ymin)) xmax float(box.findtext(xmax)) ymax float(box.findtext(ymax)) w (xmax - xmin) / width h (ymax - ymin) / height if w 0 or h 0: print(f警告: {xml_path} 中的目标 {name} 宽高非法跳过) continue x_center (xmin xmax) / 2.0 / width y_center (ymin ymax) / 2.0 / height lines.append(f{class_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}) with open(txt_path, w, encodingutf-8) as f: f.write(\n.join(lines) \n) def main(): parser argparse.ArgumentParser() parser.add_argument(--xml-dir, requiredTrue, help存放VOC XML的目录) parser.add_argument(--out-dir, requiredTrue, helpYOLO txt输出目录) parser.add_argument(--ignore-difficult, typeint, default1) args parser.parse_args() os.makedirs(args.out_dir, exist_okTrue) for xml_path in sorted(glob.glob(os.path.join(args.xml_dir, *.xml))): convert_one(xml_path, args.out_dir, args.ignore_difficult) print(转换完成:, len(glob.glob(os.path.join(args.xml_dir, *.xml))), 个文件) if __name__ __main__: main()脚本里的CLASSES顺序必须和后续训练配置文件中的names顺序完全一致这个顺序决定每个类别的索引。常用YOLO训练框架下第一个类别索引为0第二个为1依此类推。参数--ignore-difficult 1是默认开启如果数据集里没有difficult1的目标这条逻辑不会有任何影响。转换完成后我建议先打开一个任意TXT文件看一眼内容确认坐标值都在0到1之间且没有任何负数和零宽再继续做数据划分。5.3 训练与验证数据划分按场景抽分不按文件名随机拆这是最常被忽略的一步。如果直接对整个文件列表做随机划分同一段视频的连续几帧极可能同时进入train和val验证集和训练集高度重叠。模型在验证集上的指标会虚高换到真实场景立刻“翻车”这种玄学偏差在手工数据集上尤其明显。正确做法是提前按“场景”分组。我建议在图像命名时就带上场景ID比如scene01_0001.jpg、scene02_0001.jpg。划分的时候按场景ID分组同一个场景的帧要么全部进train要么全部进val不允许跨分# split_by_scene.py # 按filename前缀场景划分train和val前缀以第一个下划线前内容为准 import os import random jpg_root JPEGImages train_list [] val_list [] scene_groups {} for name in os.listdir(jpg_root): if not name.endswith(.jpg): continue scene_id name.split(_)[0] scene_groups.setdefault(scene_id, []).append(name) random.seed(42) for scene_id, names in scene_groups.items(): if random.random() 0.8: train_list.extend(names) else: val_list.extend(names) with open(train.txt, w) as f: f.write(\n.join(train_list) \n) with open(val.txt, w) as f: f.write(\n.join(val_list) \n) print(train:, len(train_list), val:, len(val_list))这个脚本的split(_)[0]依靠文件名规约如果你的图像命名没有场景前缀就得先从视频元数据里追到场景ID再分组。分组完成后再看一眼两边的类别比例如果bus类在val里一个都没有说明分组碰巧把这类样本全送进了train可以手动调整场景组归属。划分的最终比例我一般用80%训练、20%验证1000张的规模下验证集200张足够反映数据质量测试集如果需要单独留再从训练集里按场景抽10%出来。6. 验证与收尾数据交付前先看可视化别急着谈mAP一个数据集标完、转换完、划分完最忌讳的事情是直接丢进训练脚本跑几十轮然后只看mAP数字决定好坏。手工标注的质量问题凭两个坐标点是看不出来的必须在数据集交付前做一次“可视化回归”。我的做法是先跑一个极短的YOLO训练比如用YOLOv8n模型只训练30轮把batch设小一点目标不是拿到高精度而是把数据里的异常暴露出来yolo detect train datadataset.yaml modelyolov8n.pt epochs30 imgsz640 batch8训练结束后在验证集上做推理并保存可视化结果逐张看图而不是只看指标。重点盯三类问题一是漏检原本标注过的目标模型完全没框出来这往往说明标注框和图像内容存在严重风格偏差二是错位框体偏移到目标旁边这通常是标注时框体没有贴合目标主体三是误检模型对无目标区域输出了高置信度框这往往和类别定义不一致直接相关。我第一次从零开始做手工数据集时mAP0.5已经到了0.86得意了不到半天夜里出门实测发现电动车误检成行人的情况成片出现。后来我把错误样本打印出来发现漏检的几乎都是夜间远距离小人目标正确做法是回到数据层面补一些夜晚远端场景的图而不是加一个更强的增强策略。这件事让我养成了一个收尾习惯每次数据标注完成后用10分钟对着验证集的可视化结果看一遍一旦发现哪类目标频繁出错先查数据再调参。到现在我仍然会在交付前把标签在图像上叠加导出做成几页对比图发给团队复核让数据集的价值在业务使用前就被确认过。手工标注高质量人车识别VOC数据集1000张真正的考验不在鼠标而在流程意识和数据管理能力。希望这篇笔记能帮你少走我已经走过的弯路把这1000张图变成能真正上线的东西。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进