Android Camera开发实践(1)预览

4,154 阅读9分钟

欢迎关注公众号:sumsmile /专注图像处理的移动开发老兵

移动端图形、音视频的处理,离不开图像采集,需要对Camera有一定的了解。计划用两三篇文章,整理camera拍摄、openglES渲染相关的基础知识。

内容目录:

一、相机预览基本操作

    1.1获取相机信息:cameraId、orientation(相机硬件方向)

    1.2打开Camera硬件

    1.3设置预览属性,尺寸、编码格式

    1.4设置预览surface,即接收并显示图像的容器

    1.5启动预览

二、相机预览方向校正

    2.1手机自然方向和局部坐标系

    2.2显示方向

    2.3摄像头传感器方向

    2.4后置摄像头画面校正

    2.5前置摄像头画面校正

三、切换摄像头

四、拍照

实现效果

一、相机预览基本操作

1.1获取相机信息:cameraId、orientation(相机硬件方向)

int numberOfCameras = Camera.getNumberOfCameras();// 获取摄像头个数
for (int cameraId = 0; cameraId < numberOfCameras; cameraId++) {
    Camera.CameraInfo cameraInfo = new Camera.CameraInfo();
    Camera.getCameraInfo(cameraId, cameraInfo);
    if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_BACK) {
        // 后置摄像头信息
        mBackCameraId = cameraId;
        mBackCameraInfo = cameraInfo;
    } else if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT){
        // 前置摄像头信息
        mFrontCameraId = cameraId;
        mFrontCameraInfo = cameraInfo;
    }
}

1.2打开Camera硬件

配置相机、存储权限

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

设置SurfaceCallback,回调中,启动预览。注意,surfaceChanged至少回调一次

SurfaceView cameraPreview = findViewById(R.id.camera_preview);
cameraPreview.getHolder().addCallback(new PreviewSurfaceCallback());

private class PreviewSurfaceCallback implements SurfaceHolder.Callback{

    @Override
    public void surfaceCreated(SurfaceHolder holder) {

    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        mPreviewSurface = holder;
        mPreviewSurfaceWidth = width;
        mPreviewSurfaceHeight = height;
        if(mCameraHandler != null){
            mCameraHandler.obtainMessage(MSG_SET_PREVIEW_SIZE, width, height).sendToTarget();
            mCameraHandler.obtainMessage(MSG_SET_PICTURE_SIZE).sendToTarget();
            mCameraHandler.obtainMessage(MSG_SET_PREVIEW_SURFACE, holder).sendToTarget();
            mCameraHandler.sendEmptyMessage(MSG_START_PREVIEW);
        }
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        mPreviewSurface = null;
        mPreviewSurfaceWidth = 0;
        mPreviewSurfaceHeight = 0;
    }
}

打开相机,参数为cameraid

private void openCamera(int cameraId) {
    Camera camera = mCamera;

    if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED){
        //打开相机
        mCamera = Camera.open(cameraId);
        mCameraId = cameraId;
        mCameraInfo = cameraId == mFrontCameraId ? mFrontCameraInfo : mBackCameraInfo;
        // 设置相机方向,后面2.1处详细讲述
        mCamera.setDisplayOrientation(getCameraDisplayOrientation(mCameraInfo));
    }
}

1.3设置预览属性,尺寸、编码格式

查询支持的预览尺寸、编码格式,根据需要设置。

