Labelme转YOLOv8语义分割数据集:Python脚本实战指南
简介基于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二维数组提取多边形顶点归一化后写入 txtshapes[].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_pathNone): 将单个 Labelme JSON 转换为 YOLOv8 语义分割标签文本。 json_path: Labelme 标注文件路径 class_map: 类别名到ID的映射字典 image_output_path: 如果提供则将 json 内嵌的图片数据解码后写入该路径 返回: (标签文本字符串, 图片数据bytes) 或 (None, None) with open(json_path, r, encodingutf-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(parentsTrue, exist_okTrue) # 写入训练集和验证集 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], encodingutf-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加encodingutf-8不会被 Windows 默认编码搞出乱码。随机打乱前要调用random.seed而且要放在列表构建之后而不是脚本开头保证多次运行时样本顺序一致。RANDOM_SEED 42是个约定俗成的值实际工程中换任何整数都可以关键是固定下来。在协作场景里不同成员跑出的划分结果必须一致否则训练和验证不可比。最后别忘了生成data.yamlYOLOv8 训练时直接yolo train datayolo_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, encodingutf-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, encodingutf-8)改成先试 UTF-8 再回退 GBK。还有一种常见做法是把整个标注目录复制到纯英文路径下再转换规避掉所有编码问题。我在脚本里加了encodingutf-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, encodingutf-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)], isClosedTrue, colorcolor, thickness2) 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。这个信息在模型表现异常时可省很多排查时间。回到这个转换方案本身我每次做完一批新数据都会跑转换、抽三到五张训练集图片做掩码叠加、对照原图确认边界形状再进训练。这套流程虽然朴素但它把“格式对不对”和“标注质量高不高”这两件事分开验证定位问题快得多。希望帮到你。本文还有配套的精品资源点击获取