12.8. フレームのストリーミング

カスタムチャネルの最も一般的な実際の用途は、カメラから画像フレームをカメラのフレームレートでホストプログラムへストリーミングすることです。その仕組みは見た目よりも繊細です。JPEGは25 KB以上に達することもあるため、ホストはそれを複数の断片に分けて読み取ることになり、カメラのキャプチャループが読み取りの途中でバッファを上書きしないようにしなければなりません。正しいパターン(ここに示すもので、openmv-projects/tools/ 内のツールが使用しているもの)は、ホストが最後のバイトを取り出し終えるまでバッファを ラッチ します。

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

ここでは4つの要素が実際の役割を担っています:

  • frame_availableラッチ です。キャプチャループは、これが False(つまりホストが前のフレームの最後のバイトを取り出した状態)のときにのみ新しいスナップショットを取得します。ホストの読み取りは、最後のオフセットが処理されると readp の内部でこれを False に戻します。このガードがなければ、次の csi0.snapshot() が読み取りの途中でバッファを上書きし、ホストは2つのキャプチャを継ぎ合わせたフレームを受け取ってしまいます。

  • バックエンドが実装するのは read ではなく readp です。プロトコルライブラリは返されたバッファを信頼できるものとして扱い、そのバイトを直接送出パケットに読み込みます(コピーなし)。フレームサイズのペイロードでは、readp は中間コピーを強いる read よりも明らかに高速です。

  • size は何も再計算せずにキャッシュされたJPEGの長さを返します。キャプチャループはバッファを更新するたびにこれを維持します。ホストは pollreadp の間に size を呼び出して、取り出すべきバイト数を把握します。

  • send_event() は、新しいフレームが到着した瞬間にホストへ通知するため、ホストはポーリングせずに取り出しを開始できます。イベントID 0x01 はアプリケーションが定義するものです(この場合は「フレーム準備完了」)。通知の種類ごとに異なる小さな整数を使用してください。

12.8.2. 断片化

JPEG品質85のQVGA RGB565は、シーンに応じておよそ10〜25 KBに圧縮されます。これはどのカメラでもネゴシエートされた最大ペイロードよりはるかに大きくなります(protocol.init() のボードごとの表を参照)。1回のJPEG読み取りは1つのパケットに収まりませんが、プロトコルライブラリが透過的に断片化するため問題ありません。

ホストが channel_read('frame', 12000) を要求すると:

  1. カメラの readpoffset=0 と12000バイト全体の要求とともに 1回だけ 呼び出されます。これは範囲全体をカバーする1つのmemoryviewを返します。

  2. プロトコルライブラリは、そのmemoryviewを伝送路上で最大ペイロードサイズの断片に分割し、断片ごとに1つの CHANNEL_READ 応答パケットを生成します。各パケットには独自のヘッダーとCRCが付きます。バイトはバックエンドのバッファから直接送出されます(コピーなし)。

  3. ホストは断片を順番に受信し、信頼性レイヤーはCRCに失敗した断片を再送し、ホストSDKは断片をつなぎ合わせて呼び出し元に返す12000バイトの結果にします。

注釈

これが readpread の実用上の重要な違いです。readpホストの要求ごとに1回 呼び出され、プロトコルレイヤーは返された単一のバッファから断片化して送信します。read断片ごとに1回 呼び出され、ライブラリは返された各断片を自身のパケットバッファにコピーします。フレームサイズのペイロードでは、readp は断片ごとのPythonレベルの呼び出しオーバーヘッドとコピーの両方を節約します。

Tip

その差を自分で確かめたいですか? バックエンドの readp メソッドを read にリネームしてみてください。他には何も変えません。ライブラリは代わりに read 機能を採用します。そしてホストのフレームレートカウンターを前後で比較してください。遅くなった数値が、readp を使うことで回避できる断片ごとのコピーとPython呼び出しのコストです。

FrameChannel.readp のラッチは、offset + size == img_size のとき(ホストが最後のバイトを取り出した瞬間)にバッファを解放します。それまでバッファは有効なままでなければならず、これがキャプチャループが frame_availableFalse に戻ってからのみ次のスナップショットを取得する理由です。

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に渡したり、Webビューに送り込んだりできます。

12.8.4. スループットの考え方

達成可能なフレームレートを制約するものは3つあります:

  • カメラのキャプチャレート。プロトコルはセンサーが生成するよりも速くフレームを配信することはできません。選択したピクセル形式とフレームサイズがキャプチャに課す上限が天井になります。

  • ネゴシエートされた最大ペイロード。ペイロードが大きいほどフレームごとの断片数が減り、フレーミングのオーバーヘッドも減るため、プロトコルバッファが大きいカメラは小さいカメラよりも速くバイトを移動できます。

  • CRCとACKのオーバーヘッド。各パケットには14バイトのフレーミングに加えて1回のACK往復のコストがかかります。長い断片では1ペイロードあたりのオーバーヘッドは小さくなりますが、小さなペイロードではそれが支配的になります。

ほとんどのカメラからラップトップへのGUI作業では、制約要因はプロトコルスタックではなくカメラのキャプチャとJPEG圧縮の時間です。プロトコルがボトルネックになる場合(たとえば非圧縮の生フレームを高フレームレートでストリーミングする場合)の手段は、ACKを無効にすること(protocol.init(ack=False))、カメラが対応していればプロトコルバッファを増やすこと、あるいはGRAYSCALEでキャプチャして圧縮された各JPEGが3チャネルではなく1チャネルを運ぶようにし、エンコードされたフレームが伝送路上で明らかに小さくなるようにすることです。

フレームチャネルはカメラからホストへの標準的なデータフローです。同じバックエンドインターフェースに write メソッドを追加すれば、ホストは逆方向にもデータをプッシュできます。これは、オペレーターが単に見るだけでなく何かを 変更 したくなった瞬間に、インタラクティブなカメラツールが必要とするものです。