☰
CNN动物疲劳识别实战:PyTorch实现、迁移学习与避坑全记录
2026/10/21 14:36:32 网站建设 项目流程

如果把深度学习毕业设计比作做菜,卷积神经网络只是锅和铲,真正决定味道的是食材和火候。这句话放到“基于Python的CNN动物疲劳识别”这个项目上特别成立。很多同学第一次听到这个题目会觉得,不就是二分类吗?把标好的图片丢给网络训练就完了。但真上手之后会发现,数据标注标准不统一、疲劳正样本稀缺、模型学了半天是在判断背景和光线,各种问题能把人磨到怀疑人生。这篇文章就以我帮学弟调试这个项目的完整过程为线索,把设计思路、核心代码、参数选型和避坑记录全部摊开,希望给正在做类似图像识别项目的朋友一些参考。

1. 项目究竟在做什么

1.1 这个项目要解决的实际问题

动物疲劳识别听起来像是一个很“软”的研究课题,但落到实际场景里非常具体。养殖场里,动物精神萎靡、活动量下降往往是疾病或应激的早期信号;实验动物中心里,疲劳或虚弱状态会直接影响药物实验数据的可信度;动物园和救助站也需要一种相对客观的状态判读手段,而不是完全靠人工盯着监控屏。这些场景的共同特点是:图像数据容易获取,专业判读人员不够用,而且人工观察存在明显的主观性。这个项目的目标,就是把“是否疲劳”这个需要经验积累的判断,转换成CNN模型的二分类输出。

需要强调的是,“疲劳”不等于“睡觉”。如果模型最后只学会了识别闭眼,那做出来的充其量是个睡姿判断器。真正的疲劳状态往往表现为头部低垂、眼睑半闭、对刺激反应迟钝、步态不稳、活动减少等整体特征。所以项目一开始,第一件事不是写代码,而是把“疲劳”这个标签定义清楚。这一步如果偷懒,后面所有训练出来的模型都是在给一个错误标准做拟合。

1.2 为什么选CNN而不是传统方法

传统图像分类的做法是先人工提取特征,比如颜色直方图、HOG边缘特征、纹理特征,再丢给SVM或者随机森林训练。这类方法在样本量小、背景固定的时候可以跑通,可一旦换了拍摄角度、光照条件或者动物种类,人工设计的特征往往就失效了。举个例子,如果你告诉模型“耳朵下垂代表疲劳”,但某只动物的耳朵天生就是耷拉的,这个规则马上就出问题。

CNN解决的是“自动提特征”这件事。它通过一层层卷积核从像素里组合出边缘、纹理、局部形状,再到更抽象的语义信息,完全不依赖人预先设定规则。用大白话解释,CNN像是你给了一个没见过猪跑的娃一堆猪的照片,让他自己总结“什么样算猪”;传统方法则是你逐条告诉他猪的每个特征,结果真遇到一只不一样颜色的猪,他就认不出来了。对疲劳识别这种视觉特征很难用几条规则讲清楚的任务,CNN几乎是当前最稳的选择。

1.3 适用场景与可扩展方向

这类项目的适用场景比想象中宽。除了养殖场巡检,还可以用在运输过程中的动物应激状态监测、宠物医院候诊区状态观察、野生动物救助站恢复期评估等。因为是图像分类的底子,训练好的模型只要换一套数据集,也可以迁移到人的疲劳检测、驾驶员注意力判断等方向。从毕设角度讲,用牛、羊、马、猫、狗等公开图像做验证完全可以;从工程角度讲,把模型接到监控系统里定时抽帧、自动报警也是顺手就能做的扩展。

2. 数据准备:比模型更影响成绩的环节

2.1 数据集来源与标注规范

很多初学者把时间花在调模型上,实际跑下来才发现,数据才是决定项目上限的短板。我们当时做数据标注时,专门写了一份标注规范,把“疲劳”和“正常”两个类别各列了几条可操作标准。疲劳状态参考:头部低垂、眼睑半闭或全闭、站立不稳、反应迟钝、活动量明显偏低、对周围声音缺乏警觉性。正常状态参考:目光有神、耳朵或尾巴自然摆动、行走速率正常、靠近时有躲避动作。同时规定,动物被遮挡超过30%、图像过暗、目标区域小于一定像素的样本直接剔除。