private void setPreviewSize(int shortSide, int longSide) {
    if (mCamera != null && shortSide != 0 && longSide != 0){
        float aspectRatio = (float)longSide / shortSide;
        Camera.Parameters parameters = mCamera.getParameters();
        List<Camera.Size> supportedPreviewSizes = parameters.getSupportedPreviewSizes();
        for (Camera.Size previewSize : supportedPreviewSizes) {
            //1.设置预览尺寸
            if((float)previewSize.width / previewSize.height == aspectRatio  && previewSize.height <= shortSide && previewSize.width <= longSide) {
                parameters.setPreviewSize(previewSize.width, previewSize.height);
                
                //2.设置预览的编码格式,此处PREVIEW_FORMAT = ImageFormat.NV21
                // NV21 即 YUV
                if(isPreviewFormatSupported(parameters, PREVIEW_FORMAT)){
                    parameters.setPreviewFormat(PREVIEW_FORMAT);
                    int frameWidth = previewSize.width;
                    int frameHeight = previewSize.height;
                    int previewFormat = parameters.getPreviewFormat();
                    PixelFormat pixelFormat = new PixelFormat();
                    PixelFormat.getPixelFormatInfo(previewFormat, pixelFormat);
                    int bufferSize = (frameWidth * frameHeight * pixelFormat.bitsPerPixel) / 8;  
                    //3.设置预览的缓冲数组
                    mCamera.addCallbackBuffer(new byte[bufferSize]);
                    mCamera.addCallbackBuffer(new byte[bufferSize]);
                    mCamera.addCallbackBuffer(new byte[bufferSize]);
                }

                mCamera.setParameters(parameters);
            }
        }
    }
}

1.4设置预览surface,即接收并显示图像的容器

实际设置的是surfaceHolder

