☰
水下图像语义分割数据集实战:8类目标标注与可视化训练指南
2026/10/10 12:37:05 网站建设 项目流程

简介:水下目标图像语义分割数据集面向计算机视觉与深度学习初学者及研究者,提供8类前景目标(人类、海草、珊瑚、岩石、鱼等)的像素级标注,适用于分割模型训练、评估与算法验证。数据集源于640×480分辨率的水下影像,背景简单、前景丰富,已做随机旋转等预处理,并划分训练集(1525张图像与对应mask)和测试集(110张图像与对应mask)。资源包共2000个文件,以bmp图像为主(1525个),辅以474个jpg原图及1个Python可视化脚本,压缩后大小159.07MB,便于下载与解压。其中可视化脚本可随机抽取样本,将原始图片、GT彩色mask及GT蒙板效果合成展示,方便快速检查标注质量。已有1081人学习使用,适合作为水下目标分割入门练习或数据扩充参考。

1. 一块「图像分割数据集」要解决什么:下水的语义分割,难在像素级别

做水下机器视觉的人,最头疼的不是模型选型,而是手里没有一块能用的数据。陆地场景可以随手找开源的数据集,到了水下,光照、色偏、悬浮物把图像搅得一塌糊涂,目标种类又多又杂,很多人最终都卡在第一步:图像分割数据集从哪来、怎么把标签对齐、怎么验证标注好坏。这个标题给了一条完整的路子——一份水下目标图像的语义分割数据,8类目标划分,每张图配套类别标签,还带一套可视化代码。换句话说,它想让你拿到手就能把标注结果和模型输出直接渲染出来,不用自己从零去凑工具链。

这类数据集不是给你跑个效果展示用的,它对应的是实际工程需求:水下巡检、水产养殖监测、海底生态评估,都需要在像素级区分目标,而不是只画个框。适合谁?刚好是那些手里有水下视频但没标签的团队,或者想验证语义分割算法在水下场景适应性的研究者。跟着往下走,我会把数据集结构、读取方式、训练参数和最容易翻车的地方逐一说清楚,保证你照着能跑。

2. 数据集结构与类别标签的约定:8分割里每一像素的编码规则

拿到任何一份语义分割数据集,第一件事不是看图片好不好看,而是搞懂标签是怎么编码的。水下目标图像语义分割的常规组织方式是「原图 + 掩码」配对:原图是普通的 RGB 三通道照片,掩码是一张单通道索引图,每个像素的值代表一个类别编号。8 分割的意思就是掩码像素值取值范围是 0 到 7,一共 8 个类别编号。这里有一个几乎所有新手都会先踩的坑——误以为掩码也是 RGB 彩色图,结果读进来发现是灰度图,或者反过来把彩色掩码当成索引图用。后面我会专门讲这个问题。

2.1 水下目标的常见 8 类划分与标签规范

既然是 8 分割,那么「8 个类别到底是什么」就是整个数据集的地基。不同的数据集作者划分方式会略不同,但针对水下场景,常见做法是围绕「背景水体 + 生物 + 非生物底质」来划分。我见过比较典型的一组是:

类别ID目标说明
0背景/水体没有明确目标的区域,往往占像素比例最大
1鱼类自由游动的鱼
2海胆底栖生物,圆形带刺
3海星星形底栖生物
4珊瑚块状或枝状珊瑚
5水草/藻类附着或漂浮的植物
6石块/礁石非生物底质
7其他无脊椎生物螃蟹、贝类等零星目标

类别标签不是凭空写的,它决定了后续损失函数怎么加权、评价指标怎么算。特别注意:背景类往往占据 80% 以上的像素,如果直接用交叉熵损失训练,模型会学成「看到什么都说是背景」,这就是水下语义分割最典型的失败模式。所以拿到标签后,第一件事就是统计每一类的像素占比,后面训练时才能针对性地做类别加权或损失函数调整。

2.2 数据目录结构与读取代码

一个规范的水下分割数据集,目录通常长这样:

├── images/ # 原图,jpg 或 png 格式 │ ├── underwater_001.jpg │ ├── underwater_002.jpg │ └── ... ├── masks/ # 掩码,png 格式,单通道索引图 │ ├── underwater_001.png │ ├── underwater_002.png │ └── ... ├── labels.txt # 类别名称列表,一行一个 ├── train.txt # 训练集文件名列表 └── val.txt # 验证集文件名列表