数据来源一般有两条路。一是自己采集,最好用视频抽帧而不是拍静态照片,因为视频能提供连续行为信息,疲劳状态的判断依据更充分。比如一个几分钟的短视频,每隔几秒抽一帧,就能积累出几百张图像。二是用公开数据集。很多动物行为数据集并不直接标注“疲劳”,需要自己清洗和二次标注,这个工作量不能小看。我自己当时的做法是在某个动物行为监测项目里截取监控视频帧,再用上面这套标准筛了一遍,整个过程大概花了两周。

2.2 数据增强与类别不平衡

疲劳样本永远难采集。正常状态随时能拍到,疲劳状态则要看机会,这就导致类别数量极不均衡。如果疲劳样本只有正常样本的十分之一,模型学到的很可能是一个“永远预测正常”的傻瓜分类器。我给出的建议按优先级排序:

  • 先补充数据,哪怕多截取一些视频帧也比依赖算法强;
  • 在损失函数里给疲劳类别更高权重;
  • 对少数类做简单过采样,即复制几份加进训练集;
  • 最后再考虑用Focal Loss这类更复杂的方案。

不要一上来就上生成模型,很多场景根本没到那个必要程度。

数据增强是缓解样本少和过拟合的常规手段。我们常用的组合包括随机裁剪缩放、水平翻转、亮度饱和度对比度抖动、旋转±15度。要注意一个细节:如果项目里涉及方向性行为分析,水平翻转可能会制造语义冲突;但在疲劳识别这个任务里,左右翻转不会改变疲劳状态,可以放心用。另外,训练集和验证集的预处理必须分开,验证集不需要随机增强,只需要统一缩放到模型输入尺寸。

2.3 数据划分与目录组织

数据划分有一个容易被忽视的大坑:要按照“个体”划分,而不是按“帧”划分。我用同一个动物的一段视频抽出的连续帧做训练集和验证集,结果验证准确率高得离谱,一换新场景就崩。原因是连续帧之间高度相似,模型等于提前看到了答案。正确做法是先把所有图像按动物个体分组,然后按个体切分,确保训练集和验证集里的动物完全没有重叠。

目录结构直接决定后续代码能不能偷懒。我用的是PyTorch的标准组织方式:

data/ train/ normal/ fatigue/ val/ normal/ fatigue/

这样的目录直接丢给torchvision.datasets.ImageFolder就能读取,连自定义Dataset都不用写。还有一个细节:如果数据量不大,验证集比例建议留到20%到30%,不要只留几个点,否则评估结果波动会非常大。

3. 模型结构与核心参数设计

3.1 自定义CNN还是迁移学习

这是做毕设时最常被问到的问题。我的建议非常简单:如果每类样本量超过几千张,可以从零写一个三四层的CNN,结构透明、好写进论文、训练也快;如果每类只有几百张,直接上ImageNet预训练模型,比如ResNet18、ResNet34或MobileNetV3。迁移学习等于模型已经掌握了边缘、纹理、形状这些通用视觉特征,我们只需要微调后面接的分类头,让模型快速适应“疲劳识别”这个新任务。实测在小样本场景下,迁移学习往往比自定义CNN高出一大截。

为了方便对比,我一般会准备两条路线。路线A是自定义小型CNN,好处是所有代码都公开透明,每一层的作用都能讲清楚,适合用在论文的方法章节。路线B是用ResNet18做迁移学习,把最后一层全连接替换成新的分类头,准确率更容易做高,适合追求成绩的同学。两种路线在后面的代码部分都能直接跑。

3.2 学习率、Batch Size与训练轮数

这些超参数不需要像论文一样做全面网格搜索,但几个原则要把握好。输入尺寸方面,如果迁移学习,统一使用224x224;如果自定义CNN,可以用128x128或64x64来加速。Batch size在显存允许的情况下从32开始,显存小就降到16或8。Batch太小训练震荡明显,太大则可能收敛到平坦但泛化能力差的极值点。

