YOLOv4+PyTorch人脸口罩识别:从环境搭建到NMS后处理全流程实战
2026/9/14 14:22:31 网站建设 项目流程

简介:这套基于YOLOv4和PyTorch的人脸口罩识别项目源码,完整包含Python训练/推理代码、模型权重、VOC格式标注数据、字体文件、说明文档和多段测试视频,覆盖从数据准备到模型部署的主要环节。资源共367个文件,其中322个xml标注文件为训练集提供框选坐标,20个py脚本负责数据加载、模型训练与检测推理,另有pyc缓存、txt配置、jpg样例图和avi输出视频等,压缩包整体仅9.31MB,轻量易用。目前已有52人学习下载。资源适用于计算机、人工智能、自动化等专业学生的毕设、课程设计或实践入门,代码经过运行验证,作者标注答辩均分96分,可靠性较高。配合README和演示视频,可快速复现口罩佩戴检测效果,理解YOLOv4的改进结构、PyTorch损失计算与推理流程,也能基于标注数据自行扩充训练集,做迁移学习或二次开发。

1. 基于 YOLOv4 与 PyTorch 的人脸口罩识别:工程包里真正要打通的是哪四段

医院闸机、工地板房、校园考勤,过去两年这些场景批量上线过一种判断:人脸识别之前,先判断有没有戴口罩。这类需求看起来简单,落地时却比想象中多绕好几道弯——仓库里给的是 darknet 的 .weights,PyTorch 加载第一步就报 shape mismatch;数据标注格式跟 cfg 里的 classes 对不上,训练到一半 loss 发散;视频测试时框一帧有一帧无。标题这套“源代码+文档+数据+模型+测试视频”,表面是一个工程包,实质是一条完整闭环:PyTorch 环境、数据标注、改 cfg、训练、导入 darknet 权重、推理加 NMS、最后拿测试视频验收。这篇文章只讲这条闭环里的关键选择和可复现参数,适合毕设、竞赛,也适合一周内需要交付验证的安防集成项目。

2. YOLOv4 和 PyTorch 的选型理由与基础环境搭建

口罩是典型的小尺度目标,半张脸的宽度往往只有几十个像素,还伴随遮挡、逆光、低头这类噪声。YOLOv4 在整个 YOLO 家族里不是最新最强的,但它的结构复杂度、训练技巧公开度,以及 darknet 权重到 PyTorch 的转换路径都足够成熟,做业务验证比直接上 YOLOv8 更省试错成本。这一章先把架构上它靠什么解决口罩目标的问题讲清楚,再落到 PyTorch 安装、版本匹配和工程目录,因为后续训练与推理都建立在 PyTorch 基础框架上。

2.1 口罩目标为什么适合用 YOLOv4 的检测头设计

YOLOv4 相比 v3 的三个改动,恰好打在口罩识别的痛点上。第一是主干 CSPDarknet53,把特征图分成两部分再合并,减少重复梯度,网络加深的同时训练更稳;在口罩这种“目标小、背景杂”的数据上,训练稳定比网络参数多更重要。第二是 PANet 路径聚合,把 52×52 浅层的位置信息和纹理传到高层输出,YOLOv4 三个尺度分别在 13×13、26×26、52×52 上检测大中小目标,半张脸小口罩主要落在 52×52 分支。很多人小口罩检不出来,不是因为模型不够强,而是输入分辨率只有 320,小目标在图中只剩十几个像素,调大输入到 608 后 mAP 会有明显变化。

第三是 Mish 激活和 DIoU-NMS。Mish 在负值区间更平滑,对口罩边缘这类高频梯度波动响应更温和,训练初期不容易震荡;DIoU-NMS 根据中心距离惩罚重叠框,多张戴了口罩的脸挤在一起时,不会被一个高置信度大框整体吞掉。这些设计不像后来的 Anchor-Free 方法那样吃调参,YOLOv4 把 anchor 数量、尺寸、mask 全都写进 cfg,经验可以积累和迁移。

相比 YOLOv5/v8,YOLOv4 的另一个优势是 darknet 权重可以直接按 cfg 顺序装载到 PyTorch 模型里,模型、权重、训练参数三个文件互相锚定,出问题时可定位。这也是大量生产项目至今还在用“YOLOv4 训练 + PyTorch 推理”组合的原因。后面第 4 章的权重导入就是这套组合的核心动作。

