资讯动态

图像分割从传统阈值到U-Net:实验避坑与IoU误差回溯

发布时间:2026/10/26 21:52:16 来源:尧图企业网站定制
实验五的指导书翻到图像分割那一页很多人的第一反应是——不就是阈值一卡二值图一出来就完事了。可真正跑起来才发现光照稍不均匀卡出来的全是碎片换成彩色图三通道一乱阈值直接失效再换到粘连目标上好几个物体被连成一坨。图像分割这门课最坑的地方在于代码跑通和结果正确完全是两回事。我这几年带过几轮实验课也接了不少分割相关的工程活儿见过太多人代码照抄、参数拍脑袋、结果一塌糊涂然后怪数据。这篇就沿着实验五的典型流程往下捋先聊清楚不同分割任务到底在切什么再回到阈值、边缘、区域这些传统方法里那些被指导书一笔带过的细节最后接到深度学习那一套把指标计算和误差回溯的链路补齐。不管你是刚上实验课、被 IoU 卡得睡不着还是想把分割模块往产品里塞下面这些内容都能直接拿去用。1. 先想清楚要交付什么分割任务从来不是一句话的事1.1 三类分割任务实验题目里的说法经常含混图像分割这个词太宽了。实验指导书上写对图像进行分割你得先问一句是给每个像素打一个类别标签语义分割还是要把同一类里的每个个体单独抠出来实例分割或者两者兼顾全景分割。这三件事听起来像同一个东西实现难度和评价方式差得很远。语义分割的典型题目是把前景和背景分开比如从工业相机拍的零件图里把缺陷区域标出来。它不关心有几个缺陷只关心哪些像素属于缺陷。实例分割要复杂一层得回答这里有三个缺陷分别在哪。全景分割还要把背景这类无定形的类别也算进去比如天空、路面。为什么这个区分重要因为它直接决定了你能不能用一个简单的阈值方案收工。如果实验要求只是前景/背景二分而且样本的光照一致、背景干净Otsu 阈值法二十行代码就能交出不错的结果。但一旦要求区分同一类里的多个个体阈值法就彻底没戏了——它天生没有个体这个概念只能给出一个整体区域连粘连目标都拆不开。我见过有同学做细胞计数相关的实验题用全局阈值得到一片白色然后拿连通域数量当结果最后误差百分之几十还在那儿调 Canny 的阈值。问题根本不在边缘检测上是这个任务的本质要求实例级分割而他在用像素级二分类的工具硬凑。所以在动手前先把任务照下面的表对一遍这个动作花不了两分钟但能避免你后面几小时的无用功。任务类型输出形式能否用阈值法典型适用方案语义分割二类0/255 二值掩膜光照稳定时可以Otsu、自适应阈值、简单 CNN语义分割多类每像素类别索引基本不行深度学习分割网络实例分割每个个体的独立掩膜 编号完全不行Mask 类网络或分水岭 后处理全景分割类别 实例编号不行专门的全景分割框架1.2 数据规模和硬件条件很早就把你的路线锁死了实验课常见的情况是给你几十张图实验室机器上可能只有一块入门级显卡甚至只能用 CPU 跑。这个条件下你去追最新的大模型属于自我折磨。反过来如果实验方给了几百张带标注的图和有像样的显卡还硬要用阈值法交作业那是浪费资源。我一般会按这个顺序判断先看标注量。少于一百张、而且标注质量参差不齐优先考虑传统方法加精细后处理或者用预训练模型做微调别自己从头训。标注量到了几百上千张才值得认真设计训练流程。再看类别是否平衡——分割任务里前景往往只占画面很小一块这个特点会直接影响损失函数的选择后面第 4 节会展开。还有一个容易忽略的点是输入分辨率。传统方法对分辨率不敏感但深度学习分割网络对分辨率极其敏感。下采样太狠小目标直接消失分辨率给太高显存又爆。实验里常见的做法是把图像缩到 512×512 左右训练但如果你要分割的目标在原图里只有几十个像素缩完基本就没了这时候得考虑切块训练或者保持更高分辨率。提示在写实验报告之前先把我的目标在原图中大概占多少像素算出来。这个数字决定了后续所有参数的量级比盲目试阈值有效得多。2. 阈值分割最容易拿分也最容易假成功2.1 Otsu 的水面之下是它默认了双峰直方图Otsu 大津法几乎是每本教材讲分割的第一个例子原理也不复杂遍历所有可能的阈值让前景和背景两类之间的类间方差最大。类间方差最大等价于两类分得最开。实现上就一行代码的事。import cv2 import numpy as np gray cv2.imread(sample.png, cv2.IMREAD_GRAYSCALE) blur cv2.GaussianBlur(gray, (5, 5), 0) best_t, binary cv2.threshold( blur, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU ) print(Otsu 自动求出的阈值:, best_t)但它的前提条件经常被跳过图像的灰度直方图得是双峰的也就是前景和背景的灰度分布各自成峰、中间有个谷。现实里这个条件能同时满足的情况并不多。光照不均、前景内部灰度跨度大、背景里有暗色杂物任何一个都会让直方图变成单峰或者多峰Otsu 求出来的阈值就失去意义。我判断能不能用 Otsu 的办法很土但有效先把灰度直方图画出来看一眼。如果能看到两个明显分开的峰用如果是一大坨别硬用。另外预处理的高斯模糊不能省核大小取 3 或 5 都行作用是压掉高频噪声对直方图的干扰。核开太大小目标会被抹平。还有一个隐藏问题cv2.THRESH_BINARY默认是大于阈值取 255否则取 0。如果你的目标比背景暗得到的掩膜正好是反的。这时候要么在阈值类型里换成cv2.THRESH_BINARY_INV要么最后取反。实验里我见过不少人这一步搞反了然后后面所有后处理都作用在了背景上越处理越离谱。2.2 自适应阈值光照不均场景下的第一选择一旦图像出现明显的明暗渐变全局阈值就废了。典型场景是从侧面打光的工业零件、翻拍的纸质文档、显微图像里的光照不均。这时候的思路是每个小区域单独算阈值也就是自适应阈值。binary_adapt cv2.adaptiveThreshold( blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, blockSize35, C10 )两个参数得说清楚。blockSize是局部窗口大小必须是奇数它决定局部到底有多局部。窗口太小噪声会被当成目标出来一堆斑点窗口太大就等于退化回全局阈值光照不均又治不了。经验上窗口取图像短边的十分之一到二十分之一是个合理的起点然后再根据目标大小微调。C是常数偏移从局部均值里减掉作用是抑制平坦区域被误判为前景。C调大前景会收缩噪点减少但可能漏掉目标的边缘部分C调小则相反。这个参数没有普适值得看着结果调我通常从 5 到 15 之间试。这里有个实操上的坑自适应阈值对噪声的敏感度远高于全局阈值。所以前处理的高斯模糊不能只做一次必要时可以先做一次中值滤波去掉椒盐噪声再做高斯。中值滤波的核大小同样取奇数3 或者 5 起步。2.3 形态学后处理的顺序比开闭运算本身更重要拿到二值图之后几乎没有不需要后处理的。开运算去小噪点、闭运算补孔洞这两个操作大家都会写。但顺序和核的形状经常被随手填。先说顺序。如果图上既有噪点又有破洞标准做法是先开后闭。先开去掉孤立小点否则这些点会在闭运算之后被膨胀连成片反而更难清理。反过来先闭后开的话破洞填上了但噪点也在闭运算里被放大了再开裂不掉。核的形状和大小也得跟目标尺寸匹配。cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))得到的椭圆核比矩形核更贴合自然物体的形状处理圆润目标时边界更自然。矩形核在水平垂直方向较强处理规则形状的工业件更合适。核的边长要明显小于目标的最小尺度否则开运算会把小目标整个削掉。kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) mask cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations1) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations2)还有一个经常被忽略的步骤面积过滤。形态学只能改善形状管不了大小。用cv2.connectedComponentsWithStats拿到每个连通域的面积把明显过小的块直接丢掉这一步比反复调形态学参数干净得多。3. 边缘检测与区域生长两条方向相反的思路3.1 Canny 的双阈值不是两个独立的门槛Canny 是边缘检测的默认选项但它内部其实有三个环节双阈值只是最后一环。前面还有高斯平滑、梯度计算、非极大值抑制。很多人在实验里只盯着高低两个阈值调效果不好就骂 Canny其实是前面的参数在作祟。双阈值的工作方式是这样的梯度高于高阈值的一定是边缘低于低阈值的一定不是。夹在两者之间的像素只有当它和已经确认的边缘像素连通时才会被保留。这个机制叫滞后连接它的意义是让边缘不容易在弱对比区域断掉。所以高阈值决定核心边缘在哪低阈值决定边缘能延伸出去多远。经验关系是高阈值取低阈值的两到三倍。绝对数值跟图像本身的对比度有关一般建议用代码先统计梯度幅值的中位数再定而不是直接照抄别人给的 50 和 150。import numpy as np # 用梯度幅值的中位数来自适应高低阈值 v np.median(gray) lower int(max(0, 0.66 * v)) upper int(min(255, 1.33 * v)) edges cv2.Canny(blur, lower, upper)Canny 输出的边缘是单像素宽的曲线不是闭合区域。如果你想拿它当分割结果用还得做边缘闭合和区域填充这一步经常被跳过。简单做法是对边缘做一次膨胀让断口连上然后对背景区域做泛洪填充再取反。3.2 区域生长的种子选择和相似性判据区域生长的逻辑很直观给一个种子点把邻域里和种子足够相似的像素并进来然后以新并入的像素为起点继续扩散。它和边缘检测是两条相反的思路——一个找边界一个从内部长。它的问题也很直观结果完全依赖种子点。种子点落在目标内部结果正常落在边界上结果就往一边倒落在噪点上直接长歪。所以实验里通常是人工点种子或者用前面阈值法的结果做种子自动生成。相似性判据一般用灰度差配合一个容差阈值。容差太小扩散不出去只能得到种子附近一小块容差太大会越过边界扩散到背景里。对于灰度渐变的图像可以用和邻域均值的差来判据比和种子灰度的差更稳因为它能适应缓慢的亮度变化。OpenCV 自带cv2.floodFill参数稍微绕一点但够用。它的loDiff和upDiff就是相似性容差取两个不同的值可以实现非对称扩散比如允许变亮得多允许变暗得少这在某些光照梯度明显的图像上很有用。3.3 分水岭的过分割本质是种子选得太密分水岭是处理粘连目标最常用的传统方法思路是把灰度图当成地形图灰度值是海拔从低洼处向外灌水水洼相遇的地方就是分界线。直接把边缘检测结果或者梯度图丢进分水岭几乎必然过分割——每个局部极小值都会形成一个独立区域而噪声会制造大量假的极小值。标准解法是先确定确定前景和确定背景把不确定区域留给分水岭去裁决。确定前景通常用距离变换来求。dist cv2.distanceTransform(clean_mask, cv2.DIST_L2, 5) _, sure_fg cv2.threshold( dist, 0.7 * dist.max(), 255, cv2.THRESH_BINARY ) sure_fg sure_fg.astype(np.uint8) # 确定背景对前景做膨胀 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) sure_bg cv2.dilate(clean_mask, kernel, iterations3) # 不确定区域 unknown cv2.subtract(sure_bg, sure_fg)距离变换这里有个细节值得说阈值系数取 0.7 还是 0.5直接影响结果。取 0.7 意味着只把距离边界最远的那部分当成确定前景对粘连目标的分割更保守能拆开粘连但遇到本来就偏细的目标会整块丢失。取 0.5 更宽松细目标能保住但粘连可能拆不开。我的做法是先用 0.6 试然后看距离变换的图像如果每个目标的峰值都很明显就往上加如果峰值平缓、看不出明显峰就往下减。确定前景的连通域还要做连通标记因为分水岭需要知道哪些区域属于同一个实例。这一步容易漏漏了之后所有前景会被当成一个对象。4. 深度学习分割从 FCN 到 U-Net 的最小可用版本4.1 编码器解码器加跳跃连接为什么这个结构能成立传统方法的天花板很明显它依赖人工设计的判据遇到复杂纹理、多变光照就失效。深度学习分割的核心变化是把判据交给网络自己去学。早期 FCN 的做法是拿分类网络当编码器把最后的全连接层换成卷积再上采样回原尺寸。问题是下采样过程中空间细节丢得厉害上采样也补不回来边界会糊成一片。U-Net 的贡献就在跳跃连接把编码器每一层的特征直接拼到解码器对应的层上。这个设计为什么有效可以从两个角度看。一是信息流编码器的浅层保留了边缘、纹理这些细节解码器深层的语义强但没细节跳跃连接把两者拼在一起等于知道这是什么和知道它具体在哪同时具备。二是梯度流跳跃连接给梯度提供了一条更短的路径浅层网络也能拿到有效的梯度信号训练更稳定。实际写代码时一个最小的 U-Net 结构大概就是这样import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.net nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, x): return self.net(x) class MiniUNet(nn.Module): def __init__(self, in_ch3, out_ch1): super().__init__() self.d1 DoubleConv(in_ch, 32) self.d2 DoubleConv(32, 64) self.pool nn.MaxPool2d(2) self.up2 nn.ConvTranspose2d(64, 32, 2, stride2) self.u2 DoubleConv(64, 32) self.out nn.Conv2d(32, out_ch, 1) def forward(self, x): f1 self.d1(x) f2 self.d2(self.pool(f1)) g1 self.up2(f2) g1 torch.cat([g1, f1], dim1) g1 self.u2(g1) return self.out(g1)这个结构层数很浅适合实验课的算力和数据量。真正的 U-Net 有四五层下采样参数量大几十倍几十张图是喂不动的。我个人的经验是数据量在一两百张以内两层下采样的小网络加数据增强效果往往比硬上大模型更稳训练也快。4.2 标注和增强是实验里最耗时间也最容易糊弄的部分分割任务的标注成本远高于分类和检测。每张图都要逐像素标一张稍微复杂的图花十几分钟很正常。实验里标注量不够是常态这时候数据增强就不是可选项是必需品。分割的增强有个特殊要求图像和掩膜必须做完全一致的几何变换。旋转、翻转、缩放这些两者的参数必须同步。用 Albumentations 这类库能省很多事它会自动处理图像与掩膜的对齐。自己写随机增强的话最常见的事故就是只改了图像没改掩膜训练时损失下降得很漂亮预测出来全是偏移的。颜色类增强亮度、对比度、饱和度扰动可以只作用在图像上这是合理的因为它模拟的是成像条件变化掩膜本来就该保持不变。但要注意别把颜色增强调得太猛导致前景和背景的对比度关系发生根本改变那就等于给模型喂了错误的样本。还有一个隐含问题如果训练集里的目标都出现在图像中央模型可能学到的不是目标长什么样而是中间那块就是要分割的。这种捷径学习在验证集上表现很好一换数据就崩。解决办法是增强里加上随机裁剪和平移强迫模型在画面各处都能找到目标。4.3 损失函数选交叉熵还是 Dice取决于类别比例分割任务的类别极度不平衡。一张 512×512 的图如果目标只占百分之二的面积那像素级交叉熵里百分之九十八的梯度都来自背景。模型很容易学成全部预测为背景损失也能降到很低但 IoU 是零。交叉熵的形式是逐像素的每个像素独立贡献损失。它的问题就是上面说的被多数类主导。Dice 损失不一样它看的是预测区域和真实区域的重合度$$ \text{Dice} \frac{2|X \cap Y|}{|X| |Y|} $$Dice 损失就是 $1 - \text{Dice}$。它对类别不平衡天然不敏感因为分子分母都只算前景。小目标预测错一个像素Dice 掉得比交叉熵明显得多。def dice_loss(pred, target, eps1e-6): pred torch.sigmoid(pred) pred pred.contiguous().view(pred.size(0), -1) target target.contiguous().view(target.size(0), -1) inter (pred * target).sum(dim1) union pred.sum(dim1) target.sum(dim1) dice (2 * inter eps) / (union eps) return 1 - dice.mean()实际用的时候我倾向于两个一起上0.5 * 交叉熵 0.5 * Dice。交叉熵在训练早期梯度稳定收敛快Dice 在后期精修边界。只用 Dice 的话训练初期梯度可能不太稳尤其是前景几乎不存在的批次。5. 指标和误差回溯IoU 很高但结果不对是怎么回事5.1 IoU 和 Dice 的算法细节IoU 是分割任务最常用的指标交并比交集除以并集。Dice 前面说过了是两倍交集除以两者之和。两者可以互相换算Dice 2 * IoU / (1 IoU)所以它们对同一结果的排序是一致的只是数值区间不同。实现上的坑在于空集的情况。如果真实掩膜和预测掩膜都是空的交集和并集都是零直接除会出 NaN。这时候约定俗成是记 1 或者跳过这个样本。很多开源实现的代码里用了smooth项来避免除零但那会轻微影响数值严格评比的时候要注意。多类分割用 mIoU就是每一类各算一个 IoU 再取平均。这里有个陷阱如果某个类别在某个样本里根本不存在它的 IoU 该不该算进平均两种做法都有人用结果会差很多。实验报告里如果没写清楚复现的人会得到完全不同的数字。def iou_score(pred_mask, gt_mask, smooth1e-6): pred pred_mask.astype(bool).ravel() gt gt_mask.astype(bool).ravel() inter np.logical_and(pred, gt).sum() union np.logical_or(pred, gt).sum() return (inter smooth) / (union smooth)5.2 用误差图定位问题比盯着指标数字有用指标只能告诉你错了多少不能告诉你错在哪。真正能指导调参的是误差可视化把真阳性、假阳性、假阴性分别用不同颜色画出来。假阳性预测为前景但实际是背景大面积出现在目标周围说明模型倾向于把边界往外扩可能是损失函数对边界惩罚不足或者后处理的膨胀过度。假阴性漏掉的真目标集中在小目标上说明下采样太狠得提高输入分辨率或者加多尺度结构。零星散布的红绿点通常是噪声或者标注本身有问题。我习惯在验证时每几个 epoch 存一张误差图看图调整比看曲线直觉得多。有一次一个分割任务 IoU 一直在 0.85 左右上不去画了误差图才发现假阳性全都集中在图像边缘的一圈原因是数据增强的裁剪在边缘引入了黑色的填充区域模型把这块当成了目标特征。这个问题光看损失曲线永远发现不了。标注质量也得警惕。分割标注的人工误差比想象中大尤其是边界模糊的地方。如果误差图上出现模型预测和标注差了一条细窄的带子先别急着改模型去原图看看那条带子到底属于谁。6. 这些年踩出来的几个具体坑先说参数的量级问题。很多人在调阈值、调核大小的时候是盲试的试到哪算哪。其实每一步的参数都应该跟目标的实际尺度挂钩。目标直径 30 像素形态学核边长就该在 3 到 7 之间开运算核取 15 会把目标直接削没。距离变换的阈值系数、自适应阈值的窗口大小都是这个道理。养成先量目标尺度再定参数的习惯调参效率能翻好几倍。再说图像位深和通道的事。彩色图直接送进cv2.threshold不会有报错但行为是按通道逐像素比较结果和你想的完全不同。灰度化这一步必须显式做而且要清楚用的是哪种转换方式——加权平均和简单取平均对某些颜色组合的结果差异不小。另一种情况是 16 位图像阈值上限就不再是 255 而是 65535用 255 卡会得到全黑的图这个错我在别人的代码里见过不止一次。下采样和上采样的顺序也值得注意。传统方法里为了提速经常先把大图缩放再处理但缩放方式最近邻、双线性、面积插值对二值掩膜的影响完全不同。最近邻不会产生中间值但会有锯齿双线性会产生介于 0 和 255 之间的值如果你后面直接拿它当掩膜用逻辑运算会出问题。正确做法是掩膜缩放一律用最近邻并且缩放后重新二值化。评估集泄露这个坑比较隐蔽。做数据增强的时候如果先增强再划分训练验证集同一张原图增强出来的多个版本可能同时出现在两边验证指标会虚高。正确顺序永远是先划分再各自增强。这个错误在做小数据集实验时特别常见因为大家都有样本太少多增强点的心理。最后是随机种子。分割实验里如果有随机初始化、随机采样、随机增强不固定种子的话两次运行的指标能差好几个点。写实验报告要对比不同方法时至少跑三次取平均或者固定种子保证可比。单次结果就下结论很容易得出反向的结论。分割这个领域方法从传统到深度跨度很大但底层的判断逻辑其实一脉相承先搞清楚要切的是什么再看数据条件允许你用什么工具最后用可视化和误差图去验证你的判断而不是只盯着一个数字。这套思路放到任何一个新的分割任务上都能帮你少走不少弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