🦀 Rust + WASM 实战系列 第 23 篇 阅读时间:约 8 分钟 | 实战可运行
📌 写在前面
加噪 = 把一张干净的图弄脏。听起来没用,但它是图像处理里用得最多的操作之一:
- 压力测试:去噪算法好不好用?先给干净图加噪,再看能还原多少
- 模拟真实拍摄:夜景、高 ISO、监控画面,本来就是带噪的
- 数据增强:训练模型时给图片加扰动,防止过拟合
- 做旧特效:老照片、胶片颗粒感
这一篇讲两种噪声 :高斯 (传感器发热造成的)和泊松(数光子数不准造成的)。
但在讲它们之前,得先说清一件更基本的事:图像加噪,到底是在对像素做什么操作。
🚀 TL;DR
加噪的全部秘密就一行:
新像素 = 原像素 + 一个随机数
魔鬼在细节里------决定效果的是这三个"各自":
| 细节 | 做法 | 不这么做会怎样 |
|---|---|---|
| 每个像素各抽各的 | 逐像素循环,每次重新抽 | 整张图抽同一个数 → 只是整体变亮变暗,没有颗粒感 |
| 每个通道各抽各的 | R、G、B 各抽一次 | 三通道抽同一个 → 只有灰色噪点,没有彩色噪点 |
| 随机数服从钟形分布 | 高斯 / 泊松 | 用均匀随机数 → 噪点糊成一片,很"假" |
📖 目录
- 加噪到底在对像素做什么
- [噪声 1:高斯噪声(传感器热噪声)](#噪声 1:高斯噪声(传感器热噪声) "#%E4%BA%8C%E5%99%AA%E5%A3%B0-1%E9%AB%98%E6%96%AF%E5%99%AA%E5%A3%B0%E4%BC%A0%E6%84%9F%E5%99%A8%E7%83%AD%E5%99%AA%E5%A3%B0")
- [噪声 2:泊松噪声(数光子数不准)](#噪声 2:泊松噪声(数光子数不准) "#%E4%B8%89%E5%99%AA%E5%A3%B0-2%E6%B3%8A%E6%9D%BE%E5%99%AA%E5%A3%B0%E6%95%B0%E5%85%89%E5%AD%90%E6%95%B0%E4%B8%8D%E5%87%86")
- 两种噪声叠在一起
- 前端效果展示
- 踩坑提醒
- 附录:随机数是怎么变出来的
- 接下来
一、加噪到底在对像素做什么
1.1 一个像素的完整旅程
拿一个像素举例,它的 RGB 是 (120, 90, 200)。加噪要做的事:
makefile
原始 抽三个随机数 相加 clamp
R: 120 + (-7) = 113 → 113
G: 90 + (+12) = 102 → 102
B: 200 + (+18) = 218 → 218
A: 255 255 ← alpha 不动
就这样。没有任何别的操作。 遍历完所有像素,一张噪声图就出来了。
三件事值得注意:
- 三个通道抽的是三个不同的随机数(−7、+12、+18),不是同一个
- alpha 通道不参与------加了会让图片东一块西一块地变透明
- 相加之后要 clamp 回 0~255 ------因为像素是
u8,200 + 80 = 280会溢出
1.2 为什么是"颗粒感"而不是"整体变亮"
这是加噪最容易被忽略的一点。假设我们偷懒,整张图只抽一个随机数:
markdown
所有像素都 +15:
┌──────────────┐ ┌──────────────┐
│ 120 122 119 │ │ 135 137 134 │
│ 121 118 120 │ → │ 136 133 135 │
│ 119 121 122 │ │ 134 136 137 │
└──────────────┘ └──────────────┘
相邻像素差 1~3 相邻像素差还是 1~3
→ 只是变亮了,画面一样平滑
每个像素各抽各的才会出颗粒:
arduino
每个像素抽不同的数:
┌──────────────┐ ┌──────────────┐
│ 120 122 119 │ │ 113 138 121 │
│ 121 118 120 │ → │ 142 109 133 │
│ 119 121 122 │ │ 116 145 108 │
└──────────────┘ └──────────────┘
相邻像素差 1~3 相邻像素差 20~35
→ 高低起伏 = 肉眼看到的"颗粒"
关键在"相邻像素之间不再连续"。人眼对这种局部剧烈起伏极其敏感,看上去就是一层沙子。
换句话说:噪声的视觉强度,来自"像素之间的差",而不是"像素本身的偏移量"。这也是为什么去噪算法(任务 6 的均值/中值模糊)能起作用------它们干的就是"把相邻像素拉回去"。
1.3 为什么 R、G、B 要各抽一次
如果三个通道抽同一个随机数:
scss
(120, 90, 200) + 15 → (135, 105, 215)
三个通道同增同减 = 颜色没变,只是这个点变亮了 。整张图的噪点就全是灰色的(黑白胡椒面)。
各抽各的:
scss
(120, 90, 200) + (-7, +12, +18) → (113, 102, 218)
三个通道涨跌不一 = 这个点的颜色也变了 ,于是画面上出现彩色噪点(偏红的点、偏蓝的点)。
真实的相机噪声就是彩色的------因为传感器上 R、G、B 是三个物理上独立的感光单元,各自独立地出错。所以要模拟得像,就必须各抽各的。
1.4 clamp:加噪会"吃掉"信息
clamp(0, 255) 看着是个技术细节,其实有视觉后果:
原像素 250,抽到 +30 → 280 → clamp → 255
原像素 245,抽到 +40 → 285 → clamp → 255
原像素 255,抽到 -2 → 253 → → 253
前两个像素本来一个 250 一个 245,加噪后都变成了 255------它们之间的差异被永久抹掉了。
结果 :图片的高光区域会成片死白、暗部会成片死黑,噪声在中间调最明显、在两端反而"消失"。σ 拉得越大,这个现象越严重。
代码(Rust)
加噪的主循环------三个"各自"和 clamp 都在这几行里:
rust
#[wasm_bindgen]
pub fn add_noise(
pixels: &[u8], width: u32, height: u32,
sigma: f32, // 高斯强度
poisson_level: u32, // 泊松强度
) -> Vec<u8> {
let mut rng = Rng::new(12345);
let n = (width * height) as usize;
let mut out = vec![0u8; pixels.len()];
for i in 0..n { // ← ① 每个像素各抽各的
let idx = i * 4;
let r = pixels[idx] as f64;
let g = pixels[idx + 1] as f64;
let b = pixels[idx + 2] as f64;
// ② 每个通道各抽一次(这里是 3 次独立采样)
let gr = rng.gaussian() * sigma as f64;
let gg = rng.gaussian() * sigma as f64;
let gb = rng.gaussian() * sigma as f64;
// ③ 相加后 clamp 回 u8
out[idx] = (r + gr).clamp(0.0, 255.0) as u8;
out[idx + 1] = (g + gg).clamp(0.0, 255.0) as u8;
out[idx + 2] = (b + gb).clamp(0.0, 255.0) as u8;
out[idx + 3] = pixels[idx + 3]; // ④ alpha 原样保留
}
out
}
上面省略了泊松那部分,完整版在 § 三。整个加噪逻辑就这么长 ,剩下的篇幅全在讨论一件事:
rng.gaussian()抽出来的那个随机数,应该服从什么分布。
二、噪声 1:高斯噪声(传感器热噪声)
2.1 为什么不能直接用均匀随机数
最偷懒的做法:抽一个 −20 ~ +20 之间的均匀随机数。
问题是它不像 。均匀分布意味着"偏 1 和偏 20 的概率一样大",可现实中的干扰从来不是这样------小偏差经常发生,大偏差很罕见。
均匀分布(假): 高斯分布(真):
概率 概率
│████████████████ │ ▄██▄
│████████████████ │ ▄█████▄
│████████████████ │ ▄█████████▄
└────────────────→ 偏移 └──────────────→ 偏移
-20 +20 -20 0 +20
每个偏移量一样常见 大部分挤在 0 附近
视觉上的差别很明显:均匀噪声看起来是一层均匀的"脏" ,像蒙了层塑料膜;高斯噪声才有真实的颗粒感,因为大多数像素只被轻微推动,少数像素被推得很远。
物理来源 :传感器里的电子在室温下热运动,随机撞出一点点额外电流。这种"无数个微小独立扰动加在一起"的东西,根据中心极限定理(下一篇讲)必然是高斯分布。所以模拟传感器噪声就得用高斯。
2.2 σ 到底控制什么
高斯分布只有一个参数 σ(标准差),它的含义非常具体:
| 范围 | 落在里面的像素比例 |
|---|---|
| ±1σ | 68% |
| ±2σ | 95% |
| ±3σ | 99.7% |
所以 σ = 20 的意思是:
-
68% 的像素,偏移量在 ±20 以内
-
95% 的像素,偏移量在 ±40 以内
-
几乎不会有像素偏移超过 ±60
σ = 5 → 轻微颗粒,像高级相机的高 ISO
σ = 20 → 明显噪点,像手机夜景
σ = 50 → 画面被噪声淹没,压力测试用
2.3 手算一个像素
随机数发生器吐出两个 0~1 之间的均匀数 U1=0.42、 U2=0.71,经过 Box-Muller 变换(怎么变的见 § 七附录)得到一个标准正态数:
z=−0.3276
标准正态的意思是"σ = 1 的高斯"。要得到 σ = 20 的噪声,乘上去就行:
偏移=z×σ=−0.3276×20=−6.55
于是像素 120 变成:
120+(−6.55)=113.45 转 u8 113
σ 就是个缩放系数 ------先抽标准正态,再乘 σ。代码里 rng.gaussian() * sigma 一行干的就是这件事。
代码(Rust)
rust
// 每通道独立抽一个 N(0, σ²)
let gr = if use_gaussian { rng.gaussian() * sigma as f64 } else { 0.0 };
let gg = if use_gaussian { rng.gaussian() * sigma as f64 } else { 0.0 };
let gb = if use_gaussian { rng.gaussian() * sigma as f64 } else { 0.0 };
out[idx] = (r + gr).clamp(0.0, 255.0) as u8;
out[idx + 1] = (g + gg).clamp(0.0, 255.0) as u8;
out[idx + 2] = (b + gb).clamp(0.0, 255.0) as u8;
rng.gaussian() 返回标准正态(均值 0、σ 为 1),乘上 sigma 得到目标强度。σ = 0 时整段跳过,一点噪声都不加。
三、噪声 2:泊松噪声(数光子数不准)
3.1 拍照的本质是"数光子"
相机传感器干的事,说白了就是数有多少个光子打进来。
问题在于光子是一颗一颗、随机到达的。哪怕光源完全稳定,同样曝光时间内到达的光子数每次也不一样:
erlang
同一个像素,曝光 10 次,平均收到 100 个光子:
第 1 次:97 个
第 2 次:104 个
第 3 次:91 个
...
→ 平均 100,但每次都在抖
这种"计数本身的随机涨落"叫散粒噪声(Shot Noise) ,服从泊松分布 。它和相机质量无关,是光的物理性质决定的,永远消不掉。
3.2 λ:泊松分布唯一的参数
泊松分布只有一个参数 λ,它同时是:
均值=λ,方差=λ
"均值和方差相等"是泊松最特别的地方,它直接导出一个重要结论:
相对涨落=均值标准差=λλ =λ 1
λ 越大,相对涨落越小:
| λ(平均光子数) | 标准差 √λ | 相对涨落 |
|---|---|---|
| 10000(大晴天) | 100 | 1% |
| 100(室内) | 10 | 10% |
| 10(暗处) | 3.2 | 32% |
这就是夜景照片颗粒感重的根本原因 :不是相机不好,是暗处的光子本来就少,少到"数不准"。想减少散粒噪声只有一个办法------收更多光子(开大光圈、延长曝光、上更大的传感器)。
3.3 本项目里 λ 是怎么定的
代码里 λ 由像素亮度和滑块 level 共同决定:
λ=255像素值×level
意思是:像素越亮,代表收到的光子越多,λ 越大 ;level 滑块控制整体的"光子量级"。
代入几组具体值(level = 20):
| 像素值 | λ | 标准差 √λ | 相对涨落 |
|---|---|---|---|
| 240(亮) | 18.8 | 4.3 | 1.8% |
| 120(中) | 9.4 | 3.1 | 2.6% |
| 30(暗) | 2.4 | 1.5 | 5.1% |
暗处的相对涨落是亮处的近 3 倍------和 3.2 的结论一致。
3.4 ⚠️ 本项目的实现是"叠加",不是"替换"
这一节请务必看完,否则你会对着效果图困惑。
物理上正确的散粒噪声应该是替换------用泊松采样值代替原像素,均值不变、只是抖动:
scss
物理正确: 新像素 = Poisson(λ) / level × 255 ← 均值 = 原像素
而本项目当前的代码是叠加:
rust
out[idx] = (r + gr + pr).clamp(0.0, 255.0) as u8;
// ↑ ↑
// 高斯 泊松:加上去,而不是替换掉
scss
本项目: 新像素 = 原像素 + Poisson(λ) ← 均值 = 原像素 + λ
后果 :因为 λ=255像素值×level,加上去的量正比于像素值本身,所以整张图会被按比例提亮 255level:
| level | 整体提亮 | 现象 |
|---|---|---|
| 20 | +7.8% | 略微提亮 + 颗粒 |
| 50 | +19.6% | 明显发亮,高光开始溢出 |
| 100 | +39.2% | 大片高光被 clamp 成纯白 |
所以拉大 level 时,你看到的是"变亮 + 加噪"两件事同时发生,而不是纯粹的散粒噪声。
好消息是 3.3 的结论仍然成立 :泊松那一项的抖动幅度依然是 √λ,暗处相对颗粒感依然比亮处强 。想观察这个现象,建议 level 保持在 20 以内,此时提亮只有 7.8%,颗粒感是主要变化。
想改成物理正确的版本 :把
r + gr + pr换成pr / level * 255.0 + gr即可(λ = 0 时特判)。本项目保留叠加版,一是它作为"另一种随机扰动"仍然可用,二是这个对比本身就是个好教学点------同一个泊松分布,叠加和替换的视觉结果完全不同。
代码(Rust)
rust
// λ = (像素值 / 255) × level ------ 像素越亮,λ 越大
let lambda_r = if use_poisson { r / 255.0 * poisson_level as f64 } else { 0.0 };
let lambda_g = if use_poisson { g / 255.0 * poisson_level as f64 } else { 0.0 };
let lambda_b = if use_poisson { b / 255.0 * poisson_level as f64 } else { 0.0 };
// 每通道独立采一次泊松
let pr = if use_poisson { rng.poisson(lambda_r) as f64 } else { 0.0 };
let pg = if use_poisson { rng.poisson(lambda_g) as f64 } else { 0.0 };
let pb = if use_poisson { rng.poisson(lambda_b) as f64 } else { 0.0 };
// 叠加:原像素 + 高斯 + 泊松
out[idx] = (r + gr + pr).clamp(0.0, 255.0) as u8;
out[idx + 1] = (g + gg + pg).clamp(0.0, 255.0) as u8;
out[idx + 2] = (b + gb + pb).clamp(0.0, 255.0) as u8;
注意泊松也是每通道独立采样------和高斯一样,这样才有彩色噪点。
四、两种噪声叠在一起
两个参数互相独立,可以任意组合:
| σ | level | 效果 | 像什么 |
|---|---|---|---|
| 0 | 0 | 原图 | --- |
| 20 | 0 | 均匀的彩色颗粒,亮暗处一样重 | 传感器发热 / 高 ISO |
| 0 | 20 | 略微提亮,暗处颗粒比亮处明显 | 弱光下的光子涨落 |
| 20 | 20 | 两种叠加 | 真实的夜景照片 |
| 50 | 0 | 画面被噪声淹没 | 去噪算法压力测试 |
两种噪声的本质区别:
markdown
高斯:偏移量和像素亮度无关
像素 30 → ±20
像素 240 → ±20 ← 一样的抖动幅度
泊松:偏移量随像素亮度增长
像素 30 → ±1.5 ← λ 小,抖得小(但相对自己很大)
像素 240 → ±4.3 ← λ 大,抖得大(但相对自己很小)
一句话:高斯是"每个像素被同等地推一把",泊松是"越亮的像素被推得越狠、但相对而言越稳"。
五、前端效果展示


六、踩坑提醒
1. 种子必须固定
rust
let mut rng = Rng::new(12345); // 固定种子
不固定的话,每次重新渲染同一张图,噪声都不一样------调参时根本没法判断"是我改的参数起作用了,还是随机数换了一批"。固定种子后,同样的输入永远得到同样的输出。
2. alpha 通道千万别加噪
rust
out[idx + 3] = pixels[idx + 3]; // 原样复制
给 alpha 加噪的后果是整张图随机地这里透一块那里透一块,看起来像渲染出错,而不是噪声。
3. clamp 会成片吃掉高光和暗部
见 § 1.4。σ 越大越明显------图片的直方图两端会堆起两根柱子(大量像素挤在 0 和 255)。做去噪算法测试时要留意:这部分信息是真的没了,再好的去噪也还原不回来。
4. 泊松 λ = 0 会死循环
rust
if lambda <= 0.0 { return 0; } // 必须特判
Knuth 算法的终止条件是 p ≤ exp(-λ),而 exp(-0) = 1,p 从 1 开始乘以小于 1 的数,永远追不上------死循环。纯黑像素(值为 0)就会触发这个情况,所以特判是必需的。
5. λ 大的时候 Knuth 很慢
Knuth 算法的循环次数平均是 λ 次。λ = 100 就要循环 100 次,每个像素每个通道都这样,一张 1000×1000 的图要循环 3 亿次。所以代码里 λ ≥ 30 时改用高斯近似:
rust
(lambda + lambda.sqrt() * self.gaussian()).max(0.0) as u32
依据 是 λ 大时泊松分布接近正态 N(λ,λ)------这也是中心极限定理的一个例子,下一篇会详细讲。
6. 加噪顺序会影响结果
如果要先加噪再做别的处理 (比如加噪后再模糊),注意这两个操作不可交换 :先噪后糊 = 噪声被抹平;先糊后噪 = 噪声原样保留。测试去噪算法时,一定是先加噪。
七、附录:随机数是怎么变出来的
7.1 均匀随机数:xorshift64
一切的起点是均匀分布。这里用 xorshift64------移位加异或,三行搞定:
rust
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13; // 左移 13 位后异或回去
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x
}
/// 转成 [0, 1) 的浮点数
fn uniform(&mut self) -> f64 {
(self.next() >> 11) as f64 / (1u64 << 53) as f64
}
}
为什么不用 rand crate :rand 会给 wasm 包增加 100KB+ 体积,而加噪只需要"看起来够随机",不需要密码学强度。xorshift64 的周期是 264−1,对图像处理绰绰有余。
uniform() 里的 >> 11 和 1 << 53 是因为 f64 的尾数只有 53 位------取高 53 位再除,才能均匀地铺满 [0, 1)。
7.2 均匀 → 高斯:Box-Muller
问题:手上只有均匀分布,怎么造出钟形的正态分布?
Box-Muller 变换 (1958)给出了一个漂亮的答案:拿两个独立的均匀数 U1,U2∼Uniform(0,1),则
Z=−2lnU1 ⋅cos(2πU2)
就是标准正态分布 N(0,1)。
直觉上,这两个部分各司其职:
- −2lnU1 决定离原点多远(半径)
- cos(2πU2) 决定朝哪个方向(角度)
合起来相当于在二维平面上按正态密度撒一个点,取它的横坐标。
rust
fn gaussian(&mut self) -> f64 {
let u1 = self.uniform().max(1e-10); // 防 ln(0) = -∞
let u2 = self.uniform();
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
}
顺带一提,把
cos换成sin会得到第二个独立的正态数------两个均匀数其实能换两个正态数,这里只用了一个,浪费了一半。想优化的话可以缓存起来下次用。
7.3 均匀 → 泊松:Knuth
泊松分布的定义是:
P(X=k)=k!λke−λ
Knuth 算法用一个非常巧的循环直接按定义采样:
ini
L = exp(-λ)
k = 0, p = 1
重复:
k = k + 1
p = p × U (U 是新抽的均匀随机数)
直到 p ≤ L
返回 k - 1
直觉 :不停地乘上 (0,1) 之间的数,p 会越来越小。λ 越大, L=e−λ 越小,就要乘更多次才能掉到 L 以下------所以返回的次数 k 自然就服从 Poisson(λ)。
rust
fn poisson(&mut self, lambda: f64) -> u32 {
if lambda <= 0.0 { return 0; } // 特判,见踩坑 4
if lambda < 30.0 {
let l = (-lambda).exp();
let mut k = 0u32;
let mut p = 1.0;
loop {
k += 1;
p *= self.uniform();
if p <= l { return k - 1; }
if k > 1000 { return 0; } // 兜底,防极端情况
}
} else {
// λ 大时用正态近似,见踩坑 5
(lambda + lambda.sqrt() * self.gaussian()).max(0.0) as u32
}
}
7.4 三层结构小结
scss
xorshift64 → 均匀分布 U(0,1) 「原料」
↓ Box-Muller
标准正态 N(0,1) → × σ → 高斯噪声 「传感器热噪声」
↓ Knuth
Poisson(λ) → 光子计数 「散粒噪声」
所有随机分布都是从均匀分布加工出来的------这是随机数生成的通用套路,下一篇的椒盐噪声也一样。
八、接下来
任务 24:噪声模型 + 中心极限定理
这一篇讲了高斯噪声怎么加 ,下一篇讲为什么偏偏是高斯------中心极限定理会告诉你:任何一堆微小独立扰动加在一起,最后都会变成钟形。
外加第三种噪声:椒盐噪声(随机把像素砸成纯黑或纯白),以及三种噪声的对比。
任务 25 :图像增强------自适应阈值 + 直方图均衡化 任务 26:概率驱动图像生成------随机游走 / 马尔可夫链 / 图像修复
一句话总结
加噪 = 遍历每个像素,给每个通道各加一个随机数,再 clamp 回 0~255。
颗粒感来自"每个像素各抽各的 ",彩色噪点来自"每个通道各抽各的 ",真实感来自"随机数服从钟形分布"。
高斯管传感器发热 (抖动幅度和亮度无关),泊松管数光子不准(越暗相对抖得越厉害)------这就是夜景照片颗粒感重的物理原因。
📦 项目地址 :pixel-math-wasm 🦀 Rust + WebAssembly 实战系列
🏷️ 标签 :#Rust #WebAssembly #图像处理 #加噪 #高斯噪声 #泊松噪声 #散粒噪声 #Box-Muller #Knuth