做无人机遥感项目的人应该都体会过这种感觉:数据比模型更让人头疼。这个项目标题看起来很朴素——无人机视角、地理要素、labelme格式、1536张、6类别,但真正动手做过的人知道,这里面每一环都是可以讲出大量经验的地方。我最近完整跑通了从无人机影像采集到六类地物语义分割模型落地的流程,数据集正是标题里说的这套:田地、道路、森林、水体、电力线,1536张labelme格式标注图。这篇文章就把整个过程拆开讲清楚:为什么选这六个类别、标注环节怎么控质量、怎么把labelme的JSON转成训练能用的掩码图、模型怎么选怎么调参,以及最后部署到实际业务中会遇到哪些坑。
1. 先看清楚这个数据集要解决什么问题
1.1 六个类别背后的物理属性差异
航拍影像里的地物识别,跟普通自然图像分类完全不同。普通图像里“草地”“天空”“汽车”这类物体往往有明确的纹理和形状特征,但在无人机俯瞰视角下,地物的尺度、边界、光谱特性差异非常大。这套数据集里的六个类别,每一类都有自己明显的物理特性,直接决定了技术选型。
田地是最容易分割的类别,因为农田地块具有高度的纹理一致性,边界通常是田埂、沟渠或者道路边缘,在无人机正射影像里呈现规则的几何块状。但问题是,田地的光谱特征在不同季节差异巨大,水稻田有水层反射、麦田是黄色秸秆、翻耕后的裸土是深褐色,如果只靠颜色特征做分割,换一个生长周期就会崩掉。
道路是典型的线性地物,宽度从乡村土路的2米到主干公路的20米不等,材质上有水泥、沥青、碎石的区别。分割道路的难点在于遮挡——路边的树冠、车辆的阴影、路侧护栏,都会把一条连续的路打断。做道路分割时,模型必须学到“这条线虽然被挡了一部分,但它应该继续保持连通”的语义。
森林的特征是强纹理和阴影混合。树冠在航拍下有密集的颗粒状纹理,但树与树之间的阴影会形成暗色区域,与水体在颜色上有时候非常接近。我见过不少模型把大片深色树冠阴影误判成水体,就是因为没有利用纹理信息。
水体是六个类别里“看起来最简单但实际上最容易翻车”的。清澈水体呈现均匀的深蓝色调,边界清晰,但水面上有反光时会出现大面积的亮斑,干扰模型判断;此外水田灌水期跟湖泊水体的光谱特征接近,如果不加约束,模型会把水田也算成水体。
电力线,这是全项目最特殊的类别。它属于典型的小目标线性地物,在1536×1536像素的大图上只占几个像素宽度,形状是长直线或轻微弧线,背景是农田、道路、水面的混合。电力线分割本质上更接近一种特殊的边缘检测任务,它在语义分割里往往会吃掉最多的训练资源和调参成本。
1.2 为什么一定要做像素级分割而不是矩形框
这是我在项目初期就跟团队反复确认过的问题。有人提议,既然已经有六个类别,快速标注一批检测框,训练一个目标检测模型不就行了?对电力线这种细长目标,检测框确实能给出大致位置,但对田地、道路、森林、水体这种覆盖大片区域的地物,检测框会引入大量背景噪点,导致框内混入其他类别。服务端要做的是计算各要素精确面积占比、生成矢量范围、提取电力线走廊净空分析,这些业务都依赖像素级边界,而不是粗略的矩形框。
所以目标定位在这个项目里明确为语义分割:每个像素对应一个类别标签。这也是labelme格式能派上用场的原因,labelme的核心标注结构是polygon多边形,人工用点勾勒出每个地物的边界,保存为JSON文件,之后转成逐像素的标签掩码图,喂给分割模型。
1.3 1536张这个规模到底够不够
任何做过深度学习项目的人看到1536这个数字,第一反应都是:太少了。城市街景分割数据集动辄上万张,遥感分割数据集也常见几千上万张。但我要说,1536张对于六类地物分割来说,是一个“够用但必须精打细算”的规模。关键不在于总量,而在于每一类的像素覆盖是否均衡。
我统计过这套数据集中各类别像素占比:田地约34%、森林约26%、水体约15%、道路约14%、电力线约3%,其余为背景或其他未标注区域。电力线的像素占比极低,这是一个天然的长尾问题。如果直接拿原始分布去训练,模型会严重偏向压制电力线类别。所以在数据准备阶段就设计了类别均衡策略,后面会详细展开。
从经验角度看,1536张图如果全部参与训练,配合针对性增强,已经可以在六类分割上达到业务可用的精度。但前提是标注质量过硬,且训练策略干净,不能浪费任何一张标注。
2. labelme标注里的真实成本与质量管控
2.1 标注工具与格式选型
地理要素分割的标注工具,市面上能选的主要是labelme、labelImg的polygon模式、SuperAnnotate、CVAT。这个项目选择labelme,理由非常务实:完全离线部署,不需要搭建服务器,Python环境直接运行,标注结果就是标准的GeoJSON风格JSON,每个文件对应一张图片,没有额外数据库依赖。CVAT功能更强但部署成本高,SuperAnnotate是商业平台的试用限制比较多,而labelme在Linux和Windows下都能跑,小白上手成本最低。
安装方式很简单,一条命令就能完成基础环境准备。
pip install labelme启动后,用命令行打开标注工具,打开图片目录开始逐张标注。
2.2 每一类怎么勾画才不返工
标注六个类别的时候,我总结出了一套可复用的操作规范,每一条都是被返工教训换来的。
田地:建议用较大的步长勾点,田块边界基本是直线或大弧度,4到6个点就能勾勒完整块田。如果你一格一格地密集取点,放大到像素级会发现边界抖动很大,后期转掩码时会出现锯齿状边缘,影响模型学习。田块之间有共用边界时,先勾面积大的那块,再用它作为参考勾相邻地块,能减少空隙。
道路:按车道中心线勾一条带状多边形,宽度尽量保持贴近实际路面边缘。道路被树冠遮挡时,不要直接断开,要用直线或顺势连接的曲线把被遮部分“补上”。模型训练时看到的是完整道路语义,这比留下断点要更利于恢复连通性。
森林:树冠边界非常毛糙,但标注时不需要逐棵树勾边,围着树冠群的最外沿勾一圈就好。内部出现的小块空地可以单独勾出来标为田地或其他,但不要在一个森林多边形里挖空洞。如果某个林区内部是小树、灌丛与草地的混合,统一按森林处理,保持标准一致。
水体:水岸线一定要贴着实际水边勾,枯水期露出的泥滩、沙滩不算水体。水面上有荷叶、浮萍覆盖时,只要水面连续可见,就依然整体标为水体;如果浮萍密集到完全看不到水面,建议单独标为森林或作物,这个要在标注规范里提前定义,不然后期一致性检查会炸。
电力线:这是最需要耐心的类别。单条电力线在画面上只有一两个像素宽,直接勾线会非常吃力。我的做法是把图像视图缩放到200%以上,以塔基或电杆为端点,沿导线走向点出一串密集点,让多边形包裹住整条线。绝缘子、防震锤这些金具也属于电力线的一部分,因为它们的目标识别价值高,不能丢。
2.3 质量检查机制
标注完成后必须做三轮检查。第一轮是标注者自查,重点是看有没有漏标的小块地物,比如田埂边的水塘、道路交叉口的环岛。第二轮交叉检查,让另一位标注者随机抽检20%的图,重点核对电力线走向和道路连通性,这类线性目标的断裂是最高频错误。第三轮算法辅助检查,把所有JSON转成棕色的掩码图后,叠加在原图上,检查每个类别的边缘误差是否在合理范围内。
我强烈建议在项目里强制做第三轮,因为你肉眼看一张RGB图很难发现单个像素的偏移,但把掩码图以50%透明度叠加在原图后,边缘是否贴合水面岸线、道路是否穿过了建筑物,全部一目了然。
2.4 标注量级:1536张标注的真实工作量
这可能是很多新人最没概念的部分。我按每张图平均标注要素数量统计过:在无人机正射影像上,一张1536×1536的图通常包含12到25个标注要素,其中电力线平均每张图只出现2到3段,而田地道路森林水体多的情况下一张图有几十个要素。一个熟练标注员每天能完成30到40张图的完整标注和质量自查,1536张需要大约6到8人周的工作量。如果包含三轮质检和反复修改,实际成本要按10人周甚至更多来估。这部分人力成本往往比标注说明书写的要多一倍,做项目排期时一定要留足缓冲。
3. 从labelme到训练管线:转换脚本与数据切分
3.1 JSON转PNG掩码的具体实现
labelme保存的JSON结构,本质上是每个标注要素的多边形坐标列表。训练语义分割模型需要的是逐像素类别图,即一张与原始影像尺寸一致的单通道掩码图,像素值就是类别索引号。因为原始影像存在同名多次裁剪的情况,JSON转掩码必须严格保证原图尺寸和目标尺寸一致,否则会出现常见的“标注偏移”问题。
转换脚本的核心逻辑不复杂,但有三个细节必须注意。其一,在逐多边形填充掩码前,要建立大小与图像一致的零矩阵,用OpenCV的fillPoly函数填充每个多边形的类别索引。其二,如果同一个像素被两个不同类别的多边形重复覆盖,需要规定处理顺序,一般按标注列表顺序后写覆盖先写,同时把这类冲突输出成日志,人工确认。其三,JSON里可能出现空多边形或坐标点少于三个的无效标注,这类异常要直接跳过并记录,防止fillPoly报错或者生成脏掩码。
下面是转换脚本里最关键的部分。
import json import cv2 import numpy as np from pathlib import Path # 类别名称到索引的映射 CLASS_MAPPING = { "field": 1, # 田地 "road": 2, # 道路 "forest": 3, # 森林 "water": 4, # 水体 "powerline": 5, # 电力线 } def json_to_mask(json_path, image_shape, mapping=CLASS_MAPPING): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) mask = np.zeros((image_shape[0], image_shape[1]), dtype=np.uint8) conflict_pixels = 0 for shape in data["shapes"]: label = shape["label"] if label not in mapping: continue cls_idx = mapping[label] points = np.array(shape["points"], dtype=np.int32) if len(points) < 3: print(f"[警告] 无效多边形: {json_path}, label={label}") continue # 用多边形填充掩码 cv2.fillPoly(mask, [points], cls_idx) # 记录写入的类别数量,用于后续统计每一类的像素占比 return mask实际转换的时候还要加一个逻辑:统计每个类别在当前图里的像素数量,汇总后用于后续做类别权重计算和长尾分析。这个统计在项目前期非常关键,它会直接告诉你电力线到底稀缺到什么程度。
3.2 类别索引与色板设计
这里有一个容易被忽略的细节:训练时掩码图的像素值必须等于类别索引,但可视化查看时需要给每个索引分配一个直观的颜色。语义分割模型的交叉熵损失计算的是索引值,不是RGB颜色值,所以转换时一定要保证掩码是单通道灰度图,而不是三通道彩色图。很多新手在这一步直接把labelme的预览图拿来当标签,结果模型怎么训都收不收敛。
通用的色板方案如下。
| 类别 | 索引 | 可视化颜色(RGB) | 实际用途 |
|---|---|---|---|
| 背景 | 0 | 0, 0, 0 | 未标注/忽略区域 |
| 田地 | 1 | 128, 128, 0 | 农田、耕地、果园 |
| 道路 | 2 | 128, 0, 128 | 沥青路、土路、水泥路 |
| 森林 | 3 | 0, 128, 128 | 乔木林、灌丛、密集绿化 |
| 水体 | 4 | 0, 0, 128 | 河流、湖泊、干渠 |
| 电力线 | 5 | 128, 128, 128 | 导线、绝缘子、电杆附属物 |
色板设计的原则是相邻类别在视觉上要有明显区分,这样在检查掩码和原图叠加时,可以快速发现边界错位。
3.3 训练/验证/测试切分原则
1536张图不能在数据集级别直接乱序切分。无人机航拍地理要素有一个特点:同一架次、同一航线拍摄的相邻图之间有高度重叠。如果训练集、验证集、测试集直接随机切分,重叠区域会泄漏,导致验证分数虚高、测试分数失真。正确做法是从航线块或地理网格级别切分。
我把1536张图按采集航带分成了若干组,确保同一航带的图像全部落入同一数据分片,然后以组为单位切分。最终的划分比例是训练集1180张、验证集180张、测试集176张,比例大约是77%、12%、11%。这个比例下,验证集足够稳定评估mIoU,测试集保留了一些与训练完全不重叠的地理区域,其实测评估结果能反映模型在陌生区域的泛化表现。
3.4 数据增强与扩充
增强策略要针对这个数据的特殊结构来设计。日常用的随机翻转和随机裁剪必须保留,但对电力线这种小目标类别,普通裁剪会让电力线频繁出现在图像边缘甚至被切出画面,反而不利于学习。我采用的方案是:训练时在整图级别做随机翻转和少量旋转,用多尺度缩放采样训练块,裁剪尺寸设为512×512或640×640。电力线类别的裁剪策略是“有电力线优先”——每轮训练里,所有包含电力线的图块先参与训练,再补充不含电力线的图块,这样能让电力线样本的采样频率提高大约3倍。
另外还尝试了Copy-Paste增强:从一个图中裁剪出电力线区域,随机粘贴到另一张图的合理位置,比如从道路边缘或田埂上延伸过去,模拟真实线路走向。实测这个增强对电力线的mIoU提升在2%左右,代价是偶尔会出现不自然的弧线折角,需要控制粘贴后的形状平滑。
4. 模型选择与训练配置
4.1 语义分割模型怎么选
地物分割的模型选型,我见过很多人一上来就追最新的Transformer结构,但效果反而不稳定。务实一点说,对于六类地物分割任务,当前成熟可靠的方案集中在两类骨架里:一类是ResNet或ConvNeXt配合UPerNet、DeepLabV3+这类经典分割头;另一类是SegFormer这类纯Transformer结构。我最终选择的主模型是UPerNet,骨干网络用ConvNeXt-Base,输入分辨率640×640。选的逻辑有两条:UPerNet在场景解析数据集上有长期验证的稳定性;ConvNeXt对航拍图像中的多尺寸地物特征提取效果好于传统ResNet,尤其在纹理丰富的森林区域表现更强。如果显存预算不足,把骨干换成ResNet50作为降级方案,训练跟推理速度都快很多,精度也不会崩得太厉害。
这个任务的评估指标采用mIoU(平均交并比)和mAcc(平均像素精度)。mIoU的计算方式是每个类别预测与真实标签的交集除以并集,再对所有类别平均,它对类别不均衡更敏感,比单一全局准确率更能反映真实分割效果。
4.2 完整训练配置参考
因为多类别的像素分布极度不均,损失函数和类别权重需要单独设计。标准交叉熵会被29%的高频类别主导,需要引入带权重的交叉熵损失。
权重我按逆像素频率做了平滑,基础公式是每个类别的权重等于总像素除以该类像素数的对数,然后整体缩放到中位类别权重为1。电力线的类别权重按这个公式通常要吃到8到12倍于田地的权重,这个比例在实验里是有效的。
具体的训练配置参数给出一个可以直接套用的模板。
# 以MMSegmentation为例的配置片段 model = dict( type="EncoderDecoder", backbone=dict( type="ConvNeXt", arch="base", out_indices=[0, 1, 2, 3], drop_path_rate=0.4, ), neck=dict( type="FPN", in_channels=[128, 256, 512, 1024], out_channels=256, ), decode_head=dict( type="UPerHead", in_channels=[128, 256, 512, 1024], in_index=[0, 1, 2, 3], channels=512, num_classes=6, loss_decode=dict( type="CrossEntropyLoss", use_sigmoid=False, class_weight=[0.08, 1.0, 1.0, 1.0, 1.0, 2.5], ), ), )训练过程采用AdamW优化器,初始学习率设置为1e-4,配合线性预热和Poly学习率衰减。Batch size受显存限制,设定为8,所以同步BN的数量较少,模型训练初期收敛速度偏慢,需要在检查点保存策略上耐心一些。训练轮数设置在120到160轮之间,如果误差曲线在验证集上趋于平稳,可以提前停止。硬件方面一块24GB显存GPU就跑得动,ConvNeXt-Base在640分辨率下不能把batch放到16,只能折中;如果换成ResNet50骨干,batch可以提到16到32,训练速度快将近一半。
4.3 关键训练技巧
第一个技巧是在训练前先做Miccai等大尺度语义分割预训练权重的加载。由于航拍图和街景图像分布差异比较大,直接全部微调预训练权重可能效果一般,需要冻结骨干网络前两个Stage,只训练后面的模块,等损失降到一定程度再解冻全部层。这是一个可操作的稳定化策略。
第二个技巧是验证时用多尺度推理加平均:推理时对输入图做0.8倍、1.0倍、1.2倍三种尺度缩放,分别预测,再把三个概率图取平均后求argmax。这个操作对边界质量和电力线细线还原有明显改善,但推理时间约增加2倍,适合离线业务。
第三个技巧是电力线专用后处理。原始模型直接输出的电力线掩码会有大量断点和碎片,我在后处理阶段增加了形态学闭运算,核大小为3×3,再配合一个简单的连通域分析,过滤掉像素数量小于阈值20的孤立小团块,只保留较直的带状连通域。这样处理之后,电力线分割结果的连续性和视觉可用度大幅提升。
5. 常见问题与工程落地
5.1 标注和转换阶段的坑
标注阶段最常见的异常是JSON文件里出现非法多边形。labelme偶尔会保存无法闭合的路径或重复点,这类文件在转换时会静默出错。我建议在数据管线入口加一个自动校验器,扫描所有JSON文件,检查每个多边形是否包含至少三个有效点,并即时输出错误清单。
另一个高频问题是类别标签名不统一。有的标注员写了“field”,有的写了“田地”,还有的写成“农田”,这会导致类别映射表匹配不上。最好的做法是让labelme加载预定义标签列表,标注员只能从列表中选择,从源头杜绝名称不一致。
转换阶段我踩过另一个坑:原图尺寸与掩码尺寸不一致。某一些图像在预处理时做过重压缩,分辨率变了,但JSON里的多边形坐标还是基于旧分辨率记录的,转换出来的掩码与图像完全错位。解决方式是在转换前强制读取原图的真实宽高,用宽高对坐标做等比缩放。
建议读者用下面这个清单做转换前的快速体检:
- 所有JSON文件能否被正常解析
- 每个JSON里是否包含至少一个有效标注要素
- 标注要素类别是否都在映射表中
- 多边形坐标是否在图像宽高范围内
- 是否存在两个多边形大面积重叠但类别不同
- 转换后的掩码大小是否与原图一致
5.2 训练阶段的坑
训练阶段最容易遇到的是电力线类别完全训不出来。我在项目初期就碰到过这种状况:mIoU整体在85%左右,但电力线的IoU只有不足三成,肉眼可见完全喷碎。后来定位在三个问题上:一是电力线被下采样搞丢了,640分辨率下电力线太细,特征在多层下采样后消失;二是损失函数里电力线权重虽然加了但还不够;三是数据增强的随机裁剪几乎不保留电力线。
解决方案是综合的。训练图块的输入尺寸从640调到768,对电力线信息保留有正向帮助;损失权重从默认值逐步提升尽量到5倍甚至更高;增强策略改成上文提到的电力线优先裁剪,同时加了一个约束,让裁剪区域中心总是偏向电力线所在位置而非随机分布。调整过后电力线的IoU稳定提升到了62%以上,虽然和普通地物类别比还是偏低,但已经能用于生产。
还有一个容易让人迷惑的问题,训练精度很高但验证精度很低。这种情况大概率是数据泄漏或者模型过拟合,需要先回到数据切分章节,确认训练和验证集没有重叠区域。
5.3 部署阶段的经验
模型训练完之后,真正上业务前需要先做模型转换和剪枝。推荐先用ONNX导出,再用ONNX Runtime做推理。以UPerNet+ConvNeXt-Base结构为例,单帧1536×1536推理时间在GPU上约300到400毫秒,CPU上则要3秒以上,对实时业务来说压力还是很大的。可以考虑使用TensorRT INT8量化,代价是有1%左右的精度损失,但推理延迟可以降到70毫秒左右。如果是做远期部署在NVIDIA Jetson系列板卡上的方案,INT8量化几乎是必须的配置。
部署中另一个很实际的业务问题是电力线像素的骨架提取。原生产系统需要电力线走向的矢量线,而不是密集像素掩码。我在后处理里加了骨架提取步骤,即对电力线掩码做图像细化,再用折线拟合方式把细化的骨架转成带有坐标点的线矢量。这一步可以在推理后实时完成,主要调用OpenCV的连通域分析和拟合算法。
我在实际项目中的体会是,地理要素分割真正困难的地方不是训练模型,而是把标注、转换、训练、部署整条链路跑通。labelme格式的1536张数据集看起来不起眼,但它足够把这条链路完整走一遍。尤其是电力线类别,从标注时的高倍放大勾线,到训练时的优先采样和权重配平,再到部署时的细化与矢量提取,每一个环节都在跟“小目标+长尾”这个本质问题对抗。最后跟大家分享一个小技巧:做完第一版模型后,把预测不好的图收集起来,优先补标这些场景,迭代效率比盲目增加新图高得多。数据集的规模永远不嫌多,但最大化的价值在于让每一张标注都能精准解决模型当前最痛的问题。