简介:面向需要完成毕业设计或算法实践的读者,这份资源以Keras搭建Faster R-CNN框架,在VOC格式的口罩数据集上完成训练,实现人群场景中是否佩戴口罩的自动检测与识别。压缩包内共57个文件,包含Python模型脚本、数据集标注与处理说明、理论笔记、示例图片等,整体大小约12.63MB,目录按net模型构建、theory原理说明、model_data权重等模块划分,结构清晰便于直接运行与二次开发。资源内置run.py推理演示脚本与voctrain.py训练入口,配合VOC数据集制作教程、Faster R-CNN结构讲解和训练流程文档,可帮助理解RPN候选框生成、RoI Pooling、分类与回归等关键环节。已有348人学习下载,适合具备TensorFlow/Keras基础、希望快速搭建口罩检测系统完整原型的毕设学生或开发者。
1. 用Keras复现Faster R-CNN做人群口罩检测,先别急着换YOLO
人群场景做人脸口罩检测,最容易听到的建议是“直接用YOLO”。但毕设如果选了Faster R-CNN,核心原因在于两阶段检测的候选区域机制在密集、有遮挡的人脸区域上更稳,且Keras里用VGG16做特征提取、自定义RPN训练,过程比想象中好啃。这篇文章不对着源码逐行念,而是把训练一个可用模型需要的路径拆出来:数据怎么整理、anchor怎么生成、RPN和RoI池化怎么接进Keras、损失怎么写、命令怎么敲,以及最重要的几个坑。照着这套思路拼出来,你的“完整代码”结构基本就是标准毕设范本。适合会用Python和Keras的fit,但还没完整写过检测器的读者。
2. Faster R-CNN的检测流程与Keras实现的数据准备
2.1 基于候选区域的检测流程:RPN怎么把“哪里有口罩”变成可学习问题
Faster R-CNN之所以是两阶段,是因为它先通过RPN(Region Proposal Network)找“可能有人脸口罩”的区域,再对这些区域做精确分类和框回归。在人群场景里,人脸尺寸小且相互遮挡,RPN输出的候选框会覆盖多个尺度,后续RoI池化层再把每个候选框变成固定尺寸特征,减少重复计算。
Keras实现时,共享backbone一般直接用预训练VGG16。取VGG16的block5_conv3输出作为共享特征图,这时下采样倍数是16,原始尺寸为800×600的输入,特征图是50×37。RPN在这个特征图上滑窗,每个位置生成一组anchor。检测头对anchor做二分类(前景/背景)和坐标微调。第二阶段的检测头再在RPN筛选后的候选框上判断类别和最终位置。
这种结构对毕设最友好的地方是:不需要像SVM时代的检测器那样手工设计滑动窗口,也容易分批训练。你可以先冻结VGG16前几层,只训练RPN和检测头,后面再解冻微调。
2.2 人群口罩数据的目录结构与VOC标注格式
训练数据按VOC格式组织即可,不需要非要转成TFRecord。VOC的标注文件是XML,每个目标有一个bndbox框和name标签。人群口罩检测只需要两个类别:face表示没戴好口罩,mask表示正确佩戴口罩。如果只想做二分类,可以只留mask。
常见的目录结构如下:
data/ ├── VOCdevkit/ │ └── VOC2007/ │ ├── JPEGImages/ # 输入图片,jpg或png │ ├── Annotations/ # 每张图片对应的xml │ ├── ImageSets/ │ │ └── Main/ │ │ ├── train.txt │ │ └── val.txt ├── pretrain_weights/ │ └── vgg16_weights_tf_dim_ordering_tf_kernels_notop.h5 └── results/ # 训练日志和预测输出train.txt里每行写图片文件名(不带后缀),比如img_0001。训练时读取XML解析出目标框,再和anchor做匹配。XML最小格式如下:
<annotation> <folder>VOC2007</folder> <filename>img_0001.jpg</filename> <size> <width>800</width> <height>600</height> <depth>3</depth> </size> <object> <name>mask</name> <difficult>0</difficult> <bndbox> <xmin>120</xmin> <ymin>80</ymin> <xmax>260</xmax> <ymax>220</ymax> </bndbox> </object> </annotation>注意difficult标记很重要。人群场景中大量小脸即使人工标注也很容易漏检,建议把极小而无法准确判断是否戴口罩的目标标记为difficult,评估时不计入,避免mAP被拉低。
2.3 用代码生成anchor并完成正负样本匹配
RPN的anchor需要在训练前预生成。常见设置是每个位置生成9个anchor,面积取[32, 64, 128, 256]的缩放,比例取[0.5, 1, 2]。人群场景里人脸密集且面积偏小,所以后面还要加一个16的anchor面积。生成逻辑用纯numpy最稳,Keras训练时不希望每次都动态生成。
import numpy as np def generate_anchors(base_size=16, ratios=[0.5, 1, 2], scales=[16, 32, 64, 128]): # base_size表示原始感受野步长,VGG16下采样16倍 anchors = [] for scale in scales: area = base_size * base_size * scale / 16.0 # 先按面积开方得到基本边长 side = np.sqrt(area) for ratio in ratios: w = side * np.sqrt(ratio) h = side / np.sqrt(ratio) # 转为(x1,y1,x2,y2)格式,中心点为(0,0) anchors.append([-w/2, -h/2, w/2, h/2]) anchors = np.array(anchors, dtype=np.float32) # 每个位置生成9个anchor return anchors配合特征图的每个像素坐标,把anchor平移到对应位置。然后用shift生成全部候选框。匹配时计算每个anchor与所有ground truth的IoU,规则如下:
- IoU大于0.7的anchor标记为正样本;
- IoU小于0.3的anchor标记为负样本;
- 落在0.3到0.7之间的忽略;
- 如果一张图里没有正样本,取与某个ground truth IoU最大的anchor为正样本。
这段匹配逻辑是RPN训练质量的关键。正负样本数量不平衡时,随机采样256个,正样本最多128个,其余抽负样本。这个策略来自原论文,直接沿用能少踩很多坑。
表格里把这组RPN匹配参数整理成速查:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| anchor scales | [16, 32, 64, 128] | 400×400目标用;人群小脸可加[8,16] |
| anchor ratios | [0.5, 1, 2] | 覆盖横向、方形、纵向人脸 |
| RPN正样本IoU阈值 | 0.7 | 高于这个值才认为前景 |
| RPN负样本IoU阈值 | 0.3 | 低于这个值才容易识别为背景 |
| RPN训练batch大小 | 256 | 每张图采样候选anchor的数量 |
| 正样本上限 | 128 | 防止正样本过多掩盖背景学习 |
3. 用Keras定义模型核心:RPN、RoI池化与多任务损失
3.1 基于VGG16的共享特征提取与RPN输出定义
Keras实现里,模型需要拆成两个阶段却共享特征提取层。先加载不带顶层的VGG16,输入尺寸使用(None, None, 3)更好,训练时再实际固定到(800, 600, 3)也可以。推荐训练时固定尺寸,否则动态尺寸会让自定义层的batch处理非常难写。
RPN接在block5_conv3的输出上。它先通过一个3×3卷积,再分叉成两个1×1卷积:一个输出前景背景得分,另一个输出anchor的四个坐标偏移。
from tensorflow.keras.layers import Input, Conv2D, Reshape from tensorflow.keras.models import Model from tensorflow.keras.applications.vgg16 import VGG16 def build_rpn(base_layers, num_anchors=9): # 共享特征图经过3x3卷积加深感受野 x = Conv2D(512, (3, 3), padding='same', activation='relu')(base_layers) # 前景背景二分类,2表示bg和fg rpn_class = Conv2D(num_anchors * 2, (1, 1), activation='softmax')(x) rpn_bbox = Conv2D(num_anchors * 4, (1, 1))(x) # 将每个anchor的信息合并成(batch, H*W*num_anchors, 2) rpn_class = Reshape((-1, 2))(rpn_class) rpn_bbox = Reshape((-1, 4))(rpn_bbox) return rpn_class, rpn_bbox这里num_anchors是每个位置生成的anchor数量,和前面生成的09个保持一致。实际训练时还要从rpn_class里取出所有anchor的得分,做NMS得到候选框。这部分不需要写进Keras网络,放到数据生成器或自定义回调里更灵活。
3.2 RoI池化的Keras实现:从RPN候选框到固定尺寸特征
RPN输出的是大量候选框坐标,但检测头需要固定维度的输入。传统Faster R-CNN用RoI池化层,把每个候选框对应的特征区域划分成固定网格,比如7×7,然后做最大池化。在Keras里不一定要重新实现C++层,用TensorFlow后端的tf.image.crop_and_resize可以近似完成任务。
import tensorflow as tf from tensorflow.keras.layers import Layer class RoiPooling(Layer): def __init__(self, pool_size=7, **kwargs): super(RoiPooling, self).__init__(**kwargs) self.pool_size = pool_size def call(self, inputs): # inputs[0]是共享特征图, inputs[1]是候选框, 格式为(y1, x1, y2, x2) feature_maps, rois = inputs # 把坐标归一化到0~1之间的crop_and_resize要求 boxes = tf.cast(rois, dtype=tf.float32) # 处理batch维度,简化起见假设单batch box_ind = tf.zeros([tf.shape(boxes)[0]], dtype=tf.int32) crops = tf.image.crop_and_resize( feature_maps, boxes, box_ind, (self.pool_size, self.pool_size) ) return crops注意crop_and_resize的坐标是[y1, x1, y2, x2]且需要归一化到[0,1]。如果你习惯用x1,y1,x2,y2格式,要在传入前转换。这个自定义层接在TimeDistributed外面,实际上常见写法是把整张特征图和最多300个候选框输入进去,输出(300, 7, 7, 512),再展开成(300, 7*7*512)送到全连接分类头。
3.3 RPN与检测头的多任务损失函数
Faster R-CNN的训练损失由四部分组成:RPN分类、RPN回归、检测头分类、检测头回归。Keras训练时可以把多个损失相加作为自定义损失函数。RPN回归推荐使用smooth L1,因为它对大误差的梯度比L2更温和,不容易因为初始候选框偏差过大而训练发散。
def smooth_l1_loss(y_true, y_pred, sigma=3.0): sigma_squared = sigma ** 2 diff = tf.abs(y_true - y_pred) # 小于1/sigma^2的位置用二次函数 less_than_one = tf.cast(tf.less(diff, 1.0 / sigma_squared), tf.float32) loss = less_than_one * 0.5 * sigma_squared * diff ** 2 \ + (1 - less_than_one) * (diff - 0.5 / sigma_squared) return tf.reduce_mean(loss)这个损失同时用于RPN回归和检测头回归。分类损失直接用categorical_crossentropy。写训练模型时,模型的输出要同时包含RPN的输出和检测头的输出,不能只接一个。常见做法是把四个输出拼成一个列表:
model = Model(inputs=[input_img, input_rois], outputs=[rpn_class, rpn_bbox, detector_class, detector_bbox]) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-4), loss={ 'rpn_class': 'categorical_crossentropy', 'rpn_bbox': smooth_l1_loss, 'detector_class': 'categorical_crossentropy', 'detector_bbox': smooth_l1_loss }, loss_weights=[1.0, 1.0, 1.0, 1.0] )如果你不想在compile里传入多个损失,也可以直接自定义一个总损失层,把各损失加起来作为最终输出。但多输出方式在Keras回调里能直接看到每个子任务的loss,排障更方便。
3.4 训练超参数与anchor参数速查表
下面是训练人群口罩模型时常用的初始参数。这些不是拍脑袋填的,而是根据VGG16下采样16倍和常见人脸尺寸(160×160到48×48)反推出来的。输入尺寸越大,anchor能覆盖的目标越大,但显存和训练时间也会上升,毕设机器上建议先用(600, 800)跑通。
| 超参数 | 推荐值 | 说明 |
|---|---|---|
| 输入图片尺寸 | 800×600 | VGG16下采样后特征图约50×37 |
| RPN NMS阈值 | 0.7 | 训练时建议0.7,推理可降到0.6 |
| 训练时保留候选框数 | 2000 | 越多越准确但显存压力大 |
| 推理时保留候选框数 | 300 | 用于RoI池化 |
| 检测头类别数 | 2 | mask和face,或只做mask单类 |
| 检测头NMS阈值 | 0.3 | 阈值越低,重叠框被抑制越狠 |
| batch size | 1 | 单卡训练常见,因为候选框数量变化 |
| 初始学习率 | 1e-4 | 冻结VGG16训练时用1e-4 |
| 微调VGG16时学习率 | 1e-5 | 避免破坏预训练特征 |
如果你在人群密集图片上发现漏检很多,优先改的不是网络结构,而是anchor面积。因为图片里远处的人脸可能只有30像素大小,默认最小anchor是32,正好卡在边界上,很容易被RPN忽略。给它加一个8或16的scale,召回率会明显提升。
4. 让代码真正跑起来:训练、预测与常见报错
4.1 依赖环境配置与可直接运行的目录结构
Keras版Faster R-CNN在环境上最容易卡住的是版本。很多人用from keras import ...,另一个文件里又写from tensorflow.keras import ...,两个混用直接报错。建议从头统一用tensorflow.keras。既兼容老版本Keras代码,又能在TensorFlow 2.x下运行。依赖文件写成这样就能直接装:
tensorflow-gpu==1.15.0 Keras==2.3.1 numpy==1.17.4 opencv-python==4.1.2.30 Pillow==6.2.1 matplotlib==3.1.2如果你用的是TensorFlow 2.0以上,建议不要单独装Keras,直接用tensorflow自带的tf.keras,把代码里的from keras替换成from tensorflow.keras。处理图像用OpenCV读取,注意OpenCV默认读出来是BGR,改RGB再喂给模型。
完整项目目录建议比前面的数据目录再高出两层:
mask_detection/ ├── lib/ │ ├── data_generator.py │ ├── rpn.py │ ├── roi_pooling.py │ └── losses.py ├── train.py ├── predict.py ├── config.py └── data/config.py里统一管理输入尺寸、anchor参数、类别名称和各类路径。毕设答辩时老师会问你“为什么把参数写在config里”,答“方便实验对比”就是标准答案。
4.2 训练启动命令与参数含义
数据生成器建议实现成Python生成器,每次yield一批图片和对应的多任务标签。训练脚本入口要保持简单,命令行参数只暴露最关键的几个,其他从config读取。
python train.py \ --dataset data/VOCdevkit/VOC2007 \ --weights pretrain_weights/vgg16_weights_tf_dim_ordering_tf_kernels_notop.h5 \ --epochs 30 \ --batch_size 1 \ --lr 1e-4 \ --output weights/mask_rcnn.h5参数并不复杂,但要注意:batch_size在自定义RoI池化层里尽量设为1,因为每张图片的有效候选框数量不同,强行batch会用到大量pad,浪费显存。训练时前10个epoch冻结VGG16,只训练RPN和检测头;后面20个epoch解冻VGG16的block5_conv1之后的层,学习率降到1e-5。
在你的生成器里,每张图片要同时返回RPN的anchor标签和检测头需要的RoI标签。RoI标签是这样算的:先对每张图的RPN输出的前背景得分做NMS,取得分最高的300个框,再和ground truth计算IoU,IoU大于0.5的为检测头正样本,否则为负样本。这一部分是最容易出bug的地方,建议单独写单元测试,跑通一张图再整网训练。
4.3 单张图片和人群视频帧的推理脚本
推理阶段不需要RPN对每一帧重新训练,只用训练好的权重预测。预测逻辑是先过RPN得到候选框,再进检测头。这里也要做一次NMS,但阈值比训练时低。
import cv2 import numpy as np from tensorflow.keras.models import load_model def predict_image(model, img, config): # 保持宽高比缩放到416x416 h, w = img.shape[:2] scale = min(config.img_w / w, config.img_h / h) new_w, new_h = int(w * scale), int(h * scale) resized = cv2.resize(img, (new_w, new_h)) # 转成模型输入格式 inp = np.expand_dims(resized.astype(np.float32) / 255.0, axis=0) # 模型返回rpn_box, rpn_class, detector_box, detector_class rpn_box, rpn_class, det_box, det_class = model.predict(inp) # 用检测头输出的回归坐标把原始框修正 boxes = decode_boxes(rpn_box, config.anchors) # 再过滤det_class中置信度低于0.5的框 keep = [] for i, c in enumerate(det_class[0]): if c[1] > 0.5: keep.append([boxes[i], c[1]]) return nms(keep, iou_threshold=0.3)这段代码里的decode_boxes需要根据RPN输出的dx, dy, dw, dh还原成真实坐标,公式是人脸检测里最常见的x1 = x + dx * width。nms可以自己写,也可以用tf.image.non_max_suppression,后者在GPU上更快。
4.4 高频报错与排查对照表
训练过程中会碰到一些看着吓人的错误,其实原因都很集中。我用一张表列出高频问题和对应解法:
| 报错信息 | 原因 | 解决方案 |
|---|---|---|
ValueError: Shape must be rank 4 | 自定义RoI层输入格式不对 | 检查特征图是否保持(batch, h, w, channels)四维 |
TypeError: 'NoneType' object is not callable | Keras后端切换混乱 | 统一使用tensorflow.keras,不要混用独立Keras |
CUDA_ERROR_OUT_OF_MEMORY | 候选框数量过多或输入尺寸过大 | 训练时把RPN候选框降到2000,输入改为600×800 |
NaN loss出现在几百步后 | 学习率太高或RPN回归目标编码错误 | 先降到1e-5,打印回归label看是否有极端值 |
RuntimeError: tf.image.crop_and_resize输入box越界 | 候选框坐标未归一化0~1 | 在传入前做/height和/width归一化 |
还有一个不报错但后果严重的问题:VGG16预训练权重文件没下载完全。很多网络下载工具会在中途断掉,加载后不会报错但精度很差。验证方法是加载权重后先跑一次推理,看特征图是否全零。如果是,重新下载预训练权重。
5. 面向人群场景的模型评估与调优技巧
5.1 用mAP和PR曲线验证口罩检测效果
毕设光有loss曲线不够,评测指标必须用mAP。人群口罩检测的mAP计算方式和VOC标准一致:用IoU阈值0.5判定检测框是否命中,然后对所有检测框按置信度排序,逐点算P-R曲线下的面积。Keras训练日志里没有现成的mAP,需要自己写一个评估脚本。
def compute_map(predictions, ground_truths, iou_thresh=0.5): # predictions按置信度降序排列 tp = np.zeros(len(predictions)) fp = np.zeros(len(predictions)) matched_gt = set() for i, pred in enumerate(predictions): max_iou = 0 match_gt = None for gt in ground_truths: iou = calc_iou(pred['box'], gt['box']) if iou > max_iou: max_iou = iou match_gt = gt['id'] if max_iou >= iou_thresh and match_gt not in matched_gt: tp[i] = 1 matched_gt.add(match_gt) else: fp[i] = 1 # 计算recall和precision后画PR曲线 ...注意人群场景里“一个检测框匹配多个ground truth”的情况很多,已匹配的目标不能重复匹配,所以matched_gt集合必须保留。评估时把difficult目标从ground_truth里去掉,才是公平对比。
5.2 针对拥挤、小尺寸口罩的anchor与NMS调整
人群图像中最典型的失败模式是漏检远处小脸和误检密集遮挡人脸。锚框面积最大只能覆盖到128左右,对400像素以上的大脸来说,RPN区域不能填满目标。如果目标大多是近景大脸,可以把网络输入放大到1024,同时增加211面积的锚框。如果目标偏向远处小脸,把锚框面积往下调并添加一个8单位的尺度,效果通常比改backbone更直接。
NMS阈值也需要单独调。检测头NMS阈值从0.3提高到0.45,可以让密集人群中被重叠框压掉的口罩检测回来。但代价是误检增加,需要结合mAP一起判断。人群场景建议用Soft-NMS替代普通NMS,就是不对重叠框直接清零,而是按IoU权重降低其置信度。这样两个并列的口罩都能保留,代码改动不超过十行。
5.3 数据增广与模型导出
人群场景里另一个见效快的技巧是混合增广:随机裁剪、马赛克、亮度扰动。人脸朝向变化大,建议加入水平翻转而不加垂直翻转,因为倒立的人脸在数据里很少见,垂直翻转容易让模型学到错误的上下文。增广后显存允许的情况下,输入尺寸从600×800提高到700×1000,mAP会有可感知的提升。
训练完成后,模型导出分两步:先model.save('mask_final.h5')保存带结构的权重,再用model = load_model('mask_final.h5', custom_objects={'RoiPooling': RoiPooling})加载验证。注意保存时会把自定义层的类名绑进文件,如果之后改了类路径又没传custom_objects,会直接报错。检查导出的模型在验证集上跑的mAP,和你手动算出的数字做一致性核对,这样毕设实验部分能多一张有说服力的表格。
本文还有配套的精品资源,点击获取