Zig言語で“カメラの映像をリアルタイムで送信するための部品”を書いてみた

小林哲之氏:tetsu_kobaと申します。今日は、「カメラの映像をリアルタイムで送信するための部品をZig言語で書く(Part2)」というタイトルで、発表したいと思います。

概要です。Zig言語はOSのシステムコールの呼び出しや、C言語で書かれたライブラリを利用するのがとても容易です。Video for Linux 2でカメラの映像をキャプチャし、それをエンコード、デコードして、画面に表示するまでの一連のプログラムをパーツとしてZig言語で書きました。これは5月22日に別の勉強会で話した内容の続きなのでPart2です。

Zig言語でなにかを作ってみたかった

なぜこのようなことをしたかというと、映像の配信に必要な各プロセスをきちんと自分で理解するためです。実験のために再利用しやすいパーツを自分で作ります。将来的には新しいコーデックや通信方式を試したいので、そのための土台を作っておくということになります。要するに車輪の再発明ですが、とにかくZig言語でなにかを作ってみたかったということになります。

実装はとにかくシンプルに

「とにかくシンプルに」ということで、今回はいろいろな制約を付けています。まず、映像と音声の同期を今回は行いません。さらに今回は映像だけを扱っています。プロセス間のデータの受け渡しにはパイプを使いました。非常にシンプルなプロセス間通信です。パイプの中には画像のデータのみが流れます。映像のサイズは決め打ちで、コマンドラインから指定するようにしました。

ネットワーク間のデータ送信には、TCP socketを使用しました。LAN環境のみなので、ネットワークがひどく混雑している状況は想定していません。今回はとにかく正常系を優先して実装しています。異常が起きたら基本的にはそこで終了するということにしています。必要に応じてエラーリカバリを追加しています。

今回追加したもの

前回発表時までに作ったものは(スライドを示して)以下のものです。主に送信側を中心に作りました。

今回は以下のようなものを追加しています。jpegのデコーダをエンハンスして、Motion JPEGに対応しました。受信側に使うものはVP8のデコーダ、それからTCPのソケットをlistenして (待ち受けて)、受けた内容をstdoutに出力する。今回はこれをGo言語で書きました。それからI420のローデータのビューアを、libsdl2を使って作りました。

jpegのデコーダ・Convert to I420・VP8用のエンコーダとデコーダ・I420 Viewerを解説

V4L2 captureは、前回作ったものと同じです。

jpegのデコーダです。USBカメラでは、高解像度・高フレームレートの画像はmjpegのフォーマットになるので、今回このjpegのデコーダでmjpegを扱えるようにしました。これによって1,280×720の画像サイズを扱えるようになっています。

mjpegからjpegを切り出すために、jpegの先頭と最後に付いているマーカーを検出しているわけですが、このコードを書く時にいくつかTipsを見つけたので、(スライドを示して)このブログに書いています。jpegのデコードそのものには、libturbojpegのライブラリを使っています。

Convert to I420。これはさまざまな基本的な映像フォーマットをI420に変換するものです。

VP8用のエンコーダとデコーダを作りました。libvpxを使用しています。なぜVP8を利用したかというと、このエンコード済みのフォーマットがIVFになるからです。IVFはH.264やH.265で使われているNALフォーマットよりもずっとシンプルで扱いやすいです。

AV1もraw video formatとしてIVFを採用しているので、それの足掛かりとして、まずはIVFを使っているVP8でやってみました。エンコーダはVP8専用に作りましたが、デコーダはVP8とVP9の両対応にしています。

次にI420 Viewerです。これはlibsdl2を利用しています。フレームレートの制御はしていないので、データが来たタイミングで映像の更新をしています。画面に表示するためにはRGBへ変換する必要がありますが、変換はlibsdl2の内部に任せてあります。

作ったパーツを組み合わせて使用

作ったこれらのパーツを組み合わせて使用します。送信側はLinuxで動かすわけですが、V4L2 captureからmjpegのデータを取り込んでそれをデコードして、その結果をさらにI420の形式に変換します。それをVP8でエンコードしてTCP送信。それぞれのパーツをパイプでつなげています。

同様に受信側はLinux、あるいはmacで動かすようになっています。TCPで受信したものをVP8でデコードして画面に表示する。この3つがつながっています。

シェルスクリプトを解説

具体的に使用したシェルスクリプトです。(スライドを示して)このようにパラメーターをシェル変数に入れて呼んでいて、それぞれの入力・出力はファイルで指定するようになっていますが、/dev/stdin、/dev/stdoutを指定することによって、標準入力から得て標準出力に出すということにして、パイプでつなげていくことができるようになっています。

(スライドを示して)同様に、これは受信側です。前回は受信側を作っていなかったので「ffplay」にやらせていましたが、今回は受信側も自分で作りました。

パイプの効率化

パイプの効率化ですが、パイプのバッファサイズはLinuxの場合、デフォルトで64KBです。これはビデオデータを送るにはけっこう小さいので、何回もシステムコールを呼ばなければいけません。なのでパイプのバッファサイズの変更をしたり、パイプへの書き込みに対してwriteをシステムコールじゃなくvmspliceを使うことによってコピーの回数を1回減らしています。そういった工夫に関してはブログに書いたので、そちらをご覧ください。

今後の展望

Future workです。まだこれは土台なので、これからいろいろなことをやっていきたいと思っています。まずは各ライブラリをZigのパッケージにしたいと思います。それによってパイプでつなげるのではなく、もっとメモリで受け渡すとか、もっと効率の良いやり方で実用化に近いものに変更していけばいいと思います。最終的にはハードウェアアクセラレータによる動画のエンコードやデコードまでやってみたいと思っています。

まとめです。このように単純ではありますが、最低限の映像の送信と受信を行うことはできました。このレイヤーのソフトウェアを実装するのにZig言語はとても有用だと思います。特にC言語で書かれているCのABIのライブラリを使う上では、非常にスマートに使えるので、いいなと思いました。今日紹介したプログラムのソースコードはGitHubに公開しているので、ご覧ください。ブログにもいろいろなことを書いています。

デモ

このあと簡単にですが、デモをしたいと思います。2つの画面がありますが、上のほうが送信側。これはArmの評価ボードのJetson AGX OrinにSSHしているところです。下のほうは、M1 Macのターミナルです。動かすシェルスクリプトは(スライドを示して)このとおりです。

まず、下のレシーブ側から動かします。続いて送信側。こんな感じです。音に比べてそれほど遅延がないことがわかります。

以上です。終わります。ありがとうございました。