
做语义分割项目被卡在数据准备环节是件挺让人抓狂的事。最近好几个做遥感解译的朋友都问过同一个问题labelme标注完的JSON文件怎么批量变成模型训练能用的PNG掩码图这问题听起来简单真做起来却坑不少——坐标怎么取、类别值怎么定、多边形重叠怎么办、几百个文件怎么一次性跑完。这篇文章就把我在这套流程里攒下的经验完整写出来。内容覆盖道路提取、水体提取、地表覆盖、土地利用这些常见的遥感分类场景凡是需要把labelme多边形标注转成语义分割标签的都可以直接照着做。1. 项目整体设计与思路拆解1.1 核心需求JSON标签到模型训练掩码之间的距离很多刚接触语义分割的人会有一个误解标注完图数据集就算准备好了。其实还差关键一步——把标注结果转换成模型能读的格式。以道路提取为例你的原始输入是一张遥感影像尺寸可能是1024x1024或者2048x2048三通道RGB。模型训练时需要一张和原图尺寸完全一致的标签图每个像素点上要么是0非道路、要么是1道路。这类图通常叫掩码图、mask、gt或者label map格式上一般用PNG保存单通道灰度图灰度值就是类别编号。labelme保存下来的是JSON文件里面记录的是多边形顶点坐标而不是图像像素矩阵。模型训练时不会去解析JSON也不可能每个batch都现场画多边形所以必须在数据预处理阶段把JSON一次性全部转成PNG。这套流程不仅是道路提取要用。水体提取同样做二值分割水体为1、背景为0土地利用和地表覆盖则是多类别语义分割每个类别分配一个整数编号比如0背景、1道路、2建筑、3水体、4植被、5裸土。类别数变了、标注的图形数量变了但JSON到PNG的转换逻辑完全一致。1.2 技术选型为什么用labelme而不是其他标注方案老读者应该知道我做数据集标注很少依赖在线平台。原因有三个。第一遥感影像通常尺寸大、细节多。在线标注平台往往对单张图片大小有限制动辄几十MB的TIF传上去要么卡死、要么被压缩得面目全非。labelme是本地桌面工具打开多大的图都行只要机器内存扛得住。第二JSON格式完全透明。标注结果就是一组坐标点加上标签名字想怎么处理就怎么处理没有私有格式捆绑的问题。你甚至可以写脚本检查标注质量、统计每个类别的多边形数量这在批量做数据检查时特别有用。第三labelme支持自动保存、多边形节点编辑、放大缩小平滑浏览标注效率在线。尤其节点编辑这个功能标注道路这种边缘曲折的地物时简直救命。道路边界不是一条直线经常要沿着路面边缘一点点打点画完之后发现某些点位置不够准可以进入编辑模式拖动节点微调不用重画。当然labelme也不是万能的。它本身不提供协同审核、任务分配这类团队管理功能。如果你负责的是数十人同时标注的大项目还是要上专业平台。但对于个人研究者、小团队、课题组做实验数据集labelme加自写转换脚本是性价比最高的一条路。1.3 整体技术路线从原始影像到可用训练集一个完整的道路提取数据集制作流程我通常这样划分。首先是影像准备。原始大影像不能直接扔给labelme标注图太大看不清小目标标注人员容易漏标。我习惯把大影像裁剪成1024x1024或512x512的切片切片之间可以做少量重叠比如64到128像素方便目标横跨边界时能有完整上下文。然后是标注。用labelme打开切片沿道路边缘画多边形保存为同名JSON文件。这里有一个命名铁律原图叫img_001.tifJSON必须叫img_001.json最后转换出来的掩码叫img_001.png。三个文件同名字不同后缀后面做数据划分和模型读取时才不会乱套。接着就是本文的主角——批量转换脚本。遍历存放JSON的目录逐文件读取多边形坐标在空白画布上填充对应类别值输出PNG。这一步跑完之后还需要一个可视化校验环节把掩码叠加到原始影像上肉眼检查道路是否贴合、有没有漏标错标。很多初学者跳过这步直接训练结果loss死活不降最后才发现是标签错位返工成本极高。最后是数据划分和增强。按比例切出训练集、验证集、测试集做旋转翻转缩放之类的增强操作时必须保证原图和标签使用完全相同的变换参数。2. labelme标注规范与JSON格式深度解析2.1 标注前的准备工作图像切片与文件目录规划不要急着打开labelme就画。先把项目目录结构定好后面能少很多麻烦。我惯用的目录布局是这样的dataset/ ├── images/ # 原始影像切片tif或png ├── labels_json/ # labelme标注输出的json ├── masks/ # 转换后的png掩码由脚本自动生成 └── checks/ # 可视化校验图也是脚本自动生成原图和JSON分开放有两个好处。一是训练时按images和masks两个目录分别读数据路径逻辑清楚二是JSON文件里只记录文件名不记录全路径移动整个数据集目录时不会因为路径断裂而出错。切片阶段还要注意一点地理坐标系统的信息在这个流程里完全用不到。labelme操作的是普通图像坐标就是像素坐标从左往右x增大、从上往下y增大。你可能听说过地理配准、投影坐标系这些东西但在制作语义分割标签这一步它们统统不用管。等模型预测完拿到整景影像的掩码再做矢量化、GeoJSON导出那是后话。2.2 标注实操提高道路提取标签质量的四个技巧标注阶段直接决定标签质量这部分我踩过的坑最多挑四个最关键的讲。第一个技巧类别名称统一用英文小写。道路就是road水体就是water建筑就是building。一个项目里绝不能出现“road”和“Road”混用更不能出现中文名。转换脚本是按字符串匹配类别编号的拼写不一致会直接导致某个标注被丢弃。多人协作时开工前最好发一份标注规范文档把允许使用的类别名列清楚。第二个技巧背景区域不需要标注。道路提取是二值分割默认全图是背景0只需要把道路多边形标出来自动变成1。千万不要傻乎乎地把所有非道路区域都画成多边形工作量巨大不说多边形之间稍有缝隙就会在标签里留下空洞。多类别分割也一样如果类别列表是道路、建筑、水体、背景四种你只标道路、建筑、水体背景自然就是0不用主动画。第三个技巧贴合地物边界而不是贴合道路中心线。这是很多新手最容易犯的错误——把道路标成一条细线宽度远远小于真实路面。语义分割要求的是面状标签每个车道、人行道边界、交叉路口都要按可见影像边界仔细勾画。尤其是道路交叉口形状复杂宁可多打几个点也不要简化成折线。第四个技巧善用节点编辑功能。画错了一个点不需要撤销重画。右键多边形进入编辑状态可以拖动单个节点、删除节点、在两个节点之间插入新节点。道路弯道处如果轮廓不够平滑就双击边缘线上的位置插入新点然后拖到正确位置。标注软件操作熟练度直接决定你的标注速度和标签精度。2.3 JSON内部结构那些必须读懂的字段拿到一个标注好的JSON文件用文本编辑器打开结构通常是这样的{ version: 4.5.6, flags: {}, shapes: [ { label: road, points: [[124.3, 56.2], [175.8, 58.4], [200.1, 210.5]], group_id: null, shape_type: polygon, flags: {} } ], imagePath: img_001.tif, imageData: null, imageHeight: 1024, imageWidth: 1024 }几个字段要读懂。shapes是核心数组每个元素对应一个标注多边形。label是类别名points是多边形所有顶点的像素坐标shape_type决定图形类型。imagePath记录的是原图文件名。这里要注意labelme保存的是文件名不带目录前缀。如果JSON文件和原图不在同一个目录转换脚本就需要额外配置路径搜索逻辑不能直接os.path.join(json_dir, imagePath)了事。imageData字段是原图的Base64编码。如果你在启动labelme时用了--nodata参数这个字段会是nullJSON体积只有几KB如果你没用这个参数每张图都会把原图完整编码塞进JSON里一套几百张图的标注结果能占几个GB。转换脚本正常情况下用不到imageData因为原图已经存在images目录里了。所以我强烈建议启动labelme时加上--nodata --autosave两个参数前者省空间、后者防丢数据。imageWidth和imageHeight是原图尺寸。这是我转换脚本最依赖的信息之一如果因为移动文件导致原图找不到了这两个字段可以作为备用尺寸来源。3. 批量转换脚本完整实现与代码详解3.1 转换流程的设计思路写转换脚本之前先画一条逻辑链。对每一个JSON文件要经历四步读取JSON → 确定画布大小 → 逐多边形填充类别值 → 保存PNG。整个过程不需要任何深度学习框架只需要Python标准库加上Pillow和NumPy。画布大小从哪里来第一优先是原图尺寸。用Pillow打开imagePath对应的原图拿到img.size。原图找不到时退而求其次用JSON里的imageWidth和imageHeight字段。两种方式都不行脚本应该报错而不是硬着头皮用默认尺寸否则生成出来的掩码会和原图错位。逐多边形填充时我用的方案是Pillow的ImageDraw.polygon。把多边形顶点坐标展平成一维列表传进去一次性填充。这个方法比逐像素遍历快几个数量级在2000x2000的画布上画几百个多边形也只是一瞬间的事。3.2 带详细注释的批量转换脚本下面是我目前一直在用的脚本删减了无关分支保留了最核心的部分。import os import json import numpy as np from PIL import Image, ImageDraw # 类别映射表按自己的标注类别修改 # 道路提取二值分割 CLS_MAP { background: 0, road: 1, } # 多类别分割示例土地利用/地表覆盖 # CLS_MAP { # background: 0, # road: 1, # building: 2, # water: 3, # vegetation: 4, # bareland: 5, # } def find_image_path(json_path, image_file, img_dirNone): 在JSON同目录或影像目录中定位原图 candidates [] json_dir os.path.dirname(json_path) candidates.append(os.path.join(json_dir, image_file)) if img_dir: candidates.append(os.path.join(img_dir, image_file)) for c in candidates: if os.path.exists(c): return c return None def convert_one(json_path, out_dir, img_dirNone, target_sizeNone): with open(json_path, r, encodingutf-8) as f: data json.load(f) # 第一步确定画布尺寸 if target_size: width, height target_size else: img_path find_image_path(json_path, data.get(imagePath, ), img_dir) if img_path is not None: with Image.open(img_path) as im: width, height im.size else: width data.get(imageWidth, 0) height data.get(imageHeight, 0) if not width or not height: raise RuntimeError(f无法确定图像尺寸: {json_path}) # 创建背景为0的掩码矩阵 mask np.zeros((height, width), dtypenp.uint8) for shape in data.get(shapes, []): label shape.get(label, ) if label not in CLS_MAP: print(f跳过未知类别 [{label}] 于 {os.path.basename(json_path)}) continue cls_id CLS_MAP[label] shape_type shape.get(shape_type, polygon) pts np.asarray(shape[points], dtypenp.float32) if len(pts) 3: continue # polygon直接用顶点绘制 if shape_type polygon: poly pts.reshape(-1).tolist() # rectanglelabelme只存对角两点补成四点 elif shape_type rectangle: x1, y1 pts[0] x2, y2 pts[1] poly [x1, y1, x2, y1, x2, y2, x1, y2] else: # circle、line等类型默认跳过 continue # 坐标可能带小数必须转整数否则某些Pillow版本会报错 poly [int(round(p)) for p in poly] # 绘制到临时图层再与原掩码取最大值合并 tmp Image.new(L, (width, height), 0) ImageDraw.Draw(tmp).polygon(poly, fillcls_id) mask np.maximum(mask, np.array(tmp, dtypenp.uint8)) out_path os.path.join(out_dir, os.path.splitext(os.path.basename(json_path))[0] .png) Image.fromarray(mask).save(out_path) return out_path def batch_convert(json_dir, out_dir, img_dirNone, target_sizeNone): os.makedirs(out_dir, exist_okTrue) json_files [f for f in os.listdir(json_dir) if f.lower().endswith(.json)] if not json_files: print(目录下没有找到JSON文件) return for i, fname in enumerate(json_files, 1): json_path os.path.join(json_dir, fname) res convert_one(json_path, out_dir, img_dir, target_size) print(f[{i}/{len(json_files)}] 完成: {fname} - {os.path.basename(res)}) if __name__ __main__: import argparse parser argparse.ArgumentParser(descriptionlabelme JSON批量转PNG语义分割掩码) parser.add_argument(--json_dir, requiredTrue, helpJSON文件所在目录) parser.add_argument(--out_dir, requiredTrue, help掩码输出目录) parser.add_argument(--img_dir, defaultNone, help原图目录缺省则尝试JSON同目录) parser.add_argument(--width, typeint, defaultNone, help强制指定宽度) parser.add_argument(--height, typeint, defaultNone, help强制指定高度) args parser.parse_args() size (args.width, args.height) if args.width and args.height else None batch_convert(args.json_dir, args.out_dir, args.img_dir, size)用法python json_to_mask.py --json_dir dataset/labels_json --out_dir dataset/masks --img_dir dataset/images3.3 脚本里几个容易走神的细节我每次写这类脚本都会在三个地方栽跟头这里单独提出来。第一坐标必须整数化。labelme里缩放标注时坐标往往会存成带小数的浮点数。Pillow的polygon方法虽然不是不能处理小数但在某些版本上会静默失败或者画出扭曲的多边形。所以我在传给ImageDraw之前统一做一次int(round(p))把坐标收敛到最近的整数像素。第二类别值的语义。二值分割里背景一定要用0目标类别用1。千万不要习惯性地把道路填充成255。虽然255也能表达“是道路”但在PyTorch的CrossEntropyLoss里255默认会被当作ignore index忽略掉训练出来的模型等于什么都没学到。如果你的任务里有不想参与训练的区域比如边界缝、云影遮挡区可以单独用255标记这个属于进阶玩法了。第三矩形等非多边形类型的处理。labelme的新版本支持矩形、圆形、线等多种形状但除了polygon之外其他形状在语义分割里都不常用。我脚本里保留了rectangle的分支必要时可以快速用矩形框选规则地块。圆形和线在语义分割标注里基本用不上遇到就跳过让脚本继续跑。3.4 快速可视化校验脚本转换出来的掩码不能直接丢给训练先建一个叠加图肉眼检查一遍。import numpy as np from PIL import Image def overlay_mask(image_path, mask_path, save_path, color(255, 0, 0)): img np.array(Image.open(image_path).convert(RGB)) mask np.array(Image.open(mask_path).convert(L)) overlay img.copy() # 掩码区域染成红色 overlay[mask 0] overlay[mask 0] * 0.5 np.array(color) * 0.5 Image.fromarray(overlay.astype(np.uint8)).save(save_path)把原图、掩码、输出路径传给这个函数生成之后快速翻一翻重点检查三类问题道路有没有串到建筑上、水体边界有没有多出来一块、小路的细小弯道有没有被拉直。可视化校验的频率建议每转换一批就做一次不是等到全部转换完才检查。4. 道路提取与地表覆盖场景的进阶实践4.1 二值分割标签的制作要点专攻道路提取道路提取虽然是入门级任务但实际标注难度一点都不低。城市道路网密集、道路与路侧停车位边界模糊、立交桥上下层重叠每一样都在考验标注标准是否统一。我的经验是标注前必须先定规则。比如“道路”这个类别到底包含什么车道、非机动车道、路缘石以内的人行道算不算道路立交桥下重叠的部分怎么处理这些规则没有对错之分但必须整个团队一致。如果一个人按路缘石外沿标另一个人只标柏油路面模型训练时就会看到同一个位置一会儿是道路一会儿不是道路最终学出来的边界必然是糊的。另一个高频问题是道路宽窄不一。高分辨率影像上主干道可能有十几个像素宽小巷子只有两三个像素宽。标窄路时不要放大太多否则两三像素宽的树影、车辆边缘都会被圈进去。我习惯把标注视图控制在能看清车道线分界的缩放级别太细的干扰信息直接忽略。还有一点容易被忽略道路上的车辆和阴影。语义分割任务里如果目标是提取道路表面那么车辆、行人、树木阴影覆盖下的路面其实也是道路。标注时要么按遮挡物轮廓标为别的类别要么直接把可见道路表面连成一片。前者适合车辆检测和分割后者适合纯道路提取。我做过的大多数道路提取项目采用的是后者因为路面被车辆遮挡时模型需要学会“脑补”出被遮挡区域的道路走向。这种标签策略会让模型泛化能力更强。4.2 水体提取与地表覆盖/土地利用的多类别差异水体提取和道路提取在标签制作流程上几乎一样区别主要在边界判断上。水体的边界受水位、水生植物、太阳反光影响很大影像上经常出现“水-陆过渡带”。我的建议是统一以影像上可见的水陆分界线为准。被芦苇遮挡的水面看得到水就标水看不到就标植被不要靠推测把大片湿地标成水。土地利用和地表覆盖则属于典型的多类别分割。道路提取默认背景为0、目标为1但全要素的地表覆盖任务没有“背景”这个概念整张图每个像素都必须属于某个有效类别。这种情况下标注工作量会翻好几倍光靠手画多边形基本不现实。我的做法是用预标注模型先跑一版初始标签再在labelme里打开原图和初始标签手动修错。修正模块输出JSON后转换脚本完全不用改只是CLS_MAP里的类别列表变长而已。多类别分割还要特别注意类别互斥。一个像素不能既是道路又是水体。如果标注时道路多边形和水体多边形有重叠转换脚本里谁排在后面谁就覆盖前面。为了防止这种不可控的顺序影响我会在标注规范里要求重叠区域按“优先级高者得”的原则处理比如水体优先级高于道路一旦有重叠以水体为准。转换脚本只需要在遍历shapes前按优先级排序小的细节解决的是类别边界打架的大问题。4.3 数据增强时的标签同步最容易翻车的一步标签转换完成、精度检查通过还有最后一道关数据增强。遥感影像训练几乎必做旋转、翻转、缩放。很多人在这一步把标签搞坏。比如只对原图做了90度旋转标签还是原来的朝下或者用了带插值的仿射变换直接作用在标签图上结果标签里生成了0和1之间的过渡像素类别编号从1变成了2。这种错位和污染在训练初期很难察觉等发现时往往已经训了几十个epoch。正确做法是保证原图和标签应用完全相同的几何变换。PyTorch生态里可以借助albumentations这类库它支持同时传image和mask内部保证变换参数一致。如果自己写代码就先把随机参数固定下来用同一套参数对原图和标签分别做变换。比如旋转90度原图转90度标签必须同样转90度水平翻转同理。唯一要注意的是标签图不能用任何形式的插值要用modenearest保持整数类别值不变。5. 常见问题与排查技巧实录5.1 转换后整张图只有一个类别道路区域丢失这种问题大概率出在类别名匹配环节。CLS_MAP里写的是roadJSON里标注的label是road_area或者Route脚本匹配不上直接跳过最后输出全背景。排查方法很简单脚本运行时不是会打印“跳过未知类别”的警告吗看一眼警告信息如果出现大量skip就把CLS_MAP和标注用的类别名对齐。多人协作时经常有某个标注员手滑打错单词批量转换后会发现某几张图掩码里少了一个类别翻JSON确认后改正即可。5.2 掩码和原图尺寸不一致labelme保存的imageWidth/imageHeight字段是打开原图那一刻自动记录的。如果你在标注之前对原图做过缩放或裁剪但JSON又是从旧版本复制来的尺寸就会对不上。遇到这种情况转换脚本会出现坐标越界或者掩码里只有一小部分有值。解决方式有两个。一是转换时强制指定--width和--height参数以原始影像的真实尺寸为准二是修复JSON里的imageWidth和imageHeight字段。前者更简单而且是批量操作适合救急。5.3 道路提取掩码边缘全是锯齿锯齿在语义分割里不是错误。像素本质上就是方块标签图是栅格化的结果任何斜线和曲线都会产生锯齿。如果你的任务对边缘精度极其敏感建议从两个方向优化。一是标注时多打点把曲线离散得更细二是在后处理时对掩码做轻微形态学平滑。但这里有个度的问题太平滑的边缘反而会损失道路的真实宽度信息我一般不做平滑直接接受锯齿标签。5.4 同一位置重复标注导致类别互相覆盖多类别分割中两个标注人员在同一个切片上分别标了道路和建筑多边形部分重叠。转换后重叠区域的归属取决于shapes的遍历顺序不确定性很高。我在前面提到过解决的办法有两个层面。管理层面切片任务分配时保证同一张图只分配给同一个人技术层面在转换脚本里加入类别优先级排序让低优先级类别先画、高优先级类别后画。写脚本时对shapes先按优先级排序再依次绘制结果就可控了。5.5 数据集的维护习惯像管理代码一样管理标注做了几次数据集之后我养成了用版本控制管理JSON的习惯。labelme的JSON是纯文本非常适合放进Git仓库。每次修改标注提交一条记录标注版本和模型实验结果直接对应。哪次训练效果好可以反推用的是哪一批标注效果变差也可以快速回退到之前的标注版本重新转换。转换脚本本身也要纳入版本管理。CLS_MAP的改动、shape_type分支的增减都直接影响整批数据的生成结果。我习惯在脚本头部加一行CHANGELOG注释写清楚每次改了什么、对应哪个数据集版本。很多时候模型指标波动追根溯源会发现不是模型问题而是标签选择的定义变了。说实话做道路提取、水体分割这类遥感解译项目标注占用的时间精力往往比训练模型还多。JSON转PNG只是整个数据流程里不起眼的一段但这段如果没处理好后面每一步都得遭殃。我最早做这个转换脚本的时候也是一个文件一个文件手动跑后来才慢慢收敛成现在这套批量方案。如果你现在也在为标签制作发愁建议先拿二十张图跑通整个流程确认掩码没问题再大规模铺开。等模型训练时看着验证集图上一段段道路被准确预测出来回头想想这些折腾还是值得的。