☰
图像分割避坑指南:从标注、概率图到二进制mask的提取全攻略
2026/10/20 23:26:07 网站建设 项目流程

做图像分割的人,基本都绕不开一个活:把标注工具里画的多边形、模型吐出来的概率图,或者别人传给你的可视化彩色图,最终转成一张干干净净的二进制mask。这步看起来简单到不行,阈值一下、填充一下而已,但真正在项目里跑过的人都知道,同样的逻辑换一种场景就翻车,坐标、类型、插值、边界、连通域,任何一环没照顾到,后面训练和评估全跟着遭殃。这篇就把我这些年提取二进制mask时用过的各种操作思路、封装过的函数、踩过的坑一块儿整理出来,从标注文件转mask,到概率图转mask,再到多类别mask的拆分合并,一次说清楚。

1. 先说清楚:二进制mask到底是什么,为什么容易出问题

1.1 二进制mask的常见形态

二进制mask,又叫二值掩膜,本质上就是一张只有两种像素值的图:背景是0,前景是255(也有用1表示的)。在深度学习里,它的应用场景太常见了:语义分割的训练标签、实例分割的单实例掩膜、医学图像里的病灶区域ROI,还有检测任务里用来做弱监督定位的粗框掩膜,全都长这样。

很多人觉得mask无非就是一个二维数组,但实际项目里你拿到的原始数据五花八门:

  • 标注工具导出的往往是多边形坐标,存在JSON或者XML里;
  • 模型的输出是概率图,shape是(H, W)或者(N, H, W),值域0到1的浮点数;
  • 可视化工具保存的是一张彩色图,不同类别涂不同颜色;
  • 别人给你的数据集里可能直接就是一张灰度图,前景255背景0,但边缘被压缩得发虚。

所以“提取二进制mask”这个说法,真实含义是把以上各种形式的数据统一成一张严格二值的、像素级准确的前景/背景图。这步搞定了,后面的损失计算、指标评估才谈得上准确。

1.2 为什么这个环节总在“阴沟里翻船”

我见过不少人在这步上栽跟头,核心原因是:mask的存储形式差异大,而下游对像素精度极其敏感。你算IoU、Dice这些指标时,一像素的偏移都会造成零点几个点的波动;训练的时候,如果mask边缘有半透明的中间值,损失函数直接把你整个人搞懵。

另一方面,“提取mask”并不是一个函数打天下。从标注坐标来,你要做的是多边形栅格化;从概率图来,你要做的是阈值化和后处理;从彩色可视化图来,你要做的是颜色映射反查。每种来源都有自己的坑,比如坐标该不该四舍五入、概率图该不该平滑、颜色该不该容差判断。这些细节不处理,代码看着能跑,结果却对不上。

我的建议是:把这步当成项目里的一等公民,单独封装成模块,而不是每次临时在训练脚本里写几行if else。下面几章就按数据来源分类,把我常用的实现方案和背后的逻辑都摆出来。

2. 从标注文件到二进制mask:多边形栅格化的几种打开方式

2.1 OpenCV的fillPoly是最顺手的选择

标注工具导出的多边形,本质上是一组点坐标,你要做的就是把多边形内部填成前景。OpenCV的cv2.fillPoly是我用得最多的方案,代码短、速度快、凹多边形也能正确处理。

import cv2 import numpy as np def polygon_to_mask(polygons, height, width): mask = np.zeros((height, width), dtype=np.uint8) if not polygons: return mask # polygons: list of numpy arrays, shape (N, 2) 或 (N, 1, 2) for poly in polygons: poly = np.array(poly, dtype=np.int32).reshape(-1, 2) cv2.fillPoly(mask, [poly], 255) return mask

这里面有几个细节需要注意:

第一,dtype必须用int32。OpenCV的fillPoly不接受浮点坐标,如果标注文件里存的是浮点数,直接传进去会报错或者静默截断。我一般会在封装函数里统一转一次,调用方就不用操心了。

第二,点坐标是从浮点转整数时的舍入方式。很多人图省事用int(poly[i][0])截断,这样等于把所有坐标朝原点方向偏移了零点几像素,在mask边缘会体现为整体移位。正确做法是用np.round先四舍五入再转int32,实测下来边缘对齐问题少很多。

