Lemma
수학, 거꾸로
도입 · 보이지 않는 손실

JPEG는 정보를 버린다. 그런데 사진은 왜 여전히 사진처럼 보일까?

이전 페이지의 벽에서 멈췄다 — 히스토그램 엔트로피는 단단한 바닥이고, 구조를 아는 코더도 그 아래로는 못 내려간다. JPEG는 속임수를 쓴다 — 그러나 막무가내가 아니다. 사람이 알아채지 못하는 정보를 골라서 버리고, 나머지를 압축한다. 그 결과 4MB 사진이 망가져 보이지 않으면서 400KB에 들어간다. 수학은 한 번의 기저 변환과 과감한 반올림이 전부다.

손실 압축은 압축이 아니다 — 우선순위 매기기다.

위젯 — JPEG 블록 실험실
남긴 계수8 / 64
압축 (대략)8.0×
평균 절대 오차0.7
최대 오차2
원본 8×8
DCT · 유지 (색), 제거 (회색)
DC
복원
세 시점, 한 블록. 왼쪽: 원본 픽셀, 표준 시점. 가운데: DCT 계수, 자연 이미지가 희소해지는 기저로 본 같은 데이터 — DC 항 (왼쪽 위) 이 평균을 운반하고, 고주파 (오른쪽 아래) 는 보통 작다. 오른쪽: 크기 기준 상위 K개 계수만 남기고 역 DCT를 한 결과. K = 64면 정확 (DCT는 모두 유지하면 무손실). 단색 블록은 K = 1로도 정확 — 블록 전체가 한 기저 벡터 위에 산다. 질감은 눈이 알아채기 전에 대부분의 계수를 버릴 수 있다. 체스판은 에너지가 고주파 코너에 집중돼 있어, 그걸 버리면 눈에 보이는 손상이 생긴다. *같은 데이터, 다른 기저, 다른 손실 프로파일* — 한 위젯의 페이지.
흐름
1

이미지를 8×8 블록으로 쪼갠다

JPEG는 이미지를 독립된 8×8 픽셀 타일로 잘라 시작한다. 왜 블록인가? 두 가지 이유다. 국소성: 실제 사진은 화면 전체에서 통계적으로 균일하지 않다 — 얼굴과 하늘은 구조가 다르다. 작은 창 안에서 작업하면 코더가 전역 구조를 모델링하지 않고도 적응할 수 있다. 다루기 쉬움: 8×8 블록은 64개의 수에 불과해서, 직접적인 행렬 계산 (8×8 변환) 이 빠르고 정확하게 돈다. 그 대가가 블록 잡음이다 — 낮은 품질에서는 8×8 타일 경계가 눈에 띈다. 각 블록을 따로따로 반올림한 탓이다. 위젯의 “체스판” 프리셋이 한 블록이고, 나머지 그림은 그 한 블록을 반복해서 만든다.

2

블록을 다른 기저로 본다

원소가 64개인 픽셀 블록은 64차원 공간 안의 벡터다. 표준 는 “픽셀 위치마다 한 방향”이다: 좌표 (0,0)(0, 0)은 “왼쪽 위 픽셀”을 뜻한다. 가장 자연스러운 기저지만 유일한 기저는 아니다. 선형 독립인 64개의 방향이라면 어느 것이든 기저가 되고, 같은 블록도 기저마다 다른 좌표를 갖는다.

핵심은 이렇다. 기저 변환은 무손실이다. 블록의 정보는 그대로고, 라벨만 바뀐다. 새 기저가 직교정규라면 변환은 행렬 곱셈 한 번, 되돌리는 것도 전치 행렬을 곱하는 것뿐이다. 그래서 질문은 이거다 — JPEG가 하려는 일에 픽셀보다 더 좋은 기저가 있는가?

3

DCT — 자연 이미지가 희소해지는 기저

JPEG가 고른 기저는 (DCT) 이다. 64개 방향 각각이 특정 공간 주파수의 2D 코사인 패턴이다. (0,0)(0, 0) 방향은 상수 — 블록의 평균 밝기를 담고 있어 DC 항이라 부른다. (7,7)(7, 7) 방향은 가장 빠르게 변하는 체스판으로, 블록이 나타낼 수 있는 가장 높은 주파수다. 그 사이에서, 부드러운 패턴은 저주파 모서리에, 날카로운 가장자리와 잡음은 고주파 모서리에 모인다.

