图像分割任务里,mask 的读取和保存看起来是最不起眼的一环,但它坑人的地方一点都不少。我见过太多人在 PASCAL VOC 这类数据集上训练得好好的,一到评估或者可视化阶段就发现 mask 全花了——要么是类别索引被映射成了灰度值,要么是保存回去之后标签错位,要么是调色板丢失导致可视化一片黑。这些问题追根溯源,几乎都出在同一个地方:对 8 位深调色板模式(P 模式)的理解不够透彻。这篇内容就围绕 PIL 在 PASCAL VOC 分割任务中处理 mask 的完整链路展开,把读取、转换、保存这几个环节里那些文档不会明说、但实际会咬人的细节全部拆开讲清楚。不管你是刚接触分割任务的新手,还是已经跑过几个数据集但总在 mask 上翻车的老手,下面这些内容应该都能帮你省下不少调试时间。
1. 为什么 PASCAL VOC 的 mask 偏偏要用调色板模式
1.1 从一张 mask 图里到底存了什么说起
先想清楚一件事:语义分割的 mask 图,本质上是一张“每个像素存一个类别编号”的索引图。PASCAL VOC 一共 21 类(20 个前景类加 1 个背景类),也就是说每个像素的取值范围只需要 0 到 20 这 21 个整数就够了。如果按普通 RGB 图来存,每个像素要占 3 个通道、每个通道 8 位,一共 24 位,但实际有效信息只有 5 位左右,剩下全是浪费。更麻烦的是,用 RGB 存类别编号会引入一个隐患:你没法保证某个类别一定对应某个固定颜色,不同工具读进来可能解释成不同的值。
调色板模式(P 模式)解决的正是这个问题。它每个像素只占 8 位,存的是一个 0 到 255 的索引值,然后额外挂一张长度最多 256 的调色板表,表里每一项是 (R, G, B) 三元组。像素值本身是索引,调色板负责把索引翻译成颜色用于显示。关键在于:对于分割 mask,我们真正关心的是那个索引值,也就是类别编号,调色板只是给人眼看的。PASCAL VOC 官方给的 SegmentationClass 里的 PNG 就是这种结构,像素索引直接等于类别 ID,调色板则是一套约定好的颜色,方便你在看图工具里直观区分不同类别。
这里有个特别容易混淆的点:很多人以为 mask 图里像素的 RGB 值就是类别编号,于是用np.array(img)读出来发现是三维数组,取某个通道当标签用,结果训练时 loss 一直不降。实际上你应该读的是索引值,而不是调色板映射后的颜色值。这个区别是后面所有操作的基础,必须先掰扯清楚。
1.2 索引值、调色板颜色、灰度值三者的关系
把这三个概念彻底分开,后面就不会乱。假设某个像素的类别是 15(比如 PASCAL VOC 里的“人”这一类,具体编号以你用的标注约定为准),那么在 P 模式的 PNG 里:
- 索引值:这个像素存的就是 15,这是我们要的标签。
- 调色板颜色:调色板第 15 项可能是 (128, 0, 0) 这样的暗红色,纯粹为了显示。
- 灰度值:如果你把这张图转成 L 模式(8 位灰度),像素值还是 15,因为 L 模式和 P 模式的像素都是单通道 8 位,转换时通常直接搬索引值,不查调色板。但如果你转成 RGB 再转灰度,那就会先查调色板变成 (128, 0, 0),再按灰度公式算成某个值,这时候标签就彻底毁了。
我用一个具体例子说明这个坑有多隐蔽。假设你写了这么一段代码:
from PIL import Image import numpy as np mask = Image.open("2007_000032.png") arr = np.array(mask) print(arr.shape, arr.dtype)如果输出是(375, 500) uint8,恭喜你,这是 P 模式直接读出来的索引,可以直接当标签用。但如果输出是(375, 500, 3) uint8,说明这张图被当成 RGB 读了,你拿到的是调色板映射后的颜色,直接拿来训练必然出错。这时候你得先确认图片本身的模式,而不是想当然。
1.3 官方标注的调色板约定与常见误解
PASCAL VOC 的 SegmentationClass 用的是一套固定的调色板,21 个类别各对应一种颜色,背景通常是黑色或者某个特定值。这套调色板在官方文档里有说明,但很多人不看,直接凭经验处理,结果就出问题。一个典型误解是:以为背景类的索引一定是 0。实际上在 VOC 的标注里,边界区域(void)和背景的处理需要你仔细核对,有些版本里未标注区域用的是 255,训练时通常要忽略掉。如果你不处理这个 255,它会被当成第 255 类,而你的模型只有 21 类,直接越界。
另一个误解是以为调色板顺序和类别顺序严格一致。大多数情况下确实如此,但不同来源的标注文件可能有细微差异。稳妥的做法是:拿到数据后先统计一下 mask 里实际出现了哪些索引值,和你的类别列表对一遍,确认没有意外值。这一步花不了几分钟,但能避免后面几小时的无效训练。
提示:判断一张 mask 是不是 P 模式,最可靠的方式是
Image.open(path).mode,返回'P'才是调色板模式。不要靠肉眼看图或者猜文件后缀。
2. 用 PIL 读取 mask 时那些反直觉的行为
2.1 Image.open 的惰性加载与模式保持
PIL 的Image.open()是惰性加载的,它只读文件头,不立刻把像素数据全部读进内存。这对处理大批量数据是好事,但也带来一个副作用:你拿到的 Image 对象的 mode 是文件里记录的模式,直到你真正访问像素时才解码。对于 P 模式的 PNG,Image.open()返回的 mode 就是'P',这一点通常没问题。但如果你在打开之后做了某些操作,比如convert(),模式就会变。
我踩过的一个坑是这样的:为了统一处理,我在数据加载器里对所有图都调了一次convert('RGB'),想着反正后面要归一化。结果 mask 也被转成了 RGB,标签全变成了颜色值。这个 bug 特别隐蔽,因为图看起来还是“对的”,只是颜色鲜艳了一点,但训练时模型学的是颜色而不是类别,评估指标自然一塌糊涂。后来我改成对图像和 mask 分开处理:图像转 RGB,mask 保持 P 模式或者转成 L 模式,问题才解决。
所以第一条经验:在数据管道里,图像和 mask 的处理路径必须分开,绝不能图省事用同一套转换逻辑。
2.2 np.array 读出来的到底是什么
np.array(img)对 P 模式图像的行为,取决于 PIL 的版本和图像本身。在绝大多数情况下,对 P 模式图像调用np.array()会返回二维数组,值是索引。但有一个例外:如果这张 P 模式图带了透明度信息(比如某些 PNG 有 tRNS 块),行为可能不同。PASCAL VOC 的标注一般不带透明度,所以正常情况就是二维索引数组。
为了保险,我习惯在读完之后立刻做一次断言:
import numpy as np from PIL import Image def load_mask(path): img = Image.open(path) assert img.mode == 'P', f"期望 P 模式,实际 {img.mode}" arr = np.array(img) assert arr.ndim == 2, f"期望二维索引数组,实际形状 {arr.shape}" return arr这几行断言看着简单,但能帮你挡住 90% 的 mask 读取问题。尤其是当你的数据来源混杂、有人手动改过标注文件的时候,这个检查能第一时间暴露异常。
2.3 convert('L') 和直接读索引的区别
很多人会问:既然 P 模式和 L 模式的像素都是单通道 8 位,那我直接convert('L')不就行了?答案是:大多数情况下可以,但你要清楚它做了什么。convert('L')从 P 模式转 L 模式时,PIL 会把索引值直接搬过去,不查调色板。所以转换后像素值还是类别编号,可以正常用。
但这里有个陷阱:如果你先把 P 转成 RGB,再转 L,那就完蛋了,因为中间那一步查了调色板。所以顺序很重要。我的建议是:如果只是为了拿到索引数组,直接用np.array(img)最干净,不要绕道 convert。如果某些下游库要求 L 模式输入,那就img.convert('L'),但务必确认转换前后像素值一致。
img = Image.open(path) arr_direct = np.array(img) # 直接拿索引 arr_l = np.array(img.convert('L')) # 转 L 再拿 print(np.array_equal(arr_direct, arr_l)) # 正常情况应为 True如果这个比较返回 False,说明你的图有特殊情况,需要单独排查。
2.4 批量读取时的内存与速度权衡
处理整个 PASCAL VOC 训练集(一千多张 mask)时,如果全部预加载到内存,二维 uint8 数组其实占不了多少空间,一张 500x375 的 mask 也就不到 200KB,一千张不到 200MB,完全放得下。但如果你用的是更大的数据集或者高分辨率图,就得考虑按需读取。
PIL 的惰性加载在这里帮了忙:你可以只保留文件路径列表,在__getitem__里实时打开。但要注意,多进程 DataLoader 下每个 worker 都会独立打开文件,文件句柄管理要小心。我一般会在 worker 初始化时设置Image.MAX_IMAGE_PIXELS防止超大图警告,并在读取后显式关闭图像对象(用with语句或者手动img.close()),避免句柄泄漏。
注意:PIL 对某些损坏的 PNG 会抛
UnidentifiedImageError,在批量读取时最好加一层 try-except,把坏样本记录下来跳过,而不是让整个训练崩掉。
3. 从索引到标签:读取后的转换与校验
3.1 处理 void 区域和越界索引
PASCAL VOC 的标注里,物体边界处经常有一圈未标注区域,这些像素的索引通常是 255。如果你的类别数是 21,那 255 就是个非法值。训练时标准做法是把它映射成一个忽略索引(比如 -1 或者 255 本身,取决于你的损失函数),在计算 loss 时屏蔽掉。
具体操作上,我通常这样做:
def prepare_label(arr, num_classes=21, ignore_index=255): # 把越界值统一成 ignore_index arr = arr.copy() arr[arr >= num_classes] = ignore_index return arr注意这里要先 copy,因为np.array(img)返回的数组可能是只读的(取决于 PIL 内部实现),直接改会报错。另外,ignore_index 的选择要和你的损失函数对齐,比如 PyTorch 的 CrossEntropyLoss 默认 ignore_index 是 -100,你得显式设置成你用的值。
3.2 用 np.bincount 快速统计类别分布
在读入一批 mask 之后,我强烈建议先做一次类别分布统计。这能帮你发现很多问题:某个类别样本极少、出现了意料之外的索引、void 区域占比过大等等。np.bincount是最快的工具:
counts = np.bincount(arr.ravel(), minlength=256) for i, c in enumerate(counts): if c > 0: print(f"索引 {i}: {c} 像素")如果发现 21 以上的索引大量出现,那要么是 void 没处理,要么是标注文件有问题。如果某个前景类别的像素数少得可怜,训练时可能要加类别权重或者做重采样。这一步花几十秒,但能让你对数据心里有数。
3.3 可视化校验:把索引还原成彩色图
调试阶段,把索引数组还原成彩色图看一眼,是最直观的校验方式。做法是给每个类别分配一个醒目的颜色,然后生成 RGB 图:
import numpy as np from PIL import Image def colorize(arr, palette): # palette: dict {class_id: (r, g, b)} h, w = arr.shape out = np.zeros((h, w, 3), dtype=np.uint8) for cid, color in palette.items(): out[arr == cid] = color return Image.fromarray(out, mode='RGB')这里的关键是:不要用原图的调色板来可视化,因为原调色板的颜色可能很接近,肉眼分不清。自己定义一套对比强烈的颜色,比如背景黑、各类用红绿蓝黄紫等,这样一眼就能看出分割边界对不对。我习惯在训练前随机抽几张 mask 可视化,确认标签和原图对得上,这个习惯帮我抓到过好几次标注错位的问题。
3.4 常见读取异常与排查顺序
遇到 mask 读取问题时,按下面的顺序排查,基本能定位到根因:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 数组是三维的 | 图被当 RGB 读了 | 检查img.mode |
| 标签值全是 0 或 255 | 索引被调色板映射了 | 对比np.array(img)和np.array(img.convert('RGB')) |
| 类别数超过预期 | void 未处理 | 用 bincount 看最大索引 |
| 可视化一片黑 | 调色板丢失或全 0 | 检查保存时是否保留了调色板 |
| 训练 loss 不降 | 标签和图像不对应 | 可视化叠加原图和 mask |
这张表我贴在工位上过,每次出问题先扫一遍,比盲目改代码高效得多。
4. 保存 mask 时最容易丢的东西:调色板
4.1 为什么直接 save 会丢调色板
这是最经典的一个坑。你从 P 模式图读出索引数组,做了些处理(比如预测结果 argmax),然后想存回 PNG。如果你用Image.fromarray(arr)创建图像,默认得到的是 L 模式(8 位灰度),不是 P 模式。这时候 save 出来的 PNG 没有调色板,用看图工具打开就是一张灰度图,虽然像素值(索引)是对的,但显示效果和原标注不一致。
更严重的是,如果你的下游流程依赖调色板来做可视化或者某些库的自动解析,丢失调色板会导致后续步骤出错。比如有些评估脚本会读取调色板来判断类别颜色,没有调色板就直接失败。
所以保存 mask 的核心原则是:如果你需要它保持 P 模式,就必须显式地把调色板写回去。
4.2 手动构造并写回调色板
PIL 提供了putpalette()方法,可以给图像设置调色板。调色板是一个扁平化的列表,长度最多 768(256 个颜色 × 3 通道),每个颜色按 R, G, B 顺序排列。做法如下:
import numpy as np from PIL import Image def save_mask(arr, path, palette_list): # arr: 二维 uint8 索引数组 # palette_list: 长度 768 的扁平列表,或 256*3 的嵌套 img = Image.fromarray(arr, mode='P') img.putpalette(palette_list) img.save(path)注意Image.fromarray(arr, mode='P')这一步:如果你不指定 mode='P',PIL 会根据数组类型推断,uint8 二维数组默认推断成 L 模式。指定 P 模式后,再 putpalette,保存出来的就是带调色板的 P 模式 PNG。
调色板从哪来?两个来源:一是从原始标注图里提取,img.getpalette()会返回一个列表;二是自己按类别定义一套。如果是做预测结果保存,通常用自己定义的那套,保证可视化一致。
4.3 从原图继承调色板的正确姿势
如果你想保持和原标注完全一致的调色板,最稳妥的方式是从一张原始标注图里提取,然后复用:
ref = Image.open("reference_mask.png") palette = ref.getpalette() # 后续所有保存都用这个 palette但要注意,getpalette()返回的长度可能不足 768,取决于原图实际用了多少颜色。putpalette 时如果长度不够,PIL 会怎么处理?实测下来,不足的部分会被补 0,通常没问题,但为了保险,我一般会把它补齐到 768:
palette = palette + [0] * (768 - len(palette))这样无论原图调色板多长,写回去都是完整的。
4.4 保存后必须做的回读验证
保存完不验证,等于没保存。我养成的习惯是:存完之后立刻用Image.open()读回来,检查三件事——mode 是不是 P、调色板在不在、索引值有没有变。
def save_and_verify(arr, path, palette): img = Image.fromarray(arr, mode='P') img.putpalette(palette) img.save(path) reloaded = Image.open(path) assert reloaded.mode == 'P', f"回读模式错误: {reloaded.mode}" assert reloaded.getpalette() is not None, "调色板丢失" arr2 = np.array(reloaded) assert np.array_equal(arr, arr2), "索引值不一致" reloaded.close()这几行断言在批量保存预测结果时特别有用。我曾经因为一个 off-by-one 的调色板问题,导致几千张预测图全部显示错色,回读验证能第一时间发现。
5. 训练与评估链路中的 mask 处理实战
5.1 数据增强时如何不破坏索引语义
做数据增强时,几何变换(翻转、旋转、裁剪)对 mask 是安全的,因为索引值不参与插值。但颜色变换、模糊、亮度调整这些绝对不能用在 mask 上。更隐蔽的是 resize:如果你用双线性插值缩放 mask,索引值会被平均,产生 7.5 这种非整数,标签就废了。正确做法是用最近邻插值(NEAREST)。
mask_resized = mask_img.resize((new_w, new_h), resample=Image.NEAREST)如果用 albumentations 之类的库,要确保 mask 走的是 nearest 插值,图像走双线性。很多库默认对 mask 也用双线性,这是个高频坑。我一般会在增强配置里显式指定,并在增强后抽查几张 mask 的 unique 值,确认没有出现非整数或意外值。
5.2 预测结果 argmax 后如何存成合规 mask
模型输出通常是 (C, H, W) 的 logits,argmax 之后得到 (H, W) 的类别索引。这个数组直接存成 P 模式 PNG 就行,但要注意两点:一是 dtype 必须是 uint8,argmax 默认可能是 int64,要转;二是如果类别数超过 256(一般不会),得另想办法。
pred = logits.argmax(dim=0).cpu().numpy().astype(np.uint8) save_mask(pred, "pred.png", palette)存完之后,用同一套可视化脚本把预测和真值并排显示,肉眼比对一下,能发现很多指标看不出来的问题,比如某个类别系统性偏移、边界处理不一致等。
5.3 评估指标计算时的标签对齐
算 mIoU 的时候,预测和真值的索引必须严格对齐。常见错误是真值里的 void(255)没屏蔽,导致分母被污染。标准做法是构造一个 valid mask:
valid = (target != ignore_index) inter = ((pred == target) & valid).sum() union = ((pred == target) | (target != ignore_index)).sum()不同框架的 mIoU 实现细节有差异,建议自己写一遍小规模验证,用几个手工构造的简单例子确认结果正确,再上大规模评估。我见过有人直接调库,结果因为 ignore_index 设置不一致,指标虚高了好几个点,白高兴一场。
5.4 一个完整的读取-处理-保存闭环示例
把前面的点串起来,给一个可以直接抄的闭环:
import numpy as np from PIL import Image def load_mask(path): img = Image.open(path) assert img.mode == 'P' arr = np.array(img) img.close() return arr def process_mask(arr, num_classes=21, ignore_index=255): arr = arr.copy() arr[arr >= num_classes] = ignore_index return arr def save_mask(arr, path, palette): img = Image.fromarray(arr.astype(np.uint8), mode='P') img.putpalette(palette) img.save(path) img.close() # 使用 palette = Image.open("ref.png").getpalette() palette = palette + [0] * (768 - len(palette)) mask = load_mask("2007_000032.png") mask = process_mask(mask) save_mask(mask, "out.png", palette)这个闭环覆盖了读取校验、void 处理、调色板写回、保存,基本能应对 PASCAL VOC 上的绝大多数场景。实际项目里我会把它封装成一个类,加上日志和异常处理,但核心逻辑就是这些。
6. 几个我踩过之后才记住的细节
6.1 调色板长度不足 768 时的静默行为
前面提过getpalette()可能返回不足 768 的列表。我实测过,如果直接把这个短列表传给putpalette(),PIL 不会报错,但保存出来的图调色板可能不完整,某些索引对应的颜色会变成黑色。这个问题在可视化时表现为“某些类别突然变黑”,很容易误以为是标签错了。解决办法就是无条件补齐到 768,多出来的补 0 不影响已有颜色。
6.2 多进程 DataLoader 下的文件句柄问题
用 PyTorch DataLoader 且 num_workers > 0 时,每个 worker 会 fork 出独立进程。如果父进程里已经打开了图像对象没关闭,fork 后可能出现句柄共享问题。我的做法是在 Dataset 的__getitem__里用with Image.open(path) as img:确保每次读取后自动关闭,绝不让 Image 对象跨函数存活。这个小习惯帮我避免过“训练跑着跑着报 too many open files”的问题。
6.3 不同 PIL 版本对 P 模式处理的差异
PIL 和它的继任者 Pillow 在不同版本间,对 P 模式图像np.array()的行为有过细微变化。老版本某些情况下会返回调色板映射后的 RGB,新版本则稳定返回索引。如果你在团队协作中遇到“我这里正常你那里不正常”的情况,先对一下 Pillow 版本。我一般会在项目里锁定版本,并在 README 里写明,避免环境差异导致的诡异 bug。
6.4 保存为 JPEG 会彻底毁掉 mask
这个必须单独说:mask 永远不要存成 JPEG。JPEG 是有损压缩,会在像素值上引入微小变化,索引值 15 可能变成 14 或 16,标签直接错乱。而且 JPEG 不支持调色板模式,存出来必然是 RGB 或灰度。PASCAL VOC 用 PNG 是有道理的,PNG 无损且支持调色板。我见过有人为了省空间把 mask 转 JPEG,结果评估指标莫名其妙下降,查了半天才发现是压缩惹的祸。
6.5 关于索引 0 和背景的边界情况
最后一个容易忽略的点:有些标注里背景是 0,有些是 255,还有些数据集把背景单独列为一类。PASCAL VOC 里背景通常是 0,但 void 是 255。如果你的模型把背景也当预测目标,那 0 要保留;如果只关心前景,0 可能要忽略。这个取决于你的任务定义,没有统一答案,但一定要在代码里显式处理,不要靠默认行为。我习惯在配置里写清楚background_index和ignore_index,让每个环节都引用同一份配置,避免前后不一致。
把这些细节都照顾到之后,mask 这一环基本就不会再给你添乱了。剩下的精力可以安心放在模型和训练策略上,这才是真正决定分割效果的地方。