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 #图像处理 #几何变换 #鱼眼 #非线性变换 #幂函数 #分形入门

相关推荐
doiito15 小时前
Gliding Horse 时间感知系统:让 AI Agent 真正“感知“时间流逝
ai·rust·架构设计·系统设计·ai agent
SmalBox16 小时前
【节点】[ScleraIrisBlend节点]原理解析与实际应用
unity3d·游戏开发·图形学
Rockbean16 小时前
10分钟Solana-性能web3-4.3 跨合约调用
rust·web3·智能合约
程序员爱钓鱼17 小时前
第一个 Rust 程序 Hello World
后端·rust
花褪残红青杏小1 天前
Rust图像处理第14节-图片缩放与斜切畸变:齐次坐标 + 共享仿射变换
rust·webassembly·图形学
梦帮科技1 天前
GRAVIS v4.0:基于Web的极速套利架构设计与实时数据流实现
前端·人工智能·rust·自动化·区块链·智能合约·数字货币
花褪残红青杏小1 天前
Rust图像处理第13节-任意角度旋转:旋转矩阵 + 双线性插值 + 自动补白边
rust·webassembly·图形学
SmalBox2 天前
【节点】[IrisUVLocation节点]原理解析与实际应用
unity3d·游戏开发·图形学
小杍随笔2 天前
【Rust + Argon2id 生产级密码管理模块设计与实现】
网络·后端·算法·rust