乐趣区

虹软人脸识别-Android-Camera实时人脸追踪画框适配

在使用虹软人脸识别 Android SDK 的过程中,预览时一般都需要绘制人脸框,但是和 PC 平台相机应用不同,在 Android 平台相机进行应用开发还需要考虑前后置相机切换、设备横竖屏切换等情况,因此在人脸识别项目开发过程中,人脸框绘制适配的实现比较困难。针对该问题,本文将通过以下内容介绍解决方法:

  • 相机原始帧数据和预览成像画面的关系
  • 人脸框绘制到 View 上的流程
  • 具体场景适配方案介绍
  • 处理多种场景的情况,实现适配函数
  • 将适配好的人脸框绘制到 View 上

以下用到的 Rect 说明:

变量名 含义
originalRect 人脸检测回传的人脸框
scaledRect 基于 originalRect 缩放后的人脸框
drawRect 最终绘制所需的人脸框

一、相机原始帧数据和预览成像画面的关系

Android 设备一般为手持设备,相机集成在设备上,设备的旋转也会导致相机的旋转,因此成像也会发生旋转,为了解决这一问题,让用户能够看到正常的成像,Android 提供了相机预览数据绘制到控件时,设置旋转角度的相关 API,开发者可根据 Activity 的显示方向设置不同的旋转角度,这块内容在以下文章中有介绍:
Android 使用 Camera2 获取预览数据
将预览的 YUV 数据转换为 NV21,再转换为 Bitmap 并显示到控件上,同时也将该 Bitmap 转换为相机预览效果的 Bitmap 显示到控件上,便于了解原始数据和预览画面的关系

二、人脸框绘制到 View 上的流程

总体流程

  • 第一步,缩放

  • 第二步,旋转

需要根据图像数据和预览画面的旋转角度关系,选择对应的旋转方案

  • 后置摄像头(预览不镜像)

后置摄像头,旋转 0 度

后置摄像头,旋转 90 度

后置摄像头,旋转 180 度

后置摄像头,旋转 270 度

  • 前置摄像头(预览会镜像)

前置摄像头,旋转 0 度

前置摄像头,旋转 90 度

前置摄像头,旋转 180 度

前置摄像头,旋转 270 度

三、具体场景下的适配方案介绍

以如下场景为例,介绍人脸框适配方案:

屏幕分辨率 相机预览尺寸 相机 ID 屏幕朝向 原始数据 预览效果
1080×1920 1280×720 后置相机 竖屏 原始数据 预览效果

可以看到,在竖屏情况下,原始数据顺时针旋转 90 度并缩放才能达到预览画面的效果,既然图像数据旋转并缩放了,那人脸框也要随着图像旋转并缩放。我们可以先旋转再缩放,也可以先缩放在旋转,这里以先缩放再旋转为例介绍适配的步骤。

第一步,缩放

第二步,旋转

  • 第一步:缩放
    假设人脸检测结果的位置信息是 originalRect:(left, top, right, bottom)(相对于1280×720 的图像的位置),我们将其放大为相对于 1920×1080 的图像的位置:
    scaledRect:(originalRect.left * 1.5, originalRect.top * 1.5, originalRect.right * 1.5, originalRect.bottom * 1.5)
  • 第二步:旋转
    在尺寸修改完成后,我们再将人脸框旋转即可得到目标人脸框,其中旋转的过程如下:

    1. 获取原始数据和预览画面的旋转角度(以上情况为 90 度)
    2. 根据旋转角度将人脸框调整为 View 需要的人脸框,对于绘制所需的人脸框,我们分析下计算方式:

      • drawRect.left
        绘制所需的 Rect 的 left 的值也就是 scaledRect 的下边界到图像下边界的距离,也就是1080 - scaledRect.bottom
      • drawRect.top
        绘制所需的 Rect 的 top 的值也就是 scaledRect 的左边界到图像左边界的距离,也就是scaledRect.left
      • drawRect.right
        绘制所需的 Rect 的 right 的值也就是 scaledRect 的上边界到图像下边界的距离,也就是1080 - scaledRect.top
      • drawRect.bottom
        绘制所需的 Rect 的 bottom 的值也就是 scaledRect 的右边界到图像上边界的距离,也就是scaledRect.right

