我自己的体会,做图像处理这些年,遇到过不少看起来很高深的问题,最后发现根基反而在那些最基础的操作上。阈值分割就是这么个东西,听着简单,不就是设个门槛把像素分开嘛,可真到用的时候,换个场景、换个光照,结果就是天差地别。前阵子正好整理了一个小实验,把几种经典的阈值分割方法在MATLAB里做了一遍对比,效果还挺直观的。今天就以“泾渭分明”这个比喻为切入点,把这套对比实验的思路、代码和踩过的坑都记录下来,给正在学数字图像处理或者刚接触机器视觉的朋友一个参考。
1. 这个微实验到底在做什么:从“泾渭分明”说起
先说清楚这个实验的初衷。所谓“泾渭分明”,往大了说就是要把两种东西干净利落地分开。在图像处理里,最常见的“分开”就是阈值分割——把图像里的目标和背景按照灰度值分成两部分。之所以要用“对比实现”这个思路,是因为阈值分割远不是写一行代码那么简单,不同算法对同样一张图的处理结果差别可以非常大。
这个实验适合三类人看。第一类是刚学完数字图像处理理论、想在MATLAB里把算法跑起来验证一下的学生;第二类是做工业视觉检测的工程师,经常需要把工件从背景里分离出来,阈值分割是第一步;第三类是纯粹好奇“Otsu到底比固定阈值好在哪”的人,看完这篇可以直接照搬代码去试。
实验的思路很直白:选一张合成的测试图,然后用几种不同的阈值分割方法去处理它,最后把结果放在一起对比。我选了四种方法:固定阈值、Otsu最大类间方差法、迭代阈值法、局部自适应阈值。这几种方法各有代表性,固定阈值是最朴素的,Otsu是全局阈值里的经典,迭代法体现了“自反馈”的思想,局部自适应则能处理光照不均的情况。
实验环境用MATLAB就够了,我用到的是绘图工具中的图像处理工具箱,里面自带的函数够用且不需要额外配置。如果用的是旧版本,部分函数可能没有,后面我会提替代方案。
2. 四种阈值分割算法的核心原理与适用场景
2.1 固定阈值:最直白的像素分类
固定阈值的逻辑一句话就能说清:把灰度值大于某个设定值的像素归为目标,小于等于的归为背景。这里的关键是“设定值”怎么来。最省事的是人工观察灰度直方图,找两个波峰之间的波谷位置。
代码实现也非常直接:
% 灰度图读取 img = imread('test_image.png'); grayImg = rgb2gray(img); % 固定阈值分割 thresh = 128; bw = grayImg > thresh; % 显示结果 figure; imshow(bw); title(['Fixed Threshold: T=', num2str(thresh)]);固定阈值最大的问题在于它对光照和噪声太敏感。同一张图,光线稍微变一下,灰度分布整体偏移,固定的128可能就完全不合适了。所以它适合的场景非常苛刻:目标与背景灰度差异极大、光照稳定、没有噪声干扰。用专业的说法就是“图像的灰度直方图呈典型的双峰分布”。
2.2 Otsu最大类间方差法:自动寻找最佳阈值
Otsu法的思路就比我手动挑阈值高级多了,它不依赖人眼观察,而是通过计算自动找到一个阈值,使得分割后的目标区域和背景区域之间的“类间方差”最大。用人话说就是:找一个灰度值,让分出来的两拨像素各自内部越均匀越好,两拨之间的差异越大越好。
MATLAB实现起来极为简单:
% 使用Otsu方法自动计算阈值 level = graythresh(grayImg); bwOtsu = imbinarize(grayImg, level); figure; imshow(bwOtsu); title(['Otsu Threshold: T=', num2str(level)]);注意两个细节。第一,graythresh返回的level是归一化到0到1之间的值,直接用imbinarize时没问题,但如果你想自己比较像素值,记得要先乘以255。第二,graythresh要求输入是灰度图或RGB图,如果是二值图传进去会报错。
Otsu法在绝大多数全局阈值场景下表现都很好,尤其是目标背景灰度差异明显的时候。但它也怕一种情况:当目标和背景的面积比例悬殊时(比如目标只占全图的5%),类间方差的计算会被大面积的背景主导,选出的阈值会偏向面积大的那一方,导致小目标被错误分割。这是需要特别留意的。
2.3 迭代阈值法:用反馈逼近理想值
迭代阈值法的思想比固定阈值“活”一点,它不一次性给定阈值,而是先猜一个初始值,然后根据分割结果反推新的阈值,如此循环直到收敛。具体步骤是:
- 选择初始阈值,通常用最大灰度值和最小灰度值的平均值
- 按当前阈值把图像分成目标和背景两组
- 分别计算两组的平均灰度值
- 取两个均值的平均值作为新阈值
- 重复2到4步,直到阈值不再变化
MATLAB实现如下:
function T = iterThresh(grayImg) T = 128; % 初始阈值 eps = 0.5; % 收敛精度 while true g1 = grayImg(grayImg > T); g2 = grayImg(grayImg <= T); T_new = (mean(g1) + mean(g2)) / 2; if abs(T_new - T) < eps break; end T = T_new; end end迭代阈值法本质上是Otsu法的一种近似,但计算量小,很适合嵌入到实时性要求高的系统里。它的收敛速度很快,通常几十轮迭代以内就能稳定。不过它的初始阈值选择会影响最终收敛到哪个局部最优解,这一点在图像灰度分布极其复杂时要小心。
2.4 局部自适应阈值:应对光照不均的利器
前三种方法有一个共同的致命弱点:它们都使用一个全局阈值处理整幅图像。如果图像光照不均,左边亮右边暗,不管全局阈值取多少,总会有一边出错。这时候需要的是局部自适应阈值——对图像分块,每个块单独算阈值,或者对每个像素根据其邻域灰度计算阈值。
MATLAB里推荐用adaptthresh函数:
% 局部自适应阈值 TAdapt = adaptthresh(grayImg, 0.4); bwAdapt = imbinarize(grayImg, TAdapt); figure; imshow(bwAdapt); title('Local Adaptive Thresholding');adaptthresh的第二个参数0.4是灵敏度因子,取值范围0到1。值越大,分割出的前景像素越多。这个参数需要根据实际效果反复调整,我做了几次之后发现,对于光照渐变明显的图,调到0.3到0.5之间效果都比较理想,但具体最优值确实要靠试验。
还有一种更底层的局部阈值方法——在MATLAB里自己实现邻域均值滑动窗口,但adaptthresh本质上已经做了类似的工作,实际工程中优先内置函数。
3. 实战代码:完整可运行的对比实验脚本
把上面的零散代码整合成一个完整的实验脚本,方便直接复制运行。这个脚本会生成一张合成的测试图,依次用四种方法分割,并把结果拼在一张大图里显示。
%% 微实验:图像阈值分割对比实现 clear; clc; close all; % 生成测试图像:添加了渐晕(光照不均)和噪声 [x, y] = meshgrid(1:256, 1:256); % 模拟暗场下的图像,中央亮、四周暗 img = 90 + 60.*(0.5+0.5.*cos(sqrt((x-128).^2+(y-128).^2)./60 .* pi)); % 加入一个高亮的方形目标 img(100:160, 100:160) = 200; % 加一点高斯噪声 img = img + 10*randn(size(img)); grayImg = mat2gray(img); % 归一化到[0,1] figure('Name','Original'); imshow(grayImg); title('原始图像(带光照不均和噪声)');这里我特意做了一件事:给测试图加了光照不均(中央亮四周暗)和高斯噪声。目的是为了让固定阈值和Otsu这类全局方法暴露问题,让局部自适应阈值充分展示优势。如果只用一张干净的图做对比,所有方法效果几乎一样,实验就没有说服力了。
然后是对比分割:
%% 1. 固定阈值 thresh = 0.5; % 归一化后的阈值 bwFixed = grayImg > thresh; %% 2. Otsu levelOtsu = graythresh(grayImg); bwOtsu = imbinarize(grayImg, levelOtsu); %% 3. 迭代阈值法 TIter = iterThresh(uint8(grayImg*255)); bwIter = grayImg > (TIter/255); %% 4. 局部自适应阈值 TAdapt = adaptthresh(grayImg, 0.4); bwAdapt = imbinarize(grayImg, TAdapt); %% 合并显示 figure('Name','对比结果'); subplot(2,2,1); imshow(bwFixed); title(['固定阈值 T=0.5']); subplot(2,2,2); imshow(bwOtsu); title(['Otsu T=', num2str(levelOtsu)]); subplot(2,2,3); imshow(bwIter); title(['迭代阈值 T=', num2str(TIter/255)]); subplot(2,2,4); imshow(bwAdapt); title('局部自适应阈值');还需要把迭代阈值法函数单独保存成一个m文件,或者直接放在脚本末尾(MATLAB R2016b之后支持脚本内定义局部函数):
function T = iterThresh(grayImg) % 输入需为uint8类型灰度图,返回绝对灰度阈值 T = 128; % 初始阈值 epsVal = 0.5; while true g1 = grayImg(grayImg > T); g2 = grayImg(grayImg <= T); T_new = (double(mean(g1)) + double(mean(g2))) / 2; if abs(T_new - T) < epsVal break; end T = T_new; end end跑完之后可以顺手算一个量化指标——分割错误率或Dice系数。我这里简单用“目标区域像素占比误差”做一个粗略评估:
% 假设目标区域真实占比(这里已知ground truth为方形区域) trueMask = false(256,256); trueMask(100:160, 100:160) = true; % 计算Dice系数 diceFixed = 2*sum(sum(bwFixed & trueMask)) / (sum(bwFixed(:)) + sum(trueMask(:))); diceOtsu = 2*sum(sum(bwOtsu & trueMask)) / (sum(bwOtsu(:)) + sum(trueMask(:))); diceIter = 2*sum(sum(bwIter & trueMask)) / (sum(bwIter(:)) + sum(trueMask(:))); diceAdapt = 2*sum(sum(bwAdapt & trueMask)) / (sum(bwAdapt(:)) + sum(trueMask(:))); fprintf('Dice系数:固定=%.3f, Otsu=%.3f, 迭代=%.3f, 自适应=%.3f\n', ... diceFixed, diceOtsu, diceIter, diceAdapt);Dice系数越接近1说明分割结果和真实目标重叠度越高。这一跑,数据就很明显了,往往是固定阈值最低,局部自适应最高。具体数值会因随机噪声每次略有波动,但趋势稳定。
4. 实验结果深层解读:为什么自适应阈值在光照不均下更稳
跑完上面的代码,观察结果图,几乎一定能看到这样几个现象:固定阈值分割后,图像四周暗区域被错误地分成了背景,中央亮区域连背景也被分成了目标,目标方块反而出现空洞和毛边;Otsu法的结果比固定阈值好一些,但仍会出现中央背景被误判为目标、四周目标被误判为背景的情况;迭代阈值法与Otsu法结果接近,但目标边缘毛刺更多;局部自适应阈值法的结果最干净,目标方块完整,背景基本无杂点。
为什么会出现这种差异?根本原因在于全局阈值和局部阈值的数学本质不同。全局阈值(固定阈值、Otsu、迭代法)在对图像做建模时,隐含了一个假设:目标灰度和背景灰度的分布在全图是一致的。这个假设在均匀光照下勉强成立,可一旦光照变化,灰度分布就有了“位移”和“拉伸”,全局阈值只能选择一个“平均最优”的切割点,必然是顾了东边丢了西边。
局部自适应阈值则是对每个像素(或每个区域)设立一个“局部裁决标准”,相当于在每个小邻域内单独执行一次阈值判断。它隐含的假设更宽松:目标与背景的对比关系是局部的,灰度绝对值的全局一致性不重要。因此,当光照不均时,局部方法天然更稳健。
从计算复杂度看,四种方法的开销也完全不同。固定阈值最快,Otsu需要统计直方图并做累加计算,迭代法需要多次遍历图像并计算均值,自适应阈值最慢。但话说回来,对于静态图像处理问题,自适应阈值的耗时通常仍然是可以接受的——我用256乘256的图测试,单次adaptthresh加imbinarize总共不到0.3秒。但如果处理视频流的实时分割,帧率要求高,就得权衡精度和速度了。
这给了工程上一个重要的启发:没有一个“万能”的阈值分割方法。选型时要先分析应用场景的图像特性,如果环境可控(有稳定光源、固定拍摄角度),用固定阈值或Otsu就行,省时省力;如果环境不可控(户外光照变化、物体表面反光),优先考虑局部自适应,或者结合图像预处理(如背景差影、直方图均衡化)来缓解光照影响。
5. 从微实验到实际项目:参数调优与那些容易踩的坑
实验做完,代码能跑通,这只是第一步。真正到了项目里,阈值分割的坑比想象中多得多。我把自己踩过几个来回的坑总结一下,顺手给出解决思路,希望能帮读者少走弯路。
5.1 灰度图与彩色图:RGB三分量怎么选
很多初学者拿到彩色图像直接rgb2gray转灰度就完事了。这在某些场景下会有问题:如果目标和背景颜色不同但亮度相近,转成灰度后它们的灰度值可能非常接近,阈值分割就分不开。这时候更好的做法是,先分析RGB三个通道中哪个通道的目标和背景差异最大,再选那个通道做分割。有时候用HSV空间的色相或饱和度分量效果更佳。一句话:阈值分割不只是“转灰度再分割”,如何设计颜色特征本身就是一门学问。
5.2 噪声预处理:阈值分割前先降噪
我上面实验里特意给图加了高斯噪声,结果大家也看到了,不管哪种方法,噪声都会带来细碎的误判区域。解决方法是先滤波再分割,常见选择是高斯滤波或中值滤波。特别是中值滤波在去除椒盐噪声时效果极好,且能保留边缘信息。MATLAB里直接medfilt2(grayImg, [3 3])或imgaussfilt(grayImg, 1.5)即可。做分割前先降噪,这一步往往能把Dice系数提升好几个百分点。
5.3 局部阈值的邻域大小:不是越大越好
说到adaptthresh的参数,很多人只会调灵敏度,忽略了邻域大小。adaptthresh默认使用图像的全局统计信息来估算局部邻域尺寸,通常效果不错。但遇到目标尺寸特别大或特别小的图像时,默认邻域可能不合适。邻域窗口太大,局部性就没了;窗口太小,容易把噪声当成信号。一个经验是:窗口尺寸至少要是目标最大尺寸的1.5倍,但不能超过背景区域尺寸的一半。具体数值还是要实验确定,打表做一组扫参比较靠谱。
5.4 阈值分割后的形态学处理:最后一道工序
分割完成后得到的是二值图,但常常有空洞、毛刺、孤立噪点。这时候再加一道形态学操作,效果会好很多。常用操作是闭运算填小洞、开运算去孤立点、中面积滤波去掉小连通域。MATLAB里:
% 去除小于100像素的连通域 bwClean = bwareaopen(bwAdapt, 100); % 闭运算填补空洞 se = strel('disk', 3); bwClose = imclose(bwClean, se);形态学处理不能乱用,结构元素大小和迭代次数会影响目标真实边界,选大了目标会被“胖”一圈,选小了又起不到清理作用。我的习惯是:先用小于目标最小宽度1/3的结构元素,逐步增大观察效果。
5.5 对比实验的“公平性”:统一窗口和参数
有些朋友在做算法对比时,没注意各方法之间参数的公平性。比如给Otsu用了经过滤波的图,给固定阈值却用了原始图,这样对比结果就没有意义。做对比实验时,除了被验证的算法本身,其他条件尽量保持一致:同一输入、同一预处理流程、同一评价指标。这样得到的数据才可信,写报告和文章时也站得住脚。
6. 一点扩展:如果换成深度学习分割,阈值法还有用吗
写到这里,我知道很多人心里会有个疑问:现在深度学习分割那么火,U-Net、DeepLab这类模型动辄刷榜,阈值分割是不是已经过时了?我的看法是:没有过时,而且很多深度学习方案的最后一层做的事本质上就是一种高级阈值分割。
深度学习模型输出的是每个像素属于目标的概率图,概率值在0到1之间,最后要用一个阈值(通常是0.5)来二值化。这个“阈值”的选择和调优,本质讨论的还是阈值分割的问题。只是深度模型通过海量数据学出了更好的“灰度变换”,把原始图像映射成了更容易分割的特征概率图。可以理解为:深度学习把特征提取和阈值分割融合成了一个整体,是阈值分割思想的高级延伸。
而且,在工业检测、文档扫描、显微图像分析这类数据量大、实时性要求高的场景里,传统阈值分割因为速度快、可解释性强、不需要大量标注数据,仍然是工程上的首选。具体落地时,我见过很多方案是先做阈值分割得到候选区域,再用深度学习做精细分类识别,两者结合,极大降低了整体算力消耗。
所以,认真把阈值分割的几种经典方法吃透,不仅不是浪费时间,反而是深入理解更复杂分割算法的基础。这次微实验只是一个起点,建议读者拿到代码后,换几种不同的真实图像去跑,观察结果差异,再亲手调一调参数,比看十篇文章都管用。任何算法都不是万能的,关键是理解它适用的土壤和局限,才能真正把“泾渭分明”的效果落地到自己的项目里。