private void setPreviewSurface(SurfaceHolder previewSurface) {
    if (mCamera != null && previewSurface != null) {
        try {
            mCamera.setPreviewDisplay(previewSurface);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

1.5启动预览

private void startPreview() {
    if (mCamera != null && mPreviewSurface != null) {
        // 增加callback,便于buffer复用
        mCamera.setPreviewCallbackWithBuffer(new Camera.PreviewCallback() {
            @Override
            public void onPreviewFrame(byte[] data, Camera camera) {
                // 使用完buffer之后回收复用
                camera.addCallbackBuffer(data);
            }
        });
        mCamera.startPreview();
    }
}

二、相机预览方向校正

因为手机摄像头硬件的设计,不做额外的处理,相机预览的图像角度是错误的,准确的说竖屏状态下,逆时针偏了90°。

上文有一句代码,设置相机预览的旋转方向,此处补充说明.

mCamera.setDisplayOrientation(getCameraDisplayOrientation(mCameraInfo));

完整代码出自于google/Android官方文档

private int getCameraDisplayOrientation(Camera.CameraInfo cameraInfo) {
    int roration = getWindowManager().getDefaultDisplay().getRotation();
    // 屏幕显示方向角度(相对局部坐标Y轴正方向夹角)
    int degrees = 0;
    switch (roration) {
        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 (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT){
        result = (cameraInfo.orientation + degrees) % 360;
        result = (360 - result) % 360;
    } else {
        result = (cameraInfo.orientation - degrees + 360) %360;
    }
    // 相机需要校正的角度
    return result;
}

相机预览方向矫正相对复杂些,查阅了许多资料,大多照搬google代码,讲的模棱两可。上面这段代码相信许多朋友都见过,但是对最后result的计算不一定了解。

要讲清楚相机方向矫正,先介绍几个重要的概念

  • 手机自然方向
  • 局部坐标系
  • 显示方向
  • 摄像头传感器方向

2.1手机自然方向和局部坐标系

手机默认是竖屏,短边朝上为自然方向,平板默认是横屏,宽边朝上为自然方向。

局部坐标系与手机的自然状态相关,Y轴与手机自然状态时朝上的方向对齐,下图中手机的局部坐标系y轴朝上:

局部坐标系

为方便说明,后面讲各个方向,均以局部坐标Y轴正方向为基准

2.2显示方向

显示方向与横竖屏状态有关。竖屏时,显示方向朝上,显示方向与局部坐标Y轴一致,横屏时显示方向朝上与局部坐标x轴对齐。

注意,向左旋转横屏时,显示方向朝上,相对局部坐标Y轴的夹角为90°,即Y轴顺时针旋转90°才能对齐显示方向,向右旋转横屏时,该夹角为270°。

务必理解这个概念,后面计算相机角度校正要用到。

向右横屏状态

2.3摄像头传感器方向

以后置摄像头为例

开发中,竖屏状态下, window view的坐标系是短边为y轴,长边为x轴

image.png

相对手机自然方向,摄像头硬件安装时顺时针旋转了90°,短边为X轴,长边为Y轴。看起来像是专门为pad横屏设计的。(why?我也母鸡。。)

image.png

将手机朝左横屏时,两个坐标系刚好对齐,开发中不用适配显示也是对的。

向左横屏不用适配

上面代码中 cameraInfo.orientation 获取的就是相机摄像头的方向(相对局部坐标系Y轴)

2.4后置摄像头画面校正

如上所述,因为摄像头安装角度、手机横竖屏状态切换导致的显示方向变化,摄像头采集的图像显示到屏幕上就可能会产生偏角。实际开发中,我们需要计算出这个偏角,以做校正。

如果不做任何处理,degree(显示方向)为0,orientation(摄像头方向)为90°,预览是歪着的。

再次说明,角度均以局部坐标y轴正方向为参考基准

image.png

怎么理解呢?你可以想象自己的头是摄像头,你的头向右倒90°看到的图像可不就是歪的么。然后你把看到的图像传给显示屏,显示屏可不知道你是歪着脑袋采集数据的。

校正需要调用 mCamera.setDisplayOrientation(int arg),设置一个角度,将采集的图像顺时针旋转arg角度,以补偿摄像头的偏角。

以向左横屏为例说明:

arg = orientation - degree //所以如果是朝左横屏时
arg = 90 - 90 = 0 //碰巧显示对了,不用校正

可以理解为:摄像头采集的数据超前了90度(相对局部坐标系),而向左横屏造成显示方向超前了90度,如此摄像头方向和显示的方向刚好扯平对齐了。

image.png

朝右横屏呢:

// 摄像头相对局部坐标Y轴不变: orientation = 90
// 显示方向朝上,相对局部坐标Y轴顺时针旋转 degree = 270
arg = orientation - degree + 360 = 90 - 270 + 360 = 180

则需要补偿180度,其中+360是为了使旋转方向始终朝顺时钟方向,使arg不为负数,其实-180和+180是一样的。demo里也确实上下颠倒,需要补偿180°。

2.5前置摄像头画面校正

前置稍微麻烦点,区别在于
1. 自拍时自己看到的旋转角度和摄像头得到的真实角度是相反的,即你看到逆时针,真实的是顺时针
2. 相机系统在处理前置拍摄时,会左右镜像,以模拟人照镜子时的效果,所以显示屏上得到的像素是已经左右对调处理了。
这两点造成前置摄像头的校正理解起来稍微费解点,但是代码看起来差不多。
此处有点绕,笔者晚上洗澡时想这个问题走神了,在卫生间发呆了一个多小时,老婆还以为我洗澡出事了。

所以,仅考虑自拍角度相反的因素,

arg = orientation - (-degree) = orientation + degree

再考虑镜像,最终的补偿角度为:

result = 360 - arg,

这就和google官方文档提供的模板代码一致了。朋友,能看到这都理解了,为自己的好学点个赞吧。

看下图,左右镜像后,A镜像为B,A点转到Y轴正方向角度为a,B点转到Y轴正方向为b,a + b = 360,所以镜像后,真正需要补偿的角度为360 - arg

补充:最后取模360,也很好理解,保证角度在一个周期内.

result = (360 - result) % 360;

代码参考 camera demo

三、切换摄像头

主要逻辑和第一次打开预览一样,区别是先关掉之前的预览,流程如下:

  • 停止预览(实际操作中,没有停止预览也没有报错)
  • 关闭当前摄像头
  • 重新打开摄像头,使用另一个cameraId
  • 设置预览尺寸等属性
  • 设置预览surfaceHolder
// demo里用一个button点击来切换camera
Button switchCameraButton = findViewById(R.id.switch_camera);
switchCameraButton.setOnClickListener(new OnSwitchCameraButtonClickListener());

private class OnSwitchCameraButtonClickListener implements View.OnClickListener {
    @Override
    public void onClick(View v) {

        if (mCameraHandler != null && mPreviewSurface != null) {
            int cameraId = switchCameraId();// 切换摄像头 ID
            mCameraHandler.sendEmptyMessage(MSG_STOP_PREVIEW);// 停止预览
            mCameraHandler.sendEmptyMessage(MSG_CLOSE_CAMERA);// 关闭当前的摄像头
            mCameraHandler.obtainMessage(MSG_OPEN_CAMERA, cameraId, 0).sendToTarget();// 开启新的摄像头
            mCameraHandler.obtainMessage(MSG_SET_PREVIEW_SIZE, mPreviewSurfaceWidth, mPreviewSurfaceHeight).sendToTarget();// 配置预览尺寸
            mCameraHandler.obtainMessage(MSG_SET_PICTURE_SIZE, mPreviewSurfaceWidth, mPreviewSurfaceHeight).sendToTarget();// 配置照片尺寸
            mCameraHandler.obtainMessage(MSG_SET_PREVIEW_SURFACE, mPreviewSurface).sendToTarget();// 配置预览 Surface
            mCameraHandler.sendEmptyMessage(MSG_START_PREVIEW);// 开启预览
        }
    }
}

//停止预览
private void stopPreview() {
    Camera camera = mCamera;
    if (camera != null) {
        camera.stopPreview();
        Log.d(TAG, "stopPreview() called");
    }
}

//关闭相机
private void closeCamera() {
    if (mCamera != null) {
        mCamera.release();
        mCamera = null;
    }
}

四、拍照

基于预览的逻辑实现拍照就比较容易了。Camera提供了拍照的API。

  • 设置takePicture尺寸等属性,(如果未设置,可能有默认的尺寸,此处笔者未验证)
  • Camera.takePicture

设置takePicture尺寸,和预览的设置逻辑类似

/**
 * 根据指定的尺寸要求设置照片尺寸,考虑指定尺寸的比例,并且去符合比例的最大尺寸作为照片尺寸。
 *
 * @param shortSide 短边长度
 * @param longSide  长边长度
 */
private void setPictureSize(int shortSide, int longSide) {
    Camera camera = mCamera;
    if (camera != null && shortSide != 0 && longSide != 0) {
        float aspectRatio = (float) longSide / shortSide;
        Camera.Parameters parameters = camera.getParameters();
        List<Camera.Size> supportedPictureSizes = parameters.getSupportedPictureSizes();
        for (Camera.Size pictureSize : supportedPictureSizes) {
            if ((float) pictureSize.width / pictureSize.height == aspectRatio) {
                parameters.setPictureSize(pictureSize.width, pictureSize.height);
                camera.setParameters(parameters);
                break;
            }
        }
    }
}

button触发takePicture

Button takePictureButton = findViewById(R.id.take_picture);
takePictureButton.setOnClickListener(new OnTakePictureButtonClickListener());

拍照

private class OnTakePictureButtonClickListener implements View.OnClickListener {
    @Override
    public void onClick(View v) {
        takePicture();
        // 每次拍照完,preview会被stop,需要重新restartPreview
        restartPreview();
    }
}

// 拍照
private void takePicture() {
    if (mCamera != null) {
        Camera.Parameters parameters = mCamera.getParameters();
        mCamera.setParameters(parameters);
        // takePicture可以设置多个回调,可以查看源码说明,此处不赘述
        mCamera.takePicture(new ShutterCallback(), new RawCallback(), new PostviewCallback(), new JpegCallback());
    }
}

注意事项

This method is only valid when preview is active (after{@link #startPreview()}). Preview will be stopped after the image is taken; callers must call {@link #startPreview()} again if they want to re-start preview or take more pictures. This should not be called between

每次拍照完,preview会被stop,所以要继续拍照,需重新startPreview(),否则再次调用takePicture会crash

下一篇预告:

Android Camera开实践(2)OpenGL ES使用

欢迎关注公众号:sumsmile /专注图像处理的移动开发老兵

参考资料

[1] Android developer: developer.android.com/reference/a…

[2] camera预览demo: github.com/darylgo/Cam…

[3] Android平台Camera开发实践指南: juejin.cn/post/684490…

[4] 理解 Android 相机预览方向和拍照方向: www.jianshu.com/p/7d88ec134…

[5] Android Camera1 教程 ·预览: www.jianshu.com/p/705d4792e…

[6] camera getRotation: developer.android.com/reference/a…

[7] android camera API: developer.android.com/guide/topic…