ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

YOLOv11自定义模型实现人脸检测与表情识别

YOLOv11自定义模型实现人脸检测与表情识别 简介面向深度学习与计算机视觉开发者这份资源围绕 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 是 80COCO 数据集改动有三个地方任何一个不一致都会导致训练或推理报错。第一个是模型配置文件里的nc。将nc: 7按 7 类表情算写入模型 yaml框架会据此重新初始化检测头的分类分支回归分支不受影响。第二是数据集配置文件里的names列表它负责把训练时的类别 id 映射到可读标签上顺序必须和标注文件里的 class_id 完全对应。第三是 anchor 的隐式调整。YOLOv11 用的是动态 anchor 分配策略框架会在训练前根据你提供的数据集自动重新聚类 anchor 尺寸不需要手动设置三个尺度的长宽比但前提是标注框的质量足够好存在大量错误标注时聚类出来的 anchor 会整体偏移拖慢收敛速度。这里强调一个容易翻车的认知很多人以为自定义 YOLO 模型需要改网络结构代码。实际上除非你有强力的理论依据否则不要动网络主体。YOLOv11 的特征提取能力远超人脸表情任务的需求真正决定效果的是数据质量、标注一致性和训练参数。我见过 A 同学花了一周魔改 backbone最后精度反而不如原版微调。2.3 标注数据的两种常见做法与推荐选择无论是造数据还是找公开数据集标注策略决定模型上限。第一种做法是人脸框标注 表情类别。每张人脸框的 class_id 直接对应表情类别比如 0angry、1disgust、2fear、3happy、4neutral、5sad、6surprise。模型看到的任务就是「在图中找到这些类别的目标」检测和分类一步到位。第二种做法是人脸单类 独立表情分类器标注只框人脸表情标签单独建一个分类数据集。这种做法兼容现成的检测权重但违背了我们选 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), encodingutf-8) return True # 批量转换 train 和 val 的 XML for split in [train, val]: xml_dir Path(fannotations/{split}) out_dir Path(flabels/{split}) out_dir.mkdir(parentsTrue, exist_okTrue) 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 \ modelyolov11s.pt \ datadata.yaml \ epochs150 \ imgsz640 \ batch16 \ lr00.01 \ patience20 \ device0 \ projectruns/face_emotion \ names_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下降慢是小目标回归难常见对策是提高imgszcls_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, conf0.35, # 置信度阈值低于此值的框直接丢弃 iou0.5, # NMS 的 IoU 阈值重叠超过此值的框会被合并 imgsz640, device0 ) 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) # 保存可视化结果这段代码里conf0.35是落地时最需要调的参数。表情识别场景中误检一张脸比漏检一张脸的代价更大——给一个路人标记「happy」会影响整段视频的统计结果。我推荐在摄像头远距离场景把conf从默认的 0.25 提到 0.4 左右如果视频源是近景特写0.3 就可以了。iou0.5保持不变这是 NMS 的标准阈值。4.2 摄像头实时场景的三个关键参数调优实时视频流和离线图片推理是两套完全不同的调优逻辑。离线推理追求召回率实时系统追求稳定性和低延迟。用摄像头跑实时识别时我会优先调整三个参数。首先是输入尺寸。imgsz640是检测精度和速度的平衡点但如果部署机器是 CPU降到imgsz416能换来接近一倍的 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被设置为默认值输入分辨率下降导致所有指标跟着降。另一种情况是导出时框架默认开启了半精度 FP16CPU 上跑 FP16 模型时部分算子被截断精度自然掉。解决先检查推理代码里imgsz、conf、iou是否和验证脚本保持一致。排除超参数问题后再用opset12且关闭半精度重新导出。如果精度仍不达标继续对比原始 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, conf0.35, iou0.5, imgsz640 ) 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 modelruns/face_emotion/s_exp/weights/best.pt \ formatonnx imgsz640 halfFalse opset12 # 用 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, axis0) outputs sess.run(None, {sess.get_inputs()[0].name: img}) # outputs[0] 的形状是 [1, 4nc, 8400]需要转置后解析 print(模型输出 shape:, outputs[0].shape) EOF导出时halfFalse是关键。CPU 推理场景下 FP16 的收益极低且精度损失明显不如直接用 FP32。ONNX Runtime 的会话配置里只保留CPUExecutionProvider避免框架自动尝试用不存在的 CUDA provider 而报错。6.3 两个能显著提升格调的小技巧第一个技巧是检测框置信度融合。YOLOv11 输出的置信度是「这个位置有目标」和「这个目标属于该类别」的综合分数。对于表情识别我习惯把最终置信度拆开看如果某框的置信度在 0.35 到 0.5 之间但用户场景更看重召回率可以用更低阈值接受但把结果显示为「待确认」状态而不是直接给个表情标签。这个设计在门店客流情绪统计场景里很实用能过滤掉大量低质量的自动判读。第二个技巧是固定推理线程的 CPU 亲和性。在多核 CPU 上把 ONNX Runtime 的推理线程绑定到指定物理核心能减少上下文切换带来的延迟抖动。配合前面的跳帧策略可以把单路视频流的 CPU 占用从 40% 压到 15% 左右这在多路视频并行处理的边缘设备上非常关键。我早期做表情识别demo的时候总是迷信大模型和复杂后处理结果部署时发现硬件资源全被模型吃掉连多路并行都跑不动。现在固定下来的习惯是数据集质量放到第一位模型规格够用就好推理管线里做一些跳帧和滑窗反而让整体效果稳定得多。这套 YOLOv11 自定义表情数据集的方案是我目前带项目最常用的一套组合希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进