本当は顔認証をやりたかったのだけど現状では無理っぽいので とりあえず顔認識まで試してみた。

Camera.FaceDetection を使う方法

Level 14 から追加された API でカメラのプレビュー中に顔を認識してくれる機能があります。

但し、この機能はハードウェア依存らしく機種によって使えたり使えなかったりです。 さらに、顔の各パーツの認識機能も有りますが同様に機種依存です。

手持ちの機種での動作状況です。

  • Nexus5 : 顔認識=○、顔のパーツ=×
  • Nexus7(2012) : 顔認識=×、顔のパーツ=×

顔のパーツの位置がとれれば自前で認証機能を作る事も可能だったのですが顔の位置だけではどうしようもありません。

実装方法は通常のカメラプレビューに以下を追加するだけです。

camera.setFaceDetectionListener(new Camera.FaceDetectionListener(){
    @Override
    public void onFaceDetection(Camera.Face[] faces, Camera camera) {
        // 顔データ処理
    }
});
camera.startFaceDetection();

Camera.Face には以下のフィールドが有りますが Nexus5 で取得できたのは rect と score のみです。

フィールド名説明(API Doc より)
id An unique id per face while the face is visible to the tracker.
leftEyeThe coordinates of the center of the left eye.
rightEyeThe coordinates of the center of the right eye.
mouth The coordinates of the center of the mouth.
rect Bounds of the face.
score The confidence level for the detection of the face.

rect の座標系は特殊なため SurfaceView に書き込むには座標変換が必要になります。

  • プレビュー画像に対し -1000~1000 の相対座標である。
  • 座標(-1000,-1000)が左上、座標(0,0) が画像中心となる。
  • 座標系のプレビュー画像はlandscapeとなる。portraitの場合は90度回転が必要。

score は 50 以上なら高精度だそうです。

サンプル・アプリ

取得できた顔の矩形をプレビューに被せて表示するだけのアプリです。

package org.kotemaru.android.facetest;

import android.app.Activity;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Paint.Style;
import android.graphics.PixelFormat;
import android.graphics.PorterDuff;
import android.graphics.Rect;
import android.hardware.Camera;
import android.hardware.Camera.CameraInfo;
import android.hardware.Camera.Face;
import android.os.Bundle;
import android.util.Log;
import android.view.SurfaceHolder;
import android.view.SurfaceView;

public class FaceTest1Activity extends Activity {
    private static final String TAG = "FaceTest";

    private Camera camera;
    private SurfaceView preview;
    private SurfaceView overlay;
    private CameraListener cameraListener;
    private OverlayListener overlayListener;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_facetest);

        preview = (SurfaceView) findViewById(R.id.preview);
        cameraListener = new CameraListener(preview);

        overlay = (SurfaceView) findViewById(R.id.overlay);
        overlayListener = new OverlayListener(overlay);
    }
    @Override
    protected void onPostCreate(Bundle savedInstanceState) {
        super.onPostCreate(savedInstanceState);
        preview.getHolder().addCallback(cameraListener);
        overlay.getHolder().addCallback(overlayListener);
    }

    private class CameraListener implements
            SurfaceHolder.Callback,
            Camera.FaceDetectionListener
    {
        private SurfaceView surfaceView;
        private SurfaceHolder surfaceHolder;

        public CameraListener(SurfaceView surfaceView) {
            this.surfaceView = surfaceView;
        }

        @Override
        public void surfaceCreated(SurfaceHolder holder) {
            surfaceHolder = holder;
            try {
                int cameraId = -1;
                // フロントカメラを探す。
                Camera.CameraInfo info = new Camera.CameraInfo();
                for (int id = 0; id < Camera.getNumberOfCameras(); id++) {
                    Camera.getCameraInfo(id, info);
                    if (info.facing == CameraInfo.CAMERA_FACING_FRONT) {
                        cameraId = id;
                        break;
                    }
                }
                camera = Camera.open(cameraId);
                camera.setPreviewDisplay(holder);
                camera.getParameters().setPreviewFpsRange(1, 20);
                camera.setDisplayOrientation(90); // portrate 固定
                // 顔認証機能サポートチェック。
                if (camera.getParameters().getMaxNumDetectedFaces() == 0) {
                    throw new Error("Not supported face detected.");
                }
            } catch (Exception e) {
                Log.e(TAG, e.toString(), e);
            }
        }

        @Override
        public void surfaceChanged(SurfaceHolder holder, int format,
                int width, int height) {
            surfaceHolder = holder;
            camera.startPreview();
            camera.setFaceDetectionListener(cameraListener);
            camera.startFaceDetection();
        }

        @Override
        public void surfaceDestroyed(SurfaceHolder holder) {
            camera.setFaceDetectionListener(null);
            camera.release();
            camera = null;
        }

        @Override
        public void onFaceDetection(Face[] faces, Camera camera) {
            if (faces.length == 0) return;
            Face face = faces[0];
            if (face.score < 30) return;

            overlayListener.drawFace(faceRect2PixelRect(face), Color.RED);
        }

        /**
         * 顔認識範囲を描画用に座標変換する。
         * - Face.rect の座標系はプレビュー画像に対し -1000~1000 の相対座標。
         * - 座標(-1000,-1000)が左上、座標(0,0) が画像中心となる。
         * - 座標系のプレビュー画像はlandscapeとなる。portraitの場合が90度回転が必要。
         * @param face 顔認識情報
         * @return 描画用矩形範囲
         */
        private Rect faceRect2PixelRect(Face face) {
            int w = surfaceView.getWidth();
            int h = surfaceView.getHeight();
            Rect rect = new Rect();

            // フロントカメラなので左右反転、portraitなので座標軸反転
            rect.left = w * (-face.rect.top + 1000) / 2000;
            rect.right = w * (-face.rect.bottom + 1000) / 2000;
            rect.top = h * (-face.rect.left + 1000) / 2000;
            rect.bottom = h * (-face.rect.right + 1000) / 2000;
            //Log.d(TAG, "rect=" + face.rect + "=>" + rect);
            return rect;
        }

    }

    private class OverlayListener implements SurfaceHolder.Callback
    {
        private SurfaceView surfaceView;
        private SurfaceHolder surfaceHolder;

        private Paint paint = new Paint();

        public OverlayListener(SurfaceView surfaceView) {
            this.surfaceView = surfaceView;
        }

        @Override
        public void surfaceCreated(SurfaceHolder holder) {
            surfaceHolder = holder;
            surfaceHolder.setFormat(PixelFormat.TRANSPARENT);
            paint.setStyle(Style.STROKE);
            paint.setStrokeWidth(surfaceView.getWidth() / 100);
        }

        @Override
        public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
            surfaceHolder = holder;
        }

        @Override
        public void surfaceDestroyed(SurfaceHolder holder) {
            // nop.
        }

        public void drawFace(Rect rect1, int color) {
            try {
                Canvas canvas = surfaceHolder.lockCanvas();
                if (canvas != null) {
                    try {
                        canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
                        paint.setColor(color);
                        canvas.drawRect(rect1, paint);
                    } finally {
                        surfaceHolder.unlockCanvasAndPost(canvas);
                    }
                }
            } catch (IllegalArgumentException e) {
                Log.w(TAG, e.toString());
            }
        }

    }
}

実行結果

実行結果はこんな感じになりました。


顔の矩形はほぼリアルタイムにプレビューに追従します。

FaceDetector を使う方法

Camera.FaceDetection は機種依存なので API Level-1 からある FaceDetector を使って同じ事をしてみます。

FaceDetector は低速なのでリアルタイム性は犠牲になります。

FaceDetector の使い方はこれだけです。

FaceDetector faceDetector = new FaceDetector(image.getWidth(), image.getHeight(), MAX_FACE);
FaceDetector.Face[] faces = new FaceDetector.Face[MAX_FACE];
int n = faceDetector.findFaces(image, faces);

プレビューで得た画像を渡せば顔の位置が得られます。

渡す画像は RGB_565 形式で顔が正立の状態でなければなりません。
面倒なのはプレビュー画像のデータは YUV420 形式の landscape 固定なので画像変換処理が必要となる事です。

FaceDetector.Face には以下のメソッドが有ります。 厳密には顔では無く目の位置が取得できると言うことになります。

メソッド名説明(API Doc より)
confidence() Returns a confidence factor between 0 and 1.
getMidPoint(PointF point) Sets the position of the mid-point between the eyes.
eyesDistance() Returns the distance between the eyes.
pose(int euler) Returns the face's pose.

座標系は画像と同じなのでそのまま使用できます。

サンプル・アプリ

取得できた目の位置をプレビューに被せて表示するだけのアプリです。

package org.kotemaru.android.facetest;

import java.util.List;

import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Paint.Style;
import android.graphics.PixelFormat;
import android.graphics.PointF;
import android.graphics.PorterDuff;
import android.graphics.Rect;
import android.hardware.Camera;
import android.hardware.Camera.CameraInfo;
import android.media.FaceDetector;
import android.os.Bundle;
import android.util.Log;
import android.view.SurfaceHolder;
import android.view.SurfaceView;

public class FaceTest2Activity extends Activity {
    private static final String TAG = "FaceTest";

    private Camera camera;
    private SurfaceView preview;
    private SurfaceView overlay;
    private CameraListener cameraListener;
    private OverlayListener overlayListener;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_facetest);

        preview = (SurfaceView) findViewById(R.id.preview);
        cameraListener = new CameraListener(preview);

        overlay = (SurfaceView) findViewById(R.id.overlay);
        overlayListener = new OverlayListener(overlay);
    }
    @Override
    protected void onPostCreate(Bundle savedInstanceState) {
        super.onPostCreate(savedInstanceState);
        preview.getHolder().addCallback(cameraListener);
        overlay.getHolder().addCallback(overlayListener);
    }

    private class CameraListener implements
            SurfaceHolder.Callback,
            Camera.PreviewCallback
    {
        private SurfaceView surfaceView;
        private SurfaceHolder surfaceHolder;
        private Rect faceRect = new Rect();;

        public CameraListener(SurfaceView surfaceView) {
            this.surfaceView = surfaceView;
        }

        @Override
        public void surfaceCreated(SurfaceHolder holder) {
            surfaceHolder = holder;
            try {
                int cameraId = -1;
                Camera.CameraInfo info = new Camera.CameraInfo();
                for (int id = 0; id < Camera.getNumberOfCameras(); id++) {
                    Camera.getCameraInfo(id, info);
                    if (info.facing == CameraInfo.CAMERA_FACING_FRONT) {
                        cameraId = id;
                        break;
                    }
                }
                camera = Camera.open(cameraId);
                camera.setPreviewDisplay(holder);
                List<Camera.Size> sizes = camera.getParameters().getSupportedPreviewSizes();
                camera.getParameters().setPreviewSize(sizes.get(0).width, sizes.get(0).height);
                camera.getParameters().setPreviewFpsRange(1, 20);
                camera.setDisplayOrientation(90); // portrate 固定
            } catch (Exception e) {
                Log.e(TAG, e.toString(), e);
            }
        }

        @Override
        public void surfaceChanged(SurfaceHolder holder, int format,
                int width, int height) {
            surfaceHolder = holder;
            camera.setPreviewCallback(this);
            camera.startPreview();
        }

        @Override
        public void surfaceDestroyed(SurfaceHolder holder) {
            camera.setPreviewCallback(null);
            camera.release();
            camera = null;
        }

        @Override
        public void onPreviewFrame(byte[] data, Camera camera) {
            Bitmap image = decodePreview(data);

            FaceDetector faceDetector = new FaceDetector(image.getWidth(), image.getHeight(), 1);
            FaceDetector.Face[] faces = new FaceDetector.Face[1];
            int n = faceDetector.findFaces(image, faces);

            if (n>0) {
                PointF midPoint = new PointF(0, 0);
                faces[0].getMidPoint(midPoint); // 顔認識結果を取得
                float eyesDistance = faces[0].eyesDistance(); // 顔認識結果を取得
                faceRect.left = (int) (midPoint.x - eyesDistance * 0.8 );
                faceRect.right = (int) (midPoint.x + eyesDistance * 0.8 );
                faceRect.top = (int) (midPoint.y - eyesDistance * 0.2);
                faceRect.bottom = (int) (midPoint.y + eyesDistance * 0.2 );
            }
            overlayListener.drawFace(faceRect, Color.YELLOW, image);
        }


        private int[] rgb;
        private Bitmap tmpImage ;
        private Bitmap decodePreview(byte[] data) {
            int width = camera.getParameters().getPreviewSize().width;
            int height = camera.getParameters().getPreviewSize().height;
            if (rgb == null) {
                rgb = new int[width*height];
                tmpImage = Bitmap.createBitmap(height ,width , Bitmap.Config.RGB_565);
            }

            decodeYUV420SP(rgb, data, width, height);
            tmpImage.setPixels(rgb, 0, height, 0, 0, height, width);
            return tmpImage;
        }

    }

    private class OverlayListener implements SurfaceHolder.Callback
    {
        private SurfaceView surfaceView;
        private SurfaceHolder surfaceHolder;

        private Paint paint = new Paint();

        public OverlayListener(SurfaceView surfaceView) {
            this.surfaceView = surfaceView;
        }

        @Override
        public void surfaceCreated(SurfaceHolder holder) {
            surfaceHolder = holder;
            surfaceHolder.setFormat(PixelFormat.TRANSPARENT);
            paint.setStyle(Style.STROKE);
            paint.setStrokeWidth(1);
        }

        @Override
        public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
            surfaceHolder = holder;
        }

        @Override
        public void surfaceDestroyed(SurfaceHolder holder) {
            // nop.
        }

        public void drawFace(Rect rect1, int color, Bitmap previewImage) {
            try {
                Canvas canvas = surfaceHolder.lockCanvas();
                if (canvas != null) {
                    try {
                        //canvas.drawBitmap(previewImage,0,0, paint);
                        canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
                        canvas.scale(
                                (float)surfaceView.getWidth()/previewImage.getWidth(), 
                                (float)surfaceView.getHeight()/previewImage.getHeight());
                        paint.setColor(color);
                        canvas.drawRect(rect1, paint);
                    } finally {
                        surfaceHolder.unlockCanvasAndPost(canvas);
                    }
                }
            } catch (IllegalArgumentException e) {
                Log.w(TAG, e.toString());
            }
        }

    }

    // from https://code.google.com/p/android/issues/detail?id=823
    private void decodeYUV420SP(int[] rgb, byte[] yuv420sp, int width, int height) {
        final int frameSize = width * height;

        for (int j = 0; j < height; j++) {
            int uvp = frameSize + (j >> 1) * width, u = 0, v = 0;
            for (int i = 0; i < width; i++) {
                int srcp = j*width + i;
                int y = (0xff & ((int) yuv420sp[srcp])) - 16;
                if (y < 0) y = 0;
                if ((i & 1) == 0) {
                    v = (0xff & yuv420sp[uvp++]) - 128;
                    u = (0xff & yuv420sp[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;

                // 90度回転
                int xx = height-j-1;
                int yy = width-i-1;
                int dstp = yy * height + xx;
                rgb[dstp] = 0xff000000 | ((r << 6) & 0xff0000) | ((g >> 2) & 0xff00) | ((b >> 10) & 0xff);
            }
        }
    }
}

実行結果

Nexus7 での実行結果はこんな感じになりました。


思った以上にプレビューに追従してきます。
計ってませんが 5fps くらいは出ている感じで十分実用的です。 プレビュー画像の解像度を最小の 176x144 に指定しているためだと思われます。 (指定できる解像度は機種依存です。)

所感

FaceDetector でもプレビューに耐えられる事が分かったのは大きな収穫。

OpenCV を使って詳細な情報が取れれば自前で顔認証を実装することも不可能じゃ無さそう。