JPEG는 정보를 버린다. 그런데 사진은 왜 여전히 사진처럼 보일까?
이전 페이지는
손실 압축은 압축이 아니다 — 우선순위 매기기다.
이미지를 8×8 블록으로 쪼갠다
JPEG는 이미지를 독립된 8×8 픽셀 타일로 잘라 시작한다. 왜 블록인가? 두 가지 이유다. 국소성: 실제 사진은 화면 전체에서 통계적으로 균일하지 않다 — 얼굴과 하늘은 구조가 다르다. 작은 창 안에서 작업하면 코더가 전역 구조를 모델링하지 않고도 적응할 수 있다. 다루기 쉬움: 8×8 블록은 64개의 수에 불과해서, 직접적인 행렬 계산 (8×8 변환) 이 빠르고 정확하게 돈다. 그 대가가 블록 잡음이다 — 낮은 품질에서는 8×8 타일 경계가 눈에 띈다. 각 블록을 따로따로 반올림한 탓이다. 위젯의 “체스판” 프리셋이 한 블록이고, 나머지 그림은 그 한 블록을 반복해서 만든다.
블록을 다른 기저로 본다
원소가 64개인 픽셀 블록은 64차원 공간 안의 벡터다. 표준
핵심은 이렇다. 기저 변환은 무손실이다. 블록의 정보는 그대로고, 라벨만 바뀐다. 새 기저가 직교정규라면 변환은 행렬 곱셈 한 번, 되돌리는 것도 전치 행렬을 곱하는 것뿐이다. 그래서 질문은 이거다 — JPEG가 하려는 일에 픽셀보다 더 좋은 기저가 있는가?
DCT — 자연 이미지가 희소해지는 기저
JPEG가 고른 기저는
JPEG가 작동하게 만드는 경험칙은 이거다 — 자연 이미지는 DCT 기저에서 희소하다. 흔한 사진의 8×8 블록 어느 것을 떠도, 64개 DCT 계수 중 5~10개에 에너지 대부분이 몰리고 그 자리는 거의 언제나 저주파 쪽이다. 위젯에서 직접 확인할 수 있다. 그라디언트로 바꿔 보면 거의 모든 에너지가 과 그 옆 몇 칸에 모인다. 단색으로 바꾸면 말 그대로 한 계수 (DC) 가 모든 것을 담는다. 체스판으로 바꾸면 인공적인 극단이 보인다 — 에너지가 한 고주파 칸에 쏠리지만, 희소하다는 사실은 그대로다.
import numpy as np
# 8x8 DCT-II in matrix form. The cosine matrix M is the same one JPEG uses;
# applying it twice (rows then columns) gives the 2D DCT.
N = 8
def dct_matrix(N=8):
M = np.zeros((N, N))
for k in range(N):
for n in range(N):
M[k, n] = np.cos((2*n + 1) * k * np.pi / (2*N))
M[0, :] *= 1 / np.sqrt(N)
M[1:, :] *= np.sqrt(2 / N)
return M
M = dct_matrix(N)
def dct2d(block):
return M @ block @ M.T # rows, then columns
def idct2d(coef):
return M.T @ coef @ M # inverse: just transpose
# DCT itself is lossless. Round-trip an 8x8 block and the error is zero
# (up to floating point).
block = np.random.default_rng(0).integers(0, 256, size=(8, 8)).astype(float)
coef = dct2d(block)
back = idct2d(coef)
np.allclose(block, back) # → True the transform alone loses nothing양자화 — 의미 없는 것을 버린다
압축이 일어나는 곳이 바로 여기다. DCT를 거친 다음, 각 계수를 양자화 표의 정수로 나누고 가장 가까운 정수로 반올림한다. 양자화 표는 손으로 조정해 표준에 박아 놓은 것인데, 고주파 칸에서는 더 과감하게 나누고 저주파 칸에서는 덜 나눈다 — 사람의 시각계가 고주파 오차에 덜 민감하기 때문이다. 작은 고주파 계수는 그대로 0이 되고, 살아남은 계수는 정밀도를 일부 잃은 채 남는다.
위젯에서는 단순화한 방식을 쓴다 — 크기 기준 상위 개 계수만 남기고 나머지는 0으로 둔다. 실제 JPEG 양자화는 품질 설정마다 고정된 표를 가지고 계수별로 처리하지만, 결과의 성격은 같다. 질감 프리셋에서 슬라이더를 로 내려 보면 복원된 그림은 여전히 알아볼 만하다. 보고 있던 것 대부분이 그 네 개의 수에 담겨 있었던 셈이다. 까지 내리면 평균 밝기의 평평한 블록이 나온다. 단색 프리셋에서는 K=1에서도 오차가 0이다 — 평평한 블록은 애초에 수 하나 분량의 정보였으니까.
손실
# Keep top K coefficients by magnitude; zero the rest. JPEG's quantization
# step is more elaborate (a per-coefficient divisor table), but the
# qualitative effect — kill small / high-frequency entries — is the same.
def keep_top_k(coef, k):
flat = coef.flatten()
if k >= flat.size: return coef.copy()
threshold = np.sort(np.abs(flat))[-k]
out = coef.copy()
out[np.abs(out) < threshold] = 0
return out
def reconstruct(coef, k):
return idct2d(keep_top_k(coef, k))
# Compare four block types: how many of 64 coefficients does each one need?
def kept_to_target_error(block, target_mae=2.0):
coef = dct2d(block)
for k in range(1, 65):
err = np.mean(np.abs(reconstruct(coef, k) - block))
if err <= target_mae:
return k, err
return 64, np.mean(np.abs(reconstruct(coef, 64) - block))
# (assumes flat / gradient / texture / checker block builders defined elsewhere)
[(name, *kept_to_target_error(b()))
for name, b in (("flat", lambda: np.full((8,8), 128.0)),
("gradient", lambda: np.add.outer(*[np.linspace(0,255,8)]*2) / 2),
("checker", lambda: 130 + 100*((np.indices((8,8)).sum(0) % 2)*2 - 1)))]
# → [('flat', 1, 0.0), DC alone reconstructs perfectly
# ('gradient', 3, ~1.5), a handful of low-freq entries
# ('checker', 1, 0.0)] surprisingly: ALL energy at one high-freq cell
# Same data, very different sparsity in the DCT basis.복원 — 역 DCT가 그림을 되살린다
디코딩할 때 JPEG는 (이제 대부분 0이 된) 계수 격자에 역 DCT를 돌린다. 역변환은 순방향 DCT와 같은 행렬 장치를 그대로 쓴다. 단지 코사인 행렬을 전치할 뿐이다. 결과는 더 이상 원본 블록이 아니다 — 살려둔 기저 벡터들이 펼치는 부분공간 위로 원본을 사영한 것이다. 그 사영은 살려둔 방향만 써야 한다는 제약 아래에서, L² 거리로 잰 가장 가까운 근사다.
여기서 두 가지 실패 양상이 드러난다. 블로킹: 각 8×8 타일을 따로 반올림했기 때문에, 이웃한 타일이 공유 가장자리에서 어긋날 수 있다. 링잉: 날카로운 가장자리 근처의 고주파 계수를 버리면, 남은 기저 벡터가 계단 함수를 그려내지 못해 진동이 생긴다. 둘 다 낮은 품질 설정에서 눈에 띈다 — 맞바꾼 대가다.
엔트로피 부호화 — 마지막 포장
양자화를 거치고 나면, 각 블록은 정수의 스트림이다 — 대부분 0이고, 살아남은 값들은 지그재그 스캔 순서로 (저주파부터) 늘어선다. 그 스트림이 Huffman이나 산술 부호화로 — 엔트로피 모듈에서 다룬 섀넌 한계의 영역으로 — 들어가고, 바로 거기서 파일이 디스크 위에서 실제로 줄어든다. JPEG의 기여는 엔트로피 코더가 아니다. 그건 표준 부품이다. JPEG의 기여는 엔트로피 코더가 빽빽이 묶을 만한 스트림을 만들어 주는 것이다. 길게 이어진 0은 거의 흔적도 없이 압축되고, 작은 정수는 비트를 거의 먹지 않는다.
그래서 파일 크기는 세 갈래로 곱해 가며 줄어든다. 0이 아닌 계수가 줄고 (대부분이 0으로 반올림되고), 살아남은 계수는 크기 자체가 작아지고, 이어지는 0은 엔트로피 코더에 더없이 잘 맞는다. 페이지를 세 줄로 요약하면 이렇다.
- 기저를 바꿔 (DCT) 그림의 정보가 몇 좌표에 집중되게 한다.
- 작은 좌표를 0으로 양자화 (반올림) 한다 — 손실 단계.
- 결과로 나온 희소 정수 스트림을 엔트로피 부호화 (Huffman) 한다 — 바이트가 절약되는 곳.
# Why the file actually shrinks: after quantization, the coefficient
# stream has lots of zeros and small ints; entropy coding (Huffman or
# arithmetic) packs that stream tightly. Same entropy module that bounds
# tf-idf and the lossless image-compression page — JPEG just feeds it a
# stream that's already been pre-sparsified by DCT + quantization.
from collections import Counter
from math import log2
def entropy(symbols):
counts = Counter(symbols)
N = len(symbols)
return sum(-(c / N) * log2(c / N) for c in counts.values() if c > 0)
# Pretend a 256-block image. Compare the entropy of the raw pixel stream
# to the entropy of the kept-DCT-coefficient stream after rounding.
rng = np.random.default_rng(1)
img = rng.integers(50, 200, size=(8, 32)) # 8 high × 32 wide = 32 blocks of 8x8
# This is illustrative; real JPEG quantizes per coefficient (zigzag table).
raw_h = entropy(img.flatten().tolist())
print(f"raw pixel H ≈ {raw_h:.2f} bits/symbol")
# After DCT + top-8-of-64 + integer rounding, most symbols are zero.
coef_stream = []
for bj in range(4):
block = img[:, bj*8:(bj+1)*8].astype(float)
kept = keep_top_k(dct2d(block), k=8)
coef_stream.extend(np.round(kept).astype(int).flatten().tolist())
sparse_h = entropy(coef_stream)
print(f"kept-8 DCT stream H ≈ {sparse_h:.2f} bits/symbol")
# Typical run: raw ~7 bits/symbol, sparse-DCT ~1-2 bits/symbol.
# Same entropy bound, very different alphabet — the gap is what JPEG
# saves in file size on top of what discarding coefficients already saved.두 이미지가 DCT 계수 히스토그램이 같아도 (값들의 다중집합이 같아도) 양자화 뒤의 지각 품질은 크게 다를 수 있다. 지각 품질은 계수가 어느 칸에 들어 있느냐에 달려 있기 때문이다 — 고주파 오차는 묻히고, 저주파 오차는 그대로 드러난다. 히스토그램 엔트로피는 이 둘을 가르지 못하지만, 사람 눈은 가른다. JPEG의 양자화 표가 그 비대칭을 그대로 새겨 둔다 — 저주파에는 작은 나눔수, 고주파에는 큰 나눔수. 반올림된 스트림의 엔트로피가 파일 크기를 일러주고, 양자화 표가 지각 품질을 일러준다. 서로 다른 두 목적함수가 같은 DCT 기저 위에 나란히 쌓여 있는 셈이다.
손실 압축은 압축이 아니다 — 우선순위 매기기다. JPEG는 기저를 바꿔 (DCT) 그림을 희소하게 만들고, 의미 없는 좌표를 버리고 (양자화), 나머지를 Huffman으로 묶는다 (엔트로피 부호화). 세 단계, 하나의 절약: 계수는 줄고, 값은 작아지고, 0은 길게 이어진다. 수학이 엔트로피를 깨는 게 아니다 — 다른 알파벳을 고를 뿐이다.
위젯에서 단색 프리셋을 고르고 를 1까지 내려 보자. 복원된 그림은 여전히 정확히 원본이다. 계수 하나만 남겨도 왜 충분한가? 그 하나가 어느 계수이며, 무엇을 담고 있는가?
위젯에서 체스판을 고르고 DCT 패널을 보자. 에너지 대부분이 한 칸에 모여 있다. 어디인가? 왜 그 칸이며, 미세한 질감으로 가득 찬 실제 사진 조각을 JPEG가 어떻게 다룰지에 대해 무엇을 말해 주는가?
위젯에서 질감을 고르고 를 64에서 1까지 내려 보자. 가 줄수록 복원이 점점 무너진다. 어느 계수가 먼저 사라지며, 왜 그것이 실제 JPEG의 동작과 맞아떨어지는가?
같은 장면을 압축한 두 이미지의 바이트 스트림이 동일한 엔트로피를 가진다. 한쪽은 멀쩡해 보이고, 다른 쪽은 블록 잡음이 눈에 띈다. 어떻게 가능한가? 엔트로피가 무엇을 묶고 무엇을 묶지 못하는지 한 문장으로 구분해 보자.
영상 처리 강의는 DCT와 양자화를 JPEG에만 쓰는 장치처럼 다룬다. 정보 이론 강의는 엔트로피와 엔트로피 부호화를 섀넌의 영역으로만 다룬다. 그 사이를 잇는 다리 — 기저 변환이 있어야 실제 신호 위에서 엔트로피 한계가 견딜 만해진다 — 는 말로 꺼내지 않은 채 남는다. Lemma는 세 단계를 (기저 변환, 양자화, 엔트로피 부호화) 한 흐름에 묶어, 각 단계가 무엇을 하고 무엇을 하지 않는지 독자가 직접 볼 수 있게 했다. 손실 압축의 어려운 부분은 엔트로피 코더가 아니다 (그건 표준이다). 변환도 아니다 (그건 되돌릴 수 있다). 어려운 부분은 양자화 표다 — 사람이 알아채지 못하는 정보는 무엇인가를 결정하는, 손으로 조정한 가중치들. 그 표가 바로 정신물리학과 정보 이론이 만나는 자리이자, 1992년 이래 모든 코덱이 경쟁해 온 자리다.