@numa08 猫耳帽子の女の子

明日目が覚めたら俺達の業界が夢のような世界になっているとイイナ。

DJI Tello のビデオストリーム iOS でデコードする

できあがったものがこちら

github.com

4月の終わりにDJIから発売されたトイドローン、Tello。発売と同時に購入したけど、前評判通りに高い安定性を発揮してくる上、風がなければ屋外でも遊べるので、今までとは違うアングルからの写真撮影が楽しめて面白い。

そんな Tello は今のところスマホWifiで接続をして操作することしかできない。この値段帯のトイドローンならだいたいプロポが付いて来るんだけど、そういうのを削って本体のコストにしたってことなのかな?

あと、一応UDPで通信をするAPIもあるのだけれど、純正アプリと同じことはできない。純正アプリではカメラの画像をリアルタイムにプレビューできるし、前後左右上下の操作以外にもフリップや360度回転、手のひら着地なんかのコマンドを実行できる。一方で公開されているAPIは前後左右上下の操作を行うことしかできない。

そういった部分に不満を覚えつつ、夜な夜なパケットキャプチャしたデータを眺めていたら、すでに同じようなことをしていた人が海外にはいたらしい。go言語で書かれたiot用ライブラリのgobotにtelloの隠しインターフェースが追加されて公開されていた。

Hello, Tello - Hacking Drones With Go

日本版が出る前に公開されたエントリーなので、最初にこのブログをみつければパケットを開くこともなかっただろうな。

このエントリーのサンプルコードを実行するだけで、 mplayer を利用して動画のプレビューをPCから行うことができる。さらに、動画の仕様に関しては次のように書かれていた。

The streaming video is also sent to the ground station on a different UDP port. Unfortunately the Tello does not just use one of the existing standards for streaming video such as RTSP. Instead, the raw video packets are sent via UDP and need to be reassembled and decoded before they can be viewed.

Since the size of a single video frame is larger than the size of a single UDP packet, the Tello breaks apart each frame, and sends the packets with header of 2 bytes to indicate how they need to be reassembled.

Position Usage 0 Sequence number 1 Sub-sequence number 2-n Video data The video data itself is just H264 encoded YUV420p. Using this information, it is possible to decode the video using standard tools such as ffmpeg, once you remove the header bytes.

H264でエンコードされたYUV420pのピクセルデータだけど、いわゆる動画配信用のプロトコルには則っていなくて、生のバイナリをUDPで送っているので適当にデコードする必要があるよ!!って感じ。

H264でエンコードされた動画だったら、ffmpeg + lib264 とかでデコードできそうだなと思ったので、iOS 上でやってみることにした。

コード解説

ffmpeg や libx264 のビルドはすでにビルド用のスクリプトを作ってくれた人がいたので、それを利用した。

github.com

github.com

実際に動画のフレームをデコードする実装の抜粋が以下。

gistb93addb5b78d3a6417df2e7d77412655

コードの全体はこちら

Turkey/ImageDecoder.mm at 430f060486b902ee0f9178b4dc20bdc7de2242e8 · numa08/Turkey · GitHub

ffmpeg にバイナリを与える

利用しているのは ffmpeg , libx264 、そしてさっきの gobot を iOS 用にラップしたライブラリ。gobot は tello が送ってくる動画のバイナリをコールバックで渡してくるので、バイナリを処理する必要がある。アプリ側はバイナリを得ることはできるのだけれど、どうやらffmpegのインターフェースを利用する場合、直接バイナリを渡すには AVIOContext インスタンスを作って、コールバックを利用してバイナリを返す必要があるのだけれど、ちょっと考えることが多くて面倒くさい。

そこで、 NSPipe というAPIを利用することにした。

NSPipe - Foundation | Apple Developer Documentation

Linuxpipe(8)をラップしたAPIで、 pipe の作成、そして書き込みと読み込み用の file descriptor を作ってくれる。また、linux は pipe を作ると pipe:<file descriptor> というファイル名でアクセスをすることができるようになる(これの仕様ってどこに書かれてるんだ?)。この仕組みを利用することにした。

init[NSPipe pipe] を使って NSPipeインスタンスを生成する。 Tello と通信を行い動画のバイナリを取得する onNewFrame: では [self.fifoPipe.fileHandleForWriting writeData:packet]; を行うことで動画のフレームを書き込んでいる。 ffmpeg を利用する captureInFFMpeg では

int fileDescriptor = self.fifoPipe.fileHandleForReading.fileDescriptor;
const char* file = [NSString stringWithFormat:@"pipe:%d", fileDescriptor].UTF8String;
// 中略
ret = avformat_open_input(&format_context, file, input_format, &format_dictionary);

