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 をモジュールとして追加します。
File → New → Import 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
にコピーします。assets
は main
を右クリックして New → Folder → Assets Folder を選ぶことで作成できます。
appにモジュール依存関係を設定する
File → Project Structure のサイトを開きます。左側の app
を選択してから右側の Dependencies
タブを開きます。右端の +
ボタンを押して Module dependency
を選択するとモジュール選択の一覧が表示されます。openCVLibrary320
を選択状態にして OK を押します。
もう一度、 OK を押して Project Structure を閉じます。
compileSdkVersionを修正する
まだプログラムコードはまったく書いていませんが、 ここで一度ビルドをしてみます。Build → Make Project を選択します。
するとエラーがずらっと表示されました。
エラー: シンボルを見つけられません
シンボル: 変数 GL_TEXTURE_EXTERNAL_OES
場所: クラス GLES11Ext
このようなエラーが表示された場合は build.gradle (Module: openCVLibrary320)
を開いて compileSdkVersion
の値を確認してみてください。バージョン指定が 21 未満の場合は 21 以上の値に修正してください。
もう一度、 Build → Make Project を選択して、 エラーが解消していれば大丈夫です。
プログラムを作る
ここまでの手順で OpenCV を使う準備が整いました。ようやくプログラムを書き始めることができます。
このプログラムではカメラデバイスを使うので AndroidManifest.xml
には CAMERA
パーミッションを追加しておいてください。
AndroidManifest.xml<uses-permission android:name="android.permission.CAMERA"/>
先に完成しているソースコードを載せます。
MainActivity.javapackage 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.javapackage 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 の導入とサンプルコードは以上です。
そのうち、 顔認識した結果からその人物までの距離を推定するといったことにも挑戦してみようと思います。