☰
图像边缘检测与分割算法全解析:从Roberts到Canny及Otsu方法
2026/10/19 11:35:06 网站建设 项目流程

前阵子有个同学拿着课程作业来找我,说程序跑出来的边缘图全是断的,Sobel阈值调了半天也没救回来。我打开一看,问题根本不是阈值,而是他连输入图像都忘了先转double。这种场景我见过太多次——图像处理这门课,边缘检测和分割永远是新手的第一个坎,但这五个算法(Roberts、Prewitt、Sobel、Marr-Hildreth,Canny)加Otsu方法,本质上是同一条思路串出来的:找灰度突变的位置,只是各自用了不同的数学工具和工程策略。

这篇文章我想按一条完整的脉络,把这五个边缘检测算法和一个分割方法从头到尾拆开讲透,每一段都配上可以直接跑的Matlab代码,并把背后“为什么这么设计”的逻辑也说明白。适合正在做课程作业、准备考试,或者刚接触图像处理想系统理解这块内容的人。代码和思路我都会尽量兼顾完整性和可读性,你拿去改改就能用。

1. 先搞明白:边缘检测到底在检测什么

1.1 边缘的本质是灰度突变

你可以把一张灰度图像理解成一块高低不平的地形——像素灰度值是海拔,边缘就是地形上“从一块平坦区域掉到另一块平坦区域”的那条陡坡。陡坡的位置意味着灰度发生了剧烈变化,而这种变化在数学上对应着一个东西:梯度。

一维情况下,灰度突变就是函数值跳变,导数很大;没变化的地方导数为零。二维图像也一样,梯度向量的幅度大,代表这个像素附近灰度变化剧烈,大概率是边缘。所以边缘检测本质上是梯度检测,而所有经典边缘检测器的区别,都在于“怎么估计梯度”和“怎么判断哪些梯度值得保留”。

后面要讲的Roberts、Prewitt、Sobel,都是一阶导数的离散近似,计算的是梯度本身;Marr-Hildreth用的是二阶导,检测的是灰度变化率的突变点(二阶导过零);Canny则是在梯度基础上叠了非极大值抑制和双阈值,把“找梯度”升级成“提取有序的边缘线”。理解了这个框架,后面每个算法都是往这个骨架里填肉。

1.2 为什么不是“一个函数搞定所有事”

很多人会问:Matlab里edge函数一行就能出结果,为什么还要自己写这些算子和原理?因为edge是个黑盒子,里面每一步都有工程取舍。你不知道它的平滑参数、不知道它怎么处理边界、更不知道为什么某些边缘它选保留某些它选丢弃。遇到效果不合预期时,调参就是瞎猜。

另外这五个算法各有性格:Sobel抗噪不错但边缘粗,Marr-Hildreth封闭性好但对噪声极敏感,Canny最精细但参数最多,Otsu完全不需要人工阈值但只适合双峰明显的直方图。你只有亲手实现一遍,才知道这些性格的来源,才谈得上选型。

1.3 我这篇文章里代码的组织方式

我会按照“原理→公式→Matlab实现→效果点评”的顺序来讲每个算法。代码刻意用手写卷积来实现核心逻辑,不直接用现成API(除了最后对比时会提到edge),这样你既能理解原理,又能在作业里向老师展示“我真的会”。

统一约定:所有输入图像先转灰度,转成double类型再处理。放码前先给一个读取和转灰度的基础片段:

%% 读图与预处理 img = imread('test_image.png'); if size(img, 3) == 3 gray = rgb2gray(img); else gray = img; end f = double(gray); % 卷积运算一定用double,uint8会溢出截断

这一步我会在下面每个算法里复用,后面不再重复写读取代码。

2. Roberts、Prewitt、Sobel:三种一阶梯度算子

这三个算子都是“小模板滑过图像”的卷积做法,区别只在模板多大、权重怎么分配。

2.1 Roberts算子:2x2模板的直截了当

Roberts是1963年提出的老前辈,想法非常朴素——用像素对角方向的灰度差来估计梯度。它用两个2x2模板:

gx = [1 0; 0 -1]; % 45度方向差分 gy = [0 1; -1 0]; % 135度方向差分

先对图像做卷积得到两个方向偏导,梯度幅度就是二者平方和再开方:G = sqrt(gx_img.^2 + gy_img.^2)。

优点:模板最小、计算最快,边缘定位精度高。缺点也很明显:2x2模板只用4个像素,对噪声毫无抑制能力——单像素的椒盐噪声就能同时激活两个模板,输出一个强烈的假边缘。另外它只检测对角线方向,水平或垂直边缘的响应并不对称,导致同一物体在不同角度下边缘强弱不一致。

2.2 Prewitt算子:3x3模板的均值意义

Prewitt把模板扩到3x3,解决了Roberts对方向过度敏感的问题。水平模板检测垂直边缘,垂直模板检测水平边缘:

px = [-1 0 1; -1 0 1; -1 0 1]; % 检测垂直边缘 py = [-1 -1 -1; 0 0 0; 1 1 1]; % 检测水平边缘

拆开看就能发现,Prewitt模板其实做了两件事:先对图像做3x1的均值平滑(同一列取三个值累加),再做水平差分。均值平滑能压掉一部分随机噪声,所以同样面对带噪图像时,Prewitt输出的假边缘会比Roberts少一截。

代价是什么?均值平滑本身会把边缘“抹平”一点,导致检出的边缘比真实边缘略宽——这就是为什么Prewitt边缘图看起来总比Roberts“粗一圈”。边缘定位精度有所牺牲,换取噪声抑制能力,这是一个典型的工程权衡。

2.3 Sobel算子的加权诀窍

Sobel算子是Prewitt的升级版,模板从全1变成了中心权重2:

sx = [-1 0 1; -2 0 2; -1 0 1]; % 检测垂直边缘 sy = [-1 -2 -1; 0 0 0; 1 2 1]; % 检测水平边缘

多加的权重2意味着什么?中心像素的灰度对梯度贡献更大。这等价于先用一个带权重的平滑核([1;2;1])在垂直方向平滑,再做水平方向的中心差分。中心加权的平滑核更接近高斯形状,对噪声的抑制效果比Prewitt的均匀均值更好,同时保持了边缘定位的锐利度。这也是为什么Sobel在工程上比Prewitt更常用——性价比极高,模板只有3x3,但效果逼近更复杂的算子。

2.4 三种算子的Matlab手写实现

%% Roberts roberts_gx = conv2(f, gx, 'same'); roberts_gy = conv2(f, gy, 'same'); roberts_mag = sqrt(roberts_gx.^2 + roberts_gy.^2); %% Prewitt prewitt_gx = conv2(f, px, 'same'); prewitt_gy = conv2(f, py, 'same'); prewitt_mag = sqrt(prewitt_gx.^2 + prewitt_gy.^2); %% Sobel sobel_gx = conv2(f, sx, 'same'); sobel_gy = conv2(f, sy, 'same'); sobel_mag = sqrt(sobel_gx.^2 + sobel_gy.^2); %% 显示:直接显示梯度幅度,从数据范围归一化到0-255 figure; subplot(2,2,1); imshow(f, []); title('原图'); subplot(2,2,2); imshow(roberts_mag, []); title('Roberts梯度幅度'); subplot(2,2,3); imshow(prewitt_mag, []); title('Prewitt梯度幅度'); subplot(2,2,4); imshow(sobel_mag, []); title('Sobel梯度幅度');

注意两点:conv2默认在边界补零,这在图像边缘区域会产生明显的渐变假边缘——后面我在“常见问题”章节里会单独说边界处理。另外imshow加[]参数是让Matlab自动把最小值映射到0、最大值映射到255,避免梯度值范围不一导致图太黑或太白。

2.5 用阈值把梯度图变成边缘图