2.2 PyTorch 安装与环境搭建:GPU 驱动、CUDA 和 torch 版本怎么对齐

环境搭建是第一个坑。很多项目包里的 requirements 写的是torch>=1.7,但 YOLOv4 的 PyTorch 复现大多基于 1.x 编写,直接用 PyTorch 2.x 也能跑,问题往往出在 CUDA 版本与显卡不匹配,导致torch.cuda.is_available()返回 False。常见的做法是用 conda 隔离环境,按显卡选 CUDA 版本:

conda create -n mask python=3.8 -y conda activate mask pip install torch==1.12.1+cu113 torchvision==0.13.1+cu113 --extra-index-url https://download.pytorch.org/whl/cu113

第一条命令创建 Python 3.8 环境,YOLOv4 的开源实现大多在 3.7~3.9 上验证,3.8 是兼容面最宽的版本;第二条命令安装 torch 1.12.1,cu113 表示 CUDA 11.3。20 系、30 系显卡用 cu113 没问题,40 系建议换成 cu118 的轮子。没有 NVIDIA 显卡也可以做 CPU 推理,但速度大约只有 GPU 的 1/20,训练基本不可行,测试视频建议先用 GPU 机器验收。

版本对齐建议按下面的表来确认,避免装完再返工:

组件推荐配置原因
Python3.8 或 3.10老复现代码在 3.9+ 有少数语法兼容问题
PyTorch1.12 ~ 2.01.x 接口贴近 darknet 原生结构;2.x 需额外编译适配
CUDA11.3 / 11.830 系稳定,40 系用 11.8 以上
OpenCV4.5.5+视频读取和 letterbox 预处理统一依赖它

装好后先验证 PyTorch 张量基础:torch.zeros(1, 3, 416, 416)能得到一个四维张量,顺序是 NCHW,即批次、通道、高、宽。推理时要把 OpenCV 读进来的 BGR 图先转成 RGB、归一化到 0~1、再 resize 到 416×416,最后transpose成 CHW 并增加 batch 维度。这个预处理顺序错了,模型输出的置信度会整体偏低,但不会报错,属于最难排查的一类问题。

2.3 可复现工程里的目录骨架:cfg、weights、docs 与测试视频的摆放

一个能复现的 YOLOv4 口罩项目,目录结构一般长这样:

mask_detect/ ├── cfg/ │ ├── yolov4-mask.cfg # 修改 classes 和 filters 后的网络结构 │ └── mask.names # 类别名,两行:with_mask、without_mask ├── weights/ │ ├── yolov4.weights # darknet 官方预训练权重 │ └── best.pt # 训练后的 PyTorch 权重 ├── data/ │ ├── images/ # 原始图片 │ ├── labels/ # 每张图对应的 YOLO 格式 txt │ ├── train.txt │ ├── valid.txt │ └── test_video.mp4 # 测试视频只用于验收,不参与训练 ├── utils/ # 数据加载、增强、FPS 统计 ├── docs/ # 文档说明 ├── train.py ├── detect.py └── requirements.txt

我一般会要求团队把测试视频单独放在 data 目录下而不是项目根目录,因为它只承担验收职责,放根目录容易被训练脚本误扫进数据集。docs 里的文档说明至少要写三件事:数据格式定义、训练参数与官方默认值的差异、darknet 权重转换到 PyTorch 的方式。第 3 章和第 4 章的内容,本质上就是这三份文档的展开。

3. 人脸口罩数据准备与 YOLOv4 训练参数调整

人脸口罩识别有两个数据上的固有难点:一是“戴了口罩”这个类别有明确边界,但口罩颜色接近肤色时模型容易学混;二是许多数据集里with_mask远多于without_mask,训练出来的模型惯性偏置,把没戴口罩的人漏报。数据这一章要解决的是格式、增强和训练参数三者的一致性。

3.1 labelme JSON 转 YOLO 格式:标注坐标归一化

YOLO 的标签格式是每个目标一行:class_id cx cy w h,其中 cx、cy 是中心点坐标,w、h 是宽高,四个值全部归一化到 0~1。现在标记得多的是 labelme 或者 X-AnyLabeling 输出的 JSON 文件,需要转换为 txt。常见做法是按多边形外接矩形转换:

import json import os def convert_labelme(json_path, out_dir, class_map=None): class_map = class_map or {'with_mask': 0, 'without_mask': 1} with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) h, w = data['imageHeight'], data['imageWidth'] txt_name = os.path.basename(json_path).replace('.json', '.txt') lines = [] for shape in data['shapes']: cls_id = class_map.get(shape['label'], 0) points = shape['points'] xs = [p[0] for p in points] ys = [p[1] for p in points] x_min, x_max = min(xs), max(xs) y_min, y_max = min(ys), max(ys) cx = (x_min + x_max) / 2 / w cy = (y_min + y_max) / 2 / h bw = (x_max - x_min) / w bh = (y_max - y_min) / h lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") with open(os.path.join(out_dir, txt_name), 'w') as f: f.write('\n'.join(lines))

这段代码的核心是标签归一化。归一化后,训练时无论原图是 1920×1080 还是 720×1280,标签都在 0~1 区间,和网络的 grid 计算方式一致。类别编号必须和mask.names的行序一致,也就是 cfg 里的 classes 数与其对应,否则训练流程会跑完但 mAP 始终为零。另外注意外接矩形对人脸这种近似矩形的目标是够用的;如果标注的是多边形而不是矩形,直接取外接矩形,不要让框过紧,否则网络学到的回归目标方差太大。

3.2 训练集划分与数据增强参数

数据集量级上,两个类别每类至少 3000 张,其中 without_mask 建议单独多补 1000 张,用来对抗类别不平衡。划分时用随机划分,比例 8:2,但要注意同一个人的多张照片不能既进训练集又进验证集,否则验证集 mAP 虚高。增强策略按实际场景选择,下表是我在口罩项目上的常用参数:

增强策略参数建议说明
hue/sat/val0.01 / 0.5 / 0.5色调变化要小,饱和度和亮度可以大一点
mosaic4 张拼接显著改善半张脸小目标的召回
水平翻转概率 0.5人脸左右对称,翻转合理
灰度化概率 0.05模拟单色摄像头画面,防止过拟合颜色
随机旋转不使用90 度旋转后人脸语义失效

需要注意,随机旋转 30 度以内虽然可以用,但人脸检测框的宽高比会发生偏移,在 YOLO 的 anchor 匹配中容易掉到不合适的分组,所以不推荐。灰度化虽然只有 5% 概率,但对红外闸机这类偏色严重的摄像头很有帮助,代价几乎为零。

3.3 改 cfg 与训练超参:从 batch 到 filters 的一次改对

YOLOv4 的 cfg 里,每个检测头前面都有一个卷积层,它的 filters 数量必须等于(5 + num_classes) * 3。口罩识别只有 with_mask 和 without_mask 两类,所以 filters 是(5 + 2) * 3 = 21。一共有三个 yolo 层,每个 yolo 层前面的卷积层都要改成 21,只改一个会导致权重加载时的 shape 不匹配。

参数修改值含义
width / height416 或 608输入分辨率,低配显卡用 416
classes2每个 yolo 层下的 classes 行
filters21每个 yolo 层前一个卷积层
batch64显存不够就调大 subdivisions 而不是调小 batch
subdivisions16相当于把 batch 切成 16 次前向
learning_rate0.001微调权重复现常见
burn_in1000前 1000 次迭代学习率线性升温
max_batches6000自定义小数据集够用,VOC 级数据要 50000+
steps4800, 5400分别对应 max_batches 的 80% 和 90%

anchor 的修改要谨慎。官方 YOLOv4 预设了 9 组 anchor,三个检测尺度的 mask 分配是:52×52 用第 0、1、2 组,对应小目标;26×26 用第 3、4、5 组,对应中目标;13×13 用第 6、7、8 组,对应大目标。口罩人脸框的宽高比通常在 0.7~1.3 之间,官方 anchor 里的极端宽高比用不上,但直接删改 anchor 会破坏 mask 映射,容易造成训练不稳定。我一般保留官方的 9 组不动,只调输入分辨率。

3.4 训练产物、收敛判断与权重误用

