快速生成網路mp4視訊縮圖技術

YuanZiming發表於2020-09-13

背景

由於網路原因,在下載視訊之前我們往往會希望能夠先生成一些視訊的縮圖,大致瀏覽視訊內容,再確定是否應花時間下載。如何能夠快速得到視訊多個幀的縮圖的同時儘量少的下載視訊的內容,是一個值得研究的問題。

思路

眾所周知,不考慮音訊、字幕的話,視訊是由多個影像幀拼接而成的,因此我們的目標也就是儘量只下載視訊中我們想下載的幀圖片,而忽略其他的資訊,那麼就需要獲得對應幀在檔案中所在的位置、大小、以及編碼格式,為此,首先需要了解視訊容器的格式,由於日常生活中h264編碼的mp4格式用得比較多,所以這裡只分析採用h264編碼的mp4格式。

mp4格式的主要組織格式是box,box之間可以相互巢狀,每個box的前8個位元組包括box的名字和這個box大小。在我看來,這些box主要分為三類,第一類儲存的是整個視訊的元資訊,像視訊的作者、解析度、幀率等資訊,這些資訊基本對解碼沒有影響,所以可以跳過,第二類儲存的是編碼資訊,包括視訊的關鍵幀、幀的大小、視訊分塊的位置和包含的幀數等資訊,這個是我們最關心的資訊,需要完整地下載下來。第三類儲存的是實際資料,這些我們不用完整下載,可以通過前面的編碼資訊計算我們所需要的幀在資料box中的位置,然後只下載幀對應的資料就行了,這個操作也是比較簡單的。因此,重中之重就是如何分析視訊的編碼資訊。

儲存視訊編碼資訊的box叫stbl,其下包括以下幾個box:

  • stsd:包含了h264編碼的一些資訊,後面再談它的作用。
  • stts:包含時間和幀的轉換資訊,這裡我們不需要考慮它。
  • stss:關鍵幀編號,這個比較重要,因為h264是一種壓縮率非常高的編碼格式,它把幀分為三類,其中只有一類是完全獨立的,另外兩類都需要通過周圍的幀推算出來。而這裡的關鍵幀,在定址過後得到的剛好就是h264中的獨立幀,也是我們比較需要的。由於關鍵幀之間的時間差基本一致,所以我們在需要縮圖儘可能在視訊中分佈均勻的時候,就可以在關鍵幀列表中均勻地取一定數量的關鍵幀,只對這些幀進行定址。
  • stsz:每個幀的資料大小,重要性不言而喻。
  • stsc:每個分塊包含幀的數目,注意這個box儲存格式是比較簡潔的,它會把幀數目相同的連續分塊劃為一段,只記錄每段的起始分塊和每個分塊包含幀的數目,因此需要自行處理分析。
  • stco:每個分塊資料在整個檔案中的位置,重要性不言而喻。

這樣一來,找到一個關鍵幀的過程大概可以分為以下幾步:

  1. 通過stsc box,不斷累加每個分塊的幀數,得到所需關鍵幀在哪個分塊中
  2. 通過stsc box和stsz box,得到所需關鍵幀在對應分塊中的偏移量,需要將分塊中排在所需關鍵幀前面的幀的大小累加起來
  3. 通過stco box得到分塊的偏移量,加上剛才計算得到的所需關鍵幀的塊中偏移量,就是所需關鍵幀在檔案中的偏移量,通過stsz box獲得所需關鍵幀的大小,就能得到完整的所需關鍵幀資料了。

接下來需要考慮的就是得到的關鍵幀資料,怎麼轉換為圖片。mp4裡的視訊畫面資料用的編碼格式是h264,由於h264解碼演算法非常複雜,因此我使用ffmpeg程式來進行這一操作。但是,直接從mp4裡提取出來的關鍵幀資料並不能被ffmpeg的h264解碼器所識別。這是因為h264的格式分為兩類,一類叫Annex-B,稱為h264裸流,可以直接被各類解碼器、播放器處理和播放;一類叫AVCC,通常使用h264編碼的mp4、avi等視訊容器中的畫面資料用的就是這個格式。也就是說,提取出來的關鍵幀資料是AVCC格式,需要將其轉換為Annex-B格式,才能被ffmpeg轉碼成png之類的格式。

