课程设计/毕业设计里,“基于机器学习卷积神经网络的口罩佩戴识别与检测”这个选题,这几年是真火。表面看就是个二分类问题:戴了口罩一类、没戴一类,好像训练个模型就能交差。可真要从数据处理、卷积神经网络搭建、训练调参,一路做到摄像头实时检测和答辩演示,每一步都有足够多的细节可以推翻你原来的计划。不少同学喜欢直接拉一个网上的项目跑通就以为完事了,结果答辩被问两句就露馅。这篇文章把我自己做这个题目、以及这几年帮不少同学review方案时踩过的坑、验证过的做法整理一遍,从任务选型讲到训练闭环,再讲到实时检测和答辩准备,给正在做或准备做这个题目的同学一份可以直接照着操作的参考。
1. 任务定位与方案选型:先搞清楚是分类题还是检测题
1.1 课程设计真正想考核的是什么
课程设计不是算法比赛,目的不是把准确率卷到99.9%,而是考察你有没有把一个实际问题拆成完整机器学习流程的能力,并且每一步的取舍都讲得清楚。拿口罩佩戴识别来说,通常包含四件事:组织数据、搭建网络、训练调参、做出可演示的结果。老师真正想看到的是,模型精度是一个自然结果,而你解释技术方案的能力才是核心评分点。
这一认知直接决定技术路线的难度。如果只想炫技,给自己堆一个又新又复杂的检测模型,代码量巨大,大部分代码还来自网络,答辩时老师顺着某个类名或者层结构往下追问,很容易当场卡住。反过来,如果做一个三层卷积的小网络,训练半天精度只有六成多,展示效果也很尴尬。合理区间是:主体结构用成熟的卷积神经网络,配合清晰的两段处理流程,既有原理可讲,又有稳定效果可以演示。
1.2 “识别”和“检测”在题目里到底指什么
计算机视觉领域,“检测”通常指目标检测,需要输出目标的边界框和类别;“识别”通常指分类,判断输入属于哪个类别。课程题目里写“识别与检测”,很多时候是把两个词混着用,实际意思是“判断画面里的人有没有正确佩戴口罩”。你需要在报告里先做一次定义:我的方案把口罩佩戴识别定义为对人脸区域做二分类,佩戴/未佩戴;检测动作则由人脸检测器完成,先锁定位人脸位置,再把裁剪出来的人脸区域交给卷积神经网络分类。
这个定义方式有个很实际的好处:老师如果问“你的检测框在哪”,你可以解释检测是人脸检测加口罩分类的两段式流程;老师如果追问“目标检测和图像分类的区别”,你也答得上来。反而是那些一上来就想做端到端目标检测模型的人,需要海量带框标注数据,课程设计周期内根本标不完,还容易翻车。
1.3 自建CNN和迁移学习怎么选
建议两套方案都做,一主一备。自建CNN就是卷积层、ReLU激活、池化层的堆叠,参数少、训练快,能把网络原理完整展示出来;迁移学习是加载一个在大型图像数据集上预训练过的骨干网络,只替换最后的分类层做微调。前者胜在代码完全可控,老师问每一层的输入输出你都能对答如流;后者胜在精度稳定,实际展示效果更好。
我的建议顺序是:先写一个自建CNN跑通全流程,证明“我能从头搭一个卷积神经网络”;再用迁移学习模型作为最终展示版本,证明“我知道怎么用预训练模型又快又稳地提升性能”。答辩讲两个模型的对比,比只贴在网上下载的模型有说服力得多。这里有个常见的观点误区,觉得自建CNN太基础、拿不出手。其实课程设计的关键永远是“你在什么条件下做了哪些取舍、效果如何”,而不是模型结构有多新。能把一个简单模型讲透,远比贴一个跑不通的“最新网络”有价值。
2. 数据集构建:开源数据、自建数据与目录结构怎么安排
2.1 数据从哪来:公开数据为主,自拍扩充为辅
推荐先从公开的口罩人脸数据集下手。这类数据一般已经按“戴口罩/未戴口罩”两个文件夹分好,下载后只需要做一次清洗,删掉模糊的、重复的、带了奇怪遮挡的图片。如果觉得数据量少,或者场景太单一,就自己补拍。补拍方式很简单:找几个同学,手机拍视频,每两三帧截一张,分别截“口罩戴好”和“口罩未戴”两种情况。
补拍数据主要解决“公开数据集的场景和答辩现场差异太大”的问题。很多公开数据集里的人脸拍摄条件比较理想,正脸、均匀光照、口罩摆得很正。真到了答辩演示,教室里的灯光、倾斜角度、口罩戴偏,都会让模型表现明显变差。自建数据不需要多,每类一两百张就足够让模型对现场环境更皮实。
2.2 目录结构、命名与训练验证划分
推荐目录结构如下:
data/ train/ with_mask/ without_mask/ valid/ with_mask/ without_mask/ test/ with_mask/ without_mask/训练集、验证集、测试集分开,是一个被很多人忽略但非常重要的习惯。训练集用来更新参数,验证集用来在每个epoch后评估模型并指导调参,测试集是在整个训练流程结束后做最终验收的“陌生题目”。很多同学只划分训练集和测试集,验证全靠测试集,相当于考试前先看了答案,调参过程完全失真。
划分时有一个特别容易踩的坑:同一人、同一摄像头、同一时间段截出来的相似图片,不能既出现在训练集又出现在验证集。如果两边有大量几乎相同的脸,模型学到的其实是“记住这张脸”而不是“判断有没有口罩”,验证精度会虚高,这种现象在课程设计里叫数据泄漏。我不太放心的时候,会用感知哈希在训练集和验证集之间做一次相似度粗查,相似度超过阈值的样本直接调整归属,保证同一个人的面孔只出现在一个集合里。
2.3 类别数量、类别不均衡与评估方式
公开数据加自建补拍后,每类五六百张是比较合理的量级。数据集太小,CNN容易过拟合;数据集太大,筛选和处理又会占用大量时间。另外要特别注意两类数量不要差太悬殊。如果未戴口罩的图只有戴口罩的三分之一,模型训练时会把模糊一点的图都判成数量多的那一类,因为这样能让整体损失下降得更快。
类别不均衡有三个应对办法:给样本数少的类别设置更高的class weight;用数据增强给少数类成倍增加训练样本;评估时不完全看accuracy,而是看混淆矩阵、精确率、召回率。答辩时被问到“准确率这么高是不是假的”,把这三招拿出来,印象分会很不一样。
2.4 数据增强的正确打开方式
数据增强的本质是让模型对拍照时常见的“歪角、暗光、小幅偏移”不敏感。我常用的组合是水平翻转、随机亮度对比度调整、小角度旋转和随机缩放裁切。增强不是越多越好,人脸旋转超过30度就违背语义了,毕竟现实场景里很少会倒着拍照。另外,增强只应在训练集上做,验证集要保持原始分布,才能真正反映模型在真实数据上的水平。
这里要强调一个很容易犯的错误:不要用太夸张的模糊或遮挡做增强,否则模型可能学到“模糊等于戴口罩”这种奇怪关联。演示时有人用帽子遮住额头,模型反而误判成佩戴口罩,就是因为训练数据里的增强策略脱离了真实场景。在数据集处理上多花一小时,后面训练和调参会省下好几个晚上。这个顺序千万别颠倒。
3. 训练流程的完整闭环:迁移学习、数据增强与调参记录
3.1 迁移学习的基本原理:为什么预训练模型这么好用
大型图像数据集上预训练过的网络,前几层卷积学到的是通用特征,比如边缘、纹理、形状;越往后越偏向某个数据集特有的语义。把通用特征迁移到“人脸有没有口罩”这类任务上依然高度有效,因为口罩识别同样依赖边缘、轮廓和局部纹理。真正需要重新学习的只是后半部分,如何把这些通用特征组合成“口罩”的概念。
所以使用预训练骨干网络时,通常先固定骨干参数,只训练最后新增的分类层,这样训练量小、速度快,效果往往就已经不错。如果还想进一步提高,再解冻骨干网络的最后几十层,用一个更小的学习率继续微调。这个过程叫两阶段微调,是课程设计报告中一个很能展示理解深度的细节。
很多同学训练时发现,迁移学习跑的epoch不多,准确率就已经很高了,担心是不是代码出了bug。这其实是预训练权重带来的正常现象,相当于你继承了一个已经会看轮廓纹理的视觉系统,只需要教会它如何组织这些特征来判断口罩。
3.2 训练脚本的核心代码详解
用TensorFlow 2 / Keras来实现,代码量最小,适合课程设计的篇幅。数据加载部分直接用image_dataset_from_directory,它会自动从目录结构读取图片和标签,省去手动写标签的麻烦。核心代码如下:
import tensorflow as tf from tensorflow.keras import layers IMG_SIZE = (224, 224) BATCH_SIZE = 32 EPOCHS = 30 NUM_CLASSES = 2 # 数据加载 train_ds = tf.keras.preprocessing.image_dataset_from_directory( "data/train", label_mode="categorical", image_size=IMG_SIZE, batch_size=BATCH_SIZE, shuffle=True, ) valid_ds = tf.keras.preprocessing.image_dataset_from_directory( "data/valid", label_mode="categorical", image_size=IMG_SIZE, batch_size=BATCH_SIZE, shuffle=False, ) # 迁移学习:预训练骨干 + 分类头 base_model = tf.keras.applications.ResNet50( weights="imagenet", include_top=False, input_shape=(IMG_SIZE[0], IMG_SIZE[1], 3), ) base_model.trainable = False model = tf.keras.Sequential([ base_model, layers.GlobalAveragePooling2D(), layers.Dropout(0.5), layers.Dense(NUM_CLASSES, activation="softmax"), ]) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-4), loss="categorical_crossentropy", metrics=["accuracy"], )这段代码短,但每个组件都对应报告里的一个章节。GlobalAveragePooling2D把特征图压成一个向量,减少参数量;Dropout(0.5)在训练时随机丢弃一半神经元,抑制过拟合;Dense输出两类概率。老师问为什么不用Flatten,你可以回答全局平均池化更省参数、更不容易过拟合,这就能看出你是理解而不是照抄。
3.3 参数配置与回调函数:不该靠手气调参
课程设计里最常见的说法是“我试了好多参数,最后发现这个最好”。听起来很努力,但缺乏系统性。比较负责任的做法是先把参数设置好,并明确记录每一组实验的结论。下面是我常用的参数配置:
| 参数 | 取值 | 设置逻辑 |
|---|---|---|
| 输入尺寸 | 224×224 | ResNet50默认输入 |
| batch size | 32 | 大多数学生GPU/内存都能跑动 |
| 初始学习率 | 1e-4 | 迁移学习不宜过大,避免破坏预训练权重 |
| 优化器 | Adam | 自适应学习率,收敛稳定 |
| epochs | 30 | 配合早停,实际跑10~20轮即可 |
| Dropout | 0.5 | 缓解过拟合 |
训练时一定要挂上回调函数。早停可以防止过拟合,自动保存可以在训练过程中随时保留最佳模型,学习率自动衰减则能帮你在训练的后期做更精细地收敛:
callbacks = [ tf.keras.callbacks.EarlyStopping(patience=5, restore_best_weights=True), tf.keras.callbacks.ModelCheckpoint( "best_mask_model.h5", save_best_only=True ), tf.keras.callbacks.ReduceLROnPlateau( monitor="val_loss", factor=0.5, patience=3 ), ] history = model.fit( train_ds, validation_data=valid_ds, epochs=EPOCHS, callbacks=callbacks, )“保存训练曲线保存最佳模型”这两件事一定要出现在复现步骤里。答辩现场经常问:你的模型怎么保存的,之后怎么恢复?ModelCheckpoint就是答案。训练完把history里的loss和accuracy曲线画出来,贴到报告里,比写十句话都有说服力。
3.4 训练日志里最常见的三种异常
我帮人看训练过程时,发现绝大多数问题逃不出三类:
第一种,loss下降很慢,精度一直停在低水平。先查学习率是不是太小;再查数据预处理有没有统一。image_dataset_from_directory默认会做归一化,但如果你自己写数据加载,很容易漏掉这一步,导致训练和推理时图片数值范围不一致,模型效果自然很乱。
第二种,训练精度高、验证精度低。这是教科书级别的过拟合,说明模型在背训练集。对策是提高Dropout比例、加强数据增强、减少训练轮数,或者用早停提前截断。
第三种,验证精度忽高忽低震荡。多半是验证集太小或者batch大小不合适,导致每次评估的样本差异很大。可以增大batch、扩大验证集、降低学习率,再观察是否变平稳。
| 现象 | 可能原因 | 处理动作 |
|---|---|---|
| loss不降 | 学习率过小或未归一化 | 调大学习率,检查预处理 |
| 训练好验证差 | 过拟合 | 加Dropout,增强数据,提前截断 |
| 验证震荡 | 验证集太小或学习率过大 | 扩大验证集,降低学习率 |
这三类排查过程写进实验报告,比“我多次实验最终取得不错效果”这种描述要扎实得多。老师看实验记录,看的是你碰到问题之后能不能一步步定位原因。
4. 单图推理到实时检测:从摄像头画面到逐帧输出结果
4.1 单张图片推理:先确认预处理完全一致
训练结束后,别急着打开摄像头,先拿一张没参与过训练的照片做前向推理。这一步能最快暴露代码里最隐蔽的坑:训练和推理时预处理不一致。image_dataset_from_directory会自动把图片缩放到目标尺寸并做归一化,推理时你也必须手动执行同样的操作:
import numpy as np from tensorflow.keras.preprocessing import image as keras_image def preprocess(img_path): img = keras_image.load_img(img_path, target_size=(224, 224)) arr = keras_image.img_to_array(img) arr = arr / 255.0 arr = np.expand_dims(arr, axis=0) return arr def predict_mask(img_path): pred = model.predict(preprocess(img_path))[0] idx = int(np.argmax(pred)) label = ["with_mask", "without_mask"][idx] conf = float(pred[idx]) return label, conf如果训练时归一化了、推理时没除,或者反过来,图片分布整体错位,即使模型权重完全正确,输出也是乱的。这种问题第一眼看上去特别像“模型没训练好”,实际上只是预处理代码不一致。
4.2 为什么需要“人脸检测 + 口罩分类”两段式
纯分类网络只能判断整张图是不是口罩,它不知道人脸在哪个位置。想要在画面里框出每个人并判断每个人是否戴口罩,需要先定位人脸,再把每个人脸区域单独交给分类模型,这就是两段式流水线:人脸检测负责给出边界框,口罩分类负责判断框内区域。
人脸检测器怎么选?最简单的方案是OpenCV自带的Haar级联检测器,不用额外安装模型,但老一代检测器对角度的敏感度比较高,演示时脸稍微偏一点就框不到。更稳的方案是轻量的人脸检测模块,实时性好,还自带置信度。我的建议是:图省事、不想折腾环境就先用Haar级联跑通;想要稳定的演示效果,并且能在答辩时讲出“我用了一个轻量人脸检测器”,就换后者。核心调用代码很短:
import mediapipe as mp mp_fd = mp.solutions.face_detection with mp_fd.FaceDetection(model_selection=0, min_detection_confidence=0.5) as detector: results = detector.process(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) if results.detections: for d in results.detections: bbox = d.location_data.relative_bounding_box注意人脸检测返回的通常是相对坐标,也就是0到1之间的比例值,需要再映射回图像的像素坐标。这段坐标换算逻辑最好单独抽成一个face_utils.py,写完后用一张图片画框验证,确认框正好盖住整张脸。别小看这一步,我见过的检测失败案例里,相当一部分是坐标映射写错了。
4.3 摄像头实时检测的整合流程
实时检测的主循环只有三件事:读一帧画面、做人脸检测、对每个人脸框分类并画框。整体结构如下:
cap = cv2.VideoCapture(0) while cap.isOpened(): ret, frame = cap.read() if not ret: break # 先缩小画面,检测速度会快很多 frame = cv2.resize(frame, (640, 480)) faces = detect_faces(frame) # 返回 [(x, y, w, h), ...] for (x, y, w, h) in faces: face_crop = frame[y:y + h, x:x + w] label, conf = predict_mask_face(face_crop) color = (0, 200, 0) if label == "with_mask" else (0, 0, 255) cv2.rectangle(frame, (x, y), (x + w, y + h), color, 2) cv2.putText( frame, f"{label}: {conf:.2f}", (x, y - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2, ) cv2.imshow("mask detection", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()predict_mask_face复用4.1里的预处理逻辑,只是输入从图片路径变成摄像头截取的裁剪块。这里有几个常见翻车点:直接对相机原始大图跑人脸检测,帧率会掉到个位数,先缩放到640×480再处理;人脸距离镜头太远,裁剪块太小,放大到224×224后细节全丢,分类会出错,演示时让人离镜头近一些;不要在循环里频繁打印日志刷屏,结果的输出集中在画面框上就好。
4.4 帧率、漏检与误判的权衡
实时检测的瓶颈通常不在口罩分类模型,而在人脸检测器。Haar级联很轻但容易漏检;轻量人脸检测模块快,但偶尔会跳框。让检测框稳定的一个简单办法是记录最近几帧的框位置,做一个滑动平均,框就不会一抖一抖的。这个优化不是必需项,但当演示画面里的框抖得很厉害时,观感会很受影响,值得花十几分钟做。
另一个值得思考的点是置信度阈值。把口罩分类的阈值调到0.8以上,宁可少报也不误报;如果课题场景是“发现未佩戴口罩就报警”,就要适当调低阈值,保证阳性样本优先被捕获。阈值取多少,答辩时往往比整体准确率更让老师感兴趣,因为它体现了你对实际运行需求的思考,而不是只会跑代码。
5. 答辩视角的查漏补缺:高频失败模式与展示加分项
5.1 现场演示最容易翻车的几个瞬间
第一,摄像头驱动突然不兼容。提前录一段测试视频,用完全相同的处理管线跑一遍,现场如果摄像头不行,就切换成视频文件输入。这个plan B非常管用,演示的流畅度取决于你有没有备好退路。第二,后台程序抢占资源,推理卡成幻灯片。演示前关掉浏览器和即时通讯软件,尤其不要现场开一大堆标签页。第三,演示场景的光线问题。正面打光、人脸距离固定,模型表现最稳定;如果把会议室大屏的强光当成背光,再好的模型也会误判。最稳妥的演示形式不是打开摄像头对准观众,而是先在视频文件上完整演示,再补一小段摄像头实时画面。
5.2 老师大概率会问的几类问题
- 为什么用卷积神经网络,不用传统方法?——卷积可以逐层自动提取从局部纹理到全局形状的特征,传统方法需要手工设计特征,泛化能力差。
- 为什么用预训练模型?——小数据集下从头训练容易过拟合,预训练权重提供了大量通用视觉知识,迁移过来又快又稳。
- 数据集怎么来的?——公开数据加自建补拍,训练和验证已经做过身份隔离,不存在数据泄漏。
- 两类图片数量不均衡怎么处理?——用类别权重,并用混淆矩阵、精确率和召回率来评估。
- 怎么证明模型学到的确实是口罩特征,而不是背景?——用Grad-CAM热力图展示模型关注区域,这个可视化方法对口罩场景非常直观。
这些问题都不需要背标准答案,能用自己数据集的规模和训练日志里的真实数字回答出来,就已经很有说服力了。做到最后你会发现,老师真正要的是你对整套流程的理解程度,而不是模型的分数比谁都高。
5.3 低成本加分项:热力图可视化与Web演示
Grad-CAM是很经典的可解释性方法,用最后一层卷积输出的梯度生成热力图,把模型的注意力区域可视化。在口罩场景里效果非常直观:判断“戴了口罩”时,热区会集中在口鼻区域;判断“没戴”时,热区会铺开覆盖整张脸。代码用tf.GradientTape就能实现,篇幅不长,对答辩分数却很有帮助,它让评委看到你对模型行为有量化理解,而不是只会跑到一个能用的脚本。
界面方面,用Web框架把4.1的predict_mask函数包成一个交互页面,几行代码就能做到上传一张照片、显示分类置信度和热力图。课程设计不需要做成一整套Web系统工程,给一个能点击交互的Demo已经超过大部分同学的水平。最后提醒一句:随手就能做出来的低保真Demo,比一个写着“待完善”的半成品页面更有价值。
实验报告里的对比也别只写“准确率95%”这种结论。把三个版本都放进来:自建CNN、迁移学习、迁移学习加数据增强,每个版本记录loss曲线和混淆矩阵。这份实验记录本身就是最大的加分项,答辩时讲到关键表格口齿清楚,剩下的让图表说话。