☰
DeepLabV3+语义分割实战:水面漂浮物检测与报警全流程
2026/10/1 13:00:13 网站建设 项目流程

简介:这是一份基于开源语义分割模型DeepLabV3+完成的小组结课项目,同时入选极市开发者平台打榜实践,适合模式识别与机器学习课程的学生、需要完成课程设计或准备算法赛题的开发者参考。项目以水体及水面漂浮物为检测对象,利用像素级分割结果计算目标面积,并按阈值自动输出告警信息,展示了从数据集构建、模型训练到业务逻辑判断的完整链路。压缩包共258个文件、约29.77MB,核心内容包括120张png与100张jpg水面场景图像样本、26个py模型与训练脚本、7个txt标注或说明文件,另有shell脚本、Markdown说明等配套材料,目录划分清晰,便于按模块复现。目前已有223人学习下载,尤其适合希望快速上手语义分割开源模型、并借鉴极市平台项目组织方式的学习者。借助该项目的代码和样本,既能理解DeepLabV3+在真实场景中的应用,也能掌握面积阈值报警的落地思路。

1. 水面漂浮物检测:这个 DeepLabV3+ 课程项目从分割到报警做了什么

汛期河道监控画面里,水面上漂着的塑料瓶、树枝和白色泡沫,靠人眼盯十几路视频根本盯不过来。这个项目要解决的,就是把这部分巡检自动化。团队用开源模型 DeepLabV3+ 做主网,对画面里的「水体」和「水上漂浮物」做像素级语义分割——不是画检测框,而是把每一个属于漂浮物的像素找出来,再按面积阈值判断该不该输出报警消息。项目同时也是极市开发者平台的打榜题,数据来自真实河道与近海监控场景。适合两类人:一类是模式识别与机器学习课设还没定题,想找一个能讲清楚原理又能完整跑通的方案;另一类是刚接触语义分割,想从数据准备一路走到报警输出的工程师。下面按数据、模型、训练、避坑、后处理的顺序完整拆一遍。

2. 先从文件名读懂数据集:从扁平目录到可训练的分割数据

整个交付里最容易被忽略的,是这批 ZDSfloating_objects 图片:几十张看起来差不多的 JPG 平铺在根目录,没有标注目录、没有子文件夹。直接拿去训练,连 train/val 怎么切都不知道。我拿到资源后第一件事不是写模型,而是把文件名解码、目录重构、数据体检这三步走完。

2.1 解读 ZDSfloating_objects 文件名:这些字段决定你怎么切分数据

文件名按固定规律编码,比如ZDSfloating_objects20230206_V3_train_rivers_27_000044.jpg,拆开看:

字段示例值含义对训练的影响
项目前缀ZDSfloating_objects数据集标识无
日期20230206采集日期数据版本与时间跨度
版本V3数据版本筛选版本
集别train训练/测试归属需要重新按场景划分
场景rivers / sea河道 / 近海场景差异是分布差异
点位编号27 / 1 / 36拍摄点 ID同点位相邻帧强相关
帧号000044帧序号时间排序

这里值得细说的是场景和点位两个字段。rivers 和 sea 是差异很大的两种场景:河道水面窄、背景有岸堤;海面开阔、有浪花纹理和反光。如果你随机把文件切成 train/val,同一个点位 27 的相邻帧会同时出现在训练集和验证集里,验证 mIoU 会被抬高好几个点,部署到没见过的点位直接崩。所以后面切分一定要按「点位」分组,不能按文件随机切。

2.2 目录重构:把平铺的 JPG 归位

原始目录下只有图,没有 labels 子目录,也没有 annotation 文件。分割任务训练时需要两张图:一张三通道 RGB 原图,一张单通道 mask(PNG),mask 的每个像素值就是类别编号。类别编号按课程设计常见约定:0 是背景(岸堤、天空等),1 是水体,2 是漂浮物。标注文件在项目交付包里单独存在,后缀是 .png,与原图同名对应。

先把图片按场景字段归位:

