图像本质上就是一个或多个矩阵,并且通常具有低秩结构,可以利用SVD分解舍弃低奇异值的部分来实现图片的压缩存储。

简单的 Python 示例实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image


def compress_img_first_k(img_array, k):
if img_array.ndim == 2:
img_array = img_array[:, :, np.newaxis]

m, n, c = img_array.shape
compressed_channels = []

for i in range(c):
channel = img_array[:, :, i]
U, S, VT = np.linalg.svd(channel, full_matrices=False)

channel_k = U[:, :k] @ np.diag(S[:k]) @ VT[:k, :]
compressed_channels.append(channel_k)

compressed_img = np.stack(compressed_channels, axis=2)
if compressed_img.shape[2] == 1:
compressed_img = compressed_img[:, :, 0]

compress_ratio = (m * n) / (m * k + k + k * n)

return compressed_img, k, compress_ratio

采用的测试图像如下(用 Lenna 的例子都看吐了,换个 AI 图)

对灰度图像进行压缩:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
img = Image.open("code-svd-demo.jpg").convert("L")
img_array = np.array(img)
print(f"Image shape: {img_array.shape}")

fig, axes = plt.subplots(2,2,figsize=(10, 6))

axes[0][0].imshow(img, cmap='gray')
axes[0][0].set_title("Original Image")
axes[0][0].axis("off")

k = 100
compress_img, k, ratio = compress_img_first_k(img_array, k)
axes[0][1].imshow(np.uint8(np.clip(compress_img, 0, 255)), cmap='gray')
axes[0][1].set_title(f"k={k}, ratio={ratio:.2f}")
axes[0][1].axis("off")

k = 50
compress_img, k, ratio = compress_img_first_k(img_array, k)
axes[1][0].imshow(np.uint8(np.clip(compress_img, 0, 255)), cmap='gray')
axes[1][0].set_title(f"k={k}, ratio={ratio:.2f}")
axes[1][0].axis("off")

k = 20
compress_img, k, ratio = compress_img_first_k(img_array, k)
axes[1][1].imshow(np.uint8(np.clip(compress_img, 0, 255)), cmap='gray')
axes[1][1].set_title(f"k={k}, ratio={ratio:.2f}")
axes[1][1].axis("off")

plt.show()

对彩色图像进行压缩:(每一个通道的矩阵分别进行压缩)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
img = Image.open("code-svd-demo.jpg").convert("RGB")
img_array = np.array(img)
print(f"Image shape: {img_array.shape}")

fig, axes = plt.subplots(2,2,figsize=(10, 6))

axes[0][0].imshow(img)
axes[0][0].set_title("Original Image")
axes[0][0].axis("off")

k = 100
compress_img, k, ratio = compress_img_first_k(img_array, k)
axes[0][1].imshow(np.uint8(np.clip(compress_img, 0, 255)))
axes[0][1].set_title(f"k={k}, ratio={ratio:.2f}")
axes[0][1].axis("off")

k = 50
compress_img, k, ratio = compress_img_first_k(img_array, k)
axes[1][0].imshow(np.uint8(np.clip(compress_img, 0, 255)))
axes[1][0].set_title(f"k={k}, ratio={ratio:.2f}")
axes[1][0].axis("off")

k = 20
compress_img, k, ratio = compress_img_first_k(img_array, k)
axes[1][1].imshow(np.uint8(np.clip(compress_img, 0, 255)))
axes[1][1].set_title(f"k={k}, ratio={ratio:.2f}")
axes[1][1].axis("off")

plt.show()

值得注意的是,如果不指定保留的秩,而是按照保留矩阵的范数比例进行压缩,可能会得到不好的效果,因为前几个奇异值太大了,这样设置容差会导致选取的秩太小,压缩后的图像效果很差。

还有一个本质上的问题:对于图像来说,两个矩阵的范数接近就意味着图像近似的效果更好吗?矩阵范数可能并不是一个好的“度量”。

指定容差的相关函数如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def compress_img_tol(img_array, tol):
if img_array.ndim == 2:
img_array = img_array[:, :, np.newaxis]

_, _, c = img_array.shape

ks = []
for i in range(c):
channel = img_array[:, :, i]
S = np.linalg.svd(channel, compute_uv=False)

energy = np.cumsum(S**2)
total_energy = energy[-1]

k_i = np.searchsorted(energy, tol * total_energy) + 1
ks.append(k_i)

k = max(ks)
return compress_img_first_k(img_array, k)

例如取彩色图像进行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
img = Image.open("code-svd-demo.jpg").convert("RGB")
img_array = np.array(img)
print(f"Image shape: {img_array.shape}")

fig, axes = plt.subplots(2, 2, figsize=(10, 6))

axes[0][0].imshow(img)
axes[0][0].set_title("Original Image")
axes[0][0].axis("off")

tol = 0.999
compress_img, k, ratio = compress_img_tol(img_array, tol)
axes[0][1].imshow(np.uint8(np.clip(compress_img, 0, 255)))
axes[0][1].set_title(f"tol={tol}, k={k}, ratio={ratio:.2f}")
axes[0][1].axis("off")

tol = 0.99
compress_img, k, ratio = compress_img_tol(img_array, tol)
axes[1][0].imshow(np.uint8(np.clip(compress_img, 0, 255)))
axes[1][0].set_title(f"tol={tol}, k={k}, ratio={ratio:.2f}")
axes[1][0].axis("off")

tol = 0.95
compress_img, k, ratio = compress_img_tol(img_array, tol)
axes[1][1].imshow(np.uint8(np.clip(compress_img, 0, 255)))
axes[1][1].set_title(f"tol={tol}, k={k}, ratio={ratio:.2f}")
axes[1][1].axis("off")

plt.show()