☰
Python人脸情感特征提取:OpenCV检测+ResNet18微调实战
2026/10/4 1:01:41 网站建设 项目流程

简介:这是一份基于几何法的人脸情感特征提取Python实现,面向计算机视觉初学者、表情识别研究人员以及需要快速上手人脸特征提取的开发者。资源围绕面部器官特征点距离与曲线曲率计算展开,采用静态图像局部区域标记策略,适合用于表情识别、人机交互等场景。压缩包共3个文件,包含一个py主程序、一个md运行说明与一个txt依赖清单,整体仅3KB,轻量易用。其中README.md详细介绍了代码运行环境与操作步骤,requirements.txt列出所需依赖。目前已有8487人学习下载。通过这份资源,读者可以了解几何法特征提取的完整流程,掌握基于人脸关键点距离和曲率构建情感特征向量的方法,并可直接运行代码验证效果,为进一步研究动态表情识别或融合光流法、模型法提供基础。

1. 人脸情感特征提取:先想清楚你要的是“概率”还是“特征”

很多人第一次接到“python实现人脸情感特征提取”这个需求,第一反应是装一个人脸识别库,把最后的分类结果打出来当特征。实际落到产品里会发现,单纯给一个“开心/难过”的标签远远不够:交互产品要的是情绪强度、连续帧之间的表情变化轨迹,甚至不同情绪的相似关系。这篇文章按可落地的方案来拆:OpenCV DNN 做检测和对齐,PyTorch 微调 ResNet18,用 FER2013 作为训练数据,最后取出分类层之前的 512 维向量作为情感特征。适合正在做人机交互、表情画像、情感计算课题、需要先跑通一套 baseline 的工程师和学生;不适合只想在 PPT 上讲原理、不准备动手跑代码的场景。

2. 技术选型:检测、对齐、特征三件事分开,别指望一个模型全包

2.1 三条路线对比:手工特征、分类模型、特征嵌入

人脸情感特征提取在工程上有三条常见路线。第一条是传统手工特征:HOG、LBP、Gabor 这类纹理特征加 SVM 分类。优点是不需要显卡、解释性强、在小样本上也能出活;缺点是泛化能力弱,光照一变就要重调参数,而且不同人的面部结构差异会直接污染特征。第二条是端到端分类模型,常见做法是在 ResNet 上微调 FER2013 或 AffectNet 这类数据集,输出 softmax 概率作为最终标签。这条路最可靠,也是很多产品的第一版做法。第三条是特征嵌入路线:把模型当特征提取器,取分类层之前的向量作为情感特征,再在这个特征空间上做阈值判断、相似度计算或聚类。

下面按第三条路线讲,因为它兼顾了分类的可用性和特征的灵活性。先看三条路线的取舍,方便你对号入座:

路线输出优点缺点适用场景
手工特征 + HOG/SVM硬标签轻量、无需 GPU光照鲁棒性差嵌入式小样本 demo
分类模型微调概率端到端、准确率可控特征不可复用只需要最终标签
特征嵌入512 维向量可算相似度/强度需要设计下游阈值交互产品、画像、检索

如果只是演示人脸表情识别,第二条路线最快;但如果要做情绪强度曲线、表情检索、情感聚类,或者要接一个说话人情绪看板,特征嵌入才是能复用资产的路子。我一般建议手头没有明确业务约束时,直接按特征嵌入做,因为概率输出可以从特征向量后面再加一个分类头得到,两条路不冲突。

2.2 环境安装:先解决 python 层面的老生常谈

这套流程在环境上真正容易踩的坑,并不是模型本身,而是最基础的 python 安装、vscode 配置 python 开发环境这类问题。本地开发我建议直接用 conda 建隔离环境,不然 torch 和 opencv 的依赖冲突会让人折腾一晚上。下面是完整安装命令:

conda create -n emotion python=3.10 -y conda activate emotion pip install opencv-python torch torchvision numpy scikit-learn matplotlib

