
每年毕业设计总有不少人选“口罩检测”这个题目但大部分人交上来的是一个图片分类模型输入一张裁剪好的人脸输出“戴了”或者“没戴”。这样其实只回答了“这张脸有没有口罩”回答不了“画面里的人在哪个位置”。真正意义上的口罩实时检测系统应该是一个目标检测系统每一帧画面进来直接把所有人脸框出来并标注每个框内的口罩状态。这篇文章以MobileNet v2作为骨干网络结合SSD检测头完整讲一遍从数据准备到模型训练再到摄像头实时推理的实现路径适合正在准备相关毕业设计或者想用目标检测做入门实战的同学参考。技术路线我选的是TensorFlow Object Detection API中的SSD MobileNet v2预训练模型做迁移学习。这样做的好处是生态成熟、资料多不用自己从头写检测头预训练权重在大数据集上学习过通用物体特征用口罩数据微调两万步左右就能收敛。整套模型在普通CPU上也能跑出每秒10帧以上放到GPU上轻松超过30帧完全能撑住课程设计或毕业答辩的需要。下面我会从方案选型、数据准备、训练细节、实时部署和答辩避坑五个角度来讲。1. 方案选型MobileNet v2作骨干为什么比VGG/ResNet更适合口罩检测1.1 口罩检测的两个关键约束实时性和低功耗先回到任务本身。口罩检测系统的应用场景基本是出入口闸机、教室、商场监控这类固定摄像头场景。这类场景有两个共同特点第一视频流是持续不断的每一帧都要处理模型每慢一点画面就卡一下第二大多数情况下跑在边缘设备或者普通教室电脑上没有高端GPU。所以选骨干网络时首要指标不是“精度高一个点”而是“在目标硬件上能跑多少帧”。VGG、ResNet这类网络在图像分类任务上成绩很好但体积大、计算量大在实时视频流任务里往往力不从心。VGG16模型文件动辄几百MBResNet虽然结构上优化过但对嵌入式场景来说依然偏重。MobileNet v2就是专门为移动端和嵌入式设备设计的计算量小精度损失又不太大成了实时检测里非常常见的骨干选择。如果真的用VGG做骨干哪怕只接一个简单的检测头笔记本跑实时视频也会明显发热掉帧做完之后很难在答辩现场流畅演示。骨干网络模型文件大小CPU实时视频推理精度表现适合本任务VGG16较大困难高但收益有限不推荐ResNet50中等一般高可以但偏重MobileNet v2约14MB量级较流畅足够用推荐1.2 MobileNet v2的结构深度可分离卷积、倒残差和线性瓶颈这里要说清楚模型为什么快。MobileNet v2不是简单地把普通卷积换成小卷积它引入了三个核心设计。第一个是深度可分离卷积。普通卷积会对所有输入通道做一次整体卷积计算量是输入通道数乘以卷积核大小再乘以输出通道数。深度可分离卷积把过程拆成两步先对每个输入通道单独做空间卷积再用1x1卷积把通道间的信息混合起来。这一步能把计算量降到原来的八分之一到九分之一这也是速度的主要来源。第二个是倒残差结构。传统的残差结构先降维再升维MobileNet v2反过来了先用1x1卷积把通道数升上去然后用深度卷积提取特征最后再降到低维。这样做的好处是让特征在升维空间里被充分处理和传递避免降维时信息被过度压缩。你可以把它想象成把一件行李先拆开摊平整理完再折叠虽然多了展开的动作但整体找东西效率更高。第三个关键设计是在低维层去掉ReLU改用线性激活。ReLU激活在高维空间对信息的影响不大但在低维空间会直接把一部分特征置为零造成不可逆的信息丢失。所以MobileNet v2在最后的低维层选择线性映射保住细节信息。这三个设计加在一起才让它既小又快还能保持不错的精度。1.3 检测头的搭配为什么选SSD而不是R-CNN系列有骨干没有检测头还是只能做分类。想输出“人脸的边界框”必须接一个目标检测头。R-CNN系列属于两阶段检测先区域提议再分类精度高但速度慢YOLO系列一步到位速度快但它对自定义backbone的改造要写不少代码对毕业设计来说周期太长。SSD是最顺手的它从不同特征层生成不同尺度的默认框直接在多个尺度上做分类和回归。SSD MobileNet v2在目标检测框架的模型库里算是一个很成熟的组合下载预训练权重后改一下配置就能迁移学习。多尺度特征提取对口罩这种目标大小变化很大的情况很友好近处的人脸框很大远处的人脸可能只有十几个像素SSD靠多个特征层同时检测比单层特征图靠谱得多。所以从工程实现难度和效果两个角度考虑我建议毕设直接用SSD MobileNet v2。2. 数据准备从开源数据集到可训练TFRecord的完整链路2.1 数据集来源与合理性评估数据是毕设最耗时也最容易出问题的一环。常见的开源口罩检测数据集一般包含几百到几千张图片标注内容通常分两类一类是“戴了口罩的人脸”一类是“没戴口罩的人脸”。但要注意有些数据集的标注是“物体是否被遮挡”而不是“人脸是否有口罩”用之前一定要打开标注文件看一眼确认类别语义否则模型学出来的东西和你的目标根本不一致。我建议先按训练集600张、验证集150张、测试集150张来划分保证每张图片里都有至少一张人脸。如果只有几百张图也不要慌迁移学习可以弥补一部分数据量不足。但要注意场景分布如果全部是网上的正面自拍图那实际摄像头里的斜侧脸、低头、多人、戴帽子等情况就很难检测出来。最好的做法是找覆盖室内外、远近、遮挡、眼镜、不同肤色人群的图片数量不够可以拿手机对着门口录一段视频再抽帧成图片补进去。这一块最忌讳“拿来就训”。先自己把图全部快速过一遍把不符合场景的、重复的、标注错误的样本删掉后面能省大量时间。我见过有同学直接用了一个来源不明的压缩包里面混着漫画、示意图和重复帧训练出来的模型在真实场景里错得离谱最后只能重新标数据。数据质量比数据体量更重要。2.2 标注格式转换从VOC XML到TFRecord为了省事很多人直接用LabelImg标注输出的是Pascal VOC格式的XML文件。而TensorFlow Object Detection API训练时更喜欢TFRecord格式所以中间要写一个转换脚本。核心逻辑不复杂读XML读对应的图片把标签和边界框坐标整理成字典写入TFRecord。下面是一个核心转换逻辑目录结构需要根据自己的项目调整import xml.etree.ElementTree as ET import tensorflow as tf import os def xml_to_tfrecord(xml_dir, image_dir, tfrecord_path): writer tf.io.TFRecordWriter(tfrecord_path) label_map {mask: 1, no_mask: 2} for xml_file in os.listdir(xml_dir): if not xml_file.endswith(.xml): continue tree ET.parse(os.path.join(xml_dir, xml_file)) root tree.getroot() image_name root.find(filename).text image_path os.path.join(image_dir, image_name) image_data open(image_path, rb).read() image tf.io.decode_image(image_data, channels3) height, width image.shape[:2] boxes [] labels [] for obj in root.findall(object): name obj.find(name).text if name not in label_map: continue bndbox obj.find(bndbox) xmin int(bndbox.find(xmin).text) / width ymin int(bndbox.find(ymin).text) / height xmax int(bndbox.find(xmax).text) / width ymax int(bndbox.find(ymax).text) / height boxes.append([ymin, xmin, ymax, xmax]) labels.append(label_map[name]) tf_example tf.train.Example(featurestf.train.Features(feature{ image/encoded: tf.train.Feature( bytes_listtf.train.BytesList(value[image_data])), image/height: tf.train.Feature( int64_listtf.train.Int64List(value[height])), image/width: tf.train.Feature( int64_listtf.train.Int64List(value[width])), image/object/bbox/ymin: tf.train.Feature( float_listtf.train.FloatList(value[b[0] for b in boxes])), image/object/bbox/xmin: tf.train.Feature( float_listtf.train.FloatList(value[b[1] for b in boxes])), image/object/bbox/ymax: tf.train.Feature( float_listtf.train.FloatList(value[b[2] for b in boxes])), image/object/bbox/xmax: tf.train.Feature( float_listtf.train.FloatList(value[b[3] for b in boxes])), image/object/class/label: tf.train.Feature( int64_listtf.train.Int64List(valuelabels)), })) writer.write(tf_example.SerializeToString()) writer.close()这里有一个非常容易踩的坑坐标归一化时边界框的顺序和框架不一致。TensorFlow Object Detection API要求的是ymin、xmin、ymax、xmax数值范围0到1。如果你之前在画框时习惯用x,y,w,h转换时很容易把x当y结果训练时loss乱跳检测框全部错位。我一般会在转换完以后写一个小脚本随机挑三张图解析TFRecord并画框比对确认无误再进入训练。2.3 数据增强不要为了凑数量而盲目旋转很多同学一看图片少就加各种翻转、旋转、亮度调整。但口罩检测任务里上下翻转是不可取的——把图倒过来口罩跑到额头上了标签还是原来的框模型会学乱。左右翻转相对安全90度旋转需要谨慎除非你的场景里确实包含竖屏、横屏混合的画面。更好的增强是随机调整亮度、对比度、饱和度模拟不同光照随机裁剪模拟边缘人脸轻微旋转正负10度但角度不要太大。TensorFlow Object Detection API里可以通过配置data_augmentation_options实现建议默认加上random_horizontal_flip和random_adjust_brightness就够了。注意增强是在训练过程中随机作用不会改变原图所以不需要手动生成增强后的图片也不用为增强后的图片重新标注。有些同学跑了一个增强脚本把训练集从500张变成5000张结果硬盘满了一半模型还没涨点完全没必要。3. 训练细节迁移学习配置、损失观察与调参经验3.1 环境搭建与预训练权重选择训练推荐用TensorFlow 2.x配合Object Detection API。搭建环境比较磨人我建议按照官方文档一步步装不要为了省事跳过某个依赖。最关键的坑是版本匹配TensorFlow版本、TensorFlow Models库的版本、Python版本三者要一致否则会出现“某个op不存在”“无法导入某些模块”之类的错误。实在不想自己配也可以找一个带GPU的在线开发环境把数据传上去训练完再下载模型这种方式适合本机配置低的同学。预训练权重一定要选用SSD MobileNet v2在大规模公开数据集上的预训练模型。它已经学会了通用物体特征提取我们的口罩数据集相对小从头训练收敛慢精度也低。迁移学习相当于让模型站在一个“已经会看物体轮廓”的起点上只需要微调后半段网络来适应口罩特征训练速度快效果还好。3.2 pipeline配置batch size、学习率、锚框等关键参数训练前要写一个pipeline配置文件里面包含模型结构、训练集路径、验证集路径、类别映射和超参数。我常用的一组参考配置如下输入图像尺寸320x320这个尺寸在精度和速度之间比较均衡。batch size8显存不够就降到4但不要为了提速盲目调大会影响学习率。初始学习率0.04优化器用带momentum的SGD动量0.9。学习率调整训练步数达到总步数的70%和90%时各衰减一次衰减系数0.1。总训练步数20000到30000步具体看TotalLoss收敛情况。anchor生成器使用SSD默认的多尺度anchor只把类别数的num_classes改成2即可。类别映射1代表mask2代表no_mask背景类不用单独设置框架内部会自动安排。如果你的自定义数据集里类别顺序和默认不一致一定要检查label_map。我见过有同学把mask和no_mask的顺序写反训练过程一切正常推理时green框标着no_mask红色框标着mask答辩时直接被老师挑出来场面很尴尬。3.3 训练过程监控TotalLoss曲线怎么看loss不降怎么办训练启动后不要干等。我习惯每100步记录一次loss放到可视化工具里观察。正常的TotalLoss曲线会在前两三千步快速下降从起初的十几、二十几掉到3以内随后缓慢下降并趋于平稳。如果出现下面几种情况要停下来排查第一种loss从一开始就非常低比如小于0.5但检测效果很差。这种情况一般是数据或标注出了问题模型直接“躺平”把所有目标都当成背景。检查重点label_map是否正确、标注框是否归一化正确、图片和XML文件名是否一一对应。第二种loss下降很慢一直卡在5到8之间。可能原因是学习率太低或者数据集太小导致模型难以拟合。可以适当增加训练步数或者叠加更多数据增强。第三种loss在某一步突然冲高然后又回落。可能是某张异常图片导致的梯度突变先检查图片是否有损坏以及归一化坐标是否越界。训练结束后输出目录里会出现checkpoint文件这就是下一步导出需要的训练结果。训练时一定要把checkpoint间隔保存不要只保留最后一步否则中途停了就要全部重来。3.4 导出模型从CKPT到SavedModel再到推理用模型训练得到的checkpoint不能直接给OpenCV加载还需要导出成SavedModel。TensorFlow Object Detection API里可以用model_main_tf2.py配合export_dir参数导出也可以使用专门的导出脚本。我的经验是直接导出SavedModel因为推理脚本加载方便而且不依赖训练代码里的各种配置类。导出成功后目录里会有saved_model.pb和variables文件夹。到这里模型训练部分就完成了下一步就是把它接到摄像头视频流里做实时检测。4. 实时检测落地摄像头视频流推理与性能优化4.1 基于OpenCV的实时视频流读取与推理循环现在到了最有成就感的一步把模型接到摄像头上。推理脚本的思路很简单循环读帧把帧从BGR转成RGB缩放到320x320送入模型得到检测结果再画框显示。这里注意几点视频帧的输入尺寸要和训练时一致否则检测框位置会错位。缩放到320x320时宽高比例会被拉伸但SSD对这类坐标映射处理比较简单直接resize后坐标按比例乘回原图宽高即可。模型输出的是归一化坐标转回原图尺寸时x坐标乘原图宽度y坐标乘原图高度。不要每帧重新加载模型模型加载一次放在循环外。核心代码大概是这样import cv2 import tensorflow as tf model tf.saved_model.load(exported_model/saved_model) infer model.signatures[serving_default] cap cv2.VideoCapture(0) while True: ret, frame cap.read() if not ret: break h, w frame.shape[:2] rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) input_tensor tf.convert_to_tensor([rgb], dtypetf.uint8) detections infer(input_tensor) boxes detections[detection_boxes][0].numpy() scores detections[detection_scores][0].numpy() classes detections[detection_classes][0].numpy().astype(int) for i in range(len(scores)): if scores[i] 0.5: ymin, xmin, ymax, xmax boxes[i] left int(xmin * w) top int(ymin * h) right int(xmax * w) bottom int(ymax * h) if classes[i] 1: color (0, 255, 0) label mask else: color (0, 0, 255) label no_mask cv2.rectangle(frame, (left, top), (right, bottom), color, 2) cv2.putText(frame, f{label} {scores[i]:.2f}, (left, top - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) cv2.imshow(mask detection, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这段代码在普通笔记本CPU上用SSD MobileNet v2的320输入FPS大约在10到15之间如果有GPU或使用加速推理后端能到30以上。对毕设演示来说10帧每秒勉强能看但如果想让画面更顺滑还可以做进一步的优化。4.2 后处理细节NMS、阈值调整、绘制边界框很多同学第一个困惑是“为什么同一个口罩会被检测好几遍”。因为SSD会生成大量候选框模型内部通常已经做了NMS非极大值抑制但阈值设置不合适时依然会出现重叠框。在迁移学习时默认NMS的IoU阈值是0.6如果你的场景里口罩目标密集可以调高score阈值到0.6或0.7过滤掉低置信度框。不要只依赖默认的0.5。另外可以针对两个类别单独调阈值。有些数据集里“无口罩”类更难检测因为负样本多置信度普遍偏低阈值可以放宽到0.4“有口罩”类相对稳定阈值设0.5就好。部署时不要只画矩形可以在框上方写“mask”“no_mask”和置信度颜色用绿红区分。这样答辩演示时画面直观效果明显。4.3 性能瓶颈分析与优化输入尺寸、推理后端、多线程如果发现FPS不够先别急着换模型按下面的顺序排查第一看每一帧的耗时分布。是摄像头读取耗时高还是推理耗时高还是绘制耗时高可以用time.time()分别打点。摄像头读取一般不会超过20毫秒绘制也很快通常瓶颈就是模型推理。第二降低输入尺寸。320降到256FPS能提升30%左右但小目标检测能力会下降。如果你的摄像头离人不超过两三米可以试试。第三换加速推理后端。TensorFlow默认的CPU推理没有做极致优化可以转换成OpenVINO或TensorRT格式提速两到三倍是常见的。但这属于加分项不是必选项别为了优化把项目搞崩。第四用多线程或异步流水线。让读取线程不断把帧放入队列推理线程从队列取帧绘制线程负责显示。这样即使推理偶尔卡顿画面也不会一卡一卡。毕业设计里能做到这一步答辩加分很多。5. 毕业设计全流程避坑从开题报告到答辩演示的30天规划5.1 我建议的时间线先跑通后调优不要卡在环境很多学生一上来就研究高深理论结果环境都没配好。我了解到的比较顺利的节奏是这样的第1到3天搭环境下载SSD MobileNet v2预训练模型用官方Demo跑通一次推理 第4到6天整理数据集转换TFRecord写加载验证脚本 第7到14天训练模型同时准备开题报告和文献综述 第15到17天导出SavedModel写摄像头推理脚本 第18到22天调参优化重点提升检测效果和帧率 第23到25天封装一个小界面可以是PyQt或Tkinter加一个上传图片检测的入口 第26到28天写论文画系统流程图、架构图记录实验过程数据 最后2到3天做答辩PPT录制演示视频以防现场摄像头出问题。这个时间线看起来紧但实际训练时间并不长。迁移学习两万步在单张GPU上也就半天到一天。真正的重点不是“训练多久”而是“数据整理多久”。如果数据集混乱后面优化阶段会非常痛苦。5.2 演示环节最容易翻车的三个点演示环节经常见到的翻车点大多来自细节第一摄像头权限。笔记本摄像头或USB摄像头在OpenCV里打不开通常是因为被其他程序占用。提前在演示电脑上单独测一下cap.isOpened()是否为True不要等到答辩现场才发现。第二模型路径写死。训练目录拷到项目目录后推理脚本还在找原来的绝对路径移动后就报错。建议所有路径都用相对路径并集中放到一个配置变量里。第三光线变化。现场灯光和训练集光照差别大时模型会漏检。准备几张静态图片作为保底方案万一摄像头实时检测效果不好可以切换成图片检测不会冷场。5.3 论文撰写中与实验数据对应的图表要求毕业设计论文不是只有代码。口罩检测这种题目至少需要准备这几类数据不同算法的对比表比如VGGSSD、ResNetSSD、MobileNet v2SSD在精度、模型大小、帧率上的对比。这几组数据能自己跑最好不能全跑也可以引用公开数据但要在论文里注明来源。训练过程的TotalLoss曲线这个直接可视化工具里导出即可。精确率、召回率、F1值以及在不同置信度阈值下的指标变化。再进一步可以画出PR曲线这个稍微复杂但做了会很有说服力。测试集的检测结果示例图要有近景、远景、多人、不同光线、戴眼镜、遮挡等场景。答辩时老师很可能会问“为什么选择MobileNet v2”。你可以从实时性需求出发用模型大小和帧率数据来回答。还会问“你的模型对光线不好的场景效果如何”提前收集白天、晚上、昏暗走廊的视频来测试给出真实数据比空口解释有用得多。最后再分享一个训练相关的细节不要只保存最后一版checkpoint每一个有明显loss下降的里程碑版本都保留一下。我自己在做类似实时检测项目时曾经因为某次实验覆盖了较好的权重后面想回退都没法回退只能重新训练非常耽误时间。建议在配置里设置定期保存的间隔比如每5000步保存一次并且给文件名加上step编号。这样想回到某个阶段直接改pipeline里的fine_tune_checkpoint路径就行几分钟就能恢复。这个习惯很小但能帮你省掉很多不必要的重来。