Rust图像处理第13节-任意角度旋转:旋转矩阵 + 双线性插值 + 自动补白边

0 阅读2分钟

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

📌 写在前面

上一篇做的 90° 旋转只动"整数坐标",是特例

这一篇进入任意角度(30°、45°、任意小数)。三个新问题一下子全冒出来:

  1. 小数坐标:旋转 30° 后,原图 (0, 0) 落在 (0.866, 0.5),怎么取色?
  2. 越界:旋转后的图比原图大,空白处填什么颜色?
  3. 反向映射:原图旋转后可能落在画布外,要从画布反推原图坐标。

这一篇把这三个问题一次解决,旋转矩阵正式登场


🚀 TL;DR

关键点一句话
旋转矩阵R=[cosθsinθsinθcosθ]R = \begin{bmatrix}\cos\theta & -\sin\theta \\ \sin\theta & \cos\theta\end{bmatrix}
反向映射反向时用RTR^T(正交矩阵的逆 = 转置)
画布尺寸$w' =
小数坐标双线性插值 = 4 像素加权平均
越界处理填白色 (255, 255, 255)

核心理念:和上一篇一样的"反向映射",只是这次矩阵登场 + 加了插值。


📖 目录

  1. 为什么 90° 是特例?
  2. 旋转矩阵:从公式到代码
  3. 新画布尺寸:4 顶点包围盒
  4. 反向映射:以中心为锚点
  5. 插值:最近邻 vs 双线性
  6. 关键代码
  7. 前端效果展示
  8. 性能对比
  9. 踩坑提醒
  10. 下篇预告

一、为什么 90° 是特例?

维度90° 旋转任意角度旋转
坐标整数小数(含三角函数值)
采样直接拷贝需要插值
越界不存在(4 顶点仍在边界)必然越界
画布尺寸互换(h × w)需要重新计算
工具一个if + 减法旋转矩阵 + 插值

90° 是特例的关键:三角函数值都是 0 / ±1,所以坐标永远是整数;而任意角度的 sinθ\sin\theta / cosθ\cos\theta 是小数。


二、旋转矩阵:从公式到代码

2D 旋转矩阵

R(θ)=[cosθsinθsinθcosθ]R(\theta) = \begin{bmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{bmatrix}

把点 (x,y)(x, y) 绕原点旋转 θ\theta逆时针为正):

[xy]=R(θ)[xy]=[xcosθysinθxsinθ+ycosθ]\begin{bmatrix} x' \\ y' \end{bmatrix} = R(\theta) \cdot \begin{bmatrix} x \\ y \end{bmatrix} = \begin{bmatrix} x\cos\theta - y\sin\theta \\ x\sin\theta + y\cos\theta \end{bmatrix}

⚠️ 数学上逆时针为正,但图像坐标 y 轴向下,所以很多库(如 CSS、PIL)定义为顺时针为正。本篇用 JS 习惯:顺时针为正

在 nalgebra 里

use nalgebra::{Matrix2, Vector2};

let theta = angle_deg.to_radians();   // 度数 → 弧度
let c = theta.cos();
let s = theta.sin();
let r = Matrix2::new(c, -s, s, c);    // [[cos, -sin], [sin, cos]]

// 应用:把向量 (dx, dy) 旋转
let v = Vector2::new(dx, dy);
let rotated = r * v;                  // Matrix2 × Vector2 = Vector2

nalgebra 的 Matrix2 * Vector2 自动用 SIMD 优化,速度和手写循环一样。


三、新画布尺寸:4 顶点包围盒

旋转后图片会"撑大"——必须用 4 个顶点旋转后的最大包围盒

原图 4 个顶点(相对中心):
A = (-w/2, -h/2)    B = ( w/2, -h/2)
D = (-w/2,  h/2)    C = ( w/2,  h/2)

旋转 θ 后(举例 θ=30°):
        A' = (-w/2 cos + h/2 sin, -w/2 sin - h/2 cos)
            B' = ( w/2 cos + h/2 sin,  w/2 sin - h/2 cos)
            ...

公式怎么来的?(max − min)