torch 的安装要看本机有没有 CUDA,CPU 版也能跑通全流程,只是训练要慢一些,实测中小规模微调 CPU 也能接受。opencv-python 装完以后,代码里import cv2报模块找不到,基本都是当前解释器没切到这个 conda 环境,在 vscode 里重新选择解释器即可。python 安装 cv2 失败的话,先看 pip 版本是否太旧,升级 pip 再装。numpy 这类基础库如果出现编译错误,多半是 pip 试图源码编译,升级 pip 后它会默认拿预编译包,问题自动消失。

2.3 最小推理脚本:先动起来,再谈准确率

我不建议一上来就跑训练,先准备一个训练好的 checkpoint,把推理链路跑通,后面调数据、调参数时才有对照。最简推理代码如下:

import cv2 import torch from torchvision import models, transforms model = models.resnet18(weights=None) model.fc = torch.nn.Linear(model.fc.in_features, 7) state = torch.load("emotion_resnet18.pth", map_location="cpu") model.load_state_dict(state) model.eval() tf = transforms.Compose([ transforms.ToPILImage(), transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) img = cv2.imread("demo.jpg") img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) tensor = tf(img_rgb).unsqueeze(0) with torch.no_grad(): logits = model(tensor) prob = torch.softmax(logits, dim=1)[0] labels = ["angry", "disgust", "fear", "happy", "sad", "surprise", "neutral"] print(labels[prob.argmax().item()], prob.max().item())

先把输入规范到 RGB,再用 transforms 做尺寸归一化,这一步容易漏,漏了之后单张图像测试结果不会太差,但一到真实场景就暴露问题。这里的 checkpoint 结构必须和你定义的模型结构一致,最后一层 fc 的输出是 7。我先不调阈值,阈值放在验证阶段再定,过早调阈值容易被当前测试图片带偏,因为单张图的置信度分布不能代表整个数据集。

3. 数据处理:人脸检测、对齐与归一化,这步决定情感特征质量的一半

3.1 用 OpenCV DNN 做人脸检测:Haar 只适合演示

检测器这一环,网上很多代码还在用 Haar 级联分类器,但它对侧脸、暗光、眼镜的漏检率很高,放到真实视频流里框子还会跳。我一般用 OpenCV 自带的 DNN 人脸检测模块,它用 caffe 模型格式提供,输入 300×300,输出检测框和置信度。常见做法是准备 deploy.prototxt 和 res10_300x300_ssd_iter_140000_fp16.caffemodel 两个文件,代码里指定路径加载:

import cv2 import numpy as np net = cv2.dnn.readNetFromCaffe("deploy.prototxt", "res10_300x300_ssd_iter_140000_fp16.caffemodel") def detect_faces(net, img, conf_thresh=0.7): h, w = img.shape[:2] blob = cv2.dnn.blobFromImage( img, scalefactor=1.0 / 127.5, size=(300, 300), mean=(127.5, 127.5, 127.5), swapRB=False ) net.setInput(blob) dets = net.forward() faces = [] for i in range(dets.shape[2]): conf = dets[0, 0, i, 2] if conf < conf_thresh: continue box = dets[0, 0, i, 3:7] * np.array([w, h, w, h]) x1, y1, x2, y2 = box.astype(int) x1, y1 = max(0, x1), max(0, y1) x2, y2 = min(w - 1, x2), min(h - 1, y2) faces.append((x1, y1, x2, y2)) return faces

blobFromImage的参数不能随手改:这个检测模型训练时输入做了(像素/127.5 - 1.0)的缩放,所以 scalefactor 和 mean 必须按它的原始约定填,填错的话置信度和框的位置都会偏移。swapRB=False是因为我们直接把 BGR 图喂了进去。检测环节不需要转 RGB,但送入情感模型之前必须转,这一点不少人翻过车。

置信度阈值我一般取 0.7,经验值。0.5 会框很多背景,0.8 以上又会漏掉低头和侧脸。另一个技巧是裁剪时把框向外扩 10%~15%,给额头和下巴留空间,情感模型的效果会明显好于紧贴脸框。这个扩框操作极其简单,但对后期准确率影响非常大,建议一开始就做进检测函数里。

3.2 人脸对齐:两眼连线水平是基本盘

