简介:一份面向深度学习与APP开发学习者的学术文献,聚焦基于ResNet50模型的猪脸识别APP设计与实现。内容涵盖猪脸数据采集与预处理(含OpenCV分帧、labelImg标注)、ResNet50网络结构优化细节、不同学习率对验证集准确率的影响对比,以及基于MUI与Django框架的APP四大功能模块设计;实验表明优化后模型对猪脸照片识别准确率可达92%左右。该资料适合计算机视觉、智慧农业及移动应用开发方向的学生、工程师与科研人员参考,既能理解卷积神经网络的工程化落地流程,也可借鉴论文中数据增强、模型调参和实验分析的方法。包体为1个PDF文件,大小约2.03MB,图文清晰,便于阅读与打印,已有308人学习浏览,具备较好的参考价值。资源内完整呈现了从数据准备到模型训练再到APP设计的闭环,可帮助读者快速复现同类猪只识别任务,提升养殖与保险场景的管理效率。
1. 猪脸识别不是玄学:一份 ResNet50 + Django 的落地参考
不带耳标、不打耳洞,靠摄像头拍几张吃饭的照片就能识别出每头猪,准确率还能到 92%——这套基于 ResNet50 的猪脸识别 APP 设计,恰好把「深度学习 + 移动端落地」串成了一条完整链路。传统 RFID 方案耗时长、耳标易丢、打耳洞对猪和操作员都不友好,而这份资料用 Keras 训练 ResNet50,后端 Django 出接口,前端 MUI 做混合 APP,从数据采集、标注、训练到接口联调都有对应方案。对做农业 AI 项目、毕业设计或者想往边缘设备上搬视觉模型的从业者来说,它最大的价值其实不是那个 92% 的准确率,而是里面可以直接照抄的实验参数:3:1:1 数据集划分、1e-4 学习率、Dropout 微调结构、base64 传图——这些才是能让你少走弯路的部分。
2. 从监控视频到干净数据集:分帧、标注、裁剪与增强的完整链路
2.1 摄像头安装与视频采集:为什么装在食槽上方
这篇文章里猪脸数据的采集方式很有代表性:猪圈分栏管理,摄像头装在食槽上方,调整安装角度后一个摄像头能拍到五头猪吃饭时的脸部。这个位置选得很巧妙——猪进食时头会自然朝向食槽,面部正对摄像头,能拍到清晰的眼部、口鼻和耳朵轮廓。如果装在侧面或过道,拍到的多半是躯干和歪着的侧脸,识别难度直接翻倍。
拍摄周期是三天,只在上午和下午的适宜时间打开摄像头。这个细节很多人会忽略:猪圈不是完全室内,正午强光下猪脸会过曝,面部纹理细节直接丢失,模型训练时能用的特征就少了一大截。我一般会建议把每天的拍摄时段固定住,比如上午 8 点到 10 点、下午 15 点到 17 点,这样既避开正午直射,又能保证连续三天光线条件相对一致,后期分帧出来的图片不会出现「一半像白天一半像黄昏」的亮度断层。
还有一个容易被低估的点:视频清晰度直接决定标注和训练的成本。食槽上方的摄像头建议至少用 720p 以上、25fps 左右的设备,距离猪脸两到三米时,眼睛和口鼻区域仍然要能看到足够纹理。拍得太糊,后面 labelImg 标注时你连脸都框不准,更别说让 ResNet50 学到细节特征。
原始视频拿到后,通常的做法是先用 OpenCV 做分帧,把视频流变成一帧帧的 JPEG 图片。这一步在 Python 里几行就能搞定,核心是控制抽帧频率——不需要每秒所有帧都保留,相邻帧内容太像,训练时冗余度高,反而容易过拟合。常见做法是每秒抽一帧,三天下来一个栏位大概能攒一万到两万张原始帧,再人工筛掉模糊和猪脸遮挡严重的,剩下用于标注的量就足够了。
import os import cv2 video_path = "farm_1_bar_3.mp4" # 原始监控视频 frame_dir = "frames" os.makedirs(frame_dir, exist_ok=True) cap = cv2.VideoCapture(video_path) fps = cap.get(cv2.CAP_PROP_FPS) # 获取视频的帧率,单位 fps frame_id = 0 saved_count = 0 while True: ret, frame = cap.read() if not ret: break # 按帧率间隔抽帧:frame_id % round(fps) == 0 表示每隔 1 秒取一帧 if frame_id % round(fps) == 0: cv2.imwrite(os.path.join(frame_dir, f"frame_{frame_id:06d}.jpg"), frame) saved_count += 1 frame_id += 1 cap.release() print("saved frames:", saved_count)这段代码的逻辑很直白:用 VideoCapture 打开视频,逐帧读入,在编号整除帧率时把当前帧写盘,相当于每秒保留一张。之所以不把全部帧都导出来,是为了避免连续几十帧猪的动作几乎没变化,导致同一头猪的照片几十张都高度相似,训练集“看起来很大、实际信息量很少”。另外,抽帧保存的文件名最好带上帧号,后续如果发现某段视频画面异常(比如猪群打架、摄像头被拱歪),能快速回去定位对应时间段。
2.2 labelImg 标注与裁剪:从一张五头猪到单头猪脸
分帧得到的每张图片里通常有好几头猪,不能直接拿去喂模型。原文用 labelImg 工具把图片里较清晰的猪脸框出来,每张图的标签信息保存成同名的 xml 文件,这样的 PascalVOC 格式很容易读。标注这一步是整个流程里最费人力的环节,也是数据质量的生命线。框太松会把食槽、水泥墙和饲料带进训练样本;框太紧又可能切到猪耳朵,丢失关键的面部特征。我一般会提醒自己一句话:只要是肉眼觉得模糊的帧,直接放弃标注,别硬框。
labelImg 里画矩形框时,类别名务必填猪只编号,比如 pig_01、pig_02,而不是都填 pig。训练时每个编号就是一个类别,标签一旦混了,模型学到的就是错的特征对应关系。同一个目标在同一帧里如果出现两次(比如猪侧了一下头又转回来),只保留最正、最清晰的那个框,避免同一张图里同一编号出现多个不一致的裁剪结果。
标注完成之后,用 Python 解析 xml 文件,把每个 bndbox 框住的区域从原图里裁剪出来,按猪只编号存入对应文件夹。这一步本身不复杂,但有几个细节直接决定后面训练效果:一是裁剪出来的图片尺寸不统一,后续要统一 resize 到 224×224;二是裁的时候不要完全贴着 bndbox 边缘,可以在四周留出 2~5 像素的余量,防止 labelImg 的框稍微偏了一点就把猪脸边缘切掉。
import os import xml.etree.ElementTree as ET import cv2 xml_dir = "annotations" # labelImg 输出的 xml 文件夹 frame_dir = "frames" # 原始帧图片文件夹 save_dir = "pigs" # 裁剪出的单头猪脸图片根目录 os.makedirs(save_dir, exist_ok=True) for xml_name in sorted(os.listdir(xml_dir)): if not xml_name.endswith(".xml"): continue tree = ET.parse(os.path.join(xml_dir, xml_name)) root = tree.getroot() image_name = root.find("filename").text img_path = os.path.join(frame_dir, image_name) img = cv2.imread(img_path) if img is None: continue for obj in root.findall("object"): pig_id = obj.find("name").text # 例如 pig_01 bbox = obj.find("bndbox") xmin = int(bbox.find("xmin").text) ymin = int(bbox.find("ymin").text) xmax = int(bbox.find("xmax").text) ymax = int(bbox.find("ymax").text) # 四周留 3 像素余量,避免边缘被切到,同时做边界限制 margin = 3 xmin = max(0, xmin - margin) ymin = max(0, ymin - margin) xmax = min(img.shape[1], xmax + margin) ymax = min(img.shape[0], ymax + margin) crop = img[ymin:ymax, xmin:xmax] out_dir = os.path.join(save_dir, pig_id) os.makedirs(out_dir, exist_ok=True) out_path = os.path.join(out_dir, f"{image_name.rsplit('.', 1)[0]}_{xmin}_{ymin}.jpg") cv2.imwrite(out_path, crop) print("crop done")这段代码读取每个 xml 文件,遍历里面所有 object 标注框,按 name 创建对应编号的文件夹并保存裁剪图。参数上最需要注意的是 margin 和边界限制:labelImg 的框如果恰好落在图像边缘,直接裁剪会越界报错或者裁出全黑区域;留余量的另一层意义是防止猪耳朵、下颌轮廓被框线截断,这些边缘纹理对识别同样有价值。
标注时如果某一栏里猪只编号很多,建议每个编号的文件夹里先检查一遍裁剪结果:图片里是不是只有猪脸?有没有把相邻猪的前腿也框进来?有没有因为阴影导致半张脸过黑?这些在训练前发现越早,后面返工成本越低。
2.3 数据划分与增强:3:1:1 分组与 Keras 数据生成器
数据整理完之后,原文按 3:1:1 的比例把每个编号的数据集划分为训练集、验证集、测试集,也就是训练 60%、验证 20%、测试 20%。这个划分有一个非常容易踩的坑:不能把所有图片打乱后全局随机切分,必须按猪只编号分组切。原因是同一个编号在连续视频帧里的照片很相似,如果同一头猪的照片同时出现在训练集和测试集,模型其实是在「记住」这头猪的样子,而不是「识别」它,测试准确率会虚高。
import os import random from collections import defaultdict random.seed(42) # 固定随机种子,保证复现结果一致 data_root = "pigs" splits = defaultdict(list) # {"train": [("pig_01", img1), ...], ...} for pig_id in sorted(os.listdir(data_root)): pig_dir = os.path.join(data_root, pig_id) if not os.path.isdir(pig_dir): continue imgs = os.listdir(pig_dir) random.shuffle(imgs) # 同一编号内部打乱,再按比例切 n = len(imgs) n_train = int(n * 0.6) n_val = int(n * 0.2) train_imgs = imgs[:n_train] val_imgs = imgs[n_train:n_train + n_val] test_imgs = imgs[n_train + n_val:] for img in train_imgs: splits["train"].append((pig_id, img)) for img in val_imgs: splits["val"].append((pig_id, img)) for img in test_imgs: splits["test"].append((pig_id, img)) print({k: len(v) for k, v in splits.items()})这个划分逻辑的关键点有两个:第一,random.seed(42) 放在最前面,所有随机操作都基于同一随机源,下次运行结果完全一样,训练出来的模型才能稳定对比;第二,先对每个编号单独 shuffle 再按比例切,而不是把所有图片混在一起切,能保证每个编号都均匀分布到三个集合里。如果不做这一步,训练集可能集中在某几个编号,测试集又集中在另几个编号,模型训练和评估都失真。
数据增强这一步,原文用的是 Keras 里的 ImageDataGenerator,对原始数据集做旋转等操作。它的核心思想是:猪脸照片数量有限,但通过小角度旋转、平移、水平翻转,可以把一张图变成多张语义相同、像素不同的样本,相当于免费扩了一倍多的数据量。
from tensorflow.keras.preprocessing.image import ImageDataGenerator # 验证数据只看原始图,不增强 train_datagen = ImageDataGenerator( rotation_range=15, # 旋转范围 ±15 度 width_shift_range=0.1, # 水平方向随机平移 10% height_shift_range=0.1, # 垂直方向随机平移 10% horizontal_flip=True, # 随机水平翻转 fill_mode="nearest", # 平移/旋转后空白区域用最近像素填充 rescale=1.0 / 255.0 # 像素值归一化到 [0, 1] ) val_datagen = ImageDataGenerator(rescale=1.0 / 255.0) train_generator = train_datagen.flow_from_directory( train_dir, target_size=(224, 224), # ResNet50 标准输入尺寸 batch_size=32, class_mode="categorical" )ImageDataGenerator 的几个参数里,rotation_range=15 是原文「旋转」增强的具体化选择,为什么不设成 30 或 45?因为猪脸在自然进食场景下不会出现大幅度歪头,旋转角度过大反而会产生现实中不存在的奇怪姿态,让模型去学没有意义的分布;width_shift_range 和 height_shift_range 模拟的是猪站在食槽前轻微左右移动、摄像头角度略有偏移的情况。fill_mode="nearest" 表示平移后产生的空白区域用相邻像素填充,比 fill_mode="constant" 填充黑色更自然,不会在图像边缘制造突变。
flow_from_directory 会自动按文件夹名生成类别标签,文件夹名叫 pig_01 则类别标签就对应 pig_01,这就是前面强调标注时类别名必须统一的原因。到这一步,一个干净、标注规范、带划分和增强的猪脸数据集就准备好了,可以进入模型训练。
3. ResNet50 与学习率调参:把验证集准确率稳定在 92% 附近的实操记录
3.1 残差网络选型:为什么选 ResNet50,结构如何微调
图像分类里,网络层数越深理论上能学到的特征越抽象,但深层网络在反向传播时梯度连乘,容易出现梯度消失或梯度爆炸——前面的层基本学不到东西。ResNet 引入 shortcut connections,让每一层的输出可以跳过本层直接向后传递,使得梯度有了一条“高速通道”回到前层,这就是残差结构解决深度网络退化问题的核心逻辑。
原文选择 ResNet50,一个比较直观的原因是它比 VGG 系列参数少、计算量小,又比 AlexNet 这类浅层模型特征表达能力更强,在算力有限的服务器上也能跑。ResNet50 标准结构包含 49 个卷积层和 1 个全连接层,原文在最后的全连接层后又接了一个最大池化层,加 Dropout,再接 Flatten 和全连接层,形成一个针对猪脸识别微调过的分类头。这个结构改动跟官方默认的全局平均池化尾部不同,最大池化保留的是区域里响应最强的特征,对猪脸局部纹理差异更敏感。
from tensorflow.keras.applications import ResNet50 from tensorflow.keras.layers import Dense, Flatten, MaxPooling2D, Dropout from tensorflow.keras.models import Model num_classes = 20 # 猪只编号数量,按实际标注的编号数修改 base_model = ResNet50( include_top=False, # 去掉官方自带的全连接分类头 weights="imagenet", # 使用 ImageNet 预训练权重做迁移学习 input_shape=(224, 224, 3) ) x = base_model.output x = MaxPooling2D(pool_size=(2, 2))(x) # 最大池化:取 2x2 窗口内最大响应 x = Dropout(0.25)(x) # 以 25% 概率随机丢弃特征,防止过拟合 x = Flatten()(x) # 把多维特征压成一维向量 x = Dense(num_classes, activation="softmax")(x) model = Model(inputs=base_model.input, outputs=x) model.summary()这段代码里,include_top=False 非常关键:官方 ResNet50 的顶层是一个针对 ImageNet 1000 类设计的全连接层,不拿掉的话最后一层输出的维数对不上你的猪只数量;weights="imagenet" 则直接加载预训练权重,让网络一开始就具备识别边缘、纹理、形状等通用视觉能力,训练时只需要在猪脸数据上做微调,收敛速度比从零训练快得多。MaxPooling2D 和 Dropout 的组合对应原文的优化结构:池化层压缩特征图尺寸,减少参数量;Dropout 在每次前向传播时随机丢掉四分之一的特征,迫使网络不依赖某一小部分神经元,泛化能力更好。
需要提醒的是,如果你的服务器显存比较紧张,input_shape 可以改成 192×192 甚至 160×160,识别准确率会略有下降但训练速度明显提升;如果数据集的猪只编号少于 5 个,迁移学习的效果反而比少量样本从零训练更稳,因为预训练权重已经能把大部分通用特征提取出来,分类头只需要做一个小范围的映射。
3.2 学习率三组对比实验:80% 震荡、72% 欠收敛与 92% 稳定
学习率是训练过程中最值得较真的超参数之一,甚至可以说有点「玄学」——同一个模型,学习率差一个数量级,结果可能差出 20 个百分点的准确率。原文在 ResNet50 上分别拿 1e-3、1e-4、1e-5 三个学习率做了实验,结果具有非常典型的参考意义。
| 学习率 | 损失函数收敛情况 | 验证集准确率 |
|---|---|---|
| 1e-3 | 损失难以下降,曲线剧烈波动 | 80% 附近大幅震荡 |
| 1e-4 | 40 轮左右 loss 降到 0.5 附近 | 约 92%,稳定 |
| 1e-5 | 收敛速度过慢,40 轮 loss 仍在 1.0 以上 | 约 72% |
1e-3 学习率过大的表现是损失函数在最优点附近来回弹跳,每一次梯度更新步子迈得太大,直接跨过了谷底,模型始终落不到一个稳定的最小值区域,验证准确率自然跟着剧烈起伏。1e-5 则是另一个极端,每一步更新都太小,40 轮训练结束时损失还没降到 1.0 以下,模型根本没学到足够特征,准确率只有 72% 左右。1e-4 处在平衡区间:前 10 轮就能看到 loss 明显下降,到第 40 轮左右降到 0.5,验证准确率稳定在 92% 附近。这个实验给后面复现的人一个很重要的经验:不要一上来就迷信某个「推荐学习率」,果断把 1e-3、1e-4、1e-5 都跑一组短实验,每组长个 10 轮看 loss 趋势,再决定正式训练的数值。看损失函数的形状比看准确率更可靠,因为准确率在小数据集上波动大。
训练时的优化器和回调配置可以参考下面这段。注意用了 ModelCheckpoint 保存最佳权重,这个习惯非常关键——训练中后段模型可能过拟合导致验证准确率下滑,保存最佳那一轮能让你拿到全训练过程里最理想的模型。
from tensorflow.keras.optimizers import Adam from tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping model.compile( optimizer=Adam(learning_rate=1e-4), # 学习率按实验结果选 1e-4 loss="categorical_crossentropy", metrics=["accuracy"] ) callbacks = [ ModelCheckpoint( "pig_face_best.h5", monitor="val_accuracy", save_best_only=True, mode="max", verbose=1 ), EarlyStopping( monitor="val_loss", patience=8, # 连续 8 轮验证损失不改善就提前停止 restore_best_weights=True ) ] history = model.fit( train_generator, steps_per_epoch=train_generator.samples // 32, epochs=40, validation_data=val_generator, validation_steps=val_generator.samples // 32, callbacks=callbacks )Adam(learning_rate=1e-4) 是核心参数。EarlyStopping 在这里起到平衡轮数的作用,如果 40 轮之前模型已经收敛并开始过拟合,它会在验证损失连续 8 轮不改善时自动止损并恢复到最佳权重。steps_per_epoch 用样本数除以 batch_size 得到每个 epoch 的批次数,确保训练时每个 epoch 都能完整看过一遍训练集。如果你换了更大的数据集,还有余力可以把 epochs 从 40 提高到 60,配合 EarlyStopping 不会浪费算力。
3.3 模型保存与推理:只用 .h5 就能完成预测
训练完成之后,模型被保存成一个 .h5 文件,这也是后面 Django 接口里真正要用的东西。但 .h5 文件里保存的内容是有讲究的:Keras 默认的 model.save() 会把网络结构和权重一起存进去,推理时直接 load_model 一步到位;如果只保存权重 model.save_weights(),则必须先构建相同结构的模型对象,再 load_weights,否则报错。从部署角度来说,一次性保存完整模型最简单,不容易出结构对不上的问题。
from tensorflow.keras.models import load_model from tensorflow.keras.preprocessing.image import load_img, img_to_array import numpy as np # 加载训练好的完整模型,推理阶段只需要这一行 model = load_model("pig_face_best.h5") # 读取一张测试图并预处理到训练时一致的尺寸 img = load_img("test_pig_01.jpg", target_size=(224, 224)) arr = img_to_array(img) / 255.0 # 归一化到 [0, 1] arr = np.expand_dims(arr, axis=0) # 增加 batch 维度:从 (224,224,3) 变成 (1,224,224,3) pred = model.predict(arr)[0] # 输出形状为 (num_classes,) label_idx = int(np.argmax(pred)) # 概率最大的类别下标 confidence = float(pred[label_idx]) # 对应置信度 print("label index:", label_idx, "confidence:", round(confidence, 4))这里 label_idx 是类别数组的下标,不是猪只编号本身。训练时 flow_from_directory 会按文件夹名的字母顺序生成类别索引,你需要把「下标 → 猪只编号」的映射表保存下来(比如映射成 Python 字典),推理时用字典把 label_idx 转换回 pig_01 这样的实际编号。这一步如果遗漏,接口返回给 APP 的就会是 0、1、2 这种用户看不懂的数字。
还有一个容易被忽略的细节:load_model 时如果遇到版本兼容问题,最常见的是老版本 Keras 保存的模型里包含自定义层,新版本加载时会报 Unknown layer。解决办法是加载时传入 custom_objects,或者干脆在部署服务器上固定与训练时一致的 Keras 和 TensorFlow 版本。原文里用 Keras 平台训练,这个约束同样适用于复现。推理速度上,单张 224×224 的图片在 CPU 上大概需要 100~300 毫秒,在 GPU 上几十毫秒就能出来,对 APP 场景完全够用。
4. MUI + Django 混合 APP 架构:四模块拆分与前后端接口设计
4.1 四模块功能拆解:登录注册、天气气温、信息修改与照片识别
APP 的功能被拆成四个模块,这个拆法是比较典型的农业管理类应用结构。登录注册模块承担身份验证,保证不同养殖场之间的数据不串门;天气气温模块解决的是猪只养殖里的一个实际问题——气温对猪的生长发育和繁殖力都有影响,养殖户需要根据外部温度调整圈舍的通风和室温;信息修改模块用来更新猪只成长过程中的体重、健康状况等数据;照片识别模块则是核心入口,用户拍一张猪脸照片,APP 上传到服务器,服务器返回猪只编号和详细信息。
这四个模块在前后端职责划分上有一个值得借鉴的设计原则:模型训练和识别部署在服务器端,手机端只承担拍照、上传和展示。这样做的原因是 ResNet50 的推理虽然单张图只要几百毫秒,但模型文件本身有一两百 MB,直接塞进手机 App 既不现实也没必要,还会让安装包体积爆炸。放在服务器上,训练和预测都在同一个环境里完成,模型迭代时手机端完全不用重新发版,这个「高内聚低耦合」的思路对类似的 AI 应用都适用。
4.2 Django 后端搭建与 MySQL 配置:MTV 架构下的工程初始化
后端选择 Django 有一个很实际的理由:前端模型训练用的 Python,后端也用 Python,整个服务器端技术栈统一,不用像某些团队那样训练用 Python、Web 用 Java,部署时还要维护两套环境。Django 自带 MTV 架构、数据库访问组件和 admin 后台,一个项目的初始化非常快。
# 创建 Django 项目和应用 django-admin startproject pigfarm_server cd pigfarm_server python manage.py startapp recognition # 启动开发服务器,默认监听 8000 端口 python manage.py runserver 0.0.0.0:8000项目与应用分离是 Django 的默认组织方式:pigfarm_server 是整个工程的配置入口,recognition 应用里放模型接口和业务逻辑。开发阶段直接 runserver 就能接口联调,部署到生产环境后再换成 uwsgi 或者 gunicorn + nginx,这一步先不用管。
连接 MySQL 需要在 settings.py 里配置数据库信息。这里有一个非常常见的报错点:ENGINE 用了 django.db.backends.mysql 之后,如果环境里没装 mysqlclient 或者 PyMySQL,启动时立刻会报 Error loading MySQLdb module,这个错本质上是缺少数据库驱动,不是代码问题。
# pigfarm_server/settings.py 数据库配置片段 DATABASES = { "default": { "ENGINE": "django.db.backends.mysql", # 指定 MySQL 引擎 "NAME": "pigfarm", # 数据库名,需先在 MySQL 创建 "USER": "root", "PASSWORD": "your_password", # 按实际环境修改 "HOST": "127.0.0.1", "PORT": "3306", } }配置完数据库之后,用 python manage.py makemigrations 和 python manage.py migrate 建表。猪只信息表可以直接定义成 Django 模型,字段按原文的信息修改模块需求来:猪只编号唯一标识,体重和体温用于日常监测,备注字段用来存疫苗接种或者特殊情况。
# recognition/models.py 中定义猪只信息表 from django.db import models class Pig(models.Model): pig_id = models.CharField(max_length=32, unique=True) # 猪只编号,比如 pig_01 weight = models.FloatField(null=True, blank=True) # 体重,单位 kg,允许为空 temperature = models.FloatField(null=True, blank=True) # 体温,单位 ℃ remark = models.TextField(blank=True) # 备注:疫苗、健康情况等 def __str__(self): return self.pig_idPig 模型里 unique=True 保证了数据库层面不会出现两个相同编号的猪只,null=True 和 blank=True 配合表示这个字段在创建时可以没有值。定义好模型后,还需要在 admin.py 里注册,才能使用 Django 自带的 admin 后台。管理员在网页端就能直接对数据库进行增删改查,不需要写额外的后台管理界面。
4.3 图片识别接口实现:base64 解码、ResNet50 推理与 JSON 返回
APP 上传照片时,不能直接把二进制图片塞进 JSON,因为 JSON 本身是纯文本协议。原文的做法是把图片转成 base64 编码后再放进请求体,同时提到了这个编码方式对图片数据有一种「不可直接读」的效果,相当于顺带做了加密。这个说法在安全语义上不严谨——base64 只是编码,不是加密,但用在传输过程中确实能让接收方避免处理二进制流的各种边界问题,所以实践中依旧非常普遍。
import base64 # 读取本地图片并转成 base64 字符串 with open("pig_photo.jpg", "rb") as f: encoded = base64.b64encode(f.read()).decode("utf-8")转换完成后,字符串形如 /9j/4AAQSkZJRgABAQAAAQABAAD/...,长度约为原图的三分之四。这也是一个实际优化点:手机端拍照上传前,最好先用压缩库把图片压缩到 1MB 以下,否则一张 3MB 的照片转成 base64 后接近 4MB,在 4G/5G 网络下传输延迟非常明显,而 ResNet50 的输入只需要 224×224 的尺寸,原始高清图里大量信息在推理时根本用不上。
Django 端对应的识别接口可以这样组织。为了简洁,这里用原生 JsonResponse 而不是 Django REST framework——接口就一个照片识别加几个普通查询,引 DRF 反而要把序列化器、路由、视图集全都配一遍,性价比不高。
import json import base64 import io import numpy as np from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from tensorflow.keras.models import load_model from PIL import Image # 全局只加载一次模型,避免每个请求都重新读 h5 文件 model = load_model("pig_face_best.h5") label_map = {0: "pig_01", 1: "pig_02", 2: "pig_03"} # 和训练时的类别索引保持一致 @csrf_exempt def predict_pig(request): if request.method != "POST": return JsonResponse({"error": "only POST allowed"}, status=405) try: data = json.loads(request.body) b64_str = data["image"] # 前端传过来的 base64 图片字符串 # data URI 格式是 data:image/jpeg;base64,xxxx,按逗号切出纯 base64 部分 pure_b64 = b64_str.split(",")[-1] img_bytes = base64.b64decode(pure_b64) # 解码成 PIL Image,并缩放到训练时一致尺寸 img = Image.open(io.BytesIO(img_bytes)).convert("RGB").resize((224, 224)) arr = np.array(img) / 255.0 arr = np.expand_dims(arr, axis=0) # 增加 batch 维度 pred = model.predict(arr)[0] label_idx = int(np.argmax(pred)) confidence = float(pred[label_idx]) pig_id = label_map[label_idx] # 把下标还原成猪只编号 # 从数据库查询详细信息,返回给 APP 展示 pig = Pig.objects.get(pig_id=pig_id) return JsonResponse({ "pig_id": pig.pig_id, "weight": pig.weight, "temperature": pig.temperature, "confidence": confidence, }, json_dumps_params={"ensure_ascii": False}) except Exception as e: return JsonResponse({"error": str(e)}, status=500)这段代码里有两个文件值得单拎出来说。第一个是 b64_str.split(",")[-1],前端如果传的是完整 data URI,前面会有 data:image/jpeg;base64, 这一段前缀,不切掉的话 base64 解码会直接抛 binascii.Error;如果前端只传纯 base64,split(",") 之后取最后一段也不受任何影响,所以这个写法两种场景都兼容。第二个是 model = load_model 放在视图函数外面、模块加载时执行一次,否则每次请求都重新读一次一两百 MB 的 h5 文件,服务器接口响应时间会从几十毫秒退化到几十秒。
MUI 前端这边,发送请求和展示结果相对简单。MUI 封装了 mui.ajax,底层走的是 HTML5+ 的原生网络请求,兼容性比纯 Web 的 XMLHttpRequest 更好。
// MUI 框架中发送 base64 图片到识别接口 mui.ajax({ url: "http://服务器IP:8000/recognition/predict_pig/", method: "POST", data: JSON.stringify({ image: base64Str // 拍照后转成的 base64 字符串 }), dataType: "json", success: function(res) { // res.pig_id 是猪只编号,res.confidence 是置信度 mui.toast("识别结果: " + res.pig_id + " 置信度 " + res.confidence); // 根据 pig_id 从数据库查询更多信息并渲染到页面 }, error: function(xhr, type, errorThrown) { mui.toast("识别失败,请检查网络"); } });联调阶段要注意一个问题:MUI 打包的 APP 运行在手机 WebView 里,请求 http://服务器IP 时,Django 所在服务器的 IP 必须是手机能直接访问的内网或公网地址,不能是 localhost。如果用 127.0.0.1 访问,手机找的是它自己的本机地址,必然超时。开发时可以让手机和电脑连同一个 WiFi,用电脑的局域网 IP 调试,这样最快。
天气气温模块和登录注册模块在技术上比识别接口简单得多:天气模块直接调用第三方天气 API,按城市名拉取实时温度;登录注册用 Django 自带的 User 模型做身份校验即可,数据库里存用户和密码哈希。整个 APP 的信息流里,识别链路是核心,其他模块更多是围着养殖场景做配套。
5. 复现避坑清单:数据质量、过拟合与接口联调的五个坑
5.1 训练阶段:学习率震荡与过拟合
坑一:学习率设成 1e-3,验证准确率在 80% 附近剧烈波动。 现象:训练跑到第 15 轮左右,准确率曲线像锯齿一样上下跳动,loss 完全不往下降。 原因:梯度更新步长过大,参数在损失函数低谷附近来回跨越,始终收敛不到稳定区域。ResNet50 的残差结构能缓解梯度消失,但无法抵消学习率过高带来的发散问题。 解决:把 Adam 的 learning_rate 改成 1e-4,继续观察 10 轮,loss 会开始平稳下降。如果你用的不是 Adam 而是 SGD,需要适当降低到 1e-3 以下,并配合动量参数。参考原文结论,1e-4 是这一任务下的稳定点。
坑二:训练集准确率接近 99%,验证集只有 80% 出头。 现象:模型在训练集上几乎完美,换到没见过的验证照片上就出错,典型的过拟合特征。 原因:猪脸数据集规模本身不大,每个编号的照片可能只有几百张,模型把光照、圈舍背景这些与猪身份无关的信息也当成了判别特征。 解决:优先做数据增强,旋转、平移、水平翻转都是低成本手段;其次检查 Dropout 是否生效,确认模型结构里 Dropout(0.25) 真实存在于 Flatten 之前;如果增强后验证准确率提升不明显,把训练轮数降到 30 轮左右,并用 EarlyStopping 在过拟合出现前自动截断。
5.2 数据阶段:标注框与划分泄漏
坑三:裁剪出来的猪脸图片里夹带了食槽和围栏背景,模型对背景敏感。 现象:训练时 loss 下降很快,但实际拍一张新照片送到 APP 里识别,结果乱跳;看模型预测错误的图片,发现它总是关注猪脸周围的区域。 原因:labelImg 标注时框选太松,把猪脸以外的背景大量带进训练样本。ResNet50 的特征提取层会把背景纹理也编码进特征图,背景成了隐形的分类线索。 解决:重新检查标注框,尽量贴紧脸部轮廓;裁剪代码里把 margin 从 3 像素改成 1 像素甚至 0,别贪这几像素的余量;同时把裁剪后的图统一等比缩放再居中裁剪成 224×224,避免直接拉伸导致面部比例失真。
坑四:测试集准确率很高,但 APP 上线后识别新猪只效果差。 现象:测试集评估时准确率 92%,实际对着另一栏猪拍照,准确率掉到七成。 原因:划分数据时没有按猪只编号分组,同一头猪的相邻帧被同时切到训练集和测试集,形成数据泄漏,模型相当于「见过」测试照片的相似帧,评估结果虚高。 解决:严格按编号文件夹做 train/val/test 划分,确保任何一头猪的照片只落在一个集合里;标准做法是先用 3:1:1 把每个编号内部切好,再汇总拼接成整体训练文件列表,而不是把整个数据集一次性打乱再切。
5.3 部署阶段:模型加载与接口联调
坑五:Django 接口部署时加载 .h5 文件特别慢,甚至直接卡死或报 Unknown layer 错误。 现象:服务器响应前几个请求时耗时几十秒,查看日志发现卡在 load_model 那一步;或者服务启动后第一个请求直接返回 500。 原因:h5 文件里包含了完整的网络结构和训练时的优化器状态,Keras 版本不一致时,老版本保存的结构里某些层名在新版本里无法识别;服务器内存或 CPU 算力不足时,加载大模型也会显著阻塞。 解决:模型只加载一次,放到模块顶部或单独初始化函数里,不要放进视图函数;部署时先确认训练和推理环境的 TensorFlow/Keras 版本一致;生产环境建议用 model.to_json() 单独保存结构、save_weights() 保存权重,加载时先通过 json 重建结构再 load_weights,避免版本兼容问题;如果还嫌慢,可以在服务器上把 .h5 转成 tf.saved_model 格式,推理速度会有明显提升。
这些坑基本都是我实际拆这种「论文 + 代码复现」类项目时反复遇到的。绝大多数问题的根因不是模型结构不够好,而是数据没整理干净、学习率没调到位、接口加载逻辑写得粗糙——这三件事优先级高于一切调参技巧。
6. 扩展性验证:换一种动物,如何复用这套识别管线
6.1 三步改造:换数据集、改分类数、复用训练与接口代码
原文最后提到,在有合适的权重配置文件时,这套系统可以直接迁移应用于其他动物识别。实际操作起来,改造路径非常清楚。第一步,把猪脸数据集替换成牛脸、羊脸或者猫狗的数据集,数据采集方式可以沿用食槽上方摄像头方案,也可以换成圈舍通道、投喂点的定点抓拍;第二步,把模型结构里最后一层 Dense 的 num_classes 改成新动物的个体数量,其他层全部保留;第三步,重新跑第 3 章的训练代码,生成新的 .h5 文件,Django 接口和 MUI APP 完全不用动——接口只认图片内容和分类映射表,换个 label_map 就能返回新的动物编号。整个管线里需要重做的主要是标注环节,模型、后端、前端这三层几乎原样复用。
6.2 验证方法:准确率只是起点,还要看置信度分布和线上表现
换动物训练完模型之后,怎么判断它真的能用?我一般会强制走完三件事。第一件,在测试集上算整体准确率,同时画出混淆矩阵,看哪些个体容易被互相混淆——如果猪_03 和猪_07 经常被搞混,回去检查这两个编号的照片是不是标注时头型特征本身就很难区分,必要时补充这两个体的更多角度照片。第二件,统计所有测试样本的置信度分布,正确预测的样本置信度和错误预测的样本置信度之间是否有明显分界,如果错误样本也给出 0.95 以上的高置信度,说明模型的概率校准有问题,可以考虑在接口里加一个阈值:置信度低于 0.85 时返回「不确定,请重新拍摄」,这比硬报一个结果更符合实际使用场景。第三件,把模型接到 APP 上跑一天真实数据,收集用户拍照后返回的结果,定期把那些置信度高但确实是误判的样本拿出来,加入训练集做增量微调——这相当于给模型装了个持续优化的闭环。
从那以后我每次换物种做识别,都强制走一遍这三件事:先看测试集混淆矩阵,再打置信度分布,最后拿线上数据回喂训练集。这套思路比追求一次性的九成五准确率实际得多,毕竟养殖场里没有人会拿着准确率报表来夸奖你,他们只关心拍一张照片弹出来的编号到底对不对。希望这份拆解能帮你把猪脸识别的完整链路跑通,少踩几个我当年踩过的坑。
本文还有配套的精品资源,点击获取