学习率是最容易翻车的参数。迁移学习时,预训练部分的学习率建议设小,例如1e-5到1e-4,随机初始化分类头可以设1e-3。自定义CNN可以初始设1e-3,等验证集损失不再下降时按比例降低。Epochs不要固定死,我习惯设一个较大值比如50,同时配合早停,验证集连续5轮不提升就保存最优模型并停止训练。记住一句话:训练轮数是手段,验证集指标才是目的。

3.3 损失函数与评估指标的陷阱

二分类任务首选交叉熵损失,但在类别不平衡时一定要加类别权重。在PyTorch里可以直接把class_weight传给CrossEntropyLoss,少数类给大权重,让模型更关注错误代价高的类别。另一种做法是使用带权重的BCEWithLogitsLoss,本质上也是让少数类的梯度贡献更大。

评估指标方面,只报告Accuracy在样本不平衡时非常误导。假设正常类占90%,模型全预测正常,准确率就是90%,可疲劳样本一个都找不出来。项目至少要报告精确率、召回率、F1-score和ROC-AUC,并且画出混淆矩阵。混淆矩阵能直接告诉你模型到底是在哪一类上犯错,对后续数据补充和调优方向特别有价值。

4. 完整实操:从训练到推理

4.1 环境准备与依赖清单

整个项目使用Python和PyTorch实现。建议环境版本:Python 3.9以上,PyTorch 2.0以上,torchvision、opencv-python、matplotlib、scikit-learn、tqdm都装上。有独立显卡最好,没有的话用CPU跑自定义小模型也能接受,只是训练时间会翻几倍。安装PyTorch时注意选择和CUDA匹配的版本,否则会出现设备不可用的问题。

4.2 数据加载与模型构建代码

数据加载直接用ImageFolder,配合训练集增强和验证集标准化即可。下面这段代码是标准写法,可以直接替换路径使用:

import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, transforms, models mean = [0.485, 0.456, 0.406] std = [0.229, 0.224, 0.225] train_transforms = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean, std) ]) val_transforms = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean, std) ]) train_dataset = datasets.ImageFolder('data/train', transform=train_transforms) val_dataset = datasets.ImageFolder('data/val', transform=val_transforms) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4, pin_memory=True)

这里有一个实操细节:RandomResizedCrop(224, scale=(0.8, 1.0))相当于做随机裁剪并缩放,能够模拟目标大小变化。如果动物在画面里经常很小,可以把scale下限再调低一点。num_workers在Windows上设0或2比较稳,设太高有时会报错。

自定义CNN的模型定义,关键是最后用AdaptiveAvgPool2d(1)代替手动计算全连接层的输入尺寸。这样不管输入图片是128还是224,都不需要重新算特征图大小,省掉一大半数学烦恼:

class SimpleCNN(nn.Module): def __init__(self, num_classes=2): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2) ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Dropout(0.3), nn.Linear(128, 64), nn.ReLU(inplace=True), nn.Dropout(0.2), nn.Linear(64, num_classes) ) def forward(self, x): return self.classifier(self.features(x))

迁移学习路线则简单得多,加载预训练ResNet18,冻结大部分参数,只替换并训练分类头:

def build_resnet18(num_classes=2): model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) for param in model.parameters(): param.requires_grad = False in_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(0.2), nn.Linear(in_features, 256), nn.ReLU(inplace=True), nn.Dropout(0.2), nn.Linear(256, num_classes) ) return model

需要注意,冻结参数之后如果发现效果不够好,可以放开最后一层卷积块的参数参与微调。更简单的做法是把冻结范围从全网络改成model.layer4以外的部分,这样训练时间增加不多,效果往往更好。

4.3 训练与验证流程

