어떤 이미지는 왜 압축이 잘되고, 어떤 건 왜 안 될까?
같은 크기의 사진 두 장, 같은 픽셀 수 — 표면적으로는 같은 데이터. PNG로 저장해 보면 한쪽은 18KB, 다른 한쪽은 312KB. 두 번째가 압축에 버티는 이유는 무엇일까? 답은 엔트로피 모듈의 한 양을 두 번 쓰는 것이다 — 한 번은 히스토그램에, 한 번은 히스토그램이 버리고 간 구조에.
파일의 크기는 픽셀 수가 아니라 정보량에 묶인다. 색 히스토그램은 픽셀 값 위의
세 패턴 모두 같은 히스토그램 — 각 값은 16번씩 등장.
같은 데이터, 다른 정보
16×16 흑백 이미지는 픽셀 256개 — 픽셀당 1바이트씩이면 256바이트. 그게 _데이터_다. 그런데 이 이미지가 실제로 담은 _정보의 비트 수_는 얼마일까? 그건 다른 질문이고, 답은 픽셀당 8비트보다 한참 적을 수 있다. 모든 픽셀이 같은 색이라면 비트가 거의 필요 없다 — “전부 다, 값 7.” 모든 픽셀이 서로 독립인 무작위 잡음이라면 픽셀당 8비트 가까이 필요하다 — 써먹을 패턴이 없으니까. 실제 사진은 그 사이 어딘가에 있고, _데이터_와 정보 사이의 이 간격이 바로 압축기가 거두는 수확이다.
히스토그램 한계
처음 들여다볼 데는 픽셀 값 분포 — 히스토그램이다 (이 항목은 곧 정리한다). 각 값에 픽셀이 몇 개씩 있는지 세고, 총 개수로 나눠 확률을 얻고, 에 집어넣는다. 그러면 각 픽셀을 이웃과 무관하게 따로따로 인코딩할 때 픽셀당 평균 몇
16단계 패치에서 각 단계가 딱 16번씩 등장한다면, 모든 단계의 확률은 , bits/픽셀. 이게 히스토그램 한계 — 심볼 단위 인코딩의 섀넌 한계다. 픽셀당 8비트를 4비트로 반토막 내는 것만으로도, 256색 이미지가 16색 이미지의 두 배 크기인 이유가 설명된다.
import numpy as np
from collections import Counter
from math import log2
# A 16x16 patch with each of 16 grayscale levels appearing exactly 16 times.
# We'll build three layouts that share this histogram and compare entropies.
def gradient():
img = np.zeros((16, 16), dtype=np.int32)
for i in range(16):
img[i, :] = i # row i is constant level i
return img
def blocks():
img = np.zeros((16, 16), dtype=np.int32)
for i in range(16):
for j in range(16):
img[i, j] = (i // 4) * 4 + (j // 4)
return img
def scrambled(seed=0):
rng = np.random.default_rng(seed)
flat = gradient().flatten()
rng.shuffle(flat)
return flat.reshape(16, 16)
# Histogram entropy — bits/pixel if you encoded each pixel independently.
def histogram_entropy(img):
flat = img.flatten()
counts = Counter(flat)
N = len(flat)
return sum(-(c / N) * log2(c / N) for c in counts.values() if c > 0)
[histogram_entropy(p()) for p in (gradient, blocks, scrambled)]
# → [4.0, 4.0, 4.0] identical — same histogram, same H.히스토그램이 못 보는 것
위젯에서 그라디언트, 블록, 뒤섞임을 번갈아 눌러보자. 픽셀의 다중집합은 정확히 같다 — 단계마다 16개씩, 똑같이. 히스토그램 엔트로피도 세 패턴 모두 bits/픽셀. 완전히 같다.
그런데 그림은 같지 않고, 압축 결과도 같지 않다. 그라디언트는 매끈하다 — 인접한 두 픽셀이 같거나 1만큼만 차이 난다. 뒤섞인 패치는 이웃 픽셀끼리 어긋난다 — 옆에 붙은 단계가 사실상 독립이다. 같은 히스토그램, 그러나 _공간 구조_는 전혀 다르다. 그리고 실제 압축은 거의 다 이 구조에서 일어난다.
# Neighbor-difference entropy — what a real compressor sees.
# For each pair of adjacent pixels (right + down), count abs(diff). The
# resulting distribution carries the spatial structure the histogram missed.
def neighbor_diff_entropy(img):
diffs = []
H, W = img.shape
for i in range(H):
for j in range(W):
if j + 1 < W: diffs.append(abs(int(img[i, j]) - int(img[i, j+1])))
if i + 1 < H: diffs.append(abs(int(img[i, j]) - int(img[i+1, j])))
counts = Counter(diffs)
N = len(diffs)
return sum(-(c / N) * log2(c / N) for c in counts.values() if c > 0)
[neighbor_diff_entropy(p()) for p in (gradient, blocks, scrambled)]
# → [0.34, 1.34, 4.07] gradient ~0, scrambled ~log2(16) ≈ 4.
# Histograms saw three identical sources; neighbor differences see three
# very different ones. That gap is the entire point of spatial compression.이웃 차이 — 다른 알파벳의 엔트로피
요령은 이렇다. 각 픽셀을 0..15 값 그 자체로 인코딩하는 대신, 왼쪽이나 위쪽 이웃과의 _차이_로 인코딩한다. 첫 픽셀은 여전히 비트를 다 써야 하지만, 이후 픽셀은 대부분 작은 수 — 보통은 0이다. 이제 이 차이의 분포에 를 적용한다. 그라디언트라면: 차이가 거의 0 아니면 1이고, 엔트로피는 1 bit/픽셀에 한참 못 미친다. 뒤섞임이라면: 차이가 전체 범위에 고르게 퍼져 있어 엔트로피가 다시 4 가까이로 올라간다. 위젯에 두 값이 나란히 뜬다. 그 비율이 곧 구조를 보는 압축기와 히스토그램만 보는 압축기 사이의 격차다.
이 페이지를 한 문장으로 줄이면 이렇다.
무손실 vs 손실 — 히스토그램 한계 밖으로 나가는 두 길
히스토그램 엔트로피는
둘째, 정확함을 포기한다.
# Real-world check: encode all three with PNG and compare file sizes.
# PNG uses filter prediction (essentially neighbor differences) followed by
# DEFLATE (LZ77 + Huffman). Same pixel multiset, very different output.
import io
from PIL import Image
def png_size(img):
buf = io.BytesIO()
Image.fromarray((img * 16).astype('uint8'), mode='L').save(buf, 'PNG')
return len(buf.getvalue())
sizes = {p.__name__: png_size(p()) for p in (gradient, blocks, scrambled)}
# → {'gradient': ~120, 'blocks': ~140, 'scrambled': ~310}
#
# The histograms predicted 4 bits × 256 pixels = 128 bits = 16 bytes of
# *symbol payload*; PNG's framing overhead is fixed. The interesting number
# is the ratio of payloads: gradient ≈ 1×, scrambled ≈ 2-3× larger. Same
# histogram, different file size — the signature of spatial compression.히스토그램 엔트로피는 심볼끼리 독립이라고 가정한 인코딩의 하한이지, 실제 코덱이 도달하는 값의 상한은 아니다. 그라디언트 패치를 PNG로 저장하면 4 bits/픽셀 × 256 픽셀 = 128바이트보다 훨씬 작아진다 — PNG는 LZ77도 함께 쓰기 때문이고, LZ77은 이웃 차이 엔트로피가 아예 포착하지 못하는 똑같은 리터럴이 반복되는 구간을 찾는다. 히스토그램은 4 비트라 말하고, 이웃 차이는 ~0.4라고 말하고, LZ77까지 얹은 PNG는 사실상 ~0.1이라 말한다. 한 단계 내려갈 때마다, 그림이 실제로 무엇인가에 대한 더 풍부한 모델이 생긴다. 같은 이미지가 어떤 질문을 던지느냐에 따라 더 압축되기도 하고 덜 압축되기도 한다.
파일의 크기는 픽셀 수가 아니라 정보량에 묶인다. 히스토그램은 어떤 값이 나오는지를 보고, 이웃 차이는 구조를 보고, 변환 코덱은 주파수 성분을 본다. 관점을 바꿀 때마다 정보 예산이 더 작아진다 — 그리고 그것이 곧 인코더가 쓸 수 있는 예산이다.
4색 이미지에 픽셀 100개: 빨강 50, 초록 25, 파랑 15, 노랑 10. 픽셀당 비트로 히스토그램 엔트로피를 계산하라 (대략값: , , , ).
100×100 이미지 두 장. 둘 다 정확히 같은 픽셀 값 10,000개를 담고 있다: 검은 픽셀 5,000, 흰 픽셀 5,000. 이미지 A는 깔끔한 체스판 패턴, 이미지 B는 같은 픽셀들을 무작위로 흩뿌린 것. 히스토그램 엔트로피는 동일하다 ( bit/픽셀, p(검) = p(흰) = 0.5). PNG로 저장하면 어느 쪽이 더 작은가, 이유는? 대략적인 비율을 예측하라.
위젯에서 그라디언트와 뒤섞임을 번갈아 눌러보자. 둘 다 bits/픽셀로 표시된다. 그러면 순진한 엔지니어는 이렇게 결론짓는다. “두 이미지 모두 256 × 4 = 1024 비트 = 128바이트 페이로드에 PNG 오버헤드를 얹으면 끝.” arc 4의 코드가 실제 PNG 크기를 잰다. 히스토그램이 같다는데, 왜 뒤섞인 파일은 거의 두 배 크기로 나오는가?
사진의 JPEG는 같은 사진을 무손실로 인코딩할 때의 히스토그램 한계보다 더 작아지는 일이 흔하다. 어떻게 가능한가? 한 문장으로, 엔트로피가 무엇을 묶고 무엇은 묶지 못하는지 가르라.
정보이론 강의는 엔트로피를 i.i.d. 원천 위에서 정의하고 거기서 멈춘다 — 픽셀, 오디오 샘플, 언어 토큰을 모두 독립적인 추출인 듯 다룬다. 실제 신호는 한 번도 그런 적이 없다. 심볼 엔트로피와 맥락 엔트로피 (실제 코덱이 써먹는 것) 사이의 격차, 그것이 4MB 사진이 눈에 띄는 손실 없이 400KB로 줄어드는 이유 전부다. “압축”이 하나의 수가 아니라 사다리인 이유도 거기 있다. Lemma는 같은 위젯에 히스토그램 관점과 이웃 차이 관점을 함께 두어, 그 격차를 처음부터 눈에 보이게 했다 — 같은 히스토그램, 같은 H, 그러나 그림은 셋, 파일 크기도 셋. 엔트로피 모듈이 한계에 이름을 붙이고, 이 페이지는 그 한계가 놓치는 것을 보여준다.