关键认知w' 不是神秘公式,而是"4 个旋转后顶点的 x 坐标最大跨度":

w=max(xA,xB,xC,xD)min(xA,xB,xC,xD)w' = \max(x'_A, x'_B, x'_C, x'_D) - \min(x'_A, x'_B, x'_C, x'_D)
h=max(yA,yB,yC,yD)min(yA,yB,yC,yD)h' = \max(y'_A, y'_B, y'_C, y'_D) - \min(y'_A, y'_B, y'_C, y'_D)

先看 x 坐标(假设 θ 在 0°~90° 之间,cos θ > 0,sin θ > 0):

顶点旋转后的 x'
A (-w/2, -h/2)w2cosθ+h2sinθ-\frac{w}{2}\cos\theta + \frac{h}{2}\sin\theta
B ( w/2, -h/2)+w2cosθ+h2sinθ+\frac{w}{2}\cos\theta + \frac{h}{2}\sin\theta最大
C ( w/2, h/2)+w2cosθh2sinθ+\frac{w}{2}\cos\theta - \frac{h}{2}\sin\theta
D (-w/2, h/2)w2cosθh2sinθ-\frac{w}{2}\cos\theta - \frac{h}{2}\sin\theta最小
x 坐标轴上 4 个点的位置:

←D═══════════A═════════C═══════════B→
               ↑ 最左 D                    ↑ 最右 B
       -w/2 cos - h/2 sin          +w/2 cos + h/2 sin

w' = 最右 − 最左

w=(w2cosθ+h2sinθ)(w2cosθh2sinθ)=wcosθ+hsinθ\begin{aligned} w' &= \left(\frac{w}{2}\cos\theta + \frac{h}{2}\sin\theta\right) - \left(-\frac{w}{2}\cos\theta - \frac{h}{2}\sin\theta\right) \\ &= w\cos\theta + h\sin\theta \end{aligned}

y 坐标同理(假设 0°~90°):

顶点旋转后的 y'
Aw2sinθh2cosθ-\frac{w}{2}\sin\theta - \frac{h}{2}\cos\theta最小
B+w2sinθh2cosθ+\frac{w}{2}\sin\theta - \frac{h}{2}\cos\theta
C+w2sinθ+h2cosθ+\frac{w}{2}\sin\theta + \frac{h}{2}\cos\theta最大
Dw2sinθ+h2cosθ-\frac{w}{2}\sin\theta + \frac{h}{2}\cos\theta
h=wsinθ+hcosθh' = w\sin\theta + h\cos\theta

处理所有角度(加绝对值)

上面的推导假设 cos θ > 0 且 sin θ > 0(即 θ 在 0°~90°)。

θ 在其他区间时,最大最小会换位置,但差值仍然是同样的表达式统一用绝对值

关键公式(取最大绝对值):

w=wcosθ+hsinθh=wsinθ+hcosθ\begin{aligned} w' &= |w\cos\theta| + |h\sin\theta| \\ h' &= |w\sin\theta| + |h\cos\theta| \end{aligned}

3 个特殊角度验证

θw'h'物理意义
wh原图不动 ✓
90°hw宽高互换 ✓
45°(正方形)w2w\sqrt{2}w2w\sqrt{2}对角线长度(包围盒 = 正方形的对角线)✓

直觉:旋转 45° 时画布变 2\sqrt{2} 倍;旋转 90° 时画布变 h × w(退化为上一篇的结论)。

let new_w = ((w as f64) * c.abs() + (h as f64) * s.abs()).round() as usize;
let new_h = ((w as f64) * s.abs() + (h as f64) * c.abs()).round() as usize;

四、反向映射:以中心为锚点

为什么要求"逆"?(搞清 M / M⁻¹ 的方向)

我们做图像变换的流程:

正向(概念上):src(原图) ──── M ────→ dst(变换后)
                原图像素        变换       目标像素

实现时不能正向遍历——之前 §六"坐标映射的几种思路"讲过:

  • 反向遍历:对每个 dst 像素求"应该来自 src 哪里"——无空洞
  • ❌ 正向遍历:可能有空洞 / 漏点

