☰
狗狗表情识别实战:从数据到CNN模型全流程
2026/10/10 19:52:26 网站建设 项目流程

简介:这份资源面向具备一定Python与深度学习基础、希望上手图像分类实战的开发者与学习者,提供一套基于PyTorch框架的CNN狗狗表情识别完整方案,可用于课程设计、毕业项目或表情识别方向的入门练手。压缩包共906个文件,以896张jpg与4张jpeg图片构成数据集主体,另含3个py脚本与3个txt说明文件,整体约80.35MB,脚本依次覆盖数据集文本生成、模型训练与PyQt界面调用,txt则用于环境依赖与流程说明。数据集在预处理阶段通过短边补灰边将图片统一为正方形,并施加旋转、翻转等操作扩增样本,从而提升模型泛化能力。训练脚本会读取生成的标签文本,划分训练集与验证集完成迭代并保存本地模型权重,最终由界面脚本加载模型实现可视化识别。目前已有114人学习,适合想快速跑通从数据到界面全流程的读者参考。

1. 狗狗表情识别到底难在哪:从一张歪头照说起

你拍过那种照片吗?狗歪着头、耳朵半塌、嘴角微微上扬,人一眼就判定"它在笑"。可把同一张图丢给一个刚训好的分类模型,它很可能给出"愤怒"或者"困惑"。狗狗表情识别这件事,难点从来不在"狗"上,而在"表情"这个标签本身——它没有 ImageNet 那种干净的类别边界,同一只狗在不同角度、光照、毛色下,同一个表情能长得天差地别。

这个方向要解决的问题很具体:给一张狗脸图,输出它属于哪一类情绪(常见做法是分成开心、生气、悲伤、警惕、中性这几类)。适合谁做?想拿一个完整闭环练手 CNN 的 Python 开发者、需要给宠物类 App 加情绪标签的工程同学、以及做课程设计但不想再抄 MNIST 的人。它比猫狗二分类有意思,因为你要处理的是"标签主观 + 数据量小 + 类间相似"这三重麻烦,而这三样恰好是工业界真实项目里最常见的组合。下面我按自己踩过的路子,把数据、模型、训练、排错一条条讲清楚。

2. 数据集怎么盘:从压缩包到能喂进 CNN 的目录结构

2.1 先看清手里的图片数据集长什么样

拿到一个"含图片数据集"的压缩包,第一件事不是解压完就开跑,而是先摸清它的组织方式。狗狗表情数据集常见的两种形态:一种是按类别分文件夹(happy/、angry/…),一种是所有图混在一起配一个 CSV 标注。前者直接能用ImageFolder,后者要先做映射。我一般先跑一段统计脚本,把类别数、每类张数、图片尺寸分布、有没有损坏文件一次性看清楚,别等到训练到一半才发现某类只有 30 张。

import os from PIL import Image from collections import defaultdict root = "data/dog_emotion" # 解压后的根目录 stats = defaultdict(int) sizes = defaultdict(int) broken = [] for cls in os.listdir(root): cls_dir = os.path.join(root, cls) if not os.path.isdir(cls_dir): continue for fname in os.listdir(cls_dir): fpath = os.path.join(cls_dir, fname) try: with Image.open(fpath) as im: im.verify() # 校验文件完整性 with Image.open(fpath) as im: sizes[im.size] += 1 # 统计原始分辨率 stats[cls] += 1 except Exception: broken.append(fpath) # 记录损坏图 print("每类张数:", dict(stats)) print("尺寸分布 top5:", sorted(sizes.items(), key=lambda x: -x[1])[:5]) print("损坏文件:", broken[:10], "共", len(broken))

这段脚本的逻辑很直白:遍历每个类别目录,用verify()做一次完整性校验,再统计尺寸。参数上唯一要注意的是Image.open要开两次——verify()之后图像对象就失效了,想读尺寸得重新打开。跑完你会得到两个关键信息:类别是否均衡、尺寸是否统一。如果某类样本数不到最多的那类的三分之一,后面就得考虑重采样或者类权重,否则模型会偏向多数类,这是血泪经验。

2.2 划分训练/验证/测试集,别让同一只狗跨集

数据划分是新手最容易翻车的地方。很多人直接random.shuffle按 8:1:1 切,结果同一只狗的不同照片同时出现在训练集和验证集里,验证准确率虚高到 95%,一上真实场景就崩。正确做法是按"个体"划分:如果数据集里带了狗狗 ID 或者文件名前缀能区分个体,就按 ID 分组切分;实在没有 ID,至少保证同一张原图经过增强后的变体不跨集。

