简介这是一份用于目标检测项目的数据集覆盖打电话与抽烟两类行为共2037张真实场景图片。图片来自日常驾驶/监控视角适合训练YOLO系列检测模型也可用于安全驾驶、作业监管、公共场所违规行为识别等实际业务。每张图片均配套VOC格式的xml标注和YOLO格式的txt标签前者保留目标框坐标信息后者提供归一化的检测目标格式训练时可直接按目录读取无需额外转换标签与目标一一对应标注质量较可靠且未经数据增强下载后可按需加入旋转、饱和度调整、曝光变化、随机裁剪等操作扩充样本提高模型鲁棒性。资源包压缩后约291MB内含2037个jpg图像、2037个xml标注文件和2037个txt标签文件文件组织规整便于搭配不同训练框架使用。目前已有5680人学习/浏览关注度较高适合需要现成标注图像快速开展实验的目标检测开发者和研究者也可作为YOLO入门实战的练习数据。1. 为什么“打电话/抽烟”检测要从自建数据集开始2037张图的实际分量做加油站、工地或驾驶舱这类安全监控的项目时“打电话/抽烟检测”几乎都是需求单上的点名项。直接拿公开数据集或现成权重部署到真实摄像头上效果却经常让人没法给客户交代挥手被识别成打电话打火机瞬间当成抽烟到了夜间和逆光场景几乎没几个能用的框。“打电话/抽烟”这类行为是典型的“小目标 姿态依赖”和常规大目标检测完全是两种玩法。最终能让报警指标落地的路子就是标题里这条——自己收集、自己标注2037张VOC和YOLO两种格式从始至终都维护。这篇文章从采集、标注、格式转换写到训练排错与迭代验证给同样在做危险行为报警项目的从业者一条能照着复现的路径。2. 从0到2037张的采集流程行为定义先行再去抽帧2.1 把“打电话”和“抽烟”的边界定清楚比定数量更重要很多人第一步就栽在“标签是什么”上。业务里的“打电话”不是一个物体概念而是一个行为概念。加油站项目只关心手机在油枪附近时是否有人持握通话驾驶行为检测关心的是手拿电话靠在耳边或者低头长时间看手机工地安全往往只认“持电话且贴近耳朵超过两秒”。如果照搬图像分类的思路把“phone”当成检测目标模型学到一堆“手里握着手机却没在打”的正样本漏检率自然压不下来。我的做法是用“人物行为框 行为标签”而不是单独框手机或单独框烟头。常见的实现里类别名定义为phone_call和smoking标注时框出整个人或人物上半身因为这个粒度下模型能同时学到“姿态 小物体”的组合特征。只框手机的话检测器无法理解“拿着手机的人正在通话”这个完整动作只框烟头的话电子烟和细支烟在远距离下几乎没有像素差。标签规则必须提前写成一页纸发给标注的人例如打电话 手机贴耳且在通话状态抽烟 手指夹烟在嘴部区域或有明显烟雾喷出。像只掏手机看一眼屏幕、电子烟离嘴十几厘米这种模糊场景单独归到负样本里录进背景帧不强行打正标签。2.2 从视频帧里抽图采样节奏与场景维度覆盖2037张不是一次性从某个视频里截出来的而是从多个点位按时间跨度抽帧再筛选。常见的做法是用摄像头的历史录像片段按点位、时段、天气、室内外这几个维度分别抽。抽帧间隔不建议固定用1秒那样连续画面里会出现大量差异极小的重复帧等于在浪费标注产能。我一般设置每5秒取一帧人员走动快的点位缩短到2秒动作节奏慢的场景可以放到8秒。import cv2 SRC camera_clip_01.mp4 # 某个点位的原始录像 DST raw_frames # 抽帧输出目录 interval_sec 5 # 每隔5秒取一帧 cap cv2.VideoCapture(SRC) fps cap.get(cv2.CAP_PROP_FPS) frame_idx 0 save_idx 0 while True: ret, frame cap.read() if not ret: break if frame_idx % int(fps * interval_sec) 0: cv2.imwrite(f{DST}/{save_idx:05d}.jpg, frame) save_idx 1 frame_idx 1 cap.release() print(fsaved {save_idx} frames)这个脚本的逻辑很直白逐帧读取录像用frame_idx对“fps乘以间隔秒数”取模命中时写出当前帧。save_idx只用来生成连续文件名防止覆盖。实际使用中有两个容易被忽略的点一是录像分辨率如果原始录像本身是720p后续标注和训练都按720p做不需要强行放大放大反而引入插值噪声二是抽帧前要先快进扫一遍把摄像头被遮挡、断电黑屏之类的无效段跳过去这些段抽出来的帧连人工初审都过不了。2.3 模糊帧与重复帧清理两条硬性规则抽帧出来的数量通常会远超2037张可能在5000到8000张甚至更多。这时候不靠人工一张张看而是用两条规则做初筛第一清晰度过滤用Laplacian算子求灰度图的方差方差低于经验阈值比如40的帧直接丢弃第二重复度过滤相邻帧之间做像素差差异占比小于10%的只保留前一帧。这两条能把大量因摄像头抖动、焦距漂移产生的废帧滤掉再进入人工挑选。初筛你可以直接写在抽帧脚本后面也可以抽完帧后单独跑一遍批量处理脚本。核心思路不是把阈值调得多精确而是先保住“行为可见”这条底线——哪怕画面有些噪点只要人能看清手和嘴的位置这帧就可以用。真正要人工把关的是场景重复度同一个点位下如果已经挑了30帧不同时间段的画面就不建议继续从这个点位的同一角度再补帧因为模型很容易被单一视角带偏。3. 用VOC做中间标注格式为什么YOLO txt不能当“唯一存档”3.1 存档阶段采用VOC的原因YOLO txt丢掉了太多可用信息很多初学者拿到要训练的数据上手就用labellmg直接导出YOLO txt标注完才发现改起来非常痛苦。YOLO txt每行只有class x_center y_center width height五个数值全部是归一化后的浮点数人眼根本看不出坐标对不对想修正一个框得自己在文件里换算半天。更关键的是txt格式没法记录“这张图里哪些目标是被遮挡的、哪些是难例、哪些是训练时要忽略的”而这些信息在生产项目中经常要用来做数据清洗。VOC格式也就是Pascal VOC的XML天然保存了文件夹、文件名、图像宽高、每个目标的name、bndbox坐标还能带truncated、difficult这类扩展属性。2037张图的量级下用labelImg直接标注并导出XML是最靠谱的存档方式。等模型进入迭代期你要回头统计“被遮挡目标占比多少”“哪些类别标注置信度低”VOC都能支持而YOLO txt做不到。如果团队多人协作可以上CVAT标注平台由平台导出VOC再统一转成YOLO训练格式单人或两三人规模本地labelImg足够。3.2 labelImg标注规则与类别设计标注时要先把类名固定下来并同步写进VOC XML。我在这个项目里用的是phone_call和smoking两个类因为后续yaml配置、类别映射脚本都依赖同名关联。labelImg里默认保存的XML包含folder、filename、source等字段这些字段不用手工维护工具会自动填。重点盯三件事第一正样本必须框人物主体不要只框手机或只框烟。框太小时模型下采样后特征被压缩得几乎不存在训练很难收敛。第二一张图里如果有多个行为人都要标不要因为“麻烦”只标中间那个大的漏标的小目标会让模型产生“场景里的小人不算违规”的错误先验。第三把难以判断的框标记为difficult1比如背影里只能看到一个轮廓的手机通话行为标出来但在后续训练里可以过滤避免强行让模型学习模糊特征。3.3 标注完的XML一致性检查标注过程拖两三天后人的状态会波动经常出现漏标、类名拼错、框的坐标超出图像尺寸这类问题。我的习惯是写完一批立刻跑一次XML检查脚本不要等全部标完再查沉没成本会很大。import glob import xml.etree.ElementTree as ET ALLOWED {phone_call, smoking} violations [] for xml_file in glob.glob(Annotations/*.xml): root ET.parse(xml_file).getroot() objs root.findall(object) if not objs: violations.append(f{xml_file}: 空标注文件) continue for obj in objs: name obj.find(name).text if name not in ALLOWED: violations.append(f{xml_file}: 未知类别 {name}) bnd obj.find(bndbox) xmin float(bnd.find(xmin).text) ymin float(bnd.find(ymin).text) xmax float(bnd.find(xmax).text) ymax float(bnd.find(ymax).text) if xmax xmin or ymax ymin: violations.append(f{xml_file}: 退化解框 {xmin},{ymin},{xmax},{ymax}) if xmin 0 or ymin 0: violations.append(f{xml_file}: 负坐标越界) if violations: for v in violations: print(v) else: print(all checked)这段脚本用xml.etree.ElementTree解析每个XML遍历所有object节点检查类名是否在ALLOWED集合里、边框是否退化、坐标是否为负。xmax xmin这类退化框在后续转YOLO时会出现除零或负宽度直接导致训练器警告“labels not found”。检查脚本只负责暴露问题不负责修复目的是把错误在进训练流程前暴露出来。4. VOC转YOLO格式转换脚本、路径配置与4个地雷4.1 从XML到txt的转换脚本VOC XML转YOLO txt的核心逻辑就四步解析XML、读图像宽高、把bndbox的绝对坐标归一化、按类名映射成类别序号写文件。网上这类脚本很多但多数没有处理越界和退化框训练时报错的概率非常高。import xml.etree.ElementTree as ET import os import glob from pathlib import Path class_map {phone_call: 0, smoking: 1} def voc_to_yolo(xml_path, save_dir): tree ET.parse(xml_path) root tree.getroot() img_w int(root.find(size/width).text) img_h int(root.find(size/height).text) lines [] for obj in root.findall(object): name obj.find(name).text if name not in class_map: print(f[skip] {xml_path} 包含未定义类别 {name}) continue cls_id class_map[name] bnd obj.find(bndbox) x1 float(bnd.find(xmin).text) y1 float(bnd.find(ymin).text) x2 float(bnd.find(xmax).text) y2 float(bnd.find(ymax).text) # 越界修正把伸到图外的坐标裁回边界否则该条目标会被训练器丢弃 x1 max(0, min(x1, img_w)) x2 max(0, min(x2, img_w)) y1 max(0, min(y1, img_h)) y2 max(0, min(y2, img_h)) if x2 x1 or y2 y1: print(f[skip] {xml_path} 边框退化为零) continue x_center (x1 x2) / 2 / img_w y_center (y1 y2) / 2 / img_h width (x2 - x1) / img_w height (y2 - y1) / img_h lines.append(f{cls_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}) os.makedirs(save_dir, exist_okTrue) out_path os.path.join(save_dir, Path(xml_path).stem .txt) with open(out_path, w) as f: f.write(\n.join(lines)) if __name__ __main__: for xml_path in glob.glob(Annotations/*.xml): voc_to_yolo(xml_path, labels) print(done)这个脚本值得说三个点。一是class_map的序号必须和训练用的yaml文件一致后面配置里写错一个数字模型就会把两个类别学混。二是越界修正做了min/max钳制遇到标注时手滑把框拖出图外的情况不会直接生成负数坐标。三是退化框直接跳过并打印路径方便回溯到对应XML去修。转换完不要急着训练先在labels目录下随意抽三个txt对照XML里的坐标手算一遍归一化值确认格式无误再进下一步。4.2 组装数据目录与yaml配置YOLO训练需要的目录结构有固定套路我按Ultralytics的习惯组织dataset/ ├── images/ │ ├── train/ │ │ ├── 00001.jpg │ │ └── ... │ └── val/ │ ├── 01801.jpg │ └── ... ├── labels/ │ ├── train/ │ │ ├── 00001.txt │ │ └── ... │ └── val/ │ └── ... └── phone_smoke.yaml注意images和labels下的train、val目录名必须完全对应。少了val的labels目录会让训练器在验证阶段报“zero labels”。划分数据集时我会写一个小脚本按文件名随机分85%给train、15%给val固定随机种子以保证每次复现同样的划分结果。因为“打电话/抽烟”这种小目标行为对验证集很敏感同一个人在不同帧里可能被分成训练和验证直接影响mAP的客观性。phone_smoke.yaml的内容如下path: /data/project/phone_smoke train: images/train val: images/val nc: 2 names: 0: phone_call 1: smokingpath用绝对路径能省掉很多“数据集没被找到”的排查时间。如果一个数据集要放多个项目共用也可以用相对路径指向当前yaml所在目录但不同版本的Ultralytics对相对路径解析行为不一致建议始终写成绝对路径。这个具体场景里nc就是2如果有第三类要加比如“玩手机”那xml、class_map、names三处必须同步改。4.3 复制与迁移数据集时的4个典型坑第一个坑图像和标签文件名的命名字符不一样。从Windows复制到Linux服务器时中文文件名容易出现编码不一致而YOLO训练依赖文件名前缀配对最终表现为图像能找到但txt缺失样本数悄悄少了几百张。解决所有图像和标签文件名只用英文和数字不要出现汉字和空格。第二个坑txt文件是空的。标注时如果某个XML里没写任何目标转换脚本会生成一个空txt。Ultralytics能容忍空txt但有些训练器版本会报警告并跳过整张图。解决训练前统计一下labels目录下所有txt的行数总和如果远小于预期框数就把空txt全删掉同时把对应的jpg从images目录移走保持两边完全镜像。第三个坑路径里带上无关文件。比如images目录下混入了Thumbs.db、desktop.ini之类系统文件训练器扫描图片时会报非图像文件格式错误。解决进训练前跑一遍find images -type f ! -name *.jpg清理。第四个坑yaml里的names顺序和转换脚本的class_map顺序不一致。别笑这类错在真实项目里出现频率相当高因为很多人改类别时只改了一处。解决固定一个“类别定义文件”转换脚本和yaml都从这里读而不是手工维护两份。5. 训练与验证中的排查loss、漏检、报错的项目化排错5.1 现象loss降下来了mAP却几乎为零这是最让人头疼的“假收敛”。曲线很漂亮验证时每个类别AP都不到0.1。原因基本可以锁定在类别映射错乱上xml里phone_call映射成序号1yaml里names却把phone_call放在smoking后面训练器读到同一个序号时对应的是另一个类别。模型被迫用互相矛盾的特征学两个标签最终一个都没学对。解决训练前做一次三重核对。先统计VOC里的类名有哪些再统计生成的txt里每个类序号的分布最后和yaml的names逐一比对。这三个输出完全一致才能开始训练。from collections import Counter import glob import xml.etree.ElementTree as ET xml_names Counter() for xml_path in glob.glob(Annotations/*.xml): root ET.parse(xml_path).getroot() for obj in root.findall(object): xml_names[obj.find(name).text] 1 txt_classes Counter() for txt_path in glob.glob(labels/train/*.txt): with open(txt_path) as f: for line in f: txt_classes[line.split()[0]] 1 print(VOC类名统计:, dict(xml_names)) print(TXT序号统计:, dict(txt_classes))5.2 现象训练启动时提示找不到标签或图像数量为0典型报错是WARNING: no labels found in images/val但打开目录一看jpg和txt都在。出现这个现象的第一反应不是去改代码而是检查目录层级是否和yaml里写的路径一致。常见的原因是images/labels两棵目录树只复制了其中一棵或者val集下只有images没有labels。解决用一条bash命令快速比对两边文件名差异缺失的补进去或者从数据集里挪掉。for img in images/train/*.jpg; do base$(basename $img .jpg) if [ ! -f labels/train/$base.txt ]; then echo 缺失标签: $img fi done5.3 现象白天检测正常夜间漏检手机目标“打电话/抽烟”检测在夜间表现普遍差于白天原因是手机屏幕在暗光下对比度不高烟头亮但是面积太小模型下采样后特征几乎消失。这个现象不是单纯加训练数据就能解决的先检查训练集里夜间帧占比。如果低于15%模型根本没机会学夜间特征。解决从部署现场录像里补采夜间帧把夜间占比拉到25%以上训练时把imgsz从640提高到1280小目标在1280输入下的特征保留能力显著强于640。注意这会让显存占用翻倍显存不够时先降batch别把imgsz降回去优先保证小目标分辨率。5.4 现象部署环境跑起来帧率不够算力卡在推理上训练时的参数设置和部署推理是两个维度。如果你的部署机器是T4这类常见推理卡以1080p 25帧每路视频流做输入YOLO 640分辨率推理密度是够用的真正的瓶颈往往在前后处理。比如每次推理都对原始帧做缩放、NMS后处理里带了大批量循环都会吃掉大量延迟。解决先用纯CPU时间统计把预处理、推理、后处理三段耗时单独打点看瓶颈在哪一段。推理阶段导出FP16的TensorRT引擎能省掉大量显存和计算开销导出命令通常是yolo export modelbest.pt formatengine device0 imgsz640 halfTrue参数说明halfTrue表示半精度推理T4上有专门的Tensor Core加速imgsz必须与训练分辨率一致不一致时引擎会自动resize精度会受影响。导出后在代码里固定输入尺寸用letterbox方式填充不要直接拉伸图像。5.5 现象加了新数据后老场景变差回归事故很多工程师都会遇到“加了一百张夜间数据白天检测反而差点意思”的怪象。原因不是模型过拟合而是新旧数据分布冲突。夜间帧亮度和对比度与白天差异极大训练器在有限iteration里被反向传播强行拉偏相当于后面几十个epoch全在适应夜间白天特征被冲淡。解决训练时不给所有数据同一个权重把新增的夜间数据单独放一个目录训练时用两阶段策略——先统一混跑前80%的epoch后20%的epoch单独降低夜间样本的采样概率。这个操作在Ultralytics里就是修改数据集的train字段指向不同的目录或者在迭代后期把夜间样本复制一份但降低其参与度。这种做法在真实项目里比单纯堆数据更值得重视。6. 数据回流与模型迭代固定验证集才是“后悔药”训练完不要只看验证集指标就交差。我的习惯是取一段部署现场的真实录像不标注直接用模型跑完整段视频把检测结果按每10秒抽1帧保存成带框画面再用视频播放器连续翻看。这个动作能暴露验证集根本覆盖不到的问题同一视角下人物长时间静止导致检测框抖动、灯光明暗变化触发误检、新工装外套让模型把袖口误判成烟头。每轮跑完把这些典型片段整理出来按第二章的抽帧规则挑出150到200张硬样本补进数据集重新走标注和转换流程比盲目堆更多公开数据有效得多。验证环节用上表格式的检查清单会更可控。检查项操作通过标准标签完整统计txt与xml的数据分布两类目标的框数比例差不超过30%空白标签统计目录文件配对每张jpg存在同名txt且行数≥1视频盲测对未标注视频跑推理存帧单条视频持续漏检不超过3秒部署阶段导出TensorRT引擎时的halfTrue选项几乎是必开项实测能让显存占用下降约四成。但要注意的是TensorRT对输入分辨率是强绑定的训练用640就导出640不要在部署图中临时改成960去“提高精度”格式不匹配带来的重采样损失会抵消掉收益。稳的做法是训练时就用高清模式比如1280输入训练部署同样用1280推理代价是单路帧率下降适合对漏检要求极高的点位。我在这个项目里最深刻的体会是迭代效率不取决于你标注了多少张图而取决于你有没有一套固定验证基准。每次训练完同一段视频、同一个阈值、同一帧抽样位置跑出来的检测数量和置信度分布必须记录归档。没有这套基准任何新数据都可能把模型改得时好时坏你还说不出是哪次改动惹的祸。把验证基准当成数据集的“后悔药”每次动数据先跑一轮基准测量结果稳定了才允许进入下一步这个习惯救了我很多次。希望帮到你。本文还有配套的精品资源点击获取