Post

Image Segmentation

整理图像分割的任务类型、掩码格式与数据增强、U-Net 的实现、交叉熵与 Dice 损失、训练与评价指标、推理后处理,以及 torchvision 预训练分割模型的使用

Image Segmentation
深度学习系列:第 18 篇,共 22 篇

用 PyTorch 从搭建模型、训练、调试到部署的一组笔记。按下面的顺序阅读,后面的文章会直接引用前面讲过的内容

模型结构
  1. PyTorch CNN
  2. PyTorch Transformer
  3. Vision Transformer
训练
  1. PyTorch Data Loading
  2. PyTorch Training
  3. Optimizers and Learning Rate Schedules
  4. PyTorch Checkpoints and Reproducibility
  5. Normalization and Regularization
  6. PyTorch Debugging
  7. PyTorch Common Mistakes
  8. PyTorch Memory and Speed
  9. Evaluation Metrics
  10. TensorBoard
  11. Experiment Tracking
  12. PyTorch Distributed Training
任务
  1. PyTorch Fine-Tuning
  2. Object Detection
  3. Image Segmentation
  4. Keypoint Detection
  5. Generative Models
部署
  1. PyTorch Deployment
  2. Model Compression

图像分割对图像中的每一个像素做分类。与Object Detection中用矩形框描述目标不同,分割的结果能精确到目标的边界,代价是标注、模型输出和后处理都以像素为单位,数据处理中的错误也更隐蔽:插值方式用错、图像和掩码没有同步变换,都不会报错,只会让模型学到错误的标签

分割任务的类型

任务输出能否区分同类的不同个体常用模型
语义分割一张类别图,每个像素一个类别编号不能FCN、U-Net、DeepLab
实例分割每个目标一个二值掩码、一个类别和一个分数能,只处理可数的目标Mask R-CNN、YOLO 的分割版
全景分割每个像素一个类别编号,可数的目标再加实例编号能Panoptic FPN、Mask2Former

同一张图像的语义分割、实例分割与全景分割结果

  • 语义分割把两个相互接触的圆画成同一种颜色,只知道这些像素是圆,不知道有几个
  • 实例分割可以看作检测的延伸:先找到每个目标,再在框内预测掩码。它只关心人、车这类可数的目标(things),天空、地面、道路这类不可数的区域(stuff)不输出
  • 全景分割把两者合并:每个像素都有类别,属于可数目标的像素还有实例编号

本文以语义分割为主,它是另外两种任务的基础。实例分割在torchvision.models.detection中有maskrcnn_resnet50_fpn,输出格式与检测模型相同,只是每个目标多一个masks字段

标注与数据

示例数据集

下文用一个合成的数据集演示完整的流程:64x64 的噪声背景上随机画 1 到 3 个圆、矩形或三角形,颜色随机,模型只能从形状判断类别。数据集在内存中生成,不需要下载,保存为shapes.py供后面的代码导入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import cv2
import numpy as np
import torch
from torch.utils.data import Dataset

CLASSES = ['背景', '圆', '矩形', '三角形']


def make_sample(rng, size=64):
    """生成一张图像和它的掩码。image: (size, size, 3) uint8,mask: (size, size) uint8"""
    image = rng.integers(0, 80, (size, size, 3)).astype(np.uint8)  # 噪声背景
    mask = np.zeros((size, size), np.uint8)
    for _ in range(rng.integers(1, 4)):  # 1 到 3 个图形,后画的会遮挡先画的
        cls = int(rng.integers(1, 4))
        cx, cy = (int(v) for v in rng.integers(10, size - 10, 2))
        w, h = (int(v) for v in rng.integers(5, 10, 2))  # 半宽和半高
        shape = np.zeros((size, size), np.uint8)
        if cls == 1:
            cv2.circle(shape, (cx, cy), w, 1, -1)
        elif cls == 2:
            cv2.rectangle(shape, (cx - w, cy - h), (cx + w, cy + h), 1, -1)
        else:
            points = np.array([[cx, cy - h], [cx - w, cy + h], [cx + w, cy + h]])
            cv2.fillPoly(shape, [points], 1)
        # 颜色是随机的,类别只能从形状判断
        image[shape == 1] = rng.integers(120, 256, 3)
        mask[shape == 1] = cls
    noise = rng.normal(0, 12, image.shape)
    image = np.clip(image + noise, 0, 255).astype(np.uint8)
    return image, mask


class ShapesDataset(Dataset):
    def __init__(self, length, seed):
        rng = np.random.default_rng(seed)
        samples = [make_sample(rng) for _ in range(length)]
        self.images = np.stack([s[0] for s in samples])  # (length, 64, 64, 3) uint8
        self.masks = np.stack([s[1] for s in samples])   # (length, 64, 64) uint8

    def __len__(self):
        return len(self.images)

    def __getitem__(self, index):
        image = torch.from_numpy(self.images[index]).permute(2, 0, 1).float() / 255  # (3, 64, 64),0 到 1
        mask = torch.from_numpy(self.masks[index]).long()                            # (64, 64),int64
        return image, mask


if __name__ == '__main__':
    dataset = ShapesDataset(1600, seed=0)
    image, mask = dataset[0]
    print(image.shape, image.dtype)  # torch.Size([3, 64, 64]) torch.float32
    print(mask.shape, mask.dtype)    # torch.Size([64, 64]) torch.int64
    print(mask.unique())

    # 各个类别的像素所占的比例
    counts = np.bincount(dataset.masks.ravel(), minlength=len(CLASSES))
    for name, ratio in zip(CLASSES, counts / counts.sum()):
        print(f'{name} {ratio:.1%}')
1
2
3
4
5
6
7
torch.Size([3, 64, 64]) torch.float32
torch.Size([64, 64]) torch.int64
tensor([0, 1, 3])
背景 92.2%
圆 2.5%
矩形 3.4%
三角形 1.8%

背景占了 92% 的像素,这是分割数据的常态,后面的损失函数和评价指标都要考虑这一点

掩码的格式

