Rust图像处理第14节-图片缩放与斜切畸变:齐次坐标 + 共享仿射变换

0 阅读2分钟

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

📌 写在前面

上一篇用 Matrix2 做了任意角度旋转。Matrix2 够用,但有个限制:没法表示平移。

这一篇引入 齐次坐标 Matrix3(3×3)——一个能统一表示缩放 / 斜切 / 旋转 / 平移的"超级矩阵"。后面 16 篇(PCA、回归、3D 投影……)都会用到它。

这一篇 = 学一个工具,干两件事:缩放 + 斜切都靠 Matrix3。


🚀 TL;DR

操作矩阵新尺寸
缩放[sx0tx0syty001]\begin{bmatrix} s_x & 0 & t_x \\ 0 & s_y & t_y \\ 0 & 0 & 1 \end{bmatrix}w=wsx, h=hsyw' = w s_x,\ h' = h s_y
斜切[1kxtxky1ty001]\begin{bmatrix} 1 & k_x & t_x \\ k_y & 1 & t_y \\ 0 & 0 & 1 \end{bmatrix}w=w+kxhw' = w + \|k_x\| h

关键认知:两件事用的是同一个 affine_apply() 函数——只是构造的矩阵不同。这就是 Matrix3 的威力


📖 目录

  1. 齐次坐标:为什么需要 Matrix3?
  2. 缩放:最简单的非平凡变换
  3. 斜切:把矩形扭成平行四边形
  4. 关键代码:共享 affine_apply
  5. 前端效果展示
  6. 为什么 "1 个矩阵" 比 "2 个 if" 更强?
  7. 踩坑提醒
  8. 下篇预告

一、齐次坐标:为什么需要 Matrix3?

2D 变换的三种"形状"

变换数学形式需要 2×2 还是 3×3?
旋转x=xcosysinx' = x\cos - y\sin2×2 够(纯线性)
缩放x=sxxx' = s_x \cdot x2×2 够(纯线性)
斜切x=x+kxyx' = x + k_x \cdot y2×2 够(纯线性)
平移x=x+txx' = x + t_x2×2 不够

平移为什么搞不定 2×2?

2×2 矩阵乘法永远是"线性变换"——原点 (0,0)(0, 0) 永远映射到原点。无法表达"加常数"

解决方案:齐次坐标(Homogeneous Coordinates)

把 2D 点升维成 3D:(x,y)(x,y,1)(x, y) \rightarrow (x, y, 1)

然后用 3×3 矩阵:

[abtxcdty001][xy1]=[ax+by+txcx+dy+ty1]=[xy1]\begin{bmatrix} a & b & t_x \\ c & d & t_y \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ 1 \end{bmatrix} = \begin{bmatrix} a x + b y + t_x \\ c x + d y + t_y \\ 1 \end{bmatrix} = \begin{bmatrix} x' \\ y' \\ 1 \end{bmatrix}

第三行的 [0 0 1] 是"占位符",保证 (x,y,1)(x, y, 1) 永远映射回 (x,y,1)(x', y', 1)

一张表背齐所有变换

S=[sx000sy0001]R=[cosθsinθ0sinθcosθ0001]H=[1kx0ky10001]T=[10tx01ty001]\begin{aligned} S &= \begin{bmatrix} s_x & 0 & 0 \\ 0 & s_y & 0 \\ 0 & 0 & 1 \end{bmatrix} & R &= \begin{bmatrix} \cos\theta & -\sin\theta & 0 \\ \sin\theta & \cos\theta & 0 \\ 0 & 0 & 1 \end{bmatrix} \\ H &= \begin{bmatrix} 1 & k_x & 0 \\ k_y & 1 & 0 \\ 0 & 0 & 1 \end{bmatrix} & T &= \begin{bmatrix} 1 & 0 & t_x \\ 0 & 1 & t_y \\ 0 & 0 & 1 \end{bmatrix} \end{aligned}

任何"线性 + 平移"的组合都可以写成一个 Matrix3


二、缩放:最简单的非平凡变换

缩放矩阵

围绕图片中心缩放,结果画布大小 = sxw×syhs_x w \times s_y h

Mscale=[sx0wsxw20syhsyh2001]M_{\text{scale}} = \begin{bmatrix} s_x & 0 & \frac{w' - s_x w}{2} \\ 0 & s_y & \frac{h' - s_y h}{2} \\ 0 & 0 & 1 \end{bmatrix}

