【图像处理】Python 实现 SVD 奇异值分解对图片进行压缩与还原

在图像处理和机器学习领域,奇异值分解(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. 代码详细注释与解析

下面是基于 scipynumpy 实现 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. 结果分析

运行上述代码,你会观察到以下现象:

  1. 原始奇异值 (s)

    • 你会发现 s 数组中的数值下降得非常快。第一个值可能高达几万,而到了第 100 个值可能就降到了几十。这意味着图像的大部分信息(能量)都集中在前几十个数值中。
  2. k=5 (极度压缩)

    • 效果:图片极其模糊,只能依稀分辨出"天空上面亮下面暗"、"中间有个山峰的黑影"。
    • 原理:只保留了图像的低频分量(整体结构),丢失了高频分量(边缘、线条、噪点)。小猪佩奇的轮廓此时根本看不见。
  3. k=50 (中度压缩)

    • 效果:小猪佩奇、猪爸爸以及山的轮廓都已经非常清晰。虽然相比原图丢失了约 80% 的奇异值数量,但视觉效果差异极小。
    • 原理:前 50 个奇异值包含了图像 90% 以上的信息量。这就是 SVD 用于图像压缩的威力所在。
  4. 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")
相关推荐
菜鸟‍2 小时前
【论文学习】通过编辑习得分数函数实现扩散模型中的图像隐藏
人工智能·学习·机器学习
_一路向北_2 小时前
爬虫框架:Feapder使用心得
爬虫·python
AKAMAI2 小时前
无服务器计算架构的优势
人工智能·云计算
阿星AI工作室2 小时前
gemini3手势互动圣诞树保姆级教程来了!附提示词
前端·人工智能
皇族崛起2 小时前
【3D标注】- Unreal Engine 5.7 与 Python 交互基础
python·3d·ue5
刘一说2 小时前
时空大数据与AI融合:重塑物理世界的智能中枢
大数据·人工智能·gis
月亮月亮要去太阳2 小时前
基于机器学习的糖尿病预测
人工智能·机器学习
Oflycomm2 小时前
LitePoint 2025:以 Wi-Fi 8 与光通信测试推动下一代无线创新
人工智能·wifi模块·wifi7模块
机器之心3 小时前
「豆包手机」为何能靠超级Agent火遍全网,我们听听AI学者们怎么说
人工智能·openai
你想知道什么?3 小时前
Python基础篇(上) 学习笔记
笔记·python·学习