AndroidでOpenCV 3.2を使って顔検出をする

OpenCVはIntelが公開しているオープンソースの画像解析ライブラリです。今回はAndroidからOpenCVを使ってカメラ映像から人の顔を検出するプログラムを作ってみます。使用するOpenCVのバージョンは昨年末にリリースされたばかりの3.2を使用します。

OpenCV for Androidをダウンロードする

OpenCVのサイトではWindows, Linux, Mac, Android, iOSと様々なOS用のライブラリがダウンロードできるようになっています。バイナリーが提供されているのでソースコードからコンパイルするといった手間もなく簡単に使い始めることができます。

下記リンクからOpenCVのサイトを開きます。

OpenCV for Android というリンクをクリックするとダウンロードが始まります。 opencv-3.2.0-android-sdk.zip のダウンロードが完了したら適当な場所に展開します。

以下、E:\OpenCV-android-sdk に展開した前提で説明をしていきます。

Android Studioで新規プロジェクトを作成する

Android Studioで新規プロジェクトを作成していきます。

アプリケーションの名前やパッケージ名は適当でかまいません。

ターゲットプラットフォームもお好みで。私は自分の持っているスマートフォンに合わせて API 22: Android 5.1 (Lolipop) を選択しました。

アクティビティはシンプルな Empty Activity を選択しました。

Backwards Compatibility (AppCompat) のチェックを外すと、プロジェクト構成がよりシンプルになります。

OpenCVをモジュールとして追加する

Android Studioプロジェクトのひな型ができたら、OpenCVをモジュールとして追加します。

FileNewImport Module… を選択して New Moduleウィザードを起動します。 Source directory: の右側にあるボタンを押して、OpenCVライブラリを展開したフォルダー下位にある sdk\java を指定します。 Module name: には自動的に openCVLibrary320 と表示されるので、このまま Next を押します。

次の画面では、少し長い英文が表示されてチェックボックスが3つ並んでいます。チェックを入れたままにしておけば、build.gradle を自動的に書き換えてライブラリの依存関係を追加してくれます。そのまま Finish を押しましょう。

これで、プロジェクトにOpenCVモジュールが追加れました。

libopencv_java3.soをコピーする

ZIPファイルを展開した E:\OpenCV-android-sdk\sdk\native\libs 以下に armeabi-v7a などのCPUアーキテクチャ名の付いたフォルダーがあり、その中に libopencv_*.a という拡張子 .a の付いた複数のファイルと libopencv_java3.so というファイルがあります。必要になるのは libopencv_java3.so というファイルです。

これを CPUアーキテクチャ名の付いたフォルダーごとプロジェクトの app/src/main/jniLibs にコピーします。 (jniLibsというフォルダーは存在していないので作成してください。)

自分が必要としているCPUアーキテクチャの分だけコピーしてください。私は armeabi-v7a だけをコピーしました。

haarcascade_frontalface_alt.xmlをコピーする

ZIPファイルを展開した E:\OpenCV-android-sdk\sdk\etc\haarcascades 以下に複数のXMLがあります。今回は顔検出をするために haarcascade_frontalface_alt.xml を使用します。このファイルを haarcascades フォルダーと一緒にプロジェクトの assets にコピーします。 assetsmain を右クリックして NewFolderAssets Folder を選ぶことで作成できます。

appにモジュール依存関係を設定する

FileProject Structure のサイトを開きます。左側の app を選択してから右側の Dependencies タブを開きます。右端の + ボタンを押して Module dependency を選択するとモジュール選択の一覧が表示されます。 openCVLibrary320 を選択状態にして OK を押します。

もう一度、OK を押して Project Structure を閉じます。

compileSdkVersionを修正する

まだプログラムコードはまったく書いていませんが、ここで一度ビルドをしてみます。 BuildMake Project を選択します。

するとエラーがずらっと表示されました。

エラー: シンボルを見つけられません
シンボル: 変数 GL_TEXTURE_EXTERNAL_OES
場所: クラス GLES11Ext

このようなエラーが表示された場合は build.gradle (Module: openCVLibrary320) を開いて compileSdkVersion の値を確認してみてください。バージョン指定が 21未満の場合は21以上の値に修正してください。

もう一度、BuildMake Project を選択して、エラーが解消していれば大丈夫です。

プログラムを作る

ここまでの手順でOpenCVを使う準備が整いました。ようやくプログラムを書き始めることができます。

このプログラムではカメラデバイスを使うので AndroidManifest.xml には CAMERA パーミッションを追加しておいてください。

AndroidManifest.xml
<uses-permission android:name="android.permission.CAMERA"/>

先に完成しているソースコードを載せます。

MainActivity.java
package com.example.opencv; import android.app.Activity; import android.os.Bundle; import android.view.View; import android.view.ViewGroup; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; public class MainActivity extends Activity { static { System.loadLibrary("opencv_java3"); } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); try { // assetsの内容を /data/data/*/files/ にコピーします。 copyAssets("haarcascades"); } catch (IOException e) { e.printStackTrace(); } CameraView cameraView = new CameraView(this, 90); ViewGroup activityMain = (ViewGroup)findViewById(R.id.activity_main); activityMain.addView(cameraView); } private void copyAssets(String dir) throws IOException { byte[] buf = new byte[8192]; int size; File dst = new File(getFilesDir(), dir); if(!dst.exists()) { dst.mkdirs(); dst.setReadable(true, false); dst.setWritable(true, false); dst.setExecutable(true, false); } for(String filename : getAssets().list(dir)) { File file = new File(dst, filename); OutputStream out = new FileOutputStream(file); InputStream in = getAssets().open(dir + "/" + filename); while((size = in.read(buf)) >= 0) { if(size > 0) { out.write(buf, 0, size); } } in.close(); out.close(); file.setReadable(true, false); file.setWritable(true, false); file.setExecutable(true, false); } } }

MainActivity では特に難しいことはしていません。

1. opencv_java3.so のロード

以下のコードで opencv\_java3.so をロードしています。

static {
    System.loadLibrary("opencv_java3");
}
2. assets から files へのファイルコピー

copyAssetsメソッドを作って assets から files にファイルをコピーしています。

3. CameraView の追加

アクティビティに CameraView (後述)を追加しています。 CameraView の追加はJavaコードでやっているので res/layout/activity_main.xml を編集する必要はありません。

CameraView.java
package com.example.opencv; import android.content.Context; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.RectF; import android.hardware.Camera; import android.util.Log; import android.view.SurfaceHolder; import android.view.SurfaceView; import org.opencv.android.Utils; import org.opencv.core.CvType; import org.opencv.core.Mat; import org.opencv.core.MatOfRect; import org.opencv.core.Scalar; import org.opencv.objdetect.CascadeClassifier; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.List; public class CameraView extends SurfaceView implements SurfaceHolder.Callback, Camera.PreviewCallback { private static final String TAG = "CameraView"; private int degrees; private Camera camera; private int[] rgb; private Bitmap bitmap; private Mat image; private CascadeClassifier detector; private MatOfRect objects; private List<RectF> faces = new ArrayList<RectF>(); public CameraView(Context context, int displayOrientationDegrees) { super(context); setWillNotDraw(false); getHolder().addCallback(this); String filename = context.getFilesDir().getAbsolutePath() + "/haarcascades/haarcascade_frontalface_alt.xml"; detector = new CascadeClassifier(filename); objects = new MatOfRect(); degrees = displayOrientationDegrees; } /* * SurfaceHolder.Callback */ @Override public void surfaceCreated(SurfaceHolder holder) { Log.d(TAG, "surfaceCreated: holder=" + holder); if(camera == null) { camera = Camera.open(0); } camera.setDisplayOrientation(degrees); camera.setPreviewCallback(this); try { camera.setPreviewDisplay(holder); } catch(IOException e) { e.printStackTrace(); } Camera.Parameters params = camera.getParameters(); for(Camera.Size size : params.getSupportedPreviewSizes()) { Log.i(TAG, "preview size: " + size.width + "x" + size.height); } for(Camera.Size size : params.getSupportedPictureSizes()) { Log.i(TAG, "picture size: " + size.width + "x" + size.height); } params.setPreviewSize(640, 480); camera.setParameters(params); } @Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { Log.d(TAG, "surfaceChanged: holder=" + holder + ", format=" + format + ", width=" + width + ", height=" + height); if(image != null) { image.release(); image = null; } if(bitmap != null) { if(!bitmap.isRecycled()) { bitmap.recycle(); } bitmap = null; } if(rgb != null) { rgb = null; } faces.clear(); camera.startPreview(); } @Override public void surfaceDestroyed(SurfaceHolder holder) { Log.d(TAG, "surfaceDestroyed: holder=" + holder); if(camera != null) { camera.stopPreview(); camera.release(); camera = null; } if(image != null) { image.release(); image = null; } if(bitmap != null) { if(!bitmap.isRecycled()) { bitmap.recycle(); } bitmap = null; } if(rgb != null) { rgb = null; } faces.clear(); } /* * SurfaceHolder.Callback */ @Override public void onPreviewFrame(byte[] data, Camera camera) { //Log.d(TAG, "onPreviewFrame: "); int width = camera.getParameters().getPreviewSize().width; int height = camera.getParameters().getPreviewSize().height; Log.d(TAG, "onPreviewFrame: width=" + width + ", height=" + height); Bitmap bitmap = decode(data, width, height, degrees); if(degrees == 90) { int tmp = width; width = height; height = tmp; } if(image == null) { image = new Mat(height, width, CvType.CV_8U, new Scalar(4)); } Utils.bitmapToMat(bitmap, image); detector.detectMultiScale(image, objects); faces.clear(); for(org.opencv.core.Rect rect : objects.toArray()) { float left = (float)(1.0 * rect.x / width); float top = (float)(1.0 * rect.y / height); float right = left + (float)(1.0 * rect.width / width); float bottom = top + (float)(1.0 * rect.height / height); faces.add(new RectF(left, top, right, bottom)); } invalidate(); } /* * View */ @Override protected void onDraw(Canvas canvas) { //もともとSurfaceView は setWillNotDraw(true) なので super.onDraw(canvas) を呼ばなくてもよい。 //super.onDraw(canvas); Paint paint = new Paint(); paint.setColor(Color.GREEN); paint.setStyle(Paint.Style.STROKE); paint.setStrokeWidth(4); int width = getWidth(); int height = getHeight(); for(RectF face : faces) { RectF r = new RectF(width * face.left, height * face.top, width * face.right, height * face.bottom); canvas.drawRect(r, paint); } } /* * */ /** Camera.PreviewCallback.onPreviewFrame で渡されたデータを Bitmap に変換します。 * * @param data * @param width * @param height * @param degrees * @return */ private Bitmap decode(byte[] data, int width, int height, int degrees) { if (rgb == null) { rgb = new int[width * height]; } final int frameSize = width * height; for (int j = 0, yp = 0; j < height; j++) { int uvp = frameSize + (j >> 1) * width, u = 0, v = 0; for (int i = 0; i < width; i++, yp++) { int y = (0xff & ((int) data[yp])) - 16; if (y < 0) y = 0; if ((i & 1) == 0) { v = (0xff & data[uvp++]) - 128; u = (0xff & data[uvp++]) - 128; } int y1192 = 1192 * y; int r = (y1192 + 1634 * v); int g = (y1192 - 833 * v - 400 * u); int b = (y1192 + 2066 * u); if (r < 0) r = 0; else if (r > 262143) r = 262143; if (g < 0) g = 0; else if (g > 262143) g = 262143; if (b < 0) b = 0; else if (b > 262143) b = 262143; rgb[yp] = 0xff000000 | ((r << 6) & 0xff0000) | ((g >> 2) & 0xff00) | ((b >> 10) & 0xff); } } if(degrees == 90) { int[] rotatedData = new int[rgb.length]; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { rotatedData[x * height + height - y - 1] = rgb[x + y * width]; } } int tmp = width; width = height; height = tmp; rgb = rotatedData; } if(bitmap == null) { bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); } bitmap.setPixels(rgb, 0, width, 0, 0, width, height); return bitmap; } }

android.hardware.Camera を使ってカメラのプレビュー画像を onPreviewFrame で受け取っています。受け取ったデータを Bitmap に変換して、detector (OpenCVのCascadeClassifier)に渡すことで顔認識ができます。

顔認識した領域(矩形)が取得できるので、それを onDraw で描画しています。

haarcascades XMLとは?

今回は顔認識をするために haarcascade_frontalface_alt.xml を使用しましたが、 OpenCVには他にも様々なXMLファイルが用意されています。これらを使うことで、いろいろな画像解析をおこなうことができるようです。

また、顔認識をおこなうためのXMLも複数用意されています。

  • haarcascade_frontalface_alt.xml
  • haarcascade_frontalface_alt_tree.xml
  • haarcascade_frontalface_alt2.xml
  • haarcascade_frontalface_default.xml

どれを使っても顔を検出することができますが、それぞれ検出率や精度が異なるようです。仕組みの違いに関するドキュメントを見つけることはできませんでしたが、検出精度について Stack Overflow に、それぞれのXMLで検出率を比較した投稿を見つけることができました。

これによると、haarcascade_frontalface_default.xml は認識率は高いが誤認識もある、つまりゆるいようです。haarcascade_frontalface_alt_tree.xml は認識率がだいぶ下がってしまう。

OpenCV 3.2の導入とサンプルコードは以上です。

そのうち、顔認識した結果からその人物までの距離を推定するといったことに挑戦してみようと思います。