1. 项目概述:为什么一个口罩检测系统值得花三个月做毕业设计?
“毕业设计 基于YOLO实现的口罩佩戴检测系统”——这个标题乍看平平无奇,甚至有点“过时”,毕竟疫情高峰期过去好几年了,网上还能搜到一堆2020–2022年的类似课程设计。但如果你真把它当成一个“凑学分”的老套项目来糊弄,那大概率会在中期答辩被导师一句“你这个检测逻辑连鼻孔都没框准,怎么判断是否规范佩戴?”当场问住;更可能在部署实测环节发现:模型在实验室拍的白墙+单人正脸图上mAP高达92%,一拿到食堂、图书馆走廊、教学楼出入口的真实视频流里,漏检率直接飙到40%,连自己戴没戴都识别不准。
我带过三届本科毕设,每年都有至少5–7个学生选“口罩检测”作为视觉方向入门题。它之所以稳居毕业设计高频选题TOP10,并不是因为简单,恰恰相反——它是一块极佳的“能力试金石”:表面是目标检测,底层是数据工程、模型轻量化、场景泛化、边缘部署、误判归因的完整闭环。你用YOLOv5s跑通demo只算完成了15%的工作量;剩下85%,全在那些没人写进论文致谢、但决定你能不能顺利答辩的细节里。
这个项目真正解决的问题,从来不是“有没有戴口罩”,而是:
- 在光照不均(比如背光的楼梯口)、遮挡严重(低头看手机、侧身说话)、姿态多变(仰头、歪头、戴眼镜反光)的真实校园场景下,如何稳定区分“戴歪了”“只遮嘴没遮鼻”“用围巾临时替代”“口罩滑落至下巴”这四类关键违规状态;
- 如何让模型在树莓派4B+USB摄像头这种算力受限的嵌入式设备上,保持≥8 FPS的实时推理速度,同时把误报率压到5%以内;
- 当教务处突然要求“加一个统计报表功能,按楼栋/时段导出未规范佩戴人数”,你怎么在不重训模型的前提下,快速接入后端服务并输出结构化结果。
关键词“YOLO”“口罩佩戴检测”“毕业设计”背后,实际对应的是计算机视觉工程落地中最典型的三道坎:小样本泛化、轻量级部署、业务逻辑耦合。它不考你能不能调包,而考你敢不敢删掉config文件里一行默认参数、敢不敢手动重写dataloader的数据增强逻辑、敢不敢对着1000张标注错误的图片一张张修正bbox——这些事,PyTorch官网教程不会教,但你的毕设答辩PPT第一页就得写清楚。
所以别再搜“YOLO口罩检测GitHub复现教程”了。这篇内容,就是我用两届学生踩过的坑、三次推翻重做的训练日志、四台烧坏的树莓派电源整理出来的实战手册。它不讲YOLO原理推导,不列公式,只告诉你:从开题报告写第一行字开始,到答辩前夜最后一版模型部署成功,每一步该做什么、为什么这么做、哪里最容易栽跟头。
2. 整体设计与思路拆解:为什么必须放弃“直接下载预训练模型+微调”这条路?
很多同学开题就想着“找一个YOLOv8的口罩检测预训练权重,改两行代码,换自己的数据集finetune一下,两周搞定”。听起来很美,实测下来,90%的人卡死在第三步:验证集准确率虚高,测试视频一跑全是误检。原因很简单——公开的口罩检测数据集(如Real-World Mask Dataset、MAFA)和你学校真实场景存在巨大域偏移(domain shift):前者多为高清正面特写、背景干净、口罩颜色统一;后者是监控俯拍、人脸占比小、口罩五颜六色、还有大量反光/阴影/运动模糊。
因此,本项目的整体架构,从第一天起就明确拒绝“拿来主义”,采用三级渐进式建模策略:
2.1 第一级:构建强鲁棒性基础检测器(YOLOv8n + 自研数据增强)
不用YOLOv5或YOLOv7,选YOLOv8n(nano版)是经过实测权衡的结果:
- 参数量仅3.2M,比YOLOv5s(7.2M)小一半,对树莓派GPU内存更友好;
- 内置Anchor-Free检测头,对小目标(如侧脸口罩边缘)定位更准;
- 官方提供ONNX导出接口成熟,省去自定义op转换的坑。
但直接加载ultralytics官方发布的yolov8n.pt权重,在我们采集的校内数据上mAP@0.5仅为68.3%。关键瓶颈不在模型结构,而在数据质量与分布。我们最终构建的数据集包含三个子集:
- 主训练集(1200张):涵盖教学楼、图书馆、食堂、宿舍楼四个典型场景,全部由学生志愿者实拍,严格标注“规范佩戴”“未遮鼻”“未遮嘴”“未佩戴”四类标签;
- 强干扰验证集(300张):专门收集反光眼镜、口罩边缘褶皱、口罩与肤色相近(如米白口罩+浅肤色)、多人密集遮挡等易错样本;
- 跨场景测试集(200张):从校外商场、地铁站公开视频中截取片段,用于检验模型泛化能力。
提示:千万别用网络爬虫自动下载的“口罩图片”!我们试过用百度图片爬1000张,清洗后只剩87张可用——其余全是海报、商品图、艺术照,bbox标注完全失真。真实数据必须自己采、自己标、自己验。
2.2 第二级:引入规则引擎补足纯模型缺陷
YOLO再强,也是个“像素分类器”,它能告诉你“这里有个口罩形状”,但无法理解“口罩上沿必须高于鼻梁骨最低点”。所以我们设计了一个轻量级后处理模块:
- 对每个检测框,调用Dlib的68点面部关键点检测器(仅需CPU,耗时<15ms),精确定位左右眼内眦、鼻尖、人中点;
- 计算口罩框top边与鼻尖y坐标的垂直距离Δy,若Δy > 0.15×人脸高度,则判定“未遮鼻”;
- 若口罩框bottom边与人中点y坐标距离 < 0.05×人脸高度,则判定“未遮嘴”。
这个规则模块代码不到50行,却把“规范佩戴”识别准确率从79.6%提升到93.2%。它不增加模型复杂度,却用领域知识堵住了纯数据驱动的漏洞。
2.3 第三级:部署层解耦设计,支撑未来扩展
毕设常被忽略的一点是:模型只是工具,系统才是产品。我们把整个流程拆成三个可独立替换的模块:
- Detection Module:YOLOv8n + 规则引擎,输出JSON格式结果(含bbox、label、confidence、rule_flag);
- Service Module:Flask轻量API,接收RTSP流或本地视频,调用Detection Module,缓存最近10分钟统计;
- Report Module:基于SQLite的本地数据库,记录时间戳、位置标签(通过配置文件设定)、违规类型,支持CSV导出。
这样设计的好处是:答辩时演示“实时检测”只需启动Service Module;后续想加人脸识别绑定学号?只改Service Module;想把数据同步到学校统一平台?只动Report Module。所有改动都不影响核心检测逻辑。
3. 核心细节解析与实操要点:数据标注、模型训练、规则校准的硬核细节
这一节不讲概念,只列你明天就能用上的操作细节。所有参数、路径、命令,均来自我们最终通过答辩的版本。
3.1 数据标注:为什么LabelImg会毁掉你的毕设?
绝大多数同学用LabelImg画矩形框,标注“mask”一个类别。这是最大误区。YOLO对边界框的IoU敏感,而口罩边缘本身模糊、易反光,单类别标注会导致:
- 模型学习不到“未遮鼻”和“未遮嘴”的空间关系差异;
- 同一帧多人场景下,极易把A的口罩框错配给B的脸部关键点。
我们强制采用四类别+精细化标注规范:
correct:口罩上沿 ≥ 鼻梁骨最高点,下沿 ≤ 人中底部,且完全覆盖口鼻;no_nose:上沿明显低于鼻梁骨(目视可辨),但下沿正常;no_mouth:上沿正常,下沿明显高于人中底部;no_mask:面部无口罩或仅遮半张脸。
注意:标注时必须开启LabelImg的“Verify Image”功能,每张图保存前自动检查是否有重叠框、超边界、标签名拼写错误。我们曾发现某同学连续标注200张,把
no_nose全打成no_nosee,导致训练时类别索引错乱,loss直接nan。
标注工具我们最终切换到CVAT(开源在线平台),优势在于:
- 支持多人协同标注,自动合并冲突;
- 内置“Interpolation”功能,对视频序列只需标首尾帧,中间自动插值;
- 导出格式直接兼容Ultralytics的YOLOv8目录结构(
images/train/,labels/train/)。
3.2 模型训练:超参数不是调出来的,是算出来的
YOLOv8默认配置(batch=16,imgsz=640,epochs=100)在我们的数据集上效果极差:
imgsz=640导致小目标(侧脸口罩)特征丢失,recall仅52%;batch=16在单卡2060上显存溢出,强行降为8又导致BN层统计失效。
我们通过三轮消融实验确定最优组合:
| 参数 | 测试值 | mAP@0.5 | Recall | 训练耗时 | 关键现象 |
|---|---|---|---|---|---|
imgsz | 416 | 71.2% | 63.5% | 2h15m | 小目标召回显著提升,但大脸模糊 |
imgsz | 512 | 78.6% | 74.1% | 3h02m | 平衡最佳,边缘清晰度足够 |
batch | 8 | 76.3% | 71.8% | 显存占用62% | BN稳定,但收敛慢 |
batch | 12 | 78.6% | 74.1% | 显存占用89% | 需关闭--cache,但收敛快30% |
最终选定:imgsz=512,batch=12,epochs=200,lr0=0.01,lrf=0.01(余弦退火终值)。特别注意:
- 必须启用
--cache ram,否则硬盘IO成为瓶颈,训练速度下降40%; --optimizer adamw比默认sgd收敛更稳,尤其对小样本;--close-mosaic 50:前50 epoch禁用mosaic增强,让模型先学好单图基础特征。
训练命令实录:
yolo detect train \ data=mask_dataset.yaml \ model=yolov8n.pt \ epochs=200 \ imgsz=512 \ batch=12 \ lr0=0.01 \ lrf=0.01 \ optimizer=adamw \ cache=ram \ close_mosaic=50 \ name=mask_v8n_512_adamw \ device=03.3 规则引擎校准:用真实误差分布反推阈值
规则引擎的Δy阈值(0.15×人脸高度)不是拍脑袋定的。我们做了如下校准:
- 用训练好的YOLOv8n在验证集上跑一遍,提取所有
correct类别的检测框; - 对每个框,用Dlib获取面部关键点,计算实际鼻尖y坐标与框top边的距离,归一化为人脸高度比例;
- 绘制直方图,发现95%的
correct样本Δy ∈ [0.08, 0.14],而no_nose样本Δy ∈ [0.18, 0.35]; - 取交界处0.15作为分割阈值,此时假阳性率(correct被判no_nose)为3.2%,假阴性率(no_nose被判correct)为6.7%,综合F1最高。
实操心得:Dlib的68点模型在侧脸>45°时关键点漂移严重。我们加了一条硬规则——若左右眼内眦x坐标差 < 0.2×人脸宽度,则跳过规则判断,仅依赖YOLO置信度。这条规则让侧脸误判率下降11%。
4. 实操过程与核心环节实现:从训练完成到树莓派实时运行的全流程
现在你有了一个mAP 78.6%的模型,下一步不是直接部署,而是经历一场“工程化淬炼”。以下是我们最终跑通的完整链路,每一步都附有避坑说明。
4.1 模型导出:ONNX不是终点,是起点
YOLOv8官方支持export format=onnx,但默认导出的ONNX模型在树莓派上会报错:
- 缺少
opset_version=12,树莓派OpenVINO不兼容; - 动态轴(dynamic axes)未冻结,导致推理时shape报错。
正确导出命令:
yolo export \ model=runs/detect/mask_v8n_512_adamw/weights/best.pt \ format=onnx \ opset=12 \ dynamic=False \ simplify=True \ imgsz=512导出后必须用Netron打开检查:
- 输入节点名应为
images,shape为[1,3,512,512](固定batch=1); - 输出节点应为
output0,shape为[1,84,8400](YOLOv8的anchor-free输出格式)。
提示:
simplify=True会自动合并常量节点,减小模型体积35%,但可能导致某些旧版ONNX Runtime加载失败。若遇此问题,改用--simplify False,后续用onnx-simplifier手动优化。
4.2 树莓派环境搭建:绕开apt源的致命陷阱
树莓派OS(Raspberry Pi OS Bullseye)默认Python 3.9,但Ultralytics要求≥3.8,OpenVINO要求Python 3.8–3.10,看似兼容。但apt安装的python3-opencv是4.5.4版本,与YOLOv8的cv2.dnn模块存在ABI不兼容,cv2.dnn.readNetFromONNX()直接段错误。
解决方案(实测唯一稳定路径):
- 卸载apt版opencv:
sudo apt remove python3-opencv; - 用pip安装编译版:
pip3 install opencv-python-headless==4.8.1.78(指定版本,4.8.1之后的版本修复了ARM64 dnn backend bug); - 安装OpenVINO 2023.0 for ARM64:从Intel官网下载
l_openvino_toolkit_arm64_2023.0.0.12000.tgz,解压后运行sudo -E ./install.sh; - 激活环境:
source /opt/intel/openvino_2023/setupvars.sh。
验证是否成功:
from openvino.runtime import Core core = Core() print(core.available_devices) # 应输出 ['CPU']4.3 推理代码编写:别碰Ultralytics的detect.py
直接运行yolo predict source=...在树莓派上会因内存不足崩溃。我们必须手写轻量推理脚本,核心原则:
- 预分配内存,避免运行时malloc;
- 关闭所有日志和进度条;
- 图像预处理用OpenCV C++后端,不用numpy循环。
关键代码片段(inference_pi.py):
import cv2 import numpy as np from openvino.runtime import Core # 预加载模型(全局变量,避免重复加载) ie = Core() model = ie.read_model(model="best.onnx") compiled_model = ie.compile_model(model=model, device_name="CPU") # 预分配输入输出内存(关键!) input_layer = compiled_model.input(0) output_layer = compiled_model.output(0) input_shape = list(input_layer.shape) output_shape = list(output_layer.shape) # 初始化输入tensor(避免每次resize重新分配) input_tensor = np.zeros(input_shape, dtype=np.float32) def preprocess_frame(frame): # BGR to RGB + resize + normalize(全部用cv2函数,不走numpy) frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) frame_resized = cv2.resize(frame_rgb, (512, 512)) frame_norm = frame_resized.astype(np.float32) / 255.0 # HWC to CHW + add batch dim frame_chw = frame_norm.transpose(2, 0, 1)[np.newaxis, ...] return frame_chw def infer_one_frame(frame): input_data = preprocess_frame(frame) input_tensor[:] = input_data # 复用内存 result = compiled_model([input_tensor])[output_layer] return result实测效果:单帧推理耗时从1200ms(原生PyTorch)降至320ms,FPS稳定在3.1,满足实时性底线。
4.4 系统集成:Flask API的内存泄漏防护
Flask默认多线程,但OpenVINO的CompiledModel不是线程安全的。若不做处理,多请求并发时会出现:
RuntimeError: Failed to create inference request;- 内存占用每小时增长200MB,8小时后OOM。
解决方案:
- 使用
threading.Lock()保护模型调用; - 设置Flask
max_content_length=16*1024*1024,防恶意大图攻击; - 每100次请求后主动释放OpenVINO缓存:
compiled_model.reset_state()。
API核心代码:
from flask import Flask, request, jsonify import threading app = Flask(__name__) infer_lock = threading.Lock() @app.route('/detect', methods=['POST']) def detect(): if 'image' not in request.files: return jsonify({'error': 'No image provided'}), 400 file = request.files['image'] img_array = np.frombuffer(file.read(), np.uint8) frame = cv2.imdecode(img_array, cv2.IMREAD_COLOR) with infer_lock: # 关键:串行化模型调用 result = infer_one_frame(frame) # 后处理+规则引擎... return jsonify({'detections': detections})5. 常见问题与排查技巧实录:答辩前夜最怕遇到的7个问题
以下是我们在三届毕设中,学生在最后72小时集中爆发的典型问题,附真实解决过程。
5.1 问题速查表
| 现象 | 可能原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
| 训练loss震荡剧烈,不收敛 | 学习率过高或数据标注噪声大 | 1. 检查train_batch和val_batch是否一致;2. 用yolo train ... plots=True生成混淆矩阵,看no_nose类是否大量误标为correct | 降低lr0至0.005,人工复查前100张no_nose标注 |
| 树莓派推理时CPU占用100%,FPS<1 | OpenCV未启用NEON加速 | 1. 运行cv2.getBuildInformation(),确认NEON字段为YES;2. 检查是否安装了libatlas-base-dev | sudo apt install libatlas-base-dev,重装opencv |
| Flask API返回空JSON | OpenVINO输出解析错误 | 1. 打印原始result.shape,确认是否为[1,84,8400];2. 检查yolov8的postprocess函数是否适配ONNX输出 | 手动实现NMS:cv2.dnn.NMSBoxes(boxes, scores, 0.25, 0.45) |
| 规则引擎判定“未遮鼻”但肉眼明显规范 | Dlib关键点定位偏差 | 1. 保存Dlib检测的68点坐标图,叠加到原图;2. 检查鼻尖点(point 30)是否落在鼻梁骨上 | 改用MediaPipe Face Mesh(ARM64兼容),精度提升22% |
| 导出ONNX后模型体积暴涨3倍 | simplify=False且含调试节点 | 1. 用onnx.shape_inference.infer_shapes_path("best.onnx")检查;2. 查看Netron中是否有ConstantOfShape等冗余节点 | 重导出时加--simplify True,或用onnxsim工具二次简化 |
| 多线程调用CompiledModel报错 | 模型实例被多线程共享 | 1. 在infer_one_frame开头加print(threading.current_thread().name);2. 检查是否全局单例 | 改为每个线程创建独立compiled_model,用threading.local()管理 |
| 测试视频中连续多帧误检同一人 | 未做跟踪去重 | 1. 统计10秒内同一bbox中心点移动距离;2. 若<10像素则视为重复 | 加入ByteTrack轻量跟踪器,IoU阈值设为0.3 |
5.2 一个真实案例:食堂门口的“幽灵口罩”
某学生在食堂门口部署后,发现每天11:45–12:00之间,监控画面左下角固定位置总出现一个“飘忽”的口罩框,实际无人经过。持续排查3天,最终定位:
- 该位置有一块反光地砖,中午阳光直射时形成高亮区域;
- YOLO将高亮区域误判为“口罩白色区域”;
- 规则引擎因无面部关键点,跳过校验,直接输出
correct。
解决方案:
- 在预处理阶段加入局部对比度抑制:对图像分块,若某块标准差>50,则用CLAHE算法降低对比度;
- 添加运动检测过滤:用OpenCV的
cv2.createBackgroundSubtractorMOG2()检测静止区域,对静止超5秒的检测框强制置信度<0.3。
这段代码仅12行,却解决了困扰一周的“幽灵检测”,也成为答辩时最亮眼的工程细节。
5.3 答辩话术建议:把缺陷转化为亮点
导师最爱问:“你的系统有什么不足?” 别说“数据量不够”“精度有待提高”。换成:
“当前系统对强逆光场景的鲁棒性仍有提升空间。但我们已设计了可插拔的‘光照补偿模块’——当环境亮度<50lux时,自动启用CLAHE增强;当检测到大面积高光时,触发反光抑制算法。这部分已在GitHub开源,后续可无缝接入。”
把问题包装成“已规划的演进路径”,比承认缺陷更有说服力。
6. 毕设之外的延伸价值:这个项目如何帮你拿下暑期实习?
最后说点实在的。很多同学做完毕设就删代码,其实这套技术栈,正是当前工业界视觉算法岗的主流需求:
- 数据工程能力:你亲手清洗过1500张真实场景图,比刷10道LeetCode更能证明你处理脏数据的能力;
- 轻量化经验:树莓派部署经历,让你在面试“模型压缩”问题时,能说出具体参数(如ONNX opset=12的兼容性代价);
- 全栈意识:Flask API + SQLite报表,证明你不是只会调参的“模型民工”,而是懂交付的工程师。
我们有两位学生,凭这个毕设拿到了某安防公司的算法实习offer。面试官没问YOLO原理,只问:
- “如果客户要求把检测延迟从320ms压到150ms,你会怎么做?”
- “当发现新场景(如戴面罩)导致准确率下降,你的迭代流程是什么?”
答案不在书本里,而在你调试树莓派电源烧毁第七次时记下的笔记里。
所以别把它当任务,当成你工程师生涯的第一块磨刀石。当你在答辩PPT最后一页,放上食堂门口实时检测的截图,右下角显示“FPS: 3.1 | 今日规范佩戴率: 92.7%”,那一刻,你交付的不是一个毕设,而是一个能真实运转的小系统——这比任何高分都更接近技术的本质。