import random from sklearn.model_selection import train_test_split # 假设文件名形如 dogID_xxx.jpg,用前缀当个体标识 def get_dog_id(fname): return fname.split("_")[0] all_items = [] for cls in os.listdir(root): cls_dir = os.path.join(root, cls) if not os.path.isdir(cls_dir): continue for fname in os.listdir(cls_dir): all_items.append((os.path.join(cls_dir, fname), cls, get_dog_id(fname))) # 按个体去重后再切,避免同狗跨集 unique_dogs = list({item[2] for item in all_items}) train_dogs, temp_dogs = train_test_split(unique_dogs, test_size=0.2, random_state=42) val_dogs, test_dogs = train_test_split(temp_dogs, test_size=0.5, random_state=42) def pick(dog_set): return [it for it in all_items if it[2] in dog_set] train_set, val_set, test_set = pick(train_dogs), pick(val_dogs), pick(test_dogs) print(len(train_set), len(val_set), len(test_set))

这里random_state=42是为了可复现,团队协作时固定种子能省掉很多"为什么你跑出来和我不一样"的扯皮。test_size=0.2先切出 20% 做留出集,再从里面对半分验证和测试。如果你的数据集根本没有个体标识,退而求其次的做法是:先按图切分,再检查训练集和验证集里有没有视觉上高度相似的图(可以用感知哈希去重),这一步多花十分钟,能救回后面几小时的调参。

2.3 用 torchvision 搭一条带增强的输入管道

数据管道决定了模型能看到多少"变化"。狗狗表情数据量通常不大,增强几乎是必须的。我一般用torchvision.transforms组合:随机裁剪、水平翻转、轻微旋转和颜色抖动。注意翻转要谨慎——左右翻转对"开心/生气"这种对称表情没问题,但如果你的类别里有方向性语义(比如"看向左边"),翻转就会制造错误标签。

