简介:本资源是一套基于YOLOv11的人脸检测与表情识别系统完整源码与配套文档,面向计算机视觉学习者、深度学习入门者及需要快速搭建表情识别演示的开发者。系统采用YOLOv11完成人脸定位,并用自定义训练的YOLO模型识别愤怒、厌恶、高兴、中性、悲伤、惊讶六种基本表情,支持图像、视频文件与实时摄像头三种输入方式,界面基于PyQt5开发并支持暗色主题,同时集成综合数据集模型与FER2013增强模型等多套权重。压缩包共1022个文件,约58.53MB,包含190个Python源码、97个YAML配置、59个pt权重、395个Markdown说明文档,以及少量C++、Rust、Dockerfile等部署相关文件,覆盖训练、推理与容器化运行环节。目前已有166人学习下载。读者可获得端到端可运行工程、多模型推理脚本、界面代码与详细文档,便于二次开发与课程实践。
1. 从一张 1080p 摄像头画面说起:YOLOv11 人脸检测与表情识别到底能落地到什么程度
很多人第一次接触「基于 YOLOv11 的人脸检测与表情识别系统」这个标题,脑子里浮现的是实验室 demo:一张正脸、均匀光照、七类表情分类准确率 95%。但真正把摄像头接上、把模型塞进产线或门店终端之后,你会发现翻车点根本不在分类头,而在人脸框抖动、侧脸漏检、光照突变和推理帧率掉到 8 FPS。这个方案要解决的核心问题其实很具体:用一套 YOLOv11 权重同时完成人脸定位和表情分类,支持图像、视频文件、实时摄像头三种输入,并且允许你用自己的数据重新训练。它适合两类人——一类是想把表情识别接进考勤、课堂专注度、零售客流分析这类真实场景的工程师,另一类是手里已经有一批标注好的人脸数据、想找一个能跑通训练到部署全链路的基线方案的人。读完你应该能判断:这套东西值不值得投入,以及投入之后第一个坑会出现在哪。
2. YOLOv11 做表情识别:为什么不用「检测+分类」两段式,而要走多任务头
2.1 两段式 pipeline 的隐性成本
最常见的做法是 RetinaFace 或 YOLO 人脸检测 + 一个独立的 CNN 表情分类网络,比如 ResNet18 或 MobileNetV3。这个方案上手快,但落地时会暴露三个问题。第一是延迟叠加:检测 15ms、分类 8ms,看起来还行,但两段之间要做人脸对齐、裁剪、归一化,CPU 上这一套下来经常比推理本身还慢。第二是框抖动传导:检测框每帧有 2~3 像素的抖动,裁剪出来的表情图就跟着变,分类结果会在「中性」和「高兴」之间反复横跳,视频里看起来像抽风。第三是训练数据割裂:检测数据集和表情数据集往往来自不同分布,检测器在侧脸、遮挡场景下框歪了,分类器再准也没用。
YOLOv11 的多任务头思路是把这两步合成一个前向。Ultralytics 的 YOLOv11 本身是检测头,但它的 head 结构是解耦的,分类分支和回归分支分开,你可以在检测头旁边挂一个表情分类分支,共享 backbone 和 neck 的特征。这样一次前向同时输出人脸框和表情 logits,框和表情来自同一组特征,抖动传导问题从根上缓解。
2.2 多任务头的结构改法与损失权重
具体改法是在ultralytics/nn/modules/head.py里新增一个MultiTaskHead,或者更省事的做法是继承Detect类,在forward里额外接一个全连接分支。下面是一个最小可用的改法,基于 Ultralytics 8.x 的模块组织方式:
import torch import torch.nn as nn from ultralytics.nn.modules.head import Detect class DetectWithEmotion(Detect): def __init__(self, nc=1, emotion_classes=7, ch=()): super().__init__(nc=nc, ch=ch) # 表情分支:全局池化 + 两层全连接 c_last = ch[-1] if isinstance(ch, (list, tuple)) else ch self.emotion_pool = nn.AdaptiveAvgPool2d(1) self.emotion_fc = nn.Sequential( nn.Linear(c_last, 256), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(256, emotion_classes) ) def forward(self, x): # x 是 neck 输出的多尺度特征列表 det_out = super().forward(x) # 原始检测输出 feat = x[-1] if isinstance(x, (list, tuple)) else x emo = self.emotion_pool(feat).flatten(1) emo_logits = self.emotion_fc(emo) return det_out, emo_logits逻辑说明:Detect的forward返回的是训练时的 tuple 或推理时的 tensor,这里不改动它,只在旁边加一条表情分支。emotion_pool把最后一层特征图压成向量,emotion_fc输出 7 类 logits。参数上emotion_classes按你的数据集定,常见 FER2013 是 7 类(愤怒、厌恶、恐惧、高兴、悲伤、惊讶、中性),如果只做「高兴/中性/负面」三分类,改成 3 即可。Dropout(0.3)是防止表情分支过拟合,人脸数据量通常比检测数据少一个量级。
损失函数要改训练脚本,检测损失用 YOLO 自带的v8DetectionLoss,表情损失用交叉熵,总损失是加权和:
cls_loss = nn.CrossEntropyLoss(label_smoothing=0.1) total_loss = det_loss + 0.5 * emo_loss权重 0.5 是经验值。表情分支太强会干扰检测收敛,太弱则表情学不动。我一般会先冻结 backbone 训 5 个 epoch 只训表情头,再解冻联合训 50 epoch。
2.3 自定义训练的数据组织与最小命令
数据目录按 Ultralytics 的检测格式组织,表情标签单独放一个 CSV 或 JSON,用图片路径做 key:
dataset/ images/train/ images/val/ labels/train/ labels/val/ # YOLO 格式的人脸框 emotions.csv # filename,emotion_id训练命令在改完 head 之后,需要自定义 trainer 或在model.train()里传emotion_csv参数。最小可跑的命令:
yolo detect train model=yolo11n.yaml data=face_emotion.yaml epochs=80 imgsz=640 batch=16 device=0yolo11n.yaml里把 head 换成DetectWithEmotion,face_emotion.yaml里nc: 1(只检测人脸一类)。imgsz=640是平衡速度和精度的常用值,如果只做近景人脸,imgsz=416能再快 30%。batch=16在 8G 显存上跑 yolo11n 比较稳,显存不够就降到 8。
提示:表情分支的输入是全局池化后的特征,不是人脸区域裁剪,所以训练时人脸框要尽量紧,否则背景信息会污染表情特征。
3. 图像、视频、摄像头三路输入的工程实现与参数调优
3.1 图像推理:批量处理和结果保存
图像输入是最简单的场景,但批量处理时容易踩显存和 IO 的坑。下面是一个批量推理并保存结果的脚本:
from ultralytics import YOLO import cv2, os, glob model = YOLO("runs/detect/train/weights/best.pt") img_paths = glob.glob("inputs/*.jpg") for p in img_paths: results = model(p, imgsz=640, conf=0.4, iou=0.5, verbose=False) r = results[0] frame = cv2.imread(p) for box, emo_id, emo_conf in zip(r.boxes.xyxy, r.emotion_ids, r.emotion_confs): x1, y1, x2, y2 = map(int, box) label = f"{EMO_NAMES[emo_id]} {emo_conf:.2f}" cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(frame, label, (x1, y1-8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0,255,0), 2) cv2.imwrite(f"outputs/{os.path.basename(p)}", frame)逻辑说明:conf=0.4是人脸检测置信度阈值,比默认 0.25 高,因为表情识别对人脸框质量敏感,低置信框往往对应侧脸或遮挡,表情结果不可信。iou=0.5是 NMS 阈值,人群密集场景可以降到 0.4 减少重叠框。r.emotion_ids和r.emotion_confs需要你在推理后处理里从多任务头输出解析出来,Ultralytics 原生 Results 对象不包含这两个字段,要在自定义 predictor 里加。
3.2 视频文件:帧率对齐和跳帧策略
视频文件推理的核心矛盾是「处理速度 vs 时间连续性」。如果每帧都跑,1080p 视频在单卡 3090 上大概 40 FPS,但很多视频是 30 FPS,处理完比原视频还慢。常见做法是跳帧 + 结果插值:
cap = cv2.VideoCapture("input.mp4") fps = cap.get(cv2.CAP_PROP_FPS) step = max(1, int(round(proc_fps / target_fps))) # 跳帧步长 idx = 0 while True: ret, frame = cap.read() if not ret: break if idx % step == 0: results = model(frame, imgsz=640, conf=0.4, verbose=False) last_boxes = parse(results[0]) draw(frame, last_boxes) # 非推理帧复用上一帧结果 out.write(frame) idx += 1target_fps设成 15 就够表情识别用了,表情变化本身是秒级的,不需要 30 FPS。step计算出来如果是 2,就是每两帧推理一次,中间帧复用上一帧的框和表情标签。这个策略在课堂专注度分析里很实用,因为学生表情不会在 1/30 秒内突变。
3.3 实时摄像头:延迟控制和线程模型
摄像头场景最容易被忽视的是「采集-推理-显示」的流水线阻塞。如果在一个线程里顺序做,采集 5ms、推理 25ms、显示 5ms,总延迟 35ms,看起来还行,但一旦推理波动到 50ms,画面就会卡顿。稳妥做法是双线程:采集线程只负责cap.read()并放入队列,推理线程从队列取最新帧,丢弃过期帧。
import threading, queue q = queue.Queue(maxsize=2) def capture(): cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) while running: ret, frame = cap.read() if not ret: continue if q.full(): try: q.get_nowait() # 丢弃旧帧 except queue.Empty: pass q.put(frame) def infer(): while running: frame = q.get() results = model(frame, imgsz=640, conf=0.4, verbose=False) draw_and_show(frame, results[0])maxsize=2是关键,队列太长会导致延迟累积,太短会丢帧。摄像头分辨率设 1280x720 而不是 1920x1080,因为 1080p 下 yolo11n 在 CPU 上只有 10 FPS 左右,720p 能到 25 FPS。如果必须 1080p,建议用 yolo11s 或加 TensorRT。
注意:实时场景下
conf阈值不要低于 0.35,否则背景纹理容易被误检成人脸,表情分支会输出随机结果,看起来像「玄学抖动」。
4. 避坑与排查:表情识别落地时最容易翻车的 5 个点
4.1 现象:视频里同一个人表情在「高兴」和「中性」之间高频跳变
原因:检测框每帧抖动 2~3 像素,表情分支的全局池化特征对框位置敏感,导致 logits 波动。解决:在推理后处理里加一个滑动窗口投票,最近 5 帧的表情 logits 做平均再取 argmax。代码上维护一个deque(maxlen=5),每帧 append 当前 logits,输出平均后的类别。这个改动能把跳变率降低 70% 以上。
4.2 现象:侧脸和低头场景漏检严重,表情完全识别不到
原因:训练数据里正脸占比过高,YOLOv11 的检测头对极端姿态泛化不足。解决:在数据增强里开启degrees=15、shear=5、perspective=0.0005,并额外收集 10%~15% 的侧脸样本。如果漏检集中在低头场景,可以把mosaic=1.0关掉最后 10 个 epoch,让模型在真实分布上微调。
4.3 现象:训练 loss 正常下降,但验证集表情准确率卡在 40% 不动
原因:表情标签噪声大。FER2013 这类公开数据集本身标注一致性只有 65% 左右,人工标的表情经常在「恐惧」和「惊讶」之间混淆。解决:先把 7 类合并成 4 类(高兴、中性、负面、惊讶),准确率通常能跳到 70% 以上。如果业务只关心「高兴/不高兴」,直接做二分类,准确率能到 85%+。另一个检查点是表情 CSV 的 filename 和实际图片是否一一对应,路径错位会导致标签全乱。
4.4 现象:摄像头推理跑几分钟后帧率从 25 FPS 掉到 8 FPS
原因:OpenCV 的VideoCapture缓冲区累积。默认缓冲区会缓存多帧,如果推理速度跟不上采集速度,缓冲区越来越长,cap.read()返回的是旧帧,同时内存占用上升。解决:设置cap.set(cv2.CAP_PROP_BUFFERSIZE, 1),并在采集线程里用grab()丢弃过期帧。另外检查是否有cv2.imshow在主线程阻塞,显示操作要放在独立线程或降低显示频率。
4.5 现象:自定义训练后模型在测试集上检测框正常,但表情输出全是同一类
原因:表情分支的梯度被检测损失淹没。多任务训练时检测损失的量级通常比交叉熵大一个数量级,表情分支学不到东西。解决:把表情损失权重从 0.5 提到 2.0,或者用 GradNorm 这类动态权重方法。更简单的做法是分阶段训练:前 10 epoch 只训检测头(冻结表情分支),后 40 epoch 联合训练,表情分支的学习率设成检测分支的 3 倍。
5. 进阶技巧:用 TensorRT 加速和置信度校准把实时表情识别推到可用
5.1 TensorRT 导出与 INT8 量化的边界
YOLOv11 导出 TensorRT 的命令很直接:
yolo export model=best.pt format=engine half=True device=0 workspace=4half=True是 FP16,在 30 系以上显卡上通常能提速 1.5~2 倍,精度损失小于 0.5%。INT8 量化需要校准集,yolo export format=engine int8=True data=face_emotion.yaml,但表情分支对量化更敏感,我实测 INT8 下表情准确率会掉 3~5 个百分点,检测框几乎无损。如果业务对表情精度要求高,建议只对 backbone 做 INT8,表情头保持 FP16。导出后推理接口不变,model = YOLO("best.engine")即可。
5.2 置信度校准:让表情输出从「能看」到「可信」
原始 softmax 输出的置信度往往偏高,一个明显是「中性」的脸可能输出「高兴 0.7」。校准方法是温度缩放(Temperature Scaling):在验证集上拟合一个温度参数 T,推理时用softmax(logits / T)。T 通常大于 1,能把过度自信的分布压平。实现上只需要在推理后处理里加一行:
emo_probs = torch.softmax(emo_logits / T, dim=-1)T 的拟合用验证集的 NLL 损失做网格搜索,范围 0.5~3.0,步长 0.1。校准后可以设一个「拒识阈值」:如果最大概率低于 0.5,输出「不确定」而不是硬分类。这个技巧在课堂专注度分析里特别有用,因为低头、遮挡场景下模型本来就不该给确定答案。
5.3 一个我踩过的坑:别在摄像头线程里做可视化
早期我把cv2.imshow和推理放在同一个线程,结果帧率一高就卡死,查了半天以为是模型问题。后来把显示拆到独立线程,主线程只做推理和队列管理,帧率立刻稳定。另一个习惯是每次改完 head 结构后,先用model.info()打印参数量和层结构,确认表情分支真的挂上去了,而不是被 Ultralytics 的模型解析器静默忽略。这个方案值不值得做,取决于你的场景能不能接受「表情识别本质是概率输出」这件事——如果能接受,YOLOv11 多任务头是目前工程复杂度最低、部署路径最短的选择之一。希望帮到你。
本文还有配套的精品资源,点击获取