워크스루 02 — SVD로 이미지 압축

왜 하필 SVD냐면요

머신러닝과 데이터 과학을 공부하다 보면 거의 모든 길목에서 마주치는 도구가 하나 있습니다 — SVD (특이값 분해, Singular Value Decomposition). PCA의 안쪽에도 SVD가 있고, 추천 시스템의 행렬 분해도 SVD의 변형이고, 트랜스포머의 가중치 압축에도 SVD가 등장합니다.

말로 들으면 추상적인데, 이미지 압축이라는 작은 예제 하나로 풀면 직관이 한 번에 잡힙니다. 같은 이미지를 고품질 → 절반 → 극단적으로 작은 표현으로 단계적으로 줄여가면서, 정보가 어떻게 사라지는지를 눈으로 보는 겁니다. “행렬 분해가 정보를 압축한다”는 문장이 손에 잡히는 사실이 됩니다.

준비물

  • Octave 설치
  • image 패키지 (있으면 편하고, 없어도 기본 함수로 가능합니다)
  • 이미지 파일 하나. 그레이스케일이면 더 쉽고, 컬러도 됩니다.

이미지가 없으면 본인이 가진 사진 아무거나 한 장이면 됩니다. 회색조 변환 코드는 아래에 있습니다.

pkg install -forge image    # 한 번만 설치 (이미 있으면 건너뜀)

개념 — 한 단락 요약

어떤 행렬 $A$ ($m \times n$)에 대해 SVD는 다음 분해를 보장합니다:

$$A = U \Sigma V^T$$

  • $U$ ($m \times m$): 정규직교 열벡터들. “왼쪽 모양들.”
  • $\Sigma$ ($m \times n$): 대각행렬. 대각 원소가 특이값(singular values) — 항상 양수이고 내림차순입니다.
  • $V^T$ ($n \times n$): 정규직교. “오른쪽 모양들.”

핵심은 특이값의 크기입니다. 큰 특이값에 대응하는 $U$·$V$의 열이 행렬의 “중요한 구조”를 담고, 작은 특이값은 세부 잡음에 가깝습니다. 그래서 큰 특이값 몇 개만 남기고 나머지를 0으로 만들어 다시 합치면, 원본과 비슷한데 정보량은 훨씬 적은 행렬이 나옵니다. 이게 SVD 압축입니다.

이미지를 그레이스케일로 보면 그냥 숫자로 채워진 큰 행렬입니다. 그러니 같은 트릭이 그대로 통합니다.

단계별로 가봅시다

1단계 — 이미지 불러오기와 그레이스케일 변환

% svd_compress.m
clear; close all;

pkg load image    % imread, rgb2gray 등

% 본인의 이미지 경로로 바꾸세요
img_color = imread('photo.jpg');

% 그레이스케일로 변환
if size(img_color, 3) == 3
  img_gray = rgb2gray(img_color);
else
  img_gray = img_color;
end

% double 행렬로 변환 (SVD는 실수 행렬에)
A = double(img_gray);

[m, n] = size(A);
fprintf('이미지 크기: %d x %d, 총 픽셀 %d개\n', m, n, m*n);

figure;
imshow(uint8(A));
title('원본 (그레이스케일)');

여기까지 돌리면 회색조 사진 한 장이 뜹니다. 너무 큰 이미지(4000×3000 같은)는 SVD가 오래 걸리니 1000×1000 이내가 적당합니다. 크면 A = imresize(A, [800 NaN]);로 줄이세요.

2단계 — SVD 계산

% SVD 분해 (한 줄!)
[U, S, V] = svd(A);

% 특이값들 (S의 대각 원소)
sv = diag(S);

