ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

labelme标注转YoloV8分割数据集:完整转换与避坑指南

labelme标注转YoloV8分割数据集:完整转换与避坑指南 简介一套基于Python的Labelme标注转YOLOv8语义分割数据集的自动化工具能读取JSON格式的标注文件批量转换为模型训练所需的语义分割格式并自动划分训练集与验证集省去手动整理标注和图片的重复劳动。该工具面向人工智能、通信工程、自动化、物联网等专业的高校学生、教师及科研人员可应用于毕业设计、课程设计、项目初期演示等场景。压缩包共14个文件约1.95MB其中JSON标注文件保存标注信息JPEG/JPG图像提供测试样本Python源码包含格式转换和训练示例脚本Markdown文档详述环境配置与操作流程附带的示例数据可直接运行验证。目前已有70人学习下载。源码经严格测试功能完善且结构清晰附有设计文档既适合初学者对照学习也可在基础框架上扩展自定义数据集遇到问题可远程交流是一份兼顾教学与实践价值的工具型资源。1. 从 labelme 到 YoloV8 分割数据集这活儿没有想象中那么「复制粘贴」做语义分割的人十有八九都有过这种经历用 labelme 辛辛苦苦勾了一百多张图的多边形勾完才意识到 YoloV8 要的是 txt 格式每一行是「类别编号 归一化后的 x y 坐标」。两边格式差距有多大labelme 给的是 JSON里面存的是绝对像素坐标、图像尺寸、形状类型YoloV8 要的是相对坐标是一个类别的所有顶点连在一起的多边形。最头疼的是 labelme 支持画矩形、圆、线、点而 YoloV8 分割只认多边形——你之前标注的时候要是贪图省事用了矩形框转换的时候还得多一步把矩形变成四点多边形的逻辑。这个 zipped 方案解决的就是这条链路解析 JSON、归一化坐标、按类别写入 txt再顺手按比例把数据拆成训练集和验证集全程用 Python 脚本搞定。适合谁准备用 YoloV8 跑语义分割却卡在数据预处理上的人。换句话讲就是那张从「标注完成」到「能跑起来 train.py」的入场券。2. 核心转换逻辑JSON 结构拆解与坐标归一化的数学2.1 labelme 标注文件到底长什么样先翻开一个 labelme 标注的 JSON 文件看结构核心字段就那么几个imagePath存原图文件名imageWidth和imageHeight是原图尺寸这个必须用归一化全靠它shapes是一个数组数组里每个元素是一笔标注。每一笔标注里label是类别的名字比如road、building、personpoints是多边形的顶点列表[[x1,y1],[x2,y2],...]shape_type是标注类型polygon、rectangle、circle这些。关键点是labelme 默认的坐标系是图像左上角为原点x 向右、y 向下这和 YoloV8 的归一化逻辑是完全兼容的所以不需要翻转坐标轴。但有一个小陷阱后面细讲——points里顶点的顺序。用 labelme 的时候你在图上按什么顺序点顶点就按什么顺序存。YoloV8 对顶点顺序没有硬性要求只要点是按多边形边缘连续排列的就行。但如果你标注的时候画的线是交叉的、或者从中间开始又绕回去那转换出来的多边形就完全错位了。解析 JSON 用json.load()打开读进来就行不要自己写正则去抠字符串没有任何必要。2.2 归一化一句话的事细节全在边上坐标归一化公式极其简单归一化后的 x 像素 x / 图像宽度归一化后的 y 像素 y / 图像高度。YoloV8 分割标签要求所有坐标值落在 0 到 1 之间而且对超出图像边界的点会直接报错或者裁剪所以转换之前还有一个边界检查要做。我见过很多人的转换脚本里只有这一句x_norm px / img_w y_norm py / img_h写完就觉得完事了。实际上有两点必须处理第一浮点数精度。Python 里float默认是双精度 64 位写文件的时候如果不做截断会写出很长一串小数。YoloV8 加载标签时用的是np.loadtxt一类的操作小数点后六位完全够用写文件时统一round(coord, 6)既能减小体积也能避免因为精度太长导致的浮点误差积累。第二顶点数过多的问题。如果你标注的建筑轮廓特别细致一个多边形有几百个顶点归一化后全部写进一行 txt在训练时letterbox缩放后这些点之间可能产生微小的自交点虽然不影响 loss 计算但会让 mask 边缘出现细碎的锯齿。后面避坑章节会单独讲怎么抽稀。2.3 从 JSON 到 txt一张图对应一个标签文件的 IO 设计这一个环节看着简单做起来有不少讲究。最稳妥的文件命名策略是原图文件名.jpg对应原图文件名.txt。YoloV8 源码里按image_path去推断标签路径替换后缀就行。如果你的原图叫a.png标签就叫a.txt。I/O 设计上有两种思路。一种是遍历所有 JSON 文件一个个解析、写 txt。另一种是先扫描原图文件对每张原图去找对应的 JSON。我一般推荐第一种明确以 JSON 为主循环因为有可能出现「标了 JSON 但原图已经被误删」的情况以 JSON 为准能第一时间发现而以图片为准容易漏掉没标注的图导致数据集里混入「没标签的负样本」训练时直接报AssertionError: Label not found。写文件的代码框架可以这样拆import json import os def is_valid_polygon(points): # 多边形至少 3 个顶点且面积不为 0 return len(points) 3 and len(set(map(tuple, points))) 3 def convert_labelme_to_yolo(json_path, out_dir, class_name_to_id): with open(json_path, r, encodingutf-8) as f: data json.load(f) img_w data.get(imageWidth) img_h data.get(imageHeight) if not img_w or not img_h: print(f跳过 {json_path}: 缺少 imageWidth/imageHeight) return base_name os.path.splitext(os.path.basename(data[imagePath]))[0] lines [] for shape in data[shapes]: if shape[shape_type] not in (polygon, rectangle): continue label shape[label].strip() if label not in class_name_to_id: print(f警告: {base_name} 有未注册类别 {label}已跳过) continue points shape[points] if not is_valid_polygon(points): print(f警告: {base_name} 中的 {label} 顶点数不足或无效) continue cls_id class_name_to_id[label] normalized [] for px, py in points: nx round(px / img_w, 6) ny round(py / img_h, 6) normalized.append((nx, ny)) coords_str .join([f{x} {y} for x, y in normalized]) lines.append(f{cls_id} {coords_str}) if lines: out_path os.path.join(out_dir, base_name .txt) with open(out_path, w, encodingutf-8) as f: f.write(\n.join(lines)) print(f已生成 {out_path})is_valid_polygon这段处理容易被忽略labelme 里手滑点了两下就保存的情况不是没有顶点数少于 3 的多边形写到 label 文件里YoloV8 数据校验时会直接中断。set(map(tuple, points))是去重判断防止同一个点被重复记录导致面积为 0。需要注意shape_type的判断。labelme 默认标注模式画出来的是polygon但如果你用了矩形框工具shape_type就是rectangle而且points只有两个顶点左上、右下。YoloV8 分割不认这种格式所以要么在转换时把矩形展开成四个顶点(左上、右上、右下、左下)要么直接跳过。上面代码里跳过矩形可能不适合你的需求——如果你想保留矩形标注可以在循环里加一段展开逻辑if shape[shape_type] rectangle: x1, y1 shape[points][0] x2, y2 shape[points][1] points [[x1, y1], [x2, y1], [x2, y2], [x1, y2]]还有一点类别映射。class_name_to_id这个字典必须和你训练配置里的data.yaml保持完全一致否则类别就全串了。一般这么写class_name_to_id { road: 0, building: 1, vegetation: 2, person: 3, }类别从 0 开始编号YoloV8 不认从 1 开始的编号。很多人第一次跑分割训练的时候 loss 异常大检查半天发现类别就是从 1 开始写的。这个细节能在转换阶段堵住就堵住不要拖到训练才暴露到时候面对几千张图的标签根本不想改。3. 一键自动划分数据集训练集/验证集拆分的最佳策略3.1 为什么随机抽样还不够拿到全部转换后的 txt 标签之后下一步就是按比例拆训练集和验证集。这个问题听着简单写个random.shuffle再切片就行但实际做起来有几个隐性需求「可复现的随机」「同一张图的图像和标签文件要分开落在不同集合」「尽量不要出现某个类别只在验证集里出现的极端情况」。最基础的做法是打乱所有样本的序号前 80% 进训练集后 20% 进验证集。常见做法是先在图片这一层做划分再把划分结果存成一份清单文件。YoloV8 的YOLODataset类支持你传两个清单文件比如train.txt和val.txt里面每行是一个绝对路径或相对路径指向图片文件。所以我一般会生成两个 txt分别列出训练和验证的图片路径同时按另外一份映射关系把对应的标签文件也搬进两个目标目录。原因是直观——U 盘拷给别人或者换电脑训练时眼看到的就是images/train/、images/val/、labels/train/、labels/val/这种灯光下谁都能懂的目录结构。3.2 设置固定随机种子保证可复现随机种子这个细节决定你的实验可不可复现。训练集验证集是随机划的如果你的划分脚本每次跑出来结果不一样那后面训练出来的模型性能在两个 epoch 之间对比的基线就乱了。加一行random.seed(42)是必须的但这行代码放在整个脚本的最前面。这里给出一个完整可用的划分脚本它在上一章的转换之后运行输入是转换好的全部标签文件import os import random import shutil from collections import defaultdict # 固定随机种子保证每次划分结果一致 random.seed(42) img_source path/to/your/images # 原图目录 label_source path/to/your/labels # 转换后标签 txt 目录 train_ratio 0.8 # 训练集比例 out_root dataset_yolo_seg def gather_samples(img_dir, lb_dir): samples [] for fname in os.listdir(lb_dir): if not fname.endswith(.txt): continue # 过滤掉 classes.txt 这类非标签文件 if fname in (classes.txt,): continue stem os.path.splitext(fname)[0] img_candidates [ os.path.join(img_dir, stem ext) for ext in (.jpg, .jpeg, .png, .bmp) ] img_path next((p for p in img_candidates if os.path.exists(p)), None) if img_path is None: print(f跳过 {fname}: 找不到对应原图) continue samples.append((img_path, os.path.join(lb_dir, fname))) return samples samples gather_samples(img_source, label_source) random.shuffle(samples) split_idx int(len(samples) * train_ratio) train_samples samples[:split_idx] val_samples samples[split_idx:] def copy_to(sample_list, subset): img_out os.path.join(out_root, images, subset) lb_out os.path.join(out_root, labels, subset) os.makedirs(img_out, exist_okTrue) os.makedirs(lb_out, exist_okTrue) list_lines [] for img_path, lb_path in sample_list: dst_img os.path.join(img_out, os.path.basename(img_path)) dst_lb os.path.join(lb_out, os.path.basename(lb_path)) shutil.copy2(img_path, dst_img) shutil.copy2(lb_path, dst_lb) list_lines.append(dst_img) list_file os.path.join(out_root, f{subset}.txt) with open(list_file, w) as f: f.write(\n.join(list_lines)) copy_to(train_samples, train) copy_to(val_samples, val)注意几个设计选择。第一gather_samples里以标签文件为基线去配对图片避免有没有标签的图片混进来。第二原图扩展名支持.jpg/.jpeg/.png/.bmp用next()加os.path.exists的方式去探测真实文件名——因为 labelme 的 JSON 里imagePath可能有../这种相对路径而这个脚本里你面对的是已经归拢好的图片目录直接用扩展名探索最省事。第三复制而不是移动保留原始标注文件做备份。实际训练中你改了标注重新转换的情况经常发生原始 JSON 一旦丢了标注成本就白花了。这里特意用了copy2保留文件时间戳如果图片很多的话可以改成os.link或者shutil.move来省磁盘空间但优先级不高。3.3 类别均衡验证集里不能没有某一类这个点容易被忽略。按 8:2 随机抽样如果你的数据集里某个类别本身只有 5 个样本在 80/20 的随机划分下这个类别全部落进训练集的概率是0.8^5 ≈ 32.8%近三分之一的情况验证集里根本没有这个类别。验证集 mon 里只有四类但 yolo 验证时会统计每个类别的 mAP缺失的类直接给 0 分看起来像模型完全没学会这个类。处理办法是分层抽样保证每个类别在训练集和验证集里都至少出现一次。极端情况下某一个类别只有 1 张图那就把它复制到两边——虽然不完全严谨但比缺类强一个量级。实现起来不怎么复杂# 分层抽样: 对每个类别分别划分 by_label defaultdict(list) for img_path, lb_path in samples: with open(lb_path, r) as f: first_token f.readline().split()[0] by_label[first_token].append((img_path, lb_path)) train_set, val_set [], [] for label, items in by_label.items(): random.shuffle(items) n_train max(1, int(len(items) * train_ratio)) # 如果样本太少保证至少一个进验证集 if len(items) - n_train 0: n_train - 1 train_set.extend(items[:n_train]) val_set.extend(items[n_train:])这段的思路是按每个 txt 第一列类别编号把样本分组同组的才随机划分。max(1, ...)保证每类至少一张进训练集if len(items) - n_train 0保证每类至少一张进验证集。对有 2000 张以上的大数据集这种写法和纯随机划分差别不大但样本量只有一两百张时不这样做验证集的指标会忽高忽低完全是抽样偏差导致不是模型好坏。4. 转换后必做的一步用黑图和可视化检查「转换对没对」转换脚本跑完不要直接开训你需要用肉眼验证一遍。分割标签的转换错误不像检测框那样有「画框」可以直接对比——它是像素级掩码错了很难直接看出来训练的时候 loss 又能正常下降。所以可视化这一步请养成肌肉记忆。我在转换脚本里会顺带生成一个验证图。每张标注图对应用两种颜色的叠加黑色底图上画白色多边形轮廓或者直接在原图上叠加半透明的轮廓。这里给一个用 OpenCV 实现的最小验证脚本核心是还原 labelme 原始标注把多边形画出来import cv2 import numpy as np def visualize_mask(img_path, label_path, class_colors, out_path): img cv2.imread(img_path) h, w img.shape[:2] overlay img.copy() with open(label_path, r) as f: for line in f: parts line.strip().split() if len(parts) 7: # 至少要 class 3 个点 continue cls_id int(parts[0]) coords np.array(parts[1:], dtypenp.float32).reshape(-1, 2) coords[:, 0] * w # 反归一化回像素坐标 coords[:, 1] * h coords coords.astype(np.int32) color class_colors.get(cls_id, (0, 255, 0)) cv2.fillPoly(overlay, [coords], color) cv2.polylines(overlay, [coords], True, (255, 255, 255), 2) # 半透明混合 alpha 0.5 result cv2.addWeighted(overlay, alpha, img, 1 - alpha, 0) cv2.imwrite(out_path, result)这脚本干了一件事把 YoloV8 格式的 txt 标签反算回像素坐标再画到原图上。注意coords[:, 0] * w的写法就是把归一化的坐标乘回宽度和高度。跑完以后随机抽查 10 到 20 张视线集中在两个位置轮廓是否贴合物体的边缘以及类别颜色是否和物体匹配——尤其是题图上标注的不同类别挨得很近或互相遮挡的场景。我遇到过一种典型翻车把多边形的顶点顺序搞反fillPoly画出来的掩码呈自交星形。视觉检查一眼就暴露。而如果不做这一步直接开训模型会在某些区域学出诡异的纹理损失曲线看着正常推理出来的掩码边缘全乱。另一个建议是顺带生成一个dataset_stats.txt统计每张标签文件的顶点总数、类别分布。这样你能在看不到图的情况下判断脚本输出的数据整体是不是健康。比如某张图的标签里多边形顶点数突然从 200 涨到 2000说明那笔标注可能手滑了。5. 转换流程避坑5 个最常踩的坑和对应的处理办法5.1 labelme 图像路径带中文cv2 读图失败现象脚本跑了一半报cv2.error: OpenCV(4.x) ... imread_错误或者读出来的img是None后面访问img.shape直接抛异常。通常发生在 Windows 上中文用户名或者中文目录名。原因OpenCV 的imread底层用的是 C 标准库的文件读取对 UTF-8 编码的中文路径支持不好。而 Python 原生的open()函数没这个问题所以去读 JSON 文件没问题一到cv2.imread就炸。解决不要用cv2.imread读图改用np.fromfile加cv2.imdecodedef cv_imread(file_path): data np.fromfile(file_path, dtypenp.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR)同理cv2.imwrite写中文路径也会失败改成cv2.imencode(.png, img)[1].tofile(out_path)。如果你整套转换逻辑里根本不去读原图像素只从 JSON 拿宽高那就不需要改。但一旦加了可视化这一步上面这段就是必踩的坑血泪经验提前写进自己的工具函数里。5.2 归一化后坐标等于 1.0训练时 RLE 编码报 index out of range现象训练跑到一半终端打印IndexError: index 256 is out of bounds for axis 0 with size 256或者类似的 RLE 编码越界错误。但你检查 txt 文件觉得没问题。原因某个多边形的顶点坐标恰好落在图像最右侧或最下侧的像素上比如点的坐标就是imageWidth本身注意像素坐标从 0 开始最后一个像素的坐标是width - 1。归一化后这个点的 x 等于1.0。YoloV8 内部做 mask 编码时会把x * width映射回像素网格1.0 * 256得到了256而网格的索引范围是0..255。越界就崩了。解决归一化后做一次裁剪把所有坐标强制限制在[0.0, 0.999999]范围内。加两行代码nx min(max(round(px / img_w, 6), 0.0), 0.999999) ny min(max(round(py / img_h, 6), 0.0), 0.999999)这属于典型的玄学报错训练中断后第一反应普遍是去查数据集目录结构很少有人会想到是某个坐标多了一个像素。5.3 导出的 txt 里混进了 labelme 自带的填充图像现象转换后数据集目录里莫名其妙多了一堆_json文件夹或img.png文件。训练时Dataset not found或者标签列表里全是报错。原因labelme 有个「导出」功能会为每张标注图生成一个以文件名_json命名的目录里面存放转换后的可视化图像img.png是原图拷贝label.png是掩码图。如果你把整个标注目录直接当输入扔给转换脚本脚本遍历文件时把这些 PNG 也当成原图拉进来了。解决两个办法。转换脚本里用后缀白名单过滤只处理.json文件划分数据集时跳过所有路径里含_json的文件。如果已经混进去了删掉再重转不要自己写清理脚本——因为_json目录下的文件还有重名的可能。宁可这次多花 30 秒重新拷贝一遍原始标注目录不要在脏数据上修修补补。5.4 多边形顶点太密集生成的 mask 边缘锯齿严重现象转换出来的分割效果图轮辋边缘有密集的小锯齿训练后模型推理结果噪点很多。尤其标注时用多边形一点点描非常曲折的物体比如树叶轮廓一个多边形可能有 2000 多个点。原因YoloV8 在训练时会把多边形按尺寸缩放到640x640再栅格化成 mask顶点太密不仅占存储缩放时相邻点之间距离小于一个像素会产生大量无效顶点。解决用 Douglas-Peucker 抽稀算法。shapely库里自带simplify方法或者手工实现def simplify_polygon(points, tolerance2.0): from shapely.geometry import Polygon poly Polygon(points) simplified poly.simplify(tolerance, preserve_topologyTrue) if simplified.geom_type Polygon: return list(simplified.exterior.coords)[:-1] # 去重闭合点 return points # 简化失败就保留原样tolerance建议设在 1.0 到 2.0 像素之间太小没效果太大会把尖角削平。特别注意preserve_topologyTrue这个参数不能丢否则简化后的多边形可能发生自相交。5.5 划分后 labels 目录里混进了classes.txt导致 YoloV8 报错现象训练开始后报RuntimeError: Dataset xxx error ...检查数据集目录结构没错图片文件在、标签文件在、data.yaml路径也对。原因用 labelme 导出的文件里除了每张图的 JSON可能还有一个classes.txt记录了全部类别名。你写gather_samples时如果只判断.txt后缀就会把classes.txt当成标签文件拷进 labels 目录。YoloV8 加载时读到这个文件解析第一行发现是字符串而不是数字直接报错。解决转换脚本里显式排除classes.txt前面代码已有并且在划分函数里加一道白名单过滤只允许文件名和图片文件主名一致的 txt 进入最终目录。如果已经污染了直接删掉标签目录下的 classes.txt 再重跑。6. 最后的收尾习惯转换脚本本身也是产物写进你的仓库到这里转换和划分的完整流程已经走通了。但我说句实在话——真正让你下次节省时间的不是记住这些代码怎么写而是把转换脚本固定成一个可复用的命令行工具跟数据集放一起放进仓库。下次拿到一批新的 labelme 标注数据跑一条命令出来完整可训练的数据集不需要打开编辑器改路径。我习惯把这个脚本做成convert_and_split.py命令行参数这样组织python convert_and_split.py \ --json_dir ./labelme_annotations \ --image_dir ./raw_images \ --out_dir ./yolo_seg_dataset \ --class_file ./classes.txt \ --train_ratio 0.8 \ --seed 42 \ --visualize其中--visualize是白开关默认关闭开启时每次转换随机抽 5 张生成可视化检查图存到out_dir/visual_check/。用命令行参数而不是改代码里的常量最大的好处是——你三周后回来看这条命令不用逐一回忆代码逻辑就能复现完整流程。脚本本身要记得同时放到git仓库里和数据集版本一起管理。验证工作没做完之前不要开训练。怎么算验证做完了第一条随机抽 20 张visual_check的图肉眼翻一遍确认多边形贴合物体边缘、类别颜色无错位第二条确认train.txt和val.txt文件加起来等于全部样本数没有遗漏第三条训练起来第一个 epoch 的box_loss和seg_loss在正常范围下降——语义分割的seg_loss初始值一般显著低于检测的box_loss如果一开始就飞了先回来查数据。说到底从 labelme 到 YoloV8 的转换不是一个高深的技术活但它是个拼细节的体力活——细节全在那些「看起来没问题一训练就翻车」的边界条件里。把前面那五个坑全部提前堵死后面就只剩跑脚本、看曲线、调参数这些正事了。这也是我做这类数据工具的习惯一次写完整永远可复现希望帮到你。本文还有配套的精品资源点击获取
返回列表