from torchvision import transforms from torch.utils.data import DataLoader, Dataset from PIL import Image train_tf = transforms.Compose([ transforms.Resize((224, 224)), # 统一到骨干网络输入尺寸 transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(15), # 小角度旋转,模拟拍摄角度 transforms.ColorJitter(0.2, 0.2, 0.2), # 亮度/对比度/饱和度抖动 transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) val_tf = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) class DogEmotion(Dataset): def __init__(self, items, tf): self.items = items self.tf = tf self.classes = sorted({it[1] for it in items}) self.cls2idx = {c: i for i, c in enumerate(self.classes)} def __len__(self): return len(self.items) def __getitem__(self, idx): path, cls, _ = self.items[idx] img = Image.open(path).convert("RGB") return self.tf(img), self.cls2idx[cls] train_loader = DataLoader(DogEmotion(train_set, train_tf), batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(DogEmotion(val_set, val_tf), batch_size=32, shuffle=False, num_workers=4)

Normalize里那组均值方差是 ImageNet 的统计值,用预训练权重时必须对齐,否则输入分布和骨干网络预期不符,收敛会变慢。batch_size=32是个稳妥起点,显存不够就降到 16,别硬撑。num_workers在 Windows 上有时会出问题,如果报错就设成 0 先跑通。验证集只用Resize不做增强,这是铁律——验证和测试阶段要的是稳定输入,不是随机变化。

3. 模型怎么选:从自己搭 CNN 到迁移学习的两条路

3.1 手写一个能跑通的 CNN 基线

先用自己搭的小 CNN 跑通全流程,这一步的价值不是刷分,而是让你确认数据管道、损失函数、训练循环都没问题。结构上我一般堆四组"卷积 + BN + ReLU + 池化",最后接全局平均池化和全连接。别一上来就上 ResNet,调不通的时候你分不清是数据问题还是模型问题。

import torch import torch.nn as nn class SmallCNN(nn.Module): def __init__(self, num_classes=5): super().__init__() def block(in_c, out_c): return nn.Sequential( nn.Conv2d(in_c, out_c, 3, padding=1), nn.BatchNorm2d(out_c), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.features = nn.Sequential( block(3, 32), # 224 -> 112 block(32, 64), # 112 -> 56 block(64, 128), # 56 -> 28 block(128, 256), # 28 -> 14 ) self.pool = nn.AdaptiveAvgPool2d(1) # 全局平均池化,抗过拟合 self.fc = nn.Linear(256, num_classes) def forward(self, x): x = self.features(x) x = self.pool(x).flatten(1) return self.fc(x) model = SmallCNN(num_classes=5) print(sum(p.numel() for p in model.parameters()) / 1e6, "M params")

BatchNorm放在卷积和 ReLU 之间是标准顺序,它能让训练更稳、允许更大学习率。AdaptiveAvgPool2d(1)把任意空间尺寸压成 1x1,这样输入尺寸变化时全连接层不用改。参数量大概几百万级别,单卡跑得动。这个基线在均衡数据上通常能到 60% 上下,如果连 50% 都到不了,先回去查数据管道,别急着换模型。

3.2 迁移学习:小数据集的正解

狗狗表情数据量普遍不大,从零训练很容易过拟合。常见做法是拿 ImageNet 预训练的 ResNet18 或 EfficientNet-B0,换掉最后的全连接层,先冻结骨干只训分类头,再解冻后几层做微调。这套流程在几千张图的规模上,通常比自建 CNN 高十几个点。

import torchvision.models as models def build_model(num_classes=5, freeze_backbone=True): model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) if freeze_backbone: for p in model.parameters(): p.requires_grad = False in_features = model.fc.in_features model.fc = nn.Linear(in_features, num_classes) # 新分类头默认可训练 return model model = build_model(num_classes=5, freeze_backbone=True)

weights=...IMAGENET1K_V1是 torchvision 新版接口,老版本用pretrained=True,看你环境里的版本选。冻结骨干时只有model.fc的参数在更新,训练快、显存省。等分类头收敛了(一般 5 到 10 个 epoch),再解冻layer4做小学习率微调,这一步能把准确率再往上推几个点。微调时学习率要调小,通常是初始学习率的十分之一,否则预训练权重会被大梯度冲垮。

3.3 训练循环与关键超参

训练循环本身不复杂,关键是几个超参和监控点。损失用交叉熵,优化器用 AdamW,学习率 1e-3 起步(微调阶段降到 1e-4),配合余弦退火。每个 epoch 记录训练损失、验证损失和验证准确率,重点看验证损失有没有在训练损失还在降的时候先涨——那是过拟合的明确信号。

from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR device = "cuda" if torch.cuda.is_available() else "cpu" model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=20) def run_epoch(loader, train=True): model.train() if train else model.eval() total_loss, correct, total = 0, 0, 0 torch.set_grad_enabled(train) for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) outputs = model(imgs) loss = criterion(outputs, labels) if train: optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * imgs.size(0) correct += (outputs.argmax(1) == labels).sum().item() total += imgs.size(0) return total_loss / total, correct / total for epoch in range(20): tr_loss, tr_acc = run_epoch(train_loader, train=True) va_loss, va_acc = run_epoch(val_loader, train=False) scheduler.step() print(f"epoch {epoch}: train {tr_loss:.3f}/{tr_acc:.3f} val {va_loss:.3f}/{va_acc:.3f}")

weight_decay=1e-4是 L2 正则,配合数据增强一起压制过拟合。CosineAnnealingLR让学习率按余弦曲线下降,比固定学习率收敛更平滑。torch.set_grad_enabled(train)在验证时关掉梯度,省显存也提速。判断训练是否健康,看验证准确率是否随训练准确率一起涨;如果训练准确率冲到 99% 而验证卡在 60%,别怀疑人生,是过拟合,回去加增强、加 dropout、减模型容量。

4. 训练不收敛、验证虚高:狗狗表情识别的排查清单

4.1 现象:损失一直在 0.69 附近不动

原因:分类数如果是 5,随机猜的交叉熵就是 ln(5)≈1.61,但如果你的标签全被映射成同一个类,或者CrossEntropyLoss收到的 logits 和标签维度对不上,损失会卡在一个诡异的值。更常见的是学习率太大,梯度直接炸掉,参数在坏区域震荡。

解决:先打印一个 batch 的标签分布,确认不是全 0;再把学习率降到 1e-4 试跑几个 step,看损失有没有下降。如果降了,就是学习率问题;如果还是不动,检查model.fc的输出维度是不是等于类别数。

4.2 现象:验证准确率比训练还高

原因:这在小数据集上不罕见,通常是验证集太小、或者验证集和训练集分布差异大(比如验证集恰好都是容易样本)。另一个隐蔽原因是训练时开了 dropout 和强增强,验证时全关,模型在验证模式下"发挥更好"。

解决:把验证集扩大,或者做 k 折交叉验证看均值。如果确认是分布问题,检查划分脚本有没有把简单样本都分到验证集。别急着高兴,虚高的验证分数在真实场景里会打回原形。

4.3 现象:某一类永远预测不对

原因:类间相似度高(比如"警惕"和"生气"的狗脸差别很小),或者该类样本太少,模型直接放弃它。也可能是标注本身有噪声,同一类里混了错标。

解决:先看混淆矩阵,确认是哪两类在互相混。如果是样本少,用类权重CrossEntropyLoss(weight=...)或者对少数类做过采样。如果是标注噪声,抽样人工核对几十张,把明显错的挑出来。这一步没有捷径,只能看数据。

4.4 现象:训练到一半 loss 变成 nan

原因:学习率过大导致梯度爆炸,或者输入里有异常值(比如某张图归一化后出现 inf)。混合精度训练时也可能因为数值溢出出 nan。

解决:加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0),把学习率降一个量级。再检查数据管道里有没有损坏图被读进来,前面那段完整性校验脚本就是干这个的。开了混合精度的话,先关掉确认是不是它的问题。

