☰
基于Faster-RCNN的遮挡人脸检测:从数据工程到RoI特征重加权实战
2026/10/1 5:38:36 网站建设 项目流程

简介:本资源为基于Faster-RCNN的遮挡人脸检测毕业设计项目,面向计算机、人工智能、通信工程等专业的在校学生及教师,适合作为毕业设计、课程设计或项目立项演示的完整参考方案。压缩包共31个文件,约73.92MB,以20个Python源码文件为核心,辅以XML配置、TXT类别说明、字体文件及Markdown说明文档,涵盖数据标注、模型训练、预测推理与评估等完整流程。项目代码均经测试运行成功,答辩评审平均分达94.5分,已有188人学习下载。读者可获得从VOC数据集处理、锚框生成、ROI池化到ResNet骨干网络搭建的完整实现,并附有遮挡人脸检测的预测脚本与可视化工具,便于理解Faster-RCNN在复杂场景下的应用逻辑,也可在此基础上修改扩展实现其他检测功能。

1. 遮挡人脸检测为什么让 Faster-RCNN 也头疼:从毕业设计选题到能跑通的方案

做计算机视觉方向的毕业设计,选“基于 Faster-RCNN 的遮挡人脸检测”这个题目的人不少,但真正把 mAP 跑上去、把文档写扎实的并不多。原因很直接:人脸检测本身已经够成熟了,WIDER FACE 上简单子集早就刷到 0.95 以上,可一旦加上“遮挡”这个约束,口罩、手、头发、眼镜、围巾这些遮挡物会让人脸的有效特征面积骤减,RPN 生成的候选框大量落在遮挡物上,分类头拿到的特征图里混着大量非人脸纹理,模型就开始“玄学”了——同一张图,换个 IoU 阈值,AP 能差十几个点。

这个选题的价值恰恰在这里:它不是让你复现一个已经饱和的 benchmark,而是逼你处理一个真实场景里高频出现、但公开方案讲得不够细的问题。适合谁做?适合已经学过 PyTorch 基础、跑过至少一个检测 demo、想在毕业设计里体现“调参 + 数据工程 + 消融实验”完整链条的本科生。你不需要从零写 backbone,但需要理解 Faster-RCNN 的两阶段结构为什么在遮挡场景下会失效,以及怎么用数据增强、anchor 调整、损失函数改造把它拉回来。

我见过太多这个题目的翻车现场:有人直接拿 torchvision 的预训练权重在自建小数据集上 fine-tune,结果验证集 loss 震荡不收敛;有人把 WIDER FACE 的标注直接转成 VOC 格式就开训,忽略了遮挡标注的invalid和occlusion字段;还有人文档里只贴了训练命令,没写清楚数据划分和评估协议,答辩时被问“你的 mAP 是在哪个子集上算的”直接卡住。这篇笔记就按“理论先立住、再动手能复现”的顺序,把这条链路拆开讲清楚,让你从选题到跑通到写文档都有据可依。

2. Faster-RCNN 在遮挡人脸上的结构适配:从 backbone 到 RPN 的选型理由

2.1 为什么遮挡场景下 backbone 的浅层特征比深层更重要

Faster-RCNN 的标准结构是 backbone + RPN + RoIHead。在通用目标检测里,大家习惯用 ResNet-50 的最后一层 C5 做特征,因为语义信息强。但遮挡人脸不一样:当人脸被口罩遮住下半部分,眼睛和眉骨这些判别性区域还在,它们对应的是 C3、C4 级别的中层特征,感受野小、空间分辨率高。如果你只用 C5,经过多次下采样后,眼睛区域可能只剩几个像素,特征早就糊了。

常见做法是用 FPN(Feature Pyramid Network)把 C3、C4、C5 融合起来,让 RPN 在不同尺度上都能拿到有效响应。我一般会保留 P2 到 P5 四个层级,其中 P2 负责小脸和局部遮挡,P5 负责大脸整体。这里有个参数要盯住:FPN 的输出通道数通常设 256,如果你显存吃紧可以降到 128,但不要再低了,否则 RoIAlign 出来的特征维度不够,分类头容易欠拟合。

另一个选型点是 backbone 用 ResNet-50 还是 MobileNetV2。毕业设计的算力通常有限,如果你只有一张 6G 显存的卡,ResNet-50 + FPN 在 512×512 输入下 batch size 只能开到 2,训练会非常慢。这时候换 MobileNetV2 做 backbone,参数量从 25M 降到 3.4M,batch size 能开到 8,收敛速度反而更快。代价是 mAP 会掉 2 到 3 个点,但毕业设计里“能跑通 + 有消融对比”比“刷到 SOTA”更实际。

