水下目标语义分割8分类数据集构建与可视化训练全流程实战
发布时间:2026/9/28 15:36:36来源:尧图网络
简介面向图像分割算法研究与水下机器人视觉应用该数据集提供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, dtypenp.uint8) h, w arr.shape[:2] # 白底mask-1表示“未定义颜色”转换后要人工检查 label np.full((h, w), -1, dtypenp.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), modeP) 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和numpyfrom 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, alpha0.6): # 读原图和单通道标签图 img Image.open(image_path).convert(RGB) mask Image.open(mask_path).convert(P) # 关键不要convert(RGB) img_arr np.array(img, dtypenp.float32) mask_arr np.array(mask, dtypenp.uint8) # 像素值类别ID # 生成彩色掩码 color_mask np.zeros((mask_arr.shape[0], mask_arr.shape[1], 3), dtypenp.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)参数说明alpha0.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, dtypenp.uint8) # 统计每类像素占比观察类别不均衡 counts np.bincount(mask_arr.ravel(), minlength8) 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, dtypenp.int64)).long() return img_tensor, mask_tensor逻辑说明convert(P)读取后np.array(mask, dtypenp.int64)得到的就是像素索引数组值域是0到7正好作为 CrossEntropyLoss 的 target。这里故意不用transforms.Resize因为Resize的默认插值算法是双线性用在标签图上会生成柔和的边缘像素导致类别值变得不是整数。如果你一定要固定输入尺寸就用Image.Resampling.NEAREST最近邻插值保住类别的离散性。参数说明crop_size(512, 512)是常见水面以下任务的分辨率权衡。512x512能保留足够的纹理细节同时显存占用可控如果你跑的是人工管道或小目标检测场景可以提升到768或1024但要让batch_size降下来。随机裁切这段代码我加了边界保护如果iw - cw小于0torch.randint会报错所以实际项目中我会在初始化时判断一次图片最小尺寸小于crop_size的先做padding这里为了可读性省略了。4.2 训练参数怎么调参考配置和loss选择语义分割模型的选型不复杂水下目标这种中小型数据集从U-Net或DeepLabV3起步是性价比最高的做法。模型选型不是重点重点是一组能跑起来不白忙活的训练参数。我常用的参考配置如下参数推荐值说明输入尺寸512x512兼顾细节与显存batch_size8单卡RTX 3060/3090可用初始学习率1e-4AdamW默认权重衰减1e-4最大epoch80早停门槛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], dtypetorch.float32).cuda() criterion nn.CrossEntropyLoss(weightclass_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时已经全乱掉了。从那以后我给自己定了一条规矩任何新的语义分割数据集到手第一件事跑可视化第二件事跑类别统计然后才开始调模型。这套流程看起来多花一小时但比起盲调模型失败再排查省下的时间是几天的量级。希望这些踩坑经验能帮你少走一段弯路顺利跑通属于自己的水下目标分割方案。本文还有配套的精品资源点击获取
网站建设高端定制企业官网