poly = np.round(poly.astype(np.float64)).astype(np.int32)

第三,坐标越界问题。标注的时候如果画到了图像边缘外,fillPoly本身不会报错,但填充结果会超出数组边界被裁剪,这没问题。不过你如果自己先做了坐标裁剪再填充,要小心把本来就合法的边缘像素也裁掉,反倒引入误差。

2.2 用PIL的ImageDraw也能做,但要注意模式

PIL的ImageDraw.polygon是我在不需要OpenCV的场合下的备选方案。它的API更直白,适合快速验证。

from PIL import Image, ImageDraw def polygon_to_mask_pil(polygons, height, width): img = Image.new('L', (width, height), 0) draw = ImageDraw.Draw(img) for poly in polygons: poly = [(round(float(x)), round(float(y))) for x, y in poly] draw.polygon(poly, fill=255) return np.array(img, dtype=np.uint8)

这里有个关键区别:ImageDraw创建图像时,传入的size是(width, height),而numpy数组习惯是(height, width),顺序反了会在mask形状上栽跟头。我封装时通常统一入参为(height, width),内部再转。

另外PIL的ImageDraw默认不带抗锯齿。有人会觉得边缘不够平滑想开抗锯齿,但我强烈不建议在mask阶段开。mask必须严格二值,任何介于0和255之间的值都会污染后面的训练或评估。如果你觉得边缘锯齿影响可视化,那是在“可视化”这个环节去处理,而不是在mask本身上处理。

2.3 多边形栅格化的原理和边界策略

之所以要把这步单独拿出来讲,是因为很多人不太清楚fillPoly到底做了什么。本质上它做的是扫描线填充:对每一行,求出多边形与该行的交点,然后按规则填充交点之间的区间。对于凹多边形,算法会正确处理内部空洞,你不需要自己切割。

边界策略上,我的经验是:保证mask的边界与原始标注一致,不要额外收缩或扩张。常见误区是为了“让边缘干净”,有人会先画一个稍小的多边形再填充,或者填充完做一次腐蚀,结果就是标注和mask系统性错位。二维图像分割对边缘偏移很敏感,尤其是医学图像里的小目标,一两个像素的偏移都能让Dice掉几个点。

如果你拿到的是多个多边形,还想区分不同实例,那就不能填一个mask了事。我习惯维护一个“实例列表”,每个实例单独生成一张mask,最后再决定是合并成一张语义mask,还是保留成多张实例mask。合并时用np.maximum或者直接累加裁剪,都比逐像素覆盖稳妥。

3. 从模型输出概率图到二进制mask:阈值和后处理才是重头戏

3.1 别急着写prob > 0.5,先想清楚概率图的含义

模型输出的概率图通常经过sigmoid或者softmax,值域在0到1之间。最简单的转换就是阈值0.5:

binary = (prob > 0.5).astype(np.uint8) * 255

这个写法本身没错,但0.5不一定总是最优阈值。当训练样本里前景和背景比例严重失衡时,模型的概率分布会偏移,0.5这个经验值往往不是最佳分界点。我自己在几个不平衡数据集上对比过,把阈值调到0.3到0.7之间是常见操作,具体取决于你更看重召回还是精确。

另外,很多模型的输出是(N, C, H, W),要提取某个类别的mask时,得先做squeeze和索引,别把batch维当成了H或W。我踩过一次:模型输出是(1, 1, 256, 256),直接probs[0] > 0.5得到的是(1, 256, 256),后续操作全部错位,排查了半天。

3.2 自适应阈值:大津法在某些场景下更省心

如果项目里没有明确的先验阈值,我通常会用大津法(OTSU)来自动确定分界点。OpenCV里一行就能搞定:

