🦀 Rust + WASM 实战系列 第 13 篇 阅读时间:约 10 分钟 | 实战可运行
📌 写在前面
上一篇做的 90° 旋转只动"整数坐标",是特例。
这一篇进入任意角度(30°、45°、任意小数)。三个新问题一下子全冒出来:
- 小数坐标:旋转 30° 后,原图 (0, 0) 落在 (0.866, 0.5),怎么取色?
- 越界 :旋转后的图比原图大,空白处填什么颜色?
- 反向映射:原图旋转后可能落在画布外,要从画布反推原图坐标。
这一篇把这三个问题一次解决,旋转矩阵正式登场。
🚀 TL;DR
| 关键点 | 一句话 |
|---|---|
| 旋转矩阵 | R=cosθsinθ−sinθcosθ |
| 反向映射 | 反向时用 RT(正交矩阵的逆 = 转置) |
| 画布尺寸 | $w' = |
| 小数坐标 | 双线性插值 = 4 像素加权平均 |
| 越界处理 | 填白色 (255, 255, 255) |
核心理念:和上一篇一样的"反向映射",只是这次矩阵登场 + 加了插值。
📖 目录
- [为什么 90° 是特例?](#为什么 90° 是特例? "#%E4%B8%80%E4%B8%BA%E4%BB%80%E4%B9%88-90-%E6%98%AF%E7%89%B9%E4%BE%8B")
- 旋转矩阵:从公式到代码
- [新画布尺寸: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")
- 反向映射:以中心为锚点
- [插值:最近邻 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")
- 关键代码
- 前端效果展示
- 性能对比
- 踩坑提醒
- 下篇预告
一、为什么 90° 是特例?
| 维度 | 90° 旋转 | 任意角度旋转 |
|---|---|---|
| 坐标 | 整数 | 小数(含三角函数值) |
| 采样 | 直接拷贝 | 需要插值 |
| 越界 | 不存在(4 顶点仍在边界) | 必然越界 |
| 画布尺寸 | 互换(h × w) | 需要重新计算 |
| 工具 | 一个if + 减法 |
旋转矩阵 + 插值 |
90° 是特例的关键:三角函数值都是 0 / ±1 ,所以坐标永远是整数;而任意角度的 sinθ / cosθ 是小数。
二、旋转矩阵:从公式到代码
2D 旋转矩阵
R(θ)=cosθsinθ−sinθcosθ
把点 (x,y) 绕原点旋转 θ(逆时针为正):
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′)
h′=max(yA′,yB′,yC′,yD′)−min(yA′,yB′,yC′,yD′)
先看 x 坐标(假设 θ 在 0°~90° 之间,cos θ > 0,sin θ > 0):
| 顶点 | 旋转后的 x' |
|---|---|
| A (-w/2, -h/2) | −2wcosθ+2hsinθ |
| B ( w/2, -h/2) | +2wcosθ+2hsinθ ← 最大 |
| C ( w/2, h/2) | +2wcosθ−2hsinθ |
| D (-w/2, h/2) | −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′=(2wcosθ+2hsinθ)−(−2wcosθ−2hsinθ)=wcosθ+hsinθ
y 坐标同理(假设 0°~90°):
| 顶点 | 旋转后的 y' |
|---|---|
| A | −2wsinθ−2hcosθ ← 最小 |
| B | +2wsinθ−2hcosθ |
| C | +2wsinθ+2hcosθ ← 最大 |
| D | −2wsinθ+2hcosθ |
h′=wsinθ+hcosθ
处理所有角度(加绝对值)
上面的推导假设 cos θ > 0 且 sin θ > 0(即 θ 在 0°~90°)。
θ 在其他区间时,最大最小会换位置,但差值仍然是同样的表达式 。统一用绝对值:
关键公式(取最大绝对值):
w′h′=∣wcosθ∣+∣hsinθ∣=∣wsinθ∣+∣hcosθ∣
3 个特殊角度验证
| θ | w' | h' | 物理意义 |
|---|---|---|---|
| 0° | w | h | 原图不动 ✓ |
| 90° | h | w | 宽高互换 ✓ |
| 45°(正方形) | w2 | w2 | 对角线长度(包围盒 = 正方形的对角线)✓ |
直觉:旋转 45° 时画布变 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−1:
| 方向 | 公式 | 用什么 |
|---|---|---|
| 正向:src → dst | dst=M⋅src | M |
| 反向:dst → src | src=M−1⋅dst | M−1 ← 这里求逆 |
旋转为什么能用 RT 代替 R−1?
旋转矩阵是正交矩阵,所以:
R−1=RT(正交矩阵的特殊性质)
两者完全等价 ------ RT 就是 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)):
x′y′=Rx−w/2y−h/2+w/2h/2
反向映射(已知 dst,求 src):
x−w/2y−h/2=RTx′−w′/2y′−h′/2
srcx=R00T(x′−w′/2)+R01T(y′−h′/2)+w/2
💡 为什么是 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
代码:
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π 和 360° 差了 57 倍)。
4. y 轴方向
- 数学:y 向上
- 图像:y 向下
- 结果:数学上的"逆时针正方向"在图像里变成"顺时针"
这就是为什么很多图像库的 rotate(positive_angle) 是顺时针的。本篇用 JS 习惯:顺时针为正。
5. 旋转中心不一定是图片中心
本篇默认是图片中心。如果想围绕其他点旋转(如 (0, 0) 左上角),反向映射公式会不同:
src=RT(dst−c)+c
其中 c 是旋转中心。
十、下篇预告
任务 14:缩放 + 斜切。
旋转搞定了,"线性几何变换三件套"还差两件:
- 缩放 (放大 / 缩小): S=sx00sy
- 斜切 (shear): H=10k1
这两件比旋转简单(不用三角函数),但有个新概念------缩放比例不是 1 时也要插值(和旋转一模一样的问题)。
下篇还有个杀手锏:把旋转 + 缩放 + 平移组合成一个矩阵:
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 | 填白边 | 可配置 |
关键认知:这一篇把"反向映射 + 插值"这套打法建立起来了。后面所有几何变换(缩放 / 斜切 / 鱼眼 / 透视)都长一个样:
- 算出反向映射函数(矩阵 / 非线性公式)
- 对每个 dst 像素,反推 src 坐标
- 越界 → 填背景色;边界内 → 插值取色
线代工具箱正式登场。
📦 项目地址 :pixel-math-wasm 🦀 Rust + WebAssembly 实战系列
🏷️ 标签 :#Rust #WebAssembly #图像处理 #几何变换 #旋转矩阵 #双线性插值 #nalgebra #算法