这套数据集是我在一次水利监测项目中顺手整理出来的成果,名字很直白:河道口水面漂浮物识别分割数据集,labelme格式,907张,2类别。当时项目方需要对一个河道入水口上方的监控画面做像素级识别,准确圈出水面漂浮的塑料瓶、树枝、泡沫板、枯草团等杂物,而不是只给一个粗糙的检测框。原因很简单:漂浮物形状不规则,容易被遮挡,下游水量评估和清理规划要的是精确面积和轮廓,不是“那个区域里有个东西”这种信息。于是就有了这套以多边形标注为核心的分割数据集。
如果你正在做水利智慧监管、水面垃圾自动清理、河道异常漂浮物预警,或者想训练一个语义分割模型来识别水面目标,这套数据集的构建思路和踩坑记录都值得你看看。哪怕你手里没有同样的图像源,光是把labelme格式的JSON数据正确转换成训练用的mask,再落到模型训练、评估和部署那一套流程,就能避掉不少弯路。
1. 这套数据集到底解决了什么问题
1.1 河道口漂浮物监测为什么需要分割而不是检测
很多初次接触这个场景的人会问:用目标检测框住漂浮物不就行了?实际上在水面这种复杂场景里,检测框的缺陷很明显。
漂浮物很少有规整的矩形轮廓。一团枯枝可能占据不规则的一长条,一个塑料袋往往半沉半浮、边缘扭曲,多个漂浮物挨在一起时,检测框会互相重叠,导致漏检和误检。更重要的是,清理船只和河道管理需要知道漂浮物的“覆盖面积”和“边界形状”,这直接关系到清理耗时、运输体积和下游污染负荷估算。分割结果天然给出了轮廓,可以直接计算像素面积与实际面积的比例关系。
另一个关键点是背景干扰太严重。水面有倒影、波纹、太阳反光、水草、岸边的石头,这些都会让检测框疯狂误报。分割模型必须学会区分“真正的漂浮物边缘”和“水面的光影纹理”,这对模型的精细化特征提取能力要求更高,也正是分割任务能解决的问题。
1.2 907张、2类别的数据定位与适用场景
这套数据集一共907张图像,标注为2个类别。需要先说明一下这个“2类别”的含义:在labelme标注中,我们为每个多边形记录的label只有两种——floating_debris和background。
其中background并不是背景像素的精细化标注,而是用来表示“非漂浮物”的统一类别。因为语义分割在标准训练流程中通常需要一张包含所有类别像素的mask,背景类也要占一个通道。也就是说,真正要识别的目标类是floating_debris,也就是各种水面漂浮垃圾和杂物;background是它的对立面,包含水体、天空、河岸、建筑物、植被等一系列非目标区域。
很多不熟悉分割任务的人看到“2类别”会误以为数据集能分出“塑料瓶”和“树枝”两种。不是的。2类别是“目标与背景”的二分关系,这种做法在工业落地项目里非常常见,因为河道管理方只关心“哪里漂着东西”,并不关心具体是什么材质。当然,后续如果你有精细分类需求,可以把背景拆开,把不同漂浮物类别分别标注,数据量就要相应扩大。
1.3 从标注格式看数据集的工程适配性
之所以选用labelme格式,是因为它在多类别多边形标注领域几乎是最通用的中间格式。labelme生成的JSON文件以文本形式保存,包含了图像路径、图像尺寸、每个多边形的顶点坐标和标签名。几乎所有分割框架都能通过简单的转换脚本把它变成mask.png,或者直接喂给支持polygon格式的训练管线。
907张不是一个大到惊人的规模,但对于河道口这个相对固定场景来说已经足够启动一个初版模型。河道口摄像头机位固定,视角变化有限,大部分漂浮物都出现在水面中部和边缘,目标尺度相对集中。相比之下,无人机拍摄的广阔河道场景往往需要几千甚至几万张才能覆盖各种角度,而固定监控场景数据量需求小得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从原始图像到labelme标注:数据构建全过程
2.1 图像采集与筛选原则
做图像数据集的第一个原则永远是:宁缺毋滥。我们当时从某几个固定点位连续采集了几天的监控视频,再抽帧得到候选图像。如果直接把抽帧结果全塞进标注工具,里面会有大量高度相似的画面,训练时模型会对这些重复场景过拟合,验证指标虚高,换到新视角就立刻垮掉。
我的筛选原则有三条:
- 时间分布均匀:同一天内每隔一段时间抽一帧,避免连续视频帧过于相似。
- 光照条件多样:晴天、阴天、逆光、傍晚弱光都要保留,夜间的图像如果带有红外补光也可以单独归组,但要注意与白天图像分开评估。
- 漂浮物数量差异化:既有单目标图像,也有大面积的漂浮物聚集图像,还有少量完全不包含任何漂浮物的纯背景图像。
最后一条经常被人忽略。如果所有图像里都有目标,模型学到的是“只要有水面就倾向于输出目标”,遇到空荡荡的水面反而会输出一堆噪声区域。加入一些负样本能让模型学会抑制背景。
2.2 标注规范与类别定义
标注规范这件事,必须在一开始就定死,不然后面几百张图会标出五花八门的多边形。
我们用了下面的规范:
| 项目 | 规范内容 |
|---|---|
| 目标类名称 | 统一使用 floating_debris |
| 背景类 | 背景不需要手动标注,生成mask时自动填充为0 |
| 多边形贴合程度 | 顶点沿目标外轮廓布置,间距控制在轮廓弯曲处加密,直线处可稀疏 |
| 遮挡处理 | 当前景漂浮物被另一漂浮物遮挡时,分别标注各自可见部分,不画遮挡轮廓 |
| 极小目标 | 面积小于图像面积0.2%的漂浮物跳过不标,避免无法学习的噪声样本 |
| 水花与半浸物 | 只标注露出水面且轮廓清晰的可见边缘,不把水花算作目标 |
这里最需要强调的是“半浸物”。漂浮物往往不是完全浮在水面上,一部分在水下,一部分被水波覆盖。标注时我们统一规定:只把可见的、清晰的固体轮廓画出来,水面以下模糊的部分不猜。这样标注员之间的一致性才会高。
2.3 质量管控与二次审核
907张图、每张图里1到8个目标不等,全部由我和另一位同事手动标注。这种做法费时间,但能保证质量。使用labelme标一个复杂多边形的耗时大约在30秒到2分钟之间,全部标注用了大约三周业余时间,中间还穿插了很多等待和返工。
审核环节比标注本身更重要。我们是这么做的:每完成一批,随机抽取20张生成预测mask,手动叠加到原图上做透明对比。重点看几个地方:
- 多边形是否明显偏离目标边缘,比如切到了水面波纹
- 同一张图像里是不是遗漏了某个明显目标
- 有没有把一个连续目标错误地拆成多个碎片
- 有没有把岸边的石头、水草误标成漂浮物
出现上述问题时,直接在labelme里重新编辑对应多边形,然后更新JSON。审核通过后的图像才进入最终数据集。整个审核过程我们重复了两次,最终才把标注误差控制在可接受范围。
3. labelme格式详解与转换实操
3.1 标注文件的JSON结构拆解
labelme生成的每个JSON文件是一个字典结构,最核心的几个字段如下:
json复制{
"version": "5.2.1",
"flags": {},
"shapes": [
{
"label": "floating_debris",
"points": [[x1, y1], [x2, y2], ...],
"group_id": null,
"shape_type": "polygon",
"flags": {}
}
],
"imagePath": "river_023.jpg",
"imageData": null,
"imageHeight": 1080,
"imageWidth": 1920
}
其中shapes是一个列表,每个元素代表一个多边形。points是二维列表,存放多边形顶点的像素坐标。注意labelme的老版本中imageData字段可能为空,只有imagePath;新版本中imageData会包含base64编码的原始图像,便于单文件迁移,但也会让JSON文件变得非常巨大。907张图的JSON文件如果都带imageData,体积会翻几十倍。保存时可以去掉imageData,只保留路径信息。
3.2 转换为训练用分割掩码的几种方案
labelme JSON不能直接用于训练,必须转换为单通道的PNG mask。转换时有一个约定:每个mask图像中,某像素点的数值等于该位置的类别编号。比如我们把background设为0,floating_debris设为1,那么mask中目标区域的像素值就是1,其余像素都是0。
常用的转换方式有两种:
方案一:使用官方命令行工具
旧版labelme自带labelme_json_to_dataset脚本,能直接根据JSON生成img.png、label.png、label_viz.png、label_names.txt等文件。但新版labelme移除了这个命令,更推荐用户使用API自己写转换逻辑。
方案二:基于OpenCV和shapely自定义转换
我的做法是用Python写一个批量转换脚本,核心逻辑分三步:
- 读取JSON文件中的points和imageWidth/imageHeight。
- 新建一个与图像同尺寸的全零矩阵,作为mask。
- 使用OpenCV的
fillPoly方法,将多边形顶点填充为对应的类别编号。
python复制import json
import cv2
import numpy as np
import os
def json_to_mask(json_path, output_dir, class_map={"background": 0, "floating_debris": 1}):
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
h, w = data["imageHeight"], data["imageWidth"]
mask = np.zeros((h, w), dtype=np.uint8)
for shape in data["shapes"]:
label = shape["label"]
if label not in class_map:
continue
points = np.array(shape["points"], dtype=np.int32)
class_id = class_map[label]
cv2.fillPoly(mask, [points], class_id)
name = os.path.splitext(os.path.basename(json_path))[0]
cv2.imwrite(os.path.join(output_dir, f"{name}_mask.png"), mask)
这段脚本只处理polygon类型的shape。如果你的标注里出现了circle或者rect等其他类型,需要额外分支处理。907张图批量跑一遍,十几秒就能生成全部mask。
3.3 踩过的坑:坐标归一化、shape类型和路径迁移
这里必须分享几个我实际踩过的坑,都是血泪教训。
-
坐标类型问题:labelme中的points默认是整数像素坐标,但有时候手工拖动边缘后可能产生浮点数。转换时直接用
np.int32做截断,会造成边缘出现几个像素的偏移。更好的做法是用np.round()先四舍五入,再转int32。 -
shape_type不统一:如果标注时不小心用了
circle或者linestrip,fillPoly会失败。我的解决办法是在审核阶段写个快速脚本统计所有JSON中的shape_type,发现有不是polygon的立刻退回重新标注。 -
图像路径迁移问题:JSON中的
imagePath是绝对路径时会带来麻烦。同一份数据集换到别的电脑,路径会失效。不要手改每个文件,用一个脚本统一把imagePath改成相对路径,或者干脆在训练代码里忽略这个字段,二次读取时用实际图像路径覆盖。
4. 基于该数据集的模型训练与效果剖析
4.1 模型选型与训练配置参考
有了mask之后,常规分割模型都能训练。我在这套数据集上主力尝试了两种架构:U-Net和DeepLabV3+。U-Net结构简单、显存占用低,适合快速验证;DeepLabV3+的空洞空间金字塔池化能够捕捉多尺度上下文,对漂浮物大小不一的场景更友好。
如果只有907张图,我建议优先用ResNet18或ResNet34做backbone,不要一上来就上ResNet101之类的大模型。数据集规模不够大的时候,大网络非常容易过拟合。配置参考如下:
| 配置项 | 参数值 |
|---|---|
| 输入尺寸 | 512x512 |
| Backbone | ResNet34 |
| 分割头 | DeepLabV3+ |
| 优化器 | SGD,momentum=0.9 |
| 初始学习率 | 0.01 |
| 学习率策略 | Poly衰减 |
| 损失函数 | CrossEntropyLoss(类别加权) |
| Batch Size | 16 |
| Epoch | 80 |
| 数据增强 | 随机翻转、随机旋转、随机缩放 |
类别加权是因为背景像素占比通常远大于目标像素。比如在一张图里漂浮物面积只占全图的5%,如果不加权,模型会趋向于把所有像素都预测成背景,mIoU看起来还行,但目标区域完全无法分割。我用的权重是目标类设为5,背景类保持1。这样模型会强制关注目标区域。
4.2 评估指标与典型效果
评估时不要只看一个指标。我这套数据上常用的几个指标是:
- mIoU:所有类别IoU的均值。目标类IoU是最关键的一个。
- Pixel Accuracy:预测正确的像素比例,容易被背景主导,仅作参考。
- F1-score:精确率和召回率的调和平均,配上阈值搜索来判断模型输出的概率图是否可以直接使用。
在把907张划分为训练集634张、验证集136张、测试集137张之后(按7:2:1比例随机划分,但保证同一时间段采集的连续帧不落到不同集合),我用DeepLabV3+训练了80个epoch,最终目标类IoU大约在0.83到0.86之间,F1-score在0.87附近。这个水平放在实际监控场景里已经具备可用价值:如果有人工定期巡检兜底,模型输出的目标轮廓能大幅减少人工复核的工作量。
如果你训练时发现目标类IoU一直在0.5左右上不去,先不要怀疑模型,先去看自己的mask图是不是准。很多情况下是标注多边形过于粗糙,把背景区域也标进去了,导致模型学到的是错误边界。
4.3 后处理与部署落地的细节
模型输出的原始概率图不能直接发给业务系统。我通常会在部署阶段加几步后处理:
- 对目标类概率图做阈值为0.5的二值化。
- 用形态学开运算(3x3或5x5结构元素)去除细小噪点。
- 用连通域分析过滤面积过小的区域,比如小于全图面积0.1%的连通域直接去掉。
- 对每个连通域计算外接矩形、轮廓面积和凸包,方便生成业务上报数据。
这些后处理在OpenCV里很容易实现,但能显著提升最终指标的稳定性。尤其是监控画面中偶尔会出现远处水鸟、漂浮落叶等小目标,面积过滤可以帮你控制误报数量。
部署方面,如果算力有限,U-Net的FP32模型可以在常见边缘设备上跑到接近实时。DeepLabV3+稍重,但用TensorRT做INT8量化后也能满足一秒钟处理一帧的需求。关键在于输入尺寸,512x512已经能在精度和速度之间取得较好的平衡,盲目增大输入不会线性提高精度,反而让帧率掉得很难看。
5. 常见问题与排查技巧实录
5.1 标注与转换环节的高频问题
问题1:labelme打开图像后显示“file not found”。
这个通常是imagePath损坏或者路径中包含中文/特殊字符。解决方案是在JSON所在目录下重新打开labelme,并确保图像文件与JSON同级。
问题2:转换得到mask全黑。
先检查JSON中的points是否异常。如果points为空,或者坐标范围超过图像尺寸,fillPoly就画不进去。另一个常见原因是类别名映射错误,比如标注时不小心把floating_debris打成了floating-debris,class_map里匹配不到,自然全部成了背景。
问题3:mask边缘有锯齿,和标注多边形不吻合。
这是因为fillPoly的渲染精度有限。可以使用cv2.fillPoly的lineType=cv2.LINE_AA参数来获得抗锯齿边缘,但如果你要的是硬分割标签,其实锯齿边缘问题不大,主流训练框架都接受这种标签。
5.2 训练和评估阶段的坑
训练loss不下降:先别调模型,先看一眼数据集上下一致性。比如前100张图里漂浮物是深色的,后100张图里漂浮物是浅色的,模型会很困惑。可以按采集时间排序,观察标注内容的变化。
验证集指标好但测试集极差:大概率是数据划分时出现了泄漏。比如同一段视频的连续帧分别落到了训练集和测试集,模型只是记住了相似场景,而不是学会了识别漂浮物。解决办法是按视频序列或者按时间戳分组划分,尽量保证所有图像来自于独立样本单位。
模型把水面倒影当成了目标:这个很经典。如果训练数据里的负样本不够多,模型很容易把水波纹的纹理误认为漂浮物。我当时的解决办法是额外收集了200张无目标的纯水面图像,用预训练模型预测这些图像,把明显误检的样本加入训练集的负样本中。效果立竿见影。
5.3 数据集后续扩展与维护思路
907张不是终点。在项目持续运行过程中,每星期都能收到新的监控截图。我的维护方法是建立一套“模型自动预筛+人工确认”的主动学习流程:
- 用当前最佳模型对新的无标注图像做预测。
- 保存预测结果中置信度低于0.3或高于0.8的区域。
- 人工只查看置信度中等(0.4到0.6)的样本,因为这些是最容易错的决定性样本,重新标注加入训练集。
另一个方向是把labelme标注升级为实例分割的标注结构。如果你的业务需要区分“这一片漂浮物”和“那一片漂浮物”的数量,可以给每个多边形加上group_id字段,每个独立目标分配不同的group_id,这样训练实例分割模型时能直接利用。
6. 写在最后:个人实操体会
做这套数据集的过程中,我最大的体会不是“标注有多累”,而是“规范有多重要”。如果一开始没有定清楚半浸物怎么标、遮挡怎么画、极小目标怎么取舍,后面的每一张图都会成为埋雷。与其后期花两倍时间返工,不如动手前多花半小时把规则写清楚,甚至找一张示例图做标注演示。
另外,labelme格式虽然古老,但在工业项目中的生命力依然很强。它简单、透明、可读,编辑方式直观,标注结果还能被很多开源工具直接解析。即使现在很多新项目都在用COCO格式或者Cityscapes格式,做一个批量脚本转换并不困难,重点是你手里有足够干净、平衡、覆盖条件下的原始标注。
如果你正在准备类似的水面视觉项目,希望这篇内容能帮你省掉一些试错成本。等后续这套数据集扩充到更多类别或者加入夜间场景时,我可能会再分享一版包含实例分割内容的工程经验,到时候我们继续聊。
