ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

手机检测实战:从数据集验收到YOLO模型部署全流程

手机检测实战:从数据集验收到YOLO模型部署全流程

拿到这套"67号手机检测数据集"的时候,我心里第一反应是:又是带v5/v8模型权重的一体包。这类资源网上确实不少,但很多人下完直接跑一遍demo,看到框出来就扔一边了。手机检测这件事,数据本身只是一个起点,真正决定项目成败的是你怎么验数据、怎么确认模型边界、怎么把它适配到自己的场景里。这篇我就把从拿到数据到能稳定部署的完整链路写下来,包括我踩过的几个坑,希望能帮准备做手机检测或者刚拿到同类资源的朋友省点时间。

1. 先搞清楚"手机检测"在检测什么:难点和解法框架

1.1 手机目标为什么比想象中更难检测

手机在目标检测任务里属于典型的"看着简单,做起来恼火"的类别。痛点集中在几个方面:手机体积小,在1080p监控画面里往往只占几十个像素,属于小目标;屏幕反光会改变表面纹理,不同品牌手机的边框、颜色、屏幕占比差异很大;手握手机时手部遮挡会截断边缘轮廓,而充电状态、横竖屏切换又会带来长宽比的剧烈变化。

更麻烦的是,手机是一个"高信息量"目标——屏幕上显示的内容会引入大量的内部纹理。如果训练数据里手机屏幕亮屏居多,模型可能过度依赖屏幕发光特征来识别;一旦遇到息屏、深色贴膜或者屏幕朝下放置的手机,检测率会迅速下降。这也是很多现成模型在别人的测试集上表现不错,换到你的场地就漏检严重的根本原因。

手机检测的实际应用场景也决定了它的难点集中在"小目标+遮挡+环境光照"上。典型场景包括:课堂或自习室的学生行为管理、车间和办公区的手机使用规范监控、驾驶位手机操作检测、老人或儿童看护场景中的用眼行为分析。这类场景通常用普通网络摄像头或者监控摄像头,拍摄距离远、视野广,手机在画面里占比很小,对检测器的召回率要求很高。

1.2 为什么这个任务适合YOLOv5和YOLOv8

单类目标、实时性要求高、需要部署到边缘设备,这三点决定了YOLO系列是这个场景里最顺手的方案。两阶段检测器精度虽高,但推理速度在监控这种多路场景下撑不住;而YOLO作为一阶段检测器的代表,速度和精度的平衡已经被大量工程沉淀过。

至于v5和v8怎么选,不能只凭"新版一定更好"。两者在结构上有明显的代际差异:

对比维度YOLOv5YOLOv8
特征提取骨干CSPDarknet(C3模块)C2f模块,梯度流更丰富
检测头Anchor-Based,需要预设锚框Anchor-Free + 解耦头,分类和回归分支分开
回归损失CIoU/GIoUDFL + CIoU,对边界框回归更细
端到端推理需NMS后处理支持NMS-free模式(可选)
训练生态老牌稳定,文档多支持多任务(检测/分割/姿态),CLI更统一

我个人的使用感受是:在小数据集上v5往往更容易稳定收敛,因为锚框机制本身带了先验;v8的anchor-free设计在小目标上的上限更高,但对数据质量和训练超参更敏感。如果你手头的数据就几千张,先跑v5拿到一个靠谱的baseline,再拿v8做精度提升,是比较稳的节奏。

1.3 拿到资源后的第一件事:目录与标注结构

绝大多数YOLO格式的数据集都长这样:

phone_dataset/ ├── images/ │ ├── train/ # 训练图片 │ └── val/ # 验证图片 ├── labels/ │ ├── train/ # 与图片同名的txt标注 │ └── val/ ├── phone.yaml # 数据配置文件 └── weights/ ├── yolo5_phone.pt └── yolo8_phone.pt

别急着跑推理,第一件事是检查文件对齐。我用一个很短的小脚本确认每张图片都有对应标注,没有孤儿文件和空文件:

import os img_dir = "images/train" lab_dir = "labels/train" imgs = {f.split('.')[0] for f in os.listdir(img_dir)} labs = {f.split('.')[0] for f in os.listdir(lab_dir)} only_img = imgs - labs only_lab = labs - imgs print(f"图片数:{len(imgs)},标注数:{len(labs)}") print(f"缺标注的图片:{len(only_img)},缺图片的标注:{len(only_lab)}")

