CameraX原理及实战

3,021 阅读8分钟

简介

  • CameraX 是一个 Jetpack 支持库,旨在帮助您简化相机应用的开发工作。
  • 它提供一致且易于使用的 API Surface,适用于大多数 Android 设备,并可向后兼容至 Android 5.0
  • 基于Camera2,即对Camera2的封装

简单的设计思想

将整个拍照过程分为三块,每块对应一个用例

  • 预览(Preview)
  • 图片分析(ImageAnalysis)

不使用预览的数据流,使用比较轻的ImageReader读出的yuv数据,进行计算,常见的应用场景是拍照取词

  • 图片拍摄 (ImageCapture)

另外需要谈到一个session的概念,这其实来自Camera2

  • Session

CameraX通过lifecycle实现默认的生命周期回调,即onStart的时候重新打开session即openSession(), onStop的时候关闭session

下面我们看一下这三个用例如何使用的

实现步骤

  • 写xml
 <PreviewView
            android:id="@+id/camera_view"
            android:focusable="true"
            android:clickable="true"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />

PreviewView是用于显示预览界面的view,内部用surface实现的

  • 创建用例,配置用例
private fun createCameraXUseCases() {
    MyLog.d("yyyyyy", "createCameraUseCases start")
    val metrics = DisplayMetrics().also { mBinding.cameraView.display.getRealMetrics(it) }
    val ration = 1f
    val applyWidthPixels = (metrics.widthPixels * ration).toInt()
    val applyHeightPixel = (metrics.heightPixels * ration).toInt()
    val screenAspectRatio = aspectRatio(applyWidthPixels, applyHeightPixel)
    val resolution = Size(applyWidthPixels, applyHeightPixel)
    val rotation = (context?.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay.rotation
    // Preview用例配置,这里经过了大量的测试得到的最兼容的配置
    mPreview = Preview.Builder().setMaxResolution(resolution)
//            .setTargetAspectRatio(screenAspectRatio)
        .setTargetResolution(resolution)
        .setTargetRotation(rotation)
        .build()
    mImageCapture = ImageCapture.Builder()
        .setMaxResolution(resolution)
        // 尽量用screen AspectRation,fix SM-J600G 预览拉伸
        .setTargetAspectRatio(screenAspectRatio)
        .setTargetRotation(rotation)
        .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
        .build()
    // 配置此处的原因是 预览界面与相机捕获界面一致
    mImageCapture?.setCropAspectRatio(Rational(applyWidthPixels, applyHeightPixel))
    mImageAnalysis = ImageAnalysis.Builder().setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST).setMaxResolution(resolution)
//            .setTargetAspectRatio(screenAspectRatio)
        .setTargetResolution(resolution)
        .setTargetRotation(rotation)
        .build()
}
  • 绑定相机
private fun bindCameraXUseCases(owner: LifecycleOwner, selector: CameraSelector, vararg useCases: UseCase?) {
    try {
        val processCameraProvider = mCameraProviderFuture.get() as ProcessCameraProvider
        processCameraProvider.unbindAll()
        mCamera = processCameraProvider.bindToLifecycle(this, selector, *useCases)
        // 此处是实例化PreviewViewImplementation
        mPreview?.setSurfaceProvider(mBinding.cameraView.surfaceProvider)
    } catch (e: Exception) {
        mCameraSelector = null
        e.printStackTrace()
    }
}
  • 调用
 private fun startLaunchCamera() {
        ProcessCameraProvider.getInstance(requireContext()).also { mCameraProviderFuture = it }.addListener(Runnable {
            // 创建用例必须在回调里面做
            createCameraXUseCases()
            launchCamera()
        }, ContextCompat.getMainExecutor(requireContext()))
    }

下面聊聊上述几个步骤的原理

原理

写xml即PreviewView

PreviewView是只负责显示相机画面的自定义View。内部有两种实现方式SurfaceViewTextureView,默认是性能更好的surfaceView,当然如果你的手机不支持SurfaceView就选择TextureView,当然你可以通过PreviewView.setPreferredImplementationMode(ImplementationMode)指定实现方式。逻辑如下图:

从上图可以得出一个结论:当首选模式设置为 SURFACE_VIEW 时,PreviewView 会尽可能遵循您的设置 (使用 SurfaceView);而当首选模式设置为 TEXTURE_VIEW 时,PreviewView 会确保一直使用 TEXTURE_VIEW 模式。 这个具体的实现类PreviewViewImplementation的实例mImplementation的赋值需要调用Preview.setSurfaceProvider(PreviewView.getSurfaceProvider())

除了上述要点,PreviewView还有如下两个功能,在此就不多做介绍了

  • 相机对焦:直接调用API即可
  • 预览界面拖动

用例配置及原理

三个用例都需要配置,通过构建者模式且三个用例的配置几乎是一套方法,常用的方法如下图所示

你可以给三种用例设置最大分辨率,目标长宽比,目标分辨率,目标旋转角度,CameraX需要这些信息计算出各个用例的输出的最终分辨率,最终旋转角度,源码逻辑大体如下图所示

预览(Preview)

