一、问题背景

最近在做语义分割任务时,我遇到一个很典型的问题:模型训练可以正常启动,loss 也能下降一点,但预测结果非常混乱,边界不稳定,甚至会出现一些不存在的类别。

一开始我以为是模型结构、学习率或者损失函数的问题,后来检查数据预处理流程才发现,真正的问题出在 mask 标签图的 resize 插值方式 上。

在语义分割中,原图和标签图虽然都需要 resize,但二者的处理方式并不一样:

原图是连续像素值,可以使用双线性插值;
标签图是类别索引图,必须使用最近邻插值。

如果对 mask 使用了双线性插值,就会直接破坏标签类别。

二、问题现象

假设我的语义分割任务一共有 3 个类别:

0:背景
1:目标区域 A
2:目标区域 B

正常情况下,mask 标签图中的像素值应该只有:

[0, 1, 2]

但是经过数据增强或者 resize 之后,我检查 mask 的唯一值,发现出现了很多异常类别:

[0, 1, 2, 3, 4, 7, 15, 128]

这就说明 mask 已经被预处理流程污染了。

如果这种错误标签直接送进模型训练,会导致两个问题:

CrossEntropyLoss 可能报错,因为标签类别超过了类别数;
即使不报错,模型也会学习到错误标签,导致分割结果混乱。

三、为什么 mask 不能用双线性插值?

语义分割中的 mask 不是普通灰度图,而是 类别索引图。

例如某个像素值为 2,并不代表灰度强度为 2,而是代表这个像素属于类别 2。

如果对 mask 使用双线性插值:

Image.BILINEAR

或者 OpenCV 中的:

cv2.INTER_LINEAR

resize 时会根据周围像素进行加权平均。

例如原始 mask 中只有类别 0 和类别 2:

0 0 2 2
0 0 2 2

双线性插值后可能会产生中间值:

0 1 2
0 1 2

其中类别 1 是插值计算出来的,并不一定是真实类别。

对于 RGB 图像来说,插值产生中间颜色是正常的;但对于分割标签来说,类别编号不能被平均。

所以结论很明确:

语义分割原图可以使用双线性插值,mask 标签图必须使用最近邻插值。

四、错误写法

下面这种写法看起来很常见,但如果用于 mask,就是错误的。

1. PIL 错误写法

from PIL import Image

mask = Image.open("mask.png")
mask = mask.resize((512, 512), Image.BILINEAR)


2. OpenCV 错误写法

import cv2

mask = cv2.imread("mask.png", 0)
mask = cv2.resize(mask, (512, 512), interpolation=cv2.INTER_LINEAR)

这两种写法都会导致 mask 标签值被插值污染。五、正确写法
1. PIL 正确写法

from PIL import Image

image = Image.open("image.jpg").convert("RGB")
mask = Image.open("mask.png")

image = image.resize((512, 512), Image.BILINEAR)
mask = mask.resize((512, 512), Image.NEAREST)

2. OpenCV 正确写法

import cv2

image = cv2.imread("image.jpg")
mask = cv2.imread("mask.png", 0)

image = cv2.resize(image, (512, 512), interpolation=cv2.INTER_LINEAR)
mask = cv2.resize(mask, (512, 512), interpolation=cv2.INTER_NEAREST)

最近邻插值不会对类别编号做平均,只会直接取最近像素的类别值,因此不会生成新的类别。

六、训练前检查 mask 是否正常

在正式训练前,我建议先写一个脚本,遍历所有 mask,检查标签值是否符合预期。

import os
import numpy as np
from PIL import Image

mask_dir = "masks"

all_values = set()

for name in os.listdir(mask_dir):
    if not name.endswith((".png", ".jpg", ".tif")):
        continue

    mask_path = os.path.join(mask_dir, name)
    mask = Image.open(mask_path)
    mask_np = np.array(mask)

    values = np.unique(mask_np)
    all_values.update(values.tolist())

print("All mask values:", sorted(all_values))

如果你的任务只有 3 类,输出应该类似:

[0, 1, 2]

如果输出变成:

[0, 1, 2, 3, 4, 5, 15, 128]

就说明 mask 很可能在预处理、resize 或数据增强阶段出问题了。

七、PyTorch Dataset 中的正确写法

下面是一个简单的语义分割 Dataset 写法,重点是 image 和 mask 的 resize 方式不同。

