首页 / 资讯中心 / 文章详情

图像分割避坑指南:从标注、概率图到二进制mask的提取全攻略

图像分割避坑指南:从标注、概率图到二进制mask的提取全攻略 ★ FEATURED ARTICLE
做图像分割的人基本都绕不开一个活把标注工具里画的多边形、模型吐出来的概率图或者别人传给你的可视化彩色图最终转成一张干干净净的二进制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), dtypenp.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, dtypenp.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, fill255) return np.array(img, dtypenp.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.5binary (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, connectivity8) # 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] 255min_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, thicknesscv2.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.isinclasses_of_interest [1, 2] binary np.isin(label_map, classes_of_interest).astype(np.uint8) * 255看起来简单但有个细节类别索引图的dtype可能是uint8、int32甚至int64直接拿去训练时有些算子不支持最好统一转成uint8或int64看你的损失函数要求。还有如果索引图里存在未定义的类别值比如噪声像素值是254、255np.isin会把这些也算进去最好是先做一个白名单过滤valid_mask np.isin(label_map, all_valid_classes) binary np.isin(label_map, classes_of_interest) valid_mask4.2 one-hot编码mask的还原argmax要小心空类别模型输出的分割结果常用one-hot表示shape是(N, C, H, W)。要还原成索引图最自然的就是np.argmaxlabel_map np.argmax(one_hot, axis1).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(axis2) 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, connectivity8) 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), interpolationcv2.INTER_NEAREST)图像本身可以用INTER_LINEAR或者INTER_CUBICmask永远是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), flagscv2.INTER_LINEAR) mask_transformed cv2.warpAffine(mask, M, (width, height), flagscv2.INTER_NEAREST)核心思路是用一个增强参数源而不是各自随机。如果你用像Albumentations这类库它内部已经处理了这些同步但如果你自己写增强一定要记住这一点。5.3 mask的存储格式与内存优化uint8和RLEmask在数据管道里如果存成int64内存占用是uint8的8倍。一个512x512的数据集5万张图光内存就差了好几个G。我建议所有mask统一用uint8存储类别索引图用int64只在计算损失之前转换不要全程持着大类型。对于稀疏前景的maskRLE编码游程编码能大幅压缩存储。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_NEARESTmask整体偏移1到2像素坐标转换用了直接截断而非round先np.round再转int32前景中大量小碎块阈值过低或模型误检提高阈值做连通域面积过滤目标内部有很多孔洞标注不完整或模型预测不一致外轮廓填充或做闭运算图像和mask对不上增强参数不同源统一生成随机参数统一变换多进程加载时顺序错乱全局随机数生成器被多worker共享使用独立RandomState或worker固定seed彩色可视化图反查mask缺边缘JPEG压缩导致颜色偏移设置颜色容差尽量使用无损格式原图提取多类别mask时混入未定义类索引图存在噪声像素先用白名单做isin并取交集这些坑单个看都不大但串起来足以让你在项目里浪费一整天。我把这套处理逻辑拆成几个独立的小函数后后面再遇到新数据集只需要替换掉“来源解析”这一步后面的阈值、后处理、增强同步逻辑全部复用省心很多。最后再说一个我养成的习惯所有mask操作的模块里永远保留一个“快速验证”函数传入原图、mask和透明度直接输出一张叠加可视化图。每写一段新处理逻辑先跑几张图肉眼看看比任何单元测试都管用。mask这种东西数值没报错不代表结果就对眼睛扫一遍边缘、孔洞、错位比什么都快。后来我所有同事也都被我带成了这个习惯大家都说这比看loss曲线靠谱多了。
阅读完成 · 觉得有帮助?
咨询建站