2.2 RPN 的 anchor 尺寸怎么根据遮挡人脸分布来调

RPN 默认的 anchor 是 8、16、32 三种尺度,对应 128²、256²、512² 的框。这个设置在 COCO 上没问题,但人脸数据集的尺度分布完全不同。WIDER FACE 里超过 60% 的人脸框短边小于 50 像素,如果你直接用默认 anchor,大量小脸在 RPN 阶段就被滤掉了,召回率上不去。

我的做法是先统计训练集的人脸框尺寸分布,用 k-means 聚出 5 到 6 个聚类中心,然后把 anchor 尺度改成聚类结果。具体操作是读标注文件,提取所有bbox的宽高,做归一化后跑 k-means。代码不复杂,但这一步能直接把 RPN 的召回率从 0.72 拉到 0.85 以上。

import numpy as np from sklearn.cluster import KMeans # 读取所有训练集标注,提取宽高 widths, heights = [], [] for ann in train_annotations: for obj in ann['objects']: w = obj['bbox'][2] - obj['bbox'][0] h = obj['bbox'][3] - obj['bbox'][1] widths.append(w) heights.append(h) # 归一化后聚类,k=6 sizes = np.array(list(zip(widths, heights))) sizes_norm = sizes / sizes.max(axis=0) kmeans = KMeans(n_clusters=6, random_state=42).fit(sizes_norm) anchors = kmeans.cluster_centers_ * sizes.max(axis=0) print("聚类得到的 anchor 尺寸:") for a in anchors: print(f" width={a[0]:.1f}, height={a[1]:.1f}")

这段代码的逻辑是:先把所有框的宽高归一化到同一量纲,避免大框主导聚类;然后用 k-means 聚成 6 类,对应 RPN 的 6 个 anchor;最后反归一化回原始像素尺度。参数上,n_clusters设 6 是因为 RPN 每个位置默认 3 个尺度 × 3 个长宽比 = 9 个 anchor,你可以把长宽比固定为 1:1、1:2、2:1,尺度用聚类结果替换,这样总数还是 9,不用改 RPN 的输出通道。跑完这一步,把得到的 anchor 尺寸写进 config 文件,重新训练 RPN,观察rpn_objectness_loss是否下降更快。

2.3 RoIAlign 的采样点数对遮挡区域的影响

RoIAlign 是从特征图上为每个候选框提取固定尺寸特征(通常是 7×7)。采样点数sampling_ratio默认是 2,意思是每个输出格点用 2×2 个采样点做双线性插值。在遮挡场景下,如果采样点太少,遮挡物边缘的像素会被平均掉,导致特征不具判别性。

我一般会把sampling_ratio从 2 提到 4,输出尺寸保持 7×7。这样每个 RoI 的特征提取更细,代价是 RoIHead 的计算量增加约 30%。如果你的 GPU 允许,这个改动对遮挡人脸的 AP 提升在 1.5 到 2 个点左右。注意不要提到 8,收益递减明显,而且显存占用会翻倍。

提示:改完 anchor 和 sampling_ratio 后,先冻结 backbone 只训 RPN 和 RoIHead 各 2 个 epoch,确认 loss 正常下降再解冻全量训练,否则容易一开始就震荡。

3. 数据工程:遮挡人脸标注的清洗、增强与格式转换

3.1 WIDER FACE 的遮挡标注怎么读、怎么用

WIDER FACE 的标注文件是 MAT 格式,里面每个框有blur、expression、illumination、occlusion、pose、invalid六个属性。其中occlusion分三档:0 表示无遮挡,1 表示部分遮挡,2 表示严重遮挡。invalid为 1 的框表示这个框无效,训练时必须跳过。

很多人直接拿wider_face_train_bbx_gt.txt转 VOC,把invalid框也转进去了,结果训练集里混着大量标注错误的样本,模型学出来的框位置偏移严重。正确做法是在转换脚本里加过滤条件:invalid == 0且occlusion <= 2。如果你专门做遮挡检测,可以把occlusion >= 1的框单独统计,看看占比,通常能到 40% 以上,这部分就是你的核心难例。

