从PASCAL VOC到YOLOv8:991张吸烟检测数据集的完整训练实践
2026/9/23 21:18:29 网站建设 项目流程

简介:面向计算机视觉与目标检测学习者,这是一份包含991张真实吸烟场景图像的标注数据集,可用于吸烟检测、行为识别等模型的训练与算法验证。压缩包内共1982个文件,包含991张JPG原图和991个对应的Pascal VOC XML标注文件,整体大小44.92MB,图像与标注一一对应,可直接接入YOLO、Faster R-CNN等主流检测框架。目前已有262人学习浏览。所有图像均经过统一标注,平均识别率达到88.3%,标注文件包含目标类别与边界框位置信息,可直接用于模型训练与性能评估;既适合作为毕业设计、课程项目的数据基础,也适用于安全监控、公共场所行为分析等场景的模型迭代。研究者借助该数据集可大幅节省手工标注时间,快速开展算法对比与调参实验。

1. 一张991张图片的吸烟数据集,凭什么值得你从头到尾跑一遍

做安全巡检的人大多撞过同一个需求:在工地、机房、加油站或者校园的监控画面里,检测到有人吸烟,立刻告警。真去落地的时候你会发现,模型不是最难的部分,最难的是手里没有一张干净可用的标注数据。网上能翻到的吸烟图片零零散散,背景杂、分辨率乱、标注格式五花八门,光洗数据就能耗掉两三天。而这份991张原始图片、PASCAL VOC XML格式标注、平均识别率88.3%的吸烟数据集,恰好解决了“先用起来”这件事——它规模不大,但格式标准、类别明确、有可复现的基线数字,适合用来跑通从标注解析、格式转换到模型训练评估的完整流程。无论你是第一次接触目标检测,还是已经训过几个模型想快速验证思路,这份数据都能当作一块稳定的试验田。

2. 为什么是PASCAL VOC XML而不是COCO或YOLO:标注格式选型与结构拆解

2.1 VOC XML的目录约定和annotation内部结构

PASCAL VOC的标注格式在目标检测领域属于“老炮儿”,2005年由VOC挑战赛定下来,到今天大量开源数据集还在沿用。它的根目录通常长成这个样子:

VOCdevkit/ ├── VOC2007/ │ ├── JPEGImages/ # 原始图片 │ ├── Annotations/ # 每个图片对应的XML标注文件 │ └── ImageSets/ │ └── Main/ # train.txt, val.txt, test.txt

991张原始图片对应991个XML文件,文件名一一对应,比如IMG_0001.jpg对应IMG_0001.xml。打开一个XML,核心结构是这样的:

<annotation> <folder>VOC2007</folder> <filename>IMG_0001.jpg</filename> <size> <width>1280</width> <height>720</height> <depth>3</depth> </size> <object> <name>smoking</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>412</xmin> <ymin>238</ymin> <xmax>587</xmax> <ymax>402</ymax> </bndbox> </object> </annotation>

看到没有,VOC的XML是给人读的,标签名字直接写在标签里,边框坐标清清楚楚。size节点记录图片宽高深,后面转其他格式的时候要用它做归一化。truncated表示目标是否被边界截断,difficult表示是否难以辨认——这两项在评测时会影响算分方式。对吸烟检测来说,常见的标注策略是只标一个类别smoking,框住“手拿烟靠近嘴部”的区域,也有些数据会把人和烟分开标,那份数据的取舍以实际场景为准。

2.2 三种标注格式的取舍:VOC、COCO、YOLO的差异表

用过目标检测的人都知道,同样是框,三种格式的存法完全不同。这里做一张表,方便你对着自己的工具链选。

格式存储方式坐标表示典型工具/框架优势劣势
PASCAL VOCXML文件xmin, ymin, xmax, ymaxlabelImg, CVAT, 各类老旧数据集直观、可读性强、元信息丰富文件多、占空间、解析略慢
COCO单个JSON[x, y, width, height]labelme(转), CVAT, detectron2统一管理、支持分割与关键点结构嵌套深、手写容易错
YOLO纯文本txtcx, cy, w, h(归一化)labelImg(YOLO模式), 各类YOLO训练轻量、训练读取快、无冗余信息可读性差、丢了图片尺寸就废

选VOC格式最大的实际好处是它「无依赖」:一个XML就是一个独立文件,哪怕某个标注坏了也只影响那一张图,不会像COCO那样一个JSON里坏一处整个训练集读不了。而且labelImg默认的输出格式就是VOC格式,CVAT导出选项里也有它,数据换手成本极低。你后面如果想用yolov8训练,照样可以写个脚本一键转成YOLO的txt格式,这个转换脚本在下一章会直接给你。