读的时候,掩码一定要用PIL.Image读成'L'模式,而不是'RGB'。我一般会写一个检查脚本,先把所有掩码的像素值和原图尺寸打印出来,确认没有坏数据再继续:

from PIL import Image import numpy as np import os img_dir = "images" mask_dir = "masks" for name in sorted(os.listdir(img_dir))[:5]: img_path = os.path.join(img_dir, name) mask_path = os.path.join(mask_dir, name.replace(".jpg", ".png")) img = np.array(Image.open(img_path)) mask = np.array(Image.open(mask_path).convert("L")) print(f"{name}: img_shape={img.shape}, mask_shape={mask.shape}, " f"mask_unique={np.unique(mask)}, mask_dtype={mask.dtype}")

这段代码的核心是验证三件事:原图和掩码的尺寸是否一致、掩码像素值是否严格落在 0 到 7 之间、掩码通道数是否是 1。如果掩码出现 255 或者像素值有小数,说明这张标记得重做或转换。mask_unique打印出来的数组就是类别分布的直接证据,如果只有[0]或[0, 1],那这份数据的类别覆盖率很有问题,后面训练必然偏科。

3. 数据读取与预处理:把图像-掩码对送进语义分割模型之前,先做三件事

数据和标签对齐之后,下一步就是写数据加载管线的细节。水下图像最大的特点是退化严重——偏绿偏蓝、低对比度、光照不均匀。直接拿原始图喂给语义分割模型,收敛慢且精度上限低。这一章我按「读取对齐 → 色偏修正 → 数据增强」三步来讲,每一步都有现成代码可以直接抄进你的训练管道。

3.1 图像和掩码的读取与对齐

PyTorch 生态下的标准做法是自定义Dataset类,在__getitem__里同时返回原图和掩码。关键点是:原图做数据增强时,掩码要跟着做完全一样的几何变换,否则模型训练时看到的目标位置和标签对不上,训练过程直接失去意义。

from torch.utils.data import Dataset, DataLoader from PIL import Image import torchvision.transforms as T import ast class UnderwaterSegDataset(Dataset): def __init__(self, root, split="train"): with open(f"{root}/{split}.txt") as f: self.names = [line.strip() for line in f if line.strip()] self.img_root = f"{root}/images" self.mask_root = f"{root}/masks" def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] img = Image.open(f"{self.img_root}/{name}").convert("RGB") mask = Image.open(f"{self.mask_root}/{name.replace('.jpg', '.png')}").convert("L") if img.size != mask.size: mask = mask.resize(img.size, Image.NEAREST) # 索引图不能用双线性插值 return img, mask

这段代码最容易被忽略的是resize那一步:掩码是类别索引图,缩放只能用Image.NEAREST,否则插值会造出类编号之间的「混合值」,比如 2.4 这种不存在的类别,一旦混进数据,模型输出层的类别数就对不上了。训练集和验证集划分时,建议按文件名列表存成train.txt和val.txt,而不是在代码里按固定比例切,因为水下视频序列中相邻帧相似度极高,随机切会导致验证集虚高。

3.2 水下图像预处理的三个关键操作

水下图像预处理和普通图像有个显著区别:普通图像用 ImageNet 的均值方差归一化就够了,但水下图像先在色彩层面做校正,再进归一化,模型学起来会轻松很多。我常用的三件套是灰度世界白平衡、CLAHE 限制对比度直方图均衡、最后才是标准归一化。

白平衡修正是为了去掉水体的绿色色偏。灰度世界假设场景中所有颜色的平均反射率是中性的,通过调整 R、G、B 通道均值使三者平衡,实现简单且对水下场景有效。CLAHE 则解决局部对比度低的问题,它不像全局直方图均衡那样把亮部推得过曝,而是分块处理后用双线性插值拼接块边缘。归一化时不要直接套 ImageNet 统计量,我的经验是先用训练集自己算一遍各通道均值和方差,再作为归一化参数,收敛更稳。

import cv2 import numpy as np def underwater_white_balance(img): result = cv2.cvtColor(img, cv2.COLOR_RGB2LAB) avg_a = np.average(result[:, :, 1]) avg_b = np.average(result[:, :, 2]) result[:, :, 1] = result[:, :, 1] - ((avg_a - 128) * (result[:, :, 0] / 255.0) * 1.1) result[:, :, 2] = result[:, :, 2] - ((avg_b - 128) * (result[:, :, 0] / 255.0) * 1.1) return cv2.cvtColor(result, cv2.COLOR_LAB2RGB) def underwater_clahe(img): lab = cv2.cvtColor(img, cv2.COLOR_RGB2LAB) clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) lab[:, :, 0] = clahe.apply(lab[:, :, 0]) return cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)