格式形状数据类型用途
类别索引图(H, W)整数,取值为类别编号标注文件、cross_entropy的标签
one-hot(C, H, W)0 或 1计算 Dice 损失、多标签分割
二值掩码(H, W)bool 或 0/1单个类别或单个实例,实例分割的输出
调色板 PNG(H, W)uint8,带调色板Pascal VOC 等数据集的标注文件

忽略标签通常取 255:图像边缘、目标之间难以判断的边界、旋转后填充的区域标为 255,计算损失和指标时跳过这些像素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import cv2
import numpy as np
import torch
import torch.nn.functional as F
from PIL import Image

from shapes import make_sample

image, mask = make_sample(np.random.default_rng(0))
print(image.shape, image.dtype)  # (64, 64, 3) uint8
print(mask.shape, mask.dtype)    # (64, 64) uint8
print(np.unique(mask))           # [0 1 3],这张图像中有背景、圆和三角形

# 类别索引图 -> one-hot:(H, W) -> (H, W, C) -> (C, H, W)
index = torch.from_numpy(mask).long()
onehot = F.one_hot(index, num_classes=4).permute(2, 0, 1)
print(onehot.shape, onehot.dtype)             # torch.Size([4, 64, 64]) torch.int64
print(onehot.sum(dim=0).unique())             # tensor([1]),每个像素只属于一个类别
print(torch.equal(onehot.argmax(dim=0), index))  # True,argmax 可以还原

# 某一个类别的二值掩码
circle = index == 1
print(circle.shape, circle.dtype, circle.sum().item())

# 掩码必须用无损格式保存:JPEG 压缩会改变像素值,也就改变了类别编号
_, png = cv2.imencode('.png', mask)
_, jpg = cv2.imencode('.jpg', mask)
print(np.unique(cv2.imdecode(png, cv2.IMREAD_GRAYSCALE)))  # [0 1 3]
print(np.unique(cv2.imdecode(jpg, cv2.IMREAD_GRAYSCALE)))

# 调色板 PNG(Pascal VOC 的标注格式):用 PIL 读到的是类别编号,用 OpenCV 读到的是颜色
palette = Image.fromarray(mask, mode='P')
palette.putpalette([0, 0, 0, 128, 0, 0, 0, 128, 0, 128, 128, 0])
palette.save('mask.png')
print(np.unique(np.array(Image.open('mask.png'))))                         # [0 1 3]
print(np.unique(cv2.imread('mask.png').reshape(-1, 3), axis=0).tolist())  # BGR 颜色
1
2
3
4
5
6
7
8
9
10
11
(64, 64, 3) uint8
(64, 64) uint8
[0 1 3]
torch.Size([4, 64, 64]) torch.int64
tensor([1])
True
torch.Size([64, 64]) torch.bool 219
[0 1 3]
[0 1 2 3 4]
[0 1 3]
[[0, 0, 0], [0, 0, 128], [0, 128, 128]]
  • 用 JPEG 保存的掩码读回来多出了 2 和 4 两个类别:有损压缩会在边界附近产生新的像素值,掩码必须保存为 PNG
  • 调色板 PNG 中存的是类别编号,显示出来是彩色的。用PIL读取得到编号;用cv2.imread读取会按调色板转换成 BGR 颜色,得到的不是类别编号

插值方式

缩放图像时常用双线性插值,它在相邻像素之间取加权平均。对掩码这样做,两个类别的交界处会出现介于两者之间的数值,取整后就是其他类别的编号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import torch
import torch.nn.functional as F
from torchvision import tv_tensors
from torchvision.transforms import v2

# 只有背景(0)和三角形(3)两个类别的掩码
mask = torch.zeros(64, 64, dtype=torch.uint8)
mask[16:48, 16:48] = 3

# 错误:按图像的方式用双线性插值放大
x = mask[None, None].float()  # interpolate 要求 (N, C, H, W) 的浮点张量
bilinear = F.interpolate(x, size=(100, 100), mode='bilinear', align_corners=False).round().long()
print(bilinear.unique().tolist(), torch.bincount(bilinear.flatten()).tolist())

# 正确:最近邻插值
nearest = F.interpolate(x, size=(100, 100), mode='nearest').long()
print(nearest.unique().tolist(), torch.bincount(nearest.flatten()).tolist())

# torchvision 的变换把普通张量当作图像处理,同样是双线性插值
print(v2.functional.resize(mask[None], [100, 100]).unique().tolist())
# 包装成 tv_tensors.Mask 之后自动使用最近邻插值
print(v2.functional.resize(tv_tensors.Mask(mask), [100, 100]).unique().tolist())
1
2
3
4
[0, 1, 2, 3] [7308, 192, 196, 2304]
[0, 3] [7500, 0, 0, 2500]
[0, 1, 2, 3]
[0, 3]

原本只有背景(0)和三角形(3)的掩码,用双线性插值放大后,在两者的交界处多出了 192 个标为 1(圆)和 196 个标为 2(矩形)的像素,模型会被要求在三角形的边上预测出圆和矩形。最近邻插值只会复制已有的值,不会产生新的类别

掩码用双线性插值放大后在类别交界处出现新的类别编号,最近邻插值则没有

torchvision.transforms.v2根据输入的类型决定插值方式:普通张量和tv_tensors.Image当作图像,用双线性插值;tv_tensors.Mask当作掩码,自动改用最近邻插值

同步的几何变换

数据增强中的几何变换(裁剪、翻转、旋转、缩放)必须对图像和掩码使用同一组随机参数,颜色变换(亮度、对比度、模糊)只作用于图像。transforms.v2的做法是把图像和掩码在同一次调用中传入,各自包装成对应的tv_tensors类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import numpy as np
import torch
from torchvision import tv_tensors
from torchvision.transforms import v2

from shapes import make_sample

image, mask = make_sample(np.random.default_rng(0))
image = tv_tensors.Image(torch.from_numpy(image).permute(2, 0, 1))  # (3, 64, 64) uint8
mask = tv_tensors.Mask(torch.from_numpy(mask))                      # (64, 64) uint8