预览的配置非常重要,不当的配置往往会导致如下几种情况发生

  • 预览变形,比如拉伸等
  • 拍照的图和预览图不一致 至于如何配置需要看你的需求,预览框的比例是多少等,我们产品的需求是全屏的预览框,所以我使用了setTargetResolution这个函数,参数是屏幕的分辨率, 具体的计算逻辑在SupportedSurfaceCombinationgetSupportedOutputSizes中,这里给出具体的计算逻辑图

通过上图我们给出几个对实践有指导意义的说明和结论

  • 如果你不设置目标宽高比AspectRatio,CameraX会自己根据你给的目标分辨率计算出目标宽高比,最后依然是按照目标宽高比作为轴进行排序,原理如下图所示

  • CameraX在计算合适的分辨率的过程中,会将每个用例输出的SupportedOutputSizes做笛卡尔积,如果用例支持的outputSizes过多,会导致笛卡尔积后的数据非常庞大。所以我们可以设置maxsize去尽量减少这样的空间占用
  • 在输出的最后一步即筛选笛卡尔积后的数据,这个数据是用例可能的各种配置的组合,如下图

在筛选过程中就是以这个组合作为基本单位,所以他们其实是耦合的,进一步讲,假设预览,图片分析配置不变,只改变图片拍摄的目标宽高比或者目标分辨率,可能会导致预览和图片分析的输出分辨率有变化。

分析图片(ImageAnalysis)

分析图片是实时获取图片的字节流,用于分析,常见的场景有AR,识别花草,屏幕取词等,这个用例的参数配置可以沿用Preview用例,主要想说明的有以下几点

  • 生产者消费者问题:

既然是实时获取图片,就必然有生产者与消费者问题,这里每张图片的字节流产生是生产者,消费者则是分析这些图片,都是耗时操作,如何平衡生产与消费的速度?google提供了生产者与消费者模型中常见的两种方式,阻塞模式和非阻塞,前者取最老的帧,后者取最新的帧,这需要看你的需求,这个配置是通过setBackpressureStrategy,如下代码可以说明:

val imageAnalysis = ImageAnalysis.Builder()
    .setTargetResolution(Size(1280, 720))
    // 通过该函数配置模式STRATEGY_BLOCK_PRODUCER或者STRATEGY_KEEP_ONLY_LATEST
    .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
    .build()
  • CameraX图片分析返回的图像格式是YUV_420_888, 不是返回JPEG,而且不允许你配置,当然会有讨巧的方法(hook)解决,因为YUV_420_888对5.0.1的有些手机不太兼容,我们先来看看它是怎么返回的?使用第一点配置成功的imageAnalysis
imageAnalysis.setAnalyzer(mCameraExecutor, image -> {
    // 得到类型为ImageProxy的image
    image.close();
});

跟进源码,很容易看到如下类

final class ImageAnalysisBlockingAnalyzer extends ImageAnalysisAbstractAnalyzer {
    @Override
    public void onImageAvailable(@NonNull ImageReaderProxy imageReaderProxy) {
        // 1. 默认是AndroidImageReaderProxy
        ImageProxy image = imageReaderProxy.acquireNextImage();
        if (image == null) {
            return;
        }
        // 2. 回调image
        ListenableFuture<Void> analyzeFuture = analyzeImage(image);

        // Callback to close the image only after analysis complete regardless of success
        Futures.addCallback(analyzeFuture, new FutureCallback<Void>() {
            @Override
            public void onSuccess(Void result) {
                // No-op. Keep blocking the image reader until user closes the current one.
            }

            @Override
            public void onFailure(Throwable t) {
                image.close();
            }
        }, CameraXExecutors.directExecutor());
    }
}
  • 注释1处的ImageReaderProxy是一个接口,在CameraX中有四个实现,如下图

实现类的确定是在ImageAnalysis.java里的createPipeline(),里面有如下关键代码

 SessionConfig.Builder createPipeline(@NonNull String cameraId,
            @NonNull ImageAnalysisConfig config, @NonNull Size resolution) {
        // ...
        // 1. 确定imageReader队列的缓存的最大个数
        int imageQueueDepth =
                getBackpressureStrategy() == STRATEGY_BLOCK_PRODUCER ? getImageQueueDepth()
                        : NON_BLOCKING_IMAGE_DEPTH;
        SafeCloseImageReaderProxy imageReaderProxy;
        // 2. 确定使用哪个ImageReaderProxy
        if (config.getImageReaderProxyProvider() != null) {
            imageReaderProxy = new SafeCloseImageReaderProxy(
                    config.getImageReaderProxyProvider().newInstance(
                            resolution.getWidth(), resolution.getHeight(), getImageFormat(),
                            imageQueueDepth, 0));
        } else {
            //3
            imageReaderProxy =
                    new SafeCloseImageReaderProxy(ImageReaderProxys.createIsolatedReader(
                            resolution.getWidth(),
                            resolution.getHeight(),
                            getImageFormat(),
                            imageQueueDepth));
        }
        // ...
        return sessionConfigBuilder;
    }