这里clipLimit是让极端对比度被切掉的阈值,tileGridSize是分块大小,水下场景推荐 8×8 分块,太小会产生块状伪影,太大会丢细节。颜色空间选 LAB 而不是 BGR 或 RGB,是因为它的亮度通道和色彩通道分离,直接对亮度通道做均衡不会破坏色调平衡。

3.3 数据增强策略

水下数据量通常不大,增强策略决定了模型的泛化能力。我一般用「几何增强」和「颜色增强」两条线。几何增强包括水平翻转、随机旋转 ±15 度、随机缩放 0.8 到 1.2,掩码必须用和原图一致的变换参数;颜色增强包括亮度抖动、对比度抖动、色相抖动,这样可以让模型对水深和光照的变化更鲁棒。水下场景的特殊之处是,垂直翻转不要随便用,因为水下目标的方向具有物理意义,鱼不会头朝下游,海星贴在礁石上也不会整片翻转。

import random from PIL import Image, ImageEnhance def seg_transform(img, mask, mode="train"): if mode == "train": if random.random() < 0.5: img = img.transpose(Image.FLIP_LEFT_RIGHT) mask = mask.transpose(Image.FLIP_LEFT_RIGHT) angle = random.uniform(-15, 15) img = img.rotate(angle, resample=Image.BILINEAR) mask = mask.rotate(angle, resample=Image.NEAREST) if random.random() < 0.5: img = ImageEnhance.Brightness(img).enhance(random.uniform(0.8, 1.2)) if random.random() < 0.5: img = ImageEnhance.Color(img).enhance(random.uniform(0.7, 1.3)) return img, mask

这段增强代码里的玄学在于rotate时原图和掩码的插值方式不同。原图用双线性,掩码用最近邻,这和我前面说的掩码不能有非整数类别值是同一件事,哪怕旋转 1 度也会产生插值,一旦掩码变成浮点,后面损失函数计算会直接报错或者学到错误类别。颜色增强只作用在原图上,掩码不做任何颜色变换,这是原则。

4. 语义分割模型训练:从UNet到DeepLabV3+,8分类场景下的选型与参数

数据和预处理就位后,真正进入训练环节。这里我不打算给一整套完整训练代码,那会变成教科书,而是把注意力放在「怎么做选型」和「关键参数怎么定」上。很多团队在水下任务上跑不出效果,不是模型不够强,而是损失函数和类别权重没有跟上数据本身的分布特征。

4.1 选型:按你的算力和实时性要求来挑

水下语义分割的主流模型就那么几个:UNet、UNet++、DeepLabV3+。三者定位完全不同。UNet 参数量小、结构简单,是验证数据质量的第一选择,它训练一轮的时间短,跑出一个基准结果非常快。UNet++ 通过嵌套的密集连接改善了特征融合,对海胆、海星这种边缘模糊的小目标更友好,但推理速度比 UNet 慢一些。DeepLabV3+ 是精度上限最高的,它用空洞卷积扩大感受野,配上多尺度池化,对大面积水草、珊瑚这类连续目标有天然优势,但 ResNet101 骨干把显存占用拉得很高。

我实际做的时候会先拿 UNet 跑一个 baseline,再把同样的数据和增强套到 DeepLabV3+ 上对比。如果 UNet 在验证集上 mIoU 都在 65% 以下,先不要换模型,回到数据去检查标注和颜色分布,因为这种低分通常不是模型能力的问题,而是数据本身存在系统性问题。下表是我常用的选型参照:

指标UNetUNet++DeepLabV3+
骨干网络自定义编码器自定义编码器嵌套ResNet101
参数量~31M~37M~69M
推理速度快中慢
最佳用途基线对比、快速验证数据质量小目标密集场景高精度落地场景

4.2 训练参数:三大关键配置与损失函数

损失函数的选择直接决定模型能不能在类别严重不平衡的水下数据上学到东西。只用交叉熵是不够的,背景占了绝大多数,网络输出会快速收敛到「全背景」这个局部解。我常用的是交叉熵加 Dice Loss 的混合损失,交叉熵提供全局梯度的稳定性,Dice Loss 压着前景区域的梯度走,专门逼模型去把少类别目标揪出来。weight 参数按类别像素占比的倒数计算,最小值和最大值做截断,防止某些极稀少的类别权重过大导致训练震荡。