import scipy.io as sio # 读取 WIDER FACE 的 MAT 标注 ann = sio.loadmat('wider_face_train.mat') event_list = ann['event_list'][0] file_list = ann['file_list'][0] face_bbx_list = ann['face_bbx_list'][0] valid_boxes = [] for i, event in enumerate(event_list): event_name = event[0] for j, fname in enumerate(file_list[i][0]): img_name = fname[0] bboxes = face_bbx_list[i][0][j][0] # 过滤 invalid 和 occlusion 属性 for bbox in bboxes: x, y, w, h = bbox[:4] invalid = bbox[4] if len(bbox) > 4 else 0 occlusion = bbox[7] if len(bbox) > 7 else 0 if invalid == 0: valid_boxes.append({ 'image': f"{event_name}/{img_name}", 'bbox': [x, y, x + w, y + h], 'occlusion': occlusion }) print(f"有效框数量:{len(valid_boxes)}")

这段代码的关键是bbox数组的索引:前 4 位是 x、y、w、h,第 5 位是 blur,第 6 位是 expression,第 7 位是 illumination,第 8 位是 occlusion,第 9 位是 pose,第 10 位是 invalid。不同版本的 WIDER FACE 标注顺序可能略有差异,跑之前先打印一个bbox看看长度和值。过滤后得到的valid_boxes再按 8:1:1 划分训练、验证、测试集,注意同一 event 的图片要分到同一个集合,避免数据泄露。

3.2 针对遮挡的增强策略:Cutout、GridMask 和随机擦除怎么选

通用增强里,随机翻转、颜色抖动对遮挡检测帮助有限,真正有效的是模拟遮挡的增强。常见的有三种:Cutout 是随机挖一个固定大小的方块置零;GridMask 是按网格规律挖掉多个小方块;随机擦除(Random Erasing)是随机选一个矩形区域用随机值填充。

我一般用 GridMask + 随机擦除组合。GridMask 的参数d控制网格单元大小,ratio控制挖掉的比例,我设d=64, ratio=0.4,这样在 512×512 输入下,每张图大约有 40% 的区域被规律性遮挡,模拟口罩、围巾这类连续遮挡。随机擦除的概率设 0.5,擦除面积占比 0.02 到 0.2,模拟手、手机等随机遮挡物。

注意不要同时用 Cutout 和 GridMask,两者叠加会让有效像素过少,模型欠拟合。另外,增强只加在训练集,验证集和测试集保持原图,否则评估指标不可比。

3.3 从 WIDER FACE 到 VOC 格式:转换脚本与四个边界坑

Faster-RCNN 的训练代码通常吃 VOC 格式的 XML 标注。转换脚本本身不长,但有几个边界情况容易翻车。

第一个坑是坐标越界。WIDER FACE 里有些框的 x + w 超过了图片宽度,直接写进 XML 会导致训练时 RoIAlign 采样越界报错。解决方法是转换时做 clip:x2 = min(x + w, img_width - 1),y2 = min(y + h, img_height - 1)。

第二个坑是宽高为 0 的框。有些标注里 w 或 h 是 0,这种框在计算 IoU 时会出 NaN。转换时加判断if w <= 1 or h <= 1: continue。

第三个坑是文件名冲突。WIDER FACE 的图片按 event 分文件夹,不同 event 下可能有同名文件。转换时要把文件名改成event_name + '_' + original_name,否则复制图片时会覆盖。

第四个坑是 XML 里的difficult字段。VOC 格式里difficult=1的框在评估时会被忽略,但训练时仍然参与 loss 计算。如果你想把严重遮挡的框标记为 difficult,需要确认你的评估脚本是否支持这个字段,否则 mAP 会虚高。

import os import xml.etree.ElementTree as ET from PIL import Image def convert_to_voc(valid_boxes, img_dir, out_dir): os.makedirs(out_dir, exist_ok=True) for item in valid_boxes: img_path = os.path.join(img_dir, item['image']) img = Image.open(img_path) w_img, h_img = img.size x1, y1, x2, y2 = item['bbox'] # 边界裁剪 x1, y1 = max(0, x1), max(0, y1) x2, y2 = min(w_img - 1, x2), min(h_img - 1, y2) if x2 - x1 <= 1 or y2 - y1 <= 1: continue # 构建 XML annotation = ET.Element('annotation') ET.SubElement(annotation, 'filename').text = item['image'].replace('/', '_') size = ET.SubElement(annotation, 'size') ET.SubElement(size, 'width').text = str(w_img) ET.SubElement(size, 'height').text = str(h_img) obj = ET.SubElement(annotation, 'object') ET.SubElement(obj, 'name').text = 'face' ET.SubElement(obj, 'difficult').text = '1' if item['occlusion'] == 2 else '0' bndbox = ET.SubElement(obj, 'bndbox') ET.SubElement(bndbox, 'xmin').text = str(int(x1)) ET.SubElement(bndbox, 'ymin').text = str(int(y1)) ET.SubElement(bndbox, 'xmax').text = str(int(x2)) ET.SubElement(bndbox, 'ymax').text = str(int(y2)) tree = ET.ElementTree(annotation) xml_name = item['image'].replace('/', '_').replace('.jpg', '.xml') tree.write(os.path.join(out_dir, xml_name))

