☰
水下目标图像分割数据集实战:8类语义分割从标注到训练避坑指南
2026/9/28 15:36:57 网站建设 项目流程

简介:面向水下机器人、海洋生态监测等图像分割应用场景,这里提供一份包含人类、海草、珊瑚、岩石、鱼等目标的8个分割类别水下图像语义分割数据集,并附带类别标签与可视化脚本,可直接用于训练和评估分割模型。数据来自640×480分辨率的真实水下画面,背景简单但目标区域标注精细,且已做随机旋转等预处理,有利于提升模型泛化能力。压缩包约159MB,内含2000个文件,以bmp图像和jpg图像为主,另有1个Python脚本,训练集与测试集目录划分清晰,使用方便。附带脚本可随机抽取样本,将原始图、GT真值图和蒙版叠加结果一次性输出保存,便于快速检查标注质量和展示分割效果。目前已有1081人学习浏览,适合计算机视觉初学者搭建分割流程,也可为水下目标检测相关研究提供数据支撑。

1. 水下目标图像分割数据集:8类语义分割从标注到可视化的完整落地

第一次拆这份水下目标分割数据集,我没有急着看模型,而是先把训练集的 1525 张图和 1525 张 mask 全扫了一遍,确认每一对都一一对应、尺寸统一是 640×480,这才敢往下走。这套影像覆盖人类、海草、珊瑚、岩石、鱼等多类水下前景,背景简单、前景区域丰富且标注完整,并附带彩色调色 mask 和可视化代码。对正在做图像分割、语义分割算法验证的同学来说,它省掉了标注和格式转换的大量重复劳动,拿到就能直接训练或当评测基线下手。下面按拆包顺序,把结构、标签、可视化和接入训练的关键点一次说清。

2. 数据集结构与8类标签体系:先弄清mask像素值再谈训练

2.1 目录结构与文件名对应规则

数据包解压之后,顶层结构很标准,训练集和测试集分开存放,没有把图片和标注混在一起。常见结构大致是:

water_seg/ ├── train/ │ ├── images/ │ │ ├── d_r_617_.bmp │ │ ├── d_r_51_.bmp │ │ └── ... │ └── masks/ │ ├── d_r_617_.bmp │ └── ... └── test/ ├── images/ └── masks/

注意,这里的 mask 虽然也叫 .bmp,但它是调色后的彩色标注图,不是单通道灰度图。文件名规则是同一张原图和它的标注共用同一个前缀,例如d_r_617_.bmp同时出现在train/images和train/masks下。这样按文件名前缀匹配就能把 img 和 mask 一一配对。很多同学在这里想当然地认为 mask 就是 PNG 或者灰度单通道,结果读进来发现是三通道彩色图,这一点在后面避坑章节会重点展开。

文件命名里的d_r_前缀,从数据预处理的角度看,d大概率是 dataset 或 diver 的缩写,r很可能对应 rotation 旋转增强。这说明数据不是原始采集的一次性产物,而是经过预处理的版本,这类细节在写数据说明时要留意,别把增强后的文件名当成原始采集编号。

2.2 八类前景标签怎么对应mask像素值

这份数据集强调"8分割",意味着标注体系里除了背景外还有 8 个可区分的前景类别。从摘要描述看,前景覆盖人类、海草、珊瑚、岩石、鱼等目标。这里要给首次接触彩色 mask 的同学一个提醒:调色后的彩色 mask 中,每个前景类别不一定等于 RGB 值本身,它实际是类别索引值经过 colormap 映射后的视觉结果。

拿到数据后,我习惯先做一步统计,把所有 mask 中的像素去重,看看究竟有哪些颜色出现。常见做法是用 PIL 或 OpenCV 读 mask,然后对像素值做集合统计:

import cv2 import numpy as np import glob mask_paths = glob.glob('train/masks/*.bmp') color_set = set() for p in mask_paths[:50]: # 先抽查前50张 mask = cv2.imread(p) mask = cv2.cvtColor(mask, cv2.COLOR_BGR2RGB) pixels = mask.reshape(-1, 3) colors = set(map(tuple, pixels.tolist())) color_set.update(colors) print('当前抽查mask中出现的RGB值数量:', len(color_set)) for c in sorted(color_set): print(c)

