简介面向小程序端的猫狗表情识别工程基于Python与PyTorch构建内含完整图片数据集与训练、部署代码适合有基础深度学习知识、想实现前后端识别应用的开发者和学生。工程覆盖数据集预处理短边补灰边、旋转扩增、训练集/验证集文本生成、模型训练、Flask服务端以及小程序前端展示页面可直观理解从数据准备到服务上线的完整流程。压缩包共492个文件以468张jpg图片数据集为主配合3个Python脚本、6个json配置、小程序相关js/wxml/wxss文件以及环境依赖txt包体仅30.88MB便于快速下载与运行。目前已吸引156人学习对于希望复现一个小型深度学习识别项目或作为毕业设计/课程作业参考的读者是一份轻量且结构清晰的实用素材。1. 猫狗表情识别不是猫狗分类这个压缩包到底解决什么问题看到这个标题的读者十有八九是想拿猫狗表情识别做一个练手项目或毕业设计。别急着解压那份 zip先想清楚一个问题它最终要给谁用。如果只是本地跑一个 predict.py那标题前半段“小程序版”就多余了既然压缩包里带了图片数据集和微信小程序端说明这是一条完整链路小程序负责拍照和展示Python 深度学习模型在服务端做推理。猫狗表情识别比猫狗分类难一个量级。狗的耳朵形态、嘴部结构差异远大于人脸的微笑和愤怒模型非常容易把“张嘴”当“开心”把“飞机耳”当“害怕”这跟做人脸情绪识别完全是两套思路。这套项目适合两类人想系统走一遍深度学习实战全流程的学生以及需要一个手机端产品原型的开发者。本文就按数据组织、模型训练、后端接口、小程序对接的顺序把这条链路拆开包括我踩过的坑和调参经验。2. 架构怎么拆微信小程序做前端Python 后端扛推理很多初学者拿到这个压缩包第一个念头是把模型塞进手机里跑。如果你是纯本地实验那没问题但一旦涉及微信小程序这条路基本走不通下面先说清楚为什么再给出一套可落地的架构分工。2.1 为什么不在微信小程序里直接跑深度学习模型微信小程序的包体积限制是硬约束。主包不能超过 2MB整个小程序总包一般限制在 20MB 以内一个 MobileNetV3 的 ONNX 模型文件就要 15MB 左右ResNet18 更是直接奔着 45MB 去光模型就把包撑爆了还不算框架运行时。这是第一道坎。其次是推理性能。小程序的运行环境是 WebView 加 JSCore普通的 JS 引擎跑图像预处理都吃力更别说跑卷积运算。就算用 TensorFlow.js 或 ONNX Runtime Web 勉强推理用户的手机性能差异极大中低端安卓机上一张 224x224 的图可能要等两三秒体验很难接受。第三个问题是模型更新。深度学习模型大概率要迭代你今天部署 v1明天发现猫的“生气”样本不够要重新训练。模型在服务端你只需要替换一个文件模型在客户端你得发版、过审、等用户更新周期以天计。所以这个压缩包最合理的架构就是微信小程序端只做图片采集、压缩、展示和请求Python 后端负责加载模型、跑推理、返回结构化结果。角色职责技术选型微信小程序端拍照/选图、压缩、转 base64、展示结果WXML JavaScript wx.requestPython 后端接收图片、预处理、模型推理、返回 JSONFlask / FastAPI PyTorch模型文件训练产物承载分类能力ResNet18 / MobileNetV3 权重文件数据流一句话就能说清小程序把图片压缩后转成 base64 字符串POST 到 Python 后端的/predict接口后端解码、预处理、送入模型再返回“类别 置信度”的 JSON。第 4 章会给完整代码。2.2 后端框架选型Flask 够用FastAPI 更舒服这个压缩包的 Python 后端部分最常带的往往是 Flask 版本原因是它依赖最少、源码最容易看懂对刚开始接触深度学习落地的读者最友好。Flask 的 wsgi 模型是同步的一个请求占一个 worker 线程对于个人项目、毕设演示、内部工具来说完全够用。我自己的习惯是如果只是把模型包成接口给小程序调用 Flask如果还想加批量预测、流式返回、自动生成 Swagger 文档那优先 FastAPI。FastAPI 的异步机制在处理并发时更有优势但代价是引入 pydantic 和 uvicorn心智负担高一点。# 常见做法先建虚拟环境再装依赖 python -m venv venv source venv/bin/activate # Windows 上是 venv\Scripts\activate pip install flask torch torchvision pillow装依赖这一步很多人踩坑。PyTorch 的 CPU 版和 GPU 版安装方式不同如果你只是部署一个识别服务CPU 版足够如果你要训练模型才需要装 CUDA 版。建议到官网按操作系统和 CUDA 版本复制安装命令不要直接用pip install torch那会默认装 CPU 版训练慢到你怀疑人生。2.3 模型选型ResNet18 打底MobileNet 留给手机端猫狗表情识别是一个典型的图像分类问题输入一张图片输出一个类别。这类问题不需要上 Transformer 之类的复杂结构一个在 ImageNet 上预训练过的 CNN 模型迁移到猫狗表情数据集上效果就非常能打。这个项目里标签组合很关键。最常见的标签设计是四类类别名含义cat_happy猫开心cat_angry猫生气或害怕dog_happy狗开心(吐舌头、摇尾巴、张嘴)dog_angry狗生气龇牙、飞机耳注意这里把“猫狗分类”和“表情分类”合并成了一个四分类问题而不是先做猫狗二分类再做表情二分类。原因很简单数据集是配套的四类标签直接对应四个目录用 ImageFolder 就能加载训练路径最短。如果你拿到的是猫、狗、表情分开标注的数据集才需要改成先检测再分类的两段式方案。模型方面第 3 章的训练代码我会用 ResNet18它在 torchvision 里有预训练权重模型文件约 45MB放在服务器上没有任何压力。如果你想让模型更轻量把训练代码里的resnet18换成mobilenet_v3_small其他部分不用改权重文件立刻降到 10MB 左右。这个替换成本极低建议在两台机器上都试一下对比准确率和推理耗时的差距。3. 用自带图片数据集训练 CNN目录结构、数据增强与迁移学习参数训练是整套系统里最“玄学”的环节很多读者拿到压缩包直接跑训练脚本发现 val_acc 卡在 70% 上不去然后开始怀疑数据有问题。其实大部分情况是目录结构没理解对、增强参数没调、迁移学习的冻结策略不对。这一章把这些事情依次说透。3.1 数据集的目录结构先搞清楚标签是什么解压图片数据集之后第一件事不是看图片长什么样而是看目录结构。这套项目常见的数据组织方式是 ImageFolder 格式也就是每个类别一个目录目录名就是标签。# 期望的目录结构以项目实际解压结果为准 data/ ├── train/ │ ├── cat_happy/ │ ├── cat_angry/ │ ├── dog_happy/ │ └── dog_angry/ └── valid/ ├── cat_happy/ ├── cat_angry/ ├── dog_happy/ └── dog_angry/如果压缩包里解压出来只有一个总目录没有划分 train 和 valid你需要自己按比例切分。我一般写一个split.py把每个类别的图片随机取 85% 放进 train15% 放进 valid。这个操作千万别省动手深度学习的第一步就是建立数据集的信任——不划分验证集你根本不知道模型训练到什么程度算好后面的一切调参都是对着空气打拳。验证集划分还有一个容易忽略的点同一个来源的相似图片可能被分到 train 和 valid 两边导致验证集准确率虚高。这个压缩包的图片大多来自互联网爬取同一视频抽帧的连续帧很可能重复出现建议划分时用文件名前缀或图片哈希做个简单的去重能减少一部分“假高分”。3.2 数据增强的 transforms表情识别对局部特征更敏感猫狗表情识别的难点在于表情差异往往体现在嘴部、耳朵、眼睛这些局部区域而猫狗的品种差异会带来巨大的整体外观变化。如果只做简单的 Resize模型很容易学会“识别品种”而不是“识别表情”因为品种特征比表情特征更容易学。# dataset.py from torch.utils.data import DataLoader from torchvision import datasets, transforms # 训练集增强尺度、翻转、旋转、颜色抖动 train_transforms transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(15), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) # 验证集只做 Resize 和标准化不做随机增强 valid_transforms transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) train_dataset datasets.ImageFolder(data/train, transformtrain_transforms) valid_dataset datasets.ImageFolder(data/valid, transformvalid_transforms) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4) valid_loader DataLoader(valid_dataset, batch_size32, shuffleFalse, num_workers4)RandomRotation 我建议只给 10 到 15 度。猫狗照片大多是水平视角旋转角度太大会把地面、天花板拍进来反而让模型学到不真实的角度特征。ColorJitter 的亮度对比度扰动也是同理给 0.2 是模拟手机在不同光线下的成像差异给到 0.5 以上图片颜色严重失真模型学到的颜色分布和真实场景脱节。Resize 到 224x224 是 ResNet 系列的标准输入这个值不要随便改。改小了模型的分辨率不够表情细节丢失改大了会增加显存占用而且 ImageNet 预训练权重是在 224 分辨率上学习的迁移时硬用 384 分辨率效果往往不升反降。3.3 迁移学习训练冻结与解冻的四个必调参数训练部分直接上代码。这里用 PyTorch 加载 ImageNet 预训练的 ResNet18把最后一层换成 4 分类输出并冻结前三个 block只训练最后一个 block 和全连接层。这是迁移学习的标准做法。# train.py 核心片段 import torch import torch.nn as nn import torchvision.models as models # 加载 ImageNet 预训练权重 model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) num_classes len(train_dataset.classes) # 4 model.fc nn.Linear(model.fc.in_features, num_classes) # 冻结前三个 block只训练 layer4 和 fc for name, param in model.named_parameters(): if layer4 not in name and fc not in name: param.requires_grad False criterion nn.CrossEntropyLoss() # 只把 requires_gradTrue 的参数传给优化器 optimizer torch.optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lr1e-4, ) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size5, gamma0.5)这里重点说四个参数。第一个是lr1e-4迁移学习微调时预训练权重已经很接近最优解学习率太大一步就把学好的特征冲没了1e-4 是我试过最稳的起点。第二个是step_size5, gamma0.5每 5 个 epoch 学习率减半让训练后期在小步长下精细收敛。第三个是batch_size32对 224x224 的 ResNet188GB 显存能跑 3216GB 显存可以试 64再大收益有限。第四个是冻结策略这里冻结到layer4之前如果你的数据集很小几百张可以冻结更多层数据集够大几千张可以只冻结layer3之前让更多层参与微调。训练 epoch 数我一般设置在 25 到 30 之间每 5 个 epoch 在验证集上算一次准确率保存验证集准确率最高的权重为best_model.pth。注意保存时用torch.save(model.state_dict(), best_model.pth)别整个 model 直接存后者在加载时和类定义耦合紧密换个文件名或目录就报错。3.4 训练完别急着部署先看损失曲线的形态训练脚本跑完如果验证集准确率到了 88% 以上可以进入部署阶段。如果卡在 80% 以下先别调模型结构回去看两个东西。第一个是训练集和验证集的准确率差距。如果训练集 95%、验证集 75%这是典型的过拟合对应的做法是增强数据增强的强度、加入 Dropout、或者把模型换成参数量更小的 MobileNetV3。第二个是 loss 曲线是不是“锯齿状”剧烈震荡这说明学习率太大把初始学习率从 1e-4 降到 3e-5震荡通常会缓解。我在实操中遇到过 loss 一直在 1.3 左右死活不降的情况反复检查才发现数据增强里加了RandomGrayscale(p0.5)一半图变成灰度颜色特征被严重破坏。这类翻车很隐蔽排查时先逐步注释增强项把增强强度降到零确认模型能过拟合训练集再一项项加回来。4. 把模型接到小程序Flask 接口与 wx.request 的完整链路模型训练完毕剩下的工作就是把模型从“训练产物”变成“可被小程序调用的服务”。这一章给出一套能直接跑通的最小实现涉及后端接口、小程序端请求、以及真机调试时的域名配置。4.1 后端推理接口接收 base64返回类别和置信度推理接口的核心就三件事接收图片数据、预处理、返回结构化结果。这里选择 base64 字符串而不是 multipart 文件上传原因有两个小程序端读取本地文件转 base64 非常方便不需要引入上传组件后端解析 base64 不需要处理临时文件减少磁盘 IO 和清理逻辑。# app.py import base64 import io import torch from flask import Flask, request, jsonify from PIL import Image from torchvision import transforms app Flask(__name__) # 加载训练好的权重模型结构要和训练时保持一致 model models.resnet18(weightsNone) model.fc nn.Linear(model.fc.in_features, 4) model.load_state_dict(torch.load(best_model.pth, map_locationcpu)) model.eval() classes [cat_angry, cat_happy, dog_angry, dog_happy] # 注意推理时的预处理不能包含随机增强 infer_transforms transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) app.route(/predict, methods[POST]) def predict(): data request.get_json() if not data or image not in data: return jsonify({error: missing image}), 400 try: img_bytes base64.b64decode(data[image]) img Image.open(io.BytesIO(img_bytes)).convert(RGB) except Exception: return jsonify({error: invalid image}), 400 tensor infer_transforms(img).unsqueeze(0) with torch.no_grad(): logits model(tensor) prob torch.softmax(logits, dim1) idx torch.argmax(prob, dim1).item() return jsonify({ label: classes[idx], confidence: round(float(prob[0][idx]), 4), }) if __name__ __main__: app.run(host0.0.0.0, port5000)代码里有两处细节值得注意。第一处是img.convert(RGB)很多手机拍出来的图片可能是带透明通道的 PNGPIL 读进来是四通道直接送进模型会报通道数不匹配convert 统一成三通道是从根源上规避这个错误。第二处是model.eval()PyTorch 的 BatchNorm 和 Dropout 在训练和推理时行为不同不调用 eval 模式预测结果会有随机性同一个输入两次输出不一样。confidence用round限制到 4 位小数是为了让返回的 JSON 更干净也给前端一个明确的数据格式。输出的 label 我建议用英文标识因为小程序端可以根据这个字符串做中文本地化减少前后端耦合。4.2 小程序端请求chooseMedia、压缩、转 base64小程序端的调用链路是用户选图或拍照 → 压缩 → 读文件转 base64 → wx.request POST → 渲染结果。这里给出核心的 JS 代码。// pages/index/index.js Page({ data: { resultText: , confidence: 0, previewPath: , }, async onChooseImage() { const res await wx.chooseMedia({ count: 1, mediaType: [image], sizeType: [compressed], }); const filePath res.tempFiles[0].tempFilePath; this.setData({ previewPath: filePath }); const base64 this.fileToBase64(filePath); this.predict(base64); }, fileToBase64(filePath) { const fs wx.getFileSystemManager(); const buffer fs.readFileSync(filePath); return wx.arrayBufferToBase64(buffer); }, predict(base64) { wx.showLoading({ title: 识别中 }); wx.request({ url: http://127.0.0.1:5000/predict, method: POST, header: { Content-Type: application/json }, data: { image: base64 }, success: (resp) { if (resp.statusCode 200) { this.setData({ resultText: resp.data.label, confidence: resp.data.confidence, }); } else { wx.showToast({ title: 识别失败, icon: none }); } }, fail: () wx.showToast({ title: 网络错误, icon: none }), complete: () wx.hideLoading(), }); }, });wx.chooseMedia是当前推荐的选图接口它替代了老旧的wx.chooseImage支持拍照和相册二选一返回的tempFiles数组里每个元素带tempFilePath临时路径。sizeType: [compressed]是关键参数让微信先做一次有损压缩虽然只是压缩到 720p 级别但能让后端的解码和推理压力小一个档次。fileToBase64里用了wx.getFileSystemManager().readFileSync同步读取配合wx.arrayBufferToBase64转成字符串。这里有个小坑大图转出来的 base64 字符串可能有几百 KB微信小程序的wx.request对请求体大小有限制超过 1MB 会被直接拒绝。所以小程序端压缩是必选动作不是可选优化。4.3 域名与真机调试开发工具和线上环境的三点差异这是初学者翻车率最高的区域单独拿出来说。在微信开发者工具里默认情况下wx.request只能请求合法域名但开发模式下有一个开关可以绕过这个限制。提示在开发者工具的“详情 → 本地设置”中勾选“不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书”开发阶段才能请求http://127.0.0.1:5000这样的本地地址。这个开关只在开发工具有效真机预览上依然会拦截。真机上的情况更严苛。手机访问电脑上的后端服务需要电脑和手机在同一局域网后端启动时app.run(host0.0.0.0)让它监听所有网卡然后手机请求电脑的局域网 IP比如http://192.168.1.100:5000/predict。但真机预览模式下微信对“不校验域名”的约束依然生效非 https 域名大概率被拦。所以正式上线时有两件事必须做一是把后端部署到一台有公网 IP 的服务器上配置 HTTPS 证书二是在微信小程序管理后台的“开发管理 → 服务器域名”中添加你的 HTTPS 域名到 request 合法域名列表。注意这里有个前提——域名备案且必须用 443 端口不能自定义端口。如果你只是本地演示卡在局域网阶段就够了如果要做给外人看服务器加备案是绕不开的。5. 训练到上手机的避坑清单现象、原因、解决办法这一章是血泪经验的汇总。以下五类问题我在不同项目里反复见到覆盖了数据、训练、部署三个阶段。5.1 训练 acc 虚高一上真实照片就翻车现象验证集准确率 92%用小程序的摄像头对着自家猫狗拍识别结果完全离谱。原因验证集图片和训练集图片来自同一个数据源光线、角度、清晰度高度相似。真实照片的光线条件和手机压缩画质是训练集里没有的分布。这是最常见的模型过拟合变体——模型记住了数据源的风格而不是表情的本质。解决划分验证集时不要用随机抽样尽量按图片来源分组确保验证集来源不和训练集重叠。更简单的方法是留出 20 张真实拍摄的照片训练结束后单独做一个 sanity check。这一步不能省它相当于部署前最后一道质检。5.2 训练时 loss 降到 0.3 以下但 val_acc 连续 10 个 epoch 不涨现象训练集 loss 一路走低验证集准确率却停在 75% 左右。原因模型进入过拟合区间训练集学得太好泛化能力没有跟上来。在表情识别这个场景里最常见的原因是数据增强太弱模型很快就“背”下了训练集里的图片分布。解决先把训练集和验证集准确率的差距记下来。差距大于 15%说明过拟合严重。把RandomRotation从 10 度调到 20 度加上RandomResizedCrop模拟不同构图的图片再把ColorJitter的饱和度扰动从 0.2 提到 0.3。增强强度每调整一次重新训练一轮观察差距是否缩小。5.3 加载模型报“Missing key(s) in state_dict”现象后端启动时报错提示加载 best_model.pth 时缺少 key 或参数尺寸不匹配。原因训练时用torch.save(model.state_dict(), ...)保存但加载前用torch.load直接读取整个对象或者用 TensorFlow 的方式去读 PyTorch 权重。另一个常见原因是训练时改过num_classes加载时忘记同步修改model.fc的输出维度。解决严格复用 4.1 节的加载方式——先构建模型结构再load_state_dict。如果模型结构和训练时有任何改动比如把 ResNet18 换成了 ResNet34权重就作废了必须重新训练。这个坑的排查思路是打印 state_dict 的 key 列表和模型结构的 key 列表逐项比对。5.4 小程序真机请求报“404”或“request:fail”现象开发者工具里一切正常真机上请求直接失败报错信息是request:fail或者提示域名不在合法列表。原因开发者工具勾选了“不校验合法域名”掩盖了真实问题。真机上微信强制校验收到的每一项请求。另外后端没有监听所有网卡时host127.0.0.1只能在同一台机器上访问手机自然连不上。解决用app.run(host0.0.0.0, port5000)启动后端确保手机和电脑在同一局域网。用http://局域网IP:5000而不是127.0.0.1测试。真机联调时如果依然失败在小程序后台临时把 IP 加到 request 合法域名或者退回到开发者工具模拟器继续调试。注意将局域网 IP 加入合法域名只在开发阶段可行微信审核时不会接受 IP 地址线上务必使用备案过的 HTTPS 域名。5.5 服务端响应慢一张图要两秒现象后端接口返回时间在 1.5 到 2 秒小程序端体验很差误以为服务卡死。原因模型推理本身耗时是一部分更多时间浪费在图片解码和预处理上。PIL 打开大图、Resize(224, 224)对特别大的图做插值再加上 PyTorch 的 CPU 推理每一环都在累积耗时。解决把推理前的图片压缩放在小程序端限制在后端再检查一遍。Image.open之后先做一次img.thumbnail((512, 512))减少后续 Resize 的插值计算量。如果服务器有 GPU把模型to(cuda)推理耗时能降到 50ms 级别没有 GPU 就转 ONNX Runtime 加载多数机器上比 PyTorch 原生 CPU 推理快 20% 到 40%。6. 用混淆矩阵验收模型找出最容易混的两类表情很多读者训练完只看一个 val_acc 就收工了这是不够的。val_acc 是一个平均值它不会告诉你模型到底在哪些类别上犯糊涂。猫狗表情识别里最典型的混淆是“狗开心”和“狗生气”——两种表情都可能张开嘴、露出牙齿单一图片上人都不一定能快速判断模型混淆它们再正常不过。建议训练完做一次完整测试集评估直接输出混淆矩阵# evaluate.py import torch import matplotlib.pyplot as plt from sklearn.metrics import ConfusionMatrixDisplay, confusion_matrix y_true, y_pred [], [] with torch.no_grad(): for images, labels in valid_loader: outputs model(images) preds torch.argmax(outputs, dim1) y_true.extend(labels.tolist()) y_pred.extend(preds.tolist()) cm confusion_matrix(y_true, y_pred) disp ConfusionMatrixDisplay( confusion_matrixcm, display_labelsvalid_loader.dataset.classes ) disp.plot(cmapBlues) plt.savefig(confusion_matrix.png, dpi150)拿到混淆矩阵后看两个数值。第一个是“对角线占比”也就是每个类别的召回率如果dog_happy的召回率只有 60%说明这个类别的特征没有学全。第二个是“最大混淆项”找到矩阵里最大的非对角线数值比如dog_angry被预测成dog_happy的比例高达 25%那你要么去补充“生气的狗”样本要么接受这个边界情况并渲染给用户。针对混淆率高的类别对我一般会做两件事。一是收集更多这两个类别的样本尤其是中间态样本比如“张嘴但不一定生气”的照片让决策边界更清晰。二是在后端加置信度阈值当最高概率低于某个值比如 0.75时返回“不确定请换一张更清晰的照片”这比硬给一个错误标签更体面。这个阈值不要拍脑袋定用验证集上不同阈值的精确率召回率曲线去选。我的习惯是每调整一次数据或模型结构就把混淆矩阵存档成 PNG并记录下来对应的类别名和阈值一段时间后回看能清楚看到哪次改动真正解决了问题。这套方案做下来即使只依赖压缩包自带的图片集也能把四类表情的准确率稳定在 85% 以上的水平。希望帮到你。本文还有配套的精品资源点击获取