☰
人脸表情识别模型包加载、预处理与ONNX部署实践
2026/10/28 3:31:59 网站建设 项目流程

简介:面向深度学习与计算机视觉方向的学习者,这份人脸面部表情识别项目的模型文件包提供了可直接复用的训练成果,省去从零复现网络结构与训练调参的环节。压缩包共 5 个文件,约 317.46MB,以 3 个 pkl 模型权重为主,分别对应 CNN、VGG、ResNet 三种不同深度的卷积网络,可用于对比浅层卷积与深层残差结构在表情分类上的效果差异;另有 1 个 xml 人脸检测级联文件与 1 个 md 说明文档,前者负责表情识别前的人脸定位与裁剪,后者说明项目来源、使用方式与依赖环境。已有 3496 人学习下载,说明该方案在入门实战中具备较好的参考基础。适合想快速跑通表情识别流程、比较不同骨干网络精度与推理开销,或作为课程设计、毕业设计原型参考的读者,可在其基础上继续做数据增强、迁移学习与部署测试。

1. 48×48 的灰度图能读出多少情绪:这份表情识别模型包解决的是哪一段活

有位做课堂行为分析的同行找我,说摄像头装好了,人脸的框也能画出来,却卡在最后一步——怎么把框里那张脸变成一个「高兴 / 惊讶 / 中性」的标签。这份【人脸面部表情识别项目】模型文件.zip 要解决的就是这一步:里面是已经训练好的表情分类权重,配套网络结构定义和标签映射,用 Python 和 PyTorch 写成,属于计算机视觉里最典型的细粒度分类任务。拿着它,你不用凑几十万张标注图,也不用八张卡跑三天,只要接在任意一个人脸检测器后面做推理就行。它适合两类人:想往现有产品里塞一个表情能力的一线开发,和刚学深度学习、想拆开一个真实权重看看里面长什么样的同学。不适合谁?指望把原图直接丢进去就出结果的人——预处理这一关的坑,后面会一个个说。

2. 解压先别跑推理:模型包的文件构成与加载链路拆解

拿到一个模型压缩包,很多人的第一反应是照着记忆写一段model.load_state_dict(),然后被一堆Missing key(s)和Unexpected key(s)顶回来。这个顺序其实是反的。正确的动作是先确认三件套是否齐全:权重文件、网络结构定义(或者能反推出结构的键名信息)、标签映射。三件套里缺任何一件,后面的推理都是空中楼阁。表情识别这类分类模型尤其如此,因为它的输出维度直接由标签表决定,七个类和八个类的权重混用,代码一行都不会报错,结果是全错。

2.1 用 unzip -l 先看清单,判断权重是哪一代格式

压缩包不要急着解压到当前目录,先在列表里看一遍结构,能省掉很多来回。

# 只看文件清单,不解压,先把目录形态摸清楚 unzip -l 模型文件.zip | head -50 # 确认要解压了,指定一个干净的目录,避免和现有工程混在一起 mkdir -p ./fer_pkg && unzip -q 模型文件.zip -d ./fer_pkg cd ./fer_pkg && find . -type f | sed 's|^\./||' | sort

这段命令的价值在第二步:find输出的扁平文件列表比树形结构更容易判断有没有冗余的__MACOSX目录、有没有重复的权重副本、.pth和.pt是不是各存了一份。常见的包内文件大致是下面这几类,判断标准写进表格更直观:

文件形态典型命名说明与处理方式
纯权重字典*.pth/*.pt内容是state_dict,必须自己重建网络结构再load_state_dict
完整序列化模型*.pth/*.pkltorch.save(model)保存,含结构,但强依赖保存时的类路径
结构定义脚本model.py/net.py优先用包内自带的定义,能保证键名一个不差
标签映射labels.txt/class_indices.json类别顺序的唯一权威来源,必须和输出层索引对齐
导出模型*.onnx可以直接脱离 PyTorch 跑,适合部署侧验证
训练配置config.yaml/args.json里面通常藏着输入尺寸、归一化参数,是最容易被忽略的关键文件

如果包里同时有model.py和.pth,直接照抄包内的结构定义最稳妥,别自己凭键名猜。如果只有权重文件,就得走下面这一步反推。

2.2 torch.load 的三种返回类型与 weights_only 这颗雷

torch.load返回什么,取决于保存的人当时怎么写的。同一份代码在不同版本上表现还不一样,这是近两年最容易翻车的地方。

import torch # map_location 一定要显式指定,否则在无 GPU 的机器上直接抛 RuntimeError ckpt = torch.load("weights/fer_model.pth", map_location="cpu", weights_only=False) # 三种可能的返回类型,先打印类型再决定下一步 print("type:", type(ckpt)) if isinstance(ckpt, dict): keys = list(ckpt.keys()) print("top-level keys:", keys[:8]) # 常见的包装层:{"state_dict": ..., "epoch": ..., "optimizer": ...} sd = ckpt.get("state_dict", ckpt.get("model", ckpt)) else: # 直接保存了整个 nn.Module 对象,需要能 import 到原始的类定义 sd = ckpt.state_dict() print("num tensors:", len(sd))

weights_only这个参数值得单独说。较新的 PyTorch 版本把它默认改成了True,出发点是安全,只允许反序列化张量和基础类型。如果你手上的权重是用torch.save(model)存的整对象,加载时会直接报UnpicklingError: Unsupported global。这个时候把weights_only=False打开可以解决,但前提是你信任这个文件的来源。我的习惯是:外部来的包先用weights_only=True试一次,报错了再判断是格式问题还是真的有问题,而不是一上来就关掉开关。

另一个常见现象是打印出来发现所有键名都带着module.前缀,那是训练时套了DataParallel留下的痕迹。处理方式很简单,加载前统一剥掉:

sd = {k.replace("module.", "", 1): v for k, v in sd.items()}

2.3 从 state_dict 的键名和形状反推骨干网与输入尺寸

包里没给结构定义时,键名就是唯一的线索。这一步不需要网络能跑,只需要把张量形状打印出来。

import torch ckpt = torch.load("weights/fer_model.pth", map_location="cpu", weights_only=False) sd = ckpt.get("state_dict", ckpt) sd = {k.replace("module.", "", 1): v for k, v in sd.items()} # 1) 找第一个四维卷积核,它的第二维就是输入通道数:1=灰度,3=RGB first_conv = next(k for k in sd if sd[k].dim() == 4 and k.endswith("weight")) print("first conv:", first_conv, tuple(sd[first_conv].shape)) # 2) 找最后一个二维权重,它的第一维就是类别数 fc_keys = [k for k in sd if sd[k].dim() == 2] print("last fc:", fc_keys[-1], tuple(sd[fc_keys[-1]].shape)) # 3) 打印全部键名,观察命名风格 for k in list(sd)[:40]: print(f"{k:55s} {tuple(sd[k].shape)}")

键名的命名风格基本能锁定骨干家族,下面这张对照表是我自己排查时常用的:

键名前缀特征对应骨干备注
features.0.weight+classifier.0.weightVGG 系首个卷积核多为 3×3,浅层通道 64
conv1/layer1.0.conv1…layer4ResNet 系layer4存在说明是完整四阶段
features.0.0.weight+classifier.1.weightMobileNetV2深度可分离卷积,键名带两级索引
blocks.0.0.weight/stem.convEfficientNet 系键名层级深,含_bn、_conv后缀
stages.0.blocksRegNet / ConvNeXt 系以stages分段的通常是新一些的结构

确认了输入通道是 1 之后,输入尺寸大概率是 48×48 这一档;如果第一个卷积是 7×7 且步长 2,那基本是 224×224 的 RGB 输入,属于迁移学习改出来的版本。这两类模型的预处理差异极大,搞错了准确率会掉得很均匀,让你误以为是模型不行。

提示:包内如果有args.json或config.yaml,先读它再读权重。里面通常明确写着input_size、mean、std、num_classes,比自己反推快十倍。

3. 从整张照片到 48×48 张量:检测、扩边、对齐、归一化四道工序

模型本身只认张量,不认照片。一张 1920×1080 的合影进去,第一步不是缩放,而是把人脸找出来。这一整条链路里,检测器决定召回率,扩边比例决定信息完整性,对齐决定姿态鲁棒性,归一化决定数值分布是否落在训练时的那个区间。四道工序任何一道跑偏,最终表现都是「模型不准」,但真正的问题从来不在模型上。

3.1 检测器选型:Haar、MTCNN 与单阶段检测网络的实际差别

三种方案我都用过,差别不在精度数字上,而在你能接受什么代价。

方案单帧耗时(CPU)小脸召回侧脸/遮挡依赖适用场景
Haar 级联3~8 ms差差仅 OpenCV嵌入式、对延迟极敏感
MTCNN 三段级联20~60 ms中中需要额外模型文件离线批处理、图片场景
单阶段检测网络(RetinaFace 类)15~40 ms(GPU 更快)好好需 GPU 或 ONNX 运行时视频流、复杂光照

我的实际选择是:先把检测和表情识别解耦成两个模块,接口只约定「一张 BGR 图 + 一个四点框」。这样换检测器不影响表情模型,反过来也一样。真要上视频流,检测器跑 5 帧一次、中间帧用 IoU 做跟踪补位,比死磕单帧速度划算得多。

3.2 扩边比例与五点对齐:嘴角和眉梢不能裁掉

检测框通常只框到眉毛下沿到下巴,而表情判别最依赖的两个区域——嘴角和眉梢——刚好在框边缘甚至框外。所以裁剪时要主动往外扩,scale取 1.2 到 1.4 之间。

import cv2 import numpy as np def crop_with_margin(img_bgr, box, scale=1.3): """box: (x1, y1, x2, y2),按中心外扩 scale 倍并裁成正方形""" x1, y1, x2, y2 = box cx, cy = (x1 + x2) / 2.0, (y1 + y2) / 2.0 half = max(x2 - x1, y2 - y1) * scale / 2.0 h, w = img_bgr.shape[:2] # 先裁再补边,避免越界索引 nx1, ny1 = int(round(cx - half)), int(round(cy - half)) nx2, ny2 = int(round(cx + half)), int(round(cy + half)) pad_l, pad_t = max(0, -nx1), max(0, -ny1) pad_r, pad_b = max(0, nx2 - w), max(0, ny2 - h) patch = img_bgr[max(0, ny1):min(h, ny2), max(0, nx1):min(w, nx2)] if any([pad_l, pad_t, pad_r, pad_b]): patch = cv2.copyMakeBorder(patch, pad_t, pad_b, pad_l, pad_r, cv2.BORDER_REPLICATE) return cv2.resize(patch, (int(half * 2), int(half * 2)), interpolation=cv2.INTER_AREA)

scale=1.3是经验起点,人脸占画面比例越小越该往 1.4 走;INTER_AREA用于缩小,比默认的双线性少一些混叠。补边用BORDER_REPLICATE而不是填黑,因为黑色在归一化之后是极值,容易干扰卷积响应。

如果检测器能给出五个关键点(双眼、鼻尖、双嘴角),做一次相似变换对齐收益很明显:把关键点映射到固定模板,姿态差异被压掉,模型在侧脸上的稳定性肉眼可见地变好。

# 112x112 参考模板(五点:左眼、右眼、鼻尖、左嘴角、右嘴角),按目标尺寸等比缩放 REF_112 = np.array([[38.2946, 51.6963], [73.5318, 51.5014], [56.0252, 71.7366], [41.5493, 92.3655], [70.7299, 92.2041]], dtype=np.float32) def align_face(img_bgr, pts5, out_size=112): ref = REF_112 * (out_size / 112.0) # estimateAffinePartial2D 只给旋转+缩放+平移,不会引入剪切形变 M, _ = cv2.estimateAffinePartial2D(np.asarray(pts5, dtype=np.float32), ref, method=cv2.LMEDS) if M is None: return None return cv2.warpAffine(img_bgr, M, (out_size, out_size), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)

局部仿射(estimateAffinePartial2D)比全仿射稳,因为它不允许剪切,人脸不会被拉歪。LMEDS比默认的最小二乘抗关键点噪声,即便某一个点偏了几个像素,整体变换也不会崩。

3.3 通道数与归一化:灰度、RGB 与 BGR 的三方对账

这是踩得最多、也最难自己发现的一类问题。模型的第一个卷积如果是[64, 1, 3, 3],说明训练时喂的是单通道灰度图,归一化多半是mean=0.5, std=0.5;如果是[64, 3, 7, 7],多半是 ImageNet 那套mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]。这两套不能混。

import torch from torchvision import transforms # 灰度单通道分支:48x48,[-1, 1] tf_gray = transforms.Compose([ transforms.ToPILImage(), # 输入是 numpy BGR 时,先转 RGB 再进这里 transforms.Resize((48, 48)), transforms.Grayscale(num_output_channels=1), transforms.ToTensor(), # 自动除以 255,得到 [0,1] transforms.Normalize([0.5], [0.5]), # 映射到 [-1,1] ]) # RGB 三通道分支:224x224,ImageNet 统计量 tf_rgb = transforms.Compose([ transforms.ToPILImage(), transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ])

注意 OpenCV 读进来是 BGR,ToPILImage期望的是 RGB,中间必须做一次cv2.cvtColor(img, cv2.COLOR_BGR2RGB)。这一步漏掉不会报错,但红蓝通道互换对表情分类的影响是实打实的,尤其「惊讶」和「恐惧」这种依赖嘴角与眼部细节的类别。另外ToTensor()已经做了除以 255,别在它前面再手工除一次,否则输入全落在 0.004 量级,模型的 BatchNorm 统计量完全对不上。

3.4 批量推理与吞吐:把 30ms 的单帧压到 6ms

单张推理的瓶颈往往不在卷积,而在 Python 侧的张量创建和同步。改成批处理并固定内存布局,收益通常在 3 到 5 倍。

import torch @torch.inference_mode() # 比 no_grad 更彻底,不记录版本计数 def predict_batch(model, batch_tensor, device="cuda"): model.eval() model.to(device, memory_format=torch.channels_last) # 卷积对 NHWC 更友好 x = batch_tensor.to(device, non_blocking=True).contiguous( memory_format=torch.channels_last) logits = model(x) prob = logits.softmax(dim=1) conf, idx = prob.max(dim=1) return idx.cpu().tolist(), conf.cpu().tolist()

几个参数值得解释:channels_last在 Ampere 及更新的卡上对 3×3 卷积有稳定加速,但要求模型和数据布局一致,只在数据侧改是无效的;non_blocking=True配合pin_memory的 DataLoader 才有意义,单次调用看不出差别;@torch.inference_mode()在批场景里比no_grad省下 5%~10% 的时间。真正要抠延迟,先把 batch size 从 1 调到 8 测一遍,再决定要不要上 TensorRT 或 ONNX Runtime。

4. 读对输出层:七类标签的顺序、置信度校准与稀有类补救

模型吐出的是一个长度等于类别数的向量,数值本身没有意义,配上顺序正确的标签表才有意义。这一步出错的表现特别有迷惑性:程序不崩、概率看着也像模像样,只是所有「厌恶」都被读成了「悲伤」。所以验证一个模型包,第一件事是核对标签顺序,第二件事才是看准确率。

4.1 label map 对不上,模型再准也是白搭

标签表的来源有三个:包内的labels.txt、训练脚本里的class_to_idx、以及你自己的想当然。前两个不一致时以包内文件为准,但要做一次交叉验证——用一批肉眼可判的图片跑一遍,看高置信度样本的预测是否符合直觉。如果「大笑」的图稳定被判成索引 3,而你的表里索引 3 是「愤怒」,那就是顺序错了。

import json # 优先读包内的映射文件;没有就按训练脚本里的顺序硬编码,并在注释里写明来源 with open("labels.json", "r", encoding="utf-8") as f: raw = json.load(f) # 两种常见结构:{"0": "angry", ...} 或 {"angry": 0, ...} if all(k.isdigit() for k in raw): idx2label = {int(k): v for k, v in raw.items()} else: idx2label = {v: k for k, v in raw.items()} LABELS = [idx2label[i] for i in range(len(idx2label))] print(LABELS) # 必须与模型最后一层的 out_features 长度一致 assert len(LABELS) == 7, "类别数与权重不匹配,先回去核对输出层"

assert这行不是形式主义。分类模型的输出层维度是死的,标签表长度是可变的,两者不匹配时 PyTorch 不会拦你,只会让索引错位。

4.2 softmax 概率不是置信度:温度缩放怎么拟合

很多人直接把 softmax 的最大值当置信度卡阈值,然后发现阈值 0.5 时丢掉了一大半正常帧,阈值 0.3 时又混进一堆噪声。原因是分类网络在训练充分后普遍过度自信,概率分布又尖又陡,0.9 以上的样本可能只占三成。温度缩放是最省事的校准手段,只引入一个参数。

import torch import torch.nn as nn class TemperatureScaler(nn.Module): """在 logits 上除以可学习的温度 T,T>1 让分布变平,T<1 让分布变尖""" def __init__(self): super().__init__() self.log_t = nn.Parameter(torch.zeros(1)) # 用 log 参数化保证 T 恒正 def forward(self, logits): return logits / torch.exp(self.log_t) def fit_temperature(logits_val, labels_val): scaler = TemperatureScaler() nll = nn.CrossEntropyLoss() opt = torch.optim.LBFGS([scaler.log_t], lr=0.1, max_iter=50) def closure(): opt.zero_grad() loss = nll(scaler(logits_val), labels_val) loss.backward() return loss opt.step(closure) return float(torch.exp(scaler.log_t).item())

用法是把验证集的 logits 缓存下来(注意是 logits,不是 softmax 之后的值),拟合并记下这个温度,推理时统一logits / T再 softmax。拟出来的 T 一般在 1.2 到 2.5 之间。校准之后同一个阈值才有可比性,ECE(期望校准误差)通常会从 0.1 量级降到 0.03 左右。

4.3 中性类吃掉一切:用类别先验做 logit 调整

表情数据集天然不平衡,中性、高兴两个类能占一半以上,厌恶、恐惧往往不到 5%。模型在这样的分布上训练,会形成一个「保守」的决策边界:只要特征稍微模糊,就倾向判成中性。表现就是中性类召回很高、其他类惨不忍睹。

不改权重的补救办法是在推理时加一个类别先验偏置,思路是把稀有类的 logit 抬高一点:

import torch # 训练集各类别频率,顺序必须与 LABELS 完全一致 prior = torch.tensor([0.05, 0.08, 0.42, 0.10, 0.06, 0.27, 0.02]) tau = 0.8 # 调节强度,0 等于不修正,1 等于完全按先验补齐 def adjusted_predict(logits, temperature=1.5): logits = logits / temperature logits = logits + tau * torch.log(prior) # 加 log 先验,稀有类获得加成 prob = logits.softmax(dim=1) return prob.argmax(dim=1), prob.max(dim=1).values

tau需要在自己的验证集上扫一遍,取 0.3 到 1.0 之间的值,用宏平均 F1 而不是总准确率来挑。总准确率会被中性类主导,越调越偏向多数类,这个方向感一定要有。tau调大以后整体准确率可能略微下降,但稀有类的 F1 通常能翻倍,看你的业务到底在意哪个指标。

注意:先验必须来自训练集分布,不能拿推理时的数据流去估计。线上数据分布会漂移,用线上统计反推先验,等于让模型自己给自己出题。

5. 五个把准确率打回原形的坑:现象、成因与改法

这一节是我自己在这类项目上真金白银踩出来的记录。每一条的共同特征都是:代码能跑、不报错、日志正常,唯独结果是错的。

5.1 训练用 RGB,推理用灰度:同一个模型两副面孔

现象:离线用训练集抽的样本测,准确率 65% 上下;接到摄像头实时跑,几乎所有输入都被判成中性,置信度还特别高。

原因:训练脚本里用了彩色人脸图,增强之后转成三通道;推理侧为了省事,用cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)之后复制三份当 RGB 喂进去。三通道数值完全相同,等于给模型输入了一张零色度的图,而 BatchNorm 的统计量是按真实彩色分布估的,第一层卷积的响应直接偏了一个量级。

解决:看第一个卷积的in_channels,1 就走灰度,3 就走 RGB;如果包内没有结构定义,用一张彩色图和一张灰度图分别跑,比较两者 logits 的差异,差异明显说明通道搞错了。改完记得把transforms里的Grayscale和归一化参数一起对齐,别只改一半。

5.2 人脸框贴太紧:模型看不到嘴角

现象:正脸大笑的图能判对,微笑和轻蔑这类幅度小的表情几乎全靠猜,混淆矩阵里「中性」和「高兴」互相污染。

原因:检测框是按眉毛到下巴给的,而嘴角上扬的弧度、法令纹的深浅刚好在框边缘。直接resize到 48×48,等于把最关键的判别区域切掉了。分辨率越低这个问题越严重,48×48 的时候,10% 的边缘损失意味着丢掉近一半的面部细节。

解决:裁剪时按中心外扩 1.2~1.4 倍,并把裁剪区域改成正方形(长短边取最大),避免非等比缩放把人脸拉扁。改完用同一批图重新测混淆矩阵,正常情况下「中性 / 高兴」这一对的互误会有明显下降。

5.3 视频流逐帧检测:GPU 被空转拖死

现象:单张图测试 30 ms,接到 30 fps 的视频流上,实际只能处理 12 fps,GPU 利用率却只有三成。

原因:每帧都跑一次检测加一次分类,中间还夹着 H2D 拷贝和.cpu().numpy()的隐式同步。GPU 在等 CPU,CPU 在等内存,谁都没跑满。

解决:检测降频加跟踪补位,每隔 5 帧做一次人脸检测,中间帧用上一帧的框加上简单的 IoU 匹配;分类侧攒够 8 张再一次性推理。同步点集中在批处理的头尾,中间不要出现.item()或.cpu(),那些调用会把流水线打断。改完同一个视频流的吞吐通常能翻两倍以上。

5.4 手机照片的 EXIF 方向没纠正:人脸躺着进模型

现象:从某些设备导出的照片,人眼看上去是正的,程序读进来却判得乱七八糟;把图片手动旋转 90 度再存一遍就好了。

原因:JPEG 文件里的 EXIF Orientation 标记记录了拍摄方向,cv2.imread和PIL.Image.open默认都不处理它。图像数据本身是躺着的,只是查看器帮你转了。检测器在这种图上直接失败或给出错误的框。

解决:读图后统一按 EXIF 旋转,PIL用ImageOps.exif_transpose(img),一步到位。批处理场景建议在入库阶段就把方向烧进像素、清掉 EXIF,别留到推理时再处理,否则每次都要多一次解析。

5.5 CUDA 与算子版本不匹配:导出 ONNX 时才发现

现象:.pth在本地跑得好好的,换成 ONNX 导出就报某个算子不支持,或者导出的模型在 ONNXRuntime 里输出全零。

原因:模型里用了自定义算子或较新的激活函数,opset_version设低了不认;也可能是导出时模型还在train()模式,Dropout 和 BatchNorm 行为不一致,导致输出和 PyTorch 侧对不上。

解决:导出前强制model.eval(),opset_version从 17 起试,不行再降。导出后必须做一次数值对齐——同一批输入分别喂给 PyTorch 和 ONNXRuntime,逐元素比最大绝对误差,量级应该在 1e-5 以内。超过这个量级,说明某个算子被近似实现了,得回去查。

6. 导出 ONNX 再用 ONNXRuntime 复核:一次跑通三条验证线

.pth能跑只说明 PyTorch 侧通了,部署侧还有一道关。我的习惯是拿到任何权重都先导一份 ONNX,用它做三件事:验证结构、剥离框架依赖、给非 Python 环境留条后路。这步不复杂,但能提前暴露掉八成的部署问题。

先做导出,注意两个参数:

import torch model.eval() # 必须,否则 BN 和 Dropout 行为不一致 dummy = torch.randn(1, 1, 48, 48) # 形状要和真实预处理后的张量一致 torch.onnx.export( model, dummy, "fer.onnx", input_names=["input"], output_names=["logits"], dynamic_axes={"input": {0: "batch"}, "logits": {0: "batch"}}, # 允许变 batch opset_version=17, do_constant_folding=True, # 常量折叠,减小图体积 )

dynamic_axes那行别省。省了之后模型会被钉死在 batch=1,线上想批处理就得重新导。opset_version=17是比较稳的档位,覆盖了绝大多数常见算子,再低容易出现Unsupported operator。

导出完立刻做数值对齐,这是唯一能证明导出无损的手段:

import numpy as np import onnxruntime as ort sess = ort.InferenceSession("fer.onnx", providers=["CPUExecutionProvider"]) x = torch.randn(4, 1, 48, 48) with torch.no_grad(): ref = model(x).numpy() out = sess.run(None, {"input": x.numpy()})[0] print("max abs diff:", np.abs(ref - out).max()) # 期望 < 1e-5

误差在 1e-5 量级说明对齐了;到 1e-3 就得逐个算子查,通常是某个归一化层被简化掉了。如果部署端是纯 CPU 环境,还可以顺手做一次动态量化,模型体积能压到原来的四分之一,48×48 这种小输入上的延迟收益也很直接:

from onnxruntime.quantization import quantize_dynamic, QuantType # 只量化 MatMul/Conv 的权重,激活值保持浮点,精度损失可控 quantize_dynamic("fer.onnx", "fer_int8.onnx", weight_type=QuantType.QInt8)

量化后再跑一遍上面的对齐脚本,这次看的是 int8 与 fp32 的 top-1 一致率,一般能到 99% 以上;如果掉到 95% 以下,说明这个模型对权重量化太敏感,改用静态量化或者干脆放弃。

还有一条容易被忽略的验证线:拿一段已知标签的视频,逐帧跑完整链路,统计预测的时序稳定性。表情本身有惯性,真人不会在一秒内从大笑跳到暴怒。如果输出在相邻帧之间疯狂跳变,问题多半出在检测框抖动上,而不是模型——加一个简单的滑动窗口投票,或者对框做指数平滑,抖动会明显收敛。我一般用 5 帧窗口、多数表决,延迟增加不到 200 ms,稳定性提升肉眼可见。

从那以后我每次拿到新的权重包,都会强制走一遍「标签对齐 → 通道核对 → ONNX 数值比对」这三步,哪怕对方说「这个模型我们测过没问题」。三步加起来不到二十分钟,能省掉后面几天的扯皮。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询