平移项 wsxw2\frac{w' - s_x w}{2} 是为了让"中心"映射到"中心"。

💡 如果不写平移项(设为 0),缩放会围绕左上角进行——也是合法的,但视觉上"图像会跑到画布外"。

反向映射

对每个 dst 像素 (x,y)(x', y'),用 M1M^{-1} 求出 src:

M1=[1/sx0wsxw2sx01/syhsyh2sy001]M^{-1} = \begin{bmatrix} 1/s_x & 0 & -\frac{w' - s_x w}{2 s_x} \\ 0 & 1/s_y & -\frac{h' - s_y h}{2 s_y} \\ 0 & 0 & 1 \end{bmatrix}

代码

#[wasm_bindgen]
pub fn scale(pixels: &[u8], width: u32, height: u32, sx: f64, sy: f64) -> Vec<u8> {
    let w = width as usize;
    let h = height as usize;
    let new_w = ((w as f64) * sx.abs()).round() as usize;
    let new_h = ((h as f64) * sy.abs()).round() as usize;

    let tx = ((new_w as f64) - sx * (w as f64)) / 2.0;
    let ty = ((new_h as f64) - sy * (h as f64)) / 2.0;
    let m = Matrix3::new(
        sx,  0.0, tx,
        0.0, sy,  ty,
        0.0, 0.0, 1.0,
    );

    affine_apply(pixels, w, h, new_w, new_h, m)
}

affine_apply()scale 和 shear 共用的实现——下面 §四会讲。

几个常见的缩放场景

scaleXscaleY效果
2.02.0整体放大 2 倍
0.50.5整体缩小一半
2.01.0横向拉长(变成宽屏)
1.00.5纵向压扁
0.52.0横向压缩 + 纵向拉长(类似哈哈镜)

三、斜切:把矩形扭成平行四边形

斜切的直觉

原图(矩形):        斜切 kx=0.5 后(平行四边形):
┌──┬──┬──┐           ┌──┬──┬──┐
│  │  │  │           │ ╲│ ╲│ ╲│
├──┼──┼──┤           ├──┼──┼──┤
│  │  │  │     →     │  ╲│  ╲│  ╲│
├──┼──┼──┤           ├──┼──┼──┤
│  │  │  │           │  ╲│   ╲│   ╲│
└──┴──┴──┘           └──┴──┴──┴──┘
                     ↑
                  每向下 1 像素,x 往右偏 0.5

斜切矩阵

Mshear=[1kxtxky1ty001]M_{\text{shear}} = \begin{bmatrix} 1 & k_x & t_x \\ k_y & 1 & t_y \\ 0 & 0 & 1 \end{bmatrix}

其中:

  • kxk_x:水平斜切(每向下 1 像素,x 偏移 kxk_x
  • kyk_y:垂直斜切(每向右 1 像素,y 偏移 kyk_y
  • tx,tyt_x, t_y当斜切系数为负时,需要平移回画布

为什么需要平移?

kx = 0.3 时:                  kx = -0.3 时:
src(0, 0) → dst(0, 0)        src(0, 0) → dst(0, 0)
src(0, h-1) → dst(3*(h-1), h-1)  src(0, h-1) → dst(-3*(h-1), h-1)  ← 出画布!
                                 需要 tx = 0.3 * (h-1) 平移回来

新画布尺寸

w=w+kxhh=h+kyw\begin{aligned} w' &= w + |k_x| \cdot h \\ h' &= h + |k_y| \cdot w \end{aligned}

直觉:水平斜切 kxk_x 会让图像"向右下伸长 kxh|k_x| \cdot h 像素",所以宽度变 w+kxhw + |k_x| \cdot h

代码

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

    let new_w = ((w as f64) + kx.abs() * ((h as f64) - 1.0)).round() as usize;
    let new_h = ((h as f64) + ky.abs() * ((w as f64) - 1.0)).round() as usize;

    // 负斜切时,平移回画布
    let offset_x = if kx < 0.0 { -kx * ((h as f64) - 1.0) } else { 0.0 };
    let offset_y = if ky < 0.0 { -ky * ((w as f64) - 1.0) } else { 0.0 };

    let m = Matrix3::new(
        1.0, kx,  offset_x,
        ky,  1.0, offset_y,
        0.0, 0.0, 1.0,
    );

    affine_apply(pixels, w, h, new_w, new_h, m)
}

四、关键代码:共享 affine_apply

最大亮点:scale 和 shear 都调用同一个函数 affine_apply()Matrix3 让"代码复用"达到了极致

/// 通用 3×3 仿射变换:反向映射 + 双线性插值 + 自动补白边
pub(super) fn affine_apply(
    pixels: &[u8],
    w: usize,
    h: usize,
    new_w: usize,
    new_h: usize,
    m: Matrix3<f64>,
) -> Vec<u8> {
    let m_inv = m.try_inverse()
        .expect("affine matrix must be invertible");
    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 {
            // 反向映射:dst → src
            let v = m_inv * Vector3::new(x as f64, y as f64, 1.0);
            let src_x = v.x;
            let src_y = v.y;

            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;
            }

            sample_bilinear(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
}

scale 和 shear 都只是"喂矩阵"

// scale 喂一个缩放矩阵
scale(pixels, w, h, 2.0, 2.0)
  → 构造 M = [2 0 ?; 0 2 ?; 0 0 1]
  → affine_apply(pixels, w, h, 2w, 2h, M)

// shear 喂一个斜切矩阵
shear(pixels, w, h, 0.5, 0.0)
  → 构造 M = [1 0.5 ?; 0 1 ?; 0 0 1]
  → affine_apply(pixels, w, h, w+0.5h, h, M)

// 如果以后想加"先缩放再斜切"?
// → 喂 M = S × H(矩阵乘法),scale 函数都不用动

这就是"组件化"的胜利:变换是数据(矩阵),不是控制流(if/else)。要加新变换,只需要构造新矩阵,核心循环一行都不用改


五、前端效果展示

7a766e04-bf7e-4e12-b122-d1f75bcc6c1c.png


六、为什么"齐次坐标"

来对比一下从第12节到14的写法:

第十二节:每个变换单独写(90° 旋转就是这样)

// 翻
if direction == "horizontal" { src_x = w - 1 - x }
// 转
let (src_x, src_y) = if direction == "ccw" { (y, w - 1 - x) } else { (new_h - 1 - y, x) }

第十三节:用 Matrix2 但要单独写平移(任意角度旋转)

let r = Matrix2::new(c, -s, s, c);
// 中心化偏移...
let src_x = r_inv[(0, 0)] * (x - nw2) + r_inv[(0, 1)] * (y - nh2) + w2;
let src_y = r_inv[(1, 0)] * (x - nw2) + r_inv[(1, 1)] * (y - nh2) + h2;

问题:Matrix2 没法内置平移,中心化要手算坐标。

本节:Matrix3 一统天下(这一篇)✅

let m = Matrix3::new(sx, 0.0, tx, 0.0, sy, ty, 0.0, 0.0, 1.0);
// 平移已经在矩阵里了!
let src = m.try_inverse().unwrap() * Vector3::new(x as f64, y as f64, 1.0);

优势

  • 平移、缩放、斜切统一用同一个矩阵类型
  • try_inverse() 自动求逆,不用手算
  • 加新变换只需要构造矩阵,核心循环 0 改动

性能对比

维度if/elseMatrix2Matrix3
速度最快快(SIMD 优化)
可扩展❌ 差一般✅ 极好
支持平移手动手动✅ 内置
代码复用❌ 差一般✅ 极好

实际上 nalgebra 的 Matrix 运算会被 LLVM 优化到和手写循环一样的机器码,速度上几乎无差


七、注意问题

1. try_inverse() 可能失败

// 不可逆的矩阵(比如缩放系数 = 0)会让 try_inverse 返回 None
let m_inv = m.try_inverse().expect("matrix not invertible");

何时不可逆?当矩阵的行列式 = 0 时(例如 sx=sy=0s_x = s_y = 0,或所有元素都为 0)。一般正常使用不会触发。

2. 缩放系数为 0 时 new_w = 0

let new_w = ((w as f64) * sx.abs()).round() as usize;

scaleX = 0 会让 new_w = 0,导致后续 vec![0u8; 0 + 8] 出错。前端 UI 限制滑块范围到 [0.1, 3] 就规避了。

3. 边界判断要用 >= w - 1.0 而不是 >= w

双线性插值要取 (x0, x0 + 1),所以 src_x = w - 1 仍合法(x0 = w-1, x1 = w-1 clamp 一下)。和上一篇 rotate 一样的坑

4. 斜切系数很大时图像严重变形

let kx: f64 = 2.0;  // 每向下 1 像素,x 偏 2 像素

kx = 2 + h = 100 → new_w = w + 200,画布宽度翻倍多。斜切系数最好限制在 [-1, 1]


八、下篇预告

任务 15:鱼眼广角畸变——模拟鱼眼镜头的球面扭曲。

旋转、缩放、斜切都是线性变换(Matrix3 表达):

x=ax+by+tx,y=cx+dy+tyx' = ax + by + t_x, \quad y' = cx + dy + t_y

鱼眼是非线性变换

r=x2+y2θ=arctan(r)x=θxr,y=θyr\begin{aligned} r &= \sqrt{x^2 + y^2} \\ \theta &= \arctan(r) \\ x' &= \theta \cdot \frac{x}{r}, \quad y' = \theta \cdot \frac{y}{r} \end{aligned}

关键差异:鱼眼没有矩阵形式——但仍然用反向映射 + 插值这套打法。

这一篇会把前面的"几何变换三件套"扩展到非线性领域,为下一部分的"分形画板"做铺垫。


🎁 写在最后

这一篇最大的收获不是缩放或斜切,而是:

认知
✅ 齐次坐标 Matrix3 = 线性变换 + 平移的"瑞士军刀"
✅ 一个affine_apply 函数 + 不同矩阵 = 不同变换
✅ "变换是数据(矩阵),不是控制流(if/else)"
任务矩阵中心越界处理
12 翻转不用N/A不会发生
13 旋转Matrix2图片中心填白边
14 缩放/斜切Matrix3图片中心填白边
15 鱼眼非线性图片中心填白边

Matrix3 的优势会在下下篇(任务 18:分形缩放 + 拖拽)爆发——那时你做的不是"画一次",而是"反复用同一个 Matrix3 渲染",性能优势会真正显现。


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


🏷️ 标签#Rust #WebAssembly #图像处理 #几何变换 #缩放 #斜切 #齐次坐标 #Matrix3 #nalgebra