fprintf('가장 큰 특이값 5개: ');
disp(sv(1:5)');
fprintf('가장 작은 특이값 5개: ');
disp(sv(end-4:end)');

% 특이값 스펙트럼 보기
figure;
semilogy(sv, 'b.-');
xlabel('인덱스'); ylabel('특이값 (로그 스케일)');
title('특이값 스펙트럼');
grid on;

이 특이값 그래프가 가장 흥미로운 부분입니다. 거의 모든 자연 이미지에서 그래프가 뚝뚝 떨어집니다 — 처음 10개 특이값이 거대하고, 그 다음은 한참 작고, 끝쪽은 거의 0입니다. 이 급락이 바로 압축이 가능한 이유입니다.

3단계 — k개 특이값만 남겨서 복원

핵심 함수:

function A_approx = svd_approx(U, S, V, k)
  % 위에서 k개만 남기고 나머지는 0으로
  U_k = U(:, 1:k);
  S_k = S(1:k, 1:k);
  V_k = V(:, 1:k);
  A_approx = U_k * S_k * V_k';
end

여러 k 값에 대해 복원해서 비교합니다:

ks = [5, 20, 50, 100];

figure;
for i = 1:length(ks)
  k = ks(i);
  A_k = svd_approx(U, S, V, k);

  subplot(2, 3, i);
  imshow(uint8(A_k));
  title(sprintf('k = %d', k));
end

subplot(2, 3, 5);
imshow(uint8(A));
title('원본');

subplot(2, 3, 6);
semilogy(sv, 'b.-'); hold on;
for k = ks
  xline(k, 'r--');
end
xlabel('인덱스'); ylabel('특이값');
title('어디까지 살릴지');
grid on;

기대하는 그림:

  • k=5: 큰 윤곽만 살아남습니다. 거의 추상화입니다.
  • k=20: 알아볼 수 있지만 흐릿합니다.
  • k=50: 꽤 자연스럽습니다. 자세히 보면 원본보다 부드럽습니다.
  • k=100: 원본과 거의 구분이 안 됩니다.

이 그림이 SVD의 직관을 박아줍니다.

4단계 — 압축률 계산

원본은 $m \times n$ 개의 숫자를 저장해야 합니다. SVD로 k개만 남기면:

  • $U_k$: $m \times k$
  • $S_k$: $k$개의 특이값 (대각만)
  • $V_k$: $n \times k$

총 저장량은 $k(m + n + 1)$이 됩니다.

fprintf('\n=== 압축률 ===\n');
fprintf('원본: %d 개 숫자\n', m*n);
for k = ks
  stored = k * (m + n + 1);
  ratio = stored / (m*n) * 100;
  fprintf('k=%3d: %d 개 (%.1f%%)\n', k, stored, ratio);
end

흥미로운 지점: k=50쯤에서 원본 대비 10% 정도로 줄어도 시각적으로는 거의 같습니다. 정보의 90%가 “사실 별로 중요하지 않았다”는 게 숫자로 확인되는 순간입니다.

실용적인 압축이라면 JPEG/WebP가 훨씬 효율적입니다. SVD 압축은 “왜 압축이 가능한가”를 이해하기 위한 직관용 도구라고 보시면 됩니다.

AI에게 물어보기 좋은 시점

1단계 후:

“이미지를 행렬로 본다는 건 어떤 의미인가요? 컬러 이미지를 굳이 그레이스케일로 바꾸지 않고 SVD를 적용하려면 어떻게 해야 하나요? 채널별로 따로 하는 것 외의 방법이 있나요?”

특이값 스펙트럼을 본 직후:

“방금 그린 특이값 그래프가 왜 이렇게 급격히 떨어지나요? 만약 이미지가 완전히 랜덤한 잡음이라면 그래프 모양이 어떻게 달라질까요? Octave로 직접 비교해서 보여주세요. 자연 이미지와 잡음의 차이는 정보 이론과 어떻게 연결되나요?”

복원 결과를 본 직후:

“k=5일 때 윤곽만 남는 게 신기합니다. 첫 번째 특이값에 해당하는 $u_1 v_1^T$ 한 항만 그려보면 어떻게 생겼는지 Octave로 보여주세요. 두 번째 항은요? 이 항들이 이미지에서 무엇을 ‘잡아내는’ 건가요?”

4단계 압축률 계산 후:

“SVD 압축이 JPEG보다 비효율적인 이유를 설명해 주세요. SVD가 잡지 못하는 이미지의 통계적 구조는 무엇인가요? 그리고 PCA와 SVD는 정확히 어떤 관계인가요?”

막힐 만한 곳

  • imread에서 에러: image 패키지가 로드 안 된 겁니다. pkg load image 한 줄을 위에 추가하세요.
  • svd가 너무 오래 걸림: 이미지가 크면 SVD는 $O(m^2 n)$ 정도 듭니다. imresize로 줄이거나 svds(A, 100)로 상위 100개만 계산하세요.
  • 복원 이미지가 회색만 나옴: uint8 변환 전에 값이 0~255 범위를 넘은 겁니다. imshow(uint8(max(0, min(255, A_k)))) 처럼 클램프하면 됩니다.
  • 컬러 이미지에서 색이 이상함: 컬러는 R/G/B 채널을 따로 SVD한 다음 다시 합쳐야 합니다. 위 코드는 그레이스케일 가정입니다.

확장 아이디어

  • 컬러 이미지로 확장: R, G, B 각각 SVD하고 합치기. 채널별로 k를 다르게 줘서 차이도 봅니다.
  • PCA로 이어가기: 이미지가 아니라 데이터셋(예: MNIST 손글씨)에 SVD를 걸어 주성분 시각화.
  • 저장 시뮬레이션: 실제로 U_k, S_k, V_k를 디스크에 저장하고 다시 불러와 복원해 보기.
  • 노이즈 제거: 이미지에 일부러 잡음을 더한 다음 SVD로 잘라내면 잡음이 사라질까요? (저랭크 근사가 노이즈를 거릅니다)

내가 해본 기록

2026-07-26 — AI 에이전트와 함께 첫 실행

스크립트: wt02_svd_image.m, 그래프: wt02_svd_compress.png (환경: Linux + Octave 8.4, headless)

k=5/20/50/100 랭크별 복원 비교와 자연 이미지 vs 잡음의 특이값 스펙트럼

  • 이미지: 시스템 배경화면(Linux Mint의 도시 사진)을 그레이스케일 600×960으로 축소했습니다. SVD 계산은 0.35초. 생각보다 빠릅니다.
  • 특이값 스펙트럼: 예고대로 뚝뚝 떨어집니다 — 최대 38117, 10번째 4490, 100번째 213. 위의 AI 프롬프트 제안대로 같은 크기의 랜덤 잡음 행렬과 겹쳐 그려봤는데, 이 비교가 가장 인상적이었습니다. 상위 20개 특이값의 에너지 비중이 사진은 96.4%, 잡음은 55.7%. “자연 이미지는 저랭크 구조에 정보가 몰려 있다”가 숫자 하나로 요약됩니다.
  • k별 복원: k=5 (1.4% 저장, PSNR 20.6 dB)는 윤곽 추상화, k=20 (5.4%, 27.0 dB)은 알아볼 만, k=50 (13.6%, 35.5 dB)은 거의 구분 안 됨, k=100 (27.1%, 44.8 dB)은 사실상 원본. “k=50이면 충분하다”는 문서 주장이 이 이미지에서도 맞았습니다.
  • 확장도 해봤습니다 — 컬러 + 노이즈 제거 (wt02_ext_color_denoise.m): R/G/B 채널별로 k=40 SVD를 걸어 원본의 21.7% 저장량으로 자연스러운 컬러 복원을 얻었습니다. 노이즈 제거는 더 인상적이었는데요 — σ=25 가우시안 잡음을 섞은 이미지(PSNR 20.2 dB)를 k=30 저랭크 근사만 했는데 25.8 dB로 올라갔습니다. “작은 특이값은 세부 잡음에 가깝다”는 본문 문장이 문자 그대로 동작합니다. 잡음은 모든 특이값에 얇게 퍼지니까, 위쪽만 남기면 구조는 살고 잡음은 잘리는 겁니다.
  • 막힌 곳: 없었습니다. imresize로 미리 줄인 게 주효했습니다 — 원본 크기 그대로면 수십 배 느렸을 겁니다.
  • 남은 확장: MNIST 같은 데이터셋으로 PCA 이어가기.