2.3 991张图和88.3%识别率:这两个数字该怎么读

先看规模。991张原始图片对于单类别目标检测来说属于“小但够用”的级别。按一张图平均标两个烟头框算,大约有2000个左右的标注实例。一个类别2000个实例,正常训练yolov8或者SSD,跑出来的模型在类似场景下能到85%到92%的mAP50,这份数据标称的88.3%正好落在这个区间里,数字是可信的。如果你做的是多类别检测或者密集小目标场景,这个量级就明显不够,至少得到3000到5000张起步。

再看88.3%这个数字。严格来说“平均识别率”在目标检测里有两种解释:一种指mAP50,即IoU阈值取0.5时的平均精度;另一种指所有测试图片上检测正确的比例。从工程复现角度,我一般把它理解成mAP50水平——在一次常规参数训练后的合理水平。它说明这份数据“能出活”,但离生产级部署还有距离,后面第6章会讲怎么往上提。要注意的是,这个88.3%大概率是在它自带的划分方式下得到的,你换了验证集划分比例,分数浮动三五个点都正常。

3. 拿到手先做三件事:解析XML、校验标注、转YOLO格式

3.1 用Python解析全部XML,统计类别与尺寸分布

拿到这份数据集第一步不是急着训练,先把家底盘一遍。991个XML逐个解析,统计每个类别的数量、每张图的标注框数量分布、图片尺寸是否有不统一的情况。这一步能帮你提前发现数据里埋的雷。下面这个脚本直接跑:

import os import xml.etree.ElementTree as ET from collections import Counter annotations_dir = "Annotations" class_counter = Counter() boxes_per_image = [] widths = set() errors = [] for xml_file in os.listdir(annotations_dir): if not xml_file.endswith(".xml"): continue path = os.path.join(annotations_dir, xml_file) try: tree = ET.parse(path) root = tree.getroot() # 统计图片尺寸,后面转YOLO格式时要用 size = root.find("size") widths.add((size.find("width").text, size.find("height").text)) # 统计每个object的类别 objects = root.findall("object") boxes_per_image.append(len(objects)) for obj in objects: class_counter[obj.find("name").text] += 1 except Exception as e: errors.append((xml_file, str(e))) print("类别统计:", class_counter) print("每张图框数分布: 最小={}, 最大={}, 平均={:.2f}".format( min(boxes_per_image), max(boxes_per_image), sum(boxes_per_image) / len(boxes_per_image))) print("图片尺寸种类:", widths) print("解析失败文件:", errors[:5] if errors else "无")

这段脚本用xml.etree.ElementTree做解析,它是Python标准库,不需要额外安装依赖。核心逻辑是先取size节点确认所有图片尺寸是否一致,再遍历所有object节点统计类别名和框数量。如果有文件解析失败会记录到errors列表里。跑完你该得到类似“类别只有smoking,每张图平均2.1个框,所有图都是1280x720”这样的结论。如果发现有些图片尺寸是1920x1080,后面转换脚本里归一化就按各自图的size算,不能写死。

3.2 校验标注:越界框、空标注、错类名三查

标注数据是人工做的,就一定有手滑的时候。最常见的问题有三个:xmax超过图片宽度、某张图一个框都没标、类别名拼写不一致(比如smokingSmoking混用)。这些问题不提前清掉,训练时轻则loss异常,重则直接报错中断。用下面这段脚本做全面体检:

import os import xml.etree.ElementTree as ET annotations_dir = "Annotations" image_dir = "JPEGImages" issues = [] for xml_file in sorted(os.listdir(annotations_dir)): if not xml_file.endswith(".xml"): continue path = os.path.join(annotations_dir, xml_file) tree = ET.parse(path) root = tree.getroot() # 1. 校验对应的图片文件是否存在 filename = root.find("filename").text img_path = os.path.join(image_dir, filename) if not os.path.exists(img_path): issues.append(f"{xml_file}: 图片文件 {filename} 缺失") # 2. 校验边框是否越界 width = int(root.find("size/width").text) height = int(root.find("size/height").text) objects = root.findall("object") if not objects: issues.append(f"{xml_file}: 没有任何标注框") for obj in objects: name = obj.find("name").text if name not in ("smoking", "person"): # 按实际类别调整 issues.append(f"{xml_file}: 未知类别 {name}") box = obj.find("bndbox") xmin = float(box.find("xmin").text) ymin = float(box.find("ymin").text) xmax = float(box.find("xmax").text) ymax = float(box.find("ymax").text) if xmin < 0 or ymin < 0 or xmax > width or ymax > height: issues.append(f"{xml_file}: 越界框 ({xmin}, {ymin}) -> ({xmax}, {ymax})") if xmax <= xmin or ymax <= ymin: issues.append(f"{xml_file}: 宽高为负的非法框") if issues: for i in issues: print(i) print(f"共发现 {len(issues)} 个问题") else: print("所有标注文件检查通过")

