🦀 Rust + WASM 实战系列 第 10 篇 阅读时间:约 5 分钟 | 实战可运行
📌 写在前面
Sobel(任务 9)用两个 3×3 核检测边缘。浮雕 = Sobel 的"亲戚" ------用 1 个核,把"边缘"变成"凹凸"。
效果像把图片刻在石板上:平坦 = 中灰,凸起 = 亮,凹陷 = 暗。
🚀 TL;DR
原图: 浮雕后:
┌──────────┐ ┌──────────┐
│ ████ │ │ ▓▓ │ 中灰背景(128)
│ █ █ │ → │ ▓ ▓ │ 凸起 = 亮(> 128)
│ ████ │ │ ▓▓▓▓▓▓ │ 凹陷 = 暗(< 128)
└──────────┘ └──────────┘
核心 (1 个 3×3 核):
diff
-2 -1 0
-1 1 1
0 1 2
公式:新像素 = 卷积结果 + 128(128 是中灰偏置)。
📖 目录
- 浮雕的物理直觉
- [核心 3×3 核解读](#核心 3×3 核解读 "#%E4%BA%8C%E6%A0%B8%E5%BF%83-33-%E6%A0%B8%E8%A7%A3%E8%AF%BB")
- [为什么加 128?](#为什么加 128? "#%E4%B8%89%E4%B8%BA%E4%BB%80%E4%B9%88%E5%8A%A0-128")
- 关键代码
- 前端效果展示
- [8 个方向的浮雕](#8 个方向的浮雕 "#%E5%85%AD8-%E4%B8%AA%E6%96%B9%E5%90%91%E7%9A%84%E6%B5%AE%E9%9B%95")
- [Sobel vs Emboss](#Sobel vs Emboss "#%E4%B8%83sobel-vs-emboss")
- 应用场景
一、浮雕的物理直觉
想象图片被刻在石板上,光从左上方照下来:
markdown
光线从左上 → 凸起 → 朝光 → 亮
凹陷 → 背光 → 暗
平坦 → 中间色
算法本质:算"对角邻居差值"------凸起 = 远端比近端亮,凹陷 = 反过来。
二、核心 3×3 核解读
rust
let kernel = Matrix3::from_row_slice(&[
-2, -1, 0, // 左上 系数负("减")
-1, 1, 1, // 正中 系数 1("自")
0, 1, 2, // 右下 系数正("加")
]);
每个系数的意思:
arduino
左上 ──── 中上 ──── 右上
│ │ │
左中 ─┼─ ★正中 ─┼─ 右中 ← "现在"这个像素
│ │ │
左下 ──── 中下 ──── 右下
| 系数 | 含义 |
|---|---|
| -2(左上) | "远离自己" → 大幅扣除 |
| -1(左、左上) | "略远" → 少量扣除 |
| 0(左上角 + 中) | "无影响" |
| +1(右、右下) | "略近" → 少量加成 |
| +2(右下) | "最近自己" → 大幅加成 |
| +1(正中) | "自身" → 1 倍贡献 |
几何意义 :对角线(左上 ↔ 右下)的梯度------凸起 = 远端暗,凹陷 = 远端亮。
三、为什么加 128?
前面说了 新像素 = 卷积结果 + 128(128 是中灰偏置)
那没有 128 偏置会怎样?
rust
let sum: i32 = convolution_sum; // 通常 -255 ~ +255
// 不加 128:sum = 200 → 200 → 太亮
// sum = -200 → -200 → 负数(u8 下溢 → 56)
加上 128:
rust
let embossed = (sum + 128).clamp(0, 255) as u8;
// sum = 0 → 128 (中灰,无边缘 = 平坦)
// sum = +100 → 228 (亮,凸起)
// sum = -100 → 28 (暗,凹陷)
sum 卷积结果 |
+128 后 | 视觉 |
|---|---|---|
| 0 | 128 | 中灰(平) |
| +127 | 255 | 最白(最强凸) |
| -128 | 0 | 最黑(最强凹) |
128 是个"魔法数字" = u8 范围的中点。
四、关键代码
rust
// 1. 收集 3×3 邻域灰度(用 Matrix3 存,跟核矩阵同结构)
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,nalgebra 风格)
let sum: i32 = neighborhood.iter()
.zip(kernel.iter())
.map(|(a, b)| a * b)
.sum();
// 3. 加 128 偏置 + clamp
let embossed = (sum + 128).clamp(0, 255) as u8;
// 4. 三通道都填这个灰度(浮雕是"灰度感"的)
result[o] = embossed;
result[o + 1] = embossed;
result[o + 2] = embossed;
五、前端效果展示
六、8 个方向的浮雕
不同方向的光源 → 不同方向的凸起:
| 核 | 浮凸方向 |
|---|---|
-2 -1 0 / -1 1 1 / 0 1 2 |
左 → 右(光从左上) |
0 1 2 / -1 1 1 / -2 -1 0 |
右 → 左 |
-2 -1 0 / -1 1 1 / 0 1 2(转置) |
上 → 下 |
| ... | 8 个方向 |
简单实现:
rust
// 转置核 = 改变方向
let transposed: Matrix3<i32> = kernel.transpose();
完整 8 方向 在专业图像处理软件里都有,但我们只做最常用的左上 → 右下。
七、Sobel vs Emboss:相同的"卷积芯",不同的"外围"
很多人第一次看完 Sobel 和浮雕的代码会有疑问:"是不是换了个矩阵,其他都一样?"
答案是:卷积芯完全一样,外围有 4 个不同。
7.1 相同的部分(卷积算法本身)
两者都用这一段(来自任务 9 讨论过的 zip + map + sum 模式):
rust
// 1. 收集 3×3 邻域
let neighborhood: Matrix3<i32> = Matrix3::from_row_slice(&[
gray_at(pixels, w, h, x-1, y-1), // ... 9 个像素
]);
// 2. 卷积 = 对应元素相乘再求和
let sum: i32 = neighborhood.iter()
.zip(kernel.iter())
.map(|(a, b)| a * b)
.sum();
这一步 100% 相同------不管你是 Sobel、浮雕、模糊、锐化,卷积"芯"都是同一段代码。
7.2 4 个不同点
| 维度 | Sobel | Emboss | ||||
|---|---|---|---|---|---|---|
| 核数量 | 2 个(Gx + Gy,两个方向差分) | 1 个(单一对角方向差分) | ||||
| 结果合成 | `( | gx | + | gy | ).min(255)` 矢量合成 | 直接用卷积结果sum |
| 偏置 | 不加(mag 本来就 ≥ 0) | + 128(把 0 映射到中灰) | ||||
| 最终输出 | 二值化(黑或白) | 连续灰度(0~255) |
7.3 算法对照(伪代码)
把"卷积芯"抽出来成 convolve() 函数,两者差异立刻清晰:
rust
// 卷积芯(共用)
fn convolve(neighborhood: Matrix3<i32>, kernel: Matrix3<i32>) -> i32 {
neighborhood.iter()
.zip(kernel.iter())
.map(|(a, b)| a * b)
.sum()
}
// ============ Sobel ============
let gx = convolve(neighborhood, SOBEL_X);
let gy = convolve(neighborhood, SOBEL_Y);
let mag = (gx.abs() + gy.abs()).min(255);
let pixel = if mag > threshold { 255 } else { 0 }; // ← 二值化
// ============ Emboss ============
let sum = convolve(neighborhood, EMBOSS_KERNEL);
let pixel = (sum + 128).clamp(0, 255); // ← +128 偏置
共同 :
convolve(neighborhood, kernel)那段 差异:核的数量、结果怎么合成、最终怎么映射到像素
7.4 为什么浮雕只用 1 个核?
Sobel 必须用 2 个核是因为梯度本身是矢量------既有大小又有方向,Gx 和 Gy 两个分量才能完整描述"明暗在哪里突变"。
浮雕关心的是单一方向(默认是左上→右下)的明暗差异,本身就是标量,1 个核够用:
- 凸起 = 右下像素 > 左上像素(卷积结果 > 0)
- 凹陷 = 右下像素 < 左上像素(卷积结果 < 0)
- 平坦 = 两者相等(卷积结果 ≈ 0)
7.5 为什么浮雕要 +128,Sobel 不要?
| Sobel | Emboss | |
|---|---|---|
| 卷积结果范围 | mag =|gx| + |gy|,本来就 ≥ 0 | sum 可以是**-255 ~ +255** 任意值 |
| 0 的含义 | "完全没有边缘"(不显示) | "平坦区域"(要显示成中灰背景) |
| 加偏置? | 不需要(已经 ≥ 0) | 必须 +128,否则平坦区显示成纯黑 |
7.6 一句话总结
核决定"算什么",合成方式决定"怎么用结果"。
Sobel 核 → 检测边缘;浮雕核 → 检测对角差值。 Sobel 用
abs + sum + threshold→ 输出黑白二值图; 浮雕用+128→ 输出连续灰度图。换核换主题,换合成方式换玩法------这就是卷积类算法的"组件化"。
八、应用场景
| 场景 | 说明 |
|---|---|
| 艺术化照片 | "老照片 / 浮雕版" |
| UI 装饰按钮 | Material Design 风格 |
| 盲文 / 触觉图 | 转成可触摸的灰度图 |
| 游戏 / 复古风 | "老游戏" 像素感 |
| 专业印刷制版 | 模拟雕刻效果 |
🎁 写在最后
浮雕是最便宜的"立体感"特效:
- 1 个 3×3 核
- 1 行核心公式
- 几行 nalgebra 代码
但效果立竿见影 。掌握了 Sobel,再学浮雕就是5 分钟的事。
下篇预告:《故障风 RGB 通道偏移:错位错色制造电子故障 》------ 最后一个邻域卷积,不用卷积核也能做出"赛博朋克"风格,敬请期待。
📦 项目地址 :pixel-math-wasm 🦀 Rust + WebAssembly 实战系列
🏷️ 标签 :#Rust #WebAssembly #图像处理 #浮雕 #Emboss #卷积 #算法 #nalgebra