所以需要 M1M^{-1}

方向公式用什么
正向:src → dstdst=Msrc\text{dst} = M \cdot \text{src}MM
反向:dst → srcsrc=M1dst\text{src} = M^{-1} \cdot \text{dst}M1M^{-1} ← 这里求逆

旋转为什么能用 RTR^T 代替 R1R^{-1}

旋转矩阵是正交矩阵,所以:

R1=RT(正交矩阵的特殊性质)R^{-1} = R^T \quad \text{(正交矩阵的特殊性质)}

两者完全等价——RTR^T 就是 R1R^{-1},只是计算更简单:

操作复杂度
r.transpose()O(1)(2×2 矩阵直接交换对角元素)
r.try_inverse()O(n³)(高斯消元 / 伴随矩阵)

一张图把因果链串起来

我们想做的事:反向映射(对每个 dst 找 src)
        ↓
需要 M⁻¹
        ↓
旋转矩阵是正交矩阵
        ↓
M⁻¹ = M^T(转置就行,不用真求逆)
        ↓
代码:let r_inv = r.transpose();

几何直觉验证

把点正向转 30°,再反向转 30° = 回到原位:

let v = Vector2::new(1.0, 0.0);   // (1, 0)
let v_rotated = r * v;             // 旋转 30° → (0.866, 0.5)
let v_back = r_inv * v_rotated;    // 反向 30° → (1, 0) ✓

数学推导

旋转围绕图片中心,所以映射要分三步:

① dst 相对新画布中心 → ② 应用 R^T → ③ 相对原图中心 → 加上 w/2, h/2

数学推导

正向旋转(围绕图片中心 (w/2,h/2)(w/2, h/2)):

[xy]=R[xw/2yh/2]+[w/2h/2]\begin{bmatrix} x' \\ y' \end{bmatrix} = R \begin{bmatrix} x - w/2 \\ y - h/2 \end{bmatrix} + \begin{bmatrix} w/2 \\ h/2 \end{bmatrix}

反向映射(已知 dst,求 src):

[xw/2yh/2]=RT[xw/2yh/2]\begin{bmatrix} x - w/2 \\ y - h/2 \end{bmatrix} = R^T \begin{bmatrix} x' - w'/2 \\ y' - h'/2 \end{bmatrix}
srcx=R00T(xw/2)+R01T(yh/2)+w/2\text{src}_x = R^T_{00}(x'-w'/2) + R^T_{01}(y'-h'/2) + w/2

💡 为什么是 RTR^T 旋转矩阵是正交矩阵(行向量两两正交 + 单位长度),所以它的逆 = 自己的转置。算转置比算逆简单一万倍——这是正交矩阵的核心优势。

代码

let r_inv = r.transpose();   // [[cos, sin], [-sin, cos]]

for y in 0..new_h {
    for x in 0..new_w {
        // ① dst 相对新画布中心
        let dx = x as f64 - nw2;
        let dy = y as f64 - nh2;

        // ② 应用 R^T
        let src_delta = r_inv * Vector2::new(dx, dy);

        // ③ 加上原图中心,得到 src 坐标
        let src_x = src_delta.x + w2;
        let src_y = src_delta.y + h2;
    
        // src_x, src_y 现在是小数(甚至可能是负数)
        // 接下来:越界?插值?
    }
}

五、插值:最近邻 vs 双线性

问题:小数坐标怎么取色?

假设 src_x = 3.7, src_y = 5.2第 (3.7, 5.2) 个像素的颜色是什么?

方法 1:最近邻(nearest)

直接四舍五入

let sx = src_x.round() as usize;   // 3.7 → 4
let sy = src_y.round() as usize;   // 5.2 → 5

结果:从 P(4, 5) 取色。

  • ✅ 1 行代码,极快
  • ❌ 30° 以上明显锯齿("马赛克感")

方法 2:双线性(bilinear)

周围 4 像素按距离加权平均

P(x0, y0) ── fx ── P(x1, y0)
   │          ·          │
   fy        ·dst        │
   │          ·          │
P(x0, y1) ── fx ── P(x1, y1)

