12.8. 串流影格¶
自訂通道最常見的實際用途,就是以相機的影格率將影像影格從相機串流到主機程式。其運作機制比表面上看起來更微妙:一張 JPEG 可能達到 25 KB 甚至更大,因此主機會分成數個片段讀取它,而相機的擷取迴圈必須避免在讀取過程中覆寫緩衝區。正確的做法(此處所示,也是 openmv-projects/tools/ 中工具所採用的)會將緩衝區「鎖存(latch)」住,直到主機取走最後一個位元組為止。
12.8.1. 相機端¶
一個影格通道,擷取到單一影格緩衝區,在主機首次讀取時將其鎖存,並且只有在主機消化完整張影像後才擷取下一張快照:
import csi
import protocol
csi0 = csi.CSI()
csi0.reset()
csi0.pixformat(csi.RGB565)
csi0.framesize(csi.QVGA)
csi0.framebuffers(1)
img = csi0.snapshot()
img.compress(quality=85)
img_mv = memoryview(img.bytearray())
img_size = len(img_mv)
frame_available = True
class FrameChannel:
def poll(self):
return frame_available
def size(self):
return img_size
def readp(self, offset, size):
global frame_available
end = offset + size
mv = img_mv[offset:end]
if end == img_size:
# Host has just read the last byte of this frame --
# release the buffer so the capture loop can refresh.
frame_available = False
return mv
ch = protocol.register(name='frame', backend=FrameChannel())
while True:
if not frame_available:
img = csi0.snapshot()
img.compress(quality=85)
img_mv = memoryview(img.bytearray())
img_size = len(img_mv)
frame_available = True
ch.send_event(0x01) # notify host that a new frame is ready
這裡有四個部分在發揮真正的作用:
frame_available就是「鎖存(latch)」。擷取迴圈只在它為False時才擷取新快照,也就是主機已取走上一張影格的最後一個位元組。主機的讀取在readp內部、於最後一個偏移量被送出後,將它重新設回False。若沒有這個防護機制,下一次csi0.snapshot()會在讀取過程中覆寫緩衝區,主機便會收到由兩次擷取拼湊而成的影格。後端實作的是
readp而非read。協定函式庫將回傳的緩衝區視為權威來源,直接把它的位元組讀入外送封包中——無需複製。對於影格大小的酬載而言,readp明顯比read快,因為後者會強制進行一次中介複製。size回傳快取的 JPEG 長度,不會重新計算任何東西;擷取迴圈在每次重新整理緩衝區時都會維護它。主機在poll與readp之間呼叫size,以得知要取多少位元組。send_event()會在新影格抵達的瞬間通知主機,使其無需輪詢即可開始取資料。事件 ID0x01是由應用程式自行定義的(在此例中代表「影格就緒」);請為每一種通知使用不同的小整數。
12.8.2. 分片¶
QVGA RGB565 在 JPEG 品質 85 時會壓縮到大約 10-25 KB,視場景而定——遠大於任何相機所協商出的最大酬載(請參閱 protocol.init() 中的各板對照表)。一次 JPEG 讀取無法塞進單一封包,但這沒有關係,因為協定函式庫會透明地將其分片。
當主機要求 channel_read('frame', 12000) 時:
相機的
readp會被「呼叫一次」,帶有offset=0以及完整的 12000 位元組請求。它回傳一個涵蓋整個範圍的 memoryview。協定函式庫在傳輸時將該 memoryview 切分成最大酬載大小的片段,每個片段對應一個
CHANNEL_READ回覆封包,各自附有自己的標頭與 CRC。這些位元組直接從後端的緩衝區串流出去——無需複製。主機依序接收這些片段,可靠性層會重傳任何一個 CRC 驗證失敗的區塊,而主機 SDK 會將這些區塊黏合成回傳給呼叫者的 12000 位元組結果。
備註
這就是 readp 與 read 之間關鍵的實務差異。readp 是「每個主機請求呼叫一次」;協定層會從這個唯一回傳的緩衝區進行分片與傳輸。read 則是「每個片段呼叫一次」,函式庫會將每個回傳的區塊複製到它自己的封包緩衝區中。對於影格大小的酬載,readp 同時省去了每片段的 Python 層呼叫開銷與複製成本。
小訣竅
想親眼看看這個差距嗎?把後端的 readp 方法改名為 read——其他什麼都不要改;函式庫會改用 read 能力——然後比較主機在更改前後的影格率計數器。較慢的那個數字,就是你藉由使用 readp 所避開的每片段複製與 Python 呼叫成本。
FrameChannel.readp 中的鎖存會在 offset + size == img_size 時釋放緩衝區——也就是主機取走最後一個位元組的那一刻。在那之前,緩衝區必須保持有效,這正是為什麼擷取迴圈只有在 frame_available 翻回 False 後才擷取下一張快照。
12.8.3. 主機端¶
主機在一個緊湊的迴圈中取得影格:
import io
from PIL import Image
from openmv.camera import Camera
with Camera('/dev/ttyACM0', baudrate=921600) as cam:
cam.update_channels()
while True:
size = cam.channel_size('frame')
if not size:
continue
data = cam.channel_read('frame', size)
img = Image.open(io.BytesIO(data))
img.show() # or feed to a GUI
channel_size() 呼叫同時兼作「是否有東西就緒」的檢查——回傳零表示相機尚未擷取——因此迴圈會跳過對空緩衝區的讀取嘗試。對於本來就以計時器輪詢的 GUI 應用程式來說,這是很自然的做法。
Pillow 的 Image.open 會解碼 JPEG;相機已經把它 JPEG 壓縮過了,所以主機不必對 RGB565 重新進行昂貴的位元打包。主機指令碼也可以同樣輕鬆地把這些位元組存到磁碟、交給 OpenCV,或推送到網頁檢視中。
12.8.4. 吞吐量思考¶
有三件事限制了可達成的影格率:
相機的擷取率。協定無法以比感測器產生影格更快的速度傳遞影格;無論所選的像素格式與影格大小對擷取施加什麼上限,那就是天花板。
協商出的最大酬載。較大的酬載意味著每張影格的片段較少、框架開銷較低,因此具備較大協定緩衝區的相機比較小的相機移動位元組更快。
CRC 與 ACK 開銷。每個封包要花 14 位元組的框架加上一次 ACK 往返。對於較長的片段,每酬載的開銷很小;對於極小的酬載,它則佔主導地位。
對於大多數相機對筆電的 GUI 工作而言,限制因素是相機的擷取與 JPEG 壓縮時間,而非協定堆疊。在協定確實成為瓶頸之處——例如以高影格率串流未壓縮的原始影格——可運用的手段包括:關閉 ACK(protocol.init(ack=False))、在相機支援的情況下增加協定緩衝區,或以 GRAYSCALE 擷取,使每張壓縮的 JPEG 只攜帶一個通道而非三個,編碼後的影格在傳輸線上會明顯變小。
影格通道是相機對主機資料流的典範。同樣的後端介面,再加上一個 write 方法,就能讓主機反向推送資料——這正是互動式相機工具一旦操作者想要「改變」某些東西、而不只是觀看時所需要的。