prob_uint8 = (prob * 255).astype(np.uint8) _, binary = cv2.threshold(prob_uint8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

注意两点:第一,输入必须是uint8,不能直接把float32的概率图丢进去;第二,大津法假设像素分布是双峰的,如果前景占比极端(比如目标只有几十个像素),OTSU会倾向于把阈值拉低或者拉高,得到的结果反而不如固定阈值。所以我的习惯是:常规分割任务用固定0.5,你不太确定的时候用OTSU快速扫一眼结果,再根据可视化决定要不要调整。

3.3 概率图先平滑再阈值:一个常被忽略的细节

模型输出的概率图,尤其是一些分割网络直接上采样的结果,边缘附近会有很多孤立的小噪点。直接在原图上做阈值,这些噪点会原样进入mask。我的做法是:在阈值之前,先做一个轻度的cv2.GaussianBlur。这听起来有点反直觉,很多人担心模糊会把边缘弄糊,但实际上针对概率图做轻度的平滑,抑制的只是孤立噪点,真正的边缘区域由于周边概率都很高,影响很小。

prob_blurred = cv2.GaussianBlur(prob, (5, 5), 0) binary = (prob_blurred > 0.5).astype(np.uint8) * 255

这个操作对后面形态学处理也有帮助,能让连通域更干净。不过模糊核不能太大,我一般用3x3或5x5,再大边缘会被切割得圆滑,和标注对不上。

3.4 真正好用的“骚操作”:连通域过滤和最大连通域提取

阈值做完,mask还是有可能带一堆小碎块。这时候单纯做腐蚀或者中值滤波,效果往往不理想,因为碎块大小不一。真正稳定的做法是用连通域分析,按面积过滤。

num_labels, labels, stats, centers = cv2.connectedComponentsWithStats(binary, connectivity=8) # stats的行结构: [x, y, width, height, area] keep_ids = [] for i in range(1, num_labels): area = stats[i, cv2.CC_STAT_AREA] if area >= min_area: keep_ids.append(i) clean = np.zeros_like(binary) for i in keep_ids: clean[labels == i] = 255

min_area怎么定?我一般先统计一下所有连通域的面积,画个直方图,找到一个明显的“阈值谷底”。如果你只是想要最大的那个目标,直接取面积最大的连通域就行:

areas = stats[1:, cv2.CC_STAT_AREA] largest_idx = np.argmax(areas) + 1 clean = np.zeros_like(binary) clean[labels == largest_idx] = 255

这个“提取最大连通域”的操作,在医学图像和很多工业检测场景下几乎是刚需。模型输出的mask有时候会把背景里的某个纹理区域误判成前景,但只要目标本身是最大的连通域,一过滤就干净了。

3.5 孔洞填充的几条经验

语义分割的mask经常出现“前景内部有小孔”的情况。原因可能是模型对目标内部某些纹理预测成了背景,或者标注时就没标全。补孔洞最直接的办法是利用轮廓查找再填充:

contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) filled = np.zeros_like(binary) cv2.drawContours(filled, contours, -1, 255, thickness=cv2.FILLED)

这里用RETR_EXTERNAL只找外轮廓,内部孔洞自然会被填充。如果目标互相嵌套,比如一个圆环,你还想保留环形的空心,就不能无脑做外部填充,而要先判断哪些轮廓是目标边界、哪些是内孔边界。这个需求相对小众,我建议按实际情况处理,不要一上来就套统一逻辑。

4. 多类别与实例mask的拆分、提取和还原

4.1 类别索引mask提取指定类:一行numpy的事

有些数据集给的是“类别索引图”,像素值就是类别ID,比如0是背景、1是人、2是车。要提取“人”这个类别的二进制mask,只需:

class_id = 1 binary = (label_map == class_id).astype(np.uint8) * 255

如果想把多个类别合并成一个前景,比如把人和车都算作“动态物体”,用np.isin:

classes_of_interest = [1, 2] binary = np.isin(label_map, classes_of_interest).astype(np.uint8) * 255

看起来简单,但有个细节:类别索引图的dtype可能是uint8、int32甚至int64,直接拿去训练时有些算子不支持,最好统一转成uint8或int64,看你的损失函数要求。还有,如果索引图里存在未定义的类别值,比如噪声像素值是254、255,np.isin会把这些也算进去,最好是先做一个白名单过滤:

valid_mask = np.isin(label_map, all_valid_classes) binary = np.isin(label_map, classes_of_interest) & valid_mask

4.2 one-hot编码mask的还原:argmax要小心空类别

模型输出的分割结果常用one-hot表示,shape是(N, C, H, W)。要还原成索引图,最自然的就是np.argmax:

label_map = np.argmax(one_hot, axis=1).astype(np.uint8)

但如果某个类别在整张图里完全不存在,而模型又输出了较高的背景概率,argmax就会把那些区域标成背景类,这没问题。问题出在有些数据集把“未标注区域”也编码成一类,这类在argmax后会和真正的某个类别混淆。我的经验是:拿到one-hot先检查通道数是否等于类别数,以及是否存在全零通道,全零通道在argmax里永远不会被选中,但会影响softmax的数值分布。

4.3 彩色可视化mask的反向分离:容差判断是关键

别人给你的可能是彩色分割可视化图,每个类别一种颜色。提取某个类别的二进制mask,直接按颜色匹配就行:

target_color = np.array([128, 0, 128]) # 三维图,形状(H, W, 3) diff = np.abs(vis_image.astype(np.int32) - target_color.reshape(1, 1, 3)).sum(axis=2) binary = (diff < tolerance).astype(np.uint8) * 255

这里tolerance很关键。如果这张图保存过一次JPEG,颜色会产生轻微偏移,严格等于diff == 0会漏掉很多像素。我一般设tolerance = 10或者30,足以吸收压缩噪声,又不会把相近颜色混进来。

但如果彩色图用了抗锯齿渲染,边缘会有大量介于两个颜色之间的过渡色,任何固定容差都救不回来。这种情况下我的建议是:去问数据生产方要原始索引图或者每类的mask,可视化图本身就不适合作为mask的来源。

4.4 同一张图里提取“属于同一个实例”的mask

如果要按实例拆分(比如实例分割的GT),而手里只有语义mask,可以用connectedComponents做实例标记,每个连通域当作一个实例:

num_labels, labels = cv2.connectedComponents(binary, connectivity=8) instance_masks = [] for i in range(1, num_labels): instance_masks.append((labels == i).astype(np.uint8) * 255)

注意一个边界情况:两个紧挨着的目标如果像素相连,会合并成一个实例。这是语义mask的固有限制,不是算法问题。如果数据集本身要求区分这类粘连目标,就需要从标注源头解决,而不是在后期硬拆。

5. 训练管线里mask必须同步的那些细节

5.1 resize选错插值,等于mask整体错位

这是我在实际项目里见到最多的问题之一。图像增强时,如果对图像用双线性插值resize,对mask也用了同样的插值,那mask边缘就会出现“灰色过渡带”,因为双线性插值会把255和0平均成127。训练时模型看到这种半掩膜,损失函数直接算出奇怪的梯度。

正确做法是mask必须用最近邻插值:

mask_resized = cv2.resize(mask, (width, height), interpolation=cv2.INTER_NEAREST)

图像本身可以用INTER_LINEAR或者INTER_CUBIC,mask永远是INTER_NEAREST。这个规则我认为值得在项目代码注释里大写加粗,因为太容易漏了。

5.2 旋转、裁剪、翻转时,mask要和原图同源变换

做数据增强时,很多人会写成两段独立的随机变换:先对图随机旋转,再对mask随机旋转。这样两次旋转的角度如果不同,mask和原图就永远对不上了。正确做法是生成一个随机参数,比如旋转角度、缩放比例、裁剪区域,然后用同一参数分别作用在原图和mask上。

angle = random.uniform(-15, 15) M = cv2.getRotationMatrix2D((center_x, center_y), angle, 1.0) image_transformed = cv2.warpAffine(image, M, (width, height), flags=cv2.INTER_LINEAR) mask_transformed = cv2.warpAffine(mask, M, (width, height), flags=cv2.INTER_NEAREST)

核心思路是:用一个增强参数源,而不是各自随机。如果你用像Albumentations这类库,它内部已经处理了这些同步,但如果你自己写增强,一定要记住这一点。

5.3 mask的存储格式与内存优化:uint8和RLE

