此文由 Mix Space 同步更新至 xLog
為獲得最佳瀏覽體驗,建議訪問原始鏈接
https://www.do1e.cn/posts/codec/JPEG-detail
前言#
這篇博客原本於 2021-08-22 發表在CSDN,此處複製過來並順便糾正了部分格式問題
最近在學習如何進行 JPEG 編碼,在網上找了很多文章,發現很少有文章將每一個細節都講得十分清楚,以至於在編程時踩了不少的坑,因此打算儘量結合 python 代碼寫一個涉及細節部分的文章。具體程序可以參考我在 Github 上的開源項目。
當然,我這篇介紹以及代碼都不太完善,甚至可能有一些錯誤,僅僅能作為一個入門引導,還請見諒。
JPEG 文件中的各種標誌#
很多文章都對 JPEG 文件的標誌有所介紹,我也上傳了一個對實際圖片進行標註的文檔 (點擊下載) 可供參考。
所有的標誌符均以 0xff (16 進制的 255) 開始,後面跟著代表本塊數據的字節數以及描述本塊信息的數據,具體如下圖:
# 寫入jpeg格式的譯碼信息
# filename: 輸出文件名
# h: 圖片高度
# w: 圖片寬度
def write_head(filename, h, w):
# 二進制寫入形式打開文件(覆蓋)
fp = open(filename, "wb")
# SOI
fp.write(pack(">H", 0xffd8))
# APP0
fp.write(pack(">H", 0xffe0))
fp.write(pack(">H", 16)) # APP0字節數
fp.write(pack(">L", 0x4a464946)) # JFIF
fp.write(pack(">B", 0)) # 0
fp.write(pack(">H", 0x0101)) # 版本號: 1.1
fp.write(pack(">B", 0x01)) # 像素密度單位: 像素/英寸
fp.write(pack(">L", 0x00480048)) # XY方向像素密度
fp.write(pack(">H", 0x0000)) # 無縮略圖信息
# DQT_0
fp.write(pack(">H", 0xffdb))
fp.write(pack(">H", 64+3)) # 量化表字節數
fp.write(pack(">B", 0x00)) # 量化表精度: 8bit(0) 量化表ID: 0
tbl = block2zz(std_luminance_quant_tbl)
for item in tbl:
fp.write(pack(">B", item)) # 量化表0內容
# DQT_1
fp.write(pack(">H", 0xffdb))
fp.write(pack(">H", 64+3)) # 量化表字節數
fp.write(pack(">B", 0x01)) # 量化表精度: 8bit(0) 量化表ID: 1
tbl = block2zz(std_chrominance_quant_tbl)
for item in tbl:
fp.write(pack(">B", item)) # 量化表1內容
# SOF0
fp.write(pack(">H", 0xffc0))
fp.write(pack(">H", 17)) # 幀圖像信息字節數
fp.write(pack(">B", 8)) # 精度: 8bit
fp.write(pack(">H", h)) # 圖像高度
fp.write(pack(">H", w)) # 圖像寬度
fp.write(pack(">B", 3)) # 顏色分量數: 3(YCrCb)
fp.write(pack(">B", 1)) # 顏色分量ID: 1
fp.write(pack(">H", 0x1100)) # 水平垂直採樣因子: 1 使用的量化表ID: 0
fp.write(pack(">B", 2)) # 顏色分量ID: 2
fp.write(pack(">H", 0x1101)) # 水平垂直採樣因子: 1 使用的量化表ID: 1
fp.write(pack(">B", 3)) # 顏色分量ID: 3
fp.write(pack(">H", 0x1101)) # 水平垂直採樣因子: 1 使用的量化表ID: 1
# DHT_DC0
fp.write(pack(">H", 0xffc4))
fp.write(pack(">H", len(std_huffman_DC0)+3)) # 哈夫曼表字節數
fp.write(pack(">B", 0x00)) # DC0
for item in std_huffman_DC0:
fp.write(pack(">B", item)) # 哈夫曼表內容
# DHT_AC0
fp.write(pack(">H", 0xffc4))
fp.write(pack(">H", len(std_huffman_AC0)+3)) # 哈夫曼表字節數
fp.write(pack(">B", 0x10)) # AC0
for item in std_huffman_AC0:
fp.write(pack(">B", item)) # 哈夫曼表內容
# DHT_DC1
fp.write(pack(">H", 0xffc4))
fp.write(pack(">H", len(std_huffman_DC1)+3)) # 哈夫曼表字節數
fp.write(pack(">B", 0x01)) # DC1
for item in std_huffman_DC1:
fp.write(pack(">B", item)) # 哈夫曼表內容
# DHT_AC1
fp.write(pack(">H", 0xffc4))
fp.write(pack(">H", len(std_huffman_AC1)+3)) # 哈夫曼表字節數
fp.write(pack(">B", 0x11)) # AC1
for item in std_huffman_AC1:
fp.write(pack(">B", item)) # 哈夫曼表內容
# SOS
fp.write(pack(">H", 0xffda))
fp.write(pack(">H", 12)) # 掃描開始信息字節數
fp.write(pack(">B", 3)) # 顏色分量數: 3
fp.write(pack(">H", 0x0100)) # 顏色分量1 DC、AC使用的哈夫曼表ID
fp.write(pack(">H", 0x0211)) # 顏色分量2 DC、AC使用的哈夫曼表ID
fp.write(pack(">H", 0x0311)) # 顏色分量3 DC、AC使用的哈夫曼表ID
fp.write(pack(">B", 0x00))
fp.write(pack(">B", 0x3f))
fp.write(pack(">B", 0x00)) # 固定值
fp.close()
到這裡我們就只剩圖像數據部分沒有寫入了,但圖像數據部分到底是如何編碼的,以及上面提到的量化、哈夫曼編碼具體是怎樣實現的,請看下一部分的介紹。
JPEG 編碼流程#
由於 JPEG 編碼過程中需要對圖片進行 8*8 分塊,這就要求圖片的高度和寬度均為 8 的倍數,因此我們可以採用圖像內插或抽樣的方法,將圖片進行微小的改變,變成高度和寬度均為 8 的倍數的圖片,對於一個成千上萬個像素點的圖片而言,這個操作並不會對圖片的整體橫縱比產生很大的變化。
# 轉換圖片大小,必須能被切分成8*8的小塊
if((h % 8 == 0) and (w % 8 == 0)):
nblock = int(h * w / 64)
else:
h = h // 8 * 8
w = w // 8 * 8
YCrCb = cv2.resize(YCrCb, [h, w], cv2.INTER_CUBIC)
nblock = int(h * w / 64)
色彩空間轉換#
JPEG 圖像統一採用 YCbCr 的色彩空間,這是因為人眼對亮度感知較強而對色度的感知則較弱,因此我們選擇性地增大對 Cb 和 Cr 分量的壓縮,既能保證圖片觀感不變,又能更大程度地減小圖片的大小。變換為 YCbCr 空間後,我們可以對 Cb Cr 色彩分量進行採樣,減少它們的點數,從而實現更大程度的壓縮。常見的採樣格式有 4:4:4、4:2:2、4:2:0。這裡便對應了 SOF0 標識中的水平採樣因子和垂直採樣因子。為簡單起見,本文中所有的採樣因子均為 1,也就是不進行採樣,一個 Y 分量對應一個 Cb Cr 分量 (4:4:4)。而 4:2:2 為兩個 Y 分量對應一個 Cb Cr 分量,4:2:0 為四個 Y 分量對應一個 Cb Cr 分量。如下圖,每個單元格對應一個 Y 分量,而藍色格子則是 Cb Cr 分量採樣的像素點。
色彩空間轉換的公式為:
上述運算均四捨五入為整數。在 24 位的 RGBbmp 圖片中 R G B 分量的範圍均為 0-255,經過簡單的數學關係,我們不難發現 Y Cb Cr 分量的範圍也是 0-255。在 JPEG 圖像中,通常我們需要對每個分量減去 128,使範圍有正有負。
python 中可以使用 opencv 庫中的函數進行色彩空間變換:
YCrCb = cv2.cvtColor(BGR, cv2.COLOR_BGR2YCrCb)
npdata = np.array(YCrCb, np.int16)
8*8 分塊#
JPEG 編碼中是對每個 8*8 塊進行處理,按從上到下、從左到右的順序進行接下來的數據處理,最後將每個分塊的數據組合在一起就行了。對於每一個分塊的 Y Cb Cr 三個色彩分量,則按照 Y Cb Cr 的順序,採取相同的操作(使用的量化表和哈夫曼表會有所不同)。
for i in range(0, h, 8):
for j in range(0, w, 8):
...
DCT 變換#
DCT (離散餘弦變換),將空間域的數據換算到頻域進行運算,這樣我們可以在頻域上選擇性地減少高頻分量的數據,而並不會對圖像觀感產生較大影響。而相對於離散傅里葉變換,離散餘弦變換均是在實數域進行運算,更有利於計算機進行運算。離散餘弦變換的公式如下:
其中 $C (u)=\begin {cases}\frac {1}{\sqrt {2}}&u=0\\1&u\neq0\end {cases}$。在 JPEG 中,$M=N=8$
當然也可以使用 opencv 庫中的函數:
now_block = npdata[i:i+8, j:j+8, 0] - 128 # 取出一個8*8塊並減去128 Y分量
now_block = npdata[i:i+8, j:j+8, 2] - 128 # 取出一個8*8塊並減去128 Cb分量
now_block = npdata[i:i+8, j:j+8, 1] - 128 # 取出一個8*8塊並減去128 Cr分量
now_block_dct = cv2.dct(np.float32(now_block)) # DCT變換
量化#
經過 DCT 變換後,直流分量為 88 塊的第一個元素,低頻分量集中在左上角,而高頻分量集中在右下角。為了選擇性地去掉高頻分量,我們可以進行量化操作,實際上就是對 88 方塊中的每一個元素除以一個定值。量化表中左上角的元素較小而右下角較大。一組量化表例子如下所示 (Y 分量和 Cb Cr 分量使用不同的量化表):
# 亮度量化表
std_luminance_quant_tbl = 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]
],
np.uint8
)
# 色度量化表
std_chrominance_quant_tbl = np.array(
[
[17, 18, 24, 47, 99, 99, 99, 99],
[18, 21, 26, 66, 99, 99, 99, 99],
[24, 26, 56, 99, 99, 99, 99, 99],
[47, 66, 99, 99, 99, 99, 99, 99],
[99, 99, 99, 99, 99, 99, 99, 99],
[99, 99, 99, 99, 99, 99, 99, 99],
[99, 99, 99, 99, 99, 99, 99, 99],
[99, 99, 99, 99, 99, 99, 99, 99]
],
np.uint8
)
量化過程代碼:
now_block_qut = quantize(now_block_dct, 0) # Y分量 量化
now_block_qut = quantize(now_block_dct, 2) # Cb分量 量化
now_block_qut = quantize(now_block_dct, 1) # Cr分量 量化
# 量化
# block: 當前8*8塊的數據
# dim: 維度 0:Y 1:Cr 2:Cb
def quantize(block, dim):
if(dim == 0):
# 使用亮度量化表
qarr = std_luminance_quant_tbl
else:
# 使用色度量化表
qarr = std_chrominance_quant_tbl
return (block / qarr).round().astype(np.int16)
經過量化之後,8*8 方塊中右小角出現了較多的 0,為了使這些 0 集中,使行程編碼得到更少的數據量,我們接下來進行 zigzag 掃描。
zigzag 掃描#
所謂的 zigzag 掃描,實際上便是將 8*8 的方塊按照如下順序變成一個 64 項的列表。
最終我們得到一個這樣的長度為 64 的列表:(41, -8, -6, -5, 13, 11, -1, 1, 2, -2, -3, -5, 1, 1, -5, 1, 0, 0, 0, -1, 0, 0, 0, 0, 0, 0, 1, 1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0)。接下來的操作我們都以這個列表為例。
需要注意的是,存儲量化表時我們也要將量化表對應地進行 zigzag 掃描,按照這種形式存儲才能讓圖片查看器解碼出正確的圖片 (我一開始便是在這個細節上花費了很多調試的時間),可見本文一開始寫入標識符的代碼。
now_block_zz = block2zz(now_block_qut) # zigzag掃描
# zigzag掃描
# block: 當前8*8塊的數據
def block2zz(block):
re = np.empty(64, np.int16)
# 當前在block的位置
pos = np.array([0, 0])
# 定義四個掃描方向
R = np.array([0, 1])
LD = np.array([1, -1])
D = np.array([1, 0])
RU = np.array([-1, 1])
for i in range(0, 64):
re[i] = block[pos[0], pos[1]]
if(((pos[0] == 0) or (pos[0] == 7)) and (pos[1] % 2 == 0)):
pos = pos + R
elif(((pos[1] == 0) or (pos[1] == 7)) and (pos[0] % 2 == 1)):
pos = pos + D
elif((pos[0] + pos[1]) % 2 == 0):
pos = pos + RU
else:
pos = pos + LD
return re
差分編碼 (直流分量)#
直流分量的數值往往較大,同時相鄰 8*8 方塊的直流分量往往又十分相近,因此採用差分編碼能夠更大程度地節約空間。所謂的差分編碼便是存儲當前方塊與上一方塊直流分量的差值,而第一個方塊則存儲本身。需要注意的是,對於 Y Cb Cr 三個分量是對應地進行差分編碼,即各個分量對應相減。在下文再介紹如何對直流分量 now_block_dc 進行編碼與存儲。
last_block_ydc = 0
last_block_cbdc = 0
last_block_crdc = 0
now_block_dc = now_block_zz[0] - last_block_ydc # 直流分量差分形式記錄
last_block_ydc = now_block_zz[0] # 記錄本次量
now_block_dc = now_block_zz[0] - last_block_cbdc
last_block_cbdc = now_block_zz[0]
now_block_dc = now_block_zz[0] - last_block_crdc
last_block_crdc = now_block_zz[0]
0 的行程編碼 (交流分量)#
經過 zigzag 掃描後,不少 0 集中在了一起,交流分量的列表為:(-8, -6, -5, 13, 11, -1, 1, 2, -2, -3, -5, 1, 1, -5, 1, 0, 0, 0, -1, 0, 0, 0, 0, 0, 0, 1, 1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0)。
0 的行程編碼每次存儲兩個數,第二個數為一個非 0 數,第一个數則是這個非 0 的數前面有多少個 0。最後以兩個 0 作為結束標識符(尤其注意,當輸入數據不以 0 結尾時,不需要兩個 0 作為結束標識符,這個 bug 讓我找到好久,見下面代碼 23 行)。如上面這個列表的經過行程編碼後得到:(0, -8), (0, -6), (0, -5), (0, 13), (0, 11), (0, -1), (0, 1), (0, 2), (0, -2), (0, -3), (0, -5), (0, 1), (0, 1), (0, -5), (0, 1), (3, -1), (6, 1), (0, 1), (0, -1),(15, 0), (11, 1) , (0, 0)。
now_block_ac = RLE(now_block_zz[1:])
# 0的行程編碼
# AClist: 要編碼的交流數據
def RLE(AClist: np.ndarray) -> np.ndarray:
re = []
cnt = 0
for i in range(0, 63):
if(AClist[i] == 0 and cnt != 15):
cnt += 1
else:
re.append(cnt)
re.append(AClist[i])
cnt = 0
# 刪除末尾的所有[15 0]
while(re[-1] == 0):
re.pop()
re.pop()
if(len(re) == 0):
break
# 在結尾添加兩個0作為結束標記
if(AClist[-1] == 0):
re.extend([0, 0])
return np.array(re, np.int16)
JPEG 特殊二進制編碼#
經過上述鋪墊後,此部分將真正介紹經過編碼後的直流分量和交流分量都是如何以數據流形式寫入文件的。
在 JPEG 編碼中,有如下的二進制編碼形式:
數值 bit長度 實際保存值
0 0 無
-1,1 1 0,1
-3,-2,2,3 2 00,01,10,11
-7,-6,-5,-4,4,5,6,7 3 000,001,010,011,100,101,110,111
-15,..,-8,8,..,15 4 0000,..,0111,1000,..,1111
-31,..,-16,16,..,31 5 00000,..,01111,10000,..,11111
-63,..,-32,32,..,63 6 ...
-127,..,-64,64,..,127 7 ...
-255,..,-128,128,..,255 8 ...
-511,..,-256,256,..,511 9 ...
-1023,..,-512,512,..,1023 10 ...
-2047,..,-1024,1024,..,2047 11 ...
對於一個要存儲的數,我們需要按照上述形式得到需要存儲的 bit 長度和實際保存的二進制數值。觀察其規律不難發現,正數保存值就是它的實際二進制,bit 長度也為其實際的 bit 長度。而對應的負數也是相同的 bit 長度並且二進制數值為按位取非後的數值。0 則不需要存儲。
# 特殊的二進制編碼格式
# num: 待編碼的數字
def tobin(num):
s = ""
if(num > 0):
while(num != 0):
s += '0' if(num % 2 == 0) else '1'
num = int(num / 2)
s = s[::-1] # 反向
elif(num < 0):
num = -num
while(num != 0):
s += '1' if(num % 2 == 0) else '0'
num = int(num / 2)
s = s[::-1]
return s
對於直流分量,假設差分編碼後的數值為 - 41,按照上述操作我們可以得到它的 bit 長度為 6,保存的二進制數據流為 010110。對於數據 6,我們需要採用範式哈夫曼編碼保存其二進制數據流,此部分將在第 9 部分介紹,我們先假設 6 存儲的二進制數據流為 100,那麼此 8*8 方塊的某個顏色分量的直流量存儲為 100010110。
在直流分量的二進制數據流寫入文件後,接下來對 8*8 方塊的這個顏色分量的交流量進行編碼。行程編碼後得到的數值為:(0, -8), (0, -6), (0, -5), (0, 13), (0, 11), (0, -1), (0, 1), (0, 2), (0, -2), (0, -3), (0, -5), (0, 1), (0, 1), (0, -5), (0, 1), (3, -1), (6, 1), (0, 1), (0, -1),(15, 0), (11, 1) , (0, 0)。
首先存儲 (0, -8),對於第二數,採取相同的操作,可得 4bit 和 0111,但與直流分量不同的是,我們要對 0x04 進行範式哈夫曼編碼,其中高四位為 (0, -8) 的第一個數,第四位為第二個數存儲的 bit 長度。假設 0x04 的範式哈夫曼編碼後存儲為 1011,那麼 (0, -8) 存儲為 10110111。接下來對 (0, -6) 等進行相同的操作,得到的數據流依次寫入文件。
再舉個例子 (6, 1),其中 1 存儲為 1,1bit,因此對 0x61 進行範式哈夫曼編碼,假設為 1111011,那麼 (6, 1) 存儲為 11110111。(15, 0) 則僅存儲 0xf0 的範式哈夫曼編碼數值。
按照上述過程寫完一個顏色分量 (假設為 Y) 的數據後,接下來寫這個 88 方塊的 Cb 顏色分量的數據,再接著寫 Cr 分量的數據。採用相同的方式,從左到右、從上到下寫入每個 88 方塊數據後,寫入 EOI 標識 (0xffd9),作為圖像結束。
注意:寫入數據過程中需要檢測是否寫入為 0xff,為防止標誌衝突,我們需要再後面補上 0x00。
s = write_num(s, -1, now_block_dc, DC0) # 根據編碼方式寫入直流數據
for l in range(0, len(now_block_ac), 2): # 寫入交流數據
s = write_num(s, now_block_ac[l], now_block_ac[l+1], AC0)
while(len(s) >= 8): # 記錄數據太長會導致爆內存
num = int(s[0:8], 2) # 運行速度變慢
fp.write(pack(">B", num))
if(num == 0xff): # 為防止標誌衝突
fp.write(pack(">B", 0)) # 數據中出現0xff需要在後面補兩個0x00
s = s[8:len(s)]
# 根據編碼方式寫入數據
# s: 未寫入文件的二進制數據
# n: 數據前面0的個數(-1代表DC)
# num: 待寫入的數據
# tbl: 範式哈夫曼編碼字典
def write_num(s, n, num, tbl):
bit = 0
tnum = num
while(tnum != 0):
bit += 1
tnum = int(tnum / 2)
if(n == -1): # DC
tnum = bit
if(tnum > 11):
print("Write DC data Error")
exit()
else: # AC
if((n > 15) or (bit > 11) or (((n != 0) and (n != 15)) and (bit == 0))):
print("Write AC data Error")
exit()
tnum = n * 10 + bit + (0 if(n != 15) else 1)
# 範式哈夫曼編碼記錄0的個數(AC)以及num的bit長度
s += tbl[tnum].str_code
# 特殊形式的數據存儲num
s += tobin(num)
return s
範式哈夫曼編碼#
在本文中介紹的範式哈夫曼編碼共有 4 個編碼表,分別用於亮度直流分量、色度直流分量、亮度交流分量、色度交流分量。
# 亮度直流量範式哈夫曼編碼表
std_huffman_DC0 = np.array(
[0, 0, 7, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0,
4, 5, 3, 2, 6, 1, 0, 7, 8, 9, 10, 11],
np.uint8
)
...
# 換算出哈夫曼字典
DC0 = DHT2tbl(std_huffman_DC0) # 亮度直流分量
DC1 = DHT2tbl(std_huffman_DC1) # 色度直流分量
AC0 = DHT2tbl(std_huffman_AC0) # 亮度交流分量
AC1 = DHT2tbl(std_huffman_AC1) # 色度交流分量
如上代碼中 std_huffman_DC0 等便是實際保存在標識符中的數值,具體可見標識符介紹中的代碼。這串數字中前 16 個數字 (0, 0, 7, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0) 代表編碼後長度為 1-16bit 都有多少個數,而後面跟著的 12 個數字正好為前 16 個數字之和。std_huffman_DC0 描述的實際上便是下圖:
現在我們只知道每個原始數據編碼後的數據長度,而並不知道其實際是多少。
範式哈夫曼編碼有一套自己的規則:
- 最小編碼長度的第一個數的編碼為 0;
- 相同編碼長度的編碼是連續的;
- 下個編碼長度 (假設為 j) 的第一個數的編碼 a 取決於上個編碼長度 (假設為 i) 的最後一個數的編碼 b,即
a=(b+1)<<(j-i)
。
由規則 1,我們可以直到 4 的編碼為 000。由規則 2,5 的編碼為 001、3 的編碼為 010、2 的編碼為 011...、0 的編碼為 110。由規則 3,7 的編碼為 1110、8 的編碼為 11110...
# 記錄哈夫曼字典的類
# symbol: 原始數據
# code: 對應的編碼數據
# n_bit: 編碼的二進制位數
# str_code: 編碼的二進制數據
class Sym_Code():
def __init__(self, symbol, code, n_bit):
self.symbol = symbol
self.code = code
str_code=''
mask = 1 << (n_bit - 1)
for i in range(0, n_bit):
if(mask & code):
str_code += '1'
else:
str_code += '0'
mask >>= 1
self.str_code = str_code
"""定義輸出形式"""
def __str__(self):
return "0x{:0>2x} | {}".format(self.symbol, self.str_code)
"""定義排序依據"""
def __eq__(self, other):
return self.symbol == other.symbol
def __le__(self, other):
return self.symbol < other.symbol
def __gt__(self, other):
return self.symbol > other.symbol
# 將範式哈夫曼編碼表轉換為哈夫曼字典
# data: 定義的範式哈夫曼編碼表
def DHT2tbl(data):
numbers = data[0:16] # 1~16bit長度的編碼對應的個數
symbols = data[16:len(data)] # 原數據
if(sum(numbers) != len(symbols)): # 判斷是否為正確的範式哈夫曼編碼表
print("Wrong DHT!")
exit()
code = 0
SC = [] # 記錄字典的列表
for n_bit in range(1, 17):
# 按範式哈夫曼編碼規則換算出字典
for symbol in symbols[sum(numbers[0:n_bit-1]):sum(numbers[0:n_bit])]:
SC.append(Sym_Code(symbol, code, n_bit))
code += 1
code <<= 1
return sorted(SC)
最終得到的哈夫曼字典比較長,可在我的 github 項目中查看,尋找其中的規律便可以明白我在 write_num 函數中字典的索引是那樣求得的。