Rust图像处理第10节-浮雕/雕刻滤镜:邻域差值生成凹凸效果

40 阅读6分钟

🦀 Rust + WASM 实战系列 第 10 篇 阅读时间:约 5 分钟 | 实战可运行

📌 写在前面

Sobel(任务 9)用两个 3×3 核检测边缘。浮雕 = Sobel 的"亲戚"——用 1 个核,把"边缘"变成"凹凸"。

效果像把图片刻在石板上:平坦 = 中灰,凸起 = 亮,凹陷 = 暗。


🚀 TL;DR

原图:                   浮雕后:
┌──────────┐            ┌──────────┐
│  ████    │            │    ▓▓    │     中灰背景(128)
│  █  █    │    →       │   ▓  ▓   │     凸起 = 亮(> 128)
│  ████    │            │  ▓▓▓▓▓▓  │     凹陷 = 暗(< 128)
└──────────┘            └──────────┘

核心1 个 3×3 核):

-2  -1   0
-1   1   1
 0   1   2

公式:新像素 = 卷积结果 + 128(128 是中灰偏置)。


📖 目录

  1. 浮雕的物理直觉
  2. 核心 3×3 核解读
  3. 为什么加 128?
  4. 关键代码
  5. 前端效果展示
  6. 8 个方向的浮雕
  7. Sobel vs Emboss
  8. 应用场景

一、浮雕的物理直觉

想象图片被刻在石板上,光从左上方照下来:

光线从左上 → 凸起 → 朝光 → 亮
           凹陷 → 背光 → 暗
           平坦 → 中间色

算法本质:算"对角邻居差值"——凸起 = 远端比近端亮,凹陷 = 反过来。


二、核心 3×3 核解读

let kernel = Matrix3::from_row_slice(&[
    -2, -1,  0,    // 左上    系数负("减")
    -1,  1,  1,    // 正中    系数 1("自")
     0,  1,  2,    // 右下    系数正("加")
]);

每个系数的意思

   左上 ──── 中上 ──── 右上
     │         │         │
   左中  ─┼─ ★正中  ─┼─ 右中   ← "现在"这个像素
    │         │         │
   左下 ──── 中下 ──── 右下
系数含义
-2(左上)"远离自己" → 大幅扣除
-1(左、左上)"略远" → 少量扣除
0(左上角 + 中)"无影响"
+1(右、右下)"略近" → 少量加成
+2(右下)"最近自己" → 大幅加成
+1(正中)"自身" → 1 倍贡献

几何意义:对角线(左上 ↔ 右下)的梯度——凸起 = 远端暗,凹陷 = 远端亮。


三、为什么加 128?

前面说了 新像素 = 卷积结果 + 128(128 是中灰偏置)

没有 128 偏置会怎样?

let sum: i32 = convolution_sum;  // 通常 -255 ~ +255
// 不加 128:sum = 200 → 200  → 太亮
//          sum = -200 → -200 → 负数(u8 下溢 → 56)

加上 128

let embossed = (sum + 128).clamp(0, 255) as u8;
// sum =    0 → 128 (中灰,无边缘 = 平坦)
// sum = +100 → 228 (亮,凸起)
// sum = -100 →  28 (暗,凹陷)
sum 卷积结果+128 后视觉
0128中灰(平)
+127255最白(最强凸)
-1280最黑(最强凹)

128 是个"魔法数字" = u8 范围的中点。


四、关键代码

// 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;

五、前端效果展示

9b76fecb-e5c7-4247-b32c-5708bf8cec7a.png

六、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 个方向

简单实现

// 转置核 = 改变方向
let transposed: Matrix3<i32> = kernel.transpose();

完整 8 方向在专业图像处理软件里都有,但我们只做最常用的左上 → 右下


七、Sobel vs Emboss:相同的"卷积芯",不同的"外围"

很多人第一次看完 Sobel 和浮雕的代码会有疑问:"是不是换了个矩阵,其他都一样?"

答案是:卷积芯完全一样,外围有 4 个不同

7.1 相同的部分(卷积算法本身)

两者都用这一段(来自任务 9 讨论过的 zip + map + sum 模式):

// 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 个不同点

维度SobelEmboss
核数量2 个(Gx + Gy,两个方向差分)1 个(单一对角方向差分)
结果合成`(gx+gy).min(255)` 矢量合成直接用卷积结果sum
偏置不加(mag 本来就 ≥ 0)+ 128(把 0 映射到中灰)
最终输出二值化(黑或白)连续灰度(0~255)

7.3 算法对照(伪代码)

把"卷积芯"抽出来成 convolve() 函数,两者差异立刻清晰:

// 卷积芯(共用)
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 不要?

SobelEmboss
卷积结果范围mag =|gx| + |gy|,本来就 ≥ 0sum 可以是**-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