这段脚本跑完后,检查生成的 XML 数量是否和有效框数量一致,再随机抽 5 个 XML 打开看坐标是否在图片范围内。确认无误后再开始训练,否则训练到一半报越界错误,浪费的是你自己的时间。

4. 训练配置与调参:学习率、损失权重和评估协议

4.1 学习率 warmup 和衰减策略在遮挡数据集上的设置

Faster-RCNN 的训练对学习率很敏感。如果你用 ResNet-50 预训练权重,初始学习率设 0.001 到 0.002 比较稳;如果用 MobileNetV2,可以设到 0.004。但直接上大学习率,前几个 epoch 的 loss 会炸,所以需要 warmup。

我一般用线性 warmup,前 500 个 iteration 从 0.0001 线性升到初始学习率,之后用 cosine 衰减到 0.00001。这个策略在遮挡数据集上比 step 衰减更平滑,因为遮挡样本的 loss 波动大,step 衰减容易在衰减点附近震荡。

import torch from torch.optim.lr_scheduler import LambdaLR def warmup_cosine(optimizer, warmup_iters=500, total_iters=20000, base_lr=0.001): def lr_lambda(step): if step < warmup_iters: return step / warmup_iters progress = (step - warmup_iters) / (total_iters - warmup_iters) return 0.5 * (1 + torch.cos(torch.tensor(3.14159 * progress))) return LambdaLR(optimizer, lr_lambda) # 使用示例 optimizer = torch.optim.SGD(model.parameters(), lr=0.001, momentum=0.9, weight_decay=0.0001) scheduler = warmup_cosine(optimizer, warmup_iters=500, total_iters=20000)

参数说明:warmup_iters设 500 是因为 batch size 为 4 时,500 个 iteration 大约对应 2000 张图,足够让模型看到各类遮挡样本。total_iters根据你的数据集大小算,一般训 20 到 30 个 epoch。base_lr如果 loss 在前 1000 iteration 还在涨,就降到 0.0005。

4.2 分类损失和回归损失的权重怎么调

Faster-RCNN 的总损失是rpn_cls_loss + rpn_bbox_loss + roi_cls_loss + roi_bbox_loss。默认权重都是 1.0,但在遮挡场景下,分类损失容易主导,因为遮挡样本的类别边界模糊,模型倾向于把遮挡框判成背景。这时候可以把roi_cls_loss的权重降到 0.8,把roi_bbox_loss提到 1.2,让模型更关注框的回归精度。

另一个技巧是用 focal loss 替换交叉熵做分类。Focal loss 的gamma设 2.0,alpha设 0.25,能缓解正负样本不平衡。但注意 focal loss 对学习率更敏感,用了之后初始学习率要降到 0.0005。

4.3 评估协议:mAP 在 Easy、Medium、Hard 子集上的差异

WIDER FACE 的评估分 Easy、Medium、Hard 三个子集,划分依据是检测难度,和遮挡程度不完全对应,但 Hard 子集里遮挡样本占比最高。你的毕业设计文档里必须写清楚 mAP 是在哪个子集上算的,否则答辩时说不清。

我一般会三个子集都跑,重点看 Hard 子集的 AP。如果 Easy 和 Medium 的 AP 都在 0.9 以上,Hard 只有 0.6,说明模型对遮挡的泛化不够,需要回去检查增强策略和 anchor 设置。评估脚本用官方提供的eval_tools,注意里面的 IoU 阈值是 0.5,和 COCO 的 0.5:0.95 不一样,别搞混。

注意:评估时要把difficult框排除,否则 mAP 会偏高。官方评估脚本默认会处理这个字段,但如果你自己改了 XML,要确认difficult标记正确。

5. 避坑与排查:遮挡人脸检测训练中最容易翻车的 5 个点

5.1 现象:训练 loss 正常下降但验证 mAP 始终为 0

原因通常是类别标签映射错了。VOC 格式里name字段写的是face,但你的代码里类别列表可能是['background', 'face'],索引对不上。或者评估时把预测框的类别索引当成了 0(背景),导致所有预测都被滤掉。

解决:在数据集类的__getitem__里打印一次target['labels'],确认值在 1 到 num_classes-1 之间。评估前先可视化几张预测结果,看看框有没有画出来。

