L31 서술 파트의 결론은 세 줄이었다.
정규직교 기저에서는 버린 계수의 크기가 곧 오차다.
SVD는 알맹이당으로는 이기지만 예산의 99.8%를 사전에 쓰느라 진다.
압축이 되는 것은 세계가 매끄럽기 때문이다.
이 노트북에서는 셋을 전부 직접 재 본다. 거울 확장의 푸리에 변환이 정말 DCT인지 공식으로 확인하고, 압축률을 슬라이더로 올려 가며 격자 자국이 생기는 순간을 보고, SVD와 DCT를 같은 예산에서 겨루게 한다.
| 서술 파트의 내용 | 여기서 확인하는 방법 |
|---|---|
| DCT 기저를 손으로 만든다 | |
| 파세발 등식 | |
| 버린 계수 = 오차 | 소수점 여덟 자리까지 |
| DCT는 거울 확장의 DFT | |
| 절벽이 있으면 | 로그-로그 기울기 |
| 하르는 | 덧셈 횟수 |
| 계단 하나에 | 모든 위치에서 확인 |
| 알맹이당은 SVD 승 | 에카르트-영 |
| 저장량당은 DCT 승 | 예산을 맞춰서 |
| 격자 자국은 필연 | 경계 낙차 / 안쪽 낙차 |
| 백색잡음은 안 눌린다 | 어떤 직교기저에서도 |
| 세계가 매끄럽다 | 이웃 픽셀 상관계수 |
0. 준비¶
import numpy as np
import plotly.graph_objects as go
from scipy.fft import dct, idct, dctn, idctn
from linalg_viz import COLORS, show_matrix, slider_figure
np.set_printoptions(precision=4, suppress=True)
rng = np.random.default_rng(31)
print("numpy", np.__version__)numpy 2.5.2
def 시험사진(n=256, seed=31):
"""skimage 가 없으므로 시험용 이미지를 만든다.
부드러운 배경 + 또렷한 경계(사각형, 원, 사선) + 잔무늬 + 약한 잡음.
"""
r = np.random.default_rng(seed)
y, x = np.mgrid[0:n, 0:n] / (n - 1)
그림 = 0.34 + 0.24 * (1 - y) * (0.6 + 0.4 * np.cos(1.7 * x))
그림 += 0.30 * ((x > 0.10) & (x < 0.42) & (y > 0.12) & (y < 0.44))
그림 -= 0.26 * (np.hypot(x - 0.72, y - 0.32) < 0.16)
그림 += 0.22 * (np.abs(y - 0.62 * x - 0.30) < 0.020)
그림 += 0.10 * np.sin(38 * x) * ((y > 0.72) & (y < 0.90)
& (x > 0.15) & (x < 0.85))
그림 += 0.008 * r.standard_normal((n, n))
return np.clip(그림, 0, 1)
사진 = 시험사진()
m, n = 사진.shape
오차 = lambda X: float(np.linalg.norm(사진 - X) / np.linalg.norm(사진) * 100)
print(f"{m} x {n} = {사진.size:,} 개의 수")
go.Figure([go.Heatmap(z=사진[::-1], colorscale="gray", zmin=0, zmax=1,
showscale=False)],
layout=dict(title=dict(text="시험 사진"), height=460,
xaxis=dict(visible=False, scaleanchor="y"),
yaxis=dict(visible=False),
margin=dict(l=40, r=40, t=60, b=40)))256 x 256 = 65,536 개의 수
1. DCT 기저를 손으로 만든다¶
서술 파트의 정의를 그대로 옮기면 된다.
def DCT기저(N):
"""열이 DCT-II 기저벡터인 N x N 행렬."""
자리 = np.arange(N)
W = np.empty((N, N))
for k in range(N):
a = np.sqrt(1 / N) if k == 0 else np.sqrt(2 / N)
W[:, k] = a * np.cos(np.pi * (자리 + 0.5) * k / N)
return WW = DCT기저(8)
print(show_matrix(W, "W (열이 기저벡터)"))
print("정규직교인가 (W^T W = I) :", np.allclose(W.T @ W, np.eye(8)))
print()
print("0번 기저 (평균) :", np.round(W[:, 0], 4))
print("1번 기저 :", np.round(W[:, 1], 4))
print("각 열의 길이 :", np.round(np.linalg.norm(W, axis=0), 12))
print()
x = rng.normal(size=8)
print("계수는 그냥 내적인가 :", np.allclose(W.T @ x, [W[:, i] @ x for i in range(8)]))
print("scipy 와 같은가 :", np.allclose(W.T @ x, dct(x, norm="ortho")))W (열이 기저벡터)
[ 0.354 0.49 0.462 0.416 0.354 0.278 0.191 0.0975 ]
[ 0.354 0.416 0.191 -0.0975 -0.354 -0.49 -0.462 -0.278 ]
[ 0.354 0.278 -0.191 -0.49 -0.354 0.0975 0.462 0.416 ]
[ 0.354 0.0975 -0.462 -0.278 0.354 0.416 -0.191 -0.49 ]
[ 0.354 -0.0975 -0.462 0.278 0.354 -0.416 -0.191 0.49 ]
[ 0.354 -0.278 -0.191 0.49 -0.354 -0.0975 0.462 -0.416 ]
[ 0.354 -0.416 0.191 0.0975 -0.354 0.49 -0.462 0.278 ]
[ 0.354 -0.49 0.462 -0.416 0.354 -0.278 0.191 -0.0975 ]
정규직교인가 (W^T W = I) : True
0번 기저 (평균) : [0.3536 0.3536 0.3536 0.3536 0.3536 0.3536 0.3536 0.3536]
1번 기저 : [ 0.4904 0.4157 0.2778 0.0975 -0.0975 -0.2778 -0.4157 -0.4904]
각 열의 길이 : [1. 1. 1. 1. 1. 1. 1. 1.]
계수는 그냥 내적인가 : True
scipy 와 같은가 : True
파세발 등식과 “버린 계수 = 오차”¶
x = np.array([7.0, 7.5, 8.0, 8.2, 8.0, 7.0, 5.5, 4.0])
c = W.T @ x
print("x =", x)
print("c =", np.round(c, 4))
print()
print(f"|x| = {np.linalg.norm(x):.9f}")
print(f"|c| = {np.linalg.norm(c):.9f} <- 파세발")
print("W c 로 되돌아오는가 :", np.allclose(W @ c, x))x = [7. 7.5 8. 8.2 8. 7. 5.5 4. ]
c = [19.5161 2.5999 -2.7848 0.5062 -0.2828 0.0333 -0.0711 0.0547]
|x| = 19.893214924
|c| = 19.893214924 <- 파세발
W c 로 되돌아오는가 : True
def 상위만(v, k):
"""크기가 큰 k 개만 남기고 나머지는 0 으로."""
남김 = np.zeros_like(v)
큰것 = np.argsort(-np.abs(v.ravel()))[:k]
남김.ravel()[큰것] = v.ravel()[큰것]
return 남김print(f"{'남길 개수':>10}{'픽셀 기저 오차':>16}{'코사인 기저 오차':>18}"
f"{'버린 계수 크기':>16}{'같은가':>8}")
for k in (1, 2, 3, 4, 6):
픽셀 = 상위만(x, k)
코사인 = 상위만(c, k)
예측 = float(np.linalg.norm(c - 코사인))
실제 = float(np.linalg.norm(x - W @ 코사인))
print(f"{k:>10}{np.linalg.norm(x - 픽셀):>16.6f}{실제:>18.6f}"
f"{예측:>16.6f}{str(np.isclose(실제, 예측)):>8}")
print()
print("-> 코사인 기저에서는 세 개만 남겨도 오차가 0.59 다. 픽셀 기저는 14.16 이다.") 남길 개수 픽셀 기저 오차 코사인 기저 오차 버린 계수 크기 같은가
1 18.124569 3.854867 3.854867 True
2 16.263456 2.665531 2.665531 True
3 14.159802 0.587672 0.587672 True
4 12.010412 0.298589 0.298589 True
6 6.800735 0.064038 0.064038 True
-> 코사인 기저에서는 세 개만 남겨도 오차가 0.59 다. 픽셀 기저는 14.16 이다.
정규직교가 아니면 이 규칙이 깨진다¶
"큰 것부터 남긴다"가 정말 최선인지, 이니 모든 조합을 다 뒤져서 확인할 수 있다. 개를 고르는 방법은 많아야 70가지뿐이다.
def 최선인가(B, x, k):
"""큰 것 k 개를 남기는 것이 정말 최선인지 모든 조합과 견준다."""
직교 = np.allclose(B.T @ B, np.eye(len(x)))
c = B.T @ x if 직교 else np.linalg.solve(B, x)
탐욕집합 = frozenset(np.argsort(-np.abs(c))[:k].tolist())
최선, 최선집합 = np.inf, None
for 집합 in itertools.combinations(range(len(x)), k):
남 = np.zeros_like(c)
남[list(집합)] = c[list(집합)]
오 = float(np.linalg.norm(x - B @ 남))
if 오 < 최선:
최선, 최선집합 = 오, frozenset(집합)
남 = np.zeros_like(c)
남[list(탐욕집합)] = c[list(탐욕집합)]
return float(np.linalg.norm(x - B @ 남)), 최선, 탐욕집합 == 최선집합import itertools
print("[정규직교 기저] 큰 것부터 고르는 것이 정말 최선인가")
print(f"{'k':>4}{'큰 것 k개':>13}{'모든 조합 중 최선':>18}{'같은가':>8}")
for k in range(1, 6):
탐, 최, 같 = 최선인가(W, x, k)
print(f"{k:>4}{탐:>13.6f}{최:>18.6f}{str(같):>8}")
print()
print("-> 늘 최선이다. 서술 파트의 오차 식이 보장해 준다.")[정규직교 기저] 큰 것부터 고르는 것이 정말 최선인가
k 큰 것 k개 모든 조합 중 최선 같은가
1 3.854867 3.854867 True
2 2.665531 2.665531 True
3 0.587672 0.587672 True
4 0.298589 0.298589 True
5 0.095685 0.095685 True
-> 늘 최선이다. 서술 파트의 오차 식이 보장해 준다.
def 기울인기저(W, 세기, rng):
"""정규직교 기저를 무작위로 기울인다. 열 길이는 1 로 유지한다."""
B = W + 세기 * rng.normal(size=W.shape)
return B / np.linalg.norm(B, axis=0)비스듬 = 기울인기저(W, 0.35, np.random.default_rng(1))
print("열 길이가 전부 1 인가 :", np.allclose(np.linalg.norm(비스듬, axis=0), 1))
print("정규직교인가 :", np.allclose(비스듬.T @ 비스듬, np.eye(8)))
print()
print("[비스듬한 기저 하나를 예로]")
print(f"{'k':>4}{'큰 것 k개':>13}{'모든 조합 중 최선':>18}{'같은가':>8}{'몇 배 손해':>12}")
for k in range(1, 6):
탐, 최, 같 = 최선인가(비스듬, x, k)
print(f"{k:>4}{탐:>13.6f}{최:>18.6f}{str(같):>8}{탐/최:>12.2f}")열 길이가 전부 1 인가 : True
정규직교인가 : False
[비스듬한 기저 하나를 예로]
k 큰 것 k개 모든 조합 중 최선 같은가 몇 배 손해
1 37.161080 19.892265 False 1.87
2 27.226154 19.924172 False 1.37
3 32.515189 20.946167 False 1.55
4 19.209085 16.292440 False 1.18
5 16.080511 16.080511 True 1.00
뽑기 하나로는 말하기 어렵다. 기저를 200개 뽑아 비율로 재 보자.
def 실패율(세기, 횟수=200, 씨=7):
r = np.random.default_rng(씨)
실패, 전체, 최악 = 0, 0, 1.0
for _ in range(횟수):
B = 기울인기저(W, 세기, r)
if np.linalg.cond(B) > 1e6:
continue
for k in range(1, 6):
탐, 최, 같 = 최선인가(B, x, k)
전체 += 1
실패 += (not 같)
최악 = max(최악, 탐 / max(최, 1e-12))
return 실패 / 전체, 최악
print(f"{'기울인 정도':>12}{'조건수(중앙값)':>16}{'최선이 아닌 비율':>18}{'최악의 손해':>13}")
r0 = np.random.default_rng(7)
for 세기 in (0.0, 0.05, 0.15, 0.35, 0.60):
조건 = np.median([np.linalg.cond(기울인기저(W, 세기, r0)) for _ in range(40)])
비, 최악 = 실패율(세기)
print(f"{세기:>12.2f}{조건:>16.2f}{비:>18.1%}{최악:>13.2f}")
print()
print("-> 정규직교(0.00)에서는 한 번도 어긋나지 않는다.")
print(" 조금만 기울여도 '큰 것부터' 규칙이 무너지기 시작한다.")
print(" 비스듬한 기저에서는 어느 것을 버릴지 알려면 다 해 보는 수밖에 없다.") 기울인 정도 조건수(중앙값) 최선이 아닌 비율 최악의 손해
0.00 1.00 0.0% 1.00
0.05 1.31 5.6% 1.11
0.15 2.38 20.8% 1.86
0.35 14.71 61.4% 22.71
0.60 17.85 73.5% 36.77
-> 정규직교(0.00)에서는 한 번도 어긋나지 않는다.
조금만 기울여도 '큰 것부터' 규칙이 무너지기 시작한다.
비스듬한 기저에서는 어느 것을 버릴지 알려면 다 해 보는 수밖에 없다.
N = 8
x = rng.normal(size=N)
y = np.concatenate([x, x[::-1]]) # 거울로 이어 붙인다
Y = np.fft.fft(y)
자리 = np.arange(N)
print("거울 확장 :", np.round(y, 3))
print("끝과 시작이 이어지는가 :", np.isclose(y[-1], y[0]), " <- 절벽이 없다")
print()
print(f"{'k':>3}{'Y_k (거울확장의 DFT)':>30}{'유도한 공식':>30}{'같은가':>8}")
for k in range(N):
공식 = 2 * np.exp(1j*np.pi*k/(2*N)) * np.sum(
x * np.cos(np.pi * (자리 + 0.5) * k / N))
print(f"{k:>3}{f'{Y[k].real:+.5f}{Y[k].imag:+.5f}i':>30}"
f"{f'{공식.real:+.5f}{공식.imag:+.5f}i':>30}"
f"{str(np.allclose(Y[k], 공식)):>8}")
print()
print("-> DCT 는 새 변환이 아니다. 거울로 이어 붙인 신호의 DFT 다.")거울 확장 : [ 1.162 -0.938 1.776 1.202 -0.6 0.66 0.445 -1.746 -1.746 0.445
0.66 -0.6 1.202 1.776 -0.938 1.162]
끝과 시작이 이어지는가 : True <- 절벽이 없다
k Y_k (거울확장의 DFT) 유도한 공식 같은가
0 +3.92391+0.00000i +3.92391+0.00000i True
1 +5.24519+1.04333i +5.24519+1.04333i True
2 -4.09624-1.69672i -4.09624-1.69672i True
3 +0.98383+0.65737i +0.98383+0.65737i True
4 -1.92460-1.92460i -1.92460-1.92460i True
5 +5.20873+7.79542i +5.20873+7.79542i True
6 +1.72387+4.16179i +1.72387+4.16179i True
7 +0.19334+0.97199i +0.19334+0.97199i True
-> DCT 는 새 변환이 아니다. 거울로 이어 붙인 신호의 DFT 다.
절벽이 있으면 계수가 로만 줄어든다¶
def 감소기울기(신호, 끝=150):
"""계수 크기의 로그-로그 기울기. 진동하는 것은 포락선(국소 최대)으로 잰다."""
F = np.abs(np.fft.fft(신호))[1:len(신호)//2]
k = np.arange(1, len(F) + 1)
봉 = [i for i in range(1, min(끝, len(F) - 1))
if F[i] > F[i-1] and F[i] > F[i+1] and F[i] > 1e-12]
if len(봉) < 5: # 진동이 없으면 그냥 잰다
쓸 = np.flatnonzero(F[:끝] > 1e-12)
봉 = 쓸.tolist()
return float(np.polyfit(np.log(k[봉]), np.log(F[봉]), 1)[0])M = 512
t = np.arange(M)
경우 = (("톱니 (경계에 절벽)", t / M, "절벽 있음"),
("계단 하나", (t >= M//3).astype(float), "절벽 있음"),
("매끄러운 봉우리", np.exp(-((t - M/2)/40.0)**2), "절벽 없음"))
print(f"{'':>22}{'포락선 기울기':>15}{'':>4}{'뜻':>16}")
for 이름, s, 종류 in 경우:
기울기 = 감소기울기(s)
뜻 = "1/k 에 붙는다" if 기울기 > -1.5 else "훨씬 빠르다"
print(f"{이름:>22}{기울기:>15.3f}{'':>4}{뜻:>16}")
print()
print("-> 절벽이 있으면 -1 근처, 없으면 급전직하. 서술 파트의 부분적분 그대로다.")
print(" 계단은 계수가 심하게 진동하므로 그냥 재면 -0.76 이 나온다.")
print(" 봉우리만 골라 포락선을 재야 -0.90 으로 -1 에 붙는다.") 포락선 기울기 뜻
톱니 (경계에 절벽) -0.965 1/k 에 붙는다
계단 하나 -0.902 1/k 에 붙는다
매끄러운 봉우리 -10.046 훨씬 빠르다
-> 절벽이 있으면 -1 근처, 없으면 급전직하. 서술 파트의 부분적분 그대로다.
계단은 계수가 심하게 진동하므로 그냥 재면 -0.76 이 나온다.
봉우리만 골라 포락선을 재야 -0.90 으로 -1 에 붙는다.
램프 = np.linspace(0, 1, 64)
def 필요(계수, 목표=0.999):
e = np.sort(np.abs(np.asarray(계수)).ravel()**2)[::-1]
return int(np.searchsorted(np.cumsum(e)/e.sum(), 목표) + 1)
print("곧게 올라가는 기울기 하나를 64점으로 담을 때")
print(" 에너지 99.9% 에 필요한 계수")
print(" DFT :", 필요(np.fft.fft(램프)), "개 / 64")
print(" DCT :", 필요(dct(램프, norm='ortho')), "개 / 64")
print()
print("이보다 매끄러운 신호는 없다. 그런데 DFT 는 64개 중 59개를 쓴다.")
print("주기적으로 이어 붙이면서 자기가 만든 절벽을 그리느라 그렇다.")곧게 올라가는 기울기 하나를 64점으로 담을 때
에너지 99.9% 에 필요한 계수
DFT : 59 개 / 64
DCT : 3 개 / 64
이보다 매끄러운 신호는 없다. 그런데 DFT 는 64개 중 59개를 쓴다.
주기적으로 이어 붙이면서 자기가 만든 절벽을 그리느라 그렇다.
3. 하르 웨이블릿 — 이고, 경계를 안다¶
def 하르행렬(N):
"""서술 파트의 재귀식대로 만든다. 행이 기저벡터."""
H = np.array([[1.0]])
while H.shape[0] < N:
k = H.shape[0]
H = np.vstack([np.kron(H, [1.0, 1.0]),
np.kron(np.eye(k), [1.0, -1.0]) * np.sqrt(k)])
return H / np.sqrt(N)
def 빠른하르(x):
"""평균과 차이를 반복한다. 덧셈 횟수도 함께 돌려준다."""
x = np.asarray(x, dtype=float).copy()
N = 셈 = 0
N = len(x)
결과 = np.empty(N)
자 = np.sqrt(0.5)
남은 = N
while 남은 > 1:
합 = (x[0:남은:2] + x[1:남은:2]) * 자
차 = (x[0:남은:2] - x[1:남은:2]) * 자
셈 += 2 * (남은 // 2)
결과[남은//2:남은] = 차
x = np.concatenate([합, x[남은:]])
남은 //= 2
결과[0] = x[0]
return 결과, 셈H8 = 하르행렬(8)
print(show_matrix(H8 * np.sqrt(8), "sqrt(8) H (행이 기저벡터)"))
print("정규직교인가 :", np.allclose(H8 @ H8.T, np.eye(8)))
print()
print("각 기저벡터가 차지하는 칸 :")
for i, 행 in enumerate(H8):
자리 = np.flatnonzero(np.abs(행) > 1e-12)
print(f" h{i} : {자리.min()}~{자리.max()} ({len(자리)}칸)")
print()
print("-> 아래로 갈수록 좁아진다. 사인파는 언제나 8칸 전부를 쓴다.")sqrt(8) H (행이 기저벡터)
[ 1 1 1 1 1 1 1 1 ]
[ 1 1 1 1 -1 -1 -1 -1 ]
[ 1.41 1.41 -1.41 -1.41 0 0 -0 -0 ]
[ 0 0 -0 -0 1.41 1.41 -1.41 -1.41 ]
[ 2 -2 0 -0 0 -0 0 -0 ]
[ 0 -0 2 -2 0 -0 0 -0 ]
[ 0 -0 0 -0 2 -2 0 -0 ]
[ 0 -0 0 -0 0 -0 2 -2 ]
정규직교인가 : True
각 기저벡터가 차지하는 칸 :
h0 : 0~7 (8칸)
h1 : 0~7 (8칸)
h2 : 0~3 (4칸)
h3 : 4~7 (4칸)
h4 : 0~1 (2칸)
h5 : 2~3 (2칸)
h6 : 4~5 (2칸)
h7 : 6~7 (2칸)
-> 아래로 갈수록 좁아진다. 사인파는 언제나 8칸 전부를 쓴다.
print("하르 변환의 비용은 O(N) 이다")
print(f"{'N':>7}{'덧셈 횟수':>12}{'2N - 2':>10}{'N log2 N':>12}{'느린 것과 일치':>14}")
for N in (8, 64, 512, 4096):
v = rng.normal(size=N)
빠, 셈 = 빠른하르(v)
느 = 하르행렬(N) @ v
print(f"{N:>7}{셈:>12,}{2*N-2:>10,}{int(N*np.log2(N)):>12,}"
f"{str(np.allclose(np.sort(np.abs(빠)), np.sort(np.abs(느)))):>14}")
print()
print("-> FFT 의 N log N 보다도 적다. 조건 ①은 웨이블릿의 압승이다.")하르 변환의 비용은 O(N) 이다
N 덧셈 횟수 2N - 2 N log2 N 느린 것과 일치
8 14 14 24 True
64 126 126 384 True
512 1,022 1,022 4,608 True
4096 8,190 8,190 49,152 True
-> FFT 의 N log N 보다도 적다. 조건 ①은 웨이블릿의 압승이다.
계단 하나에 몇 개가 드는가¶
print(f"{'N':>7}{'어느 위치든 최대':>16}{'log2 N + 1':>13}{'DCT 는':>10}")
for N in (64, 256, 1024):
Hn = 하르행렬(N)
최대하르 = 최대DCT = 0
for p in range(1, N, max(1, N // 64)):
s = np.zeros(N); s[p:] = 1.0
최대하르 = max(최대하르, int((np.abs(Hn @ s) > 1e-10).sum()))
최대DCT = max(최대DCT, int((np.abs(dct(s, norm="ortho")) > 1e-10).sum()))
print(f"{N:>7}{최대하르:>16}{int(np.log2(N))+1:>13}{최대DCT:>10}")
print()
print("-> 하르는 신호가 길어져도 거의 안 늘고, DCT 는 길이만큼 늘어난다.") N 어느 위치든 최대 log2 N + 1 DCT 는
64 7 7 64
256 9 9 256
1024 11 11 1024
-> 하르는 신호가 길어져도 거의 안 늘고, DCT 는 길이만큼 늘어난다.
# 2차원에서도 마찬가지인가
경계그림 = np.zeros((256, 256)); 경계그림[:, 100:] = 1.0
H = 하르행렬(256)
C경 = dctn(경계그림, norm="ortho")
W경 = H @ 경계그림 @ H.T
print("세로 경계 하나뿐인 256x256 그림 (전체 65,536 개 계수)")
print(" 0 이 아닌 DCT 계수 :", int((np.abs(C경) > 1e-10).sum()))
print(" 0 이 아닌 하르 계수 :", int((np.abs(W경) > 1e-10).sum()))
print()
# 진짜 사진에서는?
C = dctn(사진, norm="ortho")
Wh = H @ 사진 @ H.T
print("시험 사진에서 같은 개수만 남기면")
print(f"{'남길 비율':>10}{'DCT 오차':>11}{'하르 오차':>12}{'이긴 쪽':>9}")
for 비율 in (0.01, 0.02, 0.05, 0.10):
k = int(비율 * 사진.size)
a = 오차(idctn(상위만(C, k), norm="ortho"))
b = 오차(H.T @ 상위만(Wh, k) @ H)
print(f"{비율:>10.0%}{a:>11.3f}{b:>12.3f}{('하르' if b<a else 'DCT'):>9}")
print()
print("-> 이 사진은 경계가 많아 하르가 앞선다. JPEG2000 이 웨이블릿을 쓰는 이유다.")세로 경계 하나뿐인 256x256 그림 (전체 65,536 개 계수)
0 이 아닌 DCT 계수 : 253
0 이 아닌 하르 계수 : 7
시험 사진에서 같은 개수만 남기면
남길 비율 DCT 오차 하르 오차 이긴 쪽
1% 5.251 5.333 DCT
2% 4.251 3.921 하르
5% 3.131 2.104 하르
10% 2.470 1.475 하르
-> 이 사진은 경계가 많아 하르가 앞선다. JPEG2000 이 웨이블릿을 쓰는 이유다.
4. SVD 대 DCT — 두 번 재면 답이 뒤집힌다¶
U, s, Vt = np.linalg.svd(사진, full_matrices=False)
SVD랭크 = lambda k: (U[:, :k] * s[:k]) @ Vt[:k]
DCT상위 = lambda k: idctn(상위만(C, k), norm="ortho")
print("[첫 번째 재기] 알맹이 개수 k 를 맞춘다 (사전은 세지 않는다)")
print(f"{'k':>6}{'DCT 상위 k개':>14}{'SVD 랭크 k':>13}{'이긴 쪽':>9}")
for k in (5, 10, 20, 50, 100):
a, b = 오차(DCT상위(k)), 오차(SVD랭크(k))
print(f"{k:>6}{a:>14.3f}{b:>13.3f}{('SVD' if b<a else 'DCT'):>9}")
print()
print("-> SVD 의 압승이다. 에카르트-영 정리 그대로다.")[첫 번째 재기] 알맹이 개수 k 를 맞춘다 (사전은 세지 않는다)
k DCT 상위 k개 SVD 랭크 k 이긴 쪽
5 21.757 8.172 SVD
10 15.814 6.541 SVD
20 13.834 4.374 SVD
50 11.408 2.610 SVD
100 9.416 1.463 SVD
-> SVD 의 압승이다. 에카르트-영 정리 그대로다.
print("[두 번째 재기] 저장할 숫자의 개수를 맞춘다")
print(f" 랭크 1 을 저장하는 값 : U 열 {m}개 + V 열 {n}개 + sigma 1개 = {m+n+1}개")
print(f" 그중 알맹이는 1 개. 사전이 {(m+n)/(m+n+1):.3%}")
print()
print(f"{'예산':>7}{'DCT k':>8}{'SVD 랭크':>9}{'DCT 오차':>10}{'SVD 오차':>10}{'이긴 쪽':>9}")
for 비율 in (0.01, 0.02, 0.05, 0.10, 0.20):
예산 = int(비율 * 사진.size)
kd, ks = 예산, max(1, 예산 // (m + n + 1))
a, b = 오차(DCT상위(kd)), 오차(SVD랭크(ks))
print(f"{비율:>7.0%}{kd:>8,}{ks:>9}{a:>10.3f}{b:>10.3f}"
f"{('SVD' if b<a else 'DCT'):>9}")
print()
print("-> 뒤집혔다. 무엇을 예산으로 세느냐가 답을 정한다.")[두 번째 재기] 저장할 숫자의 개수를 맞춘다
랭크 1 을 저장하는 값 : U 열 256개 + V 열 256개 + sigma 1개 = 513개
그중 알맹이는 1 개. 사전이 99.805%
예산 DCT k SVD 랭크 DCT 오차 SVD 오차 이긴 쪽
1% 655 1 5.251 22.031 DCT
2% 1,310 2 4.251 11.382 DCT
5% 3,276 6 3.131 7.773 DCT
10% 6,553 12 2.470 5.992 DCT
20% 13,107 25 1.823 3.794 DCT
-> 뒤집혔다. 무엇을 예산으로 세느냐가 답을 정한다.
예산 = int(0.05 * 사진.size)
ks = 예산 // (m + n + 1)
그림 = go.Figure(layout=dict(
title=dict(text=f"예산 {예산:,}개를 어디에 썼는가"),
barmode="stack", height=340,
xaxis=dict(title=dict(text="저장해야 하는 숫자의 개수")),
margin=dict(l=90, r=30, t=60, b=50)))
그림.add_bar(y=["DCT"], x=[예산], orientation="h", name="계수 (전부 알맹이)",
marker_color=COLORS["output"])
그림.add_bar(y=["SVD"], x=[ks*m], orientation="h", name="U (사전)",
marker_color=COLORS["input"])
그림.add_bar(y=["SVD"], x=[ks*n], orientation="h", name="V (사전)",
marker_color="#2ca02c")
그림.add_bar(y=["SVD"], x=[ks], orientation="h", name="sigma (알맹이)",
marker_color="#d62728")
그림print("사진 크기별로 사전이 차지하는 몫")
print(f"{'크기':>16}{'랭크 1 당 저장':>14}{'사전의 몫':>11}")
for (a, b) in ((64, 64), (256, 256), (512, 512), (1920, 1080), (3840, 2160)):
print(f"{f'{a}x{b}':>16}{a+b+1:>14,}{(a+b)/(a+b+1):>11.4%}")사진 크기별로 사전이 차지하는 몫
크기 랭크 1 당 저장 사전의 몫
64x64 129 99.2248%
256x256 513 99.8051%
512x512 1,025 99.9024%
1920x1080 3,001 99.9667%
3840x2160 6,001 99.9833%
5. 양자화와 격자 자국¶
DCT 자체는 손실이 없다. 손실은 양자화에서 생긴다.
JPEG표 = np.array([
[16, 11, 10, 16, 24, 40, 51, 61], [12, 12, 14, 19, 26, 58, 60, 55],
[14, 13, 16, 24, 40, 57, 69, 56], [14, 17, 22, 29, 51, 87, 80, 62],
[18, 22, 37, 56, 68, 109, 103, 77], [24, 35, 55, 64, 81, 104, 113, 92],
[49, 64, 78, 87, 103, 121, 120, 101],
[72, 92, 95, 98, 112, 100, 103, 99]], float)
def 블록DCT(X, N=8):
C = np.zeros_like(X)
for a in range(0, X.shape[0], N):
for b in range(0, X.shape[1], N):
C[a:a+N, b:b+N] = dctn(X[a:a+N, b:b+N], norm="ortho")
return C
def 블록역(C, N=8):
X = np.zeros_like(C)
for a in range(0, C.shape[0], N):
for b in range(0, C.shape[1], N):
X[a:a+N, b:b+N] = idctn(C[a:a+N, b:b+N], norm="ortho")
return X
def 양자화(C, q):
"""계수를 간격 q * (JPEG 표) 로 반올림한다."""
표 = np.tile(JPEG표 * q / 255.0, (C.shape[0]//8, C.shape[1]//8))
return np.round(C / 표) * 표, 표블C = 블록DCT(사진)
print("DCT 자체는 손실이 없는가 :", np.allclose(블록역(블C), 사진))
print()
print(f"{'q':>6}{'0 이 된 계수':>13}{'오차':>9}{'경계 낙차 / 안쪽 낙차':>22}")
for q in (0.25, 0.5, 1.0, 2.0, 4.0, 8.0):
양, 표 = 양자화(블C, q)
복원 = np.clip(블록역(양), 0, 1)
죽음 = float((np.abs(양) < 1e-12).mean())
차 = np.abs(np.diff(복원, axis=1))
경계, 안쪽 = 차[:, 7::8].mean(), 차[:, [0,1,2,3,4,5]].mean()
print(f"{q:>6.2f}{죽음:>13.1%}{오차(복원):>9.3f}{경계/안쪽:>22.2f}")
print()
print("-> 세게 걸수록 블록 경계의 낙차가 안쪽보다 커진다. 그것이 격자 자국이다.")
print(" 버그가 아니라, 블록을 따로 압축하기로 한 순간 정해진 결과다.")DCT 자체는 손실이 없는가 : True
q 0 이 된 계수 오차 경계 낙차 / 안쪽 낙차
0.25 82.9% 1.878 1.96
0.50 91.6% 2.426 2.92
1.00 95.1% 2.848 6.64
2.00 96.3% 3.419 7.30
4.00 97.1% 4.374 9.02
8.00 97.7% 6.263 11.57
-> 세게 걸수록 블록 경계의 낙차가 안쪽보다 커진다. 그것이 격자 자국이다.
버그가 아니라, 블록을 따로 압축하기로 한 순간 정해진 결과다.
품질 = [0.15, 0.25, 0.4, 0.6, 1.0, 1.6, 2.5, 4.0, 6.5, 10.0]
조각 = (slice(96, 160), slice(40, 104))
프레임, 이름표 = [], []
for q in 품질:
양, _ = 양자화(블C, q)
복원 = np.clip(블록역(양), 0, 1)
죽음 = float((np.abs(양) < 1e-12).mean())
프레임.append([go.Heatmap(z=복원[조각][::-1], colorscale="gray",
zmin=0, zmax=1, showscale=False)])
이름표.append(f"{q:g}")
배치 = dict(title=dict(text="같은 조각을 품질만 바꿔 가며 (8x8 격자를 보라)"),
xaxis=dict(visible=False, scaleanchor="y"),
yaxis=dict(visible=False), height=520,
margin=dict(l=40, r=40, t=60, b=40))
slider_figure(프레임, 이름표, 배치, prefix="q = ", initial=0)6. 왜 압축이 되는가¶
def 필요2(X, 목표=0.99):
e = np.sort(np.asarray(X).ravel()**2)[::-1]
return int(np.searchsorted(np.cumsum(e)/e.sum(), 목표) + 1)
Q, _ = np.linalg.qr(rng.normal(size=(m, m))) # 아무 직교행렬
가우스 = rng.standard_normal((m, n)) # 정리가 말하는 그 백색잡음
print("정규분포 백색잡음을 여러 정규직교 기저로 옮겨 본다")
print(f"{'기저':>14}{'표준편차':>11}{'99% 에 필요한 계수':>22}")
for 이름, 계수 in (("픽셀 (원본)", 가우스),
("DCT", dctn(가우스, norm="ortho")),
("하르", H @ 가우스 @ H.T),
("무작위 직교", Q.T @ 가우스 @ Q)):
k = 필요2(계수)
print(f"{이름:>14}{계수.std():>11.4f}{f'{k:,} ({k/가우스.size:.1%})':>22}")
print()
print("-> 전부 73% 대로 같다. 직교변환은 백색잡음의 분포를 바꾸지 못한다.")정규분포 백색잡음을 여러 정규직교 기저로 옮겨 본다
기저 표준편차 99% 에 필요한 계수
픽셀 (원본) 1.0038 48,103 (73.4%)
DCT 1.0038 48,083 (73.4%)
하르 1.0038 48,108 (73.4%)
무작위 직교 1.0038 48,307 (73.7%)
-> 전부 73% 대로 같다. 직교변환은 백색잡음의 분포를 바꾸지 못한다.
잡음 = rng.random((m, n)) # 균등분포
가운데 = 잡음 - 잡음.mean()
print(f"{'기저':>14}{'왜도':>9}{'첨도':>9}{'99% 에 필요한 계수':>22}")
for 이름, 계수 in (("픽셀 (균등)", 가운데),
("DCT", dctn(가운데, norm="ortho")),
("하르", H @ 가운데 @ H.T)):
v = 계수.ravel()
왜도 = float(((v - v.mean())**3).mean() / v.std()**3)
첨도 = float(((v - v.mean())**4).mean() / v.std()**4)
k = 필요2(계수)
print(f"{이름:>14}{왜도:>9.3f}{첨도:>9.3f}{f'{k:,} ({k/잡음.size:.1%})':>22}")
print()
print("-> 균등분포의 첨도는 1.8, 정규분포는 3.0 이다.")
print(" 변환을 거치면 많은 값을 더하게 되므로 중심극한정리로 정규분포에 가까워진다.")
print(" 에너지를 몰아주지는 못하지만 분포 모양은 바꾼 셈이다.") 기저 왜도 첨도 99% 에 필요한 계수
픽셀 (균등) -0.001 1.799 51,495 (78.6%)
DCT -0.015 3.028 47,976 (73.2%)
하르 0.012 2.887 48,199 (73.5%)
-> 균등분포의 첨도는 1.8, 정규분포는 3.0 이다.
변환을 거치면 많은 값을 더하게 되므로 중심극한정리로 정규분포에 가까워진다.
에너지를 몰아주지는 못하지만 분포 모양은 바꾼 셈이다.
print("사진과 잡음을 견준다")
print(f"{'':>12}{'이웃 픽셀 상관':>14}{'99% 에 필요한 DCT 계수':>26}")
for 이름, X in (("시험 사진", 사진), ("무작위 잡음", 잡음)):
a, b = X[:, :-1].ravel(), X[:, 1:].ravel()
k = 필요2(dctn(X, norm="ortho"))
print(f"{이름:>12}{np.corrcoef(a, b)[0,1]:>14.4f}"
f"{f'{k:,} ({k/X.size:.1%})':>26}")
print()
print("압축이 가능한 것은 수학 덕분이 아니라 세계가 매끄럽기 때문이다.")
그림 = go.Figure(layout=dict(
title=dict(text="DCT 계수를 큰 것부터 늘어놓으면"),
xaxis=dict(title=dict(text="몇 번째"), type="log"),
yaxis=dict(title=dict(text="크기"), type="log"),
height=420, margin=dict(l=70, r=30, t=60, b=50)))
for 이름, X, 색 in (("시험 사진", 사진, COLORS["output"]),
("무작위 잡음", 잡음, "#999999")):
cc = np.sort(np.abs(dctn(X, norm="ortho")).ravel())[::-1]
그림.add_scatter(x=np.arange(1, cc.size + 1), y=cc, mode="lines",
name=이름, line=dict(color=색, width=2.4))
그림사진과 잡음을 견준다
이웃 픽셀 상관 99% 에 필요한 DCT 계수
시험 사진 0.9841 82 (0.1%)
무작위 잡음 -0.0002 38,012 (58.0%)
압축이 가능한 것은 수학 덕분이 아니라 세계가 매끄럽기 때문이다.
마치며...¶
| 서술 파트의 내용 | 이 노트북의 코드 |
|---|---|
| DCT 기저의 정의 | DCT기저 가 scipy.fft.dct 와 일치 |
| 계수는 내적 | W.T @ x |
| 파세발 | |
| 버린 계수 = 오차 | 소수점 여섯 자리까지 같다 |
| 정규직교가 아니면 | 규칙이 깨진다 |
| DCT = 거울 확장의 DFT | 여덟 개 전부 일치 |
| 절벽이면 | 로그-로그 기울기 |
| 하르는 | 덧셈이 정확히 |
| 계단 하나 | 개 |
| 알맹이당은 SVD 승 | 5개에서 8.2% vs 21.8% |
| 저장량당은 DCT 승 | 5%에서 3.1% vs 7.8% |
| 사전이 99.8% | 크기별 표 |
| 격자 자국 | 경계 낙차가 안쪽의 6배 넘음 |
| 백색잡음 | 어떤 직교기저에서도 같다 |
| 세계가 매끄럽다 | 이웃 상관 0.984 |
더 해 볼 것¶
5절의 양자화 표를 전부 1로 바꿔 보자. 같은 개수의 계수를 죽였을 때 오차가 어떻게 달라지는가? JPEG 표가 고주파를 크게 잡는 것이 정말 이득인가?
블록 크기를 8 대신 로 바꿔 보자. 격자 자국과 오차가 어떻게 맞바뀌는가? 왜 하필 8이었을까?
사진을 90도 돌려서 같은 실험을 해 보자. DCT 오차가 달라지는가? SVD 오차는? 둘 중 어느 쪽이 방향을 타는가?
3절의 하르 변환을 2단계만 하고 멈춰 보자(전부 내려가지 말고). 이것이 "다중해상도"다. 몇 단계가 가장 좋은가?
시험 사진에 잡음을 조금씩 섞어 가며 "99% 에 필요한 계수"를 재 보자. 잡음이 얼마나 섞이면 압축이 불가능해지는가?
다음 강의에서는 마지막 질문에 답한다. 역행렬이 아예 없는 행렬을 어떻게 되돌릴 것인가.