人脸对齐是很多人跳过的一步,跳过之后训练出来的模型对少量旋转还能凑合,到真实场景就出现头稍微一歪、情感就变了的问题。对齐的目标很直接:把两只眼睛的连线拉成水平,并且把脸放到固定位置。常见操作是先通过关键点检测器拿左右眼坐标,我这里为了不引入额外模型,先基于检测框写一个可用的对齐版本,后续接关键点模型时方法同理。

import math def align_face(img, left_eye, right_eye, out_size=224): dx = right_eye[0] - left_eye[0] dy = right_eye[1] - left_eye[1] angle = math.degrees(math.atan2(dy, dx)) center = ((left_eye[0] + right_eye[0]) / 2, (left_eye[1] + right_eye[1]) / 2) eye_dist = math.hypot(dx, dy) scale = (out_size * 0.45) / max(eye_dist, 1e-6) M = cv2.getRotationMatrix2D(center, angle, scale) eye_center_new = M.dot(np.array([center[0], center[1], 1.0])) target = (out_size / 2, out_size * 0.4) M[0, 2] += target[0] - eye_center_new[0] M[1, 2] += target[1] - eye_center_new[1] return cv2.warpAffine(img, M, (out_size, out_size), flags=cv2.INTER_LINEAR)

这段代码的逻辑分三步:第一步,求两只眼睛连线相对于水平轴的旋转角;第二步,以眼睛中点为旋转中心,按这个角度旋转、按眼间距缩放;第三步,把旋转后眼睛中心搬到目标位置,水平居中、垂直在 0.4 倍高度处。最后一步如果不做,旋转后脸会跑出画面。scale 里的0.45意思是两只眼睛的像素距离约占输出宽度的 45%,太小脸太小,太大下巴容易被截掉。这个值可以按数据集微调,0.4~0.5 之间对情感识别最合适。

对齐的目标位置也值得说道:眼睛放在 0.4 倍的垂直高度,是为了给下半张脸留空间。情感特征主要来自嘴巴、脸颊、眉毛,这些区域如果被挤出画面,后面模型再强也没用。实际操作中,左眼和右眼坐标来自关键点模型,我没有在代码里写死关键点模型,你可以接 OpenCV 自带的 LBF 模型或者任意 68 点检测器,把左右眼坐标喂进这个函数即可。

3.3 归一化与数据增强:别在 ResNet 的输入上乱来

对齐得到 224×224 的图以后,下一步是归一化。ResNet 系列在 ImageNet 上的预训练分布是 RGB、mean=[0.485, 0.456, 0.406]、std=[0.229, 0.224, 0.225],如果要微调而不是洗掉预训练权重,就按这个标准来。FER2013 原始数据是灰度图,很多人直接把它当成单通道训练,推理时又把单通道复制成三通道,效果会打折扣。正确做法是Image.open(...).convert("L").convert("RGB"),让灰度值均匀分布到三个通道。

数据增强方面,FER2013 本身样本量不大,绕不开 RandomResizedCrop、RandomHorizontalFlip 和轻微 ColorJitter。我常用的训练增强如下:

from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize(256), transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])

RandomResizedCrop 的 scale 不要低于 0.8,因为表情靠的是局部肌肉纹理,裁剪太狠会直接切掉嘴巴或眉毛。HorizontalFlip 对面部是对称的,但有一类样本要小心:训练集里如果包含带手势、带文字的图片,翻转会把语义搞乱,发现这类情况建议关闭翻转或者先清洗数据。ColorJitter 强度控制在 0.2 左右足够,太强会把肤色信息带偏,模型最后学成“皮肤暗等于难过”,这属于典型的数据偏置,不是模型问题。

4. 训练与特征提取:微调 ResNet18,特征在分类层之前那一层

4.1 Dataset 实现:FER2013 目录结构与读取

训练的第一步是把数据整理成模型能读的结构。FER2013 常见的组织方式是把 7 类情感分别建目录,每个目录下放对应图片。结构约定如下,关键是类和标签一一对应,训练和推理必须共用这个映射,否则后面输出标签全错。