最终得出了旋转角度为 90 度时绘制所需的drawRect

四、处理多种场景的情况,实现适配函数

通过以上分析,可得出画框时需要用到的绘制参数如下,其中构造函数的最后两个参数是额外添加的,用于特殊场景的手动矫正:

  • previewWidth & previewHeight
    预览宽高,人脸追踪的人脸框是基于这个尺寸的
  • canvasWidth & canvasHeight
    被绘制的控件的宽高,也就是映射后的目标尺寸
  • cameraDisplayOrientation
    预览数据和源数据的旋转角度
  • cameraId
    相机 ID,系统对于前置相机是有做默认镜像处理的,而后置相机则没有
  • isMirror
    预览画面是否水平镜像显示,例如我们如果手动设置了再次镜像预览画面,则需要将最终结果也镜像处理
  • mirrorHorizontal
    为兼容部分设备使用,将调整后的框水平再次镜像
  • mirrorVertical
    为兼容部分设备使用,将调整后的框垂直再次镜像
/**
 * 创建一个绘制辅助类对象,并且设置绘制相关的参数
 *
 * @param previewWidth             预览宽度
 * @param previewHeight            预览高度
 * @param canvasWidth              绘制控件的宽度
 * @param canvasHeight             绘制控件的高度
 * @param cameraDisplayOrientation 旋转角度
 * @param cameraId                 相机 ID
 * @param isMirror                 是否水平镜像显示(若相机是手动镜像显示的,设为 true,用于纠正)* @param mirrorHorizontal         为兼容部分设备使用,水平再次镜像
 * @param mirrorVertical           为兼容部分设备使用,垂直再次镜像
 */
public DrawHelper(int previewWidth, int previewHeight, int canvasWidth,
                  int canvasHeight, int cameraDisplayOrientation, int cameraId,
                  boolean isMirror, boolean mirrorHorizontal, boolean mirrorVertical) {
    this.previewWidth = previewWidth;
    this.previewHeight = previewHeight;
    this.canvasWidth = canvasWidth;
    this.canvasHeight = canvasHeight;
    this.cameraDisplayOrientation = cameraDisplayOrientation;
    this.cameraId = cameraId;
    this.isMirror = isMirror;
    this.mirrorHorizontal = mirrorHorizontal;
    this.mirrorVertical = mirrorVertical;
}

人脸框映射的具体实现

