6.2 JPEG壓縮流程#
概念說明#
JPEG (1992) 是史上最成功的有損影像格式。它的目標:壓縮率 10:1 以上,而人眼幾乎看不出差別。達成方法是精準地只丟棄「人眼不敏感」的資訊。完整流程分六步:
1. 色彩空間轉換:RGB → YCbCr。Y 是亮度、Cb/Cr 是色度。人眼對亮度變化敏感、對色度遲鈍(視網膜的桿狀細胞只感光),所以色度可以做下取樣 (chroma subsampling):4:2:0 表示 Cb、Cr 在水平垂直都縮一半,資料量立減一半而視覺幾乎無損。
2. 8×8 區塊 DCT:每個通道切成 8×8 區塊(5.2 節),轉成 64 個頻率係數。左上角是低頻(大結構),右下角是高頻(細節)。區塊太小是 JPEG 的先天妥協:壓縮快、可局部解碼,但埋下方塊效應的種子。
3. 量化 (quantization)——有損的核心。用一張 8×8 量化表(標準表由亮度/色度心理視覺實驗定出)逐係數相除並四捨五入:
Q(u,v) = round(F(u,v) / T(u,v))
量化表的設計:低頻除數小(保真)、高頻除數大(放膽丟)。除完之後右下角大量係數變 0——這是唯一不可逆的步驟。品質參數 QF (1~100) 就是縮放這張表:QF 高 → 除數小 → 檔案大且失真小。
4. Zigzag 掃描:把 8×8 係數矩陣沿 Z 字形排成一維,讓大量 0 連成連續游程,方便 RLE。
5. RLE + 差分編碼:非零係數編成 (跳過的 0 個數, 值) 對;每區塊的 DC 係數與上一區塊做差分(相鄰區塊的 DC 通常接近,差值很小)。
6. Huffman 熵編碼:對 (游程, 值) 符號用 Huffman 碼輸出位元流。
解碼倒序執行:Huffman 解碼 → 反 RLE → 反 Zigzag → 反量化(乘回 T)→ 逆 DCT → 色度上取樣 → YCbCr 轉 RGB。反量化無法恢復量化時丟掉的精度——丟了就是丟了。
失真類型:高壓縮率下的典型偽影——(1) 方塊效應:8×8 區塊獨立處理,邊界不連續;(2) 振鈴:量化後殘留的高頻係數在強邊緣旁產生波紋(吉布斯現象);(3) 色度下取樣造成的紅色細節滲色。
演算法步驟#
- RGB → YCbCr;Cb、Cr 4:2:0 下取樣。
- 逐 8×8 區塊:減 128 → DCT。
- 除以量化表、四捨五入。
- Zigzag → RLE → DC 差分 → Huffman。
- 解碼端反向重建。
Python 程式碼範例#
import numpy as np
N = 8
k = np.arange(N)
D = np.cos((2*k[None,:]+1) * k[:,None] * np.pi / (2*N))
D[0] /= np.sqrt(2); D *= np.sqrt(2/N)
Q50 = 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]])
def jpeg_roundtrip(img, QF):
scale = 5000/QF if QF < 50 else 200 - 2*QF
Q = np.clip((Q50*scale + 50) / 100, 1, 255)
blocks, bits_est = [], 0
for y in range(0, img.shape[0], 8):
for x in range(0, img.shape[1], 8):
B = img[y:y+8, x:x+8].astype(float) - 128
C = D @ B @ D.T
Qc = np.round(C / Q).astype(int)
zz = np.concatenate([np.diagonal(np.flipud(Qc), offset=o)[::(2*(o%2)-1)]
for o in range(-7, 8)])
nz = np.count_nonzero(zz)
bits_est += nz * 4 + (64 - nz) # 粗略熵估計
blocks.append((y, x, Qc * Q))
recon = np.zeros_like(img, dtype=float)
for y, x, Cq in blocks:
recon[y:y+8, x:x+8] = D.T @ Cq @ D + 128
return recon.clip(0, 255), bits_est
rng = np.random.default_rng(11)
img = (np.add.outer(np.linspace(60,180,64), np.linspace(80,200,64))
+ rng.normal(0, 6, (64,64))).clip(0,255).astype(np.uint8)
for QF in [90, 50, 20]:
rec, bits = jpeg_roundtrip(img, QF)
mse = ((rec - img)**2).mean()
psnr = 10*np.log10(255**2/mse) if mse > 0 else 99
print(f"QF={QF}: 估計 {bits/8:.0f} bytes(原始 {4096} bytes, "
f"壓縮率 {4096/(bits/8):.1f}:1), PSNR = {psnr:.1f} dB")
# 方塊效應的量化:跨 8 的倍數邊界 vs 區塊內的梯度
rec, _ = jpeg_roundtrip(img, 10)
g_cross = np.abs(np.diff(rec, axis=1))[:, 7::8].mean() # 區塊邊界
g_inner = np.abs(np.diff(rec, axis=1))[:, [0,1,2,3,4,5]].mean()
print(f"QF=10: 邊界梯度 {g_cross:.2f} vs 區塊內 {g_inner:.2f}(比值越大方塊越明顯)")
執行後:QF=90 時 PSNR 高達 40 dB 以上、壓縮率低;QF=20 時壓縮率超過 10 倍、PSNR 掉到 30 dB 附近;QF=10 的邊界梯度明顯大於區塊內梯度——這就是方塊效應的數值證據。
複雜度分析#
每個 8×8 區塊:快速 DCT O(64·log 8),量化 O(64),熵編碼 O(64)。全影像 O(H·W)——JPEG 是線性時間的。空間:編碼中間產物 O(H·W),位元流輸出通常遠小於原圖。解碼對稱且同為線性,並支援只解碼部分區塊(快速縮圖,libjpeg 的 scaled decoding)。相比 6.1 的無損(1.5~3 倍),JPEG 的 10~30 倍來自量化環節對「視覺冗餘」的釋放;若需要無損 JPEG,可用預測式 (lossless JPEG) 或改用 JPEG-LS/WebP 無損模式。
練習#
- 把量化表換成全 1(不量化),確認流程變成無損,並算出此時相對於 6.1 無損編碼的壓縮率差距。
- 實作 4:2:0 色度下取樣與上取樣,對一張紅色細字影像比較有無下取樣的視覺差異,並量化色度通道的誤差。
- 對 QF = 5, 10, 20, 40, 80 各記錄(檔案大小, PSNR),畫出 rate-distortion 曲線;討論「PSNR 相同時視覺品質是否相同」。