1处注释是确定ImageRader的maxImages参数,这个可以通过用例配置setImageQueueDepth(n: Int)进行配置,2处注释是确定使用哪个ImageReadProxy,这里可以自己实现一个ImageReadProxy,我之前说的hook处理就是在这里实现的。所以配置可以是这样

val imageAnalysis = ImageAnalysis.Builder()
    .setTargetResolution(Size(1280, 720))
    // 通过该函数配置模式STRATEGY_BLOCK_PRODUCER或者STRATEGY_KEEP_ONLY_LATEST
    .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
    .setImageQueueDepth(4)
    .setImageReaderProxyProvider(object : ImageReaderProxyProvider {
        override fun newInstance(width: Int, height: Int, format: Int, queueDepth: Int, usage: Long): ImageReaderProxy {
            
        }
    }).build()

当然如果没有自定义的ImageReaderProxy,跟进注释3中的代码则默认使用是AndroidImageReaderProxy,这个类其实就是利用最简单的代理模式重新实现了ImageReader的所有功能。其他几个代理会用到图片捕获(caputure)上

现在让我们回到ImageAnalysisBlockingAnalyzer这段代码,

final class ImageAnalysisBlockingAnalyzer extends ImageAnalysisAbstractAnalyzer {
    @Override
    public void onImageAvailable(@NonNull ImageReaderProxy imageReaderProxy) {
        // 1. 默认是AndroidImageReaderProxy
        ImageProxy image = imageReaderProxy.acquireNextImage();
        if (image == null) {
            return;
        }
        // 2. 回调image
        ListenableFuture<Void> analyzeFuture = analyzeImage(image);

        // Callback to close the image only after analysis complete regardless of success
        Futures.addCallback(analyzeFuture, new FutureCallback<Void>() {
            @Override
            public void onSuccess(Void result) {
                // No-op. Keep blocking the image reader until user closes the current one.
            }

            @Override
            public void onFailure(Throwable t) {
                image.close();
            }
        }, CameraXExecutors.directExecutor());
    }
}

注释2处表示获取了Image之后的回调,这里的image类型是ImageProxy,它的实现自然是与之对应AndroidImageProxy, 这里使用的装饰者模式。分析analyzeImage,最后回调给开发者收到的image的实现类型是SettableImageProxy,如下代码所示

ListenableFuture<Void> analyzeImage(ImageProxy imageProxy) {
    // ...
     future = CallbackToFutureAdapter.getFuture(
                completer ->  {
                    executor.execute(() -> {
                        if (!isClosed()) {
                            // 得到image的相关信息
                            ImageInfo imageInfo = ImmutableImageInfo.create(
                                    imageProxy.getImageInfo().getTagBundle(),
                                    imageProxy.getImageInfo().getTimestamp(),
                                    mRelativeRotation);
                            // 对AndroidImageProxy包装一层
                            analyzer.analyze(new SettableImageProxy(imageProxy, imageInfo));
                            completer.set(null);
                        } else {
                            completer.setException(new OperationCanceledException("Closed "
                                    + "before analysis"));
                        }
                    });
                return "analyzeImage";
                });
    // ...
    return future;
}

SettableImageProxyAndroidImageProxy多了一些ImageInfo的信息,开发者到时需要使用到

  • 返回的Image的使用
    • 裁剪:使用ImageProxy.setCropRect(Rect rect)即可, 其实内部也是使用的Image.setCropRect
    • 转成bitmap字节流: CameraX源码提供了这样的工具类 androidx.camera.core.internal.utils.ImageUtil
图片拍摄
使用
// 使用之前构造的用例
mImageCapture?.takePicture(mCameraExecutor, object : OnImageCapturedCallback() {
    override fun onCaptureSuccess(image: ImageProxy) {
        // 和分析图片用例一样,也是返回image
    }

    override fun onError(exception: ImageCaptureException) {
        super.onError(exception)
    }
})

两点说明:

  • 可以看到,一样是返回imageProxy,格式默认是JPEG, 如需使用YUV_420_888,这个配置对拍照的速度替身还是比较明显的,原因是转字节流比较快。可以使用setBufferFormat配置,但是有一定风险.
  • 这里面还有一个mCameraExecutor参数,这个是线程池实例,takePicture内部的耗时操作运行在这个线程池里。
裁剪

另外,为了保证预览界面和拍照界面的一致性,需要设置裁剪,如下

  // 配置此处的原因是 预览界面与相机捕获界面一致
mImageCapture.setCropAspectRatio(Rational(applyWidthPixels, applyHeightPixel))

就是说如果picture捕获到的分辨率和你设置的分辨率不一致,需要裁剪成一致的比例。

旋转

由于相机硬件的不一致性,可能有些手机拍出来的图片会和你想要的角度(你在用例配置中的目标角度)的图片不一致,但没有关系,CameraX返回的image里含有你应该旋转多少度才能实现你的目标角度,比如下面的代码:

val bitmap = BitmapUtils
            .decodeByteArrayInBitmap(ImageUtil.imageToJpegByteArray(image), sCameraBitmap, inSampleSize).rotateBitmap(image.imageInfo.rotationDegrees.toFloat())

图片的旋转信息通过这个image.imageInfo.rotationDegrees获得