import torch import torch.nn as nn import torch.nn.functional as F class DiceLoss(nn.Module): def __init__(self, smooth=1.0): super().__init__() self.smooth = smooth def forward(self, logits, targets): num_classes = logits.shape[1] probs = F.softmax(logits, dim=1) targets_onehot = F.one_hot(targets.long(), num_classes).permute(0, 3, 1, 2).float() intersection = (probs * targets_onehot).sum(dim=(2, 3)) union = probs.sum(dim=(2, 3)) + targets_onehot.sum(dim=(2, 3)) dice = (2.0 * intersection + self.smooth) / (union + self.smooth) return 1.0 - dice.mean()

这段 Dice Loss 写的是一维展开的形式,batch 维度没有显式出现在公式里,实际跑的时候dice.mean()会把 batch 和类别都平均掉。smooth系数的作用是防止分母为零,也起到拉平训练初期震荡的效果,水下目标边缘细碎,smooth 取 1.0 比较稳。

优化器选 AdamW,初始学习率 1e-4,配合多项式衰减而不是固定学习率。语义分割任务里,学习率策略对后期精度的提升比模型结构还明显。batch size 受限于显存,一般取 8 或 16;如果你用的是一张 3090 或更高规格的卡,可以开到 16,配 520×520 的输入尺寸。训练轮数 60 到 80 轮之间,用验证集 mIoU 做早停,连续 10 轮不升就停。裁剪尺寸定 512×512 是水下目标分割的常见做法,这个尺寸兼顾了小目标的细节保留和显存消耗,别再往下调了,不然鱼眼、海胆刺这些细节直接糊掉。

5. 避坑指南:水下目标数据集的5条血泪踩坑记录

这一章专门说我在水下分割训练里踩过的坑,每一条都是真金白银换过来的。你可能不会全遇到,但遇到任何一个,如果不知道底层原因,排查一整天是常事。

5.1 类别不平衡的连锁反应

现象:训练出来后模型预测结果整张图都是一片背景色,唯一的前景偶尔闪几个零碎像素,mIoU 低得可怜。

原因:背景像素占比过高,实验数据里经常超过 85%。交叉熵损失里背景类梯度占了绝对优势,网络很快就陷入了「全预测为背景」的局部最优。

解决:先把训练集掩码统计一遍每类像素占比,按占比倒数设置交叉熵的weight,同时叠加 Dice Loss。我建议把weight做一次截断:最大值不要超过 10,防止极端小众类别权重过大引发震荡。

5.2 掩码的位深和格式把你骗了

现象:可视化掩码时一片漆黑,或者用 OpenCV 读出来是 255 和 0 两种值,完全不是 0 到 7 的类别号。

原因:掩码文件实际是 8bit PNG,数值本身没问题,问题出在读图方式上。如果直接用 OpenCV 的cv2.imread不做模式转换,读进来的是 BGR 三通道,你打印出的数组形状就是(H, W, 3),值也变成了 RGB 重复的假像。或者有的掩码是 16bit PNG,数值被整体缩放过,255 变成了 65535。

解决:统一用PIL.Image.open(mask_path).convert("L")来读,读完后打印np.unique(mask),确认最大值小于 8。如果发现np.unique里有 255,说明标注文件里面把某一类编成了 255 作为 ignore 标志,那你需要在数据加载前统一重映射,把 255 转成背景 0,否则输出层会把你分类数撑大,训练时类别数对不上直接崩掉。

5.3 调色板错位导致可视化误导

现象:可视化代码把鱼显示成红色、把海星显示成绿色,看起来和标注原图完全不一样,你开始怀疑标注质量。

原因:多数可视化脚本里定义了一个长度为 8 的palette列表,按[类别0颜色, 类别1颜色, ...]排好再映射。问题在水下数据集里常见:有人把调色板顺序写成了[背景, 海星, 海胆, 鱼, ...],而模型输出是 0 到 7 的索引,一旦顺序没对上,所有颜色全部错位。可视化结果看起来不是「明显错误」,而是「颜色漂移」,极难发现。

解决:写可视化代码的第一件事不是渲染,而是先按labels.txt的类别顺序生成调色板,确认labels.txt和掩码的索引一一对应。调色板每三项代表一个类别(R、G、B),我习惯在代码里直接用palette = np.zeros((256, 3), dtype=np.uint8),然后把前 8 个类别位填上颜色,避免索引越界。

5.4 水下色偏造成验证集虚高

现象:验证集 mIoU 高得吓人,但一到自己的新水下视频上,预测效果马上掉档。

