Camera 开发实战(一)——预览尺寸的选取与旋转角度的设定

 

前言

经过 OpenGL ES 的系统学习, 这里通过 Camera 开发, 来对学习的成果进行一次验收

Camera 在音视频的世界中, 就像我们的眼睛一样, 是负责图像采集的入口, 要想实现一款高可用的 Camera, 我认为需要关注以下几个方面

  • 相机的预览
  • 相机的拍摄
  • 相机的录制

OpenGL ES 主要用于图像的渲染, 因此这里主要聚焦于相机的预览, 想要让相机的数据呈现到我们屏幕上, 主要有如下几个步骤

  • 相机预览帧尺寸的选取
  • 相机预览帧旋转角度的设定
  • 使用 OpenGL ES 渲染数据

可以看到每一步想要做好都是挺有挑战性的, 本篇主要分析 相机预览帧尺寸的选取相机预览帧旋转角度的设定 这两个问题

若是使用 JetPack 组件 CameraX 的话, 基本上无需考虑这两个问题, 不过为了解析其实现思路, 这里先以 Camera1 为例, 后续会给出 CameraX 的实现代码

一. 相机预览帧尺寸的选取

如何选取预览尺寸?

在刚开始接触自定义相机的时候, 这个问题一度困扰着我, 是因为面对这个问题我一直无法给予一个很好的解决方案, 之前我的方案是, 交由用户传入一个预览的尺寸, 然后设计一个算法从相机中找寻与这个尺寸最为接近的, 这个方案看似挺合理的, 写 demo 时可以使用, 但却存在着如下的隐患

  • 不同的手机支持的预览尺寸是不一样的, 尤其是在当下 3 摄 4 摄横行的手机市场上, 每个摄像头都有自己的特性, 如广角镜头、景深信息等, 指定一个合适的尺寸成本是非常高的
  • 若我们在竖直情况下指定了 1080*1440 的尺寸, 当我们手机横置时, 要想保证合理性, 则需要让 API 使用者自己监听屏幕的旋转, 然后再反转尺寸, 这种 API 使用成本过于高昂

因此让外界指定预览尺寸的方式有些不太合理, 于是我们把目光转移到大厂的系统相机上, One UI 的系统相机如下

One UI 的系统相机

可以发现, 它们仅提供的是尺寸的比例, 它的优势如下

  • 使用简单, 仅需要指定比例即可, 使用者无需考虑旋转的问题
    • 相机的尺寸比例一般为 4:3, 16:9, 其他比例是在此基础上裁剪出来的
  • 能够获取最佳预览效果, 同比例中选择最接近 View 宽高的, 可以避免相机输出的预览帧尺寸过高, 造成性能损耗, 引起预览卡顿

因此我们可以尝试采用外界指定比例, 在同比例中选取最接近预览控件尺寸的方式作为预览帧的采集的尺寸

若想实现上述的方式, 我们第一步要做的是对相机的预览尺寸按照比例进行归类, 方便从后续从中筛选出最合适的尺寸, 接下来我们便看看第一步的操作

一) 尺寸的归类

以下代码改自 google 开源 sample

private final SizeMap mPreviewSizes = new SizeMap();

// 变量所有的预览尺寸, 按照比例分类
for (Camera.Size size : mCameraParams.getSupportedPreviewSizes()) {
    mPreviewSizes.add(new Size(size.width, size.height));
}

这里遍历了该 Camera 支持的所有预览尺寸, SizeMap.add 方法如下

class SizeMap {

    private final ArrayMap<AspectRatio, SortedSet<Size>> mRatios = new ArrayMap<>();

    /**
     * Add a new {@link Size} to this collection.
     *
     * @param size The size to add.
     * @return {@code true} if it is added, {@code false} if it already exists and is not added.
     */
    public boolean add(Size size) {
        for (AspectRatio ratio : mRatios.keySet()) {
            if (ratio.matches(size)) {
                final SortedSet<Size> sizes = mRatios.get(ratio);
                if (sizes.contains(size)) {
                    return false;
                } else {
                    sizes.add(size);
                    return true;
                }
            }
        }
        // None of the existing ratio matches the provided size; add a new key
        SortedSet<Size> sizes = new TreeSet<>();
        sizes.add(size);
        mRatios.put(AspectRatio.of(size.getWidth(), size.getHeight()), sizes);
        return true;
    }

}

通过这种方式, 我们的 mPreviewSizes 便按照比例完成分类了, 接下来看看尺寸的选取

