多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Labelme数据集标注实战:从多边形标注到生成语义分割Ground Truth

Labelme数据集标注实战:从多边形标注到生成语义分割Ground Truth 做深度学习项目最难受的往往不是模型调参而是手里没有一份趁手的数据集。公开数据集虽然多但和你的业务场景总有偏差——工业缺陷的形态、医疗影像的病灶位置、田间作物的长势这些都得靠你自己动手标注。Labelme就是这类场景下绕不开的一个工具它能通过Python环境快速部署用交互式多边形把目标轮廓框出来最终落成一份JSON文件再由JSON生成模型训练真正需要的Ground Truth。这篇文章不会只讲点鼠标标几个框我会把从安装、标注到JSON解析、批量转换GT的完整链路走一遍把你大概率会踩的坑也提前标记出来。适合刚入门语义分割、实例分割或目标检测的同学参考也适合被数据集格式折腾过的老手拿来查漏补缺。1. 为什么自己标注数据集以及Labelme在工具链里的位置1.1 公开数据集和你的场景之间隔着一个标注断层很多人刚开始接触CV时都会用VOC、COCO、Cityscapes这些公开数据集跑模型跑通之后信心满满地迁移到自己的业务数据上然后立刻发现效果崩了。原因很简单公开数据集的类别分布、拍摄角度、光照条件、目标尺度都是经过精心筛选的而你的业务数据往往来自固定的摄像头视角、特定的生产线工位、或者某种特殊的成像设备。这时候你需要的不是更强的模型而是一批针对当前场景的标注数据。自己标注数据这件事听起来简单做起来却有不少门道。标注工具选不好后面转换格式时会非常痛苦。比如有些工具只能输出VOC格式的XML你想转成COCO或者YOLO还得再写一坨脚本有些工具在线使用数据传到服务器上就涉及隐私问题。Labelme的优势在于它完全本地运行、基于Python生态、标注结果是一份结构清晰的JSON后缀转换非常灵活。更重要的是它支持多边形标注不仅能标矩形框还能沿着目标的真实轮廓抠形状这对语义分割和实例分割来说是刚需。1.2 Labelme与其他标注工具的差异以及它适合什么任务先把这个工具的定位说清楚。Labelme开源于MIT作者是Kentaro Wada和LabelImg那种以矩形框为主的标注工具不同Labelme的核心标注理念是多边形即真相。它能让你沿着目标边缘逐个打点用多边形逼近任意不规则轮廓。所以它的主要落脚点是语义分割semantic segmentation每个像素归属一个类别。实例分割instance segmentation同一类别的不同个体也要区分开。目标检测的精细标注先标多边形再在转换时生成外接矩形框。和它经常被拿来对比的LabelImg相比Labelme的JSON结构更规整一个文件里同时保存了点坐标、标签名、图像的尺寸和路径信息转换时信息不丢失。而LabelImg的XML虽然也够用但围绕多边形的扩展能力弱很多。如果你的任务只有矩形检测LabelImg完全够用一旦涉及分割直接用Labelme不要两头折腾。1.3 本教程的完整技术链路我建议你按下面这条链路走每一步都有明确产出安装Python环境与Labelme并验证启动。用Labelme打开原始图像对目标画多边形、填标签。保存得到JSON文件理解它的字段含义。写Python脚本解析JSON生成语义分割用的Ground Truth单通道PNG或VOC格式PNG。按需转换出COCO或YOLO格式适配检测/实例分割模型。对生成的GT进行像素级验证确保没有漏标、错标。后面每一节我会对应这条链路里的一个环节细讲怎么做、为什么这么做。2. 安装环节最容易被pyqt5绊倒环境准备与踩坑2.1 Python环境选型conda优先避免污染系统解释器Labelme本质上是一个Python包安装的第一步是准备Python环境。我强烈建议你用conda创建一个独立的虚拟环境而不是直接往系统Python里装。原因很实际Labelme依赖PyQt5做GUIPyQt5又依赖PyQt5-sip做C扩展绑定这两个包在不同Python版本下容易出兼容性问题。如果你在系统环境里把它们装坏了可能会连累其他项目。创建一个新的环境conda create -n labelme python3.8 -y conda activate labelme为什么推荐Python 3.8因为Labelme以及PyQt5的预编译wheel对3.8的覆盖最完整向下兼容性也最稳。3.10以上不是不能用但在Windows上遇到pyqt5-sip编译错误的概率会明显上升。如果你是Linux服务器环境同样建议先用condapython版本保持在3.8到3.11之间问题都不大。2.2 安装Labelme的两种路径pip直装和清华镜像激活环境后最直接的安装命令是pip install labelme但在国内网络环境下直接从PyPI下载PyQt5的wheel经常超时解决办法是使用清华镜像pip install labelme -i https://pypi.tuna.tsinghua.edu.cn/simple注意这里的镜像地址是pypi镜像不要和清华镜像 labelme混淆成什么特殊源。清华PyPI镜像只是把PyPI上的包同步了一份到国内安装体验会好很多。安装完后可以顺手把默认源换掉省得每次敲-i参数pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple2.3 pyqt5-sip报错这个坑的解法其实是降级搜索引擎里翻labelme无法安装pyqt5、labelme error pyqt5-sip的人特别多我当年也在这个坑里蹲过半天。错误信息通常长这样ERROR: Failed building wheel for PyQt5-sip error: command gcc failed: No such file or directory或者ImportError: PyQt5.sip is not the right version先说结论PyQt5-sip是PyQt5底层的C扩展绑定层pip在安装时如果找不到匹配当前Python版本的预编译wheel就会尝试从源码编译而编译需要系统级的C/C编译器。Windows上没有装Visual Studio Build Tools的话这个环节就直接失败了。解决办法有三种按推荐程度排序换Python版本。在conda里重新建一个Python 3.8环境再装多数情况下能直接命中预编译wheel。指定PyQt5版本pip install PyQt55.15.10如果还是不行单独先装PyQt5和PyQt5-sippip install pyqt5 pyqt5-sip -i https://pypi.tuna.tsinghua.edu.cn/simple pip install labelme装完千万别立即运行先做一个快速自检在Python里执行import pyqt5 import labelme print(labelme.__version__)不报错就说明环境OK。命令行直接输入labelme启动GUI界面能正常弹窗就说明安装彻底成功了。2.4 关于Linux服务器没有显示器的启动方式如果你在Linux服务器上装Labelme想用来批量转换JSON而不是人工标注可以跳过GUI启动。但如果你确实需要通过远程桌面或X11转发来标注记得安装相应的显示依赖sudo apt-get install x11-apps libgl1-mesa-glx这个细节很容易被忽略很多人远程登录服务器后运行labelme直接报cannot open display就是因为缺了图形环境支持。当然对大部分同学来说标注工作放在Windows本地做最省心服务器端只做JSON到GT的批量转换。3. 实操标注多边形绘制、标签管理与JSON结构解读3.1 从打开图片到保存JSON的完整操作流命令行输入labelme启动后界面非常朴素左边是工具栏中间是画布右边是文件列表。标注流程非常简单点击左侧Open Dir选择图片所在文件夹。点击Next打开第一张图片。点击Create Polygons开始画多边形。顺着目标边缘逐点单击打点最后一个点和起点闭合时会自动弹出标签输入框。输入类别名按回车确认。一个图上可以标多个目标、多个类别标完点击Save保存JSON。这里有一个很多新手不知道的小技巧画点的时候按住鼠标不松开可以连续拖动Labelme会自动记录鼠标经过的路径松开后闭合即可。这种方式比逐点单击快很多尤其是标形状不规则的目标时效率能提升一倍。保存后的文件命名和原图同名后缀是.json。如果你勾选了Save With Image Data默认开启JSON里会嵌入一整张图片的base64编码方便单文件分发但会让JSON体积变得很大。如果只是本地使用建议在File - Save Automatically打开的状态下正常保存即可不必刻意关掉Image Data。3.2 一个Labelme JSON的内部结构逐字段拆给你看标注完一张图后用文本编辑器打开JSON看到的应该是这样的结构{ version: 5.4.1, flags: {}, shapes: [ { label: defect, points: [ [341.12, 205.76], [356.48, 189.28], [384.32, 184.16], [391.92, 215.84] ], group_id: null, shape_type: polygon, flags: {} } ], imagePath: sample_001.jpg, imageData: /9j/4AAQSkZJRgABAQAAAQ..., imageHeight: 480, imageWidth: 640 }几个关键字段你必须理解shapes核心数组每个元素代表一个标注对象。label类别名字符串转换GT时会映射成像素值。points多边形的顶点坐标列表每个点是[x, y]对图像坐标系原点在左上角。shape_typeshape的类型默认polygon也可能是circle、rectangle等。group_id同一实例的分组IDinstance segmentation时用来区分不同个体。imagePath原图文件名注意如果JSON和图片不在同一目录转换脚本需要处理路径。imageDatabase64编码的图像数据默认存在转换GT时用不到但别轻易丢。理解这个结构是整个转换环节的基础。后面的Ground Truth生成本质上是把shapes里的多边形坐标在空白的像素画布上填充然后根据label映射成对应的灰度值。3.3 标注阶段就埋下的坑这些失误会让你后面非常痛苦标注看起来简单但实际操作中有几个坑特别容易踩而且踩了之后要到转换GT时才会暴露第一个坑是标签名大小写不统一。有人标注时一会儿写Defect一会儿写defectPython字典按key匹配时就会漏掉其中一个。建议在开始标注前先列一个固定的标签清单标注时严格照清单填写。多人协同时更要在团队里统一命名规范。第二个坑是多边形打点太少、形状过于简化。有些目标边缘是弧形的有人为了省事只用四五个点去逼近生成的GT边缘锯齿严重训练时模型会被误导。宁可多点几个点也别偷这个懒。第三个坑是重叠标注。语义分割任务中如果两个多边形的类别不同且有重叠区域转换脚本的填充顺序会直接决定重叠部分归属哪个类别。比较好的习惯是标注时尽量避免重叠如果实在无法避免要在转换脚本里明确规定后者覆盖前者还是前者优先。第四个坑是中文路径。Windows下如果图片路径含中文可能出现JSON可以保存但转换时图片读取失败或路径编码异常。最稳妥的做法是项目目录全程使用英文命名。4. 由JSON生成Ground Truth的三条路线与取舍4.1 官方labelme_json_to_dataset可视化GT和快速检查Labelme自带一个命令行工具labelme_json_to_dataset作用是把单个JSON文件解析成一组输出文件labelme_json_to_dataset sample_001.json -o output_dir执行后输出目录里会包含img.png提取出来的原图。label.png可视化标签图不同类别用不同颜色显示背景是黑色像素值0。label_viz.png原图和标签叠加的可视化效果图。label_names.txt标签列表第一行永远是_background_。json原始JSON的副本。注意label.png严格来说是一张可视化用的伪彩色图不是规范的语义分割GT。它把每个类别映射成一段随机RGB颜色而不是类别索引。如果你直接把这张图喂给模型当Ground Truth损失函数会计算出完全混乱的结果。它的真正用途是快速检查标注有没有明显错误比如某个目标漏标了、多边形位置偏移了扫一眼label_viz.png就能发现。所以我的用法是标注一批图片后先批量跑一遍labelme_json_to_dataset生成叠加图肉眼检查完标注质量再走下一步生成真正可训练的GT。检查环节看似多花了一两分钟但能把错误在训练前暴露出来比训练几轮后再发现标注有误划算得多。4.2 自己写脚本转语义分割GT像素值与类别索引的映射逻辑真正用来训练语义分割模型的Ground Truth应该是一个单通道PNG宽高和原图一致每个像素点的取值是类别索引背景为0。拿Labelme官网的示例来说如果你标了cat和dog两个类别那么GT图片里cat区域像素值是1dog区域像素值是2背景是0。转换的核心思路不复杂读取JSON创建一张全零的单通道矩阵然后在每个多边形的边界内填充对应的类别索引。但用skimage.draw.polygon填充时要注意它的坐标参数顺序是(row, col)而Labelme里点的顺序是(x, y)需要把xy坐标先翻转成(y, x)。一个最小可用的单文件转换脚本可以长这样import json import numpy as np from skimage import draw, io def json_to_label(json_path, output_path, class_mapping, img_size): with open(json_path, r, encodingutf-8) as f: data json.load(f) mask np.zeros(img_size, dtypenp.uint8) for shape in data[shapes]: label shape[label] if label not in class_mapping: continue class_id class_mapping[label] points np.array(shape[points], dtypenp.float32) # skimage的polygon函数期望(r, c)即(y, x) rr, cc draw.polygon(points[:, 1], points[:, 0], shapemask.shape) mask[rr, cc] class_id io.imsave(output_path, mask) if __name__ __main__: class_mapping {_background_: 0, defect: 1, normal: 2} json_to_label(sample_001.json, gt_001.png, class_mapping, (480, 640))这段代码有几个点值得展开说。首先class_mapping是从标签名到像素值的映射表。这个映射必须在你开始标注前就想好而且整个数据集保持一致。绝对不能今天标个defect映射为1明天标个Defect映射为2否则模型训练时类别数量会乱套。其次fill顺序为什么用mask[rr, cc] class_id而不是累加因为如果两个多边形有重叠后来的填充会覆盖先来的。我建议这个覆盖规则固定下来并在代码注释里写清楚。比如同一类别重叠无影响跨类别重叠以后标注者最后一次填充的覆盖。再次输出格式尽量用PNG不要用JPG。JPG是有损压缩会在相同像素值区域边缘产生色块噪点导致GT出现非预期的中间值。PNG无损压缩能保证像素值严格等于类别索引。4.3 转COCO和YOLO格式检测与实例分割场景的适配如果你的任务不是语义分割而是目标检测或实例分割多半需要把Labelme JSON转成COCO或YOLO格式。COCO格式的核心是把所有标注信息汇总到一个JSON字典里包含images、annotations、categories三大块。一张图对应一个image条目一个多边形对应一个annotation条目分割点坐标需要按[x1, y1, x2, y2, ...]的扁平化格式存储。同时还要算出一个bbox即多边形外接矩形。转换流程我封装过很多次核心部分是这样def convert_labelme_to_coco(labelme_dir, output_path): coco { images: [], annotations: [], categories: [] } # 类别自动收集 category_dict {} annotation_id 1 for idx, json_name in enumerate(sorted(glob.glob(f{labelme_dir}/*.json))): with open(json_name, r, encodingutf-8) as f: data json.load(f) image_name data[imagePath] width data[imageWidth] height data[imageHeight] coco[images].append({ id: idx, file_name: image_name, width: width, height: height }) for shape in data[shapes]: label shape[label] if label not in category_dict: cat_id len(category_dict) 1 category_dict[label] cat_id coco[categories].append({ id: cat_id, name: label }) points shape[points] flat [coord for p in points for coord in p] xs [p[0] for p in points] ys [p[1] for p in points] bbox [min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys)] coco[annotations].append({ id: annotation_id, image_id: idx, category_id: category_dict[label], segmentation: [flat], area: calculate_area(points), bbox: bbox, iscrowd: 0 }) annotation_id 1 with open(output_path, w, encodingutf-8) as f: json.dump(coco, f, indent2)这里提一个容易忽略的点bbox的宽度和高度要取max(xs) - min(xs)如果直接取max(xs)会超出图像边界后面评估mAP时会被边界惩罚。area字段在COCO格式里也必须有可以用skimage.measure计算多边形面积或者用鞋带公式算不要省略。至于YOLO格式更适合目标检测模型。YOLO需要的标注格式是每行一个目标的class_id x_center y_center width height所有坐标都是相对于图片宽高的归一化值而且只能标矩形框。你可以先读取Labelme的多边形点坐标算出外接矩形再归一化def convert_labelme_to_yolo(json_path, output_txt_path, class_mapping, img_width, img_height): with open(json_path, r, encodingutf-8) as f: data json.load(f) lines [] for shape in data[shapes]: label shape[label] class_id class_mapping[label] xs [p[0] for p in shape[points]] ys [p[1] for p in shape[points]] x_min, x_max min(xs), max(xs) y_min, y_max min(ys), max(ys) x_center (x_min x_max) / 2 / img_width y_center (y_min y_max) / 2 / img_height box_width (x_max - x_min) / img_width box_height (y_max - y_min) / img_height lines.append(f{class_id} {x_center:.6f} {y_center:.6f} {box_width:.6f} {box_height:.6f}) with open(output_txt_path, w, encodingutf-8) as f: f.write(\n.join(lines))YOLO格式这边最容易翻车的坑是归一化时忘记除以图片宽高。有人直接在像素坐标上写yolov8训练时会直接报anchor尺寸异常或者loss直接变成NaN。这类错误很隐蔽因为数据文件看起来是合法数字不跑训练根本发现不了。4.4 三条路线的选型对比别一上来就想全都要把上面的内容汇总成一张对比表方便你对着自己的任务选转换路线输出内容适用任务优点缺点labelme_json_to_dataset可视化伪彩色图、叠加图标注质量人工检查零代码开箱即用输出的label.png不是标准GT自写脚本转索引PNG单通道类别索引图语义分割训练可控性强像素值精确需要处理类别映射转COCO JSON检测/实例分割标准JSONMMDetection、Detectron2单文件汇总所有标注坐标系细节多转YOLO TXT归一化矩形框YOLOv8/Ultralytics简单直接丢失多边形轮廓信息实操建议是如果你的最终任务是语义分割直接走自写脚本转索引PNG路线如果只是跑检测直接用YOLO格式最省事如果你后续要在多个框架之间切换可以考虑转COCO作为中间格式再用现成工具二次转换。尽量不要在项目刚开始时就要求同时输出四种格式维护成本会拖垮你的标注迭代效率。5. 批量转换中的真实踩坑记录与防御性处理5.1 我在实际项目中遇到的四个典型异常异常一KeyError: points有些人标注时用了矩形框工具而不是多边形工具或者某个shape是circle、line类型转换脚本只处理polygon就会炸。防御办法是在解析shapes时先判断shape_type只处理polygon或对非多边形类型做单独逻辑。异常二多边形的点超出图像边界有些标注者手一抖最后一个闭合点点到了画布外面导致填充时报IndexError。代码里加一行裁剪就能解决points[:, 0] np.clip(points[:, 0], 0, width - 1) points[:, 1] np.clip(points[:, 1], 0, height - 1)异常三读入的JSON是空的或损坏的批量标注难免碰到程序崩溃导致JSON不完整。读取的时候可以加个异常捕获跳过坏文件并记录日志而不是让整个转换进程中断try: with open(json_path, r, encodingutf-8) as f: data json.load(f) except json.JSONDecodeError as e: print(f[SKIP] {json_path}: {e}) continue异常四图像宽高和JSON里的不一致imageHeight和imageWidth字段是标注时记录的值但如果你后续对图片做了缩放、裁剪、压缩这个字段就不再可信。最稳妥的做法是转换时用PIL重新读一次图片尺寸以实际读取为准from PIL import Image img Image.open(image_path) height, width img.height, img.width我自己遇到过一次很隐蔽的情况图片被锐化工具重导出后分辨率没变但EXIF信息里的方向字段变了导致标注坐标和像素位置错位。处理这类图时先统一转成RGB再读尺寸会比较保险。5.2 一份防御性的批量转换脚本把上面的问题全堵住把上面的防御逻辑整合成一个可复用的语义分割GT转换脚本结构如下import json import glob import os from PIL import Image import numpy as np from skimage import draw def convert_single_json(json_path, output_dir, class_mapping): with open(json_path, r, encodingutf-8) as f: data json.load(f) img_path os.path.join(os.path.dirname(json_path), data[imagePath]) img Image.open(img_path).convert(RGB) width, height img.size mask np.zeros((height, width), dtypenp.uint8) for shape in data[shapes]: if shape[shape_type] ! polygon: print(f[WARN] 跳过非polygon标注: {shape[shape_type]}) continue label shape[label] if label not in class_mapping: print(f[WARN] 未定义类别: {label}) continue points np.array(shape[points], dtypenp.float32) points[:, 0] np.clip(points[:, 0], 0, width - 1) points[:, 1] np.clip(points[:, 1], 0, height - 1) rr, cc draw.polygon(points[:, 1], points[:, 0], shapemask.shape) mask[rr, cc] class_mapping[label] out_name os.path.splitext(os.path.basename(json_path))[0] _gt.png out_path os.path.join(output_dir, out_name) Image.fromarray(mask).save(out_path) print(f[OK] {json_path} - {out_path}) def convert_all(json_dir, output_dir, class_mapping): os.makedirs(output_dir, exist_okTrue) json_files glob.glob(os.path.join(json_dir, *.json)) for json_file in sorted(json_files): try: convert_single_json(json_file, output_dir, class_mapping) except Exception as e: print(f[ERROR] {json_file} 转换失败: {e}) if __name__ __main__: class_mapping { background: 0, defect: 1, scratch: 2 } convert_all(json_dir, gt_dir, class_mapping)这个脚本的核心思想就是让批量转换过程不因为个别坏文件而整体停止同时把异常信息留到日志里供事后排查。批量处理上百个文件时宁可某个文件转不出来被跳过也不能让一个异常把整个流程搞挂。5.3 输出结果的验证方法不只肉眼看还要跑像素分布统计转换完成不代表GT正确我强烈建议在训练前做一个快速验证。第一步是用叠加图检查。把原图和GT叠在一起看确认目标轮廓和标注时看到的一致。这个可以用OpenCV一行实现import cv2 img cv2.imread(sample_001.jpg) gt cv2.imread(gt_001.png, cv2.IMREAD_GRAYSCALE) overlay img.copy() overlay[gt 0] (0, 255, 0)第二步是统计类别像素占比。一个合理的数据集背景像素通常占大头前景目标占比可能只有百分之几甚至千分之几。如果你发现某个类别的像素占比异常高多半是多边形画大了或者标签映射错乱了。第三部是抽查边界。GT里同一类别的连通区域边缘应当是光滑的如果出现大量锯齿状边缘说明多边形打点太粗糙需要回到标注阶段重新修正。验证这一步千万别省。GT的质量直接决定模型训练的天花板数据标注的质量问题是数据增强和调参永远弥补不了的。我在实际使用中最深的体会是Labelme的价值不在于它多复杂而在于它的JSON结构足够通透让人有完全的控制权。官网自带的转换工具只能解决能不能转的问题真正的效率来自于自己写脚本去控制像素值、类别映射、异常处理这些细节。先把一个类别的标注闭环跑通再扩大到整个数据集比一上来追求全格式支持稳健得多。另外再给你一个建议转换脚本里务必加上类别清单的校验逻辑一旦遇到标注时用了未登记的标签立即输出警告——这类问题在标注规模大了以后几乎一定会出现提前堵住能省下大把返工时间。
返回列表