这段代码的逻辑是先抽查 50 张 mask,把每张图的 RGB 像素转成元组后放入集合,最终输出所有出现过的不重复 RGB 值。为什么要先抽查而不是全量处理?因为全量统计 1525 张图耗时更长,而抽查 50 张已经能覆盖绝大多数类别颜色。参数上,[:50]可以改成[:200]提高覆盖率,代价是多等十几秒,磁盘够快的话建议直接跑全量glob.glob('train/masks/*.bmp')。

拿到 RGB 值列表后,需要手工做一个从 RGB 到类别索引的映射表,比如把[0, 0, 0]对应背景 0,把某个固定 RGB 对应类别 1,以此类推。这份数据集没有提供官方的类别名文件,所以映射关系要以实际像素统计为准,这也是使用前最重要的一步,类别索引错一位,后续训练和评估全白费。

2.3 训练集与测试集的划分规模

划分规模方面,训练集是 images + masks 各 1525 张,测试集是各 110 张。训练与测试比例大约 14:1,这个比例对于语义分割任务偏正向,因为在背景简单的水下场景里,模型容易过拟合,靠 1525 张图训练、110 张图评测,已经足够看出模型的基本泛化趋势。注意测试集没有单独的验证集拆分,如果你习惯用 val 集做早停,需要自己从训练集中再切一部分出来,常见做法是按 9:1 或者 8:2 从 train/images 里分出 val,但要保证 mask 同步切走。

这里有一个容易被忽略的细节:预处理描述里提到"对不同的数据进行随机的旋转等",意味着训练集里已经存在人为增强后的副本,而不是纯粹的原始采集帧。这样做的效果是增加样本多样性,但也意味着如果你继续在训练时叠加随机旋转,等于做了二次增强,建议把训练时的旋转角度调小,比如限制在正负 15 度以内,避免过度拟合增强痕迹。

3. 可视化脚本实战:三分钟确认标注质量与原图对齐

3.1 可视化脚本在做什么

项目附带的可视化脚本,作用是从数据集中随机抽一张图片,然后把它对应的原始图片、GT 标注、GT 在原图上的蒙板三张图并排展示,并保存到当前目录。这个脚本的价值在于快速确认三类信息:一是原图与 mask 是否一一对应,二是 mask 的彩色轮廓是否贴合目标边缘,三是类别分布是否合理。由于数据集背景简单,蒙板叠加后能很直观地看出前景目标是否完整、有没有漏标。

脚本的核心逻辑通常可以拆成四步:读原图、读同名 mask、把 mask 透明叠加到原图上、用 matplotlib 绘制三图对比并保存。我自己复现时习惯写成下面这段,比直接跑现成脚本更容易定位问题:

import cv2 import numpy as np import matplotlib.pyplot as plt import random import glob # 1. 随机抽一张原图 image_paths = sorted(glob.glob('train/images/*.bmp')) pick = random.choice(image_paths) # 2. 用文件名前缀找到对应的mask mask_path = pick.replace('images', 'masks') img = cv2.imread(pick) mask = cv2.imread(mask_path) mask_rgb = cv2.cvtColor(mask, cv2.COLOR_BGR2RGB) # 3. 叠加蒙板:原图保留,mask按透明度混合 overlay = img.copy() overlay = cv2.cvtColor(overlay, cv2.COLOR_BGR2RGB) blend = cv2.addWeighted(overlay, 0.6, mask_rgb, 0.4, 0) # 4. 三图并排保存 fig, axes = plt.subplots(1, 3, figsize=(15, 5)) axes[0].imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)) axes[0].set_title('Original') axes[1].imshow(mask_rgb) axes[1].set_title('GT Mask') axes[2].imshow(blend) axes[2].set_title('Overlay') for ax in axes: ax.axis('off') plt.savefig('visual_check.png', bbox_inches='tight', dpi=120) print('saved to visual_check.png, image:', pick)