二) 尺寸的选取

// 1. 获取用户期望的比例的集合
SortedSet<Size> previewSizes = mPreviewSizes.sizes(aspectRatio);
if (previewSizes == null) {
    // 1.1 用户期望的比例不存在, 选取获取默认比例
    previewSizes = mPreviewSizes.sizes(chooseDefaultAspectRatio());
}
// 2. 选择最合适的预览帧尺寸
final Size previewSize = chooseOptimalPreviewSize(previewSizes);
// 参数注入
mCameraParams.setPreviewSize(previewSize.getWidth(), previewSize.getHeight());

这里有两个需要注意的点

  • 当用户指定的比例不支持, 选取默认比例
  • 从比例对应的尺寸集合中, 选择最合适的预览帧尺寸

接下来逐一解析这两个算法

1. 选取默认比例

private AspectRatio chooseDefaultAspectRatio() {
    AspectRatio result = null;
    for (AspectRatio ratio : mPreviewSizes.ratios()) {
        result = ratio;
        if (AspectRatio.DEFAULT.equals(ratio)) {
            break;
        }
    }
    return result;
}

关于选取默认的比例代码比较简单, 即遍历预览比例集合, 判断是否存在默认比例, 若存在则返回默认比例, 反之则选取比例集合中的最后一个

  • 默认比例一般设置为 4:3 , 一般市面上所有相机都支持 4:3 的预览帧, 其支持的预览帧尺寸也较高
思考

当用户指定的比例不支持, 为什么不选取与用户设置最接近的比例呢?

为了回答这个问题, 先看看使用最接近比例算法最后选取的预览结果

 E/TAG: desire aspect is 1:1, closely is 11:9
 E/TAG: desired size is: [1080, 1080]; choose preview size is: [352x288]

可以看到从这个比例中选取的预览尺寸远低于 View 的尺寸, 从数据上看几乎会进行 3 倍的上采样, 即使上采样算法再优, 也难以保证预览的清晰度

我们无法控制用户心仪的比例, 因此当用户指定的比例不存在时, 采用指定的默认比例是能够保证预览清晰度的比较好的方式, 接下来看看如何从比例中选择最合适的预览尺寸

2. 选择最合适的预览尺寸

private Size chooseOptimalPreviewSize(SortedSet<Size> sizes) {
    // 1. 确定期望的宽高
    int desiredWidth;
    int desiredHeight;
    // 横屏状态, 直接使用 view 的宽高
    if (isLandscape(screenOrientationDegrees)) {
        desiredWidth = viewWidth;
        desiredHeight = viewHeight;
    }
    // 竖屏状态, 反转宽高
    else {
        desiredWidth = viewHeight;
        desiredHeight = viewWidth;
    }
    // 2. 查找最合适的预览尺寸
    Size result = null;
    for (Size size : sizes) {// sizes 已按照从小到大排序
        result = size;
        // 选取宽高都比期望稍大的
        if (desiredWidth <= size.getWidth() && desiredHeight <= size.getHeight()) {
            return size;
        }
    }
    return result;
}

在确定期望的宽高的过程中, 竖屏状态下会有一个宽高翻转的操作

  • 这是因为相机的尺寸总是 1920:1080, 1280:720 这种样式的, 这一点从数码相机上可以体现, 他们的取景器一般都是宽大于高的, 这已经成为了行业的标准, 我们手机上的相机是从数码相机上演变过来的, 因此也继承了这个标准

尺寸查找的主要的思路是选取比期望宽高稍大的预览尺寸

  • 控件能够展示的像素个数是固定的, 即使选取的采集尺寸高于控件的尺寸, 但是还是会降采样为控件的像素数, 所以预览时选取与展示 View 宽高相似的尺寸即可

三) 验证选取结果

通过上面的选取方式, 验证的结果如下

// 期望比例 4:3
E/TAG: desired size is: [1440, 1080]; choose preview size is: [1600x1200]
 
// 期望比例 16:19
E/TAG: desired size is: [1920, 1080]; choose preview size is: [1920x1080]

// 期望比例 1:1
E/TAG: desired size is: [1440, 1080]; choose preview size is: [1600x1200]

可以看到最终选择的结果, 是符合我们上面算法预期的

  • 当然要想实现 P30 拍月亮的效果, 这种方式就不行了, 是需要多个镜头配合工作的, 笔者一时间觉得有些茫然

好的, 预览帧采集的尺寸已经确定好了, 接下来看看预览帧旋转角度的设定