x0 = floor(src_x) = 3,   fx = 0.7
y0 = floor(src_y) = 5,   fy = 0.2
x1 = 4
y1 = 6

公式

color=(1fx)(1fy)P00+fx(1fy)P10+(1fx)fyP01+fxfyP11\text{color} = (1-f_x)(1-f_y)P_{00} + f_x(1-f_y)P_{10} + (1-f_x)f_y P_{01} + f_x f_y P_{11}

代码

let x0 = src_x.floor() as usize;
let y0 = src_y.floor() as usize;
let x1 = (x0 + 1).min(w - 1);     // 边界 clamp
let y1 = (y0 + 1).min(h - 1);
let fx = src_x - x0 as f64;
let let fy = src_y - y0 as f64;

let w00 = (1.0 - fx) * (1.0 - fy);  // P00 的权重
let w01 = fx * (1.0 - fy);          // P10 的权重
let w10 = (1.0 - fx) * fy;          // P01 的权重
let w11 = fx * fy;                  // P11 的权重

for c in 0..4 {  // RGBA 4 通道独立计算
    let v = pixels[i00 + c] as f64 * w00
          + pixels[i01 + c] as f64 * w01
          + pixels[i10 + c] as f64 * w10
          + pixels[i11 + c] as f64 * w11;
    result[dst_idx + c] = v.round().clamp(0.0, 255.0) as u8;
}
  • ✅ 平滑无锯齿,工业标准
  • ❌ 4 倍计算量(4 个像素 × 4 通道)

直观对比

原图一角(4 个像素):
  P00 = 白  P10 = 黑
  P01 = 黑  P11 = 白

src 落在 (0.5, 0.5) 正中间:

  nearest → 任取一个(白或黑,锯齿)
  bilinear → 4 像素平均 = 灰(平滑)

六、关键代码

完整 rotate 函数

#[wasm_bindgen]
pub fn rotate(
    pixels: &[u8],
    width: u32,
    height: u32,
    angle_deg: f64,
    method: &str,
) -> Vec<u8> {
    let w = width as usize;
    let h = height as usize;

    // 1. 度数 → 弧度 + 三角函数
    let theta = angle_deg.to_radians();
    let c = theta.cos();
    let s = theta.sin();

    // 2. 旋转矩阵 R
    let r = Matrix2::new(c, -s, s, c);

    // 3. 新画布尺寸
    let new_w = ((w as f64) * c.abs() + (h as f64) * s.abs()).round() as usize;
    let new_h = ((w as f64) * s.abs() + (h as f64) * c.abs()).round() as usize;

    // 4. 中心点
    let w2 = w as f64 / 2.0;
    let h2 = h as f64 / 2.0;
    let nw2 = new_w as f64 / 2.0;
    let nh2 = new_h as f64 / 2.0;

    // 5. R^T
    let r_inv = r.transpose();

    let pixel_len = new_w * new_h * 4;
    let mut result = vec![0u8; pixel_len + 8];

    for y in 0..new_h {
        for x in 0..new_w {
            // 反向映射
            let dx = x as f64 - nw2;
            let dy = y as f64 - nh2;
            let src_delta = r_inv * Vector2::new(dx, dy);
            let src_x = src_delta.x + w2;
            let src_y = src_delta.y + h2;

            let dst_idx = (y * new_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;
            }

            // 在边界内 → 插值
            if method == "bilinear" {
                sample_bilinear(pixels, w, h, src_x, src_y, &mut result, dst_idx);
            } else {
                sample_nearest(pixels, w, h, src_x, src_y, &mut result, dst_idx);
            }
        }
    }

    // 末尾 8 字节写新尺寸
    result[pixel_len..pixel_len + 4].copy_from_slice(&(new_w as u32).to_le_bytes());
    result[pixel_len + 4..pixel_len + 8].copy_from_slice(&(new_h as u32).to_le_bytes());

    result
}

七、前端效果展示

8eef7b0d-340e-49bb-8841-c21d2e6c2fd1.png


八、性能对比

测试条件:1024×768 原图,旋转 30°

