🦀 Rust + WASM 实战系列 第 20 篇 阅读时间:约 6 分钟 | 实战可运行
📌 写在前面
RGB 图像每个像素有 3 个数字 (R, G, B)。100×100 的图 = 10000 个像素 = 30000 个数字。
PCA 做的事:找 3 个"最有代表性"的方向 ,把 30000 维数据压成 k 维(k ≤ 3)------信息损失最小。
直觉 :10000 个像素的 RGB 三通道里,大部分变化都沿着 2~3 个方向------抓住这 2~3 个方向,就抓住了图的大部分信息。
🚀 TL;DR
7 步流程(每张图都走一遍):
ini
1. 像素展平为 (n × 3) 矩阵
2. 中心化(每列减均值)
3. 算协方差矩阵 C = X^T × X / (n-1)(3 × 3)
4. SVD 分解:C = U × S × V^T
5. 保留前 k 个主成分(U 的前 k 列)
6. 投影:X_projected = X_centered × P(n × k)
7. 重建:X_reconstructed = X_projected × P^T + mean
代码用 nalgebra :DMatrix.transpose() * &DMatrix 算协方差,SVD::new() 分解,库调用搞定。
📖 目录
- 核心思路:把图看成"数据点云"
- [7 步流程详解](#7 步流程详解 "#%E4%BA%8C7-%E6%AD%A5%E6%B5%81%E7%A8%8B%E8%AF%A6%E8%A7%A3")
- 关键代码(用库调用)
- 前端效果展示
- [k 怎么选?3 个主成分的方差占比](#k 怎么选?3 个主成分的方差占比 "#%E4%BA%94k-%E6%80%8E%E4%B9%88%E9%80%893-%E4%B8%AA%E4%B8%BB%E6%88%90%E5%88%86%E7%9A%84%E6%96%B9%E5%B7%AE%E5%8D%A0%E6%AF%94")
- 踩坑提醒
- 接下来
一、核心思路:把图看成"数据点云"
把图片每个像素看成一个3 维点 (R,G,B):
100 × 100 图 = 10000 个点(每个点是 RGB 三维向量)
这些点聚集在某个区域 (不是随机散布)------比如所有像素可能都集中在 (200,150,100) 附近。
PCA 找的是:这些数据变化最剧烈的几个方向(主成分)。
类比:一个倾斜的椭圆------长轴方向 = 第一主成分,短轴方向 = 第二主成分。
二、7 步流程详解
1、先看一个完整示例(4 像素玩具图)
为了把抽象公式变成可手算的数字 ,下面用一张 2×2 玩具图(4 个像素)走完 7 步。所有中间结果显式列出。
像素数据
4 个像素(RGB,alpha 省略):
| 像素 | 位置 | R | G | B |
|---|---|---|---|---|
| p1 | 左上 | 200 | 100 | 100 |
| p2 | 右上 | 100 | 200 | 100 |
| p3 | 左下 | 200 | 100 | 200 |
| p4 | 右下 | 100 | 200 | 200 |
关键观察 :4 个像素的 R、G、B 互不相同------是个彩色 图。但有一个不变量:每个像素都满足 R+G=300。这意味着 4 个点全部落在" R+G=300"这个 2 维平面上(3 维空间里的平面是 2 维),所以数据本质只有 2 个自由度------k = 2 就能完美重建,k = 1 会丢信息。
步骤 1:展平为 (4×3) 矩阵
X= 200100200100100200100200100100200200
n=4(行数 = 像素数),3 列对应 R/G/B。
步骤 2:中心化
每列均值(本例三列均值恰好都是 150,是数据对称带来的巧合):
Xˉ=(4200+100+200+100, 4100+200+100+200, 4100+100+200+200)=(150, 150, 150)
减均值只做一件事:把坐标原点搬到数据中心。 它是一次整体平移------所有点朝同一方向挪同样的距离,点云的形状、朝向、维数都不变(平移永远不会改变点之间的相对关系)。
为什么要搬原点? PCA 的主方向是从原点 出发去拟合这团点的。如果原点留在黑色 (0,0,0),拟合方向会被"整团点离原点多远"(绝对位置 / 整体亮度)带偏;把原点挪到数据中心后,主方向才只反映点与点之间怎么散开------这才是我们想要的"偏离模式"。
中心化后:
Xc=X−Xˉ= −50−50−50−50−50−50−50−50−50−50−50−50
观察 (验证"R+G=300"那个不变量真的生效):去均值后 R+G 在每行都是 0------即数据全落在 R+G=0 这个过原点的平面里(3 维空间里的平面 = 2 维)。这就是为什么后面 PC3 对应方向 (1,1,0) 的方差是 0------沿着 (1,1,0) 方向没有任何变化。
步骤 3:算 3×3 协方差矩阵
公式: Cij=n−11∑p=1nXp,i⋅Xp,j
对角元素 ( i=j,本质是方差):
CRR=CGG=CBB=31(502+502+502+502)=310000≈3333.33
非对角元素 ( i=j,本质是协方差):
CRG=31(50⋅(−50)+(−50)⋅50+50⋅(−50)+(−50)⋅50)=31(−10000)=−310000≈−3333.33
CRB=CGB=31(50⋅(−50)+(−50)⋅(−50)+50⋅50+(−50)⋅50)=31(0)=0
协方差矩阵:
C=310000 −1−1−0−1−1−0−0−0−1 ≈ −3333.33−3333.33−0−3333.33−3333.33−0−0−0−3333.33
直觉解读:
- CRR=CGG=CBB:三通道各自波动幅度一样大。
- CRG=−CRR:R 和 G 完全反相关 ------R 涨多少 G 就跌多少(因为 R+G=300 是常数)。
- CRB=CGB=0:B 跟 R/G 完全独立------B 想怎么变就怎么变,不受 R+G 约束。
协方差矩阵把" R+G=300、B 自由"这个数据特性完美编码进去了。
步骤 4:SVD 分解
C 是实对称矩阵( C=CT),所以 SVD 等价于特征分解: C=UΣUT(即 V=U), U 的列就是特征向量, Σ 的对角元是特征值(也就是每个主成分方向的方差)。
算 3 个特征值 ------解特征方程 det(C−λI)=0。
注意:这里用手动计算,计算机中可以通过svd直接求解出VUΣ
把 C 写成 310000M,其中
M= 1−10−110001
记 λ′=100003λ,则特征方程化为 det(M−λ′I)=0。
小检查(迹) : M 的迹是 1+1+1=3,所以 λ1+λ2+λ3=310000×3=10000------后面算完可以核一下。
沿第 3 行展开(这一行有 2 个 0,最省事):
det(M−λ′I)=(1−λ′)⋅det1−λ′−1−11−λ′
2×2 行列式按"主对角相乘 − 副对角相乘":
det1−λ′−1−11−λ′=(1−λ′)2−(−1)(−1)=(1−λ′)2−1
所以特征方程是:
(1−λ′)⋅(1−λ′)2−1=0
(1−λ′)⋅(1−λ′−1)⋅(1−λ′+1)=0
(1−λ′)⋅(−λ′)⋅(2−λ′)=0
解得 λ′=0, 1, 2。乘回 310000(并按从大到小排):
λ1=320000,λ2=310000,λ3=0
验证: λ1+λ2+λ3=320000+310000+0=10000 ✓
找 3 个特征向量 ------对每个 λ 解 (M−λ′I)v=0:
- λ1( λ′=2): M−2I= −1−10−1−1000−1 → v3=0, v1=−v2 → u1=(1,−1,0)T/2
- λ2( λ′=1): M−I= 0−10−100000 → v1=v2=0, v3 自由 → u2=(0,0,1)T
- λ3( λ′=0): M= 1−10−110001 → v1=v2, v3=0 → u3=(1,1,0)T/2
把 U 排好(每列一个主成分,从大到小):
U= −0.7071−0.7071−0001−0.7071−0.7071−0
奇异值 σi=λi :
- σ1=20000/3 ≈81.65
- σ2=10000/3 ≈57.74
- σ3=0
Σ= −81.65−0−0057.740000
3 个主成分方向 ( U 的 3 列):
| 主成分 | 方向向量(归一化) | 物理含义 | σ2 = 方差 | 方差占比 |
|---|---|---|---|---|
| PC1 | (1,−1,0)/2 ≈(0.707, −0.707, 0) | "红-绿"差异轴 :R 涨 G 跌(因为 R+G 守恒) | 20000/3≈6666.67 | 2/3 ≈ 66.7% |
| PC2 | (0,0,1) | "蓝"独立轴:B 自由变化 | 10000/3≈3333.33 | 1/3 ≈ 33.3% |
| PC3 | (1,1,0)/2 ≈(0.707, 0.707, 0) | "灰阶"方向------但这里没有变化 | 0 | 0% |
结论 :PC1 + PC2 的方差占比 = 100%,两个方向就够 。PC3 沿 (1,1,0) 方向方差为 0,正好对应" R+G 守恒"那个约束------SVD 把数据里的 2 维结构精确地挖了出来。
步骤 5:取前 k 个主成分
- k=1: P=u1=(1/2 , −1/2 , 0)T(3×1)------只捕捉 R-G 差异,B 通道被丢掉,重建会丢信息
- k=2: P=u1 ∣ u2(3×2)------把 R-G 差异和 B 都捕捉了,完美重建(因为数据天然只有这 2 个自由度)
- k=3: P=U------ u3 对应奇异值为 0,加了也不增加信息(与 k=2 等价)
关键 :本例是 k=2 完美、 k=1 失真、 k=3 浪费。这跟前面 §5 讲的"自然图 k=1 就够"不同------自然图的灰阶轴真的是数据主方向,而这个玩具图里灰阶方向是死的(方差为 0),所以主方向变成 R-G 差异轴。
步骤 6:投影(k = 2)
公式: Xproj=Xc⋅P,其中 P=u1 ∣ u2(3×2 矩阵)。
展开每一行:第 p 行投影 = 中心化像素向量与 u1、 u2 的点积。
- 与 u1=(1,−1,0)/2 点积 = 2 Rc−Gc
- 与 u2=(0,0,1) 点积 = Bc
| 像素 | (Rc−Gc)/2 | Bc | 投影 (x(1),x(2)) |
|---|---|---|---|
| p1 | (50−(−50))/2 =100/2 ≈70.71 | −50 | (70.71, −50) |
| p2 | (−50−50)/2 =−100/2 ≈−70.71 | −50 | (−70.71, −50) |
| p3 | (50−(−50))/2 ≈70.71 | 50 | (70.71, −50) |
| p4 | (−50−50)/2 ≈−70.71 | 50 | (−70.71, −50) |
Xproj= −70.71−70.71−70.71−70.71−50−50−50−50 (4×2)
数据量从 4×3=12 个数压到 4×2=8 个数(节省 33%)。如果想更省、选 k=1,那就只剩 4 个数,但 B 通道就丢了------下一步能看到丢多少。
步骤 7:重建(k = 2)
公式: Xrecon=Xproj⋅PT+Xˉ
每像素的重建 = 投影分量沿 u1 和 u2 还原回去 + 加回均值:
Xrecon,p=xp(1)⋅u1+xp(2)⋅u2+(150,150,150)
| 像素 | x(1)⋅u1 | x(2)⋅u2 | + Xˉ | = 重建 RGB |
|---|---|---|---|---|
| p1 | 70.71⋅(0.7071,−0.7071,0)=(50,−50,0) | −50⋅(0,0,1)=(0,0,−50) | (150,150,150) | (200, 100, 100) ✓ |
| p2 | −70.71⋅(0.7071,−0.7071,0)=(−50,50,0) | −50⋅(0,0,1)=(0,0,−50) | (150,150,150) | (100, 200, 100) ✓ |
| p3 | 70.71⋅(0.7071,−0.7071,0)=(50,−50,0) | 50⋅(0,0,1)=(0,0,50) | (150,150,150) | (200, 100, 200) ✓ |
| p4 | −70.71⋅(0.7071,−0.7071,0)=(−50,50,0) | 50⋅(0,0,1)=(0,0,50) | (150,150,150) | (100, 200, 200) ✓ |
k = 2 重建精确等于原图! 每个像素的 3 个通道都对得上原始值。
对比 k = 1 会怎样? 假如刚才投影时只取 u1(k=1),B 通道被丢,重建时 p1 只会得到 (200,100,150)------B 通道被均值"填"回去了,但跟真实值 100 差 50。这正说明:k 选多大,看数据本身有几个自由度------本例 2 个自由度,k=2 才能完美。
小结:4 像素玩具图告诉我们什么
| 步骤 | 关键数字 | 直觉 |
|---|---|---|
| X (n × 3) | 4×3 = 12 个数 | 每像素 1 行 |
| 中心化 | 每像素减 (150,150,150) | 原点搬到数据中心;R+G=300 → 中心化后 R+G=0 |
| 协方差 C | 310000 1−10−110001 | R 和 G 完全反相关;B 与 R/G 独立 |
| SVD | PC1 = (0.71,-0.71,0), σ12=320000 PC2 = (0,0,1), σ22=310000 PC3 = (0.71,0.71,0), σ32=0 | 2 个非零方向,PC3 沿灰阶方向但没变化 |
| 投影 k=2 | (±70.71, ±50) 四个 | 12 → 8 个数 |
| 重建 | 与原图精确相等 | k = 2 才完美(k=1 丢 B 通道) |
核心结论 :这个玩具图天然是 2 维 的(受 R+G=300 这一个约束限制),所以 k=2 完美重建。这跟"自然图通常 k=1 就够"并不矛盾 ------自然图里大量像素近似灰阶(肤色、天空、阴影),整个数据集的有效维度也远小于 3,但有效维数到底是 1 还是 2 还是 3,取决于数据本身 。本节用具体数字把 PCA 流程走通;真实图片的方差占比、长什么样、k 怎么选见 [§ 5](#§ 5 "#%E4%BA%94k-%E6%80%8E%E4%B9%88%E9%80%893-%E4%B8%AA%E4%B8%BB%E6%88%90%E5%88%86%E7%9A%84%E6%96%B9%E5%B7%AE%E5%8D%A0%E6%AF%94")。
2、PCA实现完整流程
1. 像素展平为 (n×3) 矩阵
X= R1R2⋮RnG1G2⋮GnB1B2⋮Bn
n = 像素数,3 列 = RGB。
2. 中心化(每列减均值)
Xcentered=X−Xˉ
其中 Xˉ 是每列的均值向量。减掉均值后,数据中心在原点------PCA 找的是"偏离方向"。
3. 协方差矩阵 C=XTX/(n−1)
C= Var(R)Cov(G,R)Cov(B,R)Cov(R,G)Var(G)Cov(B,G)Cov(R,B)Cov(G,B)Var(B)
3×3 矩阵 ------描述 R、G、B 三通道两两之间的相关性。
4. SVD 分解
C=UΣVT
- U:3×3 正交矩阵,每列是一个主成分方向
- Σ:3 个奇异值,每个值表示该主成分的"信息量"
5. 保留前 k 个主成分
取 U 的前 k 列 → 组成 P(3 × k 矩阵)。
- k=1:1 个方向 → 数据量从 3 维压到 1 维(严重失真)
- k=2:2 个方向 → 3 维压到 2 维(较好)
- k=3:3 个方向 → 不压缩(理论无损)
6. 投影
Xproj=Xcentered×P(n×k)
把 10000 像素的 3 维 RGB → 10000 像素的 k 维。
7. 重建
Xrecon=Xproj×PT+Xˉ(n×3)
把 k 维数据反投影回 3 维 RGB + 加回均值 = 重建图。
三、关键代码(用库调用)
rust
use nalgebra::{DMatrix, DVector};
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn pca_compress(pixels: &[u8], width: u32, height: u32, k: u32) -> Vec<u8> {
let w = width as usize;
let h = height as usize;
let n = w * h;
let k = (k as usize).min(3).max(1);
// 1. 像素 → 矩阵 (n × 3)
let mut data = DMatrix::<f64>::zeros(n, 3);
for i in 0..n {
let idx = i * 4;
data[(i, 0)] = pixels[idx] as f64;
data[(i, 1)] = pixels[idx + 1] as f64;
data[(i, 2)] = pixels[idx + 2] as f64;
}
// 2. 中心化
let mean = data.row_mean();
let mut centered = data.clone();
for j in 0..3 {
for i in 0..n {
centered[(i, j)] -= mean[j];
}
}
// 3. 协方差矩阵
let cov = (centered.transpose() * ¢ered) / ((n - 1) as f64);
// 4. SVD 分解
let svd = nalgebra::linalg::SVD::new(cov, true, true);
let u = svd.u.unwrap();
// 5. 取前 k 个主成分
let p = u.columns(0, k).clone();
// 6. 投影
let projected = ¢ered * &p;
// 7. 重建 + 加回均值
let mut reconstructed = projected * p.transpose();
for i in 0..n {
reconstructed[(i, 0)] += mean[0];
reconstructed[(i, 1)] += mean[1];
reconstructed[(i, 2)] += mean[2];
}
// 写回 RGBA(alpha 复制)
let mut out = vec![0u8; pixels.len()];
for i in 0..n {
let dst_idx = i * 4;
out[dst_idx] = reconstructed[(i, 0)].clamp(0.0, 255.0) as u8;
out[dst_idx + 1] = reconstructed[(i, 1)].clamp(0.0, 255.0) as u8;
out[dst_idx + 2] = reconstructed[(i, 2)].clamp(0.0, 255.0) as u8;
out[dst_idx + 3] = pixels[dst_idx + 3];
}
out
}
直接调用库,没自己写方法
四、前端效果展示

打开页面后:
- 上传一张图片
- 拖动 k 滑块(1~3),实时看重建效果
- 下方有 3 个主成分的方差占比柱状图------直观看到 PC1 通常占大头
- 对比同一张图在不同 k 下的质量:
- k = 1:严重失真(颜色偏移、细节丢失)
- k = 2:较好(能看出原图,颜色略有偏差)
- k = 3:理论无损
五、k 怎么选?3 个主成分的方差占比
5.1 经验数字
| k | 数据量 | 视觉质量 | 累计方差占比(典型自然图) |
|---|---|---|---|
| 1 | 1/3 | 失真 | 80~95% |
| 2 | 2/3 | 较好 | 95~99% |
| 3 | 100% | 无损 | 100% |
经验法则:
- PC1 通常占 80~95%(一维就够)
- PC1 + PC2 通常占 95~99%(2 维基本无损)
- PC3 只占 1~5%------几乎总是可以扔掉
5.2 为什么 PC1 ≈ (0.577, 0.577, 0.577)?
自然图的 PC1 通常极其接近灰阶轴 ------三个分量都约 0.577(= 1/3 )。
这是物理结果,不是巧合:
- 同一物体的 R/G/B 几乎同步变化 。比如把一张叶子从亮处移到阴影:R、G、B 同时按比例降低(亮度降但色相不变)。这种"整体变亮/变暗"的像素差异,方向就是 (1,1,1)。
- 颜色恒常性:人眼对绝对亮度敏感,但对"相对色相"钝感。所以拍摄同一场景时,三通道几乎总是成比例变化------协方差矩阵的"长轴"自然指向 (1,1,1) 方向。
- 极端的"灰阶图" (R=G=B)会得到 PC1 严格沿 (1,1,1)、占 100%------但这是 R=G=B 这个强约束导致的特例,不是一般情形 。§ 二.0 的玩具图虽然 R≠G≠B,但因为 R+G 守恒,灰阶方向 (1,1,0) 的方差也是 0------这说明"灰阶方向是否占主"完全取决于数据。
5.3 一个具体自然图的方差占比
把 § 二.0 的 4 像素玩具图替换为一张 100×100 自然照片(如风景照、人像),PCA 给出的 3 个奇异值通常呈下面这种形态:
σ1≈8500,σ2≈1200,σ3≈300
| 主成分 | 奇异值² = 方差 | 方差占比 | 直观解释 |
|---|---|---|---|
| PC1 ≈ (0.58, 0.58, 0.58) | 85002=72,250,000 | 96.6% | 灰阶轴(亮度变化) |
| PC2 ≈ (0.71, -0.71, 0) | 12002=1,440,000 | 1.9% | "红-绿"色相偏差 |
| PC3 ≈ (0.41, 0.41, -0.82) | 3002=90,000 | 0.1% | "黄-蓝"色相偏差 |
怎么看这个表 :总和 ≈ 73,780,000。PC1 = 72,250,000 / 73,780,000 ≈ 97.9% (具体数字随图像变化,但量级稳定)。1 个数字就能描述 96~98% 的差异------这就是 PCA 强大的原因。
5.4 为什么 RGB 图像这么容易压缩?
把上面的方差占比代入压缩比:
存储压缩比=3k×累计方差占比1(粗略)
- k = 1:压缩到 1/3 数据量,但保留 97% 信息 → 300% 效率
- k = 2:压缩到 2/3 数据量,但保留 99% 信息 → 150% 效率
而 PNG/JPEG 不做这种"按方差排序"的取舍------它们用所有 3 个通道 存数据,没有利用通道间相关性 。PCA 之所以"信息密度高",正是因为 RGB 三通道高度相关 (协方差矩阵的非对角元素接近对角元素 ),整张图的有效自由度远小于 3。
5.5 何时 PC1 不占大头?
PC1 主导依赖于"R/G/B 同步变化"。下面这些图会破坏这个假设:
| 图像类型 | 典型 PC1 占比 | 为什么 |
|---|---|---|
| 自然风景 / 人像 | 80~95% | 灰阶轴主导(亮度变化) |
| 卡通 / 二次元插画 | 50~70% | 高饱和度色块(红 vs 黄 vs 蓝),通道不同步 |
| 抽象彩色噪点图 | 30~40% | 各通道独立波动,几乎无相关性 |
| 单色 / 调色板图 | 99~100% | 退化情形(方差为 0) |
实操建议 :先看 PC1 占比再选 k。如果 PC1 ≥ 90%,大胆用 k = 1 ;如果 PC1 < 60%,说明图像偏"彩色抽象"风格,k = 2 或 k = 3 都行。
六、踩坑提醒
1. 协方差矩阵是对称的
Cij=Cji
SVD 分解也是对称矩阵,库会自动处理 ------但计算时只算上三角可以省一半时间。
2. 协方差 = 0 时 SVD 可能不稳定
如果图像纯色 (所有像素 RGB 一样),协方差矩阵全 0,SVD 会除以 0。
解决 :库会返回 Option,判 None。
3. 像素数 < 4 时无法算协方差
n - 1 在分母------n = 0 或 n = 1 时除以 0。
解决:前端限制图片最小尺寸(10×10 以上)。
4. 调色板和 PCA 不能混
PCA 是逐像素独立------不像鱼眼那样"看邻居"。压缩后颜色可能略偏(因为协方差矩阵是全局的,不是局部的)。
七、接下来
任务 21:最小二乘回归------用矩阵求逆解"拟合"问题,做图像去噪。
PCA:找数据的主方向(无监督) 回归:找 y = ax + b 的最佳拟合线(监督)
应用:图片去噪、颜色校准------用线性关系"修正"问题像素。
任务 22:协方差矩阵手写------深挖 PCA 内部原理,库 vs 手算对比。
一句话总结
PCA = 找 3 个"最有代表性"的方向,把 RGB 像素从 3 维压到 k 维。
算法核心:协方差矩阵 + SVD 分解------全部 nalgebra 库调用。
拖 k 滑块看效果:k=1 大幅失真,k=2 基本无损,k=3 完全无损。
📦 项目地址 :pixel-math-wasm 🦀 Rust + WebAssembly 实战系列
🏷️ 标签 :#Rust #WebAssembly #图像处理 #PCA #主成分分析 #协方差 #SVD #nalgebra