from torch.utils.data import Dataset from PIL import Image import os class EmotionFolder(Dataset): def __init__(self, root, transform=None): self.transform = transform self.samples = [] self.classes = sorted(os.listdir(root)) self.class2idx = {c: i for i, c in enumerate(self.classes)} for cls in self.classes: cls_dir = os.path.join(root, cls) for fname in os.listdir(cls_dir): if fname.lower().endswith((".jpg", ".png", ".jpeg")): self.samples.append((os.path.join(cls_dir, fname), self.class2idx[cls])) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] img = Image.open(path).convert("L").convert("RGB") if self.transform: img = self.transform(img) return img, label

类名和标签的顺序是固定的,训练和推理时必须保持一致。常见的错误是数据目录里混入了隐藏文件或缓存缩略图,os.listdir 读出一些奇怪的类,导致 class2idx 错位。我这里已经过滤了扩展名,但还是要提醒你确认目录里没有残留的子目录或非图像文件,这种错误很隐蔽,往往训练完成之后才发现标签全乱了。

4.2 微调参数:冻结到 layer1,学习率 1e-4 起步

ResNet18 在图像分类任务上的特征提取能力足够,情感识别这种粒度相对粗的任务用不上 ResNet50。微调时我不会从零训练整个网络,而是加载 ImageNet 预训练权重,把最后一层换成 7 分类,然后冻结前面部分层。常见冻结策略是保留 conv1、bn1、layer1 不参与训练,只训练 layer2 之后和新的 fc 层,这样既保留了通用纹理特征,又允许高层特征向情感域调整。

import torch.nn as nn import torch.optim as optim from torchvision import models model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) in_features = model.fc.in_features model.fc = nn.Linear(in_features, 7) for name, param in model.named_parameters(): if name.startswith(("conv1", "bn1", "layer1")): param.requires_grad = False criterion = nn.CrossEntropyLoss() optimizer = optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lr=1e-4, weight_decay=1e-4 )

lr 为什么取 1e-4:加载预训练后,底层特征已经比较成熟,学习率太大会在几个 step 内把预训练知识洗掉,太小则新加的 fc 层学不出来。如果你同时训练新 fc 和微调老层,可以考虑对 fc 单独给一个稍大的学习率,常见做法是按参数组设置。类别不均衡是 FER2013 的另一个问题,disgust 样本远少于 happy,直接用 CrossEntropyLoss 会让模型把所有难样本都推到 neutral 上,这个问题我在避坑章节再展开。

训练主循环比较固定,20 个 epoch 左右足够。每个 epoch 结束后在验证集上算一次准确率,只保留验证准确率最高的 checkpoint。早期我犯过“只存最后一个 epoch 模型”的错误,结果最后一轮过拟合,验证集掉了三个多点,翻车翻得很典型。下面是完整训练主循环:

from torch.utils.data import DataLoader train_loader = DataLoader(train_ds, batch_size=64, shuffle=True, num_workers=4) val_loader = DataLoader(val_ds, batch_size=64, shuffle=False, num_workers=4) best_acc = 0.0 for epoch in range(20): model.train() for x, y in train_loader: out = model(x) loss = criterion(out, y) optimizer.zero_grad() loss.backward() optimizer.step() model.eval() correct = total = 0 with torch.no_grad(): for x, y in val_loader: pred = model(x).argmax(dim=1) correct += (pred == y).sum().item() total += y.size(0) acc = correct / total print(f"epoch {epoch + 1}, acc={acc:.4f}") if acc > best_acc: best_acc = acc torch.save(model.state_dict(), "emotion_resnet18.pth")

batch_size 和 num_workers 是配套的。在小数据集上 batch 64 够用,显存不够就降到 32;num_workers 超过 CPU 核心数反而会拖慢。验证集准确率看起来不错的时候,建议按情感类别分别打印召回率,而不是只看整体数字,因为大类 happy 会把整体指标拉高,disgust 可能已经彻底躺平。

4.3 提取特征向量:删除分类头,拿到 512 维输出

如果你按特征嵌入路线走,最后这个 fc 层反而不该参与推理。正确做法是把 fc 换成恒等映射,让网络输出 512 维张量;这 512 维向量就是模型对表情肌肉状态的编码,同一情感的特征在余弦距离上接近,不同情感分开。加载方式如下:

import torch from torchvision import models backbone = models.resnet18(weights=None) backbone.fc = torch.nn.Identity() state = torch.load("emotion_resnet18.pth", map_location="cpu") backbone.load_state_dict(state, strict=False) backbone.eval()

提示:特征提取必须让模型处于 eval() 模式,并用 torch.no_grad() 包裹,否则 BatchNorm 的 running 统计量会被更新,同一张图会得到不同的输出。

strict=False是因为保存的 checkpoint 里包含 fc 层权重,而当前模型没有这个层,PyTorch 默认会报缺键。更干净的做法是加载前把fc.weight和fc.bias两个键删掉,再 strict 加载。实时场景如果追求速度,可以把这个 backbone 导出成 ONNX,配合 onnxruntime 跑 CPU,比 PyTorch 的 eager 模式快不少,后面验证阶段我会再提到。

5. 避坑:这 5 个坑我全踩过,写出来你直接绕开

5.1 BGR 与 RGB 混用导致结果乱跳

现象:同一张图,在训练脚本里预测是 happy,在推理脚本里变成了 sad,置信度还不高,来回跳。

原因:OpenCV 的cv2.imread读出来是 BGR,而 ResNet 在 ImageNet 上预训练时用的是 RGB。直接把 BGR 数组转成 Tensor,通道语义完全反了。检测环节对通道不敏感,但情感特征对颜色通道敏感。

解决:在送入 transforms 之前统一加一行cv2.cvtColor(img, cv2.COLOR_BGR2RGB)。也有同学选择把swapRB=True写在blobFromImage里,让检测环节一并处理,但检测和特征提取是两套数据流,别指望一个开关包办两件事。代码里专门抽一个load_image(path)函数,保证所有输入的通道顺序一致,是最稳的做法。

5.2 视频流里情感标签来回切

现象:单张图片测试效果不错,放到视频流里连续 30 帧,情感标签在 happy 和 neutral 之间来回跳,没法直接用。

原因:检测框本身存在抖动,框的位置变化导致裁剪区域里的眉毛、嘴巴相对位置跟着变,模型输出自然跟着跳。另外每帧独立推理没有时序信息,单帧噪声无法反映真实情绪趋势。

解决:第一,后处理加指数滑动平均,对 softmax 概率或者特征向量做时间维平滑;第二,特征层做平滑比概率层做平滑更不容易被单帧噪声带偏,因为概率已经过 softmax 非线性压抑;第三,检测框做轻量 IoU 匹配加跟踪,避免每帧重新检测引入框抖动。简单做法如下:

momentum = 0.7 smooth_feat = None for frame_feat in frame_features: if smooth_feat is None: smooth_feat = frame_feat else: smooth_feat = momentum * smooth_feat + (1 - momentum) * frame_feat # 用 smooth_feat 做相似度判断或后续分类

momentum 取 0.7 时,当前帧只占 30% 权重,在 30fps 视频里大约等效于 3~5 帧的上下文窗口。取太大,表情已经变了但输出还停在旧标签上,延迟肉眼可见;取太小,平滑起不到作用。这个参数需要结合你的目标帧率和可接受延迟来调,没有绝对标准。

5.3 CPU 推理慢到没法接实时

现象:在 CPU 机器上跑完整流程,一帧要 200ms 以上,视频画面明显卡顿。

原因:很多人把检测、对齐、模型推理全部串行,而且模型输入是 224×224,三段流程之间反复拷贝数据。OpenCV DNN 检测本身不慢,慢在 ResNet 的 eager 推理和前处理的数据搬运。

解决:第一,导出 ONNX 后用 onnxruntime 跑 CPU,推理时间能下降一半以上;第二,blob 创建和预处理放循环外复用,避免每帧重新分配内存;第三,适当降低输入尺寸,对齐后缩到 160×160,对情感特征影响不大,速度提升明显;第四,检测可以跳帧,比如每 5 帧做一次人脸检测,中间帧复用上一帧的人脸框,代价很小。实际项目里 CPU 推理的核心思路是“减少重复计算”,不是换更贵的机器。

