Android Camera2 全屏预览+实时获取预览帧进行图像处理

前言

之前公司有一个需求:全屏预览的同时将预览图像传到物体检测算法中,得到检测结果(含有识别出的物体的矩阵信息,可以在传入的图像上将其框出来)并实时在预览图像上画出检测框。这里使用的是Camera2 API,而不是Camera。

需要解决的问题有:

  1. Android调用相机在自己的应用内实现视频预览:google自己的Camear2Basic例子中就有这部分内容,需要修改(Google/Camera Samples)。
  2. 实时获取预览的图像资源:这一部分花了我最长时间,网上比较少有详细的说明。
  3. 手机屏幕适配、预览拉伸问题:由于相机输出的分辨率和屏幕上图层的分辨率不一定能百分百吻合,所以得做一些判断和选择。
  4. 物体识别算法会返回根据图片识别出的多个物体的坐标位置和结果,需要实时将其“画”在屏幕上:用一个透明的SurfaceView放置在预览图层之上就可以画框了。
  5. 手机屏幕旋转时的画框问题

开启正文之前先上效果图(后面有源码)

说是全屏,但由于相机输出尺寸和屏幕尺寸并不能完全吻合,而缩放会导致预览拉伸。所以在全面屏上竖直方向上会有留白,一些手机上能达到全屏预览效果(亲测)。
效果图

正文

问题1:Google-Camera2Basic

Google的这个例子很有代表性,虽然里面实现的是在程序内调用相机拍照并保存JPEG图片,但其中调用相机预览的逻辑是可以直接用的,网上也有很多博客对其进行了详细的介绍,这里不是本篇文章的重点,就不再累述了。以下内容也是认为读者已经对该例子基本熟悉,里面的几个函数名和一些变量名我沿用了下来。

ps:建议在官方文档上查找google例子中出现的各种API,文档上很全。至于博客里的,重在看逻辑。

问题2:实时获取预览的图像

2.1 ImageReader设置

这里只讲结论,不讲分析(因为我也不懂图像编码的知识)。
直接上代码:创建ImageReader对象,参数是输入尺寸、格式(ImageFormat)和maxImages,尺寸之后再说,格式选择YUV_420_888(众所周知,JPEG太大,传来传去会很卡),maxImages的大小没什么区别,选5即可。

// 输入相机的尺寸必须是相机支持的尺寸,这样画面才能不失真,TextureView输入相机的尺寸也是这个
mImageReader = ImageReader.newInstance(selectPreviewSize.getWidth(), selectPreviewSize.getHeight(),
        ImageFormat.YUV_420_888, /*maxImages*/5);
mImageReader.setOnImageAvailableListener(   // 设置监听和后台线程处理器
        mOnImageAvailableListener, mBackgroundHandler);
2.2 将ImageReader的Surface设为CaptureRequest的target之一
// 预览请求构建(创建适合相机预览窗口的请求:CameraDevice.TEMPLATE_PREVIEW字段)
mPreviewRequestBuilder
        = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
mPreviewRequestBuilder.addTarget(surface);  //请求捕获的目标surface
mPreviewRequestBuilder.addTarget(mImageReader.getSurface());
2.3 ImageReader的回调函数
  • 回调函数中能得到的是Image对象,由于用于物体识别的函数参数需要的是cv::Mat的对象,所以我必须将YUV_420_888格式的图像转为cv::Mat,这部分没有学过,但运气很好找到了GitHub上的一个开源算法,很好用:GitHub-quickbirdstudios / yuvToMat,有需要的朋友用的时候记得给个Star啊!
  • 然后这个回调频率和预览刷新的帧率是一样的,帧率太快这里可能会造成crash,所以用一个小技巧来降低调用耗时函数的频率,代码中有备注。
  • 最后记得用完Image一定要close(),不然肯定会crash的。
/**
 * ImageReader的回调函数, 其中的onImageAvailable会以一定频率(由EXECUTION_FREQUENCY和相机帧率决定)
 * 识别从预览中传回的图像,并在透明的SurfaceView中画框
 */
private final static int EXECUTION_FREQUENCY = 10;
private int PREVIEW_RETURN_IMAGE_COUNT;

