
如果让我用一句话形容做无人机视角地物分割数据集的感受我会说公开数据集永远差一口气。过去半年我一直泡在低空无人机影像的语义分割数据整理里最后沉淀下来的成果是1536张、6个类别、labelme格式的一套遥感影像分割数据覆盖田地、道路、森林、水体、电力线这些常见地理要素。这套数据没有什么花哨包装就是一批一批地飞、一块一块地标、一遍一遍地洗最后把采集、标注、质检、格式转换的完整链路走通。你可能会问遥感公开数据集一大堆卫星影像分割训练集也不难找为什么还要自己从头做答案其实藏在“无人机视角”这四个字里。大多数公开遥感数据的视角、分辨率、类别设计都和低空无人机实际拍到的画面存在明显落差真正跑到模型上效果总是差了一层。所以我想把这次从选图到成片的完整过程写下来给同样被地物分割数据困扰的人一个可以照着做的参考。1. 从“用不了”到“自己标”无人机视角地物分割缺的不只是图1.1 模型迁移为什么会在低空影像上“水土不服”先说分布偏移。卫星影像通常在几百公里高度拍摄接近正射视角阳光阴影很少地物边缘规整像元分辨率从亚米到几十米。无人机航拍常见飞行高度是30到120米拍出来的地面分辨率能达到厘米级视角还有一定倾斜角同一个东西在两张影像上的纹理、尺度、阴影分布完全不一样。语义分割又是一项“像素级”任务模型会抓住很多高频纹理细节来区分类别训练数据的尺度纹理一旦和目标场景不匹配精度掉得会比目标检测更明显。这不是算法本身的错而是数据分布gap在作怪。另一个更隐蔽的问题是类别体系不匹配。卫星数据集通常把水体定义成宽阔河流、湖泊道路定义成主干道或街区路网但无人机巡飞经常遇到的是一条两米宽的水渠、一段林间土路、一小片菜地。这些对象的边界在影像上并不清楚很容易和旁边的裸地、灌草混在一起。如果训练数据里压根没有这类样本模型再怎么调参也补不回来。所以与其说是缺图不如说是缺一套符合低空影像特点、面向地理要素作业的类别定义和标注约定。1.2 六类地物清单和实际业务需求的对应这套数据集最终定下的显式类别是田地、道路、森林、水体、电力线之外的像素统一归为背景合计六类。选择这五个类别是围绕无人机在地理信息采集里最常见的需求来的农田调查关心田块边界和作物分布交通调查关心道路走向和通达情况林业巡查关心林地和林间空地水资源监测关心水域变化电力巡检关心线路走廊和树线矛盾。一个类别清单同时覆盖“面状地物”和“线状地物”才有复用价值。类别ID类别名典型对象低空影像下的标注难点0背景建筑、车辆、栅栏、裸地等占比高易与田地、道路混淆1田地耕地、田块、大棚纹理随作物类型和季节变化大2道路硬化路、机耕路、土路边界不清晰与裸地过渡自然3森林连片乔木林地树冠阴影会分割树冠轮廓4水体河流、湖泊、水塘、水渠小水体容易被树冠或岸草遮挡5电力线导线、绝缘子串、杆塔细长目标像素占比极低这个ID顺序在转mask阶段最为顺手后面我会再讲怎么用。需要提前说明的是无人机影像下这些类别绝不是等比例出现的背景像素通常占一半以上电力线往往不到1%。类别不平衡会贯穿整个数据制作和训练过程相当于从一开始就埋下了伏笔。2. 六个类别的语义边界标注公约比工具更重要2.1 背景类第六类不是“不想标的都塞进去”背景类看起来最简单其实是这套数据里最容易标错的类。语义分割要覆盖图像里每一个像素没有背景类模型就不知道哪些东西是真的需要忽略、哪些只是没标到。常见做法是把所有不属于五个显式类别的对象都扔进背景但直接这样操作背景类会变成一个装着各种纹理的大杂烩里面既有建筑、车辆又有阴影、裸地和无法判读的模糊区域。模型学到的是“除了前五类都是背景”这种非常粗的分类容易在背景和显式类别交界处抖动。我采用的约定是面积足够小、语义上不重要的对象可以不单独标注默认算背景但如果是建筑、车辆这类视觉干扰明显、还经常和电力线杆塔混在一起的目标会单独用shape把轮廓圈出来只是label仍然写成background。这样做的原因很简单宁可让背景类多一些显式的负样本也不要让模型把杆塔误判成道路或者把车辆误判成水体。给负样本补上轮廓在后续训练里会让边界更干净背景区域也不是一锅粥。2.2 五种显式类别最容易扯皮的边界五个显式类别里边界争议最大的其实不是电力线而是道路、田地、森林、水体之间的几种组合。我把一轮标注中反复出现的争议整理成了几条硬规则道路与田地硬化路边缘往往有一圈土坎、杂草既不是路面也不是田地。约定是路面结构含路肩算道路土坎和路旁杂草算背景如果土坎宽度太窄很难分开就按可见的路面边界走。水体与湿地水渠旁的湿润土壤、季节性水塘在影像上很容易和水体混淆。约定是只有标注时能看到明显水面或明确水体边界才标水体湿泥、沼泽、水草覆盖区域一律归背景或田地避免同一类别跨多个视觉状态。森林与灌草稀疏林地、灌木丛、竹林经常处在线索模糊的地带。约定是有连片树冠结构才算森林低矮灌草、芦苇、高杆草丛不算森林这样森林类能保持相对一致的纹理统计特性。电力线导线、绝缘子串、耐张线夹这些线路金具纳入电力线杆塔本体也纳入电力线因为巡检场景最关心的是塔-线关系。但电线在地面的投影阴影不要标阴影不是对象本身。跨图对象同一根电力线、同一条道路被切断到相邻图片时按当前图片中完整可见的部分标不要跨图强行拼接语义。这些规则看起来繁琐但对训练很关键。同一个语义类如果混入两种完全不同的视觉状态模型会无所适从宁可把边界模糊的小对象牺牲掉归背景也不能让它在类别之间随机横跳。每一条规则都要写进标注文档并且配至少三张示例图从第一天就定稿后期改动代价很大。2.3 标注流水线初标、复检、改标三遍出活标注流程我用的是一个比较传统的流水线适合两三个人小团队原始影像、人工初筛、切分到可标注尺寸、初标、自检、交叉复检、修改、抽检通过后进入数据集。标注工具本身没有太多可说的labelme这类多边形标注工具足够用真正影响出图质量的是环节之间的衔接。初标完成之后我会让另一个人做交叉复检重点不是看“标得对不对”而是沿着边界和电力线逐张看。多人协作时最容易出现的问题是两人对同一条土路、同一块湿地的判断不同我们自己统计下来交叉复检发现的绝大部分问题都来自边界归属而不是来自多边形画得歪。所以后来我把常见争议场景做成了一个标准图库每个场景下面写清楚该归哪一类新人来了先看图库再动手同一轮标注尽量安排同一个人负责同一个地理区域风格会稳定很多。3. labelme格式解析json字段、版本坑和自检脚本3.1 一个labelme json文件里到底装了什么打开任意一份labelme标注文件核心结构大致长这样{ version: 4.6.0, flags: {}, shapes: [ { label: road, points: [[1234.5, 678.2], [1300.1, 700.9], [1280.4, 750.0]], group_id: null, shape_type: polygon, flags: {} } ], imagePath: DJI_0001.jpg, imageData: null, imageHeight: 4000, imageWidth: 6000 }shapes数组里每一个元素就是图上的一个标注对象label字段是类别名points是多边形顶点的像素坐标shape_type统一为polygon。这里有两个非常关键的细节第一coordinates是相对于原图的绝对像素坐标不是归一化坐标也不是经纬度切patch和缩放时要自己重新计算第二imageData字段如果被labelme保存成base64json会变成几十上百兆所以我导出后会把imageData清空只保留imagePath让json和同名图像放在同一个目录里。1536张影像对应1536个json文件文件名保持和原图一致这就是labelme目录组织最朴素也最稳妥的做法。另外类别ID并不体现在json里json里只有label字符串具体哪个label对应哪个整数ID完全由转换脚本里的映射表决定。这也是很多第一次用labelme的人会踩的坑。label是字符串mask要的是整数这一步没统一后面训练时类别就乱套了。3.2 三个最常见的格式坑第一个坑是版本字段差异。labelme版本变化时json字段不完全一样老版本可能缺少imageWidth和imageHeight新版本默认把imageData写成null。写读取脚本时要用data.get()这类方式兜底别直接按固定字段索引否则换一台机器、换一个版本导出的数据脚本可能直接崩。第二个坑是浮点坐标和过密顶点。手动绘制时坐标经常带小数画复杂边界时一个对象可能堆了几百个点。转polygon填充时直接四舍五入通常没问题但顶点过密会让json体积暴涨也会让后续裁剪、缩放变得很慢。可以在标注阶段让标注员尽量用直线段概括边界只在弧线和锯齿边缘处加密点一个对象几十个顶点足够表达绝大多数地物轮廓。第三个坑是imagePath丢失。labelme保存的imagePath默认是相对路径换机器或者移动目录后容易失效。批量复制时要统一检查和修正imagePath否则自动化流程读到json却找不到原图后续转换脚本跑一半才发现问题定位成本很高。3.3 用脚本给1536份json做体检数据集的制作过程中体检脚本越早写越好。下面这个脚本不复杂但能在批量文件上找出大多数低级问题import json from pathlib import Path root Path(labels) allowed_labels {background, field, road, forest, water, powerline} for p in root.glob(*.json): with open(p, encodingutf-8) as f: data json.load(f) w data.get(imageWidth) h data.get(imageHeight) if not data.get(shapes): print(f{p.name}: empty shapes) for shape in data[shapes]: label shape.get(label) pts shape.get(points, []) if label not in allowed_labels: print(f{p.name}: unknown label {label}) if len(pts) 3: print(f{p.name}: {label} has too few points) if w and h: for x, y in pts: if not (0 x w and 0 y h): print(f{p.name}: {label} point out of range)我在1536个json上跑了几轮最常见的问题是三类少数shape的顶点坐标超出图像边界画图时多拖了一下空shapes文件标注到一半保存的残次品label字段拼写不一致比如road和roads同时存在。这些问题如果不提前处理到转换mask的时候才报错定位成本会高很多。数据体检属于那种看起来不起眼、实际能帮你省下大量返工时间的步骤。4. 1536张的来历样本分布、数据清洗与划分策略4.1 这个数量是怎么权衡出来的1536不是随手拍出来的数字它是场景覆盖需求、标注人力和影像来源三者折中后的结果。低空航拍一张6000×4000左右的影像地物复杂度高的图光标多边形就要一两个小时一个熟练标注员满负荷一天能处理8到10张如果要覆盖平原农田、丘陵林地、河网地带三种典型地貌每种地貌给500张左右加起来大约就是1500张的量级。1536这个数正好落在“再多就会拖垮标注周期再少就缺样本多样性”的平衡点上。采集时还有一个容易被忽略的点同一架次连续拍摄的画面大量重叠相邻帧看起来几乎一样这种影像对模型训练没有增量信息。我的做法是按航线间隔抽帧优先保留不同高度、不同角度、不同光照条件下的画面让1536张里的场景尽量分散。数据集的容量不是靠重复堆出来的靠的是“每一张都有新东西”。4.2 训练、验证、测试的划分不能拍脑袋数据划分看起来只是比例问题实际上对评估结果影响巨大。如果直接按文件随机划分同一航带的相邻影像很容易被同时分进训练集和验证集验证指标会虚高真正面对一个新区域时模型的泛化能力并没有数据划分显示得那么好。正确做法是先按来源航次或地理条带分组再把整个组放进训练集或验证集模拟“模型没见过这片区域”的真实情况。我的划分习惯是训练集占70%、验证集15%、测试集15%并且按类别覆盖做一层抽样。先确保验证集和测试集里每一类都有足够像素再调整具体文件。对电力线这种小目标来说如果验证集里刚好只有一两张含有电力线指标波动会非常大无法反映真实水平这个细节在做线状地物标注时要特别留意。4.3 最后被清洗掉的影像正式发布前我又从初选影像里扔掉了一部分。被清洗的主要是三类一是运动模糊到看不清地物边界的二是大面积雾气或逆光导致画面信息严重丢失的三是拼接畸变让道路和电力线明显变弯的。这些图保留下来只会干扰模型因为模型会试图从模糊纹理里学习根本不存在的类别特征。另外如果在交叉复检时发现两个标注员对某一类边界争执不下、而且找不到明确裁决规则我会选择把这部分对象降级成背景而不是继续争论。数据集里偶尔少一个对象没关系但一个标签的语义不一致会让整类目标的学习信号被污染。标注这件事宁可钝一点不能乱一点。5. 从几何标注到模型输入转换、切片与增强的工程细节5.1 多边形转类别掩码的核心流程labelme保存的是几何信息模型要的是像素掩码中间的转换环节非常有讲究。我常用的流程是四步第一步读json拿到imageWidth和imageHeight创建一张全零的二维数组当作初始mask0就是背景类第二步把label字符串映射成类别ID第三步用图像库的填充多边形函数把每个shape的顶点连成闭合区域写入对应的类别ID第四步把mask保存成PNG文件并保留一份带颜色映射的PNG用于人工检查。这里有几个容易踩的细节同一个类别如果有多个多边形互相覆盖没有关系最怕的是两个不同类别重叠到同一个像素通常是标注员画多边形时多画了一小截。我在转换后的第一轮检查里专门统计了重叠像素发现数量不少于是回去改了标注规则相邻对象可以贴近但不能重叠。另外不要用matplotlib的savefig直接保存mask它会把灰度数组重映射成别的颜色类别ID就变了。要用能原样保存像素值的图像库保存后再读取mask统计一下各类别ID是否存在和标注统计做个比对转换结果是否可靠心里才有底。5.2 切patch时如何保证图像和mask不“走样”原图6000×4000直接扔进分割网络显存上不支持常规做法是先切成512×512或640×640的patch。如果数据准备阶段已经生成了整图mask切片就很简单图像和mask按照同样的窗口裁切保存成一对数据。这里最需要注意的是patch编号的一致性图像叫0001_0_0.jpgmask就叫0001_0_0.png顺序错一张训练时就全乱了。切片时还要考虑目标完整性尤其是电力线。线状目标横跨多个切片后每个patch里往往只剩下一个短线段上下文信息不足模型容易漏检。我在切片时用了25%的重叠让同一根电力线至少在某些patch里保留下相对完整的走向推理阶段也可以用相同窗口做预测再把重叠区域的预测结果按投票或平均融合能明显减少断线。增强阶段同样有讲究。如果只对图像做旋转、翻转mask必须用同一组参数变换。旋转角度我建议只使用90度的整数倍避免任意角度旋转带来的插值误差缩放和颜色抖动只在图像上做mask做最近邻插值不能用双线性插值否则类别ID会被插成中间值生成不存在的类别编号。这个坑看起来小一旦发生模型loss会莫名其妙跳变排查起来很浪费时间。5.3 这套数据在分割训练中的实测表现与瓶颈这套数据在语义分割模型上训练不同类别的表现差距很大。区域型类别比如田地、森林、水体模型的IoU一般能推到0.7以上道路因为连通性和边界问题会略低一些电力线则是重灾区像素占比经常不到1%即使给了类别加权IoU能到0.3到0.5就已经不错而且表现不够稳定。这不是数据标得不行而是线状小目标在纯语义分割里的固有难点。缓解手段有几个方向一是训练时做难例采样让包含电力线的切块被更大概率看到二是用Focal loss这类能调节难易样本权重的损失函数三是把电力线分割当独立二分类任务而不是和五个大类混在一起。模型改进之外后处理同样重要分割结果的电力线掩码可以先做形态学闭运算、再提取骨架把断线连接起来最后转成矢量线。对巡检业务来说矢量线比像素掩码好用得多。6. 复盘与延伸如果重做一遍我会改什么6.1 数据集制作过程中我最后悔没早做的事做完整个数据集体会最深的是“流程早一点自动化能省大量时间”。如果重新做这个项目我会在第一天就写好转换脚本而不是标到一半才开始写中途发现浮点坐标、重叠mask和label拼写问题再回头去改一百多个文件非常消耗判断力。标准图库也应该先做前面几百张图如果因为规则不清晰推倒重来损失的不只是时间还有标注员对规则的信任。回看整个制作流程还有几件事值得记下来每张影像来自哪个架次、哪个区域一定要从第一天就记录后面对数据划分和复盘都有大用体检脚本要定期跑而不是全部标完最后统一跑同一轮标注尽量安排同一个标注员负责同一地理区域风格一致性会好很多。这些都是花小钱办大事的细节。6.2 后续值得扩展的三个方向这套数据集后续还有很多方向可以延伸。目前样本以晴朗白天为主阴天、逆光、低照度下的影像比较少如果目标是全天候监测需要补充这些困难场景否则模型在极端天气下表现会明显退化。另外多光谱相机的波段信息对水体、植被和输电线路的区分度比可见光更高如果在同一批位置上再采集一版多光谱数据模型精度还有空间。再往后就是从分割结果转矢量化配合骨架提取和断线连接把电力线从像素结果变成可用的矢量图层对地理要素业务来说这一步才是真正的闭环。最后说一点个人体会吧。1536张、6类、labelme格式如果只看数字可能会觉得工作量不算特别夸张但真正做下来才发现每一张图背后都是在和“这个到底算不算那一个类”较劲。数据和模型同样值得花力气一套语义边界清楚、分布真实、可复用的数据集带来的价值往往比换一个更复杂的网络结构还要大。这篇记录如果能让准备做类似数据的人少走几步弯路就是它最大的价值了。