简介:基于YOLOv9的监控场景玩手机检测系统,提供完整源码、训练好的模型权重与指标曲线,适用于计算机相关专业学生完成毕业设计,或企业进行员工行为规范检测。资源包含详细运行教程,从环境配置到数据准备再到训练测试均有说明,并支持替换为自定义数据集训练。压缩包共192个文件,以Python脚本(83个py)、YOLO配置文件(30个yaml)、图像样本(33个jpg)及模型权重(3个pt)为主,附带标签文件、可视化图片和训练图表,整体容量约75.25MB。目前已有161人学习下载。除可直接运行的检测系统外,还提供重参数化脚本、训练指标记录与测试图片,便于复现训练过程、分析模型精度。用户按照教程修改配置参数即可快速训练自己的数据集,适合作为目标检测方向的项目实践或毕业设计参考。
1. 监控场景玩手机检测:为什么这个项目把YOLOv9、模型和指标曲线打包在一起
先说一句实话:在我经手的车间、机房、办公区监控项目里,“检测玩手机”从来不是单纯的目标检测问题。你真正要回答的是三件事——画面里有没有手机、手机是不是拿在人手上、这个动作持续了多久。这个标题给的YOLOv9 + Python源码 + 详细运行教程 + 模型 + 指标曲线,指向的是一条从“有监控视频”到“能跟领导交差”的完整链路:自己标数据、训练出模型、拿指标曲线证明效果、再把它接到现有监控系统里。它适合手头有Python和PyTorch基础、想独立完成整个交付而不是调个现成接口的人。下文按数据准备、模型训练、指标判读、部署避坑、行为判定五个环节展开,全是我实际跑这类项目的路径。
2. 训练数据从哪来:监控抽帧、标注规范与数据集划分的完整链路
2.1 先想清楚“玩手机”在监控画面里的检测目标是什么
很多第一次做这个项目的人上来就标“人拿着手机”这个整体动作,标注工具里画一个大框把人和手机全框进去。这么做的后果是:模型学到的是“一个坐着的人”而不是“手机”,换一个场景、换一个坐姿,误检率直接失控。
我一般建议只标两类东西,甚至只标一类:手机本体。检测目标是phone,后续部署时再用位置关系和时序来判定“是否在玩手机”,而不是让检测模型一步到位理解行为。这样标注成本低、正负样本好控制、模型泛化也稳。原因很简单——监控场景里人的姿态千变万化,但手机的外形相对稳定,目标检测器擅长的是稳定的外观,不是抽象的行为。
2.2 监控视频抽帧:不是每帧都要留,抽帧策略决定数据多样性
数据来源通常是一段段监控录像,几十分钟的视频如果全部按帧保存,一张1080p的图就要两三兆,一个小时就是几十万张图,硬盘先撑不住。更关键的是相邻帧高度相似,喂给模型全是重复样本,训练反而容易过拟合。
我一般按时间间隔抽帧,间隔取 1.5 到 2 秒一帧。这个间隔既能覆盖动作变化,又不会产生大量重复帧。抽帧时还要留意覆盖度:白天、夜晚、逆光、走廊尽头、摄像头俯视角度,这些在监控场景里差异巨大,最好在抽帧前先把视频按光线和位置分类。
# extract_frames.py # 依赖:pip install opencv-python import cv2 import os video_path = "monitor_camera_01_20250210.mp4" out_dir = "raw_frames" os.makedirs(out_dir, exist_ok=True) cap = cv2.VideoCapture(video_path) fps = cap.get(cv2.CAP_PROP_FPS) # 视频帧率,一般监控是25 interval = int(fps * 1.5) # 每1.5秒抽一帧,可调 frame_id = 0 save_id = 0 while True: ret, frame = cap.read() if not ret: break if frame_id % interval == 0: # 文件名带上视频时间戳,后期回查原始片段时能精确定位 ts = int(cap.get(cv2.CAP_PROP_POS_MSEC)) cv2.imwrite(f"{out_dir}/cam01_{save_id:06d}_{ts}ms.jpg", frame) save_id += 1 frame_id += 1 cap.release() print(f"抽取完成,共 {save_id} 帧")这段代码的逻辑很简单:按帧序号取模决定是否保存,用CAP_PROP_POS_MSEC取当前帧在视频里的毫秒时间戳写到文件名里。后面标注或者排查误检时,拿着文件名里的时间戳直接回原始录像,能省大量找素材的时间。interval是采样间隔,根据你要的数据量调整——目标数据量除以预期可用视频时长,反推出间隔秒数。
抽完帧后要人工筛一遍,把画面里没有人的、镜头被遮挡的、完全模糊的帧删掉。这一步看着费时间,但比之后反复训练返工划算得多。
2.3 标注规范:什么算玩手机,什么不算,边界必须先定死
标注是这类项目里最“玄学”也最影响结果的一环。两三个人标同一个视频,标准不统一,模型学到的东西就是乱的。
我常用的标注规范是这么定的:
- 手机拿在手上、哪怕没有看屏幕,也算正样本;
- 手机放在桌上、插在兜里、放在支架上,不标;
- 手机被手完全挡住只露出一角,不标;
- 只露脸、看不到手机,不标;
- 连续帧里同一部手机只标一次,不重复框相似外观。
这个规范的重点是:把“手机在手上”作为唯一正样本标准。原因是业务侧真正关心的就是“手上有没有手机”,桌面手机不告警才符合现场管理预期。
标注工具用常见的 labelimg 或者 labelme 都可以,输出为 VOC 格式的 XML 比较通用,后续转 YOLO 格式方便。如果团队人少,宁可花两天把规范讲透,也不要让三个人用三套自由发挥的标准去标。
2.4 把VOC标注转成YOLO格式:归一化是第一个坑
YOLOv9 训练要求每张图片配一个同名 txt 文件,每行类别id x_center y_center width height,坐标全部归一化到 0~1 之间。VOC 的 XML 存的是绝对坐标,所以必须做转换。
# voc_to_yolo.py import xml.etree.ElementTree as ET import os def convert_annotation(xml_path, out_txt_path, class_map): tree = ET.parse(xml_path) root = tree.getroot() # 读取图片真实宽高,归一化必须用这个,不能用网络输入尺寸 size = root.find('size') img_w = int(size.find('width').text) img_h = int(size.find('height').text) lines = [] for obj in root.iter('object'): name = obj.find('name').text if name not in class_map: continue cls_id = class_map[name] box = obj.find('bndbox') x1 = float(box.find('xmin').text) y1 = float(box.find('ymin').text) x2 = float(box.find('xmax').text) y2 = float(box.find('ymax').text) # 转中心点坐标 + 宽高,并除以图片真实尺寸 x_center = (x1 + x2) / 2 / img_w y_center = (y1 + y2) / 2 / img_h w = (x2 - x1) / img_w h = (y2 - y1) / img_h # 越界坐标截断到0~1,防止训练时出错 x_center = min(max(x_center, 0.0), 1.0) y_center = min(max(y_center, 0.0), 1.0) w = min(max(w, 0.0), 1.0) h = min(max(h, 0.0), 1.0) lines.append(f"{cls_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") with open(out_txt_path, 'w') as f: f.write('\n'.join(lines)) class_map = {'phone': 0}转换脚本的核心就一句话:归一化的分母是图片真实宽高。我见过不止一次有人把分母写成 640(网络输入尺寸),导致训练时所有框全部错位,损失曲线一路不降,查了三天才发现是坐标转换的问题。
2.5 数据集划分:同一段视频的帧不能同时进训练集和验证集
划分数据集是看着简单、实际最容易翻车的环节。标准做法是按比例 8:1:1 拆成训练、验证、测试三组,但很多人的 split 脚本是随机打乱所有图片后切分,这就埋了一个雷:同一段监控视频里连续抽出来的帧,画面高度相似,逻辑上就是同一个人同一部手机,同时出现在训练集和验证集里,验证集的指标会虚高,看起来很漂亮,现场一跑就露馅。
正确的做法是先按视频片段分组,再把整个视频的帧分到同一边,绝不能把同一段视频的帧随机拆开。
# split_dataset.py import os import random import shutil image_dir = "labeled_images" # 所有jpg + txt_dir = "labeled_images" # 同名txt train_dir = "dataset/images/train" val_dir = "dataset/images/val" test_dir = "dataset/images/test" # 按视频来源分组:文件名前缀cam01/cam02代表不同视频片段 video_groups = {} for fname in os.listdir(image_dir): if fname.endswith(".jpg"): prefix = fname.split("_")[0] # 例如cam01 video_groups.setdefault(prefix, []).append(fname) all_prefixes = list(video_groups.keys()) random.shuffle(all_prefixes) n = len(all_prefixes) train_prefixes = set(all_prefixes[:int(n * 0.8)]) val_prefixes = set(all_prefixes[int(n * 0.8):int(n * 0.9)]) for prefix, files in video_groups.items(): target = None if prefix in train_prefixes: target = train_dir elif prefix in val_prefixes: target = val_dir else: continue # 测试集不在训练阶段使用 for fname in files: os.makedirs(target, exist_ok=True) shutil.move(os.path.join(image_dir, fname), os.path.join(target, fname)) shutil.move(os.path.join(txt_dir, fname.replace(".jpg", ".txt")), os.path.join(target, fname.replace(".jpg", ".txt")))这段脚本的关键是prefix分组逻辑:每个摄像头拍的一段视频,抽出的帧都带着同一个前缀,按前缀整体切分,确保验证集里出现的都是“没见过的画面”。标注数据不足时宁可通过数据增强来补充,也别靠打散同源帧来凑指标。
3. 把YOLOv9训起来:Python环境配置、训练命令与三个必调参数
3.1 Python、PyTorch与YOLOv9的依赖安装顺序
这一步看着基础,实际上很多人一上来就在环境上卡了两三天。常见问题是 Python 版本和 PyTorch 版本不匹配、CUDA 装好了但 PyTorch 编译的是 CPU 版本、跑训练时报No module named 'torch'。
我的固定套路是:先装 Python 3.10 或 3.11(不要用最新的 3.13),然后用 pip 安装 PyTorch 的 CUDA 版本,再装 YOLOv9 的依赖。安装顺序不能反,如果先装依赖再装 torch,某些依赖会把 torch 解析成 CPU 版。
# 1. 创建虚拟环境,避免污染系统Python python -m venv yolo_env source yolo_env/bin/activate # Windows下是 yolo_env\Scripts\activate # 2. 安装PyTorch CUDA版,注意先确认自己的CUDA版本,nvcc -V查看 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 # 3. 安装YOLOv9项目依赖 pip install -r requirements.txt # 项目自带的依赖清单 # 4. 验证GPU可用性,这一步必须执行 python -c "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_name(0))"最后一行是验货命令:输出必须是True RTX 4090之类的,如果输出False,说明 torch 装成了 CPU 版,重装--index-url指定 CUDA 版本的轮子即可。跑训练前花三分钟验证这一步,能避免后面所有报错都往代码里找的弯路。
Python 环境相关的报错里,最气人的是“torch能 import 但torch.cuda.is_available()是 False”。原因基本可以锁定为 PyTorch 安装源没指定 CUDA,解决方式就是上面第 2 步重装,不用动 CUDA 驱动。
3.2 数据配置与最小训练命令
数据准备好后,要写一个 data.yaml 告诉 YOLOv9 数据集在哪、有几类、类名叫什么。文件路径建议全部用绝对路径,相对路径在不同机器上跑会莫名报文件找不到。
# data.yaml # YOLOv9训练时通过--data指定这个文件 path: /home/user/phone_dataset # 数据集根目录,绝对路径 train: images/train # 训练集图片目录 val: images/val # 验证集图片目录 nc: 1 # 类别数量:只检测手机 names: ['phone'] # 类别名,0对应phone然后启动训练,最小命令是这个形式:
python train.py \ --data data.yaml \ --epochs 100 \ --batch-size 16 \ --img 640 \ --device 0 \ --workers 4这里--img 640是输入分辨率,--batch-size 16是显存允许范围内越大越好,--device 0是使用第一张 GPU。训练启动后终端会输出每一轮的 mAP、损失值,还会在runs/train/exp目录生成训练日志。第一次跑建议只做 50 个 epoch 试跑,确认流程通、损失在降,再跑完整训练,避免配错了数据集白等几个小时。
3.3 监控小目标场景的三个必调参数
玩手机检测和通用物体检测有个明显区别:手机在监控画面里属于小目标。一张 1080p 的监控图缩到 640 输入后,手机可能只有 20×20 像素。这类场景有三个参数我是必调的:
第一个是--img。从 640 提到 960 或 1280,小目标特征保留明显变好。代价是显存占用涨两三倍,batch-size 要跟着降。我先用 640 跑一版看 baseline,再用 960 跑一版对比 mAP,涨幅超过 5 个点就值得。
第二个是 mosaic 增强。YOLOv9 默认开启 mosaic 拼接,把四张图拼一起训练,对一般目标效果好,但对手机这种小目标反而有害——图片被缩小四倍,手机变得更小,模型学不到有效特征。在数据量足够的情况下,建议在超参文件里关掉 mosaic,或者把--hyp指定的超参里mosaic设为 0。
第三个是--batch-size。显卡显存不够时,不要硬扛 OOM 报错,除了降 batch,还可以开--cache把图片预加载进内存,能明显降低 IO 瓶颈。但要注意内存占用,32G 内存的机器缓存几千张图没问题,别一次性塞几万张。
3.4 训练中断恢复与模型的后悔药
训练跑 50 个 epoch 要几个小时,中途断电或者显存炸了是常事。YOLOv9 支持断点续训,只要训练时保存了last.pt权重,中断后不用从头再来。
# 从last.pt继续训练,epochs改成剩余轮数 python train.py --data data.yaml --resume runs/train/exp/weights/last.pt注意--resume接的是权重文件路径,不是训练目录。恢复后普通训练会找一个last.pt同目录下或runs/train/exp里的训练状态继续。这里最容易踩的坑是恢复后 epoch 编号重置或者学习率重置,导致训练曲线出现断崖。解决办法是尽量用官方原版训练脚本,不要自己魔改保存逻辑。
4. 指标曲线怎么读:mAP50、PR曲线与损失曲线的验收标准
4.1 指标曲线文件在哪,先看哪张图
训练结束后,指标曲线不是只存在于终端滚动日志里,还会以图片和文本形式保存在训练输出目录。一般结构里能看到results.png、PR_curve.png、confusion_matrix.png这些图。这些文件是交付时的“证据链”,也是判断模型能不能用的直接依据。
先看results.png,它包含训练和验证的损失曲线、mAP50、mAP50-95、精度和召回率几个子图,一眼能看出训练是否收敛、有没有过拟合。很多人盯着终端日志里的 loss 数字看半天,不如直接打开这张图扫一眼趋势。
4.2 mAP50与mAP50-95:用哪个指标跟人交代
mAP50 是 IoU 阈值 0.5 时的平均精度,监控场景里一般 0.7 以上算能部署,0.8 以上算优秀。mAP50-95 是把 IoU 从 0.5 到 0.95 取平均,要求框的位置非常准,监控小目标上 0.4~0.5 已经相当不错,硬追 0.7 不现实。
实际交付时我一般两个都写在报告里,但对外主要讲 mAP50,因为它更直观——“十次检测里命中七八次”。mAP50-95 留给自己看定位精度,如果它远低于 mAP50,说明框的位置漂,部署时告警坐标不准,需要回头检查标注框是不是画得太松。
4.3 PR曲线:能看出误检压力在哪
PR 曲线横轴是召回率,纵轴是精度,一条曲线代表一个类别(这里只有 phone 一类)。越靠近右上角,模型越理想。重点看曲线下降的形状:如果在高召回区域(右边)精度快速掉到 0,说明提高召回率会带来大量误检;如果曲线偏平,说明精确率和召回率之间平衡得好。
部署到真实监控时,我经常把置信度阈值往上抬(从 0.25 提到 0.4 左右),这时 PR 曲线右上角部分就是决策参考——看阈值抬到这个位置后精度能上去多少、召回掉多少。如果曲线在右上角掉得厉害,说明模型在这种数据上本身偏弱,光调阈值救不回来。
4.4 损失曲线:过拟合和欠拟合看这三条线
YOLOv9 训练日志里常见的损失分量有三种:box_loss(定位损失)、cls_loss(分类损失)、dfl_loss(分布焦点损失,用于框回归细化)。results.png里训练集和验证集各画一条,重点看验证集那组。
正常收敛的表现:训练和验证的损失同步下降,最后趋于平缓,验证损失不反弹。如果验证损失降到底部后明显抬头上升,而训练损失还在降,这是过拟合的教科书信号。解决办法不是加数据,而是先加数据增强、开 dropout(如果支持)或者把 epoch 截断在验证损失最低点——这也是导出模型时选best.pt而不是last.pt的原因。
下面是我常用的验收参考线,单位不做换算,直接看相对变化:
| 指标 | 参考值 | 说人话的解释 |
|---|---|---|
| mAP50 | ≥ 0.70 | 十次检测能命中七次以上,基本可用 |
| mAP50-95 | 0.35~0.50 | 框的位置精度,监控小目标够用 |
| 验证 loss | 平稳不再下降 | 收敛标志,继续训练收益有限 |
| 验证/训练损失差 | < 15% | 差距过大说明过拟合 |
指标这东西是最容易产生“只见树木”错觉的环节。我曾被一张 mAP50=0.85 的曲线骗上过线,拿到现场一测,误报多到值班员直接把告警关了。后来才明白:指标曲线只能证明“在验证集上表现好”,而验证集的构成是否贴近真实监控,完全取决于数据准备阶段的工作。
5. 部署到监控场景:视频流接入、抽帧检测与5条避坑记录
5.1 部署架构:RTSP拉流 + 抽帧 + 检测 + 告警
训练出来的best.pt只是模型,要接进监控系统还需要一个推理服务。监控摄像头普遍走 RTSP 协议,部署端要做的事是:不断从摄像头拉流、按固定间隔取帧、送入模型检测、命中后触发告警。
推理框架常见做法有两个:一是直接用训练框架的推理脚本来加载best.pt推理;二是先把权重导出成 ONNX,再用 OpenCV 的 DNN 模块加载,省去 PyTorch 依赖。对部署到现场工控机上的场景,我一般用 ONNX 路线,因为工控机上不一定有完整 Python 训练环境,且 ONNX 推理更快。
# deploy_onnx.py import cv2 import numpy as np import onnxruntime as ort # 加载ONNX模型,优先用GPU,没有就CPU sess = ort.InferenceSession( "best.onnx", providers=['CUDAExecutionProvider', 'CPUExecutionProvider'] ) input_name = sess.get_inputs()[0].name input_size = 640 # 和训练时的--img保持一致 # 抠出检测阈值参数,先用保守值上线再调 CONF_THRESHOLD = 0.40 IOU_THRESHOLD = 0.45 cap = cv2.VideoCapture("rtsp://192.168.1.100:554/stream1") fps = cap.get(cv2.CAP_PROP_FPS) frame_skip = max(int(fps * 1.5), 1) # 每1.5秒取一帧,避免连续重复检测 frame_id = 0 while True: ret, frame = cap.read() if not ret: break frame_id += 1 if frame_id % frame_skip != 0: continue # 预处理:缩放到网络输入尺寸,转RGB,归一化,调整维度顺序 img = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (input_size, input_size)) img = img.astype(np.float32) / 255.0 img = np.transpose(img, (2, 0, 1))[None, ...] # 变成1xCxHxW outputs = sess.run(None, {input_name: img})[0] # 后处理:YOLO输出需要解码框坐标并做NMS,这里省略具体解码函数 # 命中后写入告警日志和截图 cap.release()这段代码的部署逻辑里有三个关键取舍:一是抽帧间隔设 1.5 秒而不是每帧检测,监控里人玩手机的动作是持续性的,每帧检测浪费算力且告警刷屏;二是置信度阈值取 0.4 比训练默认的 0.25 高,宁可漏掉低置信度目标也要压低误报,告警疲劳比漏检更致命;三是 ONNX 推理的输入尺寸必须和训练尺寸一致,不一致会导致检测结果整体漂移。
5.2 置信度阈值和NMS参数怎么定
模型训练完给的是原始预测框和分数,你还要决定多高的分才“算数”。置信度阈值就是把这个门槛定下来。训练时默认 0.25 很激进,什么都敢报;生产环境我会先用 0.4 起步,跑一天真实监控看误报数量,再往 0.45、0.5 方向调。
NMS 的 IoU 阈值控制的是“两个重叠框算不算同一个目标”。0.45 是常见默认值,如果同一部手机经常被框出两个重合框,把 IoU 阈值往 0.5 或 0.55 调,合并得更干净。反过来,如果两部位手机靠得很近被合并成一个框,就要把 IoU 阈值调低到 0.4。这个参数没有万能值,跟你画面的俯视角度有关,拿真实监控截图调是最快路径。
5.3 让我返工最多的5个坑
第一条:小目标检不出来。现象是训练完 mAP 不低,但真到了监控画面上,距离远、目标小的手机根本不出框。原因是训练和推理都用 640 输入,手机在图上只有 20 像素。解决:把--img提到 960,同时把远处摄像头单独抽帧补充训练数据。
第二条:桌面手机疯狂误报成“手上手机”。现象是员工没碰手机,系统一直告警。原因是标注时把桌面手机和手上手机混在同一个类里,模型只学到了“画面里有手机就报”。解决:严格按 2.3 节的规范重新筛标注,只保留“手上持机”的正样本,桌面手机单独作为困难负样本加入训练。
第三条:训练 loss 不降反升。现象是 loss 曲线前几十个 epoch 纹丝不动,甚至上涨。数据检查发现,有的图对应的 txt 标注文件是空的,有的坐标越界到 10 以上。解决:训练前写脚本扫描所有标注文件,检查坐标范围、空标注、目标尺寸,全部清洗后再训练。
第四条:RTSP 拉流画面花屏、跳帧、检测卡死。现象是部署后运行几个小时,画面开始撕裂,检测结果延迟越来越大。原因是cap.read()是阻塞式的,网络抖动时主线程被卡住。解决:把拉流放到独立线程里,用队列缓冲最新帧,检测线程只管从队列取最新帧。
第五条:模型指标很好,现场告警还是被人骂。现象是 mAP50=0.85,值班员却说“误报比真报还多”。原因是训练集全是从上午的晴天视频抽的,现场是傍晚逆光画面。解决:这是数据覆盖问题,不是模型问题——回到 2.2 步按光线、角度、摄像头位置重新抽帧补样本,不要试图靠调阈值硬压。
6. 进阶:把单帧检测升级成时序判定,用一段真实视频验证可用性
单帧检测回答的是“这帧画面里有没有手机”,但业务要的是“这个人有没有在玩手机”。两者之间的鸿沟要靠时序判定来填。我常用的做法是给检测结果加一个“持续性”条件:同一个摄像头画面内,手机框连续 N 帧(比如连续 5 次抽帧,对应约 7.5 秒)都出现,才触发告警。这个简单策略能过滤掉“路过时手机划过画面”“拿起来看一眼又放下”这类瞬时动作,误报立刻少一半。
更进一步的方案是引入位置关系。监控画面里人坐着时,手机持在手上的位置通常在躯干中下方。部署推理时拿到人形框和手机框后,判断手机框中心是否落进人形框下 2/3 区域。可以用现成的行人检测模型先出人框,再做几何判断。位置关系能有效过滤“手机放在桌上被检测到”但人坐在另一边的情况。
验证一个模型能不能交付,我的做法是取一段真实监控视频——最好 30 分钟以上、包含白天和夜间各一段——全量抽帧跑一遍推理,统计三个数字:真实告警次数(人工确认)、误报次数、漏报次数。算出来的业务级准确率比 mAP 更有说服力,因为它直接对应值班员的体验。
这里有个我翻过车的教训想分享给你:最早我拿验证集的 PR 曲线当交付依据,被现场一顿投诉后老老实实做了业务级验证,才发现曲线右上角再好看,也架不住现场角度的千奇百怪。后来我再交付任何检测项目,都先问一句“有没有一段真实的、模型没见过监控视频”,拿它跑完业务验证才收手。这条路难走,但能让你少接一半的售后电话。希望这个完整链路能帮你把项目从能跑变成能用,在实际监控场景里站稳脚。
本文还有配套的精品资源,点击获取