mask在数据管道里如果存成int64,内存占用是uint8的8倍。一个512x512的数据集,5万张图,光内存就差了好几个G。我建议所有mask统一用uint8存储,类别索引图用int64只在计算损失之前转换,不要全程持着大类型。

对于稀疏前景的mask,RLE编码(游程编码)能大幅压缩存储。OpenCV或者pycocotools里都有现成实现:

from pycocotools import mask as coco_mask rle = coco_mask.encode(np.asfortranarray(binary)) area = coco_mask.area(rle)

RLE编码在COCO格式的数据集里本来就是标配。自己做数据管线时,如果mask很稀疏,用RLE存储可以省下大量IO时间和内存。不过要注意,RLE和numpy数组互转有额外CPU开销,小数据集上不一定划算,得看瓶颈在存储还是计算。

6. 实测踩坑记录:那些debug到怀疑人生的mask问题

6.1 边缘向外扩散了一圈

有一次训练完评估,发现预测图的边界比GT整体粗了一圈。排查下来,问题出在离线增强时,我先对mask做了膨胀再resize,本意是“补充边缘”,结果边缘越来越粗。后来移除膨胀逻辑,只保留最近邻resize,指标立刻正常了。

这类边缘扩散问题还有个常见来源:多边形坐标从JSON读取后没有做round,直接用int截断,导致边界系统性偏移。建议在代码里加一个自动检查:把原图和mask边缘在可视化里叠加,如果出现单像素亮边或者暗边,优先检查坐标转换逻辑。

6.2 类别mask里混入了大量背景噪点

语义分割项目里,我提取某个类别mask后发现背景区域出现一堆小碎块。原因有两个:一是模型对该类别的误检本来就多,二是阈值选得太低,把低置信度的背景像素也纳进来了。

解决办法很简单:先调阈值,再看连通域。如果碎块仍然存在,就用上一章提到的连通域面积过滤。这个组合拳在多个项目里都很稳定,我基本把它当成标准后处理流程了。

6.3 多进程数据加载时,mask和图像错乱

用PyTorch的DataLoader配合多进程时,遇到过mask和图像张冠李戴的情况。原因是我在Dataset里用了全局的随机数生成器,多进程共享了同一个seed,导致不同worker产生了相同顺序的增强参数,但图像和mask各自取了不同的“下一个随机数”。

修复方法很简单:在Dataset内部使用独立的np.random.RandomState实例,或者干脆用Python标准库的random模块并给每个worker设置独立seed。增强库如Albumentations默认就能处理这个问题,但如果你手写增强,一定要留意。

6.4 常见问题速查表

症状常见原因推荐解决
mask边缘发灰,有中间值resize用了双线性插值改用INTER_NEAREST
mask整体偏移1到2像素坐标转换用了直接截断而非round先np.round再转int32
前景中大量小碎块阈值过低或模型误检提高阈值,做连通域面积过滤
目标内部有很多孔洞标注不完整或模型预测不一致外轮廓填充或做闭运算
图像和mask对不上增强参数不同源统一生成随机参数,统一变换
多进程加载时顺序错乱全局随机数生成器被多worker共享使用独立RandomState或worker固定seed
彩色可视化图反查mask缺边缘JPEG压缩导致颜色偏移设置颜色容差,尽量使用无损格式原图
提取多类别mask时混入未定义类索引图存在噪声像素先用白名单做isin并取交集

这些坑单个看都不大,但串起来足以让你在项目里浪费一整天。我把这套处理逻辑拆成几个独立的小函数后,后面再遇到新数据集,只需要替换掉“来源解析”这一步,后面的阈值、后处理、增强同步逻辑全部复用,省心很多。

最后再说一个我养成的习惯:所有mask操作的模块里,永远保留一个“快速验证”函数,传入原图、mask和透明度,直接输出一张叠加可视化图。每写一段新处理逻辑,先跑几张图肉眼看看,比任何单元测试都管用。mask这种东西,数值没报错不代表结果就对,眼睛扫一遍边缘、孔洞、错位,比什么都快。后来我所有同事也都被我带成了这个习惯,大家都说这比看loss曲线靠谱多了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询