做视觉项目最绕不开的一环就是数据标注。我最早做语义分割任务时拿着一批jpg图片手动描边画完还得想尽办法把标注结果转成模型能读的Ground Truth中间踩过不少坑。Labelme是老牌标注工具打开即用、离线运行、支持多边形级标注个人开发者和小团队拿它标数据非常顺手但Labelme保存下来的json里只有多边形坐标和标签信息离训练还差一步——必须把json转换成像素级的掩码图。这篇文章就记录我从环境安装、标注实操到写脚本批量生成Ground Truth的完整过程适合准备自己造数据集、还不确定标注流程怎么铺的同学参考。1. 这套流程到底在解决什么问题为什么要自己转换1.1 数据标注是视觉项目里最容易失控的环节很多人拿到任务后第一反应是找现成数据集但实际项目中目标物体常常是特定设备拍的、特定场景里出现的公开数据根本不适用。自己做数据集听起来简单做起来却全是细节标注工具选哪个、标注规范怎么定、导出格式怎么统一、后面怎么转成训练能用的标签图。任何一个环节想当然都会在训练阶段变成莫名其妙的报错或者模型效果差。我一开始也试过用LabelImg框目标检测框后来转到分割任务就发现矩形框完全不够用。语义分割需要把物体轮廓抠出来这必须靠多边形标注。Labelme正好支持这个而且每个标注结果存成一个独立json文件结构非常简单方便自己写脚本做二次处理。这个“自己写脚本”才是整个流程里最有价值的部分因为别人给的转换工具未必适合你的目录结构和类别定义自己写一遍才能完全掌控。1.2 为什么我选了Labelme而不是其他标注工具市面上标注工具不少CVAT功能全但是要部署服务端Roboflow在线标注虽方便但图像数据要上传对于数据敏感的项目直接排除。LabelImg只能画矩形框适合目标检测不适合分割。Labelme是本地桌面应用不需要联网画多边形、矩形、圆、线都可以保存成json后可以离线慢慢处理。对个人和小团队来说轻量、可控、易二次开发才是关键。1.3 矢量标注到像素掩码为什么必须转换json里存的是多边形的顶点坐标是一串(x, y)数值也就是矢量表示。而训练分割模型时监督信号必须是一张和原图尺寸相同的像素级标签图每个像素位置上写一个类别编号这就是Ground Truth也叫掩码图或label map。从矢量坐标到像素掩码需要一个栅格化过程把多边形围起来的区域填上对应的类别值。这个过程用Python的PIL库几十行代码就能实现也是本文后半部分的核心。1.4 完整的处理链路整个流程可以拆成五步准备原始图片规划类别清单。用Labelme逐张标注保存为json文件。编写脚本读取json解析shapes里的多边形。用PIL把多边形栅格化成掩码图保存为单通道PNG。检查掩码质量划分训练集/验证集进入模型训练。每一步的输入输出都很清晰原图进Labelme出jsonjson进脚本出mask.png。后面出问题时只要沿着这条链路排查很快就能定位。2. Python环境准备与Labelme安装实战2.1 装Python之前先做对取舍不少人在Windows上卡在第一步没装Python或者系统里Python版本太乱。我的建议是先装Python 3.9或3.10不要追新版本太新的Python有时会让一些带C扩展的包找不到预编译wheel。安装时记得勾选“Add Python to PATH”否则cmd里敲python会提示找不到命令。拿到Python之后强烈建议建一个虚拟环境不要让Labelme的依赖污染全局环境。用conda可以这样conda create -n labelme python3.9 conda activate labelme不想装conda的话用Python自带的venv也行python -m venv labelme-env # Windows激活 labelme-env\Scripts\activate # Linux/macOS激活 source labelme-env/bin/activate虚拟环境的价值在于Labelme依赖PyQt5PyQt5和系统里已有的一些Qt相关包容易打架隔离之后互相不干扰。后面即使把环境玩坏了删掉重建也就是几分钟的事。2.2 Labelme安装的三条路径最稳妥的方式是直接用pip安装pip install labelme国内网络如果下载慢加清华镜像pip install labelme -i https://pypi.tuna.tsinghua.edu.cn/simple如果打算改Labelme源码做定制比如加一个自动保存功能可以走源码安装git clone https://github.com/labelmeai/labelme.git cd labelme pip install -e .平时使用推荐第一种最简单自动把PyQt5、PyYAML这些依赖一起装好。千万不要去网上找什么“labelme中文版下载”官方就是英文界面操作按钮没几个用几次就熟了。2.3 安装过程中最常见的报错我见过最多的两个现象一个是安装时提示冲突另一个是装完启动就报错。典型的报错长这样ModuleNotFoundError: No module named PyQt5.sip这通常是pyqt5-sip版本和PyQt5版本不匹配导致的。可以用一条命令先升级sippip install --upgrade pyqt5-sip如果还不行就固定一个兼容的PyQt5版本再装Labelmepip install pyqt55.15.9 pip install labelme还有一种情况是Windows上提示缺少MSVC运行库这个一般装了Visual C Redistributable就能解决。不过多数用户装的是官方wheel包自带依赖遇到这个的概率不高。2.4 启动Labelme并初始化标注任务安装成功后激活虚拟环境命令行里直接输入labelme就会弹出主界面。左侧是图片列表中间是画布右侧有编辑工具。第一次使用建议先随便打开一张图画一个多边形保存成json确认流程顺畅之后再开始正式标注。另外启动时加一个参数可以控制json里是否内嵌原图labelme --nodata加上这个参数后保存的json体积会小很多因为不再把base64编码的图片塞进json里。后面讲json结构时我会细说这个字段的影响。3. Labelme标注阶段的规范与实操3.1 动手之前先把类别清单固定下来这是我在两个项目上吃过大亏之后总结出来的。一开始标了几十张图类别名今天叫“car”明天叫“vehicle”后天的json里又冒出个“carr”。转换脚本一跑结果多出一堆未知类别后期清洗数据花的时间比标注还多。正确做法是标注前先写一个classes.txt比如background car person road sidewalk vegetation所有标注人员共用同一份清单命名严格一致。如果有两个人合作标注还要约定同一物体如果被遮挡露出多少才算可标注两个物体紧挨着边界线画在哪个像素上这些规范不提前定好后期数据质量会很惨。我在实操中看到过不少争执基本都是规范缺失导致的。3.2 标注形状怎么选别把分割和检测混在一起Labelme支持polygon、rectangle、circle、line、point等几种shape_type。做语义分割和实例分割只推荐用polygon把物体轮廓描出来。rectangle适合目标检测你后面如果需要转YOLO格式再用矩形标注也不迟。circle和point属于特殊场景比如圆形工件、关键点检测一般项目用不到。有人会问分割任务里能不能先画矩形再用转换脚本把矩形转成掩码可以但掩码就是矩形区域物体边缘完全不贴合分割模型学出来效果很差。分割的Ground Truth质量直接取决于标注边界是否贴合物体所以别偷懒该描多边形就描。3.3 标注操作和保存细节操作流程并不复杂打开图片点击左侧多边形工具。在物体边缘上依次点击把轮廓的关键点打出来。回到起点附近双击多边形闭合弹出标签输入框。输入类别名确认后这个shape就出现在右侧shapes列表里。继续标下一张图里的其他物体。标完一张按CtrlS保存生成同名的json文件。如果某个关键点打歪了用编辑工具选中顶点拖动即可。一个物体需要描得很精细时可以在弯曲明显的部位多打几个点。图片放大到100%以上再打点边界会更准确但对应的json文件里坐标会非常密集后面转换时PIL绘制多边形的耗时会变高。对于大目标点控制在30到60个基本足够过度取点会让文件膨胀且没有必要。3.4 标注质量的土法检验每标好一批图片用一个小脚本把原图和生成的掩码叠在一起看。我没有用太复杂的检查工具就是Matplotlib把两张图并排显示肉眼扫一遍边缘贴合度。错误率高的图片尽早返工不要等全部标完再检查那时候返工成本已经非常高了。还有一个细节如果标注过程中发现某张图确实没法判断类别比如中间被强光遮挡与其硬标不如跳过或者用专门的“ignore”类别标出来。4. json文件深度解剖每个字段意味着什么4.1 一份json文件长什么样用文本编辑器打开Labelme生成的json结构大致如下{ version: 5.8.3, flags: {}, shapes: [ { label: car, points: [ [142, 213], [168, 226], [187, 249] ], group_id: null, description: , shape_type: polygon, flags: {} } ], imagePath: 001.jpg, imageData: /9j/4AAQSkZJRgABAQEASABIAAD...很长, imageHeight: 720, imageWidth: 1280 }看起来只有一屏不到但信息量其实不小。version是Labelme版本号flags通常为空imagePath记录原图文件名imageWidth和imageHeight是原图的宽高。真正的标注内容全在shapes列表里。4.2 shapes是核心标签、点和形状类型shapes是一个数组数组里每一项代表一个标注对象。label是类别名points是多边形的顶点坐标shape_type表示这个标注是polygon、rectangle还是其他形状group_id记录实例分组。points里的坐标是绝对像素坐标意思是直接对应原图上的位置。注意排列顺序是(x, y)也就是先列后行。这一点必须刻在脑子里因为很多人转掩码时会把x和y当成行列索引用结果掩码图整体转置了。x方向是图像宽度方向y方向是高度方向。数组里第一个数字是x对应宽第二个数字是y对应高。4.3 imageData这个字段最容易忽略imageData是base64编码后的整张原图有些json文件有好几MB大头全在这个字段上。它的作用是在原图丢失时可以从json里恢复图片也算一种保险。但如果你用--nodata启动Labelme保存的json里就不会有这个字段。我的习惯是生产环境不要imageData因为json文件会变得非常大。假设你有1000张1920x1080的图每张图塞进json体积轻松膨胀到几百MB后面批量读取、复制、备份都很痛苦。原图单独放在images目录里json和图片按文件名一一对应反而更干净。4.4 旋转、缩放与坐标系的坑Labelme的工具栏有旋转功能但那个旋转只是标注视图的临时旋转保存下来的points仍然是原图坐标系下的坐标坐标值不会变。这一点好确认标注完随便打开json看看points里是否有大于图片宽高的数值如果存在说明坐标已经不对了要警惕是不是在旋转后的视图里保存的。图片缩放问题也容易踩。如果你用2倍缩放显示然后标注保存json里存的依然是真实图片坐标不是显示坐标。Labelme内部已经帮你做了坐标反算。但如果是你自己在脚本里对图片做了resize再去读json对应旧尺寸的坐标那就一定要手动做一次缩放映射否则掩码会和原图对不上。5. 核心脚本从json批量生成Ground Truth掩码5.1 先决定Ground Truth的像素值编码Ground Truth通常是一张单通道灰度图每个像素的取值是类别索引。0给背景1给第一个类别2给第二个依此类推。这样训练时模型输出的每个像素是一张概率分布和Ground Truth做交叉熵损失。类别索引的定义要单独写一个映射表不要散落在脚本里CLASS_INDEX { background: 0, car: 1, person: 2, road: 3, sidewalk: 4, vegetation: 5, }这个映射表必须和标注时的类别清单完全一致。我后来写了一个额外的小函数把映射表自动写入labels.txt训练框架读取labels.txt就知道每个索引对应的类别名。5.2 转换脚本的逐行拆解核心逻辑并不复杂读取json遍历shapes对每个多边形在掩码上填充类别号。下面这段代码是我常用版本的精简版可以直接保存成json_to_mask.pyimport json import os import argparse import numpy as np from PIL import Image, ImageDraw CLASS_INDEX { background: 0, car: 1, person: 2, road: 3, } def resolve_image_size(data, json_path): 优先使用json里的宽高字段缺失时退回读取原图。 if imageWidth in data and imageHeight in data: return int(data[imageWidth]), int(data[imageHeight]) img_dir os.path.dirname(json_path) img_path os.path.join(img_dir, data.get(imagePath, )) with Image.open(img_path) as im: return im.size # (width, height) def make_mask_from_json(json_path): with open(json_path, r, encodingutf-8) as f: data json.load(f) w, h resolve_image_size(data, json_path) mask np.zeros((h, w), dtypenp.uint8) for shape in data[shapes]: label shape.get(label, ) if label not in CLASS_INDEX: print(f[跳过] {os.path.basename(json_path)} 含未知标签: {label}) continue idx CLASS_INDEX[label] shape_type shape.get(shape_type, polygon) points shape[points] # 多边形至少要3个点 if len(points) 3: continue layer Image.new(L, (w, h), 0) draw ImageDraw.Draw(layer) if shape_type polygon: draw.polygon([(float(x), float(y)) for x, y in points], fillidx) elif shape_type rectangle: xs [p[0] for p in points] ys [p[1] for p in points] draw.rectangle([min(xs), min(ys), max(xs), max(ys)], fillidx) elif shape_type circle: import math cx, cy points[0] px, py points[1] r math.hypot(px - cx, py - cy) draw.ellipse([cx - r, cy - r, cx r, cy r], fillidx) else: continue layer_np np.array(layer, dtypenp.uint8) # 后画的覆盖先画的 mask[layer_np 0] idx return mask def main(): parser argparse.ArgumentParser() parser.add_argument(--json_dir, requiredTrue, helpLabelme导出的json目录) parser.add_argument(--output_dir, requiredTrue, help掩码输出目录) args parser.parse_args() os.makedirs(args.output_dir, exist_okTrue) json_files sorted(f for f in os.listdir(args.json_dir) if f.endswith(.json)) print(f共发现 {len(json_files)} 个json文件) for json_name in json_files: json_path os.path.join(args.json_dir, json_name) mask make_mask_from_json(json_path) out_name os.path.splitext(json_name)[0] _mask.png out_path os.path.join(args.output_dir, out_name) Image.fromarray(mask, modeL).save(out_path) print(f[完成] {json_name} - {out_path}) if __name__ __main__: main()这段代码里有两个地方值得说明。第一我用了一个单独的layer图去画多边形画完再写回mask而不是直接在mask上画。原因是PIL的ImageDraw.polygon处理的是PIL图像对象直接操作numpy数组会麻烦很多在layer上画好之后一次性转numpy再赋值逻辑最清晰。第二mask[layer_np 0] idx这种写法天然实现了“后画覆盖先画”符合我们标注时的直觉。5.3 批量转换与目录结构的组织推荐的数据集目录结构是这样的data/ ├── images/ # 原始图片 │ ├── 001.jpg │ └── 002.jpg ├── jsons/ # Labelme保存的json │ ├── 001.json │ └── 002.json └── masks/ # 脚本生成的掩码 ├── 001_mask.png └── 002_mask.png转换时执行python json_to_mask.py --json_dir data/jsons --output_dir data/masks脚本会在控制台逐条打印处理日志。转换完成后检查一下masks目录下文件数量是否和jsons目录一致有缺失说明某个json解析出错单独排查即可。5.4 处理特殊情况的注意事项第一个特殊情况是重叠标注。如果两个类别的多边形有重叠脚本里后画的类别会直接覆盖先画的类别。这在分割数据里本身就要避免因为一个像素不能同时属于两个类别。如果场景中遮挡无法避免我建议标注时约定优先级比如“车辆被行人遮挡时标行人并把车辆区域留空”保证掩码里不出现类别竞争。第二个特殊情况是同类别多个多边形。比如一张图里有三辆车shapes里就有三个label为car的polygon。脚本会逐个填充因为三个多边形的填充值都是1所以最终掩码里三辆车所在的像素都是1这在语义分割里是对的。第三个情况是单通道PNG的存储格式。掩码图必须保存成PNG不要保存为JPG。JPG是有损压缩会在边缘产生伪影和颜色渐变训练时会产生大量错误类别标签。用PIL保存PNG时保持modeL即可。6. 转换过程中的典型报错与排查记录6.1 常见问题速查表现象可能原因处理办法安装labelme时提示PyQt5相关异常pyqt5-sip与PyQt5版本不匹配pip install --upgrade pyqt5-sip或固定pyqt55.15.9pip安装超时或极慢网络不稳定加清华镜像 -i https://pypi.tuna.tsinghua.edu.cn/simple掩码图整体转了90度points坐标(x,y)被当成行/列索引使用统一先写x再写y索引mask时用mask[y, x]掩码图边缘有杂色保存成了JPG或有损格式改为PNG保持单通道L模式掩码尺寸和原图不一致json里imageWidth/Height和当前图片不一致检查json头部的宽高字段必要时读原图尺寸覆盖json里出现未知类别标注类别名前后不一致严格按classes.txt命名转换脚本打印跳过日志原图文件丢失只有jsonjson里没存imageData无解只能重新找原图以后建议存一份已嵌入图片的json6.2 坐标轴方向排查的实战记录我第二次写转换脚本时就踩了坐标轴的坑。当时生成的掩码看起来整个是转置的物体形状倒是对的就是位置对不上。我最初以为是resize的问题排查了半天最后在脚本里打印了一个点的坐标值再用PIL画了个小标记才发现是x和y的顺序搞反了。这个事我想多说一句凡是涉及图像坐标转数组索引的代码统一都按这个顺序写w, h img.size # 宽在前高在后 mask np.zeros((h, w)) # numpy数组是高在前宽在后 mask[y, x] idx # 用y(行)索引在前x(列)索引在后把这套规则写在注释里以后就不会反复踩同一个坑。6.3 掩码与原图对不齐时的检查思路如果掩码里物体形状对但不完全贴合原图多半是标注时图片被resize过。例如你在标注前把图片从1920宽缩到了960宽而json里的坐标还是基于1920宽生成的那掩码自然就偏了。可以用一个调试脚本直接把某张图的原图和掩码按相同比例显示再用半透明叠加看看边缘。如果发现整体错位是固定偏移量检查json里imageWidth是否和实际原图一致就能定位。6.4 标注大图时渲染变慢怎么办一张4000x3000的大图多边形又密PIL绘制每个多边形都要为整张大图建一个图层几百个多边形跑下来要好几秒甚至更久。我优化过一次做法是先判断json里points的x和y最大值如果都远小于图片宽高说明坐标相对图片偏小这时要检查是不是坐标经过了缩放而不是盲目优化。真正要提速的话可以把绘制区域裁剪到多边形的包围盒附近用局部数组赋值而不是全图建layer但那个复杂度会明显上升。小数据集其实没必要优化几十张图多等几秒完全能接受。7. 从掩码到模型训练的最后一公里7.1 每次转换完先做可视化检查我强烈建议在数据进入训练之前花十分钟做一轮可视化抽样检查。写一个几行的脚本随机抽10张原图把原图和掩码按2x2拼接显示看一眼边缘、类别数量、标注遗漏。检查重点有三个掩码里非零像素的区域是不是都落在原图里有对应物体的位置有没有整张图全是背景的空标注不同类别之间有没有大面积混叠。抽查脚本差不多长这样import os import random import numpy as np import matplotlib.pyplot as plt from PIL import Image image_dir data/images mask_dir data/masks names os.listdir(image_dir) random.seed(42) samples random.sample(names, 10) fig, axes plt.subplots(2, 5, figsize(20, 8)) for ax, name in zip(axes.ravel(), samples): image Image.open(os.path.join(image_dir, name)).convert(RGB) mask Image.open(os.path.join(mask_dir, name.replace(.jpg, _mask.png))) # 把掩码套上颜色原图半透明叠底 combined np.asarray(image).copy() mask_np np.asarray(mask, dtypenp.uint8) combined[mask_np 0] (255, 0, 0) ax.imshow(combined) ax.set_title(name) ax.axis(off) plt.tight_layout() plt.show()这种叠加显示的方式能快速发现标注质量问题。7.2 训练集/验证集划分的顺序问题划分数据集要在生成掩码之后、训练之前做。如果先划分图片再做转换那每份数据都要单独跑一遍脚本反而麻烦。我用脚本把所有图片按8:1:1的比例随机分成train/val/test同时保证同一场景的多帧图不跨集合。划分时只输出文件名列表不经训练框架自己读目录逻辑更清晰。7.3 数据增强时掩码必须同步变换分割训练几乎离不开数据增强翻转、旋转、缩放、裁剪这些操作必须对原图和掩码同步进行。我第一次用albumentations库时设置好了图片的增强pipeline忘了给mask加同样的transform训练时模型看到的监督信号和输入完全不配对loss全乱。后来学乖了凡是涉及空间变换的增强参数都对image和mask传同样的值。颜色抖动之类的增强只对原图生效掩码不能做像素值上的变换。7.4 扩展到实例分割和目标检测如果后续要做实例分割需要在标注时给每个实例分配group_id脚本里以(label, group_id)作为键生成实例掩码而不是只按label合并。如果要做目标检测直接用矩形框标注然后参考Labelme自带的转换工具转成YOLO格式。我自己在实际项目中做过从语义分割到实例分割的任务切换只用Labelme的json重新调整group_id再改一行脚本逻辑就完成了这也是当初坚持用json做中间格式的回报。我在实际跑这套流程时的体会是数据标注流程里最不值钱的是重复劳动最值钱的是把规范定清楚。类别清单、坐标约定、保存格式、转换脚本每一环都提前想好后面能省掉大量返工时间。最后再分享一个我自己的习惯标注过程中每完成十张图就手动跑一次转换脚本用叠加图快速过一眼发现问题马上用Labelme打开对应的json原地修改千万不要攒到几百张再统一检查。问题越早发现修正成本越低这一条适用于所有和数据标注打交道的项目。