Rust图像处理第15节-鱼眼广角畸变:非线性几何变换

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

📌 写在前面

前2篇做的翻转、旋转、缩放、斜切------全部是"线性变换" ,都能写成一个矩阵 MM M。

鱼眼不一样:没有矩阵形式

思想是一样的:反向映射 + 插值。这一篇你会看到:

  • 矩阵没了
  • 非线性公式登场
  • 核心套路(反向映射 + 插值)依然不变

线性 vs 非线性的关键差异 :线性变换的逆 = Matrix3.try_inverse();非线性变换的逆 = 手算 / 数值迭代


🚀 TL;DR

usrc=udststrength u_{\text{src}} = u_{\text{dst}}^{\text{strength}} usrc=udststrength

  • u=r/Rmax⁡ u = r / R_{\max} u=r/Rmax:归一化半径(0 = 中心,1 = 对角最远点)
  • strength > 1:鱼眼鼓出
  • strength < 1:针孔收缩
  • strength = 1:原图

一句话:中心保持,边缘按幂函数"挪位"。


📖 目录

  1. 鱼眼是什么?
  2. 核心公式:怎么算出"球面凸起"
  3. [反向映射:从 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")
  4. 关键代码
  5. 前端效果展示
  6. [与前 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")
  7. 踩坑提醒
  8. 下篇预告(第三部分收官)

一、鱼眼是什么?

真实鱼眼 = 把世界投影到半球面,再展平 ------中心被"拉远"、边缘被"压近",中间鼓出来的就是鱼眼效果

arduino 复制代码
真实世界(3D)   →  半球面(凸)  →  平面图(2D)
                       ↑
                  中心 → 凸顶(密集)
                  边缘 → 凸底(稀疏)
                  → 整个图被"撑大"

为什么变球面? 因为半球的曲率让中心被"拉远"、边缘被"压近",再展平就是中间鼓出来的鱼眼效果。


二、核心公式:怎么算出"球面凸起"

核心两件事 :(1) 角度 θ\theta θ 不变;(2) 距离 rr r 按幂函数变形 r′=R⋅u1/sr' = R \cdot u^{1/s} r′=R⋅u1/s。这就是"径向畸变"的全部数学

鱼眼 = "径向距离按非线性函数变形"

鱼眼变换的通式只有 2 个关键点
r′=f(r),θ′=θ \boxed{ r' = f(r), \quad \theta' = \theta } r′=f(r),θ′=θ

  • rr r = 像素到中心的距离
  • θ\theta θ = 像素相对中心的角度
  • r′=f(r)r' = f(r) r′=f(r) = 变形后的距离 ff f 是非线性函数)
  • θ′=θ\theta' = \theta θ′=θ = 角度不变

角度不变 → 像素绕中心"扇出方向"不偏,只是离中心远近变了

关键选择: f(r)f(r) f(r) 是什么?

最简单的"鱼眼感"函数------幂函数
f(r)=R⋅ (rR) 1/s =R⋅u1/sf(r) = R \cdot \left(\frac{r}{R}\right)^{1/s} = R \cdot u^{1/s} f(r)=R⋅(Rr)1/s=R⋅u1/s

其中 ss s = strength(强度), u=r/Ru = r/R u=r/R = 归一化半径。

为什么是幂函数? 两个原因:

  1. 简单 :只需要一个参数 ss s
  2. 有解析逆 f−1(r′)=R⋅usf^{-1}(r') = R \cdot u^s f−1(r′)=R⋅us(直接 powf 就行)

写成更对称的形式(用归一化半径):
usrc= udst1/s (正向:src 像素映射到 dst) \boxed{ u_{\text{src}} = u_{\text{dst}}^{1/s} \quad \text{(正向:src 像素映射到 dst)} } usrc=udst1/s(正向:src 像素映射到 dst)

反向映射(实现时实际用的)

实现要走"反向映射"(避免空洞),所以反转
usrc=udsts(反向:dst 像素找 src 颜色) \boxed{ u_{\text{src}} = u_{\text{dst}}^{s} \quad \text{(反向:dst 像素找 src 颜色)} } usrc=udsts(反向:dst 像素找 src 颜色)

注意指数从 1/s1/s 1/s 变成 ss s------这是 y=x1/sy = x^{1/s} y=x1/s 的反函数

强度 ss s 的几何意义

ss s udst=0.5 u_{\text{dst}} = 0.5 udst=0.5 时 → usrc u_{\text{src}} usrc 视觉效果
0.3 0.50.3≈0.810.5^{0.3} \approx 0.81 0.50.3≈0.81 强针孔:dst 边缘在 src 80% 处取色
1.0 0.51.0=0.50.5^{1.0} = 0.5 0.51.0=0.5 原图
1.8 0.51.8≈0.290.5^{1.8} \approx 0.29 0.51.8≈0.29 鱼眼:dst 边缘在 src 29% 处取色
2.5 0.52.5≈0.180.5^{2.5} \approx 0.18 0.52.5≈0.18 强鱼眼

直觉

  • s>1s > 1 s>1: usrc<udst u_{\text{src}} < u_{\text{dst}} usrc<udst → dst 边缘点去 src 的"更靠中心"找色 → src 的中心区被"拉宽"到边缘 → 边缘鼓出
  • s<1s < 1 s<1:反过来 → 边缘收缩

为什么是"球面凸起"? 因为 dst 的所有边缘点都"对应到 src 的中心区"------src 中心区的细节被"摊大饼"到整张图的边缘,视觉上就是球面凸起

函数图像直观理解

以s=0.5为例(收缩) 看坐标归一化后的函数的图像对比 usrc和udsts u_{\text{src}} 和 u_{\text{dst}}^s usrc和udsts

usrc=udsts u_{\text{src}} = u_{\text{dst}}^s 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 找色 → 边缘向外鼓

记忆口诀

曲线在对角线上方 → 收缩;下方 → 鼓出

ss s 越大,曲线越"凹"(下方)→ 越像鱼眼 ss s 越小(< 1),曲线越"凸"(上方)→ 越像针孔

怎么"鼓"出来的?可视化

css 复制代码
strength = 1(不变)           strength = 2(凸起)
─────────────────             ─────────────────
src                              src
  ●                               ●
  │                               │ ↗
  │  ← 这是同一条径向线             │  ↗
  │                               │    ↗
  ●                               ●       ↗
                             
dst 像素按角度对应 src 同角度    dst 边缘像素映射到 src 中心区
的距离上                          → 边缘被"拉远"了

关键 :dst 上每个点都在 src 的"同一条径向线上"取色,只是离中心远近变了


三、反向映射:从 dst 推 src

9 行代码算出一个像素 :算 rr r → 归一化 uu 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;

为什么这样对? 数学证明很简短:
dxrdst =cos⁡θ  ⇒  rsrc⋅cos⁡θ=dx⋅ rsrcrdst =dx⋅ratio \frac{dx}{r_{\text{dst}}} = \cos\theta \;\Rightarrow\; r_{\text{src}} \cdot \cos\theta = dx \cdot \frac{r_{\text{src}}}{r_{\text{dst}}} = dx \cdot \text{ratio} rdstdx=cosθ⇒rsrc⋅cosθ=dx⋅rdstrsrc=dx⋅ratio

同理 dy⋅ratio=rsrc⋅sin⁡θdy \cdot \text{ratio} = r_{\text{src}} \cdot \sin\theta dy⋅ratio=rsrc⋅sinθ。

直觉 :向量 (dx,dy)(dx, dy) (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)r' = f(r) r′=f(r) 是非线性的)。 但渲染套路完全一样 :反向映射 + 插值 + 越界填白边。 唯一差异 只是"求逆"方式:线性变换的逆 = M.try_inverse(),鱼眼的逆 = 手算 usrc=udsts u_{\text{src}} = u_{\text{dst}}^s 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 = 0powf(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):根据非线性公式计算 →填色\text{for each pixel}(x, y): \quad \text{根据非线性公式计算 } \to \text{填色} for each pixel(x,y):根据非线性公式计算 →填色

鱼眼公式 usrc=udsts u_{\text{src}} = u_{\text{dst}}^s usrc=udsts 是"输入坐标 → 输出坐标"分形公式 zn+1 =zn2+c z_{n+1} = z_n^2 + c zn+1=zn2+c 是"输入复数 → 迭代输出"

思路一模一样,只是分形多了迭代逃逸半径两个概念。

掌握了反向映射 + 插值,你就掌握了所有"按坐标生成图像"的算法。


🎁 写在最后

15 个任务做完,你已经:

  • 单像素(part 1):改 RGB 值
  • 邻域卷积(part 2):用 3×3 核算新颜色
  • 几何变换(part 3):移动像素位置

接下来 3 篇(part 4)会进入全新的领域 ------分形计算公式从"空间变换"变成"迭代函数",但渲染套路一模一样。

你已经站在分形大门口了。


📦 项目地址pixel-math-wasm 🦀 Rust + WebAssembly 实战系列


🏷️ 标签#Rust #WebAssembly #图像处理 #几何变换 #鱼眼 #非线性变换 #幂函数 #分形入门

相关推荐
传奇开心果编程2 小时前
【Xilem 0.4 基础语法学与练】第一课:从零到计数器
学习·rust·前端框架
Flynt9 小时前
pnpm 12 换上了 Rust 内核,我拿项目实测了一轮构建速度
rust·vite·前端工程化
传奇开心果编程13 小时前
【Xilem基础语法学与练】第8课:条件渲染(one_of)
学习·rust·前端框架
qwsaedca15 小时前
在Mac上跑 Kokoro TTS经验总结
rust·mac·tts
qwsaedca15 小时前
Kokoro TTS v1.1 voices 文件格式逆向分析
rust
老猿讲编程18 小时前
【Eclipse OpenSOVD学习之五】拓扑引擎(Topology)
学习·rust·eclipse·sovd
object not found18 小时前
Nuxt4去掉body中默认的边距
开发语言·后端·rust
梦醒沉醉1 天前
4、Rust参考手册——Crate和源文件
rust
chainbees1 天前
Windows 系统 Rust 运行环境搭建
rust