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

🦀 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} R=cosθsinθ−sinθcosθ
反向映射 反向时用 RTR^T RT(正交矩阵的逆 = 转置)
画布尺寸 $w' =
小数坐标 双线性插值 = 4 像素加权平均
越界处理 填白色 (255, 255, 255)

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


📖 目录

  1. [为什么 90° 是特例?](#为什么 90° 是特例? "#%E4%B8%80%E4%B8%BA%E4%BB%80%E4%B9%88-90-%E6%98%AF%E7%89%B9%E4%BE%8B")
  2. 旋转矩阵:从公式到代码
  3. [新画布尺寸:4 顶点包围盒](#新画布尺寸:4 顶点包围盒 "#%E4%B8%89%E6%96%B0%E7%94%BB%E5%B8%83%E5%B0%BA%E5%AF%B84-%E9%A1%B6%E7%82%B9%E5%8C%85%E5%9B%B4%E7%9B%92")
  4. 反向映射:以中心为锚点
  5. [插值:最近邻 vs 双线性](#插值:最近邻 vs 双线性 "#%E4%BA%94%E6%8F%92%E5%80%BC%E6%9C%80%E8%BF%91%E9%82%BB-vs-%E5%8F%8C%E7%BA%BF%E6%80%A7")
  6. 关键代码
  7. 前端效果展示
  8. 性能对比
  9. 踩坑提醒
  10. 下篇预告

一、为什么 90° 是特例?

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

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


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

2D 旋转矩阵

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

把点 (x,y)(x, y) (x,y) 绕原点旋转 θ\theta θ(逆时针为正):
x′ y′ =R(θ)⋅ x y = 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} x′y′=R(θ)⋅xy=xcosθ−ysinθxsinθ+ycosθ

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

在 nalgebra 里

rust 复制代码
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 个顶点旋转后的最大包围盒

arduino 复制代码
原图 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) w′=max(xA′,xB′,xC′,xD′)−min(xA′,xB′,xC′,xD′)
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) h′=max(yA′,yB′,yC′,yD′)−min(yA′,yB′,yC′,yD′)

先看 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 −2wcosθ+2hsinθ
B ( w/2, -h/2) +w2cos⁡θ+h2sin⁡θ +\frac{w}{2}\cos\theta + \frac{h}{2}\sin\theta +2wcosθ+2hsinθ ← 最大
C ( w/2, h/2) +w2cos⁡θ−h2sin⁡θ +\frac{w}{2}\cos\theta - \frac{h}{2}\sin\theta +2wcosθ−2hsinθ
D (-w/2, h/2) −w2cos⁡θ−h2sin⁡θ -\frac{w}{2}\cos\theta - \frac{h}{2}\sin\theta −2wcosθ−2hsinθ ← 最小
arduino 复制代码
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} w′=(2wcosθ+2hsinθ)−(−2wcosθ−2hsinθ)=wcosθ+hsinθ

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

顶点 旋转后的 y'
A −w2sin⁡θ−h2cos⁡θ -\frac{w}{2}\sin\theta - \frac{h}{2}\cos\theta −2wsinθ−2hcosθ ← 最小
B +w2sin⁡θ−h2cos⁡θ +\frac{w}{2}\sin\theta - \frac{h}{2}\cos\theta +2wsinθ−2hcosθ
C +w2sin⁡θ+h2cos⁡θ +\frac{w}{2}\sin\theta + \frac{h}{2}\cos\theta +2wsinθ+2hcosθ ← 最大
D −w2sin⁡θ+h2cos⁡θ -\frac{w}{2}\sin\theta + \frac{h}{2}\cos\theta −2wsinθ+2hcosθ

h′=wsin⁡θ+hcos⁡θh' = w\sin\theta + h\cos\theta h′=wsinθ+hcosθ

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

上面的推导假设 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} w′h′=∣wcosθ∣+∣hsinθ∣=∣wsinθ∣+∣hcosθ∣

3 个特殊角度验证

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

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

rust 复制代码
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⁻¹ 的方向)

我们做图像变换的流程:

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

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

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

所以需要 M−1M^{-1} M−1

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

旋转为什么能用 RTR^T RT 代替 R−1R^{-1} R−1?

