ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Python人脸情感特征提取:OpenCV检测+ResNet18微调实战

Python人脸情感特征提取:OpenCV检测+ResNet18微调实战 简介这是一份基于几何法的人脸情感特征提取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 python3.10 -y conda activate emotion pip install opencv-python torch torchvision numpy scikit-learn matplotlibtorch 的安装要看本机有没有 CUDACPU 版也能跑通全流程只是训练要慢一些实测中小规模微调 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(weightsNone) model.fc torch.nn.Linear(model.fc.in_features, 7) state torch.load(emotion_resnet18.pth, map_locationcpu) 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, dim1)[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_thresh0.7): h, w img.shape[:2] blob cv2.dnn.blobFromImage( img, scalefactor1.0 / 127.5, size(300, 300), mean(127.5, 127.5, 127.5), swapRBFalse ) 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 facesblobFromImage的参数不能随手改这个检测模型训练时输入做了(像素/127.5 - 1.0)的缩放所以 scalefactor 和 mean 必须按它的原始约定填填错的话置信度和框的位置都会偏移。swapRBFalse是因为我们直接把 BGR 图喂了进去。检测环节不需要转 RGB但送入情感模型之前必须转这一点不少人翻过车。置信度阈值我一般取 0.7经验值。0.5 会框很多背景0.8 以上又会漏掉低头和侧脸。另一个技巧是裁剪时把框向外扩 10%~15%给额头和下巴留空间情感模型的效果会明显好于紧贴脸框。这个扩框操作极其简单但对后期准确率影响非常大建议一开始就做进检测函数里。3.2 人脸对齐两眼连线水平是基本盘人脸对齐是很多人跳过的一步跳过之后训练出来的模型对少量旋转还能凑合到真实场景就出现头稍微一歪、情感就变了的问题。对齐的目标很直接把两只眼睛的连线拉成水平并且把脸放到固定位置。常见操作是先通过关键点检测器拿左右眼坐标我这里为了不引入额外模型先基于检测框写一个可用的对齐版本后续接关键点模型时方法同理。import math def align_face(img, left_eye, right_eye, out_size224): 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), flagscv2.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(p0.5), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.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, transformNone): 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(weightsmodels.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()), lr1e-4, weight_decay1e-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_size64, shuffleTrue, num_workers4) val_loader DataLoader(val_ds, batch_size64, shuffleFalse, num_workers4) 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(dim1) correct (pred y).sum().item() total y.size(0) acc correct / total print(fepoch {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 够用显存不够就降到 32num_workers 超过 CPU 核心数反而会拖慢。验证集准确率看起来不错的时候建议按情感类别分别打印召回率而不是只看整体数字因为大类 happy 会把整体指标拉高disgust 可能已经彻底躺平。4.3 提取特征向量删除分类头拿到 512 维输出如果你按特征嵌入路线走最后这个 fc 层反而不该参与推理。正确做法是把 fc 换成恒等映射让网络输出 512 维张量这 512 维向量就是模型对表情肌肉状态的编码同一情感的特征在余弦距离上接近不同情感分开。加载方式如下import torch from torchvision import models backbone models.resnet18(weightsNone) backbone.fc torch.nn.Identity() state torch.load(emotion_resnet18.pth, map_locationcpu) backbone.load_state_dict(state, strictFalse) backbone.eval()提示特征提取必须让模型处于 eval() 模式并用 torch.no_grad() 包裹否则 BatchNorm 的 running 统计量会被更新同一张图会得到不同的输出。strictFalse是因为保存的 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)。也有同学选择把swapRBTrue写在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。这套流程做完你应该也会有同样的体感模型只是个黑匣子但特征分布是可以摸到的把手。希望帮到你。本文还有配套的精品资源点击获取
返回列表