mkdir -p data/rivers/images data/rivers/labels mkdir -p data/sea/images data/sea/labels find /path/to/original -name "*_rivers_*" -exec mv {} data/rivers/images/ \; find /path/to/original -name "*_sea_*" -exec mv {} data/sea/images/ \;

这段脚本只做一件事:按文件名里的场景字段把图挪进对应目录。find 配合 -exec 在文件量几百张时足够直接,量大时建议改用 Python 加 shutil 批量处理。注意如果同时挪标注,要找同名 .png 一起处理,别只挪原图、标注留在原地,后面 Dataset 类加载时图找得到、mask 找不到,一跑就崩。

然后做数据体检。语义分割训练过程中,损坏图片是最容易翻车的地方:

from PIL import Image from pathlib import Path import collections for root in [Path("data/rivers/images"), Path("data/sea/images")]: sizes = collections.Counter() broken = [] for img_path in sorted(root.glob("*.jpg")): try: img = Image.open(img_path).load() sizes[img.size] += 1 except Exception: broken.append(str(img_path)) print(root, "分辨率分布:", sizes.most_common(3)) print(root, "损坏图片:", broken[:5])

脚本做两件事:算出每个场景的分辨率分布,列出打不开的坏图。分辨率分布决定后面 RandomCrop 的窗口大小和要不要做统一缩放;坏图必须提前清掉,不然训练到一半 DataLoader 抛异常,整个跑批白等。常见做法是顺手把分辨率悬殊的图先缩放到同一档位,比如最长边 1024,避免 batch 内图尺寸差异过大。血泪经验:体检这步别省,打榜数据里混进一两张损坏图是常态。

2.3 看一眼标签分布:漂浮物究竟占多少像素

分割项目里最容易被跳过的,是统计标签分布。跑一段脚本确认三类像素的全局占比:

import numpy as np from PIL import Image from pathlib import Path label_dir = Path("data/rivers/labels") overall = np.zeros(3, dtype=np.float64) for p in label_dir.glob("*.png"): mask = np.array(Image.open(p)) counts = np.bincount(mask.ravel(), minlength=3)[:3] overall += counts print("全局像素分布:", overall / overall.sum())

如果漂浮物的全局占比低于 1%,第 4 章的损失函数就不能用裸的 CrossEntropy。这个数值还直接决定要不要做小目标过采样。把统计结果写进课设 PPT,评审老师基本都会认可数据准备工作是扎实的。

3. DeepLabV3+ 选型与改造:ASPP、Decoder 和三处关键代码

数据就位后选模型。课程项目为什么选 DeepLabV3+ 而不是 U-Net 或 PSPNet,评审几乎必问。先把原理讲透,再落到代码改动。

3.1 空洞卷积与 ASPP:不降分辨率地扩大感受野

分割任务和分类任务有个本质差别:分类可以一直池化到 1×1,分割要求输出和输入同尺寸,所以特征图分辨率不能丢太多。标准 CNN 层层下采样后,高层特征感受野够大,但分辨率低,直接上采样预测的结果边缘很糊。DeepLab 系列的核心思路,是不靠下采样扩大感受野,而是用空洞卷积(dilated convolution)。

空洞卷积在卷积核里插入空洞。rate=2 时,一个 3×3 的核覆盖的实际区域相当于 5×5;rate=6 时覆盖到 13×13。模型不损失分辨率,还能让每个输出像素看到更大的上下文。这对本项目意义很直接:水面是超大类,漂浮物是超小类,模型既要能「看到」整片水面判断环境,又不能因为降采样把漂浮物的细节丢掉。

DeepLabV3 在此基础上提出 ASPP(Atrous Spatial Pyramid Pooling),把四种尺度的分支并行叠起来:一个 1×1 卷积、三个空洞卷积(rate 6 / 12 / 18)、一个全局平均池化,最后拼接。多尺度的价值在于,漂浮物的尺寸在画面里变化非常大——近处垃圾可能占 200×200 像素,远处可能只有 8×8,单个固定感受野很难兼顾。

3.2 Encoder-Decoder:把低层细节接回高层语义

