在图像处理和机器学习领域,奇异值分解(Singular Value Decomposition, SVD) 是一种非常强大的矩阵分解算法。它不仅可以用于降维(如 PCA),还可以用于图像压缩。
本文将通过一段 Python 代码,解析如何利用 SVD 对单通道图像(如黑白图)进行特征提取与重构,并深入讲解其背后的数学原理。
1. SVD 原理图解
1.1 什么是 SVD?
从数学角度看,任何一个 m×nm \times nm×n 的矩阵 AAA(对应我们的图片像素矩阵),都可以分解为三个矩阵的乘积:
A=UΣVT A = U \Sigma V^T A=UΣVT
其中:
- UUU :m×mm \times mm×m 的正交矩阵(左奇异向量)。
- Σ\SigmaΣ (Sigma):m×nm \times nm×n 的对角矩阵,对角线上的元素 σi\sigma_iσi 称为奇异值 ,且按从大到小排列。
- VTV^TVT :n×nn \times nn×n 的正交矩阵(右奇异向量)。
1.2 图像压缩原理
奇异值 Σ\SigmaΣ 代表了能量或信息量的分布。
- 大的奇异值:代表图像的主要特征(轮廓、大片色块)。
- 小的奇异值:代表图像的细节特征(噪点、纹理)。
由于奇异值是按降序排列的,我们可以只取前 kkk 个最大的奇异值,忽略后面较小的数值,用这部分数据来近似还原图像。这就是 SVD 图像压缩的核心逻辑(低秩近似)。
公式演变为:
A≈Um×k⋅Σk×k⋅Vk×nT A \approx U_{m \times k} \cdot \Sigma_{k \times k} \cdot V^T_{k \times n} A≈Um×k⋅Σk×k⋅Vk×nT
1.3 原理示意图 (Mermaid)

2. 代码详细注释与解析
下面是基于 scipy 和 numpy 实现 SVD 图像重构的代码。代码中使用的图片 peppa_and_daddy_on_mountain.bmp 是一张 256x256 的单通道灰度图。
python
import numpy as np
from scipy.linalg import svd
from PIL import Image
import matplotlib.pyplot as plt
# ==========================================
# 函数:根据保留的奇异值数量 k 重构图像
# ==========================================
def get_image_feature(s, k):
"""
参数:
s: 奇异值向量 (由 svd 函数返回的一维数组)
k: 保留的前 k 个特征值数量
"""
# 1. 创建一个全零向量,长度与原奇异值向量 s 相同
# 目的是为了屏蔽掉索引 k 之后的奇异值
s_temp = np.zeros(s.shape[0])
# 2. 只取前 k 个奇异值
# python 切片特性:如果 k 超过 s 的长度,会自动取到末尾,不会报错
s_temp[0:k] = s[0:k]
# 3. 将一维的奇异值向量转换为对角矩阵
# 原理:SVD 还原公式中 Sigma 必须是矩阵形式
# 这里 s_temp * np.identity(...) 利用了广播机制或乘法生成对角阵
# 更标准的写法通常是 np.diag(s_temp)
s_matrix = s_temp * np.identity(s.shape[0])
# 4. 重构矩阵 A' = U * Sigma * V^T
# 这里的 p 对应公式中的 U, q 对应 V^T
# np.dot 是矩阵乘法
temp = np.dot(p, s_matrix)
temp = np.dot(temp, q)
# 5. 显示重构后的图像
plt.figure(figsize=(6, 6))
plt.title(f"Reconstructed with top {k} singular values")
plt.imshow(temp, cmap=plt.cm.gray, interpolation='nearest')
plt.show()
# 6. (可选) 打印原图与重构图的差异矩阵
# 随着 k 增大,差异矩阵的元素值会趋近于 0
# print(A - temp)
# ==========================================
# 主程序
# ==========================================
# 1. 加载图片
# 确保路径下有对应的 bmp 文件
image = Image.open('./peppa_and_daddy_on_mountain.bmp')
# 2. 将图片转换为 Numpy 数组 (矩阵 A)
A = np.array(image)
# 显示原始图像
plt.figure(figsize=(6, 6))
plt.title("Original Image")
plt.imshow(A, cmap=plt.cm.gray, interpolation='nearest')
plt.show()
# 3. 对图像矩阵 A 进行奇异值分解
# full_matrices=False 表示进行"紧凑型 SVD" (Reduced SVD)
# 如果 A 是 (M, N),则 p 是 (M, K), s 是 (K,), q 是 (K, N),其中 K=min(M,N)
# 这里图片是 256x256,所以 p(256,256), s(256,), q(256,256)
p, s, q = svd(A, full_matrices=False)
# 打印奇异值观察
print("奇异值总数量:", len(s))
print("前10个奇异值:", s[:10]) # 通常前几个值非常大
# 4. 不同程度的压缩还原演示
# 情况 A: k=5
# 仅使用前 5 个特征。
# 预期结果:图像非常模糊,只能看到大致的光影轮廓(主要能量)。
get_image_feature(s, 5)
# 情况 B: k=50
# 使用前 50 个特征 (约 20% 的数据量)。
# 预期结果:图像已经非常清晰,肉眼几乎看不出与原图的区别。
get_image_feature(s, 50)
# 情况 C: k=500
# 尝试使用前 500 个特征。
# 注意:原图只有 256x256,秩最大为 256。
# 代码中的切片 s[0:500] 会自动处理为 s[0:256],实际上就是无损还原。
get_image_feature(s, 500)
k=5:

k=50:

k=500:

3. 结果分析
运行上述代码,你会观察到以下现象:
-
原始奇异值 (s):
- 你会发现
s数组中的数值下降得非常快。第一个值可能高达几万,而到了第 100 个值可能就降到了几十。这意味着图像的大部分信息(能量)都集中在前几十个数值中。
- 你会发现
-
k=5 (极度压缩):
- 效果:图片极其模糊,只能依稀分辨出"天空上面亮下面暗"、"中间有个山峰的黑影"。
- 原理:只保留了图像的低频分量(整体结构),丢失了高频分量(边缘、线条、噪点)。小猪佩奇的轮廓此时根本看不见。
-
k=50 (中度压缩):
- 效果:小猪佩奇、猪爸爸以及山的轮廓都已经非常清晰。虽然相比原图丢失了约 80% 的奇异值数量,但视觉效果差异极小。
- 原理:前 50 个奇异值包含了图像 90% 以上的信息量。这就是 SVD 用于图像压缩的威力所在。
-
k=500 (无损/全秩):
- 效果:与原图完全一致。
- 原理:由于图像尺寸限制(256x256),奇异值总数只有 256 个。请求 500 个特征实际上等同于使用了所有 256 个特征,因此实现了无损还原。
4. 总结
SVD 是一种将矩阵"拆解"为"主要成分"和"次要成分"的数学工具。在图像处理中:
- p (U矩阵) 记录了垂直方向的特征变化。
- q (V矩阵) 记录了水平方向的特征变化。
- s (奇异值) 记录了这些特征的重要程度(权重)。
通过保留权重大的特征,去除权重小的特征,我们可以在大幅减少数据存储量的同时,尽可能保留图像的视觉质量。
5.测试图片
测试图片生成代码:
python
import numpy as np
from PIL import Image, ImageDraw
# 1. --- 背景生成 (复用之前的山峰逻辑) ---
width, height = 256, 256
# 天空渐变
gradient = np.linspace(180, 255, height) # 让天空稍微亮一点,方便看清黑色线条
image_data = np.tile(gradient[:, np.newaxis], (1, width))
# 山峰生成
x_coords = np.arange(width)
# 让山稍微平缓一点,方便站人
peak_shape = 230 - 100 * np.exp(-0.5 * ((x_coords - 128) / 60) ** 2)
np.random.seed(42)
noise = np.random.normal(0, 1.5, width)
mountain_line = peak_shape + noise
# 填充山体
for x in range(width):
y_start = int(mountain_line[x])
y_start = max(0, min(y_start, height - 1))
image_data[y_start:, x] = 50 # 山体颜色深灰
# 转换为 PIL 图像以便绘图
img = Image.fromarray(image_data.astype(np.uint8), mode='L')
draw = ImageDraw.Draw(img)
# 2. --- 绘制角色的辅助函数 ---
def draw_peppa_style_pig(draw_obj, x, y, scale, is_daddy=False):
"""
x, y: 角色脚底的位置
scale: 缩放比例
is_daddy: 是否是猪爸爸 (如果是,身体更圆,有眼镜)
"""
color_skin = 230 # 浅灰皮肤
color_outline = 0 # 黑色轮廓
color_dress = 100 # 深灰衣服
# 尺寸参数
head_w = 30 * scale
head_h = 25 * scale
body_h = 35 * scale
# --- 1. 身体 ---
body_top = y - body_h - (10 * scale) # 腿的高度
if is_daddy:
# 猪爸爸是圆圆的身体
draw_obj.ellipse([x - 20 * scale, body_top - 10 * scale,
x + 20 * scale, body_top + 30 * scale],
fill=color_dress, outline=color_outline)
else:
# 佩奇是梯形/连衣裙
draw_obj.polygon([
(x - 12 * scale, body_top + 30 * scale), # 左下
(x + 12 * scale, body_top + 30 * scale), # 右下
(x + 8 * scale, body_top), # 右上
(x - 8 * scale, body_top) # 左上
], fill=color_dress, outline=color_outline)
# --- 2. 腿和脚 ---
leg_len = 12 * scale
# 左腿
draw_obj.line([x - 5 * scale, body_top + 28 * scale, x - 5 * scale, y], fill=color_outline, width=1)
draw_obj.rectangle([x - 8 * scale, y, x - 2 * scale, y + 2 * scale], fill=0) # 黑鞋
# 右腿
draw_obj.line([x + 5 * scale, body_top + 28 * scale, x + 5 * scale, y], fill=color_outline, width=1)
draw_obj.rectangle([x + 2 * scale, y, x + 8 * scale, y + 2 * scale], fill=0) # 黑鞋
# --- 3. 头部 (经典的吹风机形状) ---
head_x = x
head_y = body_top - (5 * scale)
# 猪鼻子 (长椭圆)
snout_len = 20 * scale
draw_obj.ellipse([head_x - head_w / 2, head_y - head_h / 2,
head_x + head_w / 2 + snout_len, head_y + head_h / 2],
fill=color_skin, outline=color_outline)
# 遮盖掉鼻子和后脑勺连接处的线条(用皮肤色画个圆)
draw_obj.ellipse([head_x - head_w / 2 + 2, head_y - head_h / 2 + 2,
head_x + head_w / 2 - 2, head_y + head_h / 2 - 2],
fill=color_skin, outline=None)
# 鼻孔
nostril_x = head_x + head_w / 2 + snout_len - 5 * scale
draw_obj.point([nostril_x, head_y - 3 * scale], fill=color_outline)
draw_obj.point([nostril_x + 2 * scale, head_y - 3 * scale], fill=color_outline)
# 耳朵
ear_size = 6 * scale
draw_obj.ellipse([head_x - 5 * scale, head_y - head_h / 2 - ear_size,
head_x - 5 * scale + ear_size / 1.5, head_y - head_h / 2 + 2],
fill=color_skin, outline=color_outline)
draw_obj.ellipse([head_x + 2 * scale, head_y - head_h / 2 - ear_size,
head_x + 2 * scale + ear_size / 1.5, head_y - head_h / 2 + 2],
fill=color_skin, outline=color_outline)
# 眼睛
eye_x_base = head_x + 5 * scale
draw_obj.ellipse([eye_x_base, head_y - 8 * scale, eye_x_base + 4 * scale, head_y - 4 * scale],
outline=color_outline, fill=255)
draw_obj.point([eye_x_base + 2 * scale, head_y - 6 * scale], fill=0)
draw_obj.ellipse([eye_x_base + 6 * scale, head_y - 8 * scale, eye_x_base + 10 * scale, head_y - 4 * scale],
outline=color_outline, fill=255)
draw_obj.point([eye_x_base + 8 * scale, head_y - 6 * scale], fill=0)
# 嘴巴
draw_obj.arc([head_x + 5 * scale, head_y + 2 * scale, head_x + 15 * scale, head_y + 8 * scale], start=0, end=180,
fill=color_outline)
# 猪爸爸的胡茬和眼镜
if is_daddy:
# 眼镜 (两个圆圈 + 连接线)
g_r = 3 * scale
draw_obj.ellipse([eye_x_base - 1, head_y - 8 * scale - 1, eye_x_base + 4 * scale + 1, head_y - 4 * scale + 1],
outline=color_outline)
draw_obj.ellipse(
[eye_x_base + 6 * scale - 1, head_y - 8 * scale - 1, eye_x_base + 10 * scale + 1, head_y - 4 * scale + 1],
outline=color_outline)
draw_obj.line([eye_x_base + 4 * scale, head_y - 6 * scale, eye_x_base + 6 * scale, head_y - 6 * scale],
fill=color_outline)
# 胡茬 (简单的弧线)
draw_obj.arc([head_x + 10 * scale, head_y + 5 * scale, head_x + 20 * scale, head_y + 15 * scale], start=0,
end=180, fill=color_outline)
# --- 4. 手臂 ---
# 简单的棍状手臂
arm_y = body_top + 10 * scale
draw_obj.line([x - 10 * scale, arm_y, x - 20 * scale, arm_y - 5 * scale], fill=color_outline, width=1) # 左手
draw_obj.line([x - 20 * scale, arm_y - 5 * scale, x - 22 * scale, arm_y - 8 * scale], fill=color_outline,
width=1) # 手指
draw_obj.line([x + 10 * scale, arm_y, x + 20 * scale, arm_y - 5 * scale], fill=color_outline, width=1) # 右手
draw_obj.line([x + 20 * scale, arm_y - 5 * scale, x + 22 * scale, arm_y - 8 * scale], fill=color_outline,
width=1) # 手指
# 3. --- 放置角色 ---
# 获取山坡高度作为 y 坐标
peak_idx_daddy = 90
y_daddy = int(mountain_line[peak_idx_daddy])
draw_peppa_style_pig(draw, x=peak_idx_daddy, y=y_daddy, scale=1.2, is_daddy=True)
peak_idx_peppa = 160
y_peppa = int(mountain_line[peak_idx_peppa])
draw_peppa_style_pig(draw, x=peak_idx_peppa, y=y_peppa, scale=0.8, is_daddy=False)
# 保存
img.save("peppa_and_daddy_on_mountain.bmp")