JPEG가 작동하게 만드는 경험칙은 이거다 — 자연 이미지는 DCT 기저에서 희소하다. 흔한 사진의 8×8 블록 어느 것을 떠도, 64개 DCT 계수 중 5~10개에 에너지 대부분이 몰리고 그 자리는 거의 언제나 저주파 쪽이다. 위젯에서 직접 확인할 수 있다. 그라디언트로 바꿔 보면 거의 모든 에너지가 (0,0)(0, 0)과 그 옆 몇 칸에 모인다. 단색으로 바꾸면 말 그대로 한 계수 (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
4

양자화 — 의미 없는 것을 버린다

압축이 일어나는 곳이 바로 여기다. DCT를 거친 다음, 각 계수를 양자화 표의 정수로 나누고 가장 가까운 정수로 반올림한다. 양자화 표는 손으로 조정해 표준에 박아 놓은 것인데, 고주파 칸에서는 더 과감하게 나누고 저주파 칸에서는 덜 나눈다 — 사람의 시각계가 고주파 오차에 덜 민감하기 때문이다. 작은 고주파 계수는 그대로 0이 되고, 살아남은 계수는 정밀도를 일부 잃은 채 남는다.

위젯에서는 단순화한 방식을 쓴다 — 크기 기준 상위 KK개 계수만 남기고 나머지는 0으로 둔다. 실제 JPEG 양자화는 품질 설정마다 고정된 표를 가지고 계수별로 처리하지만, 결과의 성격은 같다. 질감 프리셋에서 슬라이더를 K=4K = 4로 내려 보면 복원된 그림은 여전히 알아볼 만하다. 보고 있던 것 대부분이 그 네 개의 수에 담겨 있었던 셈이다. K=1K = 1까지 내리면 평균 밝기의 평평한 블록이 나온다. 단색 프리셋에서는 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.
5

복원 — 역 DCT가 그림을 되살린다

디코딩할 때 JPEG는 (이제 대부분 0이 된) 계수 격자에 역 DCT를 돌린다. 역변환은 순방향 DCT와 같은 행렬 장치를 그대로 쓴다. 단지 코사인 행렬을 전치할 뿐이다. 결과는 더 이상 원본 블록이 아니다 — 살려둔 기저 벡터들이 펼치는 부분공간 위로 원본을 사영한 것이다. 그 사영은 살려둔 방향만 써야 한다는 제약 아래에서, L² 거리로 잰 가장 가까운 근사다.

여기서 두 가지 실패 양상이 드러난다. 블로킹: 각 8×8 타일을 따로 반올림했기 때문에, 이웃한 타일이 공유 가장자리에서 어긋날 수 있다. 링잉: 날카로운 가장자리 근처의 고주파 계수를 버리면, 남은 기저 벡터가 계단 함수를 그려내지 못해 진동이 생긴다. 둘 다 낮은 품질 설정에서 눈에 띈다 — 맞바꾼 대가다.

6

엔트로피 부호화 — 마지막 포장

양자화를 거치고 나면, 각 블록은 정수의 스트림이다 — 대부분 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은 길게 이어진다. 수학이 엔트로피를 깨는 게 아니다 — 다른 알파벳을 고를 뿐이다.

exercises · 손으로 풀기
1단색 블록 · 한 계수만으로계산기 없이

위젯에서 단색 프리셋을 고르고 KK를 1까지 내려 보자. 복원된 그림은 여전히 정확히 원본이다. 계수 하나만 남겨도 왜 충분한가? 그 하나가 어느 계수이며, 무엇을 담고 있는가?

2체스판 · 고주파 에너지

위젯에서 체스판을 고르고 DCT 패널을 보자. 에너지 대부분이 한 칸에 모여 있다. 어디인가? 왜 그 칸이며, 미세한 질감으로 가득 찬 실제 사진 조각을 JPEG가 어떻게 다룰지에 대해 무엇을 말해 주는가?

3질감 · 어떤 계수가 먼저 사라지나

위젯에서 질감을 고르고 KK를 64에서 1까지 내려 보자. KK가 줄수록 복원이 점점 무너진다. 어느 계수가 먼저 사라지며, 왜 그것이 실제 JPEG의 동작과 맞아떨어지는가?

4사악한 것 · 같은 엔트로피, 다른 품질

같은 장면을 압축한 두 이미지의 바이트 스트림이 동일한 엔트로피를 가진다. 한쪽은 멀쩡해 보이고, 다른 쪽은 블록 잡음이 눈에 띈다. 어떻게 가능한가? 엔트로피가 무엇을 묶고 무엇을 묶지 못하는지 한 문장으로 구분해 보자.

왜 교과서는 이렇게 안 가르치나

영상 처리 강의는 DCT와 양자화를 JPEG에만 쓰는 장치처럼 다룬다. 정보 이론 강의는 엔트로피와 엔트로피 부호화를 섀넌의 영역으로만 다룬다. 그 사이를 잇는 다리 — 기저 변환이 있어야 실제 신호 위에서 엔트로피 한계가 견딜 만해진다 — 는 말로 꺼내지 않은 채 남는다. Lemma는 세 단계를 (기저 변환, 양자화, 엔트로피 부호화) 한 흐름에 묶어, 각 단계가 무엇을 하고 무엇을 하지 않는지 독자가 직접 볼 수 있게 했다. 손실 압축의 어려운 부분은 엔트로피 코더가 아니다 (그건 표준이다). 변환도 아니다 (그건 되돌릴 수 있다). 어려운 부분은 양자화 표다 — 사람이 알아채지 못하는 정보는 무엇인가를 결정하는, 손으로 조정한 가중치들. 그 표가 바로 정신물리학과 정보 이론이 만나는 자리이자, 1992년 이래 모든 코덱이 경쟁해 온 자리다.

용어집 · 이 페이지에서 쓰임 · 4
lossless·무손실
_아무것도 버리지 않는_ 압축 방식: 압축을 풀면 원본 비트가 그대로 돌아온다. PNG, ZIP, FLAC이 무손실이다. 파일이 작아진 이유는 인코더가 *같은 정보*를 더 짧게 표현하는 방법을 찾아냈기 때문이지, 정보를 버려서가 아니다. 섀넌 한계가 바닥선 — 어떤 무손실 방법도, 아무리 영리해도, 원천의 엔트로피를 넘지 못한다. *손실*과 정반대 — 손실 압축은 크기를 위해 충실도를 거래한다.
basis·기저
공간 안의 어떤 벡터든 _고유한_ 스칼라 결합으로 적을 수 있게 해주는 *좌표 방향*들의 모음. 같은 벡터가 기저에 따라 다른 좌표를 가진다 — *대상*은 변하지 않고 *이름표*만 변한다. 8×8 이미지의 표준 픽셀 기저: 64개 방향, 픽셀 위치마다 하나. 같은 블록의 DCT 기저: 또 다른 64개 방향, 각각 특정 공간 주파수의 2D 코사인 패턴. 두 기저 모두 같은 64차원 공간을 묘사한다 — 한쪽을 다른 쪽으로 바꾸는 건 *관점의 전환*이지 *정보의 변화*가 아니다. JPEG의 트릭 전체는 *자연 이미지가 픽셀 기저보다 DCT 기저에서 더 희소하게 표현된다*는 사실 — 에너지의 대부분이 몇 안 되는 계수에 집중되고, 그래서 나머지를 버려도 그림이 살아남는다.
discrete cosine transform·이산 코사인 변환
유한 신호의 *기저*를 바꾸는 일 — 같은 데이터, 다른 좌표. 8×8 이미지 블록에 적용하면 DCT는 0부터 7까지 인덱스 `(u, v)`로 64개 계수를 만든다: `(0, 0)` 계수 (_DC_ 항) 는 블록의 평균 밝기, `(7, 7)` 쪽으로 갈수록 더 높은 공간 주파수. 자연 이미지에서는 에너지의 대부분이 저주파 코너에 살고, 고주파 항목은 작다. JPEG는 정확히 이 사실을 이용한다 — 작은 고주파 계수를 버리고, 큰 저주파 계수를 유지하면 그림은 지각적 내용 대부분을 유지한 채 살아남는다. DCT 자체는 *무손실*이고 _가역_. 손실은 다음 단계 (양자화) 에서 일어난다.
quantization·양자화
연속 (또는 정밀한) 값을 거친 표에서 가장 가까운 항목으로 바꾸는 일. JPEG에서: 각 DCT 계수를 *양자화 표*의 정수로 나눈 다음 가장 가까운 정수로 반올림. 작은 계수 (보통 고주파) 는 그대로 0이 되고, 큰 계수는 정밀도가 떨어진 채 살아남는다. _손실이 일어나는 곳이 바로 여기다_ — 반올림된 값에서 원래 계수를 복원할 수 없다. JPEG 품질 설정은 양자화 표를 조정한다 — 품질이 낮을수록 → 나누는 수가 커지고 → 0이 늘고 → 파일은 작지만 블록 잡음과 링잉이 더 보인다. 양자화는 실수값 DCT 출력을 엔트로피 부호화 (Huffman/run-length) 가 빽빽이 묶을 수 있는 정수 스트림으로 옮기는 다리.