DeepLabV3 有个明显短板:ASPP 输出的特征图经过 16 倍下采样后直接上采样,边界细节恢复不了。漂浮物的边缘恰恰是关键,报警面积就差在边缘像素的归属上。DeepLabV3+ 加了一段轻量 Decoder:把主干浅层输出用 1×1 卷积降通道,再和上采样后的深层特征拼接,最后卷积融合。

这个结构和 U-Net 的 skip connection 相似,但 V3+ 的 Decoder 更克制,只在最后一级做融合,参数量增加不多。在漂浮物这种边界模糊的目标上,Decoder 带来的边界改善肉眼可见:气泡、细碎树枝这类小目标,在 V3 上容易断成几截,在 V3+ 上能连成完整连通域,后续面积统计才有意义。

3.3 从开源模型到课设代码:三处必改点

极市这类打榜项目,模型通常要求复现论文。课设项目更推荐直接用 segmentation_models_pytorch,省掉从零搭 Encoder 的时间,把精力留给数据处理和报警逻辑:

import segmentation_models_pytorch as smp model = smp.DeepLabV3Plus( encoder_name="resnet101", # 主干网络 encoder_weights="imagenet", # ImageNet 预训练权重 in_channels=3, # RGB 输入 classes=3, # 背景 / 水体 / 漂浮物 )

三个参数要解释清楚。encoder_name 控制主干网络,三档选择直接对应显存和精度:

encoder_name显存占用推理速度mIoU 潜力课设适用度
mobilenet_v2低快中高,笔记本可跑
resnet50中中较高高,平衡之选
resnet101高慢最高低,需要好卡

encoder_weights 用 ImageNet 预训练,课程数据集只有几万张时效果差距明显,随机初始化要多花大量 epoch 才能追上。classes 必须从默认的 21(Pascal VOC 类别数)改成 3。

如果不用这个库,手工改 torchvision 里的 DeepLabV3 时要改对地方:一是分类头类别数,二是加载预训练权重时分类器形状对不上,需要用 strict=False 加载 backbone 部分:

import torchvision.models.segmentation as seg model = seg.deeplabv3_resnet101(pretrained=False, num_classes=3) state = torch.load("你的预训练权重路径.pth") backbone_state = {k: v for k, v in state.items() if "classifier" not in k} model.load_state_dict(backbone_state, strict=False)

因为 strict=False 会放过主干网络里可能存在的键名不匹配,加载后要打印 missing keys 确认缺失的全是分类头,别拿一个权重完全没加载进去的模型跑训练。这一点我们课设 review 时栽过——加载完没检查,训练十分钟 loss 不降,查了半天才发现预训练权重根本没进去。

4. 训练配置:损失函数、增强策略和按点位的验证集划分

模型定义好之后,训练策略是决定项目成绩的关键。这里讲三件事:损失函数怎么调、数据增强怎么配、验证集怎么切,每一件都直接影响最终报警是否靠谱。

4.1 类别不均衡与混合损失函数

从第 2 章的标签统计能看出,漂浮物类别在全局像素里占比大概率低于 1%,甚至低于 0.1%。这种数据直接用 CrossEntropy,模型会把所有像素都学成水体或背景,验证集整体准确率看着很高,漂浮物类别 IoU 却是 0。常见做法是把 CrossEntropy 和 Dice Loss 混在一起:

import torch.nn.functional as F def mixed_loss(pred, mask, dice_weight=0.5): ce = F.cross_entropy(pred, mask) prob = F.softmax(pred, dim=1) # (B, 3, H, W) onehot = F.one_hot(mask, num_classes=3).permute(0, 3, 1, 2).float() inter = (prob * onehot).sum(dim=(2, 3)) union = prob.sum(dim=(2, 3)) + onehot.sum(dim=(2, 3)) dice_per_class = 1 - (2 * inter + 1) / (union + 1) # 每类分别算 dice = dice_per_class.mean() return ce + dice_weight * dice

CE 负责整体稳定,Dice 直接对类别重叠度求梯度,对小类敏感。dice_weight 从 0.5 起调,太大时早期训练震荡明显。如果漂浮物占比特别极端,还可以把第 2 类单独加权重到 2~3 倍。这个混合损失写进组会讲稿,评委一般会追问 CE 和 Dice 的互补性,提前准备好回答。

4.2 数据增强:随机裁剪比 resize 更适合小目标

分割增强套路和分类不太一样。初学者容易直接套分类那套:Resize 到固定尺寸再随机裁剪。在漂浮物项目里,先 Resize 会把小目标的像素信息提前压缩掉,损失函数再强也救不回来。我一般用随机裁剪,保留原始分辨率信息:

import albumentations as A from albumentations.pytorch import ToTensorV2 train_transform = A.Compose([ A.RandomCrop(513, 513), A.HorizontalFlip(p=0.5), A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.3), A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ToTensorV2(), ])

