1. 项目背景与整体方案设计
1.1 为什么用Labelme做数据标注
做深度学习方向的项目,尤其是语义分割、目标检测、实例分割这几类任务,逃不开一个环节:给图片"画框"或者"画轮廓"。市面上的标注工具有很多,像LabelImg、CVAT、RectLabel、SuperAnnotate,但我个人在本地小规模数据集上最常用的还是Labelme。
Labelme是麻省理工学院计算机科学与人工智能实验室开源的图像标注工具,基于Python编写。它最大的特点就是灵活。标注结果直接落盘为JSON文件,JSON本身就是一种通用性极强的数据交换格式,你可以在后端随意解析、转换成自己需要的格式。不管你是要做二分类的分割mask,还是做目标检测的bounding box,甚至做OCR场景的文字框标注,Labelme都能胜任。
另一个选它的理由是轻量。它不需要部署服务端,不需要注册账号,不需要联网,pip安装后本地启动就能干活。对于个人开发者、学生党、小团队来说,这是最省事的一条路径。
这个标题之所以把"标注"和"json生成Ground Truth"放在一起,是因为很多新手卡在了后半段:标注完成了,json文件也有了,但不知道如何把json里的坐标信息变成模型训练真正需要的label文件。我在实际项目中见过太多同学拿着一张"全黑"的mask图来问为什么训练不了。所以这篇文章的重点会放在json转Ground Truth的完整流程上,把每一个坑都摊开讲。
适合谁来读?如果你准备用自己的图片训练一个分割模型,或者想给数据集加上像素级标注,又或者已经用Labelme标注了一部分数据但不知道怎么转成mask,这篇文章可以帮你省下一两天的摸索时间。
1.2 数据集的目录规划与命名规范
动手标注之前,我强烈建议先规划好目录结构。很多人在标注到一半的时候才想起来文件管理混乱,返工成本非常高。
推荐的结构是这样的:
dataset/ ├── images/ # 原始图片,所有待标注图片放在这里 ├── labels/ # Labelme生成的json文件 ├── masks/ # 转换后的Ground Truth mask图 └── config/ └── labels.txt # 类别标签清单每个目录的作用非常明确:images放原图,labels放标注结果,masks放最终生成的训练文件。这个结构一眼就能看懂,将来做拆分训练集、验证集的时候也方便操作。
命名规范上,有两点必须提醒。第一,图片文件名不要含中文和空格,尽量用序号或者英文命名,比如img_001.jpg。原因是Labelme保存的json里会记录文件名,如果文件名带中文,后面做数据读取时会有编码问题。第二,json文件名会自动和图片名保持一致,所以不要在标注过程中随意改文件名,否则json和图片对不上,到时候解析会报错。
还有一个容易忽略的细节:如果在标注过程中发现图片本身有问题(模糊、重复、内容错误),要么直接删除并在标注进度表里标记,要么先把图片移出images目录再重新拷贝一张新图片进入。不要直接在原目录里覆盖同名文件,因为Labelme的json里记录的图片路径是相对的,覆盖后json的内容可能和图片对不上。
1.3 标注目标类型的选择:分割、检测、还是OCR
Labelme支持的标注形状包括多边形、矩形、圆形、线段、点。你在开始之前就要想清楚,你最终要的是什么任务类型:
- 语义分割:用多边形(Polygon)标注出目标的轮廓,最后生成像素级的mask。
- 目标检测:用矩形(Rectangle)标注目标的边界框,转换成YOLO或VOC格式的txt/xml。
- 实例分割:也是用多边形,但每个目标单独标注,mask中每个实例的像素值不同。
- OCR检测:用矩形框或四边形框标注文字区域,配合识别任务使用。
- 关键点检测:用点标注关键位置,比如人脸关键点、人体骨架点。
不同的任务对应不同的Ground Truth格式。我们这篇文章主要聚焦在分割任务上,也就是把JSON转换成像素级的mask图,因为这是Labelme最典型的应用场景。
2. 环境准备与Labelme安装全流程
2.1 安装Python与基础环境
Labelme是Python包,所以第一步是确保你的机器上有可用的Python环境。我建议使用Python 3.8到3.10版本,我自己在3.9和3.10上都跑得很稳。如果你没装Python,去官网下载安装包,安装时注意把"Add Python to PATH"勾选上,这个是很多新手栽跟头的地方。
安装完成后,可以在命令行里验证:
python --version pip --version能正常输出版本号说明环境OK。有条件的强烈建议用虚拟环境,避免污染系统级的Python环境。我习惯的做法是:
python -m venv labelme_env # Windows激活 labelme_env\Scripts\activate # Linux/Mac激活 source labelme_env/bin/activate虚拟环境相当于在你的机器里开辟一个独立的隔间,里面装什么包都不影响外面。我遇到过太多次因为globally安装包版本冲突导致的项目崩溃,用虚拟环境后这种问题基本绝迹了。
2.2 Labelme安装与启动
安装Labelme本身很简单:
pip install labelme但如果你在国内网络环境下直接pip install,大概率会遇到下载超时。这种情况有两个解决办法,一是用清华镜像源:
pip install labelme -i https://pypi.tuna.tsinghua.edu.cn/simple二是如果系统提示某个依赖包需要编译导致安装失败,可以先检查一下是不是需要安装特定版本的pyqt5_sip。我整理过一套最稳的安装组合:
pip install pyqt5==5.15.10 -i https://pypi.tuna.tsinghua.edu.cn/simple pip install labelme==5.5.1 -i https://pypi.tuna.tsinghua.edu.cn/simple这个组合我实测在Windows 11、Windows 10、Ubuntu 20.04上都能正常启动。装完以后,命令行输入:
labelme正常的话会弹出Labelme的主界面。如果弹不出来,大概率是PyQt5的库冲突或者显卡驱动兼容问题,后面常见问题部分我会专门说。
启动之后,在菜单栏File里选择"Open Dir",打开你的images文件夹,就可以开始标注了。建议同时打开"File -> Save As"设置json保存路径,指向labels目录。
2.3 配置类别清单文件
很多教程不会提这点,但我强烈建议你在标注前先写好categories文件。Labelme支持进入标注模式时选择一个标签(label),如果标签列表是空的,你每次都要手动输入一个名字,非常影响效率。
在config目录下创建一个labels.txt,内容大概是:
__ignore__ _background_ person car dog这里有个小细节:Labelme会自动把__ignore__和_background_当作特殊标签处理。你可以不写这两个,但在我们自己写转换脚本的时候,背景就是像素值为0的部分,所以上面这个清单里的_background_其实对应mask里的0值区域,后面的类别从1开始编号。后续转换脚本里用到的类别列表是以此为准的。
3. 标注实操与重要细节
3.1 多边形标注的基本操作
启动Labelme之后,点击左侧工具栏的"Create Polygons"按钮,然后用鼠标左键在图像上逐一单击,勾勒出目标轮廓。每单击一个点,多边形就会多一个顶点,轮廓自然会跟着成形。勾勒完整个目标边缘后,回到起点附近双击或点击右键,就会弹出标签选择框。选择对应的类别,标注就完成了。
标注过程中你会用到一些高频快捷键:
Ctrl+Z:撤销上一个标注点(对,就是用来撤销点错的)Ctrl+S:保存当前图片的标注,生成jsonCtrl+D:复制当前标注到下一张图片(适合多张图片里有相同目标的情况)Delete:删除当前选中的标注多边形
实操中有一个小技巧:标注轮廓时,不要贪婪地打特别多的点来"描边"。点的数量越多,json文件越大,而且模型训练时处理多边形坐标的开销也越大。对于大多数目标,几十个点已经能勾勒出很光滑的轮廓了。锚点太少会导致形状失真,锚点太多会引入标注噪声,我的经验是在曲线变化大的地方加密,在直线部分少点。
3.2 一张图多个目标的处理方式
一个图像里通常会有多个目标,比如道路场景里同时有行人、汽车、红绿灯。你可以连续创建多个多边形,每个目标独立标注,标签可以相同(比如三辆车都标成"car"),也可以不同。
语义分割任务中,同一类别的多个目标最终在mask里的像素值是一样的;实例分割任务中则需要区分开每个目标。区分方式有两种:一种是在json转mask的时候,把每个多边形当成独立的连通域编号;另一种是使用Labelme自带的group_id字段。
给同类别目标设置不同group_id的方法如下:标注完第一个多边形后,在左侧的"Label List"面板中右键点击该标签,选择"Edit Group ID"。如果所有目标都要区分,就把1、2、3这样递增的编号填进去。
这步很有用,尤其当你后面要跑Mask R-CNN这类实例分割模型时,Ground Truth就需要区分每个实例。如果你明确做的是语义分割,那就不用管group_id,直接生成单通道mask图即可。
3.3 整个标注过程中最容易翻车的细节
第一,标注必须闭合。Labelme的多边形在做json转换时,底层会去读每个多边形区域的顶点列表,然后用类似多边形填充的方式生成区域。如果你标注的轮廓有交叉或者没闭合,填充出来的区域会和你预期的完全不一样。
第二,留意图片原始尺寸。后续json转mask的时候,图像尺寸必须和原图一致。如果你把原图缩放后再标注,json里只存了多边形顶点坐标,不会自动做等比缩放,转换出来的mask就会错位。标注时的图片尺寸要原封不动地用于转换。
第三,保存json后顺手检查一下文件大小。如果一张图标注完成后的json居然只有几百字节,十有八九是保存出了问题,或者多边形数据没有正常写入。打开json看看,里面一般会包含imageData字段(图片的base64),以及shapes数组。一个正常的多边形标注json通常在几KB到几十KB不等,具体取决于图片大小和标注复杂度。
4. 解析Labelme生成的JSON结构
4.1 JSON文件的字段含义
打开一个标注好的json,大概是下面这个样子:
{ "version": "5.5.1", "flags": {}, "shapes": [ { "label": "car", "points": [ [323.5, 210.0], [425.2, 213.0], [485.1, 312.5], [310.3, 320.2] ], "group_id": null, "shape_type": "polygon", "flags": {} } ], "imagePath": "img_001.jpg", "imageData": "/9j/4AAQSkZJRgABAQAAAQABAAD...", "imageHeight": 1080, "imageWidth": 1920 }各字段的含义我整理成了表格:
| 字段 | 含义 | 是否必用 |
|---|---|---|
| version | Labelme版本号 | 可选 |
| flags | 图片级全局标签 | 按需 |
| shapes | 标注对象数组,每个元素是一个标注多边形 | 核心 |
| shapes[].label | 当前多边形的类别标签 | 核心 |
| shapes[].points | 多边形顶点坐标,格式是[[x1,y1],[x2,y2],...] | 核心 |
| shapes[].group_id | 分组ID,用于区分实例 | 可选 |
| shapes[].shape_type | 形状类型,通常是polygon/rectangle/circle/line | 核心 |
| imagePath | 图片文件名,相对路径 | 核心 |
| imageData | 图片的base64编码字符串 | 可选 |
| imageHeight/imageWidth | 图片的高宽 | 参考 |
imageData字段是最大的坑。这个字段是base64编码的整张原始图片,会占很多额外空间。如果你用Labelme生成、不做任何处理就丢给团队使用,这个字段会让手头的json文件极大。而转换Ground Truth时其实基本不需要它,只要知道图片路径和shapes里的坐标就够了。
如果确实想让json瘦身,可以在标注时用Labelme的File -> Save Automatically选项,并配合把图像数据从json中去掉的方式,比如保存成不包含imageData的"精简模式"。但这个操作会丢失json自包含的信息,一旦照片移动位置就找不到原图了。我个人习惯保留imageData,毕竟目前磁盘不稀缺,但如果你正在做一个超大数据集,建议尽早制定精简策略。
4.2 坐标体系的说明
Points数组里的坐标是相对原图的像素坐标,以图片左上角为原点,x轴向右,y轴向下。这和大多数图像处理库保持一致,所以在用OpenCV或者numpy进行处理时不需要做坐标转换。
注意,这里的坐标是浮点数,代表标注时鼠标所在的亚像素位置。转换成mask的时候,用整数坐标填充区域即可,不会有精度损失。这个概念对后续生成mask非常关键,因为很多人会纠结"json里的坐标是小数,是不是需要精度处理",其实完全不用。
5. JSON转Ground Truth的完整代码实现
5.1 单张JSON转二值Mask的工具方法
现在到关键环节了。用一个Python脚本把单个json转成单通道mask图。我先把完整的核心函数写出来,再逐行解释。
import json import numpy as np import cv2 from PIL import Image import os # 定义类别映射表,注意顺序,背景固定为0 CLASS_MAPPING = { "background": 0, "person": 1, "car": 2, "dog": 3 } def json_to_mask(json_path, output_mask_path, class_mapping=None): """ 将Labelme的json文件转换为单通道语义分割mask图 json_path: json文件路径 output_mask_path: 输出mask的路径(png格式) class_mapping: 类别名字到像素值的映射字典,如果为None则使用内置默认 """ if class_mapping is None: class_mapping = CLASS_MAPPING with open(json_path, "r", encoding="utf-8") as f: label_data = json.load(f) # 获取图像尺寸 height = label_data["imageHeight"] width = label_data["imageWidth"] # 创建全0的mask,背景自动为0 mask = np.zeros((height, width), dtype=np.uint8) shapes = label_data["shapes"] for shape in shapes: # 仅处理多边形,如果想兼容矩形可加别的分支 if shape["shape_type"] != "polygon": print(f"警告:跳过 {shape['shape_type']} 类型的标注") continue label_name = shape["label"] if label_name not in class_mapping: print(f"警告:标签 {label_name} 未在类别映射表中,已跳过") continue class_value = class_mapping[label_name] # 将坐标转换成整数形式 points = np.array(shape["points"], dtype=np.int32) # 用OpenCV填充多边形区域 cv2.fillPoly(mask, [points], color=class_value) # 保存为png格式,避免jpg压缩带来的像素值漂移 cv2.imwrite(output_mask_path, mask) return mask这个函数的核心逻辑并不复杂:读取json、获取图像尺寸、创建全零mask、遍历所有多边形并填充对应类别的像素值。
为什么要用dtype=uint8?因为大多数分割模型的标签图都是单通道8位图,类别数不超过255时完全够用。如果类别数超过255(一般不可能),可以改用uint16。
为什么保存为png而不是jpg?因为jpg是有损压缩格式,会在像素级别产生微小的颜色偏移,这对普通图片无所谓,但对mask图是致命的。试想你把像素值255的背景压缩成了254,模型的交叉熵损失直接爆炸。PNG是无损压缩,保存像素值不会有一丝一毫的改变。
fillPoly之后多边形内部值被设置为class_value,外部保持0。多个多边形重叠时,后画的会覆盖先画的。如果你的数据集里存在遮挡关系,建议在后标注的目标后画,这样在mask中它才能"压住"被遮挡目标的像素。
5.2 批量转换目录下所有JSON
单张函数写好后,批量处理是水到渠成的事。下面这段脚本遍历labels目录下的所有json,生成对应的mask图到masks目录:
def batch_json_to_mask(labels_dir, masks_dir, class_mapping=None): """ 批量将labels目录下的所有json文件转换成mask图 """ os.makedirs(masks_dir, exist_ok=True) for filename in os.listdir(labels_dir): if not filename.endswith(".json"): continue json_path = os.path.join(labels_dir, filename) mask_name = filename.replace(".json", ".png") output_mask_path = os.path.join(masks_dir, mask_name) print(f"正在处理:{filename}") try: json_to_mask(json_path, output_mask_path, class_mapping) except Exception as e: print(f"处理失败 {filename}:{e}") continue print("批量转换完成") if __name__ == "__main__": labels_dir = "dataset/labels" masks_dir = "dataset/masks" batch_json_to_mask(labels_dir, masks_dir)这里有一个非常有必要的设计:每个文件用try-except包裹。因为标注的过程中可能有个别json损坏,如果不用try-except,一个文件出错整个脚本就崩了。加上try-except,至少能保证其他好的文件正常处理,损坏的文件会被记录到日志里,回头单独排查。
批量转换完成后,可以用一小段代码快速验证mask是否和原图对得上:
def visualize_overlay(image_path, mask_path, alpha=0.5): image = cv2.imread(image_path) mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) # 生成彩色mask便于可视化 colored_mask = np.zeros((mask.shape[0], mask.shape[1], 3), dtype=np.uint8) colored_mask[mask == 1] = [0, 0, 255] # 类别1红色 colored_mask[mask == 2] = [0, 255, 0] # 类别2绿色 colored_mask[mask == 3] = [255, 0, 0] # 类别3蓝色 overlay = cv2.addWeighted(image, 1 - alpha, colored_mask, alpha, 0) cv2.imshow("overlay", overlay) cv2.waitKey(0)这个可视化步骤非常推荐,每转一小批就抽查几张,看mask是否和原图轮廓吻合。很多看起来没有问题的地方,实际错误就藏在细节里。
5.3 多类别合并与RLE编码处理
实际项目里,除了生成单通道mask,还可能需要RLE编码。RLE(Run-Length Encoding)是COCO数据集使用的编码方式,它用"长度 + 像素值"的方式压缩二值mask,比PNG存储更紧凑。
看一个简单的RLE编码样例:
import numpy as np def mask_to_rle(mask): """ 将二值mask(0/1)转换为COCO格式的RLE 输入: 二维数组,值为0或1 输出: RLE字典,包含size和counts """ flattened = mask.flatten(order="F") # 按列优先展平,与COCO一致 counts = [] current_val = flattened[0] run_length = 1 for value in flattened[1:]: if value == current_val: run_length += 1 else: counts.append(current_val) counts.append(run_length) current_val = value run_length = 1 counts.append(current_val) counts.append(run_length) # 如果第一个元素不是0,需要在counts前面加0,因为COCO规定必须从0开始 if counts[0] != 0: counts = [0] + counts rle = {"size": [mask.shape[0], mask.shape[1]], "counts": counts} return rleRLE编码要求按列优先展开,这和平时习惯的行优先展开不一样,经常有人在这里出错。如果你要做COCO格式的数据集,必须用列优先,否则验证mAP的时候会莫名其妙所有指标都是0。
当然,实际项目中更推荐直接用pycocotools自带的mask转换工具,它对边缘情况的处理比我上面这个简易版本要严谨得多。但是理解手动实现的过程,能帮你更清楚COCO RLE的底层逻辑。
6. 实操过程中遇到的高频问题与排查思路
6.1 安装运行时的高频错误
问题1:pip install labelme总是卡在PyQt5相关依赖上
现象:安装过程中提示ERROR: Could not build wheels for pyqt5-sip或者干脆超时。
原因:PyQt5在部分Python版本和系统环境下没有预编译的wheel包,需要本地源码编译,但编译环境又缺东西。
解决办法:先单独固定版本安装pyqt5库,再装labelme:
pip install pyqt5==5.15.10 -i https://pypi.tuna.tsinghua.edu.cn/simple pip install labelme==5.5.1 -i https://pypi.tuna.tsinghua.edu.cn/simple如果还报错,试试升级pip和setuptools:
python -m pip install --upgrade pip setuptools wheel问题2:输入labelme后弹窗报错"Failed to load platform plugin xcb"
现象:这个问题多发于Linux环境。启动时Qt找不到图形平台插件。
解决办法:通常是缺少X11相关的依赖库。以Ubuntu为例:
sudo apt-get install libxcb-xinerama0如果屏幕分辨率有缩放问题,可以设置环境变量:
export QT_AUTO_SCREEN_SCALE_FACTOR=1 labelme问题3:标注过程中点击保存,json文件是空的
现象:json文件存在,但打开是{}或者只含有version字段。
原因:常见的原因是保存时没有选中任何已标注的多边形,或者程序在写入前崩溃了。
排查建议:标注完一张图后,先检查左侧Label List里是否有内容,再点保存。养成"标注完立刻保存"的习惯,并且设置File -> Save Automatically让Labelme在切换图片时自动保存,能大幅降低丢失标注数据的概率。
6.2 转换阶段常见问题的排查
转换阶段的问题,我整理了下面这份排查速查表:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 生成的mask全黑 | 类别映射表里没有对应label,或填充颜色值被设为0 | 检查class_mapping里是否包含了json中所有label;确认类别值从1开始编号 |
| mask轮廓和原图错位 | 标注后图片尺寸被修改过 | 重新检查json中的imageWidth和imageHeight是否与当前图片一致,不一致时按当前图片尺寸重标 |
| 多边形填充后区域有空洞 | 多边形自相交或顶点顺序混乱 | 删除该多边形,用Labelme重新标注;注意顶点顺序应为顺时针或逆时针,不能有交叉 |
| 转换报错KeyError: 'shapes' | 保存的json是不完整文件 | 检查json文件大小,重标这张图 |
| 多类别mask分割识别不了背景 | 背景像素值设置错误 | 确认背景像素值为0,前景从1开始 |
| 转换后无法用cv2.imshow查看 | 窗口不同可能会无法显示大图 | 改用PIL打开,或者把mask缩小后再显示 |
| 内存不足 | 超大图片或超大json | 分批转换,或先缩放原图到统一尺寸后再标注 |
| 合并两个mask时值被覆盖 | 两张mask使用了相同的类别编号 | 合并前检查类别映射表是否有冲突 |
6.3 多类别场景下常见的"值冲突"问题
这里有一个非常典型案例。你标注了一个数据集,里面有两个label:person和riding_person。你原本把person映射为1,riding_person映射为2。但你后来又有一个场景需要把两者合并成一个类别,直接在映射表里把riding_person改成1。这时候,只要这两张mask没有同时出现,单看没问题;一旦某张图里两个类别共存,riding_person就会覆盖person的像素,整个mask会错乱。
解决办法是:类别映射表一旦确定,就不要中途随意改动。如果非要合并,务必重新跑一遍转换脚本,而不是靠后处理去修补mask。
另外一个常见坑是:OpenCV读取PNG时默认是BGR通道,而mask是单通道灰度图,用cv2.imread读mask时要加上cv2.IMREAD_GRAYSCALE参数。如果用默认参数读,mask会被读成三通道,后续和你写的处理代码维度对不上。
6.4 如何快速定位损坏的JSON文件
批量转换时,我建议打印出每一个处理的文件名,这样一旦出错,马上能定位到具体是哪张图。实际操作中,可以加一个小工具函数:
def validate_json(json_path): """检查json文件是否完整有效""" try: with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) if "shapes" not in data: return False, "缺少shapes字段" if not isinstance(data["shapes"], list): return False, "shapes字段不是列表" # 检查shapes中是否有有效坐标 valid_polygons = 0 for shape in data["shapes"]: if shape.get("points"): valid_polygons += 1 if valid_polygons == 0: return False, "该json没有任何有效多边形" return True, "OK" except json.JSONDecodeError as e: return False, f"JSON解析失败: {e}" except Exception as e: return False, str(e)这个函数在批量转换前跑一遍,可以把异常json提前筛掉。否则转换到一半再停下来去修,等待时间会非常漫长。
7. 转换后的验证与下一步衔接
7.1 像素值统计抽检
转换完成后,建议对每一张mask做一次像素值分布统计:
import collections def inspect_mask(mask_path): mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) unique_values = np.unique(mask) print(f"mask {mask_path} 包含像素值: {unique_values.tolist()}") print(f"mask尺寸: {mask.shape}") return unique_values如果某张mask里出现了类别映射表中不存在的值,比如出现了6,而映射表只有0到3,那就说明转换逻辑有bug,可能是某个类别的编号写错了。这种问题如果不在早期发现,到了训练时会以loss不收敛的形式暴露,到那时再去定位就非常痛苦。
7.2 与模型训练的衔接
mask生成之后的用途很多,取决于你要跑什么模型。以目标检测为例,你可能并不需要像素级mask,而是需要把polygon坐标转换成YOLO格式的txt文件。
这里给一个简化的俯角:YOLO格式要求的是归一化的中心坐标和宽高,具体是class x_center y_center width height。用Labelme的矩形标注结果可以方便地转换:
def rectangle_to_yolo(json_path, output_txt_path, class_mapping_reverse): with open(json_path, "r") as f: data = json.load(f) img_w = data["imageWidth"] img_h = data["imageHeight"] with open(output_txt_path, "w") as f: for shape in data["shapes"]: if shape["shape_type"] != "rectangle": continue label = shape["label"] if label not in class_mapping_reverse: continue class_id = class_mapping_reverse[label] x1, y1 = shape["points"][0] x2, y2 = shape["points"][1] xmin, xmax = min(x1, x2), max(x1, x2) ymin, ymax = min(y1, y2), max(y1, y2) x_center = (xmin + xmax) / 2 / img_w y_center = (ymin + ymax) / 2 / img_h width = (xmax - xmin) / img_w height = (ymax - ymin) / img_h f.write(f"{class_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}\n")如果你要喂给分割模型(比如U-Net、DeepLab、Mask R-CNN),那么前面讲的单通道mask图就是标准输入。需要注意的是,训练时建议把mask里的像素值当作类别索引,而不是RGB颜色值。在PyTorch的数据加载器里,可以用torch.from_numpy(mask).long()把uint8数组转成long型张量,这就是交叉熵损失函数接收的target格式。
7.3 划分训练集、验证集、测试集
有了图片和mask之后,需要把数据划分成训练集、验证集和测试集。最简单的方式是按文件list进行划分:
import random from glob import glob image_paths = glob("dataset/images/*.jpg") random.seed(42) random.shuffle(image_paths) train_ratio = 0.7 val_ratio = 0.15 test_ratio = 0.15 train_files = image_paths[:int(len(image_paths) * train_ratio)] val_files = image_paths[int(len(image_paths) * train_ratio):int(len(image_paths) * (train_ratio + val_ratio))] test_files = image_paths[int(len(image_paths) * (train_ratio + val_ratio)):] # 写入txt索引文件 def write_file_list(file_list, output_path): with open(output_path, "w") as f: for path in file_list: f.write(path + "\n") write_file_list(train_files, "dataset/train.txt") write_file_list(val_files, "dataset/val.txt") write_file_list(test_files, "dataset/test.txt")这里有一个关键点:划分时一定要用固定的随机种子,否则每次运行脚本生成的文件列表都会不一样,会导致模型在同一批数据上重复训练或者验证集不稳定,结果不可复现。
8. 从个人经验出发的几点补充建议
8.1 标注时不要一边标一边改类别清单
在项目启动前,把类别清单想清楚,尽量一次性定下来。标注过程中如果发现类别定义有歧义(比如"person"和"riding_person"到底算不算两个类别),先记录下来,集中到一批标注完成之后再统一决定。如果边标边改,后面还得重新核验大量json文件的label是否一致,工作量会翻倍。
8.2 定期备份标注结果
标注数据是劳动密集型产物,丢起来也是连锁反应——如果某张图的json坏了,重新标注可能要花十几分钟,如果一批都坏了,那就是灾难。我习惯每隔一段时间把整个labels目录压缩一次,扔到网盘或者本地备份盘。每隔一千张图片一个批次,这样即使出问题,也只会丢失很少的进度。
8.3 小规模试跑验证
最推荐的流程是:先用十张图跑通全链路。这十张图完成"标注 -> json -> mask -> 模型训练 -> 推理"的完整流程后,再大规模标注。因为只有在训练环节,你才发现自己生成的Ground Truth是不是真的符合模型输入要求。像我之前有次以为自己生成了正确的mask,结果训练的时候loss完全不下降,排查了整整一天才发现是mask的像素值顺序写错了,类别值和标签错位。先小规模验证能省下大量后续返工时间。
我在实际项目中体会最深的点是,Labelme这套工作流的好处在于它的每个环节都很透明,数据的中间表示都是通用格式。前期花一点时间把流程理顺,中间几乎不会出现什么幺蛾子。希望这篇内容能帮你少踩一些我踩过的坑,顺利啃下标注到Ground Truth这一整条链路。