1. 项目整体拆解与核心思路
拿到【Detectron2-Panoptic Segmentation训练自定义数据集】自定义数据集制作&coco instance→coco panoptic数据集格式转换这个标题的时候,我第一反应是:这哥们儿或者姐们儿大概率是已经被数据集格式卡住了一段时间,因为全景分割和普通的实例分割在数据组织上完全是两套逻辑,很多从语义分割或者检测转过来的人,第一次看到 panoptic 的 segments_info 和那个神秘的 PNG 标注文件时都是一脸懵的。
先把这个项目到底在做什么说清楚:我们要用 Detectron2 训练一个全景分割(Panoptic Segmentation)模型,并且不是跑官方的 demo,而是用我们自己的数据集。这就涉及两条核心链路:第一,从零制作一份能用于全景分割训练的自定义数据集;第二,最常见的现实情况——你手上已经有一批 COCO 格式的实例分割(instance segmentation)标注,需要把它们转换成 COCO 全景分割(panoptic segmentation)所需的格式。整个项目的内容核心,就是围绕“标注怎么来”“格式怎么转”“转换之后边界和重叠怎么处理”“Detectron2 的训练入口怎么接”这四件事展开的。
所谓全景分割,你可以把它理解成实例分割和语义分割的合并升级版:图像里的每个像素不仅要被分到某个类别,还要区分出“这是第几个实例”。比如一张街景图,语义分割会告诉你哪些像素是“车”,但分不清三辆车;实例分割只关心“车”这种 countable 的东西,却把天空、道路、建筑这些 stuff 类别晾在一边不管。全景分割则要求:所有的东西(things,比如人、车、动物)必须按实例分得清清楚楚,所有不可数的背景(stuff,比如天空、草地、墙)也必须每个像素都归属到类别上。也就是说,每个像素都得有标签,且每个像素只能有一个标签——这是全景分割数据格式设计和训练逻辑里最容易出问题的地方。
Detectron2 是某团队开源的检测与分割框架,PyTorch 生态里最接近“开箱即用”的那一档工具库。它把 Faster R-CNN、Mask R-CNN 等一堆模型用统一的配置系统包了起来,尤其对全景分割提供了相对完整的数据注册、训练、评估链路。项目选用 Detectron2,而不是从头写一个模型或者用别的框架,核心原因有三:一是它在 COCO 系列任务上预训练权重丰富,微调自定义数据集非常方便;二是它的数据注册机制允许你灵活地对接自定义格式;三是社区踩坑案例多,遇到问题能找到线索。
基于一套从业者视角的合理补全方案,我建议把这个项目拆成三个可独立验收的阶段:
阶段A:原始标注采集与确认(你是用标注工具画 mask?还是已经有一份 COCO instance json?) 阶段B:格式转换脚本(instance -> panoptic,重点解决 id 全局唯一、重叠冲突消除、stuff/thing 映射) 阶段C:Detectron2 数据集注册与训练跑通(验证是否能正常加载、评估指标是否合理)每个阶段都有各自的坑,下面逐个展开。
2. 自定义数据集制作——标注工具与格式规范
2.1 标注工具的选型与对比
先聊标注。全景分割的标注工作和实例分割在操作层面几乎是一模一样的,因为你依然是在每个实例上画多边形或者 Brush 涂抹。区别在于后续的数据组织,而标注工具不会替你区分 thing/stuff,这些都是你在导出后自己处理的。
当前主流标注工具其实是这么几个,我实际用下来各有各的脾气:
| 工具 | 标注形式 | 导出格式 | 适合场景 | 实战感受 |
|---|---|---|---|---|
| 某标注平台 | Brush/多边形 | COCO JSON / VOC | 需要多人协作、云端标注 | 付费体验好,格式导出最接近 COCO 标准,但要注意它导出的iscrowd字段有时候会漏 |
| 某桌面标注工具 | 多边形/魔棒/掩码 | COCO JSON / 自定义 | 个人或小团队,本地离线标注 | 免费开源,导出格式规范,但大图标注略卡 |
| 某在线开源标注工具 | 多边形/RLE | COCO JSON | 纯网页、轻量快捷 | 适合快速验证,但复杂轮廓效率低 |
| 写脚本半自动 | 程序化生成掩码 | 自定义 | 合成数据、规则数据 | 如果你是在做工业检测、合成场景,这一步能省大量人力 |
我的建议非常直接:如果你是个人项目,优先用某桌面标注工具,因为它导出的 COCO 格式 JSON 基本不需要二次修整,category_id、segmentation、bbox、area 全都齐全。如果你是用某标注平台,导出后留意iscrowd字段是否按要求写入,这个字段在后面映射到 panoptic 格式时会影响像素类别的 id 分配。
2.2 thing 和 stuff 的区分:标注前就要想清楚的类别规划
很多人在这个坑里挣扎了很久才意识到:全景分割在语义上要求你把所有类别明确标成 thing 还是 stuff。这是格式转换之前必须先做好的决策,否则后边的 category_id 映射会一团乱麻。
判断标准很简单:
- thing:可数、有独立轮廓、个体之间可分。例如 人、车、箱子、瓶子、狗。
- stuff:不可数、纹理或材质连续、通常无明确边界。例如 天空、道路、草地、墙壁、水。
但现实的边缘情况很折磨人。墙到底算 thing 还是 stuff?如果你要数有多少堵墙,它就变成了 thing;如果只是想把建筑外墙整体填上标签,那它就是 stuff。避坑建议是:从应用需求倒推。检测/跟踪密集关注的东西一律划为 thing;环境背景类一律划为 stuff。同一个项目里尽量不要让同一个类别一会儿 thing 一会儿 stuff,Detectron2 的评测协议和训练逻辑不会容忍这种骑墙行为。
在设计 labels 时要关注一个关键点:stuff 类别的 id 必须避让 thing 类别的 id。为什么这么强调?因为 panoptic 格式中每个像素的取值是一个segment_id,而segments_info里通过category_id把这个segment_id映射回类别。虽然从技术上说 thing 和 stuff 的category_id可以相同(毕竟靠isthing区分),但实际转换脚本和模型输出头里 easier 的做法是:将一个统一的 id 空间分配给所有类别,stuff 的 category_id 要么从某个偏移量开始,要么显式重新映射到完整类别列表的尾部。听我一句劝:在标注工具里就把 thing 和 stuff 的 id 定义成分离的区间(比如 thing 从 1 开始、stuff 从 100 开始),后面你的转换脚本能少写八层逻辑。
举个例子,假设你要做一个园区安防全景分割数据集,类别规划可以这样:
# 定义类别全集:index -> (name, isthing) categories = { 1: ("person", True), 2: ("car", True), 3: ("tricycle", True), 4: ("building", False), 5: ("road", False), 6: ("vegetation", False), 7: ("sky", False), }注意这里 id 并没有按 thing/stuff 分区间,因为迁移到 panoptic 格式时必须保证所有 id 在完整列表里唯一。这个列表其实就是你最终categories.json的基础。
2.3 一个零基础可复制的标注实操流程
以某桌面标注工具为例,我建议你这样组织工作流:
- 建任务,导入图像目录。图像越原生越好,尽量不要在标注之前做任何缩放和裁剪,因为分割掩码的坐标是依赖原图分辨率的。
- 在工具里定义一个类别的列表,确保和上面规划的
categories严格对应。 - 逐张图标注,核心快捷键操作多用:多边形追加点(A)、闭合(C)、保存下一张(D/S)、删除选中(Delete)。标注轮廓时从实例外边界逆时针/顺时针走一圈,不要只框大致区域,因为后续模型学的是边缘细节。
- 导出时选择 COCO 格式。工具会生成一个标注 JSON 和 images 目录。拿到 JSON 后用一段脚本快速自检一下字段完整度。
自检脚本的思路大概是:
import json with open("annotations/instances_train.json") as f: data = json.load(f) assert "images" in data and "annotations" in data and "categories" in data # 检查每个 annotation 是否包含 segmentation、category_id、bbox、area for ann in data["annotations"]: for key in ["segmentation", "category_id", "bbox", "area"]: assert key in ann, f"missing {key} in {ann['id']}" print("basic check passed") print("num images:", len(data["images"]))这里顺便说一个亲测有效的技巧:标注工作最怕返工,所以建议在正式开始前挑 5 张图,完整跑一遍“标注->导出->转 panoptic->Detectron2 加载可视化”的链路,确认全流程没问题再规模化标注。别等标了 500 张图之后才发现类别 id 全错,那感觉比熬夜改 bug 还难受。
3. COCO Instance 到 COCO Panoptic 格式转换的实现细节
3.1 两种格式到底差在哪
这是一个最核心的问题,我先用最直观的语言把两种格式的区别讲透。
COCO Instance JSON 结构里,每个annotation对应图像上的一个掩码:
{ "segmentation": {"counts": "xxx", "size": [height, width]}, "area": 1234, "iscrowd": 0, "image_id": 1, "bbox": [x, y, w, h], "category_id": 2, "id": 17 }注意:这里多个实例的掩码是相互独立的、允许重叠的,category_id是它们各自在类别列表中的索引,而且同一张图里可以有两个category_id=2的实例,这不会造成歧义,因为每个 annotation 是独立个体。
COCO Panoptic JSON 的结构完全不同,它的核心是一个 per-image 的segments_info列表,外加每张图对应一张与原始图像相同尺寸的 PNG 标注图:
{ "images": [ { "id": 1, "file_name": "0000000001.png", "width": 640, "height": 480 } ], "annotations": [ { "image_id": 1, "file_name": "0000000001.png", "segments_info": [ {"id": 1, "category_id": 2, "iscrowd": 0, "area": 1234, "bbox": [x, y, w, h]}, {"id": 234, "category_id": 5, "iscrowd": 0, "area": 567, "bbox": [x, y, w, h]} ] } ], "categories": [ {"id": 1, "name": "person", "isthing": 1, "color": [220, 20, 60]}, {"id": 2, "name": "car", "isthing": 1, "color": [119, 11, 32]} ] }而那个 PNG 标注图里,每一个像素的取值(0-65535 的无符号整型)就是segments_info中的id。也就是说,PNG 是像素级编码,JSON 是元数据投影,两者通过id严密对齐。这种设计使得模型在训练时能直接读一张整图得到每个像素的目标标签,而不是像实例分割那样要为每个实例单独读取一个 mask。
那转换要做哪些事情?本质就是:把多个独立的 mask 合并成一张单通道图,同时解决重叠、赋予全局唯一 id、重建segments_info。听着简单,实际暗坑不少。
3.2 转换的核心步骤:id 分配、重叠消解与边界处理
3.2.1 像素级掩码的提取与写入
第一步是把每个 annotation 的segmentation(可能是多边形 polygon 或 RLE)解码成二值掩码。COCO 官方工具pycocotools里的maskUtils.decode可以直接处理 RLE,但 polygon 需要先用maskUtils.frPoly转成 RLE 再 decode。我在实际项目里见过不少把 polygon 坐标 * 缩放系数当成像素坐标直接用、导致掩码错位的操作,这里顺手给一个防呆代码:
from pycocotools import mask as mask_util import numpy as np def decode_annotation(segmentation, height, width): if isinstance(segmentation, dict): # RLE 格式 rle = segmentation else: # polygon 格式,转成 RLE rle = mask_util.frPyObjects(segmentation, height, width) return mask_util.decode(rle).astype(bool)解码后就是两个维度对齐的 bool 矩阵。注意 polygon 转 RLE 时不需要rle = mask_util.merge(rle)这一步合并,frPyObjects已经把列表合并好了,decode直接输出一个完整的 mask。
3.2.2 为什么必须“后来者覆盖先来者”
多个实例的重叠是一个绕不开的现实问题。标注工具里你画了人,又在人的前方画了一块围栏,两个掩码在像素上天然重叠;或者两个实例边缘紧挨着,由于抗锯齿效应也会产生几个像素的重叠区。可是全景分割的 gold 标签中,每个像素只能有一个归属,所以必须定义消解规则。
通用规则其实没有百分百的标准答案,但广泛接受的策略是:按面积从小到大排序,后者覆盖前者。为什么?因为在语义上,面积更小的实例通常是前景中的具体目标,优先让它们保持完整,而大的 stuff 区域在被遮挡时被弃用。这也和模型训练的直觉相符——留面积小的实例,丢弃被覆盖的背景块。
具体实现可以这样:
# 针对一张图的多个 mask(已解码成 bool 矩阵的 list) # 每个 mask 配上它要写入的 segment_id 和面积 annotations = sorted(annotations, key=lambda a: a["area"]) # 小面积优先 panoptic = np.zeros((height, width), dtype=np.uint16) for ann in annotations: mask = decode_annotation(ann["segmentation"], height, width) # 只写入当前 mask 为 True 且尚未被占用的像素 idx = mask & (panoptic == 0) panoptic[idx] = ann["segment_id"]注意到这里我赋值了一个ann["segment_id"],它还不是原来的category_id,而是要为每个实例新分配的唯一 id。为什么要唯一?因为segments_info里每个id必须全局唯一,否则 PNG 里同一个像素值会有两个解释。通常做法是从 1 开始,每遇到一个实例就segment_id += 1,或者用(image_index << 16) + instance_index这种做法来保证不跨图冲突。
但是,这里有个非常隐蔽、且我在一个工程项目里踩了很久的坑:如果直接在排序后给每个实例分配连续 id,会遇到stuff合并问题。你想想,一张图里有三块“天空”,由于标注操作把它们画成了三个实例 mask,但语义上它们应该合并成一个 stuff 区域。如果不合并,你会得到三个不同的segment_id且都是 stuff 类别,评测时会算成三个假目标。所以在转换脚本里要对同类别且不相连的 stuff mask 做连通域合并。做法是:先把所有同 category_id 的 stuff mask 做逻辑或,再用scipy.ndimage.label标记连通域,取最大连通域作为这个 stuff 的唯一 mask。而 thing 则必须保留每个独立实例。
下面这段代码是我整理出的一个既有整体逻辑、又有细节防护的转换骨架(只展示核心数据结构切换,完整脚本放在项目代码里):
import numpy as np from scipy import ndimage from pycocotools import mask as mask_util import json def instance_to_panoptic(annotations, categories, height, width, image_id): """ annotations: list of dict, 每个是 COCO instance annotation categories: 完整类别列表, dict id -> {name, isthing, color} 返回 (uint16 矩阵, segments_info) """ # 一、先把 stuff 聚合 stuff_masks = {} stuff_ids_pool = set() for ann in annotations: cat_id = ann["category_id"] if not categories[cat_id]["isthing"]: # 合并到同类别大 mask mask = decode_annotation(ann["segmentation"], height, width) if cat_id not in stuff_masks: stuff_masks[cat_id] = np.zeros_like(mask) stuff_masks[cat_id] |= mask stuff_ids_pool.add(cat_id) # 二、把每个聚合后的 stuff mask 做连通域处理,保留最大域 stuff_instances = [] # 每个元素是 (mask, category_id) for cat_id, mask in stuff_masks.items(): labeled, num = ndimage.label(mask) if num == 0: continue # 统计各连通域面积 sizes = ndimage.sum(mask, labeled, range(1, num + 1)) big_component = np.argmax(sizes) + 1 stuff_instances.append((labeled == big_component, cat_id)) # 三、整理 thing 实例 thing_instances = [] for ann in annotations: cat_id = ann["category_id"] if categories[cat_id]["isthing"]: mask = decode_annotation(ann["segmentation"], height, width) thing_instances.append((mask, cat_id, ann.get("area", 0))) # 四、按面积排序,小面积优先写入(前景优先) all_instances = [] for mask, cat_id in stuff_instances: all_instances.append((mask, cat_id, None)) for mask, cat_id, area in thing_instances: all_instances.append((mask, cat_id, area)) all_instances.sort(key=lambda x: (x[2] is not None, x[2] if x[2] is not None else float("inf"))) panoptic = np.zeros((height, width), dtype=np.uint16) segments_info = [] next_id = 1 for mask, cat_id, area_ in all_instances: idx = mask & (panoptic == 0) if not idx.any(): continue panoptic[idx] = next_id area = int(idx.sum()) bbox = mask_util.toBbox(mask) # 返回 [[x, y, w, h]] segments_info.append({ "id": next_id, "category_id": cat_id, "iscrowd": 0, "area": area, "bbox": [float(v) for v in bbox[0]], }) next_id += 1 return panoptic, segments_info这段代码的“排序逻辑”这里有个细节要注意:我用(x[2] is not None, x[2] if x[2] is not None else float("inf"))做了双重排序,先把 stuff 放到前面(因为is not None为 False),然后再按面积排序。这样做的理由是:stuff 通常面积大,放在后面会被 thing 的 mask 覆盖,所以即使不显式排序,把 stuff 先填充,thing 后填充也能达到同样效果。但更稳妥的做法是统一按面积排序,让所有重叠区域的“归属权”交给更小的目标。
但等等,上面这段其实有个 bug,是我故意留出来提醒你的。thing_instances中的 area 来自原标注的area字段,但经过与 stuff 重叠之后实际面积已经变了。更靠谱的做法是在写入后重新用idx.sum()计算真实面积,上面 main 循环里我已经这么做了,但这个初始排序面积用的还是原始面积,所以在重叠较多时它的优先级并不完全准确。实践中我建议跳过这个优化,直接按“stuff 先写、thing 后写,thing 内部按面积从小到大写”,效果已经足够好,而且可预测性更强,方便你排查输出。
3.2.3 颜色表与 categories 的生成
segments_info搞定了,另一个必须生成的是categories。panoptic 格式的 categories 比 instance 多一个字段isthing,还自带一个可视化用的color:
categories_out = [] for cat_id, info in categories.items(): categories_out.append({ "id": cat_id, "name": info["name"], "isthing": 1 if info["isthing"] else 0, "color": info.get("color", [random.randint(0, 255) for _ in range(3)]) })这里color的作用是什么?它主要供可视化工具或你在评测时看预测结果用的,模型训练不会直接使用它。但如果你希望做一套稳定好看的可视化颜色,建议提前定义,不要随机生成——随机生成的调色板在连续看几百张图时眼睛会报废。
3.3 验证转换结果是否正确:一个保命的可视化脚本
脚本写完必须验证,别对自己的代码太自信。常规验证手段有三步,我从轻到重给你列出来:
第一步,检查数值范围。panoptic.max()必须小于等于65535,panoptic.dtype必须是uint16。如果掩码合并后数值溢出,训练时直接乱套。
第二步,检查面积一致性。写一个小函数,将segments_info里每项的area与 PNG 中对应id的像素数做比对,差距超过 1% 就报警。注意:由于抗锯齿或是解码精度问题,个别像素的偏差是正常的,但要警惕大面积不一致。
第三步,可视化重叠覆盖。把原始 instance 掩码的叠加图和新生成的 panoptic PNG 各自渲染出来,放在一起对比。重点看边缘和遮挡区域。下面这段代码可以把 panoptic PNG 渲染成彩色图:
import cv2 def render_panoptic(panoptic, categories_info): color_map = np.zeros((*panoptic.shape, 3), dtype=np.uint8) for seg in segments_info: mask = panoptic == seg["id"] color = categories_info[seg["category_id"]]["color"] color_map[mask] = color return color_map这里有个值得一提的经验:segments_info["id"]的取值顺序并不保证与 PNG 中像素值的分布顺序一致,所以渲染时直接按panoptic == id索引,不要假设 id 从小到大递增。
4. Detectron2 中的数据集注册、训练配置与实操
4.1 注册 Panoptic 数据集的关键参数
Detectron2 提供register_coco_panoptic_separated这个函数来同时注册 instance 和 panoptic 两套标注文件。这是因为它训练时需要实例分割的 box 信息作为 Region Proposal 的监督信号(在统一的分割框架中尤其如此),而 panoptic 文件提供 pixel-level 的标签。所以训练前你其实要准备四个文件:
train/ ├── images # 原始图像 ├── panoptic_train.json # panoptic 标注(含 segments_info) ├── panoptic_train/ # 每张图对应的 panoptic PNG ├── instances_train.json # 你的 instance 标注 JSON(原格式) └── stuffthing_train.json # 可选,但也常被要求存在这里最容易被忽略的是:panoptic_train.json中的images必须与instances_train.json中的images完全一致——id、file_name、width、height缺一不可。如果两边图像的 file_name 对不上,注册后训练时就会遇到“找不到图片”或“尺寸不匹配”的诡异报错。
注册代码长这样:
from detectron2.data.datasets import register_coco_panoptic_separated register_coco_panoptic_separated( "my_dataset_train", {}, "datasets/my_data/instances_train.json", "datasets/my_data/panoptic_train.json", "datasets/my_data/panoptic_train", "datasets/my_data/images" )这里有一个metadata参数是空的{}。许多人会忘了把它填上thing_classes和stuff_classes,导致模型头输出的类别总数不对,训练时直接维度不匹配。
from detectron2.data import MetadataCatalog MetadataCatalog.get("my_dataset_train").set( thing_classes=["person", "car", "tricycle"], stuff_classes=["building", "road", "vegetation", "sky"], )注意这里thing_classes和stuff_classes共同构成完整类别列表,且顺序必须与你在转换脚本里定义的 category_id 一致。register_coco_panoptic_separated会自动从panoptic_train.json的categories里读取isthing字段来区分 thing 和 stuff,但metadata里的类别名字列表依然不可省略,否则可视化时会有一堆空标签。
4.2 训练配置解析:从预训练权重到自定义数据集
Detectron2 强大的地方在于它的 lazy config 和默认配置系统,但这也意味着你必须学会精确地覆盖那些默认参数。
如果你的项目是 COCO 风格的开始,直接加载一个预训练模型权重是最优方案。官方提供的model_final_f10217.pkl(对应某个分割架构)是在 COCO panoptic 上训练的,有 80 thing + 53 stuff。但是你得硬着头皮改掉NUM_CLASSES而不是直接用默认值。
关键配置项如下:
from detectron2.config import get_cfg from detectron2 import model_zoo from detectron2.engine import DefaultTrainer cfg = get_cfg() cfg.merge_from_file(model_zoo.get_config_file("COCO-PanopticSegmentation/panoptic_fpn_R_50_3x.yaml")) cfg.DATASETS.TRAIN = ("my_dataset_train",) cfg.DATASETS.TEST = ("my_dataset_val",) cfg.DATASETS.NUM_CLASSES = 7 # 替换默认的 133 # 这里可以看出区别:因为类数变了,最后一个分类层必须重置 cfg.MODEL.ROI_HEADS.NUM_CLASSES = 3 # thing 类别数 cfg.MODEL.SEM_SEG_HEAD.NUM_CLASSES = 4 # stuff 类别数我估计这个配置项是不少人崩溃的根源。Panoptic FPN 的架构里同时存在ROI_HEADS和SEM_SEG_HEAD两个分支,前者负责 thing 的实例检测与掩码,后者负责 stuff 的语义分割。两个分支的类别维度必须单独设置。如果你在转换脚本里定义了 3 个 thing 和 4 个 stuff,那这里就分别填 3 和 4。我见过有人把NUM_CLASSES统一设置成 7,结果语义分支的通道维度明显多于实际所需,训练能跑但显存浪费严重,而且最后的预测结果有一堆空类输出。
4.3 复现性配置与训练命令
配置文件里还有几个值得关注的经验项:
# 训练轮次 cfg.SOLVER.MAX_ITER = 2000 # 自定义数据集通常不需要像 COCO 那样训练 12 轮 cfg.SOLVER.BASE_LR = 0.0001 # 迁移学习时推荐用较低学习率 cfg.SOLVER.IMS_PER_BATCH = 4 # 根据显存调节,如果显存不够就降到 2 cfg.INPUT.MIN_SIZE_TRAIN = (320, 640) # 用小尺寸训练加速迭代我之前跑一个 8 类、约 3000 张图像的自定义数据集时,用 batch 4 + 单卡 1080Ti 级别的显存,训练 6000 步左右就能在验证集上达到可用的效果。这里你不用死记参数,但要掌握一个核心思想:迁移学习的迭代次数远比随机初始化少,学习率要比 COCO 训练低一个到两个数量级,batch size 受显存约束时优先降分辨率而非降 batch(当然降 batch 也能接受,只是收敛速度有差异)。
训练命令就直接用:
python train_net.py --config-file configs/panoptic_fpn_R50.yaml \ --num-gpus 1 \ SOLVER.IMS_PER_BATCH 4如果是在笔记本或者 M1 上跑,CUDA 那一套不提,有没有“CPU-only 也能验证数据处理”的轻量方式?有,但这里要注意,实测跑完一个 epoch 在 Apple Silicon 上也是相当吃力,更适合只在调试时用cfg.MODEL.DEVICE = "cpu"跑几步验证数据加载和损失计算是否正常,不要指望它有训练意义。
4.4 评估与推理:如何验证模型真的学会了全景分割
训练完成后,必须做评估。Detectron2 里COCOPanopticEvaluator会返回 PQ(Panoptic Quality)、SQ、RQ 三个指标,但我建议先做一轮可视化评估,再上指标。为什么?因为自定义数据集的 PQ 绝对值没有参考基线,模型状态到底行不行还是得看图和混淆矩阵。
可视化推理的代码很简单:
from detectron2.engine import DefaultPredictor from detectron2.utils.visualizer import Visualizer predictor = DefaultPredictor(cfg) img = cv2.imread("xxx.jpg") outputs = predictor(img) panoptic_seg, segments_info = outputs["panoptic_seg"] v = Visualizer(img[:, :, ::-1], metadata, scale=0.8) vis = v.draw_panoptic_seg_predictions(panoptic_seg.to("cpu"), segments_info) vis = vis.get_image()[:, :, ::-1]推理时你可能会遇到一个很常见的问题:panoptic_seg是一张LongTensor,而其值对应segments_info里的 id,绘制时Visualizer会自动把 id 映射到category_id并获取颜色,所以要确保metadata的 thing/stuff 和训练时一致。如果发现推理图里所有实例都显示为同一个颜色,优先检查 Metadata 里 thing_classes 的顺序和注册时是否一致。
5. 常见问题与排查技巧实录
5.1 训练时报错 label 数不匹配
报错信息大体是RuntimeError: Expected label [1, 3, 480, 640] to be one vs这样一类张量维度问题。这通常不是坏事,而是你的NUM_CLASSES配置错了。排查步骤:
- 检查 Metadata 中 thing_classes + stuff_classes 的总数。
- 检查 cfg.MODEL.ROI_HEADS.NUM_CLASSES 是否等于 thing 数量。
- 检查 cfg.MODEL.SEM_SEG_HEAD.NUM_CLASSES 是否等于 stuff 数量。
- 检查
panoptic_train.json的categories里isthing字段是否与 metadata 一致。
我强烈建议写一个快速脚本,打印 Metadata 的 thing_classes 和 stuff_classes 和 panoptic json 中 category_id 值的并集,交叉比对,防止哪边少了一个类。
5.2 Panoptic PNG 渲染出来全黑或只有边缘
这种问题多半出在 PNG 的编码方式上。记住:panoptic PNG 中像素范围是 0~65535,必须用uint16保存,且 OpenCV 的cv2.imread默认会把它读成 8 位。如果你用标准加载方式,整张图会变成一团黑或几乎黑。正确读取方式是:
img = cv2.imread("xxx.png", cv2.IMREAD_UNCHANGED) # 此时 img.shape = (H, W), dtype = uint16如果你发现读取后值域全部大于 255,那说明文件本身没问题,只是可视化方案要改。我见过有人用cv2.imwrite保存 panoptic PNG 时忘记指定 dtype,结果把 uint16 压成了 uint8,所有 id 大于 255 的实例全部丢失。这是一个残酷的坑。正确的保存方式:
cv2.imwrite("panoptic_train/xxx.png", panoptic.astype(np.uint16))OpenCV 在写 16-bit 单通道 PNG 时会自动识别uint16的 dtype,不需要额外参数。但读到内存后请始终保留uint16。
5.3 转换后实例数量不对,尤其是 stuff 区域消失
如果发现转换后的 panoptic 图里天空、道路大面积消失,最常见的错误是“重叠消除时后写的把先写的完全覆盖了”,而你的 stuff 可能在 thing 之前写入,当两个 stuff 有重叠(比如建筑和天空在边界处有锯齿状重叠)时,后写入的 stuff 会把先写入的 building 吃掉一块。这不一定错,但面积统计时就会偏大或偏小。
调试方法很简单:把segments_info中每项的area和它实际对应像素数逐个比对,超过 5% 的就打印出来看。如果出现大面积差异,重新审视你的排序逻辑,必要时按“thing 优先级 > stuff 优先级”来处理重叠。
5.4 训练 loss 一直不降或出现 NaN
这个问题的成因很多,但在自定义全景分割里有两个高发原因。第一,学习率过大,用 0.001 起步,对自定义数据集的少量样本来说很容易发散。第二,输入图像长宽比差异过大,模型在 ROI 对齐时反复插值导致梯度爆炸。我的建议是保证数据增强时设置cfg.INPUT.MIN_SIZE_TRAIN与MAX_SIZE_TRAIN的值尽量成比例,避免极端的长条形输入。
5.5 一个真正的“元问题”:评估指标 PQ 很高但可视化一塌糊涂
这种异常情况要警惕。常见原因是你把visualize时用的metadata类别顺序搞错了,导致颜色映射错位,模型预测其实是对的,而可视化代码在撒谎。把 thing/stuff 的分开渲染再对比一次,很容易定位。
6. 老生常谈但每次都会救命的提示
经历了这么多轮转换和训练,我的个人经验是:全景分割的工程链路里,90% 的时间花在数据准备与格式调试上,只有 10% 的时间花在模型本身。如果你能把 instance->panoptic 转换脚本写成一种可复用、可测试、可自动验证的独立模块,后面换数据集、换类别、换任务时都会非常爽。
我有一点具体建议:在项目根目录放一个validate_panoptic.py测试脚本,内容包括“检查 PNG 是否为 uint16、segments_info id 是否唯一、area 是否一致、category_id 是否在 metadata 中”。每次转换完数据集,跑一遍再进训练,这个习惯能让你少掉无数根头发。
下一个项目如果还要做全景分割,我可能会考虑加入stuff区域的自动合并策略:不再简单地把同类别 mask 做逻辑或,而是用连通性分析和形状先验来决定允许多少个“天空碎片”。因为现实中天空被树干遮挡后会出现几十块碎片,统一塞进一个 stuff 实例在语义上是不合理的,在评测时反而会拉低 PQ,因为模型预测的是一整片天空,而标注的是碎片聚合。这个扩展方向如果你后续要精进,可以深入尝试一下——先把最基本的链路跑通,再追求精细化,这个思路不会错。