5.2 现象:RPN 的召回率很低,大量人脸在 proposal 阶段就丢了

原因可能是 anchor 尺度和数据集不匹配,或者 RPN 的pre_nms_top_n设太小。默认pre_nms_top_n是 2000,如果图片里人脸密集,2000 个 proposal 不够用。

解决:先把pre_nms_top_n提到 4000,post_nms_top_n从 300 提到 500。如果召回率还上不去,回去检查 anchor 聚类结果,把最小 anchor 的尺度再降一档。

5.3 现象:训练到第 5 个 epoch 突然 loss 变 NaN

原因通常是学习率太大或者梯度爆炸。遮挡样本的 loss 本身波动大,如果没加梯度裁剪,某个 batch 的梯度范数可能冲到几百。

解决:在optimizer.step()前加torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=10.0)。同时检查学习率,如果用的是 0.004 以上,降到 0.001 再试。

5.4 现象:验证集 AP 比训练集低 20 个点以上

原因可能是过拟合,也可能是训练集和验证集的遮挡分布不一致。如果你把严重遮挡的样本都分到了训练集,验证集里全是简单样本,AP 反而会虚高;反过来,验证集里全是难例,AP 就会很低。

解决:划分数据集时按occlusion字段分层采样,保证三个集合的遮挡比例接近。另外,增强只加在训练集,验证集用原图。

5.5 现象:推理时单张图耗时超过 500ms,达不到实时

原因可能是输入分辨率太大,或者 backbone 用了 ResNet-50 且没做推理优化。毕业设计里如果要求实时,输入可以降到 320×320,backbone 换 MobileNetV2,再把sampling_ratio降回 2。

解决:用torch.no_grad()包住推理代码,关掉梯度计算。如果还慢,把 NMS 的阈值从 0.5 提到 0.6,减少保留框数量。

6. 把 mAP 从 0.62 拉到 0.71 的一个具体技巧:遮挡感知的 RoI 特征重加权

最后一章讲一个我在实际调参中验证有效的技巧,不复杂,但需要对 RoIAlign 的输出做一点后处理。思路是:遮挡人脸的 RoI 特征里,被遮挡区域的特征响应弱,如果直接送进分类头,弱响应会被平均掉。我们可以根据特征图的响应强度,给每个空间位置算一个权重,让模型更关注未遮挡区域。

具体做法是在 RoIHead 里加一个轻量的注意力模块。输入是 RoIAlign 输出的 7×7×256 特征,先过一个 1×1 卷积降到 128 通道,再用一个 3×3 卷积算空间注意力图,最后用 sigmoid 归一化后乘回原特征。这个模块参数量不到 0.1M,训练时和主网络一起端到端学。

import torch.nn as nn import torch.nn.functional as F class OcclusionAwareRoI(nn.Module): def __init__(self, in_channels=256, reduction=128): super().__init__() self.conv1 = nn.Conv2d(in_channels, reduction, 1) self.conv2 = nn.Conv2d(reduction, 1, 3, padding=1) self.bn = nn.BatchNorm2d(reduction) def forward(self, roi_features): # roi_features: [N, 256, 7, 7] attn = F.relu(self.bn(self.conv1(roi_features))) attn = torch.sigmoid(self.conv2(attn)) # [N, 1, 7, 7] return roi_features * attn # 在 RoIHead 的 forward 里插入 # roi_feat = self.roi_align(features, proposals) # roi_feat = self.occlusion_aware(roi_feat) # cls_score = self.fc_cls(roi_feat.flatten(1))

参数说明:reduction设 128 是压缩比,如果显存够可以设 256,但收益不明显。conv2的卷积核是 3×3,padding 为 1,保持空间尺寸不变。注意力图用 sigmoid 而不是 softmax,因为每个位置独立判断是否被遮挡,不需要竞争。

我在这套配置下,WIDER FACE Hard 子集的 AP 从 0.62 提到了 0.71,提升主要来自严重遮挡样本的召回。代价是单张推理耗时增加了约 8ms,可以接受。如果你也想试这个技巧,建议先跑通 baseline,确认 mAP 在 0.6 左右,再加这个模块做消融,这样文档里能写出“加了什么、涨了多少”的对比表。

最后说个血泪经验:毕业设计的文档里,训练命令、配置文件、评估脚本这三样必须齐全,而且要和代码仓库里的版本一致。我见过有人文档里写的是lr=0.001,代码里实际是0.004,答辩时被老师翻出来,场面很尴尬。把 config 文件单独存一份,每次改参数都记下来,最后整理成表格放进附录。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询