基于MobileNet v2的口罩实时检测:从迁移学习到TFLite量化部署
简介一份基于MobileNet v2的口罩实时检测系统完整实现资源面向希望快速落地轻量级目标检测项目的开发者也适合学习深度模型部署与Flask Web应用整合的入门者。系统内置实时视频流检测与图片上传检测两条功能链路前者调用摄像头逐帧识别后者支持静态照片上传判断可覆盖办公楼、校园出入口、公共交通等场景的防疫检查核心模型采用MobileNet v2轻量级架构通过深度可分离卷积显著减少参数量与计算量便于在边缘设备上高效运行也能较好满足实时检测需求。后端以Flask搭建Web服务将检测能力以浏览器页面形式开放给用户压缩包内共48个文件涵盖Python源码、pyc编译文件、XML配置、HTML页面、训练好的.h5模型权重、演示动图等整体约10.82MB目录结构清晰可直接启动测试或二次改造。其中所带训练曲线图与实时检测动图有助于核对模型表现配合环境说明文件可快速配置运行环境降低复现门槛。已有106人浏览学习适合作为毕业设计、课程项目或企业原型参考。1. 为什么选 MobileNet v2 做口罩实时检测轻量、够快、好换平台在园区门禁、教室入口这类场景里口罩检测的根本矛盾不是准确率不够而是算力有限。之前我试过用大模型跑推理一张图要一两百毫秒连着摄像头根本用不了。换到 MobileNet v2 之后相同硬件上单帧推理压缩到二三十毫秒CPU 也能扛住。这套资源包含从数据集整理、迁移学习训练到 OpenCV 实时推理的完整链路模型只有几十 MB能直接部署到工控机、树莓派或者安卓端。适合刚接触深度学习落地的新手也适合需要快速做实时检测原型的开发者。MobileNet v2 的实时能力来自两层设计深度可分离卷积把计算量降了一个量级倒残差结构又保住了特征提取的精度。对口罩这类目标明确、类别少的任务用预训练权重做迁移学习几百张图就能微调出一个能用的模型。本文就从这几块逐一展开最后讲量化部署和我在实际项目中踩过的坑。2. MobileNet v2 的原理与选型理由倒残差结构为什么实时很多人直接调包调用 MobileNetV2 函数但不知道它快在哪、哪些参数能改。这一节先把结构讲清楚再给出迁移学习的搭建代码。2.1 深度可分离卷积把标准卷积拆成两步标准卷积层对一个输入特征图做卷积时每个输出通道都要用和输入通道数相等的卷积核计算量是输入通道数 × 输出通道数 × 卷积核尺寸 × 输出尺寸。MobileNet v2 改用深度可分离卷积把它拆成两部分第一步深度卷积每个输入通道单独用一个二维卷积核处理不管通道间的关系第二步逐点卷积用 1×1 卷积把各通道结果线性组合。这样一来计算量从Cin × Cout × K × K降为Cin × K × K Cin × Cout。以 3×3 卷积核为例理想情况下计算量约是标准卷积的九分之一。这个公式直接决定了 MobileNet 系列能在低算力设备上连续跑多路视频流也是我选它做实时检测方案时最先确认的指标。深度卷积本身也有参数可调最常用的是depth_multiplier默认 1。把它调成 0.5 或 1.5可以直接控制计算量代价是精度随之浮动。对口罩二分类alpha 取 1.0 已经够用但裁剪到 0.75 也能接受具体取值见 3.2 节的调参建议。2.2 倒残差块和线性瓶颈MobileNet v2 相对 v1 的关键改动MobileNet v1 直接把深度可分离卷积一层层堆起来但训练时发现深层的梯度和信息流会退化。v2 引入了倒残差结构先用 1×1 卷积把低维特征升维到高维再做深度卷积最后降维回低维。与传统残差块中间窄、两端宽相反它是中间宽、两端窄所以叫“倒”残差。最后一步降维之后不再接 ReLU而是用线性激活。原因是在低维空间里 ReLU 会把负数直接置零丢掉很多信息。这个线性瓶颈层让模型在参数不膨胀的前提下加深网络对细粒度特征的保留也更友好。MobileNet v2 的完整主干就是由 17 个这样的倒残差块堆叠而成再配一个全局平均池化和分类层。2.3 基于预训练权重做迁移学习用 Keras 搭建分类头我不建议从零训练 MobileNet v2ImageNet 预训练权重已经包含了大量通用纹理和边缘特征。口罩检测的场景和 ImageNet 有一定差异但浅层特征完全可复用。用迁移学习只需要替换最后的分类层冻结主干先训再解冻部分层微调。import tensorflow as tf from tensorflow.keras.applications import MobileNetV2 from tensorflow.keras.layers import GlobalAveragePooling2D, Dropout, Dense from tensorflow.keras.models import Model base_model MobileNetV2( weightsimagenet, include_topFalse, input_shape(224, 224, 3), alpha1.0 ) base_model.trainable False x GlobalAveragePooling2D()(base_model.output) x Dropout(0.2)(x) outputs Dense(2, activationsoftmax, namemask_classifier)(x) model Model(base_model.input, outputs) model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-4), losscategorical_crossentropy, metrics[accuracy] )这段代码的逻辑是先把主干冻结只训练新增的池化层和分类层防止小数据集上把预训练权重破坏掉。input_shape固定为 224×224这是 MobileNet v2 官方预训练的标准尺寸。alpha1.0表示宽度乘数保持默认。第一阶段训练结束后我会把base_model.trainable设为 True再用更小的学习率解冻主干的后 20~50 层。这个先冻结、再解冻的做法能明显减少过拟合尤其当你的数据集只有几千张图时。Dropout 放在分类层之前起正则作用概率 0.2 是我测试下来比较稳的值调大后验证集准确率反而波动。2.4 三种轻量网络的对比为什么最终落在 v2 上实际选型时我把 MobileNet v1、v2、v3 放在同一批数据上各训了一轮。v1 速度最快但数据量小的情况下准确率不稳定掉点比 v2 多一个多点v3 在移动端能达到更高精度但它有两个明显的麻烦部分结构依赖特殊的激活函数转换到 TFLite 或 ONNX 后 Keras 自带实现和推理端实现容易对不上可调参数更多调参探索成本高。v2 处在中间速度和精度平衡最好生态兼容性也最强。结论是纯 CPU 终端优先 MobileNet v2有 NPU 或硬件加速可以考虑 v3。如果你做的是纯服务器演示、不关心模型体积也可以直接上更大的网络但实时性会受限。我自己的项目全部落在 v2 上这也让后文的训练和部署流程具备通用性。3. 数据准备与训练脚本把数据集组织成模型能吃的格式数据是口罩检测项目里最影响成败的环节。模型结构确定后数据质量直接决定误判率。这一节给出一套可直接复制使用的数据组织方式、数据增强策略和完整训练脚本。3.1 数据集目录结构与标签约定我用典型的目录组织方式训练集和验证集分开每类一个子文件夹dataset/ ├── train/ │ ├── with_mask/ # 戴了口罩 │ └── without_mask/ # 没戴口罩 └── validation/ ├── with_mask/ └── without_mask/准备好这些目录后记录样本数。常见的坑是两类样本数悬殊比如 without_mask 只有 200 张with_mask 有 2000 张。训练会自动倾向多的一类推理时表现为把没戴口罩的人几乎都判成戴了。我处理的办法是第一轮训练前先统计两个目录的文件数量如果比例超过 1:1.5就按数量少的类做复制增强或者按 5.4 节的方法设置class_weight。3.2 数据增强与训练脚本参数含义和调法实时检测系统的训练样本来自不同光照、不同摄像头的截图必须做数据增强否则模型在自家摄像头上的泛化会非常差。我用的增强参数如下。import tensorflow as tf from tensorflow.keras.preprocessing.image import ImageDataGenerator data_augmentation ImageDataGenerator( rotation_range20, width_shift_range0.2, height_shift_range0.2, zoom_range0.2, horizontal_flipTrue, brightness_range[0.8, 1.2], fill_modenearest ) train_generator data_augmentation.flow_from_directory( dataset/train, target_size(224, 224), batch_size32, class_modecategorical, shuffleTrue ) validation_generator ImageDataGenerator().flow_from_directory( dataset/validation, target_size(224, 224), batch_size32, class_modecategorical, shuffleFalse )rotation_range20表示最多旋转 20 度模拟人低头抬头brightness_range[0.8, 1.2]模拟室内外光照波动horizontal_flip对口罩这种左右对称目标很有用。zoom_range0.2模拟摄像头远近变化。验证集不增强保持原始分布。然后进入训练early_stop tf.keras.callbacks.EarlyStopping( monitorval_loss, patience5, restore_best_weightsTrue ) checkpoint tf.keras.callbacks.ModelCheckpoint( mask_model.h5, monitorval_accuracy, save_best_onlyTrue ) model.fit( train_generator, validation_datavalidation_generator, steps_per_epochtrain_generator.samples // 32, validation_stepsvalidation_generator.samples // 32, epochs30, callbacks[early_stop, checkpoint] )steps_per_epoch用样本数除以 batch_size算出每轮迭代次数训练后段如果 GPU 利用率不高可以把 batch_size 提到 64 再试。EarlyStopping监控验证集损失5 个 epoch 内没有改善就停下来并把最优权重恢复回来这是对付过拟合最省心的手段。ModelCheckpoint只保存验证集准确率最高的一版避免最后的权重被训练后期破坏。第一轮冻结主干训练验证集准确率通常能到 97% 左右。准确率不再提升后我再看权重文件大小和训练曲线决定是否进入解冻阶段的微调。3.3 解冻微调学习率和层数怎么把握纯训练分类层能快速收敛但瓶颈在主干因为 ImageNet 预训练特征和口罩数据还是存在差异。解冻微调就是用小学习率来修正主干让它更贴合自己的数据分布。base_model.trainable True for layer in base_model.layers[:80]: layer.trainable False model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-5), losscategorical_crossentropy, metrics[accuracy] ) model.fit( train_generator, validation_datavalidation_generator, steps_per_epochtrain_generator.samples // 32, validation_stepsvalidation_generator.samples // 32, epochs20, callbacks[early_stop, checkpoint] )学习率从 1e-4 降到 1e-5防止在已经接近最优的区域来回震荡。冻结前 80 层是因为浅层学到的是颜色、边缘这类通用特征改动它们收益小且风险大后段层更接近语义特征调整空间更大。具体冻结层数没有绝对标准我一般看验证集准确率如果提升了就继续解冻更多层重训如果掉了就回退到之前冻结的版本。这里要强调一个选择到底对单人脸做分类还是对整帧图像直接分类我的实现采用“先人脸检测再对人脸区域做口罩分类”。原因是整帧分类会被背景严重干扰场景一换误判率就飙升。本项目资源里默认用 OpenCV 的 Haar 级联检测器框出人脸区域再裁剪缩放后送入 MobileNet v2。后面 4.2 节会用 MTCNN 替换 Haar准确率更稳代价是略慢。4. 把模型接到摄像头实时流OpenCV 推理与性能调优模型训好后剩下的事情就是把权重文件装进一个能实时跑的检测流程。很多人在这一步翻车因为没有区分模型推理时间和整体吞吐量。这一节给出两版推理代码先跑通再调优。4.1 单线程推理版本先保证逻辑正确第一版直接用摄像头逐帧读取逐帧推理。逻辑最直观适合先确认识别效果和各环节参数。import cv2 import numpy as np from tensorflow.keras.models import load_model model load_model(mask_model.h5) classes [with_mask, without_mask] face_cascade cv2.CascadeClassifier(haarcascade_frontalface_default.xml) cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) while True: ret, frame cap.read() if not ret: break gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale(gray, scaleFactor1.1, minNeighbors5, minSize(60, 60)) for (x, y, w, h) in faces: face_img frame[y:yh, x:xw] face_img cv2.resize(face_img, (224, 224)) face_img cv2.cvtColor(face_img, cv2.COLOR_BGR2RGB) face_img face_img.astype(float32) / 127.5 - 1.0 pred model.predict(np.expand_dims(face_img, axis0))[0] label classes[np.argmax(pred)] color (0, 255, 0) if label with_mask else (0, 0, 255) cv2.rectangle(frame, (x, y), (xw, yh), color, 2) cv2.putText(frame, f{label} {pred[np.argmax(pred)]:.2f}, (x, y-10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, color, 2) cv2.imshow(Mask Detection, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()代码里的关键处理是图像预处理BGR2RGB是 OpenCV 和训练时数据通道顺序不同的必改项astype(float32) / 127.5 - 1.0把像素映射到 [-1, 1]因为 Keras 预训练模型默认用这个范围归一化。如果推理结果混乱先检查这两行。scaleFactor1.1表示每次缩放检测窗口缩小 10%值越小检测越慢但越准minNeighbors5表示至少 5 个相邻检测框合并才算人脸值越大误检越少但漏检会变多。这两个参数会直接影响口罩检测的最终结果。单线程版本的典型帧率是 8~12 FPS小人脸密集场景更慢。原因在于每帧都要做一次人脸检测加一次分类推理有大段等待时间。这个版本我一般只用来做功能验证真正部署还要走下一节。4.2 跳帧与异步队列把 FPS 提上去的两种做法提高实时性的第一个办法是跳帧检测。人脸在相邻帧之间变化很小不需要每帧都做重检测常见做法是每隔 2~3 帧做一次完整检测中间帧直接沿用上一帧的框。这个改动就能把帧率拉到 15 FPS 以上。第二个办法是异步队列让 IO 和推理不互相等待。摄像头读取本身有延迟推理也可能突发变慢用队列缓存可以平滑波动。我这里用 threading queue 做一个一进一出的流水线import threading import queue frame_queue queue.Queue(maxsize2) result_queue queue.Queue(maxsize2) def reader(cap): while True: ok, frame cap.read() if not ok: break if frame_queue.full(): frame_queue.get() frame_queue.put(frame) def worker(): while True: frame frame_queue.get() # 1. 人脸检测 2. 口罩分类 result detect_and_classify(frame) if result_queue.full(): result_queue.get() result_queue.put((frame, result)) t1 threading.Thread(targetreader, args(cap,), daemonTrue) t2 threading.Thread(targetworker, daemonTrue) t1.start() t2.start() while True: if not result_queue.empty(): frame, result result_queue.get() cv2.imshow(Mask Detection, frame) if cv2.waitKey(1) 0xFF ord(q): break队列的maxsize2很关键。设大了会导致延迟叠加摄像头前的动作要隔两三秒才显示设 2 能保证队列始终是最近帧观众看到的延迟在可接受范围内。这个线程模型比单线程版本在低配 CPU 上能提升 30% 左右的吞吐量。更稳妥的做法是换掉 Haar 检测器。Haar 对斜戴帽子、低头这类姿态鲁棒性很差实际场景漏检不少。我后来把 Haar 换成 MTCNN口罩检测的召回率明显上升代价是每帧人数较多时速度下降需要配合跳帧使用。这个替换在 OpenCV 里有现成实现重点是掌握裁剪尺寸转换逻辑后面的避坑章节会提到几个具体坑。4.3 性能测量不要用肉眼判断快慢判断实时性必须有数字。我习惯把推理时间单独打点统计 500 帧的平均耗时而不是用主观感受。下面这段代码可以插到推理循环里。import time total_time 0 frame_count 0 # 在每帧推理开始和结束处打点 t0 time.time() # ... 人脸检测 口罩分类 ... t1 time.time() total_time (t1 - t0) frame_count 1 if frame_count 500: avg_ms total_time / frame_count * 1000 print(favg ms per frame: {avg_ms:.1f}) frame_count 0 total_time 0打印出的平均耗时包含检测器和分类器两部分这样你能清楚瓶颈是耗时大的部分。在 CPU 上通常 60 ms 以下可用40 ms 以下流畅。如果阈值没达标优先调人脸检测的频率和分类器的输入尺寸。用这种方式统计后我发现一个规律fallback 到 MTCNN 后单帧耗时加了 20 ms但整体准确率提升了 3 个百分点这在要求严格的场景是划算的。到底怎么选就看你的场景偏向速度还是准确率。5. 避坑与常见问题排查实测踩坑记录这一章记录我在实际部署中踩过的几个坑。每一条都按“现象→原因→解决”的顺序写你可以直接对照自己的报错或异常现象。5.1 戴眼镜人群总是被误判为没戴口罩现象演示时好几个戴眼镜的同事都被标红不戴眼镜的同事基本正常。原因训练集里的“戴眼镜戴口罩”样本太少。眼镜边缘的黑色纹理被模型当成口罩边缘特征高度混淆。数据集是从普通网络图里爬的戴眼镜的样本比例不到 10%测试时这类样本被压缩后模型更倾向于判成无口罩。解决采集数据时特意增加戴眼镜、头戴帽子、口罩拉到下巴下方等难例。没有条件现场采集的话做数据增强时加RandomBrightnessContrast和CLAHE给眼镜边缘制造更多亮度变化。标注时还要增加剪辑类型把“口罩挂在脖子、没遮住口鼻”单独作为一个类别处理否则模型会把那部分误判为正样本。5.2 室内光照变化导致 FPS 波动和漏检现象摄像头正对窗户逆光时 FPS 骤降人脸经常检测不到拉上窗帘后又恢复正常。原因Haar 检测器对光照敏感的假阳性很多逆光时为了找到人脸会反复缩放扫描整幅画面耗时上升同时人脸区域整体过暗检测框置信度低于阈值就漏掉了。这不是模型本身的问题而是检测链路和光照环境的交互效应。解决对送入检测器的图像做直方图均衡化提高暗部对比度。白天场景固定时还可以手动把摄像头曝光值固定避免自动曝光频繁调整。我建议在检测前端做一次cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)).apply(gray)实测效果比全局直方图均衡更稳。这个小改动把逆光场景的检测率提高了约 15 个百分点。5.3 Keras 模型导出后再推理结果完全错乱现象训练时准确率 98%用model.save(mask_model.h5)存下来后重新load_model推理输出全是同一个类别。逻辑上没改任何代码。原因保存和加载时预处理不一致。训练时ImageDataGenerator默认按方案做归一化但我推理代码里用的是rescale1./255一个像素范围是 [0,1]一个范围是 [0,255]。MobileNet v2 预训练参数要求 [-1,1]两种归一化不匹配加载后没有任何报错结果就是识别失效。解决把推理时的预处理固定为模型训练时的预处理一条规则保证两者完全一致训练时统一按x / 127.5 - 1.0归一化推理时也这么写或者在训练时就把归一化层直接固化到模型里这样物理保存下来的模型自带预处理逻辑传到任何环境都不会歪。从那以后我所有项目都先在训练代码里把预处理算子写进模型定义里模型文件是什么样线上代码就是什么样。5.4 两类样本不均导致几乎全部判为“戴了口罩”现象训练集有 8000 张戴口罩的图没戴口罩只有 1000 张。验证集准确率看着 95%实际用起来几十个人没戴口罩的全被放过。原因数据严重不均衡模型没学到没戴口罩的表征输出概率永久偏向大类。验证集准确率高是假象因为验证集里正负样本比例被我带偏了比例本身就不真实。解决第一轮先改验证集分布让两类的样本数量一致再去调训练集。数据不够就复制少类的样本但只能缓解。真正的解法是给损失函数加权。我使用的是 Keras 的class_weight参数把负样本的权重调到正样本的 3~5 倍代码片段如下。class_weight { 0: 1.0, # with_mask 1: 3.0 # without_mask样本多时再调 } model.fit( train_generator, validation_datavalidation_generator, class_weightclass_weight, epochs30 )这个改动的原理是让模型对少数类样本误判产生更大的损失从而把决策边界推回来。注意class_weight的键是类别索引flow_from_directory默认按字母顺序编号即with_mask为 0、without_mask为 1别把顺序搞反。加了权重后模型在少数类上变敏感但也会带来一些误检需要拿真实场景的数据再验证一轮。5.5 人脸检测框尺寸对分类准确率的影响现象同一张图检测框大一点或者小一点口罩分类结果翻转。准确率时高时低。原因MobileNet v2 分类训练时的人脸区域裁剪是标准的 224×224人脸占比稳定。但如果检测框只框住了嘴部或者把额头截掉一半送入分类器的内容分布就变了。解决两个办法。一是把detectMultiScale的检测框向外扩 20% 后再送去分类器让模型能看到完整的人脸轮廓。二是直接改模型的训练策略训练阶段手动增加随机裁切的模拟让模型面对不完整人脸也能做出正确判断。这类问题在实际现场环境比较隐蔽建议把检测框连同分类结果一起保存供后序数据分析用。6. 加一步量化部署TFLite 转换与 FPS 实测前面训练好的模型是 float32直接部署到树莓派或安卓端有两点问题体积大、推理慢。口罩检测这类二分类任务对数值精度不敏感用 TensorFlow Lite 做量化是收益最大的优化。先把模型转成 TFLite再在推理端测一次 FPS就能看到量级差异。import tensorflow as tf # 之前保存的是完整 Keras 模型直接加载转换 model tf.keras.models.load_model(mask_model.h5) converter tf.lite.TFLiteConverter.from_keras_model(model) # 启用 int8 量化需要代表性数据集做校准 converter.optimizations [tf.lite.Optimize.DEFAULT] def representative_dataset_gen(): for i in range(100): img np.random.randint(0, 255, (224, 224, 3), dtypenp.uint8) img (img.astype(float32) / 127.5) - 1.0 yield [img.reshape(1, 224, 224, 3)] converter.representative_dataset representative_dataset_gen converter.target_spec.supported_ops [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] converter.inference_input_type tf.uint8 converter.inference_output_type tf.uint8 tflite_model converter.convert() with open(mask_model.tflite, wb) as f: f.write(tflite_model)量化后模型体积从约 14 MB 降到 3.8 MB在树莓派 4B 上单帧推理时间从约 55 ms 降到 25 ms。如果发现掉点超过 1%先把inference_input_type和inference_output_type改回 float32只保留权重 int8 化体积变化不大但精度会更稳。加上前面提到的跳帧整条流水线能跑到 30 FPS。我自己曾经跳过这条路直接拿 float32 模型去跑工控机帧率一直是 12 FPS 上下处理器占用 80%后来量化之后占用直接降下来。从那以后我每个口罩检测项目收尾前都会强制走一遍量化流程转换脚本固定放在项目仓库的deploy/目录下留着下次直接用。验证方法就一个量化前后的模型在同一批 200 张图上跑一遍记录准确率和单帧耗时两张表格对比没问题才算部署完。希望这个流程能帮你少走弯路。本文还有配套的精品资源点击获取