简介:面向深度学习与计算机视觉开发者,这份资源围绕 YOLOv11 及自定义 YOLO 模型,给出了人脸检测与表情识别的完整实现方案。内容涵盖模型定义、训练配置、推理脚本与可视化工具,可帮助读者快速复现从数据准备到模型部署的全流程。压缩包共1022个文件,以md说明文档、py脚本、yaml/yml配置、pt权重文件以及jpg/png图像素材为主,同时包含少量cpp扩展、ipynb示例与mp4演示视频,整体约56.71MB,目录结构清晰,适合按模块检索学习。目前已有61人学习下载,适合希望理解YOLO系列改进细节(如SPP、多尺度特征融合、注意力机制)并开展表情识别实验的研究者或工程师使用。资源附带实验笔记、模型权重与相关脚本,便于对照代码和文档进行二次开发与效果验证。
1. 用 YOLOv11 自定义模型一把搞定人脸检测与表情识别:联合输出比两套系统省一半工程量
表情识别在真实项目里最常见的设计是两段式:先跑一个人脸检测器,把框裁出来,再送进一个独立的分类模型。这套流程能跑通,但工程上很臃肿,两套模型要分别维护、分别调参、分别处理误检累积。而 YOLOv11 的检测头本身就是「分类 + 回归」的联合输出结构,把分类分支从「人脸/背景」改成「7 类表情」,就能让同一个模型同时吐出检测框和表情标签。本文就沿着这个思路,讲清楚怎么准备标注、怎么改模型配置、怎么训练和部署一套基于 YOLOv11 的自定义人脸检测与表情识别系统。这套方案在课堂专注度分析、门店客流情绪统计、安防抓拍这类落地场景里,能明显减少系统复杂度和硬件开销。目标读者是已经有基础检测经验、想快速把表情识别接到现有 YOLO 流程里的人。
2. 把 YOLOv11 改造成双任务输出:检测头 + 表情分类头的联合架构
2.1 为什么 YOLOv11 适合做人脸 + 表情的联合任务
YOLOv11 相对前代最大的变化在骨干网络:用 C3k2 模块替换了原来的 C2f,保留了梯度流的分支结构,但把标准 Bottleneck 部分替换成参数量更小的 C3k2,整体计算量下降。第二个关键变动是在骨干尾部引入了 C2PSA 注意力模块,它本质上是把多头自注意力机制以卷积形式封装进特征提取网络,对人脸这种局部纹理敏感、背景复杂的目标非常友好。注意力机制能帮模型在密集人群中更准确地聚焦到人脸区域,对表情这种细微的肌肉变化也有帮助。
这两点决定了 YOLOv11 适合做人脸检测,但真正让它适合做表情识别的,是检测头的解耦设计。YOLOv11 的检测头把分类分支和回归分支分开,每个分支独立卷积。这意味着我可以在不改变网络结构的前提下,把分类分支的类别数从「1 类人脸」改成「7 类表情」,回归分支继续输出 4 个坐标。一次前向传播,同时得到「哪块区域是人脸」和「这张脸的表情是什么」。
从工程角度看,联合模型最大的收益不是精度,而是省掉了一条推理链路。两段式方案里,检测模型的误检会直接污染分类结果,分类模型的延迟会叠加在检测延迟上。联合模型只有一个推理入口,检测框和表情标签来自同一个特征图,延迟和误差都是单份的。我一般会对团队说一句话:能用单模型解决的问题,不要用两模型。
2.2 自定义模型的改动点:nc 参数、anchor 与检测头的一致性
YOLOv11 的自定义,重点不在改网络代码,而在改配置。模型默认的类别数 nc 是 80(COCO 数据集),改动有三个地方,任何一个不一致都会导致训练或推理报错。
第一个是模型配置文件里的nc。将nc: 7(按 7 类表情算)写入模型 yaml,框架会据此重新初始化检测头的分类分支,回归分支不受影响。第二是数据集配置文件里的names列表,它负责把训练时的类别 id 映射到可读标签上,顺序必须和标注文件里的 class_id 完全对应。第三是 anchor 的隐式调整。YOLOv11 用的是动态 anchor 分配策略,框架会在训练前根据你提供的数据集自动重新聚类 anchor 尺寸,不需要手动设置三个尺度的长宽比,但前提是标注框的质量足够好,存在大量错误标注时,聚类出来的 anchor 会整体偏移,拖慢收敛速度。
这里强调一个容易翻车的认知:很多人以为自定义 YOLO 模型需要改网络结构代码。实际上,除非你有强力的理论依据,否则不要动网络主体。YOLOv11 的特征提取能力远超人脸表情任务的需求,真正决定效果的是数据质量、标注一致性和训练参数。我见过 A 同学花了一周魔改 backbone,最后精度反而不如原版微调。
2.3 标注数据的两种常见做法与推荐选择
无论是造数据还是找公开数据集,标注策略决定模型上限。第一种做法是人脸框标注 + 表情类别。每张人脸框的 class_id 直接对应表情类别,比如 0=angry、1=disgust、2=fear、3=happy、4=neutral、5=sad、6=surprise。模型看到的任务就是「在图中找到这些类别的目标」,检测和分类一步到位。第二种做法是人脸单类 + 独立表情分类器,标注只框人脸,表情标签单独建一个分类数据集。这种做法兼容现成的检测权重,但违背了我们选 YOLOv11 联合模型的初衷。
推荐第一种做法。公开的人脸检测数据集标注质量高,公开的表情数据集通常自带人脸框,把它们合并成一个多类别检测数据集,是成本最低的路径。数据规模上,我建议每个表情类别至少准备 1500 到 3000 个实例,总共 1.5 万到 2 万个标注框起步。如果某些类别(比如 disgust)样本极少,宁可先做数据扩增(水平翻转、小角度旋转、亮度扰动)把数量补上来,也不要让模型带着严重失衡的类别分布硬训。
2.4 模型选大小的取舍:从 nano 到 x 的实测体感
YOLOv11 提供 n、s、m、l、x 五种规格。人脸表情识别属于「特征不算复杂但目标偏小」的任务,我做过对比,s 和 m 是性价比最高的区间。nano 在清晰度较差的门禁摄像头画面上,小脸漏检率明显偏高;l 和 x 精度提升有限,但推理延迟翻倍。如果是 Jetson 这类边缘设备部署,选 s 用 TensorRT 转一圈,能达到实时要求;如果是服务器离线批量处理,用 m 更稳。
值得留意的是可视化层的差异。在 640 输入尺寸下,s 与 m 的 mAP 差距通常在 1 到 2 个百分点,但在遮挡、侧脸这类难例上的差距会更明显。如果你的场景是固定机位的闸机摄像头,人脸角度变化小,s 足够;如果是教室、门店这类开阔场景,人脸尺度变化大,直接上 m 能少踩很多「模型怎么调都漏检」的坑。
3. 从数据集到可训练配置:人脸标注转 YOLO 格式的转换脚本与关键参数
3.1 数据集目录结构与 YOLO 标注格式
训练 YOLOv11 的标准数据集目录结构和旧版一样,一个根目录下分images/train、images/val、labels/train、labels/val四类文件夹。图片名与标注文件名必须严格同名,标注文件是.txt,一行代表一个目标:
class_id cx cy w h其中cx、cy是归一化到 [0,1] 的中心点坐标,w、h是归一化后的框宽和框高。这里的归一化是除以图片原始宽高,不是除以网络输入尺寸。目录结构示例:
face_emotion/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ ├── data.yaml └── yolov11s_face_emotion.yaml我一般习惯把原始标注先转成 VOC 的 XML 格式,再统一转成 YOLO 的 txt。因为开源的公开人脸数据集大部分用 VOC 格式发布,先统一到 VOC 再二次转换,可以避免不同数据集标注格式的细微差异(比如坐标原点、框是否含头部上方区域)。
3.2 VOC 人脸标注转 YOLO 格式的 Python 脚本
假设你手里的人脸框标注是 XML 格式,<object>节点里有name和bndbox,转换逻辑如下:
import os import xml.etree.ElementTree as ET from pathlib import Path # 表情类别到 id 的映射,顺序必须固定,后续训练和推理都用这份映射 EMOTION_MAP = { "angry": 0, "disgust": 1, "fear": 2, "happy": 3, "neutral": 4, "sad": 5, "surprise": 6 } def convert_voc_xml_to_yolo(xml_path, output_dir): tree = ET.parse(xml_path) root = tree.getroot() img_w = int(root.find("size/width").text) img_h = int(root.find("size/height").text) lines = [] for obj in root.findall("object"): name = obj.find("name").text.strip() if name not in EMOTION_MAP: # 跳过非目标类别,比如 "face" 这类未映射标签 continue class_id = EMOTION_MAP[name] bbox = obj.find("bndbox") x1 = float(bbox.find("xmin").text) y1 = float(bbox.find("ymin").text) x2 = float(bbox.find("xmax").text) y2 = float(bbox.find("ymax").text) # VOC 坐标是左上右下,YOLO 需要中心点 + 宽高,统一归一化到 [0,1] box_w = max(x2 - x1, 1.0) box_h = max(y2 - y1, 1.0) cx = (x1 + x2) / 2.0 / img_w cy = (y1 + y2) / 2.0 / img_h nw = box_w / img_w nh = box_h / img_h # 越界截断,防止标注框超出图片边界导致训练报错 cx = min(max(cx, 0.0), 1.0) cy = min(max(cy, 0.0), 1.0) nw = min(max(nw, 0.0), 1.0) nh = min(max(nh, 0.0), 1.0) lines.append(f"{class_id} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}") if not lines: return False output_file = Path(output_dir) / (Path(xml_path).stem + ".txt") output_file.write_text("\n".join(lines), encoding="utf-8") return True # 批量转换 train 和 val 的 XML for split in ["train", "val"]: xml_dir = Path(f"annotations/{split}") out_dir = Path(f"labels/{split}") out_dir.mkdir(parents=True, exist_ok=True) for xml_file in xml_dir.glob("*.xml"): convert_voc_xml_to_yolo(str(xml_file), str(out_dir))这段脚本的唯一任务就是做坐标格式换算。max(x2 - x1, 1.0)是为了防止某些数据集中标注框宽高为 0 或者出现反向坐标,这种脏数据必须在转换阶段处理掉,而不是等训练时让框架报错再回去排查。越界截断同理,标注框超出图片边界时,YOLO 训练过程中计算 anchor 匹配会出问题,直接在转换阶段把坐标压回 [0,1] 区间是最省事的做法。
3.3 修改 YOLOv11 的模型配置文件与数据配置文件
数据配置文件data.yaml是训练入口,模型配置文件是网络结构的参数来源。两者分开维护,我用下面的模板:
# data.yaml path: /path/to/face_emotion train: images/train val: images/val nc: 7 names: 0: angry 1: disgust 2: fear 3: happy 4: neutral 5: sad 6: surprise# yolov11s_face_emotion.yaml # 基于官方 yolov11s 架构,只改 nc,不动 backbone 和 head 结构 nc: 7 scales: s: [0.33, 0.50, 1024]第二份文件是模型结构的精简写法。scales里的三个数字分别对应深度因子、宽度因子和最大通道数,s 规格就是[0.33, 0.50, 1024]。这里只写nc和scales是可行的,因为框架会自动套用官方预定义的yolov11s完整结构模板。不要自己从零写完整网络结构,很容易在某个卷积层的stride或padding上出问题,导致训练时特征图尺寸对不上。
3.4 训练命令与四个必调参数
训练命令用框架自带的 CLI 就能完成:
yolo train \ model=yolov11s.pt \ data=data.yaml \ epochs=150 \ imgsz=640 \ batch=16 \ lr0=0.01 \ patience=20 \ device=0 \ project=runs/face_emotion \ name=s_exp上面的四个必调参数是imgsz、batch、lr0和patience。imgsz决定训练时图片缩放尺寸,人脸检测任务里 640 是常用起点,如果场景以远距离小脸为主,可以试 800,但显存占用会明显上涨;batch受显存限制,在 batch 为 16 时如果显存溢出,优先把imgsz降到 512,而不是改小 batch,因为过小的 batch 会让 BN 层的统计量不稳定,训练震荡概率大增;lr0是初始学习率,0.01 是迁移学习微调的安全值,从零开始训练时要降到 0.001 以下,不然前几个 epoch loss 直接飞掉;patience是早停耐心值,设为 20 表示验证集指标连续 20 个 epoch 无提升就自动停止。
训练过程中看日志,重点盯两个指标:box_loss和cls_loss。box_loss下降慢是小目标回归难,常见对策是提高imgsz;cls_loss降不下去或者震荡,优先怀疑类别不平衡,而不是调学习率。一个血泪经验:不要一看到 loss 波动就去调学习率,前 20 个 epoch 的波动大多和 Warmup 过程有关,等 50 个 epoch 之后再判断趋势。
4. 表情识别不是独立任务:把分类头接在检测框上的推理管线与实时优化
4.1 图像与视频推理:加载自定义权重并输出表情标签
训练完成后,推理阶段最关键的一步是把模型输出的张量正确解析为「检测框 + 表情类别」。开源框架的 Python API 已经封装好了解析过程,直接调用即可:
from ultralytics import YOLO # 加载训练好的自定义模型,运行时自动读取 data.yaml 中的类别名 model = YOLO("runs/face_emotion/s_exp/weights/best.pt") # 图像推理 results = model.predict( "test.jpg", conf=0.35, # 置信度阈值,低于此值的框直接丢弃 iou=0.5, # NMS 的 IoU 阈值,重叠超过此值的框会被合并 imgsz=640, device=0 ) result = results[0] boxes = result.boxes # 检测框 + 类别 + 置信度 for box_data in boxes: cls_id = int(box_data.cls[0]) # 表情类别 id conf = float(box_data.conf[0]) # 置信度 xyxy = box_data.xyxy[0].cpu().numpy() # [x1, y1, x2, y2] 像素坐标 emotion_name = model.names[cls_id] print(f"{emotion_name} | confidence={conf:.2f} | bbox={xyxy}") result.save("test_output.jpg") # 保存可视化结果这段代码里conf=0.35是落地时最需要调的参数。表情识别场景中,误检一张脸比漏检一张脸的代价更大——给一个路人标记「happy」会影响整段视频的统计结果。我推荐在摄像头远距离场景把conf从默认的 0.25 提到 0.4 左右;如果视频源是近景特写,0.3 就可以了。iou=0.5保持不变,这是 NMS 的标准阈值。
4.2 摄像头实时场景的三个关键参数调优
实时视频流和离线图片推理是两套完全不同的调优逻辑。离线推理追求召回率,实时系统追求稳定性和低延迟。用摄像头跑实时识别时,我会优先调整三个参数。
首先是输入尺寸。imgsz=640是检测精度和速度的平衡点,但如果部署机器是 CPU,降到imgsz=416能换来接近一倍的 FPS 提升,小脸召回率会有轻微下降,不过在近距离摄像头场景影响不大。其次是跳帧策略。表情识别任务对时间连续性要求不高,没有必要对每一帧都做完整推理。我一般做「隔帧推理 + 中间帧复用上一帧结果」的策略,比如每 3 帧推理一次,把 FPS 需求降为原来的 1/3。最后是推理线程与采集线程分离。视频采集由独立线程负责,推理结果只更新最近状态,避免cap.read()阻塞住检测循环,这是实时系统最常见的延迟来源。
4.3 用混淆矩阵评估检测与分类的短板
训练完成后,验证集上的 mAP 只能说明整体水平,判断「检测差」还是「分类差」,必须看混淆矩阵。对每个检测出的框,正确检测但表情判错,和完全漏检,是两种不同的问题。
实操做法是:在验证集上跑推理,把每个框的预测类别和 GT 类别做匹配。预测框与 GT 的 IoU 大于 0.5 视为检测命中,此时如果类别不一致,记录为「错分」;如果 IoU 小于阈值,视为「误检」或「漏检」。表情识别任务中 90% 的精度瓶颈在「neutral 与 sad 互相错分」「happy 与 surprise 的边界难分」这两对混淆上。遇到这类问题,调检测参数没用,要回到数据集,检查这两类的标注是不是存在边界不清的情况,必要时统一标注口径重新清洗。
5. 训练与部署中必踩的 5 个坑:人脸小目标、类别失衡与推理漂移
5.1 小脸漏检严重,mAP 高但实证效果差
现象:在验证集上 mAP 有 0.85 以上,但一接到实际摄像头画面,两米外的人脸完全检测不到。
原因:验证集是公开的人脸数据集,人脸占比大、清晰度高。实际场景中人脸只占整幅画面的 5% 甚至更低,模型在训练时根本没有见过这种尺度的人脸。mAP 指标的统计方式也掩盖了这个问题,它对所有尺寸的目标平均,小目标的数量占比低,贡献的 mAP 权重就小。
解决:把训练imgsz从 640 提高到 800 或 1024,让模型在更大的特征图上学习小脸特征;同时在使用公开数据集训练后,加入真实场景数据做二次微调。真实数据不用标注很多,500 张覆盖实际机位的画面,就能把漏检率降下一大截。如果设备算力不支持大imgsz,另一个选择是使用 SAHI 这类切片推理方法,在推理阶段把大图切成小块分别检测,再合并结果。
5.2 disgust 类永远学不会,各类表情样本数量差距过大
现象:训练日志里cls_loss下降缓慢,验证时 disgust 的召回率接近 0,其他类别正常。
原因:公开表情数据集里 disgust 的样本数量天然稀少,可能只有 happy 的 1/10。模型在训练时学到的最优策略就是把所有可疑样本判成 neutral,因为这样做整体 loss 最小。这种现象在大模型上更明显,模型容量越大,越容易「走捷径」。
解决:步骤分两层。第一层是数据层面,对 disgust 类做针对性扩增:随机旋转 15 度、水平翻转、HSV 扰动分别扩到 3 倍数量。第二层是训练层面,修改损失函数中的类别权重,让少数类在计算cls_loss时乘以一个加权系数。开源框架支持通过参数配置方式传入类别权重列表,把 disgust 的权重设到 3.0 或更高,能有效拉高这类别的召回率。
5.3 训练到第 80 个 epoch loss 突然飞升
现象:loss 前半段正常下降,某个 epoch 后突然变成 nan,或者瞬间涨到之前的 5 倍。
原因:常见原因有三个。一是学习率过大,当 loss 降到平台期后,过大的学习率会让参数在局部极小值附近震荡发散;二是 batch 内出现了异常样本,某个标注框的坐标超出了图片边界,导致 loss 计算时出现极端值;三是模型权重数值溢出,通常和第一条伴随出现。
解决:首先把lr0降低一半,如果问题消失,说明是学习率问题,同时把cos_lr选项打开,让学习率按余弦曲线衰减到 0,能有效缓解后期震荡。其次,检查 loss 飞升前最后一批训练数据对应的标注文件,重点查有没有超出 [0,1] 范围的坐标。这里体现出一个好习惯的重要性:训练数据准备阶段就要写脚本校验一遍所有标注的坐标范围,别等训练跑崩了再回头查数据。
5.4 摄像头画面时好时坏,同一个人表情识别结果来回跳
现象:同一张脸同一表情,连续几帧推理,结果在 happy 和 neutral 之间跳来跳去,视频输出看起来非常不专业。
原因:单帧模型的置信度波动是正常现象,表情在肌肉层面的差异本来就细微,输入图像的噪声、人脸微小的角度变化都会影响分类结果。逐帧独立预测没有利用时间的连续性,抖动就在所难免。
解决:对置信度做时域平滑。维护一个长度为 5 到 10 帧的表情历史队列,每帧推理后,选择队列中出现最多的类别作为当前表情输出;或者用指数滑动平均处理各类别置信度分数。实现很简单,收益立竿见影。另一个更轻量的做法:只有当某个类别的置信度连续 3 帧超过阈值时才更新状态,否则保持上一帧的结果。
5.5 导出 ONNX 后精度明显下降
现象:训练时验证集 F1 分数 0.88,用框架自带的导出功能转成 ONNX 后在 CPU 上跑,F1 直接跌到 0.7 以下。
原因:大部分情况下不是导出出了问题,而是推理超参数不一致。最典型的是imgsz不一致:训练时用了 640 但导出后推理代码里imgsz被设置为默认值,输入分辨率下降导致所有指标跟着降。另一种情况是导出时框架默认开启了半精度 FP16,CPU 上跑 FP16 模型时部分算子被截断,精度自然掉。
解决:先检查推理代码里imgsz、conf、iou是否和验证脚本保持一致。排除超参数问题后,再用opset=12且关闭半精度重新导出。如果精度仍不达标,继续对比原始 PyTorch 模型与 ONNX 模型在相同输入下的输出张量差异,差异超过 1% 就说明有算子兼容性问题,这时需要人工检查 ONNX 图里对应的算子,改用手工重写该算子的实现。
6. 给自定义表情识别系统做一次性价比最高的验证与导出
6.1 在独立测试集上跑一次端到端体检
训练时用的验证集在训练过程中被反复查看,指标会偏乐观,所以我会从原始数据里再切一批完全不参与训练的测试集。体检脚本要回答两个问题:检测框有没有漂移,表情标签有没有错分。下面的脚本输出逐类别的召回率,帮你快速定位瓶颈:
from ultralytics import YOLO import numpy as np model = YOLO("runs/face_emotion/s_exp/weights/best.pt") GT = { "happy": 100, "neutral": 120, "sad": 80, "surprise": 60, "fear": 50, "disgust": 30, "angry": 40 } results = model.predict( "test_images", conf=0.35, iou=0.5, imgsz=640 ) correct = {k: 0 for k in GT} for result in results: for box in result.boxes: cls_id = int(box.cls[0]) emotion_name = model.names[cls_id] correct[emotion_name] += 1 for emotion, total in GT.items(): recall = correct[emotion] / total * 100 state = "OK" if recall >= 80 else "CHECK" print(f"[{state}] {emotion}: {recall:.1f}%")这个脚本没有做检测框匹配,只是粗略估计。如果发现某类召回率低于 80%,优先回看该类的标注质量和样本数量;如果所有类都低,检查测试集图片和训练集的数据分布是否一致,而不是怀疑模型本身。
6.2 导出 ONNX 并在 CPU 上跑实时的完整命令
导出和 CPU 推理命令需要闭环验证:
# 导出 ONNX,固定输入尺寸 640,关闭半精度,opset 保持 12 yolo export model=runs/face_emotion/s_exp/weights/best.pt \ format=onnx imgsz=640 half=False opset=12 # 用 ONNX Runtime 验证推理结果 python - <<EOF import onnxruntime as ort import cv2 import numpy as np sess = ort.InferenceSession("best.onnx", providers=["CPUExecutionProvider"]) img = cv2.imread("test.jpg") img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (640, 640)) img = img.astype(np.float32) / 255.0 img = img.transpose(2, 0, 1) # HWC -> CHW img = np.expand_dims(img, axis=0) outputs = sess.run(None, {sess.get_inputs()[0].name: img}) # outputs[0] 的形状是 [1, 4+nc, 8400],需要转置后解析 print("模型输出 shape:", outputs[0].shape) EOF导出时half=False是关键。CPU 推理场景下 FP16 的收益极低且精度损失明显,不如直接用 FP32。ONNX Runtime 的会话配置里只保留CPUExecutionProvider,避免框架自动尝试用不存在的 CUDA provider 而报错。
6.3 两个能显著提升格调的小技巧
第一个技巧是检测框置信度融合。YOLOv11 输出的置信度是「这个位置有目标」和「这个目标属于该类别」的综合分数。对于表情识别,我习惯把最终置信度拆开看:如果某框的置信度在 0.35 到 0.5 之间,但用户场景更看重召回率,可以用更低阈值接受,但把结果显示为「待确认」状态,而不是直接给个表情标签。这个设计在门店客流情绪统计场景里很实用,能过滤掉大量低质量的自动判读。
第二个技巧是固定推理线程的 CPU 亲和性。在多核 CPU 上,把 ONNX Runtime 的推理线程绑定到指定物理核心,能减少上下文切换带来的延迟抖动。配合前面的跳帧策略,可以把单路视频流的 CPU 占用从 40% 压到 15% 左右,这在多路视频并行处理的边缘设备上非常关键。
我早期做表情识别demo的时候,总是迷信大模型和复杂后处理,结果部署时发现硬件资源全被模型吃掉,连多路并行都跑不动。现在固定下来的习惯是:数据集质量放到第一位,模型规格够用就好,推理管线里做一些跳帧和滑窗,反而让整体效果稳定得多。这套 YOLOv11 + 自定义表情数据集的方案,是我目前带项目最常用的一套组合,希望帮到你。
本文还有配套的精品资源,点击获取