简介:面向图像分割算法研究与水下机器人视觉应用,该数据集提供1525张训练图像和110张测试图像,均为640×480分辨率。场景中前景目标涵盖人类、海草、珊瑚、岩石、鱼等多类,背景简洁、标注细致,mask为调色后的彩色图像,0表示背景,便于直观检查。压缩包内含2000个文件,其中bmp位图用于保存原始图像和分割标签,jpg提供内容预览,另有一个Python可视化脚本,可随机抽取一张样本并生成原图、GT、蒙版三者对比图,方便快速验证标注质量。数据已经过随机旋转等预处理增强,类别标签清晰,可直接用于FCN、U-Net、DeepLab等主流分割模型的训练与评测,也适合作为课程设计或科研基线数据集使用。整体包体约159MB,已有1081人学习,覆盖从数据准备到效果验证的完整闭环,可显著节省数据收集与清洗时间。
1. 水下目标图像语义分割:为什么“8分类”这个设定比想象中难做
做水下目标检测的工程师多少都有过这种体验:陆地上很成熟的语义分割算法,换到水下数据上就开始花式翻车。原因不难理解——水下图像有光照衰减、蓝绿偏色、悬浮颗粒散射,还有前景目标跟背景颜色接近,靠普通RGB特征很难分得干净。而“水下目标图像语义分割(8分割)”这个标题,看起来只是把类别标出8个而已,实际上从类别标签的定义、标记得不干净,到可视化调试,每一步都有坑。这个图像分割数据集方案(数据集+类别标签+可视化代码)适合两类人:一类是想做水下机器人、渔业监测、海底巡检的小团队,想拿一套现成数据把基线模型跑起来;另一类是刚接触语义分割的学生,需要一个从标签到可视化都闭环的最小工程来理解整个流程。接下来我会按从数据到落地的顺序,把类别标签怎么设、可视化代码怎么写、训练参数怎么调,以及我踩过的坑完整拆开。
2. 水下目标图像分割数据集结构:8个类别、标签编码方式与文件组织
2.1 八个类别怎么定义:像素值就是类别标签
语义分割和物体检测的本质区别在于:检测输出的是框,分割输出的是每个像素的类别。做水下目标分割,类别标多少不是随便拍脑袋,而是要看实际任务需求。常见的做法是把水下场景拆成这么8个语义类别:背景(水体)、鱼、珊瑚、海草/藻类、沙地/海底底质、岩石、人工物体(管道/沉船残骸)、其他生物(海星/螃蟹之类)。这里最关键的一条规则是:背景必须是第0类。
为什么要背景必须是0?因为主流的语义分割模型在计算损失时,背景像素占据的绝对数量会显著影响梯度方向。如果背景标成其他非零数字,一方面要求数据加载器额外做一次类别偏移,另一方面在可视化代码里也容易把背景当有效目标显示出来。我自己习惯在数据集目录里放一个labels.txt,每行一个类别名,从0到7按顺序写。这个文件看起来不起眼,但它是训练脚本和可视化脚本共同依赖的“唯一事实来源”,类别名的顺序一旦和标签图里的像素值对不上,后面全乱。
标签文件的载体,业界最通用的是PNG。PNG能无损保存像素值,而且支持单通道灰度图(每个像素存一个8位整数,值就是类别ID),也支持带调色板的P模式(调色板索引值等于类别ID)。这两种写法在各种框架里都能直接读。不要用jpg保存标签——有损压缩会让类别交界处的像素值发生漂移,比如本来是2的像素变成了3或4,这种错位在训练时几乎查不出来,但会让模型在边界上一直学歪。
2.2 从RGB彩色标签转成单通道ID:一张可复用的转换脚本
有些甲方的水下数据集,标签图不是单通道灰度,而是用RGB颜色来区分类别,比如珊瑚是红色、鱼是黄色。这种数据看起来直观,但训练时不能直接用,因为模型输出的是类别ID,不是颜色。你需要一个颜色映射表来转换。常见做法是定义color_to_id字面量,把每个类别的RGB颜色映射到类别ID,然后逐像素做映射。
这里我一般用PIL而不是OpenCV,因为PIL可以直接读模式信息,OpenCV读出来的三通道顺序还要注意BGR和RGB的互换问题,容易翻车。下面这段代码可以跑通:读原始RGB标签图、逐像素查映射表、输出单通道ID图。
from PIL import Image import numpy as np # 颜色映射表:RGB -> 类别ID # 顺序要和 labels.txt 完全一致 color_to_id = { (0, 0, 0): 0, # 背景/水体 (255, 255, 0): 1, # 鱼 (255, 0, 0): 2, # 珊瑚 (0, 255, 0): 3, # 海草 (128, 128, 128): 4, # 沙地 (139, 90, 43): 5, # 岩石 (255, 165, 0): 6, # 人工物体 (255, 192, 203): 7, # 其他生物 } img = Image.open("mask_rgb.png").convert("RGB") arr = np.array(img, dtype=np.uint8) h, w = arr.shape[:2] # 白底mask,-1表示“未定义颜色”,转换后要人工检查 label = np.full((h, w), -1, dtype=np.int32) for rgb, cls_id in color_to_id.items(): r, g, b = rgb label[(arr[:, :, 0] == r) & (arr[:, :, 1] == g) & (arr[:, :, 2] == b)] = cls_id # 如果还有未定义像素,说明调色板漏了颜色,必须处理 unknown = label == -1 if unknown.sum() > 0: print(f"警告:{unknown.sum()} 个像素没有映射,置为背景 0") label[unknown] = 0 # 保存成单通道P模式PNG,像素值即类别ID out = Image.fromarray(label.astype(np.uint8), mode="P") out.save("label_id.png")这段代码我特意保留了一个unknown检查逻辑。实际项目中,RGB标签图里几乎一定存在“颜色交界处的抗锯齿像素”,也就是边缘半透明过渡色,它们不属于任何颜色的精确RGB值。如果你不处理,这些像素会保留-1,最后astype(np.uint8)把它变成255,相当于凭空多出一个第255类,训练时loss会一直警告。所以这里选择把未知像素置为背景0,或者干脆在项目里约定“非0像素全部是目标”,具体看你数据的标注意图,但一定要处理,不要跳过。
转换完成后,还要做一步验证:把生成的label_id.png可视化看一遍,确认每个类别的轮廓和原图对齐。这一步说起来是常识,但我在项目里见过太多次“转换脚本跑完了没人看图片”的情况,结果类别颠倒、物体边缘被削掉一圈,后面模型训一周才发现,非常亏。
3. 可视化代码怎么写:掩码叠加、调色板映射与一键检查
3.1 最小可视化代码:把标签图叠加到原图上
可视化代码在这个标题里不是点缀,它是整个数据闭环的调试入口。语义分割的标签图是单通道灰度图,人眼直接看是黑的,根本看不出内容。你需要一段代码把类别ID映射成彩色,并叠加到原图上,这样你才能快速判断“标签到底标得准不准”。
我给一个自己常用的最小实现,依赖只有PIL和numpy:
from PIL import Image import numpy as np # 调色板:索引->RGB,背景透明处理 palette = { 0: (0, 0, 0), # 背景:黑色 1: (255, 255, 0), # 鱼:黄色 2: (255, 0, 0), # 珊瑚:红色 3: (0, 255, 0), # 海草:绿色 4: (128, 128, 128), # 沙地:灰色 5: (139, 90, 43), # 岩石:棕色 6: (255, 165, 0), # 人工物体:橙色 7: (255, 192, 203), # 其他生物:粉色 } def visualize_mask(image_path, mask_path, output_path, alpha=0.6): # 读原图和单通道标签图 img = Image.open(image_path).convert("RGB") mask = Image.open(mask_path).convert("P") # 关键:不要convert("RGB") img_arr = np.array(img, dtype=np.float32) mask_arr = np.array(mask, dtype=np.uint8) # 像素值=类别ID # 生成彩色掩码 color_mask = np.zeros((mask_arr.shape[0], mask_arr.shape[1], 3), dtype=np.uint8) for cls_id, rgb in palette.items(): color_mask[mask_arr == cls_id] = rgb # 线性叠加:只对非背景区域混合 overlay = img_arr.copy() fg = mask_arr != 0 overlay[fg] = img_arr[fg] * (1 - alpha) + color_mask[fg] * alpha # 背景区域尽量压暗,方便肉眼区分 overlay[~fg] *= 0.6 Image.fromarray(overlay.astype(np.uint8)).save(output_path)参数说明:alpha=0.6控制掩码的透明度,0.6的意思是最终图像里60%是掩码颜色、40%是原图颜色。对于鱼和珊瑚这种小目标,alpha可以调到0.8,因为目标细小,不够突出的话很难看出轮廓;对于岩石和沙地这种大面积目标,alpha调到0.4就够了,太高会把海底纹理完全盖住。mask.convert("P")是这里最容易出错的一行——如果这里用了convert("RGB"),像素值就会被解释成RGB颜色,完全没法拿来做mask_arr == cls_id的索引判断。
顺带说一句可视化代码的一个实用细节:输出目录里我会把原图和叠加图并排保存成一个大的对比图,方便直接拖到看图软件里翻。只保存叠加图也行,但人眼对“原图-掩码”对照的需求远超想象,并排图能减少至少一半的无效切换。
3.2 一键巡检:把整个数据集跑一遍看渲染结果
单张可视化有了,接下来要解决的是批量检查。一个水下数据集动辄几千张图,不可能手动一张张跑可视化再打开看。常规做法是写一个批量脚本,每隔N张输出一张对比图,并把统计信息写进控制台。
import os import numpy as np from PIL import Image data_root = "data/train" for idx, name in enumerate(sorted(os.listdir(os.path.join(data_root, "images")))): if idx % 10 != 0: # 每10张抽查1张 continue img_path = os.path.join(data_root, "images", name) mask_name = name.replace(".jpg", ".png") mask_path = os.path.join(data_root, "masks", mask_name) if not os.path.exists(mask_path): continue mask = Image.open(mask_path).convert("P") mask_arr = np.array(mask, dtype=np.uint8) # 统计每类像素占比,观察类别不均衡 counts = np.bincount(mask_arr.ravel(), minlength=8) total = mask_arr.size ratio = counts / total # 只打印占比超过1%的类别,减少刷屏 active = {i: round(ratio[i], 4) for i in range(8) if ratio[i] > 0.01} print(f"{name}: 类别占比 {active}")批量巡检的意义有两个。第一是找“标签没对齐”的样本:如果某张图的鱼类像素占比异常高,或者珊瑚类占比突然归零,这张图大概率有问题,值得拉出来单独看叠加图。第二是用统计规律摸清类别分布。水下数据集的类别不均衡非常严重,一个场景里水体背景经常占70%以上,鱼可能只占2%。如果你连这个分布都不知道,后面选loss权重就是闭着眼睛做。我建议每个数据集到手的第一个动作就是跑一遍这个统计脚本,把每类的平均占比记下来,这比模型训完再看混淆矩阵要早得多。
4. 用这套数据跑通一个语义分割模型:数据加载器、训练参数与loss选择
4.1 数据加载器核心代码:同步transform、标签保持单通道
数据集只是原料,要变成“能训练的语义分割模型”还需要数据加载器这一环。水下分割任务的加载器有几个关键点:读取时原图和标签图必须用同一套几何变换(翻转、裁切、缩放),不能各自独立随机化;标签图经过变换之后仍然要保持单通道整数类型,不能让插值把类别ID变成小数。
我给出一个PyTorch Dataset的典型写法:
import torch from torch.utils.data import Dataset from PIL import Image import os import numpy as np from torchvision import transforms class WaterMaskDataset(Dataset): def __init__(self, image_dir, mask_dir, crop_size=(512, 512)): self.image_dir = image_dir self.mask_dir = mask_dir self.crop_size = crop_size # 只取对应图像格式的文件 self.names = [n for n in os.listdir(image_dir) if n.endswith((".jpg", ".png")) and os.path.exists(os.path.join(mask_dir, n.rsplit(".", 1)[0] + ".png"))] self.to_tensor = transforms.ToTensor() # 变成0~1之间的float tensor def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] stem = name.rsplit(".", 1)[0] img = Image.open(os.path.join(self.image_dir, name)).convert("RGB") mask = Image.open(os.path.join(self.mask_dir, stem + ".png")).convert("P") # 随机裁切:保证 image 和 mask 使用同一区域 if self.crop_size: iw, ih = img.size cw, ch = self.crop_size left = torch.randint(0, iw - cw, (1,)).item() top = torch.randint(0, ih - ch, (1,)).item() img = img.crop((left, top, left + cw, top + ch)) mask = mask.crop((left, top, left + cw, top + ch)) # 图像转tensor,标签保持uint8的tensor img_tensor = self.to_tensor(img) mask_tensor = torch.from_numpy(np.array(mask, dtype=np.int64)).long() return img_tensor, mask_tensor逻辑说明:convert("P")读取后np.array(mask, dtype=np.int64)得到的就是像素索引数组,值域是0到7,正好作为 CrossEntropyLoss 的 target。这里故意不用transforms.Resize,因为Resize的默认插值算法是双线性,用在标签图上会生成柔和的边缘像素,导致类别值变得不是整数。如果你一定要固定输入尺寸,就用Image.Resampling.NEAREST最近邻插值,保住类别的离散性。
参数说明:crop_size=(512, 512)是常见水面以下任务的分辨率权衡。512x512能保留足够的纹理细节,同时显存占用可控;如果你跑的是人工管道或小目标检测场景,可以提升到768或1024,但要让batch_size降下来。随机裁切这段代码我加了边界保护,如果iw - cw小于0,torch.randint会报错,所以实际项目中我会在初始化时判断一次图片最小尺寸,小于crop_size的先做padding,这里为了可读性省略了。
4.2 训练参数怎么调:参考配置和loss选择
语义分割模型的选型不复杂,水下目标这种中小型数据集,从U-Net或DeepLabV3起步是性价比最高的做法。模型选型不是重点,重点是一组能跑起来不白忙活的训练参数。我常用的参考配置如下:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 输入尺寸 | 512x512 | 兼顾细节与显存 |
| batch_size | 8 | 单卡RTX 3060/3090可用 |
| 初始学习率 | 1e-4 | AdamW默认权重衰减1e-4 |
| 最大epoch | 80 | 早停门槛10个epoch |
| 学习率策略 | Cosine annealing | 尾部收敛更稳 |
| 损失函数 | CrossEntropyLoss带类别权重 | 类别不均衡时用 |
| 数据增强 | 随机翻转+随机亮度+随机高斯噪声 | 水下图像噪声大 |
这里我重点说loss选择。语义分割默认用交叉熵,但如果你的水下数据里背景占70%以上,直接用普通交叉熵会让模型觉得“全预测成背景”就很不错了。解决思路有两个:一是给loss传weight向量,让稀有类别(鱼、人工物体)的梯度权重放大;二是改用Focal Loss,它在交叉熵基础上加了难样本权重,对水下这种前景小且边界模糊的任务更友好。
import torch.nn as nn # 根据第3.2节的统计结果设定权重,按各类别占比的倒数归一化 # 背景0.72, 鱼0.02, 珊瑚0.05, 海草0.08, 沙地0.06, 岩石0.03, 人工0.01, 其他0.03 class_weights = torch.tensor([0.5, 12.0, 6.0, 4.0, 5.0, 10.0, 20.0, 10.0], dtype=torch.float32).cuda() criterion = nn.CrossEntropyLoss(weight=class_weights, ignore_index=-1)这段代码里的权重是一种常见的“倒数缩放”手法:占比0.01的人工物体给了20的权重,背景这类占比大但训练价值低的类别压到0.5。注意不要机械地按占比100%取倒数,背景占比0.72如果取倒数约1.4,反而比其他目标都高。实际调试时我会把背景类权重先压到0.5以下,然后跑10个epoch看验证集mIoU,如果背景不节制地膨胀,再往下调。这个调参过程没有固定答案,但方向是确定的:你的loss权重训练的是“网络认为哪类像素值得学”,而不是统计学的严格反比。
5. 水下分割最容易翻车的5个坑:从标签错位到评估失真
5.1 坑:标签图被convert成RGB,类别索引全崩
现象:可视化脚本跑出来的叠加图,彩色区域一片大乱,鱼的轮廓里混着珊瑚的颜色,整体看起来像七彩马赛克。分布上则表现为“某个类别的像素数完全不对”。
原因:PIL里面Image.open("mask.png").convert("RGB")会把单通道P模式的调色板索引值按调色板展开成RGB,于是像素值从0~7变成了一组彩色RGB。后面拿mask_arr == 1去匹配,自然匹配不到几个像素。
解决:所有标签读取统一写成Image.open(path).convert("P"),读取后的np.array()直接就是类别ID。在数据加载器和可视化脚本里都只用这个约定。这里没有例外,不要图省事。
5.2 坑:类别不均衡导致稀有类别永远分割不出来
现象:训练20个epoch后,验证集mIoU看起来还行,鱼和人工物体却完全没有预测出来,模型把所有像素都判成背景。
原因:水下数据集的背景(水体/沙地)占了大多数,普通交叉熵的梯度被大类别主导,稀有类别的类别梯度几乎淹没。
解决:先跑第3.2节的统计脚本拿到各类占比,然后按4.2节的class_weights给loss加权。如果加了权重还是学不进去,检查一下是不是初始学习率太大导致模型在一开始就锁死在“全背景”的坏局部极小——这种情况把初始学习率降到3e-5重训试试,通常能解开。
5.3 坑:可视化叠加时把背景也算进透明混合
现象:输出图里所有非目标区域变成半透明的灰色,而目标区域反而颜色不对,整体像盖了一层雾。
原因:叠加代码用overlay = img * (1 - alpha) + color_mask * alpha对全图做混合,背景区域的黑色掩码也参与混色,等于把原图压暗了一半。
解决:只对前景区域做混合,背景区域维持原图或单独压暗。回到3.1节的代码,核心就是fg = mask_arr != 0,然后只对overlay[fg]做加权,overlay[~fg] *= 0.6只是加深背景方便观察。这个判断值得在所有可视化代码里保留。
5.4 坑:数据集划分泄漏,验证集虚高
现象:训练集mIoU 0.82,验证集mIoU 0.88,验证集比训练集还好,看着反常但没人在意。换了新的一张水下视频帧,模型表现立刻掉到0.5以下。
原因:水下数据集通常来自视频抽帧,连续帧之间的相似度极高。如果用随机打乱划训练/验证集,验证集里会有大量和训练集同事件、同场景甚至同目标的帧,等于泄题。模型在验证集的“高分”只是记住了视频场景,不是泛化。
解决:按视频片段来划分。给每个样本维护一个sequence_id字段,划分时保证同一个sequence_id的所有帧只出现在训练集或验证集的其中一边,绝不分家。如果数据集没有提供视频来源信息,也可以用帧的拍摄时间戳或文件名的会话前缀来划分,总之不能用完全的随机种子。
5.5 坑:评估只报一个mIoU,模型好坏被平均掩盖
现象:模型整体mIoU 0.72,看起来不错,但部署到现场才发现岩石类几乎全错,边界处预测抖得厉害,鱼群经常被识别成背景。
原因:mIoU是所有类别IoU的算术平均,一个类别糟糕、其他类别良好的模型,mIoU依然好看。水下任务里人工物体和鱼是核心业务目标,它们占比小、难学,恰恰是最容易拖后腿但最不该被平均掩盖的类别。
解决:评估脚本必须输出每类的IoU和每类的像素准确率,不能只看汇总值。建议把每类IoU按业务优先级排序展示:鱼、人工物体这类核心类别低于0.5就说明模型不可交付,哪怕整体mIoU再高也要返工。我习惯在训练日志里同时保留mIoU和rare_class_iou(稀有类别平均IoU)两个指标,后者才是水下任务的真正验收线。
6. 让分割结果真正可用的三个验证习惯:每类IoU、边界质量和可视化巡检
指标只能告诉你“模型好不好”,但很难告诉你“哪里不好”。我训练水下分割模型时,最后一周基本不看单数mIoU,而是固定三个验证动作。
第一个动作是每类IoU的时序曲线。每个epoch记录每类IoU,画成折线图,观察哪些类别的曲线出现“先升后降”的过拟合拐点。水下数据量少,鱼和海草经常在30个epoch后开始过拟合,表现在验证集IoU不再上升而训练集IoU继续爬升。我通常在曲线拐点处回滚到表现最好的checkpoint,然后用它来做最终预测,这个“后悔药”式的模型选择方式,实际收益比一味多训几个epoch要稳定。
第二个动作是边界质量。语义分割的掩码边界最容易出问题的地方是“薄的物体”被吞掉,比如鱼鳍、海草叶片。我用的方法是生成一张“错误热力图”:预测掩码和标签图逐像素对比,把错误像素叠加为红色,然后再看这些红色像素是否成片地出现在目标边缘。如果错误集中在边缘,说明是边界平滑问题,可以考虑加大边界感知loss或使用更大的输入尺寸;如果错误成片出现在物体内部,那问题就不是分割精度,而是训练数据的标签本身标漏了。
第三个动作是可视化巡检。在训练的最后阶段,我会自己跑一遍批量可视化,把验证集里预测错得最狠的50张图输出到单独目录,然后按类别分组看:鱼类的失败图里到底是“鱼太小看不清”还是“鱼和背景颜色太接近”。这些判断没法自动化,却是调优的真正方向。
我自己的经历是个反面教材:曾经在某次水下监测项目里,拿到的标签图全部以RGB模式存储,我在加载器里图省事统一转成RGB,结果模型训练了两周期,mIoU始终卡在0.3左右,当时一度怀疑是模型问题,后来把标签图单张拉出来可视化才发现,所有类别的索引在load时已经全乱掉了。从那以后我给自己定了一条规矩:任何新的语义分割数据集到手,第一件事跑可视化,第二件事跑类别统计,然后才开始调模型。这套流程看起来多花一小时,但比起盲调模型失败再排查,省下的时间是几天的量级。希望这些踩坑经验能帮你少走一段弯路,顺利跑通属于自己的水下目标分割方案。
本文还有配套的精品资源,点击获取