简介:这份吸烟数据集面向计算机视觉方向的学习者与算法开发者,适用于目标检测模型的训练、微调与性能评测,尤其适合需要快速验证吸烟行为识别方案的场景。资源包共收录1982个文件,由991张原始图片与991个PASCAL VOC XML格式标注文件一一对应组成,图片覆盖多种吸烟姿态与背景,标注信息可直接用于主流检测框架的数据加载与训练流程,压缩包整体约44.92MB,体积轻便、便于快速下载与本地部署。目前已有262人学习下载,说明该数据集在实际项目中具备一定的参考与复用价值。基于该数据训练得到的模型平均识别率可达88.3%,读者可据此搭建吸烟检测基线、对比不同网络结构的精度表现,或将其作为数据增强与迁移学习的起点,省去从零采集与标注的成本,较快进入模型调优与落地验证阶段。
1. 吸烟数据集落地:991 张原始图片、88.3% 平均识别率背后的真实工程账
手上只有 991 张原始图片,标注是 PASICAL VOC XML 格式,官方口径平均识别率 88.3%——这个数字放在论文里好看,放到产线里能不能用,是两回事。我见过太多团队拿到这类小数据集,直接套一个 YOLO 默认配置跑一遍,mAP 出来 0.6 出头,然后开始怀疑数据有问题。问题往往不在数据,在于没搞清楚 991 张图意味着什么:它属于典型的小样本目标检测场景,类别少、场景单一、标注格式老,但恰好是验证「数据增强 + 迁移学习 + 标注清洗」这条链路的最佳试验田。这篇笔记面向两类人:一类是手上有 PASICAL VOC XML 标注、想快速跑通吸烟行为检测的算法同学;另一类是评估这个方向值不值得投入的工程负责人。我会把从数据体检、格式转换、训练参数到避坑的完整路径拆开讲,参数给到能直接抄的程度,坑也标清楚在哪一步最容易翻车。
2. 先给 991 张图做体检:PASICAL VOC XML 到底藏着什么
拿到一个 PASICAL VOC XML 数据集,第一件事不是写训练脚本,是搞清楚它的分布。991 张这个量级,如果类别分布再偏一点,某些类可能只有几十个实例,直接训就是灾难。PASICAL VOC XML 的结构本身不复杂,每张图对应一个同名 xml,里面<object>节点记录类别和<bndbox>坐标,但实际拿到的数据里,坑往往藏在细节:坐标越界、宽高为 0、类别名大小写不一致、图片和 xml 对不上号。这些不体检出来,后面训练 loss 不降你都不知道为什么。
2.1 用一段脚本把数据集分布摸清楚
我一般会先写一个统计脚本,把类别数、每类实例数、图片尺寸分布、标注框宽高分布一次性打出来。这一步花十分钟,能省后面几小时的瞎调参。
import os import xml.etree.ElementTree as ET from collections import Counter, defaultdict ANNO_DIR = "annotations" # PASICAL VOC XML 存放目录 IMG_DIR = "images" # 原始图片目录 cls_counter = Counter() size_counter = Counter() wh_list = defaultdict(list) bad_files = [] for xml_file in os.listdir(ANNO_DIR): if not xml_file.endswith(".xml"): continue path = os.path.join(ANNO_DIR, xml_file) try: tree = ET.parse(path) except Exception as e: bad_files.append((xml_file, "parse_error")) continue root = tree.getroot() # 图片尺寸 size = root.find("size") if size is not None: w = int(size.find("width").text) h = int(size.find("height").text) size_counter[(w, h)] += 1 # 遍历目标框 for obj in root.findall("object"): name = obj.find("name").text.strip() cls_counter[name] += 1 bbox = obj.find("bndbox") xmin = int(float(bbox.find("xmin").text)) ymin = int(float(bbox.find("ymin").text)) xmax = int(float(bbox.find("xmax").text)) ymax = int(float(bbox.find("ymax").text)) bw, bh = xmax - xmin, ymax - ymin if bw <= 0 or bh <= 0: bad_files.append((xml_file, "zero_bbox")) wh_list[name].append((bw, bh)) print("类别分布:", cls_counter) print("图片尺寸分布 top5:", size_counter.most_common(5)) for c, whs in wh_list.items(): ws = [w for w, _ in whs] hs = [h for _, h in whs] print(f"{c}: 实例数={len(whs)}, 平均宽={sum(ws)/len(ws):.1f}, 平均高={sum(hs)/len(hs):.1f}") print("异常文件:", bad_files[:20], "共", len(bad_files))这段脚本做三件事:统计类别实例数、统计图片尺寸分布、检查零面积框。逻辑上先解析 xml,再逐 object 取 name 和 bndbox,最后汇总。参数上ANNO_DIR和IMG_DIR按你实际目录改,bad_files里出现的文件要么修要么删,别留着。跑完你会得到几个关键判断:如果某个类实例数低于 100,后面增强要重点照顾;如果图片尺寸五花八门,训练时的 resize 策略就得统一;如果有零面积框,训练时 IoU 计算会出 NaN,必须提前清掉。
2.2 类别不平衡和尺寸分布决定你的增强策略
991 张图里,吸烟行为检测通常只有「吸烟」「未吸烟」或者「手持烟」「嘴含烟」这类 2 到 4 个类。如果统计出来某一类占比超过 70%,那就是典型的长尾。这时候不要急着上 focal loss,先把数据增强做对:对少数类做随机裁剪、亮度扰动、小角度旋转,对多数类做随机丢弃一部分。尺寸分布也很关键,如果标注框平均宽高只有几十像素,说明目标偏小,训练分辨率就不能压太低,输入尺寸至少 640,否则小目标特征在 backbone 下采样几次后就没了。
提示:PASICAL VOC XML 里
<difficult>标记为 1 的框,训练时建议保留但评估时忽略,很多开源框架默认不处理这个字段,需要自己在 dataloader 里过滤。
3. 把 PASICAL VOC XML 转成训练框架能吃的格式
PASICAL VOC XML 是标注格式,不是训练格式。不管你用 YOLO 系列、Detectron2 还是 PaddleDetection,第一步都是转格式。转格式看着简单,但坐标越界、图片名带空格、类别名映射错位这三个坑,几乎每个项目都会踩一遍。我一般会写一个转换脚本,同时做坐标裁剪和类别映射,转完再抽样可视化验证。
3.1 转 YOLO txt 格式的完整脚本与坐标裁剪
YOLO 格式要求每张图一个 txt,每行class_id cx cy w h,全部归一化到 0 到 1。PASICAL VOC XML 给的是绝对坐标 xmin ymin xmax ymax,转换时最容易翻车的是坐标超出图片边界——标注员手一抖,xmax 写成 2000,图片宽只有 1920,归一化后大于 1,训练时框就飞了。
import os import xml.etree.ElementTree as ET from PIL import Image ANNO_DIR = "annotations" IMG_DIR = "images" OUT_DIR = "labels" CLASSES = ["smoking", "not_smoking"] # 按你的实际类别改,顺序固定 os.makedirs(OUT_DIR, exist_ok=True) def clip(v, lo, hi): return max(lo, min(v, hi)) for xml_file in os.listdir(ANNO_DIR): if not xml_file.endswith(".xml"): continue stem = os.path.splitext(xml_file)[0] img_path = os.path.join(IMG_DIR, stem + ".jpg") if not os.path.exists(img_path): print("缺图:", stem) continue with Image.open(img_path) as im: iw, ih = im.size tree = ET.parse(os.path.join(ANNO_DIR, xml_file)) root = tree.getroot() lines = [] for obj in root.findall("object"): name = obj.find("name").text.strip() if name not in CLASSES: continue cid = CLASSES.index(name) bbox = obj.find("bndbox") xmin = clip(int(float(bbox.find("xmin").text)), 0, iw - 1) ymin = clip(int(float(bbox.find("ymin").text)), 0, ih - 1) xmax = clip(int(float(bbox.find("xmax").text)), 0, iw - 1) ymax = clip(int(float(bbox.find("ymax").text)), 0, ih - 1) if xmax <= xmin or ymax <= ymin: continue cx = (xmin + xmax) / 2.0 / iw cy = (ymin + ymax) / 2.0 / ih bw = (xmax - xmin) / iw bh = (ymax - ymin) / ih lines.append(f"{cid} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") with open(os.path.join(OUT_DIR, stem + ".txt"), "w") as f: f.write("\n".join(lines))脚本核心是clip函数把坐标卡在图片范围内,再算归一化中心点和宽高。CLASSES列表顺序必须和训练配置里的类别顺序完全一致,错一位整个模型就学歪。转换完建议随机抽 20 张,用 PIL 把框画回图上肉眼检查,这一步别省。
3.2 划分训练验证集时别用随机划分
991 张图,如果按 8:2 随机划分,很可能同一场景的连续帧被分到训练和验证两边,验证集精度虚高。正确做法是按场景或按视频来源分组划分,同一组的图要么全在训练,要么全在验证。如果数据里没有场景标识,退而求其次按图片文件名前缀分组。划分比例上,小数据集我一般用 7:2:1,验证集留 200 张左右,测试集留 100 张,测试集只在最后跑一次,不要拿来调参。
# 假设图片名格式为 scene01_0001.jpg,按 scene 前缀分组划分 python split_dataset.py --img_dir images --ratio 0.7 0.2 0.1 --group_by prefix划分脚本的逻辑是按前缀聚合,再在组级别打乱分配,保证同组不跨集。参数--group_by prefix表示按文件名下划线前部分分组,如果你的命名规则不同,改成对应字段即可。
4. 训练参数怎么设:从 88.3% 识别率倒推配置
88.3% 这个平均识别率,如果指的是 mAP@0.5,那对应的是一个中等偏上的小样本检测模型;如果指的是分类准确率,那参考价值有限。不管哪种,你要复现或超过它,核心在三点:backbone 选预训练权重、输入分辨率别低于 640、学习率用 warmup 加余弦退火。991 张图从头训必然过拟合,迁移学习是唯一出路。
4.1 用 YOLOv8 在 991 张图上跑通最小训练命令
以 YOLOv8 为例,先把数据组织成它要求的目录结构,然后写一个 data.yaml,再跑训练。下面是最小可复现的命令和配置。
# data.yaml path: ./smoking_dataset train: images/train val: images/val test: images/test nc: 2 names: ["smoking", "not_smoking"]yolo detect train \ data=data.yaml \ model=yolov8s.pt \ epochs=150 \ imgsz=640 \ batch=16 \ lr0=0.001 \ lrf=0.01 \ warmup_epochs=3 \ cos_lr=True \ patience=30 \ augment=True \ mosaic=1.0 \ mixup=0.1 \ degrees=10 \ translate=0.1 \ scale=0.5 \ fliplr=0.5 \ project=runs/smoking \ name=exp01参数逐个说:model=yolov8s.pt用预训练权重,小数据集别用 l 或 x,参数量大更容易过拟合;imgsz=640是下限,如果显存够可以上 768;lr0=0.001配合lrf=0.01做余弦退火,初始学习率别超过 0.01,小数据集容易震荡;warmup_epochs=3让前 3 个 epoch 学习率线性上升,避免一开始就破坏预训练特征;patience=30是早停,验证集 30 轮不涨就停;增强参数里mosaic=1.0和mixup=0.1对小数据集提升明显,但mixup别开太大,0.1 到 0.2 够了,开大了小目标会被混没。
4.2 学习率和 batch size 的联动关系
很多人只调学习率不看 batch size,这是玄学调参的根源。实际有效的 batch size 等于单卡 batch 乘梯度累积步数,学习率应该和有效 batch size 成正比缩放。比如你单卡 batch 只能开到 8,想等效 batch 16,就加--accumulate 2,同时学习率保持 0.001 不变。如果你把 batch 从 16 降到 8 却不调学习率,训练会变得不稳定,loss 曲线毛刺明显。991 张图这个量级,batch 16 加 150 epoch 是比较稳的组合,显存不够就降 batch 加累积,别硬撑。
注意:验证集 mAP 在训练中期突然掉一大截,八成是学习率太大导致跳出最优区域,先把 lr0 砍半再跑,别急着换模型。
5. 避坑与排查:991 张图训练时最容易翻车的 5 个点
小数据集训练,翻车是常态,不翻车才奇怪。下面这 5 个是我在吸烟数据集这类项目里反复遇到的,每条按现象、原因、解决写清楚,你对照自己的训练日志看。
5.1 现象:loss 从第一轮就不降,一直卡在 0.7 左右
原因通常是标注类别名和配置里的names对不上,或者转换后的 txt 里 class_id 越界。PASICAL VOC XML 里类别名可能带空格或大小写不一致,转换时没做 strip 和统一,映射就错了。解决:重新跑一遍统计脚本,打印所有出现的类别名,和data.yaml里的names逐字比对,确认 class_id 从 0 开始连续。
5.2 现象:验证集 mAP 很高,但测试集一跑就崩
这是典型的场景泄漏。随机划分把同一场景的图分到了训练和验证两边,模型记住了背景而不是目标。解决:按场景或文件名前缀分组划分,确保同一来源的图不跨集。如果数据里没有场景标识,用图片的感知哈希做聚类,同簇的图分到同一侧。
5.3 现象:小目标框全部漏检,大目标正常
原因在输入分辨率。991 张图里如果吸烟目标平均只有 40 像素宽,resize 到 640 后可能只剩 20 多像素,经过 backbone 三次下采样就没了。解决:把imgsz提到 768 或 896,同时在增强里关掉scale的下限,别让图缩得太小。如果显存不够,用切片推理,把大图切成小块分别检测再合并。
5.4 现象:训练到 80 轮后验证集精度开始下降,训练集还在涨
过拟合。991 张图对任何检测模型都偏少,模型开始背训练集。解决:加大增强强度,mosaic保持 1.0,mixup提到 0.2,加copy_paste=0.1;同时把weight_decay从默认 0.0005 提到 0.001;如果还压不住,换更小的 backbone,比如从 yolov8s 降到 yolov8n。
5.5 现象:推理时框的位置整体偏移
坐标转换时用了int截断而不是四舍五入,或者归一化时除错了宽高。PASICAL VOC XML 的坐标是 1-based,有些框架要求 0-based,差 1 像素在大目标上看不出来,小目标上就是明显偏移。解决:转换脚本里统一用float计算,最后再取整,并且用可视化脚本抽检 50 张,确认框贴合目标边缘。
6. 把 88.3% 再往上推:小样本吸烟检测的三个进阶技巧
88.3% 不是天花板,991 张图通过合理的技巧还能再榨出几个点。第一个技巧是伪标签半监督:先用当前模型对未标注图片推理,挑置信度高于 0.9 的框加入训练集,迭代两轮,通常能涨 2 到 3 个点。第二个技巧是类别平衡采样:写一个自定义 sampler,让每个 batch 里少数类实例占比不低于 30%,比 focal loss 更直接。第三个技巧是测试时增强,推理时对同一张图做水平翻转和不同尺度,把结果做 NMS 融合,mAP 能再涨 1 个点左右。
# 测试时增强的简化实现 import cv2 import numpy as np def tta_predict(model, img, scales=(1.0, 1.25), flip=True): all_boxes, all_scores, all_cls = [], [], [] for s in scales: h, w = img.shape[:2] resized = cv2.resize(img, (int(w * s), int(h * s))) for f in ([False, True] if flip else [False]): inp = cv2.flip(resized, 1) if f else resized boxes, scores, clses = model(inp) # 还原坐标到原图尺度 boxes = boxes / s if f: boxes[:, [0, 2]] = w - boxes[:, [2, 0]] all_boxes.append(boxes) all_scores.append(scores) all_cls.append(clses) # 合并后做 NMS return merge_nms(all_boxes, all_scores, all_cls)这段代码的逻辑是对不同尺度和翻转的推理结果做坐标还原,再统一 NMS。参数scales别设太多,两三个够了,多了推理时间线性涨。flip对吸烟检测这种左右对称场景有效,如果目标有方向性就别开。
验证方法上,我习惯在测试集上同时看 mAP@0.5 和 mAP@0.5:0.95,前者看整体检出,后者看框的紧致程度。如果两者差距超过 0.2,说明框回归不准,回去检查标注质量。另外按类别分开看 AP,如果某一类明显低,单独对那一类做增强或补标注。
最后说个我自己的习惯:每次改完参数,先把训练日志里的 loss 曲线和 mAP 曲线截图存档,标注清楚改了什么。991 张图这个量级,调参空间不大,但记录能让你少走回头路。小数据集项目最怕的不是精度低,是不知道哪次改动起了作用。希望帮到你。
本文还有配套的精品资源,点击获取