
简介这是一个基于开源模型DeepLabV3的模式识别与机器学习课程小组结课项目同时是极市开发者平台打榜方案面向需要完成相关课程设计或进行水面漂浮物检测任务的学习者。项目以DeepLabV3为主干对水体与漂浮物进行像素级分割并依据面积阈值判断输出报警消息具备完整可运行的工程结构。资源包共258个文件压缩后约29.77MB其中120个PNG图片与100个JPG图片构成训练数据26个Python脚本覆盖模型训练与推理流程7个文本说明、2个shell脚本及配置license文件则用于环境配置与结果分析。目前已有223人学习下载。通过该资源可以快速理解语义分割任务的数据组织方式和DeepLabV3落地流程也能参考其报警判定思路为课程答辩或项目扩展提供可直接修改的代码基础。1. 课程设计选型为什么水体漂浮物分割选了DeepLabV3先说结论这个项目能打动老师的点不在网络结构有多新而在“像素级分割 面积阈值报警”这条链路是完整的业务闭环。模式识别与机器学习课程的结课作业选 DeepLabV3 做主干把水体和水面漂浮物做像素级分割再按面积阈值输出报警消息——这套方案既覆盖了课程要求的分类、特征提取、模型评估知识点又能直接跑极市开发者平台的打榜评测。适合正在找机器学习课程设计选题、准备打榜、或者想用 Python 完整走一遍“数据集 → 训练 → 后处理 → 报警”流程的人。反直觉的一点是真正决定项目分数的往往不是分割模型的 mIoU而是你在后处理里怎么定面积阈值。2. 数据准备与预处理从文件名读懂的划分逻辑到数据增强参数数据文件拿到手第一件事不是写模型而是先搞清楚这份数据是怎么组织的。这个资源包里放的都是.jpg原图命名规律非常明显ZDSfloating_objects20230206_V3_train_rivers_27_000044.jpg。拆开看ZDSfloating_objects 是项目前缀20230206 是采集日期V3 是版本号train 表示训练集rivers 和 sea 是两种场景后面的 27、1、12、24、3、23 是场景内的子序列编号最后一段是帧号。2.1 从文件名看数据集划分逻辑命名里的 rivers 和 sea 要特别重视。水体环境差异很大河流场景里漂浮物通常是树枝、白色垃圾背景有岸堤和植被海面场景里漂浮物面积更小、反光更强背景是纹理单一的水面。如果直接把两类场景混合在一起随机划分训练集和验证集模型很可能在验证集上表现尚可但到打榜或真实场景就翻车。我一般会按子序列编号做划分而不是按单张图片随机切。什么意思rivers_27下的所有帧是一个连续视频序列sea_1下的所有帧是另一个序列。如果同一个序列的部分帧进训练集、部分帧进验证集就造成了数据泄漏——验证集里出现和训练集几乎一样的画面评估指标虚高真实泛化能力被高估。划分策略上常见做法是把完整子序列作为最小单元。比如 rivers_23、rivers_24、rivers_27 等作为训练集rivers_36、sea_12 等作为验证集。这样做的好处是验证集里的水面纹理、光照条件对模型来说是陌生的评测出的 mIoU 才可信。提示极市平台打榜时评估脚本用的是平台侧的验证集本地划分只能做自检。所以本地验证指标略低不用慌关键是确认你的划分方式没有数据泄漏。数据包里只有 train 图片没有标签图这意味着你需要自己生成 mask或者项目里另有标注文件。常见的标注形式是 PNG 格式的单通道标签图背景像素为 0漂浮物像素为 1。如果你的原始标注是 JSON 多边形或多目标检测框需要先转成 mask。转的时候注意类别定义要前后一致别把水体也算成一类。2.2 标签制作与类别不平衡水体漂浮物分割是典型的类别不平衡任务。一张 512×512 的图里水体背景动辄占 90% 以上漂浮物可能只占几十个像素。直接用普通交叉熵损失训练模型会把所有像素都预测成背景因为这样 loss 已经很低了。处理办法有两个。第一是给损失函数加类别权重背景类权重设小、漂浮物类权重设大权重大致按类别像素占比的反比来。第二是改用 Dice Loss 这类对类别不平衡更鲁棒的损失或者把交叉熵和 Dice 按 0.5:0.5 组合起来用。import torch.nn as nn # 假设 mask 中背景像素占比 0.95漂浮物占比 0.05 # 权重设为反比再归一化 bg_weight 1.0 / 0.95 obj_weight 1.0 / 0.05 norm (bg_weight obj_weight) / 2.0 criterion nn.CrossEntropyLoss( weighttorch.tensor([bg_weight / norm, obj_weight / norm]) )这里weight参数按类别顺序传入索引 0 对应背景索引 1 对应漂浮物。权重值表示该类像素对 loss 的贡献倍数背景占比大所以权重小漂浮物占比小所以权重大。这样模型在训练初期就不会直接躺平成“全背景”预测。如果漂浮物目标特别小光调权重还不够。我的习惯是损失函数里掺一部分 Dice LossDice 是直接优化区域重叠的对于小目标比交叉熵敏感得多。import torch import torch.nn.functional as F def dice_loss(pred, target, smooth1.0): # pred: [N, C, H, W] 概率输出target: [N, H, W] 类别索引 pred F.softmax(pred, dim1) target_onehot F.one_hot(target, num_classespred.shape[1]).permute(0, 3, 1, 2).float() intersection (pred * target_onehot).sum(dim(2, 3)) union pred.sum(dim(2, 3)) target_onehot.sum(dim(2, 3)) dice (2.0 * intersection smooth) / (union smooth) return 1.0 - dice.mean()target_onehot把 [N,H,W] 的索引转成 [N,C,H,W] 的独热编码pred 和 target 逐元素相乘再求和得到每个样本每个类别的交集像素数。smooth参数防止分母为 0一般取 1.0。组合损失可以写成loss criterion(pred, target) 0.5 * dice_loss(pred, target)前半部分管全局分类后半部分管区域重叠。2.3 数据增强参数怎么设水面场景增强重点不是旋转而是保持“水面感”的同时让模型见过更多光照和尺度变化。天空、岸堤、反光都属于要泛化的环境。我一般用 Albumentations 库配置如下。import albumentations as A from albumentations.pytorch import ToTensorV2 train_transform A.Compose([ A.Resize(512, 512), A.RandomBrightnessContrast(p0.5, brightness_limit0.2, contrast_limit0.2), A.HueSaturationValue(p0.3, hue_shift_limit10, sat_shift_limit15, val_shift_limit10), A.HorizontalFlip(p0.5), A.VerticalFlip(p0.1), A.RandomScale(scale_limit0.15, p0.3), A.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ToTensorV2(), ])这里RandomBrightnessContrast模拟不同时段光照海面在正午和傍晚的亮度差异极大不做增强的话模型很容易把阴影当漂浮物。RandomScale模拟摄像头距离变化因为漂浮物在不同距离下像素面积差异很大。VerticalFlip只给了 0.1 的概率水面上下翻转在物理上不完全合理但能增加少量对称性先验。注意不要用 RandomCrop 把漂浮物裁掉一半尤其是在小目标场景下裁剪会导致标签残缺、训练出错误的边缘响应。3. DeepLabV3 训练落地backbone、损失函数与学习率策略DeepLabV3 是 Google 提出的语义分割网络核心思路是在 encoder 后面接一个空洞卷积空间金字塔池化模块ASPP用不同膨胀率的空洞卷积并行抓多尺度上下文再通过 decoder 恢复细节。和 U-Net 这类纯编码解码结构比它在保持大感受野的同时对边缘细节的还原更好适合漂浮物这种小目标场景。3.1 backbone 选型显存、速度和精度的权衡课程设计场景下显存通常是最大瓶颈。老师不会要求你用 8 卡训练 ResNet101你也不需要在打榜里冲千分位。我建议按显存选backbone参数量显存占用适用场景ResNet50约 40M中显存 8G 以上追求精度ResNet18约 15M低显存 4G 以下快速出结果MobileNetV3-Large约 10M低打榜推理速度要求高用segmentation_models_pytorch库加载代码很短适合小组项目快速出基线。import segmentation_models_pytorch as smp model smp.DeepLabV3Plus( encoder_nameresnet50, encoder_weightsimagenet, in_channels3, classes2, activationsoftmax, )encoder_name指定主干网络encoder_weightsimagenet加载 ImageNet 预训练权重千万不要从头训否则小数据量下收敛极慢而且精度差。classes2对应“背景 漂浮物”二分类如果你的任务还要分水体或漂浮物类型按类别数调整。activationsoftmax会在输出层直接归一化推理时取 argmax 得到类别索引。3.2 优化器与损失函数配置优化器我习惯用 AdamW权重衰减设置 1e-4 左右比 Adam 稳不容易在训练后期振荡。配合第 2 章的组合损失能同时照顾类别不平衡和边缘质量。import torch.optim as optim optimizer optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-4) scheduler optim.lr_scheduler.PolynomialLR(optimizer, total_itersepochs * steps_per_epoch, power0.9)PolynomialLR是分割任务里很常用的学习率策略多项式衰减让学习率从初始值平滑降到接近 0。total_iters是总迭代数power0.9控制衰减曲线陡峭程度。如果你用固定学习率后期容易出现 loss 震荡停在局部最优。3.3 训练循环与评估训练循环里有一个容易被忽略的细节验证时一定要用和训练一致的图像预处理否则 mask 对不上。下面是一个基础训练循环骨架。model.train() for epoch in range(epochs): train_loss 0.0 for images, masks in train_loader: images, masks images.cuda(), masks.cuda().long() optimizer.zero_grad() outputs model(images) loss criterion(outputs, masks) 0.5 * dice_loss(outputs, masks) loss.backward() optimizer.step() train_loss loss.item() scheduler.step() # 每个 epoch 结束做一次验证 miou validate(model, val_loader) print(fepoch {epoch} loss {train_loss / len(train_loader):.4f} mIoU {miou:.4f})masks.cuda().long()确保标签类型是 long因为交叉熵要求类别索引是整型。validate函数里需要把模型设为model.eval()并且包torch.no_grad()否则会额外占用显存。mIoU 的计算方式是对每个类别算 IoU再取平均背景类别通常贡献大如果漂浮物类别 IoU 低整体 mIoU 可能仍然不低所以评估时要单独看漂浮物类别的 IoU。提示如果训练中途 loss 变成 nan优先检查学习率是否过大其次检查 mask 里是否出现超出classes-1的像素值。4. 面积阈值判定与报警输出把分割掩码变成业务消息训练完模型工作只完成了一半。课程设计要输出“报警消息”这是把深度学习模型从“能分割”变成“能干活”的关键一步。分割模型输出的是每个像素的类别概率但业务方不关心像素只关心“哪里有漂浮物、多大、要不要报警”。4.1 从预测掩码到连通域拿到预测结果后第一步是把概率图转成二值掩码然后做连通域分析。这里有个陷阱直接对整张掩码做形态学开运算会抹掉小目标所以我一般先按像素阈值筛选再做连通域。import cv2 import numpy as np pred model(image_tensor).squeeze(0).argmax(dim0).cpu().numpy() mask (pred 1).astype(np.uint8) num_labels, labels, stats, centroids cv2.connectedComponentsWithStats( mask, connectivity8 ) for i in range(1, num_labels): x, y, w, h ( stats[i, cv2.CC_STAT_LEFT], stats[i, cv2.CC_STAT_TOP], stats[i, cv2.CC_STAT_WIDTH], stats[i, cv2.CC_STAT_HEIGHT], ) area stats[i, cv2.CC_STAT_AREA] if area pixel_area_threshold: print(fdetect floating object at ({x}, {y}, {w}, {h}), area {area} px)connectedComponentsWithStats返回四个值连通域数量、标签矩阵、每个连通域的统计信息、质心坐标。connectivity8表示八连通即上下左右和四个对角都算同一个连通域。stats[i, cv2.CC_STAT_AREA]是当前连通域的像素总面积pixel_area_threshold是像素级面积门槛小于这个值的连通域直接丢弃认为是噪声。4.2 面积阈值怎么定才不容易误报面积阈值是整个项目最容易翻车的地方。平台评测时判定一条报警是否有效的标准通常是“目标区域是否有实际漂浮物”。如果你的阈值定低了水波反光、岸边阴影都会被当成目标定高了小面积漂浮物漏报。我的经验是先跑一批验证集统计所有真实目标的像素面积分布再取分位数确定阈值。# 统计验证集目标面积分布 areas [] for mask in val_masks: num, labels, stats, _ cv2.connectedComponentsWithStats( (mask 1).astype(np.uint8), connectivity8 ) for i in range(1, num): areas.append(stats[i, cv2.CC_STAT_AREA]) areas np.array(areas) threshold np.percentile(areas, 20) # 取20%分位 print(farea threshold {threshold} px)这里的逻辑是验证集里 80% 的真实目标面积都大于threshold以它作为阈值可以在保证召回率的前提下滤掉大部分噪声。如果你的项目需要换算真实面积比如“面积超过 1 平方米才报警”需要已知摄像头焦距、高度、俯仰角用相似三角形标定单像素对应的物理尺寸再用physical_area pixel_area * scale^2换算。没有标定数据时用像素面积分位数更稳妥。4.3 报警消息组装报警消息的格式决定了这个系统能不能接入真实业务。我的习惯是输出结构化 JSON包含事件编号、类别、面积、边界框、置信度和时间戳。import json import time def build_alert(image_name, x, y, w, h, area, prob): alert { event_id: f{image_name}_{x}_{y}_{w}_{h}, type: floating_object, area_px: int(area), bbox: [int(x), int(y), int(w), int(h)], confidence: round(float(prob), 4), timestamp: time.strftime(%Y-%m-%d %H:%M:%S), } return json.dumps(alert, ensure_asciiFalse)event_id用文件名加坐标拼接保证同一个漂浮物在多帧里能被追踪。confidence取连通域内所有像素概率的均值比用单像素概率更稳因为单个点的概率波动大。这样组装出来的报警消息既可以直接打印也可以写到日志文件或推送到平台接口。5. 常见问题排查训练到打榜的五个坑这一章不是泛泛的注意事项全是我在类似项目里真实踩过的坑。每条按“现象 → 原因 → 解决”展开你大概率会碰到至少其中两条。5.1 训练 loss 下降但 mIoU 不涨漂浮物全漏检现象训练损失从 0.8 降到 0.2log 很好看但验证集 mIoU 只有 0.3预测结果里漂浮物区域几乎全被预测成背景。原因类别不平衡太严重交叉熵 loss 被背景类主导。模型把全部像素预测为背景loss 也低但漂浮物类别 IoU 为 0。解决给损失函数加类别权重或者像第 2 章那样组合 Dice Loss。我排查这个问题的标准动作是打印验证集每一类的 IoU单独看漂浮物 IoU不要只盯着整体 mIoU。5.2 漂浮物边缘破碎一个目标被拆成好几个连通域现象推理结果里同一个漂浮物被预测成几个碎片连通域分析后报警消息里出现多个相邻的小框。原因单尺度预测对边缘不鲁棒尤其是小目标模型在目标中心响应高、边缘响应低二值化后边缘被切断。解决推理时采用多尺度融合把原图缩放到 0.75、1.0、1.25 倍分别预测概率取平均后再 argmax。另一个办法是分割后做一次形态学膨胀把边缘裂缝接上。5.3 报警频繁误报水面反光被判定为漂浮物现象海面场景下阳光反射区域被预测为漂浮物连通域面积超过阈值后触发报警。原因训练数据里反光区域没有标注模型把亮度高的区域误认为目标。同时也说明面积阈值定得太低反光区域在像素面积上和小漂浮物重叠。解决先按第 4 章的分位数方法重算阈值把阈值提高到覆盖 90% 的真实目标。如果还不行给训练数据里明显反光的区域补标注为背景或者在增强里加大亮度扰动让模型见过更多高亮背景。5.4 本地 mIoU 高但提交到打榜平台分数上不去现象本地验证集 mIoU 0.78平台评测分数只有 0.55差距明显。原因评估口径不一致。极市平台的评分指标可能不是逐像素 mIoU而是按报警消息的准确率、召回率计算的业务指标。本地只关注分割精度忽略了报警层面的评估。解决提交前先跑平台提供的评估工具或者把报警消息输出到评测脚本里对一遍。经验是分割 mIoU 只要不太差业务分数的差距主要来自面积阈值和报警判定逻辑而不是网络结构。5.5 训练时显存溢出batch size 调到 2 还是 OOM现象输入 512×512batch size 4ResNet50 主干显存直接爆掉。原因DeepLabV3 的 ASPP 模块有多个并行空洞卷积分支特征图占用比普通 encoder 高很多。batch size 小到 1 仍然溢出说明瓶颈可能在 validation 阶段的中间变量上。解决开启梯度累积模拟大 batch。同时把验证阶段包在torch.no_grad()里关闭梯度图。另外检查数据加载时是否把整张图的无归一化 float 类型直接送进模型Normalize之前保持 uint8能省近一半显存。6. 进阶技巧多尺度推理与帧间去抖让报警更稳报警类项目里单帧检测总会有随机误报尤其是水面上漂浮物随波浪时隐时现。这里分享一个投入产出比很高的技巧帧间稳定性过滤。与其盲目调低阈值牺牲召回不如在时间维度上做约束——同一个位置的目标连续 N 帧都被检测到才触发报警。from collections import deque class FrameStabilizer: def __init__(self, window3, min_hits2): self.window window self.min_hits min_hits self.history deque(maxlenwindow) def update(self, detections): # detections: list of (cx, cy, area) self.history.append(detections) if len(self.history) self.window: return [] confirmed [] base self.history[-1] for det in base: count 1 for frame in list(self.history)[:-1]: for other in frame: if abs(other[0] - det[0]) 20 and abs(other[1] - det[1]) 20: count 1 break if count self.min_hits: confirmed.append(det) return confirmed stabilizer FrameStabilizer(window3, min_hits2) confirmed stabilizer.update(current_detections)window3表示看最近 3 帧min_hits2表示目标在 3 帧里至少出现 2 次才确认报警。坐标距离阈值 20 像素是经验值取决于目标大小和摄像头帧率帧率越高目标位移越小阈值可以收紧。这个过滤器能把海面反光、飞鸟等偶然出现的噪声压掉大半而且不增加任何推理开销。多尺度推理和帧间去抖配合使用效果更明显。我做过一次对比单帧单尺度推理时误报警率 18%加上多尺度融合降到 12%再加 3 帧去抖降到 4% 以下。多尺度代码很简单核心就是多次 resize 推理后取概率均值这一点在 C 部署时成本高但课程设计里用 Python 实现零成本。从那以后凡是我经手的目标检测或分割报警类项目都会强制把帧间滤波作为后处理标配哪怕老师只要静态图片的结果我也会写上一段平滑逻辑——因为真实场景的视频流里没有稳定性的模型根本没法用。希望这些记录对你的课程设计或打榜项目有帮助。本文还有配套的精品资源点击获取