训练代码使用交叉熵损失和Adam优化器,配合学习率自动降低和早停。下面这个训练流程可以直接跑,关键逻辑我加了注释:

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = SimpleCNN(num_classes=2).to(device) # 迁移学习则用: # model = build_resnet18(num_classes=2).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=3) best_acc = 0.0 epochs = 50 patience = 5 no_improve = 0 def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total = 0.0, 0, 0 with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) total_loss += loss.item() * images.size(0) preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total for epoch in range(1, epochs + 1): model.train() total_loss, correct, total = 0.0, 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() total_loss += loss.item() * images.size(0) preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) train_loss = total_loss / total train_acc = correct / total val_loss, val_acc = evaluate(model, val_loader, criterion, device) scheduler.step(val_loss) print(f'Epoch {epoch}: train_loss={train_loss:.4f}, train_acc={train_acc:.4f}, ' f'val_loss={val_loss:.4f}, val_acc={val_acc:.4f}') if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'best_model.pth') no_improve = 0 else: no_improve += 1 if no_improve >= patience: print('Early stopping.') break print(f'Best val acc: {best_acc:.4f}')

训练时有几个小习惯。第一,模型保存时机看验证集准确率而不是训练准确率。第二,如果数据量小,训练集准确率很快到99%但验证集不高,说明过拟合了,优先检查数据增强和Dropout。第三,如果刚开始训练时损失完全不下降,先降低学习率,不要盲目加轮数。

4.4 模型推理与可视化

训练完之后,推理单张图片的流程非常简单:

from PIL import Image def predict_image(model, img_path, class_names): img = Image.open(img_path).convert('RGB') img_tensor = val_transforms(img).unsqueeze(0).to(device) model.eval() with torch.no_grad(): outputs = model(img_tensor) probs = torch.softmax(outputs, dim=1)[0] pred = outputs.argmax(dim=1).item() return class_names[pred], probs[pred].item() class_names = ['normal', 'fatigue'] print(predict_image(model, 'test.jpg', class_names))

可视化是毕设答辩时的加分项。训练曲线用matplotlib把每轮的train_loss、val_loss、train_acc、val_acc画出来,能直观展示收敛过程。混淆矩阵用scikit-learn生成,配合heatmap展示每个类别的分类情况。最能解释模型行为的是Grad-CAM可视化,它把模型在判断时关注的区域以热力图形式叠加在原图上。如果模型判断疲劳时关注的是动物的眼睛和姿势,说明学到的特征合理;如果热力图落在背景围栏或地面上,就要警惕模型偷懒了。

4.5 实际训练结果参考

这里放一份我实际调试时的简化日志,硬件是一张普通消费级显卡,数据量大约1200张图像,正常类800张、疲劳类400张,自定义CNN训练20轮:

轮次训练损失训练准确率验证损失验证准确率
10.680.580.720.51
50.410.830.480.77
100.260.910.350.84
150.190.950.310.87
200.150.970.280.88

混淆矩阵大致是:

实际\预测正常疲劳
正常13512
疲劳1565

从这个结果看,疲劳类别的召回率大约是65除以80,约81%;精确率是65除以77,约84%。整体准确率88%,但正常类比疲劳类更容易被分对,主要原因是疲劳样本量偏少。如果后续要提升,重点不是调网络深度,而是多采集疲劳状态的图像。

5. 常见问题与排查技巧实录

5.1 验证准确率高但新数据上表现差

这种情况最常见的原因是数据划分不合理。我之前提过,如果同一个体的连续帧同时出现在训练集和验证集,验证集就失去了代表性,得到的高分是假象。解决方法是按个体划分数据,并且采集时覆盖不同时间、不同天气、不同拍摄角度。新数据表现差的另一个原因是场景迁移,也就是训练集里动物都在干净的围栏里,应用时却出现在草地、泥地等复杂背景中。模型很可能把背景当成关键特征,这时需要用Grad-CAM检查关注区域。

5.2 训练准确率高、验证准确率低

典型过拟合。优先做三件事:增加数据增强强度,比如提高随机遮挡、随机裁剪的比例;增加Dropout比例,从0.3调到0.5;使用早停,别等验证集已经持续下降还继续训练。迁移学习也能显著缓解过拟合,因为预训练模型已经有较强的泛化表示。如果数据量实在太少,连ResNet18都容易过拟合,可以考虑只训练分类头,不微调特征层。

