简介面向水下机器人、海洋生态监测等图像分割应用场景这里提供一份包含人类、海草、珊瑚、岩石、鱼等目标的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_inchestight, dpi120) 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), flagscv2.INTER_LINEAR) mask_rot cv2.warpAffine(mask, M, (w, h), flagscv2.INTER_NEAREST) return img_rot, mask_rot这里最关键的参数是flags图像插值用INTER_LINEARmask 插值必须用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, transformNone): 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_size8、shuffleTrue就能正常参与训练了。分辨率 640×480 在常见显存下可以直接进模型但如果你用的是 512×512 输入的分割网络Resize 之后要确认 mask 同步缩放到相同尺寸最稳妥的做法是在 Dataset 内部就对 img 和 mask 执行同一套 Resize而不是靠外部 transform 各自处理。5. 避坑记录mask格式、类别索引与数据对齐的四个坑5.1 mask 读出来是三通道彩色图输入模型报维度错误现象用cv2.imread读 maskshape是(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), dtypenp.uint8) for cls_idx, rgb in color_map.items(): class_map[np.all(mask_rgb np.array(rgb), axis-1)] cls_idx return class_mapcolor_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_maskmask 端固定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_classes9): hist np.zeros(num_classes, dtypenp.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 计算时可以考虑按类别频率加权重。从那以后我每次拿到新数据集都会强制先走一遍「颜色统计 → 人工映射 → 全量占比校验」三步流程再开始写训练代码。这个习惯帮我拦下过至少三次标签映射错位的血泪事故也希望帮到你。本文还有配套的精品资源点击获取