语义分割训练中 Mask 标签错乱问题:为什么标签图 resize 必须用最近邻插值?
一、问题背景
最近在做语义分割任务时,我遇到一个很典型的问题:模型训练可以正常启动,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)
很多时候,模型效果差并不是模型本身的问题,而是数据预处理阶段已经埋下了错误。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)