简介:基于Python开发,可将Labelme标注格式转换为YoloV8语义分割数据集,并自动完成训练集与验证集的划分,极大减少人工整理标注数据的时间。面向计算机视觉学习者、高校师生、科研人员以及正在准备毕业设计或课程设计的学生,也适合作为项目立项初期的演示工具。压缩包共14个文件,约1.95MB,核心包含2个Python脚本(负责格式转换与训练示例)、5个JSON标注文件、6张示例图片以及1个Markdown使用说明,各类型文件相互配合,能够让使用者对照真实数据理解转换逻辑。目前已有70人浏览学习,具备一定的基础用户验证。代码经过严格测试可正常运行,使用自带示例数据即可完成从Labelme标注到YoloV8数据集的转换与训练全流程,降低入门门槛;同时脚本结构清晰,便于按需修改和功能扩展,可作为毕业设计、课设作业或工程预研中的高效工具。
1. 五百张图标注完才发现格式不对,所以才有了这个转换脚本
搞语义分割的人身边一定有个想骂人的时刻:标注的时候 Labelme 用得顺手,一个多边形一个多边形地描,标完发现导出来全是 JSON 阵列。而 YOLOv8 训练要的却是每张图片一个同名 txt,每行是一个归一化后的多边形坐标,前面还得挂类别编号,数据集也要预先切成 train 和 val 两份。标题里这个“基于 python 将 labelme 数据标注格式转换为 YoloV8 语义分割数据集”的脚本,就是专门解决这个衔接段的。它不碰模型训练,不做数据增强,只做一件事:把标好的 Labelme 工程目录变成 YOLOv8 可以直接开训的数据集。适合手里已有标注数据、准备换到 YOLOv8 跑语义分割的团队,也适合刚学完 YOLOv8 训练流程、正被数据集格式卡住的新手。这个转换看似简单,但坐标归一化、闭合点处理、类别映射这几个细节,翻车的概率远比想象中大。
2. Labelme 的 JSON 与 YOLOv8 的 txt:格式差异不大,但坑全在细节里
2.1 两种格式的字段对照:搞懂各自存了什么
Labelme 输出的 JSON 结构很直白:imageData字段存了 base64 编码的图片内容,shapes数组里每一项是一个标注目标,对象的label是类别名,points是折线点坐标的数组,shape_type标记了标注类型。还有imageWidth和imageHeight两个字段,这是后续坐标归一化必需的参数。
YOLOv8 语义分割的标签文件则完全不同。每个 txt 文件对应一张图,文件名和图片名一致,内容格式是一行一个目标:第一个数字是类别 ID,后面跟着一串归一化后的坐标点,x 和 y 交替排列。坐标的范围是 0 到 1,而不是像素值。因为坐标是归一化的,所以 YOLOv8 在训练时能自适应不同的输入分辨率,这一设计也是它在推理时能跨尺寸工作的基础。
| Labelme JSON 字段 | 类型 | 转换脚本中的用途 |
|---|---|---|
imageData | 字符串(base64) | 需要时解码出图片,写入训练集图片目录 |
shapes[].label | 字符串 | 映射为类别 ID,写进 txt 行首 |
shapes[].points | 二维数组 | 提取多边形顶点,归一化后写入 txt |
shapes[].shape_type | 字符串 | 判断是否为 polygon,决定是否转换 |
imageWidth/imageHeight | 整数 | 作为 x / y 坐标的归一化分母 |
两种格式的坐标系完全一致,都是图片左上角为原点、向右向下为正方向,所以转换在数学上并不复杂。真正的复杂度来自数据本身的脏乱情况:标注时手抖多了一个点、多边形没有闭合、同一个目标被分成了好几段,这些都只能在写脚本时层层设防。
2.2 shape_type 不止 polygon:矩形、圆、线、点怎么处置
很多人以为 Labelme 就是用来画多边形的,实际上它支持polygon、rectangle、circle、line、point五种标注类型,对应不同的标注场景。做语义分割时,模型需要的是闭合区域,polygon是唯一合适的选择。但实际标注过程中,偶尔会混入几个矩形标注——比如用 create rectangle 快速框了一个区域,忘了切回 create polygons。这种混用如果脚本不处理,运行时会直接报错或者把非法坐标写进 txt。
常见的做法是转换脚本里只处理shape_type == "polygon"的条目,其他类型记录到日志并跳过。这样既不会让整个流程中断,又能在转换结束后提醒你回去检查那几个非多边形标注。矩形的四个顶点本身可以转成多边形,可以直接转换,但要注意 YOLOv8 语义分割的多边形格式,矩形在边界处会出现严重的锯齿,而圆的点集导出后也往往过于密集,这些都会增加训练时 mask 解码的开销。
2.3 坐标归一化与多边形编码的三个隐藏规则
第一,闭合点要主动去掉。Labelme 里用多边形工具画完一个区域,起点和终点是同一个点,目的是让轮廓闭合。但这个重复点在 YOLOv8 的标签里是多余的,会让轮廓面积计算产生微小偏差,某些实现里还会在解码 mask 时多画一条零长度的线。转换脚本里应该判断首尾点距离是否为 0,是就去掉。
第二,归一化并不是简单地除以宽高。如果直接x / imageWidth、y / imageHeight,遇到边缘目标,坐标可能因为标注时的误差略大于 1.0。YOLOv8 训练数据加载器读到超界坐标会直接报错,表现为训练刚开始就抛AssertionError或者 loss 变成 NaN。稳妥做法是归一化后对坐标做clip(0.0, 1.0)处理,同时打印警告,让你知道哪些目标越界了,而不是默默修正。
第三,同一类别的多个目标要写成多行。语义分割和实例分割的标签编码方式有区别,但 YOLOv8 统一用行来区分实例。一个类别的多个连通区域,每个区域单独一行,类别 ID 可以重复。如果你是一个目标一个目标地读 JSON 的shapes数组,天然就满足这个要求,不要试图把同类所有点合并成一行,那会破坏 mask 的连通性。
还有一个处理复杂多边形的问题:Labelme 导出的多边形如果有“洞”,也就是环状嵌套区域,它的points会是一个内外边界混合的列表,没有专门的字段区分内外环。这种情况我在转换脚本里直接按单个多边形处理,结果是语义分割时洞被填充了。想表达带孔洞的地物,目前最可靠的做法是在标注阶段就把孔洞拆成独立的多边形,换区域类别,而不是依赖转换脚本去解析。工具链本身不支持,硬解析只会越搞越乱。
3. 动手写转换脚本:输入 JSON 文件夹,输出可训练的 YOLOv8 数据集
3.1 先定目录约定与参数配置
转换前先把目标目录结构定好。YOLOv8 官方的数据集格式要求 train 和 val 两个目录下分别有images和labels子目录,images放图片,labels放同名 txt。我在转换脚本里用output_dir作为根目录,再在其下生成images/train、images/val、labels/train、labels/val四层结构。图片不拷贝会产生空洞,其他机器跑训练时会有路径问题。
# config.py,集中放参数,方便改 import os # 原始 Labelme 标注文件所在目录 LABELME_DIR = "./labelme_data" # 输出数据集根目录 OUTPUT_DIR = "./yolo_seg_dataset" # 类别映射表,按你的标注内容修改 CLASS_MAP = { "background": 0, "facade": 1, "window": 2, } # 训练集比例,其余为验证集 TRAIN_RATIO = 0.8 # 随机种子,保证每次运行划分结果一致 RANDOM_SEED = 42 # 允许的标注类型,这里只转换多边形 ALLOWED_SHAPE_TYPE = "polygon"这里面CLASS_MAP是最容易出错的参数。Labelme 标注时类别名写错一个字母,映射失败后这个目标会被静默丢弃;类别名不一致更危险,比如有的用window,有的用windows,会导致验证集出现两个类别,而训练时模型不知道这个类别和window是同一个。标注开始前就应该拉一份严格的类别清单贴在屏幕上。
3.2 核心转换函数:单个 JSON 转成 YOLO 标签
下面这个函数是整套流程的核心,读入一个 json 文件,产出对应的 txt 文件内容。函数不会直接写文件,而是返回字符串,方便调用方决定是写入训练集还是验证集。
import json import base64 import os import numpy as np def convert_one_json(json_path, class_map, image_output_path=None): """ 将单个 Labelme JSON 转换为 YOLOv8 语义分割标签文本。 json_path: Labelme 标注文件路径 class_map: 类别名到ID的映射字典 image_output_path: 如果提供,则将 json 内嵌的图片数据解码后写入该路径 返回: (标签文本字符串, 图片数据bytes) 或 (None, None) """ with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) img_w = data.get("imageWidth", 0) img_h = data.get("imageHeight", 0) if img_w == 0 or img_h == 0: print(f"[跳过] 缺少图像尺寸信息: {json_path}") return None, None # 处理内嵌图片,等会用于数据 img_data = None if data.get("imageData"): img_data = base64.b64decode(data["imageData"]) if image_output_path: with open(image_output_path, "wb") as f: f.write(img_data) shapes = data.get("shapes", []) if not shapes: print(f"[警告] 无标注目标: {json_path}") return None, img_data lines = [] for shape in shapes: # 只转换多边形,其他类型跳过 if shape.get("shape_type") != "polygon": print(f"[跳过] 非多边形标注: {shape.get('shape_type')} in {os.path.basename(json_path)}") continue label = shape.get("label") if label not in class_map: print(f"[跳过] 类别不在映射表中: {label} in {os.path.basename(json_path)}") continue class_id = class_map[label] # 提取多边形点,拼成坐标列表 pts = shape.get("points", []) if len(pts) < 3: print(f"[跳过] 多边形点数不足: {len(pts)} in {os.path.basename(json_path)}") continue # 去掉首尾重复的闭合点 first = pts[0] last = pts[-1] if len(pts) > 3 and abs(first[0] - last[0]) < 1e-6 and abs(first[1] - last[1]) < 1e-6: pts = pts[:-1] # 归一化并裁剪到 [0, 1] 区间 norm_points = [] clipped = False for x, y in pts: nx = x / img_w ny = y / img_h if nx < 0 or nx > 1 or ny < 0 or ny > 1: clipped = True nx = max(0.0, min(1.0, nx)) ny = max(0.0, min(1.0, ny)) norm_points.append((nx, ny)) if clipped: print(f"[警告] 坐标越界已裁剪: {os.path.basename(json_path)} label={label}") # 按 YOLO 格式拼行: class_id x1 y1 x2 y2 ... coords_str = " ".join([f"{x:.6f} {y:.6f}" for x, y in norm_points]) lines.append(f"{class_id} {coords_str}") if not lines: return None, img_data return "\n".join(lines), img_data这段代码里需要解释几个设计选择。第一,imageData是 base64 字符串,直接解码能得到 PNG 或 JPEG 的原始字节。如果磁盘上已经有标注时用的原图,脚本会优先拷贝磁盘原图而不是解码 JSON 内嵌图,因为内嵌图是标注软件自动生成的副本,质量可能有损耗。第二,裁剪逻辑不能省略。Labelme 里缩放到边缘、移动目标错位,都会产生负坐标或超界坐标,训练时这些点会直接影响 loss 计算。裁剪会让多边形形状发生细微变化,但至少训练不会崩。第三,闭合点去重,判断条件用的是浮点距离小于 1e-6,而不是直接用==,因为 JSON 解析后的浮点数经过序列化,精确相等的情况反而不常见。
多类别映射是整个脚本的命门。如果标注时把类别名写成了facade和Facade,映射表里只写了小写,那么Facade对应的目标会全部被跳过,训练集里这个类别的样本直接归零。更隐蔽的坑是标注时用了中文类别名,CLASS_MAP里键名不一致导致映射失败,这类问题在运行时不会有明显报错,只能靠最后统计类别分布来发现。
3.3 批量转换与 train/val 自动划分
单个文件转换没问题后,剩下的是遍历目录、拷贝图片、划分训练集。这里有一个关键点:划分的对象是图片列表,而不是 JSON 列表。因为一张图可能对应多个标签,要以图为单位切分,训练集和验证集才能保证类别分布大致均衡。
import random import shutil from pathlib import Path def build_dataset(): random.seed(RANDOM_SEED) # 先收集所有 json 文件 json_files = sorted(Path(LABELME_DIR).glob("*.json")) # 维护一个列表,每个元素是包含图片路径和标签文本的字典 samples = [] for json_file in json_files: # 假设 json 文件名与图片名同名,只差后缀 img_path = json_file.with_suffix(".jpg") if not img_path.exists(): img_path = json_file.with_suffix(".png") if not img_path.exists(): print(f"[跳过] 找不到对应图片: {json_file}") continue # 调用转换函数,生成标签文本 label_text, _ = convert_one_json(str(json_file), CLASS_MAP) if label_text is None: continue samples.append({ "img_path": img_path, "label_text": label_text, }) if not samples: print("没有可用的样本,请检查标注目录和类别映射。") return # 打乱顺序后按比例划分 random.shuffle(samples) split_idx = int(len(samples) * TRAIN_RATIO) train_samples = samples[:split_idx] val_samples = samples[split_idx:] # 创建目录结构 dirs = [ "images/train", "images/val", "labels/train", "labels/val", ] for d in dirs: (Path(OUTPUT_DIR) / d).mkdir(parents=True, exist_ok=True) # 写入训练集和验证集 for split_name, split_samples in [("train", train_samples), ("val", val_samples)]: for item in split_samples: # 图片复制到 images/train 或 images/val src_img = item["img_path"] dst_img = Path(OUTPUT_DIR) / "images" / split_name / src_img.name if not dst_img.exists(): shutil.copy2(src_img, dst_img) # 标签写到 labels/train 或 labels/val label_name = src_img.stem + ".txt" label_path = Path(OUTPUT_DIR) / "labels" / split_name / label_name label_path.write_text(item["label_text"], encoding="utf-8") print(f"转换完成:训练集 {len(train_samples)} 张,验证集 {len(val_samples)} 张。")这段批量逻辑里,最值得讲的是img_path = json_file.with_suffix(".jpg")这一行。Labelme 保存时默认把图片格式写成和原图一致,但很多标注流程里会出现 PNG 的透明底图、JPEG 压缩图混用的情况,所以脚本里先试.jpg再试.png。这两种格式覆盖了绝大多数场景。如果你的数据集里有.bmp、.tif,在这个位置加一个循环就好。
shutil.copy2拷贝图片时保留元数据,包括文件修改时间。这在后续人工排查标签和图片是否同步时很有用——如果发现 txt 的内容和图对不上,看一眼文件时间戳就能确认是谁先谁后。标签写入用write_text加encoding="utf-8",不会被 Windows 默认编码搞出乱码。
随机打乱前要调用random.seed,而且要放在列表构建之后而不是脚本开头,保证多次运行时样本顺序一致。RANDOM_SEED = 42是个约定俗成的值,实际工程中换任何整数都可以,关键是固定下来。在协作场景里,不同成员跑出的划分结果必须一致,否则训练和验证不可比。
最后别忘了生成data.yaml,YOLOv8 训练时直接yolo train data=yolo_seg_dataset/data.yaml就能跑:
def write_yaml(): train_path = str(Path(OUTPUT_DIR) / "images" / "train") val_path = str(Path(OUTPUT_DIR) / "images" / "val") names = list(CLASS_MAP.keys()) yaml_content = f""" path: {str(Path(OUTPUT_DIR).resolve())} train: {train_path} val: {val_path} names: """ for idx, name in enumerate(names): yaml_content += f" {idx}: {name}\n" with open(Path(OUTPUT_DIR) / "data.yaml", "w", encoding="utf-8") as f: f.write(yaml_content)注意path字段要写绝对路径,YOLOv8 会拿它拼接train和val的相对路径。如果你把数据集挪了位置,只需要改data.yaml里的path一行,不需要动其他东西。names的顺序要和CLASS_MAP一致,这里直接用枚举字典键的方式,保证不会错位。如果类别数量多,建议在生成后人工打开data.yaml检查一遍names列表,顺序错了模型就会在类别交叉中翻车。
4. 转换常见坑与排查:现象、原因、解决
4.1 JSON 文件读取失败,集中在中文路径和格式编码上
现象:脚本运行时json.load抛UnicodeDecodeError或FileNotFoundError,程序中断。
原因:Labelme 在 Windows 上保存的文件名默认可能是 GBK 编码,Python 以 UTF-8 打开就会失败。如果标注目录路径里有中文,也会出现同样的问题。
解决:打开文件时显式指定编码,open(json_path, "r", encoding="utf-8")改成先试 UTF-8 再回退 GBK。还有一种常见做法是把整个标注目录复制到纯英文路径下再转换,规避掉所有编码问题。我在脚本里加了encoding="utf-8",对绝大多数 Linux 标注环境够用了,Windows 下批量处理前建议先跑一次测试数据。
4.2 坐标归一化后越界,训练刚开始就报错
现象:YOLOv8 训练几秒后抛异常,错误信息里能看到assert或expected all elements to be...,loss 直接变 NaN。
原因:Labelme 里拖动多边形时不小心把节点拖到画布外,JSON 里保存的坐标是负值,或者是标注完调整图片尺寸后坐标没有更新。除以宽高后出现负数或大于 1 的点,数据加载器无法处理。
解决:在转换函数里对归一化后的坐标执行clip(0, 1)并打印警告。这一步能保证无论原数据多脏,写出的 txt 都是合法值。要注意它不能代替人工修正,越界的多边形即使被裁剪,边界形状可能已经改变,应该回头在 Labelme 里打开原图修复。
4.3 一个目标被标成多个多边形,语义分割 Mask 出现空洞
现象:训练出的模型在某个地物上有两条预测边,同一片区域出现两个相互覆盖的类别。
原因:标注者在画一个大目标时,由于缩放操作没有跟上,把一个完整区域分成了两段画。YOLOv8 按行解析时把它们当成两个独立的实例,语义分割又要求类别互斥,于是预测结果出现互相覆盖的现象。
解决:转换前在 Labelme 里逐图检查,用编辑节点功能把相邻多边形合并。如果目标确实只有一条边被分成几段,可以在转换脚本里加上“相邻多边形共边合并”的逻辑,但这个逻辑过于复杂,容易误合并本来分离的目标。个人经验是标注阶段就要求一个连通域对应一个多边形,这个规范比任何后处理都省事。
4.4 图片被拷贝了但标签文件为空,类别映射悄悄丢数据
现象:转换完成后查看标签目录,发现部分 txt 文件只有 0 字节,图片数量明显多于标签数量。
原因:JSON 里的shapes[].label没有落在CLASS_MAP的 key 里,被跳过了。最常见的是设计类别时facade和window之间有一个空格,或者标注时手滑把类别名改成了相近的拼写。转换脚本打印了警告,但很容易在大量日志里被忽略。
解决:转换运行结束后加一个统计函数,列出每个类别被转换的目标数量。数量和标注时手工统计的结果对不上,就说明映射可能有问题。更进一步,在写标签前判断如果跳过的目标数量占总目标比例超过阈值,直接中断转换,而不是静默继续。
4.5 验证集恰好缺少某些类别,mIoU 结果失真
现象:训练集 mIoU 看着不错,验证集差得离谱,调试了一天才发现验证集里根本没有某个类别。
原因:shuffle 之后按比例切分,随机数可能把所有罕见类别的样本都分到了训练集。数据量越小越容易出现。
解决:切分后校验每个类别在训练集和验证集中是否都存在,缺失就重新切换随机种子再分一次。这是我在代码里会补的 check 逻辑,如果你要接这个方案,建议在split_idx计算之后加上一个类别分布统计,确认两边的类别数和CLASS_MAP一致,这才是语义分割数据集最不该忽略的底层问题。
5. 验证转换结果:把标签画回图片上,看一眼再进训练
转换完直接开训,十次里有八次会出问题。我的习惯是先用一段小脚本把 txt 标签反画到原图上,肉眼检查三个维度:位置对不对、类别对不对、边界贴合不贴合。这个验证只会花几分钟,但能挡住大部分低级错误。
import cv2 import numpy as np def draw_yolo_seg_mask(img_path, label_path, class_colors, class_names): img = cv2.imread(img_path) overlay = img.copy() h, w = img.shape[:2] with open(label_path, "r", encoding="utf-8") as f: lines = f.readlines() for line in lines: parts = line.strip().split() if len(parts) < 7: continue cls_id = int(parts[0]) # 每组 x, y 交替,转换成像素坐标 points = [] for i in range(1, len(parts), 2): x = float(parts[i]) * w y = float(parts[i + 1]) * h points.append((int(x), int(y))) color = class_colors[cls_id] cv2.fillPoly(overlay, [np.array(points)], color) cv2.polylines(img, [np.array(points)], isClosed=True, color=color, thickness=2) result = cv2.addWeighted(overlay, 0.5, img, 0.5, 0) cv2.imshow("check", result) cv2.waitKey(0)这段验证脚本里最值得关注的是坐标还原的方式:x * w和y * h使用的是图片实际尺寸,和转换时的分母一致。如果验证时发现掩码整体偏移,基本可以断定是读取的图片尺寸和标注时不一致,需要回看源 JSON 的imageWidth和imageHeight。另外通过验证也能直观观察训练集与验证集划分是否合理,图片风格是否跨集合混用——如果验证集里出现了和训练集几乎一模一样的图,说明划分时没有按序列切分,而是混洗后按单张切分,这在视频帧序列数据里尤其常见。
进阶方向是按序列划分数据集。无人机航拍、监控视频帧这类时序数据中,相邻帧几乎完全相同,按单张图随机划分会造成严重的数据泄漏,验证集 mIoU 虚高。做法是先按视频片段名分组,以组为单位划分,train_ratio作用在片段数量而非图片数量上。这个改动逻辑很简单,就是字典按 key 聚合后打乱,再把 val 对应的 key 全部样本写进验证集。
类别权重不平衡也是语义分割绕不开的问题。如果你标注的地物面积差异巨大,比如整面墙和一个小窗户共存在同一张图,模型会倾向于把大片区域预测成占比高的类别。转换脚本可以在统计阶段顺便输出每个类别的像素占比,看一眼大致分布,再决定要不要在训练参数里加class_weights。这个信息在模型表现异常时可省很多排查时间。
回到这个转换方案本身,我每次做完一批新数据,都会跑转换、抽三到五张训练集图片做掩码叠加、对照原图确认边界形状,再进训练。这套流程虽然朴素,但它把“格式对不对”和“标注质量高不高”这两件事分开验证,定位问题快得多。希望帮到你。
本文还有配套的精品资源,点击获取