🦀 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 步流程(每张图都走一遍):
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 步流程详解
- 关键代码(用库调用)
- 前端效果展示
- k 怎么选?3 个主成分的方差占比
- 踩坑提醒
- 接下来
一、核心思路:把图看成"数据点云"
把图片每个像素看成一个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−λ′)⋅det[1−λ′−1−11−λ′]
2×2 行列式按"主对角相乘 − 副对角相乘":
det[1−λ′−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 点积 = 2Rc−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 | 3100001−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。
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 + 加回均值 = 重建图。
三、关键代码(用库调用)
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);
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;
}
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];
}
}
let cov = (centered.transpose() * ¢ered) / ((n - 1) as f64);
let svd = nalgebra::linalg::SVD::new(cov, true, true);
let u = svd.u.unwrap();
let p = u.columns(0, k).clone();
let projected = ¢ered * &p;
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];
}
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