2026/10/4 22:19:03

Python人脸表情识别全流程:数据准备、CNN训练到ONNX部署

Python人脸表情识别全流程:数据准备、CNN训练到ONNX部署 简介这是一套基于Python实现的人脸表情识别项目资源面向具备Python基础、希望深入计算机视觉与后端开发场景的开发者。项目围绕人脸68个关键点定位展开涵盖眼睛、眉毛、鼻子、嘴唇等部位的特征提取这些关键点的准确检测是表情分析和三维面部建模的基石。资源包共含2个文件一个预训练模型文件与一个Python源码脚本整体体积约68.27MB模型文件可直接加载用于面部关键点检测的权重参数源码演示了OpenCV和dlib库的联合使用流程包括图像灰度化预处理、人脸检测、68点标定及关键点可视化等步骤。目前已有790人学习下载能帮助初学者快速走通模型加载、图像处理、特征提取的完整链路。通过研读该项目开发者还能理解后端服务如何接收客户端上传的面部图像、调用模型进行推理并以结构化数据返回结果从而为情感分析、智能监控、人机交互等更复杂的人工智能应用打下扎实基础。1. 人脸表情识别先别急着训模型把这条链路想清楚人脸表情识别的教程和开源项目都不少但多数人的卡点不在模型本身而在环境、数据和推理链路Python 版本不匹配、cv2 装不上、数据集格式没整理好好不容易训完模型一开摄像头结果全是 neutral。这套 python 人脸表情识别资源把整条链路串起来了——环境搭建、FER2013 数据组织、人脸检测与裁剪、CNN 与迁移学习训练、ONNX 导出最后接到 Python 后端接口。适合做课程设计的学生也适合想把表情识别接进现有系统的后端工程师。先给三个结论后面每一章都会落到具体代码和参数这个任务的上限在数据质量不在网络设计FER2013 的标签本身有噪声验证集数字看看就好实时识别掉帧八成是预处理链路写得糙不是算力不够。想省时间的话直接按第 2 章把环境装干净再顺着章节往下走。2. 环境与依赖Python 版本怎么选cv2 装不上怎么办2.1 版本选型3.83.10 是这个项目的甜点区在敲 pip install 之前先把 Python 解释器版本定下来。这套资源里的 PyTorch、onnxruntime、opencv-python 三个核心包对 3.8 到 3.10 都有预编译好的 wheel装的过程就是下载加解压。如果图新鲜用了 3.12 甚至更新的版本部分依赖还没跟上pip 会现场编译源码轻则多等十几分钟重则直接报 MSVC 运行库缺失或者编译失败退出。这不是什么高深原理就是 wheel 发布时间差导致的。Windows 上常见的安装路径是去 python 官网下安装包记得勾选 Add python.exe to PATH否则后面在终端敲 python 会提示命令不存在。日常开发我更建议用 Anaconda 建独立环境一条命令就得到一个干净的 python 3.10conda create -n fer python3.10 -y conda activate ferVSCode 用户按 CtrlShiftP 打开命令面板选择 Python: Select Interpreter把解释器指到刚建的 fer 环境再开终端就会看到前缀变成 (fer)。这是 vscode python 环境配置里最容易漏的一步很多人代码写完了才发现跑的是系统自带的旧版本解释器import 报错找半天都找不对方向。提示先跑通 2.3 的检测脚本再进第 3 章很多人跳过这步后回头排查了半天。3.8 也能跑和 3.10 区别不大只是 torch 从 2.2 版本开始不再支持 3.8如果之后想升级依赖直接上 3.10 更省事。项目根目录我一般会放一个 README第一行就写清楚 python 3.10方便别人 clone 下来照着搭。2.2 依赖清单固定版本别全部都上最新把下面的内容存成 requirements.txtopencv-python4.8.1.78 numpy1.26.4 torch2.1.2 torchvision0.16.2 pandas2.1.4 scikit-learn1.3.2 onnx1.15.0 onnxruntime1.16.3 fastapi0.109.2 uvicorn0.27.1 python-multipart0.0.6这份清单里两个位置最容易翻车。第一numpy 锁在 1.26.4 而不是最新版因为 torch 2.1 系列是在 numpy 1.x 时代编译的装上 numpy 2.x 后 import 阶段会报警告甚至直接崩溃后面 5.4 还会细说。第二opencv-python 不能和 opencv-contrib-python 同时存在两个包装了同一个库的不同模块表现是 import cv2 时报段错误排查起来毫无头绪。安装执行pip install -r requirements.txt python -c import cv2, torch, numpy; print(cv2.__version__, torch.__version__, numpy.__version__)下载慢的话在命令末尾加-i https://pypi.tuna.tsinghua.edu.cn/simple换成清华镜像。第二条命令的目的是在一行代码逻辑都没写之前先确认三个最关键的包能正常 import。这一步通过了再往下走后面排错的范围会小很多。2.3 首测脚本先跑通人脸检测再谈模型环境装完别急着解压数据集先用一段最小脚本验证人脸检测链路。opencv-python 自带 Haar 级联分类器不需要额外下载模型文件适合当第一块探路石import cv2 cascade cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_frontalface_default.xml ) img cv2.imread(test.jpg) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces 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) print(detected, len(faces), faces) cv2.imwrite(test_out.jpg, img)scaleFactor1.1 表示每次缩放 10%数值越小检测越精细但越慢minNeighbors5 控制候选框保留的严格程度调太大漏检、太小误检minSize(48, 48) 直接滤掉小于 48 像素的框和后面网络的输入尺寸对齐。cv2 默认按 BGR 读图画框时直接用原图坐标就行。这段代码输出 detected 1 faces 以上就说明 Python、OpenCV 和图片读取链路都是通的。Haar 对侧脸和暗光确实不行第 3 章会换成 OpenCV DNN 的 SSD 检测头但作为第一步验证它零配置、不需要管模型文件路径是最合适的起点。3. 数据与预处理FER2013 的目录结构、人脸裁剪与增强参数3.1 先看数据FER2013 的标签分布决定了训练策略FER2013 是表情识别里最常用的公开数据集一张 CSV 包含三列emotion 标签、pixels 像素串、Usage 用途标记。像素串是 48x48 灰度图按行展开的 2304 个 0~255 数值标签 0 到 6 对应 angry、disgust、fear、happy、sad、surprise、neutral 七类。按 Usage 拆成训练、验证、测试三个子集是训练前的第一件事。七类样本量分布很不均匀这份资源里的数据整理脚本会先打印统计结果类别训练集样本数angry约 4953disgust约 547fear约 5121happy约 8989sad约 6077surprise约 4002neutral约 6198disgust 只有 547 张不到 happy 的零头。如果直接拿原始数据训练网络会牺牲样本少的类别来压低整体 loss。后面 3.3 的增强参数和 4.3 的类别权重都是针对这个数字做的补偿所以数据统计不能跳过。拆分脚本import pandas as pd import numpy as np import cv2 from pathlib import Path EMOTIONS [angry, disgust, fear, happy, sad, surprise, neutral] df pd.read_csv(data/fer2013.csv) usage_map {Training: train, PublicTest: val, PrivateTest: test} for _, row in df.iterrows(): split usage_map[row[Usage]] label EMOTIONS[int(row[emotion])] out_dir Path(data/fer) / split / label out_dir.mkdir(parentsTrue, exist_okTrue) pixels np.array(row[pixels].split(), dtypenp.uint8).reshape(48, 48) img np.stack([pixels] * 3, axis-1) # 灰度图堆成三通道存储 cv2.imwrite(str(out_dir / f{row.name}.jpg), img)pixels.split() 把逗号分隔的像素串转成 numpy 数组reshape 成 48x48 后堆成三通道是为了后面用 cv2 读图、用 torchvision 的 ToPILImage 处理时少一步兼容问题。row.name 是 DataFrame 的行号用作文件名不会重名。跑完得到 data/fer/train/angry/0.jpg 这样的目录结构训练脚本按目录名取标签。3.2 人脸裁剪与对齐整图送进网络是浪费算力FER2013 已经把人脸裁剪好、标准化到 48x48但摄像头或手机拍的照片是整张图。如果整图直接缩放送进分类网络背景占掉大量像素表情主体的特征被稀释准确率掉得很明显。常见做法是先做人脸检测拿到框再按框裁剪出人脸区域喂给分类器。裁剪要留一点边距把额头和下巴包进来。FER2013 训练时人脸几乎填满画面推理时如果裁剪太紧或者太松都会偏离训练分布。我一般留 20% 的边距def crop_face(img, face_box, margin0.2): x, y, w, h face_box m int(max(w, h) * margin) x1, y1 max(x - m, 0), max(y - m, 0) x2, y2 min(x w m, img.shape[1]), min(y h m, img.shape[0]) gray cv2.cvtColor(img[y1:y2, x1:x2], cv2.COLOR_BGR2GRAY) return cv2.resize(gray, (48, 48))margin 用比例而不是固定像素是因为不同分辨率下人脸尺寸差异很大固定像素在小图上会被放大成三分之一的背景。框的边界做了 clamp防止裁切区域越界抛 index out of range。人脸对齐根据两眼位置旋转图片在专业项目里值得做能明显改善侧脸识别但这套流程里我把它标为可选FER2013 本身是正面居中的对齐对这个数据集的提升有限反而多一个出错点。先跑通端到端流程再加对齐是我一贯的顺序。检测部分正式项目建议换成 OpenCV DNN 的 res10 SSD本地没有模型文件的话先下载好再放到 models 目录Haar 只用来做链路验证。3.3 数据增强参数怎么设才不把表情搞变形针对 3.1 的样本不平衡和摄像头图像的分布偏移数据增强是投入产出比最高的一步。核心原则是增强幅度必须保持表情语义不变旋转 30 度可能还是人脸但嘴型和眉毛角度已经失真模型会学歪。from torchvision import transforms train_tf transforms.Compose([ transforms.ToPILImage(), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(10), transforms.RandomAffine(degrees0, translate(0.05, 0.05)), transforms.ToTensor(), transforms.Normalize([0.5], [0.5]), ]) val_tf transforms.Compose([ transforms.ToPILImage(), transforms.ToTensor(), transforms.Normalize([0.5], [0.5]), ])水平翻转 p0.5 是最安全的增强表情左右对称翻过去语义不变rotation 限制在 ±10 度超过 15 度眉毛和嘴型就开始扭曲translate 5% 模拟摄像头手持抖动。验证集只做 ToTensor 和 Normalize绝不做随机增强否则验证指标被平滑得失去参考价值。Normalize([0.5], [0.5]) 把输入从 [0,1] 映射到 [-1,1]这是训练时必须记住的分布。推理阶段如果忘了这一步直接拿 0~255 的像素进网络相当于把输入分布整体偏移准确率会掉几个百分点。这也是很多训练结果好、部署就拉胯案例里最常见的根因第 5 章会专门讲。4. 模型训练从基线 CNN 到 ResNet18 迁移的完整参数表4.1 基线 CNN90 万参数的小网络也能到 62%65%表情识别不需要一上来就上 ResNet。FER2013 是 48x48 小图、7 个类别一个小型 CNN 就够当可靠基线训练一轮快、逻辑清晰、出了问题好定位。结构是两个卷积块加两个全连接整体参数量在 90 万左右。import torch.nn as nn class FerCNN(nn.Module): def __init__(self, num_classes7): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.Conv2d(32, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.Conv2d(64, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Dropout(0.5), nn.Linear(64 * 12 * 12, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, num_classes), ) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) return self.classifier(x)输入是单通道 48x48两次 MaxPool 后特征图变成 12x12、64 个通道展平后是 6412129216 维。BatchNorm 放在 ReLU 前让梯度传播更顺畅两个 Dropout 是为了对抗 FER2013 只有三万多张样本的过拟合风险——这个数据量级的任务全连接层是最容易过拟合的地方。这个网络在 FER2013 私有测试集上能到 62%~65%作为基线完全够用。4.2 迁移学习ResNet18 微调把准确率提到 70% 以上基线再往上提准确率靠加深层数效果有限更快的路是迁移学习。ImageNet 预训练的 ResNet18 对边缘、纹理、形状这些底层特征已经有很好的泛化微调时只需要重新学表情这个任务。因为训练数据是 48x48 灰度图需要加一层适配import torch import torch.nn.functional as F import torchvision.models as models class ResNetFER(nn.Module): def __init__(self, num_classes7): super().__init__() self.backbone models.resnet18(weightsmodels.ResNet18_Weights.DEFAULT) self.backbone.fc nn.Linear(self.backbone.fc.in_features, num_classes) def forward(self, x): # x: [B, 1, 48, 48] x x.repeat(1, 3, 1, 1) # 灰度转三通道 x F.interpolate(x, size(224, 224), modebilinear, align_cornersFalse) return self.backbone(x)repeat 把单通道复制成三通道因为 ResNet18 第一个卷积层的权重形状是 64x3x7x7直接用单通道输入会报维度错误。把 48x48 拉升到 224x224 会损失一点细节但预训练权重带来的收益远大于这点损失。注意预训练权重首次加载需要联网下载大约 40~50MB卡住先检查网络不是代码的问题。微调分两步走先冻结主干只训分类头再解冻全部层用低学习率精调。for p in model.backbone.parameters(): p.requires_grad False # 只让 fc 层更新跑 5 个 epoch 后再解冻 for p in model.backbone.fc.parameters(): p.requires_grad True冻结是避免在数据量不够的情况下把预训练特征冲掉。fc 层是随机初始化的先用 1e-3 的学习率训到合理状态5 个 epoch 后解冻整个 backbone用一个数量级低的 1e-4 继续微调。这个两阶段顺序比全程微调更省时间效果也更可靠。4.3 训练参数表照着填就能出结果的默认值下面这张参数表是这份资源反复调过后留下的默认值。数据加载用标准 DataLoader配合一个按文件夹名取标签的 Dataset 类train_loader DataLoader(train_ds, batch_size128, shuffleTrue, num_workers4, pin_memoryTrue) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4)batch size 128 是 48x48 小图的最佳区间太小梯度噪声大太大训练变慢还浪费显存num_workers4 让数据加载和训练并行CPU 核数多的机器可以往上加。weight_decay1e-4 是给权重加一点 L2 正则对三万张数据的任务有明显帮助。参数基线 CNNResNet18 微调输入尺寸48x48 单通道224x224 三通道batch size12864初始学习率1e-31e-3冻结期/ 1e-4解冻期优化器AdamAdamweight decay1e-41e-4最大 epoch5030停止条件val loss 连续 10 轮不降同一列训练循环里只保留两个最重要的逻辑按验证集准确率保存最优权重以及验证 loss 连续不降时自动降学习率。from torch.optim.lr_scheduler import ReduceLROnPlateau def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total correct 0 loss_sum 0.0 for imgs, labels in loader: imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad() out model(imgs) loss criterion(out, labels) loss.backward() optimizer.step() total labels.size(0) correct (out.argmax(1) labels).sum().item() loss_sum loss.item() return loss_sum / len(loader), correct / total scheduler ReduceLROnPlateau(optimizer, modemin, factor0.5, patience3) # 每个 epoch 结束后 val_loss, val_acc evaluate(model, val_loader, device) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best.pt) scheduler.step(val_loss)patience3 表示连续 3 个 epoch 验证 loss 没创新低就把学习率减半factor0.5 一次降一半。保存的是 state_dict 而不是整个模型对象部署时重新实例化网络再 load这种保存方式最通用、和后面 ONNX 导出的兼容性也最好。训练到 30 轮左右如果训练集准确率超过 90% 而验证集卡在 65%那就是过拟合信号优先检查 4.1 里 Dropout 的系数而不是继续加 epoch。5. 训练与推理避坑四个高频踩坑记录与修复代码这一章是训练到摄像头实时识别途中最容易翻车的四个位置每一条按现象、原因、解决的顺序写清楚。遇到问题先对照现象找到对应条目再抄修复代码。这些都是我实际跑这个资源时留下的血泪经验比任何优化技巧都值钱。5.1 摄像头推理掉帧瓶颈在检测频率和图像拷贝现象单独跑分类模型单帧推理不到 10ms接上摄像头后整体帧率掉到 4~5 FPS画面肉眼可见卡顿。原因每一帧都重复跑人脸检测加分类光检测一步就要 100ms 以上加上 cvtColor 颜色转换、repeat 通道拷贝频繁触发内存分配CPU 被无意义的操作吃满。问题不在模型在预处理链路的设计。解决把检测从每帧必做改成跳帧检测。每 3 帧做一次完整检测中间 2 帧沿用上一次的人脸框坐标分类只对裁剪出来的小图执行。frame_skip 3 face_box None for i, frame in enumerate(cap): if i % frame_skip 0: face_box detect_face(frame) # 重检测 if face_box is None: continue x1, y1 max(face_box[0], 0), max(face_box[1], 0) x2 min(face_box[0] face_box[2], frame.shape[1]) y2 min(face_box[1] face_box[3], frame.shape[0]) crop frame[y1:y2, x1:x2] emotion model_infer(crop) cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2)检测频率降到 10Hz 对交互场景足够3 帧内人脸相对位置变化很小。沿用旧框时一定要做 x2、y2 的越界裁剪否则人脸一动切片区域超出画面范围程序直接崩。这样调完还卡的话把输入尺寸从 224 降到 96再换成 onnxruntime 推理通常能到 15 FPS 以上。5.2 中文路径读图失败cv2.imread 的编码限制现象imread(/data/测试/face.jpg) 返回 None文件确实存在换成英文路径立刻正常。Windows 上最常见。原因OpenCV 的 imread 底层依赖文件系统编码解析路径中文路径在 Windows 默认编码下解码失败函数静默返回 None不抛任何异常。解决用 np.fromfile 把文件读成字节流交给 cv2.imdecode 解码绕开 imread 的路径解析。import numpy as np import cv2 def imread_unicode(path): buf np.fromfile(path, dtypenp.uint8) return cv2.imdecode(buf, cv2.IMREAD_COLOR)imdecode 处理的是内存 buffer不碰文件系统编码中文路径、空格路径都能正常读。连带坑是 cv2.imwrite 写中文路径同样失败对策是反过来先 imencode 再 tofile。cv2.imencode(.jpg, img)[1].tofile(data/测试/out.jpg)这类问题不报错只会在训练时发现 loss 是 NaN 或者样本数对不上才反应过来排查成本极高。后来我定了条规则项目里所有涉及文件名的读写统一走这两个包装函数不用原生 imread。5.3 验证集 72% 但摄像头前全是 neutral分布不一致现象FER2013 私有测试集上 72% 准确率以为部署没问题对着摄像头实测微笑被判成 neutral皱眉被判成 sad效果和验证数字差一大截。原因三个层面叠加。训练图是 48x48 灰度、人脸占满画面摄像头画面有背景、有肤色差异、人脸占比不确定FER2013 本身存在同一个表情标注不一致的标签噪声尤其 neutral 和 sad 边界模糊加上 7 类样本极不平衡模型天然偏向样本多的 neutral。解决推理链路严格复刻训练预处理——灰度化、缩放、归一化用同一个 mean/std训练时加 ColorJitter 补偿摄像头色差最后对输出做时间平滑取最近 5 帧的众数而不是单帧 argmax。from collections import deque buf deque(maxlen5) def predict_smooth(probs): buf.append(int(probs.argmax())) return max(set(buf), keybuf.count)绝大多数部署翻车都出在训练时归一化、推理时忘了归一化这一条这是整个表情识别里最玄学也最无聊的坑。时间平滑解决的是单帧误判抖动5 帧众数能滤掉大部分闪烁代价是响应延迟约 150ms交互上基本感觉不到。从那以后我把预处理函数抽成同一个工具模块训练和推理共用一份代码从根上杜绝两边不一致。5.4 训练不收敛归一化、batch size 和学习率一起排查现象loss 前 10 个 epoch 几乎不动或者 loss 在降但准确率一直在 25% 附近徘徊和随机猜七分类相比没有本质提升。原因第一像素 0~255 直接进网络没归一化梯度量级被拉爆优化器来回震荡第二batch size 设到 512 以上小数据集上收敛极慢第三学习率用 1e-2 起步对带 BatchNorm 的小 CNN 是明显偏高梯度走几步就发散。解决按顺序做三件事。先确认输入已经除以 255 并做了均值方差归一化这一步能解决八成的不收敛batch size 退回 128学习率从 1e-3 起步配上自动衰减。optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler ReduceLROnPlateau(optimizer, modemin, factor0.5, patience3)排查不收敛还有个技巧先拿一小批样本做单 batch 过拟合。如果这一批的 loss 能压到接近 0说明数据读取和网络前向传播没问题问题在训练策略如果连这一批都降不下去问题在模型或数据读取先检查标签是否对齐、数据有没有正确归一化。这条排查顺序能帮你少试很多没用的参数组合。6. 后端部署技巧ONNX 导出、FastAPI 接口与并发参数6.1 导出 ONNX部署不再依赖 PyTorch训练完拿到 best.pt 只是第一步。服务端要装 torch 才能推理的话内存占用高、启动慢还容易和线上其他服务产生依赖冲突。更常见的做法是导出 ONNX用 onnxruntime 推理文件小、CPU 上效率也高。model.load_state_dict(torch.load(best.pt, map_locationcpu)) model.eval() dummy torch.randn(1, 1, 48, 48) torch.onnx.export( model, dummy, fer.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}}, opset_version11, )dynamic_axes 允许推理时 batch 不固定为 1接口以后想一次传多张图就不用重新导出opset 11 和 onnxruntime 1.16 兼容性良好。导出前确认输入张量的 shape 和训练时的预处理链路匹配——训练用 48x48dummy 就一定是 1x1x48x48写错的话接口跑起来第一个请求就报 shape mismatch。然后立刻用 onnxruntime 复测一次import onnxruntime as ort sess ort.InferenceSession(fer.onnx, providers[CPUExecutionProvider]) out sess.run(None, {input: dummy.numpy()})两边输出应几乎一致差值在 1e-5 量级。这一步能在部署前把模型转换问题拦下来别等接口上线了再测。从那以后我导出 ONNX 前都强制走一遍这个对比再也不吃这种低级失误的后悔药。6.2 FastAPI 接口一次性会话、上传图片与 uvicorn 参数后端接口做薄只暴露一个 POST /predict。onnxruntime 的 InferenceSession 在进程启动时创建一次、全局复用绝不能在每个请求里重建否则并发一上来内存直接被打爆。from fastapi import FastAPI, UploadFile import onnxruntime as ort import cv2 import numpy as np app FastAPI() sess ort.InferenceSession(fer.onnx, providers[CPUExecutionProvider]) def preprocess_bgr(bgr): gray cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) gray cv2.resize(gray, (48, 48)).astype(np.float32) / 255.0 - 0.5 return gray[None, None, ...] app.post(/predict) async def predict(file: UploadFile): buf await file.read() img cv2.imdecode(np.frombuffer(buf, dtypenp.uint8), cv2.IMREAD_COLOR) if img is None: return {error: invalid image} x preprocess_bgr(img) out sess.run(None, {input: x})[0] label int(out.argmax(1)[0]) return {emotion: EMOTIONS[label], prob: out.tolist()}这次直接处理字节流反而绕开了 5.2 的中文路径问题因为图片已经以内存 buffer 形式存在不涉及文件路径。preprocess_bgr 里先转灰度再 resize 到 48x48然后除以 255 减 0.5和 3.3 的 val_tf 完全一致。启动参数uvicorn app:app --host 0.0.0.0 --port 8000 --workers 2 --limit-concurrency 50workers 是进程数每个进程会各自加载一份模型到内存2 workers 就是两份模型内存。CPU 部署建议 workers 不超过 CPU 核数的一半再多线程切换开销就超过并行收益。limit-concurrency 50 限制同时处理的请求数超出部分排队等待防止突刺流量把内存打满。单张 48x48 图的 CPU 推理在 5ms 以内这个配置对多数内部系统足够。从那以后我每次接到图像识别需求都强制走一遍固定顺序先验证最短推理链路再谈训练调参部署前必须导出 ONNX 并用 onnxruntime 复测环境、数据、模型、部署四段各留一份可复跑的命令。这套流程每一步对应的脚本、参数和踩坑记录这份资源里都打包好了直接对照着往下走能省很多弯路希望帮到你。本文还有配套的精品资源点击获取