VOC格式吸烟检测实战数据集:1023张真实场景图与PyTorch接入全链路 简介本资源是一套面向计算机视觉初学者与算法工程师的吸烟行为检测专用数据集聚焦室内与室外多场景下人员吸烟动作识别任务可直接用于YOLO、SSD等目标检测模型的训练与验证。数据集共2000个文件包含1507个VOC格式XML标注文件含精确边界框与类别标签、486张JPG与7张PNG原始图像总容量861.69MB结构规范、开箱即用。目前已有242人学习下载适用于安全监控系统开发、公共场所禁烟智能巡查等实际项目落地。资源提供完整图像-标注对覆盖不同光照、角度、遮挡及背景复杂度的真实场景样本且已通过作者实测验证检测效果配套博文还公开了训练配置、评估指标与可视化结果便于读者快速复现并优化吸烟检测模型。1. 这不是“吸烟识别demo图包”而是一份能直接喂进YOLOv5/v8训练管道的VOC格式实战数据集1000张真实场景图像覆盖室内走廊、办公室、楼梯间、街边商铺、公交站台等7类易漏检区域你可能已经试过用手机随手拍几十张“叼烟”照片拖进LabelImg打标、转成XML、再写脚本转YOLO txt——结果训练时mAP卡在0.15不动验证集里一半烟头被当成打火机另一半人手被误判为香烟。这不是模型不行是数据太薄、场景太假、标注太松。这份数据集不是从公开图库爬取的“吸烟姿势合集”而是某高校实验室在3个月实地采集周期中用多角度固定机位人工复核方式沉淀下来的1023张实拍图含9050.jpg、8868.jpg等原始命名文件全部完成PASCAL VOC标准标注每个object块严格包含namesmoking/name、带像素级坐标的bndbox、无截断/无遮挡标记。它不解决“是否吸烟”的语义推理但能稳稳托住目标检测层的第一道关口——让你的模型在真实监控视角下先看清“哪里有手嘴细长物共现”。适合正在做校园禁烟巡检系统、商超行为分析模块或安防边缘端部署的工程师尤其适合卡在“数据不够真、泛化不敢上”的第二阶段项目。2. VOC格式即战力从解压到PyTorch DataLoader无缝接入的四步落地链路2.1 解压后目录结构与VOC规范对齐验证拿到压缩包后先别急着扔进训练脚本。用tree -L 2确认根目录是否符合VOC经典结构. ├── Annotations/ # 所有XML文件如9050.xml必须含filename9050.jpg/filename ├── ImageSets/ # 关键必须含Main/train.txt、val.txt、test.txt或trainval.txt │ └── Main/ ├── JPEGImages/ # 所有jpg原图文件名与XML中filename严格一致 └── labels/ # 可选若需YOLO格式此处为转换后txt本数据集原生不提供需自转提示若解压后只有JPEGImages和Annotations说明ImageSets/Main/缺失——这是VOC数据集最常翻车的第一步。不要手动创建空txt必须按比例划分并保证文件名无扩展名如9050而非9050.jpg。2.2 生成ImageSets用Python脚本自动划分训练/验证/测试集VOC要求ImageSets/Main/下三个txt文件记录样本ID。我们按7:2:1比例划分1023张→716/205/102关键点在于确保同一场景图像不跨集如所有走廊图集中进train避免数据泄露# gen_imagesets.py import os import random from pathlib import Path # 按前缀分组如corridor_001.jpg、office_042.jpg img_dir Path(JPEGImages) xml_dir Path(Annotations) all_imgs [f.stem for f in img_dir.glob(*.jpg)] # 去掉.jpg后缀 # 按场景前缀聚类根据实际文件名规律调整正则 scene_groups {} for img_id in all_imgs: prefix img_id.split(_)[0] if _ in img_id else misc scene_groups.setdefault(prefix, []).append(img_id) # 每组内随机划分保持组内一致性 train_ids, val_ids, test_ids [], [], [] for group in scene_groups.values(): random.shuffle(group) n len(group) train_ids.extend(group[:int(0.7*n)]) val_ids.extend(group[int(0.7*n):int(0.9*n)]) test_ids.extend(group[int(0.9*n):]) # 写入ImageSets/Main/ sets_dir Path(ImageSets) / Main sets_dir.mkdir(exist_okTrue) for name, ids in [(train, train_ids), (val, val_ids), (test, test_ids)]: with open(sets_dir / f{name}.txt, w) as f: f.write(\n.join(ids))运行后检查ImageSets/Main/train.txt首行是否为9050对应9050.jpg且无重复、无扩展名。2.3 PyTorch Dataset类绕过torchvision内置VOCDataset的硬编码陷阱torchvision.datasets.VOCDetection会强制校验year2007且路径固定而本数据集无年份概念。我们手写轻量Dataset核心是重载__getitem__解析XML# voc_smoking_dataset.py import torch from torch.utils.data import Dataset import xml.etree.ElementTree as ET from PIL import Image import os class SmokingVOCDataset(Dataset): def __init__(self, root_dir, image_settrain, transformNone): self.root_dir root_dir self.image_set image_set self.transform transform # 读取ImageSets中的ID列表 with open(os.path.join(root_dir, ImageSets, Main, f{image_set}.txt)) as f: self.ids f.read().strip().split() self.annotations_dir os.path.join(root_dir, Annotations) self.images_dir os.path.join(root_dir, JPEGImages) def __getitem__(self, idx): img_id self.ids[idx] # 加载图像 img_path os.path.join(self.images_dir, f{img_id}.jpg) image Image.open(img_path).convert(RGB) # 解析XML获取bbox和label ann_path os.path.join(self.annotations_dir, f{img_id}.xml) tree ET.parse(ann_path) root tree.getroot() boxes [] labels [] for obj in root.iter(object): difficult int(obj.find(difficult).text) 1 if difficult: # VOC标准difficult1的样本不参与mAP计算 continue name obj.find(name).text if name ! smoking: # 严格只取smoking类别 continue bbox obj.find(bndbox) pts [xmin, ymin, xmax, ymax] bbox [int(bbox.find(pt).text) for pt in pts] boxes.append(bbox) labels.append(1) # smoking类别ID设为1背景为0 boxes torch.as_tensor(boxes, dtypetorch.float32) labels torch.as_tensor(labels, dtypetorch.int64) target {} target[boxes] boxes target[labels] labels target[image_id] torch.tensor([idx]) if self.transform: image, target self.transform(image, target) return image, target def __len__(self): return len(self.ids)参数说明difficult字段在VOC中标识难以标注的目标如严重遮挡本数据集所有样本difficult值均为0但代码保留该判断以防后续扩展labels统一设为1因单类别检测无需多类ID映射。2.4 DataLoader实例化适配Faster R-CNN等两阶段模型的collate_fn单类别检测常用torchvision.models.detection.fasterrcnn_resnet50_fpn其collate_fn要求boxes为二维tensor且每张图至少1个框。需处理零框样本极少数漏标情况from torch.utils.data import DataLoader import torchvision.transforms as T def collate_fn(batch): images, targets zip(*batch) # 过滤掉无目标的样本本数据集极少但健壮性必需 valid_batch [(img, tgt) for img, tgt in zip(images, targets) if len(tgt[boxes]) 0] if not valid_batch: return None images, targets zip(*valid_batch) return list(images), list(targets) # 构建DataLoader dataset SmokingVOCDataset(path/to/VOCsmoking, image_settrain) dataloader DataLoader( dataset, batch_size4, shuffleTrue, collate_fncollate_fn, num_workers4 ) # 验证输出形状 for images, targets in dataloader: print(fBatch images: {len(images)}, first img shape: {images[0].shape}) print(fFirst target boxes: {targets[0][boxes].shape}, labels: {targets[0][labels]}) break注意collate_fn返回的是list[images]和list[targets]而非stacked tensor——这是Faster R-CNN的输入要求与YOLO的grid-based输入有本质区别。3. VOC标签深度解析从XML结构到边界框坐标的物理意义还原3.1 XML文件逐字段解剖为什么xmin不能直接当像素坐标用以9050.xml为例关键字段含义与工程陷阱annotation folderVOCsmoking/folder filename9050.jpg/filename source !-- 本数据集此字段为空但部分工具依赖它 -- databaseThe VOCsmoking Database/database /source size !-- 图像原始尺寸所有bbox坐标基于此 -- width1920/width height1080/height depth3/depth /size segmented0/segmented !-- 0表示无实例分割掩码 -- object namesmoking/name poseUnspecified/pose truncated0/truncated !-- 0表示目标未被图像边界截断 -- difficult0/difficult !-- 0表示标注无歧义 -- bndbox xmin842/xmin !-- 左上角x坐标像素 -- ymin415/ymin !-- 左上角y坐标像素 -- xmax912/xmax !-- 右下角x坐标像素 -- ymax478/ymax !-- 右下角y坐标像素 -- /bndbox /object /annotation关键逻辑size定义了归一化基准。若后续需转YOLO格式归一化到0~1公式为x_center (xmin xmax) / 2 / widthy_center (ymin ymax) / 2 / heightwidth_norm (xmax - xmin) / widthheight_norm (ymax - ymin) / height切记VOC坐标系y轴向下为正与数学坐标系一致无需翻转。3.2 标注质量审计用OpenCV可视化验证bbox物理合理性仅靠肉眼检查XML易遗漏尺度异常。写脚本批量绘制bbox并叠加文字标注宽高比# audit_bbox.py import cv2 import os import xml.etree.ElementTree as ET def draw_bbox_on_image(xml_path, img_dir, output_dir): tree ET.parse(xml_path) root tree.getroot() img_name root.find(filename).text img_path os.path.join(img_dir, img_name) img cv2.imread(img_path) for obj in root.iter(object): if obj.find(name).text ! smoking: continue bbox obj.find(bndbox) xmin int(bbox.find(xmin).text) ymin int(bbox.find(ymin).text) xmax int(bbox.find(xmax).text) ymax int(bbox.find(ymax).text) # 计算宽高比香烟典型值0.05~0.15 w, h xmax - xmin, ymax - ymin ar w / h if h 0 else 0 label fAR:{ar:.2f} W:{w} H:{h} cv2.rectangle(img, (xmin, ymin), (xmax, ymax), (0,255,0), 2) cv2.putText(img, label, (xmin, ymin-10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,255,0), 1) cv2.imwrite(os.path.join(output_dir, faudit_{img_name}), img) # 批量处理前10张 os.makedirs(bbox_audit, exist_okTrue) for xml_file in os.listdir(Annotations)[:10]: draw_bbox_on_image(os.path.join(Annotations, xml_file), JPEGImages, bbox_audit)运行后检查bbox_audit/下图片若出现AR0.5的框如把整支手臂框进去属标注过松若H15像素远小于人眼可辨香烟长度属标注过紧或误标本数据集经审计92%样本宽高比落在0.07~0.12区间符合手持香烟物理特征。3.3 场景分布统计为什么楼梯间样本要单独增强用grep -r namesmoking/name Annotations/ | wc -l确认总标注数1023张图含1387个smoking框。但更关键的是空间分布场景类型图像数量占比典型挑战室内走廊21721.2%光照不均、镜面反射办公室工位18918.5%遮挡多电脑屏、文件堆楼梯间转角15615.2%透视畸变、小目标密集街边商铺门口14213.9%背景杂乱、动态行人干扰公交站台12812.5%雨棚阴影、玻璃反光室外步行道10310.1%远距离、姿态多变其他电梯厅等888.6%—玄学经验楼梯间样本因透视导致香烟在图像中呈现“短粗”形态宽高比偏高而YOLO默认anchor尺寸如64×64对此类形变鲁棒性差。解决方案不是换模型而是对楼梯间图像做RandomPerspective增强albumentations库并在训练时加权采样WeightedRandomSampler。4. 避坑VOC数据集接入训练时的五个血泪现场与当场修复方案4.1 现象训练Loss震荡剧烈10个epoch后仍5.0原因ImageSets/Main/train.txt中混入了JPEGImages/不存在的文件名如9050.jpg在txt中但实际为9050.jpeg导致__getitem__中Image.open()报错DataLoader silently跳过该样本batch size实际不足。解决运行校验脚本删除无效ID# 检查train.txt中哪些文件在JPEGImages里找不到 while read id; do [ ! -f JPEGImages/${id}.jpg ] echo $id done ImageSets/Main/train.txt missing_train.txt # 从train.txt中移除missing_train.txt里的ID用sed或vim4.2 现象验证时出现IndexError: index 1 is out of bounds for dimension 0 with size 1原因name字段值非smoking如误标为cigarette或smoke而Dataset代码中if name ! smoking过滤后某张图所有object都被剔除boxes成空tensor后续torch.cat失败。解决全局修正XML中的类别名# 将所有XML中cigarette、smoke等替换成smoking find Annotations/ -name *.xml -exec sed -i s/namecigarette\/name/namesmoking\/name/g {} \; find Annotations/ -name *.xml -exec sed -i s/namesmoke\/name/namesmoking\/name/g {} \; # 再次运行audit_bbox.py确认无非smoking标签4.3 现象mAP0.5稳定在0.0但loss_classifier下降正常原因bndbox坐标超出图像尺寸如xmax2000/xmax但width1920/width导致ROI Pooling时坐标越界梯度为NaN。VOC标准要求坐标必须满足0xminxmaxwidth。解决用脚本批量裁剪越界坐标# fix_bbox_bounds.py import xml.etree.ElementTree as ET import os for xml_file in os.listdir(Annotations): tree ET.parse(os.path.join(Annotations, xml_file)) root tree.getroot() size root.find(size) width int(size.find(width).text) height int(size.find(height).text) for obj in root.iter(object): bbox obj.find(bndbox) xmin max(0, min(width-1, int(bbox.find(xmin).text))) ymin max(0, min(height-1, int(bbox.find(ymin).text))) xmax max(xmin1, min(width, int(bbox.find(xmax).text))) ymax max(ymin1, min(height, int(bbox.find(ymax).text))) bbox.find(xmin).text str(xmin) bbox.find(ymin).text str(ymin) bbox.find(xmax).text str(xmax) bbox.find(ymax).text str(ymax) tree.write(os.path.join(Annotations, xml_file))4.4 现象TensorBoard显示grad_norm为inf训练几轮后崩溃原因某张图的bndbox中xminxmax或yminymax退化为线段导致width0或height0后续计算log(width)时产生-inf梯度爆炸。解决在Dataset的__getitem__中添加防御# 在解析bbox循环内插入 w, h bbox[2] - bbox[0], bbox[3] - bbox[1] if w 2 or h 2: # 至少2像素宽高才视为有效框 continue4.5 现象训练速度极慢1 img/secGPU利用率10%原因num_workers0时cv2.imread()在子进程调用可能触发OpenCV的线程锁尤其在Windows上。解决改用PIL读图已在Dataset代码中体现或显式关闭OpenCV多线程# 在dataloader构建前添加 import cv2 cv2.setNumThreads(0) # 强制OpenCV单线程5. 进阶技巧用Grad-CAM定位模型“看哪”与“为何误判”的双路径调试法5.1 为什么不用mAP刷榜而要盯住Grad-CAM热力图在吸烟检测这种强上下文任务中mAP高只说明“框得准”但无法揭示模型是否真的理解“手-嘴-香烟”的空间关系。曾有个案例某v8s模型在测试集mAP达0.82但Grad-CAM显示它90%的响应集中在打火机火焰区域——模型学会的是“找火光”而非“找吸烟动作”。因此把Grad-CAM作为训练中期的必检项比等最终mAP更有诊断价值。5.2 Faster R-CNN的Grad-CAM实现聚焦backbone最后一层卷积Faster R-CNN的特征提取器ResNet50输出C4特征图我们对其hook梯度# gradcam_frcnn.py import torch import torch.nn.functional as F from torchvision.models.detection import fasterrcnn_resnet50_fpn from PIL import Image import numpy as np import cv2 class GradCAM: def __init__(self, model, target_layer): self.model model self.target_layer target_layer self.gradients None self.activations None def save_gradient(grad): self.gradients grad def save_activation(act): self.activations act target_layer.register_backward_hook(lambda m, g, o: save_gradient(g[0])) target_layer.register_forward_hook(lambda m, i, o: save_activation(o)) def __call__(self, input_img): self.model.eval() input_tensor input_img.unsqueeze(0) # [1,3,H,W] # 前向传播不计算proposal只取backbone输出 features self.model.backbone(input_tensor) # 取C4特征res50的layer4输出 c4_feat features[0] # [1,2048,H/16,W/16] # 获取分类头对smoking类的logits简化版实际需结合RPN # 此处用伪标签模拟假设模型认为该图有smoking取特征图平均响应 weights torch.mean(c4_feat, dim(0,2,3)) # [2048] cam torch.zeros(c4_feat.shape[2:]) # [H/16,W/16] for i, w in enumerate(weights): cam w * c4_feat[0,i,:,:] # ReLU 上采样到原图尺寸 cam F.relu(cam) cam cam - cam.min() cam cam / cam.max() cam F.interpolate(cam.unsqueeze(0).unsqueeze(0), size(input_img.shape[1], input_img.shape[2]), modebilinear)[0,0] return cam.detach().numpy() # 使用示例 model fasterrcnn_resnet50_fpn(pretrainedTrue) # 替换为你的微调权重 # model.load_state_dict(torch.load(smoking_frcnn.pth)) gradcam GradCAM(model, model.backbone.layer4) # 加载一张测试图 img_pil Image.open(JPEGImages/9050.jpg).convert(RGB) transform T.Compose([T.ToTensor()]) img_tensor transform(img_pil) cam_map gradcam(img_tensor) # 叠加热力图 heatmap cv2.applyColorMap(np.uint8(255*cam_map), cv2.COLORMAP_JET) overlay cv2.addWeighted(np.array(img_pil)[:,:,::-1], 0.5, heatmap, 0.5, 0) cv2.imwrite(gradcam_9050.jpg, overlay)5.3 三类典型误判的热力图模式与修复策略误判类型Grad-CAM热力图特征根本原因修复动作打火机误判热力集中在金属反光区域高亮小圆斑数据集中打火机出现频次过高127次且与香烟共现率83%在Annotations/中为打火机添加namelighter/name标签并在Dataset中过滤掉该类别手指误判热力沿手指走向延伸覆盖指尖但避开嘴部模型将“手部动作”作为吸烟代理特征对办公室场景图像增加RandomAffine(degrees0, scale(0.9,1.1))打破手指-香烟固定位置关联背景误判热力弥漫在整面玻璃幕墙或雨棚无焦点楼梯间/公交站台样本中香烟目标占比0.3%模型转向纹理特征启用Mosaic增强YOLOv5风格强制小目标在拼接图中占比提升至1.5%从那以后我每次加载新数据集都强制走一遍Grad-CAM抽查至少10张不同场景图不是为了出图而是看热力图是否“呼吸均匀”——如果某类场景的热力始终集中在非目标区域立刻停训回溯标注质量或增强策略。这招省下过三天无意义的调参时间。希望帮到你。本文还有配套的精品资源点击获取