旋转矩阵是正交矩阵,所以:
R−1=RT(正交矩阵的特殊性质) R^{-1} = R^T \quad \text{(正交矩阵的特殊性质)} R−1=RT(正交矩阵的特殊性质)

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

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

一张图把因果链串起来

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

几何直觉验证

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

rust 复制代码
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) ✓

数学推导

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

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

数学推导

正向旋转(围绕图片中心 (w/2,h/2)(w/2, h/2) (w/2,h/2)):
x′ y′ =R x−w/2 y−h/2 + w/2 h/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} x′y′=Rx−w/2y−h/2+w/2h/2

反向映射(已知 dst,求 src):
x−w/2 y−h/2 =RT x′−w′/2 y′−h′/2 \begin{bmatrix} x - w/2 \\ y - h/2 \end{bmatrix} = R^T \begin{bmatrix} x' - w'/2 \\ y' - h'/2 \end{bmatrix} x−w/2y−h/2=RTx′−w′/2y′−h′/2
srcx=R00T(x′−w′/2)+R01T(y′−h′/2)+w/2 \text{src}x = R^T{00}(x'-w'/2) + R^T_{01}(y'-h'/2) + w/2 srcx=R00T(x′−w′/2)+R01T(y′−h′/2)+w/2

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

代码

rust 复制代码
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)

直接四舍五入

rust 复制代码
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 像素按距离加权平均

text 复制代码
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=(1−fx)(1−fy)P00+fx(1−fy)P10+(1−fx)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} color=(1−fx)(1−fy)P00+fx(1−fy)P10+(1−fx)fyP01+fxfyP11

代码

rust 复制代码
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 通道)

直观对比

ini 复制代码
原图一角(4 个像素):
  P00 = 白  P10 = 黑
  P01 = 黑  P11 = 白

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

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

六、关键代码

完整 rotate 函数

rust 复制代码
#[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
}

七、前端效果展示


八、性能对比

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

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

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

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

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

九、踩坑提醒

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

rust 复制代码
// 错
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) 必加

rust 复制代码
let x1 = (x0 + 1).min(w - 1);  // 防止 x0 = w - 1 时 x1 = w 越界

3. 角度单位:度 vs 弧度

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

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

4. y 轴方向

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

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

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

本篇默认是图片中心。如果想围绕其他点旋转(如 (0, 0) 左上角),反向映射公式会不同:
src=RT(dst−c)+c\text{src} = R^T (\text{dst} - c) + c src=RT(dst−c)+c

其中 cc c 是旋转中心。


十、下篇预告

任务 14:缩放 + 斜切

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

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

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

下篇还有个杀手锏:把旋转 + 缩放 + 平移组合成一个矩阵:
T⋅R⋅S= cos⁡θ⋅sx −sin⁡θ⋅sy tx sin⁡θ⋅sx cos⁡θ⋅sy ty 0 0 1 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} T⋅R⋅S= cosθ⋅sxsinθ⋅sx0−sinθ⋅sycosθ⋅sy0txty1

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


🎁 写在最后

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

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

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

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

线代工具箱正式登场


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


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

相关推荐
传奇开心果编程4 小时前
【Xilem基础语法学与练】第8课:条件渲染(one_of)
学习·rust·前端框架
qwsaedca6 小时前
在Mac上跑 Kokoro TTS经验总结
rust·mac·tts
qwsaedca6 小时前
Kokoro TTS v1.1 voices 文件格式逆向分析
rust
老猿讲编程8 小时前
【Eclipse OpenSOVD学习之五】拓扑引擎(Topology)
学习·rust·eclipse·sovd
object not found8 小时前
Nuxt4去掉body中默认的边距
开发语言·后端·rust
梦醒沉醉1 天前
4、Rust参考手册——Crate和源文件
rust
chainbees1 天前
Windows 系统 Rust 运行环境搭建
rust
k4m7v2pz1 天前
从 Python 搬到 Rust:pyglet MIDI DAW 变成 egui 鬼畜采样器的迁移复盘
开发语言·python·rust
小灰灰搞电子1 天前
Rust+Slint 实现ModbusRTU从机调试助手源码分享
开发语言·rust·modbusrtu