图像分割实验避坑指南:Otsu阈值、分水岭与U-Net全流程
图像分割这门实验,我前后带过几轮,也在机房里见过太多同学对着黑乎乎的掩膜图发呆——代码明明跑通了,输出却是一片噪点,或者干脆整幅图都被判成前景。这个实验的核心其实不复杂:把一张图按某种规则切成互不重叠的若干块,让每个像素都归到某个有意义的区域里,输出一张和原图同尺寸的标签图。可真正动手就会发现,坑比想象中密集得多,阈值差个十几,结果就天翻地覆。这篇内容我会按实际做实验的顺序,把图像分割从设计思路、参数推导到代码落地、问题排查完整走一遍,重点放在那些实验指导书上不会写、但一上手就会撞上的细节。不管你是第一次接触这个实验,还是已经跑通代码但结果不满意,下面的内容应该都能直接拿去用。
1. 实验整体设计与思路拆解
1.1 先搞清楚分割到底在解什么问题
很多人一上来就找代码,结果连输入输出都没想明白。图像分割在数学上就是一个像素级标注问题:给定图像 I(H×W×3),要产出一个标签图 L(H×W),每个位置的值代表该像素所属的类别。二值分割里 L 只有 0 和 1,多类分割里 L 可以取 0 到 K-1。这个定义看着平淡,但它决定了后面所有操作的评判标准——你的输出必须和原图逐像素对齐,尺寸差一个像素都不行。
理解这一点之后,很多设计选择就顺理成章了。比如为什么几乎一定要先转灰度?因为大多数传统分割算法(阈值、边缘、区域生长)依赖的是“亮度差异”这一个维度,彩色三通道反而会引入冗余信息,还会让阈值判定变成三选一的麻烦事。再比如为什么滤波要放在分割之前?因为分割算法本质上是在找“变化”,而噪声恰恰也是一种变化,不先把噪声压下去,算法就会把噪点当成目标边界。
我在实验里一般会把整个流程拆成四段:预处理、粗分割、细化和后处理。预处理负责降噪和统一量纲;粗分割用阈值或边缘给出初始的区域划分;细化处理边界和粘连;后处理做形态学清理和小区域剔除。这个分段不是为了好看,而是为了出问题时能快速定位是哪一段的责任。实际调试中,90% 的“结果不对”都能通过分段输出来确认问题源头,比盯着最终结果瞎猜效率高得多。
1.2 传统方法和深度学习方法该怎么分工
这个实验通常要求两种路线都碰一下,但两者的定位完全不同。传统方法(Otsu 阈值、Canny 边缘、区域生长、K-means 聚类、分水岭)不需要训练数据,几行代码就能出结果,适合做原理验证和快速原型;缺点是泛化能力弱,换个光照条件、换个场景,参数就得重调。深度学习方法(FCN、U-Net 这类编码器-解码器结构)需要标注数据,训练成本高,但在复杂场景下稳定性明显更好。
我的建议是:实验的前半段老老实实把传统方法手写一遍,重点不是跑出多漂亮的结果,而是理解每个参数背后的物理含义。比如 Otsu 法里的类间方差,你手算一次就知道它为什么在双峰直方图上表现好、在单峰或者多峰上会崩。后半段再上一个轻量的 U-Net,用很小的数据集跑通训练和推理链路,理解“数据-模型-损失-评估”这个闭环。两条路线都走过一遍,你对“什么场景该用什么方法”才会有直觉。
这里有个容易被忽略的点:不要让传统方法只当“过场”。实际上在很多工业质检、医学影像的预处理环节,传统方法仍然是主力,因为它可解释、可复现、不需要 GPU。深度模型更像是把特征提取这一步外包出去了,但它对数据分布非常敏感。我在实验报告里通常会要求对比同一张图在两种方法下的边界贴合度,这个对比往往比任何文字说明都更能说明问题。
2. 核心细节解析与实操要点
2.1 灰度化和滤波这两个“不起眼”的步骤
灰度化的权重不是随便定的。常用的加权公式是 Gray = 0.299R + 0.587G + 0.114B,这三个系数来自人眼对不同波长光的敏感度差异——绿色最敏感,蓝色最迟钝。直接取三通道平均(R+G+B)/3 会导致某些颜色对比度被压平,比如红色目标和绿色背景,平均之后亮度可能几乎一样,阈值分割直接失效。我在实验里会让学生两种方式都算一遍,然后把两张灰度图的直方图并排看,差异一目了然。
滤波核的选择同样有讲究。高斯滤波的 σ 决定了平滑强度,核大小一般取 6σ+1 再向上取奇数,σ=1 时核大小约 7×7。σ 太小平滑不够,噪点还在;σ 太大边界会被糊掉,后续边缘检测定位精度下降。我一般从 σ=1.0、核 5×5 起步,看结果再调。中值滤波对付椒盐噪声效果更好,但它对细线条结构有破坏性,处理细长目标时要慎用。
注意:滤波一定要在灰度化之后、分割之前做,而且只对用于“找边界”的那一路做。如果你需要保留原始色彩做最终可视化,记得另存一份原图,别在滤波后的图上叠加彩色标记。
这里还有个实操细节:OpenCV 读进来的图是 BGR 顺序,不是 RGB。做灰度化时用 cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) 是安全的,但如果你自己手写加权公式,系数顺序要跟着换。我见过不少同学手写灰度化后结果发绿或者发紫,就是通道顺序搞反了。
2.2 Otsu 阈值:把公式手推一遍才算真懂
Otsu 的核心思想是找一个阈值 t,把直方图分成前景和背景两类,使得两类之间的方差最大。类间方差公式是 σ²(t) = ω₀(t)ω₁(t)[μ₀(t) - μ₁(t)]²,其中 ω 是两类各自的像素占比,μ 是两类各自的平均灰度。遍历所有可能的 t(0 到 255),取使 σ² 最大的那个。
举个能手算的小例子。假设一张 4×4 的图,灰度值只有 0、1、2 三档,直方图统计为:灰度 0 有 6 个像素,灰度 1 有 6 个,灰度 2 有 4 个,总共 16 个。试 t=0 时,背景类只含灰度 0(6 个,ω₀=0.375,μ₀=0),前景类含 1 和 2(10 个,ω₁=0.625,μ₁=(6×1+4×2)/10=1.4),类间方差 = 0.375×0.625×(0-1.4)² ≈ 0.459。试 t=1 时,背景含 0 和 1(12 个,ω₀=0.75,μ₀=0.5),前景只含 2(4 个,ω₁=0.25,μ₁=2),方差 = 0.75×0.25×(0.5-2)² ≈ 0.422。比较下来 t=0 更优。这个手算过程看起来繁琐,但推一遍你就明白为什么 Otsu 在直方图双峰明显时好用——它找的就是两峰之间那个让分离度最大的位置。
实际写代码时可以用 numpy 向量化,避免 256 次循环里再套像素循环。下面这段是我常用的写法:
import numpy as np import cv2 def otsu_manual(gray): hist = np.bincount(gray.ravel(), minlength=256).astype(np.float64) total = gray.size prob = hist / total omega = np.cumsum(prob) # 背景累计占比 mu = np.cumsum(prob * np.arange(256)) # 背景累计均值 mu_t = mu[-1] denom = omega * (1.0 - omega) denom[denom == 0] = 1e-12 # 防止除零 sigma_b = (mu_t * omega - mu) ** 2 / denom return int(np.argmax(sigma_b)) gray = cv2.imread('sample.png', cv2.IMREAD_GRAYSCALE) gray_blur = cv2.GaussianBlur(gray, (5, 5), 1.0) t = otsu_manual(gray_blur) _, binary = cv2.threshold(gray_blur, t, 255, cv2.THRESH_BINARY)这段代码和 cv2.threshold(..., cv2.THRESH_OTSU) 的结果应该完全一致,如果不一致,大概率是滤波或者通道处理那一步出了偏差。我习惯两个都跑一遍做交叉验证,误差为 0 才继续往下走。
2.3 Canny 的双阈值到底怎么定
Canny 的流程是四步:高斯平滑、计算梯度幅值和方向、非极大值抑制、双阈值检测加滞后连接。前三步都是确定性的,真正需要调的是最后一步的高低阈值。经验比例是 high:low 取 2:1 到 3:1,high 一般设在梯度幅值的 70 到 90 分位数附近。
滞后连接的逻辑值得说清楚:梯度大于 high 的像素直接标记为强边缘;介于 low 和 high 之间的标记为弱边缘,只有当它和某个强边缘连通时才保留。这个机制的好处是能抑制孤立的噪声响应,同时保住真实边缘的连续性。low 设太高会断边,设太低会引入大量伪边缘。
grad_x = cv2.Sobel(gray_blur, cv2.CV_16S, 1, 0, ksize=3) grad_y = cv2.Sobel(gray_blur, cv2.CV_16S, 0, 1, ksize=3) mag = cv2.convertScaleAbs(cv2.magnitude(grad_x.astype(np.float32), grad_y.astype(np.float32))) high = np.percentile(mag, 85) low = high * 0.4 edges = cv2.Canny(gray_blur, low, high)用分位数来定阈值比硬编码一个数字靠谱得多,因为它会随图像整体对比度自适应。但要注意,如果图里有大面积平坦区域,梯度幅值分布会很偏,分位数法也可能失准,这时候还是要人工看几张图定一个固定值。
Sobel 算子本身也值得一提。它的 3×3 核在 x 方向是 [[-1,0,1],[-2,0,2],[-1,0,1]],相当于先做了一次加权平均再求差分,所以对噪声有一定抑制。但它的定位精度只有像素级,且对斜向边缘响应偏弱。追求更好效果可以换 Scharr 算子,核系数更大,方向精度更高。
2.4 区域生长和分水岭:种子点决定成败
区域生长的思路很直观:选一个种子点,把灰度值在容差范围内的邻居不断并入,直到没有新像素可加。它的成败几乎完全取决于两件事——种子点位置和容差阈值。种子点要落在目标内部,容差要能覆盖目标内部的灰度波动但不能跨越边界。实际操作中我会先用阈值分割得到一个粗略掩膜,再在对每个连通域求质心作为种子点,这样比手工点选稳定得多。
分水岭算法则是把灰度图看成地形,灰度值当海拔,往低处注水,水汇合的地方就是分水岭线。它的经典问题是过分割——图里每个局部极小值都会形成一个盆地,噪声一多,盆地就碎成一片。解决办法是先做距离变换再用阈值找确定前景,把确定前景和确定背景分别做成标记,最后用 cv2.watershed 按标记引导分割。
_, sure_fg = cv2.threshold(dist_transform, 0.5 * dist_transform.max(), 255, 0) sure_fg = np.uint8(sure_fg) unknown = cv2.subtract(sure_bg, sure_fg) _, markers = cv2.connectedComponents(sure_fg) markers = markers + 1 markers[unknown == 255] = 0 markers = cv2.watershed(img_bgr, markers)这里 markers 加 1 是为了把背景标签从 0 移开,因为 0 在 watershed 里被保留给“未知区域”。这个细节不注意,结果就是整幅图被划成一块。我调试时习惯把 markers 用随机色上色可视化出来,一眼就能看出种子点布置得对不对。
3. 实操过程与核心环节实现
3.1 环境准备和数据组织方式
环境上其实没什么门槛,Python 3.8 以上加 opencv-python、numpy、matplotlib 就够跑传统方法;要上深度模型再加 torch 或者 tensorflow。我建议用虚拟环境隔离,避免不同实验的包版本互相打架,尤其是 OpenCV,4.x 不同小版本之间个别 API 行为有差异。
数据组织这块容易被轻视。我的做法是建三个目录:raw 放原图,mask 放标注或参考结果,output 放每次运行的输出,并且输出文件名带上时间戳和关键参数,比如 otsu_t128_sigma1.0.png。这样回头对比不同参数的效果时不用重新跑。A 同学曾经因为覆盖式保存,把调了两小时的中间结果冲掉了,只能从头再来,这个教训值得记一下。
提示:实验用的图尽量覆盖几种典型情况——目标与背景对比度高、对比度低、光照不均、目标相互粘连。每类准备两三张,调参时才发现参数是不是真的稳。
读图的路径也建议统一用相对路径加配置文件管理,别在代码里写死绝对路径,换台机器就跑不起来。
3.2 从阈值分割到形态学清理的完整链路
我通常的流程是这样的:读图、灰度化、高斯滤波、Otsu 二值化、形态学开运算去小噪点、闭运算补孔洞、连通域分析剔除面积过小的区域、输出叠加可视化。每一步都单独存一张中间图,方便回溯。
形态学核大小要跟目标尺度匹配。开运算的核应该小于目标的最小宽度,否则目标本身会被腐蚀掉。假设目标最细的地方大约 8 个像素宽,那核大小取 3×3 或 5×5 比较安全,取 11×11 就会把细长目标削断。闭运算的核则要略大于孔洞尺寸,一般取目标典型孔洞直径的一半左右。
连通域筛选的阈值用面积分位数定比较稳:统计所有连通域面积,剔除小于中位数 10% 的。但也别一刀切,如果图里本来就有小目标,那就得改成按绝对面积阈值筛选,或者根据目标数量的先验知识来定。
3.3 分水岭处理粘连目标的现场记录
拿一张有十几个相互接触的圆形目标做测试。直接对二值图做距离变换加阈值,阈值取最大距离的 0.5 倍时,部分小目标被漏掉了;降到 0.3 倍时,大目标内部出现了分裂,一个目标被切成两块。这个矛盾说明固定比例不可靠。
我的处理方式是改成局部极大值检测:对距离变换图做膨胀,取等于膨胀结果的点作为局部极大,再用这些点做标记。这样不管目标大小,每个目标至少能贡献一个峰。实测下来,粘连目标的分割准确率从六成多提升到九成以上,代价是需要多一次形态学操作和一点参数调试。
kernel = np.ones((5, 5), np.uint8) local_max = cv2.dilate(dist_transform, kernel) peaks = (dist_transform == local_max) & (dist_transform > 0.3 * dist_transform.max())后面那个 0.3 的下限是用来过滤掉平坦区域里那些等值的伪极大点,不加的话背景上会冒出一堆种子。
3.4 轻量 U-Net 跑通训练闭环的关键设置
传统方法跑顺之后,可以上一个很小的 U-Net 做对比。输入尺寸我一般裁到 256×256,再大显存吃不消,再小边界信息不够。网络结构就是三次下采样加三次上采样,通道数从 32 起步到 128 封顶,参数量控制在百万级以内,笔记本上也能跑。
损失函数用 Dice 加交叉熵的组合比单用交叉熵更稳,因为分割任务里前景像素往往远少于背景,单用交叉熵会让模型倾向于全判成背景。Dice 系数直接优化重叠度,对小目标更友好。学习率从 1e-3 起步,配合余弦退火,batch size 设 4 到 8。
数据量小的时候,增强比网络结构更能决定效果。随机水平翻转、小角度旋转、亮度抖动这三样基本够用。要注意亮度抖动的范围别太大,否则会破坏原本的亮度特征,反而让模型学不到东西。训练过程中每个 epoch 存一次验证集的 IoU,画成曲线看趋势,比只看 loss 更有信息量——loss 在降但 IoU 不涨,往往是过拟合的早期信号。
4. 常见问题与排查技巧实录
4.1 噪点多、边界毛刺的处理顺序
最常见的抱怨是“结果一堆小白点”。这种情况下先别急着加滤波参数,按顺序排查:第一步看二值图,如果白点是孤立的单个像素,形态学开运算一个 3×3 核就能清掉大部分;第二步看边界是否呈锯齿状,锯齿通常来自阈值判定在灰度过渡带上的抖动,可以在二值化前把图放大两倍再缩回来,或者用双边滤波代替高斯滤波,保住边界的前提下平滑内部。
如果开运算之后还剩大片碎块,那大概率是背景本身有纹理,这时候应该换分割策略,比如先做局部自适应阈值而不是全局 Otsu,或者引入区域生长来利用空间连续性。硬调全局阈值只会顾此失彼。
4.2 光照不均时全局阈值必然失效
一张图左亮右暗,全局 Otsu 一定会把暗侧的目标整片吃掉或者把亮侧背景整片误判。这是原理决定的,不是参数没调好。解决办法是自适应阈值,对每个像素取其邻域内的加权均值再减一个常数 C 作为阈值。块大小取 31 到 51,C 取 5 到 15 之间,视对比度而定。块太小会把目标内部也切出纹理,块太大则退化成全局阈值。
另一个思路是先做背景估计再相减:用大核的形态学闭运算估计出背景亮度面,原图减去背景面,把光照拉平之后再用全局阈值。这个方法在工业检测的均匀材质上效果很好,实测比自适应阈值更稳,缺点是对形态学核大小敏感。
4.3 过分割和欠分割的典型触发条件
分水岭的过分割前面说过,源头是局部极小值太多。欠分割则常见于区域生长——容差设得太小,目标内部灰度波动稍大就停止生长,结果是目标被切成几块。判断方法很简单:把生长过程可视化,看它在哪个位置停下。如果停下的地方灰度跳变很小,就是容差不够;如果跳变很大,说明确实到了边界。
还有一种隐蔽的欠分割:两个相邻目标之间的灰度差小于容差,生长直接越界把两个目标连成一片。这时候要么降容差,要么引入形状约束,比如限制生长区域的圆度或者面积上限。
4.4 问题速查表
| 现象 | 最可能的原因 | 优先尝试的调整 |
|---|---|---|
| 输出全黑或全白 | 阈值落在直方图单侧极值 | 打印直方图确认双峰位置,改用手动阈值验证 |
| 边界呈阶梯状锯齿 | 图像分辨率不足或梯度量化粗 | 上采样后分割,或改用亚像素边缘检测 |
| 目标内部出现空洞 | 目标内部灰度不均,阈值切穿了内部 | 先闭运算填洞,或改用区域生长 |
| 小目标整体丢失 | 形态学核大于目标尺寸 | 减小核尺寸,或先分割再分区域清理 |
| 相邻目标粘连 | 距离变换峰值合并 | 改局部极大值检测,加形状约束 |
| 自适应阈值后纹理噪声放大 | 块大小过小 | 增大块大小,先做一次中值滤波 |
| 训练 loss 降但 IoU 不涨 | 过拟合或类别极不平衡 | 加增强、换 Dice 损失、检查标注质量 |
这张表我一般贴在实验文档首页,八成问题都能在上面找到入口。
5. 结果评估与实验报告的关键点
5.1 IoU、Dice 和像素准确率该怎么取舍
像素准确率是最容易骗人的指标。一张图里背景占 95%,你全判成背景也能拿到 95% 的准确率,但分割完全失败。所以评估必须看 IoU(交并比)或者 Dice 系数。IoU = 交集 / 并集,Dice = 2×交集 / (预测面积 + 真值面积),两者单调相关,Dice 在数值上通常略高于 IoU。小目标场景下这两个指标对边界误差非常敏感,一个像素的偏移在小目标上可能让 IoU 掉十几个点,这是正常的,不代表算法崩了。
多类分割时要用宏平均(每类算完再平均),而不是把所有类混在一起算,否则大类会淹没小类。报告里我建议同时给出每类的 IoU 和平均值,再配一张混淆矩阵,问题一目了然。
还有一点:边界附近的评估可以单独做。用真值边界向外扩 2 个像素生成一个环带区域,只在这个环带内统计误差,能更精细地反映边界贴合度。这个指标在需要精密测量的场景里比全局 IoU 更有参考价值。
5.2 可视化呈现和结果对比的做法
报告里的图不要只放最终掩膜,要放原图、灰度图、中间二值图、最终结果四联图,读者才能看出每一步的作用。掩膜叠加到原图上时用半透明红色,透明度 0.4 左右既能看清目标又能保留原图纹理,比纯色填充信息量大。
对比实验的呈现有个技巧:不要只放“我的方法”和“参考方法”两张图,要把两者的差异图也放出来,差异像素标成醒目的颜色。这样改进到底改在哪个区域,一眼就清楚了。参数敏感性分析可以用折线图,横轴是参数值,纵轴是 IoU,找出曲线的平坦区,那个区间里的参数组合才是真正鲁棒的。
我在实际做这个实验的过程中最大的体会是,图像分割这活儿七分靠预处理和参数理解,三分才靠算法本身。同一个 Otsu,有人跑出来边界干净利落,有人跑出来一片糊,差别往往就在滤波核大小和灰度化方式这两个最不起眼的地方。另外,别迷信调参,先把直方图画出来看一眼,很多问题在图上就写着答案。至于分水岭的种子点,与其纠结阈值比例,不如老老实实做局部极大值检测,多写十行代码,省下两小时瞎试。