那麼這兩個格式有什麼不同嗎?h264格式的視訊流都是由一個個NAL包組成的,每個NAL包可能是幀資料,也可能是其他一些資料,在Annex-B中,其他資料的NAL包頭部為00 00 00 01四個位元組,幀資料的NAL包頭部為由00 00 00 01分隔的PPS和SPS位元組串,這兩種位元組串裡儲存了幀的解析度等資訊,使解碼器能夠正確解析h264流。而AVCC的NAL包的前四個位元組一律為該NAL包的大小。那麼AVCC格式的h264流在mp4容器裡是怎麼被正確解碼的呢?原來它的SPS和PPS位元組串存在前面所述的stsd box中,所以將AVCC轉換成Annex-B的方法就是將其每個NAL包的前四個位元組進行替換,如果該包儲存其他資料,就將前四個位元組改為00 00 00 01,否則改成由00 00 00 01分隔的PPS和SPS位元組串。通常儲存幀資料的NAL包第5個位元組為0x65,可以由此判斷。

於是整個程式的流程就很清晰了,每次下載當前box的名字和大小(前8個位元組),如果這個box是編碼相關box的父box就進入,否則就跳過,當到達對應的box時則下載需要的資訊。接著在關鍵幀列表中儘量均勻地選取關鍵幀,然後找到這些關鍵幀對應的資料位置把它們下載下來,將資料由AVCC轉換成Annex-B,然後執行ffmpeg將資料幀轉換成圖片格式。下載檔案片段可以使用HTTP請求裡的Range頭,為了加快速度,一些能夠並行的操作可以用多執行緒來處理。

程式碼

import sys
import struct
import requests
import subprocess
from multiprocessing.dummy import Pool

img_num = 240 # 縮圖數量
url = 'https://...' # 視訊地址
path = 'D:\\tmp\\' # 下載路徑
now = 0
small = set(['moov', 'trak', 'mdia', 'minf', 'stbl']) # 需要“進入”的box名
sample2chunk = [] # stsc box中每一段的起始chunk和幀數
chunk_offset = [] # 分塊在檔案中的地址
sample_size = [] # 幀大小
key_sample = [] # 選取的關鍵幀
frames = [] # 選取的關鍵幀在檔案中的偏移和大小
cnt = 5 # 待下載的box的數目
sps = b''
pps = b''
requestnum = 0

def getrange(start, length):
    global requestnum
    requestnum += 1
    while True:
        r = os.system(f'curl -H "Range: bytes={start}-{start + length - 1}" {url} --output {path}temp{start}.bin -m {60 + length // 4000} -f') # 這裡執行curl來下載,指定了下載重試時間
        if r == 0: # 如果下載成功(curl返回值為0)
            break
    f = open(f'{path}temp{start}.bin', 'rb')
    s = f.read()
    f.close()
    os.system(f'del {path}temp{start}.bin')
    return s