上面的梯度幅度图是连续灰度图,还不是“边缘图”。要得到二值边缘,需要做一次阈值化:梯度大于一定阈值的像素标记为边缘。阈值怎么定?最简单的是设成最大梯度的某个百分比:

thresh = 0.2 * max(sobel_mag(:)); edge_binary = sobel_mag > thresh;

这个0.2就是“灵敏度旋钮”。调低了边缘多但是噪声也多,调高了边缘干净但是会断。没有绝对标准,得看你的图和需求。如果你不想手调,也可以直接用Matlab内置自动阈值:

thresh_auto = graythresh(sobel_mag / max(sobel_mag(:))); edge_binary = sobel_mag > thresh_auto * max(sobel_mag(:));

graythresh是按Otsu方法计算阈值,这个我们第五章节细讲。

3. Marr-Hildreth算子:用二阶导的零交叉找边缘

3.1 为什么还要绕到二阶导

一阶导找的是梯度极大的位置,但梯度变化是渐变的,尤其是边缘带有过渡带或模糊时,一阶导的峰值很平缓,难以精确定位。二阶导呢?峰值点对应的是二阶导的过零点——从正变负或从负变正的那一个像素位置,定位比一阶导的峰更精确。

Marr-Hildreth(也叫LoG算法,Laplacian of Gaussian)的思路:先用高斯核平滑图像消除噪声,再对平滑结果求拉普拉斯(二阶导)。因为线性滤波可以合并,实际实现时直接用一个“高斯拉普拉斯”核,一次卷积完成。

3.2 LoG核的数学形式与生成

连续二维LoG函数是:

LoG(x, y) = -1/(πσ^4) * [1 - (x^2+y^2)/(2σ^2)] * e^(-(x^2+y^2)/(2σ^2))

这个算子在中心是正(或负,看符号约定)的碗状,外围是一圈反向的环,形象地说像个墨西哥草帽。用Matlab生成LoG核:

function h = log_kernel(sigma, n) % n为核尺寸,一般取6*sigma取奇 if nargin < 2 n = 2 * ceil(3 * sigma) + 1; end half = (n - 1) / 2; [x, y] = meshgrid(-half:half, -half:half); r2 = x.^2 + y.^2; h = -(1 / (pi * sigma^4)) * (1 - r2 / (2 * sigma^2)) .* exp(-r2 / (2 * sigma^2)); end

sigma决定核的尺寸和抗噪能力。sigma越大,平滑越强,检出的边缘越“宏观”,但定位误差也会变大;sigma太小,噪声压不住,会冒出大量假边缘。

3.3 零交叉检测的Matlab实现

卷积后用LoG核处理图像,得到的是二阶导响应图。边缘在哪?在响应从正到负跨越的那个位置。判断方法:遍历每个像素,检查它4邻域或8邻域内有没有正负号变化:

H = log_kernel(2, 15); LoG_resp = conv2(f, H, 'same'); % 零交叉检测 [rows, cols] = size(LoG_resp); edge_log = zeros(rows, cols); for i = 2:rows-1 for j = 2:cols-1 patch = LoG_resp(i-1:i+1, j-1:j+1); if max(patch(:)) > 0 && min(patch(:)) < 0 edge_log(i, j) = 1; end end end

这段循环有O(n²)复杂度,图片大时会慢,但理解起来最直观。工程优化版本可以用形态学膨胀配合符号比较来做向量化,这里不展开了。

实话说LoG有一个显著特点:输出的边缘是闭合的——它不像Sobel那样只在梯度大的地方出线,它会把所有灰度变化的边界都圈出来,包括缓慢变化的阴影边界。这在某些场景下是优点,在另一些场景下就是灾难,因为缓变区域也会被算作边缘,图会显得很“脏”。实际用的时候,通常还会加一个梯度幅度阈值过滤掉弱响应。

4. Canny边缘检测:工程界最常用的标准方案

Canny在1986年提出,三个目标:低错误率(真实边缘不漏检、非边缘不多检)、定位精准(检出的边缘像素尽量贴近真实边缘)、单像素响应(同一条边缘只输出一条线而不是一坨)。为了同时满足这三个目标,Canny设计了五步流水线。我认为理解Canny的窍门是把它“拆成流水线”,每一步解决一个具体问题。

