☰
玩手机检测数据集VOC格式处理:从XML解析到YOLO转换实战指南
2026/9/26 8:30:16 网站建设 项目流程

简介:面向目标检测入门与实战场景,玩手机检测数据集以VOC格式提供完整的XML标注文件,并已完成训练集与测试集划分,可直接用于目标检测模型训练,省去数据预处理环节。数据聚焦人群玩手机行为,包含face、drink、phone三个类别,图像为300-400分辨率的RGB图片,适合安全驾驶提醒、课堂注意力监测等行为分析场景。整个资源包共2000个文件,主要由1362个XML标注文件、636张JPG图片、1个Python可视化脚本和1个类别JSON字典组成,压缩包约53.79MB;目录按照train/test组织,训练集1090张、测试集272张,图片与标注一一对应,无需额外处理。额外提供的可视化脚本无需修改即可运行,随机传入一张图片就能绘制边界框并保存,便于快速检查标注质量。目前已有480人学习下载,适合需要现成目标检测数据集的开发者或研究人员直接使用。

1. 玩手机检测数据集:为什么VOC标注格式反而最省事

要拿目标检测去卡“玩手机”这个行为,场景基本都长一个样:工地值班室、学校课堂、驾驶舱、收费站岗亭,摄像头已经装好了,但监管人员不可能 24 小时盯着屏幕。真正落地时,模型要处理的是两个小目标——手机本身,以及拿着手机的那只手。这种任务的数据集,VOC标注格式的xml文件反而是兼容性最好的中间格式:图像归图像,xml归xml,训练时从 ImageSets 里的 txt 读文件名,想接哪个训练框架都能自己转。加上已经做了训练集和测试集划分,等于省掉了数据工程里最磨人的一步。适合正在做行为识别、安防监控或者课堂纪律分析,又不想从零开始标数据的从业者。

2. VOC标注格式的XML字段拆解:解析脚本与配对自检

2.1 从 annotation 到 bndbox:每个字段到底干什么

VOC 格式的核心是“一张图对应一个同名 XML”,里面记录图的尺寸、来源、以及图上每个目标的类别和位置。玩手机检测数据集里,绝大多数标注对象是phone,也可能混着hand或者person,先别急着写训练配置,把 XML 字段吃透再动手。

一个标准 VOC xml 的关键字段如下:

字段含义训练时是否真的被用到
folder图片所在目录名基本不用,但解析脚本别因为读不到它而报错
filename图片文件名,必须与磁盘上的文件一致最关键字段之一,配对检查就靠它
path当时标注机器的绝对/相对路径跨机器以后基本失效,千万别依赖
source数据集来源信息,含 annotation 等子节点不用
size/width, height, depth图片宽、高、通道数width 和 height 是坐标换算的基准,必须正确
segmented旧版 VOC 的语义分割标记,0 或 1目标检测不用
object/name目标类别,字符串转 YOLO 时映射成整数 id,映射错全盘错
object/pose拍摄姿势,如 Frontal检测不用
object/truncated目标是否被截断,1 表示出画面或被遮挡有些数据集不填,可忽略
object/difficult1 表示该目标难以辨认评估时会剔除,转 YOLO 时怎么处理有讲究
object/bndboxxmin, ymin, xmax, ymax 四个整数核心坐标,左上右下,单位是像素

这里最容易翻车的不是 bndbox 本身,而是filename和size。标注工具导出时如果改了文件名后缀,XML 里写image_001.jpg,磁盘上实际是image_001.png,训练框架会把这张图当成坏样本跳过。size一旦写错,所有归一化坐标都会整体偏移,模型看到的目标位置和真实位置对不上,loss 还照常下降,属于典型的“静默失败”。

2.2 批量解析全部 XML:一个脚本统计类别、框数、尺寸分布

拿到数据集先别急着训练,做一次全量统计,确认里面到底有多少类别、每类多少框、框的尺寸分布是否异常。我一般会写一个通用的 VOC 解析脚本:

import os import xml.etree.ElementTree as ET from collections import Counter def parse_voc_xml(xml_path): """解析单个VOC xml,返回文件名、图像尺寸和目标列表""" tree = ET.parse(xml_path) root = tree.getroot() filename = root.findtext("filename") size = root.find("size") width = int(size.findtext("width")) height = int(size.findtext("height")) objects = [] for obj in root.findall("object"): name = obj.findtext("name") difficult = int(obj.findtext("difficult", "0")) box = obj.find("bndbox") xmin = int(float(box.findtext("xmin"))) ymin = int(float(box.findtext("ymin"))) xmax = int(float(box.findtext("xmax"))) ymax = int(float(box.findtext("ymax"))) objects.append({ "name": name, "difficult": difficult, "bbox": (xmin, ymin, xmax, ymax), }) return filename, width, height, objects def scan_voc(xml_dir): """扫描整个Annotations目录,输出统计信息""" cls_counter = Counter() # 类别 -> 框数 box_per_img = Counter() # 单图框数 -> 出现次数 difficult_counter = Counter() # difficult标记分布 total_images = 0 for xml_name in os.listdir(xml_dir): if not xml_name.endswith(".xml"): continue xml_path = os.path.join(xml_dir, xml_name) filename, width, height, objects = parse_voc_xml(xml_path) total_images += 1 box_per_img[len(objects)] += 1 for obj in objects: cls_counter[obj["name"]] += 1 if obj["difficult"] == 1: difficult_counter[obj["name"]] += 1 print(f"图片总数: {total_images}") print("类别框数:", dict(cls_counter)) print("单图框数分布:", dict(sorted(box_per_img.items()))) print("difficult样本数:", dict(difficult_counter)) if __name__ == "__main__": scan_voc("VOC2007/Annotations")

注意我在解析 bndbox 时用了int(float(...))而不是直接int(...)。原因很实在:不少标注工具会把坐标写成124.0这种浮点字符串,直接int("124.0")会抛异常,加了float转换就能兼容整数和浮点两种写法。统计结果出来后要看三类信息:类别名称是否和预期一致、单图框数是否有极端值、difficult 样本占比是否过大。如果box_per_img里出现大量 10 框以上的密集图,说明有一部分画面是学生集体低头玩手机,这类图对模型学习很关键,不能删。

2.3 图片与 XML 配对检查:少一个文件训练就会静默失败

VOC 数据集的常见病是图片和 XML 不同步:有些标注导出的图没有对应 XML,有些 XML 对应的图片已经损坏或被移动。训练框架在加载时遇到缺文件,有的直接跳过,有的会崩。跳过的问题很大——你不会知道模型“少看”了多少张图,训练日志里没有明显报错,一个 epoch 的图片数莫名其妙少了几百张。

def check_pair(img_dir, xml_dir): """检查JPEGImages和Annotations是否一一对应""" img_names = set(os.listdir(img_dir)) xml_names = set() for name in os.listdir(xml_dir): if name.endswith(".xml"): xml_names.add(name[:-4] + os.path.splitext( next(f for f in os.listdir(img_dir) if f.startswith(name[:-4])) )[1]) missing_xml = [f for f in img_names if f[:-4] + ".xml" not in xml_names] missing_img = [f for f in xml_names if f not in img_names] print(f"缺XML的图片数: {len(missing_xml)}") print(f"缺图片的XML数: {len(missing_img)}") if missing_xml[:5]: print("示例(缺XML):", missing_xml[:5]) if missing_img[:5]: print("示例(缺图):", missing_img[:5])

这段检查脚本的逻辑是:把 XML 文件名去掉后缀,再拼接上图片实际后缀,和图片目录比对。如果发现大量缺 XML 的图,先看是不是文件名大小写不一致导致,比如IMG_001.jpg和img_001.jpg。同样一张图,Windows 文件系统不区分大小写,Linux 上就是两个文件,这是跨平台训练最容易忽略的坑。

3. 训练集和测试集划分:验证这个数据集能否开箱即用

3.1 先看懂划分文件:ImageSets/Main 里的 txt 决定了数据集怎么被读取

VOC 的划分不靠目录名区分,靠的是ImageSets/Main/下的 txt 文件。打开这个目录,通常会看到train.txt、val.txt、test.txt,有的还有trainval.txt。每个 txt 里是一行一个文件名前缀,没有扩展名,例如:

image_03421 image_08772 image_10023

训练时,框架拿着这些前缀去JPEGImages/里找.jpg,去Annotations/里找.xml。所以拿到数据集第一步,是看这些 txt 文件存不存在、内容是否为空、是否有重复行。重复行的后果是同一张图在训练集里被计算了两遍,模型会无意识地对这些图过拟合,测试集里一旦出现相近画面,mAP 就会虚高。

确认完文件存在后,还要注意区分val.txt和test.txt的用途:val.txt是训练过程中调参用的,每个 epoch 结束都要算一次 loss;test.txt是最终评估用的,整个训练过程都不该碰。很多 U 盘拷来拷去的数据集,交到你手上的划分里 test 和 val 混在一起,那就要自己重新分。

3.2 划分方式决定模型上限:随机划分和按场景划分差别巨大