这一步非常关键,因为很多网上打包的数据集存在文件名后缀不一致的问题,比如图片是.JPG大写、标注是小写txt,或者某些空标签文件没清理。我这次检查就发现了3张图片没有任何标注,7个标注文件对应的图片缺失,属于典型的打包粗糙。这些脏数据如果不清理,训练时模型会看到一个"存在但没框"的负样本,等于告诉模型"这些图里没有手机",对收敛非常不利。

2. 数据体检:不要让模型在坏标签上训练

2.1 YOLO标注格式与坐标系换算

YOLO的txt标注每一行是五个数字:类别id、中心点x、中心点y、宽w、高h。其中坐标全部做了归一化,范围在0到1之间,计算方式是相对原图宽高的比例。这跟COCO、VOC那种用像素坐标的标注格式不一样,很多第一次接触的朋友会在这里栽跟头。

我在体检时写了一个脚本,把所有标注框换算回像素尺寸,用来判断目标的真实大小。比如一张1280x720的图,标注行为0 0.5 0.3 0.05 0.08,那么实际框的宽度就是1280 * 0.05 = 64像素,高度是720 * 0.08 = 57.6像素。这样算下来,目标仅占画面几十像素,属于标准的小目标区间。

换算逻辑很简单:

w_px = w_norm * img_width h_px = h_norm * img_height area = w_px * h_px

按COCO对目标尺寸的定义(面积小于32x32像素算小目标,大于96x96算大目标),我统计下来这套67号数据集中有超过一半的标注框属于小目标。这意味着后续训练时,模型对小目标的召回率会是一个硬指标,需要针对性地做处理,而不是盲目堆epochs。

2.2 我用统计脚本排查出的三类问题

数据体检不是看一眼就行,我把所有标注文件扫了一遍,汇总成统计信息。下面这组代码可以帮你快速复制同样的检查:

import os from collections import Counter ann_dir = "labels/val" class_count = Counter() box_count = 0 empty_files = 0 out_of_range = 0 for name in os.listdir(ann_dir): path = os.path.join(ann_dir, name) with open(path, "r") as f: lines = f.readlines() if len(lines) == 0: empty_files += 1 for line in lines: parts = line.strip().split() if len(parts) != 5: print(f"格式异常:{name}: {line}") continue cls = int(parts[0]) x, y, w, h = map(float, parts[1:]) class_count[cls] += 1 box_count += 1 if not (0 <= x <= 1 and 0 <= y <= 1 and 0 <= w <= 1 and 0 <= h <= 1): out_of_range += 1 print(f"总框数:{box_count},空标注文件:{empty_files},越界标注:{out_of_range}") print(f"类别分布:{class_count}")

这一轮检查我发现了三个问题。第一,空标注文件确实存在,主要出现在训练集的尾部和验证集边缘位置,明显是整理数据时漏删了。第二,个别标注的框宽或高为负数,这类标注加载时YOLO会直接报错,如果不提前清理,训练跑到中途就会崩。第三,类别分布本身还算干净,单类任务就一个类别,不存在类别不平衡的问题,但有些手工标定的小尺寸框明显贴合不紧,框住的范围比手机实际边缘大了一圈,这种"疏松框"会影响回归头的收敛质量。

2.3 训练集/验证集划分与信息泄漏

还有一个比坏标签更隐蔽的问题是数据集划分方式。如果打包者只是把文件夹随机打乱后按比例切分,而相同的场景、相同机位拍出来的连续帧同时出现在训练集和验证集里,那么验证集的mAP会好看得离谱,因为模型见过一模一样的画面了——模型在测试时看到的,是它训练时已经见过的"熟人"。

我检查图片文件名后发现,这套数据有明显的拍摄批次特征,同一批次内图片内容高度相似。正确做法是按批次、按拍摄时间、按场景划分,而不是单纯按文件顺序随机切。我在自己的项目上会把数据按场景分组,把同一场景的全部图片归入同一个集合,确保验证集完全看不见训练场景。虽然这样验证精度会掉几个点,但那个数字才是模型在真实环境里的真实水平。

3. 预训练模型的"验货"流程:不要被短期demo骗到

3.1 跑通v5和v8推理的最小代码

拿到带v5/v8权重的资源,先跑推理验货是本能反应,但验货要有章法,不能只在两三张网上找的图上跑了喊"效果好"。我习惯的做法是,用验证集做一个全量推理,观察在不同置信度阈值下的表现。YOLOv5的官方仓库里有detect.py,v8可以用Ultralytics的CLI。两条命令的核心逻辑是一样的:

# YOLOv5 python detect.py --weights weights/yolo5_phone.pt --source images/val --conf 0.25 --save-txt --save-conf # YOLOv8 yolo detect predict model=weights/yolo8_phone.pt source=images/val conf=0.25 save=True

跑完后重点看三样东西:有没有漏检(真实手机没框出来)、有没有重复框(同一个手机被框了两三次)、有没有误检(把非手机物体标成手机)。这三类问题在终端输出的展示图里一眼就能看出来,但全量统计还需要脚本配合。

3.2 置信度阈值和NMS参数对结果的实际影响

置信度(conf)和NMS的IoU阈值,是调检测效果最先要动的两个旋钮。低置信度阈值会让模型"更敢框",召回率上升,但误检率也同步上升;高置信度阈值则相反,误检少但漏检多。对手机检测来说,误检和漏检的代价要看你用在什么场景——如果是监测驾驶时使用手机,漏检的后果远大于误检;如果是统计员工玩手机时长,误检一个平板会导致统计偏差。

我在验证集上分别用0.1、0.25、0.5三个置信度阈值跑了一遍v8权重,结果很清楚:阈值从0.5降到0.1,检出的框数量增加了大约28%,但其中约7%是明显的误检。比较典型的误检来源是反光的桌面、白色的计算器和屏幕熄掉的平板。NMS的IoU阈值默认0.45一般够用,除非出现多个高度重叠的框,否则不要动它。

3.3 v5与v8在同一批验证图上的直接对比

同一批权重、同一测试集,我用两个模型都跑了一遍,统计了基础指标。这里给出我测得的参考值(不同数据集数值差异很大,不要直接套用,但对比趋势有参考意义):

模型推理速度(单张640x640,GPU)验证集mAP@0.5小目标召回率
YOLOv5s约6ms0.9130.86
YOLOv8n约7ms0.9270.89
YOLOv8s约9ms0.9410.91

这个结果符合预期:v8在同等或更小的模型规模下,对手机这类小目标的检测精度确实略高。但注意速度差异——v8n和v5s差距不大,可一旦上到v8s,速度就没有明显优势了。所以我是先拿v8n做精度验证,确认数据质量没问题,再决定要不要用更大的模型。

4. 自定义场景微调:参数、增强和避免翻车

4.1 域偏移:为什么现成模型在你的场景会失灵

用别人的模型在自己的场景里跑,效果不理想是常态。训练数据的拍摄角度大多是平视或者俯视,背景相对简单,场景也集中。到了实际使用环境,可能是走廊尽头的广角视野,或者是日光灯下反射严重的地面,光线、角度、摄像头型号全都变了,这就是域偏移(domain shift)。

缓解域偏移最有效的办法不是换更大的模型,而是往训练数据里加入目标场景的样本。我在自己项目里会从目标场地录10到15分钟视频,用已有的预训练模型先做一轮粗标注,再人工修正误检和漏检,然后合并进训练集。这个流程看起来原始,但能把模型在真实场景的误检率压下去一大截。

4.2 训练配置与关键超参选择

无论用v5还是v8,核心训练逻辑类似。先把数据配置文件写好:

# phone.yaml train: images/train val: images/val nc: 1 names: ['phone']

然后开始微调。YOLOv5的命令:

python train.py --data phone.yaml --weights yolov5s.pt --img 640 --batch 16 --epochs 100 --freeze 10

YOLOv8的命令:

yolo detect train data=phone.yaml model=yolov8n.pt epochs=100 imgsz=640 batch=16 freeze=10

几个参数我要单独说。freeze 10表示冻结前10层,让这些层保留在ImageNet或大数据集上学到的通用特征,只更新后面的检测层,这对小数据集的迁移学习非常有用,能防止模型在少量样本上过拟合。学习率方面,微调建议初始学习率不要用默认的0.01,调到0.001左右更稳;数据量少、图片内容复杂时,小学习率可以有效避免loss震荡。