4.1 五步流水线

第一步:高斯平滑去噪。这一步直接影响后续所有结果,和LoG一样,sigma太小压不住噪声,sigma太大会抹掉细边缘。第二步:计算梯度幅度和方向。这一步通常用Sobel算子实现,得到每个像素的梯度幅度和方向(4方向量化:水平、垂直、45度、135度)。第三步:非极大值抑制——把梯度峰值两边不是最大的像素置零,让边缘变细。第四步:双阈值——设置高阈值TH和低阈值TL,把梯度大于TH的像素标记为强边缘,介于TL和TH之间的标记为弱边缘,小于TL的丢弃。第五步:滞后连接——强边缘一定是最终边缘;弱边缘只有与强边缘连通才保留,孤立弱边缘丢弃。这一步能有效去除“断裂的噪声响应”。

4.2 非极大值抑制的细节

非极大值抑制是Canny里最容易被忽视的步骤。直觉上讲:边缘附近的梯度响应是一个“小山丘”,峰值在边缘正中心,两侧梯度逐渐降低。如果我们把所有高于某阈值的像素都标成边缘,那边缘会是一条宽带。非极大值抑制就是在梯度方向上只保留局部最大的那个像素。

实现要点:判断一个像素是不是局部最大,要在它梯度的方向上看它的邻居。梯度方向垂直于边缘,所以要让3x3邻域落在梯度方向上的两个邻居(非格点方向)都做比较。通常把梯度方向量化为四种角度范围:0度、45度、90度、135度,然后比较对应位置的梯度幅度。例如梯度方向接近0度(水平方向),就比较左右两个水平邻居的梯度值。

例如量化后方向为0度,当前像素值若大于左边邻居且大于右边邻居,就保留,否则置零。下面给出一个完整但较容易理解的双循环版本,注意用向量化优化:

% 假设 Gmag 是梯度幅度,Gdir 是梯度方向(弧度) Gdir_q = round(Gdir / (pi/4)); % 量化 % 按方向决定比较邻居

篇幅有限,完整非极大值抑制代码会有点长,库函数edge(I,'canny')内部已经封装好了。教学实现时可以用简化版:只沿梯度方向比较两邻域值的最大值来更新梯度图。

4.3 双阈值和滞后连接

设高阈值TH,低阈值TL(通常TL = 0.5 × TH)。强边缘直接保留,弱边缘只有“贴”着强边缘才保留。怎么判断“贴着”?常用做法是8邻域递归/广度优先传播:从强边缘像素出发,向8邻域扩散,遇到弱边缘像素就把它提升为强边缘,再继续扩散,直到没有新像素可提升为止。这个传播过程会让原本断裂的边缘重新连起来——弱边缘如果孤立存在(大概率是噪声),则没有强邻居支撑,删除。

4.4 Matlab实现(手写版 vs 内置版)

如果你要手写Canny,我建议分文件实现逐步调试,主框架如下:

%% Canny手写主流程 sigma = 1.5; G = fspecial('gaussian', 2*ceil(3*sigma)+1, sigma); f_smooth = conv2(f, G, 'same'); [Sx, Sy] = gradient(f_smooth); % 数值梯度替代Sobel Gmag = sqrt(Sx.^2 + Sy.^2); Gdir = atan2(Sy, Sx); % 非极大值抑制(见上小节) % 双阈值+滞后连接 TH = 0.3 * max(Gmag(:)); TL = 0.1 * max(Gmag(:));

如果你就是为了出结果、做对比,直接用内置函数就好:

edge_canny = edge(f, 'canny', [TL TH], sigma);

注意这里的[TL TH]如果直接用0到1的范围值,Matlab会当成归一化阈值;也可以不传就让Matlab自动估计阈值。实际上手写版的价值在于让你能逐行打断点看中间结果,真正理解“为什么Canny边缘是连续单像素的”——这一点理解了,后面调任何边缘检测参数都有底气。

