第8回 「ネイティブコードの利用」 | エドさんのAndroid AR講座

第8回 「ネイティブコードの利用」


前回はズーム機能を実装しましたが、Javaでの画像処理には限界もあり、十分な秒間フレーム数が出せませんでした。今回は、それを補うために一部をCのコードに置き換えることによって速度を改善する方法を紹介します。


手順は次のとおりです:
【準備】
  (1) Cygwin をインストールする
  (2) Android NDK をインストールする
  (3) Eclipse の設定、ツールのインストールを行う
【ビルド】
  (4) Eclipse 上でプロジェクトの一部を C/C++ に書き換える
  (5) Cygwin 上で C/C++ コードをメイクする
  (6) Eclipse 上でプロジェクト全体をビルドする


まず、【準備】 にある 3 項目のインストールを行います。この内容は下記のページにステップごとの図入りで非常に丁寧に書かれているので、安心してそれに従ってください。


  アンドロイド開発環境の構築(その5) NDKのインストールと設定


この手順には 1~2 時間ぐらいかかりますが、その後には非常に快適な環境が待っているので、しばらく我慢してください。なお、ここまでの手順が終わったら、次に下記のページを参照してサンプルコードを動かしてみるのも、NDK に慣れるためには良いかと思います。


  Android NDKのサンプルプロジェクトをビルド/実行する


さて、では準備がすべて完了したということで、われわれのアプリの改造に入りましょう。まず、どこをネイティブコードに書き換えるかについて、慎重に検討することが必要です。後述のように、ネイティブとの間の切り替え時にはオーバーヘッドが発生しますから、速度改善の効果が見込めない箇所を書き換えることは意味がないばかりか害があることさえあります。アプリの実行時に最も時間がかかる場所を特定して、その部分をネイティブコードに書き換えることが重要です。


実行時間の計測には、たとえば次のようなコードを用いることができます。

    long cp0 = System.currentTimeMillis();
...
(計測したい部分 1)
...
long cp1 = System.currentTimeMillis();
...
(計測したい部分 2)
...
long cp2 = System.currentTimeMillis();
Log.e(TAG, String.format("Time: [%d][%d]", cp1-cp0, cp2-cp1));


計測したい場所をいくつか選んでその前後にチェックポイントを設定し、そのチェックポイント通過時に現在時刻を取得します。最後に、隣同士の差を表示することにより、各部分の実行時間がミリ秒単位で得られます。ただし、ループの中でこれを行うと膨大なログが出てきてしまうので、チェックポイントの設定箇所は慎重に検討してください。


われわれのアプリの場合、前回のコードに対してさまざまな計測を行い、またネイティブコードへの書き換えをいろいろな箇所で試してみた結果、最終的に、プレビュー画像をデコードして RGB データに変換する部分を書き換えるのが最も効率的だということがわかりました。前回のように CamData クラスを新設し、キャッシュを用いてデータを提供する Java の手法には限界があり、そこは力ずくでデータ全体を変換してしまうのが最良だということになります。


検討が済んだら、Eclipse でプロジェクト直下に jni というフォルダーを作り、そこに C のファイルとメイクファイル用の Android.mk ファイルとを用意します。下の図は、この用意が終わり、ネイティブコードのメイクとプロジェクトのビルドがすべて完了したときの様子です。この図の中で、libs と obj の 2 つのフォルダーとその中身はメイクによって自動的に作られるものなので、考慮する必要はありません。


$エドさんのAndroid AR講座-img08-01.png


decode.c を作成するにあたって、インタフェースは YUV のバイト配列と画像サイズを入力、RGB の整数配列を出力とし、どれも引数で受け渡しするという単純なものにしておきましょう。このとき、前回の CamData クラスのデコード関数を参考にして C で次のような関数を書き、decode.c として jni に保存します。


decode.c

#include <jni.h>

void Java_com_artiscc_aimnshoot_AimnShoot_decodeImage(JNIEnv* env, jobject thiz, jbyteArray imgYUV, jintArray imgRGB, jint width, jint height)
{
jbyte *yuv = (*env)->GetByteArrayElements(env, imgYUV, 0);
jint *rgb = (*env)->GetIntArrayElements(env, imgRGB, 0);
int frameSize = width * height;
int i, j;
int yp = 0;
for(j = 0; j < height; j++){
for(i = 0; i < width; i++){
int y = (0xff & (int)yuv[yp]) - 16;
int y1192 = (y < 0) ? 0 : 1192*y;

int uvp = frameSize + (j >> 1)*width + i - (i & 1);
int u = (0xff & yuv[uvp+1]) - 128;
int v = (0xff & yuv[uvp]) - 128;

int r = (y1192 + 1634 * v);
int g = (y1192 - 833 * v - 400 * u);
int b = (y1192 + 2066 * u);
r = (r < 0) ? 0 : ((r > 262143) ? 262143 : r);
g = (g < 0) ? 0 : ((g > 262143) ? 262143 : g);
b = (b < 0) ? 0 : ((b > 262143) ? 262143 : b);
rgb[yp++] = 0xff000000 | ((r << 6) & 0xff0000) | ((g >> 2) & 0xff00) | ((b >> 10) & 0xff);
}
}
(*env)->ReleaseByteArrayElements(env, imgYUV, yuv, 0);
(*env)->ReleaseIntArrayElements(env, imgRGB, rgb, 0);
}