5.3 类别不平衡导致模型偏向多数类

当你发现验证集准确率挺高,但疲劳类的召回率很低时,基本都是类别不平衡在作怪。最简单的办法是在CrossEntropyLoss里设置class_weight,让少数类贡献更高损失。如果还不满意,可以试试Focal Loss,它的核心思想是降低易分类样本的权重,让模型硬着头皮学那些难分的疲劳样本。不过Focal Loss的超参数需要调,别指望默认参数直接起飞。

5.4 模型关注背景而不是动物

这个坑特别隐蔽。疲劳识别任务里,如果疲劳样本都是在某一种围栏环境拍摄,正常样本在另一种环境拍摄,模型根本不需要看动物,直接判断背景就能拿到高准确率。我一直强调用Grad-CAM做模型核查,就是因为它能一眼暴露这种作弊行为。如果发现热力图集中在背景区域,处理办法是尽量让不同类别的样本在背景、光线、位置上都保持混合分布,或者对图像做裁剪,将动物区域放到主体位置后再训练。

5.5 训练损失不下降或者直接变NaN

损失不下降先检查学习率,过大的学习率会让损失震荡甚至爆炸,降一个数量级再试。变NaN通常是数据里有异常值或者归一化没做好。图像分类项目里最常见的原因是归一化使用了错误的均值和方差,或者数据集中有损坏的图片。排查时先在DataLoader里逐批检查输入数据的范围,再检查标签是否有超出类别数的情况。

5.6 常见问题速查表

现象可能原因处理方式
训练准确率低模型容量不足或欠拟合加深网络、去掉过度Dropout、增加训练轮数
验证准确率低过拟合增强数据增强、提高Dropout、早停、迁移学习
验证指标虚高同一体帧泄露按个体划分数据
疲劳类召回率低类别不平衡设置类别权重、Focal Loss、补数据
模型关注背景类别与背景混淆混合背景、裁剪主体、Grad-CAM排查
损失不下降学习率太高或数据问题降低学习率、检查数据归一化
损失变NaN数据异常或学习率过大检查输入范围和标签、大幅降低学习率

6. 我的经验总结与后续扩展

6.1 给毕设项目同学的几点实操建议

整个项目做下来,我最深的感受是:不要把时间浪费在一遍遍调模型结构上,而要把最多的时间花在数据清洗和标注一致性上。数据决定了模型的天花板,模型只是在逼近这个上限。标注标准一定要提前写清楚,最好配样例图,否则标注的人各按各的理解来,模型学到的就是混乱的映射关系。训练之前固定随机种子,能让实验结果可复现,写论文的时候少掉很多头发。每次修改数据或模型之后,记录一个实验日志,把学习率、数据增强、准确率这些关键信息都记下来,你会发现在写论文的讨论部分时,这些记录比玄学记忆靠谱一万倍。

6.2 后续可以怎么扩展

这个项目的扩展空间非常大。最直接的是把二分类变成多分类,把“疲劳、正常、兴奋、疑似生病”等状态都纳入识别范围。更进一步,可以引入目标检测模型,先检测出画面里的每只动物,再对每个目标做状态识别,这样能处理群体场景下的多动物监测。如果数据是视频流,还可以把CNN提取的图像特征接上LSTM或Transformer,利用时序信息捕捉“从精神到萎靡”的变化过程,识别准确率会比单帧CNN更高。工程上,训练好的模型可以导出为ONNX或TensorRT格式,部署到边缘设备,再接上监控系统做定时抽帧和报警推送,差不多就是一个完整的AIoT应用了。

最后再分享一个小技巧:训练结束后别急着删代码,把最优模型跑一遍所有验证集图片,把预测错误的几十张图单独拉出来放到一个文件夹里,一张一张看。这个动作看着简单,但每次都能帮你发现数据标注的新问题,比如某个疲劳样本其实是反光造成的模糊图、某张正常样本其实正处于低头吃草状态。把这些“边角料”理清楚,比多调两个超参数有用得多。

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

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

立即咨询