5.4 非正面脸几乎全判成 neutral

现象:测试集正脸效果不错,一到低头、侧脸、戴眼镜的场景,全部输出 neutral,阈值怎么调都没用。

原因:FER2013 里绝大多数是正面或微偏的头部姿态,模型根本没学到侧脸也有表情;同时你把对齐做成硬旋转,侧脸旋转后眼睛位置不准,反而制造了更多畸变样本。

解决:第一,训练增强里加随机旋转,范围控制在 ±10 度,超过会生成大量畸形人脸;第二,跑数据清洗,把侧脸过大的训练样本剔除或者补充;第三,工程侧加姿态估计,侧脸超过 30 度时直接返回低置信度,不要硬出情感标签。这里是个产品思路问题:承认不知道比瞎猜一个更可靠,尤其是面向 C 端的交互场景,错误的情绪判断比没有判断更让用户反感。

5.5 测试集信息泄露,验证集准确率虚高

现象:训练时验证集 acc 到了 90%,一上线真实表现只有 60%,差距大到不像是正常泛化损失。

原因:FER2013 如果从原始 csv 切分,很多人直接随机打散,原始数据里同一张脸、同一段连续帧可能同时出现在训练和验证里,模型等于见过答案。另一个泄露点是归一化的 mean、std 用全数据集计算,把验证集信息提前带进了训练。

解决:第一,按人脸 ID 或按视频片段切分,拿不到 ID 信息时,至少不要把 csv 原始文件的连续区间混切;第二,Normalize 的统计量只从训练集计算;第三,增强和随机采样固定随机种子,否则实验不可复现。以下代码在实验开始时执行一次:

import random import numpy as np import torch random.seed(42) np.random.seed(42) torch.manual_seed(42)

“泄露”这个问题不好肉眼发现,因为它只是让数字更好看,却不会在训练过程中给出任何异常信号。我现在每做一个实验,第一件事就是检查数据切分代码里有没有跨文件的混切,第二件事就是固定种子,这两步 30 秒能做完,避免后面白跑几个小时。

6. 验证与进阶:分数、一致性、检索三件套

6.1 验收指标:混淆矩阵、特征距离、特征散点

最后聊聊怎么确认这套方案真能用。只看单张图准确率是不够的,情感特征提取的验收标准至少有三项:分类准确率、连续帧一致性、特征分布可解释性。

分类准确率用混淆矩阵看,不要只看 top1,因为 disgust 和 fear 本来就容易混淆,happy 这种大类会把整体指标拉高。用 matplotlib 画混淆矩阵时,python 画图横坐标太密集是常见问题,把 xticklabels 旋转 45 度、figsize 调大,图就清楚了。一致性验证是拿 10 秒视频,看同一段表情下特征向量的平均余弦距离,距离小于 0.1 才算可用的特征。特征可解释性是把验证集特征用 PCA 降成二维散点,看不同情感是否成团,这也是后面定相似度阈值的主要依据。

6.2 进阶用法:检索、时序建模、少数类加权

进阶一,把 512 维特征当作检索向量,支持“给我一张带情绪的图,找库里最像的 10 个表情”,相似度用余弦距离,不需要重新训练模型。进阶二,在特征层面接一个轻量时序网络,比如 GRU 或一维卷积,把连续帧特征做成序列输入,能学到从 surprise 到 happy 这样的情绪转换过程,比单帧输出更有业务价值。进阶三,训练时用加权 CrossEntropyLoss 提高少数类权重,权重与训练集各类样本数倒数成比例,能明显改善 disgust 的召回,代价是 happy 的准确率会稍微下降,属于正常权衡。

以前我拿到特征就直接定阈值,结果在相似度检索上吃了大亏。后来改成每种情感各抽一组验证样本,先看特征分布再定阈值,才发现 neutral 这种安静表情天然聚集在原点附近,硬阈值会误伤一半 neutral。这套流程做完,你应该也会有同样的体感:模型只是个黑匣子,但特征分布是可以摸到的把手。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询