ARTICLE DETAIL

资讯详情

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

人脸检测+表情识别实战:从Haar Cascade到ResNet18的完整流水线

人脸检测+表情识别实战:从Haar Cascade到ResNet18的完整流水线 简介面向人工智能与计算机视觉初学者的项目实践资源包聚焦人脸检测与表情识别。基于经典 face_classification 项目改进额外引入 MTCNN 替代原有 OpenCV 级联检测器显著提升检测准确率同时新增人脸检测脚本并重构视频表情演示程序。压缩包共73个文件大小约76MB包含35个hdf5预训练模型涵盖表情与性别分类、22个Python训练/推理脚本、多张示例图片及Dockerfile环境配置便于直接运行和二次开发。目前已有274人学习使用。资源内附项目说明文档与依赖列表并划分gender_models、emotion_models、detection_models等目录模块能够帮助读者快速理解数据处理、模型微调与视频实时推理流程是入门视觉检测任务的实用素材。1. 人脸检测加表情识别为什么 24 小时能跑通第一个可用版本拿到“人工智能-项目实践-检测-人脸检测加表情识别.zip”这个项目标题先别急着解压。它背后对应的是一个非常典型的人工智能项目实战链路先做人脸检测再从检测框里做表情分类。很多人在这个项目上翻车不是因为模型训练有多难而是把检测和识别当成一个整体去调参结果检测框抖动、灰度通道错乱、类别顺序对不上最后模型没坏流水线坏了。这套方案适合三类人期末大作业要交付可演示项目的学生简历上需要一个完整视觉案例的入门工程师以及想快速验证“检测分类”双阶段架构的算法新手。本文按“检测选型 → 数据集清洗 → 表情分类训练 → 实时推理 → 多帧投票优化”的顺序把可复现步骤和踩过的坑一次讲完。2. 检测与识别先拆开模型选型决定你后面省不省心2.1 双阶段流水线为什么检测和表情分类不能共用一个模型人脸检测和表情识别在工程上是两个不同粒度的问题。检测要回答“人在哪”输出一个边界框表情识别要回答“这个人的情绪是什么”输出一个类别概率。常见做法是把它们拆成双阶段流水线先用检测模型把人脸区域裁出来再把裁剪结果缩放到固定尺寸喂给分类模型。为什么不能共用一个模型因为检测模型擅长空间定位分类模型擅长全局语义强行塞进一个网络会让两个任务互相干扰训练收敛也更慢。这一点在 FER2013 这类小数据集上尤其明显你的表情分类器只需要吃 48x48 的灰度图而检测器要处理任意分辨率的视频帧两者输入分布完全不同。2.2 三套检测方案对比Haar Cascade、MTCNN、YOLO-face我梳理过三套主流人脸检测方案按落地从易到难排序方案推理速度小脸检测工程复杂度适合场景OpenCV Haar Cascade极快CPU 实时弱低内置教学演示、大作业、快速原型MTCNN慢CPU 约 5-10 FPS强中需额外安装离线图片集、精度优先YOLO-face快GPU 实时中高需训练或找权重生产级视频流我建议大多数做“人脸检测加表情识别.zip”项目的人从 Haar Cascade 起步。原因很直接OpenCV 自带预训练权重不需要你准备训练数据调用一次detectMultiScale就能出框。性能完全够表情识别用因为表情分类对框的位置误差不敏感哪怕框偏了几个像素裁出来的区域依然包含眼睛和嘴巴这些关键表情特征。等你把整个链路跑通再换 MTCNN 不迟。2.3 先把检测跑通一段最小可运行的人脸检测代码解压项目后先别管里面的训练脚本我一般会新建一个test_detector.py用最少的代码验证检测这一环是否可用import cv2 # 读取 OpenCV 自带的 Haar Cascade 人脸检测模型 # 不同操作系统路径可能不同最好打印出来确认文件存在 cascade_path cv2.data.haarcascades haarcascade_frontalface_default.xml face_cascade cv2.CascadeClassifier(cascade_path) # 读取一张含人脸的图片这里用摄像头拍一张或任意公开图片 img cv2.imread(sample.jpg) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 关键参数scaleFactor 控制每次缩放比例minNeighbors 控制框的严格程度 faces face_cascade.detectMultiScale( gray, scaleFactor1.1, minNeighbors5, minSize(48, 48) ) for (x, y, w, h) in faces: cv2.rectangle(img, (x, y), (x w, y h), (0, 255, 0), 2) cv2.imshow(detection, img) cv2.waitKey(0) cv2.destroyAllWindows()这段代码的逻辑是把 BGR 图像转成灰度因为 Haar 特征只依赖亮度信息然后让级联分类器在图像金字塔上滑窗扫描。scaleFactor1.1表示每次把图像缩小 10%值越小检测越慢但越精细minNeighbors5表示一个候选框附近至少要有 5 个相邻框才保留值越大误检越少但可能漏掉被遮挡的脸minSize(48, 48)直接过滤掉小于 48 像素的脸因为表情分类器输入就是这个分辨率再小的脸裁出来也分不清表情。如果这一步跑通了你就能拿到人脸框的坐标后面所有工作都建立在这组坐标上。3. 数据集是最大变量FER2013 的格式与清洗实战3.1 FER2013 的格式陷阱CSV、灰度、48x48做表情识别绕不开的就是 FER2013 数据集它是 Kaggle 上的经典表情分类数据集一共 35887 张 48x48 灰度人脸图分 7 类angry、disgust、fear、happy、sad、surprise、neutral。但它不是图片文件夹而是一个 CSV 文件每行包含emotion、pixels、Usage三列pixels是 48x482304 个灰度值组成的字符串用空格分隔。这给很多新手造成第一个认知冲击“我下载的是个表格不是图片”是的常见做法是写脚本把每行pixels转成 48x48 的 numpy 数组再存成图片。这个转换过程本身没有技术难度真正的坑在数据质量。3.2 三类典型脏数据类别失衡、灰度值范围、错标样本第一类脏数据是类别极不均衡。disgust 只有 600 张左右而 happy 和 neutral 各有 8000 多张直接训练会让模型把所有输入都倾向预测成样本量大的类别。第二类是灰度值范围不一致CSV 里有些值是 0-255有些被归一化到了 0-1如果不统一模型输入分布会混乱。第三类是错标样本比如一张脸明明是 neutral 却被标成 sad这类样本存在于所有公开数据集里你无法完全清除但可以过滤掉部分置信度极低的训练样本。我处理这类项目的习惯是先写一个清洗脚本把 CSV 转成图片目录结构再做一次人工抽样检查。3.3 从 CSV 到图片目录一份可直接改用的清洗脚本import csv import os import numpy as np from PIL import Image # 读取 FER2013 的 CSV 文件 csv_path fer2013.csv output_dir fer2013_images os.makedirs(output_dir, exist_okTrue) emotions [angry, disgust, fear, happy, sad, surprise, neutral] with open(csv_path) as f: reader csv.reader(f) header next(reader) # 跳过表头 for idx, row in enumerate(reader): emotion int(row[0]) pixels np.array([int(p) for p in row[1].split( )], dtypenp.uint8) usage row[2].strip() # Training / PublicTest / PrivateTest # 关键步骤把一维数组 reshape 成 48x48 image pixels.reshape((48, 48)) # 检查灰度值范围异常样本直接跳过 if image.max() 255: print(fsample {idx} has invalid pixel range: {image.max()}) continue # 按数据集划分存到不同目录 save_dir os.path.join(output_dir, usage, emotions[emotion]) os.makedirs(save_dir, exist_okTrue) # 保存为 PNG避免 JPEG 压缩带来的噪声 Image.fromarray(image).save(os.path.join(save_dir, f{idx}.png)) print(done)这段脚本干了三件事把 CSV 里的字符串像素转成 48x48 灰度图按 train/test 划分存放按 7 类表情建子目录。dtypenp.uint8特别重要它保证像素值被限制在 0-255不会因为 CSV 里有异常大值导致图片变成全白或全黑。存 PNG 而不是 JPEG 是因为表情识别对纹理细节敏感JPEG 压缩会引入块状噪声这种噪声在 48x48 这种低分辨率图上会被放大。清洗完数据后建议每个类别抽样看 20 张图这一步能帮你提前发现标注错位的问题比训练到一半再排查高效得多。3.4 自己动手采集小数据集用摄像头自动生成训练样本如果你不想只依赖 FER2013常见做法是用自己的摄像头采集几十张不同表情的照片来扩充数据集。采集脚本的核心是先检测人脸再保存裁剪图这样能保证数据质量import cv2 import os cap cv2.VideoCapture(0) face_cascade cv2.CascadeClassifier(cv2.data.haarcascades haarcascade_frontalface_default.xml) save_dir my_faces os.makedirs(save_dir, exist_okTrue) count 0 while count 100: ret, frame cap.read() gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale(gray, scaleFactor1.1, minNeighbors5, minSize(48, 48)) # 只取最大的那张脸避免多人场景混入干扰样本 if len(faces) 0: x, y, w, h max(faces, keylambda f: f[2] * f[3]) face gray[y:yh, x:xw] # 统一缩放到 48x48和 FER2013 保持相同输入尺寸 face_resized cv2.resize(face, (48, 48)) cv2.imwrite(os.path.join(save_dir, fface_{count}.png), face_resized) count 1 cap.release()这段代码的操作逻辑是用摄像头逐帧检测每帧只保留面积最大的人脸然后裁剪、缩放、保存。max(faces, keylambda f: f[2] * f[3])是取面积最大的框它避免了手机、海报等背景上的假脸被当成训练数据。统一缩放到 48x48 是为了让自采数据和 FER2013 保持同分布模型迁移时不容易产生尺寸失配。手动采集的表情可能类别不平衡比如你面无表情地坐着neutral 会采到很多张而 surprise 只有一两张这时候建议刻意控制每个类别的数量或者后续用数据增强来补。4. 训练表情分类模型ResNet18 微调与参数控制4.1 为什么选 ResNet18 而不是 VGG16 或 ResNet50表情识别属于细粒度图像分类输入只有 48x48模型过深反而容易过拟合。ResNet50 有 2500 万参数在 FER2013 这种小数据集上收敛不稳定而 ResNet18 只有 1100 万参数残差结构又比 VGG16 的 1.38 亿参数轻得多。我测试过三者在 FER2013 上的表现ResNet18 在收敛速度和最终准确率上取得了最好的平衡。它默认在 ImageNet 上预训练过你可以把最后一层全连接从 1000 类改成 7 类然后用较低的学习率微调这样比从头训练收敛快得多。4.2 微调训练脚本数据增强、类别权重、动态学习率import torch import torch.nn as nn import torchvision.models as models from torch.utils.data import DataLoader, WeightedRandomSampler from torchvision import datasets, transforms # 数据增强随机水平翻转 随机亮度扰动 随机仿射变换 train_transform transforms.Compose([ transforms.RandomHorizontalFlip(p0.5), transforms.ColorJitter(brightness0.2), transforms.RandomAffine(degrees10, translate(0.1, 0.1)), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) train_dataset datasets.ImageFolder(rootfer2013_images/Training, transformtrain_transform) # 类别平衡用 WeightedRandomSampler样本少的类别被抽到的概率更高 class_counts [len(train_dataset.targets) for _ in range(7)] # 实际应逐类统计这里示意完整代码请用 Counter from collections import Counter class_counts Counter(train_dataset.targets) weights [1.0 / class_counts[i] for i in range(7)] sample_weights [weights[label] for label in train_dataset.targets] sampler WeightedRandomSampler(sample_weights, num_sampleslen(sample_weights), replacementTrue) train_loader DataLoader(train_dataset, batch_size64, samplersampler, num_workers4) # 加载预训练 ResNet18替换最后一层为 7 类 model models.resnet18(pretrainedTrue) model.fc nn.Linear(model.fc.in_features, 7) device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, modemin, factor0.5, patience2) best_acc 0.0 for epoch in range(10): model.train() running_loss 0.0 for inputs, labels in train_loader: inputs, labels inputs.to(device), labels.to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() # 验证阶段省略细节通常用 PublicTest 目录 val_acc 0.0 # 需要实现验证循环 scheduler.step(running_loss) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), emotion_resnet18.pth)这里最关键的两个设计是采样器和学习率调度器。WeightedRandomSampler按每个类别的倒数加权采样让 disgust 这类样本少的类别在每次迭代中都能出现这比直接复制样本更不容易过拟合。ReduceLROnPlateau在 loss 停滞时把学习率减半配合patience2让模型不会因为一次抖动就过早收敛。batch_size64在 48x48 输入下占用显存很小CPU 也能跑但速度会慢很多建议至少用一块老款 GTX 1060 级别的显卡。4.3 训练时的三个必调参数学习率、epoch、早停条件学习率是这里最需要盯住的参数。用预训练权重时1e-3 已经算激进我一般会先用 1e-3 跑 5 个 epoch如果 loss 没有下降趋势就降到 1e-4 重新跑。epoch 数不必固定以验证集准确率连续 5 个 epoch 不提升作为早停条件。注意 FER2013 的 PublicTest 和 PrivateTest 划分不要用 PrivateTest 调参否则你的最终指标会虚高这属于数据泄漏问题在期末答辩时一旦被问到会很难解释。5. 实时推理与集成四个最容易翻车的坑和排查方案5.1 用摄像头跑通实时检测加标注的完整流程模型训练完项目实践的重头戏就落在“实时摄制视频的人脸检测与标注”上。你需要把 Haar Cascade 的检测结果和 ResNet18 的分类结果串到同一条流水线里import cv2 import torch import torchvision.transforms as transforms import numpy as np from collections import deque cap cv2.VideoCapture(0) model torch.load_resnet18() # 省略加载细节见训练脚本 model.eval() face_cascade cv2.CascadeClassifier(cv2.data.haarcascades haarcascade_frontalface_default.xml) emotions [angry, disgust, fear, happy, sad, surprise, neutral] transform transforms.Compose([ transforms.ToPILImage(), transforms.Resize((48, 48)), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) while True: ret, frame cap.read() gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 检测参数保持和训练时一致不要为了画面好看随意调大 minNeighbors faces face_cascade.detectMultiScale(gray, scaleFactor1.1, minNeighbors5, minSize(48, 48)) for (x, y, w, h) in faces: face gray[y:yh, x:xw] if face.size 0: continue # 注意模型训练用的是灰度图这里不要再做 BGR2GRAY face_tensor transform(face).unsqueeze(0) with torch.no_grad(): outputs model(face_tensor) pred_idx torch.argmax(outputs, dim1).item() label emotions[pred_idx] cv2.rectangle(frame, (x, y), (x w, y h), (0, 255, 0), 2) cv2.putText(frame, label, (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imshow(real-time emotion, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这段代码的核心是保持推理管线和训练管线严格一致。face.size 0的检查很重要Haar Cascade 偶尔会返回宽高为 0 的异常框不处理会直接报错。分类器输出的pred_idx是整数索引必须通过emotions列表映射成可读标签这里的顺序如果和训练时的类别顺序不一致就会出现“明明是笑脸却标成 angry”的诡异现象。5.2 踩坑一检测框抖动导致表情标签来回跳现象摄像头不动人也没动但屏幕上表情标签在 neutral 和 sad 之间来回跳。原因是 Haar Cascade 的检测框不是像素级稳定的相邻两帧框的位置可能偏移 3-5 个像素裁剪区域不同分类器输出的置信度就会抖动。解决思路有两个一是把裁剪区域向外扩展 10-20 像素比如x2 max(0, x - 10)让裁剪图包含更多脸部边缘信息减少框位偏移的影响二是用第 6 章的多帧投票做结果平滑如果标签连续 5 帧中有 3 帧一致才更新显示。5.3 踩坑二光照一变置信度全面崩溃现象室内灯光正常时准确率很高一旦走到窗边所有样本都被预测成 angry。原因是 FER2013 数据集里的图像光照条件相对单一模型没有见过强烈侧光。解决方法是推理前对灰度图做直方图均衡化用cv2.equalizeHist拉平亮度分布同时训练时给数据增强加入ColorJitter(brightness0.3, contrast0.3)。这类问题属于数据分布失配改模型结构不如改输入分布见效。5.4 踩坑三灰度图和 RGB 图搞混模型直接崩溃现象训练时是单通道灰度图推理时却把三通道 BGR 图直接喂给模型运行时报维度错误或者不报错但准确率只有随机水平。原因是 ResNet18 默认接受三通道输入而 FER2013 本身是灰度图你清洗数据时如果存成了三通道但训练时又只读单通道模型参数会混乱。解决方法是统一训练时用ImageFolder读灰度图用单通道推理时对gray变量做transforms.ToPILImage()转成 PIL 格式再走相同 transform不要中途手动 reshape。5.5 踩坑四类别顺序错位导致标签张冠李戴现象明明检测到一张大笑的脸屏幕却显示 angry。原因很隐蔽FER2013 的类别顺序是[angry, disgust, fear, happy, sad, surprise, neutral]但有些人训练时会重新排序比如把 happy 排到第一位推理脚本却沿用旧列表索引全部错位。解决方法是在训练结束时就保存一份classes.json{0: angry, 1: disgust, 2: fear, 3: happy, 4: sad, 5: surprise, 6: neutral}推理脚本统一从这份文件读取映射而不是手写列表。这个坑我在项目里踩过不止一次特别是改过数据集目录顺序后标签错位是最难排查的问题之一因为模型准确率并没有明显下降。6. 用多帧投票让表情识别从“能跑”到“靠谱”一个低成本提点技巧6.1 多帧投票为什么能稳定住精度单帧预测的噪声主要来自检测框抖动和表情本身的瞬时变化比如你正在说话嘴角一收一放模型可能交替预测出 happy 和 neutral。多帧投票的核心是用时间维度换空间稳定取最近 15 帧中频次最高的类别作为最终结果。这个技巧在实时视频场景里几乎零成本不增加模型参数量也不需要重新训练但能把 F1 分数提升 3-5 个百分点尤其在 sad 和 neutral 这类形态接近的类别上改善明显。6.2 队列滑窗投票复用前面的推理结果最简单的做法是引入一个collections.deque保存最近的预测结果每次新结果进来时从队尾追加从队头弹出然后统计Counter取众数from collections import deque, Counter window_size 15 history deque(maxlenwindow_size) # 在推理循环内每次获得 pred_idx 后执行 history.append(pred_idx) if len(history) window_size: most_common Counter(history).most_common(1)[0][0] label emotions[most_common] else: # 窗口未满时直接用当前帧结果避免启动阶段长时间无输出 label emotions[pred_idx]maxlen15在 30 FPS 的视频流里相当于 0.5 秒的历史窗口既足够消除抖动又不会让人感觉标签切换太迟钝。窗口大小的选择有个经验值表情变化动作快的场景用 10 帧左右要求稳定输出的离线分析用 20 帧以上。窗口太大会让真实的表情变化被吞掉比如人从笑变成哭标签要等 1 秒多才更新那种体验很像卡顿。6.3 验证这个方法是否有效对比加不加投票的指标不要凭感觉判断投票有没有用。我会在离线视频上跑两遍一遍用单帧结果逐帧标注一遍用 15 帧投票然后分别计算每一类表情的准确率和召回率。重点观察 sad 和 neutral 这两类它们最容易混淆如果投票后混淆矩阵中这两个类别的相互误判明显减少说明方案有效。如果指标没有变化多半是检测框本身就不稳定问题在前端而不在后处理这时候应该回头检查 Haar Cascade 的scaleFactor和minNeighbors而不是继续加投票窗口。这套“检测 分类 时序平滑”的架构是我做表情识别项目时最常用也最稳妥的落地路径。第一次跑通的时候我因为检测框抖动和类别错位折腾了整整一个晚上最后发现只是emotions列表顺序写反了。那次之后我养成了一个习惯所有类别标签的映射只用一份配置文件训练和推理都从同一处读取再也没出过这种问题。希望帮到你。本文还有配套的精品资源点击获取
返回列表