二. 旋转角度的设定

想要正确的实现预览效果, 设定相机预览帧的旋转角度是必须要面对的问题, 想要解决这个问题, 我们需要先了解一下相机 sensor 的坐标系

一) Sensor 取景坐标系

以手机屏幕为参考系, 相机 Sensor 取景坐标系的指向与其安装的方位有关, 以后置相机为例, 其相机传感器的坐标系如下所示

相机 sensor 坐标系

可以看到, 一般相机的 sensor 坐标系如上图所示, 与手机逆时针旋转 90 度后的屏幕坐标系重合, 接下来根据这个坐标看看在手机横竖屏时, 图像的呈现效果

1. 横屏

横屏状态下的映射

可以看到横屏(逆时针 90°)状态下, Sensor 和屏幕的坐标系重合, 图像可以直接映射

2. 竖屏

竖屏状态下的映射

可以看到竖屏状态下的映射就出现问题了, 我们可以很自然的想到顺时针旋转 90° 来解决

了解了 sensor 坐标系和屏幕坐标系之间的映射, 接下来看看如何撰写相关代码

二) 旋转角度的确定

关于修改相机旋转角度的方法 Camera.setDisplayOrientation 定义如下

public class Camera {
    
    /**
     * ......
     * <pre>
     * public static void setCameraDisplayOrientation(Activity activity,
     *         int cameraId, android.hardware.Camera camera) {
     *     android.hardware.Camera.CameraInfo info =
     *             new android.hardware.Camera.CameraInfo();
     *     android.hardware.Camera.getCameraInfo(cameraId, info);
     *     int rotation = activity.getWindowManager().getDefaultDisplay()
     *             .getRotation();
     *     int degrees = 0;
     *     switch (rotation) {
     *         case Surface.ROTATION_0: degrees = 0; break;
     *         case Surface.ROTATION_90: degrees = 90; break;
     *         case Surface.ROTATION_180: degrees = 180; break;
     *         case Surface.ROTATION_270: degrees = 270; break;
     *     }
     *
     *     int result;
     *     if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
     *         result = (info.orientation + degrees) % 360;
     *         result = (360 - result) % 360;  // compensate the mirror
     *     } else {  // back-facing
     *         result = (info.orientation - degrees + 360) % 360;
     *     }
     *     camera.setDisplayOrientation(result);
     * }
     * </pre>
     * ......
     */
    public native final void setDisplayOrientation(int degrees);
    
}

我们只需要将旋转角度计算好, 传入这个方法便可以让 Camera 输出符合屏幕预览的图像了, 关于这个旋转角度的计算, 其注释上有明确的代码示例, 其主要思想如下

  • 获取当前手机逆时针旋转的角度
  • 对前后摄像头分开处理

可以看到在计算前后摄像头旋转角度时除了用到屏幕的旋转角度, 还用到了 CameraInfo.orientation 这个变量, 接下来我们逐个分析

1. CameraInfo.orientation

关于 CameraInfo.orientation 其定义如下

public class Camera {
    
     public static class CameraInfo {
        /**
         * <p>The orientation of the camera image. The value is the angle that the
         * camera image needs to be rotated clockwise so it shows correctly on
         * the display in its natural orientation. It should be 0, 90, 180, or 270.</p>
         *
         * <p>For example, suppose a device has a naturally tall screen. The
         * back-facing camera sensor is mounted in landscape. You are looking at
         * the screen. If the top side of the camera sensor is aligned with the
         * right edge of the screen in natural orientation, the value should be
         * 90. If the top side of a front-facing camera sensor is aligned with
         * the right of the screen, the value should be 270.</p>
         *
         * .......
         */
        public int orientation;
    }
}

注释的译文为: orientation 表示相机图像的方向。它的值是相机图像顺时针旋转到设备自然方向一致时的图像,它可能是 0、90、180、270 四种。对于竖屏应用来说,后置相机传感器是横屏安装的,当你面向屏幕时,如果后置相机传感器顶边和设备自然方向的右边是平行的,那么后置相机的 orientation 是 90。如果是前置相机传感器顶边和设备自然方向的右边是平行的,则前置相机的 orientation 是 270

注释看起来可能比较抽象, 它的意思是指屏幕竖置时, 传感器采集的图像想要以符合人眼观感的样式显示到屏幕上, 所需要顺时针旋转的角度, 我们通过流程图来加深一下对其的理解

CameraInfo.orientation 的作用

