简介:这份资源是面向计算机相关专业学生与项目实战学习者的毕业设计完整方案,主题为基于卷积神经网络的海洋垃圾识别分类。项目利用CNN自动提取图像特征,对海洋垃圾进行识别与分类,可用于毕业设计、课程设计或期末大作业,也适合希望入门深度学习图像处理的开发者。压缩包共101个文件,约74.62MB,包含20个Python源码文件、7个h5模型权重、6个csv数据表、9个xml标注文件、7个md说明文档,以及ipynb实验笔记、png/jpg图像素材和配置文件等,覆盖模型构建、数据处理与界面设计等模块。目前已有180人学习下载。项目经过严格调试,可直接运行,配套文档记录了系统架构、开发流程与常见问题解决方案,读者能借此掌握CNN在图像分类中的落地流程,理解从数据集训练到模型优化的完整链路,减少自行搭建的工作量,同时保证毕设质量。
1. 从一张糊成马赛克的海洋垃圾照片说起
去年帮一个做环保方向的朋友看他的毕业设计,他扔过来一个压缩包,说“模型训练完了,但识别率死活上不去,你帮我看看”。我打开他的数据集一看,好家伙,同一个塑料瓶的照片,有的被裁得只剩瓶盖,有的背景全是礁石,还有几张明显是从视频里截出来的,糊得连人眼都分不清是塑料袋还是水母。这就是海洋垃圾识别分类最真实的起点——不是模型不够深,而是数据本身就带着一身“海腥味”。
这个基于卷积神经网络的海洋垃圾识别分类项目,本质上解决的就是这类问题:把水下拍摄或海岸巡检拍到的垃圾图像,自动分到塑料、金属、玻璃、纸类、橡胶等几个大类里。它适合两类人:一类是正在做Python毕业设计、需要一套能跑通、能写进论文、能答辩的完整源码和文档的同学;另一类是刚接触CNN实战、想拿一个非玩具数据集练手的工程师。项目本身不复杂,但里面关于数据清洗、类别不平衡、小目标识别的处理,才是真正值钱的地方。
2. 卷积神经网络做垃圾识别的选型逻辑:为什么不是直接上YOLO
2.1 分类任务和检测任务的边界在哪
很多人一提到海洋垃圾识别,第一反应是上YOLO做目标检测,框出每个垃圾再分类。但毕业设计场景下,你要先想清楚一件事:你的数据标注到什么程度。如果数据集只给了整张图的类别标签,没有边界框,那强行上检测模型就是给自己挖坑。这个项目走的是图像分类路线,输入一张图,输出一个类别概率分布,标注成本低,训练速度快,在答辩时也更容易讲清楚网络结构。
常见做法是先用分类模型把“有没有垃圾、大概是什么类型”跑通,再考虑要不要升级到检测。我一般会建议:如果你的数据集里每张图基本只有一个主体垃圾,分类就够了;如果一张图里同时有塑料瓶、渔网、金属罐,那才需要检测。这个项目的源码里用的是经典CNN backbone加全局平均池化,最后接全连接分类头,结构不花哨,但胜在稳定、易复现。
2.2 为什么选迁移学习而不是从零训练
海洋垃圾图像的数据量通常不大,公开数据集也就几千到一万多张,从零训练一个ResNet50,过拟合几乎是必然的。项目里采用的是迁移学习策略:加载在ImageNet上预训练好的权重,冻结前面的卷积层,只训练最后的分类层,然后再解冻部分高层做微调。这样做的好处是,即使你的数据集只有两三千张,也能在几十个epoch内收敛到一个可用的精度。
参数上,初始学习率一般设在1e-3到1e-4之间,冻结阶段用1e-3,微调阶段降到1e-4或1e-5。batch size根据显存来,8GB显存跑224×224的输入,batch size设32比较稳。优化器用AdamW比纯Adam更不容易过拟合,权重衰减设1e-4。这些参数不是玄学,是多次翻车之后总结出来的安全区间。
import torch import torch.nn as nn from torchvision import models def build_model(num_classes=6, freeze_backbone=True): # 加载预训练ResNet50,weights='IMAGENET1K_V2'对应torchvision新接口 model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) # 冻结前面所有卷积层,只训练最后的全连接 if freeze_backbone: for param in model.parameters(): param.requires_grad = False # 替换最后的分类头,输入特征数2048,输出为垃圾类别数 in_features = model.fc.in_features model.fc = nn.Sequential( nn.Linear(in_features, 512), nn.ReLU(inplace=True), nn.Dropout(0.4), # dropout防止过拟合,0.4是经验值 nn.Linear(512, num_classes) ) return model # 实例化,假设有6类垃圾 model = build_model(num_classes=6, freeze_backbone=True)这段代码的关键点在于:requires_grad = False把预训练权重锁住,只让新加的分类头参与梯度更新。Dropout(0.4)是防止小数据集过拟合的常用手段,如果你发现训练集准确率远高于验证集,可以把这个值调到0.5。ResNet50_Weights.IMAGENET1K_V2是torchvision新版的权重接口,旧版用pretrained=True,但新代码建议用新接口,避免警告。
2.3 数据增强在海洋垃圾场景下的特殊处理
通用图像增强里的随机裁剪、翻转、颜色抖动,在海洋垃圾数据上不能无脑用。比如水平翻转对塑料瓶没问题,但对带有文字标签的垃圾可能改变语义;颜色抖动如果幅度太大,会把透明塑料和玻璃的色调差异抹掉。项目里用的增强策略是:随机旋转±15度、随机水平翻转、轻微颜色抖动(亮度0.2、对比度0.2、饱和度0.2),再加上随机擦除。随机擦除模拟的是水下遮挡,对海洋场景特别有用。
from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((256, 256)), # 先放大再裁剪,保留更多细节 transforms.RandomCrop(224), # 随机裁剪到224×224 transforms.RandomHorizontalFlip(p=0.5), # 一半概率水平翻转 transforms.RandomRotation(15), # ±15度旋转 transforms.ColorJitter(0.2, 0.2, 0.2), # 亮度、对比度、饱和度抖动 transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), # ImageNet统计值 transforms.RandomErasing(p=0.25) # 随机擦除,模拟遮挡 ])注意Resize到256再RandomCrop到224,这是标准做法,比直接Resize到224保留了更多空间信息。Normalize用的均值和方差是ImageNet的统计值,因为用了预训练权重,必须保持一致。RandomErasing的p值不要设太高,0.25左右比较合适,太高会让模型学不到完整物体特征。
3. 从原始图片到训练集:数据管道的搭建与坑
3.1 目录结构和标签映射的约定
项目源码里通常采用ImageFolder风格的目录结构,每个类别一个文件夹,文件夹名就是类别名。但海洋垃圾数据集经常出现类别名不统一的情况,比如“plastic”和“Plastic”混用,“metal”和“metal_can”混用。我一般会先写一个脚本把所有文件夹名统一成小写、去空格、去特殊字符,然后再生成标签映射表。
# 假设原始数据在 raw_data/ 下,先统一命名 cd raw_data for dir in */; do new_name=$(echo "$dir" | tr '[:upper:]' '[:lower:]' | tr -d ' ' | tr -d '-') if [ "$dir" != "$new_name/" ]; then mv "$dir" "$new_name" fi done这个bash脚本做的是批量重命名,把大写转小写、去掉空格和连字符。别小看这一步,我见过太多人因为文件夹名里有个空格,导致ImageFolder读取时路径解析出错,排查半天以为是代码问题。
3.2 类别不平衡的处理:重采样还是加权损失
海洋垃圾数据里,塑料类通常占一半以上,玻璃和橡胶可能只有几百张。直接训练的话,模型会倾向于预测多数类。项目里给了两种方案:一种是WeightedRandomSampler做重采样,让每个batch里各类别比例均衡;另一种是在损失函数里给少数类更高的权重。我一般推荐重采样,因为实现简单,效果直观。
from torch.utils.data import WeightedRandomSampler import numpy as np # 假设train_dataset是ImageFolder实例 targets = [label for _, label in train_dataset.samples] class_counts = np.bincount(targets) class_weights = 1.0 / class_counts sample_weights = [class_weights[t] for t in targets] sampler = WeightedRandomSampler( weights=sample_weights, num_samples=len(sample_weights), replacement=True # 允许重复采样少数类 ) train_loader = DataLoader( train_dataset, batch_size=32, sampler=sampler, # 注意:用了sampler就不能再设shuffle=True num_workers=4 )class_weights取类别频率的倒数,频率越低权重越高。replacement=True表示有放回采样,少数类会被反复抽到。num_samples设成和数据集一样大,保证每个epoch看到的样本总数不变。这里有个坑:用了sampler之后,DataLoader的shuffle参数必须去掉,否则会报错。
3.3 训练循环里的验证集划分和早停
验证集不能随便从训练集里抽,因为同一张原始图片可能被增强出多个版本,如果增强后的图同时出现在训练和验证集里,验证精度会虚高。正确做法是先按原始图片划分训练集和验证集,再对训练集做增强。项目里用的是random_split按8:2划分,然后分别套用不同的transform。
from torch.utils.data import random_split # 先加载无增强的数据集,用于划分 full_dataset = ImageFolder(root='data/processed', transform=None) train_size = int(0.8 * len(full_dataset)) val_size = len(full_dataset) - train_size train_subset, val_subset = random_split(full_dataset, [train_size, val_size]) # 再分别套用增强 train_subset.dataset.transform = train_transform # 这个写法有坑,见下文 val_subset.dataset.transform = val_transform上面这种写法其实有问题:random_split返回的Subset共享同一个dataset对象,直接改dataset.transform会把训练集和验证集的transform都改掉。正确做法是自定义一个Dataset包装类,或者用Subset加索引映射。项目源码里用的是自定义TransformSubset类,这里不展开,但你要知道这个坑的存在。
提示:验证集的transform只做Resize和Normalize,不要加随机翻转和擦除,否则验证结果不稳定。
4. 训练、评估与推理:把模型跑起来的关键步骤
4.1 训练脚本的参数配置与日志记录
训练脚本一般会暴露几个关键参数:--data_dir、--epochs、--batch_size、--lr、--freeze_epochs。freeze_epochs指的是前多少个epoch只训练分类头,之后解冻微调。我一般设10个epoch冻结,再20个epoch微调,总共30个epoch。日志里要记录训练损失、验证损失、验证准确率,最好再用tensorboard或wandb画曲线。
import argparse import torch.optim as optim parser = argparse.ArgumentParser() parser.add_argument('--data_dir', type=str, default='data/processed') parser.add_argument('--epochs', type=int, default=30) parser.add_argument('--batch_size', type=int, default=32) parser.add_argument('--lr', type=float, default=1e-3) parser.add_argument('--freeze_epochs', type=int, default=10) args = parser.parse_args() # 优化器只更新requires_grad=True的参数 optimizer = optim.AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr=args.lr, weight_decay=1e-4 ) # 学习率调度:余弦退火,让学习率平滑下降 scheduler = optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=args.epochs, eta_min=1e-6 )filter(lambda p: p.requires_grad, model.parameters())这行很重要,冻结阶段只把分类头的参数传给优化器,避免更新被冻结的卷积层。CosineAnnealingLR让学习率按余弦曲线下降,比阶梯下降更平滑,最后降到1e-6,适合微调阶段。
4.2 评估指标:准确率不够,要看混淆矩阵
海洋垃圾分类里,把玻璃误判成塑料的代价,和把塑料误判成玻璃的代价是不一样的。所以光看总体准确率不够,要看每个类别的精确率、召回率和F1。项目文档里给了混淆矩阵的绘制代码,这里说下怎么读:如果塑料类的召回率很高但精确率低,说明模型把很多其他类也预测成了塑料,可能是塑料类样本太多导致的。
from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns import matplotlib.pyplot as plt def evaluate(model, val_loader, device): model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) preds = torch.argmax(outputs, dim=1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) # 打印每个类别的详细指标 print(classification_report(all_labels, all_preds, target_names=val_loader.dataset.classes)) # 画混淆矩阵 cm = confusion_matrix(all_labels, all_preds) sns.heatmap(cm, annot=True, fmt='d', xticklabels=val_loader.dataset.classes, yticklabels=val_loader.dataset.classes) plt.ylabel('True') plt.xlabel('Predicted') plt.show()classification_report会输出每个类别的precision、recall、f1-score,直接看哪一类拖后腿。混淆矩阵用seaborn画出来,一眼就能看出模型把什么误判成了什么。如果某一类的召回率低于0.6,就要考虑是不是该类样本太少,或者增强策略不合适。
4.3 单张图片推理和批量推理的写法
训练完之后,推理脚本要能接受单张图片路径,也能接受一个文件夹。单张推理的预处理必须和验证集一致:Resize到256,CenterCrop到224,Normalize。不要用训练时的随机增强,否则同一张图每次预测结果都不一样。
from PIL import Image def predict_single(image_path, model, device, class_names): model.eval() img = Image.open(image_path).convert('RGB') transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), # 中心裁剪,不是随机裁剪 transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) input_tensor = transform(img).unsqueeze(0).to(device) # 增加batch维度 with torch.no_grad(): output = model(input_tensor) prob = torch.softmax(output, dim=1) pred_idx = torch.argmax(prob, dim=1).item() return class_names[pred_idx], prob[0][pred_idx].item()unsqueeze(0)是把单张图的维度从[3, 224, 224]变成[1, 3, 224, 224],因为模型要求输入有batch维度。torch.softmax把logits转成概率,方便看置信度。如果置信度低于0.5,建议人工复核,不要直接采信。
5. 避坑与排查:那些让精度掉10个点的细节
5.1 现象:训练损失正常下降,验证准确率一直卡在60%
原因:最常见的是数据泄漏。同一张原始图片经过不同增强后,分别进了训练集和验证集。模型在训练集里见过验证集的“变体”,验证精度虚高,但换一批新图就崩。另一个原因是验证集的transform和训练集不一致,比如验证集也加了随机翻转,导致评估结果不稳定。
解决:先按原始图片划分训练验证集,再分别套用transform。验证集只用Resize、CenterCrop、Normalize。检查random_split之后有没有共享dataset对象的问题。
5.2 现象:模型把玻璃全预测成塑料
原因:玻璃和透明塑料在视觉上确实像,尤其是水下拍摄时,颜色和纹理差异被水体吸收掉了。另外,如果玻璃类样本太少,模型会倾向于把不确定的样本归到多数类。
解决:对玻璃类做针对性增强,比如增加对比度、锐化边缘。在损失函数里给玻璃类更高的权重,或者用focal loss。如果数据允许,补充一些玻璃在岸上拍摄的清晰图片。
5.3 现象:训练到第5个epoch时loss突然变成NaN
原因:学习率太高,或者数据里有损坏的图片。海洋垃圾数据集经常从网上爬取,有些图片是0字节或者格式损坏,PIL读取时返回None,ToTensor时就会出问题。
解决:在Dataset的__getitem__里加try-except,遇到损坏图片就返回一张全黑的占位图,并打印警告。学习率从1e-3降到1e-4再试。另外,AdamW的weight_decay不要设太大,1e-4是安全值,1e-2容易导致NaN。
5.4 现象:推理时同一张图两次预测结果不同
原因:推理脚本里误用了训练时的transform,包含了RandomHorizontalFlip或RandomErasing。这些随机操作在推理时应该全部关掉。
解决:推理和验证用同一套确定性transform,只做Resize、CenterCrop、Normalize。如果用了model.eval()但还有dropout,检查是不是忘了加torch.no_grad()。
5.5 现象:GPU显存够但训练速度特别慢
原因:DataLoader的num_workers设成了0,数据加载在主进程里串行执行,GPU一直在等数据。另一个原因是图片分辨率太大,没有提前Resize,每次都在线处理大图。
解决:num_workers设成4或8,根据CPU核心数来。提前把图片Resize到256×256存到磁盘,训练时直接读取小图,能快3到5倍。如果磁盘IO是瓶颈,可以考虑把数据打包成LMDB格式。
6. 把模型推到能答辩的水平:几个提分技巧
6.1 用TTA做推理增强
TTA(Test Time Augmentation)是在推理时对同一张图做多种变换,把预测概率平均。比如原图、水平翻转图、轻微旋转图各预测一次,取平均。这个技巧在分类任务上通常能提1到3个点,而且实现简单,答辩时也是个加分项。
def predict_with_tta(image_path, model, device, class_names): model.eval() img = Image.open(image_path).convert('RGB') base_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) # 三种变换:原图、水平翻转、旋转10度 transforms_list = [ base_transform, transforms.Compose([transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.RandomHorizontalFlip(p=1.0), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])]), transforms.Compose([transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.RandomRotation((10, 10)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])]) ] probs = [] with torch.no_grad(): for t in transforms_list: input_tensor = t(img).unsqueeze(0).to(device) output = model(input_tensor) probs.append(torch.softmax(output, dim=1)) avg_prob = torch.mean(torch.stack(probs), dim=0) pred_idx = torch.argmax(avg_prob, dim=1).item() return class_names[pred_idx], avg_prob[0][pred_idx].item()注意RandomHorizontalFlip(p=1.0)和RandomRotation((10, 10))在推理时是确定性的,因为p=1.0表示一定翻转,旋转角度固定为10度。这样每次推理结果一致,不会出现“同一张图两次结果不同”的问题。
6.2 用Grad-CAM可视化模型关注区域
答辩时老师经常会问“模型到底学到了什么”。Grad-CAM可以把模型最后卷积层的梯度加权回传到原图,高亮出模型关注的区域。如果模型关注的是垃圾本身而不是背景,说明学对了;如果关注的是水面反光或礁石,那就要考虑数据清洗或增加背景多样性。
from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image import numpy as np # 目标层选最后一个卷积块 target_layers = [model.layer4[-1]] cam = GradCAM(model=model, target_layers=target_layers) # 准备输入 rgb_img = np.array(img.resize((224, 224))) / 255.0 input_tensor = base_transform(img).unsqueeze(0).to(device) grayscale_cam = cam(input_tensor=input_tensor)[0] visualization = show_cam_on_image(rgb_img, grayscale_cam, use_rgb=True)target_layers选model.layer4[-1],这是ResNet50最后一个卷积块,特征图分辨率是7×7,足够定位但不够精细。如果想让热力图更细,可以选layer3,但语义信息会弱一些。show_cam_on_image把热力图叠加到原图上,红色区域是模型最关注的地方。
6.3 模型导出和部署的注意事项
如果答辩时需要演示实时识别,可以把模型导出成ONNX格式,用ONNX Runtime推理,速度比PyTorch快不少。导出时注意输入尺寸固定为[1, 3, 224, 224],动态batch在ONNX里支持不好。
torch.onnx.export( model, # 模型 torch.randn(1, 3, 224, 224).to(device), # 示例输入 "ocean_trash_classifier.onnx", # 输出文件名 input_names=['input'], output_names=['output'], dynamic_axes={'input': {0: 'batch_size'}, # 动态batch 'output': {0: 'batch_size'}}, opset_version=11 )opset_version=11兼容性最好,别用太新的版本,有些部署环境不支持。dynamic_axes允许batch维度动态变化,但实际部署时建议固定batch size,性能更稳。
从那以后我每次拿到一个新的图像分类数据集,都会先跑一遍数据完整性检查、类别分布统计和可视化抽样,确认没有损坏图片、没有类别名混乱、没有训练验证集泄漏,再开始写模型代码。这个习惯帮我省下了至少三次通宵排查的时间。希望帮到你。
本文还有配套的精品资源,点击获取