干我们这行的都清楚,水面看起来是一个样,实际放到无人机视角上看,远处翻涌的急流和贴着岸边缓缓流动的清水,完全是两码事。前阵子我把手上这批无人机河流影像重新过了一遍,把标注好的360张labelme格式数据整理成了一套可复用的水流状态识别分割数据集,类别一共7种,覆盖了从平静水面到剧烈白浪的常见水态。今天这篇就当是项目阶段总结,把数据怎么来的、标注怎么定的、坑怎么踩的、模型怎么喂的,都摊开聊一聊,给准备做水域分割或者水情智能识别的人少走点弯路。
这套数据集的定位很明确:不做水陆分割,而是专门做“水里面更细的分类”。也就是说,模型要把一条河拆成不同水流状态的区域,比如哪里是静水、哪里是急流、哪里出现了漩涡、哪里是跌水形成的白泡。360张图不少,做分割数据集也基本够用,尤其是配合预训练和增强之后,能支撑起一个初版识别模型。适合读这篇的人包括无人机巡检工程师、做遥感AI应用的学生或研究员、还有搞数字孪生流域的团队。
1. 项目概述与数据集设计思路
1.1 为什么选无人机视角做水流类型识别
传统的水文监测靠点位传感器和人工目视,前者只能覆盖固定断面,后者在宽阔水域或者危险河段根本没法靠近。无人机视角的天然优势在于“既高又稳”,能在几十米到几百米的空中把一大段河道的水面纹理完整拍下来,这恰好是水流类型识别最需要的全局信息。
水面上的纹理直接反映了流速、水深和河床粗糙度,例如平静水域表面光滑,阳光反射呈镜面状;急流区域因为流速大、紊动强,水面上会形成密集的白色条纹和气泡。这些特征在无人机斜视或者俯拍视角下非常突出,正好可以用图像分割模型逐像素区分。相比雷达测流等硬件方案,纯视觉识别端的部署成本低、速度快,而且能做到全河段覆盖,和监测站点的定点数据形成互补。
做数据集时我没有选普通网络图片,而是全部用无人机实际航拍影像,因为俯拍视角的透视关系、水面反光干扰、岸上植被遮挡等因素,只有真实航拍数据才能包含。用网络上的平视摄影图训练,换到无人机场景基本就废了。
1.2 七类水流状态的定义与划分依据
最开始我拿到一批河湖影像,脑子里冒出来的分类维度可能有十几种,比如按流速分、按水深分、按扰动程度分。但标注是最贵的,类别定得越细,不同标注员之间的扯皮就越多。最终我锁定了7类,每一类在视觉上都有清晰的边界,且在实际应用里面都承担着不同的水情含义。
| 类别标识 | 类别名称 | 视觉特征 | 典型成因与场景 |
|---|---|---|---|
| 0 | 背景(非水体) | 植被、河岸、砂石、桥梁、建筑等 | 一切不属于水面的区域 |
| 1 | 平静水面 | 表面光滑,颜色均匀,无明显纹路 | 静水湖湾、水库库区、缓流河段 |
| 2 | 细微波纹 | 有轻微起伏,亮度有细小变化但无白泡 | 微风作用下的开阔水面 |
| 3 | 规律流纹 | 呈平行或带状纹路,方向感明显 | 均匀水深的顺直河道 |
| 4 | 急流/白浪 | 白色泡沫覆盖,纹理破碎且亮度高 | 陡坡、浅滩、高流速区域 |
| 5 | 漩涡/回流 | 呈环形结构,水体旋转明显 | 桥墩下游、河道弯曲与障碍物后方 |
| 6 | 跌水/水跃 | 水面垂落,伴随水花与强湍动 | 拦水坝、落差处、阶梯状河床 |
这里要特别讲一下类别0“背景”为什么单独列出来。分割任务的本质是像素级分类,但无人机影像中河岸、植被、船只、桥墩会占据大量像素,这些区域如果不单独标注,模型就会把岸上纹理当成一种水流状态,导致错误输出。所以我把背景强制纳入类别体系,让模型先学会区分水与非水,再进一步细分水内状态。
那为什么不把“水草漂浮区”“泥沙浑浊水体”也加进来?因为数据采集阶段碰到这种情况太少,硬要凑成类别会导致样本极度不均衡。与其让模型在一个只有十几张图的类别上瞎学,不如先集中精力把7类做扎实。后续如果扩充数据,再考虑增加类别。
1.3 数据集规模与格式选型
360张影像,labelme格式,也就是每张原始图像对应一份JSON文件,里面记录了每个多边形标注的形状顶点和类别标签。选择labelme作为中间格式,主要原因是它轻量、灵活、对分割任务友好,而且社区生态成熟,转成COCO、VOC或者RLE格式都很方便。
360这个数量不是随便拍的测试集,而是我在数百张影像里逐张筛选、清洗之后的满足标注质量的有效帧。分割任务常见的数据量级从几百到几万不等,如果是从零训练一个深层次模型,360张确实偏少,但在迁移学习场景下,用预训练编码器加数据增强,360张足够把水流状态分割的基线模型训练出来。我后面会把训练细节展开讲。
用labelme还有一个现实原因:我的标注团队分布在三台电脑上,labelme是纯本地运行,不需要搭建数据标注服务器,任务下发放给每个标注员,标注完把JSON文件收回来就行。对几十人规模的小团队来说,这种离线模式效率很高,而且不会受到网络同步冲突影响。
2. 数据采集与预处理实操
2.1 无人机采集参数与飞行规划
采集是整个数据集质量的地基,标注再认真,原图糊了也是白搭。我当时用的是常见四旋翼无人机,飞行高度定在50到120米区间。这个高度既能保证单张图像覆盖足够长的河道片段(大约几十到上百米),又不会让水面纹理小到无法辨认。飞行高度再低,单帧覆盖太少,岸线两侧的目标容易出框;再高,水面纹理出现混叠,急流和波纹会分不清。
云台角度我选择了两个方向:正俯视(90度)和前下视(约60到75度)。正俯视视角最适合处理水陆边界和纹理分布,前下视则能捕捉到跌水和波浪的高度信息,两者混用可以增强模型对不同观察角度的鲁棒性。飞行航线沿河流行走,保持侧向重叠率60%以上,纵向重叠率70%以上,这样相邻帧能覆盖同一水面区域的不同光照角度,有利于后续做视频时序扩展。
光照选择很关键。大晴天正午水面反光特别严重,耀斑区域完全过曝,细节丢失。我后来把采集时间限制在上午9点到11点、下午2点到4点,此时太阳高度角适中,水面的镜面反射会被压低,波浪纹理和泡沫区域的对比度反而更高。如果一定要在中午飞,就需要装上偏振镜。偏振镜能滤掉来自水面的偏振光,代价是进光量下降约一档,需要相应调整ISO和快门。
飞行速度也要控制,尤其在河道弯曲段,无人机转弯时云台惯性容易造成画面倾斜或模糊。我设置的最大飞行速度是6米每秒,拍摄快门不低于1/1000秒,配合机械快门或者电子卷帘较小的机型,基本能冻结住水面的瞬时纹理。
2.2 图像筛选与清洗
采集回来的原始素材往往超过一千张,真正能进标注管线的只有一半左右。筛选标准有三条:一是画面不能有明显运动模糊,这在水面高速流动场景里很常见,无人机对焦到远处山体时,近处水纹就拖成一片;二是不能有大面积高光溢出,白色泡沫区域如果纯白到249以上的像素占比过大,标出来也没法区分真实白浪和曝光过度;三是同一河段尽量保留至少三张不同飞行方向或拍摄角度的影像,防止模型产生视角偏向。
我在清洗阶段专门写了一个小脚本,统计每张图像的亮度直方图和拉普拉斯梯度方差。模糊图的Laplacian方差通常低于一个阈值,直接淘汰。过曝图看亮度大于250的像素占比,如果超过18%,我会人工复核决定是否丢弃。这套流程做下来,最后的有效帧基本都是纹理清晰、曝光均匀、内容多样化的。
2.3 图像尺寸与存储格式统一
不同无人机、不同相机设置拍出来的图像分辨率各不相同,标注之前必须统一到一个标准。我采取的策略是长边缩放到1920像素,短边保持原比例。这样做有三个好处:一是减少标注时的大图卡顿,labelme在打开四千万像素大图时会明显变慢;二是统一分辨率能避免模型训练时因为输入尺寸差异产生大量resize变形;三是为后续输出评估指标提供基准。统一后的图像保存为PNG无损格式,labelme的JSON并不内嵌图像数据,只保存路径,所以图像文件、JSON文件必须保持同名且放在对应文件夹中,一旦改名或移动路径,JSON就打不开了。
3. 标注规范与labelme使用详解
3.1 labelme安装与基本操作
标注工具不复杂,Python环境下直接pip安装即可。用conda创建虚拟环境是必须的,否则依赖冲突能让你折腾半天。下面是常见安装流程:
conda create -n labelme python=3.8 conda activate labelme pip install labelme labelme打开后通过“Open Dir”指定图像目录,labelme会自动加载目录下的图片。每张图需要按住鼠标沿着目标区域的边缘点击生成多边形顶点,完成一个闭合形状后弹出标签选择框,选好类别后自动保存到同名JSON文件。对于水陆边界不清晰的位置,可以先将图像放大到200%到400%,再沿着色差边缘逐点布置顶点。一个多边形至少需要6到8个点,靠近边缘处点要密集一些,曲线变化大的区域如果点太少,转成mask后会出现锯齿或变形。
3.2 七类别的标注策略与边界规定
标注标准是整个数据集质量的生命线。对比几种分类,最复杂的其实不是背景,而是相邻流动状态之间的边界。
第一条规定:优先标注覆盖面积较大的区域。如果一块区域里夹杂着少量其他类别的碎片,比如平静水面中心有一小块漂来的泡沫,可以整体归入占优类别,但前提是碎片面积小于该区域总面积的5%。如果大于5%,就需要单独拆分多边形。
第二条规定:急流/白浪和规律流纹重叠时,以“白色泡沫覆盖比例”作为判定依据。当一块区域内白色泡沫覆盖超过40%,直接标为急流/白浪;低于40%但有明显流向纹路的,标为规律流纹。这条规则很有用,因为流速和泡沫之间没有恒定公式可算,只能用视觉上的可重复标准统一不同标注员的理解。
第三条规定:漩涡/回流往往依附于障碍物下游,标注时要以螺旋结构的完整可见范围为准。如果中心水体都在图像范围内,就把整个螺旋包括中心凹陷一起画进去。如果螺旋体部分在图像外,宁可不标,也不要把残缺区域硬标成漩涡,否则模型会学着把画面边缘的某些纹理误判成旋涡。
第四条规定:跌水/水跃的边界从水面明显下垂处开始,一直到水花溅落重新归于平面处结束。如果水花飞溅产生大量离散水珠,这些水珠也纳入跌水类,不要因为水珠不连续就拆成背景,因为从图像上看它们依然是水体的一部分。
3.3 LabelMe JSON的结构与转mask方法
labelme生成的文件是JSON格式,核心内容是“shapes”列表,每个元素对应一个多边形,包含label、points(多边形的顶点坐标)、shape_type(通常为polygon)。需要注意的是,labelme默认将整张图的坐标记为绝对像素值,不会自动归一化。这在后续转成mask时需要用图像宽高换算。
实际训练分割网络时,我们需要把JSON转成单通道索引图像或RGB标签图。可以用官方的labelme.utils.shapes_to_label函数,示例代码如下:
import json import numpy as np import PIL.Image import labelme.utils def json_to_mask(json_path, img_width, img_height, label_map, output_path): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) mask = np.zeros((img_height, img_width), dtype=np.uint8) for shape in data['shapes']: label_name = shape['label'] if label_name not in label_map: continue label_index = label_map[label_name] points = np.array(shape['points']) # shapes_to_label返回的cls和lbl中,lbl为每个对象的分层mask,需要按label_index赋值 cls, lbl = labelme.utils.shapes_to_label((img_height, img_width), [shape], {label_name: label_index}) mask[lbl > 0] = label_index PIL.Image.fromarray(mask).save(output_path)我习惯把label_map定义成:
label_map = { 'background': 0, 'smooth_water': 1, 'ripple': 2, 'flow_lines': 3, 'rapid_whitewater': 4, 'vortex': 5, 'waterfall_jump': 6 }转出来的索引图再配合one-hot编码或者CrossEntropyLoss直接输入模型即可。要注意的是,当同一像素被多个多边形覆盖时,代码会由后遍历的形状覆盖先前的值,所以标注时同一个多边形不能相互压叠。我要求标注员必须让每个多边形的边界要么闭合相接,要么留至少2像素的间隙,避免重叠标注导致mask里出现上一个类别的残余像素。这一步在多人协作时很容易忽略,最后合并数据时要写脚本做像素级重叠检查。
4. 数据质量保障与增强
4.1 多人标注一致性校验
360张图不是一个人标出来的,而是三个人同时推进,所以一致性校验不能省。我的方法分两层。第一层是标注员自检,每完成20张图用脚本统计一下各类别的面积比例是否和人工预期相符,如果某张图里急流类面积异常大,要回看是不是把岸边砂石滩的纹理误标了。第二层是交叉抽检,随机抽取15%的数据,由第二人独立重新标注,然后计算两类标注重叠区域的平均交并比(mIoU)。如果重叠部分的mIoU低于0.75,就判定这批数据存在争议,需要把标注员叫到一起重新看影像,统一标准后再继续。
我试过用像素级准确率来衡量标注一致性,但像素级准确率会被大面积背景拉高,参考价值不大。mIoU更合理,因为它惩罚的是类别间混淆。在实际校验中,最容易出现不一致的是“细微波纹”和“规律流纹”,两者在俯视图里的差异比较微妙。后来我把细微波纹限定为波长较短、方向不明显的水面纹理,把规律流纹限定为有连续长纹、方向一致性强的区域,并把示例截图做进标注手册,争议率立刻降了一半。
4.2 数据增强策略
360张原始图经过翻转、缩放、旋转之后,一张图最多能变出8到12个新样本,训练集规模能扩到接近3000张,足够支撑一个轻量分割模型的基础训练。我会把增强分成几何类和色彩类两类,分别处理。
几何增强主要是水平翻转、小角度旋转、随机缩放、随机裁剪。水流状态本身没有朝向,但无人机航拍时河道方向可能是任意角度,所以水平翻转不会改变类别语义,安全可用。旋转范围建议限制在±30度以内,超过30度容易引入旋转插值导致的纹理混乱,而且会把图像四角裁出过多非水域区域。
色彩增强主要是亮度、对比度、饱和度的随机调整,以及少量高斯噪声。水面反光程度变化很大,这类增强能让模型对光照变化更鲁棒。但要注意的是,不能做随机颜色翻转或者色调反转,那样会把水面变成紫色、绿色,完全脱离真实物理规律,训练出来的模型一旦遇到真实河面,误差会非常大。全部增强操作都要在RGB空间进行,不要动到标注mask,mask只做同步的几何变换。
4.3 类别不平衡处理
分割数据集极容易出现“背景占大头,某一类占小头”的情况。我把360张图逐类统计了像素占比,结果是背景约占整个数据集的65%,平静水面约12%,规律流纹约9%,急流/白浪约6%,细微波纹约5%,涡流和跌水加起来不到3%。这个比例其实很符合实际情况,背景区域天然较大,而跌水和涡流在河段中只出现于局部位置。
处理不平衡有两条思路。一条是数据层面:给背景类做小幅裁剪,把裁剪窗口强行集中到有水体纹理的区域,相当于给模型“增加”少数类的训练权重。另一条是损失函数层面:使用带权重的交叉熵损失,或者用Dice Loss、Focal Loss,让模型把学习重心放到稀有的涡流和跌水类上。我实际下来觉得Dice Loss搭配标准CrossEntropy一起用,效果比单纯加权CE好。因为Dice Loss对类别无关,天然能缓解不平衡。但Dice Loss梯度在某些极端情况下可能不稳定,训练时要把它的权重因子调到0.3到0.5之间。
5. 基于数据集的水流类型分割模型训练要点
5.1 模型选型建议
水流状态分割本质上是语义分割任务,可选择模型范围很广。如果是做离线研究或者精度优先,我推荐使用U-Net作为基线,它在小数据集上非常稳健;也可以直接上DeepLabV3+或者SegFormer,这些模型在公开数据集上的性能更强,但对小数据集有过拟合风险。我的对比实验显示:在360张原始图、未做大规模增强的情况下,U-Net的mIoU能到68%,DeepLabV3+能到71%,但DeepLabV3+在训练集上的mIoU接近95%,明显过拟合。增加增强后,两者差距缩小到2个点左右,所以我倾向于用小模型U-Net做产品化。
如果是部署在无人机机载端或边缘计算盒子,实时性优先,可以考虑轻量化的MobileNetV3-Large结合ASPP头,或者更极端的STDC网络。水流分割任务不需要极强的全局上下文,边缘端的轻量网络反而能吃得住。
5.2 训练流程与参数设置
我采用的训练流程如下:先把图像和mask裁剪成512x512大小再输入网络,batch size为8,初始学习率0.001,采用Poly学习率衰减策略,优化器使用AdamW(权重衰减设为0.01)。训练80个epoch,前20个epoch只冻结编码器训练解码器,之后解除冻结微调全部参数。
数据划分上,按图像文件夹进行随机划分,其中70%训训练集,15%验证集,15%测试集。这里要强调:千万别按像素划分,要按图片划分,因为同一张图的相邻像素高度相关,混在一起会让训练集和测试集产生信息泄漏。
评估指标除了mIoU,还要逐类看IOU。我在测试集上得到的最终结果如下:
| 类别 | IoU |
|---|---|
| 背景 | 96.2% |
| 平静水面 | 85.7% |
| 细微波纹 | 68.3% |
| 规律流纹 | 72.5% |
| 急流/白浪 | 74.1% |
| 漩涡/回流 | 53.6% |
| 跌水/水跃 | 48.9% |
涡流和跌水因为样本太少,IoU偏低也在预料之中。后续要重点补充这两个类别的图像和标注,而不是单纯堆总图数。如果总图上去了但这两类不变,mIoU还是上不去。
5.3 后处理与结果应用
模型输出的置信度图转成最终掩膜后,还会有一些零散的小区域误报。我用连通域面积过滤法:凡是面积小于30像素的预测连通域,一律剔除,并把它们合并到周围最大的类别区域里。这个操作能去掉水面反光碎片带来的误检,但要注意不要用在跌水这种真实标签本身就有大量分散水花的场景上,否则会把真实正样本滤光。涡流类同理,涡流中心可能有一大块平静像素,如果过滤掉中心区域,反而破坏了整体结构。
后处理完的分割结果可以直接叠加在原始影像上,生成水流类型专题图。配合河道GIS矢量数据,可以统计每条河段急流长度、漩涡数量与位置、跌水区域面积等指标,这些对水电站生态流量监测、河道救援风险等级评估、水上搜救路径规划都有直接参考价值。更进一步,把多帧时序分割结果串联起来,还能追踪漩涡的生消过程和白浪面积的变化趋势,形成动态水情特征。
6. 常见问题与避坑指南
6.1 labelme格式相关的坑
labelme格式本身很简单,但用的过程中至少有四种坑,我全遇过,挨个说一下。
第一个坑是路径分离。labelme的JSON中保存了imagePath,默认是文件名而不是绝对路径。但如果有一次用Open File而不是Open Dir打开图片,imagePath可能变成绝对路径。后续转格式时,脚本通过拼接目录加文件名去读图就会失败。解决办法是统一用Open Dir操作,并在切分数据时重写imagePath为相对路径。
第二个坑是中文图片名。Windows系统下labelme可以正常打开中文图片,但生成的JSON编码有时和后续代码默认的utf-8不一致,读取时会报UnicodeDecodeError。我直接把所有图像和JSON文件统一改名为纯英文+数字的组合,比如river_001.jpg、river_001.json,省掉一堆编码问题。
第三个坑是polygon顶点数量过多。如果标一个长条形水流区域,标注员会习惯性沿边缘堆上百个点,这在视觉上很平滑,但转成mask后会让解码过程变慢,甚至在某些转换工具里触发内存暴涨。我要求每个多边形顶点不超过80个,如果超过,就用贝塞尔曲线拟合简化,或分段拆分多边形。
第四个坑是labelme自动生成的“flags”字段。这个字段在官方示例中常被忽略,但在某些旧版本中,flags里保存了额外的标签信息。如果第二次编辑JSON时不小心修改了flags,可能会把标签名污染。我的建议是不要使用flags,只使用shapes里的label字段。
6.2 无人机采集过程容易踩的坑
有人会想当然认为无人机采集就是去河边飞一圈,实际上现场的问题比想象中琐碎。水面对气流很敏感,低空飞掠时,无人机旋翼下洗气流会直接吹皱水面,产生原本不存在的波纹。我试过一次低空5米悬停,下方水面被螺旋桨气流打出一片密集波纹,模型学到的特征和自然水面完全不同。解决方案是至少保持30米以上飞行高度,或者选择顺风方向拍摄,让气流带走的扰动落到下游。
另一个问题是镜面反光。水面的镜面反射不是均匀的,太阳方向对应的高亮区和阴影区亮度差异非常大。如果只在一个方向飞,模型学到的水体颜色带会过于单一。我采用“Z字形”航线来回飞,让同一段河面分别以顺光和逆光两轮覆盖。这样模型在训练时才能见到水面在不同的光照条件下的表现。
安全方面必须多说一句。无人机飞行本身要严格遵守当地空域管理规范,不能在禁飞区、人群密集区、机场净空区飞行,高海拔山区要注意气压计失效导致的姿态漂移,水面飞行要预留返航电量。我每次作业前都会检查桨叶和电池状态,在手机里存好GPS轨迹,万一炸机还能定位打捞。这些细节和数据质量同样重要,图拍糊了可以重拍,飞机没了整个项目就停摆了。
6.3 模型训练与数据使用的注意事项
我在训练阶段反复体验到一个现象:直接用整图1920x1080的分辨率输入模型会导致显存爆炸,而简单缩放成256x256又丢掉了水流纹理细节。平衡点是让模型喂入512x512或者640x640的裁剪块,同时在全图推理时采用滑窗拼接的方式预测大图。如果不做滑窗,长条河道的边缘区域会被重复预测,边界处容易出现接缝。滑窗之间的重叠率我设为20%,预测结束之后在重叠区域采用平均概率融合,效果远好于直接取最大值。
类别标签的数值顺序也容易搞出问题。有的模型要求背景是0,有的要求背景是255,还有的模型使用黑色背景透明效果。如果不提前统一,训练时loss会在nan和乱跳之间反复徘徊。我在数据管线最开始就加入了一行断言,检查mask中最大值不超过类别总数减一,并检查每个类别在训练集里至少出现一次,从源头规避这类问题。
最后是数据扩展方向。当前的静态分割模型本质上只是看一张图的纹理,但人眼判断水流状态时还会用上动态信息,比如波纹移动速度、漩涡旋转方向、泡沫扩散轨迹。下一步可以把360张静态图扩展成短序列视频,用时序分割模型或光流特征辅助分类。训练数据永远不够,但目前这套labelme格式的360张、7类别数据集已经足够把水流识别这个方向的基线定下来。剩下的就是结合实际业务场景,持续补数据、调模型、看效果,一步一步把算法磨扎实。
在我个人看来,做这类数据项目最核心的并不是标了多少张图,而是把类别定义、标注规范、质量控制这三件事真正讲清楚。水流状态识别看起来小众,实际下游应用里无论是防汛预警还是生态评估,都特别需要这种细粒度的水面信息。后面如果有时间,我会把视频层面的动态特征分析补上,让这套数据不仅能识别“哪里是什么水”,还能判断“水流得有多急、往哪个方向走”。