1. 立项思路:从一句标题到一个完整的人脸表情分类系统
做这个项目之前,我其实已经在不少人脸相关的任务上摸爬滚打过一段时间。无论是学籍照片的合规检测,还是会议室摄像头的人像追踪,最终都会落到一个非常朴素的需求上:能不能快速判断画面里这个人是不是在笑。所以当看到"Python-人脸识别并判断表情 笑脸或非笑脸"这个标题时,我第一反应就是,这本质上是一个"二分类问题"——对每一张人脸输出笑或非笑的置信度,而不是复杂到需要理解微表情层次的多分类任务。搞清楚这一点,技术选型就清晰很多了。
选择GENKI-4K数据集,是方案里最顺理成章的一步。GENKI-4K包含4000张带有表情标注的人脸图片,每张图都给出了笑脸/非笑脸的标签,同时还附带面部区域的坐标框信息。对于训练一个表情分类器来说,这个数据量的规模非常合适:不至于小到无法训练神经网络,也不至于大到需要分布式训练集群。而且标注形式统一,不像某些爬下来的数据还需要自己花一个下午去清洗。我当时的目标很明确——用这个数据集跑通一条完整链路:从数据加载、模型训练到摄像头实时推理,全部能在普通笔记本上完成。
这个项目适合谁来参考呢?如果你正在学习深度学习图像分类,想找一个既有真实数据、又有明确应用场景的练手项目;或者你已经在做人脸检测,想往表情识别方向延伸一下;又或者你只是想在工位上做一个"能自动检测笑脸的相机"用来摸鱼拍照——这篇文章都能给你一份可以直接抄作业的完整方案。
整个系统的技术路线我设计为两段式:先用OpenCV的人脸检测器从画面中找到人脸区域,再把人脸裁剪下来送进一个二分类卷积神经网络判断是否在笑。为什么不直接输入整张图片进行分类?因为自然场景中背景占比太高,模型很容易学到"背景特征"而不是"人脸特征"。做过图像分类的朋友应该深有体会,模型学歪了之后召回率再好也是虚的,换一张背景就原形毕露。
1.1 GENKI-4K数据集里到底有什么
先说数据集本身的结构。GENKI-4K解压后会得到几个关键文件:图片目录(通常是jpg格式的彩色图片,分辨率并不统一,但人脸区域基本都在画面中心附近)、一个标注文件(存放每张图片的标签和坐标信息)。标注文件的行格式大致是:第一列是笑脸标签,1表示笑,0表示非笑;后面跟着的人脸边界框坐标;最后是图片文件名。每个字段之间用空格或者制表符隔开,用Python的split()就能轻松解析。
我第一次打开标注文件的时候还犯了个低级错误,直接用pandas.read_csv()去读,结果列名乱七八糟。后来老老实实看文件头的注释才明白,这就是一个固定字段顺序的文本文件,没必要当作CSV处理。建议你拿到数据集后先花五分钟打印前几行确认格式,再决定用什么方式解析,这个习惯能省掉后面一大半的debug时间。
图片本身的质量符合"采集场景"的特点:有室内也有室外,有正脸也有轻微偏转角度,光照条件不算极端,但也没有刻意做到完美统一。4000张图里笑脸和非笑脸的比例大概在六比四到七比三之间,分布不是绝对均衡,但还没有到需要重采样的程度。训练时稍微调整一下损失函数的类别权重,或者做一点对非笑脸样本的过采样,就能把倾向性扳回来,这部分我后面会详细讲。
1.2 为什么采用"先检脸、再判笑"的两段式方案
很多初学者拿到这个任务,第一反应是直接训练一个端到端的"图片→表情"模型。我也走过这条路,结论是:可以做,但效果很不可控。你必须在训练集里手动把人脸区域手动裁剪出来,否则模型会把背景、衣服、发型这些因素混进特征里。到了推理阶段问题更大——摄像头画面里可没有人为裁剪好的标准人脸,你可能还得额外写一个滑动窗口来"找脸",这又是另外一大摊工作。
所以"先检脸、再判笑"与其说是一种选择,不如说是工程上最自然的拆解。人脸检测器负责回答"人在哪里",表情分类器负责回答"这个人笑没笑"。两个模块各司其职,可以分别替换和优化。比如我今天对OpenCV自带的检测器不满,明天就能换成基于深度学习的人脸检测模型,表情分类器一行代码都不用改。这种解耦在实践里极其重要,因为你永远无法预测生产环境里会出现什么奇怪角度的人脸。
两段式方案还有一个隐藏优点:检测器会天然过滤掉大部分非人脸区域。表情分类器只需要处理64x64或者112x112大小的人脸图,计算量非常小,在CPU上也能做到实时。这一点对最终部署到摄像头场景来说,几乎是决定性的。
2. 环境准备和数据预处理细节
这个项目的环境一点都不复杂。我的开发机就是一台普通Windows笔记本,没有独立显卡,所有训练和推理都在CPU上完成。Python版本3.9,核心依赖就四个:OpenCV(人脸检测和图像读写)、PyTorch(模型训练)、NumPy(数组操作)、Matplotlib(画训练曲线)。版本上我没有追求最新,用的都是当时稳定了好几个月的版本,避免因为API变动去改代码。OS相关的问题倒是提一句:Windows下PyTorch直接用官方pip镜像装CPU版就行,几十MB的安装包,没有CUDA也能跑得很舒服。
比较值得留意的是OpenCV的安装。如果你只是用cv2.data.haarcascades加载自带的Haar级联人脸检测器,那么基础版本就够了。但如果想用OpenCV 4.5.7以后内置的YuNet深度人脸检测模型,就得保证版本足够新。我选择了两者都试了个遍,最终留在项目里的是YuNet,原因后面第4节详细说,先在这里给你留个悬念。
2.1 依赖库选择和版本坑
具体的安装命令很好写,但我建议用一个requirements.txt锁住版本,不然过半年你再打开这个项目,发现某个库更新后接口全换了,那滋味可不好受。我当时的文件大致长这样:
opencv-python==4.8.1.78 torch==2.0.1 torchvision==0.15.2 numpy==1.24.3 matplotlib==3.7.1 scikit-learn==1.3.0这里有个细节点:torchvision的版本必须和torch配套,否则import的时候会直接报错说找不到某个符号。不要问我怎么知道的,重装了三遍才反应过来是版本错配。另一个坑是opencv-python和opencv-contrib-python不要同时装,两个包存在文件冲突,装完容易出现各种奇怪的导入错误。如果你要用到sift这类contrib里的功能再单独换,本项目不需要,所以装基础版就够了。
目录结构我习惯这样组织,清晰也方便后面扩展:
smile_detection/ dataset/ GENKI-4K/ images/ # 4000张jpg labels.txt # 标注文件 data_prepare.py # 解析+裁剪+划分数据集 model.py # CNN模型结构 train.py # 训练脚本 evaluate.py # 评估脚本 detect_camera.py # 摄像头实时检测 models/ # 保存训练好的权重2.2 标注文件解析与数据划分
GENKI-4K的标注文件我解析完之后,发现里面的信息比我想象的更有价值。除了笑脸标签,它还给了一个面部的中心坐标和边界框尺寸,这意味着我可以直接根据标注把每张图里的人脸区域裁剪出来,不需要再单独跑一次人脸检测。这给训练数据的标准化带来了极大的便利。
解析逻辑很简单,核心代码大概是这样:
import numpy as np from collections import namedtuple Sample = namedtuple('Sample', ['filename', 'label', 'bbox']) def parse_labels(label_path): samples = [] with open(label_path, 'r', encoding='utf-8') as f: for line in f: line = line.strip() if not line or line.startswith('#'): continue parts = line.split() if len(parts) < 6: continue # 按实际格式解析:label + 坐标 + 文件名 # 不同版本的GENKI文件字段顺序会有差异,务必先打印确认 label = int(parts[0]) x, y, w, h = [float(v) for v in parts[1:5]] filename = parts[5] samples.append(Sample(filename, label, (x, y, w, h))) return samples注意注释里那句"务必先打印确认",这不是废话。GENKI数据集在流传过程中存在多个版本,不同来源的标注文件字段顺序并不完全一致。有的版本第一列是人名,有的版本坐标是"中心点x 中心点y 宽 高"而不是"左上角x 左上角y 宽 高"。我吃过一次亏,坐标理解错了,裁剪出来的人脸全是偏的,训练精度怎么也上不去,最后回到原始标注仔细核对才发现是坐标系定义的问题。
数据划分我采用7:2:1的比例,训练集2800张、验证集800张、测试集400张。为什么要单留一个测试集?因为验证集参与了你调参的过程,模型在里面表现好不代表鲁棒,只有完全没参与训练的测试集才能客观反映模型的真实水平。划分的时候用sklearn.model_selection.train_test_split设置固定的random_state,这样每次跑出来的划分都一样,结果可复现。
2.3 人脸对齐裁剪的实操方法
拿到标注坐标之后,下一步是把人脸区域裁剪出来并统一缩放到网络输入尺寸。我选的输入尺寸是112x112,这个数字不是拍脑袋定的:太小的尺寸比如32x32会丢失太多纹理细节,导致笑容这种依赖嘴部轮廓的特征很难提取;太大的尺寸比如224x224又会拖慢训练和推理速度。112x112在两者之间找到了一个还不错的平衡点,实测下来CPU推理一张图也就十几毫秒。
裁剪的时候有一个容易忽略的操作:不要把坐标框直接用,建议往外扩20%再裁。因为数据集的标注框通常紧贴脸部,裁得太严会把嘴部边缘截掉一部分,而嘴部恰恰是判断笑容最重要的区域。稍微留一点余地,让模型能看到完整的嘴巴和下巴轮廓,训练效果会好不少。扩边后再做一次缩放,保证最终输入到网络里的人脸占比合理。
import cv2 def crop_face_with_margin(img, bbox, margin_ratio=0.2, target_size=(112, 112)): x, y, w, h = [int(v) for v in bbox] mx = int(w * margin_ratio) my = int(h * margin_ratio) x1 = max(0, x - mx) y1 = max(0, y - my) x2 = min(img.shape[1], x + w + mx) y2 = min(img.shape[0], y + h + my) face = img[y1:y2, x1:x2] face = cv2.resize(face, target_size, interpolation=cv2.INTER_LINEAR) return face数据增强这里我用了三个策略:随机水平翻转、小幅度的亮度抖动、±10度内的随机旋转。水平翻转对笑脸分类特别友好,因为笑容本身是左右对称的,翻转不会改变语义标签;亮度抖动可以让模型对光照变化更鲁棒;小角度旋转能模拟摄像头画面里常见的轻微偏头。我没有用更激进的增强比如随机遮挡或者色彩扭曲,因为GENKI-4K本身是真实拍摄数据、多样性已经不错,过度增强反而可能让模型学到一些不存在的伪模式。实际训练下来,这个增强组合让验证集准确率提升了约2个百分点,属于性价比很高的操作。
3. 笑脸分类模型的设计与训练
预处理做完了,接下来是项目的核心环节:训练一个能区分笑脸和非笑脸的分类模型。模型结构我选择了一个轻量级的CNN,没有在第一次迭代就搬出VGG或者ResNet这种大网络。原因很朴素:这个任务的输入是112x112的单人脸图,类别只有两个,训练数据只有几千张,大网络在这里属于杀鸡用牛刀,还容易过拟合。先跑通一个小模型,把整个流程验证顺畅,后面再根据效果决定要不要升级模型复杂度。
3.1 网络结构的选择依据
我的CNN结构经过几次调整后固定为这样一个配置:三个卷积块,每个卷积块包含"卷积→批归一化→ReLU→最大池化",卷积核数量从32逐步增加到128,最后接一个全局平均池化和全连接输出层。用批归一化而不是普通的偏置项,是因为它能让训练过程稳定很多,不会有那种loss突然飙升到nan的情况。全连接层用的是1x1卷积的等价实现,输出一个logit,配合Sigmoid变成概率。
为什么不用那些预训练好的大模型做迁移学习?我也试过用ResNet18的预训练权重做迁移,效果确实不错,验证集准确率能到95%左右,但有个问题:预训练模型是224x224输入,迁移过来还得改输入尺寸,同时模型体积大了好几倍,CPU推理速度明显变慢。考虑到这个项目的最终目标是摄像头实时检测,我更倾向于牺牲一点点准确率换取十几毫秒的推理时间。当然,如果你手里有GPU,又不care推理速度,直接用迁移学习是可取的,两条路线各有取舍。
模型定义代码,我用PyTorch写的,完整结构如下:
import torch import torch.nn as nn class SmileCNN(nn.Module): def __init__(self, num_classes=1): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Dropout(0.5), nn.Linear(128, num_classes), ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x.squeeze(-1) # 返回logit这个结构的参数量大约在30万左右,和真正的大模型比完全是轻量级。训练一轮(2800张图)在我的CPU上大概需要一分钟,非常舒服,我可以一边跑训练一边在旁边改代码,调试节奏很快。
3.2 训练配置细节
训练超参数的选择,我踩过几次坑,最后固定下来的是:优化器Adam,初始学习率0.001,批次大小64,训练30个epoch。损失函数用BCEWithLogitsLoss,因为这里是一个二分类问题,输出logit而不是概率更稳定。类别不均衡的问题我用了一个很简单的处理方式:在损失函数的构造函数里传入正负样本权重。计算方式就是对非笑样本的loss乘一个稍大的权重,数值上我用的是正类权重0.8、负类权重1.2,意思就是让模型更在意"非笑"类别的分类错误。
学习率方面我加了一个余弦退火调度器,每10个epoch让学习率下降一个数量级,这样到训练后期参数能收敛得更精细。如果你嫌麻烦,固定学习率也不是不行,但实测下来cosine调度大概能再提升1个点左右的准确率,值得写上去。代码不长:
optimizer = torch.optim.Adam(model.parameters(), lr=0.001) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30)训练循环的框架没有什么特殊的,关键在于每个epoch结束都要在验证集上跑一次评估,并保存验证集准确率最高的权重。很多人只保存最后一个epoch的模型,这样其实是亏的——最后几个epoch模型往往已经在过拟合的边缘,验证集最好的那一刻通常出现在更早的位置。我这边第26个epoch达到了验证集最高准确率93.6%,如果只保留最后一个epoch,就只能拿到92.1%的权重,白白损失1.5个百分点的精度。
3.3 评估指标与结果分析
模型训练完之后,不能只看准确率就完事,一定要把精确率、召回率、混淆矩阵都拉出来看一遍。因为"笑"和"非笑"在成本上并不对等:如果一个系统是"笑脸快门",那么把非笑判断成笑的代价是拍了一张不好看的照片;但如果是"疲劳检测"之类的场景,把非笑判断成笑可能会漏掉真正重要的信息。不同的应用场景,对错误的容忍度完全不同。
我的测试集评估结果大概是这样:
| 指标 | 数值 |
|---|---|
| 准确率 | 92.6% |
| 精确率(笑) | 94.1% |
| 召回率(笑) | 92.8% |
| F1分数 | 93.4% |
| AUC | 0.971 |
从混淆矩阵看,模型的主要错误是"把部分轻微的、不明显的笑容判断成了非笑",而不是"把面无表情判成笑"。这个方向在我看来是健康的:因为轻微笑容本来就存在标注模糊性,不同的人对"这算不算笑"可能有完全不同的判断,模型倾向于保守反而更符合"笑脸检测"的场景预期——至少拍出来的照片不会因为误判而尴尬。
我还画了训练过程中的loss曲线和准确率曲线,观察到一个有意思的现象:训练集loss持续下降,验证集loss在第28个epoch左右开始小幅回升,说明模型进入了过拟合区间。好在由于我们每个epoch都做了验证,最终保存的权重避开了这个区间。这也是我建议所有做图像分类的朋友都保留的一个习惯。
4. 接入摄像头实现实时笑脸检测
模型训练好了,接下来是最有成就感的环节:打开摄像头,看着画面上实时框出的人脸旁边显示"Smile"或者"No Smile"。这一步的重点在于把训练好的模型和OpenCV的视频流无缝集成,同时保证帧率能接受。如果你的笔记本摄像头是720p,处理每帧的画面大概需要30-50毫秒,再加上模型推理的十几毫秒,整体能达到15帧每秒左右,对于看个笑脸效果来说完全够用。
4.1 实时检测主循环代码
整个实时代码的核心就是一个while循环,重复着"抓帧→检测人脸→裁剪→分类→画框"这几件事。人脸检测环节我最后用的是OpenCV的YuNet模型,也就是cv2.FaceDetectorYN,因为它比Haar级联更准,对侧脸和暗光场景的鲁棒性更好。YuNet是OpenCV官方维护的深度学习人脸检测器,模型文件是一个ONNX格式,可以用cv2.FaceDetectorYN.create()加载。
提到Haar级联,倒不是说它一无是处。Haar的优点是速度极快、不需要额外的模型文件,缺点是对角度和光照的变化非常敏感,稍微侧个脸就漏检。在GENKI-4K这种以正脸为主的数据集上,Haar的表现还行,但到了真实的摄像头画面里,你会看到人脸框在脸上疯狂跳动,那体验就很糟糕了。换上YuNet之后框的位置稳定了很多,误检也减少了。如果你的OpenCV版本太老不支持YuNet,直接升级版本就行,不需要额外装东西。
import cv2 import torch import numpy as np from model import SmileCNN detector = cv2.FaceDetectorYN.create( "face_detection_yunet_2023mar.onnx", "", (320, 320), 0.6, # 置信度阈值 0.3, # NMS阈值 5000 # 最大检测数量 ) model = SmileCNN() model.load_state_dict(torch.load("best_model.pt", map_location="cpu")) model.eval() cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) while True: ok, frame = cap.read() if not ok: break h, w = frame.shape[:2] detector.setInputSize((w, h)) faces = detector.detect(frame)[1] if faces is not None: for face in faces: x, y, fw, fh = face[:4].astype(int) # 扩边并保证不越界 mx, my = int(fw * 0.1), int(fh * 0.1) x1, y1 = max(0, x - mx), max(0, y - my) x2, y2 = min(w, x + fw + mx), min(h, y + fh + my) face_img = frame[y1:y2, x1:x2] face_img = cv2.resize(face_img, (112, 112)) face_img = face_img.astype(np.float32) / 255.0 face_img = np.transpose(face_img, (2, 0, 1)) # HWC转CHW face_tensor = torch.from_numpy(face_img).unsqueeze(0) with torch.no_grad(): logit = model(face_tensor) prob = torch.sigmoid(logit).item() label = "Smile" if prob > 0.5 else "No Smile" color = (0, 255, 0) if prob > 0.5 else (0, 0, 255) cv2.rectangle(frame, (x, y), (x + fw, y + fh), color, 2) cv2.putText(frame, f"{label} {prob:.2f}", (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2) cv2.imshow("Smile Detection", frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()代码里有一个细节值得注意:detector.detect(frame)返回的faces数组,每一行除了坐标之外还包含关键点信息,如果你的应用后续需要做眼睛或者嘴巴的定位,这些关键点可以直接拿出来用。我不需要关键点,所以只取了前四个坐标值。
推理时别忘了加torch.no_grad(),这个上下文管理器能显著减少内存占用和推理耗时。如果忘了加,每次前向传播都会建立一次计算图,积少成多,运行几分钟之后内存就开始明显膨胀了。
4.2 帧率优化与稳定性处理
实测我的笔记本CPU跑这个代码,640x480的画面帧率稳定在12-15帧每秒左右。如果你觉得卡,有几个非常有效的优化手段。
第一个是缩小检测输入的分辨率。YuNet的setInputSize不需要和原图一样大,我可以设置成320x240甚至是160x120,检测精度会损失一点,但速度提升非常明显。实时场景里画面本来就一直在动,人脸的姿态变化很快,稍微牺牲一点检测精度来换取更流畅的体验是值得的。第二个优化是跳帧检测:每两帧做一次人脸检测和分类,中间那一帧只显示上一个人的结果。人脸在一个连续的30帧画面里位置变化不会很大,这种策略几乎不影响观感,但速度几乎能翻倍。第三个是在最终画面输出前把整个帧缩放到合适大小再显示,imshow的窗口渲染在高分辨率下也是开销大户。
稳定性方面遇到过一个很实际的问题:人脸框会抖动。尤其当人的头部微微晃动或者光线变化时,同一个人的检测框坐标在相邻帧之间会跳来跳去。虽然不至于影响分类结果,但视觉观感不太好。我没引入复杂的追踪算法,而是做了一个简易的平滑:维护一个"上一帧框"的全局变量,如果当前帧的人脸框和上一帧的人脸框面积重叠率超过70%,就用两者的加权平均作为当前帧的输出框坐标。这样框的移动变得顺滑了不少,代码也就十几行。
如果你现场测出来的分类效果不理想,先别急着怀疑模型。先看一眼检测框是否准确覆盖了整张脸。很多时候是扩边比例不合适,裁掉了下巴或者额头,导致模型看到了一个"不完整的人脸"。把扩边参数从0.1调到0.2,往往就解决了。
5. 完成这个项目我踩过的坑
项目做完以后,我复盘了一下整个过程中最折腾我的几个问题,每一个都是真实的教训,写出来给大家避避雷。
5.1 数据集分布失衡带来的训练陷阱
GENKI-4K虽然整体质量不错,但它的笑脸和非笑脸比例不是完美的五五开。我一开始没有做任何处理直接训练,发现模型在验证集上的准确率虽然不低,但看混淆矩阵就露馅了:非笑类别的召回率明显偏低,也就是模型把很多"非笑"判断成了"笑"。原因很简单,训练集里笑脸多一些,模型往"倾向于输出笑"的方向走,会让整体准确率看起来更高,但这显然不是我们想要的行为。
解决办法我在第3.2节提到过,给非笑类别的loss加上权重。还有一个附带的好处:加了权重之后,训练的收敛过程会变得平滑一些,不会出现那种最后几个epoch在过拟合边缘反复横跳的情况。如果你的训练数据类别差距更大,比如九比一,那光靠权重可能还不够,得配合过采样或者对多数类做下采样,双管齐下才稳。
5.2 光照和角度导致的误判场景
摄像头实测最大的敌人是光线。我在晚上开着台灯测试的时候,发现一个规律:当人脸处于强背光状态时,整张脸显得很暗,模型几乎会把所有人都判定为非笑。原因不复杂,训练集里的图片即使有光照变化,也很少出现这种极端背光的情况。模型学到的"笑"特征需要看到清晰的嘴部轮廓,而背光环境下嘴部细节完全消失在阴影里了。
解决办法有两个方向,一个是在数据增强里加入更极端的亮度降低,让模型见过"暗光下的人脸"长什么样;另一个是在推理阶段做人脸区域的亮度归一化,我试过直方图均衡化,效果还可以但会改变肤色和纹理,导致模型在其他场景下表现变差。综合权衡我最后选择的是先把图像转到YCrCb空间,对Y通道单独做轻度自适应直方图均衡,再转回BGR。这一步让夜间测试的准确率从70%不到提升到了85%上下,还是值得写的。
角度方面,YuNet虽然对侧脸比Haar好很多,但超过60度的极端侧脸依然会漏检。这个说实话不好从模型侧解决,除非你去换一个更大的人脸关键点检测模型做姿态矫正,那样复杂度就上去了。我的建议是,承认这个系统的边界——它适合"人在摄像头正前方"的场景,比如拍照、视频会议,而不适合做旷野里的人群扫描。控制好使用场景预期,比在代码里硬扛要实在得多。
5.3 过拟合与泛化能力之间的平衡
训练集只有4000张,这个规模对深度学习模型来说确实不算大。我第一次训练跑了60个epoch,验证集准确率在第32个epoch到顶,之后一直在下降,这就是典型的过拟合信号。过拟合的模型在测试集上表现还不错,但一旦遇到和训练集分布差异明显的真实世界图片,准确率会断崖式下跌。
我在控制过拟合上用了三板斧:数据增强、Dropout、早停。数据增强在前面说过了,Dropout加在全连接层之前,概率设0.5,训练时随机丢一半的神经元,相当于同时训练了很多个"瘦身版"网络,最后融合的效果能显著提升泛化能力。早停则是配合验证集评估一起用的,连续6个epoch验证集准确率不再上升就停止训练,并回退到历史最优权重。
这三板斧组合下来,我的模型在完全没见过的测试集上准确率从89%提升到了93%左右,效果非常明显。如果你后续想把模型拿到别的数据集上测试,我建议你把增强策略再加一个"随机仿射变换",模拟更多不同的人脸姿态,虽然训练时间会变长,但泛化能力还能再上一个台阶。
6. 后续还能怎么扩展
项目到这里已经完整跑通了,但说实话,这套方案的潜力远不止于"判断笑没笑"这么简单。从工程角度稍微延伸一下,就能做几个很有意思的事情。
第一个想法是把二分类升级成多分类。表情本来就不止两种状态:平静、微笑、大笑、皱眉、惊讶,每一样都有实际应用价值。只需要把数据集换成类似情绪分类数据,输出层改成多类Softmax,其余代码几乎不用动。这个升级对学习曲线非常友好,你在二分类上积累的调试经验全部可以复用。
第二个想法是做成"笑脸快门"。现在很多手机相机都有这个功能:检测到所有人都在微笑的时候自动按下快门。用现在的实时检测框架,加一个简单的状态机就能实现——统计画面里所有人脸的表情,如果全部是笑脸且持续两秒,就触发拍照事件。这种小交互功能做出来以后,拿去玩家庭聚会或者朋友合影会非常有意思。
第三个想法是模型压缩。如果你未来想把这个功能搬到树莓派或者手机上,当前的CNN虽然小但还是有压缩空间。可以试一下量化:PyTorch自带的torch.quantization可以把模型权重从32位浮点压到8位整数,推理速度能再快两三倍。量化之后模型文件只有几百KB,部署起来非常灵活。我实测过量化不损失太多精度,验证集准确率从93%掉到91%左右,属于可以接受的范围内。
我个人实际使用这套系统时最舒服的一个场景,其实是拿它当"表情监视器"写代码时用的——写几行代码看一眼屏幕,确认一下自己当时的表情是不是过于狰狞,方便调整心情。这种稀奇古怪的应用方式,也算是对整个人脸识别技术落地的另一种诠释吧。如果你能把这套流程跑通,再往里面塞任何你想做的表情相关功能,都只是水到渠成的事。