4.5 现象:换了台机器结果完全不一样

原因:随机种子没固定,或者两台机器的 cuDNN 版本、GPU 型号不同导致非确定性算子结果有差异。数据加载的num_workers不同也会影响增强的随机序列。

解决:在脚本开头固定torch.manual_seed、numpy.random.seed、random.seed,并设torch.backends.cudnn.deterministic = True。注意开了确定性会牺牲一点速度,但换来可复现,做实验对比时值得。跨机器复现要求完全一致本来就不现实,能对齐到小数点后两位就算成功。

5. 把模型用起来:推理封装与一个提点技巧

训练完的模型躺在 checkpoint 里没用,得能对单张新图给出预测。我一般封装一个推理函数,把预处理、前向、softmax 串起来,返回类别和置信度。这里有个容易忽略的点:推理时的预处理必须和验证集完全一致,少一个 Normalize 都会让结果飘。

import torch.nn.functional as F def predict(image_path, model, classes, device="cpu"): model.eval() img = Image.open(image_path).convert("RGB") x = val_tf(img).unsqueeze(0).to(device) # 复用验证集变换 with torch.no_grad(): logits = model(x) probs = F.softmax(logits, dim=1)[0] idx = probs.argmax().item() return classes[idx], probs[idx].item() # 用法 # cls, conf = predict("test.jpg", model, train_loader.dataset.classes)

unsqueeze(0)是给单张图补一个 batch 维度,模型永远按 batch 处理。F.softmax把 logits 转成概率,方便设阈值——置信度低于 0.5 的样本我一般直接标"不确定",而不是硬给一个类别,这在产品里比强行分类更诚实。

想让准确率再上一个台阶,有个成本低、见效快的技巧:测试时增强(TTA)。对同一张图做几次不同的变换(原图、水平翻转、轻微裁剪),分别推理后把概率平均。它相当于让模型"多看几眼"再下结论,通常能涨一到两个点,代价是推理时间翻几倍。

def predict_tta(image_path, model, classes, device="cpu", n=3): model.eval() img = Image.open(image_path).convert("RGB") views = [val_tf(img)] views.append(val_tf(img.transpose(Image.FLIP_LEFT_RIGHT))) # 水平翻转 batch = torch.stack(views).to(device) with torch.no_grad(): probs = F.softmax(model(batch), dim=1).mean(0) idx = probs.argmax().item() return classes[idx], probs[idx].item()

torch.stack把多个视图拼成一个 batch 一次前向,比循环快。mean(0)在类别维度上平均概率。TTA 的收益在类别边界模糊的数据上最明显,狗狗表情恰好属于这类。但要注意,如果你的增强里包含随机裁剪,TTA 的每次视图会不一样,结果会有轻微波动,追求稳定的话就用确定性的翻转和缩放。

最后说个我自己的习惯:每次训完模型,我都会留一个"错题本"——把验证集里预测错的图单独存一个文件夹,隔几天回头看。十次里有八次,看错题本能发现是标注问题或者某类定义太模糊,而不是模型不行。狗狗表情识别这种主观标签的任务,改数据往往比改模型管用。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询