玩手机检测数据的来源往往是监控视频抽帧。如果划分时是把所有帧混在一起随机 split,那同一段视频里的相邻帧会同时出现在训练集和测试集里——模型训练时已经见过几乎一样的画面,测试分数当然好看,部署到新场景立刻打回原形。这个现象叫数据泄漏,是目标检测数据集开箱即用时最隐蔽的问题。

我判断数据集划分是否合理的标准很朴素:先看图片文件名是否有规律。如果文件名是video1_00012.jpg、video1_00013.jpg这种带视频序号和时间戳的,检查train.txt和test.txt里是否出现了同一个video1。出现就说明划分时没按场景切,模型评估结果会虚高至少 10 个点。反过来说,一个数据集把video1整体划进训练、video2整体划进测试,这种按场景划分的方式才是负责任的。

这里也要提醒一点:划分里的图片数和 XML 数量一致,不代表划分合理。我见过一个数据集做了 8:2 划分,但训练集里全是室内课堂图,测试集里全是室外广场图,类别分布虽然一样,场景差异却让模型阈值完全失配。这时候宁可自己重新划分,也别用现成的 txt。

3.3 用脚本核对两个集合的类别分布:train/test 不平衡一眼看清

拿到带划分的数据集,我做的第一个验证是:把 train 和 test 里的类别框数分别统计,看比例是否接近。玩手机检测常见的类别不平衡是两种:一种是phone框数远多于hand,模型最后学成只认手机不认手的“半个检测器”;另一种是 train 里手机占 90%,test 里人手占 40%,评估时模型直接懵。

import os import xml.etree.ElementTree as ET from collections import Counter sets_dir = "VOC2007/ImageSets/Main" xml_dir = "VOC2007/Annotations" def load_set(set_name): """读取ImageSets/Main下的txt,返回文件名前缀列表""" with open(os.path.join(sets_dir, set_name + ".txt"), encoding="utf-8") as f: return [line.strip() for line in f if line.strip()] def count_distribution(prefix_list): """统计一组图片的类别框数和difficult比例""" cls_counter = Counter() difficult = Counter() total_boxes = 0 for prefix in prefix_list: xml_path = os.path.join(xml_dir, prefix + ".xml") if not os.path.exists(xml_path): print(f"警告: 缺少XML {xml_path}") continue tree = ET.parse(xml_path) root = tree.getroot() for obj in root.findall("object"): total_boxes += 1 name = obj.findtext("name") cls_counter[name] += 1 if int(obj.findtext("difficult", "0")) == 1: difficult[name] += 1 return cls_counter, difficult, total_boxes train_cls, train_diff, train_total = count_distribution(load_set("train")) test_cls, test_diff, test_total = count_distribution(load_set("test")) print("Train框数: 总", train_total, dict(train_cls)) print("Test 框数: 总", test_total, dict(test_cls)) print("Test中difficult占比:", {k: round(v / test_cls[k], 2) for k, v in test_diff.items() if test_cls[k] > 0})

这个脚本跑完只看两个比例:各集合中phone与hand的相对占比是否一致,difficult 样本是否集中在某一侧。如果 train 的 difficult 占比 5%,test 的 difficult 占比 15%,说明划分时没有把难例均匀撒开,最终评估结果会被人为拉低。另外输出中一旦出现“警告: 缺少XML”,就不能叫开箱即用,需要先做数据修复。

4. 玩手机检测数据集转YOLO格式:转换脚本与四个必踩的坑

4.1 VOC到YOLO的坐标转换:归一化公式与完整脚本

VOC 的 bndbox 是绝对值,YOLO 要的是相对图片宽高的中心点坐标和宽高,全部归一化到 0~1。转换公式是:

cx = (xmin + xmax) / 2 / width cy = (ymin + ymax) / 2 / height w = (xmax - xmin) / width h = (ymax - ymin) / height

对应到代码:

import os import xml.etree.ElementTree as ET # 类别表,顺序绝对不能改动,必须与训练配置里的names一一对应 CLASS_NAMES = ["phone", "hand"] def voc_to_yolo(xml_path, out_dir, class_names): """把单个VOC XML转换成YOLO格式的txt""" tree = ET.parse(xml_path) root = tree.getroot() width = int(root.findtext("size/width")) height = int(root.findtext("size/height")) # filename可能带扩展名,统一去掉 stem = os.path.splitext(root.findtext("filename"))[0] lines = [] for obj in root.findall("object"): # difficult=1的样本默认丢弃,理由见4.3 if int(obj.findtext("difficult", "0")) == 1: continue cls_name = obj.findtext("name") if cls_name not in class_names: print(f"跳过未知类别: {cls_name}") continue cls_id = class_names.index(cls_name) box = obj.find("bndbox") xmin = int(float(box.findtext("xmin"))) ymin = int(float(box.findtext("ymin"))) xmax = int(float(box.findtext("xmax"))) ymax = int(float(box.findtext("ymax"))) # 坐标裁切到图像范围内,越界框直接保留会造成训练震荡 xmin = max(0, min(xmin, width - 1)) xmax = max(0, min(xmax, width - 1)) ymin = max(0, min(ymin, height - 1)) ymax = max(0, min(ymax, height - 1)) cx = (xmin + xmax) / 2 / width cy = (ymin + ymax) / 2 / height w = (xmax - xmin) / width h = (ymax - ymin) / height lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}") if lines: out_path = os.path.join(out_dir, stem + ".txt") with open(out_path, "w", encoding="utf-8") as f: f.write("\n".join(lines)) if __name__ == "__main__": # 先创建labels目录,再对Annotations里所有xml执行转换 os.makedirs("labels", exist_ok=True) for xml_name in os.listdir("VOC2007/Annotations"): if xml_name.endswith(".xml"): voc_to_yolo( os.path.join("VOC2007/Annotations", xml_name), "labels", CLASS_NAMES, )

这段脚本里最关键的是CLASS_NAMES。它不是随便写的列表,而是从全部 XML 里收集到的类别名的稳定排序。转换脚本负责把字符串变 id,训练配置里的names负责把 id 变字符串,两侧只要顺序不一致,训练就全面错位。脚本里我做了两件加固:一是把坐标裁切到[0, width-1],避免越界框直接进入训练;二是遇到不在类别表里的名字打印提示而不是静默跳过,这样批量转换时能及时发现标注中混入了预想不到的类别。输出精度用 6 位小数,一万张图的数据集坐标误差可以忽略不计。

4.2 坑一:bndbox 坐标越界,一张坏框带崩整个批次

现象:转换后的 YOLO 标签里出现负数或者大于 1 的坐标值,训练时 loss 曲线剧烈震荡,甚至出现 NaN;用验证集评估时,模型预测的框明显偏离目标位置。

原因:标注工具的框可以拖出图片边缘,尤其是手机这种小目标,标注员在放大画面时经常把框的某一边拖出画布。VOC xml 里残留了超出 width/height 的坐标,转 YOLO 后归一化结果就落在[0,1]区间外,模型在训练的边界样本上被反复干扰。

解决:正是 4.1 脚本里的裁切逻辑。但这只是补救,裁切后要统计一下被裁过的框数量。如果超过总框数的 1%,说明原始标注质量控制不到位,需要回到原图人工复查,而不是靠脚本硬修。

4.3 坑二:difficult 标签处理不当,评估指标虚高或训练收不干净

现象:转换后分类别统计,发现有的类别框数变少了;或者训练时模型对遮挡严重的手机完全无感,漏检率高,但 mAP 又很高。这两个现象看着矛盾,背后是同一个原因:difficult 标签处理策略不一致。

原因:VOC 评测规则里,difficult=1 的目标不参与 AP 计算,因为它本身“难到连人都难以确认”。很多转换脚本偷懒,直接把这些框一起转成训练标签,模型在训练时被迫拟合一群人眼都拿不准的样本,梯度方向反复横跳;另一部分脚本直接丢弃,评估时又拿包含 difficlut 框的测试图去算指标,模型漏检了也没人知道。训练一个标准,评估一个标准,mAP 自然失真。

解决:建立一套明确的策略。我的默认做法是训练集里丢弃 difficult=1 的框,测试集里保留 difficult 标签但评估时单独报告两组指标:干净样本的 mAP 和全量样本的 mAP。如果测试集里 difficult 占比超过 10%,在全量指标上适当放宽置信度阈值,毕竟这类框本身就是界定模糊的。所有标签脚本都要留一个开关,一键决定是丢弃、保留还是单列,不要边改边忘。

4.4 坑三:类别编号对错位,训练十小时才发现白跑

现象:训练正常启动,loss 稳步下降,但验证时发现预测框全画在奇怪的位置,或者两个类别的预测结果互换了。这种情况通常不是模型问题,是标签 id 分配错了。

原因:转换时用class_names.index(cls_name)分配 id,但class_names是手写的。假如 XML 里类别叫phone和hand,手写列表时写成了["hand", "phone"],而训练配置的 yaml 里写的names: ["phone", "hand"],转换出的 label 第一列就全部对调。模型把手机特征学成了 hand 类,学越久错越深。

解决:转换脚本里加一个自检阶段。先遍历所有 XML 收集set(name),打印唯一类别名列表;再对比CLASS_NAMES,两边必须完全一致。转换完成后随机抽 5 个 txt 和对应的 xml 人工核对第一行的类别名与 id 是否对上。这一步 5 分钟不到,能避免一整个训练周期的浪费。另外,类别名的大小写也要注意,Phone和phone是两个完全不同的类,别让一个数据集里同时出现。

4.5 坑四:Windows 与 Linux 路径分隔符导致数据加载失败

现象:在本地 Windows 机器上用os.path.join生成训练列表,传到 Linux 服务器后,训练一开始 Loading 阶段就报错,或者大量图片加载失败。仔细看报错路径,发现全是\反斜杠。

原因:Windows 系统下os.path.join默认生成\。Linux 训练框架只认/,反斜杠被当成非法字符。这个问题在拿到现成数据集时特别容易出现,因为大多数数据集制作人用的都是 Windows 标注工具。

解决:所有列表文件、配置文件里的路径一律用正斜杠/,并且尽量写相对路径而不是绝对路径。生成 train.txt 时不要直接拿os.path.join的结果写文件,而是手动拼接:

with open("train.txt", "w", encoding="utf-8") as f: for prefix in train_prefixes: f.write(f"images/{prefix}.jpg\n") # 写相对路径,不写绝对路径

顺带一提,图片读取失败还有个容易被忽略的源头:文件名里带中文或空格。某些框架的 Dataloader 在 Linux 下对这类文件名的处理并不统一,能改就改成拼音或纯数字前缀。

5. 拿到数据集先做四步验证:画框回看与基线跑通

5.1 画框回看:抽查 10 张图,胜过读 100 行日志

标签统计做得再细,都不如直接把框画回原图肉眼看一遍。我拿到任何 VOC 数据集做完解析后的第一件事,就是随机抽 10~20 张图,把 bndbox 画上去:

import cv2, os, random import xml.etree.ElementTree as ET xml_dir = "VOC2007/Annotations" img_dir = "VOC2007/JPEGImages" out_dir = "check_vis" os.makedirs(out_dir, exist_ok=True) xmls = random.sample([f for f in os.listdir(xml_dir) if f.endswith(".xml")], 10) for xml_name in xmls: tree = ET.parse(os.path.join(xml_dir, xml_name)) root = tree.getroot() img = cv2.imread(os.path.join(img_dir, root.findtext("filename"))) if img is None: print(f"图片读取失败: {root.findtext('filename')}") continue for obj in root.findall("object"): box = obj.find("bndbox") xmin, ymin = int(float(box.findtext("xmin"))), int(float(box.findtext("ymin"))) xmax, ymax = int(float(box.findtext("xmax"))), int(float(box.findtext("ymax"))) cv2.rectangle(img, (xmin, ymin), (xmax, ymax), (0, 255, 0), 2) cv2.putText(img, obj.findtext("name"), (xmin, ymin - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imwrite(os.path.join(out_dir, xml_name.replace(".xml", ".jpg")), img)

画框回看重点看三件事:框是否紧贴目标边缘、类别名是否标错、有没有整张图都是人却一个框都没画的漏标图。手机检测的标注难点在小目标,抽查时特别注意远处课桌、驾驶座中控台附近的手机,如果这些位置大量漏标,模型部署后在这类场景上会稳定漏检。

5.2 空跑一个基线:用 YOLO 命令行验证数据管线是否真的通

确认画框没问题后,我会用官方命令行先空跑一个几十步的短训练,验证从 yaml 到 DataLoader 的整条链路。训练配置里的nc和names必须和转换脚本里的CLASS_NAMES顺序完全一致:

yolo detect train data=playphone.yaml model=yolov8n.pt epochs=30 imgsz=640 batch=16

这次训练不是用来刷指标的,只看三点:训练有没有在 10 分钟内正常启动、loss 第一轮有没有明显下降、验证集的 mAP 是否在合理区间。如果 30 个 epoch 跑完 mAP 还是 0,不用怀疑模型,回头查标签文件和图片配对。我自己的经验是,这类行为检测数据集,yolov8n 跑 30 个 epoch 能把 mAP@0.5 顶到 0.6 以上就算数据干净,后面再谈提点。我每次拿到新数据集都是这套流程走一遍,不跳过画框回看,也不跳过空跑。数据集质量是玄学,但这一步做完,至少能筛掉一大半低级问题。希望帮到你。

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

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

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

立即咨询