做吸烟行为检测这个项目,起因是一位做安防集成的朋友找我说,工厂仓储区禁烟,靠保安盯着监控不现实,一个班次8小时,眼睛根本盯不住。于是我用深度学习里的目标检测思路做了一个“吸烟行为检测系统”,把模型封装成网页版服务,前端打开浏览器就能实时检测摄像头画面,后端同时保留了YOLOv8/v7/v6/v5的完整训练代码和标注好的训练数据集。这篇博客把整套方案从数据准备、模型训练到网页部署的细节都拆开来讲,给准备做类似项目的朋友一个可以直接上手的参考。
这个项目解决的核心问题,不是“识别一个人”,而是“识别一个人手上有没有点燃的香烟”。别看就多了这么一点细节,实际落地时的坑非常多:香烟在监控画面里往往只有几十个像素,属于典型的小目标检测;光照变化大,室内外、白天黑夜完全是不同的分布;手指夹着的笔、手机、打火机反光都容易造成误检。所以这个项目真正难的不是搭个框架,而是把数据、模型、部署三件事串起来。
如果你是想学YOLO目标检测的初学者,这项目是个特别好的完整案例;如果你是接安防、工地、加油站这类禁烟监控需求的技术人员,这里面的数据集制作思路和网页部署方案可以直接抄作业。下面我从需求拆解开始,一步步说清楚。
1. 需求拆解与方案选型
1.1 吸烟行为检测的本质是“小目标+时序动作”问题
先想清楚一个问题:我们到底要检测什么?很多人第一反应是检测香烟本身,这没错,但在监控场景里,单独检测香烟非常难。一根燃烧的香烟长度大约8厘米,在1080P画面、10米视距下,可能只占20乘20像素的区域,比人脸小得多。所以工业界普遍的做法是检测“手持香烟”这个整体语义,也就是把烟头、烟身和捏烟的手指一起框住,让模型学到“人手里拿着冒烟的细小物体”这个模式,而不是死抠烟头那几点像素。
这个思路决定了后续的标注标准。我在项目里把类别名定为smoke,标注时框选的是手掌附近、包含烟头烟身的小矩形,而不是整根香烟连带胸部。这样做有两个好处:第一,模型不需要区分具体烟支品牌,泛化能力更好;第二,检测框更集中,后续在网页端画框时不会因为框太大而显得识别“糊”。
另一个容易被忽略的点是“时序”。单帧检测必然有漏检和误检,比如有人拿笔在嘴边晃动,某一帧极像在吸烟。解决思路有两种:一种是在模型后面加一个轻量级动作分类器,把连续若干帧的特征串起来判断;另一种更简单的做法是后端做一个时间窗口统计,只在连续N帧中都检测到smoke时才算一次有效告警。对于禁烟监控这类场景,第二种方法性价比更高,也更容易跟客户解释。
1.2 为什么选择YOLO系列而不是其他算法
检测方案不是没别的选择。两阶段检测器Faster R-CNN精度高,但推理速度在视频流上不尽如人意;基于Transformer的DeTR、RT-DETR精度也不差,但训练显存占用大、部署链路长,对小目标的优化不如YOLO成熟落地。在监控场景里,我们通常需要一台普通GPU服务器同时处理多路视频,推理速度是第一优先级,YOLO这种单阶段检测器在速度与精度之间平衡得最好。
标题里提到YOLOv8/v7/v6/v5四个版本,我全都跑过一遍,说下实际感受:
| 版本 | 特点 | 适合场景 | 部署便利性 |
|---|---|---|---|
| YOLOv5 | 生态最成熟、教程多、PTQ量化资料多 | 新手学习、老设备CPU部署 | pytorch导出onnx顺手,TensorRT有大量现成案例 |
| YOLOv6 | 美团开源,工业部署优化好,推理速度快 | 高并发服务端推理 | 量化感知训练支持不错,但更新节奏慢 |
| YOLOv7 | 训练技巧丰富,精度在同等算力下略高 | 追求精度的离线分析 | E2E版本可去掉NMS,但环境兼容坑比较多 |
| YOLOv8 | ultralytics官方维护,训练/导出/推理API统一 | 新项目首选、快速迭代 | 导出onnx/tflite/tensorrt一条命令,省心 |
我的建议是:如果不是要复现老项目,新项目直接上YOLOv8;如果边缘设备算力特别紧张,可以用YOLOv8n(nano版)代替YOLOv5s;如果客户明说了必须跑在已有的老推理框架里,再考虑YOLOv5。v7虽然精度不错,但PyTorch版本一升级就容易报错,调试成本不低。
1.3 网页版架构的整体规划
网页版我采用了最常见的B/S架构:浏览器做展示和交互,后端用Python Flask提供HTTP接口,YOLO模型作为推理核心。流程是:摄像头RTSP流或上传的图片/视频进入后端,后端抽帧后交给模型推理,把检测框坐标和置信度返回前端,前端在画面上框出目标并触发告警提示。
为什么不用桌面客户端?因为实际客户往往是安保中心的多个人同时看监控,浏览器零部署、跨平台,领导还方便在大屏上查看。而且Flask写接口加前端页面,一个人两三天就能搞定,比开发桌面程序省太多事。如果后续要接现有安防平台,只需要把HTTP接口对接给平台方,改动成本也很低。
2. 训练数据集:精挑细选比盲目堆量重要
2.1 数据来源与标注规范
训练数据是这项目里最花时间的环节,我前后整理了两周才凑出像样的数据集。来源主要有三块:一是公开目标检测数据集里的吸烟场景图片,比如一些烟火识别比赛数据里有佩戴防毒面具和吸烟的画面,可以筛选出来;二是自己在办公室、厂房走廊、地下车库等场景用手机和摄像头拍摄的模拟吸烟画面,这部分数据虽然量不大,但和真实监控视角最接近,价值最高;三是网上能找到的零散禁烟宣传图、新闻配图,这类图片分辨率高、场景杂,可以作为补充。
关于标注数量,我踩过的坑是:别一上来就想标一万张。单类别检测,起步1000到2000个标注实例就能训练出一个可用的模型,但必须保证场景多样性。你可以这样分配:室内固定监控视角500张、室外白天300张、夜间或暗光200张、逆光150张、远距离小目标200张,剩下的再随机补充杂项。这比同样3000张但全是同一个办公室的画面有用得多。
标注规范上,一句话总结:框住“手+烟头”的小区域,类别统一为smoke。多人同时吸烟也要逐个标注,不要漏框。如果有拿笔、拿手机、吃东西的负样本,单独放在另一个目录,不标注,作为背景图参与训练。这里注意,负样本一定不要打标签,YOLO训练时会把没有标注文件的图片当作纯背景参与负采样。
2.2 标注工具与格式转换细节
YOLO训练需要的标签格式是txt文件,每一行代表一个目标,格式为:
class_id x_center y_center width height四个坐标值都是归一化到0到1的小数。比如一张宽1920像素、高1080像素的图片,某个烟盒检测框中心在x=960、y=540,宽120、高80,那对应的一行就是:
0 0.5 0.5 0.0625 0.07407手工算太容易错,所以我用X-AnyLabeling做标注。这工具可以直接输出YOLO格式,还自带一个辅助自动标注模型,可以先让模型预打框,我再手动修正,速度至少快三倍。LabelImg是老牌选择,但界面多年没更新,高分屏下体验一般。Roboflow在线标注也行,但图片上传下载流程在国外服务器上速度不稳定,数据敏感的项目不建议用。
标注完一定要做一轮清洗:打开标签文件,检查有没有坐标值大于1或小于0的异常数据;逐类统计图片数量,防止某个类别图片过少;随机抽100张图人工复核框的位置和大小,这一步能避免训出来的模型学到错误特征。清洗完成后,按9比1切成train和val目录,目录结构如下:
datasets/smoke/ ├── images/ │ ├── train/ │ └── val/ └── labels/ ├── train/ └── val/2.3 数据增强与负样本策略
YOLOv8在训练时会默认开启Mosaic、随机仿射、HSV色彩空间增强等策略,这些能显著提升模型对光照和角度的泛化能力。但对吸烟检测这种小目标场景,有几个增强参数需要单独调。旋转角度我限制在正负15度以内,角度太大会把香烟旋转成几乎竖直朝下的奇怪姿态,反而增加学习难度。HSV饱和度增强可以保留,但亮度增强幅度不要过大,否则暗光数据会被增强成过曝画面,影响模型对夜间场景的判别。
负样本是我的重点补充项。这个项目里误检来源特别明确:人手夹笔、夹手机、拿打火机点烟前的手势、甚至夹着棒棒糖,都会诱发模型在某种程度上“觉得像”。我的策略是准备300到500张这种高度相似的负样本图,不标注任何目标,混入训练数据。这样模型在训练过程中会看到大量“看似有烟但没标签”的背景,被迫学会区分细腻特征。实测这个操作能把误检率从每百帧七八次压到一两次,比单纯调置信度阈值有效得多。
3. 训练模型:参数、曲线与版本对比
3.1 环境配置与硬件选型
训练环境我建议用conda建虚拟环境,避免把系统Python搞乱。YOLOv8的安装最简单,一条pip命令解决:
conda create -n yolo-smoke python=3.10 -y conda activate yolo-smoke pip install ultralytics如果机器是NVIDIA显卡,还需要安装对应版本的PyTorch。以CUDA 11.8为例:
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118硬件方面,我的训练机是RTX 3060 12G,训练yolov8s,batch size设到16、imgsz设到640,显存占用大约8G,一张卡足够。如果只有CPU,也不是不能跑,但一个epoch可能要好几分钟,建议要么用yolov8n小模型,要么直接上云GPU。我用过的AutoDL、矩池云这类云GPU平台,按小时计费,训练一轮100个epoch大概几十块钱,比自己买卡划算。
3.2 训练参数逐项拆解
以YOLOv8为例,训练命令如下:
yolo detect train \ data=smoke.yaml \ model=yolov8s.pt \ epochs=100 \ imgsz=640 \ batch=16 \ device=0 \ patience=20 \ project=run_smoke \ name=exp1这里几个参数值得展开说。epochs=100是我在中小数据集上的默认值,配合patience=20早停机制,如果验证集指标连续20个epoch没有提升,训练会自动停止,省时间也防止过拟合。imgsz对小目标检测非常关键,640是速度和精度的平衡点;如果你发现监控画面里烟头目标很小,可以试768甚至960,精度会提升但推理变慢,需要自己权衡。
model=yolov8s.pt表示加载官方预训练权重做迁移学习。不建议从零训练,预训练模型已经学会了大量通用视觉特征,我们在它的基础上微调,5000多张图也能收敛得很快。训练数据配置smoke.yaml内容如下:
path: ./datasets/smoke train: images/train val: images/val names: 0: smoke注意path最好写绝对路径,或者使用相对路径时确保你在项目根目录下运行命令,否则YOLO很容易报找不到图片的错误。另外记得在配置文件里设nc: 1来覆盖类别数,不然会沿用预训练模型的80类,导致最后的输出维度对不上。
3.3 YOLOv5/v6/v7/v8训练差异与实测对比
这四个版本我都跑过同样的数据集,训练方式有一些差异。YOLOv5需要clone官方仓库到本地,然后用train.py训练:
git clone https://github.com/ultralytics/yolov5 cd yolov5 pip install -r requirements.txt python train.py --img 640 --batch 16 --epochs 100 \ --data ../datasets/smoke/smoke.yaml --weights yolov5s.ptYOLOv6是美团开源的,仓库结构相对独立,训练入口是tools/train.py,数据集格式也是YOLO格式,但需要在配置里指定和YOLOv5略有不同的数据字段,稍微麻烦一点。YOLOv7的环境要求比较敏感,我遇到过PyTorch 2.0以上版本跑v7训练时模型参数初始化类型不匹配的问题,后来固定用PyTorch 1.13才稳定下来。如果是新项目,我还是推荐YOLOv8,它的命令行设计和配置管理最规范。
对同一批5000张数据训练100个epoch,我得到的实测数据大概是这样(RTX 3060单卡推理,640分辨率):
| 模型 | 参数量 | mAP50 | 推理耗时(ms/帧) |
|---|---|---|---|
| YOLOv5s | 7.2M | 86.1% | 8.2 |
| YOLOv6s | 17.2M | 87.0% | 7.5 |
| YOLOv7-tiny | 6.2M | 84.5% | 8.8 |
| YOLOv8s | 11.2M | 87.4% | 7.8 |
差异不算大,YOLOv8s略胜一筹。如果你的设备是Jetson、树莓派这类边缘盒子,YOLOv8n或YOLOv5s更合适,模型体积小一半,速度更快,损失的精度在小目标检测上没有想象中那么大。
3.4 从损失曲线和指标看训练效果
训练结束后,run_smoke/exp1目录下会生成results.png,里面有box_loss、cls_loss、dfl_loss三条损失曲线,以及mAP50、mAP50-95、precision、recall四个指标曲线。先看损失曲线是否收敛,正常情况是前20个epoch下降很快,后面趋于平缓,训练末期的loss值不再大起大落。如果val损失在后期明显回升而train损失还在下降,说明过拟合了,要回到数据层面增加样本或增强,而不是继续拉长epoch。
再看精度和召回率。吸烟检测这个场景,我宁可牺牲一点精确率也要保住召回率,理由很简单:漏掉一次吸烟行为可能引发安全事故,而误报一次顶多是广播提示工作人员去现场看一眼。所以我在网页端设置置信度阈值时,通常用0.3到0.35,而不是默认的0.5。对应的precision和recall数值,我的模型能达到precision约88%、recall约82%,mAP50约87%,mAP50-95在62%左右,够用了。
4. 网页版系统实现与部署
4.1 后端接口设计:Flask快速搭建检测服务
我选择Flask而不是FastAPI,主要考虑是Flask生态太成熟了,踩坑成本低,对方即使不会Python也能看懂逻辑。核心代码很简洁,模型加载到全局变量,避免每个请求都重新加载:
from flask import Flask, request, jsonify import cv2 import numpy as np from ultralytics import YOLO app = Flask(__name__) model = YOLO("best.pt") # 训练好的权重 @app.route("/detect", methods=["POST"]) def detect(): file = request.files.get("image") if file is None: return jsonify({"error": "no image"}), 400 data = np.frombuffer(file.read(), np.uint8) img = cv2.imdecode(data, cv2.IMREAD_COLOR) results = model(img, conf=0.3, iou=0.5, verbose=False) boxes = results[0].boxes detections = [] for box in boxes: x1, y1, x2, y2 = box.xyxy[0].tolist() conf = float(box.conf[0]) detections.append({"bbox": [int(x1), int(y1), int(x2), int(y2)], "conf": round(conf, 4)}) return jsonify({"count": len(detections), "detections": detections}) if __name__ == "__main__": app.run(host="0.0.0.0", port=8000)这里有个容易忽略的点:model()内部如果传了verbose=True,每个请求都会打印推理日志,大量并发时控制台会被刷爆,CPU也被无谓占用。另外Flask自带的开发服务器不适合生产,建议用gunicorn部署:
gunicorn -w 2 -b 0.0.0.0:8000 app:app注意worker数不要设太多,2到4个就够,因为每个worker都会加载一个模型副本到显存里,设多了显存直接爆。
4.2 视频流实时检测的实现方式
图片检测接口只能处理单张图,监控场景需要视频流。我做了两种模式。第一种是H.264视频流抽帧,后端用OpenCV打开RTSP流地址:
import cv2 import threading from collections import deque cap = cv2.VideoCapture("rtsp://admin:password@192.168.1.100:554/stream1") frame_queue = deque(maxlen=1) lock = threading.Lock() def read_frames(): while True: ret, frame = cap.read() if not ret: cap.set(cv2.CAP_PROP_POS_FRAMES, 0) continue with lock: frame_queue.append(frame) threading.Thread(target=read_frames, daemon=True).start()后台线程持续拉流并保存最新一帧,避免主线程阻塞在网络读取上。前端视频展示我用的方式是MJPEG流式输出:
@app.route("/video_feed") def video_feed(): def generate(): while True: with lock: if len(frame_queue) == 0: continue frame = frame_queue[-1].copy() results = model(frame, conf=0.3, verbose=False) annotated = results[0].plot() ret, jpg = cv2.imencode(".jpg", annotated) yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + jpg.tobytes() + b"\r\n") return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame")这段代码虽然简单,但效果直观,浏览器直接<img src="/video_feed">就能看到实时标注画面。缺点是对模型推理耗时要求高,如果一帧推理超过1秒,视频会明显卡顿。所以我在generate里只处理最新帧,如果队列里已有更新的帧,旧帧直接丢弃,保证画面是实时的而不是排队处理历史。
第二种视频检测方式是本地视频或图片上传,前端用<input type="file">传文件,后端逐帧或间隔抽帧检测,再把结果用HTML展示。这种适合甲方手里有一段监控录像,让你帮忙分析里面有没有人抽烟的场景,实际项目里经常遇到。
4.3 前端页面与交互设计
前端我没有用大型框架,纯HTML+JavaScript+一点CSS就够。页面分三块区域:左上角是视频实时预览,检测框直接画在画面上;右侧是告警列表,每次检测到smoke就把时间、置信度、截图存下来推送到列表里;底部是上传入口,支持图片和视频。
最关键的一个交互设计是“告警去重”。因为视频流是连续帧检测,同一人吸烟可能连续几十帧都命中,如果每帧都往告警列表里插一条,几分钟列表就爆了。我的做法是设置一个10秒钟的时间窗,同一个摄像头在10秒内只记录一次有效告警,后续命中只更新告警列表里该条记录的“最近命中时间”。这样既保留了完整证据链,又不会刷屏。
告警记录存到SQLite里,一条表结构大概是这样:
CREATE TABLE alarm_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, camera_name TEXT, detect_time TEXT, conf REAL, snapshot_path TEXT );每天凌晨自动清理30天前的记录并打包导出,方便客户留档。
4.4 推理性能优化清单
模型训练完,推理性能直接决定系统能带几路摄像头。我在这个项目里做了四层优化,加起来效果非常明显。
第一层是模型导出。训练好的.pt权重可以直接用,但YOLOv8还支持一键导出成ONNX格式:
yolo export model=best.pt format=onnx opset=12ONNX格式配合onnxruntime推理,比PyTorch原生推理在CPU上能快30%以上。我实测同一台机器,PyTorch推理每帧65毫秒,ONNX推理每帧45毫秒。如果你有NVIDIA显卡,可以继续导出TensorRT:
yolo export model=best.pt format=engine device=0 half=TrueTensorRT在GPU上的加速更大,我的RTX 3060上可以把推理压到12毫秒左右。
第二层是输入尺寸动态化。YOLOv8的imgsz在推理时可以动态指定,比如24小时监控场景,白天光线好可以用640,夜间暗光场景因为目标特征弱、噪声大,可以动态切成768。代价是模型每次切换输入尺寸会多一次环境初始化,所以实际做法是每隔5分钟根据环境光传感器或图像灰度均值做一次尺寸决策,而不是每帧都切换。
第三层是帧采样。很多吸烟动作持续时间至少在3秒以上,如果视频是25fps,完全没必要每帧都推理。我把检测频率设在5fps,也就是每5帧取一帧检测,已经足够覆盖绝大多数吸烟行为。如果摄像头数量多,这个参数还可以拉低到2fps,能大幅降低GPU占用,只是对持续时间短的吸烟动作不够敏感,需要根据现场情况权衡。
第四层是批量推理。如果后端同时接4路摄像头,把4路的帧拼成一个batch,一次推理出4个结果,比单个依次推理吞吐量翻倍。YOLOv8的Python接口天然支持传入列表,实际这样做:
frames = [frames_cam1, frames_cam2, frames_cam3, frames_cam4] results = model(frames, conf=0.3, verbose=False)执行下来4路视频推理总消耗相当于单路推理的1.8倍左右,省了一半以上的GPU资源。
5. 常见问题与排错实录
5.1 训练阶段典型问题速查
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 训练时报“CUDA out of memory” | batch size或imgsz过大 | 把batch从16降到8或4,imgsz降到640,或改用更小模型 |
| loss为NaN且不下降 | 学习率过大或数据里有异常值 | 把lr0从0.01降到0.001,检查图片是否有损坏文件 |
| 验证集mAP一直为0 | val目录里没有真值标签 | 检查labels/val目录是否存在且文件内容非空 |
| 训练完检测不到烟头 | 训练图片里烟的尺寸太小导致特征学不到 | 用768/960分辨率训练,或使用带P2层的yolov8s-p2.yaml |
| 检测框乱跳或漏检 | 数据标注框太大,语义不纯 | 重新框选“手+烟头”区域,不要框整个上半身 |
| 负样本被识别成smoke | 误检样本在训练时占比过低 | 采集大量“拿笔、拿手机、吃东西”的负样本图,不加标签放入训练集 |
其中显存不足是个高频问题。我的经验是先把batch降到能跑起来为止,然后把imgsz从640降到512,看mAP掉多少。如果掉得不多,说明你的目标本身不算特别小,以后就用512推理,速度还快。如果掉得厉害,说明检测目标确实小,只能换分辨率更高或带P2层的模型。
还有一个我认为新手最容易踩的坑:用torch.load直接加载.pt文件来做推理,然后报各种版本错误。YOLOv8的.pt文件包含的不只是模型权重,还有配置和训练信息,用YOLO("best.pt")这种方式加载才是对的,不要手动拆权重。
5.2 部署阶段典型问题速查
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 网页打开后一直转圈不显示视频 | RTSP流地址无法访问 | 先在服务端用VLC或ffmpeg测试地址是否为局域网IP,需设置摄像头码流为H.264 |
| 上传图片检测报错500 | OpenCV不认中文路径 | 用np.fromfile(file, dtype=np.uint8)替代cv2.imread读图 |
| 推理很慢CPU占用100% | 用的PyTorch推理而非ONNX | 导出ONNX并用onnxruntime跑推理,速度提升明显 |
| gunicorn启动后一直报警告 | worker加载模型复制到显存占资源 | 减少worker数到2个,或在preload_app=True里预先加载模型 |
| 浏览器无法访问接口 | 服务器防火墙限制 | 开放8000端口,或者用Nginx反向代理到内网地址 |
| Flask返回JSON里含NaN | 置信度或坐标算出无效值 | 检查模型推理结果是否为Pytorch tensor,需要.tolist()转换后再序列化 |
部署阶段还有个很隐蔽的问题:OpenCV的imshow调试窗口会和Flask线程冲突,导致程序莫名卡死。排查了半天才发现是某段残留代码里调用了cv2.waitKey(0),把事件循环阻塞了。这种代码在正式服务里一定要删干净,调试窗口只适合本地跑GUI的环境。
另外,网页版如果部署在云服务器上,一定要在接口层做一个简单的Token认证。否则监控地址一旦泄露,任何知道接口地址的人都能调用你的检测服务,虽然不涉及隐私数据,但会白白消耗你服务器的算力。我用了一个最简单的方案:在Flask里加一个装饰器,校验请求头里的X-Api-Key字段。
5.3 检测效果不理想时的调优思路
如果训练不出满意的效果,第一步永远不是换模型,而是回去看数据。我总结出一个三不换原则:数据集质量不过关不换模型,训练曲线没看明白不换模型,硬件瓶颈没排除不换模型。换了模型结果也不会好,只会浪费更多时间。
具体调到什么程度算可以?我给自己定了个指标:在真实监控录像上测试,漏检率不超过10%,误检率不超过每5分钟1次。如果漏检率高,优先增加对应场景的数据。比如夜间漏检多,就多拍夜间画面,而不是盲目加所有场景的图。如果误检率高,就把误检的截图收集起来标注成负样本,丢到训练集里重新训练一轮。这个“错误分析-数据补充-重训”的循环,一般做两三轮就能看到明显改善。
还有一个经验值得单独说:监控画面中,距离摄像头远的小目标很难检测,但如果摄像头支持数字变焦或可以调整安装角度,尽量让吸烟高发区域占据画面更多像素。算法再强也受限于输入分辨率,这是物理规律,不是调参能解决的问题。能调整现场机位,永远比后处理调参性价比高。
我后续在这个项目上的扩展想法,是把检测结果和现场广播系统联动。后端检测到smoke后,除了在网页上告警,还自动给现场喇叭发一条播放指令,提示“禁止吸烟”。这需要对接现场的广播控制协议,难度不大,但价值很高。这类从“前端识别”到“业务闭环”的需求,才是这类AI项目真正能交付给客户的核心价值。