资讯详情

资讯详情

自定义数据集全景分割:从COCO格式转换到Detectron2训练全流程

做全景分割模型选型倒不是最费劲的真正磨人的是数据。Detectron2虽然自带了一套COCO全景分割的评估体系但一旦你想训练自己的数据集就得先搞清楚一件事你的标注格式跟Detectron2需要的panoptic格式并不是一回事。这个项目最核心的工作就是把自定义的COCO instance标注转换成COCO panoptic标注然后在Detectron2里跑通训练。整个过程包含了标注规范设计、格式转换脚本编写、数据集注册、以及一堆训练时的坑我在这里完整复盘一遍。1. 先弄明白全景分割到底要什么样的标注1.1 从每个目标一个掩码到每个像素都得有归属实例分割标注长这样一张图片里有几个目标每个目标有一个polygon或者RLE掩码互相之间独立甚至可以重叠。全景分割不一样它要求输出一个“无重叠、全覆盖”的分割图图片里的每个像素要么属于一个thing有具体个数的东西比如人、车、猫要么属于一个stuff没有固定个数的材质或背景比如天空、草地、墙面不能有空洞不能有重叠。这意味着你的训练数据也必须满足这个约束。如果你手里只有实例分割标注比如用LabelMe标了polygon那么你需要回答几个问题两个目标的掩码重叠了怎么办背景区域天空、道路要不要单独标一个“天空”类别可能是一个连贯的大区域在实例json里没有对应的annotation那全景分割的ground truth怎么生成答案就是转换格式的时候把所有非thing的区域也当作stuff类别的掩码填进去并且把重叠的部分按照某种规则通常是面积大的在下面重新切分干净。1.2 thing和stuff的区分直接决定类别ID分配在COCO panoptic格式里thing和stuff的分类是硬编码在元数据里的。每个类别都有一份thing/stuff标记thing类别有“实例”概念每个实例单独一个segmentstuff类别没有实例概念整个图片里同一种stuff类别就是一个segment即使它是几个不相连的碎片也必须共用同一个segment id。这个区分对训练影响很大。类别ID在实例json里只是一个整数但在panoptic的segment_id编码里thing和stuff的取值规则不同。看起来只是格式转换实际上如果不先定义清楚哪些类是thing、哪些是stuff后面分配segment_id就会乱。1.3 官方COCO panoptic的格式约定COCO panoptic标注由两部分组成一个JSON文件存放每个图片的segments_info另一组PNG图片每个像素直接存segment_id。像素值并不是随便取的官方约定是segment_id category_id × 1000 instance_id其中stuff类别的instance_id永远是0thing类别从1开始编号。举个例子如果你有“天空”类别id21那么天空这个stuff segment的id就是21000如果你有“人”类别id1图片里第一个人的id就是1001第二个人是1002。这个编码保证了同一张图里不会有两个segment撞车。注意不同图片之间不需要全局唯一每张图可以重新从1开始给实例编号。这个规则我们在转换时最核心的部分就是遵守它。2. 自定义数据集的前期准备从图像采集到实例标注2.1 图像筛选别一开始就搞地狱难度训练全景分割的数据比分类、检测要“娇气”得多。我踩过的第一个坑就是选了太多复杂场景的大图导致标注工作量爆炸。建议先收集一批画面相对干净的图每张图里目标数量控制在5个以内背景种类不要超过3种光照均匀没有强遮挡。先把训练流程跑通再逐步增加难度。图片分辨率统一调整到合适的尺寸比如高度1280左右宽度按比例缩放。太大会让标注工具卡顿太小则细节丢失影响mask质量。文件命名不要用中文也不要带奇怪的空格。我是用类似img_00001.jpg的格式方便后续脚本处理。最忌讳的是把不同场景的图混在一个文件夹里还不记录来源后面做验证集划分时会很痛苦。2.2 标注工具选型与标注建议标注实例分割最常见的开源工具是LabelMe。它导出的是JSON格式包含每个标注的多边形坐标。对全景分割来说这个格式够用了只要你在标注时遵守几个额外规则。每个object的label必须是你预定义的类别名不要顺便做实例命名因为你还需要在导出时把类别名映射到整数category_id。同一类别可以有多个实例LabelMe会把它们逐个记录为多边形这些多边形可以在空间上重叠但你心里要有数最终转换时有一个优先级问题。如果图像里有一整块“无标注区域”默认它不会被任何mask覆盖。转换时我们需要把未覆盖区域填充成某个stuff类别这就需要在标注时约定一个“背景类别”或者“忽略类别”。我推荐的做法是单独标注一个覆盖整个图像的多边形类别设为背景导出后会有一个大的stuff实例。这样转换时所有像素都有归属避免生成空洞。如果你确实不想额外标注背景也可以在转换脚本里把未覆盖像素统一填给某个stuff类别。但这样那个stuff类别会包含所有“没标”的东西语义不纯模型学起来会困惑。2.3 从LabelMe JSON到COCO instance JSONDetectron2的register_coco_panoptic要求你同时提供一份COCO instance格式的JSON用来在训练时做实例分割相关的loss如果你使用Mask R-CNN系列以及评估用。所以需要把LabelMe的JSON转成COCO instance格式。这一步不复杂但要注意images字段包含id、file_name、width、height。annotations字段每个实例的id、image_id、category_id、segmentationpolygon列表、area、bbox、iscrowd。categories字段包含id和name这里就要区分thing和stuff了。转换脚本我建议自己写因为后面panoptic转换也要用同一套解析函数。核心代码可以是这样import json import os from labelme import utils # labelme工具库 def labelme_to_coco(image_dir, label_dir, category_map): images, annotations [], [] ann_id 1 for idx, filename in enumerate(os.listdir(label_dir)): if not filename.endswith(.json): continue with open(os.path.join(label_dir, filename)) as f: label_data json.load(f) img_path os.path.join(image_dir, label_data[imagePath]) h, w label_data[imageHeight], label_data[imageWidth] image_id idx images.append({id: image_id, file_name: os.path.basename(img_path), width: w, height: h}) for shape in label_data[shapes]: label shape[label] category_id category_map[label] points shape[points] # 转成COCO的polygon格式一维列表 segmentation [coord for point in points for coord in point] # 计算area和bbox需要用到pyclipper或简单栅格化 annotations.append({ id: ann_id, image_id: image_id, category_id: category_id, segmentation: [segmentation], area: compute_area(points), bbox: compute_bbox(points), iscrowd: 0 }) ann_id 1 return {images: images, annotations: annotations, categories: [...]}保证每个annotation的id在全局唯一image_id对应到图片。生成的instance JSON先保存一份例如custom_instances.json后面转换panoptic时会用到。3. 核心转换从COCO instance到COCO panoptic3.1 转换思路两张信息合成一张图整个过程可以概括成三步读取实例JSON里的所有mask为每个mask分配segment_id然后按顺序把不同segment写到一张单通道PNG里。难点在于怎么处理重叠以及怎么把stuff类别的多个碎片合并成同一个segment。我用的方案是栅格化所有多边形到二值mask然后创建一个全零的int32数组作为panoptic画布。画布尺寸是原图的宽乘高。接下来按照每个annotation的面积从大到小排序。大的先填充小的后填充这样如果两个mask重叠小的会覆盖在大对象的上方。这个规则比较符合真实遮挡关系小东西通常离相机更近或者被标注者特意勾出。当然也可以自定义但关键是要保证最终每个像素只有一个segment值。3.2 segment_id分配thing和stuff分开来分配segment_id的代码逻辑是这样的def get_segment_id(category_id, instance_counters, is_thing): if not is_thing: return category_id * 1000 instance_counters[category_id] instance_counters.get(category_id, 0) 1 return category_id * 1000 instance_counters[category_id]注意stuff类别直接返回category_id * 1000不管它有几个碎片。thing类别呢我们需要一个按类别递增的计数器这样每个图片里同一个thing类的不同实例instance_id是1、2、3……不要担心跨图片图片间可以重新计数。但有一个细节categories里的stuff类别可能在实例JSON里根本没有对应annotation。比如“路面”这个stuff如果没有标注它永远不会出现在instance JSON里。所以转换时还需要处理未被任何mask覆盖的像素。我的做法是准备一个默认背景stuff类别比如{category_id: 0, name: background}或者选一个合适的stuff类别。在把所有标注mask按面积排序填完之后画布中仍然为0的像素就全部填成这个默认背景的segment_id。我通常会定义一个类别ID比较特殊的背景类别比如category_id255这样segment_id255000不容易和真实类别冲突。但如果用COCO evaluator所有类别id必须在1~N范围内所以最好还是使用一个在categories列表中的有效stuff类别并确保它覆盖整图语义。3.3 生成panoptic PNG用对数据类型才不丢值之前我吃过亏用uint8保存PNG结果所有segment_id超过255就崩了。正确做法是用numpy int32数组然后通过PIL保存为单通道32位PNGfrom PIL import Image panoptic_img np.zeros((h, w), dtypenp.int32) # 填充segment_id... Image.fromarray(panoptic_img, modeI).save(out_png_file)PIL的modeI是32位整数单通道能够存储我们的segment_id。Detectron2读取的时候用的是Image.open转成numpy数组后值不会丢失。有一点要提醒很多图像查看器打开这种PNG会显示成空白这是正常的因为像素值不是0-255范围别误以为是转换失败。3.4 segments_info计算面积和bbox每个segment生成后需要计算它实际的面积和bbox。面积可以是像素数量这个数值要和之前instance JSON里的area保持一致但如果你调整过重叠最终面积可能略有变化。因为最终训练时Detectron2用的是panoptic PNG算出来的mask保存的segments_info里面积不准确影响不大但最好做到一致。计算方法是indices np.argwhere(panoptic_img segment_id) area len(indices) bbox [indices[:,1].min(), indices[:,0].min(), indices[:,1].max() - indices[:,1].min() 1, indices[:,0].max() - indices[:,0].min() 1]注意bbox格式是[x, y, width, height]从0开始。3.5 转换脚本核心代码我把核心转换函数贴出来这个结构可以直接套用到自己的数据上import json import numpy as np from PIL import Image from pycocotools import mask as coco_mask from pycocotools import mask as coco_utils def decode_polygon(segmentation, h, w): rle coco_mask.frPyObjects(segmentation, h, w) return coco_mask.decode(coco_mask.merge(rle)) def convert_instances_to_panoptic(instance_path, panoptic_json_out, panoptic_root, thing_categories, stuff_categories): with open(instance_path) as f: data json.load(f) # 聚合annotations anns_by_image {} for ann in data[annotations]: anns_by_image.setdefault(ann[image_id], []).append(ann) panoptic_annotations [] os.makedirs(panoptic_root, exist_okTrue) for img_info in data[images]: img_id img_info[id] h, w img_info[height], img_info[width] anns anns_by_image.get(img_id, []) canvas np.zeros((h, w), dtypenp.int32) # 按面积从大到小排序 anns_sorted sorted(anns, keylambda x: x[area], reverseTrue) instance_counters {} segments_info [] for ann in anns_sorted: cat_id ann[category_id] is_thing cat_id in thing_categories if is_thing: instance_counters[cat_id] instance_counters.get(cat_id, 0) 1 seg_id cat_id * 1000 instance_counters[cat_id] else: seg_id cat_id * 1000 mask decode_polygon(ann[segmentation], h, w) canvas[mask 0] seg_id # 更新segments_info mask_bool mask.astype(bool) if mask_bool.any(): ys, xs np.where(mask_bool) bbox [int(xs.min()), int(ys.min()), int(xs.max()-xs.min()1), int(ys.max()-ys.min()1)] segments_info.append({ id: seg_id, category_id: cat_id, area: int(mask_bool.sum()), bbox: bbox, iscrowd: 0 }) # 对stuff的多个碎片合并这里用简单方式如果同一个stuff category有多个ann # 前面已经重复填充同一个seg_id但segments_info可能重复需要按seg_id合并。 # 后面再统一合并一次segments_info。 merged_segments {} for seg in segments_info: seg_id seg[id] if seg_id in merged_segments: merged_segments[seg_id][area] seg[area] # bbox取并集 x1 min(merged_segments[seg_id][bbox][0], seg[bbox][0]) y1 min(merged_segments[seg_id][bbox][1], seg[bbox][1]) x2 max(merged_segments[seg_id][bbox][0] merged_segments[seg_id][bbox][2], seg[bbox][0] seg[bbox][2]) y2 max(merged_segments[seg_id][bbox][1] merged_segments[seg_id][bbox][3], seg[bbox][1] seg[bbox][3]) merged_segments[seg_id][bbox] [x1, y1, x2-x1, y2-y1] else: merged_segments[seg_id] seg.copy() segments_info list(merged_segments.values()) # 保存PNG png_filename fpanoptic_{img_id}.png Image.fromarray(canvas, modeI).save(os.path.join(panoptic_root, png_filename)) panoptic_annotations.append({ image_id: img_id, file_name: png_filename, segments_info: segments_info }) panoptic_json { images: data[images], annotations: panoptic_annotations, categories: data[categories] } with open(panoptic_json_out, w) as f: json.dump(panoptic_json, f)需要注意上面的代码里stuff类别的多个annotation在填充时用的是同一个seg_id所以它天然合并成一个segment了。但多个碎片可能来自多个annotation它们在前面的排序中会用同一个seg_id填进canvas没问题。同时segments_info的合并逻辑也要保留。3.6 转换后的验证这一步千万别跳过。我见过很多人转换完就直接训练结果训练到一半发现PNG是全黑的或者segments_info里面积全是0。先做一个可视化验证panoptic_info json.load(open(panoptic.json)) seg panoptic_info[annotations][0] # 随便看一张 img_array np.asarray(Image.open(os.path.join(panoptic_root, seg[file_name]))) print(img_array.min(), img_array.max(), len(seg[segments_info])) unique_ids np.unique(img_array) print(unique_ids)同时最好和原始实例分割图像叠在一起看确认每个实例的轮廓保留了下来。也可以写一个小的着色函数给PNG上色看场景是否合理。如果发现某类stuff没有被填充或者canvas里还有0值说明存在未覆盖区域就要回溯标注阶段。4. 在Detectron2中注册自定义全景数据集4.1 目录结构要按Detectron2的习惯来Detectron2注册全景分割数据集时需要四个关键路径image_root、panoptic_root、panoptic_json、instance_json。其中instance_json在Detectron2的register_coco_panoptic中被用作读取实例分割标注用于计算Mask R-CNN分支的loss。整个目录组织可以是custom_dataset/ ├── images/ ├── panoptic/ ├── annotations/ │ ├── instances.json │ └── panoptic.json训练集和验证集分开注册成两个不同的dataset name。有人图省事只注册一个训练集评估也用训练集结果所有指标虚高完全没有参考价值。自定义数据规模小更要把验证集划好。4.2 注册函数和metadata注册代码很简单from detectron2.data.datasets import register_coco_panoptic register_coco_panoptic( my_dataset_train, metadatamy_metadata, image_rootcustom_dataset/images/train, panoptic_rootcustom_dataset/panoptic/train, panoptic_jsoncustom_dataset/annotations/panoptic_train.json, instance_jsoncustom_dataset/annotations/instances_train.json, evaluator_typecoco_panoptic, )同样的方式注册my_dataset_val。metadata是Detectron2的DatasetMetadata对象最核心的两个字段是thing_classes和stuff_classes。注意顺序要和你的category_id映射对起来Detectron2会从0开始索引。如果你在JSON里定义category_id为1,2,3...那么thing_classes列表的第一个元素就是category_id1的名字第二个是category_id2的名字以此类推。stuff_classes同理。如果你的类别ID不是连续的Detectron2会报错所以建议强制把自定义的类别都重映射成连续整数。4.3 快速测试数据加载注册完成后建议先写个脚本跑一次dataloader而不是直接启动训练。这么做是为了确认PNG能被正确读取、segment_id和segments_info对齐、类别数量符合模型配置。from detectron2.data import build_detection_train_loader from detectron2.config import get_cfg cfg get_cfg() # 简化配置只用默认值 cfg.DATASETS.TRAIN (my_dataset_train,) loader build_detection_train_loader(cfg) for batch in loader: # 打印batch里panoptic分割目标的shape和类别 print(batch[0][instances]._fields.keys()) break如果这一步报错最常见的是AssertionError: categories has duplicate category ids或Unknown stuff class多数是metadata没有对齐。耐心调整元数据映射。5. 训练自定义全景分割模型的参数与避坑记录5.1 基础配置复用COCO预训练权重Detectron2的自监督预训练权重非常多建议直接下载它提供的R-50-FPN预训练模型。全景分割模型在Detectron2里本质上是Mask R-CNN Semantic Head的组合预训练权重包含COCO实例分割和语义分割的部分。加载预训练权重时要防止类别数不匹配导致加载失败。一般做法是import detectron2.engine.defaults as defaults cfg.MODEL.WEIGHTS detectron2://COCO-PanopticSegmentation/panoptic_fpn_R_50_3x/... cfg.MODEL.ROI_HEADS.NUM_CLASSES num_thing_classes cfg.MODEL.SEM_SEG_HEAD.NUM_CLASSES num_stuff_classesMODEL.ROI_HEADS.NUM_CLASSES是thing类别数MODEL.SEM_SEG_HEAD.NUM_CLASSES是stuff类别数。这两个设置错一个训练时就会维度不匹配。5.2 数据增强别让背景把模型带偏全景分割中stuff类别的占比通常远大于thing类别。比如一张街道图墙面、地面可能占了80%像素但最终评估指标PQPanoptic Quality更关注thing的质量。为了平衡我在训练时开了Detectron2自带的RandomCrop、RandomFlip和RandomBrightness。特别推荐RandomCrop它可以裁剪出更丰富的局部组合减少stuff类别的大片重复。另外在线增强不要开太强毕竟我们的自定义数据集很小增强过猛容易把简单问题变复杂。5.3 类别不平衡与loss权重我训练自定义数据时loss曲线开始几轮下降得很慢后来发现是stuff类别的语义分割loss被大面积背景主导了。可以针对stuff类别设置一个较小的loss权重或者通过MODEL.SEM_SEG_HEAD.LOSS_WEIGHT调整。但如果你数据规模不大更简单的做法是调整训练时的model.iter不要一开始就把学习率拉太高。把基础学习率设置在0.0001左右batch size尽量小因为显卡显存限制Panoptic FPN的内存占用比普通Mask R-CNN高很多。5.4 评估与可视化评估可以使用Detectron2内置的COCOPanopticEvaluator它会输出PQ、SQ、RQ以及每个类别的PQ。如果你在训练过程中想直观地看效果周期性地在验证集上预测并保存可视化结果。把预测图与ground truth并排对比很多数据问题一眼就能看出来。我遇到过的情况是预测结果中stuff类别很好但thing类别边界被“吃”了一块。定位后发现是转换时重叠处理规则不对导致实例ground truth本身就缺了一块。这种问题单纯的PQ数值上看不出来必须目视检查。最后分享一点实践心得整个流程走下来最大的感受是格式转换不是一次性写完脚本就能跑的它需要反复验证。建议第一次先做10张图完成标注、转换、注册、训练迭代确认结果合理后再扩大数据集。可以用现成的COCO数据集做烟雾测试我最初就是用COCO里的两张图先跑通了转换和训练流程再接入自己数据。这样遇到问题能确定是标注问题还是代码问题。另外保存panoptic PNG时千万做好备份这个文件生成慢而且如果原始JSON改动后需要重新转换很花时间。现在我的流程是每次新增图片都跑一遍从LabelMe到COCO instance再到panoptic的完整管线所有代码都放在同一个仓库里用配置管理类别映射避免手动改来改去。这套流程算是比较磨人但跑通之后训练自定义全景分割模型就不再是玄学了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →