简介面向深度学习与计算机视觉方向的学习者及课程设计、实验复现人员这份人脸面部表情识别项目模型文件提供了可直接加载的预训练成果重点解决表情分类模型从训练到验证的复用问题。压缩包共5个文件约317.46MB以3个pkl格式的CNN、VGG、ResNet模型权重为主体另含1份md项目说明和1个xml人脸检测配置分别对应模型结果、使用指引与人脸区域定位。已有3496人学习说明该项目在表情识别入门与对比实验中具有一定参考度。包内三种网络覆盖基础卷积结构、深层小卷积核堆叠与残差连接等典型思路可用于观察不同深度与结构对表情特征提取的影响配合Haar级联检测配置还能串联人脸检测与表情分类的完整流程。说明文档有助于理解目录组织与模型用途适合用来做模型加载验证、结构对照和实验记录整理。1. 从解压这个 zip 开始一套能直接出结果的表情识别推理链路摄像头对着工位屏幕上跳出一个绿框框底下一行小字happy 0.87。把这份模型文件解压、装好依赖之后最快十几分钟就能复现这个画面。整条链路属于深度学习里最典型的计算机视觉落地形态人脸检测负责找框卷积网络负责分类PyTorch 负责把权重跑起来。它不需要重新训练就能出结果权重已经训好7 类基础表情生气、厌恶、恐惧、开心、悲伤、惊讶、中性都能直接给概率。适合三类人做课程设计想省掉训练时间的学生、手里已经有摄像头应用想加一个表情维度的开发者、以及想拿一份干净的预训练权重当迁移学习起点的算法新手。往下按「结构长什么样、怎么跑通、怎么改」三步拆。2. 结构与技术栈PyTorch 里这套表情分类骨架是怎么搭的表情识别这个任务乍看是分类问题实际难点全在数据侧同一个人的「惊讶」和「恐惧」在低分辨率图上几乎没区别光照一变灰度分布就整体漂移。所以这类项目的模型部分通常做得很保守花活留在前处理和数据增强上。这份模型文件对应的代码结构也遵循这个思路——backbone 用的是 torchvision 里现成的骨干网改动集中在分类头、输入归一化和人脸裁剪这几处。把这几处看明白后面不管是复现训练还是换数据都不会踩到结构性的坑。2.1 backbone 与 7 类分类头模型定义和权重到底长什么样先看模型定义。表情分类的类别数固定为 7所以不管选哪个骨干网最后一步都要把原来的 1000 类全连接层替换掉。这一步写错加载权重时不会报错只会静默地把预训练特征丢掉然后你会得到一堆随机数级别的输出——这是新手最常见的翻车点之一。import torch import torch.nn as nn from torchvision import models # 7 类基本表情顺序必须和训练时一致否则标签会错位 EMOTIONS [angry, disgust, fear, happy, sad, surprise, neutral] NUM_CLASSES len(EMOTIONS) def build_model(arch: str resnet18, num_classes: int NUM_CLASSES, pretrained: bool False, dropout: float 0.3): 按名字构建分类网络返回替换过分类头的模型。 if arch resnet18: model models.resnet18(weightsDEFAULT if pretrained else None) in_features model.fc.in_features # 512 model.fc nn.Sequential( nn.Dropout(dropout), # 小数据集上防过拟合别省 nn.Linear(in_features, num_classes), ) elif arch resnet50: model models.resnet50(weightsDEFAULT if pretrained else None) in_features model.fc.in_features # 2048 model.fc nn.Sequential(nn.Dropout(dropout), nn.Linear(in_features, num_classes)) elif arch vgg16: model models.vgg16(weightsDEFAULT if pretrained else None) in_features model.classifier[6].in_features model.classifier[6] nn.Linear(in_features, num_classes) else: raise ValueError(funsupported arch: {arch}) return model if __name__ __main__: net build_model(resnet18, pretrainedFalse) print(net.fc) # 确认输出维度是 7 x torch.randn(2, 3, 224, 224) print(net(x).shape) # torch.Size([2, 7])代码里几个点值得单独说。pretrainedFalse是给「只做推理」准备的权重文件里已经包含全部参数这时候再去下载 ImageNet 预训练权重纯属浪费时间断网环境下还会直接卡住。dropout0.3加在全连接层前面是因为表情数据集普遍只有几万张量级backbone 的参数量远超数据量不加正则很容易在验证集上看到 loss 先降后升的经典过拟合曲线。model.fc换成nn.Sequential而不是单个nn.Linear是因为很多权重文件保存时就是按 Sequential 的键名存的换结构会导致load_state_dict报 unexpected keys这一点在第 5 章还会展开。拿到权重之后可以先做一次结构对账不看训练日志也能判断权重和代码是否匹配state torch.load(checkpoints/best.pth, map_locationcpu) state state.get(state_dict, state) if isinstance(state, dict) else state # 打印最后几层的键名和 build_model 里定义的 fc 结构逐一对上 for k in list(state.keys())[-4:]: print(k, tuple(state[k].shape))正常输出应该是fc.1.weight torch.Size([7, 512])这类形状。如果看到fc.weight torch.Size([7, 512])说明模型定义里不该套 Sequential如果看到[1000, 512]说明保存的是没改过分类头的原始骨干网需要自己截断重训最后一层。2.2 目录树与文件职责权重、配置、脚本各自放哪模型文件本身不构成一个能跑的项目它需要一层薄薄的工程外壳。这类项目我一般按下面的方式摆优点是推理和训练共用一个模型定义改一处两边同时生效不会出现「训练用一套、推理用另一套」的经典不一致。emotion_recognition/ ├── checkpoints/ │ ├── best.pth # 验证集最优权重推理默认加载这个 │ └── last.pth # 最后一轮断点续训用 ├── configs/ │ └── infer.yaml # 输入尺寸、均值方差、置信度阈值等 ├── src/ │ ├── model.py # build_model()训练和推理共用 │ ├── dataset.py # Dataset 与 transform 定义 │ ├── train.py # 训练入口 │ ├── infer_image.py # 单图 / 批量图片推理 │ ├── infer_camera.py # 摄像头实时推理 │ └── detect.py # 人脸检测与裁剪 ├── data/ │ ├── train/ # 按类别分子目录 │ └── val/ ├── outputs/ # 可视化结果、混淆矩阵图 ├── requirements.txt └── README.mdconfigs/infer.yaml是容易被忽略但很关键的一层。输入尺寸、归一化用的均值方差、置信度阈值这些东西训练时定死、推理时必须一模一样一旦硬编码在两个脚本里改了一处忘了另一处表现就是「训练集上准得离谱摄像头里全在乱猜」。把这些值抽成配置训练侧在train.py里写回一份推理侧直接读能省掉大量对账时间。# configs/infer.yaml input_size: 224 mean: [0.485, 0.456, 0.406] # 与训练时保持一致 std: [0.229, 0.224, 0.225] conf_threshold: 0.45 # 低于该置信度不打标签只画框 smooth_alpha: 0.6 # 摄像头模式的时序平滑系数 face_expand: 0.25 # 检测框向外扩的比例detect.py单独拆出来也有讲究。人脸检测和表情分类是两个独立的失效点前者漏检画面里根本没框后者分错框在但标签离谱。合在一个函数里排查时只能看到「没结果」拆开之后可以分别打印检测框数量和分类概率定位效率差好几倍。2.3 环境依赖与运行基线装什么、跑多快、吃多少显存依赖不多但版本要留意。PyTorch 2.x 之后torchvision.models的pretrainedTrue参数已经废弃改成weightsDEFAULT老代码直接搬过来会报错。另外 OpenCV 装opencv-python就够不需要 contrib 版本除非你要用其中的 DNN 人脸检测器。# 建议用独立环境避免和系统里的 torch 打架 conda create -n emotion python3.9 -y conda activate emotion # 按自己的 CUDA 版本选一条没有 GPU 就直接装 CPU 版 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 # CPU 机器用这条 pip install torch torchvision pip install opencv-python numpy pandas scikit-learn tqdm matplotlib pyyaml跑起来之前先确认三件事torch.cuda.is_available()的返回值、cv2.VideoCapture(0)能不能打开摄像头、权重文件的路径对不对。这三件事任何一件出问题后面的代码都没法调。性能方面给一组经验量级具体数字随硬件浮动运行方式输入尺寸batch显存占用单帧耗时笔记本 CPU224×2241不占显存3060 ms单卡 GPU224×2241约 0.8 GB5 ms 以内单卡 GPU224×224321.52.5 GB批量吞吐更高CPU 上 3060 ms 意味着一秒十几帧做摄像头 demo 勉强够用但会明显掉帧如果只是处理静态图片这个速度完全无所谓。所以先明确用途再决定要不要上 GPU别一上来就折腾驱动。提示权重文件如果是从别处拷来的先用torch.load(path, map_locationcpu)在 CPU 上探一次确认能反序列化再谈 GPU。直接map_locationcuda在没卡或者显存不够的机器上会抛一个很长的 traceback新手容易误以为是权重损坏。3. 推理链路实战一张图从进内存到输出表情概率推理这件事代码量不大但顺序错了就全错。正确的顺序是读图 → 转色彩空间 → 找人脸 → 裁剪并外扩 → resize → 归一化 → 转张量 → 前向 → softmax。中间任何一步和训练时不一致输出都会莫名其妙。下面按这个顺序拆每一段都能单独跑。3.1 加载权重state_dict 前缀、map_location 与 eval 模式加载这一步要处理三种常见形态裸的 state_dict、包在{state_dict: ...}里的检查点、以及多卡训练留下的module.前缀。写一个统一的加载函数比每次手动改代码靠谱得多。import torch def load_model(ckpt_path, archresnet18, devicecpu): 加载权重并返回处于 eval 模式的模型。 model build_model(archarch, pretrainedFalse) ckpt torch.load(ckpt_path, map_locationdevice) # 三种常见包装形态纯 state_dict / {state_dict: ...} / {model: ...} if isinstance(ckpt, dict) and state_dict in ckpt: state ckpt[state_dict] elif isinstance(ckpt, dict) and model in ckpt: state ckpt[model] else: state ckpt # 多卡训练保存的键名会带 module. 前缀单卡加载前必须剥掉 state {k.replace(module., , 1): v for k, v in state.items()} missing, unexpected model.load_state_dict(state, strictFalse) if missing: print(f[warn] missing keys: {missing[:5]} (共 {len(missing)} 个)) if unexpected: print(f[warn] unexpected keys: {unexpected[:5]} (共 {len(unexpected)} 个)) model.to(device) model.eval() # 关掉 dropout 和 BN 的滑动统计更新推理必须 return modelstrictFalse是有意为之。表情项目里分类头被改过太多次不同来源的权重键名经常对不齐用严格模式会直接抛异常反而挡住了「只缺少量键」这种可修复的情况。但strictFalse不等于可以不管missing 里如果出现fc.1.weight这种关键键说明分类头没加载上模型输出就是随机的必须回头查。model.eval()更是不能漏BN 层在训练模式下用的是当前 batch 的统计量单张图推理时等于用了一个严重失真的均值方差输出会飘得很厉害。3.2 人脸检测与对齐框放大多少、灰度图怎么补成三通道表情分类网络吃的是人脸特写不是整张照片。直接把原图 resize 到 224 塞进去背景占比一高准确率掉得比想象中狠。所以必须先检测再裁剪。import cv2 def detect_faces(frame_bgr, cascade, min_size48, expand0.25): 返回 [(人脸裁剪图, (x, y, w, h)), ...]裁剪图是 BGR。 gray cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY) gray cv2.equalizeHist(gray) # 光照不均时能救回一批漏检 boxes cascade.detectMultiScale( gray, scaleFactor1.1, # 金字塔缩放步长越小越慢越不容易漏 minNeighbors5, # 邻域阈值调大能压掉误检 minSize(min_size, min_size), # 太小的框直接丢裁出来就是马赛克 ) h, w frame_bgr.shape[:2] crops [] for (x, y, bw, bh) in boxes: # 检测框通常比真实人脸紧向外扩一点能保住下巴和额头 mx, my int(bw * expand), int(bh * expand) x1, y1 max(0, x - mx), max(0, y - my) x2, y2 min(w, x bw mx), min(h, y bh my) crops.append((frame_bgr[y1:y2, x1:x2].copy(), (x1, y1, x2 - x1, y2 - y1))) return crops def preprocess(face_bgr, size224, mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)): 把一张 BGR 人脸裁剪切转换成网络输入张量。 img cv2.cvtColor(face_bgr, cv2.COLOR_BGR2RGB) # OpenCV 是 BGR必须换 img cv2.resize(img, (size, size), interpolationcv2.INTER_AREA) img img.astype(float32) / 255.0 img (img - mean) / std # 逐通道标准化 img img.transpose(2, 0, 1) # HWC - CHW return torch.from_numpy(img.copy()).float()expand这个参数看着不起眼实际影响很大。检测器给出的框一般只覆盖眉毛到嘴角而表情的关键区域包含额头纹路和下巴轮廓扩 20%30% 是常用值。扩太多会把头发和背景带进来扩太少下巴动作就丢了。scaleFactor1.1是速度与召回的折中调到 1.05 能找到更小的脸但帧率会明显下降minNeighbors从 5 往上调能压误检代价是侧脸和遮挡场景更容易漏。灰度补三通道这件事很多人第一反应是在preprocess里判断通道数。更干净的做法是在检测阶段就把灰度图处理掉如果数据集里训练用的是灰度 48×48 放大到 224那推理时也保持灰度再转三通道别一边用彩图一边用灰度图色彩信息有没有参与训练网络的响应分布是不一样的。3.3 前向传播与 softmax置信度怎么读、阈值怎么定torch.no_grad() def predict(model, tensor, devicecpu, topk3): 输入单张 CHW 张量返回 [(表情名, 概率), ...]。 model.eval() x tensor.unsqueeze(0).to(device) # 增加 batch 维度 logits model(x) # [1, 7] prob torch.softmax(logits, dim1)[0] # 转成概率分布 conf, idx prob.topk(min(topk, prob.numel())) return [(EMOTIONS[i.item()], round(c.item(), 4)) for c, i in zip(conf, idx)]torch.no_grad()不是可选项。推理时不建计算图显存占用能降一大截速度也有提升忘了加在批量处理几百张图时会很明显。softmax 放在外面做而不是揉进模型里是为了保留 logits——后面要做温度缩放或者多模型融合时logits 比概率好用得多。置信度怎么读这里有个反直觉的点softmax 输出的绝对数值并不代表「这个模型有多确定」它只反映各类之间的相对大小。7 类模型在分布外样本比如一张卡通脸或者半边遮挡的脸上照样能输出 0.9 的 happy。所以阈值不能拍脑袋定我的习惯是拿验证集扫一遍# 在验证集上统计不同阈值下的覆盖率与准确率 for thr in [0.3, 0.4, 0.45, 0.5, 0.6]: kept [(p, y) for p, y in zip(all_conf, all_correct) if p thr] acc sum(y for _, y in kept) / max(len(kept), 1) print(fthr{thr:.2f} 覆盖率{len(kept)/len(all_conf):.2%} 准确率{acc:.2%})一般会看到这样的规律阈值从 0.3 抬到 0.6覆盖率从 95% 掉到 60%准确率只涨几个点。落到具体业务上做互动娱乐可以取低阈值宁可错也要有反馈做情绪统计报表就得取高阈值宁可这一帧不打标签也别记错数据。3.4 摄像头与批量图片帧率、跳帧和时序平滑实时推理最容易出的问题是标签在相邻帧之间来回跳。明明人一直在笑输出却在 happy 和 neutral 之间闪看着像模型坏了其实是单帧噪声。class EmaSmoother: 指数滑动平均用来压住逐帧标签抖动。 def __init__(self, n_classes, alpha0.6): self.alpha alpha self.state None self.n n_classes def update(self, prob): if self.state is None: self.state prob.clone() else: self.state self.alpha * self.state (1 - self.alpha) * prob return self.state def run_camera(model, devicecpu, cam_id0, skip1): cascade cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_frontalface_default.xml) smoother EmaSmoother(len(EMOTIONS), alpha0.6) cap cv2.VideoCapture(cam_id) frame_id 0 while True: ok, frame cap.read() if not ok: break frame_id 1 if frame_id % skip ! 0: # 跳帧检测不必每帧都做 continue for crop, (x, y, w, h) in detect_faces(frame, cascade): t preprocess(crop) name, conf predict(model, t, devicedevice, topk1)[0] prob torch.softmax(model(t.unsqueeze(0).to(device)), dim1)[0] smooth smoother.update(prob.cpu()) label EMOTIONS[int(smooth.argmax())] 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.6, (0, 255, 0), 2) cv2.imshow(emotion, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()skip是为了把检测频率降下来。人脸检测的开销往往比分类网络还大隔帧检测、中间帧沿用上一次的框肉眼看不出差别但帧率能翻倍。alpha0.6表示新观测占 40%数值越大越稳但响应越迟钝做实时互动一般取 0.50.7。另外注意上面代码里为了展示清晰把前向跑了两遍实际项目里应该改成一次前向拿 logitssoftmax 结果复用别浪费算力。4. 训练侧复现把自己的数据喂进同一套网络拿现成权重做推理只是第一步多数人最终都要换成自己的数据。训练侧的关键不在模型而在数据组织、增强策略和评估方式这三件事上。下面这套写法适用于几万张量级的数据集单卡就能跑完。4.1 数据组织与 Dataset目录式与 CSV 式两种标注公开的表情数据集一般有两种形态一种是按类别分好子目录的图片另一种是一整张 CSV 记录像素值或文件路径的。两种都支持写 Dataset 时用一个参数切换比维护两份代码省事。import os import pandas as pd from PIL import Image from torch.utils.data import Dataset class EmotionDataset(Dataset): def __init__(self, root, transformNone, modedir, csv_pathNone): self.transform transform self.samples [] if mode dir: # 目录式root/angry/xxx.jpg子目录名即类别 for label, name in enumerate(EMOTIONS): d os.path.join(root, name) if not os.path.isdir(d): continue for fn in os.listdir(d): if fn.lower().endswith((.jpg, .png, .jpeg)): self.samples.append((os.path.join(d, fn), label)) elif mode csv: # CSV 式一列路径或像素串一列标签名 df pd.read_csv(csv_path) name2id {n: i for i, n in enumerate(EMOTIONS)} for _, row in df.iterrows(): self.samples.append((row[path], name2id[row[label]])) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] img Image.open(path).convert(RGB) # 灰度图也统一补成三通道 if self.transform: img self.transform(img) return img, labelconvert(RGB)这行看着多余实际不能省。表情数据集里灰度图占比很高PIL 读进来的模式是L直接进 transform 会因为通道数不对而报错或者被悄悄当成单通道张量送进网络进而触发第一个卷积层的维度不匹配。统一转 RGB 是最省心的做法代价只是三通道冗余224×224 尺寸下内存压力可以忽略。4.2 数据增强与类别不平衡7 类表情里厌恶类永远最少7 类表情的分布从来不是均匀的厌恶和恐惧这两类的样本数往往只有开心类的三分之一。不处理的话模型会倾向于把边缘样本全判成多数类宏平均 F1 会被拉得很低。from torchvision import transforms train_tf transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p0.5), # 表情左右对称安全且有效 transforms.RandomRotation(10), # 小角度大了会切掉脸 transforms.ColorJitter(0.2, 0.2, 0.2, 0.05), # 模拟不同摄像头色彩 transforms.RandomAffine(degrees0, translate(0.05, 0.05), scale(0.95, 1.05)), # 模拟检测框的小幅偏移 transforms.ToTensor(), transforms.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)), transforms.RandomErasing(p0.25, scale(0.02, 0.1)), # 遮挡鲁棒性 ]) val_tf transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)), ])增强里有两个反直觉的地方。一是不要做垂直翻转人脸上下颠倒的样本在真实场景里不存在喂进去只会让网络学歪。二是ColorJitter的强度别开太大表情识别在低分辨率图上本来就依赖灰度纹理色彩抖动过猛会破坏这些线索模型反而更难收敛。做类别不平衡优先用带权重的采样器比直接复制少数类样本更稳import numpy as np from torch.utils.data import WeightedRandomSampler def build_sampler(dataset): labels np.array([y for _, y in dataset.samples]) counts np.bincount(labels, minlengthlen(EMOTIONS)).astype(np.float32) # 每个样本的采样权重 该类样本数的倒数 weights 1.0 / counts[labels] return WeightedRandomSampler(weightsweights.tolist(), num_sampleslen(dataset), replacementTrue)配合CrossEntropyLoss(weightclass_weights)一起用效果更好采样器保证每个 batch 里各类都出现损失权重再压一遍少数类的梯度。两个都开有时候会过度补偿导致少数类召回很高但精确率暴跌所以调完之后一定要看混淆矩阵不能只看总体准确率。4.3 训练循环与学习率调度两阶段微调怎么写表情数据集小从零训基本没戏标准做法是加载 ImageNet 预训练权重先冻结 backbone 只训分类头再解冻整体小学习率微调。def train_one_epoch(model, loader, criterion, optimizer, device, scalerNone): model.train() total_loss, correct, total 0.0, 0, 0 for imgs, labels in loader: imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad(set_to_noneTrue) with torch.autocast(device_typecuda, enabledscaler is not None): logits model(imgs) loss criterion(logits, labels) if scaler is not None: scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() else: loss.backward() optimizer.step() total_loss loss.item() * imgs.size(0) correct (logits.argmax(1) labels).sum().item() total imgs.size(0) return total_loss / total, correct / total def build_optimizer(model, lr_head1e-3, lr_backbone1e-4, stage1): stage1 只训分类头stage2 解冻全部backbone 用小学习率。 if stage 1: params [p for p in model.fc.parameters() if p.requires_grad] return torch.optim.AdamW(params, lrlr_head, weight_decay1e-4) for p in model.parameters(): p.requires_grad True return torch.optim.AdamW([ {params: model.fc.parameters(), lr: lr_head}, {params: [p for n, p in model.named_parameters() if not n.startswith(fc.)], lr: lr_backbone}, ], weight_decay1e-4)第一阶段通常跑 35 轮让随机初始化的分类头先收敛到一个合理范围否则第二阶段一开始就会用巨大的梯度把预训练特征冲坏。第二阶段接CosineAnnealingLR把学习率从 1e-4 平滑降到 1e-6label_smoothing0.1顺手加上对抑制过度自信有奇效尤其是混淆严重的恐惧/惊讶这一对。混合精度在支持 Tensor Core 的卡上能省将近一半显存但要注意autocast内部不要做 softmax 之外的复杂数值运算容易溢出成 NaN。4.4 评估与泛化混淆矩阵、宏 F1 和跨数据集掉点总体准确率在类别不平衡时基本没有参考价值。真正要看的是混淆矩阵和宏平均 F1。from sklearn.metrics import confusion_matrix, f1_score, classification_report def evaluate(model, loader, device): model.eval() y_true, y_pred [], [] with torch.no_grad(): for imgs, labels in loader: logits model(imgs.to(device)) y_pred.extend(logits.argmax(1).cpu().tolist()) y_true.extend(labels.tolist()) cm confusion_matrix(y_true, y_pred, labelslist(range(len(EMOTIONS)))) macro_f1 f1_score(y_true, y_pred, averagemacro) print(classification_report(y_true, y_pred, target_namesEMOTIONS, digits3)) print(fmacro-F1 {macro_f1:.4f}) return cm, macro_f1混淆矩阵里最常见的两处高值一处是恐惧和惊讶互相串一处是悲伤和中性互相串。前者是因为两者在低分辨率图上都是「张嘴睁眼」后者是因为平静表情本身就介于中性和轻微悲伤之间。看到这两处偏高别急着调网络先回头检查标签质量很多公开数据集的标注本身就带主观性同一个人皱眉不同标注员给出的类别可能就不一样。跨数据集评估是另一个必做项。在自己划分的验证集上跑到 70% 不算数换一批来源不同的图片不同摄像头、不同光照再测一次掉个十到二十个点很正常。这个差距才是模型进入真实场景后的真实水平也是决定要不要继续补数据的依据。5. 避坑与排查表情识别在真实画面里最容易翻车的几处下面这几条都是我在类似项目里反复撞过的按「现象、原因、解决」写遇到问题时可以直接对着查。5.1 权重加载与预处理环节现象模型跑起来了不报错但所有图的预测都是同一类概率还挺高。原因九成是预处理和训练不一致。最典型的是 BGR/RGB 搞反——OpenCV 读进来是 BGR训练时用 PIL 读的是 RGB两者不转换直接把颜色通道喂反了模型看到的是一个偏色严重的世界输出自然全塌到某一类。其次是归一化均值方差用错或者忘了除以 255。解决写一个最小验证脚本拿一张训练集里的原图分别走训练时的 transform 和推理时的 preprocess把两个张量相减取绝对值求和。数值接近 0 说明预处理对齐了差得远就逐行对比。这一步比看任何日志都快。现象load_state_dict报 unexpected keys 里全是module.xxx。原因权重是多卡训练保存的键名带了DataParallel的前缀。解决加载前统一做一次k.replace(module., , 1)。反过来如果是要在多卡上加载单卡权重用model.module.load_state_dict或者干脆先用单卡跑通再考虑多卡。5.2 推理输出与实时场景现象静态图片测着挺准一开摄像头就满屏乱跳同一个人不换表情标签每秒变好几次。原因检测框在相邻帧之间抖动裁剪出来的区域每次都不一样网络看到的人脸尺度、位置都在变。加上单帧噪声输出就成了随机数。解决三件事一起做——检测框按expand外扩留余量、对概率做 EMA 平滑、置信度低于阈值时不打标签只画框。如果人脸在画面里移动快再加一个简单的框跟踪上一帧的框与当前帧做 IoU 匹配能把抖动压到几乎看不见。现象摄像头模式下帧率只有个位数画面卡成 PPT。原因每帧都在跑人脸检测而检测的开销常常比分类网络还大另外忘了加torch.no_grad()每帧都在建计算图。解决检测隔帧做中间帧复用上一次的框推理整体包进torch.no_grad()图像送进网络前先缩到 224别拿 1080p 原图直接 resize先用 ROI 裁剪再缩放能省一大截。5.3 训练与评估环节现象训练集准确率冲到 99%验证集卡在 55% 不动。原因常见的有三种——数据泄漏同一个人的人脸同时出现在训练集和验证集、增强过猛旋转角度太大把五官切掉了、以及学习率太大把预训练特征冲毁了。解决按人划分数据集而不是按图片随机划分这是最关键的一条把旋转降到 10 度以内、去掉垂直翻转两阶段微调先冻结 backbone 训分类头。三条里第一条的收益通常最大。现象某一类的召回率接近 0混淆矩阵里整行都是 0。原因该类样本太少采样器没配损失也没加权模型干脆放弃这一类。解决先看类别计数差距超过 5 倍就上WeightedRandomSampler加带权 CrossEntropyLoss如果该类样本绝对值少于几百张考虑从同分布的其他来源补数据光靠增强撑不起来。注意排查顺序永远是「先确认输入对不对再怀疑模型」。我见过的表情识别问题里超过一半最终定位到预处理或数据标注而不是网络结构。6. 二次开发换数据、换 backbone、往端侧挪换自己的数据最省事的路径是增量微调而不是从头训。保留 backbone 权重把分类头换成自己的类别数先冻结训 3 轮再整体小学习率跑 10 轮左右。这里有个容易忽略的细节如果新数据的类别集合和原来的 7 类不同别指望旧分类头的权重能复用直接重建但如果只是类别含义变了比如把「中性」拆成「平静」和「疲惫」可以保留原分类头做初始化收敛会快不少。改造目标改动点需要注意换数据、类别不变只换 dataset 路径保持归一化参数一致换数据、类别数变化改num_classes并重建分类头旧分类头权重直接丢弃换 backbone改build_model分支输入尺寸和归一化跟着变端侧部署导出 ONNX 或 TorchScript导出后必须做数值对齐换 backbone 的时候除了改模型定义输入尺寸也得跟着改。轻量网络常用 112×112 或 96×96如果你还按 224 送进去速度优势会被吃掉一大半。经验是先固定输入尺寸再选能在该尺寸下跑到目标帧率的骨干网顺序反过来往往会发现挑中的模型根本跑不动。import torch model load_model(checkpoints/best.pth, archresnet18, devicecpu) dummy torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy, emotion.onnx, input_names[input], output_names[logits], dynamic_axes{input: {0: batch}, logits: {0: batch}}, opset_version12, ) # 导出后必须做数值对齐差值超过 1e-4 就说明有算子没对上 with torch.no_grad(): torch_out model(dummy) import onnxruntime as ort sess ort.InferenceSession(emotion.onnx, providers[CPUExecutionProvider]) onnx_out sess.run(None, {input: dummy.numpy()})[0] print(max abs diff:, abs(torch_out.numpy() - onnx_out).max())导出的坑主要在动态维度上。dynamic_axes不写导出的模型就固定 batch1端上想批量处理会直接报错opset 版本太低某些归一化算子不支持得手动拆成基础运算。还有一个隐蔽问题导出前模型必须处于eval()状态且已经to(cpu)如果还挂在 GPU 上部分算子会带着设备信息一起被序列化换机器加载就崩。端侧还有一个数值细节值得单独提移动端推理框架的卷积实现和 PyTorch 不完全一致浮点误差会在多层累积最终概率可能差几个百分点。如果端上逻辑依赖固定阈值比如大于 0.45 才打标签建议把阈值也一并移到端侧配置里用端上实测的分布重新扫一遍别直接搬桌面端的数。我踩过一次很典型的坑模型在 PC 上一切正常导到端上之后所有输入都输出同一类。查了大半天才发现是端侧预处理把 BGR 当成了 RGB还顺手把归一化的 255 除法漏了。从那以后我每接一个视觉模型都先拿三张图一张训练集原图、一张摄像头实拍、一张纯色背景跑通端到端再说别的权重、预处理、后处理三段分别留一个打印点出问题五分钟内能定位到段。希望帮到你。本文还有配套的精品资源点击获取