Lesson 68: Sobel 边缘检测
练习任务
难度:中
对固定的 8×8 灰度 PGM 图像实现 Sobel 边缘检测。图像包含垂直边、水平边、对角边和均匀区域四种边缘类型。需要完成 5 个部分:
print_matrix()— 打印整数矩阵(带标题和格式对齐,每个元素%3d)convolve()— 对图像像素执行 3×3 卷积(核滑动加权求和)gradient_magnitude()— 用 Sobel 算子计算梯度幅值 G = √(Gx² + Gy²)threshold()— 阈值二值化(≥128 → 255, <128 → 0)main()— 主流程:原始图像 → 梯度幅值 → 边缘二值图
Sobel 核 GX、GY 和图像数据 IMAGE 已完整提供,保持不变。
本课只有 1 组测试用例——程序输出必须与预期输出逐字符一致(空格、换行、空行均不可错):
输出必须是格式化的三幅矩阵:原始图像、梯度幅值→ 边缘二值图
每个元素 %3d 右对齐,行末换行,矩阵后空一行提示:Sobel 边缘检测的核心是卷积——核在图像上逐像素滑动,在每个位置计算核权重与图像局部区域的加权和。理解"为什么 Gx 中间列=0 而 Gy 中间行=0"是看懂 Sobel 算子的关键。
核心知识点
- 图像表示 — 二维矩阵 I(x,y),每个元素是灰度值(0-255),PGM 文本格式
- 卷积(Convolution) — 3×3 核在图像上滑动,逐位置加权求和,边界像素无完整邻域
- Sobel 算子 — Gx 检测垂直边(右侧正/左侧负,中间列 0),Gy 检测水平边(下方正/上方负,中间行 0),中心行/列权重 2 提供噪声平滑
- 梯度幅值 — G = √(Gx² + Gy²),边缘"强度"标量值,不区分方向
- 阈值分割(Thresholding) — 将梯度幅值转为二值图(255/0),分离边缘与非边缘
- 因子对比 — Sobel(中心加权) vs Prewitt(无加权) vs Scharr(更强加权) vs Canny(多步最优)
- 边界处理 — 图像边缘像素无完整 3×3 邻域,梯度幅值设为 0
- 常见陷阱 — kernel 索引
[i+1][j+1]映射、sqrt 类型转换、const 限定符冲突、-lm链接
代码框架
#include <math.h>
#include <stdio.h>
#define ROWS 8
#define COLS 8
#define THRESHOLD 128
/*
* 图像数据 — 8×8 灰度 PGM
*
* 四种边缘类型:
* - 垂直边: 行 0-2, 列 1→2 (暗50→亮200)
* - 垂直边: 行 0-2, 列 4→5 (亮200→暗50)
* - 水平边: 行 2→3 (混合→亮200)
* - 对角边: 行 5 列 0 (暗50 与周围亮200)
* - 均匀区: 行 6-7 (全暗50, 梯度为0)
*/
static const int IMAGE[ROWS][COLS] = {
{50, 50, 200, 200, 50, 50, 50, 50}, {50, 50, 200, 200, 50, 50, 50, 50},
{50, 50, 200, 200, 50, 50, 50, 50}, {200, 200, 200, 200, 200, 200, 200, 200},
{200, 200, 200, 200, 200, 200, 200, 200}, {50, 200, 200, 200, 200, 200, 200, 200},
{50, 50, 50, 50, 50, 50, 50, 50}, {50, 50, 50, 50, 50, 50, 50, 50},
};
/* Sobel 卷积核 — 已提供,保持不变
* GX: 检测垂直边缘 (水平方向梯度)
* GY: 检测水平边缘 (垂直方向梯度) */
static const int GX[3][3] = {
{-1, 0, 1},
{-2, 0, 2},
{-1, 0, 1},
};
static const int GY[3][3] = {
{-1, -2, -1},
{0, 0, 0},
{1, 2, 1},
};
/* TODO 1: 打印矩阵 */
static void print_matrix(const char *title, int mat[ROWS][COLS]) {
// ① 打印标题行 (含 \n)
// ② 双层循环: i=0..ROWS-1, j=0..COLS-1
// j>0 时先 printf(" ") 打印分隔空格
// printf("%3d", mat[i][j])
// 每行结束 printf("\n")
// ③ 矩阵后额外 printf("\n") 输出空行
}
/* TODO 2: 3×3 卷积 */
static int convolve(int image[ROWS][COLS], const int kernel[3][3], int row, int col) {
// ① sum = 0
// ② for i = -1..1, for j = -1..1:
// sum += image[row+i][col+j] * kernel[i+1][j+1]
// ③ return sum
// 注意: kernel 索引是 [i+1][j+1] 而非 [i][j]
}
/* TODO 3: 梯度幅值 */
static int gradient_magnitude(int image[ROWS][COLS], int row, int col) {
// ① gx = convolve(image, GX, row, col)
// ② gy = convolve(image, GY, row, col)
// ③ return (int)(sqrt((double)(gx*gx + gy*gy)) + 0.5)
// 注意: (double) 强转避免整数溢出, +0.5 四舍五入
}
/* TODO 4: 阈值二值化 */
static int threshold(int value) {
// 一行: return (value >= THRESHOLD) ? 255 : 0;
// 注意: 用 >= 不是 >
}
int main(void) {
int grad_mag[ROWS][COLS];
int edge_binary[ROWS][COLS];
// TODO 5: Step 1 — 打印原始图像
// print_matrix("Original Image:", (int(*)[COLS])IMAGE);
// 注意: IMAGE 是 const, 需要强制转换去掉 const 限定符
// Step 2 — 计算梯度幅值
// for i=0..7, for j=0..7:
// if (i==0 || i==ROWS-1 || j==0 || j==COLS-1)
// grad_mag[i][j] = 0; // 边界像素, 无完整3×3邻域
// else
// grad_mag[i][j] = gradient_magnitude((int(*)[COLS])IMAGE, i, j);
// Step 3 — 阈值二值化
// for i=0..7, for j=0..7:
// edge_binary[i][j] = threshold(grad_mag[i][j]);
// Step 4 — 打印梯度幅值矩阵
// print_matrix("Gradient Magnitude:", grad_mag);
// Step 5 — 打印边缘二值图
// print_matrix("Edge Map (threshold=128):", edge_binary);
// return 0;
}阅读骨架后,尝试自己填充 // ① 到 // ⑤ 标记的部分。核心挑战在于:convolve 中 kernel 索引为什么是 [i+1][j+1] 而不是 [i][j]?边界像素如何处理?sqrt 的返回值如何正确四舍五入?print_matrix 的空格和空行为什么不能多也不能少?
TIP
先用纸笔手工计算像素 (1,2) 和 (2,3) 处的 Gx、Gy。对照 README 中的手工计算示例逐行验证——等你能在纸上算出 600 和 636,代码就自然写出来了。
深度讲解
1. 图像卷积——核在图像上滑动的加权求和
1.1 什么是卷积?
卷积(Convolution)用一个小的矩阵("核"或"滤波器")在图像上滑动,在每个位置计算核与图像局部区域的加权和。
滑动窗口: 3×3 核以 I₂₂ 为中心,对 3×3 邻域加权求和
sum = Σ(i=-1..1) Σ(j=-1..1) image[row+i][col+j] × kernel[i+1][j+1]对于 8×8 图像,只有内部 6×6 个像素有完整的 3×3 邻域——边界像素的邻域会越界,本题设为 0。
1.2 为什么卷积能检测边缘?
边缘是图像中亮度急剧变化的地方。核权重一边为正、一边为负,跨越边缘时正负产生大加权和;均匀区正负相消 ≈ 0。
卷积示意: 核 [-1,0,1] × 均匀区[50,50,50] = 0, × 垂直边[50,50,200] = 1502. Sobel 算子——两个方向的梯度探测器
2.1 核定义与直观理解
Sobel 算子由两个 3×3 核组成:
Gx (检测垂直边缘 — 水平方向梯度):
┌ ┐
│ -1 0 1 │ ← 右侧 +1, 左侧 -1, 中间 0
│ -2 0 2 │ ← 右侧 +2, 左侧 -2, 中间 0 (加倍!)
│ -1 0 1 │ ← 右侧 +1, 左侧 -1, 中间 0
└ ┘
Gy (检测水平边缘 — 垂直方向梯度):
┌ ┐
│ -1 -2 -1 │ ← 上方负
│ 0 0 0 │ ← 中间 0
│ 1 2 1 │ ← 下方正 (加倍!)
└ ┘Gx 工作原理:右侧列为正(+1, +2, +1),左侧列为负(-1, -2, -1),中间列为 0。当核跨过垂直边缘(左侧暗 → 右侧亮),正权重乘以亮值 + 负权重乘以暗值 → 很大的正数。上下三行求和结果相同,变化在水平方向。
Gy 工作原理:下方行为正(+1, +2, +1),上方行为负(-1, -2, -1),中间行为 0。当核跨过水平边缘(上方暗 → 下方亮),正权重乘以亮值 + 负权重乘以暗值 → 很大的正数。
2.2 中间行列权重 = 2 的原因
Sobel 与 Prewitt(所有权重为 ±1)的关键区别在于中心行/列加倍:
Sobel (中心加权): Prewitt (无加权):
Gx: [-1,0,1] Gx: [-1,0,1]
[-2,0,2] [-1,0,1]
[-1,0,1] [-1,0,1]
为什么加倍?
1. 强调中心——离中心像素越近,对梯度贡献应该越大
2. 噪声抑制——相对于简单的 [-1, 0, 1] 一维差分,
Sobel 提供了更好的高频噪声平滑效果
3. 数学上可分解为高斯平滑 + 差分,控制两个方向的噪声NOTE
Sobel 本质上同时做了两件事:用 [1, 2, 1] 在垂直方向做加权平均(平滑),用 [-1, 0, 1] 在水平方向做差分(找梯度)。这种"先平滑再求导"的设计是 Sobel 比单纯的 [-1, 0, 1] 更抗噪的原因。
2.3 梯度幅值的几何意义
梯度是二维向量 (Gx, Gy):
Gy ↑
│ • (Gx, Gy)
│ /
│ / G = √(Gx² + Gy²)
│/ θ
────────┼────────→ Gx
│
G 越大 = 边缘越强
θ = atan2(Gy, Gx) = 边缘法线方向 (本题不计算)/* 梯度幅值公式 */
int gx = convolve(image, GX, row, col);
int gy = convolve(image, GY, row, col);
int magnitude = (int)(sqrt((double)(gx*gx + gy*gy)) + 0.5);
/*
* 注意两个细节:
* 1. (double) 强转 — gx*gx 可能溢出 int 范围 (如 600²=360000, 安全但规范)
* 2. +0.5 — 实现四舍五入而非截断:
* sqrt(405000) = 636.396... → +0.5 = 636.896... → (int) 636 ✓
* sqrt(360000) = 600.0 → +0.5 = 600.5 → (int) 600 ✓
*/3. 8×8 图像详解——四种边缘类型
3.1 图像数据
Col 0 Col 1 Col 2 Col 3 Col 4 Col 5 Col 6 Col 7
Row 0: 50 50 200 200 50 50 50 50
Row 1: 50 50 200 200 50 50 50 50
Row 2: 50 50 200 200 50 50 50 50
Row 3: 200 200 200 200 200 200 200 200
Row 4: 200 200 200 200 200 200 200 200
Row 5: 50 200 200 200 200 200 200 200
Row 6: 50 50 50 50 50 50 50 50
Row 7: 50 50 50 50 50 50 50 503.2 四种边缘类型定位
| 边缘类型 | 位置 | 描述 | Gx/Gy 行为 |
|---|---|---|---|
| 垂直边 | 行 0-2, 列 1→2 | 暗(50) → 亮(200) | Gx 有强正响应,Gy ≈ 0 |
| 垂直边 | 行 0-2, 列 4→5 | 亮(200) → 暗(50) | Gx 有强负响应(幅值取正) |
| 水平边 | 行 2→3 | 混合 → 全亮(200) | Gy 有强响应,Gx ≈ 0 |
| 水平边 | 行 5→6 | 亮(200) → 暗(50) | Gy 有强响应 |
| 对角边 | 行 5 列 0 | 暗像素(50) 与周围亮像素(200) | Gx 和 Gy 均有响应 |
| 均匀区 | 行 6-7 | 全为暗(50) | Gx = Gy = 0,幅值 = 0 |
3.3 手工计算验证
像素 (1,2) — 垂直边缘处:
邻域: 与 GX 逐元素相乘:
[50][200][200] (-1)×50 + 0×200 + 1×200 = 150
[50][200][200] (-2)×50 + 0×200 + 2×200 = 300
[50][200][200] (-1)×50 + 0×200 + 1×200 = 150
Gx = 600
三行完全相同 → Gy = 0 (无水平方向变化)
G = √(600² + 0²) = 600 ← 强垂直边缘像素 (2,3) — 水平和垂直边缘交汇处:
邻域: 与 GX 逐元素: 与 GY 逐元素:
[ 50][200][200] (-1)×50+0×200+1×200=150 (-1)×50+(-2)×200+(-1)×200=-650
[ 50][200][200] (-2)×50+0×200+2×200=300 0×50+0×200+0×200=0
[200][200][200] (-1)×200+0×200+1×200=0 1×200+2×200+1×200=650
Gx = 450 Gy = 450 (注意: -650+0+650=450 不算错)
G = √(450² + 450²) = √405000 = 636.396... → 四舍五入 636IMPORTANT
Gx=450, Gy=450——两者都有响应——这说明像素 (2,3) 同时处于垂直和水平边缘的交汇处。这种"Gx 和 Gy 都有值"的像素比"只有 Gx"或"只有 Gy"的像素梯度幅值更大——因为边缘能量来自两个方向。
像素 (6,3) — 均匀区域:
邻域 (全为 50): 所有正负权重相消 → Gx=0, Gy=0, G=04. 算法流程——从原始图像到边缘二值图
输入: 8×8 灰度图像 IMAGE
输出: 原始图像、梯度幅值矩阵、边缘二值图
═══════════════════════════════════════════
Step 1: 打印原始图像
print_matrix("Original Image:", IMAGE)
Step 2: 对每个像素计算梯度幅值
for i = 0..7, j = 0..7:
if i==0 || i==7 || j==0 || j==7:
grad_mag[i][j] = 0 ← 边界像素, 无完整 3×3 邻域
else:
gx = convolve(IMAGE, GX, i, j)
gy = convolve(IMAGE, GY, i, j)
grad_mag[i][j] = (int)(sqrt(gx² + gy²) + 0.5)
Step 3: 阈值二值化
for i = 0..7, j = 0..7:
edge_binary[i][j] = (grad_mag[i][j] >= 128) ? 255 : 0
Step 4: 打印梯度幅值矩阵
print_matrix("Gradient Magnitude:", grad_mag)
Step 5: 打印边缘二值图
print_matrix("Edge Map (threshold=128):", edge_binary)TIP
边界像素的判断条件是 i==0 || i==ROWS-1 || j==0 || j==COLS-1,不是 i==0 || j==0。四边的像素都没有完整邻域——别忘了 ROWS-1 和 COLS-1。
预期输出见 exercises.toml 中的 stdout 字段。解读:边界全 0;非零值集中区域:垂直边(列 1-4)、水平边(行 2-4)、对角边(行 5-6);边缘二值图用 255 标记所有梯度≥128的像素。
5. 阈值分割——二值化与阈值选择
5.1 二值化规则
/* 阈值二值化: 一行代码 */
static int threshold(int value) {
return (value >= THRESHOLD) ? 255 : 0;
// ^^ 注意用 >= 不是 >
}
/* 阈值 T=128 的效果:
* G < 128 → 0 (黑色, 非边缘)
* G ≥ 128 → 255 (白色, 边缘)
*
* ┌─────────────────────────┐
* │ 非边 (0) │ 边缘 (255) │
* └───────────────┼───────────┘
* T=128
*/5.2 为什么阈值 128 在这里有效?
本题梯度幅值离散分布为 {0, 212, 474, 600, 636, 671}。这些值中,212 是最小的非零值,128 恰好落在 0 和 212 之间——完美分离边缘与非边缘。
CAUTION
这只是本题的特殊情况。在真实照片中,梯度值是连续分布的,固定阈值 128 几乎必然失败。真实场景用的替代方案包括 Otsu 自动阈值(最大化类间方差)、自适应阈值(不同区域用不同阈值)、以及 Canny 双阈值(高阈值定强边 + 低阈值追弱边)。
NOTE
如果图像整体变亮(×2),梯度幅值翻倍,固定阈值会漏掉大量边缘——这是缺乏光照不变性的问题。自适应方案:T = mean(grad) × factor 或直方图均衡化(Lesson 69 主题)。
6. 边缘检测算子对比
| 算子 | 核大小 | 特点 | Gx 核示例 |
|---|---|---|---|
| Roberts | 2×2 | 最简单,对噪声极敏感 | [+1,0; 0,-1] |
| Prewitt | 3×3 | 与 Sobel 类似但无中心加权 | [-1,0,1; -1,0,1; -1,0,1] |
| Sobel | 3×3 | 中心加权,平滑+差分,最常用 | [-1,0,1; -2,0,2; -1,0,1] |
| Scharr | 3×3 | 更强中心加权,旋转对称性更好 | [-3,0,3; -10,0,10; -3,0,3] |
| Laplacian | 3×3 | 二阶导数,检测边缘不论方向 | [0,1,0; 1,-4,1; 0,1,0] |
| Canny | 多步 | 最优检测器(S/N最高),但需多步 | 先 Sobel + 非极大抑抑制 + 双阈值 |
Sobel 优势: 简单高效(2个3x3卷积) 中心加权(噪声平滑) 同时给出幅值和方向(Canny第一步)。NOTE
Sobel 是一阶导数(斜),Laplacian 是二阶导数(曲率)。一阶导产生峰→阈值化→边缘检测;二阶导产生零交叉→定位更精确但对噪声极敏感。
参考解答
练习1: print_matrix — 格式化打印矩阵
#include <stdio.h>
#define ROWS 8
#define COLS 8
static void print_matrix(const char *title, int mat[ROWS][COLS]) {
printf("%s\n", title);
for (int i = 0; i < ROWS; i++) {
for (int j = 0; j < COLS; j++) {
if (j > 0) printf(" ");
printf("%3d", mat[i][j]);
}
printf("\n");
}
printf("\n"); // 矩阵后空行
}要点:每个元素 %3d 右对齐,j>0 时先打印空格分隔,行末换行,矩阵后额外空行。格式必须与预期输出完全一致——多一个空格或少一个换行都可能导致测试不通过。
练习2: convolve — 3×3 卷积
#define ROWS 8
#define COLS 8
static int convolve(int image[ROWS][COLS], const int kernel[3][3], int row, int col) {
int sum = 0;
for (int i = -1; i <= 1; i++)
for (int j = -1; j <= 1; j++)
sum += image[row + i][col + j] * kernel[i + 1][j + 1];
return sum;
}要点:i 从 -1 到 1(不含边界,由调用者保证)。关键点:kernel 的索是 [i+1][j+1] 而非 [i][j]——将 (-1, 0, 1) 映射到 (0, 1, 2)。如果直接写成 kernel[i][j],当 i=-1 时会数组越界。
练习3: gradient_magnitude — 梯度幅值
#include <math.h>
#define ROWS 8
#define COLS 8
static const int GX[3][3] = {{-1, 0, 1}, {-2, 0, 2}, {-1, 0, 1}};
static const int GY[3][3] = {{-1, -2, -1}, {0, 0, 0}, {1, 2, 1}};
static int gradient_magnitude(int image[ROWS][COLS], int row, int col) {
int gx = convolve(image, GX, row, col);
int gy = convolve(image, GY, row, col);
return (int)(sqrt((double)(gx * gx + gy * gy)) + 0.5);
}要点:三次关键细节——(1) (double) 强转确保 sqrt 收到 double 而非 int;(2) + 0.5 实现四舍五入((int)(636.9) = 636 而 (int)(636.9+0.5) = (int)(637.4) = 637);(3) 必须 #include <math.h>,编译时必须 -lm。
练习4: threshold — 阈值二值化
#define THRESHOLD 128
static int threshold(int value) {
return (value >= THRESHOLD) ? 255 : 0;
}要点:一行三元运算符。注意用 >=(不 >)——阈值 128 时 >= 和 > 对数据集结果相同(没有恰好为 128 的值),但 >= 是惯例写法,语义上"达到门槛即为边缘"。
练习5: main — 完整主流程
#include <math.h>
#include <stdio.h>
#define ROWS 8
#define COLS 8
#define THRESHOLD 128
static const int IMAGE[ROWS][COLS] = {
{50, 50, 200, 200, 50, 50, 50, 50}, {50, 50, 200, 200, 50, 50, 50, 50},
{50, 50, 200, 200, 50, 50, 50, 50}, {200, 200, 200, 200, 200, 200, 200, 200},
{200, 200, 200, 200, 200, 200, 200, 200}, {50, 200, 200, 200, 200, 200, 200, 200},
{50, 50, 50, 50, 50, 50, 50, 50}, {50, 50, 50, 50, 50, 50, 50, 50},
};
static const int GX[3][3] = {{-1, 0, 1}, {-2, 0, 2}, {-1, 0, 1}};
static const int GY[3][3] = {{-1, -2, -1}, {0, 0, 0}, {1, 2, 1}};
static void print_matrix(const char *title, int mat[ROWS][COLS]) {
printf("%s\n", title);
for (int i = 0; i < ROWS; i++) {
for (int j = 0; j < COLS; j++) {
if (j > 0) printf(" ");
printf("%3d", mat[i][j]);
}
printf("\n");
}
printf("\n");
}
static int convolve(int image[ROWS][COLS], const int kernel[3][3], int row, int col) {
int sum = 0;
for (int i = -1; i <= 1; i++)
for (int j = -1; j <= 1; j++)
sum += image[row + i][col + j] * kernel[i + 1][j + 1];
return sum;
}
static int gradient_magnitude(int image[ROWS][COLS], int row, int col) {
int gx = convolve(image, GX, row, col);
int gy = convolve(image, GY, row, col);
return (int)(sqrt((double)(gx * gx + gy * gy)) + 0.5);
}
static int threshold(int value) {
return (value >= THRESHOLD) ? 255 : 0;
}
int main(void) {
int grad_mag[ROWS][COLS];
int edge_binary[ROWS][COLS];
/* Step 1: 打印原始图像 — IMAGE 是 const, 需强制转换 */
print_matrix("Original Image:", (int (*)[COLS])IMAGE);
/* Step 2: 计算梯度幅值 — 边界像素设为 0 */
for (int i = 0; i < ROWS; i++) {
for (int j = 0; j < COLS; j++) {
if (i == 0 || i == ROWS - 1 || j == 0 || j == COLS - 1)
grad_mag[i][j] = 0;
else
grad_mag[i][j] = gradient_magnitude((int (*)[COLS])IMAGE, i, j);
}
}
/* Step 3: 阈值二值化 */
for (int i = 0; i < ROWS; i++)
for (int j = 0; j < COLS; j++)
edge_binary[i][j] = threshold(grad_mag[i][j]);
/* Step 4 & 5: 打印结果 */
print_matrix("Gradient Magnitude:", grad_mag);
print_matrix("Edge Map (threshold=128):", edge_binary);
return 0;
}核心逻辑解析:
(int (*)[COLS])IMAGE强制转换——IMAGE 是const int[ROWS][COLS],而函数参数是int[ROWS][COLS](可修改)。C 的类系统不会自动丢弃 const,需要显式转换。这个转换安全因为函数内部只读不写。- 边界像素处理——条件
i==0 || i==ROWS-1 || j==0 || j==COLS-1覆盖四条边的所有像素。这些像素没有完整 3×3 邻域,梯度幅值直接设为 0。 - 三步输出顺序固定——先原始图像,再梯度幅值,最后边缘二值图。顺序、标题、格式一个都不能错——测试会用
diff逐字符比较。
对照检查:
convolve中 kernel 索引是[i+1][j+1]吗?gradient_magnitude中sqrt参数转 double 了吗?+0.5四舍五入了吗?threshold用的是>=吗?边界条件包含了四条边全部像素吗?matrix 后有空行吗?编译时加了-lm吗?
课堂讨论
- 如果图像中有一条水平边缘(上半暗、下半亮),Gx 和 Gy 各自会有什么样的响应?在本题的 8×8 图像中,你能找到这条水平边缘吗?
- Sobel 核中间行/列的权重是 2。如果改成 1(变成 Prewitt 算子),结果会有什么变化?
- 阈值 128 在本题中有效。如果图像是真实照片(有噪声,梯度值连续分布),固定阈值 128 还能用吗?为什么?
- 本题中边界像素的梯度被设为 0。在实际图像处理库(如 OpenCV)中,边界通常如何处理?有哪些常见填充方式?
- 梯度方向 θ = atan2(Gy, Gx) 能告诉我们什么?在 Canny 边缘检测中它有什么关键作用?
- 将图像所有像素值 ×2(变亮),梯度幅值会怎么变?阈值 128 还能正确检测吗?如何自适应?
- 为什么 Sobel 是"一阶导数"近似而 Laplacian 是"二阶导数"?一阶和二阶在边缘检测中有什么区别?
- 像素 (2,3) 的 G=636 比 (1,2) 的 G=600 更大——。为什么?这反映了什么几何特征?
讨论答案
Q1: 水平边缘(上下差异),Gx/Gy 行为
水平边缘(上半暗、下半亮)——纯垂直方向变化:
邻域 (跨水平边缘):
[ 50][ 50][ 50] ← 上方行 (暗, ~50)
[200][200][200] ← 下方行 (亮, ~200)
[200][200][200]
Gx: 每列内部上下值不同,但核的 Gx 是"左右方向"的差分——
对每行,左右两侧相同,左右差分相消 → Gx ≈ 0
Gy: 下方行正 × 亮 = 大正数,上方行负 × 暗 = 大负数(或相反)
→ |Gy| 很大本题中行 2→3 的过渡就是典型的水平边缘:行 2 有混合亮度(50/200),行 3 全亮(200)。Gy 在此有最强的响应。
Q2: 中心加权 2 → 1(Prewitt)的变化
Sobel Gx: [-1,0,1; -2,0,2; -1,0,1] → 中间行权重×2
Prewitt: [-1,0,1; -1,0,1; -1,0,1] → 所有行权相同
差异:
1. 噪声敏感度: Sobel 的 [1,2,1] 列加权 = 带高斯平滑, 抑制高频噪声
Prewitt 无此平滑效果, 对噪声更敏感
2. 梯度幅值: 对于同样的边缘, Sobel 的 Gx 更大 (中间行贡献加倍)
3. 旋转特性: Prewitt 对 45° 边缘的响应比 Sobel 略均匀,
但这种差异在大多数应用中并不显著在本题的 8×8 图像上:Prewitt 的 Gx 值约是 Sobel 的 5/8(中间行加倍变不加倍),Gy 同理。梯度幅值普遍偏小,但边缘位置不变。
Q3: 固定阈值 vs 真实照片
固定阈值 128 在真实照片中几乎必然失败:
真实照片的问题:
- 光照不均: 同一场景不同区域亮度差异可达 5-10 倍
- 噪声: 低光照区域梯度可能达到 50-100, 被误检为边缘 (假阳性)
- 弱边缘: 对比度低的边缘梯度可能只有 30-80, 被漏检 (假阴性)
替代方案:
- Otsu 方法: 自动计算使类间方差最大的阈值
- 自适应阈值: 每个局部区域计算自己的阈值
- Canny 双阈值: T_high 定强边, T_low 追踪弱边 (T_high/T_low 常用 3:1 或 2:1)
- 百分比法: 取梯度直方图的前 p% 作为阈值 (如前 10%)Q4: 边界填充方式对比
| 填充方式 | 效果 | 适用场景 |
|---|---|---|
| Zero-padding | 边界外填 0 | 简单, 但在边界处产生伪边缘 |
| Replicate | 复制最近的边界像素 | 保持边界值连续, 常用 |
| Reflect | 沿边界镜像 | 保持梯度连续, OpenCV 默认 |
| Wrap | 循环填充(对面边) | 周期信号适用 |
Replicate/Reflect 优于 Zero-padding:均匀区边界填充应与图像值一致。Q5: 梯度方向的作用
梯度方向 θ = atan2(Gy, Gx) 给出亮度变化最快的方向(即边缘的法线方向)。
在 Canny 边缘检中的作用——非极大值抑制 (NMS):
对每个像素, 沿梯度方向检查:
┌──────────────┐
│ θ = 0° → 检查左右邻居 (水平边缘, 法线垂直)
│ θ = 45° → 检查左上-右下邻居 (对角边缘)
│ θ = 90° → 检查上下邻居 (垂直边缘, 法线水平)
│ θ = 135°→ 检查左下-右上邻居
└──────────────┘
如果当前像素的梯度幅值不是沿梯度方向的局部最大值:
→ 抑制 (设为 0) // 细化边缘, 去除"粗边"
这种做法将粗边缘细化为单像素宽度, 是 Canny 的精华所在。Q6: 亮度变化与自适应阈值
像素值 ×2 的效果:
原始: 50 和 200, 梯度 ≈ 600
×2: 100 和 400, 梯度 ≈ 1200
阈值 128 仍可检测边缘 (1200 > 128), 但...
如果所有像素偏移(如 +100): 150 和 300, 梯度不变!
如果对比度降低 (如 180 和 200): 梯度可能只有 60~80 < 128 → 漏检!
简单的自适应方案:
T = mean_gradient * k (如 k = 2.5)
T = median_gradient + 2 * std_dev
真正的解决方案需要光照归一化:
- 直方图均衡化 (Lesson 69 的主题!)
- Gamma 校正
- Retinex 理论Q7: 一阶导数 vs 二阶导数
一阶导数 (Sobel): 计算斜, 在边缘处产生"峰" → 阈值化后得边缘像素
二阶导数 (Laplacian): 计算曲率, 在边缘处产生"零交叉" → 更精确但极敏感噪声
区别: 一阶导宽响应需阈值(简单但边缘粗), 二阶导窄响应零交叉定位(准但怕噪声)。Q8: 像素 (2,3) 比 (1,2) 更大的原因
(1,2): Gx=600, Gy=0 → G=600 (仅垂直边) (2,3): Gx=450, Gy=450 → G=636 (水平和垂直边缘交汇) = √(450²+450²)=636 > √(600²+0²)=600 — 两个方向的梯度合力大于单一方向。 图像中角点的梯度幅值 > 直线边。
课后练习
实现 Prewitt 算子版本。将 Sobel 核的中间行/列权重从 2 改为 1,比较 Sobel 和 Prewitt 对同一图像的检测结果差异。
参考解答
c#include <math.h> #include <stdio.h> #define ROWS 8 #define COLS 8 #define THRESHOLD 128 static const int GX_PREWITT[3][3] = { {-1, 0, 1}, {-1, 0, 1}, {-1, 0, 1}}; static const int GY_PREWITT[3][3] = { {-1, -1, -1}, {0, 0, 0}, {1, 1, 1}}; /* 其余函数同 sobel 版本, 仅替换 GX/GY */观察结果:Prewitt 的梯度值整体偏小(约为 Sobel 的 5/8),边缘位置不变。对均匀区域响应相同(均为 0)。在噪声图像上,Prewitt 会检测出更多假边缘。
对比不同阈值。在 Sobel 检测后,尝试 T=64, T=128, T=256 三种阈值,在不同阈值下边缘二值图有什么变化?
参考解答
c/* 修改 THRESHOLD 宏或改为函数参数 */ void sobel_with_threshold(int image[ROWS][COLS], int threshold) { int grad_mag[ROWS][COLS], edge[ROWS][COLS]; /* ... 计算 grad_mag ... */ for (int i = 0; i < ROWS; i++) for (int j = 0; j < COLS; j++) edge[i][j] = (grad_mag[i][j] >= threshold) ? 255 : 0; } /* T=64: 几乎所有内部像素都是边缘 (212≥64, 474≥64...) * T=128: 正常结果 (212≥128, 0<128) * T=256: 大部分边缘被滤除 (212<256, 474≥256 — 只保留强边) * * 本数据集梯度 {0,212,474,600,636,671}: * T=64 → 212~671 全部标为边缘 (假阳最多) * T=128 → 212~671 标为边缘 ✓ * T=256 → 474~671 标为边缘 (弱边 212 被滤) * T=500 → 600~671 标为边缘 (只剩最强边缘) */边界填充实验。修改代码,对边界像素使用 Replicate 填充(复制最近像素)替代直接置 0,边界处的梯度幅值会发生什么变化?
参考解答
c/* Replicate 填充: 越界时用最近的有效像素 */ static int get_pixel(int image[ROWS][COLS], int r, int c) { if (r < 0) r = 0; if (r >= ROWS) r = ROWS - 1; if (c < 0) c = 0; if (c >= COLS) c = COLS - 1; return image[r][c]; } /* 修改 convolve 使用 get_pixel */ static int convolve_replicate(int image[ROWS][COLS], const int kernel[3][3], int row, int col) { int sum = 0; for (int i = -1; i <= 1; i++) for (int j = -1; j <= 1; j++) sum += get_pixel(image, row+i, col+j) * kernel[i+1][j+1]; return sum; } /* 主循环中不再对边界像素特殊处理—— * 所有像素素都调用 convolve_replicate */ /* 本数据集效果: * 边界像素 (如 (0,1)) 邻域复制第一行的 50, 梯度 ≈ 0 (均匀区) * 对于边缘处的边界像素 (如 (0,2)), 复制后仍有梯度值 * 总体效果: 边界像素不再强制为 0, 产生有限但模糊的梯度 */注意:本题的测试用例要求边界为 0,所以主程序仍应使用
grad_mag=0的方式。此练习仅供理解边界处理的多样性。手动提取 3×3 邻域并打印。选 — 像素 (1,2),用代码提取其 3×3 邻域,用
%3d格式打印,验证邻域数据与手工计算一致。参考解答
c#include <stdio.h> #define ROWS 8 #define COLS 8 static const int IMAGE[ROWS][COLS] = { /* ... 同原题 ... */ }; void print_neighborhood(int row, int col) { printf("Neighborhood at (%d,%d):\n", row, col); for (int i = row - 1; i <= row + 1; i++) { for (int j = col - 1; j <= col + 1; j++) { if (j > col - 1) printf(" "); printf("%3d", IMAGE[i][j]); } printf("\n"); } } int main(void) { print_neighborhood(1, 2); // 垂直边缘处: 左侧 50, 右侧 200 print_neighborhood(2, 3); // 交汇处: 上方混合, 下方 200 print_neighborhood(6, 3); // 均匀区: 全部 50 return 0; }/* T=64→全部标为边缘, T=128→正常 ✓, T=256→仅保留强边, T=500→仅最强边缘 */
:::
参考资料
- Sobel, I., & Feldman, G. (1968). "A 3×3 Isotropic Gradient Operator for Image Processing"
- Canny, J. (1986). "A Computational Approach to Edge Detection" — 至今最广泛使用的边缘检测器
- Gonzalez & Woods, "Digital Image Processing" — 图像处理经典教材,第 10 章
- OpenCV 文档:Sobel Derivatives
- 3Blue1Brown 视频:"But what is a convolution?" — 卷积的直观可视化
"An image is worth a thousand words — but its edges are worth even more." — 计算机视觉谚语