private final ImageReader.OnImageAvailableListener mOnImageAvailableListener
        = new ImageReader.OnImageAvailableListener() {
    @Override
    public void onImageAvailable(ImageReader reader) {
    	// 小技巧:降低物体识别的频率
        // 设置识别的频率,当EXECUTION_FREQUENCY为5时,也就是此处被回调五次只识别一次
        // 假若帧率被我设置在15帧/s,那么就是 1s 识别 3次,若是30帧/s,那就是1s识别6次,以此类推
        PREVIEW_RETURN_IMAGE_COUNT++;
        if(PREVIEW_RETURN_IMAGE_COUNT % EXECUTION_FREQUENCY !=0) return;
        PREVIEW_RETURN_IMAGE_COUNT = 0;

        final Image image = reader.acquireLatestImage();   // 获取最近一帧图像
        mBackgroundHandler.post(new Runnable() {    // 在子线程执行,防止预览界面卡顿
            @Override
            public void run() {
                Mat mat = Yuv.rgb(image);  // 从YUV_420_888 到 Mat(RGB),这里使用了第三方库,build.gradle中可见

                Mat input_mat = new Mat();
                Imgproc.cvtColor(mat,input_mat, Imgproc.COLOR_RGB2BGR); // 转换格式
				
				//  BvaNative.detect函数是物体识别函数,一个物体为一组数据,都在返回值里
                final float[] result = BvaNative.detect(input_mat.getNativeObjAddr(),true,mRotateDegree);  // 识别
                if(result == null){
                    Log.d(TAG, "detector: result is null!");
                }else {
                    float[][] get_finalResult = TwoArray(result);   //变为二维数组
                    show_detect_results(get_finalResult);   // 在UI线程中画框
                }
                image.close();   // 这里一定要close,不然预览会卡死
            }
        });
    }

};

问题3:手机屏幕适配、预览拉伸问题

这部分问题主要涉及到

  1. 屏幕实际尺寸
  2. 相机可用尺寸(是一个列表)
  3. 预览用的TextureView尺寸

前两个在一部手机上是固定的,我们需要根据前两个尺寸来决定预览尺寸(预览尺寸也就决定了画框用的SurfaceView尺寸和ImageReader初始化时的尺寸)。在Google的例子中,预览的尺寸并不是全屏,所以这部分得重写。网上有方案是对TextureView进行缩放:使用TextureView setTransform(Matrix)方法,解决Camera显示变形问题
才用那种方案时,我还需要将图像进行缩放才能保证框能够准确的画在物体上,但缩放会导致预览拉伸,这里是个矛盾的点。
我最终使用了折中的方案:根据相机所能提供的分辨率和屏幕分辨率之间做“适配”,在做到预览不拉伸的前提下尽可能让预览尺寸接近全屏。

3.1 屏幕尺寸获取

这里的逻辑有点乱,主要是因为横竖屏的问题(后来固定竖屏了,这部分没改过来,因为照样可以用)

// 获取当前的屏幕尺寸, 放到一个点对象里
Point screenSize = new Point();
getWindowManager().getDefaultDisplay().getSize(screenSize);
// 初始时将屏幕认为是横屏的
int screenWidth = screenSize.y;  // 2029
int screenHeight = screenSize.x; // 1080
Log.d(TAG, "screenWidth = "+screenWidth+", screenHeight = "+screenHeight); // 2029 1080

// swappedDimensions: (竖屏时true,横屏时false)
if (swappedDimensions) {
    screenWidth = screenSize.x;  // 1080
    screenHeight = screenSize.y; // 2029
}
// 尺寸太大时的极端处理(MAX_PREVIEW_WIDTH/MAX_PREVIEW_HEIGHT = 1920/1080,这是Google例子中标明的)
if (screenWidth > MAX_PREVIEW_HEIGHT) screenWidth = MAX_PREVIEW_HEIGHT;
if (screenHeight > MAX_PREVIEW_WIDTH) screenHeight = MAX_PREVIEW_WIDTH;
Log.d(TAG, "after adjust, screenWidth = "+screenWidth+", screenHeight = "+screenHeight); // 1080 1920