/**
     * 调整人脸框用来绘制
     *
     * @param ftRect FT 人脸框
     * @return 调整后的需要被绘制到 View 上的 rect
     */
    public Rect adjustRect(Rect ftRect) {
        // 预览宽高
        int previewWidth = this.previewWidth;
        int previewHeight = this.previewHeight;

        // 画布的宽高,也就是 View 的宽高
        int canvasWidth = this.canvasWidth;
        int canvasHeight = this.canvasHeight;

        // 相机预览显示旋转角度
        int cameraDisplayOrientation = this.cameraDisplayOrientation;

        // 相机 Id,前置相机在显示时会默认镜像
        int cameraId = this.cameraId;

        // 是否预览镜像
        boolean isMirror = this.isMirror;

        // 针对于一些特殊场景做额外的人脸框镜像操作,// 比如 cameraId 为 CAMERA_FACING_FRONT 的相机打开后没镜像、// 或 cameraId 为 CAMERA_FACING_BACK 的相机打开后镜像
        boolean mirrorHorizontal = this.mirrorHorizontal;
        boolean mirrorVertical = this.mirrorVertical;

        if (ftRect == null) {return null;}

        Rect rect = new Rect(ftRect);
        float horizontalRatio;
        float verticalRatio;

        // cameraDisplayOrientation 为 0 或 180,也就是 landscape 或 reverse-landscape 时
        // 或
        // cameraDisplayOrientation 为 90 或 270,也就是 portrait 或 reverse-portrait 时
        // 分别计算水平缩放比和垂直缩放比
        if (cameraDisplayOrientation % 180 == 0) {horizontalRatio = (float) canvasWidth / (float) previewWidth;
            verticalRatio = (float) canvasHeight / (float) previewHeight;
        } else {horizontalRatio = (float) canvasHeight / (float) previewWidth;
            verticalRatio = (float) canvasWidth / (float) previewHeight;
        }
        rect.left *= horizontalRatio;
        rect.right *= horizontalRatio;
        rect.top *= verticalRatio;
        rect.bottom *= verticalRatio;

        Rect newRect = new Rect();
        // 关键部分,根据旋转角度以及相机 ID 对人脸框进行旋转和镜像处理
        switch (cameraDisplayOrientation) {
            case 0:
                if (cameraId == Camera.CameraInfo.CAMERA_FACING_FRONT) {
                    newRect.left = canvasWidth - rect.right;
                    newRect.right = canvasWidth - rect.left;
                } else {
                    newRect.left = rect.left;
                    newRect.right = rect.right;
                }
                newRect.top = rect.top;
                newRect.bottom = rect.bottom;
                break;
            case 90:
                newRect.right = canvasWidth - rect.top;
                newRect.left = canvasWidth - rect.bottom;
                if (cameraId == Camera.CameraInfo.CAMERA_FACING_FRONT) {
                    newRect.top = canvasHeight - rect.right;
                    newRect.bottom = canvasHeight - rect.left;
                } else {
                    newRect.top = rect.left;
                    newRect.bottom = rect.right;
                }
                break;
            case 180:
                newRect.top = canvasHeight - rect.bottom;
                newRect.bottom = canvasHeight - rect.top;
                if (cameraId == Camera.CameraInfo.CAMERA_FACING_FRONT) {
                    newRect.left = rect.left;
                    newRect.right = rect.right;
                } else {
                    newRect.left = canvasWidth - rect.right;
                    newRect.right = canvasWidth - rect.left;
                }
                break;
            case 270:
                newRect.left = rect.top;
                newRect.right = rect.bottom;
                if (cameraId == Camera.CameraInfo.CAMERA_FACING_FRONT) {
                    newRect.top = rect.left;
                    newRect.bottom = rect.right;
                } else {
                    newRect.top = canvasHeight - rect.right;
                    newRect.bottom = canvasHeight - rect.left;
                }
                break;
            default:
                break;
        }

        /**
         * isMirror mirrorHorizontal finalIsMirrorHorizontal
         * true         true                false
         * false        false               false
         * true         false               true
         * false        true                true
         *
         * XOR
         */
        if (isMirror ^ mirrorHorizontal) {
            int left = newRect.left;
            int right = newRect.right;
            newRect.left = canvasWidth - right;
            newRect.right = canvasWidth - left;
        }
        if (mirrorVertical) {
            int top = newRect.top;
            int bottom = newRect.bottom;
            newRect.top = canvasHeight - bottom;
            newRect.bottom = canvasHeight - top;
        }
        return newRect;
    }

五、将适配好的人脸框绘制到 View 上

  • 实现一个自定义 View
/**
 * 用于显示人脸信息的控件
 */
public class FaceRectView extends View {
    private static final String TAG = "FaceRectView";
    private CopyOnWriteArrayList<DrawInfo> drawInfoList = new CopyOnWriteArrayList<>();
    private Paint paint;

    public FaceRectView(Context context) {this(context, null);
    }

    public FaceRectView(Context context, @Nullable AttributeSet attrs) {super(context, attrs);
        paint = new Paint();}

    // 主要的绘制操作
    @Override
    protected void onDraw(Canvas canvas) {super.onDraw(canvas);
        if (drawInfoList != null && drawInfoList.size() > 0) {for (int i = 0; i < drawInfoList.size(); i++) {DrawHelper.drawFaceRect(canvas, drawInfoList.get(i), 4, paint);
            }
        }
    }
    // 清空画面中的人脸
    public void clearFaceInfo() {drawInfoList.clear();
        postInvalidate();}