可以看到将 sensor 坐标系映射到屏幕坐标系之后, 还需要顺时针旋转 90° 才能正常显示, 那么其 CameraInfo.orientation 即为 90

好的, 理解了 CameraInfo.orientation 这个参数我们再回过头来看看前后摄像头的旋转算法

2. 后置镜头

public static void setCameraDisplayOrientation(Activity activity,
        int cameraId, android.hardware.Camera camera) {
    ......
    int degrees = 0;
    ......
    int result;
    if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
        ......
    } 
    // back-facing
    else {
        result = (info.orientation - degrees + 360) % 360;
    }
    camera.setDisplayOrientation(result);
}

可以看到后置镜头的计算过程是非常简单的, 摄像头图像方向要恢复到自然方向需要顺时针旋转, 而屏幕逆时针旋转正好抵掉了摄像头的旋转, 所以两者相减, 然后再加上 360 取模运算, 便可以获得最终结果

3. 前置镜头

public static void setCameraDisplayOrientation(Activity activity,
        int cameraId, android.hardware.Camera camera) {
    ......
    int degrees = 0;
    ......
    int result;
    if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
        result = (info.orientation + degrees) % 360;
        result = (360 - result) % 360;  // 抵消镜像
    } 
    // back-facing
    else {
        ......
    }
    camera.setDisplayOrientation(result);
}

前置镜头的代码比后置镜头复杂一些, 我们图解分析一下

前置镜头旋转流程

前置的采集流程如上图所示(前置镜头的 Sensor 坐标系与后置方向不同, 与安装方式有关)

可以看到若是直接按照 CameraInfo.orientation 进行旋转 270°, 那么得到的图像是非镜像的, 显然这是不符合自拍预览效果的

因此系统对其进行了镜像操作(图中的文字没有完全镜像, 请见谅), 那么只需要旋转 90°, 便可以得到想要的预览效果了, 现在再回看上面的代码, 就比较清晰了

三. CameraX 关于尺寸和旋转的处理

CameraX 是 Jetpack 基于 Camera2.0 封装的相机组件, 向 5.0 以上的版本提供服务

关于预览相机相关的配置如下

PreviewConfig config = new PreviewConfig.Builder()
         // CameraX 的宽高比和 Camera1 相反, 为 3:4 9:16......
         .setTargetAspectRatio(new Rational(aspectRatio.getY(), aspectRatio.getX()))
         // 分辨率
         .setTargetResolution(new android.util.Size(previewWidth, previewHeight))
         // 前置与否
         .setLensFacing(facing == Constants.FACING_FRONT ? CameraX.LensFacing.FRONT
                 : CameraX.LensFacing.BACK)
         .build();
Preview preview = new Preview(config);
CameraX.bindToLifecycle(mLifecycleOwner, preview);

可以看到短短几行代码便可以实现最佳预览尺寸的选择, 其旋转的角度在图像通过 SurfaceTexture 输出时, 记录在其 matrix 中, 我们可以很方便进行旋转操作(没有考虑屏幕的旋转, 需要自己矫正)

关于 CameraX 的用法, 想了解更多用法请查看官方文档, 这里就不进行赘述了

总结

关于相机预览尺寸的选取和旋转角度的设定到这里便分析结束了, 这里再简单的回顾一下

  • 相机预览尺寸的选取
    • 统计相机支持的预览尺寸, 按照比例归类
    • 从用户传入的比例中找寻与 View 尺寸相当的作为预览尺寸
  • 相机旋转角度的设定
    • sensor 坐标系
    • sensor 坐标系图像映射到屏幕坐标系
    • 对图像进行校正以确认最终的旋转角度

其中 旋转角度的设定 较之 尺寸的选取 要更为困难, 其中牵扯到坐标系的映射和映射后的旋转, 前置相机还需要考虑镜像的抵消, 不过所幸这里将其梳理清楚了

虽然有 CameraX 这种简单的 API 供我们使用, 但对于 5.0 以下的机型, 仍然需要我们自定义, 因此我们了解其原理是非常重要的, 而且 探索思考的过程比结果更重要 不是?

文中所有的代码均在 CameraSample 中, 如有不解, 请点击查看

参考文献

  • https://glumes.com/post/android/android-camera-aspect-ratio-and-orientation/
  • https://www.jianshu.com/p/9c1d26d658ea
  • https://github.com/google/cameraview
  • https://codelabs.developers.google.com/codelabs/camerax-getting-started/#0