ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

VOC吸烟检测数据集实战:从格式转换到YOLO训练调优

VOC吸烟检测数据集实战:从格式转换到YOLO训练调优 简介这份资源是面向计算机视觉开发者与目标检测学习者的吸烟行为检测数据集聚焦室内外多场景下的人员吸烟识别任务可用于训练与验证吸烟检测模型适合具备一定深度学习基础、正在做行为识别或安防监控相关项目的读者。压缩包共收录2000个文件其中1507个xml标注文件、486张jpg图像与7张png图像整体约861.69MBxml采用VOC格式可直接对接YOLO、Faster R-CNN等主流检测框架的数据读取流程。目前已有240人学习下载说明该数据集在吸烟检测方向具备一定参考价值。数据覆盖室内外多种真实场景标注信息完整读者可据此完成模型训练、精度评估与场景泛化测试也可作为自建数据集的格式参考减少标注与整理成本。1. 1000 张 VOC 吸烟检测数据集从拿到手到跑通第一条训练曲线手上拿到一份标注好的吸烟检测数据集1000 多张图室内室外场景都有标签是 VOC 格式的 XML。这个体量说大不大说小也不小——刚好够你把一条目标检测的训练链路完整跑通又不至于让你在数据清洗上耗掉一整周。吸烟检测这个任务本身有个特点烟头目标极小手部遮挡频繁室内外光照差异极大所以它不是一个「随便拿个预训练模型 fine-tune 一下就能出活」的场景。VOC 格式意味着每张图对应一个 XML里面记录了边界框的坐标和类别名你需要把它转成训练框架能吃的格式再处理类别不平衡、小目标增强、场景划分这几件事。这篇文章面向的是手里已经有这批数据、想把它真正用起来的工程师不管你是要做一个园区吸烟行为预警还是单纯想验证某个检测头在小目标上的表现下面的路径都能直接照着走。2. VOC 格式拆解与转 YOLO 训练格式的完整脚本2.1 先搞清楚 VOC XML 里到底存了什么VOC 格式的标注文件是每张图一个 XML根节点是annotation下面挂着filename、size、object这几块。size里有width、height、depthobject里才是真正的标注信息name是类别名bndbox里有xmin、ymin、xmax、ymax四个坐标。这里有个容易翻车的点VOC 的坐标是 1-based 的而大多数训练框架读图之后用的是 0-based 索引转换的时候如果不减 1框会整体偏移一个像素。单看一个像素好像无所谓但烟头这种小目标本身可能就十几个像素宽偏一个像素在 IoU 上就是实打实的损失。另一个坑是difficult标签。VOC 原始定义里difficult1表示这个目标难以识别评估时通常忽略。但很多自建数据集在导出时根本没写这个字段或者全填了 0。你拿到数据后第一件事应该是统计一下这个字段的分布如果全是 0 或者字段缺失那就不用管如果有一定比例的 1训练时可以选择保留但评估时过滤或者直接当负样本处理。还有pose和truncated这两个字段在吸烟检测里其实有实际意义。truncated1表示目标被截断比如手伸到画面外只露了半截烟。这种样本如果直接参与训练模型会学到不完整的特征。我的做法是统计 truncated 的比例如果超过 15%就在数据增强阶段针对性地加一些随机裁剪让模型适应这种不完整目标。2.2 转换脚本从 XML 到 YOLO txt 的每一步下面这个脚本把 VOC XML 转成 YOLO 需要的归一化 txt 格式每行是class_id x_center y_center width height全部归一化到 0 到 1 之间。import os import xml.etree.ElementTree as ET from pathlib import Path # 类别映射根据你的实际标签修改 CLASS_MAP { smoking: 0, # 吸烟行为 cigarette: 1, # 烟头本身 hand: 2, # 手部如果标注了 } def voc_to_yolo(xml_path, img_w, img_h, output_dir): 将单个 VOC XML 转为 YOLO txt xml_path: XML 文件路径 img_w, img_h: 对应图片的宽高从 XML 的 size 节点读取 output_dir: txt 输出目录 tree ET.parse(xml_path) root tree.getroot() # 从 XML 里读图片尺寸比重新读图快 size root.find(size) w int(size.find(width).text) h int(size.find(height).text) lines [] for obj in root.findall(object): name obj.find(name).text.strip() if name not in CLASS_MAP: continue # 跳过未定义类别 # 跳过 difficult 目标按需开启 difficult obj.find(difficult) if difficult is not None and int(difficult.text) 1: continue bbox obj.find(bndbox) xmin float(bbox.find(xmin).text) ymin float(bbox.find(ymin).text) xmax float(bbox.find(xmax).text) ymax float(bbox.find(ymax).text) # VOC 是 1-based转 0-based xmin - 1 ymin - 1 xmax - 1 ymax - 1 # 边界裁剪防止标注越界 xmin max(0, xmin) ymin max(0, ymin) xmax min(w - 1, xmax) ymax min(h - 1, ymax) # 过滤无效框 if xmax xmin or ymax ymin: continue # 归一化中心点和宽高 x_center (xmin xmax) / 2.0 / w y_center (ymin ymax) / 2.0 / h bw (xmax - xmin) / w bh (ymax - ymin) / h # 裁剪到 [0,1] x_center min(max(x_center, 0), 1) y_center min(max(y_center, 0), 1) bw min(max(bw, 0), 1) bh min(max(bh, 0), 1) lines.append(f{CLASS_MAP[name]} {x_center:.6f} {y_center:.6f} {bw:.6f} {bh:.6f}) # 写文件即使没有目标也写空文件保持图片和标签一一对应 stem Path(xml_path).stem out_path Path(output_dir) / f{stem}.txt with open(out_path, w) as f: f.write(\n.join(lines)) return len(lines) def batch_convert(xml_dir, output_dir): 批量转换整个目录 os.makedirs(output_dir, exist_okTrue) xml_files list(Path(xml_dir).glob(*.xml)) total_boxes 0 empty_count 0 for xml_file in xml_files: n voc_to_yolo(str(xml_file), 0, 0, output_dir) total_boxes n if n 0: empty_count 1 print(f转换完成: {len(xml_files)} 个文件, {total_boxes} 个框, {empty_count} 个空标签) if __name__ __main__: batch_convert(./annotations, ./labels)这段代码有几个设计决策值得说清楚。第一图片宽高直接从 XML 的size节点读不重新打开图片1000 张图能省不少 IO 时间。第二difficult1的目标默认跳过如果你希望保留把那个continue去掉就行。第三空标签文件也会写出来这是故意的——YOLO 训练时如果某张图没有对应 txt有些实现会直接报错写空文件最省事。第四坐标裁剪做了两层先裁到图片边界内再归一化后裁到 [0,1]防止个别标注越界导致训练时 loss 爆炸。转换完之后你需要生成训练集和验证集的划分文件。吸烟检测数据集里室内外场景混杂划分时不能纯随机否则可能出现训练集全是室内、验证集全是室外的尴尬情况。我的做法是按文件名前缀或者目录结构先分场景再在每个场景内部按 8:2 随机划分。import random from pathlib import Path def split_dataset(img_dir, output_txt, train_ratio0.8, seed42): 按场景分组后再划分避免场景偏差 假设文件名格式为 indoor_xxx.jpg 或 outdoor_xxx.jpg random.seed(seed) indoor [] outdoor [] for img in Path(img_dir).glob(*.jpg): if img.stem.startswith(indoor): indoor.append(img.stem) elif img.stem.startswith(outdoor): outdoor.append(img.stem) else: # 没有场景前缀的归入混合池 outdoor.append(img.stem) train_list [] val_list [] for group in [indoor, outdoor]: random.shuffle(group) split_idx int(len(group) * train_ratio) train_list.extend(group[:split_idx]) val_list.extend(group[split_idx:]) with open(output_txt, w) as f: for name in train_list: f.write(f./images/train/{name}.jpg\n) for name in val_list: f.write(f./images/val/{name}.jpg\n) print(f训练集: {len(train_list)}, 验证集: {len(val_list)}) split_dataset(./images, ./train_val_split.txt)划分逻辑的核心是「先分场景再随机」这样室内外样本在训练集和验证集里的比例基本一致。如果你的文件名没有场景标识那就得靠人工抽检或者用图像亮度、色温做粗聚类来分组这一步偷懒的话验证集指标会好看得离谱但实际部署就翻车。2.3 数据配置文件怎么写才不出错YOLO 系列训练需要一个 YAML 配置文件指定数据路径、类别数和类别名。1000 张图的数据集路径写错一个字符就是几小时的无效等待。# smoking_dataset.yaml path: /data/smoking_detection # 数据集根目录 train: images/train # 训练集图片相对路径 val: images/val # 验证集图片相对路径 nc: 3 # 类别数和 CLASS_MAP 保持一致 names: 0: smoking 1: cigarette 2: hand这里有个血泪经验path用绝对路径train和val用相对路径这是最不容易出错的组合。如果你把path写成相对路径训练脚本的工作目录一变整个数据加载就全乱了。另外nc必须和转换脚本里的CLASS_MAP长度一致多一个少一个都会在训练启动时报维度不匹配。3. 小目标吸烟检测的训练参数怎么调3.1 输入分辨率与锚框尺寸的匹配烟头在 1080p 图片里可能只占 20 到 40 个像素如果你把输入分辨率压到 416烟头就只剩 8 到 15 个像素特征图上的响应几乎消失。我的建议是输入分辨率至少 640有条件就上 960 或 1280。但分辨率上去之后显存占用是平方级增长1000 张图在 640 分辨率下batch size 设 16 大概需要 8G 显存960 分辨率就只能设 8 或者 4。锚框尺寸也要跟着调。默认的 YOLO 锚框是针对 COCO 那种中大目标设计的最小的锚框可能也有 10x13 像素。对于烟头这种目标你需要重新聚类锚框。用 k-means 对训练集里所有标注框的宽高做聚类聚出 9 组然后替换配置文件里的 anchors。import numpy as np from pathlib import Path def cluster_anchors(label_dir, n_clusters9, img_size640): 对 YOLO 格式标签做 k-means 聚类得到适配的锚框 boxes [] for txt in Path(label_dir).glob(*.txt): with open(txt) as f: for line in f: parts line.strip().split() if len(parts) 5: _, _, _, bw, bh map(float, parts) boxes.append([bw * img_size, bh * img_size]) boxes np.array(boxes) if len(boxes) n_clusters: print(样本太少直接用默认锚框) return # 简单 k-means np.random.seed(42) centers boxes[np.random.choice(len(boxes), n_clusters, replaceFalse)] for _ in range(100): distances np.sqrt(((boxes[:, None] - centers[None, :]) ** 2).sum(axis2)) labels distances.argmin(axis1) new_centers np.array([boxes[labels i].mean(axis0) for i in range(n_clusters)]) if np.allclose(centers, new_centers): break centers new_centers # 按面积排序输出 areas centers[:, 0] * centers[:, 1] sorted_idx areas.argsort() for i in sorted_idx: print(fanchor: {centers[i][0]:.1f}, {centers[i][1]:.1f}) cluster_anchors(./labels)聚类出来的锚框如果最小那组还在 10 像素以上说明你的数据里烟头目标确实偏大或者标注时把整只手都框进去了。这时候要回去检查标注质量别急着调模型。3.2 数据增强里哪些该开哪些该关吸烟检测的数据增强有几个特殊考量。Mosaic 增强能显著提升小目标检测效果因为它把四张图拼成一张变相增加了小目标的出现频率。但 Mosaic 会带来一个副作用拼接边缘可能出现不自然的截断目标如果 truncated 比例本来就高再叠加 Mosaic 会让模型学到太多残缺特征。我的做法是 Mosaic 开启但概率设 0.5不要默认的 1.0。HSV 增强里色调偏移要谨慎。烟头在不同光照下颜色变化很大但如果你把色调偏移范围设得太宽可能把烟头的橙红色调成绿色模型反而学不到稳定的颜色特征。建议 H 偏移控制在 0.015 以内S 和 V 可以放宽到 0.7 和 0.4。翻转增强里上下翻转要关掉。吸烟行为有明确的上下方向性手在上、烟在下上下翻转会制造出物理上不合理的样本。左右翻转可以开但要注意如果数据里有文字标识或者不对称的场景特征翻转后可能引入噪声。随机缩放的范围建议设 0.5 到 1.5不要用默认的 0.5 到 1.5 再叠加 Mosaic那样小目标会被缩得更小。可以在 Mosaic 之后单独做一次缩放增强范围控制在 0.8 到 1.2。3.3 损失函数与正负样本分配YOLOv8 默认用的是 TaskAlignedAssigner对小目标还算友好。但如果你发现训练几个 epoch 后召回率一直上不去可以检查一下正样本分配的数量。在 640 分辨率下一个 20 像素的烟头在 P3 特征图80x80上对应大约 2.5 个格子如果正样本阈值设得太高可能只有一两个格子被分配为正样本监督信号太弱。一个实用的调整是把分类损失的权重适当提高。吸烟检测里负样本背景远多于正样本默认的 BCE 损失会被背景主导。可以在损失配置里把cls的权重从 0.5 提到 0.8 到 1.0让模型更关注正样本的分类。另外如果数据里「吸烟」和「烟头」是两个独立类别要注意它们之间的包含关系。一根烟可能同时被标为 smoking 和 cigarette这时候 NMS 之后可能出现重叠框。我的处理方式是把这两个类别的 NMS IoU 阈值单独调低到 0.3让重叠框更容易被抑制掉。4. 室内外场景混训的避坑与排查清单4.1 验证集指标虚高但实际漏检严重现象训练完看验证集 mAP 有 0.75但拿几张实际场景图测试烟头漏检一大半。原因验证集和训练集来自同一批数据场景分布高度一致模型只是记住了这批数据的纹理特征没有学到泛化的吸烟行为模式。1000 张图如果室内外比例是 7:3而验证集随机划分后也是 7:3那验证集根本测不出场景偏移问题。解决手动构造一个「跨场景验证集」——从室内数据训练拿室外数据验证反过来再测一次。如果两个方向的 mAP 差距超过 0.15说明模型对场景过拟合了。这时候要么补充另一场景的数据要么在训练时对场景做更强的增强比如随机调整白平衡、加高斯噪声模拟不同光照。4.2 烟头小目标在训练中期突然全部消失现象前 20 个 epoch 小目标还有检测框到 30 个 epoch 之后小目标召回率断崖式下跌几乎归零。原因小目标的梯度在反向传播中被大目标主导了。YOLO 的多尺度检测头里P3 负责小目标但如果 P4、P5 的损失值远大于 P3优化器会优先降低大目标的损失P3 分支的参数更新被压制。解决检查损失日志里各尺度的损失值。如果 P3 的损失比 P5 低一个数量级说明小目标监督信号太弱。可以在损失函数里给 P3 分支加一个权重系数或者把输入分辨率再提高一档。另一个办法是冻结 P4、P5 分支训练几个 epoch让 P3 先学好小目标特征再解冻联合训练。4.3 室内场景误检率极高把反光点当成烟头现象室外场景检测正常室内场景到处是误检框尤其是灯光反射、金属边缘这些高亮区域。原因室内场景的标注数据里烟头往往出现在较暗的背景上模型学到了「亮斑烟头」的虚假关联。到了实际室内场景灯光反射也是亮斑模型就分不清了。解决在数据增强里加入随机亮度调整和对比度抖动让烟头在亮背景和暗背景下都出现。另外检查标注数据里是否有漏标的烟头——如果室内场景的标注不完整模型会把未标注的烟头当成负样本进一步加剧误检。可以用一个已经在 COCO 上预训练好的模型跑一遍室内数据把高置信度的检测框和标注做对比找出漏标样本。4.4 转换后的标签文件数量对不上图片数量现象转换脚本跑完labels 目录里的 txt 文件比 images 目录里的 jpg 少了几十个。原因XML 文件名和图片文件名不一致。有些数据集在整理时改了图片名但没改 XML 名或者 XML 里filename字段和实际文件名不同。转换脚本按 XML 文件名生成 txt自然就对不上了。解决转换前先做一次文件名对齐检查。遍历 images 目录对每个图片名去 annotations 目录找同名 XML找不到的就记录下来。反过来也遍历 XML找对应的图片。两边都列出来人工确认是删是补。这个检查脚本很简单但能省掉训练时「找不到标签」的报错排查时间。4.5 训练 loss 正常下降但 mAP 始终在 0.1 以下现象训练日志里 box_loss、cls_loss 都在降但验证集 mAP 就是上不去一直在 0.05 到 0.1 之间徘徊。原因最常见的是类别映射错了。转换脚本里CLASS_MAP把 smoking 映射成 0但 YAML 配置文件里 names 的 0 写的是别的类别。模型学的是「类别 0」评估时按 names 里的 0 去匹配名字对不上但索引对得上指标计算就全乱了。解决转换完标签后统计一下每个类别 id 出现的次数和 YAML 里的 names 逐一对一遍。另外检查一下验证集的标签是不是也转换了——有些人只转了训练集验证集还是 XML评估脚本读不到标签就直接跳过mAP 自然上不去。5. 用 1000 张图把吸烟检测推到可用的几个进阶技巧数据量固定在 1000 张左右的时候想再往上提点靠的不是换更大的模型而是把已有数据的价值榨干。我试过几个有效的手段按性价比排序。第一个是「困难样本回捞」。先用训练好的模型在验证集和一部分未标注的实拍图上跑推理把置信度在 0.3 到 0.6 之间的检测框导出来人工过一遍确认是漏检的烟头就补标注是误检就加负样本。一轮下来通常能补出 50 到 100 个高质量困难样本再训练时 mAP 能涨 3 到 5 个点。这个过程的成本主要是人工审核但比重新标 1000 张图划算得多。第二个是「多尺度测试增强」。训练完之后推理时把输入图片缩放到 640、800、960 三个尺度分别跑一遍然后把三组检测框做加权 NMS。烟头这种小目标在不同尺度下的响应差异很大多尺度融合能显著降低漏检。代价是推理时间变成三倍如果部署环境对延迟不敏感这个技巧几乎是无脑收益。第三个是「类别特定的 NMS 阈值」。前面提过 smoking 和 cigarette 有包含关系其实 hand 和 smoking 也有重叠。可以在推理后处理阶段对 smoking 和 cigarette 之间用 0.3 的 IoU 阈值对 smoking 和 hand 之间用 0.5其他类别之间用默认的 0.7。这个调整不需要重新训练改几行后处理代码就行。第四个是「背景样本的主动引入」。1000 张图里如果全是正样本模型没见过「没有吸烟行为的室内外场景」部署后遇到正常场景就会乱报。我的做法是从公开数据集或者自己拍一些不含吸烟行为的室内外图片大概 200 到 300 张作为背景负样本加入训练集标签文件写空。这批负样本不需要标注成本极低但能大幅降低误检率。最后一个技巧是关于模型选择的。1000 张图的数据量YOLOv8n 或者 YOLOv8s 就够了不要上 L 或 X。大模型在小数据集上过拟合的风险远高于收益。如果你发现 nano 模型欠拟合先把输入分辨率提上去再考虑换 s 模型。我自己的习惯是数据量低于 2000 张时模型参数量控制在 5M 以内超过这个数就是在给验证集指标打工。这些技巧里困难样本回捞和多尺度测试是我每次做小数据集检测都会用的基本不会翻车。背景样本引入在吸烟检测这种「异常行为检测」场景里尤其重要因为实际部署时绝大多数画面都是正常的模型对正常场景的抑制能力比检出能力更影响用户体验。希望帮到你。本文还有配套的精品资源点击获取
返回列表