5.33. ImageIO 串流

save()to_jpeg() 涵蓋了單張影格的 I/O 情境:應用程式擷取一張影格、將其編碼,然後把它推送到某處。另一類應用程式則需要序列情境:以自然的擷取速率連續記錄許多影格,將它們儲存在日後可以取回的地方,並以正確的速度播放回來。一個訓練資料蒐集指令碼會為機器學習流程擷取數百張範例影格;一個檢測站日誌會記錄每一個擷取到的零件以供追溯;一個開發指令碼則重播已儲存的序列,用先前現場擷取的資料來測試新演算法。

ImageIO 類別是 image 模組的錄製器/播放器。單一串流會保存一序列的 Image 影格(這些影格的尺寸與像素格式可能各不相同),並連同每張影格的影格間隔一起儲存,使播放時能重新還原原始的影格速率。可使用兩種後端儲存:檔案系統上的檔案,或 RAM 中固定大小的緩衝區。

5.33.1. 兩種後端儲存

檔案串流會在電源週期之間持續保存錄製內容,其大小僅受其後端儲存空間的限制。它以一個 16 位元組的魔術標頭 OMV IMG STR Vx.y 開頭,後接每張影格一個區塊;目前的寫入器會輸出 V2.0,而讀取器為了向後相容仍接受 V1.0V1.1 檔案。檔案路徑是建構子的引數;mode 則是檔案開啟模式('r' 讀取既有串流,'w' 截斷並寫入全新內容)。

# Recording to /sdcard/run.bin
stream = image.ImageIO("/sdcard/run.bin", "w")
for _ in range(120):
    img = csi0.snapshot()
    stream.write(img)
stream.close()

記憶體串流存在於建構時配置的 RAM 緩衝區中。建構子接受一個 (w, h, pixformat) 三元組而非路徑,而 mode 引數則變成預先配置的影格槽數量。緩衝區會精確地依該數量與所提供的尺寸來配置,且一旦配置完成便不允許成長——寫入超過最後一個槽會引發 EOFError,而寫入比每槽緩衝區更大的影格則會引發 ValueError。當應用程式需要把錄製內容交給下游階段而不經過檔案系統時(例如用於觸發並重播模式的近期影格短環形緩衝區),記憶體串流正是合適的工具。

# Pre-allocate space for 32 QVGA RGB565 frames in RAM
stream = image.ImageIO((320, 240, image.RGB565), 32)
for _ in range(32):
    stream.write(csi0.snapshot())

對於壓縮像素格式(image.JPEGimage.PNG),每槽大小是以每像素 2 位元來估算的;若編碼後的影格大於該估算值,會在寫入時引發 ValueError,因此預期要儲存高品質 JPEG 的應用程式,必須超量配置槽數量,或先以較低品質編碼。

type() 會回傳 image.ImageIO.FILE_STREAMimage.ImageIO.MEMORY_STREAM,使下游程式碼能根據所拿到的後端儲存類型來調整。

5.33.2. 錄製

write() 會將擷取到的 Image 附加到檔案串流(或將其儲存在記憶體串流的當前槽位),並將偏移量推進一格。同一次呼叫也會記錄自上次寫入以來的影格間隔,使播放端能在影格之間暫停適當的時間,從而保留錄製內容的自然影格速率。

單一檔案串流內允許異質影格:一段錄製內容可以自由混合 RGB565 擷取畫面、灰階裁切圖與 JPEG 編碼縮圖,而讀取器會以各自的原始尺寸與格式解碼。記憶體串流則是同質的(所有槽共用建構子所提供的 (w, h, pixformat)),因此記憶體錄製內容僅限於單一影格組態。

write() 會回傳串流物件,因此可以鏈式呼叫。在檔案串流的非結尾偏移處寫入會截斷檔案的其餘部分——這對編輯已儲存的序列很有用,但若下一次寫入位置被先前的 seek() 無意間移動了,則有風險。

sync() 會把待處理的寫入內容刷新到磁碟(對檔案串流而言;對記憶體串流則是無作用),在錄製長時間進行時應定期呼叫,以免在檔案關閉前相機重新啟動而遺失錄製尾端的內容。當 ImageIO 超出作用範圍時,解構子會自動關閉串流,但明確呼叫 close() 才是正確的做法。

5.33.3. 播放

read() 會讀取當前偏移處的影格、推進偏移量,並回傳新的 Image。當 copy_to_fb=True(預設值)時,接收到的影格會留在影格緩衝區中,因此回傳的影像可透過 IDE 預覽繪製;若為 copy_to_fb=False,影格則會落在 MicroPython 堆積上。

# Loop a recorded stream at its natural frame rate
stream = image.ImageIO("/sdcard/run.bin", "r")
while True:
    img = stream.read()
    # img is now in the frame buffer; the IDE shows it
    # and the script can run any analysis it likes