这段代码的关键参数在于addWeighted的 0.6 和 0.4,它们分别控制原图和 mask 的混合权重。权重比例决定了叠加后是更看重原始纹理还是更看重标注区域,背景简单的水下图像用 0.6/0.4 比较合适,前景透明能看到纹理,又不至于让类别颜色被原图盖没。replace('images', 'masks')这行利用了目录结构对称的特点,只要文件名前缀一致,字符串替换就能精准找到对应 mask,不需要再维护单独的映射文件。

3.2 从运行到出图的完整步骤

如果需要把随机抽图改成指定文件名,比如手动验证某一张难例,可以把random.choice(...)换成直接传路径。我会在前面加一个参数:

import sys if len(sys.argv) > 1: pick = sys.argv[1] else: pick = random.choice(image_paths)

这样命令行里python visualize.py d_r_617_.bmp就能固定查看编号为d_r_617_的那一张图。参数化之后,验证某个具体类别是否漏标、看某张边缘复杂样本的分割效果,都不需要反复改代码重跑。

还有一个建议:随机抽图验证靠运气,存在抽不到关键样本的可能。我自己会把代码改成循环抽样,每次抽样后打印文件名,连续抽 10 次并保存 10 张对比图,再快速翻一遍这些图。抽样次数太少可能错过问题样本,次数太多又浪费时间,10 次在验证场景下是性价比比较高的选择。

运行环境上,只需opencv-python、numpy、matplotlib三个库,Python 3.8 以上都能直接跑。如果 matplotlib 出图时标题里的中文显示成方块,说明系统缺中文字体,把set_title里的英文先留着,或者临时加上plt.rcParams['font.sans-serif'] = ['SimHei']这类字体设置,但只影响展示,不影响保存的图。还有人用cv2.imshow弹窗发现窗口显示灰色,这是 BGR 与 RGB 通道顺序的锅,叠加图和原图显示前统一走cvtColor转一次即可。

4. 数据预处理与训练/测试划分:1525张图如何喂给分割模型

4.1 旋转预处理对分割任务的双面影响

预处理描述里提到的随机旋转,本质上是数据增强里的几何变换。对分割任务来说,几何变换必须同时对原图和 mask 做同一套操作,否则标注会错位。很多同学直接调cv2.rotate只转了原图、忘了转 mask,训练时 loss 看起来很低,实际模型学的是错位后的错误对应关系。正确做法是用同一个变换矩阵处理两张图。

常见做法是用 OpenCV 的仿射变换包装一个同步增强函数:

def rotate_image_and_mask(img, mask, angle): h, w = mask.shape[:2] center = (w // 2, h // 2) M = cv2.getRotationMatrix2D(center, angle, 1.0) img_rot = cv2.warpAffine(img, M, (w, h), flags=cv2.INTER_LINEAR) mask_rot = cv2.warpAffine(mask, M, (w, h), flags=cv2.INTER_NEAREST) return img_rot, mask_rot

这里最关键的参数是flags:图像插值用INTER_LINEAR,mask 插值必须用INTER_NEAREST。原因是 mask 像素值代表类别索引,线性插值会在类别边界产生新的中间值,比如 1 和 2 之间插出 1.5,四舍五入后可能变成错误的类别,导致边界区域出现不属于任何类的伪像素。最近邻插值虽然会让边界有轻微锯齿,但保证类别索引不被污染,这是分割数据增强里必须遵守的规矩。

插值参数适用对象原因
INTER_LINEAR原图平滑处理,保留水下纹理细节
INTER_NEARESTmask保持类别索引不被插值污染

在线性插值的场景下,还可以配合旋转角度做一个小技巧:因为水下目标往往有任意朝向,旋转角度范围设为[-180, 180]比[-30, 30]泛化更好,但前提是数据集里没有大量倒置样本影响语义判断。这份数据背景简单,旋转到任意角度都不影响目标识别,可以放开角度范围,只要确保 mask 同步旋转即可。再有,旋转后图像四角会出现黑色填充区域,这些区域会被 warpAffine 填成 0,恰好是背景索引,不影响训练,但如果你的预处理里把背景之外也当作有效区域做归一化,注意不要让填充区域的统计量污染图像均值。

4.2 把数据集接入现有训练管线

要把它接入常见的 PyTorch 分割训练管线,需要写一个 Dataset 类,核心工作就是从 images 和 masks 两个目录读图、按文件名对应、返回 (img_tensor, mask_tensor) 对。一个够用的实现是:

import os from torch.utils.data import Dataset from PIL import Image import torch class WaterSegDataset(Dataset): def __init__(self, image_dir, mask_dir, transform=None): self.image_dir = image_dir self.mask_dir = mask_dir self.transform = transform self.ids = [f for f in os.listdir(image_dir) if f.endswith('.bmp')] def __len__(self): return len(self.ids) def __getitem__(self, idx): name = self.ids[idx] img_path = os.path.join(self.image_dir, name) mask_path = os.path.join(self.mask_dir, name) img = Image.open(img_path).convert('RGB') mask = Image.open(mask_path).convert('RGB') # 把彩色mask转成类别索引 mask = self.rgb_to_class(mask) if self.transform: img = self.transform(img) mask = self.transform_mask(mask) return img, mask

类中rgb_to_class就是 2.2 节里手工建立的 RGB 到类别索引映射。这里要强调一个实现细节:mask 用convert('RGB')读进来是三通道彩色图,必须转换成语义分割所用的单通道索引图,否则模型输出的 9 通道 logits 和标注的 3 通道 RGB 对不上,loss 根本没法算。很多语义分割新手都在这一步翻车,把彩色 mask 直接喂进模型,报错不报错先不说,即使能跑,监督信号也是错的。

参数方面,transform建议复用torchvision.transforms.Compose,统一做 Resize、ToTensor 和归一化;注意 Resize 时 mask 也要用NEAREST模式,和上面旋转增强是同一个道理。Dataset 写完后再用DataLoader打包,设置batch_size=8、shuffle=True就能正常参与训练了。分辨率 640×480 在常见显存下可以直接进模型,但如果你用的是 512×512 输入的分割网络,Resize 之后要确认 mask 同步缩放到相同尺寸,最稳妥的做法是在 Dataset 内部就对 img 和 mask 执行同一套 Resize,而不是靠外部 transform 各自处理。

5. 避坑记录:mask格式、类别索引与数据对齐的四个坑

5.1 mask 读出来是三通道彩色图,输入模型报维度错误

现象:用cv2.imread读 mask,shape是(640, 480, 3),当灰度图直接输入模型报维度错误,或者 loss 计算时 shape 不匹配。

原因:数据集的 mask 是调色后的彩色标注,不是训练常用的单通道索引图,很多从分类任务转过来的同学默认 mask 就是黑白图,看到三通道彩色图直接懵。

解决:读入后用 2.2 节的颜色集合统计建立 RGB 到类别索引的映射,再逐个像素转换。我这里给一个可复用的转换函数:

def rgb_to_class(mask_rgb, color_map): h, w = mask_rgb.shape[:2] class_map = np.zeros((h, w), dtype=np.uint8) for cls_idx, rgb in color_map.items(): class_map[np.all(mask_rgb == np.array(rgb), axis=-1)] = cls_idx return class_map

color_map是一个形如{1: (255, 0, 0), 2: (0, 255, 0), ...}的字典,键是类别索引,值是 RGB 元组。函数遍历每个类别,用np.all在最后一维上做精确匹配,整张图归一遍,速度在 640×480 分辨率下完全可接受。注意这里匹配的是 RGB 顺序,如果用 OpenCV 读图要先把 BGR 转成 RGB,否则颜色匹配全部落空。

5.2 文件名以d_r_617_.bmp这类前缀结尾,路径拼接配对失败

现象:写mask_path = img_path.replace('images', 'masks')后发现有些文件名配对成功、有些配对失败,或者保存叠加图时提示文件不存在。

原因:文件名前缀和目录名不完全同步,带下划线结尾的前缀在替换时如果用了不严谨的拼接方式,可能丢后缀。比如直接在循环里用img_path[:-4] + '_mask.bmp'拼出来的路径和实际文件对不上。

解决:统一用os.path.basename取文件名,再拼到 mask 目录,避免手写路径拼接出现的低级错误。

import os name = os.path.basename(img_path) mask_path = os.path.join(mask_dir, name)

两行代码就能绕开所有手工拼接的坑。以后不管目录怎么挪、前缀怎么改,只要 images 和 masks 下的文件名一致,这段逻辑就永远成立。

5.3 随机旋转只转了原图,mask 边缘整体错位

现象:训练 loss 神奇地下降很快,但验证 mIoU 始终不高,可视化发现 mask 边缘错位,目标轮廓和原图对不上。

原因:分割增强必须同步原图和 mask,任何几何变换都要用同一套参数。只转原图不转 mask,训练时模型看到的输入和标签根本不在同一个坐标系里。

解决:用 4.1 节的rotate_image_and_mask,mask 端固定INTER_NEAREST,并且在代码里加一个断言,转完后检查两张图的 shape 是否仍然一致。

assert img_rot.shape[:2] == mask_rot.shape[:2], 'image and mask shape mismatch after rotation'

这个断言放在训练循环外面即可,一旦出现维度不一致会立刻终止程序,而不是等到训练几百轮后才发现数据出了大问题。同样的逻辑也适用于裁剪、翻转、缩放等所有几何增强。

5.4 背景像素不是纯(0, 0, 0),类别统计对不上

现象:统计颜色时发现背景色不是(0, 0, 0),而是一个接近黑色但非零的 RGB,导致按纯黑匹配背景时背景全部变成"未分配"。

原因:调色时对背景做了轻微着色,或者压缩造成了颜色偏移,这类情况在 BMP 格式里偶有发生。

解决:把背景识别从"等于纯黑"改成"最接近黑色",或者直接用颜色统计结果里的实际背景色,不要死磕(0, 0, 0)这个理想值。最稳妥的做法是在统计完所有颜色后,把像素占比最高的那个颜色当作背景,再人工确认一次。占比确认这个动作,下文还会用到。

6. 可视化之外的硬校验:用类别占比统计核对标注映射

最后一招是我现在每次拿到新分割数据集都会先做的事:统计整份数据的类别像素占比,画一张类别分布条状图。别小看这个动作,它能一次性暴露出类别索引映射是否写错、是否有类别在数据里极少出现、背景占比是否畸高三个问题。对于这份水下分割数据,背景简单、前景区域丰富,理论上背景占比应该在 50% 上下浮动,如果统计出来背景占了 95%,那说明要么映射错了,要么数据里真的有一批纯背景图,需要回头查。

统计脚本也不复杂,核心就是遍历所有 mask、按索引累加像素数,最后做归一化。

import numpy as np import glob def compute_class_distribution(mask_paths, num_classes=9): hist = np.zeros(num_classes, dtype=np.float64) for p in mask_paths: mask = cv2.imread(p) mask_rgb = cv2.cvtColor(mask, cv2.COLOR_BGR2RGB) class_map = rgb_to_class(mask_rgb, color_map) # 复用第5章的转换函数 for c in range(num_classes): hist[c] += np.sum(class_map == c) hist /= hist.sum() return hist dist = compute_class_distribution(glob.glob('train/masks/*.bmp')) print('类别占比:', np.round(dist, 4))

跑完之后如果某一类的占比接近 0,那基本可以确认它被漏进了背景或者映射表少配了一条。遇到这种情况,我会把该类别对应的 RGB 单独打印出来,再抽两三张含有它的图做人工确认,确认后再更新color_map。正常范围内,八个前景类别的占比会有高有低,不需要刻意均衡,但至少要保证每一类都有足够像素参与训练,占比过低的类别在 loss 计算时可以考虑按类别频率加权重。

从那以后,我每次拿到新数据集,都会强制先走一遍「颜色统计 → 人工映射 → 全量占比校验」三步流程,再开始写训练代码。这个习惯帮我拦下过至少三次标签映射错位的血泪事故,也希望帮到你。

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

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

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

立即咨询