跳转到内容

Lesson 68: Sobel 边缘检测

练习任务

难度:中

对固定的 8×8 灰度 PGM 图像实现 Sobel 边缘检测。图像包含垂直边、水平边、对角边和均匀区域四种边缘类型。需要完成 5 个部分:

  1. print_matrix() — 打印整数矩阵(带标题和格式对齐,每个元素 %3d
  2. convolve() — 对图像像素执行 3×3 卷积(核滑动加权求和)
  3. gradient_magnitude() — 用 Sobel 算子计算梯度幅值 G = √(Gx² + Gy²)
  4. threshold() — 阈值二值化(≥128 → 255, <128 → 0)
  5. main() — 主流程:原始图像 → 梯度幅值 → 边缘二值图

Sobel 核 GXGY 和图像数据 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 链接

代码框架

68_sobel.c
c
#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] = 150

2. 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) = 边缘法线方向 (本题不计算)
gradient_formula.c
c
/* 梯度幅值公式 */
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     50

3.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² +) = 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... 四舍五入 636

IMPORTANT

Gx=450, Gy=450——两者都有响应——这说明像素 (2,3) 同时处于垂直和水平边缘的交汇处。这种"Gx 和 Gy 都有值"的像素比"只有 Gx"或"只有 Gy"的像素梯度幅值更大——因为边缘能量来自两个方向。

像素 (6,3) — 均匀区域

邻域 (全为 50): 所有正负权重相消 → Gx=0, Gy=0, G=0

4. 算法流程——从原始图像到边缘二值图

输入: 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-1COLS-1

预期输出见 exercises.toml 中的 stdout 字段。解读:边界全 0;非零值集中区域:垂直边(列 1-4)、水平边(行 2-4)、对角边(行 5-6);边缘二值图用 255 标记所有梯度≥128的像素。


5. 阈值分割——二值化与阈值选择

5.1 二值化规则

threshold_rule.c
c
/* 阈值二值化: 一行代码 */
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 核示例
Roberts2×2最简单,对噪声极敏感[+1,0; 0,-1]
Prewitt3×3与 Sobel 类似但无中心加权[-1,0,1; -1,0,1; -1,0,1]
Sobel3×3中心加权,平滑+差分,最常用[-1,0,1; -2,0,2; -1,0,1]
Scharr3×3更强中心加权,旋转对称性更好[-3,0,3; -10,0,10; -3,0,3]
Laplacian3×3二阶导数,检测边缘不论方向[0,1,0; 1,-4,1; 0,1,0]
Canny多步最优检测器(S/N最高),但需多步先 Sobel + 非极大抑抑制 + 双阈值
Sobel 优势: 简单高效(2个3x3卷积) 中心加权(噪声平滑) 同时给出幅值和方向(Canny第一步)

NOTE

Sobel 是一阶导数(斜),Laplacian 是二阶导数(曲率)。一阶导产生峰→阈值化→边缘检测;二阶导产生零交叉→定位更精确但对噪声极敏感。


参考解答

练习1: print_matrix — 格式化打印矩阵
solution_68_sobel_print_matrix.c
c
#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 卷积
solution_68_sobel_convolve.c
c
#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 — 梯度幅值
solution_68_sobel_gradient_magnitude.c
c
#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 — 阈值二值化
solution_68_sobel_threshold.c
c
#define THRESHOLD 128

static int threshold(int value) {
    return (value >= THRESHOLD) ? 255 : 0;
}

要点:一行三元运算符。注意用 >=(不 >)——阈值 128 时 >=> 对数据集结果相同(没有恰好为 128 的值),但 >= 是惯例写法,语义上"达到门槛即为边缘"。

练习5: main — 完整主流程
solution_68_sobel_main.c
c
#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;
}

核心逻辑解析:

  1. (int (*)[COLS])IMAGE 强制转换——IMAGE 是 const int[ROWS][COLS],而函数参数是 int[ROWS][COLS](可修改)。C 的类系统不会自动丢弃 const,需要显式转换。这个转换安全因为函数内部只读不写。
  2. 边界像素处理——条件 i==0 || i==ROWS-1 || j==0 || j==COLS-1 覆盖四条边的所有像素。这些像素没有完整 3×3 邻域,梯度幅值直接设为 0。
  3. 三步输出顺序固定——先原始图像,再梯度幅值,最后边缘二值图。顺序、标题、格式一个都不能错——测试会用 diff 逐字符比较。

对照检查convolve 中 kernel 索引是 [i+1][j+1] 吗?gradient_magnitudesqrt 参数转 double 了吗?+0.5 四舍五入了吗?threshold 用的是 >= 吗?边界条件包含了四条边全部像素吗?matrix 后有空行吗?编译时加了 -lm 吗?


课堂讨论

  1. 如果图像中有一条水平边缘(上半暗、下半亮),Gx 和 Gy 各自会有什么样的响应?在本题的 8×8 图像中,你能找到这条水平边缘吗?
  2. Sobel 核中间行/列的权重是 2。如果改成 1(变成 Prewitt 算子),结果会有什么变化?
  3. 阈值 128 在本题中有效。如果图像是真实照片(有噪声,梯度值连续分布),固定阈值 128 还能用吗?为什么?
  4. 本题中边界像素的梯度被设为 0。在实际图像处理库(如 OpenCV)中,边界通常如何处理?有哪些常见填充方式?
  5. 梯度方向 θ = atan2(Gy, Gx) 能告诉我们什么?在 Canny 边缘检测中它有什么关键作用?
  6. 将图像所有像素值 ×2(变亮),梯度幅值会怎么变?阈值 128 还能正确检测吗?如何自适应?
  7. 为什么 Sobel 是"一阶导数"近似而 Laplacian 是"二阶导数"?一阶和二阶在边缘检测中有什么区别?
  8. 像素 (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):

  对每个像素, 沿梯度方向检查:
    ┌──────────────┐
  θ =  检查左右邻居     (水平边缘, 法线垂直)
  θ = 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 — 两个方向的梯度合力大于单一方向。 图像中角点的梯度幅值 > 直线边。


课后练习

  1. 实现 Prewitt 算子版本。将 Sobel 核的中间行/列权重从 2 改为 1,比较 Sobel 和 Prewitt 对同一图像的检测结果差异。

    参考解答
    ex1_prewitt.c
    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 会检测出更多假边缘。

  2. 对比不同阈值。在 Sobel 检测后,尝试 T=64, T=128, T=256 三种阈值,在不同阈值下边缘二值图有什么变化?

    参考解答
    ex2_variable_threshold.c
    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 标为边缘 (只剩最强边缘)
     */
  3. 边界填充实验。修改代码,对边界像素使用 Replicate 填充(复制最近像素)替代直接置 0,边界处的梯度幅值会发生什么变化?

    参考解答
    ex3_boundary_replicate.c
    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 的方式。此练习仅供理解边界处理的多样性。

  4. 手动提取 3×3 邻域并打印。选 — 像素 (1,2),用代码提取其 3×3 邻域,用 %3d 格式打印,验证邻域数据与手工计算一致。

    参考解答
    ex4_print_neighborhood.c
    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." — 计算机视觉谚语

Released under the MIT License.