transform = v2.Compose([
    v2.RandomResizedCrop(64, scale=(0.5, 1.0)),  # 几何变换:图像和掩码使用同一组随机参数
    v2.RandomHorizontalFlip(),
    v2.RandomRotation(15, fill={ tv_tensors.Image: 0, tv_tensors.Mask: 255 }),  # 旋转后露出的区域在掩码中填 255
    v2.ColorJitter(brightness=0.3, contrast=0.3),  # 颜色变换:只作用于图像
    v2.ToDtype({ tv_tensors.Image: torch.float32, tv_tensors.Mask: torch.int64 }, scale=True),
])

torch.manual_seed(0)
out_image, out_mask = transform(image, mask)
print(type(out_image).__name__, out_image.shape, out_image.dtype)
print(type(out_mask).__name__, out_mask.shape, out_mask.dtype)
print(mask.unique().tolist(), '->', out_mask.unique().tolist())



def agreement(image, mask):
    """图形比背景亮:统计“像素是否明亮”与“掩码是否为前景”一致的比例,用来检查图像和掩码是否对齐"""
    bright = image.max(dim=0).values > 115
    return ((mask > 0) == bright).float().mean().item()


print(f'{agreement(image, mask):.4f}')

# 正确:图像和掩码一起传入,使用同一组随机参数
crop = v2.RandomCrop(32)
torch.manual_seed(0)
crop_image, crop_mask = crop(image, mask)
print(f'{agreement(crop_image, crop_mask):.4f}')

# 错误 1:对图像和掩码分别调用,两次调用的随机参数不同
torch.manual_seed(0)
crop_image, crop_mask = crop(image), crop(mask)
print(f'{agreement(crop_image, crop_mask):.4f}')

# 错误 2:掩码是普通张量。有 tv_tensors.Image 在场时,其余的普通张量被原样返回,不做任何变换
flip = v2.RandomHorizontalFlip(p=1.0)
plain_mask = mask.as_subclass(torch.Tensor)
flip_image, flip_mask = flip(image, plain_mask)
print(torch.equal(flip_mask, plain_mask), f'{agreement(flip_image, flip_mask):.4f}')
1
2
3
4
5
6
7
Image torch.Size([3, 64, 64]) torch.float32
Mask torch.Size([64, 64]) torch.int64
[0, 1, 3] -> [0, 1, 3, 255]
1.0000
1.0000
0.8428
True 0.7627
  • ToDtype对图像缩放到 0 到 1 的浮点数,对掩码只转换类型(int64是cross_entropy要求的标签类型),scale=True不会作用于掩码
  • 旋转后露出的角落在图像中填 0,在掩码中填 255。如果掩码也填 0,这些黑色区域会被当作背景参与训练
  • agreement检查的是图形区域与掩码前景是否重合,原图上为 1。一起传入时裁剪后仍然是 1;分别调用时两次裁剪的位置不同,降到 0.8428
  • 普通张量和tv_tensors.Image一起传入时,transforms.v2把普通张量当作不需要变换的附加数据原样返回。图像翻转了,掩码没有,也不会有任何警告

这类错误不会报错,训练也能进行,只是指标始终上不去。 写好数据管道后,把增强后的图像和掩码叠加画出来检查一遍,比任何单元测试都直接

模型结构

编码器与解码器

分类网络通过不断下采样得到一个全局的特征向量,分割需要的是与输入同样大小的输出。分割网络通常分为两部分:

部分作用分辨率的变化
编码器提取特征,通常就是去掉分类头的分类网络逐步缩小
解码器把特征放大回输入的分辨率,对每个像素做分类逐步放大

深层特征语义强但分辨率低,直接放大 8 倍或 32 倍得到的边界很粗糙。U-Net 在每一次上采样之后,把编码器中同分辨率的浅层特征拼接进来,用来恢复细节。上采样与拼接的形状计算见PyTorch CNN中的上采样拼接一节

一个小型 U-Net

下面的 U-Net 有 3 次下采样,基础通道数为 16。最后的 1x1 卷积把通道数变为类别数,输出形状为 (N, C, H, W),即每个像素一组未归一化的分数(logits)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import torch
import torch.nn as nn


class DoubleConv(nn.Sequential):
    """两个 3x3 卷积,不改变高度和宽度"""

    def __init__(self, in_channels, out_channels):
        super().__init__(
            nn.Conv2d(in_channels, out_channels, 3, padding=1, bias=False),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(inplace=True),
            nn.Conv2d(out_channels, out_channels, 3, padding=1, bias=False),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(inplace=True),
        )


