已经学习了 Sobel 算子,知道如何计算图像在 X、Y 两个方向上的灰度梯度。今天我们就要解决一个更进一步的问题:
既然 Sobel 已经能找到灰度变化明显的位置,为什么还需要 Canny?
核心原因是:Sobel 计算的是梯度响应,而 Canny 会进一步筛选、细化和连接边缘,最终生成更适合后续处理的二值边缘图。
今天我们主要完成四件事:理解 Canny 的四个步骤、掌握双阈值与滞后连接、完成农田图像对比实验,以及弄清楚 Canny 与 Sobel 的关系。
一、Canny 和 Sobel 到底有什么区别?
先回忆昨天的代码:
cv::Sobel(gray, gradX, CV_32F, 1, 0, 3);
cv::Sobel(gray, gradY, CV_32F, 0, 1, 3);
cv::magnitude(gradX, gradY, magnitude);
你得到的是梯度幅值图,亮度越高通常说明局部灰度变化越强。
但直接用梯度幅值作为边缘结果,可能存在三个问题:
- 一条实际边缘在梯度图里表现为较宽的亮带。
- 土壤纹理和噪声也会产生大量梯度响应。
- 只使用一个阈值,可能导致弱叶片边缘断裂。
而 Canny 就是在梯度信息的基础上增加进一步处理。
| 对比 | Sobel | Canny |
|---|---|---|
| 主要任务 | 估计图像梯度 | 提取二值边缘 |
| 输出内容 | 梯度强度、方向分量 | 通常为 0/255 边缘图 |
| 边缘细化 | 不包含完整的细化流程 | 使用非极大值抑制 |
| 边缘连接 | 无滞后连接 | 通过强、弱边缘连接 |
| 典型用途 | 梯度分析、纹理特征 | 轮廓边界、形状提取 |
可以简单理解成:
Sobel:
这附近的灰度变化有多强?
Canny:
哪些灰度变化应该最终保留为边缘?
二、Canny 的四个核心步骤

1. 高斯平滑
已经学过:
cv::GaussianBlur(
gray,
blurred,
cv::Size(5, 5),
1.0
);
为什么 Canny 常在梯度计算前进行平滑?
因为 Sobel 会对灰度变化产生响应,而噪声本身也可能制造灰度突变。
例如:
原来:
100 100 100 100 100
受到噪声干扰:
100 100 240 100 100
中间的 240 可能产生明显梯度,但不是真实物体边缘。
高斯滤波能够减弱部分噪声,从而减少伪边缘。
需要区分算法原理和 OpenCV 接口:经典 Canny 流程包含高斯平滑,但调用 cv::Canny() 并不等于它会自动执行你指定参数的 GaussianBlur()。 因此今天我们明确将其写在 Canny 之前,方便实验。
2. 梯度计算
和昨天的 Sobel 有直接联系:

梯度不仅告诉我们变化的强弱,也告诉我们变化的方向。
梯度方向可由:

得到。
比如一条竖直边缘,灰度主要沿水平方向变化,因此梯度方向大致是水平的。
注意:梯度方向通常垂直于边缘的切线方向。
3. 非极大值抑制:为什么它能细化边缘?
这是今天最值得理解的新概念。
假设某个位置附近的梯度响应为:
位置: 1 2 3 4 5
梯度强度: 10 40 90 50 20
如果直接显示梯度图,你可能看到连续几个较亮的像素。
但非极大值抑制会沿梯度方向比较相邻位置,优先保留局部最大值。
在这个简化的一维例子中:
原始梯度:10 40 90 50 20
抑制后: 0 0 90 0 0

所以 Canny 的边缘通常比直接把 Sobel 梯度图二值化后得到的结果更细。
不过,并不意味着所有输出边缘都一定是一像素宽的完整曲线,实际结果仍受纹理、离散化和参数影响。
4. 双阈值与滞后连接:为什么需要两个阈值?
你今天使用:
cv::Canny(gray, edges, 50, 150);
这里:
50 → 低阈值
150 → 高阈值
经过梯度分析和非极大值抑制之后,可以将候选响应分成三类:
| 梯度响应 | 分类 | 处理方式 |
|---|---|---|
| 低于低阈值 | 非边缘 | 丢弃 |
| 位于两个阈值之间 | 弱候选边缘 | 检查是否与强边缘连通 |
| 高于高阈值 | 强边缘 | 保留 |
阈值相等处的具体处理边界这里暂不展开,重点理解三类候选与连通关系。
假设一条叶片边缘上的梯度响应是:
180 --- 120 --- 90 --- 65 --- 160
其中:
180、160 → 强边缘
120、90、65 → 弱候选边缘
如果这些像素沿着边缘连通,中间的弱候选可能被保留下来。
而远处单独出现的弱候选,即使数值也是 120,也可能被丢弃,因为它没有与强边缘连接。