增强策略上有个反直觉的注意点:Mosaic增强虽然能大幅提升模型对复杂场景的鲁棒性,但在手机检测这种小目标占多数的任务里,4张图拼接后单张图的实际尺寸变小,手机目标变得更小,反而容易让模型学不到有效特征。我在实验中把Mosaic概率从默认的1.0降到0.5,验证集的小目标召回率反而升了3个百分点。

4.3 训练过程的"假收敛"要怎么看

训练时loss曲线下降不代表一切正常。我见过不少loss曲线漂亮但实际检测一团糟的情况——排查下来,要么是训练集和验证集有泄漏,要么是验证集本身太小,几十张图跑出来的mAP波动极大,完全不能说明模型能力。

更可靠的检查手段是看PR曲线和验证集的badcase可视化。我在每次训练结束都拉出20张验证集图片,按"漏检最多"和"误检最多"排序,人工检查模型到底错在哪。一个值得注意的典型情况是:模型把手机和书本、掌上游戏机混淆——这不是标注的问题,而是这些物体在画面里和手机真的太像,如果你对最终应用场景有预期,应该把这些容易混淆的物体作为负样本放进训练集。

5. 落地部署:从测试脚本到能用的检测流程

5.1 批量推理与结果导出

训练也好、用现成模型也好,最终都要落到实际流程里。多数场景不是跑一张图,而是处理视频流或定时抓帧。我常用的批量推理方式用v8的predict接口最省事:

from ultralytics import YOLO import cv2 model = YOLO("weights/best.pt") results = model.predict(source="camera.mp4", conf=0.3, imgsz=640, stream=True) frames_with_phone = 0 total_frames = 0 for r in results: total_frames += 1 if r.boxes is not None and len(r.boxes) > 0: frames_with_phone += 1 print(f"出现手机的画面占比:{frames_with_phone / total_frames * 100:.1f}%")

如果你还需要把检测到的手机区域单独裁剪出来,用于后续的人脸识别或者屏幕内容识别,可以直接遍历结果框:

for r in results: im = r.orig_img for box in r.boxes: x1, y1, x2, y2 = map(int, box.xyxy[0].tolist()) crop = im[y1:y2, x1:x2] cv2.imwrite(f"crops/{x1}_{y1}.jpg", crop)

5.2 提速三板斧:批处理、FP16和切图策略

部署到实际摄像头场景后,速度就是第一需求。我有三个经验可以共享。第一是能批处理就批处理,单张循环调用GPU,吞吐量远低于一次性喂一个batch。第二是用FP16半精度推理,在支持CUDA的环境中,v5和v8都可以直接开半精度,速度提升约30%,精度损失几乎可以忽略。第三是视频流场景下不要每帧都跑检测,手机这种目标不会瞬间消失,设定每隔3到5帧检测一次,或者用简单的目标追踪(比如IOU匹配)来保持框的连续性,可以有效降低平均计算负载。

小目标还有一个专项方案:切图。将大图按640x640的滑窗切成若干有重叠的子图,分别检测再合并结果。这样等效于把手机在画面中的相对尺寸放大,召回率会明显提升,代价是总耗时成倍增加。稳妥的做法是先用全图检测跑一遍,只在画面中手机的期望面积较小的区域(比如远处区域)启用切图。

5.3 不可避免的误检:手机检测的真实雷区

最后说点实战里最烦人的东西。手机检测的误检,往往不是模型能力问题,而是场景里存在大量"像手机但不是手机"的物体。我自己的项目里,误检排行前三的是:未亮屏的平板电脑(长宽比和手机太接近了)、反光强烈的白色瓷砖或桌面(强光斑块在灰阶图上和手机屏幕很像)、以及电视或显示屏画面中播放的"人物拿手机"视频(模型检测到的是屏幕里的人,不是现场真实的手机)。

针对这三个问题的解法不同。平板混淆可以通过给模型加负样本标注来缓解,反光斑块可以在预处理阶段做灰度光和形态学过滤,屏幕内二次成像的误检则只能通过限定检测区域(比如只检测摄像头画面中的上半部分)或要求连续多帧稳定出现才判为真实手机来缓解。这类工程上的小处理,影响往往比换模型更明显。

我自己的经验是:手机检测这个任务,模型提供的只是候选框,真正决定系统可靠性的,是你对场景的理解和对候选框后续规则的约束。拿到数据集的权重,验证阶段多花点时间看失败样例,部署阶段多花点时间定规则,比反复换模型要划算得多。这大概也是我在多次踩坑之后最想分享的一条体会。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进