class UpBlock(nn.Module):
    def __init__(self, in_channels, skip_channels, out_channels):
        super().__init__()
        self.up = nn.ConvTranspose2d(in_channels, in_channels // 2, kernel_size=2, stride=2)
        self.conv = DoubleConv(in_channels // 2 + skip_channels, out_channels)

    def forward(self, x, skip):
        x = self.up(x)                   # 高度和宽度放大 2 倍
        x = torch.cat([x, skip], dim=1)  # 与编码器中同分辨率的特征拼接
        return self.conv(x)


class UNet(nn.Module):
    def __init__(self, in_channels=3, num_classes=4, base=16):
        super().__init__()
        # 编码器:每下采样一次,通道数翻倍
        self.enc1 = DoubleConv(in_channels, base)
        self.enc2 = DoubleConv(base, base * 2)
        self.enc3 = DoubleConv(base * 2, base * 4)
        self.bottleneck = DoubleConv(base * 4, base * 8)
        self.pool = nn.MaxPool2d(2)
        # 解码器:每上采样一次,通道数减半
        self.dec3 = UpBlock(base * 8, base * 4, base * 4)
        self.dec2 = UpBlock(base * 4, base * 2, base * 2)
        self.dec1 = UpBlock(base * 2, base, base)
        # 1x1 卷积把通道数变为类别数,每个像素得到一组分类的 logits
        self.head = nn.Conv2d(base, num_classes, kernel_size=1)

    def forward(self, x):
        e1 = self.enc1(x)                     # (N, 16, H, W)
        e2 = self.enc2(self.pool(e1))         # (N, 32, H/2, W/2)
        e3 = self.enc3(self.pool(e2))         # (N, 64, H/4, W/4)
        b = self.bottleneck(self.pool(e3))    # (N, 128, H/8, W/8)
        d3 = self.dec3(b, e3)                 # (N, 64, H/4, W/4)
        d2 = self.dec2(d3, e2)                # (N, 32, H/2, W/2)
        d1 = self.dec1(d2, e1)                # (N, 16, H, W)
        return self.head(d1)                  # (N, num_classes, H, W)


if __name__ == '__main__':
    model = UNet()
    x = torch.randn(2, 3, 64, 64)
    print(model(x).shape)  # torch.Size([2, 4, 64, 64])
    print(sum(p.numel() for p in model.parameters()))

    # 高度和宽度必须能被 8 整除(3 次下采样),否则拼接时尺寸对不上
    try:
        model(torch.randn(2, 3, 100, 100))
    except RuntimeError as e:
        print(e)
1
2
3
torch.Size([2, 4, 64, 64])
482788
Sizes of tensors must match except in dimension 1. Expected size 24 but got size 25 for tensor number 1 in the list.

U-Net 的编码器、解码器与跳跃连接,以及每一层输出的形状

100x100 的输入在第 3 次池化后变成 12x12,放大回来是 24x24,与编码器中 25x25 的特征无法拼接。常见的处理方式是把输入填充到 8 的倍数,推理后再裁掉;或者在拼接前用F.interpolate把上采样的结果缩放到与跳跃连接相同的尺寸

其他结构与 torchvision 中的模型

结构思路
FCN把分类网络的全连接层换成卷积,直接把最后的特征图放大到原图
U-Net对称的编码器和解码器,每一级都有跳跃连接,适合小数据集
DeepLab用空洞卷积代替部分下采样,ASPP 模块用不同膨胀率的卷积并行提取多尺度特征
LR-ASPP为 MobileNetV3 设计的轻量分割头,适合移动端

torchvision.models.segmentation中提供的预训练模型:

函数骨干网络输出字典中的键
fcn_resnet50、fcn_resnet101ResNetout、aux
deeplabv3_resnet50、deeplabv3_resnet101ResNetout、aux
deeplabv3_mobilenet_v3_largeMobileNetV3-Largeout、aux
lraspp_mobilenet_v3_largeMobileNetV3-Largeout

它们的预训练权重都是在 COCO 的子集上按 Pascal VOC 的 20 个类别训练的,用法见最后一节

损失函数

像素级交叉熵

分割的交叉熵就是对每个像素分别计算分类的交叉熵,再取平均。F.cross_entropy直接支持多维输入,对形状的要求如下

参数形状数据类型说明
input(N, C, H, W)浮点网络输出的 logits,不做 softmax
target(N, H, W)int64类别编号,没有通道维度
weight(C,)浮点每个类别的权重,可选
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import torch
import torch.nn.functional as F

torch.manual_seed(0)
logits = torch.randn(2, 4, 64, 64)         # 网络输出 (N, C, H, W),不要先做 softmax
target = torch.randint(0, 4, (2, 64, 64))  # 标签 (N, H, W),int64,取值为类别编号

print(F.cross_entropy(logits, target))

# 常见的形状和类型错误
for name, wrong in [('多了通道维度', target[:, None]), ('uint8', target.to(torch.uint8)), ('float32', target.float())]:
    try:
        F.cross_entropy(logits, wrong)
    except Exception as e:
        print(f'{name}: {type(e).__name__}: {str(e).splitlines()[0]}')

# 忽略标签:把一部分像素标为 255
ignored = target.clone()
ignored[:, :16] = 255
try:
    F.cross_entropy(logits, ignored)
except Exception as e:
    print(f'{type(e).__name__}: {e}')
loss = F.cross_entropy(logits, ignored, ignore_index=255)
print(loss)
# 等价于只在没有被忽略的像素上取平均
per_pixel = F.cross_entropy(logits, ignored, ignore_index=255, reduction='none')  # (N, H, W),被忽略的位置为 0
print(per_pixel.shape, per_pixel[ignored != 255].mean())

# 类别权重:取平均时除以的是权重之和,不是像素数
weight = torch.tensor([0.2, 1.0, 1.0, 1.0])
weighted = F.cross_entropy(logits, target, weight=weight)
per_pixel = F.cross_entropy(logits, target, weight=weight, reduction='none')
print(weighted, per_pixel.sum() / weight[target].sum())
1
2
3
4
5
6
7
8
tensor(1.7180)
多了通道维度: RuntimeError: only batches of spatial targets supported (3D tensors) but got targets of dimension: 4
uint8: RuntimeError: expected scalar type Long but found Byte
float32: RuntimeError: expected target dtype to be Long or Byte, but got Float
IndexError: Target 255 is out of bounds.
tensor(1.7132)
torch.Size([2, 64, 64]) tensor(1.7132)
tensor(1.7150) tensor(1.7150)
  • 从数据集中读出的掩码常常是 (N, 1, H, W) 或uint8,要先squeeze(1)并转成long()
  • 标签中有 255 而没有设置ignore_index时会直接报错;ignore_index的默认值是 -100,不是 255,需要显式传入
  • 设置了weight之后,reduction='mean'除以的是这些像素的权重之和,不是像素数

前景很小时

前景只占很小比例时,交叉熵的大部分可以靠把背景预测对来降低。下面构造一个前景只占 0.88% 的二分类例子,比较几种预测下的交叉熵和 Dice 损失。Dice 损失是 1 减去 Dice 系数,对每个类别分别计算再取平均,用概率代替 0/1 的预测使它可导,保存为losses.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import torch
import torch.nn.functional as F


def dice_loss(logits, target, ignore_index=255, eps=1.0):
    """logits: (N, C, H, W),target: (N, H, W)。对每个类别计算 Dice,再对类别取平均"""
    num_classes = logits.shape[1]
    valid = target != ignore_index                                # (N, H, W)
    prob = logits.softmax(dim=1) * valid[:, None]                 # 被忽略的像素不参与计算
    onehot = F.one_hot(target * valid, num_classes).permute(0, 3, 1, 2) * valid[:, None]
    inter = (prob * onehot).sum(dim=(0, 2, 3))                    # (C,),在整个批次上求和
    total = prob.sum(dim=(0, 2, 3)) + onehot.sum(dim=(0, 2, 3))
    dice = (2 * inter + eps) / (total + eps)                      # eps 避免某个类别不存在时出现 0/0
    return 1 - dice.mean()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import torch
import torch.nn.functional as F

from losses import dice_loss


def report(name, logits, target):
    ce = F.cross_entropy(logits, target, reduction='none')
    accuracy = (logits.argmax(dim=1) == target).float().mean()
    foreground_share = ce[target == 1].sum() / ce.sum()
    print(f'{name}  准确率 {accuracy:.4f}  交叉熵 {ce.mean():.4f}(前景像素贡献 {foreground_share:.1%})  Dice 损失 {dice_loss(logits, target):.4f}')


# 二分类,64x64 的图像中前景只有一个 6x6 的方块,占 0.88%
target = torch.zeros(1, 64, 64, dtype=torch.long)
target[:, 20:26, 20:26] = 1
print(f'{target.float().mean():.4f}')


def make_logits(foreground_mask, margin=8.0):
    """在 foreground_mask 为 True 的位置预测前景,其余位置预测背景,两个类别的 logit 相差 margin"""
    logits = torch.zeros(1, 2, 64, 64)
    logits[:, 0] = torch.where(foreground_mask, -margin / 2, margin / 2)
    logits[:, 1] = -logits[:, 0]
    return logits


half = torch.zeros_like(target, dtype=torch.bool)
half[:, 20:26, 20:23] = True

report('输出均匀  ', torch.zeros(1, 2, 64, 64), target)
report('全预测背景', make_logits(torch.zeros_like(half)), target)
report('找到一半  ', make_logits(half), target)
report('完全正确  ', make_logits(target == 1), target)
1
2
3
4
5
0.0088
输出均匀    准确率 0.9912  交叉熵 0.6931(前景像素贡献 0.9%)  Dice 损失 0.6587
全预测背景  准确率 0.9912  交叉熵 0.0706(前景像素贡献 99.5%)  Dice 损失 0.4889
找到一半    准确率 0.9956  交叉熵 0.0355(前景像素贡献 99.1%)  Dice 损失 0.1730
完全正确    准确率 1.0000  交叉熵 0.0003(前景像素贡献 0.9%)  Dice 损失 0.0093
  • 从均匀的输出到全部预测为背景,交叉熵从 0.6931 降到 0.0706,已经走完了 90% 的路,而一个前景像素都没有找到。准确率也已经是 0.9912
  • Dice 损失对类别取平均,前景这个类别和背景的权重相同。全预测背景时前景的 Dice 是 0,损失仍有 0.4889

下面在真实的训练中验证这一点:每张 64x64 的图像中只有一个半径为 2 的圆点,前景占 0.32%,分别只用交叉熵和用交叉熵加 Dice 损失训练同一个 U-Net

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import cv2
import numpy as np
import torch
import torch.nn.functional as F

from losses import dice_loss
from unet import UNet


def make_data(length, seed):
    """单通道的噪声图像,每张图像中有一个半径为 2 的圆点,只比背景略亮"""
    rng = np.random.default_rng(seed)
    images = rng.normal(0.3, 0.1, (length, 1, 64, 64)).astype(np.float32)
    masks = np.zeros((length, 64, 64), np.int64)
    for i in range(length):
        cx, cy = (int(v) for v in rng.integers(5, 59, 2))
        dot = cv2.circle(np.zeros((64, 64), np.uint8), (cx, cy), 2, 1, -1)
        images[i, 0][dot == 1] += 0.15
        masks[i] = dot
    return torch.from_numpy(images), torch.from_numpy(masks)


train_images, train_masks = make_data(1024, seed=0)
val_images, val_masks = make_data(256, seed=1)
print(f'前景像素占比 {train_masks.float().mean():.2%}')

for use_dice in [False, True]:
    torch.manual_seed(0)
    model = UNet(in_channels=1, num_classes=2, base=8)
    optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)
    for step in range(1, 641):
        index = torch.randint(0, len(train_images), (32,))
        model.train()
        logits = model(train_images[index])
        loss = F.cross_entropy(logits, train_masks[index])
        if use_dice:
            loss = loss + dice_loss(logits, train_masks[index])
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        if step % 160 == 0:
            model.eval()
            with torch.no_grad():
                pred = model(val_images).argmax(dim=1)
            accuracy = (pred == val_masks).float().mean()
            inter = ((pred == 1) & (val_masks == 1)).sum()
            union = ((pred == 1) | (val_masks == 1)).sum()
            print(f'{"交叉熵 + Dice" if use_dice else "交叉熵       "}  step {step:3d}  '
                  f'像素准确率 {accuracy:.4f}  前景 IoU {inter / union:.4f}  预测为前景的像素 {(pred == 1).sum().item()}')
1
2
3
4
5
6
7
8
9
前景像素占比 0.32%
交叉熵         step 160  像素准确率 0.9968  前景 IoU 0.0056  预测为前景的像素 58
交叉熵         step 320  像素准确率 0.9968  前景 IoU 0.0000  预测为前景的像素 0
交叉熵         step 480  像素准确率 0.9968  前景 IoU 0.0000  预测为前景的像素 0
交叉熵         step 640  像素准确率 0.9968  前景 IoU 0.0000  预测为前景的像素 0
交叉熵 + Dice  step 160  像素准确率 0.9968  前景 IoU 0.0565  预测为前景的像素 377
交叉熵 + Dice  step 320  像素准确率 0.9969  前景 IoU 0.4753  预测为前景的像素 5791
交叉熵 + Dice  step 480  像素准确率 0.9984  前景 IoU 0.6205  预测为前景的像素 3627
交叉熵 + Dice  step 640  像素准确率 0.9992  前景 IoU 0.7859  预测为前景的像素 3412

只用交叉熵时,模型很快退化成把所有像素都预测为背景,像素准确率停在 0.9968,正好等于背景所占的比例。加上 Dice 损失后,前景 IoU 达到 0.7859。把随机种子换成 1 和 2 再各跑一次,只用交叉熵时 3 次中有 1 次在 640 步时学到了前景(IoU 0.5672),另外 2 次都是全预测背景;加上 Dice 损失的 3 次分别为 0.7859、0.7426、0.7440

处理前景过少的常用方法:

方法做法
交叉熵 + Dice两者直接相加,交叉熵提供稳定的逐像素梯度,Dice 照顾小类别
类别权重cross_entropy的weight,常取类别频率的倒数或其平方根
Focal Loss降低已经分对的像素的权重,见Object Detection
采样裁剪训练图像时优先选择包含前景的区域

Dice 损失单独使用时训练初期不稳定,实践中最常见的组合是交叉熵加 Dice。上一节形状数据集的前景约占 8%,并不算极端,在同样的设置下只用交叉熵训练 8 个 epoch 的 mIoU 为 0.9727,只用 Dice 为 0.9713,两者相加为 0.9742,差别不大

训练

训练循环与分类任务相同(DataLoader 的写法见PyTorch Data Loading,优化器、学习率调度的写法见PyTorch Training),区别只在于输出和标签都是逐像素的。在形状数据集上用交叉熵加 Dice 损失训练 8 个 epoch,每个 epoch 结束后在验证集上累积混淆矩阵,计算像素准确率和 mIoU

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import json

import torch
import torch.nn.functional as F
from torch.utils.data import DataLoader

from losses import dice_loss
from shapes import CLASSES, ShapesDataset
from unet import UNet


@torch.no_grad()
def evaluate(model, loader, num_classes):
    """累积整个验证集的混淆矩阵,返回像素准确率和各类别的 IoU"""
    model.eval()
    matrix = torch.zeros(num_classes, num_classes, dtype=torch.long)  # 行是真实类别,列是预测类别
    for images, masks in loader:
        preds = model(images).argmax(dim=1)
        matrix += torch.bincount(masks.flatten() * num_classes + preds.flatten(),
                                 minlength=num_classes ** 2).view(num_classes, num_classes)
    tp = matrix.diag()
    accuracy = tp.sum() / matrix.sum()
    iou = tp / (matrix.sum(0) + matrix.sum(1) - tp)
    return accuracy.item(), iou


if __name__ == '__main__':
    torch.manual_seed(0)
    train_loader = DataLoader(ShapesDataset(1600, seed=0), batch_size=32, shuffle=True)
    val_loader = DataLoader(ShapesDataset(400, seed=1), batch_size=100)

    epochs = 8
    model = UNet(num_classes=len(CLASSES))
    optimizer = torch.optim.AdamW(model.parameters(), lr=3e-3, weight_decay=1e-4)
    scheduler = torch.optim.lr_scheduler.OneCycleLR(optimizer, max_lr=3e-3, total_steps=epochs * len(train_loader))

    history = { 'loss': [], 'accuracy': [], 'miou': [], 'iou': [] }
    for epoch in range(epochs):
        model.train()
        for images, masks in train_loader:
            logits = model(images)  # (N, 4, 64, 64)
            loss = F.cross_entropy(logits, masks) + dice_loss(logits, masks)
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            scheduler.step()
            history['loss'].append(loss.item())

        accuracy, iou = evaluate(model, val_loader, len(CLASSES))
        history['accuracy'].append(accuracy)
        history['miou'].append(iou.mean().item())
        history['iou'].append(iou.tolist())
        print(f'epoch {epoch + 1:2d}  loss {loss.item():.4f}  像素准确率 {accuracy:.4f}  mIoU {iou.mean():.4f}')

    torch.save(model.state_dict(), 'unet.pt')
    json.dump(history, open('history.json', 'w'))
1
2
3
4
5
6
7
8
epoch  1  loss 2.0852  像素准确率 0.2488  mIoU 0.0705
epoch  2  loss 1.2556  像素准确率 0.9706  mIoU 0.7449
epoch  3  loss 0.1984  像素准确率 0.9911  mIoU 0.8255
epoch  4  loss 0.0903  像素准确率 0.9983  mIoU 0.9691
epoch  5  loss 0.0497  像素准确率 0.9981  mIoU 0.9649
epoch  6  loss 0.0401  像素准确率 0.9986  mIoU 0.9731
epoch  7  loss 0.0370  像素准确率 0.9986  mIoU 0.9741
epoch  8  loss 0.0404  像素准确率 0.9986  mIoU 0.9742

训练损失、验证集上的像素准确率和 mIoU,以及验证集上 6 张图像的标签与预测

第 2 个 epoch 结束时像素准确率已经是 0.9706,mIoU 只有 0.7449;第 3 个 epoch 像素准确率 0.9911,看起来几乎完美,mIoU 仍只有 0.8255。预测结果中的错误主要在图形的边缘和两个图形相互遮挡的地方

评价指标

IoU、Dice 和 mIoU 的定义、由混淆矩阵计算 mIoU 的方法见Evaluation Metrics。这里用训练好的模型比较三个指标,并加上一个把所有像素都预测为背景的基线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import torch
from torch.utils.data import DataLoader

from shapes import CLASSES, ShapesDataset
from unet import UNet

num_classes = len(CLASSES)
model = UNet(num_classes=num_classes)
model.load_state_dict(torch.load('unet.pt'))
model.eval()

loader = DataLoader(ShapesDataset(400, seed=1), batch_size=100)


def metrics(matrix):
    tp = matrix.diag().float()
    accuracy = tp.sum() / matrix.sum()
    iou = tp / (matrix.sum(0) + matrix.sum(1) - tp)
    dice = 2 * tp / (matrix.sum(0) + matrix.sum(1))
    return accuracy, iou, dice


# 在整个验证集上累积两个混淆矩阵:一个是模型的预测,一个是把所有像素都预测为背景
matrix = torch.zeros(num_classes, num_classes, dtype=torch.long)
baseline = torch.zeros(num_classes, num_classes, dtype=torch.long)
with torch.no_grad():
    for images, masks in loader:
        preds = model(images).argmax(dim=1)
        matrix += torch.bincount(masks.flatten() * num_classes + preds.flatten(),
                                 minlength=num_classes ** 2).view(num_classes, num_classes)
        baseline[:, 0] += torch.bincount(masks.flatten(), minlength=num_classes)

print(matrix)
for name, m in [('全预测背景', baseline), ('U-Net', matrix)]:
    accuracy, iou, dice = metrics(m)
    print(f'{name}  像素准确率 {accuracy:.4f}  mIoU {iou.mean():.4f}  平均 Dice {dice.mean():.4f}')
    print('  IoU  ', '  '.join(f'{c} {v:.4f}' for c, v in zip(CLASSES, iou.tolist())))
    print('  Dice ', '  '.join(f'{c} {v:.4f}' for c, v in zip(CLASSES, dice.tolist())))
1
2
3
4
5
6
7
8
9
10
tensor([[1506581,       2,       0,       0],
        [     14,   42629,     412,     136],
        [     27,     597,   58550,     303],
        [     13,     287,     461,   28388]])
全预测背景  像素准确率 0.9195  mIoU 0.2299  平均 Dice 0.2395
  IoU   背景 0.9195  圆 0.0000  矩形 0.0000  三角形 0.0000
  Dice  背景 0.9581  圆 0.0000  矩形 0.0000  三角形 0.0000
U-Net  像素准确率 0.9986  mIoU 0.9742  平均 Dice 0.9869
  IoU   背景 1.0000  圆 0.9671  矩形 0.9702  三角形 0.9594
  Dice  背景 1.0000  圆 0.9833  矩形 0.9849  三角形 0.9793
指标全预测背景U-Net说明
像素准确率0.91950.9986被背景主导,什么都不做也有 0.92
mIoU0.22990.9742每个类别权重相同,能反映前景的好坏
平均 Dice0.23950.9869与 mIoU 单调对应,数值总是更高
  • 混淆矩阵中前景之间的混淆(圆被预测为矩形 412 个像素,矩形被预测为圆 597 个像素)远多于前景与背景之间的混淆,说明模型找到了图形的位置,错误主要在相互遮挡处的类别判断
  • 报告结果时 mIoU 和平均 Dice 要写明是哪一个,是否包含背景类。去掉背景类后,U-Net 的 mIoU 为三个前景类别的平均值 0.9656
  • 指标要在整个验证集上累积混淆矩阵之后再计算,不能对每个批次或每张图像的 mIoU 取平均

推理与后处理

从 logits 到类别图

推理时对类别维度取argmax得到类别图。输入图像常常需要缩放后才能送入网络,结果需要还原到原图尺寸,这时应当先用双线性插值放大 logits,再取argmax;先取argmax再用最近邻插值放大类别图,边界会呈锯齿状

下面用一张 256x256 的图像,把它缩小到训练时的 64x64 再预测,然后还原到原图尺寸,并把结果拆成单个的目标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import cv2
import numpy as np
import torch
import torch.nn.functional as F

from shapes import CLASSES
from unet import UNet

# 一张 256x256 的图像:两个圆、两个相邻的矩形、一个三角形
rng = np.random.default_rng(0)
image = rng.integers(0, 80, (256, 256, 3)).astype(np.uint8)
truth = np.zeros((256, 256), np.uint8)
for cls, color, draw in [
    (1, (235, 150, 60), lambda m: cv2.circle(m, (56, 56), 28, 1, -1)),
    (1, (120, 200, 230), lambda m: cv2.circle(m, (200, 60), 24, 1, -1)),
    (2, (200, 130, 220), lambda m: cv2.rectangle(m, (24, 150), (88, 214), 1, -1)),
    (2, (230, 220, 120), lambda m: cv2.rectangle(m, (89, 170), (136, 230), 1, -1)),
    (3, (140, 230, 150), lambda m: cv2.fillPoly(m, [np.array([[196, 130], [150, 222], [242, 222]])], 1)),
]:
    shape = draw(np.zeros((256, 256), np.uint8))
    image[shape == 1] = color
    truth[shape == 1] = cls
image = np.clip(image + rng.normal(0, 12, image.shape), 0, 255).astype(np.uint8)

model = UNet(num_classes=len(CLASSES))
model.load_state_dict(torch.load('unet.pt'))
model.eval()

# 模型在 64x64 的图像上训练,先把图像缩小再送入网络
small = cv2.resize(image, (64, 64), interpolation=cv2.INTER_AREA)
x = torch.from_numpy(small).permute(2, 0, 1).float()[None] / 255
with torch.no_grad():
    logits = model(x)  # (1, 4, 64, 64)


def mean_iou(pred, truth, num_classes=4):
    ious = [((pred == c) & (truth == c)).sum() / ((pred == c) | (truth == c)).sum() for c in range(num_classes)]
    return float(np.mean(ious))


# 做法 1:先把 logits 用双线性插值放大到原图尺寸,再取 argmax
upsampled = F.interpolate(logits, size=(256, 256), mode='bilinear', align_corners=False)
pred = upsampled.argmax(dim=1)[0].numpy().astype(np.uint8)  # (256, 256)
# 做法 2:先取 argmax,再把类别图用最近邻插值放大
coarse = logits.argmax(dim=1)[0].numpy().astype(np.uint8)   # (64, 64)
pred_nearest = cv2.resize(coarse, (256, 256), interpolation=cv2.INTER_NEAREST)
print(f'放大 logits   mIoU {mean_iou(pred, truth):.4f}')
print(f'放大类别图    mIoU {mean_iou(pred_nearest, truth):.4f}')


# 连通域:把每个类别的二值掩码拆成互不相连的区域
instances = []
for cls in range(1, len(CLASSES)):
    binary = (pred == cls).astype(np.uint8)
    count, labels, stats, centroids = cv2.connectedComponentsWithStats(binary, connectivity=8)
    for i in range(1, count):  # 编号 0 是这张二值图的背景
        x0, y0, w, h, area = stats[i].tolist()
        if area < 50:          # 丢弃零碎的小区域
            continue
        # 轮廓:区域边界上的点,形状为 (点数, 1, 2),坐标顺序为 (x, y)
        contours, _ = cv2.findContours((labels == i).astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        instances.append((cls, (x0, y0, w, h), area, contours[0]))
        print(f'{CLASSES[cls]}  框 (x, y, w, h) = {(x0, y0, w, h)}  面积 {area}  轮廓点数 {len(contours[0])}')

# 叠加显示:前景像素与类别颜色各占一半,再画出轮廓和外接框
palette = np.array([[0, 0, 0], [214, 69, 69], [47, 111, 208], [46, 153, 80]], np.uint8)  # 每个类别的 RGB 颜色
overlay = image.copy()
foreground = pred > 0
overlay[foreground] = (0.5 * image[foreground] + 0.5 * palette[pred][foreground]).astype(np.uint8)
for cls, (x0, y0, w, h), area, contour in instances:
    cv2.drawContours(overlay, [contour], -1, (255, 255, 255), 1)
    cv2.rectangle(overlay, (x0, y0), (x0 + w - 1, y0 + h - 1), palette[cls].tolist(), 1)
cv2.imwrite('overlay.png', cv2.cvtColor(overlay, cv2.COLOR_RGB2BGR))  # OpenCV 按 BGR 顺序保存
1
2
3
4
5
6
放大 logits   mIoU 0.9758
放大类别图    mIoU 0.9479
圆  框 (x, y, w, h) = (28, 28, 56, 57)  面积 2440  轮廓点数 84
圆  框 (x, y, w, h) = (176, 36, 48, 49)  面积 1859  轮廓点数 53
矩形  框 (x, y, w, h) = (24, 150, 113, 82)  面积 7261  轮廓点数 35
三角形  框 (x, y, w, h) = (152, 133, 89, 91)  面积 4444  轮廓点数 116
  • 放大 logits 的 mIoU 为 0.9758,放大类别图为 0.9479。logits 是连续的,插值后再比较大小,相当于在像素之间估计边界的位置
  • 每个类别的二值掩码用cv2.connectedComponentsWithStats拆成连通域,stats的每一行是[x, y, w, h, 面积],可以直接作为检测框使用。面积过小的连通域通常是噪声,按阈值丢弃
  • cv2.findContours返回每个区域的边界点,用于绘制、计算周长或转换成多边形格式的标注
  • 两个相邻的矩形被合并成了一个连通域(框的宽度为 113)。语义分割本身不区分同类的不同个体,相互接触的同类目标无法靠连通域拆开,需要实例分割

预测结果叠加在原图上,并画出每个连通域的轮廓和外接框

常用的后处理

操作做法用途
去除小区域连通域面积小于阈值的改为背景去掉零散的误检
填充孔洞形态学闭运算(cv2.morphologyEx)让掩码内部连续
置信度阈值softmax 的最大值低于阈值的像素标为不确定只保留有把握的结果
掩码转多边形cv2.findContours后用cv2.approxPolyDP简化导出标注、矢量化

使用预训练模型

torchvision.models.segmentation中的模型返回一个字典,out是主输出,aux是训练时用于辅助损失的分支,推理时只用out。权重对象中带有类别列表和预处理方式。下面用 scikit-learn 自带的两张示例图像测试fcn_resnet50

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import torch
import torch.nn.functional as F
from sklearn.datasets import load_sample_image
from torchvision.models.segmentation import FCN_ResNet50_Weights, fcn_resnet50

weights = FCN_ResNet50_Weights.DEFAULT
model = fcn_resnet50(weights=weights)
model.eval()

categories = weights.meta['categories']
print(len(categories), categories)
preprocess = weights.transforms()  # 缩放到短边 520,除以 255,按 ImageNet 的均值和标准差归一化
print(preprocess)

for name in ['china.jpg', 'flower.jpg']:
    image = torch.from_numpy(load_sample_image(name).copy()).permute(2, 0, 1)  # (3, 427, 640) uint8
    batch = preprocess(image).unsqueeze(0)
    with torch.no_grad():
        output = model(batch)  # 一个字典
    print(name, tuple(image.shape), '->', tuple(batch.shape), { k: tuple(v.shape) for k, v in output.items() })

    # 把 logits 缩放回原图尺寸,再取类别和置信度
    logits = F.interpolate(output['out'], size=image.shape[-2:], mode='bilinear', align_corners=False)
    confidence, pred = logits.softmax(dim=1).max(dim=1)  # 均为 (1, 427, 640)
    counts = torch.bincount(pred.flatten(), minlength=len(categories))
    for index in counts.nonzero().flatten().tolist():
        print(f'  {index:2d} {categories[index]:15s} {counts[index].item():6d} 像素  {counts[index] / pred.numel():6.2%}'
              f'  平均置信度 {confidence[pred == index].mean():.3f}')
1
2
3
4
5
6
7
8
9
10
11
12
13
21 ['__background__', 'aeroplane', 'bicycle', 'bird', 'boat', 'bottle', 'bus', 'car', 'cat', 'chair', 'cow', 'diningtable', 'dog', 'horse', 'motorbike', 'person', 'pottedplant', 'sheep', 'sofa', 'train', 'tvmonitor']
SemanticSegmentation(
    resize_size=[520]
    mean=[0.485, 0.456, 0.406]
    std=[0.229, 0.224, 0.225]
    interpolation=InterpolationMode.BILINEAR
)
china.jpg (3, 427, 640) -> (1, 3, 520, 779) {'out': (1, 21, 520, 779), 'aux': (1, 21, 520, 779)}
   0 __background__  273277 像素  100.00%  平均置信度 0.991
   4 boat                 3 像素   0.00%  平均置信度 0.494
flower.jpg (3, 427, 640) -> (1, 3, 520, 779) {'out': (1, 21, 520, 779), 'aux': (1, 21, 520, 779)}
   0 __background__  237647 像素  86.96%  平均置信度 0.922
  16 pottedplant      35633 像素  13.04%  平均置信度 0.637
  • 类别列表只有 Pascal VOC 的 20 个类别加背景。china.jpg是一座中式建筑,这 20 个类别中没有建筑,模型把几乎所有像素都判为背景,只有 3 个像素被判为船
  • flower.jpg是一朵花,模型把花的大部分区域判为最接近的类别 pottedplant(盆栽),平均置信度只有 0.637,远低于背景的 0.922
  • 输出的高度和宽度与预处理后的输入相同(520x779),不是原图的尺寸,要先把 logits 缩放回原图再取argmax
  • 预训练模型只能识别训练时的类别。要分割自己的类别,保留骨干网络,把最后一层分类卷积换成新的类别数再微调,方法见PyTorch Fine-Tuning

预训练的 FCN 在 flower.jpg 上的分割结果与 pottedplant 类别的概率

This post is licensed under CC BY 4.0 by the author.