透過類檔案物件流式傳輸資料¶
在此示例中,我們將演示如何解碼流式傳輸的資料。也就是說,當檔案不位於本地時,我們將演示如何僅下載解碼所需幀的資料段。我們透過 Python 的 類檔案物件 實現此功能。我們的示例使用的是影片檔案,因此我們使用 VideoDecoder 類來解碼它。但此處的所有經驗也同樣適用於音訊檔案和 AudioDecoder 類。
首先,一些樣板程式碼。我們定義了兩個函式:一個用於從給定 URL 下載內容,另一個用於計時給定函式的執行。
import torch
import requests
from time import perf_counter_ns
def get_url_content(url):
response = requests.get(url, headers={"User-Agent": ""})
if response.status_code != 200:
raise RuntimeError(f"Failed to download video. {response.status_code = }.")
return response.content
def bench(f, average_over=10, warmup=2):
for _ in range(warmup):
f()
times = []
for _ in range(average_over):
start = perf_counter_ns()
f()
end = perf_counter_ns()
times.append(end - start)
times = torch.tensor(times) * 1e-6 # ns to ms
std = times.std().item()
med = times.median().item()
print(f"{med = :.2f}ms +- {std:.2f}")
效能:先下載 vs. 流式傳輸¶
我們將研究在解碼任何幀之前下載整個影片的成本,與在解碼時能夠流式傳輸影片資料相比。為了演示一個極端情況,我們將始終只解碼影片的第一幀,同時改變我們獲取影片資料的方式。
本教程中使用的影片在網際網路上公開可用。我們對其進行了初步下載,以便了解其大小和內容。
from torchcodec.decoders import VideoDecoder
nasa_url = "https://download.pytorch.org/torchaudio/tutorial-assets/stream-api/NASAs_Most_Scientifically_Complex_Space_Observatory_Requires_Precision-MP4.mp4"
pre_downloaded_raw_video_bytes = get_url_content(nasa_url)
decoder = VideoDecoder(pre_downloaded_raw_video_bytes)
print(f"Video size in MB: {len(pre_downloaded_raw_video_bytes) // 1024 // 1024}")
print(decoder.metadata)
Video size in MB: 253
VideoStreamMetadata:
duration_seconds_from_header: 206.039167
begin_stream_seconds_from_header: 0.0
bit_rate: 9958354.0
codec: h264
stream_index: 0
begin_stream_seconds_from_content: 0.0
end_stream_seconds_from_content: 206.039167
width: 1920
height: 1080
num_frames_from_header: 6175
num_frames_from_content: 6175
average_fps_from_header: 29.97003
pixel_aspect_ratio: 1
duration_seconds: 206.039167
begin_stream_seconds: 0.0
end_stream_seconds: 206.039167
num_frames: 6175
average_fps: 29.970029921543997
我們可以看到,影片大約為 253 MB,解析度為 1920x1080,每秒約 30 幀,時長接近 3 分半鐘。由於我們只想解碼第一幀,因此不下載整個影片顯然會使我們受益!
讓我們先測試三種場景
從我們剛剛下載的*現有*影片解碼。這是我們的基準效能,因為我們將下載成本降至 0。
在解碼之前下載整個影片。這是我們想避免的最壞情況。
將 URL 直接提供給
VideoDecoder類,它會將 URL 傳遞給 FFmpeg。然後 FFmpeg 將決定在解碼之前下載多少影片。
請注意,在我們的場景中,我們始終將 VideoDecoder 類的 seek_mode 引數設定為 "approximate"。我們這樣做是為了避免在初始化期間掃描整個影片,即使我們只想解碼第一幀,這也會導致下載整個影片。有關更多資訊,請參閱 精確與近似搜尋模式:效能和精度比較。
def decode_from_existing_download():
decoder = VideoDecoder(
source=pre_downloaded_raw_video_bytes,
seek_mode="approximate",
)
return decoder[0]
def download_before_decode():
raw_video_bytes = get_url_content(nasa_url)
decoder = VideoDecoder(
source=raw_video_bytes,
seek_mode="approximate",
)
return decoder[0]
def direct_url_to_ffmpeg():
decoder = VideoDecoder(
source=nasa_url,
seek_mode="approximate",
)
return decoder[0]
print("Decode from existing download:")
bench(decode_from_existing_download)
print()
print("Download before decode:")
bench(download_before_decode)
print()
print("Direct url to FFmpeg:")
bench(direct_url_to_ffmpeg)
Decode from existing download:
med = 238.30ms +- 1.08
Download before decode:
med = 1589.95ms +- 151.48
Direct url to FFmpeg:
med = 285.81ms +- 6.19
解碼已下載的影片顯然是最快的。每次我們只想解碼第一幀就必須下載整個影片,這比解碼現有影片要慢得多。提供直接 URL 要好得多,但我們仍然可能下載了比我們需要更多的東西。
我們可以做得更好,方法是使用一個實現自己的讀取和搜尋方法的類檔案物件,該物件僅按需從 URL 下載資料。我們不需要自己實現,而是可以使用來自 fsspec 模組的此類物件,該模組提供了 Python 的檔案系統介面。請注意,使用 fsspec 庫的這些功能還需要 aiohttp 模組。你可以使用 pip install fsspec aiohttp 安裝兩者。
import fsspec
def stream_while_decode():
# The `client_kwargs` are passed down to the aiohttp module's client
# session; we need to indicate that we need to trust the environment
# settings for proxy configuration. Depending on your environment, you may
# not need this setting.
with fsspec.open(nasa_url, client_kwargs={'trust_env': True}) as file_like:
decoder = VideoDecoder(file_like, seek_mode="approximate")
return decoder[0]
print("Stream while decode: ")
bench(stream_while_decode)
Stream while decode:
med = 257.39ms +- 0.87
透過類檔案物件流式傳輸資料比先下載影片快得多。而且它不僅比提供直接 URL 快,而且更通用。 VideoDecoder 支援直接 URL,因為底層的 FFmpeg 函式支援它們。但是支援的協議型別取決於該 FFmpeg 版本支援的內容。類檔案物件可以適應任何型別的資源,包括特定於你自己的基礎設施且 FFmpeg 未知的資源。
工作原理¶
在 Python 中,類檔案物件是任何暴露用於讀取、寫入和搜尋的特殊方法的物件。雖然這些方法顯然是面向檔案的,但類檔案物件不必由實際檔案支援。就 Python 而言,如果一個物件表現得像檔案,那它就是檔案。這是一個強大的概念,因為它使得讀取或寫入資料的庫可以假定類檔案介面。呈現新穎資源的其他庫可以透過為它們的資源提供類檔案包裝器來輕鬆使用。
在我們的例子中,我們只需要用於解碼的讀取和搜尋方法。所需的確切方法簽名在下面的示例中。我們透過包裝一個實際檔案並計算每次呼叫每個方法的次數來演示此功能,而不是包裝新穎資源。
from pathlib import Path
import tempfile
# Create a local file to interact with.
temp_dir = tempfile.mkdtemp()
nasa_video_path = Path(temp_dir) / "nasa_video.mp4"
with open(nasa_video_path, "wb") as f:
f.write(pre_downloaded_raw_video_bytes)
# A file-like class that is backed by an actual file, but it intercepts reads
# and seeks to maintain counts.
class FileOpCounter:
def __init__(self, file):
self._file = file
self.num_reads = 0
self.num_seeks = 0
def read(self, size: int) -> bytes:
self.num_reads += 1
return self._file.read(size)
def seek(self, offset: int, whence: int) -> bytes:
self.num_seeks += 1
return self._file.seek(offset, whence)
# Let's now get a file-like object from our class defined above, providing it a
# reference to the file we created. We pass our file-like object to the decoder
# rather than the file itself.
file_op_counter = FileOpCounter(open(nasa_video_path, "rb"))
counter_decoder = VideoDecoder(file_op_counter, seek_mode="approximate")
print("Decoder initialization required "
f"{file_op_counter.num_reads} reads and "
f"{file_op_counter.num_seeks} seeks.")
init_reads = file_op_counter.num_reads
init_seeks = file_op_counter.num_seeks
first_frame = counter_decoder[0]
print("Decoding the first frame required "
f"{file_op_counter.num_reads - init_reads} additional reads and "
f"{file_op_counter.num_seeks - init_seeks} additional seeks.")
Decoder initialization required 9 reads and 11 seeks.
Decoding the first frame required 2 additional reads and 1 additional seeks.
雖然我們定義了一個簡單的類,主要是為了演示,但它實際上對於診斷不同解碼操作所需的讀取和搜尋量很有用。我們還引入了一個我們應該回答的神秘之處:為什麼*初始化*解碼器比解碼第一幀需要更多的讀取和搜尋?答案是,在我們解碼器的實現中,我們實際上呼叫了一個特殊的 FFmpeg 函式,該函式解碼前幾幀以返回更豐富的元資料。
還值得注意的是,Python 的類檔案介面只是故事的一半。FFmpeg 也有自己的機制,用於在解碼期間將讀取和搜尋定向到使用者定義的函式。 VideoDecoder 物件負責將你定義的 Python 方法連線到 FFmpeg。你所要做的就是用 Python 定義你的方法,其餘的交給我們。
效能:本地檔案路徑 vs. 本地類檔案物件¶
由於我們定義了一個本地檔案,讓我們進行一次額外的效能測試。我們現在有兩種方式將本地檔案提供給 VideoDecoder
透過一個*路徑*,其中
VideoDecoder物件將負責開啟該路徑上的本地檔案。透過一個*類檔案物件*,你自行開啟檔案並向
VideoDecoder提供類檔案物件。
一個顯而易見的問題是:哪個更快?下面的程式碼測試了這個問題。
def decode_from_existing_file_path():
decoder = VideoDecoder(nasa_video_path, seek_mode="approximate")
return decoder[0]
def decode_from_existing_open_file_object():
with open(nasa_video_path, "rb") as file:
decoder = VideoDecoder(file, seek_mode="approximate")
return decoder[0]
print("Decode from existing file path:")
bench(decode_from_existing_file_path)
print()
print("Decode from existing open file object:")
bench(decode_from_existing_open_file_object)
Decode from existing file path:
med = 238.10ms +- 0.61
Decode from existing open file object:
med = 238.30ms +- 1.91
值得慶幸的是,答案是這兩種方式從本地檔案解碼所需的時間大致相同。這個結果意味著在您自己的程式碼中,您可以使用任何一種更方便的方式。這個結果表明,實際讀取和複製資料的成本主導瞭解碼過程中呼叫 Python 方法的成本。
最後,讓我們清理一下我們建立的本地資源。
import shutil
shutil.rmtree(temp_dir)
指令碼總執行時間: (0 分鐘 40.094 秒)