while cnt > 0:
    s = getrange(now, 8)
    name = s[4:].decode() # box名
    length, = struct.unpack('>l', s[:4]) # box大小
    print(name, length)
    if name in small:
        now += 8 # “進入”該box
    elif name == 'stsd': # 獲取sps和pps
        s = getrange(now + 7 * 16 + 4, length - 7 * 16 - 4)
        spsl, = struct.unpack('>H', s[0:2])
        sps = s[2:2 + spsl]
        ppsl, = struct.unpack('>H', s[2 + spsl + 1:spsl + 5])
        pps = s[spsl + 5:spsl + 5 + ppsl]
        now += length
        cnt -= 1
    elif name == 'stsc':
        s = getrange(now, length)
        count, = struct.unpack('>l', s[12:16])
        for i in range(count):
            fc, = struct.unpack('>l', s[16 + i * 12:20 + i * 12])
            spc, = struct.unpack('>l', s[20 + i * 12:24 + i * 12])
            sample2chunk.append((fc, spc))
        now += length
        cnt -= 1
    elif name == 'stco':
        fraglength = length // 12
        frag = [(now + i * fraglength, fraglength) for i in range(11)] # 由於這個box比較大,所以對這個box進行分段然後用多執行緒來處理
        frag.append((now + 11 * fraglength, length - 11 * fraglength)) # 最後一段
        pool = Pool(12)
        r = pool.map(lambda x: getrange(*x), frag)
        pool.close()
        pool.join()
        s = b''.join(r)
        count, = struct.unpack('>l', s[12:16])
        for i in range(count):
            chunk_offset.append(struct.unpack('>l', s[16 + i * 4:20 + i * 4])[0])
        now += length
        cnt -= 1
    elif name == 'stsz':
        fraglength = length // 12
        frag = [(now + i * fraglength, fraglength) for i in range(11)] # 同上,分段多執行緒
        frag.append((now + 11 * fraglength, length - 11 * fraglength))
        pool = Pool(12)
        r = pool.map(lambda x: getrange(*x), frag)
        pool.close()
        pool.join()
        s = b''.join(r)
        count, = struct.unpack('>l', s[16:20])
        for i in range(count):
            sample_size.append(struct.unpack('>l', s[20 + i * 4:24 + i * 4])[0])
        now += length
        cnt -= 1
    elif name == 'stss':
        s = getrange(now, length)
        count, = struct.unpack('>l', s[12:16])
        gap = count // img_num # 選取關鍵幀的間隔大小
        num = count % img_num # 為了儘可能均勻選取,前num個關鍵幀間隔為gap,後面的關鍵幀間隔為gap-1
        gap += 1
        for i in range(1, img_num + 1):
            if i <= num:
                key_sample.append(struct.unpack('>l', s[12 + i * gap * 4:16 + i * gap * 4])[0])
            else:
                t = (i - num) * (gap - 1)
                key_sample.append(struct.unpack('>l', s[12 + (num * gap + t) * 4:16 + (num * gap + t) * 4])[0])
        now += length
        cnt -= 1
    else: # 跳過該box
        now += length
sample2chunk.append((len(chunk_offset) + 1, 0)) # 新增一個邊界
for i in key_sample:
    sample = 0
    first_chunk = (0, 0)
    for j in range(len(sample2chunk) - 1): # 獲得幀對應的是第幾段
        if sample + (sample2chunk[j + 1][0] - sample2chunk[j][0]) * sample2chunk[j][1] >= i:
            first_chunk = sample2chunk[j]
            break
        else:
            sample += (sample2chunk[j + 1][0] - sample2chunk[j][0]) * sample2chunk[j][1]
    true_chunk = (i - sample - 1) // first_chunk[1] + first_chunk[0] # 獲得幀對應的分塊編號
    sample += (i - sample - 1) // first_chunk[1] * first_chunk[1] # 該分塊的起始幀編號
    offset = 0
    for j in sample_size[sample:i - 1]: # 累加幀在分塊中的偏移
        offset += j
    frames.append((chunk_offset[true_chunk - 1] + offset, sample_size[i - 1]))

def process_frame(frame):
    i, j = frame
    avcc = getrange(*j)
    anexb = b''
    now = 0
    sp = b'\x00\x00\x00\x01'
    while now < j[1]:
        length, = struct.unpack('>l', avcc[now:now + 4])
        if avcc[now + 4] != 0x65: # 其他包
            anexb += sp + avcc[now + 4:now + 4 + length]
            now += 4 + length
        else: # 幀資料包
            anexb += sp + sps + sp + pps + sp + avcc[now + 4:now + 4 + length]
            now += 4 + length
    f = open(f'{path}img{i:03d}.bin', 'wb')
    f.write(anexb)
    f.close()
    os.system(f'ffmpeg -i {path}img{i:03d}.bin {path}img{i:03d}.jpg') # 轉碼
    os.system(f'del {path}img{i:03d}.bin')

pool = Pool(12)
pool.map(process_frame, list(enumerate(frames))) # 獲取幀資料和轉換過程用多執行緒並行
pool.close()
pool.join()

後記

注意該程式預設視訊影像流在音訊流前面,因為圖片流和音訊流的父box名都是trak,所以如果音訊流在前面可能程式會執行出錯。

本篇文章沒有對mp4的詳細格式進行完整介紹,如果想了解可以參考網上的其他部落格,最好的方法是下載一個mp4格式檢查器(如mp4 Inspector),它可以自動顯示mp4裡的box結構,並以十六進位制顯示box裡內容,這樣看的更清楚明白。

相關文章