图片翻转与旋转:第一组"坐标重映射"几何变换
🦀 Rust + WASM 实战系列 第 12 篇 阅读时间:约 7 分钟 | 实战可运行
📌 写在前面
前两部分做了 11 个图像处理算法,但都不改变像素的位置------只是改颜色。
从这一篇开始,进入"几何变换" ------像素要搬家。
翻转和旋转是最简单的几何变换:只改坐标,不改颜色值。原理一句话就能讲完:
找到"目标像素"应该从"原图哪个坐标"取颜色。
没有卷积核,没有梯度,纯坐标重映射 ------从这节之后,就要使用线代做变换了。
🚀 TL;DR
| 操作 | 坐标映射 | 尺寸变化 |
|---|---|---|
| 左右翻转 | src_x = w - 1 - x |
不变 |
| 上下翻转 | src_y = h - 1 - y |
不变 |
| 顺时针 90° | dst(x,y) ← src(h-1-y, x) |
宽高互换 |
| 逆时针 90° | dst(x,y) ← src(y, w-1-x) |
宽高互换 |
全部代码加起来 < 60 行。这一篇就是给后面的"任意角度旋转 / 缩放 / 畸变"热身的。
📖 目录
- 什么是几何变换?
- 翻转:最简单的坐标镜像
- [旋转 90°:宽高互换](#旋转 90°:宽高互换 "#%E4%B8%89%E6%97%8B%E8%BD%AC-90%E5%AE%BD%E9%AB%98%E4%BA%92%E6%8D%A2")
- 前端效果展示
- 坐标映射的几种思路
- [进阶:180° 旋转的 3 种写法](#进阶:180° 旋转的 3 种写法 "#%E5%85%AD%E8%BF%9B%E9%98%B6180-%E6%97%8B%E8%BD%AC%E7%9A%84-3-%E7%A7%8D%E5%86%99%E6%B3%95")
- 踩坑提醒
- 下篇预告
一、什么是几何变换?
几何变换 = 改变像素的"位置",不改变像素的"颜色"。
之前我们做的:
scss
模糊 / Sobel / 浮雕 / RGB 偏移
└─ 输出 (x, y) 的颜色,可能来自原图多个邻居的混合
→ 颜色变了,但像素总数和位置感不变
现在要做的:
arduino
翻转 / 旋转 / 缩放 / 畸变
└─ 输出 (x, y) 的颜色,从原图某个特定坐标取
→ 颜色值不变,但像素的"住址"变了
核心公式(所有几何变换都长这样):
result(x,y)=original(fx(x,y),fy(x,y))
其中 fx,fy 就是变换函数。
二、翻转:最简单的坐标镜像
左右翻转(horizontal flip)
沿垂直中轴镜像:
css
原图: 左右翻转后:
┌──┬──┬──┬──┐ ┌──┬──┬──┬──┐
│ A│ B│ C│ D│ │ D│ C│ B│ A│
├──┼──┼──┼──┤ ├──┼──┼──┼──┤
│ E│ F│ G│ H│ │ H│ G│ F│ E│
└──┴──┴──┴──┘ └──┴──┴──┴──┘
坐标映射:
srcx=w−1−x,srcy=y
直觉:第 0 列的内容跑到第 w-1 列,第 w-1 列的内容跑到第 0 列。
上下翻转(vertical flip)
沿水平中轴镜像:
css
原图: 上下翻转后:
┌──┬──┐ ┌──┬──┐
│ A│ B│ │ E│ F│
├──┼──┤ → ├──┼──┤
│ E│ F│ │ A│ B│
└──┴──┘ └──┴──┘
坐标映射:
srcx=x,srcy=h−1−y
⚠️ 命名约定 :horizontal flip = 左右翻转(沿水平轴方向翻转的视觉结果),vertical flip = 上下翻转(沿垂直轴方向翻转的视觉结果)。这是图像处理领域的通用约定。
代码(Rust)
rust
let flip_x = direction == "horizontal";
let flip_y = direction == "vertical";
for y in 0..h {
for x in 0..w {
let src_x = if flip_x { w - 1 - x } else { x };
let src_y = if flip_y { h - 1 - y } else { y };
let src = (src_y * w + src_x) * 4;
let dst = (y * w + x) * 4;
// 拷贝 RGBA 4 字节
result[dst] = pixels[src];
result[dst + 1] = pixels[src + 1];
result[dst + 2] = pixels[src + 2];
result[dst + 3] = pixels[src + 3];
}
}
总共不到 20 行。
三、旋转 90°:宽高互换
旋转 90° 比翻转复杂一点,因为输出尺寸变了 ------w × h 变成 h × w。
顺时针 90°
css
原图 (3×2): 顺时针 90° 后 (2×3):
┌──┬──┬──┐ ┌──┬──┐
│ A│ B│ C│ │ D│ A│
├──┼──┼──┤ → ├──┼──┤
│ D│ E│ F│ │ E│ B│
└──┴──┴──┘ ├──┼──┤
│ F│ C│
└──┴──┘
坐标映射(关键!):
src(x,y)=dst(h−1−y,x)
翻译:原图第 (x, y) 个像素,旋转后出现在新图的第 (h-1-y, x) 个位置。
逆时针 90°
坐标映射:
src(x,y)=dst(y,w−1−x)
怎么记?画一张图
把原图左上角 A 标出来,跟着它走:
| 操作 | A 原坐标 | A 旋转后坐标 |
|---|---|---|
| 顺时针 90° | (0, 0) | (0,w-1) → (0, h-1) 在新图 |
| 逆时针 90° | (0, 0) | (h-1, 0) |
小技巧:A 一定跑到新图的对角位置(顺时针到左上角 / 逆时针到左下角,对应不同的对角线)。
代码(Rust)
rust
#[wasm_bindgen]
pub fn rotate90(
pixels: &[u8],
width: u32,
height: u32,
direction: &str,
) -> Vec<u8> {
let w = width as usize;
let h = height as usize;
let new_w = h; // 旋转后:宽 = 原来的高
let new_h = w; // 旋转后:高 = 原来的宽
let pixel_len = new_w * new_h * 4;
let mut result = vec![0u8; pixel_len + 8]; // 8 字节留给新宽高
for y in 0..new_h {
for x in 0..new_w {
let (src_x, src_y) = match direction {
"ccw" => (y, w - 1 - x), // 逆时针
_ => (new_h - 1 - y, x), // 顺时针(默认)
};
let src = (src_y * w + src_x) * 4;
let dst = (y * new_w + x) * 4;
result[dst] = pixels[src];
result[dst + 1] = pixels[src + 1];
result[dst + 2] = pixels[src + 2];
result[dst + 3] = pixels[src + 3];
}
}
// 把新宽高打包到末尾 8 字节(小端 u32)
let nw = new_w as u32;
let nh = new_h as u32;
result[pixel_len..pixel_len + 4].copy_from_slice(&nw.to_le_bytes());
result[pixel_len + 4..pixel_len + 8].copy_from_slice(&nh.to_le_bytes());
result
}
关键点 :旋转后宽高互换,所以末尾要打包 8 字节新尺寸给前端解码。
四、前端效果展示

五、坐标映射的几种思路
几何变换有两种思考方向,对应不同的代码结构:
思路 A:前向映射(source-driven)
对每个原图像素,算它"应该去目标图的哪里"。
rust
for src_y in 0..h {
for src_x in 0..w {
let dst_x = ...; // 算出 src 映射到哪个 dst
let dst_y = ...;
result[dst] = pixels[src];
}
}
优点 :每个原像素只处理一次,无重复。 缺点 :目标图可能有空洞 (多个原像素映射到同一个 dst)或漏点(某些 dst 没有原像素映射过来)。
思路 B:反向映射(destination-driven)✅ 本篇采用
对每个目标像素,反推"应该从原图哪个位置取"。
rust
for dst_y in 0..new_h {
for dst_x in 0..new_w {
let src_x = ...; // 算出 dst 应该从哪个 src 取
let src_y = ...;
result[dst] = pixels[src];
}
}
优点 :每个目标像素保证有值 ,无空洞。 缺点:原图的某些像素可能被多次采样(但 90° 旋转这种整点变换不会重复采样)。
💡 几乎所有实际图像变换都用反向映射------简单、不会空洞。后面学任意角度旋转、双线性插值时你会反复看到它。
六、进阶:180° 旋转的 3 种写法
虽然这一篇没实现 180° 旋转,但作为思考题,给出 3 种等价写法:
写法 1:左右 + 上下翻转组合(最直观)
rust
// 180° = 水平翻转 + 垂直翻转
let temp = flip(pixels, w, h, "horizontal");
let result = flip(temp, w, h, "vertical");
写法 2:单次循环,src_x 和 src_y 都翻转
rust
for y in 0..h {
for x in 0..w {
let src = ((h - 1 - y) * w + (w - 1 - x)) * 4;
let dst = (y * w + x) * 4;
// copy RGBA
}
}
写法 3:旋转 90° 两次(最浪费)
rust
let once = rotate90(pixels, w, h, "cw"); // w×h → h×w
let twice = rotate90(once, h, w, "cw"); // h×w → w×h
三种结果完全一样。写法 1 最直观(两次函数调用),写法 2 最高效(一次循环),写法 3 是绕远路(但能加深对旋转的理解)。
七、踩坑提醒
1. 旋转 90° 后宽高会互换
90° 旋转后 w × h 变成 h × w,很多人会忘记这点导致 buffer 算错。我们的解决方案是把新宽高编码进返回数组末尾。
2. 别忘了乘以 4(RGBA 4 字节)
rust
let idx = (y * w + x) * 4; // ← *4 千万别忘
每个像素是 4 字节(R/G/B/A),坐标索引要乘以 4 才到字节位置。前面 11 篇都讲过这一点,但实际写代码时仍会忘。
3. Alpha 通道也要拷贝
rust
result[dst + 3] = pixels[src + 3]; // 别漏了 alpha
漏了 alpha 通道,半透明 PNG 会突然变成不透明。
4. Rust 中 usize 与 u32 的转换
rust
let w = width as usize; // u32 → usize:要做减法 / 索引必须 usize
let new_w = w as u32; // usize → u32:写回 WASM 时要转回来
WASM 边界统一用 u32(wasm-bindgen 不支持 usize 的跨边界传递),Rust 内部循环和索引用 usize。转换别忘了。
5. 连续操作的"当前图"概念
前端实现里我们维护了一个 currentPixels 状态------每次操作基于"当前图",所以可以连续点击 4 次顺时针回到原图。这是 UX 设计的关键。
八、下篇预告
任务 13:任意角度旋转(比如 30°、45°)。
90° 旋转只需要整点映射,任意角度就要面对"小数坐标"和"插值"问题了:
scss
旋转 30° 时,原图 (0, 0) 旋转后落在 (0.866, 0.5)
反向映射时,dst (0, 0) 要从 src (-0.866, -0.5) 取颜色
→ 坐标是负数、是小数
→ 越界怎么办?
→ 小数坐标的颜色怎么算?(最近邻插值 / 双线性插值)
这些就是下篇要解决的问题 。你会发现:90° 旋转只是个特例,通用变换需要"矩阵 × 插值"两件武器。
🎁 写在最后
第三部分开篇 。从这篇开始,你做的每个算法都会和坐标 / 矩阵 / 插值打交道。
| 任务 | 难度 | 核心概念 |
|---|---|---|
| 12 翻转 / 旋转 90° | ⭐ | 坐标重映射 |
| 13 任意角度旋转 | ⭐⭐ | 矩阵 + 插值 |
| 14 缩放 / 斜切 | ⭐⭐ | 矩阵缩放 |
| 15 鱼眼畸变 | ⭐⭐⭐ | 非线性坐标变换 |
坐标系转换的直觉 从这里开始建立。掌握了"原图 → 目标图"的反向映射思路,后面所有几何变换都长得差不多。
📦 项目地址 :pixel-math-wasm 🦀 Rust + WebAssembly 实战系列
🏷️ 标签 :#Rust #WebAssembly #图像处理 #几何变换 #坐标映射 #翻转 #旋转 #算法