训练完成后不要只看 loss 降到多少,那个数字和损失函数实现强相关,跨项目对比没有意义。更实用的判断是看验证集 mAP@0.5:戴口类别单独统计,without_mask 的 mAP 一般比 with_mask 低 3~5 个点,如果低太多就要检查是不是漏检了。保存模型时建议同时保存 best.pt 和 last.pt,best 按 mAP@0.5 的峰值保存,last 用于断点续训。

提示:很多人拿到预训练权重后直接开始从头训练,这会覆盖掉 COCO 的通用特征。口罩数据集不够大时,正确做法是用 darknet 官方 yolov4.weights 作为初始权重,并且确保加载时 filters 已经改成 21,否则最后一层卷积的权重数量不同,会在 shape 匹配检查时被跳过。

4. PyTorch 实战:加载 darknet 权重、解析三尺度输出并完成 NMS

这个章节是标题里“源代码”的核心价值所在。YOLOv4 的训练在 darknet 上完成,而部署推理放在 PyTorch 里做,所以代码库里最关键的模块就是权重导入、前向解析和后处理。三者的顺序不能乱:权重导错则输出全是噪声,输出解析错则置信度恒等于 nan。

4.1 在 PyTorch 里导入 darknet 权重:头部跳过与按层装载

darknet 的 .weights 是二进制顺序存储的,没有层名,只有按 cfg 顺序排列的浮点数。加载的第一步是跳过文件头。darknet 二进制文件头部有 3 个 int32 版本号加 1 个 int64 的训练计数,共 20 字节;有些编译器版本头只有 16 字节,这也是不同转换脚本行为不一致的原因之一。常见做法是统一跳过 20 字节,然后按卷积层顺序读取:

import numpy as np import torch def load_darknet_weights(model, weights_path): with open(weights_path, 'rb') as f: version = f.read(20) # 跳过 darknet 头部 20 字节 flat = np.fromfile(f, dtype=np.float32) ptr = 0 for layer in model.layers: if layer.type != 'convolutional': continue conv = layer.conv if layer.bn is not None: n = conv.weight.shape[0] bn = layer.bn bn.bias.data.copy_(torch.from_numpy(flat[ptr:ptr + n])); ptr += n bn.weight.data.copy_(torch.from_numpy(flat[ptr:ptr + n])); ptr += n bn.running_mean.data.copy_(torch.from_numpy(flat[ptr:ptr + n])); ptr += n bn.running_var.data.copy_(torch.from_numpy(flat[ptr:ptr + n])); ptr += n shape = conv.weight.data.shape # (out_c, in_c, k, k) numel = int(np.prod(shape)) conv.weight.data.copy_(torch.from_numpy(flat[ptr:ptr + numel]).view(shape)) ptr += numel

这个函数的逻辑是:有 BatchNorm 的卷积层先加载 BN 的偏置、权重、均值、方差四个部分,最后加载卷积核;没有 BN 的层先加载卷积偏置,再加载卷积核。因为 dense 块的组成顺序在 PyTorch 复现中严格对齐 darknet cfg,ptr 指针才能持续前进而不错位。如果加载后第一张测试图输出全是 0.5 左右,通常就是头部跳过了 16 而不是 20 字节,导致所有权重整体偏移了 4 个 float。

4.2 三个尺度输出与检测头张量形状

输入 416×416 时,YOLOv4 输出三个张量,分别对应 13×13、26×26、52×52 的特征图:

输出尺度张量形状对应目标分配 anchor
大目标(1, 21, 13, 13)整张正脸(142,110) (192,243) (459,401)
中目标(1, 21, 26, 26)戴帽、部分遮挡(36,75) (76,55) (72,146)
小目标(1, 21, 52, 52)半张脸、远处人脸(12,16) (19,36) (40,28)

张量形状里的第二个维度 21 是(5 + 2) * 3,即 5 个坐标与置信度字段、2 个类别、3 个 anchor。网络输出是原始预测值,需要解码成坐标,解码过程是把张量 reshape 成(batch, 3, 5 + num_classes, grid_h, grid_w),再调整维度顺序:

def decode_prediction(pred, anchors, num_classes): batch = pred.shape[0] grid_h, grid_w = pred.shape[2], pred.shape[3] pred = pred.view(batch, len(anchors), 5 + num_classes, grid_h, grid_w) pred = pred.permute(0, 1, 3, 4, 2).contiguous() # (N, A, H, W, 5+C) xy = torch.sigmoid(pred[..., 0:2]) wh = torch.exp(pred[..., 2:4]) * anchors.view(1, -1, 1, 1, 2).to(pred.device) conf = torch.sigmoid(pred[..., 4:5]) cls = torch.sigmoid(pred[..., 5:]) return xy, wh, conf, cls

这里的参数含义需要说清楚:xy 经过 sigmoid 映射到当前格子内部的 0~1 偏移,wh 是指数回归再乘上对应 anchor 的宽高,conf 代表目标置信,cls 是每个类别的概率。很多源码里直接把 xy 加上 grid 偏移,那是后处理的一部分,和这个是同一步拆出来的。

4.3 置信度过滤、NMS 与画框前的坐标回退

拿到 conf 和 cls 之后,先算最终分数score = conf * max(cls),过滤掉低于阈值的框,再做 NMS。PyTorch 直接用torchvision.ops.nms,比手写快而且少出错:

from torchvision.ops import nms conf_thres = 0.25 iou_thres = 0.45 scores = (conf * cls.max(dim=-1).values)[0] mask = scores > conf_thres boxes = xywh2xyxy(decoded_boxes)[0][mask] scores = scores[mask] keep = nms(boxes, scores, iou_thres)

分数计算的顺序是 conf 先乘类别概率,再做阈值过滤。如果直接把 conf 和 cls 分别过滤,会出现“类别概率很高但目标置信很低”的框被误保留。参数上,口罩类建议 conf_thres=0.4,without_mask 用 0.3,后者漏检代价更高,所以阈值放低一点。iou_thres 在密集人群里调到 0.4,否则两个人脸框叠交叠严重时会把其中一个人丢掉。

框坐标还有最后一个陷阱:如果预处理用了 letterbox,原图到网络输入之间有一段黑边补偿,画框前要把网络的坐标按缩放比例和 pad 偏移回退到原图坐标,而不是直接乘一个 resize 比例。这一步写错,视频里检测框会整体向右下角偏移,且越靠边缘偏移越明显。

5. 用测试视频验收:合批推理、FPS 统计与相邻帧投票

项目包里的 test_video.mp4 是验收基准,不是调参玩具。正确用法是把它当作固定测试集,每次模型改动后跑一遍同一段视频,记录检出率和 FPS,再对比改动前后的差异。如果每次换不同视频调试,无法判断模型是变好还是变坏。

5.1 合批推理与真实 FPS 统计

视频推理时逐帧调用 detect 函数是最直观的性能杀手。一次只推 1 帧,GPU 利用率极低。常见做法是一次读 4 帧组成一个 batch,送入网络后统一做后处理,吞吐量能提高 2 倍以上,显存增加很少。FPS 统计不要用 tick 打点除帧数,那个值会被首帧预加载和 numpy 转张量的耗时拉低;推荐用总帧数除以总耗时:fps = total_frames / (end_time - start_time),这样得到的是端到端真实吞吐量。

5.2 相邻帧投票稳定检测框

视频里检测框闪烁的根源不是模型抖动,而是置信度在阈值附近震荡。给同一个目标加一个时间窗口投票,能滤掉大部分临时误检:

class TemporalVote: def __init__(self, window=5, min_votes=3, iou_thres=0.5): self.window = window self.min_votes = min_votes self.iou_thres = iou_thres self.history = [] def update(self, boxes, scores): self.history.append((boxes.clone(), scores.clone())) if len(self.history) > self.window: self.history.pop(0) for i, box in enumerate(boxes): votes = 0 for h_boxes, h_scores in self.history[-self.window:]: if len(h_boxes) == 0: continue iou = box_iou(box.unsqueeze(0), h_boxes) if (iou > self.iou_thres).any(): votes += 1 if votes < self.min_votes: scores[i] = 0.0

投票机制的核心参数是 window 和 min_votes。window=5 表示看最近 5 帧,min_votes=3 表示同一位置的框至少出现 3 次才保留。这样单帧的随机误检会在 3 帧内被沉默,而真实目标因为连续出现在视频里不受影响。角注意如果视频剪辑有镜头切换,切换后的第 1 帧不要立刻投票,应先清空 history,避免把切换前的目标带到新场景里。

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

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

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

立即咨询