// 自动计算出最适合的预览尺寸(实际从相机得到的尺寸,也是ImageReader的输入尺寸)
// 第一个参数:表示相机在SurfaceTexture上支持的输出尺寸List
selectPreviewSize = chooseOptimalSize(map.getOutputSizes(SurfaceTexture.class),
        screenWidth,screenHeight,swappedDimensions);
3.2 chooseOptimalSize函数重写
  /**
     * 计算出最适合全屏预览的尺寸
     * 原则是宽度和屏幕宽度相等,高度最接近屏幕高度
     *
     * @param choices           相机支持的尺寸list
     * @param screenWidth       屏幕宽度
     * @param screenHeight      屏幕高度
     * @return 最合适的预览尺寸
     */
    private static Size chooseOptimalSize(Size[] choices, int screenWidth, int screenHeight, boolean swappedDimensions) {
        List<Size> bigEnough = new ArrayList<>();
        StringBuilder stringBuilder = new StringBuilder();
        if(swappedDimensions){  // 竖屏
            for(Size option : choices){
                String str = "["+option.getWidth()+", "+option.getHeight()+"]";
                stringBuilder.append(str);
                if(option.getHeight() != screenWidth || option.getWidth() > screenHeight) continue;
                bigEnough.add(option);
            }
        } else{     // 横屏
            for(Size option : choices){
                String str = "["+option.getWidth()+", "+option.getHeight()+"]";
                stringBuilder.append(str);
                if(option.getWidth() != screenHeight || option.getHeight() > screenWidth) continue;
                bigEnough.add(option);
            }
        }
        Log.d(TAG, "chooseOptimalSize: "+ stringBuilder);

        if(bigEnough.size() > 0){
            return Collections.max(bigEnough, new Preview_Detector.CompareSizesByArea());
        }else {
            Log.e(TAG, "Couldn't find any suitable preview size");
            return choices[choices.length/2];
        }
    }
3.3 预览TextureView、ImageReader和surfaceView尺寸设置
// 设置画框用的surfaceView的展示尺寸,也是TextureView的展示尺寸(因为是竖屏,所以宽度比高度小)
surfaceHolder.setFixedSize(mPreviewSize.getHeight(),mPreviewSize.getWidth());
mTextureView.setAspectRatio(mPreviewSize.getHeight(), mPreviewSize.getWidth());

// 输入相机的尺寸必须是相机支持的尺寸,这样画面才能不失真,TextureView输入相机的尺寸也是这个
mImageReader = ImageReader.newInstance(selectPreviewSize.getWidth(), selectPreviewSize.getHeight(),
        ImageFormat.YUV_420_888, /*maxImages*/5);

// 以下语句在createCameraPreviewSession函数中设置            
// 获取用来预览的texture实例
SurfaceTexture texture = mTextureView.getSurfaceTexture();
assert texture != null;
texture.setDefaultBufferSize( selectPreviewSize.getWidth(),selectPreviewSize.getHeight());  // 设置宽度和高度
Surface surface = new Surface(texture);  // 用获取输出surface

问题4:画框和结果

画框倒是不难的,首先清空Canvas上已有框,然后根据识别出来的坐标画上去即可。

   /**
     * 接收识别结果进行画框
     * @param get_finalResult 识别的结果数组,包含物体名称、置信度和用于画矩形的参数(x,y,width,height)
     * 					name=floats[0]  confidence=floats[1]  x=floats[2] y=floats[3] width=floats[4] height=floats[5]
     */
    private void show_detect_results(final float[][] get_finalResult) {
        runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    ClearDraw();   // 先清空上次画的框
                    canvas = surfaceHolder.lockCanvas();   // 得到surfaceView的画布
                    for (float[] floats : get_finalResult) {   // 画框并在框上方输出识别结果和置信度
                        canvas.drawRect(floats[2], floats[3],
                                floats[2] + floats[4],
                                floats[3] + floats[5], paint_rect);
                        canvas.drawText(resultLabel.get((int) floats[0]) + "\n" + floats[1],
                                floats[2], floats[3], paint_txt);
                    }
                    surfaceHolder.unlockCanvasAndPost(canvas);  // 释放
                }
        });
    }

    /**
     * 清空上次的框
     */
    private void ClearDraw(){
        try{
            canvas = surfaceHolder.lockCanvas(null);
            canvas.drawColor(Color.WHITE);
            canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.SRC);
        }catch(Exception e){
            e.printStackTrace();
        }finally{
            if(canvas != null){
                surfaceHolder.unlockCanvasAndPost(canvas);
            }
        }
    }

