做全景分割,模型选型倒不是最费劲的,真正磨人的是数据。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类别有“实例”概念,每个实例单独一个segment;stuff类别没有实例概念,整个图片里同一种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永远是0,thing类别从1开始编号。
举个例子:如果你有“天空”类别id=21,那么天空这个stuff segment的id就是21000;如果你有“人”类别id=1,图片里第一个人的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 JSON
Detectron2的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、segmentation(polygon列表)、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 panoptic
3.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_id=255,这样segment_id=255000,不容易和真实类别冲突。但如果用COCO evaluator,所有类别id必须在1~N范围内,所以最好还是使用一个在categories列表中的有效stuff类别,并确保它覆盖整图语义。
3.3 生成panoptic PNG:用对数据类型才不丢值
之前我吃过亏,用uint8保存PNG,结果所有segment_id超过255就崩了。正确做法是用numpy int32数组,然后通过PIL保存为单通道32位PNG:
from PIL import Image panoptic_img = np.zeros((h, w), dtype=np.int32) # 填充segment_id... Image.fromarray(panoptic_img, mode='I').save(out_png_file)PIL的mode='I'是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_ok=True) 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), dtype=np.int32) # 按面积从大到小排序 anns_sorted = sorted(anns, key=lambda x: x["area"], reverse=True) 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 = f"panoptic_{img_id}.png" Image.fromarray(canvas, mode='I').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", metadata=my_metadata, image_root="custom_dataset/images/train", panoptic_root="custom_dataset/panoptic/train", panoptic_json="custom_dataset/annotations/panoptic_train.json", instance_json="custom_dataset/annotations/instances_train.json", evaluator_type="coco_panoptic", )同样的方式注册my_dataset_val。metadata是Detectron2的DatasetMetadata对象,最核心的两个字段是thing_classes和stuff_classes。注意顺序要和你的category_id映射对起来,Detectron2会从0开始索引。如果你在JSON里定义category_id为1,2,3...那么thing_classes列表的第一个元素就是category_id=1的名字,第二个是category_id=2的名字,以此类推。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%像素,但最终评估指标PQ(Panoptic 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的完整管线,所有代码都放在同一个仓库里,用配置管理类别映射,避免手动改来改去。这套流程算是比较磨人,但跑通之后,训练自定义全景分割模型就不再是玄学了。