简介面向计算机视觉初学者与深度学习开发者这份基于TensorFlow与OpenCV的人脸识别和表情识别项目系统展示了从人脸检测到七类基本表情自动分类的完整设计思路。资源围绕Emotion-master项目展开源码、模型、文档按模块组织共二十三个文件主要包含Python脚本、HDF5模型权重、XML配置文件、PDF说明手册、mp4操作演示与gif效果图压缩包整体约九点二九MB轻量且便于下载复用。目前已有五百人学习浏览适合作为课程设计、毕业设计或人脸交互系统预研的参考。借助FaceNet、VGGFace等深度模型对人脸特征进行提取匹配配合OpenCV的实时图像处理与检测能力读者可以逐步理解数据加载、模型训练、性能评估及实际预测的完整链路其中演示脚本可直接运行查看效果工具模块封装了常用的预处理辅助函数权重文件也允许跳过训练快速验证从而降低了复现门槛帮助开发者快速上手人脸识别与表情识别应用。1. 基于深度学习的人脸识别和表情识别设计一套系统里最难的不是高深算法一个门禁机要在 0.5 秒内回答两个问题来的人是谁以及他现在是平静还是焦躁。前一个靠人脸识别后一个靠表情识别而这两件事在工程上共用同一条检测链路却在模型层面走向完全不同的分支。基于深度学习的人脸识别和表情识别设计核心工作是把这两条分支组织成一条可运行的流水线先用检测模型把脸从画面里找出来再做几何对齐然后一路送去提特征向量比身份另一路送去分类网络打情绪标签。多数第一次做这个题目的人容易栽在四件事上数据没整理好、对齐做得糙、置信度阈值拍脑袋、表情类别不平衡没处理。这篇笔记从数据和选型讲起把中间能直接抄的代码放在前三章坑集中在第五章怎么验证和部署放最后。2. 先搭骨架人脸识别和表情识别共用的流程、数据与选型2.1 整条流水线为什么必须拆成检测、对齐、识别、分类四段很多初学者拿到题目后第一反应是人脸识别不就是拿一个 CNN 把整张图分个类吗。这想法不能说错但放到真实场景里会很快撞墙。摄像头拍到的画面里往往有桌子、屏幕、走动的人直接对整图分类模型会把背景特征也学进“这是张三”这个结论里。换一个办公室、换一扇窗户识别率会掉一大截。所以工程上的通行做法是先拆段。首段是检测回答“画面里哪里有脸”次段是对齐人脸对齐通过眼、鼻、嘴的五个关键点把脸扭正、缩放到统一尺寸之后才分岔身份识别这路把对齐后的脸映射成一个高维特征向量表情识别这路则直接交给一个多分类网络。两路共用前两段意味着检测和对齐的质量会同时影响两路任务的最终效果这也是我把这两段单独拎出来讲的原因。人脸对齐的重要性往往被低估。同一个人的脸在画面里左转 10 度和正对镜头直接做特征比对余弦相似度可能从 0.6 掉到 0.2。ArcFace 这类身份模型在训练时见过各种姿态的样本但对齐后输入分布更贴近训练分布差距依然明显。表情识别也一样张嘴和微笑在侧脸角度下很难区分。先对齐再分类是让后段模型省力气的关键步骤不是可有可无的预处理。2.2 数据怎么准备底库、训练集和标签目录结构动手写模型前先花半天整理数据。常见做法是准备三块数据底库注册人脸、识别训练/测试集、表情训练集。底库面向识别任务每名待识别人员放 3 到 6 张不同光照、不同角度的人脸照片表情集面向分类任务按七类常见标签组织包括愤怒、厌恶、恐惧、开心、中性、悲伤、惊讶。目录先搭好后面代码轮子才好跑。mkdir -p project/{data,models,output,logs} mkdir -p data/face_db/{zhangsan,lisi,wangwu} mkdir -p data/expr_dataset/{anger,disgust,fear,happy,neutral,sad,surprise}/train mkdir -p data/expr_dataset/{anger,disgust,fear,happy,neutral,sad,surprise}/val mkdir -p data/test_videos这段目录结构里face_db下每个人一个子目录存放注册照片expr_dataset按类别分 train 和 val后面训练时的torchvision.datasets.ImageFolder直接能读省掉自己写数据集的功夫。test_videos放你最后用来验证整条链路的真实视频别拿训练集图片自欺欺人。表情数据集要注意分布问题。公开竞赛集虽然省事但类别很不均匀“开心”往往占了三分之一还多“厌恶”“恐惧”样本很少。如果直接拿来训模型会学出一个偏向不管什么脸都猜“开心”。预处理时建议统计一下每类样本数后面微调时按类别做加权采样。识别用的底库则不存在这个问题因为底库不是训练数据它的作用是给每张待识别脸提供比对对象。2.3 模型选型为什么识别用度量学习表情用轻量 CNN 分类选型决定了后面代码的骨架。检测环节常见的是 RetinaFace 和 MTCNNMTCNN 轻、老、易部署但对侧脸和大角度姿态漏检明显RetinaFace 检测率和五点关键点精度都更好代价是计算量更大。身份识别环节主流做法是 ArcFace 这类基于特征向量的模型输出一个 512 维向量用余弦相似度判断是不是同一个人。表情识别环节用 MobileNet V3 或 ResNet 这类预训练 CNN在 ImageNet 权重基础上微调替换分类头。环节常用模型输出适用场景人脸检测RetinaFace / MTCNN框坐标 关键点RetinaFace 精度高MTCNN 适合算力紧张的设备人脸对齐仿射变换 五点关键点112×112 人脸图前后端共用直接影响两路效果身份识别ArcFace 类模型512 维特征向量门禁、考勤、底库比对表情识别MobileNet V3 / ResNet类别概率课堂专注度、客服情绪分析选型背后的理由不是模型越深越好。人脸识别本质是度量学习问题模型要把同一个人的不同照片映射到向量空间里的邻近位置把不同人推开直接用 Softmax 分类只能学到“训练集里的人是谁”遇到底库里的新人就得重新训。表情识别恰好相反它是标准的多分类问题七类标签相对固定用分类头加交叉熵损失就够了。两路模型在任务性质上的差异决定了选型方向完全不同。3. 人脸检测与对齐用 RetinaFace 把脸框准再统一到 112×1123.1 环境配置用 Miniconda 把深度学习环境独立出来多数人脸识别项目会同时依赖 PyTorch、OpenCV、ONNX Runtime版本之间互相牵制直接装在系统 Python 里容易出现“升级了一个包另一个包跑不起来”的窘境。常见做法是先建一个独立的环境把所有依赖锁在里面。conda create -n face python3.9 -y conda activate face conda install pytorch torchvision cpuonly -c pytorch pip install opencv-python onnxruntime numpy tqdm matplotlib scikit-learn这里 CPU 版 PyTorch 足够跑通调试流程训练表情模型再换 CUDA 版不迟。onnxruntime用来加载人脸检测和特征提取的 ONNX 权重opencv-python负责图像读写、仿射变换和画框。解释一句人脸识别领域很多预训练模型以 ONNX 形式发布用 ONNX Runtime 加载比回退到原始训练框架省去一堆依赖。装完验证一下三个关键库能不能同时 import不要等到跑脚本时才报错。命令行输入python -c import cv2, onnxruntime, torch; print(ok)看到 ok 再继续。这一步看着简单但能帮你把环境问题和代码问题分开排查。3.2 RetinaFace 检测与五点对齐的最小脚本检测 对齐流程里RetinaFace 返回人脸框和五个关键点左眼、右眼、鼻尖、左嘴角、右嘴角。下面脚本把关键点传给仿射变换函数将脸部区域校正到 112×112这是 ArcFace 类模型的标准输入尺寸。import cv2 import numpy as np from retinaface import RetinaFace def align_face(img, landmarks, size112): # 112x112 下的标准参考点来源是常见人脸识别训练预处理 dst np.array([ [38.2946, 51.6963], [73.5318, 51.5014], [56.0252, 71.7366], [41.5493, 92.3655], [70.7299, 92.2041] ], dtypenp.float32) src landmarks.astype(np.float32) tform, _ cv2.estimateAffinePartial2D(src, dst, methodcv2.LMEDS) aligned cv2.warpAffine(img, tform, (size, size)) return aligned img cv2.imread(test.jpg) img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) faces RetinaFace.detect_faces(img_rgb) for key in faces: item faces[key] box item[facial_area] lm item[landmarks] # RetinaFace 的 landmarks 是 dict按左眼/右眼/鼻/左嘴角/右嘴角取值 src_pts np.array([ lm[left_eye], lm[right_eye], lm[nose], lm[mouth_left], lm[mouth_right] ], dtypenp.float32) aligned align_face(img_rgb, src_pts, 112) cv2.imwrite(faligned_{key}.jpg, cv2.cvtColor(aligned, cv2.COLOR_RGB2BGR))代码逻辑分三步先用RetinaFace.detect_faces得到每张脸的框和关键点再把关键点按固定顺序整理为src_pts最后用cv2.estimateAffinePartial2D求从原图关键点到参考点的仿射矩阵warpAffine执行校正。LMEDS是随机抽样一致性算法的一种能剔除个别偏移较大的关键点防止一只眼睛标歪把整张脸拉变形。输出文件是你后续所有识别和表情工作的输入值得认真检查。把人脸检测结果单独画框保存一版肉眼看看有没有切到头发、漏掉耳朵、框偏到下巴以下的这些边界问题靠指标很难发现。3.3 影响后续识别率的三个参数det_size、置信度阈值、对齐尺寸RetinaFace 有几千个小参数但真正需要手动摸的就三个。第一个是输入尺寸有些实现里叫det_size。图像直接全分辨率送进去小脸能检到但 CPU 上耗时常超过 1 秒缩到 640 甚至 512检测速度能快几倍。第二个是置信度阈值默认值通常在 0.5 到 0.7 之间。调高到 0.8减少背景误检但侧脸也被过滤掉了调低到 0.4能找回模糊帧里的脸代价是椅子上的人形也会被当脸。第三个是对齐输出尺寸ArcFace 固定用 112表情模型用 48 或 64 也能不错但为了共用链路统一到 112 最省事。参数推荐值范围调大效果调小效果我的习惯检测输入尺寸512~1024小脸检出率上升速度更快先设 640漏检再拉高置信度阈值0.5~0.8误检减少召回率上升门禁场景用 0.6对齐输出尺寸112×112特征更精细计算量更小固定 112不做第二套这三个参数的调法有个先后顺序。先把置信度阈值调到肉眼觉得“该捡的脸都捡到了”再动输入尺寸解决速度问题最后确认对齐输出尺寸和预训练模型要求一致。对齐尺寸一旦和模型不匹配后面的特征提取会在预处理阶段静默出错表现为同一张脸两次提取的向量差得极远这类问题排查起来比显式报错更绕。4. 人脸识别与表情识别一条代码流程里同时出“是谁”和“什么表情”4.1 人脸识别不靠 SoftmaxArcFace 特征向量与余弦相似度识别分支的目标是把一张对齐后的人脸变成一个向量。ArcFace 的核心做法是在训练阶段给特征向量加角度边距让类内更紧凑、类间更分散。推理阶段它输出一个 512 维向量我们关心的是这个向量和别人向量的夹角而不是它属于哪个训练类别。import onnxruntime as ort import numpy as np session ort.InferenceSession( models/arcface_r100.onnx, providers[CUDAExecutionProvider, CPUExecutionProvider] ) input_name session.get_inputs()[0].name def preprocess(aligned_bgr): # 对齐图已经是 112x112转 RGB、归一化到 [-1,1]、转 NCHW rgb cv2.cvtColor(aligned_bgr, cv2.COLOR_BGR2RGB) rgb (rgb.astype(np.float32) - 127.5) / 127.5 tensor rgb.transpose(2, 0, 1)[None] return tensor def get_embedding(aligned_bgr): emb session.run(None, {input_name: preprocess(aligned_bgr)})[0] emb emb.reshape(1, -1) # L2 归一化让余弦相似度只和夹角有关 emb emb / np.linalg.norm(emb, axis1, keepdimsTrue) return emb[0]这段代码里最容易忽略的是最后一步 L2 归一化。ArcFace 输出向量的模长本身携带图像亮度信息同一个光照下同一个人模长相近换了个灯模长就变了。归一化之后模长为 1相似度只由方向决定对光照变化更鲁棒。providers参数里把 CPU 放在最后兜底GPU 可用时优先 GPU。用这个函数去提取底库照片和现场照片的向量两两算点积就是余弦相似度值越接近 1 越像同一个人。第一次跑通后建议先做一个自检拿同一人的两张照片算相似度应该明显高于不同人的相似度。如果这个基本区分度都没有问题多半出在对齐或预训练权重上先不要急着调阈值。4.2 底库注册与阈值把相似度 0.35 变成一条可解释的业务规则底库注册本质是“提前把每个员工的向量算好存起来”。现场来一帧脸提取向量后和底库逐个点积取最大值和阈值比。下面代码用 NumPy 管理底库不需要数据库参与适合原型验证和毕业设计生产环境再迁移到 SQLite 或向量数据库也不难。import numpy as np class FaceDB: def __init__(self, threshold0.4): self.embs [] self.names [] self.threshold threshold def register(self, name, aligned_img): emb get_embedding(aligned_img) self.embs.append(emb) self.names.append(name) def match(self, aligned_img): emb get_embedding(aligned_img) # 底库为空时直接返回未知 if len(self.embs) 0: return unknown, 0.0 sims np.dot(np.stack(self.embs), emb) idx int(np.argmax(sims)) score float(sims[idx]) return (self.names[idx], score) if score self.threshold else (unknown, score) db FaceDB(threshold0.4) db.register(zhangsan, cv2.imread(aligned_zhangsan.jpg)) name, score db.match(cv2.imread(aligned_test.jpg)) print(name, round(score, 3))match方法先算现场向量和底库所有向量的余弦相似度再取最大相似度和阈值比较。阈值 0.4 是我在普通室内摄像头场景常用的起点但不代表通用。底库人多到几百上千时撞脸概率上升需要往高调门禁安全要求高也往高调宁可多输几次密码。场景推荐阈值说明宽松通行、低风险0.25~0.35漏检少但有一小部分误放行普通考勤0.35~0.45均衡方案多数项目起点严格门禁0.45~0.55误放行率低但容易把人拒之门外阈值不是模型的一部分却直接决定用户体感。调阈值时一定要用现场录制的视频做统计看误放行和拒识别的比例别在训练集照片上自测。4.3 表情识别预训练 CNN 换分类头在表情数据上微调表情识别这路我不建议从头训练。公开表情数据集的规模通常在万级到十万级只有深度网络的一半运算量需求从头训练的收敛速度慢且容易过拟合。常见做法是拿 ImageNet 上预训练好的 MobileNet V3 做迁移学习把最后的分类头从 1000 类换成 7 类整体微调。import torch import torch.nn as nn from torchvision import models, transforms from torch.utils.data import DataLoader, WeightedRandomSampler model models.mobilenet_v3_small(weightsmodels.MobileNet_V3_Small_Weights.IMAGENET1K_V1) # MobileNet V3 的 classifier 是四层 Sequential替换最后一层为 7 分类 model.classifier[3] nn.Linear(1024, 7) transform transforms.Compose([ transforms.Resize((112, 112)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(10), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) # 假设 data/expr_dataset 下的目录结构是 类别/train 与 类别/val train_set torchvision.datasets.ImageFolder(data/expr_dataset, transformtransform)注意分类头的替换位置。MobileNet V3 Small 的classifier是一个Sequential依次是线性层、Hardswish 激活、Dropout、输出层。改第 3 个元素索引从 0 数前面的线性层和激活都保留只把最后的输出宽度从 1000 换成 7。如果错误地model.fc nn.Linear(...)运行时会报维度不匹配因为 V3 的结构里根本没有fc这个字段。训练时的学习率不要用 ImageNet 从头训的那套。预训练权重已经收敛分类头是随机初始化的常见做法是给分类头较大的学习率比如 1e-3给前面主干较小的学习率比如 1e-4。优化器用 AdamW 比 Adam 更不容易过拟合。批次大小按显存来一般 32 或 64 即可。每个 epoch 结束在 val 集上看准确率保存最佳权重不要等训完再找。4.4 一帧画面上同时输出身份和表情串联两个分支分叉的两条模型最终要合到一个流程里。对每一张检测到的脸先提取 ArcFace 向量查底库得身份再把同一张对齐图喂给表情模型得类别最后用 OpenCV 把结果画到原图上。import torch def draw_result(img_bgr, boxes, landmarks_list, db, expr_model, labels): img_rgb cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) for box, lm in zip(boxes, landmarks_list): aligned align_face(img_rgb, lm, 112) name, score db.match(aligned) tensor preprocess_for_expr(aligned) # 按 4.3 的 transform 处理 with torch.no_grad(): probs torch.softmax(expr_model(tensor), dim1)[0] expr labels[int(torch.argmax(probs))] x1, y1, x2, y2 [int(v) for v in box] cv2.rectangle(img_bgr, (x1, y1), (x2, y2), (0, 255, 0), 2) text f{name}:{score:.2f} {expr} cv2.putText(img_bgr, text, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) return img_bgr这段串联代码的核心在于aligned只算一次两个分支共用。你可能会觉得“多算一次对齐也没多少钱”但对齐里包含仿射变换和图像插值在视频流里每帧多跑一次就是明显的 CPU 占用。设计流水线时把检测和对齐结果作为字典传下去避免每个分支重复计算。表情模型的输出还应该加上概率值而不是只取类别低置信度时显示“unknown”比硬报一个情绪更符合现场体验。5. 避坑人脸识别加表情识别项目里最常见的 5 个现场问题5.1 现象CPU 上整套流程卡到两三帧检测是明显的瓶颈把 RetinaFace 用默认配置跑满整张 1920×1080 图单帧检测经常超过 800 毫秒再加识别和表情两路推理实时性直接消失。原因在于检测器在全分辨率图上做特征金字塔扫描计算量随图像面积暴涨。解决思路是先把输入帧缩到宽 640 或 512检测完再把框坐标映射回原图同时把det_size由默认的较大值改成 640。实测多数室内摄像头场景缩图后检测精度几乎不受影响速度能快三到五倍。另外检查循环里有没有反复创建 session 对象ONNX Runtime 的InferenceSession初始化开销很大全局只建一次。5.2 现象同一个人换了个角度的照片余弦相似度只有 0.2底库注册时拍的正面照光线均匀现场是侧光加低头看手机提取出的向量和底库夹角拉大阈值 0.4 下直接认不出来。背后的原因不只是模型能力而是注册样本太单一。解决方法是给每个人注册 3 到 6 张不同条件下的照片正常光、逆光、侧面、微低头。还可以在特征提取前对对齐图做一次轻量的直方图均衡化减弱光照差。我一般在注册界面提示用户“缓慢转头、录一段 3 秒视频”从中抽帧自动挑质量高的几帧入库效果比手动传照片好很多。5.3 现象表情识别训练 loss 不降验证准确率卡在 50% 附近原因要分两层找。第一层是类别不均衡“开心”样本是“恐惧”的十几倍模型把所有样本都猜成开心也能拿到高准确率第二层是标签本身有噪声公开数据集里部分样本是错的模型硬学这些错误对不上。对策是先用WeightedRandomSampler按类别权重采样让模型每轮见到各类样本的机会均等再把学习率调低光线扰动增强加多一些。如果试了这些还是卡在 50%检查一下是不是类别数配置错了分类头输出和标签类别数对不上时训练不会崩但准确率会被严重拉低。5.4 现象侧脸、戴口罩时检测框乱跳框住背景当人脸人脸检测器对正面脸最自信侧脸超过 45 度后关键点经常不准口罩还会把鼻子和嘴遮住五点对齐时嘴角关键点估计到口罩边缘上。解决思路是给检测加一个置信度下限低于阈值的帧直接跳过不参与识别同时对连续帧做多帧投票同一个 ID 在 5 帧里出现 3 次以上才确认单帧的不稳定就不会直接影响结果。若项目面向戴口罩场景可以在检测结果上再叠一个“是否戴口罩”的轻量分类器用于调整身份识别结果的置信度水平。5.5 现象实验室测试效果不错一到现场识别率就崩这是最容易被忽略的坑。实验室里的测试数据是提前拍的构图、光线、距离都理想现场屏幕光、窗外逆光、摄像头白平衡漂移人脸可能过曝或偏蓝。对到第三类问题根本原因是训练和推理的输入分布不一致。解决方法是把现场采集的 20 分钟视频纳入测试集模仿现场光照做数据增强并在部署代码里加入自适应灰度校正。我的习惯是上线前先录一段现场视频回放测试眼睛盯的不是平均准确率而是误认人的那几帧长什么样。6. 进阶把阈值和坏案例摆到明面上验证再往边缘设备收一收整个项目跑通只是第一关。如果只报一个“准确率 95%”现场维护时很难定位是哪一环出了问题。我更建议做一套阈值扫描把底库固定用一段含多人的现场视频从 0.2 到 0.6 每隔 0.02 试一次阈值统计误放行率和拒识率。这个曲线出来之后业务规则才有依据门禁机和课堂分析系统需要的阈值完全不同。for th in np.arange(0.20, 0.60, 0.02): false_accept, false_reject 0, 0 for frame in test_frames: # 模拟对比逻辑 for gt_name, sim_score in frame_results: accept sim_score th if accept and gt_name ! expected: false_accept 1 if not accept and gt_name expected: false_reject 1 print(fth{th:.2f} FAR{false_accept} FRR{false_reject})调完阈值后把模型导出成 ONNX 再压一压。PyTorch 模型在边缘设备的部署效率不如 ONNX Runtime导出命令很简单python -c import torch; modeltorch.load(expr.pt); torch.onnx.export(model, torch.randn(1,3,112,112), expr.onnx, opset_version11)导出后可以在像行空板这类带 NPU 的边缘板卡上跑也可以直接交给 OpenVINO 转换。NPU 部署时优先做 INT8 量化表情模型从 FP32 压到 INT8精度通常只掉一到两个点速度能明显提升。我自己的教训是网上很多帖子把量化说得像免费午餐实际在光线杂乱、样本类别不均衡的数据上量化掉点会翻倍必须带着自己的测试集做量化校准。不管用什么工具我都会最后回到那条线上把现场录像再过一遍。做这类项目最值钱的部分不是把准确率从 90% 刷到 92%而是知道哪一环在什么条件下不可靠。希望帮到你。本文还有配套的精品资源点击获取