首页 / 资讯中心 / 文章详情

Detectron2全景分割实战:自定义数据集制作与实例转Panoptic完整指南

Detectron2全景分割实战:自定义数据集制作与实例转Panoptic完整指南 ★ FEATURED ARTICLE
做全景分割的朋友应该都体会过这种感觉模型选型、训练参数都好说真正卡人的反而是数据集。Detectron2 里自带的 COCO 全景预训练模型跑得挺欢但轮到自己的数据就发现官方示例全是拿现成 COCO 全景格式喂进去的网上关于“自定义数据集制作”和“instance 转 panoptic 格式转换”的教程又少得可怜好不容易搜到一篇还经常是半截的。这篇文章就是要把这条链路走通从标注文件长什么样、掩码怎么存到怎样把已有的 COCO 实例分割标注转成全景分割标注再到注册进 Detectron2 开训一路讲完。读这篇文章之前建议你已经用 Detectron2 跑通过实例分割至少知道 annotation JSON 的基本结构。如果只是刚接触也不用担心我会把涉及到的格式要点全部拆开解释你照着做就能复现。我先把话说在前面全景分割的数据准备工作量通常比模型训练本身的调参工作量大得多而且坑特别多很多问题等到训练爆 NaN、mAP 全零时才回头查往往已经浪费了一两天。下面这些经验是我反复折腾过之后沉淀下来的希望能帮你把这几个坑提前填平。1. 全景分割和实例分割到底差在哪1.1 一句话理解全景分割输出实例分割做的是“把每个物体框出来、抠出来”同一个类别里多个物体彼此独立每个都有自己的掩码和编号。全景分割则更进一步它要求给图像里的每一个像素都分配一个“类别 实例编号”的标签不仅要把人、车、猫这些“东西”thing分开还要把天空、地面、墙壁这些没有固定数量的“stuff”区域完整地填满。换言之全景分割的输出是一张和原图一样大的标签图上面没有“空白”的像素。这就引出一个很要命的特性全景分割的标注数据不能只用之前实例分割那种 JSON 结构来表达它必须额外有一张“像素级真值图”。这也是整个转换工作的核心所在。1.2 两种标注格式的对比COCO 实例分割标注核心是图像里每个物体的bbox、category_id和segmentation掩码。它并不关心背景像素属于什么也不要求掩码之间不能重叠。COCO 全景分割标注则不同它由三部分组成原图、一张和原图同尺寸的 PNG 掩码图以及一份和这张 PNG 对应的segments_info数组。PNG 掩码图的每个像素值是一个“段 ID”segments_info里每个条目记录这个段 ID 对应的类别、是否是 thing、面积等信息。为了更直观我列一个表对比项COCO InstanceCOCO Panoptic标注粒度只标注物体实例每个像素都必须有归属是否覆盖背景不要求要求背景要么是 stuff 类别要么是 void掩码是否允许重叠可以重叠不允许同一像素只能属于一个段掩码存储位置JSON 中内嵌独立 PNG 掩码图 JSON 描述类别构成只有 thing 类别thing 类别 stuff 类别标注文件结构instances.jsonpanoptic.json panoptic_*.png值得强调的一点是panoptic 类别本质上是一个大列表常见做法是把 thing 和 stuff 在同一个categories数组里顺序排列它们各自有独立类别 ID 和isthing标记。COCO 原版全景有 133 类其中 80 类 thing、53 类 stuff。自定义数据集时你也得决定自己的 stuff 类别和 thing 类别分别是什么。1.3 为什么必须做格式转换网上很多现成数据集比如你自己标的一套实例分割数据只有instances.json每个物体都有掩码但背景没被标注。你需要把它转换成 panoptic 格式本质上是回答两个问题那些没有被任何实例掩码覆盖的像素应该属于哪个 stuff 类别如果两个实例掩码因为标注不规范发生重叠谁覆盖谁这两个问题不解决转换出来的 panoptic 数据一定是脏的。很多人在这一步直接写个循环把标注抠出来存成 PNG结果训练时 Evaluator 疯狂报错源头就在于此。2. 自定义数据集制作从标注设计到目录规划2.1 先想清楚 stuff 类别怎么来做自定义全景分割最容易被忽视的前置决策就是 stuff 类别。如果你的标注工具里只有“画物体轮廓”这一种模式生产出来的数据天然缺乏 stuff 标注。在这种情况下我建议你先盘点一下自己的实际需求如果你的应用场景只需要区分前景物体比如自动驾驶中只关心车和行人那么背景可以统一作为一个background类这类属于 stuff。如果场景本身对背景有细分需求比如要区分道路、天空、植被那你就得在标注阶段就把这些区域也画出来或者从已有的语义分割标注中合并。如果确实没有任何背景标注你也可以把未被覆盖的像素全部设为 void即 0训练时这些像素不计算损失推理时网络会把它归到最近的 stuff 类。但这种做法通常会导致 stuff 类别的预测质量比较差不到万不得已不建议。我遇到的多数据情况是手里已经有一套实例分割数据但背景没标任务又确实需要全景分割。最省事的方案是额外做一个“背景分割”模型或者用传统图像处理手段把背景像素粗标一遍然后作为 stuff 输入。这当然不完美但作为起步数据已经足够跑通链路。2.2 目录推荐的布局方式Convert 和训练之前先把目录结构固定下来。Detectron2 对目录没有硬性要求但清晰的结构能让你少踩路径坑。我常用下面这种data/ ├── images/ │ ├── train/ # 原图jpg 或 png 均可 │ └── val/ ├── annotations/ │ ├── instances_train.json │ ├── instances_val.json │ ├── panoptic_train.json # 转换产物 │ ├── panoptic_val.json # 转换产物 │ └── panoptic_categories.json # thing stuff 的统一类别表 ├── panoptic_masks/ │ ├── train/ # 转换产出的掩码 PNG │ └── val/ └── stuff_masks/ # 可选如果你独立标注了 stuff ├── train/ └── val/注意panoptic_masks里的 PNG 和原图必须严格一一对应文件名建议使用 image id 的 12 位补零格式例如000000000123.png。这样排序时不会乱后续调试也方便。2.3 类别 ID 分配原则COCO 原版 instance 的类别 ID 往往和 panoptic 中 thing 部分的类别 ID 可以保持一致但 stuff 部分要另起一段。比如你的 thing 类别 ID 是 1~10那 stuff 类别 ID 就从 11 开始。在panoptic_categories.json里每种类别要带三个字段id、name、isthing。这是 panoptic JSON 必须的字段缺少了isthingDetectron2 在构建类别映射时直接会报错。这里有个容易踩的细节COCO 原版 instance JSON 中stuff 类别通常不出现因为 stuff 不是实例。但 panoptic JSON 的categories列表里必须包含所有 thing stuff 类别。所以转换脚本不能只读取 instance 的 categories 原样写出去必须手动补充 stuff 类别表。3. COCO instance 转 COCO panoptic转换逻辑与完整脚本3.1 转换逻辑拆解转换的核心逻辑可以用三步概括遍历每张图的 annotation用segmentation解码出每个实例的二值掩码。把这些掩码“画”到一张空白标签图上画的时候给每个实例分配一个从 1 开始递增的段 ID。这个段 ID 就是将来 PNG 像素值。为每个段 ID 写一条segments_info记录category_id、isthing、area等信息。如果设置了背景 stuff就把标签图中仍然为 0 的像素也填上一个 stuff 段。但三步里藏着三个必须想明白的问题第一掩码重叠了怎么办。标注工具产生的掩码偶尔会由于贴边而重叠几个像素。如果不处理后画的实例会覆盖先画的实例先画实例的面积就少了这本身没问题但如果你不排序结果就不可控。我的处理习惯是把所有实例按面积从小到大排序最后画面积大的。这样面积小的前景物体不容易被大物体覆盖。第二段 ID 的连续性。同一个segments_info里的id和 PNG 像素值必须严格对应。我习惯每张图从 1 开始分配PNG 中值为 0 的像素表示 void也就是不参与训练的像素。如果一张图实例特别多超过 65535 个那得用 uint32 存储不过实际自定义数据几乎不会达到这个量级。第三stuff 的填充时机。必须在所有 thing 实例都画完之后再填充 stuff否则 stuff 会把物体区域盖住。填充时同样要给 stuff 分配一个新的段 ID并写一条isthing0的记录。3.2 核心转换脚本下面给一个可用的参考实现。我假设你的 instance JSON 里segmentation可能是polygon列表也可能是 RLE 字典所以解码函数要兼容两种。import json import os import numpy as np import cv2 from collections import defaultdict from pycocotools import mask as mask_utils def decode_segmentation(seg, height, width): 解码 COCO 的 segmentation 字段兼容 polygon 和 RLE if isinstance(seg, dict) and counts in seg: return mask_utils.decode(seg).astype(bool) mask np.zeros((height, width), dtypebool) for poly in seg: pts np.array(poly, dtypenp.float32).reshape(-1, 2) cv2.fillPoly(mask, [pts.astype(np.int32)], True) return mask def instances_to_panoptic(instance_json_path, output_png_dir, output_json_path, stuff_category_idNone, bg_stuff_category_idNone): with open(instance_json_path, r) as f: coco json.load(f) images {img[id]: img for img in coco[images]} anns_by_image defaultdict(list) for ann in coco[annotations]: anns_by_image[ann[image_id]].append(ann) os.makedirs(output_png_dir, exist_okTrue) panoptic_annotations [] for image_id, img in images.items(): height img[height] width img[width] seg_map np.zeros((height, width), dtypenp.uint16) segments_info [] anns sorted(anns_by_image[image_id], keylambda a: a[area]) segment_id 1 for ann in anns: mask decode_segmentation(ann[segmentation], height, width) if not mask.any(): continue # 画掩码覆盖既有像素 seg_map[mask] segment_id info { id: segment_id, category_id: ann[category_id], isthing: 1, area: int(mask.sum()), bbox: [float(x) for x in ann.get(bbox, [0, 0, 0, 0])], iscrowd: int(ann.get(iscrowd, 0)), } segments_info.append(info) segment_id 1 # 可选把剩余未标注区域填充为背景 stuff if bg_stuff_category_id is not None: bg_mask seg_map 0 if bg_mask.any(): seg_map[bg_mask] segment_id segments_info.append({ id: segment_id, category_id: bg_stuff_category_id, isthing: 0, area: int(bg_mask.sum()), }) segment_id 1 file_name f{image_id:012d}.png png_path os.path.join(output_png_dir, file_name) cv2.imwrite(png_path, seg_map, [cv2.IMWRITE_PNG_COMPRESSION, 0]) panoptic_annotations.append({ image_id: image_id, file_name: file_name, segments_info: segments_info, }) output { images: coco[images], annotations: panoptic_annotations, categories: coco[categories], } with open(output_json_path, w) as f: json.dump(output, f)使用的时候注意几点bg_stuff_category_id必须是你额外定义的一个 stuff 类别 ID不要在 instance JSON 的 thing 类别里随便挑一个否则网络会把背景和前景当成同一类。cv2.imwrite保存 PNG 时用IMWRITE_PNG_COMPRESSION设为 0既加快写入又避免压缩组件在高位深图上出幺蛾子。seg_map用的是uint16。如果你觉得后期可能突破 65535 个段可以改成uint32但cv2.imwrite对uint32的支持不友好需要转成uint16或分通道保存所以一般不建议。3.3 转换后必做的五项检查转换脚本跑完千万别立刻拿去训练先做人工检查。我自己总结了一套“五项检查”每一条都有血泪教训在里面随机挑几张生成的 PNG用cv2.imread(png_path, cv2.IMREAD_UNCHANGED)读出来确认不是 8 位截断的灰度图要能看到多个段对应的不同灰度值。检查 JSON 里segments_info的id是否都在对应 PNG 中出现反过来 PNG 中出现的像素值是否都能在segments_info里找到。ID 对不上训练过程中会报非常隐蔽的格式错误。检查isthing标记是不是正确尤其是 stuff 类别很多脚本会漏掉这个字段。检查 area 和掩码实际面积是否一致不一致可能是因为重叠掩码覆盖后面积变化了这通常不影响训练但影响评估时 PQ 的计算。检查图片路径是否和 JSON 里的file_name对得上Detectron2 用绝对路径或相对 root 路径去找掩码路径错了会直接跳过样本而且不报错。4. 注册数据集和训练配置4.1 注册自定义 panoptic 数据集Detectron2 官方提供了注册 panoptic 数据集的接口直接调用就行。先构造 metadata再调用注册函数。from detectron2.data import MetadataCatalog from detectron2.data.datasets import register_coco_panoptic thing_classes [person, car, bicycle] # 按你的实际类别填 stuff_classes [background, road, sky] # 按你的实际类别填 # thing ID 范围和 stuff ID 范围要跟 JSON 中保持一致 thing_id_to_contiguous {1: 0, 2: 1, 3: 2} stuff_id_to_contiguous {11: 3, 12: 4, 13: 5} metadata MetadataCatalog.get(myod_panoptic_train) metadata.thing_classes thing_classes metadata.stuff_classes stuff_classes metadata.thing_dataset_id_to_contiguous_id thing_id_to_contiguous metadata.stuff_dataset_id_to_contiguous_id stuff_id_to_contiguous register_coco_panoptic( namemyod_panoptic_train, metadatametadata, image_rootdata/images/train, panoptic_rootdata/panoptic_masks/train, panoptic_jsondata/annotations/panoptic_train.json, instance_jsondata/annotations/instances_train.json, )这里要解释一下contiguous_id的意义。Detectron2 在训练时需要把类别 ID 映射成从 0 开始的连续整数因为模型分类头的输出维度是连续整数个数。thing 和 stuff 两套映射最终会拼成一个完整的连续区间比如上面这个例子总类别数就是 6。如果你映射有重叠比如 thing 里给了 0stuff 里又给了 0那模型就会把两个类别当成一个训练完全不收敛而且不容易发现。验证数据集注册是否成功可以这样from detectron2.data import DatasetCatalog dataset DatasetCatalog.get(myod_panoptic_train) print(dataset[0])能正常打印出第一条样本字典包含image、panoptic_seg、segments_info等字段就说明注册没问题。如果这里报错不要急着训练先解决注册链路。4.2 模型配置与类别数设置训练配置可以以 Detectron2 提供的 COCO Panoptic 配置为基础改。假设是panoptic_fpn_R_50_3x.yaml你需要改动的主要是数据集名、类别数、batch size、学习率等。from detectron2.config import get_cfg from detectron2.engine import DefaultTrainer cfg get_cfg() cfg.merge_from_file( detectron2/configs/COCO-PanopticSegmentation/panoptic_fpn_R_50_3x.yaml ) cfg.DATASETS.TRAIN (myod_panoptic_train,) cfg.DATASETS.TEST (myod_panoptic_val,) # 类别总数 thing数量 stuff数量 cfg.MODEL.SEM_SEG_HEAD.NUM_CLASSES 6 cfg.SOLVER.IMS_PER_BATCH 2 cfg.SOLVER.BASE_LR 0.0001 cfg.SOLVER.MAX_ITER 20000 cfg.OUTPUT_DIR ./output/myod_panoptic特别注意cfg.MODEL.SEM_SEG_HEAD.NUM_CLASSES这个参数。PanopticFPN 把 thing 和 stuff 统一放在一个语义分割头里预测所以它必须等于 thing 类别数加 stuff 类别数而不是单独 thing 或单独 stuff 的数量。很多人在这设成实例分割的类别数结果训练出的模型东西错乱评测结果稀碎。如果你要加载 COCO 预训练权重而自定义类别数不等于 133那么权重文件里分类头相关的 shape 会对不上。常见的做法是先加载 COCO 预训练权重但重命名或删掉分类头再从自己的数据开始微调。这里贴个简化的加载逻辑from detectron2.checkpoint import DetectionCheckpointer import detectron2.utils.collect_env as collect_env cfg.MODEL.WEIGHTS detectron2://COCO-PanopticSegmentation/panoptic_fpn_R_50_3x/model_final_c10459.pklDetectron2 在加载时遇到 shape 不匹配的层会打印 warning但默认行为是跳过。也就是说你可以直接指定 COCO 预训练权重它会自动加载 backbone 等匹配的部分分类头则随机初始化。这对自定义数据集来说其实是个不错的起点。4.3 验证集注册和可视化验证集和训练集的注册流程完全一样只是名字要不同metadata 对象也要单独获取。很多人在注册验证集时忘了设置 metadata导致评估阶段类别名字全是空列表虽然不影响 PQ 数值但可视化时会看到一堆乱码标签。正确的做法metadata_val MetadataCatalog.get(myod_panoptic_val) metadata_val.thing_classes thing_classes metadata_val.stuff_classes stuff_classes metadata_val.thing_dataset_id_to_contiguous_id thing_id_to_contiguous metadata_val.stuff_dataset_id_to_contiguous_id stuff_id_to_contiguous register_coco_panoptic( namemyod_panoptic_val, metadatametadata_val, image_rootdata/images/val, panoptic_rootdata/panoptic_masks/val, panoptic_jsondata/annotations/panoptic_val.json, instance_jsondata/annotations/instances_val.json, )训练过程中建议隔一段时间就做一次可视化。Detectron2的Visualizer支持直接绘制 panoptic 预测from detectron2.utils.visualizer import Visualizer vis Visualizer(img_rgb, MetadataCatalog.get(cfg.DATASETS.TRAIN[0]), scale1.0) out vis.draw_panoptic_seg(predictions[panoptic_seg], predictions[segments_info])通过可视化你能直观发现类别映射错误、stuff 区域缺失、掩码错位等一系列问题。这一步比看任何指标都来得快。5. 训练过程中容易翻车的环节5.1 掩码位深不对导致全图读取失败我遇到过的最隐蔽的问题是生成的 panoptic PNG 是 8 位图。当你的段 ID 不超过 255 时cv2.imwrite会自动把uint16数组压缩保存成 8 位 PNG值越小可能没问题但一旦段 ID 超过 2558 位就会截断读出来全是错乱的值。更麻烦的是Detectron2 读取 panoptic 掩码时用cv2.imread(path, cv2.IMREAD_UNCHANGED)如果你保存的时候被迫转成了 8 位就会在后续处理中产生大量“找不到对应 segments_info”的报错而且错误信息往往指向另外的字段排查起来极费时间。我的解决办法是转换脚本里显式检查seg_map.dtype np.uint16才保存。保存之后再读回来验证max_value是否等于最大段 ID这一步写成自动化检查每次转换都跑一遍。你也可以用PIL.Image.fromarray(seg_map.astype(np.uint16))保存PIL 对 16 位 PNG 的保存策略更明确不会自动降位深但需要留意压缩模式和格式兼容性。5.2 NaN 损失和掩码重叠的关系训练初期 loss 变成 NaN最常见的原因是标注里出现了空掩码或者异常面积。COCO 标注中area字段可能不准确但转换脚本应该以真正mask.sum()为准而不是沿用原area。如果一个segmentationdecode 出来是空掩码说明原标注有问题要么跳过要么检查标注工具。空掩码写入 segments_info 后Detectron2 在处理空区域时可能出现除零从而让 loss 变成 NaN。另一个原因是某些实例掩码把整个图像都覆盖了导致背景 stuff 面积为零。这种情况一般不会让 loss 变 NaN但会让模型对 stuff 类别的学习变得不稳定。建议转换后统计所有图片的 stuff 覆盖率如果大量图片的 stuff 面积为 0要么重新补充背景标注要么调整 stuff 填充策略。5.3 训练集数量太少过拟合表现成乱码全景分割比实例分割需要更多数据因为模型同时要学习 thing 和 stuff 两种分布。很多人用几百张自定义图去微调结果发现验证集 PQ 很低尤其是 stuff 类别几乎不更新。我的经验是自定义全景数据集至少要有上千张图才能让端到端训练有点效果。如果数据不够可以先冻结 backbone只训练分割头或者使用更强的数据增强。Detectron2 默认的 augmentation 对全景分割支持还算完整我常用RandomCrop、RandomBrightness和RandomContrast这类轻量增强。另外不要指望小数据集训练出和 COCO 预训练同级别的效果除非你的类别分布和场景跟预训练数据高度接近。这一点只能靠数据量解决模型结构帮不了太多忙。5.4STUFF_AREA_LIMIT参数的影响Detectron2 的 PanopticFPN 里有STUFF_AREA_LIMIT这个参数默认值是 4096。它的含义是面积小于该阈值的 stuff 段会被忽略在训练时当作 void 处理。这样做是为了抑制 stuff 类别的噪声避免一些零星小块干扰训练。但这个默认值对自定义数据集可能偏大。如果你的 stuff 类别本来就很碎比如树叶、栅栏这种小面积区域4096 的阈值会把大量有效标注直接丢掉模型学不到这类 stuff。使用时要根据你的标注情况调整。我的建议是先统计每个 stuff 段的面积分布然后把STUFF_AREA_LIMIT设为中位数以下的值比如 512 或 1024别盲目用默认值。6. 结果怎么看PQ 指标与常见问题排查6.1 PQ、SQ、RQ 到底是什么全景分割最常用的指标是 PQ全称是 Panoptic Quality由三部分乘积构成识别质量 RQ 和分割质量 SQ。PQ 的计算方式是逐类别进行的对每个类别统计真阳、假阳、假阴再算平均 IoU。这个指标对 thing 和 stuff 一样对待不像 mAP 那样只关心物体检测。我在实际使用中会额外关注SQ和RQ。如果SQ偏低说明生成的掩码边界不够准可能和标注分辨率、模型结构有关。如果RQ偏低说明模型漏检或错检了大量段通常是类别映射错误、类别不平衡或者数据量不足导致的。6.2 常见报错速查表现象可能原因解决思路注册时报KeyError: segments_infopanoptic JSON 里字段名拼写错误检查 JSON key确认是segments_info报assert not a[iscrowd]相关错误转换时把iscrowd值写错确认iscrowd是 0 或 1且 thing 通常为 0训练时样本被跳过图像路径或掩码路径对不上检查image_root、panoptic_root与 JSON 里的file_namePQ 一直为 0类别映射错误比如 stuff 和 thing 重叠检查thing_dataset_id_to_contiguous_id和stuff_dataset_id_to_contiguous_id掩码可视化全是黑色seg_map位深不对或读取方式不对用IMREAD_UNCHANGED读取确认uint16loss 为 NaN空掩码或异常标注转换脚本跳过空掩码检查面积6.3 一个小技巧用 Evaluator 做离线验证训练前可以先跑一次零迭代的评估也就是加载随机权重或预训练权重在验证集上执行一次评估流程。这不会给你什么有意义的指标但能确保整个数据链路是通的。如果零迭代评估能正常跑完并能输出 PQ 值哪怕全是 0.001至少说明数据格式、注册、模型配置都对齐了。我每次转换完新数据都会先做这个“冒烟测试”再决定是否开启长训练。它帮我挡掉了至少一半的格式错误问题省下的时间远比写这个脚本的时间多。最后再分享一个小技巧可能你已经注意到了我上面的转换脚本里没有处理 thing 区域内部嵌套 stuff 的情况。如果你标注数据里有一个“窗户”类别是 stuff同时“人”这个 thing 区域可能被标注成包含窗户那么掩码重叠处理后窗户会被人的掩码完全盖掉这其实是不合理的。遇到这种层级关系建议在标注阶段就明确“互斥区域”的优先级或者在转换脚本里加一个类别优先级表让高优先级类别覆盖低优先级类别而不是单纯按面积排序。这个细节看似不起眼但对真实业务场景影响很大。我之前做一个室内全景分割项目时指令里留了各类别优先级表转换后表格根本不飞、训练的 stuff 质量马上提升了一个档次。全景分割的数据工作麻烦就麻烦在这些琐碎的一致性规则上但它们恰恰决定了模型最终的天花板。希望这篇文章能让你少走几趟弯路。
阅读完成 · 觉得有帮助?
咨询建站