Android 人脸检测实现

10,388 阅读9分钟
原文链接: yuncnc.github.io

最近公司的项目需要用到人脸检测的功能,我花了一些时间整理了一下,以此作为记录,这也是我的第一篇博文,有什么不正确的地方,希望大家指正

介绍

引用百度知道人脸识别词条中的介绍
人脸识别技术是基于人的脸部特征,对输入的人脸图像或者视频流 . 首先判断其是否存在人脸 , 如果存在人脸,则进一步的给出每个脸的位置、大小和各个主要面部器官的位置信息。并依据这些信息,进一步提取每个人脸中所蕴涵的身份特征,并将其与已知的人脸进行对比,从而识别每个人脸的身份。

人脸识别在Android平台的应用已经不是什么新鲜事了,从最初的4.0系统的人脸解锁屏幕,现在各个相机应用动画贴图效果,都是依靠人脸识别来实现的。Android中人脸相关的API,在Level 1的时候就已经存在,但是他能做到的也只是人脸的检测,而非通常上讲的人脸识别。接下来就来实现一个人脸检测的应用。

流程

首先用Camera来预览图像,那么就需要用到SurfaceView来进行显示,接下来就有两种方式来进行检测人脸

  1. 直接为Camera添加人脸检测的监听器,这种方式是在系统底层实现的,我们只要负责回调就可以了。
  2. 我们自己处理图像。拿到预览的每一帧图像,然后在调用人脸检测API来找到人脸。

最后我们要将人脸的位置信息绘制到屏幕上去,又需要用到SurfaceView

实现

由于SurfaceViewCanvas在绘制时锁定的,我们不能在已经预览CameraSurfaceView上进行绘制,所以就要用到两个重叠的SurfaceView
布局如下:

< ?xml version="1.0" encoding="utf-8"?>
< FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.ycxu.facedemo.MainActivity">
    < SurfaceView
        android:id="@+id/after_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
    < SurfaceView
        android:id="@+id/before_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
< /FrameLayout>

Camera如何进行预览以及在预览过程中所遇到的问题(比如预览画面的拉伸),在这里就不做叙述,Google和百度上面有很多这方面的内容。(此处有个巨大的Bug,会导致无法获取正确的图像,具体跳到最后)代码如下:

public class MainActivity extends AppCompatActivity implements SurfaceHolder.Callback {
    private Camera mCamera;
    private SurfaceView mAfterView, mBeforeView;
    private SurfaceHolder mAfterHolder, mBeforeHolder;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        //全屏模式
        getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
                View.SYSTEM_UI_FLAG_FULLSCREEN|
                View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
        setContentView(R.layout.activity_main);
        //隐藏ToolBar
        getSupportActionBar().hide();
        
        //为了不产生相机拉伸,在这里把整个布局的款宽高比设置为4:3
        Display defaultDisplay = getWindow().getWindowManager().getDefaultDisplay();
        int width = defaultDisplay.getWidth();
        int height = width/3*4;
        findViewById(R.id.container).setLayoutParams(new FrameLayout.LayoutParams(width,height));
        mAfterView = (SurfaceView) findViewById(R.id.after_view);
        mBeforeView = (SurfaceView) findViewById(R.id.before_view);
        mAfterHolder = mAfterView.getHolder();
        mBeforeHolder = mBeforeView.getHolder();
//        这两个方法都能让这个SurfaceView处于上层。源码上说这个两个方法会互相覆盖
//        mBeforeView.setZOrderMediaOverlay(true);
        mBeforeView.setZOrderOnTop(true);
//        让它背景透明,以便显示下面的内容
        mBeforeHolder.setFormat(PixelFormat.TRANSPARENT);
        mAfterHolder.addCallback(this);
    }
    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        mCamera = Camera.open();//可以根据ID使用不同的摄像头
        try {
            mCamera.setPreviewDisplay(holder);
        } catch (IOException e) {
            e.printStackTrace();
        }
        Camera.CameraInfo info = new Camera.CameraInfo();
        //获得默认的相机的相关信息,这里主要是拿到系统适配的相机旋转角度。但是这并不一定准确
        //http://dev.qq.com/topic/583ba1df25d735cd2797004d
        //https://www.qcloud.com/community/article/168
        Camera.getCameraInfo(0, info);
        mCamera.setDisplayOrientation(info.orientation);
        mCamera.startPreview();
    }
    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        Camera.Parameters parameters = mCamera.getParameters();
        List previewSizes = parameters.getSupportedPreviewSizes();//获得相机预览所支持的大小。
        Camera.Size size = previewSizes.get(previewSizes.size() - 1);
        parameters.setPreviewSize(size.width, size.height);
        mCamera.setParameters(parameters);
    }
    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        mCamera.stopPreview();
        mCamera.release();//Camera是系统资源,不用了要释放
    }
}

运行效果如下:

照片有点模糊,但效果已经出来了

官方实现的人脸检测

