简介:这是一份基于PyTorch与DenseNet模型的宠物行为图像分类代码包,面向需要快速搭建图像分类流程的初学者、课程设计或小型项目开发者。代码将数据清单生成、CNN训练与PyQt可视化界面拆分为3个独立Python脚本,结构简单直观;训练部分采用DenseNet作为主干网络,适合用于抓痒、舔毛、打盹、汪汪叫等常见宠物行为的识别。资源共9个文件,压缩包仅227KB,包含01生成txt.py、02CNN训练数据集.py、03pyqt界面.py三个核心脚本,以及requirements.txt环境依赖、docx图文说明文档和4张类别提示图片,可帮助使用者快速确认数据集放置方式。所有代码均配有逐行中文注释,并给出Anaconda+Python3.7/3.8+PyTorch1.7.1/1.8.1的推荐安装方案,即使基础薄弱也能按文档自行配置环境;数据集文件夹支持自定义分类,放入对应图片即可训练。目前已有141人学习,适合作为PyTorch图像分类入门或宠物行为识别方向的参考实现。
1. DenseNet 图像分类与行为识别:先看懂这份 zip 的底牌
宠物行为分类最磨人的不是网络,而是同一个动作的两帧之间几乎没有共同点:「伸爪」前一帧像在挠地,后一帧已经够到玩具,模型要把这种连续动作压进一个静态类别里。这份名为「densenet模型-python语言pytorch框架的图像分类宠物行为分类识别-不含数据集图片-含逐行注释和说明文档.zip」的项目,去掉压缩包外壳,核心是三样东西:DenseNet 在 PyTorch 里的实现、一套适合宠物行为细粒度分类的代码骨架、以及逐行注释。行为分类和普通图像分类最大的区别在于,类间差异不是「猫」与「狗」那样肉眼可辨,而是「扑咬」与「玩耍」、「警惕」与「发呆」这类同行小、动作重叠的细粒度问题。DenseNet 靠密集连接让每一层复用浅层特征,在数据量有限、靠微调的场景下,往往比同深度的 ResNet 更稳。这份 zip 不附数据集图片,意味着你得自己组织数据和标签,下面这条技术路线就按这个前提展开。
2. 数据组织与预处理:没有数据集图片时,从零搭出 DenseNet 的输入管线
2.1 项目结构和类别目录的约定
解压 zip 后,先找 requirements.txt 和说明文档里对数据路径的描述。常见做法是作者把数据入口写成 torchvision.datasets.ImageFolder,也就是要求你准备一个 train 目录,每个类别一个子目录。宠物行为分类建议最多拆到「动物/行为」两级,不要用「dog」「dog play」这种带空格的目录名。我一般先建一个 work 目录,结构如下:
data/ ├── train/ │ ├── cat_play/ │ ├── cat_attack/ │ ├── cat_neutral/ │ └── dog_bark/ └── val/ ├── cat_play/ └── ...ImageFolder 会把子目录名按字母序映射成 0、1、2…,所以类别名不要出现「cat1」和「cat10」这种会排错序的写法。行为类往往有大量「中性/无事发生」帧,把它们单独设一个类,比让模型硬归到其他动作里好得多。数据不齐时,宁可用旧的手机视频抽帧,也比从网上下载一堆尺寸和角度差异巨大的照片更可控。
2.2 用 torchvision.transforms 做与 ImageNet 匹配的预处理
DenseNet 的 PyTorch 预训练权重是在 ImageNet 上练的,归一化参数写死在 torchvision 的文档里:mean=[0.485, 0.456, 0.406],std=[0.229, 0.224, 0.225]。很多人微调时只记得 Resize,忘记归一化,结果模型输出概率几乎全在 0.5 附近,又找不到原因。行为分类的输入是视频抽帧或照片,最大边先缩到 256,再中心裁剪到 224,和官方评测口径一致。如果直接 224x224 硬拉,宠物肢体比例会被拉扁,行为特征的相对位置就变了。
from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])这里先用 CenterCrop 是为了跑通基线,后面换 RandomResizedCrop 等训练增强时,再单独放到训练分支。ToTensor 把像素从 0~255 转到 0.0~1.0,Normalize 做的是 (x - mean) / std,数值域不是为了让图片好看,而是让预训练模型的 BatchNorm 统计量和输入分布一致。这套预处理对 torchvision 里的 densenet121、densenet161 全部通用。
| 阶段 | 缩放 | 裁剪 | 水平翻转 | 用途 |
|---|---|---|---|---|
| 验证/推理 | Resize(256) | CenterCrop(224) | 关 | 保证指标可复现 |
| 训练 | 随机缩放 0.8~1.2 | RandomResizedCrop(224) | 按需 | 增加动作姿态多样性 |
2.3 DataLoader 与训练/验证集的划分:让 DenseNet 每次迭代吃到的都是「帧」
项目不含数据集图片,你需要自己写 Dataset。我用最直接的列表式实现:一个 csv,每行是图片路径和数字标签。这样做的好处是标签名不会被 ImageFolder 的字母序绑架,也方便后续做分层抽样。
import pandas as pd import torch from PIL import Image class PetBehaviorDataset(torch.utils.data.Dataset): def __init__(self, csv_path, transform=None): self.df = pd.read_csv(csv_path) self.transform = transform def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] image = Image.open(row["path"]).convert("RGB") if self.transform: image = self.transform(image) return image, int(row["label"])csv 至少要有两列,path 建议写相对路径,避免换机器后要重新改全路径;label 直接用整数,不要在 Dataset 里反复做字符串到数字的映射。接着用 sklearn 的train_test_split划分出验证集,比例 8:2 就够;行为类如果样本极少,在train_test_split里传stratify=df["label"],避免某一类在验证集里消失。
train_dataset = PetBehaviorDataset("train.csv", train_transform) val_dataset = PetBehaviorDataset("val.csv", val_transform) train_loader = torch.utils.data.DataLoader( train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True)batch_size=32在 224x224 输入下约占 8~10GB 显存,DenseNet121 的中间特征图比 ResNet50 大,显存不够就先降到 16 或 8。num_workers在 Windows 上设置大于 0 时,训练主进程要包在if __name__ == "__main__"里,否则会报 DataLoader worker 重复启动的错。pin_memory=True只在 CUDA 训练时有意义,CPU 训练开着反而增加拷贝开销。
3. PyTorch 实现:加载预训练 DenseNet、微调分类头并跑通训练与验证
3.1 torchvision 里 DenseNet 的开箱用法与模型导入的正确姿势
PyTorch torchvision 里可以直接拿到 ImageNet 预训练的 densenet121、densenet161、densenet169 和 densenet201。宠物行为分类的样本量通常只有几千到几万张,随机初始化训练 DenseNet 效果很差,常见做法是加载预训练权重后,把最后一层分类器替换成自己的类别数。densenet 的 classifier 只是一个nn.Linear(1024, 1000),替换成本比其他模型低得多。
import torch.nn as nn import torchvision.models as models from torchvision.models import DenseNet121_Weights weights = DenseNet121_Weights.IMAGENET1K_V1 model = models.densenet121(weights=weights) num_features = model.classifier.in_features num_classes = 6 model.classifier = nn.Linear(num_features, num_classes)这里必须用model.classifier.in_features去取输入维度,而不是写死 1024,因为 densenet121 和 densenet169 的 growth rate 不同,最后一个 BN 层的输出通道也不同。DenseNet 的features子模块里已经包含了最后的nn.BatchNorm2d和nn.ReLU,classifier前面还有一层nn.AdaptiveAvgPool2d,替换分类器不会破坏特征提取和池化之间的顺序。如果从说明文档里抄完整模型定义,要检查最后一个 DenseBlock 的 growth rate 是否与官方预训练权重一致,不一致时加载权重会报 size mismatch。
| 模型 | 主干参数量 | 分类器输入维度 | 适用场景 |
|---|---|---|---|
| densenet121 | 约 7M | 1024 | 默认选择,显存友好 |
| densenet161 | 约 27M | 2208 | 样本多、动作特征细微 |
| densenet169 | 约 13M | 1664 | 中间档 |
| densenet201 | 约 18M | 1920 | 追求上限但样本要够 |
3.2 训练循环与验证:交叉熵、AdamW 与学习率参数
微调 DenseNet 时,不建议用大学习率去动预训练主干。新加的线性层用 1e-3,预训练部分用 1e-4,优化器选 AdamW,配合余弦退火。下面这段是训练循环的骨架,也是 zip 里「逐行注释」经常展开的部分。
import torch import torch.nn as nn device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) optimizer = torch.optim.AdamW([ {"params": model.features.parameters(), "lr": 1e-4}, {"params": model.classifier.parameters(), "lr": 1e-3}, ], weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) criterion = nn.CrossEntropyLoss() for epoch in range(30): model.train() running_loss = 0.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() running_loss += loss.item() * images.size(0) scheduler.step() print(f"epoch {epoch:02d} loss {running_loss / len(train_loader.dataset):.4f}")用 param groups 分开设置学习率,让分类头收敛更快,又不破坏预训练特征。AdamW 的 weight_decay 只加到权重上,带偏置的参数不加会更稳,但行为分类这类中等规模数据上差别不大。每轮训练完必须跑一次验证,验证要放在torch.no_grad()下,并且model.eval()会关闭 Dropout、让 BatchNorm 用全局统计量;忘记切 eval 是训练曲线正常、验证指标忽高忽低的最常见原因。余弦退火的T_max要和总 epoch 数一致,否则学习率还没降到最低就提前停轮。
3.3 断点保存与单图推理:把 DenseNet 从训练脚本里摘出来
训练完只保存权重文件最省事,但下次要继续训练还得手动重建 optimizer 和 epoch,所以把 optimizer 状态、scheduler 状态、epoch 和权重一起打包更稳。推理脚本里只需要读model_state_dict,然后对一张图走完整的 transform 到 softmax 流程。
checkpoint = { "model_state_dict": model.state_dict(), "optimizer_state_dict": optimizer.state_dict(), "scheduler_state_dict": scheduler.state_dict(), "epoch": epoch, } torch.save(checkpoint, "model_best.pt") def predict(image_path, model, class_names, topk=3): transform = val_transform image = Image.open(image_path).convert("RGB") x = transform(image).unsqueeze(0).to(device) model.eval() with torch.no_grad(): probs = torch.softmax(model(x), dim=1)[0] top_idx = probs.topk(topk).indices.tolist() return [(class_names[i], probs[i].item()) for i in top_idx]unsqueeze(0)是必须的,模型要的是 NCHW 四维张量,单张图读完是 CHW 三维,不加 batch 维会直接报错。topk=3输出前三个候选,对行为分类很有用:当 top1 置信度不高时,看 top3 里是否包括「中性」,可以判断模型是否在犹豫。保存时把class_names单独存成一个 json,不要依赖训练脚本里的列表,否则换环境后标签顺序错位,模型准确率会高得离谱。
4. 行为分类特有的调参与踩坑:DenseNet 从通用分类器到行为识别器
4.1 行为类别与置信度阈值——别被准确率骗了
普通图像分类里 95% 准确率是好事,行为分类里它可能意味着模型把所有帧都认成「中性」。宠物行为视频大量时间没有明显动作,正样本占比往往不到 30%,如果直接对全帧算准确率,模型把每帧都判成「其他」也能刷到 70% 以上。我先把置信度阈值定为 0.5,推理时低于阈值的输出统一标记为 unknown,再重新计算各类别的 precision 和 recall,而不是只盯 accuracy。行为分类还常见「双峰」问题:模型对「扑咬」的输出概率在 0.45 和 0.98 之间跳,前者来自动作刚开始的帧,后者来自动作完成态。把阈值从 0.5 抬到 0.7,可以减少大量误触发,代价是召回率下降。调阈值时在验证集上画一条置信度-准确率曲线,选曲线平台期的值,比拍脑袋定 0.5 靠谱。
4.2 数据增强组合:不要把 ImageNet 的增强原样搬过来
ImageNet 训练常见的 RandomResizedCrop + RandomHorizontalFlip,在宠物行为上要谨慎。水平翻转会把「左爪刨地」变成「右爪刨地」,对动物来说左右并非完全对称;RandomResizedCrop 裁剪到很小区域时,可能把耳朵和尾巴裁掉,而这些恰恰是行为识别的关键部位。建议训练增强只做小角度旋转、随机缩放 0.8~1.2、轻微颜色抖动和 Cutout。
from torchvision import transforms import torchvision.transforms.v2 as v2 train_transform = v2.Compose([ v2.Resize((256, 256)), v2.RandomRotation(10), v2.RandomAffine(degrees=0, translate=(0.05, 0.05), scale=(0.9, 1.1)), v2.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.1), v2.RandomErasing(p=0.2, scale=(0.02, 0.1)), v2.RandomOrder([ v2.RandomHorizontalFlip(p=0.5), v2.Identity(), ]), v2.ToImage(), v2.ToDtype(torch.float32, scale=True), v2.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])这里用 torchvision.transforms.v2,把 RandomErasing 和 ToTensor 的流程整合得更顺:v2.Normalize要求输入已经是 float 张量,所以要先ToDtype。RandomHorizontalFlip 放进 RandomOrder,让翻转与不翻转各占一半,并不是完全禁用,而是防止模型只见过正向宠物。RandomErasing 对部分遮挡的鲁棒性提升明显,p=0.2 不喧宾夺主。
4.3 类别不平衡、标签噪声和学习率三个必调参数
| 参数 | 推荐起点 | 说明 | 调试线索 |
|---|---|---|---|
| lr(主干) | 1e-4 | 预训练权重只做微调,太大会破坏特征 | loss 先降后升 |
| lr(分类头) | 1e-3 | 新分类器从零学,可以更快 | 前两轮 acc 无变化 |
| weight_decay | 1e-4 | AdamW 下不设反而易过拟合 | 验证 loss 反弹 |
| label_smoothing | 0.1 | 行为标签噪声高,平滑可缓解过度自信 | 训练 acc 接近 1 但验证 acc 低 |
| batch_size | 32 | DenseNet 特征图显存开销比 ResNet 大 | 显存不足降 16 |
类别不平衡不要先砍样本,先用 WeightedRandomSampler,让训练时每个 batch 里各类出现的期望次数接近。实现上按 label 的逆频率算每个样本权重:
import numpy as np from torch.utils.data import WeightedRandomSampler labels = train_df["label"].to_numpy() classes, counts = np.unique(labels, return_counts=True) weights = 1.0 / counts sample_weights = weights[labels] sampler = WeightedRandomSampler(sample_weights, num_samples=len(labels), replacement=True)replacement=True允许重复采样,数据量少的时候靠它把样本少的类反复喂给模型。另一个容易被忽略的问题:行为数据多数来自连续视频,训练集和验证集如果按帧随机切分,同一个视频的相邻帧会同时出现在两边,验证结果会虚高。正确做法是按视频片段分组,一个视频的所有帧只能进训练集或只能进验证集,否则模型记住的是背景而不是动作。
5. 验证你的模型:混淆矩阵、Grad-CAM 与连续帧投票
5.1 混淆矩阵:看模型到底错在哪一类
行为分类里准确率不告诉你错在哪。用 sklearn 的 confusion_matrix 输出归一化矩阵,如果「追赶」和「玩耍」互相混淆,说明动作姿态本身接近,需要更多时序信息;如果「中性」被误判成「玩耍」,通常是你没给中性类足够样本,模型只能硬着头皮二选一。可视化时直接用 class_names 索引行列,方便贴到报告里对照。
5.2 Grad-CAM:看 DenseNet 在关注哪里
行为分类的关键是模型是否盯着爪、尾巴、头而不是背景。对 DenseNet 做 Grad-CAM,需要把 features 模块的输出和梯度都抓下来:
activations = {} def forward_hook(module, input, output): activations["feat"] = output.detach() gradients = {} def backward_hook(module, grad_input, grad_output): gradients["grad"] = grad_output[0].detach() h1 = model.features.register_forward_hook(forward_hook) h2 = model.features.register_full_backward_hook(backward_hook) out = model(x) model.zero_grad() out[0, out.argmax(dim=1).item()].backward() A = activations["feat"][0] # C, H, W G = gradients["grad"][0] # C, H, W weights = G.mean(dim=(1, 2), keepdim=True) cam = (weights * A).sum(dim=0, keepdim=True).clamp(min=0) cam = cam / cam.max()register_forward_hook抓 features 最后的输出,register_full_backward_hook抓反向时传回的梯度。Grad-CAM 对每个通道算梯度的空间平均,再对激活加权求和,最后 clamp 掉负值,得到特征图上的热力区。DenseNet 的特征图分辨率约 7x7,上采样回原图看热区,如果热点全落在背景或另一只宠物身上,说明数据里有混淆项,先清洗数据再调模型。
5.3 连续帧滑动窗口投票:让 DenseNet 从图像分类变成行为分类
单帧分类不稳定是行为识别的固有难点。常见做法是每隔 3~5 帧取一帧,对最近 N 帧的 softmax 概率求平均,再取 argmax,并且仍要过置信度阈值。这样能抑制「某一帧恰好形似攻击姿势」的抖动,同时保留动作开始和结束的过渡信息。
from collections import deque window = deque(maxlen=7) for prob in stream_of_frame_probs: window.append(prob) if len(window) < 7: continue avg = torch.stack(list(window)).mean(dim=0) conf, cls = avg.max(dim=0) if conf.item() < 0.6: print("unknown") else: print(f"{class_names[cls.item()]} {conf.item():.2f}")窗口大小根据动作时长定:偷吃、扑咬这种 0.5 秒内的动作,7 帧以 20fps 抽帧约等于 0.35 秒,比较合适;长时间舔毛可以用 15 帧。deque(maxlen=7)不需要手动清旧帧,达到长度后自动挤掉最老的帧。最后把每个视频片段分别聚合出标签,再算视频级准确率,这比逐帧指标更接近真实诉求。用这个方法在验证集上跑一遍,如果视频级准确率比帧级准确率高 5 个点以上,说明投票窗口确实压掉了噪声。
本文还有配套的精品资源,点击获取