として pipe:<file descriptor> というパスのファイルをパラメータにして ffmpeg に与えている。こうすることでファイルの入力なら簡単にできる ffmpeg にファイルに見せかけたバイナリを渡す仕組みを実現することができた。

正しいバイナリを作る

Tello から取得したバイナリを ffmpeg に与えることができたのであとは ffmpegAPIを使ってデコーダーを実装していく。これはそんなに面白くないので割愛。

しかし、実は Tello から送られてくるバイナリは厳密には動画の生のバイナリではない。実は1つのフレームのバイナリを8個の送信に分割して送ってくる。今回、アプリを作り始めるまで知らなかったのだけれど udp は送信容量に上限がある。

UDPパケットサイズと転送レートの関係:プログラマー社長のブログ:オルタナティブ・ブログ

Ethernetフレームは最大サイズが1518バイトですので、そこから、Ethernetヘッダ(14バイト)とFCS(4バイト)を除いた、1500バイトが送出できる最大サイズになります。IPv4UDPを送出するには、IPv4ヘッダ(20バイト以上)とUDPヘッダ(8バイト)を除き、1472バイト以下がデータサイズになります。

今まで心を無にしてHTTPを触ってきたので、こんな仕様があることを知らなかった。動画のバイナリは 1472 バイトを余裕で超えるので、分割されるというわけ。どうやら8個のパケットに分割されているようなので、8という数字を決め打ちにしてしまって実装を進めることにした。

動画のフレームを受け取るonNewFrame: で、8個分の NSDayaNSArray を作り、マージして書き込みを行う。

[self.packetArray addObject:frame];
if (self.packetArray.count == 8) {
  NSMutableData *packet = [NSMutableData data];
  for (NSData *d in self.packetArray) {
    [packet appendData:d];
  }
[self.fifoPipe.fileHandleForWriting writeData:packet];
self.packetArray = [NSMutableArray array];
}

先頭に正しい動画のフレームを挿入する

ここまで実装をしてどうにか iPhone の画面上に Tello の動画をプレビューすることができた。

そのしばらく後

コードを全く変えていないのに h264 がデコードに失敗するようになった。なんで??なんで??

どうやら、先頭のフレームに含まれているヘッダー情報を正しく読み込むことができなくて失敗しているように思えたので、じゃあ正しい情報を与えてやればいけるのでは・・・?と思い実装。

PCで取得をした Tello の動画のフレームの先頭をプロジェクトに加えて、Telloが動画フレームを返すより前に ffmpeg に渡してやることにした。コードではopenのあたり。

// 正しいフレーム情報をもった動画ファイルを読み込んで、
// avformat_find_stream_info を成功させる
dispatch_async(writeFifoQueue, ^{
        NSString *p = [[NSBundle mainBundle] pathForResource:@"movie" ofType:@"mov"];
        FILE *i_file = fopen(p.UTF8String, "r");
        if (i_file == NULL) {
            NSLog(@"failed fopen");
            return;
        }
        int buffer_size = 10 * 1024;
        char buff[buffer_size];
        size_t size;
        while (true) {
            size = fread(buff, buffer_size, 1, i_file);
            if (size == 0) {
                break;
            }
            write(self.fifoPipe.fileHandleForWriting.fileDescriptor, buff, buffer_size);
        }
});

ちょっとパワーの有る方法だったけどこれで確実に成功するようになった。めでたい!!ちゃんとgcdを利用していたので、変なブロック処理やwaitを挟む必要が無いのも偉い。

感想

iOSシステムコールって読んで良いんだ・・・?

最近、LinuxシステムコールAPIに関する本を読んで勉強し直したところだったのでちゃんと役立てられて嬉しい。

numa08.hateblo.jp

C言語最高!!これで行きていける!!!みたいなことを考えることもなく、それでもswiftとかkotlinとかレイヤーの高いところで生きていきたいのだけれど、とは言え触ることができるっていうのは良いことだと思う。

あと、go言語で書かれたライブラリを利用できたのも良かった。個人的にgo言語は好きな言語で、CLIのツールを作ってgithubのスターを稼いだこともあったけど、しばらくご無沙汰だったため、久しぶりに利用できたことが素直に嬉しい。はじめ、gobotのtello関連のモジュールだけをswiftに移植しようかと考えていたけど、流石に面倒だったのでやめた。gomobile を利用することでiOSAndroid向けのアプリやライブラリをビルドすることができることは知っていたけど試したことはなかったので、良い挑戦だった。と言っても、gobotが依存しているライブラリも少ないためか、特に何もしなくてもそのまま素直にビルドをすることができた。時代はgo言語って感じ。

今後

この手順で最終的に UIImage インスタンスを取得することができたので、例えば Vision framework なんかを利用して自動操縦の自撮りドローンなんかを実装できそうだなーって考えている。