简介:基于PyTorch与ShuffleNet模型的宠物年龄识别代码包,适合Python/CNN学习者完成图片分类实战。项目预置完整工作流:01生成txt.py将图片路径与标签转为训练/验证集文本,02CNN训练数据集.py自动读取txt并适配任意分类文件夹,03pyqt界面.py加载model.ckpt进行图片识别;训练过程显示进度条、准确率与损失值,并自动保存log日志。资源共9个文件,含3个Python脚本、1个说明文档、1个依赖清单、4张示意jpg,压缩包仅228KB,轻量易用。目前已有112人学习,适合需要快速搭建宠物年龄识别训练流程的开发者。下载后自行放置图片即可直接训练,逐行注释与说明文档可辅助理解关键代码,便于二次开发与课程设计参考。
1. 轻量模型判断宠物年龄:为什么选了 ShuffleNet 而不是 ResNet
宠物年龄识别这个任务,真正难的不是区分猫狗,而是同一物种在不同年龄段的外观差异太小——幼猫和成猫,差距往往只在脸型和骨架比例上。拿 ResNet 这类重网络去拟合小规模宠物照片,参数量大、推理慢,在小数据集上还容易过拟合,动不动就在训练集上跑到 99% 准确率,换一张真实照片立刻现原形。这个项目选择 ShuffleNet 解决的是实际落地场景:用普通 CPU 做推理,训练脚本只有三个,数据准备、模型训练、PyQt 界面识别各管一段,代码带逐行注释和说明文档,适合课程设计、毕业设计,也适合想在轻量网络上快速跑通完整图像分类流程的 Python 开发者。整个工程不含数据集图片,需要自己按类别整理照片,这会逼着你把深度学习的数据组织逻辑弄清楚。
2. 数据集组织与标签生成:01生成txt.py 的目录扫描逻辑
2.1 训练集目录结构与放置规范
这个项目的数据输入约定很直白:每个年龄类别一个文件夹,文件夹名就是类别名。以压缩包里的四个类别为例,常见做法是组织成下面这种结构:
dataset/ ├── 幼狗/ │ ├── 幼狗 1.jpg │ └── ... ├── 成狗/ │ ├── 成狗 1.jpg │ └── ... ├── 幼猫/ │ ├── 幼猫 1.jpg │ └── ... └── 成猫/ ├── 成猫 1.jpg └── ...需要特别注意的是:压缩包里每个文件夹内都有一张提示图片,提示你应该把搜集到的图片放到哪个位置。这个设计对新手友好,但也埋了一个坑——运行 01生成txt.py 时,如果扫描脚本不做过滤,提示图本身会被当成训练样本写进 txt,训练时模型会学到一个"固定窗口",降低真实场景下的泛化能力。处理方式有两种:要么把提示图移走,要么在扫描逻辑里对文件扩展名做白名单过滤。后面的代码采用第二种做法,更稳妥。
| 过滤项 | 建议值 | 原因 |
|---|---|---|
| 图片扩展名 | .jpg .jpeg .png .bmp,大小写不敏感 | 排除提示图、隐藏文件和临时文件 |
| 最小文件大小 | 建议大于 5 KB | 跳过损坏或不完整的图片 |
| 文件夹名编码 | 保持 UTF-8 | 避免 Windows 下中文目录名乱码导致标签映射错乱 |
2.2 01生成txt.py 如何把目录转成训练清单
这个脚本的核心工作是扫描数据目录,给每个类别分配数字标签,然后按比例切分训练集和验证集,把路径与标签写成 txt。一个典型实现如下:
import os import random def generate_txt(root_dir, output_train, output_val, train_ratio=0.8): # root_dir 为数据集根目录,例如 './dataset' classes = sorted([d for d in os.listdir(root_dir) if os.path.isdir(os.path.join(root_dir, d))]) # 按字典序给每个分类文件夹分配数字标签,保证多次运行结果一致 class_to_idx = {cls: i for i, cls in enumerate(classes)} train_lines = [] val_lines = [] for cls in classes: cls_dir = os.path.join(root_dir, cls) imgs = [] for name in os.listdir(cls_dir): # 只保留图片文件,避免把提示图/说明文档扫进来 if name.lower().endswith(('.jpg', '.jpeg', '.png', '.bmp')): imgs.append(os.path.join(cls_dir, name)) random.shuffle(imgs) # 先打乱,再划分,避免同类图片连续扎堆 split = int(len(imgs) * train_ratio) for path in imgs[:split]: train_lines.append(f"{path} {class_to_idx[cls]}\n") for path in imgs[split:]: val_lines.append(f"{path} {class_to_idx[cls]}\n") with open(output_train, 'w', encoding='utf-8') as f: f.writelines(train_lines) with open(output_val, 'w', encoding='utf-8') as f: f.writelines(val_lines) print(f"train: {len(train_lines)} 张, val: {len(val_lines)} 张")逻辑并不复杂:先扫描 root_dir 下的第一级子目录,目录名就是类别名,按字母排序后分配从 0 开始的数字标签。随后在每个类别目录里筛出图片文件,按 train_ratio 切分。输出的 txt 每行格式是"图片绝对路径 数字标签",中间用空格隔开,这是后续训练脚本读取的标准格式。
参数层面有几个值得注意的地方。train_ratio 默认 0.8,如果你的数据量少于每类 50 张,我建议调到 0.9,否则验证集可能只有几张图,评估出来的准确率波动非常大。存路径时尽量写绝对路径,避免训练脚本的工作目录与数据集目录不一致时出现 FileNotFoundError。这里用 txt 而不是 CSV,是因为 txt 的解析最省事,也方便直接打开看一眼有没有扫入奇怪的文件。
2.3 数据准备阶段就要想好的 transform 规划
transform 的代码写在第 02 个训练脚本里,但数据准备阶段必须想清楚。这个项目最终要喂给 ShuffleNet 的图片尺寸固定在 224x224,因为 torchvision 里 ShuffleNet 的 ImageNet 预训练权重就是按 224x224 输入的。如果图片尺寸不一致,模型 forward 时张量形状与全连接层维度对不上,报错都是维度不匹配。
训练集的 transform 建议包含随机水平翻转和随机裁剪,验证集只做 resize 和中心裁剪,这能让训练集有明显的数据扰动,验证集保持稳定。Normalize 的均值方差直接用 ImageNet 的统计值即可。灰度图也要在这个阶段处理掉:要么转成 RGB 三通道,要么在读取时统一调用 convert('RGB'),否则单通道输入会在卷积层直接报错。
3. 训练脚本核心解析:02CNN训练数据集.py 的类别适配与训练流程
3.1 自定义 Dataset 从 txt 读取样本
训练脚本第一步是定义 Dataset 类。为什么不用 torchvision 自带的 ImageFolder?因为这个项目已经通过 txt 清单把数据索引固化了,训练时不依赖目录结构,后续新增类别或调整数据划分都不需要改训练脚本。
from torch.utils.data import Dataset from PIL import Image class PetAgeDataset(Dataset): def __init__(self, txt_path, transform=None): self.samples = [] self.transform = transform self.classes = set() # 收集出现过的所有标签 with open(txt_path, 'r', encoding='utf-8') as f: for line in f: line = line.strip() if not line: continue path, label = line.split() self.samples.append((path, int(label))) self.classes.add(int(label)) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] img = Image.open(path).convert('RGB') # 统一转 RGB,规避灰度图与 PNG 透明通道问题 if self.transform: img = self.transform(img) return img, label这段代码有两个容易被忽略的细节。第一是 convert('RGB'):宠物照片来源多样,手机拍的可能带 EXIF 方向信息,网上下载的可能是灰度图或带 alpha 通道的 PNG,如果不统一通道数,训练中途会因为某张图张量形状不一致而崩溃。第二是 classes 集合:虽然只读最大值也能推出类别数,但显式收集 set 更稳妥,因为你要保证标签是连续的 0 到 N-1,如果 txt 里出现标签缺失,直接取 max+1 会出错。
3.2 动态类别数:如何替换 ShuffleNet 的最后一层
项目描述里强调"适配了数据集的分类文件夹个数,即使增加了分类文件夹,也不需要修改代码即可训练"。实现思路就是:先统计数据集的类别数,再根据类别数动态构造输出层。
import torchvision.models as models import torch.nn as nn def build_model(num_classes, pretrained=True): # x0_5 是最轻量档位,显存占用小;如果显存充足可以换成 x1_0 model = models.shufflenet_v2_x0_5( weights=models.ShuffleNet_V2_X0_5_Weights.IMAGENET1K_V1 if pretrained else None ) in_features = model.fc.in_features model.fc = nn.Linear(in_features, num_classes) return model关键在于 model.fc.in_features 这个属性,它返回上一层的输出维度。为什么不直接硬编码?因为 ShuffleNet x0_5 和 x1_0 的 fc 输入维度不同,不同版本 torchvision 内部实现也有差异,硬编码会把自己绑死。替换后模型的卷积骨干保持不变,只有最后输出的分类数量变化。
关于 ShuffleNet 的结构需要多说两句。ShuffleNet v2 的基础块把输入按通道拆成两半,一半直接走深度可分离卷积,另一半做恒等映射,最后拼接起来再用 channel shuffle 把通道顺序打乱。这个设计的直接收益是:在相同 FLOPs 下精度比 v1 高,实际推理速度也更快,因为深度卷积和逐点卷积的访存开销被控制住了。对宠物年龄识别这种图像细节差异不大的细粒度任务,它比 MobileNet 更擅长捕捉局部纹理变化。
pretrained 参数的选择要看数据量。每类几百张这种规模,加载 ImageNet 预训练权重,只微调最后两层,是最稳的方案。每类有上万张再考虑从零训练,否则收敛速度慢且容易落在次优解上。
3.3 训练循环:进度条、损失、准确率与模型保存
训练循环是整个项目的核心。它要把 DataLoader 里的数据一批批喂给模型,每个 epoch 结束后算准确率和损失,写日志,并保存最新的权重。
import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from tqdm import tqdm device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = build_model(num_classes).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-4) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5) for epoch in range(1, epochs + 1): model.train() running_loss = 0.0 correct = 0 total = 0 pbar = tqdm(train_loader, desc=f"Epoch {epoch}") for images, labels in pbar: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() pbar.set_postfix(loss=f"{loss.item():.4f}") epoch_loss = running_loss / total epoch_acc = correct / total with open('train_log.txt', 'a') as f: f.write(f"epoch {epoch} loss {epoch_loss:.4f} acc {epoch_acc:.4f}\n") torch.save(model.state_dict(), 'model.ckpt')几个参数需要认真对待。学习率设成 1e-4 而不是常见的 1e-3,因为加载了预训练权重后模型已经处在一个较优的局部区域,学习率太大会把学到的通用特征冲掉。batch_size 建议 16 或 32,ShuffleNet x0_5 很轻量,4GB 显存也能跑 32,CPU 训练耐心一点也能完成。scheduler 每 10 个 epoch 把学习率减半,这样 loss 到达平台期后还能继续下降。
CrossEntropyLoss 内部已经包含 softmax 操作,网络输出层不需要额外加 softmax。如果需要看置信度,在推理阶段用 torch.softmax 处理输出;训练阶段直接拿原始 logits 算损失即可。
| 超参数 | 推荐值 | 说明 |
|---|---|---|
| 输入尺寸 | 224x224 | 与 ImageNet 预训练输入对齐 |
| batch_size | 16 / 32 | 小显存用 16,CPU 也建议 16 |
| 学习率 | 1e-4 | 预训练模型微调常用范围 |
| epochs | 30~50 | 观察 loss 平台期 |
| 优化器 | Adam | 收敛快,对新手友好 |
| 损失函数 | CrossEntropyLoss | 多分类标准选择 |
这里的 torch.save 保存的是 model.state_dict(),只有权重没有结构。如果训练数据类别变了,模型结构就变了,旧权重没法直接加载。更稳妥的保存方式是存一个 dict,把 model.state_dict、类别列表、输入尺寸一起打包,后面第 4 章的推理脚本会用到这个结构。
4. PyQt 推理界面:03pyqt界面.py 从加载权重到输出预测
4.1 加载 model.ckpt 的三个一致性检查
推理脚本最容易阴沟翻船的地方不在 PyQt 布局,而在数据预处理与训练时不一致。第一是尺寸:训练用 224x224,推理也必须先 resize 到 224x224,否则卷积层接收到的张量空间尺寸不同,直接报维度错误。第二是 Normalize 参数:mean 和 std 两边必须一样,顺序也不能反。第三是通道顺序:PyTorch 要 CHW(通道在前),OpenCV 读出来是 HWC 且是 BGR,所以尽量用 PIL 读图,或者用 OpenCV 读完后显式转换。
import torch from PIL import Image import torchvision.transforms as transforms def preprocess(image_path): transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) img = Image.open(image_path).convert('RGB') return transform(img).unsqueeze(0) # 加 batch 维 def predict(model, image_path, device, class_names): model.eval() tensor = preprocess(image_path).to(device) with torch.no_grad(): output = model(tensor) prob = torch.softmax(output, dim=1) conf, idx = torch.max(prob, 1) return class_names[idx.item()], conf.item()predict 函数返回类别名和置信度。class_names 是一个 Python 列表,顺序必须与训练时目录名的排序一致。这里 torch.no_grad() 是推理的基本规范:不计算梯度,省内存,速度也更快。
4.2 模型加载:map_location 与结构重建
前面训练时保存的是裸的 state_dict,推理脚本加载时要先重建同样的模型结构,再填充权重。如果训练时保存了包含 class_names 的完整 checkpoint,加载端就省事得多。
def load_model(ckpt_path, device): ckpt = torch.load(ckpt_path, map_location=device) class_names = ckpt['class_names'] model = build_model(len(class_names), pretrained=False) model.load_state_dict(ckpt['state_dict']) model.to(device).eval() return model, class_namesmap_location 这个参数很实用。训练在 GPU 上进行,推理在 CPU 电脑上运行时,不写 map_location 会报 "Attempting to deserialize object on a CUDA device" 的错。统一写成 device 变量,加载时 torch 会按目标设备自动处理。加载完调用 model.eval() 是必须的,它会关闭 Dropout 和 BatchNorm 的训练模式,保证推理结果稳定。
如果只有裸的 state_dict,也可以通过扫描模型文件的 fc 输出维度来反推类别数,或者用第 2 章生成的 txt 重新读取类别列表,但都不如保存 checkpoint 时附带 class_names 来得干净。
4.3 PyQt 界面交互逻辑与卡顿规避
界面逻辑不复杂:一个按钮选择图片,一个按钮触发推理,两个 QLabel 分别显示预览图和结果。关键点在槽函数里不要做重活。
from PyQt5.QtWidgets import QFileDialog, QLabel, QPushButton def choose_image(self): path, _ = QFileDialog.getOpenFileName( self, "选择图片", "", "图片文件 (*.jpg *.jpeg *.png *.bmp)") if path: self.image_path = path self.preview_label.setPixmap(QPixmap(path).scaled(400, 400)) def run_predict(self): if not hasattr(self, 'image_path'): return name, conf = predict(self.model, self.image_path, self.device, self.class_names) self.result_label.setText( f"预测结果:{name},置信度:{conf:.2%}")对 ShuffleNet x0_5 来说,CPU 上单张 224x224 图片的推理通常在 1 秒内,放在按钮槽函数里不会明显卡顿。但如果以后换成 ResNet 或批量预测,就要把推理放到 QThread 子线程里,否则界面会冻结。另一个值得做的是把模型加载放到启动界面之前,避免点击按钮时卡住。
5. 验证模型到底学到了什么:一张误判表定位分类瓶颈
模型训练完别急着直接上手,先把验证集完整跑一遍,生成混淆矩阵和逐类别准确率。这里有一个很实用的排查脚本:
import numpy as np num_classes = len(class_names) cm = np.zeros((num_classes, num_classes), dtype=int) wrong_samples = [] for path, true_label in val_samples: pred_name, conf = predict(model, path, device, class_names) pred_label = class_names.index(pred_name) cm[true_label][pred_label] += 1 if pred_label != true_label: wrong_samples.append((path, true_label, pred_label, conf)) for i, name in enumerate(class_names): total = cm[i].sum() acc = cm[i][i] / total if total else 0 print(f"{name}: {acc:.2%}") wrong_samples.sort(key=lambda x: -x[3]) # 按置信度降序 for path, true_l, pred_l, conf in wrong_samples[:10]: print(f"{path} 真实={class_names[true_l]} 预测={class_names[pred_l]} 置信度={conf:.2%}")这个脚本的价值在于把"感觉模型还行"变成"具体哪类有问题"。如果成猫和幼猫之间互相误判多,先怀疑是数据混入了错误标签,打开误判图片看一眼就知道。如果某个类别准确率明显低于其他,优先补这一类的图片量,比盲目调超参数有效得多。
更进一步的分析手段是看热力图。取模型最后一个卷积层的输出特征图,按类别得分对特征图做加权求和,然后上采样到原图尺寸,叠加显示,就能定位模型在判断时关注的是宠物脸部还是背景。对年龄识别这种细粒度任务,好的热力图应该集中在五官、毛发纹理和体型轮廓上;如果高亮区域大面积落在背景上,说明训练集里不同类别的拍摄场景太相似,模型在用背景做捷径,这时要把精力花在采集多样化场景的图片,而不是继续调学习率。
另外一个容易被忽略的筛选逻辑:高置信度的误判样本优先级最高。置信度高但判错,说明模型对这个样本非常确定,这往往是标注错误或样本本身有歧义。置信度低判错的样本,说明模型本来就在边界上犹豫,改进价值反而不大。所以按置信度降序排列误判样本,一条条排查,是把有限时间花在刀刃上的做法。
本文还有配套的精品资源,点击获取