🦀 Rust + WASM 实战系列 第 9 篇 阅读时间:约 10 分钟 | 实战可运行
📌 写在前面
前 8 篇我们做的"模糊"、"马赛克"、"暗角",没有真正用到卷积------只是"邻域里算个平均/中值"。
从这一篇开始,进入图像处理的"硬核区" ------卷积。
Sobel 边缘检测是最经典的卷积算法:
- 用 2 个 3×3 矩阵(卷积核)
- 检测图片中颜色剧烈变化的地方
- 输出黑白边缘图(建筑线稿、卡通描边都用这)
🚀 TL;DR
原图: Sobel 边缘:
┌──────────┐ ┌──────────┐
│ ████ │ │ ▓▓ │
│ █ █ │ → │ ▓ ▓ │ 白色 = 边缘
│ ████ │ │ ▓▓▓▓▓▓ │ 黑色 = 背景
└──────────┘ └──────────┘
2 个核 (这是全部魔法):
| 核 | 作用 |
|---|---|
| Gx | 检测水平方向边缘 |
| Gy | 检测垂直方向边缘 |
rust
// Gx (水平边缘)
-1 0 1
-2 0 2
-1 0 1
// Gy (垂直边缘)
-1 -2 -1
0 0 0
1 2 1
📖 目录
- 什么是边缘?
- [Sobel 核心:2 个 3×3 矩阵](#Sobel 核心:2 个 3×3 矩阵 "#%E4%BA%8Csobel-%E6%A0%B8%E5%BF%832-%E4%B8%AA-33-%E7%9F%A9%E9%98%B5")
- 卷积到底是什么?
- 关键代码
- 前端效果展示
- 梯度幅值公式
- 阈值参数怎么选
- [Sobel vs 其他边缘检测](#Sobel vs 其他边缘检测 "#%E5%85%ABsobel-vs-%E5%85%B6%E4%BB%96%E8%BE%B9%E7%BC%98%E6%A3%80%E6%B5%8B")
- [为什么用 Matrix3 而不是 i32; 9?](#为什么用 Matrix3 而不是 [i32; 9]? "#%E4%B9%9D%E4%B8%BA%E4%BB%80%E4%B9%88%E7%94%A8-matrix3-%E8%80%8C%E4%B8%8D%E6%98%AF-i32-9")
- 参考资料
一、什么是边缘?
边缘 = 颜色 / 亮度突然变化的地方。
markdown
从黑到白(数值 0 → 255):
■ ■ ■ □ □ □ □ □ □ □ □ □ □ ■ ■ ■
0 0 0 0 0 0 0 0 0 0 0 0 0 0 255 ← 中间是边缘
↑
突变的位置
Sobel 做的事情:对每个像素,算"它周围有多大的变化"。变化越大 → 越可能是边缘。
二、Sobel 核心:2 个 3×3 矩阵
rust
// 用 nalgebra 定义 2 个核
let Gx = Matrix3::from_row_slice(&[
-1, 0, 1, // 第 0 行
-2, 0, 2, // 第 1 行
-1, 0, 1, // 第 2 行
]);
let Gy = Matrix3::from_row_slice(&[
-1, -2, -1, // 第 0 行
0, 0, 0, // 第 1 行
1, 2, 1, // 第 2 行
]);
每个系数的意义:
| 系数 | 作用 |
|---|---|
| 正数(如 +1, +2) | "增强"这一边的贡献 |
| 负数(如 -1, -2) | "减去"这一边的贡献 |
| 0 | "忽略"这一边的贡献 |
Gx = 右边 - 左边 :检测水平 方向的明暗变化(垂直边缘 ) Gy = 下边 - 上边 :检测垂直 方向的明暗变化(水平边缘)
三、卷积到底是什么?
卷积 = 矩阵元素对应相乘 + 求和。就这么简单。
ini
邻域: Gx 核: 相乘求和:
┌──┬──┬──┐ ┌──┬──┬──┐ ┌──┬──┬──┐
│ 50│60│70│ │-1│0 │ 1│ │-50│ 0 │ 70│
├──┼──┼──┤ × ├──┼──┼──┤ = ├──┼──┼──┤
│ 50│60│70│ │-2│ 0│ 2│ │-100│ 0 │140│
├──┼──┼──┤ ├──┼──┼──┤ ├──┼──┼──┤
│ 50│60│70│ │-1│ 0│ 1│ │-50│ 0 │ 70│
└──┴──┴──┘ └──┴──┴──┘ └──┴──┴──┘
↓
sum = -50+70-100+140-50+70 = 80
↑
这就是 Gx(水平梯度)
"卷" = 把核"卷"过图片每个像素; "积" = 对应元素相乘再求和。
四、关键代码
1. 收集 3×3 邻域(用 nalgebra 的 Matrix3 存)
rust
let neighborhood: Matrix3<i32> = Matrix3::from_row_slice(&[
gray_at(pixels, w, h, x - 1, y - 1), // 左上
gray_at(pixels, w, h, x, y - 1), // 上
gray_at(pixels, w, h, x + 1, y - 1), // 右上
gray_at(pixels, w, h, x - 1, y ), // 左
gray_at(pixels, w, h, x, y ), // 中(当前像素)
gray_at(pixels, w, h, x + 1, y ), // 右
gray_at(pixels, w, h, x - 1, y + 1), // 左下
gray_at(pixels, w, h, x, y + 1), // 下
gray_at(pixels, w, h, x + 1, y + 1), // 右下
]);
2. 卷积 = zip + map + sum
rust
// Gx = 邻域与 Gx 核的"对应元素相乘再求和"
let gx: i32 = neighborhood.iter()
.zip(SOBEL_X.iter())
.map(|(a, b)| a * b)
.sum();
// Gy 同理
let gy: i32 = neighborhood.iter()
.zip(SOBEL_Y.iter())
.map(|(a, b)| a * b)
.sum();
3. 梯度幅值 + 阈值
rust
let magnitude = (gx.abs() + gy.abs()).min(255) as u8;
let edge = if magnitude as i32 > threshold { 255 } else { 0 };
4. 链式调用拆解:iter / zip / map / sum
这段代码看起来很"函数式",其实每一步都是 Rust 标准库提供的:
rust
neighborhood.iter() // ① nalgebra 提供:把 Matrix3 摊平成 9 个元素
.zip(kx.iter()) // ② std Iterator::zip :两个迭代器两两配对
.map(|(a, b)| a * b) // ③ std Iterator::map :每个配对做乘法
.sum(); // ④ std Iterator::sum :累加成一个数
| 方法 | 来源 | 作用 |
|---|---|---|
Matrix3::iter() |
nalgebra | 把 3×3 矩阵转成迭代器 |
.zip() |
Rust std | 把两个迭代器像拉拉链一样两两配对 |
.map(闭包) |
Rust std | 对每个元素应用闭包 |
.sum() |
Rust std | 把所有元素累加起来 |
关键认知 :除了第一步 iter() 来自 ngebra,后面三个方法全是 std::iter::Iterator trait 上定义的默认方法。只要一个类型实现了 Iterator trait,zip / map / sum / filter / take / collect 等几十个方法就"白送"。
这就是 Rust 的 trait 抽象威力 :nalgebra 不用重新发明这些工具,只需让自己的迭代器实现
Iterator标准接口。
等价的传统写法:
rust
let mut gx: i32 = 0;
for i in 0..9 {
gx += neighborhood[i] * kx[i];
}
迭代器链的优势:不会写错索引 (不可能写出 kx[9])、类型安全 、LLVM 优化后和手写循环性能一样。
五、前端效果展示

六、梯度幅值公式
两种主流算法
| 公式 | 名称 | 速度 |
|---|---|---|
| ` | Gx | + |
√(Gx² + Gy²) |
欧几里得距离 | ⭐ |
两者视觉效果几乎一样 ,但曼哈顿快 3 倍(不用 sqrt):
rust
let magnitude = gx.abs() + gy.abs(); // ✓ 快(L1 曼哈顿)
let magnitude = (gx * gx + gy * gy).sqrt(); // ✗ 慢(L2 欧几里得)
// 更稳定的写法:
let magnitude = (gx as f32).hypot(gy as f32); // hypot 避免大数溢出
为什么必须 .abs()?------正负抵消的陷阱
Sobel 算子对"明→暗"和"暗→明"两条方向相反的边缘会算出相反符号的值:
css
Gx 核 = [-1, 0, 1, -2, 0, 2, -1, 0, 1]
- 黑→白 (左暗右亮)边缘:
gx= 大正数(如 +180) - 白→黑 (左亮右暗)边缘:
gx= 大负数(如 -180)
我们要判断的是"这里有没有边缘",不在乎方向,所以必须先取绝对值:
rust
let magnitude = (gx.abs() + gy.abs()).min(255) as u8;
// ^^^^^^^ ^^^^^^^
// 关键步骤!少了它,相邻方向相反的边缘会互相抵消
反例 (去掉 .abs() 的灾难):
rust
// 错误版本
let magnitude = (gx + gy).min(255) as u8;
当一条水平方向的黑→白边缘(gx = +180)和一条垂直方向的白→黑边缘(gy = -180)正好经过同一像素,结果是 0 ------ 真实存在的强边缘被抵消成了"无边缘"。
为什么还要 .min(255) as u8?
RGB 像素值的合法范围是 0~255。(gx.abs() + gy.abs()) 是 i32,可能远大于 255:
- 不限幅:
300_i32 as u8→ 截断为 44(只保留低 8 位),结果完全错乱 - 限幅后:
300.min(255) as u8→ 255,正确
.min(n) 和 .clamp(min, max) 的区别:
| 方法 | 行为 |
|---|---|
.min(n) |
只设上限:if self > n { n } else { self } |
.max(n) |
只设下限:if self < n { n } else { self } |
.clamp(min, max) |
上下都限:min(自己, max) 和 max(min, 自己) |
整条表达式的执行表
| 步骤 | 输入 | 输出 | 作用 |
|---|---|---|---|
.abs() |
gx: i32 |
正 i32 | 去掉符号 |
+ |
两个正 i32 | i32 | L1 梯度幅值 |
.min(255) |
i32 | ≤ 255 的 i32 | 防止转字节时溢出 |
as u8 |
≤ 255 的 i32 | u8 | 转成像素字节 |
数学含义:
magnitude=min(255, ∣gx∣+∣gy∣)
七、阈值参数怎么选
threshold |
效果 |
|---|---|
| 0~30 | 几乎所有像素都变边缘(噪声也成边) |
| 50~80 | 弱边也能检测(细节多) |
| 100~150 | 推荐(平衡) |
| 200~255 | 只显示最强边缘(最干净) |
调参技巧:
- 太脏(噪声多) → 调高阈值
- 太空(没几条线) → 调低阈值
- 一般从 100 开始试
八、Sobel vs 其他边缘检测
| 算法 | 原理 | 速度 | 效果 |
|---|---|---|---|
| Sobel | 一阶导数 | ⭐⭐⭐ | 边缘粗,抗噪 |
| Prewitt | 简化版 Sobel | ⭐⭐⭐ | 边缘细 |
| Roberts | 2×2 对角差分 | ⭐⭐⭐⭐ | 边缘很细 |
| Laplacian | 二阶导数 | ⭐⭐ | 边缘很细,但对噪声敏感 |
| Canny | 多阶段(Sobel + 非极大抑制 + 滞后阈值) | ⭐ | 边缘最干净 |
Sobel 是性价比之王 :快 + 抗噪 + 够用。工业首选。
九、为什么用 Matrix3 而不是 [i32; 9]?
这是个值得展开的设计取舍。先看代码实际做了什么:
rust
let gx: i32 = neighborhood.iter() // ← 第一步就 iter() 摊平了
.zip(kx.iter())
.map(|(a, b)| a * b)
.sum(); // ← 没有用到任何矩阵运算
整个流程里没有用 到 Matrix3 的任何矩阵特有功能(没有转置、没有矩阵乘法、没有求逆)。Matrix3 和 [i32; 9] 在这里行为完全等价。
用 Matrix3 的好处
| 维度 | 优势 |
|---|---|
| 可读性 | Matrix3::from_row_slice(...) 一眼看出"3×3 结构",比 9 个一维数字直观 |
| 形状约束 | 编译期就锁死 3×3 形状,写成 8 或 10 个元素会立刻报错 |
| 未来扩展 | 以后想用.transpose()、.determinant()、.try_inverse() 等都现成 |
| 跨文件统一 | 多个卷积算法都用Matrix3,形成项目范式 |
十、参考资料
- Wikipedia - Sobel operator:经典论文
- OpenCV 文档 :
cv2.Sobel()的 Python/C++ 实现 - GIMP 文档:Filters → Edge-Detect → Edge
- Pillow 库 :
ImageFilter.FIND_EDGES用的是 Sobel
🎁 写在最后
Sobel = "第一个真正的卷积" 。前面 8 篇都只是"邻域里算个平均/中值",Sobel 才是打开卷积大门的钥匙。
掌握了 Sobel:
- 浮雕 = 卷积(用差值核)
- 锐化 = 卷积(用增强核)
- 高斯模糊 = 卷积(用正态分布核)
- 神经网络 CNN = 一堆卷积
一切皆卷积------你今天迈出了关键一步。
下篇预告:《浮雕/雕刻滤镜:用邻域差值生成凹凸效果 》------ Sobel 的"孪生兄弟",1 个核就能搞定,敬请期待。
📦 项目地址 :pixel-math-wasm 🦀 Rust + WebAssembly 实战系列
🏷️ 标签 :#Rust #WebAssembly #图像处理 #Sobel #边缘检测 #卷积 #算法 #nalgebra