有兩個關鍵字控制播放行為。loop=True(檔案串流的預設值)會在到達錄製結尾時將讀取指標繞回起點,因此呼叫永遠不會回傳 Noneloop=False 則在錄製內容耗盡時回傳 None,使呼叫端的迴圈終止。pause=True(預設值)會封鎖呼叫,直到寫入時所記錄的影格間隔過去為止,使播放影格速率與原始擷取影格速率相符;pause=False 則立即回傳,這對於想要盡快讀完整段錄製而不在意原始時序的分析流程很有用。

相同的迴圈模式也適用於記憶體串流,差別在於 loop 會被忽略——讀取超過記憶體串流結尾會引發 EOFError。記憶體環形的預期模式是在需要繞回時明確地 seek() 回到零。

5.33.5. 可在主機上播放的錄製內容

當錄製內容要在相機上播放回來時,ImageIO 串流是合適的工具——它們以原生像素格式保留每一張擷取到的影格,影格間隔被精確記錄,且下游指令碼可以逐步瀏覽、seek 並重新分析而毫無損失。然而,當錄製內容必須能在主機上播放時(工作站、手機、網頁播放器),它們就不是合適的工具了。主機期望的是標準的影片容器,而非 OpenMV 的磁碟魔術標頭格式。

有兩個獨立的模組涵蓋可在主機上播放的情境。mjpeg 模組錄製 Motion JPEG:一序列 JPEG 壓縮影格打包進單一 AVI 風格的容器中,VLC、QuickTime、ffmpeg 與標準的網頁 video 標籤都能直接播放。gif 模組錄製動畫 GIF:一序列未壓縮(或調色盤壓縮)的影格,並帶有明確的每影格延遲,可在任何支援動畫 GIF 的網頁瀏覽器或影像檢視器中播放。

mjpeg 模組是長時間錄製的自然選擇。JPEG 壓縮使檔案大小維持在可控範圍內——在所設定的品質下,每一張影格都與 to_jpeg() 相當——因此延長的擷取工作階段也能維持在 SD 卡的容量預算之內。其用法與 ImageIO 的錄製方式非常相近:

import mjpeg

m = mjpeg.Mjpeg("/sdcard/run.mjpeg")
while running:
    m.add_frame(csi0.snapshot(), quality=85)
m.close()

mjpeg.Mjpeg 接受與其他影像方法相同的繪製風格位置引數與縮放關鍵字,因此錄製內容在輸入時可以逐影格縮放、裁切或調色盤映射。建構子的 widthheight 引數預設為主影格緩衝區的尺寸,並固定輸出解析度;每張附加的影格都會被縮放(保留長寬比)以符合該尺寸。sync() 會在長時間錄製期間將檔案刷新到磁碟,而 close() 則會定稿容器——未被乾淨關閉的 Motion JPEG 檔案是無法播放的,因此遵守此做法很重要。

gif 模組是短時間錄製內容並原封不動分享給非技術觀眾時的自然選擇——為示範擷取的幾秒鐘動作、用於文件的動畫插圖、嵌入聊天訊息中的事件片段。GIF 影格以未壓縮(或在 7 位元色彩深度下調色盤壓縮)方式儲存,這使得檔案每秒的大小遠大於 Motion JPEG,因而不適用於長於幾秒鐘的錄製,但其結果可直接放進任何瀏覽器:

import gif

g = gif.Gif("/sdcard/clip.gif")
while running:
    g.add_frame(csi0.snapshot(), delay=10)
g.close()

add_frame() 上的 delay 引數是以百分之一秒為單位的每影格顯示時間(10 表示每影格 100 毫秒,即 10 fps),這是標準的 GIF 播放控制方式。建構子的 loop 關鍵字設定產生的片段是否在檢視器中自動循環(預設為 True,符合慣常的「動畫 GIF」預期)。

這三種錄製途徑合起來涵蓋了常見的情境:ImageIO 用於相機上的重新處理,Motion JPEG 用於長時間且可在主機上播放的錄製,動畫 GIF 用於短時間且可在主機上播放的片段。三者之間的抉擇歸結於誰來播放錄製內容。在相機本身上執行的下游階段讀取 ImageIO;主機工作站或網頁檢視器則讀取 MJPEG 或 GIF。

5.33.6. 觸發並重播模式

一個實用的模式是把記憶體串流與觸發條件結合起來。相機持續錄製到一個 count 槽的記憶體環形緩衝區中,每繞一圈就覆寫最舊的槽。當觸發條件啟動時(色塊進入影格、移動事件超過閾值、按下按鈕),應用程式會擷取環形緩衝區的內容——最近的 count 張影格——並把它們寫入 SD 卡上的檔案串流中。其結果是一段觸發前錄製內容,它擷取了相機實際注意到的事件之前的數秒鐘,而不僅僅是事件之後的數秒鐘,後者正是天真的「觸發時才擷取」錄製器的典型限制。

一旦掌握了串流類別,實作便相當直接:一個固定大小的記憶體串流作為環形(當偏移量到達槽數量時明確 seek() 回到零),主迴圈在每次迭代時擷取影格進去,而觸發處理常式則逐影格讀出記憶體串流,並將每張影格寫入一個以觸發時間戳記命名的檔案串流中。