🦀 Rust + WASM 实战系列 第 15 篇 阅读时间:约 6 分钟 | 实战可运行
📌 写在前面
前2篇做的翻转、旋转、缩放、斜切------全部是"线性变换" ,都能写成一个矩阵 M。
鱼眼不一样:没有矩阵形式。
但思想是一样的:反向映射 + 插值。这一篇你会看到:
- 矩阵没了
- 非线性公式登场
- 核心套路(反向映射 + 插值)依然不变
线性 vs 非线性的关键差异 :线性变换的逆 = Matrix3.try_inverse();非线性变换的逆 = 手算 / 数值迭代。
🚀 TL;DR
usrc=udststrength
- u=r/Rmax:归一化半径(0 = 中心,1 = 对角最远点)
strength > 1:鱼眼鼓出strength < 1:针孔收缩strength = 1:原图
一句话:中心保持,边缘按幂函数"挪位"。
📖 目录
- 鱼眼是什么?
- 核心公式:怎么算出"球面凸起"
- [反向映射:从 dst 推 src](#反向映射:从 dst 推 src "#%E4%B8%89%E5%8F%8D%E5%90%91%E6%98%A0%E5%B0%84%E4%BB%8E-dst-%E6%8E%A8-src")
- 关键代码
- 前端效果展示
- [与前 14 篇的对比(一段话)](#与前 14 篇的对比(一段话) "#%E5%85%AD%E4%B8%8E%E5%89%8D-14-%E7%AF%87%E7%9A%84%E5%AF%B9%E6%AF%94%E4%B8%80%E6%AE%B5%E8%AF%9D")
- 踩坑提醒
- 下篇预告(第三部分收官)
一、鱼眼是什么?
真实鱼眼 = 把世界投影到半球面,再展平 ------中心被"拉远"、边缘被"压近",中间鼓出来的就是鱼眼效果。
arduino
真实世界(3D) → 半球面(凸) → 平面图(2D)
↑
中心 → 凸顶(密集)
边缘 → 凸底(稀疏)
→ 整个图被"撑大"
为什么变球面? 因为半球的曲率让中心被"拉远"、边缘被"压近",再展平就是中间鼓出来的鱼眼效果。
二、核心公式:怎么算出"球面凸起"
核心两件事 :(1) 角度 θ 不变;(2) 距离 r 按幂函数变形 r′=R⋅u1/s。这就是"径向畸变"的全部数学。
鱼眼 = "径向距离按非线性函数变形"
鱼眼变换的通式只有 2 个关键点:
r′=f(r),θ′=θ
- r = 像素到中心的距离
- θ = 像素相对中心的角度
- r′=f(r) = 变形后的距离 ( f 是非线性函数)
- θ′=θ = 角度不变
角度不变 → 像素绕中心"扇出方向"不偏,只是离中心远近变了。
关键选择: f(r) 是什么?
最简单的"鱼眼感"函数------幂函数:
f(r)=R⋅(Rr)1/s=R⋅u1/s
其中 s = strength(强度), u=r/R = 归一化半径。
为什么是幂函数? 两个原因:
- 简单 :只需要一个参数 s
- 有解析逆 : f−1(r′)=R⋅us(直接 powf 就行)
写成更对称的形式(用归一化半径):
usrc=udst1/s(正向:src 像素映射到 dst)
反向映射(实现时实际用的)
实现要走"反向映射"(避免空洞),所以反转:
usrc=udsts(反向:dst 像素找 src 颜色)
注意指数从 1/s 变成 s------这是 y=x1/s 的反函数。
强度 s 的几何意义
| s | udst=0.5 时 → usrc | 视觉效果 |
|---|---|---|
| 0.3 | 0.50.3≈0.81 | 强针孔:dst 边缘在 src 80% 处取色 |
| 1.0 | 0.51.0=0.5 | 原图 |
| 1.8 | 0.51.8≈0.29 | 鱼眼:dst 边缘在 src 29% 处取色 |
| 2.5 | 0.52.5≈0.18 | 强鱼眼 |
直觉:
- s>1: usrc<udst → dst 边缘点去 src 的"更靠中心"找色 → src 的中心区被"拉宽"到边缘 → 边缘鼓出
- s<1:反过来 → 边缘收缩
为什么是"球面凸起"? 因为 dst 的所有边缘点都"对应到 src 的中心区"------src 中心区的细节被"摊大饼"到整张图的边缘,视觉上就是球面凸起。
函数图像直观理解
以s=0.5为例(收缩) 看坐标归一化后的函数的图像对比 usrc和udsts

把 usrc=udsts 画成图,看得最清楚------曲线在哪条对角线"上方/下方"决定效果:
ini
y = u_src
↑
1.0 ┤●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●
│ ╱╱╱╱╱╱╱ ← s=0.3 (针孔)
│ ╱╱╱╱╱
│ ╱╱╱╱
0.5 ┤────────────────────╱╱╱╱────────────────── ← s=1.0 (原图直线)
│ ╱╱╱╱ ╲╲╲╲
│ ╱╱╱╱ ╲╲╲╲
│ ╱╱╱╱ ╲╲╲╲ ← s=3.0 (鱼眼)
0.0 ┤●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●
└──────────────────────────────────────────→
0.0 0.25 0.5 0.75 1.0 u_dst
3 条曲线都从 (0,0) 到 (1,1) ------但走的"路径"完全不同:
| 曲线 | 走的路径 | 在对角线的哪边 | 视觉效果 |
|---|---|---|---|
| s = 0.3(针孔) | 上凸,一开始就冲到顶部 | 上方 | 边缘去"更远"的 src 找色 → 边缘向内收 |
| s = 1.0(原图) | 直线 | 重合 | 不变 |
| s = 3.0(鱼眼) | 下凸,长时间贴在底部 | 下方 | 边缘去"更近"的 src 找色 → 边缘向外鼓 |
记忆口诀:
曲线在对角线上方 → 收缩;下方 → 鼓出。
s 越大,曲线越"凹"(下方)→ 越像鱼眼 。 s 越小(< 1),曲线越"凸"(上方)→ 越像针孔。
怎么"鼓"出来的?可视化
css
strength = 1(不变) strength = 2(凸起)
───────────────── ─────────────────
src src
● ●
│ │ ↗
│ ← 这是同一条径向线 │ ↗
│ │ ↗
● ● ↗
dst 像素按角度对应 src 同角度 dst 边缘像素映射到 src 中心区
的距离上 → 边缘被"拉远"了
关键 :dst 上每个点都在 src 的"同一条径向线上"取色,只是离中心远近变了。
三、反向映射:从 dst 推 src
9 行代码算出一个像素 :算 r → 归一化 u → 求幂 → 反归一化 → 算 ratio → 沿径向线缩回去 → 双线性插值。全在"已知 dst 求 src"的框架里。
计算步骤
text
对每个 dst 像素 (x, y):
1. dx = x - cx, dy = y - cy
2. r_dst = sqrt(dx² + dy²)
3. u_dst = r_dst / max_r
4. u_src = u_dst ^ strength ← 幂函数:非线性核心
5. r_src = max_r * u_src
6. ratio = r_src / r_dst
7. src_x = cx + dx * ratio
8. src_y = cy + dy * ratio
9. 在 (src_x, src_y) 处双线性插值取色
为什么要反向映射?
第 4 篇(旋转)就讲过:反向映射无空洞、不会漏像素。这一篇继续用。
角度怎么保持?
src = center + ratio × (dst - center) 沿着向量 (dst - center) 缩放,方向不变 ,只是长度变了 ------ 这就是"径向畸变 "的本质:只改距离,不改角度。
为什么"等比缩放 dx, dy"等于"保持角度"?
代码里没有显式算角度 (不用 atan2/cos/sin),而是直接把 dx, dy 各自乘以 ratio:
rust
let ratio = r_src / r_dst;
let src_x = cx + dx * ratio; // ← dx 和 dy 乘同一个 ratio
let src_y = cy + dy * ratio;
为什么这样对? 数学证明很简短:
rdstdx=cosθ⇒rsrc⋅cosθ=dx⋅rdstrsrc=dx⋅ratio
同理 dy⋅ratio=rsrc⋅sinθ。
直觉 :向量 (dx,dy) 乘以正数 ratio = 把向量拉长/缩短 ratio 倍,方向不变,只改长度。
好处:
- ✅ 比
atan2 + cos + sin快 5~10 倍(少 3 次三角函数调用) - ✅ 数值更稳定(避免
atan2(0, 0)未定义、cos(θ)极值不稳定等问题) - ✅ 只有一处需要特判(
r_dst < 1e-6的中心点)
四、关键代码
核心循环 = "算 src 坐标 → 插值取色" 。双线性插值函数
sample_bilinear(详见13节)。
rust
#[wasm_bindgen]
pub fn fisheye(
pixels: &[u8],
width: u32,
height: u32,
strength: f64,
) -> Vec<u8> {
let w = width as usize;
let h = height as usize;
let cx = (w as f64) / 2.0;
let cy = (h as f64) / 2.0;
let max_r = (cx * cx + cy * cy).sqrt();
let pixel_len = w * h * 4;
let mut result = vec![0u8; pixel_len + 8];
for y in 0..h {
for x in 0..w {
let dx = x as f64 - cx;
let dy = y as f64 - cy;
let r_dst = (dx * dx + dy * dy).sqrt();
// 反向映射:归一化 → 求幂 → 反归一化 → 按比例缩回 src
let (src_x, src_y) = if r_dst < 1e-6 {
(cx, cy)
} else {
let u_dst = r_dst / max_r;
let u_src = u_dst.powf(strength); // 幂函数:非线性!
let r_src = max_r * u_src;
let ratio = r_src / r_dst;
(cx + dx * ratio, cy + dy * ratio)
};
let dst_idx = (y * w + x) * 4;
// 越界 → 填白色
if src_x < 0.0 || src_x >= (w as f64) - 1.0
|| src_y < 0.0 || src_y >= (h as f64) - 1.0
{
result[dst_idx] = 255;
result[dst_idx + 1] = 255;
result[dst_idx + 2] = 255;
result[dst_idx + 3] = 255;
continue;
}
sample_bilinear(pixels, w, h, src_x, src_y, &mut result, dst_idx);
}
}
// 末尾 8 字节写尺寸(鱼眼不变尺寸)
result[pixel_len..pixel_len + 4]
.copy_from_slice(&(w as u32).to_le_bytes());
result[pixel_len + 4..pixel_len + 8]
.copy_from_slice(&(h as u32).to_le_bytes());
result
}
五、前端效果展示
六、与前 14 篇的对比(一段话)
鱼眼是第一个非线性变换 ------前面 14 篇的翻转/旋转/缩放/斜切都能用
Matrix × vec表示,鱼眼没有矩阵形式 (公式 r′=f(r) 是非线性的)。 但渲染套路完全一样 :反向映射 + 插值 + 越界填白边。 唯一差异 只是"求逆"方式:线性变换的逆 =M.try_inverse(),鱼眼的逆 = 手算 ( usrc=udsts,直接powf)。
七、踩坑提醒
鱼眼的核心数学虽简单,但有 5 个边界坑 容易踩------看一眼能省半小时调试。
1. r_dst = 0(中心点)会除以 0
rust
let ratio = r_src / r_dst; // ← r_dst = 0 时崩溃
必须加保护:
rust
let (src_x, src_y) = if r_dst < 1e-6 {
(cx, cy) // 中心点:src 就是中心
} else {
// 正常计算
};
2. strength = 0 时 powf(0) 退化为常数
rust
u_dst.powf(0.0) // = 1.0 (所有 u_dst 都映射到 1)
结果:整张图变成一个点的颜色 。前端限制 strength ≥ 0.3 就好。
3. strength = 1 是恒等映射
要专门测试 strength = 1.0 的输出和原图完全一致(像素级)。如果不一致,说明反向映射哪里写错了。
4. 鱼眼不变尺寸
不像旋转会变宽高,鱼眼画布大小不变 ------只是像素位置变了。所以 new_w = w, new_h = h,末尾 8 字节也要写(前端要靠这个解码)。
5. 用对角距离而不是 max(w, h) 作为 max_r
rust
// ❌ max(w, h) 会让横图 / 竖图"鼓出程度"不一致
let max_r = (w as f64).max(h as f64) / 2.0;
// ✅ 对角距离:保证"最远角" = u=1
let max_r = ((cx * cx + cy * cy)).sqrt();
八、下篇预告(第三部分收官)
第三部分收官 ------几何变换 4 个任务做完,掌握了反向映射 + 插值 这套打法后,任何"按坐标生成图像"的算法都能上 。下部分进入全新的领域:分形。
🎉 第三部分"图像几何变换"完结 🎉
| 任务 | 算法 | 难度 | 核心概念 |
|---|---|---|---|
| 12 | 翻转 + 90° 旋转 | ⭐ | 整数坐标映射 |
| 13 | 任意角度旋转 | ⭐⭐ | Matrix2 + 旋转矩阵 |
| 14 | 缩放 + 斜切 | ⭐⭐ | Matrix3 齐次坐标 + 平移 |
| 15 | 鱼眼 | ⭐⭐ | 非线性变换 + 反向幂函数 |
4 个任务,4 个认知升级
| 认知 |
|---|
| ✅几何变换的本质 = 反向映射 + 插值(4 篇都一样) |
| ✅表达方式有 4 种:if / Matrix2 / Matrix3 / 非线性 |
| ✅求逆方式有 3 种:手写 / 矩阵运算 / 解析 / 数值迭代 |
| ✅越界处理永远一样:填白边(也可以填黑 / 透明) |
第四部分预告:分形画板(任务 16-18)
| 任务 | 内容 | 难度 |
|---|---|---|
| 16 | 曼德博集合(Mandelbrot) | ⭐⭐⭐ |
为什么从几何变换跳到分形?
因为分形渲染的核心循环和这一篇完全一样:
for each pixel(x,y):根据非线性公式计算 →填色
鱼眼公式 usrc=udsts 是"输入坐标 → 输出坐标" ; 分形公式 zn+1=zn2+c 是"输入复数 → 迭代输出"。
思路一模一样,只是分形多了迭代 和逃逸半径两个概念。
掌握了反向映射 + 插值,你就掌握了所有"按坐标生成图像"的算法。
🎁 写在最后
15 个任务做完,你已经:
- 单像素(part 1):改 RGB 值
- 邻域卷积(part 2):用 3×3 核算新颜色
- 几何变换(part 3):移动像素位置
接下来 3 篇(part 4)会进入全新的领域 ------分形 。计算公式从"空间变换"变成"迭代函数",但渲染套路一模一样。
你已经站在分形大门口了。
📦 项目地址 :pixel-math-wasm 🦀 Rust + WebAssembly 实战系列
🏷️ 标签 :#Rust #WebAssembly #图像处理 #几何变换 #鱼眼 #非线性变换 #幂函数 #分形入门
