[Android] MediaCodecの解説と評価
こんにちは。NTTの森永です。
今回は、「現行のスマートフォン端末で撮影映像をどれだけ低遅延かつ高品質にエンコードできるか」を調査するために、Androidの低レベルなエンコード/デコード用API「MediaCodec」を評価したので、APIの使い方に関する簡単な解説と評価結果を記事にしたいと思います。
目次
少し分量が多いのでMediaCodecの使い方に関心がある方は2~3章を、我々の取り組みに関心がある方は1, 4, 5章を中心に読むと良いでしょう。
はじめに
ここまで読んで「NTTでなぜスマホのエンコーダの評価を?」と疑問に思われる方が多数だと思います。最初にこの点を前置きします。
以前の森田による記事にもある通り、弊社では、深層学習の推論処理などを通信事業者の設備内にエッジサーバ側で行う(オフロードする)ことで端末にリッチで没入感のあるARを実現する取り組みを行っています。例えば、視点カメラの画像に画風の変換やオブジェクト認識の結果を低遅延に合成できると、以下の動画のようにARの表現の可能性や楽しさがグッと広がるというわけです。
ARの中でも、ゲームや作業支援など操作遅延にシビアな領域のアプリでは、30ms〜100ms以内や30〜60fps程度の高いリアルタイム性で処理をオフロードしたい場合が考えられそうです。この時、ネットワークが十分に高速であれば問題ないのですが、理論値では5Gでは無線区画の遅延は1ms以下になると謳われていますがAT&Tなどの測定では10–12msの範囲が一般的と実測されているようですし、無線アンテナからエッジサーバまでの間の経路遅延も、最大片道10ms以下(4ページ目 Figure 4)とは言えこの手のアプリの水準では無視できません。また、5Gが来たらどの場所でも上下1Gbps以上で通信できるというのは実測報告と比べると流石に大げさな希望的観測のため、サイズの大きなカメラ画像を送る転送時間も近い将来も無視できなさそうです。また、通信量をふんだんに使ってリアルタイムにデータを送っていては電池や端末の発熱が大変なことになってしまいます。つまり、削れるデータはなるべく削って送るべきでしょう。
一方で、サーバへ送るカメラ画像の大きさや画質をむやみに下げてしまうと、エッジサーバ側で行う処理の質が下がってしまいます。例えば、画風変換した結果が荒くなったり、自己位置推定(SLAM)や物体検知の精度が下がってしまうでしょう。せっかくオフロードしたのに、結果が使い物にならなければ困ります。そこで、遅延と画質とデータ量のトレードオフに優れたカメラ画像の送り方が必要になるはずです。
小さいデータで高画質なオフロードを行うには、上記の記事で書かれたような超解像度化 以外にも、端末側でカメラ映像を動画圧縮しながら送信し、サーバ側でデコードした画像にAI処理を適用するという方針が考えられそうです。研究としては若干シンプルすぎますが、汎用的かつ簡単な方法で済ませられるならそれに越したことはないでしょう。ただし、エンコードは非力な端末側で行うため、「エンコード遅延20ms以下程度の低遅延に動いてくれるのか」あるいは「バッファなしのリアルタイムなエンコードで十分な画質が維持できるのか」といった点が不明で、確認する必要がありそうです。こうした背景から、本記事ではモバイルエンコーダの現状の性能を調査するため、Android端末上でMediaCodecの遅延と品質の測定を行なってみました。
MediaCodecとは
MediaCodecは、Androidのネイティブな映像エンコード/デコード用のAPIです。Android端末でのエンコード/デコードでハードウェアアクセラレーションを利用するには、MediaCodecを使う必要があります。動画エンコードにはOSSのエンコーダを使うという手段もありえますが、代表的なOSSエンコーダであるffmpegが未だMediaCodecを使ったエンコード実装に未だ対応していないようなので、今回はMediaCodecを直接叩いて評価を行いました。端末はメジャーでそこそこ高性能なスマートフォンとしてSamsungのGalaxy S10を用いました。開発はJavaで行い、APIレベルは28を使用しています。では、MediaCodecの使い方を見ていきましょう。
コーデックの起動と設定
MediaCodecの基本概念はシンプルで、コーデック起動後に入力バッファに入力フレームを書き込むと、出力バッファからエンコード結果が逐次的に出力されます。codecの種類は、エンコード形式で指定することができます。今回は最もメジャーな規格のH.264を評価するため”video/avc”を指定しています。この場合、実際に使用されるコーデックは自動選択されてしまいますが、他にも実装されているコーデック名で指定する方法もあります。これについては評価結果の章で紹介します。
MediaCodec codec = MediaCodec.createEncoderByType("video/avc");
codec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
codec.start();
//この間でデータを書き込み、結果を取り出す。
codec.stop();
上記とコードの順序が前後しますが、フレームレート(fps)やビットレートなどのエンコードの細かい設定は以下のようにMediaFormatで与えます。様々なパラメータがあるので細かく設定したい人は公式ドキュメントをよく見ておくと良いでしょう。
MediaFormat mediaFormat = MediaFormat.createVideoFormat("video/avc", width, height);
mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, 400000);
//パラメータの指定
エンコードと結果の取り出し
データの入力には、複数あるバッファのうちビジーでないものをdequeueInputBuffer
で取得します。取得したバッファに入力データを書き込んだらqueueInputBuffer
によりエンコードのキューに入れることができます。入力データはYUVのByteデータとSurfaceで与えることができます。実際のByteの入力の中身については複雑なため次章で解説します。今回は評価で前後のデータを生データとして取り扱いためByteを使用しますが、本記事以外の解説はSurfaceが主流のことが多かったため、Surfaceで与えたい方は他の方が書かれた記事も検索すると良いと思います。
ByteBuffer inputBuffer = null;
long timestamp;
int InputBufferId = -1;
try {
InputBufferId = codec.dequeueInputBuffer(500000);
//利用可能なバッファIDを取得
if (InputBufferId >= 0) {
inputBuffer = codec.getInputBuffer(InputBufferId);
inputBuffer.clear();
inputBuffer.put(source);
codec.queueInputBuffer(InputBufferId, offset, size, timestamp, 0);
} catch (IllegalStateException e) {}
エンコード結果のデータを取り出す用に、以下のように出力バッファの情報確認用のBufferInfoを用意しておきます。
MediaCodec.BufferInfo bufferInfo=new MediaCodec.BufferInfo();
エンコードの時と同じようにdequeueOutputBuffer
にて結果が格納されたBufferIdを取得します。この時の出力バッファIDは入力バッファのIDとは無関係で、queueに入れた入力順にエンコード結果が出力されます。バッファを取り出したら、忘れずにreleaseOutputBuffer
で解放しまましょう。
int outputBufferId = codec.dequeueOutputBuffer(bufferInfo, 1000);
if (outputBufferId >= 0) {
ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferId);
//この区間でデータを取り出す。
codec.releaseOutputBuffer(outputBufferId, false);
}
結果を取り出すにはbufferInfoのsizeやoffsetを見て、outputBufferから読み取ってあげれば良いでしょう。あるいは出力バッファをMediaMuxerで動画化する場合は、以下のように読み取らずとも直接MediaMuxerに渡すことができます。(MediaMuxerについては今回は省略します。)
if (bufferInfo.size != 0 &&(bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0!=0) {
bufferInfo.presentationTimeUs = getPTSUs();
muxer.writeSampleData(VideoTrackIdx, outputBuffer, bufferInfo);
}
(bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0
は、Codecの設定情報が出力された時のフラグです。このフラグはエンコード開始時の最初の1回と途中で設定が変わった場合に出力されるようです。CodecConfigが出力された時のbuffersizeはゼロではないが、エンコード結果ではないデータのため注意が必要です。
エンコードの終了
エンコードの終了時には終了フラグを入力バッファに与えます。
try {
InputBufferId = codec.dequeueInputBuffer(500000);
if (InputBufferId >= 0) {
codec.queueInputBuffer(InputBufferId, 0, 0, 1000,
MediaCodec.BUFFER_FLAG_END_OF_STREAM);
}
}catch (IllegalStateException e){}
以下のように出力バッファのフラグを見ることで、終了判定ができます。
if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0){
codec.stop();
codec.release();
muxer.stop();
muxer.release();
}
筆者の環境で試した感じではこれらの終了フラグは必須ではなさそうですが、終了判定をせずに止めてしまうと出力の取り出し忘れ等の実装ミスにつながるため、普通は行なった方が良いと思います。
以上がMediaCodec APIの大まかな使い方となります。
入力YUVデータ(Byte)の設定
さて、先ほど省略したエンコード用の入力の並べ方について説明します。今回はARCoreのacquireCameraImage()
を用い、Imageクラスでカメラ画像を取得しました。Camera2 APIのImageReaderなど、他の手段でも同様にImageで取得できるはずです。
private void OnUpdateFrame(){
Frame frame = fragment.getArSceneView().getArFrame();
Image image; //YUV_420_888 Multi-plane Android YUV 420 format
try {
image = frame.acquireCameraImage();
} catch (NotYetAvailableException e) {}
...
//ここで形式変換やエンコード入力を実施}
//addOnUpdateListenerを用いてOnUpdateFrameを毎フレーム呼ばれるようにできる
さて、このImageにおけるYUVの格納形式を、エンコーダの対応形式に変換する必要があります。これが少々ややこしく、先ほど説明を後回しにした理由です。ImageからYUV成分に相当する各バッファは以下のように取り出せます。
ByteBuffer yBuffer = image.getPlanes()[0].getBuffer(); //Y成分
ByteBuffer uBuffer = image.getPlanes()[1].getBuffer(); //U成分
ByteBuffer vBuffer = image.getPlanes()[2].getBuffer(); //V成分
これらのARCoreで取得したカメラ画像データにはYUV420という圧縮手法が使われます。YUV420では、Y成分は各ピクセルごとに、U成分とV成分は2×2のブロックごとに、すなわち4ピクセルごとに割り振られます。ちなみにCamera2 APIでもYUV420が標準設定のようです。
そして、yBuffer/uBuffer/vBufferの中身のデータはメモリ上に連続して並んでいるのですが、この時の並び方がなんと機種ごとに異なります。主なYUV420レイアウトの種類にはI420/YV12/NV12/NV21があり、多くの機種でNV21が使われるようです。一方で、MediaCodecの入力は基本的にNV12のため変換が必要です。NV21では、Y成分の後にV成分とU成分がVUVUとV→ Uの順で交互に並び、NV12ではY成分の後にU→Vの順に並びます。
ARCoreから取れるカメラフレームの縦横サイズは640*480なので、U成分の数は640*480/4 = 76800個のはずです。しかし uBuffer.limit()などでuBufferやvBufferのサイズを見てみると、実際には153599となります。これは上図のように、UとVのバッファが両端の1成分をのぞいて共有されているためです。あまりないですが、実際に成分にアクセスする際には少し注意が必要ですね。先ほどレイアウトが機種ごとに異なると書きましたが、NV21かNV12かの判定にはalignmentOffset()などで各バッファの先頭アドレスを取得して比較してあげれば良いでしょう。
筆者の環境では、レイアウトはNV21でした。これを踏まえて以下のようにImageからUV成分を逆にした配列を用意すれば、エンコードの入力データとして与えることができます。
byte[] uvBuffer;
ByteBuffer uBuffer = image.getPlanes()[1].getBuffer();
ByteBuffer vBuffer = image.getPlanes()[2].getBuffer();
int uSize = uBuffer.remaining();
uvBuffer = new byte[uSize + 1];
uBuffer.get(uvBuffer, 0, uSize);
for (int i=1;i<uSize+1;i+=2) {
uvBuffer[i] = vBuffer.get(i-1);
}
//(中略)
inputBuffer.clear();
inputBuffer.put(yBuffer);
inputBuffer.put(uvBuffer);
offset = 0;
size = 640*720; //YUV420での全成分の合計数
codec.queueInputBuffer(InputBufferId, offset, size, timestamp, 0);
ちなみに、uBufferはV成分の大半を含めているため、yBuffer→uBufferの順で書き込むだけでも結構それっぽいエンコード結果ができてしまいます。とりあえずエンコードを試してみるには良いでしょう。ただし、実際にはv成分が一つずれてしまうので正確な扱い方ではありません。
評価結果
まずはじめにエンコードの遅延を評価します。リアルタイムな画像処理のオフロードに使えるかという点では、最低ラインとして20ms以内には各フレームのエンコードが完了して欲しいところです。H.264でエンコードできるコーデックは各端末に複数搭載されていますので一通り比較します。MediaCodecListを使って全コーデックから以下のようにH.264のエンコーダの名前を全て取得します。
int numCodecs=MediaCodecList.getCodecCount();
for (int i = 0; i < numCodecs; i++) {
MediaCodecInfo codecInfo = MediaCodecList.getCodecInfoAt(i);
if (!codecInfo.isEncoder()) {
continue;
}
String[] types = codecInfo.getSupportedTypes();
for (int j = 0; j < types.length; j++) {
if (types[j].equalsIgnoreCase("video/avc")) {
Log.d("CODEC_NAME",codecInfo.getName());
}
}
}
APIの解説ではcreateVideoFormat
を扱いましたが、代わりにここで確認したコーデック名を使ってcreateCodecByName
で指定することもできます。今回のGalaxy S10ではOMX.qcom.video.encoder.avc
/c2.android.avc.encoder
/ OMX.Google.h264.encoder
の3つのH.264コーデックが搭載されていました。30FPSで5秒のカメラ映像をエンコードした時、これらの各フレームのエンコード遅延は以下のようになりました。
調べたところこれらのコーデックのうちOMX.qcom.video.encoder.avc
はQualcomm製のハードウェアコーデックで、OMX.Google.h264.encoder
は汎用のソフトウェア実装のようです。遅延のばらつきが大きいことからも納得がいく結果ですね。傾向の近さからc2.android.avc.encoder
も裏側は同じソフトウェア実装と推測されます。結果から、スマートフォンのハードウェアエンコーダでも、最初の数フレームを除けば10ms程度と低遅延にエンコード自体は行えそうです。一方で、ソフトウェアエンコードで安定して低遅延に結果を得るのは、現行のスマートフォンのスペックでは少し厳しそうですね。
次に、エンコード結果のフレーム符号量(各フレームのデータサイズ)を見てみましょう。設定した数値通りというわけではないですが、概ねビットレートの設定に応じて増加していますね。なお、これらの設定間に目立った遅延の差はありませんでした。
このままでは一定間隔で発生する「キーフレーム」の符号量が大きく、リアルタイムオフロードには向かないため、KEY_INTRA_REFREH_PERIOD
を1に設定してintra-refresh機能をオンにしてみます。この機能では、GDR方式と呼ばれる仕組みを使ってキーフレームを設定せずに各フレームのエンコード結果の大きさを均一にします。ただし、intra-refreshではエンコードの符号化効率が少し落ちることが知られています。
公式ドキュメントによると、このintra-refresh機能がオンになるかはコーデック依存のようですが、今回の環境は無事動作してくれるようです。オフロードでは各フレームを全て小さくして送りたいため、オンにして使うことになりそうです。
エンコード結果の品質については、比較対象としてjpeg圧縮を考えています。jpeg圧縮ではQuality Factor (Q)が設定でき、Q = 80〜85程度の設定値が画質対圧縮率に優れるとされ一般的に用いられます。今回はQ =80のjpegとMediaCodecのエンコード結果を比べます。
品質指標には元データ(YUV420のByte)とのPSNRを用いました。PSNRとは、Peak Signal-to-noise Ratioの略で、元データに対する信号の劣化度合いを表します。画像や映像のPSNRは30以上あると高画質、40以上だと元との劣化をほとんど感じないと言われています。これらのPSNRやQ値ごとの劣化度合いの肌感覚はWikipediaの画像で見ると直感的に理解しやすいと思います。
今回はMediaCodecが①「1/3程度のビットレートでjpegと同程度の品質を達成すれば、50Mbps以下など現行のLTEなどの通信環境で有用」②「同程度のビットレートでより高い品質であれば5G時代の今よりは良いが悪めの通信環境(50〜200Mbps程度)や端末の電池消費や発熱を抑えたい場合のオフロードで有用」と仮説しました。①②に対応するようなビットレートでの品質を見ていきます。以下のように想定に近い符号量となるビットレートを設定し(左図)、各設定におけるPSNRを測定しました(右図)。
残念なことにMediaCodecでのエンコード結果のPSNRは、jpegを大きく下回ってしまいました。他の結果は省略しますが、筆者の環境ではビットレート、KEY_QUALITY、コーデックと様々な設定条件を試してもPSNR=30を超えるエンコード結果は得られませんでした。このままの結果では、劣化が大きく、AI処理などのリアルタイムオフロードに用いるには不向きそうです。
ですが、MediaCodec自体が静止画圧縮以下のエンコードしかできない不用品というわけではなさそうです。例えば、符号量をこれまでの評価よりもがっつり小さくし1フレームあたり6 KByte程度となるように調整した場合、MediaCodecではPSNR=27程度とそこまで劣化しません(下図右画像)が、同程度のフレームサイズになるまでQ値を落としたQ=1のjpegでは、PSNR=22まで落ち何が写っているのかほとんどわからない状態になってしまいます(下図左画像)。ところで、この時の解像度あたりのビットレートは標準のカメラアプリで撮影した映像と概ね同程度でした。どうやら、スマートフォン搭載のハードウェアエンコーダは普段のカメラ撮影用に高速かつ高圧縮に映像を記録することに特化していそうです。測定結果の①と②でPSNRにあまり差がなかったのも恐らくこのためでしょう。
MediaCodecやMediaFormatの設定は、設定項目こそ共通化されているものの実際のパラメータの効果や挙動の大半はコーデックの実装依存なため、環境による評価結果の差が大きくありそうです。そのため今回の評価結果が全てとは言えませんが、以上でモバイル端末搭載のハードウェアエンコーダの遅延や特徴について大まかに確認できました。
おわりに
今回はエンコーダの特性のため、評価前に最低ラインと考えていたQ=80のjpeg程度の品質でのエンコード結果が得られませんでした。ですが、スマートフォン端末でも意外と低遅延にエンコードできることが確認できたため、もう一工夫して品質が回復できればMediaCodecをオフロードに使えそうです。例えば、ARCoreではなくCamera2のAPIであればより高解像度にフレームがとれるため、これを用いて高い解像度でエンコードし、デコード後に解像度を下げるという方針なら今回より高い圧縮率と品質でカメラ映像を送れると推測できます。ただし、この方針をとる場合は、上記の640×480のフレームを使ったエンコードと比べ計算量が大きいはずなので、遅延についても改めて評価が必要です。
もちろん、今回検証した動画圧縮して送るというのは一つのアプローチにすぎません。以前の記事にあるサーバ側での超解像度化や、必要な特徴量だけをデバイス側で抽出してサーバに送る、カメラ以外のセンサと組み合わせて処理するなど、他にも効率よくオフロードするための様々なアプローチが考えられそうです。
長くなりましたが、本記事の内容は以上になります。ややマイナーなAPIなので解説がお役に立てば、あるいは本記事から我々の取り組みに関心を頂ければ幸いです(Clapを頂けると喜びます😊)。弊社では、ARや深層学習、分散コンピューティングなど幅広い分野にて面白いことやOSS活動をしたい仲間を募集してます。是非採用情報をチェックしてください!