问题5:手机屏幕旋转时的画框问题

由于横竖屏切换时用户体验不好,实现起来也比较费劲,而且有一些奇怪的问题我无法解决(这才是主要原因)。所以换成固定竖屏,然后调整Canvas坐标系来适应手机的旋转。

5.1 监听屏幕旋转

首先得监听屏幕方向的旋转,这里选取了四个角度:竖屏、home键朝左、home键朝右和竖屏并上下颠倒。监听可以在onStart()或onResume()回调中开启。

// 监听屏幕的转动,mRotateDegree有四个值:0/90/180/270,0是平常的竖屏,然后依次顺时针旋转90°得到后三个值
        orientationListener = new OrientationEventListener(this,
                SensorManager.SENSOR_DELAY_NORMAL) {
            @Override
            public void onOrientationChanged(int orientation) {

                if (orientation == OrientationEventListener.ORIENTATION_UNKNOWN) {
                    return;  //手机平放时,检测不到有效的角度
                }

                //可以根据不同角度检测处理,这里只检测四个角度的改变
                // 可以扩展到多于四个的检测,以在不同角度都可以画出完美的框
                // (要在对应的画框处添加多余角度的Canvas的旋转)
                orientation = (orientation + 45) / 90 * 90;
                mRotateDegree = orientation % 360;
                //Log.d(TAG, "mRotateDegree: "+mRotateDegree);
            }
        };

        if (orientationListener.canDetectOrientation()) {
            orientationListener.enable();   // 开启此监听
        } else {
            orientationListener.disable();
        }
5.2 根据屏幕旋转方向调整坐标系

这里需要知道,Android手机固定竖屏时,坐标原点始终在“左上角”,这个左上角指的是正常竖屏放置时的左上角。但输入图像检测时一直都是正常竖直的,所以结果中的坐标也是以图像左上角为坐标原点,如果直接画框,方向肯定是错的。
最简单的解决方案就是旋转Canvas的坐标系,因为它已经提供了函数,很简单使用:canvas.translate和canvas.rotate。这里就不讲Canvas的知识了,网上有很多。

private void show_detect_results(final float[][] get_finalResult) {
        runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    ClearDraw();   // 先清空上次画的框

                    canvas = surfaceHolder.lockCanvas();   // 得到surfaceView的画布
                    // 根据屏幕旋转角度调整canvas,以使画框方向正确
                    if(mRotateDegree != 0){
                        if(mRotateDegree == 270){
                            canvas.translate(mPreviewSize.getHeight(),0); // 坐标原点在x轴方向移动屏幕宽度的距离
                            canvas.rotate(90);   // canvas顺时针旋转90°
                        } else if(mRotateDegree == 90){
                            canvas.translate(0,mPreviewSize.getWidth());
                            canvas.rotate(-90);
                        } else if(mRotateDegree == 180){
                            canvas.translate(mPreviewSize.getHeight(),mPreviewSize.getWidth());
                            canvas.rotate(180);
                        }
                    }
                    for (float[] floats : get_finalResult) {   // 画框并在框上方输出识别结果和置信度
                        canvas.drawRect(floats[2], floats[3],
                                floats[2] + floats[4],
                                floats[3] + floats[5], paint_rect);
                        canvas.drawText(resultLabel.get((int) floats[0]) + "\n" + floats[1],
                                floats[2], floats[3], paint_txt);
                    }
                    surfaceHolder.unlockCanvasAndPost(canvas);  // 释放
                }
        });
    }

源码

github/Preview_Detect
csdn/Preview_Detect

感谢

Android Camera2预览和实时帧数据获取
GitHub-quickbirdstudios / yuvToMat
设置Android Camera2预览画面的帧率
Camera2在预览的TextureView上画矩形
SurfaceView清空Canvas

  • 15
    点赞
  • 74
    收藏
    觉得还不错? 一键收藏
  • 23
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 23
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值