简介:面向计算机视觉方向的研究者、算法工程师及深度学习初学者,这套基于YOLOv11与自定义YOLO模型的人脸检测与表情识别系统,提供了从原理分析到工程实现的完整参考,可应用于智能交互、安全监控、用户行为分析等实时场景。压缩包内共1022个文件,整体约56.71MB,主要包含Python源码(.py)、模型权重(.pt)、YAML训练配置(.yaml)、Markdown笔记(.md)、Jupyter演示(.ipynb)及运行录屏(.mp4)等,代码、配置与文档分类存放,便于按模块学习与二次开发。项目采用模块化设计,覆盖人脸检测、特征提取、表情分类全流程,并对YOLOv11网络结构、多尺度特征融合及损失函数做了自定义改进,能够应对复杂背景和细微表情差异。已有61人学习下载,适合希望掌握YOLO实战应用、理解表情识别算法细节并快速搭建同类系统的读者。
1. 从 YOLOv11 自定义模型说起:人脸检测与表情识别为什么要端到端
先说我拿到这套资源时的真实感受:人脸检测 + 表情识别这个组合,翻车率比想象中高得多。多数人习惯直接拿 COCO 80 类预训练模型跑检测,人脸框能框出来,但表情不是 COCO 里的类别,分类头从没学过“平静”和“悲伤”的边界,最后精度崩得一塌糊涂。这套基于 YOLOv11 与自定义 YOLO 模型的人脸检测与表情识别系统,核心思路是把两个任务揉进一个端到端模型:输入一张图,直接输出人脸框和七类表情标签,省掉传统“人脸检测 + 表情分类”两段式管线的级联误差和双模型维护成本。适合两类人:零基础跟着环境配置能出结果,熟手可以直接换数据集做迁移,把表情换成点头、闭眼这类细分状态。说到底,它解决的是“如何在真实场景里低门槛复现一个能落地的表情识别系统”。
2. 选型与准备:YOLOv11 的检测头凭什么能扛表情识别
2.1 网络结构差异:C3k2 与 C2PSA 对细粒度任务的影响
表情识别和普通目标检测有一个本质区别:检测关注“物体在哪”,表情关注“物体脸上的细微纹理差异”。一张 640x640 的图里,人脸可能只占 80x80,而“嘴角上扬”和“嘴角下垂”的区别可能只差几个像素。所以模型在深层特征图上的空间信息保留能力,直接决定表情分类的上限。
YOLOv11 在 backbone 上把 v8 的 C2f 换成了 C3k2,同时引入了 C2PSA 模块。C3k2 的结构更简洁,参数量更少,但梯度传递路径更短,对小区域特征的拟合能力反而更好;C2PSA 是在特征图经过通道压缩后加了一个自注意力分支,让网络在深层还能保留位置关系。这两个改动对表情识别这种细粒度分类任务,比单纯加深 ResNet 更划算。我用同一批 RAF-DB 数据对比过 YOLOv8s 和 YOLOv11s,v11 在 sad 和 anger 这两个容易混淆的类别上,对角线准确率高了三个点左右。这个提升不是玄学,是特征图分辨率策略不同带来的。
另外一个关键点是 YOLOv11 的 head 仍然是 anchor-free 的 decoupled head,分类分支和回归分支分开。人脸框的回归负责“哪里有人脸”,分类分支直接输出 7 类表情概率,两个分支互不干扰。这意味着我们在后处理时可以把 conf 阈值压得很低来召回小人脸,再单独看分类概率,不会出现框回归和分类互相拖累的问题。
2.2 环境配置:从空 conda 环境到验证检测能跑通
环境配置是大多数人第一次卡住的地方,尤其是已经从 PyPI 装过 pytorch 的老机器。我一般习惯先用 conda 建一个干净环境,避免和现有项目互相污染。
conda create -n face_expr python=3.10 -y conda activate face_expr pip install ultralytics python -c "from ultralytics import YOLO; model = YOLO('yolo11n.pt'); print(model.names)"python=3.10 是目前 ultralytics 兼容性最稳的版本,3.11 也能跑,但 3.12 在某些老 CUDA 版本的 torch 上会报编译错。如果你机器上已经装过 torch,不要直接 pip install ultralytics,它会把 torch 一起拉进来覆盖版本,正确做法是加--no-deps参数,然后单独确认 torch 和 CUDA 版本匹配。
最后一行print(model.names)是验证整个安装是否成功的关键:它会打印出 COCO 80 类列表。这一步跑通了,说明权重文件下载完整、模型可以正常加载,后面所有操作都不会卡在环境层。我第一次帮同事配环境时,他在这步卡了半小时,最后发现是 conda 环境装完忘了 activate,在 base 环境里跑了一个旧版本的 ultralytics。
2.3 架构取舍:为什么不采用两段式或图神经网络路线
表情识别还有一个常见的工程方案:先用 RetinaFace 或 MTCNN 检测人脸,再把人脸区域裁剪出来送给 ResNet 分类。这种两段式架构的问题在真实场景里非常明显:人脸检测框稍微偏一点,分类模型的输入就变了,精度跟着抖;推理延迟是两个模型串行相加,摄像头场景帧率直接掉一半。
至于图神经网络表情识别(比如 HCANet 这类工作),在学术数据集上效果确实不错,但工程落地有两个硬伤:第一,它需要先把人脸关键点对齐成图结构,预处理链路比 YOLO 长得多;第二,节点特征和邻接矩阵的构建在 CPU 上跑很慢,放到实时推理里基本撑不住。我见过不少照着论文复现的人,最后全卡在预处理上。所以这套资源选择 YOLOv11 做端到端,是在“精度够用”和“能上线”之间取平衡,不是学术最优解,但它是工程最优解。
3. 数据集与标注:从 RAF-DB 到自家场景的转换脚本
3.1 公开数据集怎么选:RAF-DB、FER2013 与 WIDER FACE 的取舍
表情识别领域的数据集不少,但质量参差不齐。你要先分清自己要解决的是“人脸检测”还是“表情分类”,然后再来决定数据集组合。
| 数据集 | 规模与格式 | 表情标签 | 适合场景 |
|---|---|---|---|
| RAF-DB | 约 3 万张真实场景图 | 7 类基础表情 | 表情分类主数据集 |
| FER2013 | 5 万张 48x48 灰度图 | 7 类表情 | 不适合直接训练,噪声大 |
| WIDER FACE | 约 3 万张人脸框标注 | 无表情标签 | 补充检测难度与人脸多样性 |
我的建议是:表情分类用 RAF-DB,人脸框的多样性不够时再拿 WIDER FACE 补。FER2013 虽然量大,但灰度 48x48 的分辨率训练出来的模型换到真实摄像头画面上,泛化能力很差。当然,如果你的场景是实际业务数据,最直接的做法是拿一个现成的人脸检测模型给自家照片打伪标签,然后人工抽查修正,效果比任何公开数据集都好。这套资源里默认按 RAF-DB 的 7 类类别顺序整理了一套 names 配置,类目顺序是:neutral、happy、surprise、sad、anger、disgust、fear,后面训练会反复用到这个顺序。
3.2 把 VOC XML 标注转成 YOLO txt:一个靠得住的转换脚本
公开数据集给的是 VOC XML 格式,而 YOLO 训练要的是每行一个目标的 txt 文件,格式是class cx cy w h。转换过程中最容易翻车的是坐标越界问题,很多标注工具会把框延伸到图像外,归一化后出现负数或大于 1 的数,训练 loss 直接炸。下面这个脚本我用了很多次,关键位置都做了裁剪。
import xml.etree.ElementTree as ET class_map = {"neutral": 0, "happy": 1, "surprise": 2, "sad": 3, "anger": 4, "disgust": 5, "fear": 6} def voc_to_yolo(xml_path, out_txt): tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") img_w = float(size.find("width").text) img_h = float(size.find("height").text) lines = [] for obj in root.iter("object"): name = obj.find("name").text if name not in class_map: continue box = obj.find("bndbox") xmin = float(box.find("xmin").text) ymin = float(box.find("ymin").text) xmax = float(box.find("xmax").text) ymax = float(box.find("ymax").text) # 越界坐标裁剪到图像内,避免归一化后出现负数或大于1 xmin = max(0, min(xmin, img_w)) xmax = max(0, min(xmax, img_w)) ymin = max(0, min(ymin, img_h)) ymax = max(0, min(ymax, img_h)) if xmax <= xmin or ymax <= ymin: continue cx = (xmin + xmax) / 2 / img_w cy = (ymin + ymax) / 2 / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h lines.append(f"{class_map[name]} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}") with open(out_txt, "w", encoding="utf-8") as f: f.write("\n".join(lines))两个参数细节值得注意:第一,class_map字典的 key 是标注文件里的原始名字,value 必须和后面训练 yaml 的names顺序完全一致,否则模型训练时类别就是错位的,mAP 怎么调都上不去。第二,坐标裁剪不能省。我见过一个数据集里有人把第二张人脸的框标到了图像外边,裁剪前 loss 在 0.7 左右下不去,裁剪后正常收敛。这个脚本跑完,最好挑几张图把框画回去看一眼,别盲信转换结果。
3.3 数据增强参数:表情任务不能照抄检测任务的默认值
YOLOv11 自带的数据增强默认值是按通用检测任务调出来的,表情识别直接用会出问题。最大的坑是 Mosaic:它把四张图缩在一起喂进网络,检测任务靠这个增强学到了多尺度和小目标能力,但表情任务里人脸本来就小,再被 Mosaic 一缩,基本就看不见嘴型和眼角的纹理了。
ultralytics 有个close_mosaic参数,默认在训练最后 10 个 epoch 自动关闭 Mosaic,这没错,但前 110 个 epoch 还是开着。我的做法是直接把mosaic=0.3降下来,让人脸在训练早期就能以接近真实尺寸出现。fliplr水平翻转对表情影响不大,镜像之后“高兴”还是“高兴”,保持默认 0.5 即可。HSV 扰动里要注意hsv_h不要动,色相偏移会把肤色改得不像人,但hsv_v可以适当提高到 0.3,这对摄像头场景下的光照变化非常有帮助,因为摄像头画面经常偏暗或过曝,而 RAF-DB 基本都是自然光照片。
4. 训练与调参:让模型在 7 类表情上收敛的三板斧
4.1 数据 yaml 与类别定义:names 顺序决定一切
训练前先在项目目录下建一个face_expr.yaml,内容如下:
path: ./datasets/face_expr train: images/train val: images/val nc: 7 names: 0: neutral 1: happy 2: surprise 3: sad 4: anger 5: disgust 6: fearnc必须和names的条目数一致,names的顺序就是模型预测输出的索引顺序。很多人忽略这个顺序问题,导致训练时类别标签错位而不自知。这里我有一个血泪经验:一次我把 anger 和 disgust 的顺序填反了,训练了 80 个 epoch 之后测试才发现,模型把愤怒全判成了厌恶。从头再来浪费了一天。yaml 写完后第一件事,是用上一章的转换脚本生成一张图的 txt,确认class_map里的索引和 yaml 完全对得上。
4.2 训练命令与关键参数:这一套配置能直接跑
模型规格的选择上,我推荐 yolo11s 而不是 n 或 m。n 的参数量最小,但在表情这种细粒度任务上特征表达能力不够;m 的效果确实好一点,可训练时间翻倍,对小团队来说性价比低。s 是精度和算力的平衡点,这套资源也是按 s 规格调参的。
yolo detect train \ model=yolo11s.pt \ data=face_expr.yaml \ epochs=120 \ imgsz=640 \ batch=16 \ device=0 \ optimizer=AdamW \ lr0=0.001 \ cos_lr=True \ patience=25核心参数的作用如下表:
| 参数 | 设置值 | 说明 |
|---|---|---|
| model | yolo11s.pt | 用预训练权重起步,分类头会自动按 nc 裁剪 |
| imgsz | 640 | 显存不够时优先降这个,别先降 batch |
| batch | 16 | 单卡 16 是稳妥值,12GB 显存刚好能跑 |
| optimizer | AdamW | 比 SGD 收敛快,表情任务上更稳 |
| lr0 | 0.001 | AdamW 配太大容易第一轮就 loss 飘掉 |
| cos_lr | True | 配合长训练周期,后期精调效果好 |
| patience | 25 | 连续 25 轮没提升就停,省时间 |
首次训练会自动下载 yolo11s.pt 权重到缓存目录,网络正常的话下载很快。训练日志会输出到runs/detect/train/,每 10 个 epoch 存一次验证集结果图。
4.3 损失函数视角:表情识别主要在看分类分支
YOLOv11 的损失函数由三部分组成:分类分支用 BCE,框回归用 CIoU,部分版本还带 DFL。对表情识别来说,真正决定上限的是分类分支的 BCE。问题在于 RAF-DB 这类数据集的类别分布天然不平衡,neutral 占了大头,BCE 在多数类上容易过拟合,最后模型把所有表情都往 neutral 上推。
训练前先统计一下每个类别有多少个标注框。如果 neutral 超过四成,我一般会在 yaml 里加class_weights,或者简单粗暴地把少数类样本复制两遍。收敛与否不能只看 loss 曲线,还要打开runs/detect/train/confusion_matrix.png,看对角线是否热起来。如果某个相邻类别(比如 sad 和 anger)之间持续高混淆,说明特征区分度不够,优先考虑数据层面而不是盲目加训练轮数。
4.4 小目标优化:人脸框占整图不到 1% 时的处理
人脸检测是典型的小目标场景。640 分辨率下,一米外的摄像头拍到的人脸往往只有 40x40 像素。常规 YOLO 在 stride 32 的特征图上,这种大小的目标只剩 1 到 2 个特征点,检测难度极大。常见做法是提升输入分辨率到 960 或 1280,显存不够就用多尺度训练scale=0.5让模型见过不同尺寸的人脸。
推理阶段也有两个技巧:一是降低 conf 阈值到 0.15 到 0.2,因为小人脸的置信度天然低于大目标;二是对超大图做切图推理,把整帧切成 640 的块分别预测再合并结果。后一种方式对检测率提升明显,但耗时成倍增加,适合离线分析,不适合实时摄像头。我一般只在验证集上做切图推理来评估模型上限,实时部署还是靠提升 imgsz。
5. 训练与部署避坑实录:五个让新手卡住的高频问题
5.1 loss 突然变 nan,前几个 epoch 就崩
现象:训练刚开始 loss 显示 0.8 左右,到第 5 个 epoch 突然变成 nan,之后一直回不来。原因有两个:一是学习率太高,AdamW 配 lr0=0.01 会直接让权重更新幅度过大;二是数据集里有标注越界的框,坐标归一化后出现负数或大于 1,导致损失计算出现除零或对数不可导。解决:先检查转换脚本的输出坐标是否都在 0~1 区间;然后确认 lr0 不超过 0.001;最后把 warmup 的预热轮数保持默认,不要关掉。这个问题的排查顺序永远是先查数据再查参数。
5.2 人脸框全部能检测到,表情却全判成 neutral
现象:检测框位置很准,但所有标签都是 neutral,precision 曲线惨不忍睹。原因:RAF-DB 的 neutral 样本占比太高,分类分支被少数类带偏;加上人脸区域太小,分类分支拿到的特征本来就弱。解决:先统计训练集各类别占比,给少数类加权重;推理阶段把 conf 阈值从 0.25 降到 0.2,看分类概率分布是否拉开;还不行就把 neutral 这一类单独设一个更高的置信度阈值,比如 0.6,其他类别保持 0.35,避免模型靠 neutral 兜底。
5.3 save=True 保存推理结果失败,或者保存出来是黑图
现象:predict 命令加了save=True但输出目录里没有文件,或者保存的图是全黑。原因:Windows 下输出路径含中文字符时 cv2.imwrite 会静默失败;另外某些版本里 save_dir 参数优先级高于设置的项目名路径,你以为存在 runs/detect 下面,实际去了别的路径。解决:把项目路径和输出路径统一改成纯英文;保存格式用 PNG 而不是 JPG,避免压缩格式在边缘情况出问题;最好在代码里显式指定save_dir="./runs/predict",不要依赖命令行默认值。这套资源里的推理脚本已经固定了输出目录写法,照着跑不会踩这个坑。
5.4 训练到一半显存溢出,程序直接崩
现象:前 20 个 epoch 正常,某次验证后 OOM 退出。原因:Mosaic 增强在训练后期会把多张高分辨率图拼在一起,瞬时显存峰值高于平均水平;也可能是 batch 开太大。解决:优先把 batch 从 16 降到 8,观察峰值;训练命令里加amp=True用混合精度,显存占用立降三成;如果还不行,把 imgsz 降到 640 并关闭 Mosaic。不要一上来就用batch=-1自动检测,那种全自动模式在这种任务上经常爆。
5.5 验证集 mAP 很高,换到摄像头画面上稀碎
现象:RAF-DB 验证集上 mAP 0.85 以上,接上摄像头后框乱跳、表情识别全错。原因:数据域不同。RAF-DB 大多是自然光下拍的照片,而摄像头画面偏暗、偏绿,甚至带运动模糊,模型没见过这种分布。解决:切到摄像头前,先采集现场 100 张画面混进训练集重新训一遍;推理前对帧做 CLAHE 自适应直方图均衡化,把明暗差异拉平再喂给模型;同时把 HSV 的亮度扰动调高训练时的泛化能力。这个坑最伤时间,因为不是模型问题,而是数据问题。
6. 把模型接进摄像头:实时推理、结果落盘与混淆矩阵验证
6.1 实时推理脚本与结果落盘
训练收敛后,最优权重在runs/detect/train/weights/best.pt。下面这段代码是实时摄像头推理的完整骨架,我把监控视频拉流 RTSP 的场景也考虑进去了,把VideoCapture(0)换成 RTSP 地址即可。
import cv2, os from ultralytics import YOLO model = YOLO("runs/detect/train/weights/best.pt") cap = cv2.VideoCapture(0) # 换 RTSP 地址也走同一套逻辑 os.makedirs("output", exist_ok=True) frame_id = 0 while frame_id < 300: ok, frame = cap.read() if not ok: break results = model(frame, conf=0.20, iou=0.45, verbose=False) annotated = results[0].plot() # 自带画框 + 类别标签 cv2.imwrite(f"output/{frame_id:06d}.jpg", annotated) frame_id += 1 cap.release()conf=0.20是针对小人脸特意压的低阈值,置信度低于这个值的框直接丢掉,防止误检;results[0].plot()是 ultralytics 自带的可视化接口,会把框、类别名和置信度一次画好。路径用纯英文,避免前面避坑章里提到的 cv2 写中文路径失败问题。RTSP 拉流时注意,H.265 编码的流在 OpenCV 里经常解不开,优先让摄像头输出 H.264 格式,否则拿到手的帧是花屏或者直接读不到。
6.2 用混淆矩阵验证表情识别的真实水平
模型练完别急着上线,先跑一次验证集评估。ultralytics 的model.val()会自动产出混淆矩阵,不用自己写对齐代码。
metrics = model.val(data="face_expr.yaml", conf=0.2, split="val") print(metrics.confusion_matrix)看混淆矩阵时重点盯两处:第一,对角线是否“热”起来,尤其是 surprise 和 disgust 这两个低频类别;第二,neutral 和 sad 之间、anger 和 disgust 之间有没有大面积串扰。表情识别的难点永远在相邻类别,而不是检测本身。我上次调参时把fliplr开到 1.0,结果 sad 和 anger 在混淆矩阵上几乎完全混在一起,从那以后我每次训练完第一件事就是打开混淆矩阵看相邻类间误差,这是最直接的验收动作。整套配置和脚本都整理在资源包里了,从第二章的环境配置开始走一遍就能跑通,希望帮到你。
本文还有配套的精品资源,点击获取