基于YOLOv9的监控场景手机检测:从数据标注到部署的完整实践 简介基于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_okTrue) 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_okTrue) 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-950.35~0.50框的位置精度监控小目标够用验证 loss平稳不再下降收敛标志继续训练收益有限验证/训练损失差 15%差距过大说明过拟合指标这东西是最容易产生“只见树木”错觉的环节。我曾被一张 mAP500.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()是阻塞式的网络抖动时主线程被卡住。解决把拉流放到独立线程里用队列缓冲最新帧检测线程只管从队列取最新帧。第五条模型指标很好现场告警还是被人骂。现象是 mAP500.85值班员却说“误报比真报还多”。原因是训练集全是从上午的晴天视频抽的现场是傍晚逆光画面。解决这是数据覆盖问题不是模型问题——回到 2.2 步按光线、角度、摄像头位置重新抽帧补样本不要试图靠调阈值硬压。6. 进阶把单帧检测升级成时序判定用一段真实视频验证可用性单帧检测回答的是“这帧画面里有没有手机”但业务要的是“这个人有没有在玩手机”。两者之间的鸿沟要靠时序判定来填。我常用的做法是给检测结果加一个“持续性”条件同一个摄像头画面内手机框连续 N 帧比如连续 5 次抽帧对应约 7.5 秒都出现才触发告警。这个简单策略能过滤掉“路过时手机划过画面”“拿起来看一眼又放下”这类瞬时动作误报立刻少一半。更进一步的方案是引入位置关系。监控画面里人坐着时手机持在手上的位置通常在躯干中下方。部署推理时拿到人形框和手机框后判断手机框中心是否落进人形框下 2/3 区域。可以用现成的行人检测模型先出人框再做几何判断。位置关系能有效过滤“手机放在桌上被检测到”但人坐在另一边的情况。验证一个模型能不能交付我的做法是取一段真实监控视频——最好 30 分钟以上、包含白天和夜间各一段——全量抽帧跑一遍推理统计三个数字真实告警次数人工确认、误报次数、漏报次数。算出来的业务级准确率比 mAP 更有说服力因为它直接对应值班员的体验。这里有个我翻过车的教训想分享给你最早我拿验证集的 PR 曲线当交付依据被现场一顿投诉后老老实实做了业务级验证才发现曲线右上角再好看也架不住现场角度的千奇百怪。后来我再交付任何检测项目都先问一句“有没有一段真实的、模型没见过监控视频”拿它跑完业务验证才收手。这条路难走但能让你少接一半的售后电话。希望这个完整链路能帮你把项目从能跑变成能用在实际监控场景里站稳脚。本文还有配套的精品资源点击获取