Rust图像处理第12节-图片翻转与旋转:坐标重映射几何变换

0 阅读7分钟

图片翻转与旋转:第一组"坐标重映射"几何变换

🦀 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 行。这一篇就是给后面的"任意角度旋转 / 缩放 / 畸变"热身的。


📖 目录

  1. 什么是几何变换?
  2. 翻转:最简单的坐标镜像
  3. 旋转 90°:宽高互换
  4. 前端效果展示
  5. 坐标映射的几种思路
  6. 进阶:180° 旋转的 3 种写法
  7. 踩坑提醒
  8. 下篇预告

一、什么是几何变换?

几何变换 = 改变像素的"位置",不改变像素的"颜色"

之前我们做的:

模糊 / Sobel / 浮雕 / RGB 偏移
└─ 输出 (x, y) 的颜色,可能来自原图多个邻居的混合
   → 颜色变了,但像素总数和位置感不变

现在要做的:

翻转 / 旋转 / 缩放 / 畸变
└─ 输出 (x, y) 的颜色,从原图某个特定坐标取
   → 颜色值不变,但像素的"住址"变了

核心公式(所有几何变换都长这样):

result(x,y)=original(fx(x,y),fy(x,y))\text{result}(x, y) = \text{original}(f_x(x, y), f_y(x, y))

其中 fx,fyf_x, f_y 就是变换函数。


二、翻转:最简单的坐标镜像

左右翻转(horizontal flip)

沿垂直中轴镜像:

原图:         左右翻转后:
┌──┬──┬──┬──┐  ┌──┬──┬──┬──┐
│ AB│ C│ D│  │ D│ C│ BA│
├──┼──┼──┼──┤  ├──┼──┼──┼──┤
│ E│ F│ G│ H│  │ H│ G│ F│ E│
└──┴──┴──┴──┘  └──┴──┴──┴──┘

坐标映射

srcx=w1x,srcy=y\text{src}_x = w - 1 - x, \quad \text{src}_y = y

直觉:第 0 列的内容跑到第 w-1 列,第 w-1 列的内容跑到第 0 列。

上下翻转(vertical flip)

沿水平中轴镜像:

原图:         上下翻转后:
┌──┬──┐       ┌──┬──┐
│ AB│       │ E│ F│
├──┼──┤  →    ├──┼──┤
│ E│ F│       │ AB│
└──┴──┘       └──┴──┘

坐标映射

srcx=x,srcy=h1y\text{src}_x = x, \quad \text{src}_y = h - 1 - y

⚠️ 命名约定horizontal flip = 左右翻转(沿水平轴方向翻转的视觉结果),vertical flip = 上下翻转(沿垂直轴方向翻转的视觉结果)。这是图像处理领域的通用约定。

代码(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°

原图 (3×2):       顺时针 90° 后 (2×3):
┌──┬──┬──┐         ┌──┬──┐
│ AB│ C│         │ D│ A│
├──┼──┼──┤   →     ├──┼──┤
│ D│ E│ F│         │ E│ B│
└──┴──┴──┘         ├──┼──┤
                   │ F│ C│
                   └──┴──┘

坐标映射(关键!):

src(x,y)=dst(h1y,x)\text{src}(x, y) = \text{dst}(h - 1 - y, x)

翻译:原图第 (x, y) 个像素,旋转后出现在新图的第 (h-1-y, x) 个位置。

逆时针 90°

坐标映射

src(x,y)=dst(y,w1x)\text{src}(x, y) = \text{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)

#[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 字节新尺寸给前端解码。


四、前端效果展示

08b5b40e-af85-4227-a647-0ea07a4907d4.png

8aa489a1-c0ca-4689-a6a0-06057c722490.png

五、坐标映射的几种思路

几何变换有两种思考方向,对应不同的代码结构:

思路 A:前向映射(source-driven)

对每个原图像素,算它"应该去目标图的哪里"。

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)✅ 本篇采用

对每个目标像素,反推"应该从原图哪个位置取"。

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:左右 + 上下翻转组合(最直观)

// 180° = 水平翻转 + 垂直翻转
let temp = flip(pixels, w, h, "horizontal");
let result = flip(temp, w, h, "vertical");

写法 2:单次循环,src_x 和 src_y 都翻转

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° 两次(最浪费)

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 字节)

let idx = (y * w + x) * 4;  // ← *4 千万别忘

每个像素是 4 字节(R/G/B/A),坐标索引要乘以 4 才到字节位置。前面 11 篇都讲过这一点,但实际写代码时仍会忘。

3. Alpha 通道也要拷贝

result[dst + 3] = pixels[src + 3];  // 别漏了 alpha

漏了 alpha 通道,半透明 PNG 会突然变成不透明。

4. Rust 中 usizeu32 的转换

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° 旋转只需要整点映射,任意角度就要面对"小数坐标"和"插值"问题了:

旋转 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 #图像处理 #几何变换 #坐标映射 #翻转 #旋转 #算法