简介:这份资源面向具备一定深度学习基础、希望系统掌握图像分割技术的开发者与学习者,聚焦语义分割、实例分割与全景分割三大方向,帮助理解像素级分类、实例区分及统一分割框架的核心思路与工程实现。压缩包共4个文件,以md说明文档、Python脚本和pro工程配置为主,整体约10KB,轻量便于快速浏览与二次开发。内容围绕公开数据集预处理、标签转换与分割流程展开,涉及U-Net、Mask R-CNN、Panoptic FPN等典型模型的实践脉络,可帮助读者梳理从数据准备、模型训练到评估推理的完整链路,并借助脚本理解标签处理与工程配置细节。目前已有452人学习,适合作为计算机视觉方向课程实践或项目入门的参考素材。
1. 从一张图到三类掩码:图像分割到底在分什么
你手里有一张街景图,模型告诉你「这里有一辆车」,这叫目标检测;但如果它告诉你「这个像素属于车、那个像素属于路、右上角那片属于天空」,这就是图像分割。语义分割把所有像素按类别打标签,不区分个体;实例分割在语义基础上把「车 A」和「车 B」拆开;全景分割则把「可数的东西」(车、人)和「不可数的背景」(路、天)统一到一张掩码里。这三件事共享同一套深度学习骨架,区别只在输出头的设计和标签组织方式。如果你正在做毕设、搭广告牌图像分割系统,或者想把医学图像分割落地,这篇笔记会从数据准备一路讲到推理部署,把能抄的代码和容易翻车的地方都摊开。
2. 语义分割、实例分割、全景分割的选型逻辑与数据准备
2.1 三类任务的核心差异与模型输出形式
语义分割的输出是一张 H×W 的类别索引图,每个像素值是一个类别 ID。实例分割的输出是 N 个掩码,每个掩码对应一个检测框内的二值区域,外加类别和置信度。全景分割的输出则是一张 H×W 的 ID 图,每个像素值既编码类别又编码实例序号,通常用class_id * 1000 + instance_id这种形式拼接。
选型时先问自己三个问题:第一,需不需要区分同一类别的不同个体?需要就排除纯语义分割。第二,背景区域要不要参与评估?要就上全景分割。第三,标注预算有多少?语义分割标注成本最低,实例分割需要多边形或掩码标注,全景分割要求标注员同时处理前景实例和背景类别,成本最高。
常见做法是:语义分割用 U-Net 或 DeepLabV3+ 做基线,实例分割用 Mask R-CNN 或 YOLOv8-seg,全景分割用 Mask2Former 或 Panoptic DeepLab。如果只是做广告牌图像分割系统,语义分割足够;如果要做医学图像分割中的细胞计数,实例分割更合适。
2.2 语义分割数据集制作:从标注到 VOC 与 COCO 格式转换
语义分割数据集最常见的两种格式是 VOC 和 COCO。VOC 用 PNG 索引图存标签,COCO 用 JSON 存多边形。下面这段脚本把 LabelMe 标注的 JSON 转成 VOC 格式的语义分割标签图。
import json import numpy as np from PIL import Image import os # 类别名到索引的映射,背景固定为0 CLASS_MAP = {"background": 0, "car": 1, "person": 2, "road": 3} def json_to_voc_mask(json_path, output_path): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) h, w = data["imageHeight"], data["imageWidth"] mask = np.zeros((h, w), dtype=np.uint8) # 按标注顺序反向绘制,避免小目标被大区域覆盖 for shape in reversed(data["shapes"]): label = shape["label"] if label not in CLASS_MAP: continue points = np.array(shape["points"], dtype=np.int32) # 用 PIL 的 polygon 填充,比 cv2.fillPoly 更稳定 from PIL import ImageDraw img = Image.fromarray(mask) draw = ImageDraw.Draw(img) draw.polygon([tuple(p) for p in points], fill=CLASS_MAP[label]) mask = np.array(img) Image.fromarray(mask).save(output_path) # 批量转换 for file in os.listdir("labelme_jsons"): if file.endswith(".json"): json_to_voc_mask( os.path.join("labelme_jsons", file), os.path.join("voc_masks", file.replace(".json", ".png")) )这段代码的关键参数是CLASS_MAP,它决定了标签图的像素值。背景必须占 0,因为大多数损失函数默认忽略 0 或把 0 当背景。reversed遍历是为了让先标注的大区域后绘制,避免覆盖小目标。转换完成后,用PIL.Image.fromarray(mask).save()保存为 PNG,不要用 JPG,因为 JPG 有损压缩会改变像素值。
2.3 实例分割与全景分割的数据组织方式
实例分割的数据集通常用 COCO 格式,每张图对应一个 JSON,里面包含annotations数组,每个元素有bbox、segmentation、category_id、iscrowd。全景分割在 COCO 基础上增加了panoptic字段,用 PNG 存 ID 图,同时 JSON 里记录每个 segment 的类别和 ID 映射。
如果你手头只有语义分割标签,想转成实例分割,可以用连通域分析把同类别的不同区域拆开。下面这段代码演示如何从语义掩码生成实例掩码。
import numpy as np from scipy import ndimage def semantic_to_instance(sem_mask, class_id): # 提取指定类别的二值掩码 binary = (sem_mask == class_id).astype(np.uint8) # 连通域标记,structure 决定连通性,8连通更宽松 labeled, num = ndimage.label(binary, structure=np.ones((3, 3))) instances = [] for i in range(1, num + 1): inst_mask = (labeled == i).astype(np.uint8) # 过滤掉面积过小的噪声区域 if inst_mask.sum() < 50: continue instances.append(inst_mask) return instancesndimage.label的structure参数控制连通性,np.ones((3,3))表示 8 连通,适合不规则形状;如果目标之间容易粘连,改用 4 连通。面积阈值 50 是经验值,实际要根据图像分辨率调整,太小会保留噪声,太大会漏掉小目标。
3. 用 PyTorch 跑通语义分割训练:从 Dataset 到 DeepLabV3+
3.1 自定义 Dataset 与数据增强的四个必调参数
PyTorch 的Dataset需要实现__len__和__getitem__。语义分割的__getitem__要同时返回图像和掩码,并且做相同的几何变换。下面是一个可复现的 Dataset 骨架。
import torch from torch.utils.data import Dataset import numpy as np from PIL import Image import random class SegDataset(Dataset): def __init__(self, img_dir, mask_dir, img_size=512, augment=True): self.img_dir = img_dir self.mask_dir = mask_dir self.img_size = img_size self.augment = augment self.files = [f for f in os.listdir(img_dir) if f.endswith(".jpg")] def __len__(self): return len(self.files) def __getitem__(self, idx): name = self.files[idx] img = Image.open(os.path.join(self.img_dir, name)).convert("RGB") mask = Image.open(os.path.join(self.mask_dir, name.replace(".jpg", ".png"))) # 统一尺寸,用双线性插值处理图像,最近邻处理掩码 img = img.resize((self.img_size, self.img_size), Image.BILINEAR) mask = mask.resize((self.img_size, self.img_size), Image.NEAREST) img = np.array(img, dtype=np.float32) / 255.0 mask = np.array(mask, dtype=np.int64) # 随机水平翻转,图像和掩码必须同步 if self.augment and random.random() > 0.5: img = np.fliplr(img).copy() mask = np.fliplr(mask).copy() # 归一化,均值方差用 ImageNet 的 mean = np.array([0.485, 0.456, 0.406]) std = np.array([0.229, 0.224, 0.225]) img = (img - mean) / std img = torch.from_numpy(img).permute(2, 0, 1).float() mask = torch.from_numpy(mask).long() return img, mask四个必调参数:img_size决定显存占用和细节保留,512 是精度和速度的平衡点;augment控制是否翻转,医学图像分割中翻转要谨慎,因为器官位置有方向性;mean和std必须和预训练模型一致,否则微调效果会掉;Image.NEAREST用于掩码缩放,用双线性会引入不存在的类别 ID。
3.2 DeepLabV3+ 的损失函数与学习率调度
DeepLabV3+ 常用交叉熵损失加辅助损失。下面这段训练循环里,ignore_index=255忽略未标注像素,lr用多项式衰减。
import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader model = torch.hub.load("pytorch/vision", "deeplabv3_resnet50", pretrained=True) model.classifier[4] = nn.Conv2d(256, NUM_CLASSES, kernel_size=1) model = model.cuda() criterion = nn.CrossEntropyLoss(ignore_index=255) optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4) scheduler = optim.lr_scheduler.PolynomialLR(optimizer, total_iters=100, power=0.9) loader = DataLoader(SegDataset("images", "masks"), batch_size=8, shuffle=True, num_workers=4) for epoch in range(100): model.train() for img, mask in loader: img, mask = img.cuda(), mask.cuda() optimizer.zero_grad() output = model(img)["out"] loss = criterion(output, mask) loss.backward() optimizer.step() scheduler.step() print(f"epoch {epoch}, lr {optimizer.param_groups[0]['lr']:.6f}")ignore_index=255是语义分割的标配,标注时未覆盖区域填 255。PolynomialLR的power=0.9让学习率缓慢下降,比阶梯下降更平滑。batch_size=8在 11GB 显存下跑 512×512 输入比较稳,如果爆显存先降img_size再降 batch。
3.3 用 mIoU 验证语义分割效果
mIoU 是语义分割最常用的指标,计算每个类别的交并比再平均。下面这段代码在验证集上算 mIoU。
def compute_miou(pred, target, num_classes): # pred: [B, H, W] 类别索引, target: [B, H, W] ious = [] for cls in range(num_classes): pred_mask = (pred == cls) target_mask = (target == cls) intersection = (pred_mask & target_mask).sum().item() union = (pred_mask | target_mask).sum().item() if union == 0: continue ious.append(intersection / union) return sum(ious) / len(ious) if ious else 0.0注意union == 0时跳过,否则会除零。如果某个类别在验证集里没出现,跳过是合理的,但要在日志里记录哪些类别被跳过了,否则 mIoU 会虚高。
4. 实例分割与全景分割的落地路径:Mask R-CNN 与 Mask2Former
4.1 用 torchvision 跑通 Mask R-CNN 实例分割
Mask R-CNN 在 torchvision 里有现成实现,替换box_predictor和mask_predictor的类别数即可。
import torchvision from torchvision.models.detection.faster_rcnn import FastRCNNPredictor from torchvision.models.detection.mask_rcnn import MaskRCNNPredictor def get_mask_rcnn(num_classes): model = torchvision.models.detection.maskrcnn_resnet50_fpn(pretrained=True) # 替换框分类头 in_features = model.roi_heads.box_predictor.cls_score.in_features model.roi_heads.box_predictor = FastRCNNPredictor(in_features, num_classes) # 替换掩码头 in_features_mask = model.roi_heads.mask_predictor.conv5_mask.in_channels model.roi_heads.mask_predictor = MaskRCNNPredictor(in_features_mask, 256, num_classes) return modelnum_classes要包含背景类,所以实际类别数加 1。Mask R-CNN 的输入是List[Tensor],每张图尺寸可以不同,但为了 batch 训练,通常用GeneralizedRCNNTransform统一缩放。训练时targets需要boxes、labels、masks三个字段,masks是[N, 1, H, W]的浮点张量。
4.2 全景分割用 Mask2Former 的推理与后处理
Mask2Former 把全景分割统一成掩码分类问题,输出pred_masks、pred_logits、pred_boxes。后处理时用匈牙利匹配或阈值筛选,把掩码分配到stuff和thing两类。
from transformers import Mask2FormerForUniversalSegmentation, Mask2FormerImageProcessor processor = Mask2FormerImageProcessor.from_pretrained("facebook/mask2former-swin-base-coco-panoptic") model = Mask2FormerForUniversalSegmentation.from_pretrained("facebook/mask2former-swin-base-coco-panoptic") inputs = processor(images=image, return_tensors="pt") with torch.no_grad(): outputs = model(**inputs) # 后处理得到全景分割图 result = processor.post_process_panoptic_segmentation(outputs, target_sizes=[image.size[::-1]])[0] panoptic_map = result["segmentation"] # H×W 的 ID 图 segments_info = result["segments_info"] # 每个 segment 的类别和 IDpost_process_panoptic_segmentation会自动处理thing和stuff的合并,segments_info里isthing字段区分两者。如果显存不够,把swin-base换成swin-tiny,但小目标分割质量会下降。
4.3 三类分割任务的评估指标对照
| 任务 | 常用指标 | 计算方式 | 注意事项 |
|---|---|---|---|
| 语义分割 | mIoU | 每类交并比平均 | 忽略未标注像素 |
| 实例分割 | AP@[0.5:0.95] | 掩码 IoU 阈值下的平均精度 | 按类别和面积分组 |
| 全景分割 | PQ | 识别质量 SQ × 分割质量 RQ | 分别算 thing 和 stuff |
PQ 的计算里,SQ 是匹配上的 segment 的平均 IoU,RQ 是匹配率。如果 PQ 低但 SQ 高,说明漏检多;如果 SQ 低,说明掩码边界不准。
5. 避坑与排查:图像分割训练里最常见的五个翻车现场
5.1 损失不下降,mIoU 卡在 0.1 以下
现象:训练几个 epoch 后 loss 震荡,mIoU 几乎不变。原因通常是类别不平衡,背景像素占 90% 以上,模型学会全预测背景。解决:用WeightedCrossEntropyLoss给稀有类别加权,或者改用 Dice Loss 加交叉熵的组合。权重按类别频率的倒数设置,但不要超过 10,否则训练不稳定。
5.2 验证集 mIoU 很高,推理时掩码全是噪声
现象:验证集指标 0.8,实际跑单张图输出彩色噪点。原因多半是归一化不一致,训练时用了 ImageNet 均值方差,推理时忘了减均值除方差。解决:把预处理封装成一个函数,训练和推理共用,避免手写两套。另外检查model.eval()和torch.no_grad()是否加上,BatchNorm 在训练模式下会用当前 batch 的统计量,单张推理时统计量偏差极大。
5.3 实例分割的掩码边界比检测框大一圈
现象:Mask R-CNN 输出的掩码超出目标实际边界。原因:mask_predictor的输出是 28×28,上采样到原图时用了双线性插值,边界被平滑放大。解决:在post_process里对掩码做阈值化,通常取 0.5,然后用paste_masks_in_image的padding参数控制边界。如果还大,把MaskRCNNPredictor的dim_reduced从 256 降到 128,减少过拟合。
5.4 全景分割的 thing 和 stuff 类别 ID 冲突
现象:全景 ID 图里同一像素被分配了两个类别。原因:后处理时thing和stuff的掩码没有做非极大值抑制。解决:先按置信度排序,逐个写入 ID 图,已占用的像素跳过。Mask2Former 的post_process_panoptic_segmentation内部已经做了,但如果自己写后处理,必须加这一步。
5.5 多卡训练时 mIoU 比单卡低
现象:单卡 mIoU 0.75,四卡 DDP 后掉到 0.6。原因:学习率没随 batch size 线性放大,或者 BatchNorm 在 DDP 下同步统计量导致小 batch 不稳定。解决:lr乘以卡数,或者改用SyncBatchNorm。如果还不行,把batch_size设成每卡 4 以上,太小就换GroupNorm。
6. 把训练好的分割模型塞进推理管线:ONNX 导出与量化提速
训练完的模型要落地,第一步是导出 ONNX。下面这段代码把 DeepLabV3+ 导出并做动态量化。
import torch.onnx model.eval() dummy = torch.randn(1, 3, 512, 512).cuda() torch.onnx.export( model, dummy, "deeplabv3.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, opset_version=11 ) # 动态量化,把 Conv 和 Linear 的权重转成 int8 import torch.quantization quantized_model = torch.quantization.quantize_dynamic( model, {nn.Conv2d, nn.Linear}, dtype=torch.qint8 ) torch.save(quantized_model.state_dict(), "deeplabv3_quantized.pth")opset_version=11兼容大多数推理引擎,dynamic_axes让 batch 维度可变。动态量化对 Conv 的加速有限,因为 Conv 的计算瓶颈在乘加,int8 的收益不如 Transformer 明显。如果追求极致速度,用 TensorRT 做 FP16 或 INT8 校准,但校准集要覆盖所有类别,否则稀有类别精度会崩。
验证量化后精度的方法:在验证集上跑一遍量化模型,对比 mIoU 下降是否超过 2 个百分点。如果超过,只量化全连接层,保留卷积层为 FP32。我一般会保留一份 FP32 模型做兜底,量化模型只在边缘设备上用。
最后说个血泪教训:导出 ONNX 前一定要把模型设为eval(),并且用torch.no_grad()包住导出过程。我有一次忘了设 eval,BatchNorm 的 running_mean 被更新,导出的模型推理结果和训练时完全对不上,排查了一整天才发现是这行代码。希望帮到你。
本文还有配套的精品资源,点击获取