
简介面向目标检测入门与实战场景玩手机检测数据集以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 结束都要算一次 losstest.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), encodingutf-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): # difficult1的样本默认丢弃理由见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, encodingutf-8) as f: f.write(\n.join(lines)) if __name__ __main__: # 先创建labels目录再对Annotations里所有xml执行转换 os.makedirs(labels, exist_okTrue) 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 评测规则里difficult1 的目标不参与 AP 计算因为它本身“难到连人都难以确认”。很多转换脚本偷懒直接把这些框一起转成训练标签模型在训练时被迫拟合一群人眼都拿不准的样本梯度方向反复横跳另一部分脚本直接丢弃评估时又拿包含 difficlut 框的测试图去算指标模型漏检了也没人知道。训练一个标准评估一个标准mAP 自然失真。解决建立一套明确的策略。我的默认做法是训练集里丢弃 difficult1 的框测试集里保留 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, encodingutf-8) as f: for prefix in train_prefixes: f.write(fimages/{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_okTrue) 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 dataplayphone.yaml modelyolov8n.pt epochs30 imgsz640 batch16这次训练不是用来刷指标的只看三点训练有没有在 10 分钟内正常启动、loss 第一轮有没有明显下降、验证集的 mAP 是否在合理区间。如果 30 个 epoch 跑完 mAP 还是 0不用怀疑模型回头查标签文件和图片配对。我自己的经验是这类行为检测数据集yolov8n 跑 30 个 epoch 能把 mAP0.5 顶到 0.6 以上就算数据干净后面再谈提点。我每次拿到新数据集都是这套流程走一遍不跳过画框回看也不跳过空跑。数据集质量是玄学但这一步做完至少能筛掉一大半低级问题。希望帮到你。本文还有配套的精品资源点击获取