用Canny出的边缘,细、连续、定位准,这也就是为什么它在工业界地位这么高。

5. Otsu方法:自动找阈值的图像分割

5.1 分割与阈值的连接

边缘检测告诉你“哪里是边界”,图像分割要告诉你“哪些像素属于同一个区域”。最朴素的区域分割就是灰度阈值分割:选一个灰度值T,灰度大于T的像素标为前景,小于T的标为背景。问题是T怎么选?人工调的话,不同图片不同光照得反复试,Otsu就是为了解决这个“自动选T”的问题。

5.2 最大类间方差的思想

Otsu的准则非常清晰:如果阈值T把灰度分成前景C0和背景C1两类,我们希望这两类之间的差异尽可能大、每一类内部尽可能均匀。类间方差的公式为:

σ²B(T) = ω0(T) * ω1(T) * [μ0(T) - μ1(T)]²

其中ω0和ω1分别是前背景像素比例,μ0和μ1是两类的平均灰度。遍历所有可能的灰度值T(0到255),让σ²B最大的那个T就是最优阈值。这个准则非常直观:如果阈值选在直方图的两个峰中间的“谷底”,那么两类均值差距最大、类内方差小,类间方差最大;如果阈值偏到某个峰的内部,两类均值差距变小,类间方差自然也变小。所以找最大类间方差等于自动找谷底。

5.3 Matlab手写Otsu