这段脚本把三类高频问题一次扫完。边框越界是最隐蔽的坑,因为人工标注时鼠标稍微拖过头一点,框就出了图边界,VOC格式本身不报错,但转YOLO时归一化坐标会变成负数或大于1,训练时某些版本的ultralytics会直接崩。校验通过后再进训练流程,能省掉大量排查时间。如果发现问题文件数量少,直接改XML或删掉对应样本就行;如果数量多,就得回到标注工具里重新导出一遍。

3.3 一步到位:VOC转YOLO格式脚本

为什么必须转YOLO格式再训练?因为yolov8、yolov5这些框架的DataLoader对YOLO txt格式支持最成熟,读起来快,而且在目标检测训练管线里,YOLO格式的归一化坐标天然适合anchor匹配计算。转换逻辑不复杂:从XML里读出bndbox的绝对坐标,除以图片宽高得到相对坐标,再由 (xmin, ymin, xmax, ymax) 算出中心点坐标和宽高。脚本如下:

import os import xml.etree.ElementTree as ET from pathlib import Path def convert_voc_to_yolo(xml_file, output_dir, class_names): tree = ET.parse(xml_file) root = tree.getroot() # 读取图片尺寸用于归一化 size = root.find("size") img_width = int(size.find("width").text) img_height = int(size.find("height").text) # 输出文件名与XML同名,后缀改为.txt xml_name = Path(xml_file).stem out_path = os.path.join(output_dir, xml_name + ".txt") lines = [] for obj in root.findall("object"): class_name = obj.find("name").text if class_name not in class_names: continue # 跳过未知类别 class_id = class_names.index(class_name) box = obj.find("bndbox") xmin = float(box.find("xmin").text) ymin = float(box.find("ymin").text) xmax = float(box.find("xmax").text) ymax = float(box.find("ymax").text) # 绝对坐标转归一化中心点+宽高 x_center = (xmin + xmax) / 2.0 / img_width y_center = (ymin + ymax) / 2.0 / img_height width = (xmax - xmin) / img_width height = (ymax - ymin) / img_height # 防止浮点误差导致边界越界 x_center = min(max(x_center, 0.0), 1.0) y_center = min(max(y_center, 0.0), 1.0) width = min(max(width, 0.0), 1.0) height = min(max(height, 0.0), 1.0) lines.append(f"{class_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}") with open(out_path, "w") as f: f.write("\n".join(lines)) # 使用示例 class_names = ["smoking"] # 单类别检测按实际类别修改 xml_dir = "Annotations" yolo_label_dir = "yolo_labels" os.makedirs(yolo_label_dir, exist_ok=True) for xml_file in sorted(os.listdir(xml_dir)): if xml_file.endswith(".xml"): convert_voc_to_yolo(os.path.join(xml_dir, xml_file), yolo_label_dir, class_names) print(f"转换完成,输出到 {yolo_label_dir}")

转换脚本里最容易写错的是class_id = class_names.index(class_name)这一行——如果XML里有类别名拼写错误,index()会抛异常,所以建议先跑3.2节的校验再转换。另外注意输出坐标做了min/max截断,防止浮点计算后出现1.000001这种值,虽然不会崩,但会让某些版本的模型在计算IoU时出现诡异的行为。转换完随机打开一个txt看看,内容应该是类似0 0.5123 0.4567 0.1789 0.2334的一行行文本。

3.4 训练集/验证集/测试集划分:991张图怎么分才靠谱

数据量只有991张,划分方式直接影响88.3%这个基线能不能复现。常见做法是训练集、验证集、测试集按 8:1:1 的比例随机划分,也就是大约792张训练、100张验证、99张测试。划分时有一个重要原则:不要用随机种子固定得太随意,至少试两三个种子看结果稳不稳定。训练集和验证集各自建一个txt文件列出图片路径,内容形如:

JPEGImages/IMG_0001.jpg JPEGImages/IMG_0002.jpg

用下面的代码生成划分文件,同时保证随机种子可复现:

import os import random from pathlib import Path random.seed(42) # 固定种子,方便复现 images = [f for f in os.listdir("JPEGImages") if f.lower().endswith((".jpg", ".jpeg", ".png"))] random.shuffle(images) train_ratio = 0.8 val_ratio = 0.1 # test_ratio = 0.1 split_point1 = int(len(images) * train_ratio) split_point2 = int(len(images) * (train_ratio + val_ratio)) train_files = images[:split_point1] val_files = images[split_point1:split_point2] test_files = images[split_point2:] def write_list(file_path, file_list, img_dir="JPEGImages"): with open(file_path, "w") as f: for name in file_list: f.write(f"{img_dir}/{name}\n") write_list("train.txt", train_files) write_list("val.txt", val_files) write_list("test.txt", test_files) print(f"训练集 {len(train_files)} 张,验证集 {len(val_files)} 张,测试集 {len(test_files)} 张")

这里的关键点是img_dir参数写的是相对路径,如果你的图片和标注在同一个根目录下,这种写法配合yolov8的配置文件最省事。991张图的量级下,只用随机划分就够了,不需要按视频帧或时间段做聚类划分——那是在做视频时序数据时才用的策略。划分完后建议把val.txttest.txt里的文件名打印出来人工扫一眼,防止某个抽烟姿态特别集中的场景全被分到测试集里。

4. 避坑:991张图训出来的模型,最容易在四个地方翻车

4.1 现象:验证集mAP50有0.88,视频里一帧帧跑起来却疯狂误报

原因出在数据分布和评测方式不匹配。验证集里的图片大概率是清晰、正对镜头、光照良好的静态帧,而实际监控视频里大量出现的是人物背对镜头、手放低、烟雾被身体挡住等边角情况。看一眼这份数据集的构成你会发现,很多图是网上抓的展示图,背景相对干净,跟真实监控画质有距离。解决方式是在你的真实场景里多截背景帧加入训练,并且把验证集的评估粒度换成帧级别而非图片级别,误报率会更真实。

4.2 现象:个别XML里的filename字段和实际图片名对不上

一手数据经过多次搬运,文件名被重命名很常见。比如XML文件叫IMG_0100.xml,里面的<filename>写的却是IMG_099.jpg,少了一个0。直接按XML里的filename找图会找到错的图甚至找不到图。原因基本是有人用脚本批量改过文件名但忘了同步XML内容。解决的土办法是用XML文件名去对应JPEG文件名,不要信任XML内部的filename字段;也就是3.2节的校验脚本里加一条“用xml文件名去反查同名jpg是否存在”的逻辑,命名对得上的前提下,XML内部filename字段只是摆设。

4.3 现象:VOC转YOLO后训练直接报“unable to load dataset”

这一般是txt标签文件和图片文件的目录结构不对。yolov8的YOLO格式要求每个txt文件放在和图片同名的label目录里,常见结构是datasets/smoking/images/train/xxx.jpg对应datasets/smoking/labels/train/xxx.txt。有人图省事把所有txt和jpg平铺在同一个目录,生成一个data.yaml就丢给ultralytics去训,结果框架按默认的labels目录找txt,找不到自然报错。解决方法是按上面目录结构重新组织,并在data.yaml里写清楚train/val/test三个路径和nc类别数。

4.4 现象:戴帽子的人、背光区域、远景小人全漏检

991张图训练出来的模型摸到了数据分布的天花板。这份数据里的人往往穿着普通服装、面部清晰、烟在画面里占比大;换个戴安全帽穿反光背心的工地工人,模型就不认识了。根源是类内差异大、样本量不足。这时候给模型调参已经没多大意义,正确动作是收集真实场景图补充标注,或者用半自动标注的方式扩展数据量,把训练集扩到2000到3000张。还有一种取巧做法是降低置信度阈值,从默认的0.25降到0.1,漏检会改善,但误报也会跟着涨,属于拆东墙补西墙的临时手段。

5. 用这份数据集喂yolov8:训练参数与评估指标怎么设

5.1 最小可用训练命令与data.yaml配置

把991张图转好格式、划分好数据集之后,接着就是用yolov8把它训起来。装ultralytics包之后,最小的训练命令长这样:

yolo detect train \ data=data.yaml \ model=yolov8n.pt \ epochs=100 \ imgsz=640 \ batch=16 \ device=0 \ patience=20

对应的data.yaml文件内容如下:

path: /your/absolute/path/to/smoking_dataset train: train.txt val: val.txt test: test.txt nc: 1 names: ['smoking']

命令解析:model=yolov8n.pt用的是nano版本,参数量最小、显存占用低,991张小数据集完全够用;epochs=100是折中值,单类别数据通常到不了100轮就已经收敛;patience=20表示20个epoch验证集指标没提升就提前结束,省时间;device=0指的是用第一张GPU,纯CPU环境改成device=cpu,但训练速度会慢好几倍。

5.2 必调参数:imgsz、batch、lr0与weight_decay