crop 尺寸 513 是 DeepLab 系列论文里的常见值,显存允许可换 769,更大视野对小目标上下文有增益。RandomBrightnessContrast 模拟一天不同时段光照,尤其水面反光差异大。验证集只做 Normalize,不做随机增强,保证评估稳定。

4.3 验证集划分:按点位分组,不按文件随机切

第 2 章提过,文件名里的点位字段直接影响验证集可信度。同一个点位连续拍的帧之间非常相似,随机切分相当于「泄题」。正确做法是用 sklearn 的 GroupShuffleSplit,group 是文件名解析出的点位 ID:

from sklearn.model_selection import GroupShuffleSplit def parse_group(name): parts = name.split("_") return f"{parts[4]}_{parts[5]}" # "rivers_27" / "sea_1" imgs = sorted((data_dir / "images").glob("*.jpg")) groups = [parse_group(p.name) for p in imgs] gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(gss.split(imgs, groups=groups))

parse_group 按第 2 章解码的字段位置取名,把 rivers_27、sea_1 作为同一组。验证集点位完全没在训练集出现过,mIoU 才反映真实泛化能力。不做这一步,验证 mIoU 可能虚高 10 个点以上,报警阈值的标定也会跟着偏。

4.4 训练超参的起点

从头调超参太费时间,我一般用一套分割任务普适的初始配置,然后只看验证集曲线微调:

参数推荐初始值说明
优化器SGD(momentum=0.9)分割任务比 Adam 稳定
初始学习率0.007配合 poly 衰减到 0
批大小4~8取决于显存与 crop 尺寸
训练轮数40~80观察验证 mIoU 是否进入平台期
学习率策略poly按 (1 - iter/total)^0.9 衰减

batch size 建议先从 2 跑通显存,再往上加,直接上 8 翻车概率不低。训练中每 500 步保存一次 checkpoint,同时记录每类的 IoU 而不是只记平均 mIoU——这个习惯在下一章会看到,几乎是一个项目表象与真实效果不符时的第一道防线。

5. 避坑记录:训练和报警里最容易翻车的五个点

这个项目做下来,踩坑的时间比写代码的时间多。下面五条血泪经验按「现象 → 原因 → 解决」写,基本都是我们组里真实遇到过、并且回看时能对上根因的。

5.1 验证集 mIoU 很高,预测图却一塌糊涂

现象:训练时验证集 mIoU 到了 0.9 以上,导出模型跑出来的预测图几乎整张是背景色,漂浮物区域一片黑。

原因:类别极度不均衡。背景和水体占绝大多数像素,平均 mIoU 被大类别拉高,漂浮物类别 IoU 可能只有 0.05。平均指标好看不代表小类别被学会。

解决:训练日志里同时输出每类的 IoU,专门盯漂浮物那一行的变化;损失函数换成第 4 章的混合损失;如果漂浮物占比低于 0.1%,给类别 2 单独加权重。切换损失后,漂浮物 IoU 从接近 0 提升到能用的水平是常见结果。

5.2 水面波纹和反光被当成漂浮物

现象:推理时水面波纹、阳光反射区域被预测成漂浮物类别,报警消息里全是误报。

原因:波纹和反光在局部纹理上和漂浮物边缘很像,加上数据多来自白天监控,光照差异大。增强里只做了亮度对比度,没有针对性强化反光负样本。

