
简介这是一款面向实例分割、Mask R-CNN 等深度学习样本制作场景的轻量图像标注工具参考 VIA 与 labelme 的交互思路由 C# 编写并开放源码支持矩形框和多边形轮廓标定适合需要自建数据集的研究者、学生及算法工程师在本地完成样本标注与数据整理。压缩包共 65 个文件约 4.07MB内含 20 个 cs 源码、若干 dll 依赖、图标与配置资源、可执行程序、docx 使用说明以及完整解决方案工程兼顾直接运行和二次开发。已有 2340 人学习下载在标注工具选型与样本制作流程中具备一定参考价值。读者可获得可运行的标注程序、清晰的工程结构与源码了解矩形/多边形标注、对象类别管理、数据保存等实现方式并据此搭建自己的实例分割数据集制作流程也能为后续目标检测与 Mask R-CNN 训练准备标注样本。1. 一张 4000×3000 的工地照卡住的从来不是模型跑 mask_rcnn 的人多半经历过这一幕训练日志里 loss 稳稳下降验证集 mAP 却停在 0.02 左右换个 backbone、调三遍学习率都没用。回头翻样本才发现问题压根不在网络——标注出来的掩码本身就是坏的有的多边形自交成一团有的坐标越界还有的类别 id 从 0 开始编。这份资源就是围绕「样本制作」这一环一套面向 mask_rcnn 的图像标注工具与流程包覆盖多边形掩码点选、类别表管理以及 VIA、labelme 到 COCO 的格式转换和校验脚本。适合已经能跑通训练脚本、却被自己标的数据卡住的从业者也适合第一次给实例分割准备样本的新手。2. 工具与格式选型VIA、labelme、COCO 三套 JSON 差在哪一行选工具这件事很多人是随手下载一个就开始点点到第 300 张才发现导出格式和训练脚本对不上回头重标的成本比一开始选型高十倍。这一章把三套格式的字段摊开对比说清什么场景该用哪个。2.1 实例分割为什么绕不开多边形检测任务用矩形框就够了mask_rcnn 不行。它的 mask 分支要的是像素级前景你给一个 bbox 让它自己去猜边界等于把「背景算进前景」这件事写进了监督信号——相邻的两个行人、叠在一起的货箱矩形框里一半是别人的像素训出来的掩码边缘必然是糊的。标注方式通常有三种矩形框、多边形、笔刷。矩形框只适合做检测预训练或粗筛笔刷能处理镂空和毛发边缘但存下来是 RLE 或者位图文件大、二次编辑几乎不可能多人协作时合并冲突很难受多边形是折中点数可控存成坐标点串一个中等复杂度的目标 20 到 40 个点就够转 COCO 只占几十字节。血泪经验是类别边界清晰、目标形状规则的任务一律多边形。只有医学影像的细胞、遥感的水体这类边缘极度不规则的场景才值得上笔刷。2.2 VIA 与 labelmeregions 到底是 list 还是 dictVIA 是网页版多人同时标注最省事不需要装环境缺点是导出格式随版本变。labelme 是本地工具快捷键顺手能直接存图缺点是 JSON 里可能内嵌 base64 图像文件体积暴涨。先把字段差异摆出来这张表建议存在项目 README 里维度VIAlabelmeCOCO多边形存法all_points_x/all_points_y两条数组points点对数组segmentation扁平数组类别字段region_attributes自由键值label字符串category_id整数图像尺寸部分版本不存size是文件字节数imageHeight/imageWidthwidth/height内嵌图像否可选 base64否能否直接训练否否是两个容易翻车的点。第一VIA 老版本导出的regions是 dictkey 是 region id新版本是 list同一份标注文件你用不同版本打开再导出结构就变了转换脚本必须两种都兼容。第二VIA 2.0.x 导出的顶层size字段存的是文件字节数不是宽高尺寸得自己用 PIL 去读原图这一步漏了后面算出的 bbox 全是乱的。labelme 那边的问题相对少但imagePath可能带子目录比如images/001.jpg而 COCO 里file_name必须和训练时拼路径的方式一致否则load_image直接抛FileNotFoundError。2.3 COCO 的四条硬约束COCO 是事实标准pycocotools 的解析比想象中严格。四条约束必须守住category_id从 1 开始0 预留给背景。matterport 版的 mask_rcnn 里NUM_CLASSES len(CLASS_MAP) 1如果你的类别也从 0 编第一类会和背景撞号训完永远检不出来。bbox是[x, y, w, h]不是[x1, y1, x2, y2]。手写转换脚本时忘了这一点框会缩到左上角一小块loss 表现是正常的因为 mask 分支还在学但检测分支彻底废了。area必须和segmentation一致。评估阶段 pycocotools 用area做尺寸分档small / medium / large你随手填个 0 或者填成 bbox 面积mAP 分档结果就没参考价值了。segmentation的扁平数组是多边形[x1, y1, x2, y2, ...]不是[x1, x2, ..., y1, y2, ...]。这个顺序错一次画出来的掩码会是麻花状肉眼在 TensorBoard 上一眼能看出来但脚本不报错。提示不确定格式对不对时先用pycocotools.coco.COCO(ann_file)加载一次再annToMask取一张出来叠加到原图上看三十秒能省三天。3. 标注现场类别表、坐标基准与多边形边界的三个决定格式选完就该动手了。真正决定样本质量的不是工具多先进是标注前定下的那几条规矩——类别怎么划、坐标按什么基准、边界到底压在哪。这三件事定错了标到第 500 张再改前面的全部作废。3.1 类别表与最小可标尺寸先写类别表再开标注工具。表里包含类别英文名用于 COCO 的name、中文注释、id从 1 递增、判定标准一句话、典型误标案例一张。判定标准那一栏最容易敷衍实际必须写到能和相邻类别区分开的粒度。比如「货车」和「厢式货车」如果业务上不需要分开就合并成一类分开了但标注员各有各的理解训出来的模型会在两类之间反复横跳混淆矩阵上表现为对称的高误检。最小可标尺寸同样要提前定。常见做法是目标短边在原图上小于 16 像素的直接标ignore或者整张图不入库。原因是 mask_rcnn 默认IMAGE_MIN_DIM800、IMAGE_MAX_DIM1333一张 4000 宽的图会被压到 1333缩放比约 0.33原本 16 像素的目标只剩 5 像素RPN 阶段就没了标了也是噪声。长尾类别要单独定策略少于 50 个实例的类别优先补数据而不是调参实在补不上就在训练时降采样别硬塞进NUM_CLASSES里让它拖累整体。3.2 图像预缩放与坐标基准的统一坐标基准这件事团队里只要有两个人就一定会出分歧。有人拿原图开标有人先把图缩到 1920 长边再标最后混在一起同一类目标在两批数据里的尺度差一倍。我的做法是原图只读标注一律在原图坐标系下进行。如果原图太大导致网页端卡顿先离线批量生成一份 1920 长边的预览图标完再把坐标按比例乘回去。比例因子记在文件名或者单独的 manifest 里别靠脑子记。import os from PIL import Image SRC, DST raw_images, preview_1920 LONG_SIDE 1920 os.makedirs(DST, exist_okTrue) manifest {} for fn in os.listdir(SRC): if not fn.lower().endswith((.jpg, .png)): continue with Image.open(os.path.join(SRC, fn)) as im: w, h im.size scale LONG_SIDE / max(w, h) if scale 1.0: # 只缩不放避免插值放大造假细节 im im.resize((round(w * scale), round(h * scale)), Image.LANCZOS) im.save(os.path.join(DST, fn), quality95) else: scale 1.0 im.save(os.path.join(DST, fn)) # 记录缩放比转换时用它把坐标还原到原图 manifest[fn] {orig_w: w, orig_h: h, scale: round(scale, 6)} import json json.dump(manifest, open(preview_manifest.json, w), indent2)这段脚本做了三件事只对长边超过 1920 的图做等比缩小短边不裁切裁切会丢目标且坐标还原时对不上用 LANCZOS 重采样边缘保留比默认的双线性好把每张图的原始尺寸和缩放比写进 manifest。后面转换脚本读到 VIA 的坐标时先除以scale还原到原图坐标再去算 bbox——如果训练时是按原图跑的这步不能省。参数上LONG_SIDE不要设成和IMAGE_MAX_DIM一样。标注预览图的意义是让人看清楚1333 太小了1920 是个比较舒服的值。quality95是为了避免 JPEG 压缩在细边缘产生块效应标注员会把压缩伪影当成目标边界。3.3 点选顺序、自交与遮挡边界的处理多边形是闭合的点的顺序本身不影响掩码顺时针逆时针都行但自交会。pycocotools 的frPyObjects在遇到自交多边形时用的是奇偶填充规则画出来的掩码会出现空洞或者只保留一半area也跟着偏小。这个错误在标注工具里看不出来——工具渲染用的是另一套填充算法显示是满的转成 COCO 就废了。规避办法很土但有效点选时沿一个方向绕圈别来回跨。遇到凹陷结构比如人叉腰、车轮和车身的夹角宁可多点几个点把凹口画出来也别让两条边走成 X 形。遮挡的处理要统一。目标被挡住 30% 以内按可见轮廓画被挡部分用直线连过去挡住超过 50%标成可见部分并单独打一个occluded标签训练时视情况决定是否排除。最忌讳的是团队里一半人按可见轮廓画、一半人按推测的完整形状画模型学到的边界信号自相矛盾。边界压线的问题也得说清楚目标轮廓压在图像边缘时多边形要贴着图像边界走坐标不能超出[0, w-1]和[0, h-1]。超出的点会让frPyObjects算出畸形掩码面积虚高评估时这一类的 AP 会莫名其妙地低。4. 灌进 mask_rcnnVIA/labelme 转 COCO 的两个脚本与三道自检到这一步标注文件躺在文件夹里训练脚本要的是 COCO。中间这一跳是整个流程里最容易出错的地方而且错了往往不报错只是训得不好。下面两个脚本加一组自检建议直接抄。4.1 VIA JSON 转 COCO 的脚本先处理 VIA 的两个历史包袱regions结构不固定size字段含义不固定。import json, os import numpy as np from pycocotools import mask as mask_utils # 与训练配置里的 NUM_CLASSES len(CLASS_MAP) 1 严格对应 CLASS_MAP {person: 1, car: 2, bicycle: 3} def _iter_images(via): 兼容三种导出裸 dict / 带 _via_img_metadata / regions 为 list 或 dict root via.get(_via_img_metadata, via) for _, item in root.items(): if not isinstance(item, dict) or filename not in item: continue # 跳过 _via_settings、_via_attributes 等元数据 regions item.get(regions, []) if isinstance(regions, dict): # VIA 1.x / 部分 2.x 导出 regions list(regions.values()) yield item, regions def _img_hw(item, img_dir): size item.get(size) if isinstance(size, (list, tuple)) and len(size) 2: return int(size[1]), int(size[0]) # 部分导出是 [w, h] from PIL import Image # VIA 2.0.x 的 size 是文件字节数 with Image.open(os.path.join(img_dir, item[filename])) as im: return im.height, im.width def via2coco(via_path, img_dir, out_path, manifestNone, class_mapCLASS_MAP, min_area16.0): root json.load(open(via_path, encodingutf-8)) images, anns, skip [], [], {cls: 0, poly: 0, tiny: 0} img_id ann_id 1 for item, regions in _iter_images(root): fname os.path.basename(item[filename]) h, w _img_hw(item, img_dir) images.append({id: img_id, file_name: fname, width: w, height: h}) scale (manifest or {}).get(fname, {}).get(scale, 1.0) for reg in regions: sa reg.get(shape_attributes, {}) ra reg.get(region_attributes, {}) name ra.get(class) or ra.get(label) if sa.get(name) ! polygon or name not in class_map: skip[cls] 1 continue # 1) 还原到原图坐标 2) 裁进图像范围 xs [min(max(float(x) / scale, 0.0), w - 1.0) for x in sa[all_points_x]] ys [min(max(float(y) / scale, 0.0), h - 1.0) for y in sa[all_points_y]] if len(xs) 3: skip[poly] 1 continue poly [round(v, 2) for p in zip(xs, ys) for v in p] # 扁平交织 rle mask_utils.merge(mask_utils.frPyObjects([poly], h, w)) area float(mask_utils.area(rle)) if area min_area: # 退化多边形或误点 skip[tiny] 1 continue anns.append({ id: ann_id, image_id: img_id, category_id: class_map[name], segmentation: [poly], area: round(area, 2), bbox: [round(float(v), 2) for v in mask_utils.toBbox(rle)], iscrowd: 0, }) ann_id 1 img_id 1 # 无标注的图也保留作为负样本 coco {images: images, annotations: anns, categories: [{id: v, name: k, supercategory: none} for k, v in class_map.items()]} json.dump(coco, open(out_path, w, encodingutf-8), ensure_asciiFalse) print(fimages{len(images)} anns{len(anns)} skip{skip})几个关键点。bbox和area都从 RLE 反算不手写——toBbox返回的已经是[x, y, w, h]且和掩码一致这是省掉一整类 bug 最便宜的办法。min_area16是经验值低于 16 平方像素的实例在 1333 边长下基本不可见留着只会污染 loss。img_id 1在循环外意味着即使一张图没有任何标注它也会进images数组这对训练很重要——mask_rcnn 需要负样本告诉 RPN 什么是背景。manifest参数就是 3.2 节生成的那份只有用了预览图标注才需要传。4.2 labelme 转 COCO 的脚本labelme 的结构干净得多主要差异是它已经存了imageHeight/imageWidth不用再读图points直接是[[x, y], ...]先去重再扁平化。def labelme2coco(json_dir, out_path, class_mapCLASS_MAP): images, anns [], [] files sorted(f for f in os.listdir(json_dir) if f.endswith(.json)) for img_id, fn in enumerate(files, 1): data json.load(open(os.path.join(json_dir, fn), encodingutf-8)) h, w data[imageHeight], data[imageWidth] # imagePath 可能带子目录COCO 里统一成文件名 images.append({id: img_id, file_name: os.path.basename(data[imagePath]), width: w, height: h}) for shape in data[shapes]: if shape.get(shape_type) ! polygon: continue # rectangle / circle 一律跳过 if shape[label] not in class_map: print(f[skip] {fn} 未知类别 {shape[label]}) continue # 相邻重复点会让多边形退化先按 0.5 像素去重 pts, last [], None for x, y in shape[points]: if last is None or abs(x - last[0]) abs(y - last[1]) 0.5: pts.append((float(x), float(y))) last (x, y) if len(pts) 3: continue poly [round(v, 2) for p in pts for v in p] rle mask_utils.merge(mask_utils.frPyObjects([poly], h, w)) anns.append({ id: len(anns) 1, image_id: img_id, category_id: class_map[shape[label]], segmentation: [poly], area: round(float(mask_utils.area(rle)), 2), bbox: [round(float(v), 2) for v in mask_utils.toBbox(rle)], iscrowd: 0, }) json.dump({images: images, annotations: anns, categories: [{id: v, name: k, supercategory: none} for k, v in class_map.items()]}, open(out_path, w, encodingutf-8), ensure_asciiFalse) print(fimages{len(images)} anns{len(anns)})shape_type的过滤不能省。labelme 支持矩形、圆形、线段、点标注员手滑用了矩形转换后points只有两个点frPyObjects会算出面积为零的掩码还混在数据里查不出来。4.3 转换后的三道自检转换脚本跑完打印images1200 anns3400不代表数据可用。下面这段检查必须在接入训练前跑一遍。import json from collections import Counter from pycocotools import mask as mask_utils def shoelace(pts): s 0.0 for (x1, y1), (x2, y2) in zip(pts, pts[1:] pts[:1]): s x1 * y2 - x2 * y1 return abs(s) / 2.0 def check_coco(path, min_per_class20): d json.load(open(path, encodingutf-8)) img {i[id]: i for i in d[images]} cats {c[id] for c in d[categories]} bad [] if 0 in cats: bad.append((global, category_id 含 0会和背景撞号)) for a in d[annotations]: im img.get(a[image_id]) if im is None: bad.append((a[id], image_id 悬空)); continue if a[category_id] not in cats: bad.append((a[id], category_id 不在类别表)) x, y, w, h a[bbox] if x 0 or y 0 or x w im[width] 0.5 or y h im[height] 0.5: bad.append((a[id], bbox 越界)) if a[area] 0: bad.append((a[id], area0)); continue poly a[segmentation][0] pts list(zip(poly[0::2], poly[1::2])) if len(poly) % 2 or len(pts) 3: bad.append((a[id], 多边形点数异常)); continue # 自交检测多边形面积和掩码面积差超过 5% 基本就是自交了 if abs(shoelace(pts) - a[area]) / max(a[area], 1e-6) 0.05: bad.append((a[id], 疑似自交shoelace 与 mask 面积不一致)) cnt Counter(a[category_id] for a in d[annotations]) for cid in sorted(cats): if cnt.get(cid, 0) min_per_class: print(f[长尾] category_id{cid} 仅 {cnt.get(cid, 0)} 个实例) print(每类实例数:, {c: cnt.get(c, 0) for c in sorted(cats)}) print(f异常 {len(bad)} 条, bad[:10]) return bad三道自检对应三类事故id 与类别表错位训练不报错但 mAP 恒低、bbox 越界load_mask时随机崩、多边形自交掩码出空洞。shoelace和掩码面积对比这一招特别管用——自交多边形的有向面积会小于实际填充面积差 5% 以上基本可以确定。4.4 接进 Dataset 的三个配置项COCO 文件没问题了接到 matterport 版 mask_rcnn 上还有三个参数值得改class SampleConfig(Config): NAME sample NUM_CLASSES len(CLASS_MAP) 1 # 必须 10 是背景 IMAGE_MIN_DIM 800 # 短边下限小目标多就调到 1024 IMAGE_MAX_DIM 1333 # 长边上限和显存强相关 USE_MINI_MASK False # 细长目标必须关掉见 5.3 MINI_MASK_SHAPE (56, 56) IMAGES_PER_GPU 2 # 1333 边长下 11G 显存的经验值IMAGE_MIN_DIM和IMAGE_MAX_DIM决定了缩放比。原图长边 4000、MAX_DIM1333缩放比 0.33如果你的目标平均短边只有 30 像素缩放后剩 10 像素基本废了。这种情况要么把MAX_DIM提到 2000 并降IMAGES_PER_GPU要么回到第 3 章重定最小可标尺寸。5. 避坑与排查五个让 mAP 归零的标注事故下面五条都是踩过的按「现象 → 原因 → 解决」写碰到了直接对号入座。5.1 训练正常但 mAP 恒为 0.00x现象loss 曲线平滑下降分类分支也收敛但验证集所有类别的 AP 都在 0.001 量级可视化预测结果几乎全是背景。原因category_id从 0 开始编号。matterport 的Dataset把 0 当背景第 0 类被当成背景学习其余类别整体偏移一位评估时pycocotools按 id 匹配对不上就是全零。解决转换脚本里断言0 not in cats见 4.3 的check_coco。已经训完的模型不用重训把 COCO 的category_id整体加一、NUM_CLASSES不变重跑评估即可验证。5.2 掩码糊成一整块两个目标连在一起现象预测结果里相邻的两辆车被一个掩码盖住或者一个人身上的掩码带了半个背包。原因标注时把多个实例画进了同一个多边形或者多边形自交导致奇偶填充规则下内部被填满。前者是规范问题后者是几何问题表现很像。解决先跑 4.3 的自交检测把可疑 id 挑出来单独看。规范层面在标注工具的说明里写死一条一个多边形只能对应一个实例对象同一类别的两个目标必须是两个 region哪怕它们贴在一起。5.3 细长目标在训练中凭空消失现象电线杆、栏杆、细长工件这类目标标注明明有几百个训出来的模型一个都检不出来但同类别的粗壮目标正常。原因USE_MINI_MASKTrue默认值会把掩码按 bbox 裁出来再缩放到 56×56 的固定尺寸。一个 20×800 的细长目标缩放后短边只剩 1 个像素掩码信息在进 loss 之前就没了。解决USE_MINI_MASK False代价是显存占用上升约 30%IMAGES_PER_GPU相应下调。或者把MINI_MASK_SHAPE提到(112, 112)折中一下。5.4 训练启动即报文件找不到现象FileNotFoundError: raw_images/xxx/images/001.jpg路径里多了一层目录。原因labelme 的imagePath保留了标注时的相对路径转换脚本直接写进了file_name而训练时的IMAGE_DIR是扁平目录。解决转换时统一os.path.basename()见 4.2。已经生成的 COCO 可以用一行改回来把每个file_name取 basename 后重新 dump。另外注意中文路径cv2.imread在部分环境返回 Noneload_image会静默拿到空数组最好在 Dataset 初始化时校验一遍前 10 张图能否读出。5.5 背景图被全部删掉误检率飙升现象模型在干净背景上乱框置信度还挺高实际业务场景里误报严重。原因整理数据时觉得「没有目标的图没用」把所有无标注图删了。RPN 只见过带目标的图没学过「整张图都是背景」这个样本。解决无标注图必须保留比例控制在 10% 到 20%。转换脚本里img_id 1写在 region 循环外就是为了这个。已经删掉的话从原始数据里捞回来补进images数组annotations里不给它任何条目即可。6. 用预测结果反查标注三个抽样技巧和一个过拟合自检标注质量靠人眼看是靠不住的四百张之后眼睛就麻了。真正有效的验证是让模型反过来告诉你哪张图标错了。第一个技巧是叠加预测掩码做盲检。拿一个训到一半的模型比如 2 个 epoch在训练集上推理把预测掩码以 40% 透明度画回原图人工只看「预测和标注不一致」的位置。模型在训练集上错误的地方八成是标注本身有问题——要么漏标要么边界画歪。这个方法比随机抽查命中率高得多因为模型已经帮你把可疑样本筛出来了。我一般一次导出 50 张拼成 5×10 的网格图半小时能扫完一轮。第二个技巧是按掩码面积分箱抽样。把实例按area排序取最小的 10%、中位数附近 10%、最大的 10% 各抽 20 张。小面积箱里高频出现的是「漏标的远处小目标」和「误点产生的碎片多边形」大面积箱里常见的是「多个实例被合并成一个」。中间箱反而最干净不用花时间。第三个技巧是看 mAP0.5 和 mAP0.75 的差值。差值小于 0.1说明边界画得还算准问题在漏检和类别混淆差值大于 0.25说明框对了但掩码边界系统性偏移通常是多边形点太少或者标注员赶工需要回到 3.3 节重讲一遍点选规范。这个指标比单看 mAP 有信息量得多而且不需要额外工具。最后是一个我每次交付前必跑的过拟合自检从训练集里抽 8 张图用IMAGES_PER_GPU1、学习率 1e-4、冻结 backbone训 200 个 step然后在这 8 张图上推理。正常情况下 mAP 应该接近 1.0掩码边缘和标注基本重合。如果只有 0.6 左右说明标注本身有矛盾——同一类目标在不同图里边界定义不一致模型学不出来。这个测试只要十几分钟比训完一整天再发现数据有问题划算太多。从那以后我每批标注交付前都会强制走一遍check_coco加这 8 张图的过拟合测试两个都过了才允许进训练队列。这个习惯帮我省掉的返工时间比调参调出来的那点收益多得多。希望帮到你。本文还有配套的精品资源点击获取