import os
import numpy as np
from PIL import Image
from torch.utils.data import Dataset
import torchvision.transforms.functional as F
from torchvision.transforms import InterpolationMode


class SegDataset(Dataset):
    def __init__(self, image_dir, mask_dir, size=(512, 512)):
        self.image_dir = image_dir
        self.mask_dir = mask_dir
        self.size = size
        self.names = os.listdir(image_dir)

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

    def __getitem__(self, idx):
        image_name = self.names[idx]

        image_path = os.path.join(self.image_dir, image_name)
        mask_path = os.path.join(self.mask_dir, image_name.replace(".jpg", ".png"))

        image = Image.open(image_path).convert("RGB")
        mask = Image.open(mask_path)

        image = F.resize(image, self.size, interpolation=InterpolationMode.BILINEAR)
        mask = F.resize(mask, self.size, interpolation=InterpolationMode.NEAREST)

        image = F.to_tensor(image)

        mask = np.array(mask)
        mask = mask.astype(np.int64)

        return image, mask

这里需要注意:

image = F.resize(image, self.size, interpolation=InterpolationMode.BILINEAR)
mask = F.resize(mask, self.size, interpolation=InterpolationMode.NEAREST)

这两行是关键。

八、使用 Albumentations 时的注意点

如果使用 Albumentations 做数据增强,也要注意 mask 的插值方式。

一般情况下,Albumentations 对 mask 会默认使用最近邻插值,但如果自己写了 resize 或者自定义 transform,就需要特别检查。

示例:

import albumentations as A
import cv2

transform = A.Compose([
    A.Resize(
        height=512,
        width=512,
        interpolation=cv2.INTER_LINEAR
    ),
    A.HorizontalFlip(p=0.5),
])

这里的 interpolation=cv2.INTER_LINEAR 是给 image 用的,Albumentations 内部对 mask 通常会采用最近邻插值。

但如果你手动处理 mask,就必须明确写成:

mask = cv2.resize(mask, (512, 512), interpolation=cv2.INTER_NEAREST)

不要把 image 和 mask 都统一用 INTER_LINEAR。

九、mask 不要做颜色增强

语义分割中,图像可以做颜色增强,比如:

ColorJitter
RandomBrightness
RandomContrast
HueSaturationValue

但是 mask 不能做这些增强。

错误做法:

image = color_jitter(image)
mask = color_jitter(mask)

mask 只能做空间变换,例如:

resize
crop
flip
rotate

并且这些空间变换必须保证 image 和 mask 同步。

正确思路是:

image = color_transform(image)
# mask 不做颜色增强

如果对 mask 做亮度、对比度、模糊等操作,本质上就是在修改类别标签。

十、ignore_index 也要注意

有些语义分割数据集会使用 255 表示忽略区域,例如:

0:背景
1:目标
255:忽略区域

这种情况下,检查 mask 唯一值时出现 255 是正常的。

训练时可以这样设置:

import torch.nn as nn

criterion = nn.CrossEntropyLoss(ignore_index=255)

但是如果你的任务没有设置忽略区域,却出现了大量类似 13、45、128、200 这样的值,那就不是正常情况,很可能是 mask 被插值污染了。

十一、完整排查流程

当语义分割训练效果异常时,我一般会按下面顺序排查:

1. 检查 mask 标签值是否正常
2. 检查 image 和 mask 文件名是否对应
3. 检查 resize 时 mask 是否使用最近邻插值
4. 检查数据增强是否同步作用于 image 和 mask
5. 检查 mask 是否被错误转换成 RGB 图
6. 检查 ignore_index 是否设置正确
7. 检查类别数 num_classes 是否和标签值对应

其中第 3 点是最容易被忽略的问题。

十二、总结

语义分割中的 mask 是类别索引图,不是普通图像。

因此:

原图 resize:可以使用双线性插值
mask resize:必须使用最近邻插值

这个问题看起来只是一个很小的数据预处理细节,但它会直接影响训练稳定性和最终分割效果。

如果 mask 标签在训练前就已经错乱,那么后面不管换 U-Net、DeepLabv3+、SegFormer,还是调学习率和 loss,都很难得到正常结果。

所以在做语义分割任务时,我建议训练前一定先检查:

np.unique(mask)

很多时候,模型效果差并不是模型本身的问题,而是数据预处理阶段已经埋下了错误。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