基于卷积神经网络的海洋垃圾识别分类:从数据清洗到模型部署全流程

📅 发布时间:2026/10/9 17:27:28
基于卷积神经网络的海洋垃圾识别分类:从数据清洗到模型部署全流程
简介这是一套面向计算机相关专业学生的毕业设计资源主题为基于卷积神经网络的海洋垃圾识别分类适合正在准备毕设、课程设计或期末大作业的学习者也可作为深度学习项目实战练习的参考。资源包共101个文件约74.62MB涵盖20个Python源码文件、7个H5模型权重、9个XML标注文件、6个CSV数据表、7个Markdown说明文档及若干PNG、JPG图像与配置文件完整覆盖模型构建、数据处理、界面设计与项目说明等环节。项目围绕CNN自动提取图像特征展开通过垃圾图像数据集训练与优化实现对不同形态、材质海洋垃圾的识别分类帮助理解从数据准备到模型部署的全流程。目前已有180人学习。源码经过严格调试可直接运行配套文档记录了系统架构、开发流程与常见问题解决方案便于快速上手并保证毕设质量。1. 海洋垃圾识别这个选题为什么卷积神经网络是绕不开的那条路做毕业设计选到「海洋垃圾识别分类」这个方向很多人第一反应是找个现成模型套上去跑通就完事。但真正动手就会发现水面反光、浪花泡沫、漂浮物遮挡、类别极度不均衡这几个问题叠在一起随便拿个迁移学习模型直接推理准确率能虚高到 95%实际部署到一段真实拍摄的视频上却频繁把白色浪花判成塑料袋。这就是这个选题的核心矛盾数据集里的分布和真实水面场景的分布差距太大而卷积神经网络CNN恰好是当前处理这类图像分类任务最成熟、最容易复现、也最容易在答辩时讲清楚的技术路线。这个项目适合两类人一类是本科毕业设计需要完整跑通「数据采集→标注→训练→评估→推理」全流程的同学另一类是已经会调 PyTorch 但没做过完整图像分类项目、想拿一个真实场景练手的开发者。它不要求你有 GPU 集群一张 8GB 显存的消费级显卡足够把主干网络跑起来也不要求你从零推导反向传播但要求你理解卷积核、池化、批归一化这些组件在海洋垃圾这种纹理弱、背景杂的场景里到底起什么作用。后面几章我会按「数据怎么准备→模型怎么搭→训练怎么调→坑在哪→怎么验证」的顺序把每个环节的参数和判断依据讲清楚你照着改就能跑。2. 从原始图片到可训练数据集海洋垃圾数据的清洗与增强2.1 为什么这个任务的数据集不能直接拿来用海洋垃圾图像和常规的 ImageNet 分类任务有个本质区别类间差异小、类内差异大。一个半透明的塑料袋漂在水面上和一片白色泡沫板在低分辨率下几乎无法区分而同一个塑料瓶正面拍、侧面拍、被水草缠住拍像素分布能差出好几个量级。常见公开数据集的标注质量参差不齐很多图片是从视频里抽帧得到的相邻帧高度相似如果直接按 8:2 随机划分训练集和验证集会出现同一段视频的帧同时出现在训练和验证里导致验证准确率虚高这就是典型的 data leakage。我一般会先做三件事按视频来源分组划分、剔除分辨率低于 224×224 的样本、对类别做一次人工抽检。分组划分的意思是同一个视频抽出来的所有帧只能进训练集或只能进验证集不能混。这一步用几十行脚本就能完成但能直接把虚高的准确率打回真实水平。2.2 用脚本完成分组划分与类别统计import os import hashlib import random from collections import defaultdict from PIL import Image # 假设原始数据按 video_xxx/frame_yyy.jpg 组织 RAW_DIR raw_data MIN_SIZE 224 VAL_RATIO 0.2 def group_by_video(root): groups defaultdict(list) for video in os.listdir(root): vpath os.path.join(root, video) if not os.path.isdir(vpath): continue for fname in os.listdir(vpath): if not fname.lower().endswith((.jpg, .png)): continue fpath os.path.join(vpath, fname) # 过滤过小图片避免训练时反复 resize 引入伪影 with Image.open(fpath) as im: w, h im.size if min(w, h) MIN_SIZE: continue groups[video].append(fpath) return groups def split_groups(groups, val_ratioVAL_RATIO, seed42): videos sorted(groups.keys()) random.Random(seed).shuffle(videos) n_val max(1, int(len(videos) * val_ratio)) val_videos set(videos[:n_val]) train, val [], [] for v, paths in groups.items(): (val if v in val_videos else train).extend(paths) return train, val if __name__ __main__: groups group_by_video(RAW_DIR) train, val split_groups(groups) print(ftrain{len(train)} val{len(val)} videos{len(groups)})这段脚本的关键点有三个。第一group_by_video按视频目录聚合保证同一来源的帧不会被拆散。第二MIN_SIZE过滤掉过小图片因为后续统一 resize 到 224 时小图放大后边缘会出现明显插值伪影模型容易学到这些伪影而不是垃圾本身的纹理。第三seed固定保证每次划分结果一致方便复现实验。参数上VAL_RATIO设 0.2 是常见做法如果视频总数少于 20 个建议改成按帧数比例划分并手动检查否则验证集可能只覆盖一两个视频评估结果波动会很大。2.3 增强策略哪些能用哪些会帮倒忙海洋垃圾场景的增强不能照搬通用配方。水平翻转、随机裁剪、颜色抖动这三样基本安全但垂直翻转要慎用因为水面反射会让上下翻转后的图像出现物理上不合理的纹理模型可能学到这种伪影。另外MixUp 和 CutMix 在小数据集上容易让模型欠拟合我一般只在训练后期、模型已经能稳定收敛后再开且 alpha 设小一点0.2 左右。import torch from torchvision import transforms train_tf transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale(0.7, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) val_tf transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])RandomResizedCrop的scale下限设 0.7是为了避免裁出只剩水面没有垃圾的碎片。归一化用的均值方差是 ImageNet 统计值如果你用预训练权重就必须保持一致否则第一层卷积的输出分布会偏移收敛变慢。验证集只做 resize 和归一化不做任何随机增强这是评估的基本纪律。3. 主干网络选型与分类头改造从 ResNet 到轻量化的取舍3.1 为什么优先选 ResNet 而不是自己堆卷积毕业设计里最常见的翻车是「自己设计一个五层卷积网络」结果训练准确率卡在 60% 上不去。原因不是网络不够深而是没有残差连接和批归一化梯度在反向传播时衰减太快。ResNet 的残差结构让梯度能直接跨层回传批归一化把每层输入分布拉回稳定区间这两点对海洋垃圾这种低对比度图像尤其重要。常见做法是直接用torchvision.models.resnet18或resnet50把最后的全连接层换成你的类别数。选 18 还是 50取决于你的数据量和显存。数据量在 5000 张以下、类别不超过 10 类ResNet18 足够训练快、过拟合风险低数据量上万再考虑 ResNet50。如果答辩要求「轻量化」可以换 MobileNetV3 或 EfficientNet-B0但要注意这些网络对输入分辨率更敏感224 以下掉点明显。3.2 改造分类头与冻结策略import torch.nn as nn from torchvision import models def build_model(num_classes6, backboneresnet18, pretrainedTrue): if backbone resnet18: net models.resnet18(weightsmodels.ResNet18_Weights.DEFAULT if pretrained else None) feat_dim net.fc.in_features elif backbone mobilenet_v3: net models.mobilenet_v3_small(weightsmodels.MobileNet_V3_Small_Weights.DEFAULT if pretrained else None) feat_dim net.classifier[-1].in_features net.classifier[-1] nn.Linear(feat_dim, num_classes) return net else: raise ValueError(backbone) # 替换分类头加一层 Dropout 抑制过拟合 net.fc nn.Sequential( nn.Dropout(p0.3), nn.Linear(feat_dim, num_classes) ) return net def freeze_backbone(net, freezeTrue): # 只冻结特征提取部分分类头始终可训练 for name, param in net.named_parameters(): if fc in name or classifier in name: param.requires_grad True else: param.requires_grad not freezeDropout(p0.3)这个值不是拍脑袋定的海洋垃圾数据集通常几千张p 设 0.5 会导致训练损失下降很慢设 0.1 又压不住过拟合0.3 是我在几个类似任务上试出来的平衡点。freeze_backbone的作用是先用冻结特征训练几轮分类头让随机初始化的全连接层先收敛再解冻整体微调这样能避免一开始就大学习率把预训练权重带偏。常见做法是冻结训练 3 到 5 个 epoch然后解冻学习率降到原来的十分之一。3.3 类别不均衡的处理加权损失还是重采样海洋垃圾数据里「塑料瓶」和「塑料袋」样本往往占一半以上而「渔网」「金属罐」可能只有几百张。直接训练会让模型偏向多数类。两种处理方式一是在损失函数里给少数类更高权重二是用 WeightedRandomSampler 重采样。我一般优先用加权损失因为它不改变每个 epoch 看到的样本总数训练时间可控。import numpy as np import torch from torch.utils.data import WeightedRandomSampler def make_class_weights(labels, num_classes): counts np.bincount(labels, minlengthnum_classes).astype(np.float32) # 防止除零取倒数后归一化 weights 1.0 / np.maximum(counts, 1.0) weights weights / weights.sum() * num_classes return torch.tensor(weights, dtypetorch.float32) def make_sampler(labels, num_classes): counts np.bincount(labels, minlengthnum_classes).astype(np.float32) class_w 1.0 / np.maximum(counts, 1.0) sample_w class_w[labels] return WeightedRandomSampler(weightssample_w, num_sampleslen(labels), replacementTrue)make_class_weights返回的权重直接传给nn.CrossEntropyLoss(weight...)。注意权重归一化到类别数之和是为了让损失量级和未加权时接近学习率不用重新调。如果你同时用了加权损失和重采样容易矫枉过正少数类被反复采样导致过拟合所以二选一即可。4. 训练循环里的参数设置与显存控制4.1 学习率、批大小与优化器的搭配海洋垃圾分类任务上我常用的起点是AdamW学习率 3e-4权重衰减 1e-4批大小 32。为什么不用 SGD因为毕业设计的训练轮数通常有限30 到 50 epochSGD 收敛慢AdamW 能在更少轮数内到达可用精度。批大小 32 是在 8GB 显存下跑 ResNet18 加 224 输入的稳妥值再大容易 OOM再小批归一化统计不稳定。import torch from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR def build_optim(net, lr3e-4, wd1e-4, epochs40): optimizer AdamW( filter(lambda p: p.requires_grad, net.parameters()), lrlr, weight_decaywd ) scheduler CosineAnnealingLR(optimizer, T_maxepochs, eta_minlr * 0.01) return optimizer, schedulerfilter(lambda p: p.requires_grad, ...)这行很重要冻结主干时如果不过滤优化器仍会为冻结参数维护动量状态浪费显存。CosineAnnealingLR的eta_min设成初始学习率的 1%避免最后几轮学习率降到 0 导致完全停止更新。4.2 混合精度训练与显存监控如果显存吃紧开混合精度是最直接的缓解手段通常能省 30% 到 40% 显存速度也有提升。但要注意混合精度下损失缩放如果没配好会出现梯度下溢导致 loss 变 NaN。from torch.cuda.amp import autocast, GradScaler scaler GradScaler() for epoch in range(epochs): net.train() for imgs, labels in train_loader: imgs, labels imgs.cuda(), labels.cuda() optimizer.zero_grad() with autocast(): logits net(imgs) loss criterion(logits, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() scheduler.step()GradScaler会自动调整损失缩放系数遇到 inf 或 NaN 会跳过该步并降低缩放。如果你发现训练中途 loss 突然变 NaN先检查是不是学习率太大其次看数据里有没有损坏图片导致输出异常。监控显存可以用torch.cuda.max_memory_allocated()每个 epoch 打印一次方便判断还能不能加大批大小。4.3 验证指标怎么选才不骗自己准确率在类别不均衡时参考价值有限。我一般同时记录宏平均 F1和每类召回率。宏平均 F1 对少数类敏感能暴露模型是不是只学会了多数类。如果某个类别召回率长期低于 0.5要么是该类样本太少要么是和其他类视觉上太像需要回去看混淆矩阵。from sklearn.metrics import f1_score, classification_report def evaluate(net, loader, devicecuda): net.eval() preds, gts [], [] with torch.no_grad(): for imgs, labels in loader: imgs imgs.to(device) logits net(imgs) preds.extend(logits.argmax(1).cpu().numpy()) gts.extend(labels.numpy()) macro_f1 f1_score(gts, preds, averagemacro) print(classification_report(gts, preds, digits3)) return macro_f1classification_report会直接打出每类的 precision、recall、f1答辩时这张表比一条准确率曲线有说服力得多。注意验证时一定要net.eval()并配合torch.no_grad()否则 Dropout 和批归一化会按训练模式运行结果不可复现。5. 避坑与排查海洋垃圾分类项目里最容易翻车的五件事5.1 验证准确率 99% 但推理一塌糊涂现象训练日志里验证集准确率很快冲到 0.99但拿一段新视频抽帧推理结果惨不忍睹。原因几乎可以断定是数据划分时同一视频的帧泄漏到了验证集模型记住了背景而不是垃圾本身。解决回到 2.2 的分组划分脚本确认split_groups是按视频聚合后再划分的打印训练集和验证集的视频 ID 列表确保没有交集。5.2 训练 loss 不降反升几轮后变 NaN现象前几个 epoch loss 正常下降突然某一轮跳到 NaN之后再也回不来。原因常见有三种——学习率太大、混合精度损失缩放异常、数据里有损坏图片。解决先把学习率降到 1e-4 重跑如果还 NaN关掉混合精度再不行就写脚本遍历所有图片用 PIL 打开一遍把抛异常的图片移出数据集。我遇到过一张截断的 JPEGPIL 能打开但解码出全黑图模型对全黑图输出极端 logits直接把 loss 带飞。5.3 少数类召回率始终上不去现象宏平均 F1 卡在 0.6查看分类报告发现「渔网」这类召回率只有 0.3。原因要么样本太少要么该类和其他类在颜色纹理上高度重叠。解决先确认加权损失或重采样是否生效打印每个 batch 的类别分布如果确认生效仍无改善考虑对该类做针对性增强比如随机旋转角度加大、加运动模糊模拟水下拍摄或者干脆合并视觉上难以区分的子类减少类别数。5.4 换用 MobileNet 后精度断崖式下跌现象ResNet18 上宏 F1 有 0.85换成 MobileNetV3 后掉到 0.65。原因轻量网络的特征通道数少对低对比度目标的表达能力弱且默认输入分辨率下感受野偏小。解决把输入分辨率从 224 提到 288 或 320同时把学习率调低到 1e-4训练轮数增加 20%。如果还不行说明这个任务本身不适合过度轻量化答辩时如实说明取舍即可。5.5 推理速度慢单帧超过 200ms现象模型精度达标但部署到边缘设备上单帧推理要 200ms 以上达不到实时。原因没做推理优化模型仍在 FP32 下运行且输入预处理在 CPU 上串行执行。解决用torch.jit.trace导出 TorchScript开 FP16 推理预处理改成 GPU 上的torchvision.transforms或直接写 CUDA kernel。常见做法是先把模型导出 ONNX再用推理引擎加载速度能提升 2 到 3 倍。6. 用混淆矩阵和 Grad-CAM 验证模型到底学到了什么训练完拿到一个还不错的 F1不代表模型真的在看垃圾。我习惯做两件事来验证画混淆矩阵看错误集中在哪些类对之间用 Grad-CAM 看模型关注区域是不是落在垃圾上而不是水面反光。这两步在答辩时也是加分项因为它证明你不只是调包而是理解模型行为。混淆矩阵用 sklearn 的confusion_matrix配合 seaborn 热力图即可重点看非对角线上的大块。如果「塑料袋」和「白色泡沫」互相误判严重说明这两个类在特征空间里没被分开可以考虑加一个二阶段分类器专门区分这两类。Grad-CAM 的实现用pytorch-grad-cam库最省事指定目标层为 ResNet 的layer4把热力图叠加到原图上。from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image import numpy as np def visualize_cam(net, img_tensor, target_layer, class_idxNone): cam GradCAM(modelnet, target_layers[target_layer]) grayscale_cam cam(input_tensorimg_tensor.unsqueeze(0), targetsNone if class_idx is None else [class_idx]) grayscale_cam grayscale_cam[0] # img_tensor 需反归一化到 [0,1] 再叠加 rgb img_tensor.permute(1, 2, 0).cpu().numpy() rgb (rgb - rgb.min()) / (rgb.max() - rgb.min() 1e-6) return show_cam_on_image(rgb, grayscale_cam, use_rgbTrue)target_layers选layer4是因为它感受野最大热力图覆盖区域和语义目标对应最好选浅层会得到细碎边缘参考价值低。如果热力图高亮区域集中在图像边缘或水面波纹上说明模型学到了背景捷径这时候要回去检查数据增强是不是不够或者验证集划分仍有泄漏。一个我踩过的坑Grad-CAM 默认对 batch 里所有样本做反向如果一次传多张图显存会爆。正确做法是一次只传一张或者用batch_size1的循环。另外热力图叠加前一定要把 tensor 反归一化回 [0,1]否则叠加出来的图颜色完全失真看不出模型在看哪。最后说个习惯每次改完数据划分或增强策略我都会固定随机种子重跑一遍把宏 F1、每类召回率、混淆矩阵三样一起存到实验记录里。这样当答辩老师问「你这个提升是真实提升还是随机波动」时你能直接翻出三次不同种子的结果对比而不是靠嘴说。这个习惯帮我省过很多次后悔药也希望帮到你。本文还有配套的精品资源点击获取