function T = otsu_threshold(f) % 输入f为double灰度图 counts = imhist(uint8(f)); % 256个灰度级的像素计数 total = numel(f); p = counts / total; % 灰度概率分布 omega0 = cumsum(p); mu_cum = cumsum((0:255)' .* p); mu_total = mu_cum(end); % 遍历所有阈值1~255 sigmaB = zeros(256, 1); for t = 1:255 omega0_t = omega0(t); omega1_t = 1 - omega0_t; if omega0_t == 0 || omega1_t == 0 sigmaB(t) = 0; continue; end mu0 = mu_cum(t) / omega0_t; mu1 = (mu_total - mu_cum(t)) / omega1_t; sigmaB(t) = omega0_t * omega1_t * (mu0 - mu1)^2; end [~, T] = max(sigmaB); % 找最大类间方差对应的灰度 end

调用时:

T = otsu_threshold(f); segmented = f > T;

注意imhist(uint8(f))——如果你输入的是double,Matlab的imhist默认把0~1里的值当成灰度级别,直接调用得到的是错误直方图。转成uint8是正确姿势。另外如果图像直方图是单峰(比如大部分像素是背景,前景极小),Otsu的类间方差曲线不会有明显的峰,自动选出的阈值常常偏离预期,这时要考虑用固定阈值或先做直方图均衡化。

5.4 内置函数对比

Matlab内置对应的是:

T_norm = graythresh(gray); % 返回0~1的归一化阈值 T_real = round(T_norm * 255); bw = imbinarize(gray); % 直接得到二值图

graythresh统计过程和我上面的代码等效,所以你自己写一遍的意义,更多是理解而不是超越。不过手写版本会让你真正明白“阈值的取值依据是直方图形状,而不是某个固定灰度值”这件事。

6. 常见问题与调参技巧实录

6.1 噪声是边缘检测的头号敌人

Roberts和Marr-Hildreth对噪声极敏感。前者因为模板太小没平滑,后者因为拉普拉斯本身就放大噪声。所以实际用的流程永远是:先平滑,再检测边缘。Canny内部已经做了高斯平滑,不需要你再先平滑一次;但如果你觉得Canny检出的边缘噪声多,优先降低sigma而不是中途再模糊一次——重复模糊会让边缘偏移。

6.2 阈值选择的标准做法

对于Sobel/Prewitt这类梯度图,我通常按梯度最大值的百分比选阈值,初始选0.15到0.3之间,观察边缘连续性再微调。如果场景复杂、边缘多而杂,可以改用自适应阈值(比如局部均值加常数)替代全局阈值。对于Canny,双阈值里高阈值控制边缘的“主干信度”,低阈值控制“延伸灵敏度”,经验上低阈值设成高阈值的一半左右,再慢慢微调。调试时建议实时显示二值图和梯度幅度图对照,比只看最终结果更容易判断问题出在阈值还是出在预处理。

6.3 Matlab数据类型踩坑清单

这部分是实操中遇到最多的坑,我直接列出来:

  • uint8卷积后结果仍然是uint8,负数会截断成0,正数超过255截断成255,所以运算前一定转double。
  • conv2默认边界补零,图像边缘一圈会产生很大伪梯度。用imfilter(f, kernel, 'replicate')或者'symmetric'来延伸边界,能显著减轻边缘效果。例如prewitt_gx = imfilter(f, px, 'replicate')。
  • graythresh输入图像是uint8或double都可以,但返回阈值永远是0到1归一化值。你要实际灰度阈值时别忘了乘以255,上面提过这个坑,这里再强调一次。
  • 显示用imshow(img, [])自动拉伸灰度范围,避免因为你转成double后范围不在0到255而显示成一片黑。
  • imhist(uint8(f))和histogram(f(:), 256)在统计灰度分布时结果一致但细节有区别,调试直方图时留意。

6.4 各算法怎么选型

我用一个表来归纳具体场景下的选型建议:

需求场景推荐算法关键参数注意事项
实时性要求高、图像干净Roberts无噪声环境下极易误检
教学演示、基础边缘Prewitt无边缘较宽,需配合阈值
通用场景、默认首选Sobel阈值均衡性好,工程常用
需要闭合边缘轮廓Marr-Hildrethsigma噪声敏感,需先降噪
边缘细腻、定位精度要求高Cannysigma、双阈值参数多,但效果稳定
自动分割而不想调阈值Otsu无直方图双峰时效果好
目标与背景灰度重叠很大以上都难—考虑纹理、颜色或深度信息

从经验上看:如果不知道选哪个,默认走Sobel+Canny的组合——Sobel看整体趋势,Canny看细节轮廓,两者互补足够应付大多数入门项目。

6.5 手写边缘检测的调试心得

手写实现时我强烈建议“逐步可视化”。也就是每算完一个中间结果就figure; imshow(...)看一眼。比如写Canny,先看平滑后的图,再看梯度幅度图,再看NMS后的图,最后看双阈值连接结果。哪一步不对,一眼就知道。我曾调试过一个非极大值抑制的实现,发现边缘全错位,就是因为量化方向时把atan2结果量化错了象限,图一对比就暴露了。这种逐层调试的经验,比背十遍公式都有用。

7. 写在最后的一点经验

回顾整个算法家族,你会发现它们本质上都在回答同一个问题:如何用离散的像素数据,精确地估计连续灰度场中的突变位置。Roberts是最朴素的第一代,Prewitt和Sobel是工程化的第二梯队,Marr-Hildreth绕到二阶导获得更锐利的定位,Canny用非极大值抑制和双阈值把边缘从“点集”变成“线”。而Otsu虽然名义上是分割,但它解决的问题和边缘检测殊途同归——都是把图像里的“结构”从“背景”里剥离出来。

对我个人来说,学这块最大的收获是:不要直接信任API,也不要恐惧手写。你亲手写一遍Roberts的2x2模板,会真正理解为什么它抗噪差;你亲手实现一遍Canny的滞后连接,会彻底搞懂双阈值的意义。这些理解会在你后续做分割、目标检测、轮廓提取时反复用上。

如果你正在写课程报告,建议把每种算法的中间结果图都贴出来,配上你的阈值设定和调试说明,这比只贴一张最终效果图有说服力得多。最后送你一个小技巧:跑代码之前先看一眼图像的直方图,心里对灰度分布有个概念,再选择边缘检测还是Otsu分割——有时候问题根本不是算法,而是你一开始就选错了工具。

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

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

立即咨询