参数里最影响最终分数的是imgsz。数据集里原始图片如果是1280x720,640输入会丢失细节;直接拉到1280训练显存占用涨四倍,小卡跑不动。我的建议是先试imgsz=640跑通流程,再用imgsz=960做对比,看mAP有没有实质提升,差距小于1个点就直接用640,推理速度还快。batch设成16对大多数16GB显存的卡很安全,如果显存不够就先降到8,不要动imgsz,因为imgsz改起来影响面更大。学习率走ultralytics默认的lr0=0.01就行,991张数据量不需要特殊调整。weight_decay默认0.0005不用动,真正会过拟合的反而是epochs——看着val_loss还在降,其实test集mAP已经在掉,所以要以测试集指标为准。

提示:991张图属于小数据集,训练时不要开--cos_lr做余弦退火,学习率曲线拉太长,模型还没收敛完训练就结束了,经验上固定步长衰减更稳。

5.3 复现88.3%的评估姿势:看mAP50还是mAP50-95

训练完用测试集做一次正式评估,命令如下:

yolo detect val \ model=runs/detect/train/weights/best.pt \ data=data.yaml \ split=test \ imgsz=640 \ conf=0.25 \ iou=0.5

输出结果里两个指标最值得盯:mAP50mAP50-95mAP50是IoU阈值取0.5时的平均精度,对“检测到烟的大致位置”这件事更宽容,数值偏高;mAP50-95是0.5到0.95之间每隔0.05算一次再取平均,要求框又准又稳,数值会低不少。从实际经验看,991张数据训出来的模型如果mAP50=0.88,那么mAP50-95大概在0.55到0.62之间。评估时把conf设成0.25是为了对齐训练时的默认推理阈值,方便不同模型之间做横向对比。如果你想复现数据集标注的88.3%,就按这个流程跑一遍,mAP50落在85%到91%区间是正常波动,别因为差两个点就去怀疑数据或代码。

5.4 推理阶段的置信度阈值选择

训练完部署时,conf阈值是最后一个需要拍板的参数。阈值调高到0.4,误报变少,但远景的、被遮挡的烟头会漏掉;调低到0.1,recall上去了,误报也跟着翻倍。我的习惯是先用验证集做一次conf阈值扫描:

from ultralytics import YOLO model = YOLO("runs/detect/train/weights/best.pt") for conf in [0.1, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5]: results = model.val(split="val", conf=conf, iou=0.5, verbose=False) print(f"conf={conf:.2f} precision={results.box.mp:.4f} recall={results.box.mr:.4f}")

把这组输出打出来,挑precision和recall的交叉点附近那个阈值。对吸烟检测这种告警场景,我一般宁愿误报也不愿意漏报,所以阈值会选在偏recall的一侧,比如0.15或0.2,然后靠告警系统里的二次确认逻辑把误报滤掉。在这个问题上没有标准答案,跟你的业务容忍度强相关。

6. 把88.3%往上顶的四个方向

如果你是用真实监控画面在做落地,88.3%大概率不够用,至少要顶到93%以上才能让客户觉得“这玩意儿有点用”。方向不外乎四个。

第一是半自动标注扩数据。用好模型在真实场景帧上做预标注,再丢进CVAT或x-anylabeling里人工修正。CVAT支持跟踪模式,一段视频里标第一帧,后面的帧能自动补框,修正工作量大减。标完500张新图,模型和数据的匹配度会显著提升。

第二是数据增强里加“模糊和低光”模拟。mobile设备拍的真实监控画面经常有运动模糊、夜间噪点、逆光过曝,而这份数据集的图片相对干净。训练时把hsv_v的增强强度从默认值往上调,再配合轻微的高斯模糊增强,模型在真实画质下的表现会好不少。

第三是专门去挖难例。把测试集里漏检的图翻出来做聚类,你会发现漏检往往集中在人物背对镜头、烟藏在身体另一侧、画面里烟占比极小这三类,针对性的做法是去网上搜大量烟雾缭绕的图片做背景替换或MixUp增强。

第四是换更大的backbone或做多尺度推理。从yolov8n换到yolov8s甚至m,mAP大概能涨两三个点,代价是显存和推理速度。如果部署机器性能够,imgsz=960conf=0.15的组合性价比最高。

这些年我的习惯一直是把数据集当作活资产对待:每次从真实场景里攒到一批新样本就补进去重新训练,而不是训完一轮就收工。991张是起步线,不是终点线。这条路上踩过的坑,你们大概率也会再踩一遍,希望这篇笔记能帮你少走两趟弯路。

希望帮到你。

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

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

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

立即咨询