资讯动态

用Python和CNN实现狗体型识别:从数据清洗到Gradio部署全流程

发布时间:2026/10/23 3:31:10 来源:尧图企业网站定制
做课程设计那会儿我在开源社区翻了好多深度学习相关的分享猫狗分类、垃圾邮件识别、手写数字识别几乎被写遍了。当时我就想找一个“看起来不卷、但完整走完数据—训练—评估—部署全流程”的题目最后定下来的是“用Python和CNN做狗的体型识别”。这个题目有三分类的空间有图像分类的典型难点还能用预训练模型省时间做完拿去答辩也有东西可以讲。这篇文章我就把整个项目的完整链路写出来包括数据怎么建、CNN到底在学什么、PyTorch训练代码怎么组织、混淆矩阵怎么看以及最后怎么快速搭一个能上传图片直接出结果的演示界面。如果你也准备做图像分类方向的课程设计或者毕业设计这篇可以直接当参考模板用。1. 选题动机与任务拆解狗的体型识别到底要解决什么问题1.1 为什么这个题目适合当课程设计/毕业设计我见过不少同学一上来就选“猫狗分类”觉得这个题目足够经典。但说实话猫狗分类太成熟了随便迁移一个预训练模型都能做到 95% 以上答辩的时候反而没什么可以深挖的内容。狗的体型识别不一样它表面上也是图像分类但内部有真实存在的难度同一犬种内部体型差异大。贵宾有玩具型、迷你型、标准型看起来完全是三种狗。图片里的背景、距离、拍摄角度对体型的判断影响非常大。一张近景拍的吉娃娃和一张远景拍的萨摩耶人眼都需要思考一下模型就更需要好好调教。数据集的天然不均衡。网上大狗和小狗的照片数量差异明显不处理的话模型会学偏。这些难点意味着你可以在报告里写出“我做了什么、为什么这么做、效果如何”而不是两句“用预训练模型完事”就打发掉。对课程设计和毕业设计来说过程完整度和思考深度永远比刷一个漂亮分数重要。1.2 任务定义三分类还是五分类边界怎么定在动手建数据之前我先把“体型识别”这个模糊的说法变成了一个可计算的分类问题。我参考了常见犬种的肩高和体重分布把狗的体型分成三类小型犬肩高不超过 30 厘米体重通常 5 公斤以下。代表犬种有吉娃娃、博美、约克夏。中型犬肩高 30-60 厘米体重 5-25 公斤。代表犬种有柯基、柴犬、边境牧羊犬。大型犬肩高 60 厘米以上体重 25 公斤以上。代表犬种有金毛、拉布拉多、阿拉斯加。这里有一个很多人忽略的问题狗的体型识别和品种识别不一样人眼也必须依据“参照物”或“经验”来判断。比如同样一张狗的照片如果没有任何参照物我这个作者也得靠猜。所以在数据清洗阶段我会有意加入“画面里有常见参照物”的图片比如狗在门边、车旁、人身边的照片让模型有机会学习到尺寸感。1.3 整体技术路线整个项目我拆成了四段数据构建从公开狗类图集里按犬种粗筛再按体型标准打标做去重和清洗最后划分训练集、验证集、测试集。模型选择采用 PyTorch 框架加载 ResNet18 预训练权重把最后一层全连接改成三分类输出微调训练。训练评估记录训练损失和验证准确率用混淆矩阵分析类别之间的误判规律。可视化演示用 Gradio 搭建一个上传图片就能输出体型名称和置信度的页面方便课堂展示。这套路线很通用换一个分类目标比如“室内设计风格识别”“宠物猫品种识别”只需要改数据和标签其他地方基本可以复用。2. 数据集是第一道坎体型分类标准、图片清洗与类别均衡2.1 数据来源与筛选公开图集过一遍还不够我先从开放的犬种图集里把常见犬种按“纯种标识”级别筛选出来再按犬种标准体重区间映射到小、中、大三类。听起来简单实际操作起来有一个坑图集里的标签是犬种名称比如“贵宾”“柯基”“金毛”但贵宾这个标签下同时包含玩具贵宾和标准贵宾直接把这个标签对应的所有图片都归为“小型”就把一批中型狗错误标记了。我的处理办法是分两步走第一轮按犬种标准身高体重粗分成三类。这一步会产生大量边缘样本。第二轮人工抽检。重点抽检那些犬种本身跨体型区间的比如贵宾、雪纳瑞、腊肠犬。抽检时把明显不对的图片挑出来要么重新归类要么直接删除。另外我保留了“犬种-体型”的映射表每张图片的标签都对应到“犬种 体型”这样后期如果导师想看分类依据我能直接拿出来说明。2.2 清洗规则去重、去水印、过滤多目标图片数据清洗这一步我原本以为半小时搞定实际花了整整一天。清洗规则我总结了四条去除重复图片同一个犬种在不同图集里会出现大量相同来源的图尺寸略改就重复。我计算了图片的感知哈希相似度超过阈值就删除。去除水印和过强文字叠加图片角落有平台水印的模型容易把水印当特征。这个没有很好的自动化办法我用了人工抽检加正则先去文件名带明显来源标记的。过滤多目标图片画面里同时出现三只狗体型标签没法定义。这个必须靠人工快速扫效率不高但对准确率影响很大。删除过度裁剪的图有些图狗只占画面 10% 都不到这种样本学出来都是噪声。清洗后的数据集量级是每类 2000 张左右训练集约 5000 张验证集和测试集各约 500 张。对于三分类任务这个量级搭配预训练模型是完全够用的。2.3 类别均衡与划分不要把所有图片混在一起再随机切很多人在建数据集的时候直接train_test_split一下就把数据切了我在第一次做的时候也是这么干的后来发现验证集里出现了大量和训练集来自同一组连拍照片的相似图片导致验证准确率虚高测试集一测就掉点。正确做法是先按“图像来源批次”或“犬种类别”分组再按组划分数据集。比如同一个摄影集里连拍的 10 张同一只狗那 10 张只能全进训练集或全进验证集不能分散开。我最后是按犬种分组再随机切确保训练集里见过的狗不会出现在测试集里这样模型的实际泛化能力才有参考意义。3. CNN是怎么在一张狗照片上“看”出体型的卷积、池化与迁移学习原理3.1 卷积层在干什么小窗口扫全图逐层抽象CNN 的核心是卷积操作不用把它想得特别神秘。你可以把卷积核理解成一个“小探照灯窗口”这个窗口在整张图片上从左到右、从上到下平移每到一个位置就把覆盖区域的像素做加权求和得到一个响应值。多个不同的卷积核叠加在一起就能分别响应图片不同的局部模式。一个比较直观的例子是浅层卷积核学到的是边缘、线条、颜色块中层卷积核学到的是眼睛、耳朵、尾巴这样的局部部件深层卷积核则把前面学到的部件组合成“头部结构”“身体轮廓”这些更抽象的概念。分类依据“小型、大型”这种整体判断靠的恰恰是最高层特征之间的组合而不是单看某个局部像素。3.2 池化与全连接降维和决策每经过几次卷积特征图尺寸会变得很大直接塞进全连接层参数量爆炸。池化层的作用就是压缩特征图。最常用的最大池化就是在一个 2x2 的小窗口里只保留最大值把特征图的高宽都缩到原来一半保留最显著的信息扔掉冗余。CNN 的整体结构可以类比成一个漏斗前面宽能接收完整图像越往后越窄但每个通道表达的信息越抽象。最后一层全连接相当于把提取出来的“高维经验向量”映射到“小、中、大”三个类别上的置信度分数再用 Softmax 转成和为 1 的概率分布。3.3 为什么直接用预训练模型数据不够时的最优解从零训练一个 CNN 在 5000 张图上做三分类理论上可行但实际效果非常不稳。因为随机初始化的模型需要同时学“特征提取”和“分类决策”两层能力数据量不够时很容易过拟合验证集准确率只有 80% 上下还反复波动。预训练模型的意思是模型已经在大型公开图像库上学过“怎么从自然图像里提取特征”比如它已经认识纹理、轮廓、光照变化。我们只需要保留它的特征提取部分把最后的分类层换成自己的三分类输出再用狗图对它做微调。我选的是 ResNet18原因有三个模型小单卡显存 4G 就能跑实验室旧机器也不怕。深度学习框架自带预训练权重加载一行代码搞定。微调速度快一个 epoch 在 5000 张图上用普通 GPU 大约一两分钟。4. PyTorch实战代码解读从数据加载到训练的完整工程链路4.1 环境版本与硬件CPU也能跑但GPU快得多我用的环境是 Python 3.9 PyTorch 2.1.0 CUDA 11.8显卡是入门级独显显存 4GB。如果你的机器没有 GPU这个项目也能完整跑下来只是 20 个 epoch 可能要一晚上。我个人建议优先用 GPU哪怕只是云平台的免费版本跑起来体验会好很多。版本搭配有一个需要注意的地方PyTorch 的 CUDA 版本必须和显卡驱动支持的版本匹配。装完之后先用下面的代码确认一下输出为 True 再开始痛苦训练。import torch print(torch.cuda.is_available())4.2 数据加载器与数据增强训练集和验证集的操作不能一样数据加载部分我把训练集和验证集分开定义。训练时用数据增强提升泛化能力包括随机水平翻转、随机旋转 10 度、随机亮度变化最后统一缩放到 224x224 并归一化。验证集不做随机增强只用最基础的缩放和归一化这样才能真实反映模型在“正常图片”上的表现。为什么归一化用的是固定的均值和方差因为预训练模型训练时就用了这一组图像统计参数输入保持同分布特征提取层才能正常发挥。这个数字写错了模型表现会明显变差。from torchvision import datasets, transforms from torch.utils.data import DataLoader train_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(10), transforms.ColorJitter(brightness0.2), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) val_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) train_dataset datasets.ImageFolder(data/train, transformtrain_transform) val_dataset datasets.ImageFolder(data/val, transformval_transform) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers2) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers2)目录结构直接用ImageFolder要求的格式data/train/small、data/train/medium、data/train/large验证集同理。这样打标签和加载零成本不用手写 CSV 解析。4.3 模型构建与训练循环几个容易被忽视的关键点模型部分我加载 ResNet18 预训练权重然后替换全连接层import torch import torch.nn as nn from torchvision import models model models.resnet18(weightsmodels.ResNet18_Weights.DEFAULT) num_features model.fc.in_features model.fc nn.Linear(num_features, 3)这里强调一个细节替换掉fc之后前面的卷积层依然保留预训练参数。训练时我用的是“整体微调”也就是所有层都参与训练只是因为预训练参数本身已经很好所以我用了较小的学习率 0.0001。如果学习率设成默认的 0.01预训练权重会被很快破坏损失函数上下乱跳准确率反而上不去。训练循环的基本框架可以这样组织import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) criterion nn.CrossEntropyLoss() optimizer optim.AdamW(model.parameters(), lr1e-4) num_epochs 20 best_acc 0.0 for epoch in range(num_epochs): model.train() train_loss 0.0 correct 0 total 0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() train_loss loss.item() * images.size(0) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() train_acc correct / total avg_loss train_loss / total model.eval() val_correct 0 val_total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) val_total labels.size(0) val_correct (predicted labels).sum().item() val_acc val_correct / val_total if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth) print(fEpoch {epoch1}/{num_epochs}: loss{avg_loss:.4f}, ftrain_acc{train_acc:.4f}, val_acc{val_acc:.4f})训练循环里有三个坑值得单独说训练结束后一定要model.eval()否则 BatchNorm 和 Dropout 在推理时还会按训练模式工作结果会不稳定。optimizer.zero_grad()必须在每个 batch 前调用忘记清零会导致梯度累积。保存模型只存state_dict()不要整个torch.save(model, ...)前者更安全加载时也能灵活改结构。4.4 学习率调整与早停让训练曲线更漂亮的两个小技巧20 个 epoch 结束后我发现在第 14 个 epoch 左右验证准确率就不再上升后面几个 epoch 训练损失还在降但验证损失开始上涨这就是典型的过拟合信号。两种做法可以缓解学习率衰减每 5 个 epoch 把学习率乘以 0.1。Early stopping当验证准确率连续 5 个 epoch 不再上升时停止训练。我实际操作的时候用了最简单的StepLRscheduler optim.lr_scheduler.StepLR(optimizer, step_size5, gamma0.1)在 epoch 循环里每轮结束调用一次scheduler.step()。这样曲线看起来更平滑答辩时画出来的训练曲线也更像“有经验的人”调出来的效果。5. 训练结果分析混淆矩阵、分类报告和那些搞笑的误判5.1 评估指标准确率之外还要看什么我最后训练出来的结果测试集准确率大概在 91% 左右。这个数字单独看还可以但准确率在类别不均衡时会掩盖问题所以我额外打印了混淆矩阵和分类报告用来分析每一类的精确率和召回率。下面是一个简化的混淆矩阵例子实际\预测小型中型大型小型162302中型2514819大型118173从这个矩阵能直接看出小型和大型之间的混淆很少但中型与小、大型之间的边界混淆明显。这符合直觉体型本身是一个连续值我强行把它切成三段边界处的样本注定很难分。5.2 三个典型误判案例模型在“看尺寸”还是“看比例”我把测试集里预测错误的结果翻了一遍发现误判集中在三个类型小型犬被识别成中型常见于泰迪、雪纳瑞这一类“看起来腿长但实际体重不重”的狗。中型犬被识别成大型常见于画面没有参照物、拍摄距离极近的柯基或柴犬模型被“画面占比大”误导了。幼年大型犬被识别成中型或小型两个月大的金毛体型并不大模型只看到当下的外形无法知道它长大以后的样子。第 3 类误判特别有意思它说明模型学到的是当下图片呈现的形态特征而不是“这条狗所属犬种的标准体型”。这其实是任务本身的固有局限不全是模型的问题我在答辩的时候直接把这个当作“问题与展望”的部分讲出来了。5.3 为什么验证集和测试集差异要防信息泄漏问题第一次训练我完全没有注意“同一只狗的连拍照片会同时进入训练集和验证集”这个问题结果验证准确率冲到 95% 以上但测试集只有 86%。后来按犬种分组重新划分两侧差距才缩小到 3 个百分点以内。信息泄漏在图像分类项目里极其隐蔽尤其是宠物照片一个数据集里很容易出现同一场景的多张图。划分数据之前最好先看一遍相似度去重再按来源分组。6. 给答辩加分的可视化Demo用Gradio快速构建体型识别演示页6.1 快速上手一个函数搞定上传预测很多同学的课程设计止步于“训练完了画个准确率曲线”但演示环节对答辩分数的影响很大。我尝试过用 Flask 写完整网页前后端一分离工作量就上来了。后来改用 Gradio几行代码就能实现一个带上传区域、输出区域和置信度显示的页面非常适合课程设计演示。import gradio as gr import torch from torchvision import transforms from PIL import Image model models.resnet18(weightsmodels.ResNet18_Weights.DEFAULT) model.fc nn.Linear(model.fc.in_features, 3) model.load_state_dict(torch.load(best_model.pth, map_locationcpu)) model.eval() labels [小型犬, 中型犬, 大型犬] transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) def predict(image): img transform(image).unsqueeze(0) with torch.no_grad(): outputs model(img) probs torch.softmax(outputs, dim1).squeeze(0) pred torch.argmax(probs).item() return {labels[i]: float(probs[i]) for i in range(3)} gr.Interface( fnpredict, inputsgr.Image(typepil), outputsgr.Label(num_top_classes3), title狗体型识别 ).launch()这段代码会启动一个本地网页打开后可以拖一张狗的照片进来立刻看到三个体型的置信度分布。我测试的时候发现一个细节load_state_dict之前必须先把模型结构定义出来否则fc层的输入输出对不上。另外加载权重时加了map_locationcpu这样在一台没有 GPU 的演示机器上也能跑答辩现场不用赌主办方电脑有没有显卡。6.2 演示技巧置信度比硬答案更有说服力如果界面上只显示“小型犬”三个字老师看不出模型的判断依据。我建议把三分类的置信度以概率条的形式同时展示出来比如“小型犬 78%中型犬 21%大型犬 1%”。这样观众能看到模型在“小中型边界”上是犹豫的反而说明模型学到了合理特征而不是盲目输出一个答案。准备演示素材的时候我专门挑了三类图片一张室内近景博美、一张公园远景金毛、一张中景柯基。这三张涵盖了体型识别的主要难度场景演示时可以边点边解说模型为什么这样判断。这个环节做得好比多放一张准确率表格更能打动答辩老师。6.3 还能往下做的方向负样本与置信度阈值课程设计的增量改进不需要太复杂。我在演示版里做过一个小优化加入“非犬类”负样本。给模型在推理时加一个阈值逻辑如果所有三类置信度都低于 0.5界面输出“未能确认是否为犬类”。这个改动可以过滤掉玩具狗、卡通图、模糊照片带来的干扰。负样本不需要太多我在测试集外额外找了 100 张不包含狗的场景图收集起来不费事但能让系统的实用性上一个台阶。我自己最后跑通整套流程的体会是这种小体量项目真正决定上限的不是模型有多深而是数据清洗干不干净、评估指标有没有看透、演示环节顺不顺畅。如果你也想拿这个题目做课程设计我建议先把数据部分做扎实再谈训练调参顺序反了后面全是坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