解决:训练中保留充足的纯水面反光样本,让模型见过反光的多样性;后处理里对漂浮物连通域做最小面积过滤,低于阈值直接丢弃。这一层后处理通常能压掉一半以上误报。

5.3 报警量忽高忽低:面积阈值不可跨图比较

现象:面积阈值定在 5000 像素,有些图大量报警,有些图一个不报,看起来毫无规律。

原因:图片分辨率不一致,或模型输入做了固定 resize。相同面积的漂浮物,在不同原始分辨率下换算成像素数完全不同,固定像素阈值等于拍脑袋。

解决:统一推理流程,计算「漂浮物像素 / 水体像素」的面积占比,而不是绝对像素数;阈值先拿验证集统计面积占比分布再定,别凭感觉填。具体代码在第 6 章。

5.4 训练 OOM:模型和数据一起压爆显存

现象:batch size 设 8,crop 尺寸 769,ResNet101 主干,一启动直接 CUDA OutOfMemory。换 batch 2 能跑但太慢。

原因:三个因素叠加:ResNet101 中间特征图大、769×769 输入大、batch 8 放大太多。另外还要确认机器上有没有其他进程占显存——显存占用是门玄学。先看占用再改代码:

nvidia-smi --query-gpu=memory.used,memory.free --format=csv

解决:先跑 batch=1 确认基线,用梯度累积模拟大 batch;或把 crop 降回 513,观察验证曲线再平衡。主力卡 12G 的话,ResNet50 主干加 513 crop 加 batch 4 是性价比很高的组合。

5.5 河道效果好、近海效果崩

现象:训练集里 rivers 图多、sea 图少,模型在 rivers 点位验证 mIoU 很高,换到 sea 场景,漂浮物分割几乎失效。

原因:两个场景数据分布差距大——水面颜色、光照角度、漂浮物形态都不同,模型只拟合了数量多的那个场景。

解决:按场景分层采样,或把 sea 图片做水平翻转、亮度增强后复制进训练集,把场景比例拉平。课设时间紧,也可以接受「分场景出两个模型」,推理时按监控点位路由到对应模型——这在工业落地反而是常见的省力方案。

6. 报警消息输出:面积阈值判断与验证

训练完成后,模型输出的是每个像素的类别概率。报警模块要做的,是把概率图转成可用的业务消息。

import numpy as np from scipy import ndimage logits = model(tensor).squeeze(0) # (3, H, W) pred = logits.argmax(0).cpu().numpy() # 0 背景, 1 水体, 2 漂浮物 floating = (pred == 2).astype(np.uint8) labels, n = ndimage.label(floating) areas = ndimage.sum(floating, labels, range(1, n + 1)) ratio = floating.sum() / max((pred == 1).sum(), 1) for i, area in enumerate(areas, 1): if area >= min_area: ys, xs = np.where(labels == i) msg = { "image": name, "area_ratio": round(float(ratio), 4), "blob_area": int(area), "center": [int(xs.mean()), int(ys.mean())], } # 推送到业务端:print 或通过 HTTP / MQTT 转发

这个后处理里两个东西要标定。min_area 是单个连通域的最小像素数,低于它的噪点不报警;area_ratio 是最终报警判断指标,按第 5 章 5.3 的方法在验证集上统计分布,找到误报与漏报的平衡点。验证时不要把验证集当测试集,最好留一段完全没参与训练的点位序列,模拟新点位接入后的效果。课设演示时,把这段后处理串成一个小脚本,从图片输入到报警消息一条命令跑完,展示效果会完整很多。这个项目要交的核心是数据、模型配置和报警逻辑这三件套,拿到资源后按第 2 章的目录重构和数据体检顺序走一遍,训练、推理、报警这条链路就能完整复现。从那以后,我每次做分割项目都会强制走一遍这个流程:先统计每类 IoU,再跑一段未参与训练的数据看报警消息,确认面积阈值不是拍脑袋定的,才敢把模型交出去。希望帮到你。

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

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

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

立即咨询