原因:水下视频帧间是高度相关的,如果你按照传统的随机划分把帧打散分到训练集和验证集,那么验证图像在颜色分布、背景纹理、目标位置上与训练图像高度重合,等于拿着开卷答案考试。当前场景里,色偏、光照都是图像内容的一部分,网络记住了这些特征而不是真正的语义目标。

解决:按「视频片段」划分数据,同一段视频的帧只进训练集或只进验证集,不能混。目录里如果文件名带拍摄时间或地点编号,直接按这些字段分组。地点划分的效果更好,因为不同水域的水色、底质差异大,真实验证的是跨水域泛化。

5.5 标注边界歧义无处可查

现象:模型在海星与礁石的交界处出现一圈「混类」误判,损失函数数值已经很低,但边界 IoU 卡在 60% 左右上不去。

原因:水下图像浑浊,目标边缘在标注时本身就存在人为不确定性。两个标注员对同一根海星触手的边界勾画可能差 3 到 5 个像素。这种标注随机性被网络当作「硬标签」学习,导致输出概率在边界区域混乱。

解决:在损失函数里加边缘感知权重,对距离目标边界 3 到 5 像素的区域降低惩罚,或者用标签平滑(label smoothing)把硬标签转成软标签。另外一个偏工程的做法是训练完成后用 CRF 做后处理,但这属于打补丁,救不了标注质量差带来的上限问题。如果边界 mIoU 始终不涨,最靠谱的办法是放弃争议像素,重建一套更严格的标注规范。

6. 可视化代码的正确打开方式:一张叠加图就能快速定位模型失误

很多人拿到可视化代码,只是把预测结果保存成一张图,然后随便看一眼就完事了。这种做法浪费了可视化最大的价值——逐类别定位模型失误。我会把可视化拆成两层:基础层是「原图 + 掩码叠加」,进阶层是「预测图 + 真实标签差异标红」。两者结合才能快速回答「这个模型到底哪儿学歪了」这个核心问题。

基础叠加实现的原理是用调色板给掩码的每个类别编号上色,然后和原图按透明度融合。这个操作看起来简单,但 Pareto 原则在这里很适用:颜色和透明度参数决定你在视觉上能不能察觉目标边缘的细微错位。alpha取 0.5 是经验值,太低看不到掩码覆盖区域,太高会遮挡水下目标的细节纹理。

import numpy as np import cv2 def create_palette(num_classes): palette = np.zeros((num_classes, 3), dtype=np.uint8) palette[0] = [128, 128, 128] # 背景灰 palette[1] = [255, 0, 0] # 鱼 红 palette[2] = [0, 255, 0] # 海胆 绿 palette[3] = [0, 0, 255] # 海星 蓝 palette[4] = [255, 255, 0] # 珊瑚 黄 palette[5] = [0, 255, 255] # 水草 青 palette[6] = [138, 43, 226] # 石头 紫 palette[7] = [250, 128, 114] # 其他 橙 return palette def overlay_mask(image_bgr, mask, palette, alpha=0.5): color_mask = palette[mask] # (H, W, 3) overlay = cv2.addWeighted(image_bgr, 1 - alpha, color_mask, alpha, 0) return overlay

这段代码有两个细节值得重点讲。第一,palette[mask]是利用 NumPy 的高级索引把单通道掩码直接映射成彩色图,这一步替代了for循环遍历每个类别,即使推理 4K 图像也是毫秒级完成。第二,OpenCV 读图是 BGR 顺序,所以调色板里的 [255, 0, 0] 对应的是蓝色还是红色取决于你的cv2.imread读取路径。我的习惯是给overlay_mask加一个is_bgr参数,统一在函数内部决定是否转换,避免在不同脚本间复制代码时踩到颜色顺序的坑。

进阶可视化建议单独写一个函数,把「真实标签有而预测没有」和「预测有而真实标签没有」的区域用两种完全不同的高亮色标出来。真实目标的漏检区域标红色,背景被误检成目标区域标蓝色。这张差异图可以直接作为训练中间过程的监控指标——如果漏检区域集中在某一种特定颜色或纹理的背景中,你就知道模型学习的特征是颜色统计而不是形状语义;如果误检集中在某个类别上,你就要去查类别权重是否失衡。我通常每 5 轮训练保存一组这样的对比图,翻车时回溯历史记录,一眼就能定位是哪一轮开始学歪的,这种习惯帮我省下了大量排查时间。希望这些方法也能帮你把数据到模型的链路走得更顺。

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

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

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

立即咨询