
简介YOLOv8 AffectNet表情识别项目代码是一套面向深度学习与计算机视觉开发者的完整实现基于YOLOv8目标检测算法在AffectNet人脸表情数据集上完成表情识别与分析任务适用于情感计算、人机交互、智慧安防等场景。压缩包内共476个文件包含230个Markdown文档、130个Python训练/推理脚本、43个YAML模型与环境配置另有预训练权重、C推理示例、Dockerfile、Jupyter Notebook等类型文件整体约23.49MB。按依赖清单安装所需库即可直接运行也可通过Dockerfile快速构建跨平台环境。目前已有162人学习访问。内容覆盖数据准备、模型训练、成果验证与二次开发全流程附带轻量级yolov8n预训练权重和多种部署文件方便研究者对照实验、复现表情识别效果并在此基础上扩展至其他目标检测任务。1. 为什么用 AffectNet 训练 YOLOv8 表情识别会先卡在数据格式上AffectNet 是学术社区最常用的人脸表情基准官方把几十万张人脸图像标注成 8 类表情同时提供人脸框。但它几乎总被当成图像分类任务使用一整张图直接送进 ResNet。换成 YOLOv8 之后任务本质变成了目标检测回归分支输出人脸框告诉系统“表情出现在画面哪个位置”分类分支同时判断这个框里是高兴还是厌恶。对视频监控、注意力分析、人机交互这类需要定位的业务这个转化比单纯分类更实用也让 AffectNet 里长期被忽略的人脸框标注真正发挥价值。这篇内容不假设你已经拿到 YOLO 格式的 AffectNet 数据包直接从官方标注出发把 JSON 转成 YOLO 的 txt 标签再讲 YOLOv8 结构中 C2f 和解耦头对 8 类表情识别的影响给出可复现的训练命令、参数表和小脸/长尾/相似表情三类落地问题的做法。适合正在用 YOLOv8 训练自己人脸数据集或者想把表情识别做成目标检测任务的工程人员。2. 先读 YOLOv8 网络结构图C2f、SPPF、解耦头与 8 类表情输出的关系2.1 从 yaml 配置拆出 Backbone 与 HeadYOLOv8 的模型定义是一份结构化的 yamlBackbone 负责逐级下采样提取特征Head 负责输出检测结果。训练前建议先把自己手里的模型结构打印出来确认版本常见做法是from ultralytics import YOLO model YOLO(yolov8m.yaml) # 只加载结构不加载权重 print(model)输出的网络中可以清楚看到三段Backbone 里是 Conv、C2f、SPPF 交替最后一个 SPPF 之后特征图缩小到输入的 1/32Neck 是 PAN-FPN它把 1/8、1/16、1/32 三个尺度的特征图反复融合Head 是解耦的 Detect 模块返回三个尺度上的边界框和类别概率。这里需要理解一件事表情识别和人脸检测不同人脸框不会占满整张图通常在 640×640 的输入里只占几十到两百像素。YOLOv8 的 P3 层1/8 下采样保留的纹理信息最完整P4、P5 层更偏语义。如果数据里有很多小脸训练日志里 P3 层的 loss 往往最先下降因为它直接负责小目标的定位与分类。2.2 C2f 的作用梯度分流与细节保持C2f 是 YOLOv8 对 YOLOv5 C3 的替代模块。热词里经常出现“yolov8模型结构中c2f”主要原因在于它改变了梯度流动方式。C2f 把输入在通道维度上拆成两个分支一个分支直接向下一层传递另一个分支经过若干个 Bottleneck 后再与前者拼接。拼接后的通道数再经过一次卷积压缩恢复到目标宽度。这种设计带来的实际收益是梯度可以从检测头沿多条路径回传到浅层。表情识别依赖的眉眼嘴角纹理属于高频细节如果 Backbone 太深、skip connection 太少浅层梯度容易被稀释模型会偏向“看到一张脸就猜 happy”而不是真正去比较嘴型和眼轮匝肌的形态。C2f 相当于给浅层特征多开了几条回传通道在 AffectNet 这种类间差异很小的任务上比单路径的 C3 更保留细节。另一个能直接调的是 Backbone 的宽度倍数同一个 C2f 模块在 yolov8n 里通道少在 yolov8m 里通道翻倍对小脸的特征容量差别明显。2.3 anchor-free 的检测头为什么更适合人脸表情YOLOv8 的 Head 是 anchor-free 的分类分支和回归分支各自独立。分类分支输出 8 个数对应 AffectNet 从 neutral 到 contempt 的 8 类概率回归分支输出到格子左上角的距离不再需要预设 9 组或 6 组 anchor 尺寸。表观特征是 AffectNet 的人脸框尺度比较集中anchor 预置的收益本来就不大anchor-free 方案还省掉了调 anchor 的环节训练配置更干净。另一个细节是 Head 里的 DFLDistribution Focal Loss回归头。它把每个回归坐标看成离散分布而不是单一值相当于让模型输出“框边界落在哪个区间”的概率分布。人脸框被遮挡或者边界不清晰时这种建模方式比直接回归 x1/y1/x2/y2 更稳也有助于在最终 decode 时得到带亚像素精度的边框。2.4 不同 YOLOv8 模型参数的取舍日常训练常用的是 n/s/m/l/x 五个尺寸深度倍数和宽度倍数决定了参数量。以常见配置为例系数大致如下模型深度倍数宽度倍数参数量约6GB 显存1660 Ti提示YOLOv8n0.330.253.2Mbatch 32 可跑适合快速验证YOLOv8s0.330.5011.2Mbatch 16 可跑速度与精度均衡YOLOv8m0.670.5025.9Mbatch 8 左右最好开 AMPYOLOv8l1.001.0043.7M6GB 跑不动用多卡或云卡YOLOv8x1.001.2568.2M需要 A 系列以上级别AffectNet 有几十万张训练图用 n 级别会欠拟合x 级别在小显存设备上训练时间过长。我一般从 m 起步在 1660 Ti 这类 6GB 卡上yolov8m 配 batch 8、imgsz 640 是能跑完 60 epoch 的再大的模型给业务侧部署只会增加延迟对表情这种实时任务不一定划算。3. AffectNet 转 YOLO 格式JSON 标注到 txt 标签的完整脚本3.1 AffectNet 官方 JSON 里有什么AffectNet 官方发布包的结构是 images 文件夹加一个大型 JSON 文件没有现成的 YOLO labels。JSON 里通常是一个data列表每个元素包含图片文件名内层data子对象里有表情标签、valence/arousal 连续维度和人脸框。标签取值 0 到 7按 neutral、happy、sad、surprise、fear、disgust、anger、contempt 顺序编码-1表示该样本未标注或不确定转换时需要跳过。人脸框的键名在不同发布版本里不完全一样常见是x、y、width、height坐标可能是像素值也可能是 0 到 1 的归一化值。YOLO 的统一格式是类别 ID 加归一化的中心点 x、中心点 y、宽、高共五行四列数据。转换前先取一张图实际量一下宽高避免把像素坐标直接当成归一化坐标写进 txt。3.2 转换脚本类别映射、坐标归一化与容错下面脚本处理官方 JSON 和图像目录输出 YOLO 格式 txt。核心逻辑是读取标签、读取人脸框、统一转成归一化中心坐标并跳过缺失和越界的样本。import json from pathlib import Path from PIL import Image src_json Path(annotations/train_set.json) img_dir Path(images) label_dir Path(labels) label_dir.mkdir(exist_okTrue) # AffectNet 8 类表情的官方编号顺序固定不能随意调整 ID2NAME [neutral, happy, sad, surprise, fear, disgust, anger, contempt] with open(src_json, encodingutf-8) as f: dataset json.load(f) for item in dataset[data]: ann item.get(data, {}) label ann.get(label, -1) if label not in range(8): # 跳过 -1 和超过范围的异常标注 continue face ann.get(face, {}) x face.get(x, 0) y face.get(y, 0) w face.get(width, 0) h face.get(height, 0) if min(x, y, w, h) 0: continue img_path img_dir / item[id] if not img_path.exists(): continue with Image.open(img_path) as im: img_w, img_h im.size # 官方某些版本直接给相对坐标x 1 时认为是像素坐标 x_rel x / img_w if x 1 else x y_rel y / img_h if y 1 else y w_rel w / img_w if w 1 else w h_rel h / img_h if h 1 else h # 转成 YOLO 要求的中心点格式并做边界保护 cx min(max(x_rel w_rel / 2, 0.0), 1.0) cy min(max(y_rel h_rel / 2, 0.0), 1.0) cw min(max(w_rel, 0.0), 1.0) ch min(max(h_rel, 0.0), 1.0) out_txt label_dir / (img_path.stem .txt) with open(out_txt, a, encodingutf-8) as fout: fout.write(f{label} {cx:.6f} {cy:.6f} {cw:.6f} {ch:.6f}\n) print(fdone, labels saved to {label_dir})脚本里最值得注意的容错点是坐标阈值判断x 1时认为是像素坐标并除以图宽否则保持原值。实际转换时还可能遇到人脸框出现在图像角落、裁剪后宽高为负的情况边界保护会把这些值 clamp 到 0 到 1 之间。再检查几个生成的 txt比如下面这行表示类别 1happy框中心在 x0.512、y0.489宽高约占整图的 24% 和 33%1 0.512345 0.489012 0.240000 0.3300003.3 划分 train/val 并整理目录PyCharm 里配置 YOLOv8 环境转换完标签后按标准目录放置Ultralytics 会默认在images目录旁找同名labels目录训练时不需要额外指定标签路径。AffectNet-YOLO/ ├── data.yaml ├── images/ │ ├── train/ │ └── val/ └── labels/ ├── train/ └── val/划分时直接按官方 train/val 两份 JSON 分别执行转换脚本即可不要随机混洗后重新划分否则验证集分布和官方基准对不上。环境上只需在 PyCharm 新建项目后执行pip install ultralytics再装一个 PyTorch 版本确认torch.cuda.is_available()输出 True。CLI 训练和 Python 脚本打断点调试各有用途数据集刚转换完、第一次训练时我更建议在脚本里打断点检查单条数据的 label 和 box 是否合理。这个阶段最容易踩的坑是类别编号错位。AffectNet 的 7 号类别是 contempt不是 anger如果按自己习惯把顺序改了训练出来的模型在部署阶段会全部错乱。先跑一遍正文里的类别映射表再用yolo predict对单个样本输出names验证比事后翻训练集更省时间。4. 用 YOLOv8m 训练 AffectNetdata.yaml、关键超参与损失曲线4.1 最小训练脚本与 data.yaml 写法data.yaml只需要指定数据路径、类别数和固定顺序的类名# AffectNet-YOLO/data.yaml path: ./AffectNet-YOLO train: images/train val: images/val nc: 8 names: [neutral, happy, sad, surprise, fear, disgust, anger, contempt]训练入口用 Python 脚本比直接敲yolo detect train更适合调试因为可以逐参数看显存占用和日志输出from ultralytics import YOLO model YOLO(yolov8m.pt) # 加载 COCO 预训练权重迁移到表情任务 results model.train( dataAffectNet-YOLO/data.yaml, epochs60, imgsz640, batch16, optimizerSGD, lr00.01, lrf0.01, weight_decay0.0005, warmup_epochs3, close_mosaic10, projectruns/affect, nameyolov8m, )这里使用 COCO 预训练权重做迁移学习而不是从yolov8m.yaml随机初始化原因是 YOLOv8 在 COCO 上已经学会了通用的边缘、纹理和人脸区域响应微调 AffNet 只需较低 epoch 数即可达到好的结果。SGD 配lr00.01是目标检测任务比较稳的组合如果追求更快试验速度可以换 AdamW 并把学习率降到 1e-3。close_mosaic10的含义是最后 10 个 epoch 自动关闭 mosaic 增强让模型在接近真实分布的输入上收尾这个参数对表情任务尤其重要因为 mosaic 拼图会把一张脸切得四分五裂。4.2 面向表情识别的关键训练参数参数建议值作用imgsz640 起步小脸多就上 960决定小尺寸人脸框在特征图上的有效像素batch6GB 显存用 8显存够就 16 或 32影响 BN 统计量和收敛稳定性optimizerSGD 或 AdamWSGD 泛化好AdamW 收敛快但易过拟合cls_pw按类别频率自动计算后可手调给 fear、disgust、contempt 更高损失权重label_smoothing0.1缓解相似表情标注噪声带来的过自信close_mosaic10~15避免 end of training 阶段被 mosaic 破坏的脸干扰cls_pw是调节 8 类表情不均衡最直接的入口。AffectNet 中 neutral 和 happy 样本数量远多于 fear、disgust、contemptYOLOv8 的 BCE 分类损失会天然偏向多数类。常见做法是统计每个类别在 labels 里的出现次数按频率倒数的平方根设定逐类权重from collections import Counter counter Counter() for txt in label_dir.glob(*.txt): for line in txt.read_text().splitlines(): counter[int(line.split()[0])] 1 total sum(counter.values()) cls_pw [(total / max(counter[i], 1)) ** 0.5 for i in range(8)] print(cls_pw)取平方根而不是直接用倒数是为了避免稀有类权重放大到几十倍后把分类损失带偏。算出来之后直接在model.train里传cls_pwcls_pw。在 GTX 1660 Ti 这类 6GB 卡上yolov8m 配imgsz640时 batch 8 较稳妥显存不够就把 batch 降到 4 或换用 yolov8s.pt不要同时开 imgsz 960 和 batch 32那样很容易 OOM。4.3 读 results.csv画损失函数曲线判断过拟合与长尾训练结束后Ultralytics 会在runs/affect/yolov8m/下生成results.csv每一行是一个 epoch 的 loss 和指标。用下面的脚本画成两张图比盯终端日志直观得多import pandas as pd import matplotlib.pyplot as plt df pd.read_csv(runs/affect/yolov8m/results.csv) df.columns [c.strip() for c in df.columns] x df.iloc[:, 0] # 第一列是 epoch 序号 fig, axes plt.subplots(1, 2, figsize(12, 4)) axes[0].plot(x, df[train/cls_loss], labeltrain/cls_loss) axes[0].plot(x, df[val/cls_loss], labelval/cls_loss) axes[0].set_title(cls loss) axes[0].legend() axes[1].plot(x, df[metrics/mAP50(B)], labelmAP50) axes[1].plot(x, df[metrics/mAP50-95(B)], labelmAP50-95) axes[1].set_title(mAP) axes[1].legend() plt.tight_layout() plt.savefig(affect_curves.png)曲线判断规则很直接train/cls_loss持续下降但val/cls_loss在 30 个 epoch 后反弹说明过拟合把weight_decay从 0.0005 调到 0.001 再跑一次val/cls_loss从第 10 个 epoch 就开始来回抖动通常是长尾类权重不够或学习率过大优先调cls_pw而不是盲目降lr0。关注metrics/mAP50(B)的同时要看metrics/mAP50-95(B)后者对框的精度更严格如果两者差距大说明人脸框回归质量差应检查 face box 的标注噪声。5. 推理脚本与置信度调优平衡 Neutral 误报和人脸召回5.1 最小推理脚本conf、iou、max_det 的工程设定训练完的best.pt可以直接用于图像和视频推理。下面是带工程参数的脚本适合第一次部署时对照调试from ultralytics import YOLO model YOLO(runs/affect/yolov8m/weights/best.pt) results model.predict( sourcedemo.mp4, imgsz640, conf0.35, # 过滤低置信度框 iou0.45, # NMS 的 IoU 阈值 max_det2, # 单帧最多保留两个框 streamTrue, # 视频流式推理避免内存暴涨 device0, ) for r in results: boxes r.boxes.xyxy.cpu().numpy() clses r.boxes.cls.cpu().numpy().astype(int) confs r.boxes.conf.cpu().numpy() for box, cls_id, conf in zip(boxes, clses, confs): print(r.names[cls_id], conf, box.tolist())streamTrue会让长视频推理按帧迭代而不是一次性全部加载处理摄像头输入时必须开启。max_det2的语义是防止背景里的噪声区块被当成表情框这个值在单人脸的 AffNet 场景里设置成 1 或 2 都合理。iou0.45控制 NMS 的合并程度人脸框重叠不多用默认值就可以不需要像密集目标检测那样调小。5.2 AffectNet 的类别分布如何影响 conf 阈值AffectNet 里 neutral 和 happy 两类占比很高模型在模糊样本上更容易把它们识别为 happy实际部署时常表现为误报率偏高。通用目标检测喜欢把conf设成 0.25但在 AffectNet 任务上我一般从 0.35 起步如果业务对误报容忍度低直接提到 0.45 并配合时间序列平滑。调阈值的方法是挑一个包含各类表情的验证集按不同 conf 统计 top-1 准确率而不是只看 mAPfor conf in [0.25, 0.35, 0.45, 0.55]: hits 0 total 0 for img_path, true_label in val_pairs: res model.predict(img_path, confconf, verboseFalse)[0] if len(res.boxes) 0: continue pred int(res.boxes.cls[0].item()) total 1 hits int(pred true_label) print(conf, hits / max(total, 1))这里的顺序是先调conf再调 NMS因为表情任务误报大多来自低置信度的情绪类别而不是框重叠导致的重复检测。如果单帧没有检测到框不要在业务上强行输出一个硬编码类别而应该保留“无人脸”状态交给后端决定是补一帧还是降低阈值重试。另外人脸表情本身有连续性对视频流做边框平滑能明显减少跳变常见做法是对相邻帧的框坐标做一阶指数平滑def smooth_box(current, last, alpha0.6): return last * (1 - alpha) current * alpha5.3 检测框 独立分类头的两级识别方案YOLOv8 的分类头输出 8 维概率本质已经是一个表情分类器。但当检测框只框住半边脸或包含背景时回归框的误差会直接影响分类结果。更常见的工程方案是两级识别YOLOv8 只负责稳定的人脸框定位再把裁剪出来的人脸送给一个独立的小分类网络两者可以分别训练和调优。import torch from torchvision import transforms from PIL import Image # 假设 classifier 是已经训练好的表情分类模型 crop img[y1:y2, x1:x2] # 来自 YOLOv8 的检测框可外扩 1.2 倍 t transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) x t(Image.fromarray(crop)).unsqueeze(0).to(device) with torch.inference_mode(): logits classifier(x) cls_id logits.argmax(dim1).item() prob torch.softmax(logits, dim1).max().item()两级方案的成本是多一次分类前向但对不均衡类别的控制更灵活检测模型专注于“人脸在哪”分类模型可以单独用类别权重和平衡采样去学习 8 类表情。这套结构下 YOLOv8 仍然承担目标检测的职责情绪细分交给专用分类器两者解耦后替换任何一侧都比改单模型更容易。6. 小脸召回与相似表情的三组实用技巧6.1 小目标识别imgsz 提到 960mosaic 要关得早AffectNet 中大量人脸框只占图像的 10% 到 20%在 640 输入下映射到特征图只有十几个像素P3 层也难以保留足够纹理。遇到验证集 mAP50 高但 mAP50-95 低的情况第一反应是把 imgsz 提到 960而不是改网络结构。显存 6GB 时训练用小 batch 配合 AMP 即可。同时把close_mosaic从 10 调到 15让小脸在最后 15 个 epoch 里不受拼接裁剪影响否则 P3 层会学到“断脸”的错误特征。6.2 用类级损失与标签平滑压住相似表情混淆fear、disgust、contempt 这三类在视觉上高度相似且标注本身主观性强。label_smoothing0.1能把分类目标的 one-hot 变成软标签让模型不过分自信换取更好的泛化边界。如果cls_pw调大后 mAP50 提升但推理时的 ECE 校准变差可以检查每个类别在验证集上的实际召回重点看 contempt 是否仍然被大量分到 sadness。这一步建议先不动 Head 结构数据层面和损失层面的调整能覆盖绝大多数业务场景。6.3 深度负性类别的层级识别方案对长尾最实用的做法是层级化把 8 类先压成 3 个大类即中性、正面、负面用大类的检测器保证召回再对负面类内部单独训练一个轻量分类器区分 fear、disgust、anger、contempt。因为负面类样本在 AffNet 中数量仍足够训练一个二分类或四分类小网络但直接让 YOLOv8 同时在 8 类上学习时梯度会被 neutral 和 happy 主导。部署时把两个模型的输出联起来就能在稀有类上拿到比单模型高不少的效果。最后记得在导出 ONNX 时固定 names 顺序用model.names把索引映射写入输出消息避免部署端把 6 号 anger 和 7 号 contempt 搞混。本文还有配套的精品资源点击获取