这里与上一天的 threshold() 有一个本质区别:
Canny 的高低阈值判断的是梯度响应,不是原始灰度值。
比如原图中某个区域灰度为 200,但周围灰度也是 200,那么这个位置没有明显的局部变化,仍然可能不是边缘。
反过来,灰度为 30 和 100 的区域之间,也可能产生明显边缘。
三、正式学习 cv::Canny() 的参数
完整一些的调用方式:
cv::Canny(
blurred,
edges,
50,
150,
3,
false
);
| 参数 | 作用 | 这里的设置 |
|---|---|---|
blurred |
输入单通道 8 位图像 | 高斯平滑结果 |
edges |
输出二值边缘图 | 由函数写入 |
50 |
低阈值 | 50 |
150 |
高阈值 | 150 |
3 |
Sobel 梯度核大小 apertureSize |
3 |
false |
是否使用 L2 梯度范数 | 默认 false |
这里有一个值得连接昨天 Sobel 知识的细节。
昨天你使用的真实梯度幅值是:

但是 cv::Canny() 默认 L2gradient=false,使用的梯度强度近似是:

因此,即使是同一张图,Canny 阈值 50/150 也不能不加区分地直接套用到昨天的 L2 梯度幅值图上。
今天先使用默认设置就可以。以后需要更精细对比,再尝试 L2gradient=true。
四、完整 C++ 实验代码
建议你使用同一张具有明显苗行结构的农田图像,并且固定输入尺寸、灰度转换方式,只改变 Canny 的阈值。
下面的代码还会对比直接执行 Canny 和 先进行 GaussianBlur 再执行 Canny 的差别。
#include <opencv2/opencv.hpp>
#include <iostream>
#include <filesystem>
#include <string>
#include <vector>
namespace fs = std::filesystem;
int main() {
fs::create_directories("output");
cv::Mat image = cv::imread("images/field.jpg");
if (image.empty()) {
std::cerr << "Failed to load image\n";
return 1;
}
// 1. 转换成灰度图
cv::Mat gray;
cv::cvtColor(image, gray, cv::COLOR_BGR2GRAY);
// 2. 高斯平滑
cv::Mat blurred;
cv::GaussianBlur(
gray,
blurred,
cv::Size(5, 5),
1.0
);
// 3. 比较不同阈值组合
std::vector<std::pair<int, int>> thresholds = {
{30, 90},
{50, 150},
{100, 200}
};
for (const auto& [low, high] : thresholds) {
cv::Mat edges;
cv::Canny(
blurred,
edges,
low,
high
);
std::string filename =
"output/canny_" +
std::to_string(low) + "_" +
std::to_string(high) + ".png";
cv::imwrite(filename, edges);
// 统计边缘像素数量
int edgeCount = cv::countNonZero(edges);
double edgeRatio =
100.0 * edgeCount / image.total();
std::cout
<< "Thresholds: "
<< low << ", " << high
<< " | Edge pixels: " << edgeCount
<< " | Ratio: " << edgeRatio << "%\n";
}
// 4. 比较是否进行显式高斯平滑
cv::Mat edgesNoBlur, edgesBlur;
cv::Canny(gray, edgesNoBlur, 50, 150);
cv::Canny(blurred, edgesBlur, 50, 150);
cv::imwrite(
"output/canny_no_blur.png",
edgesNoBlur
);
cv::imwrite(
"output/canny_with_blur.png",
edgesBlur
);
std::cout << "No blur edge count: "
<< cv::countNonZero(edgesNoBlur) << '\n';
std::cout << "With blur edge count: "
<< cv::countNonZero(edgesBlur) << '\n';
return 0;
}
最终会生成五张边缘图:
output/
├── canny_30_90.png
├── canny_50_150.png
├── canny_100_200.png
├── canny_no_blur.png
└── canny_with_blur.png
代码中:
std::vector<std::pair<int, int>>
表示存储多组整数对,刚好可以用来管理低阈值和高阈值。
而:
for (const auto& [low, high] : thresholds)
是 C++17 的结构化绑定写法,能够直接取出每组阈值的两个数。你的 CMake 项目需要启用 C++17。
五、三个阈值方案应该怎样比较?
| 方案 | 低阈值 | 高阈值 | 主要观察 |
|---|---|---|---|
| A | 30 | 90 | 是否有大量土壤纹理、杂散边缘 |
| B | 50 | 150 | 噪声与叶片边缘能否取得平衡 |
| C | 100 | 200 | 小叶片、弱边缘是否开始丢失 |
通常降低阈值容易产生更多边缘响应,提高阈值则会抑制一部分较弱的响应。
但边缘数量多并不意味着检测效果好。
对于你的无人机农田图像,我建议重点观察三个区域:单株幼苗的叶片边界、植株之间的土壤,以及沿苗行方向连续出现的边缘结构。
另外,比较高斯滤波前后效果时,注意两种方法使用同一个 Canny 阈值组合。这样才能看出显式平滑对噪声和细小叶片边缘的影响。
六、今天再连接一下 findContours()
你之前已经学习过:
cv::findContours(
cleanMask,
contours,
cv::RETR_EXTERNAL,
cv::CHAIN_APPROX_SIMPLE
);
那今天能不能把 Canny 的 edges 直接传给 findContours()?
技术上可以,但要注意结果的含义。
之前:
HSV → inRange → mask → findContours
你的 mask 白色区域代表候选绿色植株,所以提取的是这些区域的边界。
今天:
GRAY → Canny → edges → findContours
白色像素代表灰度变化明显的边缘。
因此,一株植物可能产生很多轮廓:叶缘、叶脉、阴影分界、土壤纹理等。它们不一定对应独立的植株。
所以后续如果你想进行植株计数,不能简单把 Canny 轮廓数量当成植株数量。