简介一份基于Python实现的人脸表情识别项目代码包面向计算机视觉初学者、课程设计与毕业设计人群。项目以卷积神经网络构建识别系统在尝试Gabor、LBP等传统人脸特征提取后引入深度模型并在FER2013、JAFFE、CK三个公开数据集上完成评估覆盖数据预处理、模型训练、测试与实时识别完整流程。压缩包共57个文件大小16.86MB以Python源码和图像素材为主另有tflite模型文件、环境部署shell脚本、依赖说明txt/md、GUI演示图与demo动图、中文ttc字体等辅助材料src、assets、models、dataset等目录分工明确便于按模块查阅复用。项目附带了环境创建与依赖安装指引可在Python3和Keras2/TensorFlow后端下快速复现。目前已有290人学习下载可作为人脸识别课程设计、毕设项目或进阶练手的完整参考。1. 表情识别项目到底是什么先有“人脸框”才有“表情标签”做门禁机或考勤机的时候你已经把“这是谁”解决了但画面里这个人现在是平静、紧张还是开心人脸识别结果给不出答案。基于 Python 实现的表情识别项目就是在人脸识别流水线后面再补一块拼图先从视频帧里把人脸框出来再让一个分类模型判断这张脸属于愤怒、厌恶、恐惧、快乐、悲伤、惊讶还是中性。它解决的不是“你是谁”而是“你现在什么表情状态”。这个项目适合已经入门 Python、想完整走一遍数据准备、模型训练、实时推理全流程的开发者。公开数据集和工具链都很成熟两天内能跑出可演示版本后面真正花时间的是调参和跟摄像头较劲。2. 先搭对架构表情识别为什么绕不开人脸检测如果你把表情识别简单当成“图像分类”拿 FER2013 里已经裁剪好的人脸图片训练一个分类器它在测试集上的准确率通常不难看。问题出在接上摄像头以后模型并不知道人脸在哪里真实画面里的人脸也不会像数据集那样自己居中于是同样一个模型到了视频流里就开始翻车。这也是为什么做实时表情识别几乎没人走端到端路线主流可落地架构都是两级流水线——先检测再分类。2.1 先检测后分类为什么是主流流水线解耦的好处两级流水线的第一级负责从整帧画面里找到人脸框第二级只对框内区域做分类。这样做的好处不只是便于理解更关键的是两个模块可以各自独立演进。检测模型在“完整画面里找脸”这件事上积累了非常强的通用能力分类模型只需要学习“裁剪后人脸的表情差异”。你不必去收集“整帧画面 表情标签”这样的配对数据只需用现成检测器把人脸裁剪出来再拿公开数据集训练分类器工程量会小很多。解耦还能让排查变得直接。如果发现误检多就只换检测模型或调检测参数如果总是把悲伤判断成中性就只改分类模型的训练数据两部分互不牵连。换一个新场景时也不需要重新训练整个模型替换人脸检测器或者给分类模型补充一点新场景数据就行。2.2 三种人脸检测方案怎么选Haar、DNN 与 MTCNNPython 生态里做人脸检测最常见的三条路线是 OpenCV Haar 级联、OpenCV DNN 人脸检测和 MTCNN。我按实际项目里的取舍列一张对比表。方案依赖CPU 速度精度适合阶段OpenCV Haar 级联cv2 自带零额外文件最快受角度、光线影响大入门演示、快速原型OpenCV DNN 人脸检测需要 .prototxt 和 .caffemodel 两个文件中等正面及 45 度内稳定本地实时脚本MTCNN需要加载 P-Net / R-Net / O-Net 模型明显更慢在复杂背景中更好离线批量分析Haar 级联在 OpenCV 里一行就能加载不需要下载任何附加模型文件适合先把项目跑通。它最常用的两个参数是 scaleFactor 和 minNeighborsscaleFactor 控制检测时图像逐层缩放的倍率值越小检测越细但越慢常见设置为 1.1minNeighbors 控制候选框保留需要的最小邻居数量调大后误检会变少但真正偏小的人脸也可能被漏掉我一般从 5 起步。OpenCV DNN 方案的精度明显比 Haar 高尤其是侧面角度大一点、环境光线偏暗的场景。代价是要先准备两个模型文件放到工程目录路径写不对会在加载阶段直接报错。运行速度在 CPU 上仍处于可用范围这是实时脚本里最值得替换的默认方案。MTCNN 通过三级级联网络输出人脸框和五个关键点对后续做人脸对齐很有帮助但放到实时推理里 CPU 会吃不消。如果你的机器没有独立显卡我建议先把前两种方案跑通再考虑 MTCNN。2.3 表情分类模型选型7 类任务不值得一上来就搬大模型在表情识别项目里输入是灰度 48×48 小图输出只有 7 个类别。有人一上来就换 ResNet50结果模型体积大了十倍实时推理帧率被拖垮精度提升却不明显。这里的关键在于表情类别之间的差异很细微而图片本身又很小大网络的前几层感受野大部分浪费掉了。我通常用一个十几层的轻量 CNN 就够三组卷积块加上 Batch Normalization 和 Dropout最后接全连接层输出 7 类参数总量控制在百万级CPU 上单帧推理时间在几十毫秒量级。数据集规模只有三万五千张左右大模型在这个量级上很容易过拟合所谓“更高级的网络”实际收益并不稳定。如果后续确实想提升效果可以考虑 EfficientNet 这种按宽度系数缩放结构的模型但那要等基础版本跑通再说。先把数据集、训练脚本、推理脚本这条主链路打通比在第一版就追求花哨网络有价值得多。3. 从数据到实时画面FER2013 预处理、CNN 训练与 OpenCV 推理很多教程喜欢把表情识别拆成“数据集介绍”和“代码演示”两段讲但落地的痛点往往在两个模块的接缝处——标签顺序对不对、目录结构能不能被 ImageDataGenerator 直接读、模型保存后推理时类名映射还在不在。这一章按实际跑通项目的顺序来三块内容串成一条能直接跑的主线。3.1 FER2013 CSV 转目录结构标签映射不搞错后面乱不了FER2013 是表情识别最常用的公开数据集拿到手通常是一个 CSV 文件不是整理好的图片文件夹。文件里每一行包含三类信息emotion 标签、像素值字符串、Usage 字段标记是训练集还是测试集。emotion 的对应关系固定为 0angry1disgust2fear3happy4sad5surprise6neutral。像素字符串是 48×48 的灰度值用空格分隔长度是 2304。常见做法是把 CSV 按行拆分落盘成 train 和 test 两个目录每个表情一个子目录方便后面用 TensorFlow 的flow_from_directory直接读取。import csv import os import numpy as np from PIL import Image emotion_names [ angry, disgust, fear, happy, sad, surprise, neutral ] def prepare_fer2013(csv_path, out_rootdata/fer2013): # 先建好 train/test 两层目录每个表情各占一个子目录 for split in (train, test): for name in emotion_names: os.makedirs(os.path.join(out_root, split, name), exist_okTrue) # 计数器 key 是 (split, emotion_index)保证文件名不重复 counts {} for split in (train, test): for i in range(7): counts[(split, i)] 0 with open(csv_path, r) as f: reader csv.reader(f) next(reader) # 跳过 CSV 表头 for row in reader: emotion int(row[0]) pixels np.array(row[1].split(), dtypenp.uint8) pixels pixels.reshape(48, 48) usage row[2] split train if usage training else test img Image.fromarray(pixels, modeL) key (split, emotion) out_path os.path.join(out_root, split, emotion_names[emotion], f{counts[key]:05d}.jpg) img.save(out_path) counts[key] 1 # 打印每个目录的图片数用于核对数据是否完整 for split in (train, test): for i, name in enumerate(emotion_names): path os.path.join(out_root, split, name) print(f{split}/{name}: {len(os.listdir(path))}) if __name__ __main__: prepare_fer2013(fer2013.csv)这个脚本的核心是使用计数器生成五位递增文件名避免依赖os.listdir来统计文件数量因为后者在循环里反复调用会拖慢处理速度。训练集和测试集通过 Usage 字段区分public_test和private_test都归入 test 目录这是 FER2013 官方给出的两种测试划分合并到一起做验证即可。跑完后重点检查两点一是每个类比的文件数是否跟公开资料一致FER2013 中 happy 和 neutral 样本多disgust 和 fear 样本少这是正常现象二是目录名千万别拼错因为后面训练代码里类别顺序不是由代码决定而是由文件夹名称决定的。3.2 训练轻量 CNN 表情分类器网络结构、class_mapping 与三组参数训练这一步选择 TensorFlow Keras API代码量小、调试方便。网络结构就是 2.3 小节里说的轻量 CNN用ImageDataGenerator做数据归一化和增强。import json import tensorflow as tf from tensorflow.keras.models import Sequential from tensorflow.keras.layers import ( Conv2D, MaxPooling2D, Flatten, Dense, Dropout, BatchNormalization, Activation ) from tensorflow.keras.preprocessing.image import ImageDataGenerator from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint from tensorflow.keras.optimizers import Adam img_size 48 batch_size 64 epochs 30 model_path emotion_model.h5 datagen ImageDataGenerator( rescale1.0 / 255, validation_split0.1, rotation_range10, width_shift_range0.1, height_shift_range0.1, horizontal_flipTrue, fill_modenearest ) train_gen datagen.flow_from_directory( data/fer2013/train, target_size(img_size, img_size), color_modegrayscale, batch_sizebatch_size, class_modecategorical, subsettraining ) val_gen datagen.flow_from_directory( data/fer2013/train, target_size(img_size, img_size), color_modegrayscale, batch_sizebatch_size, class_modecategorical, subsetvalidation ) model Sequential() # 第一个卷积块32 个 3x3 卷积核先卷积再做 BN 和 ReLU model.add(Conv2D(32, (3, 3), input_shape(img_size, img_size, 1))) model.add(BatchNormalization()) model.add(Activation(relu)) model.add(Conv2D(32, (3, 3))) model.add(BatchNormalization()) model.add(Activation(relu)) model.add(MaxPooling2D((2, 2))) model.add(Dropout(0.25)) # 第二个卷积块64 个卷积核进一步提取特征 model.add(Conv2D(64, (3, 3))) model.add(BatchNormalization()) model.add(Activation(relu)) model.add(Conv2D(64, (3, 3))) model.add(BatchNormalization()) model.add(Activation(relu)) model.add(MaxPooling2D((2, 2))) model.add(Dropout(0.25)) # 第三个卷积块128 个卷积核控制参数量不过度膨胀 model.add(Conv2D(128, (3, 3))) model.add(BatchNormalization()) model.add(Activation(relu)) model.add(MaxPooling2D((2, 2))) model.add(Dropout(0.25)) # 分类头展平后接 128 维全连接层最后 softmax 输出 7 类 model.add(Flatten()) model.add(Dense(128)) model.add(BatchNormalization()) model.add(Activation(relu)) model.add(Dropout(0.5)) model.add(Dense(7, activationsoftmax)) model.compile( optimizerAdam(learning_rate1e-3), losscategorical_crossentropy, metrics[accuracy] ) callbacks [ EarlyStopping(patience5, restore_best_weightsTrue), ModelCheckpoint(model_path, save_best_onlyTrue) ] model.fit( train_gen, validation_dataval_gen, epochsepochs, callbackscallbacks ) # 保存训练真实使用的类别映射推理时依赖它来对齐标签 idx_to_name {v: k for k, v in train_gen.class_indices.items()} with open(class_mapping.json, w, encodingutf-8) as f: json.dump(idx_to_name, f, indent2)ImageDataGenerator里设置了四个关键参数rescale把像素值从 0 到 255 缩放到 0 到 1这是 CNN 正常收敛的前提rotation_range和两个 shift 参数控制轻微旋转和平移用于模拟摄像头画面中人脸位置的小范围浮动horizontal_flip对表情识别基本无害因为镜像后的表情语义不变。网络结构里每个卷积层后面都跟了 Batch Normalization这一点对 48×48 小图很重要。小尺寸输入更容易受内部协变量偏移影响BN 能稳定训练过程让模型不太依赖初始化参数的“运气”。最后保存class_mapping.json是我比较强调的一步。flow_from_directory会根据文件夹名称的字典序自动分配类别索引所以 angry、disgust、fear、happy、neutral、sad、surprise 这个顺序和 CSV 里的数字标签顺序并不一致。推理端如果不使用 class_mapping 去对齐预测结果会整体错位而且这种错位很难通过看准确率发现。3.3 摄像头实时推理最小可用代码与参数说明训练完拿到emotion_model.h5和class_mapping.json之后就可以写实时推理脚本了。这一版用 Haar 级联做人脸检测优点是零依赖先把全流程打通。import json import cv2 import numpy as np from tensorflow.keras.models import load_model model load_model(emotion_model.h5) # 加载训练阶段保存的类别映射而不是自己在推理端重新定义 with open(class_mapping.json, r, encodingutf-8) as f: idx_to_name json.load(f) idx_to_name {int(k): v for k, v in idx_to_name.items()} face_cascade cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_frontalface_default.xml ) cap cv2.VideoCapture(0) if not cap.isOpened(): raise SystemExit(无法打开摄像头请检查摄像头索引和系统权限) while True: ok, frame cap.read() if not ok: break gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale( gray, scaleFactor1.1, minNeighbors5, minSize(48, 48) ) for (x, y, w, h) in faces: # 裁剪、缩放并转成模型输入格式1x48x48x1 face gray[y:yh, x:xw] face cv2.resize(face, (48, 48), interpolationcv2.INTER_AREA) input_tensor face.astype(np.float32) / 255.0 input_tensor np.expand_dims(input_tensor, axis0) input_tensor np.expand_dims(input_tensor, axis-1) pred model.predict(input_tensor, verbose0)[0] pred_idx int(np.argmax(pred)) label idx_to_name[pred_idx] cv2.rectangle(frame, (x, y), (x w, y h), (0, 255, 0), 2) cv2.putText(frame, f{label} {np.max(pred):.2f}, (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) cv2.imshow(Emotion Recognition Demo, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()detectMultiScale的minSize(48, 48)必须保留。表情分类模型的输入尺寸就是 48×48如果检测框小于这个尺寸放大后的图像会严重模糊几乎不可能被正确分类。scaleFactor1.1是速度和检出率的平衡点调成 1.05 会慢很多但检出率稍微提升入门阶段不建议动。这段代码有两个地方容易被人忽略。一个是idx_to_name的转换因为 json 文件读取后 key 是字符串而np.argmax返回的是整数另一个是expand_dims的顺序原始 face 数组是 (48, 48)第一次扩维变成 (1, 48, 48)第二次扩维变成 (1, 48, 48, 1)模型接收的是“batch 大小, 高度, 宽度, 通道数”四维张量顺序错了会直接报维度错误。实时推理跑通后你会明显感觉到两个问题一是预测结果在类别之间跳来跳去二是帧率偏低。前者用第 6 章的滑动窗口解决后者可以考虑降低摄像头分辨率或每三帧做一次模型推理。4. 表情识别避坑手册五条踩坑记录现象、原因、解决一次讲清实时表情识别项目里最耗时间的往往不是训练而是部署到摄像头之后各种环境问题。这一章每一节都按“现象 → 原因 → 解决”组织是我实际折腾过程中的血泪经验新老手都能对照排查。4.1 摄像头画面黑屏但程序没报错现象cap.isOpened()返回 Truecv2.imshow弹出来的窗口全黑程序不报任何异常。原因摄像头索引不对是最常见情况。笔记本自带摄像头通常占用索引 0但外接摄像头或某些虚拟摄像头软件会抢占索引 1代码里写死 0 就会读到错误设备。另一个原因是操作系统权限macOS 和 Windows 上如果终端或 VS Code 没有相机访问权限也会黑屏但无异常。解决先写一行测试代码循环尝试索引 0 到 2打印cap.isOpened()的结果。macOS 去系统设置里给对应程序开启“相机”权限Windows 则在“隐私 - 应用权限 - 相机”里允许桌面应用访问。另外检查任务管理器是否残留了上一个 Python 进程摄像头被占用时同样会黑屏。4.2 训练集准确率高摄像头前却只预测“快乐”现象训练和验证准确率都在 80% 以上但实时推理时预测结果固定在 happy 或 neutral 这两类上其他表情几乎不出现。原因训练集 FER2013 中 happy 和 neutral 的样本量本来就远大于 disgust 和 fear模型天然偏向学这些大类。更重要的是摄像头实时采集到的人脸和训练集图片分布不一致FER2013 里的人脸是居中裁剪的而 Haar 检测框经常带着一部分背景和下巴以下区域。模型没见过这种分布最后只会按训练集中的先验概率输出。解决先检查分类映射是否对齐然后对检测框做一次“内边距收紧”也就是把(x, y, w, h)四周各向里收缩几个像素去掉多余的背景。最有效的办法是用第 5.1 小节的人脸对齐。同时打印model.predict的输出概率如果某个类概率始终接近 1说明模型根本没把特征用起来而不是某个类“识别不出来”。4.3 实时推理卡成 PPTCPU 占用 90% 以上现象窗口能显示但画面明显卡顿帧率可能不到 5 FPS。原因每一帧都执行一次model.predict而且是在主线程里同步执行。即便输入只有 48×48轻量 CNN 在 CPU 上的推理也不是零成本再加上 Haar 检测本身也要遍历多尺度图像两件事叠在一起帧率就崩了。解决最直接的办法是跳帧。检测和分类不用每帧都做把代码里的 while 循环加一个帧计数器每隔 3 帧执行一次完整推理其余帧直接显示上一帧的结果流畅度会明显提升。第二个办法是把VideoCapture的读取分辨率调低例如设置为 640×480检测耗时会更低。如果这两个都不够再考虑换 OpenCV DNN 检测或模型量化。4.4 换电脑后 OpenCV DNN 模型加载失败现象本地跑得好好的项目换一台电脑打开后cv2.dnn.readNetFromCaffe抛异常提示找不到文件或无法解析。原因路径写的是相对路径比如deploy.prototxt。换电脑后项目的当前工作目录改变了或者模型文件没有被提交进工程目录运行时自然找不到。这个问题在 VS Code 里尤其常见因为 VS Code 默认工作目录是打开文件夹的根目录而不是脚本所在目录。解决不要依赖相对路径。初始化时用os.path.join(os.path.dirname(os.path.abspath(__file__)), models, deploy.prototxt)拼出绝对路径并在加载前加os.path.exists断言。把 .prototxt 和 .caffemodel 放进工程内固定的 models 目录这样项目整体移动时只需带上整个目录结构。4.5 损失不降预测结果像在抽奖现象训练时 loss 一直在 1.9 左右晃动准确率始终在十几到二十几徘徊每个 epoch 结束后的预测结果近似随机。原因最常用的情况是数据没有归一化。模型直接收 0 到 255 的像素值梯度更新会把权重推到异常大的范围损失曲线就会抖动不下降。第二个原因是学习率设置过高例如直接把优化器换成 SGD 还保留默认学习率第三个原因是标签顺序在预处理阶段就乱了文件夹名称和 emotion 标签对应不上模型学到的是错误的监督信号。解决把rescale1.0/255写进所有ImageDataGenerator或手动把输入除以 255。优化器用 Adam 并设置learning_rate1e-3这是七分类小图任务比较稳妥的起点。再回头检查 3.1 节预处理脚本里的emotion_names顺序最好重新打印一遍每个目录的第一张图人工确认它属于对应类别。5. 把识别率从“能用”拉到“好用”对齐、学习率与数据增强的实操参数实时 demo 跑通之后准确率通常停留在“勉强能演示”水平。这一步要做的不是换更大模型而是把三个工程细节补上人脸对齐、训练超参数、数据增强与早停。本节每一项都给出可直接抄的参数。5.1 人脸对齐摄像头框出的脸和训练集的脸差一个仿射变换FER2013 训练集中的人脸基本都是居中、水平的而摄像头检测出的框很少恰好满足这两个条件。人脸倾斜超过 15 度时分类模型很容易把惊讶和恐惧搞混因为眼睛和嘴的相对位置已经变了。常见做法是检测出人脸后用关键点做对齐具体手段是取左右两眼中心计算眼睛连线与水平线的夹角再做一次仿射变换把人脸旋转到水平位置。这里给出一个基于 dlib 的对齐参考代码import cv2 import numpy as np import dlib detector dlib.get_frontal_face_detector() predictor dlib.shape_predictor(shape_predictor_68_face_landmarks.dat) def align_face(gray, rect, output_size48): shape predictor(gray, rect) coords np.array([[p.x, p.y] for p in shape.parts()]) # 第 36~47 个关键点是左右眼区域分别取均值作为眼睛中心 left_eye coords[36:42].mean(axis0) right_eye coords[42:48].mean(axis0) angle np.degrees(np.arctan2( right_eye[1] - left_eye[1], right_eye[0] - left_eye[0] )) center ((left_eye[0] right_eye[0]) / 2, (left_eye[1] right_eye[1]) / 2) matrix cv2.getRotationMatrix2D(center, angle, 1.0) aligned cv2.warpAffine(gray, matrix, (gray.shape[1], gray.shape[0])) face aligned[rect.y():rect.y()rect.height(), rect.x():rect.x()rect.width()] return cv2.resize(face, (output_size, output_size), interpolationcv2.INTER_AREA)dlib 的对齐需要额外安装依赖和下载 68 点关键点模型如果你的环境不方便还有一个退化方案检测框向外扩大 10% 再裁剪保证脸不贴边同时用 OpenCV 的minAreaRect的输出来粗略估算倾斜角度。对齐是精度上限的保障不做的话后面调参收益都很有限。5.2 学习率、batch size、epochs三个直接决定收敛效果的数字学习率决定模型能不能收敛以及收敛到哪个局部最优点。Adam(learning_rate1e-3)对这类小图任务几乎是安全牌比 SGD 稳定很多。训练到后期如果验证损失不再下降可以用ReduceLROnPlateau把学习率乘 0.5继续微调找到更低的损失点。from tensorflow.keras.callbacks import ReduceLROnPlateau lr_scheduler ReduceLROnPlateau( monitorval_loss, factor0.5, patience2, min_lr1e-5 )batch size 的搜索范围不需要太宽。48×48 灰度图的单样本开销很小64 的 batch 既能让梯度估计稳定又不会像 256 那样容易收敛到平坦但泛化差的解。如果你的训练集样本不均衡可以考虑把 batch size 调小到 32配合类别权重使用。epochs 不要拍脑袋设个 100我一般设 30 到 50然后交给 EarlyStopping 去裁断。真正值得看的指标是验证损失不是准确率。5.3 数据增强与早停用几行代码挽回一次训练翻车表情识别模型在 FER2013 上很容易过拟合因为样本只有数万张且大部分图像对比度偏低。数据增强是解决这个问题最直接的手段。我这里推荐的增强参数是rotation_range10、width_shift_range0.1、height_shift_range0.1、horizontal_flipTrue、fill_modenearest。旋转角度不超过 10 度是因为真实表情识别场景里人脸通常不会侧得太离谱过大的旋转会破坏眼睛和嘴的相对关系反而产生噪声样本。EarlyStopping 的参数用patience5意思是验证损失连续 5 个 epoch 不下降就停止训练并把权重回滚到验证损失最小的位置。这样即便你初设 epochs50实际可能只跑到十几个 epoch 就收敛了省下的时间足够你多试几组超参数。这两样东西组合在一起基本能让训练不再出现“前 10 个 epoch 看起来快成功后 10 个 epoch 又弹回去”的翻车场面。6. 进阶用 5 帧滑动窗口让表情预测稳定下来实时表情识别里最影响观感的问题是相邻两帧的预测结果会跳变同一张脸上一帧是 neutral下一帧变成 sad再下一帧又变回 neutral。这不是模型坏了而是单帧分类天然带抖动尤其是当人脸在移动、光照在变化的时候。想要输出稳定一个低成本方案是做滑动窗口投票。具体做法是维护一个固定长度的表情预测队列每帧预测完之后把结果放入队列只有队列满时才输出队列中的多数类。这里使用collections.deque和maxlen5滑动窗口大小为 5 帧兼顾稳定性和响应速度。from collections import deque window deque(maxlen5) # 在推理循环内使用 pred model.predict(input_tensor, verbose0)[0] window.append(int(np.argmax(pred))) # 窗口未填满时不输出避免前几帧的随机抖动 if len(window) window.maxlen: counts np.bincount(window) stable_idx int(np.argmax(counts)) label idx_to_name[stable_idx] # 只有稳定结果才画框和文字 cv2.rectangle(frame, (x, y), (x w, y h), (0, 255, 0), 2) cv2.putText(frame, f{label} {np.max(pred):.2f}, (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)窗口越大结果越平滑但表情切换时的反应也越慢。想要平衡的话可以在窗口内同时比较当前单帧预测和窗口众数如果某类连续出现 3 帧就提前输出不用等满 5 帧最自然的表情变化大概只需要零点几秒就能被感知。这个方案还有一个衍生用途把每 5 帧的多数类累计成统计表在会议场景下分析某人一段时间内的表情分布比直接逐帧记录日志干净得多。我在做完这个项目后有一个习惯每次改动模型或参数都会固定录一段单人正脸视频作为回归测试集保证新版本不会把 baseline 已能正确识别的表情改错。这比每次对着摄像头反复测要省事很多。希望这篇笔记能帮你把人脸识别之外的这块拼图补完整少走我踩过的那些弯路顺利把表情识别项目跑成自己想看到的样子。本文还有配套的精品资源点击获取