一般来讲,如果设备是手机,那么都会支持人脸检测功能,但是还是有个别手机或者其他的Android定制系统不支持这个功能。如何验证系统是否支持这个功能,可以通过以下代码来判断:

//获取到最大人脸检测数,如果是0,那么就是不支持
int maxNumDetectedFaces = mCamera.getParameters().getMaxNumDetectedFaces()
if (maxNumDetectedFaces > 0) {
    mCamera.setFaceDetectionListener(this);
    mCamera.startPreview();
    mCamera.startFaceDetection();
    return;
}

这里说一下人脸检测开启关闭的时机,开启时必须在开启预览之后,关闭时必须在关闭预览之前。否则会抛出异常

拿到Face以后,就可以开始进行绘制了。然而并没这么简单。通过Debug发现,除了rect参数有值以外,其他的几个关键参数却为null,而且rect坐标也有点诡异,居然有负数,这就无法直接拿来用了。

通过多种各种测试和Google,得出两个结论:

  1. 由于摄像头的安装方向的问题(一般都是横屏安装的),预览画面都是通过旋转后的画面,而用于人脸检测的图像是没有经过旋转的。
  2. 坐标系的不同,这里得到的rect是来自Camera.Area,而他的坐标系跟屏幕坐标系完全不是一个概念。

Camera.Area对象中的Rect属性描述一个映射2000*2000单元格子的正方形。坐标-1000,-1000代表相机图片的左上角,坐标1000,1000代表相机图片右下角,如下图文所示:

.红线表示在相机预览中为Camera.Area指定坐标系统。蓝色方框展示使用值为333,333,667,667的Rect的相机区域位置和形状。
该坐标系的边界总是与相机预览中可见的图像的外边缘相一致,且不会随缩放级别而缩小或扩展。同样,使用 Camera.setDisplayOrientation() 旋转预览的图片,不会重新映射坐标系统。
Camera.Area 官方API
Camera 中文 API介绍

知道了原因,但是我们无法干涉他的检测过程,那么就只有通过已有的结果,逆向推算出符合屏幕坐标系的的结果。做法就是进行各种变换,具体代码如下:

/** 该方法出自
  * http://blog.csdn.net/yanzi1225627/article/details/38098729/
  * http://bytefish.de/blog/face_detection_with_android/    
  * @param matrix 这个就不用说了
  * @param mirror 是否需要翻转,后置摄像头(手机背面)不需要翻转,前置摄像头需要翻转。
  * @param displayOrientation 旋转的角度
  * @param viewWidth 预览View的宽高
  * @param viewHeight
  */
 public void prepareMatrix(Matrix matrix, boolean mirror, int displayOrientation,
                           int viewWidth, int viewHeight) {
     // Need mirror for front camera.
     matrix.setScale(mirror ? -1 : 1, 1);
     // This is the value for android.hardware.Camera.setDisplayOrientation.
     matrix.postRotate(displayOrientation);
     // Camera driver coordinates range from (-1000, -1000) to (1000, 1000).
     // UI coordinates range from (0, 0) to (width, height)
     matrix.postScale(viewWidth / 2000f, viewHeight / 2000f);
     matrix.postTranslate(viewWidth / 2f, viewHeight / 2f);
 }

剩下的就是绘制,这点没什么说的,需要注意的都在代码中注释了。具体直接看代码:

    @Override
    public void onFaceDetection(Camera.Face[] faces, Camera camera) {
        Canvas canvas = mBeforeHolder.lockCanvas();//锁定Surface 并拿到Canvas
        canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);// 清除上一次绘制
        if (faces.length < 1) {
            mBeforeHolder.unlockCanvasAndPost(canvas);
            return;
        }
        Matrix matrix = new Matrix();
        
//        这里使用的是后置摄像头就不用翻转。由于没有进行旋转角度的兼容,这里直接传系统调整的值
        prepareMatrix(matrix, false, mOrientation, mViewWidth, mViewHeight);
        
//        canvas.save();
//        由于有的时候手机会存在一定的偏移(歪着拿手机)所以在这里需要旋转Canvas 和 matrix,
//        偏移值从OrientationEventListener获得,具体Google
//        canvas.rotate(-degrees); 默认是逆时针旋转
//        matrix.postRotate(degrees);默认是顺时针旋转
        for (int i = 0; i < faces.length; i++) {
            RectF rect = new RectF(faces[i].rect);
            matrix.mapRect(rect);//应用到rect上
            canvas.drawRect(rect,mPaint);
        }
        mBeforeHolder.unlockCanvasAndPost(canvas);//更新Canvas并解锁
//        canvas.restore();
    }

效果如下:

以上就是Camera自己实现的人脸检测。但是有的设备没有这个功能,那么就必须要我们自己来实现整个过程,这就要用到Google官方为我们提供了一个FaceDetector类。

自己动手实现的人脸检测

FaceDetector类是在Android level 1 就已经添加到Android系统,它能够在Bitmap中检测出人脸,并标记处相对Bitmap坐标的两眼之间的中心点。通过这个点,就可以进行各种操作了。那么如何获得Bitmap呢? 这个可以为Camera添加监听器获取到预览的每一帧的数据,再进行转换就能得到。首先要做的就是为Camera添加PreviewCallback:

//        以下方法都是添加预览监听,内存占用上有区别,具体Google
//        mCamera.setOneShotPreviewCallback(this);
//        mCamera.setPreviewCallbackWithBuffer(this);
        mCamera.setPreviewCallback(new Camera.PreviewCallback() {
            @Override
            public void onPreviewFrame(byte[] data, Camera camera) {
            }
        });

可以看到返回的并不是一个Bitmap,接下来要做的就是解析数据为Bitmap,这要用到Yuvimage这个类。

Camera.Size size = camera.getParameters().getPreviewSize();
//这里一定要得到系统兼容的大小,否则解析出来的是一片绿色或者其他
YuvImage yuvImage  = new YuvImage(data, ImageFormat.NV21,size.width,size.Height,null);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
yuvImage.compressToJpeg(new Rect(0,0,size.width,size.Height),80,outputStream);
BitmapFactory.Options options = new BitmapFactory.Options();
options.inPreferredConfig = Bitmap.Config.RGB_565;//必须设置为565,否则无法检测
Bitmap bitmap = BitmapFactory.decodeByteArray(outputStream.toByteArray(), 0, outputStream.toByteArray().length, options);

基本就是这个套路,有何疑问可以查看源码的注释。有了Bitmap就可以开始人脸检测了,代码如下:

         Matrix matrix = new Matrix();
                matrix.postRotate(mOrientation);//获得的图像同样是需要旋转的
                matrix.postScale(0.25f, 0.25f);//为了减小内存压力,将图片缩放,但是也不能太小,否则检测不到人脸
                bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);
//              绘制一下预览图看是否有问题
                Canvas canvas = mBeforeHolder.lockCanvas();
                canvas.drawBitmap(bitmap, new Matrix(), mPaint);
                mBeforeHolder.unlockCanvasAndPost(canvas);
                FaceDetector detector = new FaceDetector(bitmap.getWidth(), bitmap.getHeight(), 5);//5 代表人脸最大检测数
                FaceDetector.Face[] faces = new FaceDetector.Face[5];
                detector.findFaces(bitmap, faces);
                for (FaceDetector.Face face : faces) {
//                    获取一个指定范围的canvas,减少绘制的范围
//                    mBeforeHolder.lockCanvas(new Rect());
                    canvas = mBeforeHolder.lockCanvas();
                    if (face == null) {
                        canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
                        mBeforeHolder.unlockCanvasAndPost(canvas);
                        return;
                    }
                    Log.e(TAG, "run: 已检测到人脸");
                    PointF pointF = new PointF();
                    face.getMidPoint(pointF);
//                    由于这个坐标是进行缩放后的坐标,所以必须计算出正确的坐标
//                    如果是前置摄像头,那么这里就需要计算翻转后的X坐标,
//                    pointF.x = mViewWidth - mViewWidth * (pointF.x / bitmap.getWidth());
                    pointF.x = mViewWidth * (pointF.x / bitmap.getWidth());
                    pointF.y = mViewHeight * (pointF.y / bitmap.getHeight());
//                    获得中心点到两眼之间的距离
                    float v = face.eyesDistance();
//                    绘制方框
                    canvas.drawRect(pointF.x - v * 2,
                            pointF.y - v * 2,
                            pointF.x + v * 2,
                            pointF.y + v * 2,
                            mPaint);
                    mBeforeHolder.unlockCanvasAndPost(canvas);
                }

到这里基本就完成了。

总结

官方实现的人脸检测方式调用简单,获取到坐标后稍加转换就可以用,自己实现的方式,过程复杂,之间还会涉及到图片格式等问题。所以一般还是用官方实现的方式。

修正&补充

由于之前Camera设置的代码是新写的代码,与其他的代码不是一起完成,差点就铸成大错了。问题出在设置预览大小上面,先看问题:

左上角那块无法描述的东西是在检测时画上去的,造成这个问题的原因是使用YUVImage转换格式时,传入的宽高不正确。因此导致转换失败。但是参考其他的代码,都是通过获取Camera的预览大小传入的。那么问题是出在Camera身上,重新梳理了一下逻辑。终于发现了问题所在。
在设置Camera预览大小时为了让预览画面不产生拉伸。取巧的为布局设置3:4的宽高,但是预览大小设置的是却是手机兼容的最大分辨率。由此造成了看到的是正确的画面。而用于人脸检测的图像却是错误的。解决问题的办法很简单,只要尽量保持图像比例的一致就可以了。我这里是这么做的

Camera.Parameters parameters = mCamera.getParameters();
List sizes = parameters.getSupportedPreviewSizes();//获得相机预览所支持的大小。
for (Size size : sizes) {
    if (size.width / 3 == size.height / 4) {
        parameters.setPreviewSize(size.width, size.height);
        break;
    }
}
mCamera.setParameters(parameters);

参考这里