资讯动态

OpenCV+CNN人脸情绪识别全流程:Python实时摄像头项目实战

发布时间:2026/10/5 16:15:34 来源:尧图企业网站定制
简介这是一份基于 OpenCV 与深度学习的人脸情绪识别项目完整代码包适合高校学生、课程设计者与计算机视觉入门者用于期末大作业、课程实践或表情分类学习。项目已在本地编译验证可运行评审分达 95 分以上难度适中且由助教审核适合直接作为参考实现来搭建环境与复现效果。压缩包共 9 个文件包含 Python 训练与预测脚本、深度学习权重及配置文件.h5、.caffemodel、.json、.prototxt、说明文档和示例图片整体约 12.15MB目录结构清晰。目前已有 371 人学习下载具备不错的参考价值。借助这份代码读者可掌握 OpenCV 人脸检测、深度学习情绪分类的完整链路使用预训练模型快速体验识别效果同时通过源码、README 与模型配置文件还可以理解从训练到推理、再到模型部署的工程组织方式。1. 人脸情绪识别期末作业为什么我建议你直接用 Python OpenCV CNN期末答辩现场老师问我这个框是谁画出来的情绪又是谁判断的我当时的实现是——用 OpenCV 做人脸检测用深度学习 CNN 做人脸情绪识别两种技术各管一段。基于 OpenCV 与深度学习的人脸情绪识别项目代码拆开看就是一条视频流水线OpenCV 找到脸的位置裁剪出来丢给卷积神经网络输出 7 种情绪的概率。这个方案适合有一点点 Python 基础、想在期末作业里同时秀出传统图像处理和深度学习的同学。跟着这篇笔记做完你能得到一份摄像头实时识别、可复现、报告里写得清楚的项目代码而不是只有一堆跑不起来的零散脚本。2. 把视频帧拆成两条流水线OpenCV 看到脸CNN 认出情绪2.1 检测与分类的职责边界为什么不在一个模型里做完这个项目最容易被问倒的地方就是“人脸检测”和“情绪识别”到底谁负责什么。OpenCV 的 Haar 级联检测器是一个传统机器学习分类器它反复扫描图像窗口通过滑动窗口判断当前区域像不像人脸。它只输出矩形框不关心框里的人是高兴还是生气。情绪识别则是图像分类问题输入一个已经裁剪好的人脸小图输出类别概率。两段式的好处是情绪训练只需要“人脸图片 标签”不需要标注人脸框期末作业的数据成本低很多。常见的端到端做法是 MTCNN 或 YOLO 带分类头一次推理同时出框和情绪。但对期末作业来说这种方案太重训练要一张大图同时交给检测和分类两个头数据要重新整理报告也不容易讲清楚。OpenCV 图像处理项目里最常见的组合就是“检测器 分类器”OpenCV 做人脸检测深度学习网络做人脸分类。答辩时你可以明说两个模块各有分工老师反而觉得思路清楚。2.2 模型选型7 类情绪识别为什么用小 CNN 而不是 ResNet50FER2013 数据集里的图片是 48x48 的灰度图分辨率很低。对这种输入三层小卷积网络足够提取表情特征。ResNet50 不是不能用但参数多、CPU 上推理慢、期末报告里还得解释残差块没必要。小 CNN 的优势在于模型文件很小实时推理在普通笔记本 CPU 上也能跑而且每一层卷积的尺寸变化都可以写进期末报告。下面是我常用的情感分类网络结构输入 1x48x48输出 7 个类别。import torch import torch.nn as nn class EmotionNet(nn.Module): def __init__(self, num_classes7): super(EmotionNet, self).__init__() self.block1 nn.Sequential( nn.Conv2d(1, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2, 2) ) self.block2 nn.Sequential( nn.Conv2d(64, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2, 2) ) self.block3 nn.Sequential( nn.Conv2d(128, 256, 3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d(2, 2) ) self.fc nn.Sequential( nn.Dropout(0.25), nn.Linear(256 * 6 * 6, 512), nn.ReLU(inplaceTrue), nn.Linear(512, num_classes) ) def forward(self, x): x self.block1(x) x self.block2(x) x self.block3(x) x x.view(x.size(0), -1) return self.fc(x)输入 48x48经过三次 MaxPool2d(2, 2)特征图尺寸从 48 变成 24、12、6最后是 256 通道的 6x6 特征图所以全连接层输入是 256 * 6 * 6 9216。为什么用三层而不是五层因为 FER2013 本身低分辨率层数太深容易过拟合训练集 acc 能到 95%验证集却只有 60%。BatchNorm 让每层输入分布稳定Dropout 在最后一个全连接前随机丢弃 25% 的神经元这两者都是让模型不要死记训练集。2.3 环境安装与自检cv2 和 torch 怎么装才不打架期末翻车最狠的往往是环境不是算法。先建一个干净虚拟环境再用 python -m pip 安装避免系统里多个 Python 互相干扰。python -m venv emotion_env source emotion_env/bin/activate # Windows: emotion_env\Scripts\activate python -m pip install opencv-python numpy torch torchvision --index-url https://download.pytorch.org/whl/cpu python -c import cv2, torch; print(cv2.__version__, torch.__version__)这里特意指定了 CPU 版 torch 索引。如果是默认 PyPI 的 torch会连 CUDA 一起下体积好几个 GB没 GPU 环境也一样用纯属浪费时间和硬盘。最后一行的自检命令很重要能同时确认 cv2 和 torch 装在了当前环境。很多同学出现ModuleNotFoundError: No module named cv2就是因为在终端里装进了 base 环境但 Jupyter 用的却是另一个虚拟环境。装好之后再跑一段摄像头自检代码。这一步不过后面所有项目代码都没法跑。import cv2 import numpy as np cap cv2.VideoCapture(0, cv2.CAP_DSHOW) if not cap.isOpened(): print(camera open failed) else: ret, frame cap.read() print(frame.shape if ret else read failed) cap.release() cv2.destroyAllWindows()Windows 上给 VideoCapture 加cv2.CAP_DSHOW能绕开旧版 DirectShow 的兼容问题。如果索引 0 打不开后面我会讲怎么排查。这个自检代码也算答辩前检查清单的一部分千万别跳过。3. 数据准备从 FER2013 到摄像头画面的预处理链路3.1 FER2013 的读取pixels 是字符串先 split 再 reshapeFER2013 是情绪识别最常用的公开数据集7 类情绪分别是 angry、disgust、fear、happy、sad、surprise、neutral。你下载到的 CSV 文件有三列emotion 是标签pixels 是一串空格分隔的像素值Usage 是官方划分的训练/验证/测试标记。第一次写读取代码的同学常犯一个错直接把 pixels 这一列当成数组拿去 reshape。实际上它是字符串必须先用 split() 拆成整数列表。import pandas as pd import numpy as np df pd.read_csv(fer2013.csv) emotions df[emotion].values pixels df[pixels].values imgs [] for p in pixels: arr np.array(p.split(), dtypenp.uint8).reshape(48, 48) imgs.append(arr) print(imgs[0].shape, emotions[0])pixels 里的值范围是 0 到 255每个像素对应 48x48 灰度图的一个点。dtypenp.uint8是为了让内存只有原来的四分之一也符合图像本身的位深。后面转成 PyTorch 张量时会再变成浮点数这里不用提前转换。官方划分为 Training、PublicTest、PrivateTest 三个集合建议直接用这个划分不要随机切这样期末报告里可以写“与 FER2013 论文保持相同的评测协议”。3.2 数据增强与归一化翻转、裁剪和 mean/std 保持一致FER2013 的训练集接近 3 万张但类别不平均disgust 类别只有几百张。为了避免小样本类别被模型忽略除了加权损失数据增强也是有效手段。增强只加在训练集验证集和测试集只做归一化否则验证集也会被“污染”评测结果虚高。from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T train_transform T.Compose([ T.RandomHorizontalFlip(), T.RandomAffine(degrees10, translate(0.05, 0.05)), T.ToTensor(), T.Normalize((0.5,), (0.5,)) ]) val_transform T.Compose([ T.ToTensor(), T.Normalize((0.5,), (0.5,)) ]) class FERDataset(Dataset): def __init__(self, df, transform): self.imgs [np.array(p.split(), dtypenp.uint8).reshape(48, 48) for p in df[pixels]] self.labels df[emotion].tolist() self.transform transform def __len__(self): return len(self.labels) def __getitem__(self, idx): img Image.fromarray(self.imgs[idx], modeL) return self.transform(img), self.labels[idx]RandomAffine(degrees10, translate(0.05, 0.05))的意思是随机旋转最多 10 度同时允许在水平和垂直方向平移最多 5% 像素。人脸在摄像头画面里不会永远正对镜头这两个增强能让模型对轻微角度变化更鲁棒。T.Normalize((0.5,), (0.5,))的算法是(pixel - 0.5) / 0.5把 ToTensor 输出的范围从 [0, 1] 变成 [-1, 1]。这里最关键的坑是训练和测试的归一化必须完全一样。很多人训练时用 Normalize测试推理时忘了做导致模型输入分布和训练不一致acc 直接掉 10 个点。我会在实时推理代码里继续沿用同一个公式保证从摄像头走过来的像素和训练时同分布。3.3 把 OpenCV 抓到的脸变成模型输入先裁剪成正方形OpenCV 的 Haar 检测返回的是矩形框但人脸框不一定是正方形。如果直接把一个 80x60 的框 resize 成 48x48脸会被横向压扁模型训练时没见过这种变形的脸。所以先按中心点把框扩展成正方形再 resize。def crop_face_square(gray, x, y, w, h): center_x x w // 2 center_y y h // 2 side max(w, h) x1 max(center_x - side // 2, 0) y1 max(center_y - side // 2, 0) x2 min(x1 side, gray.shape[1]) y2 min(y1 side, gray.shape[0]) return gray[y1:y2, x1:x2]然后统一走一遍和训练集相同的预处理def face_to_tensor(face_img): gray cv2.resize(face_img, (48, 48)) arr gray.astype(np.float32) / 255.0 # 和 ToTensor 等价 arr (arr - 0.5) / 0.5 # 和 Normalize 等价 return torch.from_numpy(arr).unsqueeze(0).unsqueeze(0) # 1x1x48x48为什么先用灰度图再 resize因为 Haar 检测本来就在灰度图上做直接切出来的区域就是灰度。face_to_tensor里不再 cvtColor是避免把 48x48 的小图做两次色彩转换浪费时间。unsqueeze(0).unsqueeze(0)是给张量加上 batch 维度和通道维度模型要求输入形状是 [N, 1, 48, 48]其中 N 是 batch 大小推理时只有一张脸N 就是 1。4. 训练一个能交差的分类器loss 下降、加权损失、实时推理4.1 训练循环与超参数batch_size64、lr0.001、20 个 epoch训练代码看起来长但核心只有三部分DataLoader 喂数据、CrossEntropyLoss 算差距、Adam 更新参数。下面是完整循环我注释掉了每个关键步骤。import torch.optim as optim from torch.utils.data import DataLoader from tqdm import tqdm train_loader DataLoader(train_ds, batch_size64, shuffleTrue, num_workers2) val_loader DataLoader(val_ds, batch_size128, shuffleFalse, num_workers2) device torch.device(cuda if torch.cuda.is_available() else cpu) model EmotionNet().to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001, weight_decay1e-4) for epoch in range(20): model.train() total_loss 0 correct 0 total 0 for images, labels in tqdm(train_loader, descfepoch {epoch1:02d}): images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() * images.size(0) correct (outputs.argmax(dim1) labels).sum().item() total labels.size(0) print(ftrain loss: {total_loss / total:.4f}, acc: {correct / total:.4f})batch_size 选 64 是因为 FER2013 每张图只有 48x48GPU 显存占用很小CPU 上跑 64 也不慢。lr0.001 是 Adam 的常用起点。如果发现 loss 震荡不降可以先降到 0.0003如果 loss 下降很快但验证 acc 上不去就把 Dropout 从 0.25 调到 0.5。weight_decay1e-4是权重的 L2 正则能压住模型参数过大。20 个 epoch 在普通笔记本 CPU 上一个 epoch 约 440 步总共 8800 步大概十几分钟到半小时能跑完。跑太久只会过拟合期末答辩没必要追求 95% 的验证 acc能稳定超过 60% 已经能交差。4.2 类别不平衡的解法按样本数给 CrossEntropyLoss 加权FER2013 的类别分布很不均匀disgust 样本特别少。如果不处理模型会为了把整体 acc 做高直接忽略 disgust。解决办法是给每个类一个权重样本少的类权重高样本多的类权重低。from collections import Counter labels [label for _, label in train_ds] counts Counter(labels) weights [1.0 / counts[i] for i in range(7)] weights torch.tensor(weights, dtypetorch.float32).to(device) criterion nn.CrossEntropyLoss(weightweights)weights的计算逻辑很简单某个类样本越少1.0 / counts[i]越大CrossEntropyLoss 在反向传播时会给这个类的梯度放大。注意这个权重要在创建 DataLoader 之前就算好而且必须和模型一样放在 device 上否则 torch 会在 loss 计算时报 device 不匹配。加权之后整体 acc 不一定提升但每类 acc 会更均衡。报告里可以放一张各类别 acc 表格说明你意识到类别不平衡并做了处理这比只贴一个整体 acc 有说服力。4.3 保存加载与摄像头推理把 state_dict 接上 OpenCV训练完成后保存模型最干净的方式是只存 state_dict不存整个模型对象。这样换电脑、换 Python 版本都不会因为类路径问题加载失败。torch.save(model.state_dict(), emotion_model.pth)加载时需要先实例化模型再 load_state_dictmodel EmotionNet() model.load_state_dict(torch.load(emotion_model.pth, map_locationcpu)) model.eval()map_locationcpu很重要。如果你在 GPU 机器上训练换到只装了 CPU 的电脑上加载torch 会尝试用 CUDA 找设备然后报错。加了 map_location就会把权重全部搬到 CPU。model.eval()是必须写的它关闭 BatchNorm 的统计更新和 Dropout让推理结果稳定。下面是完整的摄像头实时情绪识别代码前面定义的crop_face_square和face_to_tensor直接拿来用。import cv2 import torch emotions [angry, disgust, fear, happy, sad, surprise, neutral] face_cascade cv2.CascadeClassifier(cv2.data.haarcascades haarcascade_frontalface_default.xml) model EmotionNet() model.load_state_dict(torch.load(emotion_model.pth, map_locationcpu)) model.eval() cap cv2.VideoCapture(0, cv2.CAP_DSHOW) while cap.isOpened(): ret, frame cap.read() if not ret: break gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale(gray, 1.1, 5, minSize(64, 64)) for (x, y, w, h) in faces: face crop_face_square(gray, x, y, w, h) tensor face_to_tensor(face) with torch.no_grad(): logits model(tensor) prob torch.softmax(logits, dim1)[0] # 每个情绪的概率 idx prob.argmax().item() label emotions[idx] conf prob[idx].item() cv2.rectangle(frame, (x, y), (x w, y h), (0, 255, 0), 2) cv2.putText(frame, f{label} {conf:.2f}, (x, y - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) cv2.imshow(emotion, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()detectMultiScale(gray, 1.1, 5, minSize(64, 64))里的1.1是每层金字塔缩小 10%5表示至少被 5 个相邻窗口确认才算人脸。minSize 设成 64 是避免把远处的模糊小脸也拿去识别既省时间又减少误检。5. 踩坑排查cv2.error、摄像头黑屏、模型不收敛的五条定位路径5.1 cv2.error: OpenCV(4.4.0) ... 一运行就崩先查 xml 路径现象运行人脸检测时报错结尾固定是error: (-215:Assertion failed) !empty() in function CascadeClassifier::detectMultiScale。原因cv2.CascadeClassifier(haarcascade_frontalface_default.xml)里的 xml 文件不在当前目录。很多教程直接写相对路径但你项目工程里根本没复制这个文件。解决不要靠相对路径直接用 OpenCV 自带的绝对路径拼法。import os xml_path cv2.data.haarcascades haarcascade_frontalface_default.xml print(xml_path, os.path.exists(xml_path)) face_cascade cv2.CascadeClassifier(xml_path)cv2.data.haarcascades是 OpenCV 安装包内的 cascade 目录装了 opencv-python 就自带不用额外下载。打印 os.path.exists 能快速确认文件在不在。5.2 摄像头黑屏或打不开索引和权限是首要嫌疑现象cap cv2.VideoCapture(0)后窗口能开但一直黑屏或者cap.read()一直返回(False, None)。原因Windows 下摄像头被别的软件占用或者索引 0 不是你要用的摄像头在部分笔记本上必须加后端参数CAP_DSHOW才能正常访问。解决写一个循环把常见索引都试一遍。for idx in [0, 1, -1]: cap cv2.VideoCapture(idx, cv2.CAP_DSHOW) if cap.isOpened(): print(use camera index, idx) break else: print(no camera available)另外检查系统的摄像头隐私权限Windows 设置里“相机”允许桌面应用访问。学习通、腾讯会议这些软件也会抢摄像头把它们关掉再试。5.3 loss 不降或 acc 卡在 50%先复查标签和归一化现象训练 loss 一直 1.9 左右准确率不超过 50%甚至才 20%。原因大概率是标签错位或者验证集忘了做归一化。FER2013 的 emotion 列是 0 到 6 的整数如果 pandas 读进来变成字符串或者你在循环里把图像和标签的下标弄错模型就会把特征和错误标签强行拟合。解决把预处理后的一个 batch 可视化出来确认图像内容和标签对上。import matplotlib.pyplot as plt images, labels next(iter(train_loader)) for i in range(6): plt.subplot(2, 3, i 1) plt.imshow(images[i, 0].numpy() * 0.5 0.5, cmapgray) plt.title(flabel{labels[i].item()}) plt.show()注意images[i, 0].numpy() * 0.5 0.5是把 Normalize 的值还原回 [0, 1] 范围否则 Matplotlib 显示出的图片会整体偏暗看起来像一张黑图。这也顺便验证了 Normalize 有没有写错。5.4 实时识别卡顿别每帧都跑长流程用隔帧和小图现象摄像头画面像幻灯片CPU 占用率 100%。原因Haar 检测和 CNN 推理都放在每一帧里做CPU 上每帧处理可能需要 100ms 以上10 fps 都不到。解决先缩小画面再做检测检测到的框坐标乘回原图比例并且每 3 帧只检测一次中间帧沿用上一次的人脸框。frame_small cv2.resize(frame, (320, 240)) small_gray cv2.cvtColor(frame_small, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale(small_gray, 1.1, 5) scale_x frame.shape[1] / 320.0 scale_y frame.shape[0] / 240.0 for (x, y, w, h) in faces: x, w int(x * scale_x), int(w * scale_x) y, h int(y * scale_y), int(h * scale_y) # 后续用放大后的坐标画框和裁剪人脸检测对分辨率不敏感一个 320x240 的小图足够定位脸。CNN 本身输入 48x48瓶颈不在输入而在检测步骤。如果还卡把摄像头分辨率设成 640x480别用 4K 摄像头默认的 1920x1080。5.5 ModuleNotFoundError: No module named cv2环境装错位置现象在终端python -c import cv2成功但在 Jupyter Notebook 里import cv2报 No module named cv2。原因Jupyter 的 kernel 用的 Python 解释器不是你终端里当前激活的那个虚拟环境。解决在 Jupyter 里先打印当前环境路径再让这个解释器去装包。import sys print(sys.executable)然后复制打印出的路径在终端用这个绝对路径执行/your/python/path -m pip install opencv-python torch torchvision numpy装完重启 kernel。以后装任何 Python 包统一用python -m pip install不要直接pip install因为pip可能链接到另一个 Python 环境这是很多环境冲突的根源。6. 期末演示防翻车技巧低置信度过滤、概率条和录像回放实时推理中模型总会被喂到一些模糊的、歪着脸的、甚至不是人脸的区域。如果不加控制它会把一个面无表情的侧脸硬判成 happy答辩现场很尴尬。我的习惯是加一个 0.6 的置信度阈值低于阈值显示 unknown。THRESHOLD 0.6 if conf THRESHOLD: label unknown这个过滤器会让结果更可信还能在报告里直接写“模型拒绝不明确样本避免过度自信”。如果想让效果更像产品原型可以给每个情绪画一条横向概率条。7 个类别的概率加起来是 1用长度表示每个概率画面会直观很多。bar_x, bar_y x, y h 12 colors [(0,0,255), (0,255,255), (255,0,255), (0,255,0), (255,255,0), (255,0,0), (128,128,128)] for i, emo in enumerate(emotions): length int(prob[i].item() * 100) cv2.rectangle(frame, (bar_x, bar_y i*16), (bar_x length, bar_y i*16 12), colors[i], -1) cv2.putText(frame, emo, (bar_x length 5, bar_y i*16 12), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)最后别忘了用 VideoWriter 录制一段演示视频。现场摄像头可能出问题但录好的视频一定不会黑屏。out cv2.VideoWriter(demo.avi, cv2.VideoWriter_fourcc(*MJPG), 20, (frame.shape[1], frame.shape[0])) # 每帧处理完后 out.write(frame) out.release()我第一次做这个项目时没加阈值过滤结果模型对一张白纸判成了 happy被老师当场质疑。后来我加了阈值并在日志里记录每帧的置信度报告里多写了一页“模型局限性”分析老师反而认可了这种对错误的诚实描述。希望这个流程帮到你少走一段我之前走过的弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