插值方式单次耗时视觉效果
nearest~15 ms30°+ 锯齿明显
bilinear~45 ms平滑无锯齿

3 倍速度差,几乎所有"现代图像处理库"默认都用双线性——因为人眼对锯齿极度敏感。

如果性能敏感(如实时视频),可以考虑:

  • 三次样条 / 双三次插值(更平滑但更慢,~5x)
  • SIMD 优化:手写 AVX2 / NEON,但代码复杂度爆炸
  • 分块 + 多线程:把图像分成 4 块并行处理(WASM threads)

九、踩坑提醒

1. 边界判断 >= w - 1.0 而非 >= w

// 错
if src_x >= w as f64 { ... }  // src_x = w - 0.5 时仍合法(要插值)

// 对
if src_x >= w as f64 - 1.0 { ... }  // 因为双线性要取 x0 + 1

原因:双线性插值要取 (x0, x0 + 1) 两个像素,所以 src_x = w - 1 是合法的(x0 = w-1, x1 = w-1,clamp 一下),但 src_x = w - 0.5 也是合法的。

2. 双线性 x1 = (x0 + 1).min(w - 1) 必加

let x1 = (x0 + 1).min(w - 1);  // 防止 x0 = w - 1 时 x1 = w 越界

3. 角度单位:度 vs 弧度

  • 人类:用度(30°、90°)
  • math.cos / math.sin:用弧度(π/6、π/2)
let theta = angle_deg.to_radians();   // 度 → 弧度

忘了一步,旋转结果完全错乱(角度变成"几十度"或"几度",因为 2π2\pi 和 360° 差了 57 倍)。

4. y 轴方向

  • 数学:y 向上
  • 图像:y 向下
  • 结果:数学上的"逆时针正方向"在图像里变成"顺时针"

这就是为什么很多图像库的 rotate(positive_angle) 是顺时针的。本篇用 JS 习惯:顺时针为正

5. 旋转中心不一定是图片中心

本篇默认是图片中心。如果想围绕其他点旋转(如 (0, 0) 左上角),反向映射公式会不同:

src=RT(dstc)+c\text{src} = R^T (\text{dst} - c) + c

其中 cc 是旋转中心。


十、下篇预告

任务 14:缩放 + 斜切

旋转搞定了,"线性几何变换三件套"还差两件

  • 缩放(放大 / 缩小):S=[sx00sy]S = \begin{bmatrix} s_x & 0 \\ 0 & s_y \end{bmatrix}
  • 斜切(shear):H=[1k01]H = \begin{bmatrix} 1 & k \\ 0 & 1 \end{bmatrix}

这两件比旋转简单(不用三角函数),但有个新概念——缩放比例不是 1 时也要插值(和旋转一模一样的问题)。

下篇还有个杀手锏:把旋转 + 缩放 + 平移组合成一个矩阵:

TRS=[cosθsxsinθsytxsinθsxcosθsyty001]T \cdot R \cdot S = \begin{bmatrix} \cos\theta \cdot s_x & -\sin\theta \cdot s_y & t_x \\ \sin\theta \cdot s_x & \cos\theta \cdot s_y & t_y \\ 0 & 0 & 1 \end{bmatrix}

这就是 3×3 齐次坐标矩阵——后面 16 篇(到第 5 部分的 PCA、回归)都会用到它。


🎁 写在最后

任务 12 是"特例",任务 13 才是"通用"。

任务矩阵插值越界处理中心
12不用不用不会发生N/A
13Matrix2nearest / bilinear填白边图片中心
14Matrix3 (齐次)bilinear填白边可配置

关键认知:这一篇把"反向映射 + 插值"这套打法建立起来了。后面所有几何变换(缩放 / 斜切 / 鱼眼 / 透视)都长一个样:

  1. 算出反向映射函数(矩阵 / 非线性公式)
  2. 对每个 dst 像素,反推 src 坐标
  3. 越界 → 填背景色;边界内 → 插值取色

线代工具箱正式登场


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


🏷️ 标签#Rust #WebAssembly #图像处理 #几何变换 #旋转矩阵 #双线性插值 #nalgebra #算法