まず、jni.h というヘッダをインクルードしておきます。次に関数名は、“Java_” を先頭にして、パッケージ名をピリオドを下線に変えて続け、その後にアクティビティ名と本当の関数名を続けるという形式で記述します。


引数については、まず JNIEnv* env と jobject thiz をこのとおり並べ、その後に実際の引数を記述します。引数の型は Java の型に対応して、バイト配列を jbyteArray、int 配列を jintArray、また int 型を jint とします。ここで、jint に関しては C コード中で通常の int と同じに扱うことができますが、配列に関しては Java のメモリ管理によって一般には連続した領域に取られるとは限らないので注意が必要です。


そこでまず、Java の配列を GetByteArrayElements() および GetIntArrayElements() によってネイティブ側の配列にコピーします。これらの関数は、Java 側の型によっていろいろ用意されていますが、詳細については


  Java Native Interface Programming


のようなページを参照してください。


処理の本体は、Java のコードを C コードに書き直すだけで済みます。ほとんどの場合、ここは容易に行うことができるでしょう。ただし、ループの中だけで用いる変数を for(int i = 0; ...) などのように宣言している箇所はエラーになることがあるので、ループの外で宣言するように書き換える必要があります。最後に、C 側の配列を ReleaseByteArrayElements() と ReleaseIntArrayElements() によって解放すれば完了です。


次に、これをメイクするためのテキストファイルを Android.mk という名前で作成し、jni に保存します。


Android.mk

LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := decodelib
LOCAL_SRC_FILES := decode.c
include $(BUILD_SHARED_LIBRARY)


この 1、2、5 行目は変更する必要はありません。3 行目に今回作ったライブラリ名を定義し、4 行目に上記の C ファイルを指定します。ソースファイルが複数個ある場合にはここに並べて指定します。また、C++ で書かれた .cpp ファイルも同様にここに記述します。


Android.mk ファイルの書き方の詳細は、準備 (2) でインストールした Android NDK ディレクトリ直下の ./docs/ANDROID-MK.html を参照してください。


このモジュールをメイクするには Cygwin の黒画面を立ち上げます。この環境では Windows の C: ドライブが 「/cygdrive/c/」 として参照されることに注意してください。ここで、以下のように Eclipse のワークスペースの現在のプロジェクト直下の jni ディレクトリに cd します。なお、Windows のコマンドプロンプトと違って画面上での右クリックはできませんが、黒画面左上の Cygwin のアイコンをクリックして [編集] → [貼り付け] を選べば、クリップボードにコピーしておいた文字列をコマンドとして貼り付けることができるので、パスが長い場合などには便利です。ここで ndk-build コマンドを入力すると、モジュールがメイクされ、成功すれば下記のような 3 行の出力が得られます。

$ cd /cygdrive/c/.../workspace/AimnShoot/jni
...
$ ndk-build
Compile thumb : decodelib <= decode.c
SharedLibrary : libdecodelib.so
Install : libdecodelib.so => libs/armeabi/libdecodelib.so


さて、最後にこのような書き換えに伴う Java 側の変更箇所を説明します。


CamData クラスは必要なくなったので、まずそのファイルを削除してください。変更するのは AimnShoot.java の中だけです。まず、その冒頭部分を見てみましょう。


AimnShoot.java (1)

public class AimnShoot extends Activity
{
private static final String TAG = "AimnShootMain";
private CamView mCamView = null;
private ARView mARView = null;
private GestureDetector mGDetector = null;
public native void decodeImage(byte[] yuv, int[] rgb, int width, int height);

@Override
public void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
requestWindowFeature(Window.FEATURE_NO_TITLE);
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);
mCamView = new CamView(this);
setContentView(mCamView);
mARView = new ARView(this);
addContentView(mARView, new LayoutParams(LayoutParams.FILL_PARENT,LayoutParams.FILL_PARENT));
System.loadLibrary("decodelib");
mGDetector = new GestureDetector(this, new OnGestureListener());
mTarList = new HashMap();
mHandler.sendEmptyMessageDelayed(MSG_START_CAMERA, 250);
}
...
}


変数宣言部では、まずネイティブコードで記述する関数を
  decodeImage(byte[] yuv, int[] rgb, int width, int height)
のように宣言しています。上記の C コードとの対応関係に注意してください。なお、引数の型の後に書かれている変数名はダミーなので任意の名前でかまいませんが、省略することはできません。次に、onCreate() 中でこの関数を含むライブラリを
  System.loadLibrary("decodelib")
のようにロードしています。以上で受け入れ準備は完了です。


次に、プレビューコールバック関数を、CamData クラスを使わない形に書き換えます。


AimnShoot.java (2)

    /******************************************************************************
* Camera Data Processing
******************************************************************************/
private int imgWidth;
private int imgHeight;
private byte[] imgYUV;
private int[] imgRGB;
private Camera.PreviewCallback mPrevCallback = new Camera.PreviewCallback()
{
@Override
public void onPreviewFrame(byte[] data, Camera camera)
{
if(isTracking || isFinishing() || (camera == null)){
return;
}
Camera.Parameters params = camera.getParameters();
Camera.Size size = params.getPreviewSize();
imgWidth = size.width;
imgHeight = size.height;
if((imgYUV == null) || (data.length != imgYUV.length)){
imgYUV = new byte[data.length];
}
System.arraycopy(data, 0, imgYUV, 0, data.length);
mHandler.sendEmptyMessage(MSG_PROCESS_CAMDATA);
}
};


内容は単純で、CamData クラスに保持していた画像サイズと YUV データを、単にグローバル変数で保持するように変更しただけです。imgWidth と imgHeight、さらに imgYUV がそれに当たります。また RGB 形式に変換したデータを保持する配列 imgRGB も宣言しておきます。


最後に、ズーム画像の作成は下記のように変わります。


AimnShoot.java (3)

    /******************************************************************************
* Tracking Targets
******************************************************************************/
private boolean isTracking = false;
class TrackTargets implements Runnable
{
@Override
public void run()
{
if(isTracking || (imgYUV == null)){
return;
}
isTracking = true;
imgRGB = new int[imgWidth * imgHeight];
decodeImage(imgYUV, imgRGB, imgWidth, imgHeight);
if(zoomLevel > 1){
drawZoomImage();
}
isTracking = false;
}
}

/******************************************************************************
* Drawing Zoom Image
******************************************************************************/
private Bitmap zoomBitmap = null;
private void drawZoomImage()
{
float ratio = (float)mCamView.getHeight() / (float)mCamView.getWidth();
int zoomWidth = imgWidth / zoomLevel;
int zoomHeight = Math.min((int)(zoomWidth * ratio), imgHeight);
int[] zoomRGB = new int[zoomWidth * zoomHeight];
int xbeg = (imgWidth - zoomWidth)/2;
int ybeg = (imgHeight - zoomHeight)/2;
int xend = xbeg + zoomWidth;
int yend = ybeg + zoomHeight;
if(mCamView.getHeight() > mCamView.getWidth()){
for(int p = 0, j = ybeg; j < yend; j++){
for(int i = xend - 1; i >= xbeg; i--){
zoomRGB[p++] = imgRGB[i*imgWidth+j];
}
}
}else{
for(int p = 0, j = ybeg; j < yend; j++){
int jj = j * imgWidth;
for(int i = xbeg; i < xend; i++){
zoomRGB[p++] = imgRGB[jj+i];
}
}
}
zoomBitmap = Bitmap.createBitmap(zoomRGB, zoomWidth, zoomHeight, Config.ARGB_8888);
zoomRGB = null;
mHandler.sendEmptyMessage(MSG_SET_ZOOM);
}


run() の中で、imgYUV が null でなければ、imgRGB を int 型の 1 次元配列として int[imgWidth * imgHeight] のように確保し、ネイティブの
  decodeImage(imgYUV, imgRGB, imgWidth, imgHeight);
を実行して変換データを取得しています。


あとはここからズーム画像のビットマップを作成するだけですが、ピクセルデータを取得するときの添字の計算に注意するだけで、書き換えそのものは非常に単純です。


以上でネイティブコードへの書き換えがすべて完了しました。Eclipse 上でビルドを行えば、すぐにアプリを動かすことが可能です。




第1部 ARアプリ作成の基本

第1回: カメラ画面の表示
    画面上にカメラのプレビュー画像を表示し続けるだけのアプリで、ARの基本中の基本です。
第2回: AR画面の表示
    カメラ画面の上にAR画面を載せ、カメラ画面上にいろいろな画像を重ね合わせます。
第3回: ジェスチャの認識
    タップ、ダブルタップ、長押し、フリックなどの画面操作を認識し、対応する処理を行います。
第4回: アニメーションの表示
    タイマーを用いてミサイルが目標に向かって飛んでいく単純なアニメーションを表示します。
第5回: 複雑なアニメーションの表示
    ミサイルが当たった後の爆発や残像の表示など、複雑なアニメーションを表示します。
第6回: 複数の目標の扱い
    同時に複数個の目標を設定し、それぞれをミサイルで撃破する枠組みを導入します。
第7回: ズーム機能の導入
    ズームボタンを設置し、カメラ画面をズームして表示する機構を手作りで実装します。
第8回: ネイティブコードの利用
    ズーム機構を高速化するため、C、C++ を使ったネイティブコードを導入します。

第2部 ARアプリ作成の応用

近日アップ予定! 乞う御期待!