
简介这套资源是一份基于Python PyTorch的猫狗表情识别小程序项目源码及配套图片数据集面向具备一定Python基础、希望入门深度学习图像分类或小程序后端部署的开发者。压缩包共492个文件大小约30.88MB主要包含468张jpg图片数据集、3个Python程序、小程序前端所需的json/js/wxml/wxss文件以及依赖说明txt目录结构一目了然。目前已有156人学习下载。项目自带数据预处理策略通过对短边补灰边和旋转角度等方式扩增数据集依次运行01数据集文本生成、02模型训练和03Flask服务端三个脚本即可完成从标注文件生成到模型训练再对外提供接口的完整流程训练过程会输出包含每轮验证集损失与准确率的log日志便于分析调优。适合用于课程设计或毕业设计参考。1. 拿图片数据集做猫狗表情识别真正的难点在小程序端第一次打开这个「小程序版基于python深度学习的猫狗表情识别-含图片数据集.zip」时多数人会以为工作流是解压图片 → 跑个模型 → 小程序里调用接口。实际落地后你会发现反过来了。图片数据集只是原料真正的递进关系是整理一万张带表情标签的猫狗图训一个轻量分类模型把它压缩成几兆字节的 tflite 文件最后嵌进微信小程序里实时推理。这个链路里每一环都能卡住人而且越往后越卡。最容易被忽略的事实是表情识别要求模型有足够的细粒度特征而小程序的推理环境又逼着你把模型压到尽可能小两者天然冲突。这篇文章把这套冲突讲透并把每一步写成能复现的实验流程。适合已经会用 Python 做基本图像处理、想让深度学习模型真正跑在移动端的开发者对 5 年以上的从业者来说重点看第 5 章和第 6 章——端侧预处理对齐和模型验证方法是绝大多数项目翻车的地方。2. 图片数据集的清洗与标注表情识别比猫狗分类多了一层“标签噪声”2.1 先搞明白标题里的图片数据集到底缺什么常见的猫狗图片数据集比如各类公开宠物图集通常只给两个标签cat 和 dog。但标题里说的是“表情识别”这意味着你手里的数据多半是只有物种标签的原始图片表情标签需要自己补。这是第一个隐藏成本也是决定模型上限的环节。一套可用的猫狗表情识别数据最少要覆盖 4 类表情开心、生气、害怕、中性。每类表情背后要有足够多的个体差异——不同品种的猫狗五官结构差异极大哈士奇天生一张“苦脸”加菲猫看起来永远在生气。如果数据集里恰好这类样本过多模型学到的就是品种特征而不是表情特征。这个坑在训练初期很难发现直到你拿真实场景图测试时才暴露出“物种偏见”。我一般会先把标题里的 zip 解压后做一次三方验证打开图片确认清晰度、统计每类样本数量、检查标签文件格式。打开图片这一步花不了几分钟但能过滤掉大量损坏文件——从网上下载的数据集里经常混入纯色图、截断图和带水印的图这些样本对训练是纯噪声。2.2 用 Python 把数据组织成训练器认识的目录结构深度学习训练框架无论是 PyTorch 还是 TensorFlow对数据组织方式有约定俗成的要求。常见做法是train/val/test三层目录每层下面按类别建子目录dataset/ ├── train/ │ ├── happy/ # 开心的猫和狗 │ ├── angry/ # 生气的猫和狗 │ ├── scared/ # 害怕的猫和狗 │ └── neutral/ # 中立表情 ├── val/ └── test/如果一个图片数据集里是扁平结构的照片你需要写个 Python 脚本按类别归档。这一步用标准库就能完成但建议直接用shutil因为跨盘符移动和大量小文件操作时它比os.rename更稳定import shutil from pathlib import Path # 假设原始文件名格式: img_001_happy_cat.jpg # 约定: 文件名中包含情绪关键词 source_dir Path(raw_images) dest_root Path(dataset) emotion_map { happy: [happy, joy, smile], angry: [angry, mad, ang], scared: [scared, fear, afraid], neutral: [neutral, calm, rest] } for img_path in source_dir.glob(*.jpg): fname img_path.stem.lower() for emotion, keywords in emotion_map.items(): if any(kw in fname for kw in keywords): dest_dir dest_root / train / emotion dest_dir.mkdir(parentsTrue, exist_okTrue) shutil.copy2(img_path, dest_dir / img_path.name) break这段代码的核心逻辑是关键词匹配自动归档。emotion_map里的映射关系是重点一个关键词能覆盖多少样本决定了你需要人肉补标多少。实际操作中匹配不上的图片会积累成“未分类”池这部分不要硬套而是单独抽出来人工看一遍。表情标注本身带有主观性猫 A 张着嘴可能是在打哈欠而非尖叫所以标注规范里应当写明判断标准比如“嘴巴张开且能看到牙齿”算 happy“耳朵后压且瞳孔放大”算 scared。2.3 数据集统计先算类别分布再定训练策略归档完成后跑一遍类别分布统计。这一步决定后续的损失函数要不要加权、数据增强要不要做针对性设计from pathlib import Path import collections data_root Path(dataset) for split in [train, val, test]: split_dir data_root / split counter collections.Counter() for emotion_dir in split_dir.iterdir(): if emotion_dir.is_dir(): n len(list(emotion_dir.glob(*.jpg))) counter[emotion_dir.name] n total sum(counter.values()) print(f[{split}] total{total}) for k, v in counter.most_common(): print(f {k}: {v} ({v / total * 100:.1f}%))输出里如果“angry”类只有“happy”类的五分之一训练时就要考虑两点。第一验证集不能用纯随机划分要用分层抽样否则可能整个验证集里没有一张 angry 样本第二损失函数里加类别权重是更省事的办法不用额外造数据。这两个操作一个在数据管线里做一个在训练脚本里做都会在第 3 章展开。图片数据集的预处理还有一个常被忽略的细节EXIF 方向信息。手机拍摄的图片经常带有 Orientation 标签训练框架读图时如果没有自动旋转模型会看到大量横躺竖歪的猫狗脸。用PIL.ImageOps.exif_transpose统一处理后再归档能避免一个非常隐蔽的准确率天花板。3. 深度学习模型选型与训练表情识别要用细粒度特征不能只靠层数堆3.1 为什么不用 ResNet 从头训练而是选 MobileNet 做迁移学习猫狗表情识别是一个典型的细粒度图像分类任务大类都是猫狗区分点在嘴巴、眼睛、耳朵这些局部区域的微妙变化。从零训练一个深度卷积网络需要百万级数据表情数据集通常只有几千到几万张直接训练必然过拟合——模型会把背景、毛色当成判别特征。迁移学习是更可靠的路径。选主干网络时有一个清晰的权衡线ResNet50 精度高但模型文件 90MB 以上不适合小程序端加载MobileNetV2 在 ImageNet 上预训练过的权重泛化能力够用权重文件只有 14MB 左右量化后能压到 4MB 以内。EfficientNet-Lite 系列精度略高于 MobileNet但导出到 TFLite 后算子兼容性偶尔会出问题需要你在移动端多花调试时间。这几种主干在猫狗表情这种规模的任务上的差异远小于在小程序里能不能流畅跑这个差异。我的建议是直接锁定 MobileNetV2除非你后续要做更细的品种识别。3.2 用 PyTorch 搭一个可复现的训练脚本训练脚本按 PyTorch 生态来写因为这个数据集要落地到小程序端时PyTorch 导出的 ONNX 模型转 TFLite 路径最成熟。下面是训练脚本的核心部分包含数据增强和迁移学习微调两个关键实践import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader from torchvision import transforms, models # 数据增强: 范围要克制表情识别不能过度破坏面部结构 train_transforms transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(degrees15), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) # 迁移学习: 加载预训练权重替换分类头 weights models.MobileNet_V2_Weights.DEFAULT model models.mobilenet_v2(weightsweights) model.classifier[1] nn.Linear(model.last_channel, 4) # 4 类表情 # 冻结主干只训练分类头 for param in model.features.parameters(): param.requires_grad False optimizer torch.optim.Adam(model.classifier.parameters(), lr1e-3) criterion nn.CrossEntropyLoss(weightclass_weights_tensor)transforms.Normalize那行的均值和标准差必须用 ImageNet 的标准值因为预训练权重是在这个分布上学的。RandomRotation 的角度限制在 15 度以内——猫狗的耳朵和眼睛方向对表情判断很关键旋转过大等于人为制造困难样本。class_weights_tensor来自第 2 章的类别分布统计用1 / 类别样本数再归一化得到。先冻结主干训 5 个 epoch 让分类头收敛然后解冻主干的后半部分用 1e-5 的学习率微调整个网络。解冻微调这一步能显著提升细粒度特征的提取能力但只解冻features[14:]之后的层前面的浅层特征边缘、颜色块在 ImageNet 上已经学得足够好不需要扰动。3.3 训练中的三个必调参数和不均衡处理训练循环里的关键参数不是网络结构而是这三处第一是 Batch Size。输入尺寸 224 的情况下显存 8GB 的显卡 Batch Size 取 32 比较稳。Batch Size 翻倍时学习率也要跟着调大否则收敛速度会肉眼可见地变慢。建议直接用余弦退火学习率调度器初始学习率 1e-3最小降到 1e-5配合 AdamW 优化器。第二是类别权重怎么加。把 loss 换成CrossEntropyLoss(weightclass_weights_tensor)是最省事的做法它等价于让模型在训练时把小类别的梯度放大。还有个常用技巧是 Online Hard Example Mining专门挑那些预测概率低于阈值的样本算梯度对表情这种类间差异小的任务有效但代码复杂度高一级初版训练不建议上。第三是早停策略。监控验证集 loss连续 8 个 epoch 不下降就停止训练同时用ReduceLROnPlateau在验证 loss 平台期把学习率折半。多数情况下训练在第 1525 个 epoch 之间收敛超过 30 个 epoch 还在涨就说明有问题——要么是数据泄露验证集里混入了训练图要么是增强太强导致欠拟合。训练完成后单独看每一类的召回率。如果 scared 类召回率远低于其他类不要急着调模型回第 2 章看标签噪声图片数据集里“害怕”的形态容易被误标成“中性”这类标签错误不会随训练时间变好只会让模型越来越困惑。4. 从 PyTorch 模型到 TFLite量化参数决定小程序端能不能跑得动4.1 ONNX 导出与算子兼容性检查PyTorch 模型不能直接被小程序用需要先转成 ONNX再转 TFLite。导出前有一件事必须先做把模型切到 eval 模式关掉 dropout 和 batch norm 的统计更新否则导出的图里会带着训练时才有的行为推理结果飘忽不定。import torch import onnx import onnxruntime as ort model.eval() # 固定输入尺寸TFLite 端对动态尺寸支持有限 dummy_input torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, catdog_emotion.onnx, input_names[input], output_names[logits], dynamic_axesNone, # 静态尺寸简化端侧实现 opset_version12 ) # 用 onnxruntime 做一次前向验证确保图结构正确 ort_session ort.InferenceSession(catdog_emotion.onnx) ort_out ort_session.run(None, {input: dummy_input.numpy()}) print(ONNX output shape:, ort_out[0].shape)dynamic_axesNone这个参数的意思是固定 batch size 和输入分辨率。小程序端每次推理只传一张 224×224 的图动态轴除了让转换器多一层不确定性之外没有任何收益。输出命名成logits而不是probabilities是因为 TFLite 转换后 Softmax 层经常被融合掉端侧拿到的是未归一化的 logits后处理再做 softmax 更可靠。导出后必须做的一步是算子检查。TFLite 支持的算子集合比 ONNX 小像torch.onnx.export偶发产出的Resize算子版本差异、Gather的高维索引都可能在 TFLite 转换时报 unsupported operator。常见做法是用onnxsim做一次图优化把冗余节点折叠能解决大部分兼容问题。4.2 TFLite 转换FP16 和 INT8 量化怎么选ONNX 转 TFLite 有两种路线通过onnx2tf工具或者先转成 SavedModel 再走TFLiteConverter。后者更稳定尤其是在算子兼容性上。转换时最关键的决策是量化方案。直接把模型从 PyTorch 转 ONNX 再转 TFLite得到的是 FP32 版本。在骁龙 8 系这类中端手机上FP32 模型跑一次 224×224 推理大约需要 180~250ms分类头占用的内存也不小。用 FP16 量化后速度提升约 1.5 倍精度损失几乎为零但部分低端 Android 机对 FP16 的加速支持不完整可能会更慢。要压体积和提速还得上 INT8 量化。INT8 量化分两种仅权重量化和全整型量化。只用权重量化时激活值仍是浮点体积能压到四分之一但推理速度提升有限。全整型量化要求传入代表性数据集做校准激活值也被量化到 INT8推理速度能提升 3 倍以上代价是精度损失 1% 到 3%——对表情识别这种容忍度较高的任务完全可接受。import tensorflow as tf # 加载转换后的 SavedModel converter tf.lite.TFLiteConverter.from_saved_model( catdog_emotion_savedmodel ) # 全整型量化配置 converter.optimizations [tf.lite.Optimize.DEFAULT] converter.representative_dataset representative_dataset_gen converter.target_spec.supported_ops [ tf.lite.OpsSet.TFLITE_BUILTINS_INT8 ] converter.inference_input_type tf.uint8 converter.inference_output_type tf.uint8 tflite_model converter.convert() with open(catdog_emotion_int8.tflite, wb) as f: f.write(tflite_model)representative_dataset_gen是校准数据生成器要选取 200 到 500 张来自训练集且覆盖全部类别的图片。校准集质量会影响量化后每一层的缩放因子——选图偏向某一类其他类的激活值就容易被截断精度损失会比预期大。校准集单独放一批不要复用训练集否则量化后模型的泛化能力会偏差。量化后必须手工验证精度。用同一批测试集分别跑量化前后的模型对比 top-1 准确率和混淆矩阵。如果 INT8 模型比 FP32 掉点超过 3%回退到 FP16 或者只量化权重以可接受的模型体积换回精度。表格对比如下方案模型体积推理耗时期望精度损失适用场景FP32约 14MB基准0原型验证FP16约 7MB提高约 1.5 倍极小中端机主力方案INT8 权重约 3.5MB提高约 2 倍约 1%体积敏感场景全 INT8约 3.5MB提高约 3 倍1%3%小程序端推荐方案小程序端强烈建议直接用全 INT8 版本4MB 左右的模型包在小程序分包加载时压力最小启动速度也比 FP32 快一倍以上。5. 微信小程序端神经推理用 tfjs-tflite 把模型跑在手机浏览器里5.1 小程序里跑 TFLite 的选型tfjs-tflite 插件小程序不能直接调用 Python推理必须在端侧完成。常见方案有三种调用云函数做远程推理、用小程序插件市场里的图像识别 API、把 TFLite 模型直接嵌入小程序通过 TensorFlow.js 运行。前两种都有硬伤云函数每次推理要走网络一张图传上去再等结果返回延迟通常在 600ms 以上还得按调用量付费第三方 API 则无法控制表情类别拿通用图像识别接口做猫狗表情分类结果基本不可用。端侧推理是唯一能同时满足隐私、延迟和离线使用的方案。TensorFlow.js 官方提供了适配微信小程序的 tfjs-tflite 插件它基于 WebAssembly 后端运行 TFLite 模型文件能在 Android 和 iOS 的 WebView 里跑起来不需要原生代码。限制是只支持 TFLite 模型不支持 ONNX 直接加载输入张量需要是 Float32Array 或符合量化要求的 Uint8Array首次加载模型需要下载权重文件要做好加载态管理插件初始化后整条链路就是在小程序页面里完成图片采集、Canvas 缩放、张量转换和结果渲染。5.2 完整的小程序端推理代码从图片选择到 Top-1 标签核心推理逻辑拆成三段。第一段是页面里加载模型第二段是把图片从本地路径读出来缩放到 224×224第三段是前向推理和后处理。以下代码基于 tfjs-tflite 的常见用法关键改动点是预处理必须和训练时完全一致。// pages/scan/scan.js const tflite require(tensorflow/tfjs-tflite); Page({ data: { modelLoaded: false, predicting: false, resultLabel: , resultConfidence: 0 }, async onLoad() { // 模型文件放在分包内启动后立即加载 try { this.model await tflite.loadModel( /models/catdog_emotion_int8.tflite, { wasmUrl: /models/tflite_wasm.wasm } ); this.setData({ modelLoaded: true }); } catch (err) { console.error(加载 TFLite 模型失败:, err); } }, // 从相册或相机获取图片 async onChooseImage() { const res await wx.chooseImage({ count: 1, sizeType: [compressed], sourceType: [album, camera] }); this.runInference(res.tempFilePaths[0]); }, // 图片预处理: 等比缩放 居中裁剪避免直接拉伸变形 preprocessImage(filePath) { return new Promise((resolve) { const targetSize 224; const ctx wx.createCanvasContext(canvas_processor); wx.getImageInfo({ src: filePath, success: (info) { const { width, height } info; let srcX 0, srcY 0, srcW width, srcH height; // 计算裁剪区域取图片中心的正方形区域 if (width height) { srcX (width - height) / 2; srcW height; } else { srcY (height - width) / 2; srcH width; } ctx.drawImage(filePath, srcX, srcY, srcW, srcH, 0, 0, targetSize, targetSize); ctx.draw(false, () { wx.canvasGetImageData({ canvasId: canvas_processor, x: 0, y: 0, width: targetSize, height: targetSize, success: (res) resolve(res.data), fail: (err) console.error(读取像素失败:, err) }); }); } }); }); }, async runInference(filePath) { if (!this.model || this.data.predicting) return; this.setData({ predicting: true }); // 1. 预处理: 拿到 RGBA 原始像素 const rgbaData await this.preprocessImage(filePath); // 2. 像素转换: RGBA - RGB Float32 并归一化 const float32Data new Float32Array(224 * 224 * 3); const mean [0.485, 0.456, 0.406]; const std [0.229, 0.224, 0.225]; for (let i 0; i 224 * 224; i) { for (let c 0; c 3; c) { const val (rgbaData[i * 4 c] / 255.0 - mean[c]) / std[c]; float32Data[i * 3 c] val; } } // 3. 构造输入张量并推理 const inputTensor new tflite.Tensor( float32Data, [1, 224, 224, 3], float32 ); const outputTensor this.model.predict(inputTensor); const logits outputTensor.dataSync(); // 4 个类别分数 // 4. Softmax 后处理 const expLogits logits.map(Math.exp); const sumExp expLogits.reduce((a, b) a b, 0); const probs expLogits.map(v v / sumExp); const labels [开心, 生气, 害怕, 中性]; const maxIdx probs.indexOf(Math.max(...probs)); this.setData({ resultLabel: labels[maxIdx], resultConfidence: Math.round(probs[maxIdx] * 100) / 100, predicting: false }); outputTensor.dispose(); } })这段代码里最容易出错的地方在第 2 步的像素转换。训练时transforms.ToTensor()会把像素值除以 255 得到 [0,1] 区间的浮点数再执行标准化小程序端拿到的是 0~255 的 RGBA 整数必须除以 255 再减均值除标准差顺序不能反。每张图片 224×224 有 150528 个浮点运算用Float32Array批量操作比逐元素push快得多这个写法也能避免频繁触发垃圾回收。输出层的处理用到dataSync()这是个同步方法会卡住 JS 线程直到推理完成。表情识别模型算轻量级INT8 版本在小程序里单次推理通常只需几十毫秒同步卡顿几乎感知不到如果换成大模型就要改用await outputTensor.data()异步版本防止小程序在低端机上直接无响应。中心裁剪的预处理策略对应第 4 章训练时的Resize方案训练时是直接把图片拉伸成 224×224没有做中心裁剪端侧却做了裁剪这是常见的“训练-部署不一致”问题。顶栏的canvas_processor在 wxml 里设成屏幕外定位然后注意 iOS 上wx.canvasGetImageData要求 canvas 是普通组件而非同层渲染的 type2d 版本后者在小程序基础库 2.9.0 以后接口完全不同开发时要分清版本。5.3 端侧性能优化与失败现场跑通第一版后端侧性能有三个必调的螺丝。第一个是模型加载。TFLite 文件放在小程序包里会在启动时就计入包体积如果按总包 2MB 限制来衡量4MB 的 INT8 模型必须走分包机制。把模型放在subpackages/models目录下在页面进入时才触下载能明显降低冷启动耗时。第二个是 wasm 的实例复用。tfjs-tflite 每次loadModel都会重新实例化 wasm一个页面里如果多次切换模型内存里会同时存在多个实例。正确做法是在app.js里做单例页面通过getApp().getModel()获取。第三个是输入图像的尺寸。有些机型相机输出是 4000×3000 的大图直接传给wx.getImageInfo再画到 canvas 上内存峰值会拉满。在wx.chooseImage里设置sizeType: [compressed]只能缓解最好拍照后先用wx.compressImage压一遍到宽度不超过 1024再进入预处理管线。最容易遇到的失败现场有这三种推理结果全部是同一个类别输入张量的数值范围不对检查像素是否除以 255同时确认模型输入类型是 float32 还是 uint8。INT8 全量化模型的输入层经常要求 uint8 的 0~255 像素值此时 Float32Array 的输入会直接得到垃圾输出。predict is not a functiontflite 插件加载的模型版本和框架版本不匹配确认tensorflow/tfjs-tflite插件版本与模型转换时的 TensorFlow 版本兼容。模型加载报错Unknown model version微信小程序的运行环境缓存了旧的 wasm 文件清缓存或修改 wasm 文件名后重试。6. 用混淆矩阵和类激活图定位表情识别模型的真正短板模型部署到小程序后验证工作不能只看整体准确率。表情识别里最重要的验证工具是混淆矩阵它能把错误归类可视化一眼看出模型是把“害怕”认成“生气”还是把“中性”当成“开心”的困惑。每种错误对应的修法完全不同。用 Python 在测试集上跑一次批量推理把预测结果和真实标签统计成矩阵。推荐用seaborn的热力图展示行是真实标签列是预测标签。严重对角线集中说明模型整体没问题需要加强的是细粒度区分如果出现明显的次对角线聚集说明两个类别可视化特征太接近需要回数据集看这两个类的标注边界是不是模糊——常见的是“张嘴喘气”被一些人标成“开心”、又标成“害怕”。第二层验证是类激活图它能告诉你是哪块图像区域主导了模型的判决。PyTorch 里用torch.autograd对最后一层卷积特征做梯度回传得到 Grad-CAM 热力图叠加到原图上观察。这一步的价值在于识别模型的偷懒策略import cv2 import numpy as np import torch import torch.nn.functional as F def grad_cam(model, input_tensor, target_class): features [] gradients [] def forward_hook(module, input, output): features.append(output) def backward_hook(module, grad_input, grad_output): gradients.append(grad_output[0]) # 挂到最后一层卷积 handle_fwd model.features[18].register_forward_hook(forward_hook) handle_bwd model.features[18].register_backward_hook(backward_hook) output model(input_tensor) model.zero_grad() one_hot torch.zeros_like(output) one_hot[0][target_class] 1 output.backward(gradientone_hot) feature_map features[0][0] # [C, H, W] grad gradients[0][0] # [C, H, W] weights grad.mean(dim(1, 2)) # 全局平均池化获取通道权重 cam torch.zeros(feature_map.shape[1:], dtypetorch.float32) for i, w in enumerate(weights): cam w * feature_map[i] cam F.relu(cam) handle_fwd.remove() handle_bwd.remove() # 缩放到原图尺寸 cam cam.detach().numpy() cam cv2.resize(cam, (224, 224)) cam (cam - cam.min()) / (cam.max() - cam.min() 1e-8) return cam热力图聚焦在眼睛和嘴巴周围说明模型学到了正确的表情语义如果高亮区散布在背景、项圈、甚至图片角落的水印上说明模型学的是数据集的“捷径”——比如所有“生气”的照片都带红色项圈模型实际在用颜色判案。碰到这种活修正的是数据集构建方式而不是换一个更强的骨干网络。最后一个实用技巧是给小程序端设计一个“置信度门槛”。把 Softmax 输出的最大概率当作置信度低于 0.6 时在界面上显示“无法判断请换个角度拍摄”。这个设计让最终用户在模型犯错时有明确的行动指引而不是面对一个错误标签。调门槛时记录一批真实用户上传图上的置信度分布想清楚业务能容忍漏判还是容忍误判再把阈值定下来。这一步做完这条从图片数据集到小程序端部署的链路才算真正闭环。本文还有配套的精品资源点击获取