    public void addFaceInfo(DrawInfo faceInfo) {drawInfoList.add(faceInfo);
        postInvalidate();}

    public void addFaceInfo(List<DrawInfo> faceInfoList) {drawInfoList.addAll(faceInfoList);
        postInvalidate();}
}
  • 绘制的具体操作,画人脸框
/**
     * 绘制数据信息到 view 上,若 {@link DrawInfo#getName()} 不为 null 则绘制 {@link DrawInfo#getName()}
     *
     * @param canvas            需要被绘制的 view 的 canvas
     * @param drawInfo          绘制信息
     * @param faceRectThickness 人脸框厚度
     * @param paint             画笔
     */
    public static void drawFaceRect(Canvas canvas, DrawInfo drawInfo, int faceRectThickness, Paint paint) {if (canvas == null || drawInfo == null) {return;}
        paint.setStyle(Paint.Style.STROKE);
        paint.setStrokeWidth(faceRectThickness);
        paint.setColor(drawInfo.getColor());
        paint.setAntiAlias(true);

        Path mPath = new Path();
        // 左上
        Rect rect = drawInfo.getRect();
        mPath.moveTo(rect.left, rect.top + rect.height() / 4);
        mPath.lineTo(rect.left, rect.top);
        mPath.lineTo(rect.left + rect.width() / 4, rect.top);
        // 右上
        mPath.moveTo(rect.right - rect.width() / 4, rect.top);
        mPath.lineTo(rect.right, rect.top);
        mPath.lineTo(rect.right, rect.top + rect.height() / 4);
        // 右下
        mPath.moveTo(rect.right, rect.bottom - rect.height() / 4);
        mPath.lineTo(rect.right, rect.bottom);
        mPath.lineTo(rect.right - rect.width() / 4, rect.bottom);
        // 左下
        mPath.moveTo(rect.left + rect.width() / 4, rect.bottom);
        mPath.lineTo(rect.left, rect.bottom);
        mPath.lineTo(rect.left, rect.bottom - rect.height() / 4);
        canvas.drawPath(mPath, paint);

        // 其中需要注意的是,canvas.drawText 函数传入的位置,x 是水平方向的起点,// 而 y 是 BaseLine,文字会在 BaseLine 的上方绘制
        if (drawInfo.getName() == null) {paint.setStyle(Paint.Style.FILL_AND_STROKE);
            paint.setTextSize(rect.width() / 8);

            String str = (drawInfo.getSex() == GenderInfo.MALE ? "MALE" : (drawInfo.getSex() == GenderInfo.FEMALE ? "FEMALE" : "UNKNOWN"))
                    + ","
                    + (drawInfo.getAge() == AgeInfo.UNKNOWN_AGE ? "UNKNWON" : drawInfo.getAge())
                    + ","
                    + (drawInfo.getLiveness() == LivenessInfo.ALIVE ? "ALIVE" : (drawInfo.getLiveness() == LivenessInfo.NOT_ALIVE ? "NOT_ALIVE" : "UNKNOWN"));
            canvas.drawText(str, rect.left, rect.top - 10, paint);
        } else {paint.setStyle(Paint.Style.FILL_AND_STROKE);
            paint.setTextSize(rect.width() / 8);
            canvas.drawText(drawInfo.getName(), rect.left, rect.top - 10, paint);
        }
    }

温馨提示:
本来自己研究了较长时间,后来发现虹软人脸识别 Android Demo 中早已给出该适配方案,上述代码也源于官方 Demo,通过研读 Demo,发现其中还提供了很多其他在接入虹软人脸识别 SDK 时可能用到的优化策略,如:
1. 通过异步人脸特征提取实现多人脸识别
2. 使用 faceId 优化识别逻辑
3. 识别时的画框适配方案
4. 打开双摄进行红外活体检测

Android Demo 可在 [虹软人脸识别开放平台] 下载

退出移动版